Skip to content

Add session-preserving credential-linking endpoint - #60

Merged
einari merged 7 commits into
mainfrom
feature/credential-linking-endpoint
Jul 23, 2026
Merged

Add session-preserving credential-linking endpoint#60
einari merged 7 commits into
mainfrom
feature/credential-linking-endpoint

Conversation

@einari

@einari einari commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

Stacks on #59 (feature/logout-and-persistent-tenant-switch). Merge #59 first — until then this PR's diff includes #59's commits. The link endpoint reuses the IngressExtensions.cs / WellKnownPaths.cs changes from #59.

Adds the AuthProxy half of the "add a credential" proof-of-control flow for Cratis/Studio#862: a session-preserving link challenge that authenticates a second identity-provider login for an already signed-in user and hands the freshly authenticated subject to the application — without replacing the primary session.

Added

  • GET /.cratis/link/{scheme} — a session-preserving credential-linking challenge. It authenticates the requested provider for an already signed-in user but, on the provider callback, captures the authenticated subject and posts it to the application instead of signing the new identity in, so the user's primary session is preserved. The one-time link token and a same-site-only returnUrl travel through the flow. (Cratis/Studio#862)
  • Cratis:AuthProxy:Link:ExchangeUrl configuration — the application back-channel endpoint AuthProxy posts { subject, identityProvider } to (with the link token as the bearer credential), the link counterpart of Cratis:AuthProxy:Invite:ExchangeUrl. (Cratis/Studio#862)

Security

  • The link challenge requires an authenticated session (anonymous → 401), validates the provider scheme (unknown → 404) and requires a one-time token (missing → 400). returnUrl is constrained to a same-site relative path so the endpoint can never be turned into an open redirect. The subject is delivered to the application server-to-server from a real provider authentication — never trusted from client input. (Cratis/Studio#862)

Notes for review

  • Not an iframe. The design in the issue calls for an iframe, but provider consent pages send X-Frame-Options: DENY and AuthProxy cookies are SameSite=Lax, so the flow is a popup / top-level redirect, as agreed in the issue's posted design.
  • Session preservation is achieved by short-circuiting OnTicketReceived with context.HandleResponse() before the RemoteAuthenticationHandler signs the ticket into the cookie scheme (the "capture sub/iss and short-circuit" option from the design), rather than a throwaway sign-in scheme.
  • Same-account limitation (assumption): the challenge does not force prompt=select_account, so a provider with an active SSO session may silently return the same account. Left out because prompt handling differs across OIDC/OAuth and GitHub has no standard equivalent; flagging for review — it can be added if linking a different account of the same provider must be guaranteed.

🤖 Generated with Claude Code

einari and others added 7 commits July 23, 2026 12:14
Introduce a LogoutMiddleware handling the well-known GET /.cratis/logout
path. It signs the user out of the authentication cookie and deletes every
AuthProxy session cookie (.cratis-identity, .cratis-tenant, .cratis-tenants,
.cratis-invite, .cratis-registration, .cratis-providers), then redirects to
the absolute URL supplied in the `redirect` query parameter.

The redirect target is validated against an allow-list of origins — the
proxy's own public origin (honoring forwarded headers) plus configured
service and lobby frontends — so the endpoint cannot be abused as an open
redirect; a missing or disallowed target falls back to the application root.

The middleware is registered before the authentication-challenge stages so
logout works without a valid session.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Write .cratis-tenants as a session cookie and stop deleting it when a tenant
is selected, so a user with more than one tenant keeps the list available for
an in-app tenant switcher for the rest of the browser session. Single-tenant
users still have the cookie removed on auto-selection, so no switcher is shown
for them. Every selection continues to re-validate membership against the
tenants endpoint, so a stale cookie cannot grant access to a non-member tenant.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a Logout configuration page describing /.cratis/logout — the cookies it
clears, the `redirect` parameter and its origin allow-list — and wire it into
the configuration TOC. Extend the tenant-selection page with a section on
switching tenants after selection via the retained .cratis-tenants cookie.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add Cratis:AuthProxy:Logout:AllowedRedirectOrigins so a deployment can permit
post-logout redirect targets that are neither the proxy's own origin nor a
configured service or lobby frontend — for example a separate marketing site
served from a different host. Configured entries are normalized to their origin
and compared case-insensitively alongside the existing implicit allow-list;
malformed or non-HTTP(S) entries are ignored. A target matching nothing still
falls back to the application root.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a GET /.cratis/link/{scheme} flow that authenticates a second
identity-provider login for an already signed-in user and posts the
freshly authenticated subject to the application, without ever writing
the primary authentication cookie — so the user's session is preserved.

- LinkMiddleware initiates the challenge in link mode (requires an
  authenticated user; validates scheme and one-time token; constrains
  returnUrl to a same-site relative path).
- The shared OnTicketReceived handler captures the subject on the
  provider callback and short-circuits before sign-in, handing it to
  LinkSubjectExchanger, which POSTs { subject, identityProvider } to the
  configured Link.ExchangeUrl with the link token as the bearer
  credential — mirroring the invite exchange.

Refs Cratis/Studio#862

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cover LinkMiddleware initiation (challenge in link mode, carrying the
token; 404/401/400 rejections; open-redirect-safe returnUrl fallback)
and LinkSubjectExchanger (posts the subject with the link token as
bearer; fails when unconfigured, tokenless, or on endpoint error).

Refs Cratis/Studio#862

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Refs Cratis/Studio#862

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@einari einari added the minor label Jul 23, 2026
@einari
einari merged commit df5fd80 into main Jul 23, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant