/** * Palantir Foundry v2 API client. * Handles OAuth2 auth (Authorization Code + Client Credentials), token refresh, * pre-flight credential validation, and structured error parsing. * @see https://www.palantir.com/docs/foundry/api/v2/general/overview/authentication */ import type { OAuth2TokenResponse, PageParams, PageResponse, } from "./types.js"; import { PalantirApiError, PalantirCredentialError, PalantirOAuth2Error, } from "./errors.js"; import type { PalantirApiErrorBody, OAuth2TokenErrorBody } from "./errors.js"; // ─── Client Options ───────────────────────────────────────────────── export interface PalantirClientOptions { /** Foundry stack URL, e.g. https://foundry.example.com */ stackUrl: string; /** OAuth2 client ID from Developer Console */ clientId: string; /** OAuth2 client secret (for Client Credentials grant) */ clientSecret?: string; /** OAuth2 redirect URL (for Authorization Code grant) */ redirectUrl?: string; /** Pre-obtained bearer token */ token?: string; /** Refresh token for renewal */ refreshToken?: string; /** OAuth2 scopes to request */ scopes?: string[]; /** Custom fetch (for testing or non-standard runtimes) */ fetch?: typeof globalThis.fetch; /** Ontology RID (for Ontology-scoped operations) */ ontologyRid?: string; } // ─── Token State ──────────────────────────────────────────────────── interface TokenState { accessToken: string; refreshToken?: string; expiresAt: number; } // ─── PalantirClient ───────────────────────────────────────────────── export class PalantirClient { readonly stackUrl: string; readonly clientId: string; readonly clientSecret?: string; readonly redirectUrl?: string; readonly scopes: string[]; readonly ontologyRid?: string; private _tokenState?: TokenState; private _fetch: typeof globalThis.fetch; /** Get the current access token (public accessor for namespace classes). */ async getAccessToken(): Promise { return this.#getAccessToken(); } /** Underlying fetch implementation (for namespace classes that need raw HTTP). */ get rawFetch(): typeof globalThis.fetch { return this._fetch; } constructor(options: PalantirClientOptions) { // Normalize stack URL — strip trailing slash this.stackUrl = options.stackUrl.replace(/\/+$/, ""); this.clientId = options.clientId; this.clientSecret = options.clientSecret; this.redirectUrl = options.redirectUrl; this.scopes = options.scopes ?? []; this.ontologyRid = options.ontologyRid; this._fetch = options.fetch ?? globalThis.fetch; // Seed token state if provided if (options.token) { this._tokenState = { accessToken: options.token, refreshToken: options.refreshToken, expiresAt: Infinity, }; } } // ─── Auth: Get Authorization URL ──────────────────────────────── /** Build the OAuth2 authorization URL for redirect-based login. */ getAuthorizationUrl(state?: string, codeChallenge?: string): string { if (!this.redirectUrl) { throw new PalantirCredentialError( "redirectUrl is required for Authorization Code grant. " + "Set it in the PalantirClient constructor." ); } const params = new URLSearchParams({ response_type: "code", client_id: this.clientId, redirect_uri: this.redirectUrl, scope: this.scopes.join(" ") || "api:use-*", }); if (state) params.set("state", state); if (codeChallenge) { params.set("code_challenge", codeChallenge); params.set("code_challenge_method", "S256"); } return `${this.stackUrl}/authorization/oauth2/authorize?${params.toString()}`; } // ─── Auth: Exchange Code for Token ────────────────────────────── /** Exchange authorization code for access token (Authorization Code grant). */ async exchangeCode(code: string): Promise { if (!this.redirectUrl) { throw new PalantirCredentialError( "redirectUrl is required for code exchange. " + "Configure it in Developer Console and pass to constructor." ); } const body = new URLSearchParams({ grant_type: "authorization_code", code, client_id: this.clientId, redirect_uri: this.redirectUrl, }); return this.#requestToken(body); } // ─── Auth: Client Credentials ─────────────────────────────────── /** Obtain token via Client Credentials grant (service user). */ async authenticateWithClientCredentials(): Promise { if (!this.clientSecret) { throw new PalantirCredentialError( "clientSecret is required for Client Credentials grant. " + "Obtain it from Developer Console and pass to constructor." ); } const body = new URLSearchParams({ grant_type: "client_credentials", client_id: this.clientId, client_secret: this.clientSecret, scope: this.scopes.join(" ") || "api:use-*", }); return this.#requestToken(body); } // ─── Auth: Refresh Token ──────────────────────────────────────── /** Refresh the access token using a refresh token. */ async refreshAccessToken(): Promise { const refreshToken = this._tokenState?.refreshToken; if (!refreshToken) { throw new PalantirCredentialError( "No refresh token available. Obtain one by including 'offline_access' in scopes " + "during initial authorization." ); } const body = new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: this.clientId, }); return this.#requestToken(body); } // ─── Auth: Validate Credentials ───────────────────────────────── /** * Validate that credentials are configured and the token is usable. * Calls GET /api/v2/admin/users/getCurrent to verify auth. * Throws PalantirCredentialError if credentials are missing. * Throws PalantirApiError if token is invalid/expired. */ async validateCredentials(): Promise<{ authenticated: true; user: unknown }> { const token = await this.#getAccessToken(); if (!token) { throw new PalantirCredentialError( "No access token. Either provide a token, exchange an authorization code, " + "or use client credentials to authenticate. " + "All methods require OAuth2 credentials from Developer Console." ); } const response = await this._fetch(`${this.stackUrl}/api/v2/admin/users/getCurrent`, { headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, }); if (!response.ok) { const error = await this.#parseError(response); throw error; } const user = await response.json(); return { authenticated: true, user }; } // ─── Token Request (private) ──────────────────────────────────── async #requestToken(body: URLSearchParams): Promise { const url = `${this.stackUrl}/authorization/oauth2/token`; const response = await this._fetch(url, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: body.toString(), }); if (!response.ok) { let oauthError: OAuth2TokenErrorBody; try { oauthError = (await response.json()) as OAuth2TokenErrorBody; } catch { throw new PalantirCredentialError( `Token request failed with status ${response.status}: ${response.statusText}. ` + "Verify your stackUrl, clientId, and clientSecret are correct." ); } throw new PalantirOAuth2Error(oauthError); } const tokenResponse = (await response.json()) as OAuth2TokenResponse; // Store token state this._tokenState = { accessToken: tokenResponse.access_token, refreshToken: tokenResponse.refresh_token ?? this._tokenState?.refreshToken, expiresAt: Date.now() + tokenResponse.expires_in * 1000, }; return tokenResponse; } // ─── Access Token (private) ───────────────────────────────────── async #getAccessToken(): Promise { if (!this._tokenState) return undefined; // Auto-refresh if token is about to expire (within 60s buffer) if ( this._tokenState.refreshToken && this._tokenState.expiresAt <= Date.now() + 60_000 ) { try { await this.refreshAccessToken(); } catch { // Return current token if refresh fails — the server will reject it } } return this._tokenState.accessToken; } // ─── API Request Methods ──────────────────────────────────────── /** Make a GET request to the Foundry API. */ async get(path: string, params?: Record): Promise { const url = this.#buildUrl(path, params); return this.#request("GET", url); } /** Make a POST request to the Foundry API. */ async post(path: string, body?: unknown): Promise { const url = this.#buildUrl(path); return this.#request("POST", url, body); } /** Make a PUT request to the Foundry API. */ async put(path: string, body?: unknown): Promise { const url = this.#buildUrl(path); return this.#request("PUT", url, body); } /** Make a DELETE request to the Foundry API. */ async delete(path: string): Promise { const url = this.#buildUrl(path); return this.#request("DELETE", url); } // ─── Paginated GET ────────────────────────────────────────────── /** Fetch all pages from a paginated endpoint. Max 100 pages to prevent infinite loops. */ async getAllPages( path: string, params?: PageParams & Record ): Promise { const MAX_PAGES = 100; const allItems: T[] = []; let pageToken: string | undefined; let pageCount = 0; do { if (pageCount >= MAX_PAGES) { throw new PalantirCredentialError( `getAllPages exceeded ${MAX_PAGES} pages. ` + "Use paginated access (get with pageToken) for large result sets." ); } const response = await this.get>(path, { ...params, pageToken: pageToken ?? undefined, }); if (response.data) { allItems.push(...response.data); } pageToken = response.nextPageToken; pageCount++; } while (pageToken); return allItems; } // ─── Internal Request ─────────────────────────────────────────── async #request(method: string, url: string, body?: unknown): Promise { const token = await this.#getAccessToken(); if (!token) { throw new PalantirCredentialError( "No access token available. Authenticate first using one of:\n" + " - client.authenticateWithClientCredentials()\n" + " - client.exchangeCode(code)\n" + " - Pass a token in the constructor\n\n" + "OAuth2 credentials must be obtained from the Developer Console in your Foundry stack." ); } const headers: Record = { Authorization: `Bearer ${token}`, "Content-Type": "application/json", }; const init: RequestInit = { method, headers }; if (body !== undefined) { init.body = JSON.stringify(body); } const response = await this._fetch(url, init); if (!response.ok) { throw await this.#parseError(response); } // Handle 204 No Content if (response.status === 204) { return undefined as T; } return response.json() as Promise; } // ─── URL Builder ──────────────────────────────────────────────── #buildUrl(path: string, params?: Record): string { const url = new URL(`${this.stackUrl}${path}`); if (params) { for (const [key, value] of Object.entries(params)) { if (value === undefined || value === null) continue; if (Array.isArray(value)) { for (const item of value) { url.searchParams.append(key, String(item)); } } else { url.searchParams.set(key, String(value)); } } } return url.toString(); } // ─── Error Parser ─────────────────────────────────────────────── async #parseError(response: Response): Promise { let body: PalantirApiErrorBody; try { body = (await response.json()) as PalantirApiErrorBody; } catch { return new PalantirCredentialError( `API request failed with status ${response.status} (${response.statusText}). ` + "The response body could not be parsed. " + (response.status === 401 ? "This usually means your OAuth2 credentials are invalid or expired. " + "Verify your token or re-authenticate via Developer Console." : response.status === 403 ? "This usually means your OAuth2 scopes don't cover the requested operation. " + "Update your application scopes in Developer Console." : "") ); } // Enhance credential-specific errors with actionable messages if (body.errorName === "MissingCredentials") { return new PalantirCredentialError( "The API endpoint requires an Authorization header but none was found. " + "Ensure you have authenticated via OAuth2 before making API calls. " + "Credentials must be obtained from the Developer Console in your Foundry stack." ); } if (body.errorName === "Default:Unauthorized") { return new PalantirCredentialError( `OAuth2 token is invalid or expired (instance: ${body.errorInstanceId}). ` + "Re-authenticate or refresh your token. " + "If using Client Credentials, verify your clientId and clientSecret." ); } if (body.errorName === "ApiUsageDenied") { return new PalantirCredentialError( `OAuth2 token is not authorized for this endpoint (instance: ${body.errorInstanceId}). ` + "Your application needs the appropriate API scope. " + "Update your application's maximum scope in Developer Console and re-authorize." ); } return new PalantirApiError(body, response.status); } } // ─── Factory Functions ────────────────────────────────────────────── /** Create a client pre-configured for Client Credentials (service user) flow. */ export function createServiceClient( stackUrl: string, clientId: string, clientSecret: string, scopes?: string[] ): PalantirClient { return new PalantirClient({ stackUrl, clientId, clientSecret, scopes: scopes ?? ["api:use-*"], }); } /** Create a client pre-configured for Authorization Code (user) flow. */ export function createPublicClient( stackUrl: string, clientId: string, redirectUrl: string, scopes?: string[] ): PalantirClient { return new PalantirClient({ stackUrl, clientId, redirectUrl, scopes: scopes ?? ["api:use-*", "offline_access"], }); } /** Create a client with a pre-obtained bearer token. */ export function createTokenClient( stackUrl: string, token: string, options?: { ontologyRid?: string } ): PalantirClient { return new PalantirClient({ stackUrl, clientId: "token-client", token, ontologyRid: options?.ontologyRid, }); }