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 state String @unique
codeVerifier String codeVerifier String
purpose String @default("PURCHASE") purpose String @default("PURCHASE")
platform String @default("WEB") // WEB | MOBILE — recorded for audit
saveToAccount Boolean @default(false) saveToAccount Boolean @default(false)
status String @default("PENDING") status String @default("PENDING")
errorCode String? errorCode String?

View File

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

View File

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

View File

@@ -26,6 +26,42 @@ export class StartVerificationDto {
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
saveToAccount?: boolean; 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 { export class VerifaydaCallbackDto {

View File

@@ -2,9 +2,12 @@ import { Module } from '@nestjs/common';
import { VerifaydaController } from './verifayda.controller'; import { VerifaydaController } from './verifayda.controller';
import { VerifaydaService } from './verifayda.service'; import { VerifaydaService } from './verifayda.service';
import { PrismaModule } from '../../common/prisma.module'; import { PrismaModule } from '../../common/prisma.module';
import { AuthModule } from '../auth/auth.module';
@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], controllers: [VerifaydaController],
providers: [VerifaydaService], providers: [VerifaydaService],
exports: [VerifaydaService], exports: [VerifaydaService],

View File

@@ -1,15 +1,10 @@
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { exportJWK, generateKeyPair, type JWK } from 'jose'; import { exportJWK, generateKeyPair, type JWK } from 'jose';
import { PrismaService } from '../../common/prisma.service'; import { PrismaService } from '../../common/prisma.service';
import { FaydaConfig } from '../../config/fayda.config'; import { FaydaConfig } from '../../config/fayda.config';
import { VerifaydaService } from './verifayda.service'; 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() { function buildPrismaMock() {
return { return {
faydaVerificationSession: { faydaVerificationSession: {
@@ -24,12 +19,23 @@ function buildPrismaMock() {
user: { user: {
findUnique: jest.fn(), findUnique: jest.fn(),
findFirst: jest.fn(), findFirst: jest.fn(),
create: jest.fn(),
update: 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() }, 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 { function buildConfig(overrides?: Partial<FaydaConfig>): FaydaConfig {
return { return {
enabled: true, enabled: true,
@@ -37,10 +43,8 @@ function buildConfig(overrides?: Partial<FaydaConfig>): FaydaConfig {
authorizationEndpoint: 'https://esignet.test/authorize', authorizationEndpoint: 'https://esignet.test/authorize',
tokenEndpoint: 'https://esignet.test/token', tokenEndpoint: 'https://esignet.test/token',
userInfoEndpoint: 'https://esignet.test/userinfo', 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: '' }, privateJwk: { kty: 'RSA', n: '', e: '', d: '' },
successRedirectUrl: 'https://passenger.edr.test/verify/success',
failureRedirectUrl: 'https://passenger.edr.test/verify/failure',
scope: 'openid profile email', scope: 'openid profile email',
acrValues: 'mosip:idp:acr:generated-code', acrValues: 'mosip:idp:acr:generated-code',
claimsLocales: 'en am', claimsLocales: 'en am',
@@ -59,8 +63,9 @@ function buildConfigService(faydaConfig: FaydaConfig): jest.Mocked<ConfigService
} as unknown as jest.Mocked<ConfigService>; } as unknown as jest.Mocked<ConfigService>;
} }
describe('VerifaydaService (OIDC)', () => { describe('VerifaydaService (OIDC, client-callback)', () => {
let prisma: ReturnType<typeof buildPrismaMock>; let prisma: ReturnType<typeof buildPrismaMock>;
let jwt: jest.Mocked<JwtService>;
let service: VerifaydaService; let service: VerifaydaService;
let realPrivateJwk: JWK; let realPrivateJwk: JWK;
@@ -72,10 +77,12 @@ describe('VerifaydaService (OIDC)', () => {
beforeEach(() => { beforeEach(() => {
prisma = buildPrismaMock(); prisma = buildPrismaMock();
jwt = buildJwtMock();
const cfg = buildConfig({ privateJwk: realPrivateJwk as FaydaConfig['privateJwk'] }); const cfg = buildConfig({ privateJwk: realPrivateJwk as FaydaConfig['privateJwk'] });
service = new VerifaydaService( service = new VerifaydaService(
buildConfigService(cfg), buildConfigService(cfg),
prisma as unknown as PrismaService, prisma as unknown as PrismaService,
jwt,
); );
(global as any).fetch = jest.fn(); (global as any).fetch = jest.fn();
}); });
@@ -94,29 +101,42 @@ describe('VerifaydaService (OIDC)', () => {
saveToAccount: true, saveToAccount: true,
}); });
expect(prisma.faydaVerificationSession.create).toHaveBeenCalledTimes(1);
const created = prisma.faydaVerificationSession.create.mock.calls[0][0].data; const created = prisma.faydaVerificationSession.create.mock.calls[0][0].data;
expect(created.purpose).toBe('PURCHASE'); expect(created.purpose).toBe('PURCHASE');
expect(created.saveToAccount).toBe(true); expect(created.platform).toBe('WEB');
expect(created.userId).toBe('user-1');
expect(typeof created.state).toBe('string'); expect(typeof created.state).toBe('string');
expect(typeof created.codeVerifier).toBe('string'); expect(typeof created.codeVerifier).toBe('string');
const parsed = new URL(url); const parsed = new URL(url);
expect(parsed.origin + parsed.pathname).toBe( expect(parsed.origin + parsed.pathname).toBe('https://esignet.test/authorize');
'https://esignet.test/authorize',
);
expect(parsed.searchParams.get('client_id')).toBe('edr-test-client'); 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('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); 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 () => { it('throws ServiceUnavailable when fayda integration is disabled', async () => {
const disabledCfg = buildConfig({ enabled: false });
const disabledService = new VerifaydaService( const disabledService = new VerifaydaService(
buildConfigService(disabledCfg), buildConfigService(buildConfig({ enabled: false })),
prisma as unknown as PrismaService, prisma as unknown as PrismaService,
jwt,
); );
await expect( await expect(
disabledService.startVerification({ purpose: 'PURCHASE' }), disabledService.startVerification({ purpose: 'PURCHASE' }),
@@ -124,13 +144,14 @@ describe('VerifaydaService (OIDC)', () => {
}); });
}); });
describe('handleCallback', () => { describe('completeVerification — validation', () => {
function pendingSession(overrides: Partial<any> = {}) { function pendingSession(overrides: Partial<any> = {}) {
return { return {
id: 'session-1', id: 'session-1',
state: 'state-abc', state: 'state-abc',
codeVerifier: 'verifier-xyz', codeVerifier: 'verifier-xyz',
purpose: 'PURCHASE', purpose: 'PURCHASE',
platform: 'WEB',
saveToAccount: false, saveToAccount: false,
status: 'PENDING', status: 'PENDING',
errorCode: null, 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>>) { function mockFetchSequence(...responses: Array<Partial<Response>>) {
const queue = responses.map((r) => ({ const queue = responses.map((r) => ({
ok: true, ok: true,
@@ -154,52 +238,7 @@ describe('VerifaydaService (OIDC)', () => {
(global as any).fetch = jest.fn(() => Promise.resolve(queue.shift())); (global as any).fetch = jest.fn(() => Promise.resolve(queue.shift()));
} }
it('redirects to failure URL when callback carries an error', async () => { it('stamps the booking seats and returns { verified, fullName }', 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 () => {
prisma.faydaVerificationSession.findUnique.mockResolvedValue( prisma.faydaVerificationSession.findUnique.mockResolvedValue(
pendingSession({ bookingId: 'booking-1' }), pendingSession({ bookingId: 'booking-1' }),
); );
@@ -207,45 +246,32 @@ describe('VerifaydaService (OIDC)', () => {
prisma.bookingSeat.updateMany.mockResolvedValue({ count: 1 }); prisma.bookingSeat.updateMany.mockResolvedValue({ count: 1 });
mockFetchSequence( mockFetchSequence(
{ { json: async () => ({ access_token: 'tok', token_type: 'Bearer' }) },
json: async () => ({ access_token: 'tok', token_type: 'Bearer' }),
},
{ {
headers: new Headers({ 'content-type': 'application/json' }), headers: new Headers({ 'content-type': 'application/json' }),
text: async () => text: async () =>
JSON.stringify({ JSON.stringify({ sub: 'fayda-sub-1', name: 'Test User' }),
sub: 'fayda-sub-1',
name: 'Test User',
phone_number: '+251911000000',
email: 'test@example.com',
}),
}, },
); );
const url = await service.handleCallback({ const result = await service.completeVerification({
code: 'authcode', code: 'authcode',
state: 'state-abc', 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({ expect(prisma.bookingSeat.updateMany).toHaveBeenCalledWith({
where: { bookingId: 'booking-1' }, where: { bookingId: 'booking-1' },
data: expect.objectContaining({ data: expect.objectContaining({ faydaSub: 'fayda-sub-1' }),
faydaSub: 'fayda-sub-1',
faydaVerifiedName: 'Test User',
}),
}); });
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( prisma.faydaVerificationSession.findUnique.mockResolvedValue(
pendingSession({ userId: 'user-1', saveToAccount: true }), 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', code: 'authcode',
state: 'state-abc', state: 'state-abc',
}); });
expect(url).toBe('https://passenger.edr.test/verify/success'); expect(result.verified).toBe(true);
expect(prisma.user.update).toHaveBeenCalledWith({ expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: 'user-1' }, where: { id: 'user-1' },
data: expect.objectContaining({ data: expect.objectContaining({ faydaVerified: true, faydaSub: 'fayda-sub-2' }),
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( prisma.faydaVerificationSession.findUnique.mockResolvedValue(
pendingSession({ userId: 'user-1', saveToAccount: true }), pendingSession({ userId: 'user-1', saveToAccount: true }),
); );
prisma.user.findFirst.mockResolvedValue({ id: 'other-user' }); prisma.user.findFirst.mockResolvedValue({ id: 'other-user' });
prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 });
mockFetchSequence( mockFetchSequence(
{ json: async () => ({ access_token: 'tok', token_type: 'Bearer' }) }, { json: async () => ({ access_token: 'tok', token_type: 'Bearer' }) },
@@ -292,33 +316,29 @@ describe('VerifaydaService (OIDC)', () => {
}, },
); );
const url = await service.handleCallback({ await expect(
code: 'authcode', service.completeVerification({ code: 'authcode', state: 'state-abc' }),
state: 'state-abc', ).rejects.toMatchObject({ status: 409 });
});
expect(url).toContain('reason=identity_conflict');
expect(prisma.user.update).not.toHaveBeenCalled(); 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.findUnique.mockResolvedValue(pendingSession());
prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 });
mockFetchSequence({ mockFetchSequence({
ok: false, ok: false,
status: 400, status: 400,
text: async () => '{"error":"invalid_assertion"}', text: async () => '{"error":"invalid_assertion"}',
}); });
const url = await service.handleCallback({ await expect(
code: 'authcode', service.completeVerification({ code: 'authcode', state: 'state-abc' }),
state: 'state-abc', ).rejects.toMatchObject({ status: 502 });
});
expect(url).toContain('reason=token_exchange_failed');
}); });
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.findUnique.mockResolvedValue(pendingSession());
prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 });
mockFetchSequence( mockFetchSequence(
{ json: async () => ({ access_token: 'tok', token_type: 'Bearer' }) }, { json: async () => ({ access_token: 'tok', token_type: 'Bearer' }) },
{ {
@@ -327,11 +347,9 @@ describe('VerifaydaService (OIDC)', () => {
}, },
); );
const url = await service.handleCallback({ await expect(
code: 'authcode', service.completeVerification({ code: 'authcode', state: 'state-abc' }),
state: 'state-abc', ).rejects.toMatchObject({ status: 502 });
});
expect(url).toContain('reason=userinfo_failed');
}); });
it('falls back to localized name (name#en) when name is missing', async () => { 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]; describe('completeVerification — LOGIN', () => {
expect(seatCall.data.faydaVerifiedName).toBe('English Name'); 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, Injectable,
Logger, Logger,
ServiceUnavailableException, ServiceUnavailableException,
UnauthorizedException,
} from '@nestjs/common'; } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import axios, { AxiosInstance } from 'axios'; import axios, { AxiosInstance } from 'axios';
import * as bcrypt from 'bcrypt';
import { randomBytes } from 'crypto';
import { PrismaService } from '../../common/prisma.service'; import { PrismaService } from '../../common/prisma.service';
import { FaydaConfig } from '../../config/fayda.config'; import { FaydaConfig, FaydaPlatform } from '../../config/fayda.config';
import { import {
generateCodeChallenge, generateCodeChallenge,
generateCodeVerifier, generateCodeVerifier,
@@ -43,11 +47,32 @@ export interface VerifaydaVerificationResult {
export interface StartVerificationInput { export interface StartVerificationInput {
purpose: VerifaydaPurpose; purpose: VerifaydaPurpose;
platform?: FaydaPlatform;
userId?: string; userId?: string;
bookingId?: string; bookingId?: string;
saveToAccount?: boolean; 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() @Injectable()
export class VerifaydaService { export class VerifaydaService {
private readonly logger = new Logger(VerifaydaService.name); private readonly logger = new Logger(VerifaydaService.name);
@@ -63,6 +88,7 @@ export class VerifaydaService {
constructor( constructor(
private readonly config: ConfigService, private readonly config: ConfigService,
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly jwt: JwtService,
) { ) {
const fayda = this.config.get<FaydaConfig>('fayda'); const fayda = this.config.get<FaydaConfig>('fayda');
if (!fayda) { if (!fayda) {
@@ -107,6 +133,7 @@ export class VerifaydaService {
state, state,
codeVerifier, codeVerifier,
purpose: input.purpose, purpose: input.purpose,
platform: input.platform ?? 'WEB',
saveToAccount: input.saveToAccount ?? false, saveToAccount: input.saveToAccount ?? false,
userId: input.userId ?? null, userId: input.userId ?? null,
bookingId: input.bookingId ?? null, bookingId: input.bookingId ?? null,
@@ -115,13 +142,16 @@ export class VerifaydaService {
}); });
this.logger.log( 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 }); return this.buildAuthorizationUrl({ state, codeChallenge });
} }
async handleCallback(query: VerifaydaCallbackDto): Promise<string> {
async completeVerification(
query: VerifaydaCallbackDto,
): Promise<CompleteVerificationResult> {
if (query.error) { if (query.error) {
this.logger.warn(`Fayda callback returned error: ${query.error}`); this.logger.warn(`Fayda callback returned error: ${query.error}`);
if (query.state) { if (query.state) {
@@ -131,32 +161,36 @@ export class VerifaydaService {
query.error_description, 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) { if (!query.code || !query.state) {
this.logger.warn('Fayda callback missing code or state'); throw new BadRequestException({
return this.buildFailureUrl('missing_parameters'); code: 'FAYDA_MISSING_PARAMETERS',
message: 'code and state are required',
});
} }
const session = await this.prisma.faydaVerificationSession.findUnique({ const session = await this.prisma.faydaVerificationSession.findUnique({
where: { state: query.state }, where: { state: query.state },
}); });
if (!session || session.status !== 'PENDING') {
if (!session) { this.logger.warn('Fayda complete with unknown or non-pending state');
this.logger.warn('Fayda callback with unknown state'); throw new BadRequestException({
return this.buildFailureUrl('invalid_state'); code: 'FAYDA_INVALID_STATE',
} message: 'Verification session is invalid or already used',
if (session.status !== 'PENDING') { });
this.logger.warn(
`Fayda callback for non-pending session (status=${session.status})`,
);
return this.buildFailureUrl('invalid_state');
} }
if (session.expiresAt.getTime() < Date.now()) { if (session.expiresAt.getTime() < Date.now()) {
await this.markSessionFailed(query.state, 'session_expired'); await this.markSessionFailed(query.state, 'session_expired');
this.logger.warn('Fayda callback for expired session'); throw new BadRequestException({
return this.buildFailureUrl('session_expired'); code: 'FAYDA_SESSION_EXPIRED',
message: 'Verification session has expired; start again',
});
} }
try { try {
@@ -171,25 +205,29 @@ export class VerifaydaService {
throw new FaydaUserInfoException('Fayda userinfo missing required sub'); throw new FaydaUserInfoException('Fayda userinfo missing required sub');
} }
let result: CompleteVerificationResult;
if (session.purpose === 'PURCHASE') { if (session.purpose === 'PURCHASE') {
await this.handlePurchaseSuccess(session, normalized); await this.handlePurchaseSuccess(session, normalized);
result = {
purpose: 'PURCHASE',
verified: true,
fullName: normalized.fullName,
};
} else { } 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({ await this.prisma.faydaVerificationSession.update({
where: { id: session.id }, where: { id: session.id },
data: { data: { status: 'COMPLETED', completedAt: new Date(), codeVerifier: '' },
status: 'COMPLETED',
completedAt: new Date(),
codeVerifier: '',
},
}); });
this.logger.log( 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) { } catch (err) {
const reason = this.classifyFailureReason(err); const reason = this.classifyFailureReason(err);
this.logger.error( this.logger.error(
@@ -200,10 +238,45 @@ export class VerifaydaService {
reason, reason,
(err as Error).message, (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> { async getVerificationStatus(userId: string): Promise<VerificationStatusDto> {
const user = await this.prisma.user.findUnique({ const user = await this.prisma.user.findUnique({
where: { id: userId }, 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( private async handleLoginSuccess(
_session: { id: string }, normalized: NormalizedFaydaUserInfo,
_normalized: NormalizedFaydaUserInfo, ): Promise<{ userId: string }> {
): Promise<void> { let userId: string;
// LOGIN flow (User creation / login token issuance)
// The schema currently requires email/phone/passwordHash on User as NOT NULL, const bySub = await this.prisma.user.findUnique({
// and the auth controllers haven't been wired to consume Fayda identities yet. where: { faydaSub: normalized.sub },
throw new BadRequestException({ select: { id: true },
code: 'FAYDA_LOGIN_NOT_IMPLEMENTED',
message: 'Login-with-Fayda is not yet implemented',
}); });
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( 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 { private classifyFailureReason(err: unknown): string {
if (err instanceof FaydaIdentityConflictException) return 'identity_conflict'; if (err instanceof FaydaIdentityConflictException) return 'identity_conflict';
if (err instanceof FaydaTokenExchangeException) return 'token_exchange_failed'; if (err instanceof FaydaTokenExchangeException) return 'token_exchange_failed';