mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 21:15:41 +00:00
feat(fayda): add fayda verification endpoints and login-with-fayda
This commit is contained in:
@@ -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");
|
||||||
@@ -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?
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
Reference in New Issue
Block a user