Skip to content

PKCE frontend authentication#11528

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

PKCE frontend authentication#11528
nbudin wants to merge 39 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 23 commits May 18, 2026 09:47
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>
- All sign-in, sign-up, forgot-password, and reset-password pages
  (both full-page and modal variants) now display the convention name
  in the header when on a convention subdomain.
- Created a custom Doorkeeper OAuth authorization consent page that
  shows the convention name and which OAuth application is requesting
  access.
- Made recaptcha_site_key nullable in the GraphQL schema and updated
  the sign-up pages to skip the reCAPTCHA widget when no key is
  configured (fixes system test failures in environments without
  reCAPTCHA configured).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds `conventionByOauthReturnIfPresent` to the Query type. When the user
lands on the root-domain sign-in page as part of a PKCE flow, the
session's `user_return_to` OAuth URL contains a `redirect_uri` that
identifies the convention subdomain. This resolver parses that to return
the right convention even when the request host has no convention.

AppRootQuery now fetches this as `signInConvention` and
`buildAppRootContextValue` uses it as a fallback for `conventionName`,
so auth page headers correctly show "Log in to ConventionName".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
`session[:user_return_to]` is only written when going through the
sign-in page, but the sign-up and forgot-password pages receive
`user_return_to` as a query param directly. Fall back to
`params[:user_return_to]` so those pages can also resolve the
convention name.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Removes signInConvention from AppRootQuery (which runs on every page)
and moves it into a dedicated SignInConventionQuery. A new
useSignInConventionName hook runs this query only when conventionName
isn't already available from AppRootContext, so it fires only on the
root-domain auth pages during an OAuth flow. All seven auth components
now use the hook.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a new `oauthApplicationByCurrentRequest` GraphQL field that looks up
the Doorkeeper application by `client_id` from the OAuth return URL in the
session or params.  Auth pages now show "Log in to use {{appName}}" when
signing in via a third-party OAuth app with no associated convention.

Also renames `useSignInConventionName` → `useSignInContext` (returns
`{ conventionName, oauthAppName }`) and the GraphQL query to
`SignInContextQuery` now that both concerns are covered.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@nbudin nbudin force-pushed the pkce-frontend-auth branch from 1bafc6b to e911356 Compare May 18, 2026 16:48
nbudin and others added 6 commits May 18, 2026 12:05
Replace NullResourceOwner hack with proper unauthenticated redirect in
doorkeeper.rb so Doorkeeper stores the OAuth URL in session[:user_return_to]
and redirects to the sign-in page. Also add http:// redirect URI variants
for localhost in OAuthApplication so Doorkeeper accepts the authorization
request from the frontend (which sends http://localhost:3000/oauth/callback
in development).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The http:// variants aren't needed — dev testing uses https://intercode.test:5050.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Avoids interference from old _intercode_session cookies that were set
with domain: :all (matching all subdomains). The PKCE branch removed
domain: :all since JWTs handle cross-domain auth, but browsers holding
old broad-domain cookies would send two conflicting _intercode_session
values, causing CSRF validation failures on sign-in.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Password reset links were generated using request.host, so if a user
requested a reset from a convention subdomain the email would link to
that subdomain. Now that /users/password/edit is root-site-only, those
links would 404. Use ActionMailer::Base.default_url_options instead,
which is always configured to the root INTERCODE_HOST.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
/users/edit is now root-site-only. Build the URL from
authenticationManager.issuerUrl so it always points to the root site,
and open it in a new tab so the user doesn't lose their place on the
convention site.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
nbudin and others added 6 commits May 19, 2026 09:31
Like the sign-in/sign-up pages, /users/edit needs to serve the React
app shell so EditUserForm renders within the SPA rather than falling
back to the default Devise view.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Now that the page lives on the root site and opens in a new tab,
navigating to / after save doesn't make sense. Show a Saved! message
inline instead, matching the pattern used by EditRootSite.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Wraps the form in the same container/row/card structure used by
DeviseSignInPage and DeviseSignUpPage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds an optional CMS layout specifically for authentication pages
(/users/sign_in, /users/sign_up, /users/password/*, /users/edit,
/oauth/authorize). When set, CmsContentFinder returns it instead of
the default layout for those paths on the root site.

Also removes the leftover require for the deleted DynamicCookieDomain
file from config/application.rb, which was preventing Rails from booting.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
<ErrorDisplay stringError={(updateUserError || {}).message} />
</div>
<div className="card-footer bg-light text-end">
{saved ? <span className="text-success">Saved! </span> : null}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [eslint] <i18next/no-literal-string> reported by reviewdog 🐶
disallow literal string: Saved!


<SelectWithLabel
name="auth_layout_id"
label="Layout for authentication pages"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [eslint] <i18next/no-literal-string> reported by reviewdog 🐶
disallow literal string: label="Layout for authentication pages"

<SelectWithLabel
name="auth_layout_id"
label="Layout for authentication pages"
helpText="Used for sign-in, sign-up, password reset, and account edit pages. Falls back to the default layout if not set."
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [eslint] <i18next/no-literal-string> reported by reviewdog 🐶
disallow literal string: helpText="Used for sign-in, sign-up, password reset, and account edit pages. Falls back to the default layout if not set."

@github-actions
Copy link
Copy Markdown
Contributor

Code Coverage Report: Only Changed Files listed

Package Base Coverage New Coverage Difference
app/controllers/application_controller.rb 🟠 60% 🟠 58.33% 🔴 -1.67%
app/graphql/graphql_operation.rb 🟢 100% 🟢 92.86% 🔴 -7.14%
app/graphql/mutations/setup_my_profile.rb 🔴 0% 🟠 54.55% 🟢 54.55%
app/graphql/types/client_configuration_type.rb 🟠 72.73% 🟢 100% 🟢 27.27%
app/graphql/types/query_type.rb 🟢 78.05% 🟠 71.53% 🔴 -6.52%
app/graphql/types/user_con_profile_type.rb 🟢 78.86% 🟢 79.03% 🟢 0.17%
app/javascript/Authentication/authenticationManager.ts 🔴 0% 🔴 23.08% 🟢 23.08%
app/javascript/Authentication/mutations.generated.ts 🔴 0% 🟢 100% 🟢 100%
app/javascript/useIntercodeApolloClient.ts 🔴 22.5% 🔴 20.45% 🔴 -2.05%
app/models/root_site.rb 🟠 58.06% 🟠 59.38% 🟢 1.32%
app/presenters/cms_rendering_context.rb 🟢 83.33% 🟢 82.76% 🔴 -0.57%
app/services/cms_content_finder.rb 🟢 86.36% 🟢 80.77% 🔴 -5.59%
config/initializers/cors.rb 🟠 50% 🔴 47.06% 🔴 -2.94%
config/initializers/doorkeeper.rb 🟢 85.19% 🟠 72.41% 🔴 -12.78%
lib/intercode/liquid/tags/app_component_renderer.rb 🟠 62.5% 🟠 69.23% 🟢 6.73%
Overall Coverage 🟢 53.09% 🟢 52.84% 🔴 -0.25%

Minimum allowed coverage is 0%, this run produced 52.84%

nbudin and others added 3 commits May 22, 2026 09:27
- Move auth routes (/users/*) to rootSiteRoutes so they're root-site-only
- Extend normalizePathForLayout to treat /users/* and /oauth/authorize
  paths as layout-sensitive, so the auth CMS layout is applied correctly
- Add is_intercode_frontend to AuthorizedApplicationType and suppress the
  OAuth app name on sign-in pages when it's the Intercode frontend itself
- Allow site admins to manage root site and view OAuth applications even
  when authenticated via doorkeeper token (removes !doorkeeper_token guard)
- Replace useState/setState-in-effect with useRef for cachedCmsLayoutId
  in AppRootLayout, fixing an ESLint cascading-renders warning

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Anywhere the app was popping the sign-in modal now initiates the PKCE
OAuth flow and redirects instead, consistent with the sign-in nav item:

- useLoginRequired: initiate OAuth with returnPath instead of opening modal
- RunCard "log in to sign up" button: same
- AppWrapper: remove dead openSignIn/onUnauthenticatedRef code (the
  GraphQLNotAuthenticatedErrorEvent listener in application.tsx already
  handles that path)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The sign-in modal is now completely unused — all auth triggers redirect
via PKCE OAuth flow instead. This deletes the modal and all its child
forms, the context, and cleans up the last consumers.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant