This commit is contained in:
Mabe
2026-06-20 23:00:05 +02:00
commit 4ebc1fa3cc
19 changed files with 707 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
# Ollama Cloud API key
OLLAMA_API_KEY=your_ollama_api_key_here
# OIDC settings
OIDC_ISSUER=https://your-issuer.example.com
OIDC_AUDIENCE=your-api-audience
OIDC_CLIENT_ID=your-client-id
OIDC_CLIENT_SECRET=your-client-secret
OIDC_VERIFY_ISS=true
# Frontend redirect URI
VITE_OIDC_REDIRECT_URI=http://localhost:5173
+7
View File
@@ -0,0 +1,7 @@
__pycache__/
*.py[cod]
*.env
node_modules/
dist/
build/
.DS_Store
+43
View File
@@ -0,0 +1,43 @@
# AI Chat OIDC + Ollama Cloud
This repository includes:
- `backend/` — FastAPI service validating OIDC JWTs and proxying chat to Ollama Cloud with SSE streaming
- `frontend/` — React + Vite + TypeScript UI performing OIDC Authorization Code Flow with PKCE
- `docker-compose.yml` — brings up backend, frontend, and optional Keycloak (dev-only)
## Prerequisites
- Docker >= 24.0
- Docker Compose >= 2.0
- An Ollama Cloud API key
- An OIDC issuer URL, client ID, and client secret
## Setup
1. Copy `.env.example` to `.env` and set values.
2. In the frontend, set `VITE_OIDC_REDIRECT_URI` to `http://localhost:5173` or your UI URL.
3. If using the local Keycloak dev service, enable the `dev` profile:
```bash
docker compose --profile dev up --build
```
4. If you only want backend/frontend with an external OIDC provider:
```bash
docker compose up --build
```
## API
- `GET /health` — health check
- `GET /auth/config` — OIDC discovery endpoints for the frontend
- `POST /auth/callback` — exchanges OIDC code for tokens
- `POST /chat` — protected chat endpoint streaming SSE from Ollama Cloud
## Notes
- The backend requires `OLLAMA_API_KEY` in environment.
- The frontend stores the access token in `localStorage` for simplicity.
- OpenAPI docs are available at `http://localhost:8000/docs`.
+6
View File
@@ -0,0 +1,6 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
View File
+95
View File
@@ -0,0 +1,95 @@
from functools import lru_cache
from typing import Any, Dict, Optional
from urllib.parse import urljoin
import httpx
from fastapi import HTTPException, Security
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError, jwk, jwt
from pydantic import BaseModel, BaseSettings, Field, HttpUrl
security = HTTPBearer(auto_error=False)
class Settings(BaseSettings):
oidc_issuer: HttpUrl = Field(..., env="OIDC_ISSUER")
oidc_audience: str = Field(..., env="OIDC_AUDIENCE")
oidc_client_id: str = Field(..., env="OIDC_CLIENT_ID")
oidc_client_secret: Optional[str] = Field(None, env="OIDC_CLIENT_SECRET")
oidc_verify_iss: bool = Field(True, env="OIDC_VERIFY_ISS")
class Config:
env_file = ".env"
settings = Settings()
class TokenClaims(BaseModel):
iss: str
sub: str
aud: Any
exp: int
iat: Optional[int]
azp: Optional[str]
email: Optional[str]
preferred_username: Optional[str]
@lru_cache(maxsize=1)
def get_discovery() -> Dict[str, Any]:
issuer = str(settings.oidc_issuer).rstrip("/")
discovery_url = urljoin(issuer + "/", ".well-known/openid-configuration")
with httpx.Client(timeout=10.0) as client:
response = client.get(discovery_url)
if response.status_code != 200:
raise RuntimeError(f"Unable to load OIDC discovery document from {discovery_url}")
return response.json()
@lru_cache(maxsize=1)
def get_jwks() -> Dict[str, Any]:
discovery = get_discovery()
jwks_uri = discovery.get("jwks_uri")
if not jwks_uri:
raise RuntimeError("OIDC discovery document does not contain jwks_uri")
with httpx.Client(timeout=10.0) as client:
response = client.get(jwks_uri)
if response.status_code != 200:
raise RuntimeError(f"Unable to load JWKS from {jwks_uri}")
return response.json()
def get_signing_key(token: str) -> Dict[str, Any]:
try:
headers = jwt.get_unverified_header(token)
except JWTError as exc:
raise HTTPException(status_code=401, detail="Invalid JWT header") from exc
kid = headers.get("kid")
if not kid:
raise HTTPException(status_code=401, detail="JWT missing kid header")
jwks = get_jwks().get("keys", [])
for key in jwks:
if key.get("kid") == kid:
return key
raise HTTPException(status_code=401, detail="Unable to find matching JWKS key")
async def validate_token(credentials: HTTPAuthorizationCredentials = Security(security)) -> TokenClaims:
if not credentials or credentials.scheme.lower() != "bearer":
raise HTTPException(status_code=401, detail="Missing Bearer authorization header")
token = credentials.credentials
key = get_signing_key(token)
public_key = jwk.construct(key)
try:
verified = jwt.decode(
token,
public_key,
algorithms=[key.get("alg", "RS256")],
audience=settings.oidc_audience,
issuer=str(settings.oidc_issuer) if settings.oidc_verify_iss else None,
)
except JWTError as exc:
raise HTTPException(status_code=401, detail="Invalid or expired token") from exc
try:
return TokenClaims(**verified)
except Exception as exc:
raise HTTPException(status_code=401, detail="Unable to parse token claims") from exc
+96
View File
@@ -0,0 +1,96 @@
import os
from typing import AsyncGenerator
import httpx
from fastapi import Depends, FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
from app.auth import get_discovery, settings, validate_token
from app.schemas import AuthCallbackRequest, AuthConfigResponse, ChatRequest
OLLAMA_API_KEY = os.getenv("OLLAMA_API_KEY")
OLLAMA_API_URL = os.getenv("OLLAMA_API_URL", "https://api.ollama.com/v1/chat/completions")
if not OLLAMA_API_KEY:
raise RuntimeError("Environment variable OLLAMA_API_KEY is required")
app = FastAPI(
title="OIDC-Protected Ollama Proxy",
description="FastAPI backend validating OIDC JWTs and streaming Ollama Cloud responses over SSE.",
version="0.1.0",
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
async def event_stream(request: ChatRequest) -> AsyncGenerator[str, None]:
payload = {
"model": request.model,
"messages": [message.dict() for message in request.messages],
"stream": True,
}
headers = {
"Authorization": f"Bearer {OLLAMA_API_KEY}",
"Content-Type": "application/json",
"Accept": "text/event-stream",
}
async with httpx.AsyncClient(timeout=None) as client:
async with client.stream("POST", OLLAMA_API_URL, json=payload, headers=headers) as response:
if response.status_code >= 400:
text = await response.aread()
raise HTTPException(status_code=502, detail=f"Ollama Cloud error: {text.decode('utf-8', 'ignore')}" )
async for chunk in response.aiter_text():
if not chunk:
continue
for line in chunk.splitlines():
if line.strip():
yield f"data: {line}\n\n"
@app.get("/health")
async def health() -> dict:
return {"status": "ok"}
@app.get("/auth/config", response_model=AuthConfigResponse)
async def auth_config() -> AuthConfigResponse:
discovery = get_discovery()
return AuthConfigResponse(
issuer=discovery["issuer"],
authorization_endpoint=discovery["authorization_endpoint"],
token_endpoint=discovery["token_endpoint"],
)
@app.post("/auth/callback")
async def auth_callback(payload: AuthCallbackRequest):
discovery = get_discovery()
token_endpoint = discovery.get("token_endpoint")
if not token_endpoint:
raise HTTPException(status_code=502, detail="OIDC discovery document missing token_endpoint")
data = {
"grant_type": "authorization_code",
"client_id": settings.oidc_client_id,
"code": payload.code,
"code_verifier": payload.code_verifier,
"redirect_uri": str(payload.redirect_uri),
}
if settings.oidc_client_secret:
data["client_secret"] = settings.oidc_client_secret
async with httpx.AsyncClient(timeout=15.0) as client:
response = await client.post(token_endpoint, data=data, headers={"Accept": "application/json"})
if response.status_code != 200:
raise HTTPException(status_code=400, detail=f"OIDC token exchange failed: {response.text}")
return response.json()
@app.post("/chat", response_class=StreamingResponse)
async def chat(request: ChatRequest, token=Depends(validate_token)):
return StreamingResponse(event_stream(request), media_type="text/event-stream")
+20
View File
@@ -0,0 +1,20 @@
from pydantic import BaseModel, Field, HttpUrl
from typing import Literal, List
class ChatMessage(BaseModel):
role: Literal["user", "assistant", "system"]
content: str
class ChatRequest(BaseModel):
model: str = Field(default="llama2")
messages: List[ChatMessage] = Field(default_factory=list)
class AuthCallbackRequest(BaseModel):
code: str
code_verifier: str
redirect_uri: HttpUrl
class AuthConfigResponse(BaseModel):
issuer: HttpUrl
authorization_endpoint: HttpUrl
token_endpoint: HttpUrl
+5
View File
@@ -0,0 +1,5 @@
fastapi==0.109.0
uvicorn[standard]==0.23.2
httpx==0.28.4
python-jose[cryptography]==3.3.0
pydantic==2.8.0
+25
View File
@@ -0,0 +1,25 @@
services:
backend:
build:
context: ./backend
environment:
- OLLAMA_API_KEY=${OLLAMA_API_KEY}
- OIDC_ISSUER=${OIDC_ISSUER}
- OIDC_AUDIENCE=${OIDC_AUDIENCE}
- OIDC_CLIENT_ID=${OIDC_CLIENT_ID}
- OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET}
- OIDC_VERIFY_ISS=${OIDC_VERIFY_ISS:-true}
ports:
- '8000:8000'
frontend:
build:
context: ./frontend
environment:
- VITE_API_BASE=http://localhost:8000
- VITE_OIDC_CLIENT_ID=${OIDC_CLIENT_ID}
- VITE_OIDC_REDIRECT_URI=${VITE_OIDC_REDIRECT_URI:-http://localhost:5173}
ports:
- '5173:5173'
depends_on:
- backend
+6
View File
@@ -0,0 +1,6 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
+25
View File
@@ -0,0 +1,25 @@
{
"name": "ai-chat-frontend",
"version": "0.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ai-chat-frontend",
"version": "0.0.1",
"private": true,
"type": "module",
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"typescript": "^5.6.0",
"vite": "^5.4.0"
}
}
}
}
+22
View File
@@ -0,0 +1,22 @@
{
"name": "ai-chat-frontend",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"typescript": "^5.6.0",
"vite": "^5.4.0"
}
}
+284
View File
@@ -0,0 +1,284 @@
import { useEffect, useMemo, useRef, useState } from 'react'
type Message = { role: 'user' | 'assistant' | 'system'; content: string }
type OIDCConfig = {
issuer: string
authorization_endpoint: string
token_endpoint: string
}
const OIDC_CLIENT_ID = import.meta.env.VITE_OIDC_CLIENT_ID
const OIDC_REDIRECT_URI = import.meta.env.VITE_OIDC_REDIRECT_URI || window.location.origin
const OIDC_SCOPE = 'openid profile email'
const OIDC_RESPONSE_TYPE = 'code'
const OIDC_CODE_CHALLENGE_METHOD = 'S256'
const API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8000'
function base64UrlEncode(buffer: ArrayBuffer) {
const bytes = new Uint8Array(buffer)
let str = ''
for (const byte of bytes) {
str += String.fromCharCode(byte)
}
return btoa(str)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
}
async function sha256(text: string) {
const data = new TextEncoder().encode(text)
const hash = await window.crypto.subtle.digest('SHA-256', data)
return base64UrlEncode(hash)
}
async function createCodeVerifier() {
const array = new Uint8Array(32)
window.crypto.getRandomValues(array)
return base64UrlEncode(array)
}
function getLocalToken() {
return window.localStorage.getItem('oidc_access_token')
}
function setLocalToken(token: string) {
window.localStorage.setItem('oidc_access_token', token)
}
function clearLocalToken() {
window.localStorage.removeItem('oidc_access_token')
}
async function fetchOIDCConfig(): Promise<OIDCConfig> {
const response = await fetch(`${API_BASE}/auth/config`)
if (!response.ok) {
throw new Error('Unable to fetch OIDC configuration')
}
return response.json()
}
async function handleRedirectCallback(): Promise<string | null> {
const params = new URLSearchParams(window.location.search)
const code = params.get('code')
const state = params.get('state')
const storedState = window.sessionStorage.getItem('oidc_state')
const codeVerifier = window.sessionStorage.getItem('oidc_code_verifier')
if (!code || !state || state !== storedState || !codeVerifier) {
return null
}
window.history.replaceState({}, document.title, window.location.pathname)
const response = await fetch(`${API_BASE}/auth/callback`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, code_verifier: codeVerifier, redirect_uri: OIDC_REDIRECT_URI }),
})
const result = await response.json()
if (response.ok && result.access_token) {
setLocalToken(result.access_token)
return result.access_token
}
throw new Error(result.error_description || 'OIDC callback failed')
}
function parseSSE(text: string, onData: (chunk: string) => void) {
const events = text.split('\n\n')
for (const event of events) {
const trimmed = event.trim()
if (!trimmed) continue
const lines = trimmed.split('\n')
const dataLines = lines
.filter((line) => line.startsWith('data:'))
.map((line) => line.replace(/^data:\s?/, ''))
if (dataLines.length) {
onData(dataLines.join('\n'))
}
}
}
function App() {
const [messages, setMessages] = useState<Message[]>([])
const [input, setInput] = useState('')
const [token, setToken] = useState<string | null>(getLocalToken())
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const controllerRef = useRef<AbortController | null>(null)
const isAuthenticated = !!token
useEffect(() => {
handleRedirectCallback()
.then((newToken) => {
if (newToken) {
setToken(newToken)
}
})
.catch((err) => {
console.error(err)
setError(err instanceof Error ? err.message : 'Authentication failed')
})
return () => {
controllerRef.current?.abort()
}
}, [])
const authHeaders = useMemo(
() => ({ Authorization: token ? `Bearer ${token}` : '' }),
[token],
)
async function login() {
try {
const config = await fetchOIDCConfig()
const state = crypto.randomUUID()
const codeVerifier = await createCodeVerifier()
const codeChallenge = await sha256(codeVerifier)
window.sessionStorage.setItem('oidc_state', state)
window.sessionStorage.setItem('oidc_code_verifier', codeVerifier)
const params = new URLSearchParams({
response_type: OIDC_RESPONSE_TYPE,
client_id: OIDC_CLIENT_ID,
redirect_uri: OIDC_REDIRECT_URI,
scope: OIDC_SCOPE,
state,
code_challenge: codeChallenge,
code_challenge_method: OIDC_CODE_CHALLENGE_METHOD,
})
window.location.href = `${config.authorization_endpoint}?${params.toString()}`
} catch (err) {
setError(err instanceof Error ? err.message : 'Login failed')
}
}
function logout() {
clearLocalToken()
setToken(null)
setMessages([])
}
async function submitMessage() {
if (!input.trim() || !token) return
const userMessage: Message = { role: 'user', content: input.trim() }
const messageBatch = [...messages, userMessage]
setMessages(messageBatch)
setInput('')
setLoading(true)
setError(null)
const controller = new AbortController()
controllerRef.current = controller
try {
const response = await fetch(`${API_BASE}/chat`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...authHeaders,
},
body: JSON.stringify({ model: 'llama2', messages: messageBatch }),
signal: controller.signal,
})
if (!response.ok) {
const payload = await response.text()
throw new Error(payload || 'Chat request failed')
}
const reader = response.body?.getReader()
if (!reader) {
throw new Error('No streaming body available')
}
const decoder = new TextDecoder()
let buffer = ''
const appendAssistantText = (chunk: string) => {
setMessages((prev) => {
const last = prev[prev.length - 1]
if (last?.role === 'assistant') {
return [...prev.slice(0, -1), { ...last, content: last.content + chunk }]
}
return [...prev, { role: 'assistant', content: chunk }]
})
}
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const boundary = buffer.indexOf('\n\n')
if (boundary === -1) continue
const chunk = buffer.slice(0, boundary + 2)
buffer = buffer.slice(boundary + 2)
parseSSE(chunk, appendAssistantText)
}
} catch (err) {
if (err instanceof Error) {
setError(err.message)
} else {
setError('Streaming chat failed')
}
} finally {
setLoading(false)
controllerRef.current = null
}
}
return (
<div style={{ maxWidth: 800, margin: '0 auto', padding: 24, fontFamily: 'system-ui, sans-serif' }}>
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h1>AI Chat</h1>
<div>
{isAuthenticated ? (
<button onClick={logout}>Logout</button>
) : (
<button onClick={login}>Login with OIDC</button>
)}
</div>
</header>
<main>
<section>
<h2>Chat</h2>
{!isAuthenticated && <p>Please log in to start chatting.</p>}
{error && <div style={{ color: 'red', marginBottom: 12 }}>{error}</div>}
<div style={{ minHeight: 300, border: '1px solid #ddd', padding: 16, borderRadius: 8, marginBottom: 16 }}>
{messages.map((message, index) => (
<div key={index} style={{ marginBottom: 12 }}>
<strong>{message.role}:</strong>
<p style={{ margin: '4px 0 0', whiteSpace: 'pre-wrap' }}>{message.content}</p>
</div>
))}
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<input
value={input}
onChange={(event) => setInput(event.target.value)}
disabled={!isAuthenticated || loading}
style={{ flex: 1, minWidth: 0, padding: '8px 12px' }}
placeholder="Type your message..."
/>
<button onClick={submitMessage} disabled={!isAuthenticated || loading || !input.trim()}>
Send
</button>
</div>
{loading && <p>Waiting for response...</p>}
</section>
</main>
</div>
)
}
export default App
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AI Chat</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+9
View File
@@ -0,0 +1,9 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ES2020"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+9
View File
@@ -0,0 +1,9 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
host: '0.0.0.0',
port: 5173,
},
})