/** * Service-vault OAuth flow — build / exchange / refresh / self-heal (T11939). * * EP-UNIVERSAL-SERVICE-VAULT (epic T11765 · saga SG-VAULT-CORE T10409 · M2 W2 · * task T11939). The OAuth dance the foundation (T11937) left a clean seam for. * Every function is DRIVEN BY the declarative {@link ServiceProviderDef} from the * `SERVICE_PROVIDERS` registry (contracts, T11937 + T11938 breadth) — there is no * per-provider code, only data-shaped branches on the provider's `oauth` config * and `refresh.kind` discriminant. * * ## What this module owns (the four entry points) * * 1. {@link buildAuthUrl} — compose the authorization URL (PKCE S256 + state + * provider `extraAuthParams`) for an `oauth2`/`oauth2-pkce` provider, or the * device-authorization start for a `device-code` provider. * 2. {@link exchangeCode} — POST `grant_type=authorization_code` (+ PKCE * verifier) to the token endpoint and PERSIST the resulting * `{accessToken, refreshToken}` blob `encryptGlobal`-encrypted via * {@link connectService} (the foundation accessor — reused, not reinvented). * 3. {@link refreshAccessToken} — refresh an access token honoring the * provider's `refresh` config: the `refresh-token` grant (Form/Json body × * Body/BasicAuth client auth) plus the three special variants (`github-app` * JWT, `service-account-jwt` bearer, `client-credentials`). * 4. {@link selfHealConnection} — the resolve-path self-heal: when a connection's * `expires_at` is past, transparently refresh → re-`encryptGlobal` → persist * back via the accessor, returning a FRESH {@link SealedCredential} so agents * NEVER see a stale token. Cannibalizes onecli `connect.rs::resolve_access_token`. * * ## Crypto + persistence reuse (DRY — no new crypto, no new store) * * Encryption is ALWAYS `encryptGlobal` keyed `id = service:${provider}:${label}` * (via {@link connectService} / {@link updateConnectionTokens}). The egress handle * is the shared {@link makeSealedCredential} — the plaintext materializes ONLY at * the wire inside the handle's `fetch()`, never inline on a resolve. HTTP uses the * platform `globalThis.fetch` (no new dep) so tests inject a stub. * * @module store/service-oauth * @task T11939 * @epic T11765 * @saga T10409 * @see ../../../contracts/src/vault/service-provider.ts — the DATA that drives this flow * @see ./service-connections-accessor.ts — connect/resolve/persist (reused) * @see ../llm/oauth/pkce.ts — generatePkcePair / buildAuthorizationUrl (reused) * @see ../crypto/credentials.ts — encryptGlobal / decryptGlobal (reused) * @see ../../../onecli — apps/gateway/src/{apps.rs,connect.rs} (the ported design) */ import type { SealedCredential, ServiceProviderDef } from '@cleocode/contracts'; import { type OAuthTokens } from '../llm/oauth/pkce.js'; import { type ServiceVaultDeps } from './service-connections-accessor.js'; /** * The minimal `fetch` surface this module uses — `globalThis.fetch`-compatible. * * Injectable so tests pass a stub WITHOUT monkey-patching the global. Defaults to * `globalThis.fetch`. */ export type FetchLike = (input: string, init?: { method?: string; headers?: Record; body?: string; }) => Promise<{ ok: boolean; status: number; json: () => Promise; text: () => Promise; }>; /** * Injectable dependencies for the OAuth flow — HTTP, clock, and the vault deps. * * Every field is optional; defaults bind the real implementations. Tests inject a * `fetch` stub (to mock the token endpoint), a frozen `now` (fake clock for the * self-heal expiry test), and a temp-DB `vault` handle (off `.cleo/*.db`). * * @task T11939 */ export interface ServiceOAuthDeps { /** HTTP transport. Defaults to {@link globalFetch}. */ readonly fetch?: FetchLike; /** Current epoch milliseconds. Defaults to {@link Date.now}. Fake-clock seam for self-heal. */ readonly now?: () => number; /** Vault accessor deps (db handle + crypto spies) forwarded to the foundation accessor. */ readonly vault?: ServiceVaultDeps; } /** * Resolve the {@link ServiceProviderDef} for a provider key, throwing a typed * error when unknown. * * @param provider - The stable provider key (e.g. `'github'`). * @returns The declarative provider definition. * @throws {Error} `E_SERVICE_PROVIDER_UNKNOWN` when the key is not in the registry. * @task T11939 */ export declare function resolveProviderDef(provider: string): ServiceProviderDef; /** Options for {@link buildAuthUrl}. */ export interface BuildAuthUrlOptions { /** Opaque CSRF `state`. A random value is generated when omitted. */ readonly state?: string; /** Scope override (e.g. a BYOC custom scope). Defaults to the provider's declared scope. */ readonly scope?: string; /** Client id override (e.g. a BYOC `service_configs` client id). Defaults to the registry value. */ readonly clientId?: string; /** Redirect URI override. Defaults to the provider's declared loopback redirect. */ readonly redirectUri?: string; } /** The authorization-URL result, carrying the secrets the caller must round-trip. */ export interface BuildAuthUrlResult { /** The fully-formed authorization URL the user opens to grant consent. */ readonly authUrl: string; /** * The PKCE code verifier — the SECRET counterpart sent to the token endpoint in * {@link exchangeCode}. The caller MUST retain it across the redirect. */ readonly codeVerifier: string; /** The CSRF `state` echoed in the redirect; the caller MUST verify it matches. */ readonly state: string; /** The redirect URI used — the caller MUST pass the SAME value to {@link exchangeCode}. */ readonly redirectUri: string; } /** * AC1 — build the authorization URL (+ PKCE pair) for an OAuth provider. * * Generates a fresh PKCE verifier/challenge (RFC 7636 S256) and composes the * authorization URL via the reused {@link buildAuthorizationUrl}, appending the * provider's declared `extraAuthParams` (e.g. google `access_type=offline`). * Returns the verifier + state the caller round-trips through the redirect into * {@link exchangeCode}. * * @param provider - The provider key (must have an `oauth` config). * @param opts - Optional state / scope / client-id / redirect overrides (BYOC). * @returns The auth URL plus the PKCE verifier and state to round-trip. * @throws {Error} When the provider is unknown or api-key-only. * @task T11939 */ export declare function buildAuthUrl(provider: string, opts?: BuildAuthUrlOptions): Promise; /** Options for {@link exchangeCode}. */ export interface ExchangeCodeOptions { /** The authorization code from the redirect callback. */ readonly code: string; /** The PKCE verifier from {@link buildAuthUrl} (round-tripped). */ readonly codeVerifier: string; /** The redirect URI used in {@link buildAuthUrl} (must match). */ readonly redirectUri: string; /** Connection label, unique within the provider (e.g. `'personal'`). Defaults to `'default'`. */ readonly label?: string; /** Client id override (BYOC). Defaults to the registry value. */ readonly clientId?: string; /** Non-secret metadata to store on the connection (e.g. `{ username }`). */ readonly metadata?: Readonly>; } /** The result of {@link exchangeCode} — the persisted connection's identity (non-secret). */ export interface ExchangeCodeResult { /** The `service_connections.id` of the connect/refresh upsert. */ readonly connectionId: number; /** The provider key. */ readonly provider: string; /** The connection label. */ readonly label: string; /** ISO-8601 access-token expiry, when the provider returned `expires_in`. */ readonly expiresAt: string | null; /** Whether the provider issued a refresh token (gates future self-heal). */ readonly hasRefreshToken: boolean; } /** * AC1 — exchange an authorization code for tokens and PERSIST them encrypted. * * POSTs `grant_type=authorization_code` (+ PKCE verifier) to the provider's token * endpoint via the reused {@link exchangePkceCode}, then writes the resulting * `{accessToken, refreshToken}` blob `encryptGlobal`-encrypted through * {@link connectService}. The plaintext token is NEVER returned — only the * non-secret connection identity. The expiry is computed from `expires_in`. * * @param provider - The provider key. * @param opts - The code, PKCE verifier, redirect, and connection label. * @param deps - Injectable HTTP / clock / vault (test seam). * @returns The persisted connection's non-secret identity. * @throws {Error} On HTTP failure or a token response missing `access_token`. * @task T11939 */ export declare function exchangeCode(provider: string, opts: ExchangeCodeOptions, deps?: ServiceOAuthDeps): Promise; /** Client credentials for a refresh exchange (BYOC or CLEO first-party). */ export interface RefreshClientCredentials { /** OAuth client id. */ readonly clientId: string; /** OAuth client secret (NULL for a public PKCE client with none). */ readonly clientSecret?: string; } /** Input to the JWT-bearer / github-app / client-credentials refresh variants. */ export interface RefreshVariantSecrets { /** RSA private key PEM (github-app + service-account-jwt). */ readonly privateKeyPem?: string; /** GitHub App id (github-app). */ readonly appId?: string; /** GitHub App installation id (github-app). */ readonly installationId?: string; /** Service-account client email (service-account-jwt). */ readonly clientEmail?: string; } /** Options for {@link refreshAccessToken}. */ export interface RefreshAccessTokenOptions { /** The current refresh token (for the `refresh-token` grant). */ readonly refreshToken?: string; /** Client credentials (for `refresh-token` Body/BasicAuth + `client-credentials`). */ readonly client?: RefreshClientCredentials; /** Secrets for the JWT-bearer / github-app variants. */ readonly variant?: RefreshVariantSecrets; } /** * AC2 — refresh an access token honoring the provider's declared refresh config. * * Branches on {@link RefreshConfig.kind}: * * - `refresh-token` — `grant_type=refresh_token` shaped by `bodyFormat` * (Form/Json) × `clientAuth` (Body/BasicAuth). The common OAuth case. * - `github-app` — sign an RS256 JWT with the app key, POST * `/app/installations/{id}/access_tokens`. * - `service-account-jwt` — sign an RS256 JWT, exchange via * `grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer`. * - `client-credentials` — `grant_type=client_credentials`, Basic-auth client. * * Returns normalized {@link OAuthTokens} (a fresh access token + optional rotated * refresh token). PERSISTENCE is the caller's job ({@link selfHealConnection} * does it); this function is pure HTTP + crypto so it is independently testable. * * @param provider - The provider key. * @param opts - The refresh token / client credentials / variant secrets. * @param deps - Injectable HTTP / clock (test seam). * @returns The refreshed tokens. * @throws {Error} When the provider declares no refresh, or the exchange fails. * @task T11939 */ export declare function refreshAccessToken(provider: string, opts: RefreshAccessTokenOptions, deps?: ServiceOAuthDeps): Promise; /** Options for {@link selfHealConnection}. */ export interface SelfHealOptions { /** The agent requesting the connection (trust-gated by the accessor). */ readonly agentId: string; /** The provider key. */ readonly provider: string; /** The connection label. */ readonly label: string; /** Out-of-band manual-approval flag forwarded to the trust gate. */ readonly approved?: boolean; /** Client credentials for the refresh exchange (BYOC or first-party). */ readonly client?: RefreshClientCredentials; /** Variant secrets for the JWT/github-app refresh kinds. */ readonly variant?: RefreshVariantSecrets; /** * Seconds of headroom before `expires_at` at which to PRE-emptively refresh — * a request landing in this window is refreshed too (avoids handing out a token * about to expire mid-flight). Defaults to 0 (refresh only once past). */ readonly skewSeconds?: number; } /** The result of {@link selfHealConnection}. */ export interface SelfHealResult { /** A FRESH sealed credential (or `null` when denied / missing / no credential). */ readonly sealed: SealedCredential | null; /** Whether a transparent refresh actually fired (the token was past expiry). */ readonly refreshed: boolean; /** The (possibly newly-computed) ISO-8601 expiry, or `null` when unknown. */ readonly expiresAt: string | null; } /** * AC3 — resolve a connection, transparently refreshing a PAST-EXPIRY token. * * The self-heal resolve path (cannibalized from onecli * `connect.rs::resolve_access_token`): * * 1. Read the connection's non-secret view (`expiresAt`). * 2. If `expiresAt` is past (within `skewSeconds`) AND a refresh token exists: * refresh via {@link refreshAccessToken}, re-`encryptGlobal` the new blob, and * persist it back via {@link updateConnectionTokens} — so the NEXT resolve and * every other agent see the fresh token. * 3. Return a FRESH {@link SealedCredential} resolving against the (now-current) * stored blob. A stale token is NEVER handed out: the heal runs BEFORE the * sealed handle is built, and the handle decrypts the persisted (fresh) blob. * * The trust gate runs inside the accessor's resolve — a denied agent gets `null` * with NO refresh and NO decrypt. * * @param connectionRef - The agent + provider + label, plus refresh credentials. * @param deps - Injectable HTTP / clock / vault (test seam — the fake clock proves * the past-expiry branch fires). * @returns The fresh sealed credential + whether a refresh fired. * @task T11939 */ export declare function selfHealConnection(connectionRef: SelfHealOptions, deps?: ServiceOAuthDeps): Promise; export type { OAuthTokens } from '../llm/oauth/pkce.js'; //# sourceMappingURL=service-oauth.d.ts.map