PKCE frontend authentication#11528
Draft
nbudin wants to merge 18 commits into
Draft
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
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-contentKey changes
authenticationManager: fetches client configuration (OIDC issuer, client ID) via GraphQL; manages PKCE state and token refresh via JWT bearer tokensSessionsController/RegistrationsController/PasswordsController: render app shell for Devise form routes;SessionsController#createallows cross-host redirect back to trusted convention domains after sign-inSignInButton/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 registrationappRootLoader: callssetupMyProfilemutation when no profile exists, then redirects to clickwrap agreement or profile setup page as needed — no flash of contentSetupMyProfileGraphQL mutation: creates aUserConProfilefor the current user viaSetupUserConProfileService, idempotent (returns existing profile if one already exists)Test plan
needs_update: trueprofile — should redirect to profile setup🤖 Generated with Claude Code