Pluggbar autentisering: PIN, JWT og OIDC
Dagens «auth» var én delt PIN i et X-Pin-header, sammenlignet med !=, og
den sa ingenting om hvem som sendte et utrop – køen viste bare IP-adressen.
Crew har et eget adminpanel som skal bli identitetsleverandør.
Modellen er én datatype og én funksjon: Principal (hvem + roller +
rettigheter) og require(action), som enten gir deg den eller stopper
forespørselen. Handlinger: view, preview, announce, stop, clear.
Tre leverandører, prøvd i rekkefølge, alle valgfrie:
- pin X-Pin som før, men med hmac.compare_digest istedenfor ==. Rolle
admin som standard, så eksisterende oppsett oppfører seg likt.
Beholdt bevisst: den virker uten nett, og det er nettopp da man
trenger å rope ut noe.
- jwt Authorization: Bearer. HS256 med delt hemmelighet, eller
RS256/ES256 mot panelets JWKS. Krever exp – et token uten
utløpstid er en evig nøkkel. Roller fra et konfigurerbart claim,
som tåler liste, streng, kommaliste og punktsti.
- oidc Autorisasjonskode med PKCE. Discovery hentes først ved første
innlogging, så tjenesten starter selv om panelet er nede.
En leverandør er aktiv når den står i auth.providers *eller* når den er
ferdig konfigurert. Derfor slår ANNOUNCER_PIN=1234 alene fortsatt på PIN,
slik det har virket til nå. Er ingen aktiv, er alt åpent – som før.
/auth/token?t=<jwt> er broa mens panelet mangler OIDC: panelet lenker hit
med et kortlevd token, vi setter sesjonscookie. Ett klikk, ingen PIN.
Kontrakten panelet skal oppfylle står i docs/crew-integration.md.
401 betyr «vi vet ikke hvem du er, logg inn», 403 «vi vet hvem du er, men
rollen din får ikke dette». Et gyldig token uten roller gir 403, ikke 401 –
å logge inn på nytt ville ikke hjulpet.
Samtidig:
- /api/status er lukket bak «view». Den var åpen, og lekket både
meldingstekstene og IP-ene til alle som hadde sendt noe, til hvem som
helst som nådde porten. IP vises nå bare for den som har «clear»; alle
andre ser navnet.
- Køen og en revisjonslogg i logs/announcements.jsonl viser hvem som sendte
hva. Loggen feiler aldri forespørselen – en logg som ikke lar seg skrive
skal ikke hindre et utrop.
- Branding flyttet fra index.html til /api/config, så config.toml fra
forrige commit faktisk brukes.
- Sesjonsnøkkel i instance/secret_key, ellers logges alle ut ved hver
restart. SESSION_COOKIE_SECURE settes bare bak TLS: appen kjører på rå
HTTP over LAN og NetBird, og med Secure på ville ingen klart å logge inn.
57 tester, de fleste på auth, inkludert hele OIDC-runden mot en stubbet
leverandør. Det er delen 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.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
87066bb8c2
commit
d6eb811ee5
10 changed files with 2090 additions and 117 deletions
141
AGENTS.md
141
AGENTS.md
|
|
@ -1,39 +1,130 @@
|
||||||
# sg-announcer – utrop på høyttaleranlegget (Sauda Gathering)
|
# announcer – utrop på høyttaleranlegget
|
||||||
|
|
||||||
Liten Flask-app: tekst inn i nettleseren → forhåndslytt → «Send ut på
|
Liten Flask-app: tekst inn i nettleseren → forhåndslytt → «Send ut på anlegget»
|
||||||
anlegget» spiller lyden lokalt på **denne** maskinen med `pw-play`. TTS er
|
spiller lyden lokalt på **denne** maskinen med `pw-play`. TTS er edge-tts
|
||||||
Piper (`piper-tts`, onnx, helt lokalt). Alt ligger i to filer:
|
(nevrale stemmer, krever nett) med Piper som lokal reserve.
|
||||||
`announcer.py` (server + kø) og `index.html` (sida).
|
|
||||||
|
|
||||||
Dokumentasjon for Kent: Athena-notatet
|
Appen er generisk: tittel, undertittel, aksentfarge og hurtigfraser kommer fra
|
||||||
`30 Ressurser/Teknologi/Sauda Gathering - announcer.md`. Oppdater det, ikke
|
`config.toml`, ikke fra HTML-en. Den het `sg-announcer` og var skrevet for Sauda
|
||||||
denne fila, når noe som er verdt å huske endres.
|
Gathering fram til oktober 2026.
|
||||||
|
|
||||||
|
Dokumentasjon for Kent: Athena-notatet `30 Ressurser/Teknologi/Announcer.md`.
|
||||||
|
Oppdater det, ikke denne fila, når noe som er verdt å huske endres.
|
||||||
|
|
||||||
|
Filer:
|
||||||
|
|
||||||
|
| Fil | Hva |
|
||||||
|
|---|---|
|
||||||
|
| `announcer.py` | server, kø, TTS, jingler, ruter |
|
||||||
|
| `auth.py` | `Principal`, leverandørene, OIDC-rutene |
|
||||||
|
| `config.py` | `config.toml` + miljøvariabler, standardverdier |
|
||||||
|
| `index.html` | hele sida, inline CSS/JS, ingen byggesteg |
|
||||||
|
| `config.example.toml` | kommentert mal; kopier til `config.toml` |
|
||||||
|
| `docs/crew-integration.md` | kontrakten mot crew-panelet |
|
||||||
|
| `tests/test_auth.py` | `uv run pytest` |
|
||||||
|
|
||||||
## Kjøring
|
## Kjøring
|
||||||
|
|
||||||
Tjenesten er en systemd-brukerenhet: `~/.config/systemd/user/sg-announcer.service`
|
Tjenesten er en systemd-brukerenhet: `~/.config/systemd/user/announcer.service`
|
||||||
(enabled, port 8090). `systemctl --user restart sg-announcer` etter endringer
|
(enabled, port 8090). `systemctl --user restart announcer` etter endringer i
|
||||||
i koden. Manuelt: `uv run announcer.py --port 8090`.
|
koden. Manuelt: `uv run announcer.py --port 8090`.
|
||||||
|
|
||||||
Ny maskin (Debian/Ubuntu/Mint): `git clone https://git.1x1.no/kent/sg-announcer.git`
|
Ny maskin (Debian/Ubuntu/Mint): `git clone https://git.1x1.no/kent/announcer.git`
|
||||||
og `./install.sh` gjør alt (apt, uv, Python 3.13, stemmer, enhet, ufw). Finnes
|
og `./install.sh` gjør alt (apt, uv, Python 3.13, stemmer, enhet, ufw). Finnes
|
||||||
NetBird (`wt0`) skrur det på ufw med deny incoming og åpner 22 og porten kun
|
NetBird (`wt0`) skrur det på ufw med deny incoming og åpner 22, 22022 og porten
|
||||||
på `wt0`. Kan
|
kun på `wt0`. Kan kjøres på nytt, og rydder da bort en gammel
|
||||||
kjøres på nytt. Avspilling bruker `pw-play` når PipeWire kjører, ellers
|
`sg-announcer.service` hvis den ligger der. Avspilling bruker `pw-play` når
|
||||||
`paplay` (`player_cmd()`), så Mint 21 med PulseAudio virker også.
|
PipeWire kjører, ellers `paplay` (`player_cmd()`), så Mint 21 med PulseAudio
|
||||||
|
virker også.
|
||||||
|
|
||||||
Miljøvariabler (settes i enheten): `ANNOUNCER_PORT`, `ANNOUNCER_PIN` (tom =
|
### Omdøpingen: ett punkt gjenstår
|
||||||
ingen PIN), `ANNOUNCER_SINK` (pw-play `--target`; tom = standard utgang).
|
|
||||||
|
Mappa er flyttet til `~/Work/announcer`, enheten heter `announcer.service`, og
|
||||||
|
remote peker på `https://git.1x1.no/kent/announcer.git`. **Men repoet i Forgejo
|
||||||
|
heter `annuoncer`** – en skrivefeil. Rett navnet i Forgejo, så virker remote;
|
||||||
|
Forgejo legger selv inn en omdirigering fra det gamle navnet.
|
||||||
|
|
||||||
|
Flytter du mappa igjen: `.venv` har absolutte stier i `pyvenv.cfg` og i
|
||||||
|
shebangen til hvert konsollskript, så `uv sync` alene er ikke nok. Da må
|
||||||
|
`rm -rf .venv && uv sync` til, og tjenesten startes om.
|
||||||
|
|
||||||
|
|
||||||
|
## Konfigurasjon
|
||||||
|
|
||||||
|
Tre lag, siste vinner: `DEFAULTS` i `config.py` → `config.toml` →
|
||||||
|
`ANNOUNCER_*`-miljøvariabler. **Uten `config.toml` virker alt**, med de gamle
|
||||||
|
standardverdiene og uten innlogging; det er med vilje, og
|
||||||
|
`test_uten_konfig_er_alt_apent` passer på det.
|
||||||
|
|
||||||
|
`config.toml` er gitignorert. `config.example.toml` er malen, og den er
|
||||||
|
kommentert – les den framfor å gjette.
|
||||||
|
|
||||||
|
Hemmeligheter settes som miljøvariabler i enheten, ikke i `config.toml`:
|
||||||
|
`ANNOUNCER_PIN`, `ANNOUNCER_JWT_SECRET`, `ANNOUNCER_OIDC_CLIENT_SECRET`,
|
||||||
|
`ANNOUNCER_SECRET_KEY`. Hele tabellen over variabler står i `ENV` i `config.py`;
|
||||||
|
den er eneste sted en ny variabel legges til.
|
||||||
|
|
||||||
|
## Autentisering
|
||||||
|
|
||||||
|
Modellen er én datatype og én funksjon: `Principal` (hvem + roller +
|
||||||
|
rettigheter) og `require(action)` som enten gir deg den eller stopper
|
||||||
|
forespørselen. Handlingene er `view`, `preview`, `announce`, `stop`, `clear`.
|
||||||
|
|
||||||
|
Tre leverandører, prøvd i rekkefølge, alle valgfrie:
|
||||||
|
|
||||||
|
- **pin** – `X-Pin`-header, som før. `hmac.compare_digest`, ikke `==`.
|
||||||
|
Får rollen `admin` som standard, så gammelt oppsett oppfører seg som før.
|
||||||
|
Virker uten nett, derfor beholdt.
|
||||||
|
- **jwt** – `Authorization: Bearer <jwt>` fra crew-panelet. HS256 med delt
|
||||||
|
hemmelighet, eller RS256/ES256 mot panelets JWKS (PyJWT cacher nøklene).
|
||||||
|
Krever `exp`; roller leses av et konfigurerbart claim.
|
||||||
|
- **oidc** – autorisasjonskode med PKCE. `/auth/login` → panelet →
|
||||||
|
`/auth/callback`. Discovery hentes først ved første innlogging, så tjenesten
|
||||||
|
starter selv om panelet er nede.
|
||||||
|
|
||||||
|
**En leverandør er aktiv når den står i `auth.providers` *eller* når den er
|
||||||
|
ferdig konfigurert.** Derfor slår `ANNOUNCER_PIN=1234` alene fortsatt på PIN.
|
||||||
|
Er ingen aktiv, er alt åpent.
|
||||||
|
|
||||||
|
`/auth/token?t=<jwt>` er broa mens panelet mangler OIDC: panelet lenker hit med
|
||||||
|
et kortlevd token, announcer setter sesjonscookie. Se
|
||||||
|
`docs/crew-integration.md` – det er kontrakten panelet skal oppfylle.
|
||||||
|
|
||||||
|
Roller → handlinger står i `[roles]` i `config.toml`. `"*"` betyr alt.
|
||||||
|
Rettighetene slås opp på nytt per forespørsel, så en endring der slår inn uten
|
||||||
|
at noen må logge inn igjen; nye `[auth.*]`-verdier krever omstart.
|
||||||
|
|
||||||
|
401 betyr «vi vet ikke hvem du er, logg inn», 403 betyr «vi vet hvem du er, men
|
||||||
|
rollen din får ikke dette». Et gyldig token uten roller gir 403, ikke 401 – å
|
||||||
|
logge inn på nytt ville ikke hjulpet.
|
||||||
|
|
||||||
|
Sesjonsnøkkelen genereres én gang og lagres i `instance/secret_key` (0600).
|
||||||
|
Uten den logges alle ut ved hver `systemctl restart`. `SESSION_COOKIE_SECURE`
|
||||||
|
settes bare når `server.behind_tls` er sann – appen kjører på rå HTTP over LAN
|
||||||
|
og NetBird, og med Secure på ville cookien aldri blitt sendt.
|
||||||
|
|
||||||
## Hvordan det henger sammen
|
## Hvordan det henger sammen
|
||||||
|
|
||||||
|
- `/api/config` er alt sida trenger for å tegne seg selv: branding, stemmer,
|
||||||
|
jingler og en `auth`-blokk med hvem du er og hva du får gjøre. Svarer alltid
|
||||||
|
200, også med feil PIN – klienten spør «hvem er jeg?», og «ikke innlogget» er
|
||||||
|
et svar, ikke en feil. Route-funksjonen heter `client_config()`; navnet
|
||||||
|
`config` er tatt av modulen.
|
||||||
- `/api/preview` syntetiserer og returnerer WAV til nettleseren. Ingen lyd på PC-en.
|
- `/api/preview` syntetiserer og returnerer WAV til nettleseren. Ingen lyd på PC-en.
|
||||||
- `/api/announce` legger meldingen i én FIFO-kø; en bakgrunnstråd spiller én
|
- `/api/announce` legger meldingen i én FIFO-kø; en bakgrunnstråd spiller én om
|
||||||
om gangen med `pw-play`. `/api/stop` dreper gjeldende avspilling, `/api/clear`
|
gangen med `pw-play`. `/api/stop` dreper gjeldende avspilling, `/api/clear`
|
||||||
tømmer køen.
|
tømmer køen.
|
||||||
|
- `/api/status` krever `view`. Den var åpen før, og lekket både meldingstekster
|
||||||
|
og IP-ene til dem som hadde sendt noe. IP vises nå bare for den som har
|
||||||
|
`clear`; alle andre ser navnet.
|
||||||
|
- `/` er åpen uansett, ellers kunne ingen komme til innloggingsknappen.
|
||||||
- Knappen «Send ut» er låst til nøyaktig den teksten/stemmen/hastigheten som
|
- Knappen «Send ut» er låst til nøyaktig den teksten/stemmen/hastigheten som
|
||||||
sist ble forhåndslyttet. Det er med vilje: ingen skal sende noe de ikke har hørt.
|
sist ble forhåndslyttet. Det er med vilje: ingen skal sende noe de ikke har hørt.
|
||||||
- Ferdige WAV-er bufres i `cache/` etter innholdshash. Trygt å slette.
|
- Ferdige WAV-er bufres i `cache/` etter innholdshash. Trygt å slette.
|
||||||
|
- `audit()` skriver én JSON-linje per hendelse til `logs/announcements.jsonl`
|
||||||
|
(`announce`, `played`, `stop`, `clear`) med navn, subject, metode og IP.
|
||||||
|
Rullerer til `.jsonl.1` ved 5 MB. Den feiler aldri forespørselen – en logg
|
||||||
|
som ikke lar seg skrive skal ikke hindre et utrop.
|
||||||
- Jingler: de innebygde (`BUILTIN_CHIMES`) genereres i Python med `_tone`/
|
- Jingler: de innebygde (`BUILTIN_CHIMES`) genereres i Python med `_tone`/
|
||||||
`_mix`/`_overlay`, ingen lydfiler. Egne filer i `chimes/` (wav/mp3/ogg/flac)
|
`_mix`/`_overlay`, ingen lydfiler. Egne filer i `chimes/` (wav/mp3/ogg/flac)
|
||||||
dukker opp i menyen som `file:<stem>`; ffmpeg kutter til 6 s, normaliserer
|
dukker opp i menyen som `file:<stem>`; ffmpeg kutter til 6 s, normaliserer
|
||||||
|
|
@ -65,6 +156,16 @@ Vil Kent ha enda bedre lyd: ElevenLabs (nøkkel, best norsk) eller OpenAI
|
||||||
åpnes eksplisitt for LAN-et (`sudo ufw allow from 10.20.70.0/23 to any port 8090 proto tcp`).
|
åpnes eksplisitt for LAN-et (`sudo ufw allow from 10.20.70.0/23 to any port 8090 proto tcp`).
|
||||||
- `pkill -f announcer.py` fra et skall som selv har `announcer.py` i
|
- `pkill -f announcer.py` fra et skall som selv har `announcer.py` i
|
||||||
kommandolinja dreper skallet. Bruk `systemctl --user`.
|
kommandolinja dreper skallet. Bruk `systemctl --user`.
|
||||||
|
- `player = Player()` står på modulnivå, så avspillingstråden starter ved
|
||||||
|
`import announcer` – også i `install.sh`, som importerer modulen for å laste
|
||||||
|
ned stemmer. Harmløst, men greit å vite.
|
||||||
|
- `tests/conftest.py` nuller ut alle `ANNOUNCER_*`-variabler før hver test.
|
||||||
|
Uten den arver testene maskinens miljø – systemd-enheten setter
|
||||||
|
`ANNOUNCER_PORT` – og man får røde tester med feilmeldinger som ikke peker
|
||||||
|
på noe galt.
|
||||||
|
- `pyproject.toml` har `[tool.uv] package = false`. Appen er løse `.py`-filer
|
||||||
|
som kjøres på plass, ikke en pakke; uten den vil `uv sync` bygge et wheel og
|
||||||
|
klage på at `announcer/__init__.py` mangler.
|
||||||
- Piper 1.8 bruker `SynthesisConfig(length_scale=…)`; lavere = raskere.
|
- Piper 1.8 bruker `SynthesisConfig(length_scale=…)`; lavere = raskere.
|
||||||
- Forhåndslytt svarer 502 med feilmelding hvis edge-tts feiler (ingen nett).
|
- Forhåndslytt svarer 502 med feilmelding hvis edge-tts feiler (ingen nett).
|
||||||
Køen markerer meldingen som `failed`. Bytt til en lokal reserve-stemme.
|
Køen markerer meldingen som `failed`. Bytt til en lokal reserve-stemme.
|
||||||
|
|
|
||||||
183
announcer.py
183
announcer.py
|
|
@ -1,16 +1,19 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Announcer – tekst inn, forhåndslytt i nettleseren, les ut på PC-ens lydutgang.
|
"""Announcer – tekst inn, forhåndslytt i nettleseren, les ut på PC-ens lydutgang.
|
||||||
|
|
||||||
Kjøres på PC-en som er koblet til høyttaleranlegget. Alle på nettet kan åpne
|
Kjøres på PC-en som er koblet til høyttaleranlegget. Selve avspillingen skjer
|
||||||
sida og sende meldinger; selve avspillingen skjer alltid lokalt på denne
|
alltid lokalt på denne maskinen via pw-play (PipeWire) eller paplay.
|
||||||
maskinen via pw-play (PipeWire) eller paplay.
|
|
||||||
|
Tittel, tekst og hurtigfraser kommer fra config.py, så appen ikke er bundet til
|
||||||
|
ett arrangement. Innlogging er valgfri og settes opp i auth.py; uten
|
||||||
|
konfigurasjon er alt åpent, som før.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import hashlib
|
import hashlib
|
||||||
import io
|
import json
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
|
|
@ -26,12 +29,17 @@ from pathlib import Path
|
||||||
|
|
||||||
from flask import Flask, Response, abort, jsonify, request, send_file
|
from flask import Flask, Response, abort, jsonify, request, send_file
|
||||||
|
|
||||||
|
import auth
|
||||||
|
import config
|
||||||
|
from auth import require
|
||||||
|
|
||||||
HERE = Path(__file__).resolve().parent
|
HERE = Path(__file__).resolve().parent
|
||||||
VOICE_DIR = Path(os.environ.get("ANNOUNCER_VOICES", HERE / "voices"))
|
CFG = config.load()
|
||||||
CACHE_DIR = Path(os.environ.get("ANNOUNCER_CACHE", HERE / "cache"))
|
VOICE_DIR = CFG.path("voices")
|
||||||
PIN = os.environ.get("ANNOUNCER_PIN", "").strip()
|
CACHE_DIR = CFG.path("cache")
|
||||||
SINK = os.environ.get("ANNOUNCER_SINK", "").strip() # pw-play --target
|
LOG_PATH = CFG.path("log")
|
||||||
MAX_CHARS = 600
|
SINK = str(CFG.get("audio", "sink", "")).strip() # pw-play --target
|
||||||
|
MAX_CHARS = int(CFG.app["max_chars"])
|
||||||
|
|
||||||
# Stemmene som tilbys i menyen. To motorer:
|
# Stemmene som tilbys i menyen. To motorer:
|
||||||
# edge – Microsofts nevrale stemmer via edge-tts (gratis, krever internett,
|
# edge – Microsofts nevrale stemmer via edge-tts (gratis, krever internett,
|
||||||
|
|
@ -65,11 +73,16 @@ VOICES_LIST: list[dict] = [
|
||||||
_piper("en-jenny", "Jenny (UK)", "Lokal reserve (uten nett)", "en_GB-jenny_dioco-medium"),
|
_piper("en-jenny", "Jenny (UK)", "Lokal reserve (uten nett)", "en_GB-jenny_dioco-medium"),
|
||||||
]
|
]
|
||||||
VOICES: dict[str, dict] = {v["id"]: v for v in VOICES_LIST}
|
VOICES: dict[str, dict] = {v["id"]: v for v in VOICES_LIST}
|
||||||
DEFAULT_VOICE = "finn"
|
|
||||||
EDGE_RATE = 24000
|
EDGE_RATE = 24000
|
||||||
|
|
||||||
|
# Konfigurerte standardvalg, men bare hvis de finnes – en skrivefeil i
|
||||||
|
# config.toml skal ikke gjøre sida ubrukelig.
|
||||||
|
DEFAULT_VOICE = CFG.app["default_voice"] if CFG.app["default_voice"] in VOICES else "finn"
|
||||||
|
DEFAULT_CHIME = CFG.app["default_chime"]
|
||||||
|
|
||||||
log = logging.getLogger("announcer")
|
log = logging.getLogger("announcer")
|
||||||
app = Flask(__name__, static_folder=None)
|
app = Flask(__name__, static_folder=None)
|
||||||
|
AUTH = auth.install(app, CFG)
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
|
|
@ -104,7 +117,7 @@ def load_voice(name: str):
|
||||||
# (wav/mp3/ogg/flac) i CHIME_DIR dukker opp automatisk i menyen.
|
# (wav/mp3/ogg/flac) i CHIME_DIR dukker opp automatisk i menyen.
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
CHIME_DIR = Path(os.environ.get("ANNOUNCER_CHIMES", HERE / "chimes"))
|
CHIME_DIR = CFG.path("chimes")
|
||||||
|
|
||||||
|
|
||||||
def _tone(rate: int, freq: float, dur: float, vol: float = 0.35, wave_: str = "sine",
|
def _tone(rate: int, freq: float, dur: float, vol: float = 0.35, wave_: str = "sine",
|
||||||
|
|
@ -305,17 +318,23 @@ class Item:
|
||||||
voice: str
|
voice: str
|
||||||
speed: float
|
speed: float
|
||||||
chime: str
|
chime: str
|
||||||
sender: str
|
sender: str # IP-adressen forespørselen kom fra
|
||||||
|
user: str = "" # visningsnavnet til den som sendte
|
||||||
|
subject: str = "" # stabil id, f.eks. "jwt:kent"
|
||||||
|
method: str = "none"
|
||||||
created: float = field(default_factory=time.time)
|
created: float = field(default_factory=time.time)
|
||||||
status: str = "queued" # queued | playing | done | stopped | failed
|
status: str = "queued" # queued | playing | done | stopped | failed
|
||||||
error: str = ""
|
error: str = ""
|
||||||
|
|
||||||
def public(self) -> dict:
|
def public(self, *, show_ip: bool = False) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": self.id,
|
"id": self.id,
|
||||||
"text": self.text,
|
"text": self.text,
|
||||||
"voice": VOICES[self.voice]["name"] if self.voice in VOICES else self.voice,
|
"voice": VOICES[self.voice]["name"] if self.voice in VOICES else self.voice,
|
||||||
"sender": self.sender,
|
"user": self.user,
|
||||||
|
# IP-en er bare interessant for en admin som skal finne ut hvem
|
||||||
|
# som rotet; alle andre ser navnet.
|
||||||
|
"sender": self.sender if show_ip else "",
|
||||||
"created": self.created,
|
"created": self.created,
|
||||||
"status": self.status,
|
"status": self.status,
|
||||||
"error": self.error,
|
"error": self.error,
|
||||||
|
|
@ -348,10 +367,16 @@ class Player:
|
||||||
self.seq = 0
|
self.seq = 0
|
||||||
threading.Thread(target=self._loop, daemon=True, name="player").start()
|
threading.Thread(target=self._loop, daemon=True, name="player").start()
|
||||||
|
|
||||||
def enqueue(self, text: str, voice: str, speed: float, chime: str, sender: str) -> Item:
|
def enqueue(self, text: str, voice: str, speed: float, chime: str, sender: str,
|
||||||
|
who: auth.Principal | None = None) -> Item:
|
||||||
with self.lock:
|
with self.lock:
|
||||||
self.seq += 1
|
self.seq += 1
|
||||||
item = Item(self.seq, text, voice, speed, chime, sender)
|
item = Item(
|
||||||
|
self.seq, text, voice, speed, chime, sender,
|
||||||
|
user=who.name if who else "",
|
||||||
|
subject=who.subject if who else "",
|
||||||
|
method=who.method if who else "none",
|
||||||
|
)
|
||||||
self.history.append(item)
|
self.history.append(item)
|
||||||
del self.history[:-50]
|
del self.history[:-50]
|
||||||
self.q.put(item)
|
self.q.put(item)
|
||||||
|
|
@ -373,12 +398,13 @@ class Player:
|
||||||
item.status = "stopped"
|
item.status = "stopped"
|
||||||
self.stop()
|
self.stop()
|
||||||
|
|
||||||
def snapshot(self) -> dict:
|
def snapshot(self, *, show_ip: bool = False) -> dict:
|
||||||
with self.lock:
|
with self.lock:
|
||||||
return {
|
return {
|
||||||
"current": self.current.public() if self.current else None,
|
"current": self.current.public(show_ip=show_ip) if self.current else None,
|
||||||
"queued": [i.public() for i in self.history if i.status == "queued"],
|
"queued": [i.public(show_ip=show_ip) for i in self.history if i.status == "queued"],
|
||||||
"history": [i.public() for i in reversed(self.history) if i.status not in ("queued", "playing")][:15],
|
"history": [i.public(show_ip=show_ip) for i in reversed(self.history)
|
||||||
|
if i.status not in ("queued", "playing")][:15],
|
||||||
}
|
}
|
||||||
|
|
||||||
def _loop(self) -> None:
|
def _loop(self) -> None:
|
||||||
|
|
@ -409,6 +435,9 @@ class Player:
|
||||||
with self.lock:
|
with self.lock:
|
||||||
self.current = None
|
self.current = None
|
||||||
self.proc = None
|
self.proc = None
|
||||||
|
audit("played", id=item.id, status=item.status, error=item.error,
|
||||||
|
subject=item.subject, name=item.user, method=item.method,
|
||||||
|
ip=item.sender, text=item.text, voice=item.voice)
|
||||||
|
|
||||||
|
|
||||||
player = Player()
|
player = Player()
|
||||||
|
|
@ -419,9 +448,34 @@ player = Player()
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def check_pin() -> None:
|
MAX_LOG_BYTES = 5_000_000
|
||||||
if PIN and request.headers.get("X-Pin", "") != PIN:
|
_log_lock = threading.Lock()
|
||||||
abort(401)
|
|
||||||
|
|
||||||
|
def audit(action: str, **fields) -> None:
|
||||||
|
"""Én JSON-linje per hendelse, så man i ettertid kan se hvem som sendte hva.
|
||||||
|
|
||||||
|
Append-only og rullerer ved 5 MB. Feiler aldri forespørselen – en logg som
|
||||||
|
ikke lar seg skrive skal ikke hindre et utrop.
|
||||||
|
"""
|
||||||
|
rec = {"ts": round(time.time(), 3), "action": action,
|
||||||
|
**{k: v for k, v in fields.items() if v not in ("", None)}}
|
||||||
|
line = json.dumps(rec, ensure_ascii=False)
|
||||||
|
try:
|
||||||
|
with _log_lock:
|
||||||
|
LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if LOG_PATH.exists() and LOG_PATH.stat().st_size > MAX_LOG_BYTES:
|
||||||
|
LOG_PATH.replace(LOG_PATH.with_name(LOG_PATH.name + ".1"))
|
||||||
|
with LOG_PATH.open("a", encoding="utf-8") as fh:
|
||||||
|
fh.write(line + "\n")
|
||||||
|
except OSError as e:
|
||||||
|
log.warning("fikk ikke skrevet til %s: %s", LOG_PATH, e)
|
||||||
|
|
||||||
|
|
||||||
|
def who(p: auth.Principal) -> dict:
|
||||||
|
"""Identitetsfeltene audit() vil ha."""
|
||||||
|
return {"subject": p.subject, "name": p.name, "method": p.method,
|
||||||
|
"ip": request.remote_addr or ""}
|
||||||
|
|
||||||
|
|
||||||
def parse_request() -> tuple[str, str, float, str]:
|
def parse_request() -> tuple[str, str, float, str]:
|
||||||
|
|
@ -439,9 +493,9 @@ def parse_request() -> tuple[str, str, float, str]:
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
abort(400, "Ugyldig hastighet")
|
abort(400, "Ugyldig hastighet")
|
||||||
speed = max(0.6, min(1.6, speed))
|
speed = max(0.6, min(1.6, speed))
|
||||||
chime = data.get("chime", "dingdong")
|
chime = data.get("chime", DEFAULT_CHIME)
|
||||||
if chime is True:
|
if chime is True:
|
||||||
chime = "dingdong"
|
chime = DEFAULT_CHIME
|
||||||
elif chime is False or chime is None:
|
elif chime is False or chime is None:
|
||||||
chime = "none"
|
chime = "none"
|
||||||
chime = str(chime)
|
chime = str(chime)
|
||||||
|
|
@ -456,58 +510,86 @@ def index() -> Response:
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/config")
|
@app.get("/api/config")
|
||||||
def config() -> Response:
|
def client_config() -> Response:
|
||||||
|
"""Alt sida trenger for å tegne seg selv: branding, menyer og hvem du er.
|
||||||
|
|
||||||
|
Svarer alltid 200, også med feil PIN – her spør klienten «hvem er jeg?»,
|
||||||
|
og da er svaret «ikke innlogget», ikke en feil.
|
||||||
|
"""
|
||||||
|
me = auth.current()
|
||||||
return jsonify(
|
return jsonify(
|
||||||
{
|
{
|
||||||
|
"app": {
|
||||||
|
"title": CFG.app["title"],
|
||||||
|
"subtitle": CFG.app["subtitle"],
|
||||||
|
"accent": CFG.app["accent"],
|
||||||
|
"idle_text": CFG.app["idle_text"],
|
||||||
|
"message_placeholder": CFG.app["message_placeholder"],
|
||||||
|
"quick_phrases": list(CFG.app["quick_phrases"]),
|
||||||
|
},
|
||||||
"voices": [{"id": v["id"], "name": v["name"], "group": v["group"], "engine": v["engine"]} for v in VOICES_LIST],
|
"voices": [{"id": v["id"], "name": v["name"], "group": v["group"], "engine": v["engine"]} for v in VOICES_LIST],
|
||||||
"default_voice": DEFAULT_VOICE,
|
"default_voice": DEFAULT_VOICE,
|
||||||
"chimes": list_chimes(),
|
"chimes": list_chimes(),
|
||||||
"default_chime": "dingdong",
|
"default_chime": DEFAULT_CHIME,
|
||||||
"max_chars": MAX_CHARS,
|
"max_chars": MAX_CHARS,
|
||||||
"pin_required": bool(PIN),
|
"auth": {
|
||||||
|
**AUTH.describe(),
|
||||||
|
"user": None if me.anonymous else me.public(),
|
||||||
|
"can": sorted(me.permissions),
|
||||||
|
"error": auth.error(),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/preview")
|
@app.post("/api/preview")
|
||||||
def preview() -> Response:
|
def preview() -> Response:
|
||||||
check_pin()
|
me = require("preview")
|
||||||
text, voice, speed, chime = parse_request()
|
text, voice, speed, chime = parse_request()
|
||||||
try:
|
try:
|
||||||
path = synthesize(text, voice, speed, chime)
|
path = synthesize(text, voice, speed, chime)
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
log.exception("syntese feilet")
|
log.exception("syntese feilet")
|
||||||
|
audit("preview", **who(me), text=text, voice=voice, error=str(e)[:200])
|
||||||
abort(502, f"Syntese feilet ({VOICES[voice]['name']}): {e}. Prøv en lokal reserve-stemme.")
|
abort(502, f"Syntese feilet ({VOICES[voice]['name']}): {e}. Prøv en lokal reserve-stemme.")
|
||||||
|
audit("preview", **who(me), text=text, voice=voice)
|
||||||
return send_file(path, mimetype="audio/wav", max_age=3600)
|
return send_file(path, mimetype="audio/wav", max_age=3600)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/announce")
|
@app.post("/api/announce")
|
||||||
def announce() -> Response:
|
def announce() -> Response:
|
||||||
check_pin()
|
me = require("announce")
|
||||||
text, voice, speed, chime = parse_request()
|
text, voice, speed, chime = parse_request()
|
||||||
sender = request.remote_addr or "?"
|
sender = request.remote_addr or "?"
|
||||||
item = player.enqueue(text, voice, speed, chime, sender)
|
item = player.enqueue(text, voice, speed, chime, sender, me)
|
||||||
log.info("kø #%d fra %s: %s", item.id, sender, text)
|
log.info("kø #%d fra %s (%s): %s", item.id, item.user or sender, sender, text)
|
||||||
return jsonify(item.public())
|
audit("announce", **who(me), id=item.id, text=text, voice=voice,
|
||||||
|
speed=speed, chime=chime)
|
||||||
|
return jsonify(item.public(show_ip=me.can("clear")))
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/stop")
|
@app.post("/api/stop")
|
||||||
def stop() -> Response:
|
def stop() -> Response:
|
||||||
check_pin()
|
me = require("stop")
|
||||||
player.stop()
|
player.stop()
|
||||||
|
audit("stop", **who(me))
|
||||||
return jsonify({"ok": True})
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/clear")
|
@app.post("/api/clear")
|
||||||
def clear() -> Response:
|
def clear() -> Response:
|
||||||
check_pin()
|
me = require("clear")
|
||||||
player.clear()
|
player.clear()
|
||||||
|
audit("clear", **who(me))
|
||||||
return jsonify({"ok": True})
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/status")
|
@app.get("/api/status")
|
||||||
def status() -> Response:
|
def status() -> Response:
|
||||||
return jsonify(player.snapshot())
|
# Lukket bak «view»: køen inneholder meldingstekstene og IP-ene til dem
|
||||||
|
# som har sendt noe, og det er ikke noe alle utenfra skal få se.
|
||||||
|
me = require("view")
|
||||||
|
return jsonify(player.snapshot(show_ip=me.can("clear")))
|
||||||
|
|
||||||
|
|
||||||
@app.errorhandler(400)
|
@app.errorhandler(400)
|
||||||
|
|
@ -522,19 +604,40 @@ def bad_gateway(e): # noqa: ANN001
|
||||||
|
|
||||||
@app.errorhandler(401)
|
@app.errorhandler(401)
|
||||||
def unauthorized(e): # noqa: ANN001
|
def unauthorized(e): # noqa: ANN001
|
||||||
return jsonify({"error": "Feil PIN"}), 401
|
return jsonify({"error": e.description, "login": True}), 401
|
||||||
|
|
||||||
|
|
||||||
|
@app.errorhandler(403)
|
||||||
|
def forbidden(e): # noqa: ANN001
|
||||||
|
return jsonify({"error": e.description}), 403
|
||||||
|
|
||||||
|
|
||||||
|
@app.errorhandler(404)
|
||||||
|
def not_found(e): # noqa: ANN001
|
||||||
|
# Klienten snakker JSON med /api og /auth, og skal ikke få en HTML-side
|
||||||
|
# tilbake og tolke den som en feilmelding.
|
||||||
|
if request.path.startswith(("/api/", "/auth/")):
|
||||||
|
msg = e.description or ""
|
||||||
|
if not msg or "requested URL" in msg:
|
||||||
|
msg = "Finnes ikke"
|
||||||
|
return jsonify({"error": msg}), 404
|
||||||
|
return e
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||||
ap.add_argument("--host", default="0.0.0.0")
|
ap.add_argument("--host", default=CFG.get("server", "host", "0.0.0.0"))
|
||||||
ap.add_argument("--port", type=int, default=int(os.environ.get("ANNOUNCER_PORT", 8090)))
|
ap.add_argument("--port", type=int, default=int(CFG.get("server", "port", 8090)))
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||||
logging.getLogger("werkzeug").setLevel(logging.WARNING)
|
logging.getLogger("werkzeug").setLevel(logging.WARNING)
|
||||||
ensure_voices()
|
ensure_voices()
|
||||||
load_voice("no_NO-talesyntese-medium") # varm opp reserven
|
load_voice("no_NO-talesyntese-medium") # varm opp reserven
|
||||||
log.info("klar på http://%s:%d (PIN: %s, sink: %s)", args.host, args.port, "på" if PIN else "av", SINK or "standard")
|
if CFG.source:
|
||||||
|
log.info("konfigurasjon fra %s", CFG.source)
|
||||||
|
methods = ", ".join(m["method"] for m in AUTH.describe()["methods"]) or "åpen (ingen innlogging)"
|
||||||
|
log.info("klar på http://%s:%d (auth: %s, sink: %s)",
|
||||||
|
args.host, args.port, methods, SINK or "standard")
|
||||||
app.run(host=args.host, port=args.port, threaded=True)
|
app.run(host=args.host, port=args.port, threaded=True)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
642
auth.py
Normal file
642
auth.py
Normal file
|
|
@ -0,0 +1,642 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Pluggbar autentisering for announcer.
|
||||||
|
|
||||||
|
Hele modellen er én datatype og én funksjon:
|
||||||
|
|
||||||
|
Principal – hvem dette er, hvilke roller, hva rollene får gjøre
|
||||||
|
require(action) – hent den, eller stopp forespørselen med 401/403
|
||||||
|
|
||||||
|
Rundt det ligger leverandører som prøves i rekkefølge. Hver av dem svarer på
|
||||||
|
én forespørsel med «dette er brukeren», «jeg fant ingen legitimasjon av min
|
||||||
|
type» (None) eller AuthError («fant noe, men det var ugyldig»):
|
||||||
|
|
||||||
|
pin – X-Pin-header, én delt kode. Virker uten nett, derfor beholdt.
|
||||||
|
jwt – Authorization: Bearer <jwt> fra crew-panelet. HS256 med delt
|
||||||
|
hemmelighet, eller RS256/ES256 mot panelets JWKS.
|
||||||
|
oidc – autorisasjonskode med PKCE mot crew-panelet; nettleseren logger
|
||||||
|
inn der og får en sesjonscookie her.
|
||||||
|
|
||||||
|
Er ingen leverandør konfigurert, er alt åpent – nøyaktig som før. Det er med
|
||||||
|
vilje: tjenesten skal kunne settes opp på en kveld uten identitetsleverandør.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import logging
|
||||||
|
import json
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from flask import Blueprint, abort, g, redirect, request, session, url_for
|
||||||
|
|
||||||
|
from config import ACTIONS, Config
|
||||||
|
|
||||||
|
log = logging.getLogger("announcer.auth")
|
||||||
|
|
||||||
|
SESSION_KEY = "principal"
|
||||||
|
NAME_CLAIMS = ("name", "preferred_username", "nickname", "given_name", "email")
|
||||||
|
|
||||||
|
|
||||||
|
class AuthError(Exception):
|
||||||
|
"""Legitimasjon fulgte med, men den holdt ikke."""
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Principal
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Principal:
|
||||||
|
subject: str # stabil id: "pin", "jwt:4f2a", "oidc:kent"
|
||||||
|
name: str # vises i køen og i loggen
|
||||||
|
roles: tuple[str, ...]
|
||||||
|
method: str # none | anonymous | pin | jwt | oidc
|
||||||
|
permissions: frozenset[str]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def anonymous(self) -> bool:
|
||||||
|
return self.method in ("none", "anonymous")
|
||||||
|
|
||||||
|
def can(self, action: str) -> bool:
|
||||||
|
return action in self.permissions
|
||||||
|
|
||||||
|
def public(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"subject": self.subject,
|
||||||
|
"name": self.name,
|
||||||
|
"roles": list(self.roles),
|
||||||
|
"method": self.method,
|
||||||
|
"can": sorted(self.permissions),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _claim(claims: dict, path: str) -> Any:
|
||||||
|
"""Henter et claim, også nestet: "realm_access.roles"."""
|
||||||
|
cur: Any = claims
|
||||||
|
for part in path.split("."):
|
||||||
|
if not isinstance(cur, dict):
|
||||||
|
return None
|
||||||
|
cur = cur.get(part)
|
||||||
|
return cur
|
||||||
|
|
||||||
|
|
||||||
|
def _roles(value: Any) -> tuple[str, ...]:
|
||||||
|
"""Roller kommer som liste, streng eller mellomromsdelt streng."""
|
||||||
|
if isinstance(value, str):
|
||||||
|
return tuple(v for v in value.replace(",", " ").split() if v)
|
||||||
|
if isinstance(value, (list, tuple, set)):
|
||||||
|
return tuple(str(v).strip() for v in value if str(v).strip())
|
||||||
|
return ()
|
||||||
|
|
||||||
|
|
||||||
|
def _display_name(claims: dict, name_claim: str) -> str:
|
||||||
|
if name_claim:
|
||||||
|
value = _claim(claims, name_claim)
|
||||||
|
if value:
|
||||||
|
return str(value)
|
||||||
|
for key in NAME_CLAIMS:
|
||||||
|
if claims.get(key):
|
||||||
|
return str(claims[key])
|
||||||
|
return str(claims.get("sub") or "ukjent")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# JWT-verifisering, delt av jwt- og oidc-leverandøren
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TokenVerifier:
|
||||||
|
"""Verifiserer signatur, iss, aud og exp. JWKS caches av PyJWT selv."""
|
||||||
|
|
||||||
|
def __init__(self, section: dict[str, Any]) -> None:
|
||||||
|
self.issuer = str(section.get("issuer", "")).strip()
|
||||||
|
self.audience = str(section.get("audience", "")).strip()
|
||||||
|
self.secret = str(section.get("secret", "")).strip()
|
||||||
|
self.jwks_url = str(section.get("jwks_url", "")).strip()
|
||||||
|
self.leeway = int(section.get("leeway", 30))
|
||||||
|
self.verify_signature = bool(section.get("verify_signature", True))
|
||||||
|
# Tom liste: utled av hvordan nøkkelen kommer. Delt hemmelighet er
|
||||||
|
# symmetrisk (HS256), JWKS er asymmetrisk (RS256/ES256).
|
||||||
|
algs = [str(a) for a in section.get("algorithms") or []]
|
||||||
|
self.algorithms = algs or (["RS256", "ES256"] if self.jwks_url else ["HS256"])
|
||||||
|
self._jwks: Any = None
|
||||||
|
|
||||||
|
def _signing_key(self, token: str) -> Any:
|
||||||
|
if not self.jwks_url:
|
||||||
|
return self.secret
|
||||||
|
if self._jwks is None:
|
||||||
|
import jwt # lazy: PIN-oppsett skal ikke trenge pyjwt
|
||||||
|
|
||||||
|
self._jwks = jwt.PyJWKClient(self.jwks_url, cache_keys=True, lifespan=3600)
|
||||||
|
return self._jwks.get_signing_key_from_jwt(token).key
|
||||||
|
|
||||||
|
def verify(self, token: str) -> dict:
|
||||||
|
import jwt
|
||||||
|
|
||||||
|
if not self.verify_signature:
|
||||||
|
# Bare for et crew-panel bak en proxy som alt har validert tokenet.
|
||||||
|
return jwt.decode(token, options={"verify_signature": False})
|
||||||
|
if not (self.secret or self.jwks_url):
|
||||||
|
raise AuthError("JWT er ikke konfigurert")
|
||||||
|
try:
|
||||||
|
return jwt.decode(
|
||||||
|
token,
|
||||||
|
self._signing_key(token),
|
||||||
|
algorithms=self.algorithms,
|
||||||
|
audience=self.audience or None,
|
||||||
|
issuer=self.issuer or None,
|
||||||
|
leeway=self.leeway,
|
||||||
|
options={
|
||||||
|
"verify_aud": bool(self.audience),
|
||||||
|
"verify_iss": bool(self.issuer),
|
||||||
|
"require": ["exp"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except jwt.ExpiredSignatureError as e:
|
||||||
|
raise AuthError("Tokenet er utløpt") from e
|
||||||
|
except jwt.InvalidAudienceError as e:
|
||||||
|
raise AuthError("Tokenet gjelder en annen tjeneste") from e
|
||||||
|
except jwt.InvalidTokenError as e:
|
||||||
|
raise AuthError(f"Ugyldig token ({e})") from e
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
raise AuthError(f"Fikk ikke hentet nøkler fra {self.jwks_url} ({e})") from e
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Leverandører
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class Provider:
|
||||||
|
name = ""
|
||||||
|
|
||||||
|
def authenticate(self, req) -> Principal | None: # noqa: ANN001
|
||||||
|
return None
|
||||||
|
|
||||||
|
def describe(self) -> dict[str, Any]:
|
||||||
|
return {"method": self.name}
|
||||||
|
|
||||||
|
|
||||||
|
class PinProvider(Provider):
|
||||||
|
name = "pin"
|
||||||
|
|
||||||
|
def __init__(self, cfg: Config) -> None:
|
||||||
|
s = cfg.section("auth.pin")
|
||||||
|
self.pin = str(s.get("pin", "")).strip()
|
||||||
|
self.role = str(s.get("role") or "admin")
|
||||||
|
self.label = str(s.get("label") or "PIN")
|
||||||
|
self.hint = str(s.get("hint", ""))
|
||||||
|
self.cfg = cfg
|
||||||
|
|
||||||
|
def authenticate(self, req) -> Principal | None: # noqa: ANN001
|
||||||
|
given = req.headers.get("X-Pin", "").strip()
|
||||||
|
if not given:
|
||||||
|
auth = req.headers.get("Authorization", "")
|
||||||
|
if auth[:4].lower() == "pin ":
|
||||||
|
given = auth[4:].strip()
|
||||||
|
if not given:
|
||||||
|
return None
|
||||||
|
# compare_digest, ikke ==, så svartiden ikke lekker hvor langt man kom.
|
||||||
|
if not hmac.compare_digest(given, self.pin):
|
||||||
|
raise AuthError("Feil PIN")
|
||||||
|
roles = (self.role,)
|
||||||
|
# En delt kode peker ikke på en person, og skal ikke late som. Vil man
|
||||||
|
# vite hvem som sendte hva, er det JWT eller OIDC man vil ha.
|
||||||
|
return Principal("pin", "delt PIN", roles, "pin", self.cfg.permissions(roles))
|
||||||
|
|
||||||
|
def describe(self) -> dict[str, Any]:
|
||||||
|
return {"method": "pin", "label": self.label, "hint": self.hint}
|
||||||
|
|
||||||
|
|
||||||
|
class JwtProvider(Provider):
|
||||||
|
name = "jwt"
|
||||||
|
|
||||||
|
def __init__(self, cfg: Config) -> None:
|
||||||
|
s = cfg.section("auth.jwt")
|
||||||
|
self.cfg = cfg
|
||||||
|
self.verifier = TokenVerifier(s)
|
||||||
|
self.role_claim = str(s.get("role_claim") or "roles")
|
||||||
|
self.name_claim = str(s.get("name_claim", ""))
|
||||||
|
self.default_role = str(s.get("default_role", ""))
|
||||||
|
self.session_seconds = int(s.get("session_seconds", 43200))
|
||||||
|
|
||||||
|
def principal(self, claims: dict, method: str = "jwt") -> Principal:
|
||||||
|
roles = _roles(_claim(claims, self.role_claim))
|
||||||
|
if not roles and self.default_role:
|
||||||
|
roles = (self.default_role,)
|
||||||
|
sub = str(claims.get("sub") or claims.get("email") or "ukjent")
|
||||||
|
return Principal(
|
||||||
|
f"{method}:{sub}",
|
||||||
|
_display_name(claims, self.name_claim),
|
||||||
|
roles,
|
||||||
|
method,
|
||||||
|
self.cfg.permissions(roles),
|
||||||
|
)
|
||||||
|
|
||||||
|
def authenticate(self, req) -> Principal | None: # noqa: ANN001
|
||||||
|
auth = req.headers.get("Authorization", "")
|
||||||
|
if auth[:7].lower() != "bearer ":
|
||||||
|
return None
|
||||||
|
token = auth[7:].strip()
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
return self.principal(self.verifier.verify(token))
|
||||||
|
|
||||||
|
|
||||||
|
class OidcProvider(Provider):
|
||||||
|
"""Autorisasjonskode med PKCE. Innloggingen skjer i nettleseren, så
|
||||||
|
authenticate() finner aldri noe i en header – sesjonen tar seg av resten."""
|
||||||
|
|
||||||
|
name = "oidc"
|
||||||
|
|
||||||
|
def __init__(self, cfg: Config) -> None:
|
||||||
|
s = cfg.section("auth.oidc")
|
||||||
|
self.cfg = cfg
|
||||||
|
self.issuer = str(s.get("issuer", "")).strip().rstrip("/")
|
||||||
|
self.client_id = str(s.get("client_id", "")).strip()
|
||||||
|
self.client_secret = str(s.get("client_secret", "")).strip()
|
||||||
|
self.scopes = [str(x) for x in s.get("scopes") or ["openid"]]
|
||||||
|
self.label = str(s.get("label") or "Logg inn")
|
||||||
|
self.role_claim = str(s.get("role_claim") or "roles")
|
||||||
|
self.name_claim = str(s.get("name_claim", ""))
|
||||||
|
self.default_role = str(s.get("default_role", ""))
|
||||||
|
self._manual = {
|
||||||
|
"authorization_endpoint": str(s.get("authorization_endpoint", "")).strip(),
|
||||||
|
"token_endpoint": str(s.get("token_endpoint", "")).strip(),
|
||||||
|
"jwks_uri": str(s.get("jwks_url", "")).strip(),
|
||||||
|
"end_session_endpoint": str(s.get("end_session_endpoint", "")).strip(),
|
||||||
|
}
|
||||||
|
self._meta: dict[str, Any] | None = None
|
||||||
|
self._verifier: TokenVerifier | None = None
|
||||||
|
|
||||||
|
# -- oppdagelse -------------------------------------------------------
|
||||||
|
def metadata(self) -> dict[str, Any]:
|
||||||
|
"""Discovery hentes først ved første innlogging, aldri ved oppstart –
|
||||||
|
tjenesten skal starte selv om crew-panelet er nede."""
|
||||||
|
if self._meta is not None:
|
||||||
|
return self._meta
|
||||||
|
meta = {k: v for k, v in self._manual.items() if v}
|
||||||
|
if not (meta.get("authorization_endpoint") and meta.get("token_endpoint")):
|
||||||
|
url = f"{self.issuer}/.well-known/openid-configuration"
|
||||||
|
try:
|
||||||
|
fetched = _get_json(url)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
raise AuthError(f"Fikk ikke hentet OIDC-oppsett fra {url} ({e})") from e
|
||||||
|
meta = {**fetched, **meta}
|
||||||
|
self._meta = meta
|
||||||
|
return meta
|
||||||
|
|
||||||
|
def verifier(self) -> TokenVerifier:
|
||||||
|
if self._verifier is None:
|
||||||
|
meta = self.metadata()
|
||||||
|
self._verifier = TokenVerifier(
|
||||||
|
{
|
||||||
|
"issuer": meta.get("issuer") or self.issuer,
|
||||||
|
"audience": self.client_id,
|
||||||
|
"secret": self.client_secret,
|
||||||
|
"jwks_url": meta.get("jwks_uri", ""),
|
||||||
|
"algorithms": [],
|
||||||
|
"verify_signature": bool(
|
||||||
|
self.cfg.get("auth.oidc", "verify_signature", True)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return self._verifier
|
||||||
|
|
||||||
|
# -- flyten -----------------------------------------------------------
|
||||||
|
def redirect_uri(self) -> str:
|
||||||
|
base = str(self.cfg.get("server", "public_url", "")).strip().rstrip("/")
|
||||||
|
if base:
|
||||||
|
return f"{base}/auth/callback"
|
||||||
|
return url_for("auth.callback", _external=True)
|
||||||
|
|
||||||
|
def authorize_url(self) -> str:
|
||||||
|
verifier = secrets.token_urlsafe(64)
|
||||||
|
state = secrets.token_urlsafe(24)
|
||||||
|
challenge = (
|
||||||
|
base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest())
|
||||||
|
.rstrip(b"=")
|
||||||
|
.decode()
|
||||||
|
)
|
||||||
|
session["oidc_state"] = state
|
||||||
|
session["oidc_verifier"] = verifier
|
||||||
|
params = {
|
||||||
|
"response_type": "code",
|
||||||
|
"client_id": self.client_id,
|
||||||
|
"redirect_uri": self.redirect_uri(),
|
||||||
|
"scope": " ".join(self.scopes),
|
||||||
|
"state": state,
|
||||||
|
"code_challenge": challenge,
|
||||||
|
"code_challenge_method": "S256",
|
||||||
|
}
|
||||||
|
endpoint = self.metadata()["authorization_endpoint"]
|
||||||
|
sep = "&" if "?" in endpoint else "?"
|
||||||
|
return f"{endpoint}{sep}{urllib.parse.urlencode(params)}"
|
||||||
|
|
||||||
|
def exchange(self, code: str, verifier: str) -> dict:
|
||||||
|
data = {
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"code": code,
|
||||||
|
"redirect_uri": self.redirect_uri(),
|
||||||
|
"client_id": self.client_id,
|
||||||
|
"code_verifier": verifier,
|
||||||
|
}
|
||||||
|
headers = {}
|
||||||
|
if self.client_secret:
|
||||||
|
basic = base64.b64encode(
|
||||||
|
f"{self.client_id}:{self.client_secret}".encode()
|
||||||
|
).decode()
|
||||||
|
headers["Authorization"] = f"Basic {basic}"
|
||||||
|
try:
|
||||||
|
return _post_json(self.metadata()["token_endpoint"], data, headers)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
raise AuthError(f"Kodebyttet feilet ({e})") from e
|
||||||
|
|
||||||
|
def principal(self, claims: dict) -> Principal:
|
||||||
|
roles = _roles(_claim(claims, self.role_claim))
|
||||||
|
if not roles and self.default_role:
|
||||||
|
roles = (self.default_role,)
|
||||||
|
sub = str(claims.get("sub") or claims.get("email") or "ukjent")
|
||||||
|
return Principal(
|
||||||
|
f"oidc:{sub}",
|
||||||
|
_display_name(claims, self.name_claim),
|
||||||
|
roles,
|
||||||
|
"oidc",
|
||||||
|
self.cfg.permissions(roles),
|
||||||
|
)
|
||||||
|
|
||||||
|
def describe(self) -> dict[str, Any]:
|
||||||
|
return {"method": "oidc", "label": self.label, "login_url": "/auth/login"}
|
||||||
|
|
||||||
|
|
||||||
|
PROVIDERS = {"pin": PinProvider, "jwt": JwtProvider, "oidc": OidcProvider}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# HTTP-hjelpere (stdlib, så vi slipper en ekstra avhengighet)
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _get_json(url: str, timeout: int = 10) -> dict:
|
||||||
|
req = urllib.request.Request(url, headers={"Accept": "application/json"})
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as r: # noqa: S310
|
||||||
|
return json.loads(r.read().decode())
|
||||||
|
|
||||||
|
|
||||||
|
def _post_json(url: str, form: dict, headers: dict | None = None, timeout: int = 10) -> dict:
|
||||||
|
body = urllib.parse.urlencode(form).encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
data=body,
|
||||||
|
headers={
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
**(headers or {}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as r: # noqa: S310
|
||||||
|
return json.loads(r.read().decode())
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Authenticator
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class Authenticator:
|
||||||
|
def __init__(self, cfg: Config) -> None:
|
||||||
|
self.cfg = cfg
|
||||||
|
self.providers: list[Provider] = [
|
||||||
|
PROVIDERS[name](cfg) for name in cfg.active_providers()
|
||||||
|
]
|
||||||
|
self.enabled = bool(self.providers)
|
||||||
|
|
||||||
|
def provider(self, name: str) -> Provider | None:
|
||||||
|
return next((p for p in self.providers if p.name == name), None)
|
||||||
|
|
||||||
|
# -- anonym -----------------------------------------------------------
|
||||||
|
def anonymous(self) -> Principal:
|
||||||
|
"""Uten konfigurerte leverandører får anonyme alt – dagens oppførsel.
|
||||||
|
|
||||||
|
Med leverandører får de det [roles].anonymous sier, som er ingenting
|
||||||
|
som standard. Sett den til ["view"] om statusskjermen skal henge på
|
||||||
|
veggen uten innlogging."""
|
||||||
|
if not self.enabled:
|
||||||
|
# Tomt navn: da står køen og loggen som før, uten en «bruker» som
|
||||||
|
# i praksis bare betyr «hvem som helst».
|
||||||
|
return Principal("anonymous", "", ("anonymous",), "none", frozenset(ACTIONS))
|
||||||
|
roles = ("anonymous",)
|
||||||
|
return Principal("anonymous", "ikke innlogget", roles, "anonymous", self.cfg.permissions(roles))
|
||||||
|
|
||||||
|
# -- sesjon -----------------------------------------------------------
|
||||||
|
def from_session(self) -> Principal | None:
|
||||||
|
raw = session.get(SESSION_KEY)
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return None
|
||||||
|
if raw.get("exp") and float(raw["exp"]) < time.time():
|
||||||
|
session.pop(SESSION_KEY, None)
|
||||||
|
return None
|
||||||
|
roles = tuple(str(r) for r in raw.get("roles", ()))
|
||||||
|
return Principal(
|
||||||
|
str(raw.get("subject", "?")),
|
||||||
|
str(raw.get("name", "?")),
|
||||||
|
roles,
|
||||||
|
str(raw.get("method", "jwt")),
|
||||||
|
# Rettigheter slås opp på nytt hver gang, så en endring i
|
||||||
|
# [roles] slår inn uten at folk må logge inn igjen.
|
||||||
|
self.cfg.permissions(roles),
|
||||||
|
)
|
||||||
|
|
||||||
|
def remember(self, p: Principal, seconds: int) -> None:
|
||||||
|
session.permanent = True
|
||||||
|
session[SESSION_KEY] = {
|
||||||
|
"subject": p.subject,
|
||||||
|
"name": p.name,
|
||||||
|
"roles": list(p.roles),
|
||||||
|
"method": p.method,
|
||||||
|
"exp": time.time() + seconds,
|
||||||
|
}
|
||||||
|
|
||||||
|
# -- oppslag ----------------------------------------------------------
|
||||||
|
def resolve(self) -> tuple[Principal, str]:
|
||||||
|
"""Returnerer (principal, feilmelding). Kaster ikke – /api/config skal
|
||||||
|
kunne spørre «hvem er jeg?» uten å få 401 i fleisen."""
|
||||||
|
p = self.from_session()
|
||||||
|
if p is not None:
|
||||||
|
return p, ""
|
||||||
|
for provider in self.providers:
|
||||||
|
try:
|
||||||
|
p = provider.authenticate(request)
|
||||||
|
except AuthError as e:
|
||||||
|
return self.anonymous(), str(e)
|
||||||
|
if p is not None:
|
||||||
|
return p, ""
|
||||||
|
return self.anonymous(), ""
|
||||||
|
|
||||||
|
def describe(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"enabled": self.enabled,
|
||||||
|
"methods": [p.describe() for p in self.providers],
|
||||||
|
"logout_url": "/auth/logout" if self.enabled else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_auth: Authenticator | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def current() -> Principal:
|
||||||
|
"""Principalen for denne forespørselen, slått opp én gang."""
|
||||||
|
if not hasattr(g, "principal"):
|
||||||
|
assert _auth is not None, "auth.install() er ikke kalt"
|
||||||
|
g.principal, g.auth_error = _auth.resolve()
|
||||||
|
return g.principal
|
||||||
|
|
||||||
|
|
||||||
|
def error() -> str:
|
||||||
|
current()
|
||||||
|
return getattr(g, "auth_error", "")
|
||||||
|
|
||||||
|
|
||||||
|
def require(action: str) -> Principal:
|
||||||
|
"""Enten har denne brukeren lov, eller så stopper forespørselen her.
|
||||||
|
|
||||||
|
401 når vi ikke vet hvem det er (klienten skal logge inn), 403 når vi vet
|
||||||
|
hvem det er men rollen mangler handlingen.
|
||||||
|
"""
|
||||||
|
p = current()
|
||||||
|
if p.can(action):
|
||||||
|
return p
|
||||||
|
if p.anonymous:
|
||||||
|
abort(401, error() or _login_hint())
|
||||||
|
abort(403, f"Rollen din ({', '.join(p.roles) or 'ingen'}) har ikke lov til dette.")
|
||||||
|
|
||||||
|
|
||||||
|
def _login_hint() -> str:
|
||||||
|
assert _auth is not None
|
||||||
|
names = {p.name for p in _auth.providers}
|
||||||
|
if names == {"pin"}:
|
||||||
|
return "Feil eller manglende PIN."
|
||||||
|
if "oidc" in names:
|
||||||
|
return "Du må logge inn."
|
||||||
|
return "Innlogging kreves."
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Oppsett og ruter
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def secret_key(cfg: Config) -> bytes:
|
||||||
|
"""Fast nøkkel, ellers logges alle ut ved hver restart av tjenesten."""
|
||||||
|
configured = str(cfg.get("server", "secret_key", "")).strip()
|
||||||
|
if configured:
|
||||||
|
return configured.encode()
|
||||||
|
path = cfg.path("state") / "secret_key"
|
||||||
|
if path.is_file():
|
||||||
|
return path.read_bytes()
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
key = secrets.token_bytes(32)
|
||||||
|
path.write_bytes(key)
|
||||||
|
path.chmod(0o600)
|
||||||
|
log.info("lagde ny sesjonsnøkkel i %s", path)
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
bp = Blueprint("auth", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/auth/login")
|
||||||
|
def login():
|
||||||
|
oidc = _auth.provider("oidc") if _auth else None
|
||||||
|
if not isinstance(oidc, OidcProvider):
|
||||||
|
abort(404, "OIDC er ikke satt opp")
|
||||||
|
try:
|
||||||
|
return redirect(oidc.authorize_url())
|
||||||
|
except AuthError as e:
|
||||||
|
abort(502, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/auth/callback")
|
||||||
|
def callback():
|
||||||
|
oidc = _auth.provider("oidc") if _auth else None
|
||||||
|
if not isinstance(oidc, OidcProvider):
|
||||||
|
abort(404, "OIDC er ikke satt opp")
|
||||||
|
if err := request.args.get("error"):
|
||||||
|
abort(401, f"{err}: {request.args.get('error_description', '')}".strip(": "))
|
||||||
|
state = session.pop("oidc_state", None)
|
||||||
|
verifier = session.pop("oidc_verifier", None)
|
||||||
|
if not state or not verifier or request.args.get("state") != state:
|
||||||
|
abort(400, "Innloggingen kom ut av takt. Prøv på nytt.")
|
||||||
|
code = request.args.get("code", "")
|
||||||
|
if not code:
|
||||||
|
abort(400, "Ingen kode fra identitetsleverandøren")
|
||||||
|
try:
|
||||||
|
tokens = oidc.exchange(code, verifier)
|
||||||
|
id_token = tokens.get("id_token")
|
||||||
|
if not id_token:
|
||||||
|
abort(502, "Fikk ikke id_token fra identitetsleverandøren")
|
||||||
|
claims = oidc.verifier().verify(id_token)
|
||||||
|
except AuthError as e:
|
||||||
|
abort(401, str(e))
|
||||||
|
p = oidc.principal(claims)
|
||||||
|
ttl = int(claims.get("exp", 0) - time.time()) if claims.get("exp") else 0
|
||||||
|
_auth.remember(p, max(ttl, 3600))
|
||||||
|
log.info("innlogget %s (%s) via oidc", p.name, p.subject)
|
||||||
|
return redirect("/")
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/auth/token")
|
||||||
|
def token_login():
|
||||||
|
"""Bro mens crew-panelet mangler OIDC: panelet lenker hit med et kortlevd
|
||||||
|
JWT, vi verifiserer det og setter sesjonscookien. Ett klikk, ingen PIN."""
|
||||||
|
provider = _auth.provider("jwt") if _auth else None
|
||||||
|
if not isinstance(provider, JwtProvider):
|
||||||
|
abort(404, "JWT er ikke satt opp")
|
||||||
|
raw = request.args.get("t") or request.args.get("token") or ""
|
||||||
|
if not raw.strip():
|
||||||
|
abort(400, "Mangler token")
|
||||||
|
try:
|
||||||
|
claims = provider.verifier.verify(raw.strip())
|
||||||
|
except AuthError as e:
|
||||||
|
abort(401, str(e))
|
||||||
|
p = provider.principal(claims)
|
||||||
|
ttl = int(claims.get("exp", 0) - time.time()) if claims.get("exp") else 0
|
||||||
|
_auth.remember(p, max(ttl, provider.session_seconds))
|
||||||
|
log.info("innlogget %s (%s) via token-lenke", p.name, p.subject)
|
||||||
|
return redirect("/")
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/auth/logout")
|
||||||
|
def logout():
|
||||||
|
session.clear()
|
||||||
|
oidc = _auth.provider("oidc") if _auth else None
|
||||||
|
if isinstance(oidc, OidcProvider) and oidc._meta:
|
||||||
|
if end := oidc._meta.get("end_session_endpoint"):
|
||||||
|
return redirect(end)
|
||||||
|
return redirect("/")
|
||||||
|
|
||||||
|
|
||||||
|
def install(app, cfg: Config) -> Authenticator: # noqa: ANN001
|
||||||
|
"""Kobler autentisering på Flask-appen. Returnerer Authenticatoren."""
|
||||||
|
global _auth
|
||||||
|
_auth = Authenticator(cfg)
|
||||||
|
app.secret_key = secret_key(cfg)
|
||||||
|
app.config.update(
|
||||||
|
SESSION_COOKIE_HTTPONLY=True,
|
||||||
|
SESSION_COOKIE_SAMESITE="Lax",
|
||||||
|
# Secure bare bak TLS: på rå HTTP over LAN/NetBird ville cookien
|
||||||
|
# aldri blitt sendt, og ingen ville klart å logge inn.
|
||||||
|
SESSION_COOKIE_SECURE=bool(cfg.get("server", "behind_tls", False)),
|
||||||
|
)
|
||||||
|
app.register_blueprint(bp)
|
||||||
|
return _auth
|
||||||
204
docs/crew-integration.md
Normal file
204
docs/crew-integration.md
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
# Koble announcer til crew-panelet
|
||||||
|
|
||||||
|
Announcer har ingen brukerdatabase og skal ikke ha det. Den stoler på
|
||||||
|
crew-panelet og leser identitet og roller ut av det panelet sier. Dette
|
||||||
|
dokumentet er kontrakten: hva panelet må levere, og i hvilken rekkefølge det er
|
||||||
|
verdt å bygge det.
|
||||||
|
|
||||||
|
Panelet har i dag verken JWT eller OIDC. **Bygg JWT først** – det er noen titalls
|
||||||
|
linjer i panelet og dekker behovet. OIDC kan komme etterpå uten at noe i
|
||||||
|
announcer må skrives om; begge veier ender i samme kode.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Trinn 1: JWT (anbefalt start)
|
||||||
|
|
||||||
|
Panelet signerer et kortlevd token som sier hvem brukeren er og hvilke roller
|
||||||
|
han har. Announcer verifiserer signaturen og stoler på innholdet.
|
||||||
|
|
||||||
|
### Hva tokenet skal inneholde
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"iss": "https://crew.example.no",
|
||||||
|
"aud": "announcer",
|
||||||
|
"sub": "142",
|
||||||
|
"name": "Kari Nordmann",
|
||||||
|
"roles": ["crew"],
|
||||||
|
"exp": 1760000000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Claim | Krav | Merknad |
|
||||||
|
|---|---|---|
|
||||||
|
| `iss` | må stemme med `auth.jwt.issuer` | sjekkes bare hvis den er satt i config |
|
||||||
|
| `aud` | må stemme med `auth.jwt.audience` | bruk `"announcer"` |
|
||||||
|
| `sub` | påkrevd i praksis | stabil bruker-id; havner i loggen som `jwt:<sub>` |
|
||||||
|
| `exp` | **påkrevd** | announcer avviser tokens uten utløpstid |
|
||||||
|
| `name` | valgfri | vises i køen. Faller tilbake på `preferred_username`, `email`, så `sub` |
|
||||||
|
| `roles` | valgfri | se under |
|
||||||
|
|
||||||
|
`roles` kan være en liste (`["crew"]`), en streng (`"crew"`), mellomroms- eller
|
||||||
|
kommadelt (`"crew admin"`), og den kan ligge nestet – da peker du på den med
|
||||||
|
`role_claim = "realm_access.roles"`. Mangler den helt, får brukeren
|
||||||
|
`auth.jwt.default_role`, eller ingen rettigheter hvis den ikke er satt.
|
||||||
|
|
||||||
|
Rollenavnene betyr ingenting for announcer i seg selv; `[roles]` i `config.toml`
|
||||||
|
bestemmer hva de får lov til.
|
||||||
|
|
||||||
|
### Signering
|
||||||
|
|
||||||
|
To varianter. Begge virker, den andre er bedre:
|
||||||
|
|
||||||
|
**HS256 med delt hemmelighet** – enklest. Samme streng i panelet og i
|
||||||
|
announcer. Ulempen er at announcer dermed har en nøkkel som *kan lage* gyldige
|
||||||
|
tokens.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[auth.jwt]
|
||||||
|
issuer = "https://crew.example.no"
|
||||||
|
audience = "announcer"
|
||||||
|
# settes som ANNOUNCER_JWT_SECRET i systemd-enheten, ikke her
|
||||||
|
```
|
||||||
|
|
||||||
|
**RS256 med JWKS** – panelet signerer med sin private nøkkel og publiserer den
|
||||||
|
offentlige på `/.well-known/jwks.json`. Announcer henter den og cacher den en
|
||||||
|
time. Da kan announcer bare verifisere, aldri signere, og nøkkelrullering
|
||||||
|
skjer av seg selv.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[auth.jwt]
|
||||||
|
issuer = "https://crew.example.no"
|
||||||
|
audience = "announcer"
|
||||||
|
jwks_url = "https://crew.example.no/.well-known/jwks.json"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hvordan brukeren kommer inn
|
||||||
|
|
||||||
|
To måter, og panelet kan bruke begge:
|
||||||
|
|
||||||
|
**Lenke (det du vil ha for et crew-medlem).** Panelet lager en knapp «Åpne
|
||||||
|
announcer» som peker på
|
||||||
|
|
||||||
|
```
|
||||||
|
http://<announcer>:8090/auth/token?t=<jwt>
|
||||||
|
```
|
||||||
|
|
||||||
|
Announcer verifiserer tokenet, setter en sesjonscookie og sender brukeren til
|
||||||
|
forsida. Ett klikk, ingen PIN å huske. Lag tokenet kortlevd (`exp` et par
|
||||||
|
minutter fram) – det er bare en billett inn; sesjonen varer så lenge tokenets
|
||||||
|
`exp` sier, eller `session_seconds` hvis det er lengre.
|
||||||
|
|
||||||
|
**Header (for skript og integrasjoner).** Send tokenet på hver forespørsel:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://announcer:8090/api/announce \
|
||||||
|
-H "Authorization: Bearer $JWT" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"text":"Testmelding","voice":"finn"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Minimum i panelet (Python-eksempel)
|
||||||
|
|
||||||
|
```python
|
||||||
|
import time, jwt # pyjwt
|
||||||
|
|
||||||
|
def announcer_url(user) -> str:
|
||||||
|
token = jwt.encode(
|
||||||
|
{
|
||||||
|
"iss": "https://crew.example.no",
|
||||||
|
"aud": "announcer",
|
||||||
|
"sub": str(user.id),
|
||||||
|
"name": user.full_name,
|
||||||
|
"roles": ["admin" if user.is_crew_lead else "crew"],
|
||||||
|
"exp": time.time() + 120,
|
||||||
|
},
|
||||||
|
ANNOUNCER_JWT_SECRET,
|
||||||
|
algorithm="HS256",
|
||||||
|
)
|
||||||
|
return f"http://announcer.lan:8090/auth/token?t={token}"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Trinn 2: OIDC
|
||||||
|
|
||||||
|
Når panelet har fått `authorize`-, `token`- og gjerne `userinfo`-endepunkter,
|
||||||
|
kan announcer bli en vanlig OIDC-klient. Da logger brukeren inn i panelet i
|
||||||
|
nettleseren og trenger ingen lenke med token i.
|
||||||
|
|
||||||
|
Announcer bruker autorisasjonskode med PKCE (S256). Dette må registreres i
|
||||||
|
panelet:
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| `client_id` | f.eks. `announcer` |
|
||||||
|
| `redirect_uri` | `http://<vert>:8090/auth/callback` – **eksakt**, inkludert port |
|
||||||
|
| `grant_type` | `authorization_code` |
|
||||||
|
| `scope` | `openid profile email` (eller bare `openid`) |
|
||||||
|
| PKCE | `S256` |
|
||||||
|
|
||||||
|
Announcer leser `{issuer}/.well-known/openid-configuration` ved første
|
||||||
|
innlogging. Har ikke panelet discovery, settes endepunktene direkte:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[auth.oidc]
|
||||||
|
issuer = "https://crew.example.no"
|
||||||
|
client_id = "announcer"
|
||||||
|
authorization_endpoint = "https://crew.example.no/oauth/authorize"
|
||||||
|
token_endpoint = "https://crew.example.no/oauth/token"
|
||||||
|
jwks_url = "https://crew.example.no/.well-known/jwks.json"
|
||||||
|
```
|
||||||
|
|
||||||
|
`client_secret` settes som `ANNOUNCER_OIDC_CLIENT_SECRET`. Rollene leses ut av
|
||||||
|
`id_token` på samme måte som over.
|
||||||
|
|
||||||
|
Står announcer bak en omvendt proxy, sett `server.public_url` så
|
||||||
|
`redirect_uri` blir riktig, og `server.behind_tls = true`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Roller og rettigheter
|
||||||
|
|
||||||
|
Fem handlinger. `config.toml` kobler rollenavn til dem:
|
||||||
|
|
||||||
|
| Handling | Hva den gir |
|
||||||
|
|---|---|
|
||||||
|
| `view` | se køen og hva som spilles nå |
|
||||||
|
| `preview` | forhåndslytte (koster en syntese) |
|
||||||
|
| `announce` | sende ut på anlegget |
|
||||||
|
| `stop` | stoppe det som spilles |
|
||||||
|
| `clear` | tømme køen (og se IP-adresser i køen) |
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[roles]
|
||||||
|
anonymous = [] # ["view"] for en statusskjerm
|
||||||
|
crew = ["view", "preview", "announce"]
|
||||||
|
admin = ["*"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Endrer du `[roles]` slår det inn ved neste forespørsel – ingen må logge inn på
|
||||||
|
nytt. (Nye `[auth.*]`-verdier krever omstart.)
|
||||||
|
|
||||||
|
## Hva announcer svarer
|
||||||
|
|
||||||
|
| Kode | Betyr | Hva klienten skal gjøre |
|
||||||
|
|---|---|---|
|
||||||
|
| 401 | vi vet ikke hvem du er | logg inn |
|
||||||
|
| 403 | vi vet hvem du er, rollen din får ikke dette | ikke prøv igjen |
|
||||||
|
|
||||||
|
`GET /api/config` svarer alltid 200 og forteller i `auth`-blokka hvilke metoder
|
||||||
|
som er aktive, hvem du er og hva du får gjøre. Det er den sida bruker for å
|
||||||
|
vise eller skjule knapper.
|
||||||
|
|
||||||
|
## Loggen
|
||||||
|
|
||||||
|
Hvert utrop havner i `logs/announcements.jsonl`, én JSON-linje per hendelse:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"ts":1760000000.12,"action":"announce","subject":"jwt:142","name":"Kari Nordmann",
|
||||||
|
"method":"jwt","ip":"10.20.70.55","id":7,"text":"Pizzaen er kommet","voice":"finn"}
|
||||||
|
```
|
||||||
|
|
||||||
|
`action` er `announce`, `played`, `stop` eller `clear`. Fila rullerer til
|
||||||
|
`.jsonl.1` ved 5 MB.
|
||||||
230
index.html
230
index.html
|
|
@ -17,6 +17,11 @@
|
||||||
h1 { font-size: 22px; margin: 0 0 4px; letter-spacing: .02em; }
|
h1 { font-size: 22px; margin: 0 0 4px; letter-spacing: .02em; }
|
||||||
h1 span { color: var(--accent); }
|
h1 span { color: var(--accent); }
|
||||||
.sub { color: var(--muted); margin: 0 0 20px; font-size: 14px; }
|
.sub { color: var(--muted); margin: 0 0 20px; font-size: 14px; }
|
||||||
|
.top { display: flex; gap: 12px; align-items: flex-start; justify-content: space-between; }
|
||||||
|
.who { font-size: 13px; color: var(--muted); text-align: right; white-space: nowrap; padding-top: 2px; }
|
||||||
|
.who b { color: var(--fg); font-weight: 600; }
|
||||||
|
.who a { color: var(--accent); text-decoration: none; }
|
||||||
|
.who a:hover { text-decoration: underline; }
|
||||||
.card { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 16px; margin-bottom: 16px; }
|
.card { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 16px; margin-bottom: 16px; }
|
||||||
label { display: block; font-size: 13px; color: var(--muted); margin-bottom: 6px; text-transform: uppercase; letter-spacing: .06em; }
|
label { display: block; font-size: 13px; color: var(--muted); margin-bottom: 6px; text-transform: uppercase; letter-spacing: .06em; }
|
||||||
textarea { width: 100%; min-height: 120px; resize: vertical; background: var(--panel2); color: var(--fg);
|
textarea { width: 100%; min-height: 120px; resize: vertical; background: var(--panel2); color: var(--fg);
|
||||||
|
|
@ -27,8 +32,6 @@
|
||||||
.row > div { flex: 1 1 160px; }
|
.row > div { flex: 1 1 160px; }
|
||||||
select, input[type=range] { width: 100%; }
|
select, input[type=range] { width: 100%; }
|
||||||
select { background: var(--panel2); color: var(--fg); border: 1px solid var(--line); border-radius: 8px; padding: 10px; font: inherit; }
|
select { background: var(--panel2); color: var(--fg); border: 1px solid var(--line); border-radius: 8px; padding: 10px; font: inherit; }
|
||||||
.check { display: flex; align-items: center; gap: 8px; padding: 10px 0; font-size: 15px; }
|
|
||||||
.check input { width: 18px; height: 18px; accent-color: var(--accent); }
|
|
||||||
.chips { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; }
|
.chips { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; }
|
||||||
.chip { background: var(--panel2); border: 1px solid var(--line); color: var(--fg); border-radius: 999px;
|
.chip { background: var(--panel2); border: 1px solid var(--line); color: var(--fg); border-radius: 999px;
|
||||||
padding: 6px 12px; font-size: 13px; cursor: pointer; }
|
padding: 6px 12px; font-size: 13px; cursor: pointer; }
|
||||||
|
|
@ -39,6 +42,7 @@
|
||||||
button.btn:disabled { opacity: .5; cursor: default; }
|
button.btn:disabled { opacity: .5; cursor: default; }
|
||||||
button.primary { background: var(--accent); color: var(--accent-fg); border-color: transparent; }
|
button.primary { background: var(--accent); color: var(--accent-fg); border-color: transparent; }
|
||||||
button.danger { background: transparent; color: var(--danger); border-color: var(--danger); flex: 0 1 auto; }
|
button.danger { background: transparent; color: var(--danger); border-color: var(--danger); flex: 0 1 auto; }
|
||||||
|
a.btn { text-decoration: none; display: inline-block; text-align: center; }
|
||||||
audio { width: 100%; margin-top: 12px; display: none; }
|
audio { width: 100%; margin-top: 12px; display: none; }
|
||||||
audio.show { display: block; }
|
audio.show { display: block; }
|
||||||
.msg { margin-top: 10px; font-size: 14px; min-height: 1.4em; }
|
.msg { margin-top: 10px; font-size: 14px; min-height: 1.4em; }
|
||||||
|
|
@ -47,32 +51,44 @@
|
||||||
.status-head h2 { font-size: 14px; margin: 0; color: var(--muted); text-transform: uppercase; letter-spacing: .06em; }
|
.status-head h2 { font-size: 14px; margin: 0; color: var(--muted); text-transform: uppercase; letter-spacing: .06em; }
|
||||||
.now { padding: 12px; border-radius: 8px; background: var(--panel2); border-left: 4px solid var(--ok); }
|
.now { padding: 12px; border-radius: 8px; background: var(--panel2); border-left: 4px solid var(--ok); }
|
||||||
.now.idle { border-left-color: var(--line); color: var(--muted); }
|
.now.idle { border-left-color: var(--line); color: var(--muted); }
|
||||||
.now.idle::before { content: "Stille · ingenting i kø"; }
|
|
||||||
ul.list { list-style: none; padding: 0; margin: 8px 0 0; }
|
ul.list { list-style: none; padding: 0; margin: 8px 0 0; }
|
||||||
ul.list li { padding: 8px 0; border-top: 1px solid var(--line); font-size: 14px; display: flex; gap: 10px; }
|
ul.list li { padding: 8px 0; border-top: 1px solid var(--line); font-size: 14px; display: flex; gap: 10px; }
|
||||||
ul.list li .t { color: var(--muted); min-width: 48px; font-variant-numeric: tabular-nums; }
|
ul.list li .t { color: var(--muted); min-width: 48px; font-variant-numeric: tabular-nums; }
|
||||||
ul.list li .s { margin-left: auto; color: var(--muted); font-size: 12px; white-space: nowrap; }
|
ul.list li .s { margin-left: auto; color: var(--muted); font-size: 12px; white-space: nowrap; }
|
||||||
|
ul.list li .by { color: var(--muted); font-size: 12px; }
|
||||||
ul.list li.failed .s { color: var(--danger); } ul.list li.stopped .s { color: var(--warn); }
|
ul.list li.failed .s { color: var(--danger); } ul.list li.stopped .s { color: var(--warn); }
|
||||||
.pulse { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--ok); margin-right: 8px;
|
.pulse { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--ok); margin-right: 8px;
|
||||||
animation: pulse 1.2s infinite; vertical-align: middle; }
|
animation: pulse 1.2s infinite; vertical-align: middle; }
|
||||||
@keyframes pulse { 0%,100% { opacity: 1 } 50% { opacity: .25 } }
|
@keyframes pulse { 0%,100% { opacity: 1 } 50% { opacity: .25 } }
|
||||||
#pinbox { display: none; }
|
#loginbox, #pinbox { display: none; }
|
||||||
#pinbox input { background: var(--panel2); color: var(--fg); border: 1px solid var(--line); border-radius: 8px; padding: 10px; font: inherit; width: 100%; }
|
#pinbox input { background: var(--panel2); color: var(--fg); border: 1px solid var(--line); border-radius: 8px; padding: 10px; font: inherit; width: 100%; }
|
||||||
|
.hint { color: var(--muted); font-size: 12px; margin-top: 6px; }
|
||||||
|
.hidden { display: none !important; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main>
|
<main>
|
||||||
<h1><span>Announcer</span></h1>
|
<div class="top">
|
||||||
<p class="sub">Skriv meldingen, forhåndslytt, og send den ut på anlegget.</p>
|
<div>
|
||||||
|
<h1 id="title"></h1>
|
||||||
|
<p class="sub" id="subtitle"></p>
|
||||||
|
</div>
|
||||||
|
<div class="who" id="who"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" id="loginbox">
|
||||||
|
<div id="loginbtns" class="actions" style="margin-top:0"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card" id="pinbox">
|
<div class="card" id="pinbox">
|
||||||
<label for="pin">PIN</label>
|
<label for="pin" id="pinlabel">PIN</label>
|
||||||
<input id="pin" type="password" inputmode="numeric" placeholder="Spør i crew-rommet">
|
<input id="pin" type="password" inputmode="numeric" autocomplete="current-password">
|
||||||
|
<div class="hint" id="pinhint"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<label for="text">Melding</label>
|
<label for="text">Melding</label>
|
||||||
<textarea id="text" placeholder="F.eks. «Pizzaen er kommet. Hent i kantina!»" autofocus></textarea>
|
<textarea id="text" autofocus></textarea>
|
||||||
<div class="counter"><span id="count">0</span> / <span id="max">600</span></div>
|
<div class="counter"><span id="count">0</span> / <span id="max">600</span></div>
|
||||||
|
|
||||||
<div class="chips" id="chips"></div>
|
<div class="chips" id="chips"></div>
|
||||||
|
|
@ -122,68 +138,165 @@
|
||||||
const $ = s => document.querySelector(s);
|
const $ = s => document.querySelector(s);
|
||||||
const text = $('#text'), voice = $('#voice'), speed = $('#speed'), chime = $('#chime');
|
const text = $('#text'), voice = $('#voice'), speed = $('#speed'), chime = $('#chime');
|
||||||
const msg = $('#msg'), audio = $('#player'), btnPreview = $('#btnPreview'), btnAnnounce = $('#btnAnnounce');
|
const msg = $('#msg'), audio = $('#player'), btnPreview = $('#btnPreview'), btnAnnounce = $('#btnAnnounce');
|
||||||
let cfg = {}, previewedKey = null;
|
let cfg = {}, can = [], previewedKey = null, pollTimer = null;
|
||||||
|
|
||||||
const QUICK = [
|
|
||||||
'Hei alle sammen! Pizzaen er kommet, hent i kantina.',
|
|
||||||
'Turneringen starter om ti minutter. Møt opp ved scenen.',
|
|
||||||
'Husk å rydde rundt plassen din. Takk!',
|
|
||||||
'Natta! Vi senker lyset, hold volumet nede.',
|
|
||||||
];
|
|
||||||
|
|
||||||
function key() { return JSON.stringify(payload()); }
|
function key() { return JSON.stringify(payload()); }
|
||||||
function payload() {
|
function payload() {
|
||||||
return { text: text.value.trim(), voice: voice.value, speed: parseFloat(speed.value), chime: chime.value };
|
return { text: text.value.trim(), voice: voice.value, speed: parseFloat(speed.value), chime: chime.value };
|
||||||
}
|
}
|
||||||
function headers() {
|
function headers(json) {
|
||||||
const h = { 'Content-Type': 'application/json' };
|
const h = json ? { 'Content-Type': 'application/json' } : {};
|
||||||
|
// PIN-en er den eneste legitimasjonen som ligger i nettleseren; OIDC og
|
||||||
|
// token-lenka bruker sesjonscookien, som følger med av seg selv.
|
||||||
const pin = localStorage.getItem('pin');
|
const pin = localStorage.getItem('pin');
|
||||||
if (pin) h['X-Pin'] = pin;
|
if (pin) h['X-Pin'] = pin;
|
||||||
return h;
|
return h;
|
||||||
}
|
}
|
||||||
function say(t, cls) { msg.textContent = t; msg.className = 'msg ' + (cls || ''); }
|
function say(t, cls) { msg.textContent = t; msg.className = 'msg ' + (cls || ''); }
|
||||||
|
function may(action) { return can.includes(action); }
|
||||||
function updateAnnounce() {
|
function updateAnnounce() {
|
||||||
btnAnnounce.disabled = !(previewedKey && previewedKey === key());
|
const ready = previewedKey && previewedKey === key();
|
||||||
btnAnnounce.title = btnAnnounce.disabled ? 'Forhåndslytt først' : '';
|
btnAnnounce.disabled = !ready || !may('announce');
|
||||||
|
btnAnnounce.title = !may('announce') ? 'Du har ikke lov til å sende ut' :
|
||||||
|
(btnAnnounce.disabled ? 'Forhåndslytt først' : '');
|
||||||
|
btnPreview.disabled = !may('preview');
|
||||||
|
btnPreview.title = may('preview') ? '' : 'Du har ikke lov til å forhåndslytte';
|
||||||
}
|
}
|
||||||
async function api(path, body) {
|
|
||||||
const r = await fetch(path, { method: 'POST', headers: headers(), body: JSON.stringify(body || {}) });
|
async function req(path, opts) {
|
||||||
|
const r = await fetch(path, opts);
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
let e = r.statusText; try { e = (await r.json()).error || e; } catch {}
|
let e = r.statusText, login = false;
|
||||||
throw new Error(e);
|
try { const j = await r.json(); e = j.error || e; login = !!j.login; } catch {}
|
||||||
|
const err = new Error(e);
|
||||||
|
err.status = r.status; err.login = login;
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
|
const api = (path, body) =>
|
||||||
|
req(path, { method: 'POST', headers: headers(true), body: JSON.stringify(body || {}) });
|
||||||
|
|
||||||
|
function onError(e) {
|
||||||
|
say(e.message, 'err');
|
||||||
|
// Rettighetene kan ha endret seg (riktig PIN skrevet, sesjon utløpt).
|
||||||
|
if (e.status === 401 || e.status === 403) refreshAuth();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- branding
|
||||||
|
function applyConfig(c) {
|
||||||
|
cfg = c;
|
||||||
|
const a = c.app || {};
|
||||||
|
document.title = a.title || 'Announcer';
|
||||||
|
// Siste ord i tittelen får aksentfargen; ett ord alene står som det er.
|
||||||
|
const parts = (a.title || 'Announcer').split(' ');
|
||||||
|
$('#title').textContent = '';
|
||||||
|
if (parts.length > 1) {
|
||||||
|
$('#title').append(document.createTextNode(parts.slice(0, -1).join(' ') + ' '));
|
||||||
|
const sp = document.createElement('span'); sp.textContent = parts.at(-1); $('#title').append(sp);
|
||||||
|
} else {
|
||||||
|
$('#title').textContent = parts[0];
|
||||||
|
}
|
||||||
|
$('#subtitle').textContent = a.subtitle || '';
|
||||||
|
if (a.accent) document.documentElement.style.setProperty('--accent', a.accent);
|
||||||
|
text.placeholder = a.message_placeholder || '';
|
||||||
|
$('#max').textContent = c.max_chars; text.maxLength = c.max_chars;
|
||||||
|
|
||||||
|
$('#chips').replaceChildren(...(a.quick_phrases || []).map(q => {
|
||||||
|
const b = document.createElement('button');
|
||||||
|
b.className = 'chip'; b.textContent = q.slice(0, 32) + (q.length > 32 ? '…' : ''); b.title = q;
|
||||||
|
b.onclick = () => { text.value = q; text.dispatchEvent(new Event('input')); text.focus(); };
|
||||||
|
return b;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------- auth
|
||||||
|
function applyAuth(auth) {
|
||||||
|
can = auth.can || [];
|
||||||
|
const methods = auth.methods || [];
|
||||||
|
const who = $('#who');
|
||||||
|
|
||||||
|
if (auth.user) {
|
||||||
|
who.textContent = '';
|
||||||
|
who.append(document.createTextNode('Innlogget som '));
|
||||||
|
const b = document.createElement('b'); b.textContent = auth.user.name; who.append(b);
|
||||||
|
if (auth.user.roles.length) who.append(document.createTextNode(' (' + auth.user.roles.join(', ') + ')'));
|
||||||
|
if (auth.logout_url) {
|
||||||
|
who.append(document.createTextNode(' · '));
|
||||||
|
const a = document.createElement('a'); a.href = auth.logout_url; a.textContent = 'Logg ut'; who.append(a);
|
||||||
|
}
|
||||||
|
} else if (auth.enabled) {
|
||||||
|
who.textContent = 'Ikke innlogget';
|
||||||
|
} else {
|
||||||
|
who.textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// OIDC-knapper
|
||||||
|
const oidc = methods.filter(m => m.login_url);
|
||||||
|
$('#loginbox').style.display = (oidc.length && !auth.user) ? 'block' : 'none';
|
||||||
|
$('#loginbtns').replaceChildren(...oidc.map(m => {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.className = 'btn primary'; a.href = m.login_url; a.textContent = m.label || 'Logg inn';
|
||||||
|
return a;
|
||||||
|
}));
|
||||||
|
|
||||||
|
// PIN-feltet, bare når PIN er en av metodene og vi ikke alt er innlogget
|
||||||
|
const pin = methods.find(m => m.method === 'pin');
|
||||||
|
$('#pinbox').style.display = (pin && !auth.user) ? 'block' : 'none';
|
||||||
|
if (pin) {
|
||||||
|
$('#pinlabel').textContent = pin.label || 'PIN';
|
||||||
|
$('#pinhint').textContent = auth.error || pin.hint || '';
|
||||||
|
$('#pinhint').style.color = auth.error ? 'var(--danger)' : 'var(--muted)';
|
||||||
|
if (!$('#pin').value) $('#pin').value = localStorage.getItem('pin') || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#btnStop').classList.toggle('hidden', !may('stop'));
|
||||||
|
$('#btnClear').classList.toggle('hidden', !may('clear'));
|
||||||
|
updateAnnounce();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAuth() {
|
||||||
|
try {
|
||||||
|
const c = await (await fetch('/api/config', { headers: headers(false) })).json();
|
||||||
|
applyAuth(c.auth || {});
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------- init
|
||||||
async function init() {
|
async function init() {
|
||||||
cfg = await (await fetch('/api/config')).json();
|
const c = await (await fetch('/api/config', { headers: headers(false) })).json();
|
||||||
|
applyConfig(c);
|
||||||
|
|
||||||
const groups = {};
|
const groups = {};
|
||||||
for (const v of cfg.voices) {
|
for (const v of c.voices) {
|
||||||
if (!groups[v.group]) { groups[v.group] = document.createElement('optgroup'); groups[v.group].label = v.group; voice.append(groups[v.group]); }
|
if (!groups[v.group]) { groups[v.group] = document.createElement('optgroup'); groups[v.group].label = v.group; voice.append(groups[v.group]); }
|
||||||
const o = document.createElement('option'); o.value = v.id; o.textContent = v.name; groups[v.group].append(o);
|
const o = document.createElement('option'); o.value = v.id; o.textContent = v.name; groups[v.group].append(o);
|
||||||
}
|
}
|
||||||
voice.value = localStorage.getItem('voice') || cfg.default_voice;
|
voice.value = localStorage.getItem('voice') || c.default_voice;
|
||||||
if (!voice.value) voice.value = cfg.default_voice;
|
if (!voice.value) voice.value = c.default_voice;
|
||||||
for (const c of cfg.chimes) {
|
for (const ch of c.chimes) {
|
||||||
const o = document.createElement('option'); o.value = c.id; o.textContent = c.name; chime.append(o);
|
const o = document.createElement('option'); o.value = ch.id; o.textContent = ch.name; chime.append(o);
|
||||||
}
|
}
|
||||||
chime.value = localStorage.getItem('chime') || cfg.default_chime;
|
chime.value = localStorage.getItem('chime') || c.default_chime;
|
||||||
if (!chime.value) chime.value = cfg.default_chime;
|
if (!chime.value) chime.value = c.default_chime;
|
||||||
$('#max').textContent = cfg.max_chars; text.maxLength = cfg.max_chars;
|
|
||||||
if (cfg.pin_required) { $('#pinbox').style.display = 'block'; $('#pin').value = localStorage.getItem('pin') || ''; }
|
applyAuth(c.auth || {});
|
||||||
for (const q of QUICK) {
|
poll();
|
||||||
const b = document.createElement('button'); b.className = 'chip'; b.textContent = q.slice(0, 32) + (q.length > 32 ? '…' : '');
|
pollTimer = setInterval(poll, 2000);
|
||||||
b.title = q; b.onclick = () => { text.value = q; text.dispatchEvent(new Event('input')); text.focus(); };
|
|
||||||
$('#chips').append(b);
|
|
||||||
}
|
|
||||||
poll(); setInterval(poll, 2000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let pinDebounce = null;
|
||||||
|
$('#pin').addEventListener('input', e => {
|
||||||
|
localStorage.setItem('pin', e.target.value);
|
||||||
|
clearTimeout(pinDebounce);
|
||||||
|
// Vent til han er ferdig å skrive, så slipper vi en runde per tastetrykk.
|
||||||
|
pinDebounce = setTimeout(() => { refreshAuth(); poll(); }, 600);
|
||||||
|
});
|
||||||
|
|
||||||
text.addEventListener('input', () => { $('#count').textContent = text.value.length; updateAnnounce(); });
|
text.addEventListener('input', () => { $('#count').textContent = text.value.length; updateAnnounce(); });
|
||||||
voice.addEventListener('change', () => { localStorage.setItem('voice', voice.value); updateAnnounce(); });
|
voice.addEventListener('change', () => { localStorage.setItem('voice', voice.value); updateAnnounce(); });
|
||||||
speed.addEventListener('input', () => { $('#speedval').textContent = parseFloat(speed.value).toFixed(2).replace(/0$/, '') + '×'; updateAnnounce(); });
|
speed.addEventListener('input', () => { $('#speedval').textContent = parseFloat(speed.value).toFixed(2).replace(/0$/, '') + '×'; updateAnnounce(); });
|
||||||
chime.addEventListener('change', () => { localStorage.setItem('chime', chime.value); updateAnnounce(); });
|
chime.addEventListener('change', () => { localStorage.setItem('chime', chime.value); updateAnnounce(); });
|
||||||
$('#pin').addEventListener('input', e => localStorage.setItem('pin', e.target.value));
|
|
||||||
|
|
||||||
btnPreview.addEventListener('click', async () => {
|
btnPreview.addEventListener('click', async () => {
|
||||||
const p = payload();
|
const p = payload();
|
||||||
|
|
@ -195,10 +308,10 @@ btnPreview.addEventListener('click', async () => {
|
||||||
if (audio.src) URL.revokeObjectURL(audio.src);
|
if (audio.src) URL.revokeObjectURL(audio.src);
|
||||||
audio.src = URL.createObjectURL(blob); audio.classList.add('show');
|
audio.src = URL.createObjectURL(blob); audio.classList.add('show');
|
||||||
await audio.play().catch(() => {});
|
await audio.play().catch(() => {});
|
||||||
previewedKey = JSON.stringify(p); updateAnnounce();
|
previewedKey = JSON.stringify(p);
|
||||||
say('Hør gjennom. Fornøyd? Send den ut.', 'ok');
|
say('Hør gjennom. Fornøyd? Send den ut.', 'ok');
|
||||||
} catch (e) { say(e.message, 'err'); }
|
} catch (e) { onError(e); }
|
||||||
finally { btnPreview.disabled = false; }
|
finally { btnPreview.disabled = false; updateAnnounce(); }
|
||||||
});
|
});
|
||||||
|
|
||||||
btnAnnounce.addEventListener('click', async () => {
|
btnAnnounce.addEventListener('click', async () => {
|
||||||
|
|
@ -209,29 +322,40 @@ btnAnnounce.addEventListener('click', async () => {
|
||||||
text.value = ''; text.dispatchEvent(new Event('input')); previewedKey = null;
|
text.value = ''; text.dispatchEvent(new Event('input')); previewedKey = null;
|
||||||
audio.pause(); audio.classList.remove('show');
|
audio.pause(); audio.classList.remove('show');
|
||||||
poll();
|
poll();
|
||||||
} catch (e) { say(e.message, 'err'); updateAnnounce(); }
|
} catch (e) { onError(e); }
|
||||||
|
finally { updateAnnounce(); }
|
||||||
});
|
});
|
||||||
|
|
||||||
$('#btnStop').addEventListener('click', () => api('/api/stop').then(poll).catch(e => say(e.message, 'err')));
|
$('#btnStop').addEventListener('click', () => api('/api/stop').then(poll).catch(onError));
|
||||||
$('#btnClear').addEventListener('click', () => api('/api/clear').then(poll).catch(e => say(e.message, 'err')));
|
$('#btnClear').addEventListener('click', () => api('/api/clear').then(poll).catch(onError));
|
||||||
|
|
||||||
const STATUS = { done: 'sendt', failed: 'feilet', stopped: 'stoppet', queued: 'i kø' };
|
const STATUS = { done: 'sendt', failed: 'feilet', stopped: 'stoppet', queued: 'i kø' };
|
||||||
function hhmm(ts) { return new Date(ts * 1000).toLocaleTimeString('no', { hour: '2-digit', minute: '2-digit' }); }
|
function hhmm(ts) { return new Date(ts * 1000).toLocaleTimeString('no', { hour: '2-digit', minute: '2-digit' }); }
|
||||||
function li(it) {
|
function li(it) {
|
||||||
const l = document.createElement('li'); l.className = it.status;
|
const l = document.createElement('li'); l.className = it.status;
|
||||||
l.innerHTML = `<span class="t">${hhmm(it.created)}</span><span class="x"></span><span class="s">${STATUS[it.status] || it.status}${it.error ? ' · ' + it.error : ''}</span>`;
|
l.innerHTML = `<span class="t"></span><span class="x"></span><span class="by"></span><span class="s"></span>`;
|
||||||
|
l.querySelector('.t').textContent = hhmm(it.created);
|
||||||
l.querySelector('.x').textContent = it.text;
|
l.querySelector('.x').textContent = it.text;
|
||||||
|
// Navnet står her, ikke IP-en. IP-en kommer bare med for en admin.
|
||||||
|
l.querySelector('.by').textContent = [it.user, it.sender].filter(Boolean).join(' · ');
|
||||||
|
l.querySelector('.s').textContent = (STATUS[it.status] || it.status) + (it.error ? ' · ' + it.error : '');
|
||||||
return l;
|
return l;
|
||||||
}
|
}
|
||||||
async function poll() {
|
async function poll() {
|
||||||
|
const now = $('#now');
|
||||||
try {
|
try {
|
||||||
const s = await (await fetch('/api/status')).json();
|
const s = await (await req('/api/status', { headers: headers(false) })).json();
|
||||||
const now = $('#now');
|
|
||||||
if (s.current) { now.className = 'now'; now.innerHTML = '<span class="pulse"></span>'; now.append(document.createTextNode(s.current.text)); }
|
if (s.current) { now.className = 'now'; now.innerHTML = '<span class="pulse"></span>'; now.append(document.createTextNode(s.current.text)); }
|
||||||
else { now.className = 'now idle'; now.textContent = ''; }
|
else { now.className = 'now idle'; now.textContent = (cfg.app && cfg.app.idle_text) || ''; }
|
||||||
$('#queue').replaceChildren(...s.queued.map(li));
|
$('#queue').replaceChildren(...s.queued.map(li));
|
||||||
$('#history').replaceChildren(...s.history.map(li));
|
$('#history').replaceChildren(...s.history.map(li));
|
||||||
} catch {}
|
} catch (e) {
|
||||||
|
if (e.status === 401 || e.status === 403) {
|
||||||
|
now.className = 'now idle';
|
||||||
|
now.textContent = 'Logg inn for å se køen.';
|
||||||
|
$('#queue').replaceChildren(); $('#history').replaceChildren();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
init();
|
init();
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
19
install.sh
19
install.sh
|
|
@ -7,6 +7,8 @@
|
||||||
# Kjør som vanlig bruker (ikke sudo); skriptet spør om passord der det trengs.
|
# Kjør som vanlig bruker (ikke sudo); skriptet spør om passord der det trengs.
|
||||||
# Valgfritt: ANNOUNCER_PIN=1234 ./install.sh -> sida krever PIN
|
# Valgfritt: ANNOUNCER_PIN=1234 ./install.sh -> sida krever PIN
|
||||||
# ANNOUNCER_PORT=8090 ./install.sh -> annen port
|
# ANNOUNCER_PORT=8090 ./install.sh -> annen port
|
||||||
|
# ANNOUNCER_TITLE="Utrop" ./install.sh
|
||||||
|
# Resten av konfigurasjonen hører hjemme i config.toml, se config.example.toml.
|
||||||
# Kan kjøres på nytt; da oppdateres avhengigheter og tjenesten startes om.
|
# Kan kjøres på nytt; da oppdateres avhengigheter og tjenesten startes om.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
|
@ -20,6 +22,7 @@ OLD_APP="sg-announcer" # het dette før; enheten ryddes bort nedenfor
|
||||||
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
PORT="${ANNOUNCER_PORT:-8090}"
|
PORT="${ANNOUNCER_PORT:-8090}"
|
||||||
PIN="${ANNOUNCER_PIN:-}"
|
PIN="${ANNOUNCER_PIN:-}"
|
||||||
|
TITLE="${ANNOUNCER_TITLE:-}"
|
||||||
UNIT_DIR="$HOME/.config/systemd/user"
|
UNIT_DIR="$HOME/.config/systemd/user"
|
||||||
UNIT="$UNIT_DIR/$APP.service"
|
UNIT="$UNIT_DIR/$APP.service"
|
||||||
|
|
||||||
|
|
@ -46,7 +49,7 @@ echo "uv: $UV ($("$UV" --version))"
|
||||||
step "Python 3.13 og avhengigheter"
|
step "Python 3.13 og avhengigheter"
|
||||||
cd "$REPO_DIR"
|
cd "$REPO_DIR"
|
||||||
"$UV" sync
|
"$UV" sync
|
||||||
mkdir -p cache chimes voices
|
mkdir -p cache chimes voices logs instance
|
||||||
|
|
||||||
step "Laster ned Piper-stemmene (lokal reserve, ca. 600 MB, kun første gang)"
|
step "Laster ned Piper-stemmene (lokal reserve, ca. 600 MB, kun første gang)"
|
||||||
"$UV" run python -c "import logging, announcer; logging.basicConfig(level=logging.INFO, format='%(message)s'); announcer.ensure_voices()"
|
"$UV" run python -c "import logging, announcer; logging.basicConfig(level=logging.INFO, format='%(message)s'); announcer.ensure_voices()"
|
||||||
|
|
@ -79,8 +82,16 @@ UNIT
|
||||||
else
|
else
|
||||||
echo "# Environment=ANNOUNCER_PIN=1234"
|
echo "# Environment=ANNOUNCER_PIN=1234"
|
||||||
fi
|
fi
|
||||||
|
if [[ -n "$TITLE" ]]; then
|
||||||
|
echo "Environment=ANNOUNCER_TITLE=$TITLE"
|
||||||
|
else
|
||||||
|
echo "# Environment=ANNOUNCER_TITLE=Utrop"
|
||||||
|
fi
|
||||||
cat <<'UNIT'
|
cat <<'UNIT'
|
||||||
# Environment=ANNOUNCER_SINK=alsa_output.xxx
|
# Environment=ANNOUNCER_SINK=alsa_output.xxx
|
||||||
|
# Hemmeligheter hører hjemme her og ikke i config.toml:
|
||||||
|
# Environment=ANNOUNCER_JWT_SECRET=...
|
||||||
|
# Environment=ANNOUNCER_OIDC_CLIENT_SECRET=...
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=3
|
RestartSec=3
|
||||||
|
|
||||||
|
|
@ -88,7 +99,7 @@ RestartSec=3
|
||||||
WantedBy=default.target
|
WantedBy=default.target
|
||||||
UNIT
|
UNIT
|
||||||
} > "$UNIT"
|
} > "$UNIT"
|
||||||
chmod 600 "$UNIT" # PIN står i klartekst her
|
chmod 600 "$UNIT" # PIN og nøkler står i klartekst her
|
||||||
systemctl --user daemon-reload
|
systemctl --user daemon-reload
|
||||||
systemctl --user enable --now "$APP"
|
systemctl --user enable --now "$APP"
|
||||||
systemctl --user restart "$APP"
|
systemctl --user restart "$APP"
|
||||||
|
|
@ -120,6 +131,10 @@ cat <<MSG
|
||||||
|
|
||||||
Åpne http://${IP:-<denne-maskinens-ip>}:$PORT fra hva som helst på nettet.
|
Åpne http://${IP:-<denne-maskinens-ip>}:$PORT fra hva som helst på nettet.
|
||||||
|
|
||||||
|
Uten config.toml er sida åpen for alle som når porten. Vil du ha innlogging:
|
||||||
|
cp config.example.toml config.toml og les kommentarene der.
|
||||||
|
Mot crew-panelet: docs/crew-integration.md
|
||||||
|
|
||||||
Tips for en laptop på arrangementet:
|
Tips for en laptop på arrangementet:
|
||||||
- Lyden spilles i din innloggede økt, så maskinen må være logget inn.
|
- Lyden spilles i din innloggede økt, så maskinen må være logget inn.
|
||||||
Slå på automatisk innlogging (Innstillinger -> Påloggingsvindu).
|
Slå på automatisk innlogging (Innstillinger -> Påloggingsvindu).
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,18 @@
|
||||||
[project]
|
[project]
|
||||||
name = "announcer"
|
name = "announcer"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
description = "Tekst inn i nettleseren, forhåndslytt, og les meldingen ut på høyttaleranlegget"
|
description = "Tekst inn i nettleseren, forhåndslytt, og les meldingen ut på høyttaleranlegget"
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"edge-tts>=7.2.8",
|
"edge-tts>=7.2.8",
|
||||||
"flask>=3.1.3",
|
"flask>=3.1.3",
|
||||||
"piper-tts>=1.8.0",
|
"piper-tts>=1.8.0",
|
||||||
|
"pyjwt[crypto]>=2.10",
|
||||||
|
]
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.uv]
|
[tool.uv]
|
||||||
|
|
|
||||||
22
tests/conftest.py
Normal file
22
tests/conftest.py
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
"""Felles oppsett for testene.
|
||||||
|
|
||||||
|
Hele poenget her er å nulle ut miljøet. `config.load()` lar
|
||||||
|
ANNOUNCER_*-variabler overstyre konfigurasjonen, og det er riktig oppførsel –
|
||||||
|
men det betyr at testene ellers arver det maskina tilfeldigvis har satt.
|
||||||
|
På announcer-maskina setter systemd-enheten `ANNOUNCER_PORT`, og i
|
||||||
|
docker compose settes både JWT- og OIDC-hemmeligheter. Uten denne fila feiler
|
||||||
|
28 av testene der, med feilmeldinger som ikke peker på noe som er galt.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def rent_miljø(monkeypatch):
|
||||||
|
"""Fjerner alle ANNOUNCER_*-variabler, så hver test bare ser sin egen config."""
|
||||||
|
for key in [k for k in os.environ if k.startswith("ANNOUNCER_")]:
|
||||||
|
monkeypatch.delenv(key, raising=False)
|
||||||
535
tests/test_auth.py
Normal file
535
tests/test_auth.py
Normal file
|
|
@ -0,0 +1,535 @@
|
||||||
|
"""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:
|
||||||
|
assert c.get(f"/do/{action}").status_code == 200, action
|
||||||
|
|
||||||
|
|
||||||
|
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"})
|
||||||
|
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", "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"]
|
||||||
223
uv.lock
generated
223
uv.lock
generated
|
|
@ -148,12 +148,18 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "announcer"
|
name = "announcer"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "edge-tts" },
|
{ name = "edge-tts" },
|
||||||
{ name = "flask" },
|
{ name = "flask" },
|
||||||
{ name = "piper-tts" },
|
{ name = "piper-tts" },
|
||||||
|
{ name = "pyjwt", extra = ["crypto"] },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.dev-dependencies]
|
||||||
|
dev = [
|
||||||
|
{ name = "pytest" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
|
|
@ -161,8 +167,12 @@ requires-dist = [
|
||||||
{ name = "edge-tts", specifier = ">=7.2.8" },
|
{ name = "edge-tts", specifier = ">=7.2.8" },
|
||||||
{ name = "flask", specifier = ">=3.1.3" },
|
{ name = "flask", specifier = ">=3.1.3" },
|
||||||
{ name = "piper-tts", specifier = ">=1.8.0" },
|
{ name = "piper-tts", specifier = ">=1.8.0" },
|
||||||
|
{ name = "pyjwt", extras = ["crypto"], specifier = ">=2.10" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[package.metadata.requires-dev]
|
||||||
|
dev = [{ name = "pytest", specifier = ">=8.3" }]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "attrs"
|
name = "attrs"
|
||||||
version = "26.1.0"
|
version = "26.1.0"
|
||||||
|
|
@ -190,6 +200,79 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
|
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cffi"
|
||||||
|
version = "2.1.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "click"
|
name = "click"
|
||||||
version = "8.5.0"
|
version = "8.5.0"
|
||||||
|
|
@ -199,6 +282,78 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" },
|
{ url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorama"
|
||||||
|
version = "0.4.6"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cryptography"
|
||||||
|
version = "50.0.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/9d/af/182eb91b0df3fe75c4d9f26fe70684569566745f6ba7e5c9c73a862c5252/cryptography-50.0.2.tar.gz", hash = "sha256:7b46165bb56eb4704e2eaaf86f3c940d19154535d9b0ca7d6d590b04060e00d5", size = 880623, upload-time = "2026-09-30T15:30:04.884Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/56/d194340cc4a57535e82e1bee9e89667ac4b7c13b5d3f59686deae3094dd5/cryptography-50.0.2-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:fa8f5efb344d6908a1ce62f4a24e2e5780f825d6f53f5f50ec5ffacac72936cb", size = 3914904, upload-time = "2026-09-30T14:43:44.339Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d9/69/c9bd862c3bf43d6399c433caf002df16e2dffd4be49bdf515cda38038711/cryptography-50.0.2-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:79def8d059362e7831389ed3be0ecdf58a89386e1271e35dd9f5af84e81bffd0", size = 4731146, upload-time = "2026-09-30T14:43:47.113Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/21/69/64cef1f702bf6657e0cc186ed1a2891d50d29fb41586b254e1c07adea261/cryptography-50.0.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:630ebfea3bf689d075f82316324ff7433dc447fe6bc1bfc76524b74b4a9567d2", size = 4719841, upload-time = "2026-09-30T14:43:49.01Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/38/6b/61a3f8d8c5e1e49a6cddccafc4015cc1c0021360ab0acb4080e7a423644a/cryptography-50.0.2-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f9f6143a8c75945eb960d9eb98905a441394abfa24afaae239d514ffb2586480", size = 4738340, upload-time = "2026-09-30T14:43:50.932Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/2e/7212ca32fd43dc91f2f41db20160b268098874b4c9a0e7be94d6835f5b2e/cryptography-50.0.2-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a582ab2ae1d34f67112cadc86702774c9ea4374df6bca6afe672817203c99134", size = 5367029, upload-time = "2026-09-30T14:43:52.911Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1a/f1/b474e930c4d910328780e3940da76f5aa5cbc48ce1fc14e44d239d9ea9db/cryptography-50.0.2-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4061c0079120205fb760c58acab6443e217307dcf05e3702cf970e0689972856", size = 4753050, upload-time = "2026-09-30T14:43:55.272Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7c/52/9af10e80ac16b0fcc2123f9cbd5e7afbd0fd5075bb7a607c592258a39cda/cryptography-50.0.2-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:ac9ed99d81760c62fe89d5f0815cdfa1ba9a35141cf30f1c2d044f04b4803d2e", size = 4376724, upload-time = "2026-09-30T14:43:57.24Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/37/6202e488cc1eb625ea110c292c6bda92823176e023f427d8d5660ce8d632/cryptography-50.0.2-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:87e9ce85beb6b328ba370cc6e6aea483c92617b4c95b1d33a49297eb662bfb04", size = 4737859, upload-time = "2026-09-30T14:43:59.541Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8f/30/e86d7d518489b0ae2497091a35287abcb1a2ce4037837a34afbe9b1d6964/cryptography-50.0.2-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f265528741e048bce55c3463ed721fb0aa45a5888d8add8cfeccb3035451bbdc", size = 5324103, upload-time = "2026-09-30T14:44:01.901Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d3/69/2c833a049475e0a3444e94c7d0aca0aa51d166374a449b09e92ac98138de/cryptography-50.0.2-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dab55f57c74c3cad24c323bacbbd04be4705ba6eb0d92e920b1fc4837ed5079", size = 4752576, upload-time = "2026-09-30T14:44:04.545Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6c/5d/906970b83bbfc1f5bbfb677a143c181f2801f23b6a7204a3b47c42c97e65/cryptography-50.0.2-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:25784ce8b9621c90c643efb9e1e2162ab3b0224cae446ad5e70e7fcb1ce18b51", size = 4870819, upload-time = "2026-09-30T14:44:06.884Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/68/e3/f2298d3bb55e0c4a91841ec4d01b3f020ba8c5fbf15ccdcc6dcf03f97025/cryptography-50.0.2-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:85d0d9a31b9098e98534226d5686b47264b95e62ce459dc2e62fdfc809f9fe93", size = 5030152, upload-time = "2026-09-30T14:44:09.443Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9a/4f/adfc442765721292fff86d314ce385d3249d22db42295c0dd057727b60f3/cryptography-50.0.2-cp311-abi3-win_amd64.whl", hash = "sha256:7afa5a6602a9f29af1f3a2965f831bae7c9d5d597b7cbb716d41ab3b7d89879c", size = 3824692, upload-time = "2026-09-30T14:44:11.671Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/cb/52eb3770c0d0be2702a98c6e96065ddc0a2877cf0845aa9c23397c142cd4/cryptography-50.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f785f6161f202ab04d8ca194158968798e480ca058943907972da5f12e2881e8", size = 3892731, upload-time = "2026-09-30T14:44:13.485Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/19/8e/aa1fc533d4546b127b45de8aa024eb5933d23eff9debfe25931e56861095/cryptography-50.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0ecbc5652bdb6fc9eaf89a7d196e20941adfe812f43bc4ca05d9150496821047", size = 4710431, upload-time = "2026-09-30T14:44:15.427Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/64/72bc3f75176e7e406b748a3e3830432b8c51297b38368713df04dc04898a/cryptography-50.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ab50ee449bf968271e820086f10a33d101dd060370abc10bcd22279be2656539", size = 4694824, upload-time = "2026-09-30T14:44:17.69Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4e/c6/62c77550edfa5ca3f14bf44a1e6739b9fa09d6e998a11d97ed8213bccc98/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a9f7355e6fab51f6c369b86fb7571cffa05edee2c2121e0380a37fb9ac1cd5c1", size = 4716967, upload-time = "2026-09-30T14:44:19.661Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/37/cce70f150c432914460157a6ecc161752e053aa5ec0ef3b3f7dc6e31039a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:94e5e9f108ee10471288214d3d233fbfbb492840a8457eb85178d643ddeb32c7", size = 5328676, upload-time = "2026-09-30T14:44:21.744Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/9a/6f2f0304d634ceafdeaf23e84537336664ac419b5d07611675c2ad3f6b7a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:241449bf940a5d27309bd317e6f9a2af6932113818bb2b8f5c59ddc7ef16da18", size = 4727698, upload-time = "2026-09-30T14:44:24.178Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1d/de/66bcf9244d118663b2e1aaded8990f4640e3d7b7411870a5765f252074d2/cryptography-50.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d8947001be83df1394050758ce0e745dd74fb134eef0a4b5124208dfc3a68c37", size = 4354821, upload-time = "2026-09-30T14:44:26.263Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bd/e6/db28a28c7b6c676addce89136de3d8db49ea825a8c863472e36e42ead4ad/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4a20ce1e5cb4284a86692fdcba7cb8754185c6b2e5c56fcef3751cf451d3cdc2", size = 4716748, upload-time = "2026-09-30T14:44:28.447Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/30/96/01546c7f69ea0e2ab790a2e4f0934a4052fb9b388147fbf83c2fd72f1e57/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:84f964e537f916e2cc85199e5a88742e964939b575ac8598b3f9d6cc416cdaf1", size = 5285085, upload-time = "2026-09-30T14:44:30.704Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6c/01/03263395f74d50b071e9e66daace3f8bef80493e5d410726f2ba8554736b/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:828d49b0ff5a0e3975865571c5d91dbbdd0d38d8289b249a163e9425413a5e05", size = 4727268, upload-time = "2026-09-30T14:44:32.92Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/eb/94/2bfe8f29ec0cc9c0d99359c4161adf32858e4934b72c6d100d2ac0bbe962/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:deb9fde5c60e437ee4821bc9bc39ff31b42135c27e1dc61ef0a629389c1de62e", size = 4849503, upload-time = "2026-09-30T14:44:34.969Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/44/e80651ecbf0e42b62e2bb5f5768916e07eea72e1297338956a61df361f88/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8c71ba2cd31fc93748c38e1b613200ff1c2665cbfd5341fe3a61cfde35a1430e", size = 5004057, upload-time = "2026-09-30T14:44:37.064Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f8/cc/1d33befb3cd7ea7e77d2d73f43f2066471da1b21f24a6156efcaabf6d2e8/cryptography-50.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:78198641e5be9521beea5aa782bb551a58068d10e6eb04c9c680c1b69f2e7d45", size = 3795868, upload-time = "2026-09-30T14:44:39.71Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2d/49/93f6a6e7a87c9aa68d44d3e1cdb5fe8f60c90d5d2f46acae9a56892816b8/cryptography-50.0.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:edc3342adf8f697fc5f59c887a304356f147b397809440ed64e2fa6af2f50f37", size = 4133708, upload-time = "2026-09-30T14:44:41.807Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8c/75/32ac2a56243d778805c16ca6a32b8f74fb757df7e28d7ecb560afafb59cf/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d370b8d1dfcdf7130178137f6fbee6140774a1acc6cacefc4b42643ec11d0a3a", size = 4956267, upload-time = "2026-09-30T14:44:43.693Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/a4/2c8d734e43d97f0842ee9f1b7b4bfb3d0cf5e19edebf43c2afe6675c2320/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2f9bd7f90c64fe89253f0a2c05e3c4856072660429ce8831b4235bf29403a67", size = 4966465, upload-time = "2026-09-30T14:44:45.769Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c2/58/ee288c829a6f41f6235ae9dd33d82fd19b45442b65b4c8a3da36963d9f7a/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_aarch64.whl", hash = "sha256:e275096ea1e60cc595cda2836fd4a6c725d1125108b868be17f53684d164e2cc", size = 4959356, upload-time = "2026-09-30T14:44:48.211Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/20/9ded6d51ddd9897f6b6e81fb9ebea7951d7cc5d6c890b0ed8abf77a51a80/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_ppc64le.whl", hash = "sha256:b13478603dcd0a2479ff8e87e2c19a7d525734686fe3c49542472293a204212d", size = 5548822, upload-time = "2026-09-30T14:44:50.86Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/a8/8df951850d6b31d2a00218f19e2b3f999523437ed7a819df7fa427942fca/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl", hash = "sha256:58a0c478eeca76fe5e07993c5a0703def34a6dc6a0cda4f5564639b33112ffe7", size = 5001199, upload-time = "2026-09-30T14:44:53.379Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/f9/36b3022218ce75b7cdf068fb95f809f9bd0d820e4955ef43b90c255cc7ac/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_31_armv7l.whl", hash = "sha256:d38cdff612d06fa6a32840d5e1b1f7a27cee4a349aa9085d94a67789d6bfd408", size = 4629333, upload-time = "2026-09-30T14:44:55.635Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8c/72/20f99a219f6af47cdd1cbd978c243b92d71496e168a746138af44ded4f29/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_aarch64.whl", hash = "sha256:fdd28f912fccfec1846a94e2e1e8f9b0012f557f0c46fe4f3eb0d7a87afcf90b", size = 4958822, upload-time = "2026-09-30T14:44:59.639Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f2/20/196f112617fb08eb4d608a2a6c422373d46f9cc2857f38fc0667033c0899/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_ppc64le.whl", hash = "sha256:cbc8738fd8526d80f35cb3a40d41f41a2e7030bb3b18b09a6778ef63d291c2fd", size = 5506351, upload-time = "2026-09-30T14:45:02.267Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/95/83378121ef3eaaaf71d4b781577ff794acb39b9e1b87a3f156898c8497ed/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_x86_64.whl", hash = "sha256:e105ab60406787da31fccc883fc0f733af1efd78f0136a4599692c4083a73d0c", size = 5000859, upload-time = "2026-09-30T14:45:05.009Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/f7/70fd7ae4d1dbfa7ba29b02e1b9068771519a86027756510b700ce81086a8/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6f8700550aa1474a91e5dc07049c46f98b423b5b1ddd0483e0b51362eeeaf5be", size = 5092151, upload-time = "2026-09-30T15:29:15.932Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d4/be/688367b74de86984bd58d8efacfc7c9e68b89a6a22ced0fb4f38db50254a/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:c71be1cbfa5cd9a41ee452acf1eccd82b2c05950358b106ec8ceb83411d1a020", size = 5286120, upload-time = "2026-09-30T15:29:18.309Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/39/d1/55f8a3f2ef5d1529e16835ef10cf0fe3d559ce237b46dddc440c0bba3649/cryptography-50.0.2-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:c423ab384a46c4dff7217b2ea5ba2e11cffdeab6441acd04cf65a369caf0366c", size = 4111557, upload-time = "2026-09-30T15:29:20.155Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/23/ad/ac987755d00e1e64273760228d2635ae38dae2be83e3c6e0d3289d91dec3/cryptography-50.0.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:0ec5f09541743261e66e291b4a0cbf0fb2997aeaab6d9e9c740b9dba1b58d1c2", size = 3943588, upload-time = "2026-09-30T15:29:22.265Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/8d/6d585339bedf85d45044c85d8412dac53f2bb6f918e8b7777efba1787844/cryptography-50.0.2-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c5e67125c7dca78d199ec4e116aa93dbb83494808ecbb8211a2cb09b1bf41dbd", size = 4756166, upload-time = "2026-09-30T15:29:24.58Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bf/f1/1c1f6874e8550cfddd4b688ceb38cefb6ed15ceed224d56f133f3d88c214/cryptography-50.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee247f5c245c9a2fe7c8e2214e295918838e44e00a45a6718451e4004219e767", size = 4749145, upload-time = "2026-09-30T15:29:26.807Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c1/63/61b15dc1a8de03fe0adbe3fd7608b3ad5c73bf50993bbcb1faaa930afe33/cryptography-50.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dfe9763530994147d9af1def057a5b9658b00e8f8fe8743d144d1e0911c2e454", size = 4763638, upload-time = "2026-09-30T15:29:28.588Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fc/35/b345bdfa40c9126df1a9d33236aa98418367931b8725f84fc3ae2b98dc59/cryptography-50.0.2-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:58ddb5a8e3179d12f19e4ea34d2d32e9d63a4baa142c875c1eb59f41b7243acd", size = 5382217, upload-time = "2026-09-30T15:29:30.589Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4f/87/ef344a9e616871f2519c22d6afcda79ddd5d35e9592d95eb6e677608d055/cryptography-50.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f21e8a22c8605750c7af886bab299a363721264061b4ac0a30efb73cfd58efc5", size = 4781387, upload-time = "2026-09-30T15:29:32.605Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/90/5b/f2fdb13cd0b96f6f932c8627bb292a45f11c64d21620a8e120aee9a3b848/cryptography-50.0.2-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:9c8402a82ea0dc4ceeab793db05f0fafa8ca139ca34fcde5df0f596103c74107", size = 4403790, upload-time = "2026-09-30T15:29:34.374Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bc/ce/7e4f662b1e3c393513569e402cfc85ac7da0bd3d5435e122a3140219eb2d/cryptography-50.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:0ddc924c04591c2811ca024d62ecad4f7f6f08af8939c211438f48a16bd23602", size = 4764319, upload-time = "2026-09-30T15:29:36.149Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/3f/86ff33ce34cc0de6847fb96e035a1a760d81652e38643f617c02ad32ef7a/cryptography-50.0.2-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a6557e5f38e065ca9fbdaf7cfc7435ecb1d113aa81a022d1b51921ee7432e227", size = 5338560, upload-time = "2026-09-30T15:29:39.053Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/40/cf/6b5c8e2fd9202d98988ab7cb5cc5c991704c4ad55f492ff408e4969f83f1/cryptography-50.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:1981f1db4630889b9ef7803fadef12b056f428cb6b85c27ba57b774793b6093c", size = 4780973, upload-time = "2026-09-30T15:29:41.251Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/bf/8d6ebc7dded797bd0f0160d52188021211f011a2b164ef0ae1dac4587465/cryptography-50.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a8701d6b584d76e909e3d305b7d126b41439876a5aaf76cddc67fc230eafa2e", size = 4897738, upload-time = "2026-09-30T15:29:43.106Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d4/aa/f3f6e0de7e6253b8baa8b2d8fb9d50924fa75cee3d4624bd4bc1208ee923/cryptography-50.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ce47f66801c20ec6c6632453bb5960fe38939e9306970b48b3a5a26de7745d94", size = 5058280, upload-time = "2026-09-30T15:29:44.827Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f6/b6/a1faf3a27ae9405fb34b1713cc73b2d8a26b04d5c561578fa2e6ef3e5bb9/cryptography-50.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:4e81d95e5bafc2d6e34e4bed780e53e4d5b9a2f928573428aa4d35fbec1eb0de", size = 3854095, upload-time = "2026-09-30T15:29:46.782Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "edge-tts"
|
name = "edge-tts"
|
||||||
version = "7.2.8"
|
version = "7.2.8"
|
||||||
|
|
@ -321,6 +476,15 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" },
|
{ url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iniconfig"
|
||||||
|
version = "2.3.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itsdangerous"
|
name = "itsdangerous"
|
||||||
version = "2.2.0"
|
version = "2.2.0"
|
||||||
|
|
@ -682,6 +846,15 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/12/9c/c736d1961cf9ce0655278731b9430df41892ca2ded7b09dabcb340612158/piper_tts-1.8.0-cp39-abi3-win_amd64.whl", hash = "sha256:5da9bfdb05dfe15da3536859d422e605483ffa6d2b3ec2c5b9593bae6b5aa6a4", size = 34119688, upload-time = "2026-09-04T16:47:28.339Z" },
|
{ url = "https://files.pythonhosted.org/packages/12/9c/c736d1961cf9ce0655278731b9430df41892ca2ded7b09dabcb340612158/piper_tts-1.8.0-cp39-abi3-win_amd64.whl", hash = "sha256:5da9bfdb05dfe15da3536859d422e605483ffa6d2b3ec2c5b9593bae6b5aa6a4", size = 34119688, upload-time = "2026-09-04T16:47:28.339Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pluggy"
|
||||||
|
version = "1.6.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "propcache"
|
name = "propcache"
|
||||||
version = "0.5.4"
|
version = "0.5.4"
|
||||||
|
|
@ -791,6 +964,54 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/e4/04/d52c7016b04b6c5108f26691f9d33ec82a9b65d041f1a9c771137693d618/protobuf-7.36.2-py3-none-any.whl", hash = "sha256:bdb3a345d48db958e6ce1f18e508beb0cc981d64f24088427549c866cd039f1e", size = 179806, upload-time = "2026-09-17T20:07:58.211Z" },
|
{ url = "https://files.pythonhosted.org/packages/e4/04/d52c7016b04b6c5108f26691f9d33ec82a9b65d041f1a9c771137693d618/protobuf-7.36.2-py3-none-any.whl", hash = "sha256:bdb3a345d48db958e6ce1f18e508beb0cc981d64f24088427549c866cd039f1e", size = 179806, upload-time = "2026-09-17T20:07:58.211Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pycparser"
|
||||||
|
version = "3.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/ac/d3/eb1d3bc30dda12f7e69640ae2ac8cb10240b71fb73024ad528b7d2ae73da/pycparser-3.1.tar.gz", hash = "sha256:b3fc6dec06a8b2fefa0ed4ff92285306a5e3be9987bc5603c9edbdc4e492418f", size = 105946, upload-time = "2026-10-08T12:26:34.349Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/99/ce/b3ae9ee0324d991c860187be2a6ee436d27a3d02397eaddbb101ec901f3d/pycparser-3.1-py3-none-any.whl", hash = "sha256:f09d358c840bd147b79e55f2bc494f18ea869dc897f5852a8f5766b74f787882", size = 48709, upload-time = "2026-10-08T12:26:32.97Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pygments"
|
||||||
|
version = "2.21.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyjwt"
|
||||||
|
version = "2.15.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/43/ea/5194e52748b0da83d71e082d75496eaec6e58f419f5e184786ded517e6a9/pyjwt-2.15.1.tar.gz", hash = "sha256:4f259e80cdfb6b3fc18a7de51fd1ef9ec79652f25019bae68975ca2468a34df8", size = 121252, upload-time = "2026-09-28T18:40:42.598Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/ca/44de4e75f8aadc457f0634be3b542815078ded46dca30efb960edeecad6e/pyjwt-2.15.1-py3-none-any.whl", hash = "sha256:42d59d631f7768a1028a64c7ff581a9bf7519804daf91fc5b6c56e30eec5e193", size = 33860, upload-time = "2026-09-28T18:40:41.429Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.optional-dependencies]
|
||||||
|
crypto = [
|
||||||
|
{ name = "cryptography" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest"
|
||||||
|
version = "9.1.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
{ name = "iniconfig" },
|
||||||
|
{ name = "packaging" },
|
||||||
|
{ name = "pluggy" },
|
||||||
|
{ name = "pygments" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tabulate"
|
name = "tabulate"
|
||||||
version = "0.10.0"
|
version = "0.10.0"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue