// ── Logto Management API Client ───────────────────────────────────── // Machine-to-Machine (M2M) client for proxying account management // operations through GearBox's backend, per D-04. // Users never interact with Logto directly. interface LogtoManagementConfig { issuer: string; m2mAppId: string; m2mAppSecret: string; apiResource: string; } interface LogtoUser { id: string; primaryEmail: string | null; name: string | null; avatar: string | null; createdAt: number; } export class LogtoManagementClient { private config: LogtoManagementConfig | null = null; private accessToken: string | null = null; private tokenExpiry = 0; constructor(config?: LogtoManagementConfig) { if (config) { this.config = config; return; } const issuer = process.env.OIDC_ISSUER; const m2mAppId = process.env.LOGTO_M2M_APP_ID; const m2mAppSecret = process.env.LOGTO_M2M_APP_SECRET; const apiResource = process.env.LOGTO_API_RESOURCE ?? "https://default.logto.app/api"; if (issuer && m2mAppId && m2mAppSecret) { this.config = { issuer, m2mAppId, m2mAppSecret, apiResource }; } // config stays null if env vars missing — methods will throw } private ensureConfigured(): LogtoManagementConfig { if (!this.config) { throw new Error( "Logto M2M not configured. Set LOGTO_M2M_APP_ID and LOGTO_M2M_APP_SECRET env vars.", ); } return this.config; } /** * Derive the Management API base URL from the OIDC issuer. * Strip /oidc suffix if present, to get the Logto base URL. */ private getBaseUrl(): string { const config = this.ensureConfigured(); const issuer = config.issuer.replace(/\/oidc\/?$/, ""); return issuer; } /** * Get a cached M2M access token, refreshing if expired. * Per T-28-01: never log the token or secret. * Per T-28-02: cache with TTL (expiry minus 60s buffer). */ async getAccessToken(): Promise { const config = this.ensureConfigured(); // Return cached token if still valid (with 60s buffer) if (this.accessToken && Date.now() < this.tokenExpiry - 60_000) { return this.accessToken; } const tokenUrl = `${this.getBaseUrl()}/oidc/token`; const credentials = Buffer.from( `${config.m2mAppId}:${config.m2mAppSecret}`, ).toString("base64"); const res = await fetch(tokenUrl, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", Authorization: `Basic ${credentials}`, }, body: new URLSearchParams({ grant_type: "client_credentials", resource: config.apiResource, scope: "all", }).toString(), }); if (!res.ok) { const errorBody = await res.text(); console.error( `[Logto M2M] Token request failed: HTTP ${res.status}`, `\n URL: ${tokenUrl}`, `\n Resource: ${config.apiResource}`, `\n App ID: ${config.m2mAppId.slice(0, 8)}...`, `\n Response: ${errorBody}`, ); throw new Error( `Logto M2M token request failed: HTTP ${res.status} — ${errorBody}`, ); } const data = (await res.json()) as { access_token: string; expires_in: number; }; this.accessToken = data.access_token; this.tokenExpiry = Date.now() + data.expires_in * 1000; return this.accessToken; } private async apiRequest( method: string, path: string, body?: unknown, ): Promise { const token = await this.getAccessToken(); const url = `${this.getBaseUrl()}${path}`; const headers: Record = { Authorization: `Bearer ${token}`, }; if (body !== undefined) { headers["Content-Type"] = "application/json"; } return fetch(url, { method, headers, body: body !== undefined ? JSON.stringify(body) : undefined, }); } async getUser(logtoSub: string): Promise { const res = await this.apiRequest("GET", `/api/users/${logtoSub}`); if (!res.ok) { throw new Error(`Failed to get Logto user: HTTP ${res.status}`); } return res.json() as Promise; } /** * Verify a user's current password. * Returns true if password is correct, false otherwise. */ async verifyPassword(logtoSub: string, password: string): Promise { const res = await this.apiRequest( "POST", `/api/users/${logtoSub}/password/verify`, { password }, ); if (res.status === 204 || res.status === 200) return true; if (res.status === 422 || res.status === 400) return false; throw new Error(`Logto verifyPassword unexpected: HTTP ${res.status}`); } /** * Set a new password for the user. */ async updatePassword(logtoSub: string, password: string): Promise { const res = await this.apiRequest( "PATCH", `/api/users/${logtoSub}/password`, { password }, ); if (!res.ok) { throw new Error(`Failed to update password: HTTP ${res.status}`); } } /** * Check if user has a password set (social-only accounts may not). */ async hasPassword(logtoSub: string): Promise { const res = await this.apiRequest( "GET", `/api/users/${logtoSub}/has-password`, ); if (!res.ok) { throw new Error(`Failed to check password status: HTTP ${res.status}`); } const data = (await res.json()) as { hasPassword: boolean }; return data.hasPassword; } /** * Update the user's primary email on Logto. */ async updateEmail(logtoSub: string, email: string): Promise { const res = await this.apiRequest("PATCH", `/api/users/${logtoSub}`, { primaryEmail: email, }); if (!res.ok) { throw new Error(`Failed to update email: HTTP ${res.status}`); } } /** * Delete the user from Logto entirely. */ async deleteUser(logtoSub: string): Promise { const res = await this.apiRequest("DELETE", `/api/users/${logtoSub}`); if (!res.ok) { throw new Error(`Failed to delete Logto user: HTTP ${res.status}`); } } } export const logtoClient = new LogtoManagementClient();