@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.
- 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 +278 -29
- 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 +914 -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 +358 -32
- 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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
});
|
package/src/component/schema.ts
CHANGED
|
@@ -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
|
|
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<
|
|
225
|
+
): Promise<ReturnType<typeof createLocalJWKSet>> {
|
|
222
226
|
const cached = jwksKeyCache.get(config.jwks);
|
|
223
227
|
if (cached) return cached;
|
|
224
228
|
|