Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
d1e11d4
Implement PKCE frontend authentication with JWT bearer tokens
nbudin May 14, 2026
532b855
Fetch client configuration via GraphQL instead of server-rendered props
nbudin May 14, 2026
c226c1a
Fix CORS and sign-out for PKCE frontend auth
nbudin May 15, 2026
5421b10
Fix sign-out navigation race in SignOutButton
nbudin May 15, 2026
e80e6ac
Redirect back to convention after sign-out
nbudin May 15, 2026
f2f8058
Allow cross-host redirect in respond_to_on_destroy
nbudin May 15, 2026
e2d5512
Fix respond_to_on_destroy signature to match Devise
nbudin May 15, 2026
2d4fed4
Render sign-in page in root site CMS layout
nbudin May 15, 2026
99a02d4
Render sign-in page as a React component inside AppRoot
nbudin May 15, 2026
722588b
Add Devise page components for sign-up and forgot password
nbudin May 15, 2026
7924e28
Use React Router Link for navigation between auth pages
nbudin May 15, 2026
4cdf40e
We don't need to html_safe an empty string
nbudin May 15, 2026
08e4505
Replace authentication modal with direct redirects to Devise pages
nbudin May 15, 2026
b87d89d
Port user con profile setup flow to JavaScript
nbudin May 15, 2026
2bec914
Move profile setup and clickwrap redirects into appRootLoader
nbudin May 15, 2026
6953a0d
Internationalize hardcoded strings in authentication components
nbudin May 16, 2026
b3423e1
Register GraphQLNotAuthenticatedErrorEvent listener eagerly at module…
nbudin May 16, 2026
7bdfd08
No need to escape an empty string
nbudin May 16, 2026
5b3bdac
Show convention name on auth pages; allow null recaptchaSiteKey
nbudin May 16, 2026
6dbc9be
Resolve sign-in convention via OAuth return URL in GraphQL
nbudin May 16, 2026
4f4285a
Also check request params for OAuth return URL in convention resolver
nbudin May 16, 2026
8040ae6
Factor sign-in convention into a separate on-demand query
nbudin May 16, 2026
e911356
Show OAuth app name on sign-in pages; rename sign-in context hook
nbudin May 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .yarnrc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ packageExtensions:
autoprefixer@*:
dependencies:
colorette: "*"
"framer-motion@*":
dependencies:
"@emotion/is-prop-valid": "*"

pnpEnableEsmLoader: true

Expand Down
9 changes: 8 additions & 1 deletion app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,18 @@ def graphql_authenticity_token
end
helper_method :graphql_authenticity_token

def oidc_issuer_url
issuer = Doorkeeper::OpenidConnect.configuration.issuer
issuer.respond_to?(:call) ? issuer.call : issuer
end

def app_component_props
{
recaptchaSiteKey: Recaptcha.configuration.site_key,
railsDirectUploadsUrl: rails_direct_uploads_url,
railsDefaultActiveStorageServiceName: Rails.application.config.active_storage.service.to_s
railsDefaultActiveStorageServiceName: Rails.application.config.active_storage.service.to_s,
oauthFrontendApplicationUid: Doorkeeper::Application.find_by(is_intercode_frontend: true)&.uid,
oidcIssuerUrl: oidc_issuer_url
}
end
helper_method :app_component_props
Expand Down
4 changes: 4 additions & 0 deletions app/controllers/passwords_controller.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# frozen_string_literal: true
class PasswordsController < Devise::PasswordsController
def new
render html: "", layout: "application"
end

def create
self.resource =
resource_class.find_or_initialize_with_errors(resource_class.reset_password_keys, resource_params, :not_found)
Expand Down
8 changes: 4 additions & 4 deletions app/controllers/registrations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@
class RegistrationsController < Devise::RegistrationsController
include RedirectWithAuthentication

prepend_before_action :check_captcha, only: [:create]
prepend_before_action :disable_destroy, only: [:destroy]
prepend_before_action :check_captcha, only: [:create] # rubocop:disable Rails/LexicallyScopedActionFilter
prepend_before_action :disable_destroy, only: [:destroy] # rubocop:disable Rails/LexicallyScopedActionFilter

def new
respond_to { |format| format.html { redirect_with_authentication("signUp") } }
render html: "", layout: "application"
end

private
Expand All @@ -23,6 +23,6 @@ def check_captcha
end

def disable_destroy
redirect_to root_path, alert: "To delete your account, please email the site administrators."
redirect_to root_path, alert: t("registrations.disable_destroy")
end
end
52 changes: 49 additions & 3 deletions app/controllers/sessions_controller.rb
Original file line number Diff line number Diff line change
@@ -1,15 +1,61 @@
# frozen_string_literal: true
class SessionsController < Devise::SessionsController
include RedirectWithAuthentication

layout false
prepend_before_action :set_return_to, only: [:new]

def new
respond_to { |format| format.html { redirect_with_authentication("signIn") } }
render html: "", layout: "application"
end

# Override to allow cross-host redirect back to the convention subdomain after sign-in.
def create
self.resource = warden.authenticate!(auth_options)
set_flash_message!(:notice, :signed_in)
sign_in(resource_name, resource)
path = after_sign_in_path_for(resource)
uri = parse_uri_silently(path.to_s)
redirect_to path, allow_other_host: uri&.host.present? && trusted_origin?(uri.host)
end

# Override to allow cross-host redirect back to the convention subdomain after sign-out.
def respond_to_on_destroy(non_navigational_status: :no_content)
respond_to do |format|
format.all { head non_navigational_status }
format.any(*navigational_formats) do
redirect_to after_sign_out_path_for(resource_name),
status: Devise.responder.redirect_status,
allow_other_host: true
end
end
end

private

def after_sign_out_path_for(_resource_or_scope)
trusted_referer_url || root_path
end

def trusted_referer_url
return unless request.referer

referer_uri = parse_uri_silently(request.referer)
return unless referer_uri

trusted_origin?(referer_uri.host) ? request.referer : nil
end

def parse_uri_silently(url)
URI(url)
rescue StandardError
nil
end

def trusted_origin?(host)
intercode_host = ENV.fetch("INTERCODE_HOST", nil)
host == intercode_host || (intercode_host && host&.end_with?(".#{intercode_host}")) ||
Convention.exists?(domain: host)
end

def set_return_to
return if params[:user_return_to].blank?
session[:user_return_to] = params[:user_return_to]
Expand Down
2 changes: 1 addition & 1 deletion app/graphql/graphql_operations_generated.json

Large diffs are not rendered by default.

20 changes: 20 additions & 0 deletions app/graphql/mutations/setup_my_profile.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# frozen_string_literal: true
class Mutations::SetupMyProfile < Mutations::BaseMutation
description "Creates a UserConProfile for the currently signed-in user in the current convention."

field :my_profile, Types::UserConProfileType, null: false, description: "The created or existing profile."

require_user

def authorized?
!!current_user && !!convention
end

def resolve
existing = convention.user_con_profiles.find_by(user: current_user)
return { my_profile: existing } if existing

result = SetupUserConProfileService.new(convention:, user: current_user).call!
{ my_profile: result.user_con_profile }
end
end
23 changes: 22 additions & 1 deletion app/graphql/types/client_configuration_type.rb
Original file line number Diff line number Diff line change
@@ -1,14 +1,26 @@
# frozen_string_literal: true
class Types::ClientConfigurationType < Types::BaseObject
description "Client-side configuration values needed for frontend initialization"

field :oauth_frontend_application_uid,
String,
null: true,
description: "The OAuth application UID for the Intercode frontend SPA (used for PKCE auth)"
field :oidc_issuer_url,
String,
null: true,
description: "The OIDC issuer URL (used as the base for OpenID Connect discovery)"
field :rails_default_active_storage_service_name,
String,
null: false,
description: "The default Active Storage service name configured in Rails"
# rubocop:disable GraphQL/ExtractType
field :rails_direct_uploads_url, String, null: false, description: "The URL endpoint for Rails Direct Uploads"
# rubocop:enable GraphQL/ExtractType
field :recaptcha_site_key, String, null: false, description: "The reCAPTCHA site key for client-side verification"
field :recaptcha_site_key,
String,
null: true,
description: "The reCAPTCHA site key for client-side verification, or null if reCAPTCHA is disabled"

def rails_default_active_storage_service_name
Rails.application.config.active_storage.service.to_s
Expand All @@ -21,4 +33,13 @@ def rails_direct_uploads_url
def recaptcha_site_key
Recaptcha.configuration.site_key
end

def oauth_frontend_application_uid
Doorkeeper::Application.find_by(is_intercode_frontend: true)&.uid
end

def oidc_issuer_url
issuer = Doorkeeper::OpenidConnect.configuration.issuer
issuer.respond_to?(:call) ? issuer.call : issuer
end
end
1 change: 1 addition & 0 deletions app/graphql/types/mutation_type.rb
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,7 @@ def self.authorized?(_value, context)
field :acceptClickwrapAgreement, null: false, mutation: Mutations::AcceptClickwrapAgreement
field :createUserConProfile, null: false, mutation: Mutations::CreateUserConProfile
field :deleteUserConProfile, null: false, mutation: Mutations::DeleteUserConProfile
field :setupMyProfile, null: false, mutation: Mutations::SetupMyProfile
field :updateUserConProfile, null: false, mutation: Mutations::UpdateUserConProfile
field :withdrawAllUserConProfileSignups, null: false, mutation: Mutations::WithdrawAllUserConProfileSignups
end
44 changes: 44 additions & 0 deletions app/graphql/types/query_type.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,23 @@ class Types::QueryType < Types::BaseObject
MARKDOWN
end

field :convention_by_oauth_return_if_present, Types::ConventionType, null: true do
description <<~MARKDOWN
Returns the convention associated with the current OAuth sign-in flow, if any. Parses the
`redirect_uri` from the stored OAuth return URL in the session to determine which convention
the user is signing into. Useful on the root-domain sign-in page where
`conventionByRequestHostIfPresent` returns null.
MARKDOWN
end

field :oauth_application_by_current_request, Types::AuthorizedApplicationType, null: true do
description <<~MARKDOWN
Returns the OAuth application initiating the current sign-in flow, if any. Parses the
`client_id` from the stored OAuth return URL in the session or params to identify which
application the user is signing in to use.
MARKDOWN
end

field :convention_by_id, Types::ConventionType, null: false do
argument :id, ID, required: false, camelize: true
description <<~MARKDOWN
Expand Down Expand Up @@ -196,6 +213,33 @@ def convention_by_request_host_if_present
context[:convention]
end

def convention_by_oauth_return_if_present
return_to = context[:controller].session[:user_return_to] || context[:controller].params[:user_return_to]
return unless return_to

return_to_uri = URI.parse(return_to)
redirect_uri_str = Rack::Utils.parse_query(return_to_uri.query)["redirect_uri"]
return unless redirect_uri_str

redirect_uri = URI.parse(redirect_uri_str)
Convention.find_by(domain: redirect_uri.host)
rescue URI::InvalidURIError, ArgumentError
nil
end

def oauth_application_by_current_request
return_to = context[:controller].session[:user_return_to] || context[:controller].params[:user_return_to]
return unless return_to

return_to_uri = URI.parse(return_to)
client_id = Rack::Utils.parse_query(return_to_uri.query)["client_id"]
return unless client_id

Doorkeeper::Application.find_by(uid: client_id)
rescue URI::InvalidURIError, ArgumentError
nil
end

def convention_by_id(id: nil)
Convention.find(id)
end
Expand Down
3 changes: 3 additions & 0 deletions app/graphql/types/user_con_profile_type.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# frozen_string_literal: true
# rubocop:disable Metrics/ClassLength
class Types::UserConProfileType < Types::BaseObject
description <<~MARKDOWN
A UserConProfile is a user's profile in a particular convention web site. Most convention-level objects are
Expand Down Expand Up @@ -53,6 +54,7 @@ def self.personal_info_field(field_name, ...)
field :name_without_nickname, String, null: false do # rubocop:disable GraphQL/ExtractType
description "This user profile's full name, not including their nickname."
end
field :needs_update, Boolean, null: false, description: "Does this profile need to be updated by the user?"
field :nickname, String, null: true, description: "This user profile's nickname."
field :order_summary, String, null: false, description: "A human-readable summary of all this profile's orders."
field :orders, [Types::OrderType], null: false, description: "All the orders placed by this profile."
Expand Down Expand Up @@ -227,3 +229,4 @@ def form
convention.user_con_profile_form
end
end
# rubocop:enable Metrics/ClassLength
36 changes: 2 additions & 34 deletions app/javascript/AppRoot.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Suspense, useMemo, useState, useEffect, useContext, useRef } from 'react';
import { useLocation, useNavigate, useLoaderData, Outlet, useNavigation } from 'react-router';
import { Suspense, useMemo, useState, useEffect, useRef } from 'react';
import { useLocation, useLoaderData, Outlet, useNavigation } from 'react-router';
import { Settings } from 'luxon';
import { PageLoadingIndicator } from '@neinteractiveliterature/litform';

Expand All @@ -10,8 +10,6 @@ import getI18n from './setupI18Next';
import { timespanFromConvention } from './TimespanUtils';
import { LazyStripeContext } from './LazyStripe';
import { Stripe } from '@stripe/stripe-js';
import AuthenticationModalContext from './Authentication/AuthenticationModalContext';
import { GraphQLNotAuthenticatedErrorEvent } from './useIntercodeApolloClient';
import { reloadOnAppEntrypointHeadersMismatch } from './checkAppEntrypointHeadersMatch';
import { initErrorReporting } from 'ErrorReporting';

Expand Down Expand Up @@ -78,9 +76,7 @@ export function buildAppRootContextValue(

function AppRoot(): React.JSX.Element {
const location = useLocation();
const navigate = useNavigate();
const data = useLoaderData() as AppRootQueryData;
const authenticationModal = useContext(AuthenticationModalContext);
const navigation = useNavigation();
const navigationBarRef = useRef<HTMLElement>(null);

Expand All @@ -99,19 +95,6 @@ function AppRoot(): React.JSX.Element {
[data, navigationBarRef],
);

useEffect(() => {
if (
data.convention?.my_profile &&
(data.convention.clickwrap_agreement || '').trim() !== '' &&
!data.convention.my_profile.accepted_clickwrap_agreement &&
location.pathname !== '/clickwrap_agreement' &&
location.pathname !== '/' &&
!location.pathname.startsWith('/pages')
) {
navigate('/clickwrap_agreement', { replace: true });
}
}, [data, navigate, location]);

useEffect(() => {
if (appRootContextValue?.language) {
getI18n().then((i18n) => {
Expand All @@ -121,21 +104,6 @@ function AppRoot(): React.JSX.Element {
}
}, [appRootContextValue]);

useEffect(() => {
const unauthenticatedHandler = () => {
if (!authenticationModal.visible) {
authenticationModal.open({ currentView: 'signIn' });
authenticationModal.setAfterSignInPath(location.pathname);
}
};

window.addEventListener(GraphQLNotAuthenticatedErrorEvent.type, unauthenticatedHandler);

return () => {
window.removeEventListener(GraphQLNotAuthenticatedErrorEvent.type, unauthenticatedHandler);
};
}, [authenticationModal, location.pathname]);

return (
<AppRootContext.Provider value={appRootContextValue}>
<LazyStripeContext.Provider
Expand Down
Loading
Loading