@ory/argus 0.13.9 → 1.0.0
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 -45
- 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 +43 -97
- package/assets/skills/ory-e2b-sandbox/SKILL.md +18 -18
- package/assets/skills/ory-temporal-worker/SKILL.md +38 -42
- package/assets/skills/permissions-onboarding/SKILL.md +131 -104
- package/dist/adapters.d.ts +93 -30
- package/dist/adapters.js +464 -136
- package/dist/agent-auth.d.ts +258 -68
- package/dist/agent-auth.js +998 -202
- package/dist/auth-store.d.ts +37 -2
- package/dist/auth-store.js +37 -3
- package/dist/auth.d.ts +40 -4
- package/dist/auth.js +247 -19
- package/dist/bash-parser.d.ts +98 -0
- package/dist/bash-parser.js +396 -0
- package/dist/branding.d.ts +128 -0
- package/dist/branding.js +151 -0
- 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 -278
- package/dist/client.d.ts +175 -138
- package/dist/client.js +672 -391
- package/dist/config.d.ts +249 -57
- package/dist/config.js +486 -62
- package/dist/context.d.ts +10 -0
- package/dist/context.js +21 -0
- package/dist/contract-suite.d.ts +8 -8
- package/dist/contract-suite.js +88 -69
- 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 +31 -19
- package/dist/index.js +182 -31
- 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.d.ts +14 -0
- package/dist/local/health.js +50 -4
- 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 +160 -39
- package/dist/local/ports.d.ts +158 -0
- package/dist/local/ports.js +443 -0
- package/dist/local/seed.d.ts +22 -25
- package/dist/local/seed.js +88 -56
- 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 +83 -55
- package/dist/runtime-credential.d.ts +140 -0
- package/dist/runtime-credential.js +572 -0
- package/dist/runtime.d.ts +408 -0
- package/dist/runtime.js +748 -0
- package/dist/setup.d.ts +23 -28
- package/dist/setup.js +57 -84
- package/dist/status-cli.d.ts +29 -13
- package/dist/status-cli.js +124 -144
- package/dist/status-data.d.ts +195 -0
- package/dist/status-data.js +333 -0
- package/dist/status-system.d.ts +24 -0
- package/dist/status-system.js +56 -0
- 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 +74 -19
- package/dist/uninstall.js +224 -49
- package/dist/user-login.d.ts +22 -16
- package/dist/user-login.js +67 -96
- package/dist/watch-cli.d.ts +6 -0
- package/dist/watch-cli.js +217 -0
- package/package.json +3 -11
- package/dist/dev.d.ts +0 -103
- package/dist/dev.js +0 -584
- package/dist/interactive-setup.d.ts +0 -165
- package/dist/interactive-setup.js +0 -1546
- 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/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/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
|
|
@@ -91,3 +120,10 @@ export declare function refreshAccessToken(args: {
|
|
|
91
120
|
clientId: string;
|
|
92
121
|
refreshToken: string;
|
|
93
122
|
}): Promise<OryOAuth2Tokens>;
|
|
123
|
+
/**
|
|
124
|
+
* Best-effort human-readable name for the signed-in user, pulled from the
|
|
125
|
+
* id_token's identity claims — `email`, then `name`, then `preferred_username`.
|
|
126
|
+
* Returns undefined when the token carries none of them (e.g. an `openid`-only
|
|
127
|
+
* token), so callers fall back to the opaque subject id.
|
|
128
|
+
*/
|
|
129
|
+
export declare function displayNameFromIdToken(idToken: string | undefined): string | undefined;
|
package/dist/auth.js
CHANGED
|
@@ -12,6 +12,39 @@
|
|
|
12
12
|
* package via dynamic import so the CJS-emitted core stays compatible
|
|
13
13
|
* with its ESM-only export shape.
|
|
14
14
|
*/
|
|
15
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
16
|
+
if (k2 === undefined) k2 = k;
|
|
17
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
18
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
19
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
20
|
+
}
|
|
21
|
+
Object.defineProperty(o, k2, desc);
|
|
22
|
+
}) : (function(o, m, k, k2) {
|
|
23
|
+
if (k2 === undefined) k2 = k;
|
|
24
|
+
o[k2] = m[k];
|
|
25
|
+
}));
|
|
26
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
27
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
28
|
+
}) : function(o, v) {
|
|
29
|
+
o["default"] = v;
|
|
30
|
+
});
|
|
31
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
32
|
+
var ownKeys = function(o) {
|
|
33
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
34
|
+
var ar = [];
|
|
35
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
36
|
+
return ar;
|
|
37
|
+
};
|
|
38
|
+
return ownKeys(o);
|
|
39
|
+
};
|
|
40
|
+
return function (mod) {
|
|
41
|
+
if (mod && mod.__esModule) return mod;
|
|
42
|
+
var result = {};
|
|
43
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
44
|
+
__setModuleDefault(result, mod);
|
|
45
|
+
return result;
|
|
46
|
+
};
|
|
47
|
+
})();
|
|
15
48
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
16
49
|
exports.DEFAULT_LOGIN_TIMEOUT_MS = exports.LOOPBACK_PORTS = void 0;
|
|
17
50
|
exports.detectHeadless = detectHeadless;
|
|
@@ -20,13 +53,40 @@ exports.generateCodeVerifier = generateCodeVerifier;
|
|
|
20
53
|
exports.sha256Base64Url = sha256Base64Url;
|
|
21
54
|
exports.buildAuthorizeUrl = buildAuthorizeUrl;
|
|
22
55
|
exports.refreshAccessToken = refreshAccessToken;
|
|
56
|
+
exports.displayNameFromIdToken = displayNameFromIdToken;
|
|
23
57
|
const node_crypto_1 = require("node:crypto");
|
|
24
58
|
const node_http_1 = require("node:http");
|
|
25
59
|
const node_url_1 = require("node:url");
|
|
60
|
+
const fs = __importStar(require("node:fs"));
|
|
61
|
+
const path = __importStar(require("node:path"));
|
|
62
|
+
const branding_js_1 = require("./branding.js");
|
|
26
63
|
/** Loopback ports tried in order. All must be registered as redirect URIs on the Ory OAuth2 client. */
|
|
27
64
|
exports.LOOPBACK_PORTS = [47823, 47824, 47825, 47826];
|
|
28
|
-
/**
|
|
29
|
-
|
|
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;
|
|
30
90
|
/**
|
|
31
91
|
* Default headless heuristic. Returns true only for unattended runs
|
|
32
92
|
* (CI=true / CI=1) so {@link pkceLogin} can short-circuit instead of
|
|
@@ -51,12 +111,40 @@ function detectHeadless(env = process.env) {
|
|
|
51
111
|
*/
|
|
52
112
|
async function pkceLogin(options) {
|
|
53
113
|
const isHeadless = options.isHeadless ?? detectHeadless;
|
|
54
|
-
|
|
55
|
-
if (headless) {
|
|
114
|
+
if (isHeadless()) {
|
|
56
115
|
// Unattended environment (CI). No human to follow the URL, so we
|
|
57
116
|
// skip the loopback listener entirely instead of timing out.
|
|
58
117
|
return { kind: "declined", reason: "headless" };
|
|
59
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) {
|
|
60
148
|
const { server, port } = await listenOnFirstAvailablePort(options.ports ?? exports.LOOPBACK_PORTS);
|
|
61
149
|
if (!server || port === null) {
|
|
62
150
|
return { kind: "declined", reason: "no_port" };
|
|
@@ -65,7 +153,6 @@ async function pkceLogin(options) {
|
|
|
65
153
|
const codeChallenge = sha256Base64Url(codeVerifier);
|
|
66
154
|
const state = (0, node_crypto_1.randomBytes)(16).toString("base64url");
|
|
67
155
|
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
68
|
-
const scope = options.scope ?? "openid offline_access";
|
|
69
156
|
const authorizeUrl = buildAuthorizeUrl({
|
|
70
157
|
projectUrl: options.projectUrl,
|
|
71
158
|
clientId: options.clientId,
|
|
@@ -87,6 +174,7 @@ async function pkceLogin(options) {
|
|
|
87
174
|
expectedState: state,
|
|
88
175
|
timeoutMs,
|
|
89
176
|
signal: options.signal,
|
|
177
|
+
scopeFallbackPending,
|
|
90
178
|
});
|
|
91
179
|
// Browser launch is best-effort. If `open` throws or the platform has
|
|
92
180
|
// no usable browser, we keep the callback server alive and rely on the
|
|
@@ -117,10 +205,35 @@ async function pkceLogin(options) {
|
|
|
117
205
|
});
|
|
118
206
|
return { kind: "ok", tokens };
|
|
119
207
|
}
|
|
120
|
-
catch {
|
|
121
|
-
|
|
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
|
+
};
|
|
122
218
|
}
|
|
123
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
|
+
}
|
|
124
237
|
// ─── PKCE primitives ──────────────────────────────────────────────────
|
|
125
238
|
/** Generate a 64-byte (≈86-char base64url) code verifier per RFC 7636. */
|
|
126
239
|
function generateCodeVerifier() {
|
|
@@ -201,14 +314,37 @@ function waitForCallback(server, opts) {
|
|
|
201
314
|
return;
|
|
202
315
|
}
|
|
203
316
|
const url = new node_url_1.URL(req.url ?? "/", `http://127.0.0.1`);
|
|
317
|
+
if (url.pathname === "/favicon.ico") {
|
|
318
|
+
respondFavicon(res);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
204
321
|
if (url.pathname !== "/callback") {
|
|
205
322
|
respond(res, 404, "Not Found");
|
|
206
323
|
return;
|
|
207
324
|
}
|
|
208
325
|
const error = url.searchParams.get("error");
|
|
209
326
|
if (error) {
|
|
210
|
-
|
|
211
|
-
|
|
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
|
+
}
|
|
212
348
|
return;
|
|
213
349
|
}
|
|
214
350
|
const state = url.searchParams.get("state");
|
|
@@ -238,18 +374,40 @@ function respondHtml(res, status, html) {
|
|
|
238
374
|
res.writeHead(status, { "content-type": "text/html; charset=utf-8" });
|
|
239
375
|
res.end(html);
|
|
240
376
|
}
|
|
377
|
+
/**
|
|
378
|
+
* Serve the shared Ory favicon (identical to console.ory.com's) so the sign-in
|
|
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.
|
|
383
|
+
*/
|
|
384
|
+
function respondFavicon(res) {
|
|
385
|
+
// Compiled to dist/auth.js; look for an optional favicon under dist/webapp.
|
|
386
|
+
const file = path.join(__dirname, "webapp", "favicon.ico");
|
|
387
|
+
try {
|
|
388
|
+
const buf = fs.readFileSync(file);
|
|
389
|
+
res.writeHead(200, {
|
|
390
|
+
"content-type": "image/x-icon",
|
|
391
|
+
"cache-control": "no-store",
|
|
392
|
+
});
|
|
393
|
+
res.end(buf);
|
|
394
|
+
}
|
|
395
|
+
catch {
|
|
396
|
+
respond(res, 404, "Not Found");
|
|
397
|
+
}
|
|
398
|
+
}
|
|
241
399
|
/**
|
|
242
400
|
* The page the browser lands on after the OAuth2 redirect. The whole point is
|
|
243
401
|
* to make the next action unambiguous: on success it tells the user, in plain
|
|
244
402
|
* words, that they can close the tab and return to the terminal (the #1
|
|
245
|
-
* source of "is it done? do I close this?" confusion).
|
|
246
|
-
*
|
|
247
|
-
*
|
|
403
|
+
* source of "is it done? do I close this?" confusion). Inline styles only, no
|
|
404
|
+
* external requests — the only asset is the same-origin Ory favicon the
|
|
405
|
+
* loopback server serves — so it renders identically offline.
|
|
248
406
|
*/
|
|
249
|
-
function resultPage(kind) {
|
|
407
|
+
function resultPage(kind, detail) {
|
|
250
408
|
const view = {
|
|
251
409
|
success: {
|
|
252
|
-
accent: "#
|
|
410
|
+
accent: "#22C55E", // green — a positive "done" check
|
|
253
411
|
icon: "✓", // ✓
|
|
254
412
|
title: "Signed in to Ory",
|
|
255
413
|
body: "You can <strong>close this tab</strong> and return to your terminal — setup continues automatically.",
|
|
@@ -266,12 +424,30 @@ function resultPage(kind) {
|
|
|
266
424
|
title: "Sign-in could not be verified",
|
|
267
425
|
body: "The login response failed a security check. <strong>Close this tab</strong> and re-run the login from your terminal.",
|
|
268
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
|
+
},
|
|
269
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
|
+
: "";
|
|
270
445
|
return `<!doctype html>
|
|
271
446
|
<html lang="en">
|
|
272
447
|
<head>
|
|
273
448
|
<meta charset="utf-8">
|
|
274
449
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
450
|
+
<link rel="icon" href="/favicon.ico">
|
|
275
451
|
<title>${view.title}</title>
|
|
276
452
|
<style>
|
|
277
453
|
:root { color-scheme: light dark; }
|
|
@@ -291,18 +467,32 @@ function resultPage(kind) {
|
|
|
291
467
|
h1 { font-size: 1.35rem; margin: 0 0 .5rem; color: #F8FAFC; }
|
|
292
468
|
p { margin: 0; line-height: 1.55; color: #94A3B8; }
|
|
293
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
|
+
}
|
|
294
475
|
</style>
|
|
295
476
|
</head>
|
|
296
477
|
<body>
|
|
297
478
|
<div class="card">
|
|
298
479
|
<div class="badge">${view.icon}</div>
|
|
299
480
|
<h1>${view.title}</h1>
|
|
300
|
-
<p>${view.body}</p
|
|
481
|
+
<p>${view.body}</p>${detailBlock}
|
|
301
482
|
</div>
|
|
302
483
|
</body>
|
|
303
484
|
</html>
|
|
304
485
|
`;
|
|
305
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
|
+
}
|
|
306
496
|
// ─── Browser launch ───────────────────────────────────────────────────
|
|
307
497
|
async function launchBrowser(url, override) {
|
|
308
498
|
if (override) {
|
|
@@ -332,7 +522,23 @@ async function exchangeCodeForTokens(args) {
|
|
|
332
522
|
body: body.toString(),
|
|
333
523
|
});
|
|
334
524
|
if (!res.ok) {
|
|
335
|
-
|
|
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}`);
|
|
336
542
|
}
|
|
337
543
|
const json = (await res.json());
|
|
338
544
|
return tokensFromTokenResponse(json, args.clientId);
|
|
@@ -369,18 +575,23 @@ function tokensFromTokenResponse(json, clientId, fallbackRefreshToken) {
|
|
|
369
575
|
throw new Error("Token response missing access_token");
|
|
370
576
|
const expiresIn = typeof json.expires_in === "number" ? json.expires_in : 3600;
|
|
371
577
|
const refreshToken = typeof json.refresh_token === "string" ? json.refresh_token : fallbackRefreshToken;
|
|
578
|
+
const idToken = typeof json.id_token === "string" ? json.id_token : undefined;
|
|
372
579
|
return {
|
|
373
580
|
accessToken,
|
|
374
581
|
refreshToken,
|
|
375
582
|
expiresAt: Math.floor(Date.now() / 1000) + expiresIn,
|
|
376
583
|
clientId,
|
|
377
|
-
|
|
584
|
+
displayName: displayNameFromIdToken(idToken),
|
|
378
585
|
scope: typeof json.scope === "string" ? json.scope : undefined,
|
|
379
|
-
subject: subjectFromIdToken(
|
|
586
|
+
subject: subjectFromIdToken(idToken),
|
|
380
587
|
};
|
|
381
588
|
}
|
|
382
589
|
/** Best-effort sub-claim extraction from a JWT id_token. Returns undefined on any failure. */
|
|
383
590
|
function subjectFromIdToken(idToken) {
|
|
591
|
+
return claimsFromIdToken(idToken)?.sub;
|
|
592
|
+
}
|
|
593
|
+
/** Best-effort JWT id_token payload decode. Returns undefined on any failure. */
|
|
594
|
+
function claimsFromIdToken(idToken) {
|
|
384
595
|
if (!idToken)
|
|
385
596
|
return undefined;
|
|
386
597
|
const parts = idToken.split(".");
|
|
@@ -388,9 +599,26 @@ function subjectFromIdToken(idToken) {
|
|
|
388
599
|
return undefined;
|
|
389
600
|
try {
|
|
390
601
|
const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf-8"));
|
|
391
|
-
return typeof payload
|
|
602
|
+
return payload && typeof payload === "object" ? payload : undefined;
|
|
392
603
|
}
|
|
393
604
|
catch {
|
|
394
605
|
return undefined;
|
|
395
606
|
}
|
|
396
607
|
}
|
|
608
|
+
/**
|
|
609
|
+
* Best-effort human-readable name for the signed-in user, pulled from the
|
|
610
|
+
* id_token's identity claims — `email`, then `name`, then `preferred_username`.
|
|
611
|
+
* Returns undefined when the token carries none of them (e.g. an `openid`-only
|
|
612
|
+
* token), so callers fall back to the opaque subject id.
|
|
613
|
+
*/
|
|
614
|
+
function displayNameFromIdToken(idToken) {
|
|
615
|
+
const claims = claimsFromIdToken(idToken);
|
|
616
|
+
if (!claims)
|
|
617
|
+
return undefined;
|
|
618
|
+
for (const key of ["email", "name", "preferred_username"]) {
|
|
619
|
+
const v = claims[key];
|
|
620
|
+
if (typeof v === "string" && v.trim())
|
|
621
|
+
return v.trim();
|
|
622
|
+
}
|
|
623
|
+
return undefined;
|
|
624
|
+
}
|