@samebase/identity-broker 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +4 -0
  3. package/README.md +90 -2
  4. package/dist/cli/index.d.ts +2 -0
  5. package/dist/cli/index.js +26 -0
  6. package/dist/cli/index.js.map +1 -0
  7. package/dist/client/connectors/email.d.ts +6 -0
  8. package/dist/client/connectors/email.js +5 -0
  9. package/dist/client/connectors/email.js.map +1 -0
  10. package/dist/client/connectors/github.d.ts +7 -0
  11. package/dist/client/connectors/github.js +57 -0
  12. package/dist/client/connectors/github.js.map +1 -0
  13. package/dist/client/connectors/google.d.ts +7 -0
  14. package/dist/client/connectors/google.js +13 -0
  15. package/dist/client/connectors/google.js.map +1 -0
  16. package/dist/client/connectors/types.d.ts +47 -0
  17. package/dist/client/connectors/types.js +2 -0
  18. package/dist/client/connectors/types.js.map +1 -0
  19. package/dist/client/connectors/upstream.d.ts +25 -0
  20. package/dist/client/connectors/upstream.js +86 -0
  21. package/dist/client/connectors/upstream.js.map +1 -0
  22. package/dist/client/html.d.ts +30 -0
  23. package/dist/client/html.js +84 -0
  24. package/dist/client/html.js.map +1 -0
  25. package/dist/client/index.d.ts +78 -0
  26. package/dist/client/index.js +721 -0
  27. package/dist/client/index.js.map +1 -0
  28. package/dist/client/jwt.d.ts +31 -0
  29. package/dist/client/jwt.js +55 -0
  30. package/dist/client/jwt.js.map +1 -0
  31. package/dist/client/keys.d.ts +44 -0
  32. package/dist/client/keys.js +130 -0
  33. package/dist/client/keys.js.map +1 -0
  34. package/dist/client/protocol.d.ts +75 -0
  35. package/dist/client/protocol.js +259 -0
  36. package/dist/client/protocol.js.map +1 -0
  37. package/dist/client/responses.d.ts +24 -0
  38. package/dist/client/responses.js +57 -0
  39. package/dist/client/responses.js.map +1 -0
  40. package/dist/client/subject.d.ts +15 -0
  41. package/dist/client/subject.js +32 -0
  42. package/dist/client/subject.js.map +1 -0
  43. package/dist/component/_generated/api.d.ts +45 -0
  44. package/dist/component/_generated/api.js +31 -0
  45. package/dist/component/_generated/api.js.map +1 -0
  46. package/dist/component/_generated/component.d.ts +375 -0
  47. package/dist/component/_generated/component.js +11 -0
  48. package/dist/component/_generated/component.js.map +1 -0
  49. package/dist/component/_generated/dataModel.d.ts +45 -0
  50. package/dist/component/_generated/dataModel.js +11 -0
  51. package/dist/component/_generated/dataModel.js.map +1 -0
  52. package/dist/component/_generated/server.d.ts +132 -0
  53. package/dist/component/_generated/server.js +80 -0
  54. package/dist/component/_generated/server.js.map +1 -0
  55. package/dist/component/apps.d.ts +36 -0
  56. package/dist/component/apps.js +124 -0
  57. package/dist/component/apps.js.map +1 -0
  58. package/dist/component/authRequests.d.ts +254 -0
  59. package/dist/component/authRequests.js +392 -0
  60. package/dist/component/authRequests.js.map +1 -0
  61. package/dist/component/clients.d.ts +43 -0
  62. package/dist/component/clients.js +95 -0
  63. package/dist/component/clients.js.map +1 -0
  64. package/dist/component/convex.config.d.ts +2 -0
  65. package/dist/component/convex.config.js +3 -0
  66. package/dist/component/convex.config.js.map +1 -0
  67. package/dist/component/crypto.d.ts +2 -0
  68. package/dist/component/crypto.js +9 -0
  69. package/dist/component/crypto.js.map +1 -0
  70. package/dist/component/lifecycle.d.ts +15 -0
  71. package/dist/component/lifecycle.js +59 -0
  72. package/dist/component/lifecycle.js.map +1 -0
  73. package/dist/component/registration.d.ts +17 -0
  74. package/dist/component/registration.js +57 -0
  75. package/dist/component/registration.js.map +1 -0
  76. package/dist/component/schema.d.ts +650 -0
  77. package/dist/component/schema.js +122 -0
  78. package/dist/component/schema.js.map +1 -0
  79. package/dist/component/signIns.d.ts +8 -0
  80. package/dist/component/signIns.js +26 -0
  81. package/dist/component/signIns.js.map +1 -0
  82. package/package.json +51 -3
@@ -0,0 +1,721 @@
1
+ import { httpActionGeneric, internalMutationGeneric, internalQueryGeneric, } from "convex/server";
2
+ import { v } from "convex/values";
3
+ import * as oauth from "oauth4webapi";
4
+ import { finishUpstream, startUpstream } from "./connectors/upstream.js";
5
+ import { chooserPage, emailAddressPage, emailCodePage, errorPage } from "./html.js";
6
+ import { signAccessToken, signIdToken, TOKEN_TTL_SECONDS, verifyAccessToken, } from "./jwt.js";
7
+ import { parseKeySet, publicJwks, SIGNING_ALGORITHM } from "./keys.js";
8
+ import { canonicalEmail, hostAllowed, readBearerToken, readClientAuthentication, validateAuthorizeRest, validateAuthorizeTarget, validateRegistration, validateTokenRequest, } from "./protocol.js";
9
+ import { authorizationError, authorizationResponse, json, oauthError, readParameters, redirect, } from "./responses.js";
10
+ import { pairwiseSubject, randomDigits, randomToken, sha256Hex } from "./subject.js";
11
+ export { github } from "./connectors/github.js";
12
+ export { google } from "./connectors/google.js";
13
+ export { email } from "./connectors/email.js";
14
+ export { createKeySet, rotateKeySet } from "./keys.js";
15
+ /** A request lives this long from /authorize to the code redirect. */
16
+ const AUTH_REQUEST_TTL_MS = 10 * 60 * 1000;
17
+ /** A code is redeemed by the client's callback at once. */
18
+ const CODE_TTL_MS = 60 * 1000;
19
+ const EMAIL_CODE_TTL_MS = 10 * 60 * 1000;
20
+ const EMAIL_MAX_ATTEMPTS = 5;
21
+ const REGISTRATION_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000;
22
+ /** Lifecycle values the host's cron passes to `cleanup`. */
23
+ export const PREVIEW_CLIENT_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
24
+ export const SIGN_IN_RETENTION_MS = 90 * 24 * 60 * 60 * 1000;
25
+ export function defineIdentityBroker(config) {
26
+ const issuerUrl = new URL(config.issuer);
27
+ if (issuerUrl.search !== "" || issuerUrl.hash !== "") {
28
+ throw new Error("The issuer must not have a query or fragment");
29
+ }
30
+ const issuer = issuerUrl.href.replace(/\/$/, "");
31
+ const prefix = issuerUrl.pathname.replace(/\/$/, "");
32
+ const path = (suffix) => `${prefix}${suffix}`;
33
+ const component = config.component;
34
+ const connectors = new Map(config.connectors.map((connector) => [connector.name, connector]));
35
+ if (connectors.size !== config.connectors.length) {
36
+ throw new Error("Connector names must be unique");
37
+ }
38
+ if (connectors.size === 0) {
39
+ throw new Error("At least one connector is required");
40
+ }
41
+ const keySet = () => parseKeySet(config.keys());
42
+ const callbackUrl = (connector) => `${issuer}/connectors/${connector.name}/callback`;
43
+ const metadata = () => ({
44
+ issuer,
45
+ authorization_endpoint: `${issuer}/authorize`,
46
+ token_endpoint: `${issuer}/token`,
47
+ userinfo_endpoint: `${issuer}/userinfo`,
48
+ jwks_uri: `${issuer}/.well-known/jwks.json`,
49
+ registration_endpoint: `${issuer}/register`,
50
+ scopes_supported: ["openid", "email", "profile"],
51
+ response_types_supported: ["code"],
52
+ response_modes_supported: ["query"],
53
+ grant_types_supported: ["authorization_code"],
54
+ subject_types_supported: ["pairwise"],
55
+ id_token_signing_alg_values_supported: [SIGNING_ALGORITHM],
56
+ token_endpoint_auth_methods_supported: ["client_secret_post", "client_secret_basic"],
57
+ code_challenge_methods_supported: ["S256"],
58
+ claims_supported: [
59
+ "sub",
60
+ "iss",
61
+ "aud",
62
+ "exp",
63
+ "iat",
64
+ "auth_time",
65
+ "nonce",
66
+ "email",
67
+ "email_verified",
68
+ "name",
69
+ "picture",
70
+ "connector",
71
+ ],
72
+ authorization_response_iss_parameter_supported: true,
73
+ claims_parameter_supported: false,
74
+ request_parameter_supported: false,
75
+ request_uri_parameter_supported: false,
76
+ });
77
+ const discovery = httpActionGeneric(async () => json(metadata(), 200, { "Cache-Control": "public, max-age=300" }));
78
+ const jwks = httpActionGeneric(async () => json(publicJwks(keySet()), 200, { "Cache-Control": "public, max-age=300" }));
79
+ /** Sends the user to a connector for a stored request, or shows the email form. */
80
+ async function startConnector(ctx, args) {
81
+ const { connector, request } = args;
82
+ const back = { redirectUri: request.redirectUri, issuer, state: request.state };
83
+ if (connector.kind === "email") {
84
+ return emailAddressPage({
85
+ appName: args.appName,
86
+ action: path("/connectors/email"),
87
+ requestId: request.requestId,
88
+ });
89
+ }
90
+ if (request.freshLogin && connector.kind === "oauth") {
91
+ return authorizationError({
92
+ ...back,
93
+ error: "login_required",
94
+ description: `${connector.label} cannot reauthenticate the user, which prompt=login or max_age asked for.`,
95
+ });
96
+ }
97
+ const start = await startUpstream({
98
+ connector,
99
+ callbackUrl: callbackUrl(connector),
100
+ freshLogin: request.freshLogin,
101
+ });
102
+ const now = Date.now();
103
+ const result = await ctx.runMutation(component.authRequests.startUpstream, {
104
+ requestId: request.requestId,
105
+ connector: connector.name,
106
+ upstreamStateHash: await sha256Hex(start.state),
107
+ upstreamCodeVerifier: start.codeVerifier,
108
+ ...(start.nonce === undefined ? {} : { upstreamNonce: start.nonce }),
109
+ now,
110
+ });
111
+ if (!result.ok) {
112
+ return result.error === "not_found"
113
+ ? errorPage("Sign-in not found", "This sign-in is unknown. Return to the app and try again.", 404)
114
+ : authorizationError({
115
+ ...back,
116
+ error: "access_denied",
117
+ description: `The sign-in ${result.error === "expired" ? "expired" : "already moved on"}.`,
118
+ });
119
+ }
120
+ return redirect(start.url);
121
+ }
122
+ async function readApp(ctx, appId) {
123
+ return await ctx.runQuery(component.apps.get, { appId });
124
+ }
125
+ const authorize = httpActionGeneric(async (ctx, request) => {
126
+ const params = await readParameters(request);
127
+ if (params === null) {
128
+ return oauthError(415, "invalid_request", "A POST must be application/x-www-form-urlencoded.");
129
+ }
130
+ const target = validateAuthorizeTarget(params);
131
+ if (!target.ok) {
132
+ return errorPage("Invalid sign-in request", target.description, target.status);
133
+ }
134
+ const client = await ctx.runQuery(component.clients.get, { clientId: target.value.clientId });
135
+ if (client === null) {
136
+ return errorPage("Unknown client", "The client_id is not registered with this provider.", 400);
137
+ }
138
+ // Exact string comparison, and no redirect to a URI that is not registered (RFC 9700 4.1.3).
139
+ if (client.redirectUri !== target.value.redirectUri) {
140
+ return errorPage("Invalid redirect URI", "The redirect_uri is not the one registered for this client.", 400);
141
+ }
142
+ const back = {
143
+ redirectUri: client.redirectUri,
144
+ issuer,
145
+ state: params.get("state") ?? undefined,
146
+ };
147
+ const rest = validateAuthorizeRest(params, target.value);
148
+ if (!rest.ok) {
149
+ return authorizationError({ ...back, error: rest.error, description: rest.description });
150
+ }
151
+ const app = await readApp(ctx, client.appId);
152
+ if (app === null) {
153
+ return authorizationError({
154
+ ...back,
155
+ error: "server_error",
156
+ description: "The client's app is gone.",
157
+ });
158
+ }
159
+ if (rest.value.prompt.includes("none")) {
160
+ // The broker keeps no session, so it can never answer without interaction.
161
+ return authorizationError({
162
+ ...back,
163
+ error: "login_required",
164
+ description: "This provider has no session to reuse.",
165
+ });
166
+ }
167
+ const freshLogin = rest.value.prompt.includes("login") || rest.value.maxAge !== undefined;
168
+ const now = Date.now();
169
+ const requestId = randomToken(24);
170
+ await ctx.runMutation(component.authRequests.create, {
171
+ requestId,
172
+ appId: app.appId,
173
+ clientId: client.clientId,
174
+ redirectUri: client.redirectUri,
175
+ scope: rest.value.scopes.join(" "),
176
+ ...(rest.value.state === undefined ? {} : { state: rest.value.state }),
177
+ ...(rest.value.nonce === undefined ? {} : { nonce: rest.value.nonce }),
178
+ codeChallenge: rest.value.codeChallenge,
179
+ freshLogin,
180
+ createdAt: now,
181
+ expiresAt: now + AUTH_REQUEST_TTL_MS,
182
+ });
183
+ const stored = {
184
+ requestId,
185
+ appId: app.appId,
186
+ redirectUri: client.redirectUri,
187
+ state: rest.value.state,
188
+ freshLogin,
189
+ };
190
+ // A first-party app may skip the chooser: the one it named, or the only one. A third-party app
191
+ // always gets the page, which doubles as consent (RFC 9700 4.11).
192
+ if (app.firstParty) {
193
+ const named = rest.value.connector === undefined ? undefined : connectors.get(rest.value.connector);
194
+ const only = connectors.size === 1 ? [...connectors.values()][0] : undefined;
195
+ const chosen = named ?? only;
196
+ if (chosen !== undefined) {
197
+ return await startConnector(ctx, { connector: chosen, request: stored, appName: app.name });
198
+ }
199
+ }
200
+ return chooserPage({
201
+ appName: app.name,
202
+ firstParty: app.firstParty,
203
+ connectors: [...connectors.values()].map((connector) => ({
204
+ name: connector.name,
205
+ label: connector.label,
206
+ href: `${path("/continue")}?request=${encodeURIComponent(requestId)}&connector=${encodeURIComponent(connector.name)}`,
207
+ })),
208
+ denyHref: `${path("/continue")}?request=${encodeURIComponent(requestId)}&deny=1`,
209
+ });
210
+ });
211
+ /** A chooser click: start the chosen connector, or abandon the request on deny. */
212
+ const continueSignIn = httpActionGeneric(async (ctx, request) => {
213
+ const url = new URL(request.url);
214
+ const requestId = url.searchParams.get("request") ?? "";
215
+ const stored = await ctx.runQuery(component.authRequests.get, { requestId });
216
+ if (stored === null) {
217
+ return errorPage("Sign-in not found", "This sign-in is unknown or finished. Return to the app and try again.", 404);
218
+ }
219
+ const back = { redirectUri: stored.redirectUri, issuer, state: stored.state };
220
+ if (url.searchParams.get("deny") !== null) {
221
+ await ctx.runMutation(component.authRequests.abandon, { requestId });
222
+ return authorizationError({
223
+ ...back,
224
+ error: "access_denied",
225
+ description: "The user cancelled the sign-in.",
226
+ });
227
+ }
228
+ if (stored.status === "completed" || stored.status === "claimed") {
229
+ return errorPage("Sign-in finished", "This sign-in already finished. Return to the app.", 409);
230
+ }
231
+ const connector = connectors.get(url.searchParams.get("connector") ?? "");
232
+ if (connector === undefined) {
233
+ return errorPage("Unknown connector", "That sign-in method is not configured.", 404);
234
+ }
235
+ const app = await readApp(ctx, stored.appId);
236
+ if (app === null) {
237
+ return authorizationError({
238
+ ...back,
239
+ error: "server_error",
240
+ description: "The client's app is gone.",
241
+ });
242
+ }
243
+ return await startConnector(ctx, {
244
+ connector,
245
+ request: {
246
+ requestId,
247
+ appId: stored.appId,
248
+ redirectUri: stored.redirectUri,
249
+ state: stored.state,
250
+ freshLogin: stored.freshLogin,
251
+ },
252
+ appName: app.name,
253
+ });
254
+ });
255
+ function upstreamCallback(connector) {
256
+ return httpActionGeneric(async (ctx, request) => {
257
+ const url = new URL(request.url);
258
+ const state = url.searchParams.get("state");
259
+ if (state === null) {
260
+ return errorPage("Invalid callback", "The provider sent no state.", 400);
261
+ }
262
+ const now = Date.now();
263
+ const claim = await ctx.runMutation(component.authRequests.claimUpstream, {
264
+ upstreamStateHash: await sha256Hex(state),
265
+ now,
266
+ });
267
+ if (claim.status === "missing") {
268
+ return errorPage("Sign-in link used", "This sign-in link is invalid or was already used. Return to the app and try again.", 400);
269
+ }
270
+ const back = { redirectUri: claim.redirectUri, issuer, state: claim.state };
271
+ if (claim.status === "expired") {
272
+ return authorizationError({
273
+ ...back,
274
+ error: "access_denied",
275
+ description: "The sign-in expired.",
276
+ });
277
+ }
278
+ let identity;
279
+ try {
280
+ identity = await finishUpstream({
281
+ connector,
282
+ callbackUrl: callbackUrl(connector),
283
+ currentUrl: url,
284
+ state,
285
+ codeVerifier: claim.upstreamCodeVerifier,
286
+ nonce: claim.upstreamNonce,
287
+ });
288
+ }
289
+ catch (error) {
290
+ await ctx.runMutation(component.authRequests.abandon, { requestId: claim.requestId });
291
+ if (error instanceof oauth.AuthorizationResponseError) {
292
+ return authorizationError({
293
+ ...back,
294
+ error: error.error === "access_denied" ? "access_denied" : "server_error",
295
+ description: `${connector.label} answered ${error.error}.`,
296
+ });
297
+ }
298
+ console.error(`Connector "${connector.name}" failed`, error);
299
+ return authorizationError({
300
+ ...back,
301
+ error: "server_error",
302
+ description: `${connector.label} sign-in failed.`,
303
+ });
304
+ }
305
+ const code = randomToken(32);
306
+ const completed = await ctx.runMutation(component.authRequests.complete, {
307
+ requestId: claim.requestId,
308
+ identity: { connector: connector.name, ...identity },
309
+ authTime: now,
310
+ codeHash: await sha256Hex(code),
311
+ codeExpiresAt: now + CODE_TTL_MS,
312
+ now,
313
+ });
314
+ if (!completed.ok) {
315
+ return authorizationError({
316
+ ...back,
317
+ error: "access_denied",
318
+ description: "The sign-in expired.",
319
+ });
320
+ }
321
+ return authorizationResponse({ ...back, params: { code } });
322
+ });
323
+ }
324
+ const emailConnector = [...connectors.values()].find((connector) => connector.kind === "email");
325
+ const emailStep = httpActionGeneric(async (ctx, request) => {
326
+ if (emailConnector === undefined) {
327
+ return errorPage("Not configured", "Email sign-in is not configured.", 404);
328
+ }
329
+ const params = await readParameters(request);
330
+ if (params === null) {
331
+ return errorPage("Invalid form", "The form must be application/x-www-form-urlencoded.", 415);
332
+ }
333
+ const requestId = params.get("request") ?? "";
334
+ const stored = await ctx.runQuery(component.authRequests.get, { requestId });
335
+ if (stored === null) {
336
+ return errorPage("Sign-in not found", "This sign-in is unknown or finished. Return to the app and try again.", 404);
337
+ }
338
+ const back = { redirectUri: stored.redirectUri, issuer, state: stored.state };
339
+ const app = await readApp(ctx, stored.appId);
340
+ if (app === null) {
341
+ return authorizationError({
342
+ ...back,
343
+ error: "server_error",
344
+ description: "The client's app is gone.",
345
+ });
346
+ }
347
+ const action = path("/connectors/email");
348
+ const now = Date.now();
349
+ const step = params.get("step");
350
+ if (step === "send") {
351
+ const address = canonicalEmail(params.get("email") ?? "");
352
+ if (address === null) {
353
+ return emailAddressPage({
354
+ appName: app.name,
355
+ action,
356
+ requestId,
357
+ error: "Enter a valid email address.",
358
+ });
359
+ }
360
+ const code = randomDigits(6);
361
+ const started = await ctx.runMutation(component.authRequests.startEmail, {
362
+ requestId,
363
+ connector: emailConnector.name,
364
+ email: address,
365
+ emailCodeHash: await sha256Hex(code),
366
+ emailCodeExpiresAt: now + EMAIL_CODE_TTL_MS,
367
+ now,
368
+ });
369
+ if (!started.ok) {
370
+ return started.error === "not_found"
371
+ ? errorPage("Sign-in not found", "This sign-in is unknown. Return to the app and try again.", 404)
372
+ : authorizationError({
373
+ ...back,
374
+ error: "access_denied",
375
+ description: "The sign-in expired.",
376
+ });
377
+ }
378
+ try {
379
+ await ctx.runAction(emailConnector.send, { to: address, code, appName: app.name });
380
+ }
381
+ catch (error) {
382
+ console.error("Email code delivery failed", error);
383
+ return errorPage("Email not sent", "The code could not be sent. Try again in a moment.", 502);
384
+ }
385
+ return emailCodePage({ appName: app.name, action, requestId, email: address });
386
+ }
387
+ if (step === "verify") {
388
+ if (stored.status !== "emailSent") {
389
+ return emailAddressPage({
390
+ appName: app.name,
391
+ action,
392
+ requestId,
393
+ error: "Request a code first.",
394
+ });
395
+ }
396
+ const typed = (params.get("code") ?? "").trim();
397
+ const authorizationCode = randomToken(32);
398
+ const result = await ctx.runMutation(component.authRequests.verifyEmailCode, {
399
+ requestId,
400
+ emailCodeHash: await sha256Hex(typed),
401
+ maxAttempts: EMAIL_MAX_ATTEMPTS,
402
+ codeHash: await sha256Hex(authorizationCode),
403
+ codeExpiresAt: now + CODE_TTL_MS,
404
+ now,
405
+ });
406
+ if (result.ok) {
407
+ return authorizationResponse({ ...back, params: { code: authorizationCode } });
408
+ }
409
+ switch (result.error) {
410
+ case "wrong_code":
411
+ return emailCodePage({
412
+ appName: app.name,
413
+ action,
414
+ requestId,
415
+ email: stored.email,
416
+ error: "That code is not right.",
417
+ });
418
+ case "code_expired":
419
+ return emailAddressPage({
420
+ appName: app.name,
421
+ action,
422
+ requestId,
423
+ error: "The code expired. Request a new one.",
424
+ });
425
+ case "locked":
426
+ return authorizationError({
427
+ ...back,
428
+ error: "access_denied",
429
+ description: "Too many wrong codes.",
430
+ });
431
+ case "expired":
432
+ return authorizationError({
433
+ ...back,
434
+ error: "access_denied",
435
+ description: "The sign-in expired.",
436
+ });
437
+ case "wrong_status":
438
+ return emailAddressPage({
439
+ appName: app.name,
440
+ action,
441
+ requestId,
442
+ error: "Request a code first.",
443
+ });
444
+ case "not_found":
445
+ return errorPage("Sign-in not found", "This sign-in is unknown. Return to the app and try again.", 404);
446
+ default:
447
+ return result;
448
+ }
449
+ }
450
+ return errorPage("Invalid form", "Unknown step.", 400);
451
+ });
452
+ const register = httpActionGeneric(async (ctx, request) => {
453
+ const token = readBearerToken(request.headers.get("authorization"));
454
+ if (token === null) {
455
+ return oauthError(401, "invalid_token", "A registration token is required as a Bearer token.");
456
+ }
457
+ const now = Date.now();
458
+ const app = await ctx.runQuery(component.registration.readToken, {
459
+ tokenHash: await sha256Hex(token),
460
+ now,
461
+ });
462
+ if (app === null) {
463
+ return oauthError(401, "invalid_token", "The registration token is unknown or expired.");
464
+ }
465
+ let payload;
466
+ try {
467
+ payload = await request.json();
468
+ }
469
+ catch {
470
+ return oauthError(400, "invalid_client_metadata", "The request body must be JSON.");
471
+ }
472
+ const validation = validateRegistration(payload);
473
+ if (!validation.ok) {
474
+ return oauthError(validation.status, validation.error, validation.description);
475
+ }
476
+ const host = new URL(validation.value.redirectUri).hostname;
477
+ if (!hostAllowed(host, app.allowedCallbackHosts)) {
478
+ return oauthError(400, "invalid_redirect_uri", `The host "${host}" is not allowed for this app.`);
479
+ }
480
+ const clientId = `cl_${randomToken(16)}`;
481
+ const clientSecret = randomToken(32);
482
+ await ctx.runMutation(component.clients.create, {
483
+ clientId,
484
+ appId: app.appId,
485
+ secretHash: await sha256Hex(clientSecret),
486
+ tokenEndpointAuthMethod: validation.value.tokenEndpointAuthMethod,
487
+ redirectUri: validation.value.redirectUri,
488
+ deploymentType: validation.value.deploymentType,
489
+ clientName: validation.value.clientName,
490
+ softwareVersion: validation.value.softwareVersion,
491
+ createdAt: now,
492
+ });
493
+ return json({
494
+ client_id: clientId,
495
+ client_secret: clientSecret,
496
+ client_id_issued_at: Math.floor(now / 1000),
497
+ client_secret_expires_at: 0,
498
+ redirect_uris: [validation.value.redirectUri],
499
+ client_name: validation.value.clientName,
500
+ software_id: app.appId,
501
+ software_version: validation.value.softwareVersion,
502
+ deployment_type: validation.value.deploymentType,
503
+ token_endpoint_auth_method: validation.value.tokenEndpointAuthMethod,
504
+ grant_types: ["authorization_code"],
505
+ response_types: ["code"],
506
+ }, 201);
507
+ });
508
+ const token = httpActionGeneric(async (ctx, request) => {
509
+ const contentType = request.headers.get("content-type")?.toLowerCase() ?? "";
510
+ if (!contentType.startsWith("application/x-www-form-urlencoded")) {
511
+ return oauthError(415, "invalid_request", "The body must be application/x-www-form-urlencoded.");
512
+ }
513
+ const params = new URLSearchParams(await request.text());
514
+ const clientAuth = readClientAuthentication(request.headers.get("authorization"), params);
515
+ if (!clientAuth.ok) {
516
+ return oauthError(clientAuth.status, clientAuth.error, clientAuth.description);
517
+ }
518
+ const validation = validateTokenRequest(params, clientAuth.value);
519
+ if (!validation.ok) {
520
+ return oauthError(validation.status, validation.error, validation.description);
521
+ }
522
+ const client = await ctx.runQuery(component.clients.get, {
523
+ clientId: clientAuth.value.clientId,
524
+ });
525
+ if (client === null ||
526
+ client.tokenEndpointAuthMethod !== clientAuth.value.method ||
527
+ client.secretHash !== (await sha256Hex(clientAuth.value.clientSecret))) {
528
+ return oauthError(401, "invalid_client", "Client authentication failed.");
529
+ }
530
+ const now = Date.now();
531
+ const exchange = await ctx.runMutation(component.authRequests.exchangeCode, {
532
+ codeHash: await sha256Hex(validation.value.code),
533
+ clientId: client.clientId,
534
+ ...(validation.value.redirectUri === undefined
535
+ ? {}
536
+ : { redirectUri: validation.value.redirectUri }),
537
+ codeVerifier: validation.value.codeVerifier,
538
+ now,
539
+ });
540
+ if (!exchange.ok) {
541
+ return oauthError(400, "invalid_grant", exchange.description);
542
+ }
543
+ const keys = keySet();
544
+ const sub = await pairwiseSubject({
545
+ appId: exchange.appId,
546
+ connector: exchange.identity.connector,
547
+ upstreamId: exchange.identity.upstreamId,
548
+ salt: keys.pairwiseSalt,
549
+ });
550
+ const claims = { sub, connector: exchange.identity.connector };
551
+ if (exchange.identity.email !== undefined) {
552
+ claims.email = exchange.identity.email;
553
+ claims.email_verified = exchange.identity.emailVerified === true;
554
+ }
555
+ if (exchange.identity.name !== undefined) {
556
+ claims.name = exchange.identity.name;
557
+ }
558
+ if (exchange.identity.picture !== undefined) {
559
+ claims.picture = exchange.identity.picture;
560
+ }
561
+ const [idToken, accessToken] = await Promise.all([
562
+ signIdToken({
563
+ keySet: keys,
564
+ issuer,
565
+ clientId: client.clientId,
566
+ claims,
567
+ authTime: exchange.authTime,
568
+ nonce: exchange.nonce,
569
+ }),
570
+ signAccessToken({ keySet: keys, issuer, clientId: client.clientId, claims }),
571
+ ]);
572
+ await ctx.runMutation(component.signIns.record, {
573
+ appId: exchange.appId,
574
+ clientId: client.clientId,
575
+ connector: exchange.identity.connector,
576
+ sub,
577
+ at: now,
578
+ });
579
+ return json({
580
+ access_token: accessToken,
581
+ token_type: "Bearer",
582
+ expires_in: TOKEN_TTL_SECONDS,
583
+ id_token: idToken,
584
+ scope: exchange.scope,
585
+ });
586
+ });
587
+ const userinfo = httpActionGeneric(async (_ctx, request) => {
588
+ const bearer = readBearerToken(request.headers.get("authorization"));
589
+ const claims = bearer === null ? null : await verifyAccessToken({ keySet: keySet(), issuer, token: bearer });
590
+ if (claims === null) {
591
+ return json({
592
+ error: "invalid_token",
593
+ error_description: "The access token is missing, invalid or expired.",
594
+ }, 401, {
595
+ "WWW-Authenticate": 'Bearer error="invalid_token"',
596
+ });
597
+ }
598
+ return json(claims);
599
+ });
600
+ return {
601
+ /** Mounts every endpoint on the host's router. */
602
+ addHttpRoutes(http) {
603
+ http.route({
604
+ path: path("/.well-known/openid-configuration"),
605
+ method: "GET",
606
+ handler: discovery,
607
+ });
608
+ http.route({
609
+ path: `/.well-known/oauth-authorization-server${prefix}`,
610
+ method: "GET",
611
+ handler: discovery,
612
+ });
613
+ http.route({ path: path("/.well-known/jwks.json"), method: "GET", handler: jwks });
614
+ http.route({ path: path("/authorize"), method: "GET", handler: authorize });
615
+ http.route({ path: path("/authorize"), method: "POST", handler: authorize });
616
+ http.route({ path: path("/continue"), method: "GET", handler: continueSignIn });
617
+ http.route({ path: path("/register"), method: "POST", handler: register });
618
+ http.route({ path: path("/token"), method: "POST", handler: token });
619
+ http.route({ path: path("/userinfo"), method: "GET", handler: userinfo });
620
+ http.route({ path: path("/userinfo"), method: "POST", handler: userinfo });
621
+ for (const connector of connectors.values()) {
622
+ if (connector.kind === "email") {
623
+ http.route({ path: path("/connectors/email"), method: "POST", handler: emailStep });
624
+ }
625
+ else {
626
+ http.route({
627
+ path: path(`/connectors/${connector.name}/callback`),
628
+ method: "GET",
629
+ handler: upstreamCallback(connector),
630
+ });
631
+ }
632
+ }
633
+ },
634
+ /**
635
+ * Administration as internal functions of the host, for `npx convex run`: export them from a
636
+ * host module such as `convex/identityBroker.ts`.
637
+ */
638
+ admin() {
639
+ const { array, boolean, number, optional, string } = v;
640
+ return {
641
+ createApp: internalMutationGeneric({
642
+ args: { name: string(), firstParty: boolean(), allowedCallbackHosts: array(string()) },
643
+ returns: v.object({ appId: string() }),
644
+ handler: async (ctx, args) => {
645
+ const slug = args.name
646
+ .toLowerCase()
647
+ .replace(/[^a-z0-9]+/g, "-")
648
+ .replace(/^-|-$/g, "") || "app";
649
+ const appId = `${slug}-${randomToken(4)
650
+ .toLowerCase()
651
+ .replace(/[^a-z0-9]/g, "")
652
+ .slice(0, 5)}`;
653
+ await ctx.runMutation(component.apps.create, {
654
+ appId,
655
+ name: args.name,
656
+ firstParty: args.firstParty,
657
+ allowedCallbackHosts: args.allowedCallbackHosts,
658
+ createdAt: Date.now(),
659
+ });
660
+ return { appId };
661
+ },
662
+ }),
663
+ mintRegistrationToken: internalMutationGeneric({
664
+ args: { appId: string(), expiresInDays: optional(number()) },
665
+ returns: v.object({ token: string(), expiresAt: number() }),
666
+ handler: async (ctx, args) => {
667
+ const token = `rt_${randomToken(32)}`;
668
+ const now = Date.now();
669
+ const expiresAt = now +
670
+ (args.expiresInDays === undefined
671
+ ? REGISTRATION_TOKEN_TTL_MS
672
+ : args.expiresInDays * 24 * 60 * 60 * 1000);
673
+ await ctx.runMutation(component.registration.mintToken, {
674
+ tokenHash: await sha256Hex(token),
675
+ appId: args.appId,
676
+ expiresAt,
677
+ createdAt: now,
678
+ });
679
+ return { token, expiresAt };
680
+ },
681
+ }),
682
+ setAllowedCallbackHosts: internalMutationGeneric({
683
+ args: { appId: string(), allowedCallbackHosts: array(string()) },
684
+ returns: v.null(),
685
+ handler: async (ctx, args) => {
686
+ await ctx.runMutation(component.apps.setAllowedCallbackHosts, {
687
+ appId: args.appId,
688
+ allowedCallbackHosts: args.allowedCallbackHosts,
689
+ });
690
+ return null;
691
+ },
692
+ }),
693
+ deleteApp: internalMutationGeneric({
694
+ args: { appId: string() },
695
+ returns: v.null(),
696
+ handler: async (ctx, args) => {
697
+ await ctx.runMutation(component.apps.remove, { appId: args.appId });
698
+ return null;
699
+ },
700
+ }),
701
+ listApps: internalQueryGeneric({
702
+ args: {},
703
+ handler: async (ctx) => await ctx.runQuery(component.apps.list, {}),
704
+ }),
705
+ listClients: internalQueryGeneric({
706
+ args: { appId: string() },
707
+ handler: async (ctx, args) => await ctx.runQuery(component.clients.listByApp, { appId: args.appId }),
708
+ }),
709
+ cleanup: internalMutationGeneric({
710
+ args: {},
711
+ handler: async (ctx) => await ctx.runMutation(component.lifecycle.cleanup, {
712
+ now: Date.now(),
713
+ previewClientMaxAgeMs: PREVIEW_CLIENT_MAX_AGE_MS,
714
+ signInRetentionMs: SIGN_IN_RETENTION_MS,
715
+ }),
716
+ }),
717
+ };
718
+ },
719
+ };
720
+ }
721
+ //# sourceMappingURL=index.js.map