feat(auth): /auth/login + /callback + /logout endpoints (closes #16) #21
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "16-auth-endpoints"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
keycloak.tslibrary./auth/login(GET): generates HMAC-signed state via lib + inline PKCE code_verifier, sets transientwestside_admin_statecookie (Path=/auth/callback, Max-Age=600), 302s to Keycloak/authorizewith S256 challenge./auth/callback(GET): validates state before any token exchange, POSTs code to Keycloak/tokenwith confidential-client basic auth + PKCE verifier, sets encryptedwestside_admin_sessioncookie (Max-Age tracksrefresh_expires_in), 302s to the same-origin redirect captured at login time./auth/logout(POST): CSRF-defended via canonical-origin check, clears session cookie, 302s to Keycloak/logoutwithid_token_hint+ canonicalpost_logout_redirect_uri.Changes
src/routes/auth/login/+server.ts(new) — GET handler. Inline PKCE generation per #16 constraints (lib is frozen). Same-origin path guard on theredirectquery param.src/routes/auth/callback/+server.ts(new) — GET handler. State validation runs at line 109; token POST runs at line 126. Token-fetch failures and malformed responses both fall through to a generic502 auth_upstream_failed— Keycloak's response body is never echoed to the client.src/routes/auth/logout/+server.ts(new) — POST-only handler. Origin header must equalhttps://westside-admin.tail5b443a.ts.net.id_token_hintis best-effort: a missing or undecryptable session cookie still clears state and proceeds.src/lib/server/keycloak.tsandkeycloak.test.tsare untouched (frozen at #18 merge). No new exports were added to the lib. PKCEcode_verifiergeneration is inline viacrypto.randomBytes(32).toString('base64url')in/auth/login.Funnel-Auth Review (per
feedback_funnel_requires_auth)The westside-admin app sits behind a Tailscale funnel (
tailscale.com/funnel: "true") — public ingress reachable from the open internet. These three endpoints are the only routes outside the admin-role gate, so they carry the entire CSRF + replay + open-redirect surface for the property. The mitigations stacked here:verifyOidcStateruns at callback line 109;fetch(tokenEndpoint, …)runs at line 126. Any state mismatch, missing transient cookie, decrypt failure, or absentcode/statequery param short-circuits with HTTP 400 and the token POST never executes. This closes the classic OAuth CSRF where an attacker dangles a pre-bakedcodeat a logged-in admin's browser — without a matching transient cookie issued by this origin's/auth/login, the redemption hop refuses to run.HttpOnly; Secure; SameSite=Lax; Path=/auth/callback; Max-Age=600.HttpOnlydenies JS access (XSS can't exfil),Securedenies plaintext exposure,SameSite=Laxdenies cross-site POST (still permits the top-level GET redirect from Keycloak that the protocol requires),Path=/auth/callbackdenies sending the cookie to any other route on the property,Max-Age=600denies replay outside the 10-minute auth window. Session cookie carries the same posture atPath=/withMax-Age=refresh_expires_inso the cookie expires alongside the user's ability to refresh.encryptCookiePayload. The transient cookie holds{signed, code_verifier, redirect}; the session cookie holds{access_token, refresh_token, id_token, exp, refresh_exp}. AEAD tag failure returns null — tampered cookies cannot impersonate state.redirect_uri=https://westside-admin.tail5b443a.ts.net/auth/callbackis hardcoded as a module constant, never derived fromrequest.urlor any header. Keycloak's "Valid redirect URIs" entry mirrors this string. Drift breaks login intentionally — there is no wildcard surface for an attacker-controlled host to be substituted.stateandcodeleak in URL logs, the redemption hop requires thecode_verifierfrom the transient cookie on this browser. Logout'spost_logout_redirect_uriis hardcoded the same way — no host injection at SLO time.redirectquery param at/auth/loginis run through a same-origin path guard (safeRedirectPath) before being stashed inside the transient cookie. The callback's finalthrow redirect(302, ...)re-applies the same guard. Path-relative only; protocol-relative//evil.testand absolutehttps://evil.test/both collapse to/./auth/logoutis POST-only with an explicitOrigin === CANONICAL_APP_ORIGINcheck. A drive-by GET cannot log the user out; a cross-site form post is rejected with 403 before the cookie is cleared.502 auth_upstream_failedbody. Keycloak'serror_descriptionis never relayed — no oracle for an attacker probing client config.Test Plan
npm run check— 342 files / 0 errors / 0 warnings.npm test— 25/25 vitest cases passing (no new tests for these endpoints; per #16 they're integration-boundary, manual browser flow).npm run build— clean adapter-node build emitsauth/login/_server.ts.js,auth/callback/_server.ts.js,auth/logout/_server.ts.js.eslint src/routes/auth/— clean (zero warnings).callback/+server.ts,verifyOidcStateis invoked at line 109 and the firstfetch(tokenEndpoint, ...)is at line 126 — state validation runs strictly before token exchange.grep -nE "console\.[a-z]+" src/routes/auth/returns nothing — zero log statements in the new files; no token / verifier / ciphertext leak surface./auth/login→ Keycloak login form. Confirmstate,code_challenge,code_challenge_method=S256query params present./auth/callback?code=...&state=...→ 302 to/. Confirmwestside_admin_sessioncookie set withHttpOnly,Secure,SameSite=Lax,Path=/.statequery param manually → expect HTTP 400state_mismatch. Confirm token endpoint was NOT hit (Keycloak access log)./auth/callbackwith no transient cookie → expect HTTP 400state_missing./auth/logoutfrom the app → expect 302 to Keycloak/logout?.... Cookie cleared./auth/logout→ 405 (method not allowed).Review Checklist
src/routes/auth/).keycloak.tsandkeycloak.test.tsuntouched (frozen).Related Notes
Closes #16
forgejo_admin/westside-admin#2— Keycloak cookie SSR auth + admin role gate.#14(merged in #18 — provides thekeycloak.tslib these endpoints consume) and#15(parallel — thehooks.server.tsredirect target lands at/auth/login).#17—(unauthorized)403 page.project-westside-admin— owning project.sop-keycloak-client-creation— defines the redirect URI list and state requirement that the constants in this PR must mirror exactly.feedback_funnel_requires_auth— funnel-auth posture documented in the section above.QA Review (self-review, dev agent)
Diff is +382/-0 across three new files in
src/routes/auth/. No edits tosrc/lib/server/keycloak.tsorkeycloak.test.ts(frozen). I walked the diff against every AC in #16 and every Critical Correctness Item in my dispatch prompt.Acceptance criteria
/auth/loginGET → fresh state via lib, transient cookiewestside_admin_statewithHttpOnly; Secure; SameSite=Lax; Path=/auth/callback; Max-Age=600, 302 to Keycloak/authorizewith all required params (client_id,redirect_uri,response_type=code,scope=openid profile email,state,code_challenge_method=S256,code_challenge)login/+server.ts:85-91for cookie attrs,:94-101for the URL builder/auth/callbackmismatched state → HTTP 400state_mismatch. Token POST must NOT have runverifyOidcStateatcallback/+server.ts:109precedes the onlyfetch(tokenEndpoint, ...)at:126. Mismatch path throws before any token POST is constructed/auth/callbackmissing transient cookie → HTTP 400state_missing:79-82/auth/callbackvalid state → token exchange → encrypt viaencryptCookiePayload→westside_admin_session(HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=refresh_expires_in) → clear transient cookie → 302 to original target:114-122builds the body,:126-140exchanges,:178-189sets the session cookie,:192clears transient,:194redirects:135-138for 4xx/5xx,:141-153catches network. Body of upstream is neverawait resp.text()'d into a response/auth/logoutPOST clears session cookie, 302s to Keycloak/logoutwithpost_logout_redirect_uri=https://westside-admin.tail5b443a.ts.net/andid_token_hint={id_token}when knownlogout/+server.ts:60clears,:64-71builds the URL,:73redirects/auth/logoutGET does NOT work — POST only, with CSRF protectionPOSTis exported (SvelteKit returns 405 for any unexported method). Origin header check atlogout/+server.ts:48-51enforces the canonical origin/tokenbody in any loggrep -nE "console\." src/routes/auth/returns zero matchesCritical correctness items (from dispatch prompt)
:109, fetch at:126. Also, both error-param and missingcode/stateshort-circuit before reaching the verify branch, so even malformed callbacks never hit the token endpoint.HttpOnly; Secure; SameSite=Lax; Path=/auth/callback; Max-Age=600— confirmed inlogin/+server.ts:85-91. The encrypted payload is{signed, code_verifier, redirect}so the callback never re-reads any unsigned query state.HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=refresh_expires_in(with a 1h fallback if Keycloak omits it). Confirmedcallback/+server.ts:183-189.CANONICAL_REDIRECT_URI = 'https://westside-admin.tail5b443a.ts.net/auth/callback'— module constant, never derived from request. Mirrored in callback for the token POSTredirect_urifield. Logoutpost_logout_redirect_uri = 'https://westside-admin.tail5b443a.ts.net/'.csrf_check_failedbefore clearing the cookie. A drive-by GET is 405; a cross-site form post is 403.!resp.okbranch and the network-throw branch both end inthrow error(502, 'auth_upstream_failed'). Upstream body is never read into the response.fetchonly, no axios/got: confirmed. Singlefetch(tokenEndpoint, ...)call.keycloak.tsfrozen: diff touches zero lines of the lib or its tests.urlSafeBase64(randomBytes(32))produces the same bytes asrandomBytes(32).toString('base64url'). SHA-256 challenge derived inline. No new export added tokeycloak.ts.Observations / minor style notes (non-blocking)
randomBytes(32).toString('base64url')literally. I used aurlSafeBase64()helper that produces byte-identical output. Since Node 16+ supports'base64url'natively, the helper could be replaced with.toString('base64url')for two fewer lines — but the helper also serves the SHA-256 digest path, so it pays its keep. Functionally equivalent.state_missing(not a third error code). The issue enumerates onlystate_missingandstate_mismatch; treating "ciphertext is garbage" as "functionally no cookie" is a reasonable read of the spec. Caller behavior is identical: re-initiate at/auth/login.oauthErrorvariable in the callback is only checked for truthiness; could beurl.searchParams.has('error'). Style nit; ESLint and prettier are clean.Verification log
npm run check→342 FILES 0 ERRORS 0 WARNINGS 0 FILES_WITH_PROBLEMSnpm test→25 passed (25)— no regressions inkeycloak.test.tsnpm run build→ adapter-node build emits all three handlers cleanlynpx eslint src/routes/auth/→ cleannpx prettier --check src/routes/auth/→ cleangrep -nE "console\." src/routes/auth/→ no matches16-auth-endpointspushed to origin, offorigin/mainat982df5bVERDICT: APPROVED
All ACs met. All critical correctness items honored. No blocking issues. Ready for Lucas's review and merge decision. Per
feedback_no_merge_without_approval, not merging.PR #21 Review
DOMAIN REVIEW
Stack: TypeScript / SvelteKit server endpoints / OIDC authorization-code-with-PKCE + RP-initiated logout. Three new files under
src/routes/auth/. Frozenkeycloak.tslib consumed via documented exports only.1. State-before-token-exchange ordering (CSRF defense)
Read
src/routes/auth/callback/+server.tstop-to-bottom. The control flow before anyfetch(tokenEndpoint, ...)is:cookies.get(STATE_COOKIE_NAME)→ 400state_missingif absentdecryptCookiePayload(stateCookie)+isTransientStateBlobshape check → 400 if tamper / wrong keyurl.searchParams.get('error')Keycloak-side error short-circuit → 400auth_failed(generic body, noerror_descriptionecho)code+statequery params present → 400 if missingverifyOidcState(stateParam, decrypted.signed)→ 400state_mismatchon failurefetch(tokenEndpoint, ...)executeEvery failure path in steps 1-6 short-circuits with
throw error(...)before the token POST. The classic OAuth CSRF (attacker dangles a pre-bakedcodeat a logged-in admin) is closed: without a matching transient cookie issued by this origin's/auth/loginAND a matching HMAC-signed state, the redemption hop refuses to run. Ordering is correct.2. Missing transient state cookie returns 400
Lines ~78-80:
if (!stateCookie) { throw error(400, 'state_missing'); }. Returns 400, not 500, not "valid because absent". Replay/late-callback hardening is correct.3. PKCE cryptographic correctness
In
src/routes/auth/login/+server.ts:randomBytes(32)fromnode:crypto— 256 bits CSPRNG, base64url-encoded → 43-char verifier (RFC 7636 compliant: 43-128 chars).code_challenge=base64url(SHA-256(code_verifier)).Math.random. NoDate.now-derived material. No PRNG reuse.code_challenge_method=S256set on the/authorizeURL.Correct per RFC 7636.
4. CSRF on logout
if (origin !== CANONICAL_APP_ORIGIN) { throw error(403, 'csrf_check_failed'); }— strict equality againsthttps://westside-admin.tail5b443a.ts.net.nullOrigin:null !== 'https://...'→ 403. Rejects sandboxed iframes / restricted referrer policies (those wouldn't carry a valid session cookie anyway).Acceptable for v1. SvelteKit's built-in form-action CSRF token would be slightly more robust against future browser quirks, but the Origin check correctly defends against the actual threat model (third-party drive-by). Worth a follow-up nit if/when the logout button is wired into a SvelteKit form action — switching to the framework's CSRF token is one-line cheaper than maintaining the Origin allowlist.
5. Redirect URI exactness
const CANONICAL_REDIRECT_URI = 'https://westside-admin.tail5b443a.ts.net/auth/callback';— hardcoded module constant in both login and callback. Not derived fromrequest.url,Hostheader, or any env var. Used identically in:/auth/login:authorize.searchParams.set('redirect_uri', CANONICAL_REDIRECT_URI)/auth/callback:body.append('redirect_uri', CANONICAL_REDIRECT_URI)in token POSTNo wildcards, no env-var substitution that could differ from Keycloak client config. Per
sop-keycloak-client-creation, Keycloak's "Valid redirect URIs" must mirror this exact string.Correct.
6. Token POST failure body relay
Three failure paths in the callback's token-exchange block:
!resp.ok:throw error(502, 'auth_upstream_failed')— generic body, noresp.text()/resp.json()echo.try: catch block → same generic 502 (with the SvelteKit-error re-throw guard so existing 4xx/5xx propagate cleanly).access_tokennot a string, etc.): same generic 502.Keycloak's
error_descriptionand realm structure are never relayed. Correct — no oracle exposed.Additional checks
/auth/logoutPOST-only: onlyPOSTis exported. SvelteKit auto-returns 405 for unhandled methods. GET cannot trigger logout. Correct.anycasts: usesunknown+ type guards (isTransientStateBlob,isSessionBlob) andas Record<string, unknown>for narrowing. The singleas KeycloakTokenResponsefollows shape validation. Noanyanywhere. Correct.+382/-0), all undersrc/routes/auth/.keycloak.ts,keycloak.test.ts,hooks.server.ts,(unauthorized)/*,health/*untouched. Correct.safeRedirectPathrejects//evil.test,https://evil.test/, control chars, and non-strings. Applied at both/auth/login(capture) and/auth/callback(final redirect). Defense in depth.HttpOnly; Secure; SameSite=Lax; Path=/auth/callback; Max-Age=600. Session cookie same posture atPath=/withMax-Age=refresh_expires_in. Correct.BLOCKERS
None. The OIDC protocol surface is implemented correctly:
NITS
No unit tests for the three endpoints. PR documents this as #16's manual-browser-flow constraint, and the consumed lib (
keycloak.ts) is tested. A vitest with mockedfetchasserting "state mismatch never reaches token POST" would convert the code-walk verification into a regression-proof guard. Non-blocking, recommend follow-up ticket.Logout CSRF could migrate to SvelteKit form-action token. Origin check is correct for the current threat model, but when logout gets wired into a UI form,
<form method="POST" action="/auth/logout">with SvelteKit's built-in CSRF token would close the (very narrow)null-Origin edge cases without code in this handler.oauthErrorcheck sits after cookie decrypt. Minor: if Keycloak returns?error=access_denied, we still decrypt the cookie before short-circuiting. The decrypt is needed for cleanup anyway, so this is a wash. Could swap for clarity. Non-blocking.as { status: unknown }in the catch block is a defensible narrowing pattern but could use a typed guard (function isHttpError(e: unknown): e is { status: number }) for symmetry with the other guards in the file. Pure style.SOP COMPLIANCE
16-auth-endpointsfollows{issue}-{kebab-purpose}conventiontemplate-pr-bodysop-keycloak-client-creation(redirect URI exact-match invariant)feedback_funnel_requires_auth— documents state-before-token-exchange, cookie attributes, AES-256-GCM encryption, exact-match redirect URI, PKCE S256, open-redirect closure, logout CSRF, generic upstream errors. This is exemplary funnel-auth documentation..envfiles / credentials committedsrc/routes/auth/)keycloak.tsandkeycloak.test.tsuntouched (frozen at #18)npm run check342/0/0 andnpm test25/25 claimed; no regressions expected since lib is frozenPROCESS OBSERVATIONS
feedback_nits_to_epilogue, QA nits never dismiss./auth/loginis reachable without the hook gate, but full login flow needs #20 too.VERDICT: APPROVED