/** * OAuth 2.1 authorization server for HTTP mode (#303). * * The design decision that shapes everything here: this AS authenticates the * human to the container, it does not broker Coolify credentials. The * container acts with its own env-var Coolify token; the client only ever * holds a short-lived, audience-bound, revocable MCP token. There is no * secrets database — the store below holds OAuth artefacts only (registered * clients, one-time codes, token hashes), never a Coolify credential. * * Tier-2 authorisation ("Coolify token as proof of access"): at authorize * time the user presents their own Coolify API token, the caller validates it * against `GET /teams/current` (a bad token 401s) and then discards it. The * proof never enters this module and is never stored — `completeAuthorization` * runs strictly after validation and receives no token. * * OAuth 2.1 requirements implemented: PKCE S256 on every code flow (plain is * rejected), exact redirect-URI matching, single-use short-lived codes, * rotating refresh tokens with reuse detection (a replayed refresh token * revokes the whole grant family, per the OAuth 2.1 BCP), and RFC 8707 * resource binding so a token issued for this server cannot be replayed * against another. * * Tokens are opaque random strings; the store keeps only their SHA-256, so * the persisted state file contains nothing directly usable. */ /** RFC 7591 client metadata subset we accept and persist. */ export interface RegisteredClient { client_id: string; client_name?: string; redirect_uris: string[]; /** 'none' (public + PKCE) or 'client_secret_post'. */ token_endpoint_auth_method: string; /** Present only for confidential clients. Stored hashed. */ client_secret_hash?: string; created_at: number; } /** Matches the SDK's AuthInfo shape so the verifier can return it directly. */ export interface VerifiedAccessToken { token: string; clientId: string; scopes: string[]; expiresAt: number; resource?: URL; } export declare class OAuthErrorResponse extends Error { readonly code: string; readonly description: string; readonly status: number; constructor(code: string, description: string, status?: number); } /** * Strip a hash fragment and normalize for RFC 8707 comparison, mirroring the * spec's rule that resource identifiers are compared without fragments. */ export declare function canonicalResource(value: string): string; export interface OAuthProviderOptions { /** Public issuer URL, e.g. https://mcp.example.com */ issuer: string; /** The protected resource identifier tokens must be bound to (the /mcp URL). */ resource: string; /** Access token lifetime in seconds. */ accessTokenTtl: number; /** Refresh token lifetime in seconds. Short by design: revocation propagates at re-auth. */ refreshTokenTtl: number; /** Where OAuth state persists. Empty string keeps everything in memory (tests). */ stateFile: string; /** * Fetches a Client ID Metadata Document (#340). Defaults to the * SSRF-guarded {@link fetchPublicJson}; tests inject a stub. Whatever is * passed must refuse private addresses and redirects the way the default * does, because the URL is attacker-chosen. */ fetchClientMetadata?: (url: string) => Promise; /** How long a fetched metadata document is trusted, in ms. Default one hour. */ clientMetadataTtl?: number; } /** Does this client_id look like a Client Identifier URL rather than a registered id? */ export declare function isClientIdUrl(clientId: string): boolean; /** * Does a requested `redirect_uri` match one the client registered? * * Exact string equality, except that a **loopback** redirect may differ in its * port. RFC 8252 section 7.3: "the authorization server MUST allow any port to * be specified at the time of the request for loopback IP redirect URIs". * * This is not a nicety. A native client binds an ephemeral port at the moment * it starts the flow, so it registers `http://127.0.0.1/callback` and then calls * back on `http://127.0.0.1:51763/callback`. Exact matching rejects that, and * the failure surfaces to the user as a generic "invalid_request" partway * through a browser redirect, which is close to undiagnosable. * * The relaxation is deliberately narrow: scheme, host and path must still match * exactly, and only loopback hosts qualify. A remote https callback that * differs by port is a different endpoint, and treating it as the same one * would let a client registered for :443 be redirected to an attacker's :8443. */ export declare function redirectUriMatches(registered: string, requested: string): boolean; export declare class OAuthProvider { private readonly options; private readonly clients; /** * Clients identified by a Client ID Metadata Document URL (#340). Memory * only, never persisted: the document is the registration, and re-fetching * it is how a client changes its redirect URIs. This is also what stops the * state file growing by one client per fresh connection, which DCR does. */ private readonly metadataClients; /** In-flight document fetches, so concurrent requests for one client_id fetch once. */ private readonly pendingResolves; private readonly codes; private readonly tokens; private persistTimer; private persistBroken; /** * True while the last state write failed (#417): every registration and * token is in memory only and gone at the next restart. Surfaced on * /healthz so whatever restarts on health can see it before it does. */ get persistenceDegraded(): boolean; constructor(options: OAuthProviderOptions); authorizationServerMetadata(): Record; protectedResourceMetadata(): Record; registerClient(metadata: Record): Record; /** * Make `client_id` resolvable before a synchronous lookup. A registered id * is already known; a Client Identifier URL is fetched, validated and * cached for {@link OAuthProviderOptions.clientMetadataTtl}. Anything else * is left for the lookup to reject as unknown. Errors are never cached: * a client whose document was briefly unreachable is retried next time. * * The URL is attacker-chosen, so the fetch goes through the SSRF guard * (public addresses only, no redirects, pinned DNS, size and time caps). */ resolveClient(clientId: string): Promise; private fetchAndCache; /** Drop lapsed documents (beyond grace) and cap the map at CLIENT_METADATA_MAX. */ private evictMetadataClients; private clientFromMetadataDocument; private lookupClient; /** * Validate the query half of an authorization request. Called on GET (to * decide whether to render the consent form at all) and again on POST * before issuing a code. Throws {@link OAuthErrorResponse} on anything * malformed; per spec, redirect-uri and client-id problems must NOT * redirect, so the caller renders those as a plain error page. */ validateAuthorizationRequest(params: URLSearchParams): { client: RegisteredClient; redirectUri: string; codeChallenge: string; scope: string; resource: string; state: string | null; }; /** * Issue an authorization code. The caller MUST have completed * proof-of-access first (tier 2: the presented Coolify token validated * against `/teams/current` and discarded) — this method deliberately takes * no credential, so there is nothing here to store or leak. */ completeAuthorization(request: ReturnType): { redirectTo: string; }; exchange(params: URLSearchParams): Record; private authenticateClient; private exchangeCode; private exchangeRefresh; private issueTokens; verifyAccessToken(token: string): Promise; private revokeGrant; /** Drop expired codes and tokens so the state file cannot grow unbounded. */ private prune; private persist; /** Flush pending state to disk immediately (shutdown hook). */ flush(): void; private writeState; private load; }