@pafi-dev/issuer 0.38.1 → 0.39.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,421 @@
1
+ import { Type, DynamicModule, ForwardReference, OnModuleInit } from '@nestjs/common';
2
+ import { JWK, JWTPayload } from 'jose';
3
+ import { P as PafiAuthClient } from '../pafi-auth-client-DzHd_Ts_.js';
4
+
5
+ /**
6
+ * Issuer-side abstraction for "make sure the local user row exists,
7
+ * keyed by the canonical_pafi_user_id the gateway just resolved."
8
+ *
9
+ * The SDK can't own this because each issuer has a different
10
+ * `users` table schema (gg56 has `wallet_address`, lotteria doesn't,
11
+ * future issuers might add `phone_number` / `kyc_status` / etc.).
12
+ * Each consuming issuer backend implements `IUserStore` once and
13
+ * passes the class to `PafiDirectAuthModule.forRoot({ userStore })`.
14
+ *
15
+ * Idempotency contract: callers invoke this on every successful
16
+ * direct-auth flow (email OTP / Google / Kakao). Implementation MUST
17
+ * be idempotent — re-calling with the same args is a no-op, NOT an
18
+ * error.
19
+ *
20
+ * Lookup precedence (recommended): canonical_id first, fall back to
21
+ * email. Same canonical across issuers = cross-issuer wallet merge.
22
+ *
23
+ * Race-safety: parallel requests for the same brand-new canonical
24
+ * should be tolerated. The SDK assumes the underlying DB enforces a
25
+ * unique constraint on canonical_id; on conflict, treat as "row
26
+ * already exists" rather than re-throwing.
27
+ *
28
+ * @example
29
+ * ```ts
30
+ * @Injectable()
31
+ * class Gg56UserStore implements IUserStore {
32
+ * constructor(
33
+ * @InjectRepository(UserEntity)
34
+ * private readonly users: Repository<UserEntity>,
35
+ * ) {}
36
+ *
37
+ * async upsertByCanonicalAndEmail(args: {
38
+ * canonicalId: string;
39
+ * verifiedEmail?: string;
40
+ * }): Promise<void> {
41
+ * const byCanonical = await this.users.findOne({
42
+ * where: { canonicalId: args.canonicalId },
43
+ * });
44
+ * if (byCanonical) return;
45
+ * // ... fall back to email, create row, race-handle, etc.
46
+ * }
47
+ * }
48
+ * ```
49
+ */
50
+ interface IUserStore {
51
+ upsertByCanonicalAndEmail(args: {
52
+ /** canonical_pafi_user_id the gateway just minted. Always present. */
53
+ canonicalId: string;
54
+ /**
55
+ * Verified email when the auth method exposed one (email OTP +
56
+ * Google always; Kakao only if user shared email at consent).
57
+ * Issuer may use to display profile, NOT for primary user lookup
58
+ * (canonicalId is the stable id).
59
+ */
60
+ verifiedEmail?: string;
61
+ }): Promise<void>;
62
+ }
63
+ /** DI token for IUserStore. Use with `@Inject(USER_STORE)`. */
64
+ declare const USER_STORE: unique symbol;
65
+
66
+ /**
67
+ * Issuer-side abstraction for "mint an issuer-native session token
68
+ * the FE will Bearer-auth with for all subsequent /api calls."
69
+ *
70
+ * The SDK can't own this because:
71
+ * - Each issuer signs with their own `JWT_SECRET` (HS256) OR with
72
+ * their own RSA/ECDSA key — SDK shouldn't see secrets
73
+ * - Token shape may differ (sub format, scope claims, extra claims
74
+ * like role/tier specific to the issuer's app)
75
+ * - Lifetime may differ (24h default, but some issuers want shorter)
76
+ * - Existing JwtGuard / SessionTokenGuard in the issuer backend
77
+ * expects a specific shape — SDK can't predict it
78
+ *
79
+ * The SDK orchestrator calls `mint()` once per successful direct-auth
80
+ * flow, BEFORE returning the bundled response to the issuer's HTTP
81
+ * controller. The minter is responsible for:
82
+ * 1. Producing a token the issuer's existing guards accept
83
+ * 2. Embedding `authAttribute: {type, value}` if the issuer's
84
+ * /wallet/exchange-pafi-jwt refresh path needs it (most do — see
85
+ * ISSUER_INTEGRATION_GUIDE.md §9 refresh path)
86
+ * 3. Computing absolute expiry timestamp the FE can show
87
+ *
88
+ * @example
89
+ * ```ts
90
+ * @Injectable()
91
+ * class Gg56SessionMinter implements ISessionTokenMinter {
92
+ * private readonly secret: Uint8Array;
93
+ * constructor(config: ConfigService) {
94
+ * this.secret = new TextEncoder().encode(
95
+ * config.getOrThrow('JWT_SECRET'),
96
+ * );
97
+ * }
98
+ *
99
+ * async mint(args: {
100
+ * canonicalId: string;
101
+ * verifiedEmail?: string;
102
+ * }): Promise<{ token: string; expiresAt: string }> {
103
+ * const now = Math.floor(Date.now() / 1000);
104
+ * const exp = now + 86400;
105
+ * const token = await new SignJWT({
106
+ * userKey: args.canonicalId,
107
+ * loginType: 'pafi-direct',
108
+ * scope: 'pafi-session',
109
+ * ...(args.verifiedEmail
110
+ * ? { authAttribute: { type: 'email', value: args.verifiedEmail } }
111
+ * : {}),
112
+ * })
113
+ * .setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
114
+ * .setSubject(args.canonicalId)
115
+ * .setIssuedAt(now)
116
+ * .setExpirationTime(exp)
117
+ * .sign(this.secret);
118
+ * return { token, expiresAt: new Date(exp * 1000).toISOString() };
119
+ * }
120
+ * }
121
+ * ```
122
+ */
123
+ interface ISessionTokenMinter {
124
+ mint(args: {
125
+ /** canonical_pafi_user_id, use as `sub` so downstream identifies user uniformly. */
126
+ canonicalId: string;
127
+ /**
128
+ * Verified email when available. Implementations SHOULD embed this
129
+ * as `authAttribute: { type: 'email', value }` in the JWT body so
130
+ * the legacy /wallet/exchange-pafi-jwt refresh path keeps deriving
131
+ * the same canonical_id from the same attribute across refreshes.
132
+ */
133
+ verifiedEmail?: string;
134
+ }): Promise<{
135
+ /** The signed session token (any JWT shape — issuer guards must accept). */
136
+ token: string;
137
+ /** ISO 8601 absolute expiry timestamp. Returned to FE so it can show "expires at X". */
138
+ expiresAt: string;
139
+ }>;
140
+ }
141
+ /** DI token for ISessionTokenMinter. Use with `@Inject(SESSION_TOKEN_MINTER)`. */
142
+ declare const SESSION_TOKEN_MINTER: unique symbol;
143
+
144
+ /**
145
+ * Options consumed by `PafiDirectAuthModule.forRoot(options)`. The
146
+ * host (issuer backend) supplies:
147
+ *
148
+ * - Gateway credentials (URL, issuer_id, client_id, private JWK)
149
+ * - Issuer-specific implementations of IUserStore + ISessionTokenMinter
150
+ *
151
+ * Typically read from `ConfigService` in the issuer backend. Use
152
+ * `forRootAsync()` (planned) when config needs `inject: [ConfigService]`.
153
+ */
154
+ interface PafiDirectAuthModuleOptions {
155
+ /** Base URL of the PAFI gateway (e.g. `https://id-dev.pacificfinance.org`). */
156
+ gatewayUrl: string;
157
+ /** Issuer identifier registered with the gateway (e.g. `gg56`). */
158
+ issuerId: string;
159
+ /**
160
+ * Gateway client_id assigned at issuer onboarding. Also acts as the
161
+ * `iss`/`sub` of the client_assertion JWT (RFC 7523 §3).
162
+ */
163
+ clientId: string;
164
+ /**
165
+ * Private JWK the issuer uses to sign client_assertion. MUST include
166
+ * `kid` — gateway looks up the matching public JWK by kid.
167
+ */
168
+ clientPrivateJwk: JWK & {
169
+ kid: string;
170
+ };
171
+ /**
172
+ * Issuer-implemented user-row upsert. SDK calls this on every
173
+ * successful direct-auth flow to ensure the local users row exists
174
+ * keyed by canonical_id. See `IUserStore` for contract details.
175
+ */
176
+ userStore: Type<IUserStore>;
177
+ /**
178
+ * Issuer-implemented session-token minter. SDK calls this once per
179
+ * successful direct-auth flow to wrap the gateway-returned canonical
180
+ * + verified email into the issuer's own session JWT. See
181
+ * `ISessionTokenMinter` for contract details.
182
+ */
183
+ sessionTokenMinter: Type<ISessionTokenMinter>;
184
+ }
185
+ /** DI token for the resolved options. Use with `@Inject(PAFI_DIRECT_AUTH_MODULE_OPTIONS)`. */
186
+ declare const PAFI_DIRECT_AUTH_MODULE_OPTIONS: unique symbol;
187
+
188
+ /**
189
+ * Extra `imports` an issuer can pass into {@link PafiDirectAuthModule.forRoot}
190
+ * so dependencies of the adapter classes (e.g.
191
+ * `TypeOrmModule.forFeature([UserEntity])` for a TypeORM-backed
192
+ * `IUserStore`) are visible from inside the dynamic module's DI scope.
193
+ */
194
+ type PafiDirectAuthExtraImports = Array<Type<unknown> | DynamicModule | Promise<DynamicModule> | ForwardReference>;
195
+ /**
196
+ * NestJS module wiring the direct-auth surface (`/auth/v2/*`
197
+ * controller, orchestrator service, gateway client, session
198
+ * verifier).
199
+ *
200
+ * Issuer backends mount this once at the root module with their
201
+ * gateway credentials + their `IUserStore` / `ISessionTokenMinter`
202
+ * implementations. Critically, the adapter classes are instantiated
203
+ * **inside this dynamic module** (not the host) so the SDK's
204
+ * orchestrator service can resolve them — meaning any module the
205
+ * adapter classes depend on (e.g. TypeORM features for repositories)
206
+ * MUST be passed via `imports`:
207
+ *
208
+ * ```ts
209
+ * @Module({
210
+ * imports: [
211
+ * ConfigModule.forRoot({ isGlobal: true }),
212
+ * PafiDirectAuthModule.forRoot({
213
+ * imports: [TypeOrmModule.forFeature([UserEntity])],
214
+ * gatewayUrl: process.env.PAFI_GATEWAY_URL!,
215
+ * issuerId: process.env.PAFI_GATEWAY_ISSUER_ID!,
216
+ * clientId: process.env.PAFI_GATEWAY_CLIENT_ID!,
217
+ * clientPrivateJwk: JSON.parse(
218
+ * process.env.PAFI_GATEWAY_CLIENT_PRIVATE_JWK_JSON!,
219
+ * ) as JWK & { kid: string },
220
+ * userStore: Gg56UserStore,
221
+ * sessionTokenMinter: Gg56SessionMinter,
222
+ * }),
223
+ * ],
224
+ * })
225
+ * export class AuthModule {}
226
+ * ```
227
+ *
228
+ * The adapter classes do NOT need to be listed in the host module's
229
+ * `providers:` array — `PafiDirectAuthModule` registers them via
230
+ * `useClass` so they live in this dynamic module's DI scope. Anything
231
+ * they `@Inject` (`Repository<UserEntity>`, `ConfigService`,
232
+ * `@Inject(REQUEST)`, etc.) must reach that scope via:
233
+ * - `options.imports` for module-scoped providers (e.g. TypeORM features)
234
+ * - The host's global module set for global providers (e.g.
235
+ * `ConfigModule.forRoot({ isGlobal: true })` makes ConfigService
236
+ * globally visible — no `imports:` entry needed)
237
+ *
238
+ * For ConfigService-based async setup, use `forRootAsync()` (TODO).
239
+ */
240
+ declare class PafiDirectAuthModule {
241
+ static forRoot(options: PafiDirectAuthModuleOptions & {
242
+ imports?: PafiDirectAuthExtraImports;
243
+ }): DynamicModule;
244
+ }
245
+
246
+ /**
247
+ * Thin DI wrapper around {@link PafiAuthClient}. Constructs a single
248
+ * client at boot using the issuer's gateway credentials and exposes it
249
+ * to services that need to call the direct-auth endpoints.
250
+ *
251
+ * One instance per process — the SDK client is stateless and the
252
+ * client_assertion is signed on every call (no warm-up needed).
253
+ *
254
+ * Config comes from `PafiDirectAuthModule.forRoot(options)` (see
255
+ * `pafi-direct-auth.module.ts`). The host app supplies `gatewayUrl`,
256
+ * `issuerId`, `clientId`, and `clientPrivateJwk` either as literal
257
+ * values OR as a factory that resolves them from NestJS ConfigService
258
+ * (`forRootAsync` variant).
259
+ */
260
+ declare class PafiAuthClientProvider implements OnModuleInit {
261
+ private readonly options;
262
+ private _client;
263
+ constructor(options: PafiDirectAuthModuleOptions);
264
+ onModuleInit(): void;
265
+ get client(): PafiAuthClient;
266
+ }
267
+
268
+ interface PafiSessionClaims {
269
+ sub: string;
270
+ scope: "pafi-session";
271
+ /** Verified attribute the gateway used to derive canonical_id. */
272
+ verifiedAttribute?: {
273
+ type: string;
274
+ valueHash?: string;
275
+ };
276
+ /** Issuer that originated the auth (must equal our PAFI_GATEWAY_ISSUER_ID). */
277
+ issuerId?: string;
278
+ exp: number;
279
+ iat: number;
280
+ raw: JWTPayload;
281
+ }
282
+ /**
283
+ * Verifies the long-lived `pafi_session_token` returned by the
284
+ * gateway's direct-auth endpoints. Defense-in-depth: the gateway just
285
+ * minted this token, so the chance of forgery is near zero — BUT
286
+ * verifying it locally guarantees we never trust an attacker-supplied
287
+ * "fake gateway response" that bypassed the network call (e.g. a
288
+ * compromised inbound proxy injecting an arbitrary JSON body). One
289
+ * audit line for a millisecond of crypto work.
290
+ *
291
+ * Uses the gateway's published JWKS (`/.well-known/jwks.json`) — same
292
+ * key set Privy uses when verifying the short-lived `pafi_jwt`. The
293
+ * remote JWKS is cached + auto-refreshed by jose.
294
+ */
295
+ declare class PafiSessionVerifierService {
296
+ private readonly jwks;
297
+ private readonly expectedIssuer;
298
+ constructor(options: PafiDirectAuthModuleOptions);
299
+ verify(token: string): Promise<PafiSessionClaims>;
300
+ }
301
+
302
+ interface DirectAuthResult {
303
+ sessionToken: string;
304
+ sessionExpiresAt: string;
305
+ pafiJwt: string;
306
+ pafiSessionToken: string;
307
+ canonicalId: string;
308
+ isFirstLogin: boolean;
309
+ verifiedEmail?: string;
310
+ }
311
+ /**
312
+ * Orchestrates the gateway-owned auth flows for an issuer backend. The
313
+ * gateway is the sole verifier (it ran the OTP check / verified the
314
+ * Google id_token / exchanged the Kakao code); this service is a thin
315
+ * adapter that:
316
+ *
317
+ * 1. Forwards the call via {@link PafiAuthClient} (RFC 7523
318
+ * client_assertion signed by the issuer's private JWK)
319
+ * 2. Verifies the returned `pafi_session_token` against the gateway's
320
+ * JWKS (defense in depth — see {@link PafiSessionVerifierService})
321
+ * 3. Upserts the local user row via {@link IUserStore} (issuer
322
+ * implements; SDK doesn't know the DB schema)
323
+ * 4. Mints an issuer-native session token via
324
+ * {@link ISessionTokenMinter} (issuer implements; SDK doesn't see
325
+ * the issuer's JWT_SECRET)
326
+ *
327
+ * The returned `sessionToken.sub` is the canonical_id — the same value
328
+ * the gateway uses for the PAFI JWT's `sub`. This means downstream
329
+ * services that need a stable user id can use it without caring which
330
+ * auth method produced it.
331
+ *
332
+ * Extracted from per-issuer boilerplate code in 2026-06-30 SDK refactor.
333
+ * Previously copy-pasted (~260 lines per issuer); now ~60 lines per
334
+ * issuer (UserStore + SessionMinter + module wiring).
335
+ */
336
+ declare class PafiDirectAuthService {
337
+ private readonly clientProvider;
338
+ private readonly sessionVerifier;
339
+ private readonly userStore;
340
+ private readonly sessionTokenMinter;
341
+ private readonly logger;
342
+ constructor(clientProvider: PafiAuthClientProvider, sessionVerifier: PafiSessionVerifierService, userStore: IUserStore, sessionTokenMinter: ISessionTokenMinter);
343
+ startEmail(args: {
344
+ email: string;
345
+ correlationId?: string;
346
+ }): Promise<{
347
+ challengeId: string;
348
+ expiresInSec: number;
349
+ }>;
350
+ verifyEmail(args: {
351
+ challengeId: string;
352
+ otpCode: string;
353
+ correlationId?: string;
354
+ }): Promise<DirectAuthResult>;
355
+ exchangeGoogle(args: {
356
+ idToken: string;
357
+ correlationId?: string;
358
+ }): Promise<DirectAuthResult>;
359
+ exchangeKakao(args: {
360
+ code: string;
361
+ redirectUri?: string;
362
+ correlationId?: string;
363
+ }): Promise<DirectAuthResult>;
364
+ private finalize;
365
+ }
366
+
367
+ declare class EmailStartRequestDto {
368
+ email: string;
369
+ }
370
+ declare class EmailVerifyRequestDto {
371
+ challengeId: string;
372
+ otpCode: string;
373
+ }
374
+ declare class GoogleExchangeRequestDto {
375
+ idToken: string;
376
+ }
377
+ declare class KakaoExchangeRequestDto {
378
+ code: string;
379
+ redirectUri?: string;
380
+ }
381
+ declare class EmailStartResponseDto {
382
+ challengeId: string;
383
+ expiresInSec: number;
384
+ }
385
+ declare class PafiAuthSuccessDto {
386
+ sessionToken: string;
387
+ sessionExpiresAt: string;
388
+ pafiJwt: string;
389
+ pafiSessionToken: string;
390
+ canonicalId: string;
391
+ isFirstLogin: boolean;
392
+ verifiedEmail?: string;
393
+ }
394
+
395
+ /**
396
+ * `/auth/v2/*` — gateway-owned auth endpoints (2026-06-30 direct-auth
397
+ * surface, shipped by `@pafi-dev/issuer/direct-auth`).
398
+ *
399
+ * Issuer backend no longer verifies OTP codes, OAuth id_tokens, or
400
+ * holds OAuth client_secrets. All of that lives at the PAFI gateway.
401
+ * These endpoints are thin proxies that forward user input via
402
+ * {@link PafiDirectAuthService}, then upsert the local user row (via
403
+ * the issuer-provided {@link IUserStore}) + mint an issuer-native
404
+ * session token (via the issuer-provided {@link ISessionTokenMinter})
405
+ * wrapping the gateway-assigned canonical_id.
406
+ *
407
+ * Mounted automatically by `PafiDirectAuthModule.forRoot(options)`.
408
+ * Issuer apps that want to layer extra auth-error handling (e.g. a
409
+ * custom `AuthErrorFilter`) can wrap the controller's routes via a
410
+ * NestJS global filter — no per-route `@UseFilters` needed here.
411
+ */
412
+ declare class PafiDirectAuthController {
413
+ private readonly directAuth;
414
+ constructor(directAuth: PafiDirectAuthService);
415
+ startEmail(body: EmailStartRequestDto): Promise<EmailStartResponseDto>;
416
+ verifyEmail(body: EmailVerifyRequestDto): Promise<PafiAuthSuccessDto>;
417
+ exchangeGoogle(body: GoogleExchangeRequestDto): Promise<PafiAuthSuccessDto>;
418
+ exchangeKakao(body: KakaoExchangeRequestDto): Promise<PafiAuthSuccessDto>;
419
+ }
420
+
421
+ export { type DirectAuthResult, EmailStartRequestDto, EmailStartResponseDto, EmailVerifyRequestDto, GoogleExchangeRequestDto, type ISessionTokenMinter, type IUserStore, KakaoExchangeRequestDto, PAFI_DIRECT_AUTH_MODULE_OPTIONS, PafiAuthClientProvider, PafiAuthSuccessDto, PafiDirectAuthController, PafiDirectAuthModule, type PafiDirectAuthModuleOptions, PafiDirectAuthService, type PafiSessionClaims, PafiSessionVerifierService, SESSION_TOKEN_MINTER, USER_STORE };