302 lines
9.0 KiB
TypeScript
302 lines
9.0 KiB
TypeScript
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<string, any> = {}) => {
|
|
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<void> }).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<void> }).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<void> }).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();
|