feat: add portfolio SOPS secret for CI status

This commit is contained in:
2026-08-31 22:33:00 -07:00
parent f3c88c0d9d
commit 4a1a60104d
3 changed files with 230 additions and 0 deletions
+206
View File
@@ -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: '[email protected]',
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=<from-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 <token>` to all LLM requests
@@ -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
+1
View File
@@ -26,3 +26,4 @@ files:
- paperless-secrets.enc.yaml - paperless-secrets.enc.yaml
- vault-secrets.enc.yaml - vault-secrets.enc.yaml
- vault-unseal-keys.enc.yaml - vault-unseal-keys.enc.yaml
- portfolio-secrets.enc.yaml