okengine 0.17.2 → 0.18.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -5
- package/manifest.v1.schema.json +61 -2
- package/package.json +18 -4
- package/site/content/docs/elements/clock.mdx +1 -1
- package/site/content/docs/elements/flow.mdx +25 -1
- package/site/content/docs/elements/store.mdx +287 -341
- package/site/content/docs/elements/vault.mdx +5 -5
- package/site/content/docs/get-started/installation.mdx +1 -2
- package/site/content/docs/get-started/introduction.mdx +54 -110
- package/site/content/docs/get-started/meta.json +9 -1
- package/site/content/docs/get-started/testing.mdx +328 -0
- package/site/content/docs/get-started/why.mdx +94 -70
- package/site/content/docs/index.mdx +1 -1
- package/site/content/docs/plugins/apple.mdx +151 -0
- package/site/content/docs/plugins/discord.mdx +139 -0
- package/site/content/docs/plugins/facebook.mdx +134 -0
- package/site/content/docs/plugins/figma.mdx +138 -0
- package/site/content/docs/plugins/github.mdx +138 -0
- package/site/content/docs/plugins/google.mdx +153 -0
- package/site/content/docs/plugins/index.mdx +47 -1
- package/site/content/docs/plugins/meta.json +10 -0
- package/site/content/docs/plugins/microsoft.mdx +151 -0
- package/site/content/docs/plugins/oauth.mdx +188 -0
- package/site/content/docs/plugins/x.mdx +125 -0
- package/site/content/docs/reference/cli.md +3 -2
- package/site/content/docs/reference/client.mdx +58 -1
- package/site/content/docs/reference/configuration.mdx +2 -4
- package/site/content/docs/reference/fx.mdx +3 -1
- package/site/content/docs/reference/index.mdx +0 -5
- package/site/content/docs/reference/meta.json +2 -2
- package/site/content/docs/reference/okid.mdx +137 -0
- package/src/auth/bindings.ts +1 -1
- package/src/auth/config.ts +9 -0
- package/src/auth/identity-sql.ts +314 -0
- package/src/auth/identity.ts +140 -2
- package/src/auth/index.ts +17 -1
- package/src/auth/method-context.ts +3 -0
- package/src/auth/oauth-as/cimd.ts +132 -0
- package/src/auth/oauth-as/crypto.test.ts +101 -0
- package/src/auth/oauth-as/crypto.ts +393 -0
- package/src/auth/oauth-as/errors.ts +68 -0
- package/src/auth/oauth-as/http.test.ts +419 -0
- package/src/auth/oauth-as/http.ts +842 -0
- package/src/auth/oauth-as/stores.ts +61 -0
- package/src/auth/oauth-as/tables.ts +142 -0
- package/src/auth/tables.ts +0 -11
- package/src/bench/README.md +83 -0
- package/src/bench/REPORT.md +176 -0
- package/src/bench/g01-rls-stamp.bench.ts +194 -0
- package/src/bench/g02-clock-per-tenant.bench.ts +158 -0
- package/src/bench/g03-signal-once.bench.ts +157 -0
- package/src/bench/g03-signal-reconnect.bench.ts +254 -0
- package/src/bench/g03-signal-sse-memory.bench.ts +191 -0
- package/src/bench/g04-auth-vault-hotpath.bench.ts +170 -0
- package/src/bench/g05-sustained-full.bench.ts +265 -0
- package/src/bench/g06-mixed-load.bench.ts +260 -0
- package/src/bench/g07-vault-crypto.bench.ts +100 -0
- package/src/bench/g07-vault-rotate-under-read.bench.ts +285 -0
- package/src/bench/g08-conn-oversubscribe.bench.ts +194 -0
- package/src/bench/g08-store-kv-durable.bench.ts +133 -0
- package/src/bench/g08-store-sql.bench.ts +178 -0
- package/src/bench/g09-journal-sustained.bench.ts +203 -0
- package/src/bench/g10-observability-contention.bench.ts +246 -0
- package/src/bench/g11-cold-start-cycle.bench.ts +164 -0
- package/src/bench/g13-elements.bench.ts +427 -0
- package/src/bench/g14-graceful-shutdown.bench.ts +244 -0
- package/src/bench/g15-postgres-degradation.bench.ts +264 -0
- package/src/bench/g16-live-query-fanout.bench.ts +206 -0
- package/src/bench/lib/event-loop-lag.ts +26 -0
- package/src/bench/lib/infra.ts +60 -0
- package/src/bench/lib/report.ts +52 -0
- package/src/bench/lib/rss-sampler.ts +61 -0
- package/src/bench/lib/signal-pg.ts +88 -0
- package/src/bench/load-app.ts +337 -0
- package/src/bench/load-child.ts +108 -0
- package/src/bench/smoke.bench.ts +43 -0
- package/src/cli/competitor-mention-removal.test.ts +28 -0
- package/src/cli/doctor-fd.ts +117 -0
- package/src/cli/doctor.test.ts +192 -0
- package/src/cli/doctor.ts +129 -1
- package/src/client/create.ts +95 -1
- package/src/client/index.ts +9 -2
- package/src/client/transport.ts +11 -4
- package/src/client/use-live-query.ts +154 -0
- package/src/client-react/index.ts +15 -1
- package/src/client-react/live-resource.ts +246 -0
- package/src/client-react/use-live-query.test.ts +475 -0
- package/src/client-react/use-live-query.ts +530 -0
- package/src/compiler/extract.test.ts +518 -0
- package/src/compiler/extract.ts +386 -19
- package/src/console/server/invoke-user-flow.ts +2 -1
- package/src/console/ui-next/dist/assets/{access-page-DnWbnGzq.js → access-page-DY4N6nnk.js} +1 -1
- package/src/console/ui-next/dist/assets/{flows-page-BiZ4-6yQ.js → flows-page-CsPDMrVM.js} +1 -1
- package/src/console/ui-next/dist/assets/{index-C8NRK2R-.js → index-CcTDXHuz.js} +3 -3
- package/src/console/ui-next/dist/assets/{observability-page-CrB6vd1T.js → observability-page-CKR595wP.js} +1 -1
- package/src/console/ui-next/dist/assets/{store-page-CS5-aETQ.js → store-page-02xOiqIK.js} +3 -3
- package/src/console/ui-next/dist/assets/{units-page-CjtdlW8l.js → units-page-CpPFFKyE.js} +1 -1
- package/src/console/ui-next/dist/assets/{vault-page-C6Xxm9SA.js → vault-page-BsMf-9_W.js} +1 -1
- package/src/console/ui-next/dist/index.html +1 -1
- package/src/console/ui-next/src/features/store/lib/fields-from-table.ts +36 -2
- package/src/drivers/cdc-outbox.ts +389 -0
- package/src/drivers/memory.ts +20 -0
- package/src/drivers/oauth-apple.ts +156 -0
- package/src/drivers/oauth-discord.ts +79 -0
- package/src/drivers/oauth-facebook.ts +80 -0
- package/src/drivers/oauth-figma.ts +116 -0
- package/src/drivers/oauth-github.ts +92 -0
- package/src/drivers/oauth-google.ts +142 -0
- package/src/drivers/oauth-microsoft.ts +174 -0
- package/src/drivers/oauth-oidc.ts +293 -0
- package/src/drivers/oauth-shared.ts +326 -0
- package/src/drivers/oauth-types.ts +159 -0
- package/src/drivers/oauth-x.ts +77 -0
- package/src/drivers/oauth2-common.ts +95 -0
- package/src/drivers/oauth2-token.ts +61 -0
- package/src/drivers/pg-rls-row-passes.ts +251 -0
- package/src/drivers/pg-rls.ts +2 -0
- package/src/drivers/postgres.ts +45 -2
- package/src/drivers/signal-postgres.ts +2 -1
- package/src/elements/channel/runtime.ts +29 -2
- package/src/elements/channel.test.ts +52 -0
- package/src/elements/gate/boot.ts +29 -2
- package/src/elements/store/emit-drizzle.ts +147 -14
- package/src/elements/store/field-ddl.test.ts +118 -0
- package/src/elements/store/field-types.test.ts +455 -0
- package/src/elements/store/list-query.golden.json +777 -0
- package/src/elements/store/list-query.parity.test.ts +396 -0
- package/src/elements/store/list-query.ts +792 -0
- package/src/elements/store/live-default.test.ts +136 -0
- package/src/elements/store/live-http.test.ts +160 -0
- package/src/elements/store/live-isolation.test.ts +291 -0
- package/src/elements/store/live-query-runtime.test.ts +323 -0
- package/src/elements/store/live-query-runtime.ts +403 -0
- package/src/elements/store/live-query-server.test.ts +377 -0
- package/src/elements/store/live-query-server.ts +102 -0
- package/src/elements/store/live-query.ts +97 -0
- package/src/elements/store/resource.ts +189 -680
- package/src/elements/store/rls-row-passes-policies.parity.test.ts +665 -0
- package/src/elements/store/schema-decl.ts +539 -41
- package/src/elements/store/sql-rls-stamp.test.ts +27 -0
- package/src/elements/store/sql-session.ts +297 -35
- package/src/elements/store/table.ts +102 -21
- package/src/elements/store.test.ts +3 -1
- package/src/elements/store.ts +12 -1
- package/src/elements/vault/chaos-child.ts +74 -1
- package/src/elements/vault/chaos.test.ts +4 -2
- package/src/elements/vault/storage.ts +4 -2
- package/src/index.ts +4 -1
- package/src/kernel/app-auth.ts +1 -0
- package/src/kernel/app.ts +116 -2
- package/src/kernel/auth-sharing.test.ts +196 -0
- package/src/kernel/boot.test.ts +3 -3
- package/src/kernel/errors.ts +8 -0
- package/src/kernel/fx.test.ts +1 -0
- package/src/kernel/fx.ts +14 -2
- package/src/kernel/horizontal-child.ts +2 -1
- package/src/kernel/http-resource.ts +33 -7
- package/src/kernel/identity-host-persist.test.ts +119 -0
- package/src/kernel/instance-id.ts +4 -2
- package/src/kernel/journal.ts +2 -1
- package/src/kernel/mcp-tool.test.ts +95 -0
- package/src/kernel/on.ts +9 -0
- package/src/kernel/realtime-bind.ts +326 -0
- package/src/kernel/resource-live.ts +117 -0
- package/src/kernel/triggers.ts +86 -4
- package/src/manifest/diff.ts +37 -0
- package/src/manifest/types.ts +64 -2
- package/src/okid.bench.test.ts +64 -0
- package/src/okid.test.ts +338 -0
- package/src/okid.ts +245 -0
- package/src/plugins/anonymous.ts +19 -1
- package/src/plugins/auth/shared.ts +15 -0
- package/src/plugins/index.ts +2 -0
- package/src/plugins/magic-link.ts +10 -8
- package/src/plugins/mcp-oauth.ts +208 -0
- package/src/plugins/oauth/flow-store.ts +117 -0
- package/src/plugins/oauth/link.ts +69 -0
- package/src/plugins/oauth/shared.ts +108 -0
- package/src/plugins/oauth/token-vault.ts +100 -0
- package/src/plugins/oauth.security.test.ts +535 -0
- package/src/plugins/oauth.ts +532 -0
- package/src/plugins/otp.ts +48 -6
- package/src/plugins/passkey.ts +20 -1
- package/src/plugins/two-factor.ts +11 -0
- package/src/plugins/username.ts +40 -7
- package/src/release/build-lib.ts +7 -1
- package/src/release/measure.ts +1 -0
- package/src/release/official-plugins.ts +4 -1
- package/src/runs/collect.ts +2 -1
- package/src/runs/drivers/files.ts +2 -1
- package/src/test/create-test-app.ts +114 -5
- package/src/test/export-bundle.test.ts +33 -0
- package/src/test/live-signals.test.ts +83 -0
- package/src/test/tenant-isolation.test.ts +175 -0
- package/src/testing.ts +26 -0
- package/src/upgrade/codemods.ts +1 -1
- package/site/content/docs/reference/migrating-environments.mdx +0 -158
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exploit-proof security audit for the OAuth social-login plugin.
|
|
3
|
+
*
|
|
4
|
+
* Real HTTP against a booted app whose drivers talk to a mock IdP
|
|
5
|
+
* (injectable fetch). Covers the two locked threat classes:
|
|
6
|
+
* - GHSA-6g38-8j4p-j3pr — unverified-email account takeover blocked at
|
|
7
|
+
* `linkOrProvision`; authenticated linking still succeeds (control).
|
|
8
|
+
* - Mix-up (RFC 9700 §4.4 / RFC 9207) — cross-provider state replay and
|
|
9
|
+
* wrong-issuer ID tokens rejected; flow rows are single-use.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
13
|
+
import { memoryVaultDriver } from "../drivers/index.ts";
|
|
14
|
+
import { createIdentityStore, type IdentityStore } from "../auth/identity.ts";
|
|
15
|
+
import { rawToJose } from "../drivers/oauth-oidc.ts";
|
|
16
|
+
import { parseEmailVerified } from "../drivers/oauth-shared.ts";
|
|
17
|
+
import { openOAuthDiscord } from "../drivers/oauth-discord.ts";
|
|
18
|
+
import { openOAuthGithub } from "../drivers/oauth-github.ts";
|
|
19
|
+
import { verifyIdToken } from "../drivers/oauth-oidc.ts";
|
|
20
|
+
import { base64UrlEncode } from "../drivers/oauth-shared.ts";
|
|
21
|
+
import { oke } from "../kernel/app.ts";
|
|
22
|
+
import { resetFlowSeq } from "../kernel/flow.ts";
|
|
23
|
+
import { resetBindings } from "../kernel/on.ts";
|
|
24
|
+
import { oauth } from "./oauth.ts";
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
resetBindings();
|
|
28
|
+
resetFlowSeq();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const SECRET = "test-secret-at-least-16";
|
|
32
|
+
|
|
33
|
+
/** Client secrets seeded into the memory vault chain for every harness. */
|
|
34
|
+
const VAULT_SEED: Readonly<Record<string, string>> = {
|
|
35
|
+
OAUTH_GOOGLE_CLIENT_SECRET: "g-secret",
|
|
36
|
+
OAUTH_GITHUB_CLIENT_SECRET: "gh-secret",
|
|
37
|
+
OAUTH_DISCORD_CLIENT_SECRET: "dc-secret",
|
|
38
|
+
OAUTH_FACEBOOK_CLIENT_SECRET: "fb-secret",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/* ------------------------------------------------------------------ */
|
|
42
|
+
/* Harness */
|
|
43
|
+
/* ------------------------------------------------------------------ */
|
|
44
|
+
|
|
45
|
+
function jsonPost(path: string, body: unknown, headers: Record<string, string> = {}): Request {
|
|
46
|
+
return new Request(`http://localhost${path}`, {
|
|
47
|
+
method: "POST",
|
|
48
|
+
headers: { "content-type": "application/json", ...headers },
|
|
49
|
+
body: JSON.stringify(body),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function readError(res: Response): Promise<{ code?: string; reason?: string }> {
|
|
54
|
+
const body = (await res.json()) as {
|
|
55
|
+
error?: { code?: string; data?: { reason?: string } };
|
|
56
|
+
};
|
|
57
|
+
return { code: body.error?.code, reason: body.error?.data?.reason };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function jsonResponse(body: unknown, status = 200): Response {
|
|
61
|
+
return new Response(JSON.stringify(body), {
|
|
62
|
+
status,
|
|
63
|
+
headers: { "content-type": "application/json" },
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface MockRoute {
|
|
68
|
+
readonly match: (url: URL) => boolean;
|
|
69
|
+
readonly respond: () => Response | Promise<Response>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function jsonRoute(match: (url: URL) => boolean, body: unknown, status = 200): MockRoute {
|
|
73
|
+
return { match, respond: () => jsonResponse(body, status) };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Provider-side mock: routes requests to matching stubs and records every
|
|
78
|
+
* URL so tests can prove a step was (or was never) reached.
|
|
79
|
+
*/
|
|
80
|
+
function mockFetch(
|
|
81
|
+
routes: readonly MockRoute[],
|
|
82
|
+
calls: { readonly urls: string[] } = { urls: [] },
|
|
83
|
+
): typeof globalThis.fetch {
|
|
84
|
+
const impl = async (input: string | URL | Request, init: RequestInit = {}): Promise<Response> => {
|
|
85
|
+
void init;
|
|
86
|
+
const url = new URL(input instanceof Request ? input.url : String(input));
|
|
87
|
+
calls.urls.push(url.href);
|
|
88
|
+
for (const route of routes) {
|
|
89
|
+
if (route.match(url)) return await route.respond();
|
|
90
|
+
}
|
|
91
|
+
throw new Error(`mock IdP: no route for ${url.href}`);
|
|
92
|
+
};
|
|
93
|
+
return impl as typeof globalThis.fetch;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function bootHarness(fetchFn: typeof globalThis.fetch): Promise<{
|
|
97
|
+
app: ReturnType<typeof buildOauthApp>;
|
|
98
|
+
identities: IdentityStore;
|
|
99
|
+
}> {
|
|
100
|
+
const app = buildOauthApp(fetchFn);
|
|
101
|
+
await app.boot({
|
|
102
|
+
env: "test",
|
|
103
|
+
vault: {
|
|
104
|
+
chain: [
|
|
105
|
+
{ driver: memoryVaultDriver, source: "driver", options: { secrets: { ...VAULT_SEED } } },
|
|
106
|
+
],
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
return { app, identities: harnessIdentities(app) };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function buildOauthApp(fetchFn: typeof globalThis.fetch) {
|
|
113
|
+
// One shared store for Gate auth AND the OAuth plugin — the same rule the
|
|
114
|
+
// production wiring enforces via `resolveSharedIdentities`.
|
|
115
|
+
const identities = createIdentityStore();
|
|
116
|
+
const app = oke({
|
|
117
|
+
name: `oauth-sec-${crypto.randomUUID()}`,
|
|
118
|
+
env: "test",
|
|
119
|
+
registry: "ignore",
|
|
120
|
+
gate: {
|
|
121
|
+
auth: { secret: SECRET, emailAndPassword: { enabled: true }, identities },
|
|
122
|
+
unguardedHttp: "deny",
|
|
123
|
+
},
|
|
124
|
+
}).plug(
|
|
125
|
+
oauth({
|
|
126
|
+
secret: SECRET,
|
|
127
|
+
identities,
|
|
128
|
+
fetch: fetchFn,
|
|
129
|
+
providers: {
|
|
130
|
+
google: {
|
|
131
|
+
enabled: true,
|
|
132
|
+
clientId: "g-client",
|
|
133
|
+
redirectUri: "https://app.example.com/auth/oauth/callback/google",
|
|
134
|
+
},
|
|
135
|
+
github: {
|
|
136
|
+
enabled: true,
|
|
137
|
+
clientId: "gh-client",
|
|
138
|
+
redirectUri: "https://app.example.com/auth/oauth/callback/github",
|
|
139
|
+
},
|
|
140
|
+
discord: {
|
|
141
|
+
enabled: true,
|
|
142
|
+
clientId: "dc-client",
|
|
143
|
+
redirectUri: "https://app.example.com/auth/oauth/callback/discord",
|
|
144
|
+
},
|
|
145
|
+
facebook: {
|
|
146
|
+
enabled: true,
|
|
147
|
+
clientId: "fb-client",
|
|
148
|
+
redirectUri: "https://app.example.com/auth/oauth/callback/facebook",
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
}),
|
|
152
|
+
);
|
|
153
|
+
appWithIdentities.set(app, identities);
|
|
154
|
+
return app;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const appWithIdentities = new WeakMap<object, IdentityStore>();
|
|
158
|
+
|
|
159
|
+
function harnessIdentities(app: ReturnType<typeof buildOauthApp>): IdentityStore {
|
|
160
|
+
const store = appWithIdentities.get(app);
|
|
161
|
+
if (!store) throw new Error("harness identities missing");
|
|
162
|
+
return store;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function startOAuth(
|
|
166
|
+
app: ReturnType<typeof buildOauthApp>,
|
|
167
|
+
provider: string,
|
|
168
|
+
): Promise<{ authorizationUrl: string; state: string }> {
|
|
169
|
+
const res = await app.fetch(jsonPost(`/auth/oauth/${provider}/start`, { provider }));
|
|
170
|
+
expect(res.status).toBe(200);
|
|
171
|
+
const body = (await res.json()) as { data: { authorizationUrl: string } };
|
|
172
|
+
const state = new URL(body.data.authorizationUrl).searchParams.get("state");
|
|
173
|
+
expect(state).toBeTruthy();
|
|
174
|
+
return { authorizationUrl: body.data.authorizationUrl, state: state! };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function callbackGet(provider: string, state: string, code = "auth-code"): Request {
|
|
178
|
+
return new Request(
|
|
179
|
+
`http://localhost/auth/oauth/callback/${provider}?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function signUpViaPassword(
|
|
184
|
+
app: ReturnType<typeof buildOauthApp>,
|
|
185
|
+
email: string,
|
|
186
|
+
): Promise<{ userId: string; accessToken: string }> {
|
|
187
|
+
const res = await app.fetch(
|
|
188
|
+
jsonPost("/auth/sign-up/email", { email, password: "CorrectHorse1!" }),
|
|
189
|
+
);
|
|
190
|
+
expect(res.status).toBe(200);
|
|
191
|
+
const body = (await res.json()) as { data: { userId: string; accessToken: string } };
|
|
192
|
+
return body.data;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Craft a real ES256-signed ID token ("attacker-minted") plus the JWK that
|
|
197
|
+
* verifies it, for mix-up simulations.
|
|
198
|
+
*/
|
|
199
|
+
async function signEs256IdToken(claims: Readonly<Record<string, unknown>>): Promise<{
|
|
200
|
+
token: string;
|
|
201
|
+
publicKeyJwk: Record<string, unknown>;
|
|
202
|
+
}> {
|
|
203
|
+
const pair = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, [
|
|
204
|
+
"sign",
|
|
205
|
+
"verify",
|
|
206
|
+
]);
|
|
207
|
+
const exported = (await crypto.subtle.exportKey("jwk", pair.publicKey)) as Record<
|
|
208
|
+
string,
|
|
209
|
+
unknown
|
|
210
|
+
>;
|
|
211
|
+
const enc = new TextEncoder();
|
|
212
|
+
const header = base64UrlEncode(enc.encode(JSON.stringify({ alg: "ES256", kid: "atk-kid" })));
|
|
213
|
+
const payload = base64UrlEncode(enc.encode(JSON.stringify(claims)));
|
|
214
|
+
const der = new Uint8Array(
|
|
215
|
+
await crypto.subtle.sign(
|
|
216
|
+
{ name: "ECDSA", hash: "SHA-256" },
|
|
217
|
+
pair.privateKey,
|
|
218
|
+
enc.encode(`${header}.${payload}`),
|
|
219
|
+
),
|
|
220
|
+
);
|
|
221
|
+
return {
|
|
222
|
+
token: `${header}.${payload}.${rawToJose(der.subarray(0, 32), der.subarray(32))}`,
|
|
223
|
+
publicKeyJwk: { ...exported, kid: "atk-kid", alg: "ES256" },
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/* ------------------------------------------------------------------ */
|
|
228
|
+
/* (a) GHSA-6g38-8j4p-j3pr — unverified-email hijack */
|
|
229
|
+
/* ------------------------------------------------------------------ */
|
|
230
|
+
|
|
231
|
+
describe("GHSA-6g38-8j4p-j3pr — unverified email hijack", () => {
|
|
232
|
+
test("Facebook callback claiming a registered email cannot sign in as the victim", async () => {
|
|
233
|
+
const victimEmail = "victim@example.com";
|
|
234
|
+
const calls = { urls: [] as string[] };
|
|
235
|
+
const { app, identities } = await bootHarness(
|
|
236
|
+
mockFetch(
|
|
237
|
+
[
|
|
238
|
+
jsonRoute(
|
|
239
|
+
(u) => u.href.startsWith("https://graph.facebook.com/v21.0/oauth/access_token"),
|
|
240
|
+
{ access_token: "fb-at", token_type: "bearer" },
|
|
241
|
+
),
|
|
242
|
+
jsonRoute((u) => u.href.startsWith("https://graph.facebook.com/v21.0/me"), {
|
|
243
|
+
id: "fb-evildoer",
|
|
244
|
+
name: "Evildoer",
|
|
245
|
+
email: victimEmail,
|
|
246
|
+
}),
|
|
247
|
+
],
|
|
248
|
+
calls,
|
|
249
|
+
),
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
const victim = await signUpViaPassword(app, victimEmail);
|
|
253
|
+
|
|
254
|
+
const { state } = await startOAuth(app, "facebook");
|
|
255
|
+
const res = await app.fetch(callbackGet("facebook", state));
|
|
256
|
+
expect(res.status).toBeGreaterThanOrEqual(400);
|
|
257
|
+
const err = await readError(res);
|
|
258
|
+
// Enumeration hygiene: email_in_use, never "email belongs to victim".
|
|
259
|
+
expect(err.reason).toBe("email_in_use");
|
|
260
|
+
|
|
261
|
+
// Victim untouched; attacker gained no credential row.
|
|
262
|
+
expect(identities.byEmail.get(victimEmail)).toBe(victim.userId);
|
|
263
|
+
expect(identities.byProvider.has("oauth:facebook:fb-evildoer")).toBe(false);
|
|
264
|
+
expect(calls.urls.some((u) => u.includes("/me"))).toBe(true);
|
|
265
|
+
|
|
266
|
+
// Victim's original credential still authenticates.
|
|
267
|
+
const relogin = await app.fetch(
|
|
268
|
+
jsonPost("/auth/sign-in/email", { email: victimEmail, password: "CorrectHorse1!" }),
|
|
269
|
+
);
|
|
270
|
+
expect(relogin.status).toBe(200);
|
|
271
|
+
|
|
272
|
+
await app.stop();
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("control: fresh unverified Facebook email provisions normally with emailVerified=false", async () => {
|
|
276
|
+
const { app, identities } = await bootHarness(
|
|
277
|
+
mockFetch([
|
|
278
|
+
jsonRoute((u) => u.href.startsWith("https://graph.facebook.com/v21.0/oauth/access_token"), {
|
|
279
|
+
access_token: "fb-at",
|
|
280
|
+
}),
|
|
281
|
+
jsonRoute((u) => u.href.startsWith("https://graph.facebook.com/v21.0/me"), {
|
|
282
|
+
id: "fb-newbie",
|
|
283
|
+
name: "Newbie",
|
|
284
|
+
email: "fresh-fb@example.com",
|
|
285
|
+
}),
|
|
286
|
+
]),
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
const { state } = await startOAuth(app, "facebook");
|
|
290
|
+
const res = await app.fetch(callbackGet("facebook", state));
|
|
291
|
+
expect(res.status).toBe(200);
|
|
292
|
+
const body = (await res.json()) as { data: { userId: string; accessToken: string } };
|
|
293
|
+
expect(body.data.accessToken).toBeTruthy();
|
|
294
|
+
|
|
295
|
+
const userId = identities.byEmail.get("fresh-fb@example.com");
|
|
296
|
+
expect(userId).toBe(body.data.userId);
|
|
297
|
+
// Locked-decision trust matrix: Facebook attests nothing → unverified.
|
|
298
|
+
expect(identities.users.get(userId!)?.emailVerified).toBe(false);
|
|
299
|
+
|
|
300
|
+
await app.stop();
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test("control: authenticated linking WITH a session claims the existing account", async () => {
|
|
304
|
+
const ownerEmail = "linkme@example.com";
|
|
305
|
+
const { app, identities } = await bootHarness(
|
|
306
|
+
mockFetch([
|
|
307
|
+
jsonRoute((u) => u.href.startsWith("https://graph.facebook.com/v21.0/oauth/access_token"), {
|
|
308
|
+
access_token: "fb-at",
|
|
309
|
+
}),
|
|
310
|
+
jsonRoute((u) => u.href.startsWith("https://graph.facebook.com/v21.0/me"), {
|
|
311
|
+
id: "fb-linker",
|
|
312
|
+
name: "Owner",
|
|
313
|
+
email: ownerEmail,
|
|
314
|
+
}),
|
|
315
|
+
]),
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
const owner = await signUpViaPassword(app, ownerEmail);
|
|
319
|
+
|
|
320
|
+
const linkRes = await app.fetch(
|
|
321
|
+
jsonPost(
|
|
322
|
+
"/auth/oauth/facebook/link",
|
|
323
|
+
{ provider: "facebook" },
|
|
324
|
+
{ authorization: `Bearer ${owner.accessToken}` },
|
|
325
|
+
),
|
|
326
|
+
);
|
|
327
|
+
expect(linkRes.status).toBe(200);
|
|
328
|
+
const linkBody = (await linkRes.json()) as { data: { authorizationUrl: string } };
|
|
329
|
+
const state = new URL(linkBody.data.authorizationUrl).searchParams.get("state");
|
|
330
|
+
expect(state).toBeTruthy();
|
|
331
|
+
|
|
332
|
+
const res = await app.fetch(callbackGet("facebook", state!));
|
|
333
|
+
expect(res.status).toBe(200);
|
|
334
|
+
const body = (await res.json()) as { data: { userId: string } };
|
|
335
|
+
expect(body.data.userId).toBe(owner.userId);
|
|
336
|
+
expect(identities.byProvider.has("oauth:facebook:fb-linker")).toBe(true);
|
|
337
|
+
|
|
338
|
+
await app.stop();
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test("Discord dropping its verified flag yields emailVerified=false at the assertion boundary", async () => {
|
|
342
|
+
const driver = openOAuthDiscord({
|
|
343
|
+
fetch: mockFetch([
|
|
344
|
+
jsonRoute((u) => u.href === "https://discord.com/api/users/@me", {
|
|
345
|
+
id: "dc-42",
|
|
346
|
+
username: "dave",
|
|
347
|
+
email: "dave@example.com",
|
|
348
|
+
// Nhost bug class: `verified` silently absent.
|
|
349
|
+
}),
|
|
350
|
+
]),
|
|
351
|
+
});
|
|
352
|
+
const assertion = await driver.resolveAssertion({
|
|
353
|
+
tokens: { accessToken: "tok", raw: {} },
|
|
354
|
+
expectedIssuer: "https://discord.com",
|
|
355
|
+
expectedAudience: "dc-client",
|
|
356
|
+
});
|
|
357
|
+
expect(assertion.email).toBe("dave@example.com");
|
|
358
|
+
expect(assertion.emailVerified).toBe(false);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
test("GitHub honors an explicit primary verified=true (positive trust control)", async () => {
|
|
362
|
+
const driver = openOAuthGithub({
|
|
363
|
+
fetch: mockFetch([
|
|
364
|
+
jsonRoute((u) => u.href === "https://api.github.com/user", { id: 9001, login: "octocat" }),
|
|
365
|
+
jsonRoute(
|
|
366
|
+
(u) => u.href === "https://api.github.com/user/emails",
|
|
367
|
+
[
|
|
368
|
+
{ email: "shadow@example.com", primary: false, verified: false },
|
|
369
|
+
{ email: "primary@example.com", primary: true, verified: true },
|
|
370
|
+
],
|
|
371
|
+
),
|
|
372
|
+
]),
|
|
373
|
+
});
|
|
374
|
+
const assertion = await driver.resolveAssertion({
|
|
375
|
+
tokens: { accessToken: "tok", raw: {} },
|
|
376
|
+
expectedIssuer: "https://github.com",
|
|
377
|
+
expectedAudience: "gh-client",
|
|
378
|
+
});
|
|
379
|
+
expect(assertion.email).toBe("primary@example.com");
|
|
380
|
+
expect(assertion.emailVerified).toBe(true);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
test("parseEmailVerified collapses every falsy encoding to false (Apple 'false' string)", () => {
|
|
384
|
+
expect(parseEmailVerified(true)).toBe(true);
|
|
385
|
+
expect(parseEmailVerified("true")).toBe(true);
|
|
386
|
+
expect(parseEmailVerified("1")).toBe(true);
|
|
387
|
+
expect(parseEmailVerified("false")).toBe(false);
|
|
388
|
+
expect(parseEmailVerified(false)).toBe(false);
|
|
389
|
+
expect(parseEmailVerified(undefined)).toBe(false);
|
|
390
|
+
expect(parseEmailVerified(null)).toBe(false);
|
|
391
|
+
expect(parseEmailVerified(1)).toBe(false);
|
|
392
|
+
expect(parseEmailVerified("")).toBe(false);
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
/* ------------------------------------------------------------------ */
|
|
397
|
+
/* (b) Mix-up attacks (RFC 9700 §4.4 / RFC 9207) */
|
|
398
|
+
/* ------------------------------------------------------------------ */
|
|
399
|
+
|
|
400
|
+
describe("Mix-up attack defense", () => {
|
|
401
|
+
test("state minted for google is dead on the github callback route", async () => {
|
|
402
|
+
const calls = { urls: [] as string[] };
|
|
403
|
+
const { app, identities } = await bootHarness(
|
|
404
|
+
mockFetch(
|
|
405
|
+
[
|
|
406
|
+
jsonRoute((u) => u.href === "https://github.com/login/oauth/access_token", {
|
|
407
|
+
access_token: "gh-at",
|
|
408
|
+
}),
|
|
409
|
+
jsonRoute((u) => u.href === "https://api.github.com/user", {
|
|
410
|
+
id: 666,
|
|
411
|
+
login: "evil",
|
|
412
|
+
}),
|
|
413
|
+
jsonRoute((u) => u.href === "https://api.github.com/user/emails", []),
|
|
414
|
+
],
|
|
415
|
+
calls,
|
|
416
|
+
),
|
|
417
|
+
);
|
|
418
|
+
|
|
419
|
+
const { state } = await startOAuth(app, "google");
|
|
420
|
+
|
|
421
|
+
const res = await app.fetch(callbackGet("github", state));
|
|
422
|
+
expect(res.status).toBeGreaterThanOrEqual(400);
|
|
423
|
+
expect((await readError(res)).reason).toBe("invalid_state");
|
|
424
|
+
|
|
425
|
+
// Defense layer 1 fired before any provider call — no profile fetches,
|
|
426
|
+
// no identity writes.
|
|
427
|
+
expect(calls.urls.some((u) => u.includes("api.github.com"))).toBe(false);
|
|
428
|
+
expect(identities.byProvider.has("oauth:github:666")).toBe(false);
|
|
429
|
+
|
|
430
|
+
await app.stop();
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
test("OIDC ID token whose iss points at another provider is rejected at assertion", async () => {
|
|
434
|
+
const { token, publicKeyJwk } = await signEs256IdToken({
|
|
435
|
+
iss: "https://appleid.apple.com",
|
|
436
|
+
aud: "g-client",
|
|
437
|
+
sub: "evil-sub",
|
|
438
|
+
exp: Math.floor(Date.now() / 1000) + 300,
|
|
439
|
+
});
|
|
440
|
+
const { app, identities } = await bootHarness(
|
|
441
|
+
mockFetch([
|
|
442
|
+
// Discovery for the google issuer (cached process-wide).
|
|
443
|
+
jsonRoute((u) => u.href.endsWith("/.well-known/openid-configuration"), {
|
|
444
|
+
authorization_endpoint: "https://accounts.google.com/o/oauth2/v2/auth",
|
|
445
|
+
token_endpoint: "https://oauth2.googleapis.com/token",
|
|
446
|
+
jwks_uri: "https://accounts.google.com/oauth2/v3/certs",
|
|
447
|
+
}),
|
|
448
|
+
// Compromised/mixed-up token endpoint hands back the attacker token.
|
|
449
|
+
jsonRoute((u) => u.href === "https://oauth2.googleapis.com/token", {
|
|
450
|
+
access_token: "stolen-at",
|
|
451
|
+
id_token: token,
|
|
452
|
+
expires_in: 3600,
|
|
453
|
+
}),
|
|
454
|
+
// Attacker-held key published under google's JWKS URL: the signature
|
|
455
|
+
// verifies, so rejection MUST come from issuer validation.
|
|
456
|
+
jsonRoute((u) => u.href === "https://accounts.google.com/oauth2/v3/certs", {
|
|
457
|
+
keys: [publicKeyJwk],
|
|
458
|
+
}),
|
|
459
|
+
]),
|
|
460
|
+
);
|
|
461
|
+
|
|
462
|
+
const { state } = await startOAuth(app, "google");
|
|
463
|
+
const res = await app.fetch(callbackGet("google", state));
|
|
464
|
+
|
|
465
|
+
expect(res.status).toBeGreaterThanOrEqual(400);
|
|
466
|
+
expect((await readError(res)).reason).toBe("issuer_mismatch");
|
|
467
|
+
expect(identities.byProvider.has("oauth:google:evil-sub")).toBe(false);
|
|
468
|
+
|
|
469
|
+
await app.stop();
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
test("verifyIdToken: equal iss passes, foreign iss fails (defense is not vacuous)", async () => {
|
|
473
|
+
const good = await signEs256IdToken({
|
|
474
|
+
iss: "https://accounts.google.com",
|
|
475
|
+
aud: "unit-client",
|
|
476
|
+
sub: "real-sub",
|
|
477
|
+
exp: Math.floor(Date.now() / 1000) + 300,
|
|
478
|
+
});
|
|
479
|
+
const evil = await signEs256IdToken({
|
|
480
|
+
iss: "https://appleid.apple.com",
|
|
481
|
+
aud: "unit-client",
|
|
482
|
+
sub: "real-sub",
|
|
483
|
+
exp: Math.floor(Date.now() / 1000) + 300,
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
const claims = await verifyIdToken({
|
|
487
|
+
idToken: good.token,
|
|
488
|
+
jwksUri: "https://unit.test/good/certs",
|
|
489
|
+
expectedIssuer: "https://accounts.google.com",
|
|
490
|
+
expectedAudience: "unit-client",
|
|
491
|
+
fetchFn: mockFetch([jsonRoute(() => true, { keys: [good.publicKeyJwk] })]),
|
|
492
|
+
});
|
|
493
|
+
expect(claims.subject).toBe("real-sub");
|
|
494
|
+
expect(claims.issuer).toBe("https://accounts.google.com");
|
|
495
|
+
|
|
496
|
+
let reason: string | undefined;
|
|
497
|
+
try {
|
|
498
|
+
await verifyIdToken({
|
|
499
|
+
idToken: evil.token,
|
|
500
|
+
jwksUri: "https://unit.test/bad/certs",
|
|
501
|
+
expectedIssuer: "https://accounts.google.com",
|
|
502
|
+
expectedAudience: "unit-client",
|
|
503
|
+
fetchFn: mockFetch([jsonRoute(() => true, { keys: [evil.publicKeyJwk] })]),
|
|
504
|
+
});
|
|
505
|
+
} catch (err) {
|
|
506
|
+
reason = (err as { reason?: string }).reason;
|
|
507
|
+
}
|
|
508
|
+
expect(reason).toBe("issuer_mismatch");
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
test("flow rows are single-use — a consumed state cannot be replayed", async () => {
|
|
512
|
+
const { app } = await bootHarness(
|
|
513
|
+
mockFetch([
|
|
514
|
+
jsonRoute((u) => u.href.startsWith("https://graph.facebook.com/v21.0/oauth/access_token"), {
|
|
515
|
+
access_token: "fb-at",
|
|
516
|
+
}),
|
|
517
|
+
jsonRoute((u) => u.href.startsWith("https://graph.facebook.com/v21.0/me"), {
|
|
518
|
+
id: "fb-replay",
|
|
519
|
+
name: "Replay",
|
|
520
|
+
email: "replay@example.com",
|
|
521
|
+
}),
|
|
522
|
+
]),
|
|
523
|
+
);
|
|
524
|
+
|
|
525
|
+
const { state } = await startOAuth(app, "facebook");
|
|
526
|
+
const first = await app.fetch(callbackGet("facebook", state));
|
|
527
|
+
expect(first.status).toBe(200);
|
|
528
|
+
|
|
529
|
+
const replay = await app.fetch(callbackGet("facebook", state));
|
|
530
|
+
expect(replay.status).toBeGreaterThanOrEqual(400);
|
|
531
|
+
expect((await readError(replay)).reason).toBe("invalid_state");
|
|
532
|
+
|
|
533
|
+
await app.stop();
|
|
534
|
+
});
|
|
535
|
+
});
|