implement ACCR aware authentications

This commit is contained in:
Johan Lundberg 2026-06-29 09:19:52 +02:00
parent 7d06d747d6
commit 8143db5aea
No known key found for this signature in database
GPG key ID: A6C152738D03C7D1
4 changed files with 144 additions and 8 deletions

View file

@ -0,0 +1,19 @@
"""Authentication Context Class Reference (``acr``) values per login method.
The ``acr`` claim tells relying parties *how* the user authenticated. idpyoidc
only carries ``acr`` through the authentication event (it has no ``amr``
support), so this single value is our only signal of authentication strength
it must reflect the method actually used, not a static placeholder.
The chosen ``acr`` for a login is recorded in the session under
:data:`SESSION_ACR_KEY` and read back when the OIDC authorization is completed.
"""
# Password over TLS. SAML Authentication Context class — single factor.
ACR_PASSWORD = "urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport"
# WebAuthn / passkey. REFEDS MFA profile — phishing-resistant authentication.
ACR_WEBAUTHN = "https://refeds.org/profile/mfa"
# Session key holding the acr for the current authenticated login.
SESSION_ACR_KEY = "acr"

View file

@ -5,6 +5,7 @@ from fastapi import APIRouter, Form, Request, Response
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fido2.webauthn import AttestedCredentialData, AuthenticationResponse
from porchlight.authn.acr import ACR_PASSWORD, ACR_WEBAUTHN, SESSION_ACR_KEY
from porchlight.models import User
from porchlight.rate_limit import limiter
from porchlight.userid import generate_unique_userid
@ -36,12 +37,15 @@ def _is_sign_count_rollback(stored: int, presented: int) -> bool:
return presented <= stored
def _establish_authenticated_session(request: Request, user: User) -> None:
def _establish_authenticated_session(request: Request, user: User, acr: str) -> None:
"""Reset the session before recording the authenticated identity.
Clearing first defeats session fixation: any values an attacker planted in
the pre-auth session are dropped and the session cookie is reissued. A
pending OIDC authorization request is the only pre-auth state worth keeping.
``acr`` records how the user actually authenticated so the OIDC layer can
surface a truthful Authentication Context Class Reference (see acr.py).
"""
pending_oidc = request.session.get("oidc_auth_request")
request.session.clear()
@ -49,6 +53,7 @@ def _establish_authenticated_session(request: Request, user: User) -> None:
request.session["oidc_auth_request"] = pending_oidc
request.session["userid"] = user.userid
request.session["username"] = user.username
request.session[SESSION_ACR_KEY] = acr
@router.get("/login", response_class=HTMLResponse)
@ -84,7 +89,7 @@ async def login_password(
if not user.active:
return HTMLResponse(error_html)
_establish_authenticated_session(request, user)
_establish_authenticated_session(request, user, ACR_PASSWORD)
response = Response()
response.headers["HX-Redirect"] = _login_redirect_target(request)
@ -152,7 +157,10 @@ async def register_magic_link(request: Request, token: str) -> Response:
user = User(userid=userid, username=link.username, groups=["users"])
await user_repo.create(user)
_establish_authenticated_session(request, user)
# Magic-link registration is single-factor (email possession); mark it as
# such. It normally redirects to credential setup rather than completing an
# OIDC flow, but the session acr governs any later authorization too.
_establish_authenticated_session(request, user, ACR_PASSWORD)
return RedirectResponse("/manage/credentials?setup=1", status_code=303)
@ -217,6 +225,6 @@ async def login_webauthn_complete(request: Request) -> Response: # noqa: PLR091
if user is None or not user.active:
return JSONResponse({"error": "Authentication failed"}, status_code=400)
_establish_authenticated_session(request, user)
_establish_authenticated_session(request, user, ACR_WEBAUTHN)
return JSONResponse({"redirect": _login_redirect_target(request)})

View file

@ -12,6 +12,7 @@ from fastapi import APIRouter, Request, Response
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from idpyoidc.time_util import utc_time_sans_frac
from porchlight.authn.acr import ACR_PASSWORD, SESSION_ACR_KEY
from porchlight.oidc.claims import PorchlightUserInfo, user_to_claims
logger = logging.getLogger(__name__)
@ -169,12 +170,14 @@ async def _complete_authorization( # noqa: PLR0913
claims = user_to_claims(user)
userinfo.set_user_claims(username, claims)
# Create idpyoidc session — authn_method needs a kwargs dict
# Create idpyoidc session — authn_method needs a kwargs dict. The acr
# reflects how the user actually authenticated (set at login); fall back to
# the single-factor password acr if absent.
authn_method = SimpleNamespace(kwargs={})
session_id = endpoint.create_session( # type: ignore[union-attr]
request=parsed,
user_id=username,
acr="urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport",
acr=request.session.get(SESSION_ACR_KEY, ACR_PASSWORD),
time_stamp=utc_time_sans_frac(),
authn_method=authn_method,
)

View file

@ -7,15 +7,28 @@ from typing import Any
from urllib.parse import parse_qs, urlparse
from argon2 import PasswordHasher
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.hashes import SHA256
from cryptojwt.jwk.jwk import key_from_jwk_dict
from cryptojwt.jws.jws import JWS
from fastapi import FastAPI
from fido2.cose import ES256
from fido2.utils import sha256, websafe_decode, websafe_encode
from fido2.webauthn import (
Aaguid,
AttestedCredentialData,
AuthenticatorData,
CollectedClientData,
)
from httpx import AsyncClient
from porchlight.authn.acr import ACR_PASSWORD, ACR_WEBAUTHN
from porchlight.authn.password import PasswordService
from porchlight.models import PasswordCredential, User
from porchlight.models import PasswordCredential, User, WebAuthnCredential
from tests.conftest import get_csrf_token
RP_ID = "localhost"
CLIENT_ID = "e2e-rp"
CLIENT_SECRET = "e2e-secret-0123456789abcdef" # 30+ chars
REDIRECT_URI = "http://localhost:9000/callback"
@ -161,7 +174,9 @@ async def _exchange_token(client: AsyncClient, code: str, code_verifier: str | N
return token_data
async def _validate_id_token(client: AsyncClient, id_token_jwt: str, nonce: str) -> str:
async def _validate_id_token(
client: AsyncClient, id_token_jwt: str, nonce: str, expected_acr: str = ACR_PASSWORD
) -> str:
"""Validate the ID token via JWKS and return the sub claim."""
jwks_res = await client.get("/jwks")
assert jwks_res.status_code == 200
@ -184,6 +199,7 @@ async def _validate_id_token(client: AsyncClient, id_token_jwt: str, nonce: str)
assert id_token_payload["iss"] == "http://localhost:8000"
assert CLIENT_ID in id_token_payload["aud"]
assert id_token_payload["nonce"] == nonce
assert id_token_payload["acr"] == expected_acr
id_token_sub = id_token_payload["sub"]
assert isinstance(id_token_sub, str)
@ -329,3 +345,93 @@ async def test_refresh_grant_does_not_mint_id_token(client: AsyncClient) -> None
refreshed = res.json()
assert "access_token" in refreshed
assert "id_token" not in refreshed
async def _add_webauthn_credential(
app: FastAPI, userid: str
) -> tuple[ec.EllipticCurvePrivateKey, bytes]:
"""Register a WebAuthn credential for an existing user; return (key, cred_id)."""
private_key = ec.generate_private_key(ec.SECP256R1())
cose_key = ES256.from_cryptography_key(private_key.public_key())
credential_id = secrets.token_bytes(32)
attested = AttestedCredentialData.create(
aaguid=Aaguid.NONE, credential_id=credential_id, public_key=cose_key
)
await app.state.credential_repo.create_webauthn(
WebAuthnCredential(user_id=userid, credential_id=credential_id, public_key=bytes(attested))
)
return private_key, credential_id
async def _login_webauthn_and_authorize(
client: AsyncClient, userid: str, private_key: ec.EllipticCurvePrivateKey, credential_id: bytes, state: str, nonce: str
) -> str:
"""Authorize, log in via WebAuthn assertion, consent, return the auth code."""
auth_res = await client.get(
"/authorization",
params={
"response_type": "code",
"client_id": CLIENT_ID,
"redirect_uri": REDIRECT_URI,
"scope": "openid profile email",
"state": state,
"nonce": nonce,
},
follow_redirects=False,
)
assert "/login" in auth_res.headers["location"]
# Begin WebAuthn login (usernameless) to seed the challenge into the session.
token = await get_csrf_token(client)
begin = await client.post("/login/webauthn/begin", headers={"X-CSRF-Token": token})
assert begin.status_code == 200
challenge = websafe_decode(begin.json()["publicKey"]["challenge"])
# Sign a valid assertion with the registered key.
client_data = CollectedClientData.create(
type=CollectedClientData.TYPE.GET, challenge=challenge, origin=f"http://{RP_ID}:8000"
)
auth_data = AuthenticatorData.create(rp_id_hash=sha256(RP_ID.encode()), flags=AuthenticatorData.FLAG.UP, counter=1)
signature = private_key.sign(bytes(auth_data) + client_data.hash, ec.ECDSA(SHA256()))
complete = await client.post(
"/login/webauthn/complete",
json={
"id": websafe_encode(credential_id),
"rawId": websafe_encode(credential_id),
"type": "public-key",
"response": {
"clientDataJSON": websafe_encode(bytes(client_data)),
"authenticatorData": websafe_encode(bytes(auth_data)),
"signature": websafe_encode(signature),
"userHandle": websafe_encode(userid.encode()),
},
},
headers={"X-CSRF-Token": token},
)
assert complete.status_code == 200, f"WebAuthn login failed: {complete.text}"
assert "/authorization/complete" in complete.json()["redirect"]
complete_res = await client.get("/authorization/complete", follow_redirects=False)
assert "/consent" in complete_res.headers["location"]
token = await get_csrf_token(client)
consent_res = await client.post(
"/consent",
data={"action": "allow", "scope": ["openid", "profile", "email"]},
headers={"X-CSRF-Token": token},
follow_redirects=False,
)
return parse_qs(urlparse(consent_res.headers["location"]).query)["code"][0]
async def test_webauthn_login_emits_mfa_acr(client: AsyncClient) -> None:
"""A WebAuthn login must surface the phishing-resistant MFA acr, not the
static password acr."""
app = client._transport.app # type: ignore[union-attr]
await _setup_rp_and_user(app)
private_key, credential_id = await _add_webauthn_credential(app, "lusab-bansen")
state, nonce = secrets.token_urlsafe(16), secrets.token_urlsafe(16)
code = await _login_webauthn_and_authorize(client, "lusab-bansen", private_key, credential_id, state, nonce)
token_data = await _exchange_token(client, code)
await _validate_id_token(client, token_data["id_token"], nonce, expected_acr=ACR_WEBAUTHN)