From f2f729a50b8b40cd9a18cd1f87fcad0220d64aa7 Mon Sep 17 00:00:00 2001 From: Vadym Samoilenko Date: Mon, 23 Mar 2026 12:34:52 +0000 Subject: [PATCH] Switch Azure AD auth to MSAL SPA (browser-side token exchange) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/api/middlewares/auth_middleware.py | 3 +- backend/api/v1/auth/router.py | 66 ++++++++-------------- backend/services/auth_service.py | 50 +++++++--------- backend/tests/test_auth_service.py | 6 +- frontend/app/login/page.tsx | 41 ++++++++++++-- frontend/app/page.tsx | 17 +----- frontend/app/providers.tsx | 17 ++++-- frontend/lib/msalConfig.ts | 24 ++++++++ frontend/next.config.mjs | 4 ++ frontend/package-lock.json | 36 ++++++++++++ frontend/package.json | 2 + 11 files changed, 163 insertions(+), 103 deletions(-) create mode 100644 frontend/lib/msalConfig.ts diff --git a/backend/api/middlewares/auth_middleware.py b/backend/api/middlewares/auth_middleware.py index 7496c59..b4ccf82 100644 --- a/backend/api/middlewares/auth_middleware.py +++ b/backend/api/middlewares/auth_middleware.py @@ -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", diff --git a/backend/api/v1/auth/router.py b/backend/api/v1/auth/router.py index e1784ae..4c73672 100644 --- a/backend/api/v1/auth/router.py +++ b/backend/api/v1/auth/router.py @@ -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}") diff --git a/backend/services/auth_service.py b/backend/services/auth_service.py index d3b7e5d..08d9f9e 100644 --- a/backend/services/auth_service.py +++ b/backend/services/auth_service.py @@ -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 = { diff --git a/backend/tests/test_auth_service.py b/backend/tests/test_auth_service.py index 50cea14..efc9853 100644 --- a/backend/tests/test_auth_service.py +++ b/backend/tests/test_auth_service.py @@ -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") diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx index d16834d..1f3acee 100644 --- a/frontend/app/login/page.tsx +++ b/frontend/app/login/page.tsx @@ -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(); + 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 && ( )} diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index a7fdb58..9b8a312 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -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; diff --git a/frontend/app/providers.tsx b/frontend/app/providers.tsx index 0ca4181..de86eea 100644 --- a/frontend/app/providers.tsx +++ b/frontend/app/providers.tsx @@ -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 = ( - - {children} - + {children} ); + + if (!msalInstance) return inner; + + return {inner}; } diff --git a/frontend/lib/msalConfig.ts b/frontend/lib/msalConfig.ts new file mode 100644 index 0000000..fe05c32 --- /dev/null +++ b/frontend/lib/msalConfig.ts @@ -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'], +}; diff --git a/frontend/next.config.mjs b/frontend/next.config.mjs index a8eab82..4427446 100644 --- a/frontend/next.config.mjs +++ b/frontend/next.config.mjs @@ -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: { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4dd5dd3..a77707a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 8f6d61a..225fb72 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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",