Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 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
cff55d8
Fix PKCE OAuth redirect loop in development
nbudin May 18, 2026
536a38e
Revert localhost http:// redirect URI change
nbudin May 18, 2026
e12e429
Rename session cookie to _intercode_session2
nbudin May 18, 2026
8778363
Clean up session cookie config
nbudin May 19, 2026
0861a38
Always use root site host for password reset emails
nbudin May 19, 2026
ad86c67
Open My Account link on root site in new tab
nbudin May 19, 2026
94e89e1
Render SPA layout for /users/edit
nbudin May 19, 2026
32c728a
Stay on /users/edit after save and show Saved! message
nbudin May 19, 2026
db4f1c8
Give EditUserForm the same card layout as other auth pages
nbudin May 19, 2026
64d7099
Add auth layout option to root site settings
nbudin May 19, 2026
acf51b3
Fix missing import from i18next
nbudin May 22, 2026
525b586
Regenerate annotations
nbudin May 22, 2026
77a0108
UX fixes from click testing
nbudin May 22, 2026
1aa98fd
Replace sign-in modal triggers with OAuth redirect
nbudin May 22, 2026
a578bf6
Remove authentication modal in favor of OAuth redirect
nbudin May 22, 2026
4bc4b9c
Couple manual fixes
nbudin May 23, 2026
09597ad
Fix i18n literal string violations in EditUser and EditRootSite
nbudin May 23, 2026
c3ffc46
Use all configured Doorkeeper scopes for the intercode frontend OAuth…
nbudin May 23, 2026
7b0c177
Gate site admin OAuth actions on manage_intercode scope
nbudin May 23, 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
11 changes: 10 additions & 1 deletion 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 All @@ -20,7 +24,12 @@ def create
def actually_do_reset
return unless resource.persisted?

resource.reset_password_mail_options = { host: request.host, port: request.port, protocol: request.protocol }
mailer_url_options = ActionMailer::Base.default_url_options
resource.reset_password_mail_options = {
host: mailer_url_options[:host],
port: mailer_url_options[:port],
protocol: mailer_url_options[:protocol] || request.protocol
}

resource.send_reset_password_instructions
end
Expand Down
12 changes: 8 additions & 4 deletions app/controllers/registrations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,15 @@
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

def edit
render html: "", layout: "application"
end

private
Expand All @@ -23,6 +27,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
12 changes: 9 additions & 3 deletions app/graphql/types/authorized_application_type.rb
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
# frozen_string_literal: true
class Types::AuthorizedApplicationType < Types::BaseObject
field :name, String, null: false
field :scopes, [String], null: false
field :uid, ID, null: false
description "An OAuth application that a user has authorized."

field :is_intercode_frontend,
Boolean,
null: false,
description: "Whether this is the built-in Intercode frontend application."
field :name, String, null: false, description: "The display name of the OAuth application."
field :scopes, [String], null: false, description: "The OAuth scopes granted to this application."
field :uid, ID, null: false, description: "The OAuth application's unique identifier."

def scopes
object.scopes.to_a
Expand Down
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
21 changes: 18 additions & 3 deletions app/graphql/types/root_site_input_type.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,21 @@
# frozen_string_literal: true
class Types::RootSiteInputType < Types::BaseInputObject
argument :default_layout_id, ID, required: false, camelize: true
argument :root_page_id, ID, required: false, camelize: true
argument :site_name, String, required: false, camelize: false
description "Input type for updating root site settings."

argument :auth_layout_id,
ID,
required: false,
camelize: true,
description: "ID of the CMS layout to use for authentication pages."
argument :default_layout_id,
ID,
required: false,
camelize: true,
description: "ID of the default CMS layout for pages."
argument :root_page_id, ID, required: false, camelize: true, description: "ID of the root page."
argument :site_name,
String,
required: false,
camelize: false,
description: "Display name shown in the navigation bar."
end
11 changes: 7 additions & 4 deletions app/graphql/types/root_site_type.rb
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
# frozen_string_literal: true
class Types::RootSiteType < Types::BaseObject
description "The root site, which hosts global CMS content and authentication pages."

implements Types::CmsParent
include CmsParentImplementation

field :host, String, null: false
field :id, ID, null: false
field :site_name, String, null: false, camelize: false
field :url, String, null: false
field :auth_layout, Types::CmsLayoutType, null: true, description: "CMS layout used for authentication pages."
field :host, String, null: false, description: "The hostname of the root site."
field :id, ID, null: false, description: "A fixed identifier for the singleton root site."
field :site_name, String, null: false, camelize: false, description: "The display name shown in the navigation bar."
field :url, String, null: false, description: "The base URL of the root site."

def id
"singleton"
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
Loading
Loading