@deque/axe-auth 1.1.0-next.6ad261c8 → 1.1.0-next.907ffbd7
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 +14 -8
- package/dist/oauth/authorizationURL.d.ts +29 -0
- package/dist/oauth/authorizationURL.js +52 -0
- package/dist/oauth/authorize.d.ts +83 -0
- package/dist/oauth/authorize.js +114 -0
- package/dist/oauth/discoverOIDC.d.ts +50 -0
- package/dist/oauth/discoverOIDC.js +145 -0
- package/dist/oauth/errors.d.ts +53 -2
- package/dist/oauth/errors.js +35 -1
- package/dist/oauth/index.d.ts +9 -2
- package/dist/oauth/index.js +9 -1
- package/dist/oauth/issuerURL.d.ts +22 -0
- package/dist/oauth/issuerURL.js +38 -0
- package/dist/oauth/openBrowser.d.ts +19 -0
- package/dist/oauth/openBrowser.js +78 -0
- package/dist/oauth/pkce.d.ts +17 -0
- package/dist/oauth/pkce.js +43 -0
- package/dist/oauth/tokenExchange.d.ts +49 -0
- package/dist/oauth/tokenExchange.js +136 -0
- package/dist/oauth/tokenStore.d.ts +78 -0
- package/dist/oauth/tokenStore.js +176 -0
- package/package.json +8 -2
package/dist/oauth/index.js
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.OAuthCallbackError = exports.startCallbackServer = void 0;
|
|
3
|
+
exports.discoverOIDC = exports.STORED_BLOB_VERSION = exports.KeyringTokenStore = exports.authorize = exports.OAuthFlowError = exports.OAuthCallbackError = exports.startCallbackServer = void 0;
|
|
4
4
|
var callbackServer_1 = require("./callbackServer");
|
|
5
5
|
Object.defineProperty(exports, "startCallbackServer", { enumerable: true, get: function () { return callbackServer_1.startCallbackServer; } });
|
|
6
6
|
var errors_1 = require("./errors");
|
|
7
7
|
Object.defineProperty(exports, "OAuthCallbackError", { enumerable: true, get: function () { return errors_1.OAuthCallbackError; } });
|
|
8
|
+
Object.defineProperty(exports, "OAuthFlowError", { enumerable: true, get: function () { return errors_1.OAuthFlowError; } });
|
|
9
|
+
var authorize_1 = require("./authorize");
|
|
10
|
+
Object.defineProperty(exports, "authorize", { enumerable: true, get: function () { return authorize_1.authorize; } });
|
|
11
|
+
var tokenStore_1 = require("./tokenStore");
|
|
12
|
+
Object.defineProperty(exports, "KeyringTokenStore", { enumerable: true, get: function () { return tokenStore_1.KeyringTokenStore; } });
|
|
13
|
+
Object.defineProperty(exports, "STORED_BLOB_VERSION", { enumerable: true, get: function () { return tokenStore_1.STORED_BLOB_VERSION; } });
|
|
14
|
+
var discoverOIDC_1 = require("./discoverOIDC");
|
|
15
|
+
Object.defineProperty(exports, "discoverOIDC", { enumerable: true, get: function () { return discoverOIDC_1.discoverOIDC; } });
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonicalizes an OIDC issuer URL for equivalence comparison. Two URLs
|
|
3
|
+
* that normalize to the same string refer to the same issuer, which is
|
|
4
|
+
* what `discoverOIDC` uses to build discovery URLs and what
|
|
5
|
+
* `KeyringTokenStore` uses to key keychain entries.
|
|
6
|
+
*
|
|
7
|
+
* Rules (per RFC 3986 §6.2 URI comparison):
|
|
8
|
+
* - Trailing slashes stripped from the path.
|
|
9
|
+
* - Scheme and authority (host + optional port) lowercased — both are
|
|
10
|
+
* case-insensitive per the RFC.
|
|
11
|
+
* - Default ports (80 for http, 443 for https) collapsed via `URL.host`.
|
|
12
|
+
* - Path preserved case-sensitively.
|
|
13
|
+
* - Query string and fragment dropped: OIDC issuers are defined by
|
|
14
|
+
* scheme + authority + path, and carrying them through would break
|
|
15
|
+
* downstream path concatenation (e.g. appending
|
|
16
|
+
* `/.well-known/openid-configuration`).
|
|
17
|
+
*
|
|
18
|
+
* If the input is not a parseable URL, the function trims trailing
|
|
19
|
+
* slashes and returns — discovery will surface a clearer error than
|
|
20
|
+
* this function could.
|
|
21
|
+
*/
|
|
22
|
+
export declare function normalizeIssuerURL(url: string): string;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.normalizeIssuerURL = normalizeIssuerURL;
|
|
4
|
+
/**
|
|
5
|
+
* Canonicalizes an OIDC issuer URL for equivalence comparison. Two URLs
|
|
6
|
+
* that normalize to the same string refer to the same issuer, which is
|
|
7
|
+
* what `discoverOIDC` uses to build discovery URLs and what
|
|
8
|
+
* `KeyringTokenStore` uses to key keychain entries.
|
|
9
|
+
*
|
|
10
|
+
* Rules (per RFC 3986 §6.2 URI comparison):
|
|
11
|
+
* - Trailing slashes stripped from the path.
|
|
12
|
+
* - Scheme and authority (host + optional port) lowercased — both are
|
|
13
|
+
* case-insensitive per the RFC.
|
|
14
|
+
* - Default ports (80 for http, 443 for https) collapsed via `URL.host`.
|
|
15
|
+
* - Path preserved case-sensitively.
|
|
16
|
+
* - Query string and fragment dropped: OIDC issuers are defined by
|
|
17
|
+
* scheme + authority + path, and carrying them through would break
|
|
18
|
+
* downstream path concatenation (e.g. appending
|
|
19
|
+
* `/.well-known/openid-configuration`).
|
|
20
|
+
*
|
|
21
|
+
* If the input is not a parseable URL, the function trims trailing
|
|
22
|
+
* slashes and returns — discovery will surface a clearer error than
|
|
23
|
+
* this function could.
|
|
24
|
+
*/
|
|
25
|
+
function normalizeIssuerURL(url) {
|
|
26
|
+
let parsed;
|
|
27
|
+
try {
|
|
28
|
+
parsed = new URL(url);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return url.replace(/\/+$/, "");
|
|
32
|
+
}
|
|
33
|
+
// `URL.protocol` is already lowercased by the URL parser.
|
|
34
|
+
// `URL.host` retains input casing, so lowercase it explicitly.
|
|
35
|
+
const host = parsed.host.toLowerCase();
|
|
36
|
+
const pathname = parsed.pathname.replace(/\/+$/, "");
|
|
37
|
+
return `${parsed.protocol}//${host}${pathname}`;
|
|
38
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ChildProcess, SpawnOptions } from "node:child_process";
|
|
2
|
+
/** Injection seam for `child_process.spawn`. Used by tests. */
|
|
3
|
+
export type SpawnFn = (command: string, args: readonly string[], options: SpawnOptions) => ChildProcess;
|
|
4
|
+
/** Options for `openBrowser`. */
|
|
5
|
+
export interface OpenBrowserOptions {
|
|
6
|
+
/** Override for `process.platform`. Used by tests. */
|
|
7
|
+
platform?: NodeJS.Platform;
|
|
8
|
+
/** Override for `child_process.spawn`. Used by tests. */
|
|
9
|
+
spawnFn?: SpawnFn;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Launches the system browser at `url` in a detached child process.
|
|
13
|
+
* Returns synchronously once the child is spawned — completion of the
|
|
14
|
+
* browser load is intentionally not awaited.
|
|
15
|
+
*
|
|
16
|
+
* @param url Absolute URL to open.
|
|
17
|
+
* @param options Platform/spawn overrides; only exposed for tests.
|
|
18
|
+
*/
|
|
19
|
+
export declare function openBrowser(url: string, options?: OpenBrowserOptions): void;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.openBrowser = openBrowser;
|
|
4
|
+
const node_child_process_1 = require("node:child_process");
|
|
5
|
+
const errors_1 = require("./errors");
|
|
6
|
+
// On Windows `start` is a cmd.exe builtin, not a standalone binary.
|
|
7
|
+
// The empty `""` pair is a positional placeholder for the window
|
|
8
|
+
// title — without it `start` treats the URL as the title.
|
|
9
|
+
//
|
|
10
|
+
// The escape class covers:
|
|
11
|
+
// - `& | ^ < >` — cmd metacharacters that would otherwise split the
|
|
12
|
+
// command line.
|
|
13
|
+
// - `"` — would prematurely terminate the argument and break
|
|
14
|
+
// `start`'s quoting.
|
|
15
|
+
// - `%` — triggers cmd.exe environment-variable expansion (e.g.
|
|
16
|
+
// `%USERNAME%`), which could leak or alter the URL.
|
|
17
|
+
// - `\r \n` — embedded newlines let a crafted URL inject additional
|
|
18
|
+
// commands onto cmd.exe's line.
|
|
19
|
+
//
|
|
20
|
+
// URLs normally percent-encode most of these (so this is defense in
|
|
21
|
+
// depth), but we do not fully trust the authorization endpoint that
|
|
22
|
+
// came back from discovery.
|
|
23
|
+
function windowsCommand(url) {
|
|
24
|
+
return {
|
|
25
|
+
command: "cmd.exe",
|
|
26
|
+
args: ["/c", "start", '""', url.replace(/[&|^<>"%\r\n]/g, (c) => `^${c}`)],
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
function browserCommand(platform, url) {
|
|
30
|
+
switch (platform) {
|
|
31
|
+
case "darwin":
|
|
32
|
+
return { command: "open", args: [url] };
|
|
33
|
+
case "win32":
|
|
34
|
+
return windowsCommand(url);
|
|
35
|
+
default:
|
|
36
|
+
// linux / freebsd / openbsd — xdg-open is part of xdg-utils which
|
|
37
|
+
// is near-universal on desktop Linux. Environments without it
|
|
38
|
+
// (headless servers, containers) will report the missing binary
|
|
39
|
+
// via an asynchronous child-process `error` event, which this
|
|
40
|
+
// module deliberately swallows (see `child.once("error", ...)`
|
|
41
|
+
// below); `BROWSER_LAUNCH_FAILED` is only raised for synchronous
|
|
42
|
+
// `spawn()` throws. The caller's fallback is the URL already
|
|
43
|
+
// surfaced via `onAuthorizationUrl` so the user can finish the
|
|
44
|
+
// flow manually.
|
|
45
|
+
return { command: "xdg-open", args: [url] };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Launches the system browser at `url` in a detached child process.
|
|
50
|
+
* Returns synchronously once the child is spawned — completion of the
|
|
51
|
+
* browser load is intentionally not awaited.
|
|
52
|
+
*
|
|
53
|
+
* @param url Absolute URL to open.
|
|
54
|
+
* @param options Platform/spawn overrides; only exposed for tests.
|
|
55
|
+
*/
|
|
56
|
+
function openBrowser(url, options = {}) {
|
|
57
|
+
const platform = options.platform ?? process.platform;
|
|
58
|
+
const spawnFn = options.spawnFn ?? node_child_process_1.spawn;
|
|
59
|
+
const { command, args } = browserCommand(platform, url);
|
|
60
|
+
let child;
|
|
61
|
+
try {
|
|
62
|
+
child = spawnFn(command, args, {
|
|
63
|
+
detached: true,
|
|
64
|
+
stdio: "ignore",
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
catch (cause) {
|
|
68
|
+
throw new errors_1.OAuthFlowError("BROWSER_LAUNCH_FAILED", `Failed to launch the system browser (${command}). Open this URL manually:\n${url}`, { cause });
|
|
69
|
+
}
|
|
70
|
+
// `spawn` itself can succeed (the parent fork was fine) and then emit
|
|
71
|
+
// `error` asynchronously if the binary isn't on PATH. We can't surface
|
|
72
|
+
// that synchronously, but attaching a handler prevents the default
|
|
73
|
+
// "unhandled error" crash. Callers get a benign no-op if the browser
|
|
74
|
+
// never opens; the authorize() orchestrator prints the URL alongside
|
|
75
|
+
// the launch so the user has a fallback.
|
|
76
|
+
child.once("error", () => { });
|
|
77
|
+
child.unref();
|
|
78
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generates a cryptographically random PKCE `code_verifier` per RFC 7636
|
|
3
|
+
* §4.1. 43 base64url characters, 256 bits of entropy.
|
|
4
|
+
*/
|
|
5
|
+
export declare function generateCodeVerifier(): string;
|
|
6
|
+
/**
|
|
7
|
+
* Derives the PKCE S256 `code_challenge` for the given verifier per
|
|
8
|
+
* RFC 7636 §4.2: `BASE64URL(SHA256(ASCII(verifier)))`.
|
|
9
|
+
*
|
|
10
|
+
* @param verifier The PKCE verifier produced by `generateCodeVerifier`.
|
|
11
|
+
*/
|
|
12
|
+
export declare function deriveCodeChallenge(verifier: string): string;
|
|
13
|
+
/**
|
|
14
|
+
* Generates a cryptographically random OAuth `state` value for CSRF
|
|
15
|
+
* protection. 22 base64url characters, 128 bits of entropy.
|
|
16
|
+
*/
|
|
17
|
+
export declare function generateState(): string;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.generateCodeVerifier = generateCodeVerifier;
|
|
4
|
+
exports.deriveCodeChallenge = deriveCodeChallenge;
|
|
5
|
+
exports.generateState = generateState;
|
|
6
|
+
const node_crypto_1 = require("node:crypto");
|
|
7
|
+
// PKCE per RFC 7636. We only ever emit S256; plain is permitted by the RFC
|
|
8
|
+
// but explicitly disallowed by our authorization-server config so it can't
|
|
9
|
+
// silently fall back in the face of a buggy server.
|
|
10
|
+
/**
|
|
11
|
+
* Entropy for the PKCE `code_verifier`. 32 bytes yields 43 base64url chars
|
|
12
|
+
* (no padding), the minimum length RFC 7636 allows (43–128). 256 bits
|
|
13
|
+
* matches the S256 hash's security ceiling.
|
|
14
|
+
*/
|
|
15
|
+
const VERIFIER_ENTROPY_BYTES = 32;
|
|
16
|
+
/**
|
|
17
|
+
* Entropy for the CSRF `state` parameter. 16 bytes yields 22 base64url
|
|
18
|
+
* chars — unguessable without bloating the authorization URL.
|
|
19
|
+
*/
|
|
20
|
+
const STATE_ENTROPY_BYTES = 16;
|
|
21
|
+
/**
|
|
22
|
+
* Generates a cryptographically random PKCE `code_verifier` per RFC 7636
|
|
23
|
+
* §4.1. 43 base64url characters, 256 bits of entropy.
|
|
24
|
+
*/
|
|
25
|
+
function generateCodeVerifier() {
|
|
26
|
+
return (0, node_crypto_1.randomBytes)(VERIFIER_ENTROPY_BYTES).toString("base64url");
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Derives the PKCE S256 `code_challenge` for the given verifier per
|
|
30
|
+
* RFC 7636 §4.2: `BASE64URL(SHA256(ASCII(verifier)))`.
|
|
31
|
+
*
|
|
32
|
+
* @param verifier The PKCE verifier produced by `generateCodeVerifier`.
|
|
33
|
+
*/
|
|
34
|
+
function deriveCodeChallenge(verifier) {
|
|
35
|
+
return (0, node_crypto_1.createHash)("sha256").update(verifier, "ascii").digest("base64url");
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Generates a cryptographically random OAuth `state` value for CSRF
|
|
39
|
+
* protection. 22 base64url characters, 128 bits of entropy.
|
|
40
|
+
*/
|
|
41
|
+
function generateState() {
|
|
42
|
+
return (0, node_crypto_1.randomBytes)(STATE_ENTROPY_BYTES).toString("base64url");
|
|
43
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tokens returned by a successful authorization-code exchange.
|
|
3
|
+
*
|
|
4
|
+
* `refreshToken` is optional because not all flows return one —
|
|
5
|
+
* callers that did not request `offline_access` (or the provider
|
|
6
|
+
* equivalent) will receive only an access token. Refresh logic (issue
|
|
7
|
+
* #422) must handle this case.
|
|
8
|
+
*
|
|
9
|
+
* `grantedScope` reflects the authorization server's `scope` response
|
|
10
|
+
* field when present (RFC 6749 §5.1 says `scope` is required when the
|
|
11
|
+
* granted set differs from the requested set; optional otherwise).
|
|
12
|
+
* Callers comparing granted vs requested to surface diagnostics should
|
|
13
|
+
* read this field.
|
|
14
|
+
*/
|
|
15
|
+
export interface TokenSet {
|
|
16
|
+
/** Access token for authenticated API calls. */
|
|
17
|
+
accessToken: string;
|
|
18
|
+
/** Long-lived token used to mint new access tokens without re-auth. Absent if the flow did not request it. */
|
|
19
|
+
refreshToken?: string;
|
|
20
|
+
/** Absolute timestamp (ms since epoch) when the access token expires. */
|
|
21
|
+
expiresAt: number;
|
|
22
|
+
/** Space-delimited scopes the server actually granted, if reported. */
|
|
23
|
+
grantedScope?: string;
|
|
24
|
+
}
|
|
25
|
+
/** Options for `exchangeCodeForTokens`. */
|
|
26
|
+
export interface ExchangeCodeForTokensOptions {
|
|
27
|
+
/** Token endpoint resolved from OIDC discovery. */
|
|
28
|
+
tokenEndpoint: string;
|
|
29
|
+
/** OAuth client identifier. */
|
|
30
|
+
clientId: string;
|
|
31
|
+
/** Authorization code received via the loopback callback. */
|
|
32
|
+
code: string;
|
|
33
|
+
/** PKCE verifier paired with the `code_challenge` sent at auth time. */
|
|
34
|
+
codeVerifier: string;
|
|
35
|
+
/** Redirect URI originally sent to the authorization endpoint. */
|
|
36
|
+
redirectUri: string;
|
|
37
|
+
/** Source of `now`. Injected for test determinism; defaults to `Date.now`. */
|
|
38
|
+
now?: () => number;
|
|
39
|
+
/** Aborts the underlying fetch when fired. */
|
|
40
|
+
signal?: AbortSignal;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Exchanges an authorization code for a `TokenSet` via the
|
|
44
|
+
* authorization server's token endpoint (RFC 6749 §4.1.3 + RFC 7636
|
|
45
|
+
* §4.5). Rejects with `OAuthFlowError("TOKEN_EXCHANGE_FAILED", ...)`
|
|
46
|
+
* for any failure mode, surfacing the OAuth `error` /
|
|
47
|
+
* `error_description` when available.
|
|
48
|
+
*/
|
|
49
|
+
export declare function exchangeCodeForTokens(options: ExchangeCodeForTokensOptions): Promise<TokenSet>;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.exchangeCodeForTokens = exchangeCodeForTokens;
|
|
4
|
+
const errors_1 = require("./errors");
|
|
5
|
+
function isNonEmptyString(v) {
|
|
6
|
+
return typeof v === "string" && v.length > 0;
|
|
7
|
+
}
|
|
8
|
+
// RFC 6749 §5.1 describes `expires_in` as "the lifetime in seconds"
|
|
9
|
+
// without pinning the JSON type, and some providers historically send
|
|
10
|
+
// numeric strings. Accept both; reject anything non-positive or
|
|
11
|
+
// non-finite.
|
|
12
|
+
function parseExpiresIn(v) {
|
|
13
|
+
if (typeof v === "number" && Number.isFinite(v) && v > 0)
|
|
14
|
+
return v;
|
|
15
|
+
if (typeof v === "string") {
|
|
16
|
+
const n = Number(v);
|
|
17
|
+
if (Number.isFinite(n) && n > 0)
|
|
18
|
+
return n;
|
|
19
|
+
}
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
function parseErrorBody(body) {
|
|
23
|
+
let parsed;
|
|
24
|
+
try {
|
|
25
|
+
parsed = JSON.parse(body);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return {};
|
|
29
|
+
}
|
|
30
|
+
if (parsed === null || typeof parsed !== "object")
|
|
31
|
+
return {};
|
|
32
|
+
const raw = parsed;
|
|
33
|
+
return {
|
|
34
|
+
error: isNonEmptyString(raw.error) ? raw.error : undefined,
|
|
35
|
+
description: isNonEmptyString(raw.error_description)
|
|
36
|
+
? raw.error_description
|
|
37
|
+
: undefined,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function throwFromErrorResponse(status, body) {
|
|
41
|
+
const { error, description } = parseErrorBody(body);
|
|
42
|
+
const suffix = error
|
|
43
|
+
? description
|
|
44
|
+
? `: ${error}: ${description}`
|
|
45
|
+
: `: ${error}`
|
|
46
|
+
: "";
|
|
47
|
+
const details = {};
|
|
48
|
+
if (error)
|
|
49
|
+
details.error = error;
|
|
50
|
+
if (description)
|
|
51
|
+
details.error_description = description;
|
|
52
|
+
throw new errors_1.OAuthFlowError("TOKEN_EXCHANGE_FAILED", `Token exchange failed with HTTP ${status}${suffix}`, Object.keys(details).length > 0 ? { details } : undefined);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Exchanges an authorization code for a `TokenSet` via the
|
|
56
|
+
* authorization server's token endpoint (RFC 6749 §4.1.3 + RFC 7636
|
|
57
|
+
* §4.5). Rejects with `OAuthFlowError("TOKEN_EXCHANGE_FAILED", ...)`
|
|
58
|
+
* for any failure mode, surfacing the OAuth `error` /
|
|
59
|
+
* `error_description` when available.
|
|
60
|
+
*/
|
|
61
|
+
async function exchangeCodeForTokens(options) {
|
|
62
|
+
const now = options.now ?? Date.now;
|
|
63
|
+
const body = new URLSearchParams({
|
|
64
|
+
grant_type: "authorization_code",
|
|
65
|
+
client_id: options.clientId,
|
|
66
|
+
code: options.code,
|
|
67
|
+
code_verifier: options.codeVerifier,
|
|
68
|
+
redirect_uri: options.redirectUri,
|
|
69
|
+
});
|
|
70
|
+
// Capture `issuedAt` before the network call so we don't drift past
|
|
71
|
+
// expiry just because the network was slow. Slightly conservative —
|
|
72
|
+
// the token actually expires `expires_in` seconds from when the
|
|
73
|
+
// server issued it, so the effective usable window is `expires_in -
|
|
74
|
+
// RTT`, which errs toward "expires sooner" rather than "expires
|
|
75
|
+
// later." That's the safer direction for any consumer doing
|
|
76
|
+
// pre-expiry checks.
|
|
77
|
+
const issuedAt = now();
|
|
78
|
+
let response;
|
|
79
|
+
try {
|
|
80
|
+
response = await fetch(options.tokenEndpoint, {
|
|
81
|
+
method: "POST",
|
|
82
|
+
headers: {
|
|
83
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
84
|
+
Accept: "application/json",
|
|
85
|
+
},
|
|
86
|
+
body,
|
|
87
|
+
signal: options.signal,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
catch (cause) {
|
|
91
|
+
throw new errors_1.OAuthFlowError("TOKEN_EXCHANGE_FAILED", `Could not reach the token endpoint at ${options.tokenEndpoint}. Check your network connection.`, { cause });
|
|
92
|
+
}
|
|
93
|
+
if (!response.ok) {
|
|
94
|
+
const text = await response.text().catch(() => "");
|
|
95
|
+
throwFromErrorResponse(response.status, text);
|
|
96
|
+
}
|
|
97
|
+
let parsed;
|
|
98
|
+
try {
|
|
99
|
+
parsed = await response.json();
|
|
100
|
+
}
|
|
101
|
+
catch (cause) {
|
|
102
|
+
throw new errors_1.OAuthFlowError("TOKEN_EXCHANGE_FAILED", `Token endpoint at ${options.tokenEndpoint} returned a non-JSON response`, { cause });
|
|
103
|
+
}
|
|
104
|
+
if (parsed === null || typeof parsed !== "object") {
|
|
105
|
+
throw new errors_1.OAuthFlowError("TOKEN_EXCHANGE_FAILED", `Token endpoint at ${options.tokenEndpoint} returned a non-object response`);
|
|
106
|
+
}
|
|
107
|
+
const raw = parsed;
|
|
108
|
+
if (!isNonEmptyString(raw.access_token)) {
|
|
109
|
+
throw new errors_1.OAuthFlowError("TOKEN_EXCHANGE_FAILED", `Token response missing 'access_token'`);
|
|
110
|
+
}
|
|
111
|
+
const expiresIn = parseExpiresIn(raw.expires_in);
|
|
112
|
+
if (expiresIn === null) {
|
|
113
|
+
throw new errors_1.OAuthFlowError("TOKEN_EXCHANGE_FAILED", `Token response missing or has invalid 'expires_in'`);
|
|
114
|
+
}
|
|
115
|
+
// RFC 6749 §5.1: token_type is REQUIRED. We only speak Bearer;
|
|
116
|
+
// DPoP / MAC / other proof-of-possession types need request-side
|
|
117
|
+
// support we don't implement, and silently treating them as Bearer
|
|
118
|
+
// would send tokens in the wrong header with unclear semantics.
|
|
119
|
+
if (!isNonEmptyString(raw.token_type)) {
|
|
120
|
+
throw new errors_1.OAuthFlowError("TOKEN_EXCHANGE_FAILED", `Token response missing required 'token_type'`);
|
|
121
|
+
}
|
|
122
|
+
if (raw.token_type.toLowerCase() !== "bearer") {
|
|
123
|
+
throw new errors_1.OAuthFlowError("TOKEN_EXCHANGE_FAILED", `Unsupported token_type '${raw.token_type}'; this library only handles Bearer.`);
|
|
124
|
+
}
|
|
125
|
+
const tokens = {
|
|
126
|
+
accessToken: raw.access_token,
|
|
127
|
+
expiresAt: issuedAt + expiresIn * 1000,
|
|
128
|
+
};
|
|
129
|
+
if (isNonEmptyString(raw.refresh_token)) {
|
|
130
|
+
tokens.refreshToken = raw.refresh_token;
|
|
131
|
+
}
|
|
132
|
+
if (isNonEmptyString(raw.scope)) {
|
|
133
|
+
tokens.grantedScope = raw.scope;
|
|
134
|
+
}
|
|
135
|
+
return tokens;
|
|
136
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { TokenSet } from "./tokenExchange";
|
|
2
|
+
/**
|
|
3
|
+
* Current on-disk blob schema version. Exported so consumers can
|
|
4
|
+
* display "stored v:N, expected v:M" diagnostics when `load()` returns
|
|
5
|
+
* a `version-mismatch` result.
|
|
6
|
+
*/
|
|
7
|
+
export declare const STORED_BLOB_VERSION = 1;
|
|
8
|
+
/**
|
|
9
|
+
* Outcome of a `TokenStore.load()` call.
|
|
10
|
+
*
|
|
11
|
+
* Note on downgrades: the migrator chain only walks *forward*. A user
|
|
12
|
+
* who downgrades `axe-auth` to a release that predates a schema bump
|
|
13
|
+
* will see `version-mismatch` on any blob written by the newer
|
|
14
|
+
* release, even if the change was strictly additive. That is the safe
|
|
15
|
+
* default for a credentials blob — the older version cannot vouch for
|
|
16
|
+
* the meaning of fields it has never seen. Callers hitting this case
|
|
17
|
+
* should treat it as "re-authenticate" rather than attempting to
|
|
18
|
+
* parse an unknown future shape.
|
|
19
|
+
*/
|
|
20
|
+
export type LoadResult = {
|
|
21
|
+
ok: true;
|
|
22
|
+
tokens: TokenSet;
|
|
23
|
+
} | {
|
|
24
|
+
ok: false;
|
|
25
|
+
reason: "empty";
|
|
26
|
+
} | {
|
|
27
|
+
ok: false;
|
|
28
|
+
reason: "corrupt";
|
|
29
|
+
} | {
|
|
30
|
+
ok: false;
|
|
31
|
+
reason: "version-mismatch";
|
|
32
|
+
storedVersion: number;
|
|
33
|
+
};
|
|
34
|
+
/** Persistence layer for an OAuth `TokenSet`. */
|
|
35
|
+
export interface TokenStore {
|
|
36
|
+
/** Write-through save. Replaces any previously stored tokens. */
|
|
37
|
+
save(tokens: TokenSet): Promise<void>;
|
|
38
|
+
/**
|
|
39
|
+
* Reads the stored tokens and returns a structured result.
|
|
40
|
+
*
|
|
41
|
+
* Callers should branch on `result.ok` first. When `ok` is `false`,
|
|
42
|
+
* `reason` tells them *why* there is no usable `TokenSet`: `empty`
|
|
43
|
+
* (nothing stored), `corrupt` (unparseable or shape-invalid), or
|
|
44
|
+
* `version-mismatch` (stored under a schema we cannot migrate from).
|
|
45
|
+
* The library does not emit output on these cases — surfacing them
|
|
46
|
+
* to the user is the caller's responsibility.
|
|
47
|
+
*/
|
|
48
|
+
load(): Promise<LoadResult>;
|
|
49
|
+
/** Removes any stored tokens. No-op if none are present. */
|
|
50
|
+
clear(): Promise<void>;
|
|
51
|
+
}
|
|
52
|
+
/** Minimal keyring-entry surface consumed by `KeyringTokenStore`. */
|
|
53
|
+
export interface KeyringEntry {
|
|
54
|
+
/** Writes the password for this entry. */
|
|
55
|
+
setPassword(password: string): void;
|
|
56
|
+
/** Reads the current password, or returns `null` if none is set. */
|
|
57
|
+
getPassword(): string | null;
|
|
58
|
+
/** Deletes the password and returns `true` if one existed. */
|
|
59
|
+
deletePassword(): boolean;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Factory for `KeyringEntry` values. Injection seam for tests;
|
|
63
|
+
* production callers use the default that constructs
|
|
64
|
+
* `@napi-rs/keyring` entries lazily.
|
|
65
|
+
*/
|
|
66
|
+
export type KeyringEntryFactory = (service: string, account: string) => KeyringEntry;
|
|
67
|
+
/**
|
|
68
|
+
* `TokenStore` backed by the operating system's native keychain via
|
|
69
|
+
* `@napi-rs/keyring` (macOS Keychain, Windows Credential Manager, Linux
|
|
70
|
+
* Secret Service). Account is keyed by the normalized issuer URL.
|
|
71
|
+
*/
|
|
72
|
+
export declare class KeyringTokenStore implements TokenStore {
|
|
73
|
+
#private;
|
|
74
|
+
constructor(issuerURL: string, entryFactory?: KeyringEntryFactory);
|
|
75
|
+
save(tokens: TokenSet): Promise<void>;
|
|
76
|
+
load(): Promise<LoadResult>;
|
|
77
|
+
clear(): Promise<void>;
|
|
78
|
+
}
|