Flere anlegg – hovedscene, resepsjon, crew – og ett sted å sende fra, med avkryssing for hvilke soner meldingen skal til. Sentralen er en vanlig announcer med [zones] i config. En sone er en vanlig announcer med ANNOUNCER_MESH_SECRET. Samme image, ingen mode, og en sone kan brukes alene hvis sentralen faller ut. Hver avhengighet med C-kode har aarch64-wheel i uv.lock, så det samme bildet går på en Raspberry Pi uten å kompilere noe. Sentralen syntetiserer én gang og sender ferdig WAV (POST /api/play, multipart). Sonen trenger da verken nett, edge-tts eller Pipers 670 MB for det den får – Item.wav er hele endringen i avspillingen. Køene forblir per sone: dispatcheren har egen tråd og egen kø, leverer til hver sone i hver sin tråd med timeout, og går videre. En opptatt hovedscene holder ikke igjen resepsjonen, og en sone som er nede feiler alene – synlig per sone i UI og revisjonslogg. Auth gjenbrukes: ny leverandør mesh på header X-Mesh-Key (ikke Bearer – den leses av JWT-leverandøren, som avviser alt som ikke er et JWT før neste får prøve), med én handling, play. To regler som fulgte med: - play følger aldri med «alt åpent». Uten det kunne hvem som helst POSTe en WAV til en åpen sone og få den spilt. Testen for åpen modus fanget det. - En legitimasjon som vises fram og er feil gir 401 også der gjester ellers har lov. Før slapp en feil PIN i åpen modus stille gjennom som gjest. mesh teller ikke som innlogging for folk: en sone med bare nøkkelen har fortsatt åpen side lokalt. Planlagte sendinger husker sonene sine og fordeles gjennom dispatcheren; planen ender som sent hvis minst én sone tok imot, med «hovedscene: kø #3, crew: nede» som note. Hodeløs Pi: install.sh kjører loginctl enable-linger, ellers finnes ikke lydsocketen når ingen logger inn. docs/mesh.md tar også raspi-config for jack framfor HDMI. 38 nye tester, 148 totalt. Fordelingen testes uten nett med stubbet transport; selve HTTP-en testes mot en levende werkzeug-server, så den håndrullede multiparten møter Flask sin parser. Verifisert i compose med sentral og to soner: til alle, bare til én, og én sone stoppet. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
550 lines
21 KiB
Python
550 lines
21 KiB
Python
"""Tester for autentiseringen.
|
||
|
||
Auth er den ene delen av denne appen man ikke kan se etter med øyet: at en PIN
|
||
virker ser du med en gang, at et utløpt token blir avvist ser du aldri før det
|
||
smeller. Derfor står prøvene her og ikke rundt resten.
|
||
|
||
Testene kjører mot en bar Flask-app, ikke mot announcer.py – det ville dratt inn
|
||
piper, lastet stemmer og startet avspillingstråden.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import sys
|
||
import time
|
||
import urllib.parse
|
||
from pathlib import Path
|
||
|
||
import jwt as pyjwt
|
||
import pytest
|
||
from flask import Flask, jsonify
|
||
|
||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||
|
||
import auth # noqa: E402
|
||
import config # noqa: E402
|
||
|
||
SECRET = "en-delt-hemmelighet-mellom-panelet-og-announcer"
|
||
|
||
|
||
def build(tmp_path: Path, toml: str = "", **env: str):
|
||
"""Lager en liten app med gitt konfigurasjon. Returnerer testklienten."""
|
||
cfg_path = tmp_path / "config.toml"
|
||
cfg_path.write_text(f'[paths]\nstate = "{tmp_path}"\nlog = "{tmp_path}/a.jsonl"\n\n{toml}')
|
||
for key, value in env.items():
|
||
__import__("os").environ[key] = value
|
||
try:
|
||
cfg = config.load(cfg_path)
|
||
finally:
|
||
for key in env:
|
||
__import__("os").environ.pop(key, None)
|
||
|
||
app = Flask(__name__)
|
||
app.config["TESTING"] = True
|
||
authenticator = auth.install(app, cfg)
|
||
|
||
@app.get("/do/<action>")
|
||
def do(action: str): # noqa: ANN202
|
||
return jsonify(auth.require(action).public())
|
||
|
||
@app.errorhandler(401)
|
||
def _401(e): # noqa: ANN001, ANN202
|
||
return jsonify({"error": e.description}), 401
|
||
|
||
@app.errorhandler(403)
|
||
def _403(e): # noqa: ANN001, ANN202
|
||
return jsonify({"error": e.description}), 403
|
||
|
||
return app.test_client(), authenticator
|
||
|
||
|
||
def token(claims: dict, *, secret: str = SECRET, alg: str = "HS256", **over) -> str:
|
||
body = {"sub": "u1", "exp": time.time() + 600, **claims, **over}
|
||
return pyjwt.encode(body, secret, algorithm=alg)
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Uten konfigurasjon skal ingenting være stengt
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
def test_uten_konfig_er_alt_apent(tmp_path):
|
||
c, a = build(tmp_path)
|
||
assert a.enabled is False
|
||
for action in config.ACTIONS:
|
||
if action == "play":
|
||
continue
|
||
assert c.get(f"/do/{action}").status_code == 200, action
|
||
# Det ene unntaket: «play» er for en sentral med mesh-nøkkel. En åpen
|
||
# sone skal ikke spille lyd for hvem som helst som finner den.
|
||
assert c.get("/do/play").status_code == 401
|
||
|
||
|
||
def test_feil_legitimasjon_er_feil_ogsa_pa_apen_sone(tmp_path):
|
||
"""En PIN som vises fram og er feil, skal avvises – ikke behandles som
|
||
gjest fordi gjester tilfeldigvis har lov."""
|
||
c, _ = build(tmp_path, '[mesh]\nsecret = "riktig"\n')
|
||
assert c.get("/do/view").status_code == 200 # gjest: lov
|
||
assert c.get("/do/view", headers={"X-Mesh-Key": "feil"}).status_code == 401 # feil nøkkel: nei
|
||
assert c.get("/do/play", headers={"X-Mesh-Key": "riktig"}).status_code == 200
|
||
|
||
|
||
def test_tom_providers_liste_er_fortsatt_apen(tmp_path):
|
||
c, a = build(tmp_path, "[auth]\nproviders = []\n")
|
||
assert a.enabled is False
|
||
assert c.get("/do/clear").status_code == 200
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# PIN
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
def test_pin_kreves_nar_den_er_satt(tmp_path):
|
||
c, a = build(tmp_path, '[auth.pin]\npin = "1234"\n')
|
||
assert a.enabled is True
|
||
assert [p.name for p in a.providers] == ["pin"]
|
||
assert c.get("/do/announce").status_code == 401
|
||
assert c.get("/do/announce", headers={"X-Pin": "feil"}).status_code == 401
|
||
assert c.get("/do/announce", headers={"X-Pin": "1234"}).status_code == 200
|
||
|
||
|
||
def test_pin_fra_miljovariabel_slar_pa_leverandoren(tmp_path):
|
||
"""Bakoverkompatibilitet: dette er alt systemd-enheten har satt fram til nå."""
|
||
c, a = build(tmp_path, ANNOUNCER_PIN="9999")
|
||
assert [p.name for p in a.providers] == ["pin"]
|
||
assert c.get("/do/clear", headers={"X-Pin": "9999"}).status_code == 200
|
||
assert c.get("/do/clear").status_code == 401
|
||
|
||
|
||
def test_feil_pin_gir_forstaelig_melding(tmp_path):
|
||
c, _ = build(tmp_path, '[auth.pin]\npin = "1234"\n')
|
||
assert c.get("/do/announce", headers={"X-Pin": "nope"}).get_json()["error"] == "Feil PIN"
|
||
|
||
|
||
def test_pin_gir_admin_som_standard(tmp_path):
|
||
c, _ = build(tmp_path, '[auth.pin]\npin = "1234"\n')
|
||
body = c.get("/do/clear", headers={"X-Pin": "1234"}).get_json()
|
||
assert body["roles"] == ["admin"]
|
||
assert sorted(body["can"]) == sorted(config.ACTIONS)
|
||
|
||
|
||
def test_pin_med_crew_rolle_far_ikke_tomme_koen(tmp_path):
|
||
c, _ = build(tmp_path, '[auth.pin]\npin = "1234"\nrole = "crew"\n')
|
||
assert c.get("/do/announce", headers={"X-Pin": "1234"}).status_code == 200
|
||
r = c.get("/do/clear", headers={"X-Pin": "1234"})
|
||
assert r.status_code == 403
|
||
assert "crew" in r.get_json()["error"]
|
||
|
||
|
||
def test_pin_godtas_ogsa_i_authorization_header(tmp_path):
|
||
c, _ = build(tmp_path, '[auth.pin]\npin = "1234"\n')
|
||
assert c.get("/do/announce", headers={"Authorization": "Pin 1234"}).status_code == 200
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Anonym rolle
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
def test_anonym_kan_gis_leserett(tmp_path):
|
||
"""For statusskjermen som henger på veggen uten noen til å logge inn."""
|
||
c, _ = build(tmp_path, '[auth.pin]\npin = "1234"\n\n[roles]\nanonymous = ["view"]\n')
|
||
assert c.get("/do/view").status_code == 200
|
||
assert c.get("/do/announce").status_code == 401
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# JWT
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
JWT_CFG = f'''
|
||
[auth.jwt]
|
||
secret = "{SECRET}"
|
||
audience = "announcer"
|
||
issuer = "https://crew.example.no"
|
||
'''
|
||
|
||
|
||
def test_gyldig_jwt_slipper_inn_med_rolle_og_navn(tmp_path):
|
||
c, a = build(tmp_path, JWT_CFG)
|
||
assert [p.name for p in a.providers] == ["jwt"]
|
||
t = token({"aud": "announcer", "iss": "https://crew.example.no",
|
||
"name": "Kari Nordmann", "roles": ["crew"], "sub": "42"})
|
||
body = c.get("/do/announce", headers={"Authorization": f"Bearer {t}"}).get_json()
|
||
assert body["name"] == "Kari Nordmann"
|
||
assert body["roles"] == ["crew"]
|
||
assert body["subject"] == "jwt:42"
|
||
assert body["method"] == "jwt"
|
||
|
||
|
||
def test_utlopt_jwt_avvises(tmp_path):
|
||
c, _ = build(tmp_path, JWT_CFG)
|
||
t = token({"aud": "announcer", "iss": "https://crew.example.no", "roles": ["admin"]},
|
||
exp=time.time() - 3600)
|
||
r = c.get("/do/announce", headers={"Authorization": f"Bearer {t}"})
|
||
assert r.status_code == 401
|
||
assert "utløpt" in r.get_json()["error"]
|
||
|
||
|
||
def test_jwt_for_en_annen_tjeneste_avvises(tmp_path):
|
||
c, _ = build(tmp_path, JWT_CFG)
|
||
t = token({"aud": "et-annet-system", "iss": "https://crew.example.no", "roles": ["admin"]})
|
||
r = c.get("/do/announce", headers={"Authorization": f"Bearer {t}"})
|
||
assert r.status_code == 401
|
||
assert "annen tjeneste" in r.get_json()["error"]
|
||
|
||
|
||
def test_jwt_fra_feil_utsteder_avvises(tmp_path):
|
||
c, _ = build(tmp_path, JWT_CFG)
|
||
t = token({"aud": "announcer", "iss": "https://ondsinnet.example", "roles": ["admin"]})
|
||
assert c.get("/do/announce", headers={"Authorization": f"Bearer {t}"}).status_code == 401
|
||
|
||
|
||
def test_jwt_med_feil_signatur_avvises(tmp_path):
|
||
c, _ = build(tmp_path, JWT_CFG)
|
||
t = token({"aud": "announcer", "iss": "https://crew.example.no", "roles": ["admin"]},
|
||
secret="en-helt-annen-hemmelighet-som-er-lang-nok-for-hmac")
|
||
assert c.get("/do/announce", headers={"Authorization": f"Bearer {t}"}).status_code == 401
|
||
|
||
|
||
def test_jwt_uten_exp_avvises(tmp_path):
|
||
"""Et token uten utløpstid er en evig nøkkel; den vil vi ikke ha."""
|
||
c, _ = build(tmp_path, JWT_CFG)
|
||
body = {"sub": "u1", "aud": "announcer", "iss": "https://crew.example.no", "roles": ["admin"]}
|
||
t = pyjwt.encode(body, SECRET, algorithm="HS256")
|
||
assert c.get("/do/announce", headers={"Authorization": f"Bearer {t}"}).status_code == 401
|
||
|
||
|
||
def test_jwt_rolle_uten_rettighet_gir_403_ikke_401(tmp_path):
|
||
"""Forskjellen betyr noe for klienten: 401 = logg inn, 403 = du får ikke."""
|
||
c, _ = build(tmp_path, JWT_CFG)
|
||
t = token({"aud": "announcer", "iss": "https://crew.example.no", "roles": ["crew"]})
|
||
h = {"Authorization": f"Bearer {t}"}
|
||
assert c.get("/do/announce", headers=h).status_code == 200
|
||
assert c.get("/do/clear", headers=h).status_code == 403
|
||
|
||
|
||
def test_ingen_authorization_header_gir_401(tmp_path):
|
||
c, _ = build(tmp_path, JWT_CFG)
|
||
assert c.get("/do/view").status_code == 401
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"claims, role_claim, forventet",
|
||
[
|
||
({"roles": ["crew", "admin"]}, "roles", ["crew", "admin"]),
|
||
({"roles": "crew admin"}, "roles", ["crew", "admin"]),
|
||
({"roles": "crew,admin"}, "roles", ["crew", "admin"]),
|
||
({"roles": "crew"}, "roles", ["crew"]),
|
||
({"realm_access": {"roles": ["crew"]}}, "realm_access.roles", ["crew"]),
|
||
({"groups": ["admin"]}, "groups", ["admin"]),
|
||
({}, "roles", []),
|
||
],
|
||
)
|
||
def test_rolleclaim_tolkes_i_alle_formene(tmp_path, claims, role_claim, forventet):
|
||
c, _ = build(tmp_path, JWT_CFG + f'role_claim = "{role_claim}"\n')
|
||
t = token({"aud": "announcer", "iss": "https://crew.example.no", **claims})
|
||
r = c.get("/do/view", headers={"Authorization": f"Bearer {t}"})
|
||
if forventet:
|
||
assert r.get_json()["roles"] == forventet
|
||
else:
|
||
# Gyldig token uten roller: vi *vet* hvem dette er, han får bare ikke
|
||
# gjøre noe. Da er 403 riktig – å logge inn på nytt hjelper ikke.
|
||
assert r.status_code == 403
|
||
|
||
|
||
def test_default_role_brukes_nar_tokenet_mangler_roller(tmp_path):
|
||
c, _ = build(tmp_path, JWT_CFG + 'default_role = "crew"\n')
|
||
t = token({"aud": "announcer", "iss": "https://crew.example.no"})
|
||
body = c.get("/do/announce", headers={"Authorization": f"Bearer {t}"}).get_json()
|
||
assert body["roles"] == ["crew"]
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"claims, forventet",
|
||
[
|
||
({"name": "Kari"}, "Kari"),
|
||
({"preferred_username": "kari"}, "kari"),
|
||
({"email": "kari@example.no"}, "kari@example.no"),
|
||
({}, "u1"),
|
||
],
|
||
)
|
||
def test_visningsnavn_finnes_fram_fra_det_som_er(tmp_path, claims, forventet):
|
||
c, _ = build(tmp_path, JWT_CFG + 'default_role = "admin"\n')
|
||
t = token({"aud": "announcer", "iss": "https://crew.example.no", **claims})
|
||
assert c.get("/do/view", headers={"Authorization": f"Bearer {t}"}).get_json()["name"] == forventet
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Token-lenka fra crew-panelet
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
def test_token_lenke_gir_sesjon_sa_resten_virker_uten_header(tmp_path):
|
||
c, _ = build(tmp_path, JWT_CFG)
|
||
t = token({"aud": "announcer", "iss": "https://crew.example.no",
|
||
"name": "Kent", "roles": ["admin"]})
|
||
r = c.get(f"/auth/token?t={t}")
|
||
assert r.status_code == 302
|
||
body = c.get("/do/clear").get_json() # ingen header denne gangen
|
||
assert body["name"] == "Kent"
|
||
assert body["method"] == "jwt"
|
||
|
||
|
||
def test_token_lenke_med_ugyldig_token_gir_ingen_sesjon(tmp_path):
|
||
c, _ = build(tmp_path, JWT_CFG)
|
||
assert c.get("/auth/token?t=aldeles-ugyldig").status_code == 401
|
||
assert c.get("/do/view").status_code == 401
|
||
|
||
|
||
def test_token_lenke_finnes_ikke_uten_jwt_oppsett(tmp_path):
|
||
c, _ = build(tmp_path, '[auth.pin]\npin = "1234"\n')
|
||
assert c.get("/auth/token?t=hva-som-helst").status_code == 404
|
||
|
||
|
||
def test_utlogging_tommer_sesjonen(tmp_path):
|
||
c, _ = build(tmp_path, JWT_CFG)
|
||
t = token({"aud": "announcer", "iss": "https://crew.example.no", "roles": ["admin"]})
|
||
c.get(f"/auth/token?t={t}")
|
||
assert c.get("/do/clear").status_code == 200
|
||
c.get("/auth/logout")
|
||
assert c.get("/do/clear").status_code == 401
|
||
|
||
|
||
def test_rettigheter_slas_opp_pa_nytt_for_hver_forespørsel(tmp_path):
|
||
"""Endrer man [roles] skal det slå inn uten at folk må logge inn på nytt."""
|
||
c, a = build(tmp_path, JWT_CFG)
|
||
t = token({"aud": "announcer", "iss": "https://crew.example.no", "roles": ["crew"]})
|
||
c.get(f"/auth/token?t={t}")
|
||
assert c.get("/do/clear").status_code == 403
|
||
a.cfg.data["roles"]["crew"] = ["*"]
|
||
assert c.get("/do/clear").status_code == 200
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Flere metoder samtidig
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
def test_pin_og_jwt_kan_virke_side_om_side(tmp_path):
|
||
c, a = build(tmp_path, JWT_CFG + '\n[auth.pin]\npin = "1234"\nrole = "crew"\n')
|
||
assert sorted(p.name for p in a.providers) == ["jwt", "pin"]
|
||
assert c.get("/do/announce", headers={"X-Pin": "1234"}).status_code == 200
|
||
t = token({"aud": "announcer", "iss": "https://crew.example.no", "roles": ["admin"]})
|
||
assert c.get("/do/clear", headers={"Authorization": f"Bearer {t}"}).status_code == 200
|
||
|
||
|
||
def test_rekkefolgen_i_providers_bestemmer(tmp_path):
|
||
c, a = build(tmp_path, '[auth]\nproviders = ["jwt", "pin"]\n' + JWT_CFG + '\n[auth.pin]\npin = "1"\n')
|
||
assert [p.name for p in a.providers] == ["jwt", "pin"]
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Enhetstester på småtingene
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"value, forventet",
|
||
[
|
||
(["a", "b"], ("a", "b")),
|
||
("a b", ("a", "b")),
|
||
("a,b", ("a", "b")),
|
||
("a", ("a",)),
|
||
(None, ()),
|
||
(42, ()),
|
||
([" a ", ""], ("a",)),
|
||
],
|
||
)
|
||
def test_roller_tolkes(value, forventet):
|
||
assert auth._roles(value) == forventet
|
||
|
||
|
||
def test_nestet_claim_hentes():
|
||
claims = {"realm_access": {"roles": ["crew"]}}
|
||
assert auth._claim(claims, "realm_access.roles") == ["crew"]
|
||
assert auth._claim(claims, "finnes.ikke") is None
|
||
assert auth._claim(claims, "realm_access.roles.dypere") is None
|
||
|
||
|
||
def test_rolle_til_handling():
|
||
cfg = config.load("/finnes-ikke.toml")
|
||
assert cfg.permissions(("admin",)) == frozenset(config.ACTIONS)
|
||
assert cfg.permissions(("crew",)) == frozenset({"view", "preview", "announce", "schedule"})
|
||
assert cfg.permissions(("ukjent-rolle",)) == frozenset()
|
||
assert cfg.permissions(()) == frozenset()
|
||
# Roller legges sammen
|
||
cfg.data["roles"]["bare_stopp"] = ["stop"]
|
||
assert cfg.permissions(("crew", "bare_stopp")) == frozenset(
|
||
{"view", "preview", "announce", "schedule", "stop"})
|
||
|
||
|
||
def test_ukjente_handlinger_i_roles_ignoreres():
|
||
cfg = config.load("/finnes-ikke.toml")
|
||
cfg.data["roles"]["rar"] = ["announce", "slett-alt"]
|
||
assert cfg.permissions(("rar",)) == frozenset({"announce"})
|
||
|
||
|
||
def test_sesjonsnokkel_er_stabil_mellom_omstarter(tmp_path):
|
||
cfg = config.load("/finnes-ikke.toml")
|
||
cfg.data["paths"]["state"] = str(tmp_path)
|
||
first = auth.secret_key(cfg)
|
||
assert auth.secret_key(cfg) == first
|
||
assert (tmp_path / "secret_key").stat().st_mode & 0o777 == 0o600
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# OIDC
|
||
# --------------------------------------------------------------------------
|
||
# Crew-panelet har ikke OIDC-endepunkter ennå, så her står en stubbet
|
||
# identitetsleverandør i stedet. Det er HTTP-kallene som stubbes; discovery,
|
||
# PKCE, state-sjekken, kodebyttet og verifiseringen av id_token er vår egen
|
||
# kode og kjøres på ordentlig.
|
||
|
||
OIDC_CFG = '''
|
||
[auth.oidc]
|
||
issuer = "https://crew.example.no"
|
||
client_id = "announcer"
|
||
client_secret = "panel-hemmelighet-som-er-lang-nok-for-hmac-sha256"
|
||
'''
|
||
|
||
DISCOVERY = {
|
||
"issuer": "https://crew.example.no",
|
||
"authorization_endpoint": "https://crew.example.no/oauth/authorize",
|
||
"token_endpoint": "https://crew.example.no/oauth/token",
|
||
"jwks_uri": "",
|
||
}
|
||
|
||
|
||
@pytest.fixture
|
||
def idp(monkeypatch):
|
||
"""Stubber HTTP-laget og husker hva vi sendte til tokenendepunktet."""
|
||
state: dict = {"posts": [], "claims": {}}
|
||
|
||
def fake_get(url, timeout=10):
|
||
assert url == "https://crew.example.no/.well-known/openid-configuration"
|
||
return dict(DISCOVERY)
|
||
|
||
def fake_post(url, form, headers=None, timeout=10):
|
||
state["posts"].append({"url": url, "form": form, "headers": headers or {}})
|
||
claims = {
|
||
"iss": "https://crew.example.no",
|
||
"aud": "announcer",
|
||
"exp": time.time() + 600,
|
||
"sub": "142",
|
||
**state["claims"],
|
||
}
|
||
# Panelet signerer id_token med client_secret (HS256) i dette oppsettet.
|
||
return {"id_token": pyjwt.encode(claims, "panel-hemmelighet-som-er-lang-nok-for-hmac-sha256", algorithm="HS256")}
|
||
|
||
monkeypatch.setattr(auth, "_get_json", fake_get)
|
||
monkeypatch.setattr(auth, "_post_json", fake_post)
|
||
return state
|
||
|
||
|
||
def test_oidc_login_sender_brukeren_til_panelet_med_pkce(tmp_path, idp):
|
||
c, _ = build(tmp_path, OIDC_CFG)
|
||
r = c.get("/auth/login")
|
||
assert r.status_code == 302
|
||
url = urllib.parse.urlparse(r.headers["Location"])
|
||
q = urllib.parse.parse_qs(url.query)
|
||
assert url.netloc == "crew.example.no" and url.path == "/oauth/authorize"
|
||
assert q["response_type"] == ["code"]
|
||
assert q["client_id"] == ["announcer"]
|
||
assert q["code_challenge_method"] == ["S256"]
|
||
assert q["redirect_uri"][0].endswith("/auth/callback")
|
||
assert len(q["code_challenge"][0]) == 43 # sha256, base64url uten padding
|
||
assert q["state"][0]
|
||
|
||
|
||
def test_oidc_hele_runden_gir_innlogget_sesjon(tmp_path, idp):
|
||
idp["claims"] = {"name": "Kari Nordmann", "roles": ["crew"]}
|
||
c, _ = build(tmp_path, OIDC_CFG)
|
||
q = urllib.parse.parse_qs(urllib.parse.urlparse(c.get("/auth/login").headers["Location"]).query)
|
||
r = c.get(f"/auth/callback?code=en-kode&state={q['state'][0]}")
|
||
assert r.status_code == 302 and r.headers["Location"] == "/"
|
||
|
||
body = c.get("/do/announce").get_json()
|
||
assert body["name"] == "Kari Nordmann"
|
||
assert body["roles"] == ["crew"]
|
||
assert body["subject"] == "oidc:142"
|
||
assert body["method"] == "oidc"
|
||
assert c.get("/do/clear").status_code == 403
|
||
|
||
# Kodebyttet: PKCE-verifier med, hemmeligheten som Basic-auth, ikke i body.
|
||
post = idp["posts"][0]
|
||
assert post["url"] == "https://crew.example.no/oauth/token"
|
||
assert post["form"]["grant_type"] == "authorization_code"
|
||
assert post["form"]["code"] == "en-kode"
|
||
assert len(post["form"]["code_verifier"]) > 40
|
||
assert "client_secret" not in post["form"]
|
||
assert post["headers"]["Authorization"].startswith("Basic ")
|
||
|
||
|
||
def test_oidc_callback_med_feil_state_avvises(tmp_path, idp):
|
||
"""Uten denne sjekken kan en angriper få deg til å logge inn som seg selv."""
|
||
c, _ = build(tmp_path, OIDC_CFG)
|
||
c.get("/auth/login")
|
||
r = c.get("/auth/callback?code=en-kode&state=tilfeldig-tull")
|
||
assert r.status_code == 400
|
||
assert c.get("/do/view").status_code == 401
|
||
|
||
|
||
def test_oidc_callback_uten_forutgaende_login_avvises(tmp_path, idp):
|
||
c, _ = build(tmp_path, OIDC_CFG)
|
||
assert c.get("/auth/callback?code=x&state=y").status_code == 400
|
||
|
||
|
||
def test_oidc_feil_fra_panelet_vises(tmp_path, idp):
|
||
c, _ = build(tmp_path, OIDC_CFG)
|
||
c.get("/auth/login")
|
||
r = c.get("/auth/callback?error=access_denied&error_description=Avvist+av+bruker")
|
||
assert r.status_code == 401
|
||
assert "access_denied" in r.get_json()["error"]
|
||
|
||
|
||
def test_oidc_id_token_for_en_annen_klient_avvises(tmp_path, idp, monkeypatch):
|
||
c, _ = build(tmp_path, OIDC_CFG)
|
||
monkeypatch.setattr(auth, "_post_json", lambda url, form, headers=None, timeout=10: {
|
||
"id_token": pyjwt.encode(
|
||
{"iss": "https://crew.example.no", "aud": "et-annet-system",
|
||
"exp": time.time() + 600, "sub": "1"},
|
||
"panel-hemmelighet-som-er-lang-nok-for-hmac-sha256", algorithm="HS256")})
|
||
q = urllib.parse.parse_qs(urllib.parse.urlparse(c.get("/auth/login").headers["Location"]).query)
|
||
assert c.get(f"/auth/callback?code=k&state={q['state'][0]}").status_code == 401
|
||
|
||
|
||
def test_oidc_state_kan_ikke_brukes_to_ganger(tmp_path, idp):
|
||
c, _ = build(tmp_path, OIDC_CFG)
|
||
q = urllib.parse.parse_qs(urllib.parse.urlparse(c.get("/auth/login").headers["Location"]).query)
|
||
state = q["state"][0]
|
||
assert c.get(f"/auth/callback?code=k&state={state}").status_code == 302
|
||
c.get("/auth/logout")
|
||
assert c.get(f"/auth/callback?code=k&state={state}").status_code == 400
|
||
|
||
|
||
def test_oidc_manuelle_endepunkter_hopper_over_discovery(tmp_path, monkeypatch):
|
||
"""Panelet har ikke .well-known ennå, så dette er veien dit først."""
|
||
def nei(*a, **kw):
|
||
raise AssertionError("skulle ikke slått opp discovery")
|
||
monkeypatch.setattr(auth, "_get_json", nei)
|
||
c, _ = build(tmp_path, OIDC_CFG + '''
|
||
authorization_endpoint = "https://crew.example.no/oauth/authorize"
|
||
token_endpoint = "https://crew.example.no/oauth/token"
|
||
''')
|
||
assert c.get("/auth/login").status_code == 302
|
||
|
||
|
||
def test_oidc_login_finnes_ikke_uten_oppsett(tmp_path):
|
||
c, _ = build(tmp_path, '[auth.pin]\npin = "1"\n')
|
||
assert c.get("/auth/login").status_code == 404
|
||
assert c.get("/auth/callback?code=x&state=y").status_code == 404
|
||
|
||
|
||
def test_public_url_styrer_redirect_uri(tmp_path, idp):
|
||
"""Bak en omvendt proxy må redirect_uri peke utover, ikke på localhost."""
|
||
c, _ = build(tmp_path, '[server]\npublic_url = "https://utrop.example.no"\n' + OIDC_CFG)
|
||
q = urllib.parse.parse_qs(urllib.parse.urlparse(c.get("/auth/login").headers["Location"]).query)
|
||
assert q["redirect_uri"] == ["https://utrop.example.no/auth/callback"]
|