From 3217e85c85178f34b4b1eabeab480b1cf021d48b Mon Sep 17 00:00:00 2001 From: jaime-talkdesk <42963872+jaime-talkdesk@users.noreply.github.com> Date: Wed, 15 Jul 2020 10:20:20 +0100 Subject: [PATCH] Adds Github to signin page providers (#1590) --- packages/app/src/App.tsx | 2 +- .../implementations/auth/github/GithubAuth.ts | 2 +- .../src/layout/SignInPage/githubProvider.tsx | 89 ++++++++ .../core/src/layout/SignInPage/providers.tsx | 5 +- .../src/providers/github/provider.test.ts | 207 ++++++++++++++++++ .../src/providers/github/provider.ts | 65 +++++- 6 files changed, 358 insertions(+), 12 deletions(-) create mode 100644 packages/core/src/layout/SignInPage/githubProvider.tsx create mode 100644 plugins/auth-backend/src/providers/github/provider.test.ts diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 3c8054d995..c569308fc7 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -33,7 +33,7 @@ const app = createApp({ SignInPage: props => ( ), }, diff --git a/packages/core-api/src/apis/implementations/auth/github/GithubAuth.ts b/packages/core-api/src/apis/implementations/auth/github/GithubAuth.ts index 9309cfd437..aaea681cef 100644 --- a/packages/core-api/src/apis/implementations/auth/github/GithubAuth.ts +++ b/packages/core-api/src/apis/implementations/auth/github/GithubAuth.ts @@ -90,7 +90,7 @@ class GithubAuth implements OAuthApi, SessionStateApi { const sessionManager = new StaticAuthSessionManager({ connector, - defaultScopes: new Set(['user']), + defaultScopes: new Set(['read:user']), sessionScopes: (session: GithubSession) => session.providerInfo.scopes, }); diff --git a/packages/core/src/layout/SignInPage/githubProvider.tsx b/packages/core/src/layout/SignInPage/githubProvider.tsx new file mode 100644 index 0000000000..fd7815e7b8 --- /dev/null +++ b/packages/core/src/layout/SignInPage/githubProvider.tsx @@ -0,0 +1,89 @@ +/* + * 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 React from 'react'; +import { Grid, Typography, Button } from '@material-ui/core'; +import { InfoCard } from '../InfoCard/InfoCard'; +import { ProviderComponent, ProviderLoader, SignInProvider } from './types'; +import { useApi, githubAuthApiRef, errorApiRef } from '@backstage/core-api'; + +const Component: ProviderComponent = ({ onResult }) => { + const githubAuthApi = useApi(githubAuthApiRef); + const errorApi = useApi(errorApiRef); + + const handleLogin = async () => { + try { + const identity = await githubAuthApi.getBackstageIdentity({ + instantPopup: true, + }); + + const profile = await githubAuthApi.getProfile(); + onResult({ + userId: identity!.id, + profile: profile!, + getIdToken: () => { + return githubAuthApi.getBackstageIdentity().then(i => i!.idToken); + }, + logout: async () => { + await githubAuthApi.logout(); + }, + }); + } catch (error) { + errorApi.post(error); + } + }; + + return ( + + + Sign In + + } + > + Sign In using Github + + + ); +}; + +const loader: ProviderLoader = async apis => { + const githubAuthApi = apis.get(githubAuthApiRef)!; + + const identity = await githubAuthApi.getBackstageIdentity({ + optional: true, + }); + + if (!identity) { + return undefined; + } + + const profile = await githubAuthApi.getProfile(); + + return { + userId: identity.id, + profile: profile!, + getIdToken: () => + githubAuthApi.getBackstageIdentity().then(i => i!.idToken), + logout: async () => { + await githubAuthApi.logout(); + }, + }; +}; + +export const githubProvider: SignInProvider = { Component, loader }; diff --git a/packages/core/src/layout/SignInPage/providers.tsx b/packages/core/src/layout/SignInPage/providers.tsx index 53d6b75eac..e4f4aee8c7 100644 --- a/packages/core/src/layout/SignInPage/providers.tsx +++ b/packages/core/src/layout/SignInPage/providers.tsx @@ -20,6 +20,7 @@ import { googleProvider } from './googleProvider'; import { customProvider } from './customProvider'; import { gitlabProvider } from './gitlabProvider'; import { oktaProvider } from './oktaProvider'; +import { githubProvider } from './githubProvider'; import { SignInPageProps, SignInResult, @@ -37,7 +38,8 @@ export type SignInProviderId = | 'google' | 'gitlab' | 'custom' - | 'okta'; + | 'okta' + | 'github'; const signInProviders: { [id in SignInProviderId]: SignInProvider } = { guest: guestProvider, @@ -45,6 +47,7 @@ const signInProviders: { [id in SignInProviderId]: SignInProvider } = { gitlab: gitlabProvider, custom: customProvider, okta: oktaProvider, + github: githubProvider, }; export const useSignInProviders = ( diff --git a/plugins/auth-backend/src/providers/github/provider.test.ts b/plugins/auth-backend/src/providers/github/provider.test.ts new file mode 100644 index 0000000000..61d4bb887f --- /dev/null +++ b/plugins/auth-backend/src/providers/github/provider.test.ts @@ -0,0 +1,207 @@ +/* + * 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 { GithubAuthProvider } from './provider'; + +describe('GithubAuthProvider', () => { + describe('should transform to type OAuthResponse', () => { + it('when all fields are present, it should be able to map them', () => { + const accessToken = '19xasczxcm9n7gacn9jdgm19me'; + const rawProfile = { + id: 'uid-123', + username: 'jimmymarkum', + provider: 'github', + displayName: 'Jimmy Markum', + emails: [ + { + value: 'jimmymarkum@gmail.com', + }, + ], + photos: [ + { + value: + 'https://a1cf74336522e87f135f-2f21ace9a6cf0052456644b80fa06d4f.ssl.cf2.rackcdn.com/images/characters_opt/p-mystic-river-sean-penn.jpg', + }, + ], + }; + + const params = { + scope: 'read:scope', + }; + + const expected = { + backstageIdentity: { + id: 'jimmymarkum', + }, + providerInfo: { + accessToken: '19xasczxcm9n7gacn9jdgm19me', + expiresInSeconds: undefined, + idToken: undefined, + scope: 'read:scope', + }, + profile: { + email: 'jimmymarkum@gmail.com', + displayName: 'Jimmy Markum', + picture: + 'https://a1cf74336522e87f135f-2f21ace9a6cf0052456644b80fa06d4f.ssl.cf2.rackcdn.com/images/characters_opt/p-mystic-river-sean-penn.jpg', + }, + }; + expect( + GithubAuthProvider.transformOAuthResponse( + accessToken, + rawProfile, + params, + ), + ).toEqual(expected); + }); + + it('when "email" is missing, it should be able to create the profile without it', () => { + const accessToken = '19xasczxcm9n7gacn9jdgm19me'; + const rawProfile = { + id: 'uid-123', + username: 'jimmymarkum', + provider: 'github', + displayName: 'Jimmy Markum', + emails: null, + photos: [ + { + value: + 'https://a1cf74336522e87f135f-2f21ace9a6cf0052456644b80fa06d4f.ssl.cf2.rackcdn.com/images/characters_opt/p-mystic-river-sean-penn.jpg', + }, + ], + }; + + const params = { + scope: 'read:scope', + }; + + const expected = { + backstageIdentity: { + id: 'jimmymarkum', + }, + providerInfo: { + accessToken: '19xasczxcm9n7gacn9jdgm19me', + expiresInSeconds: undefined, + idToken: undefined, + scope: 'read:scope', + }, + profile: { + displayName: 'Jimmy Markum', + picture: + 'https://a1cf74336522e87f135f-2f21ace9a6cf0052456644b80fa06d4f.ssl.cf2.rackcdn.com/images/characters_opt/p-mystic-river-sean-penn.jpg', + }, + }; + + expect( + GithubAuthProvider.transformOAuthResponse( + accessToken, + rawProfile, + params, + ), + ).toEqual(expected); + }); + + it('when "displayName" is missing, it should be able to create the profile and map "displayName" with "username"', () => { + const accessToken = '19xasczxcm9n7gacn9jdgm19me'; + const rawProfile = { + id: 'uid-123', + username: 'jimmymarkum', + provider: 'github', + displayName: null, + emails: null, + photos: [ + { + value: + 'https://a1cf74336522e87f135f-2f21ace9a6cf0052456644b80fa06d4f.ssl.cf2.rackcdn.com/images/characters_opt/p-mystic-river-sean-penn.jpg', + }, + ], + }; + + const params = { + scope: 'read:scope', + }; + const expected = { + backstageIdentity: { + id: 'jimmymarkum', + }, + providerInfo: { + accessToken: '19xasczxcm9n7gacn9jdgm19me', + expiresInSeconds: undefined, + idToken: undefined, + scope: 'read:scope', + }, + profile: { + displayName: 'jimmymarkum', + picture: + 'https://a1cf74336522e87f135f-2f21ace9a6cf0052456644b80fa06d4f.ssl.cf2.rackcdn.com/images/characters_opt/p-mystic-river-sean-penn.jpg', + }, + }; + + expect( + GithubAuthProvider.transformOAuthResponse( + accessToken, + rawProfile, + params, + ), + ).toEqual(expected); + }); + + it('when "photos" is missing, it should be able to create the profile without it', () => { + const accessToken = + 'ajakljsdoiahoawxbrouawucmbawe.awkxjemaneasdxwe.sodijxqeqwexeqwxe'; + const rawProfile = { + id: 'ipd12039', + username: 'daveboyle', + provider: 'gitlab', + displayName: 'Dave Boyle', + emails: [ + { + value: 'daveboyle@gitlab.org', + }, + ], + }; + + const params = { + scope: 'read:user', + }; + + const expected = { + backstageIdentity: { + id: 'daveboyle', + }, + providerInfo: { + accessToken: + 'ajakljsdoiahoawxbrouawucmbawe.awkxjemaneasdxwe.sodijxqeqwexeqwxe', + scope: 'read:user', + expiresInSeconds: undefined, + idToken: undefined, + }, + profile: { + displayName: 'Dave Boyle', + email: 'daveboyle@gitlab.org', + }, + }; + + expect( + GithubAuthProvider.transformOAuthResponse( + accessToken, + rawProfile, + params, + ), + ).toEqual(expected); + }); + }); +}); diff --git a/plugins/auth-backend/src/providers/github/provider.ts b/plugins/auth-backend/src/providers/github/provider.ts index ec444733ba..ed06c5786e 100644 --- a/plugins/auth-backend/src/providers/github/provider.ts +++ b/plugins/auth-backend/src/providers/github/provider.ts @@ -38,10 +38,60 @@ import { } from '../../lib/EnvironmentHandler'; import { Logger } from 'winston'; import { TokenIssuer } from '../../identity'; +import passport from 'passport'; export class GithubAuthProvider implements OAuthProviderHandlers { private readonly _strategy: GithubStrategy; + static transformPassportProfile(rawProfile: any): passport.Profile { + const profile: passport.Profile = { + id: rawProfile.username, + username: rawProfile.username, + provider: rawProfile.provider, + displayName: rawProfile.displayName || rawProfile.username, + photos: rawProfile.photos, + emails: rawProfile.emails, + }; + + return profile; + } + + static transformOAuthResponse( + accessToken: string, + rawProfile: any, + params: any = {}, + ): OAuthResponse { + const passportProfile = GithubAuthProvider.transformPassportProfile( + rawProfile, + ); + + const profile = makeProfileInfo(passportProfile, params.id_token); + const providerInfo = { + accessToken, + scope: params.scope, + expiresInSeconds: params.expires_in, + idToken: params.id_token, + }; + + // Github provides an id numeric value (123) + // as a fallback + const id = passportProfile!.id; + + if (params.expires_in) { + providerInfo.expiresInSeconds = params.expires_in; + } + if (params.id_token) { + providerInfo.idToken = params.id_token; + } + return { + providerInfo, + profile, + backstageIdentity: { + id, + }, + }; + } + constructor(options: OAuthProviderOptions) { this._strategy = new GithubStrategy( { ...options }, @@ -52,15 +102,12 @@ export class GithubAuthProvider implements OAuthProviderHandlers { rawProfile: any, done: PassportDoneCallback, ) => { - const profile = makeProfileInfo(rawProfile); - done(undefined, { - providerInfo: { - accessToken, - scope: params.scope, - expiresInSeconds: params.expires_in, - }, - profile, - }); + const oauthResponse = GithubAuthProvider.transformOAuthResponse( + accessToken, + rawProfile, + params, + ); + done(undefined, oauthResponse); }, ); }