Initial commit: Hedena Gold monorepo.
First commit of the Hedena Gold product (separate from gold-bank): branding, package names, and deploy defaults aligned to the new brand; legacy Henza trees stay local-only via .gitignore.
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
import { createSign, generateKeyPairSync, type KeyObject } from 'node:crypto';
|
||||
import { IamLogoutService, IamLogoutTokenError } from './iam-logout.service';
|
||||
import type { IamService } from './iam.service';
|
||||
|
||||
/**
|
||||
* Back-channel logout token verification.
|
||||
*
|
||||
* This endpoint is unauthenticated by design: the token's signature is the only
|
||||
* thing standing between a stranger and "sign out every admin of this system".
|
||||
* Each test here is one of the checks that keeps that true.
|
||||
*/
|
||||
describe('IamLogoutService', () => {
|
||||
const ISSUER = 'https://auth.test.local';
|
||||
const CLIENT_ID = 'hedena_gold_api';
|
||||
const EVENT = 'http://schemas.openid.net/event/backchannel-logout';
|
||||
|
||||
let keyPair: { publicKey: KeyObject; privateKey: KeyObject };
|
||||
let otherKeyPair: { publicKey: KeyObject; privateKey: KeyObject };
|
||||
let service: IamLogoutService;
|
||||
let fetchMock: jest.Mock;
|
||||
|
||||
const jwk = (key: KeyObject, kid: string) => ({ ...key.export({ format: 'jwk' }), kid, alg: 'RS256', use: 'sig' });
|
||||
|
||||
const sign = (
|
||||
payload: Record<string, unknown>,
|
||||
options: { kid?: string; alg?: string; key?: KeyObject } = {},
|
||||
): string => {
|
||||
const header = { alg: options.alg ?? 'RS256', typ: 'logout+jwt', kid: options.kid ?? 'key-1' };
|
||||
const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString('base64url');
|
||||
const body = `${encode(header)}.${encode(payload)}`;
|
||||
const signer = createSign('RSA-SHA256');
|
||||
signer.update(body);
|
||||
return `${body}.${signer.sign(options.key ?? keyPair.privateKey).toString('base64url')}`;
|
||||
};
|
||||
|
||||
const validPayload = (overrides: Record<string, unknown> = {}) => ({
|
||||
iss: ISSUER,
|
||||
aud: CLIENT_ID,
|
||||
iat: Math.floor(Date.now() / 1000),
|
||||
exp: Math.floor(Date.now() / 1000) + 120,
|
||||
jti: 'logout-1',
|
||||
sub: 'sub-1',
|
||||
sid: 'sid-1',
|
||||
events: { [EVENT]: {} },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
keyPair = generateKeyPairSync('rsa', { modulusLength: 2048 });
|
||||
otherKeyPair = generateKeyPairSync('rsa', { modulusLength: 2048 });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
service = new IamLogoutService({ issuer: ISSUER, clientId: CLIENT_ID } as IamService);
|
||||
fetchMock = jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ keys: [jwk(keyPair.publicKey, 'key-1')] }),
|
||||
});
|
||||
global.fetch = fetchMock as unknown as typeof fetch;
|
||||
});
|
||||
|
||||
/* ─────────────── the happy path ─────────────── */
|
||||
|
||||
it('accepts a properly signed logout token and reports the session', async () => {
|
||||
const result = await service.verify(sign(validPayload()));
|
||||
|
||||
expect(result).toEqual({ sessionId: 'sid-1', subject: 'sub-1' });
|
||||
expect(fetchMock.mock.calls[0][0]).toBe(`${ISSUER}/.well-known/jwks.json`);
|
||||
});
|
||||
|
||||
it('accepts a token that names only the identity', async () => {
|
||||
const result = await service.verify(sign(validPayload({ sid: undefined })));
|
||||
|
||||
expect(result).toEqual({ sessionId: null, subject: 'sub-1' });
|
||||
});
|
||||
|
||||
it('caches the keys instead of fetching them for every notification', async () => {
|
||||
await service.verify(sign(validPayload()));
|
||||
await service.verify(sign(validPayload({ jti: 'logout-2' })));
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('refetches the keys when an unknown key id appears, so rotation needs no restart', async () => {
|
||||
await service.verify(sign(validPayload()));
|
||||
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ keys: [jwk(otherKeyPair.publicKey, 'key-2')] }),
|
||||
});
|
||||
|
||||
const rotated = sign(validPayload({ jti: 'logout-3' }), { kid: 'key-2', key: otherKeyPair.privateKey });
|
||||
await expect(service.verify(rotated)).resolves.toMatchObject({ sessionId: 'sid-1' });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
/* ─────────────── what must be refused ─────────────── */
|
||||
|
||||
it('refuses a token signed with an unknown key', async () => {
|
||||
const forged = sign(validPayload(), { key: otherKeyPair.privateKey });
|
||||
|
||||
await expect(service.verify(forged)).rejects.toBeInstanceOf(IamLogoutTokenError);
|
||||
});
|
||||
|
||||
it('refuses an unsigned token however it describes itself', async () => {
|
||||
const parts = sign(validPayload()).split('.');
|
||||
const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'logout+jwt' })).toString('base64url');
|
||||
|
||||
await expect(service.verify(`${header}.${parts[1]}.`)).rejects.toBeInstanceOf(IamLogoutTokenError);
|
||||
});
|
||||
|
||||
it('refuses a token from another issuer', async () => {
|
||||
await expect(
|
||||
service.verify(sign(validPayload({ iss: 'https://evil.test.local' }))),
|
||||
).rejects.toThrow('another identity service');
|
||||
});
|
||||
|
||||
it('refuses a token addressed to another client', async () => {
|
||||
await expect(service.verify(sign(validPayload({ aud: 'someone_else' })))).rejects.toThrow(
|
||||
'not addressed to this client',
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses an ID token replayed as a logout token', async () => {
|
||||
// Without the events check, an ID token from a normal login would end sessions
|
||||
await expect(service.verify(sign(validPayload({ events: undefined })))).rejects.toThrow(
|
||||
'back-channel logout event',
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses a logout token carrying a nonce', async () => {
|
||||
await expect(service.verify(sign(validPayload({ nonce: 'n-1' })))).rejects.toThrow('nonce');
|
||||
});
|
||||
|
||||
it('refuses an expired token', async () => {
|
||||
const stale = validPayload({
|
||||
iat: Math.floor(Date.now() / 1000) - 3600,
|
||||
exp: Math.floor(Date.now() / 1000) - 3000,
|
||||
});
|
||||
|
||||
await expect(service.verify(sign(stale))).rejects.toThrow('expired');
|
||||
});
|
||||
|
||||
it('refuses a token that names neither a session nor an identity', async () => {
|
||||
await expect(
|
||||
service.verify(sign(validPayload({ sid: undefined, sub: undefined }))),
|
||||
).rejects.toThrow('neither a session nor a subject');
|
||||
});
|
||||
|
||||
it('refuses a malformed token', async () => {
|
||||
await expect(service.verify('not-a-token')).rejects.toBeInstanceOf(IamLogoutTokenError);
|
||||
});
|
||||
|
||||
it('reports an unreachable identity service as an availability problem', async () => {
|
||||
fetchMock.mockRejectedValue(new Error('ECONNREFUSED'));
|
||||
|
||||
await expect(service.verify(sign(validPayload()))).rejects.toThrow('could not reach');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { createPublicKey, createVerify, type KeyObject } from 'node:crypto';
|
||||
import { IamService, IamUnavailableError } from './iam.service';
|
||||
|
||||
/** The logout token was not a valid, current back-channel logout token. */
|
||||
export class IamLogoutTokenError extends Error {}
|
||||
|
||||
export interface LogoutSubject {
|
||||
/** The IAM session id (`sid`), when the token names one session. */
|
||||
sessionId: string | null;
|
||||
/** The IAM user id (`sub`), when the token names a whole identity. */
|
||||
subject: string | null;
|
||||
}
|
||||
|
||||
const BACKCHANNEL_LOGOUT_EVENT = 'http://schemas.openid.net/event/backchannel-logout';
|
||||
|
||||
/**
|
||||
* Verification of OpenID Connect Back-Channel Logout 1.0 tokens.
|
||||
*
|
||||
* The IAM calls this app when a session ends elsewhere (the user signed out, or
|
||||
* an administrator revoked the session). The request carries no credentials by
|
||||
* design — its authority is the token's RS256 signature, checked here against the
|
||||
* IAM's published keys — so every one of the spec's checks matters:
|
||||
*
|
||||
* - signature verifies against a key from the IAM's JWKS (keys are cached, and
|
||||
* an unknown `kid` refetches once so key rotation needs no restart);
|
||||
* - `iss` is exactly the configured issuer and `aud` contains this client id,
|
||||
* otherwise another tenant's token could sign out our admins;
|
||||
* - the `events` claim holds the back-channel logout event, so an ID token
|
||||
* cannot be replayed here;
|
||||
* - a `nonce` MUST NOT be present (that would make it an ID token);
|
||||
* - `iat`/`exp` are honoured with a small clock skew allowance.
|
||||
*
|
||||
* No new dependency: Node verifies RS256 and imports a JWK natively.
|
||||
*/
|
||||
@Injectable()
|
||||
export class IamLogoutService {
|
||||
private readonly logger = new Logger(IamLogoutService.name);
|
||||
|
||||
/** kid -> public key, plus when the set was fetched. */
|
||||
private keys = new Map<string, KeyObject>();
|
||||
private keysFetchedAt = 0;
|
||||
|
||||
constructor(private readonly iam: IamService) {}
|
||||
|
||||
/** Seconds of clock skew tolerated on `iat`/`exp`. */
|
||||
private get leeway(): number {
|
||||
return 60;
|
||||
}
|
||||
|
||||
async verify(logoutToken: string): Promise<LogoutSubject> {
|
||||
const parts = logoutToken.split('.');
|
||||
if (parts.length !== 3) throw new IamLogoutTokenError('logout token is malformed');
|
||||
|
||||
const [rawHeader, rawPayload, rawSignature] = parts as [string, string, string];
|
||||
const header = this.decodeJson(rawHeader, 'header');
|
||||
const payload = this.decodeJson(rawPayload, 'payload');
|
||||
|
||||
if (header.alg !== 'RS256') {
|
||||
// Never trust the token's own word that it needs no signature.
|
||||
throw new IamLogoutTokenError(`unsupported logout token algorithm: ${String(header.alg)}`);
|
||||
}
|
||||
|
||||
const key = await this.publicKey(typeof header.kid === 'string' ? header.kid : null);
|
||||
const verifier = createVerify('RSA-SHA256');
|
||||
verifier.update(`${rawHeader}.${rawPayload}`);
|
||||
if (!verifier.verify(key, Buffer.from(rawSignature, 'base64url'))) {
|
||||
throw new IamLogoutTokenError('logout token signature does not verify');
|
||||
}
|
||||
|
||||
this.assertClaims(payload);
|
||||
|
||||
const sessionId = typeof payload.sid === 'string' && payload.sid !== '' ? payload.sid : null;
|
||||
const subject = typeof payload.sub === 'string' && payload.sub !== '' ? payload.sub : null;
|
||||
if (!sessionId && !subject) {
|
||||
throw new IamLogoutTokenError('logout token names neither a session nor a subject');
|
||||
}
|
||||
|
||||
return { sessionId, subject };
|
||||
}
|
||||
|
||||
private assertClaims(payload: Record<string, unknown>): void {
|
||||
if (payload.iss !== this.iam.issuer) {
|
||||
throw new IamLogoutTokenError('logout token was issued by another identity service');
|
||||
}
|
||||
|
||||
const audiences = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
|
||||
if (!audiences.includes(this.iam.clientId)) {
|
||||
throw new IamLogoutTokenError('logout token is not addressed to this client');
|
||||
}
|
||||
|
||||
// A logout token carries the logout event; an ID token does not. Without this
|
||||
// check an ID token obtained during a normal login could end sessions.
|
||||
const events = payload.events as Record<string, unknown> | undefined;
|
||||
if (!events || typeof events !== 'object' || !(BACKCHANNEL_LOGOUT_EVENT in events)) {
|
||||
throw new IamLogoutTokenError('logout token does not carry the back-channel logout event');
|
||||
}
|
||||
|
||||
if ('nonce' in payload) {
|
||||
throw new IamLogoutTokenError('a logout token must not carry a nonce');
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (typeof payload.exp === 'number' && payload.exp + this.leeway < now) {
|
||||
throw new IamLogoutTokenError('logout token has expired');
|
||||
}
|
||||
if (typeof payload.iat === 'number' && payload.iat - this.leeway > now) {
|
||||
throw new IamLogoutTokenError('logout token is not valid yet');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The signing key for this `kid`. An unknown kid triggers one refetch, so a key
|
||||
* rotation at the IAM takes effect without restarting this service.
|
||||
*/
|
||||
private async publicKey(kid: string | null): Promise<KeyObject> {
|
||||
const cacheAgeSeconds = (Date.now() - this.keysFetchedAt) / 1000;
|
||||
const stale = cacheAgeSeconds > 24 * 3600;
|
||||
const cached = kid ? this.keys.get(kid) : [...this.keys.values()][0];
|
||||
|
||||
if (cached && !stale) return cached;
|
||||
|
||||
await this.loadKeys(false);
|
||||
|
||||
let key = kid ? this.keys.get(kid) : [...this.keys.values()][0];
|
||||
if (!key && kid) {
|
||||
await this.loadKeys(true);
|
||||
key = this.keys.get(kid);
|
||||
}
|
||||
if (!key) throw new IamLogoutTokenError('no matching signing key at the identity service');
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
private async loadKeys(bypassCache: boolean): Promise<void> {
|
||||
const uri = bypassCache
|
||||
? `${this.iam.issuer}/.well-known/jwks.json?_=${Date.now()}`
|
||||
: `${this.iam.issuer}/.well-known/jwks.json`;
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(uri, {
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
...(bypassCache ? { 'cache-control': 'no-cache', pragma: 'no-cache' } : {}),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
throw new IamUnavailableError(`could not reach the identity service: ${(error as Error).message}`);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new IamUnavailableError(`the identity service returned ${response.status} for its keys`);
|
||||
}
|
||||
|
||||
const body = (await response.json()) as { keys?: Array<Record<string, unknown>> };
|
||||
const keys = new Map<string, KeyObject>();
|
||||
|
||||
for (const jwk of body.keys ?? []) {
|
||||
if (jwk.kty !== 'RSA' || typeof jwk.kid !== 'string') continue;
|
||||
try {
|
||||
keys.set(jwk.kid, createPublicKey({ key: jwk as never, format: 'jwk' }));
|
||||
} catch (error) {
|
||||
this.logger.warn(`skipping an unusable key from the identity service: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (keys.size === 0) throw new IamUnavailableError('the identity service published no usable keys');
|
||||
|
||||
this.keys = keys;
|
||||
this.keysFetchedAt = Date.now();
|
||||
}
|
||||
|
||||
private decodeJson(segment: string, what: string): Record<string, unknown> {
|
||||
try {
|
||||
return JSON.parse(Buffer.from(segment, 'base64url').toString('utf8')) as Record<string, unknown>;
|
||||
} catch {
|
||||
throw new IamLogoutTokenError(`logout token ${what} is not readable`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Body, Controller, HttpCode, Logger, Post } from '@nestjs/common';
|
||||
import { Public } from '../auth/guards';
|
||||
import { AdminSessionService } from '../sessions/admin-session.service';
|
||||
import { AppSessionService } from '../sessions/app-session.service';
|
||||
import { IamLogoutService, IamLogoutTokenError } from './iam-logout.service';
|
||||
import { IamService } from './iam.service';
|
||||
|
||||
/**
|
||||
* Back-channel logout receiver (OpenID Connect Back-Channel Logout 1.0).
|
||||
*
|
||||
* Register this path as the client's `backchannel_logout_uri` in the IAM:
|
||||
*
|
||||
* https://<api-host>/api/iam/backchannel-logout
|
||||
*
|
||||
* The endpoint is public because it carries no credentials by design — its
|
||||
* authority is the logout token's signature, verified against the IAM's keys. It
|
||||
* always answers 200, as the spec asks, so a rejected token does not make the IAM
|
||||
* retry forever; the reason is logged here instead.
|
||||
*
|
||||
* Nothing in the UI changes: this is how a central "sign out everywhere" or an
|
||||
* administrator revoking a session actually ends a session in this app.
|
||||
*/
|
||||
@Controller('iam')
|
||||
export class IamController {
|
||||
private readonly logger = new Logger(IamController.name);
|
||||
|
||||
constructor(
|
||||
private readonly iam: IamService,
|
||||
private readonly logout: IamLogoutService,
|
||||
private readonly sessions: AdminSessionService,
|
||||
private readonly appSessions: AppSessionService,
|
||||
) {}
|
||||
|
||||
@Public()
|
||||
@Post('backchannel-logout')
|
||||
@HttpCode(200)
|
||||
async backchannelLogout(@Body() body: Record<string, unknown>) {
|
||||
if (!this.iam.enabled) {
|
||||
// Not configured: nothing to act on, and no information to give away.
|
||||
return { status: 'ignored' };
|
||||
}
|
||||
|
||||
const token = typeof body?.logout_token === 'string' ? body.logout_token : '';
|
||||
if (!token) {
|
||||
this.logger.warn('back-channel logout called without a logout token');
|
||||
return { status: 'ignored' };
|
||||
}
|
||||
|
||||
try {
|
||||
const { sessionId, subject } = await this.logout.verify(token);
|
||||
|
||||
// `sid` names one session; a token with only `sub` means every session of
|
||||
// that identity — matching the spec's two forms.
|
||||
//
|
||||
// Both token families are ended: the admin panel AND the app (customer /
|
||||
// sales expert). Leaving the app out would make a central sign-out look
|
||||
// like it worked while the phone in the user's pocket stayed signed in.
|
||||
const [admin, app] = sessionId
|
||||
? await Promise.all([
|
||||
this.sessions.revokeByIamSession(sessionId),
|
||||
this.appSessions.revokeByIamSession(sessionId),
|
||||
])
|
||||
: await Promise.all([
|
||||
this.sessions.revokeByIamSubject(subject as string),
|
||||
this.appSessions.revokeByIamSubject(subject as string),
|
||||
]);
|
||||
|
||||
const revoked = admin + app;
|
||||
this.logger.log(
|
||||
`back-channel logout accepted (${sessionId ? `sid=${sessionId}` : `sub=${subject}`}): ` +
|
||||
`${admin} admin + ${app} app session(s) revoked`,
|
||||
);
|
||||
|
||||
return { status: 'ok', revoked };
|
||||
} catch (error) {
|
||||
// A bad token is a security event worth logging, not a 4xx worth retrying.
|
||||
const detail = (error as Error).message;
|
||||
if (error instanceof IamLogoutTokenError) {
|
||||
this.logger.warn(`rejected a back-channel logout token: ${detail}`);
|
||||
} else {
|
||||
this.logger.error(`back-channel logout could not be processed: ${detail}`);
|
||||
}
|
||||
|
||||
return { status: 'ignored' };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { IamController } from './iam.controller';
|
||||
import { IamLogoutService } from './iam-logout.service';
|
||||
import { IamService } from './iam.service';
|
||||
|
||||
/**
|
||||
* Central IAM integration.
|
||||
*
|
||||
* Global so any module can delegate credential checks without wiring imports;
|
||||
* the service itself is inert until `IAM_ENABLED=true`, so importing it costs
|
||||
* nothing in a deployment that does not use the identity service.
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
controllers: [IamController],
|
||||
providers: [IamService, IamLogoutService],
|
||||
exports: [IamService, IamLogoutService],
|
||||
})
|
||||
export class IamModule {}
|
||||
@@ -0,0 +1,214 @@
|
||||
import {
|
||||
IamInvalidCredentialsError,
|
||||
IamMfaRequiredError,
|
||||
IamService,
|
||||
IamUnavailableError,
|
||||
} from './iam.service';
|
||||
|
||||
/**
|
||||
* Credential delegation to the central identity service.
|
||||
*
|
||||
* The behaviour these lock down is the safety contract: the feature is inert
|
||||
* until fully configured, and any uncertain outcome must leave the existing
|
||||
* local password check in charge so an IAM problem can never lock admins out.
|
||||
*/
|
||||
describe('IamService', () => {
|
||||
const ORIGINAL_ENV = process.env;
|
||||
let service: IamService;
|
||||
let fetchMock: jest.Mock;
|
||||
|
||||
const configure = (overrides: Record<string, string> = {}) => {
|
||||
process.env = {
|
||||
...ORIGINAL_ENV,
|
||||
IAM_ENABLED: 'true',
|
||||
IAM_ISSUER: 'https://auth.test.local',
|
||||
IAM_CLIENT_ID: 'hedena_gold_api',
|
||||
IAM_CLIENT_SECRET: 'server-secret',
|
||||
IAM_TENANT_SLUG: 'hedena-gold',
|
||||
IAM_LOGIN_DELEGATION: 'fallback',
|
||||
...overrides,
|
||||
};
|
||||
};
|
||||
|
||||
const respondWith = (status: number, body: unknown) => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => body,
|
||||
} as Response);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
service = new IamService();
|
||||
fetchMock = jest.fn();
|
||||
global.fetch = fetchMock as unknown as typeof fetch;
|
||||
configure();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = ORIGINAL_ENV;
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
/* ─────────────── the switch ─────────────── */
|
||||
|
||||
it('is disabled by default', () => {
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
delete process.env.IAM_ENABLED;
|
||||
|
||||
expect(new IamService().enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('stays disabled when the switch is on but configuration is incomplete', () => {
|
||||
configure({ IAM_CLIENT_SECRET: '' });
|
||||
expect(service.enabled).toBe(false);
|
||||
|
||||
configure({ IAM_ISSUER: '' });
|
||||
expect(service.enabled).toBe(false);
|
||||
|
||||
configure({ IAM_CLIENT_ID: '' });
|
||||
expect(service.enabled).toBe(false);
|
||||
|
||||
configure();
|
||||
expect(service.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('treats an empty value as unset, like the rest of the codebase', () => {
|
||||
// docker-compose passes unset optional variables as "" — see common/env.ts
|
||||
configure({ IAM_ENABLED: ' ' });
|
||||
expect(service.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('is disabled when delegation is explicitly turned off', () => {
|
||||
configure({ IAM_LOGIN_DELEGATION: 'off' });
|
||||
expect(service.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to the local password check unless exclusive mode is set', () => {
|
||||
expect(service.allowsLocalFallback).toBe(true);
|
||||
|
||||
configure({ IAM_LOGIN_DELEGATION: 'exclusive' });
|
||||
expect(service.allowsLocalFallback).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores an unknown delegation mode rather than failing', () => {
|
||||
configure({ IAM_LOGIN_DELEGATION: 'nonsense' });
|
||||
expect(service.mode).toBe('fallback');
|
||||
});
|
||||
|
||||
/* ─────────────── request contract ─────────────── */
|
||||
|
||||
it('sends client credentials, tenant and the end-user IP', async () => {
|
||||
respondWith(200, { status: 'SUCCESS', user: { id: 'sub-1' }, session_id: 'sid-1' });
|
||||
|
||||
await service.verifyCredentials('09120000000', 'secret', '203.0.113.7');
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe('https://auth.test.local/api/v1/auth/login');
|
||||
|
||||
const body = JSON.parse((init as RequestInit).body as string);
|
||||
expect(body.identifier).toBe('09120000000');
|
||||
expect(body.client_id).toBe('hedena_gold_api');
|
||||
expect(body.client_secret).toBe('server-secret');
|
||||
expect(body.tenant_slug).toBe('hedena-gold');
|
||||
// Without this the IAM would rate-limit on this server's IP and lock
|
||||
// out every admin after a handful of attempts.
|
||||
expect(body.client_ip).toBe('203.0.113.7');
|
||||
});
|
||||
|
||||
it('prefers tenant id over slug when both are configured', async () => {
|
||||
configure({ IAM_TENANT_ID: '11111111-2222-3333-4444-555555555555' });
|
||||
respondWith(200, { status: 'SUCCESS', user: { id: 'sub-1' } });
|
||||
|
||||
await service.verifyCredentials('09120000000', 'secret');
|
||||
|
||||
const body = JSON.parse((fetchMock.mock.calls[0][1] as RequestInit).body as string);
|
||||
expect(body.tenant_id).toBe('11111111-2222-3333-4444-555555555555');
|
||||
expect(body.tenant_slug).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns the identity on success', async () => {
|
||||
respondWith(200, {
|
||||
status: 'SUCCESS',
|
||||
session_id: 'sid-9',
|
||||
user: {
|
||||
id: 'sub-9',
|
||||
email: 'a@b.c',
|
||||
phone_number: '09120000000',
|
||||
first_name: 'رضا',
|
||||
last_name: 'کاظمی',
|
||||
},
|
||||
});
|
||||
|
||||
const identity = await service.verifyCredentials('09120000000', 'secret');
|
||||
|
||||
expect(identity).toEqual({
|
||||
sub: 'sub-9',
|
||||
email: 'a@b.c',
|
||||
phoneNumber: '09120000000',
|
||||
firstName: 'رضا',
|
||||
lastName: 'کاظمی',
|
||||
sessionId: 'sid-9',
|
||||
browserTicket: null,
|
||||
});
|
||||
});
|
||||
|
||||
/* ─────────────── failure mapping ─────────────── */
|
||||
|
||||
it('maps a rejected password to an invalid-credentials error', async () => {
|
||||
respondWith(401, { error: 'invalid_credentials' });
|
||||
|
||||
await expect(service.verifyCredentials('09120000000', 'bad')).rejects.toBeInstanceOf(
|
||||
IamInvalidCredentialsError,
|
||||
);
|
||||
});
|
||||
|
||||
it('treats a locked account as a definite rejection, not an outage', async () => {
|
||||
// Falling back to the local password here would defeat the IAM's lockout.
|
||||
respondWith(403, { error: 'account_locked' });
|
||||
|
||||
await expect(service.verifyCredentials('09120000000', 'x')).rejects.toBeInstanceOf(
|
||||
IamInvalidCredentialsError,
|
||||
);
|
||||
});
|
||||
|
||||
it('maps a two-factor challenge to its own error', async () => {
|
||||
respondWith(200, { status: 'MFA_REQUIRED', mfa_session_token: 'x' });
|
||||
|
||||
await expect(service.verifyCredentials('09120000000', 'x')).rejects.toBeInstanceOf(
|
||||
IamMfaRequiredError,
|
||||
);
|
||||
});
|
||||
|
||||
it('maps a server error to an availability problem', async () => {
|
||||
respondWith(500, {});
|
||||
|
||||
await expect(service.verifyCredentials('09120000000', 'x')).rejects.toBeInstanceOf(
|
||||
IamUnavailableError,
|
||||
);
|
||||
});
|
||||
|
||||
it('maps rate limiting to an availability problem so login still falls back', async () => {
|
||||
respondWith(429, { error: 'rate_limited' });
|
||||
|
||||
await expect(service.verifyCredentials('09120000000', 'x')).rejects.toBeInstanceOf(
|
||||
IamUnavailableError,
|
||||
);
|
||||
});
|
||||
|
||||
it('maps a network failure to an availability problem', async () => {
|
||||
fetchMock.mockRejectedValue(new Error('ECONNREFUSED'));
|
||||
|
||||
await expect(service.verifyCredentials('09120000000', 'x')).rejects.toBeInstanceOf(
|
||||
IamUnavailableError,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an unexpected success payload instead of trusting it', async () => {
|
||||
respondWith(200, { status: 'SOMETHING_ELSE' });
|
||||
|
||||
await expect(service.verifyCredentials('09120000000', 'x')).rejects.toBeInstanceOf(
|
||||
IamUnavailableError,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,362 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { envList, envOr, envOrNull } from '../../common/env';
|
||||
|
||||
/**
|
||||
* Credential delegation to the central identity service (Central IAM).
|
||||
*
|
||||
* The point of this module is that **nothing in the UI changes**. Admins keep
|
||||
* typing their phone and password into this app's own login form; when the
|
||||
* feature is switched on, this service checks those credentials against the
|
||||
* IAM instead of (or before) the local password hash. There is no redirect, no
|
||||
* SSO button and no extra screen.
|
||||
*
|
||||
* Safety first: every uncertain outcome (service down, user unknown there,
|
||||
* rate limited) falls back to the existing local check, so turning this on can
|
||||
* never lock admins out. Only `exclusive` mode refuses the local password.
|
||||
*/
|
||||
|
||||
export type IamDelegationMode = 'off' | 'fallback' | 'exclusive';
|
||||
|
||||
export interface IamIdentity {
|
||||
sub: string;
|
||||
email: string | null;
|
||||
phoneNumber: string | null;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
sessionId: string | null;
|
||||
/** بلیت یکبارمصرف برای گذاشتن کوکی نشست روی دامنه IAM */
|
||||
browserTicket: string | null;
|
||||
}
|
||||
|
||||
/** The identity service rejected the credentials (unknown user or wrong password). */
|
||||
export class IamInvalidCredentialsError extends Error {}
|
||||
|
||||
/** The identity service could not be reached or failed; not the user's fault. */
|
||||
export class IamUnavailableError extends Error {}
|
||||
|
||||
/** The account has two-factor enabled there, which this UI-less path cannot collect. */
|
||||
export class IamMfaRequiredError extends Error {}
|
||||
|
||||
/** The identity service does not know this user — it never creates accounts. */
|
||||
export class IamUserUnknownError extends Error {}
|
||||
|
||||
/**
|
||||
* The identity service refused this identity outright (locked, deactivated, or
|
||||
* two-factor). Unlike an unknown user, this is a decision that must be honoured
|
||||
* rather than worked around locally.
|
||||
*/
|
||||
export class IamAccessDeniedError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly reason: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class IamService {
|
||||
private readonly logger = new Logger(IamService.name);
|
||||
|
||||
get issuer(): string {
|
||||
return (envOrNull('IAM_ISSUER') ?? '').replace(/\/$/, '');
|
||||
}
|
||||
|
||||
get clientId(): string | null {
|
||||
return envOrNull('IAM_CLIENT_ID');
|
||||
}
|
||||
|
||||
get mode(): IamDelegationMode {
|
||||
const value = envOr('IAM_LOGIN_DELEGATION', 'fallback');
|
||||
return value === 'off' || value === 'exclusive' ? value : 'fallback';
|
||||
}
|
||||
|
||||
/**
|
||||
* Enabled only when the switch is on **and** the minimum configuration is
|
||||
* present. A half-configured deployment behaves exactly like an unconfigured
|
||||
* one rather than failing in confusing ways at login time.
|
||||
*/
|
||||
get enabled(): boolean {
|
||||
return (
|
||||
envOr('IAM_ENABLED', 'false').toLowerCase() === 'true' &&
|
||||
this.issuer !== '' &&
|
||||
this.clientId !== null &&
|
||||
envOrNull('IAM_CLIENT_SECRET') !== null &&
|
||||
this.mode !== 'off'
|
||||
);
|
||||
}
|
||||
|
||||
/** Should an unknown user / wrong password still be checked locally? */
|
||||
get allowsLocalFallback(): boolean {
|
||||
return this.mode !== 'exclusive';
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a phone + password pair against the identity service.
|
||||
*
|
||||
* `clientIp` is forwarded so the IAM applies its per-IP rate limit to the
|
||||
* real end user. Without it every login would share this server's IP and a
|
||||
* handful of attempts would lock out the whole admin panel.
|
||||
*/
|
||||
async verifyCredentials(
|
||||
identifier: string,
|
||||
password: string,
|
||||
clientIp?: string,
|
||||
): Promise<IamIdentity> {
|
||||
const body: Record<string, string> = {
|
||||
identifier,
|
||||
password,
|
||||
client_id: this.clientId as string,
|
||||
client_secret: envOrNull('IAM_CLIENT_SECRET') as string,
|
||||
};
|
||||
|
||||
const tenantId = envOrNull('IAM_TENANT_ID');
|
||||
const tenantSlug = envOrNull('IAM_TENANT_SLUG');
|
||||
if (tenantId) body.tenant_id = tenantId;
|
||||
else if (tenantSlug) body.tenant_slug = tenantSlug;
|
||||
if (clientIp) body.client_ip = clientIp;
|
||||
const returnTo = this.adminBrowserReturnUrl();
|
||||
if (returnTo) body.return_to = returnTo;
|
||||
|
||||
const timeout = Number(envOr('IAM_HTTP_TIMEOUT_MS', '8000'));
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${this.issuer}/api/v1/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(timeout),
|
||||
});
|
||||
} catch (error) {
|
||||
throw new IamUnavailableError(
|
||||
`identity service unreachable: ${(error as Error).message}`,
|
||||
);
|
||||
}
|
||||
|
||||
const payload = (await response.json().catch(() => ({}))) as Record<string, any>;
|
||||
|
||||
if (response.ok) {
|
||||
if (payload.status === 'MFA_REQUIRED') {
|
||||
throw new IamMfaRequiredError('two-factor authentication required');
|
||||
}
|
||||
if (payload.status !== 'SUCCESS') {
|
||||
throw new IamUnavailableError('unexpected response from identity service');
|
||||
}
|
||||
|
||||
const user = (payload.user ?? {}) as Record<string, any>;
|
||||
return {
|
||||
sub: String(user.id ?? ''),
|
||||
email: user.email ?? null,
|
||||
phoneNumber: user.phone_number ?? null,
|
||||
firstName: user.first_name ?? null,
|
||||
lastName: user.last_name ?? null,
|
||||
sessionId: payload.session_id ?? null,
|
||||
browserTicket: typeof payload.browser_ticket === 'string' ? payload.browser_ticket : null,
|
||||
};
|
||||
}
|
||||
|
||||
// 401/403/422 mean the credentials themselves were not accepted.
|
||||
if ([401, 403, 422].includes(response.status)) {
|
||||
const code = String(payload.error ?? '');
|
||||
if (code === 'account_locked' || code === 'user_inactive') {
|
||||
// A locked account is a definite answer, not an "unknown user" —
|
||||
// falling back to the local password would defeat the lockout.
|
||||
throw new IamInvalidCredentialsError(code);
|
||||
}
|
||||
throw new IamInvalidCredentialsError('invalid_credentials');
|
||||
}
|
||||
|
||||
this.logger.warn(
|
||||
`identity service returned ${response.status} for a delegated login`,
|
||||
);
|
||||
throw new IamUnavailableError(`identity service returned ${response.status}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Report a one-time code this app generated, sent and verified itself.
|
||||
*
|
||||
* The app's login has **no password**, so `verifyCredentials` cannot be used:
|
||||
* there is nothing to send. Without this call, an OTP login never reaches the
|
||||
* identity service at all — no central session, no entry in the security log
|
||||
* and no way for a central sign-out to end it.
|
||||
*
|
||||
* The IAM only accepts this from a trusted confidential client carrying the
|
||||
* explicit `passwordless_delegation_allowed` flag. An unknown mobile is
|
||||
* created there. A locked or inactive account is still refused.
|
||||
*/
|
||||
async attestOtpLogin(
|
||||
identifier: string,
|
||||
clientIp?: string,
|
||||
factorReference?: string | null,
|
||||
): Promise<IamIdentity> {
|
||||
const body: Record<string, string> = {
|
||||
identifier,
|
||||
client_id: this.clientId as string,
|
||||
client_secret: envOrNull('IAM_CLIENT_SECRET') as string,
|
||||
factor: 'sms_otp',
|
||||
};
|
||||
|
||||
const tenantId = envOrNull('IAM_TENANT_ID');
|
||||
const tenantSlug = envOrNull('IAM_TENANT_SLUG');
|
||||
if (tenantId) body.tenant_id = tenantId;
|
||||
else if (tenantSlug) body.tenant_slug = tenantSlug;
|
||||
if (clientIp) body.client_ip = clientIp;
|
||||
if (factorReference) body.factor_reference = factorReference;
|
||||
const returnTo = this.customerBrowserReturnUrl();
|
||||
if (returnTo) body.return_to = returnTo;
|
||||
|
||||
const timeout = Number(envOr('IAM_HTTP_TIMEOUT_MS', '8000'));
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${this.issuer}/api/v1/auth/login/delegated-otp`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(timeout),
|
||||
});
|
||||
} catch (error) {
|
||||
throw new IamUnavailableError(`identity service unreachable: ${(error as Error).message}`);
|
||||
}
|
||||
|
||||
const payload = (await response.json().catch(() => ({}))) as Record<string, any>;
|
||||
|
||||
if (response.ok) {
|
||||
if (payload.status !== 'SUCCESS') {
|
||||
throw new IamUnavailableError('unexpected response from identity service');
|
||||
}
|
||||
const user = (payload.user ?? {}) as Record<string, any>;
|
||||
return {
|
||||
sub: String(user.id ?? ''),
|
||||
email: user.email ?? null,
|
||||
phoneNumber: user.phone_number ?? null,
|
||||
firstName: user.first_name ?? null,
|
||||
lastName: user.last_name ?? null,
|
||||
sessionId: payload.session_id ?? null,
|
||||
browserTicket: typeof payload.browser_ticket === 'string' ? payload.browser_ticket : null,
|
||||
};
|
||||
}
|
||||
|
||||
const code = String(payload.error ?? '');
|
||||
|
||||
// Not migrated to the IAM yet: this app stays the authority for them.
|
||||
if (response.status === 401 && code === 'user_not_found') {
|
||||
throw new IamUserUnknownError(identifier);
|
||||
}
|
||||
|
||||
// Locked, deactivated or two-factor: a decision, not a gap. Honour it.
|
||||
if ([401, 403, 422].includes(response.status)) {
|
||||
throw new IamAccessDeniedError(`identity service refused the login: ${code}`, code || 'denied');
|
||||
}
|
||||
|
||||
this.logger.warn(`identity service returned ${response.status} for an OTP attestation`);
|
||||
throw new IamUnavailableError(`identity service returned ${response.status}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* مقصد بلیت مرورگر. خروج از فهرست گستردهتری استفاده میکند؛ اگر ورود فقط
|
||||
* `IAM_BROWSER_RETURN_URL` را ببیند و آن خالی باشد، کوکی مشترک گذاشته نمیشود
|
||||
* و فقط خروج به IAM میرود.
|
||||
*/
|
||||
private customerBrowserReturnUrl(): string | null {
|
||||
return (
|
||||
envOrNull('IAM_BROWSER_RETURN_URL') ||
|
||||
envOrNull('IAM_POST_LOGOUT_REDIRECT_URI') ||
|
||||
envList('IAM_ALLOWED_RETURN_URLS')[0] ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
private adminBrowserReturnUrl(): string | null {
|
||||
return (
|
||||
envOrNull('IAM_ADMIN_BROWSER_RETURN_URL') ||
|
||||
envOrNull('IAM_ADMIN_POST_LOGOUT_REDIRECT_URI') ||
|
||||
envList('IAM_ADMIN_ALLOWED_RETURN_URLS')[0] ||
|
||||
this.customerBrowserReturnUrl()
|
||||
);
|
||||
}
|
||||
|
||||
browserBootstrapUrl(ticket: string | null | undefined): string | null {
|
||||
if (!ticket || !this.issuer) return null;
|
||||
return `${this.issuer}/auth/bootstrap?ticket=${encodeURIComponent(ticket)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* پایان همهٔ نشستهای IAM این شخص، از جمله نشستی که اپ دیگر با آن وارد شده.
|
||||
* کلاینت باید خودش در همان نشست شرکت کرده باشد.
|
||||
*/
|
||||
async endDelegatedSession(sessionId: string): Promise<void> {
|
||||
if (!this.enabled || !this.issuer || !this.clientId) return;
|
||||
const timeout = Number(envOr('IAM_HTTP_TIMEOUT_MS', '8000'));
|
||||
const response = await fetch(`${this.issuer}/api/v1/auth/session/end`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
||||
body: JSON.stringify({
|
||||
client_id: this.clientId,
|
||||
client_secret: envOrNull('IAM_CLIENT_SECRET'),
|
||||
session_id: sessionId,
|
||||
}),
|
||||
signal: AbortSignal.timeout(timeout),
|
||||
});
|
||||
if (!response.ok && response.status !== 403) {
|
||||
throw new IamUnavailableError(`identity service refused session end (${response.status})`);
|
||||
}
|
||||
}
|
||||
|
||||
/** تبادل کد مجوز مرورگر. پاسخ از خود issuer و با راز کلاینت آمده است. */
|
||||
async exchangeAuthorizationCode(
|
||||
code: string,
|
||||
codeVerifier: string,
|
||||
redirectUri: string,
|
||||
): Promise<{ sub: string; phoneNumber: string | null; sessionId: string | null }> {
|
||||
const timeout = Number(envOr('IAM_HTTP_TIMEOUT_MS', '8000'));
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: redirectUri,
|
||||
code_verifier: codeVerifier,
|
||||
client_id: this.clientId as string,
|
||||
client_secret: envOrNull('IAM_CLIENT_SECRET') as string,
|
||||
});
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${this.issuer}/oauth/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
|
||||
body,
|
||||
signal: AbortSignal.timeout(timeout),
|
||||
});
|
||||
} catch (error) {
|
||||
throw new IamUnavailableError(`identity service unreachable: ${(error as Error).message}`);
|
||||
}
|
||||
|
||||
const payload = (await response.json().catch(() => ({}))) as Record<string, any>;
|
||||
if (!response.ok || typeof payload.id_token !== 'string') {
|
||||
throw new IamUnavailableError(`identity service refused the authorization code (${response.status})`);
|
||||
}
|
||||
|
||||
const claims = decodeIdToken(payload.id_token);
|
||||
if (claims.iss !== this.issuer) {
|
||||
throw new IamUnavailableError('identity token issuer does not match');
|
||||
}
|
||||
const aud = claims.aud;
|
||||
const audiences = Array.isArray(aud) ? aud : [aud];
|
||||
if (!audiences.includes(this.clientId)) {
|
||||
throw new IamUnavailableError('identity token audience does not match');
|
||||
}
|
||||
|
||||
return {
|
||||
sub: String(claims.sub ?? ''),
|
||||
phoneNumber: typeof claims.phone_number === 'string' ? claims.phone_number : null,
|
||||
sessionId: typeof claims.sid === 'string' ? claims.sid : null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function decodeIdToken(token: string): Record<string, any> {
|
||||
const part = token.split('.')[1];
|
||||
if (!part) throw new IamUnavailableError('identity token is malformed');
|
||||
return JSON.parse(Buffer.from(part, 'base64url').toString('utf8')) as Record<string, any>;
|
||||
}
|
||||
Reference in New Issue
Block a user