Skip to content

Rails 8

Source: integrations/rails.md · Live: https://docs.1pass.dev/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 빠른 시작 (권장)
  • L107-L156: ## Controller
  • L157-L171: ## JWT 검증
  • L172-L184: ## Scope
  • L185-L204: ## Post-deploy 검증
  • L205-L208: ## 첫 로그인 추가 정보
  • L209-L283: ## 기존 계정에 logi 연결 (account linking)
    • L222-L268: ### 권장: 로그인된 세션 위 명시적 link
    • L269-L283: ### Troubleshooting: AlreadyLinkedError / "이미 다른 계정과 연결되어 있습니다"
  • L284-L432: ## canonical_sub (Account Merge)
    • L288-L313: ### 1. LogiIdentityLink 모델
    • 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 비교
    • L427-L432: ### 7. enforce_canonical_resolution flip
  • L433-L582: ## Health check endpoint
    • L437-L449: ### 1. Env 변수 셋업
    • L450-L456: ### 2. 라우트
    • L457-L498: ### 3. 컨트롤러
    • L499-L502: ### 4. 콘솔에서 활성화
    • L503-L519: ### 5. 자가 검증
    • L520-L582: ### 6. RSpec

Rails 8

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

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

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

🚨 Danger: 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 라우터에 동일 적용.

SDK 빠른 시작 (권장)

공식 Ruby gem logi_auth 가 authorize URL 생성 + code 교환 + id_token 검증(RS256) 을 한 번에 처리합니다. confidential 통합에서 서버가 audience(aud) 를 검증하는 것이 SoT — 아래 gem 경로가 권장이고, 이어지는 수동 Controller·JWT 검증은 fallback 입니다.

ruby
# Gemfile
gem "logi_auth", "~> 1.0.1"
ruby
# config/initializers/logi_auth.rb
LOGI = LogiAuth::Server.new(
  client_id:     ENV.fetch("LOGI_CLIENT_ID"),
  client_secret: ENV.fetch("LOGI_CLIENT_SECRET"),   # confidential — 서버 env 전용
  redirect_uri:  ENV.fetch("LOGI_REDIRECT_URI")
  # issuer 기본값 https://api.1pass.dev · token_issuer 기본값 "https://api.1pass.dev" · scopes 기본값 %w[openid profile:basic email]
)
ruby
# 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: code 교환 + id_token 검증(alg==RS256 → kid → JWKS → 서명 → iss("https://api.1pass.dev") → aud → azp(멀티 aud 조건부) → exp → iat → nonce → sub → at_hash)까지 한 번에
    result = LOGI.exchange_code_and_verify(
      code:  params[:code],
      nonce: session.delete(:logi_nonce)     # id_token.nonce 대조 (필수)
    )
    # result.sub — 검증 완료된 subject. 첫 로그인 추가정보/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: "로그인 실패 (#{e.code})"
  end
end

LogiAuth::Server/exchange_code_and_verify 는 id_token 을 alg(RS256) → kid → JWKS 조회 → RS256 서명검증 → iss(=="https://api.1pass.dev") → aud(내 client_id 포함) → azp(멀티 aud 일 때만, 값=client_id) → exp(skew 60s) → iatnonce(사용 시) → subat_hash(마지막, present-only) 순서로 검증합니다 (scope 는 인가 축이라 검증 순서에 미포함). azp멀티 aud 일 때만 필수(값=client_id) — gem 이 조건부로 검증합니다. at_hash 바인딩 (v1.0.1): 계약은 base64url_nopad(SHA256(access_token 의 UTF-8 바이트)[처음 16바이트])(OIDC §3.1.3.6). id_token 에 at_hash 가 있고 access_token 이 제공될 때만 검증하며(불일치 시 at_hash_mismatch), exchange_code_and_verify 는 access_token 을 내부적으로 배선하므로 RP 가 별도로 넘길 필요가 없습니다. v1.0.1 은 JWKS kty=="RSA"+use/alg 필터로 EC 키 혼입에 견고합니다(JWKS). PKCE(public) 변형은 client_secret 생략 + authorization_url(state:, nonce:, code_challenge:) + exchange_code_and_verify(code:, nonce:, code_verifier:). 스탠드얼론 검증은 LogiAuth::IdTokenVerifier.verify(id_token, jwks:, expected:, access_token:). 반환 Session: sub / email / id_token / access_token / refresh_token / expires_at / scope / claims. 에러는 LogiAuth::ServerError(.code, .detailLogiAuth::IdTokenError(.code). canonical_sub·account merge 패턴은 아래 절 참고.

Controller

ℹ️ Note: 수동 통합 (SDK 미사용) 아래 Controller·JWT 검증은 gem 없이 직접 OAuth 클라이언트를 구현하는 fallback 입니다. 신규 통합은 위 SDK 빠른 시작 을 권장합니다.

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: "https://api.1pass.dev", 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! 금지, 첫 로그인 미니 폼: 첫 로그인 완료 폼.

기존 계정에 logi 연결 (account linking)

이미 자체 비밀번호 가입(legacy account)이 있는 RP 가 logi 를 추가 로그인 수단으로 붙일 때의 패턴입니다. easy-bracket RP 가 실제 적용한 방식이 근거입니다.

🚨 Danger: verified-email 자동 bootstrap 은 takeover 벡터 콜백에서 logi email 만으로 기존 비번 계정을 찾아 자동 연결(email bootstrap)하면 이메일 검증이 없는 RP 에서 계정 탈취가 됩니다:

  1. 공격자가 RP 에 임의 이메일 victim@example.com 으로 비번 가입 (RP 가 이메일 검증을 안 하면 성공).
  2. 진짜 주인이 logi(검증된 동일 이메일)로 로그인.
  3. 콜백이 email 매칭으로 공격자가 만든 row 에 logi 정체성을 붙임 → 주인의 logi 로그인이 공격자 계정으로 들어감.

따라서 로그인되지 않은 상태에서 email 매칭만으로 기존 계정에 자동 link 하지 마세요. 신규 사용자는 canonical_sub 로 새 row 를 만들거나 첫 로그인 폼으로 보냅니다. email 동기화 자체의 위험은 Email Claim 정책 참고.

이미 본인 인증을 마친(로그인된) 세션의 current_user 가 직접 "logi 연결" 버튼을 누르는 흐름이면 takeover 가 아닙니다 — 누가 누구를 연결하는지가 세션으로 증명되기 때문입니다. 이때 식별 키로 canonical_sub(현재 살아있는 logi user.id)도 함께 stamp 해서, 이후 다른 플랫폼(다른 client_id → 다른 pairwise sub)에서 로그인해도 canonical 로 같은 사람으로 resolve 되게 합니다.

start 에 의도 표식(intent=link)을 실어 콜백이 "신규 로그인" 과 "기존 계정 연결" 을 구분하게 합니다:

ruby
# 로그인된 사용자만 진입 (before_action :authenticate_user! 등)
def start
  # ... PKCE/state 생성 (위 Controller 예시와 동일) ...
  session[:logi_intent] = params[:intent] # "link" | nil
  # ... redirect_to .../oauth/authorize ...
end

def callback
  # ... state 검증 + 토큰 교환 + JWT/userinfo 로 claims 확보 ...
  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) # 신규/기존 logi 로그인 경로
  end
end

private

# 이미 로그인된 current_user 에 logi 정체성을 붙인다 (takeover 아님 — 본인 세션)
def link_logi_to_current_user!(sub, canonical_sub)
  raise "no session" unless current_user # 로그인 필수 — 무로그인 link 금지

  # 이 logi 정체성(사람 단위 = canonical_sub)이 다른 로컬 계정에 이미 묶여 있으면 거부
  owner = User.find_by(logi_canonical_sub: canonical_sub)
  if owner && owner.id != current_user.id
    return redirect_to settings_path, alert: "이미 다른 계정과 연결된 1pass 입니다."
  end

  current_user.update!(
    logi_sub:           sub,           # 이 client 의 pairwise sub (외래키 보존용)
    logi_canonical_sub: canonical_sub  # 사람 단위 식별 키 — 크로스플랫폼 resolve 의 기준
  )
end

식별/조회는 항상 canonical_sub 기준입니다. 멀티플랫폼(web/iOS/Android)은 client_id 가 갈려 pairwise sub 이 플랫폼마다 다르므로, sub 으로 키잉하면 두 번째 플랫폼 로그인 때 같은 사람을 다른 정체성으로 보게 됩니다 — 이것이 아래 AlreadyLinkedError 의 주원인입니다. 정책 배경은 Sub 정책으로 cross-link.

Troubleshooting: AlreadyLinkedError / "이미 다른 계정과 연결되어 있습니다"

증상: logi 로 로그인(또는 연결)하려는데 "이미 다른 계정과 연결되어 있습니다" 로 거부됨. 두 가지 원인이 대부분입니다.

원인무슨 일이 벌어지나해결
pairwise sub 로 키잉RP 가 logi_sub = sub 로 사용자를 식별. 같은 사람이 다른 플랫폼(client_id)에서 로그인하면 다른 pairwise sub 이 와서 "내 계정의 sub 과 다른데 이미 누가 이 사람이네" → 충돌.식별 키를 canonical_sub 로 전환. logi_sub(pairwise)은 외래키 보존용으로 두되, 사람 단위 lookup 은 logi_canonical_sub 로. Sub 정책 · canonical_sub 섹션
비번 계정 자동 bootstrap 거부의 부작용takeover 방지를 위해 무로그인 email-bootstrap 을 끈 RP 에서, 사용자가 link 플로우를 거치지 않고 곧장 logi 로그인 → 매칭되는 logi row 가 없어 신규 생성 시도 → 기존 비번 row 와 email/제약 충돌.기존 계정 보유자는 반드시 로그인 후 명시적 link(intent=link)로 안내. 신규는 canonical_sub 로 새 row. (위 권장 섹션)

역추적 체크리스트:

  1. RP DB 에서 충돌난 두 row 의 logi_sub서로 다른데 같은 사람인가? → pairwise sub 문제. canonical_sub 로 묶어야 함.
  2. 신규 로그인 경로가 로그인되지 않은 상태에서 기존 row 를 건드리는가? → 무로그인 link 금지. link 는 current_user 세션에서만.
  3. 이미 갈라진 두 row 는 canonical_sub (Account Merge)LogiIdentityLink + canonical resolver 로 사후 통합.

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=<콘솔에서 복사>

⚠️ WarningLOGI_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

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