테마
권장 아키텍처 (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 sub → 자기 user 모델
├─ Set-Cookie: session=…
◀── 302 → /dashboard
4) [/dashboard 진입]백엔드 엔드포인트 3개
1. POST /api/auth/1pass/challenge — PKCE 챌린지 발급
code_verifier 는 서버 세션에만 저장. 브라우저로 내려보내면 안 됨.
ruby
# 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
endts
// 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
ruby
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"])
user = User.find_or_create_by!(onepass_sub: id_token_payload["sub"]) do |u|
u.email = id_token_payload["email"]
end
start_session_for(user)
redirect_to params[:return_to].presence || dashboard_path
end3. (선택) 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 최소화
ruby
# ❌ 과다
scope = "openid profile:basic email phone birthdate"
# ✅ 권장
scope = "openid profile:basic email"profile:basic 에 name/picture 포함. 추가 claim 은 progressive profile 로. Scope 레퍼런스.
Production 체크리스트
- [ ]
redirect_uri가 HTTPS (production), trailing slash 정확 - [ ]
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 처리