import { tap, expect } from '@git.zone/tstest/tapbundle'; import { App } from '../ts/reception/classes.app.js'; import { AppConnection } from '../ts/reception/classes.appconnection.js'; 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'; import { Role } from '../ts/reception/classes.role.js'; import { User } from '../ts/reception/classes.user.js'; const createTestOidcManager = (receptionOverridesArg: Record = {}) => { const oidcManager = new OidcManager({ db: { smartdataDb: {} }, typedrouter: { addTypedRouter: () => undefined }, options: { baseUrl: 'https://idp.example' }, ...receptionOverridesArg, } 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'; authCode.data.codeHash = OidcAuthorizationCode.hashCode('plain-auth-code'); let saveCount = 0; (authCode as OidcAuthorizationCode & { save: () => Promise }).save = async () => { saveCount++; }; expect(authCode.matchesCode('plain-auth-code')).toBeTrue(); expect(authCode.matchesCode('wrong-code')).toBeFalse(); await authCode.markUsed(); expect(authCode.data.used).toBeTrue(); expect(saveCount).toEqual(1); }); tap.test('stores access tokens without plaintext persistence', async () => { const accessToken = new OidcAccessToken(); accessToken.id = 'oidc-access-token'; accessToken.data.tokenHash = OidcAccessToken.hashToken('plain-access-token'); accessToken.data.expiresAt = Date.now() + 60_000; expect(accessToken.matchesToken('plain-access-token')).toBeTrue(); expect(accessToken.matchesToken('different-access-token')).toBeFalse(); expect(accessToken.isExpired()).toBeFalse(); }); tap.test('revokes persisted refresh tokens', async () => { const refreshToken = new OidcRefreshToken(); refreshToken.id = 'oidc-refresh-token'; refreshToken.data.tokenHash = OidcRefreshToken.hashToken('plain-refresh-token'); refreshToken.data.expiresAt = Date.now() + 60_000; let saveCount = 0; (refreshToken as OidcRefreshToken & { save: () => Promise }).save = async () => { saveCount++; }; expect(refreshToken.matchesToken('plain-refresh-token')).toBeTrue(); expect(refreshToken.data.revoked).toBeFalse(); await refreshToken.revoke(); expect(refreshToken.data.revoked).toBeTrue(); expect(saveCount).toEqual(1); }); tap.test('merges user consent scopes without duplicates', async () => { const consent = new OidcUserConsent(); consent.id = 'oidc-consent'; consent.data.userId = 'user-1'; consent.data.clientId = 'client-1'; consent.data.scopes = ['openid']; let saveCount = 0; (consent as OidcUserConsent & { save: () => Promise }).save = async () => { saveCount++; }; await consent.grantScopes(['openid', 'email', 'profile']); expect(consent.data.scopes.sort()).toEqual(['email', 'openid', 'profile']); expect(consent.data.grantedAt).toBeGreaterThan(0); expect(consent.data.updatedAt).toBeGreaterThan(0); 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(); }); tap.test('includes connected app role mappings in roles-scope claims', async () => { const user = new User(); user.id = 'user-1'; user.data = { name: 'Finance User', username: 'finance-user', email: 'finance@example.com', status: 'active', connectedOrgs: ['org-1'], }; const role = new Role(); role.id = 'role-1'; role.data = { userId: user.id, organizationId: 'org-1', roles: ['finance'], }; const app = new App(); app.id = 'app-1'; app.type = 'global'; app.data = { name: 'Accounting', description: 'Accounting app', logoUrl: '', appUrl: 'https://accounting.example', category: 'finance', isActive: true, createdAt: Date.now(), createdByUserId: 'admin-1', oauthCredentials: { clientId: 'client-1', clientSecretHash: 'secret-hash', redirectUris: ['https://accounting.example/callback'], allowedScopes: ['openid', 'roles'], grantTypes: ['authorization_code'], }, }; const connection = new AppConnection(); connection.id = 'connection-1'; connection.data = { organizationId: 'org-1', appId: app.id, appType: 'global', status: 'active', connectedAt: Date.now(), connectedByUserId: 'admin-1', grantedScopes: ['openid', 'roles'], roleMappings: [{ orgRoleKey: 'finance', appRoles: ['accountant'], permissions: ['invoices:read'], scopes: ['billing'], }], }; const oidcManager = createTestOidcManager({ userManager: { CUser: { getInstance: async () => user, }, }, roleManager: { getAllRolesForUser: async () => [role], }, appManager: { CApp: { getInstances: async () => [app], }, }, appConnectionManager: { CAppConnection: { getInstances: async () => [connection], }, }, }); const claims = await (oidcManager as any).getUserClaims(user.id, ['roles'], 'client-1'); expect(claims.app_roles).toEqual(['accountant']); expect(claims.app_permissions).toEqual(['invoices:read']); expect(claims.app_scopes).toEqual(['billing']); await oidcManager.stop(); }); export default tap.start();