diff --git a/.changeset/strong-taxis-refuse.md b/.changeset/strong-taxis-refuse.md new file mode 100644 index 0000000000..f198dc4bab --- /dev/null +++ b/.changeset/strong-taxis-refuse.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend': patch +--- + +Added support for storing static GitHub access tokens in cookies and using them to refresh the Backstage session. diff --git a/plugins/auth-backend/api-report.md b/plugins/auth-backend/api-report.md index b6c484e066..88b75d99b0 100644 --- a/plugins/auth-backend/api-report.md +++ b/plugins/auth-backend/api-report.md @@ -735,6 +735,6 @@ export type WebMessageResponse = // // src/identity/types.d.ts:31:9 - (ae-forgotten-export) The symbol "AnyJWK" needs to be exported by the entry point index.d.ts // src/providers/aws-alb/provider.d.ts:77:5 - (ae-forgotten-export) The symbol "AwsAlbResult" needs to be exported by the entry point index.d.ts -// src/providers/github/provider.d.ts:81:5 - (ae-forgotten-export) The symbol "StateEncoder" needs to be exported by the entry point index.d.ts +// src/providers/github/provider.d.ts:97:5 - (ae-forgotten-export) The symbol "StateEncoder" needs to be exported by the entry point index.d.ts // src/providers/types.d.ts:98:5 - (ae-forgotten-export) The symbol "AuthProviderConfig" needs to be exported by the entry point index.d.ts ``` diff --git a/plugins/auth-backend/src/providers/github/provider.test.ts b/plugins/auth-backend/src/providers/github/provider.test.ts index 15eeccc07c..206df52083 100644 --- a/plugins/auth-backend/src/providers/github/provider.test.ts +++ b/plugins/auth-backend/src/providers/github/provider.test.ts @@ -98,6 +98,7 @@ describe('GithubAuthProvider', () => { providerInfo: { accessToken: '19xasczxcm9n7gacn9jdgm19me', scope: 'read:scope', + expiresInSeconds: 3600, }, profile: { email: 'jimmymarkum@gmail.com', @@ -143,6 +144,7 @@ describe('GithubAuthProvider', () => { providerInfo: { accessToken: '19xasczxcm9n7gacn9jdgm19me', scope: 'read:scope', + expiresInSeconds: 3600, }, profile: { displayName: 'Jimmy Markum', @@ -186,6 +188,7 @@ describe('GithubAuthProvider', () => { providerInfo: { accessToken: '19xasczxcm9n7gacn9jdgm19me', scope: 'read:scope', + expiresInSeconds: 3600, }, profile: { displayName: 'jimmymarkum', @@ -230,6 +233,7 @@ describe('GithubAuthProvider', () => { accessToken: 'ajakljsdoiahoawxbrouawucmbawe.awkxjemaneasdxwe.sodijxqeqwexeqwxe', scope: 'read:user', + expiresInSeconds: 3600, }, profile: { displayName: 'Dave Boyle', @@ -316,7 +320,7 @@ describe('GithubAuthProvider', () => { ], }); - const result = await provider.refresh({} as any); + const result = await provider.refresh({ scope: 'actual-scope' } as any); expect(result).toEqual({ response: { @@ -332,11 +336,65 @@ describe('GithubAuthProvider', () => { providerInfo: { accessToken: 'a.b.c', expiresInSeconds: 123, - scope: 'read_user', + 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: { + id: 'mockuser', + token: 'token-for-user:default/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 9dc70cf60e..ec7f8cafa6 100644 --- a/plugins/auth-backend/src/providers/github/provider.ts +++ b/plugins/auth-backend/src/providers/github/provider.ts @@ -41,11 +41,15 @@ import { OAuthStartRequest, encodeState, OAuthRefreshRequest, - OAuthResponse, } from '../../lib/oauth'; import { CatalogIdentityClient } from '../../lib/catalog'; import { TokenIssuer } from '../../identity'; +const ACCESS_TOKEN_PREFIX = 'access-token.'; + +// TODO(Rugvip): Auth providers need a way to access this in a less hardcoded way +const BACKSTAGE_SESSION_EXPIRATION = 3600; + type PrivateInfo = { refreshToken?: string; }; @@ -123,31 +127,69 @@ export class GithubAuthProvider implements OAuthHandlers { 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: privateInfo.refreshToken, + refreshToken, }; } async refresh(req: OAuthRefreshRequest) { - const { accessToken, refreshToken, params } = - await executeRefreshTokenStrategy( - this._strategy, - req.refreshToken, - req.scope, - ); - const fullProfile = await executeFetchUserProfileStrategy( - this._strategy, - accessToken, - ); + // 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, - params, - accessToken, + fullProfile: await executeFetchUserProfileStrategy( + this._strategy, + result.accessToken, + ), + params: { ...result.params, scope }, + accessToken: result.accessToken, }), - refreshToken, + refreshToken: result.refreshToken, }; } @@ -160,27 +202,41 @@ export class GithubAuthProvider implements OAuthHandlers { const { profile } = await this.authHandler(result, context); const expiresInStr = result.params.expires_in; - const response: OAuthResponse = { - providerInfo: { - accessToken: result.accessToken, - scope: result.params.scope, - expiresInSeconds: - expiresInStr === undefined ? undefined : Number(expiresInStr), - }, - profile, - }; + let expiresInSeconds = + expiresInStr === undefined ? undefined : Number(expiresInStr); + + let backstageIdentity = undefined; if (this.signInResolver) { - response.backstageIdentity = await this.signInResolver( + backstageIdentity = await this.signInResolver( { result, profile, }, context, ); + + // 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 response; + return { + backstageIdentity, + providerInfo: { + accessToken: result.accessToken, + scope: result.params.scope, + expiresInSeconds, + }, + profile, + }; } }