feat: add portfolio SOPS secret for CI status
This commit is contained in:
@@ -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
|
||||
@@ -26,3 +26,4 @@ files:
|
||||
- paperless-secrets.enc.yaml
|
||||
- vault-secrets.enc.yaml
|
||||
- vault-unseal-keys.enc.yaml
|
||||
- portfolio-secrets.enc.yaml
|
||||
|
||||
Reference in New Issue
Block a user