Merge pull request #1074 from spotify/eide/github-auth

Add github auth
This commit is contained in:
Marcus Eide
2020-06-02 10:45:00 +02:00
committed by GitHub
16 changed files with 349 additions and 25 deletions
+11
View File
@@ -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({
@@ -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);
});
});
@@ -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<GithubSession>) {}
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<string> {
if (!scope) {
return new Set();
}
const scopeList = Array.isArray(scope)
? scope
: scope.split(/[\s|,]/).filter(Boolean);
return new Set(scopeList);
}
}
export default GithubAuth;
@@ -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';
@@ -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<string>;
expiresAt: Date;
};
@@ -15,3 +15,4 @@
*/
export * from './google';
export * from './github';
@@ -15,4 +15,5 @@
*/
export { RefreshingAuthSessionManager } from './RefreshingAuthSessionManager';
export { StaticAuthSessionManager } from './StaticAuthSessionManager';
export * from './types';
+8 -6
View File
@@ -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",
@@ -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<any> {
@@ -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`];
@@ -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,
},
];
@@ -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;
}
}
@@ -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';
@@ -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<RedirectInfo> {
return await executeRedirectStrategy(req, this._strategy, options);
}
async handler(
req: express.Request,
): Promise<{ user: AuthInfoBase; info: AuthInfoPrivate }> {
return await executeFrameHandlerStrategy(req, this._strategy);
}
}
+1 -5
View File
@@ -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 };
};
+2 -1
View File
@@ -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<any>;
handler(req: express.Request): Promise<any>;
refresh(refreshToken: string, scope: string): Promise<any>;
refresh?(refreshToken: string, scope: string): Promise<any>;
logout?(): Promise<any>;
}
+16
View File
@@ -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"