@codefox-inc/oauth-provider 0.3.2 → 0.4.1

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 +278 -29
  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 +914 -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 +358 -32
  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
@@ -15,6 +15,9 @@ import { hashToken, isHashedToken } from "./token_security.js";
15
15
  export function isLoopbackRedirectUri(uri: string): boolean {
16
16
  try {
17
17
  const parsed = new URL(uri);
18
+ if (parsed.username || parsed.password || parsed.hash) {
19
+ return false;
20
+ }
18
21
  return (
19
22
  parsed.hostname === "127.0.0.1" ||
20
23
  parsed.hostname === "::1" ||
@@ -25,6 +28,62 @@ export function isLoopbackRedirectUri(uri: string): boolean {
25
28
  }
26
29
  }
27
30
 
31
+ const PKCE_PARAMETER_PATTERN = /^[A-Za-z0-9._~-]{43,128}$/;
32
+
33
+ function isValidPkceParameter(value: string): boolean {
34
+ return PKCE_PARAMETER_PATTERN.test(value);
35
+ }
36
+
37
+ export function isValidResourceUri(resource: string): boolean {
38
+ try {
39
+ const parsed = new URL(resource);
40
+ return parsed.protocol.length > 0 && parsed.hash === "";
41
+ } catch {
42
+ return false;
43
+ }
44
+ }
45
+
46
+ function validateResourceUri(resource: string | undefined): void {
47
+ if (resource !== undefined && !isValidResourceUri(resource)) {
48
+ throw new Error("invalid_target");
49
+ }
50
+ }
51
+
52
+ async function verifyPkce(
53
+ codeChallengeMethod: string,
54
+ codeChallenge: string,
55
+ codeVerifier: string
56
+ ) {
57
+ if (codeChallengeMethod !== "S256" && codeChallengeMethod !== "plain") {
58
+ throw new Error("unsupported_code_challenge_method");
59
+ }
60
+
61
+ if (!isValidPkceParameter(codeVerifier)) {
62
+ throw new Error("invalid_code_verifier");
63
+ }
64
+
65
+ if (codeChallengeMethod === "S256") {
66
+ const encoder = new TextEncoder();
67
+ const data = encoder.encode(codeVerifier);
68
+ const hashBuffer = await crypto.subtle.digest("SHA-256", data);
69
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
70
+ const hashBase64 = btoa(String.fromCharCode(...hashArray))
71
+ .replace(/\+/g, "-")
72
+ .replace(/\//g, "_")
73
+ .replace(/=+$/, "");
74
+
75
+ if (hashBase64 !== codeChallenge) {
76
+ throw new Error("invalid_code_verifier");
77
+ }
78
+ } else if (codeChallengeMethod === "plain") {
79
+ if (codeVerifier !== codeChallenge) {
80
+ throw new Error("invalid_code_verifier");
81
+ }
82
+ } else {
83
+ throw new Error("unsupported_code_challenge_method");
84
+ }
85
+ }
86
+
28
87
  /**
29
88
  * Match redirect URI with registered URIs
30
89
  * RFC 6749 Section 3.1.2.3: Exact string matching required
@@ -40,13 +99,21 @@ export function matchRedirectUri(requested: string, registered: string[]): boole
40
99
  if (isLoopbackRedirectUri(requested)) {
41
100
  try {
42
101
  const reqUrl = new URL(requested);
102
+ if (reqUrl.protocol !== "http:" || reqUrl.username || reqUrl.password || reqUrl.hash) {
103
+ return false;
104
+ }
43
105
  for (const regUri of registered) {
44
106
  if (isLoopbackRedirectUri(regUri)) {
45
107
  const regUrl = new URL(regUri);
46
- // ホストとパスが一致すればポート違いを許容
108
+ // loopback 例外は port の差分だけを許容する
47
109
  if (
110
+ regUrl.protocol === "http:" &&
111
+ !regUrl.username &&
112
+ !regUrl.password &&
113
+ !regUrl.hash &&
48
114
  reqUrl.hostname === regUrl.hostname &&
49
- reqUrl.pathname === regUrl.pathname
115
+ reqUrl.pathname === regUrl.pathname &&
116
+ reqUrl.search === regUrl.search
50
117
  ) {
51
118
  return true;
52
119
  }
@@ -78,12 +145,19 @@ export const issueAuthorizationCode = mutation({
78
145
  codeChallenge: v.string(),
79
146
  codeChallengeMethod: v.string(),
80
147
  nonce: v.optional(v.string()),
148
+ resource: v.optional(v.string()),
149
+ authTime: v.optional(v.number()),
81
150
  },
82
151
  handler: async (ctx, args) => {
152
+ validateResourceUri(args.resource);
153
+
83
154
  // 1. PKCE検証(RFC 7636)
84
155
  if (!args.codeChallenge || args.codeChallenge.trim() === "") {
85
156
  throw new Error("code_challenge required");
86
157
  }
158
+ if (!isValidPkceParameter(args.codeChallenge)) {
159
+ throw new Error("invalid_code_challenge");
160
+ }
87
161
  if (args.codeChallengeMethod !== "S256") {
88
162
  throw new Error("plain code_challenge_method is not supported, use S256");
89
163
  }
@@ -124,6 +198,8 @@ export const issueAuthorizationCode = mutation({
124
198
  codeChallenge: args.codeChallenge,
125
199
  codeChallengeMethod: args.codeChallengeMethod,
126
200
  nonce: args.nonce,
201
+ resource: args.resource,
202
+ authTime: args.authTime ?? Math.floor(Date.now() / 1000),
127
203
  expiresAt: Date.now() + OAUTH_CONSTANTS.CODE_EXPIRY_MS,
128
204
  });
129
205
 
@@ -144,6 +220,7 @@ export const consumeAuthCode = mutation({
144
220
  clientId: v.string(),
145
221
  redirectUri: v.optional(v.string()), // OAuth 2.1: optional
146
222
  codeVerifier: v.string(),
223
+ resource: v.optional(v.string()),
147
224
  },
148
225
  handler: async (ctx, args) => {
149
226
  // 1. Find Code (by hash)
@@ -165,8 +242,35 @@ export const consumeAuthCode = mutation({
165
242
  throw new Error("invalid_grant");
166
243
  }
167
244
 
245
+ const validateCodeRequest = async () => {
246
+ if (authCode.clientId !== args.clientId) {
247
+ throw new Error("invalid_grant");
248
+ }
249
+
250
+ // OAuth 2.1: token request redirect_uri is optional.
251
+ // If supplied, it must exactly match the stored authorization code binding.
252
+ if (args.redirectUri && authCode.redirectUri !== args.redirectUri) {
253
+ throw new Error("redirect_uri_mismatch");
254
+ }
255
+
256
+ if (!authCode.resource && args.resource) {
257
+ throw new Error("invalid_target");
258
+ }
259
+ if (authCode.resource && args.resource && authCode.resource !== args.resource) {
260
+ throw new Error("invalid_target");
261
+ }
262
+
263
+ await verifyPkce(
264
+ authCode.codeChallengeMethod,
265
+ authCode.codeChallenge,
266
+ args.codeVerifier
267
+ );
268
+ };
269
+
168
270
  // RFC Line 1136: Detect authorization code reuse (replay attack)
169
271
  if (authCode.usedAt !== undefined) {
272
+ await validateCodeRequest();
273
+
170
274
  // Code was already used - this is a replay attack
171
275
  // Revoke all tokens issued with this code
172
276
  const tokensToRevoke = await ctx.db
@@ -178,8 +282,7 @@ export const consumeAuthCode = mutation({
178
282
  await ctx.db.delete(token._id);
179
283
  }
180
284
 
181
- // Delete the code
182
- await ctx.db.delete(authCode._id);
285
+ await ctx.db.patch(authCode._id, { replayDetectedAt: Date.now() });
183
286
 
184
287
  // Return error status (cannot throw because it would rollback token deletion)
185
288
  return {
@@ -189,47 +292,12 @@ export const consumeAuthCode = mutation({
189
292
  }
190
293
 
191
294
  // 2. Validation
192
- if (authCode.clientId !== args.clientId) {
193
- throw new Error("invalid_client");
194
- }
195
-
196
295
  if (authCode.expiresAt < Date.now()) {
197
296
  await ctx.db.delete(authCode._id);
198
297
  throw new Error("invalid_grant");
199
298
  }
200
299
 
201
- // redirect_uri validation: 発行時に設定されている場合は必須
202
- // RFC 6749 Section 4.1.3: redirect_uri REQUIRED if included in authorization request
203
- if (authCode.redirectUri) {
204
- if (!args.redirectUri) {
205
- throw new Error("redirect_uri_required");
206
- }
207
- if (authCode.redirectUri !== args.redirectUri) {
208
- throw new Error("redirect_uri_mismatch");
209
- }
210
- }
211
-
212
- // PKCE検証(エラーメッセージ改善)
213
- if (authCode.codeChallengeMethod === "S256") {
214
- const encoder = new TextEncoder();
215
- const data = encoder.encode(args.codeVerifier);
216
- const hashBuffer = await crypto.subtle.digest("SHA-256", data);
217
- const hashArray = Array.from(new Uint8Array(hashBuffer));
218
- const hashBase64 = btoa(String.fromCharCode(...hashArray))
219
- .replace(/\+/g, "-")
220
- .replace(/\//g, "_")
221
- .replace(/=+$/, "");
222
-
223
- if (hashBase64 !== authCode.codeChallenge) {
224
- throw new Error("invalid_code_verifier");
225
- }
226
- } else if (authCode.codeChallengeMethod === "plain") {
227
- if (args.codeVerifier !== authCode.codeChallenge) {
228
- throw new Error("invalid_code_verifier");
229
- }
230
- } else {
231
- throw new Error("unsupported_code_challenge_method");
232
- }
300
+ await validateCodeRequest();
233
301
 
234
302
  // 3. Mark Code as Used (RFC Line 1136: detect replay)
235
303
  await ctx.db.patch(authCode._id, { usedAt: Date.now() });
@@ -241,6 +309,8 @@ export const consumeAuthCode = mutation({
241
309
  codeChallengeMethod: authCode.codeChallengeMethod,
242
310
  redirectUri: authCode.redirectUri,
243
311
  nonce: authCode.nonce,
312
+ resource: authCode.resource,
313
+ authTime: authCode.authTime,
244
314
  codeHash, // Return code hash to link tokens
245
315
  };
246
316
  },
@@ -262,8 +332,22 @@ export const saveTokens = mutation({
262
332
  expiresAt: v.number(),
263
333
  refreshTokenExpiresAt: v.optional(v.number()),
264
334
  authorizationCode: v.optional(v.string()), // Hashed code for replay detection (RFC Line 1136)
335
+ resource: v.optional(v.string()),
336
+ audience: v.optional(v.string()),
265
337
  },
266
338
  handler: async (ctx, args) => {
339
+ const authorizationCode = args.authorizationCode;
340
+ if (authorizationCode) {
341
+ const authCode = await ctx.db
342
+ .query("oauthCodes")
343
+ .withIndex("by_code", (q) => q.eq("code", authorizationCode))
344
+ .unique();
345
+
346
+ if (authCode?.replayDetectedAt !== undefined) {
347
+ throw new Error("authorization_code_reuse_detected");
348
+ }
349
+ }
350
+
267
351
  // Hash tokens before storing for security
268
352
  // The original tokens are returned to the client, hashes are stored
269
353
  await ctx.db.insert("oauthTokens", {
@@ -293,6 +377,8 @@ export const rotateRefreshToken = mutation({
293
377
  scopes: v.array(v.string()),
294
378
  expiresAt: v.number(),
295
379
  refreshTokenExpiresAt: v.optional(v.number()),
380
+ resource: v.optional(v.string()),
381
+ audience: v.optional(v.string()),
296
382
  },
297
383
  handler: async (ctx, args) => {
298
384
  // Hash the old refresh token for lookup
@@ -363,6 +449,8 @@ export const rotateRefreshToken = mutation({
363
449
  scopes: args.scopes,
364
450
  expiresAt: args.expiresAt,
365
451
  refreshTokenExpiresAt: args.refreshTokenExpiresAt,
452
+ resource: args.resource,
453
+ audience: args.audience,
366
454
  });
367
455
  },
368
456
  });
@@ -437,8 +525,11 @@ export const upsertAuthorization = mutation({
437
525
  userId: v.string(),
438
526
  clientId: v.string(),
439
527
  scopes: v.array(v.string()),
528
+ resource: v.optional(v.string()),
440
529
  },
441
530
  handler: async (ctx, args) => {
531
+ validateResourceUri(args.resource);
532
+
442
533
  const existing = await ctx.db
443
534
  .query("oauthAuthorizations")
444
535
  .withIndex("by_user_client", (q) =>
@@ -453,6 +544,7 @@ export const upsertAuthorization = mutation({
453
544
  const mergedScopes = [...new Set([...existing.scopes, ...args.scopes])];
454
545
  await ctx.db.patch(existing._id, {
455
546
  scopes: mergedScopes,
547
+ resource: args.resource ?? existing.resource,
456
548
  lastUsedAt: now,
457
549
  });
458
550
  return existing._id;
@@ -462,6 +554,7 @@ export const upsertAuthorization = mutation({
462
554
  userId: args.userId,
463
555
  clientId: args.clientId,
464
556
  scopes: args.scopes,
557
+ resource: args.resource,
465
558
  authorizedAt: now,
466
559
  lastUsedAt: now,
467
560
  });
@@ -18,6 +18,11 @@ export default defineSchema({
18
18
  clientId: v.string(), // Public ID (UUID v4)
19
19
  clientSecret: v.optional(v.string()), // Hashed Secret (for confidential clients)
20
20
  type: v.union(v.literal("confidential"), v.literal("public")),
21
+ tokenEndpointAuthMethod: v.optional(v.union(
22
+ v.literal("client_secret_basic"),
23
+ v.literal("client_secret_post"),
24
+ v.literal("none"),
25
+ )),
21
26
 
22
27
  redirectUris: v.array(v.string()), // Must be exact match
23
28
  allowedScopes: v.array(v.string()), // e.g. ["openid", "profile", "email"]
@@ -42,9 +47,12 @@ export default defineSchema({
42
47
  codeChallenge: v.string(),
43
48
  codeChallengeMethod: v.string(), // "S256" or "plain"
44
49
  nonce: v.optional(v.string()), // OIDC Nonce
50
+ resource: v.optional(v.string()),
51
+ authTime: v.optional(v.number()), // OIDC auth_time (seconds since epoch)
45
52
 
46
53
  expiresAt: v.number(), // Usually 10 minutes
47
54
  usedAt: v.optional(v.number()), // RFC Line 1136: Track code usage for replay detection
55
+ replayDetectedAt: v.optional(v.number()), // Tombstone marker to reject late token saves
48
56
  }).index("by_code", ["code"]),
49
57
 
50
58
  /**
@@ -64,6 +72,8 @@ export default defineSchema({
64
72
 
65
73
  // RFC Line 1136: Track which authorization code issued this token for replay detection
66
74
  authorizationCode: v.optional(v.string()), // Hashed authorization code
75
+ resource: v.optional(v.string()),
76
+ audience: v.optional(v.string()),
67
77
  })
68
78
  .index("by_access_token", ["accessToken"])
69
79
  .index("by_refresh_token", ["refreshToken"])
@@ -80,6 +90,7 @@ export default defineSchema({
80
90
 
81
91
  // Authorized scopes
82
92
  scopes: v.array(v.string()),
93
+ resource: v.optional(v.string()),
83
94
 
84
95
  // When the user first authorized this client
85
96
  authorizedAt: v.number(),
@@ -1,4 +1,5 @@
1
1
  import { describe, it, expect, beforeEach } from "vitest";
2
+ import { SignJWT, decodeProtectedHeader, importPKCS8 } from "jose";
2
3
  import {
3
4
  sign,
4
5
  verifyAccessToken,
@@ -122,6 +123,73 @@ describe("OAuth JWT and Utilities", () => {
122
123
  expect(payload.iss).toBe("https://example.com");
123
124
  });
124
125
 
126
+ it("should sign access tokens with RFC9068 protected header typ", async () => {
127
+ const token = await sign(
128
+ { scope: "openid profile", client_id: "client", jti: "token-id" },
129
+ "user123",
130
+ "test-audience",
131
+ "1h",
132
+ TEST_PRIVATE_KEY,
133
+ "https://example.com",
134
+ "test-key-1"
135
+ );
136
+
137
+ expect(decodeProtectedHeader(token).typ).toBe("at+jwt");
138
+ const payload = await verifyAccessToken(
139
+ token,
140
+ { jwks: TEST_JWKS },
141
+ "https://example.com",
142
+ "test-audience"
143
+ );
144
+ expect(payload).toMatchObject({
145
+ scope: "openid profile",
146
+ client_id: "client",
147
+ jti: "token-id",
148
+ });
149
+ });
150
+
151
+ it("should reject access tokens without the RFC9068 protected header typ", async () => {
152
+ const privateKey = await importPKCS8(TEST_PRIVATE_KEY, "RS256");
153
+ const token = await new SignJWT({})
154
+ .setProtectedHeader({ alg: "RS256", typ: "JWT", kid: "test-key-1" })
155
+ .setIssuedAt()
156
+ .setIssuer("https://example.com")
157
+ .setSubject("user123")
158
+ .setAudience("test-audience")
159
+ .setExpirationTime("1h")
160
+ .sign(privateKey);
161
+
162
+ await expect(
163
+ verifyAccessToken(
164
+ token,
165
+ { jwks: TEST_JWKS },
166
+ "https://example.com",
167
+ "test-audience"
168
+ )
169
+ ).rejects.toThrow(/typ/);
170
+ });
171
+
172
+ it("should verify access tokens with the RFC9068 media-type protected header typ", async () => {
173
+ const privateKey = await importPKCS8(TEST_PRIVATE_KEY, "RS256");
174
+ const token = await new SignJWT({})
175
+ .setProtectedHeader({ alg: "RS256", typ: "application/at+jwt", kid: "test-key-1" })
176
+ .setIssuedAt()
177
+ .setIssuer("https://example.com")
178
+ .setSubject("user123")
179
+ .setAudience("test-audience")
180
+ .setExpirationTime("1h")
181
+ .sign(privateKey);
182
+
183
+ const payload = await verifyAccessToken(
184
+ token,
185
+ { jwks: TEST_JWKS },
186
+ "https://example.com",
187
+ "test-audience"
188
+ );
189
+
190
+ expect(payload.sub).toBe("user123");
191
+ });
192
+
125
193
  it("should verify with JWKS missing kid", async () => {
126
194
  const token = await sign(
127
195
  { custom: "claim" },
package/src/lib/oauth.ts CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  importSPKI,
7
7
  createLocalJWKSet
8
8
  } from "jose";
9
- import type { JWTPayload, KeyLike } from "jose";
9
+ import type { JWTPayload } from "jose";
10
10
  import type { Auth } from "convex/server";
11
11
  import type { RunActionCtx } from "./convex-types.js";
12
12
 
@@ -177,7 +177,7 @@ export async function sign(
177
177
  const privateKey = await getPrivateKey(privateKeyPEM);
178
178
 
179
179
  const jwt = new SignJWT(payload)
180
- .setProtectedHeader({ alg: "RS256", kid: keyId })
180
+ .setProtectedHeader({ alg: "RS256", typ: "at+jwt", kid: keyId })
181
181
  .setIssuedAt()
182
182
  .setSubject(subject)
183
183
  .setAudience(audience)
@@ -206,10 +206,14 @@ export async function verifyAccessToken(
206
206
  issuer: issuerUrl,
207
207
  audience,
208
208
  };
209
- const { payload } = typeof publicKey === "function"
209
+ const { payload, protectedHeader } = typeof publicKey === "function"
210
210
  ? await jwtVerify(token, publicKey, options)
211
211
  : await jwtVerify(token, publicKey, options);
212
212
 
213
+ if (protectedHeader.typ !== "at+jwt" && protectedHeader.typ !== "application/at+jwt") {
214
+ throw new Error("Invalid access token typ");
215
+ }
216
+
213
217
  return payload;
214
218
  }
215
219
 
@@ -218,7 +222,7 @@ export async function verifyAccessToken(
218
222
  */
219
223
  async function getVerificationKey(
220
224
  config: OAuthConfig
221
- ): Promise<KeyLike | ReturnType<typeof createLocalJWKSet>> {
225
+ ): Promise<ReturnType<typeof createLocalJWKSet>> {
222
226
  const cached = jwksKeyCache.get(config.jwks);
223
227
  if (cached) return cached;
224
228