Files
nessi f925009977 chore: initial project setup with backend, frontend, Android app, and CI/CD
Add complete NexaMFA push MFA system with:
- FastAPI backend with PostgreSQL, Redis, OIDC provider, and Prometheus metrics
- React TypeScript admin console
- Android Kotlin/Jetpack Compose app with biometric authentication
- Docker Compose deployment configuration
- Gitea CI workflow for backend, frontend, and Android builds
- Environment configuration template with security settings
- Documentation for security model, deployment
2026-06-28 09:37:51 +02:00

194 lines
6.8 KiB
Python

import json
from uuid import UUID
from fastapi import APIRouter, Depends, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import Settings
from app.core.deps import get_app_settings, get_db
from app.models.challenge import Challenge
from app.models.oidc import AuthorizationCode
from app.services.challenges import create_challenge, expire_if_needed
from app.services.oidc import (
build_token_response,
consume_authorization_code,
is_approved,
issue_authorization_code,
jwks,
redirect_with_code,
redirect_with_error,
validate_authorize_request,
)
from app.services.push import PushService
router = APIRouter(tags=["oidc"])
OIDC_PRIVATE_KEY = None
def set_oidc_private_key(key) -> None:
global OIDC_PRIVATE_KEY
OIDC_PRIVATE_KEY = key
@router.get("/.well-known/openid-configuration")
async def openid_configuration(settings: Settings = Depends(get_app_settings)):
issuer = settings.oidc_issuer.rstrip("/")
return {
"issuer": issuer,
"authorization_endpoint": f"{issuer}/oauth/authorize",
"token_endpoint": f"{issuer}/oauth/token",
"userinfo_endpoint": f"{issuer}/oauth/userinfo",
"jwks_uri": f"{issuer}/oauth/jwks",
"response_types_supported": ["code"],
"subject_types_supported": ["public"],
"id_token_signing_alg_values_supported": ["RS256"],
"scopes_supported": ["openid", "profile", "email"],
"token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"],
"claims_supported": ["sub", "name", "preferred_username", "email", "amr"],
}
@router.get("/oauth/jwks")
async def jwks_endpoint():
return jwks(OIDC_PRIVATE_KEY)
@router.get("/oauth/authorize")
async def authorize(
request: Request,
response_type: str,
client_id: str,
redirect_uri: str,
scope: str = "openid profile email",
state: str | None = None,
nonce: str | None = None,
login_hint: str | None = None,
session: AsyncSession = Depends(get_db),
settings: Settings = Depends(get_app_settings),
):
error = validate_authorize_request(settings, client_id, redirect_uri, response_type)
if error:
return RedirectResponse(redirect_with_error(redirect_uri, error, state))
if not login_hint:
return HTMLResponse("<h1>NexaMFA</h1><p>authentik must send login_hint with the username.</p>", status_code=400)
challenge = await create_challenge(
session,
settings,
PushService(settings),
username=login_hint,
relying_party="authentik",
requester_ip=request.client.host if request.client else "unknown",
location=None,
ttl_seconds=settings.challenge_ttl_seconds,
oidc_state=state,
)
if not challenge:
return RedirectResponse(redirect_with_error(redirect_uri, "access_denied", state))
await session.commit()
html = f"""
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>NexaMFA Approval</title>
<style>
body {{ font-family: system-ui, sans-serif; margin: 0; min-height: 100vh; display: grid; place-items: center; background: #0f172a; color: #e5e7eb; }}
main {{ max-width: 560px; padding: 32px; }}
.dot {{ display: inline-block; width: 10px; height: 10px; border-radius: 50%; background: #22c55e; animation: pulse 1s infinite alternate; }}
@keyframes pulse {{ from {{ opacity: .35 }} to {{ opacity: 1 }} }}
</style>
</head>
<body>
<main>
<h1>Approve sign-in</h1>
<p><span class="dot"></span> A NexaMFA push request was sent to your enrolled Android device.</p>
<p>This request expires in {settings.challenge_ttl_seconds} seconds.</p>
</main>
<script>
const challengeId = {json.dumps(str(challenge.id))};
const params = new URLSearchParams({json.dumps({
"client_id": client_id,
"redirect_uri": redirect_uri,
"scope": scope,
"state": state or "",
"nonce": nonce or "",
})});
async function poll() {{
const res = await fetch(`/oauth/status/${{challengeId}}?${{params.toString()}}`);
const data = await res.json();
if (data.redirect) window.location = data.redirect;
else if (data.done) document.body.innerHTML = "<main><h1>Request ended</h1><p>" + data.status + "</p></main>";
}}
setInterval(poll, 2000);
poll();
</script>
</body>
</html>
"""
return HTMLResponse(html)
@router.get("/oauth/status/{challenge_id}")
async def oauth_status(
challenge_id: UUID,
client_id: str,
redirect_uri: str,
scope: str = "openid profile email",
state: str | None = None,
nonce: str | None = None,
session: AsyncSession = Depends(get_db),
settings: Settings = Depends(get_app_settings),
):
challenge = await session.get(Challenge, challenge_id)
if not challenge:
raise HTTPException(status_code=404, detail="Challenge not found")
await expire_if_needed(session, challenge)
if is_approved(challenge):
code = await issue_authorization_code(
session,
settings,
challenge=challenge,
client_id=client_id,
redirect_uri=redirect_uri,
scope=scope,
state=state,
nonce=nonce,
)
await session.commit()
return {"done": True, "redirect": redirect_with_code(redirect_uri, code, state)}
if challenge.status.value in {"denied", "expired"}:
await session.commit()
return {"done": True, "status": challenge.status.value, "redirect": redirect_with_error(redirect_uri, "access_denied", state)}
await session.commit()
return {"done": False, "status": challenge.status.value}
@router.post("/oauth/token")
async def token(
grant_type: str = Form(...),
code: str = Form(...),
redirect_uri: str = Form(...),
client_id: str = Form(...),
client_secret: str = Form(...),
session: AsyncSession = Depends(get_db),
settings: Settings = Depends(get_app_settings),
):
if grant_type != "authorization_code":
raise HTTPException(status_code=400, detail="unsupported_grant_type")
if client_id != settings.oidc_client_id or client_secret != settings.oidc_client_secret:
raise HTTPException(status_code=401, detail="invalid_client")
auth_code = await consume_authorization_code(session, code=code, client_id=client_id, redirect_uri=redirect_uri)
if not auth_code:
raise HTTPException(status_code=400, detail="invalid_grant")
response = await build_token_response(session, settings, OIDC_PRIVATE_KEY, auth_code)
await session.commit()
return JSONResponse(response)
@router.get("/oauth/userinfo")
async def userinfo():
return {"service": "NexaMFA", "note": "Use ID token claims for authenticated user details."}