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

NexaMFA

authentik must send login_hint with the username.

", 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""" NexaMFA Approval

Approve sign-in

A NexaMFA push request was sent to your enrolled Android device.

This request expires in {settings.challenge_ttl_seconds} seconds.

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