Skip to content

feat(oauth)!: fallback discovery na /o/*, tekstowy tryb logowania, wymagany BPP_BASE_URL#5

Merged
mpasternak merged 3 commits into
mainfrom
feat-oauth-discovery-fallback-i-tryb-tekstowy
Jul 22, 2026
Merged

feat(oauth)!: fallback discovery na /o/*, tekstowy tryb logowania, wymagany BPP_BASE_URL#5
mpasternak merged 3 commits into
mainfrom
feat-oauth-discovery-fallback-i-tryb-tekstowy

Conversation

@mpasternak

Copy link
Copy Markdown
Member

Wyszło przy podłączaniu instancji z nowym API (publikacje.up.lublin.pl).
Warstwa anonimowa działała od ręki — szukaj_publikacji, szukaj_autora,
slownik odpowiadały poprawnie. Zablokowana była wyłącznie autoryzacja, i to
z trzech niezależnych powodów.

1. Discovery padało na 403 mimo sprawnego OAuth

discover() czyta metadane RFC 8414 spod
/.well-known/oauth-authorization-server. Część wdrożeń blokuje na brzegu cały
/.well-known/ regułą nginksa na pliki ukryte (location ~ /\.) i oddaje 403 —
podczas gdy serwer autoryzacji jest w pełni sprawny:

Endpoint Status na żywej instancji
/o/authorize/ 302 → /accounts/login/
/o/token/ 400 (czeka na parametry)
/o/register/ (DCR) 201, poprawny client_id
/.well-known/oauth-authorization-server 403 nginx

raise_for_status() wywracało logowanie na pierwszym kroku, więc komunikat
brzmiał „nie da się zalogować", choć logowanie było w pełni wykonalne.

discover() cofa się teraz na konwencjonalne ścieżki django-oauth-toolkit na
tym samym hoście. Fallback nie zgaduje hosta ani sekretów, a prawidłowe
metadane zawsze mają pierwszeństwo
— instancja z serwerem autoryzacji pod
innym adresem nie zostanie nadpisana (osobny test). Fallback obejmuje 403, 404,
200-z-HTML-em, JSON bez wymaganych pól i błąd sieci.

Naprawa u źródła jest po stronie deploymentu:
iplweb/bpp-deploy#21. Fallback
zostaje jako odporność klienta — ale nie naprawia natywnego przycisku
„authorize" w trybie HTTP, bo tam discovery robi sam klient Claude.

2. Logowanie zakładało przeglądarkę na tej samej maszynie

Przy pracy zdalnej (SSH, serwer bez GUI) callback na 127.0.0.1 nigdy nie
wraca — przeglądarka stoi gdzie indziej.

login() wypisuje teraz adres autoryzacji także tekstem i równolegle z
loopbackiem przyjmuje wklejkę użytkownika: pełny adres przekierowania albo sam
parametr code. Obie drogi celują w tę samą kolejkę, więc nie ma trybów do
wyboru — automat działa, kiedy może, wklejka ratuje, kiedy nie może.

3. Domyślny BPP_BASE_URL cicho kierował na cudzą uczelnię

BREAKING CHANGE.

Domyślny host to było https://bpp.umlub.pl. Serwer jest z założenia
wielo-instancyjny, więc każdy zaszyty host faworyzuje jedną uczelnię — ale
gorszy jest tryb awarii: użytkownik, który zapomni ustawić BPP_BASE_URL, nie
dostaje błędu, tylko kompletną, poprawnie wyglądającą bibliografię cudzej
instytucji. Błąd cichy i niewykrywalny po samych wynikach.

Do tego domyślna instancja była przestarzała: bpp.umlub.pl nie ma Fazy 0
(brak /api/v1/szukaj/), nie ma /o/ ani /api/v1/whoami/. Domyślna
konfiguracja celowała więc w serwer, na którym połowa narzędzi nie działa.

Config.from_env() podnosi teraz BrakKonfiguracji z komunikatem mówiącym, co
ustawić (exit 2, bez tracebacku). --help działa bez konfiguracji. README nie
wymienia już żadnej konkretnej uczelni.

Bezpieczeństwo

Rozluźnienie kontroli state (potrzebne przy wklejeniu samego kodu, który
state nie niesie) było w pierwszym podejściu omijalne z sieci — znacznik
_recznie siedział w tym samym słowniku parametrów, który handler loopbacku
buduje z query stringa. Żądanie:

GET http://127.0.0.1:<port>/callback?code=PODSZYTY&_recznie=1

przechodziło bez żadnej weryfikacji state. Port jest efemeryczny, ale
skanowalny ze złośliwej strony — dokładnie ten scenariusz CSRF, przed którym
state ma chronić (RFC 8252).

Naprawione osobnym commitem: _parsuj_wklejone zwraca (params, goly_kod),
kolejka niesie krotkę, a loopback wstawia False na sztywno — źródło znacznika
jest strukturalnie niedostępne dla sieci. Rozluźnienie dotyczy wyłącznie tekstu
wklejonego na stdin. Test regresyjny odtwarza atak i wymaga odrzucenia.

Weryfikacja

  • 157 testów zielonych, ruff czysty.
  • discover() na żywej instancji zwraca poprawną trójkę endpointów mimo 403.
  • Pełny łańcuch przetestowany end-to-end na żywym serwerze: bpp-mcp login
    token w store → whoami 200 → zapytanie_rekord("rok >= 2024 and impact_factor > 5") → 318 trafień.
  • CLI bez BPP_BASE_URL: czytelny komunikat + exit 2; --help działa.

Michał Pasternak and others added 3 commits July 22, 2026 17:20
…klejka

Dwie przeszkody wyszly przy podlaczaniu instancji publikacje.up.lublin.pl.

1) Discovery (RFC 8414) padalo na 403. Brzegowy nginx blokuje tam caly
   /.well-known/ regula na pliki ukryte (`location ~ /\.`), mimo ze serwer
   autoryzacji dziala bez zarzutu: /o/authorize/ -> 302, /o/token/ -> 400,
   /o/register/ -> 201 z poprawnym client_id. discover() wywracalo logowanie
   na pierwszym kroku, choc bylo w pelni wykonalne.

   discover() cofa sie teraz na konwencjonalne sciezki django-oauth-toolkit
   na TYM SAMYM hoscie. Fallback nie zgaduje hosta ani sekretow, a prawidlowe
   metadane maja pierwszenstwo — instancja z serwerem autoryzacji pod innym
   adresem nie zostanie nadpisana (osobny test).

2) Logowanie zakladalo, ze przegladarka i bpp-mcp stoja na tej samej maszynie.
   Przy pracy zdalnej callback na 127.0.0.1 nigdy nie wraca.

   login() wypisuje teraz adres autoryzacji takze tekstem i rownolegle z
   loopbackiem przyjmuje wklejke uzytkownika (pelny adres przekierowania albo
   sam parametr code). Obie drogi celuja w ta sama kolejke — wygrywa szybsza,
   wiec nie ma trybow do wyboru.

   Kontrola state pozostaje twarda na loopbacku. Rozluzniona jest WYLACZNIE
   dla recznie wklejonego golego kodu (znacznik _recznie), ktory z natury nie
   niesie state. Wklejka z niezgodnym state jest odrzucana — osobny test,
   zeby wklejka nie stala sie furtka omijajaca CSRF-ochrone.

Zweryfikowane na zywej instancji: discover() zwraca poprawna trojke
endpointow mimo 403 na /.well-known/. 152 testy zielone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019D6Zytbsj8xjZCx8etyMeN
…z sieci

Poprzedni commit obiecywal, ze kontrola state pozostaje twarda na loopbacku.
Nie byla. Znacznik `_recznie` siedzial w tym samym slowniku parametrow, ktory
_CallbackHandler buduje z query stringa przyjetego na loopbacku, a login()
czytal go wlasnie stamtad. Zadanie:

    GET http://127.0.0.1:<port>/callback?code=PODSZYTY&_recznie=1

dawalo params bez `state` i z ustawionym znacznikiem, wiec warunek
`not (params.get("_recznie") and otrzymany_state is None)` wychodzil falszem
i kod przechodzil bez zadnej weryfikacji state. Port jest efemeryczny, ale
skanowalny ze zlosliwej strony w przegladarce — to dokladnie ten scenariusz
CSRF, przed ktorym state ma chronic (RFC 8252).

Naprawa: `_parsuj_wklejone` zwraca teraz `(params, goly_kod)`, a kolejka
niesie krotke. Loopback wstawia `False` na sztywno, wiec zrodlo znacznika jest
strukturalnie niedostepne dla sieci — nie da sie go podszyc zadnym parametrem.
Rozluznienie state dotyczy wylacznie tekstu wklejonego na stdin.

Test regresyjny odtwarza atak (callback z `_recznie=1` bez `state`) i wymaga
odrzucenia; przed poprawka pada, po poprawce 153/153 zielone.

Znalezione przez automatyczny przeglad bezpieczenstwa.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019D6Zytbsj8xjZCx8etyMeN
… umlub

Domyslny host wskazywal na https://bpp.umlub.pl. Serwer jest z zalozenia
wielo-instancyjny, wiec kazdy zaszyty host faworyzuje jedna uczelnie, ale
gorszy jest tryb awarii: uzytkownik, ktory zapomni ustawic BPP_BASE_URL,
NIE dostaje bledu — dostaje kompletna, poprawnie wygladajaca bibliografie
CUDZEJ instytucji. Blad cichy i trudny do zauwazenia po wynikach.

Doszlo do tego, ze domyslna instancja byla przestarzala: bpp.umlub.pl nie ma
Fazy 0 (brak /api/v1/szukaj/), nie ma /o/ ani /api/v1/whoami/. Czyli domyslna
konfiguracja celowala w serwer, na ktorym polowa narzedzi nie dziala.

Config.from_env() podnosi teraz BrakKonfiguracji z komunikatem mowiacym,
co ustawic. Pusty/bialy string traktowany jak brak.

BREAKING CHANGE: konfiguracje bez BPP_BASE_URL przestaja startowac. Swiadomie
— glosna odmowa jest lepsza niz ciche odpytywanie nie tej bazy, co trzeba.
Blad wychodzi na stderr z instrukcja i exit 2, bez tracebacku; `--help`,
`login` i `logout` dzialaja normalnie po ustawieniu hosta, a `--help` takze
bez niego (parse_args idzie przed odczytem konfiguracji).

Modulowy `mcp` w server.py budowal sie przy imporcie, wiec brak zmiennej
wywracalby import tracebackiem zanim main() zdazy cokolwiek wypisac — stad
opakowanie w try i `mcp = None`, a czytelny komunikat wychodzi z main().
conftest ustawia host przed importem modulow testowych (fixtury sa na to
za pozno — server buduje sie na etapie kolekcji).

README bez zadnej konkretnej uczelni: wszystkie przyklady na
bpp.twoja-uczelnia.pl.

157 testow zielonych.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019D6Zytbsj8xjZCx8etyMeN
@mpasternak
mpasternak merged commit 7769db9 into main Jul 22, 2026
4 checks passed
@mpasternak
mpasternak deleted the feat-oauth-discovery-fallback-i-tryb-tekstowy branch July 22, 2026 16:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant