Skip to content

PPT-2536: auth.cr drop-in fixes (public clients, verified cookie, PKCE, refresh identity, session secret, OAuth provider params)#3

Merged
camreeves merged 15 commits into
masterfrom
PPT-2536-dropin-merge
Jul 17, 2026
Merged

PPT-2536: auth.cr drop-in fixes (public clients, verified cookie, PKCE, refresh identity, session secret, OAuth provider params)#3
camreeves merged 15 commits into
masterfrom
PPT-2536-dropin-merge

Conversation

@camreeves

Copy link
Copy Markdown
Contributor

Lands the PPT-2536 drop-in fixes that make auth.cr a working replacement for the legacy Ruby auth, validated end-to-end (Backoffice logs in + refreshes tokens through the real browser flow against the local stack).

Fixes (each a distinct commit)

  1. fix(oauth): public clients without a client_secret — the token endpoint required client_secret, 422-ing every Backoffice (public/SPA) token exchange. Public clients now authenticate via PKCE; client_credentials stays confidential-only.
  2. fix(session): issue the nginx-validated verified asset-access cookie — auth.cr never issued it, so nginx bounced every SPA asset to /auth/login (infinite loop). Restored, keyed on the shared SECRET_KEY_BASE.
  3. fix(oauth): accept base64url PKCE challenges (RFC 7636) — authly only accepted standard-base64; ts-client/Backoffice send base64url, so real handshakes were rejected. Normalized in auth.cr.
  4. fix(oauth): preserve user identity across token refresh — authly's refresh grant minted a random sub; the refreshed token lost the user (and the u{}/aud claims). Recovered via the existing monkey-patch pattern.
  5. fix(session): key COOKIE_SESSION_SECRET off SECRET_KEY_BASE — so sessions survive a restart/redeploy in both docker-compose and k8s (auth doesn't receive PLACE_SERVER_SECRET in k8s).
  6. fix(oauth): restore authorize_params + info_mappings comma-fallback — Google access_type=offline/prompt=consent and Azure userPrincipalName mappings, entirely in auth.cr (no multi_auth edit).

Testing

  • Real browser e2e (Playwright): Backoffice login → PKCE token exchange with no client_secret → refresh preserves the user sub → rest-api accepts the token (200).
  • Session-survives-restart verified (cookie fix).
  • Per-area specs green; CI runs the full suite.

🤖 Generated with Claude Code

camreeves and others added 15 commits July 17, 2026 12:39
When COOKIE_SESSION_SECRET is unset the service generated an ephemeral
per-boot key, so sessions did not survive a restart and did not match
across replicas — the PlaceOS platform does not provide that env var
(the Ruby auth used SECRET_KEY_BASE), so a real deployment logs every
user out on each redeploy and fails intermittently behind >1 replica.

Fall back to a stable key derived (SHA-256, namespaced) from
PLACE_SERVER_SECRET, which the platform always provides and which is
identical across restarts and replicas — so sessions persist with no
new deployment config. Ephemeral generation remains only when neither
secret is set (a bare dev box).

Verified in a local platform stack: a session issued before an auth
container restart is still accepted afterwards.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… a secret

The token endpoint declared `client_secret` as a required parameter, so a
public client (SPA / native app, `confidential: false`) that authenticates
with PKCE — Backoffice, for example — was rejected with a 422
"missing required parameter 'client_secret'" before any OAuth logic ran.

Doorkeeper made `client_secret` optional for public clients and skipped
client-secret validation for them. This restores that:

- `token` now takes `client_secret : String? = nil`.
- `AuthlyAdapter::Client#authorized?` returns true for public clients
  (they authenticate via PKCE); confidential clients still require a
  constant-time secret match.
- `allowed_grant_type?` denies `client_credentials` to public clients, so
  the secret bypass can't be used to mint owner-scoped tokens from a
  (public) client_id.
- Discovery advertises `token_endpoint_auth_methods_supported: [..., "none"]`.

PKCE is left optional (matching Doorkeeper); enforcement can be enabled
later via `Authly.config.enforce_pkce`.

Specs updated: fixtures that authenticate with a real secret now model a
confidential client, and a new "public client" block covers the
no-secret code exchange + refresh and the client_credentials denial.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
nginx gates every SPA asset behind a `verified` cookie: its
access_by_lua_block recomputes HMAC-SHA256(SECRET_KEY_BASE, <data>) and
redirects to /auth/login whenever the cookie is missing or its signature
is wrong. auth.cr never issued it (deferred to "Phase 6"), so after login
every asset request bounced back to /auth/login — an infinite loop; the
SPA (Backoffice) never loaded.

The legacy Ruby service set this cookie at the tail of `new_session` and
cleared it in `remove_session`. This restores byte-for-byte parity:

- `SECRET_KEY_BASE` constant, read from the same env var nginx renders
  into its lua (verified: both containers already share the value).
- `configure_asset_access` sets `verified = "<16 hex>.<hmac>"` at path /,
  Secure, HttpOnly, SameSite=None, 19y expiry; `clear_asset_access`
  expires it. Wired into `new_session` / `remove_session`, plus the
  inline-api-key continue branch and `/auth/authority` (valid token),
  matching the Ruby call sites.

Spec: asserts the cookie is issued on signin with a signature that
recomputes correctly, cleared on logout, and has the right attributes.
`Spec.signin!` and one assertion updated to select the session cookie by
name now that responses carry two Set-Cookie headers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ts-client / Backoffice — like most OAuth SDKs — send the S256
`code_challenge` as base64url (`-`/`_`). authly validates it against
Crystal's `Digest::SHA256.base64digest`, which is *standard* base64
(`+`/`/`, padded), so every real browser PKCE handshake was rejected with
`unauthorized_client` (visible only once the client_secret barrier was
removed). Normalize the challenge from base64url to standard base64 (and
pad to a multiple of 4) before handing it to authly, for S256 only —
`plain` challenges are the verifier verbatim and legitimately contain
`-`/`_`. auth.cr now accepts both base64url and standard-base64 clients.

Underlying cause is in place-labs/authly (S256#valid? should use base64url
per RFC 7636); this normalization is the drop-in fix and also insulates
auth.cr from that behaviour. Spec drives the full authorize->token flow
with a ts-client-style base64url challenge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
authly's refresh grant structurally loses the resource owner: `RefreshToken`
never overrides `user_id`, and the refresh token it mints carries
`sub => client_id` with no user id — so a refreshed access token gets a
random `sub`. Downstream that also strips the `u{n,e,p,r}` block and the
`aud` claim our ClaimsProvider attaches (it does `User.find?(sub)` and
returns early when the sub isn't a real user), leaving an unidentified,
domain-less token that fails `ensure_matching_domain`.

This is the same upstream gap the existing `AuthorizationCode#user_id`
patch already compensates for on the code path; here we extend the identical
mechanism to refresh (no authly shard change):

- reopen `Authly::AccessToken#initialize` to re-mint the refresh token with
  the resource-owner id embedded (`@sub` is already the real user id at that
  point for user grants; client_credentials is left untouched);
- reopen `Authly::RefreshToken#user_id` to recover it on redeem — a direct
  analog of the AuthorizationCode patch.

Identity chains across refresh-of-refresh because each new refresh token
re-embeds the recovered id. Spec verifies sub + u{} + aud survive one and
two refreshes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two GenericOAuth2 drop-in regressions vs the legacy Ruby generic_oauth,
both fixed entirely in auth.cr (no multi_auth shard edit):

(A) authorize_params dropped. The OAuthAuthentication.authorize_params
    column (e.g. Google access_type=offline/prompt=consent for refresh
    tokens, Azure domain_hint) never reached the authorize request —
    multi_auth has no concept of the column. ProviderCallbacks#initiate
    already owns and rewrites the authorize URL, so it now merges the
    strat's authorize_params into it before redirecting (safe &-append
    leaves the embedded redirect_uri untouched for the b2c rewrite).

(B) info_mappings comma-fallback lost. Ruby treated each mapping value as
    a comma-separated fallback list (email => "email,mail,userPrincipalName",
    first present wins — how Azure/Entra surfaces email); multi_auth does a
    single literal lookup so comma-lists resolve to nil. Reopen
    MultiAuth::Provider::GenericOAuth2#get_value_from_json from auth.cr
    (same technique as authly_adapter.cr) to try each comma-separated key.
    It's the single choke point for every mapped field, so it also fixes
    the getter-only uid/name that can't be overridden after construction.

Specs added to the provider-flows suite cover both, incl. a comma-fallback
case that exercises the getter-only uid field.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…E_SERVER_SECRET)

Investigation of the dev + k8s deployments showed auth does NOT receive
PLACE_SERVER_SECRET in prod (it's wired to api/core/fe-loader for settings
encryption, not auth). Keying the session-cookie secret off it would fall
back to an ephemeral per-boot key in prod, so sessions would still not
survive a restart — the exact bug this fix targets.

auth already receives SECRET_KEY_BASE in both deployments (docker-compose
`.env.secret_key` and k8s `auth.secrets.SECRET_KEY_BASE`), it's the value
nginx shares for the asset-access cookie, and it's what Rails/Doorkeeper
derived its session key from — so it's the correct, faithful choice.
Derivation stays SHA-256-namespaced so it doesn't collide with the raw-HMAC
use of the same secret.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…on agnostic)

The trailing comment made crystal 1.16.3 insert a blank line before ensure
that crystal:latest strips, so the crystal-style (latest) and Ameba (older)
CI checks disagreed. Moving the comment to its own line formats identically
under both.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@camreeves
camreeves merged commit 19ebade into master Jul 17, 2026
7 checks passed
@camreeves
camreeves deleted the PPT-2536-dropin-merge branch July 21, 2026 04:00
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