diff --git a/.env.example b/.env.example index c8b0eeb..fd03219 100644 --- a/.env.example +++ b/.env.example @@ -17,3 +17,8 @@ STORAGE_ROOT=/storage # LLM LLM_MODEL=claude-sonnet-4-6 + +# Azure AD SSO (optional — set AZURE_AD_SSO_ENABLED=true to enable) +AZURE_AD_TENANT_ID= +AZURE_AD_CLIENT_ID= +AZURE_AD_SSO_ENABLED=false diff --git a/.env.production.example b/.env.production.example index c16fbba..baaf9e3 100644 --- a/.env.production.example +++ b/.env.production.example @@ -25,6 +25,11 @@ STORAGE_ROOT=/storage # LLM Model LLM_MODEL=claude-sonnet-4-6 +# Azure AD SSO +AZURE_AD_TENANT_ID=e519c2e6-bc6d-4fdf-8d9c-923c2f002385 +AZURE_AD_CLIENT_ID=9079054c-9620-4757-a256-23413042f1ef +AZURE_AD_SSO_ENABLED=true + # Frontend (set at Docker build time via docker-compose.prod.yml) NEXT_PUBLIC_API_URL=/amazon-transcreation NEXT_PUBLIC_WS_URL= diff --git a/backend/alembic/versions/c1d2e3f4a5b6_add_sso_fields.py b/backend/alembic/versions/c1d2e3f4a5b6_add_sso_fields.py new file mode 100644 index 0000000..c14d27c --- /dev/null +++ b/backend/alembic/versions/c1d2e3f4a5b6_add_sso_fields.py @@ -0,0 +1,51 @@ +"""add sso fields: nullable password_hash and auth_provider + +Revision ID: c1d2e3f4a5b6 +Revises: b2c3d4e5f6a7 +Create Date: 2026-04-15 00:00:00.000000 + +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "c1d2e3f4a5b6" +down_revision: Union[str, None] = "b2c3d4e5f6a7" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Make password_hash nullable to support SSO users without passwords + op.alter_column( + "users", + "password_hash", + existing_type=sa.String(255), + nullable=True, + ) + # Add auth_provider to distinguish local vs SSO users + op.add_column( + "users", + sa.Column( + "auth_provider", + sa.String(50), + nullable=False, + server_default="local", + ), + ) + + +def downgrade() -> None: + op.drop_column("users", "auth_provider") + # Restore NOT NULL — set any NULL hashes to a placeholder first + op.execute( + "UPDATE users SET password_hash = 'SSO_NO_PASSWORD' WHERE password_hash IS NULL" + ) + op.alter_column( + "users", + "password_hash", + existing_type=sa.String(255), + nullable=False, + ) diff --git a/backend/app/auth/providers/azure_ad.py b/backend/app/auth/providers/azure_ad.py new file mode 100644 index 0000000..37a8b1d --- /dev/null +++ b/backend/app/auth/providers/azure_ad.py @@ -0,0 +1,121 @@ +"""Azure AD token validation via JWKS. + +Validates ID tokens issued by Azure AD for the configured tenant and client, +using the public JWKS endpoint (no client secret required). +""" + +import time +from typing import Any + +import httpx +from fastapi import HTTPException, status +from jose import JWTError, jwt +from jose.exceptions import ExpiredSignatureError + +from app.config import settings + +# ── JWKS Cache ────────────────────────────────────────────────────────────── + +_jwks_cache: dict[str, Any] = {} +_jwks_fetched_at: float = 0.0 +_JWKS_TTL_SECONDS = 86_400 # 24 hours + + +def _jwks_url() -> str: + return ( + f"https://login.microsoftonline.com/" + f"{settings.AZURE_AD_TENANT_ID}/discovery/v2.0/keys" + ) + + +async def _fetch_jwks(force: bool = False) -> dict[str, Any]: + """Fetch and cache Azure AD JWKS. Re-fetches if TTL expired or force=True.""" + global _jwks_cache, _jwks_fetched_at + + if not force and _jwks_cache and (time.time() - _jwks_fetched_at) < _JWKS_TTL_SECONDS: + return _jwks_cache + + try: + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.get(_jwks_url()) + response.raise_for_status() + _jwks_cache = response.json() + _jwks_fetched_at = time.time() + return _jwks_cache + except httpx.HTTPError as exc: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=f"Failed to fetch Azure AD signing keys: {exc}", + ) + + +def _find_key(jwks: dict[str, Any], kid: str) -> dict[str, Any] | None: + """Find the signing key matching the given key ID.""" + for key in jwks.get("keys", []): + if key.get("kid") == kid: + return key + return None + + +# ── Token Validation ───────────────────────────────────────────────────────── + + +async def validate_azure_token(token: str) -> dict[str, Any]: + """Validate an Azure AD ID token and return its claims. + + Raises HTTPException(401) on any validation failure. + """ + # Decode the header to get kid without full verification + try: + unverified_header = jwt.get_unverified_header(token) + except JWTError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid Azure AD token format", + ) + + kid = unverified_header.get("kid") + if not kid: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Azure AD token missing key ID", + ) + + # Fetch JWKS (uses cache) + jwks = await _fetch_jwks() + key = _find_key(jwks, kid) + + # If kid not found, refetch once (handles key rotation) + if key is None: + jwks = await _fetch_jwks(force=True) + key = _find_key(jwks, kid) + + if key is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Azure AD signing key not found", + ) + + issuer = f"https://login.microsoftonline.com/{settings.AZURE_AD_TENANT_ID}/v2.0" + + try: + claims = jwt.decode( + token, + key, + algorithms=["RS256"], + audience=settings.AZURE_AD_CLIENT_ID, + options={"verify_iss": True}, + issuer=issuer, + ) + except ExpiredSignatureError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Azure AD token has expired", + ) + except JWTError as exc: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=f"Azure AD token validation failed: {exc}", + ) + + return claims diff --git a/backend/app/auth/router.py b/backend/app/auth/router.py index 0b2c750..83a2292 100644 --- a/backend/app/auth/router.py +++ b/backend/app/auth/router.py @@ -1,8 +1,15 @@ from fastapi import APIRouter, Depends, HTTPException, Request, status from sqlalchemy.ext.asyncio import AsyncSession -from app.auth.schemas import LoginRequest, RefreshRequest, TokenResponse, UserClaims +from app.auth.schemas import ( + LoginRequest, + RefreshRequest, + SSOLoginRequest, + TokenResponse, + UserClaims, +) from app.auth.service import AuthService +from app.config import settings from app.dependencies import get_current_user, get_db from app.services.audit_service import AuditService @@ -43,6 +50,44 @@ async def login( return TokenResponse(**result) +@router.post("/sso/login", response_model=TokenResponse) +async def sso_login( + body: SSOLoginRequest, + request: Request, + db: AsyncSession = Depends(get_db), +) -> TokenResponse: + """Authenticate via Azure AD ID token (SPA flow) and return app tokens.""" + if not settings.AZURE_AD_SSO_ENABLED: + raise HTTPException( + status_code=status.HTTP_501_NOT_IMPLEMENTED, + detail="SSO is not enabled on this server", + ) + + result = await auth_service.sso_login(body.token, db) + if result is None: + await audit_service.log( + db, action="sso_login_failed", entity_type="user", entity_id="unknown", + details={"reason": "SSO authentication failed"}, + ip_address=request.client.host if request.client else None, + ) + await db.commit() + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="SSO authentication failed", + ) + + claims = auth_service.validate_token(result["access_token"]) + user_id = claims["sub"] if claims else "unknown" + await audit_service.log( + db, action="sso_login", entity_type="user", entity_id=str(user_id), + user_id=user_id if claims else None, + details={"provider": "azure_ad"}, + ip_address=request.client.host if request.client else None, + ) + await db.commit() + return TokenResponse(**result) + + @router.post("/refresh", response_model=TokenResponse) async def refresh_token(body: RefreshRequest) -> TokenResponse: """Exchange a valid refresh token for a new token pair.""" diff --git a/backend/app/auth/schemas.py b/backend/app/auth/schemas.py index 7a9ab28..1a2ec50 100644 --- a/backend/app/auth/schemas.py +++ b/backend/app/auth/schemas.py @@ -14,6 +14,10 @@ class TokenResponse(BaseModel): token_type: str = "bearer" +class SSOLoginRequest(BaseModel): + token: str # Azure AD ID token from MSAL.js + + class RefreshRequest(BaseModel): refresh_token: str diff --git a/backend/app/auth/service.py b/backend/app/auth/service.py index ce446c3..cbd7c9a 100644 --- a/backend/app/auth/service.py +++ b/backend/app/auth/service.py @@ -1,10 +1,12 @@ from typing import Any from sqlalchemy import select +from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession +from app.auth.providers.azure_ad import validate_azure_token from app.auth.providers.jwt_provider import JWTAuthProvider -from app.models.user import User +from app.models.user import User, UserRole, UserStatus class AuthService: @@ -22,6 +24,8 @@ class AuthService: if user is None: return None + if user.password_hash is None: + return None # SSO-only user, cannot authenticate with password if not self.provider.verify_password(password, user.password_hash): return None if user.status.value != "active": @@ -40,6 +44,61 @@ class AuthService: "token_type": "bearer", } + async def sso_login( + self, azure_token: str, db: AsyncSession + ) -> dict[str, str] | None: + """Validate an Azure AD token, auto-provision the user, and return app tokens.""" + claims = await validate_azure_token(azure_token) + + # Extract email — Azure AD may use either 'email' or 'preferred_username' + email: str = claims.get("email") or claims.get("preferred_username") or "" + email = email.strip().lower() + if not email: + return None + + name: str = claims.get("name") or email.split("@")[0] + + # Look up existing user + result = await db.execute(select(User).where(User.email == email)) + user = result.scalar_one_or_none() + + if user is None: + # Auto-provision SSO user with reviewer role + user = User( + email=email, + name=name, + password_hash=None, + role=UserRole.reviewer, + status=UserStatus.active, + auth_provider="azure_ad", + ) + db.add(user) + try: + await db.flush() + except IntegrityError: + # Race condition: another request created the user first — re-query + await db.rollback() + result = await db.execute(select(User).where(User.email == email)) + user = result.scalar_one_or_none() + if user is None: + return None + else: + if user.status.value != "active": + return None + + token_data = { + "sub": str(user.id), + "email": user.email, + "role": user.role.value, + "name": user.name, + } + + return { + "access_token": self.provider.create_access_token(token_data), + "refresh_token": self.provider.create_refresh_token(token_data), + "token_type": "bearer", + } + def refresh_tokens(self, refresh_token: str) -> dict[str, str] | None: """Validate a refresh token and issue new token pair.""" claims = self.provider.validate_token(refresh_token) diff --git a/backend/app/config.py b/backend/app/config.py index 729328a..b0cc6e7 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -12,6 +12,9 @@ class Settings(BaseSettings): JWT_EXPIRY_HOURS: int = 8 STORAGE_ROOT: str = "/storage" LLM_MODEL: str = "claude-sonnet-4-6" + AZURE_AD_TENANT_ID: str = "" + AZURE_AD_CLIENT_ID: str = "" + AZURE_AD_SSO_ENABLED: bool = False model_config = { "env_file": ".env", diff --git a/backend/app/models/user.py b/backend/app/models/user.py index f9ba47d..1555c8d 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -26,7 +26,8 @@ class User(Base, TimestampMixin): ) email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False) name: Mapped[str] = mapped_column(String(255), nullable=False) - password_hash: Mapped[str] = mapped_column(String(255), nullable=False) + password_hash: Mapped[str | None] = mapped_column(String(255), nullable=True) + auth_provider: Mapped[str] = mapped_column(String(50), nullable=False, server_default="local") role: Mapped[UserRole] = mapped_column( Enum(UserRole, name="user_role", create_constraint=True), nullable=False, diff --git a/deploy.sh b/deploy.sh index 34686af..5c63f93 100755 --- a/deploy.sh +++ b/deploy.sh @@ -77,6 +77,11 @@ if [ "$INIT" = true ]; then warn ".env created from template. You MUST edit it to set:" warn " - ANTHROPIC_API_KEY (your Claude API key)" warn "" + warn "Pre-filled values (verify these are correct):" + warn " - AZURE_AD_TENANT_ID" + warn " - AZURE_AD_CLIENT_ID" + warn " - AZURE_AD_SSO_ENABLED" + warn "" warn "Auto-generated values:" warn " - JWT_SECRET_KEY" warn " - DB_PASSWORD" @@ -147,6 +152,24 @@ if [ "$INIT" = true ]; then log "Starting all services..." $COMPOSE up -d --remove-orphans + # --------------------------------------------------------------- + # SSO configuration check + # --------------------------------------------------------------- + SSO_ENABLED_VAL=$(grep -E "^AZURE_AD_SSO_ENABLED=" .env | cut -d= -f2 | tr -d '[:space:]' || true) + TENANT_ID_VAL=$(grep -E "^AZURE_AD_TENANT_ID=" .env | cut -d= -f2 | tr -d '[:space:]' || true) + CLIENT_ID_VAL=$(grep -E "^AZURE_AD_CLIENT_ID=" .env | cut -d= -f2 | tr -d '[:space:]' || true) + + if [ "$SSO_ENABLED_VAL" = "true" ]; then + if [ -z "$TENANT_ID_VAL" ] || [ -z "$CLIENT_ID_VAL" ]; then + warn "AZURE_AD_SSO_ENABLED=true but AZURE_AD_TENANT_ID or AZURE_AD_CLIENT_ID is empty." + warn "SSO will not work until these are set in .env." + else + log "SSO configured: tenant=${TENANT_ID_VAL:0:8}... client=${CLIENT_ID_VAL:0:8}..." + fi + else + log "SSO disabled (AZURE_AD_SSO_ENABLED != true). Set it in .env to enable." + fi + # --------------------------------------------------------------- # Apache configuration # --------------------------------------------------------------- @@ -212,6 +235,9 @@ if [ "$INIT" = true ]; then log " 1. Verify ANTHROPIC_API_KEY is set in .env" log " 2. Ensure Apache config is loaded (see above)" log " 3. Change default passwords via admin panel" + log " 4. SSO: verify AZURE_AD_SSO_ENABLED=true and Azure AD vars in .env" + log " Redirect URI registered in Azure AD:" + log " https://optical-dev.oliver.solutions/amazon-transcreation/login" exit 0 fi @@ -224,6 +250,19 @@ fi log "Pulling latest code..." git pull --ff-only || error "Git pull failed. Resolve conflicts manually." +# Check SSO vars are in .env (they bake into the frontend image at build time) +SSO_ENABLED_VAL=$(grep -E "^AZURE_AD_SSO_ENABLED=" .env | cut -d= -f2 | tr -d '[:space:]' || true) +if [ "$SSO_ENABLED_VAL" = "true" ]; then + TENANT_ID_VAL=$(grep -E "^AZURE_AD_TENANT_ID=" .env | cut -d= -f2 | tr -d '[:space:]' || true) + CLIENT_ID_VAL=$(grep -E "^AZURE_AD_CLIENT_ID=" .env | cut -d= -f2 | tr -d '[:space:]' || true) + if [ -z "$TENANT_ID_VAL" ] || [ -z "$CLIENT_ID_VAL" ]; then + warn "AZURE_AD_SSO_ENABLED=true but AZURE_AD_TENANT_ID or AZURE_AD_CLIENT_ID is empty in .env." + warn "The SSO button will NOT appear in the frontend build." + else + log "SSO enabled — Azure AD vars will be baked into the frontend image." + fi +fi + # Build images # Frontend always gets --no-cache to avoid stale Next.js builds from Docker layer cache if [ "$REBUILD" = true ]; then diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 33ca149..a8a91ec 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -71,6 +71,9 @@ services: - NEXT_PUBLIC_API_URL=/amazon-transcreation - NEXT_PUBLIC_WS_URL= - NEXT_PUBLIC_BASE_PATH=/amazon-transcreation + - NEXT_PUBLIC_AZURE_AD_TENANT_ID=${AZURE_AD_TENANT_ID} + - NEXT_PUBLIC_AZURE_AD_CLIENT_ID=${AZURE_AD_CLIENT_ID} + - NEXT_PUBLIC_AZURE_AD_SSO_ENABLED=${AZURE_AD_SSO_ENABLED:-false} restart: unless-stopped ports: - "127.0.0.1:3050:3000" diff --git a/frontend/.env.local.example b/frontend/.env.local.example index 894a886..9c385c0 100644 --- a/frontend/.env.local.example +++ b/frontend/.env.local.example @@ -5,3 +5,8 @@ NEXT_PUBLIC_WS_URL=ws://localhost:8040 # For production (behind nginx, same origin — leave empty or omit) # NEXT_PUBLIC_API_URL= # NEXT_PUBLIC_WS_URL= + +# Azure AD SSO +NEXT_PUBLIC_AZURE_AD_TENANT_ID=e519c2e6-bc6d-4fdf-8d9c-923c2f002385 +NEXT_PUBLIC_AZURE_AD_CLIENT_ID=9079054c-9620-4757-a256-23413042f1ef +NEXT_PUBLIC_AZURE_AD_SSO_ENABLED=true diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 033115a..93eff71 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -5,9 +5,15 @@ WORKDIR /app ARG NEXT_PUBLIC_API_URL= ARG NEXT_PUBLIC_WS_URL= ARG NEXT_PUBLIC_BASE_PATH= +ARG NEXT_PUBLIC_AZURE_AD_TENANT_ID= +ARG NEXT_PUBLIC_AZURE_AD_CLIENT_ID= +ARG NEXT_PUBLIC_AZURE_AD_SSO_ENABLED=false ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL ENV NEXT_PUBLIC_WS_URL=$NEXT_PUBLIC_WS_URL ENV NEXT_PUBLIC_BASE_PATH=$NEXT_PUBLIC_BASE_PATH +ENV NEXT_PUBLIC_AZURE_AD_TENANT_ID=$NEXT_PUBLIC_AZURE_AD_TENANT_ID +ENV NEXT_PUBLIC_AZURE_AD_CLIENT_ID=$NEXT_PUBLIC_AZURE_AD_CLIENT_ID +ENV NEXT_PUBLIC_AZURE_AD_SSO_ENABLED=$NEXT_PUBLIC_AZURE_AD_SSO_ENABLED COPY package.json package-lock.json* ./ RUN npm ci diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9efca44..98a3aad 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "amazon-transcreation-frontend", "version": "0.1.0", "dependencies": { + "@azure/msal-browser": "^5.6.3", "@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-collapsible": "^1.0.3", "@radix-ui/react-dialog": "^1.0.5", @@ -55,6 +56,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@azure/msal-browser": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.6.3.tgz", + "integrity": "sha512-sTjMtUm+bJpENU/1WlRzHEsgEHppZDZ1EtNyaOODg/sQBtMxxJzGB+MOCM+T2Q5Qe1fKBrdxUmjyRxm0r7Ez9w==", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "16.4.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-common": { + "version": "16.4.1", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.4.1.tgz", + "integrity": "sha512-Bl8f+w37xkXsYh7QRkAKCFGYtWMYuOVO7Lv+BxILrvGz3HbIEF22Pt0ugyj0QPOl6NLrHcnNUQ9yeew98P/5iw==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/@babel/runtime": { "version": "7.29.2", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 3b8f325..117cd84 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,37 +9,38 @@ "lint": "next lint" }, "dependencies": { + "@azure/msal-browser": "^5.6.3", + "@radix-ui/react-checkbox": "^1.0.4", + "@radix-ui/react-collapsible": "^1.0.3", + "@radix-ui/react-dialog": "^1.0.5", + "@radix-ui/react-dropdown-menu": "^2.0.6", + "@radix-ui/react-label": "^2.0.2", + "@radix-ui/react-progress": "^1.0.3", + "@radix-ui/react-radio-group": "^1.1.3", + "@radix-ui/react-select": "^2.0.0", + "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-tabs": "^1.0.4", + "@radix-ui/react-tooltip": "^1.0.7", + "axios": "^1.6.0", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.0", + "date-fns": "^3.6.0", + "lucide-react": "^0.370.0", "next": "^14.2.0", "react": "^18.3.0", "react-dom": "^18.3.0", - "@radix-ui/react-dialog": "^1.0.5", - "@radix-ui/react-dropdown-menu": "^2.0.6", - "@radix-ui/react-select": "^2.0.0", - "@radix-ui/react-checkbox": "^1.0.4", - "@radix-ui/react-radio-group": "^1.1.3", - "@radix-ui/react-tabs": "^1.0.4", - "@radix-ui/react-tooltip": "^1.0.7", - "@radix-ui/react-progress": "^1.0.3", - "@radix-ui/react-collapsible": "^1.0.3", - "@radix-ui/react-slot": "^1.0.2", - "@radix-ui/react-label": "^2.0.2", - "class-variance-authority": "^0.7.0", - "clsx": "^2.1.0", - "tailwind-merge": "^2.2.0", - "lucide-react": "^0.370.0", "recharts": "^2.12.0", - "axios": "^1.6.0", - "date-fns": "^3.6.0" + "tailwind-merge": "^2.2.0" }, "devDependencies": { - "typescript": "^5.4.0", "@types/node": "^20.12.0", "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", - "tailwindcss": "^3.4.0", - "postcss": "^8.4.0", "autoprefixer": "^10.4.0", "eslint": "^8.57.0", - "eslint-config-next": "^14.2.0" + "eslint-config-next": "^14.2.0", + "postcss": "^8.4.0", + "tailwindcss": "^3.4.0", + "typescript": "^5.4.0" } } diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx index 1aba9f7..5f66e21 100644 --- a/frontend/src/app/login/page.tsx +++ b/frontend/src/app/login/page.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useRef } from "react"; import Image from "next/image"; import { useRouter } from "next/navigation"; import { Button } from "@/components/ui/button"; @@ -8,21 +8,66 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Card, CardContent } from "@/components/ui/card"; import { AlertCircle, Loader2 } from "lucide-react"; -import { login } from "@/lib/api"; +import { login, ssoLogin } from "@/lib/api"; import { setAuth, isAuthenticated } from "@/lib/auth"; +import { getMsalInstance, loginRequest } from "@/lib/msal"; +import type { PublicClientApplication } from "@azure/msal-browser"; + +const SSO_ENABLED = process.env.NEXT_PUBLIC_AZURE_AD_SSO_ENABLED === "true"; export default function LoginPage() { const router = useRouter(); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [loading, setLoading] = useState(false); + const [ssoProcessing, setSsoProcessing] = useState(SSO_ENABLED); const [error, setError] = useState(null); + const msalRef = useRef(null); - // If already authenticated, redirect to dashboard + // Initialize MSAL and handle the redirect callback useEffect(() => { if (isAuthenticated()) { router.replace("/dashboard"); + return; } + + if (!SSO_ENABLED) return; + + let cancelled = false; + + (async () => { + try { + const msal = await getMsalInstance(); + if (cancelled) return; + msalRef.current = msal; + + // Process any redirect response from Azure AD + const result = await msal.handleRedirectPromise(); + if (cancelled) return; + + if (result?.idToken) { + // Exchange Azure AD ID token for app JWT + const response = await ssoLogin(result.idToken); + setAuth(response); + router.replace("/dashboard"); + return; + } + } catch (err: unknown) { + if (!cancelled) { + const message = + err instanceof Error ? err.message : "SSO authentication failed"; + setError(message); + } + } finally { + if (!cancelled) { + setSsoProcessing(false); + } + } + })(); + + return () => { + cancelled = true; + }; }, [router]); const handleSubmit = async (e: React.FormEvent) => { @@ -41,6 +86,18 @@ export default function LoginPage() { } }; + const handleSsoLogin = async () => { + setError(null); + try { + const msal = msalRef.current ?? (await getMsalInstance()); + await msal.loginRedirect(loginRequest); + } catch (err: unknown) { + const message = + err instanceof Error ? err.message : "Failed to initiate SSO login"; + setError(message); + } + }; + return (
@@ -59,9 +116,7 @@ export default function LoginPage() {
-

- Sign In -

+

Sign In

Transcreation Platform

@@ -74,54 +129,94 @@ export default function LoginPage() {
)} -
-
- - setEmail(e.target.value)} - required - autoComplete="email" - /> + {ssoProcessing ? ( +
+ + Signing you in…
+ ) : ( + <> + +
+ + setEmail(e.target.value)} + required + autoComplete="email" + /> +
-
- - setPassword(e.target.value)} - required - autoComplete="current-password" - /> -
+
+ + setPassword(e.target.value)} + required + autoComplete="current-password" + /> +
- + + + {SSO_ENABLED && ( +
+
+
+ or +
+
+ +
)} - - - -
-

- SSO login coming soon -

-
+ + )} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 4b77f04..8d2a9cc 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -205,6 +205,13 @@ export async function login(data: LoginRequest): Promise { return response.data; } +export async function ssoLogin(azureToken: string): Promise { + const response = await api.post("/auth/sso/login", { + token: azureToken, + }); + return response.data; +} + // ─── Jobs ──────────────────────────────────────────────────────────── export async function getJobs( diff --git a/frontend/src/lib/msal.ts b/frontend/src/lib/msal.ts new file mode 100644 index 0000000..b5bb90f --- /dev/null +++ b/frontend/src/lib/msal.ts @@ -0,0 +1,47 @@ +import { + type Configuration, + LogLevel, + PublicClientApplication, +} from "@azure/msal-browser"; + +const msalConfig: Configuration = { + auth: { + clientId: process.env.NEXT_PUBLIC_AZURE_AD_CLIENT_ID || "", + authority: `https://login.microsoftonline.com/${process.env.NEXT_PUBLIC_AZURE_AD_TENANT_ID || ""}`, + redirectUri: + typeof window !== "undefined" + ? `${window.location.origin}${process.env.NEXT_PUBLIC_BASE_PATH || ""}/login` + : "", + postLogoutRedirectUri: + typeof window !== "undefined" + ? `${window.location.origin}${process.env.NEXT_PUBLIC_BASE_PATH || ""}/login` + : "", + }, + cache: { + cacheLocation: "sessionStorage", + }, + system: { + loggerOptions: { + logLevel: LogLevel.Warning, + }, + }, +}; + +export const loginRequest = { + scopes: ["openid", "profile", "email"], +}; + +let msalInstance: PublicClientApplication | null = null; +let msalInitPromise: Promise | null = null; + +export async function getMsalInstance(): Promise { + if (!msalInstance) { + msalInstance = new PublicClientApplication(msalConfig); + msalInitPromise = msalInstance.initialize(); + } + if (msalInitPromise) { + await msalInitPromise; + msalInitPromise = null; + } + return msalInstance; +}