Authorization Code with PKCE against any provider that publishes a discovery document, configured entirely from the UI — no environment variables, no restart to fix a typo in a client id, and a Test button that fetches the provider's metadata and says what it found. The best decision here was not writing any new session machinery. The callback sets the same httpOnly refresh cookie a password login sets and redirects to "/", and the SPA's existing boot-time restore() trades it for an access token. So an SSO session *is* a normal session — same revocation, same token_version checks, same everything — and no token is ever put in a URL fragment or query string where a proxy log or the browser history would keep it. The alternative everyone reaches for first, redirecting with #access_token=..., would have been a second code path and a worse one. What is actually verified, because "the provider said so" is worth nothing otherwise: the ID token's signature against the provider's published JWKS (re-fetched once if the kid is unknown, so key rotation heals itself), issuer, audience, expiry, and a nonce minted for that specific login. The state row is deleted when it is consumed, which is what makes a replayed callback fail, and it lives in the database rather than a dict so it survives the worker restart that can happen between the redirect out and the redirect back. Accounts match on sub, not username. It is the only identifier a provider promises is stable, so somebody renamed upstream stays the same account instead of silently acquiring a second one. An existing local account with that username is linked rather than duplicated, and keeps its role — linking must not quietly demote an admin. Claim-based admin mapping works in both directions: removed from the group upstream means read-only on the next sign-in. Two things this turned up that were already broken. verify_password raised passlib's UnknownHashError on a hash it could not parse, so a password attempt against an SSO account — which stores a deliberately unusable marker — would have been a 500 rather than a 401; it now returns false for any unparseable hash, which is the right answer for a corrupt row too. And the bundled nginx never forwarded X-Forwarded-Proto, so uvicorn saw plain HTTP behind TLS: the derived redirect URI came out as http:// and the refresh cookie lost its Secure flag. Both fixed. The password form stays on the login screen no matter what. A provider outage locking you out of the machine that runs your provider is a failure mode worth designing against. The authorization matrix made me write down why three routes are public, which is the right question to be asked: they are the path by which an unauthenticated person becomes an authenticated one. status deliberately returns only a boolean and a label — no issuer, no client id — so it tells a stranger nothing the button would not. 31 tests, with a throwaway RSA key standing in for a provider so verification is exercised for real rather than mocked: wrong key under the right kid, wrong audience, wrong issuer, expired, replayed nonce, reused state. Plus an end-to-end run of the whole flow — redirect, callback, cookie, session, group-mapped admin, replay refused, password login against the SSO account cleanly refused. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
471 lines
16 KiB
Python
471 lines
16 KiB
Python
"""Signing in through an identity provider.
|
|
|
|
No provider is contacted: a throwaway RSA key stands in for one, so ID tokens
|
|
can be minted here and the verification exercised for real rather than mocked
|
|
away. That is the point — the whole feature rests on "the provider said so"
|
|
being worth something, which it only is if the signature is actually checked.
|
|
|
|
The cases that matter are the ones where a token looks fine and must still be
|
|
refused: signed by the wrong key, issued to somebody else, from another issuer,
|
|
replayed with an old nonce, or arriving on a state that was already used.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
import pytest
|
|
from jose import jwt
|
|
from sqlmodel import Session, delete, select
|
|
|
|
ISSUER = "https://idp.test/realms/homelab"
|
|
CLIENT_ID = "stackpilot"
|
|
|
|
# A 2048-bit RSA key, generated once for these tests and used nowhere else.
|
|
KEY = None
|
|
WRONG_KEY = None
|
|
|
|
|
|
def _keypair():
|
|
"""Build a JWK pair lazily — generating RSA keys is slow enough to matter."""
|
|
global KEY, WRONG_KEY
|
|
if KEY is None:
|
|
from cryptography.hazmat.primitives import serialization
|
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
|
|
def make(kid):
|
|
private = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
pem = private.private_bytes(
|
|
encoding=serialization.Encoding.PEM,
|
|
format=serialization.PrivateFormat.PKCS8,
|
|
encryption_algorithm=serialization.NoEncryption(),
|
|
).decode()
|
|
from jose import jwk
|
|
|
|
public = jwk.construct(
|
|
private.public_key()
|
|
.public_bytes(
|
|
encoding=serialization.Encoding.PEM,
|
|
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
)
|
|
.decode(),
|
|
algorithm="RS256",
|
|
).to_dict()
|
|
public = {k: (v.decode() if isinstance(v, bytes) else v) for k, v in public.items()}
|
|
public["kid"] = kid
|
|
public["alg"] = "RS256"
|
|
public["use"] = "sig"
|
|
return pem, public
|
|
|
|
KEY = make("test-key-1")
|
|
WRONG_KEY = make("test-key-1") # same kid, different key: the nasty case
|
|
return KEY, WRONG_KEY
|
|
|
|
|
|
def _id_token(nonce: str, *, key=None, claims=None, kid="test-key-1") -> str:
|
|
(good_pem, _), (bad_pem, _) = _keypair()
|
|
payload = {
|
|
"iss": ISSUER,
|
|
"aud": CLIENT_ID,
|
|
"sub": "idp-subject-1",
|
|
"preferred_username": "alice",
|
|
"email": "alice@example.test",
|
|
"nonce": nonce,
|
|
"exp": int(time.time()) + 300,
|
|
"iat": int(time.time()),
|
|
}
|
|
payload.update(claims or {})
|
|
return jwt.encode(payload, key or good_pem, algorithm="RS256", headers={"kid": kid})
|
|
|
|
|
|
@pytest.fixture
|
|
def svc(db):
|
|
from services import oidc_service
|
|
|
|
oidc_service._cache.clear()
|
|
return oidc_service
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def clean(db):
|
|
from database import engine
|
|
from models.oidc import OidcConfig, OidcState
|
|
from models.user import User
|
|
|
|
def wipe():
|
|
with Session(engine) as session:
|
|
session.exec(delete(OidcState))
|
|
session.exec(delete(OidcConfig))
|
|
for user in session.exec(select(User)).all():
|
|
if user.oidc_subject or user.username in ("alice", "bob"):
|
|
session.delete(user)
|
|
session.commit()
|
|
|
|
wipe()
|
|
yield
|
|
wipe()
|
|
|
|
|
|
@pytest.fixture
|
|
def config(db, svc, monkeypatch):
|
|
"""A configured provider, with discovery and JWKS served from memory."""
|
|
from database import engine
|
|
from models.oidc import OidcConfig
|
|
from services import crypto_service
|
|
|
|
(_pem, public), _ = _keypair()
|
|
document = {
|
|
"issuer": ISSUER,
|
|
"authorization_endpoint": f"{ISSUER}/protocol/openid-connect/auth",
|
|
"token_endpoint": f"{ISSUER}/protocol/openid-connect/token",
|
|
"jwks_uri": f"{ISSUER}/protocol/openid-connect/certs",
|
|
}
|
|
|
|
async def fake_discover(issuer, force=False):
|
|
return document
|
|
|
|
async def fake_keys(doc, force=False):
|
|
return {"keys": [public]}
|
|
|
|
monkeypatch.setattr(svc, "discover", fake_discover)
|
|
monkeypatch.setattr(svc, "_signing_keys", fake_keys)
|
|
|
|
with Session(engine) as session:
|
|
row = OidcConfig(
|
|
id=1,
|
|
enabled=True,
|
|
issuer=ISSUER,
|
|
client_id=CLIENT_ID,
|
|
client_secret=crypto_service.encrypt("shhh"),
|
|
auto_create=True,
|
|
default_role="user",
|
|
)
|
|
session.add(row)
|
|
session.commit()
|
|
session.refresh(row)
|
|
yield session, row
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# State, PKCE and single use
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_a_login_gets_its_own_state_verifier_and_nonce(svc, config):
|
|
session, _row = config
|
|
first = svc.begin(session, "https://sp.test/cb")
|
|
second = svc.begin(session, "https://sp.test/cb")
|
|
assert first.state != second.state
|
|
assert first.verifier != second.verifier
|
|
assert first.nonce != second.nonce
|
|
# PKCE verifiers must be long enough to be worth anything.
|
|
assert len(first.verifier) >= 43
|
|
|
|
|
|
def test_the_authorize_url_carries_pkce_and_the_nonce(svc, config):
|
|
import asyncio
|
|
from urllib.parse import parse_qs, urlparse
|
|
|
|
session, row = config
|
|
state = svc.begin(session, "https://sp.test/cb")
|
|
url = asyncio.run(svc.authorize_url(row, state))
|
|
query = parse_qs(urlparse(url).query)
|
|
|
|
assert query["code_challenge_method"] == ["S256"]
|
|
# The challenge is the hash, never the verifier itself.
|
|
assert query["code_challenge"][0] != state.verifier
|
|
assert query["state"] == [state.state]
|
|
assert query["nonce"] == [state.nonce]
|
|
assert query["redirect_uri"] == ["https://sp.test/cb"]
|
|
|
|
|
|
def test_a_state_can_only_be_used_once(svc, config):
|
|
session, _row = config
|
|
state = svc.begin(session, "https://sp.test/cb")
|
|
assert svc.take_state(session, state.state) is not None
|
|
# A replayed callback finds nothing.
|
|
assert svc.take_state(session, state.state) is None
|
|
|
|
|
|
def test_an_expired_state_is_refused(svc, config):
|
|
from models.oidc import OidcState
|
|
|
|
session, _row = config
|
|
state = svc.begin(session, "https://sp.test/cb")
|
|
row = session.get(OidcState, state.state)
|
|
row.created_at = datetime.now(timezone.utc) - svc.STATE_TTL - timedelta(minutes=1)
|
|
session.add(row)
|
|
session.commit()
|
|
assert svc.take_state(session, state.state) is None
|
|
|
|
|
|
def test_an_unknown_state_is_refused(svc, config):
|
|
session, _row = config
|
|
assert svc.take_state(session, "never-issued") is None
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# ID token verification
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def _verify(svc, row, state, token, access_token=None):
|
|
import asyncio
|
|
|
|
tokens = {"id_token": token}
|
|
if access_token:
|
|
tokens["access_token"] = access_token
|
|
return asyncio.run(svc.verify_id_token(row, tokens, state))
|
|
|
|
|
|
def test_a_properly_signed_token_is_accepted(svc, config):
|
|
session, row = config
|
|
state = svc.begin(session, "https://sp.test/cb")
|
|
claims = _verify(svc, row, state, _id_token(state.nonce))
|
|
assert claims["preferred_username"] == "alice"
|
|
|
|
|
|
def test_a_token_signed_with_the_wrong_key_is_refused(svc, config):
|
|
"""The one that matters: same kid, different key."""
|
|
session, row = config
|
|
state = svc.begin(session, "https://sp.test/cb")
|
|
(_good, _), (bad_pem, _) = _keypair()
|
|
with pytest.raises(svc.OidcError):
|
|
_verify(svc, row, state, _id_token(state.nonce, key=bad_pem))
|
|
|
|
|
|
def test_a_token_for_another_audience_is_refused(svc, config):
|
|
session, row = config
|
|
state = svc.begin(session, "https://sp.test/cb")
|
|
with pytest.raises(svc.OidcError):
|
|
_verify(svc, row, state, _id_token(state.nonce, claims={"aud": "some-other-app"}))
|
|
|
|
|
|
def test_a_token_from_another_issuer_is_refused(svc, config):
|
|
session, row = config
|
|
state = svc.begin(session, "https://sp.test/cb")
|
|
with pytest.raises(svc.OidcError):
|
|
_verify(svc, row, state, _id_token(state.nonce, claims={"iss": "https://evil.test"}))
|
|
|
|
|
|
def test_an_expired_token_is_refused(svc, config):
|
|
session, row = config
|
|
state = svc.begin(session, "https://sp.test/cb")
|
|
with pytest.raises(svc.OidcError):
|
|
_verify(svc, row, state, _id_token(state.nonce, claims={"exp": int(time.time()) - 600}))
|
|
|
|
|
|
def test_a_token_with_the_wrong_nonce_is_refused(svc, config):
|
|
"""Replaying an ID token obtained during a different login."""
|
|
session, row = config
|
|
state = svc.begin(session, "https://sp.test/cb")
|
|
with pytest.raises(svc.OidcError) as caught:
|
|
_verify(svc, row, state, _id_token("a-nonce-from-somewhere-else"))
|
|
assert "nonce" in str(caught.value)
|
|
|
|
|
|
def test_a_token_signed_with_an_unknown_key_is_refused(svc, config):
|
|
session, row = config
|
|
state = svc.begin(session, "https://sp.test/cb")
|
|
with pytest.raises(svc.OidcError):
|
|
_verify(svc, row, state, _id_token(state.nonce, kid="some-other-kid"))
|
|
|
|
|
|
def test_garbage_is_refused(svc, config):
|
|
session, row = config
|
|
state = svc.begin(session, "https://sp.test/cb")
|
|
with pytest.raises(svc.OidcError):
|
|
_verify(svc, row, state, "not.a.token")
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Claims to accounts
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_a_first_sign_in_creates_the_account(svc, config):
|
|
session, row = config
|
|
user = svc.resolve_user(session, row, {"sub": "s1", "preferred_username": "alice"})
|
|
assert user.username == "alice"
|
|
assert user.role == "user"
|
|
assert user.oidc_subject == "s1"
|
|
|
|
|
|
def test_the_created_account_cannot_be_signed_into_with_a_password(svc, config):
|
|
import auth as auth_mod
|
|
|
|
session, row = config
|
|
user = svc.resolve_user(session, row, {"sub": "s1", "preferred_username": "alice"})
|
|
# Whatever is stored must not verify against anything, empty string included
|
|
# — and must fail cleanly rather than raising on an unparseable hash.
|
|
assert not auth_mod.verify_password("", user.hashed_password)
|
|
assert not auth_mod.verify_password("hunter2", user.hashed_password)
|
|
assert auth_mod.authenticate(session, "alice", "") is None
|
|
|
|
|
|
def test_a_password_login_against_an_oidc_account_is_a_clean_401(client, svc, config):
|
|
"""Not a 500: an unparseable stored hash used to raise out of passlib."""
|
|
session, row = config
|
|
svc.resolve_user(session, row, {"sub": "s1", "preferred_username": "alice"})
|
|
response = client.post("/api/auth/login", json={"username": "alice", "password": "x"})
|
|
assert response.status_code == 401
|
|
|
|
|
|
def test_an_existing_local_account_is_linked_not_duplicated(svc, config):
|
|
import auth as auth_mod
|
|
from models.user import User
|
|
|
|
session, row = config
|
|
session.add(
|
|
User(username="alice", hashed_password=auth_mod.hash_password("pw"), role="admin")
|
|
)
|
|
session.commit()
|
|
|
|
user = svc.resolve_user(session, row, {"sub": "s1", "preferred_username": "alice"})
|
|
assert user.oidc_subject == "s1"
|
|
# The local role survives: linking must not quietly demote an admin.
|
|
assert user.role == "admin"
|
|
assert len(session.exec(select(User).where(User.username == "alice")).all()) == 1
|
|
|
|
|
|
def test_the_subject_wins_over_a_renamed_username(svc, config):
|
|
session, row = config
|
|
first = svc.resolve_user(session, row, {"sub": "s1", "preferred_username": "alice"})
|
|
again = svc.resolve_user(session, row, {"sub": "s1", "preferred_username": "alice-renamed"})
|
|
assert again.id == first.id
|
|
|
|
|
|
def test_auto_create_can_be_turned_off(svc, config):
|
|
session, row = config
|
|
row.auto_create = False
|
|
with pytest.raises(svc.OidcError) as caught:
|
|
svc.resolve_user(session, row, {"sub": "s1", "preferred_username": "nobody"})
|
|
assert "automatic creation is off" in str(caught.value)
|
|
|
|
|
|
def test_a_disabled_account_cannot_sign_in_through_the_provider(svc, config):
|
|
session, row = config
|
|
user = svc.resolve_user(session, row, {"sub": "s1", "preferred_username": "alice"})
|
|
user.is_active = False
|
|
session.add(user)
|
|
session.commit()
|
|
with pytest.raises(svc.OidcError) as caught:
|
|
svc.resolve_user(session, row, {"sub": "s1", "preferred_username": "alice"})
|
|
assert "disabled" in str(caught.value)
|
|
|
|
|
|
def test_the_username_claim_is_configurable_with_fallbacks(svc, config):
|
|
session, row = config
|
|
row.username_claim = "email"
|
|
user = svc.resolve_user(
|
|
session, row, {"sub": "s1", "email": "bob@example.test", "preferred_username": "x"}
|
|
)
|
|
assert user.username == "bob@example.test"
|
|
|
|
|
|
def test_a_group_claim_can_grant_and_remove_admin(svc, config):
|
|
session, row = config
|
|
row.admin_claim = "groups"
|
|
row.admin_value = "stackpilot-admins"
|
|
|
|
promoted = svc.resolve_user(
|
|
session, row, {"sub": "s1", "preferred_username": "alice", "groups": ["stackpilot-admins"]}
|
|
)
|
|
assert promoted.role == "admin"
|
|
|
|
# Removed from the group upstream: the next sign-in takes it away again.
|
|
demoted = svc.resolve_user(
|
|
session, row, {"sub": "s1", "preferred_username": "alice", "groups": ["other"]}
|
|
)
|
|
assert demoted.role == "user"
|
|
|
|
|
|
def test_without_a_mapping_the_local_role_is_left_alone(svc, config):
|
|
session, row = config
|
|
user = svc.resolve_user(session, row, {"sub": "s1", "preferred_username": "alice"})
|
|
user.role = "admin"
|
|
session.add(user)
|
|
session.commit()
|
|
|
|
again = svc.resolve_user(session, row, {"sub": "s1", "preferred_username": "alice"})
|
|
assert again.role == "admin"
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Through the API
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_the_status_endpoint_is_public_and_says_little(client, config):
|
|
response = client.get("/api/auth/oidc/status")
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert body["enabled"] is True
|
|
# No issuer, no client id: a stranger learns only that SSO exists.
|
|
assert set(body) == {"enabled", "button_label"}
|
|
|
|
|
|
def test_status_is_false_when_nothing_is_configured(client, db):
|
|
assert client.get("/api/auth/oidc/status").json()["enabled"] is False
|
|
|
|
|
|
def test_the_secret_never_comes_back_out(as_admin, config):
|
|
body = as_admin.get("/api/auth/oidc/config").text
|
|
assert "shhh" not in body
|
|
assert '"has_client_secret":true' in body.replace(" ", "")
|
|
|
|
|
|
def test_saving_without_a_secret_keeps_the_stored_one(as_admin, config, svc):
|
|
session, _row = config
|
|
saved = as_admin.put(
|
|
"/api/auth/oidc/config",
|
|
json={
|
|
"enabled": True,
|
|
"issuer": ISSUER,
|
|
"client_id": CLIENT_ID,
|
|
"button_label": "Sign in with Authentik",
|
|
"default_role": "user",
|
|
},
|
|
)
|
|
assert saved.status_code == 200, saved.text
|
|
session.expire_all()
|
|
assert svc.client_secret(svc.get_config(session)) == "shhh"
|
|
|
|
|
|
def test_enabling_without_an_issuer_is_refused(as_admin, db):
|
|
response = as_admin.put(
|
|
"/api/auth/oidc/config",
|
|
json={"enabled": True, "issuer": "", "client_id": "", "default_role": "user"},
|
|
)
|
|
assert response.status_code == 400
|
|
|
|
|
|
def test_a_callback_with_an_unknown_state_lands_back_on_the_login_page(client, config):
|
|
response = client.get(
|
|
"/api/auth/oidc/callback", params={"code": "x", "state": "made-up"},
|
|
follow_redirects=False,
|
|
)
|
|
assert response.status_code == 302
|
|
assert "/login?sso_error=" in response.headers["location"]
|
|
|
|
|
|
def test_a_provider_error_is_passed_through_to_the_login_page(client, config):
|
|
response = client.get(
|
|
"/api/auth/oidc/callback",
|
|
params={"error": "access_denied", "error_description": "User said no"},
|
|
follow_redirects=False,
|
|
)
|
|
assert response.status_code == 302
|
|
assert "User%20said%20no" in response.headers["location"]
|
|
|
|
|
|
def test_the_read_only_role_cannot_read_or_change_the_config(as_user, config):
|
|
assert as_user.get("/api/auth/oidc/config").status_code == 403
|
|
assert (
|
|
as_user.put(
|
|
"/api/auth/oidc/config",
|
|
json={"enabled": False, "issuer": "", "client_id": "", "default_role": "user"},
|
|
).status_code
|
|
== 403
|
|
)
|