Files
letta-code/src/auth/oauth.ts
Shubham Naik 58362c9c46 chore: proper auth handling (#65)
Co-authored-by: Shubham Naik <shub@memgpt.ai>
2025-11-05 16:35:37 -08:00

210 lines
5.5 KiB
TypeScript

/**
* OAuth 2.0 utilities for Letta Cloud authentication
* Uses Device Code Flow for CLI authentication
*/
import Letta from "@letta-ai/letta-client";
export const OAUTH_CONFIG = {
clientId: "ci-let-724dea7e98f4af6f8f370f4b1466200c",
clientSecret: "", // Not needed for device code flow
authBaseUrl: "https://app.letta.com",
apiBaseUrl: "https://api.letta.com",
} as const;
export interface DeviceCodeResponse {
device_code: string;
user_code: string;
verification_uri: string;
verification_uri_complete: string;
expires_in: number;
interval: number;
}
export interface TokenResponse {
access_token: string;
refresh_token?: string;
token_type: string;
expires_in: number;
scope?: string;
}
export interface OAuthError {
error: string;
error_description?: string;
}
/**
* Device Code Flow - Step 1: Request device code
*/
export async function requestDeviceCode(): Promise<DeviceCodeResponse> {
const response = await fetch(
`${OAUTH_CONFIG.authBaseUrl}/api/oauth/device/code`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
client_id: OAUTH_CONFIG.clientId,
}),
},
);
if (!response.ok) {
const error = (await response.json()) as OAuthError;
throw new Error(
`Failed to request device code: ${error.error_description || error.error}`,
);
}
return (await response.json()) as DeviceCodeResponse;
}
/**
* Device Code Flow - Step 2: Poll for token
*/
export async function pollForToken(
deviceCode: string,
interval: number = 5,
expiresIn: number = 900,
): Promise<TokenResponse> {
const startTime = Date.now();
const expiresInMs = expiresIn * 1000;
let pollInterval = interval * 1000;
while (Date.now() - startTime < expiresInMs) {
await new Promise((resolve) => setTimeout(resolve, pollInterval));
try {
const response = await fetch(
`${OAUTH_CONFIG.authBaseUrl}/api/oauth/token`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
client_id: OAUTH_CONFIG.clientId,
device_code: deviceCode,
}),
},
);
const result = await response.json();
if (response.ok) {
return result as TokenResponse;
}
const error = result as OAuthError;
if (error.error === "authorization_pending") {
// User hasn't authorized yet, keep polling
continue;
}
if (error.error === "slow_down") {
// We're polling too fast, increase interval by 5 seconds
pollInterval += 5000;
continue;
}
if (error.error === "access_denied") {
throw new Error("User denied authorization");
}
if (error.error === "expired_token") {
throw new Error("Device code expired");
}
throw new Error(`OAuth error: ${error.error_description || error.error}`);
} catch (error) {
if (error instanceof Error) {
throw error;
}
throw new Error(`Failed to poll for token: ${String(error)}`);
}
}
throw new Error("Timeout waiting for authorization (15 minutes)");
}
/**
* Refresh an access token using a refresh token
*/
export async function refreshAccessToken(
refreshToken: string,
): Promise<TokenResponse> {
const response = await fetch(`${OAUTH_CONFIG.authBaseUrl}/api/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
grant_type: "refresh_token",
client_id: OAUTH_CONFIG.clientId,
refresh_token: refreshToken,
refresh_token_mode: "new",
}),
});
if (!response.ok) {
const error = (await response.json()) as OAuthError;
throw new Error(
`Failed to refresh access token: ${error.error_description || error.error}`,
);
}
return (await response.json()) as TokenResponse;
}
/**
* Revoke a refresh token (logout)
*/
export async function revokeToken(refreshToken: string): Promise<void> {
try {
const response = await fetch(
`${OAUTH_CONFIG.authBaseUrl}/api/oauth/revoke`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
client_id: OAUTH_CONFIG.clientId,
token: refreshToken,
token_type_hint: "refresh_token",
}),
},
);
// OAuth 2.0 revoke endpoint should return 200 even if token is already invalid
if (!response.ok) {
const error = (await response.json()) as OAuthError;
console.error(
`Warning: Failed to revoke token: ${error.error_description || error.error}`,
);
// Don't throw - we still want to clear local credentials
}
} catch (error) {
console.error("Warning: Failed to revoke token:", error);
// Don't throw - we still want to clear local credentials
}
}
/**
* Validate credentials by checking health endpoint
* Validate credentials by checking an authenticated endpoint
* Uses SDK's agents.list() which requires valid authentication
*/
export async function validateCredentials(
baseUrl: string,
apiKey: string,
): Promise<boolean> {
try {
// Create a temporary client to test authentication
const client = new Letta({ apiKey, baseURL: baseUrl });
// Try to list agents - this requires valid authentication
await client.agents.list({ limit: 1 });
return true;
} catch {
return false;
}
}