@codefox-inc/oauth-provider 0.4.1 → 0.4.2
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 +28 -0
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +5 -1
- package/dist/client/index.js.map +1 -1
- package/dist/component/clientManagement.d.ts.map +1 -1
- package/dist/component/clientManagement.js +9 -0
- package/dist/component/clientManagement.js.map +1 -1
- package/dist/component/handlers.d.ts +19 -1
- package/dist/component/handlers.d.ts.map +1 -1
- package/dist/component/handlers.js +76 -15
- package/dist/component/handlers.js.map +1 -1
- package/dist/component/mutations.d.ts +3 -1
- package/dist/component/mutations.d.ts.map +1 -1
- package/dist/component/mutations.js +113 -19
- package/dist/component/mutations.js.map +1 -1
- package/dist/component/queries.d.ts +7 -1
- package/dist/component/queries.d.ts.map +1 -1
- package/dist/component/queries.js +7 -1
- package/dist/component/queries.js.map +1 -1
- package/dist/component/schema.d.ts +7 -1
- package/dist/component/schema.d.ts.map +1 -1
- package/dist/component/schema.js +3 -0
- package/dist/component/schema.js.map +1 -1
- package/dist/lib/oauth.d.ts.map +1 -1
- package/dist/lib/oauth.js +26 -8
- package/dist/lib/oauth.js.map +1 -1
- package/package.json +1 -1
- package/src/client/__tests__/oauth-provider.test.ts +15 -0
- package/src/client/index.ts +6 -1
- package/src/component/__tests__/bugs.test.ts +1001 -0
- package/src/component/__tests__/handlers-protocol.test.ts +148 -0
- package/src/component/__tests__/oauth.test.ts +18 -15
- package/src/component/__tests__/rfc-compliance.test.ts +233 -0
- package/src/component/clientManagement.ts +11 -0
- package/src/component/handlers.ts +116 -18
- package/src/component/mutations.ts +159 -17
- package/src/component/queries.ts +6 -1
- package/src/component/schema.ts +3 -0
- package/src/lib/__tests__/oauth-jwt.test.ts +1 -1
- package/src/lib/oauth.ts +28 -8
|
@@ -245,14 +245,24 @@ export interface OAuthComponentAPI {
|
|
|
245
245
|
allowedScopes: string[];
|
|
246
246
|
tokenEndpointAuthMethod?: TokenEndpointAuthMethod;
|
|
247
247
|
} | null>;
|
|
248
|
+
getAuthorization?: (ctx: RunQueryCtx, args: { userId: string; clientId: string }) => Promise<{
|
|
249
|
+
userId: string;
|
|
250
|
+
clientId: string;
|
|
251
|
+
scopes: string[];
|
|
252
|
+
resource?: string;
|
|
253
|
+
} | null>;
|
|
248
254
|
getRefreshToken: (ctx: RunQueryCtx, args: { refreshToken: string }) => Promise<{
|
|
249
255
|
refreshToken?: string;
|
|
250
256
|
clientId: string;
|
|
251
257
|
userId: string;
|
|
252
258
|
scopes: string[];
|
|
253
259
|
refreshTokenExpiresAt?: number;
|
|
260
|
+
authorizationCode?: string;
|
|
261
|
+
refreshTokenFamilyId?: string;
|
|
262
|
+
refreshTokenRotatedAt?: number;
|
|
254
263
|
resource?: string;
|
|
255
264
|
audience?: string;
|
|
265
|
+
authTime?: number;
|
|
256
266
|
} | null>;
|
|
257
267
|
getTokensByUser: (ctx: RunQueryCtx, args: { userId: string }) => Promise<Array<{
|
|
258
268
|
_id: string;
|
|
@@ -303,6 +313,7 @@ export interface OAuthComponentAPI {
|
|
|
303
313
|
authorizationCode?: string;
|
|
304
314
|
resource?: string;
|
|
305
315
|
audience?: string;
|
|
316
|
+
authTime?: number;
|
|
306
317
|
}) => Promise<void>;
|
|
307
318
|
rotateRefreshToken: (ctx: RunMutationCtx, args: {
|
|
308
319
|
oldRefreshToken: string;
|
|
@@ -315,7 +326,7 @@ export interface OAuthComponentAPI {
|
|
|
315
326
|
refreshTokenExpiresAt: number;
|
|
316
327
|
resource?: string;
|
|
317
328
|
audience?: string;
|
|
318
|
-
}) => Promise<void>;
|
|
329
|
+
}) => Promise<void | { error: string; revokedTokens: number; authorizationDeleted: boolean }>;
|
|
319
330
|
upsertAuthorization: (ctx: RunMutationCtx, args: {
|
|
320
331
|
userId: string;
|
|
321
332
|
clientId: string;
|
|
@@ -437,6 +448,15 @@ export async function authorizeHandler(
|
|
|
437
448
|
);
|
|
438
449
|
}
|
|
439
450
|
|
|
451
|
+
if (params.has("request") || params.has("request_uri")) {
|
|
452
|
+
return buildAuthorizeErrorRedirect(
|
|
453
|
+
redirectUri,
|
|
454
|
+
"invalid_request",
|
|
455
|
+
"request and request_uri parameters are not supported",
|
|
456
|
+
state
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
|
|
440
460
|
if (consent === "approve" && !isConsentFromProvider(request, config)) {
|
|
441
461
|
return buildAuthorizeErrorRedirect(
|
|
442
462
|
redirectUri,
|
|
@@ -485,7 +505,11 @@ export async function authorizeHandler(
|
|
|
485
505
|
let requestedScopes = scope
|
|
486
506
|
? scope.split(" ").filter(Boolean)
|
|
487
507
|
: [];
|
|
488
|
-
if (
|
|
508
|
+
if (
|
|
509
|
+
requestedScopes.includes("offline_access") &&
|
|
510
|
+
!promptValues.has("consent") &&
|
|
511
|
+
!promptValues.has("none")
|
|
512
|
+
) {
|
|
489
513
|
requestedScopes = requestedScopes.filter((s) => s !== "offline_access");
|
|
490
514
|
}
|
|
491
515
|
if (requestedScopes.length === 0) {
|
|
@@ -531,7 +555,52 @@ export async function authorizeHandler(
|
|
|
531
555
|
);
|
|
532
556
|
}
|
|
533
557
|
|
|
534
|
-
if (
|
|
558
|
+
if (!config.getUserId) {
|
|
559
|
+
return new OAuthError("server_error", "getUserId is not configured", 500).toResponse(headers);
|
|
560
|
+
}
|
|
561
|
+
const userId = await config.getUserId(ctx as RunActionCtx & { auth: Auth }, request);
|
|
562
|
+
|
|
563
|
+
if (promptValues.has("none")) {
|
|
564
|
+
if (promptValues.size > 1) {
|
|
565
|
+
return buildAuthorizeErrorRedirect(
|
|
566
|
+
redirectUri,
|
|
567
|
+
"invalid_request",
|
|
568
|
+
"prompt=none cannot be combined with other prompt values",
|
|
569
|
+
state
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
if (!userId) {
|
|
573
|
+
return buildAuthorizeErrorRedirect(
|
|
574
|
+
redirectUri,
|
|
575
|
+
"login_required",
|
|
576
|
+
"User not authenticated",
|
|
577
|
+
state
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
if (consent !== "approve") {
|
|
581
|
+
if (!api.queries.getAuthorization) {
|
|
582
|
+
return buildAuthorizeErrorRedirect(
|
|
583
|
+
redirectUri,
|
|
584
|
+
"server_error",
|
|
585
|
+
"OAuth component API is out of date; regenerate component API references",
|
|
586
|
+
state
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
const authorization = await api.queries.getAuthorization(ctx, { userId, clientId });
|
|
590
|
+
const hasScopes = authorization !== null &&
|
|
591
|
+
requestedScopes.every((scope) => authorization.scopes.includes(scope));
|
|
592
|
+
const hasResource = authorization !== null &&
|
|
593
|
+
authorization.resource === (resource ?? undefined);
|
|
594
|
+
if (!hasScopes || !hasResource) {
|
|
595
|
+
return buildAuthorizeErrorRedirect(
|
|
596
|
+
redirectUri,
|
|
597
|
+
"consent_required",
|
|
598
|
+
"User consent required",
|
|
599
|
+
state
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
} else if (consent !== "approve") {
|
|
535
604
|
return buildAuthorizeErrorRedirect(
|
|
536
605
|
redirectUri,
|
|
537
606
|
"access_denied",
|
|
@@ -540,10 +609,6 @@ export async function authorizeHandler(
|
|
|
540
609
|
);
|
|
541
610
|
}
|
|
542
611
|
|
|
543
|
-
if (!config.getUserId) {
|
|
544
|
-
return new OAuthError("server_error", "getUserId is not configured", 500).toResponse(headers);
|
|
545
|
-
}
|
|
546
|
-
const userId = await config.getUserId(ctx as RunActionCtx & { auth: Auth }, request);
|
|
547
612
|
if (!userId) {
|
|
548
613
|
return buildAuthorizeErrorRedirect(
|
|
549
614
|
redirectUri,
|
|
@@ -586,7 +651,7 @@ export async function openIdConfigurationHandler(
|
|
|
586
651
|
if (corsResponse) return corsResponse;
|
|
587
652
|
const headers = createCorsHeaders(request.headers.get("Origin"), config, "GET, OPTIONS");
|
|
588
653
|
|
|
589
|
-
const backendUrl = config.convexSiteUrl ?? config.siteUrl;
|
|
654
|
+
const backendUrl = (config.convexSiteUrl ?? config.siteUrl).replace(/\/+$/, "");
|
|
590
655
|
const prefix = normalizePrefix(config.prefix);
|
|
591
656
|
|
|
592
657
|
const issuerUrl = getIssuerUrl(config);
|
|
@@ -607,6 +672,9 @@ export async function openIdConfigurationHandler(
|
|
|
607
672
|
token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post", "none"],
|
|
608
673
|
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
609
674
|
code_challenge_methods_supported: ["S256"],
|
|
675
|
+
request_uri_parameter_supported: false,
|
|
676
|
+
request_parameter_supported: false,
|
|
677
|
+
claims_parameter_supported: false,
|
|
610
678
|
};
|
|
611
679
|
|
|
612
680
|
if (config.allowDynamicClientRegistration) {
|
|
@@ -695,12 +763,16 @@ export async function tokenHandler(
|
|
|
695
763
|
throw new OAuthError("invalid_request", "Multiple client authentication methods");
|
|
696
764
|
}
|
|
697
765
|
const basicCredentials = parseBasicClientCredentials(authHeader);
|
|
766
|
+
if (bodyClientId && bodyClientId !== basicCredentials.clientId) {
|
|
767
|
+
throw new OAuthError("invalid_request", "Conflicting client_id");
|
|
768
|
+
}
|
|
698
769
|
clientId = basicCredentials.clientId;
|
|
699
770
|
clientSecret = basicCredentials.clientSecret;
|
|
700
771
|
usedAuthMethod = "client_secret_basic";
|
|
701
772
|
}
|
|
702
773
|
|
|
703
774
|
if (!clientId) throw new OAuthError("invalid_request", "client_id required");
|
|
775
|
+
if (!grantType) throw new OAuthError("invalid_request", "grant_type required");
|
|
704
776
|
|
|
705
777
|
// Client existence + confidential client check
|
|
706
778
|
const client = await api.queries.getClient(ctx, { clientId });
|
|
@@ -816,6 +888,7 @@ export async function tokenHandler(
|
|
|
816
888
|
authorizationCode: codeData.codeHash, // Link to authorization code
|
|
817
889
|
resource: codeData.resource,
|
|
818
890
|
audience: accessTokenAudience,
|
|
891
|
+
authTime: codeData.authTime,
|
|
819
892
|
});
|
|
820
893
|
|
|
821
894
|
// F. Create/Update Authorization Record
|
|
@@ -858,6 +931,23 @@ export async function tokenHandler(
|
|
|
858
931
|
const oldToken = await api.queries.getRefreshToken(ctx, { refreshToken });
|
|
859
932
|
|
|
860
933
|
if (!oldToken) throw new OAuthError("invalid_grant", "Invalid refresh token");
|
|
934
|
+
if (oldToken.refreshTokenRotatedAt !== undefined) {
|
|
935
|
+
// rotateRefreshToken detects tombstones before storing the supplied replacement tokens.
|
|
936
|
+
await api.mutations.rotateRefreshToken(ctx, {
|
|
937
|
+
oldRefreshToken: refreshToken,
|
|
938
|
+
accessToken: "refresh-token-reuse-detected",
|
|
939
|
+
refreshToken: "refresh-token-reuse-detected",
|
|
940
|
+
clientId: oldToken.clientId,
|
|
941
|
+
userId: oldToken.userId,
|
|
942
|
+
scopes: oldToken.scopes,
|
|
943
|
+
expiresAt: Date.now(),
|
|
944
|
+
refreshTokenExpiresAt: Date.now(),
|
|
945
|
+
resource: oldToken.resource,
|
|
946
|
+
audience: oldToken.audience,
|
|
947
|
+
});
|
|
948
|
+
throw new OAuthError("invalid_grant", "Invalid refresh token");
|
|
949
|
+
}
|
|
950
|
+
if (oldToken.clientId !== clientId) throw new OAuthError("invalid_grant", "Client mismatch");
|
|
861
951
|
const refreshTokenResource = oldToken.resource;
|
|
862
952
|
const refreshTokenAudience = oldToken.audience ?? refreshTokenResource ?? config.applicationID ?? "convex";
|
|
863
953
|
const accessTokenAudience = refreshTokenResource ?? refreshTokenAudience;
|
|
@@ -872,8 +962,6 @@ export async function tokenHandler(
|
|
|
872
962
|
throw new OAuthError("invalid_grant", "Refresh token expired");
|
|
873
963
|
}
|
|
874
964
|
|
|
875
|
-
if (oldToken.clientId !== clientId) throw new OAuthError("invalid_grant", "Client mismatch");
|
|
876
|
-
|
|
877
965
|
const userId = oldToken.userId;
|
|
878
966
|
|
|
879
967
|
// RFC 6749 Section 6: スコープパラメータ処理(アクセストークン用)
|
|
@@ -943,6 +1031,7 @@ export async function tokenHandler(
|
|
|
943
1031
|
sub: userId,
|
|
944
1032
|
iss: issuerUrl,
|
|
945
1033
|
aud: clientId,
|
|
1034
|
+
auth_time: oldToken.authTime,
|
|
946
1035
|
})
|
|
947
1036
|
.setProtectedHeader({ alg: "RS256", typ: "JWT", kid: keyId })
|
|
948
1037
|
.setIssuedAt()
|
|
@@ -952,7 +1041,7 @@ export async function tokenHandler(
|
|
|
952
1041
|
|
|
953
1042
|
// Rotate - 元のスコープ維持
|
|
954
1043
|
try {
|
|
955
|
-
await api.mutations.rotateRefreshToken(ctx, {
|
|
1044
|
+
const rotationResult = await api.mutations.rotateRefreshToken(ctx, {
|
|
956
1045
|
oldRefreshToken: refreshToken,
|
|
957
1046
|
accessToken,
|
|
958
1047
|
refreshToken: newRefreshToken,
|
|
@@ -964,6 +1053,9 @@ export async function tokenHandler(
|
|
|
964
1053
|
resource: refreshTokenResource,
|
|
965
1054
|
audience: refreshTokenAudience,
|
|
966
1055
|
});
|
|
1056
|
+
if (rotationResult && "error" in rotationResult && rotationResult.error === "refresh_token_reuse_detected") {
|
|
1057
|
+
throw new OAuthError("invalid_grant", "Invalid refresh token");
|
|
1058
|
+
}
|
|
967
1059
|
|
|
968
1060
|
// Update authorization lastUsedAt
|
|
969
1061
|
await api.mutations.updateAuthorizationLastUsed(ctx, {
|
|
@@ -1038,8 +1130,7 @@ export async function tokenHandler(
|
|
|
1038
1130
|
return new OAuthError("invalid_scope", e.message).toResponse(tokenHeaders);
|
|
1039
1131
|
}
|
|
1040
1132
|
}
|
|
1041
|
-
|
|
1042
|
-
return new OAuthError("invalid_request", message).toResponse(tokenHeaders);
|
|
1133
|
+
return new OAuthError("invalid_request", "Invalid request").toResponse(tokenHeaders);
|
|
1043
1134
|
}
|
|
1044
1135
|
}
|
|
1045
1136
|
|
|
@@ -1057,7 +1148,8 @@ export async function userInfoHandler(
|
|
|
1057
1148
|
const headers = createCorsHeaders(request.headers.get("Origin"), config, "GET, POST, OPTIONS");
|
|
1058
1149
|
|
|
1059
1150
|
const authHeader = request.headers.get("Authorization");
|
|
1060
|
-
|
|
1151
|
+
const authMatch = authHeader?.match(/^Bearer\s+(.+)$/i);
|
|
1152
|
+
if (!authMatch) {
|
|
1061
1153
|
return new Response(null, {
|
|
1062
1154
|
status: 401,
|
|
1063
1155
|
headers: {
|
|
@@ -1067,7 +1159,7 @@ export async function userInfoHandler(
|
|
|
1067
1159
|
});
|
|
1068
1160
|
}
|
|
1069
1161
|
|
|
1070
|
-
const token =
|
|
1162
|
+
const token = authMatch[1];
|
|
1071
1163
|
|
|
1072
1164
|
try {
|
|
1073
1165
|
const issuerUrl = getIssuerUrl(config);
|
|
@@ -1126,7 +1218,14 @@ export async function userInfoHandler(
|
|
|
1126
1218
|
responseBody.email_verified = user.email_verified;
|
|
1127
1219
|
}
|
|
1128
1220
|
|
|
1129
|
-
return new Response(JSON.stringify(responseBody), {
|
|
1221
|
+
return new Response(JSON.stringify(responseBody), {
|
|
1222
|
+
headers: {
|
|
1223
|
+
...headers,
|
|
1224
|
+
"Content-Type": "application/json",
|
|
1225
|
+
"Cache-Control": "no-store",
|
|
1226
|
+
"Pragma": "no-cache",
|
|
1227
|
+
},
|
|
1228
|
+
});
|
|
1130
1229
|
|
|
1131
1230
|
} catch {
|
|
1132
1231
|
return new Response(null, {
|
|
@@ -1257,8 +1356,7 @@ export async function registerHandler(
|
|
|
1257
1356
|
if (e instanceof OAuthError) {
|
|
1258
1357
|
return e.toResponse(headers);
|
|
1259
1358
|
}
|
|
1260
|
-
|
|
1261
|
-
return new OAuthError("invalid_request", message).toResponse(headers);
|
|
1359
|
+
return new OAuthError("invalid_request", "Invalid client metadata").toResponse(headers);
|
|
1262
1360
|
}
|
|
1263
1361
|
}
|
|
1264
1362
|
|
|
@@ -21,6 +21,7 @@ export function isLoopbackRedirectUri(uri: string): boolean {
|
|
|
21
21
|
return (
|
|
22
22
|
parsed.hostname === "127.0.0.1" ||
|
|
23
23
|
parsed.hostname === "::1" ||
|
|
24
|
+
parsed.hostname === "[::1]" ||
|
|
24
25
|
parsed.hostname === "localhost"
|
|
25
26
|
);
|
|
26
27
|
} catch {
|
|
@@ -49,12 +50,50 @@ function validateResourceUri(resource: string | undefined): void {
|
|
|
49
50
|
}
|
|
50
51
|
}
|
|
51
52
|
|
|
53
|
+
async function revokeTokenFamily(ctx: any, token: {
|
|
54
|
+
clientId: string;
|
|
55
|
+
userId: string;
|
|
56
|
+
authorizationCode?: string;
|
|
57
|
+
refreshTokenFamilyId?: string;
|
|
58
|
+
}) {
|
|
59
|
+
const tokens = await ctx.db
|
|
60
|
+
.query("oauthTokens")
|
|
61
|
+
.withIndex("by_user", (q: any) => q.eq("userId", token.userId))
|
|
62
|
+
.collect();
|
|
63
|
+
const toDelete = tokens.filter((candidate: any) =>
|
|
64
|
+
candidate.clientId === token.clientId &&
|
|
65
|
+
(
|
|
66
|
+
(token.refreshTokenFamilyId !== undefined && candidate.refreshTokenFamilyId === token.refreshTokenFamilyId) ||
|
|
67
|
+
(token.authorizationCode !== undefined && candidate.authorizationCode === token.authorizationCode)
|
|
68
|
+
)
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
for (const candidate of toDelete) {
|
|
72
|
+
await ctx.db.delete(candidate._id);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const authorization = await ctx.db
|
|
76
|
+
.query("oauthAuthorizations")
|
|
77
|
+
.withIndex("by_user_client", (q: any) =>
|
|
78
|
+
q.eq("userId", token.userId).eq("clientId", token.clientId)
|
|
79
|
+
)
|
|
80
|
+
.unique();
|
|
81
|
+
if (authorization) {
|
|
82
|
+
await ctx.db.delete(authorization._id);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
revokedTokens: toDelete.length,
|
|
87
|
+
authorizationDeleted: authorization !== null,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
52
91
|
async function verifyPkce(
|
|
53
92
|
codeChallengeMethod: string,
|
|
54
93
|
codeChallenge: string,
|
|
55
94
|
codeVerifier: string
|
|
56
95
|
) {
|
|
57
|
-
if (codeChallengeMethod !== "S256"
|
|
96
|
+
if (codeChallengeMethod !== "S256") {
|
|
58
97
|
throw new Error("unsupported_code_challenge_method");
|
|
59
98
|
}
|
|
60
99
|
|
|
@@ -75,12 +114,6 @@ async function verifyPkce(
|
|
|
75
114
|
if (hashBase64 !== codeChallenge) {
|
|
76
115
|
throw new Error("invalid_code_verifier");
|
|
77
116
|
}
|
|
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
117
|
}
|
|
85
118
|
}
|
|
86
119
|
|
|
@@ -282,12 +315,23 @@ export const consumeAuthCode = mutation({
|
|
|
282
315
|
await ctx.db.delete(token._id);
|
|
283
316
|
}
|
|
284
317
|
|
|
318
|
+
const authorization = await ctx.db
|
|
319
|
+
.query("oauthAuthorizations")
|
|
320
|
+
.withIndex("by_user_client", (q) =>
|
|
321
|
+
q.eq("userId", authCode.userId).eq("clientId", authCode.clientId)
|
|
322
|
+
)
|
|
323
|
+
.unique();
|
|
324
|
+
if (authorization) {
|
|
325
|
+
await ctx.db.delete(authorization._id);
|
|
326
|
+
}
|
|
327
|
+
|
|
285
328
|
await ctx.db.patch(authCode._id, { replayDetectedAt: Date.now() });
|
|
286
329
|
|
|
287
330
|
// Return error status (cannot throw because it would rollback token deletion)
|
|
288
331
|
return {
|
|
289
332
|
error: "authorization_code_reuse_detected",
|
|
290
333
|
revokedTokens: tokensToRevoke.length,
|
|
334
|
+
authorizationDeleted: authorization !== null,
|
|
291
335
|
} as any;
|
|
292
336
|
}
|
|
293
337
|
|
|
@@ -334,6 +378,7 @@ export const saveTokens = mutation({
|
|
|
334
378
|
authorizationCode: v.optional(v.string()), // Hashed code for replay detection (RFC Line 1136)
|
|
335
379
|
resource: v.optional(v.string()),
|
|
336
380
|
audience: v.optional(v.string()),
|
|
381
|
+
authTime: v.optional(v.number()),
|
|
337
382
|
},
|
|
338
383
|
handler: async (ctx, args) => {
|
|
339
384
|
const authorizationCode = args.authorizationCode;
|
|
@@ -356,6 +401,9 @@ export const saveTokens = mutation({
|
|
|
356
401
|
refreshToken: args.refreshToken
|
|
357
402
|
? await hashToken(args.refreshToken)
|
|
358
403
|
: undefined,
|
|
404
|
+
refreshTokenFamilyId: args.refreshToken
|
|
405
|
+
? (args.authorizationCode ?? crypto.randomUUID())
|
|
406
|
+
: undefined,
|
|
359
407
|
});
|
|
360
408
|
},
|
|
361
409
|
});
|
|
@@ -402,6 +450,14 @@ export const rotateRefreshToken = mutation({
|
|
|
402
450
|
throw new Error("invalid_grant");
|
|
403
451
|
}
|
|
404
452
|
|
|
453
|
+
if (oldToken.refreshTokenRotatedAt !== undefined) {
|
|
454
|
+
const result = await revokeTokenFamily(ctx, oldToken);
|
|
455
|
+
return {
|
|
456
|
+
error: "refresh_token_reuse_detected",
|
|
457
|
+
...result,
|
|
458
|
+
} as any;
|
|
459
|
+
}
|
|
460
|
+
|
|
405
461
|
// 2. Validate Client/User consistency
|
|
406
462
|
if (oldToken.clientId !== args.clientId || oldToken.userId !== args.userId) {
|
|
407
463
|
throw new Error("invalid_grant");
|
|
@@ -437,8 +493,13 @@ export const rotateRefreshToken = mutation({
|
|
|
437
493
|
throw new Error(`invalid_scope: ${invalidScopes.join(", ")}`);
|
|
438
494
|
}
|
|
439
495
|
|
|
440
|
-
|
|
441
|
-
|
|
496
|
+
const refreshTokenFamilyId = oldToken.refreshTokenFamilyId ?? oldToken.authorizationCode ?? crypto.randomUUID();
|
|
497
|
+
|
|
498
|
+
// 5. Keep old refresh token as a tombstone so reuse can revoke the family.
|
|
499
|
+
await ctx.db.patch(oldToken._id, {
|
|
500
|
+
refreshTokenFamilyId,
|
|
501
|
+
refreshTokenRotatedAt: Date.now(),
|
|
502
|
+
});
|
|
442
503
|
|
|
443
504
|
// 6. Insert New Token (with hashed values)
|
|
444
505
|
await ctx.db.insert("oauthTokens", {
|
|
@@ -451,6 +512,9 @@ export const rotateRefreshToken = mutation({
|
|
|
451
512
|
refreshTokenExpiresAt: args.refreshTokenExpiresAt,
|
|
452
513
|
resource: args.resource,
|
|
453
514
|
audience: args.audience,
|
|
515
|
+
authorizationCode: oldToken.authorizationCode,
|
|
516
|
+
refreshTokenFamilyId,
|
|
517
|
+
authTime: oldToken.authTime,
|
|
454
518
|
});
|
|
455
519
|
},
|
|
456
520
|
});
|
|
@@ -472,6 +536,30 @@ export const deleteClient = mutation({
|
|
|
472
536
|
throw new Error("Client not found");
|
|
473
537
|
}
|
|
474
538
|
|
|
539
|
+
const tokens = await ctx.db
|
|
540
|
+
.query("oauthTokens")
|
|
541
|
+
.filter((q) => q.eq(q.field("clientId"), args.clientId))
|
|
542
|
+
.collect();
|
|
543
|
+
for (const token of tokens) {
|
|
544
|
+
await ctx.db.delete(token._id);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const codes = await ctx.db
|
|
548
|
+
.query("oauthCodes")
|
|
549
|
+
.filter((q) => q.eq(q.field("clientId"), args.clientId))
|
|
550
|
+
.collect();
|
|
551
|
+
for (const code of codes) {
|
|
552
|
+
await ctx.db.delete(code._id);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const authorizations = await ctx.db
|
|
556
|
+
.query("oauthAuthorizations")
|
|
557
|
+
.filter((q) => q.eq(q.field("clientId"), args.clientId))
|
|
558
|
+
.collect();
|
|
559
|
+
for (const authorization of authorizations) {
|
|
560
|
+
await ctx.db.delete(authorization._id);
|
|
561
|
+
}
|
|
562
|
+
|
|
475
563
|
await ctx.db.delete(client._id);
|
|
476
564
|
},
|
|
477
565
|
});
|
|
@@ -485,24 +573,65 @@ export const cleanupExpired = internalMutation({
|
|
|
485
573
|
handler: async (ctx) => {
|
|
486
574
|
const now = Date.now();
|
|
487
575
|
|
|
488
|
-
// Cleanup expired codes
|
|
576
|
+
// Cleanup unused expired codes. Used codes are replay-detection tombstones and
|
|
577
|
+
// must outlive refresh tokens minted from the same authorization.
|
|
489
578
|
const expiredCodes = await ctx.db
|
|
490
579
|
.query("oauthCodes")
|
|
491
580
|
.filter(q => q.lt(q.field("expiresAt"), now))
|
|
492
581
|
.take(100);
|
|
493
582
|
|
|
494
583
|
for (const code of expiredCodes) {
|
|
495
|
-
await ctx.db
|
|
584
|
+
const descendantTokens = await ctx.db
|
|
585
|
+
.query("oauthTokens")
|
|
586
|
+
.withIndex("by_authorization_code", (q) => q.eq("authorizationCode", code.code))
|
|
587
|
+
.collect();
|
|
588
|
+
const maxDescendantRefreshExpiry = Math.max(
|
|
589
|
+
0,
|
|
590
|
+
...descendantTokens.map((token) => token.refreshTokenExpiresAt ?? 0)
|
|
591
|
+
);
|
|
592
|
+
const replayRetentionUntil = Math.max(
|
|
593
|
+
(code.usedAt ?? code.replayDetectedAt ?? 0) + OAUTH_CONSTANTS.REFRESH_TOKEN_EXPIRY_MS,
|
|
594
|
+
maxDescendantRefreshExpiry
|
|
595
|
+
);
|
|
596
|
+
if (code.usedAt === undefined && code.replayDetectedAt === undefined) {
|
|
597
|
+
await ctx.db.delete(code._id);
|
|
598
|
+
} else if (replayRetentionUntil < now) {
|
|
599
|
+
await ctx.db.delete(code._id);
|
|
600
|
+
}
|
|
496
601
|
}
|
|
497
602
|
|
|
498
|
-
// Cleanup expired
|
|
603
|
+
// Cleanup tokens only after every credential stored in the row has expired.
|
|
499
604
|
const expiredTokens = await ctx.db
|
|
500
605
|
.query("oauthTokens")
|
|
501
606
|
.filter(q => q.lt(q.field("expiresAt"), now))
|
|
502
607
|
.take(100);
|
|
503
608
|
|
|
504
609
|
for (const token of expiredTokens) {
|
|
505
|
-
|
|
610
|
+
const familyTokens = token.refreshTokenFamilyId
|
|
611
|
+
? await ctx.db
|
|
612
|
+
.query("oauthTokens")
|
|
613
|
+
.filter((q) =>
|
|
614
|
+
q.and(
|
|
615
|
+
q.eq(q.field("clientId"), token.clientId),
|
|
616
|
+
q.eq(q.field("userId"), token.userId),
|
|
617
|
+
q.eq(q.field("refreshTokenFamilyId"), token.refreshTokenFamilyId)
|
|
618
|
+
)
|
|
619
|
+
)
|
|
620
|
+
.collect()
|
|
621
|
+
: [];
|
|
622
|
+
const maxFamilyRefreshExpiry = Math.max(
|
|
623
|
+
0,
|
|
624
|
+
...familyTokens.map((familyToken) => familyToken.refreshTokenExpiresAt ?? 0)
|
|
625
|
+
);
|
|
626
|
+
const shouldKeepTombstone =
|
|
627
|
+
token.refreshTokenRotatedAt !== undefined &&
|
|
628
|
+
maxFamilyRefreshExpiry >= now;
|
|
629
|
+
if (
|
|
630
|
+
!shouldKeepTombstone &&
|
|
631
|
+
(!token.refreshToken || !token.refreshTokenExpiresAt || token.refreshTokenExpiresAt < now)
|
|
632
|
+
) {
|
|
633
|
+
await ctx.db.delete(token._id);
|
|
634
|
+
}
|
|
506
635
|
}
|
|
507
636
|
|
|
508
637
|
return {
|
|
@@ -540,11 +669,9 @@ export const upsertAuthorization = mutation({
|
|
|
540
669
|
const now = Date.now();
|
|
541
670
|
|
|
542
671
|
if (existing) {
|
|
543
|
-
// Update: merge scopes, update lastUsedAt
|
|
544
|
-
const mergedScopes = [...new Set([...existing.scopes, ...args.scopes])];
|
|
545
672
|
await ctx.db.patch(existing._id, {
|
|
546
|
-
scopes:
|
|
547
|
-
resource: args.resource
|
|
673
|
+
scopes: args.scopes,
|
|
674
|
+
resource: args.resource,
|
|
548
675
|
lastUsedAt: now,
|
|
549
676
|
});
|
|
550
677
|
return existing._id;
|
|
@@ -616,9 +743,24 @@ export const revokeAuthorization = mutation({
|
|
|
616
743
|
await ctx.db.delete(token._id);
|
|
617
744
|
}
|
|
618
745
|
|
|
746
|
+
// 3. Delete pending authorization codes for this user-client pair.
|
|
747
|
+
const codes = (await ctx.db
|
|
748
|
+
.query("oauthCodes")
|
|
749
|
+
.filter((q) =>
|
|
750
|
+
q.and(
|
|
751
|
+
q.eq(q.field("userId"), args.userId),
|
|
752
|
+
q.eq(q.field("clientId"), args.clientId)
|
|
753
|
+
)
|
|
754
|
+
)
|
|
755
|
+
.collect()).filter((code) => code.usedAt === undefined);
|
|
756
|
+
for (const code of codes) {
|
|
757
|
+
await ctx.db.delete(code._id);
|
|
758
|
+
}
|
|
759
|
+
|
|
619
760
|
return {
|
|
620
761
|
authorizationDeleted: !!auth,
|
|
621
762
|
tokensDeleted: toDelete.length,
|
|
763
|
+
codesDeleted: codes.length,
|
|
622
764
|
};
|
|
623
765
|
},
|
|
624
766
|
});
|
package/src/component/queries.ts
CHANGED
|
@@ -8,10 +8,15 @@ import { hashToken, isHashedToken } from "./token_security.js";
|
|
|
8
8
|
export const getClient = query({
|
|
9
9
|
args: { clientId: v.string() },
|
|
10
10
|
handler: async (ctx, args) => {
|
|
11
|
-
|
|
11
|
+
const client = await ctx.db
|
|
12
12
|
.query("oauthClients")
|
|
13
13
|
.withIndex("by_client_id", (q) => q.eq("clientId", args.clientId))
|
|
14
14
|
.unique();
|
|
15
|
+
if (!client) return null;
|
|
16
|
+
return {
|
|
17
|
+
...client,
|
|
18
|
+
clientSecret: undefined,
|
|
19
|
+
};
|
|
15
20
|
},
|
|
16
21
|
});
|
|
17
22
|
|
package/src/component/schema.ts
CHANGED
|
@@ -72,8 +72,11 @@ export default defineSchema({
|
|
|
72
72
|
|
|
73
73
|
// RFC Line 1136: Track which authorization code issued this token for replay detection
|
|
74
74
|
authorizationCode: v.optional(v.string()), // Hashed authorization code
|
|
75
|
+
refreshTokenFamilyId: v.optional(v.string()),
|
|
76
|
+
refreshTokenRotatedAt: v.optional(v.number()),
|
|
75
77
|
resource: v.optional(v.string()),
|
|
76
78
|
audience: v.optional(v.string()),
|
|
79
|
+
authTime: v.optional(v.number()),
|
|
77
80
|
})
|
|
78
81
|
.index("by_access_token", ["accessToken"])
|
|
79
82
|
.index("by_refresh_token", ["refreshToken"])
|
|
@@ -402,7 +402,7 @@ describe("OAuth JWT and Utilities", () => {
|
|
|
402
402
|
privateKey: TEST_PRIVATE_KEY,
|
|
403
403
|
keyId: "override-key"
|
|
404
404
|
};
|
|
405
|
-
expect(getSigningKeyId(configWithOverride)).
|
|
405
|
+
expect(() => getSigningKeyId(configWithOverride)).toThrow(/not present in JWKS/);
|
|
406
406
|
|
|
407
407
|
const configWithMissingKid: OAuthConfig = {
|
|
408
408
|
siteUrl: "https://example.com",
|