okengine 0.17.2 → 0.18.4

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 (209) hide show
  1. package/AGENTS.md +5 -3
  2. package/README.md +14 -10
  3. package/manifest.v1.schema.json +61 -2
  4. package/package.json +19 -5
  5. package/site/content/docs/ai/index.mdx +1 -1
  6. package/site/content/docs/ai/mcp.mdx +10 -1
  7. package/site/content/docs/ai/skills.mdx +9 -9
  8. package/site/content/docs/elements/ai.mdx +1 -1
  9. package/site/content/docs/elements/clock.mdx +1 -1
  10. package/site/content/docs/elements/flow.mdx +25 -1
  11. package/site/content/docs/elements/gate.mdx +3 -2
  12. package/site/content/docs/elements/store.mdx +289 -341
  13. package/site/content/docs/elements/vault.mdx +5 -5
  14. package/site/content/docs/get-started/basic-usage.mdx +3 -10
  15. package/site/content/docs/get-started/index.mdx +1 -1
  16. package/site/content/docs/get-started/installation.mdx +2 -3
  17. package/site/content/docs/get-started/introduction.mdx +58 -121
  18. package/site/content/docs/get-started/meta.json +9 -1
  19. package/site/content/docs/get-started/project-structure.mdx +4 -11
  20. package/site/content/docs/get-started/testing.mdx +328 -0
  21. package/site/content/docs/get-started/why.mdx +93 -71
  22. package/site/content/docs/index.mdx +44 -11
  23. package/site/content/docs/meta.json +8 -5
  24. package/site/content/docs/plugins/apple.mdx +151 -0
  25. package/site/content/docs/plugins/discord.mdx +139 -0
  26. package/site/content/docs/plugins/facebook.mdx +134 -0
  27. package/site/content/docs/plugins/figma.mdx +138 -0
  28. package/site/content/docs/plugins/github.mdx +138 -0
  29. package/site/content/docs/plugins/google.mdx +153 -0
  30. package/site/content/docs/plugins/index.mdx +47 -1
  31. package/site/content/docs/plugins/meta.json +10 -0
  32. package/site/content/docs/plugins/microsoft.mdx +151 -0
  33. package/site/content/docs/plugins/oauth.mdx +188 -0
  34. package/site/content/docs/plugins/x.mdx +125 -0
  35. package/site/content/docs/providers/index.mdx +2 -0
  36. package/site/content/docs/recipes/index.mdx +2 -0
  37. package/site/content/docs/reference/cli.md +3 -2
  38. package/site/content/docs/reference/client.mdx +58 -1
  39. package/site/content/docs/reference/configuration.mdx +2 -4
  40. package/site/content/docs/reference/fx.mdx +3 -1
  41. package/site/content/docs/reference/index.mdx +0 -5
  42. package/site/content/docs/reference/meta.json +2 -2
  43. package/site/content/docs/reference/okid.mdx +137 -0
  44. package/src/auth/bindings.ts +1 -1
  45. package/src/auth/config.ts +9 -0
  46. package/src/auth/identity-sql.ts +314 -0
  47. package/src/auth/identity.ts +140 -2
  48. package/src/auth/index.ts +17 -1
  49. package/src/auth/method-context.ts +3 -0
  50. package/src/auth/oauth-as/cimd.ts +132 -0
  51. package/src/auth/oauth-as/crypto.test.ts +101 -0
  52. package/src/auth/oauth-as/crypto.ts +393 -0
  53. package/src/auth/oauth-as/errors.ts +68 -0
  54. package/src/auth/oauth-as/http.test.ts +419 -0
  55. package/src/auth/oauth-as/http.ts +842 -0
  56. package/src/auth/oauth-as/stores.ts +61 -0
  57. package/src/auth/oauth-as/tables.ts +142 -0
  58. package/src/auth/tables.ts +0 -11
  59. package/src/bench/README.md +83 -0
  60. package/src/bench/REPORT.md +176 -0
  61. package/src/bench/g01-rls-stamp.bench.ts +194 -0
  62. package/src/bench/g02-clock-per-tenant.bench.ts +158 -0
  63. package/src/bench/g03-signal-once.bench.ts +157 -0
  64. package/src/bench/g03-signal-reconnect.bench.ts +254 -0
  65. package/src/bench/g03-signal-sse-memory.bench.ts +191 -0
  66. package/src/bench/g04-auth-vault-hotpath.bench.ts +170 -0
  67. package/src/bench/g05-sustained-full.bench.ts +265 -0
  68. package/src/bench/g06-mixed-load.bench.ts +260 -0
  69. package/src/bench/g07-vault-crypto.bench.ts +100 -0
  70. package/src/bench/g07-vault-rotate-under-read.bench.ts +285 -0
  71. package/src/bench/g08-conn-oversubscribe.bench.ts +194 -0
  72. package/src/bench/g08-store-kv-durable.bench.ts +133 -0
  73. package/src/bench/g08-store-sql.bench.ts +178 -0
  74. package/src/bench/g09-journal-sustained.bench.ts +203 -0
  75. package/src/bench/g10-observability-contention.bench.ts +246 -0
  76. package/src/bench/g11-cold-start-cycle.bench.ts +164 -0
  77. package/src/bench/g13-elements.bench.ts +427 -0
  78. package/src/bench/g14-graceful-shutdown.bench.ts +244 -0
  79. package/src/bench/g15-postgres-degradation.bench.ts +264 -0
  80. package/src/bench/g16-live-query-fanout.bench.ts +206 -0
  81. package/src/bench/lib/event-loop-lag.ts +26 -0
  82. package/src/bench/lib/infra.ts +60 -0
  83. package/src/bench/lib/report.ts +52 -0
  84. package/src/bench/lib/rss-sampler.ts +61 -0
  85. package/src/bench/lib/signal-pg.ts +88 -0
  86. package/src/bench/load-app.ts +337 -0
  87. package/src/bench/load-child.ts +108 -0
  88. package/src/bench/smoke.bench.ts +43 -0
  89. package/src/cli/competitor-mention-removal.test.ts +28 -0
  90. package/src/cli/doctor-fd.ts +117 -0
  91. package/src/cli/doctor.test.ts +192 -0
  92. package/src/cli/doctor.ts +129 -1
  93. package/src/client/create.ts +95 -1
  94. package/src/client/index.ts +9 -2
  95. package/src/client/transport.ts +11 -4
  96. package/src/client/use-live-query.ts +154 -0
  97. package/src/client-react/index.ts +15 -1
  98. package/src/client-react/live-resource.ts +246 -0
  99. package/src/client-react/use-live-query.test.ts +475 -0
  100. package/src/client-react/use-live-query.ts +530 -0
  101. package/src/compiler/extract.test.ts +518 -0
  102. package/src/compiler/extract.ts +386 -19
  103. package/src/console/server/invoke-user-flow.ts +2 -1
  104. package/src/console/ui-next/dist/assets/{access-page-DnWbnGzq.js → access-page-De7Lc2JC.js} +1 -1
  105. package/src/console/ui-next/dist/assets/{flows-page-BiZ4-6yQ.js → flows-page-RGy7VEA_.js} +1 -1
  106. package/src/console/ui-next/dist/assets/{index-C8NRK2R-.js → index-_rgpdVzo.js} +3 -3
  107. package/src/console/ui-next/dist/assets/{observability-page-CrB6vd1T.js → observability-page-Ds6pcnh-.js} +1 -1
  108. package/src/console/ui-next/dist/assets/{store-page-CS5-aETQ.js → store-page-02xOiqIK.js} +3 -3
  109. package/src/console/ui-next/dist/assets/{units-page-CjtdlW8l.js → units-page-4rHOePuE.js} +1 -1
  110. package/src/console/ui-next/dist/assets/{vault-page-C6Xxm9SA.js → vault-page-DISPgxLM.js} +1 -1
  111. package/src/console/ui-next/dist/index.html +1 -1
  112. package/src/console/ui-next/src/features/store/lib/fields-from-table.ts +36 -2
  113. package/src/drivers/cdc-outbox.ts +389 -0
  114. package/src/drivers/memory.ts +20 -0
  115. package/src/drivers/oauth-apple.ts +156 -0
  116. package/src/drivers/oauth-discord.ts +79 -0
  117. package/src/drivers/oauth-facebook.ts +80 -0
  118. package/src/drivers/oauth-figma.ts +116 -0
  119. package/src/drivers/oauth-github.ts +92 -0
  120. package/src/drivers/oauth-google.ts +142 -0
  121. package/src/drivers/oauth-microsoft.ts +174 -0
  122. package/src/drivers/oauth-oidc.ts +293 -0
  123. package/src/drivers/oauth-shared.ts +326 -0
  124. package/src/drivers/oauth-types.ts +159 -0
  125. package/src/drivers/oauth-x.ts +77 -0
  126. package/src/drivers/oauth2-common.ts +95 -0
  127. package/src/drivers/oauth2-token.ts +61 -0
  128. package/src/drivers/pg-rls-row-passes.ts +251 -0
  129. package/src/drivers/pg-rls.ts +2 -0
  130. package/src/drivers/postgres.ts +45 -2
  131. package/src/drivers/signal-postgres.ts +2 -1
  132. package/src/elements/channel/runtime.ts +29 -2
  133. package/src/elements/channel.test.ts +52 -0
  134. package/src/elements/gate/boot.ts +29 -2
  135. package/src/elements/store/emit-drizzle.ts +147 -14
  136. package/src/elements/store/field-ddl.test.ts +118 -0
  137. package/src/elements/store/field-types.test.ts +455 -0
  138. package/src/elements/store/list-query.golden.json +777 -0
  139. package/src/elements/store/list-query.parity.test.ts +396 -0
  140. package/src/elements/store/list-query.ts +792 -0
  141. package/src/elements/store/live-default.test.ts +136 -0
  142. package/src/elements/store/live-http.test.ts +160 -0
  143. package/src/elements/store/live-isolation.test.ts +291 -0
  144. package/src/elements/store/live-query-runtime.test.ts +323 -0
  145. package/src/elements/store/live-query-runtime.ts +403 -0
  146. package/src/elements/store/live-query-server.test.ts +377 -0
  147. package/src/elements/store/live-query-server.ts +102 -0
  148. package/src/elements/store/live-query.ts +97 -0
  149. package/src/elements/store/resource.ts +189 -680
  150. package/src/elements/store/rls-row-passes-policies.parity.test.ts +665 -0
  151. package/src/elements/store/schema-decl.ts +539 -41
  152. package/src/elements/store/sql-rls-stamp.test.ts +27 -0
  153. package/src/elements/store/sql-session.ts +297 -35
  154. package/src/elements/store/table.ts +102 -21
  155. package/src/elements/store.test.ts +3 -1
  156. package/src/elements/store.ts +12 -1
  157. package/src/elements/vault/chaos-child.ts +74 -1
  158. package/src/elements/vault/chaos.test.ts +4 -2
  159. package/src/elements/vault/storage.ts +4 -2
  160. package/src/index.ts +5 -2
  161. package/src/kernel/app-auth.ts +1 -0
  162. package/src/kernel/app.ts +116 -2
  163. package/src/kernel/auth-sharing.test.ts +196 -0
  164. package/src/kernel/boot.test.ts +3 -3
  165. package/src/kernel/errors.ts +8 -0
  166. package/src/kernel/fx.test.ts +1 -0
  167. package/src/kernel/fx.ts +14 -2
  168. package/src/kernel/horizontal-child.ts +2 -1
  169. package/src/kernel/http-resource.ts +33 -7
  170. package/src/kernel/identity-host-persist.test.ts +119 -0
  171. package/src/kernel/instance-id.ts +4 -2
  172. package/src/kernel/journal.ts +2 -1
  173. package/src/kernel/mcp-tool.test.ts +95 -0
  174. package/src/kernel/on.ts +9 -0
  175. package/src/kernel/realtime-bind.ts +326 -0
  176. package/src/kernel/resource-live.ts +117 -0
  177. package/src/kernel/triggers.ts +86 -4
  178. package/src/manifest/diff.ts +37 -0
  179. package/src/manifest/types.ts +64 -2
  180. package/src/okid.bench.test.ts +64 -0
  181. package/src/okid.test.ts +338 -0
  182. package/src/okid.ts +245 -0
  183. package/src/plugins/anonymous.ts +19 -1
  184. package/src/plugins/auth/shared.ts +15 -0
  185. package/src/plugins/index.ts +2 -0
  186. package/src/plugins/magic-link.ts +10 -8
  187. package/src/plugins/mcp-oauth.ts +208 -0
  188. package/src/plugins/oauth/flow-store.ts +117 -0
  189. package/src/plugins/oauth/link.ts +69 -0
  190. package/src/plugins/oauth/shared.ts +108 -0
  191. package/src/plugins/oauth/token-vault.ts +100 -0
  192. package/src/plugins/oauth.security.test.ts +535 -0
  193. package/src/plugins/oauth.ts +532 -0
  194. package/src/plugins/otp.ts +48 -6
  195. package/src/plugins/passkey.ts +20 -1
  196. package/src/plugins/two-factor.ts +11 -0
  197. package/src/plugins/username.ts +40 -7
  198. package/src/release/build-lib.ts +7 -1
  199. package/src/release/measure.ts +1 -0
  200. package/src/release/official-plugins.ts +4 -1
  201. package/src/runs/collect.ts +2 -1
  202. package/src/runs/drivers/files.ts +2 -1
  203. package/src/test/create-test-app.ts +114 -5
  204. package/src/test/export-bundle.test.ts +33 -0
  205. package/src/test/live-signals.test.ts +83 -0
  206. package/src/test/tenant-isolation.test.ts +175 -0
  207. package/src/testing.ts +26 -0
  208. package/src/upgrade/codemods.ts +1 -1
  209. package/site/content/docs/reference/migrating-environments.mdx +0 -158
@@ -0,0 +1,532 @@
1
+ /**
2
+ * OAuth social-login plugin — Authorization Code + PKCE for eight providers,
3
+ * routed through `linkOrProvision` with the per-provider email trust matrix.
4
+ *
5
+ * Security posture (locked decisions):
6
+ * - PKCE S256 mandatory; implicit / ROPC never implemented
7
+ * - Single-use `state` rows (SHA-256-hashed at rest) on the verification store
8
+ * - Mix-up defense (RFC 9700 §4.4 / RFC 9207): flow records pin the initiated
9
+ * issuer; callbacks verify route provider == stored provider and, for OIDC,
10
+ * ID token `iss` == stored expectation (Microsoft `{tenantid}` template via
11
+ * concrete per-tenant regex)
12
+ * - Exact registered redirect URIs — never derived from the request
13
+ * - Unverified emails cannot take over existing accounts (`linkOrProvision`
14
+ * refuses `email_in_use` without an authenticated `currentUserId`)
15
+ * - Provider tokens are NOT stored unless `storeProviderTokens`; then only in
16
+ * Vault behind envelope sealing
17
+ */
18
+
19
+ import { z } from "zod";
20
+ import {
21
+ authPublicGates,
22
+ bindPublicAuth,
23
+ bindSessionAuth,
24
+ createMethodRuntime,
25
+ fail,
26
+ flow,
27
+ http,
28
+ resolveSharedIdentities,
29
+ } from "./auth/shared.ts";
30
+ import { bindAuthHttp } from "../auth/bindings.ts";
31
+ import { hashChallenge } from "../auth/verification.ts";
32
+ import { issueSessionWithScopes } from "../auth/sessions.ts";
33
+ import type { Binding } from "../kernel/on.ts";
34
+ import type { Fx } from "../kernel/fx.ts";
35
+ import type { AnyFlowDef } from "../kernel/flow.ts";
36
+ import { identityFailureReason, oauthLink } from "./oauth/link.ts";
37
+ import {
38
+ consumeOauthFlow,
39
+ createOAuthFlowStore,
40
+ findOauthFlow,
41
+ putOauthFlow,
42
+ } from "./oauth/flow-store.ts";
43
+ import {
44
+ mintPkcePair,
45
+ OAUTH_PROVIDER_IDS,
46
+ bindPublicAuthGet,
47
+ type OAuthOptions,
48
+ type OAuthProviderConfig,
49
+ type OAuthProviderId,
50
+ } from "./oauth/shared.ts";
51
+ import { sealTokenEnvelope, unsealTokenEnvelope } from "./oauth/token-vault.ts";
52
+ import { generateState } from "../drivers/oauth-shared.ts";
53
+ import type { OAuthDriver, OAuthTokenSet } from "../drivers/oauth-types.ts";
54
+ import { openOAuthGoogle } from "../drivers/oauth-google.ts";
55
+ import { openOAuthApple } from "../drivers/oauth-apple.ts";
56
+ import { openOAuthMicrosoft } from "../drivers/oauth-microsoft.ts";
57
+ import { openOAuthGithub } from "../drivers/oauth-github.ts";
58
+ import { openOAuthDiscord } from "../drivers/oauth-discord.ts";
59
+ import { openOAuthX } from "../drivers/oauth-x.ts";
60
+ import { openOAuthFacebook } from "../drivers/oauth-facebook.ts";
61
+ import { openOAuthFigma } from "../drivers/oauth-figma.ts";
62
+ import { createAppleClientSecretJwt } from "../drivers/oauth-oidc.ts";
63
+ import { vault } from "../elements/vault.ts";
64
+ import { plugin } from "../kernel/plugin.ts";
65
+
66
+ export { OAUTH_PROVIDER_IDS };
67
+ export type { OAuthOptions, OAuthProviderConfig, OAuthProviderId };
68
+
69
+ const PROVIDER_ENUM = z.enum(OAUTH_PROVIDER_IDS);
70
+
71
+ /** Output of the start / link-start Flows. */
72
+ const StartOut = z.object({
73
+ provider: PROVIDER_ENUM,
74
+ authorizationUrl: z.string(),
75
+ expiresInMs: z.number(),
76
+ });
77
+
78
+ /** Session payload issued on a successful callback. */
79
+ const SessionTokensOut = z.object({
80
+ accessToken: z.string(),
81
+ refreshToken: z.string(),
82
+ accessExpiresAt: z.number(),
83
+ userId: z.string(),
84
+ });
85
+
86
+ const AuthFailed = z.object({ reason: z.string().optional() });
87
+ const AuthRateLimited = z.object({ reason: z.string() });
88
+
89
+ const StartIn = z.object({ provider: PROVIDER_ENUM });
90
+
91
+ const CallbackIn = z.object({
92
+ provider: PROVIDER_ENUM,
93
+ code: z.string().optional(),
94
+ state: z.string().optional(),
95
+ error: z.string().optional(),
96
+ /** Apple `response_mode=form_post` delivers urlencoded bodies here. */
97
+ body: z.unknown().optional(),
98
+ });
99
+
100
+ /** Resolved per-provider runtime bundle. */
101
+ interface ProviderRuntime {
102
+ readonly id: OAuthProviderId;
103
+ readonly driver: OAuthDriver;
104
+ readonly config: OAuthProviderConfig;
105
+ }
106
+
107
+ /**
108
+ * Social login plugin. Enable `gate.auth`, configure providers, then
109
+ * `.plug(oauth({...}))`.
110
+ *
111
+ * @param options - Provider configs, base URL, optional shared stores/fetch
112
+ */
113
+ export function oauth(options: OAuthOptions = {}) {
114
+ const runtime = createMethodRuntime(options);
115
+ const identities = resolveSharedIdentities(options);
116
+ const verifications = createOAuthFlowStore(options.verifications);
117
+ const now: () => number = () => runtime.now();
118
+ const providers = buildProviders(options);
119
+
120
+ const start = flow("auth.oauthStart", {
121
+ plane: "user",
122
+ in: StartIn,
123
+ out: StartOut,
124
+ errors: { AuthFailed, AuthRateLimited },
125
+ effects: {},
126
+ do: async (input) => {
127
+ const p = providers.find((c) => c.id === input.provider);
128
+ if (!p) return fail("AuthFailed", { reason: "provider_disabled" });
129
+ const redirectUri = resolveRedirectUri(p, options);
130
+ const clientId = p.config.clientId;
131
+ if (!redirectUri || !clientId) {
132
+ return fail("AuthFailed", { reason: "provider_misconfigured" });
133
+ }
134
+ const state = generateState();
135
+ const { verifier, challenge } = await mintPkcePair();
136
+ const nonce = p.driver.kind === "oidc" ? generateState() : undefined;
137
+ await putOauthFlow(
138
+ verifications,
139
+ {
140
+ provider: p.id,
141
+ stateHash: await hashChallenge(state),
142
+ codeVerifier: verifier,
143
+ redirectUri,
144
+ ...(nonce !== undefined ? { nonce } : {}),
145
+ expectedIssuer: p.driver.authorizationServerId,
146
+ clientId,
147
+ },
148
+ now(),
149
+ );
150
+ return {
151
+ provider: p.id,
152
+ authorizationUrl: p.driver.buildAuthorizeUrl({
153
+ clientId,
154
+ redirectUri,
155
+ scopes: p.config.scopes ?? [],
156
+ state,
157
+ codeChallenge: challenge,
158
+ ...(nonce !== undefined ? { nonce } : {}),
159
+ }).url,
160
+ expiresInMs: OAUTH_FLOW_TTL_MS,
161
+ };
162
+ },
163
+ });
164
+
165
+ const callback = flow("auth.oauthCallback", {
166
+ plane: "user",
167
+ in: CallbackIn,
168
+ out: SessionTokensOut,
169
+ errors: { AuthFailed, AuthRateLimited },
170
+ effects: { secrets: callbackSecretRefs(providers) },
171
+ do: async (input, fx) => {
172
+ if (typeof input.error === "string" && input.error.length > 0) {
173
+ return fail("AuthFailed", { reason: "authorization_failed" });
174
+ }
175
+ const code = input.code ?? fieldFromBody(input.body, "code");
176
+ const state = input.state ?? fieldFromBody(input.body, "state");
177
+ if (!code || !state) return fail("AuthFailed", { reason: "invalid_callback" });
178
+ const t = now();
179
+
180
+ // Mix-up defense layer 1 — rows are keyed `oauth:{provider}:{hash}`,
181
+ // so a state minted for another provider never matches this route.
182
+ const record = await findOauthFlow(verifications, input.provider, state, t);
183
+ if (!record || record.provider !== input.provider) {
184
+ return fail("AuthFailed", { reason: "invalid_state" });
185
+ }
186
+ await consumeOauthFlow(verifications, input.provider, state, t);
187
+
188
+ const p = providers.find((c) => c.id === record.provider);
189
+ if (!p) return fail("AuthFailed", { reason: "invalid_state" });
190
+
191
+ let credentials: Awaited<ReturnType<typeof loadCredentials>>;
192
+ try {
193
+ credentials = await loadCredentials(p, fx);
194
+ } catch (err) {
195
+ return fail("AuthFailed", { reason: credentialFailureReason(err) });
196
+ }
197
+
198
+ // Protocol phase — exchange + assertion. Any failure here is the
199
+ // provider's or an attacker's; nothing has touched identity state.
200
+ let assertion;
201
+ let exchangedTokens: OAuthTokenSet | undefined;
202
+ try {
203
+ const exchanged = await p.driver.exchangeCode({
204
+ clientId: record.clientId,
205
+ redirectUri: record.redirectUri,
206
+ code,
207
+ codeVerifier: record.codeVerifier,
208
+ ...(options.fetch !== undefined ? { fetch: options.fetch } : {}),
209
+ ...credentials,
210
+ });
211
+ exchangedTokens = exchanged.tokens;
212
+ // Mix-up defense layer 2 — drivers verify ID-token `iss` against the
213
+ // flow-record expectation; OAuth2 assertions carry the pinned AS id.
214
+ assertion = await p.driver.resolveAssertion({
215
+ tokens: exchanged.tokens,
216
+ expectedIssuer: record.expectedIssuer,
217
+ expectedAudience: record.clientId,
218
+ ...(record.nonce !== undefined ? { expectedNonce: record.nonce } : {}),
219
+ });
220
+ } catch (err) {
221
+ return fail("AuthFailed", { reason: protocolReason(err) });
222
+ }
223
+ if (assertion.issuer !== record.expectedIssuer) {
224
+ return fail("AuthFailed", { reason: "issuer_mismatch" });
225
+ }
226
+
227
+ // Identity phase — the ONLY path identities are ever written from.
228
+ let linked;
229
+ try {
230
+ linked = await oauthLink(identities, p.id, assertion, record.currentUserId);
231
+ } catch (err) {
232
+ const reason = identityFailureReason(err);
233
+ if (reason === undefined) throw err;
234
+ return fail("AuthFailed", { reason });
235
+ }
236
+
237
+ if (p.config.storeProviderTokens === true && exchangedTokens !== undefined) {
238
+ await persistProviderTokens(
239
+ p.id,
240
+ linked.userId,
241
+ exchangedTokens,
242
+ runtime.crypto.secret,
243
+ fx,
244
+ );
245
+ }
246
+ const issued = await issueSessionWithScopes(runtime.sessions, runtime.crypto, {
247
+ id: linked.userId,
248
+ plane: "user",
249
+ scopes: [],
250
+ });
251
+ return {
252
+ accessToken: issued.accessToken,
253
+ refreshToken: issued.refreshToken,
254
+ accessExpiresAt: issued.accessExpiresAt,
255
+ userId: linked.userId,
256
+ };
257
+ },
258
+ });
259
+
260
+ const linkStart = flow("auth.oauthLinkStart", {
261
+ plane: "user",
262
+ in: StartIn,
263
+ out: StartOut,
264
+ errors: { AuthFailed, AuthRateLimited },
265
+ effects: {},
266
+ do: async (input, fx) => {
267
+ const currentUserId = fx.auth.userId;
268
+ if (!currentUserId) return fail("AuthFailed", { reason: "unauthenticated" });
269
+ const p = providers.find((c) => c.id === input.provider);
270
+ if (!p) return fail("AuthFailed", { reason: "provider_disabled" });
271
+ const redirectUri = resolveRedirectUri(p, options);
272
+ const clientId = p.config.clientId;
273
+ if (!redirectUri || !clientId) {
274
+ return fail("AuthFailed", { reason: "provider_misconfigured" });
275
+ }
276
+ const state = generateState();
277
+ const { verifier, challenge } = await mintPkcePair();
278
+ const nonce = p.driver.kind === "oidc" ? generateState() : undefined;
279
+ await putOauthFlow(
280
+ verifications,
281
+ {
282
+ provider: p.id,
283
+ stateHash: await hashChallenge(state),
284
+ codeVerifier: verifier,
285
+ redirectUri,
286
+ ...(nonce !== undefined ? { nonce } : {}),
287
+ expectedIssuer: p.driver.authorizationServerId,
288
+ clientId,
289
+ currentUserId,
290
+ },
291
+ now(),
292
+ );
293
+ return {
294
+ provider: p.id,
295
+ authorizationUrl: p.driver.buildAuthorizeUrl({
296
+ clientId,
297
+ redirectUri,
298
+ scopes: p.config.scopes ?? [],
299
+ state,
300
+ codeChallenge: challenge,
301
+ ...(nonce !== undefined ? { nonce } : {}),
302
+ }).url,
303
+ expiresInMs: OAUTH_FLOW_TTL_MS,
304
+ };
305
+ },
306
+ });
307
+
308
+ const def = plugin("oauth", {
309
+ version: "0.0.1",
310
+ config: { providers: providers.map((p) => p.id) },
311
+ })
312
+ .needs("auth")
313
+ .flow(start)
314
+ .flow(callback)
315
+ .flow(linkStart);
316
+
317
+ def.binding(bindPublicAuth("/oauth/:provider/start", start, "otp"));
318
+ for (const b of bindCallbackBothMethods(callback)) def.binding(b);
319
+ def.binding(bindSessionAuth("/oauth/:provider/link", linkStart));
320
+
321
+ for (const contract of vaultContracts(providers)) def.vault(contract);
322
+
323
+ return def;
324
+ }
325
+
326
+ /* ------------------------------------------------------------------ */
327
+ /* Internals */
328
+ /* ------------------------------------------------------------------ */
329
+
330
+ const OAUTH_FLOW_TTL_MS = 10 * 60_000;
331
+
332
+ /**
333
+ * Bind the callback flow to both GET (standard `?code=` redirects) and POST
334
+ * (Apple `response_mode=form_post`) on the same per-provider path.
335
+ *
336
+ * @param flowDef - Callback flow
337
+ */
338
+ function bindCallbackBothMethods(flowDef: AnyFlowDef): Binding[] {
339
+ const gates = authPublicGates("otp");
340
+ const getBinding = bindPublicAuthGet("/auth/oauth/callback/:provider", flowDef, gates);
341
+ const postBinding = bindAuthHttp(
342
+ http
343
+ .post("/auth/oauth/callback/:provider")
344
+ .public()
345
+ .gate(...gates),
346
+ flowDef,
347
+ );
348
+ return [getBinding, postBinding];
349
+ }
350
+
351
+ function buildProviders(options: OAuthOptions): ReadonlyArray<ProviderRuntime> {
352
+ const out: ProviderRuntime[] = [];
353
+ for (const id of OAUTH_PROVIDER_IDS) {
354
+ const config = options.providers?.[id];
355
+ if (!config?.enabled) continue;
356
+ const common = {
357
+ ...(config.scopes !== undefined ? { scopes: config.scopes } : {}),
358
+ ...(options.fetch !== undefined ? { fetch: options.fetch } : {}),
359
+ };
360
+ let driver: OAuthDriver;
361
+ switch (id) {
362
+ case "google":
363
+ driver = openOAuthGoogle(common);
364
+ break;
365
+ case "apple":
366
+ driver = openOAuthApple({
367
+ ...common,
368
+ ...(config.teamId !== undefined ? { teamId: config.teamId } : {}),
369
+ ...(config.keyId !== undefined ? { keyId: config.keyId } : {}),
370
+ });
371
+ break;
372
+ case "microsoft":
373
+ driver = openOAuthMicrosoft({
374
+ ...common,
375
+ ...(config.tenant !== undefined ? { tenant: config.tenant } : {}),
376
+ });
377
+ break;
378
+ case "github":
379
+ driver = openOAuthGithub(common);
380
+ break;
381
+ case "discord":
382
+ driver = openOAuthDiscord(common);
383
+ break;
384
+ case "x":
385
+ driver = openOAuthX(common);
386
+ break;
387
+ case "facebook":
388
+ driver = openOAuthFacebook(common);
389
+ break;
390
+ case "figma":
391
+ driver = openOAuthFigma(common);
392
+ break;
393
+ }
394
+ out.push({ id, driver, config });
395
+ }
396
+ return out;
397
+ }
398
+
399
+ /**
400
+ * Exact registered redirect URI — explicit config wins, else synthesized
401
+ * from `baseUrl` using this plugin's canonical per-provider callback path.
402
+ */
403
+ function resolveRedirectUri(p: ProviderRuntime, options: OAuthOptions): string | undefined {
404
+ if (p.config.redirectUri !== undefined) return p.config.redirectUri;
405
+ if (options.baseUrl !== undefined) {
406
+ return `${options.baseUrl.replace(/\/$/, "")}/auth/oauth/callback/${p.id}`;
407
+ }
408
+ return undefined;
409
+ }
410
+
411
+ /** Vault path holding the sealed per-user provider-token envelope. */
412
+ function tokenVaultPath(provider: OAuthProviderId): string {
413
+ return `oauth/tokens/${provider}`;
414
+ }
415
+
416
+ /** Secret refs the callback may read/write (declared up front). */
417
+ function callbackSecretRefs(providers: ReadonlyArray<ProviderRuntime>): string[] {
418
+ const refs = new Set<string>();
419
+ for (const p of providers) {
420
+ const upper = p.id.toUpperCase();
421
+ if (p.id === "apple") refs.add("OAUTH_APPLE_PRIVATE_KEY");
422
+ else refs.add(`OAUTH_${upper}_CLIENT_SECRET`);
423
+ if (p.config.clientId === undefined) refs.add(`OAUTH_${upper}_CLIENT_ID`);
424
+ if (p.config.storeProviderTokens === true) refs.add(tokenVaultPath(p.id));
425
+ }
426
+ return [...refs];
427
+ }
428
+
429
+ function vaultContracts(providers: ReadonlyArray<ProviderRuntime>) {
430
+ const contracts = [];
431
+ for (const p of providers) {
432
+ if (p.id === "apple") {
433
+ contracts.push(
434
+ vault.secret("OAUTH_APPLE_PRIVATE_KEY", {
435
+ description: "Sign in with Apple team private key (PKCS#8 PEM)",
436
+ sensitive: true,
437
+ }),
438
+ );
439
+ } else {
440
+ contracts.push(
441
+ vault.secret(`OAUTH_${p.id.toUpperCase()}_CLIENT_SECRET`, {
442
+ description: `${p.id} OAuth client secret`,
443
+ sensitive: true,
444
+ }),
445
+ );
446
+ }
447
+ }
448
+ return contracts;
449
+ }
450
+
451
+ /** Extract one urlencoded field from a form_post body string. */
452
+ function fieldFromBody(body: unknown, field: string): string | undefined {
453
+ if (typeof body !== "string") return undefined;
454
+ return new URLSearchParams(body).get(field) ?? undefined;
455
+ }
456
+
457
+ interface ClientCredentials {
458
+ readonly clientSecret?: string;
459
+ readonly clientSecretJwt?: () => Promise<string>;
460
+ }
461
+
462
+ /**
463
+ * Resolve client credential material at the Vault boundary (the one place
464
+ * `Redacted.reveal()` is allowed).
465
+ */
466
+ async function loadCredentials(p: ProviderRuntime, fx: Fx): Promise<ClientCredentials> {
467
+ if (p.id === "apple") {
468
+ const pem = (await fx.vault.get("OAUTH_APPLE_PRIVATE_KEY")).reveal();
469
+ return {
470
+ clientSecretJwt: async () =>
471
+ createAppleClientSecretJwt({
472
+ privateKeyPem: pem,
473
+ teamId: p.config.teamId!,
474
+ keyId: p.config.keyId!,
475
+ clientId: p.config.clientId!,
476
+ }),
477
+ };
478
+ }
479
+ if (p.id === "x") {
480
+ // X authorization-code+PKCE is a public client — no secret on the call.
481
+ return {};
482
+ }
483
+ const upper = p.id.toUpperCase();
484
+ const secret = (await fx.vault.get(`OAUTH_${upper}_CLIENT_SECRET`)).reveal();
485
+ return { clientSecret: secret };
486
+ }
487
+
488
+ function credentialFailureReason(err: unknown): string {
489
+ const message = err instanceof Error ? err.message : String(err);
490
+ if (message.includes("missing") || message.includes("not found")) return "secret_unavailable";
491
+ return "secret_unavailable";
492
+ }
493
+
494
+ /**
495
+ * Envelope-seal provider tokens per user into one static vault path.
496
+ * Capability sets are exact-match, so dynamic per-user paths cannot be
497
+ * declared — per-user isolation lives inside the sealed envelope.
498
+ */
499
+ async function persistProviderTokens(
500
+ provider: OAuthProviderId,
501
+ userId: string,
502
+ tokens: OAuthTokenSet,
503
+ sealSecret: string,
504
+ fx: Fx,
505
+ ): Promise<void> {
506
+ const path = tokenVaultPath(provider);
507
+ let envelope: Record<string, string> = {};
508
+ try {
509
+ envelope = await unsealTokenEnvelope(sealSecret, (await fx.vault.get(path)).reveal());
510
+ } catch {
511
+ envelope = {};
512
+ }
513
+ const sanitized = stripRaw(tokens);
514
+ envelope[userId] = await sealTokenEnvelope(sealSecret, JSON.stringify(sanitized));
515
+ await fx.vault.set(path, JSON.stringify(envelope));
516
+ }
517
+
518
+ /** Drop the raw provider response before persisting. */
519
+ function stripRaw(tokens: OAuthTokenSet): Omit<OAuthTokenSet, "raw"> {
520
+ return {
521
+ accessToken: tokens.accessToken,
522
+ ...(tokens.refreshToken !== undefined ? { refreshToken: tokens.refreshToken } : {}),
523
+ ...(tokens.expiresAt !== undefined ? { expiresAt: tokens.expiresAt } : {}),
524
+ ...(tokens.scopes !== undefined ? { scopes: tokens.scopes } : {}),
525
+ ...(tokens.idToken !== undefined ? { idToken: tokens.idToken } : {}),
526
+ };
527
+ }
528
+
529
+ function protocolReason(err: unknown): string {
530
+ const reason = (err as { reason?: unknown } | null)?.reason;
531
+ return typeof reason === "string" ? reason : "exchange_failed";
532
+ }
@@ -5,10 +5,11 @@
5
5
 
6
6
  import { constantTimeEqual } from "../auth/constant-time.ts";
7
7
  import {
8
- createIdentityStore,
9
- ensureUserByEmail,
8
+ IdentityError,
9
+ linkOrProvision,
10
10
  normalizeEmail,
11
11
  type IdentityStore,
12
+ type UserIdentityRow,
12
13
  } from "../auth/identity.ts";
13
14
  import type { OtpPluginConfig } from "../auth/otp-capability.ts";
14
15
  import { sealOtp, unsealOtp } from "../auth/otp-seal.ts";
@@ -35,6 +36,7 @@ import {
35
36
  fail,
36
37
  flow,
37
38
  resolveMethodSecret,
39
+ resolveSharedIdentities,
38
40
  z,
39
41
  type AuthMethodOptions,
40
42
  } from "./auth/shared.ts";
@@ -198,7 +200,7 @@ export function otp(opts: OtpOptions): PluginDef {
198
200
  const runtime = createMethodRuntime(opts);
199
201
  const secret = resolveMethodSecret(opts);
200
202
  const phones = opts.phones ?? createPhoneStore();
201
- const identities = opts.identities ?? createIdentityStore();
203
+ const identities = resolveSharedIdentities(opts);
202
204
  const verifications = opts.verifications ?? createVerificationStore();
203
205
  const ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
204
206
  const resendCooldownMs =
@@ -319,7 +321,19 @@ export function otp(opts: OtpOptions): PluginDef {
319
321
 
320
322
  let userId = phones.byPhone.get(phone);
321
323
  if (!userId) {
322
- userId = crypto.randomUUID();
324
+ try {
325
+ const { user } = await linkOrProvision(identities, {
326
+ provider: "otp",
327
+ providerAccountId: phone,
328
+ now: () => now,
329
+ });
330
+ userId = user.id;
331
+ } catch (err) {
332
+ if (err instanceof IdentityError) {
333
+ return fail("AuthFailed", { reason: "invalid_credentials" });
334
+ }
335
+ throw err;
336
+ }
323
337
  phones.byPhone.set(phone, userId);
324
338
  }
325
339
  const issued = await issueSessionWithScopes(runtime.sessions, runtime.crypto, {
@@ -518,7 +532,19 @@ export function otp(opts: OtpOptions): PluginDef {
518
532
  if (phone) {
519
533
  let userId = phones.byPhone.get(phone);
520
534
  if (!userId) {
521
- userId = crypto.randomUUID();
535
+ try {
536
+ const { user } = await linkOrProvision(identities, {
537
+ provider: "otp",
538
+ providerAccountId: phone,
539
+ now: () => now,
540
+ });
541
+ userId = user.id;
542
+ } catch (err) {
543
+ if (err instanceof IdentityError) {
544
+ return fail("AuthFailed", { reason: "invalid_credentials" });
545
+ }
546
+ throw err;
547
+ }
522
548
  phones.byPhone.set(phone, userId);
523
549
  }
524
550
  const issued = await issueSessionWithScopes(runtime.sessions, runtime.crypto, {
@@ -534,7 +560,23 @@ export function otp(opts: OtpOptions): PluginDef {
534
560
  };
535
561
  }
536
562
 
537
- const user = ensureUserByEmail(identities, email!, now);
563
+ let user: UserIdentityRow;
564
+ try {
565
+ user = (
566
+ await linkOrProvision(identities, {
567
+ provider: "otp",
568
+ providerAccountId: email!,
569
+ email,
570
+ emailVerified: true,
571
+ now: () => now,
572
+ })
573
+ ).user;
574
+ } catch (err) {
575
+ if (err instanceof IdentityError) {
576
+ return fail("AuthFailed", { reason: "invalid_credentials" });
577
+ }
578
+ throw err;
579
+ }
538
580
  const issued = await issueSessionWithScopes(runtime.sessions, runtime.crypto, {
539
581
  id: user.id,
540
582
  plane: "user",
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import { constantTimeEqual } from "../auth/constant-time.ts";
10
+ import { IdentityError, linkOrProvision } from "../auth/identity.ts";
10
11
  import {
11
12
  createVerificationStore,
12
13
  hashChallenge,
@@ -24,6 +25,7 @@ import {
24
25
  createMethodRuntime,
25
26
  fail,
26
27
  flow,
28
+ resolveSharedIdentities,
27
29
  z,
28
30
  type AuthMethodOptions,
29
31
  } from "./auth/shared.ts";
@@ -82,6 +84,7 @@ const CeremonyIn = z.object({
82
84
  */
83
85
  export function passkey(opts: PasskeyOptions = {}): PluginDef {
84
86
  const runtime = createMethodRuntime(opts);
87
+ const identities = resolveSharedIdentities(opts);
85
88
  const passkeys = opts.passkeys ?? createPasskeyStore();
86
89
  const challenges = opts.challenges ?? createVerificationStore();
87
90
  const rpId = opts.rpId ?? "localhost";
@@ -152,9 +155,25 @@ export function passkey(opts: PasskeyOptions = {}): PluginDef {
152
155
  if (passkeys.byCredentialId.has(input.credentialId)) {
153
156
  return fail("AuthFailed", { reason: "invalid_credentials" });
154
157
  }
158
+ let userId: string;
159
+ try {
160
+ userId = (
161
+ await linkOrProvision(identities, {
162
+ provider: "passkey",
163
+ providerAccountId: input.credentialId,
164
+ currentUserId: sessionUser,
165
+ now: runtime.now,
166
+ })
167
+ ).user.id;
168
+ } catch (err) {
169
+ if (err instanceof IdentityError) {
170
+ return fail("AuthFailed", { reason: "invalid_credentials" });
171
+ }
172
+ throw err;
173
+ }
155
174
  const cred: PasskeyCredential = {
156
175
  credentialId: input.credentialId,
157
- userId: input.userId,
176
+ userId,
158
177
  publicKey: input.publicKey,
159
178
  counter: verified.signCount,
160
179
  createdAt: runtime.now(),