From ff251064ae57fce729d3cf7a0164523e28e89ffb Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Thu, 3 Jul 2025 19:36:24 +0200 Subject: [PATCH] chore: implementing the routers Signed-off-by: benjdlambert --- .../src/service/OidcRouter.test.ts | 4 + .../auth-backend/src/service/OidcRouter.ts | 183 ++++++++++++++++-- plugins/auth-backend/src/service/router.ts | 1 + 3 files changed, 177 insertions(+), 11 deletions(-) diff --git a/plugins/auth-backend/src/service/OidcRouter.test.ts b/plugins/auth-backend/src/service/OidcRouter.test.ts index ccc2426035..c0f1543487 100644 --- a/plugins/auth-backend/src/service/OidcRouter.test.ts +++ b/plugins/auth-backend/src/service/OidcRouter.test.ts @@ -60,6 +60,8 @@ describe('OidcRouter', () => { auth, tokenIssuer: {} as any, baseUrl: 'http://localhost:7000', + appUrl: 'http://localhost:3000', + logger: mockServices.logger.mock(), userInfo: mockUserInfo, oidc: mockOidc, }).getRouter(), @@ -130,6 +132,8 @@ describe('OidcRouter', () => { auth, tokenIssuer: {} as any, baseUrl: 'http://localhost:7000', + appUrl: 'http://localhost:3000', + logger: mockServices.logger.mock(), userInfo: mockUserInfo, oidc: mockOidc, }).getRouter(), diff --git a/plugins/auth-backend/src/service/OidcRouter.ts b/plugins/auth-backend/src/service/OidcRouter.ts index 0e14b91d79..ba2f2ccef9 100644 --- a/plugins/auth-backend/src/service/OidcRouter.ts +++ b/plugins/auth-backend/src/service/OidcRouter.ts @@ -26,17 +26,25 @@ export class OidcRouter { private constructor( private readonly oidc: OidcService, private readonly logger: LoggerService, + private readonly auth: AuthService, + private readonly appUrl: string, ) {} static create(options: { auth: AuthService; tokenIssuer: TokenIssuer; baseUrl: string; + appUrl: string; logger: LoggerService; userInfo: UserInfoDatabase; oidc: OidcDatabase; }) { - return new OidcRouter(OidcService.create(options), options.logger); + return new OidcRouter( + OidcService.create(options), + options.logger, + options.auth, + options.appUrl, + ); } public getRouter() { @@ -44,15 +52,25 @@ export class OidcRouter { router.use(json()); + // OpenID Provider Configuration endpoint + // https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig + // Returns the OpenID Provider Configuration document containing metadata about the provider router.get('/.well-known/openid-configuration', (_req, res) => { res.json(this.oidc.getConfiguration()); }); + // JSON Web Key Set endpoint + // https://openid.net/specs/openid-connect-core-1_0.html#rfc.section.10.1.1 + // Returns the public keys used to verify JWTs issued by this provider router.get('/.well-known/jwks.json', async (_req, res) => { const { keys } = await this.oidc.listPublicKeys(); res.json({ keys }); }); + // Authorization endpoint + // https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest + // Handles the initial authorization request from the client, validates parameters, + // and redirects to the consent page for user approval router.get('/v1/authorize', async (req, res) => { // todo(blam): maybe add zod types for validating input const { @@ -76,11 +94,7 @@ export class OidcRouter { } try { - // use default user entity ref for now, as we need a redirect to the frontend plugin - // for the consent flow in order to issue the right token for the right user. - const userEntityRef = 'user:default/guest'; - - const { redirectUrl } = await this.oidc.authorize({ + const result = await this.oidc.createConsentRequest({ clientId: clientId as string, redirectUri: redirectUri as string, responseType: responseType as string, @@ -89,10 +103,14 @@ export class OidcRouter { nonce: nonce as string, codeChallenge: codeChallenge as string, codeChallengeMethod: codeChallengeMethod as string, - userEntityRef, }); - return res.redirect(redirectUrl); + // todo(blam): maybe this URL could be overridable by config if + // the plugin is mounted somewhere else? + const consentUrl = new URL('/oidc/consent', this.appUrl); + consentUrl.searchParams.append('consent_id', result.consentRequestId); + + return res.redirect(consentUrl.toString()); } catch (error) { const errorParams = new URLSearchParams(); errorParams.append( @@ -113,6 +131,146 @@ export class OidcRouter { } }); + // Consent request details endpoint + // Returns consent request details for the frontend consent page + router.get('/v1/consent/:consentId', async (req, res) => { + const { consentId } = req.params; + + if (!consentId) { + return res.status(400).json({ + error: 'invalid_request', + error_description: 'Missing consent ID', + }); + } + + try { + const consentRequest = await this.oidc.getConsentRequest({ + consentRequestId: consentId, + }); + + return res.json({ + id: consentRequest.id, + clientName: consentRequest.clientName, + scope: consentRequest.scope, + redirectUri: consentRequest.redirectUri, + }); + } catch (error) { + this.logger.error( + `Failed to get consent request: ${ + isError(error) ? error.message : 'Unknown error' + }`, + error, + ); + return res.status(404).json({ + error: 'not_found', + error_description: 'Consent request not found or expired', + }); + } + }); + + // Consent approval endpoint + // Handles user approval of consent requests and generates authorization codes + router.post('/v1/consent/:consentId/approve', async (req, res) => { + const { consentId } = req.params; + + if (!consentId) { + return res.status(400).json({ + error: 'invalid_request', + error_description: 'Missing consent ID', + }); + } + + try { + const authHeader = req.headers.authorization; + if (!authHeader?.startsWith('Bearer ')) { + return res.status(401).json({ + error: 'unauthorized', + error_description: 'Bearer token required', + }); + } + + const token = authHeader.substring(7); + const credentials = await this.auth.authenticate(token); + if (!this.auth.isPrincipal(credentials, 'user')) { + return res.status(401).json({ + error: 'unauthorized', + error_description: 'Authentication required', + }); + } + + const userEntityRef = credentials.principal.userEntityRef; + + const result = await this.oidc.approveConsentRequest({ + consentRequestId: consentId, + userEntityRef, + }); + + return res.json({ + redirectUrl: result.redirectUrl, + }); + } catch (error) { + this.logger.error( + `Failed to approve consent: ${ + isError(error) ? error.message : 'Unknown error' + }`, + error, + ); + return res.status(400).json({ + error: 'invalid_request', + error_description: isError(error) ? error.message : 'Unknown error', + }); + } + }); + + // Consent rejection endpoint + // Handles user rejection of consent requests and redirects with error + router.post('/v1/consent/:consentId/reject', async (req, res) => { + const { consentId } = req.params; + + if (!consentId) { + return res.status(400).json({ + error: 'invalid_request', + error_description: 'Missing consent ID', + }); + } + + try { + const consentRequest = await this.oidc.getConsentRequest({ + consentRequestId: consentId, + }); + + await this.oidc.deleteConsentRequest({ consentRequestId: consentId }); + + const errorParams = new URLSearchParams(); + errorParams.append('error', 'access_denied'); + errorParams.append('error_description', 'User denied the request'); + if (consentRequest.state) { + errorParams.append('state', consentRequest.state); + } + + const redirectUrl = new URL(consentRequest.redirectUri); + redirectUrl.search = errorParams.toString(); + + return res.json({ + redirectUrl: redirectUrl.toString(), + }); + } catch (error) { + this.logger.error( + `Failed to reject consent: ${ + isError(error) ? error.message : 'Unknown error' + }`, + error, + ); + return res.status(400).json({ + error: 'invalid_request', + error_description: isError(error) ? error.message : 'Unknown error', + }); + } + }); + + // Token endpoint + // https://openid.net/specs/openid-connect-core-1_0.html#TokenRequest + // Exchanges authorization codes for access tokens and ID tokens router.post('/v1/token', async (req, res) => { // todo(blam): maybe add zod types for validating input const { @@ -180,9 +338,9 @@ export class OidcRouter { } }); - // 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 + // UserInfo endpoint + // https://openid.net/specs/openid-connect-core-1_0.html#UserInfo + // Returns claims about the authenticated user using an access token router.get('/v1/userinfo', async (req, res) => { const matches = req.headers.authorization?.match(/^Bearer[ ]+(\S+)$/i); const token = matches?.[1]; @@ -200,6 +358,9 @@ export class OidcRouter { res.json(userInfo); }); + // Dynamic Client Registration endpoint + // https://openid.net/specs/openid-connect-registration-1_0.html#ClientRegistration + // Allows clients to register themselves dynamically with the provider router.post('/v1/register', async (req, res) => { // todo(blam): maybe add zod types for validating input const registrationRequest = req.body; diff --git a/plugins/auth-backend/src/service/router.ts b/plugins/auth-backend/src/service/router.ts index 5eb3450451..28f218c749 100644 --- a/plugins/auth-backend/src/service/router.ts +++ b/plugins/auth-backend/src/service/router.ts @@ -154,6 +154,7 @@ export async function createRouter( auth: options.auth, tokenIssuer, baseUrl: authUrl, + appUrl, userInfo, oidc, logger,