diff --git a/plugins/auth-backend/src/service/OidcRouter.test.ts b/plugins/auth-backend/src/service/OidcRouter.test.ts index 8e53abbcaf..ccc2426035 100644 --- a/plugins/auth-backend/src/service/OidcRouter.test.ts +++ b/plugins/auth-backend/src/service/OidcRouter.test.ts @@ -23,6 +23,7 @@ import Router from 'express-promise-router'; import request from 'supertest'; import { OidcRouter } from './OidcRouter'; import { UserInfoDatabase } from '../database/UserInfoDatabase'; +import { OidcDatabase } from '../database/OidcDatabase'; describe('OidcRouter', () => { describe('/v1/userinfo', () => { @@ -37,7 +38,12 @@ describe('OidcRouter', () => { }), } as unknown as UserInfoDatabase; - const mockOidc = {}; + const mockOidc = { + createClient: jest.fn().mockResolvedValue({ + clientId: 'test', + clientSecret: 'test', + }), + } as unknown as OidcDatabase; const { server } = await startTestBackend({ features: [ @@ -55,6 +61,7 @@ describe('OidcRouter', () => { tokenIssuer: {} as any, baseUrl: 'http://localhost:7000', userInfo: mockUserInfo, + oidc: mockOidc, }).getRouter(), ); httpRouter.use(router); @@ -101,6 +108,13 @@ describe('OidcRouter', () => { }), } as unknown as UserInfoDatabase; + const mockOidc = { + createClient: jest.fn().mockResolvedValue({ + clientId: 'test', + clientSecret: 'test', + }), + } as unknown as OidcDatabase; + const { server } = await startTestBackend({ features: [ createBackendPlugin({ @@ -117,6 +131,7 @@ describe('OidcRouter', () => { tokenIssuer: {} as any, baseUrl: 'http://localhost:7000', userInfo: mockUserInfo, + oidc: mockOidc, }).getRouter(), ); httpRouter.use(router); diff --git a/plugins/auth-backend/src/service/OidcRouter.ts b/plugins/auth-backend/src/service/OidcRouter.ts index 219e026a53..721dedf8c3 100644 --- a/plugins/auth-backend/src/service/OidcRouter.ts +++ b/plugins/auth-backend/src/service/OidcRouter.ts @@ -19,7 +19,6 @@ import { AuthenticationError, isError } from '@backstage/errors'; import { AuthService } from '@backstage/backend-plugin-api'; import { TokenIssuer } from '../identity/types'; import { UserInfoDatabase } from '../database/UserInfoDatabase'; -import { rest } from 'lodash'; import { OidcDatabase } from '../database/OidcDatabase'; export class OidcRouter { @@ -47,11 +46,118 @@ export class OidcRouter { res.json({ keys }); }); - router.get('/v1/token', (_req, res) => { - res.status(501).send('Not Implemented'); + router.get('/v1/authorize', async (req, res) => { + // todo(blam): maybe add zod types for validating input + const { + client_id: clientId, + redirect_uri: redirectUri, + response_type: responseType, + scope, + state, + nonce, + code_challenge: codeChallenge, + code_challenge_method: codeChallengeMethod, + } = req.query; + + if (!clientId || !redirectUri || !responseType) { + return res.status(400).json({ + error: 'invalid_request', + error_description: + 'Missing required parameters: client_id, redirect_uri, response_type', + }); + } + + try { + // For simplicity, we'll use a default user entity ref for now + // In a real implementation, this should be obtained from the authenticated user + const userEntityRef = 'user:default/guest'; + + const { redirectUrl } = await this.oidc.authorize({ + clientId: clientId as string, + redirectUri: redirectUri as string, + responseType: responseType as string, + scope: scope as string, + state: state as string, + nonce: nonce as string, + codeChallenge: codeChallenge as string, + codeChallengeMethod: codeChallengeMethod as string, + userEntityRef, + }); + + return res.redirect(redirectUrl); + } catch (error) { + const errorParams = new URLSearchParams(); + errorParams.append( + 'error', + isError(error) ? error.name : 'server_error', + ); + errorParams.append( + 'error_description', + isError(error) ? error.message : 'Unknown error', + ); + if (state) { + errorParams.append('state', state as string); + } + + const redirectUrl = new URL(redirectUri as string); + redirectUrl.search = errorParams.toString(); + return res.redirect(redirectUrl.toString()); + } }); - // This endpoint doesn't use the regular HttpAuthoidc, since the contract + router.post('/v1/token', async (req, res) => { + // todo(blam): maybe add zod types for validating input + const { + grant_type: grantType, + code, + client_id: clientId, + client_secret: clientSecret, + redirect_uri: redirectUri, + code_verifier: codeVerifier, + } = req.body; + + if (!grantType || !code || !clientId || !clientSecret || !redirectUri) { + return res.status(400).json({ + error: 'invalid_request', + error_description: 'Missing required parameters', + }); + } + + try { + const result = await this.oidc.exchangeCodeForToken({ + code, + clientId, + clientSecret, + redirectUri, + codeVerifier, + grantType, + }); + + return res.json(result); + } catch (error) { + if (isError(error)) { + if (error.name === 'AuthenticationError') { + return res.status(401).json({ + error: 'invalid_client', + error_description: error.message, + }); + } + if (error.name === 'InputError') { + return res.status(400).json({ + error: 'invalid_request', + error_description: error.message, + }); + } + } + + return res.status(500).json({ + error: 'server_error', + error_description: isError(error) ? error.message : 'Unknown error', + }); + } + }); + + // This endpoint doesn't use the regular HttpAuth, since the contract // is specifically for the header to be communicated in the Authorization // header, regardless of token type router.get('/v1/userinfo', async (req, res) => { @@ -71,7 +177,7 @@ export class OidcRouter { res.json(userInfo); }); - router.get('/v1/register', async (req, res) => { + router.post('/v1/register', async (req, res) => { // todo(blam): maybe add zod types for validating input const registrationRequest = req.body; if (!registrationRequest.redirect_uris?.length) { diff --git a/plugins/auth-backend/src/service/OidcService.ts b/plugins/auth-backend/src/service/OidcService.ts index 919c0dac64..104c2290ae 100644 --- a/plugins/auth-backend/src/service/OidcService.ts +++ b/plugins/auth-backend/src/service/OidcService.ts @@ -16,10 +16,11 @@ import { AuthService } from '@backstage/backend-plugin-api'; import { TokenIssuer } from '../identity/types'; import { UserInfoDatabase } from '../database/UserInfoDatabase'; -import { InputError } from '@backstage/errors'; +import { InputError, AuthenticationError } from '@backstage/errors'; import { decodeJwt } from 'jose'; import crypto from 'crypto'; import { OidcDatabase } from '../database/OidcDatabase'; +import { DateTime } from 'luxon'; export class OidcService { private constructor( @@ -52,7 +53,7 @@ export class OidcService { token_endpoint: `${this.baseUrl}/v1/token`, userinfo_endpoint: `${this.baseUrl}/v1/userinfo`, jwks_uri: `${this.baseUrl}/.well-known/jwks.json`, - response_types_supported: ['id_token'], + response_types_supported: ['code', 'id_token'], subject_types_supported: ['public'], id_token_signing_alg_values_supported: [ 'RS256', @@ -67,11 +68,15 @@ export class OidcService { 'EdDSA', ], scopes_supported: ['openid'], - token_endpoint_auth_methods_supported: [], + token_endpoint_auth_methods_supported: [ + 'client_secret_basic', + 'client_secret_post', + ], claims_supported: ['sub', 'ent'], - grant_types_supported: [], + grant_types_supported: ['authorization_code'], authorization_endpoint: `${this.baseUrl}/v1/authorize`, registration_endpoint: `${this.baseUrl}/v1/register`, + code_challenge_methods_supported: ['S256', 'plain'], }; } @@ -117,4 +122,191 @@ export class OidcService { scope: opts.scope, }); } + + public async authorize(opts: { + clientId: string; + redirectUri: string; + responseType: string; + scope?: string; + state?: string; + nonce?: string; + codeChallenge?: string; + codeChallengeMethod?: string; + userEntityRef: string; + }) { + const { + clientId, + redirectUri, + responseType, + scope, + state, + nonce, + codeChallenge, + codeChallengeMethod, + userEntityRef, + } = opts; + + if (responseType !== 'code') { + throw new InputError('Only authorization code flow is supported'); + } + + const client = await this.oidc.getClient({ clientId }); + if (!client) { + throw new InputError('Invalid client_id'); + } + + if (!client.redirectUris.includes(redirectUri)) { + throw new InputError('Invalid redirect_uri'); + } + + if (codeChallenge) { + if ( + !codeChallengeMethod || + !['S256', 'plain'].includes(codeChallengeMethod) + ) { + throw new InputError('Invalid code_challenge_method'); + } + } + + const authorizationCode = crypto.randomBytes(32).toString('base64url'); + const expiresAt = DateTime.now().plus({ minutes: 10 }).toISO(); + + await this.oidc.createAuthorizationCode({ + code: authorizationCode, + clientId, + userEntityRef, + redirectUri, + scope, + codeChallenge, + codeChallengeMethod, + nonce, + expiresAt, + }); + + const redirectUrl = new URL(redirectUri); + redirectUrl.searchParams.append('code', authorizationCode); + if (state) { + redirectUrl.searchParams.append('state', state); + } + + return { + redirectUrl: redirectUrl.toString(), + }; + } + + public async exchangeCodeForToken(params: { + code: string; + clientId: string; + clientSecret: string; + redirectUri: string; + codeVerifier?: string; + grantType: string; + }) { + const { + code, + clientId, + clientSecret, + redirectUri, + codeVerifier, + grantType, + } = params; + + if (grantType !== 'authorization_code') { + throw new InputError('Unsupported grant type'); + } + + const client = await this.oidc.getClient({ clientId }); + if (!client) { + throw new AuthenticationError('Invalid client'); + } + + if (client.clientSecret !== clientSecret) { + throw new AuthenticationError('Invalid client credentials'); + } + + const authCode = await this.oidc.getAuthorizationCode({ code }); + if (!authCode) { + throw new AuthenticationError('Invalid authorization code'); + } + + if (DateTime.fromISO(authCode.expiresAt) < DateTime.now()) { + throw new AuthenticationError('Authorization code expired'); + } + + if (authCode.used) { + throw new AuthenticationError('Authorization code already used'); + } + + if (authCode.clientId !== clientId) { + throw new AuthenticationError('Client ID mismatch'); + } + + if (authCode.redirectUri !== redirectUri) { + throw new AuthenticationError('Redirect URI mismatch'); + } + + if (authCode.codeChallenge) { + if (!codeVerifier) { + throw new AuthenticationError('Code verifier required for PKCE'); + } + + if ( + !this.verifyPkce( + authCode.codeChallenge, + codeVerifier, + authCode.codeChallengeMethod, + ) + ) { + throw new AuthenticationError('Invalid code verifier'); + } + } + + await this.oidc.updateAuthorizationCode({ + code, + used: true, + }); + + const accessTokenId = crypto.randomUUID(); + const expiresAt = DateTime.now().plus({ hours: 1 }).toISO(); + + await this.oidc.createAccessToken({ + tokenId: accessTokenId, + clientId, + userEntityRef: authCode.userEntityRef, + scope: authCode.scope, + expiresAt, + }); + + const { token } = await this.tokenIssuer.issueToken({ + claims: { + sub: authCode.userEntityRef, + }, + }); + + return { + access_token: token, + token_type: 'Bearer', + expires_in: 3600, + id_token: token, + scope: authCode.scope || 'openid', + }; + } + + private verifyPkce( + codeChallenge: string, + codeVerifier: string, + method?: string, + ): boolean { + if (!method || method === 'plain') { + return codeChallenge === codeVerifier; + } + + if (method === 'S256') { + const hash = crypto.createHash('sha256').update(codeVerifier).digest(); + const base64urlHash = hash.toString('base64url'); + return codeChallenge === base64urlHash; + } + + return false; + } } diff --git a/plugins/auth-backend/src/service/router.ts b/plugins/auth-backend/src/service/router.ts index 5012c90106..f81390daf8 100644 --- a/plugins/auth-backend/src/service/router.ts +++ b/plugins/auth-backend/src/service/router.ts @@ -40,6 +40,7 @@ import { StaticTokenIssuer } from '../identity/StaticTokenIssuer'; import { StaticKeyStore } from '../identity/StaticKeyStore'; import { bindProviderRouters, ProviderFactories } from '../providers/router'; import { OidcRouter } from './OidcRouter'; +import { OidcDatabase } from '../database/OidcDatabase'; interface RouterOptions { logger: LoggerService; @@ -147,11 +148,14 @@ export async function createRouter( userInfo, }); + const oidc = await OidcDatabase.create({ database }); + const oidcRouter = OidcRouter.create({ auth: options.auth, tokenIssuer, baseUrl: authUrl, userInfo, + oidc, }); router.use(oidcRouter.getRouter());