@agent-native/core 0.151.2 → 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 (250) 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/turn-into-app/SKILL.md +24 -0
  131. package/corpus/templates/slides/app/components/editor/EditorToolbar.tsx +6 -0
  132. package/corpus/templates/slides/app/i18n/en-US.ts +2 -0
  133. package/corpus/templates/slides/app/root.tsx +0 -1
  134. package/corpus/templates/slides/changelog/2026-08-11-browser-no-longer-offers-desktop-install.md +6 -0
  135. package/corpus/templates/slides/changelog/2026-08-11-share-dialog-uses-commenter-terminology.md +6 -0
  136. package/corpus/templates/slides/public/manifest.json +0 -1
  137. package/corpus/templates/tasks/.agents/skills/sharing/SKILL.md +1 -1
  138. package/corpus/templates/tasks/.agents/skills/turn-into-app/SKILL.md +24 -0
  139. package/corpus/templates/tasks/app/root.tsx +0 -1
  140. package/corpus/templates/tasks/changelog/2026-08-11-browser-no-longer-offers-desktop-install.md +6 -0
  141. package/corpus/templates/tasks/public/manifest.json +0 -1
  142. package/dist/agent/production-agent.js +1 -22
  143. package/dist/agent/run-manager.d.ts +7 -0
  144. package/dist/agent/run-manager.js +46 -3
  145. package/dist/agent/tool-approval-migrations.d.ts +40 -0
  146. package/dist/agent/tool-approval-migrations.js +51 -0
  147. package/dist/agent/tool-approval-store.js +5 -20
  148. package/dist/cli/templates-meta.js +2 -2
  149. package/dist/client/AgentPanel.js +10 -1
  150. package/dist/client/AssistantChat.d.ts +3 -0
  151. package/dist/client/AssistantChat.js +148 -140
  152. package/dist/client/agent-page/AgentTabsPage.js +2 -2
  153. package/dist/client/chat/attachment-adapters.js +8 -2
  154. package/dist/client/chat/tool-call-display.d.ts +1 -0
  155. package/dist/client/chat/tool-call-display.js +3 -1
  156. package/dist/client/chat-first.js +11 -5
  157. package/dist/client/hooks/index.d.ts +1 -1
  158. package/dist/client/hooks/index.js +1 -1
  159. package/dist/client/org/OrgSwitcher.d.ts +3 -1
  160. package/dist/client/org/OrgSwitcher.js +22 -16
  161. package/dist/client/org/WorkspaceNotice.js +3 -1
  162. package/dist/client/resources/index.d.ts +1 -1
  163. package/dist/client/resources/index.js +1 -1
  164. package/dist/client/resources/use-mcp-servers.d.ts +1 -0
  165. package/dist/client/resources/use-mcp-servers.js +14 -0
  166. package/dist/client/settings/AccountSettingsCard.js +8 -4
  167. package/dist/client/settings/SettingsPanel.js +46 -13
  168. package/dist/client/settings/SettingsTabsPage.js +8 -3
  169. package/dist/client/sharing/ShareButton.d.ts +7 -1
  170. package/dist/client/sharing/ShareButton.js +14 -7
  171. package/dist/client/sharing/ShareDialog.js +5 -1
  172. package/dist/client/sharing/useShareButtonController.d.ts +4 -0
  173. package/dist/client/sharing/useShareButtonController.js +15 -0
  174. package/dist/client/sharing/useShareDialogController.d.ts +7 -0
  175. package/dist/client/sharing/useShareDialogController.js +14 -0
  176. package/dist/client/sse-event-processor.js +37 -0
  177. package/dist/client/use-session.d.ts +2 -0
  178. package/dist/client/use-session.js +69 -5
  179. package/dist/collab/struct-routes.d.ts +1 -1
  180. package/dist/deploy/build.js +1 -1
  181. package/dist/file-upload/pre-upload-attachments.d.ts +3 -4
  182. package/dist/file-upload/pre-upload-attachments.js +65 -17
  183. package/dist/ingestion/index.d.ts +1 -0
  184. package/dist/ingestion/index.js +1 -0
  185. package/dist/ingestion/office.d.ts +3 -3
  186. package/dist/ingestion/office.js +24 -2
  187. package/dist/ingestion/spreadsheet.d.ts +41 -0
  188. package/dist/ingestion/spreadsheet.js +172 -0
  189. package/dist/integrations/adapters/slack.js +7 -3
  190. package/dist/localization/default-messages.d.ts +3 -0
  191. package/dist/localization/default-messages.js +3 -0
  192. package/dist/mcp/embed-app.js +20 -9
  193. package/dist/mcp/screen-memory-stdio.d.ts +7 -7
  194. package/dist/notifications/routes.d.ts +6 -6
  195. package/dist/oauth-tokens/migrations.d.ts +8 -0
  196. package/dist/oauth-tokens/migrations.js +36 -0
  197. package/dist/org/workspace-url.d.ts +8 -0
  198. package/dist/org/workspace-url.js +26 -2
  199. package/dist/secrets/routes.d.ts +3 -3
  200. package/dist/server/agent-chat-plugin.js +16 -8
  201. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  202. package/dist/server/auth.d.ts +0 -11
  203. package/dist/server/auth.js +37 -20
  204. package/dist/server/better-auth-migrations.js +23 -0
  205. package/dist/server/email-template.d.ts +5 -0
  206. package/dist/server/email-template.js +9 -0
  207. package/dist/server/embed-route.js +17 -5
  208. package/dist/server/identity-sso-store.d.ts +40 -66
  209. package/dist/server/identity-sso-store.js +187 -150
  210. package/dist/server/identity-sso.d.ts +17 -73
  211. package/dist/server/identity-sso.js +294 -297
  212. package/dist/server/onboarding-html.d.ts +0 -12
  213. package/dist/server/onboarding-html.js +46 -269
  214. package/dist/server/release-migrations.js +8 -0
  215. package/dist/shared/index.d.ts +1 -1
  216. package/dist/shared/index.js +1 -1
  217. package/dist/shared/workspace-app-id.d.ts +2 -0
  218. package/dist/shared/workspace-app-id.js +10 -0
  219. package/dist/sharing/actions/share-resource.d.ts +1 -0
  220. package/dist/sharing/actions/share-resource.js +20 -5
  221. package/dist/templates/chat/.agents/skills/sharing/SKILL.md +1 -1
  222. package/dist/templates/chat/.agents/skills/turn-into-app/SKILL.md +24 -0
  223. package/dist/templates/chat/app/root.tsx +0 -1
  224. package/dist/templates/chat/changelog/2026-08-11-browser-no-longer-offers-desktop-install.md +6 -0
  225. package/dist/templates/chat/public/manifest.json +0 -1
  226. package/dist/templates/default/.agents/skills/turn-into-app/SKILL.md +24 -0
  227. package/dist/templates/default/app/root.tsx +0 -1
  228. package/dist/templates/default/public/manifest.json +0 -1
  229. package/dist/templates/headless/.agents/skills/turn-into-app/SKILL.md +24 -0
  230. package/dist/templates/workspace-core/.agents/skills/authentication/SKILL.md +22 -11
  231. package/dist/templates/workspace-core/.agents/skills/sharing/SKILL.md +1 -1
  232. package/dist/templates/workspace-core/.agents/skills/turn-into-app/SKILL.md +24 -0
  233. package/docs/content/cross-app-sso.mdx +85 -27
  234. package/docs/content/template-mail.mdx +0 -6
  235. package/package.json +4 -3
  236. package/src/templates/chat/.agents/skills/sharing/SKILL.md +1 -1
  237. package/src/templates/chat/.agents/skills/turn-into-app/SKILL.md +24 -0
  238. package/src/templates/chat/app/root.tsx +0 -1
  239. package/src/templates/chat/changelog/2026-08-11-browser-no-longer-offers-desktop-install.md +6 -0
  240. package/src/templates/chat/public/manifest.json +0 -1
  241. package/src/templates/default/.agents/skills/turn-into-app/SKILL.md +24 -0
  242. package/src/templates/default/app/root.tsx +0 -1
  243. package/src/templates/default/public/manifest.json +0 -1
  244. package/src/templates/headless/.agents/skills/turn-into-app/SKILL.md +24 -0
  245. package/src/templates/workspace-core/.agents/skills/authentication/SKILL.md +22 -11
  246. package/src/templates/workspace-core/.agents/skills/sharing/SKILL.md +1 -1
  247. package/src/templates/workspace-core/.agents/skills/turn-into-app/SKILL.md +24 -0
  248. package/corpus/templates/calendar/changelog/2026-06-23-added-a-heads-up-explaining-google-s-app-not-verified-screen.md +0 -6
  249. package/corpus/templates/clips/app/components/meetings/google-oauth-identity-notice.tsx +0 -51
  250. package/corpus/templates/clips/changelog/2026-07-30-google-calendar-warning.md +0 -6
@@ -1,151 +1,63 @@
1
1
  /**
2
2
  * Cross-app SSO ("Sign in with Agent-Native") — the CLIENT side.
3
3
  *
4
- * Each hosted `*.agent-native.com` app has its OWN Better Auth user store
5
- * (a separate database per app). This module lets an app federate sign-in to
6
- * an identity authority (Dispatch) so a user logged in there can land in this
7
- * app without re-entering credentials.
4
+ * Each hosted app has its own Better Auth store. Dispatch is the identity
5
+ * authority, but the browser only ever carries a short-lived, one-time
6
+ * authorization code. The client keeps the PKCE verifier in an HttpOnly,
7
+ * callback-scoped cookie and redeems the code server-to-server. Only that
8
+ * server-to-server response may contain the signed identity assertion.
8
9
  *
9
- * Opt-in, OFF by default, fully reversible. Direct web federation is gated on
10
- * `AGENT_NATIVE_IDENTITY_HUB_URL`; the packaged Desktop SSO Canary may use the
11
- * canonical Dispatch authority on canonical hosted apps, where Dispatch's
12
- * default-off feature flag controls rollout:
13
- *
14
- * - UNSET → normal browser requests remain protected and the login page
15
- * renders no SSO button. Packaged Canary requests on canonical app origins
16
- * may reach the flow, but Dispatch refuses them unless its rollout flag
17
- * allows the user.
18
- * - SET (e.g. `https://dispatch.agent-native.com`) → two routes mount:
19
- * GET /_agent-native/identity/login
20
- * 302 → `<HUB>/_agent-native/identity/authorize?app=<id>
21
- * &redirect_uri=<thisOrigin>/_agent-native/identity/callback
22
- * &state=<single-use CSRF state>`
23
- * GET /_agent-native/identity/callback?token=<jwt>&state=<state>
24
- * Verifies the hub-issued identity JWT (HS256 over the SHARED A2A
25
- * secret — the exact verify path A2A / MCP `verifyAuth` use), checks
26
- * `scope:"identity"`, `exp`, single-use CSRF `state`, and (best
27
- * effort) `jti` replay, then JIT-links the verified email into this
28
- * app's local Better Auth store and mints a normal framework session
29
- * the SAME way the Google OAuth callback does.
30
- *
31
- * Crypto reuse: the hub signs with `jose.SignJWT(...).sign(A2A_SECRET)` (the
32
- * existing `signA2AToken` builder). We verify with the identical
33
- * `jose.jwtVerify(token, A2A_SECRET)` call `mcp/build-server.ts#verifyAuth`
34
- * uses — no new crypto, no new keys.
35
- *
36
- * Session reuse: a NEW email is created via `auth.api.signUpEmail` — the
37
- * exact Better Auth signup path `maybeAutoCreateDevSession` already uses, so
38
- * the adapter creates the `user` (+ adapter-managed credential `account`)
39
- * row schema-correctly and the normal `databaseHooks.user.create.after`
40
- * (org auto-join, analytics) fires. The framework session is then minted via
41
- * `createOAuthSession` — the literal Google-OAuth session-mint path
42
- * (`addSession` + `setFrameworkSessionCookie`). An EXISTING email is never
43
- * mutated: we only ADD an inert federated-provider `account` row (if absent)
44
- * and mint the same framework session. Removing the env returns the app to
45
- * its prior auth with no residue.
10
+ * Direct browser federation remains opt-in through
11
+ * `AGENT_NATIVE_IDENTITY_HUB_URL`. The packaged Desktop Canary may use the
12
+ * canonical Dispatch authority on canonical app origins, as established by
13
+ * the merged feature-flagged Desktop work. This module does not change the
14
+ * Desktop implementation or any ordinary auth surface.
46
15
  */
47
- import { randomBytes } from "node:crypto";
48
- import { getHeader, getMethod } from "h3";
16
+ import { createHash, randomBytes } from "node:crypto";
17
+ import { deleteCookie, getCookie, getHeader, getMethod, setCookie } from "h3";
49
18
  import * as jose from "jose";
50
19
  import { GOOGLE_AUTH_REQUIRED_MESSAGE, isGoogleSignInRequiredForEmail, } from "../org/auth-policy.js";
51
20
  import { SIGN_IN_ENTRY_PATH } from "../shared/sign-in-journey.js";
52
21
  import { getAppName } from "./app-name.js";
53
- import { getSession, safeReturnPath, isExpectedAuthFailure } from "./auth.js";
22
+ import { getSession, isExpectedAuthFailure, safeReturnPath } from "./auth.js";
54
23
  import { getBetterAuth, getBetterAuthInternalAdapter, } from "./better-auth-instance.js";
55
24
  import { createOAuthSession, getOrigin } from "./google-oauth.js";
56
- import { createSsoState, consumeSsoState, isJtiReplayed, getIdentityHubUrl, isIdentitySsoEnabled, identitySsoLoginButtonHtml, isCanonicalAgentNativeAppRequest, isDesktopSsoCanaryUserAgent, } from "./identity-sso-store.js";
57
- export { getIdentityHubUrl, isIdentitySsoEnabled, identitySsoLoginButtonHtml };
58
- /**
59
- * The provider id recorded on the additive `account` row we link for an
60
- * EXISTING local user. Must match the value the Dispatch authority agent
61
- * expects to interoperate with — documented in the report so the two sides
62
- * stay in sync. Inert when this provider is unused, so removing the env var
63
- * leaves no behavioural residue.
64
- */
25
+ import { consumeSsoState, createSsoState, getIdentityHubUrl, identitySsoLoginButtonHtml, isCanonicalAgentNativeAppRequest, isDesktopSsoCanaryUserAgent, isIdentitySsoEnabled, isJtiReplayed, SSO_STATE_TTL_MS, } from "./identity-sso-store.js";
26
+ export { getIdentityHubUrl, identitySsoLoginButtonHtml, isIdentitySsoEnabled };
65
27
  export const IDENTITY_SSO_PROVIDER_ID = "agent-native";
66
- /**
67
- * The JWT `scope` claim the hub MUST set on the identity token. The callback
68
- * rejects any token whose `scope` is not exactly this value, so an A2A
69
- * delegation JWT (no scope, or `scope:"mcp-connect"`) can never be replayed
70
- * as an identity assertion.
71
- */
72
28
  export const IDENTITY_SSO_SCOPE = "identity";
73
29
  export const IDENTITY_SSO_DESKTOP_COMPLETE_PATH = "/_agent-native/identity/desktop-complete";
30
+ export const IDENTITY_SSO_CALLBACK_PATH = "/_agent-native/identity/callback";
31
+ export const IDENTITY_SSO_TOKEN_PATH = "/_agent-native/identity/token";
74
32
  const DESKTOP_COMPLETION_NONCE = /^[A-Za-z0-9_-]{32,128}$/;
33
+ const STATE_PATTERN = /^[A-Za-z0-9_-]{43}$/;
34
+ const CODE = /^[A-Za-z0-9_-]{43}$/;
35
+ const CODE_VERIFIER = /^[A-Za-z0-9._~-]{43,128}$/;
75
36
  const DESKTOP_IDENTITY_HUB_URL = "https://dispatch.agent-native.com";
76
- /** Identity tokens older than this are rejected even if `exp` is generous. */
77
- const MAX_TOKEN_AGE_SECONDS = 10 * 60;
78
- /**
79
- * Direct web federation stays explicitly configured through
80
- * `AGENT_NATIVE_IDENTITY_HUB_URL`. The packaged Desktop SSO Canary may use the
81
- * canonical Dispatch authority without per-app deployment configuration, but
82
- * only for canonical HTTPS Agent Native app origins. Dispatch's default-off
83
- * feature flag remains the authoritative rollout gate.
84
- */
85
- export function resolveIdentityHubUrl(event) {
86
- const configured = getIdentityHubUrl();
87
- if (configured)
88
- return configured;
89
- if (!isDesktopSsoCanaryUserAgent(getHeader(event, "user-agent"))) {
90
- return undefined;
91
- }
92
- try {
93
- if (!isCanonicalAgentNativeAppRequest(getHeader(event, "host"), getHeader(event, "x-forwarded-proto"))) {
94
- return undefined;
95
- }
96
- return DESKTOP_IDENTITY_HUB_URL;
97
- }
98
- catch {
99
- return undefined;
100
- }
101
- }
102
- /**
103
- * A stable id for THIS app, sent to the hub as `?app=` so the authority can
104
- * record / display which app requested sign-in. Best-effort, non-secret,
105
- * never trusted for identity. Falls back to the request host.
106
- */
107
- function resolveAppId(event) {
108
- const configured = process.env.AGENT_NATIVE_APP_ID?.trim() ||
109
- process.env.AGENT_NATIVE_WORKSPACE_APP_ID?.trim();
110
- if (configured)
111
- return configured;
112
- const name = getAppName();
113
- if (name && name !== "app")
114
- return name;
115
- try {
116
- const origin = getOrigin(event);
117
- return new URL(origin).hostname.split(".")[0] || "app";
118
- }
119
- catch {
120
- return "app";
121
- }
122
- }
37
+ const SSO_VERIFIER_COOKIE_PREFIX = "agent_native_sso_verifier_";
38
+ const MAX_ASSERTION_AGE_SECONDS = 5 * 60;
39
+ const LOCALHOST_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
123
40
  function html(body, status = 200) {
124
41
  return new Response(body, {
125
42
  status,
126
- headers: { "Content-Type": "text/html; charset=utf-8" },
43
+ headers: {
44
+ "Cache-Control": "no-store",
45
+ "Content-Type": "text/html; charset=utf-8",
46
+ "Referrer-Policy": "no-referrer",
47
+ },
127
48
  });
128
49
  }
129
50
  function redirect(event, location) {
130
- // Mirror any Set-Cookie staged on the event (e.g. the framework session
131
- // cookie set by `createOAuthSession`) onto the 302. h3 v2's
132
- // `prepareResponse` only merges staged Set-Cookie into a *2xx* web
133
- // Response and drops them for non-2xx — so a bare
134
- // `new Response("", { status: 302 })` here would silently lose the
135
- // session cookie and the user would finish "Sign in with Agent-Native"
136
- // still logged out. This mirrors the framework's `redirectWithStagedCookies`
137
- // (auth.ts) exactly; it is a no-op when nothing is staged.
138
- const headers = new Headers({ Location: location });
51
+ const headers = new Headers({
52
+ "Cache-Control": "no-store",
53
+ Location: location,
54
+ "Referrer-Policy": "no-referrer",
55
+ });
139
56
  const staged = event.res?.headers?.getSetCookie?.() ?? [];
140
57
  for (const cookie of staged)
141
58
  headers.append("set-cookie", cookie);
142
59
  return new Response("", { status: 302, headers });
143
60
  }
144
- /**
145
- * Minimal self-contained error page (same inline-HTML approach as the auth /
146
- * connect pages). Used when the federated round-trip fails so the user gets
147
- * an actionable message instead of a raw 4xx. `message` is plain text.
148
- */
149
61
  function errorPage(message, loginPath) {
150
62
  const safe = message
151
63
  .replace(/&/g, "&amp;")
@@ -166,47 +78,200 @@ function errorPage(message, loginPath) {
166
78
  `</head><body><div class="card"><h1>Could not sign you in</h1>` +
167
79
  `<p>${safe}</p><a href="${safeHref}">Back to sign in</a></div></body></html>`, 400);
168
80
  }
169
- /**
170
- * Create a strong, random, NEVER-exposed credential for a JIT-created SSO
171
- * user. It is used once by Better Auth's signup path and is intentionally
172
- * unavailable to every login surface, including later SSO retries.
173
- */
174
81
  function createUnusableSsoCredential() {
175
82
  return `an-sso_${randomBytes(32).toString("base64url")}`;
176
83
  }
84
+ function normalizeAuthority(raw) {
85
+ try {
86
+ const url = new URL(raw);
87
+ if ((url.protocol !== "https:" &&
88
+ !(url.protocol === "http:" && LOCALHOST_HOSTS.has(url.hostname))) ||
89
+ url.username ||
90
+ url.password ||
91
+ url.search ||
92
+ url.hash) {
93
+ return null;
94
+ }
95
+ return `${url.protocol}//${url.host}${url.pathname}`.replace(/\/+$/, "");
96
+ }
97
+ catch (error) {
98
+ void error;
99
+ return null;
100
+ }
101
+ }
102
+ function resolveAppId(event) {
103
+ const configured = process.env.AGENT_NATIVE_APP_ID?.trim() ||
104
+ process.env.AGENT_NATIVE_WORKSPACE_APP_ID?.trim();
105
+ if (configured)
106
+ return configured;
107
+ const name = getAppName();
108
+ if (name && name !== "app")
109
+ return name;
110
+ try {
111
+ return new URL(getOrigin(event)).hostname.split(".")[0] || "app";
112
+ }
113
+ catch {
114
+ return "app";
115
+ }
116
+ }
117
+ function resolveClientId(appId) {
118
+ return process.env.AGENT_NATIVE_SSO_CLIENT_ID?.trim() || appId;
119
+ }
120
+ function verifierCookieName(state) {
121
+ return `${SSO_VERIFIER_COOKIE_PREFIX}${state}`;
122
+ }
123
+ function createPkceVerifier() {
124
+ return randomBytes(48).toString("base64url");
125
+ }
126
+ function createPkceChallenge(verifier) {
127
+ return createHash("sha256").update(verifier).digest("base64url");
128
+ }
129
+ function requestUrl(event) {
130
+ return event.node?.req?.url ?? event.path ?? "/";
131
+ }
132
+ function setPkceVerifierCookie(event, state, verifier, secure) {
133
+ setCookie(event, verifierCookieName(state), verifier, {
134
+ httpOnly: true,
135
+ maxAge: Math.floor(SSO_STATE_TTL_MS / 1_000),
136
+ path: IDENTITY_SSO_CALLBACK_PATH,
137
+ sameSite: "lax",
138
+ secure,
139
+ });
140
+ }
141
+ function clearPkceVerifierCookie(event, state) {
142
+ deleteCookie(event, verifierCookieName(state), {
143
+ path: IDENTITY_SSO_CALLBACK_PATH,
144
+ });
145
+ }
146
+ function resolveClientBinding(event, hub) {
147
+ const appId = resolveAppId(event);
148
+ const clientId = resolveClientId(appId);
149
+ const redirectUri = `${getOrigin(event)}${IDENTITY_SSO_CALLBACK_PATH}`;
150
+ const authority = normalizeAuthority(hub);
151
+ if (!authority || !appId || !clientId || !redirectUri)
152
+ return null;
153
+ return { appId, clientId, redirectUri, authority };
154
+ }
177
155
  /**
178
- * JIT-link the verified hub identity into THIS app's local Better Auth
179
- * store, strictly by verified email and strictly additively.
180
- *
181
- * - EXISTING email → the local `user` / `session` / existing `account`
182
- * rows are NEVER read-modify-written. We only ADD (if absent) one
183
- * federated-provider `account` row via Better Auth's OWN
184
- * `internalAdapter.linkAccount` — so id, timestamps, and schema stay
185
- * adapter-correct. The row is inert (no template path reads
186
- * `provider_id = "agent-native"`), so removing the env var leaves zero
187
- * behavioural residue.
188
- * - NEW email → created via the SAME `auth.api.signUpEmail` path the app
189
- * already uses (`maybeAutoCreateDevSession` uses the identical call), so
190
- * the adapter creates the `user` (+ a schema-correct credential
191
- * `account`) and `databaseHooks.user.create.after` (org auto-join,
192
- * analytics) fires exactly as for a normal first-time signup. Idempotent
193
- * under a concurrent create (the "already exists" failure is swallowed).
194
- *
195
- * Returns nothing — success is implied by not throwing. Account-link
196
- * failures for an existing user are swallowed (the verified email already
197
- * authenticated them; the link row is bookkeeping and must never block the
198
- * session).
156
+ * The packaged Desktop SSO Canary may use Dispatch without each canonical app
157
+ * deploying the direct-web hub env. Ordinary browsers and self-hosted apps
158
+ * remain strictly env-gated.
199
159
  */
160
+ export function resolveIdentityHubUrl(event) {
161
+ const configured = getIdentityHubUrl();
162
+ if (configured)
163
+ return configured;
164
+ if (!isDesktopSsoCanaryUserAgent(getHeader(event, "user-agent"))) {
165
+ return undefined;
166
+ }
167
+ try {
168
+ if (!isCanonicalAgentNativeAppRequest(getHeader(event, "host"), getHeader(event, "x-forwarded-proto"))) {
169
+ return undefined;
170
+ }
171
+ return DESKTOP_IDENTITY_HUB_URL;
172
+ }
173
+ catch (error) {
174
+ void error;
175
+ return undefined;
176
+ }
177
+ }
178
+ /** Verify only the server-to-server assertion returned by Dispatch /token. */
179
+ async function verifyIdentityAssertion(assertion, binding) {
180
+ const secret = process.env.A2A_SECRET;
181
+ if (!secret || !assertion)
182
+ return null;
183
+ try {
184
+ const { payload } = await jose.jwtVerify(assertion, new TextEncoder().encode(secret), { audience: binding.redirectUri });
185
+ if (payload.scope !== IDENTITY_SSO_SCOPE)
186
+ return null;
187
+ if (payload.identity_client_id !== binding.clientId)
188
+ return null;
189
+ if (payload.redirect_uri !== binding.redirectUri)
190
+ return null;
191
+ if (payload.identity_authority !== binding.authority)
192
+ return null;
193
+ const issuer = typeof payload.iss === "string" ? normalizeAuthority(payload.iss) : null;
194
+ if (issuer !== binding.authority)
195
+ return null;
196
+ const email = typeof payload.email === "string" && payload.email.includes("@")
197
+ ? payload.email.trim().toLowerCase()
198
+ : null;
199
+ if (!email)
200
+ return null;
201
+ const iat = typeof payload.iat === "number" ? payload.iat : null;
202
+ if (iat == null ||
203
+ iat > Date.now() / 1_000 + 60 ||
204
+ Date.now() / 1_000 - iat > MAX_ASSERTION_AGE_SECONDS) {
205
+ return null;
206
+ }
207
+ const jti = typeof payload.jti === "string" ? payload.jti : "";
208
+ if (!jti)
209
+ return null;
210
+ return {
211
+ email,
212
+ name: typeof payload.name === "string" && payload.name.trim()
213
+ ? payload.name.trim()
214
+ : "",
215
+ orgDomain: typeof payload.org_domain === "string" && payload.org_domain
216
+ ? payload.org_domain
217
+ : undefined,
218
+ sub: typeof payload.sub === "string" && payload.sub ? payload.sub : email,
219
+ jti,
220
+ };
221
+ }
222
+ catch (error) {
223
+ void error;
224
+ return null;
225
+ }
226
+ }
227
+ async function exchangeIdentityCode(hub, binding, input) {
228
+ if (!CODE.test(input.code) || !STATE_PATTERN.test(input.state))
229
+ return null;
230
+ if (!CODE_VERIFIER.test(input.codeVerifier))
231
+ return null;
232
+ const tokenEndpoint = `${hub}${IDENTITY_SSO_TOKEN_PATH}`;
233
+ try {
234
+ const response = await fetch(tokenEndpoint, {
235
+ method: "POST",
236
+ headers: {
237
+ "content-type": "application/json",
238
+ accept: "application/json",
239
+ },
240
+ body: JSON.stringify({
241
+ grant_type: "authorization_code",
242
+ code: input.code,
243
+ state: input.state,
244
+ app_id: binding.appId,
245
+ client_id: binding.clientId,
246
+ redirect_uri: binding.redirectUri,
247
+ code_verifier: input.codeVerifier,
248
+ }),
249
+ redirect: "error",
250
+ });
251
+ if (!response.ok)
252
+ return null;
253
+ const body = (await response.json().catch((error) => {
254
+ void error;
255
+ return null;
256
+ }));
257
+ return typeof body?.assertion === "string" ? body.assertion : null;
258
+ }
259
+ catch (error) {
260
+ void error;
261
+ return null;
262
+ }
263
+ }
200
264
  async function jitLinkIdentity(identity) {
201
265
  const adapter = await getBetterAuthInternalAdapter();
202
- // Look up the local user via Better Auth's own adapter (read-only).
203
266
  let existing = adapter
204
267
  ? await adapter
205
268
  .findUserByEmail(identity.email, { includeAccounts: true })
206
- .catch(() => null)
269
+ .catch((error) => {
270
+ void error;
271
+ return null;
272
+ })
207
273
  : null;
208
274
  if (!existing) {
209
- // No local user → create via the SAME signup path the app already uses.
210
275
  const auth = await getBetterAuth();
211
276
  try {
212
277
  await auth.api.signUpEmail({
@@ -217,24 +282,23 @@ async function jitLinkIdentity(identity) {
217
282
  },
218
283
  });
219
284
  }
220
- catch (e) {
221
- // "already exists" (concurrent create / pre-existing user the adapter
222
- // lookup missed) is expected and fine — fall through to linking.
223
- if (!isExpectedAuthFailure(e))
224
- throw e;
285
+ catch (error) {
286
+ if (!isExpectedAuthFailure(error))
287
+ throw error;
225
288
  }
226
289
  if (adapter) {
227
290
  existing = await adapter
228
291
  .findUserByEmail(identity.email, { includeAccounts: true })
229
- .catch(() => null);
292
+ .catch((error) => {
293
+ void error;
294
+ return null;
295
+ });
230
296
  }
231
297
  }
232
- // ADD the inert federated-provider link iff a local user resolved and the
233
- // link is absent. Better Auth's `linkAccount` is the additive, schema-
234
- // correct API — we never UPDATE/DELETE/RENAME any identity row.
235
298
  if (adapter && existing?.user?.id) {
236
299
  const accountId = identity.sub || identity.email;
237
- const alreadyLinked = (existing.accounts ?? []).some((a) => a.providerId === IDENTITY_SSO_PROVIDER_ID && a.accountId === accountId);
300
+ const alreadyLinked = (existing.accounts ?? []).some((account) => account.providerId === IDENTITY_SSO_PROVIDER_ID &&
301
+ account.accountId === accountId);
238
302
  if (!alreadyLinked) {
239
303
  try {
240
304
  await adapter.linkAccount({
@@ -243,183 +307,121 @@ async function jitLinkIdentity(identity) {
243
307
  accountId,
244
308
  });
245
309
  }
246
- catch {
247
- // Inert bookkeeping row — never block sign-in on a link failure.
310
+ catch (error) {
311
+ void error;
312
+ // The verified email already authenticates the user. This inert,
313
+ // additive bookkeeping link must never block session creation.
248
314
  }
249
315
  }
250
316
  }
251
317
  }
252
- /**
253
- * Verify the hub-issued identity JWT using the EXACT same path A2A / MCP use:
254
- * `jose.jwtVerify(token, A2A_SECRET)`. `jwtVerify` enforces `exp`
255
- * automatically. We additionally require:
256
- * - `scope === "identity"` (so an A2A delegation token can't be replayed)
257
- * - `aud` is THIS app's callback URL (so a token minted for one app cannot
258
- * be replayed against another app's callback with a fresh state)
259
- * - a non-empty `email` claim (the join key — comes ONLY from the verified
260
- * token, never a query param)
261
- * - issued no more than `MAX_TOKEN_AGE_SECONDS` ago (belt-and-braces on top
262
- * of `exp` in case the hub mints long-lived tokens)
263
- *
264
- * Returns the verified identity, or `null` for ANY failure (bad signature,
265
- * expired, wrong scope, missing email, malformed). The caller maps `null` to
266
- * a generic error — it never leaks which check failed.
267
- */
268
- async function verifyIdentityToken(token, expectedAudience) {
269
- const secret = process.env.A2A_SECRET;
270
- if (!secret || !token)
271
- return null;
318
+ function safeRequestReturnPath(event) {
272
319
  try {
273
- const { payload } = await jose.jwtVerify(token, new TextEncoder().encode(secret));
274
- if (payload.scope !== IDENTITY_SSO_SCOPE)
275
- return null;
276
- const aud = payload.aud;
277
- const audienceMatches = Array.isArray(aud)
278
- ? aud.includes(expectedAudience)
279
- : aud === expectedAudience;
280
- if (!audienceMatches)
281
- return null;
282
- if (typeof payload.redirect_uri === "string" &&
283
- payload.redirect_uri !== expectedAudience) {
284
- return null;
285
- }
286
- const email = typeof payload.email === "string" && payload.email.includes("@")
287
- ? payload.email.trim().toLowerCase()
288
- : null;
289
- if (!email)
290
- return null;
291
- const iat = typeof payload.iat === "number" ? payload.iat : undefined;
292
- if (iat !== undefined && Date.now() / 1000 - iat > MAX_TOKEN_AGE_SECONDS) {
293
- return null;
294
- }
295
- const sub = typeof payload.sub === "string" && payload.sub ? payload.sub : email;
296
- return {
297
- email,
298
- name: typeof payload.name === "string" && payload.name.trim()
299
- ? payload.name.trim()
300
- : "",
301
- orgDomain: typeof payload.org_domain === "string" && payload.org_domain
302
- ? payload.org_domain
303
- : undefined,
304
- sub,
305
- jti: typeof payload.jti === "string" && payload.jti
306
- ? payload.jti
307
- : undefined,
308
- };
320
+ const url = new URL(requestUrl(event), "http://an.invalid");
321
+ return safeReturnPath(url.searchParams.get("return"));
309
322
  }
310
323
  catch {
311
- // Bad signature / expired / malformed — never reveal which.
312
- return null;
324
+ return "/";
313
325
  }
314
326
  }
315
- // ---------------------------------------------------------------------------
316
- // Route handler — single entry point; the core-routes-plugin dispatches the
317
- // subpath, mirroring `handleMcpConnect`.
318
- // ---------------------------------------------------------------------------
319
- /**
320
- * Handle a `/_agent-native/identity/*` request. `subpath` is the part after
321
- * `/identity` (e.g. `/login`, `/callback`). Returns a 404 Response whenever
322
- * the feature is disabled so an unset env var is a true no-op even if the
323
- * route somehow gets mounted.
324
- */
325
327
  export async function handleIdentitySso(event, subpath) {
326
328
  const hub = resolveIdentityHubUrl(event);
327
- if (!hub) {
329
+ if (!hub)
328
330
  return new Response("Not found", { status: 404 });
329
- }
330
331
  const method = getMethod(event);
331
332
  const sub = ("/" + subpath.replace(/^\/+/, "").replace(/\/+$/, "")).replace(/^\/$/, "");
332
- const origin = getOrigin(event);
333
333
  const loginPath = SIGN_IN_ENTRY_PATH;
334
- // ---- GET /login → 302 to the hub authorize endpoint ------------------
335
334
  if (sub === "/login") {
336
335
  if (method !== "GET" && method !== "HEAD") {
337
336
  return new Response("Method not allowed", { status: 405 });
338
337
  }
339
- // Already signed in here? Skip the round-trip.
340
- const existing = await getSession(event).catch(() => null);
341
- let returnPath = "/";
342
- try {
343
- const u = new URL(event.node?.req?.url ?? event.path ?? "/", "http://an.invalid");
344
- returnPath = safeReturnPath(u.searchParams.get("return"));
345
- }
346
- catch {
347
- returnPath = "/";
348
- }
349
- if (existing?.email) {
338
+ const existing = await getSession(event).catch((error) => {
339
+ void error;
340
+ return null;
341
+ });
342
+ const returnPath = safeRequestReturnPath(event);
343
+ if (existing?.email)
350
344
  return redirect(event, returnPath);
351
- }
345
+ const binding = resolveClientBinding(event, hub);
346
+ if (!binding)
347
+ return errorPage("Federated sign-in is not configured.", loginPath);
348
+ const verifier = createPkceVerifier();
349
+ const challenge = createPkceChallenge(verifier);
352
350
  let state;
353
351
  try {
354
- state = await createSsoState(returnPath === "/" ? null : returnPath);
352
+ state = await createSsoState({
353
+ returnPath: returnPath === "/" ? null : returnPath,
354
+ ...binding,
355
+ codeChallenge: challenge,
356
+ });
355
357
  }
356
- catch (e) {
357
- if (e?.message === "RATE_LIMITED") {
358
+ catch (error) {
359
+ if (error?.message === "RATE_LIMITED") {
358
360
  return errorPage("Too many sign-in attempts. Please wait a moment and try again.", loginPath);
359
361
  }
360
362
  return errorPage("Could not start federated sign-in. Please try again.", loginPath);
361
363
  }
362
- const redirectUri = `${origin}/_agent-native/identity/callback`;
364
+ setPkceVerifierCookie(event, state, verifier, binding.redirectUri.startsWith("https://"));
363
365
  const authorizeUrl = `${hub}/_agent-native/identity/authorize` +
364
- `?app=${encodeURIComponent(resolveAppId(event))}` +
365
- `&redirect_uri=${encodeURIComponent(redirectUri)}` +
366
- `&state=${encodeURIComponent(state)}`;
366
+ `?response_type=code` +
367
+ `&app=${encodeURIComponent(binding.appId)}` +
368
+ `&client_id=${encodeURIComponent(binding.clientId)}` +
369
+ `&redirect_uri=${encodeURIComponent(binding.redirectUri)}` +
370
+ `&state=${encodeURIComponent(state)}` +
371
+ `&code_challenge=${encodeURIComponent(challenge)}` +
372
+ `&code_challenge_method=S256`;
367
373
  return redirect(event, authorizeUrl);
368
374
  }
369
- // ---- GET /callback → verify token, JIT-link, mint session ------------
370
375
  if (sub === "/callback") {
371
376
  if (method !== "GET" && method !== "HEAD") {
372
377
  return new Response("Method not allowed", { status: 405 });
373
378
  }
374
- let token = "";
375
- let stateParam = "";
379
+ let code = "";
380
+ let state = "";
376
381
  try {
377
- const u = new URL(event.node?.req?.url ?? event.path ?? "/", "http://an.invalid");
378
- token =
379
- u.searchParams.get("token") || u.searchParams.get("id_token") || "";
380
- stateParam = u.searchParams.get("state") || "";
382
+ const url = new URL(requestUrl(event), "http://an.invalid");
383
+ code = url.searchParams.get("code") || "";
384
+ state = url.searchParams.get("state") || "";
381
385
  }
382
386
  catch {
383
387
  return errorPage("Malformed sign-in response.", loginPath);
384
388
  }
385
- // CSRF: the state must be one we minted, unexpired, and never consumed.
386
- // Consume it FIRST (single-use) so a replayed callback can't pass even
387
- // with a still-valid token.
388
- const stateResult = await consumeSsoState(stateParam);
389
+ if (!STATE_PATTERN.test(state) || !CODE.test(code)) {
390
+ return errorPage("Malformed sign-in response.", loginPath);
391
+ }
392
+ const binding = resolveClientBinding(event, hub);
393
+ const verifier = getCookie(event, verifierCookieName(state));
394
+ clearPkceVerifierCookie(event, state);
395
+ if (!binding || !verifier) {
396
+ return errorPage("Your sign-in session expired or was already used. Please try again.", loginPath);
397
+ }
398
+ const stateResult = await consumeSsoState(state, {
399
+ ...binding,
400
+ codeChallenge: createPkceChallenge(verifier),
401
+ });
389
402
  if (!stateResult.ok) {
390
403
  return errorPage("Your sign-in session expired or was already used. Please try again.", loginPath);
391
404
  }
392
- // Identity comes ONLY from the signature-verified token. The query
393
- // `email` (if any) is never trusted.
394
- const expectedAudience = `${origin}/_agent-native/identity/callback`;
395
- const identity = await verifyIdentityToken(token, expectedAudience);
396
- if (!identity) {
405
+ const assertion = await exchangeIdentityCode(hub, binding, {
406
+ code,
407
+ state,
408
+ codeVerifier: verifier,
409
+ });
410
+ const identity = assertion
411
+ ? await verifyIdentityAssertion(assertion, binding)
412
+ : null;
413
+ if (!identity || (await isJtiReplayed(identity.jti))) {
397
414
  return errorPage("We could not verify the sign-in response. Please try again.", loginPath);
398
415
  }
399
- // Replay guard (best-effort, defence in depth on top of single-use
400
- // state): reject a token whose jti we've already accepted.
401
- if (await isJtiReplayed(identity.jti)) {
402
- return errorPage("This sign-in link was already used. Please try again.", loginPath);
403
- }
404
- // Identity-hub federation is a separate provider from Google. Do not let
405
- // it mint a legacy session that bypasses an org's Google-only policy.
406
416
  if (await isGoogleSignInRequiredForEmail(identity.email)) {
407
417
  return errorPage(GOOGLE_AUTH_REQUIRED_MESSAGE, loginPath);
408
418
  }
409
- // JIT link STRICTLY by verified email — additive only. Existing users
410
- // are never mutated; new users are created via the app's own signup
411
- // path; an inert federated `account` link is added via Better Auth's
412
- // own adapter API. A failure here must not leave the user signed out
413
- // mid-flow, so surface a retryable error rather than a half state.
414
419
  try {
415
420
  await jitLinkIdentity(identity);
416
421
  }
417
422
  catch {
418
423
  return errorPage("Could not finish linking your account. Please try again.", loginPath);
419
424
  }
420
- // Mint a normal framework session EXACTLY the way the Google OAuth
421
- // callback does (`createOAuthSession` → addSession + framework cookie).
422
- // `hasProductionSession: false` so a fresh session cookie is always set.
423
425
  try {
424
426
  await createOAuthSession(event, identity.email, {
425
427
  hasProductionSession: false,
@@ -428,9 +430,7 @@ export async function handleIdentitySso(event, subpath) {
428
430
  catch {
429
431
  return errorPage("Signed in, but could not start your session. Please try again.", loginPath);
430
432
  }
431
- // Land the user back where they started (validated same-origin path).
432
- const dest = safeReturnPath(stateResult.returnPath);
433
- return redirect(event, dest);
433
+ return redirect(event, safeReturnPath(stateResult.returnPath));
434
434
  }
435
435
  if (sub === "/desktop-complete") {
436
436
  if (method !== "GET" && method !== "HEAD") {
@@ -438,8 +438,8 @@ export async function handleIdentitySso(event, subpath) {
438
438
  }
439
439
  let nonce = "";
440
440
  try {
441
- const url = new URL(event.node?.req?.url ?? event.path ?? "/", "http://an.invalid");
442
- nonce = url.searchParams.get("nonce") || "";
441
+ nonce =
442
+ new URL(requestUrl(event), "http://an.invalid").searchParams.get("nonce") || "";
443
443
  }
444
444
  catch {
445
445
  return new Response("Invalid completion request", { status: 400 });
@@ -447,7 +447,10 @@ export async function handleIdentitySso(event, subpath) {
447
447
  if (!DESKTOP_COMPLETION_NONCE.test(nonce)) {
448
448
  return new Response("Invalid completion request", { status: 400 });
449
449
  }
450
- const current = await getSession(event).catch(() => null);
450
+ const current = await getSession(event).catch((error) => {
451
+ void error;
452
+ return null;
453
+ });
451
454
  if (!current?.email) {
452
455
  return new Response("Authentication required", { status: 401 });
453
456
  }
@@ -456,21 +459,15 @@ export async function handleIdentitySso(event, subpath) {
456
459
  "<title>Signed in</title></head><body>Signed in. You can close this window.</body></html>", {
457
460
  status: 200,
458
461
  headers: {
459
- "Content-Type": "text/html; charset=utf-8",
460
462
  "Cache-Control": "no-store",
461
463
  "Content-Security-Policy": "default-src 'none'; style-src 'none'",
464
+ "Content-Type": "text/html; charset=utf-8",
462
465
  "Referrer-Policy": "no-referrer",
463
466
  },
464
467
  });
465
468
  }
466
469
  return new Response("Not found", { status: 404 });
467
470
  }
468
- /**
469
- * Whether the given (already base-path-stripped) request path is one of the
470
- * two SSO routes that must bypass the blanket auth guard. The handler makes
471
- * the request-scoped enablement decision and fails closed with 404; no other
472
- * identity subpath bypasses authentication.
473
- */
474
471
  export function isIdentitySsoBypassPath(p) {
475
472
  if (!isIdentitySsoEnabled())
476
473
  return false;