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
|
- 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
|
||||||
|
|||||||
Reference in New Issue
Block a user