@portel/photon 1.22.0 → 1.23.0
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 +19 -8
- package/dist/a2ui/mapper.d.ts +40 -0
- package/dist/a2ui/mapper.d.ts.map +1 -0
- package/dist/a2ui/mapper.js +286 -0
- package/dist/a2ui/mapper.js.map +1 -0
- package/dist/a2ui/types.d.ts +129 -0
- package/dist/a2ui/types.d.ts.map +1 -0
- package/dist/a2ui/types.js +20 -0
- package/dist/a2ui/types.js.map +1 -0
- package/dist/ag-ui/adapter.d.ts +9 -1
- package/dist/ag-ui/adapter.d.ts.map +1 -1
- package/dist/ag-ui/adapter.js +33 -16
- package/dist/ag-ui/adapter.js.map +1 -1
- package/dist/auto-ui/beam/routes/api-daemon.d.ts +18 -0
- package/dist/auto-ui/beam/routes/api-daemon.d.ts.map +1 -0
- package/dist/auto-ui/beam/routes/api-daemon.js +118 -0
- package/dist/auto-ui/beam/routes/api-daemon.js.map +1 -0
- package/dist/auto-ui/beam.d.ts.map +1 -1
- package/dist/auto-ui/beam.js +34 -34
- package/dist/auto-ui/beam.js.map +1 -1
- package/dist/auto-ui/bridge/renderers.d.ts.map +1 -1
- package/dist/auto-ui/bridge/renderers.js +371 -0
- package/dist/auto-ui/bridge/renderers.js.map +1 -1
- package/dist/auto-ui/streamable-http-transport.d.ts.map +1 -1
- package/dist/auto-ui/streamable-http-transport.js +38 -1
- package/dist/auto-ui/streamable-http-transport.js.map +1 -1
- package/dist/auto-ui/types.d.ts +19 -0
- package/dist/auto-ui/types.d.ts.map +1 -1
- package/dist/auto-ui/types.js.map +1 -1
- package/dist/beam.bundle.js +757 -107
- package/dist/beam.bundle.js.map +4 -4
- package/dist/cli/commands/beam.d.ts.map +1 -1
- package/dist/cli/commands/beam.js +2 -0
- package/dist/cli/commands/beam.js.map +1 -1
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/build.js +2 -0
- package/dist/cli/commands/build.js.map +1 -1
- package/dist/cli/commands/doctor.d.ts.map +1 -1
- package/dist/cli/commands/doctor.js +92 -3
- package/dist/cli/commands/doctor.js.map +1 -1
- package/dist/cli/commands/host.d.ts.map +1 -1
- package/dist/cli/commands/host.js +9 -1
- package/dist/cli/commands/host.js.map +1 -1
- package/dist/cli/commands/info.d.ts.map +1 -1
- package/dist/cli/commands/info.js +7 -3
- package/dist/cli/commands/info.js.map +1 -1
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/init.js +4 -0
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/cli/commands/maker.d.ts +8 -0
- package/dist/cli/commands/maker.d.ts.map +1 -1
- package/dist/cli/commands/maker.js +113 -46
- package/dist/cli/commands/maker.js.map +1 -1
- package/dist/cli/commands/marketplace.d.ts.map +1 -1
- package/dist/cli/commands/marketplace.js +7 -1
- package/dist/cli/commands/marketplace.js.map +1 -1
- package/dist/cli/commands/mcp.d.ts +10 -0
- package/dist/cli/commands/mcp.d.ts.map +1 -1
- package/dist/cli/commands/mcp.js +215 -4
- package/dist/cli/commands/mcp.js.map +1 -1
- package/dist/cli/commands/package.d.ts.map +1 -1
- package/dist/cli/commands/package.js +33 -15
- package/dist/cli/commands/package.js.map +1 -1
- package/dist/cli/commands/ps.d.ts +16 -0
- package/dist/cli/commands/ps.d.ts.map +1 -0
- package/dist/cli/commands/ps.js +267 -0
- package/dist/cli/commands/ps.js.map +1 -0
- package/dist/cli/commands/run.d.ts.map +1 -1
- package/dist/cli/commands/run.js +7 -0
- package/dist/cli/commands/run.js.map +1 -1
- package/dist/cli/commands/update.d.ts.map +1 -1
- package/dist/cli/commands/update.js +14 -4
- package/dist/cli/commands/update.js.map +1 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +9 -4
- package/dist/cli/index.js.map +1 -1
- package/dist/context-store.d.ts +4 -4
- package/dist/context-store.d.ts.map +1 -1
- package/dist/context-store.js +20 -17
- package/dist/context-store.js.map +1 -1
- package/dist/context.d.ts +5 -4
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +68 -14
- package/dist/context.js.map +1 -1
- package/dist/daemon/client.d.ts +60 -0
- package/dist/daemon/client.d.ts.map +1 -1
- package/dist/daemon/client.js +76 -0
- package/dist/daemon/client.js.map +1 -1
- package/dist/daemon/execution-history-sqlite.d.ts +50 -0
- package/dist/daemon/execution-history-sqlite.d.ts.map +1 -0
- package/dist/daemon/execution-history-sqlite.js +165 -0
- package/dist/daemon/execution-history-sqlite.js.map +1 -0
- package/dist/daemon/execution-history.d.ts +78 -0
- package/dist/daemon/execution-history.d.ts.map +1 -0
- package/dist/daemon/execution-history.js +246 -0
- package/dist/daemon/execution-history.js.map +1 -0
- package/dist/daemon/hot-reload-state.d.ts +27 -0
- package/dist/daemon/hot-reload-state.d.ts.map +1 -0
- package/dist/daemon/hot-reload-state.js +48 -0
- package/dist/daemon/hot-reload-state.js.map +1 -0
- package/dist/daemon/protocol.d.ts +5 -1
- package/dist/daemon/protocol.d.ts.map +1 -1
- package/dist/daemon/protocol.js +13 -0
- package/dist/daemon/protocol.js.map +1 -1
- package/dist/daemon/registry-keys.d.ts +88 -0
- package/dist/daemon/registry-keys.d.ts.map +1 -0
- package/dist/daemon/registry-keys.js +91 -0
- package/dist/daemon/registry-keys.js.map +1 -0
- package/dist/daemon/server.js +1521 -186
- package/dist/daemon/server.js.map +1 -1
- package/dist/daemon/session-resolver.d.ts +28 -0
- package/dist/daemon/session-resolver.d.ts.map +1 -0
- package/dist/daemon/session-resolver.js +41 -0
- package/dist/daemon/session-resolver.js.map +1 -0
- package/dist/data-migration.js +20 -9
- package/dist/data-migration.js.map +1 -1
- package/dist/loader.d.ts +22 -8
- package/dist/loader.d.ts.map +1 -1
- package/dist/loader.js +214 -94
- package/dist/loader.js.map +1 -1
- package/dist/marketplace-manager.d.ts.map +1 -1
- package/dist/marketplace-manager.js +9 -5
- package/dist/marketplace-manager.js.map +1 -1
- package/dist/namespace-migration.d.ts.map +1 -1
- package/dist/namespace-migration.js +28 -23
- package/dist/namespace-migration.js.map +1 -1
- package/dist/photon-cli-runner.d.ts.map +1 -1
- package/dist/photon-cli-runner.js +57 -8
- package/dist/photon-cli-runner.js.map +1 -1
- package/dist/serv/auth/auth-store.d.ts +155 -0
- package/dist/serv/auth/auth-store.d.ts.map +1 -0
- package/dist/serv/auth/auth-store.js +240 -0
- package/dist/serv/auth/auth-store.js.map +1 -0
- package/dist/serv/auth/endpoints.d.ts +113 -0
- package/dist/serv/auth/endpoints.d.ts.map +1 -0
- package/dist/serv/auth/endpoints.js +1005 -0
- package/dist/serv/auth/endpoints.js.map +1 -0
- package/dist/serv/auth/http-adapter.d.ts +60 -0
- package/dist/serv/auth/http-adapter.d.ts.map +1 -0
- package/dist/serv/auth/http-adapter.js +235 -0
- package/dist/serv/auth/http-adapter.js.map +1 -0
- package/dist/serv/auth/jwt.d.ts +92 -6
- package/dist/serv/auth/jwt.d.ts.map +1 -1
- package/dist/serv/auth/jwt.js +226 -24
- package/dist/serv/auth/jwt.js.map +1 -1
- package/dist/serv/auth/oauth-sqlite-stores.d.ts +48 -0
- package/dist/serv/auth/oauth-sqlite-stores.d.ts.map +1 -0
- package/dist/serv/auth/oauth-sqlite-stores.js +212 -0
- package/dist/serv/auth/oauth-sqlite-stores.js.map +1 -0
- package/dist/serv/auth/sqlite-stores.d.ts +85 -0
- package/dist/serv/auth/sqlite-stores.d.ts.map +1 -0
- package/dist/serv/auth/sqlite-stores.js +446 -0
- package/dist/serv/auth/sqlite-stores.js.map +1 -0
- package/dist/serv/auth/well-known.d.ts +54 -1
- package/dist/serv/auth/well-known.d.ts.map +1 -1
- package/dist/serv/auth/well-known.js +166 -17
- package/dist/serv/auth/well-known.js.map +1 -1
- package/dist/serv/index.d.ts +45 -2
- package/dist/serv/index.d.ts.map +1 -1
- package/dist/serv/index.js +65 -1
- package/dist/serv/index.js.map +1 -1
- package/dist/serv/types/index.d.ts +80 -0
- package/dist/serv/types/index.d.ts.map +1 -1
- package/dist/serv/types/index.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +61 -6
- package/dist/server.js.map +1 -1
- package/dist/shared/announce-context.d.ts +51 -0
- package/dist/shared/announce-context.d.ts.map +1 -0
- package/dist/shared/announce-context.js +73 -0
- package/dist/shared/announce-context.js.map +1 -0
- package/dist/shared/audit-sqlite.d.ts +63 -0
- package/dist/shared/audit-sqlite.d.ts.map +1 -0
- package/dist/shared/audit-sqlite.js +187 -0
- package/dist/shared/audit-sqlite.js.map +1 -0
- package/dist/shared/audit.d.ts +25 -3
- package/dist/shared/audit.d.ts.map +1 -1
- package/dist/shared/audit.js +97 -3
- package/dist/shared/audit.js.map +1 -1
- package/dist/shared/error-handler.d.ts +10 -1
- package/dist/shared/error-handler.d.ts.map +1 -1
- package/dist/shared/error-handler.js +17 -2
- package/dist/shared/error-handler.js.map +1 -1
- package/dist/shared/security.d.ts +12 -0
- package/dist/shared/security.d.ts.map +1 -1
- package/dist/shared/security.js +80 -0
- package/dist/shared/security.js.map +1 -1
- package/dist/shared/sqlite-runtime.d.ts +46 -0
- package/dist/shared/sqlite-runtime.d.ts.map +1 -0
- package/dist/shared/sqlite-runtime.js +110 -0
- package/dist/shared/sqlite-runtime.js.map +1 -0
- package/dist/tasks/store.d.ts +1 -1
- package/dist/tasks/store.d.ts.map +1 -1
- package/dist/tasks/store.js +29 -15
- package/dist/tasks/store.js.map +1 -1
- package/dist/telemetry/metrics.d.ts +26 -0
- package/dist/telemetry/metrics.d.ts.map +1 -1
- package/dist/telemetry/metrics.js +31 -0
- package/dist/telemetry/metrics.js.map +1 -1
- package/dist/test-runner.d.ts.map +1 -1
- package/dist/test-runner.js +3 -3
- package/dist/test-runner.js.map +1 -1
- package/dist/version-checker.d.ts.map +1 -1
- package/dist/version-checker.js +7 -14
- package/dist/version-checker.js.map +1 -1
- package/dist/version.d.ts +12 -0
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +103 -1
- package/dist/version.js.map +1 -1
- package/package.json +6 -2
- package/templates/photon.template.ts +7 -13
|
@@ -0,0 +1,1005 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth 2.1 Authorization Server HTTP handlers.
|
|
3
|
+
*
|
|
4
|
+
* Pure functions (no HTTP framework coupling): each handler takes an
|
|
5
|
+
* `AuthRequest` describing the inbound HTTP request, plus a `Deps` object
|
|
6
|
+
* with the stores it needs, and returns an `AuthResponse` `{status, headers, body}`.
|
|
7
|
+
*
|
|
8
|
+
* The HTTP-framework adapter (Express/Fetch/Cloudflare Worker) is responsible
|
|
9
|
+
* for parsing the request, authenticating the user session (if any), and
|
|
10
|
+
* translating the response back to its native HTTP primitive.
|
|
11
|
+
*
|
|
12
|
+
* Implements:
|
|
13
|
+
* - `/authorize` — RFC 6749 §4.1 authorization code grant (PKCE required)
|
|
14
|
+
* - `/token` — RFC 6749 §4.1.3 / §6 / §4.4 (code, refresh, client_credentials)
|
|
15
|
+
* - `/register` — RFC 7591 dynamic client registration
|
|
16
|
+
* - `/consent` — HTML consent screen + POST approve/deny
|
|
17
|
+
*
|
|
18
|
+
* CIMD (HTTPS client_id) is resolved via `resolveClientMetadata` from
|
|
19
|
+
* `./well-known.js`. Both CIMD and DCR clients are accepted at `/authorize`
|
|
20
|
+
* and `/token`; `/register` writes DCR-only.
|
|
21
|
+
*/
|
|
22
|
+
import { generateSecureToken, hashClientSecret, verifyClientSecret, normalizeScopes, } from './auth-store.js';
|
|
23
|
+
import { verifyCodeChallenge } from './jwt.js';
|
|
24
|
+
import { resolveClientMetadata } from './well-known.js';
|
|
25
|
+
import { recordAuthEvent, recordCimdFetch } from '../../telemetry/metrics.js';
|
|
26
|
+
export const DEFAULT_ENDPOINT_CONFIG = {
|
|
27
|
+
firstPartyClientIds: new Set(['photon-cli', 'photon-beam']),
|
|
28
|
+
defaultScopes: ['mcp:read'],
|
|
29
|
+
consentTtlDays: 30,
|
|
30
|
+
codeTtlSeconds: 60,
|
|
31
|
+
accessTokenTtlSeconds: 15 * 60,
|
|
32
|
+
refreshTokenTtlSeconds: 30 * 24 * 60 * 60,
|
|
33
|
+
pendingTtlSeconds: 10 * 60,
|
|
34
|
+
clientIdleTtlMs: 30 * 24 * 60 * 60 * 1000,
|
|
35
|
+
};
|
|
36
|
+
// ============================================================================
|
|
37
|
+
// Response Helpers
|
|
38
|
+
// ============================================================================
|
|
39
|
+
const JSON_HEADERS = {
|
|
40
|
+
'Content-Type': 'application/json',
|
|
41
|
+
'Cache-Control': 'no-store',
|
|
42
|
+
Pragma: 'no-cache',
|
|
43
|
+
};
|
|
44
|
+
function jsonResponse(status, body) {
|
|
45
|
+
return { status, headers: { ...JSON_HEADERS }, body: JSON.stringify(body) };
|
|
46
|
+
}
|
|
47
|
+
function errorResponse(status, error, errorDescription, errorUri) {
|
|
48
|
+
const body = { error, error_description: errorDescription };
|
|
49
|
+
if (errorUri)
|
|
50
|
+
body.error_uri = errorUri;
|
|
51
|
+
return jsonResponse(status, body);
|
|
52
|
+
}
|
|
53
|
+
function redirectResponse(location, extraHeaders = {}) {
|
|
54
|
+
return {
|
|
55
|
+
status: 302,
|
|
56
|
+
headers: { Location: location, 'Cache-Control': 'no-store', ...extraHeaders },
|
|
57
|
+
body: '',
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
function htmlResponse(status, html) {
|
|
61
|
+
return {
|
|
62
|
+
status,
|
|
63
|
+
headers: {
|
|
64
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
65
|
+
'Cache-Control': 'no-store',
|
|
66
|
+
'X-Frame-Options': 'DENY',
|
|
67
|
+
'Content-Security-Policy': "default-src 'self'; style-src 'unsafe-inline'; img-src https:",
|
|
68
|
+
},
|
|
69
|
+
body: html,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Build a redirect URL for an authorization error per RFC 6749 §4.1.2.1.
|
|
74
|
+
*/
|
|
75
|
+
function authorizeErrorRedirect(redirectUri, state, error, errorDescription) {
|
|
76
|
+
const url = new URL(redirectUri);
|
|
77
|
+
url.searchParams.set('error', error);
|
|
78
|
+
url.searchParams.set('error_description', errorDescription);
|
|
79
|
+
if (state)
|
|
80
|
+
url.searchParams.set('state', state);
|
|
81
|
+
return redirectResponse(url.toString());
|
|
82
|
+
}
|
|
83
|
+
// ============================================================================
|
|
84
|
+
// /authorize
|
|
85
|
+
// ============================================================================
|
|
86
|
+
export async function handleAuthorize(req, deps) {
|
|
87
|
+
const res = await handleAuthorizeImpl(req, deps);
|
|
88
|
+
observeAuth('authorize', res, { clientType: detectClientType(req.url) });
|
|
89
|
+
return res;
|
|
90
|
+
}
|
|
91
|
+
async function handleAuthorizeImpl(req, deps) {
|
|
92
|
+
if (req.method !== 'GET' && req.method !== 'POST') {
|
|
93
|
+
return errorResponse(405, 'invalid_request', 'method not allowed');
|
|
94
|
+
}
|
|
95
|
+
const url = new URL(req.url);
|
|
96
|
+
const params = url.searchParams;
|
|
97
|
+
const clientId = params.get('client_id');
|
|
98
|
+
const redirectUri = params.get('redirect_uri');
|
|
99
|
+
const responseType = params.get('response_type');
|
|
100
|
+
const scope = params.get('scope') ?? '';
|
|
101
|
+
const state = params.get('state') ?? undefined;
|
|
102
|
+
const codeChallenge = params.get('code_challenge');
|
|
103
|
+
const codeChallengeMethod = params.get('code_challenge_method');
|
|
104
|
+
const prompt = params.get('prompt') ?? undefined;
|
|
105
|
+
const nonce = params.get('nonce') ?? undefined;
|
|
106
|
+
// Pre-redirect validations return error JSON (can't trust the redirect_uri yet).
|
|
107
|
+
if (!clientId) {
|
|
108
|
+
return errorResponse(400, 'invalid_request', 'client_id is required');
|
|
109
|
+
}
|
|
110
|
+
if (!redirectUri) {
|
|
111
|
+
return errorResponse(400, 'invalid_request', 'redirect_uri is required');
|
|
112
|
+
}
|
|
113
|
+
if (responseType !== 'code') {
|
|
114
|
+
return errorResponse(400, 'unsupported_response_type', 'only response_type=code is supported');
|
|
115
|
+
}
|
|
116
|
+
if (!codeChallenge) {
|
|
117
|
+
return errorResponse(400, 'invalid_request', 'code_challenge is required (PKCE)');
|
|
118
|
+
}
|
|
119
|
+
if (codeChallengeMethod && codeChallengeMethod !== 'S256') {
|
|
120
|
+
return errorResponse(400, 'invalid_request', 'only code_challenge_method=S256 is supported');
|
|
121
|
+
}
|
|
122
|
+
// Resolve client: CIMD (HTTPS URL) or DCR registry
|
|
123
|
+
const clientInfo = await resolveClient(clientId, deps);
|
|
124
|
+
if (!clientInfo) {
|
|
125
|
+
return errorResponse(400, 'invalid_client', `unknown or invalid client_id: ${clientId}`);
|
|
126
|
+
}
|
|
127
|
+
// Validate redirect_uri against client's allowed list (exact match)
|
|
128
|
+
if (!clientInfo.redirectUris.includes(redirectUri)) {
|
|
129
|
+
return errorResponse(400, 'invalid_request', 'redirect_uri does not match any registered URI for this client');
|
|
130
|
+
}
|
|
131
|
+
// Everything below here may redirect to redirect_uri with error params.
|
|
132
|
+
// Determine subject
|
|
133
|
+
const userId = deps.config.singleUserId ?? req.userId;
|
|
134
|
+
if (!userId) {
|
|
135
|
+
if (prompt === 'none') {
|
|
136
|
+
return authorizeErrorRedirect(redirectUri, state, 'login_required', 'user not authenticated');
|
|
137
|
+
}
|
|
138
|
+
// Redirect to login with return_to pointing back at /authorize with full query
|
|
139
|
+
const loginUrl = new URL(deps.config.loginUrl);
|
|
140
|
+
loginUrl.searchParams.set('return_to', req.url);
|
|
141
|
+
return redirectResponse(loginUrl.toString());
|
|
142
|
+
}
|
|
143
|
+
// Normalize scopes
|
|
144
|
+
const requestedScopes = scope ? scope.split(/\s+/).filter(Boolean) : deps.config.defaultScopes;
|
|
145
|
+
// Consent check
|
|
146
|
+
const isFirstParty = deps.config.firstPartyClientIds.has(clientId);
|
|
147
|
+
const alreadyConsented = isFirstParty
|
|
148
|
+
? true
|
|
149
|
+
: await deps.consentStore.covers(userId, deps.tenant.id, clientId, requestedScopes);
|
|
150
|
+
const forceConsent = prompt === 'consent';
|
|
151
|
+
const needConsent = forceConsent || !alreadyConsented;
|
|
152
|
+
if (needConsent) {
|
|
153
|
+
if (prompt === 'none') {
|
|
154
|
+
return authorizeErrorRedirect(redirectUri, state, 'consent_required', 'user consent required but prompt=none');
|
|
155
|
+
}
|
|
156
|
+
// Stash pending request, redirect to consent page
|
|
157
|
+
const pendingId = generateSecureToken(24);
|
|
158
|
+
const now = (deps.now ?? (() => new Date()))();
|
|
159
|
+
const pending = {
|
|
160
|
+
id: pendingId,
|
|
161
|
+
clientId,
|
|
162
|
+
redirectUri,
|
|
163
|
+
scope: normalizeScopes(requestedScopes.join(' ')),
|
|
164
|
+
state,
|
|
165
|
+
nonce,
|
|
166
|
+
codeChallenge,
|
|
167
|
+
codeChallengeMethod: 'S256',
|
|
168
|
+
userId,
|
|
169
|
+
tenantId: deps.tenant.id,
|
|
170
|
+
responseType: 'code',
|
|
171
|
+
expiresAt: new Date(now.getTime() + deps.config.pendingTtlSeconds * 1000),
|
|
172
|
+
createdAt: now,
|
|
173
|
+
};
|
|
174
|
+
await deps.pendingStore.save(pending);
|
|
175
|
+
const consentUrl = new URL(deps.config.consentUrl);
|
|
176
|
+
consentUrl.searchParams.set('req', pendingId);
|
|
177
|
+
return redirectResponse(consentUrl.toString());
|
|
178
|
+
}
|
|
179
|
+
// Consent granted (or skipped) — mint code and redirect
|
|
180
|
+
return await issueCodeAndRedirect({
|
|
181
|
+
clientId,
|
|
182
|
+
redirectUri,
|
|
183
|
+
scope: requestedScopes.join(' '),
|
|
184
|
+
state,
|
|
185
|
+
nonce,
|
|
186
|
+
codeChallenge,
|
|
187
|
+
userId,
|
|
188
|
+
}, deps);
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Issue an authorization code and redirect back to the client.
|
|
192
|
+
* Extracted so `/consent` can resume the flow symmetrically.
|
|
193
|
+
*/
|
|
194
|
+
async function issueCodeAndRedirect(args, deps) {
|
|
195
|
+
const now = (deps.now ?? (() => new Date()))();
|
|
196
|
+
const code = generateSecureToken(32);
|
|
197
|
+
const authCode = {
|
|
198
|
+
code,
|
|
199
|
+
clientId: args.clientId,
|
|
200
|
+
redirectUri: args.redirectUri,
|
|
201
|
+
scope: args.scope,
|
|
202
|
+
userId: args.userId,
|
|
203
|
+
tenantId: deps.tenant.id,
|
|
204
|
+
codeChallenge: args.codeChallenge,
|
|
205
|
+
codeChallengeMethod: 'S256',
|
|
206
|
+
nonce: args.nonce,
|
|
207
|
+
expiresAt: new Date(now.getTime() + deps.config.codeTtlSeconds * 1000),
|
|
208
|
+
createdAt: now,
|
|
209
|
+
};
|
|
210
|
+
await deps.codeStore.save(authCode);
|
|
211
|
+
const redirect = new URL(args.redirectUri);
|
|
212
|
+
redirect.searchParams.set('code', code);
|
|
213
|
+
if (args.state)
|
|
214
|
+
redirect.searchParams.set('state', args.state);
|
|
215
|
+
deps.log?.('info', 'authorization_code_issued', {
|
|
216
|
+
client_id: args.clientId,
|
|
217
|
+
user_id: args.userId,
|
|
218
|
+
scope: args.scope,
|
|
219
|
+
});
|
|
220
|
+
return redirectResponse(redirect.toString());
|
|
221
|
+
}
|
|
222
|
+
// ============================================================================
|
|
223
|
+
// /consent (GET = screen, POST = approve/deny)
|
|
224
|
+
// ============================================================================
|
|
225
|
+
export async function handleConsent(req, deps) {
|
|
226
|
+
const res = await handleConsentImpl(req, deps);
|
|
227
|
+
observeAuth('consent', res);
|
|
228
|
+
return res;
|
|
229
|
+
}
|
|
230
|
+
async function handleConsentImpl(req, deps) {
|
|
231
|
+
const url = new URL(req.url);
|
|
232
|
+
if (req.method === 'GET') {
|
|
233
|
+
const pendingId = url.searchParams.get('req');
|
|
234
|
+
if (!pendingId) {
|
|
235
|
+
return errorResponse(400, 'invalid_request', 'missing pending request id');
|
|
236
|
+
}
|
|
237
|
+
// Peek without consuming — we consume on POST approve.
|
|
238
|
+
// A tiny race exists here (expiry between GET and POST); acceptable for 10-min window.
|
|
239
|
+
const pending = await deps.pendingStore.peek(pendingId);
|
|
240
|
+
if (!pending) {
|
|
241
|
+
return errorResponse(400, 'invalid_request', 'pending request not found or expired');
|
|
242
|
+
}
|
|
243
|
+
const client = await resolveClient(pending.clientId, deps);
|
|
244
|
+
return htmlResponse(200, renderConsentPage(pending, client, deps.tenant));
|
|
245
|
+
}
|
|
246
|
+
if (req.method === 'POST') {
|
|
247
|
+
const userId = deps.config.singleUserId ?? req.userId;
|
|
248
|
+
if (!userId)
|
|
249
|
+
return errorResponse(401, 'unauthorized', 'login required');
|
|
250
|
+
const form = parseFormBody(req.body ?? '');
|
|
251
|
+
const pendingId = form.get('req');
|
|
252
|
+
const decision = form.get('decision');
|
|
253
|
+
if (!pendingId) {
|
|
254
|
+
return errorResponse(400, 'invalid_request', 'missing req field');
|
|
255
|
+
}
|
|
256
|
+
// Peek so a wrong-session submission returns 403 without burning
|
|
257
|
+
// the legitimate user's pending request. Consume only after ownership
|
|
258
|
+
// is verified — the caller can then restart consent if needed.
|
|
259
|
+
const pending = await deps.pendingStore.peek(pendingId);
|
|
260
|
+
if (!pending) {
|
|
261
|
+
return errorResponse(400, 'invalid_request', 'pending request not found or expired');
|
|
262
|
+
}
|
|
263
|
+
if (pending.userId !== userId || pending.tenantId !== deps.tenant.id) {
|
|
264
|
+
deps.log?.('warn', 'consent_user_mismatch', {
|
|
265
|
+
pending_user: pending.userId,
|
|
266
|
+
actual_user: userId,
|
|
267
|
+
});
|
|
268
|
+
return errorResponse(403, 'forbidden', 'pending request does not belong to this user');
|
|
269
|
+
}
|
|
270
|
+
// Ownership verified — consume atomically. A parallel approve/deny
|
|
271
|
+
// from the same user races here and the loser gets a fresh 400.
|
|
272
|
+
const consumed = await deps.pendingStore.consume(pendingId);
|
|
273
|
+
if (!consumed) {
|
|
274
|
+
return errorResponse(400, 'invalid_request', 'pending request not found or expired');
|
|
275
|
+
}
|
|
276
|
+
if (decision !== 'approve') {
|
|
277
|
+
return authorizeErrorRedirect(consumed.redirectUri, consumed.state, 'access_denied', 'user denied consent');
|
|
278
|
+
}
|
|
279
|
+
// Save remembered consent
|
|
280
|
+
const now = (deps.now ?? (() => new Date()))();
|
|
281
|
+
const record = {
|
|
282
|
+
userId,
|
|
283
|
+
tenantId: deps.tenant.id,
|
|
284
|
+
clientId: consumed.clientId,
|
|
285
|
+
scopes: consumed.scope,
|
|
286
|
+
expiresAt: new Date(now.getTime() + deps.config.consentTtlDays * 24 * 60 * 60 * 1000),
|
|
287
|
+
createdAt: now,
|
|
288
|
+
};
|
|
289
|
+
await deps.consentStore.save(record);
|
|
290
|
+
return await issueCodeAndRedirect({
|
|
291
|
+
clientId: consumed.clientId,
|
|
292
|
+
redirectUri: consumed.redirectUri,
|
|
293
|
+
scope: consumed.scope,
|
|
294
|
+
state: consumed.state,
|
|
295
|
+
nonce: consumed.nonce,
|
|
296
|
+
codeChallenge: consumed.codeChallenge,
|
|
297
|
+
userId,
|
|
298
|
+
}, deps);
|
|
299
|
+
}
|
|
300
|
+
return errorResponse(405, 'invalid_request', 'method not allowed');
|
|
301
|
+
}
|
|
302
|
+
// ============================================================================
|
|
303
|
+
// /token
|
|
304
|
+
// ============================================================================
|
|
305
|
+
export async function handleToken(req, deps) {
|
|
306
|
+
const form = parseFormBody(req.body ?? '');
|
|
307
|
+
const grantTypeRaw = form.get('grant_type');
|
|
308
|
+
const res = await handleTokenImpl(req, deps, form);
|
|
309
|
+
observeAuth('token', res, { grantType: normalizeGrantType(grantTypeRaw) });
|
|
310
|
+
return res;
|
|
311
|
+
}
|
|
312
|
+
async function handleTokenImpl(req, deps, form) {
|
|
313
|
+
if (req.method !== 'POST') {
|
|
314
|
+
return errorResponse(405, 'invalid_request', 'method must be POST');
|
|
315
|
+
}
|
|
316
|
+
const grantType = form.get('grant_type');
|
|
317
|
+
// Client authentication (Basic or post-body)
|
|
318
|
+
const authedClient = await authenticateTokenClient(req, form, deps);
|
|
319
|
+
// Some grants allow public clients (no auth). We return the failure only
|
|
320
|
+
// where it's actually required.
|
|
321
|
+
switch (grantType) {
|
|
322
|
+
case 'authorization_code':
|
|
323
|
+
return await handleAuthorizationCodeGrant(form, authedClient, deps);
|
|
324
|
+
case 'refresh_token':
|
|
325
|
+
return await handleRefreshTokenGrant(form, authedClient, deps);
|
|
326
|
+
case 'client_credentials':
|
|
327
|
+
return await handleClientCredentialsGrant(authedClient, form, deps);
|
|
328
|
+
case 'urn:ietf:params:oauth:grant-type:token-exchange':
|
|
329
|
+
return await handleTokenExchangeGrant(form, authedClient, deps);
|
|
330
|
+
default:
|
|
331
|
+
return errorResponse(400, 'unsupported_grant_type', `grant_type '${grantType ?? ''}' is not supported`);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* RFC 8693 OAuth Token Exchange.
|
|
336
|
+
*
|
|
337
|
+
* Enables the confused-deputy fix for MCP: when an MCP server needs to call
|
|
338
|
+
* an upstream API on behalf of a user, it exchanges the user's delegation
|
|
339
|
+
* token for a downstream token scoped to the upstream audience, with the
|
|
340
|
+
* MCP server identified in the `act` claim.
|
|
341
|
+
*
|
|
342
|
+
* This implementation supports the 80/20 subset:
|
|
343
|
+
* - subject_token_type = access_token (JWT issued by this AS)
|
|
344
|
+
* - requested_token_type = access_token (default)
|
|
345
|
+
* - Delegation only (actor_token not required; authenticated client fills act)
|
|
346
|
+
* - Scope may be narrowed, never expanded
|
|
347
|
+
* - audience is required; emitted token's `aud` claim is set to it
|
|
348
|
+
*
|
|
349
|
+
* Out of scope: saml1/saml2/jwt subject tokens (add when needed),
|
|
350
|
+
* impersonation flows (always use delegation for audit).
|
|
351
|
+
*/
|
|
352
|
+
async function handleTokenExchangeGrant(form, authedClient, deps) {
|
|
353
|
+
if (!authedClient) {
|
|
354
|
+
return errorResponse(401, 'invalid_client', 'token exchange requires authenticated client');
|
|
355
|
+
}
|
|
356
|
+
const subjectToken = form.get('subject_token');
|
|
357
|
+
const subjectTokenType = form.get('subject_token_type');
|
|
358
|
+
const audience = form.get('audience');
|
|
359
|
+
const requestedTokenType = form.get('requested_token_type') ?? 'urn:ietf:params:oauth:token-type:access_token';
|
|
360
|
+
const scopeParam = form.get('scope');
|
|
361
|
+
if (!subjectToken || !subjectTokenType) {
|
|
362
|
+
return errorResponse(400, 'invalid_request', 'subject_token and subject_token_type are required');
|
|
363
|
+
}
|
|
364
|
+
if (!audience) {
|
|
365
|
+
return errorResponse(400, 'invalid_request', 'audience is required; target resource must be identified');
|
|
366
|
+
}
|
|
367
|
+
if (subjectTokenType !== 'urn:ietf:params:oauth:token-type:access_token') {
|
|
368
|
+
return errorResponse(400, 'invalid_request', `subject_token_type '${subjectTokenType}' is not supported`);
|
|
369
|
+
}
|
|
370
|
+
if (requestedTokenType !== 'urn:ietf:params:oauth:token-type:access_token') {
|
|
371
|
+
return errorResponse(400, 'invalid_request', `requested_token_type '${requestedTokenType}' is not supported`);
|
|
372
|
+
}
|
|
373
|
+
// Validate subject token — signature, issuer, expiration
|
|
374
|
+
const decoded = deps.jwtService.verifySessionToken(subjectToken);
|
|
375
|
+
if (!decoded) {
|
|
376
|
+
return errorResponse(400, 'invalid_grant', 'subject_token is invalid or expired');
|
|
377
|
+
}
|
|
378
|
+
// Scope narrowing: requested must be subset of subject's scope
|
|
379
|
+
const subjectScope = decoded.scope ?? '';
|
|
380
|
+
const subjectScopes = new Set(subjectScope.split(' ').filter(Boolean));
|
|
381
|
+
let scope = subjectScope;
|
|
382
|
+
if (scopeParam) {
|
|
383
|
+
const requested = scopeParam.split(/\s+/).filter(Boolean);
|
|
384
|
+
if (!requested.every((s) => subjectScopes.has(s))) {
|
|
385
|
+
return errorResponse(400, 'invalid_scope', 'requested scope exceeds subject token scope');
|
|
386
|
+
}
|
|
387
|
+
scope = requested.join(' ');
|
|
388
|
+
}
|
|
389
|
+
// Build act chain — nested for delegation chains (RFC 8693 §4.1)
|
|
390
|
+
const existingAct = decoded.act;
|
|
391
|
+
const act = { sub: `client:${authedClient.clientId}` };
|
|
392
|
+
if (existingAct)
|
|
393
|
+
act.act = existingAct;
|
|
394
|
+
const now = (deps.now ?? (() => new Date()))();
|
|
395
|
+
const nowSec = Math.floor(now.getTime() / 1000);
|
|
396
|
+
const exchanged = deps.jwtService.exchangeSign({
|
|
397
|
+
sub: decoded.sub,
|
|
398
|
+
aud: audience,
|
|
399
|
+
exp: nowSec + deps.config.accessTokenTtlSeconds,
|
|
400
|
+
iat: nowSec,
|
|
401
|
+
iss: deps.config.issuer,
|
|
402
|
+
scope,
|
|
403
|
+
client_id: authedClient.clientId,
|
|
404
|
+
tenant_id: deps.tenant.id,
|
|
405
|
+
act,
|
|
406
|
+
});
|
|
407
|
+
deps.log?.('info', 'token_exchange_issued', {
|
|
408
|
+
sub: decoded.sub,
|
|
409
|
+
actor: authedClient.clientId,
|
|
410
|
+
audience,
|
|
411
|
+
scope,
|
|
412
|
+
});
|
|
413
|
+
return jsonResponse(200, {
|
|
414
|
+
access_token: exchanged,
|
|
415
|
+
issued_token_type: 'urn:ietf:params:oauth:token-type:access_token',
|
|
416
|
+
token_type: 'Bearer',
|
|
417
|
+
expires_in: deps.config.accessTokenTtlSeconds,
|
|
418
|
+
scope,
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
async function handleAuthorizationCodeGrant(form, authedClient, deps) {
|
|
422
|
+
const code = form.get('code');
|
|
423
|
+
const redirectUri = form.get('redirect_uri');
|
|
424
|
+
const codeVerifier = form.get('code_verifier');
|
|
425
|
+
const clientIdParam = form.get('client_id');
|
|
426
|
+
if (!code || !redirectUri || !codeVerifier) {
|
|
427
|
+
return errorResponse(400, 'invalid_request', 'code, redirect_uri, and code_verifier are required');
|
|
428
|
+
}
|
|
429
|
+
// Peek first so malformed retries (wrong verifier, missing Basic auth,
|
|
430
|
+
// mismatched redirect_uri) don't permanently burn a valid code. The code
|
|
431
|
+
// is consumed atomically below after every check passes — race between
|
|
432
|
+
// two concurrent requests with the same code still loses to consume().
|
|
433
|
+
const stored = await deps.codeStore.peek(code);
|
|
434
|
+
if (!stored) {
|
|
435
|
+
return errorResponse(400, 'invalid_grant', 'authorization code is invalid or expired');
|
|
436
|
+
}
|
|
437
|
+
// Client identity: either from Basic auth (confidential) or client_id form param (public)
|
|
438
|
+
const effectiveClientId = authedClient?.clientId ?? clientIdParam;
|
|
439
|
+
if (effectiveClientId !== stored.clientId) {
|
|
440
|
+
return errorResponse(400, 'invalid_grant', 'client_id does not match the code issuer');
|
|
441
|
+
}
|
|
442
|
+
if (stored.redirectUri !== redirectUri) {
|
|
443
|
+
return errorResponse(400, 'invalid_grant', 'redirect_uri does not match original request');
|
|
444
|
+
}
|
|
445
|
+
// PKCE verify
|
|
446
|
+
if (!verifyCodeChallenge(codeVerifier, stored.codeChallenge)) {
|
|
447
|
+
return errorResponse(400, 'invalid_grant', 'code_verifier does not match code_challenge');
|
|
448
|
+
}
|
|
449
|
+
// If the client is registered as confidential, it MUST authenticate
|
|
450
|
+
const registered = await deps.clientRegistry.find(stored.clientId);
|
|
451
|
+
if (registered && !registered.isPublic && !authedClient) {
|
|
452
|
+
return errorResponse(401, 'invalid_client', 'confidential client must authenticate');
|
|
453
|
+
}
|
|
454
|
+
// All checks passed — consume atomically. If another request already
|
|
455
|
+
// consumed the code between peek and now, reject (RFC 6749 §4.1.3
|
|
456
|
+
// single-use requirement).
|
|
457
|
+
const consumed = await deps.codeStore.consume(code);
|
|
458
|
+
if (!consumed) {
|
|
459
|
+
return errorResponse(400, 'invalid_grant', 'authorization code is invalid or expired');
|
|
460
|
+
}
|
|
461
|
+
await deps.clientRegistry.touch(consumed.clientId);
|
|
462
|
+
return await issueTokens({
|
|
463
|
+
clientId: consumed.clientId,
|
|
464
|
+
userId: consumed.userId,
|
|
465
|
+
scope: consumed.scope,
|
|
466
|
+
nonce: consumed.nonce,
|
|
467
|
+
}, deps);
|
|
468
|
+
}
|
|
469
|
+
async function handleRefreshTokenGrant(form, authedClient, deps) {
|
|
470
|
+
const refreshToken = form.get('refresh_token');
|
|
471
|
+
if (!refreshToken) {
|
|
472
|
+
return errorResponse(400, 'invalid_request', 'refresh_token is required');
|
|
473
|
+
}
|
|
474
|
+
const clientIdParam = form.get('client_id');
|
|
475
|
+
const existing = await deps.refreshTokenStore.find(refreshToken);
|
|
476
|
+
if (!existing) {
|
|
477
|
+
return errorResponse(400, 'invalid_grant', 'refresh_token is invalid or expired');
|
|
478
|
+
}
|
|
479
|
+
const effectiveClientId = authedClient?.clientId ?? clientIdParam;
|
|
480
|
+
if (effectiveClientId !== existing.clientId) {
|
|
481
|
+
return errorResponse(400, 'invalid_grant', 'client_id does not match refresh token');
|
|
482
|
+
}
|
|
483
|
+
const registered = await deps.clientRegistry.find(existing.clientId);
|
|
484
|
+
if (registered && !registered.isPublic && !authedClient) {
|
|
485
|
+
return errorResponse(401, 'invalid_client', 'confidential client must authenticate');
|
|
486
|
+
}
|
|
487
|
+
// Optional scope narrowing
|
|
488
|
+
const requestedScope = form.get('scope');
|
|
489
|
+
let scope = existing.scope;
|
|
490
|
+
if (requestedScope) {
|
|
491
|
+
const existingSet = new Set(existing.scope.split(' ').filter(Boolean));
|
|
492
|
+
const requested = requestedScope.split(/\s+/).filter(Boolean);
|
|
493
|
+
if (!requested.every((s) => existingSet.has(s))) {
|
|
494
|
+
return errorResponse(400, 'invalid_scope', 'requested scope exceeds original grant');
|
|
495
|
+
}
|
|
496
|
+
scope = requested.join(' ');
|
|
497
|
+
}
|
|
498
|
+
// Rotate the refresh token (OAuth 2.1 requires rotation for public clients)
|
|
499
|
+
const now = (deps.now ?? (() => new Date()))();
|
|
500
|
+
const newRefreshToken = generateSecureToken(32);
|
|
501
|
+
const rotated = await deps.refreshTokenStore.rotate(refreshToken, {
|
|
502
|
+
token: newRefreshToken,
|
|
503
|
+
clientId: existing.clientId,
|
|
504
|
+
userId: existing.userId,
|
|
505
|
+
tenantId: existing.tenantId,
|
|
506
|
+
scope,
|
|
507
|
+
expiresAt: new Date(now.getTime() + deps.config.refreshTokenTtlSeconds * 1000),
|
|
508
|
+
createdAt: now,
|
|
509
|
+
supersedes: hashClientSecret(refreshToken), // hashed for replay detection
|
|
510
|
+
});
|
|
511
|
+
if (!rotated) {
|
|
512
|
+
return errorResponse(400, 'invalid_grant', 'refresh_token rotation failed');
|
|
513
|
+
}
|
|
514
|
+
await deps.clientRegistry.touch(existing.clientId);
|
|
515
|
+
return await issueTokens({ clientId: existing.clientId, userId: existing.userId, scope, preRotated: newRefreshToken }, deps);
|
|
516
|
+
}
|
|
517
|
+
async function handleClientCredentialsGrant(authedClient, form, deps) {
|
|
518
|
+
if (!authedClient) {
|
|
519
|
+
return errorResponse(401, 'invalid_client', 'client_credentials grant requires client authentication');
|
|
520
|
+
}
|
|
521
|
+
if (authedClient.registered.isPublic) {
|
|
522
|
+
return errorResponse(400, 'unauthorized_client', 'public clients cannot use client_credentials');
|
|
523
|
+
}
|
|
524
|
+
const requestedScope = form.get('scope');
|
|
525
|
+
const allowedScopes = new Set(authedClient.registered.scope.split(' ').filter(Boolean));
|
|
526
|
+
let scope;
|
|
527
|
+
if (requestedScope) {
|
|
528
|
+
const requested = requestedScope.split(/\s+/).filter(Boolean);
|
|
529
|
+
if (!requested.every((s) => allowedScopes.has(s))) {
|
|
530
|
+
return errorResponse(400, 'invalid_scope', 'requested scope not permitted for client');
|
|
531
|
+
}
|
|
532
|
+
scope = requested.join(' ');
|
|
533
|
+
}
|
|
534
|
+
else {
|
|
535
|
+
scope = authedClient.registered.scope;
|
|
536
|
+
}
|
|
537
|
+
await deps.clientRegistry.touch(authedClient.clientId);
|
|
538
|
+
// No refresh token for client_credentials per RFC 6749 §4.4.3
|
|
539
|
+
const now = (deps.now ?? (() => new Date()))();
|
|
540
|
+
const accessToken = deps.jwtService.generateAccessToken({
|
|
541
|
+
sub: `client:${authedClient.clientId}`,
|
|
542
|
+
tenantId: deps.tenant.id,
|
|
543
|
+
scope,
|
|
544
|
+
clientId: authedClient.clientId,
|
|
545
|
+
expiresInSeconds: deps.config.accessTokenTtlSeconds,
|
|
546
|
+
now,
|
|
547
|
+
});
|
|
548
|
+
return jsonResponse(200, {
|
|
549
|
+
access_token: accessToken,
|
|
550
|
+
token_type: 'Bearer',
|
|
551
|
+
expires_in: deps.config.accessTokenTtlSeconds,
|
|
552
|
+
scope,
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
async function issueTokens(args, deps) {
|
|
556
|
+
const now = (deps.now ?? (() => new Date()))();
|
|
557
|
+
const accessToken = deps.jwtService.generateAccessToken({
|
|
558
|
+
sub: args.userId,
|
|
559
|
+
tenantId: deps.tenant.id,
|
|
560
|
+
scope: args.scope,
|
|
561
|
+
clientId: args.clientId,
|
|
562
|
+
expiresInSeconds: deps.config.accessTokenTtlSeconds,
|
|
563
|
+
now,
|
|
564
|
+
});
|
|
565
|
+
let refreshToken = args.preRotated;
|
|
566
|
+
if (!refreshToken) {
|
|
567
|
+
refreshToken = generateSecureToken(32);
|
|
568
|
+
const record = {
|
|
569
|
+
token: refreshToken,
|
|
570
|
+
clientId: args.clientId,
|
|
571
|
+
userId: args.userId,
|
|
572
|
+
tenantId: deps.tenant.id,
|
|
573
|
+
scope: args.scope,
|
|
574
|
+
expiresAt: new Date(now.getTime() + deps.config.refreshTokenTtlSeconds * 1000),
|
|
575
|
+
createdAt: now,
|
|
576
|
+
};
|
|
577
|
+
await deps.refreshTokenStore.save(record);
|
|
578
|
+
}
|
|
579
|
+
// OIDC id_token per OpenID Connect Core §3.1.3.3: emit when `openid` scope
|
|
580
|
+
// was granted. Contains identity claims (sub, iss, aud, exp, iat, azp),
|
|
581
|
+
// signed with the same key/alg as the access token.
|
|
582
|
+
const scopes = args.scope.split(' ').filter(Boolean);
|
|
583
|
+
const response = {
|
|
584
|
+
access_token: accessToken,
|
|
585
|
+
token_type: 'Bearer',
|
|
586
|
+
expires_in: deps.config.accessTokenTtlSeconds,
|
|
587
|
+
refresh_token: refreshToken,
|
|
588
|
+
scope: args.scope,
|
|
589
|
+
};
|
|
590
|
+
if (scopes.includes('openid')) {
|
|
591
|
+
response.id_token = deps.jwtService.generateIdToken({
|
|
592
|
+
sub: args.userId,
|
|
593
|
+
tenantId: deps.tenant.id,
|
|
594
|
+
clientId: args.clientId,
|
|
595
|
+
expiresInSeconds: deps.config.accessTokenTtlSeconds,
|
|
596
|
+
nonce: args.nonce,
|
|
597
|
+
now,
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
return jsonResponse(200, response);
|
|
601
|
+
}
|
|
602
|
+
// ============================================================================
|
|
603
|
+
// /register (RFC 7591 DCR)
|
|
604
|
+
// ============================================================================
|
|
605
|
+
export async function handleRegister(req, deps) {
|
|
606
|
+
const res = await handleRegisterImpl(req, deps);
|
|
607
|
+
observeAuth('register', res);
|
|
608
|
+
return res;
|
|
609
|
+
}
|
|
610
|
+
async function handleRegisterImpl(req, deps) {
|
|
611
|
+
if (req.method !== 'POST') {
|
|
612
|
+
return errorResponse(405, 'invalid_request', 'method must be POST');
|
|
613
|
+
}
|
|
614
|
+
let body;
|
|
615
|
+
try {
|
|
616
|
+
body = JSON.parse(req.body ?? '{}');
|
|
617
|
+
}
|
|
618
|
+
catch {
|
|
619
|
+
return errorResponse(400, 'invalid_client_metadata', 'body must be valid JSON');
|
|
620
|
+
}
|
|
621
|
+
if (!body.redirect_uris ||
|
|
622
|
+
!Array.isArray(body.redirect_uris) ||
|
|
623
|
+
body.redirect_uris.length === 0) {
|
|
624
|
+
return errorResponse(400, 'invalid_redirect_uri', 'redirect_uris must be a non-empty array');
|
|
625
|
+
}
|
|
626
|
+
for (const uri of body.redirect_uris) {
|
|
627
|
+
if (typeof uri !== 'string' || !/^https?:\/\//.test(uri)) {
|
|
628
|
+
return errorResponse(400, 'invalid_redirect_uri', `redirect_uri '${uri}' must be an http(s) URL`);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
const clientName = typeof body.client_name === 'string' ? body.client_name : 'Unnamed Client';
|
|
632
|
+
const grantTypes = Array.isArray(body.grant_types) && body.grant_types.length > 0
|
|
633
|
+
? body.grant_types.filter((g) => typeof g === 'string')
|
|
634
|
+
: ['authorization_code', 'refresh_token'];
|
|
635
|
+
const responseTypes = Array.isArray(body.response_types) && body.response_types.length > 0
|
|
636
|
+
? body.response_types.filter((r) => typeof r === 'string')
|
|
637
|
+
: ['code'];
|
|
638
|
+
const tokenEndpointAuthMethod = typeof body.token_endpoint_auth_method === 'string'
|
|
639
|
+
? body.token_endpoint_auth_method
|
|
640
|
+
: 'client_secret_basic';
|
|
641
|
+
const isPublic = tokenEndpointAuthMethod === 'none';
|
|
642
|
+
const clientId = generateSecureToken(32);
|
|
643
|
+
const clientSecret = isPublic ? undefined : generateSecureToken(32);
|
|
644
|
+
const now = (deps.now ?? (() => new Date()))();
|
|
645
|
+
const record = {
|
|
646
|
+
clientId,
|
|
647
|
+
clientSecretHash: clientSecret ? hashClientSecret(clientSecret) : undefined,
|
|
648
|
+
clientName,
|
|
649
|
+
redirectUris: body.redirect_uris,
|
|
650
|
+
grantTypes,
|
|
651
|
+
responseTypes,
|
|
652
|
+
scope: typeof body.scope === 'string' && body.scope
|
|
653
|
+
? body.scope
|
|
654
|
+
: deps.config.defaultScopes.join(' '),
|
|
655
|
+
contacts: Array.isArray(body.contacts)
|
|
656
|
+
? body.contacts.filter((c) => typeof c === 'string')
|
|
657
|
+
: undefined,
|
|
658
|
+
logoUri: typeof body.logo_uri === 'string' ? body.logo_uri : undefined,
|
|
659
|
+
tosUri: typeof body.tos_uri === 'string' ? body.tos_uri : undefined,
|
|
660
|
+
policyUri: typeof body.policy_uri === 'string' ? body.policy_uri : undefined,
|
|
661
|
+
isPublic,
|
|
662
|
+
createdAt: now,
|
|
663
|
+
lastUsedAt: now,
|
|
664
|
+
registrationContext: {
|
|
665
|
+
userAgent: firstHeaderValue(req.headers['user-agent']),
|
|
666
|
+
ipAddress: firstHeaderValue(req.headers['x-forwarded-for'])?.split(',')[0]?.trim(),
|
|
667
|
+
},
|
|
668
|
+
};
|
|
669
|
+
await deps.clientRegistry.save(record);
|
|
670
|
+
deps.log?.('warn', 'dcr_client_registered', {
|
|
671
|
+
client_id: clientId,
|
|
672
|
+
client_name: clientName,
|
|
673
|
+
user_agent: record.registrationContext?.userAgent,
|
|
674
|
+
ip: record.registrationContext?.ipAddress,
|
|
675
|
+
// warn-level so operators see the deprecation signal in logs
|
|
676
|
+
hint: 'CIMD is preferred over DCR — see docs/internals/oauth-authorization-server.md',
|
|
677
|
+
});
|
|
678
|
+
return jsonResponse(201, {
|
|
679
|
+
client_id: clientId,
|
|
680
|
+
...(clientSecret ? { client_secret: clientSecret } : {}),
|
|
681
|
+
client_id_issued_at: Math.floor(now.getTime() / 1000),
|
|
682
|
+
...(clientSecret ? { client_secret_expires_at: 0 } : {}), // 0 = never expires per RFC 7591
|
|
683
|
+
client_name: clientName,
|
|
684
|
+
redirect_uris: record.redirectUris,
|
|
685
|
+
grant_types: record.grantTypes,
|
|
686
|
+
response_types: record.responseTypes,
|
|
687
|
+
scope: record.scope,
|
|
688
|
+
token_endpoint_auth_method: tokenEndpointAuthMethod,
|
|
689
|
+
...(record.contacts ? { contacts: record.contacts } : {}),
|
|
690
|
+
...(record.logoUri ? { logo_uri: record.logoUri } : {}),
|
|
691
|
+
...(record.tosUri ? { tos_uri: record.tosUri } : {}),
|
|
692
|
+
...(record.policyUri ? { policy_uri: record.policyUri } : {}),
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
// ============================================================================
|
|
696
|
+
// /revoke (RFC 7009)
|
|
697
|
+
// ============================================================================
|
|
698
|
+
/**
|
|
699
|
+
* Token revocation endpoint per RFC 7009.
|
|
700
|
+
*
|
|
701
|
+
* Accepts `token` + `token_type_hint` (access_token|refresh_token).
|
|
702
|
+
* Always returns 200 even if the token didn't exist (spec §2.2 — prevents
|
|
703
|
+
* token scanning). Access tokens are JWTs so we can't actively revoke them
|
|
704
|
+
* without a denylist; we revoke the refresh token and rely on the 15-min
|
|
705
|
+
* access-token TTL for cleanup.
|
|
706
|
+
*/
|
|
707
|
+
export async function handleRevoke(req, deps) {
|
|
708
|
+
const res = await handleRevokeImpl(req, deps);
|
|
709
|
+
observeAuth('revoke', res);
|
|
710
|
+
return res;
|
|
711
|
+
}
|
|
712
|
+
async function handleRevokeImpl(req, deps) {
|
|
713
|
+
if (req.method !== 'POST') {
|
|
714
|
+
return errorResponse(405, 'invalid_request', 'method must be POST');
|
|
715
|
+
}
|
|
716
|
+
const form = parseFormBody(req.body ?? '');
|
|
717
|
+
const token = form.get('token');
|
|
718
|
+
const hint = form.get('token_type_hint');
|
|
719
|
+
if (!token) {
|
|
720
|
+
return errorResponse(400, 'invalid_request', 'token is required');
|
|
721
|
+
}
|
|
722
|
+
// Authenticate the caller if possible; RFC 7009 §2.1 says the AS must
|
|
723
|
+
// authenticate confidential clients before revocation. Public clients
|
|
724
|
+
// skip auth.
|
|
725
|
+
const authedClient = await authenticateTokenClient(req, form, deps);
|
|
726
|
+
// RFC 7009: try the hinted type first, fall back to the other.
|
|
727
|
+
let revoked = false;
|
|
728
|
+
if (hint === 'access_token') {
|
|
729
|
+
// Access tokens are self-contained JWTs; we'd need a denylist store.
|
|
730
|
+
// For now, we only fully revoke refresh tokens.
|
|
731
|
+
revoked = false;
|
|
732
|
+
}
|
|
733
|
+
else {
|
|
734
|
+
const existing = await deps.refreshTokenStore.find(token);
|
|
735
|
+
if (existing) {
|
|
736
|
+
if (authedClient && authedClient.clientId !== existing.clientId) {
|
|
737
|
+
return errorResponse(400, 'invalid_client', 'token does not belong to authenticated client');
|
|
738
|
+
}
|
|
739
|
+
revoked = await deps.refreshTokenStore.revoke(token);
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
deps.log?.(revoked ? 'info' : 'info', 'token_revoke', { revoked, hint: hint ?? undefined });
|
|
743
|
+
// RFC 7009 §2.2: always return 200, no body
|
|
744
|
+
return { status: 200, headers: { 'Cache-Control': 'no-store' }, body: '' };
|
|
745
|
+
}
|
|
746
|
+
// ============================================================================
|
|
747
|
+
// /introspect (RFC 7662)
|
|
748
|
+
// ============================================================================
|
|
749
|
+
/**
|
|
750
|
+
* Token introspection endpoint per RFC 7662.
|
|
751
|
+
*
|
|
752
|
+
* Accepts `token` and returns metadata: active (boolean), scope, client_id,
|
|
753
|
+
* sub, exp, iat. Returns `{active: false}` for unknown/expired/revoked
|
|
754
|
+
* tokens without leaking why.
|
|
755
|
+
*
|
|
756
|
+
* Caller must be an authenticated confidential client (§2.1 — "protected
|
|
757
|
+
* resource"); this prevents arbitrary callers from probing token validity.
|
|
758
|
+
*/
|
|
759
|
+
export async function handleIntrospect(req, deps) {
|
|
760
|
+
const res = await handleIntrospectImpl(req, deps);
|
|
761
|
+
observeAuth('introspect', res);
|
|
762
|
+
return res;
|
|
763
|
+
}
|
|
764
|
+
async function handleIntrospectImpl(req, deps) {
|
|
765
|
+
if (req.method !== 'POST') {
|
|
766
|
+
return errorResponse(405, 'invalid_request', 'method must be POST');
|
|
767
|
+
}
|
|
768
|
+
const form = parseFormBody(req.body ?? '');
|
|
769
|
+
const token = form.get('token');
|
|
770
|
+
const hint = form.get('token_type_hint');
|
|
771
|
+
if (!token) {
|
|
772
|
+
return errorResponse(400, 'invalid_request', 'token is required');
|
|
773
|
+
}
|
|
774
|
+
// RFC 7662 §2.1: only authenticated protected-resource callers may introspect
|
|
775
|
+
const authedClient = await authenticateTokenClient(req, form, deps);
|
|
776
|
+
if (!authedClient) {
|
|
777
|
+
return errorResponse(401, 'invalid_client', 'introspection requires authenticated client');
|
|
778
|
+
}
|
|
779
|
+
// Try refresh-token lookup first if hinted, then JWT decode
|
|
780
|
+
if (hint === 'refresh_token' || !hint) {
|
|
781
|
+
const rt = await deps.refreshTokenStore.find(token);
|
|
782
|
+
if (rt) {
|
|
783
|
+
return jsonResponse(200, {
|
|
784
|
+
active: true,
|
|
785
|
+
scope: rt.scope,
|
|
786
|
+
client_id: rt.clientId,
|
|
787
|
+
sub: rt.userId,
|
|
788
|
+
token_type: 'refresh_token',
|
|
789
|
+
exp: Math.floor(rt.expiresAt.getTime() / 1000),
|
|
790
|
+
iat: Math.floor(rt.createdAt.getTime() / 1000),
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
// JWT access-token path: decode + verify
|
|
795
|
+
const decoded = deps.jwtService.verifySessionToken(token);
|
|
796
|
+
if (decoded) {
|
|
797
|
+
return jsonResponse(200, {
|
|
798
|
+
active: true,
|
|
799
|
+
scope: decoded.scope,
|
|
800
|
+
client_id: decoded.client_id,
|
|
801
|
+
sub: decoded.sub,
|
|
802
|
+
token_type: 'Bearer',
|
|
803
|
+
exp: decoded.exp,
|
|
804
|
+
iat: decoded.iat,
|
|
805
|
+
iss: decoded.iss,
|
|
806
|
+
aud: decoded.aud,
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
return jsonResponse(200, { active: false });
|
|
810
|
+
}
|
|
811
|
+
async function resolveClient(clientId, deps) {
|
|
812
|
+
if (clientId.startsWith('https://')) {
|
|
813
|
+
const result = await resolveClientMetadata(clientId, {
|
|
814
|
+
cache: deps.cimdCache,
|
|
815
|
+
allowedDomains: deps.tenant.settings.allowedClientDomains,
|
|
816
|
+
});
|
|
817
|
+
recordCimdFetch({
|
|
818
|
+
status: result.ok ? 'ok' : 'error',
|
|
819
|
+
cached: result.fromCache === true,
|
|
820
|
+
errorCode: result.error,
|
|
821
|
+
});
|
|
822
|
+
if (!result.ok || !result.metadata) {
|
|
823
|
+
deps.log?.('warn', 'cimd_resolution_failed', {
|
|
824
|
+
client_id: clientId,
|
|
825
|
+
error: result.error,
|
|
826
|
+
});
|
|
827
|
+
return null;
|
|
828
|
+
}
|
|
829
|
+
return {
|
|
830
|
+
clientId,
|
|
831
|
+
redirectUris: result.metadata.redirect_uris,
|
|
832
|
+
clientName: result.metadata.client_name ?? clientId,
|
|
833
|
+
cimdUrl: clientId,
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
const registered = await deps.clientRegistry.find(clientId);
|
|
837
|
+
if (!registered)
|
|
838
|
+
return null;
|
|
839
|
+
return {
|
|
840
|
+
clientId,
|
|
841
|
+
redirectUris: registered.redirectUris,
|
|
842
|
+
clientName: registered.clientName,
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
async function authenticateTokenClient(req, form, deps) {
|
|
846
|
+
// Try HTTP Basic first
|
|
847
|
+
const authHeader = firstHeaderValue(req.headers.authorization);
|
|
848
|
+
if (authHeader?.startsWith('Basic ')) {
|
|
849
|
+
try {
|
|
850
|
+
const decoded = Buffer.from(authHeader.slice(6), 'base64').toString('utf8');
|
|
851
|
+
const idx = decoded.indexOf(':');
|
|
852
|
+
if (idx < 0)
|
|
853
|
+
return null;
|
|
854
|
+
const clientId = decoded.slice(0, idx);
|
|
855
|
+
const clientSecret = decoded.slice(idx + 1);
|
|
856
|
+
return await verifyClient(clientId, clientSecret, deps);
|
|
857
|
+
}
|
|
858
|
+
catch {
|
|
859
|
+
return null;
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
// Fallback: client_secret_post
|
|
863
|
+
const clientId = form.get('client_id');
|
|
864
|
+
const clientSecret = form.get('client_secret');
|
|
865
|
+
if (clientId && clientSecret) {
|
|
866
|
+
return await verifyClient(clientId, clientSecret, deps);
|
|
867
|
+
}
|
|
868
|
+
return null;
|
|
869
|
+
}
|
|
870
|
+
async function verifyClient(clientId, clientSecret, deps) {
|
|
871
|
+
const registered = await deps.clientRegistry.find(clientId);
|
|
872
|
+
if (!registered || !registered.clientSecretHash)
|
|
873
|
+
return null;
|
|
874
|
+
if (!verifyClientSecret(clientSecret, registered.clientSecretHash))
|
|
875
|
+
return null;
|
|
876
|
+
return { clientId, registered };
|
|
877
|
+
}
|
|
878
|
+
// ============================================================================
|
|
879
|
+
// Form / header helpers
|
|
880
|
+
// ============================================================================
|
|
881
|
+
function parseFormBody(body) {
|
|
882
|
+
return new URLSearchParams(body);
|
|
883
|
+
}
|
|
884
|
+
function firstHeaderValue(raw) {
|
|
885
|
+
if (raw === undefined)
|
|
886
|
+
return undefined;
|
|
887
|
+
if (Array.isArray(raw))
|
|
888
|
+
return raw[0];
|
|
889
|
+
return raw;
|
|
890
|
+
}
|
|
891
|
+
// ============================================================================
|
|
892
|
+
// Consent screen HTML (minimal, inlined styles)
|
|
893
|
+
// ============================================================================
|
|
894
|
+
function renderConsentPage(pending, client, tenant) {
|
|
895
|
+
const clientName = client?.clientName ?? pending.clientId;
|
|
896
|
+
const cimdBadge = client?.cimdUrl
|
|
897
|
+
? `<span class="cimd">hosted metadata: ${escapeHtml(client.cimdUrl)}</span>`
|
|
898
|
+
: '';
|
|
899
|
+
const scopes = pending.scope.split(' ').filter(Boolean);
|
|
900
|
+
const scopeList = scopes.length
|
|
901
|
+
? `<ul class="scopes">${scopes.map((s) => `<li>${escapeHtml(s)}</li>`).join('')}</ul>`
|
|
902
|
+
: '<p class="muted">No specific scopes requested.</p>';
|
|
903
|
+
return `<!DOCTYPE html>
|
|
904
|
+
<html lang="en">
|
|
905
|
+
<head>
|
|
906
|
+
<meta charset="utf-8">
|
|
907
|
+
<title>Authorize ${escapeHtml(clientName)}</title>
|
|
908
|
+
<style>
|
|
909
|
+
body { font-family: -apple-system, system-ui, sans-serif; max-width: 480px; margin: 60px auto; padding: 24px; color: #1a1a1a; }
|
|
910
|
+
h1 { font-size: 20px; margin: 0 0 8px; }
|
|
911
|
+
.muted { color: #666; }
|
|
912
|
+
.cimd { display: inline-block; font-size: 12px; color: #555; background: #f4f4f4; padding: 2px 8px; border-radius: 4px; margin-top: 8px; }
|
|
913
|
+
.scopes { background: #fafafa; border: 1px solid #eee; border-radius: 8px; padding: 12px 24px; list-style: disc; }
|
|
914
|
+
.scopes li { margin: 6px 0; font-family: monospace; font-size: 13px; }
|
|
915
|
+
form { margin-top: 24px; display: flex; gap: 12px; }
|
|
916
|
+
button { flex: 1; padding: 12px 16px; font-size: 15px; border-radius: 8px; border: 1px solid #ccc; background: #fff; cursor: pointer; }
|
|
917
|
+
button.approve { background: #0066cc; color: #fff; border-color: #0066cc; }
|
|
918
|
+
button:hover { filter: brightness(0.95); }
|
|
919
|
+
</style>
|
|
920
|
+
</head>
|
|
921
|
+
<body>
|
|
922
|
+
<h1>${escapeHtml(clientName)} wants to access ${escapeHtml(tenant.name)}</h1>
|
|
923
|
+
<p class="muted">The application is requesting permission to act on your behalf.</p>
|
|
924
|
+
${cimdBadge}
|
|
925
|
+
<p><strong>Requested scopes:</strong></p>
|
|
926
|
+
${scopeList}
|
|
927
|
+
<form method="POST" action="${escapeHtml(pendingConsentUrl(pending.id))}">
|
|
928
|
+
<input type="hidden" name="req" value="${escapeHtml(pending.id)}">
|
|
929
|
+
<button type="submit" name="decision" value="deny">Deny</button>
|
|
930
|
+
<button type="submit" name="decision" value="approve" class="approve">Approve</button>
|
|
931
|
+
</form>
|
|
932
|
+
</body>
|
|
933
|
+
</html>`;
|
|
934
|
+
}
|
|
935
|
+
function pendingConsentUrl(id) {
|
|
936
|
+
// Relative to current host — the HTML template posts to the same /consent path.
|
|
937
|
+
return `?req=${encodeURIComponent(id)}`;
|
|
938
|
+
}
|
|
939
|
+
function escapeHtml(input) {
|
|
940
|
+
return input
|
|
941
|
+
.replace(/&/g, '&')
|
|
942
|
+
.replace(/</g, '<')
|
|
943
|
+
.replace(/>/g, '>')
|
|
944
|
+
.replace(/"/g, '"')
|
|
945
|
+
.replace(/'/g, ''');
|
|
946
|
+
}
|
|
947
|
+
// ============================================================================
|
|
948
|
+
// Metrics helpers
|
|
949
|
+
// ============================================================================
|
|
950
|
+
/**
|
|
951
|
+
* Record an endpoint event from its `AuthResponse`. Extracts the OAuth
|
|
952
|
+
* `error` code from either a JSON body (RFC 6749 §5.2 token-error format)
|
|
953
|
+
* or a `Location` redirect (§4.1.2.1 authorize-error format).
|
|
954
|
+
*/
|
|
955
|
+
function observeAuth(endpoint, res, extra = {}) {
|
|
956
|
+
const status = res.status >= 400 ? 'error' : 'ok';
|
|
957
|
+
let errorCode;
|
|
958
|
+
// Redirects with ?error=... are authorize-endpoint errors even though status is 302
|
|
959
|
+
if (res.headers.Location) {
|
|
960
|
+
try {
|
|
961
|
+
const loc = new URL(res.headers.Location);
|
|
962
|
+
const e = loc.searchParams.get('error');
|
|
963
|
+
if (e)
|
|
964
|
+
errorCode = e;
|
|
965
|
+
}
|
|
966
|
+
catch {
|
|
967
|
+
// malformed Location; skip
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
if (!errorCode && status === 'error') {
|
|
971
|
+
try {
|
|
972
|
+
const body = JSON.parse(res.body);
|
|
973
|
+
if (body.error)
|
|
974
|
+
errorCode = body.error;
|
|
975
|
+
}
|
|
976
|
+
catch {
|
|
977
|
+
// non-JSON error body; skip
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
recordAuthEvent({
|
|
981
|
+
endpoint,
|
|
982
|
+
status: errorCode ? 'error' : status,
|
|
983
|
+
errorCode,
|
|
984
|
+
...extra,
|
|
985
|
+
});
|
|
986
|
+
}
|
|
987
|
+
function detectClientType(url) {
|
|
988
|
+
try {
|
|
989
|
+
const parsed = new URL(url);
|
|
990
|
+
const clientId = parsed.searchParams.get('client_id');
|
|
991
|
+
if (!clientId)
|
|
992
|
+
return 'unknown';
|
|
993
|
+
return clientId.startsWith('https://') ? 'cimd' : 'dcr';
|
|
994
|
+
}
|
|
995
|
+
catch {
|
|
996
|
+
return 'unknown';
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
function normalizeGrantType(raw) {
|
|
1000
|
+
if (raw === 'authorization_code' || raw === 'refresh_token' || raw === 'client_credentials') {
|
|
1001
|
+
return raw;
|
|
1002
|
+
}
|
|
1003
|
+
return 'unknown';
|
|
1004
|
+
}
|
|
1005
|
+
//# sourceMappingURL=endpoints.js.map
|