From eb2297fe6d792dd03aa766b97802adee7d1f18b7 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Thu, 3 Jul 2025 19:36:00 +0200 Subject: [PATCH] chore: updating the oidc service to handle consent Signed-off-by: benjdlambert Signed-off-by: benjdlambert --- .../auth-backend/src/service/OidcService.ts | 249 ++++++++++++++++-- 1 file changed, 234 insertions(+), 15 deletions(-) diff --git a/plugins/auth-backend/src/service/OidcService.ts b/plugins/auth-backend/src/service/OidcService.ts index cc60b2b432..4214f53a8c 100644 --- a/plugins/auth-backend/src/service/OidcService.ts +++ b/plugins/auth-backend/src/service/OidcService.ts @@ -112,6 +112,9 @@ export class OidcService { const generatedClientId = crypto.randomUUID(); const generatedClientSecret = crypto.randomUUID(); + // todo(blam): add validation for redirectUris here. + // should be a list of urls and / or allowed schemes or something. + return await this.oidc.createClient({ clientId: generatedClientId, clientName: opts.clientName, @@ -123,6 +126,194 @@ export class OidcService { }); } + public async createConsentRequest(opts: { + clientId: string; + redirectUri: string; + responseType: string; + scope?: string; + state?: string; + nonce?: string; + codeChallenge?: string; + codeChallengeMethod?: string; + }) { + const { + clientId, + redirectUri, + responseType, + scope, + state, + nonce, + codeChallenge, + codeChallengeMethod, + } = 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 sessionId = crypto.randomUUID(); + const sessionExpiresAt = DateTime.now().plus({ hours: 1 }).toISO(); + + await this.oidc.createAuthorizationSession({ + id: sessionId, + clientId, + redirectUri, + responseType, + scope, + state, + codeChallenge, + codeChallengeMethod, + nonce, + expiresAt: sessionExpiresAt, + }); + + const consentRequestId = crypto.randomUUID(); + const consentExpiresAt = DateTime.now().plus({ minutes: 30 }).toISO(); + + await this.oidc.createConsentRequest({ + id: consentRequestId, + sessionId, + expiresAt: consentExpiresAt, + }); + + return { + consentRequestId, + clientName: client.clientName, + scope, + redirectUri, + }; + } + + public async approveConsentRequest(opts: { + consentRequestId: string; + userEntityRef: string; + }) { + const { consentRequestId, userEntityRef } = opts; + + const consentRequest = await this.oidc.getConsentRequest({ + id: consentRequestId, + }); + if (!consentRequest) { + throw new InputError('Invalid consent request'); + } + + if (DateTime.fromISO(consentRequest.expiresAt) < DateTime.now()) { + throw new InputError('Consent request expired'); + } + + const session = await this.oidc.getAuthorizationSession({ + id: consentRequest.sessionId, + }); + if (!session) { + throw new InputError('Invalid authorization session'); + } + + if (DateTime.fromISO(session.expiresAt) < DateTime.now()) { + throw new InputError('Authorization session expired'); + } + + await this.oidc.updateAuthorizationSession({ + id: session.id, + userEntityRef, + status: 'approved', + }); + + const authorizationCode = crypto.randomBytes(32).toString('base64url'); + const codeExpiresAt = DateTime.now().plus({ minutes: 10 }).toISO(); + + await this.oidc.createAuthorizationCode({ + code: authorizationCode, + sessionId: session.id, + expiresAt: codeExpiresAt, + }); + + await this.oidc.deleteConsentRequest({ id: consentRequestId }); + + const redirectUrl = new URL(session.redirectUri); + redirectUrl.searchParams.append('code', authorizationCode); + if (session.state) { + redirectUrl.searchParams.append('state', session.state); + } + + return { + redirectUrl: redirectUrl.toString(), + }; + } + + public async getConsentRequest(opts: { consentRequestId: string }) { + const consentRequest = await this.oidc.getConsentRequest({ + id: opts.consentRequestId, + }); + if (!consentRequest) { + throw new InputError('Invalid consent request'); + } + + if (DateTime.fromISO(consentRequest.expiresAt) < DateTime.now()) { + throw new InputError('Consent request expired'); + } + + const session = await this.oidc.getAuthorizationSession({ + id: consentRequest.sessionId, + }); + + if (!session) { + throw new InputError('Invalid authorization session'); + } + + const client = await this.oidc.getClient({ clientId: session.clientId }); + if (!client) { + throw new InputError('Invalid client_id'); + } + + return { + id: consentRequest.id, + clientId: session.clientId, + clientName: client.clientName, + redirectUri: session.redirectUri, + scope: session.scope, + state: session.state, + responseType: session.responseType, + codeChallenge: session.codeChallenge, + codeChallengeMethod: session.codeChallengeMethod, + nonce: session.nonce, + expiresAt: consentRequest.expiresAt, + }; + } + + public async deleteConsentRequest(opts: { consentRequestId: string }) { + const consentRequest = await this.oidc.getConsentRequest({ + id: opts.consentRequestId, + }); + if (!consentRequest) { + return; + } + + await this.oidc.updateAuthorizationSession({ + id: consentRequest.sessionId, + status: 'rejected', + }); + + await this.oidc.deleteConsentRequest({ id: opts.consentRequestId }); + } + public async authorize(opts: { clientId: string; redirectUri: string; @@ -168,19 +359,35 @@ export class OidcService { } } - const authorizationCode = crypto.randomBytes(32).toString('base64url'); - const expiresAt = DateTime.now().plus({ minutes: 10 }).toISO(); + const sessionId = crypto.randomUUID(); + const sessionExpiresAt = DateTime.now().plus({ hours: 1 }).toISO(); - await this.oidc.createAuthorizationCode({ - code: authorizationCode, + await this.oidc.createAuthorizationSession({ + id: sessionId, clientId, userEntityRef, redirectUri, + responseType, scope, + state, codeChallenge, codeChallengeMethod, nonce, - expiresAt, + expiresAt: sessionExpiresAt, + }); + + await this.oidc.updateAuthorizationSession({ + id: sessionId, + status: 'approved', + }); + + const authorizationCode = crypto.randomBytes(32).toString('base64url'); + const codeExpiresAt = DateTime.now().plus({ minutes: 10 }).toISO(); + + await this.oidc.createAuthorizationCode({ + code: authorizationCode, + sessionId, + expiresAt: codeExpiresAt, }); const redirectUrl = new URL(redirectUri); @@ -237,24 +444,38 @@ export class OidcService { throw new AuthenticationError('Authorization code already used'); } - if (authCode.clientId !== clientId) { + const session = await this.oidc.getAuthorizationSession({ + id: authCode.sessionId, + }); + if (!session) { + throw new AuthenticationError('Invalid authorization session'); + } + if (session.clientId !== clientId) { throw new AuthenticationError('Client ID mismatch'); } - if (authCode.redirectUri !== redirectUri) { + if (session.redirectUri !== redirectUri) { throw new AuthenticationError('Redirect URI mismatch'); } - if (authCode.codeChallenge) { + if (session.status !== 'approved') { + throw new AuthenticationError('Authorization not approved'); + } + + if (!session.userEntityRef) { + throw new AuthenticationError('No user associated with authorization'); + } + + if (session.codeChallenge) { if (!codeVerifier) { throw new AuthenticationError('Code verifier required for PKCE'); } if ( !this.verifyPkce( - authCode.codeChallenge, + session.codeChallenge, codeVerifier, - authCode.codeChallengeMethod, + session.codeChallengeMethod, ) ) { throw new AuthenticationError('Invalid code verifier'); @@ -271,15 +492,13 @@ export class OidcService { await this.oidc.createAccessToken({ tokenId: accessTokenId, - clientId, - userEntityRef: authCode.userEntityRef, - scope: authCode.scope, + sessionId: session.id, expiresAt, }); const { token } = await this.tokenIssuer.issueToken({ claims: { - sub: authCode.userEntityRef, + sub: session.userEntityRef, }, }); @@ -288,7 +507,7 @@ export class OidcService { tokenType: 'Bearer', expiresIn: 3600, idToken: token, - scope: authCode.scope || 'openid', + scope: session.scope || 'openid', }; }