@naylence/runtime 0.3.5-test.910 → 0.3.5-test.913
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/browser/index.cjs +1915 -1214
- package/dist/browser/index.mjs +1910 -1209
- package/dist/cjs/naylence/fame/config/extended-fame-config.js +52 -0
- package/dist/cjs/naylence/fame/factory-manifest.js +2 -0
- package/dist/cjs/naylence/fame/http/jwks-api-router.js +16 -18
- package/dist/cjs/naylence/fame/http/oauth2-server.js +28 -31
- package/dist/cjs/naylence/fame/http/oauth2-token-router.js +901 -93
- package/dist/cjs/naylence/fame/http/openid-configuration-router.js +30 -32
- package/dist/cjs/naylence/fame/node/admission/admission-profile-factory.js +79 -0
- package/dist/cjs/naylence/fame/security/auth/oauth2-pkce-token-provider-factory.js +171 -0
- package/dist/cjs/naylence/fame/security/auth/oauth2-pkce-token-provider.js +560 -0
- package/dist/cjs/naylence/fame/security/crypto/providers/default-crypto-provider.js +0 -162
- package/dist/cjs/naylence/fame/telemetry/open-telemetry-trace-emitter-factory.js +19 -2
- package/dist/cjs/naylence/fame/telemetry/open-telemetry-trace-emitter.js +19 -9
- package/dist/cjs/naylence/fame/util/register-runtime-factories.js +6 -0
- package/dist/cjs/version.js +2 -2
- package/dist/esm/naylence/fame/config/extended-fame-config.js +52 -0
- package/dist/esm/naylence/fame/factory-manifest.js +2 -0
- package/dist/esm/naylence/fame/http/jwks-api-router.js +16 -17
- package/dist/esm/naylence/fame/http/oauth2-server.js +28 -31
- package/dist/esm/naylence/fame/http/oauth2-token-router.js +901 -93
- package/dist/esm/naylence/fame/http/openid-configuration-router.js +30 -31
- package/dist/esm/naylence/fame/node/admission/admission-profile-factory.js +79 -0
- package/dist/esm/naylence/fame/security/auth/oauth2-pkce-token-provider-factory.js +134 -0
- package/dist/esm/naylence/fame/security/auth/oauth2-pkce-token-provider.js +555 -0
- package/dist/esm/naylence/fame/security/crypto/providers/default-crypto-provider.js +0 -162
- package/dist/esm/naylence/fame/telemetry/open-telemetry-trace-emitter-factory.js +19 -2
- package/dist/esm/naylence/fame/telemetry/open-telemetry-trace-emitter.js +19 -9
- package/dist/esm/naylence/fame/util/register-runtime-factories.js +6 -0
- package/dist/esm/version.js +2 -2
- package/dist/node/index.cjs +1911 -1210
- package/dist/node/index.mjs +1910 -1209
- package/dist/node/node.cjs +2945 -1439
- package/dist/node/node.mjs +2944 -1438
- package/dist/types/naylence/fame/factory-manifest.d.ts +1 -1
- package/dist/types/naylence/fame/http/jwks-api-router.d.ts +8 -8
- package/dist/types/naylence/fame/http/oauth2-server.d.ts +3 -3
- package/dist/types/naylence/fame/http/oauth2-token-router.d.ts +75 -19
- package/dist/types/naylence/fame/http/openid-configuration-router.d.ts +8 -8
- package/dist/types/naylence/fame/security/auth/oauth2-pkce-token-provider-factory.d.ts +27 -0
- package/dist/types/naylence/fame/security/auth/oauth2-pkce-token-provider.d.ts +42 -0
- package/dist/types/naylence/fame/security/crypto/providers/default-crypto-provider.d.ts +0 -1
- package/dist/types/naylence/fame/telemetry/open-telemetry-trace-emitter.d.ts +4 -0
- package/dist/types/version.d.ts +1 -1
- package/package.json +4 -4
- package/dist/esm/naylence/fame/fastapi/oauth2-server.js +0 -205
- package/dist/types/naylence/fame/fastapi/oauth2-server.d.ts +0 -22
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
import { getLogger } from '../../util/logging.js';
|
|
2
|
+
import { credentialToString, } from '../credential/credential-provider.js';
|
|
3
|
+
const logger = getLogger('naylence.fame.security.auth.oauth2_pkce_token_provider');
|
|
4
|
+
const DEFAULT_SCOPES = [];
|
|
5
|
+
const DEFAULT_CLOCK_SKEW_SECONDS = 30;
|
|
6
|
+
const DEFAULT_CODE_VERIFIER_LENGTH = 48; // bytes before base64url encoding
|
|
7
|
+
const DEFAULT_CODE_CHALLENGE_METHOD = 'S256';
|
|
8
|
+
function normalizeScopes(candidate) {
|
|
9
|
+
if (Array.isArray(candidate)) {
|
|
10
|
+
const scopes = candidate
|
|
11
|
+
.map((scope) => (typeof scope === 'string' ? scope.trim() : ''))
|
|
12
|
+
.filter((scope) => scope.length > 0);
|
|
13
|
+
return scopes.length > 0 ? scopes : undefined;
|
|
14
|
+
}
|
|
15
|
+
if (typeof candidate === 'string') {
|
|
16
|
+
const scopes = candidate
|
|
17
|
+
.split(/[,\s]+/u)
|
|
18
|
+
.map((scope) => scope.trim())
|
|
19
|
+
.filter((scope) => scope.length > 0);
|
|
20
|
+
return scopes.length > 0 ? scopes : undefined;
|
|
21
|
+
}
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
function coerceString(value) {
|
|
25
|
+
if (typeof value !== 'string') {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
const trimmed = value.trim();
|
|
29
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
30
|
+
}
|
|
31
|
+
function coerceNumber(value) {
|
|
32
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
33
|
+
return value;
|
|
34
|
+
}
|
|
35
|
+
if (typeof value === 'string' && value.trim().length > 0) {
|
|
36
|
+
const parsed = Number(value);
|
|
37
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
38
|
+
}
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
function normalizeOptions(raw) {
|
|
42
|
+
const camel = raw;
|
|
43
|
+
const snake = raw;
|
|
44
|
+
const authorizeUrl = coerceString(camel.authorizeUrl) ?? coerceString(snake.authorize_url);
|
|
45
|
+
if (!authorizeUrl) {
|
|
46
|
+
throw new Error('OAuth2PkceTokenProvider authorizeUrl must be provided');
|
|
47
|
+
}
|
|
48
|
+
const tokenUrl = coerceString(camel.tokenUrl) ?? coerceString(snake.token_url);
|
|
49
|
+
if (!tokenUrl) {
|
|
50
|
+
throw new Error('OAuth2PkceTokenProvider tokenUrl must be provided');
|
|
51
|
+
}
|
|
52
|
+
const redirectUri = coerceString(camel.redirectUri) ?? coerceString(snake.redirect_uri);
|
|
53
|
+
if (!redirectUri) {
|
|
54
|
+
throw new Error('OAuth2PkceTokenProvider redirectUri must be provided');
|
|
55
|
+
}
|
|
56
|
+
const clientId = coerceString(camel.clientId) ?? coerceString(snake.client_id);
|
|
57
|
+
if (!clientId) {
|
|
58
|
+
throw new Error('OAuth2PkceTokenProvider clientId must be provided');
|
|
59
|
+
}
|
|
60
|
+
const usernameProvider = camel.usernameProvider ??
|
|
61
|
+
snake.username_provider;
|
|
62
|
+
const clientSecretProvider = camel.clientSecretProvider ??
|
|
63
|
+
snake.client_secret_provider;
|
|
64
|
+
const scopes = normalizeScopes(camel.scopes) ??
|
|
65
|
+
normalizeScopes(snake.scopes ?? snake.scope) ??
|
|
66
|
+
DEFAULT_SCOPES.slice();
|
|
67
|
+
const audience = coerceString(camel.audience) ??
|
|
68
|
+
coerceString(snake.audience ?? snake.aud);
|
|
69
|
+
const fetchImpl = (camel.fetchImpl ?? snake.fetch_impl);
|
|
70
|
+
const clockSkewSeconds = coerceNumber(camel.clockSkewSeconds) ??
|
|
71
|
+
coerceNumber(snake.clock_skew_seconds) ??
|
|
72
|
+
DEFAULT_CLOCK_SKEW_SECONDS;
|
|
73
|
+
const codeVerifierLength = coerceNumber(camel.codeVerifierLength) ??
|
|
74
|
+
coerceNumber(snake.code_verifier_length) ??
|
|
75
|
+
DEFAULT_CODE_VERIFIER_LENGTH;
|
|
76
|
+
const methodCandidate = coerceString(camel.codeChallengeMethod) ??
|
|
77
|
+
coerceString(snake.code_challenge_method);
|
|
78
|
+
const codeChallengeMethod = methodCandidate && methodCandidate.toUpperCase() === 'PLAIN'
|
|
79
|
+
? 'PLAIN'
|
|
80
|
+
: DEFAULT_CODE_CHALLENGE_METHOD;
|
|
81
|
+
const loginHintParam = coerceString(camel.loginHintParam) ??
|
|
82
|
+
coerceString(snake.login_hint_param) ??
|
|
83
|
+
'login_hint';
|
|
84
|
+
return {
|
|
85
|
+
authorizeUrl,
|
|
86
|
+
tokenUrl,
|
|
87
|
+
redirectUri,
|
|
88
|
+
clientId,
|
|
89
|
+
usernameProvider,
|
|
90
|
+
clientSecretProvider,
|
|
91
|
+
scopes,
|
|
92
|
+
audience,
|
|
93
|
+
fetchImpl,
|
|
94
|
+
clockSkewSeconds,
|
|
95
|
+
codeVerifierLength,
|
|
96
|
+
codeChallengeMethod,
|
|
97
|
+
loginHintParam,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
function generateRandomBytes(length) {
|
|
101
|
+
if (typeof globalThis.crypto?.getRandomValues !== 'function') {
|
|
102
|
+
throw new Error('crypto.getRandomValues is unavailable. Provide a secure random source.');
|
|
103
|
+
}
|
|
104
|
+
const buffer = new Uint8Array(length);
|
|
105
|
+
globalThis.crypto.getRandomValues(buffer);
|
|
106
|
+
return buffer;
|
|
107
|
+
}
|
|
108
|
+
function base64UrlEncode(buffer) {
|
|
109
|
+
const bufferCtor = globalThis.Buffer;
|
|
110
|
+
if (bufferCtor) {
|
|
111
|
+
return bufferCtor
|
|
112
|
+
.from(buffer)
|
|
113
|
+
.toString('base64')
|
|
114
|
+
.replace(/\+/g, '-')
|
|
115
|
+
.replace(/\//g, '_')
|
|
116
|
+
.replace(/=+$/u, '');
|
|
117
|
+
}
|
|
118
|
+
let binary = '';
|
|
119
|
+
for (const byte of buffer) {
|
|
120
|
+
binary += String.fromCharCode(byte);
|
|
121
|
+
}
|
|
122
|
+
if (typeof globalThis.btoa === 'function') {
|
|
123
|
+
return globalThis
|
|
124
|
+
.btoa(binary)
|
|
125
|
+
.replace(/\+/g, '-')
|
|
126
|
+
.replace(/\//g, '_')
|
|
127
|
+
.replace(/=+$/u, '');
|
|
128
|
+
}
|
|
129
|
+
throw new Error('Base64 encoding is unavailable in this environment');
|
|
130
|
+
}
|
|
131
|
+
async function computeS256(verifier) {
|
|
132
|
+
if (typeof globalThis.crypto?.subtle !== 'object') {
|
|
133
|
+
throw new Error('crypto.subtle.digest is unavailable. Provide an environment with Web Crypto support.');
|
|
134
|
+
}
|
|
135
|
+
const encoder = new TextEncoder();
|
|
136
|
+
const digest = await globalThis.crypto.subtle.digest('SHA-256', encoder.encode(verifier));
|
|
137
|
+
return base64UrlEncode(new Uint8Array(digest));
|
|
138
|
+
}
|
|
139
|
+
function ensureFinitePositive(value, label) {
|
|
140
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
141
|
+
throw new Error(`${label} must be a positive finite number`);
|
|
142
|
+
}
|
|
143
|
+
return Math.max(1, Math.floor(value));
|
|
144
|
+
}
|
|
145
|
+
const STORAGE_NAMESPACE = 'naylence.oauth2_pkce.';
|
|
146
|
+
const TOKEN_STORAGE_SUFFIX = '.token';
|
|
147
|
+
function getStorageKey(clientId) {
|
|
148
|
+
return `${STORAGE_NAMESPACE}${clientId}`;
|
|
149
|
+
}
|
|
150
|
+
function getTokenStorageKey(clientId) {
|
|
151
|
+
return `${STORAGE_NAMESPACE}${clientId}${TOKEN_STORAGE_SUFFIX}`;
|
|
152
|
+
}
|
|
153
|
+
function isBrowserEnvironment() {
|
|
154
|
+
return (typeof window !== 'undefined' &&
|
|
155
|
+
typeof window.location !== 'undefined' &&
|
|
156
|
+
typeof window.sessionStorage !== 'undefined');
|
|
157
|
+
}
|
|
158
|
+
function readPendingAuthorization(clientId) {
|
|
159
|
+
if (!isBrowserEnvironment()) {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
const raw = window.sessionStorage.getItem(getStorageKey(clientId));
|
|
164
|
+
if (!raw) {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
const parsed = JSON.parse(raw);
|
|
168
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
return parsed;
|
|
172
|
+
}
|
|
173
|
+
catch (error) {
|
|
174
|
+
logger.debug('pkce_storage_read_failed', {
|
|
175
|
+
error: error instanceof Error ? error.message : String(error),
|
|
176
|
+
});
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
function writePendingAuthorization(clientId, pending) {
|
|
181
|
+
if (!isBrowserEnvironment()) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
try {
|
|
185
|
+
const key = getStorageKey(clientId);
|
|
186
|
+
if (!pending) {
|
|
187
|
+
window.sessionStorage.removeItem(key);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
window.sessionStorage.setItem(key, JSON.stringify(pending));
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
logger.debug('pkce_storage_write_failed', {
|
|
194
|
+
error: error instanceof Error ? error.message : String(error),
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
function stableScopeKey(scopes) {
|
|
199
|
+
if (!scopes || scopes.length === 0) {
|
|
200
|
+
return '';
|
|
201
|
+
}
|
|
202
|
+
return [...scopes].sort().join(' ');
|
|
203
|
+
}
|
|
204
|
+
function readPersistedToken(clientId) {
|
|
205
|
+
if (!isBrowserEnvironment()) {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
try {
|
|
209
|
+
const raw = window.sessionStorage.getItem(getTokenStorageKey(clientId));
|
|
210
|
+
if (!raw) {
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
const parsed = JSON.parse(raw);
|
|
214
|
+
const value = coerceString(parsed.value);
|
|
215
|
+
if (!value) {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
const expiresAt = coerceNumber(parsed.expiresAt);
|
|
219
|
+
const scopes = normalizeScopes(parsed.scopes);
|
|
220
|
+
const audience = coerceString(parsed.audience);
|
|
221
|
+
const record = {
|
|
222
|
+
value,
|
|
223
|
+
scopes,
|
|
224
|
+
audience,
|
|
225
|
+
};
|
|
226
|
+
if (typeof expiresAt === 'number') {
|
|
227
|
+
record.expiresAt = expiresAt;
|
|
228
|
+
}
|
|
229
|
+
return record;
|
|
230
|
+
}
|
|
231
|
+
catch (error) {
|
|
232
|
+
logger.debug('pkce_token_storage_read_failed', {
|
|
233
|
+
error: error instanceof Error ? error.message : String(error),
|
|
234
|
+
});
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
function writePersistedToken(clientId, token) {
|
|
239
|
+
if (!isBrowserEnvironment()) {
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const key = getTokenStorageKey(clientId);
|
|
243
|
+
try {
|
|
244
|
+
if (!token) {
|
|
245
|
+
window.sessionStorage.removeItem(key);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
window.sessionStorage.setItem(key, JSON.stringify(token));
|
|
249
|
+
}
|
|
250
|
+
catch (error) {
|
|
251
|
+
logger.debug('pkce_token_storage_write_failed', {
|
|
252
|
+
error: error instanceof Error ? error.message : String(error),
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
function clearOAuthParamsFromUrl(url) {
|
|
257
|
+
if (!isBrowserEnvironment()) {
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
try {
|
|
261
|
+
const cleaned = new URL(url.toString());
|
|
262
|
+
cleaned.searchParams.delete('code');
|
|
263
|
+
cleaned.searchParams.delete('state');
|
|
264
|
+
cleaned.searchParams.delete('error');
|
|
265
|
+
cleaned.searchParams.delete('error_description');
|
|
266
|
+
cleaned.searchParams.delete('error_description'.toUpperCase());
|
|
267
|
+
cleaned.searchParams.delete('scope');
|
|
268
|
+
cleaned.searchParams.delete('scope'.toUpperCase());
|
|
269
|
+
const finalUrl = `${cleaned.pathname}${cleaned.search}${cleaned.hash}`;
|
|
270
|
+
window.history.replaceState(window.history.state, '', finalUrl);
|
|
271
|
+
}
|
|
272
|
+
catch (error) {
|
|
273
|
+
logger.debug('pkce_replace_state_failed', {
|
|
274
|
+
error: error instanceof Error ? error.message : String(error),
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
function collectRedirectOutcome(currentLocation) {
|
|
279
|
+
const url = new URL(currentLocation.href);
|
|
280
|
+
const code = url.searchParams.get('code');
|
|
281
|
+
const state = url.searchParams.get('state');
|
|
282
|
+
const error = url.searchParams.get('error');
|
|
283
|
+
const errorDescription = url.searchParams.get('error_description') ??
|
|
284
|
+
url.searchParams.get('error_description'.toUpperCase());
|
|
285
|
+
if (!code && !error) {
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
return {
|
|
289
|
+
code: code ?? '',
|
|
290
|
+
state: state ?? '',
|
|
291
|
+
error: error ?? undefined,
|
|
292
|
+
errorDescription: errorDescription ?? undefined,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
export class OAuth2PkceRedirectInitiatedError extends Error {
|
|
296
|
+
constructor(message = 'OAuth2PkceTokenProvider initiated browser redirect') {
|
|
297
|
+
super(message);
|
|
298
|
+
this.name = 'OAuth2PkceRedirectInitiatedError';
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
export class OAuth2PkceTokenProvider {
|
|
302
|
+
constructor(rawOptions) {
|
|
303
|
+
this.options = normalizeOptions(rawOptions);
|
|
304
|
+
}
|
|
305
|
+
async getToken() {
|
|
306
|
+
if (!isBrowserEnvironment()) {
|
|
307
|
+
throw new Error('OAuth2PkceTokenProvider requires a browser environment with sessionStorage support');
|
|
308
|
+
}
|
|
309
|
+
if (!this.cachedToken) {
|
|
310
|
+
const persisted = readPersistedToken(this.options.clientId);
|
|
311
|
+
if (persisted) {
|
|
312
|
+
if (this.isTokenCompatible(persisted.scopes, persisted.audience)) {
|
|
313
|
+
if (!persisted.expiresAt || this.isTokenFresh(persisted)) {
|
|
314
|
+
logger.debug('using_persisted_oauth2_pkce_token', {
|
|
315
|
+
authorize_url: this.options.authorizeUrl,
|
|
316
|
+
});
|
|
317
|
+
const cached = {
|
|
318
|
+
value: persisted.value,
|
|
319
|
+
};
|
|
320
|
+
if (typeof persisted.expiresAt === 'number') {
|
|
321
|
+
cached.expiresAt = persisted.expiresAt;
|
|
322
|
+
}
|
|
323
|
+
this.cachedToken = cached;
|
|
324
|
+
}
|
|
325
|
+
else {
|
|
326
|
+
writePersistedToken(this.options.clientId, null);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
else {
|
|
330
|
+
writePersistedToken(this.options.clientId, null);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
if (this.cachedToken && this.isTokenFresh(this.cachedToken)) {
|
|
335
|
+
logger.debug('using_cached_oauth2_pkce_token', {
|
|
336
|
+
authorize_url: this.options.authorizeUrl,
|
|
337
|
+
});
|
|
338
|
+
return { ...this.cachedToken };
|
|
339
|
+
}
|
|
340
|
+
const tokenFromRedirect = await this.tryCompletePendingAuthorization();
|
|
341
|
+
if (tokenFromRedirect) {
|
|
342
|
+
this.cachedToken = tokenFromRedirect;
|
|
343
|
+
return { ...this.cachedToken };
|
|
344
|
+
}
|
|
345
|
+
await this.beginBrowserAuthorization();
|
|
346
|
+
throw new OAuth2PkceRedirectInitiatedError();
|
|
347
|
+
}
|
|
348
|
+
isTokenFresh(token) {
|
|
349
|
+
if (typeof token.expiresAt !== 'number') {
|
|
350
|
+
return true;
|
|
351
|
+
}
|
|
352
|
+
const skew = this.options.clockSkewSeconds ?? DEFAULT_CLOCK_SKEW_SECONDS;
|
|
353
|
+
return token.expiresAt - skew * 1000 > Date.now();
|
|
354
|
+
}
|
|
355
|
+
async beginBrowserAuthorization() {
|
|
356
|
+
const existing = readPendingAuthorization(this.options.clientId);
|
|
357
|
+
if (existing) {
|
|
358
|
+
logger.debug('pkce_redirect_in_progress', {
|
|
359
|
+
authorize_url: this.options.authorizeUrl,
|
|
360
|
+
});
|
|
361
|
+
this.navigate(existing.authorizeUrl);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
const verifierLength = ensureFinitePositive(this.options.codeVerifierLength ?? DEFAULT_CODE_VERIFIER_LENGTH, 'codeVerifierLength');
|
|
365
|
+
const codeVerifier = base64UrlEncode(generateRandomBytes(verifierLength));
|
|
366
|
+
const state = base64UrlEncode(generateRandomBytes(24));
|
|
367
|
+
const codeChallengeMethod = this.options.codeChallengeMethod ?? 'S256';
|
|
368
|
+
const codeChallenge = codeChallengeMethod === 'S256'
|
|
369
|
+
? await computeS256(codeVerifier)
|
|
370
|
+
: codeVerifier;
|
|
371
|
+
const authorizeUrl = await this.buildAuthorizeUrl({
|
|
372
|
+
state,
|
|
373
|
+
codeChallenge,
|
|
374
|
+
codeChallengeMethod,
|
|
375
|
+
});
|
|
376
|
+
const pending = {
|
|
377
|
+
state,
|
|
378
|
+
codeVerifier,
|
|
379
|
+
codeChallenge,
|
|
380
|
+
codeChallengeMethod,
|
|
381
|
+
authorizeUrl,
|
|
382
|
+
createdAt: Date.now(),
|
|
383
|
+
scopes: this.options.scopes,
|
|
384
|
+
audience: this.options.audience,
|
|
385
|
+
};
|
|
386
|
+
writePersistedToken(this.options.clientId, null);
|
|
387
|
+
writePendingAuthorization(this.options.clientId, pending);
|
|
388
|
+
logger.debug('pkce_redirect_start', {
|
|
389
|
+
authorize_url: this.options.authorizeUrl,
|
|
390
|
+
redirect_uri: this.options.redirectUri,
|
|
391
|
+
});
|
|
392
|
+
this.navigate(authorizeUrl);
|
|
393
|
+
}
|
|
394
|
+
navigate(url) {
|
|
395
|
+
if (!isBrowserEnvironment()) {
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
try {
|
|
399
|
+
window.location.assign(url);
|
|
400
|
+
}
|
|
401
|
+
catch (error) {
|
|
402
|
+
logger.error('pkce_navigation_failed', {
|
|
403
|
+
authorize_url: url,
|
|
404
|
+
error: error instanceof Error ? error.message : String(error),
|
|
405
|
+
});
|
|
406
|
+
throw error;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
async tryCompletePendingAuthorization() {
|
|
410
|
+
const pending = readPendingAuthorization(this.options.clientId);
|
|
411
|
+
if (!pending) {
|
|
412
|
+
return null;
|
|
413
|
+
}
|
|
414
|
+
const outcome = collectRedirectOutcome(window.location);
|
|
415
|
+
if (!outcome) {
|
|
416
|
+
return null;
|
|
417
|
+
}
|
|
418
|
+
clearOAuthParamsFromUrl(new URL(window.location.href));
|
|
419
|
+
writePendingAuthorization(this.options.clientId, null);
|
|
420
|
+
if (outcome.error) {
|
|
421
|
+
const suffix = outcome.errorDescription
|
|
422
|
+
? ` - ${outcome.errorDescription}`
|
|
423
|
+
: '';
|
|
424
|
+
throw new Error(`OAuth2 authorization failed: ${outcome.error}${suffix}`);
|
|
425
|
+
}
|
|
426
|
+
if (!outcome.code) {
|
|
427
|
+
throw new Error('Authorization redirect missing code parameter');
|
|
428
|
+
}
|
|
429
|
+
if (!outcome.state || outcome.state !== pending.state) {
|
|
430
|
+
throw new Error('Authorization state mismatch');
|
|
431
|
+
}
|
|
432
|
+
const fetchImpl = this.resolveFetch();
|
|
433
|
+
const clientSecret = await this.resolveOptionalSecret(this.options.clientSecretProvider);
|
|
434
|
+
const token = await this.exchangeToken({
|
|
435
|
+
fetchImpl,
|
|
436
|
+
codeVerifier: pending.codeVerifier,
|
|
437
|
+
authorizationCode: outcome.code,
|
|
438
|
+
clientSecret,
|
|
439
|
+
scopes: pending.scopes,
|
|
440
|
+
audience: pending.audience,
|
|
441
|
+
});
|
|
442
|
+
this.persistToken(token, {
|
|
443
|
+
scopes: pending.scopes,
|
|
444
|
+
audience: pending.audience,
|
|
445
|
+
});
|
|
446
|
+
return token;
|
|
447
|
+
}
|
|
448
|
+
isTokenCompatible(scopes, audience) {
|
|
449
|
+
const expectedScopeKey = stableScopeKey(this.options.scopes);
|
|
450
|
+
const tokenScopeKey = stableScopeKey(scopes);
|
|
451
|
+
if (expectedScopeKey !== tokenScopeKey) {
|
|
452
|
+
return false;
|
|
453
|
+
}
|
|
454
|
+
const expectedAudience = this.options.audience ?? '';
|
|
455
|
+
const tokenAudience = audience ?? '';
|
|
456
|
+
return expectedAudience === tokenAudience;
|
|
457
|
+
}
|
|
458
|
+
persistToken(token, metadata) {
|
|
459
|
+
writePersistedToken(this.options.clientId, {
|
|
460
|
+
value: token.value,
|
|
461
|
+
expiresAt: token.expiresAt,
|
|
462
|
+
scopes: metadata.scopes ? [...metadata.scopes] : undefined,
|
|
463
|
+
audience: metadata.audience,
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
async buildAuthorizeUrl(params) {
|
|
467
|
+
const authorizeUrl = new URL(this.options.authorizeUrl);
|
|
468
|
+
authorizeUrl.searchParams.set('response_type', 'code');
|
|
469
|
+
authorizeUrl.searchParams.set('client_id', this.options.clientId);
|
|
470
|
+
authorizeUrl.searchParams.set('redirect_uri', this.options.redirectUri);
|
|
471
|
+
authorizeUrl.searchParams.set('state', params.state);
|
|
472
|
+
authorizeUrl.searchParams.set('code_challenge_method', params.codeChallengeMethod);
|
|
473
|
+
authorizeUrl.searchParams.set('code_challenge', params.codeChallenge);
|
|
474
|
+
if (this.options.scopes && this.options.scopes.length > 0) {
|
|
475
|
+
authorizeUrl.searchParams.set('scope', this.options.scopes.join(' '));
|
|
476
|
+
}
|
|
477
|
+
if (this.options.audience) {
|
|
478
|
+
authorizeUrl.searchParams.set('audience', this.options.audience);
|
|
479
|
+
}
|
|
480
|
+
const loginHint = await this.resolveOptionalSecret(this.options.usernameProvider);
|
|
481
|
+
if (loginHint) {
|
|
482
|
+
authorizeUrl.searchParams.set(this.options.loginHintParam ?? 'login_hint', loginHint);
|
|
483
|
+
}
|
|
484
|
+
return authorizeUrl.toString();
|
|
485
|
+
}
|
|
486
|
+
resolveFetch() {
|
|
487
|
+
if (this.options.fetchImpl) {
|
|
488
|
+
return this.options.fetchImpl;
|
|
489
|
+
}
|
|
490
|
+
if (typeof fetch === 'function') {
|
|
491
|
+
return fetch.bind(globalThis);
|
|
492
|
+
}
|
|
493
|
+
throw new Error('Global fetch implementation is not available. Provide fetchImpl in options.');
|
|
494
|
+
}
|
|
495
|
+
async resolveOptionalSecret(provider) {
|
|
496
|
+
if (!provider) {
|
|
497
|
+
return undefined;
|
|
498
|
+
}
|
|
499
|
+
const value = credentialToString(await provider.get());
|
|
500
|
+
return value ?? undefined;
|
|
501
|
+
}
|
|
502
|
+
async exchangeToken(params) {
|
|
503
|
+
const { fetchImpl, codeVerifier, authorizationCode, clientSecret, scopes, audience, } = params;
|
|
504
|
+
const form = new URLSearchParams({
|
|
505
|
+
grant_type: 'authorization_code',
|
|
506
|
+
client_id: this.options.clientId,
|
|
507
|
+
code: authorizationCode,
|
|
508
|
+
redirect_uri: this.options.redirectUri,
|
|
509
|
+
code_verifier: codeVerifier,
|
|
510
|
+
});
|
|
511
|
+
if (scopes && scopes.length > 0) {
|
|
512
|
+
form.set('scope', scopes.join(' '));
|
|
513
|
+
}
|
|
514
|
+
if (audience) {
|
|
515
|
+
form.set('audience', audience);
|
|
516
|
+
}
|
|
517
|
+
if (clientSecret) {
|
|
518
|
+
form.set('client_secret', clientSecret);
|
|
519
|
+
}
|
|
520
|
+
const response = await fetchImpl(this.options.tokenUrl, {
|
|
521
|
+
method: 'POST',
|
|
522
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
523
|
+
body: form.toString(),
|
|
524
|
+
});
|
|
525
|
+
if (!response.ok) {
|
|
526
|
+
const errorBody = await response.text().catch(() => '<unavailable>');
|
|
527
|
+
throw new Error(`OAuth2 PKCE token request failed: ${response.status} ${response.statusText} - ${errorBody}`);
|
|
528
|
+
}
|
|
529
|
+
const payload = (await response.json());
|
|
530
|
+
const accessToken = payload.access_token;
|
|
531
|
+
if (typeof accessToken !== 'string' || accessToken.length === 0) {
|
|
532
|
+
throw new Error('OAuth2 PKCE token response missing access_token');
|
|
533
|
+
}
|
|
534
|
+
const expiresInCandidate = typeof payload.expires_in === 'number'
|
|
535
|
+
? payload.expires_in
|
|
536
|
+
: typeof payload.expires === 'number'
|
|
537
|
+
? payload.expires
|
|
538
|
+
: undefined;
|
|
539
|
+
const expiresInSeconds = expiresInCandidate && Number.isFinite(expiresInCandidate)
|
|
540
|
+
? Math.max(1, Math.floor(expiresInCandidate))
|
|
541
|
+
: 3600;
|
|
542
|
+
const token = {
|
|
543
|
+
value: accessToken,
|
|
544
|
+
expiresAt: Date.now() + expiresInSeconds * 1000,
|
|
545
|
+
};
|
|
546
|
+
logger.debug('oauth2_pkce_token_fetched', {
|
|
547
|
+
authorize_url: this.options.authorizeUrl,
|
|
548
|
+
token_url: this.options.tokenUrl,
|
|
549
|
+
expires_in: expiresInSeconds,
|
|
550
|
+
scopes,
|
|
551
|
+
audience,
|
|
552
|
+
});
|
|
553
|
+
return token;
|
|
554
|
+
}
|
|
555
|
+
}
|