@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
|
@@ -1,17 +1,164 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/**
|
|
3
|
-
* OAuth2 client credentials grant
|
|
3
|
+
* OAuth2 client credentials and authorization code (PKCE) grant router for Fastify
|
|
4
4
|
*
|
|
5
|
-
* Provides /oauth/token
|
|
6
|
-
* Implements OAuth2 client credentials grant with JWT token issuance
|
|
5
|
+
* Provides /oauth/token and /oauth/authorize endpoints for local development and testing.
|
|
6
|
+
* Implements OAuth2 client credentials grant with JWT token issuance and
|
|
7
|
+
* OAuth2 authorization code grant with PKCE verification.
|
|
7
8
|
*/
|
|
8
9
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
10
|
exports.createOAuth2TokenRouter = createOAuth2TokenRouter;
|
|
10
11
|
const tslib_1 = require("tslib");
|
|
11
|
-
const
|
|
12
|
+
const formbody_1 = tslib_1.__importDefault(require("@fastify/formbody"));
|
|
13
|
+
const node_crypto_1 = require("node:crypto");
|
|
12
14
|
const jwt_token_issuer_js_1 = require("../security/auth/jwt-token-issuer.js");
|
|
13
15
|
const logging_js_1 = require("../util/logging.js");
|
|
14
16
|
const logger = (0, logging_js_1.getLogger)('naylence.fame.http.oauth2_token_router');
|
|
17
|
+
class RouterCompat {
|
|
18
|
+
constructor() {
|
|
19
|
+
this.routes = [];
|
|
20
|
+
}
|
|
21
|
+
get(path, handler) {
|
|
22
|
+
this.routes.push({ method: 'GET', path, handler });
|
|
23
|
+
}
|
|
24
|
+
post(path, handler) {
|
|
25
|
+
this.routes.push({ method: 'POST', path, handler });
|
|
26
|
+
}
|
|
27
|
+
toPlugin() {
|
|
28
|
+
return async (fastify) => {
|
|
29
|
+
await fastify.register(formbody_1.default);
|
|
30
|
+
for (const route of this.routes) {
|
|
31
|
+
fastify.route({
|
|
32
|
+
method: route.method,
|
|
33
|
+
url: route.path,
|
|
34
|
+
handler: async (request, reply) => {
|
|
35
|
+
const compatRequest = toCompatRequest(request);
|
|
36
|
+
const compatResponse = new FastifyResponseAdapter(reply);
|
|
37
|
+
await route.handler(compatRequest, compatResponse);
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
class FastifyResponseAdapter {
|
|
45
|
+
constructor(reply) {
|
|
46
|
+
this.reply = reply;
|
|
47
|
+
}
|
|
48
|
+
status(code) {
|
|
49
|
+
this.reply.status(code);
|
|
50
|
+
return this;
|
|
51
|
+
}
|
|
52
|
+
set(field, value) {
|
|
53
|
+
if (field.toLowerCase() === 'set-cookie') {
|
|
54
|
+
this.appendHeader(field, value);
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
this.reply.header(field, value);
|
|
58
|
+
}
|
|
59
|
+
return this;
|
|
60
|
+
}
|
|
61
|
+
type(contentType) {
|
|
62
|
+
const normalized = contentType === 'html'
|
|
63
|
+
? 'text/html'
|
|
64
|
+
: contentType === 'json'
|
|
65
|
+
? 'application/json'
|
|
66
|
+
: contentType;
|
|
67
|
+
this.reply.type(normalized);
|
|
68
|
+
return this;
|
|
69
|
+
}
|
|
70
|
+
json(payload) {
|
|
71
|
+
this.reply.send(payload);
|
|
72
|
+
}
|
|
73
|
+
send(payload) {
|
|
74
|
+
this.reply.send(payload);
|
|
75
|
+
}
|
|
76
|
+
redirect(statusOrUrl, maybeUrl) {
|
|
77
|
+
if (typeof statusOrUrl === 'number') {
|
|
78
|
+
if (maybeUrl === undefined) {
|
|
79
|
+
throw new Error('redirect url is required when status code is provided');
|
|
80
|
+
}
|
|
81
|
+
this.reply.status(statusOrUrl);
|
|
82
|
+
this.reply.header('Location', maybeUrl);
|
|
83
|
+
this.reply.send();
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
this.reply.redirect(statusOrUrl);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
cookie(name, value, options) {
|
|
90
|
+
const serialized = serializeCookie(name, value, options);
|
|
91
|
+
this.appendHeader('Set-Cookie', serialized);
|
|
92
|
+
}
|
|
93
|
+
appendHeader(name, value) {
|
|
94
|
+
const existing = this.reply.getHeader(name);
|
|
95
|
+
if (Array.isArray(existing)) {
|
|
96
|
+
this.reply.header(name, [...existing, value]);
|
|
97
|
+
}
|
|
98
|
+
else if (typeof existing === 'string') {
|
|
99
|
+
this.reply.header(name, [existing, value]);
|
|
100
|
+
}
|
|
101
|
+
else if (existing === undefined) {
|
|
102
|
+
this.reply.header(name, value);
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
this.reply.header(name, [String(existing), value]);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
function toCompatRequest(request) {
|
|
110
|
+
const headers = {};
|
|
111
|
+
for (const [key, value] of Object.entries(request.headers)) {
|
|
112
|
+
if (typeof value === 'string') {
|
|
113
|
+
headers[key.toLowerCase()] = value;
|
|
114
|
+
}
|
|
115
|
+
else if (Array.isArray(value)) {
|
|
116
|
+
headers[key.toLowerCase()] = value.join(', ');
|
|
117
|
+
}
|
|
118
|
+
else if (value !== undefined && value !== null) {
|
|
119
|
+
headers[key.toLowerCase()] = String(value);
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
headers[key.toLowerCase()] = undefined;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
body: request.body,
|
|
127
|
+
headers,
|
|
128
|
+
method: request.method,
|
|
129
|
+
originalUrl: request.raw.url ?? request.url,
|
|
130
|
+
query: request.query ?? {},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
function serializeCookie(name, value, options) {
|
|
134
|
+
const segments = [
|
|
135
|
+
`${encodeURIComponent(name)}=${encodeURIComponent(value)}`,
|
|
136
|
+
];
|
|
137
|
+
if (options.maxAge !== undefined) {
|
|
138
|
+
const maxAgeMs = options.maxAge;
|
|
139
|
+
const maxAgeSeconds = Math.floor(maxAgeMs / 1000);
|
|
140
|
+
segments.push(`Max-Age=${maxAgeSeconds}`);
|
|
141
|
+
const expires = new Date(Date.now() + maxAgeMs).toUTCString();
|
|
142
|
+
segments.push(`Expires=${expires}`);
|
|
143
|
+
}
|
|
144
|
+
segments.push(`Path=${options.path ?? '/'}`);
|
|
145
|
+
if (options.httpOnly) {
|
|
146
|
+
segments.push('HttpOnly');
|
|
147
|
+
}
|
|
148
|
+
if (options.secure) {
|
|
149
|
+
segments.push('Secure');
|
|
150
|
+
}
|
|
151
|
+
if (options.sameSite) {
|
|
152
|
+
const normalized = options.sameSite.toLowerCase();
|
|
153
|
+
const formatted = normalized === 'strict'
|
|
154
|
+
? 'Strict'
|
|
155
|
+
: normalized === 'none'
|
|
156
|
+
? 'None'
|
|
157
|
+
: 'Lax';
|
|
158
|
+
segments.push(`SameSite=${formatted}`);
|
|
159
|
+
}
|
|
160
|
+
return segments.join('; ');
|
|
161
|
+
}
|
|
15
162
|
const DEFAULT_PREFIX = '/oauth';
|
|
16
163
|
const ENV_VAR_CLIENT_ID = 'FAME_JWT_CLIENT_ID';
|
|
17
164
|
const ENV_VAR_CLIENT_SECRET = 'FAME_JWT_CLIENT_SECRET';
|
|
@@ -19,7 +166,21 @@ const ENV_VAR_ALLOWED_SCOPES = 'FAME_JWT_ALLOWED_SCOPES';
|
|
|
19
166
|
const ENV_VAR_JWT_ISSUER = 'FAME_JWT_ISSUER';
|
|
20
167
|
const ENV_VAR_JWT_ALGORITHM = 'FAME_JWT_ALGORITHM';
|
|
21
168
|
const ENV_VAR_JWT_AUDIENCE = 'FAME_JWT_AUDIENCE';
|
|
169
|
+
const ENV_VAR_ENABLE_PKCE = 'FAME_OAUTH_ENABLE_PKCE';
|
|
170
|
+
const ENV_VAR_ALLOW_PUBLIC_CLIENTS = 'FAME_OAUTH_ALLOW_PUBLIC_CLIENTS';
|
|
171
|
+
const ENV_VAR_AUTHORIZATION_CODE_TTL = 'FAME_OAUTH_CODE_TTL_SEC';
|
|
172
|
+
const ENV_VAR_ENABLE_DEV_LOGIN = 'FAME_OAUTH_ENABLE_DEV_LOGIN';
|
|
173
|
+
const ENV_VAR_DEV_LOGIN_USERNAME = 'FAME_OAUTH_DEV_USERNAME';
|
|
174
|
+
const ENV_VAR_DEV_LOGIN_PASSWORD = 'FAME_OAUTH_DEV_PASSWORD';
|
|
175
|
+
const ENV_VAR_SESSION_TTL = 'FAME_OAUTH_SESSION_TTL_SEC';
|
|
176
|
+
const ENV_VAR_SESSION_COOKIE_NAME = 'FAME_OAUTH_SESSION_COOKIE_NAME';
|
|
177
|
+
const ENV_VAR_SESSION_SECURE_COOKIE = 'FAME_OAUTH_SESSION_SECURE';
|
|
178
|
+
const ENV_VAR_LOGIN_TITLE = 'FAME_OAUTH_LOGIN_TITLE';
|
|
22
179
|
const DEFAULT_JWT_ALGORITHM = 'EdDSA';
|
|
180
|
+
const DEFAULT_AUTHORIZATION_CODE_TTL_SEC = 300;
|
|
181
|
+
const DEFAULT_SESSION_TTL_SEC = 3600;
|
|
182
|
+
const DEFAULT_SESSION_COOKIE_NAME = 'naylence_dev_session';
|
|
183
|
+
const DEFAULT_LOGIN_TITLE = 'Developer Login';
|
|
23
184
|
function coerceString(value) {
|
|
24
185
|
if (typeof value !== 'string') {
|
|
25
186
|
return undefined;
|
|
@@ -39,6 +200,29 @@ function coerceNumber(value) {
|
|
|
39
200
|
}
|
|
40
201
|
return undefined;
|
|
41
202
|
}
|
|
203
|
+
function coerceBoolean(value) {
|
|
204
|
+
if (typeof value === 'boolean') {
|
|
205
|
+
return value;
|
|
206
|
+
}
|
|
207
|
+
if (typeof value === 'string') {
|
|
208
|
+
const normalized = value.trim().toLowerCase();
|
|
209
|
+
if (normalized === 'true' || normalized === '1' || normalized === 'yes') {
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
if (normalized === 'false' || normalized === '0' || normalized === 'no') {
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
217
|
+
if (value === 0) {
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
if (value === 1) {
|
|
221
|
+
return true;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return undefined;
|
|
225
|
+
}
|
|
42
226
|
function coerceStringArray(value) {
|
|
43
227
|
if (Array.isArray(value)) {
|
|
44
228
|
const entries = value
|
|
@@ -70,6 +254,26 @@ function normalizeCreateOAuth2TokenRouterOptions(options) {
|
|
|
70
254
|
coerceString(descriptor.algorithm);
|
|
71
255
|
const tokenTtlSec = coerceNumber(descriptor.tokenTtlSec) ??
|
|
72
256
|
coerceNumber(descriptor.token_ttl_sec);
|
|
257
|
+
const enablePkce = coerceBoolean(descriptor.enablePkce) ??
|
|
258
|
+
coerceBoolean(descriptor.enable_pkce);
|
|
259
|
+
const allowPublicClients = coerceBoolean(descriptor.allowPublicClients) ??
|
|
260
|
+
coerceBoolean(descriptor.allow_public_clients);
|
|
261
|
+
const authorizationCodeTtlSec = coerceNumber(descriptor.authorizationCodeTtlSec) ??
|
|
262
|
+
coerceNumber(descriptor.authorization_code_ttl_sec);
|
|
263
|
+
const enableDevLogin = coerceBoolean(descriptor.enableDevLogin) ??
|
|
264
|
+
coerceBoolean(descriptor.enable_dev_login);
|
|
265
|
+
const devLoginUsername = coerceString(descriptor.devLoginUsername) ??
|
|
266
|
+
coerceString(descriptor.dev_login_username);
|
|
267
|
+
const devLoginPassword = coerceString(descriptor.devLoginPassword) ??
|
|
268
|
+
coerceString(descriptor.dev_login_password);
|
|
269
|
+
const devLoginSessionTtlSec = coerceNumber(descriptor.devLoginSessionTtlSec) ??
|
|
270
|
+
coerceNumber(descriptor.dev_login_session_ttl_sec);
|
|
271
|
+
const devLoginCookieName = coerceString(descriptor.devLoginCookieName) ??
|
|
272
|
+
coerceString(descriptor.dev_login_cookie_name);
|
|
273
|
+
const devLoginSecureCookie = coerceBoolean(descriptor.devLoginSecureCookie) ??
|
|
274
|
+
coerceBoolean(descriptor.dev_login_secure_cookie);
|
|
275
|
+
const devLoginTitle = coerceString(descriptor.devLoginTitle) ??
|
|
276
|
+
coerceString(descriptor.dev_login_title);
|
|
73
277
|
return {
|
|
74
278
|
cryptoProvider,
|
|
75
279
|
prefix,
|
|
@@ -78,6 +282,16 @@ function normalizeCreateOAuth2TokenRouterOptions(options) {
|
|
|
78
282
|
allowedScopes,
|
|
79
283
|
algorithm,
|
|
80
284
|
tokenTtlSec,
|
|
285
|
+
enablePkce,
|
|
286
|
+
allowPublicClients,
|
|
287
|
+
authorizationCodeTtlSec,
|
|
288
|
+
enableDevLogin,
|
|
289
|
+
devLoginUsername,
|
|
290
|
+
devLoginPassword,
|
|
291
|
+
devLoginSessionTtlSec,
|
|
292
|
+
devLoginCookieName,
|
|
293
|
+
devLoginSecureCookie,
|
|
294
|
+
devLoginTitle,
|
|
81
295
|
};
|
|
82
296
|
}
|
|
83
297
|
/**
|
|
@@ -143,11 +357,234 @@ function validateScope(requestedScope, allowedScopes) {
|
|
|
143
357
|
const grantedScopes = requestedScopes.filter((scope) => allowedScopes.includes(scope));
|
|
144
358
|
return grantedScopes.length > 0 ? grantedScopes : allowedScopes;
|
|
145
359
|
}
|
|
360
|
+
function base64UrlEncode(buffer) {
|
|
361
|
+
return Buffer.from(buffer)
|
|
362
|
+
.toString('base64')
|
|
363
|
+
.replace(/\+/g, '-')
|
|
364
|
+
.replace(/\//g, '_')
|
|
365
|
+
.replace(/=+$/u, '');
|
|
366
|
+
}
|
|
367
|
+
function computeS256Challenge(verifier) {
|
|
368
|
+
const digest = (0, node_crypto_1.createHash)('sha256').update(verifier, 'utf8').digest();
|
|
369
|
+
return base64UrlEncode(digest);
|
|
370
|
+
}
|
|
371
|
+
function safeTimingEqual(expected, actual) {
|
|
372
|
+
const expectedBuffer = Buffer.from(expected);
|
|
373
|
+
const actualBuffer = Buffer.from(actual);
|
|
374
|
+
if (expectedBuffer.length !== actualBuffer.length) {
|
|
375
|
+
return false;
|
|
376
|
+
}
|
|
377
|
+
return (0, node_crypto_1.timingSafeEqual)(expectedBuffer, actualBuffer);
|
|
378
|
+
}
|
|
379
|
+
function isValidCodeVerifier(value) {
|
|
380
|
+
if (!value) {
|
|
381
|
+
return false;
|
|
382
|
+
}
|
|
383
|
+
if (value.length < 43 || value.length > 128) {
|
|
384
|
+
return false;
|
|
385
|
+
}
|
|
386
|
+
return /^[A-Za-z0-9\-._~]+$/u.test(value);
|
|
387
|
+
}
|
|
388
|
+
function isValidCodeChallenge(value) {
|
|
389
|
+
if (!value) {
|
|
390
|
+
return false;
|
|
391
|
+
}
|
|
392
|
+
if (value.length < 43 || value.length > 128) {
|
|
393
|
+
return false;
|
|
394
|
+
}
|
|
395
|
+
return /^[A-Za-z0-9\-._~]+$/u.test(value);
|
|
396
|
+
}
|
|
397
|
+
function generateAuthorizationCode() {
|
|
398
|
+
return base64UrlEncode((0, node_crypto_1.randomBytes)(32));
|
|
399
|
+
}
|
|
400
|
+
function generateSessionId() {
|
|
401
|
+
return base64UrlEncode((0, node_crypto_1.randomBytes)(32));
|
|
402
|
+
}
|
|
403
|
+
function cleanupAuthorizationCodes(store, nowMs) {
|
|
404
|
+
for (const [code, record] of store.entries()) {
|
|
405
|
+
if (record.expiresAt <= nowMs) {
|
|
406
|
+
store.delete(code);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
function toSingleQueryValue(value) {
|
|
411
|
+
if (Array.isArray(value)) {
|
|
412
|
+
return value.length > 0 ? coerceString(value[0]) : undefined;
|
|
413
|
+
}
|
|
414
|
+
return coerceString(value);
|
|
415
|
+
}
|
|
416
|
+
function ensurePositiveInteger(value) {
|
|
417
|
+
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
|
|
418
|
+
return Math.floor(value);
|
|
419
|
+
}
|
|
420
|
+
return undefined;
|
|
421
|
+
}
|
|
422
|
+
function parseCookies(cookieHeader) {
|
|
423
|
+
if (!cookieHeader) {
|
|
424
|
+
return {};
|
|
425
|
+
}
|
|
426
|
+
return cookieHeader.split(';').reduce((acc, entry) => {
|
|
427
|
+
const [rawName, ...rawValueParts] = entry.split('=');
|
|
428
|
+
const name = rawName?.trim();
|
|
429
|
+
if (!name) {
|
|
430
|
+
return acc;
|
|
431
|
+
}
|
|
432
|
+
const rawValue = rawValueParts.join('=').trim();
|
|
433
|
+
if (rawValue.length === 0) {
|
|
434
|
+
return acc;
|
|
435
|
+
}
|
|
436
|
+
try {
|
|
437
|
+
acc[name] = decodeURIComponent(rawValue);
|
|
438
|
+
}
|
|
439
|
+
catch {
|
|
440
|
+
acc[name] = rawValue;
|
|
441
|
+
}
|
|
442
|
+
return acc;
|
|
443
|
+
}, {});
|
|
444
|
+
}
|
|
445
|
+
function sanitizeReturnTo(value, allowedPrefix, fallback) {
|
|
446
|
+
if (!value) {
|
|
447
|
+
return fallback;
|
|
448
|
+
}
|
|
449
|
+
try {
|
|
450
|
+
const candidate = new URL(value, 'http://localhost');
|
|
451
|
+
if (candidate.origin !== 'http://localhost') {
|
|
452
|
+
return fallback;
|
|
453
|
+
}
|
|
454
|
+
if (!candidate.pathname.startsWith(allowedPrefix)) {
|
|
455
|
+
return fallback;
|
|
456
|
+
}
|
|
457
|
+
return `${candidate.pathname}${candidate.search}${candidate.hash}`;
|
|
458
|
+
}
|
|
459
|
+
catch {
|
|
460
|
+
return fallback;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
function escapeHtml(text) {
|
|
464
|
+
if (!text) {
|
|
465
|
+
return '';
|
|
466
|
+
}
|
|
467
|
+
return text
|
|
468
|
+
.replace(/&/g, '&')
|
|
469
|
+
.replace(/</g, '<')
|
|
470
|
+
.replace(/>/g, '>')
|
|
471
|
+
.replace(/"/g, '"')
|
|
472
|
+
.replace(/'/g, ''');
|
|
473
|
+
}
|
|
474
|
+
function renderLoginPage(options) {
|
|
475
|
+
const { title, prefix, returnTo, username, errorMessage } = options;
|
|
476
|
+
return `<!DOCTYPE html>
|
|
477
|
+
<html lang="en">
|
|
478
|
+
<head>
|
|
479
|
+
<meta charset="utf-8" />
|
|
480
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
481
|
+
<title>${escapeHtml(title)}</title>
|
|
482
|
+
<style>
|
|
483
|
+
:root { color-scheme: light dark; }
|
|
484
|
+
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f172a; min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 16px; }
|
|
485
|
+
.card { width: min(360px, 100%); background: rgba(15, 23, 42, 0.92); border-radius: 16px; padding: 32px; box-shadow: 0 20px 45px rgba(15, 23, 42, 0.25); color: #e2e8f0; backdrop-filter: blur(20px); }
|
|
486
|
+
h1 { margin: 0 0 24px; font-size: 24px; font-weight: 600; text-align: center; }
|
|
487
|
+
label { display: block; font-size: 14px; margin-bottom: 8px; color: #cbd5f5; }
|
|
488
|
+
input[type="text"], input[type="password"] { width: 100%; padding: 12px 14px; border-radius: 10px; border: 1px solid rgba(148, 163, 184, 0.4); background: rgba(15, 23, 42, 0.6); color: inherit; font-size: 15px; transition: border-color 0.2s, box-shadow 0.2s; }
|
|
489
|
+
input[type="text"]:focus, input[type="password"]:focus { outline: none; border-color: #38bdf8; box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.25); }
|
|
490
|
+
.field { margin-bottom: 18px; }
|
|
491
|
+
button { width: 100%; padding: 12px 14px; border-radius: 10px; border: none; background: linear-gradient(135deg, #38bdf8, #818cf8); color: #0f172a; font-weight: 600; font-size: 15px; cursor: pointer; transition: transform 0.15s, box-shadow 0.15s; }
|
|
492
|
+
button:hover { transform: translateY(-1px); box-shadow: 0 10px 25px rgba(129, 140, 248, 0.35); }
|
|
493
|
+
.error { margin-bottom: 18px; padding: 12px 14px; border-radius: 10px; background: rgba(239, 68, 68, 0.18); color: #fecaca; font-size: 13px; }
|
|
494
|
+
.support { margin-top: 16px; font-size: 12px; text-align: center; color: rgba(148, 163, 184, 0.75); }
|
|
495
|
+
a { color: #38bdf8; text-decoration: none; }
|
|
496
|
+
a:hover { text-decoration: underline; }
|
|
497
|
+
.brand { text-align: center; font-size: 14px; letter-spacing: 0.08em; text-transform: uppercase; color: rgba(148, 163, 184, 0.9); margin-bottom: 12px; }
|
|
498
|
+
</style>
|
|
499
|
+
</head>
|
|
500
|
+
<body>
|
|
501
|
+
<main class="card">
|
|
502
|
+
<div class="brand">NAYLENCE</div>
|
|
503
|
+
<h1>${escapeHtml(title)}</h1>
|
|
504
|
+
${errorMessage ? `<div class="error">${escapeHtml(errorMessage)}</div>` : ''}
|
|
505
|
+
<form method="post" action="${escapeHtml(`${prefix}/login`)}">
|
|
506
|
+
<div class="field">
|
|
507
|
+
<label for="username">Username</label>
|
|
508
|
+
<input
|
|
509
|
+
id="username"
|
|
510
|
+
name="username"
|
|
511
|
+
type="text"
|
|
512
|
+
autocomplete="username"
|
|
513
|
+
value="${escapeHtml(username ?? '')}"
|
|
514
|
+
required
|
|
515
|
+
/>
|
|
516
|
+
</div>
|
|
517
|
+
<div class="field">
|
|
518
|
+
<label for="password">Password</label>
|
|
519
|
+
<input
|
|
520
|
+
id="password"
|
|
521
|
+
name="password"
|
|
522
|
+
type="password"
|
|
523
|
+
autocomplete="current-password"
|
|
524
|
+
required
|
|
525
|
+
/>
|
|
526
|
+
</div>
|
|
527
|
+
<input type="hidden" name="return_to" value="${escapeHtml(returnTo)}" />
|
|
528
|
+
<button type="submit">Sign in</button>
|
|
529
|
+
</form>
|
|
530
|
+
<p class="support">Cookies are used to keep your session active in this local environment.</p>
|
|
531
|
+
</main>
|
|
532
|
+
</body>
|
|
533
|
+
</html>`;
|
|
534
|
+
}
|
|
535
|
+
function normalizeCookiePath(prefix) {
|
|
536
|
+
if (!prefix || prefix === '/') {
|
|
537
|
+
return '/';
|
|
538
|
+
}
|
|
539
|
+
return prefix.endsWith('/') && prefix.length > 1
|
|
540
|
+
? prefix.slice(0, -1)
|
|
541
|
+
: prefix;
|
|
542
|
+
}
|
|
543
|
+
function cleanupLoginSessions(store, nowMs) {
|
|
544
|
+
for (const [key, record] of store.entries()) {
|
|
545
|
+
if (record.expiresAt <= nowMs) {
|
|
546
|
+
store.delete(key);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
function getActiveSession(req, store, cookieName, sessionTtlMs) {
|
|
551
|
+
const cookies = parseCookies(req.headers.cookie);
|
|
552
|
+
const sessionId = cookies[cookieName];
|
|
553
|
+
if (!sessionId) {
|
|
554
|
+
return undefined;
|
|
555
|
+
}
|
|
556
|
+
const record = store.get(sessionId);
|
|
557
|
+
if (!record) {
|
|
558
|
+
return undefined;
|
|
559
|
+
}
|
|
560
|
+
const now = Date.now();
|
|
561
|
+
if (record.expiresAt <= now) {
|
|
562
|
+
store.delete(sessionId);
|
|
563
|
+
return undefined;
|
|
564
|
+
}
|
|
565
|
+
record.expiresAt = now + sessionTtlMs;
|
|
566
|
+
store.set(sessionId, record);
|
|
567
|
+
return record;
|
|
568
|
+
}
|
|
569
|
+
function setNoCacheHeaders(res) {
|
|
570
|
+
res.set('Cache-Control', 'no-store');
|
|
571
|
+
res.set('Pragma', 'no-cache');
|
|
572
|
+
}
|
|
573
|
+
function respondInvalidClient(res) {
|
|
574
|
+
res
|
|
575
|
+
.status(401)
|
|
576
|
+
.set('WWW-Authenticate', 'Basic')
|
|
577
|
+
.json({
|
|
578
|
+
error: 'invalid_client',
|
|
579
|
+
error_description: 'Invalid client credentials',
|
|
580
|
+
});
|
|
581
|
+
}
|
|
146
582
|
/**
|
|
147
|
-
* Create
|
|
583
|
+
* Create a Fastify plugin that implements OAuth2 token and authorization endpoints
|
|
584
|
+
* with support for client credentials and authorization code (PKCE) grants.
|
|
148
585
|
*
|
|
149
586
|
* @param options - Router configuration options
|
|
150
|
-
* @returns
|
|
587
|
+
* @returns Fastify plugin with OAuth2 token and authorization endpoints
|
|
151
588
|
*
|
|
152
589
|
* Environment Variables:
|
|
153
590
|
* FAME_JWT_CLIENT_ID: OAuth2 client identifier
|
|
@@ -156,26 +593,17 @@ function validateScope(requestedScope, allowedScopes) {
|
|
|
156
593
|
* FAME_JWT_AUDIENCE: JWT audience claim (optional)
|
|
157
594
|
* FAME_JWT_ALGORITHM: JWT signing algorithm (optional, default: EdDSA)
|
|
158
595
|
* FAME_JWT_ALLOWED_SCOPES: Allowed scopes (optional, default: node.connect)
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
* import express from 'express';
|
|
163
|
-
* import { createOAuth2TokenRouter } from '@naylence/runtime';
|
|
164
|
-
*
|
|
165
|
-
* const app = express();
|
|
166
|
-
* app.use(express.urlencoded({ extended: true }));
|
|
167
|
-
*
|
|
168
|
-
* const cryptoProvider = new MyCryptoProvider();
|
|
169
|
-
* app.use(createOAuth2TokenRouter({ cryptoProvider }));
|
|
170
|
-
* ```
|
|
596
|
+
* FAME_OAUTH_ENABLE_PKCE: Enable PKCE authorization endpoints (optional, default: true)
|
|
597
|
+
* FAME_OAUTH_ALLOW_PUBLIC_CLIENTS: Allow PKCE exchanges without client_secret (optional, default: true)
|
|
598
|
+
* FAME_OAUTH_CODE_TTL_SEC: Authorization code TTL in seconds (optional, default: 300)
|
|
171
599
|
*/
|
|
172
600
|
function createOAuth2TokenRouter(options) {
|
|
173
|
-
const router =
|
|
174
|
-
const { cryptoProvider, prefix = DEFAULT_PREFIX, issuer, audience, tokenTtlSec, allowedScopes: configAllowedScopes, algorithm: configAlgorithm, } = normalizeCreateOAuth2TokenRouterOptions(options);
|
|
601
|
+
const router = new RouterCompat();
|
|
602
|
+
const { cryptoProvider, prefix = DEFAULT_PREFIX, issuer, audience, tokenTtlSec, allowedScopes: configAllowedScopes, algorithm: configAlgorithm, enablePkce: configEnablePkce, allowPublicClients: configAllowPublicClients, authorizationCodeTtlSec: configAuthorizationCodeTtlSec, enableDevLogin: configEnableDevLogin, devLoginUsername: configDevLoginUsername, devLoginPassword: configDevLoginPassword, devLoginSessionTtlSec: configDevLoginSessionTtlSec, devLoginCookieName: configDevLoginCookieName, devLoginSecureCookie: configDevLoginSecureCookie, devLoginTitle: configDevLoginTitle, } = normalizeCreateOAuth2TokenRouterOptions(options);
|
|
175
603
|
if (!cryptoProvider) {
|
|
176
604
|
throw new Error('cryptoProvider is required to create OAuth2 token router');
|
|
177
605
|
}
|
|
178
|
-
|
|
606
|
+
const provider = cryptoProvider;
|
|
179
607
|
const defaultIssuer = process.env[ENV_VAR_JWT_ISSUER] ?? issuer ?? 'https://auth.fame.fabric';
|
|
180
608
|
const defaultAudience = process.env[ENV_VAR_JWT_AUDIENCE] ?? audience ?? 'fame-fabric';
|
|
181
609
|
const algorithm = process.env[ENV_VAR_JWT_ALGORITHM] ??
|
|
@@ -183,6 +611,38 @@ function createOAuth2TokenRouter(options) {
|
|
|
183
611
|
DEFAULT_JWT_ALGORITHM;
|
|
184
612
|
const allowedScopes = getAllowedScopes(configAllowedScopes);
|
|
185
613
|
const resolvedTokenTtlSec = tokenTtlSec ?? 3600;
|
|
614
|
+
const enablePkce = coerceBoolean(process.env[ENV_VAR_ENABLE_PKCE]) ??
|
|
615
|
+
(configEnablePkce ?? true);
|
|
616
|
+
const allowPublicClients = coerceBoolean(process.env[ENV_VAR_ALLOW_PUBLIC_CLIENTS]) ??
|
|
617
|
+
(configAllowPublicClients ?? true);
|
|
618
|
+
const authorizationCodeTtlSec = ensurePositiveInteger(coerceNumber(process.env[ENV_VAR_AUTHORIZATION_CODE_TTL]) ??
|
|
619
|
+
configAuthorizationCodeTtlSec) ?? DEFAULT_AUTHORIZATION_CODE_TTL_SEC;
|
|
620
|
+
const devLoginExplicitlyEnabled = coerceBoolean(process.env[ENV_VAR_ENABLE_DEV_LOGIN]) ??
|
|
621
|
+
configEnableDevLogin;
|
|
622
|
+
const devLoginUsername = coerceString(process.env[ENV_VAR_DEV_LOGIN_USERNAME]) ??
|
|
623
|
+
configDevLoginUsername;
|
|
624
|
+
const devLoginPassword = coerceString(process.env[ENV_VAR_DEV_LOGIN_PASSWORD]) ??
|
|
625
|
+
configDevLoginPassword;
|
|
626
|
+
const devLoginSessionTtlSec = ensurePositiveInteger(coerceNumber(process.env[ENV_VAR_SESSION_TTL]) ??
|
|
627
|
+
configDevLoginSessionTtlSec) ?? DEFAULT_SESSION_TTL_SEC;
|
|
628
|
+
const devLoginCookieName = coerceString(process.env[ENV_VAR_SESSION_COOKIE_NAME]) ??
|
|
629
|
+
configDevLoginCookieName ??
|
|
630
|
+
DEFAULT_SESSION_COOKIE_NAME;
|
|
631
|
+
const devLoginSecureCookie = coerceBoolean(process.env[ENV_VAR_SESSION_SECURE_COOKIE]) ??
|
|
632
|
+
(configDevLoginSecureCookie ?? false);
|
|
633
|
+
const devLoginTitle = coerceString(process.env[ENV_VAR_LOGIN_TITLE]) ??
|
|
634
|
+
configDevLoginTitle ??
|
|
635
|
+
DEFAULT_LOGIN_TITLE;
|
|
636
|
+
const devLoginCredentialsConfigured = !!devLoginUsername && !!devLoginPassword;
|
|
637
|
+
const devLoginEnabled = (devLoginExplicitlyEnabled ?? false) || devLoginCredentialsConfigured;
|
|
638
|
+
if (devLoginEnabled && !devLoginCredentialsConfigured) {
|
|
639
|
+
throw new Error('Developer login is enabled but credentials are not configured');
|
|
640
|
+
}
|
|
641
|
+
const sessionCookiePath = normalizeCookiePath(prefix);
|
|
642
|
+
const authorizationRedirectPath = prefix.endsWith('/')
|
|
643
|
+
? `${prefix}authorize`
|
|
644
|
+
: `${prefix}/authorize`;
|
|
645
|
+
const devLoginSessionTtlMs = devLoginSessionTtlSec * 1000;
|
|
186
646
|
logger.debug('oauth2_router_created', {
|
|
187
647
|
prefix,
|
|
188
648
|
issuer: defaultIssuer,
|
|
@@ -190,20 +650,265 @@ function createOAuth2TokenRouter(options) {
|
|
|
190
650
|
algorithm,
|
|
191
651
|
allowedScopes,
|
|
192
652
|
tokenTtlSec: resolvedTokenTtlSec,
|
|
653
|
+
enablePkce,
|
|
654
|
+
allowPublicClients,
|
|
655
|
+
authorizationCodeTtlSec,
|
|
656
|
+
devLoginEnabled,
|
|
657
|
+
devLoginSessionTtlSec,
|
|
658
|
+
devLoginCookieName,
|
|
659
|
+
devLoginSecureCookie,
|
|
193
660
|
});
|
|
194
|
-
|
|
195
|
-
|
|
661
|
+
const authorizationCodes = new Map();
|
|
662
|
+
const loginSessions = new Map();
|
|
663
|
+
if (devLoginEnabled) {
|
|
664
|
+
logger.info('oauth2_dev_login_enabled', {
|
|
665
|
+
loginTitle: devLoginTitle,
|
|
666
|
+
cookieName: devLoginCookieName,
|
|
667
|
+
sessionTtlSec: devLoginSessionTtlSec,
|
|
668
|
+
secureCookie: devLoginSecureCookie,
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
router.get(`${prefix}/authorize`, (req, res) => {
|
|
672
|
+
if (!enablePkce) {
|
|
673
|
+
res.status(404).json({
|
|
674
|
+
error: 'endpoint_disabled',
|
|
675
|
+
error_description: 'PKCE authorization endpoint is disabled',
|
|
676
|
+
});
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
cleanupAuthorizationCodes(authorizationCodes, Date.now());
|
|
680
|
+
if (devLoginEnabled) {
|
|
681
|
+
cleanupLoginSessions(loginSessions, Date.now());
|
|
682
|
+
const activeSession = getActiveSession(req, loginSessions, devLoginCookieName, devLoginSessionTtlMs);
|
|
683
|
+
if (!activeSession) {
|
|
684
|
+
const returnTo = sanitizeReturnTo(req.originalUrl, sessionCookiePath, authorizationRedirectPath);
|
|
685
|
+
const loginLocation = `${prefix}/login?return_to=${encodeURIComponent(returnTo)}`;
|
|
686
|
+
setNoCacheHeaders(res);
|
|
687
|
+
res.redirect(302, loginLocation);
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
let configuredCreds;
|
|
692
|
+
try {
|
|
693
|
+
configuredCreds = getConfiguredClientCredentials();
|
|
694
|
+
}
|
|
695
|
+
catch (error) {
|
|
696
|
+
logger.error('oauth2_config_error', {
|
|
697
|
+
error: error.message,
|
|
698
|
+
});
|
|
699
|
+
res.status(500).json({
|
|
700
|
+
error: 'server_error',
|
|
701
|
+
error_description: 'Server configuration error',
|
|
702
|
+
});
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
const responseType = toSingleQueryValue(req.query.response_type);
|
|
706
|
+
if (responseType !== 'code') {
|
|
707
|
+
res.status(400).json({
|
|
708
|
+
error: 'unsupported_response_type',
|
|
709
|
+
error_description: 'Only authorization code response type is supported',
|
|
710
|
+
});
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
const clientId = toSingleQueryValue(req.query.client_id);
|
|
714
|
+
if (!clientId) {
|
|
715
|
+
res.status(400).json({
|
|
716
|
+
error: 'invalid_request',
|
|
717
|
+
error_description: 'client_id is required',
|
|
718
|
+
});
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
if (clientId !== configuredCreds.clientId) {
|
|
722
|
+
logger.warning('oauth2_authorize_invalid_client', { clientId });
|
|
723
|
+
respondInvalidClient(res);
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
const redirectUriText = toSingleQueryValue(req.query.redirect_uri);
|
|
727
|
+
if (!redirectUriText) {
|
|
728
|
+
res.status(400).json({
|
|
729
|
+
error: 'invalid_request',
|
|
730
|
+
error_description: 'redirect_uri is required',
|
|
731
|
+
});
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
let redirectUrl;
|
|
196
735
|
try {
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
736
|
+
redirectUrl = new URL(redirectUriText);
|
|
737
|
+
}
|
|
738
|
+
catch {
|
|
739
|
+
res.status(400).json({
|
|
740
|
+
error: 'invalid_request',
|
|
741
|
+
error_description: 'redirect_uri must be a valid absolute URL',
|
|
742
|
+
});
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
const requestedScope = toSingleQueryValue(req.query.scope);
|
|
746
|
+
const grantedScopes = validateScope(requestedScope, allowedScopes);
|
|
747
|
+
const codeChallenge = toSingleQueryValue(req.query.code_challenge);
|
|
748
|
+
if (!isValidCodeChallenge(codeChallenge)) {
|
|
749
|
+
res.status(400).json({
|
|
750
|
+
error: 'invalid_request',
|
|
751
|
+
error_description: 'code_challenge is invalid or missing',
|
|
752
|
+
});
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
const codeChallengeMethodCandidate = (toSingleQueryValue(req.query.code_challenge_method) ?? 'S256').toUpperCase();
|
|
756
|
+
const codeChallengeMethod = codeChallengeMethodCandidate === 'PLAIN' ? 'PLAIN' : 'S256';
|
|
757
|
+
const state = toSingleQueryValue(req.query.state);
|
|
758
|
+
const authorizationCode = generateAuthorizationCode();
|
|
759
|
+
const expiresAt = Date.now() + authorizationCodeTtlSec * 1000;
|
|
760
|
+
authorizationCodes.set(authorizationCode, {
|
|
761
|
+
code: authorizationCode,
|
|
762
|
+
clientId,
|
|
763
|
+
redirectUri: redirectUrl.toString(),
|
|
764
|
+
scope: grantedScopes,
|
|
765
|
+
codeChallenge,
|
|
766
|
+
codeChallengeMethod,
|
|
767
|
+
expiresAt,
|
|
768
|
+
requestedState: state,
|
|
769
|
+
});
|
|
770
|
+
logger.debug('oauth2_authorization_code_issued', {
|
|
771
|
+
clientId,
|
|
772
|
+
scope: grantedScopes,
|
|
773
|
+
method: codeChallengeMethod,
|
|
774
|
+
expiresAt,
|
|
775
|
+
});
|
|
776
|
+
const redirectLocation = new URL(redirectUrl.toString());
|
|
777
|
+
redirectLocation.searchParams.set('code', authorizationCode);
|
|
778
|
+
if (state) {
|
|
779
|
+
redirectLocation.searchParams.set('state', state);
|
|
780
|
+
}
|
|
781
|
+
if (grantedScopes.length > 0) {
|
|
782
|
+
redirectLocation.searchParams.set('scope', grantedScopes.join(' '));
|
|
783
|
+
}
|
|
784
|
+
setNoCacheHeaders(res);
|
|
785
|
+
res.redirect(302, redirectLocation.toString());
|
|
786
|
+
});
|
|
787
|
+
router.get(`${prefix}/login`, (req, res) => {
|
|
788
|
+
if (!devLoginEnabled) {
|
|
789
|
+
res.status(404).json({
|
|
790
|
+
error: 'endpoint_disabled',
|
|
791
|
+
error_description: 'Developer login is disabled',
|
|
792
|
+
});
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
cleanupLoginSessions(loginSessions, Date.now());
|
|
796
|
+
const returnTo = sanitizeReturnTo(toSingleQueryValue(req.query.return_to), sessionCookiePath, authorizationRedirectPath);
|
|
797
|
+
const session = getActiveSession(req, loginSessions, devLoginCookieName, devLoginSessionTtlMs);
|
|
798
|
+
if (session) {
|
|
799
|
+
setNoCacheHeaders(res);
|
|
800
|
+
res.redirect(302, returnTo);
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
const html = renderLoginPage({
|
|
804
|
+
title: devLoginTitle,
|
|
805
|
+
prefix,
|
|
806
|
+
returnTo,
|
|
807
|
+
username: undefined,
|
|
808
|
+
errorMessage: undefined,
|
|
809
|
+
});
|
|
810
|
+
setNoCacheHeaders(res);
|
|
811
|
+
res.status(200).type('html').send(html);
|
|
812
|
+
});
|
|
813
|
+
router.post(`${prefix}/login`, (req, res) => {
|
|
814
|
+
if (!devLoginEnabled) {
|
|
815
|
+
res.status(404).json({
|
|
816
|
+
error: 'endpoint_disabled',
|
|
817
|
+
error_description: 'Developer login is disabled',
|
|
818
|
+
});
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
cleanupLoginSessions(loginSessions, Date.now());
|
|
822
|
+
const username = coerceString(req.body?.username);
|
|
823
|
+
const password = coerceString(req.body?.password);
|
|
824
|
+
const returnTo = sanitizeReturnTo(coerceString(req.body?.return_to), sessionCookiePath, authorizationRedirectPath);
|
|
825
|
+
if (!username || !password) {
|
|
826
|
+
const html = renderLoginPage({
|
|
827
|
+
title: devLoginTitle,
|
|
828
|
+
prefix,
|
|
829
|
+
returnTo,
|
|
830
|
+
username: username ?? undefined,
|
|
831
|
+
errorMessage: 'Username and password are required.',
|
|
832
|
+
});
|
|
833
|
+
setNoCacheHeaders(res);
|
|
834
|
+
res.status(400).type('html').send(html);
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
if (username !== devLoginUsername || password !== devLoginPassword) {
|
|
838
|
+
logger.warning('oauth2_dev_login_failed', { username });
|
|
839
|
+
const html = renderLoginPage({
|
|
840
|
+
title: devLoginTitle,
|
|
841
|
+
prefix,
|
|
842
|
+
returnTo,
|
|
843
|
+
username,
|
|
844
|
+
errorMessage: 'Invalid username or password.',
|
|
845
|
+
});
|
|
846
|
+
setNoCacheHeaders(res);
|
|
847
|
+
res.status(401).type('html').send(html);
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
const sessionId = generateSessionId();
|
|
851
|
+
const expiresAt = Date.now() + devLoginSessionTtlMs;
|
|
852
|
+
loginSessions.set(sessionId, {
|
|
853
|
+
id: sessionId,
|
|
854
|
+
username,
|
|
855
|
+
expiresAt,
|
|
856
|
+
});
|
|
857
|
+
const cookieOptions = {
|
|
858
|
+
httpOnly: true,
|
|
859
|
+
sameSite: 'lax',
|
|
860
|
+
path: sessionCookiePath,
|
|
861
|
+
maxAge: devLoginSessionTtlMs,
|
|
862
|
+
};
|
|
863
|
+
if (devLoginSecureCookie) {
|
|
864
|
+
cookieOptions.secure = true;
|
|
865
|
+
}
|
|
866
|
+
res.cookie(devLoginCookieName, sessionId, cookieOptions);
|
|
867
|
+
logger.info('oauth2_dev_login_success', { username });
|
|
868
|
+
setNoCacheHeaders(res);
|
|
869
|
+
res.redirect(302, returnTo);
|
|
870
|
+
});
|
|
871
|
+
const logoutHandler = (req, res) => {
|
|
872
|
+
if (!devLoginEnabled) {
|
|
873
|
+
res.status(404).json({
|
|
874
|
+
error: 'endpoint_disabled',
|
|
875
|
+
error_description: 'Developer login is disabled',
|
|
876
|
+
});
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
cleanupLoginSessions(loginSessions, Date.now());
|
|
880
|
+
const cookies = parseCookies(req.headers.cookie);
|
|
881
|
+
const sessionId = cookies[devLoginCookieName];
|
|
882
|
+
if (sessionId) {
|
|
883
|
+
loginSessions.delete(sessionId);
|
|
884
|
+
}
|
|
885
|
+
const cookieOptions = {
|
|
886
|
+
httpOnly: true,
|
|
887
|
+
sameSite: 'lax',
|
|
888
|
+
path: sessionCookiePath,
|
|
889
|
+
maxAge: 0,
|
|
890
|
+
};
|
|
891
|
+
if (devLoginSecureCookie) {
|
|
892
|
+
cookieOptions.secure = true;
|
|
893
|
+
}
|
|
894
|
+
res.cookie(devLoginCookieName, '', cookieOptions);
|
|
895
|
+
setNoCacheHeaders(res);
|
|
896
|
+
res.redirect(302, `${prefix}/login`);
|
|
897
|
+
};
|
|
898
|
+
router.post(`${prefix}/logout`, logoutHandler);
|
|
899
|
+
router.get(`${prefix}/logout`, logoutHandler);
|
|
900
|
+
router.post(`${prefix}/token`, async (req, res) => {
|
|
901
|
+
try {
|
|
902
|
+
cleanupAuthorizationCodes(authorizationCodes, Date.now());
|
|
903
|
+
const { grant_type, client_id, client_secret, scope, audience: reqAudience, code, redirect_uri, code_verifier, } = req.body ?? {};
|
|
904
|
+
if (grant_type !== 'client_credentials' &&
|
|
905
|
+
grant_type !== 'authorization_code') {
|
|
200
906
|
res.status(400).json({
|
|
201
907
|
error: 'unsupported_grant_type',
|
|
202
|
-
error_description: 'Only client_credentials grant
|
|
908
|
+
error_description: 'Only client_credentials and authorization_code grant types are supported',
|
|
203
909
|
});
|
|
204
910
|
return;
|
|
205
911
|
}
|
|
206
|
-
// Get configured credentials
|
|
207
912
|
let configuredCreds;
|
|
208
913
|
try {
|
|
209
914
|
configuredCreds = getConfiguredClientCredentials();
|
|
@@ -218,43 +923,148 @@ function createOAuth2TokenRouter(options) {
|
|
|
218
923
|
});
|
|
219
924
|
return;
|
|
220
925
|
}
|
|
221
|
-
// Extract client credentials from request
|
|
222
|
-
let requestCreds = null;
|
|
223
|
-
// Try Basic Auth first
|
|
224
926
|
const authHeader = req.headers.authorization;
|
|
225
|
-
|
|
226
|
-
|
|
927
|
+
const basicAuthCreds = parseBasicAuth(authHeader);
|
|
928
|
+
const bodyClientId = coerceString(client_id);
|
|
929
|
+
const bodyClientSecret = coerceString(client_secret);
|
|
930
|
+
const resolvedClientId = basicAuthCreds?.clientId ?? bodyClientId;
|
|
931
|
+
if (!resolvedClientId) {
|
|
932
|
+
res.status(400).json({
|
|
933
|
+
error: 'invalid_request',
|
|
934
|
+
error_description: 'client_id is required',
|
|
935
|
+
});
|
|
936
|
+
return;
|
|
227
937
|
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
938
|
+
if (resolvedClientId !== configuredCreds.clientId) {
|
|
939
|
+
logger.warning('oauth2_invalid_client_id', {
|
|
940
|
+
clientId: resolvedClientId,
|
|
941
|
+
});
|
|
942
|
+
respondInvalidClient(res);
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
945
|
+
const providedSecret = basicAuthCreds?.clientSecret ?? bodyClientSecret ?? undefined;
|
|
946
|
+
let clientAuthenticated = false;
|
|
947
|
+
if (providedSecret !== undefined) {
|
|
948
|
+
clientAuthenticated = verifyClientCredentials({ clientId: resolvedClientId, clientSecret: providedSecret }, configuredCreds);
|
|
949
|
+
if (!clientAuthenticated) {
|
|
950
|
+
logger.warning('oauth2_invalid_credentials', {
|
|
951
|
+
clientId: resolvedClientId,
|
|
952
|
+
});
|
|
953
|
+
respondInvalidClient(res);
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
231
956
|
}
|
|
232
|
-
if (
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
957
|
+
if (grant_type === 'client_credentials') {
|
|
958
|
+
if (!clientAuthenticated) {
|
|
959
|
+
respondInvalidClient(res);
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
if (!provider.signingPrivatePem || !provider.signatureKeyId) {
|
|
963
|
+
logger.error('oauth2_missing_keys', {
|
|
964
|
+
hasPrivateKey: !!provider.signingPrivatePem,
|
|
965
|
+
hasKeyId: !!provider.signatureKeyId,
|
|
966
|
+
});
|
|
967
|
+
res.status(500).json({
|
|
968
|
+
error: 'server_error',
|
|
969
|
+
error_description: 'Server cryptographic configuration error',
|
|
970
|
+
});
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
const grantedScopes = validateScope(scope, allowedScopes);
|
|
974
|
+
const response = await issueTokenResponse({
|
|
975
|
+
clientId: resolvedClientId,
|
|
976
|
+
scopes: grantedScopes,
|
|
977
|
+
audience: coerceString(reqAudience),
|
|
236
978
|
});
|
|
979
|
+
setNoCacheHeaders(res);
|
|
980
|
+
res.json(response);
|
|
237
981
|
return;
|
|
238
982
|
}
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
983
|
+
if (!enablePkce) {
|
|
984
|
+
res.status(400).json({
|
|
985
|
+
error: 'unsupported_grant_type',
|
|
986
|
+
error_description: 'PKCE support is disabled',
|
|
243
987
|
});
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
if (!clientAuthenticated && !allowPublicClients) {
|
|
991
|
+
respondInvalidClient(res);
|
|
992
|
+
return;
|
|
993
|
+
}
|
|
994
|
+
const authorizationCode = coerceString(code);
|
|
995
|
+
const redirectUriText = coerceString(redirect_uri);
|
|
996
|
+
const verifier = coerceString(code_verifier);
|
|
997
|
+
if (!authorizationCode ||
|
|
998
|
+
!redirectUriText ||
|
|
999
|
+
!isValidCodeVerifier(verifier)) {
|
|
1000
|
+
res.status(400).json({
|
|
1001
|
+
error: 'invalid_request',
|
|
1002
|
+
error_description: 'code, redirect_uri, and a valid code_verifier are required for PKCE',
|
|
1003
|
+
});
|
|
1004
|
+
return;
|
|
1005
|
+
}
|
|
1006
|
+
let redirectUrl;
|
|
1007
|
+
try {
|
|
1008
|
+
redirectUrl = new URL(redirectUriText);
|
|
1009
|
+
}
|
|
1010
|
+
catch {
|
|
1011
|
+
res.status(400).json({
|
|
1012
|
+
error: 'invalid_request',
|
|
1013
|
+
error_description: 'redirect_uri must be a valid absolute URL',
|
|
247
1014
|
});
|
|
248
1015
|
return;
|
|
249
1016
|
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
1017
|
+
const record = authorizationCodes.get(authorizationCode);
|
|
1018
|
+
if (!record) {
|
|
1019
|
+
res.status(400).json({
|
|
1020
|
+
error: 'invalid_grant',
|
|
1021
|
+
error_description: 'Authorization code is invalid or expired',
|
|
1022
|
+
});
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
if (record.expiresAt <= Date.now()) {
|
|
1026
|
+
authorizationCodes.delete(authorizationCode);
|
|
1027
|
+
res.status(400).json({
|
|
1028
|
+
error: 'invalid_grant',
|
|
1029
|
+
error_description: 'Authorization code has expired',
|
|
1030
|
+
});
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
if (record.clientId !== resolvedClientId) {
|
|
1034
|
+
authorizationCodes.delete(authorizationCode);
|
|
1035
|
+
respondInvalidClient(res);
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
1038
|
+
if (record.redirectUri !== redirectUrl.toString()) {
|
|
1039
|
+
authorizationCodes.delete(authorizationCode);
|
|
1040
|
+
res.status(400).json({
|
|
1041
|
+
error: 'invalid_grant',
|
|
1042
|
+
error_description: 'redirect_uri does not match authorization request',
|
|
1043
|
+
});
|
|
1044
|
+
return;
|
|
1045
|
+
}
|
|
1046
|
+
let pkceValid = false;
|
|
1047
|
+
if (record.codeChallengeMethod === 'S256') {
|
|
1048
|
+
const expected = record.codeChallenge;
|
|
1049
|
+
const actual = computeS256Challenge(verifier);
|
|
1050
|
+
pkceValid = safeTimingEqual(expected, actual);
|
|
1051
|
+
}
|
|
1052
|
+
else {
|
|
1053
|
+
pkceValid = safeTimingEqual(record.codeChallenge, verifier);
|
|
1054
|
+
}
|
|
1055
|
+
if (!pkceValid) {
|
|
1056
|
+
authorizationCodes.delete(authorizationCode);
|
|
1057
|
+
res.status(400).json({
|
|
1058
|
+
error: 'invalid_grant',
|
|
1059
|
+
error_description: 'code_verifier does not match code_challenge',
|
|
1060
|
+
});
|
|
1061
|
+
return;
|
|
1062
|
+
}
|
|
1063
|
+
authorizationCodes.delete(authorizationCode);
|
|
1064
|
+
if (!provider.signingPrivatePem || !provider.signatureKeyId) {
|
|
255
1065
|
logger.error('oauth2_missing_keys', {
|
|
256
|
-
hasPrivateKey: !!
|
|
257
|
-
hasKeyId: !!
|
|
1066
|
+
hasPrivateKey: !!provider.signingPrivatePem,
|
|
1067
|
+
hasKeyId: !!provider.signatureKeyId,
|
|
258
1068
|
});
|
|
259
1069
|
res.status(500).json({
|
|
260
1070
|
error: 'server_error',
|
|
@@ -262,50 +1072,48 @@ function createOAuth2TokenRouter(options) {
|
|
|
262
1072
|
});
|
|
263
1073
|
return;
|
|
264
1074
|
}
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
issuer: defaultIssuer,
|
|
270
|
-
algorithm,
|
|
271
|
-
ttlSec: resolvedTokenTtlSec,
|
|
272
|
-
audience: defaultAudience,
|
|
1075
|
+
const response = await issueTokenResponse({
|
|
1076
|
+
clientId: resolvedClientId,
|
|
1077
|
+
scopes: record.scope,
|
|
1078
|
+
audience: coerceString(reqAudience),
|
|
273
1079
|
});
|
|
274
|
-
|
|
275
|
-
const claims = {
|
|
276
|
-
sub: requestCreds.clientId,
|
|
277
|
-
client_id: requestCreds.clientId,
|
|
278
|
-
scope: grantedScopes.join(' '),
|
|
279
|
-
};
|
|
280
|
-
// Add audience claim
|
|
281
|
-
if (reqAudience) {
|
|
282
|
-
claims.aud = reqAudience;
|
|
283
|
-
}
|
|
284
|
-
else if (defaultAudience) {
|
|
285
|
-
claims.aud = defaultAudience;
|
|
286
|
-
}
|
|
287
|
-
// Issue the token (async)
|
|
288
|
-
const accessToken = await tokenIssuer.issue(claims);
|
|
289
|
-
logger.debug('oauth2_token_issued', {
|
|
290
|
-
clientId: requestCreds.clientId,
|
|
291
|
-
scopes: grantedScopes,
|
|
292
|
-
algorithm,
|
|
293
|
-
});
|
|
294
|
-
// Return token response
|
|
295
|
-
const response = {
|
|
296
|
-
access_token: accessToken,
|
|
297
|
-
token_type: 'Bearer',
|
|
298
|
-
expires_in: resolvedTokenTtlSec,
|
|
299
|
-
};
|
|
300
|
-
if (grantedScopes.length > 0) {
|
|
301
|
-
response.scope = grantedScopes.join(' ');
|
|
302
|
-
}
|
|
1080
|
+
setNoCacheHeaders(res);
|
|
303
1081
|
res.json(response);
|
|
304
1082
|
}
|
|
305
1083
|
catch (error) {
|
|
306
1084
|
logger.error('oauth2_token_error', { error: error.message });
|
|
307
|
-
|
|
1085
|
+
throw error;
|
|
308
1086
|
}
|
|
309
1087
|
});
|
|
310
|
-
|
|
1088
|
+
async function issueTokenResponse(params) {
|
|
1089
|
+
const tokenIssuer = new jwt_token_issuer_js_1.JWTTokenIssuer({
|
|
1090
|
+
signingKeyPem: provider.signingPrivatePem ?? undefined,
|
|
1091
|
+
kid: provider.signatureKeyId ?? undefined,
|
|
1092
|
+
issuer: defaultIssuer,
|
|
1093
|
+
algorithm,
|
|
1094
|
+
ttlSec: resolvedTokenTtlSec,
|
|
1095
|
+
audience: params.audience ?? defaultAudience,
|
|
1096
|
+
});
|
|
1097
|
+
const claims = {
|
|
1098
|
+
sub: params.clientId,
|
|
1099
|
+
client_id: params.clientId,
|
|
1100
|
+
scope: params.scopes.join(' '),
|
|
1101
|
+
};
|
|
1102
|
+
const accessToken = await tokenIssuer.issue(claims);
|
|
1103
|
+
logger.debug('oauth2_token_issued', {
|
|
1104
|
+
clientId: params.clientId,
|
|
1105
|
+
scopes: params.scopes,
|
|
1106
|
+
algorithm,
|
|
1107
|
+
});
|
|
1108
|
+
const response = {
|
|
1109
|
+
access_token: accessToken,
|
|
1110
|
+
token_type: 'Bearer',
|
|
1111
|
+
expires_in: resolvedTokenTtlSec,
|
|
1112
|
+
};
|
|
1113
|
+
if (params.scopes.length > 0) {
|
|
1114
|
+
response.scope = params.scopes.join(' ');
|
|
1115
|
+
}
|
|
1116
|
+
return response;
|
|
1117
|
+
}
|
|
1118
|
+
return router.toPlugin();
|
|
311
1119
|
}
|