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:
parent
e4e36fff4d
commit
f60b7261b5
18 changed files with 593 additions and 74 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=
|
||||
|
|
|
|||
51
backend/alembic/versions/c1d2e3f4a5b6_add_sso_fields.py
Normal file
51
backend/alembic/versions/c1d2e3f4a5b6_add_sso_fields.py
Normal 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,
|
||||
)
|
||||
121
backend/app/auth/providers/azure_ad.py
Normal file
121
backend/app/auth/providers/azure_ad.py
Normal 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
|
||||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
39
deploy.sh
39
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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
22
frontend/package-lock.json
generated
22
frontend/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
47
frontend/src/lib/msal.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue