@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.
Files changed (133) hide show
  1. package/README.md +31 -45
  2. package/assets/commands/temporal-up.md +1 -1
  3. package/assets/skills/auth-setup/SKILL.md +1 -1
  4. package/assets/skills/local-dev/SKILL.md +17 -7
  5. package/assets/skills/ory-build-agent/SKILL.md +43 -97
  6. package/assets/skills/ory-e2b-sandbox/SKILL.md +18 -18
  7. package/assets/skills/ory-temporal-worker/SKILL.md +38 -42
  8. package/assets/skills/permissions-onboarding/SKILL.md +131 -104
  9. package/dist/adapters.d.ts +93 -30
  10. package/dist/adapters.js +464 -136
  11. package/dist/agent-auth.d.ts +258 -68
  12. package/dist/agent-auth.js +998 -202
  13. package/dist/auth-store.d.ts +37 -2
  14. package/dist/auth-store.js +37 -3
  15. package/dist/auth.d.ts +40 -4
  16. package/dist/auth.js +247 -19
  17. package/dist/bash-parser.d.ts +98 -0
  18. package/dist/bash-parser.js +396 -0
  19. package/dist/branding.d.ts +128 -0
  20. package/dist/branding.js +151 -0
  21. package/dist/build-info.json +4 -4
  22. package/dist/cli-invocation.d.ts +1 -1
  23. package/dist/cli-invocation.js +2 -1
  24. package/dist/cli.d.ts +20 -29
  25. package/dist/cli.js +271 -278
  26. package/dist/client.d.ts +175 -138
  27. package/dist/client.js +672 -391
  28. package/dist/config.d.ts +249 -57
  29. package/dist/config.js +486 -62
  30. package/dist/context.d.ts +10 -0
  31. package/dist/context.js +21 -0
  32. package/dist/contract-suite.d.ts +8 -8
  33. package/dist/contract-suite.js +88 -69
  34. package/dist/denial.d.ts +36 -3
  35. package/dist/denial.js +79 -10
  36. package/dist/event-reporter.d.ts +77 -0
  37. package/dist/event-reporter.js +776 -0
  38. package/dist/external-registrations-main.d.ts +10 -0
  39. package/dist/external-registrations-main.js +38 -0
  40. package/dist/external-registrations.d.ts +79 -0
  41. package/dist/external-registrations.js +188 -0
  42. package/dist/help-cli.d.ts +39 -0
  43. package/dist/help-cli.js +55 -0
  44. package/dist/hook-timeout.d.ts +64 -0
  45. package/dist/hook-timeout.js +88 -0
  46. package/dist/index.d.ts +31 -19
  47. package/dist/index.js +182 -31
  48. package/dist/lifecycle.d.ts +3 -3
  49. package/dist/lifecycle.js +38 -6
  50. package/dist/local/cli.js +11 -6
  51. package/dist/local/configs.d.ts +74 -18
  52. package/dist/local/configs.js +291 -84
  53. package/dist/local/health.d.ts +14 -0
  54. package/dist/local/health.js +50 -4
  55. package/dist/local/index.d.ts +2 -2
  56. package/dist/local/index.js +24 -10
  57. package/dist/local/manager.d.ts +20 -1
  58. package/dist/local/manager.js +160 -39
  59. package/dist/local/ports.d.ts +158 -0
  60. package/dist/local/ports.js +443 -0
  61. package/dist/local/seed.d.ts +22 -25
  62. package/dist/local/seed.js +88 -56
  63. package/dist/logger.d.ts +54 -25
  64. package/dist/logger.js +329 -63
  65. package/dist/mcp.d.ts +2 -2
  66. package/dist/mcp.js +10 -5
  67. package/dist/mirror-bootstrap.d.ts +48 -0
  68. package/dist/mirror-bootstrap.js +254 -0
  69. package/dist/opl.d.ts +289 -0
  70. package/dist/opl.js +446 -0
  71. package/dist/permission-mode.d.ts +87 -0
  72. package/dist/permission-mode.js +307 -0
  73. package/dist/permissions-cli.d.ts +13 -49
  74. package/dist/permissions-cli.js +154 -348
  75. package/dist/permissions.d.ts +148 -38
  76. package/dist/permissions.js +591 -45
  77. package/dist/post-install.d.ts +33 -0
  78. package/dist/post-install.js +127 -0
  79. package/dist/read-credential.d.ts +65 -0
  80. package/dist/read-credential.js +86 -0
  81. package/dist/registry/cli.js +5 -2
  82. package/dist/registry/config.d.ts +0 -17
  83. package/dist/registry/config.js +0 -23
  84. package/dist/registry/index.d.ts +1 -1
  85. package/dist/registry/index.js +2 -2
  86. package/dist/registry/manager.d.ts +4 -21
  87. package/dist/registry/manager.js +83 -55
  88. package/dist/runtime-credential.d.ts +140 -0
  89. package/dist/runtime-credential.js +572 -0
  90. package/dist/runtime.d.ts +408 -0
  91. package/dist/runtime.js +748 -0
  92. package/dist/setup.d.ts +23 -28
  93. package/dist/setup.js +57 -84
  94. package/dist/status-cli.d.ts +29 -13
  95. package/dist/status-cli.js +124 -144
  96. package/dist/status-data.d.ts +195 -0
  97. package/dist/status-data.js +333 -0
  98. package/dist/status-system.d.ts +24 -0
  99. package/dist/status-system.js +56 -0
  100. package/dist/subject.d.ts +126 -20
  101. package/dist/subject.js +215 -30
  102. package/dist/testing.d.ts +74 -38
  103. package/dist/testing.js +185 -68
  104. package/dist/tool-catalog.d.ts +53 -11
  105. package/dist/tool-catalog.js +164 -13
  106. package/dist/tool-metadata.d.ts +7 -6
  107. package/dist/tool-metadata.js +6 -5
  108. package/dist/types.d.ts +11 -1
  109. package/dist/uninstall.d.ts +74 -19
  110. package/dist/uninstall.js +224 -49
  111. package/dist/user-login.d.ts +22 -16
  112. package/dist/user-login.js +67 -96
  113. package/dist/watch-cli.d.ts +6 -0
  114. package/dist/watch-cli.js +217 -0
  115. package/package.json +3 -11
  116. package/dist/dev.d.ts +0 -103
  117. package/dist/dev.js +0 -584
  118. package/dist/interactive-setup.d.ts +0 -165
  119. package/dist/interactive-setup.js +0 -1546
  120. package/dist/local/jaeger-main.d.ts +0 -13
  121. package/dist/local/jaeger-main.js +0 -85
  122. package/dist/local/jaeger.d.ts +0 -50
  123. package/dist/local/jaeger.js +0 -162
  124. package/dist/otel/exporter.d.ts +0 -17
  125. package/dist/otel/exporter.js +0 -12
  126. package/dist/otel/index.d.ts +0 -2
  127. package/dist/otel/index.js +0 -8
  128. package/dist/otel/otlp.d.ts +0 -103
  129. package/dist/otel/otlp.js +0 -385
  130. package/dist/tracer.d.ts +0 -190
  131. package/dist/tracer.js +0 -481
  132. package/dist/watch-sandbox.d.ts +0 -9
  133. package/dist/watch-sandbox.js +0 -81
@@ -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 (e.g. the dev launcher at startup) where you
59
- * know no peer is mid-flow. Returns true when a lock was removed.
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
  /**
@@ -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: tokens },
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 (e.g. the dev launcher at startup) where you
207
- * know no peer is mid-flow. Returns true when a lock was removed.
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
- /** Hard timeout if the user never returns to the browser. */
18
- export declare const DEFAULT_LOGIN_TIMEOUT_MS = 120000;
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
- /** Hard timeout if the user never returns to the browser. */
29
- exports.DEFAULT_LOGIN_TIMEOUT_MS = 120_000;
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
- const headless = isHeadless();
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
- return { kind: "declined", reason: "token_exchange_failed" };
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
- respondHtml(res, 400, resultPage("cancelled"));
211
- settle({ kind: "declined", reason: "user_denied" });
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). Self-contained HTML
246
- * inline styles only, Ory-branded (Indigo #4F46E5), no external requests — so
247
- * it renders identically offline and on the loopback origin.
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: "#4F46E5", // Ory Indigo
410
+ accent: "#22C55E", // green — a positive "done" check
253
411
  icon: "&#10003;", // ✓
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: "&#8635;", // ↻
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, "&amp;")
491
+ .replace(/</g, "&lt;")
492
+ .replace(/>/g, "&gt;")
493
+ .replace(/"/g, "&quot;")
494
+ .replace(/'/g, "&#39;");
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
- throw new Error(`Token exchange failed: HTTP ${res.status}`);
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
- idToken: typeof json.id_token === "string" ? json.id_token : undefined,
584
+ displayName: displayNameFromIdToken(idToken),
378
585
  scope: typeof json.scope === "string" ? json.scope : undefined,
379
- subject: subjectFromIdToken(typeof json.id_token === "string" ? json.id_token : undefined),
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.sub === "string" ? payload.sub : undefined;
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
+ }