@codefox-inc/oauth-provider 0.3.2 → 0.4.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 +40 -14
- package/dist/client/index.d.ts +4 -0
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +1 -0
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/component.d.ts +9 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/clientManagement.d.ts +1 -0
- package/dist/component/clientManagement.d.ts.map +1 -1
- package/dist/component/clientManagement.js +24 -0
- package/dist/component/clientManagement.js.map +1 -1
- package/dist/component/handlers.d.ts +16 -0
- package/dist/component/handlers.d.ts.map +1 -1
- package/dist/component/handlers.js +275 -28
- package/dist/component/handlers.js.map +1 -1
- package/dist/component/mutations.d.ts +9 -0
- package/dist/component/mutations.d.ts.map +1 -1
- package/dist/component/mutations.js +112 -40
- package/dist/component/mutations.js.map +1 -1
- package/dist/component/queries.d.ts +8 -0
- package/dist/component/queries.d.ts.map +1 -1
- package/dist/component/schema.d.ts +18 -4
- package/dist/component/schema.d.ts.map +1 -1
- package/dist/component/schema.js +7 -0
- package/dist/component/schema.js.map +1 -1
- package/dist/lib/oauth.d.ts.map +1 -1
- package/dist/lib/oauth.js +5 -2
- package/dist/lib/oauth.js.map +1 -1
- package/package.json +39 -39
- package/src/client/__tests__/oauth-provider.test.ts +39 -0
- package/src/client/index.ts +4 -0
- package/src/component/__tests__/handlers-protocol.test.ts +880 -0
- package/src/component/__tests__/mutations-protocol.test.ts +448 -0
- package/src/component/__tests__/oauth.test.ts +32 -28
- package/src/component/__tests__/rfc-compliance.test.ts +79 -11
- package/src/component/_generated/component.ts +17 -1
- package/src/component/clientManagement.ts +31 -0
- package/src/component/handlers.ts +355 -31
- package/src/component/mutations.ts +133 -40
- package/src/component/schema.ts +11 -0
- package/src/lib/__tests__/oauth-jwt.test.ts +68 -0
- package/src/lib/oauth.ts +8 -4
|
@@ -31,6 +31,8 @@ interface OAuthRegistrationBody {
|
|
|
31
31
|
[key: string]: unknown;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
type TokenEndpointAuthMethod = "client_secret_basic" | "client_secret_post" | "none";
|
|
35
|
+
|
|
34
36
|
function buildAuthorizeErrorRedirect(
|
|
35
37
|
redirectUri: string,
|
|
36
38
|
error: string,
|
|
@@ -57,19 +59,145 @@ function isValidRedirectUri(uri: string): boolean {
|
|
|
57
59
|
}
|
|
58
60
|
|
|
59
61
|
if (parsed.hash) return false;
|
|
62
|
+
if (parsed.username || parsed.password) return false;
|
|
60
63
|
|
|
61
64
|
const host = parsed.hostname.toLowerCase();
|
|
62
65
|
const isLoopback =
|
|
63
66
|
host === "localhost" ||
|
|
64
67
|
host === "127.0.0.1" ||
|
|
68
|
+
host === "[::1]" ||
|
|
65
69
|
host === "::1";
|
|
66
70
|
|
|
67
71
|
if (parsed.protocol === "https:") return true;
|
|
68
72
|
if (parsed.protocol === "http:" && isLoopback) return true;
|
|
73
|
+
if (isValidPrivateUseRedirectUri(parsed)) return true;
|
|
69
74
|
|
|
70
75
|
return false;
|
|
71
76
|
}
|
|
72
77
|
|
|
78
|
+
function isValidPrivateUseRedirectUri(parsed: URL): boolean {
|
|
79
|
+
const scheme = parsed.protocol.slice(0, -1);
|
|
80
|
+
const reverseDomainStyle = /^[a-z][a-z0-9]*(\.[a-z0-9][a-z0-9-]*){2,}$/i;
|
|
81
|
+
return (
|
|
82
|
+
reverseDomainStyle.test(scheme) &&
|
|
83
|
+
parsed.hostname === "" &&
|
|
84
|
+
parsed.host === "" &&
|
|
85
|
+
parsed.pathname.startsWith("/") &&
|
|
86
|
+
parsed.pathname.length > 1
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function isValidMetadataUri(uri: string): boolean {
|
|
91
|
+
let parsed: URL;
|
|
92
|
+
try {
|
|
93
|
+
parsed = new URL(uri);
|
|
94
|
+
} catch {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (parsed.hash || parsed.username || parsed.password) return false;
|
|
99
|
+
|
|
100
|
+
const host = parsed.hostname.toLowerCase();
|
|
101
|
+
const isLoopback =
|
|
102
|
+
host === "localhost" ||
|
|
103
|
+
host === "127.0.0.1" ||
|
|
104
|
+
host === "[::1]" ||
|
|
105
|
+
host === "::1";
|
|
106
|
+
|
|
107
|
+
if (parsed.protocol === "https:") return true;
|
|
108
|
+
if (parsed.protocol === "http:" && isLoopback) return true;
|
|
109
|
+
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function formValueToString(value: FormDataEntryValue | null): string | null {
|
|
114
|
+
return typeof value === "string" ? value : null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function isValidResourceUri(value: string): boolean {
|
|
118
|
+
try {
|
|
119
|
+
const url = new URL(value);
|
|
120
|
+
return url.protocol.length > 1 && url.hash === "";
|
|
121
|
+
} catch {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function getResourceFormString(formData: FormData): string | null {
|
|
127
|
+
const values = formData.getAll("resource");
|
|
128
|
+
if (values.length === 0) return null;
|
|
129
|
+
if (values.length > 1) {
|
|
130
|
+
throw new OAuthError("invalid_target", "Multiple resource parameters are not supported");
|
|
131
|
+
}
|
|
132
|
+
return formValueToString(values[0]);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function createInvalidClientResponse(error: OAuthError, headers: Record<string, string>): Response {
|
|
136
|
+
if (error.code === "invalid_client" && error.statusCode === 401) {
|
|
137
|
+
return error.toResponse({
|
|
138
|
+
...headers,
|
|
139
|
+
"WWW-Authenticate": 'Basic realm="oauth"',
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
return error.toResponse(headers);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function getRegisteredTokenAuthMethod(client: {
|
|
146
|
+
type: "confidential" | "public";
|
|
147
|
+
tokenEndpointAuthMethod?: TokenEndpointAuthMethod;
|
|
148
|
+
}): TokenEndpointAuthMethod | undefined {
|
|
149
|
+
return client.tokenEndpointAuthMethod ?? (client.type === "public" ? "none" : undefined);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function validateRequestedResource(resource: string | null): string | undefined {
|
|
153
|
+
if (!resource) return undefined;
|
|
154
|
+
if (!isValidResourceUri(resource)) {
|
|
155
|
+
throw new OAuthError("invalid_target", "resource must be an absolute URI without fragment");
|
|
156
|
+
}
|
|
157
|
+
return resource;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const PKCE_PARAMETER_PATTERN = /^[A-Za-z0-9._~-]{43,128}$/;
|
|
161
|
+
|
|
162
|
+
function isValidPkceParameter(value: string): boolean {
|
|
163
|
+
return PKCE_PARAMETER_PATTERN.test(value);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function decodeFormComponent(value: string): string {
|
|
167
|
+
return decodeURIComponent(value.replace(/\+/g, " "));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function parseBasicClientCredentials(authHeader: string): {
|
|
171
|
+
clientId: string;
|
|
172
|
+
clientSecret: string;
|
|
173
|
+
} {
|
|
174
|
+
const [scheme, credentials, ...extra] = authHeader.trim().split(/\s+/);
|
|
175
|
+
if (!scheme || scheme.toLowerCase() !== "basic" || !credentials || extra.length > 0) {
|
|
176
|
+
throw new OAuthError("invalid_client", "Unsupported client authentication method", 401);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
let decoded: string;
|
|
180
|
+
try {
|
|
181
|
+
decoded = atob(credentials);
|
|
182
|
+
} catch {
|
|
183
|
+
throw new OAuthError("invalid_client", "Invalid client credentials", 401);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const separator = decoded.indexOf(":");
|
|
187
|
+
if (separator < 0) {
|
|
188
|
+
throw new OAuthError("invalid_client", "Invalid client credentials", 401);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
return {
|
|
193
|
+
clientId: decodeFormComponent(decoded.slice(0, separator)),
|
|
194
|
+
clientSecret: decodeFormComponent(decoded.slice(separator + 1)),
|
|
195
|
+
};
|
|
196
|
+
} catch {
|
|
197
|
+
throw new OAuthError("invalid_client", "Invalid client credentials", 401);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
73
201
|
function isConsentFromProvider(request: Request, config: OAuthConfig): boolean {
|
|
74
202
|
const allowedOrigins = [config.siteUrl, config.convexSiteUrl]
|
|
75
203
|
.filter(Boolean)
|
|
@@ -115,6 +243,7 @@ export interface OAuthComponentAPI {
|
|
|
115
243
|
type: "confidential" | "public";
|
|
116
244
|
redirectUris: string[];
|
|
117
245
|
allowedScopes: string[];
|
|
246
|
+
tokenEndpointAuthMethod?: TokenEndpointAuthMethod;
|
|
118
247
|
} | null>;
|
|
119
248
|
getRefreshToken: (ctx: RunQueryCtx, args: { refreshToken: string }) => Promise<{
|
|
120
249
|
refreshToken?: string;
|
|
@@ -122,6 +251,8 @@ export interface OAuthComponentAPI {
|
|
|
122
251
|
userId: string;
|
|
123
252
|
scopes: string[];
|
|
124
253
|
refreshTokenExpiresAt?: number;
|
|
254
|
+
resource?: string;
|
|
255
|
+
audience?: string;
|
|
125
256
|
} | null>;
|
|
126
257
|
getTokensByUser: (ctx: RunQueryCtx, args: { userId: string }) => Promise<Array<{
|
|
127
258
|
_id: string;
|
|
@@ -141,12 +272,15 @@ export interface OAuthComponentAPI {
|
|
|
141
272
|
codeChallenge: string;
|
|
142
273
|
codeChallengeMethod: string;
|
|
143
274
|
nonce?: string;
|
|
275
|
+
resource?: string;
|
|
276
|
+
authTime?: number;
|
|
144
277
|
}) => Promise<string>;
|
|
145
278
|
consumeAuthCode: (ctx: RunMutationCtx, args: {
|
|
146
279
|
code: string;
|
|
147
280
|
clientId: string;
|
|
148
281
|
redirectUri?: string;
|
|
149
282
|
codeVerifier: string;
|
|
283
|
+
resource?: string;
|
|
150
284
|
}) => Promise<{
|
|
151
285
|
userId: string;
|
|
152
286
|
scopes: string[];
|
|
@@ -155,6 +289,8 @@ export interface OAuthComponentAPI {
|
|
|
155
289
|
redirectUri: string;
|
|
156
290
|
nonce?: string;
|
|
157
291
|
codeHash: string;
|
|
292
|
+
resource?: string;
|
|
293
|
+
authTime?: number;
|
|
158
294
|
}>;
|
|
159
295
|
saveTokens: (ctx: RunMutationCtx, args: {
|
|
160
296
|
accessToken: string;
|
|
@@ -165,6 +301,8 @@ export interface OAuthComponentAPI {
|
|
|
165
301
|
expiresAt: number;
|
|
166
302
|
refreshTokenExpiresAt?: number;
|
|
167
303
|
authorizationCode?: string;
|
|
304
|
+
resource?: string;
|
|
305
|
+
audience?: string;
|
|
168
306
|
}) => Promise<void>;
|
|
169
307
|
rotateRefreshToken: (ctx: RunMutationCtx, args: {
|
|
170
308
|
oldRefreshToken: string;
|
|
@@ -175,11 +313,14 @@ export interface OAuthComponentAPI {
|
|
|
175
313
|
scopes: string[];
|
|
176
314
|
expiresAt: number;
|
|
177
315
|
refreshTokenExpiresAt: number;
|
|
316
|
+
resource?: string;
|
|
317
|
+
audience?: string;
|
|
178
318
|
}) => Promise<void>;
|
|
179
319
|
upsertAuthorization: (ctx: RunMutationCtx, args: {
|
|
180
320
|
userId: string;
|
|
181
321
|
clientId: string;
|
|
182
322
|
scopes: string[];
|
|
323
|
+
resource?: string;
|
|
183
324
|
}) => Promise<string>;
|
|
184
325
|
updateAuthorizationLastUsed: (ctx: RunMutationCtx, args: {
|
|
185
326
|
userId: string;
|
|
@@ -196,6 +337,7 @@ export interface OAuthComponentAPI {
|
|
|
196
337
|
logoUrl?: string;
|
|
197
338
|
tosUrl?: string;
|
|
198
339
|
policyUrl?: string;
|
|
340
|
+
tokenEndpointAuthMethod?: TokenEndpointAuthMethod;
|
|
199
341
|
}) => Promise<{
|
|
200
342
|
clientId: string;
|
|
201
343
|
clientSecret?: string;
|
|
@@ -238,9 +380,13 @@ export async function authorizeHandler(
|
|
|
238
380
|
const scope = params.get("scope") ?? "";
|
|
239
381
|
const state = params.get("state");
|
|
240
382
|
const consent = params.get("consent");
|
|
383
|
+
const prompt = params.get("prompt");
|
|
241
384
|
const codeChallenge = params.get("code_challenge");
|
|
242
385
|
const codeChallengeMethod = params.get("code_challenge_method");
|
|
243
386
|
const nonce = params.get("nonce") ?? undefined;
|
|
387
|
+
const resource = params.get("resource");
|
|
388
|
+
const resourceValues = params.getAll("resource");
|
|
389
|
+
const maxAge = params.get("max_age");
|
|
244
390
|
|
|
245
391
|
if (!clientId) {
|
|
246
392
|
return new OAuthError("invalid_request", "client_id required").toResponse(headers);
|
|
@@ -257,6 +403,40 @@ export async function authorizeHandler(
|
|
|
257
403
|
return new OAuthError("invalid_request", "redirect_uri mismatch").toResponse(headers);
|
|
258
404
|
}
|
|
259
405
|
|
|
406
|
+
const singletonParameters = [
|
|
407
|
+
"response_type",
|
|
408
|
+
"client_id",
|
|
409
|
+
"redirect_uri",
|
|
410
|
+
"scope",
|
|
411
|
+
"state",
|
|
412
|
+
"consent",
|
|
413
|
+
"prompt",
|
|
414
|
+
"code_challenge",
|
|
415
|
+
"code_challenge_method",
|
|
416
|
+
"nonce",
|
|
417
|
+
"max_age",
|
|
418
|
+
];
|
|
419
|
+
const duplicateParameter = singletonParameters.find(
|
|
420
|
+
(name) => params.getAll(name).length > 1
|
|
421
|
+
);
|
|
422
|
+
if (duplicateParameter) {
|
|
423
|
+
return buildAuthorizeErrorRedirect(
|
|
424
|
+
redirectUri,
|
|
425
|
+
"invalid_request",
|
|
426
|
+
`Duplicate parameter: ${duplicateParameter}`,
|
|
427
|
+
state
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
if (resourceValues.length > 1) {
|
|
432
|
+
return buildAuthorizeErrorRedirect(
|
|
433
|
+
redirectUri,
|
|
434
|
+
"invalid_target",
|
|
435
|
+
"Multiple resource parameters are not supported",
|
|
436
|
+
state
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
|
|
260
440
|
if (consent === "approve" && !isConsentFromProvider(request, config)) {
|
|
261
441
|
return buildAuthorizeErrorRedirect(
|
|
262
442
|
redirectUri,
|
|
@@ -275,9 +455,39 @@ export async function authorizeHandler(
|
|
|
275
455
|
);
|
|
276
456
|
}
|
|
277
457
|
|
|
278
|
-
const
|
|
458
|
+
const promptValues = new Set((prompt ?? "").split(/\s+/).filter(Boolean));
|
|
459
|
+
if (maxAge !== null) {
|
|
460
|
+
if (!/^(0|[1-9]\d*)$/.test(maxAge)) {
|
|
461
|
+
return buildAuthorizeErrorRedirect(
|
|
462
|
+
redirectUri,
|
|
463
|
+
"invalid_request",
|
|
464
|
+
"max_age must be a non-negative integer",
|
|
465
|
+
state
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
return buildAuthorizeErrorRedirect(
|
|
469
|
+
redirectUri,
|
|
470
|
+
"login_required",
|
|
471
|
+
"Current authentication time cannot satisfy max_age",
|
|
472
|
+
state
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (resource && !isValidResourceUri(resource)) {
|
|
477
|
+
return buildAuthorizeErrorRedirect(
|
|
478
|
+
redirectUri,
|
|
479
|
+
"invalid_target",
|
|
480
|
+
"resource must be an absolute URI without fragment",
|
|
481
|
+
state
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
let requestedScopes = scope
|
|
279
486
|
? scope.split(" ").filter(Boolean)
|
|
280
487
|
: [];
|
|
488
|
+
if (requestedScopes.includes("offline_access") && !promptValues.has("consent")) {
|
|
489
|
+
requestedScopes = requestedScopes.filter((s) => s !== "offline_access");
|
|
490
|
+
}
|
|
281
491
|
if (requestedScopes.length === 0) {
|
|
282
492
|
return buildAuthorizeErrorRedirect(
|
|
283
493
|
redirectUri,
|
|
@@ -304,6 +514,14 @@ export async function authorizeHandler(
|
|
|
304
514
|
state
|
|
305
515
|
);
|
|
306
516
|
}
|
|
517
|
+
if (!isValidPkceParameter(codeChallenge)) {
|
|
518
|
+
return buildAuthorizeErrorRedirect(
|
|
519
|
+
redirectUri,
|
|
520
|
+
"invalid_request",
|
|
521
|
+
"invalid code_challenge",
|
|
522
|
+
state
|
|
523
|
+
);
|
|
524
|
+
}
|
|
307
525
|
if (codeChallengeMethod !== "S256") {
|
|
308
526
|
return buildAuthorizeErrorRedirect(
|
|
309
527
|
redirectUri,
|
|
@@ -343,6 +561,8 @@ export async function authorizeHandler(
|
|
|
343
561
|
codeChallenge,
|
|
344
562
|
codeChallengeMethod,
|
|
345
563
|
nonce,
|
|
564
|
+
resource: resource ?? undefined,
|
|
565
|
+
authTime: Math.floor(Date.now() / 1000),
|
|
346
566
|
});
|
|
347
567
|
|
|
348
568
|
const redirect = new URL(redirectUri);
|
|
@@ -384,7 +604,7 @@ export async function openIdConfigurationHandler(
|
|
|
384
604
|
subject_types_supported: ["public"],
|
|
385
605
|
id_token_signing_alg_values_supported: ["RS256"],
|
|
386
606
|
scopes_supported: supportedScopes,
|
|
387
|
-
token_endpoint_auth_methods_supported: ["client_secret_post", "none"],
|
|
607
|
+
token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post", "none"],
|
|
388
608
|
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
389
609
|
code_challenge_methods_supported: ["S256"],
|
|
390
610
|
};
|
|
@@ -441,30 +661,69 @@ export async function tokenHandler(
|
|
|
441
661
|
|
|
442
662
|
try {
|
|
443
663
|
const formData = await request.formData();
|
|
444
|
-
const
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
664
|
+
const singleValueParameters = [
|
|
665
|
+
"grant_type",
|
|
666
|
+
"code",
|
|
667
|
+
"redirect_uri",
|
|
668
|
+
"client_id",
|
|
669
|
+
"code_verifier",
|
|
670
|
+
"client_secret",
|
|
671
|
+
"refresh_token",
|
|
672
|
+
"scope",
|
|
673
|
+
];
|
|
674
|
+
const duplicateParameter = singleValueParameters.find(
|
|
675
|
+
(name) => formData.getAll(name).length > 1
|
|
676
|
+
);
|
|
677
|
+
if (duplicateParameter) {
|
|
678
|
+
throw new OAuthError("invalid_request", `Duplicate parameter: ${duplicateParameter}`);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
const grantType = formValueToString(formData.get("grant_type"));
|
|
682
|
+
const code = formValueToString(formData.get("code"));
|
|
683
|
+
const redirectUri = formValueToString(formData.get("redirect_uri"));
|
|
684
|
+
const bodyClientId = formValueToString(formData.get("client_id"));
|
|
685
|
+
const codeVerifier = formValueToString(formData.get("code_verifier"));
|
|
686
|
+
const bodyClientSecret = formValueToString(formData.get("client_secret"));
|
|
687
|
+
const requestedResource = validateRequestedResource(getResourceFormString(formData));
|
|
688
|
+
const authHeader = request.headers.get("Authorization");
|
|
689
|
+
|
|
690
|
+
let clientId = bodyClientId;
|
|
691
|
+
let clientSecret = bodyClientSecret;
|
|
692
|
+
let usedAuthMethod: TokenEndpointAuthMethod = bodyClientSecret ? "client_secret_post" : "none";
|
|
693
|
+
if (authHeader) {
|
|
694
|
+
if (bodyClientSecret) {
|
|
695
|
+
throw new OAuthError("invalid_request", "Multiple client authentication methods");
|
|
696
|
+
}
|
|
697
|
+
const basicCredentials = parseBasicClientCredentials(authHeader);
|
|
698
|
+
clientId = basicCredentials.clientId;
|
|
699
|
+
clientSecret = basicCredentials.clientSecret;
|
|
700
|
+
usedAuthMethod = "client_secret_basic";
|
|
701
|
+
}
|
|
450
702
|
|
|
451
703
|
if (!clientId) throw new OAuthError("invalid_request", "client_id required");
|
|
452
704
|
|
|
453
705
|
// Client existence + confidential client check
|
|
454
|
-
const client = await api.queries.getClient(ctx, { clientId
|
|
706
|
+
const client = await api.queries.getClient(ctx, { clientId });
|
|
455
707
|
if (!client) {
|
|
456
708
|
throw new OAuthError("invalid_client", "Unknown client", 401);
|
|
457
709
|
}
|
|
458
710
|
|
|
711
|
+
const registeredAuthMethod = getRegisteredTokenAuthMethod(client);
|
|
712
|
+
if (registeredAuthMethod && usedAuthMethod !== "none" && usedAuthMethod !== registeredAuthMethod) {
|
|
713
|
+
throw new OAuthError("invalid_client", "Client authentication method not allowed", 401);
|
|
714
|
+
}
|
|
715
|
+
|
|
459
716
|
if (client.type === "confidential") {
|
|
460
717
|
if (!clientSecret) throw new OAuthError("invalid_client", "client_secret required", 401);
|
|
461
718
|
|
|
462
719
|
const isValid = await api.clientManagement.verifyClientSecret(ctx, {
|
|
463
|
-
clientId
|
|
464
|
-
clientSecret
|
|
720
|
+
clientId,
|
|
721
|
+
clientSecret,
|
|
465
722
|
});
|
|
466
723
|
|
|
467
724
|
if (!isValid) throw new OAuthError("invalid_client", "Invalid client secret", 401);
|
|
725
|
+
} else if (clientSecret || usedAuthMethod !== "none") {
|
|
726
|
+
throw new OAuthError("invalid_client", "Public clients must not authenticate", 401);
|
|
468
727
|
}
|
|
469
728
|
|
|
470
729
|
if (grantType === "authorization_code") {
|
|
@@ -475,9 +734,10 @@ export async function tokenHandler(
|
|
|
475
734
|
// A. Consume Code
|
|
476
735
|
const codeData = await api.mutations.consumeAuthCode(ctx, {
|
|
477
736
|
code: code as string,
|
|
478
|
-
clientId
|
|
479
|
-
redirectUri: redirectUri
|
|
737
|
+
clientId,
|
|
738
|
+
redirectUri: redirectUri ?? undefined,
|
|
480
739
|
codeVerifier: codeVerifier as string,
|
|
740
|
+
resource: requestedResource,
|
|
481
741
|
});
|
|
482
742
|
|
|
483
743
|
// Check for authorization code reuse (RFC Line 1136)
|
|
@@ -491,6 +751,14 @@ export async function tokenHandler(
|
|
|
491
751
|
const accessTokenExpiresIn = 3600;
|
|
492
752
|
const issuerUrl = getIssuerUrl(config);
|
|
493
753
|
const keyId = getSigningKeyId(config);
|
|
754
|
+
const defaultAudience = config.applicationID ?? "convex";
|
|
755
|
+
if (requestedResource && !codeData.resource) {
|
|
756
|
+
throw new OAuthError("invalid_target", "Requested resource was not included in the authorization grant");
|
|
757
|
+
}
|
|
758
|
+
if (codeData.resource && requestedResource && codeData.resource !== requestedResource) {
|
|
759
|
+
throw new OAuthError("invalid_target", "Requested resource does not match authorization grant");
|
|
760
|
+
}
|
|
761
|
+
const accessTokenAudience = codeData.resource ?? defaultAudience;
|
|
494
762
|
|
|
495
763
|
// Access Token
|
|
496
764
|
const accessToken = await sign(
|
|
@@ -498,9 +766,12 @@ export async function tokenHandler(
|
|
|
498
766
|
uid: userId,
|
|
499
767
|
scp: codeData.scopes,
|
|
500
768
|
cid: clientId,
|
|
769
|
+
scope: codeData.scopes.join(" "),
|
|
770
|
+
client_id: clientId,
|
|
771
|
+
jti: crypto.randomUUID(),
|
|
501
772
|
},
|
|
502
773
|
userId,
|
|
503
|
-
|
|
774
|
+
accessTokenAudience,
|
|
504
775
|
"1h",
|
|
505
776
|
config.privateKey,
|
|
506
777
|
issuerUrl,
|
|
@@ -515,8 +786,9 @@ export async function tokenHandler(
|
|
|
515
786
|
const idTokenClaims = {
|
|
516
787
|
sub: userId,
|
|
517
788
|
iss: issuerUrl,
|
|
518
|
-
aud: clientId
|
|
789
|
+
aud: clientId,
|
|
519
790
|
nonce: codeData.nonce,
|
|
791
|
+
auth_time: codeData.authTime,
|
|
520
792
|
};
|
|
521
793
|
|
|
522
794
|
idToken = await new SignJWT(idTokenClaims)
|
|
@@ -542,13 +814,16 @@ export async function tokenHandler(
|
|
|
542
814
|
expiresAt: (now + accessTokenExpiresIn) * 1000,
|
|
543
815
|
refreshTokenExpiresAt: refreshToken ? (now + 3600 * 24 * 30) * 1000 : undefined,
|
|
544
816
|
authorizationCode: codeData.codeHash, // Link to authorization code
|
|
817
|
+
resource: codeData.resource,
|
|
818
|
+
audience: accessTokenAudience,
|
|
545
819
|
});
|
|
546
820
|
|
|
547
821
|
// F. Create/Update Authorization Record
|
|
548
822
|
await api.mutations.upsertAuthorization(ctx, {
|
|
549
823
|
userId,
|
|
550
|
-
clientId
|
|
824
|
+
clientId,
|
|
551
825
|
scopes: codeData.scopes,
|
|
826
|
+
resource: codeData.resource,
|
|
552
827
|
});
|
|
553
828
|
|
|
554
829
|
// Build response
|
|
@@ -575,14 +850,23 @@ export async function tokenHandler(
|
|
|
575
850
|
}
|
|
576
851
|
|
|
577
852
|
if (grantType === "refresh_token") {
|
|
578
|
-
const refreshToken = formData.get("refresh_token")
|
|
579
|
-
const requestedScope = formData.get("scope")
|
|
853
|
+
const refreshToken = formValueToString(formData.get("refresh_token"));
|
|
854
|
+
const requestedScope = formValueToString(formData.get("scope")); // RFC 6749 Section 6
|
|
580
855
|
|
|
581
856
|
if (!refreshToken) throw new OAuthError("invalid_request", "refresh_token required");
|
|
582
857
|
|
|
583
858
|
const oldToken = await api.queries.getRefreshToken(ctx, { refreshToken });
|
|
584
859
|
|
|
585
860
|
if (!oldToken) throw new OAuthError("invalid_grant", "Invalid refresh token");
|
|
861
|
+
const refreshTokenResource = oldToken.resource;
|
|
862
|
+
const refreshTokenAudience = oldToken.audience ?? refreshTokenResource ?? config.applicationID ?? "convex";
|
|
863
|
+
const accessTokenAudience = refreshTokenResource ?? refreshTokenAudience;
|
|
864
|
+
if (!refreshTokenResource && requestedResource) {
|
|
865
|
+
throw new OAuthError("invalid_target", "Requested resource was not included in the refresh token grant");
|
|
866
|
+
}
|
|
867
|
+
if (refreshTokenResource && requestedResource && requestedResource !== refreshTokenResource) {
|
|
868
|
+
throw new OAuthError("invalid_target", "Requested resource does not match refresh token grant");
|
|
869
|
+
}
|
|
586
870
|
|
|
587
871
|
if (!oldToken.refreshTokenExpiresAt || oldToken.refreshTokenExpiresAt < Date.now()) {
|
|
588
872
|
throw new OAuthError("invalid_grant", "Refresh token expired");
|
|
@@ -635,10 +919,13 @@ export async function tokenHandler(
|
|
|
635
919
|
{
|
|
636
920
|
uid: userId,
|
|
637
921
|
scp: accessTokenScopes, // 縮小可能
|
|
638
|
-
cid: clientId
|
|
922
|
+
cid: clientId,
|
|
923
|
+
scope: accessTokenScopes.join(" "),
|
|
924
|
+
client_id: clientId,
|
|
925
|
+
jti: crypto.randomUUID(),
|
|
639
926
|
},
|
|
640
927
|
userId,
|
|
641
|
-
|
|
928
|
+
accessTokenAudience,
|
|
642
929
|
"1h",
|
|
643
930
|
config.privateKey,
|
|
644
931
|
issuerUrl,
|
|
@@ -655,7 +942,7 @@ export async function tokenHandler(
|
|
|
655
942
|
idToken = await new SignJWT({
|
|
656
943
|
sub: userId,
|
|
657
944
|
iss: issuerUrl,
|
|
658
|
-
aud: clientId
|
|
945
|
+
aud: clientId,
|
|
659
946
|
})
|
|
660
947
|
.setProtectedHeader({ alg: "RS256", typ: "JWT", kid: keyId })
|
|
661
948
|
.setIssuedAt()
|
|
@@ -669,17 +956,19 @@ export async function tokenHandler(
|
|
|
669
956
|
oldRefreshToken: refreshToken,
|
|
670
957
|
accessToken,
|
|
671
958
|
refreshToken: newRefreshToken,
|
|
672
|
-
clientId
|
|
959
|
+
clientId,
|
|
673
960
|
userId,
|
|
674
961
|
scopes: refreshTokenScopes, // 元のスコープと同一
|
|
675
962
|
expiresAt: (now + accessTokenExpiresIn) * 1000,
|
|
676
963
|
refreshTokenExpiresAt: (now + 3600 * 24 * 30) * 1000,
|
|
964
|
+
resource: refreshTokenResource,
|
|
965
|
+
audience: refreshTokenAudience,
|
|
677
966
|
});
|
|
678
967
|
|
|
679
968
|
// Update authorization lastUsedAt
|
|
680
969
|
await api.mutations.updateAuthorizationLastUsed(ctx, {
|
|
681
970
|
userId,
|
|
682
|
-
clientId
|
|
971
|
+
clientId,
|
|
683
972
|
});
|
|
684
973
|
} catch (e) {
|
|
685
974
|
if (e instanceof Error && e.message.includes("invalid_grant")) {
|
|
@@ -715,7 +1004,7 @@ export async function tokenHandler(
|
|
|
715
1004
|
} catch (e) {
|
|
716
1005
|
console.error(e);
|
|
717
1006
|
if (e instanceof OAuthError) {
|
|
718
|
-
return e
|
|
1007
|
+
return createInvalidClientResponse(e, tokenHeaders);
|
|
719
1008
|
}
|
|
720
1009
|
if (e instanceof Error) {
|
|
721
1010
|
// シンプルなエラーメッセージを先にチェック(完全一致)
|
|
@@ -723,7 +1012,10 @@ export async function tokenHandler(
|
|
|
723
1012
|
return new OAuthError("invalid_grant", "Invalid grant").toResponse(tokenHeaders);
|
|
724
1013
|
}
|
|
725
1014
|
if (e.message === "invalid_client") {
|
|
726
|
-
return
|
|
1015
|
+
return createInvalidClientResponse(
|
|
1016
|
+
new OAuthError("invalid_client", "Invalid client", 401),
|
|
1017
|
+
tokenHeaders
|
|
1018
|
+
);
|
|
727
1019
|
}
|
|
728
1020
|
|
|
729
1021
|
// 特定エラーメッセージをOAuthエラーコードにマッピング(部分一致)
|
|
@@ -733,6 +1025,7 @@ export async function tokenHandler(
|
|
|
733
1025
|
"unsupported_code_challenge_method": ["invalid_request", "Unsupported code challenge method", undefined],
|
|
734
1026
|
"scope_change_not_allowed": ["invalid_scope", "Refresh token scope must remain identical", undefined],
|
|
735
1027
|
"authorization_code_reuse_detected": ["invalid_grant", "Authorization code has already been used", undefined],
|
|
1028
|
+
"invalid_target": ["invalid_target", "Requested resource does not match authorization grant", undefined],
|
|
736
1029
|
};
|
|
737
1030
|
|
|
738
1031
|
for (const [pattern, [code, message, status]] of Object.entries(errorMap)) {
|
|
@@ -769,7 +1062,7 @@ export async function userInfoHandler(
|
|
|
769
1062
|
status: 401,
|
|
770
1063
|
headers: {
|
|
771
1064
|
...headers,
|
|
772
|
-
"WWW-Authenticate": 'Bearer
|
|
1065
|
+
"WWW-Authenticate": 'Bearer realm="userinfo"',
|
|
773
1066
|
},
|
|
774
1067
|
});
|
|
775
1068
|
}
|
|
@@ -814,7 +1107,13 @@ export async function userInfoHandler(
|
|
|
814
1107
|
const user = await getUserProfile(userId);
|
|
815
1108
|
|
|
816
1109
|
if (!user) {
|
|
817
|
-
return new Response(null, {
|
|
1110
|
+
return new Response(null, {
|
|
1111
|
+
status: 401,
|
|
1112
|
+
headers: {
|
|
1113
|
+
...headers,
|
|
1114
|
+
"WWW-Authenticate": 'Bearer error="invalid_token", error_description="User profile not found"',
|
|
1115
|
+
},
|
|
1116
|
+
});
|
|
818
1117
|
}
|
|
819
1118
|
|
|
820
1119
|
const responseBody: UserProfile = { sub: userId };
|
|
@@ -880,20 +1179,40 @@ export async function registerHandler(
|
|
|
880
1179
|
}
|
|
881
1180
|
const scopes = requestedScopes;
|
|
882
1181
|
const authMethod = body.token_endpoint_auth_method;
|
|
883
|
-
if (
|
|
1182
|
+
if (
|
|
1183
|
+
authMethod &&
|
|
1184
|
+
authMethod !== "client_secret_basic" &&
|
|
1185
|
+
authMethod !== "client_secret_post" &&
|
|
1186
|
+
authMethod !== "none"
|
|
1187
|
+
) {
|
|
884
1188
|
throw new OAuthError(
|
|
885
1189
|
"invalid_client_metadata",
|
|
886
1190
|
"Unsupported token_endpoint_auth_method"
|
|
887
1191
|
);
|
|
888
1192
|
}
|
|
889
|
-
const
|
|
1193
|
+
const tokenEndpointAuthMethod = (authMethod || "client_secret_basic") as TokenEndpointAuthMethod;
|
|
1194
|
+
const type = (tokenEndpointAuthMethod === "none") ? "public" : "confidential";
|
|
890
1195
|
|
|
891
1196
|
if (redirectUris.length === 0) {
|
|
892
1197
|
throw new OAuthError("invalid_request", "redirect_uris required");
|
|
893
1198
|
}
|
|
894
1199
|
const invalidRedirect = redirectUris.find((uri) => !isValidRedirectUri(uri));
|
|
895
1200
|
if (invalidRedirect) {
|
|
896
|
-
throw new OAuthError("
|
|
1201
|
+
throw new OAuthError("invalid_redirect_uri", `Invalid redirect_uri: ${invalidRedirect}`);
|
|
1202
|
+
}
|
|
1203
|
+
const metadataUrls = {
|
|
1204
|
+
logo_uri: body.logo_uri,
|
|
1205
|
+
client_uri: body.client_uri,
|
|
1206
|
+
tos_uri: body.tos_uri,
|
|
1207
|
+
policy_uri: body.policy_uri,
|
|
1208
|
+
};
|
|
1209
|
+
for (const [field, uri] of Object.entries(metadataUrls)) {
|
|
1210
|
+
if (uri !== undefined && !isValidMetadataUri(uri)) {
|
|
1211
|
+
throw new OAuthError(
|
|
1212
|
+
"invalid_client_metadata",
|
|
1213
|
+
`Invalid ${field}: ${uri}`
|
|
1214
|
+
);
|
|
1215
|
+
}
|
|
897
1216
|
}
|
|
898
1217
|
|
|
899
1218
|
const result = await api.clientManagement.registerClient(ctx, {
|
|
@@ -905,6 +1224,7 @@ export async function registerHandler(
|
|
|
905
1224
|
website: body.client_uri,
|
|
906
1225
|
tosUrl: body.tos_uri,
|
|
907
1226
|
policyUrl: body.policy_uri,
|
|
1227
|
+
tokenEndpointAuthMethod,
|
|
908
1228
|
});
|
|
909
1229
|
|
|
910
1230
|
const responseBody: Record<string, unknown> = {
|
|
@@ -914,10 +1234,14 @@ export async function registerHandler(
|
|
|
914
1234
|
grant_types: ["authorization_code", "refresh_token"],
|
|
915
1235
|
response_types: ["code"],
|
|
916
1236
|
scope: scopes.join(" "),
|
|
917
|
-
token_endpoint_auth_method:
|
|
1237
|
+
token_endpoint_auth_method: tokenEndpointAuthMethod,
|
|
918
1238
|
application_type: "web",
|
|
919
1239
|
client_name: clientName,
|
|
920
1240
|
};
|
|
1241
|
+
if (body.logo_uri) responseBody.logo_uri = body.logo_uri;
|
|
1242
|
+
if (body.client_uri) responseBody.client_uri = body.client_uri;
|
|
1243
|
+
if (body.tos_uri) responseBody.tos_uri = body.tos_uri;
|
|
1244
|
+
if (body.policy_uri) responseBody.policy_uri = body.policy_uri;
|
|
921
1245
|
|
|
922
1246
|
if (result.clientSecret) {
|
|
923
1247
|
responseBody.client_secret = result.clientSecret;
|