Skip to content

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.

erb
<%= 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

ruby
# 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
end

JWT verification

ruby
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

ruby
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

bash
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:

bash
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.

ruby
# 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
end
ruby
create_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_id

2. Canonical Resolution Concern

ruby
# 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
end

3. Webhook Receiver

ruby
# 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
end

4. Merge Reconciler

ruby
# 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
end

5. Polling Reconciler

ruby
# 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
end

6. Pundit canonical comparison

ruby
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
end

In 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:

bash
# .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

ruby
# config/routes.rb
get "/.well-known/logi-rp-health" => "logi_rp_health#show"

3. Controller

ruby
# 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

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

bash
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

ruby
# 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

Identity가 제품의 신뢰를 만듭니다.