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.
<%= 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 입니다.
# Gemfile
gem "logi_auth", "~> 1.0.1"# 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]
)# 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
endLogiAuth::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) → iat → nonce(사용 시) → sub → at_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, .detail)·LogiAuth::IdTokenError(.code). canonical_sub·account merge 패턴은 아래 절 참고.
Controller
ℹ️ Note: 수동 통합 (SDK 미사용) 아래 Controller·JWT 검증은 gem 없이 직접 OAuth 클라이언트를 구현하는 fallback 입니다. 신규 통합은 위 SDK 빠른 시작 을 권장합니다.
# 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: "https://api.1pass.dev", 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! 금지, 첫 로그인 미니 폼: 첫 로그인 완료 폼.
기존 계정에 logi 연결 (account linking)
이미 자체 비밀번호 가입(legacy account)이 있는 RP 가 logi 를 추가 로그인 수단으로 붙일 때의 패턴입니다. easy-bracket RP 가 실제 적용한 방식이 근거입니다.
🚨 Danger: verified-email 자동 bootstrap 은 takeover 벡터 콜백에서 logi
- 공격자가 RP 에 임의 이메일
victim@example.com으로 비번 가입 (RP 가 이메일 검증을 안 하면 성공). - 진짜 주인이 logi(검증된 동일 이메일)로 로그인.
- 콜백이 email 매칭으로 공격자가 만든 row 에 logi 정체성을 붙임 → 주인의 logi 로그인이 공격자 계정으로 들어감.
따라서 로그인되지 않은 상태에서 email 매칭만으로 기존 계정에 자동 link 하지 마세요. 신규 사용자는 canonical_sub 로 새 row 를 만들거나 첫 로그인 폼으로 보냅니다. email 동기화 자체의 위험은 Email Claim 정책 참고.
권장: 로그인된 세션 위 명시적 link
이미 본인 인증을 마친(로그인된) 세션의 current_user 가 직접 "logi 연결" 버튼을 누르는 흐름이면 takeover 가 아닙니다 — 누가 누구를 연결하는지가 세션으로 증명되기 때문입니다. 이때 식별 키로 canonical_sub(현재 살아있는 logi user.id)도 함께 stamp 해서, 이후 다른 플랫폼(다른 client_id → 다른 pairwise sub)에서 로그인해도 canonical 로 같은 사람으로 resolve 되게 합니다.
start 에 의도 표식(intent=link)을 실어 콜백이 "신규 로그인" 과 "기존 계정 연결" 을 구분하게 합니다:
# 로그인된 사용자만 진입 (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. (위 권장 섹션) |
역추적 체크리스트:
- RP DB 에서 충돌난 두 row 의
logi_sub가 서로 다른데 같은 사람인가? → pairwise sub 문제.canonical_sub로 묶어야 함. - 신규 로그인 경로가 로그인되지 않은 상태에서 기존 row 를 건드리는가? → 무로그인 link 금지. link 는
current_user세션에서만. - 이미 갈라진 두 row 는 canonical_sub (Account Merge) 의
LogiIdentityLink+ canonical resolver 로 사후 통합.
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💡 Tip: 왜
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