@ory/argus 0.14.0 → 1.0.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 +31 -46
- package/assets/commands/temporal-up.md +1 -1
- package/assets/skills/auth-setup/SKILL.md +1 -1
- package/assets/skills/local-dev/SKILL.md +17 -7
- package/assets/skills/ory-build-agent/SKILL.md +39 -91
- package/assets/skills/ory-e2b-sandbox/SKILL.md +18 -17
- package/assets/skills/ory-temporal-worker/SKILL.md +31 -33
- package/assets/skills/permissions-onboarding/SKILL.md +131 -104
- package/dist/adapters.d.ts +88 -32
- package/dist/adapters.js +443 -164
- package/dist/agent-auth.d.ts +226 -67
- package/dist/agent-auth.js +951 -205
- package/dist/auth-store.d.ts +37 -2
- package/dist/auth-store.js +37 -3
- package/dist/auth.d.ts +33 -4
- package/dist/auth.js +163 -20
- package/dist/bash-parser.d.ts +98 -0
- package/dist/bash-parser.js +396 -0
- package/dist/branding.d.ts +77 -16
- package/dist/branding.js +93 -23
- package/dist/build-info.json +4 -4
- package/dist/cli-invocation.d.ts +1 -1
- package/dist/cli-invocation.js +2 -1
- package/dist/cli.d.ts +20 -29
- package/dist/cli.js +271 -239
- package/dist/client.d.ts +175 -138
- package/dist/client.js +672 -391
- package/dist/config.d.ts +217 -54
- package/dist/config.js +461 -42
- package/dist/context.d.ts +10 -0
- package/dist/context.js +21 -0
- package/dist/contract-suite.d.ts +3 -5
- package/dist/contract-suite.js +75 -47
- package/dist/denial.d.ts +36 -3
- package/dist/denial.js +79 -10
- package/dist/event-reporter.d.ts +77 -0
- package/dist/event-reporter.js +776 -0
- package/dist/external-registrations-main.d.ts +10 -0
- package/dist/external-registrations-main.js +38 -0
- package/dist/external-registrations.d.ts +79 -0
- package/dist/external-registrations.js +188 -0
- package/dist/help-cli.d.ts +39 -0
- package/dist/help-cli.js +55 -0
- package/dist/hook-timeout.d.ts +64 -0
- package/dist/hook-timeout.js +88 -0
- package/dist/index.d.ts +28 -23
- package/dist/index.js +173 -58
- package/dist/lifecycle.d.ts +3 -3
- package/dist/lifecycle.js +38 -6
- package/dist/local/cli.js +11 -6
- package/dist/local/configs.d.ts +74 -18
- package/dist/local/configs.js +291 -84
- package/dist/local/health.js +7 -7
- package/dist/local/index.d.ts +2 -2
- package/dist/local/index.js +24 -10
- package/dist/local/manager.d.ts +20 -1
- package/dist/local/manager.js +159 -36
- package/dist/local/ports.d.ts +158 -0
- package/dist/local/ports.js +443 -0
- package/dist/local/seed.d.ts +13 -5
- package/dist/local/seed.js +62 -38
- package/dist/logger.d.ts +54 -25
- package/dist/logger.js +329 -63
- package/dist/mcp.d.ts +2 -2
- package/dist/mcp.js +10 -5
- package/dist/mirror-bootstrap.d.ts +48 -0
- package/dist/mirror-bootstrap.js +254 -0
- package/dist/opl.d.ts +289 -0
- package/dist/opl.js +446 -0
- package/dist/permission-mode.d.ts +87 -0
- package/dist/permission-mode.js +307 -0
- package/dist/permissions-cli.d.ts +13 -49
- package/dist/permissions-cli.js +154 -348
- package/dist/permissions.d.ts +148 -38
- package/dist/permissions.js +591 -45
- package/dist/post-install.d.ts +33 -0
- package/dist/post-install.js +127 -0
- package/dist/read-credential.d.ts +65 -0
- package/dist/read-credential.js +86 -0
- package/dist/registry/cli.js +5 -2
- package/dist/registry/config.d.ts +0 -17
- package/dist/registry/config.js +0 -23
- package/dist/registry/index.d.ts +1 -1
- package/dist/registry/index.js +2 -2
- package/dist/registry/manager.d.ts +4 -21
- package/dist/registry/manager.js +21 -37
- package/dist/runtime-credential.d.ts +140 -0
- package/dist/runtime-credential.js +572 -0
- package/dist/runtime.d.ts +413 -0
- package/dist/runtime.js +825 -0
- package/dist/setup.d.ts +23 -47
- package/dist/setup.js +59 -116
- package/dist/skills.js +0 -7
- package/dist/status-cli.d.ts +27 -11
- package/dist/status-cli.js +121 -115
- package/dist/status-data.d.ts +116 -17
- package/dist/status-data.js +121 -38
- package/dist/subject.d.ts +126 -20
- package/dist/subject.js +215 -30
- package/dist/testing.d.ts +74 -38
- package/dist/testing.js +185 -68
- package/dist/tool-catalog.d.ts +53 -11
- package/dist/tool-catalog.js +164 -13
- package/dist/tool-metadata.d.ts +7 -6
- package/dist/tool-metadata.js +6 -5
- package/dist/types.d.ts +11 -1
- package/dist/uninstall.d.ts +71 -34
- package/dist/uninstall.js +217 -85
- package/dist/user-login.d.ts +9 -10
- package/dist/user-login.js +56 -75
- package/dist/watch-cli.d.ts +6 -0
- package/dist/watch-cli.js +217 -0
- package/package.json +4 -27
- package/assets/commands/dashboard.md +0 -34
- package/dist/dashboard-cli.d.ts +0 -8
- package/dist/dashboard-cli.js +0 -70
- package/dist/dev.d.ts +0 -103
- package/dist/dev.js +0 -583
- package/dist/interactive-setup.d.ts +0 -286
- package/dist/interactive-setup.js +0 -1734
- package/dist/local/jaeger-main.d.ts +0 -13
- package/dist/local/jaeger-main.js +0 -85
- package/dist/local/jaeger.d.ts +0 -50
- package/dist/local/jaeger.js +0 -162
- package/dist/otel/exporter.d.ts +0 -17
- package/dist/otel/exporter.js +0 -12
- package/dist/otel/index.d.ts +0 -2
- package/dist/otel/index.js +0 -8
- package/dist/otel/otlp.d.ts +0 -103
- package/dist/otel/otlp.js +0 -385
- package/dist/project-api-key.d.ts +0 -69
- package/dist/project-api-key.js +0 -147
- package/dist/setup-actions.d.ts +0 -232
- package/dist/setup-actions.js +0 -507
- package/dist/tracer.d.ts +0 -190
- package/dist/tracer.js +0 -481
- package/dist/watch-sandbox.d.ts +0 -9
- package/dist/watch-sandbox.js +0 -81
- package/dist/web/api.d.ts +0 -33
- package/dist/web/api.js +0 -294
- package/dist/web/launch.d.ts +0 -11
- package/dist/web/launch.js +0 -96
- package/dist/web/server.d.ts +0 -20
- package/dist/web/server.js +0 -233
- package/dist/web/types.d.ts +0 -65
- package/dist/web/types.js +0 -2
- package/dist/webapp/assets/index-Wucl4SZs.css +0 -1
- package/dist/webapp/assets/index-m-GtEdq0.js +0 -49
- package/dist/webapp/favicon.ico +0 -0
- package/dist/webapp/index.html +0 -15
package/dist/auth-store.d.ts
CHANGED
|
@@ -18,6 +18,41 @@ export declare function loadTokens(): OryOAuth2Tokens | undefined;
|
|
|
18
18
|
export declare function saveTokens(tokens: OryOAuth2Tokens): void;
|
|
19
19
|
/** Remove the user's persisted OAuth2 tokens. */
|
|
20
20
|
export declare function clearTokens(): void;
|
|
21
|
+
/**
|
|
22
|
+
* A user credential supplied out-of-band through the environment (CI, scripted
|
|
23
|
+
* runs, a harness launched by a wrapper that already authenticated).
|
|
24
|
+
*/
|
|
25
|
+
export interface EnvUserCredential {
|
|
26
|
+
/** The bearer, if any of the four accepted variables is set. */
|
|
27
|
+
token?: string;
|
|
28
|
+
/** `ORY_USER_SUBJECT_ID`, when the operator pinned the subject. */
|
|
29
|
+
subject?: string;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Read the pre-supplied user credential from the environment.
|
|
33
|
+
*
|
|
34
|
+
* **`ORY_USER_OAUTH2_TOKEN` is the only accepted variable**, because an OAuth2
|
|
35
|
+
* access token is the only thing the credential is ever used as. All three
|
|
36
|
+
* consumers of `userPrincipal.token` demand one: broker delegation
|
|
37
|
+
* authentication and the agent + sub-agent DCR bootstraps (which send it as a
|
|
38
|
+
* bearer to `/oauth2/register`). Nothing calls `verifySession`, so a Kratos
|
|
39
|
+
* session token is never consumed as a session token.
|
|
40
|
+
*
|
|
41
|
+
* Three variables used to be accepted alongside it and have been removed:
|
|
42
|
+
* `ORY_USER_SESSION_TOKEN` (plus its legacy alias `ORY_SESSION_TOKEN`) named a
|
|
43
|
+
* Kratos session token and took *first* precedence, so supplying the credential
|
|
44
|
+
* its name asked for made every permission check fail `session_inactive` — and
|
|
45
|
+
* beat the variable that would have worked. `ORY_OAUTH2_TOKEN` was a redundant
|
|
46
|
+
* legacy alias. Pin the subject with `ORY_USER_SUBJECT_ID`.
|
|
47
|
+
*
|
|
48
|
+
* This lives here, and only here, because two call sites depend on agreeing:
|
|
49
|
+
* the login gate's `env_token` short-circuit (which runs on `SessionStart`) and
|
|
50
|
+
* the per-client rehydration in `OryAgentClient.fromEnv` (which runs on *every*
|
|
51
|
+
* lifecycle event, including the subprocess tool-call events where the gate
|
|
52
|
+
* never runs). If those disagreed, an env-supplied token would authenticate the
|
|
53
|
+
* session and then silently stop authenticating the permission checks.
|
|
54
|
+
*/
|
|
55
|
+
export declare function readEnvUserCredential(env?: NodeJS.ProcessEnv): EnvUserCredential;
|
|
21
56
|
/** Number of seconds before nominal expiry that we consider tokens stale. */
|
|
22
57
|
export declare const TOKEN_EXPIRY_SKEW_SEC = 60;
|
|
23
58
|
/** Returns true if the access token is missing or within the skew window of expiry. */
|
|
@@ -55,8 +90,8 @@ export interface PkceFlightLock {
|
|
|
55
90
|
export declare function tryAcquirePkceFlightLock(): PkceFlightLock | null;
|
|
56
91
|
/**
|
|
57
92
|
* Unconditionally remove the pkce-flight lock, if any. Use this from
|
|
58
|
-
* single-owner contexts
|
|
59
|
-
*
|
|
93
|
+
* single-owner maintenance contexts where no peer is mid-flow. Returns true
|
|
94
|
+
* when a lock was removed.
|
|
60
95
|
*/
|
|
61
96
|
export declare function clearPkceFlightLock(): boolean;
|
|
62
97
|
/**
|
package/dist/auth-store.js
CHANGED
|
@@ -50,6 +50,7 @@ exports.TOKEN_EXPIRY_SKEW_SEC = void 0;
|
|
|
50
50
|
exports.loadTokens = loadTokens;
|
|
51
51
|
exports.saveTokens = saveTokens;
|
|
52
52
|
exports.clearTokens = clearTokens;
|
|
53
|
+
exports.readEnvUserCredential = readEnvUserCredential;
|
|
53
54
|
exports.isExpired = isExpired;
|
|
54
55
|
exports.refreshAndSave = refreshAndSave;
|
|
55
56
|
exports.tryAcquirePkceFlightLock = tryAcquirePkceFlightLock;
|
|
@@ -73,9 +74,10 @@ function loadTokens() {
|
|
|
73
74
|
}
|
|
74
75
|
/** Persist the user's OAuth2 tokens. Existing tokens are replaced atomically. */
|
|
75
76
|
function saveTokens(tokens) {
|
|
77
|
+
const { idToken: _discardedIdToken, ...persistedTokens } = tokens;
|
|
76
78
|
(0, config_js_1.mutateConfig)((current) => ({
|
|
77
79
|
...current,
|
|
78
|
-
user: { ...(current.user ?? {}), oauth2:
|
|
80
|
+
user: { ...(current.user ?? {}), oauth2: persistedTokens },
|
|
79
81
|
}));
|
|
80
82
|
}
|
|
81
83
|
/** Remove the user's persisted OAuth2 tokens. */
|
|
@@ -93,6 +95,38 @@ function clearTokens() {
|
|
|
93
95
|
return next;
|
|
94
96
|
});
|
|
95
97
|
}
|
|
98
|
+
/**
|
|
99
|
+
* Read the pre-supplied user credential from the environment.
|
|
100
|
+
*
|
|
101
|
+
* **`ORY_USER_OAUTH2_TOKEN` is the only accepted variable**, because an OAuth2
|
|
102
|
+
* access token is the only thing the credential is ever used as. All three
|
|
103
|
+
* consumers of `userPrincipal.token` demand one: broker delegation
|
|
104
|
+
* authentication and the agent + sub-agent DCR bootstraps (which send it as a
|
|
105
|
+
* bearer to `/oauth2/register`). Nothing calls `verifySession`, so a Kratos
|
|
106
|
+
* session token is never consumed as a session token.
|
|
107
|
+
*
|
|
108
|
+
* Three variables used to be accepted alongside it and have been removed:
|
|
109
|
+
* `ORY_USER_SESSION_TOKEN` (plus its legacy alias `ORY_SESSION_TOKEN`) named a
|
|
110
|
+
* Kratos session token and took *first* precedence, so supplying the credential
|
|
111
|
+
* its name asked for made every permission check fail `session_inactive` — and
|
|
112
|
+
* beat the variable that would have worked. `ORY_OAUTH2_TOKEN` was a redundant
|
|
113
|
+
* legacy alias. Pin the subject with `ORY_USER_SUBJECT_ID`.
|
|
114
|
+
*
|
|
115
|
+
* This lives here, and only here, because two call sites depend on agreeing:
|
|
116
|
+
* the login gate's `env_token` short-circuit (which runs on `SessionStart`) and
|
|
117
|
+
* the per-client rehydration in `OryAgentClient.fromEnv` (which runs on *every*
|
|
118
|
+
* lifecycle event, including the subprocess tool-call events where the gate
|
|
119
|
+
* never runs). If those disagreed, an env-supplied token would authenticate the
|
|
120
|
+
* session and then silently stop authenticating the permission checks.
|
|
121
|
+
*/
|
|
122
|
+
function readEnvUserCredential(env = process.env) {
|
|
123
|
+
const token = env.ORY_USER_OAUTH2_TOKEN || undefined;
|
|
124
|
+
const subject = env.ORY_USER_SUBJECT_ID || undefined;
|
|
125
|
+
return {
|
|
126
|
+
...(token ? { token } : {}),
|
|
127
|
+
...(subject ? { subject } : {}),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
96
130
|
/** Number of seconds before nominal expiry that we consider tokens stale. */
|
|
97
131
|
exports.TOKEN_EXPIRY_SKEW_SEC = 60;
|
|
98
132
|
/** Returns true if the access token is missing or within the skew window of expiry. */
|
|
@@ -203,8 +237,8 @@ function isLockStale(file) {
|
|
|
203
237
|
}
|
|
204
238
|
/**
|
|
205
239
|
* Unconditionally remove the pkce-flight lock, if any. Use this from
|
|
206
|
-
* single-owner contexts
|
|
207
|
-
*
|
|
240
|
+
* single-owner maintenance contexts where no peer is mid-flow. Returns true
|
|
241
|
+
* when a lock was removed.
|
|
208
242
|
*/
|
|
209
243
|
function clearPkceFlightLock() {
|
|
210
244
|
try {
|
package/dist/auth.d.ts
CHANGED
|
@@ -14,14 +14,31 @@
|
|
|
14
14
|
import { OryOAuth2Tokens } from "./config.js";
|
|
15
15
|
/** Loopback ports tried in order. All must be registered as redirect URIs on the Ory OAuth2 client. */
|
|
16
16
|
export declare const LOOPBACK_PORTS: readonly [47823, 47824, 47825, 47826];
|
|
17
|
-
/**
|
|
18
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Hard timeout if the user never returns to the browser.
|
|
19
|
+
*
|
|
20
|
+
* Sized to fit **inside** the tightest session-start hook window any harness
|
|
21
|
+
* will accept, so the login always gives up on its own terms and records a
|
|
22
|
+
* `timeout` decline the user can act on. Being SIGKILLed by the harness
|
|
23
|
+
* instead surfaces nothing at all: the session proceeds with no user identity,
|
|
24
|
+
* and under `enforce` every subsequent tool is denied for a reason nothing
|
|
25
|
+
* explains (#220).
|
|
26
|
+
*
|
|
27
|
+
* The binding constraint is Antigravity, which validates its hook timeout to
|
|
28
|
+
* `5..120` seconds — so 120s is the ceiling, and this leaves headroom for the
|
|
29
|
+
* agent DCR registration and delegation record that run after the login inside
|
|
30
|
+
* the same hook invocation. See `hook-timeout.ts`, which derives every
|
|
31
|
+
* harness's declared window and asserts it outlasts this value.
|
|
32
|
+
*/
|
|
33
|
+
export declare const DEFAULT_LOGIN_TIMEOUT_MS = 90000;
|
|
19
34
|
export interface PkceLoginOptions {
|
|
20
35
|
/** Ory project URL (e.g. https://your-project.projects.oryapis.com). */
|
|
21
36
|
projectUrl: string;
|
|
22
37
|
/** Public OAuth2 client id registered with the loopback redirect URIs. */
|
|
23
38
|
clientId: string;
|
|
24
|
-
/** Scopes to request. Defaults to `openid offline_access
|
|
39
|
+
/** Scopes to request. Defaults to `openid offline_access profile email`
|
|
40
|
+
* — `profile` / `email` populate the id_token's name/username/email
|
|
41
|
+
* claims so the signed-in user can be shown by name, not just subject id. */
|
|
25
42
|
scope?: string;
|
|
26
43
|
/** Audience to request, if any. */
|
|
27
44
|
audience?: string;
|
|
@@ -42,8 +59,20 @@ export type PkceLoginOutcome = {
|
|
|
42
59
|
} | {
|
|
43
60
|
kind: "declined";
|
|
44
61
|
reason: PkceDeclineReason;
|
|
62
|
+
/**
|
|
63
|
+
* Concise, secret-free diagnostic for the failure, surfaced in the
|
|
64
|
+
* `user.auth` audit event and the operator message. Populated for
|
|
65
|
+
* `token_exchange_failed` (the underlying HTTP status / OAuth2 error,
|
|
66
|
+
* or a fetch cause code such as `UNABLE_TO_GET_ISSUER_CERT_LOCALLY` /
|
|
67
|
+
* `ECONNREFUSED`). Never contains the authorization code or a token.
|
|
68
|
+
*/
|
|
69
|
+
detail?: string;
|
|
45
70
|
};
|
|
46
|
-
export type PkceDeclineReason = "headless" | "timeout" | "user_denied" | "state_mismatch" | "no_port" | "browser_launch_failed" | "aborted" | "token_exchange_failed"
|
|
71
|
+
export type PkceDeclineReason = "headless" | "timeout" | "user_denied" | "state_mismatch" | "no_port" | "browser_launch_failed" | "aborted" | "token_exchange_failed"
|
|
72
|
+
/** The authorization server rejected the requested scope (`invalid_scope`). */
|
|
73
|
+
| "invalid_scope"
|
|
74
|
+
/** The authorization server returned some other `error` on the redirect. */
|
|
75
|
+
| "oauth_error";
|
|
47
76
|
/**
|
|
48
77
|
* Default headless heuristic. Returns true only for unattended runs
|
|
49
78
|
* (CI=true / CI=1) so {@link pkceLogin} can short-circuit instead of
|
package/dist/auth.js
CHANGED
|
@@ -59,10 +59,34 @@ const node_http_1 = require("node:http");
|
|
|
59
59
|
const node_url_1 = require("node:url");
|
|
60
60
|
const fs = __importStar(require("node:fs"));
|
|
61
61
|
const path = __importStar(require("node:path"));
|
|
62
|
+
const branding_js_1 = require("./branding.js");
|
|
62
63
|
/** Loopback ports tried in order. All must be registered as redirect URIs on the Ory OAuth2 client. */
|
|
63
64
|
exports.LOOPBACK_PORTS = [47823, 47824, 47825, 47826];
|
|
64
|
-
/**
|
|
65
|
-
|
|
65
|
+
/**
|
|
66
|
+
* Hard timeout if the user never returns to the browser.
|
|
67
|
+
*
|
|
68
|
+
* Sized to fit **inside** the tightest session-start hook window any harness
|
|
69
|
+
* will accept, so the login always gives up on its own terms and records a
|
|
70
|
+
* `timeout` decline the user can act on. Being SIGKILLed by the harness
|
|
71
|
+
* instead surfaces nothing at all: the session proceeds with no user identity,
|
|
72
|
+
* and under `enforce` every subsequent tool is denied for a reason nothing
|
|
73
|
+
* explains (#220).
|
|
74
|
+
*
|
|
75
|
+
* The binding constraint is Antigravity, which validates its hook timeout to
|
|
76
|
+
* `5..120` seconds — so 120s is the ceiling, and this leaves headroom for the
|
|
77
|
+
* agent DCR registration and delegation record that run after the login inside
|
|
78
|
+
* the same hook invocation. See `hook-timeout.ts`, which derives every
|
|
79
|
+
* harness's declared window and asserts it outlasts this value.
|
|
80
|
+
*/
|
|
81
|
+
exports.DEFAULT_LOGIN_TIMEOUT_MS = 90_000;
|
|
82
|
+
/**
|
|
83
|
+
* Login scopes, derived from the single source of truth in branding so the
|
|
84
|
+
* PKCE request can never drift from what the install registers the client
|
|
85
|
+
* with. `WIDE` (with profile/email) is requested by default and dropped to
|
|
86
|
+
* `BASE` if the client isn't allowed those scopes (see {@link pkceLogin}).
|
|
87
|
+
*/
|
|
88
|
+
const BASE_LOGIN_SCOPE = branding_js_1.USER_LOGIN_BASE_SCOPES.join(" ");
|
|
89
|
+
const WIDE_LOGIN_SCOPE = branding_js_1.USER_LOGIN_SCOPE_OAUTH;
|
|
66
90
|
/**
|
|
67
91
|
* Default headless heuristic. Returns true only for unattended runs
|
|
68
92
|
* (CI=true / CI=1) so {@link pkceLogin} can short-circuit instead of
|
|
@@ -87,12 +111,40 @@ function detectHeadless(env = process.env) {
|
|
|
87
111
|
*/
|
|
88
112
|
async function pkceLogin(options) {
|
|
89
113
|
const isHeadless = options.isHeadless ?? detectHeadless;
|
|
90
|
-
|
|
91
|
-
if (headless) {
|
|
114
|
+
if (isHeadless()) {
|
|
92
115
|
// Unattended environment (CI). No human to follow the URL, so we
|
|
93
116
|
// skip the loopback listener entirely instead of timing out.
|
|
94
117
|
return { kind: "declined", reason: "headless" };
|
|
95
118
|
}
|
|
119
|
+
// An explicit scope is honored verbatim. The default asks for `profile` and
|
|
120
|
+
// `email` (so the id_token carries the signed-in user's name) but falls back
|
|
121
|
+
// to the base scope when the login client isn't allowed them — otherwise a
|
|
122
|
+
// client not configured for those scopes could never complete login
|
|
123
|
+
// (`invalid_scope`). The fallback costs one extra redirect, no user action.
|
|
124
|
+
const scopes = options.scope ? [options.scope] : [WIDE_LOGIN_SCOPE, BASE_LOGIN_SCOPE];
|
|
125
|
+
let outcome = { kind: "declined", reason: "user_denied" };
|
|
126
|
+
for (let i = 0; i < scopes.length; i++) {
|
|
127
|
+
const scopeFallbackPending = i < scopes.length - 1;
|
|
128
|
+
outcome = await runPkceAttempt(options, scopes[i], scopeFallbackPending);
|
|
129
|
+
if (outcome.kind === "ok")
|
|
130
|
+
return outcome;
|
|
131
|
+
if (outcome.reason === "invalid_scope" && scopeFallbackPending) {
|
|
132
|
+
process.stderr.write("The sign-in client isn't allowed the requested profile/email scopes; " +
|
|
133
|
+
"retrying with basic scopes (your name may not be shown in status).\n\n");
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
return outcome;
|
|
137
|
+
}
|
|
138
|
+
return outcome;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* One PKCE attempt with a specific scope string: bind a loopback port, open the
|
|
142
|
+
* browser, wait for the callback, and exchange the code. `scopeFallbackPending`
|
|
143
|
+
* is true when {@link pkceLogin} will retry with a narrower scope if this
|
|
144
|
+
* attempt is rejected for `invalid_scope`, so the browser page can say
|
|
145
|
+
* "reconnecting" instead of showing a hard failure.
|
|
146
|
+
*/
|
|
147
|
+
async function runPkceAttempt(options, scope, scopeFallbackPending) {
|
|
96
148
|
const { server, port } = await listenOnFirstAvailablePort(options.ports ?? exports.LOOPBACK_PORTS);
|
|
97
149
|
if (!server || port === null) {
|
|
98
150
|
return { kind: "declined", reason: "no_port" };
|
|
@@ -101,7 +153,6 @@ async function pkceLogin(options) {
|
|
|
101
153
|
const codeChallenge = sha256Base64Url(codeVerifier);
|
|
102
154
|
const state = (0, node_crypto_1.randomBytes)(16).toString("base64url");
|
|
103
155
|
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
104
|
-
const scope = options.scope ?? "openid offline_access";
|
|
105
156
|
const authorizeUrl = buildAuthorizeUrl({
|
|
106
157
|
projectUrl: options.projectUrl,
|
|
107
158
|
clientId: options.clientId,
|
|
@@ -123,6 +174,7 @@ async function pkceLogin(options) {
|
|
|
123
174
|
expectedState: state,
|
|
124
175
|
timeoutMs,
|
|
125
176
|
signal: options.signal,
|
|
177
|
+
scopeFallbackPending,
|
|
126
178
|
});
|
|
127
179
|
// Browser launch is best-effort. If `open` throws or the platform has
|
|
128
180
|
// no usable browser, we keep the callback server alive and rely on the
|
|
@@ -153,10 +205,35 @@ async function pkceLogin(options) {
|
|
|
153
205
|
});
|
|
154
206
|
return { kind: "ok", tokens };
|
|
155
207
|
}
|
|
156
|
-
catch {
|
|
157
|
-
|
|
208
|
+
catch (err) {
|
|
209
|
+
// Surface the underlying cause (HTTP status + OAuth2 error, or a fetch
|
|
210
|
+
// cause code like a TLS-trust failure) instead of swallowing it — this
|
|
211
|
+
// is the difference between a diagnosable failure and a bare
|
|
212
|
+
// "exchange failed" message. The detail is secret-free.
|
|
213
|
+
return {
|
|
214
|
+
kind: "declined",
|
|
215
|
+
reason: "token_exchange_failed",
|
|
216
|
+
detail: describeExchangeError(err),
|
|
217
|
+
};
|
|
158
218
|
}
|
|
159
219
|
}
|
|
220
|
+
/**
|
|
221
|
+
* Render an error thrown by {@link exchangeCodeForTokens} into a concise,
|
|
222
|
+
* secret-free detail string. For a failed `fetch` the useful signal is the
|
|
223
|
+
* low-level cause code (`UNABLE_TO_GET_ISSUER_CERT_LOCALLY`, `ECONNREFUSED`,
|
|
224
|
+
* …); for a non-2xx response the message already carries the HTTP status and
|
|
225
|
+
* the OAuth2 `error`. Neither includes the authorization code or any token.
|
|
226
|
+
*/
|
|
227
|
+
function describeExchangeError(err) {
|
|
228
|
+
if (err instanceof Error) {
|
|
229
|
+
const cause = err.cause;
|
|
230
|
+
const code = cause && typeof cause === "object" && "code" in cause
|
|
231
|
+
? String(cause.code)
|
|
232
|
+
: undefined;
|
|
233
|
+
return code ? `${err.message} (${code})` : err.message;
|
|
234
|
+
}
|
|
235
|
+
return String(err);
|
|
236
|
+
}
|
|
160
237
|
// ─── PKCE primitives ──────────────────────────────────────────────────
|
|
161
238
|
/** Generate a 64-byte (≈86-char base64url) code verifier per RFC 7636. */
|
|
162
239
|
function generateCodeVerifier() {
|
|
@@ -247,8 +324,27 @@ function waitForCallback(server, opts) {
|
|
|
247
324
|
}
|
|
248
325
|
const error = url.searchParams.get("error");
|
|
249
326
|
if (error) {
|
|
250
|
-
|
|
251
|
-
|
|
327
|
+
const description = url.searchParams.get("error_description") ?? undefined;
|
|
328
|
+
// `access_denied` is the user clicking "cancel" — not an error to alarm
|
|
329
|
+
// them with. Everything else is a real authorization-server rejection
|
|
330
|
+
// (misconfigured client, unsupported scope, …); show the actual reason
|
|
331
|
+
// so it can be acted on rather than a generic "cancelled".
|
|
332
|
+
if (error === "access_denied") {
|
|
333
|
+
respondHtml(res, 400, resultPage("cancelled"));
|
|
334
|
+
settle({ kind: "declined", reason: "user_denied" });
|
|
335
|
+
}
|
|
336
|
+
else if (error === "invalid_scope") {
|
|
337
|
+
// A pending scope fallback will retry immediately with fewer scopes,
|
|
338
|
+
// so don't flash a hard failure — say we're reconnecting.
|
|
339
|
+
respondHtml(res, 400, opts.scopeFallbackPending
|
|
340
|
+
? resultPage("reconnecting")
|
|
341
|
+
: resultPage("error", { code: error, description }));
|
|
342
|
+
settle({ kind: "declined", reason: "invalid_scope" });
|
|
343
|
+
}
|
|
344
|
+
else {
|
|
345
|
+
respondHtml(res, 400, resultPage("error", { code: error, description }));
|
|
346
|
+
settle({ kind: "declined", reason: "oauth_error" });
|
|
347
|
+
}
|
|
252
348
|
return;
|
|
253
349
|
}
|
|
254
350
|
const state = url.searchParams.get("state");
|
|
@@ -280,14 +376,13 @@ function respondHtml(res, status, html) {
|
|
|
280
376
|
}
|
|
281
377
|
/**
|
|
282
378
|
* Serve the shared Ory favicon (identical to console.ory.com's) so the sign-in
|
|
283
|
-
* result page carries the same tab icon as the Console
|
|
284
|
-
*
|
|
285
|
-
*
|
|
286
|
-
*
|
|
287
|
-
* request is made.
|
|
379
|
+
* result page carries the same tab icon as the Console, when a bundled
|
|
380
|
+
* `dist/webapp/favicon.ico` is present. It normally isn't, so this falls back
|
|
381
|
+
* to a 404 — the page still renders, just without a favicon. Same-origin on the
|
|
382
|
+
* loopback server, so no external request is made.
|
|
288
383
|
*/
|
|
289
384
|
function respondFavicon(res) {
|
|
290
|
-
// Compiled to dist/auth.js;
|
|
385
|
+
// Compiled to dist/auth.js; look for an optional favicon under dist/webapp.
|
|
291
386
|
const file = path.join(__dirname, "webapp", "favicon.ico");
|
|
292
387
|
try {
|
|
293
388
|
const buf = fs.readFileSync(file);
|
|
@@ -309,7 +404,7 @@ function respondFavicon(res) {
|
|
|
309
404
|
* external requests — the only asset is the same-origin Ory favicon the
|
|
310
405
|
* loopback server serves — so it renders identically offline.
|
|
311
406
|
*/
|
|
312
|
-
function resultPage(kind) {
|
|
407
|
+
function resultPage(kind, detail) {
|
|
313
408
|
const view = {
|
|
314
409
|
success: {
|
|
315
410
|
accent: "#22C55E", // green — a positive "done" check
|
|
@@ -329,7 +424,24 @@ function resultPage(kind) {
|
|
|
329
424
|
title: "Sign-in could not be verified",
|
|
330
425
|
body: "The login response failed a security check. <strong>Close this tab</strong> and re-run the login from your terminal.",
|
|
331
426
|
},
|
|
427
|
+
error: {
|
|
428
|
+
accent: "#F43F5E",
|
|
429
|
+
icon: "!",
|
|
430
|
+
title: "Sign-in failed",
|
|
431
|
+
body: "Ory rejected the sign-in request. <strong>Close this tab</strong> and return to your terminal — the details below say why.",
|
|
432
|
+
},
|
|
433
|
+
reconnecting: {
|
|
434
|
+
accent: "#6366F1", // indigo — a neutral "in progress"
|
|
435
|
+
icon: "↻", // ↻
|
|
436
|
+
title: "Reconnecting…",
|
|
437
|
+
body: "Re-opening sign-in with basic permissions. You can <strong>close this tab</strong>; a new one will open to continue.",
|
|
438
|
+
},
|
|
332
439
|
}[kind];
|
|
440
|
+
// The authorization server's own `error` / `error_description`, shown verbatim
|
|
441
|
+
// (escaped) so a misconfiguration is actionable instead of opaque.
|
|
442
|
+
const detailBlock = detail && (detail.code || detail.description)
|
|
443
|
+
? `\n <pre class="detail">${escapeHtml([detail.code, detail.description].filter(Boolean).join("\n"))}</pre>`
|
|
444
|
+
: "";
|
|
333
445
|
return `<!doctype html>
|
|
334
446
|
<html lang="en">
|
|
335
447
|
<head>
|
|
@@ -355,18 +467,32 @@ function resultPage(kind) {
|
|
|
355
467
|
h1 { font-size: 1.35rem; margin: 0 0 .5rem; color: #F8FAFC; }
|
|
356
468
|
p { margin: 0; line-height: 1.55; color: #94A3B8; }
|
|
357
469
|
strong { color: #E2E8F0; font-weight: 600; }
|
|
470
|
+
.detail {
|
|
471
|
+
margin: 1.25rem 0 0; padding: .75rem 1rem; text-align: left;
|
|
472
|
+
border-radius: .5rem; background: #1E293B; color: #CBD5E1;
|
|
473
|
+
font-size: .8rem; line-height: 1.5; white-space: pre-wrap; word-break: break-word;
|
|
474
|
+
}
|
|
358
475
|
</style>
|
|
359
476
|
</head>
|
|
360
477
|
<body>
|
|
361
478
|
<div class="card">
|
|
362
479
|
<div class="badge">${view.icon}</div>
|
|
363
480
|
<h1>${view.title}</h1>
|
|
364
|
-
<p>${view.body}</p
|
|
481
|
+
<p>${view.body}</p>${detailBlock}
|
|
365
482
|
</div>
|
|
366
483
|
</body>
|
|
367
484
|
</html>
|
|
368
485
|
`;
|
|
369
486
|
}
|
|
487
|
+
/** Minimal HTML-entity escape for untrusted text interpolated into the page. */
|
|
488
|
+
function escapeHtml(s) {
|
|
489
|
+
return s
|
|
490
|
+
.replace(/&/g, "&")
|
|
491
|
+
.replace(/</g, "<")
|
|
492
|
+
.replace(/>/g, ">")
|
|
493
|
+
.replace(/"/g, """)
|
|
494
|
+
.replace(/'/g, "'");
|
|
495
|
+
}
|
|
370
496
|
// ─── Browser launch ───────────────────────────────────────────────────
|
|
371
497
|
async function launchBrowser(url, override) {
|
|
372
498
|
if (override) {
|
|
@@ -396,7 +522,23 @@ async function exchangeCodeForTokens(args) {
|
|
|
396
522
|
body: body.toString(),
|
|
397
523
|
});
|
|
398
524
|
if (!res.ok) {
|
|
399
|
-
|
|
525
|
+
// Include the OAuth2 error from the body when present. `error` /
|
|
526
|
+
// `error_description` are standard OAuth2 diagnostics (e.g.
|
|
527
|
+
// `invalid_client`, `invalid_grant`), not secrets — they turn an opaque
|
|
528
|
+
// 400/401 into an actionable message.
|
|
529
|
+
let suffix = "";
|
|
530
|
+
try {
|
|
531
|
+
const body = (await res.json());
|
|
532
|
+
const error = typeof body.error === "string" ? body.error : undefined;
|
|
533
|
+
if (error) {
|
|
534
|
+
const desc = typeof body.error_description === "string" ? body.error_description : undefined;
|
|
535
|
+
suffix = desc ? `: ${error} — ${desc}` : `: ${error}`;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
catch {
|
|
539
|
+
// Non-JSON body; the status alone is the diagnostic.
|
|
540
|
+
}
|
|
541
|
+
throw new Error(`Token exchange failed: HTTP ${res.status}${suffix}`);
|
|
400
542
|
}
|
|
401
543
|
const json = (await res.json());
|
|
402
544
|
return tokensFromTokenResponse(json, args.clientId);
|
|
@@ -433,14 +575,15 @@ function tokensFromTokenResponse(json, clientId, fallbackRefreshToken) {
|
|
|
433
575
|
throw new Error("Token response missing access_token");
|
|
434
576
|
const expiresIn = typeof json.expires_in === "number" ? json.expires_in : 3600;
|
|
435
577
|
const refreshToken = typeof json.refresh_token === "string" ? json.refresh_token : fallbackRefreshToken;
|
|
578
|
+
const idToken = typeof json.id_token === "string" ? json.id_token : undefined;
|
|
436
579
|
return {
|
|
437
580
|
accessToken,
|
|
438
581
|
refreshToken,
|
|
439
582
|
expiresAt: Math.floor(Date.now() / 1000) + expiresIn,
|
|
440
583
|
clientId,
|
|
441
|
-
|
|
584
|
+
displayName: displayNameFromIdToken(idToken),
|
|
442
585
|
scope: typeof json.scope === "string" ? json.scope : undefined,
|
|
443
|
-
subject: subjectFromIdToken(
|
|
586
|
+
subject: subjectFromIdToken(idToken),
|
|
444
587
|
};
|
|
445
588
|
}
|
|
446
589
|
/** Best-effort sub-claim extraction from a JWT id_token. Returns undefined on any failure. */
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shell-command decomposition for fine-grained permission checks.
|
|
3
|
+
*
|
|
4
|
+
* The permission gate authorizes on *tool name* only, so a denied surface
|
|
5
|
+
* (`WebFetch`, `Read`, an MCP write) is trivially reached through
|
|
6
|
+
* `bash -c "curl …"`. This module parses a shell command with the
|
|
7
|
+
* `tree-sitter-bash` grammar (via `web-tree-sitter`) into a **structured
|
|
8
|
+
* command model** — the shared foundation for the permission work:
|
|
9
|
+
*
|
|
10
|
+
* - #76 (this): check each command word as `ShellTool:<word>#use`.
|
|
11
|
+
* Derived from `ParsedShell.commands.map(c => c.name)`.
|
|
12
|
+
* - #77 (argument scoping): per-binary extractors read `ShellCommand.args`.
|
|
13
|
+
* - #78 (combinatory dataflow): edges are built from `ShellCommand.pipeline`
|
|
14
|
+
* grouping and `ShellCommand.redirects`.
|
|
15
|
+
* - #79 (env escalation): `ShellCommand.assignments` and redirect targets.
|
|
16
|
+
*
|
|
17
|
+
* Building the model once (one parse, one AST walk) keeps every follow-on
|
|
18
|
+
* additive instead of re-walking the tree.
|
|
19
|
+
*
|
|
20
|
+
* **Why a grammar parser, not a regex/tokenizer.** A hand-rolled tokenizer is
|
|
21
|
+
* vulnerable to *parser-differential attacks* — where the checker reads a
|
|
22
|
+
* command differently than the shell that will run it. tree-sitter-bash gives
|
|
23
|
+
* us the shell's own view of the command.
|
|
24
|
+
*
|
|
25
|
+
* **Failure postures (two, deliberately opposite):**
|
|
26
|
+
* - `tooComplex` — parsed but unresolvable (dynamic command name, parse
|
|
27
|
+
* error, dynamic `sh -c` target). Security signal: enforce denies.
|
|
28
|
+
* - `parserUnavailable` — the runtime/grammar could not load. Availability
|
|
29
|
+
* signal: the gate falls open. A broken build must never block shell use.
|
|
30
|
+
*
|
|
31
|
+
* **Known static-analysis limits (checks for them are #77/#78/#79):** command
|
|
32
|
+
* names built at runtime, programs invoked as interpreter arguments
|
|
33
|
+
* (`python -c "…"`, `find -exec`), and dynamic dataflow. The backstop is that
|
|
34
|
+
* the decode/exec primitives themselves (`sh`, `bash`, `eval`, `base64`, …) are
|
|
35
|
+
* ordinary checked words.
|
|
36
|
+
*/
|
|
37
|
+
/** A redirection attached to a command (`> file`, `< file`, `2>err`, `>/dev/tcp/…`). */
|
|
38
|
+
export interface ShellRedirect {
|
|
39
|
+
/** Raw operator text (`>`, `>>`, `<`, `2>`, `&>`, …). */
|
|
40
|
+
operator: string;
|
|
41
|
+
/** Literal redirect target (path / fd / device), or `null` when dynamic. */
|
|
42
|
+
target: string | null;
|
|
43
|
+
/** `read` for `<`/`<<`/`<<<`, `write` for `>`/`>>`/`&>`. */
|
|
44
|
+
direction: "read" | "write";
|
|
45
|
+
}
|
|
46
|
+
/** A NAME=value assignment (an `env`-style prefix on a command). */
|
|
47
|
+
export interface ShellAssignment {
|
|
48
|
+
name: string;
|
|
49
|
+
/** Literal value, or `null` when it contains an expansion. */
|
|
50
|
+
value: string | null;
|
|
51
|
+
}
|
|
52
|
+
/** One effective command invocation extracted from a shell command line. */
|
|
53
|
+
export interface ShellCommand {
|
|
54
|
+
/**
|
|
55
|
+
* Resolved program/builtin word, normalized to its basename. `null` when the
|
|
56
|
+
* command name is dynamic/unresolvable (`$X`, `$(…)`), which also flips
|
|
57
|
+
* {@link ParsedShell.tooComplex}.
|
|
58
|
+
*/
|
|
59
|
+
name: string | null;
|
|
60
|
+
/** Raw text of each argument token, in order (for #77 argument scoping). */
|
|
61
|
+
args: string[];
|
|
62
|
+
/** `NAME=value` assignment prefixes on this command (for #79 env scoping). */
|
|
63
|
+
assignments: ShellAssignment[];
|
|
64
|
+
/** Redirects on this command / its enclosing statement (for #78/#79). */
|
|
65
|
+
redirects: ShellRedirect[];
|
|
66
|
+
/**
|
|
67
|
+
* Pipeline group id. Commands joined by `|` share an id, so #78 can form the
|
|
68
|
+
* pipe edges between them. Standalone commands each get a distinct id.
|
|
69
|
+
*/
|
|
70
|
+
pipeline: number;
|
|
71
|
+
}
|
|
72
|
+
/** Structured result of decomposing one shell command line. */
|
|
73
|
+
export interface ParsedShell {
|
|
74
|
+
/** Every effective command invocation (including wrapper- and `sh -c`-nested). */
|
|
75
|
+
commands: ShellCommand[];
|
|
76
|
+
/** Something couldn't be resolved statically (dynamic name / parse error). */
|
|
77
|
+
tooComplex: boolean;
|
|
78
|
+
/** The tree-sitter runtime/grammar failed to load — fail OPEN, don't block. */
|
|
79
|
+
parserUnavailable?: boolean;
|
|
80
|
+
}
|
|
81
|
+
/** Back-compat flat view used by the #76 gate: the deduped command words. */
|
|
82
|
+
export interface BashParseResult {
|
|
83
|
+
words: string[];
|
|
84
|
+
tooComplex: boolean;
|
|
85
|
+
parserUnavailable?: boolean;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Decompose a shell command into a {@link ParsedShell} model. Never throws.
|
|
89
|
+
* This is the shared foundation; see {@link extractBashCommandWords} for the
|
|
90
|
+
* flat word list the #76 gate uses.
|
|
91
|
+
*/
|
|
92
|
+
export declare function extractShellCommands(command: string): Promise<ParsedShell>;
|
|
93
|
+
/**
|
|
94
|
+
* Flat view of {@link extractShellCommands} for the #76 gate: the deduped set
|
|
95
|
+
* of command/builtin words the command will execute. Never throws. See
|
|
96
|
+
* {@link BashParseResult} for the two failure postures.
|
|
97
|
+
*/
|
|
98
|
+
export declare function extractBashCommandWords(command: string): Promise<BashParseResult>;
|