Fix auth for optical-dev.oliver.solutions/ppt-tool/ deployment

- AZURE_AD_REDIRECT_URI set to https://optical-dev.oliver.solutions/ppt-tool/
- Root page intercepts ?code= from Azure AD and forwards to backend callback
- Post-OAuth redirect uses NEXT_PUBLIC_BASE_PATH env var (/ppt-tool/dashboard)
- Cookie secure flag driven by COOKIE_SECURE env var (true in prod)
- Dev login now works alongside Azure AD when DEV_AUTH_PASSWORD is set
- Login page shows both Microsoft SSO and dev form when both modes enabled
- docker-compose.prod.yml: add COOKIE_SECURE=true and NEXT_PUBLIC_BASE_PATH

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-03-20 17:38:42 +00:00
parent 1e28574512
commit 19222ab36d
7 changed files with 73 additions and 21 deletions

View file

@ -8,7 +8,7 @@ REDIS_URL=redis://redis:6379/0
AZURE_AD_TENANT_ID=
AZURE_AD_CLIENT_ID=
AZURE_AD_CLIENT_SECRET=
AZURE_AD_REDIRECT_URI=http://localhost/api/v1/auth/callback
AZURE_AD_REDIRECT_URI=https://yourdomain.com/api/v1/auth/callback
# JWT
JWT_SECRET_KEY=change-me-to-a-random-256-bit-key

View file

@ -1,3 +1,4 @@
import os
import uuid
from fastapi import APIRouter, Depends, HTTPException, Response, Request
from fastapi.responses import RedirectResponse, JSONResponse
@ -20,8 +21,12 @@ class DevLoginRequest(BaseModel):
@AUTH_ROUTER.get("/dev-status")
async def dev_status():
"""Check if dev auth mode is enabled."""
return {"dev_mode": auth_service.is_dev_mode}
"""Check auth modes available."""
return {
"dev_mode": auth_service.is_dev_mode,
"dev_login_enabled": auth_service.dev_login_enabled,
"azure_enabled": not auth_service.is_dev_mode,
}
@AUTH_ROUTER.get("/login")
@ -63,12 +68,14 @@ async def callback(
user = await auth_service.get_or_create_user(claims, session)
token = auth_service.create_session_jwt(user)
response = RedirectResponse(url="/dashboard", status_code=302)
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.set_cookie(
key="session_token",
value=token,
httponly=True,
secure=False, # Set True in production with HTTPS
secure=is_https,
samesite="lax",
max_age=86400, # 24 hours
)
@ -86,8 +93,8 @@ async def dev_login(
body: DevLoginRequest,
session: AsyncSession = Depends(get_async_session),
):
"""Dev-mode login with email and password. Only available when Azure AD is not configured."""
if not auth_service.is_dev_mode:
"""Dev login with email and password. Available when DEV_AUTH_PASSWORD is set."""
if not auth_service.dev_login_enabled:
raise HTTPException(status_code=404, detail="Dev login not available")
user = await auth_service.dev_login(body.email, body.password, session)
@ -96,6 +103,7 @@ async def dev_login(
token = auth_service.create_session_jwt(user)
is_https = os.environ.get("COOKIE_SECURE", "false").lower() == "true"
response = JSONResponse(
content={
"id": str(user.id),
@ -108,7 +116,7 @@ async def dev_login(
key="session_token",
value=token,
httponly=True,
secure=False,
secure=is_https,
samesite="lax",
max_age=86400,
)

View file

@ -35,6 +35,11 @@ class AuthService:
def is_dev_mode(self) -> bool:
return not self.tenant_id
@property
def dev_login_enabled(self) -> bool:
"""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:
@ -148,8 +153,8 @@ class AuthService:
return user
async def dev_login(self, email: str, password: str, session) -> Optional[UserModel]:
"""Dev-mode login: validate password, get or create user."""
if not self.is_dev_mode:
"""Dev login: validate password, get or create user. Works even when Azure AD is configured."""
if not self.dev_login_enabled:
return None
if password != self.dev_password:
return None

View file

@ -22,6 +22,8 @@ services:
- "127.0.0.1:${API_PORT:-8000}:8000"
environment:
PYTHONUNBUFFERED: "1"
NEXT_PUBLIC_BASE_PATH: "/ppt-tool"
COOKIE_SECURE: "true"
worker:
environment:

View file

@ -4,17 +4,18 @@ import { useEffect, useState } from 'react';
import { apiFetch } from '../../lib/apiFetch';
import { useSelector } from 'react-redux';
import { RootState } from '@/store/store';
import { useRouter } from 'next/navigation';
import { useRouter, useSearchParams } from 'next/navigation';
export default function LoginPage() {
const router = useRouter();
const { isAuthenticated, isDevMode } = useSelector(
const searchParams = useSearchParams();
const { isAuthenticated, isDevMode, devLoginEnabled, azureEnabled } = useSelector(
(state: RootState) => state.auth
);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [error, setError] = useState(searchParams.get('error') || '');
const [loading, setLoading] = useState(false);
useEffect(() => {
@ -74,7 +75,7 @@ export default function LoginPage() {
</p>
</div>
{!isDevMode && (
{azureEnabled && (
<button
onClick={handleMicrosoftLogin}
className="w-full flex items-center justify-center gap-3 bg-[#2F2F2F] text-white rounded-lg px-4 py-3 font-medium hover:bg-[#1a1a1a] transition-colors"
@ -89,11 +90,22 @@ export default function LoginPage() {
</button>
)}
{isDevMode && (
{devLoginEnabled && azureEnabled && (
<div className="relative my-4">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-border" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-white px-2 text-muted-foreground">or</span>
</div>
</div>
)}
{devLoginEnabled && (
<form onSubmit={handleDevLogin} className="space-y-4">
<div className="bg-[hsl(var(--warning)/0.08)] border border-[hsl(var(--warning)/0.3)] rounded-lg p-3 mb-4">
<p className="text-xs text-[hsl(var(--warning))] font-medium">
Development Mode Azure AD not configured
{isDevMode ? 'Development Mode — Azure AD not configured' : 'Dev login (temporary)'}
</p>
</div>

View file

@ -7,7 +7,22 @@ export default function RootPage() {
const router = useRouter();
useEffect(() => {
router.replace("/dashboard");
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]);
return null;

View file

@ -14,6 +14,8 @@ interface AuthState {
isLoading: boolean;
isAuthenticated: boolean;
isDevMode: boolean;
devLoginEnabled: boolean;
azureEnabled: boolean;
}
const initialState: AuthState = {
@ -21,6 +23,8 @@ const initialState: AuthState = {
isLoading: true,
isAuthenticated: false,
isDevMode: false,
devLoginEnabled: false,
azureEnabled: false,
};
export const fetchCurrentUser = createAsyncThunk(
@ -48,11 +52,15 @@ export const checkDevMode = createAsyncThunk(
const response = await apiFetch("/api/v1/auth/dev-status");
if (response.ok) {
const data = await response.json();
return data.dev_mode ?? false;
return {
dev_mode: data.dev_mode ?? false,
dev_login_enabled: data.dev_login_enabled ?? false,
azure_enabled: data.azure_enabled ?? false,
};
}
return false;
return { dev_mode: false, dev_login_enabled: false, azure_enabled: false };
} catch {
return false;
return { dev_mode: false, dev_login_enabled: false, azure_enabled: false };
}
}
);
@ -90,7 +98,9 @@ const authSlice = createSlice({
state.isLoading = false;
})
.addCase(checkDevMode.fulfilled, (state, action) => {
state.isDevMode = action.payload;
state.isDevMode = action.payload.dev_mode;
state.devLoginEnabled = action.payload.dev_login_enabled;
state.azureEnabled = action.payload.azure_enabled;
})
.addCase(logoutUser.fulfilled, (state) => {
state.user = null;