diff --git a/.changeset/sharp-wombats-speak.md b/.changeset/sharp-wombats-speak.md new file mode 100644 index 0000000000..8a6dd39354 --- /dev/null +++ b/.changeset/sharp-wombats-speak.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend': patch +--- + +Added an optional `additionalScopes` configuration parameter to `okta` providers, that lets you add additional scopes on top of the default ones. diff --git a/docs/auth/okta/provider.md b/docs/auth/okta/provider.md index e4fc8bfb45..99ff664a67 100644 --- a/docs/auth/okta/provider.md +++ b/docs/auth/okta/provider.md @@ -43,6 +43,8 @@ auth: audience: ${AUTH_OKTA_DOMAIN} authServerId: ${AUTH_OKTA_AUTH_SERVER_ID} # Optional idp: ${AUTH_OKTA_IDP} # Optional + # https://developer.okta.com/docs/reference/api/oidc/#scope-dependent-claims-not-always-returned + additionalScopes: ${AUTH_OKTA_ADDITIONAL_SCOPES} # Optional ``` The values referenced are found on the Application page on your Okta site. @@ -55,6 +57,8 @@ The values referenced are found on the Application page on your Okta site. - `authServerId`: The authorization server ID for the Application - `idp`: The identity provider for the application, e.g. `0oaulob4BFVa4zQvt0g3` +`additionalScopes` is an optional value, a string of space separated scopes, that will be combined with the default `scope` value of `openid profile email offline_access` to adjust the `scope` sent to Okta during OAuth. This will have an impact on [the dependent claims returned](https://developer.okta.com/docs/reference/api/oidc/#scope-dependent-claims-not-always-returned). For example, setting the `additionalScopes` value to `groups` will result in the claim returning a list of the groups that the user is a member of that also match the ID token group filter of the client app. + ## Adding the provider to the Backstage frontend To add the provider to the frontend, add the `oktaAuthApi` reference and diff --git a/plugins/auth-backend/config.d.ts b/plugins/auth-backend/config.d.ts index 54fad12939..c13c46664c 100644 --- a/plugins/auth-backend/config.d.ts +++ b/plugins/auth-backend/config.d.ts @@ -146,6 +146,7 @@ export interface Config { authServerId?: string; idp?: string; callbackUrl?: string; + additionalScopes?: string; }; }; /** @visibility frontend */ diff --git a/plugins/auth-backend/src/providers/okta/provider.test.ts b/plugins/auth-backend/src/providers/okta/provider.test.ts index bc27129300..588b00be02 100644 --- a/plugins/auth-backend/src/providers/okta/provider.test.ts +++ b/plugins/auth-backend/src/providers/okta/provider.test.ts @@ -17,13 +17,14 @@ import { OktaAuthProvider } from './provider'; import * as helpers from '../../lib/passport/PassportStrategyHelper'; import { OAuthResult } from '../../lib/oauth'; -import { AuthResolverContext } from '../types'; +import { AuthResolverContext, OAuthStartResponse } from '../types'; jest.mock('../../lib/passport/PassportStrategyHelper', () => { return { executeFrameHandlerStrategy: jest.fn(), executeRefreshTokenStrategy: jest.fn(), executeFetchUserProfileStrategy: jest.fn(), + executeRedirectStrategy: jest.fn(), }; }); @@ -34,6 +35,11 @@ const mockFrameHandler = jest.spyOn( () => Promise<{ result: OAuthResult; privateInfo: any }> >; +const mockRedirectStrategy = jest.spyOn( + helpers, + 'executeRedirectStrategy', +) as unknown as jest.MockedFunction<() => Promise>; + describe('createOktaProvider', () => { it('should auth', async () => { const provider = new OktaAuthProvider({ @@ -97,4 +103,41 @@ describe('createOktaProvider', () => { }, }); }); + + it('should pass a custom scope to start and refresh requests', async () => { + const additionalScopes = 'groups'; + const reqScope = 'openid profile email offline_access'; + const combinedScope = `${reqScope} ${additionalScopes}`; + const provider = new OktaAuthProvider({ + resolverContext: {} as AuthResolverContext, + authHandler: async ({ fullProfile }) => ({ + profile: { + email: fullProfile.emails![0]!.value, + displayName: fullProfile.displayName, + }, + }), + audience: 'http://example.com', + clientId: 'mock', + clientSecret: 'mock', + callbackUrl: 'mock', + additionalScopes, + }); + + mockRedirectStrategy.mockResolvedValueOnce({ + url: 'http://example.com/', + }); + + const req: any = { + state: { + nonce: 'nonce', + env: 'development', + }, + scope: reqScope, + }; + + await provider.start(req); + const mockCallScope = (mockRedirectStrategy.mock.calls[0] as any)?.[2] + ?.scope; + expect(mockCallScope).toBe(combinedScope); + }); }); diff --git a/plugins/auth-backend/src/providers/okta/provider.ts b/plugins/auth-backend/src/providers/okta/provider.ts index 05e0451bdb..6b27fbb612 100644 --- a/plugins/auth-backend/src/providers/okta/provider.ts +++ b/plugins/auth-backend/src/providers/okta/provider.ts @@ -60,6 +60,7 @@ export type OktaAuthProviderOptions = OAuthProviderOptions & { signInResolver?: SignInResolver; authHandler: AuthHandler; resolverContext: AuthResolverContext; + additionalScopes?: string; }; export class OktaAuthProvider implements OAuthHandlers { @@ -67,6 +68,7 @@ export class OktaAuthProvider implements OAuthHandlers { private readonly signInResolver?: SignInResolver; private readonly authHandler: AuthHandler; private readonly resolverContext: AuthResolverContext; + private readonly additionalScopes: string; /** * Due to passport-okta-oauth forcing options.state = true, @@ -89,6 +91,7 @@ export class OktaAuthProvider implements OAuthHandlers { this.signInResolver = options.signInResolver; this.authHandler = options.authHandler; this.resolverContext = options.resolverContext; + this.additionalScopes = options.additionalScopes || ''; this.strategy = new OktaStrategy( { @@ -125,11 +128,19 @@ export class OktaAuthProvider implements OAuthHandlers { ); } + private combineScopeStrings(scopesA: string, scopesB: string) { + const scopesAArray = scopesA.split(' '); + const scopesBArray = scopesB.split(' '); + const combinedScopes = new Set([...scopesAArray, ...scopesBArray]); + return Array.from(combinedScopes).join(' '); + } + async start(req: OAuthStartRequest): Promise { + const scope = this.combineScopeStrings(req.scope, this.additionalScopes); return await executeRedirectStrategy(req, this.strategy, { accessType: 'offline', prompt: 'consent', - scope: req.scope, + scope: scope, state: encodeState(req.state), }); } @@ -147,12 +158,9 @@ export class OktaAuthProvider implements OAuthHandlers { } async refresh(req: OAuthRefreshRequest) { + const scope = this.combineScopeStrings(req.scope, this.additionalScopes); const { accessToken, refreshToken, params } = - await executeRefreshTokenStrategy( - this.strategy, - req.refreshToken, - req.scope, - ); + await executeRefreshTokenStrategy(this.strategy, req.refreshToken, scope); const fullProfile = await executeFetchUserProfileStrategy( this.strategy, @@ -230,6 +238,8 @@ export const okta = createAuthProviderIntegration({ const callbackUrl = customCallbackUrl || `${globalConfig.baseUrl}/${providerId}/handler/frame`; + const additionalScopes = + envConfig.getOptionalString('additionalScopes'); // This is a safe assumption as `passport-okta-oauth` uses the audience // as the base for building the authorization, token, and user info URLs. @@ -254,6 +264,7 @@ export const okta = createAuthProviderIntegration({ authHandler, signInResolver: options?.signIn?.resolver, resolverContext, + additionalScopes, }); return OAuthAdapter.fromConfig(globalConfig, provider, {