From f7e10a751019d72789063297ab2720a9aac05e91 Mon Sep 17 00:00:00 2001 From: Josh Uvi Date: Wed, 15 Nov 2023 15:20:16 +0000 Subject: [PATCH] Initial commit Signed-off-by: Josh Uvi --- .../.eslintrc.js | 1 + .../README.md | 30 ++ .../api-report.md | 30 ++ .../catalog-info.yaml | 10 + .../config.d.ts | 36 +++ .../dev/index.ts | 24 ++ .../package.json | 45 +++ .../src/authenticator.ts | 93 +++++++ .../src/index.ts | 25 ++ .../src/module.test.ts | 82 ++++++ .../src/module.ts | 48 ++++ .../src/resolvers.ts | 54 ++++ .../src/types.d.ts | 25 ++ plugins/auth-backend/config.d.ts | 15 - plugins/auth-backend/package.json | 2 +- .../src/providers/okta/provider.test.ts | 143 ---------- .../src/providers/okta/provider.ts | 260 +----------------- yarn.lock | 20 +- 18 files changed, 536 insertions(+), 407 deletions(-) create mode 100644 plugins/auth-backend-module-okta-provider/.eslintrc.js create mode 100644 plugins/auth-backend-module-okta-provider/README.md create mode 100644 plugins/auth-backend-module-okta-provider/api-report.md create mode 100644 plugins/auth-backend-module-okta-provider/catalog-info.yaml create mode 100644 plugins/auth-backend-module-okta-provider/config.d.ts create mode 100644 plugins/auth-backend-module-okta-provider/dev/index.ts create mode 100644 plugins/auth-backend-module-okta-provider/package.json create mode 100644 plugins/auth-backend-module-okta-provider/src/authenticator.ts create mode 100644 plugins/auth-backend-module-okta-provider/src/index.ts create mode 100644 plugins/auth-backend-module-okta-provider/src/module.test.ts create mode 100644 plugins/auth-backend-module-okta-provider/src/module.ts create mode 100644 plugins/auth-backend-module-okta-provider/src/resolvers.ts create mode 100644 plugins/auth-backend-module-okta-provider/src/types.d.ts delete mode 100644 plugins/auth-backend/src/providers/okta/provider.test.ts diff --git a/plugins/auth-backend-module-okta-provider/.eslintrc.js b/plugins/auth-backend-module-okta-provider/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/auth-backend-module-okta-provider/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/auth-backend-module-okta-provider/README.md b/plugins/auth-backend-module-okta-provider/README.md new file mode 100644 index 0000000000..3a320a9b1a --- /dev/null +++ b/plugins/auth-backend-module-okta-provider/README.md @@ -0,0 +1,30 @@ +# Auth Module: Okta Provider + +This module provides an Okta auth provider implementation for `@backstage/plugin-auth-backend`. + +## Utilization + +This module is used in `auth-backend/src/providers/okta` + +```ts +import { oktaAuthenticator } from '@backstage/plugin-auth-backend-module-okta-provider'; + +export const okta = createAuthProviderIntegration({ + create({ + authHandler?: AuthHandler, + + signIn?: { + resolver: SignInResolver, + }, + }) { + return createOAuthProviderFactory({ + authenticator: oktaAuthenticator, + }); + }, +}); +``` + +## Links + +- [Repository](https://okta.com/backstage/backstage/tree/master/plugins/auth-backend-module-okta-provider) +- [Backstage Project Homepage](https://backstage.io) diff --git a/plugins/auth-backend-module-okta-provider/api-report.md b/plugins/auth-backend-module-okta-provider/api-report.md new file mode 100644 index 0000000000..e04c762fe2 --- /dev/null +++ b/plugins/auth-backend-module-okta-provider/api-report.md @@ -0,0 +1,30 @@ +## API Report File for "@backstage/plugin-auth-backend-module-okta-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) +const authModuleOktaProvider: () => BackendFeature; +export default authModuleOktaProvider; + +// @public (undocumented) +export const oktaAuthenticator: OAuthAuthenticator< + PassportOAuthAuthenticatorHelper, + PassportProfile +>; + +// @public +export namespace oktaSignInResolvers { + const emailMatchingUserEntityAnnotation: SignInResolverFactory< + OAuthAuthenticatorResult, + unknown + >; +} +``` diff --git a/plugins/auth-backend-module-okta-provider/catalog-info.yaml b/plugins/auth-backend-module-okta-provider/catalog-info.yaml new file mode 100644 index 0000000000..7a5536edcf --- /dev/null +++ b/plugins/auth-backend-module-okta-provider/catalog-info.yaml @@ -0,0 +1,10 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: backstage-plugin-auth-backend-module-okta-provider + title: '@backstage/plugin-auth-backend-module-okta-provider' + description: The okta-provider backend module for the auth plugin. +spec: + lifecycle: experimental + type: backstage-backend-plugin-module + owner: maintainers diff --git a/plugins/auth-backend-module-okta-provider/config.d.ts b/plugins/auth-backend-module-okta-provider/config.d.ts new file mode 100644 index 0000000000..f047c96739 --- /dev/null +++ b/plugins/auth-backend-module-okta-provider/config.d.ts @@ -0,0 +1,36 @@ +/* + * 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. + */ + +export interface Config { + auth?: { + providers?: { + /** @visibility frontend */ + okta?: { + [authEnv: string]: { + clientId: string; + /** + * @visibility secret + */ + clientSecret: string; + audience?: string; + authServerId?: string; + idp?: string; + callbackUrl?: string; + }; + }; + }; + }; +} diff --git a/plugins/auth-backend-module-okta-provider/dev/index.ts b/plugins/auth-backend-module-okta-provider/dev/index.ts new file mode 100644 index 0000000000..d3c18c1d48 --- /dev/null +++ b/plugins/auth-backend-module-okta-provider/dev/index.ts @@ -0,0 +1,24 @@ +/* + * 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'; + +const backend = createBackend(); + +backend.add(import('@backstage/plugin-auth-backend')); +backend.add(import('../src')); + +backend.start(); diff --git a/plugins/auth-backend-module-okta-provider/package.json b/plugins/auth-backend-module-okta-provider/package.json new file mode 100644 index 0000000000..c6c714d92c --- /dev/null +++ b/plugins/auth-backend-module-okta-provider/package.json @@ -0,0 +1,45 @@ +{ + "name": "@backstage/plugin-auth-backend-module-okta-provider", + "description": "The okta-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:^", + "@davidzemon/passport-okta-oauth": "^0.0.5", + "express": "^4.18.2", + "passport": "^0.6.0" + }, + "devDependencies": { + "@backstage/backend-defaults": "workspace:^", + "@backstage/backend-test-utils": "workspace:^", + "@backstage/cli": "workspace:^", + "@backstage/plugin-auth-backend": "workspace:^", + "supertest": "^6.3.3" + }, + "configSchema": "config.d.ts", + "files": [ + "dist", + "config.d.ts" + ] +} diff --git a/plugins/auth-backend-module-okta-provider/src/authenticator.ts b/plugins/auth-backend-module-okta-provider/src/authenticator.ts new file mode 100644 index 0000000000..42201232f1 --- /dev/null +++ b/plugins/auth-backend-module-okta-provider/src/authenticator.ts @@ -0,0 +1,93 @@ +/* + * 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 OktaStrategy } from '@davidzemon/passport-okta-oauth'; +import { + createOAuthAuthenticator, + PassportOAuthAuthenticatorHelper, + PassportOAuthDoneCallback, + PassportProfile, +} from '@backstage/plugin-auth-node'; + +/** @public */ +export const oktaAuthenticator = createOAuthAuthenticator({ + defaultProfileTransform: + PassportOAuthAuthenticatorHelper.defaultProfileTransform, + 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( + { + clientID: clientId, + clientSecret: clientSecret, + callbackURL: callbackUrl, + audience: audience, + authServerID: authServerId, + idp: idp, + passReqToCallback: false, + response_type: 'code', + scope, + }, + ( + 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) { + return helper.authenticate(input); + }, + + async refresh(input, helper) { + return helper.refresh(input); + }, +}); diff --git a/plugins/auth-backend-module-okta-provider/src/index.ts b/plugins/auth-backend-module-okta-provider/src/index.ts new file mode 100644 index 0000000000..43a075d56f --- /dev/null +++ b/plugins/auth-backend-module-okta-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 okta-provider backend module for the auth plugin. + * + * @packageDocumentation + */ + +export { oktaAuthenticator } from './authenticator'; +export { authModuleOktaProvider as default } from './module'; +export { oktaSignInResolvers } from './resolvers'; diff --git a/plugins/auth-backend-module-okta-provider/src/module.test.ts b/plugins/auth-backend-module-okta-provider/src/module.test.ts new file mode 100644 index 0000000000..e469067b3f --- /dev/null +++ b/plugins/auth-backend-module-okta-provider/src/module.test.ts @@ -0,0 +1,82 @@ +/* + * 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 { authModuleOktaProvider } from './module'; +import request from 'supertest'; +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'), + authModuleOktaProvider, + mockServices.rootConfig.factory({ + data: { + app: { + baseUrl: 'http://localhost:3000', + }, + auth: { + providers: { + okta: { + development: { + clientId: 'my-client-id', + clientSecret: 'my-client-secret', + additionalScopes, + }, + }, + }, + }, + }, + }), + ], + }); + + const agent = request.agent(server); + + const res = await agent.get('/api/auth/okta/start?env=development'); + + expect(res.status).toEqual(302); + + const nonceCookie = agent.jar.getCookie('okta-nonce', { + domain: 'localhost', + path: '/api/auth/okta/handler', + script: false, + secure: false, + }); + expect(nonceCookie).toBeDefined(); + + const startUrl = new URL(res.get('location')); + expect(startUrl.origin).toBe('https://okta.com'); + expect(startUrl.pathname).toBe('/oauth2/v1/authorize'); + expect(Object.fromEntries(startUrl.searchParams)).toEqual({ + response_type: 'code', + scope: combinedScopes, + client_id: 'my-client-id', + redirect_uri: `http://localhost:${server.port()}/api/auth/okta/handler/frame`, + state: expect.any(String), + }); + + expect(decodeOAuthState(startUrl.searchParams.get('state')!)).toEqual({ + env: 'development', + nonce: decodeURIComponent(nonceCookie.value), + }); + }); +}); diff --git a/plugins/auth-backend-module-okta-provider/src/module.ts b/plugins/auth-backend-module-okta-provider/src/module.ts new file mode 100644 index 0000000000..35cb6634d5 --- /dev/null +++ b/plugins/auth-backend-module-okta-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 { oktaAuthenticator } from './authenticator'; +import { oktaSignInResolvers } from './resolvers'; + +/** @public */ +export const authModuleOktaProvider = createBackendModule({ + pluginId: 'auth', + moduleId: 'okta-provider', + register(reg) { + reg.registerInit({ + deps: { + providers: authProvidersExtensionPoint, + }, + async init({ providers }) { + providers.registerProvider({ + providerId: 'okta', + factory: createOAuthProviderFactory({ + authenticator: oktaAuthenticator, + signInResolverFactories: { + ...oktaSignInResolvers, + ...commonSignInResolvers, + }, + }), + }); + }, + }); + }, +}); diff --git a/plugins/auth-backend-module-okta-provider/src/resolvers.ts b/plugins/auth-backend-module-okta-provider/src/resolvers.ts new file mode 100644 index 0000000000..8bc3f38f17 --- /dev/null +++ b/plugins/auth-backend-module-okta-provider/src/resolvers.ts @@ -0,0 +1,54 @@ +/* + * 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 Okta auth provider. + * + * @public + */ +export namespace oktaSignInResolvers { + /** + * Looks up the user by matching their Okta email to the entity email. + */ + + export const emailMatchingUserEntityAnnotation = createSignInResolverFactory({ + create() { + return async ( + info: SignInInfo>, + ctx, + ) => { + const { profile } = info; + + if (!profile.email) { + throw new Error('Okta profile contained no email'); + } + + return ctx.signInWithCatalogUser({ + annotations: { + 'okta.com/email': profile.email, + }, + }); + }; + }, + }); +} diff --git a/plugins/auth-backend-module-okta-provider/src/types.d.ts b/plugins/auth-backend-module-okta-provider/src/types.d.ts new file mode 100644 index 0000000000..58cfb74173 --- /dev/null +++ b/plugins/auth-backend-module-okta-provider/src/types.d.ts @@ -0,0 +1,25 @@ +/* + * 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. + */ + +declare module 'passport-okta2' { + import { Request } from 'express'; + import { StrategyCreated } from 'passport'; + + export class Strategy { + constructor(options: any, verify: any); + authenticate(this: StrategyCreated, req: Request, options?: any): any; + } +} diff --git a/plugins/auth-backend/config.d.ts b/plugins/auth-backend/config.d.ts index c13c46664c..34139593d3 100644 --- a/plugins/auth-backend/config.d.ts +++ b/plugins/auth-backend/config.d.ts @@ -135,21 +135,6 @@ export interface Config { acceptedClockSkewMs?: number; }; /** @visibility frontend */ - okta?: { - [authEnv: string]: { - clientId: string; - /** - * @visibility secret - */ - clientSecret: string; - audience: string; - authServerId?: string; - idp?: string; - callbackUrl?: string; - additionalScopes?: string; - }; - }; - /** @visibility frontend */ oauth2?: { [authEnv: string]: { clientId: string; diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index 5ef46964ea..3ac92634b6 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -43,10 +43,10 @@ "@backstage/plugin-auth-backend-module-gitlab-provider": "workspace:^", "@backstage/plugin-auth-backend-module-google-provider": "workspace:^", "@backstage/plugin-auth-backend-module-oauth2-provider": "workspace:^", + "@backstage/plugin-auth-backend-module-okta-provider": "workspace:^", "@backstage/plugin-auth-node": "workspace:^", "@backstage/plugin-catalog-node": "workspace:^", "@backstage/types": "workspace:^", - "@davidzemon/passport-okta-oauth": "^0.0.5", "@google-cloud/firestore": "^6.0.0", "@types/express": "^4.17.6", "@types/passport": "^1.0.3", diff --git a/plugins/auth-backend/src/providers/okta/provider.test.ts b/plugins/auth-backend/src/providers/okta/provider.test.ts deleted file mode 100644 index 588b00be02..0000000000 --- a/plugins/auth-backend/src/providers/okta/provider.test.ts +++ /dev/null @@ -1,143 +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 { OktaAuthProvider } from './provider'; -import * as helpers from '../../lib/passport/PassportStrategyHelper'; -import { OAuthResult } from '../../lib/oauth'; -import { AuthResolverContext, OAuthStartResponse } from '../types'; - -jest.mock('../../lib/passport/PassportStrategyHelper', () => { - return { - executeFrameHandlerStrategy: jest.fn(), - executeRefreshTokenStrategy: jest.fn(), - executeFetchUserProfileStrategy: jest.fn(), - executeRedirectStrategy: jest.fn(), - }; -}); - -const mockFrameHandler = jest.spyOn( - helpers, - 'executeFrameHandlerStrategy', -) as unknown as jest.MockedFunction< - () => 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({ - 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', - }); - - 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', - }, - }); - }); - - 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 6b27fbb612..4c8d5c37d7 100644 --- a/plugins/auth-backend/src/providers/okta/provider.ts +++ b/plugins/auth-backend/src/providers/okta/provider.ts @@ -14,195 +14,16 @@ * limitations under the License. */ -import express from 'express'; -import { - OAuthAdapter, - OAuthProviderOptions, - OAuthHandlers, - OAuthResponse, - OAuthEnvironmentHandler, - OAuthStartRequest, - encodeState, - OAuthRefreshRequest, - OAuthResult, -} from '../../lib/oauth'; -import { Strategy as OktaStrategy } from '@davidzemon/passport-okta-oauth'; -import passport from 'passport'; -import { - executeFrameHandlerStrategy, - executeRedirectStrategy, - executeRefreshTokenStrategy, - makeProfileInfo, - executeFetchUserProfileStrategy, - PassportDoneCallback, -} from '../../lib/passport'; -import { - AuthHandler, - OAuthStartResponse, - SignInResolver, - AuthResolverContext, -} from '../types'; +import { AuthHandler, SignInResolver } from '../types'; +import { OAuthResult } from '../../lib/oauth'; + import { createAuthProviderIntegration } from '../createAuthProviderIntegration'; +import { createOAuthProviderFactory } from '@backstage/plugin-auth-node'; import { - commonByEmailLocalPartResolver, - commonByEmailResolver, -} from '../resolvers'; -import { StateStore } from 'passport-oauth2'; - -type PrivateInfo = { - refreshToken: string; -}; - -export type OktaAuthProviderOptions = OAuthProviderOptions & { - audience: string; - authServerId?: string; - idp?: string; - signInResolver?: SignInResolver; - authHandler: AuthHandler; - resolverContext: AuthResolverContext; - additionalScopes?: string; -}; - -export class OktaAuthProvider implements OAuthHandlers { - private readonly strategy: any; - 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, - * passport-oauth2 requires express-session to be installed - * so that the 'state' parameter of the oauth2 flow can be stored. - * This implementation of StateStore matches the NullStore found within - * passport-oauth2, which is the StateStore implementation used when options.state = false, - * allowing us to avoid using express-session in order to integrate with Okta. - */ - private store: StateStore = { - store(_req: express.Request, cb: any) { - cb(null, null); - }, - verify(_req: express.Request, _state: string, cb: any) { - cb(null, true); - }, - }; - - constructor(options: OktaAuthProviderOptions) { - this.signInResolver = options.signInResolver; - this.authHandler = options.authHandler; - this.resolverContext = options.resolverContext; - this.additionalScopes = options.additionalScopes || ''; - - this.strategy = new OktaStrategy( - { - clientID: options.clientId, - clientSecret: options.clientSecret, - callbackURL: options.callbackUrl, - audience: options.audience, - authServerID: options.authServerId, - idp: options.idp, - passReqToCallback: false, - store: this.store, - response_type: 'code', - }, - ( - accessToken: any, - refreshToken: any, - params: any, - fullProfile: passport.Profile, - done: PassportDoneCallback, - ) => { - done( - undefined, - { - accessToken, - refreshToken, - params, - fullProfile, - }, - { - refreshToken, - }, - ); - }, - ); - } - - 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: scope, - state: encodeState(req.state), - }); - } - - async handler(req: express.Request) { - const { result, privateInfo } = await executeFrameHandlerStrategy< - OAuthResult, - PrivateInfo - >(req, this.strategy); - - return { - response: await this.handleResult(result), - refreshToken: privateInfo.refreshToken, - }; - } - - async refresh(req: OAuthRefreshRequest) { - const scope = this.combineScopeStrings(req.scope, this.additionalScopes); - const { accessToken, refreshToken, params } = - await executeRefreshTokenStrategy(this.strategy, req.refreshToken, scope); - - const fullProfile = await executeFetchUserProfileStrategy( - this.strategy, - accessToken, - ); - - return { - response: await this.handleResult({ - fullProfile, - params, - accessToken, - }), - refreshToken, - }; - } - - private async handleResult(result: OAuthResult) { - const { profile } = await this.authHandler(result, this.resolverContext); - - 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, - }, - this.resolverContext, - ); - } - - return response; - } -} + adaptLegacyOAuthHandler, + adaptLegacyOAuthSignInResolver, +} from '../../lib/legacy'; +import { oktaAuthenticator } from '@backstage/plugin-auth-backend-module-okta-provider'; /** * Auth provider integration for Okta auth @@ -216,75 +37,20 @@ export const okta = createAuthProviderIntegration({ * 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. */ signIn?: { - /** - * Maps an auth result to a Backstage identity for the user. - */ resolver: SignInResolver; }; }) { - return ({ providerId, globalConfig, config, resolverContext }) => - OAuthEnvironmentHandler.mapConfig(config, envConfig => { - const clientId = envConfig.getString('clientId'); - const clientSecret = envConfig.getString('clientSecret'); - const audience = envConfig.getString('audience'); - const authServerId = envConfig.getOptionalString('authServerId'); - const idp = envConfig.getOptionalString('idp'); - const customCallbackUrl = envConfig.getOptionalString('callbackUrl'); - 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. - // https://github.com/fischerdan/passport-okta-oauth/blob/ea9ac42d/lib/passport-okta-oauth/oauth2.js#L12-L14 - if (!audience.startsWith('https://')) { - throw new Error("URL for 'audience' must start with 'https://'."); - } - - const authHandler: AuthHandler = options?.authHandler - ? options.authHandler - : async ({ fullProfile, params }) => ({ - profile: makeProfileInfo(fullProfile, params.id_token), - }); - - const provider = new OktaAuthProvider({ - audience, - authServerId, - idp, - clientId, - clientSecret, - callbackUrl, - authHandler, - signInResolver: options?.signIn?.resolver, - resolverContext, - additionalScopes, - }); - - return OAuthAdapter.fromConfig(globalConfig, provider, { - providerId, - callbackUrl, - }); - }); + return createOAuthProviderFactory({ + authenticator: oktaAuthenticator, + profileTransform: adaptLegacyOAuthHandler(options?.authHandler), + signInResolver: adaptLegacyOAuthSignInResolver(options?.signIn?.resolver), + }); }, resolvers: { - /** - * Looks up the user by matching their email local part to the entity name. - */ - emailLocalPartMatchingUserEntityName: () => commonByEmailLocalPartResolver, - /** - * Looks up the user by matching their email to the entity email. - */ - emailMatchingUserEntityProfileEmail: () => commonByEmailResolver, - /** - * Looks up the user by matching their email to the `okta.com/email` annotation. - */ emailMatchingUserEntityAnnotation(): SignInResolver { return async (info, ctx) => { const { profile } = info; diff --git a/yarn.lock b/yarn.lock index 2d15952490..1c76e282ae 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4914,6 +4914,24 @@ __metadata: languageName: unknown linkType: soft +"@backstage/plugin-auth-backend-module-okta-provider@workspace:^, @backstage/plugin-auth-backend-module-okta-provider@workspace:plugins/auth-backend-module-okta-provider": + version: 0.0.0-use.local + resolution: "@backstage/plugin-auth-backend-module-okta-provider@workspace:plugins/auth-backend-module-okta-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:^" + "@davidzemon/passport-okta-oauth": ^0.0.5 + express: ^4.18.2 + passport: ^0.6.0 + supertest: ^6.3.3 + languageName: unknown + linkType: soft + "@backstage/plugin-auth-backend-module-pinniped-provider@workspace:plugins/auth-backend-module-pinniped-provider": version: 0.0.0-use.local resolution: "@backstage/plugin-auth-backend-module-pinniped-provider@workspace:plugins/auth-backend-module-pinniped-provider" @@ -4957,10 +4975,10 @@ __metadata: "@backstage/plugin-auth-backend-module-gitlab-provider": "workspace:^" "@backstage/plugin-auth-backend-module-google-provider": "workspace:^" "@backstage/plugin-auth-backend-module-oauth2-provider": "workspace:^" + "@backstage/plugin-auth-backend-module-okta-provider": "workspace:^" "@backstage/plugin-auth-node": "workspace:^" "@backstage/plugin-catalog-node": "workspace:^" "@backstage/types": "workspace:^" - "@davidzemon/passport-okta-oauth": ^0.0.5 "@google-cloud/firestore": ^6.0.0 "@types/body-parser": ^1.19.0 "@types/cookie-parser": ^1.4.2