@webstir-io/webstir-backend 0.1.15 → 0.1.16
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/README.md +106 -79
- package/dist/add.d.ts +59 -0
- package/dist/add.js +626 -0
- package/dist/build/artifacts.d.ts +115 -1
- package/dist/build/artifacts.js +4 -4
- package/dist/build/entries.js +1 -1
- package/dist/build/pipeline.d.ts +33 -1
- package/dist/build/pipeline.js +307 -65
- package/dist/cache/diff.js +9 -8
- package/dist/cache/reporters.js +1 -1
- package/dist/deploy-cli.d.ts +2 -0
- package/dist/deploy-cli.js +86 -0
- package/dist/diagnostics/summary.js +2 -2
- package/dist/index.d.ts +6 -0
- package/dist/index.js +4 -0
- package/dist/manifest/pipeline.js +103 -32
- package/dist/provider.js +35 -17
- package/dist/runtime/bun.d.ts +51 -0
- package/dist/runtime/bun.js +499 -0
- package/dist/runtime/core.d.ts +141 -0
- package/dist/runtime/core.js +316 -0
- package/dist/runtime/deploy-backend.d.ts +20 -0
- package/dist/runtime/deploy-backend.js +175 -0
- package/dist/runtime/deploy-shared.d.ts +43 -0
- package/dist/runtime/deploy-shared.js +75 -0
- package/dist/runtime/deploy-static.d.ts +2 -0
- package/dist/runtime/deploy-static.js +161 -0
- package/dist/runtime/deploy.d.ts +3 -0
- package/dist/runtime/deploy.js +91 -0
- package/dist/runtime/forms.d.ts +73 -0
- package/dist/runtime/forms.js +236 -0
- package/dist/runtime/request-hooks.d.ts +47 -0
- package/dist/runtime/request-hooks.js +102 -0
- package/dist/runtime/session-metadata.d.ts +13 -0
- package/dist/runtime/session-metadata.js +98 -0
- package/dist/runtime/session-runtime.d.ts +28 -0
- package/dist/runtime/session-runtime.js +180 -0
- package/dist/runtime/session.d.ts +83 -0
- package/dist/runtime/session.js +396 -0
- package/dist/runtime/views.d.ts +74 -0
- package/dist/runtime/views.js +221 -0
- package/dist/scaffold/assets.js +25 -21
- package/dist/testing/context.js +1 -1
- package/dist/testing/index.d.ts +1 -1
- package/dist/testing/index.js +100 -56
- package/dist/utils/bun.d.ts +2 -0
- package/dist/utils/bun.js +13 -0
- package/dist/watch.d.ts +13 -1
- package/dist/watch.js +345 -97
- package/dist/workspace.d.ts +8 -0
- package/dist/workspace.js +44 -3
- package/package.json +49 -14
- package/scripts/publish.sh +2 -92
- package/scripts/smoke.mjs +282 -107
- package/scripts/update-contract.sh +12 -10
- package/src/add.ts +964 -0
- package/src/build/artifacts.ts +49 -46
- package/src/build/entries.ts +12 -12
- package/src/build/pipeline.ts +779 -403
- package/src/cache/diff.ts +111 -105
- package/src/cache/reporters.ts +26 -26
- package/src/deploy-cli.ts +111 -0
- package/src/diagnostics/summary.ts +28 -22
- package/src/index.ts +11 -0
- package/src/manifest/pipeline.ts +328 -215
- package/src/provider.ts +115 -98
- package/src/runtime/bun.ts +793 -0
- package/src/runtime/core.ts +598 -0
- package/src/runtime/deploy-backend.ts +239 -0
- package/src/runtime/deploy-shared.ts +136 -0
- package/src/runtime/deploy-static.ts +191 -0
- package/src/runtime/deploy.ts +143 -0
- package/src/runtime/forms.ts +364 -0
- package/src/runtime/request-hooks.ts +165 -0
- package/src/runtime/session-metadata.ts +135 -0
- package/src/runtime/session-runtime.ts +267 -0
- package/src/runtime/session.ts +642 -0
- package/src/runtime/views.ts +385 -0
- package/src/scaffold/assets.ts +77 -73
- package/src/testing/context.js +8 -9
- package/src/testing/context.ts +9 -9
- package/src/testing/index.d.ts +14 -3
- package/src/testing/index.js +254 -175
- package/src/testing/index.ts +298 -195
- package/src/testing/types.d.ts +18 -19
- package/src/testing/types.ts +18 -18
- package/src/utils/bun.ts +26 -0
- package/src/watch.ts +503 -99
- package/src/workspace.ts +59 -3
- package/templates/backend/.env.example +15 -0
- package/templates/backend/auth/adapter.ts +335 -36
- package/templates/backend/db/connection.ts +190 -65
- package/templates/backend/db/migrate.ts +149 -43
- package/templates/backend/db/types.d.ts +1 -1
- package/templates/backend/env.ts +132 -20
- package/templates/backend/functions/hello/index.ts +1 -2
- package/templates/backend/index.ts +15 -508
- package/templates/backend/jobs/nightly/index.ts +1 -1
- package/templates/backend/jobs/runtime.ts +24 -11
- package/templates/backend/jobs/scheduler.ts +208 -46
- package/templates/backend/module.ts +227 -13
- package/templates/backend/observability/logger.ts +2 -12
- package/templates/backend/observability/metrics.ts +8 -5
- package/templates/backend/session/sqlite.ts +152 -0
- package/templates/backend/session/store.ts +45 -0
- package/templates/backend/tsconfig.json +1 -1
- package/tests/add.test.js +327 -0
- package/tests/authAdapter.test.js +315 -0
- package/tests/bundlerParity.test.js +217 -0
- package/tests/cacheReporter.test.js +10 -10
- package/tests/dbConnection.test.js +209 -0
- package/tests/deploy.test.js +357 -0
- package/tests/envLoader.test.js +271 -17
- package/tests/integration.test.js +2432 -3
- package/tests/jobsScheduler.test.js +253 -0
- package/tests/manifest.test.js +287 -12
- package/tests/migrationRunner.test.js +249 -0
- package/tests/sessionScaffoldStore.test.js +752 -0
- package/tests/sessionStore.test.js +490 -0
- package/tests/testing.test.js +252 -0
- package/tests/watch.test.js +192 -32
- package/tsconfig.json +3 -10
- package/templates/backend/server/fastify.ts +0 -288
package/src/workspace.ts
CHANGED
|
@@ -1,14 +1,51 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
2
3
|
|
|
3
4
|
import type { ResolvedModuleWorkspace } from '@webstir-io/module-contract';
|
|
4
5
|
|
|
5
6
|
export type BackendBuildMode = 'build' | 'publish' | 'test';
|
|
6
7
|
|
|
8
|
+
interface ResolveWorkspaceRootOptions {
|
|
9
|
+
readonly workspaceRoot?: string;
|
|
10
|
+
readonly env?: Record<string, string | undefined>;
|
|
11
|
+
readonly cwd?: string;
|
|
12
|
+
readonly importMetaUrl?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const WORKSPACE_ROOT_PATTERN = /^(.*)[/\\](?:src|build)[/\\]backend(?:[/\\].*)?$/;
|
|
16
|
+
|
|
17
|
+
export function resolveWorkspaceRoot(options?: string | ResolveWorkspaceRootOptions): string {
|
|
18
|
+
if (typeof options === 'string') {
|
|
19
|
+
return path.resolve(options);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const explicitRoot = options?.workspaceRoot?.trim();
|
|
23
|
+
if (explicitRoot) {
|
|
24
|
+
return path.resolve(explicitRoot);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const env = options?.env ?? process.env;
|
|
28
|
+
const envRoot = env.WORKSPACE_ROOT?.trim() || env.WEBSTIR_WORKSPACE_ROOT?.trim();
|
|
29
|
+
if (envRoot) {
|
|
30
|
+
return path.resolve(envRoot);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const inferredRoot = options?.importMetaUrl
|
|
34
|
+
? inferWorkspaceRootFromImportMetaUrl(options.importMetaUrl)
|
|
35
|
+
: undefined;
|
|
36
|
+
if (inferredRoot) {
|
|
37
|
+
return inferredRoot;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return path.resolve(options?.cwd ?? process.cwd());
|
|
41
|
+
}
|
|
42
|
+
|
|
7
43
|
export function resolveWorkspacePaths(workspaceRoot: string): ResolvedModuleWorkspace {
|
|
44
|
+
const resolvedWorkspaceRoot = resolveWorkspaceRoot(workspaceRoot);
|
|
8
45
|
return {
|
|
9
|
-
sourceRoot: path.join(
|
|
10
|
-
buildRoot: path.join(
|
|
11
|
-
testsRoot: path.join(
|
|
46
|
+
sourceRoot: path.join(resolvedWorkspaceRoot, 'src', 'backend'),
|
|
47
|
+
buildRoot: path.join(resolvedWorkspaceRoot, 'build', 'backend'),
|
|
48
|
+
testsRoot: path.join(resolvedWorkspaceRoot, 'src', 'backend', 'tests'),
|
|
12
49
|
};
|
|
13
50
|
}
|
|
14
51
|
|
|
@@ -20,3 +57,22 @@ export function normalizeMode(rawMode: unknown): BackendBuildMode {
|
|
|
20
57
|
const normalized = rawMode.toLowerCase();
|
|
21
58
|
return normalized === 'publish' || normalized === 'test' ? normalized : 'build';
|
|
22
59
|
}
|
|
60
|
+
|
|
61
|
+
function inferWorkspaceRootFromImportMetaUrl(importMetaUrl: string): string | undefined {
|
|
62
|
+
try {
|
|
63
|
+
return inferWorkspaceRootFromFilePath(fileURLToPath(importMetaUrl));
|
|
64
|
+
} catch {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function inferWorkspaceRootFromFilePath(filePath: string): string | undefined {
|
|
70
|
+
const normalizedFilePath = path.resolve(filePath);
|
|
71
|
+
const match = normalizedFilePath.match(WORKSPACE_ROOT_PATTERN);
|
|
72
|
+
if (!match) {
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const inferredRoot = match[1];
|
|
77
|
+
return inferredRoot || path.parse(normalizedFilePath).root;
|
|
78
|
+
}
|
|
@@ -1,13 +1,28 @@
|
|
|
1
1
|
NODE_ENV=development
|
|
2
2
|
PORT=4000
|
|
3
3
|
API_BASE_URL=http://localhost:4000
|
|
4
|
+
# Choose one JWT verification mode:
|
|
5
|
+
# - Shared secret: AUTH_JWT_SECRET
|
|
6
|
+
# - RSA public key file: AUTH_JWT_PUBLIC_KEY_FILE
|
|
7
|
+
# - JWKS endpoint: AUTH_JWKS_URL
|
|
4
8
|
AUTH_JWT_SECRET=change-me
|
|
9
|
+
# AUTH_JWT_PUBLIC_KEY_FILE=./config/jwt-public-key.pem
|
|
10
|
+
# AUTH_JWKS_URL=https://example-idp/.well-known/jwks.json
|
|
5
11
|
AUTH_JWT_ISSUER=https://example-idp/
|
|
6
12
|
AUTH_JWT_AUDIENCE=webstir-dev
|
|
7
13
|
AUTH_SERVICE_TOKENS=local-service-token
|
|
14
|
+
SESSION_SECRET=change-me-too
|
|
15
|
+
SESSION_COOKIE_NAME=webstir_session
|
|
16
|
+
SESSION_COOKIE_SECURE=off
|
|
17
|
+
SESSION_MAX_AGE=86400
|
|
18
|
+
# Development defaults to memory; production defaults to sqlite.
|
|
19
|
+
# SESSION_STORE_DRIVER=memory
|
|
20
|
+
SESSION_STORE_URL=file:./data/sessions.sqlite
|
|
8
21
|
LOG_LEVEL=info
|
|
9
22
|
LOG_SERVICE_NAME=backend-template
|
|
10
23
|
METRICS_ENABLED=on
|
|
11
24
|
METRICS_WINDOW=200
|
|
12
25
|
DATABASE_URL=file:./data/dev.sqlite
|
|
13
26
|
DATABASE_MIGRATIONS_TABLE=_webstir_migrations
|
|
27
|
+
REQUEST_BODY_MAX_BYTES=1048576
|
|
28
|
+
WEBSTIR_BACKEND_SERVER_RUNTIME=node
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import crypto from 'node:crypto';
|
|
2
|
-
import type http from 'node:http';
|
|
3
2
|
|
|
4
3
|
import type { AuthSecrets } from '../env.js';
|
|
5
4
|
|
|
@@ -14,18 +13,40 @@ export interface AuthContext {
|
|
|
14
13
|
claims: Record<string, unknown>;
|
|
15
14
|
}
|
|
16
15
|
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
interface JwtHeader extends Record<string, unknown> {
|
|
17
|
+
alg?: string;
|
|
18
|
+
kid?: string;
|
|
19
|
+
typ?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface CachedJwkKey {
|
|
23
|
+
kid?: string;
|
|
24
|
+
key: crypto.KeyObject;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface CachedJwksEntry {
|
|
28
|
+
keys: readonly CachedJwkKey[];
|
|
29
|
+
expiresAt: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const DEFAULT_JWKS_CACHE_MS = 5 * 60 * 1000;
|
|
33
|
+
const JWKS_FETCH_TIMEOUT_MS = 5_000;
|
|
34
|
+
const jwksCache = new Map<string, CachedJwksEntry>();
|
|
35
|
+
const jwksFetches = new Map<string, Promise<readonly CachedJwkKey[]>>();
|
|
36
|
+
const publicKeyCache = new Map<string, crypto.KeyObject>();
|
|
37
|
+
|
|
38
|
+
export async function resolveRequestAuth(
|
|
39
|
+
request: Request,
|
|
19
40
|
secrets: AuthSecrets,
|
|
20
|
-
logger?: { warn?(message: string, metadata?: Record<string, unknown>): void }
|
|
21
|
-
): AuthContext | undefined {
|
|
22
|
-
const bearer = getHeader(
|
|
41
|
+
logger?: { warn?(message: string, metadata?: Record<string, unknown>): void },
|
|
42
|
+
): Promise<AuthContext | undefined> {
|
|
43
|
+
const bearer = getHeader(request, 'authorization');
|
|
23
44
|
if (bearer?.startsWith('Bearer ')) {
|
|
24
|
-
if (!secrets
|
|
25
|
-
logger?.warn?.('Authorization header provided but
|
|
45
|
+
if (!hasJwtVerificationSecrets(secrets)) {
|
|
46
|
+
logger?.warn?.('Authorization header provided but no JWT verification config is set.');
|
|
26
47
|
} else {
|
|
27
48
|
const token = bearer.slice(7).trim();
|
|
28
|
-
const context = verifyJwtToken(token, secrets);
|
|
49
|
+
const context = await verifyJwtToken(token, secrets);
|
|
29
50
|
if (context) {
|
|
30
51
|
return context;
|
|
31
52
|
}
|
|
@@ -33,7 +54,7 @@ export function resolveRequestAuth(
|
|
|
33
54
|
}
|
|
34
55
|
}
|
|
35
56
|
|
|
36
|
-
const serviceToken = getHeader(
|
|
57
|
+
const serviceToken = getHeader(request, 'x-service-token') ?? getHeader(request, 'x-api-key');
|
|
37
58
|
if (serviceToken && secrets.serviceTokens.length > 0) {
|
|
38
59
|
if (secrets.serviceTokens.includes(serviceToken)) {
|
|
39
60
|
return {
|
|
@@ -44,7 +65,7 @@ export function resolveRequestAuth(
|
|
|
44
65
|
claims: {},
|
|
45
66
|
userId: undefined,
|
|
46
67
|
email: undefined,
|
|
47
|
-
name: undefined
|
|
68
|
+
name: undefined,
|
|
48
69
|
};
|
|
49
70
|
}
|
|
50
71
|
logger?.warn?.('Service token did not match any allowed AUTH_SERVICE_TOKENS entry');
|
|
@@ -53,25 +74,44 @@ export function resolveRequestAuth(
|
|
|
53
74
|
return undefined;
|
|
54
75
|
}
|
|
55
76
|
|
|
56
|
-
function
|
|
57
|
-
|
|
77
|
+
function hasJwtVerificationSecrets(secrets: AuthSecrets): boolean {
|
|
78
|
+
return Boolean(secrets.jwtSecret || secrets.jwtPublicKey || secrets.jwksUrl);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function verifyJwtToken(
|
|
82
|
+
token: string,
|
|
83
|
+
secrets: AuthSecrets,
|
|
84
|
+
): Promise<AuthContext | undefined> {
|
|
58
85
|
const parts = token.split('.');
|
|
59
|
-
if (parts.length !== 3)
|
|
60
|
-
|
|
86
|
+
if (parts.length !== 3) {
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
61
89
|
|
|
62
|
-
const
|
|
63
|
-
|
|
90
|
+
const [encodedHeader, encodedPayload, signature] = parts;
|
|
91
|
+
const header = decodeSegment<JwtHeader>(encodedHeader);
|
|
92
|
+
if (!header?.alg) {
|
|
64
93
|
return undefined;
|
|
65
94
|
}
|
|
66
95
|
|
|
67
|
-
const payload = decodeSegment(encodedPayload);
|
|
96
|
+
const payload = decodeSegment<Record<string, unknown>>(encodedPayload);
|
|
68
97
|
if (!payload) {
|
|
69
98
|
return undefined;
|
|
70
99
|
}
|
|
71
100
|
|
|
72
101
|
const signedContent = `${encodedHeader}.${encodedPayload}`;
|
|
73
|
-
const
|
|
74
|
-
if (!
|
|
102
|
+
const signatureBuffer = decodeBase64Url(signature);
|
|
103
|
+
if (!signatureBuffer) {
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const verified =
|
|
108
|
+
header.alg === 'HS256'
|
|
109
|
+
? verifyHmacSignature(signedContent, signature, secrets.jwtSecret)
|
|
110
|
+
: header.alg === 'RS256'
|
|
111
|
+
? await verifyRsaSignature(signedContent, signatureBuffer, header, secrets)
|
|
112
|
+
: false;
|
|
113
|
+
|
|
114
|
+
if (!verified) {
|
|
75
115
|
return undefined;
|
|
76
116
|
}
|
|
77
117
|
|
|
@@ -83,8 +123,15 @@ function verifyJwtToken(token: string, secrets: AuthSecrets): AuthContext | unde
|
|
|
83
123
|
return undefined;
|
|
84
124
|
}
|
|
85
125
|
|
|
126
|
+
const now = Date.now() / 1000;
|
|
127
|
+
if (!isValidTimeClaims(payload, now)) {
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
|
|
86
131
|
const scopes = normalizeScopes(payload.scope);
|
|
87
|
-
const roles = normalizeRoles(
|
|
132
|
+
const roles = normalizeRoles(
|
|
133
|
+
payload.roles ?? payload.role ?? payload['https://schemas.webstir.dev/roles'],
|
|
134
|
+
);
|
|
88
135
|
|
|
89
136
|
return {
|
|
90
137
|
source: 'jwt',
|
|
@@ -94,14 +141,235 @@ function verifyJwtToken(token: string, secrets: AuthSecrets): AuthContext | unde
|
|
|
94
141
|
name: typeof payload.name === 'string' ? payload.name : undefined,
|
|
95
142
|
scopes,
|
|
96
143
|
roles,
|
|
97
|
-
claims: payload
|
|
144
|
+
claims: payload,
|
|
98
145
|
};
|
|
99
146
|
}
|
|
100
147
|
|
|
101
|
-
function
|
|
148
|
+
function verifyHmacSignature(signedContent: string, signature: string, secret?: string): boolean {
|
|
149
|
+
if (!secret) {
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const expectedSignature = crypto
|
|
154
|
+
.createHmac('sha256', secret)
|
|
155
|
+
.update(signedContent)
|
|
156
|
+
.digest('base64url');
|
|
157
|
+
return timingSafeEqual(signature, expectedSignature);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function verifyRsaSignature(
|
|
161
|
+
signedContent: string,
|
|
162
|
+
signature: Buffer,
|
|
163
|
+
header: JwtHeader,
|
|
164
|
+
secrets: AuthSecrets,
|
|
165
|
+
): Promise<boolean> {
|
|
166
|
+
if (secrets.jwtPublicKey) {
|
|
167
|
+
const publicKey = getConfiguredPublicKey(secrets.jwtPublicKey);
|
|
168
|
+
if (publicKey && verifyWithPublicKey(signedContent, signature, publicKey)) {
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (!secrets.jwksUrl) {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const jwksKeys = await getJwksKeys(secrets.jwksUrl);
|
|
178
|
+
const initialCandidates = selectJwksCandidates(jwksKeys, header);
|
|
179
|
+
for (const candidate of initialCandidates) {
|
|
180
|
+
if (verifyWithPublicKey(signedContent, signature, candidate.key)) {
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (!header.kid) {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const refreshedKeys = await getJwksKeys(secrets.jwksUrl, { forceRefresh: true });
|
|
190
|
+
for (const candidate of selectJwksCandidates(refreshedKeys, header)) {
|
|
191
|
+
if (verifyWithPublicKey(signedContent, signature, candidate.key)) {
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function verifyWithPublicKey(
|
|
200
|
+
signedContent: string,
|
|
201
|
+
signature: Buffer,
|
|
202
|
+
publicKey: crypto.KeyObject,
|
|
203
|
+
): boolean {
|
|
204
|
+
try {
|
|
205
|
+
return crypto.verify('RSA-SHA256', Buffer.from(signedContent), publicKey, signature);
|
|
206
|
+
} catch {
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function getConfiguredPublicKey(value: string): crypto.KeyObject | undefined {
|
|
212
|
+
const cached = publicKeyCache.get(value);
|
|
213
|
+
if (cached) {
|
|
214
|
+
return cached;
|
|
215
|
+
}
|
|
216
|
+
|
|
102
217
|
try {
|
|
103
|
-
const
|
|
104
|
-
|
|
218
|
+
const trimmed = value.trim();
|
|
219
|
+
const key = trimmed.startsWith('{')
|
|
220
|
+
? crypto.createPublicKey({ key: JSON.parse(trimmed) as crypto.JsonWebKey, format: 'jwk' })
|
|
221
|
+
: crypto.createPublicKey(trimmed);
|
|
222
|
+
publicKeyCache.set(value, key);
|
|
223
|
+
return key;
|
|
224
|
+
} catch {
|
|
225
|
+
return undefined;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function selectJwksCandidates(
|
|
230
|
+
keys: readonly CachedJwkKey[],
|
|
231
|
+
header: JwtHeader,
|
|
232
|
+
): readonly CachedJwkKey[] {
|
|
233
|
+
if (header.kid) {
|
|
234
|
+
return keys.filter((key) => key.kid === header.kid);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return keys;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function getJwksKeys(
|
|
241
|
+
url: string,
|
|
242
|
+
options: { forceRefresh?: boolean } = {},
|
|
243
|
+
): Promise<readonly CachedJwkKey[]> {
|
|
244
|
+
if (options.forceRefresh) {
|
|
245
|
+
jwksCache.delete(url);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const cached = jwksCache.get(url);
|
|
249
|
+
const now = Date.now();
|
|
250
|
+
if (cached && cached.expiresAt > now) {
|
|
251
|
+
return cached.keys;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const pending = jwksFetches.get(url);
|
|
255
|
+
if (pending) {
|
|
256
|
+
return await pending;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const fetchPromise = fetchJwksKeys(url, cached?.keys ?? []).finally(() => {
|
|
260
|
+
jwksFetches.delete(url);
|
|
261
|
+
});
|
|
262
|
+
jwksFetches.set(url, fetchPromise);
|
|
263
|
+
return await fetchPromise;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function fetchJwksKeys(
|
|
267
|
+
url: string,
|
|
268
|
+
fallbackKeys: readonly CachedJwkKey[],
|
|
269
|
+
): Promise<readonly CachedJwkKey[]> {
|
|
270
|
+
const controller = new AbortController();
|
|
271
|
+
const timeout = setTimeout(() => controller.abort(), JWKS_FETCH_TIMEOUT_MS);
|
|
272
|
+
try {
|
|
273
|
+
const response = await fetch(url, { signal: controller.signal });
|
|
274
|
+
if (!response.ok) {
|
|
275
|
+
throw new Error(`JWKS request failed with status ${response.status}`);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const body = (await response.json()) as { keys?: unknown };
|
|
279
|
+
const keys = Array.isArray(body.keys) ? body.keys : [];
|
|
280
|
+
const resolvedKeys = keys
|
|
281
|
+
.map((key) => jwkToCachedKey(key))
|
|
282
|
+
.filter((key): key is CachedJwkKey => key !== undefined);
|
|
283
|
+
|
|
284
|
+
const ttlMs = resolveJwksCacheTtl(response.headers);
|
|
285
|
+
jwksCache.set(url, {
|
|
286
|
+
keys: resolvedKeys,
|
|
287
|
+
expiresAt: Date.now() + ttlMs,
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
return resolvedKeys;
|
|
291
|
+
} catch {
|
|
292
|
+
if (fallbackKeys.length > 0) {
|
|
293
|
+
jwksCache.set(url, {
|
|
294
|
+
keys: fallbackKeys,
|
|
295
|
+
expiresAt: Date.now() + DEFAULT_JWKS_CACHE_MS,
|
|
296
|
+
});
|
|
297
|
+
return fallbackKeys;
|
|
298
|
+
}
|
|
299
|
+
jwksCache.delete(url);
|
|
300
|
+
return [];
|
|
301
|
+
} finally {
|
|
302
|
+
clearTimeout(timeout);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function jwkToCachedKey(value: unknown): CachedJwkKey | undefined {
|
|
307
|
+
if (!value || typeof value !== 'object') {
|
|
308
|
+
return undefined;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
try {
|
|
312
|
+
const jwk = value as crypto.JsonWebKey;
|
|
313
|
+
const key = crypto.createPublicKey({ key: jwk, format: 'jwk' });
|
|
314
|
+
return {
|
|
315
|
+
kid: typeof jwk.kid === 'string' ? jwk.kid : undefined,
|
|
316
|
+
key,
|
|
317
|
+
};
|
|
318
|
+
} catch {
|
|
319
|
+
return undefined;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function resolveJwksCacheTtl(headers: Headers): number {
|
|
324
|
+
const cacheControl = headers.get('cache-control');
|
|
325
|
+
if (cacheControl) {
|
|
326
|
+
const maxAgeMatch = cacheControl.match(/max-age=(\d+)/i);
|
|
327
|
+
if (maxAgeMatch) {
|
|
328
|
+
const maxAgeSeconds = Number(maxAgeMatch[1]);
|
|
329
|
+
if (Number.isFinite(maxAgeSeconds) && maxAgeSeconds > 0) {
|
|
330
|
+
return maxAgeSeconds * 1000;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
if (/\bno-store\b/i.test(cacheControl)) {
|
|
334
|
+
return 0;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const expires = headers.get('expires');
|
|
339
|
+
if (expires) {
|
|
340
|
+
const expiresAt = Date.parse(expires);
|
|
341
|
+
if (Number.isFinite(expiresAt)) {
|
|
342
|
+
const ttl = expiresAt - Date.now();
|
|
343
|
+
if (ttl > 0) {
|
|
344
|
+
return ttl;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return DEFAULT_JWKS_CACHE_MS;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function decodeSegment<T extends Record<string, unknown>>(segment: string): T | undefined {
|
|
353
|
+
const decoded = decodeBase64Url(segment);
|
|
354
|
+
if (!decoded) {
|
|
355
|
+
return undefined;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
try {
|
|
359
|
+
const json = decoded.toString('utf8');
|
|
360
|
+
return JSON.parse(json) as T;
|
|
361
|
+
} catch {
|
|
362
|
+
return undefined;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function decodeBase64Url(value: string): Buffer | undefined {
|
|
367
|
+
if (!/^[A-Za-z0-9_-]+$/.test(value)) {
|
|
368
|
+
return undefined;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
try {
|
|
372
|
+
return Buffer.from(value, 'base64url');
|
|
105
373
|
} catch {
|
|
106
374
|
return undefined;
|
|
107
375
|
}
|
|
@@ -126,13 +394,47 @@ function audienceMatches(value: unknown, expected: string): boolean {
|
|
|
126
394
|
return false;
|
|
127
395
|
}
|
|
128
396
|
|
|
397
|
+
function isValidTimeClaims(payload: Record<string, unknown>, now: number): boolean {
|
|
398
|
+
if (payload.iat !== undefined && parseNumericDateClaim(payload.iat) === undefined) {
|
|
399
|
+
return false;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const notBefore = parseNumericDateClaim(payload.nbf);
|
|
403
|
+
if (payload.nbf !== undefined && notBefore === undefined) {
|
|
404
|
+
return false;
|
|
405
|
+
}
|
|
406
|
+
if (notBefore !== undefined && now < notBefore) {
|
|
407
|
+
return false;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const expiresAt = parseNumericDateClaim(payload.exp);
|
|
411
|
+
if (payload.exp !== undefined && expiresAt === undefined) {
|
|
412
|
+
return false;
|
|
413
|
+
}
|
|
414
|
+
if (expiresAt !== undefined && now >= expiresAt) {
|
|
415
|
+
return false;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
return true;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function parseNumericDateClaim(value: unknown): number | undefined {
|
|
422
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
423
|
+
return undefined;
|
|
424
|
+
}
|
|
425
|
+
return value;
|
|
426
|
+
}
|
|
427
|
+
|
|
129
428
|
function normalizeScopes(value: unknown): string[] {
|
|
130
429
|
if (!value) return [];
|
|
131
430
|
if (Array.isArray(value)) {
|
|
132
431
|
return value.map((scope) => String(scope));
|
|
133
432
|
}
|
|
134
433
|
if (typeof value === 'string') {
|
|
135
|
-
return value
|
|
434
|
+
return value
|
|
435
|
+
.split(' ')
|
|
436
|
+
.map((scope) => scope.trim())
|
|
437
|
+
.filter((scope) => scope.length > 0);
|
|
136
438
|
}
|
|
137
439
|
return [];
|
|
138
440
|
}
|
|
@@ -143,18 +445,15 @@ function normalizeRoles(value: unknown): string[] {
|
|
|
143
445
|
return value.map((role) => String(role));
|
|
144
446
|
}
|
|
145
447
|
if (typeof value === 'string') {
|
|
146
|
-
return value
|
|
448
|
+
return value
|
|
449
|
+
.split(',')
|
|
450
|
+
.map((role) => role.trim())
|
|
451
|
+
.filter((role) => role.length > 0);
|
|
147
452
|
}
|
|
148
453
|
return [];
|
|
149
454
|
}
|
|
150
455
|
|
|
151
|
-
function getHeader(
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
return value;
|
|
155
|
-
}
|
|
156
|
-
if (Array.isArray(value)) {
|
|
157
|
-
return value[0];
|
|
158
|
-
}
|
|
159
|
-
return undefined;
|
|
456
|
+
function getHeader(request: Pick<Request, 'headers'>, name: string): string | undefined {
|
|
457
|
+
const normalized = request.headers.get(name) ?? request.headers.get(name.toLowerCase());
|
|
458
|
+
return normalized ?? undefined;
|
|
160
459
|
}
|