@deque/axe-auth 1.1.0-next.816dc5a1 → 1.1.0-next.82d244e3
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 +13 -17
- package/credits.json +53 -0
- package/dist/cli/commonArgs.d.ts +53 -37
- package/dist/cli/commonArgs.help.d.ts +1 -1
- package/dist/cli/commonArgs.help.js +12 -11
- package/dist/cli/commonArgs.js +37 -66
- package/dist/cli/errors.d.ts +0 -10
- package/dist/cli/errors.js +1 -16
- package/dist/cli/testUtils.js +3 -3
- package/dist/cli/types.d.ts +8 -11
- package/dist/commands/login.d.ts +3 -0
- package/dist/commands/login.help.d.ts +1 -1
- package/dist/commands/login.help.js +11 -5
- package/dist/commands/login.js +38 -14
- package/dist/commands/logout.d.ts +1 -1
- package/dist/commands/logout.help.d.ts +1 -1
- package/dist/commands/logout.help.js +5 -4
- package/dist/commands/logout.js +1 -15
- package/dist/commands/token.d.ts +2 -7
- package/dist/commands/token.help.d.ts +1 -1
- package/dist/commands/token.help.js +5 -5
- package/dist/commands/token.js +10 -22
- package/dist/index.js +23 -51
- package/dist/oauth/authorize.d.ts +7 -0
- package/dist/oauth/authorize.js +2 -1
- package/dist/oauth/discoverOIDC.js +26 -0
- package/dist/oauth/discoverSSOConfig.d.ts +47 -0
- package/dist/oauth/discoverSSOConfig.js +105 -0
- package/dist/oauth/getValidAccessToken.js +1 -0
- package/dist/oauth/openBrowser.d.ts +14 -3
- package/dist/oauth/openBrowser.js +22 -5
- package/dist/oauth/tokenStore.d.ts +13 -0
- package/dist/oauth/tokenStore.js +27 -2
- package/docs/architecture.md +27 -18
- package/package.json +6 -2
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.openBrowser = openBrowser;
|
|
4
4
|
const node_child_process_1 = require("node:child_process");
|
|
5
|
+
const shlex_1 = require("shlex");
|
|
5
6
|
const errors_1 = require("./errors");
|
|
6
7
|
// On Windows `start` is a cmd.exe builtin, not a standalone binary.
|
|
7
8
|
// The empty `""` pair is a positional placeholder for the window
|
|
@@ -26,7 +27,11 @@ function windowsCommand(url) {
|
|
|
26
27
|
args: ["/c", "start", '""', url.replace(/[&|^<>"%\r\n]/g, (c) => `^${c}`)],
|
|
27
28
|
};
|
|
28
29
|
}
|
|
29
|
-
function browserCommand(platform, url) {
|
|
30
|
+
function browserCommand(platform, url, browserOverride) {
|
|
31
|
+
if (browserOverride && browserOverride.length > 0) {
|
|
32
|
+
const [command, ...extraArgs] = browserOverride;
|
|
33
|
+
return { command, args: [...extraArgs, url] };
|
|
34
|
+
}
|
|
30
35
|
switch (platform) {
|
|
31
36
|
case "darwin":
|
|
32
37
|
return { command: "open", args: [url] };
|
|
@@ -47,16 +52,28 @@ function browserCommand(platform, url) {
|
|
|
47
52
|
}
|
|
48
53
|
/**
|
|
49
54
|
* Launches the system browser at `url` in a detached child process.
|
|
50
|
-
*
|
|
51
|
-
*
|
|
55
|
+
* Honors `$BROWSER` when set, falling back to the platform default
|
|
56
|
+
* (`open` / `xdg-open` / `cmd start`). Returns synchronously once the
|
|
57
|
+
* child is spawned — completion of the browser load is intentionally
|
|
58
|
+
* not awaited.
|
|
52
59
|
*
|
|
53
60
|
* @param url Absolute URL to open.
|
|
54
|
-
* @param options Platform/spawn overrides; only exposed for tests.
|
|
61
|
+
* @param options Platform/spawn/env overrides; only exposed for tests.
|
|
55
62
|
*/
|
|
56
63
|
function openBrowser(url, options = {}) {
|
|
57
64
|
const platform = options.platform ?? process.platform;
|
|
58
65
|
const spawnFn = options.spawnFn ?? node_child_process_1.spawn;
|
|
59
|
-
const
|
|
66
|
+
const browserEnv = options.browserEnv ?? process.env.BROWSER;
|
|
67
|
+
let browserOverride;
|
|
68
|
+
if (browserEnv && browserEnv.length > 0) {
|
|
69
|
+
try {
|
|
70
|
+
browserOverride = (0, shlex_1.split)(browserEnv);
|
|
71
|
+
}
|
|
72
|
+
catch (cause) {
|
|
73
|
+
throw new errors_1.OAuthFlowError("BROWSER_LAUNCH_FAILED", `Failed to parse $BROWSER (${browserEnv}). Open this URL manually:\n${url}`, { cause });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const { command, args } = browserCommand(platform, url, browserOverride);
|
|
60
77
|
let child;
|
|
61
78
|
try {
|
|
62
79
|
child = spawnFn(command, args, {
|
|
@@ -21,6 +21,11 @@ export interface StoredEntry {
|
|
|
21
21
|
clientId: string;
|
|
22
22
|
/** Whether the original login allowed a non-loopback http issuer. */
|
|
23
23
|
allowInsecureIssuer: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Originating axe server (walnut) URL the user supplied (or the
|
|
26
|
+
* SaaS prod default) at login.
|
|
27
|
+
*/
|
|
28
|
+
walnutURL: string;
|
|
24
29
|
}
|
|
25
30
|
/**
|
|
26
31
|
* Outcome of a `TokenStore.load()` call.
|
|
@@ -95,6 +100,14 @@ export type BlobChainResult = {
|
|
|
95
100
|
* and `MIGRATORS` and applies the latest-shape check on top.
|
|
96
101
|
*/
|
|
97
102
|
export declare function parseAndMigrateBlob(raw: string | null, expectedVersion?: number, migrators?: ReadonlyMap<number, (old: unknown) => unknown | null>): BlobChainResult;
|
|
103
|
+
/**
|
|
104
|
+
* Returns a per-platform hint appended to keychain error messages so
|
|
105
|
+
* users see actionable guidance for their OS instead of generic or
|
|
106
|
+
* Linux-only advice. Exported (but not re-exported from the package
|
|
107
|
+
* index) so tests can exercise each branch without mocking
|
|
108
|
+
* `process.platform`.
|
|
109
|
+
*/
|
|
110
|
+
export declare function platformKeyringHint(platform?: NodeJS.Platform): string;
|
|
98
111
|
/**
|
|
99
112
|
* `TokenStore` backed by the operating system's native keychain via
|
|
100
113
|
* `@napi-rs/keyring` (macOS Keychain, Windows Credential Manager, Linux
|
package/dist/oauth/tokenStore.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.KeyringTokenStore = exports.STORED_BLOB_VERSION = void 0;
|
|
4
4
|
exports.parseAndMigrateBlob = parseAndMigrateBlob;
|
|
5
|
+
exports.platformKeyringHint = platformKeyringHint;
|
|
5
6
|
const errors_1 = require("./errors");
|
|
6
7
|
const keyringBinding_1 = require("./keyringBinding");
|
|
7
8
|
// On macOS: Keychain generic password item with the service name below.
|
|
@@ -72,7 +73,9 @@ function isLatestBlob(blob) {
|
|
|
72
73
|
(b.refreshToken === undefined || typeof b.refreshToken === "string") &&
|
|
73
74
|
typeof b.issuerURL === "string" &&
|
|
74
75
|
typeof b.clientId === "string" &&
|
|
75
|
-
typeof b.allowInsecureIssuer === "boolean"
|
|
76
|
+
typeof b.allowInsecureIssuer === "boolean" &&
|
|
77
|
+
typeof b.walnutURL === "string" &&
|
|
78
|
+
b.walnutURL.length > 0);
|
|
76
79
|
}
|
|
77
80
|
function blobToEntry(blob) {
|
|
78
81
|
const tokens = {
|
|
@@ -86,6 +89,7 @@ function blobToEntry(blob) {
|
|
|
86
89
|
issuerURL: blob.issuerURL,
|
|
87
90
|
clientId: blob.clientId,
|
|
88
91
|
allowInsecureIssuer: blob.allowInsecureIssuer,
|
|
92
|
+
walnutURL: blob.walnutURL,
|
|
89
93
|
};
|
|
90
94
|
}
|
|
91
95
|
function entryToBlob(entry) {
|
|
@@ -96,6 +100,7 @@ function entryToBlob(entry) {
|
|
|
96
100
|
issuerURL: entry.issuerURL,
|
|
97
101
|
clientId: entry.clientId,
|
|
98
102
|
allowInsecureIssuer: entry.allowInsecureIssuer,
|
|
103
|
+
walnutURL: entry.walnutURL,
|
|
99
104
|
};
|
|
100
105
|
if (entry.tokens.refreshToken)
|
|
101
106
|
blob.refreshToken = entry.tokens.refreshToken;
|
|
@@ -149,7 +154,27 @@ function parseAndMigrateBlob(raw, expectedVersion = exports.STORED_BLOB_VERSION,
|
|
|
149
154
|
return { ok: true, blob: current };
|
|
150
155
|
}
|
|
151
156
|
function wrapKeyringError(op, cause) {
|
|
152
|
-
|
|
157
|
+
const causeMessage = cause instanceof Error ? cause.message : String(cause);
|
|
158
|
+
throw new errors_1.OAuthFlowError("KEYRING_UNAVAILABLE", `System keychain ${op} failed: ${causeMessage}. ${platformKeyringHint()}`, { cause });
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Returns a per-platform hint appended to keychain error messages so
|
|
162
|
+
* users see actionable guidance for their OS instead of generic or
|
|
163
|
+
* Linux-only advice. Exported (but not re-exported from the package
|
|
164
|
+
* index) so tests can exercise each branch without mocking
|
|
165
|
+
* `process.platform`.
|
|
166
|
+
*/
|
|
167
|
+
function platformKeyringHint(platform = process.platform) {
|
|
168
|
+
switch (platform) {
|
|
169
|
+
case "darwin":
|
|
170
|
+
return "On macOS this usually means Keychain Access denied or cancelled the prompt.";
|
|
171
|
+
case "win32":
|
|
172
|
+
return "On Windows this usually means Credential Manager rejected the operation.";
|
|
173
|
+
case "linux":
|
|
174
|
+
return "On Linux this usually means no D-Bus Secret Service is running (e.g. GNOME Keyring or KWallet).";
|
|
175
|
+
default:
|
|
176
|
+
return `Underlying platform: ${platform}.`;
|
|
177
|
+
}
|
|
153
178
|
}
|
|
154
179
|
/**
|
|
155
180
|
* `TokenStore` backed by the operating system's native keychain via
|
package/docs/architecture.md
CHANGED
|
@@ -13,9 +13,11 @@ flowchart TB
|
|
|
13
13
|
browser[System browser]
|
|
14
14
|
callback[Loopback callback server<br/>127.0.0.1:ephemeral]
|
|
15
15
|
keychain[(OS keychain)]
|
|
16
|
+
axe[axe server]
|
|
16
17
|
keycloak[Customer Keycloak]
|
|
17
18
|
|
|
18
19
|
user -- "axe-auth login / token / logout" --> cli
|
|
20
|
+
cli -- "GET /api/sso-config (login only)" --> axe
|
|
19
21
|
cli -- "spawns" --> callback
|
|
20
22
|
cli -- "opens" --> browser
|
|
21
23
|
browser -- "authorize redirect<br/>(state, PKCE challenge)" --> keycloak
|
|
@@ -23,7 +25,7 @@ flowchart TB
|
|
|
23
25
|
browser -- "GET /callback?code=...&state=..." --> callback
|
|
24
26
|
callback -- "code, state" --> cli
|
|
25
27
|
cli <-- "OIDC discovery, token exchange,<br/>refresh, revoke (HTTPS)" --> keycloak
|
|
26
|
-
cli <-- "tokens + issuer/client<br/>(versioned blob)" --> keychain
|
|
28
|
+
cli <-- "tokens + issuer/client/walnutURL<br/>(versioned blob)" --> keychain
|
|
27
29
|
cli -- "access token (stdout)" --> user
|
|
28
30
|
```
|
|
29
31
|
|
|
@@ -34,7 +36,8 @@ flowchart TB
|
|
|
34
36
|
3. **System browser**: the developer's default OS browser (Chrome, Safari, Firefox, etc.). Used only for the user-interactive part of the OAuth Authorization Code flow. Runs on the host, never in a container or sandbox controlled by `axe-auth`.
|
|
35
37
|
4. **Loopback callback server**: an HTTP listener bound to `127.0.0.1` on an OS-assigned ephemeral port. Spawned by the CLI at the start of `login` and torn down as soon as the OAuth callback fires. Per RFC 8252 §7.3, this is the standard pattern for native-app OAuth.
|
|
36
38
|
5. **OS keychain**: the platform-native credential store accessed through [`@napi-rs/keyring`](https://www.npmjs.com/package/@napi-rs/keyring) — macOS Keychain, Windows Credential Manager, or Linux Secret Service (GNOME Keyring, KWallet). The CLI writes one entry per machine.
|
|
37
|
-
6. **
|
|
39
|
+
6. **axe server**: the customer's deployment of the axe API. The CLI hits its `/api/sso-config` endpoint at the start of `login` to discover the Keycloak URL, realm, and OAuth client ID; no other CLI traffic flows through the axe server.
|
|
40
|
+
7. **Customer Keycloak**: the OAuth authorization server for the customer's deployment. Issues access and refresh tokens. Federation between Keycloak and any upstream enterprise IdP (Okta, AAD, etc.) is the customer's concern and out of scope for this document.
|
|
38
41
|
|
|
39
42
|
`axe-auth` itself does **not** communicate with Deque's API services directly. The access tokens it produces are consumed by downstream tools, most notably the axe MCP server, which presents them to Deque's services as `Authorization: Bearer ...`.
|
|
40
43
|
|
|
@@ -48,9 +51,12 @@ sequenceDiagram
|
|
|
48
51
|
participant CLI as axe-auth CLI
|
|
49
52
|
participant Browser as System browser
|
|
50
53
|
participant CB as Loopback callback<br/>(127.0.0.1:port)
|
|
54
|
+
participant Axe as axe server
|
|
51
55
|
participant KC as Customer Keycloak
|
|
52
56
|
|
|
53
|
-
User->>CLI: axe-auth login [
|
|
57
|
+
User->>CLI: axe-auth login [--server <axe-url>]
|
|
58
|
+
CLI->>Axe: GET /api/sso-config
|
|
59
|
+
Axe-->>CLI: { url, realm, mcpClientId }
|
|
54
60
|
CLI->>KC: GET /.well-known/openid-configuration
|
|
55
61
|
KC-->>CLI: { authorization_endpoint, token_endpoint, ... }
|
|
56
62
|
CLI->>CB: spawn on 127.0.0.1:<ephemeral>
|
|
@@ -63,21 +69,22 @@ sequenceDiagram
|
|
|
63
69
|
CB-->>CLI: { code, state }
|
|
64
70
|
CLI->>KC: POST /token (code, code_verifier)
|
|
65
71
|
KC-->>CLI: { access_token, refresh_token, expires_in }
|
|
66
|
-
Note over CLI: save tokens + issuer/client to OS keychain
|
|
72
|
+
Note over CLI: save tokens + issuer/client/walnutURL to OS keychain
|
|
67
73
|
CLI-->>User: ✓ Authenticated.
|
|
68
74
|
```
|
|
69
75
|
|
|
70
|
-
1. The developer invokes `axe-auth login
|
|
71
|
-
2. The CLI fetches
|
|
72
|
-
3.
|
|
73
|
-
4. The CLI
|
|
74
|
-
5. The CLI
|
|
75
|
-
6. The developer
|
|
76
|
-
7.
|
|
77
|
-
8.
|
|
78
|
-
9. The
|
|
79
|
-
10. The CLI
|
|
80
|
-
11. The CLI
|
|
76
|
+
1. The developer invokes `axe-auth login`, optionally with `--server <axe-url>` (or `AXE_SERVER_URL`); when neither is set the CLI defaults to Deque's SaaS prod axe server.
|
|
77
|
+
2. The CLI fetches `<axe-server>/api/sso-config` to learn the Keycloak base URL, realm, and OAuth client ID. The axe server returns `mcpClientId: null` when the deployment has not been configured for OAuth-based MCP authentication, and the field is absent on older axe server versions; both cases surface as a clear error before any browser is opened.
|
|
78
|
+
3. With the discovered coordinates the CLI fetches the OIDC discovery document at `<issuer>/.well-known/openid-configuration` to learn the authorization, token, and revocation endpoint URLs.
|
|
79
|
+
4. The CLI generates a PKCE `code_verifier` + `code_challenge` (S256) and a random `state` value.
|
|
80
|
+
5. The CLI starts a loopback HTTP listener on `127.0.0.1` at an OS-assigned port.
|
|
81
|
+
6. The CLI opens the developer's system browser to the authorization endpoint with the PKCE challenge, the state, and the loopback `redirect_uri`.
|
|
82
|
+
7. The developer authenticates with Keycloak (typically via the customer's federated SSO).
|
|
83
|
+
8. Keycloak redirects the browser to the loopback `redirect_uri` with an authorization `code` and the original `state`.
|
|
84
|
+
9. The loopback listener validates `state`, captures the `code`, and renders a small success page so the developer knows they can close the tab.
|
|
85
|
+
10. The CLI POSTs `code` + `code_verifier` to Keycloak's token endpoint and receives an `access_token`, `refresh_token`, and `expires_in`.
|
|
86
|
+
11. The CLI persists the resulting `StoredEntry` (tokens plus the issuer/client coordinates that minted them, plus the originating axe server URL) into the OS keychain.
|
|
87
|
+
12. The CLI prints `✓ Authenticated.` on stdout and exits 0.
|
|
81
88
|
|
|
82
89
|
### `axe-auth token`
|
|
83
90
|
|
|
@@ -173,14 +180,16 @@ sequenceDiagram
|
|
|
173
180
|
"accessToken": "...",
|
|
174
181
|
"refreshToken": "...",
|
|
175
182
|
"expiresAt": 1714426800000,
|
|
176
|
-
"issuerURL": "https://auth.customer.example.com/realms/customer",
|
|
177
|
-
"clientId": "axe-auth",
|
|
178
|
-
"allowInsecureIssuer": false
|
|
183
|
+
"issuerURL": "https://auth.customer.example.com/auth/realms/customer",
|
|
184
|
+
"clientId": "axe-auth-cli",
|
|
185
|
+
"allowInsecureIssuer": false,
|
|
186
|
+
"walnutURL": "https://axe.customer.example.com"
|
|
179
187
|
}
|
|
180
188
|
```
|
|
181
189
|
|
|
182
190
|
- **Tokens** (`accessToken`, `refreshToken`, `expiresAt`): the OAuth token set returned by Keycloak. `refreshToken` is omitted if the granted scopes did not include `offline_access`.
|
|
183
191
|
- **Issuer / client coordinates** (`issuerURL`, `clientId`, `allowInsecureIssuer`): the values the tokens were minted against. Persisting them lets `token` and `logout` operate flag-free after first login: the CLI resolves the right discovery URL, token endpoint, and revocation endpoint from the stored values, with no separate "default issuer" pointer to drift out of sync with the tokens themselves.
|
|
192
|
+
- **`walnutURL`**: the originating axe server URL that the SSO discovery used to resolve the OAuth coordinates. Persisted so future verbs can re-discover `/api/sso-config` without user-supplied flags.
|
|
184
193
|
- **Schema version** (`v`): incremented when the blob shape changes incompatibly. A mismatch surfaces as `version-mismatch` from `KeyringTokenStore.load()`, and the CLI prompts re-authentication rather than guessing at unknown shapes.
|
|
185
194
|
|
|
186
195
|
No other persistent state exists. There is no filesystem cache of OIDC discovery documents (each `login` and `logout` re-fetches), no separate config file, and no logs written to disk by default.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@deque/axe-auth",
|
|
3
|
-
"version": "1.1.0-next.
|
|
3
|
+
"version": "1.1.0-next.82d244e3",
|
|
4
4
|
"description": "CLI authentication utility for Deque services",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"type": "commonjs",
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
"files": [
|
|
13
13
|
"dist",
|
|
14
14
|
"!dist/**/*.test.*",
|
|
15
|
-
"docs"
|
|
15
|
+
"docs",
|
|
16
|
+
"credits.json"
|
|
16
17
|
],
|
|
17
18
|
"publishConfig": {
|
|
18
19
|
"access": "public",
|
|
@@ -24,11 +25,14 @@
|
|
|
24
25
|
"dependencies": {
|
|
25
26
|
"@napi-rs/keyring": "^1.2.0",
|
|
26
27
|
"remove-trailing-slash": "^0.1.1",
|
|
28
|
+
"shlex": "^3.0.0",
|
|
27
29
|
"ts-dedent": "^2.2.0"
|
|
28
30
|
},
|
|
29
31
|
"devDependencies": {
|
|
32
|
+
"@hono/node-server": "^1.19.14",
|
|
30
33
|
"@types/node": "^22.13.10",
|
|
31
34
|
"c8": "^10.1.3",
|
|
35
|
+
"hono": "^4.12.16",
|
|
32
36
|
"tsx": "^4.20.6",
|
|
33
37
|
"typescript": "^5.9.3"
|
|
34
38
|
},
|