First-Login Completion Form (First-time Signup)
The logi (1pass) IdP passes only minimal identity — roughly sub + email (+ nickname) — to the RP. Yet most RPs require extra fields at their own sign-up (role, org, domain, terms consent, and so on). This page lays out the recommended pattern for collecting that info exactly once, at first login.
Automatic signup is risky
The "just User.create! in the callback handler" pattern easily leads to a vulnerability where unauthorized users are granted arbitrary permissions. A real example: krx_listing auto-signed users up with a role: :reviewer default, so anyone who merely passed 1pass authentication could obtain reviewer permissions — a flaw.
If you must use automatic signup, always create the user with the most restrictive permission (something like role: :guest) and make sure an operator reviews and promotes the user before granting broader access.
Recommended flow
sequenceDiagram
autonumber
participant U as Browser
participant RP as RP (Rails/Inertia)
participant L as logi
U->>RP: GET /login → click "Sign in with 1pass"
RP->>L: GET /oauth/authorize (PKCE)
L->>U: /session/new (QR or Apple/Google)
U->>L: authenticate
L->>RP: 302 /auth/1pass/callback?code&state
RP->>L: POST /oauth/token (code → tokens)
RP->>L: GET /oauth/userinfo
L-->>RP: {sub, email, nickname}
alt a user matching sub exists
RP->>U: redirect / (sign_in)
else a user matching email exists (case B)
RP->>RP: User.update — link sso_provider/subject
RP->>U: redirect / (sign_in)
else new user
RP->>RP: session[:one_pass_pending] = {sub,email,name,expires_at,tokens}
RP->>U: redirect /auth/1pass/complete_signup
U->>RP: GET /auth/1pass/complete_signup<br>(email/name read-only + pick required fields)
U->>RP: POST /auth/1pass/complete_signup
alt role=low privilege (e.g. issuer)
RP->>RP: User.create!(account_status: :active, sso_provider:, sso_subject:, role:, ...)
RP->>U: redirect / (sign_in)
else role=elevated privilege (e.g. reviewer)
RP->>RP: User.create!(account_status: :pending, ...)
RP->>U: redirect /login (notice: awaiting admin approval)
Note over RP: sign_in is blocked until an admin activates the user in /admin/users
end
endLikewise, a user who attempts an SSO login again with a known sub must also pass the active_for_authentication? check before sign_in — pending/blocked users are rejected without issuing tokens or creating a session.
Core principles
- Self-selecting a role ≠ safe — restrict any role a user can self-select to the lowest privilege only. For elevated roles (reviewer/admin/oncall, etc.), either (a) don't activate them immediately — create them as
account_status: pendingand route them through admin approval, or (b) gate them so they can only be requested after passing an email/domain/invite-code allowlist. krx_listing applies pattern (a). - Keep the pending TTL short — 10 minutes recommended. Matching it to logi's OAuth state TTL keeps things consistent.
- Store tokens after completion — while pending, keep tokens only inside
session[:one_pass_pending]. Only after the user finishes signup and aUseris created do you move them to normal keys likesession[:one_pass_access_token]. - Don't overwrite an existing sso_subject on an email match — if the email is already linked to a different 1pass account, reject signup + show a notice. Prevents user confusion and guards against account takeover.
- Replay/double-submit guard — on entry to the
complete_signupPOST, re-check by(sso_provider, sso_subject)oremail. The user may have signed up through another path between callback and form submit. - The device_poll (QR) branch is identical — send new users to the completion form not only from the callback but also from the RFC 8628 device flow poll. Blocking only one side leaves a bypass.
- Keep the permission whitelist in one place — pull whitelists like
ALLOWED_ROLESandALLOWED_DOMAINSinto a controller concern so that regular signup (/register) and the SSO completion form use the same values.
Implementation checklist (when integrating an RP)
- [ ] Remove
find_or_create_user-style auto-creation code from the callback; returnnilwhen not found. - [ ] When
nil, store a pending session + redirect to the completion form page. - [ ] Define the RP-specific fields the completion form will collect (usually role, org, domain, terms consent).
- [ ] Create elevated roles (reviewer/admin, etc.) as
account_status: pending+ skip sign_in and route to an "awaiting admin approval" screen. - [ ] Add the same handling to the device flow / QR poll branch.
- [ ] On an expired/missing pending, redirect to
/login+ flash. - [ ] Add the guard against overwriting an existing sso_subject on an email match.
- [ ] Clear the pending session after completion (
session.delete(:one_pass_pending)). - [ ] (Optional) Limit the number of completion-form entries, or rate-limit.
- [ ] Apply the same elevated-role gating to the regular signup form (
/register) too — the UI path may differ, but the policy stays the same.
Rails 8 example (excerpt from krx_listing)
# app/controllers/concerns/signup_attribute_whitelist.rb
module SignupAttributeWhitelist
ALLOWED_ROLES = %w[issuer reviewer].freeze
ALLOWED_DOMAINS = %w[listing delisting both].freeze
private
def allowed_signup_role(value)
ALLOWED_ROLES.include?(value.to_s) ? value.to_s : nil
end
def allowed_signup_domain(value)
ALLOWED_DOMAINS.include?(value.to_s) ? value.to_s : nil
end
end# app/controllers/web/one_pass_sso_controller.rb (summary)
class Web::OnePassSsoController < ApplicationController
include SignupAttributeWhitelist
PENDING_TTL = 10.minutes
def callback
# ... PKCE/state verification, token exchange, and userinfo lookup stay as before ...
user, link_error = find_or_link_existing_user(sub:, email:, userinfo:)
return fail_with(link_error) if link_error
if user.nil?
store_pending_signup!(sub:, email:, userinfo:, tokens:)
return redirect_to auth_1pass_complete_signup_path
end
sign_in(user)
persist_one_pass_tokens(tokens)
redirect_to root_path
end
def complete_signup_new
pending = fetch_valid_pending or
return redirect_to(login_path, alert: "Your 1pass authentication session has expired.")
render inertia: "Auth/OnePassCompleteSignup",
props: { email: pending["email"], name: pending["name"],
allowed_roles: ALLOWED_ROLES, allowed_domains: ALLOWED_DOMAINS }
end
def complete_signup_create
pending = fetch_valid_pending or
return redirect_to(login_path, alert: "Your 1pass authentication session has expired.")
role = allowed_signup_role(params[:role])
domain = allowed_signup_domain(params[:user_domain])
return redirect_to(auth_1pass_complete_signup_path,
inertia: { errors: { base: "The role/domain selection is invalid." } },
status: :unprocessable_entity) if role.nil? || domain.nil?
sub = pending["sub"]
pending_email = pending["email"].to_s
tokens = pending["tokens"] || {}
# Replay guard ①: if someone already signed up with the same sub, sign them in there.
if (existing = User.find_by(sso_provider: "1pass", sso_subject: sub))
clear_pending_signup!
sign_in(existing)
return redirect_to root_path
end
# Replay guard ②: the same email may have been signed up through another path after callback.
# In that case, reject instead of overwriting the existing user — tell them to log in normally and then link.
if pending_email.present? && User.find_by(email: pending_email).present?
clear_pending_signup!
return redirect_to login_path,
alert: "This email is already registered. Please log in normally and then try linking 1pass."
end
# An elevated role stays pending until admin approval — prevents self-elevation (principle 0).
initial_status = (role == "reviewer") ? :pending : :active
user = User.create!(
email: pending_email.presence || "1pass-#{sub}@1pass.internal",
name: pending["name"].presence || "1pass user",
role: role,
user_domain: domain,
password: SecureRandom.hex(32),
account_status: initial_status,
sso_provider: "1pass",
sso_subject: sub
)
clear_pending_signup!
if user.account_status == "active"
sign_in(user)
persist_one_pass_tokens(tokens)
redirect_to root_path, notice: "Signup with your 1pass account is complete."
else
# pending — login is blocked until an admin activates the user in /admin/users.
redirect_to login_path, notice: "Your signup request has been received. You can log in after admin approval."
end
end
private
def find_or_link_existing_user(sub:, email:, userinfo:)
user = User.find_by(sso_provider: "1pass", sso_subject: sub)
return [ user, nil ] if user
if email.present? && !email.end_with?("@1pass.internal") &&
(user = User.find_by(email: email))
# Don't overwrite a different sso_subject
return [ nil, "A different 1pass account is already linked to this email." ] if
user.sso_subject.present? && user.sso_subject != sub
user.update!(sso_provider: "1pass", sso_subject: sub) if user.sso_subject != sub
return [ user, nil ]
end
[ nil, nil ] # new user — the caller will route to the completion form
end
def store_pending_signup!(sub:, email:, userinfo:, tokens:)
session[:one_pass_pending] = {
"sub" => sub, "email" => email.to_s,
"name" => (userinfo["nickname"].presence || userinfo["name"]).to_s,
"expires_at" => (Time.current + PENDING_TTL).to_i,
"tokens" => tokens.slice("access_token", "refresh_token", "expires_in")
}
end
def fetch_valid_pending
pending = session[:one_pass_pending]
return nil if pending.blank? || pending["expires_at"].to_i < Time.current.to_i
pending
end
def clear_pending_signup!; session.delete(:one_pass_pending); end
endSecurity notes
- Session store: Rails' default
cookie_storeis signed and AES-encrypted withsecret_key_base, so the browser can neither read nor tamper with the contents. That said, the encrypted blob itself is stored in the user's cookie and round-trips to the client. If you also put tokens in the cookie, explicitly operateSecure,HttpOnly,SameSite=Lax|Strict+ asecret_key_baserotation policy. If you need stricter control, switch to a server-side store likeActionDispatch::Session::CacheStore(Redis, etc.). - CSRF:
complete_signup_createis a regular POST, so Rails' CSRF token check applies automatically (protect_from_forgery). The Inertia client sends the CSRF header automatically. - Session fixation: the standard baseline is to call
reset_sessionatsign_inand then re-plant only the necessary keys. This reference calls an externally designedAuthentication#sign_in, so it doesn't callreset_sessionseparately — in your own RP, we recommend calling it explicitly at the login boundary. - Logging: log pending entry / completion / expiry all at INFO level, but mask the sub/email. (For example, with a
mask(value)helper that exposes only the first 6 and last 2 characters.) Outside of debugging, don't log raw values in production.
Adoption status across other RPs
| RP | Adoption status | Note |
|---|---|---|
| krx_listing | ✅ reference implementation (the model in this doc) | role + user_domain |
| ainote | ⏭ planned | role (free/premium) + after deciding the external signup policy |
| launchcrew | ⏭ planned | omniauth-based; must also collect terms consent |
| ax_admin | ⏭ planned | role + status; needs a decision on public signup vs. invite code |