mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +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
|
||||
codeVerifier String
|
||||
purpose String @default("PURCHASE")
|
||||
platform String @default("WEB") // WEB | MOBILE — recorded for audit
|
||||
saveToAccount Boolean @default(false)
|
||||
status String @default("PENDING")
|
||||
errorCode String?
|
||||
|
||||
@@ -15,6 +15,8 @@ export interface FaydaJwk {
|
||||
qi?: string;
|
||||
}
|
||||
|
||||
export type FaydaPlatform = 'WEB' | 'MOBILE';
|
||||
|
||||
export interface FaydaConfig {
|
||||
enabled: boolean;
|
||||
clientId: string;
|
||||
@@ -23,8 +25,6 @@ export interface FaydaConfig {
|
||||
userInfoEndpoint: string;
|
||||
redirectUri: string;
|
||||
privateJwk: FaydaJwk;
|
||||
successRedirectUrl: string;
|
||||
failureRedirectUrl: string;
|
||||
scope: string;
|
||||
acrValues: string;
|
||||
claimsLocales: string;
|
||||
@@ -36,10 +36,7 @@ const REQUIRED_VARS = [
|
||||
'FAYDA_AUTHORIZATION_ENDPOINT',
|
||||
'FAYDA_TOKEN_ENDPOINT',
|
||||
'FAYDA_USERINFO_ENDPOINT',
|
||||
'FAYDA_REDIRECT_URI',
|
||||
'FAYDA_PRIVATE_KEY_BASE64',
|
||||
'FAYDA_SUCCESS_REDIRECT_URL',
|
||||
'FAYDA_FAILURE_REDIRECT_URL',
|
||||
] as const;
|
||||
|
||||
function decodePrivateJwk(base64: string): FaydaJwk {
|
||||
@@ -73,7 +70,7 @@ export default registerAs('fayda', (): FaydaConfig => {
|
||||
const acrValues = process.env.FAYDA_ACR_VALUES ?? 'mosip:idp:acr:generated-code';
|
||||
const claimsLocales = process.env.FAYDA_CLAIMS_LOCALES ?? 'en am';
|
||||
const sessionTtl = Number.parseInt(process.env.FAYDA_SESSION_TTL_MINUTES ?? '10', 10);
|
||||
|
||||
const redirectUri = process.env.FAYDA_REDIRECT_URI ?? '';
|
||||
if (!enabled) {
|
||||
return {
|
||||
enabled: false,
|
||||
@@ -81,10 +78,8 @@ export default registerAs('fayda', (): FaydaConfig => {
|
||||
authorizationEndpoint: process.env.FAYDA_AUTHORIZATION_ENDPOINT ?? '',
|
||||
tokenEndpoint: process.env.FAYDA_TOKEN_ENDPOINT ?? '',
|
||||
userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT ?? '',
|
||||
redirectUri: process.env.FAYDA_REDIRECT_URI ?? '',
|
||||
redirectUri,
|
||||
privateJwk: { kty: 'RSA', n: '', e: '', d: '' },
|
||||
successRedirectUrl: process.env.FAYDA_SUCCESS_REDIRECT_URL ?? '',
|
||||
failureRedirectUrl: process.env.FAYDA_FAILURE_REDIRECT_URL ?? '',
|
||||
scope,
|
||||
acrValues,
|
||||
claimsLocales,
|
||||
@@ -98,6 +93,11 @@ export default registerAs('fayda', (): FaydaConfig => {
|
||||
`Fayda integration is enabled (FAYDA_ENABLED=true) but the following env vars are missing: ${missing.join(', ')}`,
|
||||
);
|
||||
}
|
||||
if (!redirectUri) {
|
||||
throw new Error(
|
||||
'Fayda integration is enabled but the redirect URI is missing: set FAYDA_REDIRECT_URI',
|
||||
);
|
||||
}
|
||||
if (Number.isNaN(sessionTtl) || sessionTtl <= 0) {
|
||||
throw new Error('FAYDA_SESSION_TTL_MINUTES must be a positive integer');
|
||||
}
|
||||
@@ -108,10 +108,8 @@ export default registerAs('fayda', (): FaydaConfig => {
|
||||
authorizationEndpoint: process.env.FAYDA_AUTHORIZATION_ENDPOINT!,
|
||||
tokenEndpoint: process.env.FAYDA_TOKEN_ENDPOINT!,
|
||||
userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT!,
|
||||
redirectUri: process.env.FAYDA_REDIRECT_URI!,
|
||||
redirectUri,
|
||||
privateJwk: decodePrivateJwk(process.env.FAYDA_PRIVATE_KEY_BASE64!),
|
||||
successRedirectUrl: process.env.FAYDA_SUCCESS_REDIRECT_URL!,
|
||||
failureRedirectUrl: process.env.FAYDA_FAILURE_REDIRECT_URL!,
|
||||
scope,
|
||||
acrValues,
|
||||
claimsLocales,
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
@@ -19,6 +18,7 @@ import {
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
import { OptionalJwtGuard } from './optional-jwt.guard';
|
||||
import {
|
||||
CompleteVerificationResultDto,
|
||||
StartVerificationDto,
|
||||
VerifaydaCallbackDto,
|
||||
VerificationStatusDto,
|
||||
@@ -33,17 +33,14 @@ interface AuthedUser {
|
||||
passengerId?: string;
|
||||
}
|
||||
|
||||
/** Minimal slices of the Express req/res we actually touch (avoids a hard
|
||||
* dependency on `@types/express`, which isn't resolved in this package). */
|
||||
/** Minimal slices of the Express req we touch (avoids a hard dependency on
|
||||
* `@types/express`, which isn't resolved in this package). */
|
||||
interface RequestWithOptionalUser {
|
||||
user?: AuthedUser;
|
||||
}
|
||||
interface RequestWithUser {
|
||||
user: AuthedUser;
|
||||
}
|
||||
interface RedirectableResponse {
|
||||
redirect(url: string): void;
|
||||
}
|
||||
|
||||
@ApiTags('Fayda Verification')
|
||||
@Controller('fayda/verification')
|
||||
@@ -77,6 +74,7 @@ export class VerifaydaController {
|
||||
): Promise<{ authorizationUrl: string }> {
|
||||
const authorizationUrl = await this.service.startVerification({
|
||||
purpose: dto.purpose ?? 'PURCHASE',
|
||||
platform: dto.platform ?? 'WEB',
|
||||
userId: req.user?.userId,
|
||||
bookingId: dto.bookingId,
|
||||
saveToAccount: dto.saveToAccount,
|
||||
@@ -84,19 +82,16 @@ export class VerifaydaController {
|
||||
return { authorizationUrl };
|
||||
}
|
||||
|
||||
@Get('callback')
|
||||
@Get('complete')
|
||||
@ApiOperation({
|
||||
summary: 'eSignet redirect callback (browser lands here)',
|
||||
description: `Fayda/eSignet redirects the user's browser here with \`?code&state\` (success) or \`?error&error_description\` (failure).
|
||||
|
||||
This endpoint is **not** authenticated — Fayda sends no bearer token. It exchanges the code for tokens, fetches the verified claims, records the result, and **302-redirects** the browser to the configured success or failure frontend URL (failures carry a \`?reason=\` the frontend can switch on).`,
|
||||
summary: 'Complete a verification (Fayda redirect / client callback lands here)',
|
||||
description: `This is the registered Fayda \`redirect_uri\`. Fayda redirects the browser here with \`?code&state\``,
|
||||
})
|
||||
async callback(
|
||||
@Query() query: VerifaydaCallbackDto,
|
||||
@Res() res: RedirectableResponse,
|
||||
): Promise<void> {
|
||||
const redirectUrl = await this.service.handleCallback(query);
|
||||
res.redirect(redirectUrl);
|
||||
@ApiOkResponse({ type: CompleteVerificationResultDto })
|
||||
async complete(
|
||||
@Query() dto: VerifaydaCallbackDto,
|
||||
): Promise<CompleteVerificationResultDto> {
|
||||
return this.service.completeVerification(dto);
|
||||
}
|
||||
|
||||
@Get('status')
|
||||
|
||||
@@ -26,6 +26,42 @@ export class StartVerificationDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
saveToAccount?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: ['WEB', 'MOBILE'],
|
||||
default: 'WEB',
|
||||
description:
|
||||
'Client platform. Decides where /callback redirects on completion: a web https URL (WEB) or a custom-scheme deep link the Flutter app intercepts (MOBILE).',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(['WEB', 'MOBILE'])
|
||||
platform?: 'WEB' | 'MOBILE';
|
||||
}
|
||||
|
||||
export class CompleteVerificationResultDto {
|
||||
@ApiProperty({ enum: ['LOGIN', 'PURCHASE'] })
|
||||
purpose: 'LOGIN' | 'PURCHASE';
|
||||
|
||||
@ApiProperty() verified: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: 'JWT (LOGIN flow only).' })
|
||||
token?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Authenticated user summary (LOGIN flow only; same shape as /auth/login).',
|
||||
})
|
||||
user?: {
|
||||
id: string;
|
||||
email: string;
|
||||
role: string;
|
||||
passengerId?: string;
|
||||
agentId?: string;
|
||||
};
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Verified full name from Fayda (PURCHASE flow).',
|
||||
})
|
||||
fullName?: string;
|
||||
}
|
||||
|
||||
export class VerifaydaCallbackDto {
|
||||
|
||||
@@ -2,9 +2,12 @@ import { Module } from '@nestjs/common';
|
||||
import { VerifaydaController } from './verifayda.controller';
|
||||
import { VerifaydaService } from './verifayda.service';
|
||||
import { PrismaModule } from '../../common/prisma.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
// AuthModule re-exports JwtModule, giving us JwtService (same secret/expiry
|
||||
// config as /auth/login) to mint tokens for the LOGIN flow.
|
||||
imports: [PrismaModule, AuthModule],
|
||||
controllers: [VerifaydaController],
|
||||
providers: [VerifaydaService],
|
||||
exports: [VerifaydaService],
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { exportJWK, generateKeyPair, type JWK } from 'jose';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { FaydaConfig } from '../../config/fayda.config';
|
||||
import { VerifaydaService } from './verifayda.service';
|
||||
|
||||
type AnyFn = (...args: any[]) => any;
|
||||
|
||||
function mockFn<T extends AnyFn>(impl?: T): jest.Mock {
|
||||
return impl ? jest.fn(impl) : jest.fn();
|
||||
}
|
||||
|
||||
function buildPrismaMock() {
|
||||
return {
|
||||
faydaVerificationSession: {
|
||||
@@ -24,12 +19,23 @@ function buildPrismaMock() {
|
||||
user: {
|
||||
findUnique: jest.fn(),
|
||||
findFirst: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
passenger: { create: jest.fn() },
|
||||
loyaltyAccount: { create: jest.fn() },
|
||||
walletAccount: { create: jest.fn() },
|
||||
userPreferences: { create: jest.fn() },
|
||||
verifaydaVerification: { create: jest.fn() },
|
||||
};
|
||||
}
|
||||
|
||||
function buildJwtMock(): jest.Mocked<JwtService> {
|
||||
return {
|
||||
sign: jest.fn(() => 'signed.jwt.token'),
|
||||
} as unknown as jest.Mocked<JwtService>;
|
||||
}
|
||||
|
||||
function buildConfig(overrides?: Partial<FaydaConfig>): FaydaConfig {
|
||||
return {
|
||||
enabled: true,
|
||||
@@ -37,10 +43,8 @@ function buildConfig(overrides?: Partial<FaydaConfig>): FaydaConfig {
|
||||
authorizationEndpoint: 'https://esignet.test/authorize',
|
||||
tokenEndpoint: 'https://esignet.test/token',
|
||||
userInfoEndpoint: 'https://esignet.test/userinfo',
|
||||
redirectUri: 'https://api.edr.test/api/v1/fayda/verification/callback',
|
||||
redirectUri: 'http://localhost:4000/fayda/verification/complete',
|
||||
privateJwk: { kty: 'RSA', n: '', e: '', d: '' },
|
||||
successRedirectUrl: 'https://passenger.edr.test/verify/success',
|
||||
failureRedirectUrl: 'https://passenger.edr.test/verify/failure',
|
||||
scope: 'openid profile email',
|
||||
acrValues: 'mosip:idp:acr:generated-code',
|
||||
claimsLocales: 'en am',
|
||||
@@ -59,8 +63,9 @@ function buildConfigService(faydaConfig: FaydaConfig): jest.Mocked<ConfigService
|
||||
} as unknown as jest.Mocked<ConfigService>;
|
||||
}
|
||||
|
||||
describe('VerifaydaService (OIDC)', () => {
|
||||
describe('VerifaydaService (OIDC, client-callback)', () => {
|
||||
let prisma: ReturnType<typeof buildPrismaMock>;
|
||||
let jwt: jest.Mocked<JwtService>;
|
||||
let service: VerifaydaService;
|
||||
let realPrivateJwk: JWK;
|
||||
|
||||
@@ -72,10 +77,12 @@ describe('VerifaydaService (OIDC)', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
prisma = buildPrismaMock();
|
||||
jwt = buildJwtMock();
|
||||
const cfg = buildConfig({ privateJwk: realPrivateJwk as FaydaConfig['privateJwk'] });
|
||||
service = new VerifaydaService(
|
||||
buildConfigService(cfg),
|
||||
prisma as unknown as PrismaService,
|
||||
jwt,
|
||||
);
|
||||
(global as any).fetch = jest.fn();
|
||||
});
|
||||
@@ -94,29 +101,42 @@ describe('VerifaydaService (OIDC)', () => {
|
||||
saveToAccount: true,
|
||||
});
|
||||
|
||||
expect(prisma.faydaVerificationSession.create).toHaveBeenCalledTimes(1);
|
||||
const created = prisma.faydaVerificationSession.create.mock.calls[0][0].data;
|
||||
expect(created.purpose).toBe('PURCHASE');
|
||||
expect(created.saveToAccount).toBe(true);
|
||||
expect(created.userId).toBe('user-1');
|
||||
expect(created.platform).toBe('WEB');
|
||||
expect(typeof created.state).toBe('string');
|
||||
expect(typeof created.codeVerifier).toBe('string');
|
||||
|
||||
const parsed = new URL(url);
|
||||
expect(parsed.origin + parsed.pathname).toBe(
|
||||
'https://esignet.test/authorize',
|
||||
);
|
||||
expect(parsed.origin + parsed.pathname).toBe('https://esignet.test/authorize');
|
||||
expect(parsed.searchParams.get('client_id')).toBe('edr-test-client');
|
||||
expect(parsed.searchParams.get('response_type')).toBe('code');
|
||||
expect(parsed.searchParams.get('code_challenge_method')).toBe('S256');
|
||||
expect(parsed.searchParams.get('redirect_uri')).toBe(
|
||||
'http://localhost:4000/fayda/verification/complete',
|
||||
);
|
||||
expect(parsed.searchParams.get('state')).toBe(created.state);
|
||||
});
|
||||
|
||||
it('uses the same single redirect_uri regardless of platform (platform is only recorded)', async () => {
|
||||
prisma.faydaVerificationSession.create.mockResolvedValue({});
|
||||
|
||||
const url = await service.startVerification({
|
||||
purpose: 'LOGIN',
|
||||
platform: 'MOBILE',
|
||||
});
|
||||
|
||||
const created = prisma.faydaVerificationSession.create.mock.calls[0][0].data;
|
||||
expect(created.platform).toBe('MOBILE');
|
||||
expect(new URL(url).searchParams.get('redirect_uri')).toBe(
|
||||
'http://localhost:4000/fayda/verification/complete',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws ServiceUnavailable when fayda integration is disabled', async () => {
|
||||
const disabledCfg = buildConfig({ enabled: false });
|
||||
const disabledService = new VerifaydaService(
|
||||
buildConfigService(disabledCfg),
|
||||
buildConfigService(buildConfig({ enabled: false })),
|
||||
prisma as unknown as PrismaService,
|
||||
jwt,
|
||||
);
|
||||
await expect(
|
||||
disabledService.startVerification({ purpose: 'PURCHASE' }),
|
||||
@@ -124,13 +144,14 @@ describe('VerifaydaService (OIDC)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleCallback', () => {
|
||||
describe('completeVerification — validation', () => {
|
||||
function pendingSession(overrides: Partial<any> = {}) {
|
||||
return {
|
||||
id: 'session-1',
|
||||
state: 'state-abc',
|
||||
codeVerifier: 'verifier-xyz',
|
||||
purpose: 'PURCHASE',
|
||||
platform: 'WEB',
|
||||
saveToAccount: false,
|
||||
status: 'PENDING',
|
||||
errorCode: null,
|
||||
@@ -142,6 +163,69 @@ describe('VerifaydaService (OIDC)', () => {
|
||||
};
|
||||
}
|
||||
|
||||
it('throws and marks failed when callback carries an error', async () => {
|
||||
prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 });
|
||||
await expect(
|
||||
service.completeVerification({
|
||||
error: 'access_denied',
|
||||
error_description: 'user cancelled',
|
||||
state: 'state-abc',
|
||||
}),
|
||||
).rejects.toMatchObject({ status: 400 });
|
||||
expect(prisma.faydaVerificationSession.updateMany).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws FAYDA_MISSING_PARAMETERS when code/state absent', async () => {
|
||||
await expect(service.completeVerification({})).rejects.toMatchObject({
|
||||
status: 400,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws FAYDA_INVALID_STATE for unknown state', async () => {
|
||||
prisma.faydaVerificationSession.findUnique.mockResolvedValue(null);
|
||||
await expect(
|
||||
service.completeVerification({ code: 'c', state: 'bogus' }),
|
||||
).rejects.toMatchObject({ status: 400 });
|
||||
});
|
||||
|
||||
it('throws FAYDA_INVALID_STATE for a non-pending session', async () => {
|
||||
prisma.faydaVerificationSession.findUnique.mockResolvedValue(
|
||||
pendingSession({ status: 'COMPLETED' }),
|
||||
);
|
||||
await expect(
|
||||
service.completeVerification({ code: 'c', state: 'state-abc' }),
|
||||
).rejects.toMatchObject({ status: 400 });
|
||||
});
|
||||
|
||||
it('throws FAYDA_SESSION_EXPIRED and marks failed for an expired session', async () => {
|
||||
prisma.faydaVerificationSession.findUnique.mockResolvedValue(
|
||||
pendingSession({ expiresAt: new Date(Date.now() - 1000) }),
|
||||
);
|
||||
prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 });
|
||||
await expect(
|
||||
service.completeVerification({ code: 'c', state: 'state-abc' }),
|
||||
).rejects.toMatchObject({ status: 400 });
|
||||
expect(prisma.faydaVerificationSession.updateMany).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('completeVerification — PURCHASE', () => {
|
||||
function pendingSession(overrides: Partial<any> = {}) {
|
||||
return {
|
||||
id: 'session-1',
|
||||
state: 'state-abc',
|
||||
codeVerifier: 'verifier-xyz',
|
||||
purpose: 'PURCHASE',
|
||||
platform: 'WEB',
|
||||
saveToAccount: false,
|
||||
status: 'PENDING',
|
||||
userId: null,
|
||||
bookingId: null,
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function mockFetchSequence(...responses: Array<Partial<Response>>) {
|
||||
const queue = responses.map((r) => ({
|
||||
ok: true,
|
||||
@@ -154,52 +238,7 @@ describe('VerifaydaService (OIDC)', () => {
|
||||
(global as any).fetch = jest.fn(() => Promise.resolve(queue.shift()));
|
||||
}
|
||||
|
||||
it('redirects to failure URL when callback carries an error', async () => {
|
||||
prisma.faydaVerificationSession.findUnique.mockResolvedValue(null);
|
||||
|
||||
const url = await service.handleCallback({
|
||||
error: 'access_denied',
|
||||
error_description: 'user cancelled',
|
||||
state: 'state-abc',
|
||||
});
|
||||
|
||||
expect(url).toContain('https://passenger.edr.test/verify/failure');
|
||||
expect(url).toContain('reason=access_denied');
|
||||
expect(prisma.faydaVerificationSession.updateMany).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('redirects to failure URL when state is unknown', async () => {
|
||||
prisma.faydaVerificationSession.findUnique.mockResolvedValue(null);
|
||||
const url = await service.handleCallback({
|
||||
code: 'authcode',
|
||||
state: 'bogus',
|
||||
});
|
||||
expect(url).toContain('reason=invalid_state');
|
||||
});
|
||||
|
||||
it('redirects to failure URL when session is not pending', async () => {
|
||||
prisma.faydaVerificationSession.findUnique.mockResolvedValue(
|
||||
pendingSession({ status: 'COMPLETED' }),
|
||||
);
|
||||
const url = await service.handleCallback({
|
||||
code: 'authcode',
|
||||
state: 'state-abc',
|
||||
});
|
||||
expect(url).toContain('reason=invalid_state');
|
||||
});
|
||||
|
||||
it('redirects to failure URL when session is expired', async () => {
|
||||
prisma.faydaVerificationSession.findUnique.mockResolvedValue(
|
||||
pendingSession({ expiresAt: new Date(Date.now() - 1000) }),
|
||||
);
|
||||
const url = await service.handleCallback({
|
||||
code: 'authcode',
|
||||
state: 'state-abc',
|
||||
});
|
||||
expect(url).toContain('reason=session_expired');
|
||||
});
|
||||
|
||||
it('happy path: exchanges code, fetches userinfo, updates booking seat', async () => {
|
||||
it('stamps the booking seats and returns { verified, fullName }', async () => {
|
||||
prisma.faydaVerificationSession.findUnique.mockResolvedValue(
|
||||
pendingSession({ bookingId: 'booking-1' }),
|
||||
);
|
||||
@@ -207,45 +246,32 @@ describe('VerifaydaService (OIDC)', () => {
|
||||
prisma.bookingSeat.updateMany.mockResolvedValue({ count: 1 });
|
||||
|
||||
mockFetchSequence(
|
||||
{
|
||||
json: async () => ({ access_token: 'tok', token_type: 'Bearer' }),
|
||||
},
|
||||
{ json: async () => ({ access_token: 'tok', token_type: 'Bearer' }) },
|
||||
{
|
||||
headers: new Headers({ 'content-type': 'application/json' }),
|
||||
text: async () =>
|
||||
JSON.stringify({
|
||||
sub: 'fayda-sub-1',
|
||||
name: 'Test User',
|
||||
phone_number: '+251911000000',
|
||||
email: 'test@example.com',
|
||||
}),
|
||||
JSON.stringify({ sub: 'fayda-sub-1', name: 'Test User' }),
|
||||
},
|
||||
);
|
||||
|
||||
const url = await service.handleCallback({
|
||||
const result = await service.completeVerification({
|
||||
code: 'authcode',
|
||||
state: 'state-abc',
|
||||
});
|
||||
|
||||
expect(url).toBe('https://passenger.edr.test/verify/success');
|
||||
expect(result).toMatchObject({
|
||||
purpose: 'PURCHASE',
|
||||
verified: true,
|
||||
fullName: 'Test User',
|
||||
});
|
||||
expect(result.token).toBeUndefined();
|
||||
expect(prisma.bookingSeat.updateMany).toHaveBeenCalledWith({
|
||||
where: { bookingId: 'booking-1' },
|
||||
data: expect.objectContaining({
|
||||
faydaSub: 'fayda-sub-1',
|
||||
faydaVerifiedName: 'Test User',
|
||||
}),
|
||||
data: expect.objectContaining({ faydaSub: 'fayda-sub-1' }),
|
||||
});
|
||||
expect(prisma.faydaVerificationSession.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
status: 'COMPLETED',
|
||||
codeVerifier: '',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('saves to User when saveToAccount=true and no conflict', async () => {
|
||||
it('saves to the User account when saveToAccount=true and no conflict', async () => {
|
||||
prisma.faydaVerificationSession.findUnique.mockResolvedValue(
|
||||
pendingSession({ userId: 'user-1', saveToAccount: true }),
|
||||
);
|
||||
@@ -262,26 +288,24 @@ describe('VerifaydaService (OIDC)', () => {
|
||||
},
|
||||
);
|
||||
|
||||
const url = await service.handleCallback({
|
||||
const result = await service.completeVerification({
|
||||
code: 'authcode',
|
||||
state: 'state-abc',
|
||||
});
|
||||
|
||||
expect(url).toBe('https://passenger.edr.test/verify/success');
|
||||
expect(result.verified).toBe(true);
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: 'user-1' },
|
||||
data: expect.objectContaining({
|
||||
faydaVerified: true,
|
||||
faydaSub: 'fayda-sub-2',
|
||||
}),
|
||||
data: expect.objectContaining({ faydaVerified: true, faydaSub: 'fayda-sub-2' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects with identity_conflict when faydaSub is on another user', async () => {
|
||||
it('throws identity_conflict (409) when faydaSub belongs to another user', async () => {
|
||||
prisma.faydaVerificationSession.findUnique.mockResolvedValue(
|
||||
pendingSession({ userId: 'user-1', saveToAccount: true }),
|
||||
);
|
||||
prisma.user.findFirst.mockResolvedValue({ id: 'other-user' });
|
||||
prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 });
|
||||
|
||||
mockFetchSequence(
|
||||
{ json: async () => ({ access_token: 'tok', token_type: 'Bearer' }) },
|
||||
@@ -292,33 +316,29 @@ describe('VerifaydaService (OIDC)', () => {
|
||||
},
|
||||
);
|
||||
|
||||
const url = await service.handleCallback({
|
||||
code: 'authcode',
|
||||
state: 'state-abc',
|
||||
});
|
||||
expect(url).toContain('reason=identity_conflict');
|
||||
await expect(
|
||||
service.completeVerification({ code: 'authcode', state: 'state-abc' }),
|
||||
).rejects.toMatchObject({ status: 409 });
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports token_exchange_failed when token endpoint returns 4xx', async () => {
|
||||
it('throws 502 when the token endpoint returns 4xx', async () => {
|
||||
prisma.faydaVerificationSession.findUnique.mockResolvedValue(pendingSession());
|
||||
|
||||
prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 });
|
||||
mockFetchSequence({
|
||||
ok: false,
|
||||
status: 400,
|
||||
text: async () => '{"error":"invalid_assertion"}',
|
||||
});
|
||||
|
||||
const url = await service.handleCallback({
|
||||
code: 'authcode',
|
||||
state: 'state-abc',
|
||||
});
|
||||
expect(url).toContain('reason=token_exchange_failed');
|
||||
await expect(
|
||||
service.completeVerification({ code: 'authcode', state: 'state-abc' }),
|
||||
).rejects.toMatchObject({ status: 502 });
|
||||
});
|
||||
|
||||
it('reports userinfo_failed when userinfo response is unsupported', async () => {
|
||||
it('throws 502 when userinfo is an unsupported format', async () => {
|
||||
prisma.faydaVerificationSession.findUnique.mockResolvedValue(pendingSession());
|
||||
|
||||
prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 });
|
||||
mockFetchSequence(
|
||||
{ json: async () => ({ access_token: 'tok', token_type: 'Bearer' }) },
|
||||
{
|
||||
@@ -327,11 +347,9 @@ describe('VerifaydaService (OIDC)', () => {
|
||||
},
|
||||
);
|
||||
|
||||
const url = await service.handleCallback({
|
||||
code: 'authcode',
|
||||
state: 'state-abc',
|
||||
});
|
||||
expect(url).toContain('reason=userinfo_failed');
|
||||
await expect(
|
||||
service.completeVerification({ code: 'authcode', state: 'state-abc' }),
|
||||
).rejects.toMatchObject({ status: 502 });
|
||||
});
|
||||
|
||||
it('falls back to localized name (name#en) when name is missing', async () => {
|
||||
@@ -354,10 +372,176 @@ describe('VerifaydaService (OIDC)', () => {
|
||||
},
|
||||
);
|
||||
|
||||
await service.handleCallback({ code: 'c', state: 'state-abc' });
|
||||
const result = await service.completeVerification({
|
||||
code: 'c',
|
||||
state: 'state-abc',
|
||||
});
|
||||
expect(result.fullName).toBe('English Name');
|
||||
expect(prisma.bookingSeat.updateMany.mock.calls[0][0].data.faydaVerifiedName).toBe(
|
||||
'English Name',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const seatCall = prisma.bookingSeat.updateMany.mock.calls[0][0];
|
||||
expect(seatCall.data.faydaVerifiedName).toBe('English Name');
|
||||
describe('completeVerification — LOGIN', () => {
|
||||
function loginSession(overrides: Partial<any> = {}) {
|
||||
return {
|
||||
id: 'login-session',
|
||||
state: 'state-login',
|
||||
codeVerifier: 'verifier-xyz',
|
||||
purpose: 'LOGIN',
|
||||
platform: 'WEB',
|
||||
saveToAccount: false,
|
||||
status: 'PENDING',
|
||||
userId: null,
|
||||
bookingId: null,
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function mockLoginFetch(userInfo: Record<string, unknown>) {
|
||||
const queue = [
|
||||
{
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ access_token: 'tok', token_type: 'Bearer' }),
|
||||
text: async () => '',
|
||||
headers: new Headers({ 'content-type': 'application/json' }),
|
||||
},
|
||||
{
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({}),
|
||||
text: async () => JSON.stringify(userInfo),
|
||||
headers: new Headers({ 'content-type': 'application/json' }),
|
||||
},
|
||||
];
|
||||
(global as any).fetch = jest.fn(() => Promise.resolve(queue.shift()));
|
||||
}
|
||||
|
||||
/** user.findUnique answers the faydaSub lookup and the issueLoginToken id lookup. */
|
||||
function mockUserFindUnique(bySub: any, fullUser: any) {
|
||||
prisma.user.findUnique.mockImplementation(async (args: any) => {
|
||||
if (args?.where?.faydaSub !== undefined) return bySub;
|
||||
if (args?.where?.id !== undefined) return fullUser;
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
prisma.faydaVerificationSession.findUnique.mockResolvedValue(loginSession());
|
||||
});
|
||||
|
||||
it('creates a new user when no match and returns { token, user }', async () => {
|
||||
const fullUser = {
|
||||
id: 'new-user',
|
||||
email: 'new@example.com',
|
||||
role: 'PASSENGER',
|
||||
passenger: { id: 'p-new' },
|
||||
agent: null,
|
||||
};
|
||||
mockUserFindUnique(null, fullUser);
|
||||
prisma.user.findFirst.mockResolvedValue(null);
|
||||
prisma.user.create.mockResolvedValue({ id: 'new-user' });
|
||||
prisma.passenger.create.mockResolvedValue({ id: 'p-new' });
|
||||
prisma.loyaltyAccount.create.mockResolvedValue({});
|
||||
prisma.walletAccount.create.mockResolvedValue({});
|
||||
prisma.userPreferences.create.mockResolvedValue({});
|
||||
prisma.faydaVerificationSession.update.mockResolvedValue({});
|
||||
|
||||
mockLoginFetch({ sub: 'login-sub-1', name: 'New Person', email: 'new@example.com' });
|
||||
|
||||
const result = await service.completeVerification({
|
||||
code: 'c',
|
||||
state: 'state-login',
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
purpose: 'LOGIN',
|
||||
verified: true,
|
||||
token: 'signed.jwt.token',
|
||||
user: { id: 'new-user', passengerId: 'p-new' },
|
||||
});
|
||||
expect(prisma.user.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
faydaSub: 'login-sub-1',
|
||||
faydaVerified: true,
|
||||
email: 'new@example.com',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(prisma.passenger.create).toHaveBeenCalled();
|
||||
expect(jwt.sign).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sub: 'new-user', passengerId: 'p-new' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('logs in an existing user already linked by faydaSub', async () => {
|
||||
const fullUser = {
|
||||
id: 'known-user',
|
||||
email: 'k@example.com',
|
||||
role: 'PASSENGER',
|
||||
passenger: { id: 'p-k' },
|
||||
agent: null,
|
||||
};
|
||||
mockUserFindUnique({ id: 'known-user' }, fullUser);
|
||||
prisma.faydaVerificationSession.update.mockResolvedValue({});
|
||||
|
||||
mockLoginFetch({ sub: 'login-sub-2', name: 'Known' });
|
||||
|
||||
const result = await service.completeVerification({
|
||||
code: 'c',
|
||||
state: 'state-login',
|
||||
});
|
||||
|
||||
expect(result.user?.id).toBe('known-user');
|
||||
expect(prisma.user.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('links Fayda to an existing account matched by email', async () => {
|
||||
const fullUser = {
|
||||
id: 'acc-1',
|
||||
email: 'match@example.com',
|
||||
role: 'PASSENGER',
|
||||
passenger: { id: 'p-1' },
|
||||
agent: null,
|
||||
};
|
||||
mockUserFindUnique(null, fullUser);
|
||||
prisma.user.findFirst.mockResolvedValue({ id: 'acc-1', faydaSub: null });
|
||||
prisma.user.update.mockResolvedValue({});
|
||||
prisma.faydaVerificationSession.update.mockResolvedValue({});
|
||||
|
||||
mockLoginFetch({ sub: 'login-sub-3', email: 'match@example.com' });
|
||||
|
||||
const result = await service.completeVerification({
|
||||
code: 'c',
|
||||
state: 'state-login',
|
||||
});
|
||||
|
||||
expect(result.user?.id).toBe('acc-1');
|
||||
expect(prisma.user.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: 'acc-1' },
|
||||
data: expect.objectContaining({ faydaSub: 'login-sub-3' }),
|
||||
}),
|
||||
);
|
||||
expect(prisma.user.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws identity_conflict (409) when matched account has a different faydaSub', async () => {
|
||||
mockUserFindUnique(null, null);
|
||||
prisma.user.findFirst.mockResolvedValue({ id: 'acc-2', faydaSub: 'someone-else' });
|
||||
prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 });
|
||||
|
||||
mockLoginFetch({ sub: 'login-sub-4', email: 'match@example.com' });
|
||||
|
||||
await expect(
|
||||
service.completeVerification({ code: 'c', state: 'state-login' }),
|
||||
).rejects.toMatchObject({ status: 409 });
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
expect(prisma.user.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -3,11 +3,15 @@ import {
|
||||
Injectable,
|
||||
Logger,
|
||||
ServiceUnavailableException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { FaydaConfig } from '../../config/fayda.config';
|
||||
import { FaydaConfig, FaydaPlatform } from '../../config/fayda.config';
|
||||
import {
|
||||
generateCodeChallenge,
|
||||
generateCodeVerifier,
|
||||
@@ -43,11 +47,32 @@ export interface VerifaydaVerificationResult {
|
||||
|
||||
export interface StartVerificationInput {
|
||||
purpose: VerifaydaPurpose;
|
||||
platform?: FaydaPlatform;
|
||||
userId?: string;
|
||||
bookingId?: string;
|
||||
saveToAccount?: boolean;
|
||||
}
|
||||
|
||||
export interface FaydaUserSummary {
|
||||
id: string;
|
||||
email: string;
|
||||
role: string;
|
||||
passengerId?: string;
|
||||
agentId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of completing a verification. `verified` is always true on success.
|
||||
* LOGIN additionally returns a JWT + user; PURCHASE returns the verified name.
|
||||
*/
|
||||
export interface CompleteVerificationResult {
|
||||
purpose: VerifaydaPurpose;
|
||||
verified: boolean;
|
||||
token?: string;
|
||||
user?: FaydaUserSummary;
|
||||
fullName?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class VerifaydaService {
|
||||
private readonly logger = new Logger(VerifaydaService.name);
|
||||
@@ -63,6 +88,7 @@ export class VerifaydaService {
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwt: JwtService,
|
||||
) {
|
||||
const fayda = this.config.get<FaydaConfig>('fayda');
|
||||
if (!fayda) {
|
||||
@@ -107,6 +133,7 @@ export class VerifaydaService {
|
||||
state,
|
||||
codeVerifier,
|
||||
purpose: input.purpose,
|
||||
platform: input.platform ?? 'WEB',
|
||||
saveToAccount: input.saveToAccount ?? false,
|
||||
userId: input.userId ?? null,
|
||||
bookingId: input.bookingId ?? null,
|
||||
@@ -115,13 +142,16 @@ export class VerifaydaService {
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Fayda verification started: purpose=${input.purpose} userId=${input.userId ?? 'none'} bookingId=${input.bookingId ?? 'none'}`,
|
||||
`Fayda verification started: purpose=${input.purpose} platform=${input.platform ?? 'WEB'} userId=${input.userId ?? 'none'} bookingId=${input.bookingId ?? 'none'}`,
|
||||
);
|
||||
|
||||
return this.buildAuthorizationUrl({ state, codeChallenge });
|
||||
}
|
||||
|
||||
async handleCallback(query: VerifaydaCallbackDto): Promise<string> {
|
||||
|
||||
async completeVerification(
|
||||
query: VerifaydaCallbackDto,
|
||||
): Promise<CompleteVerificationResult> {
|
||||
if (query.error) {
|
||||
this.logger.warn(`Fayda callback returned error: ${query.error}`);
|
||||
if (query.state) {
|
||||
@@ -131,32 +161,36 @@ export class VerifaydaService {
|
||||
query.error_description,
|
||||
);
|
||||
}
|
||||
return this.buildFailureUrl(query.error);
|
||||
throw new BadRequestException({
|
||||
code: 'FAYDA_AUTH_ERROR',
|
||||
message: query.error,
|
||||
description: query.error_description,
|
||||
});
|
||||
}
|
||||
|
||||
if (!query.code || !query.state) {
|
||||
this.logger.warn('Fayda callback missing code or state');
|
||||
return this.buildFailureUrl('missing_parameters');
|
||||
throw new BadRequestException({
|
||||
code: 'FAYDA_MISSING_PARAMETERS',
|
||||
message: 'code and state are required',
|
||||
});
|
||||
}
|
||||
|
||||
const session = await this.prisma.faydaVerificationSession.findUnique({
|
||||
where: { state: query.state },
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
this.logger.warn('Fayda callback with unknown state');
|
||||
return this.buildFailureUrl('invalid_state');
|
||||
}
|
||||
if (session.status !== 'PENDING') {
|
||||
this.logger.warn(
|
||||
`Fayda callback for non-pending session (status=${session.status})`,
|
||||
);
|
||||
return this.buildFailureUrl('invalid_state');
|
||||
if (!session || session.status !== 'PENDING') {
|
||||
this.logger.warn('Fayda complete with unknown or non-pending state');
|
||||
throw new BadRequestException({
|
||||
code: 'FAYDA_INVALID_STATE',
|
||||
message: 'Verification session is invalid or already used',
|
||||
});
|
||||
}
|
||||
if (session.expiresAt.getTime() < Date.now()) {
|
||||
await this.markSessionFailed(query.state, 'session_expired');
|
||||
this.logger.warn('Fayda callback for expired session');
|
||||
return this.buildFailureUrl('session_expired');
|
||||
throw new BadRequestException({
|
||||
code: 'FAYDA_SESSION_EXPIRED',
|
||||
message: 'Verification session has expired; start again',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -171,25 +205,29 @@ export class VerifaydaService {
|
||||
throw new FaydaUserInfoException('Fayda userinfo missing required sub');
|
||||
}
|
||||
|
||||
let result: CompleteVerificationResult;
|
||||
if (session.purpose === 'PURCHASE') {
|
||||
await this.handlePurchaseSuccess(session, normalized);
|
||||
result = {
|
||||
purpose: 'PURCHASE',
|
||||
verified: true,
|
||||
fullName: normalized.fullName,
|
||||
};
|
||||
} else {
|
||||
await this.handleLoginSuccess(session, normalized);
|
||||
const { userId } = await this.handleLoginSuccess(normalized);
|
||||
const login = await this.issueLoginToken(userId);
|
||||
result = { purpose: 'LOGIN', verified: true, ...login };
|
||||
}
|
||||
|
||||
await this.prisma.faydaVerificationSession.update({
|
||||
where: { id: session.id },
|
||||
data: {
|
||||
status: 'COMPLETED',
|
||||
completedAt: new Date(),
|
||||
codeVerifier: '',
|
||||
},
|
||||
data: { status: 'COMPLETED', completedAt: new Date(), codeVerifier: '' },
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Fayda verification completed: purpose=${session.purpose}`,
|
||||
`Fayda verification completed: purpose=${session.purpose} platform=${session.platform}`,
|
||||
);
|
||||
return this.buildSuccessUrl();
|
||||
return result;
|
||||
} catch (err) {
|
||||
const reason = this.classifyFailureReason(err);
|
||||
this.logger.error(
|
||||
@@ -200,10 +238,45 @@ export class VerifaydaService {
|
||||
reason,
|
||||
(err as Error).message,
|
||||
);
|
||||
return this.buildFailureUrl(reason);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Loads a user (+ relations) and mints the same JWT shape as `/auth/login`. */
|
||||
private async issueLoginToken(
|
||||
userId: string,
|
||||
): Promise<{ token: string; user: FaydaUserSummary }> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
include: { passenger: true, agent: true },
|
||||
});
|
||||
if (!user) {
|
||||
// Should not happen — we just resolved/created this user.
|
||||
throw new UnauthorizedException({
|
||||
code: 'FAYDA_LOGIN_FAILED',
|
||||
message: 'Could not load the verified user',
|
||||
});
|
||||
}
|
||||
|
||||
const summary: FaydaUserSummary = {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
passengerId: user.passenger?.id,
|
||||
agentId: user.agent?.id,
|
||||
};
|
||||
const token = this.jwt.sign({
|
||||
sub: summary.id,
|
||||
email: summary.email,
|
||||
role: summary.role,
|
||||
passengerId: summary.passengerId,
|
||||
agentId: summary.agentId,
|
||||
});
|
||||
|
||||
this.logger.log(`Fayda login issued token for user ${user.id}`);
|
||||
return { token, user: summary };
|
||||
}
|
||||
|
||||
async getVerificationStatus(userId: string): Promise<VerificationStatusDto> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
@@ -383,17 +456,106 @@ export class VerifaydaService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the User for a LOGIN flow and returns its id (the caller mints the
|
||||
* JWT via {@link issueLoginToken}). Resolution order:
|
||||
* 1. Existing user already linked to this Fayda `sub`.
|
||||
* 2. Existing account whose email/phone matches — linked to this `sub`.
|
||||
* 3. Otherwise a fresh Fayda-backed account is created.
|
||||
*/
|
||||
private async handleLoginSuccess(
|
||||
_session: { id: string },
|
||||
_normalized: NormalizedFaydaUserInfo,
|
||||
): Promise<void> {
|
||||
// LOGIN flow (User creation / login token issuance)
|
||||
// The schema currently requires email/phone/passwordHash on User as NOT NULL,
|
||||
// and the auth controllers haven't been wired to consume Fayda identities yet.
|
||||
throw new BadRequestException({
|
||||
code: 'FAYDA_LOGIN_NOT_IMPLEMENTED',
|
||||
message: 'Login-with-Fayda is not yet implemented',
|
||||
normalized: NormalizedFaydaUserInfo,
|
||||
): Promise<{ userId: string }> {
|
||||
let userId: string;
|
||||
|
||||
const bySub = await this.prisma.user.findUnique({
|
||||
where: { faydaSub: normalized.sub },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (bySub) {
|
||||
userId = bySub.id;
|
||||
} else {
|
||||
const matchers: Array<{ email?: string; phone?: string }> = [];
|
||||
if (normalized.email) matchers.push({ email: normalized.email });
|
||||
if (normalized.phoneNumber) matchers.push({ phone: normalized.phoneNumber });
|
||||
|
||||
const existing = matchers.length
|
||||
? await this.prisma.user.findFirst({
|
||||
where: { OR: matchers },
|
||||
select: { id: true, faydaSub: true },
|
||||
})
|
||||
: null;
|
||||
|
||||
if (existing) {
|
||||
if (existing.faydaSub && existing.faydaSub !== normalized.sub) {
|
||||
// The matched account is already tied to a different Fayda identity.
|
||||
throw new FaydaIdentityConflictException();
|
||||
}
|
||||
await this.prisma.user.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
faydaSub: normalized.sub,
|
||||
faydaVerified: true,
|
||||
faydaVerifiedAt: new Date(),
|
||||
},
|
||||
});
|
||||
userId = existing.id;
|
||||
this.logger.log(`Fayda login linked existing user ${existing.id}`);
|
||||
} else {
|
||||
userId = await this.createFaydaUser(normalized);
|
||||
this.logger.log(`Fayda login created new user ${userId}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { userId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Fayda-backed User plus the same satellite rows registration makes
|
||||
* (Passenger, LoyaltyAccount, WalletAccount, UserPreferences).
|
||||
*
|
||||
* The user has no password — `passwordHash` is set to a bcrypt of random bytes
|
||||
* so password login is impossible; they authenticate only via Fayda. When
|
||||
* Fayda doesn't supply an email/phone, a deterministic placeholder derived from
|
||||
* the (unique) `sub` keeps the NOT NULL + unique columns satisfied.
|
||||
*/
|
||||
private async createFaydaUser(
|
||||
normalized: NormalizedFaydaUserInfo,
|
||||
): Promise<string> {
|
||||
const passwordHash = await bcrypt.hash(
|
||||
randomBytes(32).toString('hex'),
|
||||
10,
|
||||
);
|
||||
const email = normalized.email ?? `fayda_${normalized.sub}@users.fayda.local`;
|
||||
const phone = normalized.phoneNumber ?? `fayda:${normalized.sub}`;
|
||||
const fullName = normalized.fullName ?? 'Fayda User';
|
||||
|
||||
const user = await this.prisma.user.create({
|
||||
data: {
|
||||
fullName,
|
||||
email,
|
||||
phone,
|
||||
passwordHash,
|
||||
faydaVerified: true,
|
||||
faydaVerifiedAt: new Date(),
|
||||
faydaSub: normalized.sub,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
const passenger = await this.prisma.passenger.create({
|
||||
data: { userId: user.id },
|
||||
select: { id: true },
|
||||
});
|
||||
await this.prisma.loyaltyAccount.create({
|
||||
data: { passengerId: passenger.id },
|
||||
});
|
||||
await this.prisma.walletAccount.create({
|
||||
data: { passengerId: passenger.id },
|
||||
});
|
||||
await this.prisma.userPreferences.create({ data: { userId: user.id } });
|
||||
|
||||
return user.id;
|
||||
}
|
||||
|
||||
private async markSessionFailed(
|
||||
@@ -413,16 +575,6 @@ export class VerifaydaService {
|
||||
});
|
||||
}
|
||||
|
||||
private buildSuccessUrl(): string {
|
||||
return this.faydaConfig.successRedirectUrl;
|
||||
}
|
||||
|
||||
private buildFailureUrl(reason: string): string {
|
||||
const url = new URL(this.faydaConfig.failureRedirectUrl);
|
||||
url.searchParams.set('reason', reason);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
private classifyFailureReason(err: unknown): string {
|
||||
if (err instanceof FaydaIdentityConflictException) return 'identity_conflict';
|
||||
if (err instanceof FaydaTokenExchangeException) return 'token_exchange_failed';
|
||||
|
||||
Reference in New Issue
Block a user