@agent-native/core 0.151.1 → 0.152.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 (256) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/.agents/skills/sharing/SKILL.md +1 -1
  3. package/corpus/templates/analytics/.agents/skills/turn-into-app/SKILL.md +24 -0
  4. package/corpus/templates/analytics/actions/bigquery-query.ts +21 -0
  5. package/corpus/templates/analytics/app/components/ConnectionTestStatus.tsx +65 -0
  6. package/corpus/templates/analytics/app/components/layout/Sidebar.tsx +3 -3
  7. package/corpus/templates/analytics/app/pages/DataSources.tsx +15 -37
  8. package/corpus/templates/analytics/app/root.tsx +0 -1
  9. package/corpus/templates/analytics/changelog/2026-08-11-analytics-custom-blocks-keep-loading-with-legacy-bigquery-action.md +6 -0
  10. package/corpus/templates/analytics/changelog/2026-08-11-analytics-routes-company-knowledge-and-slack-context-questio.md +6 -0
  11. package/corpus/templates/analytics/changelog/2026-08-11-browser-no-longer-offers-desktop-install.md +6 -0
  12. package/corpus/templates/analytics/public/manifest.json +0 -1
  13. package/corpus/templates/analytics/server/lib/agent-chat-plan-mode.ts +5 -0
  14. package/corpus/templates/analytics/server/plugins/agent-chat.ts +5 -0
  15. package/corpus/templates/assets/.agents/skills/sharing/SKILL.md +1 -1
  16. package/corpus/templates/assets/.agents/skills/turn-into-app/SKILL.md +24 -0
  17. package/corpus/templates/assets/app/root.tsx +0 -1
  18. package/corpus/templates/assets/changelog/2026-08-11-browser-no-longer-offers-desktop-install.md +6 -0
  19. package/corpus/templates/assets/public/manifest.json +0 -1
  20. package/corpus/templates/brain/.agents/skills/sharing/SKILL.md +1 -1
  21. package/corpus/templates/brain/.agents/skills/turn-into-app/SKILL.md +24 -0
  22. package/corpus/templates/brain/app/root.tsx +0 -1
  23. package/corpus/templates/brain/changelog/2026-08-11-browser-no-longer-offers-desktop-install.md +6 -0
  24. package/corpus/templates/brain/public/manifest.json +0 -1
  25. package/corpus/templates/calendar/.agents/skills/sharing/SKILL.md +1 -1
  26. package/corpus/templates/calendar/.agents/skills/turn-into-app/SKILL.md +24 -0
  27. package/corpus/templates/calendar/app/components/calendar/GoogleConnectBanner.tsx +0 -53
  28. package/corpus/templates/calendar/app/i18n-data.ts +0 -80
  29. package/corpus/templates/calendar/app/root.tsx +0 -1
  30. package/corpus/templates/calendar/changelog/2026-08-11-browser-no-longer-offers-desktop-install.md +6 -0
  31. package/corpus/templates/calendar/changelog/2026-08-11-google-calendar-connection-buttons-now-open-the-sign-in-flow.md +6 -0
  32. package/corpus/templates/calendar/public/manifest.json +0 -1
  33. package/corpus/templates/calendar/server/plugins/auth.ts +0 -10
  34. package/corpus/templates/chat/.agents/skills/sharing/SKILL.md +1 -1
  35. package/corpus/templates/chat/.agents/skills/turn-into-app/SKILL.md +24 -0
  36. package/corpus/templates/chat/app/root.tsx +0 -1
  37. package/corpus/templates/chat/changelog/2026-08-11-browser-no-longer-offers-desktop-install.md +6 -0
  38. package/corpus/templates/chat/public/manifest.json +0 -1
  39. package/corpus/templates/clips/.agents/skills/turn-into-app/SKILL.md +24 -0
  40. package/corpus/templates/clips/app/components/player/playback-comment-overlay.tsx +1 -1
  41. package/corpus/templates/clips/app/components/player/share-dialog.tsx +6 -0
  42. package/corpus/templates/clips/app/components/sharing/share-ui.tsx +20 -3
  43. package/corpus/templates/clips/app/i18n/en-US.ts +4 -4
  44. package/corpus/templates/clips/app/root.tsx +0 -1
  45. package/corpus/templates/clips/app/routes/_app.meetings._index.tsx +1 -3
  46. package/corpus/templates/clips/changelog/2026-08-11-browser-no-longer-offers-desktop-install.md +6 -0
  47. package/corpus/templates/clips/changelog/2026-08-11-clips-desktop-settings-are-now-easier-to-scan-with-a-wider-t.md +6 -0
  48. package/corpus/templates/clips/changelog/2026-08-11-google-calendar-connections-from-meetings-now-open-directly.md +6 -0
  49. package/corpus/templates/clips/changelog/2026-08-11-recording-viewers-can-comment-and-react.md +6 -0
  50. package/corpus/templates/clips/changelog/2026-08-11-system-audio-recording-now-lives-inside-the-microphone-menu-.md +6 -0
  51. package/corpus/templates/clips/changelog/2026-08-11-timestamped-comments-stay-on-the-video-for-three-seconds-wh.md +6 -0
  52. package/corpus/templates/clips/desktop/src/app.tsx +773 -1001
  53. package/corpus/templates/clips/desktop/src/components/MediaDeviceRow.tsx +15 -27
  54. package/corpus/templates/clips/desktop/src/styles.css +364 -5
  55. package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +1 -1
  56. package/corpus/templates/clips/public/manifest.json +0 -1
  57. package/corpus/templates/content/.agents/skills/sharing/SKILL.md +1 -1
  58. package/corpus/templates/content/.agents/skills/turn-into-app/SKILL.md +24 -0
  59. package/corpus/templates/content/AGENTS.md +3 -0
  60. package/corpus/templates/content/actions/resolve-content-landing.ts +120 -0
  61. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +9 -0
  62. package/corpus/templates/content/app/i18n-data.ts +58 -0
  63. package/corpus/templates/content/app/lib/content-landing.ts +22 -0
  64. package/corpus/templates/content/app/root.tsx +0 -1
  65. package/corpus/templates/content/app/routes/_app._index.tsx +36 -26
  66. package/corpus/templates/content/changelog/2026-08-11-browser-no-longer-offers-desktop-install.md +6 -0
  67. package/corpus/templates/content/changelog/2026-08-11-content-opens-your-last-page-on-return-and-gives-new-users-a.md +6 -0
  68. package/corpus/templates/content/docs/product/capabilities/content.workspace.session-restore.md +7 -1
  69. package/corpus/templates/content/parity/matrix.md +1 -0
  70. package/corpus/templates/content/parity/matrix.ts +22 -0
  71. package/corpus/templates/content/public/manifest.json +0 -1
  72. package/corpus/templates/content/shared/content-landing.ts +17 -0
  73. package/corpus/templates/crm/.agents/skills/sharing/SKILL.md +1 -1
  74. package/corpus/templates/crm/.agents/skills/turn-into-app/SKILL.md +24 -0
  75. package/corpus/templates/crm/app/root.tsx +0 -1
  76. package/corpus/templates/crm/changelog/2026-08-11-browser-no-longer-offers-desktop-install.md +6 -0
  77. package/corpus/templates/crm/public/manifest.json +0 -1
  78. package/corpus/templates/design/.agents/skills/sharing/SKILL.md +1 -1
  79. package/corpus/templates/design/.agents/skills/turn-into-app/SKILL.md +24 -0
  80. package/corpus/templates/design/app/i18n/en-US.ts +5 -0
  81. package/corpus/templates/design/app/root.tsx +0 -1
  82. package/corpus/templates/design/changelog/2026-08-11-browser-no-longer-offers-desktop-install.md +6 -0
  83. package/corpus/templates/design/changelog/2026-08-11-design-sharing-now-identifies-viewers-who-can-add-review-com.md +6 -0
  84. package/corpus/templates/design/public/manifest.json +0 -1
  85. package/corpus/templates/dispatch/.agents/skills/sharing/SKILL.md +1 -1
  86. package/corpus/templates/dispatch/.agents/skills/turn-into-app/SKILL.md +24 -0
  87. package/corpus/templates/dispatch/AGENTS.md +9 -0
  88. package/corpus/templates/dispatch/actions/list-dispatch-usage-metrics.ts +5 -19
  89. package/corpus/templates/dispatch/actions/view-screen.ts +10 -3
  90. package/corpus/templates/dispatch/app/root.tsx +0 -1
  91. package/corpus/templates/dispatch/app/routes/settings.tsx +13 -1
  92. package/corpus/templates/dispatch/changelog/2026-08-11-browser-no-longer-offers-desktop-install.md +6 -0
  93. package/corpus/templates/dispatch/changelog/2026-08-11-clicking-the-dispatch-logo-returns-to-the-overview-page.md +6 -0
  94. package/corpus/templates/dispatch/changelog/2026-08-11-dispatch-no-longer-links-to-itself-from-the-app-switcher.md +6 -0
  95. package/corpus/templates/dispatch/changelog/2026-08-11-dispatch-settings-now-link-directly-to-admin.md +6 -0
  96. package/corpus/templates/dispatch/changelog/2026-08-11-dispatch-shows-usage-trends-recent-prompts-and-an-agent-revi.md +6 -0
  97. package/corpus/templates/dispatch/changelog/2026-08-11-human-friendly-app-names.md +6 -0
  98. package/corpus/templates/dispatch/public/manifest.json +0 -1
  99. package/corpus/templates/dispatch/server/lib/identity-sso.ts +432 -158
  100. package/corpus/templates/dispatch/server/plugins/identity-sso.ts +204 -123
  101. package/corpus/templates/dispatch/server/plugins/setup-dispatch.ts +1 -0
  102. package/corpus/templates/factory/.agents/skills/sharing/SKILL.md +1 -1
  103. package/corpus/templates/factory/.agents/skills/turn-into-app/SKILL.md +24 -0
  104. package/corpus/templates/factory/app/root.tsx +0 -1
  105. package/corpus/templates/factory/changelog/2026-08-11-browser-no-longer-offers-desktop-install.md +6 -0
  106. package/corpus/templates/factory/public/manifest.json +0 -1
  107. package/corpus/templates/forms/.agents/skills/sharing/SKILL.md +1 -1
  108. package/corpus/templates/forms/.agents/skills/turn-into-app/SKILL.md +24 -0
  109. package/corpus/templates/forms/app/root.tsx +0 -1
  110. package/corpus/templates/forms/changelog/2026-08-11-browser-no-longer-offers-desktop-install.md +6 -0
  111. package/corpus/templates/forms/public/manifest.json +0 -1
  112. package/corpus/templates/macros/.agents/skills/sharing/SKILL.md +1 -1
  113. package/corpus/templates/macros/.agents/skills/turn-into-app/SKILL.md +24 -0
  114. package/corpus/templates/mail/.agents/skills/sharing/SKILL.md +1 -1
  115. package/corpus/templates/mail/.agents/skills/turn-into-app/SKILL.md +24 -0
  116. package/corpus/templates/mail/app/root.tsx +0 -1
  117. package/corpus/templates/mail/changelog/2026-08-11-browser-no-longer-offers-desktop-install.md +6 -0
  118. package/corpus/templates/mail/changelog/2026-08-11-google-sign-in-now-opens-the-gmail-connection-flow-directly.md +6 -0
  119. package/corpus/templates/mail/public/manifest.json +0 -1
  120. package/corpus/templates/mail/server/plugins/auth.ts +0 -10
  121. package/corpus/templates/plan/.agents/skills/sharing/SKILL.md +1 -1
  122. package/corpus/templates/plan/.agents/skills/turn-into-app/SKILL.md +24 -0
  123. package/corpus/templates/plan/app/i18n/en-US.ts +2 -0
  124. package/corpus/templates/plan/app/pages/PlansPage.tsx +6 -0
  125. package/corpus/templates/plan/app/root.tsx +0 -1
  126. package/corpus/templates/plan/changelog/2026-08-11-browser-no-longer-offers-desktop-install.md +6 -0
  127. package/corpus/templates/plan/changelog/2026-08-11-plan-sharing-now-identifies-viewers-who-can-add-comments.md +6 -0
  128. package/corpus/templates/plan/public/manifest.json +0 -1
  129. package/corpus/templates/slides/.agents/skills/sharing/SKILL.md +1 -1
  130. package/corpus/templates/slides/.agents/skills/slide-editing/SKILL.md +13 -0
  131. package/corpus/templates/slides/.agents/skills/turn-into-app/SKILL.md +24 -0
  132. package/corpus/templates/slides/app/components/editor/EditorToolbar.tsx +6 -0
  133. package/corpus/templates/slides/app/i18n/en-US.ts +2 -0
  134. package/corpus/templates/slides/app/root.tsx +0 -1
  135. package/corpus/templates/slides/changelog/2026-08-10-asking-the-agent-for-several-slide-animations-at-once-now-ke.md +6 -0
  136. package/corpus/templates/slides/changelog/2026-08-11-browser-no-longer-offers-desktop-install.md +6 -0
  137. package/corpus/templates/slides/changelog/2026-08-11-share-dialog-uses-commenter-terminology.md +6 -0
  138. package/corpus/templates/slides/public/manifest.json +0 -1
  139. package/corpus/templates/tasks/.agents/skills/sharing/SKILL.md +1 -1
  140. package/corpus/templates/tasks/.agents/skills/turn-into-app/SKILL.md +24 -0
  141. package/corpus/templates/tasks/app/root.tsx +0 -1
  142. package/corpus/templates/tasks/changelog/2026-08-11-browser-no-longer-offers-desktop-install.md +6 -0
  143. package/corpus/templates/tasks/public/manifest.json +0 -1
  144. package/dist/agent/production-agent.js +1 -22
  145. package/dist/agent/run-manager.d.ts +7 -0
  146. package/dist/agent/run-manager.js +46 -3
  147. package/dist/agent/tool-approval-migrations.d.ts +40 -0
  148. package/dist/agent/tool-approval-migrations.js +51 -0
  149. package/dist/agent/tool-approval-store.js +5 -20
  150. package/dist/cli/templates-meta.js +2 -2
  151. package/dist/client/AgentPanel.js +10 -1
  152. package/dist/client/AssistantChat.d.ts +3 -0
  153. package/dist/client/AssistantChat.js +148 -140
  154. package/dist/client/agent-page/AgentTabsPage.js +2 -2
  155. package/dist/client/chat/attachment-adapters.js +8 -2
  156. package/dist/client/chat/tool-call-display.d.ts +1 -0
  157. package/dist/client/chat/tool-call-display.js +3 -1
  158. package/dist/client/chat-first.js +11 -5
  159. package/dist/client/hooks/index.d.ts +1 -1
  160. package/dist/client/hooks/index.js +1 -1
  161. package/dist/client/org/OrgSwitcher.d.ts +3 -1
  162. package/dist/client/org/OrgSwitcher.js +22 -16
  163. package/dist/client/org/WorkspaceNotice.js +3 -1
  164. package/dist/client/resources/index.d.ts +1 -1
  165. package/dist/client/resources/index.js +1 -1
  166. package/dist/client/resources/use-mcp-servers.d.ts +1 -0
  167. package/dist/client/resources/use-mcp-servers.js +14 -0
  168. package/dist/client/settings/AccountSettingsCard.js +8 -4
  169. package/dist/client/settings/SettingsPanel.js +46 -13
  170. package/dist/client/settings/SettingsTabsPage.js +8 -3
  171. package/dist/client/sharing/ShareButton.d.ts +7 -1
  172. package/dist/client/sharing/ShareButton.js +14 -7
  173. package/dist/client/sharing/ShareDialog.js +5 -1
  174. package/dist/client/sharing/useShareButtonController.d.ts +4 -0
  175. package/dist/client/sharing/useShareButtonController.js +15 -0
  176. package/dist/client/sharing/useShareDialogController.d.ts +7 -0
  177. package/dist/client/sharing/useShareDialogController.js +14 -0
  178. package/dist/client/sse-event-processor.js +37 -0
  179. package/dist/client/use-session.d.ts +2 -0
  180. package/dist/client/use-session.js +69 -5
  181. package/dist/collab/struct-routes.d.ts +1 -1
  182. package/dist/deploy/build.js +1 -1
  183. package/dist/file-upload/pre-upload-attachments.d.ts +3 -4
  184. package/dist/file-upload/pre-upload-attachments.js +65 -17
  185. package/dist/ingestion/index.d.ts +1 -0
  186. package/dist/ingestion/index.js +1 -0
  187. package/dist/ingestion/office.d.ts +3 -3
  188. package/dist/ingestion/office.js +24 -2
  189. package/dist/ingestion/spreadsheet.d.ts +41 -0
  190. package/dist/ingestion/spreadsheet.js +172 -0
  191. package/dist/integrations/adapters/slack.js +7 -3
  192. package/dist/localization/default-messages.d.ts +3 -0
  193. package/dist/localization/default-messages.js +3 -0
  194. package/dist/mcp/embed-app.js +20 -9
  195. package/dist/mcp/screen-memory-stdio.d.ts +7 -7
  196. package/dist/notifications/routes.d.ts +6 -6
  197. package/dist/oauth-tokens/migrations.d.ts +8 -0
  198. package/dist/oauth-tokens/migrations.js +36 -0
  199. package/dist/observability/routes.d.ts +5 -5
  200. package/dist/org/workspace-url.d.ts +8 -0
  201. package/dist/org/workspace-url.js +26 -2
  202. package/dist/progress/routes.d.ts +1 -1
  203. package/dist/secrets/routes.d.ts +3 -3
  204. package/dist/server/agent-chat-plugin.js +16 -8
  205. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  206. package/dist/server/auth.d.ts +0 -11
  207. package/dist/server/auth.js +37 -20
  208. package/dist/server/better-auth-migrations.js +23 -0
  209. package/dist/server/email-template.d.ts +5 -0
  210. package/dist/server/email-template.js +9 -0
  211. package/dist/server/embed-route.js +17 -5
  212. package/dist/server/identity-sso-store.d.ts +40 -66
  213. package/dist/server/identity-sso-store.js +187 -150
  214. package/dist/server/identity-sso.d.ts +17 -73
  215. package/dist/server/identity-sso.js +294 -297
  216. package/dist/server/onboarding-html.d.ts +0 -12
  217. package/dist/server/onboarding-html.js +46 -269
  218. package/dist/server/realtime-token.d.ts +1 -1
  219. package/dist/server/release-migrations.js +8 -0
  220. package/dist/server/transcribe-voice.d.ts +1 -1
  221. package/dist/shared/index.d.ts +1 -1
  222. package/dist/shared/index.js +1 -1
  223. package/dist/shared/workspace-app-id.d.ts +2 -0
  224. package/dist/shared/workspace-app-id.js +10 -0
  225. package/dist/sharing/actions/share-resource.d.ts +1 -0
  226. package/dist/sharing/actions/share-resource.js +20 -5
  227. package/dist/templates/chat/.agents/skills/sharing/SKILL.md +1 -1
  228. package/dist/templates/chat/.agents/skills/turn-into-app/SKILL.md +24 -0
  229. package/dist/templates/chat/app/root.tsx +0 -1
  230. package/dist/templates/chat/changelog/2026-08-11-browser-no-longer-offers-desktop-install.md +6 -0
  231. package/dist/templates/chat/public/manifest.json +0 -1
  232. package/dist/templates/default/.agents/skills/turn-into-app/SKILL.md +24 -0
  233. package/dist/templates/default/app/root.tsx +0 -1
  234. package/dist/templates/default/public/manifest.json +0 -1
  235. package/dist/templates/headless/.agents/skills/turn-into-app/SKILL.md +24 -0
  236. package/dist/templates/workspace-core/.agents/skills/authentication/SKILL.md +22 -11
  237. package/dist/templates/workspace-core/.agents/skills/sharing/SKILL.md +1 -1
  238. package/dist/templates/workspace-core/.agents/skills/turn-into-app/SKILL.md +24 -0
  239. package/docs/content/cross-app-sso.mdx +85 -27
  240. package/docs/content/template-mail.mdx +0 -6
  241. package/package.json +4 -3
  242. package/src/templates/chat/.agents/skills/sharing/SKILL.md +1 -1
  243. package/src/templates/chat/.agents/skills/turn-into-app/SKILL.md +24 -0
  244. package/src/templates/chat/app/root.tsx +0 -1
  245. package/src/templates/chat/changelog/2026-08-11-browser-no-longer-offers-desktop-install.md +6 -0
  246. package/src/templates/chat/public/manifest.json +0 -1
  247. package/src/templates/default/.agents/skills/turn-into-app/SKILL.md +24 -0
  248. package/src/templates/default/app/root.tsx +0 -1
  249. package/src/templates/default/public/manifest.json +0 -1
  250. package/src/templates/headless/.agents/skills/turn-into-app/SKILL.md +24 -0
  251. package/src/templates/workspace-core/.agents/skills/authentication/SKILL.md +22 -11
  252. package/src/templates/workspace-core/.agents/skills/sharing/SKILL.md +1 -1
  253. package/src/templates/workspace-core/.agents/skills/turn-into-app/SKILL.md +24 -0
  254. package/corpus/templates/calendar/changelog/2026-06-23-added-a-heads-up-explaining-google-s-app-not-verified-screen.md +0 -6
  255. package/corpus/templates/clips/app/components/meetings/google-oauth-identity-notice.tsx +0 -51
  256. package/corpus/templates/clips/changelog/2026-07-30-google-calendar-warning.md +0 -6
@@ -1,49 +1,66 @@
1
1
  /**
2
- * Identity-authority logic for "Sign in with Agent-Native".
2
+ * Identity-authority primitives for "Sign in with Agent-Native".
3
3
  *
4
- * Dispatch is the canonical identity authority. A first-party client app
5
- * (mail, calendar, analytics, …) bounces an unauthenticated visitor to
6
- * Dispatch's `/_agent-native/identity/authorize`. Dispatch reuses its EXISTING
7
- * Better Auth session + login flow, then mints a short-lived signed identity
8
- * JWT (the existing A2A signer) and 302s back to the client's `redirect_uri`
9
- * with the token + the caller's untouched `state`.
4
+ * Dispatch is the identity authority. The browser receives only a short-lived
5
+ * one-time authorization code. The client redeems it server-to-server with a
6
+ * PKCE verifier; only that server-to-server response may contain the signed
7
+ * identity assertion used to create the app-local session.
10
8
  *
11
- * This module holds the pure, side-effect-free pieces so they can be unit
12
- * tested in isolation:
13
- * - `isAllowedRedirectUri` — the single most important security control.
14
- * - `buildIdentityClaims` — the exact JWT claim set + TTL.
15
- * - `buildRedirectLocation` — where the token + state land on the redirect.
16
- *
17
- * The HTTP wiring (session resolution, login bounce, signing) lives in
18
- * `server/plugins/identity-sso.ts` so this file stays trivially testable with
19
- * no Nitro / crypto / DB dependencies.
9
+ * This module also owns the exact app registry and the additive code store.
10
+ * Canonical apps are compiled into the registry. Custom workspace apps must be
11
+ * explicitly registered with `IDENTITY_SSO_APP_REGISTRY_JSON`; host suffixes
12
+ * and wildcard domains are never accepted.
20
13
  */
21
14
 
22
- import { randomUUID } from "node:crypto";
15
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
23
16
 
24
- // Control-char guard (NUL..US + DEL). Defined via codepoints so this source
25
- // file stays plain ASCII (mirrors packages/core/src/server/open-route.ts).
26
- const CONTROL_CHARS = new RegExp("[\\u0000-\\u001f\\u007f]");
17
+ import { getDbExec, intType } from "@agent-native/core/db";
27
18
 
28
- /**
29
- * Identity tokens are auth codes, NOT API tokens. They are exchanged
30
- * immediately by the client for its own session, so a tight TTL is correct.
31
- * 5 minutes is the documented upper bound from the build spec; we use 2.
32
- *
33
- * NOTE on the type: `signA2AToken` forwards `expiresIn` to
34
- * `jose.SignJWT.setExpirationTime`. In jose, a NUMBER is interpreted as an
35
- * ABSOLUTE Unix timestamp (seconds), while a STRING like `"2m"` is parsed
36
- * as a duration from now. We MUST pass the duration string form, so the
37
- * canonical constant is the jose duration string; the seconds value is
38
- * exported alongside it for tests/assertions only.
39
- */
19
+ const CONTROL_CHARS = /[\u0000-\u001f\u007f]/;
20
+ const APP_ID = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
21
+ const CLIENT_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
22
+ const STATE_PATTERN = /^[A-Za-z0-9_-]{43}$/;
23
+ const CODE = /^[A-Za-z0-9_-]{43}$/;
24
+ const CODE_CHALLENGE = /^[A-Za-z0-9_-]{43}$/;
25
+ const CODE_VERIFIER = /^[A-Za-z0-9._~-]{43,128}$/;
26
+ const LOCALHOST_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
27
+
28
+ export const IDENTITY_SCOPE = "identity";
40
29
  export const IDENTITY_TOKEN_TTL_SECONDS = 120;
41
30
  export const IDENTITY_TOKEN_TTL = "2m";
31
+ export const IDENTITY_AUTHORIZATION_CODE_TTL_MS =
32
+ IDENTITY_TOKEN_TTL_SECONDS * 1_000;
33
+ export const IDENTITY_SSO_CALLBACK_PATH = "/_agent-native/identity/callback";
34
+ export const IDENTITY_SSO_TOKEN_PATH = "/_agent-native/identity/token";
42
35
 
43
- /** JWT `scope` claim marking this as an identity (login) token, not A2A/MCP. */
44
- export const IDENTITY_SCOPE = "identity";
36
+ export function isValidSsoState(value: unknown): value is string {
37
+ return typeof value === "string" && STATE_PATTERN.test(value);
38
+ }
45
39
 
46
- /** Exact first-party clients trusted to receive identity tokens. */
40
+ export function normalizeIdentityAuthority(raw: unknown): string | null {
41
+ if (typeof raw !== "string" || !raw.trim() || CONTROL_CHARS.test(raw)) {
42
+ return null;
43
+ }
44
+ try {
45
+ const url = new URL(raw.trim());
46
+ if (
47
+ (url.protocol !== "https:" &&
48
+ !(url.protocol === "http:" && LOCALHOST_HOSTS.has(url.hostname))) ||
49
+ url.username ||
50
+ url.password ||
51
+ url.search ||
52
+ url.hash
53
+ ) {
54
+ return null;
55
+ }
56
+ return `${url.protocol}//${url.host}${url.pathname}`.replace(/\/+$/, "");
57
+ } catch (error) {
58
+ void error;
59
+ return null;
60
+ }
61
+ }
62
+
63
+ /** Exact first-party app origins. Never replace this with a suffix check. */
47
64
  export const CANONICAL_IDENTITY_SSO_APP_ORIGINS = {
48
65
  analytics: "https://analytics.agent-native.com",
49
66
  assets: "https://assets.agent-native.com",
@@ -66,143 +83,226 @@ export const CANONICAL_IDENTITY_SSO_APP_ORIGINS = {
66
83
  export const DEFAULT_ALLOWED_ORIGINS: readonly string[] = Object.values(
67
84
  CANONICAL_IDENTITY_SSO_APP_ORIGINS,
68
85
  );
69
- const IDENTITY_SSO_CALLBACK_PATH = "/_agent-native/identity/callback";
70
86
 
71
- /** Loopback hosts allowed for local development of the client side. */
72
- const LOCALHOST_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
87
+ export interface IdentitySsoAppRegistration {
88
+ appId: string;
89
+ clientId: string;
90
+ origin: string;
91
+ callbackPath: typeof IDENTITY_SSO_CALLBACK_PATH;
92
+ }
73
93
 
74
- /**
75
- * Read an optional comma-separated extra-host-suffix allowlist from the
76
- * environment. Used for staging/preview client hosts that are not on
77
- * `*.agent-native.com`. Each entry is normalised to a lower-case,
78
- * dot-prefixed suffix so it can only ever broaden by whole-host suffix,
79
- * never by substring.
80
- *
81
- * Deploy-level configuration (not a user credential), so reading it from
82
- * the environment here is correct and outside the credentials-guard paths.
83
- */
84
- export function getConfiguredHostSuffixes(
85
- env: NodeJS.ProcessEnv = process.env,
86
- ): string[] {
87
- const raw = env.IDENTITY_SSO_ALLOWED_HOST_SUFFIXES;
88
- if (!raw || typeof raw !== "string") return [];
89
- return (
90
- raw
91
- .split(",")
92
- .map((s) => s.trim().toLowerCase())
93
- .filter(Boolean)
94
- .map((s) => (s.startsWith(".") ? s : `.${s}`))
95
- // Guard against a misconfigured "" / "." that would match everything.
96
- .filter((s) => s.length > 1 && s !== ".")
94
+ function canonicalRegistrations(): IdentitySsoAppRegistration[] {
95
+ return Object.entries(CANONICAL_IDENTITY_SSO_APP_ORIGINS).map(
96
+ ([appId, origin]) => ({
97
+ appId,
98
+ clientId: appId,
99
+ origin,
100
+ callbackPath: IDENTITY_SSO_CALLBACK_PATH,
101
+ }),
97
102
  );
98
103
  }
99
104
 
100
- /**
101
- * The redirect-URI allowlist. THE critical control on this endpoint.
102
- *
103
- * A `redirect_uri` is accepted ONLY when ALL of the following hold:
104
- * 1. It parses as an absolute URL.
105
- * 2. Its scheme is exactly `https:` — EXCEPT loopback dev hosts, where
106
- * `http:` is also allowed (TLS is not available on localhost).
107
- * 3. It has no embedded credentials (`user:pass@host`) — those are an
108
- * open-redirect obfuscation vector.
109
- * 4. Its origin is an exact registered first-party app, a loopback host,
110
- * or an operator-configured extra host suffix.
111
- *
112
- * Everything else is rejected. The authorize endpoint adds a second check
113
- * that binds the app id to the exact origin and callback path.
114
- */
115
- export function isAllowedRedirectUri(
116
- rawRedirectUri: unknown,
117
- options: { allowedHostSuffixes?: readonly string[] } = {},
118
- ): boolean {
119
- if (typeof rawRedirectUri !== "string" || rawRedirectUri.length === 0) {
120
- return false;
105
+ function exactOrigin(raw: unknown): string | null {
106
+ if (typeof raw !== "string" || !raw || CONTROL_CHARS.test(raw)) return null;
107
+ try {
108
+ const url = new URL(raw);
109
+ if (
110
+ url.protocol !== "https:" &&
111
+ !(url.protocol === "http:" && LOCALHOST_HOSTS.has(url.hostname))
112
+ ) {
113
+ return null;
114
+ }
115
+ if (
116
+ url.username ||
117
+ url.password ||
118
+ url.pathname !== "/" ||
119
+ url.search ||
120
+ url.hash
121
+ ) {
122
+ return null;
123
+ }
124
+ return url.origin;
125
+ } catch (error) {
126
+ void error;
127
+ return null;
121
128
  }
122
- // Reject control chars up front (CR/LF header-injection, NUL, etc.).
123
- if (CONTROL_CHARS.test(rawRedirectUri)) return false;
129
+ }
124
130
 
125
- let url: URL;
131
+ function parseCustomRegistrations(
132
+ env: NodeJS.ProcessEnv,
133
+ ): IdentitySsoAppRegistration[] {
134
+ const raw = env.IDENTITY_SSO_APP_REGISTRY_JSON?.trim();
135
+ if (!raw) return [];
136
+ let value: unknown;
126
137
  try {
127
- url = new URL(rawRedirectUri);
128
- } catch {
129
- return false;
138
+ value = JSON.parse(raw);
139
+ } catch (error) {
140
+ void error;
141
+ return [];
130
142
  }
143
+ if (!Array.isArray(value)) return [];
131
144
 
132
- // No embedded credentials — `https://evil@good.agent-native.com` style.
133
- if (url.username || url.password) return false;
134
-
135
- const hostname = url.hostname.toLowerCase();
136
- const isLoopback = LOCALHOST_HOSTS.has(hostname);
137
-
138
- // Scheme: https everywhere, http only for loopback dev.
139
- if (url.protocol === "https:") {
140
- // ok
141
- } else if (url.protocol === "http:" && isLoopback) {
142
- // ok — local dev
143
- } else {
144
- return false;
145
+ const registrations: IdentitySsoAppRegistration[] = [];
146
+ const seen = new Set<string>();
147
+ for (const entry of value) {
148
+ if (!entry || typeof entry !== "object") continue;
149
+ const candidate = entry as Record<string, unknown>;
150
+ const appId = candidate.appId;
151
+ const clientId = candidate.clientId;
152
+ const origin = exactOrigin(candidate.origin);
153
+ const callbackPath = candidate.callbackPath;
154
+ const capabilities = candidate.capabilities;
155
+ if (
156
+ typeof appId !== "string" ||
157
+ !APP_ID.test(appId) ||
158
+ typeof clientId !== "string" ||
159
+ !CLIENT_ID.test(clientId) ||
160
+ !origin ||
161
+ callbackPath !== IDENTITY_SSO_CALLBACK_PATH ||
162
+ !Array.isArray(capabilities) ||
163
+ !capabilities.includes("identity-sso") ||
164
+ seen.has(appId) ||
165
+ Object.prototype.hasOwnProperty.call(
166
+ CANONICAL_IDENTITY_SSO_APP_ORIGINS,
167
+ appId,
168
+ )
169
+ ) {
170
+ continue;
171
+ }
172
+ seen.add(appId);
173
+ registrations.push({
174
+ appId,
175
+ clientId,
176
+ origin,
177
+ callbackPath: IDENTITY_SSO_CALLBACK_PATH,
178
+ });
145
179
  }
180
+ return registrations;
181
+ }
146
182
 
147
- if (isLoopback) return true;
148
-
149
- if (DEFAULT_ALLOWED_ORIGINS.includes(url.origin)) return true;
183
+ /**
184
+ * Return the exact configured registry. Invalid custom entries are ignored;
185
+ * they never broaden the canonical registry. A deployment can therefore roll
186
+ * back custom registration by removing the env without changing code or data.
187
+ */
188
+ export function getIdentitySsoAppRegistry(
189
+ env: NodeJS.ProcessEnv = process.env,
190
+ ): IdentitySsoAppRegistration[] {
191
+ return [...canonicalRegistrations(), ...parseCustomRegistrations(env)];
192
+ }
150
193
 
151
- const suffixes = options.allowedHostSuffixes ?? getConfiguredHostSuffixes();
194
+ function parseAbsoluteUrl(raw: string): URL | null {
195
+ if (!raw || CONTROL_CHARS.test(raw)) return null;
196
+ try {
197
+ const url = new URL(raw);
198
+ if (url.username || url.password) return null;
199
+ return url;
200
+ } catch (error) {
201
+ void error;
202
+ return null;
203
+ }
204
+ }
152
205
 
153
- // Suffix match against the full hostname, leading-dot anchored so a
154
- // sibling/parent host can never satisfy a child's suffix:
155
- // "mail.agent-native.com".endsWith(".agent-native.com") -> true
156
- // "agent-native.com.evil.com".endsWith(".agent-native.com") -> false
157
- // "evil-agent-native.com".endsWith(".agent-native.com") -> false
158
- return suffixes.some((suffix) => hostname.endsWith(suffix));
206
+ /**
207
+ * General redirect-origin validation. The authorize route additionally calls
208
+ * `resolveIdentitySsoApp`, which enforces the exact registered callback.
209
+ * Localhost remains available for development, but only with exact callback
210
+ * path and client binding in the app resolver below.
211
+ */
212
+ export function isAllowedRedirectUri(rawRedirectUri: unknown): boolean {
213
+ if (typeof rawRedirectUri !== "string") return false;
214
+ const url = parseAbsoluteUrl(rawRedirectUri);
215
+ if (!url) return false;
216
+ if (url.protocol === "http:" && LOCALHOST_HOSTS.has(url.hostname)) {
217
+ return true;
218
+ }
219
+ return (
220
+ url.protocol === "https:" && DEFAULT_ALLOWED_ORIGINS.includes(url.origin)
221
+ );
159
222
  }
160
223
 
161
- const APP_ID = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
224
+ function exactCallbackMatches(
225
+ registration: IdentitySsoAppRegistration,
226
+ rawRedirectUri: string,
227
+ ): boolean {
228
+ const url = parseAbsoluteUrl(rawRedirectUri);
229
+ if (!url) return false;
230
+ return (
231
+ url.origin === registration.origin &&
232
+ url.pathname === registration.callbackPath &&
233
+ !url.search &&
234
+ !url.hash
235
+ );
236
+ }
162
237
 
163
- export function isAllowedIdentityRedirect(
238
+ /**
239
+ * Resolve an app only when app id, client id, exact origin, and callback path
240
+ * all agree. Loopback is the narrowly-scoped development exception and still
241
+ * requires a valid app/client id pair plus the fixed callback path.
242
+ */
243
+ export function resolveIdentitySsoApp(
164
244
  appId: unknown,
245
+ clientId: unknown,
165
246
  rawRedirectUri: unknown,
166
- options: { allowedHostSuffixes?: readonly string[] } = {},
167
- ): boolean {
168
- if (typeof appId !== "string" || !APP_ID.test(appId)) return false;
169
- if (!isAllowedRedirectUri(rawRedirectUri, options)) return false;
170
-
171
- const url = new URL(rawRedirectUri as string);
172
- if (url.pathname !== IDENTITY_SSO_CALLBACK_PATH || url.search || url.hash) {
173
- return false;
247
+ env: NodeJS.ProcessEnv = process.env,
248
+ ): IdentitySsoAppRegistration | null {
249
+ if (
250
+ typeof appId !== "string" ||
251
+ !APP_ID.test(appId) ||
252
+ typeof clientId !== "string" ||
253
+ !CLIENT_ID.test(clientId) ||
254
+ typeof rawRedirectUri !== "string"
255
+ ) {
256
+ return null;
174
257
  }
258
+ const redirectUri = rawRedirectUri as string;
259
+ const parsedRedirect = parseAbsoluteUrl(redirectUri);
260
+ if (!parsedRedirect) return null;
261
+ const registered = getIdentitySsoAppRegistry(env).find(
262
+ (candidate) =>
263
+ candidate.appId === appId &&
264
+ candidate.clientId === clientId &&
265
+ exactCallbackMatches(candidate, redirectUri),
266
+ );
267
+ if (registered) return registered;
175
268
 
176
- if (DEFAULT_ALLOWED_ORIGINS.includes(url.origin)) {
177
- return (
178
- CANONICAL_IDENTITY_SSO_APP_ORIGINS[
179
- appId as keyof typeof CANONICAL_IDENTITY_SSO_APP_ORIGINS
180
- ] === url.origin
181
- );
269
+ const url = parseAbsoluteUrl(redirectUri);
270
+ if (
271
+ url &&
272
+ LOCALHOST_HOSTS.has(url.hostname) &&
273
+ clientId === appId &&
274
+ url.pathname === IDENTITY_SSO_CALLBACK_PATH &&
275
+ !url.search &&
276
+ !url.hash
277
+ ) {
278
+ return {
279
+ appId,
280
+ clientId,
281
+ origin: url.origin,
282
+ callbackPath: IDENTITY_SSO_CALLBACK_PATH,
283
+ };
182
284
  }
285
+ return null;
286
+ }
183
287
 
184
- return true;
288
+ export function isAllowedIdentityRedirect(
289
+ appId: unknown,
290
+ rawRedirectUri: unknown,
291
+ options: { clientId?: unknown; env?: NodeJS.ProcessEnv } = {},
292
+ ): boolean {
293
+ const clientId = options.clientId ?? appId;
294
+ return !!resolveIdentitySsoApp(appId, clientId, rawRedirectUri, options.env);
185
295
  }
186
296
 
187
297
  export interface IdentityClaims {
188
- /** Stable user identifier — the Dispatch account email. */
189
298
  sub: string;
190
299
  email: string;
191
- /** Display name when the auth provider supplied one. */
192
300
  name?: string;
193
- /** Resolved org domain when the user has an active org; omitted otherwise. */
194
301
  org_domain?: string;
195
- /** Marks this token as an identity (login) token. */
196
302
  scope: typeof IDENTITY_SCOPE;
197
- /** Random per-token id for replay/debugging. */
198
303
  jti: string;
199
304
  }
200
305
 
201
- /**
202
- * Build the EXACT claim set for the identity token. No secrets/passwords
203
- * ever go in here. `sub` and `email` are the same value (the account email)
204
- * so a verifier can key on either. `org_domain` is included only when known.
205
- */
206
306
  export function buildIdentityClaims(input: {
207
307
  email: string;
208
308
  name?: string | null;
@@ -212,37 +312,211 @@ export function buildIdentityClaims(input: {
212
312
  sub: input.email,
213
313
  email: input.email,
214
314
  scope: IDENTITY_SCOPE,
215
- jti: randomUUID(),
315
+ jti: randomBytes(16).toString("base64url"),
216
316
  };
217
- if (input.name && input.name.trim()) claims.name = input.name.trim();
218
- if (input.orgDomain && input.orgDomain.trim()) {
219
- claims.org_domain = input.orgDomain.trim();
220
- }
317
+ if (input.name?.trim()) claims.name = input.name.trim();
318
+ if (input.orgDomain?.trim()) claims.org_domain = input.orgDomain.trim();
221
319
  return claims;
222
320
  }
223
321
 
224
- /**
225
- * Construct the final redirect `Location`: the validated `redirect_uri`
226
- * with `token` and the caller's untouched `state` appended as QUERY
227
- * parameters (not the fragment) so a server-side client callback can read
228
- * them. Preserves any pre-existing query the client put on its
229
- * `redirect_uri`.
230
- *
231
- * The token is placed in `?token=<jwt>` and the opaque caller value in
232
- * `?state=<state>` — the client MUST echo-check `state` against what it
233
- * generated before trusting the token.
234
- *
235
- * `rawRedirectUri` MUST already have passed `isAllowedRedirectUri`.
236
- */
322
+ function sha256Base64Url(value: string): string {
323
+ return createHash("sha256").update(value).digest("base64url");
324
+ }
325
+
326
+ export function createCodeChallenge(verifier: string): string | null {
327
+ if (!CODE_VERIFIER.test(verifier)) return null;
328
+ return sha256Base64Url(verifier);
329
+ }
330
+
331
+ function safeEqual(left: string, right: string): boolean {
332
+ const a = Buffer.from(left);
333
+ const b = Buffer.from(right);
334
+ return a.length === b.length && timingSafeEqual(a, b);
335
+ }
336
+
237
337
  export function buildRedirectLocation(
238
338
  rawRedirectUri: string,
239
- token: string,
339
+ code: string,
240
340
  state: string | null | undefined,
241
341
  ): string {
242
342
  const url = new URL(rawRedirectUri);
243
- url.searchParams.set("token", token);
343
+ url.searchParams.set("code", code);
244
344
  if (typeof state === "string" && state.length > 0) {
245
345
  url.searchParams.set("state", state);
246
346
  }
247
347
  return url.toString();
248
348
  }
349
+
350
+ export interface CreateIdentityAuthorizationCodeInput {
351
+ state: string;
352
+ appId: string;
353
+ clientId: string;
354
+ redirectUri: string;
355
+ authority: string;
356
+ codeChallenge: string;
357
+ email: string;
358
+ name?: string | null;
359
+ orgDomain?: string | null;
360
+ }
361
+
362
+ export interface ConsumedIdentityAuthorizationCode {
363
+ email: string;
364
+ name?: string;
365
+ orgDomain?: string;
366
+ jti: string;
367
+ }
368
+
369
+ let codeTableInitPromise: Promise<void> | undefined;
370
+
371
+ function buildCodeTableSql(): string {
372
+ return `
373
+ CREATE TABLE IF NOT EXISTS identity_sso_authorization_code (
374
+ code_hash TEXT PRIMARY KEY,
375
+ state TEXT NOT NULL,
376
+ app_id TEXT NOT NULL,
377
+ client_id TEXT NOT NULL,
378
+ redirect_uri TEXT NOT NULL,
379
+ authority TEXT NOT NULL,
380
+ code_challenge TEXT NOT NULL,
381
+ email TEXT NOT NULL,
382
+ name TEXT,
383
+ org_domain TEXT,
384
+ jti TEXT NOT NULL,
385
+ created_at ${intType()} NOT NULL,
386
+ expires_at ${intType()} NOT NULL,
387
+ consumed_at ${intType()}
388
+ )
389
+ `;
390
+ }
391
+
392
+ async function ensureCodeTable(): Promise<void> {
393
+ if (!codeTableInitPromise) {
394
+ codeTableInitPromise = getDbExec()
395
+ .execute(buildCodeTableSql())
396
+ .then(() => undefined)
397
+ .catch((error) => {
398
+ codeTableInitPromise = undefined;
399
+ throw error;
400
+ });
401
+ }
402
+ return codeTableInitPromise;
403
+ }
404
+
405
+ function affectedRows(result: any): number {
406
+ return Number(result?.rowsAffected ?? result?.rowCount ?? result?.count ?? 0);
407
+ }
408
+
409
+ function identityCodeHash(code: string): string {
410
+ return sha256Base64Url(code);
411
+ }
412
+
413
+ export async function createIdentityAuthorizationCode(
414
+ input: CreateIdentityAuthorizationCodeInput,
415
+ ): Promise<string> {
416
+ if (
417
+ !STATE_PATTERN.test(input.state) ||
418
+ !APP_ID.test(input.appId) ||
419
+ !CLIENT_ID.test(input.clientId) ||
420
+ !CODE_CHALLENGE.test(input.codeChallenge) ||
421
+ !input.email ||
422
+ !resolveIdentitySsoApp(input.appId, input.clientId, input.redirectUri)
423
+ ) {
424
+ throw new Error("INVALID_IDENTITY_AUTHORIZATION_CODE");
425
+ }
426
+ await ensureCodeTable();
427
+ const code = randomBytes(32).toString("base64url");
428
+ const now = Date.now();
429
+ const claims = buildIdentityClaims(input);
430
+ await getDbExec().execute({
431
+ sql:
432
+ "INSERT INTO identity_sso_authorization_code " +
433
+ "(code_hash, state, app_id, client_id, redirect_uri, authority, code_challenge, email, name, org_domain, jti, created_at, expires_at, consumed_at) " +
434
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
435
+ args: [
436
+ identityCodeHash(code),
437
+ input.state,
438
+ input.appId,
439
+ input.clientId,
440
+ input.redirectUri,
441
+ input.authority,
442
+ input.codeChallenge,
443
+ claims.email,
444
+ claims.name ?? null,
445
+ claims.org_domain ?? null,
446
+ claims.jti,
447
+ now,
448
+ now + IDENTITY_AUTHORIZATION_CODE_TTL_MS,
449
+ null,
450
+ ],
451
+ });
452
+ void getDbExec()
453
+ .execute({
454
+ sql: "DELETE FROM identity_sso_authorization_code WHERE expires_at < ?",
455
+ args: [now],
456
+ })
457
+ .catch(() => {});
458
+ return code;
459
+ }
460
+
461
+ export async function consumeIdentityAuthorizationCode(input: {
462
+ code: string;
463
+ state: string;
464
+ appId: string;
465
+ clientId: string;
466
+ redirectUri: string;
467
+ authority: string;
468
+ codeVerifier: string;
469
+ }): Promise<ConsumedIdentityAuthorizationCode | null> {
470
+ if (
471
+ !CODE.test(input.code) ||
472
+ !isValidSsoState(input.state) ||
473
+ !APP_ID.test(input.appId) ||
474
+ !CLIENT_ID.test(input.clientId) ||
475
+ !CODE_VERIFIER.test(input.codeVerifier)
476
+ ) {
477
+ return null;
478
+ }
479
+ const challenge = createCodeChallenge(input.codeVerifier);
480
+ if (!challenge) return null;
481
+ await ensureCodeTable();
482
+ const codeHash = identityCodeHash(input.code);
483
+ const { rows } = await getDbExec().execute({
484
+ sql:
485
+ "SELECT state, app_id, client_id, redirect_uri, authority, code_challenge, email, name, org_domain, jti, expires_at, consumed_at " +
486
+ "FROM identity_sso_authorization_code WHERE code_hash = ?",
487
+ args: [codeHash],
488
+ });
489
+ if (rows.length !== 1) return null;
490
+ const row: any = rows[0];
491
+ const expiresAt = Number(row.expires_at ?? row.expiresAt);
492
+ if (
493
+ row.consumed_at != null ||
494
+ !Number.isFinite(expiresAt) ||
495
+ expiresAt < Date.now() ||
496
+ row.state !== input.state ||
497
+ row.app_id !== input.appId ||
498
+ row.client_id !== input.clientId ||
499
+ row.redirect_uri !== input.redirectUri ||
500
+ row.authority !== input.authority ||
501
+ !safeEqual(String(row.code_challenge ?? ""), challenge)
502
+ ) {
503
+ return null;
504
+ }
505
+ const result = await getDbExec().execute({
506
+ sql:
507
+ "UPDATE identity_sso_authorization_code SET consumed_at = ? " +
508
+ "WHERE code_hash = ? AND consumed_at IS NULL",
509
+ args: [Date.now(), codeHash],
510
+ });
511
+ if (affectedRows(result) !== 1) return null;
512
+ if (typeof row.email !== "string" || !row.email.includes("@")) return null;
513
+ if (typeof row.jti !== "string" || !row.jti) return null;
514
+ return {
515
+ email: row.email,
516
+ ...(typeof row.name === "string" && row.name ? { name: row.name } : {}),
517
+ ...(typeof row.org_domain === "string" && row.org_domain
518
+ ? { orgDomain: row.org_domain }
519
+ : {}),
520
+ jti: row.jti,
521
+ };
522
+ }