@coreiam/auth 1.0.392-beta.1 → 1.0.392-beta.12

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.
@@ -41,6 +41,7 @@ export interface RefreshSessionResult {
41
41
  }
42
42
  /** Maps gateway token claims → CoreIAMIdentity (same shape the Next.js middleware injects). */
43
43
  export declare function toIdentity(payload: JWTPayload): CoreIAMIdentity;
44
+ export declare function retryAfterSeconds(body: unknown): number | undefined;
44
45
  /**
45
46
  export class CoreIAM {
46
47
  private apiKey: string = '';
@@ -55,6 +56,16 @@ export class CoreIAM {
55
56
  if (!this.baseUrl) throw new Error('CoreIAM: Missing Gateway URL (COREIAM_API_URL)');
56
57
  }
57
58
  */
59
+ /** Default gateway labels (the retired coreiam.e-qalam.com is never a default). */
60
+ export declare const COREIAM_DEV_GATEWAY_URL = "https://dev.e-qalam.com";
61
+ export declare const COREIAM_LIVE_GATEWAY_URL = "https://api.e-qalam.com";
62
+ /** Default by key prefix: pk_dev_ → dev label, anything else → live. */
63
+ export declare function defaultGatewayUrlForKey(apiKey: string): string;
64
+ /** Resolution order: explicit baseUrl > COREIAM_API_URL > key-prefix default. */
65
+ export declare function resolveGatewayUrl(config?: {
66
+ baseUrl?: string;
67
+ apiKey?: string;
68
+ }): string;
58
69
  export declare class CoreIAM {
59
70
  private apiKey;
60
71
  private baseUrl;
@@ -73,9 +84,14 @@ export declare class CoreIAM {
73
84
  * this instance's API key. Server-to-server, read-only.
74
85
  * THROWS on failure — an audit must never silently see an empty roster.
75
86
  */
76
- listIdentities(): Promise<{
87
+ listIdentities(params?: {
88
+ limit?: number;
89
+ offset?: number;
90
+ roles?: string[];
91
+ }): Promise<{
77
92
  users: DirectoryIdentity[];
78
93
  total: number;
94
+ next_offset?: number;
79
95
  }>;
80
96
  private readCookieHeader;
81
97
  private readSessionCookie;
@@ -131,9 +147,53 @@ export declare class CoreIAM {
131
147
  loginWithCsrfProtection(identifier: string, password: string, incomingHeaders: Headers): Promise<Response>;
132
148
  registerWithCsrfProtection(userData: any, incomingHeaders: Headers): Promise<Response>;
133
149
  updateProfile(updateData: any, incomingHeaders: Headers): Promise<Response>;
150
+ changePassword(currentPassword: string, newPassword: string, incomingHeaders: Headers): Promise<Response>;
134
151
  setupMfa(incomingHeaders: Headers): Promise<Response>;
135
152
  verifyMfa(token: string, incomingHeaders: Headers): Promise<Response>;
136
153
  disableMfa(incomingHeaders: Headers): Promise<Response>;
137
- requestOtp(body: any, incomingHeaders: Headers): Promise<Response>;
138
- verifyOtp(body: any, incomingHeaders: Headers): Promise<Response>;
154
+ requestOtp(body: RequestOtpBody, incomingHeaders: Headers): Promise<Response>;
155
+ verifyOtp(body: VerifyOtpBody, incomingHeaders: Headers): Promise<Response>;
156
+ forgotPassword(email: string, incomingHeaders: Headers): Promise<Response>;
157
+ verifyEmail(body: VerifyEmailBody, incomingHeaders: Headers): Promise<Response>;
158
+ resendVerification(email: string, incomingHeaders: Headers): Promise<Response>;
159
+ resetPassword(email: string, otp: string, newPassword: string, incomingHeaders: Headers): Promise<Response>;
160
+ listSessions(incomingHeaders: Headers): Promise<Response>;
161
+ revokeSession(sessionId: string, incomingHeaders: Headers): Promise<Response>;
162
+ revokeAllSessions(incomingHeaders: Headers): Promise<Response>;
163
+ }
164
+ export declare const CSRF_PROTECTED_PATHS: ReadonlySet<string>;
165
+ export declare const OAUTH_PROVIDERS: ReadonlySet<string>;
166
+ export interface RequestOtpBody {
167
+ email: string;
168
+ }
169
+ export interface VerifyOtpBody {
170
+ email: string;
171
+ otp: string;
172
+ }
173
+ export interface VerifyEmailBody {
174
+ email: string;
175
+ otp: string;
176
+ }
177
+ export interface RequestResetBody {
178
+ email: string;
179
+ }
180
+ export interface ResetPasswordBody {
181
+ email: string;
182
+ otp: string;
183
+ newPassword: string;
184
+ }
185
+ export interface SessionSummary {
186
+ id: string;
187
+ userAgent: string;
188
+ ipAddress: string;
189
+ createdAt: string;
190
+ lastActiveAt: string;
191
+ isCurrent: boolean;
192
+ }
193
+ export interface ListSessionsResponse {
194
+ sessions: SessionSummary[];
195
+ }
196
+ export interface RevokeAllSessionsResponse {
197
+ success: boolean;
198
+ sessionsRevoked: number;
139
199
  }
@@ -1,7 +1,10 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.CoreIAM = void 0;
3
+ exports.OAUTH_PROVIDERS = exports.CSRF_PROTECTED_PATHS = exports.CoreIAM = exports.COREIAM_LIVE_GATEWAY_URL = exports.COREIAM_DEV_GATEWAY_URL = void 0;
4
4
  exports.toIdentity = toIdentity;
5
+ exports.retryAfterSeconds = retryAfterSeconds;
6
+ exports.defaultGatewayUrlForKey = defaultGatewayUrlForKey;
7
+ exports.resolveGatewayUrl = resolveGatewayUrl;
5
8
  // src/core/index.ts
6
9
  const jose_1 = require("jose");
7
10
  /** Maps gateway token claims → CoreIAMIdentity (same shape the Next.js middleware injects). */
@@ -13,6 +16,17 @@ function toIdentity(payload) {
13
16
  roles: Array.isArray(payload.roles) ? payload.roles : [],
14
17
  };
15
18
  }
19
+ function retryAfterSeconds(body) {
20
+ const msg = body?.message;
21
+ for (const m of Array.isArray(msg) ? msg : [msg]) {
22
+ if (typeof m === 'string') {
23
+ const match = m.match(/Try again in (\d+) seconds?/);
24
+ if (match)
25
+ return Number(match[1]);
26
+ }
27
+ }
28
+ return undefined;
29
+ }
16
30
  /**
17
31
  export class CoreIAM {
18
32
  private apiKey: string = '';
@@ -27,6 +41,21 @@ export class CoreIAM {
27
41
  if (!this.baseUrl) throw new Error('CoreIAM: Missing Gateway URL (COREIAM_API_URL)');
28
42
  }
29
43
  */
44
+ // ── K13c: config layer ──────────────────────────────────────────────
45
+ /** Default gateway labels (the retired coreiam.e-qalam.com is never a default). */
46
+ exports.COREIAM_DEV_GATEWAY_URL = 'https://dev.e-qalam.com';
47
+ exports.COREIAM_LIVE_GATEWAY_URL = 'https://api.e-qalam.com';
48
+ /** Default by key prefix: pk_dev_ → dev label, anything else → live. */
49
+ function defaultGatewayUrlForKey(apiKey) {
50
+ return (apiKey || '').startsWith('pk_dev_') ? exports.COREIAM_DEV_GATEWAY_URL : exports.COREIAM_LIVE_GATEWAY_URL;
51
+ }
52
+ /** Resolution order: explicit baseUrl > COREIAM_API_URL > key-prefix default. */
53
+ function resolveGatewayUrl(config) {
54
+ const explicit = (config?.baseUrl || process.env.COREIAM_API_URL || '').replace(/\/+$/, '');
55
+ if (explicit)
56
+ return explicit;
57
+ return defaultGatewayUrlForKey(config?.apiKey || process.env.COREIAM_API_KEY || '');
58
+ }
30
59
  class CoreIAM {
31
60
  apiKey = '';
32
61
  baseUrl = '';
@@ -38,7 +67,7 @@ class CoreIAM {
38
67
  refreshInFlight = null;
39
68
  constructor(config) {
40
69
  this.apiKey = config?.apiKey || process.env.COREIAM_API_KEY || '';
41
- this.baseUrl = (config?.baseUrl || process.env.COREIAM_API_URL || '').replace(/\/+$/, '');
70
+ this.baseUrl = resolveGatewayUrl(config);
42
71
  this.issuer = config?.issuer ?? 'coreiam';
43
72
  this.audience = config?.audience;
44
73
  this.sessionCookieName = config?.sessionCookieName ?? 'jwt';
@@ -72,14 +101,30 @@ class CoreIAM {
72
101
  * this instance's API key. Server-to-server, read-only.
73
102
  * THROWS on failure — an audit must never silently see an empty roster.
74
103
  */
75
- async listIdentities() {
76
- const response = await this.proxy('/api/v1/directory', { method: 'GET' }, new Headers());
104
+ async listIdentities(params) {
105
+ const search = new URLSearchParams();
106
+ if (params?.limit != null)
107
+ search.set('limit', String(params.limit));
108
+ if (params?.offset != null)
109
+ search.set('offset', String(params.offset));
110
+ if (params?.roles?.length)
111
+ search.set('roles', params.roles.join(','));
112
+ const qs = search.toString();
113
+ const response = await this.proxy(`/api/v1/directory${qs ? `?${qs}` : ''}`, { method: 'GET' }, new Headers());
77
114
  if (!response.ok) {
78
115
  const body = await response.text().catch(() => '');
79
116
  throw new Error(`listIdentities failed: ${response.status} ${body}`);
80
117
  }
81
- const data = await response.json();
82
- return { users: data.users ?? [], total: data.total ?? 0 };
118
+ const data = await response.json().catch(() => null);
119
+ if (!data || !Array.isArray(data.users)) {
120
+ throw new Error(`listIdentities: unexpected response shape — HTTP 200 without a users array. ` +
121
+ `This is not an empty directory; verify COREIAM_API_URL points at the CoreIAM gateway.`);
122
+ }
123
+ return {
124
+ users: data.users,
125
+ total: typeof data.total === 'number' ? data.total : data.users.length,
126
+ ...(typeof data.next_offset === 'number' ? { next_offset: data.next_offset } : {}),
127
+ };
83
128
  }
84
129
  readCookieHeader(input) {
85
130
  if (typeof input === 'string')
@@ -88,15 +133,33 @@ class CoreIAM {
88
133
  return input.get('cookie') || '';
89
134
  return input.cookie || '';
90
135
  }
136
+ // private readSessionCookie(input: SessionInput): string | null {
137
+ // const raw = this.readCookieHeader(input);
138
+ // if (!raw) return null;
139
+ // // Anchored to a real cookie-name boundary (same pattern proxy() uses),
140
+ // // so a cookie named "notjwt" or "xjwt" can never match.
141
+ // const escaped = this.sessionCookieName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
142
+ // const match = raw.match(new RegExp(`(?:^|;\\s*)${escaped}=([^;]+)`));
143
+ // return match ? decodeURIComponent(match[1]) : null;
144
+ // }
145
+ // PATCH 1 — readSessionCookie: a corrupt cookie value (%E0%A4%A) currently
146
+ // throws URIError out of verifySession → 500 on /session. A corrupt value is
147
+ // an invalid token, not a server error: return it raw; jwtVerify classifies
148
+ // it as 'invalid_signature'.
91
149
  readSessionCookie(input) {
92
150
  const raw = this.readCookieHeader(input);
93
151
  if (!raw)
94
152
  return null;
95
- // Anchored to a real cookie-name boundary (same pattern proxy() uses),
96
- // so a cookie named "notjwt" or "xjwt" can never match.
97
153
  const escaped = this.sessionCookieName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
98
154
  const match = raw.match(new RegExp(`(?:^|;\\s*)${escaped}=([^;]+)`));
99
- return match ? decodeURIComponent(match[1]) : null;
155
+ if (!match)
156
+ return null;
157
+ try {
158
+ return decodeURIComponent(match[1]);
159
+ }
160
+ catch {
161
+ return match[1];
162
+ }
100
163
  }
101
164
  async verifySession(input) {
102
165
  const token = this.readSessionCookie(input);
@@ -305,8 +368,22 @@ class CoreIAM {
305
368
  const existingCookieStr = incomingHeaders.get('cookie') || '';
306
369
  const newCookiePairs = csrfCookies.map(c => c.split(';')[0]);
307
370
  if (newCookiePairs.length > 0) {
308
- const combinedCookies = [existingCookieStr, ...newCookiePairs].filter(Boolean).join('; ');
309
- authHeaders.set('cookie', combinedCookies);
371
+ // FIX (SDK-4): dedupe by cookie NAME, newest value wins. The old
372
+ // [existing, ...new].join() emitted 'csrfToken=old; csrfToken=new' once
373
+ // the browser jar already held a csrf cookie and the handshake rotated
374
+ // it — whether the backend parser reads the first or last duplicate
375
+ // decided 403-or-pass. Nondeterministic across parsers is a bug.
376
+ const jar = new Map();
377
+ for (const pair of [...existingCookieStr.split(';'), ...newCookiePairs]) {
378
+ const trimmed = pair.trim();
379
+ if (!trimmed)
380
+ continue;
381
+ const eq = trimmed.indexOf('=');
382
+ if (eq === -1)
383
+ continue;
384
+ jar.set(trimmed.slice(0, eq), trimmed.slice(eq + 1));
385
+ }
386
+ authHeaders.set('cookie', [...jar.entries()].map(([k, v]) => `${k}=${v}`).join('; '));
310
387
  }
311
388
  return this.proxy(path, init, authHeaders);
312
389
  }
@@ -331,6 +408,13 @@ class CoreIAM {
331
408
  body: JSON.stringify(updateData),
332
409
  }, incomingHeaders);
333
410
  }
411
+ async changePassword(currentPassword, newPassword, incomingHeaders) {
412
+ return this.proxyWithCsrfProtection('/auth/password', {
413
+ method: 'PUT',
414
+ headers: { 'Content-Type': 'application/json' },
415
+ body: JSON.stringify({ currentPassword, newPassword }),
416
+ }, incomingHeaders);
417
+ }
334
418
  async setupMfa(incomingHeaders) {
335
419
  return this.proxyWithCsrfProtection('/auth/mfa/setup', { method: 'POST' }, incomingHeaders);
336
420
  }
@@ -358,5 +442,77 @@ class CoreIAM {
358
442
  body: JSON.stringify(body),
359
443
  }, incomingHeaders);
360
444
  }
445
+ // W3 beta.6: forgot-password / reset-password. Both go through the same
446
+ // CSRF funnel as every other CsrfGuard-decorated endpoint — the paths are
447
+ // pre-registered in CSRF_PROTECTED_PATHS (SDK-6), so the middleware proxy
448
+ // and these methods stay in parity.
449
+ async forgotPassword(email, incomingHeaders) {
450
+ return this.proxyWithCsrfProtection('/auth/request-reset', {
451
+ method: 'POST',
452
+ headers: { 'Content-Type': 'application/json' },
453
+ body: JSON.stringify({ email }),
454
+ }, incomingHeaders);
455
+ }
456
+ async verifyEmail(body, incomingHeaders) {
457
+ return this.proxyWithCsrfProtection('/auth/verify-email', {
458
+ method: 'POST',
459
+ headers: { 'Content-Type': 'application/json' },
460
+ body: JSON.stringify(body),
461
+ }, incomingHeaders);
462
+ }
463
+ async resendVerification(email, incomingHeaders) {
464
+ return this.proxyWithCsrfProtection('/auth/resend-verification', {
465
+ method: 'POST',
466
+ headers: { 'Content-Type': 'application/json' },
467
+ body: JSON.stringify({ email }),
468
+ }, incomingHeaders);
469
+ }
470
+ async resetPassword(email, otp, newPassword, incomingHeaders) {
471
+ return this.proxyWithCsrfProtection('/auth/reset-password', {
472
+ method: 'POST',
473
+ headers: { 'Content-Type': 'application/json' },
474
+ body: JSON.stringify({ email, otp, newPassword }),
475
+ }, incomingHeaders);
476
+ }
477
+ // W4 (beta.7): session management surface. listSessions is a plain
478
+ // authenticated GET (same shape as the /auth/profile GET precedent —
479
+ // JWT comes from the forwarded cookie via proxy(), reads carry no CSRF
480
+ // requirement); the two state-changing calls go through the shared CSRF
481
+ // handshake exactly like changePassword.
482
+ async listSessions(incomingHeaders) {
483
+ return this.proxy('/auth/sessions', { method: 'GET' }, incomingHeaders);
484
+ }
485
+ async revokeSession(sessionId, incomingHeaders) {
486
+ // Same rationale as OAUTH_PROVIDERS (SDK-5): never interpolate an
487
+ // unvalidated path segment into a URL. The backend's ParseUUIDPipe
488
+ // would reject garbage with a 400 anyway — this fails fast
489
+ // client-side and can never become a traversal string.
490
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(sessionId)) {
491
+ throw new Error('revokeSession: sessionId must be a UUID');
492
+ }
493
+ return this.proxyWithCsrfProtection(`/auth/sessions/${sessionId}`, { method: 'DELETE' }, incomingHeaders);
494
+ }
495
+ async revokeAllSessions(incomingHeaders) {
496
+ return this.proxyWithCsrfProtection('/auth/sessions/revoke-all', { method: 'POST' }, incomingHeaders);
497
+ }
361
498
  }
362
499
  exports.CoreIAM = CoreIAM;
500
+ exports.CSRF_PROTECTED_PATHS = new Set([
501
+ '/auth/login', '/auth/register', '/auth/discover-tenants',
502
+ '/auth/request-otp', '/auth/verify-otp', '/auth/mfa/login', '/auth/verify-email', '/auth/resend-verification',
503
+ '/auth/profile', '/auth/mfa/setup', '/auth/mfa/verify', '/auth/mfa/disable',
504
+ '/auth/password',
505
+ '/auth/request-reset', '/auth/reset-password',
506
+ // W4 (beta.7): revoke-all is exact-matched. The per-session revoke is a
507
+ // DYNAMIC path (/auth/sessions/<uuid>) a string Set cannot match — the SDK
508
+ // methods self-handshake via proxyWithCsrfProtection so server-action
509
+ // callers are fully covered; middleware-proxied browser-direct DELETEs to
510
+ // /api/auth/sessions/<uuid> are not auto-handshaked (known limitation; the
511
+ // fix is a prefix matcher inside middleware.ts, deliberately deferred to
512
+ // keep this patch off that file).
513
+ '/auth/sessions/revoke-all',
514
+ ]);
515
+ // FIX (SDK-5): single source of truth for supported OAuth providers. Every
516
+ // place that interpolates a provider segment into a backend URL must check
517
+ // this set first — an unvalidated path segment is a traversal surface.
518
+ exports.OAUTH_PROVIDERS = new Set(['google', 'github']);
@@ -6,11 +6,6 @@ exports.requireAuth = requireAuth;
6
6
  // src/express/index.ts
7
7
  const express_1 = require("express");
8
8
  const core_1 = require("../core");
9
- const CSRF_PROTECTED = new Set([
10
- '/auth/login', '/auth/register', '/auth/profile',
11
- '/auth/mfa/setup', '/auth/mfa/verify', '/auth/mfa/disable',
12
- '/auth/request-otp', '/auth/verify-otp',
13
- ]);
14
9
  /** Express 4 does not catch async rejections — route them to next(err). */
15
10
  const wrap = (fn) => (req, res, next) => { fn(req, res, next).catch(next); };
16
11
  function sessionCookie(name, value, maxAgeSec, isProd) {
@@ -21,7 +16,7 @@ function coreIamExpress(options) {
21
16
  const authPath = (options.authPath ?? '/api/auth').replace(/\/+$/, '');
22
17
  const successUrl = options.successUrl ?? '/';
23
18
  const signInUrl = options.signInUrl ?? '/login';
24
- const gatewayUrl = (options.baseUrl || process.env.COREIAM_API_URL || '').replace(/\/+$/, '');
19
+ const gatewayUrl = (0, core_1.resolveGatewayUrl)(options);
25
20
  const isProd = process.env.NODE_ENV === 'production';
26
21
  const iam = new core_1.CoreIAM(options);
27
22
  const router = (0, express_1.Router)();
@@ -70,7 +65,12 @@ function coreIamExpress(options) {
70
65
  const code = req.query.code;
71
66
  const state = req.query.state;
72
67
  const redirectUri = `${appUrl}${authPath}/callback/${provider}`;
73
- const target = `/auth/oauth/callback/${provider}?code=${code}&state=${state}&redirectUri=${encodeURIComponent(redirectUri)}`;
68
+ // const target = `/auth/oauth/callback/${provider}?code=${code}&state=${state}&redirectUri=${encodeURIComponent(redirectUri)}`;
69
+ // OAuth callback — encode all three params:
70
+ const target = `/auth/oauth/callback/${provider}`
71
+ + `?code=${encodeURIComponent(String(code ?? ''))}`
72
+ + `&state=${encodeURIComponent(String(state ?? ''))}`
73
+ + `&redirectUri=${encodeURIComponent(redirectUri)}`;
74
74
  const response = await iam.proxy(target, { method: 'GET' }, toHeaders(req));
75
75
  const data = await response.json().catch(() => ({}));
76
76
  // Account linking: redirect to CoreIAM's hosted confirmation page
@@ -138,7 +138,7 @@ function coreIamExpress(options) {
138
138
  init.body = req.body ? JSON.stringify(req.body) : undefined;
139
139
  init.headers = { 'Content-Type': 'application/json' };
140
140
  }
141
- const response = CSRF_PROTECTED.has(target)
141
+ const response = core_1.CSRF_PROTECTED_PATHS.has(target)
142
142
  ? await iam.proxyWithCsrfProtection(target, init, toHeaders(req))
143
143
  : await iam.proxy(target, init, toHeaders(req));
144
144
  const data = await response.json().catch(() => ({}));
@@ -0,0 +1,33 @@
1
+ import { Hono, type MiddlewareHandler } from 'hono';
2
+ import { CoreIAMIdentity } from '../core';
3
+ export interface CoreIamHonoOptions {
4
+ apiKey: string;
5
+ baseUrl: string;
6
+ /** Your app's public URL (OAuth callbacks redirect back here). */
7
+ appUrl: string;
8
+ /** Mount path of the adapter. MUST match how you mount it. Default '/api/auth'. */
9
+ authPath?: string;
10
+ signInUrl?: string;
11
+ successUrl?: string;
12
+ }
13
+ /** Pass to your app: `new Hono<CoreIAMEnv>()` so `c.get('identity')` is typed. */
14
+ export type CoreIAMEnv = {
15
+ Variables: {
16
+ identity: CoreIAMIdentity;
17
+ };
18
+ };
19
+ /**
20
+ * Drop-in auth router for Hono (Node, Bun, Deno, Workers).
21
+ * const app = new Hono();
22
+ * app.route('/api/auth', coreIamHono({ apiKey, baseUrl, appUrl }));
23
+ * Same behavior as the Express adapter: CSRF handshake, OAuth + account
24
+ * linking, cookie forwarding, silent refresh on /session.
25
+ */
26
+ export declare function coreIamHono(options: CoreIamHonoOptions): Hono;
27
+ export interface RequireAuthConfig {
28
+ apiKey?: string;
29
+ baseUrl?: string;
30
+ signInUrl?: string;
31
+ }
32
+ /** Route guard: local verify → c.get('identity'); 401s API requests, redirects pages. */
33
+ export declare function requireAuth(config?: RequireAuthConfig): MiddlewareHandler<CoreIAMEnv>;
@@ -0,0 +1,195 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.coreIamHono = coreIamHono;
4
+ exports.requireAuth = requireAuth;
5
+ // src/hono/index.ts
6
+ const hono_1 = require("hono");
7
+ const core_1 = require("../core");
8
+ /**
9
+ * Drop-in auth router for Hono (Node, Bun, Deno, Workers).
10
+ * const app = new Hono();
11
+ * app.route('/api/auth', coreIamHono({ apiKey, baseUrl, appUrl }));
12
+ * Same behavior as the Express adapter: CSRF handshake, OAuth + account
13
+ * linking, cookie forwarding, silent refresh on /session.
14
+ */
15
+ function coreIamHono(options) {
16
+ const appUrl = options.appUrl.replace(/\/+$/, '');
17
+ const authPath = (options.authPath ?? '/api/auth').replace(/\/+$/, '');
18
+ const successUrl = options.successUrl ?? '/';
19
+ const signInUrl = options.signInUrl ?? '/login';
20
+ const gatewayUrl = options.baseUrl.replace(/\/+$/, '');
21
+ const isProd = process.env.NODE_ENV === 'production';
22
+ const iam = new core_1.CoreIAM(options);
23
+ const app = new hono_1.Hono();
24
+ // c.req.path is always the FULL path (Hono does not rewrite it per sub-app),
25
+ // so the mount prefix must be stripped by hand to get '/login' → '/auth/login'.
26
+ const relPath = (c) => {
27
+ const p = c.req.path;
28
+ if (p === authPath)
29
+ return '/';
30
+ if (p.startsWith(`${authPath}/`))
31
+ return p.slice(authPath.length);
32
+ if (process.env.NODE_ENV !== 'production') {
33
+ console.warn(`[coreIamHono] ${p} does not start with authPath '${authPath}' — ` +
34
+ `if you mounted the adapter elsewhere, pass the same value as authPath.`);
35
+ }
36
+ return p;
37
+ };
38
+ // Same attributes as the Express adapter's sessionCookie().
39
+ const sessionCookie = (name, value, maxAgeSec) => `${name}=${encodeURIComponent(value)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAgeSec}${isProd ? '; Secure' : ''}`;
40
+ // Mirrors the Express adapter (json() + urlencoded()): both content types
41
+ // accepted on every auth route, re-serialized as JSON for the gateway.
42
+ const readBody = async (c) => {
43
+ if ((c.req.header('content-type') || '').includes('application/x-www-form-urlencoded')) {
44
+ return Object.fromEntries(new URLSearchParams(await c.req.text()).entries());
45
+ }
46
+ return c.req.json().catch(() => undefined);
47
+ };
48
+ // ── CORS preflight short-circuit — same as the Next.js middleware, so ──
49
+ // cross-origin frontends work with zero setup. Same-origin (the documented
50
+ // pattern) never sends OPTIONS.
51
+ app.options('*', (c) => {
52
+ const origin = c.req.header('origin');
53
+ if (origin) {
54
+ c.header('Access-Control-Allow-Origin', origin);
55
+ c.header('Access-Control-Allow-Credentials', 'true');
56
+ c.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
57
+ c.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-CSRF-Token');
58
+ c.header('Vary', 'Origin', { append: true });
59
+ }
60
+ return c.body(null, 204);
61
+ });
62
+ // ── GET {authPath}/session — local verify + silent refresh (React provider) ──
63
+ app.get('/session', async (c) => {
64
+ const result = await iam.verifySession(c.req.raw.headers);
65
+ if (result.identity)
66
+ return c.json(result.identity);
67
+ if (result.reason === 'expired') {
68
+ const { identity, setCookies } = await iam.refreshSession(c.req.raw.headers);
69
+ if (identity) {
70
+ setCookies?.forEach((sc) => c.header('Set-Cookie', sc, { append: true }));
71
+ return c.json(identity);
72
+ }
73
+ }
74
+ return c.json({ error: 'unauthenticated' }, 401);
75
+ });
76
+ // ── GET {authPath}/oauth/:provider — initiate ──
77
+ app.get('/oauth/:provider', async (c, next) => {
78
+ const provider = c.req.param('provider');
79
+ if (provider === 'link')
80
+ return next(); // link/confirm is POSTed below
81
+ const redirectUri = `${appUrl}${authPath}/callback/${provider}`;
82
+ const target = `/auth/oauth/initiate/${provider}?redirectUri=${encodeURIComponent(redirectUri)}&scopes=email,profile`;
83
+ const response = await iam.proxy(target, { method: 'GET' }, c.req.raw.headers);
84
+ const data = await response.json().catch(() => ({}));
85
+ if (data.url)
86
+ return c.redirect(data.url, 302);
87
+ return c.redirect(signInUrl, 302);
88
+ });
89
+ // ── GET {authPath}/callback/:provider ──
90
+ app.get('/callback/:provider', async (c) => {
91
+ const provider = c.req.param('provider');
92
+ const code = c.req.query('code');
93
+ const state = c.req.query('state');
94
+ const redirectUri = `${appUrl}${authPath}/callback/${provider}`;
95
+ const target = `/auth/oauth/callback/${provider}`
96
+ + `?code=${encodeURIComponent(code ?? '')}`
97
+ + `&state=${encodeURIComponent(state ?? '')}`
98
+ + `&redirectUri=${encodeURIComponent(redirectUri)}`;
99
+ const response = await iam.proxy(target, { method: 'GET' }, c.req.raw.headers);
100
+ const data = (await response.json().catch(() => ({})));
101
+ // Account linking → CoreIAM's hosted confirmation page
102
+ if (data.action === 'link_candidate') {
103
+ const redirect = new URL(`${gatewayUrl}/auth/hosted/confirm-link`);
104
+ redirect.searchParams.set('token', data.tempToken);
105
+ redirect.searchParams.set('provider', data.provider);
106
+ redirect.searchParams.set('email', data.email || '');
107
+ redirect.searchParams.set('appOrigin', appUrl);
108
+ return c.redirect(redirect.toString(), 303);
109
+ }
110
+ // Normal login: forward gateway cookies, then redirect.
111
+ response.headers.getSetCookie().forEach((sc) => c.header('Set-Cookie', sc, { append: true }));
112
+ return c.redirect(successUrl, 302);
113
+ });
114
+ // ── POST {authPath}/oauth/link/confirm (hosted page posts urlencoded) ──
115
+ app.post('/oauth/link/confirm', async (c) => {
116
+ const contentType = c.req.header('content-type') || '';
117
+ let formData;
118
+ if (contentType.includes('application/x-www-form-urlencoded')) {
119
+ const params = new URLSearchParams(await c.req.text());
120
+ formData = { tempToken: params.get('tempToken') ?? undefined, password: params.get('password') ?? undefined };
121
+ }
122
+ else {
123
+ formData = (await c.req.json().catch(() => ({})));
124
+ }
125
+ const response = await iam.proxy('/auth/oauth/link/confirm', {
126
+ method: 'POST',
127
+ body: JSON.stringify(formData),
128
+ headers: { 'Content-Type': 'application/json' },
129
+ }, c.req.raw.headers);
130
+ const data = (await response.json().catch(() => ({})));
131
+ if (response.ok && data.success) {
132
+ const finalUrl = new URL(data.redirectUri || '/', appUrl).toString();
133
+ if (data.accessToken)
134
+ c.header('Set-Cookie', sessionCookie('jwt', data.accessToken, 24 * 60 * 60), { append: true });
135
+ if (data.clientRefreshToken)
136
+ c.header('Set-Cookie', sessionCookie('refreshToken', data.clientRefreshToken, 7 * 24 * 60 * 60), { append: true });
137
+ // 200 + JS redirect — NOT a 303: breaks the cross-site redirect chain so
138
+ // the browser sends the freshly-set SameSite=Lax cookies on the next hop.
139
+ // (Identical to the Express adapter and Next middleware — do not "simplify".)
140
+ return c.html(`<!DOCTYPE html>
141
+ <html>
142
+ <head><title>Linking Successful</title></head>
143
+ <body>
144
+ <p>Account linked successfully! Redirecting...</p>
145
+ <script>window.location.href = ${JSON.stringify(finalUrl)};</script>
146
+ </body>
147
+ </html>`);
148
+ }
149
+ const errorRedirect = new URL(`${gatewayUrl}/auth/hosted/confirm-link`);
150
+ errorRedirect.searchParams.set('token', formData.tempToken || '');
151
+ errorRedirect.searchParams.set('provider', data.provider || 'google');
152
+ errorRedirect.searchParams.set('email', data.email || '');
153
+ errorRedirect.searchParams.set('appOrigin', appUrl);
154
+ errorRedirect.searchParams.set('error', data.message || 'Invalid password. Link failed.');
155
+ return c.redirect(errorRedirect.toString(), 303);
156
+ });
157
+ // ── Everything else: generic JSON passthrough (login, register, logout,
158
+ // csrf-token, MFA, OTP, profile). CSRF-protected endpoints run the
159
+ // handshake first — the same set, from core, as Express/Next. ──
160
+ app.all('*', async (c) => {
161
+ const target = `/auth${relPath(c)}`;
162
+ const init = { method: c.req.method };
163
+ if (c.req.method !== 'GET' && c.req.method !== 'HEAD') {
164
+ const body = await readBody(c);
165
+ if (body !== undefined) {
166
+ init.body = JSON.stringify(body);
167
+ init.headers = { 'Content-Type': 'application/json' };
168
+ }
169
+ }
170
+ const response = core_1.CSRF_PROTECTED_PATHS.has(target)
171
+ ? await iam.proxyWithCsrfProtection(target, init, c.req.raw.headers)
172
+ : await iam.proxy(target, init, c.req.raw.headers);
173
+ c.status(response.status);
174
+ response.headers.getSetCookie().forEach((sc) => c.header('Set-Cookie', sc, { append: true }));
175
+ return c.json(await response.json().catch(() => ({})));
176
+ });
177
+ return app;
178
+ }
179
+ /** Route guard: local verify → c.get('identity'); 401s API requests, redirects pages. */
180
+ function requireAuth(config = {}) {
181
+ const iam = new core_1.CoreIAM(config);
182
+ const { signInUrl = '/login' } = config;
183
+ return async (c, next) => {
184
+ const identity = await iam.getSession(c.req.raw.headers);
185
+ if (!identity) {
186
+ const accept = c.req.header('accept') || '';
187
+ const wantsJson = c.req.path.startsWith('/api') || accept.includes('json') || accept.trim() === '*/*';
188
+ if (wantsJson)
189
+ return c.json({ error: 'unauthenticated' }, 401);
190
+ return c.redirect(signInUrl, 302);
191
+ }
192
+ c.set('identity', identity);
193
+ await next();
194
+ };
195
+ }
@@ -1,7 +1,8 @@
1
- import { CoreIAMIdentity } from '../core';
1
+ import { CoreIAMIdentity, RequestOtpBody, VerifyOtpBody, VerifyEmailBody } from '../core';
2
2
  export interface AuthResult {
3
3
  ok: boolean;
4
4
  status: number;
5
+ retryAfter?: number;
5
6
  data: any;
6
7
  }
7
8
  export declare function getSession(): Promise<CoreIAMIdentity | null>;
@@ -9,8 +10,16 @@ export declare function loginActionSdk(identifier: string, password: string): Pr
9
10
  export declare function registerActionSdk(userData: any): Promise<AuthResult>;
10
11
  export declare function logoutActionSdk(): Promise<AuthResult>;
11
12
  export declare function updateProfileActionSdk(updateData: Record<string, any>): Promise<AuthResult>;
13
+ export declare function changePasswordActionSdk(currentPassword: string, newPassword: string): Promise<AuthResult>;
12
14
  export declare function setupMfaActionSdk(): Promise<AuthResult>;
13
15
  export declare function verifyMfaActionSdk(token: string): Promise<AuthResult>;
14
16
  export declare function disableMfaActionSdk(): Promise<AuthResult>;
15
- export declare function requestOtpActionSdk(body: Record<string, any>): Promise<AuthResult>;
16
- export declare function verifyOtpActionSdk(body: Record<string, any>): Promise<AuthResult>;
17
+ export declare function requestOtpActionSdk(body: RequestOtpBody): Promise<AuthResult>;
18
+ export declare function verifyOtpActionSdk(body: VerifyOtpBody): Promise<AuthResult>;
19
+ export declare function verifyEmailActionSdk(body: VerifyEmailBody): Promise<AuthResult>;
20
+ export declare function resendVerificationActionSdk(email: string): Promise<AuthResult>;
21
+ export declare function forgotPasswordActionSdk(email: string): Promise<AuthResult>;
22
+ export declare function resetPasswordActionSdk(email: string, otp: string, newPassword: string): Promise<AuthResult>;
23
+ export declare function listSessionsActionSdk(): Promise<AuthResult>;
24
+ export declare function revokeSessionActionSdk(sessionId: string): Promise<AuthResult>;
25
+ export declare function revokeAllSessionsActionSdk(): Promise<AuthResult>;