First-Login Completion Form (First-time Signup)
Source:
en/integrations/first-time-signup.md· Live: https://docs.1pass.dev/en/integrations/first-time-signup LLM-sanitized: internal links absolutized, VitePress containers → admonitions, line numbers in the Jump-to Index reference this rendered file (1-indexed).
📍 Jump-to Index
- L26-L64: ## Recommended flow
- L65-L74: ## Core principles
- L75-L87: ## Implementation checklist (when integrating an RP)
- L88-L229: ## Rails 8 example (excerpt from krx_listing)
- L230-L236: ## Security notes
- L237-L245: ## Adoption status across other RPs
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.
⚠️ Warning: 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 arole: :reviewerdefault, 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 |