Skip to content

feat: recovery sessions and account notice delivery, with parity hardening - #12

Merged
Bccorb merged 2 commits into
mainfrom
feat/v020-adapter
Oct 25, 2026
Merged

Bccorb merged 2 commits into
mainfrom
feat/v020-adapter

Conversation

@Bccorb

@Bccorb Bccorb commented Oct 25, 2026

Copy link
Copy Markdown
Contributor

The v0.20 adapter work for Go: parity with the Python and Rust adapters, the console framing headers, placeholder secrets, recovery sessions and account recovery notice delivery.

#11 Parity with Python and Rust

  • Redirects: the upstream client never follows a redirect (CheckRedirect returns http.ErrUseLastResponse). A caller-supplied HTTPClient is copied with that CheckRedirect, so the caller's own client is not changed. It also gets the 15 second timeout if it has none, as in Python.
  • Logs: upstream transport errors are logged without the *url.Error URL, so no path tokens or query strings end up in logs. This covers manifest routes, logout, the manifest fetch and JWKS.
  • JWKS: the key set goes stale after 10 minutes. A failed fetch with no key set backs off 5 seconds, and an unknown kid refetches at most every 30 seconds. While the API is unreachable a stale set keeps verifying, as in Python. The response is capped at 256 KiB.
  • exp: checkTimes now requires exp, for both HS256 cookies and RS256 API tokens.
  • Logout: a rejected credential still clears every held cookie.
  • Cookie transport body: a pick no longer lets token or refreshToken through.
  • Cookie TTL: capped at 400 days.
  • AuthServerURL: must be an http or https URL with a host.

#10 Console headers

ConsoleHandler now also forwards X-Frame-Options, Content-Security-Policy and X-Content-Type-Options.

Placeholder secrets (fells-code/seamless-auth-server#231)

New refuses dev-only-cookie-signing-key-replace-me, GRAB_FROM_SEAMLESS_AUTH_PORTAL_DEV and any value containing replace-me (case-insensitive) for CookieSecret and ServiceSecret. With InsecureCookies (the adapter's local development mode, as in Python and Rust) they are allowed, with a warning.

Recovery sessions (fells-code/seamless-auth-server#232)

  • RequireAuth answers 403 { "error": "enrollment_required" } for an access token with recovery: true, by cookie or bearer, and Authenticate returns the new ErrEnrollmentRequired. Options.AllowRecoverySessions admits such a token, and User.Recovery marks it.
  • In cookie transport, a successful POST /webauthn/register/finish or POST /totp/enroll/verify from a recovery session refreshes the session, so the new access cookie drops the claim. This also works when a silent refresh happened earlier in the same request: the refresh uses the token that refresh just issued. It goes through the shared refresher, so parallel tabs do not trip reuse detection. If the refresh fails, the enrollment response is still returned. Bearer clients hold their own refresh token, so the README tells them to call POST /auth/refresh after enrolling.

Account recovery delivery (fells-code/seamless-auth-api#500)

  • ManifestRoute.Notices reads the manifest's new optional notices field. The bundled manifest is synced from the API's committed adapter-manifest.json (0.19.4), which adds the recovery, recovery code and policy routes.
  • New opt-in Options.DeliverAccountNotices, false by default. A single Deliver callback cannot show whether it handles the new kinds. Only with this option (and Deliver) does a notices: true route send x-seamless-auth-delivery-mode: external, the delivery service token and x-seamless-auth-delivery-kinds: account_notice_email,account_notice_sms. Without it, external mode is not sent on that route, and the API sends the notices itself.
  • Every item in deliveries goes to Deliver. deliveries (and delivery) are stripped from the response in both transports. If at least one notice is delivered, the response is the API's. If none is, the adapter cancels the request: it sends POST /recovery/cancel with {"cancelToken": <fragment of cancelUrl>} and the proxy service token, then answers 503 recovery_notice_failed. The cancel URL and token are never logged.
  • Delivery gains Purpose, Notice, Subject, Text and CancelURL, plus Delivery* kind constants and PurposeAccountRecovery. Its String/GoString methods leave out the code and links. An unknown kind, or a notice kind without the opt-in, is logged and skipped, not passed to Deliver.
  • Pass-through is covered by tests: /recovery/*, /recovery-codes*, GET /recovery/requests, DELETE /recovery/requests/{id}, every /system-config/policy* route, 503 recovery_notice_failed and a 403 step_up_required body on an admin write.

Checks

$ gofmt -l .
(no output)
$ go vet ./...
(no output)
$ go test -race -count=1 ./...
ok  	github.com/fells-code/seamless-auth-go	1.455s
?   	github.com/fells-code/seamless-auth-go/conformance/refapp	[no test files]
$ GOTOOLCHAIN=go1.22.12 go vet ./...
(no output)
$ GOOS=linux GOTOOLCHAIN=go1.22.12 go test -c .
(compiles)
$ scripts/api-check.sh
## compatible changes
(19 additions, no incompatible changes)
Suggested version: v0.3.0

Go 1.22 test binaries cannot run on the local macOS (a dyld LC_UUID loader error), so the 1.22 test run is left to CI. The conformance suite was not run locally.

Closes #10
Closes #11

The console proxy now forwards X-Frame-Options, Content-Security-Policy and
X-Content-Type-Options from the auth API, so the console served on the app origin cannot be
framed.

Closes #10
…ening

Recovery sessions: RequireAuth refuses an access token carrying recovery: true with 403
enrollment_required, and Authenticate returns ErrEnrollmentRequired, unless
AllowRecoverySessions is set. User.Recovery marks one. After passkey registration or TOTP
enrollment from a recovery session, cookie transport refreshes so the new access cookie drops
the claim.

Account notices: with DeliverAccountNotices, routes the manifest marks notices ask for external
delivery with x-seamless-auth-delivery-kinds, hand every item in deliveries to Deliver and strip
it from the response. If no notice is delivered, the adapter cancels the recovery request with
the token from the cancel link and answers 503 recovery_notice_failed. Without the option the
API sends the notices itself. Delivery gains Purpose, Notice, Subject, Text and CancelURL, prints
without its credentials, and an unknown kind is logged and skipped. The bundled manifest is
synced with API 0.19.4 (recovery, recovery code and policy routes).

Parity with the Python and Rust adapters: the upstream client refuses redirects, a supplied
client is copied rather than changed, upstream errors are logged without the URL, the JWKS cache
expires after 10 minutes with a 5 second failure backoff and a 256 KiB cap, JWTs must carry exp,
logout clears the cookies when the credential is rejected, a cookie transport pick never carries
tokens, cookie lifetimes are capped at 400 days, and AuthServerURL must be http or https.

Placeholder secrets: New refuses the example cookie and service secrets unless InsecureCookies
is set.

Closes #11

fix: refuse upstream redirects and keep upstream URLs out of logs
fix: expire the JWKS cache, back off failed fetches and cap the key set size
fix: require exp, clear cookies on a rejected logout and never pick tokens into a body
fix: cap cookie lifetimes at 400 days and require an http or https AuthServerURL
fix: refuse placeholder cookie and service secrets outside insecure local mode
@Bccorb
Bccorb merged commit 6e8750f into main Oct 25, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant