Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 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
d05fbdb
Fix typo in Doorkeeper scope override for frontend OAuth app
nbudin May 24, 2026
dc32c5b
Bound frontend access-token lifetimes and rotate refresh tokens
nbudin May 24, 2026
ea1b9ec
Revoke frontend access tokens on Devise sign-out
nbudin May 24, 2026
fb2ec7d
Treat invalid bearer tokens on /graphql as anonymous requests
nbudin May 24, 2026
ba032c5
Auto-refresh the OAuth access token before and after expiry
nbudin May 24, 2026
11feea4
Add /client_configuration JSON endpoint
nbudin May 24, 2026
133b94d
Bootstrap the SPA from /client_configuration instead of GraphQL+HTML
nbudin May 24, 2026
a13e3c1
Remove the GraphQL ClientConfiguration type
nbudin May 24, 2026
0538c6a
Add /oauth_session/{exchange,refresh,sign_out} endpoints
nbudin May 24, 2026
2c3841c
Move SPA OAuth session into the HttpOnly cookie
nbudin May 24, 2026
d53efff
Enforce PKCE on the OAuth authorization-code grant
nbudin May 24, 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
22 changes: 22 additions & 0 deletions app/controllers/client_configuration_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# frozen_string_literal: true

# Returns the small bundle of config the SPA needs *before* it can fire any
# authenticated GraphQL queries (in particular, the OAuth client UID and OIDC
# issuer URL, without which the auto-refresh path can't construct a refresh
# request). Replaces the previous server-rendered `data-react-props` blob on
# the `AppRoot` div and the GraphQL `clientConfiguration` query.
class ClientConfigurationController < ApplicationController
skip_before_action :ensure_user_con_profile_exists
skip_before_action :redirect_if_user_con_profile_needs_update
skip_before_action :ensure_clickwrap_agreement_accepted

def show
render json: {
oauth_frontend_application_uid: Doorkeeper::Application.find_by(is_intercode_frontend: true)&.uid,
oidc_issuer_url: oidc_issuer_url,
rails_default_active_storage_service_name: Rails.application.config.active_storage.service.to_s,
rails_direct_uploads_url: rails_direct_uploads_url,
recaptcha_site_key: Recaptcha.configuration.site_key
}
end
end
23 changes: 21 additions & 2 deletions app/controllers/graphql_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ class Context
assumed_identity_from_profile: :assumed_identity_from_profile,
verified_request: :verified_request?,
timezone_for_request: :timezone_for_request
}
}.freeze

METHOD_CACHE = {}
METHOD_CACHE = {} # rubocop:disable Style/MutableConstant -- memoizes resolved instance methods

def initialize(controller, **values)
@controller = controller
Expand Down Expand Up @@ -63,6 +63,12 @@ def dup
end
end

# Strip a stale/revoked bearer token before any before_action reads `current_user`,
# so Devise's Doorkeeper strategy doesn't reject the whole request with 401. We'd
# rather degrade to an anonymous request and let the SPA notice via the missing
# `currentUser` and re-auth.
prepend_before_action :ignore_unacceptable_bearer_token

skip_before_action :verify_authenticity_token # We're doing this in MutationType.authorized?
skip_before_action :ensure_user_con_profile_exists
skip_before_action :redirect_if_user_con_profile_needs_update
Expand All @@ -87,6 +93,19 @@ def execute

private

def ignore_unacceptable_bearer_token
return unless request.authorization&.start_with?("Bearer ")

token = Doorkeeper.authenticate(request)
return if token&.acceptable?(Doorkeeper.configuration.default_scopes)

request.env.delete("HTTP_AUTHORIZATION")
# Signal to the SPA that we ignored its bearer so it can refresh and retry
# — needed when the client's clock skew makes a JWT *look* fresh locally
# but the server still considers it expired/revoked.
response.headers["X-Bearer-Token-Rejected"] = "true"
end

def execute_from_params(params)
context = Context.new(self)

Expand Down
113 changes: 113 additions & 0 deletions app/controllers/oauth_sessions_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# frozen_string_literal: true

# Mediates the SPA's OAuth session via an HttpOnly cookie so the long-lived
# refresh token never has to live in JavaScript-readable storage.
#
# POST /oauth_session/exchange — finalize the PKCE auth_code grant
# POST /oauth_session/refresh — rotate the cookie, mint a new access token
# POST /oauth_session/sign_out — revoke the row, clear the cookie
#
# CSRF: the cookie is `SameSite=Strict` so the browser will never attach it to
# a cross-origin request, including top-level navigation from an attacker's
# page. That's the CSRF defense for this controller, hence the
# `skip_before_action :verify_authenticity_token`.
class OAuthSessionsController < ApplicationController
COOKIE_NAME = "__Host-intercode_refresh"

skip_before_action :verify_authenticity_token
skip_before_action :ensure_user_con_profile_exists
skip_before_action :redirect_if_user_con_profile_needs_update
skip_before_action :ensure_clickwrap_agreement_accepted

def exchange
return render_oauth_error(:invalid_request, "code is required", status: :bad_request) if params[:code].blank?

frontend_app = OAuthApplication.find_by(is_intercode_frontend: true)
return render_oauth_error(:invalid_client, status: :bad_request) unless frontend_app

grant = Doorkeeper.config.access_grant_model.by_token(params[:code])
return render_oauth_error(:invalid_grant, status: :bad_request) unless grant

client = Doorkeeper::OAuth::Client.find(frontend_app.uid)
request =
Doorkeeper::OAuth::AuthorizationCodeRequest.new(
Doorkeeper.config,
grant,
client,
redirect_uri: params[:redirect_uri],
code_verifier: params[:code_verifier]
)

respond_with_token_request(request)
end

def refresh
refresh_token_value = cookies[COOKIE_NAME]
return render_oauth_error(:invalid_grant, status: :unauthorized) if refresh_token_value.blank?

access_token = Doorkeeper::AccessToken.by_refresh_token(refresh_token_value)
return render_oauth_error(:invalid_grant, status: :unauthorized) if access_token.nil?

frontend_app = OAuthApplication.find_by(is_intercode_frontend: true)
return render_oauth_error(:invalid_client, status: :bad_request) unless frontend_app

# Public client: secret is nil. `RefreshTokenRequest` requires credentials
# whenever the token row has an `application_id`, even for public clients.
credentials = Doorkeeper::OAuth::Client::Credentials.new(frontend_app.uid, nil)
request = Doorkeeper::OAuth::RefreshTokenRequest.new(Doorkeeper.config, access_token, credentials)

respond_with_token_request(request)
end

def sign_out
refresh_token_value = cookies[COOKIE_NAME]
if refresh_token_value.present?
access_token = Doorkeeper::AccessToken.by_refresh_token(refresh_token_value)
access_token&.revoke
end
clear_refresh_cookie
head :no_content
end

private

def respond_with_token_request(request)
response = request.authorize
if response.is_a?(Doorkeeper::OAuth::ErrorResponse)
clear_refresh_cookie
render json: response.body, status: response.status
return
end

body = response.body
set_refresh_cookie(body["refresh_token"]) if body["refresh_token"].present?
render json: {
access_token: body["access_token"],
token_type: body["token_type"],
expires_in: body["expires_in"],
scope: body["scope"]
}
end

# rubocop:disable Naming/AccessorMethodName -- not a setter on the controller; writes to the cookie jar
def set_refresh_cookie(value)
cookies[COOKIE_NAME] = { value: value, **cookie_attributes }
end
# rubocop:enable Naming/AccessorMethodName

def clear_refresh_cookie
cookies.delete(COOKIE_NAME, **cookie_attributes)
end

# `__Host-` prefix requires `Secure`, no `Domain`, and `Path=/`. Browsers
# reject cookies with that prefix that don't meet these conditions.
def cookie_attributes
{ httponly: true, secure: true, same_site: :strict, path: "/" }
end

def render_oauth_error(error_code, description = nil, status:)
body = { error: error_code }
body[:error_description] = description if description
render json: body, status: status
end
end
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
76 changes: 73 additions & 3 deletions app/controllers/sessions_controller.rb
Original file line number Diff line number Diff line change
@@ -1,17 +1,87 @@
# 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

# Revoke the user's intercode-frontend access tokens before Devise tears down
# the session, so any JWT copy that may have been exfiltrated (e.g. via XSS)
# stops working at sign-out time. This also revokes the refresh tokens that
# share the same DB row. Intentionally revokes across all of the user's
# browser sessions, since signing out is a "secure my account" action.
def destroy
revoke_frontend_access_tokens_for(current_user) if current_user
super
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]
end

def revoke_frontend_access_tokens_for(user)
frontend_app = OAuthApplication.find_by(is_intercode_frontend: true)
return unless frontend_app

# rubocop:disable Rails/SkipsModelValidations -- Doorkeeper's own `AccessToken#revoke` uses `update_column`;
# no validations need to run when stamping revoked_at, and a single UPDATE is cheaper than per-row `.revoke`.
Doorkeeper::AccessToken.where(
resource_owner_id: user.id,
application_id: frontend_app.id,
revoked_at: nil
).update_all(revoked_at: Time.current)
# rubocop:enable Rails/SkipsModelValidations
end
end
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
Loading
Loading