diff --git a/.changeset/clever-kids-return.md b/.changeset/clever-kids-return.md new file mode 100644 index 0000000000..a6cab79f2b --- /dev/null +++ b/.changeset/clever-kids-return.md @@ -0,0 +1,13 @@ +--- +'@backstage/plugin-auth-node': patch +--- + +Updated scope management for OAuth providers, where the `createOAuthAuthenticator` now accepts a new collection of `scopes` options: + +- `scopes.persist` - Whether scopes should be persisted, replaces the `shouldPersistScopes` option. +- `scopes.required` - A list of required scopes that will always be requested. +- `scopes.transform` - A function that can be used to transform the scopes before they are requested. + +The `createOAuthProviderFactory` has also received a new `additionalScopes` option, and will also read `additionalScopes` from the auth provider configuration. Both of these can be used to add additional scopes that should always be requested. + +A significant change under the hood that this new scope management brings is that providers that persist scopes will now always merge the already granted scopes with the requested ones. The previous behavior was that the full authorization flow would not include existing scopes, while the refresh flow would only include the existing scopes. diff --git a/.changeset/long-llamas-push-atlassian.md b/.changeset/long-llamas-push-atlassian.md new file mode 100644 index 0000000000..3e0ae5ad14 --- /dev/null +++ b/.changeset/long-llamas-push-atlassian.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend-module-atlassian-provider': minor +--- + +**BREAKING**: The `scope` and `scopes` config options have been removed and replaced by the standard `additionalScopes` config. In addition, the `offline_access`, `read:jira-work`, and `read:jira-user` scopes have been set to required and will always be present. diff --git a/.changeset/long-llamas-push-bitbucket.md b/.changeset/long-llamas-push-bitbucket.md new file mode 100644 index 0000000000..36a68219eb --- /dev/null +++ b/.changeset/long-llamas-push-bitbucket.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend-module-bitbucket-provider': patch +--- + +Added support for the new shared `additionalScopes` configuration. In addition, the `account` scope has been set to required and will always be present. diff --git a/.changeset/long-llamas-push-github.md b/.changeset/long-llamas-push-github.md new file mode 100644 index 0000000000..c68fb8e925 --- /dev/null +++ b/.changeset/long-llamas-push-github.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend-module-github-provider': patch +--- + +Added support for the new shared `additionalScopes` configuration. In addition, the `read:user` scope has been set to required and will always be present. diff --git a/.changeset/long-llamas-push-gitlab.md b/.changeset/long-llamas-push-gitlab.md new file mode 100644 index 0000000000..fe5d293723 --- /dev/null +++ b/.changeset/long-llamas-push-gitlab.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend-module-gitlab-provider': patch +--- + +Added support for the new shared `additionalScopes` configuration. In addition, the `read_user` scope has been set to required and will always be present. diff --git a/.changeset/long-llamas-push-google.md b/.changeset/long-llamas-push-google.md new file mode 100644 index 0000000000..c9de73eb36 --- /dev/null +++ b/.changeset/long-llamas-push-google.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend-module-google-provider': patch +--- + +Added support for the new shared `additionalScopes` configuration. In addition, the `openid`, `userinfo.email`, and `userinfo.profile` scopes have been set to required and will always be present. diff --git a/.changeset/long-llamas-push-microsoft.md b/.changeset/long-llamas-push-microsoft.md new file mode 100644 index 0000000000..f6509db0f7 --- /dev/null +++ b/.changeset/long-llamas-push-microsoft.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend-module-microsoft-provider': patch +--- + +Added support for the new shared `additionalScopes` configuration. diff --git a/.changeset/long-llamas-push-oauth2.md b/.changeset/long-llamas-push-oauth2.md new file mode 100644 index 0000000000..2a233dbb42 --- /dev/null +++ b/.changeset/long-llamas-push-oauth2.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend-module-oauth2-provider': minor +--- + +**BREAKING**: The `scope` config option have been removed and replaced by the standard `additionalScopes` config. diff --git a/.changeset/long-llamas-push-oidc.md b/.changeset/long-llamas-push-oidc.md new file mode 100644 index 0000000000..248d762864 --- /dev/null +++ b/.changeset/long-llamas-push-oidc.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend-module-oidc-provider': minor +--- + +**BREAKING**: The `scope` config option have been removed and replaced by the standard `additionalScopes` config. In addition, `openid`, `profile`, and `email` scopes have been set to required and will always be present. diff --git a/.changeset/long-llamas-push-okta.md b/.changeset/long-llamas-push-okta.md new file mode 100644 index 0000000000..d330746d2e --- /dev/null +++ b/.changeset/long-llamas-push-okta.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend-module-okta-provider': patch +--- + +Added support for the new shared `additionalScopes` configuration, which means it can now also be specified as an array. In addition, the `openid`, `email`, `profile`, and `offline_access` scopes have been set to required and will always be present. diff --git a/.changeset/long-llamas-push-pinniped.md b/.changeset/long-llamas-push-pinniped.md new file mode 100644 index 0000000000..a9c6a54cee --- /dev/null +++ b/.changeset/long-llamas-push-pinniped.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend-module-pinniped-provider': patch +--- + +**BREAKING**: The `scope` config option have been removed and replaced by the standard `additionalScopes` config. In addition, the `openid`, `pinniped:request-audience`, `username`, and `offline_access` scopes have been set to required and will always be present. diff --git a/.changeset/long-llamas-push-vmware-cloud.md b/.changeset/long-llamas-push-vmware-cloud.md new file mode 100644 index 0000000000..9edfb01f68 --- /dev/null +++ b/.changeset/long-llamas-push-vmware-cloud.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend-module-vmware-cloud-provider': minor +--- + +**BREAKING**: The `scope` config option have been removed and replaced by the standard `additionalScopes` config. In addition, `openid`, and `offline_access` scopes have been set to required and will always be present. diff --git a/plugins/auth-backend-module-atlassian-provider/config.d.ts b/plugins/auth-backend-module-atlassian-provider/config.d.ts index 57def03aab..d2c8a249c1 100644 --- a/plugins/auth-backend-module-atlassian-provider/config.d.ts +++ b/plugins/auth-backend-module-atlassian-provider/config.d.ts @@ -27,7 +27,7 @@ export interface Config { clientSecret: string; audience?: string; callbackUrl?: string; - scope?: string; + additionalScopes?: string | string[]; }; }; }; diff --git a/plugins/auth-backend-module-atlassian-provider/src/authenticator.ts b/plugins/auth-backend-module-atlassian-provider/src/authenticator.ts index b31de13134..0ced9cf831 100644 --- a/plugins/auth-backend-module-atlassian-provider/src/authenticator.ts +++ b/plugins/auth-backend-module-atlassian-provider/src/authenticator.ts @@ -26,15 +26,20 @@ import { Strategy as AtlassianStrategy } from 'passport-atlassian-oauth2'; export const atlassianAuthenticator = createOAuthAuthenticator({ defaultProfileTransform: PassportOAuthAuthenticatorHelper.defaultProfileTransform, + scopes: { + required: ['offline_access', 'read:jira-work', 'read:jira-user'], + }, initialize({ callbackUrl, config }) { const clientId = config.getString('clientId'); const clientSecret = config.getString('clientSecret'); - const scope = - config.getOptionalString('scope') ?? - config.getOptionalString('scopes') ?? - 'offline_access read:jira-work read:jira-user'; const baseUrl = 'https://auth.atlassian.com'; + if (config.has('scope') || config.has('scopes')) { + throw new Error( + 'The atlassian provider no longer supports the "scope" or "scopes" configuration options. Please use the "additionalScopes" option instead.', + ); + } + return PassportOAuthAuthenticatorHelper.from( new AtlassianStrategy( { @@ -45,7 +50,6 @@ export const atlassianAuthenticator = createOAuthAuthenticator({ authorizationURL: `${baseUrl}/authorize`, tokenURL: `${baseUrl}/oauth/token`, profileURL: `${baseUrl}/api/v4/user`, - scope, }, ( accessToken: string, diff --git a/plugins/auth-backend-module-atlassian-provider/src/module.test.ts b/plugins/auth-backend-module-atlassian-provider/src/module.test.ts index 08b84ad165..7b92218d56 100644 --- a/plugins/auth-backend-module-atlassian-provider/src/module.test.ts +++ b/plugins/auth-backend-module-atlassian-provider/src/module.test.ts @@ -75,7 +75,8 @@ describe('authModuleAtlassianProvider', () => { nonce: decodeURIComponent(nonceCookie.value), }); }); - it('should start with and use custom scopes from scope config field', async () => { + + it('should start with and use custom scopes from additionalScopes config field', async () => { const { server } = await startTestBackend({ features: [ import('@backstage/plugin-auth-backend'), @@ -91,7 +92,10 @@ describe('authModuleAtlassianProvider', () => { development: { clientId: 'my-client-id', clientSecret: 'my-client-secret', - scope: 'offline_access read:filter:jira read:jira-work', + additionalScopes: [ + 'read:filter:jira', + 'read:jira-work', // already required + ], }, }, }, @@ -123,7 +127,7 @@ describe('authModuleAtlassianProvider', () => { client_id: 'my-client-id', redirect_uri: `http://localhost:${server.port()}/api/auth/atlassian/handler/frame`, state: expect.any(String), - scope: 'offline_access read:filter:jira read:jira-work', + scope: 'offline_access read:jira-work read:jira-user read:filter:jira', }); expect(decodeOAuthState(startUrl.searchParams.get('state')!)).toEqual({ @@ -131,60 +135,35 @@ describe('authModuleAtlassianProvider', () => { nonce: decodeURIComponent(nonceCookie.value), }); }); - it('should start with and use custom scopes from scopes config field for backward compatibility', async () => { - const { server } = await startTestBackend({ - features: [ - import('@backstage/plugin-auth-backend'), - authModuleAtlassianProvider, - mockServices.rootConfig.factory({ - data: { - app: { - baseUrl: 'http://localhost:3000', - }, - auth: { - providers: { - atlassian: { - development: { - clientId: 'my-client-id', - clientSecret: 'my-client-secret', - scopes: 'offline_access read:filter:jira read:jira-work', + + it('should fail to start with scope or scopes config', async () => { + await expect( + startTestBackend({ + features: [ + import('@backstage/plugin-auth-backend'), + authModuleAtlassianProvider, + mockServices.rootConfig.factory({ + data: { + app: { + baseUrl: 'http://localhost:3000', + }, + auth: { + providers: { + atlassian: { + development: { + clientId: 'my-client-id', + clientSecret: 'my-client-secret', + scope: 'foo', + }, }, }, }, }, - }, - }), - ], - }); - - const agent = request.agent(server); - - const res = await agent.get('/api/auth/atlassian/start?env=development'); - - expect(res.status).toEqual(302); - - const nonceCookie = agent.jar.getCookie('atlassian-nonce', { - domain: 'localhost', - path: '/api/auth/atlassian/handler', - script: false, - secure: false, - }); - expect(nonceCookie).toBeDefined(); - - const startUrl = new URL(res.get('location')); - expect(startUrl.origin).toBe('https://auth.atlassian.com'); - expect(startUrl.pathname).toBe('/authorize'); - expect(Object.fromEntries(startUrl.searchParams)).toEqual({ - response_type: 'code', - client_id: 'my-client-id', - redirect_uri: `http://localhost:${server.port()}/api/auth/atlassian/handler/frame`, - state: expect.any(String), - scope: 'offline_access read:filter:jira read:jira-work', - }); - - expect(decodeOAuthState(startUrl.searchParams.get('state')!)).toEqual({ - env: 'development', - nonce: decodeURIComponent(nonceCookie.value), - }); + }), + ], + }), + ).rejects.toThrow( + /atlassian provider no longer supports the "scope" or "scopes" configuration options/, + ); }); }); diff --git a/plugins/auth-backend-module-bitbucket-provider/config.d.ts b/plugins/auth-backend-module-bitbucket-provider/config.d.ts index 81e835a5e6..17cac1c194 100644 --- a/plugins/auth-backend-module-bitbucket-provider/config.d.ts +++ b/plugins/auth-backend-module-bitbucket-provider/config.d.ts @@ -25,6 +25,7 @@ export interface Config { * @visibility secret */ clientSecret: string; + additionalScopes?: string | string[]; }; }; }; diff --git a/plugins/auth-backend-module-bitbucket-provider/src/authenticator.ts b/plugins/auth-backend-module-bitbucket-provider/src/authenticator.ts index bc962ec773..a2d8275964 100644 --- a/plugins/auth-backend-module-bitbucket-provider/src/authenticator.ts +++ b/plugins/auth-backend-module-bitbucket-provider/src/authenticator.ts @@ -26,6 +26,9 @@ import { export const bitbucketAuthenticator = createOAuthAuthenticator({ defaultProfileTransform: PassportOAuthAuthenticatorHelper.defaultProfileTransform, + scopes: { + required: ['account'], + }, initialize({ callbackUrl, config }) { const clientID = config.getString('clientId'); const clientSecret = config.getString('clientSecret'); diff --git a/plugins/auth-backend-module-bitbucket-provider/src/module.test.ts b/plugins/auth-backend-module-bitbucket-provider/src/module.test.ts index e761369a9c..f1426d1b0d 100644 --- a/plugins/auth-backend-module-bitbucket-provider/src/module.test.ts +++ b/plugins/auth-backend-module-bitbucket-provider/src/module.test.ts @@ -67,6 +67,7 @@ describe('authModuleBitbucketProvider', () => { client_id: 'my-client-id', redirect_uri: `http://localhost:${server.port()}/api/auth/bitbucket/handler/frame`, state: expect.any(String), + scope: 'account', }); expect(decodeOAuthState(startUrl.searchParams.get('state')!)).toEqual({ diff --git a/plugins/auth-backend-module-github-provider/config.d.ts b/plugins/auth-backend-module-github-provider/config.d.ts index e79711310d..f60d4c239d 100644 --- a/plugins/auth-backend-module-github-provider/config.d.ts +++ b/plugins/auth-backend-module-github-provider/config.d.ts @@ -27,6 +27,7 @@ export interface Config { clientSecret: string; callbackUrl?: string; enterpriseInstanceUrl?: string; + additionalScopes?: string | string[]; }; }; }; diff --git a/plugins/auth-backend-module-github-provider/src/authenticator.ts b/plugins/auth-backend-module-github-provider/src/authenticator.ts index 4a9710d114..7c94a04c30 100644 --- a/plugins/auth-backend-module-github-provider/src/authenticator.ts +++ b/plugins/auth-backend-module-github-provider/src/authenticator.ts @@ -28,7 +28,10 @@ const ACCESS_TOKEN_PREFIX = 'access-token.'; export const githubAuthenticator = createOAuthAuthenticator({ defaultProfileTransform: PassportOAuthAuthenticatorHelper.defaultProfileTransform, - shouldPersistScopes: true, + scopes: { + persist: true, + required: ['read:user'], + }, initialize({ callbackUrl, config }) { const clientId = config.getString('clientId'); const clientSecret = config.getString('clientSecret'); diff --git a/plugins/auth-backend-module-github-provider/src/module.test.ts b/plugins/auth-backend-module-github-provider/src/module.test.ts index 437e002bc3..53308938c0 100644 --- a/plugins/auth-backend-module-github-provider/src/module.test.ts +++ b/plugins/auth-backend-module-github-provider/src/module.test.ts @@ -67,11 +67,13 @@ describe('authModuleGithubProvider', () => { client_id: 'my-client-id', redirect_uri: `http://localhost:${server.port()}/api/auth/github/handler/frame`, state: expect.any(String), + scope: 'read:user', }); expect(decodeOAuthState(startUrl.searchParams.get('state')!)).toEqual({ env: 'development', nonce: decodeURIComponent(nonceCookie.value), + scope: 'read:user', }); }); }); diff --git a/plugins/auth-backend-module-gitlab-provider/config.d.ts b/plugins/auth-backend-module-gitlab-provider/config.d.ts index d7f04d872a..18f7cb5005 100644 --- a/plugins/auth-backend-module-gitlab-provider/config.d.ts +++ b/plugins/auth-backend-module-gitlab-provider/config.d.ts @@ -27,6 +27,7 @@ export interface Config { clientSecret: string; audience?: string; callbackUrl?: string; + additionalScopes?: string | string[]; }; }; }; diff --git a/plugins/auth-backend-module-gitlab-provider/src/authenticator.ts b/plugins/auth-backend-module-gitlab-provider/src/authenticator.ts index 9cd5857b0d..8e93921b63 100644 --- a/plugins/auth-backend-module-gitlab-provider/src/authenticator.ts +++ b/plugins/auth-backend-module-gitlab-provider/src/authenticator.ts @@ -26,6 +26,9 @@ import { export const gitlabAuthenticator = createOAuthAuthenticator({ defaultProfileTransform: PassportOAuthAuthenticatorHelper.defaultProfileTransform, + scopes: { + required: ['read_user'], + }, initialize({ callbackUrl, config }) { const clientId = config.getString('clientId'); const clientSecret = config.getString('clientSecret'); diff --git a/plugins/auth-backend-module-google-provider/config.d.ts b/plugins/auth-backend-module-google-provider/config.d.ts index 6a1716c5bf..08a0e08f2b 100644 --- a/plugins/auth-backend-module-google-provider/config.d.ts +++ b/plugins/auth-backend-module-google-provider/config.d.ts @@ -26,6 +26,7 @@ export interface Config { */ clientSecret: string; callbackUrl?: string; + additionalScopes?: string | string[]; }; }; }; diff --git a/plugins/auth-backend-module-google-provider/src/authenticator.ts b/plugins/auth-backend-module-google-provider/src/authenticator.ts index a428519fee..9f1e298ae8 100644 --- a/plugins/auth-backend-module-google-provider/src/authenticator.ts +++ b/plugins/auth-backend-module-google-provider/src/authenticator.ts @@ -27,6 +27,13 @@ import { Strategy as GoogleStrategy } from 'passport-google-oauth20'; export const googleAuthenticator = createOAuthAuthenticator({ defaultProfileTransform: PassportOAuthAuthenticatorHelper.defaultProfileTransform, + scopes: { + required: [ + 'openid', + `https://www.googleapis.com/auth/userinfo.email`, + `https://www.googleapis.com/auth/userinfo.profile`, + ], + }, initialize({ callbackUrl, config }) { const clientId = config.getString('clientId'); const clientSecret = config.getString('clientSecret'); diff --git a/plugins/auth-backend-module-google-provider/src/module.test.ts b/plugins/auth-backend-module-google-provider/src/module.test.ts index 59066acd4c..8ece0c1942 100644 --- a/plugins/auth-backend-module-google-provider/src/module.test.ts +++ b/plugins/auth-backend-module-google-provider/src/module.test.ts @@ -69,6 +69,8 @@ describe('authModuleGoogleProvider', () => { client_id: 'my-client-id', redirect_uri: `http://localhost:${server.port()}/api/auth/google/handler/frame`, state: expect.any(String), + scope: + 'openid https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile', }); expect(decodeOAuthState(startUrl.searchParams.get('state')!)).toEqual({ diff --git a/plugins/auth-backend-module-microsoft-provider/config.d.ts b/plugins/auth-backend-module-microsoft-provider/config.d.ts index df63e9ccf4..a7ec027083 100644 --- a/plugins/auth-backend-module-microsoft-provider/config.d.ts +++ b/plugins/auth-backend-module-microsoft-provider/config.d.ts @@ -28,7 +28,7 @@ export interface Config { clientSecret: string; domainHint?: string; callbackUrl?: string; - additionalScopes?: string[]; + additionalScopes?: string | string[]; }; }; }; diff --git a/plugins/auth-backend-module-microsoft-provider/src/module.test.ts b/plugins/auth-backend-module-microsoft-provider/src/module.test.ts index 2ef92c0b44..b50956b9eb 100644 --- a/plugins/auth-backend-module-microsoft-provider/src/module.test.ts +++ b/plugins/auth-backend-module-microsoft-provider/src/module.test.ts @@ -67,7 +67,7 @@ describe('authModuleMicrosoftProvider', () => { expect(startUrl.pathname).toBe('/my-tenant-id/oauth2/v2.0/authorize'); expect(Object.fromEntries(startUrl.searchParams)).toEqual({ response_type: 'code', - scope: 'user.read User.Read.All', + scope: 'User.Read.All', client_id: 'my-client-id', redirect_uri: `http://localhost:${server.port()}/api/auth/microsoft/handler/frame`, state: expect.any(String), diff --git a/plugins/auth-backend-module-oauth2-provider/config.d.ts b/plugins/auth-backend-module-oauth2-provider/config.d.ts index afbfcf8f67..9b1ee18f1b 100644 --- a/plugins/auth-backend-module-oauth2-provider/config.d.ts +++ b/plugins/auth-backend-module-oauth2-provider/config.d.ts @@ -27,7 +27,9 @@ export interface Config { clientSecret: string; authorizationUrl: string; tokenUrl: string; + /** @deprecated use `additionalScopes` instead */ scope?: string; + additionalScopes?: string | string[]; disableRefresh?: boolean; includeBasicAuth?: boolean; }; diff --git a/plugins/auth-backend-module-oauth2-provider/src/authenticator.ts b/plugins/auth-backend-module-oauth2-provider/src/authenticator.ts index d6f84cf1c0..698d9e51cb 100644 --- a/plugins/auth-backend-module-oauth2-provider/src/authenticator.ts +++ b/plugins/auth-backend-module-oauth2-provider/src/authenticator.ts @@ -31,9 +31,14 @@ export const oauth2Authenticator = createOAuthAuthenticator({ const clientSecret = config.getString('clientSecret'); const authorizationUrl = config.getString('authorizationUrl'); const tokenUrl = config.getString('tokenUrl'); - const scope = config.getOptionalString('scope'); const includeBasicAuth = config.getOptionalBoolean('includeBasicAuth'); + if (config.has('scope')) { + throw new Error( + 'The oauth2 provider no longer supports the "scope" configuration option. Please use the "additionalScopes" option instead.', + ); + } + return PassportOAuthAuthenticatorHelper.from( new Oauth2Strategy( { @@ -43,7 +48,6 @@ export const oauth2Authenticator = createOAuthAuthenticator({ authorizationURL: authorizationUrl, tokenURL: tokenUrl, passReqToCallback: false, - scope: scope, customHeaders: includeBasicAuth ? { Authorization: `Basic ${encodeClientCredentials( diff --git a/plugins/auth-backend-module-oidc-provider/api-report.md b/plugins/auth-backend-module-oidc-provider/api-report.md index d96d9952a0..eb9b4fb21e 100644 --- a/plugins/auth-backend-module-oidc-provider/api-report.md +++ b/plugins/auth-backend-module-oidc-provider/api-report.md @@ -19,7 +19,6 @@ export default authModuleOidcProvider; // @public (undocumented) export const oidcAuthenticator: OAuthAuthenticator< { - initializedScope: string | undefined; initializedPrompt: string | undefined; promise: Promise<{ helper: PassportOAuthAuthenticatorHelper; diff --git a/plugins/auth-backend-module-oidc-provider/config.d.ts b/plugins/auth-backend-module-oidc-provider/config.d.ts index 16131c0761..398e63c466 100644 --- a/plugins/auth-backend-module-oidc-provider/config.d.ts +++ b/plugins/auth-backend-module-oidc-provider/config.d.ts @@ -29,7 +29,7 @@ export interface Config { callbackUrl?: string; tokenEndpointAuthMethod?: string; tokenSignedResponseAlg?: string; - scope?: string; + additionalScopes?: string | string[]; prompt?: string; }; }; diff --git a/plugins/auth-backend-module-oidc-provider/src/authenticator.test.ts b/plugins/auth-backend-module-oidc-provider/src/authenticator.test.ts index 67bfd27b19..4203a70153 100644 --- a/plugins/auth-backend-module-oidc-provider/src/authenticator.test.ts +++ b/plugins/auth-backend-module-oidc-provider/src/authenticator.test.ts @@ -254,19 +254,6 @@ describe('oidcAuthenticator', () => { expect(fakeSession['oidc:oidc.test'].code_verifier).toBeDefined(); }); - it('requests default scopes if none are provided in config', async () => { - const startResponse = await oidcAuthenticator.start( - startRequest, - implementation, - ); - const { searchParams } = new URL(startResponse.url); - const scopes = searchParams.get('scope')?.split(' ') ?? []; - - expect(scopes).toEqual( - expect.arrayContaining(['openid', 'profile', 'email']), - ); - }); - it('encodes OAuth state in query param', async () => { const startResponse = await oidcAuthenticator.start( startRequest, diff --git a/plugins/auth-backend-module-oidc-provider/src/authenticator.ts b/plugins/auth-backend-module-oidc-provider/src/authenticator.ts index 2b6a9b34f1..efcf1b61dd 100644 --- a/plugins/auth-backend-module-oidc-provider/src/authenticator.ts +++ b/plugins/auth-backend-module-oidc-provider/src/authenticator.ts @@ -53,7 +53,6 @@ export type OidcAuthResult = { /** @public */ export const oidcAuthenticator = createOAuthAuthenticator({ - shouldPersistScopes: true, defaultProfileTransform: async ( input: OAuthAuthenticatorResult, ) => ({ @@ -63,6 +62,10 @@ export const oidcAuthenticator = createOAuthAuthenticator({ displayName: input.fullProfile.userinfo.name, }, }), + scopes: { + persist: true, + required: ['openid', 'profile', 'email'], + }, initialize({ callbackUrl, config }) { const clientId = config.getString('clientId'); const clientSecret = config.getString('clientSecret'); @@ -74,9 +77,14 @@ export const oidcAuthenticator = createOAuthAuthenticator({ const tokenSignedResponseAlg = config.getOptionalString( 'tokenSignedResponseAlg', ); - const initializedScope = config.getOptionalString('scope'); const initializedPrompt = config.getOptionalString('prompt'); + if (config.has('scope')) { + throw new Error( + 'The oidc provider no longer supports the "scope" configuration option. Please use the "additionalScopes" option instead.', + ); + } + Issuer[custom.http_options] = httpOptionsProvider; const promise = Issuer.discover(metadataUrl).then(issuer => { issuer[custom.http_options] = httpOptionsProvider; @@ -91,7 +99,6 @@ export const oidcAuthenticator = createOAuthAuthenticator({ token_endpoint_auth_method: tokenEndpointAuthMethod || 'client_secret_basic', id_token_signed_response_alg: tokenSignedResponseAlg || 'RS256', - scope: initializedScope || '', }); client[custom.http_options] = httpOptionsProvider; @@ -123,14 +130,14 @@ export const oidcAuthenticator = createOAuthAuthenticator({ return { helper, client, strategy }; }); - return { initializedScope, initializedPrompt, promise }; + return { initializedPrompt, promise }; }, async start(input, ctx) { - const { initializedScope, initializedPrompt, promise } = ctx; + const { initializedPrompt, promise } = ctx; const { helper, strategy } = await promise; const options: Record = { - scope: input.scope || initializedScope || 'openid profile email', + scope: input.scope, state: input.state, nonce: crypto.randomBytes(16).toString('base64'), }; diff --git a/plugins/auth-backend-module-oidc-provider/src/module.test.ts b/plugins/auth-backend-module-oidc-provider/src/module.test.ts index 0edb43f9f3..1359634950 100644 --- a/plugins/auth-backend-module-oidc-provider/src/module.test.ts +++ b/plugins/auth-backend-module-oidc-provider/src/module.test.ts @@ -212,6 +212,7 @@ describe('authModuleOidcProvider', () => { expect(decodeOAuthState(startUrl.searchParams.get('state')!)).toEqual({ env: 'development', nonce: decodeURIComponent(nonceCookie.value), + scope: 'openid profile email', }); }); diff --git a/plugins/auth-backend-module-okta-provider/config.d.ts b/plugins/auth-backend-module-okta-provider/config.d.ts index 42688528ee..aaf6ec1f91 100644 --- a/plugins/auth-backend-module-okta-provider/config.d.ts +++ b/plugins/auth-backend-module-okta-provider/config.d.ts @@ -29,7 +29,7 @@ export interface Config { authServerId?: string; idp?: string; callbackUrl?: string; - additionalScopes?: string; + additionalScopes?: string | string[]; }; }; }; diff --git a/plugins/auth-backend-module-okta-provider/src/authenticator.ts b/plugins/auth-backend-module-okta-provider/src/authenticator.ts index 42201232f1..1454d580cb 100644 --- a/plugins/auth-backend-module-okta-provider/src/authenticator.ts +++ b/plugins/auth-backend-module-okta-provider/src/authenticator.ts @@ -26,25 +26,15 @@ import { export const oktaAuthenticator = createOAuthAuthenticator({ defaultProfileTransform: PassportOAuthAuthenticatorHelper.defaultProfileTransform, + scopes: { + required: ['openid', 'email', 'profile', 'offline_access'], + }, initialize({ callbackUrl, config }) { const clientId = config.getString('clientId'); const clientSecret = config.getString('clientSecret'); const audience = config.getOptionalString('audience') || 'https://okta.com'; const authServerId = config.getOptionalString('authServerId'); const idp = config.getOptionalString('idp'); - // default scopes are taken from - // https://developer.okta.com/docs/reference/api/oidc/#response-example-success-refresh-token - const defaultScopes = 'openid profile email'; - // additional scopes can be configured in the config as a space separated string - const additionalScopes = config.getOptionalString('additionalScopes') || ''; - // combine default and additional scopes and remove duplicates - const combineScopeStrings = (scopesA: string, scopesB: string) => { - const scopesAArray = scopesA.split(' '); - const scopesBArray = scopesB.split(' '); - const combinedScopes = new Set([...scopesAArray, ...scopesBArray]); - return Array.from(combinedScopes).join(' '); - }; - const scope = combineScopeStrings(defaultScopes, additionalScopes); return PassportOAuthAuthenticatorHelper.from( new OktaStrategy( @@ -57,7 +47,6 @@ export const oktaAuthenticator = createOAuthAuthenticator({ idp: idp, passReqToCallback: false, response_type: 'code', - scope, }, ( accessToken: string, diff --git a/plugins/auth-backend-module-okta-provider/src/module.test.ts b/plugins/auth-backend-module-okta-provider/src/module.test.ts index e469067b3f..16a58b8713 100644 --- a/plugins/auth-backend-module-okta-provider/src/module.test.ts +++ b/plugins/auth-backend-module-okta-provider/src/module.test.ts @@ -21,9 +21,6 @@ import { decodeOAuthState } from '@backstage/plugin-auth-node'; describe('authModuleOktaProvider', () => { it('should start', async () => { - const defaultScopes = 'openid profile email'; - const additionalScopes = 'groups phone'; - const combinedScopes = `${defaultScopes} ${additionalScopes}`; const { server } = await startTestBackend({ features: [ import('@backstage/plugin-auth-backend'), @@ -39,7 +36,7 @@ describe('authModuleOktaProvider', () => { development: { clientId: 'my-client-id', clientSecret: 'my-client-secret', - additionalScopes, + additionalScopes: 'groups phone', }, }, }, @@ -68,7 +65,7 @@ describe('authModuleOktaProvider', () => { expect(startUrl.pathname).toBe('/oauth2/v1/authorize'); expect(Object.fromEntries(startUrl.searchParams)).toEqual({ response_type: 'code', - scope: combinedScopes, + scope: 'openid email profile offline_access groups phone', client_id: 'my-client-id', redirect_uri: `http://localhost:${server.port()}/api/auth/okta/handler/frame`, state: expect.any(String), diff --git a/plugins/auth-backend-module-pinniped-provider/src/authenticator.test.ts b/plugins/auth-backend-module-pinniped-provider/src/authenticator.test.ts index 2ecd54e80a..e2dedf2129 100644 --- a/plugins/auth-backend-module-pinniped-provider/src/authenticator.test.ts +++ b/plugins/auth-backend-module-pinniped-provider/src/authenticator.test.ts @@ -249,22 +249,14 @@ describe('pinnipedAuthenticator', () => { expect(fakeSession['oidc:pinniped.test'].code_verifier).toBeDefined(); }); - it('requests sufficient scopes for token exchange by default', async () => { + it('forwards scopes for token exchange', async () => { const startResponse = await pinnipedAuthenticator.start( - startRequest, + { ...startRequest, scope: 'openid username' }, authCtx, ); const { searchParams } = new URL(startResponse.url); - const scopes = searchParams.get('scope')?.split(' ') ?? []; - expect(scopes).toEqual( - expect.arrayContaining([ - 'openid', - 'pinniped:request-audience', - 'username', - 'offline_access', - ]), - ); + expect(searchParams.get('scope')).toBe('openid username'); }); it('encodes OAuth state in query param', async () => { diff --git a/plugins/auth-backend-module-pinniped-provider/src/authenticator.ts b/plugins/auth-backend-module-pinniped-provider/src/authenticator.ts index e07eaba759..abacb14ccc 100644 --- a/plugins/auth-backend-module-pinniped-provider/src/authenticator.ts +++ b/plugins/auth-backend-module-pinniped-provider/src/authenticator.ts @@ -127,7 +127,6 @@ export class PinnipedStrategyCache { client_secret: this.config.getString('clientSecret'), redirect_uris: [this.callbackUrl], response_types: ['code'], - scope: this.config.getOptionalString('scope') || '', id_token_signed_response_alg: 'ES256', }); const providerStrategy = new OidcStrategy( @@ -154,7 +153,20 @@ export class PinnipedStrategyCache { /** @public */ export const pinnipedAuthenticator = createOAuthAuthenticator({ defaultProfileTransform: async (_r, _c) => ({ profile: {} }), + scopes: { + required: [ + 'openid', + 'pinniped:request-audience', + 'username', + 'offline_access', + ], + }, initialize({ callbackUrl, config }) { + if (config.has('scope')) { + throw new Error( + 'The pinniped provider no longer supports the "scope" configuration option. Please use the "additionalScopes" option instead.', + ); + } return new PinnipedStrategyCache(callbackUrl, config); }, async start(input, ctx): Promise<{ url: string; status?: number }> { @@ -163,9 +175,7 @@ export const pinnipedAuthenticator = createOAuthAuthenticator({ const decodedState = decodeOAuthState(input.state); const state = { ...decodedState, audience: stringifiedAudience }; const options: Record = { - scope: - input.scope || - 'openid pinniped:request-audience username offline_access', + scope: input.scope, state: encodeOAuthState(state), }; diff --git a/plugins/auth-backend-module-vmware-cloud-provider/config.d.ts b/plugins/auth-backend-module-vmware-cloud-provider/config.d.ts index 10b3566899..6ea07f1936 100644 --- a/plugins/auth-backend-module-vmware-cloud-provider/config.d.ts +++ b/plugins/auth-backend-module-vmware-cloud-provider/config.d.ts @@ -24,6 +24,7 @@ export interface Config { organizationId: string; scope?: string; consoleEndpoint?: string; + additionalScopes?: string | string[]; }; }; }; diff --git a/plugins/auth-backend-module-vmware-cloud-provider/src/authenticator.test.ts b/plugins/auth-backend-module-vmware-cloud-provider/src/authenticator.test.ts index f38ceeb462..c69d4e9ab1 100644 --- a/plugins/auth-backend-module-vmware-cloud-provider/src/authenticator.test.ts +++ b/plugins/auth-backend-module-vmware-cloud-provider/src/authenticator.test.ts @@ -163,9 +163,9 @@ describe('vmwareCloudAuthenticator', () => { expect(searchParams.get('redirect_uri')).toBe('http://callbackUrl'); }); - it('requests scopes for ID and refresh token', async () => { + it('forwards scopes for ID and refresh token', async () => { const startResponse = await vmwareCloudAuthenticator.start( - startRequest, + { ...startRequest, scope: 'openid offline_access' }, authenticatorCtx, ); const { searchParams } = new URL(startResponse.url); diff --git a/plugins/auth-backend-module-vmware-cloud-provider/src/authenticator.ts b/plugins/auth-backend-module-vmware-cloud-provider/src/authenticator.ts index 4db4836f13..55b3b5daec 100644 --- a/plugins/auth-backend-module-vmware-cloud-provider/src/authenticator.ts +++ b/plugins/auth-backend-module-vmware-cloud-provider/src/authenticator.ts @@ -96,6 +96,9 @@ export const vmwareCloudAuthenticator = createOAuthAuthenticator< }, }; }, + scopes: { + required: ['openid', 'offline_access'], + }, initialize({ callbackUrl, config }) { const consoleEndpoint = config.getOptionalString('consoleEndpoint') ?? @@ -106,7 +109,12 @@ export const vmwareCloudAuthenticator = createOAuthAuthenticator< const clientSecret = ''; const authorizationUrl = `${consoleEndpoint}/csp/gateway/discovery`; const tokenUrl = `${consoleEndpoint}/csp/gateway/am/api/auth/token`; - const scope = config.getOptionalString('scope') ?? 'openid offline_access'; + + if (config.has('scope')) { + throw new Error( + 'The vmware-cloud provider no longer supports the "scope" configuration option. Please use the "additionalScopes" option instead.', + ); + } const providerStrategy = new OAuth2Strategy( { @@ -118,7 +126,6 @@ export const vmwareCloudAuthenticator = createOAuthAuthenticator< passReqToCallback: false, pkce: true, state: true, - scope: scope, customHeaders: { Authorization: `Basic ${encodeClientCredentials( clientId, diff --git a/plugins/auth-node/api-report.md b/plugins/auth-node/api-report.md index 6a43d3b127..dc1d86308a 100644 --- a/plugins/auth-node/api-report.md +++ b/plugins/auth-node/api-report.md @@ -173,6 +173,7 @@ export function createOAuthAuthenticator( // @public (undocumented) export function createOAuthProviderFactory(options: { authenticator: OAuthAuthenticator; + additionalScopes?: string[]; stateTransform?: OAuthStateTransform; profileTransform?: ProfileTransform>; signInResolver?: SignInResolver>; @@ -291,6 +292,8 @@ export interface OAuthAuthenticator { ctx: TContext, ): Promise>; // (undocumented) + scopes?: OAuthAuthenticatorScopeOptions; + // @deprecated (undocumented) shouldPersistScopes?: boolean; // (undocumented) start( @@ -336,6 +339,21 @@ export interface OAuthAuthenticatorResult { session: OAuthSession; } +// @public (undocumented) +export interface OAuthAuthenticatorScopeOptions { + // (undocumented) + persist?: boolean; + // (undocumented) + required?: string[]; + // (undocumented) + transform?: (options: { + requested: Iterable; + granted: Iterable; + required: Iterable; + additional: Iterable; + }) => Iterable; +} + // @public (undocumented) export interface OAuthAuthenticatorStartInput { // (undocumented) @@ -366,6 +384,8 @@ export class OAuthEnvironmentHandler implements AuthProviderRouteHandlers { // @public (undocumented) export interface OAuthRouteHandlersOptions { + // (undocumented) + additionalScopes?: string[]; // (undocumented) appUrl: string; // (undocumented) diff --git a/plugins/auth-node/src/oauth/CookieScopeManager.test.ts b/plugins/auth-node/src/oauth/CookieScopeManager.test.ts new file mode 100644 index 0000000000..6cdee1168f --- /dev/null +++ b/plugins/auth-node/src/oauth/CookieScopeManager.test.ts @@ -0,0 +1,266 @@ +/* + * Copyright 2024 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 express from 'express'; +import { + OAuthAuthenticator, + OAuthAuthenticatorResult, + OAuthAuthenticatorScopeOptions, +} from './types'; +import { OAuthCookieManager } from './OAuthCookieManager'; +import { OAuthState } from './state'; +import { CookieScopeManager } from './CookieScopeManager'; +import { ConfigReader } from '@backstage/config'; + +function makeReq(scope?: string): express.Request { + return { + query: { scope }, + res: {}, + get: _name => 'https://example.com', + } as express.Request; +} + +const baseOpts = { + authenticator: {} as OAuthAuthenticator, + cookieManager: {} as OAuthCookieManager, +}; + +describe('CookieScopeManager', () => { + it('should work with minimal config', async () => { + const manager = CookieScopeManager.create({ + authenticator: {} as OAuthAuthenticator, + cookieManager: {} as OAuthCookieManager, + }); + + await expect(manager.start(makeReq())).resolves.toEqual({ + scope: '', + }); + await expect(manager.start(makeReq('x'))).resolves.toEqual({ + scope: 'x', + }); + await expect(manager.start(makeReq('x,y'))).resolves.toEqual({ + scope: 'x y', + }); + + await expect( + manager.handleCallback(makeReq(), { + result: { session: { scope: 'x,y' } } as OAuthAuthenticatorResult, + state: {} as OAuthState, + }), + ).resolves.toEqual('x y'); + + await expect(manager.clear(makeReq())).resolves.toBe(undefined); + + const refresh = await manager.refresh(makeReq('x,y')); + expect(refresh.scope).toBe('x y'); + + await expect( + refresh.commit({ + session: { scope: 'y,z' }, + } as OAuthAuthenticatorResult), + ).resolves.toEqual('y z'); + }); + + it('should include additional scopes', async () => { + const manager = CookieScopeManager.create({ + additionalScopes: ['a', 'b'], + ...baseOpts, + }); + + await expect(manager.start(makeReq())).resolves.toEqual({ + scope: 'a b', + }); + await expect(manager.start(makeReq('x'))).resolves.toEqual({ + scope: 'x a b', + }); + await expect(manager.start(makeReq('x,y'))).resolves.toEqual({ + scope: 'x y a b', + }); + + const refresh = await manager.refresh(makeReq('x|y')); + expect(refresh.scope).toBe('x y a b'); + + await expect( + refresh.commit({ + session: { scope: 'y,z a' }, + } as OAuthAuthenticatorResult), + ).resolves.toEqual('y z a'); + }); + + it('should include additional scopes from config', async () => { + await expect( + CookieScopeManager.create({ + additionalScopes: ['a'], + ...baseOpts, + }).start(makeReq()), + ).resolves.toEqual({ scope: 'a' }); + + await expect( + CookieScopeManager.create({ + config: new ConfigReader({ additionalScopes: 'a,b' }), + additionalScopes: ['c'], + ...baseOpts, + }).start(makeReq()), + ).resolves.toEqual({ scope: 'a b c' }); + + await expect( + CookieScopeManager.create({ + config: new ConfigReader({ additionalScopes: ['a', 'b'] }), + additionalScopes: ['c'], + ...baseOpts, + }).start(makeReq()), + ).resolves.toEqual({ scope: 'a b c' }); + }); + + it('should persist scopes', async () => { + const setGrantedScopes = jest.fn(); + const removeGrantedScopes = jest.fn(); + const manager = CookieScopeManager.create({ + authenticator: { + scopes: { + persist: true, + } as OAuthAuthenticatorScopeOptions, + } as OAuthAuthenticator, + cookieManager: { + getGrantedScopes: () => 'g', + setGrantedScopes, + removeGrantedScopes, + } as unknown as OAuthCookieManager, + }); + + await expect(manager.start(makeReq())).resolves.toEqual({ + scope: 'g', + scopeState: { + scope: 'g', + }, + }); + await expect(manager.start(makeReq('x'))).resolves.toEqual({ + scope: 'x g', + scopeState: { + scope: 'x g', + }, + }); + + expect(setGrantedScopes).not.toHaveBeenCalled(); + await expect( + manager.handleCallback(makeReq(), { + // The state is prioritized even if scope is present in the result + result: { session: { scope: 'x,y' } } as OAuthAuthenticatorResult, + state: { scope: 'x g' } as OAuthState, + origin: 'https://other.example.com', + }), + ).resolves.toEqual('x g'); + expect(setGrantedScopes).toHaveBeenCalledWith( + expect.anything(), + 'x g', + 'https://other.example.com', + ); + setGrantedScopes.mockClear(); + + expect(removeGrantedScopes).not.toHaveBeenCalled(); + await expect(manager.clear(makeReq())).resolves.toBe(undefined); + expect(removeGrantedScopes).toHaveBeenCalledWith( + expect.anything(), + 'https://example.com', + ); + + const refresh = await manager.refresh(makeReq('x,y')); + expect(refresh.scope).toBe('x y g'); + + expect(setGrantedScopes).not.toHaveBeenCalled(); + await expect( + refresh.commit({ + session: { scope: 'y,z a' }, + } as OAuthAuthenticatorResult), + ).resolves.toEqual('x y g'); + expect(setGrantedScopes).toHaveBeenCalledWith( + expect.anything(), + 'x y g', + 'https://example.com', + ); + }); + + it('should use custom scope transform', async () => { + const manager = CookieScopeManager.create({ + additionalScopes: ['b'], + authenticator: { + scopes: { + persist: true, + required: ['a'], + transform: ({ required, additional, requested, granted }) => + new Set([ + ...[...requested].map(s => `requested-${s}`), + ...[...granted].map(s => `granted-${s}`), + ...[...required].map(s => `required-${s}`), + ...[...additional].map(s => `additional-${s}`), + ]), + } as OAuthAuthenticatorScopeOptions, + } as OAuthAuthenticator, + cookieManager: { + getGrantedScopes: _req => 'g', + setGrantedScopes: (_req, _scope, _origin) => {}, + } as OAuthCookieManager, + }); + + await expect(manager.start(makeReq())).resolves.toEqual({ + scope: 'granted-g required-a additional-b', + scopeState: { + scope: 'granted-g required-a additional-b', + }, + }); + await expect(manager.start(makeReq('x'))).resolves.toEqual({ + scope: 'requested-x granted-g required-a additional-b', + scopeState: { + scope: 'requested-x granted-g required-a additional-b', + }, + }); + + const refresh = await manager.refresh(makeReq('x,y')); + expect(refresh.scope).toBe( + 'requested-x requested-y granted-g required-a additional-b', + ); + + await expect( + refresh.commit({ + session: { scope: 'y,z a' }, + } as OAuthAuthenticatorResult), + ).resolves.toEqual( + 'requested-x requested-y granted-g required-a additional-b', + ); + }); + + it('should fail on invalid input', async () => { + const manager = CookieScopeManager.create({ + authenticator: { + scopes: { + persist: true, + } as OAuthAuthenticatorScopeOptions, + } as OAuthAuthenticator, + cookieManager: {} as OAuthCookieManager, + }); + + await expect( + manager.handleCallback(makeReq(), { + result: { session: { scope: 'x,y' } } as OAuthAuthenticatorResult, + state: {} as OAuthState, + }), + ).rejects.toThrow('No scope found in OAuth state'); + + await expect(manager.clear({} as express.Request)).rejects.toThrow( + 'No response object found in request', + ); + }); +}); diff --git a/plugins/auth-node/src/oauth/CookieScopeManager.ts b/plugins/auth-node/src/oauth/CookieScopeManager.ts new file mode 100644 index 0000000000..3cbf977e09 --- /dev/null +++ b/plugins/auth-node/src/oauth/CookieScopeManager.ts @@ -0,0 +1,164 @@ +/* + * Copyright 2024 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 express from 'express'; +import { + OAuthAuthenticator, + OAuthAuthenticatorResult, + OAuthAuthenticatorScopeOptions, +} from './types'; +import { OAuthCookieManager } from './OAuthCookieManager'; +import { OAuthState } from './state'; +import { AuthenticationError } from '@backstage/errors'; +import { Config } from '@backstage/config'; + +function reqRes(req: express.Request): express.Response { + const res = req.res; + if (!res) { + throw new Error('No response object found in request'); + } + return res; +} + +const defaultTransform: Required['transform'] = + ({ requested, granted, required, additional }) => { + return [...requested, ...granted, ...required, ...additional]; + }; + +function splitScope(scope?: string): Iterable { + if (!scope) { + return []; + } + + return scope.split(/[\s|,]/).filter(Boolean); +} + +export class CookieScopeManager { + static create(options: { + config?: Config; + additionalScopes?: string[]; + authenticator: OAuthAuthenticator; + cookieManager: OAuthCookieManager; + }) { + const { authenticator, config } = options; + + const shouldPersistScopes = + authenticator.scopes?.persist ?? + authenticator.shouldPersistScopes ?? + false; + + const configScopes = + typeof config?.getOptional('additionalScopes') === 'string' + ? splitScope(config.getString('additionalScopes')) + : config?.getOptionalStringArray('additionalScopes') ?? []; + + const transform = authenticator?.scopes?.transform ?? defaultTransform; + const additional = [...configScopes, ...(options.additionalScopes ?? [])]; + const required = authenticator?.scopes?.required ?? []; + + return new CookieScopeManager( + (requested, granted) => + Array.from( + new Set(transform({ required, additional, requested, granted })), + ).join(' '), + shouldPersistScopes ? options.cookieManager : undefined, + ); + } + + private constructor( + private readonly scopeTransform: ( + requested: Iterable, + granted: Iterable, + ) => string, + private readonly cookieManager?: OAuthCookieManager, + ) {} + + async start( + req: express.Request, + ): Promise<{ scopeState?: Partial; scope: string }> { + const requestScope = splitScope(req.query.scope?.toString()); + const grantedScope = splitScope(this.cookieManager?.getGrantedScopes(req)); + + const scope = this.scopeTransform(requestScope, grantedScope); + + if (this.cookieManager) { + // If scopes are persisted then we pass them through the state so that we + // can set the cookie on successful auth + return { + scope, + scopeState: { scope }, + }; + } + return { scope }; + } + + async handleCallback( + req: express.Request, + ctx: { + result: OAuthAuthenticatorResult; + state: OAuthState; + origin?: string; + }, + ): Promise { + // If we are not persisting scopes we can forward the scope from the result + if (!this.cookieManager) { + return Array.from(splitScope(ctx.result.session.scope)).join(' '); + } + + const scope = ctx.state.scope; + if (scope === undefined) { + throw new AuthenticationError('No scope found in OAuth state'); + } + + // Store the scope that we have been granted for this session. This is useful if + // the provider does not return granted scopes on refresh or if they are normalized. + this.cookieManager.setGrantedScopes(reqRes(req), scope, ctx.origin); + + return scope; + } + + async clear(req: express.Request): Promise { + if (this.cookieManager) { + this.cookieManager.removeGrantedScopes(reqRes(req), req.get('origin')); + } + } + + async refresh(req: express.Request): Promise<{ + scope: string; + commit(result: OAuthAuthenticatorResult): Promise; + }> { + const requestScope = splitScope(req.query.scope?.toString()); + const grantedScope = splitScope(this.cookieManager?.getGrantedScopes(req)); + + const scope = this.scopeTransform(requestScope, grantedScope); + + return { + scope, + commit: async result => { + if (this.cookieManager) { + this.cookieManager.setGrantedScopes( + reqRes(req), + scope, + req.get('origin'), + ); + return scope; + } + + return Array.from(splitScope(result.session.scope)).join(' '); + }, + }; + } +} diff --git a/plugins/auth-node/src/oauth/OAuthCookieManager.ts b/plugins/auth-node/src/oauth/OAuthCookieManager.ts index 5fe2304610..1d06242a2e 100644 --- a/plugins/auth-node/src/oauth/OAuthCookieManager.ts +++ b/plugins/auth-node/src/oauth/OAuthCookieManager.ts @@ -106,6 +106,13 @@ export class OAuthCookieManager { }); } + removeGrantedScopes(res: Response, origin?: string) { + res.cookie(this.grantedScopeCookie, '', { + maxAge: 0, + ...this.getConfig(origin), + }); + } + setGrantedScopes(res: Response, scope: string, origin?: string) { res.cookie(this.grantedScopeCookie, scope, { maxAge: THOUSAND_DAYS_MS, @@ -113,15 +120,15 @@ export class OAuthCookieManager { }); } - getNonce(req: Request) { + getNonce(req: Request): string | undefined { return req.cookies[this.nonceCookie]; } - getRefreshToken(req: Request) { + getRefreshToken(req: Request): string | undefined { return req.cookies[this.refreshTokenCookie]; } - getGrantedScopes(req: Request) { + getGrantedScopes(req: Request): string | undefined { return req.cookies[this.grantedScopeCookie]; } } diff --git a/plugins/auth-node/src/oauth/createOAuthProviderFactory.ts b/plugins/auth-node/src/oauth/createOAuthProviderFactory.ts index 87fde6a1a2..a3387b7038 100644 --- a/plugins/auth-node/src/oauth/createOAuthProviderFactory.ts +++ b/plugins/auth-node/src/oauth/createOAuthProviderFactory.ts @@ -29,6 +29,7 @@ import { SignInResolverFactory } from '../sign-in/createSignInResolverFactory'; /** @public */ export function createOAuthProviderFactory(options: { authenticator: OAuthAuthenticator; + additionalScopes?: string[]; stateTransform?: OAuthStateTransform; profileTransform?: ProfileTransform>; signInResolver?: SignInResolver>; @@ -57,6 +58,7 @@ export function createOAuthProviderFactory(options: { cookieConfigurer: ctx.cookieConfigurer, providerId: ctx.providerId, resolverContext: ctx.resolverContext, + additionalScopes: options.additionalScopes, stateTransform: options.stateTransform, profileTransform: options.profileTransform, signInResolver, diff --git a/plugins/auth-node/src/oauth/createOAuthRouteHandlers.ts b/plugins/auth-node/src/oauth/createOAuthRouteHandlers.ts index a0735e3ba0..82bfa2f02c 100644 --- a/plugins/auth-node/src/oauth/createOAuthRouteHandlers.ts +++ b/plugins/auth-node/src/oauth/createOAuthRouteHandlers.ts @@ -27,7 +27,6 @@ import { encodeOAuthState, decodeOAuthState, OAuthStateTransform, - OAuthState, } from './state'; import { sendWebMessageResponse } from '../flow'; import { prepareBackstageIdentityResponse } from '../identity'; @@ -42,6 +41,7 @@ import { } from '../types'; import { OAuthAuthenticator, OAuthAuthenticatorResult } from './types'; import { Config } from '@backstage/config'; +import { CookieScopeManager } from './CookieScopeManager'; /** @public */ export interface OAuthRouteHandlersOptions { @@ -52,6 +52,7 @@ export interface OAuthRouteHandlersOptions { providerId: string; config: Config; resolverContext: AuthResolverContext; + additionalScopes?: string[]; stateTransform?: OAuthStateTransform; profileTransform?: ProfileTransform>; cookieConfigurer?: CookieConfigurer; @@ -111,14 +112,19 @@ export function createOAuthRouteHandlers( cookieConfigurer, }); + const scopeManager = CookieScopeManager.create({ + config, + authenticator, + cookieManager, + additionalScopes: options.additionalScopes, + }); + return { async start( this: never, req: express.Request, res: express.Response, ): Promise { - // retrieve scopes from request - const scope = req.query.scope?.toString() ?? ''; const env = req.query.env?.toString(); const origin = req.query.origin?.toString(); const redirectUrl = req.query.redirectUrl?.toString(); @@ -132,19 +138,17 @@ export function createOAuthRouteHandlers( // set a nonce cookie before redirecting to oauth provider cookieManager.setNonce(res, nonce, origin); - const state: OAuthState = { nonce, env, origin, redirectUrl, flow }; - - // If scopes are persisted then we pass them through the state so that we - // can set the cookie on successful auth - if (authenticator.shouldPersistScopes && scope) { - state.scope = scope; - } + const { scope, scopeState } = await scopeManager.start(req); + const state = { nonce, env, origin, redirectUrl, flow, ...scopeState }; const { state: transformedState } = await stateTransform(state, { req }); - const encodedState = encodeOAuthState(transformedState); const { url, status } = await options.authenticator.start( - { req, scope, state: encodedState }, + { + req, + scope, + state: encodeOAuthState(transformedState), + }, authenticatorCtx, ); @@ -159,19 +163,19 @@ export function createOAuthRouteHandlers( req: express.Request, res: express.Response, ): Promise { - let appOrigin = defaultAppOrigin; + let origin = defaultAppOrigin; try { const state = decodeOAuthState(req.query.state?.toString() ?? ''); if (state.origin) { try { - appOrigin = new URL(state.origin).origin; + origin = new URL(state.origin).origin; } catch { throw new NotAllowedError('App origin is invalid, failed to parse'); } - if (!isOriginAllowed(appOrigin)) { - throw new NotAllowedError(`Origin '${appOrigin}' is not allowed`); + if (!isOriginAllowed(origin)) { + throw new NotAllowedError(`Origin '${origin}' is not allowed`); } } @@ -191,38 +195,35 @@ export function createOAuthRouteHandlers( ); const { profile } = await profileTransform(result, resolverContext); + const signInResult = + signInResolver && + (await signInResolver({ profile, result }, resolverContext)); + + const grantedScopes = await scopeManager.handleCallback(req, { + result, + state, + origin, + }); + const response: ClientOAuthResponse = { profile, providerInfo: { idToken: result.session.idToken, accessToken: result.session.accessToken, - scope: result.session.scope, + scope: grantedScopes, expiresInSeconds: result.session.expiresInSeconds, }, + ...(signInResult && { + backstageIdentity: prepareBackstageIdentityResponse(signInResult), + }), }; - if (signInResolver) { - const identity = await signInResolver( - { profile, result }, - resolverContext, - ); - response.backstageIdentity = - prepareBackstageIdentityResponse(identity); - } - - // Store the scope that we have been granted for this session. This is useful if - // the provider does not return granted scopes on refresh or if they are normalized. - if (authenticator.shouldPersistScopes && state.scope) { - cookieManager.setGrantedScopes(res, state.scope, appOrigin); - response.providerInfo.scope = state.scope; - } - if (result.session.refreshToken) { // set new refresh token cookieManager.setRefreshToken( res, result.session.refreshToken, - appOrigin, + origin, ); } @@ -239,7 +240,7 @@ export function createOAuthRouteHandlers( } // post message back to popup if successful - sendWebMessageResponse(res, appOrigin, { + sendWebMessageResponse(res, origin, { type: 'authorization_response', response, }); @@ -248,7 +249,7 @@ export function createOAuthRouteHandlers( ? error : new Error('Encountered invalid error'); // Being a bit safe and not forwarding the bad value // post error message back to popup if failure - sendWebMessageResponse(res, appOrigin, { + sendWebMessageResponse(res, origin, { type: 'authorization_response', error: { name, message }, }); @@ -273,6 +274,9 @@ export function createOAuthRouteHandlers( // remove refresh token cookie if it is set cookieManager.removeRefreshToken(res, req.get('origin')); + // remove persisted scopes + await scopeManager.clear(req); + res.status(200).end(); }, @@ -294,16 +298,15 @@ export function createOAuthRouteHandlers( throw new InputError('Missing session cookie'); } - let scope = req.query.scope?.toString() ?? ''; - if (authenticator.shouldPersistScopes) { - scope = cookieManager.getGrantedScopes(req); - } + const scopeRefresh = await scopeManager.refresh(req); const result = await authenticator.refresh( - { req, scope, refreshToken }, + { req, scope: scopeRefresh.scope, refreshToken }, authenticatorCtx, ); + const grantedScope = await scopeRefresh.commit(result); + const { profile } = await profileTransform(result, resolverContext); const newRefreshToken = result.session.refreshToken; @@ -320,9 +323,7 @@ export function createOAuthRouteHandlers( providerInfo: { idToken: result.session.idToken, accessToken: result.session.accessToken, - scope: authenticator.shouldPersistScopes - ? scope - : result.session.scope, + scope: grantedScope, expiresInSeconds: result.session.expiresInSeconds, }, }; diff --git a/plugins/auth-node/src/oauth/index.ts b/plugins/auth-node/src/oauth/index.ts index 25213dd2f0..9cff6c5358 100644 --- a/plugins/auth-node/src/oauth/index.ts +++ b/plugins/auth-node/src/oauth/index.ts @@ -39,6 +39,7 @@ export { type OAuthAuthenticatorLogoutInput, type OAuthAuthenticatorRefreshInput, type OAuthAuthenticatorResult, + type OAuthAuthenticatorScopeOptions, type OAuthAuthenticatorStartInput, type OAuthSession, } from './types'; diff --git a/plugins/auth-node/src/oauth/types.ts b/plugins/auth-node/src/oauth/types.ts index 4bd7bd3f3f..c4ae2ee1cf 100644 --- a/plugins/auth-node/src/oauth/types.ts +++ b/plugins/auth-node/src/oauth/types.ts @@ -29,6 +29,22 @@ export interface OAuthSession { refreshTokenExpiresInSeconds?: number; } +/** @public */ +export interface OAuthAuthenticatorScopeOptions { + persist?: boolean; + required?: string[]; + transform?: (options: { + /** Scopes requested by the client */ + requested: Iterable; + /** Scopes which have already been granted */ + granted: Iterable; + /** Scopes that are required for the authenticator to function */ + required: Iterable; + /** Additional scopes added through configuration */ + additional: Iterable; + }) => Iterable; +} + /** @public */ export interface OAuthAuthenticatorStartInput { scope: string; @@ -64,7 +80,9 @@ export interface OAuthAuthenticatorResult { /** @public */ export interface OAuthAuthenticator { defaultProfileTransform: ProfileTransform>; + /** @deprecated use `scopes.persist` instead */ shouldPersistScopes?: boolean; + scopes?: OAuthAuthenticatorScopeOptions; initialize(ctx: { callbackUrl: string; config: Config }): TContext; start( input: OAuthAuthenticatorStartInput,