94 lines
4.6 KiB
JavaScript
94 lines
4.6 KiB
JavaScript
import { CREDENTIALS_FILE_VERSION } from "../../core/credentials.mjs";
|
|
import { GRANT_TYPE_REFRESH_TOKEN, MANDATORY_REFRESH_THRESHOLD_IN_SECONDS, OAUTH_API_BETA_HEADER, TOKEN_ENDPOINT, WorkloadIdentityError, checkCredentialsFileSafety, parseTokenResponse, redactSensitive, requireSecureTokenEndpoint, writeCredentialsFileAtomic, } from "./types.mjs";
|
|
import { nowAsSeconds } from "../../internal/utils/time.mjs";
|
|
import { VERSION } from "../../version.mjs";
|
|
/**
|
|
* Reads a user-oauth credential file. Returns the cached access token while
|
|
* fresh; on expiry performs a `refresh_token` grant and writes the new
|
|
* tokens back to the credentials file (atomic replace, fsync'd).
|
|
*
|
|
* If `clientId` is empty, the access token is treated as static — the
|
|
* credentials file is read on every call but no refresh is attempted, and
|
|
* an expired token without a `refresh_token` raises.
|
|
*/
|
|
export function userOAuthProvider(config) {
|
|
return async (opts) => {
|
|
const fs = await import('node:fs');
|
|
await checkCredentialsFileSafety(config.credentialsPath, config.onSafetyWarning);
|
|
let raw;
|
|
try {
|
|
raw = await fs.promises.readFile(config.credentialsPath, 'utf-8');
|
|
}
|
|
catch (err) {
|
|
throw new WorkloadIdentityError(`Credentials file not found at ${config.credentialsPath}: ${err}`);
|
|
}
|
|
let creds;
|
|
try {
|
|
creds = JSON.parse(raw);
|
|
}
|
|
catch (err) {
|
|
throw new WorkloadIdentityError(`Credentials file at ${config.credentialsPath} is not valid JSON: ${err}`);
|
|
}
|
|
const accessToken = creds.access_token;
|
|
if (!accessToken) {
|
|
throw new WorkloadIdentityError(`Credentials file at ${config.credentialsPath} must include 'access_token'`);
|
|
}
|
|
// Return cached token if still fresh (or no expiry info), unless the
|
|
// caller is forcing a refresh after a 401 — then go straight to refresh
|
|
// even if the file's expires_at still looks valid.
|
|
const expiresAt = creds.expires_at;
|
|
if (!opts?.forceRefresh &&
|
|
(expiresAt == null || nowAsSeconds() < expiresAt - MANDATORY_REFRESH_THRESHOLD_IN_SECONDS)) {
|
|
return { token: accessToken, expiresAt: expiresAt ?? null };
|
|
}
|
|
const refreshToken = creds.refresh_token;
|
|
if (!config.clientId || !refreshToken) {
|
|
throw new WorkloadIdentityError(`Access token at ${config.credentialsPath} has expired and no refresh is available ` +
|
|
`(client_id ${config.clientId ? 'set' : 'empty'}, refresh_token ${refreshToken ? 'set' : 'empty'})`);
|
|
}
|
|
requireSecureTokenEndpoint(config.baseURL);
|
|
const body = {
|
|
grant_type: GRANT_TYPE_REFRESH_TOKEN,
|
|
refresh_token: refreshToken,
|
|
client_id: config.clientId,
|
|
};
|
|
const url = `${config.baseURL}${TOKEN_ENDPOINT}`;
|
|
let resp;
|
|
try {
|
|
resp = await config.fetch(url, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'anthropic-beta': OAUTH_API_BETA_HEADER,
|
|
'User-Agent': config.userAgent || `anthropic-sdk-typescript/${VERSION} userOAuthProvider`,
|
|
},
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
catch (err) {
|
|
throw new WorkloadIdentityError(`User OAuth refresh failed to reach token endpoint: ${err}`);
|
|
}
|
|
const requestId = resp.headers.get('Request-Id');
|
|
if (!resp.ok) {
|
|
const text = await resp.text().catch(() => '');
|
|
throw new WorkloadIdentityError(`User OAuth refresh failed (HTTP ${resp.status}): ${redactSensitive(text)}`, resp.status, redactSensitive(text), requestId);
|
|
}
|
|
const data = await parseTokenResponse(resp, requestId);
|
|
const expiresIn = Number(data.expires_in);
|
|
if (!Number.isFinite(expiresIn)) {
|
|
throw new WorkloadIdentityError(`User OAuth refresh response missing or invalid expires_in: ${JSON.stringify(redactSensitive(data))}`, resp.status, redactSensitive(data), requestId);
|
|
}
|
|
const newExpiresAt = nowAsSeconds() + expiresIn;
|
|
const newRefreshToken = data.refresh_token || refreshToken;
|
|
await writeCredentialsFileAtomic(config.credentialsPath, {
|
|
...creds,
|
|
version: CREDENTIALS_FILE_VERSION,
|
|
type: 'oauth_token',
|
|
access_token: data.access_token,
|
|
expires_at: newExpiresAt,
|
|
refresh_token: newRefreshToken,
|
|
});
|
|
return { token: data.access_token, expiresAt: newExpiresAt };
|
|
};
|
|
}
|
|
//# sourceMappingURL=user-oauth.mjs.map
|