@apostra/sdk 0.38.0 → 0.39.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -2
- package/SKILL.md +1 -1
- package/dist/cli-login.d.ts +76 -0
- package/dist/cli-login.js +377 -0
- package/dist/generated/client.d.ts +167 -55
- package/dist/generated/client.js +110 -47
- package/dist/generated/metadata.d.ts +136 -36
- package/dist/generated/metadata.js +176 -41
- package/dist/generated/types.gen.d.ts +4648 -3949
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/transport.js +14 -0
- package/llms.txt +1 -1
- package/package.json +8 -4
package/README.md
CHANGED
|
@@ -26,8 +26,11 @@ values. Use `accessToken` for an existing REST/M2M bearer token, or
|
|
|
26
26
|
one credential source. `m2mTokenProvider` acquires and caches a server-side
|
|
27
27
|
client-credentials token with the published M2M endpoint; it refreshes 60
|
|
28
28
|
seconds before expiry and shares a concurrent refresh. Never put API keys or
|
|
29
|
-
client secrets in browser bundles, URLs, examples or logs.
|
|
30
|
-
|
|
29
|
+
client secrets in browser bundles, URLs, examples or logs. With no
|
|
30
|
+
explicit credential and no `APOSTRA_API_KEY`, a Node.js client uses the login
|
|
31
|
+
that `apostra login` stored for the base URL, if there is one; the SDK never
|
|
32
|
+
opens a browser and writes only to that login's file (refreshed tokens). The
|
|
33
|
+
package does not replay 401 responses. M2M token refreshes have a
|
|
31
34
|
30-second deadline by default; pass `timeoutMs` to override it.
|
|
32
35
|
|
|
33
36
|
```ts
|
package/SKILL.md
CHANGED
|
@@ -5,7 +5,7 @@ description: Choose the Apostra TypeScript SDK method for a coding task, then re
|
|
|
5
5
|
|
|
6
6
|
# Apostra TypeScript SDK
|
|
7
7
|
|
|
8
|
-
Read the [SDK guide](https://docs.apostra.com/
|
|
8
|
+
Read the [SDK guide](https://docs.apostra.com/v3/sdk-guide), [V3 HTTP API
|
|
9
9
|
guide](https://docs.apostra.com/v3/http-api), and [authentication
|
|
10
10
|
guide](https://docs.apostra.com/v3/authentication) before relying on an
|
|
11
11
|
operation. They are the source of truth for inputs, results, permissions, and
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { TokenProvider } from './transport.js';
|
|
2
|
+
export type CliLoginEntry = {
|
|
3
|
+
issuer: string;
|
|
4
|
+
resource: string;
|
|
5
|
+
clientId: string;
|
|
6
|
+
accessToken: string;
|
|
7
|
+
refreshToken: string;
|
|
8
|
+
/** ISO-8601 time the access token stops working. */
|
|
9
|
+
expiresAt: string;
|
|
10
|
+
accountId: string;
|
|
11
|
+
scopes: string[];
|
|
12
|
+
};
|
|
13
|
+
export type CliLoginDiscovery = {
|
|
14
|
+
issuer: string;
|
|
15
|
+
authorizationEndpoint: string;
|
|
16
|
+
tokenEndpoint: string;
|
|
17
|
+
revocationEndpoint?: string;
|
|
18
|
+
codeChallengeMethods?: string[];
|
|
19
|
+
};
|
|
20
|
+
export type CliLoginTokens = Pick<CliLoginEntry, 'accessToken' | 'refreshToken' | 'expiresAt'> & {
|
|
21
|
+
scopes?: string[];
|
|
22
|
+
accountId?: string;
|
|
23
|
+
};
|
|
24
|
+
type Fetch = typeof globalThis.fetch;
|
|
25
|
+
type Options = {
|
|
26
|
+
fetch?: Fetch;
|
|
27
|
+
};
|
|
28
|
+
/** `${XDG_CONFIG_HOME:-~/.config}/apostra/credentials.json` (`%APPDATA%` on Windows). */
|
|
29
|
+
export declare function credentialsPath(): string;
|
|
30
|
+
/** The store key: the API base URL without a trailing slash. */
|
|
31
|
+
export declare function normalizeBaseUrl(raw: string): string;
|
|
32
|
+
/** The first-party public client for an API origin. */
|
|
33
|
+
export declare const cliClientId: (resource: string) => string;
|
|
34
|
+
export declare function readCliLogin(baseUrl: string): CliLoginEntry | undefined;
|
|
35
|
+
/** Like `readCliLogin`, but never throws: for the SDK's default credential chain. */
|
|
36
|
+
export declare function storedCliLogin(baseUrl: string): CliLoginEntry | undefined;
|
|
37
|
+
export declare const isFresh: (entry: Pick<CliLoginEntry, "expiresAt">) => boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Serialise every read-modify-write of the store, across processes. Refresh
|
|
40
|
+
* tokens rotate, so two processes must never present the same one outside the
|
|
41
|
+
* server's reuse grace. A lock older than 30 seconds belongs to a dead process.
|
|
42
|
+
*/
|
|
43
|
+
export declare function withCliLoginLock<T>(fn: () => Promise<T>): Promise<T>;
|
|
44
|
+
/** Save a login. Call inside `withCliLoginLock`. */
|
|
45
|
+
export declare function writeCliLogin(baseUrl: string, entry: CliLoginEntry): void;
|
|
46
|
+
/** Protected-resource metadata, then authorization-server metadata (RFC 9728, RFC 8414). */
|
|
47
|
+
export declare function discoverCliLogin(resource: string, { fetch: f }?: Options): Promise<CliLoginDiscovery>;
|
|
48
|
+
export declare function exchangeCliLoginCode(options: Options, discovery: CliLoginDiscovery, resource: string, grant: {
|
|
49
|
+
code: string;
|
|
50
|
+
verifier: string;
|
|
51
|
+
redirectUri: string;
|
|
52
|
+
}): Promise<CliLoginTokens>;
|
|
53
|
+
/**
|
|
54
|
+
* Return a usable login for `baseUrl`, refreshing it if the access token is
|
|
55
|
+
* about to expire. After taking the lock the file is read again: if another
|
|
56
|
+
* process refreshed first, its token is used and no request is made.
|
|
57
|
+
*/
|
|
58
|
+
export declare function refreshCliLogin(baseUrl: string, options?: Options): Promise<CliLoginEntry>;
|
|
59
|
+
/**
|
|
60
|
+
* Revoke the refresh token (RFC 7009), then delete the entry. The entry is
|
|
61
|
+
* deleted even when revocation fails; `revoked` tells the caller which.
|
|
62
|
+
*/
|
|
63
|
+
export declare function logoutCliLogin(baseUrl: string, { fetch: f }?: Options): Promise<{
|
|
64
|
+
found: boolean;
|
|
65
|
+
revoked: boolean;
|
|
66
|
+
}>;
|
|
67
|
+
/**
|
|
68
|
+
* A token provider backed by `apostra login`. Use it as `tokenProvider`, or
|
|
69
|
+
* rely on the default credential chain, which uses it when no explicit
|
|
70
|
+
* credential or `APOSTRA_API_KEY` is set and a login exists for the base URL.
|
|
71
|
+
*/
|
|
72
|
+
export declare function cliLoginTokenProvider(options?: {
|
|
73
|
+
baseUrl?: string;
|
|
74
|
+
fetch?: Fetch;
|
|
75
|
+
}): TokenProvider;
|
|
76
|
+
export {};
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The credential store behind `apostra login`, shared by the CLI and the SDK.
|
|
3
|
+
*
|
|
4
|
+
* Node only. Nothing here imports a Node module at load time (it uses
|
|
5
|
+
* `process.getBuiltinModule`), so a browser bundle can include the SDK and
|
|
6
|
+
* simply never call it. The SDK never opens a browser; the interactive sign-in
|
|
7
|
+
* lives in `@apostra/cli`.
|
|
8
|
+
*/
|
|
9
|
+
import { baseUrl as defaultBaseUrl } from './generated/metadata.js';
|
|
10
|
+
const FRESH_MARGIN_MS = 60_000;
|
|
11
|
+
const REQUEST_TIMEOUT_MS = 15_000;
|
|
12
|
+
const LOCK_STALE_MS = 30_000;
|
|
13
|
+
const LOCK_HEARTBEAT_MS = 5_000;
|
|
14
|
+
const LOCK_WAIT_MS = 30_000;
|
|
15
|
+
function proc() {
|
|
16
|
+
const p = globalThis.process;
|
|
17
|
+
if (!p?.versions?.node || typeof p.getBuiltinModule !== 'function')
|
|
18
|
+
throw new Error('Apostra CLI login is available only in Node.js');
|
|
19
|
+
return p;
|
|
20
|
+
}
|
|
21
|
+
function node() {
|
|
22
|
+
const p = proc();
|
|
23
|
+
const get = p.getBuiltinModule;
|
|
24
|
+
return {
|
|
25
|
+
fs: get('node:fs'),
|
|
26
|
+
os: get('node:os'),
|
|
27
|
+
path: get('node:path'),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
const isRecord = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
31
|
+
const errorCode = (error) => isRecord(error) && typeof error.code === 'string' ? error.code : undefined;
|
|
32
|
+
/** `${XDG_CONFIG_HOME:-~/.config}/apostra/credentials.json` (`%APPDATA%` on Windows). */
|
|
33
|
+
export function credentialsPath() {
|
|
34
|
+
const { os, path } = node();
|
|
35
|
+
const { env, platform } = proc();
|
|
36
|
+
const base = platform === 'win32'
|
|
37
|
+
? (env.APPDATA ?? path.join(os.homedir(), 'AppData', 'Roaming'))
|
|
38
|
+
: env.XDG_CONFIG_HOME && path.isAbsolute(env.XDG_CONFIG_HOME)
|
|
39
|
+
? env.XDG_CONFIG_HOME
|
|
40
|
+
: path.join(os.homedir(), '.config');
|
|
41
|
+
return path.join(base, 'apostra', 'credentials.json');
|
|
42
|
+
}
|
|
43
|
+
function secureUrl(value, name) {
|
|
44
|
+
const url = new URL(typeof value === 'string' ? value : '');
|
|
45
|
+
if (url.username ||
|
|
46
|
+
url.password ||
|
|
47
|
+
url.hash ||
|
|
48
|
+
(url.protocol !== 'https:' &&
|
|
49
|
+
!(url.protocol === 'http:' &&
|
|
50
|
+
['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))))
|
|
51
|
+
throw new TypeError(`${name} must be an HTTPS URL (HTTP only on loopback)`);
|
|
52
|
+
return url.href;
|
|
53
|
+
}
|
|
54
|
+
/** The store key: the API base URL without a trailing slash. */
|
|
55
|
+
export function normalizeBaseUrl(raw) {
|
|
56
|
+
const url = new URL(secureUrl(raw, 'Base URL'));
|
|
57
|
+
if (url.search)
|
|
58
|
+
throw new TypeError('Base URL must not have a query string');
|
|
59
|
+
return url.href.replace(/\/$/, '');
|
|
60
|
+
}
|
|
61
|
+
/** The first-party public client for an API origin. */
|
|
62
|
+
export const cliClientId = (resource) => `${new URL(resource).origin}/oauth/clients/apostra-cli.json`;
|
|
63
|
+
function parseEntry(value) {
|
|
64
|
+
if (!isRecord(value))
|
|
65
|
+
return undefined;
|
|
66
|
+
const text = ['issuer', 'resource', 'clientId', 'accessToken', 'refreshToken'];
|
|
67
|
+
if (!text.every((k) => typeof value[k] === 'string' && value[k]) ||
|
|
68
|
+
typeof value.expiresAt !== 'string' ||
|
|
69
|
+
Number.isNaN(Date.parse(value.expiresAt)) ||
|
|
70
|
+
typeof value.accountId !== 'string' ||
|
|
71
|
+
!Array.isArray(value.scopes) ||
|
|
72
|
+
!value.scopes.every((s) => typeof s === 'string'))
|
|
73
|
+
return undefined;
|
|
74
|
+
return value;
|
|
75
|
+
}
|
|
76
|
+
function readAll() {
|
|
77
|
+
const { fs } = node();
|
|
78
|
+
try {
|
|
79
|
+
const file = JSON.parse(fs.readFileSync(credentialsPath(), 'utf8'));
|
|
80
|
+
return isRecord(file) && isRecord(file.logins) ? file.logins : {};
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return {};
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/** Atomic (temp file + rename), directory 0700, file 0600. */
|
|
87
|
+
function writeAll(logins) {
|
|
88
|
+
const { fs, path } = node();
|
|
89
|
+
const file = credentialsPath();
|
|
90
|
+
const dir = path.dirname(file);
|
|
91
|
+
if (Object.keys(logins).length === 0) {
|
|
92
|
+
fs.rmSync(file, { force: true });
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
96
|
+
fs.chmodSync(dir, 0o700);
|
|
97
|
+
const temp = `${file}.${proc().pid}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
98
|
+
const fd = fs.openSync(temp, 'wx', 0o600);
|
|
99
|
+
try {
|
|
100
|
+
fs.writeSync(fd, `${JSON.stringify({ version: 1, logins }, null, 2)}\n`);
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
fs.closeSync(fd);
|
|
104
|
+
}
|
|
105
|
+
fs.renameSync(temp, file);
|
|
106
|
+
}
|
|
107
|
+
export function readCliLogin(baseUrl) {
|
|
108
|
+
return parseEntry(readAll()[normalizeBaseUrl(baseUrl)]);
|
|
109
|
+
}
|
|
110
|
+
/** Like `readCliLogin`, but never throws: for the SDK's default credential chain. */
|
|
111
|
+
export function storedCliLogin(baseUrl) {
|
|
112
|
+
try {
|
|
113
|
+
return readCliLogin(baseUrl);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
export const isFresh = (entry) => Date.parse(entry.expiresAt) - FRESH_MARGIN_MS > Date.now();
|
|
120
|
+
/**
|
|
121
|
+
* Serialise every read-modify-write of the store, across processes. Refresh
|
|
122
|
+
* tokens rotate, so two processes must never present the same one outside the
|
|
123
|
+
* server's reuse grace. A lock older than 30 seconds belongs to a dead process.
|
|
124
|
+
*/
|
|
125
|
+
export async function withCliLoginLock(fn) {
|
|
126
|
+
const { fs, path } = node();
|
|
127
|
+
const lock = `${credentialsPath()}.lock`;
|
|
128
|
+
fs.mkdirSync(path.dirname(lock), { recursive: true, mode: 0o700 });
|
|
129
|
+
// The lock file holds its owner's token, so a process only ever removes a
|
|
130
|
+
// lock it owns, and a heartbeat keeps a live lock fresh however long the
|
|
131
|
+
// bounded refresh takes. Only a lock whose heartbeat stopped is stale.
|
|
132
|
+
const owner = `${proc().pid}:${globalThis.crypto.randomUUID()}`;
|
|
133
|
+
const ownerOf = () => {
|
|
134
|
+
try {
|
|
135
|
+
return fs.readFileSync(lock, 'utf8');
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
const deadline = Date.now() + LOCK_WAIT_MS;
|
|
142
|
+
for (;;) {
|
|
143
|
+
try {
|
|
144
|
+
const fd = fs.openSync(lock, 'wx', 0o600);
|
|
145
|
+
try {
|
|
146
|
+
fs.writeSync(fd, owner);
|
|
147
|
+
}
|
|
148
|
+
finally {
|
|
149
|
+
fs.closeSync(fd);
|
|
150
|
+
}
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
if (errorCode(error) !== 'EEXIST')
|
|
155
|
+
throw error;
|
|
156
|
+
try {
|
|
157
|
+
const stale = ownerOf();
|
|
158
|
+
if (Date.now() - fs.statSync(lock).mtimeMs > LOCK_STALE_MS) {
|
|
159
|
+
if (ownerOf() === stale)
|
|
160
|
+
fs.rmSync(lock, { force: true });
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (Date.now() > deadline)
|
|
168
|
+
throw new Error('Timed out waiting for another Apostra login process');
|
|
169
|
+
await new Promise((r) => setTimeout(r, 25 + Math.random() * 50));
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const heartbeat = setInterval(() => {
|
|
173
|
+
try {
|
|
174
|
+
if (ownerOf() === owner)
|
|
175
|
+
fs.utimesSync(lock, new Date(), new Date());
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
// A missed beat only shortens the lease; the owner check still holds.
|
|
179
|
+
}
|
|
180
|
+
}, LOCK_HEARTBEAT_MS);
|
|
181
|
+
heartbeat.unref?.();
|
|
182
|
+
try {
|
|
183
|
+
return await fn();
|
|
184
|
+
}
|
|
185
|
+
finally {
|
|
186
|
+
clearInterval(heartbeat);
|
|
187
|
+
if (ownerOf() === owner)
|
|
188
|
+
fs.rmSync(lock, { force: true });
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
/** Save a login. Call inside `withCliLoginLock`. */
|
|
192
|
+
export function writeCliLogin(baseUrl, entry) {
|
|
193
|
+
writeAll({ ...readAll(), [normalizeBaseUrl(baseUrl)]: entry });
|
|
194
|
+
}
|
|
195
|
+
async function send(fetcher, url, init = {}) {
|
|
196
|
+
return fetcher(url, {
|
|
197
|
+
...init,
|
|
198
|
+
headers: { Accept: 'application/json', ...init.headers },
|
|
199
|
+
redirect: 'error',
|
|
200
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
async function getJson(fetcher, url) {
|
|
204
|
+
const response = await send(fetcher, url);
|
|
205
|
+
if (!response.ok)
|
|
206
|
+
throw new Error(`Could not read ${url} (HTTP ${response.status})`);
|
|
207
|
+
return response.json();
|
|
208
|
+
}
|
|
209
|
+
function wellKnown(name, location) {
|
|
210
|
+
const url = new URL(location);
|
|
211
|
+
return `${url.origin}/.well-known/${name}${url.pathname === '/' ? '' : url.pathname}`;
|
|
212
|
+
}
|
|
213
|
+
/** Protected-resource metadata, then authorization-server metadata (RFC 9728, RFC 8414). */
|
|
214
|
+
export async function discoverCliLogin(resource, { fetch: f = globalThis.fetch } = {}) {
|
|
215
|
+
const prm = await getJson(f, wellKnown('oauth-protected-resource', resource));
|
|
216
|
+
if (!isRecord(prm) ||
|
|
217
|
+
typeof prm.resource !== 'string' ||
|
|
218
|
+
prm.resource.replace(/\/$/, '') !== resource ||
|
|
219
|
+
!Array.isArray(prm.authorization_servers))
|
|
220
|
+
throw new Error(`${resource} does not publish OAuth protected-resource metadata`);
|
|
221
|
+
const issuer = secureUrl(prm.authorization_servers[0], 'Issuer').replace(/\/$/, '');
|
|
222
|
+
const as = await getJson(f, wellKnown('oauth-authorization-server', issuer));
|
|
223
|
+
if (!isRecord(as) || as.issuer !== issuer)
|
|
224
|
+
throw new Error(`${issuer} published mismatched authorization-server metadata`);
|
|
225
|
+
const methods = as.code_challenge_methods_supported;
|
|
226
|
+
return {
|
|
227
|
+
issuer,
|
|
228
|
+
authorizationEndpoint: secureUrl(as.authorization_endpoint, 'authorization_endpoint'),
|
|
229
|
+
tokenEndpoint: secureUrl(as.token_endpoint, 'token_endpoint'),
|
|
230
|
+
revocationEndpoint: as.revocation_endpoint === undefined
|
|
231
|
+
? undefined
|
|
232
|
+
: secureUrl(as.revocation_endpoint, 'revocation_endpoint'),
|
|
233
|
+
codeChallengeMethods: Array.isArray(methods)
|
|
234
|
+
? methods.map(String)
|
|
235
|
+
: undefined,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
async function tokenRequest({ fetch: f = globalThis.fetch }, discovery, resource, form) {
|
|
239
|
+
const response = await send(f, discovery.tokenEndpoint, {
|
|
240
|
+
method: 'POST',
|
|
241
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
242
|
+
body: new URLSearchParams({ ...form, resource }),
|
|
243
|
+
});
|
|
244
|
+
const body = await response.json().catch(() => undefined);
|
|
245
|
+
if (!response.ok) {
|
|
246
|
+
// Never echo the response body: it may carry reflected credentials.
|
|
247
|
+
if (isRecord(body) && body.error === 'invalid_grant')
|
|
248
|
+
throw new Error('Your Apostra login expired or was revoked. Run `apostra login` again.');
|
|
249
|
+
throw new Error(`Apostra token request failed (HTTP ${response.status})`);
|
|
250
|
+
}
|
|
251
|
+
if (!isRecord(body) ||
|
|
252
|
+
typeof body.access_token !== 'string' ||
|
|
253
|
+
!body.access_token ||
|
|
254
|
+
typeof body.expires_in !== 'number' ||
|
|
255
|
+
!(body.expires_in > 0))
|
|
256
|
+
throw new Error('Apostra token response is invalid');
|
|
257
|
+
// Rotation: the old refresh token is spent; keep the new one. A refresh that
|
|
258
|
+
// does not rotate keeps the old one, but a login with no refresh token
|
|
259
|
+
// could not be kept past the access token's hour, so it is refused here,
|
|
260
|
+
// before anything is written.
|
|
261
|
+
const refreshToken = typeof body.refresh_token === 'string' && body.refresh_token
|
|
262
|
+
? body.refresh_token
|
|
263
|
+
: form.refresh_token;
|
|
264
|
+
if (!refreshToken)
|
|
265
|
+
throw new Error('Apostra did not return a refresh token, so this login cannot be kept. Run `apostra login` again.');
|
|
266
|
+
const account = body.account_id;
|
|
267
|
+
return {
|
|
268
|
+
accessToken: body.access_token,
|
|
269
|
+
refreshToken,
|
|
270
|
+
expiresAt: new Date(Date.now() + body.expires_in * 1000).toISOString(),
|
|
271
|
+
scopes: typeof body.scope === 'string'
|
|
272
|
+
? body.scope.split(' ').filter(Boolean)
|
|
273
|
+
: undefined,
|
|
274
|
+
accountId: typeof account === 'string' || typeof account === 'number'
|
|
275
|
+
? String(account)
|
|
276
|
+
: undefined,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
export function exchangeCliLoginCode(options, discovery, resource, grant) {
|
|
280
|
+
return tokenRequest(options, discovery, resource, {
|
|
281
|
+
grant_type: 'authorization_code',
|
|
282
|
+
code: grant.code,
|
|
283
|
+
code_verifier: grant.verifier,
|
|
284
|
+
redirect_uri: grant.redirectUri,
|
|
285
|
+
client_id: cliClientId(resource),
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Return a usable login for `baseUrl`, refreshing it if the access token is
|
|
290
|
+
* about to expire. After taking the lock the file is read again: if another
|
|
291
|
+
* process refreshed first, its token is used and no request is made.
|
|
292
|
+
*/
|
|
293
|
+
export async function refreshCliLogin(baseUrl, options = {}) {
|
|
294
|
+
const key = normalizeBaseUrl(baseUrl);
|
|
295
|
+
const missing = () => new Error(`Not signed in to ${key}. Run \`apostra login\`.`);
|
|
296
|
+
const first = readCliLogin(key);
|
|
297
|
+
if (!first)
|
|
298
|
+
throw missing();
|
|
299
|
+
if (isFresh(first))
|
|
300
|
+
return first;
|
|
301
|
+
return withCliLoginLock(async () => {
|
|
302
|
+
const entry = readCliLogin(key);
|
|
303
|
+
if (!entry)
|
|
304
|
+
throw missing();
|
|
305
|
+
if (isFresh(entry))
|
|
306
|
+
return entry;
|
|
307
|
+
const discovery = await discoverCliLogin(entry.resource, options);
|
|
308
|
+
const tokens = await tokenRequest(options, discovery, entry.resource, {
|
|
309
|
+
grant_type: 'refresh_token',
|
|
310
|
+
refresh_token: entry.refreshToken,
|
|
311
|
+
client_id: entry.clientId,
|
|
312
|
+
});
|
|
313
|
+
const next = {
|
|
314
|
+
...entry,
|
|
315
|
+
accessToken: tokens.accessToken,
|
|
316
|
+
refreshToken: tokens.refreshToken,
|
|
317
|
+
expiresAt: tokens.expiresAt,
|
|
318
|
+
scopes: tokens.scopes ?? entry.scopes,
|
|
319
|
+
};
|
|
320
|
+
writeCliLogin(key, next);
|
|
321
|
+
return next;
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Revoke the refresh token (RFC 7009), then delete the entry. The entry is
|
|
326
|
+
* deleted even when revocation fails; `revoked` tells the caller which.
|
|
327
|
+
*/
|
|
328
|
+
export async function logoutCliLogin(baseUrl, { fetch: f = globalThis.fetch } = {}) {
|
|
329
|
+
const key = normalizeBaseUrl(baseUrl);
|
|
330
|
+
return withCliLoginLock(async () => {
|
|
331
|
+
const entry = readCliLogin(key);
|
|
332
|
+
if (!entry)
|
|
333
|
+
return { found: false, revoked: false };
|
|
334
|
+
let revoked = false;
|
|
335
|
+
try {
|
|
336
|
+
const { revocationEndpoint } = await discoverCliLogin(entry.resource, {
|
|
337
|
+
fetch: f,
|
|
338
|
+
});
|
|
339
|
+
if (revocationEndpoint)
|
|
340
|
+
revoked = (await send(f, revocationEndpoint, {
|
|
341
|
+
method: 'POST',
|
|
342
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
343
|
+
body: new URLSearchParams({
|
|
344
|
+
token: entry.refreshToken,
|
|
345
|
+
token_type_hint: 'refresh_token',
|
|
346
|
+
client_id: entry.clientId,
|
|
347
|
+
}),
|
|
348
|
+
})).ok;
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
revoked = false;
|
|
352
|
+
}
|
|
353
|
+
const { [key]: _removed, ...rest } = readAll();
|
|
354
|
+
writeAll(rest);
|
|
355
|
+
return { found: true, revoked };
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* A token provider backed by `apostra login`. Use it as `tokenProvider`, or
|
|
360
|
+
* rely on the default credential chain, which uses it when no explicit
|
|
361
|
+
* credential or `APOSTRA_API_KEY` is set and a login exists for the base URL.
|
|
362
|
+
*/
|
|
363
|
+
export function cliLoginTokenProvider(options = {}) {
|
|
364
|
+
const key = normalizeBaseUrl(options.baseUrl ?? proc().env.APOSTRA_BASE_URL ?? defaultBaseUrl);
|
|
365
|
+
let refreshing;
|
|
366
|
+
return () => {
|
|
367
|
+
const entry = readCliLogin(key);
|
|
368
|
+
if (entry && isFresh(entry))
|
|
369
|
+
return entry.accessToken;
|
|
370
|
+
refreshing ??= refreshCliLogin(key, { fetch: options.fetch })
|
|
371
|
+
.then((e) => e.accessToken)
|
|
372
|
+
.finally(() => {
|
|
373
|
+
refreshing = undefined;
|
|
374
|
+
});
|
|
375
|
+
return refreshing;
|
|
376
|
+
};
|
|
377
|
+
}
|