// ---------------------------------------------------------------------------// @executor/plugin-oauth2 — generic OAuth 2.0 helpers//// Pure helpers for building authorization URLs, exchanging codes, and// refreshing tokens against a standards-compliant OAuth 2.0 token endpoint.// Plugins (google-discovery, openapi, ...) wrap these with their own// session storage, secret management, and onboarding UI.//// Every public helper is intentionally provider-agnostic. Provider-specific// query parameters (Google's `access_type=offline`, `prompt=consent`, etc.)// are passed via the `extraParams` escape hatch so callers don't lose// fidelity when switching from a hand-rolled implementation.// --------------------------------------------------------------------------- import { createHash, randomBytes } from "node:crypto"; import { Data, Effect, ParseResult, Schema } from "effect"; // ---------------------------------------------------------------------------// Errors// --------------------------------------------------------------------------- export class OAuth2Error extends Data.TaggedError("OAuth2Error")<{ readonly message: string; readonly cause?: unknown;}> {} // ---------------------------------------------------------------------------// Token response shape (RFC 6749 §5.1)// --------------------------------------------------------------------------- export type OAuth2TokenResponse = { readonly access_token: string; readonly token_type?: string; readonly refresh_token?: string; readonly expires_in?: number; readonly scope?: string;}; // ---------------------------------------------------------------------------// Constants// --------------------------------------------------------------------------- /** Refresh tokens this many ms before expiry to avoid mid-request expiration. */export const OAUTH2_REFRESH_SKEW_MS = 60_000; /** Default token-endpoint timeout. */export const OAUTH2_DEFAULT_TIMEOUT_MS = 20_000; // ---------------------------------------------------------------------------// PKCE (RFC 7636)// --------------------------------------------------------------------------- const encodeBase64Url = (input: Buffer): string => input.toString("base64").replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); /** Generate a 48-byte (64-char base64url) PKCE code verifier. */export const createPkceCodeVerifier = (): string => encodeBase64Url(randomBytes(48)); /** Compute the S256 code challenge for a given verifier. */export const createPkceCodeChallenge = (verifier: string): string => encodeBase64Url(createHash("sha256").update(verifier).digest()); // ---------------------------------------------------------------------------// Authorization URL builder// --------------------------------------------------------------------------- export type BuildAuthorizationUrlInput = { readonly authorizationUrl: string; readonly clientId: string; readonly redirectUrl: string; readonly scopes: readonly string[]; readonly state: string; readonly codeVerifier: string; /** Separator between scopes. RFC 6749 says space; some providers use comma. */ readonly scopeSeparator?: string; /** * Provider-specific extra params (e.g. Google's `access_type=offline`, * `prompt=consent`, `include_granted_scopes=true`). Merged AFTER the * standard params so callers can override if absolutely necessary. */ readonly extraParams?: Readonly<Record<string, string>>;}; export const buildAuthorizationUrl = (input: BuildAuthorizationUrlInput): string => { const url = new URL(input.authorizationUrl); const separator = input.scopeSeparator ?? " "; url.searchParams.set("client_id", input.clientId); url.searchParams.set("redirect_uri", input.redirectUrl); url.searchParams.set("response_type", "code"); url.searchParams.set("scope", input.scopes.join(separator)); url.searchParams.set("state", input.state); url.searchParams.set("code_challenge_method", "S256"); url.searchParams.set("code_challenge", createPkceCodeChallenge(input.codeVerifier)); if (input.extraParams) { for (const [k, v] of Object.entries(input.extraParams)) { url.searchParams.set(k, v); } } return url.toString();}; // ---------------------------------------------------------------------------// Token endpoint response parsing// --------------------------------------------------------------------------- const oauth2Error = (message: string, cause?: unknown): OAuth2Error => new OAuth2Error({ message, cause }); // ---------------------------------------------------------------------------// Schemas// --------------------------------------------------------------------------- /** * `expires_in` per RFC 6749 is a number of seconds, but some providers * (Azure, older OAuth servers) return it as a string. Accept either and * coerce to number. */const TokenExpirySeconds = Schema.transformOrFail( Schema.Union(Schema.Number, Schema.String), Schema.Number, { strict: true, decode: (input, _options, ast) => { if (typeof input === "number") return ParseResult.succeed(input); const parsed = Number(input); return Number.isFinite(parsed) ? ParseResult.succeed(parsed) : ParseResult.fail( new ParseResult.Type(ast, input, `expires_in "${input}" is not a number`), ); }, encode: (value) => ParseResult.succeed(value), },); const OAuth2TokenSuccessSchema = Schema.Struct({ /** RFC 6749 §5.1 requires a non-empty access_token on success. */ access_token: Schema.String.pipe(Schema.minLength(1)), token_type: Schema.optional(Schema.String), refresh_token: Schema.optional(Schema.String), expires_in: Schema.optional(TokenExpirySeconds), scope: Schema.optional(Schema.String),}); /** RFC 6749 §5.2 error response envelope — all fields optional; we probe them. */const OAuth2ErrorEnvelopeSchema = Schema.Struct({ error: Schema.optional(Schema.String), error_description: Schema.optional(Schema.String),}); const decodeSuccessBody = Schema.decodeUnknown(OAuth2TokenSuccessSchema);const decodeErrorBody = Schema.decodeUnknown(OAuth2ErrorEnvelopeSchema); /** * Parse the body of a token endpoint Response as JSON and ensure it's a * plain object. Fails with `OAuth2Error` whose message matches the * historical wording (`non-JSON response (${status})` / * `invalid JSON payload (${status})`) so callers keep their fidelity * guarantees. */const parseJsonObject = ( rawText: string, status: number,): Effect.Effect<Record<string, unknown>, OAuth2Error> => Effect.try({ try: () => JSON.parse(rawText) as unknown, catch: () => oauth2Error(`OAuth token endpoint returned non-JSON response (${status})`), }).pipe( Effect.flatMap((parsed) => parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? Effect.succeed(parsed as Record<string, unknown>) : Effect.fail( oauth2Error(`OAuth token endpoint returned invalid JSON payload (${status})`), ), ), ); /** * Parse a `Response` from an OAuth 2.0 token endpoint into an * `OAuth2TokenResponse`. Failures surface through the Effect failure * channel as `OAuth2Error`. * * Handles, in order, the failure modes we have seen in the wild: * 1. Non-JSON bodies (HTML error pages from misconfigured proxies / 5xx) * 2. JSON arrays / primitives instead of an object * 3. RFC 6749 error responses (`error_description` → `error` → `status N`) * 4. 200 responses with empty / missing `access_token` * 5. `expires_in` returned as a string instead of a number (Azure et al.) */export const decodeTokenResponse = ( response: Response,): Effect.Effect<OAuth2TokenResponse, OAuth2Error> => Effect.gen(function* () { const rawText = yield* Effect.tryPromise({ try: () => response.text(), catch: (cause) => oauth2Error( `Failed to read OAuth token endpoint body: ${cause instanceof Error ? cause.message : String(cause)}`, cause, ), }); const record = yield* parseJsonObject(rawText, response.status); if (!response.ok) { // Probe the error envelope. A body that doesn't match the envelope // (e.g. completely empty) is not itself a failure — we just fall // back to `status N`. This mirrors the tolerant behavior of the // prior hand-rolled parser. const envelope = yield* decodeErrorBody(record).pipe( Effect.catchAll(() => Effect.succeed({} as { error?: string; error_description?: string })), ); const description = envelope.error_description ?? envelope.error ?? `status ${response.status}`; return yield* Effect.fail(oauth2Error(`OAuth token exchange failed: ${description}`)); } return yield* decodeSuccessBody(record).pipe( Effect.mapError(() => // The only schema constraint that can fail on a 2xx is "access_token // is a non-empty string". Any other shape mismatch (wrong type on an // optional field, malformed expires_in) also surfaces as the same // message — we deliberately fold them together: the user needs an // access token, and they didn't get one. oauth2Error("OAuth token endpoint did not return an access_token"), ), ); }); // ---------------------------------------------------------------------------// Token endpoint POST// --------------------------------------------------------------------------- export type ClientAuthMethod = "body" | "basic"; const buildClientAuthHeaders = ( clientId: string, clientSecret: string | null | undefined, method: ClientAuthMethod,): Record<string, string> => { if (method !== "basic" || !clientSecret) return {}; const encoded = Buffer.from(`${clientId}:${clientSecret}`).toString("base64"); return { authorization: `Basic ${encoded}` };}; const applyClientAuthBody = ( body: URLSearchParams, clientId: string, clientSecret: string | null | undefined, method: ClientAuthMethod,): void => { if (method === "basic") return; body.set("client_id", clientId); if (clientSecret) body.set("client_secret", clientSecret);}; const postToTokenEndpoint = (input: { readonly tokenUrl: string; readonly body: URLSearchParams; readonly extraHeaders: Record<string, string>; readonly timeoutMs: number;}): Effect.Effect<OAuth2TokenResponse, OAuth2Error> => Effect.tryPromise({ try: () => fetch(input.tokenUrl, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json", ...input.extraHeaders, }, body: input.body, signal: AbortSignal.timeout(input.timeoutMs), }), catch: (cause) => oauth2Error(cause instanceof Error ? cause.message : String(cause), cause), }).pipe(Effect.flatMap(decodeTokenResponse)); // ---------------------------------------------------------------------------// Exchange authorization code → tokens// --------------------------------------------------------------------------- export type ExchangeAuthorizationCodeInput = { readonly tokenUrl: string; readonly clientId: string; readonly clientSecret?: string | null; readonly redirectUrl: string; readonly codeVerifier: string; readonly code: string; /** "body" (default) sends client creds in the form body; "basic" uses HTTP Basic. */ readonly clientAuth?: ClientAuthMethod; readonly timeoutMs?: number;}; export const exchangeAuthorizationCode = ( input: ExchangeAuthorizationCodeInput,): Effect.Effect<OAuth2TokenResponse, OAuth2Error> => { const clientAuth = input.clientAuth ?? "body"; const body = new URLSearchParams({ grant_type: "authorization_code", redirect_uri: input.redirectUrl, code_verifier: input.codeVerifier, code: input.code, }); applyClientAuthBody(body, input.clientId, input.clientSecret, clientAuth); return postToTokenEndpoint({ tokenUrl: input.tokenUrl, body, extraHeaders: buildClientAuthHeaders(input.clientId, input.clientSecret, clientAuth), timeoutMs: input.timeoutMs ?? OAUTH2_DEFAULT_TIMEOUT_MS, });}; // ---------------------------------------------------------------------------// Exchange client credentials → tokens (RFC 6749 §4.4)// --------------------------------------------------------------------------- export type ExchangeClientCredentialsInput = { readonly tokenUrl: string; readonly clientId: string; readonly clientSecret: string; readonly scopes?: readonly string[]; readonly scopeSeparator?: string; /** "body" (default) sends client creds in the form body; "basic" uses HTTP Basic. */ readonly clientAuth?: ClientAuthMethod; readonly timeoutMs?: number;}; export const exchangeClientCredentials = ( input: ExchangeClientCredentialsInput,): Effect.Effect<OAuth2TokenResponse, OAuth2Error> => { const clientAuth = input.clientAuth ?? "body"; const body = new URLSearchParams({ grant_type: "client_credentials" }); if (input.scopes && input.scopes.length > 0) { body.set("scope", input.scopes.join(input.scopeSeparator ?? " ")); } applyClientAuthBody(body, input.clientId, input.clientSecret, clientAuth); return postToTokenEndpoint({ tokenUrl: input.tokenUrl, body, extraHeaders: buildClientAuthHeaders(input.clientId, input.clientSecret, clientAuth), timeoutMs: input.timeoutMs ?? OAUTH2_DEFAULT_TIMEOUT_MS, });}; // ---------------------------------------------------------------------------// Refresh access token// --------------------------------------------------------------------------- export type RefreshAccessTokenInput = { readonly tokenUrl: string; readonly clientId: string; readonly clientSecret?: string | null; readonly refreshToken: string; readonly scopes?: readonly string[]; readonly scopeSeparator?: string; readonly clientAuth?: ClientAuthMethod; readonly timeoutMs?: number;}; export const refreshAccessToken = ( input: RefreshAccessTokenInput,): Effect.Effect<OAuth2TokenResponse, OAuth2Error> => { const clientAuth = input.clientAuth ?? "body"; const body = new URLSearchParams({ grant_type: "refresh_token", refresh_token: input.refreshToken, }); applyClientAuthBody(body, input.clientId, input.clientSecret, clientAuth); if (input.scopes && input.scopes.length > 0) { body.set("scope", input.scopes.join(input.scopeSeparator ?? " ")); } return postToTokenEndpoint({ tokenUrl: input.tokenUrl, body, extraHeaders: buildClientAuthHeaders(input.clientId, input.clientSecret, clientAuth), timeoutMs: input.timeoutMs ?? OAUTH2_DEFAULT_TIMEOUT_MS, });}; // ---------------------------------------------------------------------------// Refresh-needed predicate// --------------------------------------------------------------------------- /** * Returns true iff the current time is within `OAUTH2_REFRESH_SKEW_MS` of * `expiresAt`. A null `expiresAt` (server didn't return `expires_in`) means * we cannot proactively refresh — callers should fall back to reactive * refresh on 401 responses. */export const shouldRefreshToken = (input: { readonly expiresAt: number | null; readonly now?: number; readonly skewMs?: number;}): boolean => { if (input.expiresAt === null) return false; const now = input.now ?? Date.now(); const skew = input.skewMs ?? OAUTH2_REFRESH_SKEW_MS; return input.expiresAt <= now + skew;};
// ---------------------------------------------------------------------------
// @executor/plugin-oauth2 — generic OAuth 2.0 helpers
//
// Pure helpers for building authorization URLs, exchanging codes, and
// refreshing tokens against a standards-compliant OAuth 2.0 token endpoint.
// Plugins (google-discovery, openapi, ...) wrap these with their own
// session storage, secret management, and onboarding UI.
//
// Every public helper is intentionally provider-agnostic. Provider-specific
// query parameters (Google's `access_type=offline`, `prompt=consent`, etc.)
// are passed via the `extraParams` escape hatch so callers don't lose
// fidelity when switching from a hand-rolled implementation.
// ---------------------------------------------------------------------------
import { createHash, randomBytes } from "node:crypto";
import { Data, Effect, ParseResult, Schema } from "effect";
// ---------------------------------------------------------------------------
// Errors
// ---------------------------------------------------------------------------
export class OAuth2Error extends Data.TaggedError("OAuth2Error")<{
readonly message: string;
readonly cause?: unknown;
}> {}
// ---------------------------------------------------------------------------
// Token response shape (RFC 6749 §5.1)
// ---------------------------------------------------------------------------
export type OAuth2TokenResponse = {
readonly access_token: string;
readonly token_type?: string;
readonly refresh_token?: string;
readonly expires_in?: number;
readonly scope?: string;
};
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** Refresh tokens this many ms before expiry to avoid mid-request expiration. */
export const OAUTH2_REFRESH_SKEW_MS = 60_000;
/** Default token-endpoint timeout. */
export const OAUTH2_DEFAULT_TIMEOUT_MS = 20_000;
// ---------------------------------------------------------------------------
// PKCE (RFC 7636)
// ---------------------------------------------------------------------------
const encodeBase64Url = (input: Buffer): string =>
input.toString("base64").replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
/** Generate a 48-byte (64-char base64url) PKCE code verifier. */
export const createPkceCodeVerifier = (): string => encodeBase64Url(randomBytes(48));
/** Compute the S256 code challenge for a given verifier. */
export const createPkceCodeChallenge = (verifier: string): string =>
encodeBase64Url(createHash("sha256").update(verifier).digest());
// ---------------------------------------------------------------------------
// Authorization URL builder
// ---------------------------------------------------------------------------
export type BuildAuthorizationUrlInput = {
readonly authorizationUrl: string;
readonly clientId: string;
readonly redirectUrl: string;
readonly scopes: readonly string[];
readonly state: string;
readonly codeVerifier: string;
/** Separator between scopes. RFC 6749 says space; some providers use comma. */
readonly scopeSeparator?: string;
/**
* Provider-specific extra params (e.g. Google's `access_type=offline`,
* `prompt=consent`, `include_granted_scopes=true`). Merged AFTER the
* standard params so callers can override if absolutely necessary.
*/
readonly extraParams?: Readonly<Record<string, string>>;
};
export const buildAuthorizationUrl = (input: BuildAuthorizationUrlInput): string => {
const url = new URL(input.authorizationUrl);
const separator = input.scopeSeparator ?? " ";
url.searchParams.set("client_id", input.clientId);
url.searchParams.set("redirect_uri", input.redirectUrl);
url.searchParams.set("response_type", "code");
url.searchParams.set("scope", input.scopes.join(separator));
url.searchParams.set("state", input.state);
url.searchParams.set("code_challenge_method", "S256");
url.searchParams.set("code_challenge", createPkceCodeChallenge(input.codeVerifier));
if (input.extraParams) {
for (const [k, v] of Object.entries(input.extraParams)) {
url.searchParams.set(k, v);
}
}
return url.toString();
};
// ---------------------------------------------------------------------------
// Token endpoint response parsing
// ---------------------------------------------------------------------------
const oauth2Error = (message: string, cause?: unknown): OAuth2Error =>
new OAuth2Error({ message, cause });
// ---------------------------------------------------------------------------
// Schemas
// ---------------------------------------------------------------------------
/**
* `expires_in` per RFC 6749 is a number of seconds, but some providers
* (Azure, older OAuth servers) return it as a string. Accept either and
* coerce to number.
*/
const TokenExpirySeconds = Schema.transformOrFail(
Schema.Union(Schema.Number, Schema.String),
Schema.Number,
{
strict: true,
decode: (input, _options, ast) => {
if (typeof input === "number") return ParseResult.succeed(input);
const parsed = Number(input);
return Number.isFinite(parsed)
? ParseResult.succeed(parsed)
: ParseResult.fail(
new ParseResult.Type(ast, input, `expires_in "${input}" is not a number`),
);
},
encode: (value) => ParseResult.succeed(value),
},
);
const OAuth2TokenSuccessSchema = Schema.Struct({
/** RFC 6749 §5.1 requires a non-empty access_token on success. */
access_token: Schema.String.pipe(Schema.minLength(1)),
token_type: Schema.optional(Schema.String),
refresh_token: Schema.optional(Schema.String),
expires_in: Schema.optional(TokenExpirySeconds),
scope: Schema.optional(Schema.String),
});
/** RFC 6749 §5.2 error response envelope — all fields optional; we probe them. */
const OAuth2ErrorEnvelopeSchema = Schema.Struct({
error: Schema.optional(Schema.String),
error_description: Schema.optional(Schema.String),
});
const decodeSuccessBody = Schema.decodeUnknown(OAuth2TokenSuccessSchema);
const decodeErrorBody = Schema.decodeUnknown(OAuth2ErrorEnvelopeSchema);
/**
* Parse the body of a token endpoint Response as JSON and ensure it's a
* plain object. Fails with `OAuth2Error` whose message matches the
* historical wording (`non-JSON response (${status})` /
* `invalid JSON payload (${status})`) so callers keep their fidelity
* guarantees.
*/
const parseJsonObject = (
rawText: string,
status: number,
): Effect.Effect<Record<string, unknown>, OAuth2Error> =>
Effect.try({
try: () => JSON.parse(rawText) as unknown,
catch: () => oauth2Error(`OAuth token endpoint returned non-JSON response (${status})`),
}).pipe(
Effect.flatMap((parsed) =>
parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
? Effect.succeed(parsed as Record<string, unknown>)
: Effect.fail(
oauth2Error(`OAuth token endpoint returned invalid JSON payload (${status})`),
),
),
);
/**
* Parse a `Response` from an OAuth 2.0 token endpoint into an
* `OAuth2TokenResponse`. Failures surface through the Effect failure
* channel as `OAuth2Error`.
*
* Handles, in order, the failure modes we have seen in the wild:
* 1. Non-JSON bodies (HTML error pages from misconfigured proxies / 5xx)
* 2. JSON arrays / primitives instead of an object
* 3. RFC 6749 error responses (`error_description` → `error` → `status N`)
* 4. 200 responses with empty / missing `access_token`
* 5. `expires_in` returned as a string instead of a number (Azure et al.)
*/
export const decodeTokenResponse = (
response: Response,
): Effect.Effect<OAuth2TokenResponse, OAuth2Error> =>
Effect.gen(function* () {
const rawText = yield* Effect.tryPromise({
try: () => response.text(),
catch: (cause) =>
oauth2Error(
`Failed to read OAuth token endpoint body: ${cause instanceof Error ? cause.message : String(cause)}`,
cause,
),
});
const record = yield* parseJsonObject(rawText, response.status);
if (!response.ok) {
// Probe the error envelope. A body that doesn't match the envelope
// (e.g. completely empty) is not itself a failure — we just fall
// back to `status N`. This mirrors the tolerant behavior of the
// prior hand-rolled parser.
const envelope = yield* decodeErrorBody(record).pipe(
Effect.catchAll(() => Effect.succeed({} as { error?: string; error_description?: string })),
);
const description =
envelope.error_description ?? envelope.error ?? `status ${response.status}`;
return yield* Effect.fail(oauth2Error(`OAuth token exchange failed: ${description}`));
}
return yield* decodeSuccessBody(record).pipe(
Effect.mapError(() =>
// The only schema constraint that can fail on a 2xx is "access_token
// is a non-empty string". Any other shape mismatch (wrong type on an
// optional field, malformed expires_in) also surfaces as the same
// message — we deliberately fold them together: the user needs an
// access token, and they didn't get one.
oauth2Error("OAuth token endpoint did not return an access_token"),
),
);
});
// ---------------------------------------------------------------------------
// Token endpoint POST
// ---------------------------------------------------------------------------
export type ClientAuthMethod = "body" | "basic";
const buildClientAuthHeaders = (
clientId: string,
clientSecret: string | null | undefined,
method: ClientAuthMethod,
): Record<string, string> => {
if (method !== "basic" || !clientSecret) return {};
const encoded = Buffer.from(`${clientId}:${clientSecret}`).toString("base64");
return { authorization: `Basic ${encoded}` };
};
const applyClientAuthBody = (
body: URLSearchParams,
clientId: string,
clientSecret: string | null | undefined,
method: ClientAuthMethod,
): void => {
if (method === "basic") return;
body.set("client_id", clientId);
if (clientSecret) body.set("client_secret", clientSecret);
};
const postToTokenEndpoint = (input: {
readonly tokenUrl: string;
readonly body: URLSearchParams;
readonly extraHeaders: Record<string, string>;
readonly timeoutMs: number;
}): Effect.Effect<OAuth2TokenResponse, OAuth2Error> =>
Effect.tryPromise({
try: () =>
fetch(input.tokenUrl, {
method: "POST",
headers: {
"content-type": "application/x-www-form-urlencoded",
accept: "application/json",
...input.extraHeaders,
},
body: input.body,
signal: AbortSignal.timeout(input.timeoutMs),
}),
catch: (cause) => oauth2Error(cause instanceof Error ? cause.message : String(cause), cause),
}).pipe(Effect.flatMap(decodeTokenResponse));
// ---------------------------------------------------------------------------
// Exchange authorization code → tokens
// ---------------------------------------------------------------------------
export type ExchangeAuthorizationCodeInput = {
readonly tokenUrl: string;
readonly clientId: string;
readonly clientSecret?: string | null;
readonly redirectUrl: string;
readonly codeVerifier: string;
readonly code: string;
/** "body" (default) sends client creds in the form body; "basic" uses HTTP Basic. */
readonly clientAuth?: ClientAuthMethod;
readonly timeoutMs?: number;
};
export const exchangeAuthorizationCode = (
input: ExchangeAuthorizationCodeInput,
): Effect.Effect<OAuth2TokenResponse, OAuth2Error> => {
const clientAuth = input.clientAuth ?? "body";
const body = new URLSearchParams({
grant_type: "authorization_code",
redirect_uri: input.redirectUrl,
code_verifier: input.codeVerifier,
code: input.code,
});
applyClientAuthBody(body, input.clientId, input.clientSecret, clientAuth);
return postToTokenEndpoint({
tokenUrl: input.tokenUrl,
body,
extraHeaders: buildClientAuthHeaders(input.clientId, input.clientSecret, clientAuth),
timeoutMs: input.timeoutMs ?? OAUTH2_DEFAULT_TIMEOUT_MS,
});
};
// ---------------------------------------------------------------------------
// Exchange client credentials → tokens (RFC 6749 §4.4)
// ---------------------------------------------------------------------------
export type ExchangeClientCredentialsInput = {
readonly tokenUrl: string;
readonly clientId: string;
readonly clientSecret: string;
readonly scopes?: readonly string[];
readonly scopeSeparator?: string;
/** "body" (default) sends client creds in the form body; "basic" uses HTTP Basic. */
readonly clientAuth?: ClientAuthMethod;
readonly timeoutMs?: number;
};
export const exchangeClientCredentials = (
input: ExchangeClientCredentialsInput,
): Effect.Effect<OAuth2TokenResponse, OAuth2Error> => {
const clientAuth = input.clientAuth ?? "body";
const body = new URLSearchParams({ grant_type: "client_credentials" });
if (input.scopes && input.scopes.length > 0) {
body.set("scope", input.scopes.join(input.scopeSeparator ?? " "));
}
applyClientAuthBody(body, input.clientId, input.clientSecret, clientAuth);
return postToTokenEndpoint({
tokenUrl: input.tokenUrl,
body,
extraHeaders: buildClientAuthHeaders(input.clientId, input.clientSecret, clientAuth),
timeoutMs: input.timeoutMs ?? OAUTH2_DEFAULT_TIMEOUT_MS,
});
};
// ---------------------------------------------------------------------------
// Refresh access token
// ---------------------------------------------------------------------------
export type RefreshAccessTokenInput = {
readonly tokenUrl: string;
readonly clientId: string;
readonly clientSecret?: string | null;
readonly refreshToken: string;
readonly scopes?: readonly string[];
readonly scopeSeparator?: string;
readonly clientAuth?: ClientAuthMethod;
readonly timeoutMs?: number;
};
export const refreshAccessToken = (
input: RefreshAccessTokenInput,
): Effect.Effect<OAuth2TokenResponse, OAuth2Error> => {
const clientAuth = input.clientAuth ?? "body";
const body = new URLSearchParams({
grant_type: "refresh_token",
refresh_token: input.refreshToken,
});
applyClientAuthBody(body, input.clientId, input.clientSecret, clientAuth);
if (input.scopes && input.scopes.length > 0) {
body.set("scope", input.scopes.join(input.scopeSeparator ?? " "));
}
return postToTokenEndpoint({
tokenUrl: input.tokenUrl,
body,
extraHeaders: buildClientAuthHeaders(input.clientId, input.clientSecret, clientAuth),
timeoutMs: input.timeoutMs ?? OAUTH2_DEFAULT_TIMEOUT_MS,
});
};
// ---------------------------------------------------------------------------
// Refresh-needed predicate
// ---------------------------------------------------------------------------
/**
* Returns true iff the current time is within `OAUTH2_REFRESH_SKEW_MS` of
* `expiresAt`. A null `expiresAt` (server didn't return `expires_in`) means
* we cannot proactively refresh — callers should fall back to reactive
* refresh on 401 responses.
*/
export const shouldRefreshToken = (input: {
readonly expiresAt: number | null;
readonly now?: number;
readonly skewMs?: number;
}): boolean => {
if (input.expiresAt === null) return false;
const now = input.now ?? Date.now();
const skew = input.skewMs ?? OAUTH2_REFRESH_SKEW_MS;
return input.expiresAt <= now + skew;
};