feature/entra id authentication added

This commit is contained in:
2026-01-15 21:32:35 +01:00
parent 5bbec0e240
commit 2f93fb6cb5
13 changed files with 465 additions and 11 deletions

View File

@@ -14,3 +14,15 @@ OPENAI_MODEL=gpt-4-turbo-preview
MAX_TOKENS=4000 MAX_TOKENS=4000
TEMPERATURE=0.7 TEMPERATURE=0.7
FRONTEND_URL=http://localhost:3000 FRONTEND_URL=http://localhost:3000
# Microsoft Entra ID (Azure AD)
# Create an app registration at: https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps
# Add redirect URI: http://localhost:3000/auth/callback
ENTRA_TENANT_ID=your-tenant-id
ENTRA_CLIENT_ID=your-client-id
ENTRA_CLIENT_SECRET=your-client-secret
ENTRA_REDIRECT_URI=http://localhost:3000/auth/callback
# JWT Configuration
JWT_SECRET=change-this-to-a-secure-random-string
JWT_EXPIRY_HOURS=24

162
backend/app/api/auth.py Normal file
View File

@@ -0,0 +1,162 @@
from datetime import datetime, timedelta, timezone
import jwt
import msal
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from ..config import settings
from ..models.schemas import (
AuthCallbackRequest,
AuthCallbackResponse,
AuthUrlResponse,
UserResponse,
)
router = APIRouter(prefix="/api/auth", tags=["auth"])
security = HTTPBearer(auto_error=False)
def get_msal_app():
"""Create MSAL confidential client application"""
if not all(
[
settings.ENTRA_TENANT_ID,
settings.ENTRA_CLIENT_ID,
settings.ENTRA_CLIENT_SECRET,
]
):
return None
return msal.ConfidentialClientApplication(
client_id=settings.ENTRA_CLIENT_ID,
client_credential=settings.ENTRA_CLIENT_SECRET,
authority=f"https://login.microsoftonline.com/{settings.ENTRA_TENANT_ID}",
)
def create_jwt_token(user_data: dict) -> str:
"""Create JWT token with user data"""
payload = {
"sub": user_data.get("oid") or user_data.get("sub"),
"name": user_data.get("name"),
"email": user_data.get("preferred_username"),
"exp": datetime.now(timezone.utc) + timedelta(hours=settings.JWT_EXPIRY_HOURS),
"iat": datetime.now(timezone.utc),
}
return jwt.encode(payload, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM)
def decode_jwt_token(token: str) -> dict:
"""Decode and validate JWT token"""
try:
payload = jwt.decode(
token, settings.JWT_SECRET, algorithms=[settings.JWT_ALGORITHM]
)
return payload
except jwt.ExpiredSignatureError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired"
)
except jwt.InvalidTokenError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token"
)
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
) -> dict:
"""Dependency to get current user from JWT token"""
if not credentials:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated"
)
return decode_jwt_token(credentials.credentials)
@router.get("/login", response_model=AuthUrlResponse)
async def login():
"""Get Microsoft OAuth2 authorization URL"""
msal_app = get_msal_app()
if not msal_app:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Authentication not configured. Please set ENTRA_TENANT_ID, ENTRA_CLIENT_ID, and ENTRA_CLIENT_SECRET.",
)
auth_url = msal_app.get_authorization_request_url(
scopes=["User.Read"], redirect_uri=settings.ENTRA_REDIRECT_URI
)
return AuthUrlResponse(auth_url=auth_url)
@router.post("/callback", response_model=AuthCallbackResponse)
async def callback(request: AuthCallbackRequest):
"""Exchange authorization code for tokens"""
msal_app = get_msal_app()
if not msal_app:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Authentication not configured",
)
result = msal_app.acquire_token_by_authorization_code(
code=request.code,
scopes=["User.Read"],
redirect_uri=settings.ENTRA_REDIRECT_URI,
)
if "error" in result:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Authentication failed: {result.get('error_description', result.get('error'))}",
)
# Extract user info from ID token claims
id_token_claims = result.get("id_token_claims", {})
# Create our JWT token
token = create_jwt_token(id_token_claims)
return AuthCallbackResponse(
token=token,
user=UserResponse(
id=id_token_claims.get("oid") or id_token_claims.get("sub"),
name=id_token_claims.get("name"),
email=id_token_claims.get("preferred_username"),
),
)
@router.get("/me", response_model=UserResponse)
async def me(current_user: dict = Depends(get_current_user)):
"""Get current user info"""
return UserResponse(
id=current_user.get("sub"),
name=current_user.get("name"),
email=current_user.get("email"),
)
@router.post("/logout")
async def logout():
"""Logout (client should clear token)"""
return {"message": "Logged out successfully"}
@router.get("/status")
async def auth_status():
"""Check if authentication is configured"""
configured = all(
[
settings.ENTRA_TENANT_ID,
settings.ENTRA_CLIENT_ID,
settings.ENTRA_CLIENT_SECRET,
]
)
return {"configured": configured}

View File

@@ -1,9 +1,10 @@
import json import json
from fastapi import APIRouter, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from ..config import settings from ..config import settings
from ..middleware.auth import require_auth
from ..models.schemas import ChatRequest, ChatResponse, ProviderListResponse from ..models.schemas import ChatRequest, ChatResponse, ProviderListResponse
from ..services.provider_manager import provider_manager from ..services.provider_manager import provider_manager
@@ -11,7 +12,7 @@ router = APIRouter(prefix="/api/chat", tags=["chat"])
@router.post("/", response_model=ChatResponse) @router.post("/", response_model=ChatResponse)
async def chat(request: ChatRequest): async def chat(request: ChatRequest, user: dict = Depends(require_auth)):
""" """
Non-streaming chat endpoint Non-streaming chat endpoint
""" """
@@ -30,7 +31,7 @@ async def chat(request: ChatRequest):
@router.post("/stream") @router.post("/stream")
async def chat_stream(request: ChatRequest): async def chat_stream(request: ChatRequest, user: dict = Depends(require_auth)):
""" """
Streaming chat endpoint - returns SSE (Server-Sent Events) Streaming chat endpoint - returns SSE (Server-Sent Events)
""" """

View File

@@ -25,6 +25,17 @@ class Settings(BaseSettings):
RATE_LIMIT_REQUESTS: int = 10 RATE_LIMIT_REQUESTS: int = 10
RATE_LIMIT_WINDOW: int = 60 RATE_LIMIT_WINDOW: int = 60
# Microsoft Entra ID
ENTRA_TENANT_ID: Optional[str] = None
ENTRA_CLIENT_ID: Optional[str] = None
ENTRA_CLIENT_SECRET: Optional[str] = None
ENTRA_REDIRECT_URI: str = "http://localhost:3000/auth/callback"
# JWT
JWT_SECRET: str = "change-this-in-production-use-a-secure-random-string"
JWT_ALGORITHM: str = "HS256"
JWT_EXPIRY_HOURS: int = 24
class Config: class Config:
env_file = ".env" env_file = ".env"
case_sensitive = True case_sensitive = True

View File

@@ -4,7 +4,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from .api import chat from .api import auth, chat
from .config import settings from .config import settings
from .services.provider_manager import provider_manager from .services.provider_manager import provider_manager
@@ -26,6 +26,7 @@ app.add_middleware(
) )
# Include routers # Include routers
app.include_router(auth.router)
app.include_router(chat.router) app.include_router(chat.router)

View File

View File

@@ -0,0 +1,45 @@
import jwt
from fastapi import HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from ..config import settings
security = HTTPBearer(auto_error=False)
def decode_jwt_token(token: str) -> dict:
"""Decode and validate JWT token"""
try:
payload = jwt.decode(
token, settings.JWT_SECRET, algorithms=[settings.JWT_ALGORITHM]
)
return payload
except jwt.ExpiredSignatureError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired"
)
except jwt.InvalidTokenError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token"
)
async def require_auth(request: Request):
"""Dependency to require authentication"""
auth_header = request.headers.get("Authorization")
if not auth_header:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated"
)
if not auth_header.startswith("Bearer "):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authorization header",
)
token = auth_header[7:] # Remove "Bearer " prefix
user = decode_jwt_token(token)
request.state.user = user
return user

View File

@@ -22,3 +22,23 @@ class ChatResponse(BaseModel):
class ProviderListResponse(BaseModel): class ProviderListResponse(BaseModel):
providers: List[str] providers: List[str]
default: str default: str
# Auth schemas
class AuthUrlResponse(BaseModel):
auth_url: str
class AuthCallbackRequest(BaseModel):
code: str
class UserResponse(BaseModel):
id: str
name: Optional[str] = None
email: Optional[str] = None
class AuthCallbackResponse(BaseModel):
token: str
user: UserResponse

View File

@@ -6,3 +6,5 @@ pydantic>=2.6.0
pydantic-settings>=2.1.0 pydantic-settings>=2.1.0
python-dotenv>=1.0.0 python-dotenv>=1.0.0
httpx>=0.27.0 httpx>=0.27.0
msal>=1.24.0
PyJWT>=2.8.0

View File

@@ -26,6 +26,12 @@ services:
- CLAUDE_MODEL=${CLAUDE_MODEL:-claude-3-5-sonnet-20241022} - CLAUDE_MODEL=${CLAUDE_MODEL:-claude-3-5-sonnet-20241022}
- OPENAI_MODEL=${OPENAI_MODEL:-gpt-4-turbo-preview} - OPENAI_MODEL=${OPENAI_MODEL:-gpt-4-turbo-preview}
- FRONTEND_URL=http://localhost:3000 - FRONTEND_URL=http://localhost:3000
- ENTRA_TENANT_ID=${ENTRA_TENANT_ID}
- ENTRA_CLIENT_ID=${ENTRA_CLIENT_ID}
- ENTRA_CLIENT_SECRET=${ENTRA_CLIENT_SECRET}
- ENTRA_REDIRECT_URI=${ENTRA_REDIRECT_URI:-http://localhost:3000/auth/callback}
- JWT_SECRET=${JWT_SECRET:-change-this-in-production}
- JWT_EXPIRY_HOURS=${JWT_EXPIRY_HOURS:-24}
env_file: env_file:
- .env - .env
restart: unless-stopped restart: unless-stopped

View File

@@ -7,8 +7,20 @@
<link rel="stylesheet" href="style.css" /> <link rel="stylesheet" href="style.css" />
</head> </head>
<body> <body>
<!-- Welcome Screen (centered, shown initially) --> <!-- Login Screen (shown when not authenticated) -->
<div class="welcome-screen" id="welcomeScreen"> <div class="login-screen" id="loginScreen">
<div class="logo">
<span class="logo-dev">Dev</span
><span class="logo-den">Den</span>
</div>
<button class="login-btn" id="loginBtn">
Sign in with Microsoft
</button>
<div class="hint">Authenticate to continue</div>
</div>
<!-- Welcome Screen (shown after auth, centered) -->
<div class="welcome-screen hidden" id="welcomeScreen">
<div class="logo"> <div class="logo">
<span class="logo-dev">Dev</span <span class="logo-dev">Dev</span
><span class="logo-den">Den</span> ><span class="logo-den">Den</span>
@@ -20,7 +32,6 @@
id="welcomeInput" id="welcomeInput"
placeholder="Ask anything..." placeholder="Ask anything..."
autocomplete="off" autocomplete="off"
autofocus
/> />
</div> </div>
<div class="hint">Press <kbd>Enter</kbd> to send</div> <div class="hint">Press <kbd>Enter</kbd> to send</div>

153
script.js
View File

@@ -1,20 +1,141 @@
const loginScreen = document.getElementById("loginScreen");
const welcomeScreen = document.getElementById("welcomeScreen"); const welcomeScreen = document.getElementById("welcomeScreen");
const chatScreen = document.getElementById("chatScreen"); const chatScreen = document.getElementById("chatScreen");
const chatMessages = document.getElementById("chatMessages"); const chatMessages = document.getElementById("chatMessages");
const welcomeInput = document.getElementById("welcomeInput"); const welcomeInput = document.getElementById("welcomeInput");
const chatInput = document.getElementById("chatInput"); const chatInput = document.getElementById("chatInput");
const loginBtn = document.getElementById("loginBtn");
const API_URL = "http://localhost:8000/api/chat"; const API_URL = "http://localhost:8000";
let isInChat = false; let isInChat = false;
// Auth functions
function getToken() {
return localStorage.getItem("devden_token");
}
function setToken(token) {
localStorage.setItem("devden_token", token);
}
function clearToken() {
localStorage.removeItem("devden_token");
}
function showLoginScreen() {
loginScreen.classList.remove("hidden");
welcomeScreen.classList.add("hidden");
chatScreen.classList.add("hidden");
}
function showWelcomeScreen() {
loginScreen.classList.add("hidden");
welcomeScreen.classList.remove("hidden");
chatScreen.classList.add("hidden");
welcomeInput.focus();
}
function switchToChat() { function switchToChat() {
loginScreen.classList.add("hidden");
welcomeScreen.classList.add("hidden"); welcomeScreen.classList.add("hidden");
chatScreen.classList.remove("hidden"); chatScreen.classList.remove("hidden");
chatInput.focus(); chatInput.focus();
isInChat = true; isInChat = true;
} }
async function checkAuth() {
const token = getToken();
if (!token) {
showLoginScreen();
return;
}
try {
const response = await fetch(`${API_URL}/api/auth/me`, {
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
showWelcomeScreen();
} else {
clearToken();
showLoginScreen();
}
} catch (error) {
console.error("Auth check failed:", error);
showLoginScreen();
}
}
async function handleLogin() {
loginBtn.disabled = true;
loginBtn.textContent = "Redirecting...";
try {
// Check if auth is configured
const statusResponse = await fetch(`${API_URL}/api/auth/status`);
const statusData = await statusResponse.json();
if (!statusData.configured) {
alert(
"Authentication not configured. Please set ENTRA_TENANT_ID, ENTRA_CLIENT_ID, and ENTRA_CLIENT_SECRET in your .env file.",
);
loginBtn.disabled = false;
loginBtn.textContent = "Sign in with Microsoft";
return;
}
// Get auth URL and redirect
const response = await fetch(`${API_URL}/api/auth/login`);
const data = await response.json();
if (data.auth_url) {
window.location.href = data.auth_url;
} else {
throw new Error("No auth URL returned");
}
} catch (error) {
console.error("Login failed:", error);
alert("Login failed: " + error.message);
loginBtn.disabled = false;
loginBtn.textContent = "Sign in with Microsoft";
}
}
async function handleCallback() {
const params = new URLSearchParams(window.location.search);
const code = params.get("code");
if (!code) return false;
try {
const response = await fetch(`${API_URL}/api/auth/callback`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || "Callback failed");
}
const data = await response.json();
setToken(data.token);
// Clean up URL
window.history.replaceState({}, "", "/");
return true;
} catch (error) {
console.error("Callback failed:", error);
alert("Authentication failed: " + error.message);
return false;
}
}
// Chat functions
function addMessage(text, type = "user") { function addMessage(text, type = "user") {
const msg = document.createElement("div"); const msg = document.createElement("div");
msg.className = `message ${type}`; msg.className = `message ${type}`;
@@ -69,10 +190,12 @@ async function sendMessage(text) {
showTyping(); showTyping();
try { try {
const response = await fetch(`${API_URL}/stream`, { const token = getToken();
const response = await fetch(`${API_URL}/api/chat/stream`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
Authorization: `Bearer ${token}`,
}, },
body: JSON.stringify({ body: JSON.stringify({
message: text, message: text,
@@ -80,6 +203,13 @@ async function sendMessage(text) {
}), }),
}); });
if (response.status === 401) {
hideTyping();
clearToken();
showLoginScreen();
return;
}
if (!response.ok) { if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`); throw new Error(`HTTP error! status: ${response.status}`);
} }
@@ -136,7 +266,9 @@ async function sendMessage(text) {
} }
} }
// Welcome screen input handler // Event listeners
loginBtn.addEventListener("click", handleLogin);
welcomeInput.addEventListener("keydown", (e) => { welcomeInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") { if (e.key === "Enter") {
e.preventDefault(); e.preventDefault();
@@ -147,10 +279,23 @@ welcomeInput.addEventListener("keydown", (e) => {
} }
}); });
// Chat input handler
chatInput.addEventListener("keydown", (e) => { chatInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") { if (e.key === "Enter") {
e.preventDefault(); e.preventDefault();
sendMessage(chatInput.value); sendMessage(chatInput.value);
} }
}); });
// Initialize
async function init() {
// Check for OAuth callback first
const callbackSuccess = await handleCallback();
if (callbackSuccess) {
showWelcomeScreen();
} else {
await checkAuth();
}
}
init();

View File

@@ -49,6 +49,44 @@ body {
display: none !important; display: none !important;
} }
/* ==================== LOGIN SCREEN ==================== */
.login-screen {
height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 32px;
}
.login-btn {
background: var(--ctp-surface0);
border: 2px solid var(--ctp-surface1);
color: var(--ctp-text);
padding: 14px 24px;
font-family: inherit;
font-size: 14px;
cursor: pointer;
transition:
border-color 0.15s ease,
background 0.15s ease;
user-select: none;
}
.login-btn:hover {
border-color: var(--ctp-mauve);
background: var(--ctp-surface1);
}
.login-btn:active {
background: var(--ctp-surface2);
}
.login-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ==================== WELCOME SCREEN ==================== */ /* ==================== WELCOME SCREEN ==================== */
.welcome-screen { .welcome-screen {
height: 100vh; height: 100vh;