From 23af27f5ce79a07556c7587dcc976e165c966208 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 19 Aug 2023 15:13:09 +0200 Subject: [PATCH 1/4] auth: add github provider module Signed-off-by: Patrik Oldsberg --- .changeset/warm-panthers-lick.md | 5 + .../.eslintrc.js | 1 + .../README.md | 8 ++ .../api-report.md | 29 ++++ .../dev/index.ts | 26 ++++ .../package.json | 41 ++++++ .../src/authenticator.ts | 125 ++++++++++++++++++ .../src/index.ts | 25 ++++ .../src/module.ts | 48 +++++++ .../src/resolvers.ts | 50 +++++++ yarn.lock | 16 +++ 11 files changed, 374 insertions(+) create mode 100644 .changeset/warm-panthers-lick.md create mode 100644 plugins/auth-backend-module-github-provider/.eslintrc.js create mode 100644 plugins/auth-backend-module-github-provider/README.md create mode 100644 plugins/auth-backend-module-github-provider/api-report.md create mode 100644 plugins/auth-backend-module-github-provider/dev/index.ts create mode 100644 plugins/auth-backend-module-github-provider/package.json create mode 100644 plugins/auth-backend-module-github-provider/src/authenticator.ts create mode 100644 plugins/auth-backend-module-github-provider/src/index.ts create mode 100644 plugins/auth-backend-module-github-provider/src/module.ts create mode 100644 plugins/auth-backend-module-github-provider/src/resolvers.ts diff --git a/.changeset/warm-panthers-lick.md b/.changeset/warm-panthers-lick.md new file mode 100644 index 0000000000..b06809fac1 --- /dev/null +++ b/.changeset/warm-panthers-lick.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend-module-github-provider': minor +--- + +New module for `@backstage/plugin-auth-backend` that adds a GitHub auth provider. diff --git a/plugins/auth-backend-module-github-provider/.eslintrc.js b/plugins/auth-backend-module-github-provider/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/auth-backend-module-github-provider/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/auth-backend-module-github-provider/README.md b/plugins/auth-backend-module-github-provider/README.md new file mode 100644 index 0000000000..7928ce1034 --- /dev/null +++ b/plugins/auth-backend-module-github-provider/README.md @@ -0,0 +1,8 @@ +# Auth Module: GitHub Provider + +This module provides an GitHub auth provider implementation for `@backstage/plugin-auth-backend`. + +## Links + +- [Backstage](https://backstage.io) +- [Repository](https://github.com/backstage/backstage/tree/master/plugins/auth-backend-module-github-provider) diff --git a/plugins/auth-backend-module-github-provider/api-report.md b/plugins/auth-backend-module-github-provider/api-report.md new file mode 100644 index 0000000000..2d0b33334c --- /dev/null +++ b/plugins/auth-backend-module-github-provider/api-report.md @@ -0,0 +1,29 @@ +## API Report File for "@backstage/plugin-auth-backend-module-github-provider" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { BackendFeature } from '@backstage/backend-plugin-api'; +import { OAuthAuthenticator } from '@backstage/plugin-auth-node'; +import { OAuthAuthenticatorResult } from '@backstage/plugin-auth-node'; +import { PassportOAuthAuthenticatorHelper } from '@backstage/plugin-auth-node'; +import { PassportProfile } from '@backstage/plugin-auth-node'; +import { SignInResolverFactory } from '@backstage/plugin-auth-node'; + +// @public (undocumented) +export const authModuleGithubProvider: () => BackendFeature; + +// @public (undocumented) +export const githubAuthenticator: OAuthAuthenticator< + PassportOAuthAuthenticatorHelper, + PassportProfile +>; + +// @public +export namespace githubSignInResolvers { + const usernameMatchingUserEntityName: SignInResolverFactory< + OAuthAuthenticatorResult, + unknown + >; +} +``` diff --git a/plugins/auth-backend-module-github-provider/dev/index.ts b/plugins/auth-backend-module-github-provider/dev/index.ts new file mode 100644 index 0000000000..9ea084004a --- /dev/null +++ b/plugins/auth-backend-module-github-provider/dev/index.ts @@ -0,0 +1,26 @@ +/* + * Copyright 2023 The Backstage Authors + * + * 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 { createBackend } from '@backstage/backend-defaults'; +import { authPlugin } from '@backstage/plugin-auth-backend'; +import { authModuleGithubProvider } from '../src'; + +const backend = createBackend(); + +backend.add(authPlugin); +backend.add(authModuleGithubProvider); + +backend.start(); diff --git a/plugins/auth-backend-module-github-provider/package.json b/plugins/auth-backend-module-github-provider/package.json new file mode 100644 index 0000000000..d436e512ea --- /dev/null +++ b/plugins/auth-backend-module-github-provider/package.json @@ -0,0 +1,41 @@ +{ + "name": "@backstage/plugin-auth-backend-module-github-provider", + "description": "The github-provider backend module for the auth plugin.", + "version": "0.0.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "backstage": { + "role": "backend-plugin-module" + }, + "scripts": { + "start": "backstage-cli package start", + "build": "backstage-cli package build", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test", + "clean": "backstage-cli package clean", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack" + }, + "dependencies": { + "@backstage/backend-common": "workspace:^", + "@backstage/backend-plugin-api": "workspace:^", + "@backstage/plugin-auth-node": "workspace:^", + "passport-github2": "^0.1.12" + }, + "devDependencies": { + "@backstage/backend-defaults": "workspace:^", + "@backstage/backend-test-utils": "workspace:^", + "@backstage/cli": "workspace:^", + "@backstage/plugin-auth-backend": "workspace:^", + "supertest": "^6.3.3" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/auth-backend-module-github-provider/src/authenticator.ts b/plugins/auth-backend-module-github-provider/src/authenticator.ts new file mode 100644 index 0000000000..f59ac3d727 --- /dev/null +++ b/plugins/auth-backend-module-github-provider/src/authenticator.ts @@ -0,0 +1,125 @@ +/* + * Copyright 2023 The Backstage Authors + * + * 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 { Strategy as GithubStrategy } from 'passport-github2'; +import { + createOAuthAuthenticator, + PassportOAuthAuthenticatorHelper, + PassportOAuthDoneCallback, + PassportProfile, +} from '@backstage/plugin-auth-node'; + +const ACCESS_TOKEN_PREFIX = 'access-token.'; + +/** @public */ +export const githubAuthenticator = createOAuthAuthenticator({ + defaultProfileTransform: + PassportOAuthAuthenticatorHelper.defaultProfileTransform, + initialize({ callbackUrl, config }) { + const clientId = config.getString('clientId'); + const clientSecret = config.getString('clientSecret'); + const enterpriseInstanceUrl = config + .getOptionalString('enterpriseInstanceUrl') + ?.replace(/\/$/, ''); + const authorizationUrl = enterpriseInstanceUrl + ? `${enterpriseInstanceUrl}/login/oauth/authorize` + : undefined; + const tokenUrl = enterpriseInstanceUrl + ? `${enterpriseInstanceUrl}/login/oauth/access_token` + : undefined; + const userProfileUrl = enterpriseInstanceUrl + ? `${enterpriseInstanceUrl}/api/v3/user` + : undefined; + + return PassportOAuthAuthenticatorHelper.from( + new GithubStrategy( + { + clientID: clientId, + clientSecret: clientSecret, + callbackURL: callbackUrl, + tokenURL: tokenUrl, + userProfileURL: userProfileUrl, + authorizationURL: authorizationUrl, + }, + ( + accessToken: string, + refreshToken: string, + params: any, + fullProfile: PassportProfile, + done: PassportOAuthDoneCallback, + ) => { + done( + undefined, + { fullProfile, params, accessToken }, + { refreshToken }, + ); + }, + ), + ); + }, + + async start(input, helper) { + return helper.start(input, { + accessType: 'offline', + prompt: 'consent', + }); + }, + + async authenticate(input, helper) { + const { fullProfile, session } = await helper.authenticate(input); + + // If we do not have a real refresh token and we have a non-expiring + // access token, then we use that as our refresh token. + if (!session.refreshToken && !session.expiresInSeconds) { + session.refreshToken = ACCESS_TOKEN_PREFIX + session.accessToken; + } + return { fullProfile, session }; + }, + + async refresh(input, helper) { + // This is the OAuth App flow. A non-expiring access token is stored in the + // refresh token cookie. We use that token to fetch the user profile and + // refresh the Backstage session when needed. + if (input.refreshToken?.startsWith(ACCESS_TOKEN_PREFIX)) { + const accessToken = input.refreshToken.slice(ACCESS_TOKEN_PREFIX.length); + + const fullProfile = await helper + .fetchProfile(accessToken) + .catch(error => { + if (error.oauthError?.statusCode === 401) { + throw new Error('Invalid access token'); + } + throw error; + }); + + return { + fullProfile, + session: { + accessToken, + tokenType: 'bearer', + scope: input.scope, + refreshToken: input.refreshToken, + // No expiration + }, + }; + } + + // This is the App flow, which is close to a standard OAuth refresh flow. It has a + // pretty long session expiration, and it also ignores the requested scope, instead + // just allowing access to whatever is configured as part of the app installation. + return helper.refresh(input); + }, +}); diff --git a/plugins/auth-backend-module-github-provider/src/index.ts b/plugins/auth-backend-module-github-provider/src/index.ts new file mode 100644 index 0000000000..ba389cbdc7 --- /dev/null +++ b/plugins/auth-backend-module-github-provider/src/index.ts @@ -0,0 +1,25 @@ +/* + * Copyright 2023 The Backstage Authors + * + * 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. + */ + +/** + * The github-provider backend module for the auth plugin. + * + * @packageDocumentation + */ + +export { githubAuthenticator } from './authenticator'; +export { authModuleGithubProvider } from './module'; +export { githubSignInResolvers } from './resolvers'; diff --git a/plugins/auth-backend-module-github-provider/src/module.ts b/plugins/auth-backend-module-github-provider/src/module.ts new file mode 100644 index 0000000000..54286c94a7 --- /dev/null +++ b/plugins/auth-backend-module-github-provider/src/module.ts @@ -0,0 +1,48 @@ +/* + * Copyright 2023 The Backstage Authors + * + * 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 { createBackendModule } from '@backstage/backend-plugin-api'; +import { + authProvidersExtensionPoint, + commonSignInResolvers, + createOAuthProviderFactory, +} from '@backstage/plugin-auth-node'; +import { githubAuthenticator } from './authenticator'; +import { githubSignInResolvers } from './resolvers'; + +/** @public */ +export const authModuleGithubProvider = createBackendModule({ + pluginId: 'auth', + moduleId: 'github-provider', + register(reg) { + reg.registerInit({ + deps: { + providers: authProvidersExtensionPoint, + }, + async init({ providers }) { + providers.registerProvider({ + providerId: 'github', + factory: createOAuthProviderFactory({ + authenticator: githubAuthenticator, + signInResolverFactories: { + ...githubSignInResolvers, + ...commonSignInResolvers, + }, + }), + }); + }, + }); + }, +}); diff --git a/plugins/auth-backend-module-github-provider/src/resolvers.ts b/plugins/auth-backend-module-github-provider/src/resolvers.ts new file mode 100644 index 0000000000..496080a33c --- /dev/null +++ b/plugins/auth-backend-module-github-provider/src/resolvers.ts @@ -0,0 +1,50 @@ +/* + * Copyright 2023 The Backstage Authors + * + * 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 { + createSignInResolverFactory, + OAuthAuthenticatorResult, + PassportProfile, + SignInInfo, +} from '@backstage/plugin-auth-node'; + +/** + * Available sign-in resolvers for the GitHub auth provider. + * + * @public + */ +export namespace githubSignInResolvers { + /** + * Looks up the user by matching their GitHub username to the entity name. + */ + export const usernameMatchingUserEntityName = createSignInResolverFactory({ + create() { + return async ( + info: SignInInfo>, + ctx, + ) => { + const { fullProfile } = info.result; + + const userId = fullProfile.username; + if (!userId) { + throw new Error(`GitHub user profile does not contain a username`); + } + + return ctx.signInWithCatalogUser({ entityRef: { name: userId } }); + }; + }, + }); +} diff --git a/yarn.lock b/yarn.lock index 1a10972dcb..69b8b92929 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4553,6 +4553,22 @@ __metadata: languageName: unknown linkType: soft +"@backstage/plugin-auth-backend-module-github-provider@workspace:plugins/auth-backend-module-github-provider": + version: 0.0.0-use.local + resolution: "@backstage/plugin-auth-backend-module-github-provider@workspace:plugins/auth-backend-module-github-provider" + dependencies: + "@backstage/backend-common": "workspace:^" + "@backstage/backend-defaults": "workspace:^" + "@backstage/backend-plugin-api": "workspace:^" + "@backstage/backend-test-utils": "workspace:^" + "@backstage/cli": "workspace:^" + "@backstage/plugin-auth-backend": "workspace:^" + "@backstage/plugin-auth-node": "workspace:^" + passport-github2: ^0.1.12 + supertest: ^6.3.3 + languageName: unknown + linkType: soft + "@backstage/plugin-auth-backend-module-google-provider@workspace:^, @backstage/plugin-auth-backend-module-google-provider@workspace:plugins/auth-backend-module-google-provider": version: 0.0.0-use.local resolution: "@backstage/plugin-auth-backend-module-google-provider@workspace:plugins/auth-backend-module-google-provider" From d852a15972358f487849ba6bec7aa221a04b67d9 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 20 Aug 2023 14:42:35 +0200 Subject: [PATCH 2/4] auth-node: add refreshTokenExpiresInSeconds field to OAuthSession Signed-off-by: Patrik Oldsberg --- plugins/auth-node/api-report.md | 2 ++ plugins/auth-node/src/oauth/types.ts | 1 + 2 files changed, 3 insertions(+) diff --git a/plugins/auth-node/api-report.md b/plugins/auth-node/api-report.md index bc17c328d4..a782847d7a 100644 --- a/plugins/auth-node/api-report.md +++ b/plugins/auth-node/api-report.md @@ -384,6 +384,8 @@ export interface OAuthSession { // (undocumented) refreshToken?: string; // (undocumented) + refreshTokenExpiresInSeconds?: number; + // (undocumented) scope: string; // (undocumented) tokenType: string; diff --git a/plugins/auth-node/src/oauth/types.ts b/plugins/auth-node/src/oauth/types.ts index bba961d27d..4bd7bd3f3f 100644 --- a/plugins/auth-node/src/oauth/types.ts +++ b/plugins/auth-node/src/oauth/types.ts @@ -26,6 +26,7 @@ export interface OAuthSession { scope: string; expiresInSeconds?: number; refreshToken?: string; + refreshTokenExpiresInSeconds?: number; } /** @public */ From 72f7979fd2d650a6edc86e35ed7da2582d1135b2 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 20 Aug 2023 14:50:01 +0200 Subject: [PATCH 3/4] auth-backend: migrate github provider Signed-off-by: Patrik Oldsberg --- plugins/auth-backend/api-report.md | 4 +- plugins/auth-backend/package.json | 1 + .../src/providers/github/provider.test.ts | 410 ------------------ .../src/providers/github/provider.ts | 308 +++---------- yarn.lock | 3 +- 5 files changed, 62 insertions(+), 664 deletions(-) delete mode 100644 plugins/auth-backend/src/providers/github/provider.test.ts diff --git a/plugins/auth-backend/api-report.md b/plugins/auth-backend/api-report.md index 9c5249f733..d50a26db2e 100644 --- a/plugins/auth-backend/api-report.md +++ b/plugins/auth-backend/api-report.md @@ -478,7 +478,7 @@ export const providers: Readonly<{ authHandler?: AuthHandler | undefined; signIn?: | { - resolver: SignInResolver; + resolver: SignInResolver_2; } | undefined; stateEncoder?: StateEncoder | undefined; @@ -486,7 +486,7 @@ export const providers: Readonly<{ | undefined, ) => AuthProviderFactory_2; resolvers: Readonly<{ - usernameMatchingUserEntityName: () => SignInResolver; + usernameMatchingUserEntityName: () => SignInResolver_2; }>; }>; gitlab: Readonly<{ diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index e1500d6516..00002b4e3f 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -39,6 +39,7 @@ "@backstage/config": "workspace:^", "@backstage/errors": "workspace:^", "@backstage/plugin-auth-backend-module-gcp-iap-provider": "workspace:^", + "@backstage/plugin-auth-backend-module-github-provider": "workspace:^", "@backstage/plugin-auth-backend-module-google-provider": "workspace:^", "@backstage/plugin-auth-node": "workspace:^", "@backstage/plugin-catalog-node": "workspace:^", diff --git a/plugins/auth-backend/src/providers/github/provider.test.ts b/plugins/auth-backend/src/providers/github/provider.test.ts deleted file mode 100644 index ff750223d3..0000000000 --- a/plugins/auth-backend/src/providers/github/provider.test.ts +++ /dev/null @@ -1,410 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * 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 { Profile as PassportProfile } from 'passport'; -import { GithubAuthProvider, GithubOAuthResult, github } from './provider'; -import * as helpers from '../../lib/passport/PassportStrategyHelper'; -import { makeProfileInfo } from '../../lib/passport/PassportStrategyHelper'; -import { OAuthStartRequest, encodeState } from '../../lib/oauth'; -import { AuthResolverContext } from '../types'; - -jest.mock('../../lib/passport/PassportStrategyHelper', () => { - return { - ...jest.requireActual('../../lib/passport/PassportStrategyHelper'), - executeFrameHandlerStrategy: jest.fn(), - executeRefreshTokenStrategy: jest.fn(), - executeFetchUserProfileStrategy: jest.fn(), - }; -}); - -const mockFrameHandler = jest.spyOn( - helpers, - 'executeFrameHandlerStrategy', -) as unknown as jest.MockedFunction< - () => Promise<{ - result: GithubOAuthResult; - privateInfo: { refreshToken?: string }; - }> ->; - -describe('GithubAuthProvider', () => { - const provider = new GithubAuthProvider({ - resolverContext: { - signInWithCatalogUser: jest.fn(({ entityRef }) => ({ - token: `token-for-user:${entityRef.name}`, - })), - } as unknown as AuthResolverContext, - signInResolver: github.resolvers.usernameMatchingUserEntityName(), - authHandler: async ({ fullProfile }) => ({ - profile: makeProfileInfo(fullProfile), - }), - stateEncoder: async (req: OAuthStartRequest) => ({ - encodedState: encodeState(req.state), - }), - callbackUrl: 'mock', - clientId: 'mock', - clientSecret: 'mock', - }); - - describe('should transform to type OAuthResponse', () => { - it('when all fields are present, it should be able to map them', async () => { - const accessToken = '19xasczxcm9n7gacn9jdgm19me'; - const fullProfile = { - 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: { - token: 'token-for-user:jimmymarkum', - }, - providerInfo: { - accessToken: '19xasczxcm9n7gacn9jdgm19me', - scope: 'read:scope', - expiresInSeconds: 3600, - }, - 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', - }, - }; - - mockFrameHandler.mockResolvedValueOnce({ - result: { fullProfile, accessToken, params }, - privateInfo: {}, - }); - const { response } = await provider.handler({} as any); - expect(response).toEqual(expected); - }); - - it('when "email" is missing, it should be able to create the profile without it', async () => { - const accessToken = '19xasczxcm9n7gacn9jdgm19me'; - const fullProfile = { - 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', - }, - ], - } as unknown as PassportProfile; - - const params = { - scope: 'read:scope', - }; - - const expected = { - backstageIdentity: { - token: 'token-for-user:jimmymarkum', - }, - providerInfo: { - accessToken: '19xasczxcm9n7gacn9jdgm19me', - scope: 'read:scope', - expiresInSeconds: 3600, - }, - profile: { - displayName: 'Jimmy Markum', - picture: - 'https://a1cf74336522e87f135f-2f21ace9a6cf0052456644b80fa06d4f.ssl.cf2.rackcdn.com/images/characters_opt/p-mystic-river-sean-penn.jpg', - }, - }; - - mockFrameHandler.mockResolvedValueOnce({ - result: { fullProfile, accessToken, params }, - privateInfo: {}, - }); - const { response } = await provider.handler({} as any); - expect(response).toEqual(expected); - }); - - it('when "displayName" is missing, it should be able to create the profile and map "displayName" with "username"', async () => { - const accessToken = '19xasczxcm9n7gacn9jdgm19me'; - const fullProfile = { - 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', - }, - ], - } as unknown as PassportProfile; - - const params = { - scope: 'read:scope', - }; - const expected = { - backstageIdentity: { - token: 'token-for-user:jimmymarkum', - }, - providerInfo: { - accessToken: '19xasczxcm9n7gacn9jdgm19me', - scope: 'read:scope', - expiresInSeconds: 3600, - }, - profile: { - displayName: 'jimmymarkum', - picture: - 'https://a1cf74336522e87f135f-2f21ace9a6cf0052456644b80fa06d4f.ssl.cf2.rackcdn.com/images/characters_opt/p-mystic-river-sean-penn.jpg', - }, - }; - - mockFrameHandler.mockResolvedValueOnce({ - result: { fullProfile, accessToken, params }, - privateInfo: {}, - }); - const { response } = await provider.handler({} as any); - expect(response).toEqual(expected); - }); - - it('when "photos" is missing, it should be able to create the profile without it', async () => { - const accessToken = - 'ajakljsdoiahoawxbrouawucmbawe.awkxjemaneasdxwe.sodijxqeqwexeqwxe'; - const fullProfile = { - id: 'ipd12039', - username: 'daveboyle', - provider: 'github', - displayName: 'Dave Boyle', - emails: [ - { - value: 'daveboyle@github.org', - }, - ], - }; - - const params = { - scope: 'read:user', - }; - - const expected = { - backstageIdentity: { - token: 'token-for-user:daveboyle', - }, - providerInfo: { - accessToken: - 'ajakljsdoiahoawxbrouawucmbawe.awkxjemaneasdxwe.sodijxqeqwexeqwxe', - scope: 'read:user', - expiresInSeconds: 3600, - }, - profile: { - displayName: 'Dave Boyle', - email: 'daveboyle@github.org', - }, - }; - - mockFrameHandler.mockResolvedValueOnce({ - result: { fullProfile, accessToken, params }, - privateInfo: {}, - }); - const { response } = await provider.handler({} as any); - expect(response).toEqual(expected); - }); - - it('should forward a refresh token', async () => { - mockFrameHandler.mockResolvedValueOnce({ - result: { - fullProfile: { - id: 'ipd12039', - username: 'daveboyle', - provider: 'github', - displayName: 'Dave Boyle', - }, - accessToken: 'a.b.c', - params: { - scope: 'read:user', - expires_in: '123', - }, - }, - privateInfo: { refreshToken: 'refresh-me' }, - }); - - const response = await provider.handler({} as any); - - expect(response).toEqual({ - response: { - backstageIdentity: { - token: 'token-for-user:daveboyle', - }, - providerInfo: { - accessToken: 'a.b.c', - scope: 'read:user', - expiresInSeconds: 123, - }, - profile: { - displayName: 'Dave Boyle', - }, - }, - refreshToken: 'refresh-me', - }); - }); - - it('should fail if username is not available', async () => { - mockFrameHandler.mockResolvedValueOnce({ - result: { - fullProfile: { - id: 'ipd12039', - provider: 'github', - displayName: 'Dave Boyle', - }, - accessToken: 'a.b.c', - params: { - scope: 'read:user', - expires_in: '123', - }, - }, - privateInfo: { refreshToken: 'refresh-me' }, - }); - - await expect(provider.handler({} as any)).rejects.toThrow( - 'GitHub user profile does not contain a username', - ); - }); - - it('should forward a new refresh token on refresh', async () => { - const mockRefreshToken = jest.spyOn( - helpers, - 'executeRefreshTokenStrategy', - ) as unknown as jest.MockedFunction<() => Promise<{}>>; - - mockRefreshToken.mockResolvedValueOnce({ - accessToken: 'a.b.c', - refreshToken: 'dont-forget-to-send-refresh', - params: { - id_token: 'my-id', - expires_in: '123', - scope: 'read_user', - }, - }); - - const mockUserProfile = jest.spyOn( - helpers, - 'executeFetchUserProfileStrategy', - ) as unknown as jest.MockedFunction<() => Promise>; - - mockUserProfile.mockResolvedValueOnce({ - id: 'mockid', - username: 'mockuser', - provider: 'github', - displayName: 'Mocked User', - emails: [ - { - value: 'mockuser@gmail.com', - }, - ], - }); - - const result = await provider.refresh({ scope: 'actual-scope' } as any); - - expect(result).toEqual({ - response: { - backstageIdentity: { - token: 'token-for-user:mockuser', - }, - profile: { - displayName: 'Mocked User', - email: 'mockuser@gmail.com', - picture: undefined, - }, - providerInfo: { - accessToken: 'a.b.c', - expiresInSeconds: 123, - scope: 'actual-scope', - }, - }, - refreshToken: 'dont-forget-to-send-refresh', - }); - - mockRefreshToken.mockRestore(); - mockUserProfile.mockRestore(); - }); - - it('should use access token as refresh token', async () => { - const mockUserProfile = jest.spyOn( - helpers, - 'executeFetchUserProfileStrategy', - ) as unknown as jest.MockedFunction<() => Promise>; - - mockUserProfile.mockResolvedValueOnce({ - id: 'mockid', - username: 'mockuser', - provider: 'github', - displayName: 'Mocked User', - emails: [ - { - value: 'mockuser@gmail.com', - }, - ], - }); - - const result = await provider.refresh({ - refreshToken: 'access-token.le-token', - scope: 'the-scope', - } as any); - - expect(mockUserProfile).toHaveBeenCalledTimes(1); - expect(mockUserProfile).toHaveBeenCalledWith( - expect.anything(), - 'le-token', - ); - expect(result).toEqual({ - response: { - backstageIdentity: { - token: 'token-for-user:mockuser', - }, - profile: { - displayName: 'Mocked User', - email: 'mockuser@gmail.com', - picture: undefined, - }, - providerInfo: { - accessToken: 'le-token', - expiresInSeconds: 3600, - scope: 'the-scope', - }, - }, - refreshToken: 'access-token.le-token', - }); - - mockUserProfile.mockRestore(); - }); - }); -}); diff --git a/plugins/auth-backend/src/providers/github/provider.ts b/plugins/auth-backend/src/providers/github/provider.ts index 11a71ca018..51595b0428 100644 --- a/plugins/auth-backend/src/providers/github/provider.ts +++ b/plugins/auth-backend/src/providers/github/provider.ts @@ -14,41 +14,16 @@ * limitations under the License. */ -import express from 'express'; import { Profile as PassportProfile } from 'passport'; -import { Strategy as GithubStrategy } from 'passport-github2'; -import { - executeFetchUserProfileStrategy, - executeFrameHandlerStrategy, - executeRedirectStrategy, - executeRefreshTokenStrategy, - makeProfileInfo, - PassportDoneCallback, -} from '../../lib/passport'; -import { - OAuthStartResponse, - AuthHandler, - SignInResolver, - StateEncoder, - AuthResolverContext, -} from '../types'; -import { - OAuthAdapter, - OAuthProviderOptions, - OAuthHandlers, - OAuthEnvironmentHandler, - OAuthStartRequest, - encodeState, - OAuthRefreshRequest, -} from '../../lib/oauth'; +import { AuthHandler, StateEncoder } from '../types'; import { createAuthProviderIntegration } from '../createAuthProviderIntegration'; -import { BACKSTAGE_SESSION_EXPIRATION } from '../../lib/session'; - -const ACCESS_TOKEN_PREFIX = 'access-token.'; - -type PrivateInfo = { - refreshToken?: string; -}; +import { + createOAuthProviderFactory, + OAuthAuthenticatorResult, + ProfileTransform, + SignInResolver, +} from '@backstage/plugin-auth-node'; +import { githubAuthenticator } from '@backstage/plugin-auth-backend-module-github-provider'; /** @public */ export type GithubOAuthResult = { @@ -62,170 +37,6 @@ export type GithubOAuthResult = { refreshToken?: string; }; -export type GithubAuthProviderOptions = OAuthProviderOptions & { - tokenUrl?: string; - userProfileUrl?: string; - authorizationUrl?: string; - signInResolver?: SignInResolver; - authHandler: AuthHandler; - stateEncoder: StateEncoder; - resolverContext: AuthResolverContext; -}; - -export class GithubAuthProvider implements OAuthHandlers { - private readonly _strategy: GithubStrategy; - private readonly signInResolver?: SignInResolver; - private readonly authHandler: AuthHandler; - private readonly resolverContext: AuthResolverContext; - private readonly stateEncoder: StateEncoder; - - constructor(options: GithubAuthProviderOptions) { - this.signInResolver = options.signInResolver; - this.authHandler = options.authHandler; - this.stateEncoder = options.stateEncoder; - this.resolverContext = options.resolverContext; - this._strategy = new GithubStrategy( - { - clientID: options.clientId, - clientSecret: options.clientSecret, - callbackURL: options.callbackUrl, - tokenURL: options.tokenUrl, - userProfileURL: options.userProfileUrl, - authorizationURL: options.authorizationUrl, - }, - ( - accessToken: any, - refreshToken: any, - params: any, - fullProfile: any, - done: PassportDoneCallback, - ) => { - done(undefined, { fullProfile, params, accessToken }, { refreshToken }); - }, - ); - } - - async start(req: OAuthStartRequest): Promise { - return await executeRedirectStrategy(req, this._strategy, { - scope: req.scope, - state: (await this.stateEncoder(req)).encodedState, - }); - } - - async handler(req: express.Request) { - const { result, privateInfo } = await executeFrameHandlerStrategy< - GithubOAuthResult, - PrivateInfo - >(req, this._strategy); - - let refreshToken = privateInfo.refreshToken; - - // If we do not have a real refresh token and we have a non-expiring - // access token, then we use that as our refresh token. - if (!refreshToken && !result.params.expires_in) { - refreshToken = ACCESS_TOKEN_PREFIX + result.accessToken; - } - - return { - response: await this.handleResult(result), - refreshToken, - }; - } - - async refresh(req: OAuthRefreshRequest) { - // We've enable persisting scope in the OAuth provider, so scope here will - // be whatever was stored in the cookie - const { scope, refreshToken } = req; - - // This is the OAuth App flow. A non-expiring access token is stored in the - // refresh token cookie. We use that token to fetch the user profile and - // refresh the Backstage session when needed. - if (refreshToken?.startsWith(ACCESS_TOKEN_PREFIX)) { - const accessToken = refreshToken.slice(ACCESS_TOKEN_PREFIX.length); - - const fullProfile = await executeFetchUserProfileStrategy( - this._strategy, - accessToken, - ).catch(error => { - if (error.oauthError?.statusCode === 401) { - throw new Error('Invalid access token'); - } - throw error; - }); - - return { - response: await this.handleResult({ - fullProfile, - params: { scope }, - accessToken, - }), - refreshToken, - }; - } - - // This is the App flow, which is close to a standard OAuth refresh flow. It has a - // pretty long session expiration, and it also ignores the requested scope, instead - // just allowing access to whatever is configured as part of the app installation. - const result = await executeRefreshTokenStrategy( - this._strategy, - refreshToken, - scope, - ); - return { - response: await this.handleResult({ - fullProfile: await executeFetchUserProfileStrategy( - this._strategy, - result.accessToken, - ), - params: { ...result.params, scope }, - accessToken: result.accessToken, - }), - refreshToken: result.refreshToken, - }; - } - - private async handleResult(result: GithubOAuthResult) { - const { profile } = await this.authHandler(result, this.resolverContext); - - const expiresInStr = result.params.expires_in; - let expiresInSeconds = - expiresInStr === undefined ? undefined : Number(expiresInStr); - - let backstageIdentity = undefined; - - if (this.signInResolver) { - backstageIdentity = await this.signInResolver( - { - result, - profile, - }, - this.resolverContext, - ); - - // GitHub sessions last longer than Backstage sessions, so if we're using - // GitHub for sign-in, then we need to expire the sessions earlier - if (expiresInSeconds) { - expiresInSeconds = Math.min( - expiresInSeconds, - BACKSTAGE_SESSION_EXPIRATION, - ); - } else { - expiresInSeconds = BACKSTAGE_SESSION_EXPIRATION; - } - } - - return { - backstageIdentity, - providerInfo: { - accessToken: result.accessToken, - scope: result.params.scope, - expiresInSeconds, - }, - profile, - }; - } -} - /** * Auth provider integration for GitHub auth * @@ -267,60 +78,55 @@ export const github = createAuthProviderIntegration({ */ stateEncoder?: StateEncoder; }) { - return ({ providerId, globalConfig, config, resolverContext }) => - OAuthEnvironmentHandler.mapConfig(config, envConfig => { - const clientId = envConfig.getString('clientId'); - const clientSecret = envConfig.getString('clientSecret'); - const enterpriseInstanceUrl = envConfig - .getOptionalString('enterpriseInstanceUrl') - ?.replace(/\/$/, ''); - const customCallbackUrl = envConfig.getOptionalString('callbackUrl'); - const authorizationUrl = enterpriseInstanceUrl - ? `${enterpriseInstanceUrl}/login/oauth/authorize` - : undefined; - const tokenUrl = enterpriseInstanceUrl - ? `${enterpriseInstanceUrl}/login/oauth/access_token` - : undefined; - const userProfileUrl = enterpriseInstanceUrl - ? `${enterpriseInstanceUrl}/api/v3/user` - : undefined; - const callbackUrl = - customCallbackUrl || - `${globalConfig.baseUrl}/${providerId}/handler/frame`; - - const authHandler: AuthHandler = options?.authHandler - ? options.authHandler - : async ({ fullProfile }) => ({ - profile: makeProfileInfo(fullProfile), - }); - - const stateEncoder: StateEncoder = - options?.stateEncoder ?? - (async ( - req: OAuthStartRequest, - ): Promise<{ encodedState: string }> => { - return { encodedState: encodeState(req.state) }; - }); - - const provider = new GithubAuthProvider({ - clientId, - clientSecret, - callbackUrl, - tokenUrl, - userProfileUrl, - authorizationUrl, - signInResolver: options?.signIn?.resolver, - authHandler, - stateEncoder, - resolverContext, - }); - - return OAuthAdapter.fromConfig(globalConfig, provider, { - persistScopes: true, - providerId, - callbackUrl, - }); - }); + const authHandler = options?.authHandler; + const signInResolver = options?.signIn?.resolver; + return createOAuthProviderFactory({ + authenticator: githubAuthenticator, + profileTransform: + authHandler && + ((async (result, ctx) => + authHandler!( + { + fullProfile: result.fullProfile, + accessToken: result.session.accessToken, + params: { + scope: result.session.scope, + expires_in: result.session.expiresInSeconds + ? String(result.session.expiresInSeconds) + : '', + refresh_token_expires_in: result.session + .refreshTokenExpiresInSeconds + ? String(result.session.refreshTokenExpiresInSeconds) + : '', + }, + }, + ctx, + )) as ProfileTransform>), + signInResolver: + signInResolver && + ((async ({ profile, result }, ctx) => + signInResolver( + { + profile: profile, + result: { + fullProfile: result.fullProfile, + accessToken: result.session.accessToken, + refreshToken: result.session.refreshToken, + params: { + scope: result.session.scope, + expires_in: result.session.expiresInSeconds + ? String(result.session.expiresInSeconds) + : '', + refresh_token_expires_in: result.session + .refreshTokenExpiresInSeconds + ? String(result.session.refreshTokenExpiresInSeconds) + : '', + }, + }, + }, + ctx, + )) as SignInResolver>), + }); }, resolvers: { /** diff --git a/yarn.lock b/yarn.lock index 69b8b92929..553df083c5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4553,7 +4553,7 @@ __metadata: languageName: unknown linkType: soft -"@backstage/plugin-auth-backend-module-github-provider@workspace:plugins/auth-backend-module-github-provider": +"@backstage/plugin-auth-backend-module-github-provider@workspace:^, @backstage/plugin-auth-backend-module-github-provider@workspace:plugins/auth-backend-module-github-provider": version: 0.0.0-use.local resolution: "@backstage/plugin-auth-backend-module-github-provider@workspace:plugins/auth-backend-module-github-provider" dependencies: @@ -4599,6 +4599,7 @@ __metadata: "@backstage/config": "workspace:^" "@backstage/errors": "workspace:^" "@backstage/plugin-auth-backend-module-gcp-iap-provider": "workspace:^" + "@backstage/plugin-auth-backend-module-github-provider": "workspace:^" "@backstage/plugin-auth-backend-module-google-provider": "workspace:^" "@backstage/plugin-auth-node": "workspace:^" "@backstage/plugin-catalog-node": "workspace:^" From b2a8da6e77739955e61696375f14abc590b8bed8 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 20 Aug 2023 14:50:32 +0200 Subject: [PATCH 4/4] auth-backend-module-github-provider: add tests Signed-off-by: Patrik Oldsberg --- .../src/authenticator.test.ts | 142 ++++++++++++++++++ .../src/module.test.ts | 78 ++++++++++ 2 files changed, 220 insertions(+) create mode 100644 plugins/auth-backend-module-github-provider/src/authenticator.test.ts create mode 100644 plugins/auth-backend-module-github-provider/src/module.test.ts diff --git a/plugins/auth-backend-module-github-provider/src/authenticator.test.ts b/plugins/auth-backend-module-github-provider/src/authenticator.test.ts new file mode 100644 index 0000000000..dc5c7208ea --- /dev/null +++ b/plugins/auth-backend-module-github-provider/src/authenticator.test.ts @@ -0,0 +1,142 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 { + PassportOAuthAuthenticatorHelper, + PassportProfile, +} from '@backstage/plugin-auth-node'; +import { githubAuthenticator } from './authenticator'; + +describe('githubAuthenticator', () => { + it('should store access token without expiration as refresh token', async () => { + await expect( + githubAuthenticator.authenticate( + {} as any, + { + authenticate: async _input => ({ + fullProfile: { id: 'id' } as PassportProfile, + session: { + accessToken: 'my-token', + scope: 'user:read', + tokenType: 'bearer', + }, + }), + } as PassportOAuthAuthenticatorHelper, + ), + ).resolves.toEqual({ + fullProfile: { id: 'id' }, + session: { + accessToken: 'my-token', + scope: 'user:read', + tokenType: 'bearer', + refreshToken: 'access-token.my-token', + }, + }); + }); + + it('should not use access token as refresh token if it expires', async () => { + await expect( + githubAuthenticator.authenticate( + {} as any, + { + authenticate: async _input => ({ + fullProfile: { id: 'id' } as PassportProfile, + session: { + accessToken: 'my-token', + scope: 'user:read', + tokenType: 'bearer', + expiresInSeconds: 3, + }, + }), + } as PassportOAuthAuthenticatorHelper, + ), + ).resolves.toEqual({ + fullProfile: { id: 'id' }, + session: { + accessToken: 'my-token', + scope: 'user:read', + tokenType: 'bearer', + expiresInSeconds: 3, + }, + }); + }); + + it('should not store access token without expiration if a refresh token is provided', async () => { + await expect( + githubAuthenticator.authenticate( + {} as any, + { + authenticate: async _input => ({ + fullProfile: { id: 'id' } as PassportProfile, + session: { + accessToken: 'my-token', + scope: 'user:read', + tokenType: 'bearer', + refreshToken: 'my-refresh-token', + }, + }), + } as PassportOAuthAuthenticatorHelper, + ), + ).resolves.toEqual({ + fullProfile: { id: 'id' }, + session: { + accessToken: 'my-token', + scope: 'user:read', + tokenType: 'bearer', + refreshToken: 'my-refresh-token', + }, + }); + }); + + it('should refresh with access token', async () => { + await expect( + githubAuthenticator.refresh( + { + refreshToken: 'access-token.my-token', + req: {} as any, + scope: 'user:read', + }, + { + fetchProfile: async _input => ({ id: 'id' } as PassportProfile), + } as PassportOAuthAuthenticatorHelper, + ), + ).resolves.toEqual({ + fullProfile: { id: 'id' }, + session: { + accessToken: 'my-token', + scope: 'user:read', + tokenType: 'bearer', + refreshToken: 'access-token.my-token', + }, + }); + }); + + it('should refresh with refresh token', async () => { + const res = {}; + await expect( + githubAuthenticator.refresh( + { + refreshToken: 'my-refresh-token', + req: {} as any, + scope: 'user:read', + }, + { + refresh: async _input => res as any, + } as PassportOAuthAuthenticatorHelper, + ), + ).resolves.toBe(res); + }); +}); diff --git a/plugins/auth-backend-module-github-provider/src/module.test.ts b/plugins/auth-backend-module-github-provider/src/module.test.ts new file mode 100644 index 0000000000..d4fc36cbc6 --- /dev/null +++ b/plugins/auth-backend-module-github-provider/src/module.test.ts @@ -0,0 +1,78 @@ +/* + * Copyright 2023 The Backstage Authors + * + * 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 { mockServices, startTestBackend } from '@backstage/backend-test-utils'; +import { authPlugin } from '@backstage/plugin-auth-backend'; +import { authModuleGithubProvider } from './module'; +import request from 'supertest'; +import { decodeOAuthState } from '@backstage/plugin-auth-node'; + +describe('authModuleGithubProvider', () => { + it('should start', async () => { + const { server } = await startTestBackend({ + features: [ + authPlugin, + authModuleGithubProvider, + mockServices.rootConfig.factory({ + data: { + app: { + baseUrl: 'http://localhost:3000', + }, + auth: { + providers: { + github: { + development: { + clientId: 'my-client-id', + clientSecret: 'my-client-secret', + }, + }, + }, + }, + }, + }), + ], + }); + + const agent = request.agent(server); + + const res = await agent.get('/api/auth/github/start?env=development'); + + expect(res.status).toEqual(302); + + const nonceCookie = agent.jar.getCookie('github-nonce', { + domain: 'localhost', + path: '/api/auth/github/handler', + script: false, + secure: false, + }); + expect(nonceCookie).toBeDefined(); + + const startUrl = new URL(res.get('location')); + expect(startUrl.origin).toBe('https://github.com'); + expect(startUrl.pathname).toBe('/login/oauth/authorize'); + expect(Object.fromEntries(startUrl.searchParams)).toEqual({ + response_type: 'code', + client_id: 'my-client-id', + redirect_uri: `http://localhost:${server.port()}/api/auth/github/handler/frame`, + state: expect.any(String), + }); + + expect(decodeOAuthState(startUrl.searchParams.get('state')!)).toEqual({ + env: 'development', + nonce: decodeURIComponent(nonceCookie.value), + }); + }); +});