import hashlib from urllib.parse import urlencode from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.config import Settings from app.core.security import expires_in, now_utc, public_jwk, random_token, sign_jwt from app.models.challenge import Challenge, ChallengeStatus from app.models.oidc import AuthorizationCode from app.models.user import User OIDC_KEY_ID = "nexamfa-oidc-1" def hash_code(code: str) -> str: return hashlib.sha256(code.encode("utf-8")).hexdigest() def validate_authorize_request(settings: Settings, client_id: str, redirect_uri: str, response_type: str) -> str | None: if client_id != settings.oidc_client_id: return "invalid_client" if redirect_uri not in settings.oidc_redirect_uri_list: return "invalid_redirect_uri" if response_type != "code": return "unsupported_response_type" return None async def issue_authorization_code( session: AsyncSession, settings: Settings, *, challenge: Challenge, client_id: str, redirect_uri: str, scope: str, state: str | None, nonce: str | None, ) -> str: code = random_token(32) session.add( AuthorizationCode( code_hash=hash_code(code), client_id=client_id, redirect_uri=redirect_uri, scope=scope, state=state, nonce=nonce, user_id=challenge.user_id, challenge_id=challenge.id, expires_at=expires_in(settings.auth_code_ttl_seconds), ) ) return code async def consume_authorization_code( session: AsyncSession, *, code: str, client_id: str, redirect_uri: str, ) -> AuthorizationCode | None: auth_code = await session.scalar(select(AuthorizationCode).where(AuthorizationCode.code_hash == hash_code(code))) if not auth_code or auth_code.used or auth_code.expires_at <= now_utc(): return None if auth_code.client_id != client_id or auth_code.redirect_uri != redirect_uri: return None auth_code.used = True return auth_code def redirect_with_code(redirect_uri: str, code: str, state: str | None) -> str: query = {"code": code} if state: query["state"] = state return f"{redirect_uri}?{urlencode(query)}" def redirect_with_error(redirect_uri: str, error: str, state: str | None = None) -> str: query = {"error": error} if state: query["state"] = state return f"{redirect_uri}?{urlencode(query)}" async def build_token_response( session: AsyncSession, settings: Settings, private_key, auth_code: AuthorizationCode, ) -> dict: user = await session.get(User, auth_code.user_id) now = int(now_utc().timestamp()) exp = now + settings.access_token_ttl_seconds claims = { "iss": settings.oidc_issuer, "sub": str(user.id), "aud": auth_code.client_id, "exp": exp, "iat": now, "auth_time": now, "amr": ["push", "biometric"], "name": user.display_name or user.username, "preferred_username": user.username, "email": user.email, } if auth_code.nonce: claims["nonce"] = auth_code.nonce id_token = sign_jwt(claims, private_key, OIDC_KEY_ID) access_token = sign_jwt( {"iss": settings.oidc_issuer, "sub": str(user.id), "aud": "nexamfa-api", "exp": exp, "scope": auth_code.scope}, private_key, OIDC_KEY_ID, ) return { "access_token": access_token, "id_token": id_token, "token_type": "Bearer", "expires_in": settings.access_token_ttl_seconds, "scope": auth_code.scope, } def jwks(private_key) -> dict: return {"keys": [public_jwk(private_key, OIDC_KEY_ID)]} def is_approved(challenge: Challenge) -> bool: return challenge.status == ChallengeStatus.approved