Rails 8
Confidential client
The server passes client_secret to /oauth/token. When you register the RP, choose Client type: Confidential. For a Public/SPA client, see Public Clients.
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.
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 } } %>
<!-- or -->
<%= 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.
Controller
# 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: "logi", 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.
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_SECRET is 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
endWhy 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