Jak funguje přihlášení přes M-ID (OAuth 2.1 a PKCE)

Co se děje mezi kliknutím na „Přihlásit přes M-ID“ a přihlášenou aplikací, a proč je každý krok tam, kde je.

  • 9 min čtení
  • kvíz: 10 z 15 otázek
  • certifikát na 24 měsíců

M-ID je jednotné přihlášení pro aplikace rodiny melichar.dev na adrese https://id.melichar.dev. Uživatel má jeden účet a jedno heslo (nebo passkey) a do každé aplikace se přihlašuje přes M-ID. Aplikace tak nikdy nevidí jeho heslo: dostane jen potvrzení, kdo se přihlásil.

Technicky jde o autorizační kód s PKCE ve stylu OAuth 2.1, doplněný o OpenID Connect (popis serveru najdete na /.well-known/openid-configuration). Tento text projde celý tok krok za krokem a u každého kroku vysvětlí, před jakým útokem chrání.

Kdo se toku účastní

  • Prohlížeč uživatele, který jen přenáší přesměrování mezi stranami.
  • Server aplikace (například Attesto), která chce vědět, kdo je uživatel.
  • M-ID, které uživatele přihlásí a aplikaci vydá jednorázový kód.

Důležité je, co se odehrává na serveru aplikace a co v prohlížeči. Tajné hodnoty se potkávají jen na serveru; prohlížeč přenáší pouze to, co smí vidět kdokoli.

Registrace aplikace

Každá aplikace má v M-ID záznam klienta (ClientApp) s identifikátorem client_id a seznamem povolených návratových adres redirectUris.

Návratová adresa (redirect_uri) musí s registrovanou adresou souhlasit přesně, znak po znaku: schéma, doména, port i cesta. Neplatí žádné zástupné znaky, žádné query parametry ani shoda podle začátku adresy. Díky tomu útočník nemůže nechat kód poslat na svou adresu.

Aplikace si redirect_uri skládá z pevně nastavené veřejné adresy (proměnná APP_URL), ne z hlavičky Host příchozího požadavku. Za reverzní proxy nebo v Dockeru může hlavička obsahovat interní adresu a přihlášení by selhalo, nebo by se dalo zneužít.

Klient je buď veřejný (nemá tajný klíč), nebo důvěrný (má client_secret). U veřejného klienta je PKCE povinné vždy. Má-li klient tajný klíč, M-ID ho při výměně kódu vyžaduje. Attesto je důvěrný klient a PKCE používá také.

Krok 1: začátek přihlášení

Když uživatel klikne na „Přihlásit“, server aplikace připraví tři hodnoty:

  1. state: alespoň 32 náhodných bajtů v kódování base64url. Spojuje začátek toku s jeho koncem.
  2. code_verifier: 32 náhodných bajtů v base64url (43 znaků). Je to tajemství, které nikdy neopustí server aplikace a cookie.
  3. code_challenge: base64url(SHA-256(code_verifier)), tedy otisk verifieru. Metoda je vždy S256; metodu plain M-ID odmítne.

Hodnoty state a code_verifier si aplikace uloží do krátkodobé „pending“ cookie: HttpOnly (JavaScript na stránce ji nepřečte), SameSite=Lax, platnost 15 minut a na HTTPS s příznakem Secure a prefixem __Host-.

Pak prohlížeč přesměruje na přihlašovací stránku M-ID s parametry response_type=code, client_id, redirect_uri, scope=openid email profile, state, code_challenge a code_challenge_method=S256.

Proč otisk, a ne samotný verifier? Adresa s parametry projde prohlížečem a může skončit v historii nebo v logu. Z otisku SHA-256 verifier spočítat nejde, takže kdo zachytí adresu, nemá v ruce nic, co by mu k výměně kódu stačilo.

Krok 2: návrat do aplikace

Po přihlášení M-ID přesměruje prohlížeč zpět na redirect_uri s parametry code a state.

Aplikace nejprve načte pending cookie a porovná state z adresy s hodnotou v cookie. Porovnání probíhá v konstantním čase (crypto.timingSafeEqual), aby z délky odpovědi nešlo hodnotu postupně uhodnout. Pak cookie hned smaže: platí jen jednou.

Pokud tento prohlížeč tok nezačal, cookie chybí nebo state nesouhlasí, a aplikace kód nevymění. Tím se brání útoku login CSRF, kdy by útočník podstrčil oběti svůj vlastní kód a oběť by nevědomky pracovala pod účtem útočníka.

Pokud adresa místo kódu nese parametr error, aplikace uživateli srozumitelně oznámí, že přihlášení se nepovedlo.

Krok 3: výměna kódu za tokeny

Výměna probíhá jen na serveru aplikace: server pošle POST /api/auth/exchange s grant_type=authorization_code, kódem, client_id, stejnou redirect_uri jako v kroku 1 a code_verifier z cookie. Důvěrný klient přidá client_secret.

M-ID spočítá SHA-256 z došlého verifieru a porovná ho s code_challenge z kroku 1. Ukradený kód je tak bez verifieru k ničemu.

Autorizační kód platí 60 sekund a jen jednou: první pokus o výměnu ho spotřebuje, ať uspěje, nebo ne. Neznámý, prošlý, už použitý kód, jiná redirect_uri nebo špatný verifier skončí chybou invalid_grant; chybný nebo chybějící tajný klíč chybou invalid_client.

Co aplikace dostane

Úspěšná výměna vrátí:

  • access_token s platností 15 minut (expires_in: 900) a volitelně id_token;
  • objekt user s položkami id, email, isEmailVerified, jménem a adresou avatara;
  • seznam organizations, pokud aplikace pracuje s organizacemi.

Obnovovací (refresh) token klientské aplikace nedostávají. Proto si aplikace po přihlášení vytvoří vlastní session (u Attesta podepsaná cookie na 30 dní) a token M-ID dál nepotřebuje. Když session vyprší, uživatel projde tokem znovu; má-li v M-ID platné přihlášení, proběhne to bez zadávání hesla.

Místní účet aplikace páruje podle user.id (v OpenID Connect je to sub), ne podle e-mailu: e-mail se může změnit, identifikátor ne. E-mailu aplikace věří, jen když isEmailVerified je true.

Odhlášení

Odhlášení má dva kroky a záleží na jejich pořadí. Aplikace nejprve smaže svou session a potom přesměruje prohlížeč na /api/auth/end-session s client_id a post_logout_redirect_uri.

Bez druhého kroku zůstane v prohlížeči přihlašovací cookie M-ID a další přihlášení na stejném zařízení proběhne potichu, bez hesla. Adresa post_logout_redirect_uri musí mít stejný původ (origin) jako některá registrovaná redirect_uri klienta; jinak M-ID uživatele pošle na svou přihlašovací stránku.

Odebrání přístupu a webhooky

Uživatel může aplikaci v M-ID kdykoli odebrat přístup (Dashboard → Aplikace → Odebrat přístup). M-ID pak smaže nevyužité kódy pro tuto aplikaci a při dalším přihlášení po uživateli chce nový souhlas tlačítkem Povolit přístup. Souhlasovou obrazovku zobrazuje M-ID, aplikace ji neimplementuje.

Aby aplikace o odebrání věděla hned, přihlásí se k webhookům. M-ID pošle POST s událostí user.app_revoked a aplikace smaže všechny místní session daného uživatele. Událost user.updated hlásí změnu profilu, ping je zkušební.

Každý webhook nese hlavičku X-Melichar-Signature: t=<unix čas>,v1=<hex>. Aplikace spočítá HMAC-SHA256 se sdíleným tajemstvím nad řetězcem "<t>.<tělo požadavku>" a porovná ho s v1. Požadavek, jehož čas t se od aktuálního liší o víc než 300 sekund, odmítne, aby nešlo znovu přehrát starou zprávu.

Shrnutí

  • redirect_uri přesně podle registrace a z pevné APP_URL.
  • PKCE S256: v prohlížeči jen otisk, verifier zůstává na serveru.
  • state v HttpOnly cookie, ověřený v konstantním čase, cookie smazaná po prvním použití.
  • Výměna kódu jen na serveru; kód platí 60 sekund a jednou.
  • Účet podle user.id, e-mail jen ověřený, vlastní session místo refresh tokenu.
  • Odhlášení: nejdřív vlastní session, pak end-session.
  • Webhooky s ověřeným podpisem a oknem 300 sekund; na user.app_revoked smazat session.