@coreiam/auth 1.0.392-beta.2 → 1.0.392-beta.3

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.
@@ -142,3 +142,4 @@ export declare class CoreIAM {
142
142
  requestOtp(body: any, incomingHeaders: Headers): Promise<Response>;
143
143
  verifyOtp(body: any, incomingHeaders: Headers): Promise<Response>;
144
144
  }
145
+ export declare const CSRF_PROTECTED_PATHS: ReadonlySet<string>;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.CoreIAM = void 0;
3
+ exports.CSRF_PROTECTED_PATHS = exports.CoreIAM = void 0;
4
4
  exports.toIdentity = toIdentity;
5
5
  // src/core/index.ts
6
6
  const jose_1 = require("jose");
@@ -104,15 +104,33 @@ class CoreIAM {
104
104
  return input.get('cookie') || '';
105
105
  return input.cookie || '';
106
106
  }
107
+ // private readSessionCookie(input: SessionInput): string | null {
108
+ // const raw = this.readCookieHeader(input);
109
+ // if (!raw) return null;
110
+ // // Anchored to a real cookie-name boundary (same pattern proxy() uses),
111
+ // // so a cookie named "notjwt" or "xjwt" can never match.
112
+ // const escaped = this.sessionCookieName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
113
+ // const match = raw.match(new RegExp(`(?:^|;\\s*)${escaped}=([^;]+)`));
114
+ // return match ? decodeURIComponent(match[1]) : null;
115
+ // }
116
+ // PATCH 1 — readSessionCookie: a corrupt cookie value (%E0%A4%A) currently
117
+ // throws URIError out of verifySession → 500 on /session. A corrupt value is
118
+ // an invalid token, not a server error: return it raw; jwtVerify classifies
119
+ // it as 'invalid_signature'.
107
120
  readSessionCookie(input) {
108
121
  const raw = this.readCookieHeader(input);
109
122
  if (!raw)
110
123
  return null;
111
- // Anchored to a real cookie-name boundary (same pattern proxy() uses),
112
- // so a cookie named "notjwt" or "xjwt" can never match.
113
124
  const escaped = this.sessionCookieName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
114
125
  const match = raw.match(new RegExp(`(?:^|;\\s*)${escaped}=([^;]+)`));
115
- return match ? decodeURIComponent(match[1]) : null;
126
+ if (!match)
127
+ return null;
128
+ try {
129
+ return decodeURIComponent(match[1]);
130
+ }
131
+ catch {
132
+ return match[1];
133
+ }
116
134
  }
117
135
  async verifySession(input) {
118
136
  const token = this.readSessionCookie(input);
@@ -376,3 +394,8 @@ class CoreIAM {
376
394
  }
377
395
  }
378
396
  exports.CoreIAM = CoreIAM;
397
+ exports.CSRF_PROTECTED_PATHS = new Set([
398
+ '/auth/login', '/auth/register', '/auth/discover-tenants',
399
+ '/auth/request-otp', '/auth/verify-otp', '/auth/mfa/login',
400
+ '/auth/profile', '/auth/mfa/setup', '/auth/mfa/verify', '/auth/mfa/disable',
401
+ ]);
@@ -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
+ }
@@ -275,13 +275,21 @@ async function coreIamAuth(request) {
275
275
  const code = searchParams.get('code');
276
276
  const state = searchParams.get('state');
277
277
  const redirectUri = `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/callback/${provider}`;
278
- targetPath = `/auth/oauth/callback/${provider}?code=${code}&state=${state}&redirectUri=${redirectUri}`;
278
+ // targetPath = `/auth/oauth/callback/${provider}?code=${code}&state=${state}&redirectUri=${redirectUri}`;
279
+ // OAuth callback targetPath — encode all three:
280
+ targetPath = `/auth/oauth/callback/${provider}`
281
+ + `?code=${encodeURIComponent(code ?? '')}`
282
+ + `&state=${encodeURIComponent(state ?? '')}`
283
+ + `&redirectUri=${encodeURIComponent(redirectUri)}`;
279
284
  }
280
- const response = await iam.proxy(targetPath, {
285
+ const init = {
281
286
  method,
282
287
  body: body ? JSON.stringify(body) : undefined,
283
288
  headers: { 'Content-Type': 'application/json' },
284
- }, request.headers);
289
+ };
290
+ const response = core_1.CSRF_PROTECTED_PATHS.has(targetPath)
291
+ ? await iam.proxyWithCsrfProtection(targetPath, init, request.headers)
292
+ : await iam.proxy(targetPath, init, request.headers);
285
293
  // Handle OAuth Initiate Redirects
286
294
  if (pathname.startsWith('/api/auth/oauth/') && !pathname.includes('callback') && !pathname.includes('link')) {
287
295
  const data = await response.json().catch(() => ({}));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coreiam/auth",
3
- "version": "1.0.392-beta.2",
3
+ "version": "1.0.392-beta.3",
4
4
  "main": "./dist/core/index.js",
5
5
  "types": "./dist/core/index.d.ts",
6
6
  "exports": {
@@ -37,17 +37,24 @@
37
37
  "auth",
38
38
  "saas",
39
39
  "multi-tenant",
40
- "nextjs"
40
+ "nextjs",
41
+ "hono",
42
+ "react",
43
+ "vite",
44
+ "express"
41
45
  ],
42
46
  "author": "coreIAM",
43
47
  "license": "MIT",
44
48
  "publishConfig": {
45
49
  "access": "public"
46
50
  },
51
+ "./hono": { "types": "./dist/hono/index.d.ts", "import": "./dist/hono/index.js", "require": "./dist/hono/index.js" },
52
+ "./ui": { "types": "./dist/ui/buttons.d.ts", "import": "./dist/ui/buttons.js", "require": "./dist/ui/buttons.js" },
47
53
  "peerDependencies": {
48
54
  "express": "^4.0.0 || ^5.0.0",
49
55
  "next": ">=14",
50
- "react": ">=18"
56
+ "react": ">=18",
57
+ "hono": ">=4"
51
58
  },
52
59
  "peerDependenciesMeta": {
53
60
  "next": {
@@ -58,6 +65,9 @@
58
65
  },
59
66
  "express": {
60
67
  "optional": true
68
+ },
69
+ "hono": {
70
+ "optional": true
61
71
  }
62
72
  },
63
73
  "devDependencies": {
@@ -69,7 +79,8 @@
69
79
  "react": "^19.2.7",
70
80
  "supertest": "^7.3.0",
71
81
  "typescript": "^5.0.0",
72
- "vitest": "^3.2.7"
82
+ "vitest": "^3.2.7",
83
+ "hono": "^4.6.0"
73
84
  },
74
85
  "dependencies": {
75
86
  "@radix-ui/react-slot": "^1.3.3",