feat(fayda): add fayda verification endpoints and login-with-fayda

This commit is contained in:
Abubeker Yasin
2026-05-26 17:28:50 +03:00
parent ac55c0cebc
commit 4e500700ec
8 changed files with 576 additions and 195 deletions

View File

@@ -0,0 +1,12 @@
/*
Warnings:
- A unique constraint covering the columns `[authCode]` on the table `FaydaVerificationSession` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE "FaydaVerificationSession" ADD COLUMN "authCode" TEXT,
ADD COLUMN "platform" TEXT NOT NULL DEFAULT 'WEB';
-- CreateIndex
CREATE UNIQUE INDEX "FaydaVerificationSession_authCode_key" ON "FaydaVerificationSession"("authCode");

View File

@@ -1261,6 +1261,7 @@ model FaydaVerificationSession {
state String @unique
codeVerifier String
purpose String @default("PURCHASE")
platform String @default("WEB") // WEB | MOBILE — recorded for audit
saveToAccount Boolean @default(false)
status String @default("PENDING")
errorCode String?

View File

@@ -15,6 +15,8 @@ export interface FaydaJwk {
qi?: string;
}
export type FaydaPlatform = 'WEB' | 'MOBILE';
export interface FaydaConfig {
enabled: boolean;
clientId: string;
@@ -23,8 +25,6 @@ export interface FaydaConfig {
userInfoEndpoint: string;
redirectUri: string;
privateJwk: FaydaJwk;
successRedirectUrl: string;
failureRedirectUrl: string;
scope: string;
acrValues: string;
claimsLocales: string;
@@ -36,10 +36,7 @@ const REQUIRED_VARS = [
'FAYDA_AUTHORIZATION_ENDPOINT',
'FAYDA_TOKEN_ENDPOINT',
'FAYDA_USERINFO_ENDPOINT',
'FAYDA_REDIRECT_URI',
'FAYDA_PRIVATE_KEY_BASE64',
'FAYDA_SUCCESS_REDIRECT_URL',
'FAYDA_FAILURE_REDIRECT_URL',
] as const;
function decodePrivateJwk(base64: string): FaydaJwk {
@@ -73,7 +70,7 @@ export default registerAs('fayda', (): FaydaConfig => {
const acrValues = process.env.FAYDA_ACR_VALUES ?? 'mosip:idp:acr:generated-code';
const claimsLocales = process.env.FAYDA_CLAIMS_LOCALES ?? 'en am';
const sessionTtl = Number.parseInt(process.env.FAYDA_SESSION_TTL_MINUTES ?? '10', 10);
const redirectUri = process.env.FAYDA_REDIRECT_URI ?? '';
if (!enabled) {
return {
enabled: false,
@@ -81,10 +78,8 @@ export default registerAs('fayda', (): FaydaConfig => {
authorizationEndpoint: process.env.FAYDA_AUTHORIZATION_ENDPOINT ?? '',
tokenEndpoint: process.env.FAYDA_TOKEN_ENDPOINT ?? '',
userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT ?? '',
redirectUri: process.env.FAYDA_REDIRECT_URI ?? '',
redirectUri,
privateJwk: { kty: 'RSA', n: '', e: '', d: '' },
successRedirectUrl: process.env.FAYDA_SUCCESS_REDIRECT_URL ?? '',
failureRedirectUrl: process.env.FAYDA_FAILURE_REDIRECT_URL ?? '',
scope,
acrValues,
claimsLocales,
@@ -98,6 +93,11 @@ export default registerAs('fayda', (): FaydaConfig => {
`Fayda integration is enabled (FAYDA_ENABLED=true) but the following env vars are missing: ${missing.join(', ')}`,
);
}
if (!redirectUri) {
throw new Error(
'Fayda integration is enabled but the redirect URI is missing: set FAYDA_REDIRECT_URI',
);
}
if (Number.isNaN(sessionTtl) || sessionTtl <= 0) {
throw new Error('FAYDA_SESSION_TTL_MINUTES must be a positive integer');
}
@@ -108,10 +108,8 @@ export default registerAs('fayda', (): FaydaConfig => {
authorizationEndpoint: process.env.FAYDA_AUTHORIZATION_ENDPOINT!,
tokenEndpoint: process.env.FAYDA_TOKEN_ENDPOINT!,
userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT!,
redirectUri: process.env.FAYDA_REDIRECT_URI!,
redirectUri,
privateJwk: decodePrivateJwk(process.env.FAYDA_PRIVATE_KEY_BASE64!),
successRedirectUrl: process.env.FAYDA_SUCCESS_REDIRECT_URL!,
failureRedirectUrl: process.env.FAYDA_FAILURE_REDIRECT_URL!,
scope,
acrValues,
claimsLocales,

View File

@@ -7,7 +7,6 @@ import {
Post,
Query,
Req,
Res,
UseGuards,
} from '@nestjs/common';
import {
@@ -19,6 +18,7 @@ import {
import { JwtGuard } from '../../common/jwt.guard';
import { OptionalJwtGuard } from './optional-jwt.guard';
import {
CompleteVerificationResultDto,
StartVerificationDto,
VerifaydaCallbackDto,
VerificationStatusDto,
@@ -33,17 +33,14 @@ interface AuthedUser {
passengerId?: string;
}
/** Minimal slices of the Express req/res we actually touch (avoids a hard
* dependency on `@types/express`, which isn't resolved in this package). */
/** Minimal slices of the Express req we touch (avoids a hard dependency on
* `@types/express`, which isn't resolved in this package). */
interface RequestWithOptionalUser {
user?: AuthedUser;
}
interface RequestWithUser {
user: AuthedUser;
}
interface RedirectableResponse {
redirect(url: string): void;
}
@ApiTags('Fayda Verification')
@Controller('fayda/verification')
@@ -77,6 +74,7 @@ export class VerifaydaController {
): Promise<{ authorizationUrl: string }> {
const authorizationUrl = await this.service.startVerification({
purpose: dto.purpose ?? 'PURCHASE',
platform: dto.platform ?? 'WEB',
userId: req.user?.userId,
bookingId: dto.bookingId,
saveToAccount: dto.saveToAccount,
@@ -84,19 +82,16 @@ export class VerifaydaController {
return { authorizationUrl };
}
@Get('callback')
@Get('complete')
@ApiOperation({
summary: 'eSignet redirect callback (browser lands here)',
description: `Fayda/eSignet redirects the user's browser here with \`?code&state\` (success) or \`?error&error_description\` (failure).
This endpoint is **not** authenticated — Fayda sends no bearer token. It exchanges the code for tokens, fetches the verified claims, records the result, and **302-redirects** the browser to the configured success or failure frontend URL (failures carry a \`?reason=\` the frontend can switch on).`,
summary: 'Complete a verification (Fayda redirect / client callback lands here)',
description: `This is the registered Fayda \`redirect_uri\`. Fayda redirects the browser here with \`?code&state\``,
})
async callback(
@Query() query: VerifaydaCallbackDto,
@Res() res: RedirectableResponse,
): Promise<void> {
const redirectUrl = await this.service.handleCallback(query);
res.redirect(redirectUrl);
@ApiOkResponse({ type: CompleteVerificationResultDto })
async complete(
@Query() dto: VerifaydaCallbackDto,
): Promise<CompleteVerificationResultDto> {
return this.service.completeVerification(dto);
}
@Get('status')

View File

@@ -26,6 +26,42 @@ export class StartVerificationDto {
@IsOptional()
@IsBoolean()
saveToAccount?: boolean;
@ApiPropertyOptional({
enum: ['WEB', 'MOBILE'],
default: 'WEB',
description:
'Client platform. Decides where /callback redirects on completion: a web https URL (WEB) or a custom-scheme deep link the Flutter app intercepts (MOBILE).',
})
@IsOptional()
@IsIn(['WEB', 'MOBILE'])
platform?: 'WEB' | 'MOBILE';
}
export class CompleteVerificationResultDto {
@ApiProperty({ enum: ['LOGIN', 'PURCHASE'] })
purpose: 'LOGIN' | 'PURCHASE';
@ApiProperty() verified: boolean;
@ApiPropertyOptional({ description: 'JWT (LOGIN flow only).' })
token?: string;
@ApiPropertyOptional({
description: 'Authenticated user summary (LOGIN flow only; same shape as /auth/login).',
})
user?: {
id: string;
email: string;
role: string;
passengerId?: string;
agentId?: string;
};
@ApiPropertyOptional({
description: 'Verified full name from Fayda (PURCHASE flow).',
})
fullName?: string;
}
export class VerifaydaCallbackDto {

View File

@@ -2,9 +2,12 @@ import { Module } from '@nestjs/common';
import { VerifaydaController } from './verifayda.controller';
import { VerifaydaService } from './verifayda.service';
import { PrismaModule } from '../../common/prisma.module';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [PrismaModule],
// AuthModule re-exports JwtModule, giving us JwtService (same secret/expiry
// config as /auth/login) to mint tokens for the LOGIN flow.
imports: [PrismaModule, AuthModule],
controllers: [VerifaydaController],
providers: [VerifaydaService],
exports: [VerifaydaService],

View File

@@ -1,15 +1,10 @@
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { exportJWK, generateKeyPair, type JWK } from 'jose';
import { PrismaService } from '../../common/prisma.service';
import { FaydaConfig } from '../../config/fayda.config';
import { VerifaydaService } from './verifayda.service';
type AnyFn = (...args: any[]) => any;
function mockFn<T extends AnyFn>(impl?: T): jest.Mock {
return impl ? jest.fn(impl) : jest.fn();
}
function buildPrismaMock() {
return {
faydaVerificationSession: {
@@ -24,12 +19,23 @@ function buildPrismaMock() {
user: {
findUnique: jest.fn(),
findFirst: jest.fn(),
create: jest.fn(),
update: jest.fn(),
},
passenger: { create: jest.fn() },
loyaltyAccount: { create: jest.fn() },
walletAccount: { create: jest.fn() },
userPreferences: { create: jest.fn() },
verifaydaVerification: { create: jest.fn() },
};
}
function buildJwtMock(): jest.Mocked<JwtService> {
return {
sign: jest.fn(() => 'signed.jwt.token'),
} as unknown as jest.Mocked<JwtService>;
}
function buildConfig(overrides?: Partial<FaydaConfig>): FaydaConfig {
return {
enabled: true,
@@ -37,10 +43,8 @@ function buildConfig(overrides?: Partial<FaydaConfig>): FaydaConfig {
authorizationEndpoint: 'https://esignet.test/authorize',
tokenEndpoint: 'https://esignet.test/token',
userInfoEndpoint: 'https://esignet.test/userinfo',
redirectUri: 'https://api.edr.test/api/v1/fayda/verification/callback',
redirectUri: 'http://localhost:4000/fayda/verification/complete',
privateJwk: { kty: 'RSA', n: '', e: '', d: '' },
successRedirectUrl: 'https://passenger.edr.test/verify/success',
failureRedirectUrl: 'https://passenger.edr.test/verify/failure',
scope: 'openid profile email',
acrValues: 'mosip:idp:acr:generated-code',
claimsLocales: 'en am',
@@ -59,8 +63,9 @@ function buildConfigService(faydaConfig: FaydaConfig): jest.Mocked<ConfigService
} as unknown as jest.Mocked<ConfigService>;
}
describe('VerifaydaService (OIDC)', () => {
describe('VerifaydaService (OIDC, client-callback)', () => {
let prisma: ReturnType<typeof buildPrismaMock>;
let jwt: jest.Mocked<JwtService>;
let service: VerifaydaService;
let realPrivateJwk: JWK;
@@ -72,10 +77,12 @@ describe('VerifaydaService (OIDC)', () => {
beforeEach(() => {
prisma = buildPrismaMock();
jwt = buildJwtMock();
const cfg = buildConfig({ privateJwk: realPrivateJwk as FaydaConfig['privateJwk'] });
service = new VerifaydaService(
buildConfigService(cfg),
prisma as unknown as PrismaService,
jwt,
);
(global as any).fetch = jest.fn();
});
@@ -94,29 +101,42 @@ describe('VerifaydaService (OIDC)', () => {
saveToAccount: true,
});
expect(prisma.faydaVerificationSession.create).toHaveBeenCalledTimes(1);
const created = prisma.faydaVerificationSession.create.mock.calls[0][0].data;
expect(created.purpose).toBe('PURCHASE');
expect(created.saveToAccount).toBe(true);
expect(created.userId).toBe('user-1');
expect(created.platform).toBe('WEB');
expect(typeof created.state).toBe('string');
expect(typeof created.codeVerifier).toBe('string');
const parsed = new URL(url);
expect(parsed.origin + parsed.pathname).toBe(
'https://esignet.test/authorize',
);
expect(parsed.origin + parsed.pathname).toBe('https://esignet.test/authorize');
expect(parsed.searchParams.get('client_id')).toBe('edr-test-client');
expect(parsed.searchParams.get('response_type')).toBe('code');
expect(parsed.searchParams.get('code_challenge_method')).toBe('S256');
expect(parsed.searchParams.get('redirect_uri')).toBe(
'http://localhost:4000/fayda/verification/complete',
);
expect(parsed.searchParams.get('state')).toBe(created.state);
});
it('uses the same single redirect_uri regardless of platform (platform is only recorded)', async () => {
prisma.faydaVerificationSession.create.mockResolvedValue({});
const url = await service.startVerification({
purpose: 'LOGIN',
platform: 'MOBILE',
});
const created = prisma.faydaVerificationSession.create.mock.calls[0][0].data;
expect(created.platform).toBe('MOBILE');
expect(new URL(url).searchParams.get('redirect_uri')).toBe(
'http://localhost:4000/fayda/verification/complete',
);
});
it('throws ServiceUnavailable when fayda integration is disabled', async () => {
const disabledCfg = buildConfig({ enabled: false });
const disabledService = new VerifaydaService(
buildConfigService(disabledCfg),
buildConfigService(buildConfig({ enabled: false })),
prisma as unknown as PrismaService,
jwt,
);
await expect(
disabledService.startVerification({ purpose: 'PURCHASE' }),
@@ -124,13 +144,14 @@ describe('VerifaydaService (OIDC)', () => {
});
});
describe('handleCallback', () => {
describe('completeVerification — validation', () => {
function pendingSession(overrides: Partial<any> = {}) {
return {
id: 'session-1',
state: 'state-abc',
codeVerifier: 'verifier-xyz',
purpose: 'PURCHASE',
platform: 'WEB',
saveToAccount: false,
status: 'PENDING',
errorCode: null,
@@ -142,6 +163,69 @@ describe('VerifaydaService (OIDC)', () => {
};
}
it('throws and marks failed when callback carries an error', async () => {
prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 });
await expect(
service.completeVerification({
error: 'access_denied',
error_description: 'user cancelled',
state: 'state-abc',
}),
).rejects.toMatchObject({ status: 400 });
expect(prisma.faydaVerificationSession.updateMany).toHaveBeenCalled();
});
it('throws FAYDA_MISSING_PARAMETERS when code/state absent', async () => {
await expect(service.completeVerification({})).rejects.toMatchObject({
status: 400,
});
});
it('throws FAYDA_INVALID_STATE for unknown state', async () => {
prisma.faydaVerificationSession.findUnique.mockResolvedValue(null);
await expect(
service.completeVerification({ code: 'c', state: 'bogus' }),
).rejects.toMatchObject({ status: 400 });
});
it('throws FAYDA_INVALID_STATE for a non-pending session', async () => {
prisma.faydaVerificationSession.findUnique.mockResolvedValue(
pendingSession({ status: 'COMPLETED' }),
);
await expect(
service.completeVerification({ code: 'c', state: 'state-abc' }),
).rejects.toMatchObject({ status: 400 });
});
it('throws FAYDA_SESSION_EXPIRED and marks failed for an expired session', async () => {
prisma.faydaVerificationSession.findUnique.mockResolvedValue(
pendingSession({ expiresAt: new Date(Date.now() - 1000) }),
);
prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 });
await expect(
service.completeVerification({ code: 'c', state: 'state-abc' }),
).rejects.toMatchObject({ status: 400 });
expect(prisma.faydaVerificationSession.updateMany).toHaveBeenCalled();
});
});
describe('completeVerification — PURCHASE', () => {
function pendingSession(overrides: Partial<any> = {}) {
return {
id: 'session-1',
state: 'state-abc',
codeVerifier: 'verifier-xyz',
purpose: 'PURCHASE',
platform: 'WEB',
saveToAccount: false,
status: 'PENDING',
userId: null,
bookingId: null,
expiresAt: new Date(Date.now() + 60_000),
...overrides,
};
}
function mockFetchSequence(...responses: Array<Partial<Response>>) {
const queue = responses.map((r) => ({
ok: true,
@@ -154,52 +238,7 @@ describe('VerifaydaService (OIDC)', () => {
(global as any).fetch = jest.fn(() => Promise.resolve(queue.shift()));
}
it('redirects to failure URL when callback carries an error', async () => {
prisma.faydaVerificationSession.findUnique.mockResolvedValue(null);
const url = await service.handleCallback({
error: 'access_denied',
error_description: 'user cancelled',
state: 'state-abc',
});
expect(url).toContain('https://passenger.edr.test/verify/failure');
expect(url).toContain('reason=access_denied');
expect(prisma.faydaVerificationSession.updateMany).toHaveBeenCalled();
});
it('redirects to failure URL when state is unknown', async () => {
prisma.faydaVerificationSession.findUnique.mockResolvedValue(null);
const url = await service.handleCallback({
code: 'authcode',
state: 'bogus',
});
expect(url).toContain('reason=invalid_state');
});
it('redirects to failure URL when session is not pending', async () => {
prisma.faydaVerificationSession.findUnique.mockResolvedValue(
pendingSession({ status: 'COMPLETED' }),
);
const url = await service.handleCallback({
code: 'authcode',
state: 'state-abc',
});
expect(url).toContain('reason=invalid_state');
});
it('redirects to failure URL when session is expired', async () => {
prisma.faydaVerificationSession.findUnique.mockResolvedValue(
pendingSession({ expiresAt: new Date(Date.now() - 1000) }),
);
const url = await service.handleCallback({
code: 'authcode',
state: 'state-abc',
});
expect(url).toContain('reason=session_expired');
});
it('happy path: exchanges code, fetches userinfo, updates booking seat', async () => {
it('stamps the booking seats and returns { verified, fullName }', async () => {
prisma.faydaVerificationSession.findUnique.mockResolvedValue(
pendingSession({ bookingId: 'booking-1' }),
);
@@ -207,45 +246,32 @@ describe('VerifaydaService (OIDC)', () => {
prisma.bookingSeat.updateMany.mockResolvedValue({ count: 1 });
mockFetchSequence(
{
json: async () => ({ access_token: 'tok', token_type: 'Bearer' }),
},
{ json: async () => ({ access_token: 'tok', token_type: 'Bearer' }) },
{
headers: new Headers({ 'content-type': 'application/json' }),
text: async () =>
JSON.stringify({
sub: 'fayda-sub-1',
name: 'Test User',
phone_number: '+251911000000',
email: 'test@example.com',
}),
JSON.stringify({ sub: 'fayda-sub-1', name: 'Test User' }),
},
);
const url = await service.handleCallback({
const result = await service.completeVerification({
code: 'authcode',
state: 'state-abc',
});
expect(url).toBe('https://passenger.edr.test/verify/success');
expect(result).toMatchObject({
purpose: 'PURCHASE',
verified: true,
fullName: 'Test User',
});
expect(result.token).toBeUndefined();
expect(prisma.bookingSeat.updateMany).toHaveBeenCalledWith({
where: { bookingId: 'booking-1' },
data: expect.objectContaining({
faydaSub: 'fayda-sub-1',
faydaVerifiedName: 'Test User',
}),
data: expect.objectContaining({ faydaSub: 'fayda-sub-1' }),
});
expect(prisma.faydaVerificationSession.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
status: 'COMPLETED',
codeVerifier: '',
}),
}),
);
});
it('saves to User when saveToAccount=true and no conflict', async () => {
it('saves to the User account when saveToAccount=true and no conflict', async () => {
prisma.faydaVerificationSession.findUnique.mockResolvedValue(
pendingSession({ userId: 'user-1', saveToAccount: true }),
);
@@ -262,26 +288,24 @@ describe('VerifaydaService (OIDC)', () => {
},
);
const url = await service.handleCallback({
const result = await service.completeVerification({
code: 'authcode',
state: 'state-abc',
});
expect(url).toBe('https://passenger.edr.test/verify/success');
expect(result.verified).toBe(true);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: 'user-1' },
data: expect.objectContaining({
faydaVerified: true,
faydaSub: 'fayda-sub-2',
}),
data: expect.objectContaining({ faydaVerified: true, faydaSub: 'fayda-sub-2' }),
});
});
it('rejects with identity_conflict when faydaSub is on another user', async () => {
it('throws identity_conflict (409) when faydaSub belongs to another user', async () => {
prisma.faydaVerificationSession.findUnique.mockResolvedValue(
pendingSession({ userId: 'user-1', saveToAccount: true }),
);
prisma.user.findFirst.mockResolvedValue({ id: 'other-user' });
prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 });
mockFetchSequence(
{ json: async () => ({ access_token: 'tok', token_type: 'Bearer' }) },
@@ -292,33 +316,29 @@ describe('VerifaydaService (OIDC)', () => {
},
);
const url = await service.handleCallback({
code: 'authcode',
state: 'state-abc',
});
expect(url).toContain('reason=identity_conflict');
await expect(
service.completeVerification({ code: 'authcode', state: 'state-abc' }),
).rejects.toMatchObject({ status: 409 });
expect(prisma.user.update).not.toHaveBeenCalled();
});
it('reports token_exchange_failed when token endpoint returns 4xx', async () => {
it('throws 502 when the token endpoint returns 4xx', async () => {
prisma.faydaVerificationSession.findUnique.mockResolvedValue(pendingSession());
prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 });
mockFetchSequence({
ok: false,
status: 400,
text: async () => '{"error":"invalid_assertion"}',
});
const url = await service.handleCallback({
code: 'authcode',
state: 'state-abc',
});
expect(url).toContain('reason=token_exchange_failed');
await expect(
service.completeVerification({ code: 'authcode', state: 'state-abc' }),
).rejects.toMatchObject({ status: 502 });
});
it('reports userinfo_failed when userinfo response is unsupported', async () => {
it('throws 502 when userinfo is an unsupported format', async () => {
prisma.faydaVerificationSession.findUnique.mockResolvedValue(pendingSession());
prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 });
mockFetchSequence(
{ json: async () => ({ access_token: 'tok', token_type: 'Bearer' }) },
{
@@ -327,11 +347,9 @@ describe('VerifaydaService (OIDC)', () => {
},
);
const url = await service.handleCallback({
code: 'authcode',
state: 'state-abc',
});
expect(url).toContain('reason=userinfo_failed');
await expect(
service.completeVerification({ code: 'authcode', state: 'state-abc' }),
).rejects.toMatchObject({ status: 502 });
});
it('falls back to localized name (name#en) when name is missing', async () => {
@@ -354,10 +372,176 @@ describe('VerifaydaService (OIDC)', () => {
},
);
await service.handleCallback({ code: 'c', state: 'state-abc' });
const result = await service.completeVerification({
code: 'c',
state: 'state-abc',
});
expect(result.fullName).toBe('English Name');
expect(prisma.bookingSeat.updateMany.mock.calls[0][0].data.faydaVerifiedName).toBe(
'English Name',
);
});
});
const seatCall = prisma.bookingSeat.updateMany.mock.calls[0][0];
expect(seatCall.data.faydaVerifiedName).toBe('English Name');
describe('completeVerification — LOGIN', () => {
function loginSession(overrides: Partial<any> = {}) {
return {
id: 'login-session',
state: 'state-login',
codeVerifier: 'verifier-xyz',
purpose: 'LOGIN',
platform: 'WEB',
saveToAccount: false,
status: 'PENDING',
userId: null,
bookingId: null,
expiresAt: new Date(Date.now() + 60_000),
...overrides,
};
}
function mockLoginFetch(userInfo: Record<string, unknown>) {
const queue = [
{
ok: true,
status: 200,
json: async () => ({ access_token: 'tok', token_type: 'Bearer' }),
text: async () => '',
headers: new Headers({ 'content-type': 'application/json' }),
},
{
ok: true,
status: 200,
json: async () => ({}),
text: async () => JSON.stringify(userInfo),
headers: new Headers({ 'content-type': 'application/json' }),
},
];
(global as any).fetch = jest.fn(() => Promise.resolve(queue.shift()));
}
/** user.findUnique answers the faydaSub lookup and the issueLoginToken id lookup. */
function mockUserFindUnique(bySub: any, fullUser: any) {
prisma.user.findUnique.mockImplementation(async (args: any) => {
if (args?.where?.faydaSub !== undefined) return bySub;
if (args?.where?.id !== undefined) return fullUser;
return null;
});
}
beforeEach(() => {
prisma.faydaVerificationSession.findUnique.mockResolvedValue(loginSession());
});
it('creates a new user when no match and returns { token, user }', async () => {
const fullUser = {
id: 'new-user',
email: 'new@example.com',
role: 'PASSENGER',
passenger: { id: 'p-new' },
agent: null,
};
mockUserFindUnique(null, fullUser);
prisma.user.findFirst.mockResolvedValue(null);
prisma.user.create.mockResolvedValue({ id: 'new-user' });
prisma.passenger.create.mockResolvedValue({ id: 'p-new' });
prisma.loyaltyAccount.create.mockResolvedValue({});
prisma.walletAccount.create.mockResolvedValue({});
prisma.userPreferences.create.mockResolvedValue({});
prisma.faydaVerificationSession.update.mockResolvedValue({});
mockLoginFetch({ sub: 'login-sub-1', name: 'New Person', email: 'new@example.com' });
const result = await service.completeVerification({
code: 'c',
state: 'state-login',
});
expect(result).toMatchObject({
purpose: 'LOGIN',
verified: true,
token: 'signed.jwt.token',
user: { id: 'new-user', passengerId: 'p-new' },
});
expect(prisma.user.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
faydaSub: 'login-sub-1',
faydaVerified: true,
email: 'new@example.com',
}),
}),
);
expect(prisma.passenger.create).toHaveBeenCalled();
expect(jwt.sign).toHaveBeenCalledWith(
expect.objectContaining({ sub: 'new-user', passengerId: 'p-new' }),
);
});
it('logs in an existing user already linked by faydaSub', async () => {
const fullUser = {
id: 'known-user',
email: 'k@example.com',
role: 'PASSENGER',
passenger: { id: 'p-k' },
agent: null,
};
mockUserFindUnique({ id: 'known-user' }, fullUser);
prisma.faydaVerificationSession.update.mockResolvedValue({});
mockLoginFetch({ sub: 'login-sub-2', name: 'Known' });
const result = await service.completeVerification({
code: 'c',
state: 'state-login',
});
expect(result.user?.id).toBe('known-user');
expect(prisma.user.create).not.toHaveBeenCalled();
});
it('links Fayda to an existing account matched by email', async () => {
const fullUser = {
id: 'acc-1',
email: 'match@example.com',
role: 'PASSENGER',
passenger: { id: 'p-1' },
agent: null,
};
mockUserFindUnique(null, fullUser);
prisma.user.findFirst.mockResolvedValue({ id: 'acc-1', faydaSub: null });
prisma.user.update.mockResolvedValue({});
prisma.faydaVerificationSession.update.mockResolvedValue({});
mockLoginFetch({ sub: 'login-sub-3', email: 'match@example.com' });
const result = await service.completeVerification({
code: 'c',
state: 'state-login',
});
expect(result.user?.id).toBe('acc-1');
expect(prisma.user.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'acc-1' },
data: expect.objectContaining({ faydaSub: 'login-sub-3' }),
}),
);
expect(prisma.user.create).not.toHaveBeenCalled();
});
it('throws identity_conflict (409) when matched account has a different faydaSub', async () => {
mockUserFindUnique(null, null);
prisma.user.findFirst.mockResolvedValue({ id: 'acc-2', faydaSub: 'someone-else' });
prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 });
mockLoginFetch({ sub: 'login-sub-4', email: 'match@example.com' });
await expect(
service.completeVerification({ code: 'c', state: 'state-login' }),
).rejects.toMatchObject({ status: 409 });
expect(prisma.user.update).not.toHaveBeenCalled();
expect(prisma.user.create).not.toHaveBeenCalled();
});
});

View File

@@ -3,11 +3,15 @@ import {
Injectable,
Logger,
ServiceUnavailableException,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import axios, { AxiosInstance } from 'axios';
import * as bcrypt from 'bcrypt';
import { randomBytes } from 'crypto';
import { PrismaService } from '../../common/prisma.service';
import { FaydaConfig } from '../../config/fayda.config';
import { FaydaConfig, FaydaPlatform } from '../../config/fayda.config';
import {
generateCodeChallenge,
generateCodeVerifier,
@@ -43,11 +47,32 @@ export interface VerifaydaVerificationResult {
export interface StartVerificationInput {
purpose: VerifaydaPurpose;
platform?: FaydaPlatform;
userId?: string;
bookingId?: string;
saveToAccount?: boolean;
}
export interface FaydaUserSummary {
id: string;
email: string;
role: string;
passengerId?: string;
agentId?: string;
}
/**
* Result of completing a verification. `verified` is always true on success.
* LOGIN additionally returns a JWT + user; PURCHASE returns the verified name.
*/
export interface CompleteVerificationResult {
purpose: VerifaydaPurpose;
verified: boolean;
token?: string;
user?: FaydaUserSummary;
fullName?: string;
}
@Injectable()
export class VerifaydaService {
private readonly logger = new Logger(VerifaydaService.name);
@@ -63,6 +88,7 @@ export class VerifaydaService {
constructor(
private readonly config: ConfigService,
private readonly prisma: PrismaService,
private readonly jwt: JwtService,
) {
const fayda = this.config.get<FaydaConfig>('fayda');
if (!fayda) {
@@ -107,6 +133,7 @@ export class VerifaydaService {
state,
codeVerifier,
purpose: input.purpose,
platform: input.platform ?? 'WEB',
saveToAccount: input.saveToAccount ?? false,
userId: input.userId ?? null,
bookingId: input.bookingId ?? null,
@@ -115,13 +142,16 @@ export class VerifaydaService {
});
this.logger.log(
`Fayda verification started: purpose=${input.purpose} userId=${input.userId ?? 'none'} bookingId=${input.bookingId ?? 'none'}`,
`Fayda verification started: purpose=${input.purpose} platform=${input.platform ?? 'WEB'} userId=${input.userId ?? 'none'} bookingId=${input.bookingId ?? 'none'}`,
);
return this.buildAuthorizationUrl({ state, codeChallenge });
}
async handleCallback(query: VerifaydaCallbackDto): Promise<string> {
async completeVerification(
query: VerifaydaCallbackDto,
): Promise<CompleteVerificationResult> {
if (query.error) {
this.logger.warn(`Fayda callback returned error: ${query.error}`);
if (query.state) {
@@ -131,32 +161,36 @@ export class VerifaydaService {
query.error_description,
);
}
return this.buildFailureUrl(query.error);
throw new BadRequestException({
code: 'FAYDA_AUTH_ERROR',
message: query.error,
description: query.error_description,
});
}
if (!query.code || !query.state) {
this.logger.warn('Fayda callback missing code or state');
return this.buildFailureUrl('missing_parameters');
throw new BadRequestException({
code: 'FAYDA_MISSING_PARAMETERS',
message: 'code and state are required',
});
}
const session = await this.prisma.faydaVerificationSession.findUnique({
where: { state: query.state },
});
if (!session) {
this.logger.warn('Fayda callback with unknown state');
return this.buildFailureUrl('invalid_state');
}
if (session.status !== 'PENDING') {
this.logger.warn(
`Fayda callback for non-pending session (status=${session.status})`,
);
return this.buildFailureUrl('invalid_state');
if (!session || session.status !== 'PENDING') {
this.logger.warn('Fayda complete with unknown or non-pending state');
throw new BadRequestException({
code: 'FAYDA_INVALID_STATE',
message: 'Verification session is invalid or already used',
});
}
if (session.expiresAt.getTime() < Date.now()) {
await this.markSessionFailed(query.state, 'session_expired');
this.logger.warn('Fayda callback for expired session');
return this.buildFailureUrl('session_expired');
throw new BadRequestException({
code: 'FAYDA_SESSION_EXPIRED',
message: 'Verification session has expired; start again',
});
}
try {
@@ -171,25 +205,29 @@ export class VerifaydaService {
throw new FaydaUserInfoException('Fayda userinfo missing required sub');
}
let result: CompleteVerificationResult;
if (session.purpose === 'PURCHASE') {
await this.handlePurchaseSuccess(session, normalized);
result = {
purpose: 'PURCHASE',
verified: true,
fullName: normalized.fullName,
};
} else {
await this.handleLoginSuccess(session, normalized);
const { userId } = await this.handleLoginSuccess(normalized);
const login = await this.issueLoginToken(userId);
result = { purpose: 'LOGIN', verified: true, ...login };
}
await this.prisma.faydaVerificationSession.update({
where: { id: session.id },
data: {
status: 'COMPLETED',
completedAt: new Date(),
codeVerifier: '',
},
data: { status: 'COMPLETED', completedAt: new Date(), codeVerifier: '' },
});
this.logger.log(
`Fayda verification completed: purpose=${session.purpose}`,
`Fayda verification completed: purpose=${session.purpose} platform=${session.platform}`,
);
return this.buildSuccessUrl();
return result;
} catch (err) {
const reason = this.classifyFailureReason(err);
this.logger.error(
@@ -200,10 +238,45 @@ export class VerifaydaService {
reason,
(err as Error).message,
);
return this.buildFailureUrl(reason);
throw err;
}
}
/** Loads a user (+ relations) and mints the same JWT shape as `/auth/login`. */
private async issueLoginToken(
userId: string,
): Promise<{ token: string; user: FaydaUserSummary }> {
const user = await this.prisma.user.findUnique({
where: { id: userId },
include: { passenger: true, agent: true },
});
if (!user) {
// Should not happen — we just resolved/created this user.
throw new UnauthorizedException({
code: 'FAYDA_LOGIN_FAILED',
message: 'Could not load the verified user',
});
}
const summary: FaydaUserSummary = {
id: user.id,
email: user.email,
role: user.role,
passengerId: user.passenger?.id,
agentId: user.agent?.id,
};
const token = this.jwt.sign({
sub: summary.id,
email: summary.email,
role: summary.role,
passengerId: summary.passengerId,
agentId: summary.agentId,
});
this.logger.log(`Fayda login issued token for user ${user.id}`);
return { token, user: summary };
}
async getVerificationStatus(userId: string): Promise<VerificationStatusDto> {
const user = await this.prisma.user.findUnique({
where: { id: userId },
@@ -383,17 +456,106 @@ export class VerifaydaService {
}
}
/**
* Resolves the User for a LOGIN flow and returns its id (the caller mints the
* JWT via {@link issueLoginToken}). Resolution order:
* 1. Existing user already linked to this Fayda `sub`.
* 2. Existing account whose email/phone matches — linked to this `sub`.
* 3. Otherwise a fresh Fayda-backed account is created.
*/
private async handleLoginSuccess(
_session: { id: string },
_normalized: NormalizedFaydaUserInfo,
): Promise<void> {
// LOGIN flow (User creation / login token issuance)
// The schema currently requires email/phone/passwordHash on User as NOT NULL,
// and the auth controllers haven't been wired to consume Fayda identities yet.
throw new BadRequestException({
code: 'FAYDA_LOGIN_NOT_IMPLEMENTED',
message: 'Login-with-Fayda is not yet implemented',
normalized: NormalizedFaydaUserInfo,
): Promise<{ userId: string }> {
let userId: string;
const bySub = await this.prisma.user.findUnique({
where: { faydaSub: normalized.sub },
select: { id: true },
});
if (bySub) {
userId = bySub.id;
} else {
const matchers: Array<{ email?: string; phone?: string }> = [];
if (normalized.email) matchers.push({ email: normalized.email });
if (normalized.phoneNumber) matchers.push({ phone: normalized.phoneNumber });
const existing = matchers.length
? await this.prisma.user.findFirst({
where: { OR: matchers },
select: { id: true, faydaSub: true },
})
: null;
if (existing) {
if (existing.faydaSub && existing.faydaSub !== normalized.sub) {
// The matched account is already tied to a different Fayda identity.
throw new FaydaIdentityConflictException();
}
await this.prisma.user.update({
where: { id: existing.id },
data: {
faydaSub: normalized.sub,
faydaVerified: true,
faydaVerifiedAt: new Date(),
},
});
userId = existing.id;
this.logger.log(`Fayda login linked existing user ${existing.id}`);
} else {
userId = await this.createFaydaUser(normalized);
this.logger.log(`Fayda login created new user ${userId}`);
}
}
return { userId };
}
/**
* Creates a Fayda-backed User plus the same satellite rows registration makes
* (Passenger, LoyaltyAccount, WalletAccount, UserPreferences).
*
* The user has no password — `passwordHash` is set to a bcrypt of random bytes
* so password login is impossible; they authenticate only via Fayda. When
* Fayda doesn't supply an email/phone, a deterministic placeholder derived from
* the (unique) `sub` keeps the NOT NULL + unique columns satisfied.
*/
private async createFaydaUser(
normalized: NormalizedFaydaUserInfo,
): Promise<string> {
const passwordHash = await bcrypt.hash(
randomBytes(32).toString('hex'),
10,
);
const email = normalized.email ?? `fayda_${normalized.sub}@users.fayda.local`;
const phone = normalized.phoneNumber ?? `fayda:${normalized.sub}`;
const fullName = normalized.fullName ?? 'Fayda User';
const user = await this.prisma.user.create({
data: {
fullName,
email,
phone,
passwordHash,
faydaVerified: true,
faydaVerifiedAt: new Date(),
faydaSub: normalized.sub,
},
select: { id: true },
});
const passenger = await this.prisma.passenger.create({
data: { userId: user.id },
select: { id: true },
});
await this.prisma.loyaltyAccount.create({
data: { passengerId: passenger.id },
});
await this.prisma.walletAccount.create({
data: { passengerId: passenger.id },
});
await this.prisma.userPreferences.create({ data: { userId: user.id } });
return user.id;
}
private async markSessionFailed(
@@ -413,16 +575,6 @@ export class VerifaydaService {
});
}
private buildSuccessUrl(): string {
return this.faydaConfig.successRedirectUrl;
}
private buildFailureUrl(reason: string): string {
const url = new URL(this.faydaConfig.failureRedirectUrl);
url.searchParams.set('reason', reason);
return url.toString();
}
private classifyFailureReason(err: unknown): string {
if (err instanceof FaydaIdentityConflictException) return 'identity_conflict';
if (err instanceof FaydaTokenExchangeException) return 'token_exchange_failed';