unauthorized_client.
Dat is de hele foutmelding. Geen veld, geen hint, geen aanwijzing over welke helft van de uitwisseling Keycloak dwarszat. De client ID klopte, hij bestond, hij stond aan, en het secret dat ik in de applicatie had geplakt was het secret dat Keycloak me liet zien. Ik had het gecontroleerd. Twee keer.
Waar dit over ging
Een zelfgehoste Paperless achter Keycloak voor OIDC. Niets exotisch. Het soort koppeling dat je op een zondagmiddag doet en waarvan je verwacht dat hij twintig minuten kost.
Ik had het client secret die dag eerder geroteerd als onderdeel van een opruimactie, opnieuw gegenereerd in de Keycloak-adminconsole, in de config van de applicatie gezet, herstart, en wilde inloggen.
unauthorized_client.
Het voor de hand liggende uitsluiten
Eerste ingeving: ik heb de plakactie verprutst. Dus deed ik de enige controle die die vraag echt beslecht, namelijk beide kanten hashen in plaats van ernaar kijken.
# in de Keycloak-adminconsole, naar een bestand gekopieerd, zonder newline
printf '%s' "$secret_uit_keycloak" | sha256sum | cut -c1-16
# en op de applicatiehost
printf '%s' "$secret_in_config" | sha256sum | cut -c1-16
Dezelfde zestien tekens. Dezelfde lengte. Het secret was byte-identiek aan beide kanten van de verbinding.
Dat is het punt waarop het interessant wordt, want het schrapt de hele categorie verklaringen die ik tot dan toe met me meedroeg. Het was geen typefout, geen afsluitende newline, geen teken dat de shell onderweg naar het configbestand had opgegeten. De twee strings wáren dezelfde string.
Het volgende voor de hand liggende uitsluiten
Dan moet het de clientconfiguratie zijn. Die ben ik doorgelopen.
Client authentication aan. Standard flow aan. Geldige redirect URI's die matchen, inclusief het pad erachter. Client niet op public. Service accounts hier niet relevant. Realm klopt. Klokverschil tussen de twee hosts onder een seconde.
Ik heb de client vanaf nul opnieuw aangemaakt met dezelfde instellingen. Zelfde fout.
Op dat moment had ik ongeveer veertig minuten besteed aan een klus van twintig, en had ik zowel het secret als de configuratie uitgesloten, wat samen het hele probleem hoort te zijn.
Een andere vraag stellen
Als de waarde klopt en de configuratie klopt, blijft het transport over. Dus stopte ik met de vraag "klopt het secret" en begon ik te vragen "wat komt er aan de andere kant aan".
Daar is een specifieke test voor, en het is de nuttigste debug-zet die ik ken voor authenticatieproblemen. Stuur hetzelfde tokenverzoek op drie manieren en vergelijk wat eruit komt:
# 1. secret in de body, url-encoded
curl -s -o /dev/null -w '%{http_code}\n' \
-d grant_type=client_credentials \
--data-urlencode "client_id=$ID" \
--data-urlencode "client_secret=$SECRET" \
"$TOKEN_URL"
# 2. secret in de body, rauw
curl -s -o /dev/null -w '%{http_code}\n' \
-d "grant_type=client_credentials&client_id=$ID&client_secret=$SECRET" \
"$TOKEN_URL"
# 3. secret via HTTP Basic
curl -s -o /dev/null -w '%{http_code}\n' \
-u "$ID:$SECRET" \
-d grant_type=client_credentials \
"$TOKEN_URL"
Zijn alle drie het eens, dan klopt het secret niet. Wijken ze van elkaar af, dan klopt het secret wel en klopt er iets niet aan de codering.
Ik kreeg 200, 401, 200.
Drie verzoeken, hetzelfde secret, twee verschillende antwoorden. Het secret was niet fout. De middelste was fout.
Het teken
Het verschil tussen verzoek 1 en 2 is dat --data-urlencode de waarde escapet en -d niet. Wat in dit secret moest dan geëscapet worden?
gK7+xQ2mLp...
Een plusteken.
In een application/x-www-form-urlencoded body betekent + een spatie. Het is geen escape, geen randgeval, het is de definitie van het formaat: een letterlijke plus moet als %2B verstuurd worden, en een kale + decodeert bij aankomst als U+0020. Keycloak ontving een secret met een spatie waar mijn plus stond, vergeleek dat met de opgeslagen hash, en weigerde terecht.
De applicatie bouwde zijn tokenverzoek zonder het secret te coderen. In rust aan beide kanten byte-identiek, onderweg één teken anders.
De oplossing, en de betere oplossing
De directe oplossing kostte tien seconden: genereer het secret opnieuw tot er geen + in zit.
De echte oplossing is stoppen met machine-secrets genereren uit een alfabet met tekens waar andere lagen betekenis aan geven. Een secret reist door een form-body, een URL, een shell, een YAML-bestand, een systemd-unit en een Go-template voordat iets het valideert, en elk van die lagen kent betekenis toe aan een andere verzameling tekens. Lengte is gratis voor een machine. Het alfabet niet.
Machine-secrets zijn hier dus 40 tekens A-Za-z0-9 en verder niets. Dat is 238 bits, ruim voorbij alles wat ertoe doet, en geen enkele laag onderweg kan het verkeerd lezen.
mkpw -a # 40 tekens, A-Za-z0-9
De tekens die ik nu weiger, en waar ze elk bijten:
| Teken | Waar het breekt |
|---|---|
+ | form-urlencoded body, decodeert als spatie |
/ = | base64-padding, URL-paden, DSN's, rclone-config |
& ? # | query-string-scheiders, # kapt de rest van een URL af |
% | start een percent-escape, ook speciaal in systemd-units |
: @ | breken scheme://user:pass@host |
$ ` \ " ' ! | shell- en history-expansie |
| spatie | overal |
{ } | Go-templates, Helm, secret-templating |
- ~ aan het begin | een CLI leest het als vlag |
-_.~ zijn strikt genomen URL-veilig. Ze staan er toch op, want "geen uitzonderingen" is een regel waar je je om elf uur 's avonds niet in kunt vergissen.
Wat ik niet heb opgelost
Vier van de acht OIDC-clients in die realm hebben nog secrets van de oude generator, want een secret roteren betekent een gecoördineerde herstart en die heb ik niet ingepland. Ze werken. Ze blijven werken tot er eentje in een codepad belandt dat anders codeert, en dan raak ik weer een uur kwijt aan een foutmelding van één woord.
Ik schrijf dat hier mede op zodat het zichtbaar blijft.
De algemene vorm ervan
De les gaat niet over OIDC en eigenlijk ook niet over Keycloak, dat zich de hele tijd correct gedroeg en geen manier had om me iets nuttigers te vertellen.
Het is dat wanneer een credential aantoonbaar klopt en authenticatie toch faalt, de fout in de vórm zit en niet in de waarde. Vergelijk hashes om de waarde te beslechten. Stuur daarna hetzelfde verzoek op drie manieren en kijk waar de antwoorden uiteenlopen. Uiteenlopen betekent een coderingsfout, en coderingsfouten noemen zichzelf nooit in de foutmelding.