Skip to content

PKCE frontend authentication#11528

Draft
nbudin wants to merge 18 commits into
mainfrom
pkce-frontend-auth
Draft

PKCE frontend authentication#11528
nbudin wants to merge 18 commits into
mainfrom
pkce-frontend-auth

Conversation

@nbudin
Copy link
Copy Markdown
Contributor

@nbudin nbudin commented May 16, 2026

Summary

  • Implements PKCE-based OAuth authentication flow entirely in the frontend, removing dependency on server-side session management for convention subdomain auth
  • Replaces the in-app authentication modal with direct redirects to Devise pages hosted on the root site (sign in, sign up, forgot password), rendered in the CMS layout as React components
  • Ports user con profile setup and clickwrap redirect logic to JavaScript (previously server-side before_actions that don't fire in the PKCE flow); profile creation and destination routing now happen in the React Router loader before any rendering, eliminating flash-of-content

Key changes

  • authenticationManager: fetches client configuration (OIDC issuer, client ID) via GraphQL; manages PKCE state and token refresh via JWT bearer tokens
  • SessionsController / RegistrationsController / PasswordsController: render app shell for Devise form routes; SessionsController#create allows cross-host redirect back to trusted convention domains after sign-in
  • SignInButton / SignUpButton: initiate OAuth PKCE flow rather than opening a modal; sign-up navigates to /users/sign_up?user_return_to=<oauthUrl> so the user lands back at the convention after registration
  • appRootLoader: calls setupMyProfile mutation when no profile exists, then redirects to clickwrap agreement or profile setup page as needed — no flash of content
  • SetupMyProfile GraphQL mutation: creates a UserConProfile for the current user via SetupUserConProfileService, idempotent (returns existing profile if one already exists)

Test plan

  • Sign in via root site — should redirect back to convention and be authenticated
  • Sign out from convention — should redirect back to convention home page
  • Sign up as new user on a convention with a clickwrap agreement — should land on clickwrap, then profile setup
  • Sign up as new user on a convention without a clickwrap — should land directly on profile setup
  • Return visit with needs_update: true profile — should redirect to profile setup
  • Forgot password flow — should render in CMS layout, work end-to-end

🤖 Generated with Claude Code

nbudin and others added 18 commits May 14, 2026 12:05
Replaces the Devise session cookie + CSRF token approach for the
frontend SPA with an OpenID Connect PKCE flow using JWT bearer tokens.
The Doorkeeper/doorkeeper-openid_connect backend was already in place
from the jwt-backend-auth branch; this wires up the frontend side.

Key changes:
- Add AuthenticationManager, openid.ts, OAuthCallback to manage PKCE
  state, discovery, token exchange, and localStorage JWT storage
- Update Apollo client to send Authorization: Bearer header when JWT present
- Update SignInForm to redirect through PKCE rather than posting credentials
- Update SignOutButton to call end_session_endpoint
- Add /oauth/callback route in AppRouter
- Expose oauth_frontend_application_uid and oidc_issuer_url via
  ClientConfiguration GraphQL type and app component props
- Fix OAuthApplication#redirect_uri to generate URLs for both the app
  server port (from ActionMailer defaults) and the assets server port,
  covering development and production origins
- Fix SessionsController to render server-side Devise sign-in form when
  inside an OAuth authorize flow, preserving the existing React modal
  flow for all other sign-in scenarios
- Remove binding.pry left in skip_authorization block

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Rather than embedding config (OAuth client ID, OIDC issuer URL, etc.)
in HTML data attributes from the Rails app_component_props helper, the
frontend now fetches ClientConfiguration directly via Apollo at startup
using React's `use()` hook with a Suspense wrapper.

This removes the tight coupling between server-rendered props and the
React app entry point, and eliminates the need to pass Rails props
through Liquid tag rendering helpers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Allow convention subdomains of INTERCODE_HOST to make CORS requests to
the root site, so the OIDC discovery endpoint can be reached during sign-
out. Also allow GET on /users/sign_out and switch Devise sign_out_via to
:get so the end_session_endpoint browser redirect works.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
afterSessionChange was always called after signOut(), overriding the
window.location.href navigation to the end_session_endpoint. Only
navigate via afterSessionChange when there is no endSessionEndpoint.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Override after_sign_out_path_for to return the referer URL when it comes
from a trusted origin (INTERCODE_HOST, its subdomains, or a known
convention domain), so users land back on the convention page after
signing out from the root site.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Rails 7 raises OpenRedirectError for cross-host redirects. Override
respond_to_on_destroy to pass allow_other_host: true so the post-sign-out
redirect back to a convention subdomain is permitted.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Devise calls respond_to_on_destroy with a non_navigational_status
keyword argument; match the correct signature.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Create a cms_devise layout that wraps the Devise sign-in form inside the
site's Liquid CMS layout (with the React navbar), and switch
SessionsController to use it for the OAuth authorization flow.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces the server-rendered Devise sign-in view with a React form component
that renders inside the AppRoot React tree, allowing the CMS navigation bar
to work correctly. The form still POSTs natively to /users/sign_in via Devise.
Removes the now-unused cms_devise layout.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Ports SignUpForm and ForgotPasswordForm from the authentication modal into
standalone page components (DeviseSignUpPage, DeviseForgotPasswordPage) so
these flows work on the root site. Updates RegistrationsController and
PasswordsController to render the React app shell at GET /users/sign_up and
GET /users/password/new respectively.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces <a href> with <Link to> on the sign-in, sign-up, and forgot-password
page components so navigating between them uses client-side routing instead of
full-page reloads.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Nav bar sign-in/sign-up buttons now redirect straight to the root site's
Devise forms (/users/sign_in, /users/sign_up) with user_return_to set to
the current URL, so Devise sends the user back after authentication.

- Add rootSiteHost to AppRootContext (from rootSite.host GraphQL field)
- Rewrite SignInButton/SignUpButton as plain <a> links to the root site
- Simplify SessionsController#new to always render the React app shell
- Override SessionsController#create to allow cross-host redirect to
  trusted origins after sign-in (same pattern as sign-out)
- Rename trusted_sign_out_origin? -> trusted_origin? since it now covers
  both sign-in and sign-out redirects

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previously, profile creation and the needs_update redirect were handled
by server-side before_actions that don't fire in the PKCE OAuth flow.
Now AppRoot detects a missing or stale profile and handles it client-side
via a new setupMyProfile GraphQL mutation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previously these redirects happened in useEffect, causing a flash of
page content before navigating. Moving the logic into the loader means
the redirect happens before rendering, so the user lands directly on
their final destination in one step.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace all literal strings flagged by i18n linting with t() calls.
New keys added under authentication.oauthCallback, authentication.signOut,
and authentication.signUp in en.json/es.json. The registrations controller
alert now uses a Rails I18n key.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… level

Moves the unauthenticated handler from AppRoot's useEffect to module-level
in packs/application.tsx, eliminating a race condition where loaders running
in parallel with AppRoot mounting could dispatch the event before the listener
was installed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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