@coreiam/auth 1.0.39 → 1.0.392-beta.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.
@@ -1,7 +1,16 @@
1
+ import { type JWTPayload } from 'jose';
1
2
  export interface CoreIAMConfig {
2
3
  baseUrl?: string;
3
4
  apiKey?: string;
4
5
  tenantId?: string;
6
+ /** Expected token issuer. Default: 'coreiam'. */
7
+ issuer?: string;
8
+ /** Optional strict audience check — off by default, matches current gateway tokens. */
9
+ audience?: string;
10
+ /** Session cookie name. Default: 'jwt'. */
11
+ sessionCookieName?: string;
12
+ /** JWKS path on the gateway. Default: '/.well-known/jwks.json'. */
13
+ jwksPath?: string;
5
14
  }
6
15
  export interface CoreIAMIdentity {
7
16
  sub: string;
@@ -10,14 +19,100 @@ export interface CoreIAMIdentity {
10
19
  roles?: string[];
11
20
  [key: string]: any;
12
21
  }
22
+ export interface DirectoryIdentity {
23
+ sub: string;
24
+ email?: string;
25
+ first_name?: string;
26
+ last_name?: string;
27
+ is_active?: boolean;
28
+ roles: string[];
29
+ }
30
+ /** Accepts a Headers instance, an Express req.headers object, or a raw Cookie string. */
31
+ export type SessionInput = Headers | Record<string, string | string[] | undefined> | string;
32
+ export type SessionFailureReason = 'no_cookie' | 'expired' | 'invalid_claims' | 'invalid_signature' | 'jwks_unavailable';
33
+ export interface SessionResult {
34
+ identity: CoreIAMIdentity | null;
35
+ reason?: SessionFailureReason;
36
+ }
37
+ export interface RefreshSessionResult {
38
+ identity: CoreIAMIdentity | null;
39
+ /** Raw Set-Cookie strings to apply on your response (rotated jwt/refreshToken). */
40
+ setCookies: string[] | null;
41
+ }
42
+ /** Maps gateway token claims → CoreIAMIdentity (same shape the Next.js middleware injects). */
43
+ export declare function toIdentity(payload: JWTPayload): CoreIAMIdentity;
44
+ /**
45
+ export class CoreIAM {
46
+ private apiKey: string = '';
47
+ private baseUrl: string = '';
48
+
49
+ constructor(config?: { apiKey?: string; baseUrl?: string }) {
50
+ this.apiKey = config?.apiKey || process.env.COREIAM_API_KEY || '';
51
+ // FIX: Removed hardcoded URL. Throw if missing.
52
+ this.baseUrl = config?.baseUrl || process.env.COREIAM_API_URL || '';
53
+
54
+ if (!this.apiKey) throw new Error('CoreIAM: Missing API Key (COREIAM_API_KEY)');
55
+ if (!this.baseUrl) throw new Error('CoreIAM: Missing Gateway URL (COREIAM_API_URL)');
56
+ }
57
+ */
13
58
  export declare class CoreIAM {
14
59
  private apiKey;
15
60
  private baseUrl;
16
- constructor(config?: {
17
- apiKey?: string;
18
- baseUrl?: string;
19
- });
20
- refreshTokens(headers: Headers): Promise<string[] | null>;
61
+ private issuer;
62
+ private audience?;
63
+ private sessionCookieName;
64
+ private jwksPath;
65
+ private jwks;
66
+ private refreshInFlight;
67
+ constructor(config?: CoreIAMConfig);
68
+ private getJwks;
69
+ /** Optional warm-up: call at boot so the first verification pays no JWKS latency. */
70
+ preloadJwks(): Promise<void>;
71
+ /**
72
+ * Tenant directory: all identities (with roles) for the tenant scoped to
73
+ * this instance's API key. Server-to-server, read-only.
74
+ * THROWS on failure — an audit must never silently see an empty roster.
75
+ */
76
+ listIdentities(): Promise<{
77
+ users: DirectoryIdentity[];
78
+ total: number;
79
+ }>;
80
+ private readCookieHeader;
81
+ private readSessionCookie;
82
+ verifySession(input: SessionInput): Promise<SessionResult>;
83
+ getSession(input: SessionInput): Promise<CoreIAMIdentity | null>;
84
+ /**
85
+ * One-call silent refresh for non-Next servers: posts the refresh cookie
86
+ * to the gateway, verifies the rotated access token, and returns the new
87
+ * Set-Cookie strings so the caller can forward them on its response.
88
+ */
89
+ refreshSession(input: SessionInput): Promise<RefreshSessionResult>;
90
+ refreshTokens(input: SessionInput): Promise<string[] | null>;
91
+ private doRefresh;
92
+ /**
93
+ async refreshTokens(headers: Headers): Promise<string[] | null> {
94
+ try {
95
+ const response = await fetch(`${this.baseUrl}/auth/refresh-token`, {
96
+ method: 'POST',
97
+ headers: {
98
+ 'X-API-Key': this.apiKey,
99
+ 'cookie': headers.get('cookie') || '',
100
+ },
101
+ });
102
+
103
+ if (response.ok) {
104
+ const setCookies = response.headers.getSetCookie();
105
+ if (setCookies && setCookies.length > 0) {
106
+ return setCookies;
107
+ }
108
+ }
109
+ return null;
110
+ } catch (error) {
111
+ return null;
112
+ }
113
+ }
114
+
115
+ */
21
116
  proxy(path: string, init: RequestInit, incomingHeaders: Headers): Promise<Response>;
22
117
  getCsrfToken(headers: Headers): Promise<Response>;
23
118
  login(body: any, headers: Headers): Promise<Response>;
@@ -1,41 +1,203 @@
1
1
  "use strict";
2
- // src/core/index.ts
3
2
  Object.defineProperty(exports, "__esModule", { value: true });
4
3
  exports.CoreIAM = void 0;
4
+ exports.toIdentity = toIdentity;
5
+ // src/core/index.ts
6
+ const jose_1 = require("jose");
7
+ /** Maps gateway token claims → CoreIAMIdentity (same shape the Next.js middleware injects). */
8
+ function toIdentity(payload) {
9
+ return {
10
+ sub: payload.sub,
11
+ email: payload.email,
12
+ tenantId: payload.tenantId,
13
+ roles: Array.isArray(payload.roles) ? payload.roles : [],
14
+ };
15
+ }
16
+ /**
17
+ export class CoreIAM {
18
+ private apiKey: string = '';
19
+ private baseUrl: string = '';
20
+
21
+ constructor(config?: { apiKey?: string; baseUrl?: string }) {
22
+ this.apiKey = config?.apiKey || process.env.COREIAM_API_KEY || '';
23
+ // FIX: Removed hardcoded URL. Throw if missing.
24
+ this.baseUrl = config?.baseUrl || process.env.COREIAM_API_URL || '';
25
+
26
+ if (!this.apiKey) throw new Error('CoreIAM: Missing API Key (COREIAM_API_KEY)');
27
+ if (!this.baseUrl) throw new Error('CoreIAM: Missing Gateway URL (COREIAM_API_URL)');
28
+ }
29
+ */
5
30
  class CoreIAM {
6
31
  apiKey = '';
7
32
  baseUrl = '';
33
+ issuer = 'coreiam';
34
+ audience;
35
+ sessionCookieName = 'jwt';
36
+ jwksPath = '/.well-known/jwks.json';
37
+ jwks = null;
38
+ refreshInFlight = null;
8
39
  constructor(config) {
9
40
  this.apiKey = config?.apiKey || process.env.COREIAM_API_KEY || '';
10
- // FIX: Removed hardcoded URL. Throw if missing.
11
- this.baseUrl = config?.baseUrl || process.env.COREIAM_API_URL || '';
41
+ this.baseUrl = (config?.baseUrl || process.env.COREIAM_API_URL || '').replace(/\/+$/, '');
42
+ this.issuer = config?.issuer ?? 'coreiam';
43
+ this.audience = config?.audience;
44
+ this.sessionCookieName = config?.sessionCookieName ?? 'jwt';
45
+ this.jwksPath = config?.jwksPath ?? '/.well-known/jwks.json';
12
46
  if (!this.apiKey)
13
47
  throw new Error('CoreIAM: Missing API Key (COREIAM_API_KEY)');
14
48
  if (!this.baseUrl)
15
49
  throw new Error('CoreIAM: Missing Gateway URL (COREIAM_API_URL)');
16
50
  }
17
- // Exposed publicly so the Next.js layer can use it for getSession()
18
- async refreshTokens(headers) {
51
+ getJwks() {
52
+ // Lazy singleton: jose fetches once, caches, and handles key rotation.
53
+ this.jwks ??= (0, jose_1.createRemoteJWKSet)(new URL(`${this.baseUrl}${this.jwksPath}`), {
54
+ cooldownDuration: 30_000,
55
+ cacheMaxAge: 10 * 60_000,
56
+ });
57
+ return this.jwks;
58
+ }
59
+ /** Optional warm-up: call at boot so the first verification pays no JWKS latency. */
60
+ async preloadJwks() {
61
+ try {
62
+ // Deliberately look up a key that won't exist — jose fetches and
63
+ // caches the whole key set on the first lookup, which is the warm-up.
64
+ await this.getJwks()({ alg: 'RS256', kid: '__preload__' }, {});
65
+ }
66
+ catch {
67
+ /* expected: no matching key — the fetch is the point */
68
+ }
69
+ }
70
+ /**
71
+ * Tenant directory: all identities (with roles) for the tenant scoped to
72
+ * this instance's API key. Server-to-server, read-only.
73
+ * THROWS on failure — an audit must never silently see an empty roster.
74
+ */
75
+ async listIdentities() {
76
+ const response = await this.proxy('/api/v1/directory', { method: 'GET' }, new Headers());
77
+ if (!response.ok) {
78
+ const body = await response.text().catch(() => '');
79
+ throw new Error(`listIdentities failed: ${response.status} ${body}`);
80
+ }
81
+ const data = await response.json();
82
+ return { users: data.users ?? [], total: data.total ?? 0 };
83
+ }
84
+ readCookieHeader(input) {
85
+ if (typeof input === 'string')
86
+ return input;
87
+ if (typeof input.get === 'function')
88
+ return input.get('cookie') || '';
89
+ return input.cookie || '';
90
+ }
91
+ readSessionCookie(input) {
92
+ const raw = this.readCookieHeader(input);
93
+ if (!raw)
94
+ 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
+ const escaped = this.sessionCookieName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
98
+ const match = raw.match(new RegExp(`(?:^|;\\s*)${escaped}=([^;]+)`));
99
+ return match ? decodeURIComponent(match[1]) : null;
100
+ }
101
+ async verifySession(input) {
102
+ const token = this.readSessionCookie(input);
103
+ if (!token)
104
+ return { identity: null, reason: 'no_cookie' };
105
+ try {
106
+ const { payload } = await (0, jose_1.jwtVerify)(token, this.getJwks(), {
107
+ algorithms: ['RS256'], // pinned — blocks alg confusion
108
+ issuer: this.issuer,
109
+ ...(this.audience ? { audience: this.audience } : {}),
110
+ });
111
+ return { identity: toIdentity(payload) };
112
+ }
113
+ catch (error) {
114
+ if (error instanceof jose_1.errors.JWTExpired)
115
+ return { identity: null, reason: 'expired' };
116
+ if (error instanceof jose_1.errors.JWTClaimValidationFailed)
117
+ return { identity: null, reason: 'invalid_claims' };
118
+ // JOSE errors = the token itself is bad. Anything else (fetch failure
119
+ // while getting the JWKS) = we couldn't check — that is not a sign-out.
120
+ if (error instanceof jose_1.errors.JOSEError)
121
+ return { identity: null, reason: 'invalid_signature' };
122
+ return { identity: null, reason: 'jwks_unavailable' };
123
+ }
124
+ }
125
+ async getSession(input) {
126
+ return (await this.verifySession(input)).identity;
127
+ }
128
+ /**
129
+ * One-call silent refresh for non-Next servers: posts the refresh cookie
130
+ * to the gateway, verifies the rotated access token, and returns the new
131
+ * Set-Cookie strings so the caller can forward them on its response.
132
+ */
133
+ async refreshSession(input) {
134
+ const setCookies = await this.refreshTokens(input);
135
+ if (!setCookies)
136
+ return { identity: null, setCookies: null };
137
+ const rotated = setCookies
138
+ .find(c => c.startsWith(`${this.sessionCookieName}=`))
139
+ ?.split(';')[0]; // "jwt=<new value>" — a valid single-cookie header string
140
+ if (rotated) {
141
+ const { identity } = await this.verifySession(rotated);
142
+ if (identity)
143
+ return { identity, setCookies };
144
+ }
145
+ return { identity: null, setCookies };
146
+ }
147
+ async refreshTokens(input) {
148
+ // Single-flight: concurrent requests share one gateway refresh call
149
+ // (matters with rotating refresh tokens). Input widened from Headers
150
+ // to SessionInput — backwards compatible.
151
+ this.refreshInFlight ??= this.doRefresh(input).finally(() => {
152
+ this.refreshInFlight = null;
153
+ });
154
+ return this.refreshInFlight;
155
+ }
156
+ async doRefresh(input) {
19
157
  try {
20
158
  const response = await fetch(`${this.baseUrl}/auth/refresh-token`, {
21
159
  method: 'POST',
22
160
  headers: {
23
161
  'X-API-Key': this.apiKey,
24
- 'cookie': headers.get('cookie') || '',
162
+ 'cookie': this.readCookieHeader(input),
25
163
  },
26
164
  });
27
165
  if (response.ok) {
28
166
  const setCookies = response.headers.getSetCookie();
29
- if (setCookies && setCookies.length > 0) {
167
+ if (setCookies && setCookies.length > 0)
30
168
  return setCookies;
31
- }
32
169
  }
33
170
  return null;
34
171
  }
35
- catch (error) {
172
+ catch {
36
173
  return null;
37
174
  }
38
175
  }
176
+ // Exposed publicly so the Next.js layer can use it for getSession()
177
+ /**
178
+ async refreshTokens(headers: Headers): Promise<string[] | null> {
179
+ try {
180
+ const response = await fetch(`${this.baseUrl}/auth/refresh-token`, {
181
+ method: 'POST',
182
+ headers: {
183
+ 'X-API-Key': this.apiKey,
184
+ 'cookie': headers.get('cookie') || '',
185
+ },
186
+ });
187
+
188
+ if (response.ok) {
189
+ const setCookies = response.headers.getSetCookie();
190
+ if (setCookies && setCookies.length > 0) {
191
+ return setCookies;
192
+ }
193
+ }
194
+ return null;
195
+ } catch (error) {
196
+ return null;
197
+ }
198
+ }
199
+
200
+ */
39
201
  async proxy(path, init, incomingHeaders) {
40
202
  const url = `${this.baseUrl}${path}`;
41
203
  const headers = new Headers(init.headers);
@@ -0,0 +1,26 @@
1
+ import { Router, type RequestHandler } from 'express';
2
+ import { type CoreIAMConfig } from '../core';
3
+ export interface CoreIamExpressOptions extends CoreIAMConfig {
4
+ /**
5
+ * Public URL of the app as the user's browser reaches it (e.g. http://localhost:5173
6
+ * in Vite dev, or your production domain). /api/auth must route from this origin
7
+ * to this Express server (reverse proxy in prod, Vite dev proxy in dev).
8
+ */
9
+ appUrl: string;
10
+ /** Mount path of the auth router. Default: '/api/auth'. */
11
+ authPath?: string;
12
+ /** Redirect target after successful OAuth login. Default: '/'. */
13
+ successUrl?: string;
14
+ /** Redirect target when OAuth initiation fails. Default: '/login'. */
15
+ signInUrl?: string;
16
+ }
17
+ export declare function coreIamExpress(options: CoreIamExpressOptions): Router;
18
+ /**
19
+ * Route guard: verifies the session cookie locally and puts the identity on
20
+ * res.locals.identity. 401s API requests; redirects page requests.
21
+ */
22
+ export declare function requireAuth(config?: CoreIAMConfig & {
23
+ signInUrl?: string;
24
+ }): RequestHandler;
25
+ export { CoreIAM } from '../core';
26
+ export type { CoreIAMIdentity } from '../core';
@@ -0,0 +1,171 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CoreIAM = void 0;
4
+ exports.coreIamExpress = coreIamExpress;
5
+ exports.requireAuth = requireAuth;
6
+ // src/express/index.ts
7
+ const express_1 = require("express");
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
+ /** Express 4 does not catch async rejections — route them to next(err). */
15
+ const wrap = (fn) => (req, res, next) => { fn(req, res, next).catch(next); };
16
+ function sessionCookie(name, value, maxAgeSec, isProd) {
17
+ return `${name}=${encodeURIComponent(value)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAgeSec}${isProd ? '; Secure' : ''}`;
18
+ }
19
+ function coreIamExpress(options) {
20
+ const appUrl = options.appUrl.replace(/\/+$/, '');
21
+ const authPath = (options.authPath ?? '/api/auth').replace(/\/+$/, '');
22
+ const successUrl = options.successUrl ?? '/';
23
+ const signInUrl = options.signInUrl ?? '/login';
24
+ const gatewayUrl = (options.baseUrl || process.env.COREIAM_API_URL || '').replace(/\/+$/, '');
25
+ const isProd = process.env.NODE_ENV === 'production';
26
+ const iam = new core_1.CoreIAM(options);
27
+ const router = (0, express_1.Router)();
28
+ // Body parsing for JSON + the urlencoded link-confirm post.
29
+ // Skips automatically if the host app already parsed the body.
30
+ router.use((0, express_1.json)());
31
+ router.use((0, express_1.urlencoded)({ extended: false }));
32
+ const toHeaders = (req) => new Headers(req.headers);
33
+ // ---------------------------------------------------------------
34
+ // GET {authPath}/session — matches the React provider's default
35
+ // sessionUrl ('/api/auth/session'). Local JWKS verify + silent refresh.
36
+ // ---------------------------------------------------------------
37
+ router.get('/session', wrap(async (req, res) => {
38
+ const result = await iam.verifySession(req.headers);
39
+ if (result.identity)
40
+ return void res.json(result.identity);
41
+ if (result.reason === 'expired') {
42
+ const { identity, setCookies } = await iam.refreshSession(req.headers);
43
+ if (identity) {
44
+ setCookies?.forEach(c => res.append('Set-Cookie', c));
45
+ return void res.json(identity);
46
+ }
47
+ }
48
+ res.status(401).json({ error: 'unauthenticated' });
49
+ }));
50
+ // ---------------------------------------------------------------
51
+ // OAuth initiate: GET {authPath}/oauth/:provider
52
+ // ---------------------------------------------------------------
53
+ router.get('/oauth/:provider', wrap(async (req, res, next) => {
54
+ const provider = req.params.provider;
55
+ if (provider === 'link')
56
+ return next(); // not an initiate route
57
+ const redirectUri = `${appUrl}${authPath}/callback/${provider}`;
58
+ const target = `/auth/oauth/initiate/${provider}?redirectUri=${encodeURIComponent(redirectUri)}&scopes=email,profile`;
59
+ const response = await iam.proxy(target, { method: 'GET' }, toHeaders(req));
60
+ const data = await response.json().catch(() => ({}));
61
+ if (data.url)
62
+ return void res.redirect(302, data.url);
63
+ res.redirect(302, signInUrl);
64
+ }));
65
+ // ---------------------------------------------------------------
66
+ // OAuth callback: GET {authPath}/callback/:provider
67
+ // ---------------------------------------------------------------
68
+ router.get('/callback/:provider', wrap(async (req, res) => {
69
+ const provider = req.params.provider;
70
+ const code = req.query.code;
71
+ const state = req.query.state;
72
+ const redirectUri = `${appUrl}${authPath}/callback/${provider}`;
73
+ const target = `/auth/oauth/callback/${provider}?code=${code}&state=${state}&redirectUri=${encodeURIComponent(redirectUri)}`;
74
+ const response = await iam.proxy(target, { method: 'GET' }, toHeaders(req));
75
+ const data = await response.json().catch(() => ({}));
76
+ // Account linking: redirect to CoreIAM's hosted confirmation page
77
+ if (data.action === 'link_candidate') {
78
+ const redirect = new URL(`${gatewayUrl}/auth/hosted/confirm-link`);
79
+ redirect.searchParams.set('token', data.tempToken);
80
+ redirect.searchParams.set('provider', data.provider);
81
+ redirect.searchParams.set('email', data.email || '');
82
+ redirect.searchParams.set('appOrigin', appUrl);
83
+ return void res.redirect(303, redirect.toString());
84
+ }
85
+ // Normal OAuth login: forward the gateway's session cookies, then redirect.
86
+ // (Set-Cookie must be appended BEFORE res.redirect — Express sends immediately.)
87
+ response.headers.getSetCookie().forEach(c => res.append('Set-Cookie', c));
88
+ res.redirect(302, successUrl);
89
+ }));
90
+ // ---------------------------------------------------------------
91
+ // Account-link confirmation: POST {authPath}/oauth/link/confirm
92
+ // (the hosted page posts tempToken + password back here)
93
+ // ---------------------------------------------------------------
94
+ router.post('/oauth/link/confirm', wrap(async (req, res) => {
95
+ const formData = (req.body ?? {});
96
+ const response = await iam.proxy('/auth/oauth/link/confirm', {
97
+ method: 'POST',
98
+ body: JSON.stringify({ tempToken: formData.tempToken, password: formData.password }),
99
+ headers: { 'Content-Type': 'application/json' },
100
+ }, toHeaders(req));
101
+ const data = await response.json().catch(() => ({}));
102
+ if (response.ok && data.success) {
103
+ const finalUrl = new URL(data.redirectUri || '/', appUrl).toString();
104
+ // Same cookie attributes the Next.js handler uses.
105
+ if (data.accessToken)
106
+ res.append('Set-Cookie', sessionCookie('jwt', data.accessToken, 24 * 60 * 60, isProd));
107
+ if (data.clientRefreshToken)
108
+ res.append('Set-Cookie', sessionCookie('refreshToken', data.clientRefreshToken, 7 * 24 * 60 * 60, isProd));
109
+ // 200 + JS redirect breaks the cross-site redirect chain so the browser
110
+ // sends the freshly-set (SameSite=Lax) cookies on the next hop.
111
+ return void res.status(200).type('html').send(`<!DOCTYPE html>
112
+ <html>
113
+ <head><title>Linking Successful</title></head>
114
+ <body>
115
+ <p>Account linked successfully! Redirecting...</p>
116
+ <script>window.location.href = ${JSON.stringify(finalUrl)};</script>
117
+ </body>
118
+ </html>`);
119
+ }
120
+ // Failed (wrong password) -> back to the hosted page with the error
121
+ const errorRedirect = new URL(`${gatewayUrl}/auth/hosted/confirm-link`);
122
+ errorRedirect.searchParams.set('token', formData.tempToken);
123
+ errorRedirect.searchParams.set('provider', data.provider || 'google');
124
+ errorRedirect.searchParams.set('email', data.email || '');
125
+ errorRedirect.searchParams.set('appOrigin', appUrl);
126
+ errorRedirect.searchParams.set('error', data.message || 'Invalid password. Link failed.');
127
+ res.redirect(303, errorRedirect.toString());
128
+ }));
129
+ // ---------------------------------------------------------------
130
+ // Everything else: generic JSON passthrough (login, register, logout,
131
+ // csrf-token, MFA, OTP, profile...). CSRF-protected endpoints run the
132
+ // handshake first, same as the Next.js server actions.
133
+ // ---------------------------------------------------------------
134
+ router.use(wrap(async (req, res) => {
135
+ const target = `/auth${req.path}`; // /login -> /auth/login
136
+ const init = { method: req.method };
137
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
138
+ init.body = req.body ? JSON.stringify(req.body) : undefined;
139
+ init.headers = { 'Content-Type': 'application/json' };
140
+ }
141
+ const response = CSRF_PROTECTED.has(target)
142
+ ? await iam.proxyWithCsrfProtection(target, init, toHeaders(req))
143
+ : await iam.proxy(target, init, toHeaders(req));
144
+ const data = await response.json().catch(() => ({}));
145
+ res.status(response.status);
146
+ response.headers.getSetCookie().forEach(c => res.append('Set-Cookie', c));
147
+ res.json(data);
148
+ }));
149
+ return router;
150
+ }
151
+ /**
152
+ * Route guard: verifies the session cookie locally and puts the identity on
153
+ * res.locals.identity. 401s API requests; redirects page requests.
154
+ */
155
+ function requireAuth(config) {
156
+ const iam = new core_1.CoreIAM(config);
157
+ const { signInUrl = '/login' } = config ?? {};
158
+ return wrap(async (req, res, next) => {
159
+ const identity = await iam.getSession(req.headers);
160
+ if (!identity) {
161
+ if (req.path.startsWith('/api') || req.accepts(['html', 'json']) === 'json') {
162
+ return void res.status(401).json({ error: 'unauthenticated' });
163
+ }
164
+ return void res.redirect(302, signInUrl);
165
+ }
166
+ res.locals.identity = identity;
167
+ next();
168
+ });
169
+ }
170
+ var core_2 = require("../core");
171
+ Object.defineProperty(exports, "CoreIAM", { enumerable: true, get: function () { return core_2.CoreIAM; } });
@@ -124,12 +124,21 @@ exports.coreIamMiddleware = coreIamMiddleware;
124
124
  // }
125
125
  // src/nextjs/middleware.ts
126
126
  const server_1 = require("next/server");
127
- const jose_1 = require("jose");
127
+ //import { jwtVerify, createRemoteJWKSet } from 'jose';
128
128
  const core_1 = require("../core");
129
129
  const gatewayUrl = process.env.COREIAM_API_URL;
130
+ /**
130
131
  const JWKS = gatewayUrl
131
- ? (0, jose_1.createRemoteJWKSet)(new URL(`${gatewayUrl}/.well-known/jwks.json`))
132
- : null;
132
+ ? createRemoteJWKSet(new URL(`${gatewayUrl}/.well-known/jwks.json`))
133
+ : null;
134
+ */
135
+ let iam = null;
136
+ function getIam() {
137
+ if (!gatewayUrl || !process.env.COREIAM_API_KEY)
138
+ return null;
139
+ iam ??= new core_1.CoreIAM({ apiKey: process.env.COREIAM_API_KEY, baseUrl: gatewayUrl });
140
+ return iam;
141
+ }
133
142
  let refreshInFlight = null;
134
143
  async function performRefresh(refreshTokenCookie, jwtCookie) {
135
144
  if (!gatewayUrl)
@@ -181,7 +190,14 @@ async function coreIamAuth(request) {
181
190
  }
182
191
  return { response: res, isAuthenticated: false };
183
192
  }
184
- const iam = new core_1.CoreIAM({ apiKey: process.env.COREIAM_API_KEY, baseUrl: gatewayUrl });
193
+ // const iam = new CoreIAM({ apiKey: process.env.COREIAM_API_KEY!, baseUrl: gatewayUrl! });
194
+ const iam = getIam();
195
+ if (!iam) {
196
+ return {
197
+ response: new server_1.NextResponse('CoreIAM is not configured (COREIAM_API_URL / COREIAM_API_KEY)', { status: 500 }),
198
+ isAuthenticated: false,
199
+ };
200
+ }
185
201
  // FIX: Special handler for the Link Confirm POST! (Must be at the TOP, before generic body read)
186
202
  if (pathname === '/api/auth/oauth/link/confirm') {
187
203
  let formData = {};
@@ -295,33 +311,62 @@ async function coreIamAuth(request) {
295
311
  setCookies.forEach(c => res.headers.append('set-cookie', c));
296
312
  return { response: res, isAuthenticated: false };
297
313
  }
314
+ /**
315
+ // 2. LOCAL JWT VERIFICATION & REFRESH
316
+ const jwtCookie = request.cookies.get("jwt");
317
+ const refreshTokenCookie = request.cookies.get("refreshToken");
318
+
319
+ let isAuthenticated = false;
320
+ let needsRefresh = false;
321
+
322
+ if (jwtCookie && JWKS) {
323
+ try {
324
+ const { payload } = await jwtVerify(jwtCookie.value, JWKS, {
325
+ issuer: 'coreiam',
326
+ algorithms: ['RS256']
327
+ });
328
+
329
+ const requestHeaders = new Headers(request.headers);
330
+ requestHeaders.set('x-coreiam-identity', JSON.stringify({
331
+ sub: payload.sub,
332
+ email: payload.email,
333
+ tenantId: payload.tenantId,
334
+ roles: payload.roles || []
335
+ }));
336
+
337
+ return {
338
+ response: NextResponse.next({ request: { headers: requestHeaders } }),
339
+ isAuthenticated: true
340
+ };
341
+ } catch (error: any) {
342
+ if (error.code === 'ERR_JWT_EXPIRED' && refreshTokenCookie) {
343
+ needsRefresh = true;
344
+ }
345
+ }
346
+ } else if (refreshTokenCookie) {
347
+ needsRefresh = true;
348
+ }
349
+
350
+ */
298
351
  // 2. LOCAL JWT VERIFICATION & REFRESH
352
+ // Now delegates to CoreIAM.verifySession — the same code path Express
353
+ // users get, so the two implementations can never drift.
299
354
  const jwtCookie = request.cookies.get("jwt");
300
355
  const refreshTokenCookie = request.cookies.get("refreshToken");
301
- let isAuthenticated = false;
356
+ const client = getIam();
302
357
  let needsRefresh = false;
303
- if (jwtCookie && JWKS) {
304
- try {
305
- const { payload } = await (0, jose_1.jwtVerify)(jwtCookie.value, JWKS, {
306
- issuer: 'coreiam',
307
- algorithms: ['RS256']
308
- });
358
+ if (client) {
359
+ const result = await client.verifySession(request.headers);
360
+ if (result.identity) {
309
361
  const requestHeaders = new Headers(request.headers);
310
- requestHeaders.set('x-coreiam-identity', JSON.stringify({
311
- sub: payload.sub,
312
- email: payload.email,
313
- tenantId: payload.tenantId,
314
- roles: payload.roles || []
315
- }));
362
+ requestHeaders.set('x-coreiam-identity', JSON.stringify(result.identity));
316
363
  return {
317
364
  response: server_1.NextResponse.next({ request: { headers: requestHeaders } }),
318
365
  isAuthenticated: true
319
366
  };
320
367
  }
321
- catch (error) {
322
- if (error.code === 'ERR_JWT_EXPIRED' && refreshTokenCookie) {
323
- needsRefresh = true;
324
- }
368
+ if (refreshTokenCookie && (result.reason === 'expired' || result.reason === 'no_cookie')) {
369
+ needsRefresh = true;
325
370
  }
326
371
  }
327
372
  else if (refreshTokenCookie) {
@@ -1,6 +1,7 @@
1
1
  import React from 'react';
2
+ import type { CoreIAMIdentity } from '../core';
2
3
  interface AuthContextType {
3
- user: any | null;
4
+ user: CoreIAMIdentity | null;
4
5
  isLoading: boolean;
5
6
  }
6
7
  export declare function CoreIAMProvider({ children, sessionUrl }: {
@@ -1,25 +1,72 @@
1
1
  "use strict";
2
2
  // src/react/provider.tsx
3
+ /**
4
+ 'use client';
5
+
6
+ import React, { createContext, useContext, useEffect, useState } from 'react';
7
+
8
+ interface AuthContextType {
9
+ user: any | null;
10
+ isLoading: boolean;
11
+ }
12
+
13
+ const AuthContext = createContext<AuthContextType>({
14
+ user: null,
15
+ isLoading: true,
16
+ });
17
+
18
+ export function CoreIAMProvider({
19
+ children,
20
+ sessionUrl = '/api/auth/session'
21
+ }: {
22
+ children: React.ReactNode;
23
+ sessionUrl?: string;
24
+ }) {
25
+ const [user, setUser] = useState<any | null>(null);
26
+ const [isLoading, setIsLoading] = useState(true);
27
+
28
+ useEffect(() => {
29
+ fetch(sessionUrl, { credentials: 'include' })
30
+ .then(res => res.ok ? res.json() : null)
31
+ .then(data => setUser(data))
32
+ .finally(() => setIsLoading(false));
33
+ }, [sessionUrl]);
34
+
35
+ return (
36
+ <AuthContext.Provider value={{ user, isLoading }}>
37
+ {children}
38
+ </AuthContext.Provider>
39
+ );
40
+ }
41
+
42
+ export function useAuth() {
43
+ return useContext(AuthContext);
44
+ }
45
+
46
+ */
3
47
  'use client';
4
48
  Object.defineProperty(exports, "__esModule", { value: true });
5
49
  exports.CoreIAMProvider = CoreIAMProvider;
6
50
  exports.useAuth = useAuth;
7
51
  const jsx_runtime_1 = require("react/jsx-runtime");
8
52
  const react_1 = require("react");
9
- const AuthContext = (0, react_1.createContext)({
10
- user: null,
11
- isLoading: true,
12
- });
53
+ const AuthContext = (0, react_1.createContext)({ user: null, isLoading: true });
13
54
  function CoreIAMProvider({ children, sessionUrl = '/api/auth/session' }) {
14
55
  const [user, setUser] = (0, react_1.useState)(null);
15
56
  const [isLoading, setIsLoading] = (0, react_1.useState)(true);
16
57
  (0, react_1.useEffect)(() => {
58
+ let cancelled = false;
17
59
  fetch(sessionUrl, { credentials: 'include' })
18
- .then(res => res.ok ? res.json() : null)
19
- .then(data => setUser(data))
20
- .finally(() => setIsLoading(false));
60
+ .then(res => (res.ok ? res.json() : null))
61
+ .then(data => { if (!cancelled)
62
+ setUser(data); })
63
+ .catch(() => { if (!cancelled)
64
+ setUser(null); }) // NEW: network failure no longer leaves an unhandled rejection
65
+ .finally(() => { if (!cancelled)
66
+ setIsLoading(false); });
67
+ return () => { cancelled = true; }; // NEW: no state updates after unmount
21
68
  }, [sessionUrl]);
22
- return ((0, jsx_runtime_1.jsx)(AuthContext.Provider, { value: { user, isLoading }, children: children }));
69
+ return (0, jsx_runtime_1.jsx)(AuthContext.Provider, { value: { user, isLoading }, children: children });
23
70
  }
24
71
  function useAuth() {
25
72
  return (0, react_1.useContext)(AuthContext);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coreiam/auth",
3
- "version": "1.0.39",
3
+ "version": "1.0.392-beta.0",
4
4
  "main": "./dist/core/index.js",
5
5
  "types": "./dist/core/index.d.ts",
6
6
  "exports": {
@@ -18,13 +18,19 @@
18
18
  "types": "./dist/react/index.d.ts",
19
19
  "import": "./dist/react/index.js",
20
20
  "require": "./dist/react/index.js"
21
+ },
22
+ "./express": {
23
+ "types": "./dist/express/index.d.ts",
24
+ "import": "./dist/express/index.js",
25
+ "require": "./dist/express/index.js"
21
26
  }
22
27
  },
23
28
  "files": [
24
29
  "dist"
25
30
  ],
26
31
  "scripts": {
27
- "build": "tsc"
32
+ "build": "tsc",
33
+ "test": "vitest run"
28
34
  },
29
35
  "keywords": [
30
36
  "coreiam",
@@ -39,6 +45,7 @@
39
45
  "access": "public"
40
46
  },
41
47
  "peerDependencies": {
48
+ "express": "^4.0.0 || ^5.0.0",
42
49
  "next": ">=14",
43
50
  "react": ">=18"
44
51
  },
@@ -48,13 +55,21 @@
48
55
  },
49
56
  "react": {
50
57
  "optional": true
58
+ },
59
+ "express": {
60
+ "optional": true
51
61
  }
52
62
  },
53
63
  "devDependencies": {
64
+ "@types/express": "^5.0.6",
54
65
  "@types/react": "^19.2.7",
66
+ "@types/supertest": "^7.2.1",
67
+ "express": "^5.2.1",
55
68
  "next": "^16.2.11",
56
69
  "react": "^19.2.7",
57
- "typescript": "^5.0.0"
70
+ "supertest": "^7.3.0",
71
+ "typescript": "^5.0.0",
72
+ "vitest": "^3.2.7"
58
73
  },
59
74
  "dependencies": {
60
75
  "@radix-ui/react-slot": "^1.3.3",