@pafi-dev/issuer 0.38.1 → 0.39.0

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,405 @@
1
+ import { Type, DynamicModule, 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
+ * NestJS module wiring the direct-auth surface (`/auth/v2/*`
190
+ * controller, orchestrator service, gateway client, session
191
+ * verifier).
192
+ *
193
+ * Issuer backends mount this once at the root module with their
194
+ * gateway credentials + their `IUserStore` and `ISessionTokenMinter`
195
+ * implementations:
196
+ *
197
+ * ```ts
198
+ * @Module({
199
+ * imports: [
200
+ * ConfigModule.forRoot(),
201
+ * TypeOrmModule.forFeature([UserEntity]),
202
+ * PafiDirectAuthModule.forRoot({
203
+ * gatewayUrl: process.env.PAFI_GATEWAY_URL!,
204
+ * issuerId: process.env.PAFI_GATEWAY_ISSUER_ID!,
205
+ * clientId: process.env.PAFI_GATEWAY_CLIENT_ID!,
206
+ * clientPrivateJwk: JSON.parse(
207
+ * process.env.PAFI_GATEWAY_CLIENT_PRIVATE_JWK_JSON!,
208
+ * ) as JWK & { kid: string },
209
+ * userStore: Gg56UserStore,
210
+ * sessionTokenMinter: Gg56SessionMinter,
211
+ * }),
212
+ * ],
213
+ * providers: [Gg56UserStore, Gg56SessionMinter],
214
+ * })
215
+ * export class AuthModule {}
216
+ * ```
217
+ *
218
+ * The `IUserStore` + `ISessionTokenMinter` classes MUST be provided
219
+ * separately in the host module's `providers:` array (NestJS needs to
220
+ * see them to instantiate via DI). This module wires them to the
221
+ * orchestrator via the `USER_STORE` + `SESSION_TOKEN_MINTER` injection
222
+ * tokens.
223
+ *
224
+ * For ConfigService-based async setup, use `forRootAsync()` (TODO).
225
+ */
226
+ declare class PafiDirectAuthModule {
227
+ static forRoot(options: PafiDirectAuthModuleOptions): DynamicModule;
228
+ }
229
+
230
+ /**
231
+ * Thin DI wrapper around {@link PafiAuthClient}. Constructs a single
232
+ * client at boot using the issuer's gateway credentials and exposes it
233
+ * to services that need to call the direct-auth endpoints.
234
+ *
235
+ * One instance per process — the SDK client is stateless and the
236
+ * client_assertion is signed on every call (no warm-up needed).
237
+ *
238
+ * Config comes from `PafiDirectAuthModule.forRoot(options)` (see
239
+ * `pafi-direct-auth.module.ts`). The host app supplies `gatewayUrl`,
240
+ * `issuerId`, `clientId`, and `clientPrivateJwk` either as literal
241
+ * values OR as a factory that resolves them from NestJS ConfigService
242
+ * (`forRootAsync` variant).
243
+ */
244
+ declare class PafiAuthClientProvider implements OnModuleInit {
245
+ private readonly options;
246
+ private _client;
247
+ constructor(options: PafiDirectAuthModuleOptions);
248
+ onModuleInit(): void;
249
+ get client(): PafiAuthClient;
250
+ }
251
+
252
+ interface PafiSessionClaims {
253
+ sub: string;
254
+ scope: "pafi-session";
255
+ /** Verified attribute the gateway used to derive canonical_id. */
256
+ verifiedAttribute?: {
257
+ type: string;
258
+ valueHash?: string;
259
+ };
260
+ /** Issuer that originated the auth (must equal our PAFI_GATEWAY_ISSUER_ID). */
261
+ issuerId?: string;
262
+ exp: number;
263
+ iat: number;
264
+ raw: JWTPayload;
265
+ }
266
+ /**
267
+ * Verifies the long-lived `pafi_session_token` returned by the
268
+ * gateway's direct-auth endpoints. Defense-in-depth: the gateway just
269
+ * minted this token, so the chance of forgery is near zero — BUT
270
+ * verifying it locally guarantees we never trust an attacker-supplied
271
+ * "fake gateway response" that bypassed the network call (e.g. a
272
+ * compromised inbound proxy injecting an arbitrary JSON body). One
273
+ * audit line for a millisecond of crypto work.
274
+ *
275
+ * Uses the gateway's published JWKS (`/.well-known/jwks.json`) — same
276
+ * key set Privy uses when verifying the short-lived `pafi_jwt`. The
277
+ * remote JWKS is cached + auto-refreshed by jose.
278
+ */
279
+ declare class PafiSessionVerifierService {
280
+ private readonly jwks;
281
+ private readonly expectedIssuer;
282
+ constructor(options: PafiDirectAuthModuleOptions);
283
+ verify(token: string): Promise<PafiSessionClaims>;
284
+ }
285
+
286
+ interface DirectAuthResult {
287
+ sessionToken: string;
288
+ sessionExpiresAt: string;
289
+ pafiJwt: string;
290
+ pafiSessionToken: string;
291
+ canonicalId: string;
292
+ isFirstLogin: boolean;
293
+ verifiedEmail?: string;
294
+ }
295
+ /**
296
+ * Orchestrates the gateway-owned auth flows for an issuer backend. The
297
+ * gateway is the sole verifier (it ran the OTP check / verified the
298
+ * Google id_token / exchanged the Kakao code); this service is a thin
299
+ * adapter that:
300
+ *
301
+ * 1. Forwards the call via {@link PafiAuthClient} (RFC 7523
302
+ * client_assertion signed by the issuer's private JWK)
303
+ * 2. Verifies the returned `pafi_session_token` against the gateway's
304
+ * JWKS (defense in depth — see {@link PafiSessionVerifierService})
305
+ * 3. Upserts the local user row via {@link IUserStore} (issuer
306
+ * implements; SDK doesn't know the DB schema)
307
+ * 4. Mints an issuer-native session token via
308
+ * {@link ISessionTokenMinter} (issuer implements; SDK doesn't see
309
+ * the issuer's JWT_SECRET)
310
+ *
311
+ * The returned `sessionToken.sub` is the canonical_id — the same value
312
+ * the gateway uses for the PAFI JWT's `sub`. This means downstream
313
+ * services that need a stable user id can use it without caring which
314
+ * auth method produced it.
315
+ *
316
+ * Extracted from per-issuer boilerplate code in 2026-06-30 SDK refactor.
317
+ * Previously copy-pasted (~260 lines per issuer); now ~60 lines per
318
+ * issuer (UserStore + SessionMinter + module wiring).
319
+ */
320
+ declare class PafiDirectAuthService {
321
+ private readonly clientProvider;
322
+ private readonly sessionVerifier;
323
+ private readonly userStore;
324
+ private readonly sessionTokenMinter;
325
+ private readonly logger;
326
+ constructor(clientProvider: PafiAuthClientProvider, sessionVerifier: PafiSessionVerifierService, userStore: IUserStore, sessionTokenMinter: ISessionTokenMinter);
327
+ startEmail(args: {
328
+ email: string;
329
+ correlationId?: string;
330
+ }): Promise<{
331
+ challengeId: string;
332
+ expiresInSec: number;
333
+ }>;
334
+ verifyEmail(args: {
335
+ challengeId: string;
336
+ otpCode: string;
337
+ correlationId?: string;
338
+ }): Promise<DirectAuthResult>;
339
+ exchangeGoogle(args: {
340
+ idToken: string;
341
+ correlationId?: string;
342
+ }): Promise<DirectAuthResult>;
343
+ exchangeKakao(args: {
344
+ code: string;
345
+ redirectUri?: string;
346
+ correlationId?: string;
347
+ }): Promise<DirectAuthResult>;
348
+ private finalize;
349
+ }
350
+
351
+ declare class EmailStartRequestDto {
352
+ email: string;
353
+ }
354
+ declare class EmailVerifyRequestDto {
355
+ challengeId: string;
356
+ otpCode: string;
357
+ }
358
+ declare class GoogleExchangeRequestDto {
359
+ idToken: string;
360
+ }
361
+ declare class KakaoExchangeRequestDto {
362
+ code: string;
363
+ redirectUri?: string;
364
+ }
365
+ declare class EmailStartResponseDto {
366
+ challengeId: string;
367
+ expiresInSec: number;
368
+ }
369
+ declare class PafiAuthSuccessDto {
370
+ sessionToken: string;
371
+ sessionExpiresAt: string;
372
+ pafiJwt: string;
373
+ pafiSessionToken: string;
374
+ canonicalId: string;
375
+ isFirstLogin: boolean;
376
+ verifiedEmail?: string;
377
+ }
378
+
379
+ /**
380
+ * `/auth/v2/*` — gateway-owned auth endpoints (2026-06-30 direct-auth
381
+ * surface, shipped by `@pafi-dev/issuer/direct-auth`).
382
+ *
383
+ * Issuer backend no longer verifies OTP codes, OAuth id_tokens, or
384
+ * holds OAuth client_secrets. All of that lives at the PAFI gateway.
385
+ * These endpoints are thin proxies that forward user input via
386
+ * {@link PafiDirectAuthService}, then upsert the local user row (via
387
+ * the issuer-provided {@link IUserStore}) + mint an issuer-native
388
+ * session token (via the issuer-provided {@link ISessionTokenMinter})
389
+ * wrapping the gateway-assigned canonical_id.
390
+ *
391
+ * Mounted automatically by `PafiDirectAuthModule.forRoot(options)`.
392
+ * Issuer apps that want to layer extra auth-error handling (e.g. a
393
+ * custom `AuthErrorFilter`) can wrap the controller's routes via a
394
+ * NestJS global filter — no per-route `@UseFilters` needed here.
395
+ */
396
+ declare class PafiDirectAuthController {
397
+ private readonly directAuth;
398
+ constructor(directAuth: PafiDirectAuthService);
399
+ startEmail(body: EmailStartRequestDto): Promise<EmailStartResponseDto>;
400
+ verifyEmail(body: EmailVerifyRequestDto): Promise<PafiAuthSuccessDto>;
401
+ exchangeGoogle(body: GoogleExchangeRequestDto): Promise<PafiAuthSuccessDto>;
402
+ exchangeKakao(body: KakaoExchangeRequestDto): Promise<PafiAuthSuccessDto>;
403
+ }
404
+
405
+ 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 };