Files
homelab/AUTH_INTEGRATION.md

5.8 KiB

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).

// 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:

# 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)

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)

// 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

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

# .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

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):
// 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:
# 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