diff --git a/packages/app/src/apis.ts b/packages/app/src/apis.ts index 8f5198bd5a..6a23de1edd 100644 --- a/packages/app/src/apis.ts +++ b/packages/app/src/apis.ts @@ -25,9 +25,11 @@ import { featureFlagsApiRef, FeatureFlags, GoogleAuth, + GithubAuth, oauthRequestApiRef, OAuthRequestManager, googleAuthApiRef, + githubAuthApiRef, } from '@backstage/core'; import { @@ -64,6 +66,15 @@ builder.add( }), ); +builder.add( + githubAuthApiRef, + GithubAuth.create({ + apiOrigin: 'http://localhost:7000', + basePath: '/auth/', + oauthRequestApi, + }), +); + builder.add( techRadarApiRef, new TechRadar({ diff --git a/packages/core-api/src/apis/implementations/auth/github/GithubAuth.test.ts b/packages/core-api/src/apis/implementations/auth/github/GithubAuth.test.ts new file mode 100644 index 0000000000..7c8e6ce9f0 --- /dev/null +++ b/packages/core-api/src/apis/implementations/auth/github/GithubAuth.test.ts @@ -0,0 +1,29 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import GithubAuth from './GithubAuth'; + +describe('GithubAuth', () => { + it('should get access token', async () => { + const getSession = jest + .fn() + .mockResolvedValue({ accessToken: 'access-token' }); + const githubAuth = new GithubAuth({ getSession } as any); + + expect(await githubAuth.getAccessToken()).toBe('access-token'); + expect(getSession).toBeCalledTimes(1); + }); +}); diff --git a/packages/core-api/src/apis/implementations/auth/github/GithubAuth.ts b/packages/core-api/src/apis/implementations/auth/github/GithubAuth.ts new file mode 100644 index 0000000000..d75bceef97 --- /dev/null +++ b/packages/core-api/src/apis/implementations/auth/github/GithubAuth.ts @@ -0,0 +1,111 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import GithubIcon from '@material-ui/icons/AcUnit'; +import { DefaultAuthConnector } from '../../../../lib/AuthConnector'; +import { GithubSession } from './types'; +import { OAuthApi, AccessTokenOptions } from '../../../definitions/auth'; +import { OAuthRequestApi, AuthProvider } from '../../../definitions'; +import { SessionManager } from '../../../../lib/AuthSessionManager/types'; +import { StaticAuthSessionManager } from '../../../../lib/AuthSessionManager'; + +type CreateOptions = { + // TODO(Rugvip): These two should be grabbed from global config when available, they're not unique to GithubAuth + apiOrigin: string; + basePath: string; + + oauthRequestApi: OAuthRequestApi; + + environment?: string; + provider?: AuthProvider & { id: string }; +}; + +export type GithubAuthResponse = { + accessToken: string; + idToken: string; + scope: string; + expiresInSeconds: number; +}; + +const DEFAULT_PROVIDER = { + id: 'github', + title: 'Github', + icon: GithubIcon, +}; + +class GithubAuth implements OAuthApi { + static create({ + apiOrigin, + basePath, + environment = 'dev', + provider = DEFAULT_PROVIDER, + oauthRequestApi, + }: CreateOptions) { + const connector = new DefaultAuthConnector({ + apiOrigin, + basePath, + environment, + provider, + oauthRequestApi: oauthRequestApi, + sessionTransform(res: GithubAuthResponse): GithubSession { + return { + accessToken: res.accessToken, + scopes: GithubAuth.normalizeScope(res.scope), + expiresAt: new Date(Date.now() + res.expiresInSeconds * 1000), + }; + }, + }); + + const sessionManager = new StaticAuthSessionManager({ + connector, + defaultScopes: new Set(['user']), + sessionScopes: session => session.scopes, + }); + + return new GithubAuth(sessionManager); + } + + constructor(private readonly sessionManager: SessionManager) {} + + async getAccessToken(scope?: string, options?: AccessTokenOptions) { + const normalizedScopes = GithubAuth.normalizeScope(scope); + const session = await this.sessionManager.getSession({ + ...options, + scopes: normalizedScopes, + }); + if (session) { + return session.accessToken; + } + return ''; + } + + async logout() { + await this.sessionManager.removeSession(); + } + + static normalizeScope(scope?: string): Set { + if (!scope) { + return new Set(); + } + + const scopeList = Array.isArray(scope) + ? scope + : scope.split(/[\s|,]/).filter(Boolean); + + return new Set(scopeList); + } +} +export default GithubAuth; diff --git a/packages/core-api/src/apis/implementations/auth/github/index.ts b/packages/core-api/src/apis/implementations/auth/github/index.ts new file mode 100644 index 0000000000..9e1722f4a4 --- /dev/null +++ b/packages/core-api/src/apis/implementations/auth/github/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './types'; +export { default as GithubAuth } from './GithubAuth'; diff --git a/packages/core-api/src/apis/implementations/auth/github/types.ts b/packages/core-api/src/apis/implementations/auth/github/types.ts new file mode 100644 index 0000000000..282017b80d --- /dev/null +++ b/packages/core-api/src/apis/implementations/auth/github/types.ts @@ -0,0 +1,21 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export type GithubSession = { + accessToken: string; + scopes: Set; + expiresAt: Date; +}; diff --git a/packages/core-api/src/apis/implementations/auth/index.ts b/packages/core-api/src/apis/implementations/auth/index.ts index 5fa6644b2a..f13368b5c4 100644 --- a/packages/core-api/src/apis/implementations/auth/index.ts +++ b/packages/core-api/src/apis/implementations/auth/index.ts @@ -15,3 +15,4 @@ */ export * from './google'; +export * from './github'; diff --git a/packages/core-api/src/lib/AuthSessionManager/index.ts b/packages/core-api/src/lib/AuthSessionManager/index.ts index 426c514646..16a8d3c378 100644 --- a/packages/core-api/src/lib/AuthSessionManager/index.ts +++ b/packages/core-api/src/lib/AuthSessionManager/index.ts @@ -15,4 +15,5 @@ */ export { RefreshingAuthSessionManager } from './RefreshingAuthSessionManager'; +export { StaticAuthSessionManager } from './StaticAuthSessionManager'; export * from './types'; diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index 9cb8017f15..505240853d 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -16,21 +16,23 @@ }, "dependencies": { "@backstage/backend-common": "^0.1.1-alpha.6", + "@types/cookie-parser": "^1.4.2", + "@types/passport": "^1.0.3", + "@types/passport-github2": "^1.2.4", + "@types/passport-google-oauth20": "^2.0.3", "compression": "^1.7.4", + "cookie-parser": "^1.4.5", "cors": "^2.8.5", "express": "^4.17.1", "express-promise-router": "^3.0.3", "fs-extra": "^9.0.0", "helmet": "^3.22.0", "morgan": "^1.10.0", - "winston": "^3.2.1", - "yn": "^4.0.0", "passport": "^0.4.1", + "passport-github2": "^0.1.12", "passport-google-oauth20": "^2.0.0", - "cookie-parser": "^1.4.5", - "@types/passport": "^1.0.3", - "@types/passport-google-oauth20": "^2.0.3", - "@types/cookie-parser": "^1.4.2" + "winston": "^3.2.1", + "yn": "^4.0.0" }, "devDependencies": { "@backstage/cli": "^0.1.1-alpha.6", diff --git a/plugins/auth-backend/src/providers/OAuthProvider.ts b/plugins/auth-backend/src/providers/OAuthProvider.ts index 822cd5b267..a2be783155 100644 --- a/plugins/auth-backend/src/providers/OAuthProvider.ts +++ b/plugins/auth-backend/src/providers/OAuthProvider.ts @@ -124,9 +124,15 @@ export const ensuresXRequestedWith = (req: express.Request) => { export class OAuthProvider implements AuthProviderRouteHandlers { private readonly provider: string; private readonly providerHandlers: OAuthProviderHandlers; - constructor(providerHandlers: OAuthProviderHandlers, provider: string) { + private readonly disableRefresh: boolean; + constructor( + providerHandlers: OAuthProviderHandlers, + provider: string, + disableRefresh?: boolean, + ) { this.provider = provider; this.providerHandlers = providerHandlers; + this.disableRefresh = disableRefresh ?? false; } async start(req: express.Request, res: express.Response): Promise { @@ -164,14 +170,16 @@ export class OAuthProvider implements AuthProviderRouteHandlers { const { user, info } = await this.providerHandlers.handler(req); - // throw error if missing refresh token - const { refreshToken } = info; - if (!refreshToken) { - throw new Error('Missing refresh token'); - } + if (!this.disableRefresh) { + // throw error if missing refresh token + const { refreshToken } = info; + if (!refreshToken) { + throw new Error('Missing refresh token'); + } - // set new refresh token - setRefreshTokenCookie(res, this.provider, refreshToken); + // set new refresh token + setRefreshTokenCookie(res, this.provider, refreshToken); + } // post message back to popup if successful return postMessageResponse(res, { @@ -195,8 +203,10 @@ export class OAuthProvider implements AuthProviderRouteHandlers { return res.status(401).send('Invalid X-Requested-With header'); } - // remove refresh token cookie before logout - removeRefreshTokenCookie(res, this.provider); + if (!this.disableRefresh) { + // remove refresh token cookie before logout + removeRefreshTokenCookie(res, this.provider); + } return res.send('logout!'); } @@ -205,6 +215,12 @@ export class OAuthProvider implements AuthProviderRouteHandlers { return res.status(401).send('Invalid X-Requested-With header'); } + if (!this.providerHandlers.refresh || this.disableRefresh) { + return res.send( + `Refresh token not supported for provider: ${this.provider}`, + ); + } + try { const refreshToken = req.cookies[`${this.provider}-refresh-token`]; diff --git a/plugins/auth-backend/src/providers/config.ts b/plugins/auth-backend/src/providers/config.ts index 45098afb89..8d04dc5a1f 100644 --- a/plugins/auth-backend/src/providers/config.ts +++ b/plugins/auth-backend/src/providers/config.ts @@ -23,4 +23,13 @@ export const providers = [ callbackURL: 'http://localhost:7000/auth/google/handler/frame', }, }, + { + provider: 'github', + options: { + clientID: process.env.AUTH_GITHUB_CLIENT_ID!, + clientSecret: process.env.AUTH_GITHUB_CLIENT_SECRET!, + callbackURL: 'http://localhost:7000/auth/github/handler/frame', + }, + disableRefresh: true, + }, ]; diff --git a/plugins/auth-backend/src/providers/factories.ts b/plugins/auth-backend/src/providers/factories.ts index 0a1e639082..0c9f99e878 100644 --- a/plugins/auth-backend/src/providers/factories.ts +++ b/plugins/auth-backend/src/providers/factories.ts @@ -14,21 +14,37 @@ * limitations under the License. */ -import { AuthProviderFactories, AuthProviderFactory } from './types'; +import { + AuthProviderFactories, + AuthProviderRouteHandlers, + AuthProviderConfig, +} from './types'; import { GoogleAuthProvider } from './google'; +import { GithubAuthProvider } from './github'; +import { OAuthProvider } from './OAuthProvider'; export class ProviderFactories { private static readonly providerFactories: AuthProviderFactories = { google: GoogleAuthProvider, + github: GithubAuthProvider, }; - public static getProviderFactory(providerId: string): AuthProviderFactory { + public static getProviderFactory( + config: AuthProviderConfig, + ): AuthProviderRouteHandlers { + const providerId = config.provider; const ProviderImpl = ProviderFactories.providerFactories[providerId]; if (!ProviderImpl) { throw Error( `Provider Implementation missing for : ${providerId} auth provider`, ); } - return ProviderImpl; + const providerInstance = new ProviderImpl(config); + const oauthProvider = new OAuthProvider( + providerInstance, + providerId, + config.disableRefresh, + ); + return oauthProvider; } } diff --git a/plugins/auth-backend/src/providers/github/index.ts b/plugins/auth-backend/src/providers/github/index.ts new file mode 100644 index 0000000000..c3a48d35e0 --- /dev/null +++ b/plugins/auth-backend/src/providers/github/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { GithubAuthProvider } from './provider'; diff --git a/plugins/auth-backend/src/providers/github/provider.ts b/plugins/auth-backend/src/providers/github/provider.ts new file mode 100644 index 0000000000..4445e98451 --- /dev/null +++ b/plugins/auth-backend/src/providers/github/provider.ts @@ -0,0 +1,59 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import express from 'express'; +import { Strategy as GithubStrategy } from 'passport-github2'; +import { + executeFrameHandlerStrategy, + executeRedirectStrategy, +} from '../PassportStrategyHelper'; +import { + OAuthProviderHandlers, + AuthProviderConfig, + RedirectInfo, + AuthInfoBase, + AuthInfoPrivate, +} from '../types'; + +export class GithubAuthProvider implements OAuthProviderHandlers { + private readonly providerConfig: AuthProviderConfig; + private readonly _strategy: GithubStrategy; + + constructor(providerConfig: AuthProviderConfig) { + this.providerConfig = providerConfig; + this._strategy = new GithubStrategy( + { ...this.providerConfig.options }, + (accessToken: any, _: any, params: any, profile: any, done: any) => { + done(undefined, { + profile, + accessToken, + scope: params.scope, + expiresInSeconds: params.expires_in, + }); + }, + ); + } + + async start(req: express.Request, options: any): Promise { + return await executeRedirectStrategy(req, this._strategy, options); + } + + async handler( + req: express.Request, + ): Promise<{ user: AuthInfoBase; info: AuthInfoPrivate }> { + return await executeFrameHandlerStrategy(req, this._strategy); + } +} diff --git a/plugins/auth-backend/src/providers/index.ts b/plugins/auth-backend/src/providers/index.ts index 59dd116fa6..cfbabbbad1 100644 --- a/plugins/auth-backend/src/providers/index.ts +++ b/plugins/auth-backend/src/providers/index.ts @@ -17,7 +17,6 @@ import Router from 'express-promise-router'; import { AuthProviderRouteHandlers, AuthProviderConfig } from './types'; import { ProviderFactories } from './factories'; -import { OAuthProvider } from './OAuthProvider'; export const defaultRouter = (provider: AuthProviderRouteHandlers) => { const router = Router(); @@ -32,10 +31,7 @@ export const defaultRouter = (provider: AuthProviderRouteHandlers) => { export const makeProvider = (config: AuthProviderConfig) => { const providerId = config.provider; - const ProviderImpl = ProviderFactories.getProviderFactory(providerId); - const providerInstance = new ProviderImpl(config); - - const oauthProvider = new OAuthProvider(providerInstance, providerId); + const oauthProvider = ProviderFactories.getProviderFactory(config); const providerRouter = defaultRouter(oauthProvider); return { providerId, providerRouter }; }; diff --git a/plugins/auth-backend/src/providers/types.ts b/plugins/auth-backend/src/providers/types.ts index dcbe6ad1de..dc83c19dd0 100644 --- a/plugins/auth-backend/src/providers/types.ts +++ b/plugins/auth-backend/src/providers/types.ts @@ -20,12 +20,13 @@ import passport from 'passport'; export type AuthProviderConfig = { provider: string; options: any; + disableRefresh?: boolean; }; export interface OAuthProviderHandlers { start(req: express.Request, options: any): Promise; handler(req: express.Request): Promise; - refresh(refreshToken: string, scope: string): Promise; + refresh?(refreshToken: string, scope: string): Promise; logout?(): Promise; } diff --git a/yarn.lock b/yarn.lock index b322cb1da4..760f1421b7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3785,6 +3785,15 @@ resolved "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0" integrity sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA== +"@types/passport-github2@^1.2.4": + version "1.2.4" + resolved "https://registry.npmjs.org/@types/passport-github2/-/passport-github2-1.2.4.tgz#f56c386d1fe6435e359430e57adc1747a627bd86" + integrity sha512-dtGtA0Uyzk6ne3SrgQi/I1ClClLE3i7JmSiMaJgkGH8v1nbE9JdBpG7QWJ1XPlLdcf7EvoPdHmkWN2+Kln9y8g== + dependencies: + "@types/express" "*" + "@types/passport" "*" + "@types/passport-oauth2" "*" + "@types/passport-google-oauth20@^2.0.3": version "2.0.3" resolved "https://registry.npmjs.org/@types/passport-google-oauth20/-/passport-google-oauth20-2.0.3.tgz#f554ff6d39f395acff3f1d762e54462194dac8da" @@ -14045,6 +14054,13 @@ pascalcase@^0.1.1: resolved "https://registry.npmjs.org/pascalcase/-/pascalcase-0.1.1.tgz#b363e55e8006ca6fe21784d2db22bd15d7917f14" integrity sha1-s2PlXoAGym/iF4TS2yK9FdeRfxQ= +passport-github2@^0.1.12: + version "0.1.12" + resolved "https://registry.npmjs.org/passport-github2/-/passport-github2-0.1.12.tgz#a72ebff4fa52a35bc2c71122dcf470d1116f772c" + integrity sha512-3nPUCc7ttF/3HSP/k9sAXjz3SkGv5Nki84I05kSQPo01Jqq1NzJACgMblCK0fGcv9pKCG/KXU3AJRDGLqHLoIw== + dependencies: + passport-oauth2 "1.x.x" + passport-google-oauth20@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz#0d241b2d21ebd3dc7f2b60669ec4d587e3a674ef"