@codefox-inc/oauth-provider 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +572 -0
- package/dist/client/_generated/_ignore.d.ts +1 -0
- package/dist/client/_generated/_ignore.d.ts.map +1 -0
- package/dist/client/_generated/_ignore.js +3 -0
- package/dist/client/_generated/_ignore.js.map +1 -0
- package/dist/client/auth-config.d.ts +85 -0
- package/dist/client/auth-config.d.ts.map +1 -0
- package/dist/client/auth-config.js +81 -0
- package/dist/client/auth-config.js.map +1 -0
- package/dist/client/auth-helper.d.ts +81 -0
- package/dist/client/auth-helper.d.ts.map +1 -0
- package/dist/client/auth-helper.js +97 -0
- package/dist/client/auth-helper.js.map +1 -0
- package/dist/client/index.d.ts +189 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +230 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/routes.d.ts +94 -0
- package/dist/client/routes.d.ts.map +1 -0
- package/dist/client/routes.js +113 -0
- package/dist/client/routes.js.map +1 -0
- package/dist/component/_generated/api.d.ts +44 -0
- package/dist/component/_generated/api.d.ts.map +1 -0
- package/dist/component/_generated/api.js +31 -0
- package/dist/component/_generated/api.js.map +1 -0
- package/dist/component/_generated/component.d.ts +123 -0
- package/dist/component/_generated/component.d.ts.map +1 -0
- package/dist/component/_generated/component.js +11 -0
- package/dist/component/_generated/component.js.map +1 -0
- package/dist/component/_generated/dataModel.d.ts +46 -0
- package/dist/component/_generated/dataModel.d.ts.map +1 -0
- package/dist/component/_generated/dataModel.js +11 -0
- package/dist/component/_generated/dataModel.js.map +1 -0
- package/dist/component/_generated/server.d.ts +121 -0
- package/dist/component/_generated/server.d.ts.map +1 -0
- package/dist/component/_generated/server.js +78 -0
- package/dist/component/_generated/server.js.map +1 -0
- package/dist/component/clientManagement.d.ts +39 -0
- package/dist/component/clientManagement.d.ts.map +1 -0
- package/dist/component/clientManagement.js +169 -0
- package/dist/component/clientManagement.js.map +1 -0
- package/dist/component/constants.d.ts +31 -0
- package/dist/component/constants.d.ts.map +1 -0
- package/dist/component/constants.js +36 -0
- package/dist/component/constants.js.map +1 -0
- package/dist/component/convex.config.d.ts +3 -0
- package/dist/component/convex.config.d.ts.map +1 -0
- package/dist/component/convex.config.js +3 -0
- package/dist/component/convex.config.js.map +1 -0
- package/dist/component/handlers.d.ts +143 -0
- package/dist/component/handlers.d.ts.map +1 -0
- package/dist/component/handlers.js +624 -0
- package/dist/component/handlers.js.map +1 -0
- package/dist/component/mutations.d.ts +111 -0
- package/dist/component/mutations.d.ts.map +1 -0
- package/dist/component/mutations.js +459 -0
- package/dist/component/mutations.js.map +1 -0
- package/dist/component/queries.d.ts +127 -0
- package/dist/component/queries.d.ts.map +1 -0
- package/dist/component/queries.js +145 -0
- package/dist/component/queries.js.map +1 -0
- package/dist/component/schema.d.ts +116 -0
- package/dist/component/schema.d.ts.map +1 -0
- package/dist/component/schema.js +77 -0
- package/dist/component/schema.js.map +1 -0
- package/dist/component/token_security.d.ts +53 -0
- package/dist/component/token_security.d.ts.map +1 -0
- package/dist/component/token_security.js +91 -0
- package/dist/component/token_security.js.map +1 -0
- package/dist/lib/convex-types.d.ts +21 -0
- package/dist/lib/convex-types.d.ts.map +1 -0
- package/dist/lib/convex-types.js +2 -0
- package/dist/lib/convex-types.js.map +1 -0
- package/dist/lib/oauth.d.ts +123 -0
- package/dist/lib/oauth.d.ts.map +1 -0
- package/dist/lib/oauth.js +295 -0
- package/dist/lib/oauth.js.map +1 -0
- package/dist/react/index.d.ts +2 -0
- package/dist/react/index.d.ts.map +1 -0
- package/dist/react/index.js +6 -0
- package/dist/react/index.js.map +1 -0
- package/package.json +121 -0
- package/src/client/__tests__/auth-config.test.ts +244 -0
- package/src/client/__tests__/auth-helper.test.ts +273 -0
- package/src/client/__tests__/oauth-provider.test.ts +418 -0
- package/src/client/__tests__/routes.test.ts +428 -0
- package/src/client/_generated/_ignore.ts +1 -0
- package/src/client/auth-config.ts +157 -0
- package/src/client/auth-helper.ts +201 -0
- package/src/client/index.ts +326 -0
- package/src/client/routes.ts +251 -0
- package/src/component/__tests__/oauth.test.ts +3310 -0
- package/src/component/__tests__/rfc-compliance.test.ts +788 -0
- package/src/component/__tests__/token-security.test.ts +133 -0
- package/src/component/_generated/api.ts +60 -0
- package/src/component/_generated/component.ts +201 -0
- package/src/component/_generated/dataModel.ts +60 -0
- package/src/component/_generated/server.ts +156 -0
- package/src/component/clientManagement.ts +189 -0
- package/src/component/constants.ts +40 -0
- package/src/component/convex.config.ts +3 -0
- package/src/component/handlers.ts +964 -0
- package/src/component/mutations.ts +531 -0
- package/src/component/queries.ts +165 -0
- package/src/component/schema.ts +92 -0
- package/src/component/token_security.ts +102 -0
- package/src/lib/__tests__/oauth-helpers.test.ts +143 -0
- package/src/lib/__tests__/oauth-jwt.test.ts +405 -0
- package/src/lib/convex-types.ts +37 -0
- package/src/lib/oauth.ts +412 -0
- package/src/react/index.ts +7 -0
- package/src/test.ts +21 -0
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
import { v } from "convex/values";
|
|
2
|
+
import { mutation, internalMutation } from "./_generated/server";
|
|
3
|
+
import { generateCode } from "../lib/oauth.js";
|
|
4
|
+
import { OAUTH_CONSTANTS } from "./constants";
|
|
5
|
+
import { hashToken, isHashedToken } from "./token_security";
|
|
6
|
+
|
|
7
|
+
// --------------------------------------------------------------------------
|
|
8
|
+
// Helper Functions
|
|
9
|
+
// --------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Check if a URI is a loopback address (localhost, 127.0.0.1, ::1)
|
|
13
|
+
* RFC 8252 Section 7.3: Loopback redirect URIs with variable ports
|
|
14
|
+
*/
|
|
15
|
+
export function isLoopbackRedirectUri(uri: string): boolean {
|
|
16
|
+
try {
|
|
17
|
+
const parsed = new URL(uri);
|
|
18
|
+
return (
|
|
19
|
+
parsed.hostname === "127.0.0.1" ||
|
|
20
|
+
parsed.hostname === "::1" ||
|
|
21
|
+
parsed.hostname === "localhost"
|
|
22
|
+
);
|
|
23
|
+
} catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Match redirect URI with registered URIs
|
|
30
|
+
* RFC 6749 Section 3.1.2.3: Exact string matching required
|
|
31
|
+
* RFC 8252 Section 7.3: Exception for loopback URIs (variable ports allowed)
|
|
32
|
+
*/
|
|
33
|
+
export function matchRedirectUri(requested: string, registered: string[]): boolean {
|
|
34
|
+
// 厳密一致チェック
|
|
35
|
+
if (registered.includes(requested)) {
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// localhost/127.0.0.1の可変ポート例外(RFC 8252 Section 7.3)
|
|
40
|
+
if (isLoopbackRedirectUri(requested)) {
|
|
41
|
+
try {
|
|
42
|
+
const reqUrl = new URL(requested);
|
|
43
|
+
for (const regUri of registered) {
|
|
44
|
+
if (isLoopbackRedirectUri(regUri)) {
|
|
45
|
+
const regUrl = new URL(regUri);
|
|
46
|
+
// ホストとパスが一致すればポート違いを許容
|
|
47
|
+
if (
|
|
48
|
+
reqUrl.hostname === regUrl.hostname &&
|
|
49
|
+
reqUrl.pathname === regUrl.pathname
|
|
50
|
+
) {
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
} catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// --------------------------------------------------------------------------
|
|
64
|
+
// Authorization Code Flow
|
|
65
|
+
// --------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Issue Authorization Code
|
|
69
|
+
* RFC 7636: PKCE validation
|
|
70
|
+
* RFC 6749 Section 3.1.2.3: Redirect URI validation
|
|
71
|
+
*/
|
|
72
|
+
export const issueAuthorizationCode = mutation({
|
|
73
|
+
args: {
|
|
74
|
+
clientId: v.string(),
|
|
75
|
+
userId: v.string(), // Convex users table Id (string, passed from app)
|
|
76
|
+
scopes: v.array(v.string()),
|
|
77
|
+
redirectUri: v.string(),
|
|
78
|
+
codeChallenge: v.string(),
|
|
79
|
+
codeChallengeMethod: v.string(),
|
|
80
|
+
nonce: v.optional(v.string()),
|
|
81
|
+
},
|
|
82
|
+
handler: async (ctx, args) => {
|
|
83
|
+
// 1. PKCE検証(RFC 7636)
|
|
84
|
+
if (!args.codeChallenge || args.codeChallenge.trim() === "") {
|
|
85
|
+
throw new Error("code_challenge required");
|
|
86
|
+
}
|
|
87
|
+
if (args.codeChallengeMethod !== "S256") {
|
|
88
|
+
throw new Error("plain code_challenge_method is not supported, use S256");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// 2. クライアント取得
|
|
92
|
+
const client = await ctx.db
|
|
93
|
+
.query("oauthClients")
|
|
94
|
+
.withIndex("by_client_id", (q) => q.eq("clientId", args.clientId))
|
|
95
|
+
.unique();
|
|
96
|
+
|
|
97
|
+
if (!client) {
|
|
98
|
+
throw new Error("invalid_client");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 3. リダイレクトURI検証(RFC 6749 + RFC 8252)
|
|
102
|
+
if (!matchRedirectUri(args.redirectUri, client.redirectUris)) {
|
|
103
|
+
throw new Error("redirect_uri_mismatch");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// 4. スコープ検証
|
|
107
|
+
const invalidScopes = args.scopes.filter(
|
|
108
|
+
(scope) => !client.allowedScopes.includes(scope)
|
|
109
|
+
);
|
|
110
|
+
if (invalidScopes.length > 0) {
|
|
111
|
+
throw new Error(`invalid_scope: ${invalidScopes.join(", ")}`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// 5. Generate Code
|
|
115
|
+
const code = generateCode(OAUTH_CONSTANTS.AUTH_CODE_LENGTH);
|
|
116
|
+
|
|
117
|
+
// 6. Save Code (hashed for security)
|
|
118
|
+
await ctx.db.insert("oauthCodes", {
|
|
119
|
+
code: await hashToken(code),
|
|
120
|
+
clientId: args.clientId,
|
|
121
|
+
userId: args.userId,
|
|
122
|
+
scopes: args.scopes,
|
|
123
|
+
redirectUri: args.redirectUri,
|
|
124
|
+
codeChallenge: args.codeChallenge,
|
|
125
|
+
codeChallengeMethod: args.codeChallengeMethod,
|
|
126
|
+
nonce: args.nonce,
|
|
127
|
+
expiresAt: Date.now() + OAUTH_CONSTANTS.CODE_EXPIRY_MS,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
return code;
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Validate and Consume Authorization Code
|
|
136
|
+
* Returns code data if valid, throws otherwise.
|
|
137
|
+
* Marks the code as used (not deleted) to detect replay attacks.
|
|
138
|
+
* RFC Line 1136: SHOULD revoke all tokens if code is reused.
|
|
139
|
+
* RFC Section 10.2: OAuth 2.1 - redirect_uri is OPTIONAL
|
|
140
|
+
*/
|
|
141
|
+
export const consumeAuthCode = mutation({
|
|
142
|
+
args: {
|
|
143
|
+
code: v.string(),
|
|
144
|
+
clientId: v.string(),
|
|
145
|
+
redirectUri: v.optional(v.string()), // OAuth 2.1: optional
|
|
146
|
+
codeVerifier: v.string(),
|
|
147
|
+
},
|
|
148
|
+
handler: async (ctx, args) => {
|
|
149
|
+
// 1. Find Code (by hash)
|
|
150
|
+
const codeHash = await hashToken(args.code);
|
|
151
|
+
let authCode = await ctx.db
|
|
152
|
+
.query("oauthCodes")
|
|
153
|
+
.withIndex("by_code", (q) => q.eq("code", codeHash))
|
|
154
|
+
.unique();
|
|
155
|
+
|
|
156
|
+
// Backward compatibility: try plaintext lookup if hash lookup fails
|
|
157
|
+
if (!authCode && !isHashedToken(args.code)) {
|
|
158
|
+
authCode = await ctx.db
|
|
159
|
+
.query("oauthCodes")
|
|
160
|
+
.withIndex("by_code", (q) => q.eq("code", args.code))
|
|
161
|
+
.unique();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (!authCode) {
|
|
165
|
+
throw new Error("invalid_grant");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// RFC Line 1136: Detect authorization code reuse (replay attack)
|
|
169
|
+
if (authCode.usedAt !== undefined) {
|
|
170
|
+
// Code was already used - this is a replay attack
|
|
171
|
+
// Revoke all tokens issued with this code
|
|
172
|
+
const tokensToRevoke = await ctx.db
|
|
173
|
+
.query("oauthTokens")
|
|
174
|
+
.withIndex("by_authorization_code", (q) => q.eq("authorizationCode", codeHash))
|
|
175
|
+
.collect();
|
|
176
|
+
|
|
177
|
+
for (const token of tokensToRevoke) {
|
|
178
|
+
await ctx.db.delete(token._id);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Delete the code
|
|
182
|
+
await ctx.db.delete(authCode._id);
|
|
183
|
+
|
|
184
|
+
// Return error status (cannot throw because it would rollback token deletion)
|
|
185
|
+
return {
|
|
186
|
+
error: "authorization_code_reuse_detected",
|
|
187
|
+
revokedTokens: tokensToRevoke.length,
|
|
188
|
+
} as any;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// 2. Validation
|
|
192
|
+
if (authCode.clientId !== args.clientId) {
|
|
193
|
+
throw new Error("invalid_client");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (authCode.expiresAt < Date.now()) {
|
|
197
|
+
await ctx.db.delete(authCode._id);
|
|
198
|
+
throw new Error("invalid_grant");
|
|
199
|
+
}
|
|
200
|
+
|
|
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
|
+
}
|
|
233
|
+
|
|
234
|
+
// 3. Mark Code as Used (RFC Line 1136: detect replay)
|
|
235
|
+
await ctx.db.patch(authCode._id, { usedAt: Date.now() });
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
userId: authCode.userId,
|
|
239
|
+
scopes: authCode.scopes,
|
|
240
|
+
codeChallenge: authCode.codeChallenge,
|
|
241
|
+
codeChallengeMethod: authCode.codeChallengeMethod,
|
|
242
|
+
redirectUri: authCode.redirectUri,
|
|
243
|
+
nonce: authCode.nonce,
|
|
244
|
+
codeHash, // Return code hash to link tokens
|
|
245
|
+
};
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Save Tokens
|
|
251
|
+
*
|
|
252
|
+
* Note: Tokens are stored as SHA-256 hashes for security.
|
|
253
|
+
* The original token value should be returned to the client, not stored.
|
|
254
|
+
*/
|
|
255
|
+
export const saveTokens = mutation({
|
|
256
|
+
args: {
|
|
257
|
+
accessToken: v.string(),
|
|
258
|
+
refreshToken: v.optional(v.string()),
|
|
259
|
+
clientId: v.string(),
|
|
260
|
+
userId: v.string(),
|
|
261
|
+
scopes: v.array(v.string()),
|
|
262
|
+
expiresAt: v.number(),
|
|
263
|
+
refreshTokenExpiresAt: v.optional(v.number()),
|
|
264
|
+
authorizationCode: v.optional(v.string()), // Hashed code for replay detection (RFC Line 1136)
|
|
265
|
+
},
|
|
266
|
+
handler: async (ctx, args) => {
|
|
267
|
+
// Hash tokens before storing for security
|
|
268
|
+
// The original tokens are returned to the client, hashes are stored
|
|
269
|
+
await ctx.db.insert("oauthTokens", {
|
|
270
|
+
...args,
|
|
271
|
+
accessToken: await hashToken(args.accessToken),
|
|
272
|
+
refreshToken: args.refreshToken
|
|
273
|
+
? await hashToken(args.refreshToken)
|
|
274
|
+
: undefined,
|
|
275
|
+
});
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Rotate Refresh Token (Delete old, Insert new)
|
|
281
|
+
* RFC 4.3.3: New refresh token MUST have identical scope as the old one
|
|
282
|
+
*
|
|
283
|
+
* Note: Tokens are stored as SHA-256 hashes for security.
|
|
284
|
+
*/
|
|
285
|
+
export const rotateRefreshToken = mutation({
|
|
286
|
+
args: {
|
|
287
|
+
oldRefreshToken: v.string(),
|
|
288
|
+
// New Token Data
|
|
289
|
+
accessToken: v.string(),
|
|
290
|
+
refreshToken: v.optional(v.string()),
|
|
291
|
+
clientId: v.string(),
|
|
292
|
+
userId: v.string(),
|
|
293
|
+
scopes: v.array(v.string()),
|
|
294
|
+
expiresAt: v.number(),
|
|
295
|
+
refreshTokenExpiresAt: v.optional(v.number()),
|
|
296
|
+
},
|
|
297
|
+
handler: async (ctx, args) => {
|
|
298
|
+
// Hash the old refresh token for lookup
|
|
299
|
+
const oldRefreshTokenHash = await hashToken(args.oldRefreshToken);
|
|
300
|
+
|
|
301
|
+
// 1. Verify Old Token Exists (lookup by hash)
|
|
302
|
+
let oldToken = await ctx.db
|
|
303
|
+
.query("oauthTokens")
|
|
304
|
+
.withIndex("by_refresh_token", (q) => q.eq("refreshToken", oldRefreshTokenHash))
|
|
305
|
+
.unique();
|
|
306
|
+
|
|
307
|
+
// Backward compatibility: try plaintext lookup if hash lookup fails
|
|
308
|
+
if (!oldToken && !isHashedToken(args.oldRefreshToken)) {
|
|
309
|
+
oldToken = await ctx.db
|
|
310
|
+
.query("oauthTokens")
|
|
311
|
+
.withIndex("by_refresh_token", (q) => q.eq("refreshToken", args.oldRefreshToken))
|
|
312
|
+
.unique();
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (!oldToken) {
|
|
316
|
+
throw new Error("invalid_grant");
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// 2. Validate Client/User consistency
|
|
320
|
+
if (oldToken.clientId !== args.clientId || oldToken.userId !== args.userId) {
|
|
321
|
+
throw new Error("invalid_grant");
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// 3. RFC 4.3.3: 新RTのスコープは元RTと完全一致が必須
|
|
325
|
+
// スコープの完全一致を検証(エスカレーションも縮小も不可)
|
|
326
|
+
const scopesMatch =
|
|
327
|
+
args.scopes.length === oldToken.scopes.length &&
|
|
328
|
+
args.scopes.every((scope) => oldToken.scopes.includes(scope)) &&
|
|
329
|
+
oldToken.scopes.every((scope) => args.scopes.includes(scope));
|
|
330
|
+
|
|
331
|
+
if (!scopesMatch) {
|
|
332
|
+
throw new Error(
|
|
333
|
+
"scope_change_not_allowed: Refresh token scope must remain identical"
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// 4. クライアントの許可スコープ検証
|
|
338
|
+
const client = await ctx.db
|
|
339
|
+
.query("oauthClients")
|
|
340
|
+
.withIndex("by_client_id", (q) => q.eq("clientId", args.clientId))
|
|
341
|
+
.unique();
|
|
342
|
+
|
|
343
|
+
if (!client) {
|
|
344
|
+
throw new Error("invalid_client");
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const invalidScopes = args.scopes.filter(
|
|
348
|
+
(scope) => !client.allowedScopes.includes(scope)
|
|
349
|
+
);
|
|
350
|
+
if (invalidScopes.length > 0) {
|
|
351
|
+
throw new Error(`invalid_scope: ${invalidScopes.join(", ")}`);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// 5. Delete Old Token
|
|
355
|
+
await ctx.db.delete(oldToken._id);
|
|
356
|
+
|
|
357
|
+
// 6. Insert New Token (with hashed values)
|
|
358
|
+
await ctx.db.insert("oauthTokens", {
|
|
359
|
+
accessToken: await hashToken(args.accessToken),
|
|
360
|
+
refreshToken: args.refreshToken ? await hashToken(args.refreshToken) : undefined,
|
|
361
|
+
clientId: args.clientId,
|
|
362
|
+
userId: args.userId,
|
|
363
|
+
scopes: args.scopes,
|
|
364
|
+
expiresAt: args.expiresAt,
|
|
365
|
+
refreshTokenExpiresAt: args.refreshTokenExpiresAt,
|
|
366
|
+
});
|
|
367
|
+
},
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Delete Client
|
|
372
|
+
*/
|
|
373
|
+
export const deleteClient = mutation({
|
|
374
|
+
args: {
|
|
375
|
+
clientId: v.string(),
|
|
376
|
+
},
|
|
377
|
+
handler: async (ctx, args) => {
|
|
378
|
+
const client = await ctx.db
|
|
379
|
+
.query("oauthClients")
|
|
380
|
+
.withIndex("by_client_id", (q) => q.eq("clientId", args.clientId))
|
|
381
|
+
.unique();
|
|
382
|
+
|
|
383
|
+
if (!client) {
|
|
384
|
+
throw new Error("Client not found");
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
await ctx.db.delete(client._id);
|
|
388
|
+
},
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Clean up expired codes/tokens (utility)
|
|
393
|
+
* RFC Line 1136: Also cleanup used codes after retention period
|
|
394
|
+
*/
|
|
395
|
+
export const cleanupExpired = internalMutation({
|
|
396
|
+
args: {},
|
|
397
|
+
handler: async (ctx) => {
|
|
398
|
+
const now = Date.now();
|
|
399
|
+
|
|
400
|
+
// Cleanup expired codes (both unused and used codes past retention period)
|
|
401
|
+
const expiredCodes = await ctx.db
|
|
402
|
+
.query("oauthCodes")
|
|
403
|
+
.filter(q => q.lt(q.field("expiresAt"), now))
|
|
404
|
+
.take(100);
|
|
405
|
+
|
|
406
|
+
for (const code of expiredCodes) {
|
|
407
|
+
await ctx.db.delete(code._id);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Cleanup expired tokens
|
|
411
|
+
const expiredTokens = await ctx.db
|
|
412
|
+
.query("oauthTokens")
|
|
413
|
+
.filter(q => q.lt(q.field("expiresAt"), now))
|
|
414
|
+
.take(100);
|
|
415
|
+
|
|
416
|
+
for (const token of expiredTokens) {
|
|
417
|
+
await ctx.db.delete(token._id);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
return {
|
|
421
|
+
deletedCodes: expiredCodes.length,
|
|
422
|
+
deletedTokens: expiredTokens.length,
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
// --------------------------------------------------------------------------
|
|
428
|
+
// Authorization Management
|
|
429
|
+
// --------------------------------------------------------------------------
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Create or update authorization (upsert)
|
|
433
|
+
* Called when user grants consent
|
|
434
|
+
*/
|
|
435
|
+
export const upsertAuthorization = mutation({
|
|
436
|
+
args: {
|
|
437
|
+
userId: v.string(),
|
|
438
|
+
clientId: v.string(),
|
|
439
|
+
scopes: v.array(v.string()),
|
|
440
|
+
},
|
|
441
|
+
handler: async (ctx, args) => {
|
|
442
|
+
const existing = await ctx.db
|
|
443
|
+
.query("oauthAuthorizations")
|
|
444
|
+
.withIndex("by_user_client", (q) =>
|
|
445
|
+
q.eq("userId", args.userId).eq("clientId", args.clientId)
|
|
446
|
+
)
|
|
447
|
+
.unique();
|
|
448
|
+
|
|
449
|
+
const now = Date.now();
|
|
450
|
+
|
|
451
|
+
if (existing) {
|
|
452
|
+
// Update: merge scopes, update lastUsedAt
|
|
453
|
+
const mergedScopes = [...new Set([...existing.scopes, ...args.scopes])];
|
|
454
|
+
await ctx.db.patch(existing._id, {
|
|
455
|
+
scopes: mergedScopes,
|
|
456
|
+
lastUsedAt: now,
|
|
457
|
+
});
|
|
458
|
+
return existing._id;
|
|
459
|
+
} else {
|
|
460
|
+
// Create new authorization
|
|
461
|
+
return await ctx.db.insert("oauthAuthorizations", {
|
|
462
|
+
userId: args.userId,
|
|
463
|
+
clientId: args.clientId,
|
|
464
|
+
scopes: args.scopes,
|
|
465
|
+
authorizedAt: now,
|
|
466
|
+
lastUsedAt: now,
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
},
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Update lastUsedAt when tokens are issued
|
|
474
|
+
*/
|
|
475
|
+
export const updateAuthorizationLastUsed = mutation({
|
|
476
|
+
args: {
|
|
477
|
+
userId: v.string(),
|
|
478
|
+
clientId: v.string(),
|
|
479
|
+
},
|
|
480
|
+
handler: async (ctx, args) => {
|
|
481
|
+
const auth = await ctx.db
|
|
482
|
+
.query("oauthAuthorizations")
|
|
483
|
+
.withIndex("by_user_client", (q) =>
|
|
484
|
+
q.eq("userId", args.userId).eq("clientId", args.clientId)
|
|
485
|
+
)
|
|
486
|
+
.unique();
|
|
487
|
+
|
|
488
|
+
if (auth) {
|
|
489
|
+
await ctx.db.patch(auth._id, { lastUsedAt: Date.now() });
|
|
490
|
+
}
|
|
491
|
+
},
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Revoke authorization and delete all associated tokens
|
|
496
|
+
*/
|
|
497
|
+
export const revokeAuthorization = mutation({
|
|
498
|
+
args: {
|
|
499
|
+
userId: v.string(),
|
|
500
|
+
clientId: v.string(),
|
|
501
|
+
},
|
|
502
|
+
handler: async (ctx, args) => {
|
|
503
|
+
// 1. Delete authorization record
|
|
504
|
+
const auth = await ctx.db
|
|
505
|
+
.query("oauthAuthorizations")
|
|
506
|
+
.withIndex("by_user_client", (q) =>
|
|
507
|
+
q.eq("userId", args.userId).eq("clientId", args.clientId)
|
|
508
|
+
)
|
|
509
|
+
.unique();
|
|
510
|
+
|
|
511
|
+
if (auth) {
|
|
512
|
+
await ctx.db.delete(auth._id);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// 2. Delete all tokens for this user-client pair
|
|
516
|
+
const tokens = await ctx.db
|
|
517
|
+
.query("oauthTokens")
|
|
518
|
+
.withIndex("by_user", (q) => q.eq("userId", args.userId))
|
|
519
|
+
.collect();
|
|
520
|
+
|
|
521
|
+
const toDelete = tokens.filter(t => t.clientId === args.clientId);
|
|
522
|
+
for (const token of toDelete) {
|
|
523
|
+
await ctx.db.delete(token._id);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
return {
|
|
527
|
+
authorizationDeleted: !!auth,
|
|
528
|
+
tokensDeleted: toDelete.length,
|
|
529
|
+
};
|
|
530
|
+
},
|
|
531
|
+
});
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { v } from "convex/values";
|
|
2
|
+
import { query } from "./_generated/server";
|
|
3
|
+
import { hashToken, isHashedToken } from "./token_security";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Get OAuth Client by clientId
|
|
7
|
+
*/
|
|
8
|
+
export const getClient = query({
|
|
9
|
+
args: { clientId: v.string() },
|
|
10
|
+
handler: async (ctx, args) => {
|
|
11
|
+
return await ctx.db
|
|
12
|
+
.query("oauthClients")
|
|
13
|
+
.withIndex("by_client_id", (q) => q.eq("clientId", args.clientId))
|
|
14
|
+
.unique();
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Get Refresh Token
|
|
20
|
+
*
|
|
21
|
+
* Note: Tokens are stored as SHA-256 hashes. This query hashes the input
|
|
22
|
+
* before lookup, with backward compatibility for plaintext tokens.
|
|
23
|
+
*/
|
|
24
|
+
export const getRefreshToken = query({
|
|
25
|
+
args: { refreshToken: v.string() },
|
|
26
|
+
handler: async (ctx, args) => {
|
|
27
|
+
// Hash the token for lookup
|
|
28
|
+
const refreshTokenHash = await hashToken(args.refreshToken);
|
|
29
|
+
|
|
30
|
+
// Try hash lookup first
|
|
31
|
+
let token = await ctx.db
|
|
32
|
+
.query("oauthTokens")
|
|
33
|
+
.withIndex("by_refresh_token", (q) => q.eq("refreshToken", refreshTokenHash))
|
|
34
|
+
.unique();
|
|
35
|
+
|
|
36
|
+
// Backward compatibility: try plaintext lookup if hash lookup fails
|
|
37
|
+
if (!token && !isHashedToken(args.refreshToken)) {
|
|
38
|
+
token = await ctx.db
|
|
39
|
+
.query("oauthTokens")
|
|
40
|
+
.withIndex("by_refresh_token", (q) => q.eq("refreshToken", args.refreshToken))
|
|
41
|
+
.unique();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return token;
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* List OAuth Clients (for admin)
|
|
50
|
+
*/
|
|
51
|
+
export const listClients = query({
|
|
52
|
+
args: {},
|
|
53
|
+
handler: async (ctx) => {
|
|
54
|
+
const clients = await ctx.db.query("oauthClients").collect();
|
|
55
|
+
// Don't return secrets
|
|
56
|
+
return clients.map(client => ({
|
|
57
|
+
...client,
|
|
58
|
+
clientSecret: undefined,
|
|
59
|
+
}));
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Get tokens by user ID
|
|
65
|
+
*/
|
|
66
|
+
export const getTokensByUser = query({
|
|
67
|
+
args: { userId: v.string() },
|
|
68
|
+
handler: async (ctx, args) => {
|
|
69
|
+
return await ctx.db
|
|
70
|
+
.query("oauthTokens")
|
|
71
|
+
.withIndex("by_user", (q) => q.eq("userId", args.userId))
|
|
72
|
+
.collect();
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// --------------------------------------------------------------------------
|
|
77
|
+
// Authorization Queries
|
|
78
|
+
// --------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Get authorization for a specific user-client pair
|
|
82
|
+
*/
|
|
83
|
+
export const getAuthorization = query({
|
|
84
|
+
args: {
|
|
85
|
+
userId: v.string(),
|
|
86
|
+
clientId: v.string(),
|
|
87
|
+
},
|
|
88
|
+
handler: async (ctx, args) => {
|
|
89
|
+
return await ctx.db
|
|
90
|
+
.query("oauthAuthorizations")
|
|
91
|
+
.withIndex("by_user_client", (q) =>
|
|
92
|
+
q.eq("userId", args.userId).eq("clientId", args.clientId)
|
|
93
|
+
)
|
|
94
|
+
.unique();
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Check if authorization exists (for revocation check)
|
|
100
|
+
* Returns true if authorization is valid, false if revoked or not found
|
|
101
|
+
*/
|
|
102
|
+
export const hasAuthorization = query({
|
|
103
|
+
args: {
|
|
104
|
+
userId: v.string(),
|
|
105
|
+
clientId: v.string(),
|
|
106
|
+
},
|
|
107
|
+
handler: async (ctx, args) => {
|
|
108
|
+
const auth = await ctx.db
|
|
109
|
+
.query("oauthAuthorizations")
|
|
110
|
+
.withIndex("by_user_client", (q) =>
|
|
111
|
+
q.eq("userId", args.userId).eq("clientId", args.clientId)
|
|
112
|
+
)
|
|
113
|
+
.unique();
|
|
114
|
+
return auth !== null;
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Check if user has any valid authorization (for OAuth token validation)
|
|
120
|
+
* If user has no authorizations, they shouldn't be able to access via OAuth
|
|
121
|
+
*/
|
|
122
|
+
export const hasAnyAuthorization = query({
|
|
123
|
+
args: {
|
|
124
|
+
userId: v.string(),
|
|
125
|
+
},
|
|
126
|
+
handler: async (ctx, args) => {
|
|
127
|
+
const auth = await ctx.db
|
|
128
|
+
.query("oauthAuthorizations")
|
|
129
|
+
.withIndex("by_user", (q) => q.eq("userId", args.userId))
|
|
130
|
+
.first();
|
|
131
|
+
return auth !== null;
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* List all authorizations for a user (with client info)
|
|
137
|
+
*/
|
|
138
|
+
export const listUserAuthorizations = query({
|
|
139
|
+
args: { userId: v.string() },
|
|
140
|
+
handler: async (ctx, args) => {
|
|
141
|
+
const authorizations = await ctx.db
|
|
142
|
+
.query("oauthAuthorizations")
|
|
143
|
+
.withIndex("by_user", (q) => q.eq("userId", args.userId))
|
|
144
|
+
.collect();
|
|
145
|
+
|
|
146
|
+
// Enrich with client info
|
|
147
|
+
const result = await Promise.all(
|
|
148
|
+
authorizations.map(async (auth) => {
|
|
149
|
+
const client = await ctx.db
|
|
150
|
+
.query("oauthClients")
|
|
151
|
+
.withIndex("by_client_id", (q) => q.eq("clientId", auth.clientId))
|
|
152
|
+
.unique();
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
...auth,
|
|
156
|
+
clientName: client?.name ?? "Unknown App",
|
|
157
|
+
clientLogoUrl: client?.logoUrl,
|
|
158
|
+
clientWebsite: client?.website,
|
|
159
|
+
};
|
|
160
|
+
})
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
return result;
|
|
164
|
+
},
|
|
165
|
+
});
|