diff --git a/.changeset/tidy-jokes-train.md b/.changeset/tidy-jokes-train.md new file mode 100644 index 0000000000..b9d0d43bea --- /dev/null +++ b/.changeset/tidy-jokes-train.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend': patch +--- + +Add Sign In and Handler resolver for Okta provider diff --git a/plugins/auth-backend/src/providers/index.ts b/plugins/auth-backend/src/providers/index.ts index 6b262dcf68..4c093df8ce 100644 --- a/plugins/auth-backend/src/providers/index.ts +++ b/plugins/auth-backend/src/providers/index.ts @@ -16,6 +16,7 @@ export * from './google'; export * from './microsoft'; +export * from './okta'; export { factories as defaultAuthProviderFactories } from './factories'; // Export the minimal interface required for implementing a diff --git a/plugins/auth-backend/src/providers/okta/index.ts b/plugins/auth-backend/src/providers/okta/index.ts index 37abef181a..fd9ebf46da 100644 --- a/plugins/auth-backend/src/providers/okta/index.ts +++ b/plugins/auth-backend/src/providers/okta/index.ts @@ -14,5 +14,5 @@ * limitations under the License. */ -export { createOktaProvider } from './provider'; +export { createOktaProvider, oktaEmailSignInResolver } from './provider'; export type { OktaProviderOptions } from './provider'; diff --git a/plugins/auth-backend/src/providers/okta/provider.test.ts b/plugins/auth-backend/src/providers/okta/provider.test.ts new file mode 100644 index 0000000000..37d1713c93 --- /dev/null +++ b/plugins/auth-backend/src/providers/okta/provider.test.ts @@ -0,0 +1,104 @@ +/* + * 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 { OktaAuthProvider } from './provider'; +import * as helpers from '../../lib/passport/PassportStrategyHelper'; +import { OAuthResult } from '../../lib/oauth'; +import { getVoidLogger } from '@backstage/backend-common'; +import { TokenIssuer } from '../../identity/types'; +import { CatalogIdentityClient } from '../../lib/catalog'; + +const mockFrameHandler = (jest.spyOn( + helpers, + 'executeFrameHandlerStrategy', +) as unknown) as jest.MockedFunction< + () => Promise<{ result: OAuthResult; privateInfo: any }> +>; + +describe('createOktaProvider', () => { + it('should auth', async () => { + const tokenIssuer = { + issueToken: jest.fn(), + listPublicKeys: jest.fn(), + }; + const catalogIdentityClient = { + findUser: jest.fn(), + }; + + const provider = new OktaAuthProvider({ + logger: getVoidLogger(), + catalogIdentityClient: (catalogIdentityClient as unknown) as CatalogIdentityClient, + tokenIssuer: (tokenIssuer as unknown) as TokenIssuer, + authHandler: async ({ fullProfile }) => ({ + profile: { + email: fullProfile.emails![0]!.value, + displayName: fullProfile.displayName, + }, + }), + audience: 'http://example.com', + clientId: 'mock', + clientSecret: 'mock', + callbackUrl: 'mock', + }); + + mockFrameHandler.mockResolvedValueOnce({ + result: { + fullProfile: { + emails: [ + { + type: 'work', + value: 'conrad@example.com', + }, + ], + displayName: 'Conrad', + name: { + familyName: 'Ribas', + givenName: 'Francisco', + }, + id: 'conrad', + provider: 'okta', + photos: [ + { + value: 'some-data', + }, + ], + }, + params: { + id_token: 'idToken', + scope: 'scope', + expires_in: 123, + }, + accessToken: 'accessToken', + }, + privateInfo: { + refreshToken: 'wacka', + }, + }); + const { response } = await provider.handler({} as any); + expect(response).toEqual({ + providerInfo: { + accessToken: 'accessToken', + expiresInSeconds: 123, + idToken: 'idToken', + scope: 'scope', + }, + profile: { + email: 'conrad@example.com', + displayName: 'Conrad', + }, + }); + }); +}); diff --git a/plugins/auth-backend/src/providers/okta/provider.ts b/plugins/auth-backend/src/providers/okta/provider.ts index 9709b549d7..24ee14becc 100644 --- a/plugins/auth-backend/src/providers/okta/provider.ts +++ b/plugins/auth-backend/src/providers/okta/provider.ts @@ -35,8 +35,16 @@ import { executeFetchUserProfileStrategy, PassportDoneCallback, } from '../../lib/passport'; -import { RedirectInfo, AuthProviderFactory } from '../types'; +import { + AuthProviderFactory, + AuthHandler, + RedirectInfo, + SignInResolver, +} from '../types'; import { StateStore } from 'passport-oauth2'; +import { CatalogIdentityClient, getEntityClaims } from '../../lib/catalog'; +import { TokenIssuer } from '../../identity'; +import { Logger } from 'winston'; type PrivateInfo = { refreshToken: string; @@ -44,10 +52,20 @@ type PrivateInfo = { export type OktaAuthProviderOptions = OAuthProviderOptions & { audience: string; + signInResolver?: SignInResolver; + authHandler: AuthHandler; + tokenIssuer: TokenIssuer; + catalogIdentityClient: CatalogIdentityClient; + logger: Logger; }; export class OktaAuthProvider implements OAuthHandlers { private readonly _strategy: any; + private readonly _signInResolver?: SignInResolver; + private readonly _authHandler: AuthHandler; + private readonly _tokenIssuer: TokenIssuer; + private readonly _catalogIdentityClient: CatalogIdentityClient; + private readonly _logger: Logger; /** * Due to passport-okta-oauth forcing options.state = true, @@ -67,6 +85,12 @@ export class OktaAuthProvider implements OAuthHandlers { }; constructor(options: OktaAuthProviderOptions) { + this._signInResolver = options.signInResolver; + this._authHandler = options.authHandler; + this._tokenIssuer = options.tokenIssuer; + this._catalogIdentityClient = options.catalogIdentityClient; + this._logger = options.logger; + this._strategy = new OktaStrategy( { clientID: options.clientId, @@ -117,18 +141,8 @@ export class OktaAuthProvider implements OAuthHandlers { PrivateInfo >(req, this._strategy); - const profile = makeProfileInfo(result.fullProfile, result.params.id_token); - return { - response: await this.populateIdentity({ - profile, - providerInfo: { - idToken: result.params.id_token, - accessToken: result.accessToken, - scope: result.params.scope, - expiresInSeconds: result.params.expires_in, - }, - }), + response: await this.handleResult(result), refreshToken: privateInfo.refreshToken, }; } @@ -144,52 +158,157 @@ export class OktaAuthProvider implements OAuthHandlers { this._strategy, accessToken, ); - const profile = makeProfileInfo(fullProfile, params.id_token); - return this.populateIdentity({ - providerInfo: { - accessToken, - idToken: params.id_token, - expiresInSeconds: params.expires_in, - scope: params.scope, - }, - profile, + return this.handleResult({ + fullProfile, + params, + accessToken, + refreshToken: req.refreshToken, }); } - private async populateIdentity( - response: OAuthResponse, - ): Promise { - const { profile } = response; + private async handleResult(result: OAuthResult) { + const { profile } = await this._authHandler(result); - if (!profile.email) { - throw new Error('Okta profile contained no email'); + const response: OAuthResponse = { + providerInfo: { + idToken: result.params.id_token, + accessToken: result.accessToken, + scope: result.params.scope, + expiresInSeconds: result.params.expires_in, + }, + profile, + }; + + if (this._signInResolver) { + response.backstageIdentity = await this._signInResolver( + { + result, + profile, + }, + { + tokenIssuer: this._tokenIssuer, + catalogIdentityClient: this._catalogIdentityClient, + logger: this._logger, + }, + ); } - // TODO(Rugvip): Hardcoded to the local part of the email for now - const id = profile.email.split('@')[0]; - - return { ...response, backstageIdentity: { id } }; + return response; } } -export type OktaProviderOptions = {}; +export const oktaEmailSignInResolver: SignInResolver = async ( + info, + ctx, +) => { + const { profile } = info; + + if (!profile.email) { + throw new Error('Okta profile contained no email'); + } + + const entity = await ctx.catalogIdentityClient.findUser({ + annotations: { + 'okta.com/email': profile.email, + }, + }); + + const claims = getEntityClaims(entity); + const token = await ctx.tokenIssuer.issueToken({ claims }); + + return { id: entity.metadata.name, entity, token }; +}; + +export const oktaDefaultSignInResolver: SignInResolver = async ( + info, + ctx, +) => { + const { profile } = info; + + if (!profile.email) { + throw new Error('Okta profile contained no email'); + } + + // TODO(Rugvip): Hardcoded to the local part of the email for now + const userId = profile.email.split('@')[0]; + + const token = await ctx.tokenIssuer.issueToken({ + claims: { sub: userId, ent: [`user:default/${userId}`] }, + }); + + return { id: userId, token }; +}; + +export type OktaProviderOptions = { + /** + * The profile transformation function used to verify and convert the auth response + * into the profile that will be presented to the user. + */ + authHandler?: AuthHandler; + + /** + * Configure sign-in for this provider, without it the provider can not be used to sign users in. + */ + /** + * Maps an auth result to a Backstage identity for the user. + * + * Set to `'email'` to use the default email-based sign in resolver, which will search + * the catalog for a single user entity that has a matching `okta.com/email` annotation. + */ + signIn?: { + resolver?: SignInResolver; + }; +}; export const createOktaProvider = ( _options?: OktaProviderOptions, ): AuthProviderFactory => { - return ({ providerId, globalConfig, config, tokenIssuer }) => + return ({ + providerId, + globalConfig, + config, + tokenIssuer, + catalogApi, + logger, + }) => OAuthEnvironmentHandler.mapConfig(config, envConfig => { const clientId = envConfig.getString('clientId'); const clientSecret = envConfig.getString('clientSecret'); const audience = envConfig.getString('audience'); const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`; + const catalogIdentityClient = new CatalogIdentityClient({ + catalogApi, + tokenIssuer, + }); + + const authHandler: AuthHandler = _options?.authHandler + ? _options.authHandler + : async ({ fullProfile, params }) => ({ + profile: makeProfileInfo(fullProfile, params.id_token), + }); + + const signInResolverFn = + _options?.signIn?.resolver ?? oktaDefaultSignInResolver; + + const signInResolver: SignInResolver = info => + signInResolverFn(info, { + catalogIdentityClient, + tokenIssuer, + logger, + }); + const provider = new OktaAuthProvider({ audience, clientId, clientSecret, callbackUrl, + authHandler, + signInResolver, + tokenIssuer, + catalogIdentityClient, + logger, }); return OAuthAdapter.fromConfig(globalConfig, provider, {