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,19 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
import { AdminSessionService } from './admin-session.service';
|
||||
import { AppSessionService } from './app-session.service';
|
||||
|
||||
/**
|
||||
* Revocable admin sessions.
|
||||
*
|
||||
* Global so every login path (admin panel and app) and the IAM
|
||||
* back-channel-logout endpoint can use these without wiring imports. Both
|
||||
* services are inert while tracking is off (see AdminSessionService).
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
providers: [AdminSessionService, AppSessionService],
|
||||
exports: [AdminSessionService, AppSessionService],
|
||||
})
|
||||
export class AdminSessionModule {}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { AdminSessionService } from './admin-session.service';
|
||||
import type { IamService } from '../iam/iam.service';
|
||||
|
||||
/**
|
||||
* Revocable admin sessions.
|
||||
*
|
||||
* Two contracts are locked down here. First, with tracking off nothing changes:
|
||||
* no `jti`, no row, and no extra query on an authenticated request. Second, with
|
||||
* it on, a revoked session stops working immediately — which is the whole point,
|
||||
* because before this table an issued token was usable for its full 12h no matter
|
||||
* what happened centrally.
|
||||
*/
|
||||
describe('AdminSessionService', () => {
|
||||
const ORIGINAL_ENV = process.env;
|
||||
|
||||
const build = (opts: { iamEnabled?: boolean; tracking?: string | null } = {}) => {
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
delete process.env.AUTH_SESSION_TRACKING;
|
||||
if (opts.tracking != null) process.env.AUTH_SESSION_TRACKING = opts.tracking;
|
||||
|
||||
const prisma = {
|
||||
adminSession: {
|
||||
create: jest.fn().mockResolvedValue({}),
|
||||
findUnique: jest.fn(),
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 2 }),
|
||||
},
|
||||
};
|
||||
const iam = { enabled: opts.iamEnabled ?? false } as IamService;
|
||||
|
||||
return { service: new AdminSessionService(prisma as never, iam), prisma };
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
process.env = ORIGINAL_ENV;
|
||||
});
|
||||
|
||||
/* ─────────────── off by default ─────────────── */
|
||||
|
||||
it('is off in a deployment that does not use the identity service', () => {
|
||||
expect(build().service.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('turns itself on with the identity service, so central revocation reaches us', () => {
|
||||
expect(build({ iamEnabled: true }).service.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('can be turned on on its own', () => {
|
||||
expect(build({ tracking: 'true' }).service.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('can be turned off explicitly even with the identity service on', () => {
|
||||
// Opting out of the per-request lookup; central revocation then cannot reach here
|
||||
expect(build({ iamEnabled: true, tracking: 'false' }).service.enabled).toBe(false);
|
||||
});
|
||||
|
||||
/* ─────────────── unchanged behaviour while off ─────────────── */
|
||||
|
||||
it('issues no token id and writes no row while off', async () => {
|
||||
const { service, prisma } = build();
|
||||
|
||||
expect(service.newTokenId()).toBeNull();
|
||||
await service.record({ jti: 'x', adminUserId: 'a' });
|
||||
|
||||
expect(prisma.adminSession.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('authenticates without a database lookup while off', async () => {
|
||||
const { service, prisma } = build();
|
||||
|
||||
await expect(service.isActive('some-jti')).resolves.toBe(true);
|
||||
expect(prisma.adminSession.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
/* ─────────────── enforcement while on ─────────────── */
|
||||
|
||||
it('accepts a live session', async () => {
|
||||
const { service, prisma } = build({ iamEnabled: true });
|
||||
prisma.adminSession.findUnique.mockResolvedValue({ revokedAt: null });
|
||||
|
||||
await expect(service.isActive('jti-1')).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('refuses a revoked session', async () => {
|
||||
const { service, prisma } = build({ iamEnabled: true });
|
||||
prisma.adminSession.findUnique.mockResolvedValue({ revokedAt: new Date() });
|
||||
|
||||
// The gap this closes: previously the token kept working until it expired
|
||||
await expect(service.isActive('jti-1')).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('refuses a token whose session row is gone', async () => {
|
||||
const { service, prisma } = build({ iamEnabled: true });
|
||||
prisma.adminSession.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(service.isActive('jti-1')).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('keeps tokens issued before tracking was switched on working', async () => {
|
||||
const { service, prisma } = build({ iamEnabled: true });
|
||||
|
||||
// Such a token carries no jti; refusing it would sign every admin out at once
|
||||
await expect(service.isActive(undefined)).resolves.toBe(true);
|
||||
expect(prisma.adminSession.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
/* ─────────────── recording and revoking ─────────────── */
|
||||
|
||||
it('records the session with an expiry derived from the token lifetime', async () => {
|
||||
process.env.JWT_EXPIRES_IN = '12h';
|
||||
const { service, prisma } = build({ iamEnabled: true });
|
||||
process.env.JWT_EXPIRES_IN = '12h';
|
||||
|
||||
await service.record({ jti: 'jti-1', adminUserId: 'admin-1', iamSessionId: 'sid-1', iamSub: 'sub-1' });
|
||||
|
||||
const data = prisma.adminSession.create.mock.calls[0][0].data;
|
||||
expect(data).toMatchObject({ jti: 'jti-1', adminUserId: 'admin-1', iamSessionId: 'sid-1' });
|
||||
expect(data.expiresAt.getTime()).toBeGreaterThan(Date.now() + 11 * 3600 * 1000);
|
||||
});
|
||||
|
||||
it('never breaks login when the session row cannot be written', async () => {
|
||||
const { service, prisma } = build({ iamEnabled: true });
|
||||
prisma.adminSession.create.mockRejectedValue(new Error('unique violation'));
|
||||
|
||||
// Worst case is a session that cannot be revoked early — not a failed login
|
||||
await expect(service.record({ jti: 'jti-1', adminUserId: 'a' })).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('revokes by the identity service session id', async () => {
|
||||
const { service, prisma } = build({ iamEnabled: true });
|
||||
|
||||
await expect(service.revokeByIamSession('sid-1')).resolves.toBe(2);
|
||||
expect(prisma.adminSession.updateMany).toHaveBeenCalledWith({
|
||||
where: { iamSessionId: 'sid-1', revokedAt: null },
|
||||
data: expect.objectContaining({ revokedReason: 'iam_backchannel_logout' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('revokes every session of an identity when the token names only a subject', async () => {
|
||||
const { service, prisma } = build({ iamEnabled: true });
|
||||
|
||||
await service.revokeByIamSubject('sub-1');
|
||||
expect(prisma.adminSession.updateMany).toHaveBeenCalledWith({
|
||||
where: { iamSub: 'sub-1', revokedAt: null },
|
||||
data: expect.objectContaining({ revokedReason: 'iam_backchannel_logout' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('revokes every session of a local admin', async () => {
|
||||
const { service, prisma } = build({ iamEnabled: true });
|
||||
|
||||
await service.revokeAllForAdmin('admin-1', 'account_disabled');
|
||||
expect(prisma.adminSession.updateMany).toHaveBeenCalledWith({
|
||||
where: { adminUserId: 'admin-1', revokedAt: null },
|
||||
data: expect.objectContaining({ revokedReason: 'account_disabled' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('does not touch anything while off', async () => {
|
||||
const { service, prisma } = build();
|
||||
|
||||
await expect(service.revokeByIamSession('sid-1')).resolves.toBe(0);
|
||||
expect(prisma.adminSession.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { envOr } from '../../common/env';
|
||||
import { IamService } from '../iam/iam.service';
|
||||
|
||||
/**
|
||||
* Revocable admin sessions.
|
||||
*
|
||||
* An admin token is self-contained and lives for 12h, so until now nothing could
|
||||
* end a session early: deactivating the account or signing the user out centrally
|
||||
* left every already-issued token working until it expired. Each issued token now
|
||||
* carries a `jti` recorded on AdminSession, and JwtStrategy refuses a token whose
|
||||
* row is revoked or gone.
|
||||
*
|
||||
* The previous behaviour is preserved exactly when tracking is off: no row is
|
||||
* written, no extra query runs on authentication, and tokens keep working as
|
||||
* before. Tracking turns itself on when the IAM is enabled — without it a central
|
||||
* revocation would silently do nothing here — and can be turned on independently
|
||||
* with `AUTH_SESSION_TRACKING=true`.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AdminSessionService {
|
||||
private readonly logger = new Logger(AdminSessionService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly iam: IamService,
|
||||
) {}
|
||||
|
||||
get enabled(): boolean {
|
||||
if (envOr('AUTH_SESSION_TRACKING', '').toLowerCase() === 'true') return true;
|
||||
// Explicit opt-out for a deployment that uses the IAM but does not want the
|
||||
// per-request lookup; central revocation then cannot reach this app.
|
||||
if (envOr('AUTH_SESSION_TRACKING', '').toLowerCase() === 'false') return false;
|
||||
return this.iam.enabled;
|
||||
}
|
||||
|
||||
/** A fresh `jti` for a token about to be signed, or null while tracking is off. */
|
||||
newTokenId(): string | null {
|
||||
return this.enabled ? randomUUID() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an issued token. A failure here must not break login, so it is logged
|
||||
* and swallowed: the worst case is a session that cannot be revoked early,
|
||||
* which is exactly the behaviour before this table existed.
|
||||
*/
|
||||
async record(input: {
|
||||
jti: string;
|
||||
adminUserId: string;
|
||||
iamSessionId?: string | null;
|
||||
iamSub?: string | null;
|
||||
ipAddress?: string | null;
|
||||
userAgent?: string | null;
|
||||
}): Promise<void> {
|
||||
if (!this.enabled) return;
|
||||
|
||||
try {
|
||||
await this.prisma.adminSession.create({
|
||||
data: {
|
||||
jti: input.jti,
|
||||
adminUserId: input.adminUserId,
|
||||
iamSessionId: input.iamSessionId ?? null,
|
||||
iamSub: input.iamSub ?? null,
|
||||
ipAddress: input.ipAddress ?? null,
|
||||
userAgent: input.userAgent ?? null,
|
||||
expiresAt: this.tokenExpiry(),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn(`could not record the admin session: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this token still usable? Called on every authenticated request.
|
||||
*
|
||||
* A token signed before tracking was switched on carries no `jti`; it stays
|
||||
* valid until it expires, so enabling the feature does not sign everyone out.
|
||||
*/
|
||||
async isActive(jti: string | undefined): Promise<boolean> {
|
||||
if (!this.enabled || !jti) return true;
|
||||
|
||||
const session = await this.prisma.adminSession.findUnique({
|
||||
where: { jti },
|
||||
select: { revokedAt: true },
|
||||
});
|
||||
|
||||
// A missing row means the session was deleted (or the token was signed
|
||||
// elsewhere): refuse rather than assume.
|
||||
return session !== null && session.revokedAt === null;
|
||||
}
|
||||
|
||||
/** Revoke by the IAM's session id (`sid` in a back-channel logout token). */
|
||||
async revokeByIamSession(iamSessionId: string, reason = 'iam_backchannel_logout'): Promise<number> {
|
||||
return this.revokeWhere({ iamSessionId }, reason);
|
||||
}
|
||||
|
||||
/** Revoke every session of an IAM identity (a logout token carrying only `sub`). */
|
||||
async revokeByIamSubject(iamSub: string, reason = 'iam_backchannel_logout'): Promise<number> {
|
||||
return this.revokeWhere({ iamSub }, reason);
|
||||
}
|
||||
|
||||
/** Revoke every session of a local admin (account disabled, password changed). */
|
||||
async revokeAllForAdmin(adminUserId: string, reason = 'revoked'): Promise<number> {
|
||||
return this.revokeWhere({ adminUserId }, reason);
|
||||
}
|
||||
|
||||
private async revokeWhere(
|
||||
where: { iamSessionId?: string; iamSub?: string; adminUserId?: string },
|
||||
reason: string,
|
||||
): Promise<number> {
|
||||
if (!this.enabled) return 0;
|
||||
|
||||
const result = await this.prisma.adminSession.updateMany({
|
||||
where: { ...where, revokedAt: null },
|
||||
data: { revokedAt: new Date(), revokedReason: reason },
|
||||
});
|
||||
|
||||
return result.count;
|
||||
}
|
||||
|
||||
/**
|
||||
* When the recorded token expires, mirroring JWT_EXPIRES_IN so old rows can be
|
||||
* pruned. Only `<n>h` / `<n>m` / `<n>d` / `<n>s` are understood — anything else
|
||||
* leaves the column null rather than guessing.
|
||||
*/
|
||||
private tokenExpiry(): Date | null {
|
||||
const raw = envOr('JWT_EXPIRES_IN', '12h');
|
||||
const match = /^(\d+)\s*([smhd])$/.exec(raw);
|
||||
if (!match) return null;
|
||||
|
||||
const seconds = Number(match[1]) * { s: 1, m: 60, h: 3600, d: 86400 }[match[2] as 's' | 'm' | 'h' | 'd'];
|
||||
return new Date(Date.now() + seconds * 1000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { envOr } from '../../common/env';
|
||||
import { IamService } from '../iam/iam.service';
|
||||
|
||||
/** What an app token's `sub` points at — a Customer, or an AdminUser (D21). */
|
||||
export type AppSubjectType = 'customer' | 'sales-expert';
|
||||
|
||||
/**
|
||||
* Revocable app sessions (customer app and sales-expert app).
|
||||
*
|
||||
* The counterpart of AdminSessionService for the other token family. It matters
|
||||
* more here: the app's login is OTP-only, so there is no password an operator
|
||||
* could change to cut off a stolen 30-day token — before this table, nothing
|
||||
* could end an app session early.
|
||||
*
|
||||
* Exactly as with the admin side, everything is inert while tracking is off: no
|
||||
* `jti`, no row, and no extra query on the authenticated path.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AppSessionService {
|
||||
private readonly logger = new Logger(AppSessionService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly iam: IamService,
|
||||
) {}
|
||||
|
||||
get enabled(): boolean {
|
||||
const flag = envOr('AUTH_SESSION_TRACKING', '').toLowerCase();
|
||||
if (flag === 'true') return true;
|
||||
if (flag === 'false') return false;
|
||||
return this.iam.enabled;
|
||||
}
|
||||
|
||||
newTokenId(): string | null {
|
||||
return this.enabled ? randomUUID() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an issued token. A failure must not break the login: the worst case
|
||||
* is a session that cannot be revoked early, which is the behaviour that
|
||||
* existed before this table.
|
||||
*/
|
||||
async record(input: {
|
||||
jti: string;
|
||||
subjectId: string;
|
||||
subjectType: AppSubjectType;
|
||||
mobile?: string | null;
|
||||
iamSessionId?: string | null;
|
||||
iamSub?: string | null;
|
||||
ipAddress?: string | null;
|
||||
userAgent?: string | null;
|
||||
}): Promise<void> {
|
||||
if (!this.enabled) return;
|
||||
|
||||
try {
|
||||
await this.prisma.appSession.create({
|
||||
data: {
|
||||
jti: input.jti,
|
||||
subjectId: input.subjectId,
|
||||
subjectType: input.subjectType,
|
||||
mobile: input.mobile ?? null,
|
||||
iamSessionId: input.iamSessionId ?? null,
|
||||
iamSub: input.iamSub ?? null,
|
||||
ipAddress: input.ipAddress ?? null,
|
||||
userAgent: input.userAgent ?? null,
|
||||
expiresAt: this.tokenExpiry(),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn(`could not record the app session: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this token still usable? A token signed before tracking was switched on
|
||||
* carries no `jti` and stays valid until it expires, so enabling the feature
|
||||
* does not sign every app user out at once.
|
||||
*/
|
||||
async isActive(jti: string | undefined): Promise<boolean> {
|
||||
if (!this.enabled || !jti) return true;
|
||||
|
||||
const session = await this.prisma.appSession.findUnique({
|
||||
where: { jti },
|
||||
select: { revokedAt: true },
|
||||
});
|
||||
|
||||
return session !== null && session.revokedAt === null;
|
||||
}
|
||||
|
||||
/** IAM session ids still tied to this mobile, so logout can end the other app too. */
|
||||
async activeIamSessionIds(mobile: string): Promise<string[]> {
|
||||
if (!this.enabled || !mobile) return [];
|
||||
const rows = await this.prisma.appSession.findMany({
|
||||
where: { mobile, revokedAt: null, iamSessionId: { not: null } },
|
||||
select: { iamSessionId: true },
|
||||
});
|
||||
return [...new Set(rows.map((row) => row.iamSessionId).filter((id): id is string => Boolean(id)))];
|
||||
}
|
||||
|
||||
/** Revoke by the IAM's session id (`sid` in a back-channel logout token). */
|
||||
revokeByIamSession(iamSessionId: string, reason = 'iam_backchannel_logout'): Promise<number> {
|
||||
return this.revokeWhere({ iamSessionId }, reason);
|
||||
}
|
||||
|
||||
/** Revoke every session of an IAM identity (a logout token carrying only `sub`). */
|
||||
revokeByIamSubject(iamSub: string, reason = 'iam_backchannel_logout'): Promise<number> {
|
||||
return this.revokeWhere({ iamSub }, reason);
|
||||
}
|
||||
|
||||
/** Revoke every app session of one subject (customer archived/deactivated). */
|
||||
revokeAllForSubject(subjectId: string, reason = 'revoked'): Promise<number> {
|
||||
return this.revokeWhere({ subjectId }, reason);
|
||||
}
|
||||
|
||||
private async revokeWhere(
|
||||
where: { iamSessionId?: string; iamSub?: string; subjectId?: string },
|
||||
reason: string,
|
||||
): Promise<number> {
|
||||
if (!this.enabled) return 0;
|
||||
|
||||
const result = await this.prisma.appSession.updateMany({
|
||||
where: { ...where, revokedAt: null },
|
||||
data: { revokedAt: new Date(), revokedReason: reason },
|
||||
});
|
||||
|
||||
return result.count;
|
||||
}
|
||||
|
||||
/** Mirrors JWT_CUSTOMER_EXPIRES_IN so old rows can be pruned. */
|
||||
private tokenExpiry(): Date | null {
|
||||
const match = /^(\d+)\s*([smhd])$/.exec(envOr('JWT_CUSTOMER_EXPIRES_IN', '30d'));
|
||||
if (!match) return null;
|
||||
|
||||
const seconds = Number(match[1]) * { s: 1, m: 60, h: 3600, d: 86400 }[match[2] as 's' | 'm' | 'h' | 'd'];
|
||||
return new Date(Date.now() + seconds * 1000);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user