@12-apps/mcp 1.18.0 → 1.20.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/ADOPTING.md +248 -0
- package/README.md +39 -12
- package/package.json +28 -9
- package/prisma/mcp.prisma +108 -0
- package/prisma/migrations/20260812150000_add_mcp_oauth_tables/migration.sql +152 -0
- package/scripts/sync-mcp-schema.mjs +61 -0
- package/src/auth/authorization-server-metadata.ts +27 -8
- package/src/coverage-gate/index.ts +243 -0
- package/src/coverage-gate/route-methods.ts +86 -0
- package/src/generate/index.ts +197 -0
- package/src/hono/index.ts +43 -0
- package/src/index.ts +1 -0
- package/src/oauth/access-token.ts +186 -0
- package/src/oauth/authorization-code.ts +215 -0
- package/src/oauth/authorize.ts +260 -0
- package/src/oauth/clients.ts +158 -0
- package/src/oauth/code-replay.ts +51 -0
- package/src/oauth/config.ts +142 -0
- package/src/oauth/context.ts +253 -0
- package/src/oauth/create-api-mcp-oauth.ts +205 -0
- package/src/oauth/index.ts +117 -0
- package/src/oauth/keys.ts +107 -0
- package/src/oauth/pkce.ts +93 -0
- package/src/oauth/prisma-stores.ts +306 -0
- package/src/oauth/refresh.ts +286 -0
- package/src/oauth/register.ts +282 -0
- package/src/oauth/stores.ts +157 -0
- package/src/oauth/token-grants.ts +326 -0
- package/src/oauth/token-response.ts +154 -0
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import { ACCESS_TOKEN_TTL_SECONDS, signAccessToken } from "./access-token";
|
|
2
|
+
import { AuthorizationCodeError, verifyCode } from "./authorization-code";
|
|
3
|
+
import { providerFromRedirectUris } from "./clients";
|
|
4
|
+
import type { McpConnectionRecording, McpOauthContext } from "./context";
|
|
5
|
+
import { verifyChallenge } from "./pkce";
|
|
6
|
+
import {
|
|
7
|
+
RefreshTokenError,
|
|
8
|
+
getRefreshTokenIdentity,
|
|
9
|
+
issueRefreshToken,
|
|
10
|
+
rotateRefreshToken,
|
|
11
|
+
} from "./refresh";
|
|
12
|
+
import type { McpConnectionStore } from "./stores";
|
|
13
|
+
import {
|
|
14
|
+
authenticateClient,
|
|
15
|
+
readClientCredentials,
|
|
16
|
+
tokenError,
|
|
17
|
+
tokenSuccess,
|
|
18
|
+
type ClientCredentials,
|
|
19
|
+
} from "./token-response";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The two grant handlers of the token endpoint (12-23, ported from future-pay's
|
|
23
|
+
* `lib/mcp/oauth/token-grants.ts`).
|
|
24
|
+
*
|
|
25
|
+
* Security invariants enforced here, unchanged:
|
|
26
|
+
* - **Single-use codes:** the code's `jti` is consumed the moment it is
|
|
27
|
+
* redeemed; a replay of the same code is `invalid_grant`.
|
|
28
|
+
* - **PKCE:** a `code_verifier` that does not S256-match the code's
|
|
29
|
+
* `code_challenge` is `invalid_grant`.
|
|
30
|
+
* - **Client auth:** a public client's `client_id` must equal the code's bound
|
|
31
|
+
* client; a confidential client must present a secret whose SHA-256 matches
|
|
32
|
+
* the stored hash, else `invalid_client` (401).
|
|
33
|
+
* - **Bound `redirect_uri`:** it must equal the one the code was minted with
|
|
34
|
+
* (RFC 6749 §4.1.3).
|
|
35
|
+
* - **Refresh rotation:** client-bound, replay-revoking, narrow-only scope.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/** Throttle default: don't rewrite liveness on every grant. */
|
|
39
|
+
const DEFAULT_ACTIVITY_THROTTLE_MS = 60_000;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Best-effort: record that this user's AI host (OAuth client) is live, so an
|
|
43
|
+
* account page can show "connected via Claude · active 2 min ago". Runs on the
|
|
44
|
+
* token grant, not the per-request hot path; hosts refresh every ~15 min so
|
|
45
|
+
* liveness stays fresh.
|
|
46
|
+
*
|
|
47
|
+
* NEVER lets a failure break token issuance — a recording error is swallowed, and
|
|
48
|
+
* nothing about it is logged, because the only interesting values here are an
|
|
49
|
+
* email and a client id. Skipped when the host resolves no user row (email is the
|
|
50
|
+
* identity) or when no connection recording is configured at all.
|
|
51
|
+
*/
|
|
52
|
+
async function recordHostConnection(
|
|
53
|
+
context: McpOauthContext,
|
|
54
|
+
email: string,
|
|
55
|
+
clientId: string,
|
|
56
|
+
): Promise<void> {
|
|
57
|
+
const recording = context.connections;
|
|
58
|
+
const store = context.stores.connections;
|
|
59
|
+
if (!recording || !store) return;
|
|
60
|
+
try {
|
|
61
|
+
await writeConnectionActivity(context, { recording, store }, email, clientId);
|
|
62
|
+
} catch {
|
|
63
|
+
// Liveness is non-critical — never fail the grant on it. Nothing is logged
|
|
64
|
+
// either: the only values here are an email and a client id.
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The write itself, once the recording ports are known to exist. */
|
|
69
|
+
async function writeConnectionActivity(
|
|
70
|
+
context: McpOauthContext,
|
|
71
|
+
ports: { recording: McpConnectionRecording; store: McpConnectionStore },
|
|
72
|
+
email: string,
|
|
73
|
+
clientId: string,
|
|
74
|
+
): Promise<void> {
|
|
75
|
+
const { recording, store } = ports;
|
|
76
|
+
const [userId, client] = await Promise.all([
|
|
77
|
+
recording.resolveUserId(email),
|
|
78
|
+
context.stores.clients.findByClientId(clientId),
|
|
79
|
+
]);
|
|
80
|
+
// No user row yet: email is the identity the AS binds to, so the grant stands
|
|
81
|
+
// and there is simply nothing to attribute it to.
|
|
82
|
+
if (!userId) return;
|
|
83
|
+
|
|
84
|
+
const throttleMs = recording.activityThrottleMs ?? DEFAULT_ACTIVITY_THROTTLE_MS;
|
|
85
|
+
const lastActiveAt = await store.lastActiveAt(userId, clientId);
|
|
86
|
+
const now = new Date();
|
|
87
|
+
if (lastActiveAt && now.getTime() - lastActiveAt.getTime() < throttleMs) return;
|
|
88
|
+
|
|
89
|
+
await store.recordActivity({
|
|
90
|
+
userId,
|
|
91
|
+
oauthClientId: clientId,
|
|
92
|
+
clientName: client?.clientName ?? null,
|
|
93
|
+
// Attribute to a provider from the client's redirect URIs (claude.ai →
|
|
94
|
+
// claude, chatgpt.com → chatgpt) so an account page lights the right card.
|
|
95
|
+
host: client ? providerFromRedirectUris(client.redirectUris, recording.providerRules) : null,
|
|
96
|
+
at: now,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The presented `authorization_code` grant parameters, once validated present. */
|
|
101
|
+
interface AuthorizationCodeParams {
|
|
102
|
+
code: string;
|
|
103
|
+
redirectUri: string;
|
|
104
|
+
codeVerifier: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Read + presence-check the `authorization_code` form parameters. Returns the three
|
|
109
|
+
* required values, or an `invalid_request` (400) naming the first missing field.
|
|
110
|
+
*/
|
|
111
|
+
function readAuthorizationCodeParams(form: URLSearchParams): AuthorizationCodeParams | Response {
|
|
112
|
+
const code = form.get("code");
|
|
113
|
+
const redirectUri = form.get("redirect_uri");
|
|
114
|
+
const codeVerifier = form.get("code_verifier");
|
|
115
|
+
|
|
116
|
+
if (!code) return tokenError("invalid_request", 400, "missing code");
|
|
117
|
+
if (!redirectUri) return tokenError("invalid_request", 400, "missing redirect_uri");
|
|
118
|
+
if (!codeVerifier) return tokenError("invalid_request", 400, "missing code_verifier");
|
|
119
|
+
|
|
120
|
+
return { code, redirectUri, codeVerifier };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Redeem a presented code, or refuse.
|
|
125
|
+
*
|
|
126
|
+
* The ORDER is the security contract, and it is the order future-pay established:
|
|
127
|
+
* verify the code's signature, authenticate the presenting client against the
|
|
128
|
+
* client the code was bound to, check the bound `redirect_uri`, check PKCE — and
|
|
129
|
+
* only THEN consume the single-use `jti`. Consuming earlier would let a failed
|
|
130
|
+
* attempt (a wrong secret, a mismatched verifier) burn a legitimate code.
|
|
131
|
+
*/
|
|
132
|
+
async function redeemCode(
|
|
133
|
+
context: McpOauthContext,
|
|
134
|
+
params: AuthorizationCodeParams,
|
|
135
|
+
credentials: ClientCredentials,
|
|
136
|
+
origin: string,
|
|
137
|
+
): Promise<Awaited<ReturnType<typeof verifyCode>> | Response> {
|
|
138
|
+
const { code, redirectUri, codeVerifier } = params;
|
|
139
|
+
|
|
140
|
+
// Every code-level failure (expired, tampered, wrong-audience) is invalid_grant.
|
|
141
|
+
let verified;
|
|
142
|
+
try {
|
|
143
|
+
verified = await verifyCode(context.signingKey, code, { origin });
|
|
144
|
+
} catch (error) {
|
|
145
|
+
if (error instanceof AuthorizationCodeError) {
|
|
146
|
+
return tokenError("invalid_grant", 400, "invalid or expired authorization code");
|
|
147
|
+
}
|
|
148
|
+
throw error;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const authError = await authenticateClient(
|
|
152
|
+
context.stores.clients,
|
|
153
|
+
credentials,
|
|
154
|
+
verified.clientId,
|
|
155
|
+
);
|
|
156
|
+
if (authError) return authError;
|
|
157
|
+
|
|
158
|
+
// The redirect_uri MUST match the one the code was bound to (RFC 6749 §4.1.3).
|
|
159
|
+
if (redirectUri !== verified.redirectUri) {
|
|
160
|
+
return tokenError("invalid_grant", 400, "redirect_uri mismatch");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// PKCE: the presented verifier must S256-match the bound challenge.
|
|
164
|
+
if (!(await verifyChallenge(codeVerifier, verified.codeChallenge))) {
|
|
165
|
+
return tokenError("invalid_grant", 400, "PKCE verification failed");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Single-use: consume the code's jti; a replay of the same code fails.
|
|
169
|
+
if (!(await context.codeReplay.consume(verified.jti, Date.now()))) {
|
|
170
|
+
return tokenError("invalid_grant", 400, "authorization code already used");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return verified;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Handle the `authorization_code` grant. */
|
|
177
|
+
async function handleAuthorizationCode(
|
|
178
|
+
context: McpOauthContext,
|
|
179
|
+
form: URLSearchParams,
|
|
180
|
+
credentials: ClientCredentials,
|
|
181
|
+
origin: string,
|
|
182
|
+
): Promise<Response> {
|
|
183
|
+
const params = readAuthorizationCodeParams(form);
|
|
184
|
+
if (params instanceof Response) return params;
|
|
185
|
+
|
|
186
|
+
const verified = await redeemCode(context, params, credentials, origin);
|
|
187
|
+
if (verified instanceof Response) return verified;
|
|
188
|
+
|
|
189
|
+
const scopes = verified.scope.split(/\s+/).filter(Boolean);
|
|
190
|
+
|
|
191
|
+
const accessToken = await signAccessToken(context.signingKey, {
|
|
192
|
+
email: verified.email,
|
|
193
|
+
subject: verified.sub,
|
|
194
|
+
scopes,
|
|
195
|
+
origin,
|
|
196
|
+
resourcePath: context.resourcePath,
|
|
197
|
+
ttlSeconds: context.accessTokenTtlSeconds,
|
|
198
|
+
});
|
|
199
|
+
if (!accessToken) {
|
|
200
|
+
// No signing key configured while the surface is on — refuse rather than fall
|
|
201
|
+
// back to a weaker mode (safe-by-default).
|
|
202
|
+
return tokenError("invalid_request", 400, "token issuance unavailable");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const refresh = await issueRefreshToken(
|
|
206
|
+
{ store: context.stores.refreshTokens, ttlMs: context.refreshTokenTtlMs },
|
|
207
|
+
{
|
|
208
|
+
userEmail: verified.email,
|
|
209
|
+
userSub: verified.sub,
|
|
210
|
+
clientId: verified.clientId,
|
|
211
|
+
scopes,
|
|
212
|
+
},
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
await recordHostConnection(context, verified.email, verified.clientId);
|
|
216
|
+
|
|
217
|
+
return tokenSuccess({
|
|
218
|
+
access_token: accessToken,
|
|
219
|
+
token_type: "Bearer",
|
|
220
|
+
expires_in: context.accessTokenTtlSeconds ?? ACCESS_TOKEN_TTL_SECONDS,
|
|
221
|
+
refresh_token: refresh.refreshToken,
|
|
222
|
+
scope: refresh.scopes.join(" "),
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Handle the `refresh_token` grant. */
|
|
227
|
+
async function handleRefreshToken(
|
|
228
|
+
context: McpOauthContext,
|
|
229
|
+
form: URLSearchParams,
|
|
230
|
+
credentials: ClientCredentials,
|
|
231
|
+
origin: string,
|
|
232
|
+
): Promise<Response> {
|
|
233
|
+
const refreshToken = form.get("refresh_token");
|
|
234
|
+
const requestedScope = form.get("scope");
|
|
235
|
+
|
|
236
|
+
if (!refreshToken) return tokenError("invalid_request", 400, "missing refresh_token");
|
|
237
|
+
|
|
238
|
+
// Authenticate the presenting client (public: client_id present; confidential:
|
|
239
|
+
// secret checked). `authenticateClient` rejects a missing client_id, so on
|
|
240
|
+
// success `credentials.clientId` is non-null and is the identity the rotation is
|
|
241
|
+
// bound to below.
|
|
242
|
+
const authError = await authenticateClient(context.stores.clients, credentials);
|
|
243
|
+
if (authError) return authError;
|
|
244
|
+
const clientId = credentials.clientId as string;
|
|
245
|
+
|
|
246
|
+
const newScopes = requestedScope ? requestedScope.split(/\s+/).filter(Boolean) : undefined;
|
|
247
|
+
const refreshContext = {
|
|
248
|
+
store: context.stores.refreshTokens,
|
|
249
|
+
ttlMs: context.refreshTokenTtlMs,
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
// Rotation enforces client binding (the token's stored clientId must equal the
|
|
253
|
+
// authenticated one, else invalid_grant — OAuth 2.1 §4.3), plus
|
|
254
|
+
// replay-revocation and scope-narrowing.
|
|
255
|
+
let rotated;
|
|
256
|
+
try {
|
|
257
|
+
rotated = await rotateRefreshToken(refreshContext, refreshToken, clientId, newScopes);
|
|
258
|
+
} catch (error) {
|
|
259
|
+
if (error instanceof RefreshTokenError) {
|
|
260
|
+
return tokenError(error.code, 400, error.message);
|
|
261
|
+
}
|
|
262
|
+
throw error;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// The refresh token binds the user's email AND original OAuth `sub`; recover both
|
|
266
|
+
// so the successor access token carries the SAME stable `sub` as the initial
|
|
267
|
+
// token (RFC 6749 §5.1 / OIDC §2), not the email.
|
|
268
|
+
const identity = await getRefreshTokenIdentity(refreshContext, rotated.refreshToken);
|
|
269
|
+
if (!identity) return tokenError("invalid_grant", 400, "refresh token binding not found");
|
|
270
|
+
|
|
271
|
+
const accessToken = await signAccessToken(context.signingKey, {
|
|
272
|
+
email: identity.userEmail,
|
|
273
|
+
subject: identity.userSub,
|
|
274
|
+
scopes: rotated.scopes,
|
|
275
|
+
origin,
|
|
276
|
+
resourcePath: context.resourcePath,
|
|
277
|
+
ttlSeconds: context.accessTokenTtlSeconds,
|
|
278
|
+
});
|
|
279
|
+
if (!accessToken) return tokenError("invalid_request", 400, "token issuance unavailable");
|
|
280
|
+
|
|
281
|
+
await recordHostConnection(context, identity.userEmail, clientId);
|
|
282
|
+
|
|
283
|
+
return tokenSuccess({
|
|
284
|
+
access_token: accessToken,
|
|
285
|
+
token_type: "Bearer",
|
|
286
|
+
expires_in: context.accessTokenTtlSeconds ?? ACCESS_TOKEN_TTL_SECONDS,
|
|
287
|
+
refresh_token: rotated.refreshToken,
|
|
288
|
+
scope: rotated.scopes.join(" "),
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* `POST <token>` — the endpoint itself: gate → parse form → dispatch by
|
|
294
|
+
* `grant_type`. Thin on purpose; the flows above are where the invariants live.
|
|
295
|
+
*/
|
|
296
|
+
export async function tokenEndpoint(
|
|
297
|
+
context: McpOauthContext,
|
|
298
|
+
request: Request,
|
|
299
|
+
): Promise<Response> {
|
|
300
|
+
const origin = context.originOf(request);
|
|
301
|
+
|
|
302
|
+
let form: URLSearchParams;
|
|
303
|
+
try {
|
|
304
|
+
form = new URLSearchParams(await request.text());
|
|
305
|
+
} catch {
|
|
306
|
+
return tokenError("invalid_request", 400, "malformed request body");
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const grantType = form.get("grant_type");
|
|
310
|
+
if (!grantType) return tokenError("invalid_request", 400, "missing grant_type");
|
|
311
|
+
|
|
312
|
+
const credentials = readClientCredentials(request, form);
|
|
313
|
+
|
|
314
|
+
switch (grantType) {
|
|
315
|
+
case "authorization_code":
|
|
316
|
+
return handleAuthorizationCode(context, form, credentials, origin);
|
|
317
|
+
case "refresh_token":
|
|
318
|
+
return handleRefreshToken(context, form, credentials, origin);
|
|
319
|
+
default:
|
|
320
|
+
return tokenError(
|
|
321
|
+
"unsupported_grant_type",
|
|
322
|
+
400,
|
|
323
|
+
`grant_type '${grantType}' is not supported`,
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import type { OAuthClientStore } from "./stores";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The token endpoint's wire helpers: the RFC 6749 §5.1/§5.2 bodies and client
|
|
7
|
+
* authentication (12-23, split out of the grant handlers so each file stays under
|
|
8
|
+
* the size gate — the same split future-pay made).
|
|
9
|
+
*
|
|
10
|
+
* These bodies are NOT the house `{ data }` envelope, deliberately: they are read
|
|
11
|
+
* by OAuth clients that expect the RFC shapes at the top level, and `Cache-Control:
|
|
12
|
+
* no-store` is required on every one of them because they carry credentials.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** OAuth 2.1 / RFC 6749 §5.2 error codes the token endpoint can emit. */
|
|
16
|
+
type TokenErrorCode =
|
|
17
|
+
| "invalid_request"
|
|
18
|
+
| "invalid_client"
|
|
19
|
+
| "invalid_grant"
|
|
20
|
+
| "invalid_scope"
|
|
21
|
+
| "unsupported_grant_type";
|
|
22
|
+
|
|
23
|
+
/** The RFC 6749 §5.1 successful token response. */
|
|
24
|
+
interface TokenSuccessResponse {
|
|
25
|
+
access_token: string;
|
|
26
|
+
token_type: "Bearer";
|
|
27
|
+
expires_in: number;
|
|
28
|
+
refresh_token: string;
|
|
29
|
+
scope: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const JSON_HEADERS = {
|
|
33
|
+
"content-type": "application/json; charset=utf-8",
|
|
34
|
+
"cache-control": "no-store",
|
|
35
|
+
} as const;
|
|
36
|
+
|
|
37
|
+
/** A JSON error response in the RFC 6749 §5.2 shape. */
|
|
38
|
+
export function tokenError(
|
|
39
|
+
error: TokenErrorCode,
|
|
40
|
+
status: number,
|
|
41
|
+
description?: string,
|
|
42
|
+
headers: Record<string, string> = {},
|
|
43
|
+
): Response {
|
|
44
|
+
const body: { error: TokenErrorCode; error_description?: string } = { error };
|
|
45
|
+
if (description) body.error_description = description;
|
|
46
|
+
return new Response(JSON.stringify(body), {
|
|
47
|
+
status,
|
|
48
|
+
headers: { ...JSON_HEADERS, ...headers },
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A JSON success response with `Cache-Control: no-store` (RFC 6749 §5.1). */
|
|
53
|
+
export function tokenSuccess(payload: TokenSuccessResponse): Response {
|
|
54
|
+
return new Response(JSON.stringify(payload), { status: 200, headers: { ...JSON_HEADERS } });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Constant-time equality of two SHA-256 hex digests. */
|
|
58
|
+
function hashesEqual(a: string, b: string): boolean {
|
|
59
|
+
const bufA = Buffer.from(a, "hex");
|
|
60
|
+
const bufB = Buffer.from(b, "hex");
|
|
61
|
+
if (bufA.length !== bufB.length || bufA.length === 0) return false;
|
|
62
|
+
return timingSafeEqual(bufA, bufB);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** SHA-256 hex digest — matches the at-rest client-secret hashing convention. */
|
|
66
|
+
function sha256Hex(value: string): string {
|
|
67
|
+
return createHash("sha256").update(value).digest("hex");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Client credentials extracted from HTTP Basic auth or the form body. */
|
|
71
|
+
export interface ClientCredentials {
|
|
72
|
+
clientId: string | null;
|
|
73
|
+
clientSecret: string | null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Resolve the presented client credentials. HTTP Basic (`client_secret_basic`)
|
|
78
|
+
* takes precedence over the form-body `client_id` per RFC 6749 §2.3.1; a malformed
|
|
79
|
+
* Basic header is treated as absent (the form body still applies).
|
|
80
|
+
*/
|
|
81
|
+
export function readClientCredentials(
|
|
82
|
+
request: Request,
|
|
83
|
+
form: URLSearchParams,
|
|
84
|
+
): ClientCredentials {
|
|
85
|
+
const authorization = request.headers.get("authorization");
|
|
86
|
+
if (authorization && authorization.startsWith("Basic ")) {
|
|
87
|
+
const decoded = Buffer.from(authorization.slice(6), "base64").toString("utf8");
|
|
88
|
+
const separator = decoded.indexOf(":");
|
|
89
|
+
if (separator !== -1) {
|
|
90
|
+
return {
|
|
91
|
+
clientId: decoded.slice(0, separator),
|
|
92
|
+
clientSecret: decoded.slice(separator + 1),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return { clientId: form.get("client_id"), clientSecret: form.get("client_secret") };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The 401 challenge issued on a client-authentication failure. */
|
|
100
|
+
const CLIENT_AUTH_CHALLENGE = { "www-authenticate": 'Basic realm="oauth-token"' };
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Authenticate the client that presents the request. A public client is identified
|
|
104
|
+
* by `client_id` alone (which must equal `expectedClientId` when provided). A
|
|
105
|
+
* confidential client (`client_secret_basic`) MUST present a secret whose SHA-256
|
|
106
|
+
* matches the stored hash. Returns `null` on success, or a 401 `invalid_client`
|
|
107
|
+
* response on failure — never a body that says which half was wrong.
|
|
108
|
+
*/
|
|
109
|
+
export async function authenticateClient(
|
|
110
|
+
clients: OAuthClientStore,
|
|
111
|
+
credentials: ClientCredentials,
|
|
112
|
+
expectedClientId?: string,
|
|
113
|
+
): Promise<Response | null> {
|
|
114
|
+
const clientId = credentials.clientId;
|
|
115
|
+
if (!clientId) {
|
|
116
|
+
return tokenError("invalid_client", 401, "missing client_id", CLIENT_AUTH_CHALLENGE);
|
|
117
|
+
}
|
|
118
|
+
if (expectedClientId && clientId !== expectedClientId) {
|
|
119
|
+
// The presenting client does not match the client the code was issued to.
|
|
120
|
+
return tokenError(
|
|
121
|
+
"invalid_client",
|
|
122
|
+
401,
|
|
123
|
+
"client_id does not match the grant",
|
|
124
|
+
CLIENT_AUTH_CHALLENGE,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const client = await clients.findByClientId(clientId);
|
|
129
|
+
if (!client) {
|
|
130
|
+
return tokenError("invalid_client", 401, "unknown client", CLIENT_AUTH_CHALLENGE);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (client.tokenEndpointAuthMethod === "client_secret_basic") {
|
|
134
|
+
const secret = credentials.clientSecret;
|
|
135
|
+
if (!secret || !client.clientSecretHash) {
|
|
136
|
+
return tokenError(
|
|
137
|
+
"invalid_client",
|
|
138
|
+
401,
|
|
139
|
+
"client authentication required",
|
|
140
|
+
CLIENT_AUTH_CHALLENGE,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
if (!hashesEqual(sha256Hex(secret), client.clientSecretHash)) {
|
|
144
|
+
return tokenError(
|
|
145
|
+
"invalid_client",
|
|
146
|
+
401,
|
|
147
|
+
"invalid client credentials",
|
|
148
|
+
CLIENT_AUTH_CHALLENGE,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return null;
|
|
154
|
+
}
|