From 29a21fd3b36686577f16234058a04854bfbabd97 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Mon, 20 Apr 2026 09:46:13 +0000 Subject: [PATCH] feat(auth): add abuse protection for login and OIDC flows with consent-based authorization handling --- changelog.md | 9 + test/test.abuse.node.ts | 94 +++++ test/test.oidc.node.ts | 132 +++++++ ts/00_commitinfo_data.ts | 2 +- .../classes.abuseprotectionmanager.ts | 102 +++++ ts/reception/classes.abusewindow.ts | 33 ++ ts/reception/classes.housekeeping.ts | 20 + ts/reception/classes.loginsessionmanager.ts | 73 ++++ ts/reception/classes.oidcmanager.ts | 201 +++++++++- ts/reception/classes.reception.ts | 2 + ts_idpclient/classes.idprequests.ts | 12 + ts_interfaces/data/abusewindow.ts | 13 + ts_interfaces/data/appconnection.ts | 2 +- ts_interfaces/data/billingplan.ts | 2 +- ts_interfaces/data/device.ts | 2 +- ts_interfaces/data/index.ts | 31 +- ts_interfaces/data/organization.ts | 6 +- ts_interfaces/data/property.ts | 4 +- ts_interfaces/data/role.ts | 2 +- ts_interfaces/data/user.ts | 4 +- ts_interfaces/data/userinvitation.ts | 2 +- ts_interfaces/request/admin.ts | 2 +- ts_interfaces/request/app.ts | 2 +- ts_interfaces/request/authorization.ts | 55 ++- ts_interfaces/request/billingplan.ts | 2 +- ts_interfaces/request/index.ts | 24 +- ts_interfaces/request/jwt.ts | 2 +- ts_interfaces/request/login.ts | 2 +- ts_interfaces/request/organization.ts | 2 +- ts_interfaces/request/plan.ts | 2 +- ts_interfaces/request/registration.ts | 2 +- ts_interfaces/request/user.ts | 2 +- ts_interfaces/request/userinvitation.ts | 2 +- ts_interfaces/tags/index.ts | 2 +- ts_web/00_commitinfo_data.ts | 2 +- ts_web/elements/idp-loginprompt.ts | 362 ++++++++++++++++-- 36 files changed, 1129 insertions(+), 84 deletions(-) create mode 100644 test/test.abuse.node.ts create mode 100644 ts/reception/classes.abuseprotectionmanager.ts create mode 100644 ts/reception/classes.abusewindow.ts create mode 100644 ts_interfaces/data/abusewindow.ts diff --git a/changelog.md b/changelog.md index da396d6..4bf3157 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,14 @@ # Changelog +## 2026-04-20 - 1.20.0 - feat(auth) +add abuse protection for login and OIDC flows with consent-based authorization handling + +- introduces AbuseProtectionManager and AbuseWindow storage to rate limit password login, magic link, password reset, and OIDC token exchange attempts +- adds housekeeping cleanup for expired abuse protection windows +- adds typed OIDC prepare/complete authorization requests plus consent evaluation and redirect URL generation +- updates the login prompt to support OIDC authorization continuation after user login or consent +- includes tests for abuse protection behavior and OIDC authorization preparation/completion flows + ## 2026-04-20 - 1.19.1 - fix(ts_interfaces) rename generated TypeScript interface files to remove the loint-reception prefix diff --git a/test/test.abuse.node.ts b/test/test.abuse.node.ts new file mode 100644 index 0000000..81db73e --- /dev/null +++ b/test/test.abuse.node.ts @@ -0,0 +1,94 @@ +import { tap, expect } from '@git.zone/tstest/tapbundle'; + +import { + AbuseProtectionManager, + type IAbuseProtectionConfig, +} from '../ts/reception/classes.abuseprotectionmanager.js'; +import { AbuseWindow } from '../ts/reception/classes.abusewindow.js'; + +const createTestAbuseProtectionManager = () => { + const manager = new AbuseProtectionManager({ + db: { smartdataDb: {} }, + } as any); + + const store = new Map(); + const originalSave = AbuseWindow.prototype.save; + const originalDelete = AbuseWindow.prototype.delete; + + (AbuseWindow.prototype as AbuseWindow & { save: () => Promise }).save = async function () { + store.set(this.id, this); + }; + (AbuseWindow.prototype as AbuseWindow & { delete: () => Promise }).delete = async function () { + store.delete(this.id); + }; + + (manager as any).CAbuseWindow = { + getInstance: async (queryArg) => store.get(queryArg.id) ?? null, + }; + + const restore = () => { + AbuseWindow.prototype.save = originalSave; + AbuseWindow.prototype.delete = originalDelete; + }; + + return { + manager, + store, + restore, + }; +}; + +const testConfig: IAbuseProtectionConfig = { + maxAttempts: 2, + windowMillis: 1_000, + blockDurationMillis: 2_000, +}; + +tap.test('blocks after too many attempts within the active window', async () => { + const { manager, restore } = createTestAbuseProtectionManager(); + + try { + await manager.consumeAttempt('passwordLogin', 'phil@example.com', testConfig); + await manager.consumeAttempt('passwordLogin', 'phil@example.com', testConfig); + + await expect(manager.consumeAttempt('passwordLogin', 'phil@example.com', testConfig)).rejects.toThrow(); + } finally { + restore(); + } +}); + +tap.test('resets attempts after the block and window have elapsed', async () => { + const { manager, store, restore } = createTestAbuseProtectionManager(); + + try { + await manager.consumeAttempt('passwordLogin', 'phil@example.com', testConfig); + await manager.consumeAttempt('passwordLogin', 'phil@example.com', testConfig); + await expect(manager.consumeAttempt('passwordLogin', 'phil@example.com', testConfig)).rejects.toThrow(); + + const abuseWindow = Array.from(store.values())[0]; + abuseWindow.data.blockedUntil = Date.now() - 10; + abuseWindow.data.windowStartedAt = Date.now() - testConfig.windowMillis - 10; + abuseWindow.data.validUntil = Date.now() + 1_000; + + await manager.consumeAttempt('passwordLogin', 'phil@example.com', testConfig); + expect(abuseWindow.data.attemptCount).toEqual(1); + } finally { + restore(); + } +}); + +tap.test('clears stored attempts after a successful action', async () => { + const { manager, store, restore } = createTestAbuseProtectionManager(); + + try { + await manager.consumeAttempt('passwordLogin', 'phil@example.com', testConfig); + expect(store.size).toEqual(1); + + await manager.clearAttempts('passwordLogin', 'phil@example.com'); + expect(store.size).toEqual(0); + } finally { + restore(); + } +}); + +export default tap.start(); diff --git a/test/test.oidc.node.ts b/test/test.oidc.node.ts index 78ee1fb..b4f248b 100644 --- a/test/test.oidc.node.ts +++ b/test/test.oidc.node.ts @@ -2,9 +2,20 @@ import { tap, expect } from '@git.zone/tstest/tapbundle'; import { OidcAccessToken } from '../ts/reception/classes.oidcaccesstoken.js'; import { OidcAuthorizationCode } from '../ts/reception/classes.oidcauthorizationcode.js'; +import { OidcManager } from '../ts/reception/classes.oidcmanager.js'; import { OidcRefreshToken } from '../ts/reception/classes.oidcrefreshtoken.js'; import { OidcUserConsent } from '../ts/reception/classes.oidcuserconsent.js'; +const createTestOidcManager = () => { + const oidcManager = new OidcManager({ + db: { smartdataDb: {} }, + typedrouter: { addTypedRouter: () => undefined }, + options: { baseUrl: 'https://idp.example' }, + } as any); + void oidcManager.stop(); + return oidcManager; +}; + tap.test('stores authorization codes as hashes and marks them used', async () => { const authCode = new OidcAuthorizationCode(); authCode.id = 'oidc-auth-code'; @@ -73,4 +84,125 @@ tap.test('merges user consent scopes without duplicates', async () => { expect(saveCount).toEqual(1); }); +tap.test('builds an OAuth redirect URL after successful authorization completion', async () => { + const oidcManager = createTestOidcManager(); + + (oidcManager as any).findAppByClientId = async () => ({ + data: { + name: 'Example App', + appUrl: 'https://app.example', + logoUrl: 'https://app.example/logo.png', + oauthCredentials: { + clientId: 'client-1', + redirectUris: ['https://app.example/callback'], + allowedScopes: ['openid', 'profile', 'email'], + }, + }, + }); + + (oidcManager as any).generateAuthorizationCode = async () => 'generated-auth-code'; + (oidcManager as any).getUserConsent = async () => ({ + data: { + scopes: ['openid', 'profile', 'email'], + }, + }); + (oidcManager as any).upsertUserConsent = async () => undefined; + + const result = await oidcManager.completeAuthorizationForUser('user-1', { + clientId: 'client-1', + redirectUri: 'https://app.example/callback', + scope: 'openid profile email', + state: 'xyz-state', + codeChallenge: 'challenge', + codeChallengeMethod: 'S256', + nonce: 'nonce-1', + consentApproved: true, + }); + + expect(result.code).toEqual('generated-auth-code'); + expect(result.redirectUrl).toEqual( + 'https://app.example/callback?code=generated-auth-code&state=xyz-state' + ); + + await oidcManager.stop(); +}); + +tap.test('prepares OAuth consent when scopes are not yet granted', async () => { + const oidcManager = createTestOidcManager(); + + (oidcManager as any).findAppByClientId = async () => ({ + data: { + name: 'Example App', + appUrl: 'https://app.example', + logoUrl: 'https://app.example/logo.png', + oauthCredentials: { + clientId: 'client-1', + redirectUris: ['https://app.example/callback'], + allowedScopes: ['openid', 'profile', 'email'], + }, + }, + }); + + (oidcManager as any).getUserConsent = async () => ({ + data: { + scopes: ['openid'], + }, + }); + + const result = await oidcManager.prepareAuthorizationForUser('user-1', { + clientId: 'client-1', + redirectUri: 'https://app.example/callback', + scope: 'openid profile email', + state: 'xyz-state', + prompt: undefined, + codeChallenge: undefined, + codeChallengeMethod: undefined, + nonce: undefined, + }); + + expect(result.status).toEqual('consent_required'); + expect(result.requestedScopes.sort()).toEqual(['email', 'openid', 'profile']); + expect(result.grantedScopes).toEqual(['openid']); + + await oidcManager.stop(); +}); + +tap.test('prepares OAuth authorization as ready when consent already exists', async () => { + const oidcManager = createTestOidcManager(); + + (oidcManager as any).findAppByClientId = async () => ({ + data: { + name: 'Example App', + appUrl: 'https://app.example', + logoUrl: 'https://app.example/logo.png', + oauthCredentials: { + clientId: 'client-1', + redirectUris: ['https://app.example/callback'], + allowedScopes: ['openid', 'profile', 'email'], + }, + }, + }); + + (oidcManager as any).getUserConsent = async () => ({ + data: { + scopes: ['openid', 'profile', 'email'], + }, + }); + + const result = await oidcManager.prepareAuthorizationForUser('user-1', { + clientId: 'client-1', + redirectUri: 'https://app.example/callback', + scope: 'openid profile email', + state: 'xyz-state', + prompt: undefined, + codeChallenge: undefined, + codeChallengeMethod: undefined, + nonce: undefined, + }); + + expect(result.status).toEqual('ready'); + + await oidcManager.stop(); +}); + export default tap.start(); diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 39e4156..0aea42a 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@idp.global/idp.global', - version: '1.19.1', + version: '1.20.0', description: 'An identity provider software managing user authentications, registrations, and sessions.' } diff --git a/ts/reception/classes.abuseprotectionmanager.ts b/ts/reception/classes.abuseprotectionmanager.ts new file mode 100644 index 0000000..a2841d5 --- /dev/null +++ b/ts/reception/classes.abuseprotectionmanager.ts @@ -0,0 +1,102 @@ +import * as plugins from '../plugins.js'; + +import { Reception } from './classes.reception.js'; +import { AbuseWindow } from './classes.abusewindow.js'; + +export interface IAbuseProtectionConfig { + maxAttempts: number; + windowMillis: number; + blockDurationMillis: number; +} + +export class AbuseProtectionManager { + public receptionRef: Reception; + + public get db() { + return this.receptionRef.db.smartdataDb; + } + + public CAbuseWindow = plugins.smartdata.setDefaultManagerForDoc(this, AbuseWindow); + + constructor(receptionRefArg: Reception) { + this.receptionRef = receptionRefArg; + } + + private normalizeIdentifier(identifierArg: string) { + return identifierArg.trim().toLowerCase(); + } + + private hashIdentifier(identifierArg: string) { + return plugins.smarthash.sha256FromStringSync(this.normalizeIdentifier(identifierArg)); + } + + private createWindowId(actionArg: string, identifierArg: string) { + return plugins.smarthash.sha256FromStringSync( + `${actionArg}:${this.hashIdentifier(identifierArg)}` + ); + } + + private async getWindow(actionArg: string, identifierArg: string) { + return this.CAbuseWindow.getInstance({ + id: this.createWindowId(actionArg, identifierArg), + }); + } + + public async consumeAttempt( + actionArg: string, + identifierArg: string, + configArg: IAbuseProtectionConfig, + errorTextArg = 'Too many attempts. Please wait before trying again.' + ) { + const now = Date.now(); + let abuseWindow = await this.getWindow(actionArg, identifierArg); + + if (!abuseWindow) { + abuseWindow = new AbuseWindow(); + abuseWindow.id = this.createWindowId(actionArg, identifierArg); + abuseWindow.data.action = actionArg; + abuseWindow.data.identifierHash = this.hashIdentifier(identifierArg); + abuseWindow.data.createdAt = now; + } + + if (abuseWindow.isBlocked(now)) { + throw new plugins.typedrequest.TypedResponseError(errorTextArg); + } + + if (abuseWindow.data.blockedUntil && abuseWindow.data.blockedUntil <= now) { + abuseWindow.data.attemptCount = 0; + abuseWindow.data.windowStartedAt = now; + abuseWindow.data.blockedUntil = 0; + } + + if ( + !abuseWindow.data.windowStartedAt || + abuseWindow.data.windowStartedAt + configArg.windowMillis <= now + ) { + abuseWindow.data.attemptCount = 0; + abuseWindow.data.windowStartedAt = now; + } + + abuseWindow.data.attemptCount += 1; + abuseWindow.data.updatedAt = now; + abuseWindow.data.validUntil = now + configArg.windowMillis; + + if (abuseWindow.data.attemptCount > configArg.maxAttempts) { + abuseWindow.data.blockedUntil = now + configArg.blockDurationMillis; + abuseWindow.data.validUntil = abuseWindow.data.blockedUntil; + await abuseWindow.save(); + throw new plugins.typedrequest.TypedResponseError(errorTextArg); + } + + await abuseWindow.save(); + } + + public async clearAttempts(actionArg: string, identifierArg: string) { + const abuseWindow = await this.getWindow(actionArg, identifierArg); + if (!abuseWindow) { + return; + } + + await abuseWindow.delete(); + } +} diff --git a/ts/reception/classes.abusewindow.ts b/ts/reception/classes.abusewindow.ts new file mode 100644 index 0000000..96406f0 --- /dev/null +++ b/ts/reception/classes.abusewindow.ts @@ -0,0 +1,33 @@ +import * as plugins from '../plugins.js'; + +import type { AbuseProtectionManager } from './classes.abuseprotectionmanager.js'; + +@plugins.smartdata.Manager() +export class AbuseWindow extends plugins.smartdata.SmartDataDbDoc< + AbuseWindow, + plugins.idpInterfaces.data.IAbuseWindow, + AbuseProtectionManager +> { + @plugins.smartdata.unI() + public id: string; + + @plugins.smartdata.svDb() + public data: plugins.idpInterfaces.data.IAbuseWindow['data'] = { + action: '', + identifierHash: '', + attemptCount: 0, + windowStartedAt: 0, + blockedUntil: 0, + validUntil: 0, + createdAt: 0, + updatedAt: 0, + }; + + public isBlocked(nowArg = Date.now()) { + return this.data.blockedUntil > nowArg; + } + + public isExpired(nowArg = Date.now()) { + return this.data.validUntil < nowArg; + } +} diff --git a/ts/reception/classes.housekeeping.ts b/ts/reception/classes.housekeeping.ts index 12241b8..0d8c9be 100644 --- a/ts/reception/classes.housekeeping.ts +++ b/ts/reception/classes.housekeeping.ts @@ -74,6 +74,26 @@ export class ReceptionHousekeeping { '2 * * * * *' ); + this.taskmanager.addAndScheduleTask( + new plugins.taskbuffer.Task({ + name: 'expiredAbuseWindows', + taskFunction: async () => { + const expiredAbuseWindows = + await this.receptionRef.abuseProtectionManager.CAbuseWindow.getInstances({ + data: { + validUntil: { + $lt: Date.now(), + } as any, + }, + }); + for (const abuseWindow of expiredAbuseWindows) { + await abuseWindow.delete(); + } + }, + }), + '2 * * * * *' + ); + this.taskmanager.start(); logger.log('info', 'housekeeping started'); } diff --git a/ts/reception/classes.loginsessionmanager.ts b/ts/reception/classes.loginsessionmanager.ts index c67a910..cd27066 100644 --- a/ts/reception/classes.loginsessionmanager.ts +++ b/ts/reception/classes.loginsessionmanager.ts @@ -5,6 +5,34 @@ import { Reception } from './classes.reception.js'; import { logger } from './logging.js'; export class LoginSessionManager { + private readonly abuseProtectionConfigs = { + passwordLogin: { + maxAttempts: 5, + windowMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 15 }), + blockDurationMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 30 }), + }, + emailLoginRequest: { + maxAttempts: 5, + windowMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 15 }), + blockDurationMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 15 }), + }, + emailLoginToken: { + maxAttempts: 5, + windowMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 15 }), + blockDurationMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 30 }), + }, + passwordResetRequest: { + maxAttempts: 5, + windowMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 15 }), + blockDurationMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 15 }), + }, + passwordResetCompletion: { + maxAttempts: 5, + windowMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 15 }), + blockDurationMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 30 }), + }, + }; + // refs public receptionRef: Reception; public get db() { @@ -23,6 +51,14 @@ export class LoginSessionManager { new plugins.typedrequest.TypedHandler( 'loginWithEmailOrUsernameAndPassword', async (requestData) => { + const loginIdentifier = requestData.username; + await this.receptionRef.abuseProtectionManager.consumeAttempt( + 'passwordLogin', + loginIdentifier, + this.abuseProtectionConfigs.passwordLogin, + 'Too many login attempts. Please wait before trying again.' + ); + let user = await this.receptionRef.userManager.CUser.getInstance({ data: { username: requestData.username, @@ -54,6 +90,11 @@ export class LoginSessionManager { throw new plugins.typedrequest.TypedResponseError('Could not create login session'); } + await this.receptionRef.abuseProtectionManager.clearAttempts( + 'passwordLogin', + loginIdentifier + ); + return { refreshToken, twoFaNeeded: false, @@ -69,6 +110,12 @@ export class LoginSessionManager { new plugins.typedrequest.TypedHandler( 'loginWithEmail', async (requestDataArg) => { + await this.receptionRef.abuseProtectionManager.consumeAttempt( + 'emailLoginRequest', + requestDataArg.email, + this.abuseProtectionConfigs.emailLoginRequest, + 'Too many magic link requests. Please wait before trying again.' + ); logger.log('info', `loginWithEmail requested for: ${requestDataArg.email}`); const existingUser = await this.receptionRef.userManager.CUser.getInstance({ data: { @@ -101,6 +148,12 @@ export class LoginSessionManager { new plugins.typedrequest.TypedHandler( 'loginWithEmailAfterEmailTokenAquired', async (requestArg) => { + await this.receptionRef.abuseProtectionManager.consumeAttempt( + 'emailLoginToken', + requestArg.email, + this.abuseProtectionConfigs.emailLoginToken, + 'Too many magic link attempts. Please wait before trying again.' + ); const tokenObject = await this.consumeEmailActionToken( requestArg.email, requestArg.token, @@ -120,6 +173,10 @@ export class LoginSessionManager { if (!refreshToken) { throw new plugins.typedrequest.TypedResponseError('Could not create login session'); } + await this.receptionRef.abuseProtectionManager.clearAttempts( + 'emailLoginToken', + requestArg.email + ); return { refreshToken, }; @@ -188,6 +245,12 @@ export class LoginSessionManager { new plugins.typedrequest.TypedHandler( 'resetPassword', async (requestDataArg) => { + await this.receptionRef.abuseProtectionManager.consumeAttempt( + 'passwordResetRequest', + requestDataArg.email, + this.abuseProtectionConfigs.passwordResetRequest, + 'Too many password reset requests. Please wait before trying again.' + ); const emailOfPasswordToReset = requestDataArg.email; const existingUser = await this.receptionRef.userManager.CUser.getInstance({ data: { @@ -216,6 +279,12 @@ export class LoginSessionManager { new plugins.typedrequest.TypedHandler( 'setNewPassword', async (requestData) => { + await this.receptionRef.abuseProtectionManager.consumeAttempt( + 'passwordResetCompletion', + requestData.email, + this.abuseProtectionConfigs.passwordResetCompletion, + 'Too many password change attempts. Please wait before trying again.' + ); const user = await this.receptionRef.userManager.CUser.getInstance({ data: { email: requestData.email, @@ -253,6 +322,10 @@ export class LoginSessionManager { requestData.newPassword ); await user.save(); + await this.receptionRef.abuseProtectionManager.clearAttempts( + 'passwordResetCompletion', + requestData.email + ); return { status: 'ok', }; diff --git a/ts/reception/classes.oidcmanager.ts b/ts/reception/classes.oidcmanager.ts index 60b9cf3..5680f3d 100644 --- a/ts/reception/classes.oidcmanager.ts +++ b/ts/reception/classes.oidcmanager.ts @@ -11,11 +11,21 @@ import { OidcUserConsent } from './classes.oidcuserconsent.js'; * for third-party client authentication. */ export class OidcManager { + private readonly abuseProtectionConfig = { + oidcTokenExchange: { + maxAttempts: 10, + windowMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 10 }), + blockDurationMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 15 }), + }, + }; + public receptionRef: Reception; public get db() { return this.receptionRef.db.smartdataDb; } + public typedRouter = new plugins.typedrequest.TypedRouter(); + public COidcAuthorizationCode = plugins.smartdata.setDefaultManagerForDoc( this, OidcAuthorizationCode @@ -31,6 +41,35 @@ export class OidcManager { constructor(receptionRefArg: Reception) { this.receptionRef = receptionRefArg; + this.receptionRef.typedrouter.addTypedRouter(this.typedRouter); + + this.typedRouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'prepareOidcAuthorization', + async (requestArg) => { + const jwt = await this.receptionRef.jwtManager.verifyJWTAndGetData(requestArg.jwt); + if (!jwt) { + throw new plugins.typedrequest.TypedResponseError('Invalid JWT'); + } + + return this.prepareAuthorizationForUser(jwt.data.userId, requestArg); + } + ) + ); + + this.typedRouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'completeOidcAuthorization', + async (requestArg) => { + const jwt = await this.receptionRef.jwtManager.verifyJWTAndGetData(requestArg.jwt); + if (!jwt) { + throw new plugins.typedrequest.TypedResponseError('Invalid JWT'); + } + + return this.completeAuthorizationForUser(jwt.data.userId, requestArg); + } + ) + ); this.startCleanupTask(); } @@ -128,6 +167,10 @@ export class OidcManager { return this.errorResponse('unsupported_response_type', 'Only code response type is supported'); } + if (prompt && !this.isSupportedPrompt(prompt)) { + return this.errorResponse('invalid_request', 'Unsupported prompt value'); + } + // Validate code challenge method if present if (codeChallenge && codeChallengeMethod !== 'S256') { return this.errorResponse('invalid_request', 'Only S256 code challenge method is supported'); @@ -169,6 +212,9 @@ export class OidcManager { if (nonce) { loginUrl.searchParams.set('nonce', nonce); } + if (prompt) { + loginUrl.searchParams.set('prompt', prompt); + } return Response.redirect(loginUrl.toString(), 302); } @@ -202,10 +248,71 @@ export class OidcManager { }; await authCode.save(); - await this.upsertUserConsent(userId, clientId, scopes); return code; } + public async prepareAuthorizationForUser( + userIdArg: string, + requestArg: Omit + ): Promise { + const resolvedRequest = await this.resolveAuthorizationRequest(requestArg); + const consentState = await this.evaluateConsentRequirement( + userIdArg, + resolvedRequest.clientId, + resolvedRequest.validScopes, + resolvedRequest.prompt + ); + + return { + status: consentState.consentRequired ? ('consent_required' as const) : ('ready' as const), + clientId: resolvedRequest.clientId, + appName: resolvedRequest.app.data.name, + appUrl: resolvedRequest.app.data.appUrl, + logoUrl: resolvedRequest.app.data.logoUrl, + requestedScopes: resolvedRequest.validScopes, + grantedScopes: consentState.grantedScopes, + }; + } + + public async completeAuthorizationForUser( + userIdArg: string, + requestArg: Omit + ) { + const resolvedRequest = await this.resolveAuthorizationRequest(requestArg); + const consentState = await this.evaluateConsentRequirement( + userIdArg, + resolvedRequest.clientId, + resolvedRequest.validScopes, + resolvedRequest.prompt + ); + + if (consentState.consentRequired && !requestArg.consentApproved) { + throw new Error('Consent required'); + } + + if (requestArg.consentApproved) { + await this.upsertUserConsent(userIdArg, resolvedRequest.clientId, resolvedRequest.validScopes); + } + + const code = await this.generateAuthorizationCode( + resolvedRequest.clientId, + userIdArg, + resolvedRequest.validScopes, + resolvedRequest.redirectUri, + resolvedRequest.codeChallenge, + resolvedRequest.nonce + ); + + const redirectUrl = new URL(resolvedRequest.redirectUri); + redirectUrl.searchParams.set('code', code); + redirectUrl.searchParams.set('state', resolvedRequest.state); + + return { + code, + redirectUrl: redirectUrl.toString(), + }; + } + /** * Handle the token endpoint request */ @@ -236,6 +343,13 @@ export class OidcManager { return this.tokenErrorResponse('invalid_client', 'Missing client_id'); } + await this.receptionRef.abuseProtectionManager.consumeAttempt( + 'oidcTokenExchange', + clientId, + this.abuseProtectionConfig.oidcTokenExchange, + 'Too many token endpoint attempts. Please wait before retrying.' + ); + // Find and validate app const app = await this.findAppByClientId(clientId); if (!app) { @@ -250,13 +364,20 @@ export class OidcManager { } } + let response: Response; if (grantType === 'authorization_code') { - return this.handleAuthorizationCodeGrant(formData, app); + response = await this.handleAuthorizationCodeGrant(formData, app); } else if (grantType === 'refresh_token') { - return this.handleRefreshTokenGrant(formData, app); + response = await this.handleRefreshTokenGrant(formData, app); } else { - return this.tokenErrorResponse('unsupported_grant_type', 'Unsupported grant type'); + response = this.tokenErrorResponse('unsupported_grant_type', 'Unsupported grant type'); } + + if (response.status === 200) { + await this.receptionRef.abuseProtectionManager.clearAttempts('oidcTokenExchange', clientId); + } + + return response; } /** @@ -625,6 +746,78 @@ export class OidcManager { return apps[0] || null; } + private isSupportedPrompt(promptArg: string): promptArg is 'none' | 'login' | 'consent' { + return ['none', 'login', 'consent'].includes(promptArg); + } + + private async resolveAuthorizationRequest( + requestArg: Pick< + plugins.idpInterfaces.request.IReq_CompleteOidcAuthorization['request'], + 'clientId' | 'redirectUri' | 'scope' | 'state' | 'prompt' | 'codeChallenge' | 'codeChallengeMethod' | 'nonce' + > + ) { + if (!requestArg.clientId || !requestArg.redirectUri || !requestArg.scope || !requestArg.state) { + throw new Error('Missing required OAuth authorization parameters'); + } + + if (requestArg.prompt && !this.isSupportedPrompt(requestArg.prompt)) { + throw new Error('Unsupported prompt value'); + } + + if (requestArg.codeChallenge && requestArg.codeChallengeMethod !== 'S256') { + throw new Error('Only S256 code challenge method is supported'); + } + + const app = await this.findAppByClientId(requestArg.clientId); + if (!app) { + throw new Error('Unknown client_id'); + } + + if (!app.data.oauthCredentials.redirectUris.includes(requestArg.redirectUri)) { + throw new Error('Invalid redirect_uri'); + } + + const requestedScopes = requestArg.scope + .split(' ') + .filter(Boolean) as plugins.idpInterfaces.data.TOidcScope[]; + const allowedScopes = + app.data.oauthCredentials.allowedScopes as plugins.idpInterfaces.data.TOidcScope[]; + const validScopes = requestedScopes.filter((scopeArg) => allowedScopes.includes(scopeArg)); + + if (!validScopes.includes('openid')) { + throw new Error('openid scope is required'); + } + + return { + app, + clientId: requestArg.clientId, + redirectUri: requestArg.redirectUri, + state: requestArg.state, + prompt: requestArg.prompt, + codeChallenge: requestArg.codeChallenge, + codeChallengeMethod: requestArg.codeChallengeMethod, + nonce: requestArg.nonce, + validScopes, + }; + } + + private async evaluateConsentRequirement( + userIdArg: string, + clientIdArg: string, + scopesArg: plugins.idpInterfaces.data.TOidcScope[], + promptArg?: 'none' | 'login' | 'consent' + ) { + const existingConsent = await this.getUserConsent(userIdArg, clientIdArg); + const grantedScopes = existingConsent?.data.scopes || []; + const missingScopes = scopesArg.filter((scopeArg) => !grantedScopes.includes(scopeArg)); + + return { + grantedScopes, + missingScopes, + consentRequired: promptArg === 'consent' || missingScopes.length > 0, + }; + } + private createOpaqueToken(byteLength = 32): string { return plugins.crypto.randomBytes(byteLength).toString('base64url'); } diff --git a/ts/reception/classes.reception.ts b/ts/reception/classes.reception.ts index 1d19e28..2b05c18 100644 --- a/ts/reception/classes.reception.ts +++ b/ts/reception/classes.reception.ts @@ -17,6 +17,7 @@ import { AppConnectionManager } from './classes.appconnectionmanager.js'; import { ActivityLogManager } from './classes.activitylogmanager.js'; import { UserInvitationManager } from './classes.userinvitationmanager.js'; import { OidcManager } from './classes.oidcmanager.js'; +import { AbuseProtectionManager } from './classes.abuseprotectionmanager.js'; export interface IReceptionOptions { /** @@ -48,6 +49,7 @@ export class Reception { public appConnectionManager = new AppConnectionManager(this); public activityLogManager = new ActivityLogManager(this); public userInvitationManager = new UserInvitationManager(this); + public abuseProtectionManager = new AbuseProtectionManager(this); public oidcManager = new OidcManager(this); housekeeping = new ReceptionHousekeeping(this); diff --git a/ts_idpclient/classes.idprequests.ts b/ts_idpclient/classes.idprequests.ts index 847711a..e27cf9c 100644 --- a/ts_idpclient/classes.idprequests.ts +++ b/ts_idpclient/classes.idprequests.ts @@ -76,6 +76,18 @@ export class IdpRequests { ); } + public get completeOidcAuthorization() { + return this.idpClientArg.typedsocket.createTypedRequest( + 'completeOidcAuthorization' + ); + } + + public get prepareOidcAuthorization() { + return this.idpClientArg.typedsocket.createTypedRequest( + 'prepareOidcAuthorization' + ); + } + public get resetPassword() { return this.idpClientArg.typedsocket.createTypedRequest( 'resetPassword' diff --git a/ts_interfaces/data/abusewindow.ts b/ts_interfaces/data/abusewindow.ts new file mode 100644 index 0000000..c0472b2 --- /dev/null +++ b/ts_interfaces/data/abusewindow.ts @@ -0,0 +1,13 @@ +export interface IAbuseWindow { + id: string; + data: { + action: string; + identifierHash: string; + attemptCount: number; + windowStartedAt: number; + blockedUntil: number; + validUntil: number; + createdAt: number; + updatedAt: number; + }; +} diff --git a/ts_interfaces/data/appconnection.ts b/ts_interfaces/data/appconnection.ts index 302136d..4e955eb 100644 --- a/ts_interfaces/data/appconnection.ts +++ b/ts_interfaces/data/appconnection.ts @@ -1,4 +1,4 @@ -import type { TAppType } from './loint-reception.app.js'; +import type { TAppType } from './app.js'; export type TAppConnectionStatus = 'active' | 'disconnected'; diff --git a/ts_interfaces/data/billingplan.ts b/ts_interfaces/data/billingplan.ts index 56b3e04..02c574a 100644 --- a/ts_interfaces/data/billingplan.ts +++ b/ts_interfaces/data/billingplan.ts @@ -1,4 +1,4 @@ -import * as plugins from '../loint-reception.plugins.js'; +import * as plugins from '../plugins.js'; export type TSupportedCurrency = 'EUR'; diff --git a/ts_interfaces/data/device.ts b/ts_interfaces/data/device.ts index c23b5a5..9884083 100644 --- a/ts_interfaces/data/device.ts +++ b/ts_interfaces/data/device.ts @@ -1,3 +1,3 @@ -import * as plugins from '../loint-reception.plugins.js'; +import * as plugins from '../plugins.js'; export interface IDevice extends plugins.tsclass.network.IDevice {} diff --git a/ts_interfaces/data/index.ts b/ts_interfaces/data/index.ts index d255e21..11204c5 100644 --- a/ts_interfaces/data/index.ts +++ b/ts_interfaces/data/index.ts @@ -1,15 +1,16 @@ -export * from './loint-reception.activity.js'; -export * from './loint-reception.app.js'; -export * from './loint-reception.emailactiontoken.js'; -export * from './loint-reception.oidc.js'; -export * from './loint-reception.appconnection.js'; -export * from './loint-reception.billingplan.js'; -export * from './loint-reception.device.js'; -export * from './loint-reception.jwt.js'; -export * from './loint-reception.loginsession.js'; -export * from './loint-reception.organization.js'; -export * from './loint-reception.paddlecheckoutdata.js'; -export * from './loint-reception.registrationsession.js'; -export * from './loint-reception.role.js'; -export * from './loint-reception.user.js'; -export * from './loint-reception.userinvitation.js'; +export * from './abusewindow.js'; +export * from './activity.js'; +export * from './app.js'; +export * from './emailactiontoken.js'; +export * from './oidc.js'; +export * from './appconnection.js'; +export * from './billingplan.js'; +export * from './device.js'; +export * from './jwt.js'; +export * from './loginsession.js'; +export * from './organization.js'; +export * from './paddlecheckoutdata.js'; +export * from './registrationsession.js'; +export * from './role.js'; +export * from './user.js'; +export * from './userinvitation.js'; diff --git a/ts_interfaces/data/organization.ts b/ts_interfaces/data/organization.ts index 6fb65e8..6d5d18f 100644 --- a/ts_interfaces/data/organization.ts +++ b/ts_interfaces/data/organization.ts @@ -1,6 +1,6 @@ -import * as plugins from '../loint-reception.plugins.js'; -import { type IBillingPlan } from './loint-reception.billingplan.js'; -import { type IRole } from './loint-reception.role.js'; +import * as plugins from '../plugins.js'; +import { type IBillingPlan } from './billingplan.js'; +import { type IRole } from './role.js'; export interface IOrganization { id: string; diff --git a/ts_interfaces/data/property.ts b/ts_interfaces/data/property.ts index 106fbdd..2a4cbc8 100644 --- a/ts_interfaces/data/property.ts +++ b/ts_interfaces/data/property.ts @@ -1,5 +1,5 @@ -import * as plugins from '../loint-reception.plugins.js'; -import { type IRole } from './loint-reception.role.js'; +import * as plugins from '../plugins.js'; +import { type IRole } from './role.js'; export interface ISubOrgProperty { name: string; diff --git a/ts_interfaces/data/role.ts b/ts_interfaces/data/role.ts index 93de81a..8fa9ac6 100644 --- a/ts_interfaces/data/role.ts +++ b/ts_interfaces/data/role.ts @@ -1,4 +1,4 @@ -import * as plugins from '../loint-reception.plugins.js'; +import * as plugins from '../plugins.js'; /** Standard role types available in all organizations */ export type TStandardRole = 'owner' | 'admin' | 'editor' | 'guest' | 'viewer' | 'outlaw'; diff --git a/ts_interfaces/data/user.ts b/ts_interfaces/data/user.ts index 6e69067..659946d 100644 --- a/ts_interfaces/data/user.ts +++ b/ts_interfaces/data/user.ts @@ -1,5 +1,5 @@ -import * as plugins from '../loint-reception.plugins.js'; -import { type IRole } from './loint-reception.role.js'; +import * as plugins from '../plugins.js'; +import { type IRole } from './role.js'; export interface IUser { id: string; diff --git a/ts_interfaces/data/userinvitation.ts b/ts_interfaces/data/userinvitation.ts index eff0874..226fa29 100644 --- a/ts_interfaces/data/userinvitation.ts +++ b/ts_interfaces/data/userinvitation.ts @@ -1,4 +1,4 @@ -import * as plugins from '../loint-reception.plugins.js'; +import * as plugins from '../plugins.js'; /** * A UserInvitation represents an invitation to join an organization. diff --git a/ts_interfaces/request/admin.ts b/ts_interfaces/request/admin.ts index 71c4846..dce3bcb 100644 --- a/ts_interfaces/request/admin.ts +++ b/ts_interfaces/request/admin.ts @@ -1,4 +1,4 @@ -import * as plugins from '../loint-reception.plugins.js'; +import * as plugins from '../plugins.js'; import * as data from '../data/index.js'; /** diff --git a/ts_interfaces/request/app.ts b/ts_interfaces/request/app.ts index 72cac41..f6daa24 100644 --- a/ts_interfaces/request/app.ts +++ b/ts_interfaces/request/app.ts @@ -1,5 +1,5 @@ import * as data from '../data/index.js'; -import * as plugins from '../loint-reception.plugins.js'; +import * as plugins from '../plugins.js'; // Get all global apps export interface IReq_GetGlobalApps diff --git a/ts_interfaces/request/authorization.ts b/ts_interfaces/request/authorization.ts index e566ca1..43d076a 100644 --- a/ts_interfaces/request/authorization.ts +++ b/ts_interfaces/request/authorization.ts @@ -1,5 +1,6 @@ -import * as plugins from '../loint-reception.plugins.js'; +import * as plugins from '../plugins.js'; import { type IUser, type IRole } from '../data/index.js'; +import { type TOidcScope } from '../data/index.js'; export interface IReq_InternalAuthorization extends plugins.typedRequestInterfaces.implementsTR< @@ -17,3 +18,55 @@ export interface IReq_InternalAuthorization relevantRoles: IRole[]; }; } + +export interface IReq_CompleteOidcAuthorization + extends plugins.typedRequestInterfaces.implementsTR< + plugins.typedRequestInterfaces.ITypedRequest, + IReq_CompleteOidcAuthorization + > { + method: 'completeOidcAuthorization'; + request: { + jwt: string; + clientId: string; + redirectUri: string; + scope: string; + state: string; + prompt?: 'none' | 'login' | 'consent'; + codeChallenge?: string; + codeChallengeMethod?: 'S256'; + nonce?: string; + consentApproved?: boolean; + }; + response: { + code: string; + redirectUrl: string; + }; +} + +export interface IReq_PrepareOidcAuthorization + extends plugins.typedRequestInterfaces.implementsTR< + plugins.typedRequestInterfaces.ITypedRequest, + IReq_PrepareOidcAuthorization + > { + method: 'prepareOidcAuthorization'; + request: { + jwt: string; + clientId: string; + redirectUri: string; + scope: string; + state: string; + prompt?: 'none' | 'login' | 'consent'; + codeChallenge?: string; + codeChallengeMethod?: 'S256'; + nonce?: string; + }; + response: { + status: 'ready' | 'consent_required'; + clientId: string; + appName: string; + appUrl: string; + logoUrl?: string; + requestedScopes: TOidcScope[]; + grantedScopes: TOidcScope[]; + }; +} diff --git a/ts_interfaces/request/billingplan.ts b/ts_interfaces/request/billingplan.ts index 45d42d5..1ceb5ec 100644 --- a/ts_interfaces/request/billingplan.ts +++ b/ts_interfaces/request/billingplan.ts @@ -1,4 +1,4 @@ -import * as plugins from '../loint-reception.plugins.js'; +import * as plugins from '../plugins.js'; import * as data from '../data/index.js'; export interface IReq_UpdatePaymentMethod diff --git a/ts_interfaces/request/index.ts b/ts_interfaces/request/index.ts index 6ee4299..92cf45e 100644 --- a/ts_interfaces/request/index.ts +++ b/ts_interfaces/request/index.ts @@ -1,12 +1,12 @@ -export * from './loint-reception.admin.js'; -export * from './loint-reception.apitoken.js'; -export * from './loint-reception.app.js'; -export * from './loint-reception.authorization.js'; -export * from './loint-reception.billingplan.js'; -export * from './loint-reception.jwt.js'; -export * from './loint-reception.login.js'; -export * from './loint-reception.organization.js'; -export * from './loint-reception.plan.js'; -export * from './loint-reception.registration.js'; -export * from './loint-reception.user.js'; -export * from './loint-reception.userinvitation.js'; +export * from './admin.js'; +export * from './apitoken.js'; +export * from './app.js'; +export * from './authorization.js'; +export * from './billingplan.js'; +export * from './jwt.js'; +export * from './login.js'; +export * from './organization.js'; +export * from './plan.js'; +export * from './registration.js'; +export * from './user.js'; +export * from './userinvitation.js'; diff --git a/ts_interfaces/request/jwt.ts b/ts_interfaces/request/jwt.ts index 44df79c..3661a87 100644 --- a/ts_interfaces/request/jwt.ts +++ b/ts_interfaces/request/jwt.ts @@ -1,5 +1,5 @@ import * as data from '../data/index.js'; -import * as plugins from '../loint-reception.plugins.js'; +import * as plugins from '../plugins.js'; /** * Request to get the public key for JWT validation. diff --git a/ts_interfaces/request/login.ts b/ts_interfaces/request/login.ts index e79c588..66ec022 100644 --- a/ts_interfaces/request/login.ts +++ b/ts_interfaces/request/login.ts @@ -1,4 +1,4 @@ -import * as plugins from '../loint-reception.plugins.js'; +import * as plugins from '../plugins.js'; import * as data from '../data/index.js'; export interface IReq_LoginWithEmailOrUsernameAndPassword diff --git a/ts_interfaces/request/organization.ts b/ts_interfaces/request/organization.ts index 65737fc..34d29af 100644 --- a/ts_interfaces/request/organization.ts +++ b/ts_interfaces/request/organization.ts @@ -1,5 +1,5 @@ import * as data from '../data/index.js'; -import * as plugins from '../loint-reception.plugins.js'; +import * as plugins from '../plugins.js'; export interface IReq_GetOrganizationById extends plugins.typedRequestInterfaces.implementsTR< diff --git a/ts_interfaces/request/plan.ts b/ts_interfaces/request/plan.ts index a8da97b..7cc769f 100644 --- a/ts_interfaces/request/plan.ts +++ b/ts_interfaces/request/plan.ts @@ -1,5 +1,5 @@ import * as data from '../data/index.js'; -import * as plugins from '../loint-reception.plugins.js'; +import * as plugins from '../plugins.js'; export interface IReq_GetPlansForOrganizationId extends plugins.typedRequestInterfaces.implementsTR< diff --git a/ts_interfaces/request/registration.ts b/ts_interfaces/request/registration.ts index fb17c03..1c233c3 100644 --- a/ts_interfaces/request/registration.ts +++ b/ts_interfaces/request/registration.ts @@ -1,4 +1,4 @@ -import * as plugins from '../loint-reception.plugins.js'; +import * as plugins from '../plugins.js'; import { type IUser } from '../data/index.js'; export interface IReq_FirstRegistration diff --git a/ts_interfaces/request/user.ts b/ts_interfaces/request/user.ts index b7bd66b..bbbffd9 100644 --- a/ts_interfaces/request/user.ts +++ b/ts_interfaces/request/user.ts @@ -1,5 +1,5 @@ import * as data from '../data/index.js'; -import * as plugins from '../loint-reception.plugins.js'; +import * as plugins from '../plugins.js'; export interface IReq_GetUserData extends plugins.typedRequestInterfaces.implementsTR< diff --git a/ts_interfaces/request/userinvitation.ts b/ts_interfaces/request/userinvitation.ts index 1ee7047..1d7e632 100644 --- a/ts_interfaces/request/userinvitation.ts +++ b/ts_interfaces/request/userinvitation.ts @@ -1,5 +1,5 @@ import * as data from '../data/index.js'; -import * as plugins from '../loint-reception.plugins.js'; +import * as plugins from '../plugins.js'; /** * Create an invitation to join an organization diff --git a/ts_interfaces/tags/index.ts b/ts_interfaces/tags/index.ts index 4e8f114..649e3f5 100644 --- a/ts_interfaces/tags/index.ts +++ b/ts_interfaces/tags/index.ts @@ -1,4 +1,4 @@ -import * as plugins from '../loint-reception.plugins.js'; +import * as plugins from '../plugins.js'; export interface ITag_LolePubapi extends plugins.typedRequestInterfaces.implementsTag< diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index 39e4156..0aea42a 100644 --- a/ts_web/00_commitinfo_data.ts +++ b/ts_web/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@idp.global/idp.global', - version: '1.19.1', + version: '1.20.0', description: 'An identity provider software managing user authentications, registrations, and sessions.' } diff --git a/ts_web/elements/idp-loginprompt.ts b/ts_web/elements/idp-loginprompt.ts index b184afc..7187e6c 100644 --- a/ts_web/elements/idp-loginprompt.ts +++ b/ts_web/elements/idp-loginprompt.ts @@ -12,7 +12,6 @@ import { domtools, } from '@design.estate/dees-element'; -// third party catalogs import '@uptime.link/webwidget'; import '@design.estate/dees-catalog'; @@ -29,6 +28,12 @@ declare global { export class IdpLoginPrompt extends DeesElement { public static demo = () => html``; + @state() + accessor oidcConsentState: plugins.idpInterfaces.request.IReq_PrepareOidcAuthorization['response'] | null = null; + + @state() + accessor oidcConsentError = ''; + @property() accessor productOfInterest: string; @@ -48,6 +53,155 @@ export class IdpLoginPrompt extends DeesElement { domtools.elementBasic.setup(); } + private getOidcAuthorizationContext(): Omit< + plugins.idpInterfaces.request.IReq_CompleteOidcAuthorization['request'], + 'jwt' + > | null { + const currentUrl = plugins.smarturl.Smarturl.createFromUrl(window.location.href); + + if (currentUrl.searchParams.oauth !== 'true') { + return null; + } + + const clientId = currentUrl.searchParams.client_id; + const redirectUri = currentUrl.searchParams.redirect_uri; + const scope = currentUrl.searchParams.scope; + const state = currentUrl.searchParams.state; + + if (!clientId || !redirectUri || !scope || !state) { + return null; + } + + const prompt = ['none', 'login', 'consent'].includes(currentUrl.searchParams.prompt) + ? (currentUrl.searchParams.prompt as 'none' | 'login' | 'consent') + : undefined; + + return { + clientId, + redirectUri, + scope, + state, + prompt, + codeChallenge: currentUrl.searchParams.code_challenge || undefined, + codeChallengeMethod: + currentUrl.searchParams.code_challenge_method === 'S256' ? 'S256' : undefined, + nonce: currentUrl.searchParams.nonce || undefined, + }; + } + + private redirectOidcError(errorArg: string, descriptionArg?: string) { + const oidcContext = this.getOidcAuthorizationContext(); + if (!oidcContext) { + return false; + } + + const redirectUrl = new URL(oidcContext.redirectUri); + redirectUrl.searchParams.set('error', errorArg); + redirectUrl.searchParams.set('state', oidcContext.state); + if (descriptionArg) { + redirectUrl.searchParams.set('error_description', descriptionArg); + } + window.location.href = redirectUrl.toString(); + return true; + } + + private getOidcScopeDescription(scopeArg: plugins.idpInterfaces.data.TOidcScope) { + const scopeMap: Record = { + openid: 'Confirm your identity with this app.', + profile: 'Share your display name and username.', + email: 'Share your email address.', + organizations: 'Share your organizations and their roles.', + roles: 'Share your platform roles.', + }; + + return scopeMap[scopeArg]; + } + + private getOidcAppHost(appUrlArg: string) { + try { + return new URL(appUrlArg).hostname; + } catch { + return appUrlArg; + } + } + + private async prepareOidcAuthorization(jwtArg: string) { + const oidcContext = this.getOidcAuthorizationContext(); + if (!oidcContext) { + return null; + } + + const idpState = await IdpState.getSingletonInstance(); + return idpState.idpClient.requests.prepareOidcAuthorization + .fire({ + jwt: jwtArg, + ...oidcContext, + }) + .catch(() => null); + } + + private async handleOidcAfterLogin(jwtArg: string) { + const oidcContext = this.getOidcAuthorizationContext(); + if (!oidcContext) { + return false; + } + + const loginForm = this.shadowRoot.querySelector('#loginForm') as DeesForm | null; + loginForm?.setStatus('pending', 'preparing application authorization...'); + this.oidcConsentError = ''; + + const preparation = await this.prepareOidcAuthorization(jwtArg); + if (!preparation) { + loginForm?.setStatus('error', 'could not prepare the application authorization'); + return true; + } + + if (preparation.status === 'consent_required') { + if (oidcContext.prompt === 'none') { + this.redirectOidcError('consent_required'); + return true; + } + + this.oidcConsentState = preparation; + return true; + } + + await this.completeOidcAuthorization(jwtArg); + return true; + } + + private async completeOidcAuthorization(jwtArg: string, consentApproved = false) { + const oidcContext = this.getOidcAuthorizationContext(); + if (!oidcContext) { + return false; + } + + const idpState = await IdpState.getSingletonInstance(); + const loginForm = this.shadowRoot.querySelector('#loginForm') as DeesForm | null; + loginForm?.setStatus('pending', 'authorizing application...'); + this.oidcConsentError = ''; + + const response = await idpState.idpClient.requests.completeOidcAuthorization + .fire({ + jwt: jwtArg, + ...oidcContext, + consentApproved, + }) + .catch(() => null); + + if (!response?.redirectUrl) { + if (this.oidcConsentState) { + this.oidcConsentError = 'Could not authorize the application.'; + } else { + loginForm?.setStatus('error', 'could not authorize the application'); + } + return false; + } + + window.location.href = response.redirectUrl; + return true; + } + public static styles = [ cssManager.defaultStyles, css` @@ -103,10 +257,147 @@ export class IdpLoginPrompt extends DeesElement { .form-footer a:hover { opacity: 0.8; } + + .consent-card { + display: flex; + flex-direction: column; + gap: 16px; + padding: 24px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 18px; + background: rgba(255, 255, 255, 0.04); + } + + .consent-appname { + font-size: 20px; + font-weight: 600; + } + + .consent-appurl { + color: var(--muted-foreground); + font-size: 14px; + word-break: break-word; + } + + .consent-scopes { + display: flex; + flex-direction: column; + gap: 12px; + } + + .consent-scope { + padding: 14px 16px; + border-radius: 14px; + background: rgba(255, 255, 255, 0.03); + } + + .consent-scope-header { + display: flex; + justify-content: space-between; + gap: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + font-size: 12px; + } + + .consent-scope-tag { + color: #9cd67c; + } + + .consent-scope-description { + margin-top: 6px; + color: var(--muted-foreground); + font-size: 14px; + line-height: 1.5; + } + + .consent-actions { + display: flex; + justify-content: flex-end; + gap: 12px; + } + + .consent-button { + border: none; + border-radius: 999px; + padding: 12px 18px; + font: inherit; + cursor: pointer; + } + + .consent-button-secondary { + background: rgba(255, 255, 255, 0.08); + color: var(--foreground); + } + + .consent-button-primary { + background: linear-gradient(135deg, #9b7bff, #5fd1ff); + color: #0a0a0a; + font-weight: 600; + } + + .consent-error { + color: #ff9a9a; + font-size: 14px; + } `, ]; public render(): TemplateResult { + if (this.oidcConsentState) { + return html` + +
+

Continue to ${this.oidcConsentState.appName}

+

Review and approve the access this app is requesting.

+
+ +
+ `; + } + return html`
@@ -115,12 +406,12 @@ export class IdpLoginPrompt extends DeesElement {