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.
<%= 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
# 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 검증
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")표준 조합:
- 일반 SSO:
openid profile:basic email - ID Token 만:
openid - 닉네임만:
openid profile:basic
logi 앱 등록의 allowed_scopes 와 일치해야 함. 매트릭스: Scope 레퍼런스.
Post-deploy 검증
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:
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.
1. LogiIdentityLink 모델
# 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 비교
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회 노출되는 시크릿을 환경 변수로 저장합니다:
# .env (또는 Render env)
LOGI_CLIENT_ID=logi_xxxxxxxxxxxxxxxx
LOGI_RP_HEALTH_SECRET=<콘솔에서 복사>WARNING
LOGI_RP_HEALTH_SECRET 은 서버 측 env 전용. 클라이언트 번들에 절대 노출 금지.
2. 라우트
# config/routes.rb
get "/.well-known/logi-rp-health" => "logi_rp_health#show"3. 컨트롤러
# 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. 자가 검증
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
# 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