Skip to main content

Overview

Loaf supports three authentication providers with different authentication mechanisms. All credentials are stored securely using the system keychain when available.

Supported Providers

  • OpenAI - OAuth 2.0 with device code flow or browser-based login
  • OpenRouter - API key authentication
  • Antigravity - Google OAuth 2.0 with browser flow

OpenAI Authentication

OAuth Flow

OpenAI uses OAuth 2.0 with PKCE for secure authentication:
import { runOpenAiOauthLogin } from "./openai-oauth.js";

const result = await runOpenAiOauthLogin({
  openBrowser: true,        // Auto-open browser
  mode: "auto",             // Auto-detect browser vs device code
  onAuthUrl: (url) => {
    console.log(`Visit: ${url}`);
  },
});

console.log(`Access token: ${result.chatgptAuth.accessToken}`);
console.log(`Account ID: ${result.chatgptAuth.accountId}`);

Authentication Modes

type OpenAiOauthLoginOptions = {
  mode?: "auto" | "browser" | "device_code";
  // ... other options
};
  • auto (default) - Detects headless environments and chooses appropriate flow
  • browser - Opens browser and uses localhost callback
  • device_code - Uses device code flow for headless environments

Headless Detection

Loaf automatically detects headless environments (src/openai-oauth.ts:274):
export function isLikelyHeadlessEnvironment(): boolean {
  if (
    hasNonEmptyEnv("CI") ||
    hasNonEmptyEnv("SSH_CONNECTION") ||
    hasNonEmptyEnv("SSH_CLIENT") ||
    hasNonEmptyEnv("SSH_TTY")
  ) {
    return true;
  }

  if (
    process.platform === "linux" &&
    !hasNonEmptyEnv("DISPLAY") &&
    !hasNonEmptyEnv("WAYLAND_DISPLAY")
  ) {
    return true;
  }

  return false;
}
Headless environments automatically use device code flow.

Browser Flow

The browser flow uses PKCE with local callback server:
  1. Generate PKCE challenge (src/openai-oauth.ts:226):
function generatePkce(): { codeVerifier: string; codeChallenge: string } {
  const codeVerifier = randomBase64Url(64);
  const digest = crypto.createHash("sha256").update(codeVerifier).digest();
  const codeChallenge = toBase64Url(digest);
  return { codeVerifier, codeChallenge };
}
  1. Start local server on port 1455 (or random if taken)
  2. Build authorization URL:
const authUrl = `${issuer}/oauth/authorize?` + new URLSearchParams({
  response_type: "code",
  client_id: clientId,
  redirect_uri: `http://localhost:${port}/auth/callback`,
  scope: "openid profile email offline_access",
  code_challenge: pkce.codeChallenge,
  code_challenge_method: "S256",
  state: randomState,
});
  1. Exchange code for tokens at /oauth/token
  2. Extract ChatGPT account ID from ID token JWT

Device Code Flow

For headless environments (src/openai-oauth.ts:380):
const deviceCode = await requestOpenAiDeviceCode(issuer, clientId);
// Returns:
// {
//   deviceAuthId: string,
//   userCode: string,  // e.g., "ABCD-EFGH"
//   verificationUrl: string,  // e.g., "https://auth.openai.com/codex/device"
//   intervalSeconds: 5,
//   expiresInSeconds: 900,
// }

console.log(`Visit ${deviceCode.verificationUrl}`);
console.log(`Enter code: ${deviceCode.userCode}`);

// Poll for authorization
const auth = await waitForOpenAiDeviceAuthorization({
  issuer,
  deviceAuthId: deviceCode.deviceAuthId,
  userCode: deviceCode.userCode,
  intervalSeconds: 5,
  timeoutMs: 15 * 60 * 1000,
});
The device code flow:
  1. Requests a user code from /api/accounts/deviceauth/usercode
  2. Displays verification URL and code to user
  3. Polls /api/accounts/deviceauth/token every 5 seconds
  4. Returns authorization code when user approves
  5. Exchanges code for tokens

Token Exchange

OpenAI supports exchanging ID tokens for API keys (src/openai-oauth.ts:729):
async function exchangeIdTokenForApiKey(params: {
  tokenEndpoint: string;
  clientId: string;
  idToken: string;
}): Promise<string> {
  const body = new URLSearchParams({
    grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
    client_id: params.clientId,
    requested_token: "openai-api-key",
    subject_token: params.idToken,
    subject_token_type: "urn:ietf:params:oauth:token-type:id_token",
  });

  const response = await fetch(params.tokenEndpoint, {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body,
  });

  const json = await response.json();
  return json.access_token; // OpenAI API key
}

Credential Storage

OpenAI credentials are stored in two locations:
  1. Secure store (system keychain):
type StoredOpenAiChatgptSecrets = {
  version: 1;
  accessToken: string;
  accountId?: string | null;
  refreshToken?: string;
  idToken?: string;
  apiKey?: string;
};
  1. Auth metadata file (~/.loaf/auth.json):
type OpenAiAuthJson = {
  auth_mode?: "chatgpt";
  tokens?: {
    account_id?: string;  // Non-sensitive metadata
  };
  last_refresh?: string;  // ISO timestamp
};

Loading Credentials

import { loadPersistedOpenAiChatgptAuth } from "./openai-oauth.js";

const auth = await loadPersistedOpenAiChatgptAuth();
if (auth) {
  console.log(`Access token: ${auth.accessToken}`);
  console.log(`Account: ${auth.accountId}`);
  console.log(`Has API key: ${Boolean(auth.apiKey)}`);
}
Loading priority:
  1. Read from secure store (preferred)
  2. Migrate from legacy auth.json if found
  3. Return null if no credentials

Configuration

const DEFAULT_ISSUER = "https://auth.openai.com";
const DEFAULT_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
const DEFAULT_PORT = 1455;
const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000; // 15 minutes

OpenRouter Authentication

OpenRouter uses simple API key authentication:
# Set API key in environment
export OPENROUTER_API_KEY="sk-or-..."

# Or configure in loaf
loaf config set openrouter.apiKey "sk-or-..."
API keys are passed in request headers:
const response = await fetch("https://openrouter.ai/api/v1/chat/completions", {
  headers: {
    "Authorization": `Bearer ${apiKey}`,
    "HTTP-Referer": "https://loaf.sh",
    "X-Title": "Loaf CLI",
  },
  // ...
});

Antigravity Authentication

Google OAuth Flow

Antigravity uses Google OAuth 2.0 with offline access:
import { runAntigravityOauthLogin } from "./antigravity-oauth.js";

const result = await runAntigravityOauthLogin({
  openBrowser: true,
  onAuthUrl: (url) => console.log(`Visit: ${url}`),
});

console.log(`Access token: ${result.tokenInfo.accessToken}`);
console.log(`Profile: ${result.profile?.name} <${result.profile?.email}>`);

OAuth Configuration

const ANTIGRAVITY_CLIENT_ID = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com";
const ANTIGRAVITY_CLIENT_SECRET = "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";
const ANTIGRAVITY_SCOPES = [
  "https://www.googleapis.com/auth/cloud-platform",
  "https://www.googleapis.com/auth/userinfo.email",
  "https://www.googleapis.com/auth/userinfo.profile",
  "https://www.googleapis.com/auth/cclog",
  "https://www.googleapis.com/auth/experimentsandconfigs",
];

Token Storage

Antigravity tokens are stored in the secure store:
type StoredAntigravityOauthSecrets = {
  version: 1;
  accessToken: string;
  refreshToken: string;
  expiryDateSeconds: number;
  tokenType: string;
};

Token Refresh

Tokens are automatically refreshed when nearing expiry (src/antigravity-oauth.ts:123):
export async function refreshAntigravityOauthTokenIfNeeded(
  tokenInfo: AntigravityOauthTokenInfo
): Promise<AntigravityOauthTokenInfo | null> {
  const TOKEN_REFRESH_WINDOW_SECONDS = 5 * 60;
  const nowSeconds = Date.now() / 1000;
  
  if (tokenInfo.expiryDateSeconds - nowSeconds >= TOKEN_REFRESH_WINDOW_SECONDS) {
    return tokenInfo; // Still fresh
  }

  const oauthClient = new OAuth2Client(CLIENT_ID, CLIENT_SECRET);
  oauthClient.setCredentials({
    refresh_token: tokenInfo.refreshToken,
    access_token: tokenInfo.accessToken,
    expiry_date: tokenInfo.expiryDateSeconds * 1000,
  });

  const refreshed = await oauthClient.refreshAccessToken();
  const next = {
    accessToken: refreshed.credentials.access_token ?? "",
    refreshToken: refreshed.credentials.refresh_token ?? "",
    expiryDateSeconds: Math.floor((refreshed.credentials.expiry_date ?? 0) / 1000),
    tokenType: refreshed.credentials.token_type ?? "",
  };

  await persistAntigravityOauthTokenInfo(next);
  return next;
}
Tokens refresh automatically when within 5 minutes of expiry.

Profile Data

Antigravity fetches user profile from Google APIs (src/antigravity-oauth.ts:162):
export async function fetchAntigravityProfileData(
  accessToken: string,
  timeoutMs = 5000
): Promise<AntigravityOauthProfile | null> {
  const response = await fetch(
    "https://www.googleapis.com/oauth2/v2/userinfo",
    {
      headers: { Authorization: `Bearer ${accessToken}` },
      signal: AbortSignal.timeout(timeoutMs),
    }
  );

  const data = await response.json();
  return {
    email: data.email,
    name: data.name,
    picture: data.picture,
  };
}

Loading Credentials

import { loadPersistedAntigravityOauthTokenInfo } from "./antigravity-oauth.js";

const tokenInfo = await loadPersistedAntigravityOauthTokenInfo();
if (tokenInfo) {
  // Tokens are automatically refreshed if needed
  console.log(`Access token: ${tokenInfo.accessToken}`);
  console.log(`Expires: ${new Date(tokenInfo.expiryDateSeconds * 1000)}`);
}

Secure Storage

Loaf uses the system keychain for credential storage:
import { getSecureValue, setSecureValue } from "./secure-store.js";

// Store credential
await setSecureValue("my-secret-key", JSON.stringify(credentials));

// Retrieve credential
const raw = await getSecureValue("my-secret-key");
const credentials = JSON.parse(raw);
Secret account keys:
export const SECRET_ACCOUNT_OPENAI_CHATGPT_AUTH = "loaf:accounts:openai:chatgpt";
export const SECRET_ACCOUNT_ANTIGRAVITY_OAUTH_TOKEN_INFO = "loaf:accounts:antigravity:oauth";

Best Practices

Security

  1. Use secure storage - Never store tokens in plain text files
  2. Rotate regularly - Refresh tokens periodically
  3. Limit scope - Request only necessary OAuth scopes
  4. Handle expiry - Check token expiration before use
  5. Clean up - Remove credentials when switching accounts

Error Handling

  1. Network failures - Retry with exponential backoff
  2. Token expiry - Automatically refresh when possible
  3. Invalid credentials - Prompt for re-authentication
  4. Timeout - Set reasonable timeouts for auth flows

Multi-Provider Support

  1. Check availability - Verify provider is configured before use
  2. Fallback providers - Support multiple providers for resilience
  3. Provider switching - Allow easy switching between providers

CLI Usage

# OpenAI login
loaf auth login openai

# OpenRouter configuration
loaf auth login openrouter

# Antigravity login
loaf auth login antigravity

# Check auth status
loaf auth status

# Logout
loaf auth logout

Source Code Reference

  • src/openai-oauth.ts - OpenAI OAuth implementation (src/openai-oauth.ts:1)
  • src/antigravity-oauth.ts - Antigravity OAuth implementation (src/antigravity-oauth.ts:1)
  • src/secure-store.ts - Secure credential storage
  • src/secret-accounts.ts - Secret key definitions
  • src/config.ts - Provider type definitions (src/config.ts:2)

Build docs developers (and LLMs) love