@agent-native/core 0.18.1 → 0.19.1
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 +1 -11
- package/dist/a2a/caller-auth.d.ts +1 -0
- package/dist/a2a/caller-auth.d.ts.map +1 -1
- package/dist/a2a/caller-auth.js +1 -1
- package/dist/a2a/caller-auth.js.map +1 -1
- package/dist/a2a/client.d.ts +7 -0
- package/dist/a2a/client.d.ts.map +1 -1
- package/dist/a2a/client.js +3 -0
- package/dist/a2a/client.js.map +1 -1
- package/dist/agent/production-agent.d.ts +1 -1
- package/dist/agent/production-agent.d.ts.map +1 -1
- package/dist/agent/production-agent.js +34 -2
- package/dist/agent/production-agent.js.map +1 -1
- package/dist/cli/code-agent-executor.d.ts.map +1 -1
- package/dist/cli/code-agent-executor.js +47 -256
- package/dist/cli/code-agent-executor.js.map +1 -1
- package/dist/cli/connect.d.ts +94 -0
- package/dist/cli/connect.d.ts.map +1 -0
- package/dist/cli/connect.js +443 -0
- package/dist/cli/connect.js.map +1 -0
- package/dist/cli/index.js +16 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/mcp-config-writers.d.ts +71 -0
- package/dist/cli/mcp-config-writers.d.ts.map +1 -0
- package/dist/cli/mcp-config-writers.js +210 -0
- package/dist/cli/mcp-config-writers.js.map +1 -0
- package/dist/client/AgentPanel.d.ts +3 -1
- package/dist/client/AgentPanel.d.ts.map +1 -1
- package/dist/client/AgentPanel.js +4 -4
- package/dist/client/AgentPanel.js.map +1 -1
- package/dist/client/AssistantChat.d.ts +3 -0
- package/dist/client/AssistantChat.d.ts.map +1 -1
- package/dist/client/AssistantChat.js +22 -66
- package/dist/client/AssistantChat.js.map +1 -1
- package/dist/client/MultiTabAssistantChat.d.ts.map +1 -1
- package/dist/client/MultiTabAssistantChat.js +4 -1
- package/dist/client/MultiTabAssistantChat.js.map +1 -1
- package/dist/client/composer/PromptComposer.d.ts +6 -1
- package/dist/client/composer/PromptComposer.d.ts.map +1 -1
- package/dist/client/composer/PromptComposer.js +5 -4
- package/dist/client/composer/PromptComposer.js.map +1 -1
- package/dist/client/composer/TiptapComposer.d.ts +6 -1
- package/dist/client/composer/TiptapComposer.d.ts.map +1 -1
- package/dist/client/composer/TiptapComposer.js +20 -10
- package/dist/client/composer/TiptapComposer.js.map +1 -1
- package/dist/client/conversation/AgentConversation.d.ts +18 -0
- package/dist/client/conversation/AgentConversation.d.ts.map +1 -0
- package/dist/client/conversation/AgentConversation.js +94 -0
- package/dist/client/conversation/AgentConversation.js.map +1 -0
- package/dist/client/conversation/AgentConversation.spec.d.ts +2 -0
- package/dist/client/conversation/AgentConversation.spec.d.ts.map +1 -0
- package/dist/client/conversation/AgentConversation.spec.js +69 -0
- package/dist/client/conversation/AgentConversation.spec.js.map +1 -0
- package/dist/client/conversation/index.d.ts +4 -0
- package/dist/client/conversation/index.d.ts.map +1 -0
- package/dist/client/conversation/index.js +3 -0
- package/dist/client/conversation/index.js.map +1 -0
- package/dist/client/conversation/types.d.ts +54 -0
- package/dist/client/conversation/types.d.ts.map +1 -0
- package/dist/client/conversation/types.js +2 -0
- package/dist/client/conversation/types.js.map +1 -0
- package/dist/client/conversation/use-near-bottom-autoscroll.d.ts +15 -0
- package/dist/client/conversation/use-near-bottom-autoscroll.d.ts.map +1 -0
- package/dist/client/conversation/use-near-bottom-autoscroll.js +66 -0
- package/dist/client/conversation/use-near-bottom-autoscroll.js.map +1 -0
- package/dist/client/dynamic-suggestions.d.ts +43 -0
- package/dist/client/dynamic-suggestions.d.ts.map +1 -0
- package/dist/client/dynamic-suggestions.js +344 -0
- package/dist/client/dynamic-suggestions.js.map +1 -0
- package/dist/client/index.d.ts +2 -0
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +2 -0
- package/dist/client/index.js.map +1 -1
- package/dist/client/resources/ResourceTree.d.ts.map +1 -1
- package/dist/client/resources/ResourceTree.js +2 -2
- package/dist/client/resources/ResourceTree.js.map +1 -1
- package/dist/client/resources/ResourcesPanel.d.ts.map +1 -1
- package/dist/client/resources/ResourcesPanel.js +4 -28
- package/dist/client/resources/ResourcesPanel.js.map +1 -1
- package/dist/client/settings/SettingsPanel.js +2 -2
- package/dist/client/settings/SettingsPanel.js.map +1 -1
- package/dist/code-agents/index.d.ts +1 -0
- package/dist/code-agents/index.d.ts.map +1 -1
- package/dist/code-agents/index.js +1 -0
- package/dist/code-agents/index.js.map +1 -1
- package/dist/code-agents/transcript-normalizer.d.ts +50 -0
- package/dist/code-agents/transcript-normalizer.d.ts.map +1 -0
- package/dist/code-agents/transcript-normalizer.js +356 -0
- package/dist/code-agents/transcript-normalizer.js.map +1 -0
- package/dist/coding-tools/index.d.ts +31 -0
- package/dist/coding-tools/index.d.ts.map +1 -0
- package/dist/coding-tools/index.js +411 -0
- package/dist/coding-tools/index.js.map +1 -0
- package/dist/extensions/schema.d.ts +1 -1
- package/dist/mcp/build-server.d.ts.map +1 -1
- package/dist/mcp/build-server.js +30 -0
- package/dist/mcp/build-server.js.map +1 -1
- package/dist/mcp/builtin-tools.d.ts.map +1 -1
- package/dist/mcp/builtin-tools.js +85 -26
- package/dist/mcp/builtin-tools.js.map +1 -1
- package/dist/mcp/connect-route.d.ts +43 -0
- package/dist/mcp/connect-route.d.ts.map +1 -0
- package/dist/mcp/connect-route.js +744 -0
- package/dist/mcp/connect-route.js.map +1 -0
- package/dist/mcp/connect-store.d.ts +132 -0
- package/dist/mcp/connect-store.d.ts.map +1 -0
- package/dist/mcp/connect-store.js +434 -0
- package/dist/mcp/connect-store.js.map +1 -0
- package/dist/mcp/org-directory.d.ts +83 -0
- package/dist/mcp/org-directory.d.ts.map +1 -0
- package/dist/mcp/org-directory.js +201 -0
- package/dist/mcp/org-directory.js.map +1 -0
- package/dist/mcp/server.d.ts +38 -1
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +208 -77
- package/dist/mcp/server.js.map +1 -1
- package/dist/scripts/dev/index.d.ts +6 -4
- package/dist/scripts/dev/index.d.ts.map +1 -1
- package/dist/scripts/dev/index.js +28 -13
- package/dist/scripts/dev/index.js.map +1 -1
- package/dist/server/agent-chat-plugin.d.ts +6 -6
- package/dist/server/agent-chat-plugin.d.ts.map +1 -1
- package/dist/server/agent-chat-plugin.js +32 -32
- package/dist/server/agent-chat-plugin.js.map +1 -1
- package/dist/server/agent-teams.js +2 -2
- package/dist/server/agent-teams.js.map +1 -1
- package/dist/server/agents-bundle.d.ts +3 -3
- package/dist/server/agents-bundle.js +5 -5
- package/dist/server/agents-bundle.js.map +1 -1
- package/dist/server/auth.d.ts +17 -0
- package/dist/server/auth.d.ts.map +1 -1
- package/dist/server/auth.js +149 -33
- package/dist/server/auth.js.map +1 -1
- package/dist/server/better-auth-instance.d.ts +43 -0
- package/dist/server/better-auth-instance.d.ts.map +1 -1
- package/dist/server/better-auth-instance.js +25 -0
- package/dist/server/better-auth-instance.js.map +1 -1
- package/dist/server/core-routes-plugin.d.ts +12 -0
- package/dist/server/core-routes-plugin.d.ts.map +1 -1
- package/dist/server/core-routes-plugin.js +42 -0
- package/dist/server/core-routes-plugin.js.map +1 -1
- package/dist/server/identity-sso-store.d.ts +86 -0
- package/dist/server/identity-sso-store.d.ts.map +1 -0
- package/dist/server/identity-sso-store.js +243 -0
- package/dist/server/identity-sso-store.js.map +1 -0
- package/dist/server/identity-sso.d.ts +78 -0
- package/dist/server/identity-sso.d.ts.map +1 -0
- package/dist/server/identity-sso.js +425 -0
- package/dist/server/identity-sso.js.map +1 -0
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +1 -0
- package/dist/server/index.js.map +1 -1
- package/dist/server/onboarding-html.d.ts.map +1 -1
- package/dist/server/onboarding-html.js +2 -1
- package/dist/server/onboarding-html.js.map +1 -1
- package/dist/server/sentry.d.ts.map +1 -1
- package/dist/server/sentry.js +17 -2
- package/dist/server/sentry.js.map +1 -1
- package/dist/sharing/schema.d.ts +1 -1
- package/docs/content/client.md +15 -0
- package/docs/content/code-agents-ui.md +25 -4
- package/docs/content/cross-app-sso.md +118 -0
- package/docs/content/drop-in-agent.md +3 -1
- package/docs/content/external-agents.md +130 -51
- package/docs/content/frames.md +1 -1
- package/docs/content/migration-workbench.md +6 -1
- package/package.json +2 -1
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Framework-table store for the cross-app SSO ("Sign in with Agent-Native")
|
|
3
|
+
* CLIENT side. Backs two pieces of the federated login round-trip:
|
|
4
|
+
*
|
|
5
|
+
* - `identity_sso_state` — short-lived (10 min), single-use, crypto-random
|
|
6
|
+
* CSRF `state` values. Minted at `/_agent-native/identity/login`,
|
|
7
|
+
* consumed exactly once at `/_agent-native/identity/callback`. Carries an
|
|
8
|
+
* optional same-origin `return` path so the user lands back where they
|
|
9
|
+
* started after federated sign-in.
|
|
10
|
+
* - `identity_sso_jti` — replayed-token guard. The hub-issued identity JWT
|
|
11
|
+
* carries a random `jti`; the first callback that verifies a given `jti`
|
|
12
|
+
* records it here, and any later callback that presents the same `jti`
|
|
13
|
+
* is rejected. Best-effort: a DB blip never widens the trust boundary
|
|
14
|
+
* (signature + exp + scope + single-use state are still enforced), it
|
|
15
|
+
* only relaxes the extra replay gate.
|
|
16
|
+
*
|
|
17
|
+
* Mirrors `mcp/connect-store.ts`: lazy `ensureTable()`, `getDbExec()`,
|
|
18
|
+
* dialect-agnostic SQL via `intType()`, `isConnectionError()` swallow so a
|
|
19
|
+
* transient Neon WS drop never 500s. `CREATE TABLE IF NOT EXISTS` only —
|
|
20
|
+
* strictly additive, never DROP / ALTER (shared prod DB rule).
|
|
21
|
+
*
|
|
22
|
+
* Node-only (crypto), bundled alongside the other framework auth modules.
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* Read + normalise `AGENT_NATIVE_IDENTITY_HUB_URL`. Returns `undefined`
|
|
26
|
+
* (feature OFF) unless it is set to a syntactically valid http(s) URL. A
|
|
27
|
+
* malformed value is treated as OFF rather than throwing, so a typo can
|
|
28
|
+
* never brick an app's login — it just behaves as if SSO were unconfigured.
|
|
29
|
+
*/
|
|
30
|
+
export declare function getIdentityHubUrl(): string | undefined;
|
|
31
|
+
/**
|
|
32
|
+
* Whether the federated-SSO client is active. When false, NOTHING in the
|
|
33
|
+
* SSO module has any effect: the route 404s, the guard bypass is inert, the
|
|
34
|
+
* login button is not rendered. This is the single switch the
|
|
35
|
+
* env-unset-no-op invariant is asserted against.
|
|
36
|
+
*/
|
|
37
|
+
export declare function isIdentitySsoEnabled(): boolean;
|
|
38
|
+
/**
|
|
39
|
+
* The conditional "Sign in with Agent-Native" entry injected into the login
|
|
40
|
+
* page — ONLY when the feature is enabled. Returns an empty string when
|
|
41
|
+
* disabled so the login HTML is byte-for-byte identical to today's output
|
|
42
|
+
* with the env unset (asserted by the env-unset-no-op regression test). Pure
|
|
43
|
+
* string builder, no I/O — safe to call during HTML render. Lives in this
|
|
44
|
+
* leaf module so `onboarding-html.ts` can import it without creating an
|
|
45
|
+
* `auth.ts` ↔ `identity-sso.ts` import cycle.
|
|
46
|
+
*/
|
|
47
|
+
export declare function identitySsoLoginButtonHtml(): string;
|
|
48
|
+
/** CSRF state values are valid for 10 minutes. */
|
|
49
|
+
export declare const SSO_STATE_TTL_MS: number;
|
|
50
|
+
/**
|
|
51
|
+
* Rate limit for `identity/login`: at most this many state rows may be
|
|
52
|
+
* created within `SSO_LOGIN_WINDOW_MS`. The endpoint is reachable without a
|
|
53
|
+
* session (it's the entry point), so keep a coarse global cap to stop table
|
|
54
|
+
* flooding without per-IP plumbing.
|
|
55
|
+
*/
|
|
56
|
+
export declare const SSO_LOGIN_MAX = 60;
|
|
57
|
+
export declare const SSO_LOGIN_WINDOW_MS = 60000;
|
|
58
|
+
/**
|
|
59
|
+
* Mint a fresh crypto-random `state` value, persist it with an optional
|
|
60
|
+
* same-origin return path, and return it. Rate-limited at creation: at most
|
|
61
|
+
* `SSO_LOGIN_MAX` rows within `SSO_LOGIN_WINDOW_MS`. Throws `RATE_LIMITED`
|
|
62
|
+
* when the cap is exceeded so the route can map it to a 429.
|
|
63
|
+
*/
|
|
64
|
+
export declare function createSsoState(returnPath: string | null): Promise<string>;
|
|
65
|
+
export interface SsoStateConsumeResult {
|
|
66
|
+
ok: boolean;
|
|
67
|
+
returnPath: string | null;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Atomically consume a `state` value. Returns `{ ok: true, returnPath }` only
|
|
71
|
+
* when the state existed, had not expired, and had not been consumed before —
|
|
72
|
+
* and this call is the one that transitioned it to consumed (single-use,
|
|
73
|
+
* enforced via a conditional UPDATE so a double callback can't both pass).
|
|
74
|
+
* Any other condition returns `{ ok: false }`.
|
|
75
|
+
*/
|
|
76
|
+
export declare function consumeSsoState(state: string): Promise<SsoStateConsumeResult>;
|
|
77
|
+
/**
|
|
78
|
+
* Returns true when the given identity-token `jti` has already been seen
|
|
79
|
+
* (i.e. this is a replay). On the first sighting, records the `jti` and
|
|
80
|
+
* returns false. Best-effort: a store/DB error returns `false` (not a
|
|
81
|
+
* replay) so a transient Neon WS drop never blocks a legitimate first-time
|
|
82
|
+
* sign-in — signature + exp + scope + single-use CSRF state remain the hard
|
|
83
|
+
* gates; this only adds defence in depth against token replay.
|
|
84
|
+
*/
|
|
85
|
+
export declare function isJtiReplayed(jti: string | undefined): Promise<boolean>;
|
|
86
|
+
//# sourceMappingURL=identity-sso-store.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"identity-sso-store.d.ts","sourceRoot":"","sources":["../../src/server/identity-sso-store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAeH;;;;;GAKG;AACH,wBAAgB,iBAAiB,IAAI,MAAM,GAAG,SAAS,CAUtD;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,IAAI,OAAO,CAE9C;AAED;;;;;;;;GAQG;AACH,wBAAgB,0BAA0B,IAAI,MAAM,CAWnD;AAED,kDAAkD;AAClD,eAAO,MAAM,gBAAgB,QAAc,CAAC;AAE5C;;;;;GAKG;AACH,eAAO,MAAM,aAAa,KAAK,CAAC;AAChC,eAAO,MAAM,mBAAmB,QAAS,CAAC;AA0C1C;;;;;GAKG;AACH,wBAAsB,cAAc,CAClC,UAAU,EAAE,MAAM,GAAG,IAAI,GACxB,OAAO,CAAC,MAAM,CAAC,CA2BjB;AAED,MAAM,WAAW,qBAAqB;IACpC,EAAE,EAAE,OAAO,CAAC;IACZ,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3B;AAED;;;;;;GAMG;AACH,wBAAsB,eAAe,CACnC,KAAK,EAAE,MAAM,GACZ,OAAO,CAAC,qBAAqB,CAAC,CAgChC;AAMD;;;;;;;GAOG;AACH,wBAAsB,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,CAAC,OAAO,CAAC,CA0B7E"}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Framework-table store for the cross-app SSO ("Sign in with Agent-Native")
|
|
3
|
+
* CLIENT side. Backs two pieces of the federated login round-trip:
|
|
4
|
+
*
|
|
5
|
+
* - `identity_sso_state` — short-lived (10 min), single-use, crypto-random
|
|
6
|
+
* CSRF `state` values. Minted at `/_agent-native/identity/login`,
|
|
7
|
+
* consumed exactly once at `/_agent-native/identity/callback`. Carries an
|
|
8
|
+
* optional same-origin `return` path so the user lands back where they
|
|
9
|
+
* started after federated sign-in.
|
|
10
|
+
* - `identity_sso_jti` — replayed-token guard. The hub-issued identity JWT
|
|
11
|
+
* carries a random `jti`; the first callback that verifies a given `jti`
|
|
12
|
+
* records it here, and any later callback that presents the same `jti`
|
|
13
|
+
* is rejected. Best-effort: a DB blip never widens the trust boundary
|
|
14
|
+
* (signature + exp + scope + single-use state are still enforced), it
|
|
15
|
+
* only relaxes the extra replay gate.
|
|
16
|
+
*
|
|
17
|
+
* Mirrors `mcp/connect-store.ts`: lazy `ensureTable()`, `getDbExec()`,
|
|
18
|
+
* dialect-agnostic SQL via `intType()`, `isConnectionError()` swallow so a
|
|
19
|
+
* transient Neon WS drop never 500s. `CREATE TABLE IF NOT EXISTS` only —
|
|
20
|
+
* strictly additive, never DROP / ALTER (shared prod DB rule).
|
|
21
|
+
*
|
|
22
|
+
* Node-only (crypto), bundled alongside the other framework auth modules.
|
|
23
|
+
*/
|
|
24
|
+
import { getDbExec, isConnectionError, intType } from "../db/client.js";
|
|
25
|
+
import { randomBytes } from "node:crypto";
|
|
26
|
+
let _initPromise;
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Feature switch — the SINGLE source of truth for whether the federated-SSO
|
|
29
|
+
// client is active. Lives here (a leaf module with no dependency on auth.ts)
|
|
30
|
+
// so BOTH the auth guard and the route handler import the identical
|
|
31
|
+
// validator and can never drift. Pure env read, no I/O — safe on the guard
|
|
32
|
+
// hot path.
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
/**
|
|
35
|
+
* Read + normalise `AGENT_NATIVE_IDENTITY_HUB_URL`. Returns `undefined`
|
|
36
|
+
* (feature OFF) unless it is set to a syntactically valid http(s) URL. A
|
|
37
|
+
* malformed value is treated as OFF rather than throwing, so a typo can
|
|
38
|
+
* never brick an app's login — it just behaves as if SSO were unconfigured.
|
|
39
|
+
*/
|
|
40
|
+
export function getIdentityHubUrl() {
|
|
41
|
+
const raw = process.env.AGENT_NATIVE_IDENTITY_HUB_URL?.trim();
|
|
42
|
+
if (!raw)
|
|
43
|
+
return undefined;
|
|
44
|
+
try {
|
|
45
|
+
const u = new URL(raw);
|
|
46
|
+
if (u.protocol !== "https:" && u.protocol !== "http:")
|
|
47
|
+
return undefined;
|
|
48
|
+
return `${u.protocol}//${u.host}${u.pathname}`.replace(/\/+$/, "");
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Whether the federated-SSO client is active. When false, NOTHING in the
|
|
56
|
+
* SSO module has any effect: the route 404s, the guard bypass is inert, the
|
|
57
|
+
* login button is not rendered. This is the single switch the
|
|
58
|
+
* env-unset-no-op invariant is asserted against.
|
|
59
|
+
*/
|
|
60
|
+
export function isIdentitySsoEnabled() {
|
|
61
|
+
return !!getIdentityHubUrl();
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The conditional "Sign in with Agent-Native" entry injected into the login
|
|
65
|
+
* page — ONLY when the feature is enabled. Returns an empty string when
|
|
66
|
+
* disabled so the login HTML is byte-for-byte identical to today's output
|
|
67
|
+
* with the env unset (asserted by the env-unset-no-op regression test). Pure
|
|
68
|
+
* string builder, no I/O — safe to call during HTML render. Lives in this
|
|
69
|
+
* leaf module so `onboarding-html.ts` can import it without creating an
|
|
70
|
+
* `auth.ts` ↔ `identity-sso.ts` import cycle.
|
|
71
|
+
*/
|
|
72
|
+
export function identitySsoLoginButtonHtml() {
|
|
73
|
+
if (!isIdentitySsoEnabled())
|
|
74
|
+
return "";
|
|
75
|
+
return (`\n <a class="btn-identity-sso" id="identity-sso-btn" ` +
|
|
76
|
+
`href="/_agent-native/identity/login" ` +
|
|
77
|
+
`style="display:flex;align-items:center;justify-content:center;gap:0.5rem;` +
|
|
78
|
+
`width:100%;padding:0.7rem 1rem;margin-bottom:0.75rem;border-radius:8px;` +
|
|
79
|
+
`border:1px solid rgba(255,255,255,0.18);background:transparent;` +
|
|
80
|
+
`color:inherit;font:inherit;font-weight:600;text-decoration:none;` +
|
|
81
|
+
`cursor:pointer">Sign in with Agent-Native</a>\n`);
|
|
82
|
+
}
|
|
83
|
+
/** CSRF state values are valid for 10 minutes. */
|
|
84
|
+
export const SSO_STATE_TTL_MS = 10 * 60_000;
|
|
85
|
+
/**
|
|
86
|
+
* Rate limit for `identity/login`: at most this many state rows may be
|
|
87
|
+
* created within `SSO_LOGIN_WINDOW_MS`. The endpoint is reachable without a
|
|
88
|
+
* session (it's the entry point), so keep a coarse global cap to stop table
|
|
89
|
+
* flooding without per-IP plumbing.
|
|
90
|
+
*/
|
|
91
|
+
export const SSO_LOGIN_MAX = 60;
|
|
92
|
+
export const SSO_LOGIN_WINDOW_MS = 60_000;
|
|
93
|
+
async function ensureTable() {
|
|
94
|
+
if (!_initPromise) {
|
|
95
|
+
_initPromise = (async () => {
|
|
96
|
+
const client = getDbExec();
|
|
97
|
+
// Additive only. Never DROP / ALTER — this DB is shared across every
|
|
98
|
+
// deploy context (preview/branch/prod) for hosted templates.
|
|
99
|
+
await client.execute(`
|
|
100
|
+
CREATE TABLE IF NOT EXISTS identity_sso_state (
|
|
101
|
+
state TEXT PRIMARY KEY,
|
|
102
|
+
return_path TEXT,
|
|
103
|
+
created_at ${intType()},
|
|
104
|
+
expires_at ${intType()},
|
|
105
|
+
consumed_at ${intType()}
|
|
106
|
+
)
|
|
107
|
+
`);
|
|
108
|
+
await client.execute(`
|
|
109
|
+
CREATE TABLE IF NOT EXISTS identity_sso_jti (
|
|
110
|
+
jti TEXT PRIMARY KEY,
|
|
111
|
+
seen_at ${intType()}
|
|
112
|
+
)
|
|
113
|
+
`);
|
|
114
|
+
})().catch((err) => {
|
|
115
|
+
// Don't cache a rejection — let the next caller retry a fresh init.
|
|
116
|
+
_initPromise = undefined;
|
|
117
|
+
throw err;
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
return _initPromise;
|
|
121
|
+
}
|
|
122
|
+
function numOrNull(v) {
|
|
123
|
+
if (v == null)
|
|
124
|
+
return null;
|
|
125
|
+
const n = Number(v);
|
|
126
|
+
return Number.isFinite(n) ? n : null;
|
|
127
|
+
}
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// CSRF state
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
/**
|
|
132
|
+
* Mint a fresh crypto-random `state` value, persist it with an optional
|
|
133
|
+
* same-origin return path, and return it. Rate-limited at creation: at most
|
|
134
|
+
* `SSO_LOGIN_MAX` rows within `SSO_LOGIN_WINDOW_MS`. Throws `RATE_LIMITED`
|
|
135
|
+
* when the cap is exceeded so the route can map it to a 429.
|
|
136
|
+
*/
|
|
137
|
+
export async function createSsoState(returnPath) {
|
|
138
|
+
await ensureTable();
|
|
139
|
+
const client = getDbExec();
|
|
140
|
+
const now = Date.now();
|
|
141
|
+
try {
|
|
142
|
+
const { rows } = await client.execute({
|
|
143
|
+
sql: `SELECT COUNT(*) AS n FROM identity_sso_state WHERE created_at > ?`,
|
|
144
|
+
args: [now - SSO_LOGIN_WINDOW_MS],
|
|
145
|
+
});
|
|
146
|
+
const n = Number(rows[0]?.n ?? rows[0]?.["COUNT(*)"] ?? 0);
|
|
147
|
+
if (Number.isFinite(n) && n >= SSO_LOGIN_MAX) {
|
|
148
|
+
throw new Error("RATE_LIMITED");
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
if (err?.message === "RATE_LIMITED")
|
|
153
|
+
throw err;
|
|
154
|
+
// A read failure must not block legitimate logins — single-use +
|
|
155
|
+
// short-TTL state is the primary protection. Continue.
|
|
156
|
+
}
|
|
157
|
+
const state = randomBytes(32).toString("base64url");
|
|
158
|
+
const expiresAt = now + SSO_STATE_TTL_MS;
|
|
159
|
+
await client.execute({
|
|
160
|
+
sql: `INSERT INTO identity_sso_state (state, return_path, created_at, expires_at, consumed_at) VALUES (?, ?, ?, ?, ?)`,
|
|
161
|
+
args: [state, returnPath ?? null, now, expiresAt, null],
|
|
162
|
+
});
|
|
163
|
+
return state;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Atomically consume a `state` value. Returns `{ ok: true, returnPath }` only
|
|
167
|
+
* when the state existed, had not expired, and had not been consumed before —
|
|
168
|
+
* and this call is the one that transitioned it to consumed (single-use,
|
|
169
|
+
* enforced via a conditional UPDATE so a double callback can't both pass).
|
|
170
|
+
* Any other condition returns `{ ok: false }`.
|
|
171
|
+
*/
|
|
172
|
+
export async function consumeSsoState(state) {
|
|
173
|
+
if (!state)
|
|
174
|
+
return { ok: false, returnPath: null };
|
|
175
|
+
await ensureTable();
|
|
176
|
+
const client = getDbExec();
|
|
177
|
+
const now = Date.now();
|
|
178
|
+
const { rows } = await client.execute({
|
|
179
|
+
sql: `SELECT state, return_path, expires_at, consumed_at FROM identity_sso_state WHERE state = ?`,
|
|
180
|
+
args: [state],
|
|
181
|
+
});
|
|
182
|
+
if (rows.length === 0)
|
|
183
|
+
return { ok: false, returnPath: null };
|
|
184
|
+
const row = rows[0];
|
|
185
|
+
const expiresAt = numOrNull(row.expires_at ?? row.expiresAt);
|
|
186
|
+
const consumedAt = numOrNull(row.consumed_at ?? row.consumedAt);
|
|
187
|
+
if (consumedAt != null)
|
|
188
|
+
return { ok: false, returnPath: null };
|
|
189
|
+
if (expiresAt != null && expiresAt < now) {
|
|
190
|
+
return { ok: false, returnPath: null };
|
|
191
|
+
}
|
|
192
|
+
// Single-use: only the caller that flips `consumed_at` from NULL wins.
|
|
193
|
+
const result = await client.execute({
|
|
194
|
+
sql: `UPDATE identity_sso_state SET consumed_at = ? WHERE state = ? AND consumed_at IS NULL`,
|
|
195
|
+
args: [now, state],
|
|
196
|
+
});
|
|
197
|
+
if (result.rowsAffected === 0) {
|
|
198
|
+
// Lost the race to a concurrent callback — treat as already consumed.
|
|
199
|
+
return { ok: false, returnPath: null };
|
|
200
|
+
}
|
|
201
|
+
const returnPath = (row.return_path ?? row.returnPath ?? null);
|
|
202
|
+
return { ok: true, returnPath };
|
|
203
|
+
}
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
// Replay (jti) guard
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
/**
|
|
208
|
+
* Returns true when the given identity-token `jti` has already been seen
|
|
209
|
+
* (i.e. this is a replay). On the first sighting, records the `jti` and
|
|
210
|
+
* returns false. Best-effort: a store/DB error returns `false` (not a
|
|
211
|
+
* replay) so a transient Neon WS drop never blocks a legitimate first-time
|
|
212
|
+
* sign-in — signature + exp + scope + single-use CSRF state remain the hard
|
|
213
|
+
* gates; this only adds defence in depth against token replay.
|
|
214
|
+
*/
|
|
215
|
+
export async function isJtiReplayed(jti) {
|
|
216
|
+
if (!jti)
|
|
217
|
+
return false;
|
|
218
|
+
try {
|
|
219
|
+
await ensureTable();
|
|
220
|
+
const client = getDbExec();
|
|
221
|
+
const result = await client.execute({
|
|
222
|
+
sql: `INSERT INTO identity_sso_jti (jti, seen_at) VALUES (?, ?)`,
|
|
223
|
+
args: [jti, Date.now()],
|
|
224
|
+
});
|
|
225
|
+
// A successful insert means this jti was never seen → not a replay.
|
|
226
|
+
return result.rowsAffected === 0;
|
|
227
|
+
}
|
|
228
|
+
catch (err) {
|
|
229
|
+
// Primary-key conflict = the jti already exists = replay.
|
|
230
|
+
const msg = String(err?.message ?? "").toLowerCase();
|
|
231
|
+
if (msg.includes("unique") ||
|
|
232
|
+
msg.includes("duplicate") ||
|
|
233
|
+
msg.includes("constraint")) {
|
|
234
|
+
return true;
|
|
235
|
+
}
|
|
236
|
+
// Any other error (incl. connection blips): fail open — do not block a
|
|
237
|
+
// legitimate first sign-in over a transient DB issue.
|
|
238
|
+
if (isConnectionError(err))
|
|
239
|
+
return false;
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
//# sourceMappingURL=identity-sso-store.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"identity-sso-store.js","sourceRoot":"","sources":["../../src/server/identity-sso-store.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Framework-table store for the cross-app SSO (\"Sign in with Agent-Native\")\n * CLIENT side. Backs two pieces of the federated login round-trip:\n *\n * - `identity_sso_state` — short-lived (10 min), single-use, crypto-random\n * CSRF `state` values. Minted at `/_agent-native/identity/login`,\n * consumed exactly once at `/_agent-native/identity/callback`. Carries an\n * optional same-origin `return` path so the user lands back where they\n * started after federated sign-in.\n * - `identity_sso_jti` — replayed-token guard. The hub-issued identity JWT\n * carries a random `jti`; the first callback that verifies a given `jti`\n * records it here, and any later callback that presents the same `jti`\n * is rejected. Best-effort: a DB blip never widens the trust boundary\n * (signature + exp + scope + single-use state are still enforced), it\n * only relaxes the extra replay gate.\n *\n * Mirrors `mcp/connect-store.ts`: lazy `ensureTable()`, `getDbExec()`,\n * dialect-agnostic SQL via `intType()`, `isConnectionError()` swallow so a\n * transient Neon WS drop never 500s. `CREATE TABLE IF NOT EXISTS` only —\n * strictly additive, never DROP / ALTER (shared prod DB rule).\n *\n * Node-only (crypto), bundled alongside the other framework auth modules.\n */\n\nimport { getDbExec, isConnectionError, intType } from \"../db/client.js\";\nimport { randomBytes } from \"node:crypto\";\n\nlet _initPromise: Promise<void> | undefined;\n\n// ---------------------------------------------------------------------------\n// Feature switch — the SINGLE source of truth for whether the federated-SSO\n// client is active. Lives here (a leaf module with no dependency on auth.ts)\n// so BOTH the auth guard and the route handler import the identical\n// validator and can never drift. Pure env read, no I/O — safe on the guard\n// hot path.\n// ---------------------------------------------------------------------------\n\n/**\n * Read + normalise `AGENT_NATIVE_IDENTITY_HUB_URL`. Returns `undefined`\n * (feature OFF) unless it is set to a syntactically valid http(s) URL. A\n * malformed value is treated as OFF rather than throwing, so a typo can\n * never brick an app's login — it just behaves as if SSO were unconfigured.\n */\nexport function getIdentityHubUrl(): string | undefined {\n const raw = process.env.AGENT_NATIVE_IDENTITY_HUB_URL?.trim();\n if (!raw) return undefined;\n try {\n const u = new URL(raw);\n if (u.protocol !== \"https:\" && u.protocol !== \"http:\") return undefined;\n return `${u.protocol}//${u.host}${u.pathname}`.replace(/\\/+$/, \"\");\n } catch {\n return undefined;\n }\n}\n\n/**\n * Whether the federated-SSO client is active. When false, NOTHING in the\n * SSO module has any effect: the route 404s, the guard bypass is inert, the\n * login button is not rendered. This is the single switch the\n * env-unset-no-op invariant is asserted against.\n */\nexport function isIdentitySsoEnabled(): boolean {\n return !!getIdentityHubUrl();\n}\n\n/**\n * The conditional \"Sign in with Agent-Native\" entry injected into the login\n * page — ONLY when the feature is enabled. Returns an empty string when\n * disabled so the login HTML is byte-for-byte identical to today's output\n * with the env unset (asserted by the env-unset-no-op regression test). Pure\n * string builder, no I/O — safe to call during HTML render. Lives in this\n * leaf module so `onboarding-html.ts` can import it without creating an\n * `auth.ts` ↔ `identity-sso.ts` import cycle.\n */\nexport function identitySsoLoginButtonHtml(): string {\n if (!isIdentitySsoEnabled()) return \"\";\n return (\n `\\n <a class=\"btn-identity-sso\" id=\"identity-sso-btn\" ` +\n `href=\"/_agent-native/identity/login\" ` +\n `style=\"display:flex;align-items:center;justify-content:center;gap:0.5rem;` +\n `width:100%;padding:0.7rem 1rem;margin-bottom:0.75rem;border-radius:8px;` +\n `border:1px solid rgba(255,255,255,0.18);background:transparent;` +\n `color:inherit;font:inherit;font-weight:600;text-decoration:none;` +\n `cursor:pointer\">Sign in with Agent-Native</a>\\n`\n );\n}\n\n/** CSRF state values are valid for 10 minutes. */\nexport const SSO_STATE_TTL_MS = 10 * 60_000;\n\n/**\n * Rate limit for `identity/login`: at most this many state rows may be\n * created within `SSO_LOGIN_WINDOW_MS`. The endpoint is reachable without a\n * session (it's the entry point), so keep a coarse global cap to stop table\n * flooding without per-IP plumbing.\n */\nexport const SSO_LOGIN_MAX = 60;\nexport const SSO_LOGIN_WINDOW_MS = 60_000;\n\nasync function ensureTable(): Promise<void> {\n if (!_initPromise) {\n _initPromise = (async () => {\n const client = getDbExec();\n // Additive only. Never DROP / ALTER — this DB is shared across every\n // deploy context (preview/branch/prod) for hosted templates.\n await client.execute(`\n CREATE TABLE IF NOT EXISTS identity_sso_state (\n state TEXT PRIMARY KEY,\n return_path TEXT,\n created_at ${intType()},\n expires_at ${intType()},\n consumed_at ${intType()}\n )\n `);\n await client.execute(`\n CREATE TABLE IF NOT EXISTS identity_sso_jti (\n jti TEXT PRIMARY KEY,\n seen_at ${intType()}\n )\n `);\n })().catch((err) => {\n // Don't cache a rejection — let the next caller retry a fresh init.\n _initPromise = undefined;\n throw err;\n });\n }\n return _initPromise;\n}\n\nfunction numOrNull(v: unknown): number | null {\n if (v == null) return null;\n const n = Number(v);\n return Number.isFinite(n) ? n : null;\n}\n\n// ---------------------------------------------------------------------------\n// CSRF state\n// ---------------------------------------------------------------------------\n\n/**\n * Mint a fresh crypto-random `state` value, persist it with an optional\n * same-origin return path, and return it. Rate-limited at creation: at most\n * `SSO_LOGIN_MAX` rows within `SSO_LOGIN_WINDOW_MS`. Throws `RATE_LIMITED`\n * when the cap is exceeded so the route can map it to a 429.\n */\nexport async function createSsoState(\n returnPath: string | null,\n): Promise<string> {\n await ensureTable();\n const client = getDbExec();\n const now = Date.now();\n\n try {\n const { rows } = await client.execute({\n sql: `SELECT COUNT(*) AS n FROM identity_sso_state WHERE created_at > ?`,\n args: [now - SSO_LOGIN_WINDOW_MS],\n });\n const n = Number(rows[0]?.n ?? rows[0]?.[\"COUNT(*)\"] ?? 0);\n if (Number.isFinite(n) && n >= SSO_LOGIN_MAX) {\n throw new Error(\"RATE_LIMITED\");\n }\n } catch (err: any) {\n if (err?.message === \"RATE_LIMITED\") throw err;\n // A read failure must not block legitimate logins — single-use +\n // short-TTL state is the primary protection. Continue.\n }\n\n const state = randomBytes(32).toString(\"base64url\");\n const expiresAt = now + SSO_STATE_TTL_MS;\n await client.execute({\n sql: `INSERT INTO identity_sso_state (state, return_path, created_at, expires_at, consumed_at) VALUES (?, ?, ?, ?, ?)`,\n args: [state, returnPath ?? null, now, expiresAt, null],\n });\n return state;\n}\n\nexport interface SsoStateConsumeResult {\n ok: boolean;\n returnPath: string | null;\n}\n\n/**\n * Atomically consume a `state` value. Returns `{ ok: true, returnPath }` only\n * when the state existed, had not expired, and had not been consumed before —\n * and this call is the one that transitioned it to consumed (single-use,\n * enforced via a conditional UPDATE so a double callback can't both pass).\n * Any other condition returns `{ ok: false }`.\n */\nexport async function consumeSsoState(\n state: string,\n): Promise<SsoStateConsumeResult> {\n if (!state) return { ok: false, returnPath: null };\n await ensureTable();\n const client = getDbExec();\n const now = Date.now();\n\n const { rows } = await client.execute({\n sql: `SELECT state, return_path, expires_at, consumed_at FROM identity_sso_state WHERE state = ?`,\n args: [state],\n });\n if (rows.length === 0) return { ok: false, returnPath: null };\n const row: any = rows[0];\n const expiresAt = numOrNull(row.expires_at ?? row.expiresAt);\n const consumedAt = numOrNull(row.consumed_at ?? row.consumedAt);\n if (consumedAt != null) return { ok: false, returnPath: null };\n if (expiresAt != null && expiresAt < now) {\n return { ok: false, returnPath: null };\n }\n\n // Single-use: only the caller that flips `consumed_at` from NULL wins.\n const result = await client.execute({\n sql: `UPDATE identity_sso_state SET consumed_at = ? WHERE state = ? AND consumed_at IS NULL`,\n args: [now, state],\n });\n if (result.rowsAffected === 0) {\n // Lost the race to a concurrent callback — treat as already consumed.\n return { ok: false, returnPath: null };\n }\n const returnPath = (row.return_path ?? row.returnPath ?? null) as\n | string\n | null;\n return { ok: true, returnPath };\n}\n\n// ---------------------------------------------------------------------------\n// Replay (jti) guard\n// ---------------------------------------------------------------------------\n\n/**\n * Returns true when the given identity-token `jti` has already been seen\n * (i.e. this is a replay). On the first sighting, records the `jti` and\n * returns false. Best-effort: a store/DB error returns `false` (not a\n * replay) so a transient Neon WS drop never blocks a legitimate first-time\n * sign-in — signature + exp + scope + single-use CSRF state remain the hard\n * gates; this only adds defence in depth against token replay.\n */\nexport async function isJtiReplayed(jti: string | undefined): Promise<boolean> {\n if (!jti) return false;\n try {\n await ensureTable();\n const client = getDbExec();\n const result = await client.execute({\n sql: `INSERT INTO identity_sso_jti (jti, seen_at) VALUES (?, ?)`,\n args: [jti, Date.now()],\n });\n // A successful insert means this jti was never seen → not a replay.\n return result.rowsAffected === 0;\n } catch (err) {\n // Primary-key conflict = the jti already exists = replay.\n const msg = String((err as any)?.message ?? \"\").toLowerCase();\n if (\n msg.includes(\"unique\") ||\n msg.includes(\"duplicate\") ||\n msg.includes(\"constraint\")\n ) {\n return true;\n }\n // Any other error (incl. connection blips): fail open — do not block a\n // legitimate first sign-in over a transient DB issue.\n if (isConnectionError(err)) return false;\n return false;\n }\n}\n"]}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-app SSO ("Sign in with Agent-Native") — the CLIENT side.
|
|
3
|
+
*
|
|
4
|
+
* Each hosted `*.agent-native.com` app has its OWN Better Auth user store
|
|
5
|
+
* (a separate database per app). This module lets an app federate sign-in to
|
|
6
|
+
* an identity authority (Dispatch) so a user logged in there can land in this
|
|
7
|
+
* app without re-entering credentials.
|
|
8
|
+
*
|
|
9
|
+
* Opt-in, OFF by default, fully reversible. Everything here is gated on the
|
|
10
|
+
* single env var `AGENT_NATIVE_IDENTITY_HUB_URL`:
|
|
11
|
+
*
|
|
12
|
+
* - UNSET → `isIdentitySsoEnabled()` is false. The route handler 404s, the
|
|
13
|
+
* auth-guard bypass does not apply, and the login page renders no SSO
|
|
14
|
+
* button. Existing auth is byte-for-byte unchanged.
|
|
15
|
+
* - SET (e.g. `https://dispatch.agent-native.com`) → two routes mount:
|
|
16
|
+
* GET /_agent-native/identity/login
|
|
17
|
+
* 302 → `<HUB>/_agent-native/identity/authorize?app=<id>
|
|
18
|
+
* &redirect_uri=<thisOrigin>/_agent-native/identity/callback
|
|
19
|
+
* &state=<single-use CSRF state>`
|
|
20
|
+
* GET /_agent-native/identity/callback?token=<jwt>&state=<state>
|
|
21
|
+
* Verifies the hub-issued identity JWT (HS256 over the SHARED A2A
|
|
22
|
+
* secret — the exact verify path A2A / MCP `verifyAuth` use), checks
|
|
23
|
+
* `scope:"identity"`, `exp`, single-use CSRF `state`, and (best
|
|
24
|
+
* effort) `jti` replay, then JIT-links the verified email into this
|
|
25
|
+
* app's local Better Auth store and mints a normal framework session
|
|
26
|
+
* the SAME way the Google OAuth callback does.
|
|
27
|
+
*
|
|
28
|
+
* Crypto reuse: the hub signs with `jose.SignJWT(...).sign(A2A_SECRET)` (the
|
|
29
|
+
* existing `signA2AToken` builder). We verify with the identical
|
|
30
|
+
* `jose.jwtVerify(token, A2A_SECRET)` call `mcp/build-server.ts#verifyAuth`
|
|
31
|
+
* uses — no new crypto, no new keys.
|
|
32
|
+
*
|
|
33
|
+
* Session reuse: a NEW email is created via `auth.api.signUpEmail` — the
|
|
34
|
+
* exact Better Auth signup path `maybeAutoCreateDevSession` already uses, so
|
|
35
|
+
* the adapter creates the `user` (+ adapter-managed credential `account`)
|
|
36
|
+
* row schema-correctly and the normal `databaseHooks.user.create.after`
|
|
37
|
+
* (org auto-join, analytics) fires. The framework session is then minted via
|
|
38
|
+
* `createOAuthSession` — the literal Google-OAuth session-mint path
|
|
39
|
+
* (`addSession` + `setFrameworkSessionCookie`). An EXISTING email is never
|
|
40
|
+
* mutated: we only ADD an inert federated-provider `account` row (if absent)
|
|
41
|
+
* and mint the same framework session. Removing the env returns the app to
|
|
42
|
+
* its prior auth with no residue.
|
|
43
|
+
*/
|
|
44
|
+
import type { H3Event } from "h3";
|
|
45
|
+
import { getIdentityHubUrl, isIdentitySsoEnabled, identitySsoLoginButtonHtml } from "./identity-sso-store.js";
|
|
46
|
+
export { getIdentityHubUrl, isIdentitySsoEnabled, identitySsoLoginButtonHtml };
|
|
47
|
+
/**
|
|
48
|
+
* The provider id recorded on the additive `account` row we link for an
|
|
49
|
+
* EXISTING local user. Must match the value the Dispatch authority agent
|
|
50
|
+
* expects to interoperate with — documented in the report so the two sides
|
|
51
|
+
* stay in sync. Inert when this provider is unused, so removing the env var
|
|
52
|
+
* leaves no behavioural residue.
|
|
53
|
+
*/
|
|
54
|
+
export declare const IDENTITY_SSO_PROVIDER_ID = "agent-native";
|
|
55
|
+
/**
|
|
56
|
+
* The JWT `scope` claim the hub MUST set on the identity token. The callback
|
|
57
|
+
* rejects any token whose `scope` is not exactly this value, so an A2A
|
|
58
|
+
* delegation JWT (no scope, or `scope:"mcp-connect"`) can never be replayed
|
|
59
|
+
* as an identity assertion.
|
|
60
|
+
*/
|
|
61
|
+
export declare const IDENTITY_SSO_SCOPE = "identity";
|
|
62
|
+
/**
|
|
63
|
+
* Handle a `/_agent-native/identity/*` request. `subpath` is the part after
|
|
64
|
+
* `/identity` (e.g. `/login`, `/callback`). Returns a 404 Response whenever
|
|
65
|
+
* the feature is disabled so an unset env var is a true no-op even if the
|
|
66
|
+
* route somehow gets mounted.
|
|
67
|
+
*/
|
|
68
|
+
export declare function handleIdentitySso(event: H3Event, subpath: string): Promise<Response>;
|
|
69
|
+
/**
|
|
70
|
+
* Whether the given (already base-path-stripped) request path is one of the
|
|
71
|
+
* SSO routes that must bypass the blanket auth guard. Both routes resolve /
|
|
72
|
+
* mint the browser session themselves: `/login` is the unauthenticated entry
|
|
73
|
+
* point, and `/callback` is hit by a user who is (by definition) not yet
|
|
74
|
+
* signed in to THIS app. Returns false when the feature is disabled, so the
|
|
75
|
+
* guard's behaviour is unchanged with the env unset.
|
|
76
|
+
*/
|
|
77
|
+
export declare function isIdentitySsoBypassPath(p: string): boolean;
|
|
78
|
+
//# sourceMappingURL=identity-sso.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"identity-sso.d.ts","sourceRoot":"","sources":["../../src/server/identity-sso.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0CG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,IAAI,CAAC;AAWlC,OAAO,EAIL,iBAAiB,EACjB,oBAAoB,EACpB,0BAA0B,EAC3B,MAAM,yBAAyB,CAAC;AAEjC,OAAO,EAAE,iBAAiB,EAAE,oBAAoB,EAAE,0BAA0B,EAAE,CAAC;AAE/E;;;;;;GAMG;AACH,eAAO,MAAM,wBAAwB,iBAAiB,CAAC;AAEvD;;;;;GAKG;AACH,eAAO,MAAM,kBAAkB,aAAa,CAAC;AAoQ7C;;;;;GAKG;AACH,wBAAsB,iBAAiB,CACrC,KAAK,EAAE,OAAO,EACd,OAAO,EAAE,MAAM,GACd,OAAO,CAAC,QAAQ,CAAC,CAiJnB;AAED;;;;;;;GAOG;AACH,wBAAgB,uBAAuB,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAM1D"}
|