/** * Authentik OAuth client for service account authentication. * Uses client_credentials grant with token caching. */ interface TokenResponse { access_token: string token_type: string expires_in: number } interface CachedToken { token: string expiresAt: number } let cachedToken: CachedToken | null = null /** * Get a valid access token, fetching a new one if expired. * Tokens are cached with 60s buffer before expiry. * * For local dev: set LLM_API_TOKEN env var to skip OAuth. */ export async function getAccessToken(): Promise { // Local dev fallback - use static token if set const staticToken = process.env.LLM_API_TOKEN if (staticToken) { return staticToken } const now = Date.now() // Return cached token if still valid (with 60s buffer) if (cachedToken && cachedToken.expiresAt > now + 60000) { return cachedToken.token } const clientId = process.env.AUTHENTIK_CLIENT_ID const clientSecret = process.env.AUTHENTIK_CLIENT_SECRET const tokenUrl = process.env.AUTHENTIK_TOKEN_URL || 'https://authentik.riotpiao.com/application/o/token/' if (!clientId || !clientSecret) { throw new Error( 'Missing OAuth credentials. Set AUTHENTIK_CLIENT_ID + AUTHENTIK_CLIENT_SECRET, ' + 'or LLM_API_TOKEN for local dev.' ) } const response = await fetch(tokenUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, body: new URLSearchParams({ grant_type: 'client_credentials', client_id: clientId, client_secret: clientSecret, scope: 'openid roles', }), }) if (!response.ok) { const error = await response.text() throw new Error(`Failed to get access token: ${response.status} - ${error}`) } const data: TokenResponse = await response.json() cachedToken = { token: data.access_token, expiresAt: now + (data.expires_in * 1000), } return cachedToken.token }