@alphafox/cli 0.1.2 → 0.1.4
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/dist/auth/refresh.d.ts +19 -0
- package/dist/auth/refresh.js +188 -0
- package/dist/http/client.d.ts +2 -0
- package/dist/http/client.js +86 -4
- package/dist/keychain/store.js +5 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Silent access-token renewal via refresh_token grant.
|
|
3
|
+
* Access tokens are short-lived (~10m); refresh tokens last ~30d (web ADR).
|
|
4
|
+
*/
|
|
5
|
+
import type { ProfileConfig } from "../config/profiles";
|
|
6
|
+
import { type StoredTokens } from "../keychain/store";
|
|
7
|
+
/** Refresh when access token expires within this window. */
|
|
8
|
+
export declare const ACCESS_TOKEN_REFRESH_SKEW_MS = 60000;
|
|
9
|
+
export declare function accessTokenNeedsRefresh(tokens: StoredTokens, now?: number): boolean;
|
|
10
|
+
/**
|
|
11
|
+
* Exchange refresh_token for a new AT/RT pair and persist to keychain.
|
|
12
|
+
* Returns null if no tokens, no refresh token, or the AS rejects renewal.
|
|
13
|
+
*/
|
|
14
|
+
export declare function refreshStoredTokens(profile: ProfileConfig, env?: NodeJS.ProcessEnv, fetchImpl?: typeof fetch, options?: {
|
|
15
|
+
readonly now?: number;
|
|
16
|
+
readonly force?: boolean;
|
|
17
|
+
}): Promise<StoredTokens | null>;
|
|
18
|
+
/** Test helper: clear in-flight map between cases. */
|
|
19
|
+
export declare function clearRefreshInflightForTests(): void;
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Silent access-token renewal via refresh_token grant.
|
|
4
|
+
* Access tokens are short-lived (~10m); refresh tokens last ~30d (web ADR).
|
|
5
|
+
*/
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
exports.ACCESS_TOKEN_REFRESH_SKEW_MS = void 0;
|
|
8
|
+
exports.accessTokenNeedsRefresh = accessTokenNeedsRefresh;
|
|
9
|
+
exports.refreshStoredTokens = refreshStoredTokens;
|
|
10
|
+
exports.clearRefreshInflightForTests = clearRefreshInflightForTests;
|
|
11
|
+
const store_1 = require("../keychain/store");
|
|
12
|
+
/** Refresh when access token expires within this window. */
|
|
13
|
+
exports.ACCESS_TOKEN_REFRESH_SKEW_MS = 60_000;
|
|
14
|
+
/** In-flight refresh promises so concurrent API calls share one rotation. */
|
|
15
|
+
const inflightByProfile = new Map();
|
|
16
|
+
function accessTokenNeedsRefresh(tokens, now = Date.now()) {
|
|
17
|
+
if (!tokens.refreshToken?.trim()) {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
return tokens.expiresAt <= now + exports.ACCESS_TOKEN_REFRESH_SKEW_MS;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Exchange refresh_token for a new AT/RT pair and persist to keychain.
|
|
24
|
+
* Returns null if no tokens, no refresh token, or the AS rejects renewal.
|
|
25
|
+
*/
|
|
26
|
+
async function refreshStoredTokens(profile, env = process.env, fetchImpl = fetch, options = {}) {
|
|
27
|
+
const existing = (0, store_1.loadTokens)(profile.name, env);
|
|
28
|
+
if (!existing?.refreshToken?.trim()) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
if (!options.force &&
|
|
32
|
+
!accessTokenNeedsRefresh(existing, options.now ?? Date.now())) {
|
|
33
|
+
return existing;
|
|
34
|
+
}
|
|
35
|
+
const key = profile.name;
|
|
36
|
+
const pending = inflightByProfile.get(key);
|
|
37
|
+
if (pending) {
|
|
38
|
+
return pending;
|
|
39
|
+
}
|
|
40
|
+
const work = performRefresh(profile, existing, env, fetchImpl).finally(() => {
|
|
41
|
+
inflightByProfile.delete(key);
|
|
42
|
+
});
|
|
43
|
+
inflightByProfile.set(key, work);
|
|
44
|
+
return work;
|
|
45
|
+
}
|
|
46
|
+
async function performRefresh(profile, existing, env, fetchImpl) {
|
|
47
|
+
const origin = profile.apiBaseUrl.replace(/\/$/, "").replace(/\/api\/v1$/, "");
|
|
48
|
+
const url = `${origin}/api/auth/oauth/token`;
|
|
49
|
+
const body = {
|
|
50
|
+
grant_type: "refresh_token",
|
|
51
|
+
refresh_token: existing.refreshToken,
|
|
52
|
+
client_id: existing.clientId || profile.clientId,
|
|
53
|
+
};
|
|
54
|
+
let response;
|
|
55
|
+
try {
|
|
56
|
+
response = await fetchFollowingSameSiteRedirects(fetchImpl, url, {
|
|
57
|
+
method: "POST",
|
|
58
|
+
headers: {
|
|
59
|
+
Accept: "application/json",
|
|
60
|
+
"Content-Type": "application/json",
|
|
61
|
+
"X-Alphafox-Client": "alphafox-cli",
|
|
62
|
+
"X-Alphafox-Client-Version": env.ALPHAFOX_CLI_VERSION ?? "0.1.0",
|
|
63
|
+
},
|
|
64
|
+
body: JSON.stringify(body),
|
|
65
|
+
redirect: "manual",
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
if (response.status >= 400) {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
let json;
|
|
75
|
+
try {
|
|
76
|
+
json = await response.json();
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
if (!json || typeof json !== "object") {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
const o = json;
|
|
85
|
+
const access = typeof o.access_token === "string"
|
|
86
|
+
? o.access_token
|
|
87
|
+
: typeof o.accessToken === "string"
|
|
88
|
+
? o.accessToken
|
|
89
|
+
: null;
|
|
90
|
+
const refresh = typeof o.refresh_token === "string"
|
|
91
|
+
? o.refresh_token
|
|
92
|
+
: typeof o.refreshToken === "string"
|
|
93
|
+
? o.refreshToken
|
|
94
|
+
: null;
|
|
95
|
+
if (!access || !refresh) {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
const expiresIn = typeof o.expires_in === "number"
|
|
99
|
+
? o.expires_in
|
|
100
|
+
: typeof o.expiresIn === "number"
|
|
101
|
+
? o.expiresIn
|
|
102
|
+
: 600;
|
|
103
|
+
const scopeRaw = typeof o.scope === "string"
|
|
104
|
+
? o.scope
|
|
105
|
+
: existing.scopes.join(" ");
|
|
106
|
+
const next = {
|
|
107
|
+
accessToken: access,
|
|
108
|
+
refreshToken: refresh,
|
|
109
|
+
expiresAt: Date.now() + expiresIn * 1000,
|
|
110
|
+
environment: existing.environment || profile.name,
|
|
111
|
+
issuer: existing.issuer || profile.issuer,
|
|
112
|
+
audience: existing.audience || profile.audience,
|
|
113
|
+
clientId: existing.clientId || profile.clientId,
|
|
114
|
+
scopes: scopeRaw.split(/\s+/).filter(Boolean),
|
|
115
|
+
};
|
|
116
|
+
(0, store_1.saveTokens)(profile.name, next, env);
|
|
117
|
+
return next;
|
|
118
|
+
}
|
|
119
|
+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
120
|
+
async function fetchFollowingSameSiteRedirects(fetchImpl, startUrl, init, maxHops = 5) {
|
|
121
|
+
let url = startUrl;
|
|
122
|
+
let method = (init.method ?? "GET").toUpperCase();
|
|
123
|
+
let body = init.body;
|
|
124
|
+
let response = await fetchImpl(url, {
|
|
125
|
+
...init,
|
|
126
|
+
method,
|
|
127
|
+
body,
|
|
128
|
+
redirect: "manual",
|
|
129
|
+
});
|
|
130
|
+
for (let hop = 0; hop < maxHops && REDIRECT_STATUSES.has(response.status); hop++) {
|
|
131
|
+
const location = response.headers.get("location");
|
|
132
|
+
if (!location)
|
|
133
|
+
break;
|
|
134
|
+
const nextUrl = new URL(location, url).toString();
|
|
135
|
+
if (!sameAuthSite(originOf(url), originOf(nextUrl)))
|
|
136
|
+
break;
|
|
137
|
+
if (response.status === 303 ||
|
|
138
|
+
((response.status === 301 || response.status === 302) &&
|
|
139
|
+
method !== "GET" &&
|
|
140
|
+
method !== "HEAD")) {
|
|
141
|
+
method = "GET";
|
|
142
|
+
body = undefined;
|
|
143
|
+
}
|
|
144
|
+
url = nextUrl;
|
|
145
|
+
response = await fetchImpl(url, {
|
|
146
|
+
...init,
|
|
147
|
+
method,
|
|
148
|
+
body,
|
|
149
|
+
redirect: "manual",
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
return response;
|
|
153
|
+
}
|
|
154
|
+
function originOf(value) {
|
|
155
|
+
if (!value)
|
|
156
|
+
return null;
|
|
157
|
+
try {
|
|
158
|
+
if (value.startsWith("http://") || value.startsWith("https://")) {
|
|
159
|
+
return new URL(value).origin;
|
|
160
|
+
}
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
function sameAuthSite(a, b) {
|
|
168
|
+
if (!a || !b)
|
|
169
|
+
return false;
|
|
170
|
+
if (a === b)
|
|
171
|
+
return true;
|
|
172
|
+
try {
|
|
173
|
+
const ua = new URL(a);
|
|
174
|
+
const ub = new URL(b);
|
|
175
|
+
if (ua.protocol !== ub.protocol)
|
|
176
|
+
return false;
|
|
177
|
+
const ha = ua.hostname.replace(/^www\./i, "").toLowerCase();
|
|
178
|
+
const hb = ub.hostname.replace(/^www\./i, "").toLowerCase();
|
|
179
|
+
return ha === hb && ua.port === ub.port;
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
/** Test helper: clear in-flight map between cases. */
|
|
186
|
+
function clearRefreshInflightForTests() {
|
|
187
|
+
inflightByProfile.clear();
|
|
188
|
+
}
|
package/dist/http/client.d.ts
CHANGED
|
@@ -8,6 +8,8 @@ export interface ApiRequestOptions {
|
|
|
8
8
|
readonly requestId?: string;
|
|
9
9
|
readonly skipAuth?: boolean;
|
|
10
10
|
readonly idempotencyKey?: string;
|
|
11
|
+
/** Internal: already attempted one silent refresh+retry for this call. */
|
|
12
|
+
readonly _refreshRetried?: boolean;
|
|
11
13
|
}
|
|
12
14
|
export interface ApiResponse {
|
|
13
15
|
readonly status: number;
|
package/dist/http/client.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.apiRequest = apiRequest;
|
|
4
4
|
const envelope_1 = require("../envelope");
|
|
5
|
+
const refresh_1 = require("../auth/refresh");
|
|
5
6
|
const store_1 = require("../keychain/store");
|
|
6
7
|
const allowlist_1 = require("../catalog/allowlist");
|
|
7
8
|
async function apiRequest(options, env = process.env, fetchImpl = fetch) {
|
|
@@ -48,15 +49,24 @@ async function apiRequest(options, env = process.env, fetchImpl = fetch) {
|
|
|
48
49
|
...(options.headers ?? {}),
|
|
49
50
|
};
|
|
50
51
|
if (!options.skipAuth) {
|
|
51
|
-
|
|
52
|
+
let tokens = (0, store_1.loadTokens)(options.profile.name, env);
|
|
53
|
+
// Proactive refresh before the access token expires (or once already expired).
|
|
54
|
+
if (tokens && (0, refresh_1.accessTokenNeedsRefresh)(tokens)) {
|
|
55
|
+
const renewed = await (0, refresh_1.refreshStoredTokens)(options.profile, env, fetchImpl, { force: true });
|
|
56
|
+
if (renewed) {
|
|
57
|
+
tokens = renewed;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
52
60
|
if (tokens) {
|
|
53
|
-
// Never send tokens to a different
|
|
61
|
+
// Never send tokens to a different site than the profile audience.
|
|
62
|
+
// Apex/www (and trailing host variants) of the same registrable domain
|
|
63
|
+
// are treated as equivalent so production domain redirects do not break CLI.
|
|
54
64
|
const tokenAudienceOrigin = originOf(tokens.audience);
|
|
55
65
|
const targetOrigin = originOf(url);
|
|
56
66
|
if (tokens.audience &&
|
|
57
67
|
tokenAudienceOrigin &&
|
|
58
68
|
targetOrigin &&
|
|
59
|
-
tokenAudienceOrigin
|
|
69
|
+
!sameAuthSite(tokenAudienceOrigin, targetOrigin)) {
|
|
60
70
|
throw Object.assign(new Error("Refusing to send stored tokens to a different origin than token audience (fail-closed)."), {
|
|
61
71
|
status: 403,
|
|
62
72
|
type: "authorization",
|
|
@@ -72,12 +82,15 @@ async function apiRequest(options, env = process.env, fetchImpl = fetch) {
|
|
|
72
82
|
const init = {
|
|
73
83
|
method: options.method.toUpperCase(),
|
|
74
84
|
headers,
|
|
85
|
+
// Follow redirects ourselves so Authorization is re-attached after apex→www.
|
|
86
|
+
// fetch()'s automatic redirect drops Authorization on cross-origin hops.
|
|
87
|
+
redirect: "manual",
|
|
75
88
|
};
|
|
76
89
|
if (options.body !== undefined) {
|
|
77
90
|
headers["Content-Type"] = "application/json";
|
|
78
91
|
init.body = JSON.stringify(options.body);
|
|
79
92
|
}
|
|
80
|
-
const response = await fetchImpl
|
|
93
|
+
const response = await fetchFollowingAuthRedirects(fetchImpl, url, init);
|
|
81
94
|
const bodyText = await response.text();
|
|
82
95
|
let json = null;
|
|
83
96
|
try {
|
|
@@ -89,6 +102,19 @@ async function apiRequest(options, env = process.env, fetchImpl = fetch) {
|
|
|
89
102
|
const responseRequestId = response.headers.get("x-request-id") ??
|
|
90
103
|
response.headers.get("X-Request-Id") ??
|
|
91
104
|
requestId;
|
|
105
|
+
// Reactive: one silent refresh+retry on 401 for authenticated product calls.
|
|
106
|
+
if (response.status === 401 &&
|
|
107
|
+
!options.skipAuth &&
|
|
108
|
+
!options._refreshRetried &&
|
|
109
|
+
!isOAuthAsPath) {
|
|
110
|
+
const tokens = (0, store_1.loadTokens)(options.profile.name, env);
|
|
111
|
+
if (tokens?.refreshToken?.trim()) {
|
|
112
|
+
const renewed = await (0, refresh_1.refreshStoredTokens)(options.profile, env, fetchImpl, { force: true });
|
|
113
|
+
if (renewed) {
|
|
114
|
+
return apiRequest({ ...options, requestId, _refreshRetried: true }, env, fetchImpl);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
92
118
|
return {
|
|
93
119
|
status: response.status,
|
|
94
120
|
headers: response.headers,
|
|
@@ -97,7 +123,44 @@ async function apiRequest(options, env = process.env, fetchImpl = fetch) {
|
|
|
97
123
|
json,
|
|
98
124
|
};
|
|
99
125
|
}
|
|
126
|
+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
127
|
+
/**
|
|
128
|
+
* Re-issue requests on same-site redirects while keeping Authorization.
|
|
129
|
+
* Needed because alphafox.app → www.alphafox.app is a cross-origin hop for fetch.
|
|
130
|
+
*/
|
|
131
|
+
async function fetchFollowingAuthRedirects(fetchImpl, startUrl, init, maxHops = 5) {
|
|
132
|
+
let url = startUrl;
|
|
133
|
+
let method = (init.method ?? "GET").toUpperCase();
|
|
134
|
+
let body = init.body;
|
|
135
|
+
let response = await fetchImpl(url, { ...init, method, body, redirect: "manual" });
|
|
136
|
+
for (let hop = 0; hop < maxHops && REDIRECT_STATUSES.has(response.status); hop++) {
|
|
137
|
+
const location = response.headers.get("location");
|
|
138
|
+
if (!location) {
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
const nextUrl = new URL(location, url).toString();
|
|
142
|
+
if (!sameAuthSite(originOf(url), originOf(nextUrl))) {
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
// 303 switches to GET without body; 301/302 historically do for non-GET.
|
|
146
|
+
if (response.status === 303 ||
|
|
147
|
+
((response.status === 301 || response.status === 302) && method !== "GET" && method !== "HEAD")) {
|
|
148
|
+
method = "GET";
|
|
149
|
+
body = undefined;
|
|
150
|
+
}
|
|
151
|
+
url = nextUrl;
|
|
152
|
+
response = await fetchImpl(url, {
|
|
153
|
+
...init,
|
|
154
|
+
method,
|
|
155
|
+
body,
|
|
156
|
+
redirect: "manual",
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
return response;
|
|
160
|
+
}
|
|
100
161
|
function originOf(value) {
|
|
162
|
+
if (!value)
|
|
163
|
+
return null;
|
|
101
164
|
try {
|
|
102
165
|
if (value.startsWith("http://") || value.startsWith("https://")) {
|
|
103
166
|
return new URL(value).origin;
|
|
@@ -108,3 +171,22 @@ function originOf(value) {
|
|
|
108
171
|
return null;
|
|
109
172
|
}
|
|
110
173
|
}
|
|
174
|
+
/** Apex and www hosts of the same site share auth tokens. */
|
|
175
|
+
function sameAuthSite(a, b) {
|
|
176
|
+
if (!a || !b)
|
|
177
|
+
return false;
|
|
178
|
+
if (a === b)
|
|
179
|
+
return true;
|
|
180
|
+
try {
|
|
181
|
+
const ua = new URL(a);
|
|
182
|
+
const ub = new URL(b);
|
|
183
|
+
if (ua.protocol !== ub.protocol)
|
|
184
|
+
return false;
|
|
185
|
+
const ha = ua.hostname.replace(/^www\./i, "").toLowerCase();
|
|
186
|
+
const hb = ub.hostname.replace(/^www\./i, "").toLowerCase();
|
|
187
|
+
return ha === hb && ua.port === ub.port;
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
}
|
package/dist/keychain/store.js
CHANGED
|
@@ -38,10 +38,14 @@ function saveTokens(profile, tokens, env = process.env) {
|
|
|
38
38
|
function loadTokens(profile, env = process.env) {
|
|
39
39
|
// Controlled test injection — never document as prod automation.
|
|
40
40
|
if (env.ALPHAFOX_TEST_ACCESS_TOKEN?.trim()) {
|
|
41
|
+
const expiresAtRaw = env.ALPHAFOX_TEST_EXPIRES_AT?.trim();
|
|
42
|
+
const expiresAt = expiresAtRaw
|
|
43
|
+
? Number(expiresAtRaw)
|
|
44
|
+
: Date.now() + 3600_000;
|
|
41
45
|
return {
|
|
42
46
|
accessToken: env.ALPHAFOX_TEST_ACCESS_TOKEN.trim(),
|
|
43
47
|
refreshToken: env.ALPHAFOX_TEST_REFRESH_TOKEN?.trim() ?? "",
|
|
44
|
-
expiresAt: Date.now() + 3600_000,
|
|
48
|
+
expiresAt: Number.isFinite(expiresAt) ? expiresAt : Date.now() + 3600_000,
|
|
45
49
|
environment: profile,
|
|
46
50
|
issuer: env.ALPHAFOX_TEST_ISSUER ?? "",
|
|
47
51
|
audience: env.ALPHAFOX_TEST_AUDIENCE ?? "",
|
package/dist/version.d.ts
CHANGED
package/dist/version.js
CHANGED
|
@@ -3,5 +3,5 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.CLI_CONTRACT_VERSION = exports.CLI_VERSION = exports.CLI_PACKAGE = exports.CLI_NAME = void 0;
|
|
4
4
|
exports.CLI_NAME = "alphafox";
|
|
5
5
|
exports.CLI_PACKAGE = "@alphafox/cli";
|
|
6
|
-
exports.CLI_VERSION = "0.1.
|
|
6
|
+
exports.CLI_VERSION = "0.1.4";
|
|
7
7
|
exports.CLI_CONTRACT_VERSION = "2026-08-11";
|