Add Azure AD MSAL SSO (SPA token exchange)

- Backend: Azure AD JWKS validator with 24h cache, new POST /api/v1/auth/sso/login
  endpoint, sso_login() in AuthService with auto-provisioning, password_hash made
  nullable, auth_provider column added, Alembic migration c1d2e3f4a5b6
- Frontend: @azure/msal-browser, msal.ts config singleton, ssoLogin() API function,
  login page updated with SSO button and redirect callback handling
- Deploy: frontend Dockerfile and docker-compose.prod.yml updated to bake Azure AD
  vars into the image at build time; deploy.sh validates SSO config on init/deploy

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-04-15 18:08:46 +01:00
parent e4e36fff4d
commit f60b7261b5
18 changed files with 593 additions and 74 deletions

View file

@ -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

View file

@ -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=

View file

@ -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,
)

View file

@ -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

View file

@ -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."""

View file

@ -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

View file

@ -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)

View file

@ -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",

View file

@ -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,

View file

@ -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

View file

@ -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"

View file

@ -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

View file

@ -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

View file

@ -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",

View file

@ -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"
}
}

View file

@ -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<string | null>(null);
const msalRef = useRef<PublicClientApplication | null>(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 (
<div className="min-h-screen bg-amazon-light flex items-center justify-center p-4">
<div className="w-full max-w-[400px]">
@ -59,9 +116,7 @@ export default function LoginPage() {
<Card className="shadow-md">
<CardContent className="pt-6">
<div className="text-center mb-6">
<h1 className="text-2xl font-bold text-amazon-text">
Sign In
</h1>
<h1 className="text-2xl font-bold text-amazon-text">Sign In</h1>
<p className="text-sm text-gray-500 mt-1">
Transcreation Platform
</p>
@ -74,54 +129,94 @@ export default function LoginPage() {
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="you@amazon.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
/>
{ssoProcessing ? (
<div className="flex flex-col items-center justify-center py-8 gap-3 text-sm text-gray-500">
<Loader2 className="h-6 w-6 animate-spin text-amazon-orange" />
Signing you in
</div>
) : (
<>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="you@amazon.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
/>
</div>
<Button
type="submit"
className="w-full h-11 text-base font-semibold"
disabled={loading}
>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Signing in...
</>
) : (
"Sign In"
<Button
type="submit"
className="w-full h-11 text-base font-semibold"
disabled={loading}
>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Signing in...
</>
) : (
"Sign In"
)}
</Button>
</form>
{SSO_ENABLED && (
<div className="mt-4 pt-4 border-t border-gray-200">
<div className="flex items-center gap-3 mb-4">
<div className="flex-1 h-px bg-gray-200" />
<span className="text-xs text-gray-400">or</span>
<div className="flex-1 h-px bg-gray-200" />
</div>
<Button
type="button"
variant="outline"
className="w-full h-11 text-base font-medium flex items-center justify-center gap-2"
onClick={handleSsoLogin}
>
{/* Microsoft logo mark */}
<svg
width="18"
height="18"
viewBox="0 0 21 21"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<rect x="1" y="1" width="9" height="9" fill="#f25022" />
<rect x="11" y="1" width="9" height="9" fill="#7fba00" />
<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
</Button>
</div>
)}
</Button>
</form>
<div className="mt-4 pt-4 border-t border-gray-200">
<p className="text-xs text-gray-400 text-center">
SSO login coming soon
</p>
</div>
</>
)}
</CardContent>
</Card>

View file

@ -205,6 +205,13 @@ export async function login(data: LoginRequest): Promise<LoginResponse> {
return response.data;
}
export async function ssoLogin(azureToken: string): Promise<LoginResponse> {
const response = await api.post<LoginResponse>("/auth/sso/login", {
token: azureToken,
});
return response.data;
}
// ─── Jobs ────────────────────────────────────────────────────────────
export async function getJobs(

47
frontend/src/lib/msal.ts Normal file
View file

@ -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<void> | null = null;
export async function getMsalInstance(): Promise<PublicClientApplication> {
if (!msalInstance) {
msalInstance = new PublicClientApplication(msalConfig);
msalInitPromise = msalInstance.initialize();
}
if (msalInitPromise) {
await msalInitPromise;
msalInitPromise = null;
}
return msalInstance;
}