Skip to content

Rails 8

Confidential client

client_secret 을 서버에서 /oauth/token 에 전달합니다. RP 등록 시 Client type: Confidential. Public/SPA 면 Public Clients.

앱(RP) 등록 전 이메일 인증 필요

개발자 콘솔에서 앱(RP)을 등록하려면 먼저 이메일 인증이 필요합니다. 가입 후 인증 메일의 링크를 클릭하거나 콘솔 /account 에서 재발송하세요. 미인증 시 등록이 차단됩니다. → 앱 등록 가이드 · 사전 요건

필수 env: LOGI_API_URL, LOGI_CLIENT_ID, LOGI_CLIENT_SECRET. 누락 시 localhost:3000 fallback.

Hotwire / Turbo

"1pass 로그인" 버튼은 반드시 data-turbo="false". Turbo Drive 가 cross-origin 302 를 가로채면 silently fail.

erb
<%= button_to "1pass 로 로그인", logi_start_path, method: :get,
      form: { data: { turbo: false } } %>
<!-- 또는 -->
<%= link_to "1pass 로 로그인", logi_start_path, data: { turbo: false } %>

Inertia.js / HTMX / Next.js Link 등 모든 client-side 라우터에 동일 적용.

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 검증

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")

표준 조합:

  • 일반 SSO: openid profile:basic email
  • ID Token 만: openid
  • 닉네임만: openid profile:basic

logi 앱 등록의 allowed_scopes 와 일치해야 함. 매트릭스: Scope 레퍼런스.

Post-deploy 검증

bash
curl -sIL "https://yourapp.com/auth/logi/start" | grep -iE "^(HTTP|location)"

기대:

HTTP/2 302
location: https://api.1pass.dev/oauth/authorize?client_id=logi_xxx&...&code_challenge=...&state=...

체크: 302 / location host=api.1pass.dev / client_id 쿼리 / scope 쿼리.

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 누락 — env 점검"; exit 1; }

첫 로그인 추가 정보

logi 는 sub + email (+ nickname) 만 전달. 추가 필드는 자동 User.create! 금지, 첫 로그인 미니 폼: 첫 로그인 완료 폼.

canonical_sub (Account Merge)

배경: 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 비교

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

모든 policy 의 record.user_id == current_user.id 직접 비교를 grep → same_canonical? 로 교체.

7. enforce_canonical_resolution flip

위 컴포넌트 active 확인 후 logi 운영자에게 통보 → enforce_canonical_resolution=true.

트러블슈팅: Troubleshooting.

Health check endpoint

RP 활성 헬스 체크 프로토콜의 Rails 측 핸들러입니다. 매시간 1pass IdP 가 /.well-known/logi-rp-health 로 HMAC 서명된 GET 을 보내고, RP 가 자신의 등록 client_id 를 echo 하면 콘솔에 🟢 으로 표시됩니다.

1. Env 변수 셋업

앱 등록 직후 콘솔에서 1회 노출되는 시크릿을 환경 변수로 저장합니다:

bash
# .env (또는 Render env)
LOGI_CLIENT_ID=logi_xxxxxxxxxxxxxxxx
LOGI_RP_HEALTH_SECRET=<콘솔에서 복사>

WARNING

LOGI_RP_HEALTH_SECRET서버 측 env 전용. 클라이언트 번들에 절대 노출 금지.

2. 라우트

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

3. 컨트롤러

ruby
# app/controllers/logi_rp_health_controller.rb
class LogiRpHealthController < ApplicationController
  # Public probe — 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 는 항상 64자. secure_compare 가 길이 다르면 일부 Rails
    # 버전에서 ArgumentError 를 발생시켜 500 으로 떨어질 수 있음 — 방어적 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

secure_compare?

타이밍 공격 방어. == 는 첫 다른 바이트에서 일찍 리턴해 응답 시간 차이가 시크릿을 한 글자씩 누설할 수 있습니다.

4. 콘솔에서 활성화

개발자 콘솔 → 앱 → RP active health check 체크박스 ON → 저장. 다음 페이지에서 시크릿이 노출되면 env 에 복사합니다 (grandfathered 앱이라면 이 단계에서 신규 발급).

5. 자가 검증

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"
# 기대: HTTP/2 200 + body.client_id == $CID
# 참고: HTTP 헤더는 case-insensitive — 발송 시 어느 케이스든 동작.

1시간 이내 콘솔 카드에 🟢 표시되는지 확인.

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가 제품의 신뢰를 만듭니다.