Switch Azure AD auth to MSAL SPA (browser-side token exchange)

- Replace server-side ConfidentialClientApplication + OAuth callback
  with MSAL browser popup flow (PKCE, no client_secret required)
- Backend: add POST /sso-token endpoint that validates Azure AD ID token
  via Microsoft JWKS, issues session cookie; remove /login + /callback
- Frontend: install @azure/msal-browser + @azure/msal-react, wrap app
  with MsalProvider, login page uses loginPopup() → sends id_token to backend
- Pass NEXT_PUBLIC_AZURE_* env vars through next.config.mjs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-03-23 12:34:52 +00:00
parent 864278a0fa
commit f2f729a50b
11 changed files with 163 additions and 103 deletions

View file

@ -12,8 +12,7 @@ from models.sql.user import UserModel
PUBLIC_PATHS = [
"/api/v1/auth/dev-status",
"/api/v1/auth/dev-login",
"/api/v1/auth/login",
"/api/v1/auth/callback",
"/api/v1/auth/sso-token",
"/docs",
"/openapi.json",
"/api/health",

View file

@ -1,7 +1,7 @@
import os
import uuid
from fastapi import APIRouter, Depends, HTTPException, Response, Request
from fastapi.responses import RedirectResponse, JSONResponse
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import JSONResponse
from jose import JWTError
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
@ -14,6 +14,10 @@ AUTH_ROUTER = APIRouter(prefix="/api/v1/auth", tags=["Auth"])
auth_service = AuthService()
class SSOTokenRequest(BaseModel):
id_token: str
class DevLoginRequest(BaseModel):
email: str
password: str
@ -29,59 +33,39 @@ async def dev_status():
}
@AUTH_ROUTER.get("/login")
@limiter.limit("5/minute")
async def login(request: Request):
"""Redirect to Azure AD login, or return dev mode info."""
if auth_service.is_dev_mode:
return JSONResponse(
status_code=200,
content={
"dev_mode": True,
"message": "Use POST /api/v1/auth/dev-login with email and password",
},
)
try:
url = auth_service.get_authorization_url()
return RedirectResponse(url=url)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to generate login URL: {e}")
@AUTH_ROUTER.get("/callback")
async def callback(
code: str = "",
error: str = "",
error_description: str = "",
@AUTH_ROUTER.post("/sso-token")
@limiter.limit("10/minute")
async def sso_token(
request: Request,
body: SSOTokenRequest,
session: AsyncSession = Depends(get_async_session),
):
"""Azure AD OAuth callback."""
if error:
raise HTTPException(status_code=401, detail=error_description or error)
if not code:
raise HTTPException(status_code=400, detail="Missing authorization code")
"""Exchange Azure AD MSAL id_token for a session cookie (SPA flow)."""
if auth_service.is_dev_mode:
raise HTTPException(status_code=404, detail="Azure AD not configured")
try:
result = await auth_service.exchange_code_for_token(code)
claims = result.get("id_token_claims", {})
claims = await auth_service.validate_azure_token(body.id_token)
user = await auth_service.get_or_create_user(claims, session)
token = auth_service.create_session_jwt(user)
base_path = os.environ.get("NEXT_PUBLIC_BASE_PATH", "")
is_https = os.environ.get("COOKIE_SECURE", "false").lower() == "true"
response = RedirectResponse(url=f"{base_path}/dashboard", status_code=302)
response = JSONResponse(content={
"id": str(user.id),
"email": user.email,
"display_name": user.display_name,
"role": user.role,
})
response.set_cookie(
key="session_token",
value=token,
httponly=True,
secure=is_https,
samesite="lax",
max_age=86400, # 24 hours
max_age=86400,
)
return response
except ValueError as e:
raise HTTPException(status_code=401, detail=str(e))
except JWTError as e:
raise HTTPException(status_code=401, detail=f"Invalid token: {e}")
except Exception as e:
raise HTTPException(status_code=500, detail=f"Authentication failed: {e}")

View file

@ -3,6 +3,7 @@ import uuid
from datetime import datetime, timedelta, timezone
from typing import Optional
import httpx
from jose import jwt, JWTError
from sqlmodel import select
@ -15,8 +16,6 @@ class AuthService:
def __init__(self):
self.tenant_id = os.getenv("AZURE_AD_TENANT_ID")
self.client_id = os.getenv("AZURE_AD_CLIENT_ID")
self.client_secret = os.getenv("AZURE_AD_CLIENT_SECRET")
self.redirect_uri = os.getenv("AZURE_AD_REDIRECT_URI", "http://localhost/api/v1/auth/callback")
self.jwt_secret = os.getenv("JWT_SECRET_KEY", "dev-secret-change-me")
self.jwt_algorithm = "HS256"
self.jwt_expiry_hours = 24
@ -29,7 +28,7 @@ class AuthService:
"Set AZURE_AD_TENANT_ID to enable Azure AD SSO instead."
)
self._msal_app = None
self._jwks_cache: Optional[dict] = None
@property
def is_dev_mode(self) -> bool:
@ -40,37 +39,26 @@ class AuthService:
"""Dev login is available whenever DEV_AUTH_PASSWORD is set, regardless of Azure AD config."""
return bool(self.dev_password)
@property
def msal_app(self):
if self._msal_app is None and not self.is_dev_mode:
import msal
self._msal_app = msal.ConfidentialClientApplication(
self.client_id,
authority=f"https://login.microsoftonline.com/{self.tenant_id}",
client_credential=self.client_secret,
)
return self._msal_app
def get_authorization_url(self) -> str:
async def validate_azure_token(self, id_token: str) -> dict:
"""Validate Azure AD ID token from MSAL SPA flow using Microsoft's public JWKS."""
if self.is_dev_mode:
raise ValueError("Azure AD not configured — use dev login")
result = self.msal_app.get_authorization_request_url(
scopes=["User.Read"],
redirect_uri=self.redirect_uri,
)
return result
raise ValueError("Azure AD not configured")
async def exchange_code_for_token(self, code: str) -> dict:
if self.is_dev_mode:
raise ValueError("Azure AD not configured — use dev login")
result = self.msal_app.acquire_token_by_authorization_code(
code,
scopes=["User.Read"],
redirect_uri=self.redirect_uri,
# Fetch JWKS (Microsoft public keys)
if not self._jwks_cache:
jwks_uri = f"https://login.microsoftonline.com/{self.tenant_id}/discovery/v2.0/keys"
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(jwks_uri)
resp.raise_for_status()
self._jwks_cache = resp.json()
claims = jwt.decode(
id_token,
self._jwks_cache,
algorithms=["RS256"],
audience=self.client_id,
)
if "error" in result:
raise ValueError(f"Token exchange failed: {result.get('error_description', result.get('error'))}")
return result
return claims
def create_session_jwt(self, user: UserModel) -> str:
payload = {

View file

@ -73,6 +73,6 @@ class TestDevMode:
"""Without AZURE_AD_TENANT_ID, service is in dev mode."""
assert auth_service.is_dev_mode is True
def test_get_authorization_url_raises_in_dev_mode(self, auth_service):
with pytest.raises(ValueError, match="dev login"):
auth_service.get_authorization_url()
async def test_validate_azure_token_raises_in_dev_mode(self, auth_service):
with pytest.raises(ValueError, match="not configured"):
await auth_service.validate_azure_token("dummy.token.here")

View file

@ -2,13 +2,18 @@
import { useEffect, useState } from 'react';
import { apiFetch } from '../../lib/apiFetch';
import { useSelector } from 'react-redux';
import { RootState } from '@/store/store';
import { useSelector, useDispatch } from 'react-redux';
import { RootState, AppDispatch } from '@/store/store';
import { fetchCurrentUser } from '@/store/slices/authSlice';
import { useRouter, useSearchParams } from 'next/navigation';
import { useMsal } from '@azure/msal-react';
import { loginRequest } from '@/lib/msalConfig';
export default function LoginPage() {
const router = useRouter();
const searchParams = useSearchParams();
const dispatch = useDispatch<AppDispatch>();
const { instance } = useMsal();
const { isAuthenticated, isDevMode, devLoginEnabled, azureEnabled } = useSelector(
(state: RootState) => state.auth
);
@ -24,8 +29,31 @@ export default function LoginPage() {
}
}, [isAuthenticated, router]);
const handleMicrosoftLogin = () => {
window.location.href = '/api/v1/auth/login';
const handleMicrosoftLogin = async () => {
setError('');
setLoading(true);
try {
const result = await instance.loginPopup(loginRequest);
const response = await apiFetch('/api/v1/auth/sso-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id_token: result.idToken }),
});
if (response.ok) {
await dispatch(fetchCurrentUser());
router.push('/dashboard');
} else {
const data = await response.json();
setError(data.detail || 'Authentication failed');
}
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
if (!msg.includes('user_cancelled') && !msg.includes('popup_window_error')) {
setError('Login failed. Please try again.');
}
} finally {
setLoading(false);
}
};
const handleDevLogin = async (e: React.FormEvent) => {
@ -78,7 +106,8 @@ export default function LoginPage() {
{azureEnabled && (
<button
onClick={handleMicrosoftLogin}
className="w-full flex items-center justify-center gap-3 bg-[#2F2F2F] text-white rounded-lg px-4 py-3 font-medium hover:bg-[#1a1a1a] transition-colors"
disabled={loading}
className="w-full flex items-center justify-center gap-3 bg-[#2F2F2F] text-white rounded-lg px-4 py-3 font-medium hover:bg-[#1a1a1a] transition-colors disabled:opacity-50"
>
<svg width="20" height="20" viewBox="0 0 21 21" fill="none">
<rect x="1" y="1" width="9" height="9" fill="#F25022" />
@ -86,7 +115,7 @@ export default function LoginPage() {
<rect x="1" y="11" width="9" height="9" fill="#00A4EF" />
<rect x="11" y="11" width="9" height="9" fill="#FFB900" />
</svg>
Sign in with Microsoft
{loading ? 'Signing in…' : 'Sign in with Microsoft'}
</button>
)}

View file

@ -7,22 +7,7 @@ export default function RootPage() {
const router = useRouter();
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const code = params.get("code");
const state = params.get("state");
const error = params.get("error");
const errorDescription = params.get("error_description");
if (code) {
// Azure AD OAuth callback — forward to backend for code exchange
let callbackUrl = `/api/v1/auth/callback?code=${encodeURIComponent(code)}`;
if (state) callbackUrl += `&state=${encodeURIComponent(state)}`;
window.location.href = callbackUrl;
} else if (error) {
router.replace(`/login?error=${encodeURIComponent(errorDescription || error)}`);
} else {
router.replace("/dashboard");
}
router.replace("/dashboard");
}, [router]);
return null;

View file

@ -3,13 +3,22 @@
import { Provider } from 'react-redux';
import { store } from '../store/store';
import { AuthGuard } from '@/components/AuthGuard';
import { PublicClientApplication } from '@azure/msal-browser';
import { MsalProvider } from '@azure/msal-react';
import { msalConfig } from '@/lib/msalConfig';
// Created once on the client side (module-level singleton)
const msalInstance =
typeof window !== 'undefined' ? new PublicClientApplication(msalConfig) : null;
export function Providers({ children }: { children: React.ReactNode }) {
return (
const inner = (
<Provider store={store}>
<AuthGuard>
{children}
</AuthGuard>
<AuthGuard>{children}</AuthGuard>
</Provider>
);
if (!msalInstance) return inner;
return <MsalProvider instance={msalInstance}>{inner}</MsalProvider>;
}

View file

@ -0,0 +1,24 @@
import { Configuration, PopupRequest } from '@azure/msal-browser';
export const msalConfig: Configuration = {
auth: {
clientId: process.env.NEXT_PUBLIC_AZURE_CLIENT_ID!,
authority: `https://login.microsoftonline.com/${process.env.NEXT_PUBLIC_AZURE_TENANT_ID}`,
redirectUri:
typeof window !== 'undefined'
? `${window.location.origin}/ppt-tool/login`
: 'http://localhost/ppt-tool/login',
postLogoutRedirectUri:
typeof window !== 'undefined'
? `${window.location.origin}/ppt-tool/login`
: 'http://localhost/ppt-tool/login',
},
cache: {
cacheLocation: 'sessionStorage',
storeAuthStateInCookie: false,
},
};
export const loginRequest: PopupRequest = {
scopes: ['openid', 'profile', 'email'],
};

View file

@ -2,6 +2,10 @@
const API_URL = process.env.API_INTERNAL_URL || 'http://localhost:8000';
const nextConfig = {
env: {
NEXT_PUBLIC_AZURE_TENANT_ID: process.env.AZURE_AD_TENANT_ID || '',
NEXT_PUBLIC_AZURE_CLIENT_ID: process.env.AZURE_AD_CLIENT_ID || '',
},
basePath: "/ppt-tool",
assetPrefix: "/ppt-tool",
publicRuntimeConfig: {

View file

@ -8,6 +8,8 @@
"name": "oliver-deckforge",
"version": "0.1.0",
"dependencies": {
"@azure/msal-browser": "^4.30.0",
"@azure/msal-react": "^3.0.29",
"@babel/standalone": "^7.28.2",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@ -118,6 +120,40 @@
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@azure/msal-browser": {
"version": "4.30.0",
"resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-4.30.0.tgz",
"integrity": "sha512-HBBKfbZkMVzzF5bofvS1cXuNHFVc+gt4/HOnCmG/0hsHuZRJvJvDg/+7nTwIpoqvJc8BQp5o23rBUfisOLxR+w==",
"license": "MIT",
"dependencies": {
"@azure/msal-common": "15.17.0"
},
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/@azure/msal-common": {
"version": "15.17.0",
"resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-15.17.0.tgz",
"integrity": "sha512-VQ5/gTLFADkwue+FohVuCqlzFPUq4xSrX8jeZe+iwZuY6moliNC8xt86qPVNYdtbQfELDf2Nu6LI+demFPHGgw==",
"license": "MIT",
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/@azure/msal-react": {
"version": "3.0.29",
"resolved": "https://registry.npmjs.org/@azure/msal-react/-/msal-react-3.0.29.tgz",
"integrity": "sha512-RpFfq3aIpmKajcshbaJH7Q/1CesxQRAeKorMv+uMpDw98jvi+/L0RJkNnTRmeXrV3aM34kj2LFWBQrQ9DOXs1Q==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@azure/msal-browser": "^4.30.0",
"react": "^16.8.0 || ^17 || ^18 || ^19.2.1"
}
},
"node_modules/@babel/code-frame": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",

View file

@ -12,6 +12,8 @@
"test:e2e:open": "cypress open"
},
"dependencies": {
"@azure/msal-browser": "^4.30.0",
"@azure/msal-react": "^3.0.29",
"@babel/standalone": "^7.28.2",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",