diff --git a/.changeset/fair-seals-burn.md b/.changeset/fair-seals-burn.md new file mode 100644 index 0000000000..fcc8fe5949 --- /dev/null +++ b/.changeset/fair-seals-burn.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-node': minor +--- + +Accepts an optional options object in the `PassportOAuthAuthenticatorHelper.authenticate` method. diff --git a/.changeset/hip-bottles-hear.md b/.changeset/hip-bottles-hear.md new file mode 100644 index 0000000000..678cc3191c --- /dev/null +++ b/.changeset/hip-bottles-hear.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend': patch +--- + +Migrated the `Auth0` auth provider to be implemented using the new `@backstage/plugin-auth-backend-module-auth0-provider` module. diff --git a/.changeset/rare-dogs-deny.md b/.changeset/rare-dogs-deny.md new file mode 100644 index 0000000000..4a215cabfd --- /dev/null +++ b/.changeset/rare-dogs-deny.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend-module-auth0-provider': minor +--- + +New module for `@backstage/plugin-auth-backend` that adds a Auth0 auth provider. diff --git a/docs/auth/auth0/provider--old.md b/docs/auth/auth0/provider--old.md new file mode 100644 index 0000000000..a089236aca --- /dev/null +++ b/docs/auth/auth0/provider--old.md @@ -0,0 +1,75 @@ +--- +id: provider--old +title: Auth0 Authentication Provider +sidebar_label: Auth0 +description: Adding Auth0 as an authentication provider in Backstage +--- + +:::info +This documentation is written for the old backend which has been replaced by +[the new backend system](../../backend-system/index.md), being the default since +Backstage [version 1.24](../../releases/v1.24.0.md). If have migrated to the new +backend system, you may want to read [its own article](./provider.md) +instead. Otherwise, [consider migrating](../../backend-system/building-backends/08-migrating.md)! +::: + +The Backstage `core-plugin-api` package comes with an Auth0 authentication +provider that can authenticate users using OAuth. + +## Create an Auth0 Application + +1. Log in to the [Auth0 dashboard](https://manage.auth0.com/dashboard/) +2. Navigate to **Applications** +3. Create an Application + - Name: Backstage (or your custom app name) + - Application type: Single Page Web Application +4. Click on the Settings tab +5. Add under `Application URIs` > `Allowed Callback URLs`: + `http://localhost:7007/api/auth/auth0/handler/frame` +6. Click `Save Changes` + +## Configuration + +The provider configuration can then be added to your `app-config.yaml` under the +root `auth` configuration: + +```yaml +auth: + environment: development + providers: + auth0: + development: + clientId: ${AUTH_AUTH0_CLIENT_ID} + clientSecret: ${AUTH_AUTH0_CLIENT_SECRET} + domain: ${AUTH_AUTH0_DOMAIN_ID} + audience: ${AUTH_AUTH0_AUDIENCE} + connection: ${AUTH_AUTH0_CONNECTION} + connectionScope: ${AUTH_AUTH0_CONNECTION_SCOPE} + session: + secret: ${AUTH_SESSION_SECRET} +``` + +The Auth0 provider is a structure with these configuration keys: + +- `clientId`: The Application client ID, found on the Auth0 Application page +- `clientSecret`: The Application client secret, found on the Auth0 Application + page +- `domain`: The Application domain, found on the Auth0 Application page + +It additionally relies on the following configuration to function: + +- `session.secret`: The session secret is a key used for signing and/or encrypting cookies set by the application to maintain session state. In this case, 'your session secret' should be replaced with a long, complex, and unique string that only your application knows. + +Auth0 requires a session, so you need to give the session a secret key. + +## Optional Configuration + +- `audience`: The intended recipients of the token +- `connection`: Social identity provider name. To check the available social connections, please visit [Auth0 Social Connections](https://marketplace.auth0.com/features/social-connections). +- `connectionScope`: Additional scopes in the interactive token request. It should always be used in combination with the `connection` parameter + +## Adding the provider to the Backstage frontend + +To add the provider to the frontend, add the `auth0AuthApi` reference and +`SignInPage` component as shown in +[Adding the provider to the sign-in page](../index.md#sign-in-configuration). diff --git a/docs/auth/auth0/provider.md b/docs/auth/auth0/provider.md index ae44114906..bc2a02fe1f 100644 --- a/docs/auth/auth0/provider.md +++ b/docs/auth/auth0/provider.md @@ -5,6 +5,13 @@ sidebar_label: Auth0 description: Adding Auth0 as an authentication provider in Backstage --- +:::info +This documentation is written for [the new backend system](../../backend-system/index.md) which is the default since Backstage +[version 1.24](../../releases/v1.24.0.md). If you are still on the old backend +system, you may want to read [its own article](./provider--old.md) +instead, and [consider migrating](../../backend-system/building-backends/08-migrating.md)! +::: + The Backstage `core-plugin-api` package comes with an Auth0 authentication provider that can authenticate users using OAuth. @@ -54,12 +61,46 @@ It additionally relies on the following configuration to function: Auth0 requires a session, so you need to give the session a secret key. -## Optional Configuration +### Optional - `audience`: The intended recipients of the token - `connection`: Social identity provider name. To check the available social connections, please visit [Auth0 Social Connections](https://marketplace.auth0.com/features/social-connections). - `connectionScope`: Additional scopes in the interactive token request. It should always be used in combination with the `connection` parameter +### Resolvers + +This provider includes several resolvers out of the box that you can use: + +- `emailMatchingUserEntityProfileEmail`: Matches the email address from the auth provider with the User entity that has a matching `spec.profile.email`. If no match is found it will throw a `NotFoundError`. +- `emailLocalPartMatchingUserEntityName`: Matches the [local part](https://en.wikipedia.org/wiki/Email_address#Local-part) of the email address from the auth provider with the User entity that has a matching `name`. If no match is found it will throw a `NotFoundError`. + +:::note Note + +The resolvers will be tried in order, but will only be skipped if they throw a `NotFoundError`. + +::: + +If these resolvers do not fit your needs you can build a custom resolver, this is covered in the [Building Custom Resolvers](../identity-resolver.md#building-custom-resolvers) section of the Sign-in Identities and Resolvers documentation. + +## Backend Installation + +To add the provider to the backend we will first need to install the package by running this command: + +```bash title="from your Backstage root directory" +yarn --cwd packages/backend add @backstage/plugin-auth-backend-module-auth0-provider +``` + +Then we will need to add this line: + +```ts title="packages/backend/src/index.ts" +import { createBackend } from '@backstage/backend-defaults'; +//... +backend.add(import('@backstage/plugin-auth-backend')); +// highlight-add-next-line +backend.add(import('@backstage/plugin-auth-backend-module-auth0-provider')); +//... +``` + ## Adding the provider to the Backstage frontend To add the provider to the frontend, add the `auth0AuthApi` reference and diff --git a/plugins/auth-backend-module-auth0-provider/.eslintrc.js b/plugins/auth-backend-module-auth0-provider/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/auth-backend-module-auth0-provider/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/auth-backend-module-auth0-provider/README.md b/plugins/auth-backend-module-auth0-provider/README.md new file mode 100644 index 0000000000..c2a98b0335 --- /dev/null +++ b/plugins/auth-backend-module-auth0-provider/README.md @@ -0,0 +1,8 @@ +# Auth Module: Auth0 Provider + +This module provides an Auth0 auth provider implementation for `@backstage/plugin-auth-backend`. + +## Links + +- [Repository](https://gitlab.com/backstage/backstage/tree/master/plugins/auth-backend-module-auth0-provider) +- [Backstage Project Homepage](https://backstage.io) diff --git a/plugins/auth-backend-module-auth0-provider/api-report.md b/plugins/auth-backend-module-auth0-provider/api-report.md new file mode 100644 index 0000000000..87b5a8b482 --- /dev/null +++ b/plugins/auth-backend-module-auth0-provider/api-report.md @@ -0,0 +1,51 @@ +## API Report File for "@backstage/plugin-auth-backend-module-auth0-provider" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import Auth0InternalStrategy from 'passport-auth0'; +import { BackendFeature } from '@backstage/backend-plugin-api'; +import { OAuthAuthenticator } from '@backstage/plugin-auth-node'; +import { PassportOAuthAuthenticatorHelper } from '@backstage/plugin-auth-node'; +import { PassportProfile } from '@backstage/plugin-auth-node'; +import { StateStore } from 'passport-oauth2'; + +// @public (undocumented) +export const auth0Authenticator: OAuthAuthenticator< + { + helper: PassportOAuthAuthenticatorHelper; + audience: string | undefined; + connection: string | undefined; + connectionScope: string | undefined; + }, + PassportProfile +>; + +// @public (undocumented) +export class Auth0Strategy extends Auth0InternalStrategy { + constructor( + options: Auth0StrategyOptionsWithRequest, + verify: Auth0InternalStrategy.VerifyFunction, + ); +} + +// @public (undocumented) +export interface Auth0StrategyOptionsWithRequest { + // (undocumented) + callbackURL: string; + // (undocumented) + clientID: string; + // (undocumented) + clientSecret: string; + // (undocumented) + domain: string; + // (undocumented) + passReqToCallback: true; + // (undocumented) + store: StateStore; +} + +// @public (undocumented) +const authModuleAuth0Provider: BackendFeature; +export default authModuleAuth0Provider; +``` diff --git a/plugins/auth-backend-module-auth0-provider/catalog-info.yaml b/plugins/auth-backend-module-auth0-provider/catalog-info.yaml new file mode 100644 index 0000000000..26b540ca8a --- /dev/null +++ b/plugins/auth-backend-module-auth0-provider/catalog-info.yaml @@ -0,0 +1,10 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: backstage-plugin-auth-backend-module-auth0-provider + title: '@backstage/plugin-auth-backend-module-auth0-provider' + description: The auth0-provider backend module for the auth plugin. +spec: + lifecycle: experimental + type: backstage-backend-plugin-module + owner: maintainers diff --git a/plugins/auth-backend-module-auth0-provider/config.d.ts b/plugins/auth-backend-module-auth0-provider/config.d.ts new file mode 100644 index 0000000000..a0ec7d4eac --- /dev/null +++ b/plugins/auth-backend-module-auth0-provider/config.d.ts @@ -0,0 +1,37 @@ +/* + * 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. + */ + +export interface Config { + auth?: { + providers?: { + /** @visibility frontend */ + auth0?: { + [authEnv: string]: { + clientId: string; + /** + * @visibility secret + */ + clientSecret: string; + domain: string; + callbackUrl?: string; + audience?: string; + connection?: string; + connectionScope?: string; + }; + }; + }; + }; +} diff --git a/plugins/auth-backend-module-auth0-provider/dev/index.ts b/plugins/auth-backend-module-auth0-provider/dev/index.ts new file mode 100644 index 0000000000..c22389cc57 --- /dev/null +++ b/plugins/auth-backend-module-auth0-provider/dev/index.ts @@ -0,0 +1,26 @@ +/* + * 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 { createBackend } from '@backstage/backend-defaults'; +import authPlugin from '@backstage/plugin-auth-backend'; +import authModuleAuth0Provider from '../src'; + +const backend = createBackend(); + +backend.add(authPlugin); +backend.add(authModuleAuth0Provider); + +backend.start(); diff --git a/plugins/auth-backend-module-auth0-provider/package.json b/plugins/auth-backend-module-auth0-provider/package.json new file mode 100644 index 0000000000..34276a0608 --- /dev/null +++ b/plugins/auth-backend-module-auth0-provider/package.json @@ -0,0 +1,53 @@ +{ + "name": "@backstage/plugin-auth-backend-module-auth0-provider", + "version": "0.0.0", + "description": "The auth0-provider backend module for the auth plugin.", + "backstage": { + "role": "backend-plugin-module", + "pluginId": "auth", + "pluginPackage": "@backstage/plugin-auth-backend" + }, + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/auth-backend-module-auth0-provider" + }, + "license": "Apache-2.0", + "main": "src/index.ts", + "types": "src/index.ts", + "files": [ + "dist", + "config.d.ts" + ], + "scripts": { + "build": "backstage-cli package build", + "clean": "backstage-cli package clean", + "lint": "backstage-cli package lint", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack", + "start": "backstage-cli package start", + "test": "backstage-cli package test" + }, + "dependencies": { + "@backstage/backend-plugin-api": "workspace:^", + "@backstage/plugin-auth-node": "workspace:^", + "@types/passport-auth0": "^1.0.5", + "@types/passport-oauth2": "^1.4.15", + "express": "^4.17.1", + "passport-auth0": "^1.4.3", + "passport-oauth2": "^1.6.1" + }, + "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" +} diff --git a/plugins/auth-backend-module-auth0-provider/src/authenticator.ts b/plugins/auth-backend-module-auth0-provider/src/authenticator.ts new file mode 100644 index 0000000000..a042f113f8 --- /dev/null +++ b/plugins/auth-backend-module-auth0-provider/src/authenticator.ts @@ -0,0 +1,116 @@ +/* + * 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 { + createOAuthAuthenticator, + PassportOAuthAuthenticatorHelper, + PassportOAuthDoneCallback, + PassportProfile, +} from '@backstage/plugin-auth-node'; +import { Auth0Strategy } from './strategy'; + +/** @public */ +export const auth0Authenticator = createOAuthAuthenticator({ + defaultProfileTransform: + PassportOAuthAuthenticatorHelper.defaultProfileTransform, + initialize({ callbackUrl, config }) { + const clientID = config.getString('clientId'); + const clientSecret = config.getString('clientSecret'); + const domain = config.getString('domain'); + const audience = config.getOptionalString('audience'); + const connection = config.getOptionalString('connection'); + const connectionScope = config.getOptionalString('connectionScope'); + const callbackURL = config.getOptionalString('callbackUrl') ?? callbackUrl; + // Due to passport-auth0 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 auth0. + const store = { + store(_req: express.Request, cb: any) { + cb(null, null); + }, + verify(_req: express.Request, _state: string, cb: any) { + cb(null, true); + }, + }; + + const helper = PassportOAuthAuthenticatorHelper.from( + new Auth0Strategy( + { + clientID, + clientSecret, + callbackURL, + domain, + store, + // We need passReqToCallback set to false to get params, but there's + // no matching type signature for that, so instead behold this beauty + passReqToCallback: false as true, + }, + ( + accessToken: string, + refreshToken: string, + params: any, + fullProfile: PassportProfile, + done: PassportOAuthDoneCallback, + ) => { + done( + undefined, + { + fullProfile, + accessToken, + params, + }, + { + refreshToken, + }, + ); + }, + ), + ); + return { helper, audience, connection, connectionScope }; + }, + + async start( + input, + { helper, audience, connection, connectionScope: connection_scope }, + ) { + return helper.start(input, { + accessType: 'offline', + prompt: 'consent', + ...(audience ? { audience } : {}), + ...(connection ? { connection } : {}), + ...(connection_scope ? { connection_scope } : {}), + }); + }, + + async authenticate( + input, + { helper, audience, connection, connectionScope: connection_scope }, + ) { + return helper.authenticate(input, { + ...(audience ? { audience } : {}), + ...(connection ? { connection } : {}), + ...(connection_scope ? { connection_scope } : {}), + }); + }, + + async refresh(input, { helper }) { + return helper.refresh(input); + }, +}); diff --git a/plugins/auth-backend-module-auth0-provider/src/index.ts b/plugins/auth-backend-module-auth0-provider/src/index.ts new file mode 100644 index 0000000000..34c8b9a85e --- /dev/null +++ b/plugins/auth-backend-module-auth0-provider/src/index.ts @@ -0,0 +1,28 @@ +/* + * 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. + */ + +/** + * The auth0-provider backend module for the auth plugin. + * + * @packageDocumentation + */ + +export { + Auth0Strategy, + type Auth0StrategyOptionsWithRequest, +} from './strategy'; +export { auth0Authenticator } from './authenticator'; +export { authModuleAuth0Provider as default } from './module'; diff --git a/plugins/auth-backend-module-auth0-provider/src/module.test.ts b/plugins/auth-backend-module-auth0-provider/src/module.test.ts new file mode 100644 index 0000000000..630f1dd070 --- /dev/null +++ b/plugins/auth-backend-module-auth0-provider/src/module.test.ts @@ -0,0 +1,90 @@ +/* + * 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 { mockServices, startTestBackend } from '@backstage/backend-test-utils'; +import authPlugin from '@backstage/plugin-auth-backend'; +import { authModuleAuth0Provider } from './module'; +import request from 'supertest'; +import { decodeOAuthState } from '@backstage/plugin-auth-node'; + +describe('authModuleAuth0Provider', () => { + it('should start', async () => { + const { server } = await startTestBackend({ + features: [ + authPlugin, + authModuleAuth0Provider, + mockServices.rootConfig.factory({ + data: { + app: { + baseUrl: 'http://localhost:3000', + }, + auth: { + providers: { + auth0: { + development: { + clientId: 'clientId', + clientSecret: 'clientSecret', + domain: 'domain', + connection: 'connection', + connectionScope: 'connectionScope', + }, + }, + }, + session: { + secret: 'secret', + }, + }, + }, + }), + ], + }); + + const agent = request.agent(server); + + const res = await agent.get('/api/auth/auth0/start?env=development'); + + expect(res.status).toEqual(302); + + const nonceCookie = agent.jar.getCookie('auth0-nonce', { + domain: 'localhost', + path: '/api/auth/auth0/handler', + script: false, + secure: false, + }); + expect(nonceCookie).toBeDefined(); + + const startUrl = new URL(res.get('location')); + expect(startUrl.origin).toBe('https://domain'); + expect(startUrl.pathname).toBe('/authorize'); + expect(Object.fromEntries(startUrl.searchParams)).toEqual({ + response_type: 'code', + scope: '', + client_id: 'clientId', + redirect_uri: `http://localhost:${server.port()}/api/auth/auth0/handler/frame`, + prompt: 'consent', + accessType: 'offline', + connection: 'connection', + connection_scope: 'connectionScope', + nonce: expect.any(String), + state: expect.any(String), + }); + + expect(decodeOAuthState(startUrl.searchParams.get('state')!)).toEqual({ + env: 'development', + nonce: decodeURIComponent(nonceCookie.value), + }); + }); +}); diff --git a/plugins/auth-backend-module-auth0-provider/src/module.ts b/plugins/auth-backend-module-auth0-provider/src/module.ts new file mode 100644 index 0000000000..fbdb752780 --- /dev/null +++ b/plugins/auth-backend-module-auth0-provider/src/module.ts @@ -0,0 +1,47 @@ +/* + * 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 { createBackendModule } from '@backstage/backend-plugin-api'; +import { + authProvidersExtensionPoint, + commonSignInResolvers, + createOAuthProviderFactory, +} from '@backstage/plugin-auth-node'; +import { auth0Authenticator } from './authenticator'; + +/** @public */ +export const authModuleAuth0Provider = createBackendModule({ + pluginId: 'auth', + moduleId: 'auth0-provider', + register(reg) { + reg.registerInit({ + deps: { + providers: authProvidersExtensionPoint, + }, + async init({ providers }) { + providers.registerProvider({ + providerId: 'auth0', + factory: createOAuthProviderFactory({ + authenticator: auth0Authenticator, + signInResolverFactories: { + ...commonSignInResolvers, + }, + }), + }); + }, + }); + }, +}); diff --git a/plugins/auth-backend-module-auth0-provider/src/strategy.ts b/plugins/auth-backend-module-auth0-provider/src/strategy.ts new file mode 100644 index 0000000000..42625eb77d --- /dev/null +++ b/plugins/auth-backend-module-auth0-provider/src/strategy.ts @@ -0,0 +1,45 @@ +/* + * 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 Auth0InternalStrategy from 'passport-auth0'; +import { StateStore } from 'passport-oauth2'; + +/** @public */ +export interface Auth0StrategyOptionsWithRequest { + clientID: string; + clientSecret: string; + callbackURL: string; + domain: string; + passReqToCallback: true; + store: StateStore; +} + +/** @public */ +export class Auth0Strategy extends Auth0InternalStrategy { + constructor( + options: Auth0StrategyOptionsWithRequest, + verify: Auth0InternalStrategy.VerifyFunction, + ) { + const optionsWithURLs = { + ...options, + authorizationURL: `https://${options.domain}/authorize`, + tokenURL: `https://${options.domain}/oauth/token`, + userInfoURL: `https://${options.domain}/userinfo`, + apiUrl: `https://${options.domain}/api`, + }; + super(optionsWithURLs, verify); + } +} diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index 9f1bf8ddad..a5052855a9 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -50,6 +50,7 @@ "@backstage/config": "workspace:^", "@backstage/errors": "workspace:^", "@backstage/plugin-auth-backend-module-atlassian-provider": "workspace:^", + "@backstage/plugin-auth-backend-module-auth0-provider": "workspace:^", "@backstage/plugin-auth-backend-module-aws-alb-provider": "workspace:^", "@backstage/plugin-auth-backend-module-azure-easyauth-provider": "workspace:^", "@backstage/plugin-auth-backend-module-bitbucket-provider": "workspace:^", diff --git a/plugins/auth-backend/src/providers/auth0/provider.ts b/plugins/auth-backend/src/providers/auth0/provider.ts index c399eb08c4..44e7c5bb6e 100644 --- a/plugins/auth-backend/src/providers/auth0/provider.ts +++ b/plugins/auth-backend/src/providers/auth0/provider.ts @@ -14,40 +14,25 @@ * limitations under the License. */ -import express from 'express'; -import passport from 'passport'; -import Auth0Strategy from './strategy'; -import { - OAuthAdapter, - OAuthProviderOptions, - OAuthHandlers, - OAuthResponse, - OAuthEnvironmentHandler, - OAuthStartRequest, - encodeState, - OAuthRefreshRequest, - OAuthResult, -} from '../../lib/oauth'; -import { - executeFetchUserProfileStrategy, - executeFrameHandlerStrategy, - executeRedirectStrategy, - executeRefreshTokenStrategy, - makeProfileInfo, - PassportDoneCallback, -} from '../../lib/passport'; -import { OAuthStartResponse, AuthHandler } from '../types'; +import { OAuthProviderOptions, OAuthResult } from '../../lib/oauth'; + +import { AuthHandler } from '../types'; import { createAuthProviderIntegration } from '../createAuthProviderIntegration'; -import { StateStore } from 'passport-oauth2'; import { AuthResolverContext, + createOAuthProviderFactory, SignInResolver, } from '@backstage/plugin-auth-node'; +import { + adaptLegacyOAuthHandler, + adaptLegacyOAuthSignInResolver, +} from '../../lib/legacy'; +import { auth0Authenticator } from '@backstage/plugin-auth-backend-module-auth0-provider'; -type PrivateInfo = { - refreshToken: string; -}; - +/** + * @public + * @deprecated The Auth0 auth provider was extracted to `@backstage/plugin-auth-backend-module-auth0-provider`. + */ export type Auth0AuthProviderOptions = OAuthProviderOptions & { domain: string; signInResolver?: SignInResolver; @@ -58,155 +43,6 @@ export type Auth0AuthProviderOptions = OAuthProviderOptions & { connectionScope?: string; }; -export class Auth0AuthProvider implements OAuthHandlers { - private readonly _strategy: Auth0Strategy; - private readonly signInResolver?: SignInResolver; - private readonly authHandler: AuthHandler; - private readonly resolverContext: AuthResolverContext; - private readonly audience?: string; - private readonly connection?: string; - private readonly connectionScope?: string; - - /** - * Due to passport-auth0 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 auth0. - */ - 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: Auth0AuthProviderOptions) { - this.signInResolver = options.signInResolver; - this.authHandler = options.authHandler; - this.resolverContext = options.resolverContext; - this.audience = options.audience; - this.connection = options.connection; - this.connectionScope = options.connectionScope; - this._strategy = new Auth0Strategy( - { - clientID: options.clientId, - clientSecret: options.clientSecret, - callbackURL: options.callbackUrl, - domain: options.domain, - // We need passReqToCallback set to false to get params, but there's - // no matching type signature for that, so instead behold this beauty - passReqToCallback: false as true, - store: this.store, - }, - ( - accessToken: any, - refreshToken: any, - params: any, - fullProfile: passport.Profile, - done: PassportDoneCallback, - ) => { - done( - undefined, - { - fullProfile, - accessToken, - refreshToken, - params, - }, - { - refreshToken, - }, - ); - }, - ); - } - - async start(req: OAuthStartRequest): Promise { - return await executeRedirectStrategy(req, this._strategy, { - accessType: 'offline', - prompt: 'consent', - scope: req.scope, - state: encodeState(req.state), - ...(this.audience ? { audience: this.audience } : {}), - ...(this.connection ? { connection: this.connection } : {}), - ...(this.connectionScope - ? { connection_scope: this.connectionScope } - : {}), - }); - } - - async handler(req: express.Request) { - const { result, privateInfo } = await executeFrameHandlerStrategy< - OAuthResult, - PrivateInfo - >(req, this._strategy, { - ...(this.audience ? { audience: this.audience } : {}), - ...(this.connection ? { connection: this.connection } : {}), - ...(this.connectionScope - ? { connection_scope: this.connectionScope } - : {}), - }); - - return { - response: await this.handleResult(result), - refreshToken: privateInfo.refreshToken, - }; - } - - async refresh(req: OAuthRefreshRequest) { - const { accessToken, refreshToken, params } = - await executeRefreshTokenStrategy( - this._strategy, - req.refreshToken, - req.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; - } -} - /** * Auth provider integration for auth0 auth * @@ -230,44 +66,10 @@ export const auth0 = createAuthProviderIntegration({ resolver: SignInResolver; }; }) { - return ({ providerId, globalConfig, config, resolverContext }) => - OAuthEnvironmentHandler.mapConfig(config, envConfig => { - const clientId = envConfig.getString('clientId'); - const clientSecret = envConfig.getString('clientSecret'); - const domain = envConfig.getString('domain'); - const customCallbackUrl = envConfig.getOptionalString('callbackUrl'); - const audience = envConfig.getOptionalString('audience'); - const connection = envConfig.getOptionalString('connection'); - const connectionScope = envConfig.getOptionalString('connectionScope'); - const callbackUrl = - customCallbackUrl || - `${globalConfig.baseUrl}/${providerId}/handler/frame`; - - const authHandler: AuthHandler = options?.authHandler - ? options.authHandler - : async ({ fullProfile, params }) => ({ - profile: makeProfileInfo(fullProfile, params.id_token), - }); - - const signInResolver = options?.signIn?.resolver; - - const provider = new Auth0AuthProvider({ - clientId, - clientSecret, - callbackUrl, - domain, - authHandler, - signInResolver, - resolverContext, - audience, - connection, - connectionScope, - }); - - return OAuthAdapter.fromConfig(globalConfig, provider, { - providerId, - callbackUrl, - }); - }); + return createOAuthProviderFactory({ + authenticator: auth0Authenticator, + profileTransform: adaptLegacyOAuthHandler(options?.authHandler), + signInResolver: adaptLegacyOAuthSignInResolver(options?.signIn?.resolver), + }); }, }); diff --git a/plugins/auth-node/api-report.md b/plugins/auth-node/api-report.md index a3750819f9..5a8750127d 100644 --- a/plugins/auth-node/api-report.md +++ b/plugins/auth-node/api-report.md @@ -503,6 +503,7 @@ export class PassportOAuthAuthenticatorHelper { // (undocumented) authenticate( input: OAuthAuthenticatorAuthenticateInput, + options?: Record, ): Promise>; // (undocumented) static defaultProfileTransform: ProfileTransform< diff --git a/plugins/auth-node/src/oauth/PassportOAuthAuthenticatorHelper.ts b/plugins/auth-node/src/oauth/PassportOAuthAuthenticatorHelper.ts index c03a9cb56f..dfcdb3d297 100644 --- a/plugins/auth-node/src/oauth/PassportOAuthAuthenticatorHelper.ts +++ b/plugins/auth-node/src/oauth/PassportOAuthAuthenticatorHelper.ts @@ -85,12 +85,13 @@ export class PassportOAuthAuthenticatorHelper { async authenticate( input: OAuthAuthenticatorAuthenticateInput, + options?: Record, ): Promise> { const { result, privateInfo } = await PassportHelpers.executeFrameHandlerStrategy< PassportOAuthResult, PassportOAuthPrivateInfo - >(input.req, this.#strategy); + >(input.req, this.#strategy, options); return { fullProfile: result.fullProfile as PassportProfile, diff --git a/yarn.lock b/yarn.lock index 58e53c1424..c6a5ddaab5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4795,6 +4795,25 @@ __metadata: languageName: unknown linkType: soft +"@backstage/plugin-auth-backend-module-auth0-provider@workspace:^, @backstage/plugin-auth-backend-module-auth0-provider@workspace:plugins/auth-backend-module-auth0-provider": + version: 0.0.0-use.local + resolution: "@backstage/plugin-auth-backend-module-auth0-provider@workspace:plugins/auth-backend-module-auth0-provider" + dependencies: + "@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:^" + "@types/passport-auth0": ^1.0.5 + "@types/passport-oauth2": ^1.4.15 + express: ^4.17.1 + passport-auth0: ^1.4.3 + passport-oauth2: ^1.6.1 + supertest: ^6.3.3 + languageName: unknown + linkType: soft + "@backstage/plugin-auth-backend-module-aws-alb-provider@workspace:^, @backstage/plugin-auth-backend-module-aws-alb-provider@workspace:plugins/auth-backend-module-aws-alb-provider": version: 0.0.0-use.local resolution: "@backstage/plugin-auth-backend-module-aws-alb-provider@workspace:plugins/auth-backend-module-aws-alb-provider" @@ -5118,6 +5137,7 @@ __metadata: "@backstage/config": "workspace:^" "@backstage/errors": "workspace:^" "@backstage/plugin-auth-backend-module-atlassian-provider": "workspace:^" + "@backstage/plugin-auth-backend-module-auth0-provider": "workspace:^" "@backstage/plugin-auth-backend-module-aws-alb-provider": "workspace:^" "@backstage/plugin-auth-backend-module-azure-easyauth-provider": "workspace:^" "@backstage/plugin-auth-backend-module-bitbucket-provider": "workspace:^"