unfazed 0.8.0 → 0.9.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/CHANGELOG.md +80 -0
- package/README.md +93 -5
- package/REFERENCE.md +72 -13
- package/dist/cli/login.d.ts +1 -1
- package/dist/client.d.ts +21 -1
- package/dist/client.js +12 -4
- package/dist/client.js.map +1 -1
- package/dist/errors.d.ts +49 -0
- package/dist/errors.js +25 -0
- package/dist/errors.js.map +1 -1
- package/dist/generated/gql.d.ts +14 -2
- package/dist/generated/gql.js +5 -2
- package/dist/generated/gql.js.map +1 -1
- package/dist/generated/graphql.d.ts +82 -1
- package/dist/generated/graphql.js +87 -1
- package/dist/generated/graphql.js.map +1 -1
- package/dist/index.d.ts +17 -3
- package/dist/index.js +14 -1
- package/dist/index.js.map +1 -1
- package/dist/oidc.d.ts +161 -0
- package/dist/oidc.js +391 -0
- package/dist/oidc.js.map +1 -0
- package/dist/operations.d.ts +29 -1
- package/dist/operations.js +70 -1
- package/dist/operations.js.map +1 -1
- package/dist/reference.generated.js +1 -1
- package/dist/reference.generated.js.map +1 -1
- package/dist/reference.js +3 -3
- package/dist/reference.js.map +1 -1
- package/dist/unfazed.d.ts +84 -19
- package/dist/unfazed.js +249 -54
- package/dist/unfazed.js.map +1 -1
- package/package.json +1 -1
- package/schema/SOURCE.json +3 -3
- package/schema/schema.graphqls +81 -5
package/dist/oidc.d.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workload identity: OIDC tokens the platform issues to an app.
|
|
3
|
+
*
|
|
4
|
+
* An app running on unfazed (a service or a cron job) asks the control plane
|
|
5
|
+
* for a short-lived JWT that names it, for one audience, and presents it to a
|
|
6
|
+
* service that trusts the issuer `https://unfazed.dev/oidc` instead of a
|
|
7
|
+
* static API key. The only credential is the app's own `UNFZ_STATE_TOKEN`,
|
|
8
|
+
* which the platform puts into each of its VMs; no `UNFZ_API_TOKEN` is
|
|
9
|
+
* involved.
|
|
10
|
+
*
|
|
11
|
+
* ```ts
|
|
12
|
+
* import { oidcToken } from "unfazed";
|
|
13
|
+
* const jwt = await oidcToken("https://api.example.com");
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* Wire contract: `POST <platform>/oidc/token` with
|
|
17
|
+
* `Authorization: Bearer $UNFZ_STATE_TOKEN` and `{ audience, ttl_seconds? }`;
|
|
18
|
+
* 200 is `{ token, expires_at, token_type: "Bearer" }`, an error
|
|
19
|
+
* `{ error, error_description }`.
|
|
20
|
+
*/
|
|
21
|
+
/** Environment variable holding the app's state token, set in every unfazed service and cron VM. */
|
|
22
|
+
export declare const STATE_TOKEN_ENV_VAR = "UNFZ_STATE_TOKEN";
|
|
23
|
+
/** Path of the token endpoint under the platform origin. */
|
|
24
|
+
export declare const OIDC_TOKEN_PATH = "/oidc/token";
|
|
25
|
+
/**
|
|
26
|
+
* The `fetch` the token request goes through: the global `fetch`, or anything
|
|
27
|
+
* shaped like it (the client's `FetchLike` fits). `headers` on the response
|
|
28
|
+
* is read for `Retry-After` when present.
|
|
29
|
+
*/
|
|
30
|
+
export type OIDCTransport = (input: string, init: {
|
|
31
|
+
method: string;
|
|
32
|
+
headers: Record<string, string>;
|
|
33
|
+
body: string;
|
|
34
|
+
signal?: AbortSignal;
|
|
35
|
+
}) => Promise<{
|
|
36
|
+
status: number;
|
|
37
|
+
headers?: {
|
|
38
|
+
get(name: string): string | null;
|
|
39
|
+
};
|
|
40
|
+
text(): Promise<string>;
|
|
41
|
+
}>;
|
|
42
|
+
/** A `fetch` as the OpenAI SDK (and most HTTP clients) take one. */
|
|
43
|
+
export type TokenFetch = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
44
|
+
export interface OIDCOptions {
|
|
45
|
+
/** The app's state token. Default: `UNFZ_STATE_TOKEN`, read on every call. */
|
|
46
|
+
stateToken?: string;
|
|
47
|
+
/**
|
|
48
|
+
* Full GraphQL endpoint; tokens come from its origin. Wins over `baseUrl`.
|
|
49
|
+
* Falls back to `UNFZ_API_URL`, then `https://unfazed.dev` (as the client).
|
|
50
|
+
*/
|
|
51
|
+
endpoint?: string;
|
|
52
|
+
/** Platform origin; `/oidc/token` is appended. */
|
|
53
|
+
baseUrl?: string;
|
|
54
|
+
/** Where `UNFZ_STATE_TOKEN` and `UNFZ_API_URL` are read from. Default: `process.env`. */
|
|
55
|
+
env?: Record<string, string | undefined>;
|
|
56
|
+
/**
|
|
57
|
+
* The transport of the token request. Default: the global `fetch`. An
|
|
58
|
+
* instance with its own `fetch` (or `now`) keeps its own cache; every other
|
|
59
|
+
* one shares the process's.
|
|
60
|
+
*/
|
|
61
|
+
fetch?: OIDCTransport;
|
|
62
|
+
/** Overrides the default `unfazed-sdk/<version>` User-Agent. */
|
|
63
|
+
userAgent?: string;
|
|
64
|
+
/** Timeout of one attempt, in ms. Default 10_000; 0 disables it. */
|
|
65
|
+
timeoutMs?: number;
|
|
66
|
+
/** Injected wall clock, ms (tests). */
|
|
67
|
+
now?: () => number;
|
|
68
|
+
/** Injected sleep between retries (tests). */
|
|
69
|
+
sleep?: (ms: number) => Promise<void>;
|
|
70
|
+
}
|
|
71
|
+
export interface OIDCTokenOptions {
|
|
72
|
+
/** Token lifetime, a whole number of seconds from 60 to 3600. Default: the platform's. */
|
|
73
|
+
ttlSeconds?: number;
|
|
74
|
+
/**
|
|
75
|
+
* Stops waiting, with {@link UnfazedTimeoutError}. A request other callers
|
|
76
|
+
* wait on too keeps going (and fills the cache).
|
|
77
|
+
*/
|
|
78
|
+
signal?: AbortSignal;
|
|
79
|
+
}
|
|
80
|
+
/** A token with its expiry, from {@link UnfazedOIDC.tokenResponse}. */
|
|
81
|
+
export interface OIDCToken {
|
|
82
|
+
/** The JWT. */
|
|
83
|
+
token: string;
|
|
84
|
+
/** When it expires (the platform's `expires_at`). */
|
|
85
|
+
expiresAt: Date;
|
|
86
|
+
}
|
|
87
|
+
export interface FetchWithTokenOptions {
|
|
88
|
+
/** Token lifetime, a whole number of seconds from 60 to 3600. Default: the platform's. */
|
|
89
|
+
ttlSeconds?: number;
|
|
90
|
+
/** The `fetch` that sends the request. Default: the global `fetch`, at call time. */
|
|
91
|
+
fetch?: TokenFetch;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Workload identity tokens for the app this code runs in: `unfazed.oidc` on an
|
|
95
|
+
* {@link import("./unfazed.js").Unfazed} instance, or the standalone `oidc`.
|
|
96
|
+
*
|
|
97
|
+
* Tokens are cached per (audience, ttl) and reused until 80% of their
|
|
98
|
+
* lifetime has passed; concurrent calls for the same pair share one request.
|
|
99
|
+
* 503 (and a gateway's 502/504), 429 and network failures are retried up to 3
|
|
100
|
+
* times (200, 400, 800 ms; a 429's or 503's `Retry-After` up to 10 s is waited
|
|
101
|
+
* out instead); no other 4xx is retried. When a refresh fails for one of
|
|
102
|
+
* those passing reasons, the token it was replacing is returned while it is
|
|
103
|
+
* still valid.
|
|
104
|
+
*/
|
|
105
|
+
export declare class UnfazedOIDC {
|
|
106
|
+
#private;
|
|
107
|
+
constructor(options?: OIDCOptions);
|
|
108
|
+
/**
|
|
109
|
+
* A JWT for `audience`, from the cache while it is fresh.
|
|
110
|
+
*
|
|
111
|
+
* ```ts
|
|
112
|
+
* const jwt = await unfazed.oidc.token("https://api.example.com", { ttlSeconds: 300 });
|
|
113
|
+
* ```
|
|
114
|
+
*
|
|
115
|
+
* Throws {@link UnfazedOIDCError}: `not_in_app` without `UNFZ_STATE_TOKEN`,
|
|
116
|
+
* `invalid_audience`, `invalid_request` (`ttlSeconds`), or the platform's
|
|
117
|
+
* code.
|
|
118
|
+
*/
|
|
119
|
+
token(audience: string | undefined, options?: OIDCTokenOptions): Promise<string>;
|
|
120
|
+
/** {@link token}, with the token's expiry: `{ token, expiresAt }`. */
|
|
121
|
+
tokenResponse(audience: string | undefined, options?: OIDCTokenOptions): Promise<OIDCToken>;
|
|
122
|
+
/**
|
|
123
|
+
* A `fetch` that sends every request with `Authorization: Bearer <token>`,
|
|
124
|
+
* a fresh token for `audience` each time (from the cache while it lasts).
|
|
125
|
+
* Every other header, the body and the signal pass through; an
|
|
126
|
+
* `Authorization` already there is replaced.
|
|
127
|
+
*
|
|
128
|
+
* ```ts
|
|
129
|
+
* const openai = new OpenAI({
|
|
130
|
+
* baseURL: process.env.PRIVATEINFERENCE_BASE_URL,
|
|
131
|
+
* apiKey: "unused",
|
|
132
|
+
* fetch: unfazed.oidc.fetchWithToken(process.env.PRIVATEINFERENCE_AUDIENCE),
|
|
133
|
+
* });
|
|
134
|
+
* ```
|
|
135
|
+
*
|
|
136
|
+
* An invalid `audience` (an unset variable among them) throws here, at
|
|
137
|
+
* once; a missing `UNFZ_STATE_TOKEN` on the first request.
|
|
138
|
+
*/
|
|
139
|
+
fetchWithToken(audience: string | undefined, options?: FetchWithTokenOptions): TokenFetch;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* The process's own {@link UnfazedOIDC}: `UNFZ_STATE_TOKEN` and `UNFZ_API_URL`
|
|
143
|
+
* read on every call, the global `fetch`, one cache.
|
|
144
|
+
*
|
|
145
|
+
* ```ts
|
|
146
|
+
* import { oidc } from "unfazed";
|
|
147
|
+
* const fetchWithToken = oidc.fetchWithToken(process.env.PRIVATEINFERENCE_AUDIENCE);
|
|
148
|
+
* ```
|
|
149
|
+
*/
|
|
150
|
+
export declare const oidc: UnfazedOIDC;
|
|
151
|
+
/**
|
|
152
|
+
* A JWT for `audience`, issued to the app this code runs in:
|
|
153
|
+
* {@link UnfazedOIDC.token} on the process's {@link oidc}. Needs only
|
|
154
|
+
* `UNFZ_STATE_TOKEN`.
|
|
155
|
+
*
|
|
156
|
+
* ```ts
|
|
157
|
+
* import { oidcToken } from "unfazed";
|
|
158
|
+
* const res = await fetch(url, { headers: { Authorization: `Bearer ${await oidcToken(url)}` } });
|
|
159
|
+
* ```
|
|
160
|
+
*/
|
|
161
|
+
export declare function oidcToken(audience: string | undefined, options?: OIDCTokenOptions): Promise<string>;
|
package/dist/oidc.js
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workload identity: OIDC tokens the platform issues to an app.
|
|
3
|
+
*
|
|
4
|
+
* An app running on unfazed (a service or a cron job) asks the control plane
|
|
5
|
+
* for a short-lived JWT that names it, for one audience, and presents it to a
|
|
6
|
+
* service that trusts the issuer `https://unfazed.dev/oidc` instead of a
|
|
7
|
+
* static API key. The only credential is the app's own `UNFZ_STATE_TOKEN`,
|
|
8
|
+
* which the platform puts into each of its VMs; no `UNFZ_API_TOKEN` is
|
|
9
|
+
* involved.
|
|
10
|
+
*
|
|
11
|
+
* ```ts
|
|
12
|
+
* import { oidcToken } from "unfazed";
|
|
13
|
+
* const jwt = await oidcToken("https://api.example.com");
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* Wire contract: `POST <platform>/oidc/token` with
|
|
17
|
+
* `Authorization: Bearer $UNFZ_STATE_TOKEN` and `{ audience, ttl_seconds? }`;
|
|
18
|
+
* 200 is `{ token, expires_at, token_type: "Bearer" }`, an error
|
|
19
|
+
* `{ error, error_description }`.
|
|
20
|
+
*/
|
|
21
|
+
import { DEFAULT_ENDPOINT, VERSION, configuredEndpoint, platformBaseUrl, processEnv, untilAborted, } from "./client.js";
|
|
22
|
+
import { UnfazedConfigError, UnfazedOIDCError, UnfazedTimeoutError } from "./errors.js";
|
|
23
|
+
/** Environment variable holding the app's state token, set in every unfazed service and cron VM. */
|
|
24
|
+
export const STATE_TOKEN_ENV_VAR = "UNFZ_STATE_TOKEN";
|
|
25
|
+
/** Path of the token endpoint under the platform origin. */
|
|
26
|
+
export const OIDC_TOKEN_PATH = "/oidc/token";
|
|
27
|
+
/** A cached token is used until this share of its lifetime has passed. */
|
|
28
|
+
const REFRESH_AT = 0.8;
|
|
29
|
+
/** Retries after the first attempt, for 503s, 429s and network failures. */
|
|
30
|
+
const MAX_RETRIES = 3;
|
|
31
|
+
/** First retry delay; doubles per retry (200, 400, 800 ms). */
|
|
32
|
+
const BACKOFF_MS = 200;
|
|
33
|
+
/** The longest `Retry-After` waited out; a longer one fails at once, carrying it. */
|
|
34
|
+
const MAX_RETRY_AFTER_MS = 10_000;
|
|
35
|
+
const MIN_TTL_SECONDS = 60;
|
|
36
|
+
const MAX_TTL_SECONDS = 3600;
|
|
37
|
+
/** One attempt's timeout. */
|
|
38
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
39
|
+
/** 1 to 256 printable ASCII characters, none of them whitespace. */
|
|
40
|
+
const AUDIENCE = /^[\x21-\x7e]{1,256}$/;
|
|
41
|
+
/**
|
|
42
|
+
* One cache per process, not per copy of this module (a framework can bundle
|
|
43
|
+
* the package into several server layers) nor per `Unfazed` instance (a
|
|
44
|
+
* request handler that builds one per request must not ask for a token per
|
|
45
|
+
* request). Keys carry the token URL and the state token, so instances
|
|
46
|
+
* configured differently never share a token.
|
|
47
|
+
*/
|
|
48
|
+
const STORE_KEY = Symbol.for("unfazed//oidc/v1");
|
|
49
|
+
function sharedStore() {
|
|
50
|
+
const g = globalThis;
|
|
51
|
+
return (g[STORE_KEY] ??= newStore());
|
|
52
|
+
}
|
|
53
|
+
function newStore() {
|
|
54
|
+
return { cache: new Map(), inflight: new Map() };
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Workload identity tokens for the app this code runs in: `unfazed.oidc` on an
|
|
58
|
+
* {@link import("./unfazed.js").Unfazed} instance, or the standalone `oidc`.
|
|
59
|
+
*
|
|
60
|
+
* Tokens are cached per (audience, ttl) and reused until 80% of their
|
|
61
|
+
* lifetime has passed; concurrent calls for the same pair share one request.
|
|
62
|
+
* 503 (and a gateway's 502/504), 429 and network failures are retried up to 3
|
|
63
|
+
* times (200, 400, 800 ms; a 429's or 503's `Retry-After` up to 10 s is waited
|
|
64
|
+
* out instead); no other 4xx is retried. When a refresh fails for one of
|
|
65
|
+
* those passing reasons, the token it was replacing is returned while it is
|
|
66
|
+
* still valid.
|
|
67
|
+
*/
|
|
68
|
+
export class UnfazedOIDC {
|
|
69
|
+
#options;
|
|
70
|
+
#store;
|
|
71
|
+
constructor(options = {}) {
|
|
72
|
+
this.#options = { ...options };
|
|
73
|
+
this.#store = options.fetch || options.now ? newStore() : undefined;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* A JWT for `audience`, from the cache while it is fresh.
|
|
77
|
+
*
|
|
78
|
+
* ```ts
|
|
79
|
+
* const jwt = await unfazed.oidc.token("https://api.example.com", { ttlSeconds: 300 });
|
|
80
|
+
* ```
|
|
81
|
+
*
|
|
82
|
+
* Throws {@link UnfazedOIDCError}: `not_in_app` without `UNFZ_STATE_TOKEN`,
|
|
83
|
+
* `invalid_audience`, `invalid_request` (`ttlSeconds`), or the platform's
|
|
84
|
+
* code.
|
|
85
|
+
*/
|
|
86
|
+
async token(audience, options = {}) {
|
|
87
|
+
return (await this.tokenResponse(audience, options)).token;
|
|
88
|
+
}
|
|
89
|
+
/** {@link token}, with the token's expiry: `{ token, expiresAt }`. */
|
|
90
|
+
async tokenResponse(audience, options = {}) {
|
|
91
|
+
checkAudience(audience);
|
|
92
|
+
const ttl = options.ttlSeconds;
|
|
93
|
+
checkTtl(ttl);
|
|
94
|
+
const { signal } = options;
|
|
95
|
+
if (signal?.aborted)
|
|
96
|
+
throw aborted();
|
|
97
|
+
const stateToken = this.#stateToken();
|
|
98
|
+
const url = this.#tokenUrl();
|
|
99
|
+
const key = `${url}\n${stateToken}\n${audience}\n${ttl ?? ""}`;
|
|
100
|
+
const store = this.#store ?? sharedStore();
|
|
101
|
+
const cached = store.cache.get(key);
|
|
102
|
+
if (cached && this.#now() < cached.refreshAt)
|
|
103
|
+
return view(cached);
|
|
104
|
+
let pending = store.inflight.get(key);
|
|
105
|
+
if (!pending) {
|
|
106
|
+
pending = this.#issue(url, stateToken, audience, ttl)
|
|
107
|
+
.then((entry) => {
|
|
108
|
+
store.cache.set(key, entry);
|
|
109
|
+
return entry;
|
|
110
|
+
}, (error) => {
|
|
111
|
+
// The control plane restarting must not take down an app whose
|
|
112
|
+
// token is still good: fall back to the token being replaced.
|
|
113
|
+
const previous = store.cache.get(key);
|
|
114
|
+
if (previous && error instanceof UnfazedOIDCError && error.retryable && this.#now() < previous.validUntil) {
|
|
115
|
+
return previous;
|
|
116
|
+
}
|
|
117
|
+
throw error;
|
|
118
|
+
})
|
|
119
|
+
.finally(() => store.inflight.delete(key));
|
|
120
|
+
// Every caller may stop waiting (its signal); the request then ends
|
|
121
|
+
// with nobody listening, which must not be an unhandled rejection.
|
|
122
|
+
pending.catch(() => { });
|
|
123
|
+
store.inflight.set(key, pending);
|
|
124
|
+
}
|
|
125
|
+
return view(await untilAborted(pending, signal).catch(rethrowAbort));
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* A `fetch` that sends every request with `Authorization: Bearer <token>`,
|
|
129
|
+
* a fresh token for `audience` each time (from the cache while it lasts).
|
|
130
|
+
* Every other header, the body and the signal pass through; an
|
|
131
|
+
* `Authorization` already there is replaced.
|
|
132
|
+
*
|
|
133
|
+
* ```ts
|
|
134
|
+
* const openai = new OpenAI({
|
|
135
|
+
* baseURL: process.env.PRIVATEINFERENCE_BASE_URL,
|
|
136
|
+
* apiKey: "unused",
|
|
137
|
+
* fetch: unfazed.oidc.fetchWithToken(process.env.PRIVATEINFERENCE_AUDIENCE),
|
|
138
|
+
* });
|
|
139
|
+
* ```
|
|
140
|
+
*
|
|
141
|
+
* An invalid `audience` (an unset variable among them) throws here, at
|
|
142
|
+
* once; a missing `UNFZ_STATE_TOKEN` on the first request.
|
|
143
|
+
*/
|
|
144
|
+
fetchWithToken(audience, options = {}) {
|
|
145
|
+
checkAudience(audience);
|
|
146
|
+
const aud = audience;
|
|
147
|
+
const ttl = options.ttlSeconds;
|
|
148
|
+
checkTtl(ttl);
|
|
149
|
+
return async (input, init) => {
|
|
150
|
+
const request = typeof Request === "function" && input instanceof Request ? input : undefined;
|
|
151
|
+
const signal = init?.signal ?? request?.signal ?? undefined;
|
|
152
|
+
const token = await this.token(aud, {
|
|
153
|
+
...(ttl === undefined ? {} : { ttlSeconds: ttl }),
|
|
154
|
+
...(signal ? { signal } : {}),
|
|
155
|
+
});
|
|
156
|
+
// init.headers replace a Request's own, as fetch() itself does.
|
|
157
|
+
const headers = new Headers(init?.headers ?? request?.headers);
|
|
158
|
+
headers.set("Authorization", `Bearer ${token}`);
|
|
159
|
+
const send = options.fetch ?? globalFetch();
|
|
160
|
+
return send(input, { ...init, headers });
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
#now() {
|
|
164
|
+
return (this.#options.now ?? Date.now)();
|
|
165
|
+
}
|
|
166
|
+
#stateToken() {
|
|
167
|
+
const env = this.#options.env ?? processEnv();
|
|
168
|
+
const token = (this.#options.stateToken ?? env[STATE_TOKEN_ENV_VAR])?.trim();
|
|
169
|
+
if (!token) {
|
|
170
|
+
throw new UnfazedOIDCError("not_in_app", `unfazed: not running inside an unfazed app: ${STATE_TOKEN_ENV_VAR} is not set. ` +
|
|
171
|
+
`OIDC tokens are issued to an app's services and cron jobs, for the ${STATE_TOKEN_ENV_VAR} ` +
|
|
172
|
+
"the platform gives each of them (no UNFZ_API_TOKEN needed).");
|
|
173
|
+
}
|
|
174
|
+
return token;
|
|
175
|
+
}
|
|
176
|
+
#tokenUrl() {
|
|
177
|
+
const { endpoint, baseUrl } = this.#options;
|
|
178
|
+
const configured = configuredEndpoint({ ...(endpoint ? { endpoint } : {}), ...(baseUrl ? { baseUrl } : {}) }, this.#options.env ?? processEnv());
|
|
179
|
+
return platformBaseUrl(configured ?? DEFAULT_ENDPOINT) + OIDC_TOKEN_PATH;
|
|
180
|
+
}
|
|
181
|
+
async #issue(url, stateToken, audience, ttl) {
|
|
182
|
+
const transport = this.#options.fetch ?? globalFetch();
|
|
183
|
+
const sleep = this.#options.sleep ?? defaultSleep;
|
|
184
|
+
const userAgent = this.#options.userAgent ?? `unfazed-sdk/${VERSION}`;
|
|
185
|
+
const headers = {
|
|
186
|
+
"Content-Type": "application/json",
|
|
187
|
+
Accept: "application/json",
|
|
188
|
+
Authorization: `Bearer ${stateToken}`,
|
|
189
|
+
// The edge refuses some generic agents; always name ourselves.
|
|
190
|
+
...(userAgent ? { "User-Agent": userAgent } : {}),
|
|
191
|
+
};
|
|
192
|
+
const body = JSON.stringify(ttl === undefined ? { audience } : { audience, ttl_seconds: ttl });
|
|
193
|
+
const what = `unfazed: no OIDC token for audience ${JSON.stringify(audience)}`;
|
|
194
|
+
for (let retries = 0;; retries++) {
|
|
195
|
+
const startedAt = this.#now();
|
|
196
|
+
let answer;
|
|
197
|
+
try {
|
|
198
|
+
answer = await this.#send(transport, url, headers, body);
|
|
199
|
+
}
|
|
200
|
+
catch (cause) {
|
|
201
|
+
if (retries < MAX_RETRIES) {
|
|
202
|
+
await sleep(BACKOFF_MS * 2 ** retries);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
throw new UnfazedOIDCError("unreachable", `${what}: ${url} did not answer in ${retries + 1} attempts (${describe(cause)})`, { retryable: true, cause });
|
|
206
|
+
}
|
|
207
|
+
const { status, text, retryAfter } = answer;
|
|
208
|
+
if (status >= 200 && status < 300)
|
|
209
|
+
return this.#entry(text, startedAt, what);
|
|
210
|
+
const error = refusal(status, text, retryAfterMs(retryAfter, this.#now()), what);
|
|
211
|
+
if (!error.retryable || retries >= MAX_RETRIES)
|
|
212
|
+
throw error;
|
|
213
|
+
const waitMs = error.retryAfterMs ?? BACKOFF_MS * 2 ** retries;
|
|
214
|
+
if (waitMs > MAX_RETRY_AFTER_MS)
|
|
215
|
+
throw error;
|
|
216
|
+
await sleep(waitMs);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
async #send(transport, url, headers, body) {
|
|
220
|
+
const timeoutMs = this.#options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
221
|
+
const controller = timeoutMs > 0 && typeof AbortController === "function" ? new AbortController() : undefined;
|
|
222
|
+
const timer = controller ? setTimeout(() => controller.abort(), timeoutMs) : undefined;
|
|
223
|
+
try {
|
|
224
|
+
const signal = controller?.signal;
|
|
225
|
+
const response = await untilAborted(transport(url, { method: "POST", headers, body, ...(signal ? { signal } : {}) }), signal);
|
|
226
|
+
const text = await untilAborted(response.text(), signal);
|
|
227
|
+
return { status: response.status, text, retryAfter: response.headers?.get("retry-after") ?? null };
|
|
228
|
+
}
|
|
229
|
+
finally {
|
|
230
|
+
if (timer !== undefined)
|
|
231
|
+
clearTimeout(timer);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
#entry(text, startedAt, what) {
|
|
235
|
+
const body = parseJson(text);
|
|
236
|
+
const token = body?.token;
|
|
237
|
+
const expiresAtMs = typeof body?.expires_at === "string" ? Date.parse(body.expires_at) : NaN;
|
|
238
|
+
if (typeof token !== "string" || token === "" || !Number.isFinite(expiresAtMs)) {
|
|
239
|
+
throw new UnfazedOIDCError("invalid_response", `${what}: the platform answered 200 without a token and its expires_at: ${truncate(text)}`);
|
|
240
|
+
}
|
|
241
|
+
// The JWT's own iat/exp are both the platform's clock, so its lifetime
|
|
242
|
+
// holds even when this VM's clock is off (a guest resumed from a
|
|
243
|
+
// snapshot); elapsed time is then measured on this clock alone.
|
|
244
|
+
const lifetimeMs = jwtLifetimeMs(token) ?? expiresAtMs - startedAt;
|
|
245
|
+
const life = Math.max(0, lifetimeMs);
|
|
246
|
+
return {
|
|
247
|
+
token,
|
|
248
|
+
expiresAtMs,
|
|
249
|
+
refreshAt: startedAt + REFRESH_AT * life,
|
|
250
|
+
validUntil: startedAt + life,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* The process's own {@link UnfazedOIDC}: `UNFZ_STATE_TOKEN` and `UNFZ_API_URL`
|
|
256
|
+
* read on every call, the global `fetch`, one cache.
|
|
257
|
+
*
|
|
258
|
+
* ```ts
|
|
259
|
+
* import { oidc } from "unfazed";
|
|
260
|
+
* const fetchWithToken = oidc.fetchWithToken(process.env.PRIVATEINFERENCE_AUDIENCE);
|
|
261
|
+
* ```
|
|
262
|
+
*/
|
|
263
|
+
export const oidc = /* @__PURE__ */ new UnfazedOIDC();
|
|
264
|
+
/**
|
|
265
|
+
* A JWT for `audience`, issued to the app this code runs in:
|
|
266
|
+
* {@link UnfazedOIDC.token} on the process's {@link oidc}. Needs only
|
|
267
|
+
* `UNFZ_STATE_TOKEN`.
|
|
268
|
+
*
|
|
269
|
+
* ```ts
|
|
270
|
+
* import { oidcToken } from "unfazed";
|
|
271
|
+
* const res = await fetch(url, { headers: { Authorization: `Bearer ${await oidcToken(url)}` } });
|
|
272
|
+
* ```
|
|
273
|
+
*/
|
|
274
|
+
export function oidcToken(audience, options) {
|
|
275
|
+
return oidc.token(audience, options);
|
|
276
|
+
}
|
|
277
|
+
function view(entry) {
|
|
278
|
+
return { token: entry.token, expiresAt: new Date(entry.expiresAtMs) };
|
|
279
|
+
}
|
|
280
|
+
function checkAudience(audience) {
|
|
281
|
+
if (typeof audience === "string" && AUDIENCE.test(audience))
|
|
282
|
+
return;
|
|
283
|
+
const got = typeof audience === "string" ? truncate(JSON.stringify(audience), 80) : String(audience);
|
|
284
|
+
throw new UnfazedOIDCError("invalid_audience", `unfazed: invalid OIDC audience ${got}: it must be 1 to 256 printable ASCII characters, without whitespace`);
|
|
285
|
+
}
|
|
286
|
+
function checkTtl(ttl) {
|
|
287
|
+
if (ttl === undefined)
|
|
288
|
+
return;
|
|
289
|
+
if (typeof ttl === "number" && Number.isInteger(ttl) && ttl >= MIN_TTL_SECONDS && ttl <= MAX_TTL_SECONDS)
|
|
290
|
+
return;
|
|
291
|
+
throw new UnfazedOIDCError("invalid_request", `unfazed: ttlSeconds must be a whole number from ${MIN_TTL_SECONDS} to ${MAX_TTL_SECONDS} (got ${String(ttl)})`);
|
|
292
|
+
}
|
|
293
|
+
/** The error for a non-2xx answer; `retryable` says whether the loop asks again. */
|
|
294
|
+
function refusal(status, text, retryAfter, what) {
|
|
295
|
+
const body = parseJson(text);
|
|
296
|
+
const code = typeof body?.error === "string" && body.error ? body.error : codeForStatus(status);
|
|
297
|
+
const description = typeof body?.error_description === "string" && body.error_description ? body.error_description : undefined;
|
|
298
|
+
const detail = description ?? (body === undefined && text ? truncate(text) : undefined);
|
|
299
|
+
// Never a 4xx but 429: the request itself is what is wrong.
|
|
300
|
+
const retryable = status === 429 ||
|
|
301
|
+
status === 502 ||
|
|
302
|
+
status === 503 ||
|
|
303
|
+
status === 504 ||
|
|
304
|
+
(status >= 500 && code === "temporarily_unavailable");
|
|
305
|
+
const hint = code === "invalid_token" ? ` (the platform refused ${STATE_TOKEN_ENV_VAR})` : "";
|
|
306
|
+
const later = retryAfter === undefined ? "" : `; retry after ${Math.ceil(retryAfter / 1000)} s`;
|
|
307
|
+
return new UnfazedOIDCError(code, `${what}: ${code} (HTTP ${status})${hint}${detail ? `: ${detail}` : ""}${later}`, {
|
|
308
|
+
status,
|
|
309
|
+
...(description ? { description } : {}),
|
|
310
|
+
retryable,
|
|
311
|
+
...(retryAfter === undefined ? {} : { retryAfterMs: retryAfter }),
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
function codeForStatus(status) {
|
|
315
|
+
switch (status) {
|
|
316
|
+
case 400:
|
|
317
|
+
return "invalid_request";
|
|
318
|
+
case 401:
|
|
319
|
+
return "invalid_token";
|
|
320
|
+
case 429:
|
|
321
|
+
return "rate_limited";
|
|
322
|
+
case 503:
|
|
323
|
+
return "temporarily_unavailable";
|
|
324
|
+
default:
|
|
325
|
+
return "http_error";
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
/** `Retry-After` in ms: delay-seconds or an HTTP-date; undefined when absent or unreadable. */
|
|
329
|
+
function retryAfterMs(value, now) {
|
|
330
|
+
if (value === null)
|
|
331
|
+
return undefined;
|
|
332
|
+
const trimmed = value.trim();
|
|
333
|
+
if (/^\d+$/.test(trimmed))
|
|
334
|
+
return Number(trimmed) * 1000;
|
|
335
|
+
const at = Date.parse(trimmed);
|
|
336
|
+
return Number.isFinite(at) ? Math.max(0, at - now) : undefined;
|
|
337
|
+
}
|
|
338
|
+
/** `exp - iat` of a JWT, in ms, when both are there. */
|
|
339
|
+
function jwtLifetimeMs(token) {
|
|
340
|
+
const payload = token.split(".")[1];
|
|
341
|
+
if (!payload || typeof atob !== "function")
|
|
342
|
+
return undefined;
|
|
343
|
+
try {
|
|
344
|
+
const base64 = payload.replace(/-/g, "+").replace(/_/g, "/");
|
|
345
|
+
const claims = JSON.parse(atob(base64.padEnd(Math.ceil(base64.length / 4) * 4, "=")));
|
|
346
|
+
const { iat, exp } = claims;
|
|
347
|
+
if (typeof iat === "number" && typeof exp === "number" && Number.isFinite(iat) && exp > iat) {
|
|
348
|
+
return (exp - iat) * 1000;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
catch {
|
|
352
|
+
// Not a JWT we can read: the platform's expires_at decides.
|
|
353
|
+
}
|
|
354
|
+
return undefined;
|
|
355
|
+
}
|
|
356
|
+
function globalFetch() {
|
|
357
|
+
const fetch = globalThis.fetch;
|
|
358
|
+
if (typeof fetch !== "function") {
|
|
359
|
+
throw new UnfazedConfigError("unfazed: no global fetch. Use Bun, Node >= 20, or pass { fetch }.");
|
|
360
|
+
}
|
|
361
|
+
return fetch.bind(globalThis);
|
|
362
|
+
}
|
|
363
|
+
function defaultSleep(ms) {
|
|
364
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
365
|
+
}
|
|
366
|
+
function aborted() {
|
|
367
|
+
return new UnfazedTimeoutError("unfazed: OIDC token request aborted");
|
|
368
|
+
}
|
|
369
|
+
function rethrowAbort(error) {
|
|
370
|
+
if (typeof error === "object" && error !== null && error.name === "AbortError") {
|
|
371
|
+
throw aborted();
|
|
372
|
+
}
|
|
373
|
+
throw error;
|
|
374
|
+
}
|
|
375
|
+
function describe(cause) {
|
|
376
|
+
if (cause instanceof Error)
|
|
377
|
+
return cause.name === "AbortError" ? "timed out" : cause.message;
|
|
378
|
+
return String(cause);
|
|
379
|
+
}
|
|
380
|
+
function parseJson(text) {
|
|
381
|
+
try {
|
|
382
|
+
return JSON.parse(text);
|
|
383
|
+
}
|
|
384
|
+
catch {
|
|
385
|
+
return undefined;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
function truncate(s, max = 300) {
|
|
389
|
+
return s.length <= max ? s : `${s.slice(0, max)}…`;
|
|
390
|
+
}
|
|
391
|
+
//# sourceMappingURL=oidc.js.map
|
package/dist/oidc.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"oidc.js","sourceRoot":"","sources":["../src/oidc.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Workload identity: OIDC tokens the platform issues to an app.\n *\n * An app running on unfazed (a service or a cron job) asks the control plane\n * for a short-lived JWT that names it, for one audience, and presents it to a\n * service that trusts the issuer `https://unfazed.dev/oidc` instead of a\n * static API key. The only credential is the app's own `UNFZ_STATE_TOKEN`,\n * which the platform puts into each of its VMs; no `UNFZ_API_TOKEN` is\n * involved.\n *\n * ```ts\n * import { oidcToken } from \"unfazed\";\n * const jwt = await oidcToken(\"https://api.example.com\");\n * ```\n *\n * Wire contract: `POST <platform>/oidc/token` with\n * `Authorization: Bearer $UNFZ_STATE_TOKEN` and `{ audience, ttl_seconds? }`;\n * 200 is `{ token, expires_at, token_type: \"Bearer\" }`, an error\n * `{ error, error_description }`.\n */\n\nimport {\n DEFAULT_ENDPOINT,\n VERSION,\n configuredEndpoint,\n platformBaseUrl,\n processEnv,\n untilAborted,\n} from \"./client.js\";\nimport { UnfazedConfigError, UnfazedOIDCError, UnfazedTimeoutError, type OIDCErrorCode } from \"./errors.js\";\n\n/** Environment variable holding the app's state token, set in every unfazed service and cron VM. */\nexport const STATE_TOKEN_ENV_VAR = \"UNFZ_STATE_TOKEN\";\n\n/** Path of the token endpoint under the platform origin. */\nexport const OIDC_TOKEN_PATH = \"/oidc/token\";\n\n/** A cached token is used until this share of its lifetime has passed. */\nconst REFRESH_AT = 0.8;\n/** Retries after the first attempt, for 503s, 429s and network failures. */\nconst MAX_RETRIES = 3;\n/** First retry delay; doubles per retry (200, 400, 800 ms). */\nconst BACKOFF_MS = 200;\n/** The longest `Retry-After` waited out; a longer one fails at once, carrying it. */\nconst MAX_RETRY_AFTER_MS = 10_000;\nconst MIN_TTL_SECONDS = 60;\nconst MAX_TTL_SECONDS = 3600;\n/** One attempt's timeout. */\nconst DEFAULT_TIMEOUT_MS = 10_000;\n/** 1 to 256 printable ASCII characters, none of them whitespace. */\nconst AUDIENCE = /^[\\x21-\\x7e]{1,256}$/;\n\n/**\n * The `fetch` the token request goes through: the global `fetch`, or anything\n * shaped like it (the client's `FetchLike` fits). `headers` on the response\n * is read for `Retry-After` when present.\n */\nexport type OIDCTransport = (\n input: string,\n init: {\n method: string;\n headers: Record<string, string>;\n body: string;\n signal?: AbortSignal;\n },\n) => Promise<{\n status: number;\n headers?: { get(name: string): string | null };\n text(): Promise<string>;\n}>;\n\n/** A `fetch` as the OpenAI SDK (and most HTTP clients) take one. */\nexport type TokenFetch = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;\n\nexport interface OIDCOptions {\n /** The app's state token. Default: `UNFZ_STATE_TOKEN`, read on every call. */\n stateToken?: string;\n /**\n * Full GraphQL endpoint; tokens come from its origin. Wins over `baseUrl`.\n * Falls back to `UNFZ_API_URL`, then `https://unfazed.dev` (as the client).\n */\n endpoint?: string;\n /** Platform origin; `/oidc/token` is appended. */\n baseUrl?: string;\n /** Where `UNFZ_STATE_TOKEN` and `UNFZ_API_URL` are read from. Default: `process.env`. */\n env?: Record<string, string | undefined>;\n /**\n * The transport of the token request. Default: the global `fetch`. An\n * instance with its own `fetch` (or `now`) keeps its own cache; every other\n * one shares the process's.\n */\n fetch?: OIDCTransport;\n /** Overrides the default `unfazed-sdk/<version>` User-Agent. */\n userAgent?: string;\n /** Timeout of one attempt, in ms. Default 10_000; 0 disables it. */\n timeoutMs?: number;\n /** Injected wall clock, ms (tests). */\n now?: () => number;\n /** Injected sleep between retries (tests). */\n sleep?: (ms: number) => Promise<void>;\n}\n\nexport interface OIDCTokenOptions {\n /** Token lifetime, a whole number of seconds from 60 to 3600. Default: the platform's. */\n ttlSeconds?: number;\n /**\n * Stops waiting, with {@link UnfazedTimeoutError}. A request other callers\n * wait on too keeps going (and fills the cache).\n */\n signal?: AbortSignal;\n}\n\n/** A token with its expiry, from {@link UnfazedOIDC.tokenResponse}. */\nexport interface OIDCToken {\n /** The JWT. */\n token: string;\n /** When it expires (the platform's `expires_at`). */\n expiresAt: Date;\n}\n\nexport interface FetchWithTokenOptions {\n /** Token lifetime, a whole number of seconds from 60 to 3600. Default: the platform's. */\n ttlSeconds?: number;\n /** The `fetch` that sends the request. Default: the global `fetch`, at call time. */\n fetch?: TokenFetch;\n}\n\ninterface Entry {\n token: string;\n expiresAtMs: number;\n /** When a call stops using it and asks for a new one (80% of its lifetime). */\n refreshAt: number;\n /** When it expires, on this process's clock. */\n validUntil: number;\n}\n\ninterface Store {\n cache: Map<string, Entry>;\n inflight: Map<string, Promise<Entry>>;\n}\n\n/**\n * One cache per process, not per copy of this module (a framework can bundle\n * the package into several server layers) nor per `Unfazed` instance (a\n * request handler that builds one per request must not ask for a token per\n * request). Keys carry the token URL and the state token, so instances\n * configured differently never share a token.\n */\nconst STORE_KEY = Symbol.for(\"unfazed//oidc/v1\");\n\nfunction sharedStore(): Store {\n const g = globalThis as typeof globalThis & Record<symbol, Store | undefined>;\n return (g[STORE_KEY] ??= newStore());\n}\n\nfunction newStore(): Store {\n return { cache: new Map(), inflight: new Map() };\n}\n\n/**\n * Workload identity tokens for the app this code runs in: `unfazed.oidc` on an\n * {@link import(\"./unfazed.js\").Unfazed} instance, or the standalone `oidc`.\n *\n * Tokens are cached per (audience, ttl) and reused until 80% of their\n * lifetime has passed; concurrent calls for the same pair share one request.\n * 503 (and a gateway's 502/504), 429 and network failures are retried up to 3\n * times (200, 400, 800 ms; a 429's or 503's `Retry-After` up to 10 s is waited\n * out instead); no other 4xx is retried. When a refresh fails for one of\n * those passing reasons, the token it was replacing is returned while it is\n * still valid.\n */\nexport class UnfazedOIDC {\n readonly #options: OIDCOptions;\n readonly #store: Store | undefined;\n\n constructor(options: OIDCOptions = {}) {\n this.#options = { ...options };\n this.#store = options.fetch || options.now ? newStore() : undefined;\n }\n\n /**\n * A JWT for `audience`, from the cache while it is fresh.\n *\n * ```ts\n * const jwt = await unfazed.oidc.token(\"https://api.example.com\", { ttlSeconds: 300 });\n * ```\n *\n * Throws {@link UnfazedOIDCError}: `not_in_app` without `UNFZ_STATE_TOKEN`,\n * `invalid_audience`, `invalid_request` (`ttlSeconds`), or the platform's\n * code.\n */\n async token(audience: string | undefined, options: OIDCTokenOptions = {}): Promise<string> {\n return (await this.tokenResponse(audience, options)).token;\n }\n\n /** {@link token}, with the token's expiry: `{ token, expiresAt }`. */\n async tokenResponse(audience: string | undefined, options: OIDCTokenOptions = {}): Promise<OIDCToken> {\n checkAudience(audience);\n const ttl = options.ttlSeconds;\n checkTtl(ttl);\n const { signal } = options;\n if (signal?.aborted) throw aborted();\n\n const stateToken = this.#stateToken();\n const url = this.#tokenUrl();\n const key = `${url}\\n${stateToken}\\n${audience}\\n${ttl ?? \"\"}`;\n const store = this.#store ?? sharedStore();\n\n const cached = store.cache.get(key);\n if (cached && this.#now() < cached.refreshAt) return view(cached);\n\n let pending = store.inflight.get(key);\n if (!pending) {\n pending = this.#issue(url, stateToken, audience, ttl)\n .then(\n (entry) => {\n store.cache.set(key, entry);\n return entry;\n },\n (error: unknown) => {\n // The control plane restarting must not take down an app whose\n // token is still good: fall back to the token being replaced.\n const previous = store.cache.get(key);\n if (previous && error instanceof UnfazedOIDCError && error.retryable && this.#now() < previous.validUntil) {\n return previous;\n }\n throw error;\n },\n )\n .finally(() => store.inflight.delete(key));\n // Every caller may stop waiting (its signal); the request then ends\n // with nobody listening, which must not be an unhandled rejection.\n pending.catch(() => {});\n store.inflight.set(key, pending);\n }\n return view(await untilAborted(pending, signal).catch(rethrowAbort));\n }\n\n /**\n * A `fetch` that sends every request with `Authorization: Bearer <token>`,\n * a fresh token for `audience` each time (from the cache while it lasts).\n * Every other header, the body and the signal pass through; an\n * `Authorization` already there is replaced.\n *\n * ```ts\n * const openai = new OpenAI({\n * baseURL: process.env.PRIVATEINFERENCE_BASE_URL,\n * apiKey: \"unused\",\n * fetch: unfazed.oidc.fetchWithToken(process.env.PRIVATEINFERENCE_AUDIENCE),\n * });\n * ```\n *\n * An invalid `audience` (an unset variable among them) throws here, at\n * once; a missing `UNFZ_STATE_TOKEN` on the first request.\n */\n fetchWithToken(audience: string | undefined, options: FetchWithTokenOptions = {}): TokenFetch {\n checkAudience(audience);\n const aud: string = audience;\n const ttl = options.ttlSeconds;\n checkTtl(ttl);\n return async (input, init) => {\n const request = typeof Request === \"function\" && input instanceof Request ? input : undefined;\n const signal = init?.signal ?? request?.signal ?? undefined;\n const token = await this.token(aud, {\n ...(ttl === undefined ? {} : { ttlSeconds: ttl }),\n ...(signal ? { signal } : {}),\n });\n // init.headers replace a Request's own, as fetch() itself does.\n const headers = new Headers(init?.headers ?? request?.headers);\n headers.set(\"Authorization\", `Bearer ${token}`);\n const send = options.fetch ?? (globalFetch() as TokenFetch);\n return send(input, { ...init, headers });\n };\n }\n\n #now(): number {\n return (this.#options.now ?? Date.now)();\n }\n\n #stateToken(): string {\n const env = this.#options.env ?? processEnv();\n const token = (this.#options.stateToken ?? env[STATE_TOKEN_ENV_VAR])?.trim();\n if (!token) {\n throw new UnfazedOIDCError(\n \"not_in_app\",\n `unfazed: not running inside an unfazed app: ${STATE_TOKEN_ENV_VAR} is not set. ` +\n `OIDC tokens are issued to an app's services and cron jobs, for the ${STATE_TOKEN_ENV_VAR} ` +\n \"the platform gives each of them (no UNFZ_API_TOKEN needed).\",\n );\n }\n return token;\n }\n\n #tokenUrl(): string {\n const { endpoint, baseUrl } = this.#options;\n const configured = configuredEndpoint(\n { ...(endpoint ? { endpoint } : {}), ...(baseUrl ? { baseUrl } : {}) },\n this.#options.env ?? processEnv(),\n );\n return platformBaseUrl(configured ?? DEFAULT_ENDPOINT) + OIDC_TOKEN_PATH;\n }\n\n async #issue(url: string, stateToken: string, audience: string, ttl: number | undefined): Promise<Entry> {\n const transport = this.#options.fetch ?? (globalFetch() as OIDCTransport);\n const sleep = this.#options.sleep ?? defaultSleep;\n const userAgent = this.#options.userAgent ?? `unfazed-sdk/${VERSION}`;\n const headers: Record<string, string> = {\n \"Content-Type\": \"application/json\",\n Accept: \"application/json\",\n Authorization: `Bearer ${stateToken}`,\n // The edge refuses some generic agents; always name ourselves.\n ...(userAgent ? { \"User-Agent\": userAgent } : {}),\n };\n const body = JSON.stringify(ttl === undefined ? { audience } : { audience, ttl_seconds: ttl });\n const what = `unfazed: no OIDC token for audience ${JSON.stringify(audience)}`;\n\n for (let retries = 0; ; retries++) {\n const startedAt = this.#now();\n let answer: { status: number; text: string; retryAfter: string | null };\n try {\n answer = await this.#send(transport, url, headers, body);\n } catch (cause) {\n if (retries < MAX_RETRIES) {\n await sleep(BACKOFF_MS * 2 ** retries);\n continue;\n }\n throw new UnfazedOIDCError(\n \"unreachable\",\n `${what}: ${url} did not answer in ${retries + 1} attempts (${describe(cause)})`,\n { retryable: true, cause },\n );\n }\n const { status, text, retryAfter } = answer;\n if (status >= 200 && status < 300) return this.#entry(text, startedAt, what);\n\n const error = refusal(status, text, retryAfterMs(retryAfter, this.#now()), what);\n if (!error.retryable || retries >= MAX_RETRIES) throw error;\n const waitMs = error.retryAfterMs ?? BACKOFF_MS * 2 ** retries;\n if (waitMs > MAX_RETRY_AFTER_MS) throw error;\n await sleep(waitMs);\n }\n }\n\n async #send(\n transport: OIDCTransport,\n url: string,\n headers: Record<string, string>,\n body: string,\n ): Promise<{ status: number; text: string; retryAfter: string | null }> {\n const timeoutMs = this.#options.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n const controller = timeoutMs > 0 && typeof AbortController === \"function\" ? new AbortController() : undefined;\n const timer = controller ? setTimeout(() => controller.abort(), timeoutMs) : undefined;\n try {\n const signal = controller?.signal;\n const response = await untilAborted(\n transport(url, { method: \"POST\", headers, body, ...(signal ? { signal } : {}) }),\n signal,\n );\n const text = await untilAborted(response.text(), signal);\n return { status: response.status, text, retryAfter: response.headers?.get(\"retry-after\") ?? null };\n } finally {\n if (timer !== undefined) clearTimeout(timer);\n }\n }\n\n #entry(text: string, startedAt: number, what: string): Entry {\n const body = parseJson(text) as { token?: unknown; expires_at?: unknown } | undefined;\n const token = body?.token;\n const expiresAtMs = typeof body?.expires_at === \"string\" ? Date.parse(body.expires_at) : NaN;\n if (typeof token !== \"string\" || token === \"\" || !Number.isFinite(expiresAtMs)) {\n throw new UnfazedOIDCError(\n \"invalid_response\",\n `${what}: the platform answered 200 without a token and its expires_at: ${truncate(text)}`,\n );\n }\n // The JWT's own iat/exp are both the platform's clock, so its lifetime\n // holds even when this VM's clock is off (a guest resumed from a\n // snapshot); elapsed time is then measured on this clock alone.\n const lifetimeMs = jwtLifetimeMs(token) ?? expiresAtMs - startedAt;\n const life = Math.max(0, lifetimeMs);\n return {\n token,\n expiresAtMs,\n refreshAt: startedAt + REFRESH_AT * life,\n validUntil: startedAt + life,\n };\n }\n}\n\n/**\n * The process's own {@link UnfazedOIDC}: `UNFZ_STATE_TOKEN` and `UNFZ_API_URL`\n * read on every call, the global `fetch`, one cache.\n *\n * ```ts\n * import { oidc } from \"unfazed\";\n * const fetchWithToken = oidc.fetchWithToken(process.env.PRIVATEINFERENCE_AUDIENCE);\n * ```\n */\nexport const oidc: UnfazedOIDC = /* @__PURE__ */ new UnfazedOIDC();\n\n/**\n * A JWT for `audience`, issued to the app this code runs in:\n * {@link UnfazedOIDC.token} on the process's {@link oidc}. Needs only\n * `UNFZ_STATE_TOKEN`.\n *\n * ```ts\n * import { oidcToken } from \"unfazed\";\n * const res = await fetch(url, { headers: { Authorization: `Bearer ${await oidcToken(url)}` } });\n * ```\n */\nexport function oidcToken(audience: string | undefined, options?: OIDCTokenOptions): Promise<string> {\n return oidc.token(audience, options);\n}\n\nfunction view(entry: Entry): OIDCToken {\n return { token: entry.token, expiresAt: new Date(entry.expiresAtMs) };\n}\n\nfunction checkAudience(audience: unknown): asserts audience is string {\n if (typeof audience === \"string\" && AUDIENCE.test(audience)) return;\n const got = typeof audience === \"string\" ? truncate(JSON.stringify(audience), 80) : String(audience);\n throw new UnfazedOIDCError(\n \"invalid_audience\",\n `unfazed: invalid OIDC audience ${got}: it must be 1 to 256 printable ASCII characters, without whitespace`,\n );\n}\n\nfunction checkTtl(ttl: unknown): void {\n if (ttl === undefined) return;\n if (typeof ttl === \"number\" && Number.isInteger(ttl) && ttl >= MIN_TTL_SECONDS && ttl <= MAX_TTL_SECONDS) return;\n throw new UnfazedOIDCError(\n \"invalid_request\",\n `unfazed: ttlSeconds must be a whole number from ${MIN_TTL_SECONDS} to ${MAX_TTL_SECONDS} (got ${String(ttl)})`,\n );\n}\n\n/** The error for a non-2xx answer; `retryable` says whether the loop asks again. */\nfunction refusal(status: number, text: string, retryAfter: number | undefined, what: string): UnfazedOIDCError {\n const body = parseJson(text) as { error?: unknown; error_description?: unknown } | undefined;\n const code = typeof body?.error === \"string\" && body.error ? body.error : codeForStatus(status);\n const description =\n typeof body?.error_description === \"string\" && body.error_description ? body.error_description : undefined;\n const detail = description ?? (body === undefined && text ? truncate(text) : undefined);\n // Never a 4xx but 429: the request itself is what is wrong.\n const retryable =\n status === 429 ||\n status === 502 ||\n status === 503 ||\n status === 504 ||\n (status >= 500 && code === \"temporarily_unavailable\");\n const hint = code === \"invalid_token\" ? ` (the platform refused ${STATE_TOKEN_ENV_VAR})` : \"\";\n const later = retryAfter === undefined ? \"\" : `; retry after ${Math.ceil(retryAfter / 1000)} s`;\n return new UnfazedOIDCError(code, `${what}: ${code} (HTTP ${status})${hint}${detail ? `: ${detail}` : \"\"}${later}`, {\n status,\n ...(description ? { description } : {}),\n retryable,\n ...(retryAfter === undefined ? {} : { retryAfterMs: retryAfter }),\n });\n}\n\nfunction codeForStatus(status: number): OIDCErrorCode {\n switch (status) {\n case 400:\n return \"invalid_request\";\n case 401:\n return \"invalid_token\";\n case 429:\n return \"rate_limited\";\n case 503:\n return \"temporarily_unavailable\";\n default:\n return \"http_error\";\n }\n}\n\n/** `Retry-After` in ms: delay-seconds or an HTTP-date; undefined when absent or unreadable. */\nfunction retryAfterMs(value: string | null, now: number): number | undefined {\n if (value === null) return undefined;\n const trimmed = value.trim();\n if (/^\\d+$/.test(trimmed)) return Number(trimmed) * 1000;\n const at = Date.parse(trimmed);\n return Number.isFinite(at) ? Math.max(0, at - now) : undefined;\n}\n\n/** `exp - iat` of a JWT, in ms, when both are there. */\nfunction jwtLifetimeMs(token: string): number | undefined {\n const payload = token.split(\".\")[1];\n if (!payload || typeof atob !== \"function\") return undefined;\n try {\n const base64 = payload.replace(/-/g, \"+\").replace(/_/g, \"/\");\n const claims = JSON.parse(atob(base64.padEnd(Math.ceil(base64.length / 4) * 4, \"=\"))) as {\n iat?: unknown;\n exp?: unknown;\n };\n const { iat, exp } = claims;\n if (typeof iat === \"number\" && typeof exp === \"number\" && Number.isFinite(iat) && exp > iat) {\n return (exp - iat) * 1000;\n }\n } catch {\n // Not a JWT we can read: the platform's expires_at decides.\n }\n return undefined;\n}\n\nfunction globalFetch(): (input: string, init: RequestInit) => Promise<Response> {\n const fetch = (globalThis as { fetch?: unknown }).fetch;\n if (typeof fetch !== \"function\") {\n throw new UnfazedConfigError(\"unfazed: no global fetch. Use Bun, Node >= 20, or pass { fetch }.\");\n }\n return fetch.bind(globalThis) as (input: string, init: RequestInit) => Promise<Response>;\n}\n\nfunction defaultSleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\nfunction aborted(): UnfazedTimeoutError {\n return new UnfazedTimeoutError(\"unfazed: OIDC token request aborted\");\n}\n\nfunction rethrowAbort(error: unknown): never {\n if (typeof error === \"object\" && error !== null && (error as { name?: unknown }).name === \"AbortError\") {\n throw aborted();\n }\n throw error;\n}\n\nfunction describe(cause: unknown): string {\n if (cause instanceof Error) return cause.name === \"AbortError\" ? \"timed out\" : cause.message;\n return String(cause);\n}\n\nfunction parseJson(text: string): unknown {\n try {\n return JSON.parse(text) as unknown;\n } catch {\n return undefined;\n }\n}\n\nfunction truncate(s: string, max = 300): string {\n return s.length <= max ? s : `${s.slice(0, max)}…`;\n}\n"]}
|