Full user sign-in:
Landing page → "Sign in with HCA" → HCA OAuth (external) → callback validates CSRF
→ find/create User by hca_id → refresh Slack profile → session[:user_id] = user.id
→ redirect to / → landing redirects logged-in users to /path
Trial user sign-in:
Landing page → enter email → POST /trial_session → validate email
→ if email belongs to verified user: X-Inertia-Location header → HCA with login_hint (prefills email)
→ else: find/create TrialUser by email+device_token → set encrypted cookie (1yr)
→ session[:user_id] = trial_user.id → redirect to return_to or /path
Trial → Full promotion:
Trial user clicks "Go Verify" → HCA OAuth → callback:
→ email mismatch? abort, stay as trial
→ email match? transaction: transfer projects + onboarding responses + ahoy visits
→ delete device cookie → soft-purge ALL same-email trial users (all devices)
→ fire Slack welcome message + channel invite jobs
→ apply pending professor enrollment + claim pending collaboration invites
→ sign in as verified user → redirect to return_to or /
Before-action pipeline (every request):
set_current_user → authenticate_user! → redirect_banned_user!
→ redirect_discarded_trial_user! → authenticate_verified_user! → redirect_to_onboarding!
Each step depends on previous steps. Order is critical. Default: everything locked down. Controllers opt in to relaxations with allow_unauthenticated_access, allow_trial_access, skip_onboarding_redirect.
| Question | File |
|---|---|
| How does the before-action chain work? | app/controllers/concerns/authentication.rb |
| How does HCA OAuth work? | app/controllers/auth_controller.rb, app/services/hca_service.rb |
| Where is the OAuth CSRF state stored? | app/controllers/concerns/oauth_state.rb (encrypted cookie, not session) |
| How do trial sessions work? | app/controllers/trial_sessions_controller.rb |
| What data do users have? | app/models/user.rb, app/models/trial_user.rb |
| What's shared with the frontend? | app/controllers/application_controller.rb (inertia_share) |
| How does Pundit authorization work? | app/policies/, app/controllers/application_controller.rb |
The system has two distinct user types stored in one users table via Rails STI (Single Table Inheritance):
Full User (User) |
Trial User (TrialUser) |
|
|---|---|---|
type column |
nil (base class) |
'TrialUser' |
| Auth method | HCA OAuth | Email + device cookie |
| Scope | Cross-device | Device-cookie-scoped |
| Data access | All their data | Only data created on this device |
hca_id |
Required (present) | nil |
slack_id |
Required | nil |
roles |
["user"] etc. |
[] |
device_token |
nil |
64-char hex token |
trial? |
false |
true |
verified? |
true |
false |
admin?/staff? |
Possible (via roles) | Always false |
id bigint PK
type string -- nil=User, 'TrialUser'=TrialUser
email string NOT NULL
display_name string NOT NULL
avatar string NOT NULL
timezone string NOT NULL
roles string[] NOT NULL default=[]
is_banned boolean NOT NULL default=false
ban_type string -- one of BAN_PRIORITY (fallout/hcb/hardware/age/hackatime)
ban_reason text
onboarded boolean NOT NULL default=false
hca_id string -- nil for trial users
slack_id string -- nil for trial users
hca_token text encrypted -- nil for trial users
lapse_token text encrypted
slack_token text encrypted
device_token text encrypted (deterministic) -- nil for full users; deterministic so find_by works
discarded_at datetime -- soft delete timestamp
is_adult boolean NOT NULL default=false
verification_status string
has_hca_address boolean NOT NULL default=false
professor_enrolled_at datetime -- nil until enrolled in the Professor/mentor program
first_name string -- cached from HCA identity for batch sync use
last_name string -- cached from HCA identity for batch sync use
country string -- ISO2 code from HCA primary address (normalized)
created_at/updated_at datetime
(Other columns exist — streak_*, hcb_email, bio, pronouns, etc. — but are unrelated to auth.)
Indexes:
index_users_on_device_token— forfind_by(device_token: ...)(deterministic-encrypted)index_users_on_discarded_at— forkept/discardedscope queriesindex_users_on_hca_id—UNIQUE WHERE (hca_id IS NOT NULL)— enforces one full user per HCA IDindex_users_unique_verified_email—UNIQUE WHERE (type IS NULL AND discarded_at IS NULL)— enforces no two active full users share an email at the DB level
Why partial index instead of regular unique index?
- Trial users can share email with (discarded) trial users from other devices
- Discarded records must be preserved for audit but shouldn't block re-use
- A regular unique index on
emailwould fail when soft-purging trial users and creating a new verified user with the same email
GET / landing#index (root)
GET /auth/hca/start auth#new (signin)
GET /auth/hca/callback auth#create (hca_callback)
DELETE /auth/signout auth#destroy (signout)
POST /trial_session trial_sessions#create (trial_session)
POST /rsvp rsvps#create (rsvp)
GET /path path#index
GET /sorry bans#show (sorry)
GET /onboarding onboarding#show
POST /onboarding onboarding#update
scope :verified, -> { where(type: nil) }CRITICAL GOTCHA: Do NOT use where.not(type: "TrialUser"). In PostgreSQL, WHERE type != 'TrialUser' excludes NULL rows (SQL NULL comparisons return NULL, not TRUE/FALSE). Users with type: nil would be invisible. where(type: nil) correctly generates WHERE type IS NULL.
def trial?; false; end
def verified?; true; end
def admin?; has_role?(:admin); end
def staff?; admin? || reviewer?; endvalidates :slack_id, presence: true, unless: :trial?
validates :hca_id, presence: true, unless: :trial?
validates :roles, presence: true, unless: :trial?
validate :roles_must_be_valid, unless: :trial?These are nil/empty for trial users, so the validations must be conditional. roles_must_be_valid rejects any role not in VALID_ROLES (user admin time_auditor requirements_checker pass2_reviewer hcb).
HcaService.exchange_code_for_token→ POST to HCA/oauth/tokenwith code + redirect_uri → getaccess_tokenHcaService.me(access_token)→ GET/api/v1/mewith Bearer token → response hash;identity = hca_response["identity"]identity["id"]=hca_id,identity["primary_email"]= emailUser.find_by(hca_id: hca_id)— look up by HCA ID first- If found: update
hca_token+email, callapply_identity_cache!(identity)(syncs verification_status/address/name/country) +refresh_profile_from_slack, return user - If not found:
create_from_hca(identity, access_token)(which also callsapply_identity_cache!) thenrefresh_profile_from_slack
Note: find_by(hca_id: ...) only searches base User class (type=nil), never TrialUser (they have nil hca_id).
class TrialUser < User
validates :device_token, presence: true
validate :email_not_taken_by_verified_user
def trial?; true; end
def verified?; false; end
def self.find_or_create_from_device(email:, device_token:)
find_by(email: email, device_token: device_token) ||
create!(
email: email,
device_token: device_token,
display_name: email.split("@").first.presence || "Guest",
avatar: "/static-assets/pfp_fallback.webp",
timezone: "UTC",
is_banned: false,
roles: []
)
end
private
def email_not_taken_by_verified_user
errors.add(:email, "is associated with an existing account") if User.verified.kept.exists?(email: email)
end
endfind_or_create_from_device: scopes by BOTH email AND device_token. Two users on different devices with the same email get separate TrialUser records and cannot see each other's data. If the same device revisits with the same email, they get the same record back.
email_not_taken_by_verified_user: model-level guard. Also enforced at controller level and DB level (partial index).
The Authentication concern is included in ApplicationController. Every controller in the app inherits this chain:
before_action :set_current_user
before_action :authenticate_user!
before_action :redirect_banned_user!
before_action :redirect_discarded_trial_user!
before_action :authenticate_verified_user!
before_action :redirect_to_onboarding!Order is critical — each action below depends on the ones above having run.
def set_current_user
@current_user = User.find_by(id: session[:user_id]) if session[:user_id]
@true_user = session[:impersonator_id] ? User.find_by(id: session[:impersonator_id]) : @current_user
endUser.find_by(id: ...)uses Rails STI automatically — returnsTrialUserinstance iftype='TrialUser',Userinstance iftype=nil.- Sets
@current_user = nilif no session or user not found. @true_useris the real human behind the request — the admin during impersonation, otherwisecurrent_user(see Admin Impersonation below).
Admins can browse the app as another user. The mechanism is intentionally thin: while impersonating, session[:user_id] is the target's id, so the entire pipeline (Pundit, ActionCable, shared props, analytics) sees exactly what that user sees — with the target's (lesser) privileges, never the admin's. The real admin id is parked in session[:impersonator_id] for the exit path and audit attribution.
- Helpers (
Authenticationconcern):true_user(the admin while impersonating),impersonating?(session[:impersonator_id]present and ≠ current_user). Both arehelper_methods and available toinertia_share. - Audit attribution:
user_for_paper_trailis overridden to returntrue_user&.id, so PaperTrailwhodunnitrecords the admin (a clean integer id) for any change made during impersonation — not the target. Start/stop are also logged toRails.loggerwith admin id, target id, and IP. - Start —
POST /admin/users/:id/impersonate→Admin::UsersController#impersonate(insideAdminConstraint, double-gated byrequire_admin!+authorize→UserPolicy#impersonate? == admin?). Refuses staff (privilege escalation, incl.hcbmoney access), self, trial, discarded, banned, and un-onboarded targets — the last three would otherwise trap abefore_actionredirect before the user could exit. - Stop —
DELETE /impersonate→ImpersonationsController#destroy. Lives outside the admin constraint because the effective user is the non-staff target (would failAdminConstraint). Restoressession[:user_id]fromimpersonator_idand redirects back to the admin user page. Blanket-skips both Pundit verify callbacks (noindex; session-scoped, nothing to authorize). - Frontend:
impersonationshared prop (nil unless active; exposes only the admin display name — no PII — andstop_path) drives a fixedImpersonationBannerinDefaultLayout. The admin user-show page offers an "Impersonate" button gated by the same rules as the server (STAFF_ROLES, not self/banned/discarded).
def authenticate_user!
unless current_user
redirect_to root_path, alert: "You need to be logged in to see this!"
end
endBlocks unauthenticated access. Skipped with allow_unauthenticated_access only: %i[action].
def redirect_banned_user!
redirect_to sorry_path if current_user&.is_banned?
endSends banned users to /sorry. Skipped on bans#show itself (that's the destination) and auth#destroy (banned users must be able to sign out).
def redirect_discarded_trial_user!
return unless current_user&.discarded?
is_trial = current_user.trial?
email = current_user.email
@current_user = nil
terminate_session
if is_trial
cookies.delete(:trial_device_token)
redirect_to signin_path(login_hint: email), notice: "Your trial session has expired. Please sign in to continue."
else
redirect_to root_path, notice: "Your account is no longer active."
end
endCatches stale trial sessions: when Device B's trial user is soft-purged because Device A verified, Device B's session still has the now-discarded trial user's ID. This before-action detects that, clears the session and device cookie, and redirects to HCA sign-in.
Must run before authenticate_verified_user! — if it redirected first, the stale trial user would get the generic "verify your account" error instead of the informative expiry message.
def authenticate_verified_user!
redirect_to signin_path(login_hint: current_user.email), alert: "Please verify your account to access this." if current_user&.trial?
endThe default-deny gate for trial users. Blocks trial users from every action unless the controller explicitly opts in with allow_trial_access.
current_user&.trial? is nil-safe: unauthenticated visitors have current_user = nil, so nil&.trial? returns nil (falsy) — unauthenticated users are unaffected.
def redirect_to_onboarding!
redirect_to onboarding_path if current_user&.needs_onboarding?
endSends users who haven't completed onboarding to /onboarding. Skipped on onboarding#show and onboarding#update (the destination itself).
These are defined on Authentication as class_methods and called at the class level in controllers:
def self.allow_unauthenticated_access(only: nil)
skip_before_action :authenticate_user!, only: only
endSkips the authenticate_user! check. Does NOT affect authenticate_verified_user!. A nil/unauthenticated current_user doesn't trigger authenticate_verified_user! (nil-safe).
def self.allow_trial_access(only: nil)
skip_before_action :authenticate_verified_user!, only: only
endAllows trial users into specific actions. Must be called on any controller that trial users need to access.
def self.skip_onboarding_redirect(only: nil)
skip_before_action :redirect_to_onboarding!, only: only
endThe problem: Rails 8.1 validates ALL action names referenced in only: or except: clauses across the entire inherited callback chain. ApplicationController defines:
after_action :verify_authorized, except: :index
after_action :verify_policy_scoped, only: :indexBoth reference :index. Any subcontroller WITHOUT an index action triggers AbstractController::ActionNotFound if it tries to use only: or except: on skip_after_action.
The fix: Controllers without an index action use unconditional (blanket) skips:
skip_after_action :verify_authorized # comment: No authorizable resource on any action
skip_after_action :verify_policy_scoped # comment: No index action; no policy-scoped queriesControllers WITH an index action (e.g. ProjectsController, PathController) can safely use only::
skip_after_action :verify_authorized, only: %i[index]This is safe: verify_policy_scoped only runs for index anyway; blanket-skipping verify_authorized on non-resource controllers is the established convention (same as AuthController).
| Controller | Actions | Reason |
|---|---|---|
AuthController |
new, create, destroy |
Critical: trial users must be able to start HCA verification (new), complete it (create), and sign out (destroy). Without this, authenticate_verified_user! blocks them before they can verify. |
LandingController |
index |
Redirects user_signed_in? to path. Without allow_trial_access, authenticate_verified_user! fires first and sends trial users to HCA sign-in instead. |
BansController |
show |
Banned trial users must see the ban notice. Without this: redirect_banned_user! sends them to /sorry, then authenticate_verified_user! kicks them out of /sorry → redirect loop. |
PathController |
index |
Trial users view their path (main experience page). |
ProjectsController |
index show new create edit update destroy onboarding export_journal |
Trial users create/view/edit their single project and export its journal. (show is also allow_unauthenticated_access — listed project pages are public from Explore/the public API.) |
JournalEntriesController |
preview |
Trial users can preview journal markdown (but cannot create entries — unverified emails prevent abuse). |
YouTubeVideosController |
lookup |
Trial users can look up video metadata during journal creation. |
MarkdownController |
show |
Trial users can read docs. Also has allow_unauthenticated_access but that only skips authenticate_user!, not authenticate_verified_user!. |
OnboardingController |
show, update |
Both trial and full users complete onboarding. |
CertificateVerificationsController |
show |
Public certificate-verification link (/verify/:token) — must work for anyone, signed in or not. Also allow_unauthenticated_access and skip_onboarding_redirect. Token-gated (skip_after_action :verify_authorized/:verify_policy_scoped, no Pundit resource), mirrors PendingCollaborationInvitesController. Token is a short 8-char alphanumeric slug (collision-checked on generation), not a long random token, since it's printed on the certificate. Lookup scopes on User.verified.kept.not_banned so a user banned after earning a certificate loses their public verification page, and trial users (who can never hold a token) are excluded defensively. |
Notable omission: RsvpsController has allow_unauthenticated_access but not allow_trial_access. This means logged-in trial users are blocked from submitting RSVPs by authenticate_verified_user!. Only unauthenticated visitors (no session) can submit. This appears intentional — RSVPs are for the landing page email form, and trial users have already entered the platform.
Why allow_unauthenticated_access is not sufficient for public endpoints that trial users visit: allow_unauthenticated_access only skips authenticate_user!. If a trial user is signed in, authenticate_verified_user! still fires and redirects them to HCA sign-in. Public endpoints that need to be accessible to trial users need BOTH:
allow_unauthenticated_access(for truly unauthenticated visitors)allow_trial_access(for trial users who are signed in)
AuthController includes the OauthState concern (app/controllers/concerns/oauth_state.rb) for storing the CSRF state in a dedicated encrypted cookie rather than the session.
def new
state = SecureRandom.hex(24)
# Store OAuth state in a dedicated cookie (not the session) so it survives
# cross-site redirects and reset_session. Keep the last 4 so concurrent tabs
# don't overwrite each other.
existing = Array(cookies.encrypted[:oauth_state]).last(4)
set_oauth_cookie(:oauth_state, existing + [ state ])
redirect_to HcaService.authorize_url(hca_callback_url, state, login_hint: params[:login_hint]), allow_other_host: true
end- Generate 48-char CSRF state token, append it to the
oauth_stateencrypted cookie (keeps up to the last 4 states so concurrent tabs don't clobber each other; cookie expires in 10 min,same_site: :lax) - Build HCA OAuth URL with client_id, redirect_uri, scopes, state, and optional
login_hint - Redirect to HCA (external host, hence
allow_other_host: true)
Why a cookie instead of session[:state]: the session cookie can be dropped during cross-site OAuth redirects under strict browser privacy settings, and terminate_session/reset_session would wipe it. The oauth_state cookie survives both. Multiple states are stored so a user with several tabs mid-flow doesn't fail CSRF.
login_hint: If params[:login_hint] is present, appended to the HCA authorize URL. HCA uses this to prefill the email field. Set when TrialSessionsController detects the entered email belongs to a verified user.
def create
# 1. CSRF validation: state must be one of the stored oauth_state cookie values
valid_states = Array(cookies.encrypted[:oauth_state])
delete_oauth_cookie(:oauth_state)
unless valid_states.include?(params[:state])
# ErrorReporter.capture_message(...), redirect_to root_path, alert: "Authentication failed due to CSRF token mismatch"
return
end
begin
# 2. Exchange code for user
user = User.exchange_hca_token(params[:code], hca_callback_url)
# 3. Trial promotion (if applicable)
trial_conversion = current_user&.trial?
if trial_conversion
if current_user.email != user.email
redirect_to path_path, alert: "This email already has an account! Please sign out and log in with HCA."
return
end
ActiveRecord::Base.transaction do
current_user.projects.update_all(user_id: user.id)
existing_keys = user.onboarding_responses.pluck(:question_key)
current_user.onboarding_responses.where.not(question_key: existing_keys).update_all(user_id: user.id)
current_user.ahoy_visits.update_all(user_id: user.id) # Transfer attribution data
user.update!(onboarded: true) if current_user.onboarded? && !user.onboarded?
end
cookies.delete(:trial_device_token)
end
# 4. Soft-purge all trial users with this email
TrialUser.kept.where(email: user.email).update_all(discarded_at: Time.current)
# 5. Slack welcome for trial conversions
if trial_conversion && user.slack_id.present?
SlackMsgJob.perform_later(user.slack_id, welcome_message)
SlackChannelInviteJob.perform_later(user.slack_id, User::SLACK_WELCOME_CHANNELS)
end
# 6. Apply pending professor enrollment (intent stored in onboarding_responses,
# applied here once HCA promotion grants a slack_id). Non-blocking.
# ...
# 7. Claim any pending collaboration invites matching this email
PendingCollaborationInvite.claim_all_for_email!(user.email, user)
# 8. Sign in as verified user
terminate_session
session[:user_id] = user.id
redirect_to_return_to_or(root_path, notice: "Welcome back, #{user.display_name}!")
rescue StandardError => e
ErrorReporter.capture_exception(e)
redirect_to root_path, alert: "Authentication failed. Please try again."
end
endCSRF validation: params[:state] must match one of the values in the oauth_state cookie (multiple states for concurrent tabs). The cookie is deleted before the check (single-use). A mismatch is logged via ErrorReporter.capture_message with a "CSRF validation failed" message and redirects to root_path with a CSRF-mismatch alert.
Pending professor enrollment: if the trial user signed up for a mentor during onboarding (intent stored in onboarding_responses under professor_enrollment), it's applied here once they have a slack_id via ProfessorService.manual_add. Non-blocking — a failure leaves professor_enrolled_at nil and the auth flow continues.
Pending collaboration invites: PendingCollaborationInvite.claim_all_for_email! attaches any invites that were sent to this user's email before they had an account.
Post-auth redirect: uses redirect_to_return_to_or(root_path, ...) — honors session[:return_to] if set (preserved across terminate_session), otherwise falls back to root_path.
Email mismatch guard: If the trial user's email doesn't match the HCA account's email, abort. This prevents cross-contamination where a user accidentally verifies with the wrong HCA account.
Onboarding response transfer: Only transfer responses for question_key values the verified user doesn't already have. update_all(user_id: user.id) on the full set would throw PG::UniqueViolation if the verified user has already answered some questions.
Soft purge scope: TrialUser.kept.where(email: user.email) — purges ALL active trial users with that email across ALL devices, not just the current one. Uses update_all(discarded_at: Time.current) instead of Discard's discard_all to stay efficient.
def destroy
terminate_session
redirect_to root_path, notice: "Signed out successfully. Cya!"
endterminate_session calls reset_session but preserves session[:return_to] across the reset (so post-auth redirects survive the OAuth flow). Does NOT delete the trial_device_token cookie — that persists so the user can resume their trial session if they re-enter the same email.
def create
redirect_to path_path and return if user_signed_in? && !current_user.trial?
email = params[:email].to_s.strip.downcase
# 1. Validate email format
unless email.match?(URI::MailTo::EMAIL_REGEXP)
redirect_to root_path, alert: "Please enter a valid email."
return
end
# 2. Redirect known verified users to HCA with email prefilled
if User.verified.exists?(email: email)
# Inertia XHR can't follow external redirects (CORS). Use X-Inertia-Location
# so the client does window.location.href = url, letting the browser navigate natively.
response.headers["X-Inertia-Location"] = signin_path(login_hint: email)
head :conflict
return
end
# 3. Find or create trial user for this device+email
device_token = cookies.encrypted[:trial_device_token] || SecureRandom.hex(32)
begin
trial_user = TrialUser.find_or_create_from_device(email: email, device_token: device_token)
rescue ActiveRecord::RecordInvalid
# TOCTOU race: a verified user was created between the exists? check and create!
response.headers["X-Inertia-Location"] = signin_path(login_hint: email)
head :conflict
return
end
# 4. Set/refresh device cookie
cookies.encrypted[:trial_device_token] = {
value: device_token,
httponly: true,
secure: Rails.env.production?,
same_site: :strict,
expires: 1.year
}
# 5. Sign in as trial user
session[:user_id] = trial_user.id
redirect_to_return_to_or(path_path)
endVerified user redirect: Uses X-Inertia-Location header (not a standard redirect) because Inertia XHR requests can't follow cross-origin redirects due to CORS. The client reads this header and does window.location.href = url. login_hint: email prefills HCA's email field.
TOCTOU race condition: Between User.verified.exists?(email:) and TrialUser.create!, another request could create a verified user with the same email. The RecordInvalid rescue catches the model-level email_not_taken_by_verified_user validation failure.
Device token lifecycle:
- First visit:
SecureRandom.hex(32)generates a fresh 64-char hex token - Return visit (same browser): cookie already set, token reused → same TrialUser record returned by
find_or_create_from_device - Different browser/device: no cookie → new token → separate TrialUser record even with same email
Cookie security: httponly: true (JS cannot read), secure: true in production (HTTPS only), same_site: :strict (no cross-site requests). Encrypted by Rails cookie encryption.
allow_unauthenticated_access only: %i[create]: Skips the inherited authenticate_user! check so unauthenticated visitors can reach this action. skip_onboarding_redirect only: %i[create] is also required — without it, a signed-in user who hasn't completed onboarding would be redirected away before the action body runs.
Sessions are standard Rails cookie-backed sessions:
session[:user_id]— ID of the signed-in user (full or trial)session[:return_to]— optional post-auth redirect target; preserved acrossterminate_sessionand consumed byredirect_to_return_to_orset_current_user→User.find_by(id: session[:user_id])→ Rails STI returns correct subclassterminate_session→reset_session(regenerates session ID and clears all session data, but re-storesreturn_to)
OAuth CSRF state is NOT stored in the session — it lives in a dedicated encrypted oauth_state cookie (see OauthState concern) so it survives reset_session and cross-site redirects that can drop session cookies.
There is no JWT, no server-side session store — purely cookie-based Rails sessions.
Every controller action must satisfy both Pundit checks by default:
after_action :verify_authorized, except: :index # every action except index called authorize(resource)
after_action :verify_policy_scoped, only: :index # index actions called policy_scope(collection)Controllers that don't work with Pundit resources skip these:
skip_after_action :verify_authorized # (no only: — see Rails 8.1 gotcha above)
skip_after_action :verify_policy_scopedrescue_from Pundit::NotAuthorizedError in ApplicationController handles unauthorized access with a flash alert + redirect back.
Builds the HCA OAuth authorize URL. Appends login_hint to the query string if present (HCA uses it to prefill the email field).
Production host: https://auth.hackclub.com
Development host: https://hca.dinosaurbbq.org
POST to /oauth/token with grant_type: "authorization_code". Returns parsed JSON or nil on failure.
GET /api/v1/me with Bearer authorization. Returns the parsed response hash (the identity is nested under the "identity" key, containing id, primary_email, first_name, last_name, slack_id, profile_picture, verification_status, addresses, birthday). Raises HcaService::InvalidToken on 401/403 so callers can clear a dead stored token; returns nil on transient network errors.
Convenience wrapper: calls me and returns the inner "identity" hash (or {}). Used by the periodic identity refresh job.
Build HCA portal URLs (/portal/verify, /portal/address) with a return_to query param. Powers the identity-gate CTAs shared to the frontend.
ApplicationController shares these with every page via inertia_share:
inertia_share auth: -> {
{
user: current_user&.then { |u|
{
id: u.id,
display_name: u.display_name,
avatar: u.avatar,
roles: u.roles,
is_admin: u.admin?,
is_staff: u.staff?,
is_banned: u.is_banned,
ban_type: u.ban_type,
is_trial: u.trial?,
is_onboarded: u.onboarded?,
professor_enrolled: u.professor_enrolled?,
professor_recently_enrolled: u.professor_enrolled? && u.professor_enrolled_at > 24.hours.ago,
professor_enrollment_eligible: u.professor_enrollment_eligible? && !u.professor_enrolled?
}
}
}
}
inertia_share flash: -> { flash.to_hash }
inertia_share sign_in_path: -> { signin_path(login_hint: current_user&.trial? ? current_user.email : nil) }
inertia_share sign_out_path: -> { signout_path }
inertia_share trial_session_path: -> { trial_session_path }
inertia_share rsvp_path: -> { rsvp_path }
inertia_share features: -> { ... } # per-user Flipper flags (empty {} for trial/unauth users)
inertia_share streak_freezes: -> { ... } # 0 for trial/unauth users
inertia_share identity_gate: -> { ... } # nil for trial/unauth users; { state, verify_url, address_url }
inertia_share show_feedback_banner: -> { ... } # true only for full users with an emailNote: email is intentionally NOT included in the shared auth.user props — it's PII. Frontend code that needs it must obtain it from an admin-only/authorized page prop.
has_unread_mail and current_streak are scoped to PathController only (declared via inertia_share inside PathController, not ApplicationController) — they're only consumed by the path page header, so they aren't shared on every other authenticated request (saves 2 queries/page).
IMPORTANT: sign_in_path, sign_out_path, trial_session_path, rsvp_path are top-level shared props, NOT nested under auth. Access as shared.sign_in_path, not shared.auth.sign_in_path.
sign_in_path is dynamic: for trial users, it includes login_hint: current_user.email so HCA prefills their email field. For full users or unauthenticated visitors, no hint.
features: returns empty {} for trial/unauthenticated users (feature flags are full-user only). Otherwise based on Flipper: { collaborators: bool, shop: bool, grant_fulfillment: true, hcb_top_ups: bool }.
identity_gate: nil for trial/unauthenticated users (no HCA account to link). Otherwise { state: identity_gate_state, verify_url:, address_url: } driving the AnnouncementsBar identity card.
Security note: All attributes passed to the frontend are visible in browser devtools. hca_token, lapse_token, slack_token, and email are never exposed in shared props.
interface SharedProps {
auth: { user: User | null } // just user, nothing else nested
flash: FlashData // Record<string, string>
features: Features // { collaborators?, shop?, grant_fulfillment: true }
sign_in_path: string // top-level, NOT under auth
sign_out_path: string
trial_session_path: string
rsvp_path: string
has_unread_mail: boolean // only present on the path page
current_streak: number // only present on the path page
unsubmitted_hours: number | null
streak_freezes: number
identity_gate: IdentityGate | null
show_feedback_banner: boolean
errors: Record<string, string[]>
[key: string]: unknown
}
interface User {
id: number
display_name: string
avatar: string
roles: string[]
is_admin: boolean
is_staff: boolean
is_banned: boolean
ban_type: BanType | null
is_trial: boolean
is_onboarded: boolean
professor_enrolled: boolean
professor_recently_enrolled: boolean
professor_enrollment_eligible: boolean
}const shared = usePage<SharedProps>().props
// Check trial status
if (shared.auth.user?.is_trial) { ... }
// Sign out
router.delete(shared.sign_out_path)
// Show verify CTA only for trial users
{shared.auth.user?.is_trial && <SignUpCta signInPath={shared.sign_in_path} />}Admin and staff routes are protected at the routing layer via route constraints:
constraints Constraints::StaffConstraint.new do
namespace :admin do
# ... staff-accessible review queues
end
end
constraints Constraints::AdminConstraint.new do
mount MissionControl::Jobs::Engine, at: "/jobs" # admins only
namespace :admin do
# ... admin-only resources (projects, users, etc.)
end
end(There is also a small admin block intentionally OUTSIDE the StaffConstraint for reviewer-specific routes — see config/routes.rb.)
Constraints::StaffConstraint and Constraints::AdminConstraint (lib/constraints/) look up User.find_by(id: request.session[:user_id]) and check user&.staff? / user&.admin?. Trial users have roles: [], so both return false — they cannot reach any admin routes.
Admin controllers inherit from Admin::ApplicationController, which enforces require_staff! (and require_admin! where needed) as a before-action too (defense in depth).
The in-house Discardable concern (app/models/concerns/discardable.rb, NOT the discard gem) adds:
- relies on the
discarded_attimestamp column discard/undiscard/discarded?instance methodskeptscope (WHERE discarded_at IS NULL)discardedscope (WHERE discarded_at IS NOT NULL)
Trial users are soft-purged (not hard-deleted) on promotion so that:
redirect_discarded_trial_user!can detect stale sessions on other devices- Data is preserved for auditing/support
- The operation is reversible if triggered accidentally
Hard deleting trial users would cause set_current_user to silently return nil for other devices, which would then hit authenticate_user! with a generic "not logged in" error instead of the informative "trial expired" message.
1. User clicks "Sign in with HCA"
→ GET /auth/hca/start (auth#new)
→ append random CSRF token to oauth_state encrypted cookie
→ redirect to https://auth.hackclub.com/oauth/authorize?...
2. User authenticates on HCA
→ HCA redirects to GET /auth/hca/callback?code=xxx&state=yyy
3. auth#create:
→ validate state ∈ oauth_state cookie (then delete cookie)
→ User.exchange_hca_token(code, redirect_uri)
→ POST /oauth/token → access_token
→ GET /api/v1/me → identity
→ find or create User by hca_id
→ apply_identity_cache! + refresh Slack profile
→ (if trial user: transfer data, delete cookie)
→ soft-purge same-email trial users
→ apply pending professor enrollment + claim pending collaboration invites
→ terminate_session; session[:user_id] = user.id
→ redirect to return_to or / (landing) → landing redirects logged-in users to /path
1. User enters email on landing page
→ POST /trial_session (trial_sessions#create)
→ validate email format
→ if verified user exists with email: X-Inertia-Location header → HCA with login_hint
→ else: find/create TrialUser by email+device_token (rescue TOCTOU race)
→ set encrypted trial_device_token cookie (1 year)
→ session[:user_id] = trial_user.id
→ redirect to return_to or /path
1. Trial user (email A) clicks "Go Verify" → auth#new → HCA
2. HCA returns account with email B (mismatch):
→ redirect to /path, alert: "This email already has an account! ..."
→ trial session remains active
3. HCA returns account with email A (match):
→ transaction:
- transfer projects: UPDATE projects SET user_id=verified_id WHERE user_id=trial_id
- transfer onboarding responses (skip keys already answered by verified user)
- transfer ahoy visits (preserves attribution like first_ref)
- mark verified user onboarded if trial was onboarded
→ cookies.delete(:trial_device_token)
→ soft-purge ALL TrialUser.kept.where(email: A)
→ SlackMsgJob + SlackChannelInviteJob (welcome message + channel invites)
→ apply pending professor enrollment (if intent recorded + now has slack_id)
→ PendingCollaborationInvite.claim_all_for_email!
→ terminate_session; session[:user_id] = verified_user.id
→ redirect to return_to or /
4. Other devices with same email A trial session:
→ next request: redirect_discarded_trial_user! fires
→ session cleared, cookie deleted, redirect to /auth/hca/start?login_hint=email
User.verifieduseswhere(type: nil)— NOTwhere.not(type: "TrialUser")(PostgreSQL NULL exclusion gotcha)- trial users have
roles: []—admin?andstaff?always false; no role-based privilege escalation device_tokennever in params — only read fromcookies.encrypted[]; strong params don't include it- Promotion transfers only current device's data — content from the verifying device transfers; other devices' trial content does not
- Email is downcased before storage —
TrialSessionsControllerdoes.strip.downcase; HCA emails should already be normalized - Rate limiting on auth endpoints — primarily at the Rack::Attack layer (
config/initializers/rack_attack.rb, Redis-backed, consistent across Puma workers):POST /trial_session10/3min per IP + 5/hour per email;GET /auth/hca/start10/min per IP;GET /auth/hca/callback20/min per IP;DELETE /auth/signout10/min per IP;POST /rsvp5/min per IP.auth#createadditionally has a Rails-nativerate_limit to: 10, within: 3.minutes. - CSRF on HCA OAuth —
statestored in theoauth_stateencrypted cookie (not the session) and round-trip verified; mismatch logged to ErrorReporter and rejected