From 32636832fc544002bb53bc04dce3c5422c81a2c5 Mon Sep 17 00:00:00 2001 From: Story Crater Bot <19826264+Riotpiaole@users.noreply.github.com> Date: Thu, 3 Sep 2026 19:32:12 -0700 Subject: [PATCH] auth: switch to Authentik OAuth for LLM API - Add lib/auth.ts: OAuth client with token caching - Use client_credentials grant with portfolio-agent service account - Mount portfolio-agent-oidc secret for credentials - Remove static LLM_API_TOKEN dependency --- app/api/chat/route.ts | 16 +++---- infra/portfolio/base/deployment.yaml | 17 ++++++-- lib/auth.ts | 65 ++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 14 deletions(-) create mode 100644 lib/auth.ts diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index 908c908..4871e9f 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -1,7 +1,8 @@ import { NextRequest } from 'next/server' +import { getAccessToken } from '@/lib/auth' -const LLM_API_URL = 'http://api-gateway.api.svc.cluster.local:8080/v1/chat/completions' -const MODEL = 'reasoning' +const LLM_API_URL = process.env.LLM_API_URL || 'http://api-gateway.api.svc.cluster.local:8080/v1/chat/completions' +const MODEL = process.env.LLM_MODEL || 'reasoning' const SYSTEM_PROMPT = `You are **Poimen**, Rock Liang's AI assistant embedded in his portfolio. You help visitors understand Rock's journey, technical depth, and what drives him. Speak with a humble, curious tone—Rock is someone who learns by building, breaks things to understand them, and is genuinely excited about distributed systems and AI. @@ -134,16 +135,9 @@ GOOD example: BAD: "Rock resolved the Terraform configuration drift issue by implementing automated drift detection and real-time visibility into discrepancies..." — this is garbage. Never do this.` export async function POST(request: NextRequest) { - const token = process.env.LLM_API_TOKEN - - if (!token) { - return new Response( - JSON.stringify({ error: 'LLM_API_TOKEN not configured' }), - { status: 500, headers: { 'Content-Type': 'application/json' } } - ) - } - try { + // Get OAuth token (cached, auto-refreshes) + const token = await getAccessToken() const { message } = await request.json() const response = await fetch(LLM_API_URL, { diff --git a/infra/portfolio/base/deployment.yaml b/infra/portfolio/base/deployment.yaml index 804a057..9d90270 100644 --- a/infra/portfolio/base/deployment.yaml +++ b/infra/portfolio/base/deployment.yaml @@ -26,11 +26,22 @@ spec: secretKeyRef: name: portfolio-secrets key: FORGEJO_TOKEN - - name: LLM_API_TOKEN + # OAuth credentials for LLM API (client_credentials grant) + - name: AUTHENTIK_CLIENT_ID valueFrom: secretKeyRef: - name: portfolio-secrets - key: LLM_API_TOKEN + name: portfolio-agent-oidc + key: CLIENT_ID + - name: AUTHENTIK_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: portfolio-agent-oidc + key: CLIENT_SECRET + - name: AUTHENTIK_TOKEN_URL + valueFrom: + secretKeyRef: + name: portfolio-agent-oidc + key: TOKEN_URL ports: - name: http containerPort: 3000 diff --git a/lib/auth.ts b/lib/auth.ts new file mode 100644 index 0000000..f7881ed --- /dev/null +++ b/lib/auth.ts @@ -0,0 +1,65 @@ +/** + * 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. + */ +export async function getAccessToken(): Promise { + 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('AUTHENTIK_CLIENT_ID and AUTHENTIK_CLIENT_SECRET must be set') + } + + 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 +}