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.
- package/AGENTS.md +5 -3
- package/README.md +14 -10
- package/manifest.v1.schema.json +61 -2
- package/package.json +19 -5
- package/site/content/docs/ai/index.mdx +1 -1
- package/site/content/docs/ai/mcp.mdx +10 -1
- package/site/content/docs/ai/skills.mdx +9 -9
- package/site/content/docs/elements/ai.mdx +1 -1
- package/site/content/docs/elements/clock.mdx +1 -1
- package/site/content/docs/elements/flow.mdx +25 -1
- package/site/content/docs/elements/gate.mdx +3 -2
- package/site/content/docs/elements/store.mdx +289 -341
- package/site/content/docs/elements/vault.mdx +5 -5
- package/site/content/docs/get-started/basic-usage.mdx +3 -10
- package/site/content/docs/get-started/index.mdx +1 -1
- package/site/content/docs/get-started/installation.mdx +2 -3
- package/site/content/docs/get-started/introduction.mdx +58 -121
- package/site/content/docs/get-started/meta.json +9 -1
- package/site/content/docs/get-started/project-structure.mdx +4 -11
- package/site/content/docs/get-started/testing.mdx +328 -0
- package/site/content/docs/get-started/why.mdx +93 -71
- package/site/content/docs/index.mdx +44 -11
- package/site/content/docs/meta.json +8 -5
- 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/providers/index.mdx +2 -0
- package/site/content/docs/recipes/index.mdx +2 -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-De7Lc2JC.js} +1 -1
- package/src/console/ui-next/dist/assets/{flows-page-BiZ4-6yQ.js → flows-page-RGy7VEA_.js} +1 -1
- package/src/console/ui-next/dist/assets/{index-C8NRK2R-.js → index-_rgpdVzo.js} +3 -3
- package/src/console/ui-next/dist/assets/{observability-page-CrB6vd1T.js → observability-page-Ds6pcnh-.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-4rHOePuE.js} +1 -1
- package/src/console/ui-next/dist/assets/{vault-page-C6Xxm9SA.js → vault-page-DISPgxLM.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 +5 -2
- 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,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `facebook` OAuth2 driver — Graph API `GET /me?fields=id,name,email`.
|
|
3
|
+
*
|
|
4
|
+
* Trust: `emailVerified` is **always false** — Facebook provides no
|
|
5
|
+
* verification signal to apps (locked decision 5). Email may be null for
|
|
6
|
+
* phone-only accounts.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
type OAuthAssertion,
|
|
11
|
+
type OAuthAssertionInput,
|
|
12
|
+
type OAuthAuthorizeInput,
|
|
13
|
+
type OAuthAuthorizeResult,
|
|
14
|
+
type OAuthDriver,
|
|
15
|
+
type OAuthExchangeInput,
|
|
16
|
+
type OAuthExchangeResult,
|
|
17
|
+
} from "./oauth-types.ts";
|
|
18
|
+
import { buildAuthorizeQuery } from "./oauth-shared.ts";
|
|
19
|
+
import { fetchProviderJson, resolveOauth2Assertion } from "./oauth2-common.ts";
|
|
20
|
+
import { postTokenRequest } from "./oauth2-token.ts";
|
|
21
|
+
|
|
22
|
+
const AUTHORIZATION_SERVER_ID = "https://www.facebook.com";
|
|
23
|
+
const GRAPH_VERSION = "v21.0";
|
|
24
|
+
|
|
25
|
+
/** Default Facebook scopes. */
|
|
26
|
+
export const FACEBOOK_DEFAULT_SCOPES = ["email", "public_profile"] as const;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Open the Facebook driver.
|
|
30
|
+
*
|
|
31
|
+
* @param options - Optional Graph version / scope override / injectable fetch
|
|
32
|
+
*/
|
|
33
|
+
export function openOAuthFacebook(
|
|
34
|
+
options: {
|
|
35
|
+
readonly graphVersion?: string;
|
|
36
|
+
readonly scopes?: readonly string[];
|
|
37
|
+
readonly fetch?: typeof globalThis.fetch;
|
|
38
|
+
} = {},
|
|
39
|
+
): OAuthDriver {
|
|
40
|
+
const version = options.graphVersion ?? GRAPH_VERSION;
|
|
41
|
+
const fetchFn = options.fetch ?? globalThis.fetch;
|
|
42
|
+
return {
|
|
43
|
+
id: "facebook",
|
|
44
|
+
kind: "oauth2",
|
|
45
|
+
authorizationServerId: AUTHORIZATION_SERVER_ID,
|
|
46
|
+
buildAuthorizeUrl(input: OAuthAuthorizeInput): OAuthAuthorizeResult {
|
|
47
|
+
return {
|
|
48
|
+
url: buildAuthorizeQuery(`https://www.facebook.com/${version}/dialog/oauth`, input),
|
|
49
|
+
};
|
|
50
|
+
},
|
|
51
|
+
async exchangeCode(input: OAuthExchangeInput): Promise<OAuthExchangeResult> {
|
|
52
|
+
return {
|
|
53
|
+
tokens: await postTokenRequest({
|
|
54
|
+
tokenEndpoint: `https://graph.facebook.com/${version}/oauth/access_token`,
|
|
55
|
+
input,
|
|
56
|
+
}),
|
|
57
|
+
};
|
|
58
|
+
},
|
|
59
|
+
async resolveAssertion(input: OAuthAssertionInput): Promise<OAuthAssertion> {
|
|
60
|
+
return resolveOauth2Assertion(input, async (accessToken) => {
|
|
61
|
+
const me = await fetchProviderJson(
|
|
62
|
+
`https://graph.facebook.com/${version}/me?fields=id,name,email`,
|
|
63
|
+
accessToken,
|
|
64
|
+
fetchFn,
|
|
65
|
+
"facebook",
|
|
66
|
+
);
|
|
67
|
+
const subject = typeof me["id"] === "string" ? me["id"] : undefined;
|
|
68
|
+
if (subject === undefined) throw new Error("facebook: profile missing id");
|
|
69
|
+
return {
|
|
70
|
+
providerId: "facebook",
|
|
71
|
+
subject,
|
|
72
|
+
...(me["email"] !== undefined && me["email"] !== null ? { email: me["email"] } : {}),
|
|
73
|
+
// No trustworthy provider signal — always unverified.
|
|
74
|
+
...(typeof me["name"] === "string" ? { name: me["name"] } : {}),
|
|
75
|
+
raw: me,
|
|
76
|
+
};
|
|
77
|
+
});
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `figma` OAuth2 driver — `GET /v1/me`. No verification field exists in the
|
|
3
|
+
* API, so `emailVerified` is always false (locked decision 5). Token endpoint
|
|
4
|
+
* requires HTTP Basic client auth.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
OAuthProtocolError,
|
|
9
|
+
type OAuthAssertion,
|
|
10
|
+
type OAuthAssertionInput,
|
|
11
|
+
type OAuthAuthorizeInput,
|
|
12
|
+
type OAuthAuthorizeResult,
|
|
13
|
+
type OAuthDriver,
|
|
14
|
+
type OAuthExchangeInput,
|
|
15
|
+
type OAuthExchangeResult,
|
|
16
|
+
} from "./oauth-types.ts";
|
|
17
|
+
import { buildAuthorizeQuery } from "./oauth-shared.ts";
|
|
18
|
+
import { fetchProviderJson, resolveOauth2Assertion } from "./oauth2-common.ts";
|
|
19
|
+
|
|
20
|
+
const AUTHORIZATION_SERVER_ID = "https://www.figma.com";
|
|
21
|
+
|
|
22
|
+
/** Default Figma scopes. */
|
|
23
|
+
export const FIGMA_DEFAULT_SCOPES = ["file_read"] as const;
|
|
24
|
+
|
|
25
|
+
function basicAuth(clientId: string, clientSecret: string): string {
|
|
26
|
+
const raw = new TextEncoder().encode(`${clientId}:${clientSecret}`);
|
|
27
|
+
let binary = "";
|
|
28
|
+
for (const b of raw) binary += String.fromCharCode(b);
|
|
29
|
+
return `Basic ${btoa(binary)}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Open the Figma driver.
|
|
34
|
+
*
|
|
35
|
+
* @param options - Optional scope override / injectable fetch
|
|
36
|
+
*/
|
|
37
|
+
export function openOAuthFigma(
|
|
38
|
+
options: { readonly scopes?: readonly string[]; readonly fetch?: typeof globalThis.fetch } = {},
|
|
39
|
+
): OAuthDriver {
|
|
40
|
+
const fetchFn = options.fetch ?? globalThis.fetch;
|
|
41
|
+
return {
|
|
42
|
+
id: "figma",
|
|
43
|
+
kind: "oauth2",
|
|
44
|
+
authorizationServerId: AUTHORIZATION_SERVER_ID,
|
|
45
|
+
buildAuthorizeUrl(input: OAuthAuthorizeInput): OAuthAuthorizeResult {
|
|
46
|
+
return { url: buildAuthorizeQuery("https://www.figma.com/oauth", input) };
|
|
47
|
+
},
|
|
48
|
+
async exchangeCode(input: OAuthExchangeInput): Promise<OAuthExchangeResult> {
|
|
49
|
+
if (!input.clientSecret) {
|
|
50
|
+
throw new OAuthProtocolError("missing_secret", "figma requires a client secret");
|
|
51
|
+
}
|
|
52
|
+
const params = new URLSearchParams({
|
|
53
|
+
grant_type: "authorization_code",
|
|
54
|
+
code: input.code,
|
|
55
|
+
redirect_uri: input.redirectUri,
|
|
56
|
+
client_id: input.clientId,
|
|
57
|
+
code_verifier: input.codeVerifier,
|
|
58
|
+
});
|
|
59
|
+
const res = await fetchFn("https://api.figma.com/v1/oauth/token", {
|
|
60
|
+
method: "POST",
|
|
61
|
+
headers: {
|
|
62
|
+
authorization: basicAuth(input.clientId, input.clientSecret),
|
|
63
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
64
|
+
accept: "application/json",
|
|
65
|
+
},
|
|
66
|
+
body: params.toString(),
|
|
67
|
+
});
|
|
68
|
+
const body = (await res.json().catch(() => ({}))) as Record<string, unknown>;
|
|
69
|
+
if (!res.ok || typeof body["access_token"] !== "string") {
|
|
70
|
+
throw new OAuthProtocolError(
|
|
71
|
+
"exchange_failed",
|
|
72
|
+
`figma token endpoint returned HTTP ${res.status}: ${String(body["err"] ?? body["error"] ?? "unknown")}`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
const expiresIn = body["expires_in"];
|
|
76
|
+
return {
|
|
77
|
+
tokens: {
|
|
78
|
+
accessToken: body["access_token"],
|
|
79
|
+
...(typeof body["refresh_token"] === "string"
|
|
80
|
+
? { refreshToken: body["refresh_token"] as string }
|
|
81
|
+
: {}),
|
|
82
|
+
...(typeof expiresIn === "number"
|
|
83
|
+
? { expiresAt: Math.floor(Date.now() / 1000 + expiresIn * 1000) }
|
|
84
|
+
: {}),
|
|
85
|
+
...(typeof body["scope"] === "string" ? { scopes: [body["scope"] as string] } : {}),
|
|
86
|
+
raw: body,
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
},
|
|
90
|
+
async resolveAssertion(input: OAuthAssertionInput): Promise<OAuthAssertion> {
|
|
91
|
+
return resolveOauth2Assertion(input, async (accessToken) => {
|
|
92
|
+
const me = await fetchProviderJson(
|
|
93
|
+
"https://api.figma.com/v1/me",
|
|
94
|
+
accessToken,
|
|
95
|
+
fetchFn,
|
|
96
|
+
"figma",
|
|
97
|
+
);
|
|
98
|
+
const subject =
|
|
99
|
+
typeof me["id"] === "string"
|
|
100
|
+
? me["id"]
|
|
101
|
+
: typeof me["handle"] === "string"
|
|
102
|
+
? me["handle"]
|
|
103
|
+
: undefined;
|
|
104
|
+
if (subject === undefined) throw new Error("figma: profile missing id");
|
|
105
|
+
return {
|
|
106
|
+
providerId: "figma",
|
|
107
|
+
subject,
|
|
108
|
+
...(me["email"] !== undefined && me["email"] !== null ? { email: me["email"] } : {}),
|
|
109
|
+
// API exposes no verification field — always unverified.
|
|
110
|
+
...(typeof me["handle"] === "string" ? { name: me["handle"] } : {}),
|
|
111
|
+
raw: me,
|
|
112
|
+
};
|
|
113
|
+
});
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `github` OAuth2 driver — `GET /user` plus **`GET /user/emails`** (the
|
|
3
|
+
* `/user` `email` field is often null; the primary verified email requires
|
|
4
|
+
* the `user:email` scope). Trust: `primary.verified === true`.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
type OAuthAssertion,
|
|
9
|
+
type OAuthAssertionInput,
|
|
10
|
+
type OAuthAuthorizeInput,
|
|
11
|
+
type OAuthAuthorizeResult,
|
|
12
|
+
type OAuthDriver,
|
|
13
|
+
type OAuthExchangeInput,
|
|
14
|
+
type OAuthExchangeResult,
|
|
15
|
+
} from "./oauth-types.ts";
|
|
16
|
+
import { buildAuthorizeQuery } from "./oauth-shared.ts";
|
|
17
|
+
import { fetchProviderJson, resolveOauth2Assertion } from "./oauth2-common.ts";
|
|
18
|
+
import { postTokenRequest } from "./oauth2-token.ts";
|
|
19
|
+
|
|
20
|
+
const AUTHORIZATION_SERVER_ID = "https://github.com";
|
|
21
|
+
|
|
22
|
+
/** Default GitHub scopes. */
|
|
23
|
+
export const GITHUB_DEFAULT_SCOPES = ["read:user", "user:email"] as const;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Open the GitHub driver.
|
|
27
|
+
*
|
|
28
|
+
* @param options - Optional scope override / injectable fetch
|
|
29
|
+
*/
|
|
30
|
+
export function openOAuthGithub(
|
|
31
|
+
options: { readonly scopes?: readonly string[]; readonly fetch?: typeof globalThis.fetch } = {},
|
|
32
|
+
): OAuthDriver {
|
|
33
|
+
const fetchFn = options.fetch ?? globalThis.fetch;
|
|
34
|
+
return {
|
|
35
|
+
id: "github",
|
|
36
|
+
kind: "oauth2",
|
|
37
|
+
authorizationServerId: AUTHORIZATION_SERVER_ID,
|
|
38
|
+
buildAuthorizeUrl(input: OAuthAuthorizeInput): OAuthAuthorizeResult {
|
|
39
|
+
// GitHub PKCE is accepted and forwarded even though not OIDC.
|
|
40
|
+
return { url: buildAuthorizeQuery("https://github.com/login/oauth/authorize", input) };
|
|
41
|
+
},
|
|
42
|
+
async exchangeCode(input: OAuthExchangeInput): Promise<OAuthExchangeResult> {
|
|
43
|
+
return {
|
|
44
|
+
tokens: await postTokenRequest({
|
|
45
|
+
tokenEndpoint: "https://github.com/login/oauth/access_token",
|
|
46
|
+
input,
|
|
47
|
+
}),
|
|
48
|
+
};
|
|
49
|
+
},
|
|
50
|
+
async resolveAssertion(input: OAuthAssertionInput): Promise<OAuthAssertion> {
|
|
51
|
+
return resolveOauth2Assertion(input, async (accessToken) => {
|
|
52
|
+
const user = await fetchProviderJson(
|
|
53
|
+
"https://api.github.com/user",
|
|
54
|
+
accessToken,
|
|
55
|
+
fetchFn,
|
|
56
|
+
"github",
|
|
57
|
+
);
|
|
58
|
+
const subject = typeof user["id"] === "number" ? String(user["id"]) : undefined;
|
|
59
|
+
if (subject === undefined) throw new Error("github: profile missing numeric id");
|
|
60
|
+
const login = typeof user["login"] === "string" ? user.login : undefined;
|
|
61
|
+
const emails = await fetchProviderJson(
|
|
62
|
+
"https://api.github.com/user/emails",
|
|
63
|
+
accessToken,
|
|
64
|
+
fetchFn,
|
|
65
|
+
"github",
|
|
66
|
+
).catch(() => undefined);
|
|
67
|
+
const list: readonly unknown[] = Array.isArray(emails) ? emails : [];
|
|
68
|
+
const parsed = list.filter(isGitHubEmail);
|
|
69
|
+
const primary = parsed.find((e) => e.primary === true) ?? parsed[0];
|
|
70
|
+
return {
|
|
71
|
+
providerId: "github",
|
|
72
|
+
subject,
|
|
73
|
+
...(primary?.email !== undefined ? { email: primary.email } : {}),
|
|
74
|
+
// Trust only an explicit verified flag on the selected entry.
|
|
75
|
+
...(primary !== undefined ? { verified: primary.verified } : {}),
|
|
76
|
+
...(login !== undefined ? { name: login } : {}),
|
|
77
|
+
raw: user,
|
|
78
|
+
};
|
|
79
|
+
});
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface GitHubEmail {
|
|
85
|
+
readonly email?: string;
|
|
86
|
+
readonly primary?: boolean;
|
|
87
|
+
readonly verified?: boolean;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function isGitHubEmail(value: unknown): value is GitHubEmail {
|
|
91
|
+
return typeof value === "object" && value !== null;
|
|
92
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `google` OIDC driver — Authorization Code + PKCE against
|
|
3
|
+
* `https://accounts.google.com`. Mix-up anchor: ID token
|
|
4
|
+
* `iss=https://accounts.google.com` (RFC 9207 also honored via the same
|
|
5
|
+
* equality check when Google includes it).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
OAuthProtocolError,
|
|
10
|
+
type OAuthAssertion,
|
|
11
|
+
type OAuthAssertionInput,
|
|
12
|
+
type OAuthAuthorizeInput,
|
|
13
|
+
type OAuthAuthorizeResult,
|
|
14
|
+
type OAuthDriver,
|
|
15
|
+
type OAuthExchangeInput,
|
|
16
|
+
type OAuthExchangeResult,
|
|
17
|
+
} from "./oauth-types.ts";
|
|
18
|
+
import {
|
|
19
|
+
buildAuthorizeQuery,
|
|
20
|
+
formPost,
|
|
21
|
+
normalizeEmail,
|
|
22
|
+
parseEmailVerified,
|
|
23
|
+
} from "./oauth-shared.ts";
|
|
24
|
+
import { discoverOpenId, verifyIdToken } from "./oauth-oidc.ts";
|
|
25
|
+
|
|
26
|
+
const AUTHORIZATION_SERVER_ID = "https://accounts.google.com";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Open the Google driver.
|
|
30
|
+
*
|
|
31
|
+
* @param options - Optional scope override / injectable fetch
|
|
32
|
+
*/
|
|
33
|
+
export function openOAuthGoogle(
|
|
34
|
+
options: { readonly scopes?: readonly string[]; readonly fetch?: typeof globalThis.fetch } = {},
|
|
35
|
+
): OAuthDriver {
|
|
36
|
+
const fetchFn = options.fetch ?? globalThis.fetch;
|
|
37
|
+
return {
|
|
38
|
+
id: "google",
|
|
39
|
+
kind: "oidc",
|
|
40
|
+
authorizationServerId: AUTHORIZATION_SERVER_ID,
|
|
41
|
+
buildAuthorizeUrl(input: OAuthAuthorizeInput): OAuthAuthorizeResult {
|
|
42
|
+
return {
|
|
43
|
+
url: buildAuthorizeQuery(
|
|
44
|
+
`${AUTHORIZATION_SERVER_ID}/o/oauth2/v2/auth`,
|
|
45
|
+
input,
|
|
46
|
+
// Always (re)select accounts — never an untargeted session.
|
|
47
|
+
{ prompt: "select_account" },
|
|
48
|
+
),
|
|
49
|
+
};
|
|
50
|
+
},
|
|
51
|
+
async exchangeCode(input: OAuthExchangeInput): Promise<OAuthExchangeResult> {
|
|
52
|
+
if (!input.clientSecret) {
|
|
53
|
+
throw new OAuthProtocolError("missing_secret", "google requires a client secret");
|
|
54
|
+
}
|
|
55
|
+
const endpoints = await discoverOpenId(AUTHORIZATION_SERVER_ID, fetchFn);
|
|
56
|
+
return exchangeForm(endpoints.tokenEndpoint, input);
|
|
57
|
+
},
|
|
58
|
+
async resolveAssertion(input: OAuthAssertionInput): Promise<OAuthAssertion> {
|
|
59
|
+
if (!input.tokens.idToken) {
|
|
60
|
+
throw new OAuthProtocolError("missing_id_token", "google token response missing id_token");
|
|
61
|
+
}
|
|
62
|
+
const claims = await verifyIdToken({
|
|
63
|
+
idToken: input.tokens.idToken,
|
|
64
|
+
jwksUri: `${AUTHORIZATION_SERVER_ID}/oauth2/v3/certs`,
|
|
65
|
+
expectedIssuer: input.expectedIssuer,
|
|
66
|
+
expectedAudience: input.expectedAudience,
|
|
67
|
+
...(input.expectedNonce !== undefined ? { expectedNonce: input.expectedNonce } : {}),
|
|
68
|
+
fetchFn,
|
|
69
|
+
...(input.now !== undefined ? { now: input.now } : {}),
|
|
70
|
+
});
|
|
71
|
+
const email = normalizeEmail(claims.payload["email"]);
|
|
72
|
+
return {
|
|
73
|
+
subject: claims.subject,
|
|
74
|
+
issuer: claims.issuer,
|
|
75
|
+
...(email !== undefined ? { email } : {}),
|
|
76
|
+
emailVerified: parseEmailVerified(claims.payload["email_verified"]),
|
|
77
|
+
...(typeof claims.payload["name"] === "string"
|
|
78
|
+
? { name: claims.payload["name"] as string }
|
|
79
|
+
: {}),
|
|
80
|
+
raw: claims.payload,
|
|
81
|
+
};
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Shared form POST for OIDC token exchanges (Google). */
|
|
87
|
+
export async function exchangeForm(
|
|
88
|
+
tokenEndpoint: string,
|
|
89
|
+
input: OAuthExchangeInput,
|
|
90
|
+
): Promise<OAuthExchangeResult> {
|
|
91
|
+
const clientSecret =
|
|
92
|
+
input.clientSecretJwt !== undefined ? await input.clientSecretJwt() : input.clientSecret;
|
|
93
|
+
if (!clientSecret) {
|
|
94
|
+
throw new OAuthProtocolError("missing_secret", "token exchange requires a client secret");
|
|
95
|
+
}
|
|
96
|
+
const fetchFn = input.fetch ?? globalThis.fetch;
|
|
97
|
+
const res = await fetchFn(
|
|
98
|
+
tokenEndpoint,
|
|
99
|
+
formPost({
|
|
100
|
+
grant_type: "authorization_code",
|
|
101
|
+
code: input.code,
|
|
102
|
+
redirect_uri: input.redirectUri,
|
|
103
|
+
client_id: input.clientId,
|
|
104
|
+
client_secret: clientSecret,
|
|
105
|
+
code_verifier: input.codeVerifier,
|
|
106
|
+
}),
|
|
107
|
+
);
|
|
108
|
+
const body = (await res.json().catch(() => ({}))) as Record<string, unknown>;
|
|
109
|
+
if (!res.ok || typeof body["access_token"] !== "string") {
|
|
110
|
+
throw new OAuthProtocolError(
|
|
111
|
+
"exchange_failed",
|
|
112
|
+
`Token endpoint returned HTTP ${res.status}: ${String(body["error"] ?? "unknown")}`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
return { tokens: tokensFromResponse(body) };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Normalize a provider token response into an {@link OAuthTokenSet}-shape. */
|
|
119
|
+
export function tokensFromResponse(body: Record<string, unknown>) {
|
|
120
|
+
const expiresIn = body["expires_in"];
|
|
121
|
+
const nowSec = Date.now() / 1000;
|
|
122
|
+
return {
|
|
123
|
+
accessToken: requireAccessToken(body),
|
|
124
|
+
...(typeof body["refresh_token"] === "string"
|
|
125
|
+
? { refreshToken: body["refresh_token"] as string }
|
|
126
|
+
: {}),
|
|
127
|
+
...(typeof expiresIn === "number" ? { expiresAt: Math.floor(nowSec + expiresIn * 1000) } : {}),
|
|
128
|
+
...(typeof body["scope"] === "string"
|
|
129
|
+
? { scopes: (body["scope"] as string).split(/\s+/).filter((s) => s.length > 0) }
|
|
130
|
+
: {}),
|
|
131
|
+
...(typeof body["id_token"] === "string" ? { idToken: body["id_token"] as string } : {}),
|
|
132
|
+
raw: body,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function requireAccessToken(body: Record<string, unknown>): string {
|
|
137
|
+
const token = body["access_token"];
|
|
138
|
+
if (typeof token !== "string" || token.length === 0) {
|
|
139
|
+
throw new OAuthProtocolError("exchange_failed", "Token response missing access_token");
|
|
140
|
+
}
|
|
141
|
+
return token;
|
|
142
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `microsoft` OIDC driver — Entra ID v2.0 endpoints, `tenant` selector
|
|
3
|
+
* (default `common`: personal MSA + work/school).
|
|
4
|
+
*
|
|
5
|
+
* Issuer nuance: discovery under `common` / `organizations` / `consumers`
|
|
6
|
+
* advertises `https://login.microsoftonline.com/{tenantid}/v2.0`, so the flow
|
|
7
|
+
* record stores that template as `expectedIssuer` and the callback validates
|
|
8
|
+
* the concrete token `iss` against the per-tenant regex with a matching
|
|
9
|
+
* GUID-shaped `tid` claim.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
OAuthProtocolError,
|
|
14
|
+
type OAuthAssertion,
|
|
15
|
+
type OAuthAssertionInput,
|
|
16
|
+
type OAuthAuthorizeInput,
|
|
17
|
+
type OAuthAuthorizeResult,
|
|
18
|
+
type OAuthDriver,
|
|
19
|
+
type OAuthExchangeInput,
|
|
20
|
+
type OAuthExchangeResult,
|
|
21
|
+
} from "./oauth-types.ts";
|
|
22
|
+
import { buildAuthorizeQuery, normalizeEmail, parseEmailVerified } from "./oauth-shared.ts";
|
|
23
|
+
import { discoverOpenId, verifyIdToken } from "./oauth-oidc.ts";
|
|
24
|
+
|
|
25
|
+
const LOGIN_ORIGIN = "https://login.microsoftonline.com";
|
|
26
|
+
const TENANT_TEMPLATE_ISSUER = `${LOGIN_ORIGIN}/{tenantid}/v2.0`;
|
|
27
|
+
const CONCRETE_ISSUER_RE = /^https:\/\/login\.microsoftonline\.com\/([0-9a-fA-F-]{36})\/v2\.0$/;
|
|
28
|
+
|
|
29
|
+
/** Microsoft tenant selector or concrete tenant id. */
|
|
30
|
+
export type MicrosoftTenant = "common" | "organizations" | "consumers" | string;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Open the Microsoft driver.
|
|
34
|
+
*
|
|
35
|
+
* @param options - Tenant selector + injectable fetch
|
|
36
|
+
*/
|
|
37
|
+
export function openOAuthMicrosoft(
|
|
38
|
+
options: {
|
|
39
|
+
readonly tenant?: MicrosoftTenant;
|
|
40
|
+
readonly scopes?: readonly string[];
|
|
41
|
+
readonly fetch?: typeof globalThis.fetch;
|
|
42
|
+
} = {},
|
|
43
|
+
): OAuthDriver {
|
|
44
|
+
const tenant = options.tenant ?? "common";
|
|
45
|
+
const discoveryIssuer = `${LOGIN_ORIGIN}/${tenant}/v2.0`;
|
|
46
|
+
const fetchFn = options.fetch ?? globalThis.fetch;
|
|
47
|
+
return {
|
|
48
|
+
id: "microsoft",
|
|
49
|
+
kind: "oidc",
|
|
50
|
+
// Flow records store the {tenantid} template — never the discovery URL.
|
|
51
|
+
authorizationServerId: TENANT_TEMPLATE_ISSUER,
|
|
52
|
+
buildAuthorizeUrl(input: OAuthAuthorizeInput): OAuthAuthorizeResult {
|
|
53
|
+
return {
|
|
54
|
+
url: buildAuthorizeQuery(`${discoveryIssuer}/authorize`, input, {
|
|
55
|
+
response_mode: "query",
|
|
56
|
+
}),
|
|
57
|
+
};
|
|
58
|
+
},
|
|
59
|
+
async exchangeCode(input: OAuthExchangeInput): Promise<OAuthExchangeResult> {
|
|
60
|
+
if (!input.clientSecret) {
|
|
61
|
+
throw new OAuthProtocolError("missing_secret", "microsoft requires a client secret");
|
|
62
|
+
}
|
|
63
|
+
const endpoints = await discoverOpenId(discoveryIssuer, fetchFn);
|
|
64
|
+
const res = await fetchFn(endpoints.tokenEndpoint, {
|
|
65
|
+
method: "POST",
|
|
66
|
+
headers: {
|
|
67
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
68
|
+
accept: "application/json",
|
|
69
|
+
},
|
|
70
|
+
body: new URLSearchParams({
|
|
71
|
+
grant_type: "authorization_code",
|
|
72
|
+
code: input.code,
|
|
73
|
+
redirect_uri: input.redirectUri,
|
|
74
|
+
client_id: input.clientId,
|
|
75
|
+
client_secret: input.clientSecret,
|
|
76
|
+
code_verifier: input.codeVerifier,
|
|
77
|
+
}).toString(),
|
|
78
|
+
});
|
|
79
|
+
const body = (await res.json().catch(() => ({}))) as Record<string, unknown>;
|
|
80
|
+
if (!res.ok || typeof body["access_token"] !== "string") {
|
|
81
|
+
throw new OAuthProtocolError(
|
|
82
|
+
"exchange_failed",
|
|
83
|
+
`Token endpoint returned HTTP ${res.status}: ${String(body["error"] ?? "unknown")}`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
const expiresIn = body["expires_in"];
|
|
87
|
+
return {
|
|
88
|
+
tokens: {
|
|
89
|
+
accessToken: body["access_token"],
|
|
90
|
+
...(typeof body["refresh_token"] === "string"
|
|
91
|
+
? { refreshToken: body["refresh_token"] as string }
|
|
92
|
+
: {}),
|
|
93
|
+
...(typeof expiresIn === "number"
|
|
94
|
+
? { expiresAt: Math.floor(Date.now() / 1000 + expiresIn * 1000) }
|
|
95
|
+
: {}),
|
|
96
|
+
...(typeof body["scope"] === "string" ? { scopes: [body["scope"] as string] } : {}),
|
|
97
|
+
...(typeof body["id_token"] === "string" ? { idToken: body["id_token"] as string } : {}),
|
|
98
|
+
raw: body,
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
},
|
|
102
|
+
async resolveAssertion(input: OAuthAssertionInput): Promise<OAuthAssertion> {
|
|
103
|
+
if (!input.tokens.idToken) {
|
|
104
|
+
throw new OAuthProtocolError(
|
|
105
|
+
"missing_id_token",
|
|
106
|
+
"microsoft token response missing id_token",
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
const claims = await verifyMicrosoftIdToken({
|
|
110
|
+
idToken: input.tokens.idToken,
|
|
111
|
+
expectedAudience: input.expectedAudience,
|
|
112
|
+
...(input.expectedNonce !== undefined ? { expectedNonce: input.expectedNonce } : {}),
|
|
113
|
+
fetchFn,
|
|
114
|
+
...(input.now !== undefined ? { now: input.now } : {}),
|
|
115
|
+
});
|
|
116
|
+
const email =
|
|
117
|
+
normalizeEmail(claims.payload["email"]) ??
|
|
118
|
+
normalizeEmail(claims.payload["preferred_username"]);
|
|
119
|
+
return {
|
|
120
|
+
subject: claims.subject,
|
|
121
|
+
issuer: claims.issuer,
|
|
122
|
+
...(email !== undefined ? { email } : {}),
|
|
123
|
+
emailVerified: parseEmailVerified(claims.payload["email_verified"]),
|
|
124
|
+
...(typeof claims.payload["name"] === "string"
|
|
125
|
+
? { name: claims.payload["name"] as string }
|
|
126
|
+
: {}),
|
|
127
|
+
raw: claims.payload,
|
|
128
|
+
};
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Verify a Microsoft ID token: standard OIDC checks against the concrete
|
|
135
|
+
* per-tenant issuer (`…/{tid}/v2.0`) plus `tid` shape validation — the
|
|
136
|
+
* `{tenantid}` discovery template is never accepted as a literal issuer.
|
|
137
|
+
*
|
|
138
|
+
* @param options - Token + expectations + injectable fetch/clock
|
|
139
|
+
*/
|
|
140
|
+
async function verifyMicrosoftIdToken(options: {
|
|
141
|
+
readonly idToken: string;
|
|
142
|
+
readonly expectedAudience: string;
|
|
143
|
+
readonly expectedNonce?: string;
|
|
144
|
+
readonly fetchFn: typeof globalThis.fetch;
|
|
145
|
+
readonly now?: () => number;
|
|
146
|
+
}): Promise<{ payload: Record<string, unknown>; subject: string; issuer: string }> {
|
|
147
|
+
const parsed = await import("./oauth-shared.ts").then((m) => m.parseJwt(options.idToken));
|
|
148
|
+
const iss = parsed.payload["iss"];
|
|
149
|
+
if (typeof iss !== "string") {
|
|
150
|
+
throw new OAuthProtocolError("issuer_mismatch", "ID token missing iss");
|
|
151
|
+
}
|
|
152
|
+
const match = CONCRETE_ISSUER_RE.exec(iss);
|
|
153
|
+
if (!match) {
|
|
154
|
+
throw new OAuthProtocolError(
|
|
155
|
+
"issuer_mismatch",
|
|
156
|
+
`ID token iss ${iss} is not a concrete login.microsoftonline.com tenant issuer`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
const tid = parsed.payload["tid"];
|
|
160
|
+
if (typeof tid !== "string" || !/^[0-9a-fA-F-]{36}$/.test(tid)) {
|
|
161
|
+
throw new OAuthProtocolError("tenant_mismatch", "ID token tid is not a GUID");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const claims = await verifyIdToken({
|
|
165
|
+
idToken: options.idToken,
|
|
166
|
+
jwksUri: `${iss}/discovery/v2.0/keys`,
|
|
167
|
+
expectedIssuer: iss,
|
|
168
|
+
expectedAudience: options.expectedAudience,
|
|
169
|
+
...(options.expectedNonce !== undefined ? { expectedNonce: options.expectedNonce } : {}),
|
|
170
|
+
fetchFn: options.fetchFn,
|
|
171
|
+
...(options.now !== undefined ? { now: options.now } : {}),
|
|
172
|
+
});
|
|
173
|
+
return claims;
|
|
174
|
+
}
|