From 4a1a60104dbbf663a5b246e5b7ff307e43a6f1fb Mon Sep 17 00:00:00 2001 From: rock Date: Mon, 31 Aug 2026 22:33:00 -0700 Subject: [PATCH] feat: add portfolio SOPS secret for CI status --- AUTH_INTEGRATION.md | 206 ++++++++++++++++++ k8s/argocd/secrets/portfolio-secrets.enc.yaml | 23 ++ k8s/argocd/secrets/secret-generator.yaml | 1 + 3 files changed, 230 insertions(+) create mode 100644 AUTH_INTEGRATION.md create mode 100644 k8s/argocd/secrets/portfolio-secrets.enc.yaml diff --git a/AUTH_INTEGRATION.md b/AUTH_INTEGRATION.md new file mode 100644 index 0000000..992c4f8 --- /dev/null +++ b/AUTH_INTEGRATION.md @@ -0,0 +1,206 @@ +# 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 diff --git a/k8s/argocd/secrets/portfolio-secrets.enc.yaml b/k8s/argocd/secrets/portfolio-secrets.enc.yaml new file mode 100644 index 0000000..780acac --- /dev/null +++ b/k8s/argocd/secrets/portfolio-secrets.enc.yaml @@ -0,0 +1,23 @@ +apiVersion: ENC[AES256_GCM,data:bnY=,iv:Fuc3aqncHQ+L16o7eLarPbOECD3o8Mk5c2r9pQBpy70=,tag:JPfEnbNb3wZXPdXafnJDqw==,type:str] +kind: ENC[AES256_GCM,data:WFlmi4Yg,iv:Zq/KQbgNcBVoo8ZsQ2H79ygyc8Dtkgxh4fCpEExfwSg=,tag:cWHP6Y5V+nZP2tFMJrOB8A==,type:str] +metadata: + name: ENC[AES256_GCM,data:iCXbhwvg3Zq6YL/4j0wAy7Y=,iv:8s+d/8lDVEL7bGdIF+GOtAxapKnmx8JTjLSO04hXF5I=,tag:LjzX40DjK+uRZPCXsMlmwQ==,type:str] + namespace: ENC[AES256_GCM,data:HRMdZdCbxORQ,iv:MvaIWoKWjJRA7/fce0KtXRkFH/7cn0OuIg2QwHEdQzM=,tag:NqztiGyfU3BaopWBKhx2eg==,type:str] +type: ENC[AES256_GCM,data:myBW86Za,iv:3x9ys5UzVhAuX8gvZO67B1e+Orw4Aqasv/lHBgUV0b4=,tag:yl18P6SaxVLJidwmRtd4aQ==,type:str] +stringData: + FORGEJO_TOKEN: ENC[AES256_GCM,data:SUoBpNKOItyNGY01EhKNlPH0fyN4N7g6bfU2jsGogpCMhP7NuRipoA==,iv:h77RtmYZjXxiHYw1pHynQHuVX1+yJDGHwsXLf+DbUYA=,tag:GHzoDNHP1GGqlN5eT/N7TQ==,type:str] +sops: + age: + - enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBzTVBsekl3TGgzQVRMUU9m + cTduS2NoZW5uZFNNMG13cFY2cGVsTnlXaXhrCjJjbzhLdHZ4ZWpUV3J0cDQ0eVlM + WDNxdzVoQ2ZzcGJSbTU3RVorcnczNVkKLS0tIFdtQTE4Umk2TDBzUmdKOXNkbjFi + Vk5vK2VuUHVsb3FQL21vcGU1UW5CT1kKFM8vVjji3Cg9dvfTr4Hx7BJC8JH5ovef + Dj6zkofhsNWPgP9T+mnQakj+C0RKmHOMJqfWP7vwCBkZoNosIJVlMw== + -----END AGE ENCRYPTED FILE----- + recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla + lastmodified: "2026-09-01T05:32:42Z" + mac: ENC[AES256_GCM,data:it24T9y9ixXo2aiL37k93vKFR+SRjjuI9DQdv0sWYtTogWnc7+uXBY4Zip/ouWyCse1muKKAGuek5c0XVrvSw4an9VkaXFczeunaZb6MOyVbVOkmJr+5xZFpZGjYcSkrhaWcVheedZ3iIFU5UWI7BBn/qQCf+HJ483cJqwtrV34=,iv:WprlWJdsMBNjqaA0O3ekfXMUpX5gC6OLYortQXYdTS4=,tag:rGqSSRTYTv2VR6AcRokO0A==,type:str] + unencrypted_suffix: _unencrypted + version: 3.13.2 diff --git a/k8s/argocd/secrets/secret-generator.yaml b/k8s/argocd/secrets/secret-generator.yaml index 095af74..c09d01b 100644 --- a/k8s/argocd/secrets/secret-generator.yaml +++ b/k8s/argocd/secrets/secret-generator.yaml @@ -26,3 +26,4 @@ files: - paperless-secrets.enc.yaml - vault-secrets.enc.yaml - vault-unseal-keys.enc.yaml + - portfolio-secrets.enc.yaml