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:
parent
864278a0fa
commit
f2f729a50b
11 changed files with 163 additions and 103 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
24
frontend/lib/msalConfig.ts
Normal file
24
frontend/lib/msalConfig.ts
Normal 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'],
|
||||
};
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
36
frontend/package-lock.json
generated
36
frontend/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue