권장 아키텍처 (RP 통합)
Widget SDK, Device Flow, Authorization Code Flow 공통 confidential client + PKCE 구성. RP 백엔드가 client_secret 과 code_verifier 를 보관합니다.
SPA 단독
백엔드 없이 public client (PKCE-only) 로도 가능. verifier 는 sessionStorage / Web Crypto. 트레이드오프는 Public vs Confidential.
흐름도
브라우저 (RP 페이지) RP 백엔드 1pass IdP
───────────────────── ─────────── ─────────
1) [로그인 버튼 클릭]
─POST /api/auth/1pass/challenge──▶
├─ generate verifier(32B)
├─ challenge = sha256(verifier)
├─ state = random(32B)
├─ session[state] = verifier
◀──{state, code_challenge}─
2) [Universal Link / Widget mount]
─→ authorize?response_type=code ──────────────────────────────▶
client_id, redirect_uri,
state, code_challenge,
code_challenge_method=S256
◀── redirect_uri?code=…&state=─
3) [브라우저 → /api/auth/1pass/callback]
─GET /api/auth/1pass/callback?code=…──▶
├─ verify state
├─ verifier = session.delete
├─ POST /oauth/token ──────────▶
│ grant_type=auth_code
│ client_id+client_secret
│ code, code_verifier
│ ◀──{access_token, id_token}──
├─ verify id_token (JWKS)
├─ map canonical_sub → 자기 user 모델
├─ Set-Cookie: session=…
◀── 302 → /dashboard
4) [/dashboard 진입]백엔드 엔드포인트 3개
1. POST /api/auth/1pass/challenge — PKCE 챌린지 발급
code_verifier 는 서버 세션에만 저장. 브라우저로 내려보내면 안 됨.
# Rails (app/controllers/api/auth/one_pass_controller.rb)
class Api::Auth::OnePassController < ApplicationController
protect_from_forgery with: :null_session
def challenge
state = SecureRandom.urlsafe_base64(32)
verifier = SecureRandom.urlsafe_base64(64).tr("=", "").first(128)
challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false)
session[:onepass_pkce] ||= {}
session[:onepass_pkce][state] = { verifier: verifier, created_at: Time.current.to_i }
# 최근 5개만 유지 (세션 비대 방지)
if session[:onepass_pkce].size > 5
session[:onepass_pkce] = session[:onepass_pkce]
.sort_by { |_, v| -v[:created_at] }.first(5).to_h
end
render json: { state: state, code_challenge: challenge }
end
end// Next.js App Router (app/api/auth/1pass/challenge/route.ts)
import { randomBytes, createHash } from "crypto";
export async function POST() {
const state = randomBytes(32).toString("base64url");
const verifier = randomBytes(64).toString("base64url").slice(0, 128);
const challenge = createHash("sha256").update(verifier).digest("base64url");
const session = await getOrCreateSession();
session.onepassPkce = { ...session.onepassPkce, [state]: { verifier, createdAt: Date.now() } };
await session.save();
return Response.json({ state, code_challenge: challenge });
}2. GET /api/auth/1pass/callback — 인증 코드 교환
state검증 (CSRF)- 세션에서
verifier꺼내고 삭제 (1회용) /oauth/token교환 (confidential client + PKCE)id_tokenJWKS 검증- 자체 세션 cookie 발급 + redirect
def callback
return error!("missing_params") if params[:code].blank? || params[:state].blank?
pkce = (session[:onepass_pkce] || {}).delete(params[:state])
return error!("invalid_state") unless pkce
return error!("verifier_expired") if Time.current.to_i - pkce[:created_at] > 600
tokens = OnePassClient.exchange_code(
code: params[:code],
redirect_uri: api_auth_one_pass_callback_url,
code_verifier: pkce[:verifier]
)
id_token_payload = OnePassClient.verify_id_token!(tokens["id_token"])
# `canonical_sub` 으로 사람을 식별 — 같은 사용자가 web/ios/android 등
# 여러 client_id 로 로그인해도 하나의 row 로 모입니다.
# (`sub` 은 client 별 pairwise hash 라 client 마다 다름 — 아래 경고 참고)
# canonical_sub 은 /oauth/userinfo 에서 내려옵니다 (id_token 에는 sub 만 있음).
userinfo = OnePassClient.userinfo(tokens["access_token"])
canonical_sub = userinfo["canonical_sub"]
user = User.find_or_create_by!(onepass_canonical_sub: canonical_sub) do |u|
u.email = userinfo["email"]
end
# 감사/추적용으로 client 별 pairwise sub 도 보관 가능 (식별 키로는 사용 X)
user.update(last_onepass_sub: id_token_payload["sub"])
start_session_for(user)
redirect_to params[:return_to].presence || dashboard_path
end멀티-client RP 는 canonical_sub 로 식별하세요
sub 은 RP(client)별 pairwise subject identifier 입니다 — user.id 가 아니라 client 별 솔트로 만든 opaque hash 라서 client_id 마다 값이 다릅니다. 같은 RP 가 web / ios / android 처럼 여러 client_id 를 운영하면, sub 을 외래키로 쓸 경우 한 사람이 client 수만큼 여러 row 로 쪼개집니다.
사람 단위 식별은 userinfo 의 canonical_sub(현재 살아있는 logi user.id) 으로 하세요. sub 은 단일 client·단일 grant 안에서만 안정적입니다.
- Sub 정책 —
sub/canonical_sub/linked_subs의 정확한 의미 - RP Migration Guide — 기존
sub외래키에서canonical_sub로 이전하는 절차
3. (선택) POST /api/auth/1pass/exchange — Widget SDK 전용
위젯은 postMessage 로 { code, state } 를 부모에 전달. 부모가 JSON body 로 POST → 토큰 교환. 본질은 callback 과 동일, 입력 위치만 다름.
INFO
2/3번을 한 컨트롤러로 묶을 수 있음. code 가 params → redirect, body → JSON 응답으로 분기.
세션 / 쿠키
| 항목 | 권장 값 | 이유 |
|---|---|---|
code_verifier 저장 | encrypted server session | 브라우저 노출 시 PKCE 무효 |
code_verifier TTL | 10 분 | 1pass code 만료 시간과 동일 (OauthAccessGrant::EXPIRY) |
state 검증 | secure_compare (timing-safe) | == 는 timing attack 노출 |
| 자체 세션 cookie | Secure; HttpOnly; SameSite=Lax | XSS / CSRF 기본 방어 |
| 위젯 사용 시 | SameSite=None; Secure | 3rd-party iframe cookie |
CSRF 토큰 vs OAuth state
분리할 것. CSRF 토큰을 state 로 재사용 금지 — OAuth flow 한 번에 CSRF 토큰이 노출됨.
- CSRF 토큰: 같은 RP 안의 form submission 보호
- OAuth state: redirect 왕복 보호용 nonce, 세션의 challenge 와 함께 묶임
scope 최소화
# ❌ 과다
scope = "openid profile:basic email phone birthdate"
# ✅ 권장
scope = "openid profile:basic email"profile:basic 에 name/picture 포함. 추가 claim 은 progressive profile 로. Scope 레퍼런스.
연결 해제(unlink) 가드 — 유일 신원 보호
"설정 > logi 연결 해제"를 제공한다면, 해제 후에도 사용자가 실제로 쓸 수 있는 로그인 수단이 남는지 검증한 뒤에만 허용하세요. sub↔계정 매핑은 그 계정을 logi 로 되찾을 수 있는 유일한 열쇠입니다 — 남는 수단 없이 지우면 계정 행과 데이터는 DB 에 그대로 남는데 도달할 방법만 사라진 고아 계정이 됩니다 (관리자 개입 없이는 복구 불가).
흔한 오판 세 가지:
| 오판 | 실제 | 가드 |
|---|---|---|
| "password digest 가 있다 = 비밀번호 로그인 가능" | SSO 가입 시 ORM(has_secure_password 류) 요구로 자동 생성된 랜덤 값일 수 있음 — 사용자는 그 값을 모름 | digest 유무가 아니라 "재설정 메일이 닿는 주소가 있는가" 로 판단 |
| "email 컬럼이 차 있다 = 복구 경로 있음" | placeholder 주소(*.invalid 등)는 수신 불가 (Email Claim) | placeholder suffix 는 복구 경로로 인정하지 않음 |
| "연결 기록은 한 곳에만 있다" | users 레거시 컬럼 + oauth 연결 테이블 이중 기록이 흔함 | 충돌 검사·연결·해제 모두 양쪽 을 본다 (한쪽만 보면 거짓 성공 / 반쪽 해제) |
def can_unlink_sso?(user)
other_sso = user.oauth_providers.where.not(provider: '1pass').exists?
usable_password = user.password_digest.present? &&
user.email.present? &&
!user.email.end_with?('.invalid') # 자체 placeholder 규칙에 맞춰 조정
other_sso || usable_password
end거부할 때는 막다른 안내 대신 완주 가능한 경로를 주세요 — "먼저 이메일·비밀번호를 설정하거나 다른 SSO 를 연결해 주세요". Follow 전략을 켜두었다면 placeholder 계정도 verified 실주소가 도착하는 즉시 자동 승격되므로 이 안내가 실제로 성립합니다.
Production 체크리스트
- [ ]
redirect_uri가 HTTPS (production), trailing slash 정확 - [ ] 연결 해제(unlink) UI 제공 시 유일 신원 가드 적용
- [ ]
client_secret환경변수 (git/번들 포함 X) - [ ]
code_verifier서버 세션 only (브라우저 DevTools 비노출) - [ ]
state검증 (secure_compare) + 만료 10 분 - [ ]
id_token검증: JWKS 서명 +iss == https://api.1pass.dev+aud == client_id+exp미래 - [ ] 위젯 사용 시
widget_origins에 정확한 origin (scheme + host + port) 등록 - [ ] CSP
frame-ancestors가 위젯 (embed.1pass.dev) 차단 안 함 - [ ] 토큰 교환 실패 / 만료 / state mismatch 별 에러 페이지
- [ ] 로그아웃 시 자체 세션 + (필요시)
/oauth/revoke - [ ] Webhook backchannel logout 처리