@samebase/identity-broker 0.1.0 → 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/README.md CHANGED
@@ -18,6 +18,9 @@ registers each deployment as a client at deploy time and never touches GitHub or
18
18
  your own action. `github()`, `google()` and `email()` are included.
19
19
  - A hub is a small Convex app that mounts the component. Fork
20
20
  [samebase/identity-hub](https://github.com/samebase/identity-hub) for one.
21
+ - `@samebase/alchemy-identity-hub`: an Alchemy v2 resource, `IdentityHub.Client`, that registers a
22
+ deployment as a client at deploy time. See
23
+ [packages/alchemy-identity-hub](https://github.com/samebase/identity-broker/tree/main/packages/alchemy-identity-hub).
21
24
 
22
25
  ## Mount it
23
26
 
@@ -62,12 +65,23 @@ export default http;
62
65
  ```ts
63
66
  // convex/identityBroker.ts: administration for `npx convex run`
64
67
  import { broker } from "./http";
65
- export const { createApp, mintRegistrationToken, deleteApp, listApps, listClients, cleanup } =
66
- broker.admin();
68
+ export const {
69
+ createApp,
70
+ mintRegistrationToken,
71
+ listRegistrationTokens,
72
+ revokeRegistrationToken,
73
+ setAllowedCallbackHosts,
74
+ deleteClient,
75
+ deleteApp,
76
+ listApps,
77
+ listClients,
78
+ cleanup,
79
+ } = broker.admin();
67
80
  ```
68
81
 
69
82
  Keys: `npx identity-broker keys` prints the value for `IDENTITY_BROKER_KEYS`; `keys --rotate`
70
- advances it.
83
+ advances it, and `keys --rotate --no-overlap` replaces a compromised key at once. Back up the key
84
+ set: its salt makes every `sub`. `parseKeySet` checks a value before a deploy.
71
85
 
72
86
  ## Register an app's deployments
73
87
 
package/dist/cli/index.js CHANGED
@@ -1,7 +1,10 @@
1
1
  #!/usr/bin/env node
2
- import { createKeySet, parseKeySet, rotateKeySet } from "../client/keys.js";
3
- const USAGE = `identity-broker keys print a new key set for IDENTITY_BROKER_KEYS
4
- identity-broker keys --rotate read the current key set on stdin, print the rotated one`;
2
+ import { createKeySet, parseKeySet, rotateKeySet, rotateKeySetWithoutOverlap, } from "../client/keys.js";
3
+ const USAGE = `identity-broker keys print a new key set for IDENTITY_BROKER_KEYS
4
+ identity-broker keys --rotate read the current key set on stdin, print the rotated
5
+ one; the retired public key stays for 14 days
6
+ identity-broker keys --rotate --no-overlap the same after a key compromise: retired keys are
7
+ dropped at once; the salt is kept`;
5
8
  async function readStdin() {
6
9
  const chunks = [];
7
10
  for await (const chunk of process.stdin) {
@@ -14,8 +17,18 @@ async function main(argv) {
14
17
  console.error(USAGE);
15
18
  return 2;
16
19
  }
17
- if (argv.includes("--rotate")) {
18
- const rotated = await rotateKeySet(parseKeySet(await readStdin()));
20
+ const rotate = argv.includes("--rotate");
21
+ const noOverlap = argv.includes("--no-overlap");
22
+ // Without --rotate, --no-overlap would print a new key set with a new salt: every sub changes.
23
+ if (noOverlap && !rotate) {
24
+ console.error(USAGE);
25
+ return 2;
26
+ }
27
+ if (rotate) {
28
+ const current = parseKeySet(await readStdin());
29
+ const rotated = noOverlap
30
+ ? await rotateKeySetWithoutOverlap(current)
31
+ : await rotateKeySet(current);
19
32
  process.stdout.write(`${JSON.stringify(rotated)}\n`);
20
33
  return 0;
21
34
  }
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/cli/index.ts"],"names":[],"mappings":";AACA,OAAO,EAAE,YAAY,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAE5E,MAAM,KAAK,GAAG;yFAC2E,CAAC;AAE1F,KAAK,UAAU,SAAS;IACtB,MAAM,MAAM,GAAa,EAAE,CAAC;IAC5B,IAAI,KAAK,EAAE,MAAM,KAAK,IAAI,OAAO,CAAC,KAAK,EAAE,CAAC;QACxC,MAAM,CAAC,IAAI,CAAC,OAAO,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC;IACtE,CAAC;IACD,OAAO,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;AAChD,CAAC;AAED,KAAK,UAAU,IAAI,CAAC,IAAc;IAChC,IAAI,IAAI,CAAC,CAAC,CAAC,KAAK,MAAM,EAAE,CAAC;QACvB,OAAO,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;QACrB,OAAO,CAAC,CAAC;IACX,CAAC;IACD,IAAI,IAAI,CAAC,QAAQ,CAAC,UAAU,CAAC,EAAE,CAAC;QAC9B,MAAM,OAAO,GAAG,MAAM,YAAY,CAAC,WAAW,CAAC,MAAM,SAAS,EAAE,CAAC,CAAC,CAAC;QACnE,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,GAAG,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;QACrD,OAAO,CAAC,CAAC;IACX,CAAC;IACD,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,GAAG,IAAI,CAAC,SAAS,CAAC,MAAM,YAAY,EAAE,CAAC,IAAI,CAAC,CAAC;IAClE,OAAO,CAAC,CAAC;AACX,CAAC;AAED,OAAO,CAAC,QAAQ,GAAG,MAAM,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/cli/index.ts"],"names":[],"mappings":";AACA,OAAO,EACL,YAAY,EACZ,WAAW,EACX,YAAY,EACZ,0BAA0B,GAC3B,MAAM,mBAAmB,CAAC;AAE3B,MAAM,KAAK,GAAG;;;;+EAIiE,CAAC;AAEhF,KAAK,UAAU,SAAS;IACtB,MAAM,MAAM,GAAa,EAAE,CAAC;IAC5B,IAAI,KAAK,EAAE,MAAM,KAAK,IAAI,OAAO,CAAC,KAAK,EAAE,CAAC;QACxC,MAAM,CAAC,IAAI,CAAC,OAAO,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC;IACtE,CAAC;IACD,OAAO,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;AAChD,CAAC;AAED,KAAK,UAAU,IAAI,CAAC,IAAc;IAChC,IAAI,IAAI,CAAC,CAAC,CAAC,KAAK,MAAM,EAAE,CAAC;QACvB,OAAO,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;QACrB,OAAO,CAAC,CAAC;IACX,CAAC;IACD,MAAM,MAAM,GAAG,IAAI,CAAC,QAAQ,CAAC,UAAU,CAAC,CAAC;IACzC,MAAM,SAAS,GAAG,IAAI,CAAC,QAAQ,CAAC,cAAc,CAAC,CAAC;IAChD,+FAA+F;IAC/F,IAAI,SAAS,IAAI,CAAC,MAAM,EAAE,CAAC;QACzB,OAAO,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;QACrB,OAAO,CAAC,CAAC;IACX,CAAC;IACD,IAAI,MAAM,EAAE,CAAC;QACX,MAAM,OAAO,GAAG,WAAW,CAAC,MAAM,SAAS,EAAE,CAAC,CAAC;QAC/C,MAAM,OAAO,GAAG,SAAS;YACvB,CAAC,CAAC,MAAM,0BAA0B,CAAC,OAAO,CAAC;YAC3C,CAAC,CAAC,MAAM,YAAY,CAAC,OAAO,CAAC,CAAC;QAChC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,GAAG,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;QACrD,OAAO,CAAC,CAAC;IACX,CAAC;IACD,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,GAAG,IAAI,CAAC,SAAS,CAAC,MAAM,YAAY,EAAE,CAAC,IAAI,CAAC,CAAC;IAClE,OAAO,CAAC,CAAC;AACX,CAAC;AAED,OAAO,CAAC,QAAQ,GAAG,MAAM,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC"}
@@ -1,5 +1,5 @@
1
1
  /** The few pages the broker renders itself. Plain HTML, one stylesheet, no script. */
2
- export declare function page(title: string, body: string): Response;
2
+ export declare function page(title: string, body: string, status?: number): Response;
3
3
  /**
4
4
  * The connector choice. A third-party app gets the consent wording and a deny link; a first-party
5
5
  * app only sees the buttons.
@@ -7,6 +7,8 @@ export declare function page(title: string, body: string): Response;
7
7
  export declare function chooserPage(args: {
8
8
  appName: string;
9
9
  firstParty: boolean;
10
+ /** The granted scopes: the consent names only what they release. */
11
+ scopes: string[];
10
12
  connectors: Array<{
11
13
  name: string;
12
14
  label: string;
@@ -19,6 +21,7 @@ export declare function emailAddressPage(args: {
19
21
  action: string;
20
22
  requestId: string;
21
23
  error?: string;
24
+ status?: number;
22
25
  }): Response;
23
26
  export declare function emailCodePage(args: {
24
27
  appName: string;
@@ -26,5 +29,6 @@ export declare function emailCodePage(args: {
26
29
  requestId: string;
27
30
  email: string;
28
31
  error?: string;
32
+ status?: number;
29
33
  }): Response;
30
34
  export declare function errorPage(title: string, message: string, status?: number): Response;
@@ -20,7 +20,7 @@ const STYLE = `
20
20
  .error { color: #b00020; }
21
21
  .muted { color: #555; }
22
22
  `;
23
- export function page(title, body) {
23
+ export function page(title, body, status = 200) {
24
24
  const html = `<!doctype html>
25
25
  <html lang="en">
26
26
  <head>
@@ -33,7 +33,7 @@ export function page(title, body) {
33
33
  <body><main>${body}</main></body>
34
34
  </html>`;
35
35
  return new Response(html, {
36
- status: 200,
36
+ status,
37
37
  headers: {
38
38
  "Content-Type": "text/html; charset=utf-8",
39
39
  "Cache-Control": "no-store",
@@ -49,9 +49,17 @@ export function chooserPage(args) {
49
49
  const buttons = args.connectors
50
50
  .map((connector) => `<a class="button" href="${escape(connector.href)}">Continue with ${escape(connector.label)}</a>`)
51
51
  .join("\n");
52
+ const released = [
53
+ "a stable id",
54
+ ...(args.scopes.includes("profile") ? ["your name and picture"] : []),
55
+ ...(args.scopes.includes("email") ? ["your verified email address"] : []),
56
+ ];
57
+ const list = released.length <= 2
58
+ ? released.join(" and ")
59
+ : `${released.slice(0, -1).join(", ")}, and ${released.slice(-1).join("")}`;
52
60
  const consent = args.firstParty
53
61
  ? ""
54
- : `<p class="muted">${escape(args.appName)} will receive your identity from the provider you choose: a stable id, your name, and your verified email address.</p>`;
62
+ : `<p class="muted">${escape(args.appName)} will receive your identity from the provider you choose: ${list}.</p>`;
55
63
  const deny = args.firstParty
56
64
  ? ""
57
65
  : `<p><a href="${escape(args.denyHref)}">Cancel and return to ${escape(args.appName)}</a></p>`;
@@ -65,7 +73,7 @@ export function emailAddressPage(args) {
65
73
  <input type="hidden" name="step" value="send">
66
74
  <input type="email" name="email" required autocomplete="email" autofocus placeholder="you@example.com">
67
75
  <button type="submit">Send code</button>
68
- </form>`);
76
+ </form>`, args.status);
69
77
  }
70
78
  export function emailCodePage(args) {
71
79
  const error = args.error === undefined ? "" : `<p class="error">${escape(args.error)}</p>`;
@@ -75,10 +83,9 @@ export function emailCodePage(args) {
75
83
  <input type="hidden" name="step" value="verify">
76
84
  <input type="text" name="code" inputmode="numeric" pattern="[0-9]{6}" required autocomplete="one-time-code" autofocus placeholder="000000">
77
85
  <button type="submit">Continue</button>
78
- </form>`);
86
+ </form>`, args.status);
79
87
  }
80
88
  export function errorPage(title, message, status = 400) {
81
- const response = page(title, `<h1>${escape(title)}</h1><p>${escape(message)}</p>`);
82
- return new Response(response.body, { status, headers: response.headers });
89
+ return page(title, `<h1>${escape(title)}</h1><p>${escape(message)}</p>`, status);
83
90
  }
84
91
  //# sourceMappingURL=html.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"html.js","sourceRoot":"","sources":["../../src/client/html.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"html.js","sourceRoot":"","sources":["../../src/client/html.ts"],"names":[],"mappings":"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"}
@@ -5,7 +5,7 @@ export type { Connector, EmailConnector, OauthConnector, OidcConnector, SendEmai
5
5
  export { github } from "./connectors/github.js";
6
6
  export { google } from "./connectors/google.js";
7
7
  export { email } from "./connectors/email.js";
8
- export { createKeySet, rotateKeySet, type KeySet } from "./keys.js";
8
+ export { createKeySet, parseKeySet, rotateKeySet, type KeySet } from "./keys.js";
9
9
  export type IdentityBrokerConfig = {
10
10
  /** `components.identityBroker` of the host app. */
11
11
  component: ComponentApi;
@@ -44,6 +44,19 @@ export declare function defineIdentityBroker(config: IdentityBrokerConfig): {
44
44
  allowedCallbackHosts: string[];
45
45
  appId: string;
46
46
  }, Promise<null>>;
47
+ listRegistrationTokens: import("convex/server").RegisteredQuery<"internal", {
48
+ appId: string;
49
+ }, Promise<{
50
+ createdAt: number;
51
+ expiresAt: number;
52
+ tokenHash: string;
53
+ }[]>>;
54
+ revokeRegistrationToken: import("convex/server").RegisteredMutation<"internal", {
55
+ tokenHash: string;
56
+ }, Promise<null>>;
57
+ deleteClient: import("convex/server").RegisteredMutation<"internal", {
58
+ clientId: string;
59
+ }, Promise<null>>;
47
60
  deleteApp: import("convex/server").RegisteredMutation<"internal", {
48
61
  appId: string;
49
62
  }, Promise<null>>;
@@ -71,7 +84,9 @@ export declare function defineIdentityBroker(config: IdentityBrokerConfig): {
71
84
  cleanup: import("convex/server").RegisteredMutation<"internal", {}, Promise<{
72
85
  authRequests: number;
73
86
  authorizationCodes: number;
87
+ emailSends: number;
74
88
  previewClients: number;
89
+ registrationTokens: number;
75
90
  signIns: number;
76
91
  }>>;
77
92
  };
@@ -11,13 +11,15 @@ import { pairwiseSubject, randomDigits, randomToken, sha256Hex } from "./subject
11
11
  export { github } from "./connectors/github.js";
12
12
  export { google } from "./connectors/google.js";
13
13
  export { email } from "./connectors/email.js";
14
- export { createKeySet, rotateKeySet } from "./keys.js";
14
+ export { createKeySet, parseKeySet, rotateKeySet } from "./keys.js";
15
15
  /** A request lives this long from /authorize to the code redirect. */
16
16
  const AUTH_REQUEST_TTL_MS = 10 * 60 * 1000;
17
17
  /** A code is redeemed by the client's callback at once. */
18
18
  const CODE_TTL_MS = 60 * 1000;
19
19
  const EMAIL_CODE_TTL_MS = 10 * 60 * 1000;
20
20
  const EMAIL_MAX_ATTEMPTS = 5;
21
+ /** The scopes the broker grants; any other requested scope is dropped. */
22
+ const SCOPES = ["openid", "email", "profile"];
21
23
  const REGISTRATION_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000;
22
24
  /** Lifecycle values the host's cron passes to `cleanup`. */
23
25
  export const PREVIEW_CLIENT_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
@@ -30,6 +32,9 @@ export function defineIdentityBroker(config) {
30
32
  const issuer = issuerUrl.href.replace(/\/$/, "");
31
33
  const prefix = issuerUrl.pathname.replace(/\/$/, "");
32
34
  const path = (suffix) => `${prefix}${suffix}`;
35
+ /** Binds the chooser's links to its browser. `__Host-` needs Path=/, so a path issuer uses a plain name. */
36
+ const txnCookieName = prefix === "" ? "__Host-broker_txn" : "broker_txn";
37
+ const txnCookieAttributes = `HttpOnly; Secure; SameSite=Lax; Path=${prefix === "" ? "/" : prefix}`;
33
38
  const component = config.component;
34
39
  const connectors = new Map(config.connectors.map((connector) => [connector.name, connector]));
35
40
  if (connectors.size !== config.connectors.length) {
@@ -47,7 +52,7 @@ export function defineIdentityBroker(config) {
47
52
  userinfo_endpoint: `${issuer}/userinfo`,
48
53
  jwks_uri: `${issuer}/.well-known/jwks.json`,
49
54
  registration_endpoint: `${issuer}/register`,
50
- scopes_supported: ["openid", "email", "profile"],
55
+ scopes_supported: SCOPES,
51
56
  response_types_supported: ["code"],
52
57
  response_modes_supported: ["query"],
53
58
  grant_types_supported: ["authorization_code"],
@@ -165,6 +170,15 @@ export function defineIdentityBroker(config) {
165
170
  });
166
171
  }
167
172
  const freshLogin = rest.value.prompt.includes("login") || rest.value.maxAge !== undefined;
173
+ const scopes = SCOPES.filter((scope) => rest.value.scopes.includes(scope));
174
+ // A first-party app may skip the chooser: the one it named, or the only one. A third-party app
175
+ // always gets the page, which doubles as consent (RFC 9700 4.11).
176
+ const named = rest.value.connector === undefined ? undefined : connectors.get(rest.value.connector);
177
+ const only = connectors.size === 1 ? [...connectors.values()][0] : undefined;
178
+ const chosen = app.firstParty ? (named ?? only) : undefined;
179
+ // The chooser's links work only with this cookie, so a client that fetches the chooser itself
180
+ // cannot send a user's browser on from it.
181
+ const txnCookie = randomToken(32);
168
182
  const now = Date.now();
169
183
  const requestId = randomToken(24);
170
184
  await ctx.runMutation(component.authRequests.create, {
@@ -172,34 +186,32 @@ export function defineIdentityBroker(config) {
172
186
  appId: app.appId,
173
187
  clientId: client.clientId,
174
188
  redirectUri: client.redirectUri,
175
- scope: rest.value.scopes.join(" "),
189
+ scope: scopes.join(" "),
176
190
  ...(rest.value.state === undefined ? {} : { state: rest.value.state }),
177
191
  ...(rest.value.nonce === undefined ? {} : { nonce: rest.value.nonce }),
178
192
  codeChallenge: rest.value.codeChallenge,
179
193
  freshLogin,
194
+ ...(chosen === undefined ? { txnCookieHash: await sha256Hex(txnCookie) } : {}),
180
195
  createdAt: now,
181
196
  expiresAt: now + AUTH_REQUEST_TTL_MS,
182
197
  });
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
- }
198
+ if (chosen !== undefined) {
199
+ return await startConnector(ctx, {
200
+ connector: chosen,
201
+ request: {
202
+ requestId,
203
+ appId: app.appId,
204
+ redirectUri: client.redirectUri,
205
+ state: rest.value.state,
206
+ freshLogin,
207
+ },
208
+ appName: app.name,
209
+ });
199
210
  }
200
- return chooserPage({
211
+ const page = chooserPage({
201
212
  appName: app.name,
202
213
  firstParty: app.firstParty,
214
+ scopes,
203
215
  connectors: [...connectors.values()].map((connector) => ({
204
216
  name: connector.name,
205
217
  label: connector.label,
@@ -207,6 +219,8 @@ export function defineIdentityBroker(config) {
207
219
  })),
208
220
  denyHref: `${path("/continue")}?request=${encodeURIComponent(requestId)}&deny=1`,
209
221
  });
222
+ page.headers.append("Set-Cookie", `${txnCookieName}=${txnCookie}; ${txnCookieAttributes}`);
223
+ return page;
210
224
  });
211
225
  /** A chooser click: start the chosen connector, or abandon the request on deny. */
212
226
  const continueSignIn = httpActionGeneric(async (ctx, request) => {
@@ -216,6 +230,17 @@ export function defineIdentityBroker(config) {
216
230
  if (stored === null) {
217
231
  return errorPage("Sign-in not found", "This sign-in is unknown or finished. Return to the app and try again.", 404);
218
232
  }
233
+ const txnCookie = request.headers
234
+ .get("cookie")
235
+ ?.split(";")
236
+ .map((pair) => pair.trim())
237
+ .find((pair) => pair.startsWith(`${txnCookieName}=`))
238
+ ?.slice(txnCookieName.length + 1);
239
+ if (stored.txnCookieHash === undefined ||
240
+ txnCookie === undefined ||
241
+ (await sha256Hex(txnCookie)) !== stored.txnCookieHash) {
242
+ return errorPage("Sign-in not continued", "This sign-in did not start in this browser. Return to the app and try again.", 400);
243
+ }
219
244
  const back = { redirectUri: stored.redirectUri, issuer, state: stored.state };
220
245
  if (url.searchParams.get("deny") !== null) {
221
246
  await ctx.runMutation(component.authRequests.abandon, { requestId });
@@ -262,6 +287,7 @@ export function defineIdentityBroker(config) {
262
287
  const now = Date.now();
263
288
  const claim = await ctx.runMutation(component.authRequests.claimUpstream, {
264
289
  upstreamStateHash: await sha256Hex(state),
290
+ connector: connector.name,
265
291
  now,
266
292
  });
267
293
  if (claim.status === "missing") {
@@ -288,14 +314,24 @@ export function defineIdentityBroker(config) {
288
314
  }
289
315
  catch (error) {
290
316
  await ctx.runMutation(component.authRequests.abandon, { requestId: claim.requestId });
291
- if (error instanceof oauth.AuthorizationResponseError) {
317
+ // The provider's error code picks one fixed answer; nothing the provider wrote is echoed.
318
+ if (error instanceof oauth.AuthorizationResponseError && error.error === "access_denied") {
319
+ return authorizationError({
320
+ ...back,
321
+ error: "access_denied",
322
+ description: `${connector.label} sign-in was declined.`,
323
+ });
324
+ }
325
+ if (error instanceof oauth.AuthorizationResponseError &&
326
+ error.error === "temporarily_unavailable") {
292
327
  return authorizationError({
293
328
  ...back,
294
- error: error.error === "access_denied" ? "access_denied" : "server_error",
295
- description: `${connector.label} answered ${error.error}.`,
329
+ error: "temporarily_unavailable",
330
+ description: `${connector.label} is temporarily unavailable.`,
296
331
  });
297
332
  }
298
- console.error(`Connector "${connector.name}" failed`, error);
333
+ // Only the class name: provider errors carry token responses and decoded claims.
334
+ console.error(`Connector "${connector.name}" failed: ${error instanceof Error ? error.constructor.name : typeof error}`);
299
335
  return authorizationError({
300
336
  ...back,
301
337
  error: "server_error",
@@ -362,24 +398,59 @@ export function defineIdentityBroker(config) {
362
398
  requestId,
363
399
  connector: emailConnector.name,
364
400
  email: address,
401
+ emailHash: await sha256Hex(address),
365
402
  emailCodeHash: await sha256Hex(code),
366
403
  emailCodeExpiresAt: now + EMAIL_CODE_TTL_MS,
404
+ maxAttempts: EMAIL_MAX_ATTEMPTS,
367
405
  now,
368
406
  });
369
407
  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
- });
408
+ switch (started.error) {
409
+ case "not_found":
410
+ return errorPage("Sign-in not found", "This sign-in is unknown. Return to the app and try again.", 404);
411
+ case "expired":
412
+ case "wrong_status":
413
+ return authorizationError({
414
+ ...back,
415
+ error: "access_denied",
416
+ description: "The sign-in expired.",
417
+ });
418
+ case "locked":
419
+ return authorizationError({
420
+ ...back,
421
+ error: "access_denied",
422
+ description: "Too many wrong codes.",
423
+ });
424
+ case "too_soon":
425
+ case "request_limit":
426
+ case "address_limit":
427
+ case "global_limit": {
428
+ // The page the user is on, with one neutral line: nothing says whether the address is
429
+ // known, and neither the address nor a code is logged.
430
+ const error = started.error === "too_soon"
431
+ ? "Wait a minute before asking for another code."
432
+ : "Too many codes were sent. Try again later.";
433
+ return stored.status === "emailSent"
434
+ ? emailCodePage({
435
+ appName: app.name,
436
+ action,
437
+ requestId,
438
+ email: stored.email,
439
+ error,
440
+ status: 429,
441
+ })
442
+ : emailAddressPage({ appName: app.name, action, requestId, error, status: 429 });
443
+ }
444
+ default:
445
+ return started;
446
+ }
377
447
  }
378
448
  try {
379
449
  await ctx.runAction(emailConnector.send, { to: address, code, appName: app.name });
380
450
  }
381
451
  catch (error) {
382
- console.error("Email code delivery failed", error);
452
+ // Only the class name: a delivery error can carry the address or the code.
453
+ console.error(`Connector "${emailConnector.name}" could not send a code: ${error instanceof Error ? error.constructor.name : typeof error}`);
383
454
  return errorPage("Email not sent", "The code could not be sent. Try again in a moment.", 502);
384
455
  }
385
456
  return emailCodePage({ appName: app.name, action, requestId, email: address });
@@ -511,9 +582,12 @@ export function defineIdentityBroker(config) {
511
582
  return oauthError(415, "invalid_request", "The body must be application/x-www-form-urlencoded.");
512
583
  }
513
584
  const params = new URLSearchParams(await request.text());
514
- const clientAuth = readClientAuthentication(request.headers.get("authorization"), params);
585
+ const authorization = request.headers.get("authorization");
586
+ // RFC 6749 section 5.2: a client that authenticated with the header gets a 401 with a challenge.
587
+ const challenge = authorization === null ? {} : { "WWW-Authenticate": 'Basic realm="token"' };
588
+ const clientAuth = readClientAuthentication(authorization, params);
515
589
  if (!clientAuth.ok) {
516
- return oauthError(clientAuth.status, clientAuth.error, clientAuth.description);
590
+ return oauthError(clientAuth.status, clientAuth.error, clientAuth.description, clientAuth.status === 401 ? challenge : {});
517
591
  }
518
592
  const validation = validateTokenRequest(params, clientAuth.value);
519
593
  if (!validation.ok) {
@@ -525,7 +599,7 @@ export function defineIdentityBroker(config) {
525
599
  if (client === null ||
526
600
  client.tokenEndpointAuthMethod !== clientAuth.value.method ||
527
601
  client.secretHash !== (await sha256Hex(clientAuth.value.clientSecret))) {
528
- return oauthError(401, "invalid_client", "Client authentication failed.");
602
+ return oauthError(401, "invalid_client", "Client authentication failed.", challenge);
529
603
  }
530
604
  const now = Date.now();
531
605
  const exchange = await ctx.runMutation(component.authRequests.exchangeCode, {
@@ -547,15 +621,17 @@ export function defineIdentityBroker(config) {
547
621
  upstreamId: exchange.identity.upstreamId,
548
622
  salt: keys.pairwiseSalt,
549
623
  });
624
+ // Claims follow the granted scopes (OIDC Core 5.4); userinfo reads them from the access token.
625
+ const scopes = exchange.scope.split(" ");
550
626
  const claims = { sub, connector: exchange.identity.connector };
551
- if (exchange.identity.email !== undefined) {
627
+ if (scopes.includes("email") && exchange.identity.email !== undefined) {
552
628
  claims.email = exchange.identity.email;
553
629
  claims.email_verified = exchange.identity.emailVerified === true;
554
630
  }
555
- if (exchange.identity.name !== undefined) {
631
+ if (scopes.includes("profile") && exchange.identity.name !== undefined) {
556
632
  claims.name = exchange.identity.name;
557
633
  }
558
- if (exchange.identity.picture !== undefined) {
634
+ if (scopes.includes("profile") && exchange.identity.picture !== undefined) {
559
635
  claims.picture = exchange.identity.picture;
560
636
  }
561
637
  const [idToken, accessToken] = await Promise.all([
@@ -690,6 +766,28 @@ export function defineIdentityBroker(config) {
690
766
  return null;
691
767
  },
692
768
  }),
769
+ listRegistrationTokens: internalQueryGeneric({
770
+ args: { appId: string() },
771
+ handler: async (ctx, args) => await ctx.runQuery(component.registration.listTokens, { appId: args.appId }),
772
+ }),
773
+ revokeRegistrationToken: internalMutationGeneric({
774
+ args: { tokenHash: string() },
775
+ returns: v.null(),
776
+ handler: async (ctx, args) => {
777
+ await ctx.runMutation(component.registration.revokeToken, {
778
+ tokenHash: args.tokenHash,
779
+ });
780
+ return null;
781
+ },
782
+ }),
783
+ deleteClient: internalMutationGeneric({
784
+ args: { clientId: string() },
785
+ returns: v.null(),
786
+ handler: async (ctx, args) => {
787
+ await ctx.runMutation(component.clients.remove, { clientId: args.clientId });
788
+ return null;
789
+ },
790
+ }),
693
791
  deleteApp: internalMutationGeneric({
694
792
  args: { appId: string() },
695
793
  returns: v.null(),