@coreiam/auth 1.0.392-beta.0 → 1.0.392-beta.11

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 = '';
@@ -73,9 +74,14 @@ export declare class CoreIAM {
73
74
  * this instance's API key. Server-to-server, read-only.
74
75
  * THROWS on failure — an audit must never silently see an empty roster.
75
76
  */
76
- listIdentities(): Promise<{
77
+ listIdentities(params?: {
78
+ limit?: number;
79
+ offset?: number;
80
+ roles?: string[];
81
+ }): Promise<{
77
82
  users: DirectoryIdentity[];
78
83
  total: number;
84
+ next_offset?: number;
79
85
  }>;
80
86
  private readCookieHeader;
81
87
  private readSessionCookie;
@@ -131,9 +137,53 @@ export declare class CoreIAM {
131
137
  loginWithCsrfProtection(identifier: string, password: string, incomingHeaders: Headers): Promise<Response>;
132
138
  registerWithCsrfProtection(userData: any, incomingHeaders: Headers): Promise<Response>;
133
139
  updateProfile(updateData: any, incomingHeaders: Headers): Promise<Response>;
140
+ changePassword(currentPassword: string, newPassword: string, incomingHeaders: Headers): Promise<Response>;
134
141
  setupMfa(incomingHeaders: Headers): Promise<Response>;
135
142
  verifyMfa(token: string, incomingHeaders: Headers): Promise<Response>;
136
143
  disableMfa(incomingHeaders: Headers): Promise<Response>;
137
- requestOtp(body: any, incomingHeaders: Headers): Promise<Response>;
138
- verifyOtp(body: any, incomingHeaders: Headers): Promise<Response>;
144
+ requestOtp(body: RequestOtpBody, incomingHeaders: Headers): Promise<Response>;
145
+ verifyOtp(body: VerifyOtpBody, incomingHeaders: Headers): Promise<Response>;
146
+ forgotPassword(email: string, incomingHeaders: Headers): Promise<Response>;
147
+ verifyEmail(body: VerifyEmailBody, incomingHeaders: Headers): Promise<Response>;
148
+ resendVerification(email: string, incomingHeaders: Headers): Promise<Response>;
149
+ resetPassword(email: string, otp: string, newPassword: string, incomingHeaders: Headers): Promise<Response>;
150
+ listSessions(incomingHeaders: Headers): Promise<Response>;
151
+ revokeSession(sessionId: string, incomingHeaders: Headers): Promise<Response>;
152
+ revokeAllSessions(incomingHeaders: Headers): Promise<Response>;
153
+ }
154
+ export declare const CSRF_PROTECTED_PATHS: ReadonlySet<string>;
155
+ export declare const OAUTH_PROVIDERS: ReadonlySet<string>;
156
+ export interface RequestOtpBody {
157
+ email: string;
158
+ }
159
+ export interface VerifyOtpBody {
160
+ email: string;
161
+ otp: string;
162
+ }
163
+ export interface VerifyEmailBody {
164
+ email: string;
165
+ otp: string;
166
+ }
167
+ export interface RequestResetBody {
168
+ email: string;
169
+ }
170
+ export interface ResetPasswordBody {
171
+ email: string;
172
+ otp: string;
173
+ newPassword: string;
174
+ }
175
+ export interface SessionSummary {
176
+ id: string;
177
+ userAgent: string;
178
+ ipAddress: string;
179
+ createdAt: string;
180
+ lastActiveAt: string;
181
+ isCurrent: boolean;
182
+ }
183
+ export interface ListSessionsResponse {
184
+ sessions: SessionSummary[];
185
+ }
186
+ export interface RevokeAllSessionsResponse {
187
+ success: boolean;
188
+ sessionsRevoked: number;
139
189
  }
@@ -1,7 +1,8 @@
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 = void 0;
4
4
  exports.toIdentity = toIdentity;
5
+ exports.retryAfterSeconds = retryAfterSeconds;
5
6
  // src/core/index.ts
6
7
  const jose_1 = require("jose");
7
8
  /** Maps gateway token claims → CoreIAMIdentity (same shape the Next.js middleware injects). */
@@ -13,6 +14,17 @@ function toIdentity(payload) {
13
14
  roles: Array.isArray(payload.roles) ? payload.roles : [],
14
15
  };
15
16
  }
17
+ function retryAfterSeconds(body) {
18
+ const msg = body?.message;
19
+ for (const m of Array.isArray(msg) ? msg : [msg]) {
20
+ if (typeof m === 'string') {
21
+ const match = m.match(/Try again in (\d+) seconds?/);
22
+ if (match)
23
+ return Number(match[1]);
24
+ }
25
+ }
26
+ return undefined;
27
+ }
16
28
  /**
17
29
  export class CoreIAM {
18
30
  private apiKey: string = '';
@@ -72,14 +84,30 @@ class CoreIAM {
72
84
  * this instance's API key. Server-to-server, read-only.
73
85
  * THROWS on failure — an audit must never silently see an empty roster.
74
86
  */
75
- async listIdentities() {
76
- const response = await this.proxy('/api/v1/directory', { method: 'GET' }, new Headers());
87
+ async listIdentities(params) {
88
+ const search = new URLSearchParams();
89
+ if (params?.limit != null)
90
+ search.set('limit', String(params.limit));
91
+ if (params?.offset != null)
92
+ search.set('offset', String(params.offset));
93
+ if (params?.roles?.length)
94
+ search.set('roles', params.roles.join(','));
95
+ const qs = search.toString();
96
+ const response = await this.proxy(`/api/v1/directory${qs ? `?${qs}` : ''}`, { method: 'GET' }, new Headers());
77
97
  if (!response.ok) {
78
98
  const body = await response.text().catch(() => '');
79
99
  throw new Error(`listIdentities failed: ${response.status} ${body}`);
80
100
  }
81
- const data = await response.json();
82
- return { users: data.users ?? [], total: data.total ?? 0 };
101
+ const data = await response.json().catch(() => null);
102
+ if (!data || !Array.isArray(data.users)) {
103
+ throw new Error(`listIdentities: unexpected response shape — HTTP 200 without a users array. ` +
104
+ `This is not an empty directory; verify COREIAM_API_URL points at the CoreIAM gateway.`);
105
+ }
106
+ return {
107
+ users: data.users,
108
+ total: typeof data.total === 'number' ? data.total : data.users.length,
109
+ ...(typeof data.next_offset === 'number' ? { next_offset: data.next_offset } : {}),
110
+ };
83
111
  }
84
112
  readCookieHeader(input) {
85
113
  if (typeof input === 'string')
@@ -88,15 +116,33 @@ class CoreIAM {
88
116
  return input.get('cookie') || '';
89
117
  return input.cookie || '';
90
118
  }
119
+ // private readSessionCookie(input: SessionInput): string | null {
120
+ // const raw = this.readCookieHeader(input);
121
+ // if (!raw) return null;
122
+ // // Anchored to a real cookie-name boundary (same pattern proxy() uses),
123
+ // // so a cookie named "notjwt" or "xjwt" can never match.
124
+ // const escaped = this.sessionCookieName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
125
+ // const match = raw.match(new RegExp(`(?:^|;\\s*)${escaped}=([^;]+)`));
126
+ // return match ? decodeURIComponent(match[1]) : null;
127
+ // }
128
+ // PATCH 1 — readSessionCookie: a corrupt cookie value (%E0%A4%A) currently
129
+ // throws URIError out of verifySession → 500 on /session. A corrupt value is
130
+ // an invalid token, not a server error: return it raw; jwtVerify classifies
131
+ // it as 'invalid_signature'.
91
132
  readSessionCookie(input) {
92
133
  const raw = this.readCookieHeader(input);
93
134
  if (!raw)
94
135
  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
136
  const escaped = this.sessionCookieName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
98
137
  const match = raw.match(new RegExp(`(?:^|;\\s*)${escaped}=([^;]+)`));
99
- return match ? decodeURIComponent(match[1]) : null;
138
+ if (!match)
139
+ return null;
140
+ try {
141
+ return decodeURIComponent(match[1]);
142
+ }
143
+ catch {
144
+ return match[1];
145
+ }
100
146
  }
101
147
  async verifySession(input) {
102
148
  const token = this.readSessionCookie(input);
@@ -305,8 +351,22 @@ class CoreIAM {
305
351
  const existingCookieStr = incomingHeaders.get('cookie') || '';
306
352
  const newCookiePairs = csrfCookies.map(c => c.split(';')[0]);
307
353
  if (newCookiePairs.length > 0) {
308
- const combinedCookies = [existingCookieStr, ...newCookiePairs].filter(Boolean).join('; ');
309
- authHeaders.set('cookie', combinedCookies);
354
+ // FIX (SDK-4): dedupe by cookie NAME, newest value wins. The old
355
+ // [existing, ...new].join() emitted 'csrfToken=old; csrfToken=new' once
356
+ // the browser jar already held a csrf cookie and the handshake rotated
357
+ // it — whether the backend parser reads the first or last duplicate
358
+ // decided 403-or-pass. Nondeterministic across parsers is a bug.
359
+ const jar = new Map();
360
+ for (const pair of [...existingCookieStr.split(';'), ...newCookiePairs]) {
361
+ const trimmed = pair.trim();
362
+ if (!trimmed)
363
+ continue;
364
+ const eq = trimmed.indexOf('=');
365
+ if (eq === -1)
366
+ continue;
367
+ jar.set(trimmed.slice(0, eq), trimmed.slice(eq + 1));
368
+ }
369
+ authHeaders.set('cookie', [...jar.entries()].map(([k, v]) => `${k}=${v}`).join('; '));
310
370
  }
311
371
  return this.proxy(path, init, authHeaders);
312
372
  }
@@ -331,6 +391,13 @@ class CoreIAM {
331
391
  body: JSON.stringify(updateData),
332
392
  }, incomingHeaders);
333
393
  }
394
+ async changePassword(currentPassword, newPassword, incomingHeaders) {
395
+ return this.proxyWithCsrfProtection('/auth/password', {
396
+ method: 'PUT',
397
+ headers: { 'Content-Type': 'application/json' },
398
+ body: JSON.stringify({ currentPassword, newPassword }),
399
+ }, incomingHeaders);
400
+ }
334
401
  async setupMfa(incomingHeaders) {
335
402
  return this.proxyWithCsrfProtection('/auth/mfa/setup', { method: 'POST' }, incomingHeaders);
336
403
  }
@@ -358,5 +425,77 @@ class CoreIAM {
358
425
  body: JSON.stringify(body),
359
426
  }, incomingHeaders);
360
427
  }
428
+ // W3 beta.6: forgot-password / reset-password. Both go through the same
429
+ // CSRF funnel as every other CsrfGuard-decorated endpoint — the paths are
430
+ // pre-registered in CSRF_PROTECTED_PATHS (SDK-6), so the middleware proxy
431
+ // and these methods stay in parity.
432
+ async forgotPassword(email, incomingHeaders) {
433
+ return this.proxyWithCsrfProtection('/auth/request-reset', {
434
+ method: 'POST',
435
+ headers: { 'Content-Type': 'application/json' },
436
+ body: JSON.stringify({ email }),
437
+ }, incomingHeaders);
438
+ }
439
+ async verifyEmail(body, incomingHeaders) {
440
+ return this.proxyWithCsrfProtection('/auth/verify-email', {
441
+ method: 'POST',
442
+ headers: { 'Content-Type': 'application/json' },
443
+ body: JSON.stringify(body),
444
+ }, incomingHeaders);
445
+ }
446
+ async resendVerification(email, incomingHeaders) {
447
+ return this.proxyWithCsrfProtection('/auth/resend-verification', {
448
+ method: 'POST',
449
+ headers: { 'Content-Type': 'application/json' },
450
+ body: JSON.stringify({ email }),
451
+ }, incomingHeaders);
452
+ }
453
+ async resetPassword(email, otp, newPassword, incomingHeaders) {
454
+ return this.proxyWithCsrfProtection('/auth/reset-password', {
455
+ method: 'POST',
456
+ headers: { 'Content-Type': 'application/json' },
457
+ body: JSON.stringify({ email, otp, newPassword }),
458
+ }, incomingHeaders);
459
+ }
460
+ // W4 (beta.7): session management surface. listSessions is a plain
461
+ // authenticated GET (same shape as the /auth/profile GET precedent —
462
+ // JWT comes from the forwarded cookie via proxy(), reads carry no CSRF
463
+ // requirement); the two state-changing calls go through the shared CSRF
464
+ // handshake exactly like changePassword.
465
+ async listSessions(incomingHeaders) {
466
+ return this.proxy('/auth/sessions', { method: 'GET' }, incomingHeaders);
467
+ }
468
+ async revokeSession(sessionId, incomingHeaders) {
469
+ // Same rationale as OAUTH_PROVIDERS (SDK-5): never interpolate an
470
+ // unvalidated path segment into a URL. The backend's ParseUUIDPipe
471
+ // would reject garbage with a 400 anyway — this fails fast
472
+ // client-side and can never become a traversal string.
473
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(sessionId)) {
474
+ throw new Error('revokeSession: sessionId must be a UUID');
475
+ }
476
+ return this.proxyWithCsrfProtection(`/auth/sessions/${sessionId}`, { method: 'DELETE' }, incomingHeaders);
477
+ }
478
+ async revokeAllSessions(incomingHeaders) {
479
+ return this.proxyWithCsrfProtection('/auth/sessions/revoke-all', { method: 'POST' }, incomingHeaders);
480
+ }
361
481
  }
362
482
  exports.CoreIAM = CoreIAM;
483
+ exports.CSRF_PROTECTED_PATHS = new Set([
484
+ '/auth/login', '/auth/register', '/auth/discover-tenants',
485
+ '/auth/request-otp', '/auth/verify-otp', '/auth/mfa/login', '/auth/verify-email', '/auth/resend-verification',
486
+ '/auth/profile', '/auth/mfa/setup', '/auth/mfa/verify', '/auth/mfa/disable',
487
+ '/auth/password',
488
+ '/auth/request-reset', '/auth/reset-password',
489
+ // W4 (beta.7): revoke-all is exact-matched. The per-session revoke is a
490
+ // DYNAMIC path (/auth/sessions/<uuid>) a string Set cannot match — the SDK
491
+ // methods self-handshake via proxyWithCsrfProtection so server-action
492
+ // callers are fully covered; middleware-proxied browser-direct DELETEs to
493
+ // /api/auth/sessions/<uuid> are not auto-handshaked (known limitation; the
494
+ // fix is a prefix matcher inside middleware.ts, deliberately deferred to
495
+ // keep this patch off that file).
496
+ '/auth/sessions/revoke-all',
497
+ ]);
498
+ // FIX (SDK-5): single source of truth for supported OAuth providers. Every
499
+ // place that interpolates a provider segment into a backend URL must check
500
+ // this set first — an unvalidated path segment is a traversal surface.
501
+ 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) {
@@ -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>;