207 lines
5.8 KiB
Markdown
207 lines
5.8 KiB
Markdown
# 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
|