@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.
Files changed (42) hide show
  1. package/README.md +40 -14
  2. package/dist/client/index.d.ts +4 -0
  3. package/dist/client/index.d.ts.map +1 -1
  4. package/dist/client/index.js +1 -0
  5. package/dist/client/index.js.map +1 -1
  6. package/dist/component/_generated/component.d.ts +9 -0
  7. package/dist/component/_generated/component.d.ts.map +1 -1
  8. package/dist/component/clientManagement.d.ts +1 -0
  9. package/dist/component/clientManagement.d.ts.map +1 -1
  10. package/dist/component/clientManagement.js +24 -0
  11. package/dist/component/clientManagement.js.map +1 -1
  12. package/dist/component/handlers.d.ts +16 -0
  13. package/dist/component/handlers.d.ts.map +1 -1
  14. package/dist/component/handlers.js +275 -28
  15. package/dist/component/handlers.js.map +1 -1
  16. package/dist/component/mutations.d.ts +9 -0
  17. package/dist/component/mutations.d.ts.map +1 -1
  18. package/dist/component/mutations.js +112 -40
  19. package/dist/component/mutations.js.map +1 -1
  20. package/dist/component/queries.d.ts +8 -0
  21. package/dist/component/queries.d.ts.map +1 -1
  22. package/dist/component/schema.d.ts +18 -4
  23. package/dist/component/schema.d.ts.map +1 -1
  24. package/dist/component/schema.js +7 -0
  25. package/dist/component/schema.js.map +1 -1
  26. package/dist/lib/oauth.d.ts.map +1 -1
  27. package/dist/lib/oauth.js +5 -2
  28. package/dist/lib/oauth.js.map +1 -1
  29. package/package.json +39 -39
  30. package/src/client/__tests__/oauth-provider.test.ts +39 -0
  31. package/src/client/index.ts +4 -0
  32. package/src/component/__tests__/handlers-protocol.test.ts +880 -0
  33. package/src/component/__tests__/mutations-protocol.test.ts +448 -0
  34. package/src/component/__tests__/oauth.test.ts +32 -28
  35. package/src/component/__tests__/rfc-compliance.test.ts +79 -11
  36. package/src/component/_generated/component.ts +17 -1
  37. package/src/component/clientManagement.ts +31 -0
  38. package/src/component/handlers.ts +355 -31
  39. package/src/component/mutations.ts +133 -40
  40. package/src/component/schema.ts +11 -0
  41. package/src/lib/__tests__/oauth-jwt.test.ts +68 -0
  42. 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 requestedScopes = scope
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 grantType = formData.get("grant_type");
445
- const code = formData.get("code");
446
- const redirectUri = formData.get("redirect_uri");
447
- const clientId = formData.get("client_id");
448
- const codeVerifier = formData.get("code_verifier");
449
- const clientSecret = formData.get("client_secret");
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: clientId as string });
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: clientId as string,
464
- clientSecret: clientSecret as string,
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: clientId as string,
479
- redirectUri: redirectUri as string | undefined,
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
- config.applicationID ?? "convex",
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 as string,
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: clientId as string,
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") as string;
579
- const requestedScope = formData.get("scope") as string | null; // RFC 6749 Section 6
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 as string,
922
+ cid: clientId,
923
+ scope: accessTokenScopes.join(" "),
924
+ client_id: clientId,
925
+ jti: crypto.randomUUID(),
639
926
  },
640
927
  userId,
641
- config.applicationID ?? "convex",
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 as string,
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: clientId as string,
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: clientId as string,
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.toResponse(tokenHeaders);
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 new OAuthError("invalid_client", "Invalid client", 401).toResponse(tokenHeaders);
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 error="invalid_token", error_description="Missing bearer token"',
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, { status: 401, headers });
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 (authMethod && authMethod !== "client_secret_post" && authMethod !== "none") {
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 type = (authMethod === "none") ? "public" : "confidential";
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("invalid_request", `Invalid redirect_uri: ${invalidRedirect}`);
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: authMethod || "client_secret_post",
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;