Rails 8
Source:
en/integrations/rails.md· Live: https://docs.1pass.dev/en/integrations/rails LLM-sanitized: internal links absolutized, VitePress containers → admonitions, line numbers in the Jump-to Index reference this rendered file (1-indexed).
📍 Jump-to Index
- L57-L106: ## SDK quick start (recommended)
- L107-L156: ## Controller
- L157-L171: ## JWT verification
- L172-L184: ## Scope
- L185-L204: ## Post-deploy verification
- L205-L208: ## Extra info on first login
- L209-L283: ## Linking logi to an existing account (account linking)
- L222-L268: ### Recommended: explicit link on top of a signed-in session
- L269-L283: ### Troubleshooting:
AlreadyLinkedError/ "Already linked to another account"
- L284-L432: ## canonical_sub (Account Merge)
- L288-L313: ### 1. LogiIdentityLink model
- L314-L339: ### 2. Canonical Resolution Concern
- L340-L365: ### 3. Webhook Receiver
- L366-L383: ### 4. Merge Reconciler
- L384-L403: ### 5. Polling Reconciler
- L404-L426: ### 6. Pundit canonical comparison
- L427-L432: ### 7. enforce_canonical_resolution flip
- L433-L583: ## Health check endpoint
- L437-L449: ### 1. Env variable setup
- L450-L456: ### 2. Route
- L457-L499: ### 3. Controller
- L500-L503: ### 4. Enable it in the console
- L504-L520: ### 5. Self-verification
- L521-L583: ### 6. RSpec
Rails 8
⚠️ Warning: Confidential client The server passes
client_secretto/oauth/token. When you register the RP, choose Client type: Confidential. For a Public/SPA client, see Public Clients.
⚠️ Warning: Email verification required before registering an app (RP) Before you can register an app (RP) in the developer console, you must verify your email. After signing up, click the link in the verification email, or resend it from the console at
/account. Registration is blocked until you verify. → App registration guide · Prerequisite
Required env: LOGI_API_URL, LOGI_CLIENT_ID, LOGI_CLIENT_SECRET. If they are missing, the app falls back to localhost:3000.
🚨 Danger: Hotwire / Turbo The "Sign in with 1pass" button must set
data-turbo="false". If Turbo Drive intercepts the cross-origin 302, it fails silently.
<%= button_to "Sign in with 1pass", logi_start_path, method: :get,
form: { data: { turbo: false } } %>
<%= link_to "Sign in with 1pass", logi_start_path, data: { turbo: false } %>The same applies to every client-side router — Inertia.js, HTMX, Next.js Link, and so on.
SDK quick start (recommended)
The official Ruby gem logi_auth builds the authorize URL and does the code exchange + id_token verification (RS256) in one call. For a confidential integration, the server verifying the audience (aud) is the SoT — the gem path below is recommended; the manual Controller and JWT verification that follow are a fallback.
# Gemfile
gem "logi_auth", "~> 1.0.1"# config/initializers/logi_auth.rb
LOGI = LogiAuth::Server.new(
client_id: ENV.fetch("LOGI_CLIENT_ID"),
client_secret: ENV.fetch("LOGI_CLIENT_SECRET"), # confidential — server env only
redirect_uri: ENV.fetch("LOGI_REDIRECT_URI")
# issuer defaults to https://api.1pass.dev · token_issuer defaults to "https://api.1pass.dev" · scopes default to %w[openid profile:basic email]
)# app/controllers/logi_sessions_controller.rb
class LogiSessionsController < ApplicationController
def start
state = SecureRandom.hex(16)
nonce = SecureRandom.hex(16)
session[:logi_state] = state
session[:logi_nonce] = nonce
redirect_to LOGI.authorization_url(state: state, nonce: nonce), allow_other_host: true
end
def callback
return head(:bad_request) unless params[:state] == session.delete(:logi_state)
# exchange_code_and_verify: token exchange + id_token verification (alg==RS256 → kid → JWKS → signature → iss("https://api.1pass.dev") → aud → azp(conditional on multi-aud) → exp → iat → nonce → sub → at_hash) in one call
result = LOGI.exchange_code_and_verify(
code: params[:code],
nonce: session.delete(:logi_nonce) # matched against id_token.nonce (required)
)
# result.sub — verified subject. See the sections below for first-login extras / canonical_sub
user = User.find_or_create_by!(logi_sub: result.sub) { |u| u.email = result.email }
session[:user_id] = user.id
redirect_to root_path
rescue LogiAuth::ServerError => e
redirect_to login_path, alert: "Login failed (#{e.code})"
end
endLogiAuth::Server/exchange_code_and_verify verifies the id_token in this order: alg (RS256) → kid → JWKS lookup → RS256 signature → iss (=="https://api.1pass.dev") → aud (must contain your client_id) → azp (only when aud is multi-valued; value=client_id) → exp (60s skew) → iat → nonce (if used) → sub → at_hash (last, present-only) (scope is the authorization axis and is not part of this sequence). azp is required only when aud is multi-valued (value=client_id) — the gem checks it conditionally. at_hash binding (v1.0.1): the contract is base64url_nopad(SHA256(access_token UTF-8 bytes)[first 16 bytes]) (OIDC §3.1.3.6). It is verified only when the id_token carries at_hash and an access_token is supplied (rejected with at_hash_mismatch); exchange_code_and_verify wires the access_token internally so the RP need not pass it. v1.0.1 selects the signing key with a kty=="RSA"+use/alg filter, robust to EC-key mix-in (JWKS). The PKCE (public) variant omits client_secret and uses authorization_url(state:, nonce:, code_challenge:) + exchange_code_and_verify(code:, nonce:, code_verifier:). Standalone verification is LogiAuth::IdTokenVerifier.verify(id_token, jwks:, expected:, access_token:). The returned Session has sub / email / id_token / access_token / refresh_token / expires_at / scope / claims. Errors are LogiAuth::ServerError (.code, .detail) and LogiAuth::IdTokenError (.code). For canonical_sub / account-merge patterns, see the section below.
Controller
ℹ️ Note: Manual integration (without the SDK) The Controller and JWT verification below implement the OAuth client by hand, without the gem — a fallback. For new integrations, prefer the SDK quick start above.
# app/controllers/sessions_controller.rb
class LogiSessionsController < ApplicationController
def start
verifier = SecureRandom.urlsafe_base64(32).delete("=")
challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false)
state = SecureRandom.hex(16)
session[:logi_pkce] = verifier
session[:logi_state] = state
redirect_to "#{ENV['LOGI_API_URL']}/oauth/authorize?" + {
client_id: ENV["LOGI_CLIENT_ID"],
redirect_uri: logi_callback_url,
response_type: "code",
scope: ENV.fetch("LOGI_SCOPES", "openid profile:basic email"),
state: state,
code_challenge: challenge,
code_challenge_method: "S256",
}.to_query, allow_other_host: true
end
def callback
return render_error("state mismatch") if params[:state] != session[:logi_state]
res = Net::HTTP.post_form(
URI("#{ENV['LOGI_API_URL']}/oauth/token"),
grant_type: "authorization_code",
code: params[:code],
redirect_uri: logi_callback_url,
code_verifier: session[:logi_pkce],
client_id: ENV["LOGI_CLIENT_ID"],
client_secret: ENV["LOGI_CLIENT_SECRET"]
)
tokens = JSON.parse(res.body)
session.delete(:logi_pkce); session.delete(:logi_state)
cookies.signed.permanent[:logi_rt] = {
value: tokens["refresh_token"], httponly: true, secure: Rails.env.production?, same_site: :strict
}
redirect_to root_path
end
endJWT verification
gem "jwt"
jwks = JSON.parse(Net::HTTP.get(URI("#{ENV['LOGI_API_URL']}/.well-known/jwks.json")))
payload, = JWT.decode(
access_token, nil, true,
algorithms: ["RS256"],
jwks: JWT::JWK::Set.new(jwks),
iss: "https://api.1pass.dev", verify_iss: true,
aud: ENV["LOGI_CLIENT_ID"], verify_aud: true
)Scope
scope: ENV.fetch("LOGI_SCOPES", "openid profile:basic email")Standard combinations:
- General SSO:
openid profile:basic email - ID Token only:
openid - Nickname only:
openid profile:basic
This must match the allowed_scopes of your logi app registration. For the full matrix, see the Scope reference.
Post-deploy verification
curl -sIL "https://yourapp.com/auth/logi/start" | grep -iE "^(HTTP|location)"Expected:
HTTP/2 302
location: https://api.1pass.dev/oauth/authorize?client_id=logi_xxx&...&code_challenge=...&state=...Check: 302 / location host = api.1pass.dev / client_id query / scope query.
CI smoke test:
curl -sI "https://yourapp.com/auth/logi/start" | grep -i "^location:" | grep -q "api.1pass.dev" \
&& echo "✓ logi env OK" || { echo "✗ LOGI_API_URL missing — check env"; exit 1; }Extra info on first login
logi sends only sub + email (+ nickname). Do not auto-User.create! with additional fields; show a short completion form on first login: First-login completion form.
Linking logi to an existing account (account linking)
This is the pattern for an RP that already has its own password-based signups (legacy accounts) and wants to attach logi as an additional login method. The approach is grounded in what the easy-bracket RP actually shipped.
🚨 Danger: Auto-bootstrapping by verified email is a takeover vector If your callback finds an existing password account by logi's
- An attacker signs up at the RP with an arbitrary email
victim@example.com(this succeeds if the RP skips email verification). - The real owner logs in via logi (the same, verified email).
- The callback matches by email and attaches the logi identity to the row the attacker created → the owner's logi login ends up in the attacker's account.
Therefore, do not auto-link to an existing account by email match alone while the user is not signed in. Create a new row keyed on canonical_sub for new users, or send them to the first-login completion form. For the risk of email sync itself, see the Email Claim policy.
Recommended: explicit link on top of a signed-in session
If the flow is an already-authenticated (signed-in) session where current_user clicks a "Link logi" button themselves, it is not a takeover — the session proves who is linking whom. At this point, also stamp canonical_sub (the currently live logi user.id) as the identifying key, so that logging in later from another platform (a different client_id → a different pairwise sub) still resolves to the same person via canonical.
Carry an intent marker (intent=link) into start so the callback can distinguish "new login" from "linking an existing account":
# Signed-in users only (e.g. before_action :authenticate_user!)
def start
# ... generate PKCE/state (same as the Controller example above) ...
session[:logi_intent] = params[:intent] # "link" | nil
# ... redirect_to .../oauth/authorize ...
end
def callback
# ... verify state + exchange token + obtain claims via JWT/userinfo ...
sub = claims["sub"]
canonical_sub = claims["canonical_sub"] || sub
if session.delete(:logi_intent) == "link"
link_logi_to_current_user!(sub, canonical_sub)
else
sign_in_with_logi(sub, canonical_sub) # new/existing logi login path
end
end
private
# Attach a logi identity to the already-signed-in current_user (not a takeover — it's their own session)
def link_logi_to_current_user!(sub, canonical_sub)
raise "no session" unless current_user # sign-in required — no link without login
# Reject if this logi identity (person-level = canonical_sub) is already bound to another local account
owner = User.find_by(logi_canonical_sub: canonical_sub)
if owner && owner.id != current_user.id
return redirect_to settings_path, alert: "This 1pass is already linked to another account."
end
current_user.update!(
logi_sub: sub, # this client's pairwise sub (kept for foreign-key continuity)
logi_canonical_sub: canonical_sub # person-level identifying key — the basis for cross-platform resolution
)
endAlways identify and look up by canonical_sub. On a multi-platform setup (web/iOS/Android), client_id differs so the pairwise sub differs per platform; keying on sub makes the second platform's login see the same person as a different identity — this is the main cause of the AlreadyLinkedError below. For the policy background, cross-link to Sub Policy.
Troubleshooting: AlreadyLinkedError / "Already linked to another account"
Symptom: you try to log in (or link) with logi but get rejected with "Already linked to another account." Two causes account for most cases.
| Cause | What happens | Fix |
|---|---|---|
Keyed on pairwise sub | The RP identifies the user by logi_sub = sub. When the same person logs in from another platform (client_id), a different pairwise sub arrives — "this differs from my account's sub, yet someone already is this person" → collision. | Switch the identifying key to canonical_sub. Keep logi_sub (pairwise) for foreign-key continuity, but do person-level lookup on logi_canonical_sub. Sub Policy · canonical_sub section |
| Side effect of rejecting password-account auto-bootstrap | On an RP that disabled login-less email-bootstrap to prevent takeover, the user logs in straight via logi without going through the link flow → no matching logi row exists → it attempts to create a new one → email/constraint collision with the existing password row. | Always guide users who already have an account to an explicit link after login (intent=link). New users get a new row keyed on canonical_sub. (See the Recommended section above.) |
Backtracking checklist:
- In the RP DB, do the two colliding rows have different
logi_subvalues yet represent the same person? → pairwise-sub problem. They must be unified undercanonical_sub. - Does the new-login path touch an existing row while the user is not signed in? → no link without login. Linking must happen only within a
current_usersession. - For two rows that have already split, unify them after the fact with the
LogiIdentityLink+ canonical resolver from canonical_sub (Account Merge).
canonical_sub (Account Merge)
Background: Account Merge, RP Migration Guide.
1. LogiIdentityLink model
# app/models/logi_identity_link.rb
class LogiIdentityLink < ApplicationRecord
validates :linked_user_id, uniqueness: true
validates :primary_user_id, :linked_user_id, :merged_via, presence: true
def self.canonical_for(sub)
find_by(linked_user_id: sub)&.primary_user_id || sub
end
endcreate_table :logi_identity_links do |t|
t.string :primary_user_id, null: false
t.string :linked_user_id, null: false
t.string :merged_via, null: false
t.datetime :occurred_at, null: false
t.timestamps
end
add_index :logi_identity_links, :linked_user_id, unique: true
add_index :logi_identity_links, :primary_user_id2. Canonical Resolution Concern
# app/controllers/concerns/logi_canonical_resolution.rb
module LogiCanonicalResolution
extend ActiveSupport::Concern
private
def resolve_logi_user(claims)
sub = claims["sub"]
canonical_sub = claims["canonical_sub"] || sub
if canonical_sub != sub && !LogiIdentityLink.exists?(linked_user_id: sub)
LogiIdentityLink.create_or_find_by!(linked_user_id: sub) do |link|
link.primary_user_id = canonical_sub
link.merged_via = "login_time_fallback"
link.occurred_at = Time.current
end
end
User.find_by(logi_sub: canonical_sub)
end
end3. Webhook Receiver
# app/controllers/webhooks/logi_controller.rb
class Webhooks::LogiController < ActionController::Base
skip_forgery_protection
def create
return head :unauthorized unless Logi::SignatureVerifier.valid?(request)
event = JSON.parse(request.raw_post)
return head :ok if LogiEvent.exists?(event_id: event["event_id"])
LogiEvent.create!(
event_id: event["event_id"],
event_type: event["event_type"],
payload: event,
received_at: Time.current
)
MergeReconciler.new(event).apply if event["event_type"] == "user.merged"
head :ok
end
end4. Merge Reconciler
# app/services/merge_reconciler.rb
class MergeReconciler
def initialize(event) = @event = event
def apply
data = @event.fetch("data")
LogiIdentityLink.create_or_find_by!(linked_user_id: data["merged_sub"]) do |link|
link.primary_user_id = data["survivor_canonical_sub"]
link.merged_via = data["merged_via"]
link.occurred_at = Time.parse(data["triggered_at"])
end
end
end5. Polling Reconciler
# app/jobs/logi_polling_reconciler_job.rb
class LogiPollingReconcilerJob < ApplicationJob
def perform
cursor = LogiState.last_event_id
loop do
resp = LogiClient.events(since: cursor, limit: 200)
ApplicationRecord.transaction do
resp[:events].each { |e| MergeReconciler.new(e).apply if e["event_type"] == "user.merged" }
LogiState.update!(last_event_id: resp[:next_cursor]) if resp[:next_cursor]
end
cursor = resp[:next_cursor]
break unless resp[:has_more]
end
end
end6. Pundit canonical comparison
class ApplicationPolicy
protected
def same_canonical?(other_user)
return false unless other_user
a = LogiIdentityLink.canonical_for(current_user.logi_sub)
b = LogiIdentityLink.canonical_for(other_user.logi_sub)
a == b
end
end
class TournamentPolicy < ApplicationPolicy
def show?
same_canonical?(record.owner)
end
endIn every policy, grep for direct record.user_id == current_user.id comparisons and replace them with same_canonical?.
7. enforce_canonical_resolution flip
Once you confirm the components above are active, notify the logi operator → enforce_canonical_resolution=true.
Troubleshooting: Troubleshooting.
Health check endpoint
This is the Rails handler for the RP active health check protocol. Every hour the 1pass IdP sends an HMAC-signed GET to /.well-known/logi-rp-health, and when the RP echoes its registered client_id, the console shows 🟢.
1. Env variable setup
Store the secret — revealed once in the console right after app registration — as an environment variable:
# .env (or Render env)
LOGI_CLIENT_ID=logi_xxxxxxxxxxxxxxxx
LOGI_RP_HEALTH_SECRET=<copy from the console>⚠️ Warning
LOGI_RP_HEALTH_SECRETis server-side env only. Never expose it in the client bundle.
2. Route
# config/routes.rb
get "/.well-known/logi-rp-health" => "logi_rp_health#show"3. Controller
# app/controllers/logi_rp_health_controller.rb
class LogiRpHealthController < ApplicationController
# Public probe — bypass auth middleware such as Devise.
skip_before_action :authenticate_user!, raise: false
skip_before_action :verify_authenticity_token, raise: false
MAX_SKEW = 300 # seconds
def show
timestamp = request.headers["X-Logi-Timestamp"].to_i
client_id = request.headers["X-Logi-Client-Id"].to_s
signature = request.headers["X-Logi-Signature"].to_s
return head :unauthorized if (Time.now.to_i - timestamp).abs > MAX_SKEW
return head :unauthorized if client_id != ENV["LOGI_CLIENT_ID"]
# HMAC-SHA256 hex is always 64 chars. On some Rails versions secure_compare
# raises ArgumentError on a length mismatch, which would fall through as a
# 500 — so add a defensive pre-check.
return head :unauthorized unless signature.match?(/\A[0-9a-f]{64}\z/i)
expected = OpenSSL::HMAC.hexdigest(
"SHA256",
ENV["LOGI_RP_HEALTH_SECRET"].to_s,
"#{timestamp}.#{client_id}"
)
return head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(expected, signature)
render json: {
status: "ok",
client_id: client_id,
timestamp: Time.current.iso8601,
sdk_version: "logi-rp-integrate/1.0"
}
end
end💡 Tip: Why
secure_compare? It defends against timing attacks.==returns early at the first differing byte, so the difference in response time can leak the secret one character at a time.
4. Enable it in the console
Developer Console → your app → check the RP active health check box → Save. When the secret is revealed on the next page, copy it into your env (for a grandfathered app, a new secret is issued at this step).
5. Self-verification
TS=$(date +%s)
CID=$LOGI_CLIENT_ID
SIG=$(printf "%s.%s" "$TS" "$CID" | openssl dgst -sha256 -hmac "$LOGI_RP_HEALTH_SECRET" -hex | awk '{print $2}')
curl -i \
-H "x-logi-timestamp: $TS" \
-H "x-logi-client-id: $CID" \
-H "x-logi-signature: $SIG" \
"$YOUR_HOST/.well-known/logi-rp-health"
# Expect: HTTP/2 200 + body.client_id == $CID
# Note: HTTP headers are case-insensitive — any case works when sending.Confirm that the 🟢 indicator appears on the console card within an hour.
6. RSpec
# spec/requests/logi_rp_health_spec.rb
require "rails_helper"
RSpec.describe "GET /.well-known/logi-rp-health" do
let(:secret) { "test_secret" }
let(:cid) { "logi_test" }
let(:ts) { Time.now.to_i }
before do
allow(ENV).to receive(:[]).and_call_original
allow(ENV).to receive(:[]).with("LOGI_CLIENT_ID").and_return(cid)
allow(ENV).to receive(:[]).with("LOGI_RP_HEALTH_SECRET").and_return(secret)
end
def sig(ts, cid, secret)
OpenSSL::HMAC.hexdigest("SHA256", secret, "#{ts}.#{cid}")
end
it "200 + echoes client_id on valid HMAC" do
get "/.well-known/logi-rp-health", headers: {
"X-Logi-Timestamp" => ts.to_s,
"X-Logi-Client-Id" => cid,
"X-Logi-Signature" => sig(ts, cid, secret)
}
expect(response).to have_http_status(:ok)
expect(response.parsed_body["client_id"]).to eq(cid)
end
it "rejects time drift > 5min" do
bad_ts = ts - 600
get "/.well-known/logi-rp-health", headers: {
"X-Logi-Timestamp" => bad_ts.to_s,
"X-Logi-Client-Id" => cid,
"X-Logi-Signature" => sig(bad_ts, cid, secret)
}
expect(response).to have_http_status(:unauthorized)
end
it "rejects tampered signature" do
get "/.well-known/logi-rp-health", headers: {
"X-Logi-Timestamp" => ts.to_s,
"X-Logi-Client-Id" => cid,
"X-Logi-Signature" => "0" * 64
}
expect(response).to have_http_status(:unauthorized)
end
it "rejects malformed (non-hex / short) signature with 401, not 500" do
%w[zz garbage short 0123].each do |bad|
get "/.well-known/logi-rp-health", headers: {
"X-Logi-Timestamp" => ts.to_s,
"X-Logi-Client-Id" => cid,
"X-Logi-Signature" => bad
}
expect(response).to have_http_status(:unauthorized), "expected 401 for #{bad.inspect}"
end
end
end