# Authentik Auth Integration for NextJS ## Current State ### Gateway Auth Status | Endpoint | Auth Status | Notes | |----------|-------------|-------| | `/v1/chat/completions` | ❌ **OFF** | LLM routes have no auth middleware | | `/v1/embeddings` | ❌ **OFF** | Same - no auth | | `/v1/rerank` | ❌ **OFF** | Same - no auth | | `X-Service: sqs` | ✅ **ON** | JWT validated via `internal/auth/jwt.go` | | `/workflow` | ❌ **OFF** | Pass-through to Temporal | **Auth module exists** at `homelab-frontend/internal/auth/jwt.go` but only wired for SQS. LLM routes in `internal/proxy/proxy.go` have no auth middleware. ### Authentik App Authentik app `local-llm` exists for LLM API auth: - **Client ID**: `local-llm` - **Client Secret**: `kubectl -n llm-serving get secret local-llm-jwt -o jsonpath='{.data.client-secret}' | base64 -d` - **Token endpoint**: `https://authentik.riotpiao.com/application/o/token/` - **Userinfo endpoint**: `https://authentik.riotpiao.com/application/o/userinfo/` - **OIDC discovery**: `https://authentik.riotpiao.com/application/o/local-llm/.well-known/openid-configuration` ## Sign-in Methods ### 1. Resource Owner Password Credentials (ROPC) Direct username/password login. Server-side only (needs client_secret). ```typescript // API Route: app/api/auth/login/route.ts const response = await fetch('https://authentik.riotpiao.com/application/o/token/', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'password', client_id: 'local-llm', client_secret: process.env.AUTHENTIK_CLIENT_SECRET, username: 'user@example.com', password: 'userpassword', scope: 'openid email profile groups', }), }); const tokens = await response.json(); // { access_token, refresh_token, expires_in, token_type } ``` ### 2. Authorization Code Flow (Browser Redirect) Requires adding redirect URIs to `local-llm` Authentik app: ```python # In k8s/infra/iam/scripts/authentik-provision.py, update: "local-llm": { ... "redirect_uris": [ "http://localhost:3000/api/auth/callback", # dev "https://your-nextjs-app.com/api/auth/callback", # prod ], } ``` Then standard OIDC flow: 1. Redirect to `https://authentik.riotpiao.com/application/o/authorize/?client_id=local-llm&redirect_uri=...&response_type=code&scope=openid email profile groups` 2. User logs in via Authentik UI 3. Callback receives `code`, exchange for tokens ## JWT Token Persistence ### Browser (localStorage) ```typescript const TOKEN_KEY = 'llm_auth_token'; // Save localStorage.setItem(TOKEN_KEY, JSON.stringify({ access_token: tokens.access_token, refresh_token: tokens.refresh_token, expires_at: Date.now() + tokens.expires_in * 1000, })); // Load const stored = JSON.parse(localStorage.getItem(TOKEN_KEY) || 'null'); if (stored && stored.expires_at > Date.now()) { // Token valid } // Clear (logout) localStorage.removeItem(TOKEN_KEY); ``` ### Server-side (HTTP-only cookies) ```typescript // app/api/auth/login/route.ts import { cookies } from 'next/headers'; // After successful login cookies().set('llm_auth_token', JSON.stringify(tokens), { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', maxAge: tokens.expires_in, path: '/', }); // Read in middleware or API routes const tokenCookie = cookies().get('llm_auth_token'); const tokens = JSON.parse(tokenCookie?.value || 'null'); ``` ## Token Refresh ```typescript async function refreshAccessToken(refresh_token: string) { const response = await fetch('https://authentik.riotpiao.com/application/o/token/', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'refresh_token', client_id: 'local-llm', client_secret: process.env.AUTHENTIK_CLIENT_SECRET, refresh_token, }), }); return response.json(); } ``` ## Environment Variables ```bash # .env.local AUTHENTIK_URL=https://authentik.riotpiao.com AUTHENTIK_CLIENT_ID=local-llm AUTHENTIK_CLIENT_SECRET= # For client-side (public) NEXT_PUBLIC_AUTHENTIK_URL=https://authentik.riotpiao.com NEXT_PUBLIC_AUTHENTIK_CLIENT_ID=local-llm ``` ## Using Token with LLM API ```typescript const token = await getValidToken(); // from localStorage or cookie const response = await fetch('https://api.riotpiao.com/v1/chat/completions', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}`, // JWT from Authentik }, body: JSON.stringify({ model: 'reasoning', messages: [{ role: 'user', content: 'Hello' }], }), }); ``` ## TODO ### Gateway-side (homelab-frontend) - [ ] Wire `internal/auth/jwt.go` into LLM proxy handler (`internal/proxy/proxy.go`) - [ ] Add `authRequired: true` to model config or create LLM-specific middleware - [ ] Example pattern from SQS (in `internal/serviceadapter/router.go`): ```go // In proxy.go ServeHTTP, before dispatching to LLM upstream: if strings.HasPrefix(r.URL.Path, "/v1/") { authHeader := r.Header.Get("Authorization") claims, err := llmJWTAuth.ValidateBearerToken(authHeader) if err != nil { // Return 401/403 } if !llmJWTAuth.CheckPermissions(claims, "llm:inference", "*") { // Return 403 insufficient permissions } } ``` ### Authentik-side - [ ] Enable ROPC grant in Authentik provider settings (if not already) - [ ] Add redirect URIs to `local-llm` app if browser OAuth flow needed: ```python # k8s/infra/iam/scripts/authentik-provision.py "local-llm": { ... "redirect_uris": [ "http://localhost:3000/api/auth/callback", "https://your-app.com/api/auth/callback", ], } ``` ### NextJS-side - [ ] Until gateway auth is wired, LLM API works without token - [ ] Once wired, add `Authorization: Bearer ` to all LLM requests