@coreiam/auth 1.0.39 → 1.0.391
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.
- package/dist/core/index.d.ts +83 -5
- package/dist/core/index.js +157 -9
- package/dist/express/index.d.ts +26 -0
- package/dist/express/index.js +171 -0
- package/dist/nextjs/middleware.js +66 -21
- package/dist/react/provider.d.ts +2 -1
- package/dist/react/provider.js +55 -8
- package/package.json +18 -3
package/dist/core/index.d.ts
CHANGED
|
@@ -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,83 @@ export interface CoreIAMIdentity {
|
|
|
10
19
|
roles?: string[];
|
|
11
20
|
[key: string]: any;
|
|
12
21
|
}
|
|
22
|
+
/** Accepts a Headers instance, an Express req.headers object, or a raw Cookie string. */
|
|
23
|
+
export type SessionInput = Headers | Record<string, string | string[] | undefined> | string;
|
|
24
|
+
export type SessionFailureReason = 'no_cookie' | 'expired' | 'invalid_claims' | 'invalid_signature' | 'jwks_unavailable';
|
|
25
|
+
export interface SessionResult {
|
|
26
|
+
identity: CoreIAMIdentity | null;
|
|
27
|
+
reason?: SessionFailureReason;
|
|
28
|
+
}
|
|
29
|
+
export interface RefreshSessionResult {
|
|
30
|
+
identity: CoreIAMIdentity | null;
|
|
31
|
+
/** Raw Set-Cookie strings to apply on your response (rotated jwt/refreshToken). */
|
|
32
|
+
setCookies: string[] | null;
|
|
33
|
+
}
|
|
34
|
+
/** Maps gateway token claims → CoreIAMIdentity (same shape the Next.js middleware injects). */
|
|
35
|
+
export declare function toIdentity(payload: JWTPayload): CoreIAMIdentity;
|
|
36
|
+
/**
|
|
37
|
+
export class CoreIAM {
|
|
38
|
+
private apiKey: string = '';
|
|
39
|
+
private baseUrl: string = '';
|
|
40
|
+
|
|
41
|
+
constructor(config?: { apiKey?: string; baseUrl?: string }) {
|
|
42
|
+
this.apiKey = config?.apiKey || process.env.COREIAM_API_KEY || '';
|
|
43
|
+
// FIX: Removed hardcoded URL. Throw if missing.
|
|
44
|
+
this.baseUrl = config?.baseUrl || process.env.COREIAM_API_URL || '';
|
|
45
|
+
|
|
46
|
+
if (!this.apiKey) throw new Error('CoreIAM: Missing API Key (COREIAM_API_KEY)');
|
|
47
|
+
if (!this.baseUrl) throw new Error('CoreIAM: Missing Gateway URL (COREIAM_API_URL)');
|
|
48
|
+
}
|
|
49
|
+
*/
|
|
13
50
|
export declare class CoreIAM {
|
|
14
51
|
private apiKey;
|
|
15
52
|
private baseUrl;
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
53
|
+
private issuer;
|
|
54
|
+
private audience?;
|
|
55
|
+
private sessionCookieName;
|
|
56
|
+
private jwksPath;
|
|
57
|
+
private jwks;
|
|
58
|
+
private refreshInFlight;
|
|
59
|
+
constructor(config?: CoreIAMConfig);
|
|
60
|
+
private getJwks;
|
|
61
|
+
/** Optional warm-up: call at boot so the first verification pays no JWKS latency. */
|
|
62
|
+
preloadJwks(): Promise<void>;
|
|
63
|
+
private readCookieHeader;
|
|
64
|
+
private readSessionCookie;
|
|
65
|
+
verifySession(input: SessionInput): Promise<SessionResult>;
|
|
66
|
+
getSession(input: SessionInput): Promise<CoreIAMIdentity | null>;
|
|
67
|
+
/**
|
|
68
|
+
* One-call silent refresh for non-Next servers: posts the refresh cookie
|
|
69
|
+
* to the gateway, verifies the rotated access token, and returns the new
|
|
70
|
+
* Set-Cookie strings so the caller can forward them on its response.
|
|
71
|
+
*/
|
|
72
|
+
refreshSession(input: SessionInput): Promise<RefreshSessionResult>;
|
|
73
|
+
refreshTokens(input: SessionInput): Promise<string[] | null>;
|
|
74
|
+
private doRefresh;
|
|
75
|
+
/**
|
|
76
|
+
async refreshTokens(headers: Headers): Promise<string[] | null> {
|
|
77
|
+
try {
|
|
78
|
+
const response = await fetch(`${this.baseUrl}/auth/refresh-token`, {
|
|
79
|
+
method: 'POST',
|
|
80
|
+
headers: {
|
|
81
|
+
'X-API-Key': this.apiKey,
|
|
82
|
+
'cookie': headers.get('cookie') || '',
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
if (response.ok) {
|
|
87
|
+
const setCookies = response.headers.getSetCookie();
|
|
88
|
+
if (setCookies && setCookies.length > 0) {
|
|
89
|
+
return setCookies;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
} catch (error) {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
*/
|
|
21
99
|
proxy(path: string, init: RequestInit, incomingHeaders: Headers): Promise<Response>;
|
|
22
100
|
getCsrfToken(headers: Headers): Promise<Response>;
|
|
23
101
|
login(body: any, headers: Headers): Promise<Response>;
|
package/dist/core/index.js
CHANGED
|
@@ -1,41 +1,189 @@
|
|
|
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
|
-
|
|
11
|
-
this.
|
|
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
|
-
|
|
18
|
-
|
|
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
|
+
readCookieHeader(input) {
|
|
71
|
+
if (typeof input === 'string')
|
|
72
|
+
return input;
|
|
73
|
+
if (typeof input.get === 'function')
|
|
74
|
+
return input.get('cookie') || '';
|
|
75
|
+
return input.cookie || '';
|
|
76
|
+
}
|
|
77
|
+
readSessionCookie(input) {
|
|
78
|
+
const raw = this.readCookieHeader(input);
|
|
79
|
+
if (!raw)
|
|
80
|
+
return null;
|
|
81
|
+
// Anchored to a real cookie-name boundary (same pattern proxy() uses),
|
|
82
|
+
// so a cookie named "notjwt" or "xjwt" can never match.
|
|
83
|
+
const escaped = this.sessionCookieName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
84
|
+
const match = raw.match(new RegExp(`(?:^|;\\s*)${escaped}=([^;]+)`));
|
|
85
|
+
return match ? decodeURIComponent(match[1]) : null;
|
|
86
|
+
}
|
|
87
|
+
async verifySession(input) {
|
|
88
|
+
const token = this.readSessionCookie(input);
|
|
89
|
+
if (!token)
|
|
90
|
+
return { identity: null, reason: 'no_cookie' };
|
|
91
|
+
try {
|
|
92
|
+
const { payload } = await (0, jose_1.jwtVerify)(token, this.getJwks(), {
|
|
93
|
+
algorithms: ['RS256'], // pinned — blocks alg confusion
|
|
94
|
+
issuer: this.issuer,
|
|
95
|
+
...(this.audience ? { audience: this.audience } : {}),
|
|
96
|
+
});
|
|
97
|
+
return { identity: toIdentity(payload) };
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
if (error instanceof jose_1.errors.JWTExpired)
|
|
101
|
+
return { identity: null, reason: 'expired' };
|
|
102
|
+
if (error instanceof jose_1.errors.JWTClaimValidationFailed)
|
|
103
|
+
return { identity: null, reason: 'invalid_claims' };
|
|
104
|
+
// JOSE errors = the token itself is bad. Anything else (fetch failure
|
|
105
|
+
// while getting the JWKS) = we couldn't check — that is not a sign-out.
|
|
106
|
+
if (error instanceof jose_1.errors.JOSEError)
|
|
107
|
+
return { identity: null, reason: 'invalid_signature' };
|
|
108
|
+
return { identity: null, reason: 'jwks_unavailable' };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
async getSession(input) {
|
|
112
|
+
return (await this.verifySession(input)).identity;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* One-call silent refresh for non-Next servers: posts the refresh cookie
|
|
116
|
+
* to the gateway, verifies the rotated access token, and returns the new
|
|
117
|
+
* Set-Cookie strings so the caller can forward them on its response.
|
|
118
|
+
*/
|
|
119
|
+
async refreshSession(input) {
|
|
120
|
+
const setCookies = await this.refreshTokens(input);
|
|
121
|
+
if (!setCookies)
|
|
122
|
+
return { identity: null, setCookies: null };
|
|
123
|
+
const rotated = setCookies
|
|
124
|
+
.find(c => c.startsWith(`${this.sessionCookieName}=`))
|
|
125
|
+
?.split(';')[0]; // "jwt=<new value>" — a valid single-cookie header string
|
|
126
|
+
if (rotated) {
|
|
127
|
+
const { identity } = await this.verifySession(rotated);
|
|
128
|
+
if (identity)
|
|
129
|
+
return { identity, setCookies };
|
|
130
|
+
}
|
|
131
|
+
return { identity: null, setCookies };
|
|
132
|
+
}
|
|
133
|
+
async refreshTokens(input) {
|
|
134
|
+
// Single-flight: concurrent requests share one gateway refresh call
|
|
135
|
+
// (matters with rotating refresh tokens). Input widened from Headers
|
|
136
|
+
// to SessionInput — backwards compatible.
|
|
137
|
+
this.refreshInFlight ??= this.doRefresh(input).finally(() => {
|
|
138
|
+
this.refreshInFlight = null;
|
|
139
|
+
});
|
|
140
|
+
return this.refreshInFlight;
|
|
141
|
+
}
|
|
142
|
+
async doRefresh(input) {
|
|
19
143
|
try {
|
|
20
144
|
const response = await fetch(`${this.baseUrl}/auth/refresh-token`, {
|
|
21
145
|
method: 'POST',
|
|
22
146
|
headers: {
|
|
23
147
|
'X-API-Key': this.apiKey,
|
|
24
|
-
'cookie':
|
|
148
|
+
'cookie': this.readCookieHeader(input),
|
|
25
149
|
},
|
|
26
150
|
});
|
|
27
151
|
if (response.ok) {
|
|
28
152
|
const setCookies = response.headers.getSetCookie();
|
|
29
|
-
if (setCookies && setCookies.length > 0)
|
|
153
|
+
if (setCookies && setCookies.length > 0)
|
|
30
154
|
return setCookies;
|
|
31
|
-
}
|
|
32
155
|
}
|
|
33
156
|
return null;
|
|
34
157
|
}
|
|
35
|
-
catch
|
|
158
|
+
catch {
|
|
36
159
|
return null;
|
|
37
160
|
}
|
|
38
161
|
}
|
|
162
|
+
// Exposed publicly so the Next.js layer can use it for getSession()
|
|
163
|
+
/**
|
|
164
|
+
async refreshTokens(headers: Headers): Promise<string[] | null> {
|
|
165
|
+
try {
|
|
166
|
+
const response = await fetch(`${this.baseUrl}/auth/refresh-token`, {
|
|
167
|
+
method: 'POST',
|
|
168
|
+
headers: {
|
|
169
|
+
'X-API-Key': this.apiKey,
|
|
170
|
+
'cookie': headers.get('cookie') || '',
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
if (response.ok) {
|
|
175
|
+
const setCookies = response.headers.getSetCookie();
|
|
176
|
+
if (setCookies && setCookies.length > 0) {
|
|
177
|
+
return setCookies;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return null;
|
|
181
|
+
} catch (error) {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
*/
|
|
39
187
|
async proxy(path, init, incomingHeaders) {
|
|
40
188
|
const url = `${this.baseUrl}${path}`;
|
|
41
189
|
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
|
-
|
|
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
|
-
|
|
132
|
-
|
|
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
|
|
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
|
-
|
|
356
|
+
const client = getIam();
|
|
302
357
|
let needsRefresh = false;
|
|
303
|
-
if (
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
322
|
-
|
|
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) {
|
package/dist/react/provider.d.ts
CHANGED
package/dist/react/provider.js
CHANGED
|
@@ -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 =>
|
|
20
|
-
|
|
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 (
|
|
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.
|
|
3
|
+
"version": "1.0.391",
|
|
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
|
-
"
|
|
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",
|