diff --git a/plugins/auth-backend-module-oidc-provider/config.d.ts b/plugins/auth-backend-module-oidc-provider/config.d.ts index ba141d9555..803b9add73 100644 --- a/plugins/auth-backend-module-oidc-provider/config.d.ts +++ b/plugins/auth-backend-module-oidc-provider/config.d.ts @@ -25,8 +25,11 @@ export interface Config { * @visibility secret */ clientSecret: string; - audience?: string; + metadataUrl: string; callbackUrl?: string; + tokenSignedResponseAlg?: string; + scope?: 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 new file mode 100644 index 0000000000..6f22517361 --- /dev/null +++ b/plugins/auth-backend-module-oidc-provider/src/authenticator.test.ts @@ -0,0 +1,394 @@ +/* + * 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 { + OAuthAuthenticatorAuthenticateInput, + OAuthAuthenticatorRefreshInput, + OAuthAuthenticatorStartInput, + OAuthState, + decodeOAuthState, + encodeOAuthState, +} from '@backstage/plugin-auth-node'; +import { oidcAuthenticator } from './authenticator'; +import { setupServer } from 'msw/node'; +import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; +import { ConfigReader } from '@backstage/config'; +import { JWK, SignJWT, exportJWK, generateKeyPair } from 'jose'; +import { rest } from 'msw'; +import express from 'express'; + +describe('oidcAuthenticator', () => { + let implementation: any; + let oauthState: OAuthState; + let idToken: string; + let publicKey: JWK; + + const mswServer = setupServer(); + setupRequestMockHandlers(mswServer); + + const issuerMetadata = { + issuer: 'https://oidc.test', + authorization_endpoint: 'https://oidc.test/oauth2/authorize', + token_endpoint: 'https://oidc.test/oauth2/token', + revocation_endpoint: 'https://oidc.test/oauth2/revoke_token', + userinfo_endpoint: 'https://oidc.test/idp/userinfo.openid', + introspection_endpoint: 'https://oidc.test/introspect.oauth2', + jwks_uri: 'https://oidc.test/jwks.json', + scopes_supported: [ + 'openid', + 'offline_access', + 'oidc:request-audience', + 'username', + 'groups', + ], + claims_supported: ['email', 'username', 'groups', 'additionalClaims'], + response_types_supported: ['code'], + id_token_signing_alg_values_supported: ['RS256', 'RS512', 'HS256'], + token_endpoint_auth_signing_alg_values_supported: [ + 'RS256', + 'RS512', + 'HS256', + ], + request_object_signing_alg_values_supported: ['RS256', 'RS512', 'HS256'], + }; + + beforeAll(async () => { + const keyPair = await generateKeyPair('RS256'); + const privateKey = await exportJWK(keyPair.privateKey); + publicKey = await exportJWK(keyPair.publicKey); + publicKey.alg = privateKey.alg = 'RS256'; + + idToken = await new SignJWT({ + sub: 'test', + iss: 'https://oidc.test', + iat: Date.now(), + aud: 'clientId', + exp: Date.now() + 10000, + }) + .setProtectedHeader({ alg: privateKey.alg, kid: privateKey.kid }) + .sign(keyPair.privateKey); + }); + + beforeEach(() => { + mswServer.use( + rest.get( + 'https://oidc.test/.well-known/openid-configuration', + (_req, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(issuerMetadata), + ), + ), + rest.get('https://oidc.test/jwks.json', async (_req, res, ctx) => + res(ctx.status(200), ctx.json({ keys: [{ ...publicKey }] })), + ), + rest.post('https://oidc.test/oauth2/token', async (req, res, ctx) => { + return res( + req.headers.get('Authorization') + ? ctx.json({ + access_token: 'accessToken', + id_token: idToken, + refresh_token: 'refreshToken', + scope: 'testScope', + token_type: '', + }) + : ctx.status(401), + ); + }), + rest.get( + 'https://oidc.test/idp/userinfo.openid', + async (_req, res, ctx) => + res( + ctx.status(200), + ctx.json({ + sub: 'test', + name: 'Alice Adams', + given_name: 'Alice', + family_name: 'Adams', + email: 'alice@test.com', + }), + ), + ), + ); + + implementation = oidcAuthenticator.initialize({ + callbackUrl: 'https://backstage.test/callback', + config: new ConfigReader({ + metadataUrl: 'https://oidc.test', + clientId: 'clientId', + clientSecret: 'clientSecret', + }), + }); + + oauthState = { + nonce: 'nonce', + env: 'env', + }; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('#start', () => { + let fakeSession: Record; + let startRequest: OAuthAuthenticatorStartInput; + + beforeEach(() => { + fakeSession = {}; + startRequest = { + state: encodeOAuthState(oauthState), + req: { + method: 'GET', + url: 'test', + session: fakeSession, + }, + } as unknown as OAuthAuthenticatorStartInput; + }); + + it('redirects to authorization endpoint returned from OIDC metadata endpoint', async () => { + const startResponse = await oidcAuthenticator.start( + startRequest, + implementation, + ); + const url = new URL(startResponse.url); + + expect(url.protocol).toBe('https:'); + expect(url.hostname).toBe('oidc.test'); + expect(url.pathname).toBe('/oauth2/authorize'); + }); + + it('initiates authorization code grant', async () => { + const startResponse = await oidcAuthenticator.start( + startRequest, + implementation, + ); + const { searchParams } = new URL(startResponse.url); + + expect(searchParams.get('response_type')).toBe('code'); + }); + + it('passes client ID from config', async () => { + const startResponse = await oidcAuthenticator.start( + startRequest, + implementation, + ); + const { searchParams } = new URL(startResponse.url); + + expect(searchParams.get('client_id')).toBe('clientId'); + }); + + it('passes callback URL from config', async () => { + const startResponse = await oidcAuthenticator.start( + startRequest, + implementation, + ); + const { searchParams } = new URL(startResponse.url); + + expect(searchParams.get('redirect_uri')).toBe( + 'https://backstage.test/callback', + ); + }); + + it('generates PKCE challenge', async () => { + const startResponse = await oidcAuthenticator.start( + startRequest, + implementation, + ); + const { searchParams } = new URL(startResponse.url); + + expect(searchParams.get('code_challenge_method')).toBe('S256'); + expect(searchParams.get('code_challenge')).not.toBeNull(); + }); + + it('stores PKCE verifier in session', async () => { + await oidcAuthenticator.start(startRequest, implementation); + expect(fakeSession['oidc:oidc.test'].code_verifier).toBeDefined(); + }); + + // without the offline_access scope refresh should not work should we add in the test that is a required scope to test for? curently dont see that by defualt in the provider implementation. + 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, + implementation, + ); + const { searchParams } = new URL(startResponse.url); + const stateParam = searchParams.get('state'); + const decodedState = decodeOAuthState(stateParam!); + + expect(decodedState).toMatchObject(oauthState); + }); + + it('fails when request has no session', async () => { + return expect( + oidcAuthenticator.start( + { + state: encodeOAuthState(oauthState), + req: { + method: 'GET', + url: 'test', + }, + } as unknown as OAuthAuthenticatorStartInput, + implementation, + ), + ).rejects.toThrow('authentication requires session support'); + }); + }); + + describe('#authenticate', () => { + let handlerRequest: OAuthAuthenticatorAuthenticateInput; + + beforeEach(() => { + handlerRequest = { + req: { + method: 'GET', + url: `https://test?code=authorization_code&state=${encodeOAuthState( + oauthState, + )}`, + session: { + 'oidc:oidc.test': { + state: encodeOAuthState(oauthState), + }, + }, + } as unknown as express.Request, + }; + }); + + it('exchanges authorization code for access token', async () => { + const handlerResponse = await oidcAuthenticator.authenticate( + handlerRequest, + implementation, + ); + const accessToken = handlerResponse.session.accessToken; + + expect(accessToken).toEqual('accessToken'); + }); + + it('exchanges authorization code for refresh token', async () => { + const handlerResponse = await oidcAuthenticator.authenticate( + handlerRequest, + implementation, + ); + const refreshToken = handlerResponse.session.refreshToken; + + expect(refreshToken).toEqual('refreshToken'); + }); + + it('returns granted scope', async () => { + const handlerResponse = await oidcAuthenticator.authenticate( + handlerRequest, + implementation, + ); + const responseScope = handlerResponse.session.scope; + + expect(responseScope).toEqual('testScope'); + }); + + it('fails without authorization code', async () => { + handlerRequest.req.url = 'https://test.com'; + return expect( + oidcAuthenticator.authenticate(handlerRequest, implementation), + ).rejects.toThrow('Unexpected redirect'); + }); + + it('fails without oauth state', async () => { + return expect( + oidcAuthenticator.authenticate( + { + req: { + method: 'GET', + url: `https://test?code=authorization_code}`, + session: { + ['oidc:pinniped.test']: { + state: { handle: 'sessionid', code_verifier: 'foo' }, + }, + }, + } as unknown as express.Request, + }, + implementation, + ), + ).rejects.toThrow( + 'Authentication failed, did not find expected authorization request details in session, req.session["oidc:oidc.test"] is undefined', + ); + }); + + it('fails when request has no session', async () => { + return expect( + oidcAuthenticator.authenticate( + { + req: { + method: 'GET', + url: 'https://test.com', + } as unknown as express.Request, + }, + implementation, + ), + ).rejects.toThrow('authentication requires session support'); + }); + }); + + describe('#refresh', () => { + let refreshRequest: OAuthAuthenticatorRefreshInput; + + beforeEach(() => { + refreshRequest = { + scope: '', + refreshToken: 'otherRefreshToken', + req: {} as express.Request, + }; + }); + + it('gets new refresh token', async () => { + const refreshResponse = await oidcAuthenticator.refresh( + refreshRequest, + implementation, + ); + + expect(refreshResponse.session.refreshToken).toBe('refreshToken'); + }); + + it('gets access token', async () => { + const refreshResponse = await oidcAuthenticator.refresh( + refreshRequest, + implementation, + ); + + expect(refreshResponse.session.accessToken).toBe('accessToken'); + }); + + it('gets id token', async () => { + const refreshResponse = await oidcAuthenticator.refresh( + refreshRequest, + implementation, + ); + + expect(refreshResponse.session.idToken).toBe(idToken); + }); + }); +}); diff --git a/plugins/auth-backend-module-oidc-provider/src/authenticator.ts b/plugins/auth-backend-module-oidc-provider/src/authenticator.ts index 7a7d57d7e2..7a7ebafe5c 100644 --- a/plugins/auth-backend-module-oidc-provider/src/authenticator.ts +++ b/plugins/auth-backend-module-oidc-provider/src/authenticator.ts @@ -23,15 +23,10 @@ import { } from 'openid-client'; import { createOAuthAuthenticator, - decodeOAuthState, - encodeOAuthState, - PassportDoneCallback, PassportOAuthAuthenticatorHelper, PassportOAuthDoneCallback, - PassportOAuthPrivateInfo, - PassportProfile, } from '@backstage/plugin-auth-node'; -import { OidcAuthResult } from '@backstage/plugin-auth-backend'; +// import { BACKSTAGE_SESSION_EXPIRATION } from '@backstage/plugin-auth-backend/src/lib/session'; /** @public */ export const oidcAuthenticator = createOAuthAuthenticator({ @@ -40,9 +35,9 @@ export const oidcAuthenticator = createOAuthAuthenticator({ async initialize({ callbackUrl, config }) { const clientId = config.getString('clientId'); const clientSecret = config.getString('clientSecret'); + const metadataUrl = config.getString('metadataUrl'); const customCallbackUrl = config.getOptionalString('callbackUrl'); const callbackUrl2 = customCallbackUrl || callbackUrl; - const metadataUrl = config.getString('metadataUrl'); const tokenEndpointAuthMethod = config.getOptionalString( 'tokenEndpointAuthMethod', ) as ClientAuthMethod; @@ -66,39 +61,54 @@ export const oidcAuthenticator = createOAuthAuthenticator({ scope: initializedScope || '', }); - const helper = PassportOAuthAuthenticatorHelper.from( - new OidcStrategy( - { - client, - passReqToCallback: false, - }, - ( - tokenset: TokenSet, - userinfo: UserinfoResponse, - done: PassportDoneCallback, - ) => { - if (typeof done !== 'function') { - throw new Error( - 'OIDC IdP must provide a userinfo_endpoint in the metadata response', - ); - } - done( - undefined, - { tokenset, userinfo }, - { - refreshToken: tokenset.refresh_token, - }, + const strategy = new OidcStrategy( + { + client, + passReqToCallback: false, + }, + ( + tokenset: TokenSet, + userinfo: UserinfoResponse, + done: PassportOAuthDoneCallback, + ) => { + if (typeof done !== 'function') { + throw new Error( + 'OIDC IdP must provide a userinfo_endpoint in the metadata response', ); - }, - ), + } + + const expiresInSeconds = !tokenset.expires_in + ? 3600 + : Math.min(tokenset.expires_in!, 3600); + + done( + undefined, + { + fullProfile: { + provider: 'oidc', + id: userinfo.sub, + displayName: userinfo.name!, + }, + accessToken: tokenset.access_token!, + params: { + scope: tokenset.scope!, + expires_in: expiresInSeconds, + }, + }, + { + refreshToken: tokenset.refresh_token, + }, + ); + }, ); - return { helper, client, initializedScope, initializedPrompt }; + const helper = PassportOAuthAuthenticatorHelper.from(strategy); + + return { helper, client, initializedScope, initializedPrompt, strategy }; }, - async start(input, implementation) { - const { initializedScope, initializedPrompt, helper } = - await implementation; + async start(input, ctx) { + const { initializedScope, initializedPrompt, helper, strategy } = await ctx; const options: Record = { scope: input.scope || initializedScope || 'openid profile email', state: input.state, @@ -108,16 +118,48 @@ export const oidcAuthenticator = createOAuthAuthenticator({ options.prompt = prompt; } - return helper.start(input, { - ...options, + return new Promise((resolve, reject) => { + strategy.error = reject; + + return helper + .start(input, { + ...options, + }) + .then(resolve); }); }, - async authenticate(input, implementation) { - return (await implementation).helper.authenticate(input); + async authenticate(input, ctx) { + return (await ctx).helper.authenticate(input); }, - async refresh(input, implementation) { - return (await implementation).helper.refresh(input); + async refresh(input, ctx) { + const { client } = await ctx; + const tokenset = await client.refresh(input.refreshToken); + if (!tokenset.access_token) { + throw new Error('Refresh failed'); + } + const userinfo = await client.userinfo(tokenset.access_token); + + return new Promise((resolve, reject) => { + if (!tokenset.access_token) { + reject(new Error('Refresh Failed')); + } + resolve({ + fullProfile: { + provider: 'oidc', + id: userinfo.sub, + displayName: userinfo.name!, + }, + session: { + accessToken: tokenset.access_token!, + tokenType: tokenset.token_type ?? 'bearer', + scope: tokenset.scope!, + expiresInSeconds: tokenset.expires_in, + idToken: tokenset.id_token, + refreshToken: tokenset.refresh_token, + }, + }); + }); }, }); 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 f85afec0c3..5e01ae343f 100644 --- a/plugins/auth-backend-module-oidc-provider/src/module.test.ts +++ b/plugins/auth-backend-module-oidc-provider/src/module.test.ts @@ -67,10 +67,10 @@ describe('authModuleOidcProvider', () => { }; beforeAll(async () => { - const keyPair = await generateKeyPair('ES256'); + const keyPair = await generateKeyPair('RS256'); const privateKey = await exportJWK(keyPair.privateKey); publicKey = await exportJWK(keyPair.publicKey); - publicKey.alg = privateKey.alg = 'ES256'; + publicKey.alg = privateKey.alg = 'RS256'; idToken = await new SignJWT({ sub: 'test', @@ -109,6 +109,36 @@ describe('authModuleOidcProvider', () => { ctx.set('Location', callbackUrl.toString()), ); }), + rest.get('https://oidc.test/jwks.json', async (_req, res, ctx) => + res(ctx.status(200), ctx.json({ keys: [{ ...publicKey }] })), + ), + rest.post('https://oidc.test/oauth2/token', async (req, res, ctx) => { + return res( + req.headers.get('Authorization') + ? ctx.json({ + access_token: 'accessToken', + id_token: idToken, + refresh_token: 'refreshToken', + scope: 'testScope', + token_type: '', + }) + : ctx.status(401), + ); + }), + rest.get( + 'https://oidc.test/idp/userinfo.openid', + async (_req, res, ctx) => + res( + ctx.status(200), + ctx.json({ + sub: 'test', + name: 'Alice Adams', + given_name: 'Alice', + family_name: 'Adams', + email: 'alice@test.com', + }), + ), + ), ); const secret = 'secret'; @@ -212,5 +242,38 @@ describe('authModuleOidcProvider', () => { }); }); - // TODO: This seems to be the place for integration testing, so far only have test that hit metadata endpoints along with /start but might be missing responses for handler? + it('#authenticate exchanges authorization code for a access_token', async () => { + const agent = request.agent(''); + + // make /start request with audience parameter + const startResponse = await agent.get( + `${appUrl}/api/auth/oidc/start?env=development`, + ); + // follow redirect to authorization endpoint + const authorizationResponse = await agent.get( + startResponse.header.location, + ); + // follow redirect to token_endpoint + const handlerResponse = await agent.get( + authorizationResponse.header.location, + ); + + expect(handlerResponse.text).toContain( + encodeURIComponent( + JSON.stringify({ + type: 'authorization_response', + response: { + profile: { + displayName: 'Alice Adams', + }, + providerInfo: { + accessToken: 'accessToken', + scope: 'testScope', + expiresInSeconds: 3600, + }, + }, + }), + ), + ); + }); }); diff --git a/plugins/auth-backend-module-oidc-provider/src/resolvers.ts b/plugins/auth-backend-module-oidc-provider/src/resolvers.ts index 930ff462f2..5367ab5216 100644 --- a/plugins/auth-backend-module-oidc-provider/src/resolvers.ts +++ b/plugins/auth-backend-module-oidc-provider/src/resolvers.ts @@ -14,12 +14,7 @@ * limitations under the License. */ -import { - createSignInResolverFactory, - OAuthAuthenticatorResult, - PassportProfile, - SignInInfo, -} from '@backstage/plugin-auth-node'; +import { commonSignInResolvers } from '@backstage/plugin-auth-node'; /** * Available sign-in resolvers for the Oidc auth provider. @@ -28,44 +23,16 @@ import { */ export namespace oidcSignInResolvers { /** - * Looks up the user by matching their Oidc username to the entity name. + * A oidc resolver that looks up the user using the local part of + * their email address as the entity name. */ - export const usernameMatchingUserEntityName = createSignInResolverFactory({ - create() { - return async ( - info: SignInInfo>, - ctx, - ) => { - const { result } = info; - - const id = result.fullProfile.username; - if (!id) { - throw new Error(`Oidc user profile does not contain a username`); - } - - return ctx.signInWithCatalogUser({ entityRef: { name: id } }); - }; - }, - }); + export const emailLocalPartMatchingUserEntityName = + commonSignInResolvers.emailLocalPartMatchingUserEntityName; /** - * Looks up the user by matching their email to the `google.com/email` annotation. Still working out this resolver..... + * A oidc resolver that looks up the user using their email address + * as email of the entity. */ - export const emailMatchingUserEntityAnnotation = createSignInResolverFactory({ - create() { - return async (info: SignInInfo, ctx) => { - const email = info.result.iapToken.email; - - if (!email) { - throw new Error('Google IAP sign-in result is missing email'); - } - - return ctx.signInWithCatalogUser({ - annotations: { - 'google.com/email': email, - }, - }); - }; - }, - }); + export const emailMatchingUserEntityProfileEmail = + commonSignInResolvers.emailMatchingUserEntityProfileEmail; } diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index a2b4d01b6c..ca92d4c9dd 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -45,6 +45,7 @@ "@backstage/plugin-auth-backend-module-google-provider": "workspace:^", "@backstage/plugin-auth-backend-module-oauth2-provider": "workspace:^", "@backstage/plugin-auth-backend-module-oauth2-proxy-provider": "workspace:^", + "@backstage/plugin-auth-backend-module-oidc-provider": "workspace:^", "@backstage/plugin-auth-backend-module-okta-provider": "workspace:^", "@backstage/plugin-auth-node": "workspace:^", "@backstage/plugin-catalog-node": "workspace:^", diff --git a/plugins/auth-backend/src/providers/oidc/provider.test.ts b/plugins/auth-backend/src/providers/oidc/provider.test.ts deleted file mode 100644 index 7161ef1d6b..0000000000 --- a/plugins/auth-backend/src/providers/oidc/provider.test.ts +++ /dev/null @@ -1,189 +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 { Config, ConfigReader } from '@backstage/config'; -import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; -import express from 'express'; -import { Session } from 'express-session'; -import { UnsecuredJWT } from 'jose'; -import { rest } from 'msw'; -import { setupServer } from 'msw/node'; -import { ClientMetadata, IssuerMetadata } from 'openid-client'; -import { OAuthAdapter } from '../../lib/oauth'; -import { oidc, OidcAuthProvider, Options } from './provider'; -import { AuthResolverContext } from '../types'; - -const issuerMetadata = { - issuer: 'https://oidc.test', - authorization_endpoint: 'https://oidc.test/as/authorization.oauth2', - token_endpoint: 'https://oidc.test/as/token.oauth2', - revocation_endpoint: 'https://oidc.test/as/revoke_token.oauth2', - userinfo_endpoint: 'https://oidc.test/idp/userinfo.openid', - introspection_endpoint: 'https://oidc.test/as/introspect.oauth2', - jwks_uri: 'https://oidc.test/pf/JWKS', - scopes_supported: ['openid'], - claims_supported: ['email'], - response_types_supported: ['code'], - id_token_signing_alg_values_supported: ['RS256', 'RS512', 'HS256'], - token_endpoint_auth_signing_alg_values_supported: ['RS256', 'RS512', 'HS256'], - request_object_signing_alg_values_supported: ['RS256', 'RS512', 'HS256'], -}; - -const clientMetadata: Options = { - authHandler: async input => ({ - profile: { - displayName: input.userinfo.email, - }, - }), - resolverContext: {} as AuthResolverContext, - callbackUrl: 'https://oidc.test/callback', - clientId: 'testclientid', - clientSecret: 'testclientsecret', - metadataUrl: 'https://oidc.test/.well-known/openid-configuration', - tokenEndpointAuthMethod: 'none', - tokenSignedResponseAlg: 'none', -}; - -describe('OidcAuthProvider', () => { - const worker = setupServer(); - setupRequestMockHandlers(worker); - - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('hit the metadata url', async () => { - const handler = jest.fn((_req, res, ctx) => { - return res( - ctx.status(200), - ctx.set('Content-Type', 'application/json'), - ctx.json(issuerMetadata), - ); - }); - worker.use( - rest.get('https://oidc.test/.well-known/openid-configuration', handler), - ); - const provider = new OidcAuthProvider(clientMetadata); - const { strategy } = (await (provider as any).implementation) as any as { - strategy: { - _client: ClientMetadata; - _issuer: IssuerMetadata; - }; - }; - // Assert that the expected request to the metadaurl was made. - expect(handler).toHaveBeenCalledTimes(1); - const { _client, _issuer } = strategy; - expect(_client.client_id).toBe(clientMetadata.clientId); - expect(_issuer.token_endpoint).toBe(issuerMetadata.token_endpoint); - }); - - it('OidcAuthProvider#handler successfully invokes the oidc endpoints', async () => { - const sub = 'alice'; - const iss = 'https://oidc.test'; - const iat = Date.now(); - const aud = clientMetadata.clientId; - const exp = Date.now() + 10000; - const jwt = await new UnsecuredJWT({ iss, sub, aud, iat, exp }) - .setIssuer(iss) - .setAudience(aud) - .setSubject(sub) - .setIssuedAt(iat) - .setExpirationTime(exp) - .encode(); - const requestSequence: Array = []; - - // The array of expected requests executed by the provider handler - const requests: Array<{ - method: 'get' | 'post'; - url: string; - payload: object; - }> = [ - { - method: 'get', - url: 'https://oidc.test/.well-known/openid-configuration', - payload: issuerMetadata, - }, - { - method: 'post', - url: 'https://oidc.test/as/token.oauth2', - payload: { - id_token: jwt, - access_token: 'test', - authorization_signed_response_alg: 'HS256', - }, - }, - { - method: 'get', - url: 'https://oidc.test/idp/userinfo.openid', - payload: { - sub: 'alice', - email: 'alice@oidc.test', - }, - }, - ]; - worker.use( - ...requests.map(r => { - return rest[r.method](r.url, (_req, res, ctx) => { - requestSequence.push(r.url); - return res( - ctx.status(200), - ctx.set('Content-Type', 'application/json'), - ctx.json(r.payload), - ); - }); - }), - ); - const provider = new OidcAuthProvider(clientMetadata); - const req = { - method: 'GET', - url: 'https://oidc.test/?code=test2', - session: { 'oidc:oidc.test': 'test' } as any as Session, - } as express.Request; - await provider.handler(req); - expect(requestSequence).toEqual([0, 1, 2].map(i => requests[i].url)); - }); - - it('oidc.create', async () => { - const handler = jest.fn((_req, res, ctx) => { - return res( - ctx.status(200), - ctx.set('Content-Type', 'application/json'), - ctx.json(issuerMetadata), - ); - }); - worker.use( - rest.get('https://oidc.test/.well-known/openid-configuration', handler), - ); - const config: Config = new ConfigReader({ - testEnv: { - ...clientMetadata, - metadataUrl: 'https://oidc.test/.well-known/openid-configuration', - }, - } as any); - const provider = oidc.create()({ - globalConfig: { - appUrl: 'https://oidc.test', - baseUrl: 'https://oidc.test', - }, - config, - } as any) as OAuthAdapter; - expect(provider.start).toBeDefined(); - // Cast provider as any here to be able to inspect private members - await (provider as any).handlers.get('testEnv').handlers.implementation; - // Assert that the expected request to the metadaurl was made. - expect(handler).toHaveBeenCalledTimes(1); - }); -}); diff --git a/plugins/auth-backend/src/providers/oidc/provider.ts b/plugins/auth-backend/src/providers/oidc/provider.ts index b6608aebbd..ce8809c55a 100644 --- a/plugins/auth-backend/src/providers/oidc/provider.ts +++ b/plugins/auth-backend/src/providers/oidc/provider.ts @@ -14,278 +14,282 @@ * limitations under the License. */ -import express from 'express'; -import { - Client, - ClientAuthMethod, - Issuer, - Strategy as OidcStrategy, - TokenSet, - UserinfoResponse, -} from 'openid-client'; -import { - encodeState, - OAuthAdapter, - OAuthEnvironmentHandler, - OAuthHandlers, - OAuthProviderOptions, - OAuthRefreshRequest, - OAuthResponse, - OAuthStartRequest, -} from '../../lib/oauth'; -import { - executeFrameHandlerStrategy, - executeRedirectStrategy, - PassportDoneCallback, -} from '../../lib/passport'; -import { - AuthHandler, - AuthResolverContext, - OAuthStartResponse, - SignInResolver, -} 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 { BACKSTAGE_SESSION_EXPIRATION } from '../../lib/session'; + adaptLegacyOAuthHandler, + adaptLegacyOAuthSignInResolver, +} from '../../lib/legacy'; -type PrivateInfo = { - refreshToken?: string; -}; +import { oidcAuthenticator } from '@backstage/plugin-auth-backend-module-oidc-provider'; -type OidcImpl = { - strategy: OidcStrategy; - client: Client; -}; +// type PrivateInfo = { +// refreshToken?: string; +// }; -/** - * authentication result for the OIDC which includes the token set and user information (a profile response sent by OIDC server) - * @public - */ -export type OidcAuthResult = { - tokenset: TokenSet; - userinfo: UserinfoResponse; -}; +// type OidcImpl = { +// strategy: OidcStrategy; +// client: Client; +// }; -export type Options = OAuthProviderOptions & { - metadataUrl: string; - scope?: string; - prompt?: string; - tokenEndpointAuthMethod?: ClientAuthMethod; - tokenSignedResponseAlg?: string; - signInResolver?: SignInResolver; - authHandler: AuthHandler; - resolverContext: AuthResolverContext; -}; +// /** +// * authentication result for the OIDC which includes the token set and user information (a profile response sent by OIDC server) +// * @public +// */ +// export type OidcAuthResult = { +// tokenset: TokenSet; +// userinfo: UserinfoResponse; +// }; -export class OidcAuthProvider implements OAuthHandlers { - private readonly implementation: Promise; - private readonly scope?: string; - private readonly prompt?: string; +// export type Options = OAuthProviderOptions & { +// metadataUrl: string; +// scope?: string; +// prompt?: string; +// tokenEndpointAuthMethod?: ClientAuthMethod; +// tokenSignedResponseAlg?: string; +// signInResolver?: SignInResolver; +// authHandler: AuthHandler; +// resolverContext: AuthResolverContext; +// }; - private readonly signInResolver?: SignInResolver; - private readonly authHandler: AuthHandler; - private readonly resolverContext: AuthResolverContext; +// export class OidcAuthProvider implements OAuthHandlers { +// private readonly implementation: Promise; +// private readonly scope?: string; +// private readonly prompt?: string; - constructor(options: Options) { - this.implementation = this.setupStrategy(options); - this.scope = options.scope; - this.prompt = options.prompt; - this.signInResolver = options.signInResolver; - this.authHandler = options.authHandler; - this.resolverContext = options.resolverContext; - } +// private readonly signInResolver?: SignInResolver; +// private readonly authHandler: AuthHandler; +// private readonly resolverContext: AuthResolverContext; - async start(req: OAuthStartRequest): Promise { - const { strategy } = await this.implementation; - const options: Record = { - scope: req.scope || this.scope || 'openid profile email', - state: encodeState(req.state), - }; - const prompt = this.prompt || 'none'; - if (prompt !== 'auto') { - options.prompt = prompt; - } - return await executeRedirectStrategy(req, strategy, options); - } +// constructor(options: Options) { +// this.implementation = this.setupStrategy(options); +// this.scope = options.scope; +// this.prompt = options.prompt; +// this.signInResolver = options.signInResolver; +// this.authHandler = options.authHandler; +// this.resolverContext = options.resolverContext; +// } - async handler(req: express.Request) { - const { strategy } = await this.implementation; - const { result, privateInfo } = await executeFrameHandlerStrategy< - OidcAuthResult, - PrivateInfo - >(req, strategy); +// async start(req: OAuthStartRequest): Promise { +// const { strategy } = await this.implementation; +// const options: Record = { +// scope: req.scope || this.scope || 'openid profile email', +// state: encodeState(req.state), +// }; +// const prompt = this.prompt || 'none'; +// if (prompt !== 'auto') { +// options.prompt = prompt; +// } +// return await executeRedirectStrategy(req, strategy, options); +// } - return { - response: await this.handleResult(result), - refreshToken: privateInfo.refreshToken, - }; - } +// async handler(req: express.Request) { +// const { strategy } = await this.implementation; +// const { result, privateInfo } = await executeFrameHandlerStrategy< +// OidcAuthResult, +// PrivateInfo +// >(req, strategy); - async refresh(req: OAuthRefreshRequest) { - const { client } = await this.implementation; - const tokenset = await client.refresh(req.refreshToken); - if (!tokenset.access_token) { - throw new Error('Refresh failed'); - } - if (!tokenset.scope) { - tokenset.scope = req.scope; - } - const userinfo = await client.userinfo(tokenset.access_token); + // async refresh(req: OAuthRefreshRequest) { + // const { client } = await this.implementation; + // const tokenset = await client.refresh(req.refreshToken); + // if (!tokenset.access_token) { + // throw new Error('Refresh failed'); + // } + // if (!tokenset.scope) { + // tokenset.scope = req.scope; + // } + // const userinfo = await client.userinfo(tokenset.access_token); +// return { +// response: await this.handleResult(result), +// refreshToken: privateInfo.refreshToken, +// }; +// } - return { - response: await this.handleResult({ tokenset, userinfo }), - refreshToken: tokenset.refresh_token, - }; - } +// async refresh(req: OAuthRefreshRequest) { +// const { client } = await this.implementation; +// const tokenset = await client.refresh(req.refreshToken); +// if (!tokenset.access_token) { +// throw new Error('Refresh failed'); +// } +// const userinfo = await client.userinfo(tokenset.access_token); - private async setupStrategy(options: Options): Promise { - const issuer = await Issuer.discover(options.metadataUrl); - const client = new issuer.Client({ - access_type: 'offline', // this option must be passed to provider to receive a refresh token - client_id: options.clientId, - client_secret: options.clientSecret, - redirect_uris: [options.callbackUrl], - response_types: ['code'], - token_endpoint_auth_method: - options.tokenEndpointAuthMethod || 'client_secret_basic', - id_token_signed_response_alg: options.tokenSignedResponseAlg || 'RS256', - scope: options.scope || '', - }); +// return { +// response: await this.handleResult({ tokenset, userinfo }), +// refreshToken: tokenset.refresh_token, +// }; +// } - const strategy = new OidcStrategy( - { - client, - passReqToCallback: false, - }, - ( - tokenset: TokenSet, - userinfo: UserinfoResponse, - done: PassportDoneCallback, - ) => { - if (typeof done !== 'function') { - throw new Error( - 'OIDC IdP must provide a userinfo_endpoint in the metadata response', - ); - } - done( - undefined, - { tokenset, userinfo }, - { - refreshToken: tokenset.refresh_token, - }, - ); - }, - ); - strategy.error = console.error; - return { strategy, client }; - } +// private async setupStrategy(options: Options): Promise { +// const issuer = await Issuer.discover(options.metadataUrl); +// const client = new issuer.Client({ +// access_type: 'offline', // this option must be passed to provider to receive a refresh token +// client_id: options.clientId, +// client_secret: options.clientSecret, +// redirect_uris: [options.callbackUrl], +// response_types: ['code'], +// token_endpoint_auth_method: +// options.tokenEndpointAuthMethod || 'client_secret_basic', +// id_token_signed_response_alg: options.tokenSignedResponseAlg || 'RS256', +// scope: options.scope || '', +// }); - // Use this function to grab the user profile info from the token - // Then populate the profile with it - private async handleResult(result: OidcAuthResult): Promise { - const { profile } = await this.authHandler(result, this.resolverContext); +// const strategy = new OidcStrategy( +// { +// client, +// passReqToCallback: false, +// }, +// ( +// tokenset: TokenSet, +// userinfo: UserinfoResponse, +// done: PassportDoneCallback, +// ) => { +// if (typeof done !== 'function') { +// throw new Error( +// 'OIDC IdP must provide a userinfo_endpoint in the metadata response', +// ); +// } +// done( +// undefined, +// { tokenset, userinfo }, +// { +// refreshToken: tokenset.refresh_token, +// }, +// ); +// }, +// ); +// strategy.error = console.error; +// return { strategy, client }; +// } - const expiresInSeconds = - result.tokenset.expires_in === undefined - ? BACKSTAGE_SESSION_EXPIRATION - : Math.min(result.tokenset.expires_in, BACKSTAGE_SESSION_EXPIRATION); +// Use this function to grab the user profile info from the token +// Then populate the profile with it +// private async handleResult(result: OidcAuthResult): Promise { +// const { profile } = await this.authHandler(result, this.resolverContext); - let backstageIdentity = undefined; - if (this.signInResolver) { - backstageIdentity = await this.signInResolver( - { - result, - profile, - }, - this.resolverContext, - ); - } +// const expiresInSeconds = +// result.tokenset.expires_in === undefined +// ? BACKSTAGE_SESSION_EXPIRATION +// : Math.min(result.tokenset.expires_in, BACKSTAGE_SESSION_EXPIRATION); - return { - backstageIdentity, - providerInfo: { - idToken: result.tokenset.id_token, - accessToken: result.tokenset.access_token!, - scope: result.tokenset.scope!, - expiresInSeconds, - }, - profile, - }; - } -} +// let backstageIdentity = undefined; +// if (this.signInResolver) { +// backstageIdentity = await this.signInResolver( +// { +// result, +// profile, +// }, +// this.resolverContext, +// ); +// } + +// return { +// backstageIdentity, +// providerInfo: { +// idToken: result.tokenset.id_token, +// accessToken: result.tokenset.access_token!, +// scope: result.tokenset.scope!, +// expiresInSeconds, +// }, +// profile, +// }; +// } +// } /** * Auth provider integration for generic OpenID Connect auth * * @public */ +// export const oidc = createAuthProviderIntegration({ +// create(options?: { +// authHandler?: AuthHandler; + +// signIn?: { +// resolver: SignInResolver; +// }; +// }) { +// return ({ providerId, globalConfig, config, resolverContext }) => +// OAuthEnvironmentHandler.mapConfig(config, envConfig => { +// const clientId = envConfig.getString('clientId'); +// const clientSecret = envConfig.getString('clientSecret'); +// const customCallbackUrl = envConfig.getOptionalString('callbackUrl'); +// const callbackUrl = +// customCallbackUrl || +// `${globalConfig.baseUrl}/${providerId}/handler/frame`; +// const metadataUrl = envConfig.getString('metadataUrl'); +// const tokenEndpointAuthMethod = envConfig.getOptionalString( +// 'tokenEndpointAuthMethod', +// ) as ClientAuthMethod; +// const tokenSignedResponseAlg = envConfig.getOptionalString( +// 'tokenSignedResponseAlg', +// ); +// const scope = envConfig.getOptionalString('scope'); +// const prompt = envConfig.getOptionalString('prompt'); + +// const authHandler: AuthHandler = options?.authHandler +// ? options.authHandler +// : async ({ userinfo }) => ({ +// profile: { +// displayName: userinfo.name, +// email: userinfo.email, +// picture: userinfo.picture, +// }, +// }); + +// const provider = new OidcAuthProvider({ +// clientId, +// clientSecret, +// callbackUrl, +// tokenEndpointAuthMethod, +// tokenSignedResponseAlg, +// metadataUrl, +// scope, +// prompt, +// signInResolver: options?.signIn?.resolver, +// authHandler, +// resolverContext, +// }); + +// return OAuthAdapter.fromConfig(globalConfig, provider, { +// providerId, +// callbackUrl, +// }); +// }); +// }, +// 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, +// }, +// }); + export const oidc = createAuthProviderIntegration({ create(options?: { - authHandler?: AuthHandler; + /** + * 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. + */ signIn?: { - resolver: SignInResolver; + resolver: SignInResolver; }; }) { - return ({ providerId, globalConfig, config, resolverContext }) => - OAuthEnvironmentHandler.mapConfig(config, envConfig => { - const clientId = envConfig.getString('clientId'); - const clientSecret = envConfig.getString('clientSecret'); - const customCallbackUrl = envConfig.getOptionalString('callbackUrl'); - const callbackUrl = - customCallbackUrl || - `${globalConfig.baseUrl}/${providerId}/handler/frame`; - const metadataUrl = envConfig.getString('metadataUrl'); - const tokenEndpointAuthMethod = envConfig.getOptionalString( - 'tokenEndpointAuthMethod', - ) as ClientAuthMethod; - const tokenSignedResponseAlg = envConfig.getOptionalString( - 'tokenSignedResponseAlg', - ); - const scope = envConfig.getOptionalString('scope'); - const prompt = envConfig.getOptionalString('prompt'); - - const authHandler: AuthHandler = options?.authHandler - ? options.authHandler - : async ({ userinfo }) => ({ - profile: { - displayName: userinfo.name, - email: userinfo.email, - picture: userinfo.picture, - }, - }); - - const provider = new OidcAuthProvider({ - clientId, - clientSecret, - callbackUrl, - tokenEndpointAuthMethod, - tokenSignedResponseAlg, - metadataUrl, - scope, - prompt, - signInResolver: options?.signIn?.resolver, - authHandler, - resolverContext, - }); - - return OAuthAdapter.fromConfig(globalConfig, provider, { - providerId, - callbackUrl, - }); - }); - }, - 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, + return createOAuthProviderFactory({ + authenticator: oidcAuthenticator, + profileTransform: adaptLegacyOAuthHandler(options?.authHandler), + signInResolver: adaptLegacyOAuthSignInResolver(options?.signIn?.resolver), + }); }, }); diff --git a/yarn.lock b/yarn.lock index 6e6721b99e..16979362ca 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4672,7 +4672,7 @@ __metadata: languageName: unknown linkType: soft -"@backstage/plugin-auth-backend-module-oidc-provider@workspace:plugins/auth-backend-module-oidc-provider": +"@backstage/plugin-auth-backend-module-oidc-provider@workspace:^, @backstage/plugin-auth-backend-module-oidc-provider@workspace:plugins/auth-backend-module-oidc-provider": version: 0.0.0-use.local resolution: "@backstage/plugin-auth-backend-module-oidc-provider@workspace:plugins/auth-backend-module-oidc-provider" dependencies: @@ -4777,6 +4777,7 @@ __metadata: "@backstage/plugin-auth-backend-module-google-provider": "workspace:^" "@backstage/plugin-auth-backend-module-oauth2-provider": "workspace:^" "@backstage/plugin-auth-backend-module-oauth2-proxy-provider": "workspace:^" + "@backstage/plugin-auth-backend-module-oidc-provider": "workspace:^" "@backstage/plugin-auth-backend-module-okta-provider": "workspace:^" "@backstage/plugin-auth-node": "workspace:^" "@backstage/plugin-catalog-node": "workspace:^" @@ -31276,13 +31277,20 @@ __metadata: languageName: node linkType: hard -"jose@npm:^4.14.6, jose@npm:^4.15.4, jose@npm:^4.6.0": +"jose@npm:^4.14.6, jose@npm:^4.15.1": version: 4.15.4 resolution: "jose@npm:4.15.4" checksum: dccad91cb3357f36423774a0b89ad830dd84b31090de65cd139b85488439f16a00f8c59c0773825e8a1adb0dd9d13ad725ad66e6ea33880ecb3959bb99e1ea5b languageName: node linkType: hard +"jose@npm:^4.6.0": + version: 4.15.2 + resolution: "jose@npm:4.15.2" + checksum: 8f0cab1eef31243abe14a935b2b330cd95f10f9b69808fd642088ae5000e50e566664934537d2c6413ab2f6b54acd8265a5033da05157aa1260c5f1d7e57fab0 + languageName: node + linkType: hard + "joycon@npm:^3.0.1": version: 3.1.0 resolution: "joycon@npm:3.1.0" @@ -35655,19 +35663,7 @@ __metadata: languageName: node linkType: hard -"openid-client@npm:^5.2.1, openid-client@npm:^5.3.0, openid-client@npm:^5.4.3": - version: 5.6.4 - resolution: "openid-client@npm:5.6.4" - dependencies: - jose: ^4.15.4 - lru-cache: ^6.0.0 - object-hash: ^2.2.0 - oidc-token-hash: ^5.0.3 - checksum: 69843f078dacbbc6bad6d65ca6689414ac73f095dfe2f8e606822e6cfc9d9cd7d0dfaf2649352eda604653806f0ea65326ad2d6266da897e4740ec93d26d21f6 - languageName: node - linkType: hard - -"openid-client@npm:^5.5.0": +"openid-client@npm:^5.2.1, openid-client@npm:^5.3.0, openid-client@npm:^5.5.0": version: 5.5.0 resolution: "openid-client@npm:5.5.0" dependencies: @@ -35679,6 +35675,18 @@ __metadata: languageName: node linkType: hard +"openid-client@npm:^5.4.3": + version: 5.6.1 + resolution: "openid-client@npm:5.6.1" + dependencies: + jose: ^4.15.1 + lru-cache: ^6.0.0 + object-hash: ^2.2.0 + oidc-token-hash: ^5.0.3 + checksum: 9d939cec57540e6dd3f67e9a248ec5ecec3b439b7ab5bd2e9fb4481bd03e8d030deedd87a447348194be7f3e93e84085841b0414033caf86479870f526cdbc2f + languageName: node + linkType: hard + "oppa@npm:^0.4.0": version: 0.4.0 resolution: "oppa@npm:0.4.0"