cynx-lib 1.3.34 → 1.3.36

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,209 +1,214 @@
1
- /**
2
- * cynx/utils — OpenID Connect & OAuth 2.0 Engine (oidc.js)
3
- *
4
- * Lightweight, zero-external-dependency OIDC/OAuth 2.0 client utility for Node.js / SSR.
5
- * Supports Discovery, PKCE (RFC 7636), signed state tokens (CSRF protection),
6
- * token exchange, and profile resolution.
7
- */
8
-
9
- import crypto from 'crypto';
10
-
11
- const DEFAULT_SECRET = (typeof process !== 'undefined' && (process.env?.OIDC_STATE_SECRET || process.env?.JWT_SECRET)) || 'cynx-oidc-state-secret-salt';
12
-
13
- /**
14
- * Generate a cryptographically signed state token with embedded payload and timestamp.
15
- *
16
- * @param {object} [payload={}] Data to embed (e.g. { codeVerifier, returnTo })
17
- * @param {string} [secret] Custom HMAC signing secret
18
- * @returns {string} Signed state token in format "base64urlData.base64urlSig"
19
- */
20
- export function generateState(payload = {}, secret = DEFAULT_SECRET) {
21
- const data = Buffer.from(JSON.stringify({ ...payload, ts: Date.now() })).toString('base64url');
22
- const sig = crypto.createHmac('sha256', secret).update(data).digest('base64url');
23
- return `${data}.${sig}`;
24
- }
25
-
26
- /**
27
- * Verify, parse, and consume a signed state token with replay & TTL validation.
28
- *
29
- * @param {string} stateStr The state token received from redirect
30
- * @param {string} [secret] Custom HMAC signing secret
31
- * @param {number} [ttlMs=600000] Maximum token lifetime in ms (default: 10 minutes)
32
- * @returns {object|null} Decoded payload if valid and not expired, null otherwise
33
- */
34
- export function consumeState(stateStr, secret = DEFAULT_SECRET, ttlMs = 600_000) {
35
- if (!stateStr || typeof stateStr !== 'string') return null;
36
- const parts = stateStr.split('.');
37
- if (parts.length !== 2) return null;
38
- const [data, sig] = parts;
39
- const expectedSig = crypto.createHmac('sha256', secret).update(data).digest('base64url');
40
-
41
- if (sig.length !== expectedSig.length) return null;
42
- if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expectedSig))) return null;
43
-
44
- try {
45
- const payload = JSON.parse(Buffer.from(data, 'base64url').toString('utf8'));
46
- if (!payload.ts || Date.now() - payload.ts > ttlMs) return null;
47
- return payload;
48
- } catch {
49
- return null;
50
- }
51
- }
52
-
53
- /**
54
- * Generate a secure PKCE (Proof Key for Code Exchange) pair for Authorization Code flow.
55
- *
56
- * @returns {{ codeVerifier: string, codeChallenge: string }}
57
- */
58
- export function generatePKCE() {
59
- const codeVerifier = crypto.randomBytes(32).toString('base64url');
60
- const codeChallenge = crypto.createHash('sha256')
61
- .update(codeVerifier)
62
- .digest('base64url');
63
- return { codeVerifier, codeChallenge };
64
- }
65
-
66
- /**
67
- * Fetch and discover OpenID Connect provider configuration document.
68
- *
69
- * @param {string} issuer Base issuer URL (e.g. "https://auth.example.com/realms/master")
70
- * @param {string} [discoveryPath='/.well-known/openid-configuration'] Custom discovery path
71
- * @returns {Promise<object>} Discovery metadata (authorization_endpoint, token_endpoint, etc.)
72
- */
73
- export async function discover(issuer, discoveryPath = '/.well-known/openid-configuration') {
74
- let base = issuer.replace(/\/+$/, '');
75
- let path = discoveryPath || '/.well-known/openid-configuration';
76
- if (base.endsWith(path)) {
77
- base = base.slice(0, -path.length).replace(/\/+$/, '');
78
- }
79
- if (!path.startsWith('/')) path = '/' + path;
80
- const url = base + path;
81
- const res = await fetch(url);
82
- if (!res.ok) throw new Error(`OIDC Discovery failed: ${res.status} — ${url}`);
83
- return res.json();
84
- }
85
-
86
- /**
87
- * Build the authorization redirect URL to initiate login.
88
- *
89
- * @param {object} config
90
- * @param {string} config.issuer Issuer base URL
91
- * @param {string} config.clientId Client ID
92
- * @param {string} config.redirectUri Callback redirect URI
93
- * @param {string} [config.scopes='openid profile email'] Space-separated scopes
94
- * @param {boolean} [config.pkceEnabled=true] Whether PKCE is enabled
95
- * @param {string} [config.discoveryPath]
96
- * @param {string} state Signed state parameter
97
- * @param {string} [codeChallenge] PKCE code challenge
98
- * @returns {string} Full authorization URL
99
- */
100
- export function buildAuthorizeUrl(config, state, codeChallenge) {
101
- const params = new URLSearchParams({
102
- response_type: 'code',
103
- client_id: config.clientId,
104
- redirect_uri: config.redirectUri,
105
- scope: config.scopes || 'openid profile email',
106
- state,
107
- });
108
- if (config.pkceEnabled !== false && codeChallenge) {
109
- params.set('code_challenge', codeChallenge);
110
- params.set('code_challenge_method', 'S256');
111
- }
112
- const base = config.issuer.replace(/\/+$/, '');
113
- return `${base}/authorize?${params.toString()}`;
114
- }
115
-
116
- /**
117
- * Exchange an authorization code for access, ID, and refresh tokens.
118
- *
119
- * @param {object} config
120
- * @param {string} config.issuer Issuer base URL
121
- * @param {string} config.clientId Client ID
122
- * @param {string} [config.clientSecret] Client secret (for confidential clients)
123
- * @param {string} config.redirectUri Callback redirect URI
124
- * @param {boolean} [config.pkceEnabled=true]
125
- * @param {string} [config.discoveryPath]
126
- * @param {string} code Authorization code from callback
127
- * @param {string} [codeVerifier] PKCE code verifier
128
- * @returns {Promise<{ access_token: string, id_token?: string, refresh_token?: string, expires_in?: number, token_type?: string }>}
129
- */
130
- export async function exchangeCode(config, code, codeVerifier) {
131
- const discoverDoc = await discover(config.issuer, config.discoveryPath);
132
- const tokenUrl = discoverDoc.token_endpoint;
133
-
134
- const body = new URLSearchParams({
135
- grant_type: 'authorization_code',
136
- code,
137
- redirect_uri: config.redirectUri,
138
- client_id: config.clientId,
139
- });
140
-
141
- if (config.clientSecret) {
142
- body.set('client_secret', config.clientSecret);
143
- }
144
-
145
- if (config.pkceEnabled !== false && codeVerifier) {
146
- body.set('code_verifier', codeVerifier);
147
- }
148
-
149
- const res = await fetch(tokenUrl, {
150
- method: 'POST',
151
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
152
- body,
153
- });
154
-
155
- if (!res.ok) {
156
- const err = await res.text();
157
- throw new Error(`Token exchange failed: ${res.status} ${err}`);
158
- }
159
- return res.json();
160
- }
161
-
162
- /**
163
- * Fetch user profile from the UserInfo endpoint using an access token.
164
- *
165
- * @param {object} config
166
- * @param {string} config.issuer Issuer base URL
167
- * @param {string} [config.discoveryPath]
168
- * @param {string} accessToken Bearer access token
169
- * @returns {Promise<object>} User claims and profile info
170
- */
171
- export async function getUserInfo(config, accessToken) {
172
- const discoverDoc = await discover(config.issuer, config.discoveryPath);
173
- const userinfoUrl = discoverDoc.userinfo_endpoint;
174
- if (!userinfoUrl) throw new Error('No userinfo_endpoint in discovery document');
175
-
176
- const res = await fetch(userinfoUrl, {
177
- headers: { Authorization: `Bearer ${accessToken}` },
178
- });
179
-
180
- if (!res.ok) throw new Error(`Userinfo failed: ${res.status}`);
181
- return res.json();
182
- }
183
-
184
- /**
185
- * Safely decode payload from an ID token (JWT) without verification.
186
- *
187
- * @param {string} idToken Raw JWT id_token string
188
- * @returns {object} Decoded JSON payload claims
189
- */
190
- export function decodeIdToken(idToken) {
191
- if (!idToken || typeof idToken !== 'string') throw new Error('Invalid id_token');
192
- const parts = idToken.split('.');
193
- if (parts.length !== 3) throw new Error('Invalid id_token format (expected 3 parts)');
194
- const payload = Buffer.from(parts[1], 'base64url').toString('utf8');
195
- return JSON.parse(payload);
196
- }
197
-
198
- // ─── MAIN EXPORT ────────────────────────────────────────────────────────────────
199
-
200
- export default {
201
- generateState,
202
- consumeState,
203
- generatePKCE,
204
- discover,
205
- buildAuthorizeUrl,
206
- exchangeCode,
207
- getUserInfo,
208
- decodeIdToken,
209
- };
1
+ /**
2
+ * cynx/utils — OpenID Connect & OAuth 2.0 Engine (oidc.js)
3
+ *
4
+ * Lightweight, zero-external-dependency OIDC/OAuth 2.0 client utility for Node.js / SSR.
5
+ * Supports Discovery, PKCE (RFC 7636), signed state tokens (CSRF protection),
6
+ * token exchange, and profile resolution.
7
+ */
8
+
9
+ import crypto from 'crypto';
10
+
11
+ const DEFAULT_SECRET = (typeof process !== 'undefined' && (process.env?.OIDC_STATE_SECRET || process.env?.JWT_SECRET)) || 'cynx-oidc-state-secret-salt';
12
+
13
+ /**
14
+ * Generate a cryptographically signed state token with embedded payload and timestamp.
15
+ *
16
+ * @param {object} [payload={}] Data to embed (e.g. { codeVerifier, returnTo })
17
+ * @param {string} [secret] Custom HMAC signing secret
18
+ * @returns {string} Signed state token in format "base64urlData.base64urlSig"
19
+ */
20
+ export function generateState(payload = {}, secret = DEFAULT_SECRET) {
21
+ const data = Buffer.from(JSON.stringify({ ...payload, ts: Date.now() })).toString('base64url');
22
+ const sig = crypto.createHmac('sha256', secret).update(data).digest('base64url');
23
+ return `${data}.${sig}`;
24
+ }
25
+
26
+ /**
27
+ * Verify, parse, and consume a signed state token with replay & TTL validation.
28
+ *
29
+ * @param {string} stateStr The state token received from redirect
30
+ * @param {string} [secret] Custom HMAC signing secret
31
+ * @param {number} [ttlMs=600000] Maximum token lifetime in ms (default: 10 minutes)
32
+ * @returns {object|null} Decoded payload if valid and not expired, null otherwise
33
+ */
34
+ export function consumeState(stateStr, secret = DEFAULT_SECRET, ttlMs = 600_000) {
35
+ if (!stateStr || typeof stateStr !== 'string') return null;
36
+ const parts = stateStr.split('.');
37
+ if (parts.length !== 2) return null;
38
+ const [data, sig] = parts;
39
+ const expectedSig = crypto.createHmac('sha256', secret).update(data).digest('base64url');
40
+
41
+ if (sig.length !== expectedSig.length) return null;
42
+ if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expectedSig))) return null;
43
+
44
+ try {
45
+ const payload = JSON.parse(Buffer.from(data, 'base64url').toString('utf8'));
46
+ if (!payload.ts || Date.now() - payload.ts > ttlMs) return null;
47
+ return payload;
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Generate a secure PKCE (Proof Key for Code Exchange) pair for Authorization Code flow.
55
+ *
56
+ * @returns {{ codeVerifier: string, codeChallenge: string }}
57
+ */
58
+ export function generatePKCE() {
59
+ const codeVerifier = crypto.randomBytes(32).toString('base64url');
60
+ const codeChallenge = crypto.createHash('sha256')
61
+ .update(codeVerifier)
62
+ .digest('base64url');
63
+ return { codeVerifier, codeChallenge };
64
+ }
65
+
66
+ /**
67
+ * Fetch and discover OpenID Connect provider configuration document.
68
+ *
69
+ * @param {string} issuer Base issuer URL (e.g. "https://auth.example.com/realms/master")
70
+ * @param {string} [discoveryPath='/.well-known/openid-configuration'] Custom discovery path
71
+ * @returns {Promise<object>} Discovery metadata (authorization_endpoint, token_endpoint, etc.)
72
+ */
73
+ export async function discover(issuer, discoveryPath = '/.well-known/openid-configuration') {
74
+ let base = issuer.replace(/\/+$/, '');
75
+ let path = discoveryPath || '/.well-known/openid-configuration';
76
+ if (base.endsWith(path)) {
77
+ base = base.slice(0, -path.length).replace(/\/+$/, '');
78
+ }
79
+ if (!path.startsWith('/')) path = '/' + path;
80
+ const url = base + path;
81
+ const res = await fetch(url);
82
+ if (!res.ok) throw new Error(`OIDC Discovery failed: ${res.status} — ${url}`);
83
+ return res.json();
84
+ }
85
+
86
+ /**
87
+ * Build the authorization redirect URL to initiate login.
88
+ *
89
+ * @param {object} config
90
+ * @param {string} config.issuer Issuer base URL
91
+ * @param {string} config.clientId Client ID
92
+ * @param {string} config.redirectUri Callback redirect URI
93
+ * @param {string} [config.scopes='openid profile email'] Space-separated scopes
94
+ * @param {boolean} [config.pkceEnabled=true] Whether PKCE is enabled
95
+ * @param {string} [config.discoveryPath]
96
+ * @param {string} state Signed state parameter
97
+ * @param {string} [codeChallenge] PKCE code challenge
98
+ * @returns {string} Full authorization URL
99
+ */
100
+ export function buildAuthorizeUrl(config, state, codeChallenge) {
101
+ const params = new URLSearchParams({
102
+ response_type: 'code',
103
+ client_id: config.clientId,
104
+ redirect_uri: config.redirectUri,
105
+ scope: config.scopes || 'openid profile email',
106
+ state,
107
+ });
108
+ if (config.pkceEnabled !== false && codeChallenge) {
109
+ params.set('code_challenge', codeChallenge);
110
+ params.set('code_challenge_method', 'S256');
111
+ }
112
+ const base = config.issuer.replace(/\/+$/, '');
113
+ return `${base}/authorize?${params.toString()}`;
114
+ }
115
+
116
+ /**
117
+ * Exchange an authorization code for access, ID, and refresh tokens.
118
+ *
119
+ * @param {object} config
120
+ * @param {string} config.issuer Issuer base URL
121
+ * @param {string} config.clientId Client ID
122
+ * @param {string} [config.clientSecret] Client secret (for confidential clients)
123
+ * @param {string} config.redirectUri Callback redirect URI
124
+ * @param {boolean} [config.pkceEnabled=true]
125
+ * @param {string} [config.discoveryPath]
126
+ * @param {string} code Authorization code from callback
127
+ * @param {string} [codeVerifier] PKCE code verifier
128
+ * @returns {Promise<{ access_token: string, id_token?: string, refresh_token?: string, expires_in?: number, token_type?: string }>}
129
+ */
130
+ export async function exchangeCode(config, code, codeVerifier) {
131
+ const discoverDoc = await discover(config.issuer, config.discoveryPath);
132
+ const tokenUrl = discoverDoc.token_endpoint;
133
+
134
+ const body = new URLSearchParams({
135
+ grant_type: 'authorization_code',
136
+ code,
137
+ redirect_uri: config.redirectUri,
138
+ client_id: config.clientId,
139
+ });
140
+
141
+ if (config.clientSecret) {
142
+ body.set('client_secret', config.clientSecret);
143
+ }
144
+
145
+ if (config.pkceEnabled !== false && codeVerifier) {
146
+ body.set('code_verifier', codeVerifier);
147
+ }
148
+
149
+ const res = await fetch(tokenUrl, {
150
+ method: 'POST',
151
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
152
+ body,
153
+ });
154
+
155
+ if (!res.ok) {
156
+ const err = await res.text();
157
+ throw new Error(`Token exchange failed: ${res.status} ${err}`);
158
+ }
159
+ return res.json();
160
+ }
161
+
162
+ /**
163
+ * Fetch user profile from the UserInfo endpoint using an access token.
164
+ *
165
+ * @param {object} config
166
+ * @param {string} config.issuer Issuer base URL
167
+ * @param {string} [config.discoveryPath]
168
+ * @param {string} accessToken Bearer access token
169
+ * @returns {Promise<object>} User claims and profile info
170
+ */
171
+ export async function getUserInfo(config, accessToken) {
172
+ const discoverDoc = await discover(config.issuer, config.discoveryPath);
173
+ const userinfoUrl = discoverDoc.userinfo_endpoint;
174
+ if (!userinfoUrl) throw new Error('No userinfo_endpoint in discovery document');
175
+
176
+ const res = await fetch(userinfoUrl, {
177
+ headers: { Authorization: `Bearer ${accessToken}` },
178
+ });
179
+
180
+ if (!res.ok) throw new Error(`Userinfo failed: ${res.status}`);
181
+ return res.json();
182
+ }
183
+
184
+ /**
185
+ * Safely decode payload from an ID token (JWT) without verification.
186
+ *
187
+ * @param {string} idToken Raw JWT id_token string
188
+ * @returns {object} Decoded JSON payload claims
189
+ */
190
+ export function decodeIdToken(idToken) {
191
+ if (!idToken || typeof idToken !== 'string') throw new Error('Invalid id_token');
192
+ const parts = idToken.split('.');
193
+ if (parts.length !== 3) throw new Error('Invalid id_token format (expected 3 parts)');
194
+ const payload = Buffer.from(parts[1], 'base64url').toString('utf8');
195
+ return JSON.parse(payload);
196
+ }
197
+
198
+ export function storeVerifier() {
199
+ // Verifier is stored directly inside the signed state token or handled by PKCE
200
+ }
201
+
202
+ // ─── MAIN EXPORT ────────────────────────────────────────────────────────────────
203
+
204
+ export default {
205
+ generateState,
206
+ storeVerifier,
207
+ consumeState,
208
+ generatePKCE,
209
+ discover,
210
+ buildAuthorizeUrl,
211
+ exchangeCode,
212
+ getUserInfo,
213
+ decodeIdToken,
214
+ };