Back-Channel Logout (RP 통합)
Source:
oauth/back-channel-logout.md· Live: https://docs.1pass.dev/oauth/back-channel-logout LLM-sanitized: internal links absolutized, VitePress containers → admonitions, line numbers in the Jump-to Index reference this rendered file (1-indexed).
📍 Jump-to Index
- L31-L61: ## 1. 엔드포인트
- L62-L126: ## 2. logout_token 검증 레시피
- L127-L150: ## 3. 🔴 어떤 클레임으로 사용자를 매칭할 것인가 —
subvscanonical_sub - L151-L168: ## 4. jti replay 방지 — 폐기 성공 후 마킹
- L169-L188: ## 5. 🔴 WAF 함정 — 콜백 경로를 bot-UA / IP 차단에서 제외하라
- L189-L213: ## 6. logi 등록 + 어떤 이벤트가 BCL 을 발사하는가
- L214-L238: ## 7. 설정 계약 (cross-service)
- L239-L264: ## 8. 검증 (E2E) — 실제 POST 를 쏴라
- L265-L273: ## 9. 레퍼런스 구현
Back-Channel Logout (RP 통합)
사용자가 logi(IdP)에서 푸시 승인을 취소하거나, 계정을 삭제하거나, 연결된 앱을 해제하면 — logi 는 그 RP 의 서버 세션까지 OIDC Back-Channel Logout 1.0 으로 즉시 강제 종료합니다. RP 가 이 신호를 받으려면 서버에 콜백 엔드포인트 하나를 구현해 등록하면 됩니다.
이 문서는 RP(Relying Party) 측에서 수신부를 구현하는 방법을 다룹니다. logi 발행부(producer)는 이미 완성돼 있으므로 RP 코드만 추가하면 됩니다.
ℹ️ Note: 언제 필요한가 계정 탈취/피싱 후 "되돌리기"가 실제로 동작하려면 access token 폐기만으로는 부족합니다 — RP 가 자체 세션 쿠키 / API 토큰 / 디바이스 토큰을 들고 있으면 그 세션은 logi 와 무관하게 살아있습니다. Back-Channel Logout 이 그 마지막 조각(RP 세션 강제 종료)을 닫습니다.
1. 엔드포인트
RP 는 다음 형태의 엔드포인트 하나를 노출합니다.
POST {rp_origin}/<your-path>/backchannel-logout
Content-Type: application/x-www-form-urlencoded
logout_token=<RS256 JWT>- 서버-서버(S2S) 호출 — 브라우저가 아니라 logi 서버가 직접 POST 합니다. 세션 쿠키·CSRF 토큰·로그인 상태가 없습니다.
- 무인증 — 인증 헤더가 없습니다. 유일한 게이트는
logout_token의 RS256 서명입니다. 따라서 검증을 빈틈없이 해야 합니다(§2). - 본문은 form-urlencoded 의
logout_token한 개. JSON 아님. - 경로는 RP 가 정합니다. 레퍼런스 구현:
- ainote:
POST https://app.ainote.dev/auth/1pass/backchannel-logout - easybracket:
POST https://easybracket.org/api/v1/webhooks/logi/backchannel_logout
- ainote:
응답 규약(OIDC BCL §2.8):
| 상황 | 응답 |
|---|---|
| 검증 성공 + 세션 폐기 | 200, 빈 본문, Cache-Control: no-store |
| 검증 성공 + 해당 사용자 없음(unknown sub) | 200 (no-op) — enumeration 방지 |
| jti replay(이미 처리됨) | 200 (no-op, DB write 없음) |
| 검증 실패(서명/iss/aud/events/alg/nonce) | 400 |
unknown sub 를 404 로 주면 공격자가 "어느 sub 가 이 RP 에 존재하는지" 떠볼 수 있습니다 → 항상 200.
2. logout_token 검증 레시피
logout_token 은 logi 가 RS256 으로 서명한 JWT 입니다. 다음을 전부 통과해야 신뢰합니다.
header { alg: "RS256", kid: "<key id>", typ: "logout+jwt" }
payload {
iss: "https://api.1pass.dev", # logi OIDC_ISSUER
aud: "<your client_id>", # string 또는 array
iat: 1750000000,
jti: "<uuid>", # replay 방지 키
sub: "<pairwise sub>", # 표준 클레임 (§3)
canonical_sub: "<canonical id>", # logi 비표준 추가 클레임 (§3)
events: { "http://schemas.openid.net/event/backchannel-logout": {} }
}검증 순서:
- alg 하드핀 —
algorithms: ["RS256"]만 허용.alg: none/HS256혼동 공격을 구조적으로 차단합니다. JWT 라이브러리의 기본 alg 추론에 절대 의존하지 마세요. - JWKS 키 조회 —
kid로{issuer}/.well-known/jwks.json에서 공개키를 가져와 서명 검증.- JWKS 는 캐시(예: 1시간)합니다.
jku(헤더의 키 URL)는 무시 — issuer 도메인 고정 URL 만 사용(키 출처 스푸핑 방지). - unknown
kid가 와도 JWKS 강제 refetch 금지. 캐시에 없으면 그냥 검증 실패(400). 공격자가 임의 kid 로 refetch 루프를 돌려 DoS 하는 것을 막습니다. (키 회전은 캐시 TTL 안에서 자연 수렴.)
- JWKS 는 캐시(예: 1시간)합니다.
- iss —
verify_iss: true, 기대값 = RP 의 issuer env(§7). logiOIDC_ISSUER와 byte-identical 이어야 합니다. - aud —
verify_aud: true, 기대값 = RP 의 client_id.aud는 string 또는 array 일 수 있으니 "포함 검사"로(대부분의 JWT 라이브러리가 자동 처리). client_id 가 여러 개(web/iOS/Android)면 allowlist 로(§7). - iat —
verify_iat: true+ max-age 검사(예: 10분 초과한 토큰 거부). 오래된 토큰 재사용 차단. - typ — 헤더
typ == "logout+jwt"확인. - events —
events가 object 이고http://schemas.openid.net/event/backchannel-logout키를 포함하는지 확인. (이게 "이건 로그아웃 토큰이다"의 표식) - nonce 거부 —
nonce클레임이 있으면 거부(BCL §2.6: logout_token 에 nonce 가 있으면 안 됨 — id_token 으로 오인된 토큰 차단). - 본문 크기 상한 —
logout_token길이에 상한(예: 8KB)을 둬서 거대 입력 거부.
⚠️ Warning: sub-only 로그아웃 logi 의 logout_token 은
sid(특정 세션 식별자) 없이sub만 담습니다. 즉 "이 사용자의 모든 세션을 종료"하는 의미입니다. post-revoke / 계정삭제 맥락에서는 이게 의도된 안전 동작입니다. RP 가sid기반 단일 세션 로그아웃을 원하면 별도 협의가 필요합니다(현재 미지원).
Ruby 참고 구현(ainote OnePassSsoService#verify_logout_token):
def verify_logout_token(logout_token)
raise SsoError, "logout_token required" if logout_token.blank?
ensure_https_issuer!
jwks = fetch_jwks # {issuer}/.well-known/jwks.json, 1h 캐시, jku 무시
claims, header = JWT.decode(
logout_token, nil, true,
algorithms: %w[RS256], # alg 하드핀
iss: issuer, verify_iss: true, # iss == ONE_PASS_URL
aud: client_id, verify_aud: true, # aud 포함 검사
verify_iat: true,
required_claims: %w[iss aud iat jti sub events],
leeway: 60,
jwks: jwks
)
raise SsoError, "wrong typ" unless header["typ"] == "logout+jwt"
raise SsoError, "nonce forbidden" if claims.key?("nonce")
ev = claims["events"]
unless ev.is_a?(Hash) && ev["http://schemas.openid.net/event/backchannel-logout"].is_a?(Hash)
raise SsoError, "events malformed"
end
{ sub: claims["sub"], jti: claims["jti"] } # canonical_sub RP 는 canonical_sub 반환
rescue JWT::DecodeError, JWT::VerificationError, JWT::ExpiredSignature => e
raise SsoError, "logout_token verification failed: #{e.class} #{e.message}"
end3. 🔴 어떤 클레임으로 사용자를 매칭할 것인가 — sub vs canonical_sub
이게 RP 통합에서 가장 중요한 결정입니다. logout_token 에는 두 개의 식별 클레임이 있습니다.
| 클레임 | 값 | 성격 |
|---|---|---|
sub | pairwise — RP(client)마다 다른 불투명 값 | OIDC 표준. id_token / userinfo 의 sub 와 동일 |
canonical_sub | logi 의 canonical user id — 모든 client 공통, 계정 병합 시 survivor 로 수렴 | logi 비표준 추가 클레임 |
RP 가 로그인 때 저장한 식별 키와 같은 것으로 매칭해야 합니다.
💡 Tip: 결정 가이드
- 로그인 때
sub(pairwise)를 저장했다면 →sub로 매칭. (ainote 방식:OauthProvider.uid = sub) - 로그인 때 canonical 식별자(전 client 공통)를 저장했다면 →
canonical_sub로 매칭. (easybracket 방식:User.logi_user_sub = canonical_sub)
둘을 섞으면 절대 매칭되지 않아 세션이 안 죽습니다. pairwise sub 는 client 마다 다르므로 canonical 저장소에서 조회하면 항상 miss 입니다.
logi userinfo 의 식별 클레임 정책은 Sub 정책 을 참조하세요. canonical_sub 를 쓰는 RP 라면 로그인(userinfo)과 logout(logout_token) 양쪽에서 같은 canonical_sub 값이 오는지 확인하세요 — logi 는 둘 다 user.canonical_id.to_s 로 동일하게 채웁니다.
🚨 Danger: canonical RP 의 구조적 안전장치 canonical_sub 로 매칭하는 RP 의 검증 함수는
canonical_sub만 반환하고 표준sub(pairwise)는 절대 반환/사용하지 마세요. 호출자가 실수로 pairwise sub 로 조회하는 사고를 코드 레벨에서 차단합니다. "pairwise sub 는 있는데 canonical_sub 가 없는" 토큰은 매칭 실패(no-op 200)로 떨어지는 회귀 테스트를 두세요.
4. jti replay 방지 — 폐기 성공 후 마킹
logout_token 의 jti 를 짧은 TTL 캐시(예: 10분 = iat max-age 와 동일)에 기록해 재처리를 막습니다. 핵심은 순서입니다.
1. verify_logout_token → sub/canonical_sub, jti
2. jti 가 이미 캐시에 있으면 → 200 (no-op, DB write 없음)
3. force_logout!(user) → 세션 폐기 (성공해야 함)
4. ✅ 성공한 뒤에 jti 캐시 기록 ← 반드시 "성공 후"🚨 Danger: jti 를 폐기 전에 마킹하면 안 됨 검증 통과 직후(폐기 전) jti 를 마킹하면, 폐기 단계에서 DB 오류가 나도 jti 는 이미 "처리됨"으로 남습니다. 그러면 logi 의 재시도가 멱등 가드에 삼켜져 실제 세션이 영영 안 죽습니다. 반드시
force_logout!성공 후에 마킹하세요. (ainote 6-lens + codex 리뷰가 고신뢰로 수렴한 수정.)
전용 캐시 키를 쓰고(예: logi_bcl_jti:<jti>), 다른 webhook/event 의 dedupe 키와 혼용하지 마세요(스키마 의미가 다름).
5. 🔴 WAF 함정 — 콜백 경로를 bot-UA / IP 차단에서 제외하라
가장 잡기 어려운 prod 함정입니다. 코드 리뷰로는 절대 안 잡힙니다.
많은 RP 가 애플리케이션 레이어에 봇 차단을 둡니다(Rack::Attack bad_user_agents blocklist, Cloudflare bot-fight, rate limiter 등). 이들은 보통 curl / python-requests / wget / go-http-client / bot / crawler 같은 User-Agent 를 403 으로 막습니다.
logi BackChannelLogoutJob 은 서버-서버 POST 이고, 이런 차단의 표적이 될 수 있습니다.
🚨 Danger: 실제 사고 (ainote) ainote
rack_attack.rb의bad_user_agentsblocklist 가 콜백 경로를 exempt 하지 않았습니다. logi 의 HTTP 클라이언트가Net::HTTP(User-Agent 헤더 미설정)라서 우연히 통과했지만(기본 UA 없음 → bad_agent 매칭 안 됨), logi 가 UA 를 붙이는 라이브러리(Faraday 등)로 바뀌면 조용히 403 으로 차단될 fragile 한 상태였습니다.
견고화: 콜백 경로를 UA blocklist 에서 명시적으로 exempt (/health, /.well-known/ 등과 같은 범주). RS256 서명이 진짜 게이트이므로 UA 를 면제해도 보안은 약화되지 않습니다.
체크리스트:
- 콜백 경로를 UA blocklist 에서 exempt (또는 이미 검증된 S2S webhook 경로 하위, 예:
/api/v1/webhooks/...에 둠). - rate limiter 가 있으면 콜백 경로에 별도(관대한) throttle — 정상 트래픽은 revoke 당 1회지만, 차단되면 logout 이 통째로 실패합니다.
- 반드시 실제 POST E2E 로 검증하세요(§8). 코드 리뷰(codex / 멀티렌즈)는 RP 의 WAF / Rack::Attack / rate-limit 인프라 config 를 보지 못합니다. 크립토는 통과하는데 200 대신 403 이 나오는지는 실제 요청을 쏴봐야만 드러납니다.
6. logi 등록 + 어떤 이벤트가 BCL 을 발사하는가
RP 콜백은 logi 의 OauthApplication.backchannel_logout_uri 에 등록돼야 발사됩니다.
OauthApplication(client_id: "<your client_id>")
.backchannel_logout_uri = "https://<rp-host>/<your-path>/backchannel-logout"backchannel_logout_uri가 비어 있는 RP 는 dormant — webhook(audit) 신호만 받고 BCL 은 안 받습니다. 등록은 reversible(nil 로 되돌리면 비활성).- client_id 가 여러 개(web / iOS / Android)면 BCL 을 받을 각 app 에 uri 를 등록해야 합니다. push 승인 취소는 본질적으로 web 로그인 시나리오이므로, 보통 web client 에만 등록하면 충분합니다(서버 세션은 canonical 매칭으로 어차피 전부 폐기됨 — native 는 트리거만 추가하면 커버).
어떤 logi 이벤트가 BCL 을 발사하는가:
| logi 이벤트 | BCL 발사? |
|---|---|
| 푸시 승인 취소 (push-approval-revoke) | ✅ |
| 계정 삭제 | ✅ |
| 연결된 앱 해제 (app disconnect) | ✅ |
/oauth/revoke (RFC 7009 token revocation) | ❌ (audit webhook 만) |
/oauth/revoke는 BCL 을 발사하지 않습니다. 이건 토큰 폐기 표준 엔드포인트이지 "사용자 세션 종료" 신호가 아니기 때문입니다.
7. 설정 계약 (cross-service)
배포 전 양쪽 값이 일치하는지 반드시 확인하세요. 불일치는 모든 logout_token 이 조용히 400 으로 깨지는 footgun 입니다.
| 항목 | logi 측 | RP 측 |
|---|---|---|
| issuer | OIDC_ISSUER (= https://api.1pass.dev) | issuer env (ONE_PASS_URL / ONE_PASS_ISSUER) — byte-identical |
| aud | app client_id | client_id (다중이면 allowlist env) |
| JWKS | {issuer}/.well-known/jwks.json (RS256) | 같은 URL 에서 fetch + 캐시 |
| events URI | http://schemas.openid.net/event/backchannel-logout | 동일 키 확인 |
| typ | logout+jwt | 동일 확인 |
⚠️ Warning: issuer 는 환경변수 계약 — 리터럴로 적지 말 것
iss는 "logi 가 발행하는 env 값 == RP 가 기대하는 env 값"의 계약입니다. 양쪽 prod 가 모두https://api.1pass.dev로 명시 설정돼 있어야 합니다. logi 의 issuer 기본값은 URL 이 아니므로(OIDC_ISSUER미설정 시 fallback), env 가 빠지면 RP 들의 BCL 이 iss 불일치로 전부 깨집니다. logi 는 production/staging 부팅 시OIDC_ISSUER가 유효한 https URL 인지 fail-fast 가드로 검증합니다. RP 도 자신의 issuer env 를 명시 설정하세요.
다중 client_id allowlist(easybracket 패턴):
# aud 검증: 콤마 구분 env, 미설정 시 단일 client_id
allowed = ENV.fetch("ONE_PASS_CLIENT_IDS", ENV["ONE_PASS_CLIENT_ID"]).split(",").map(&:strip)
# logout_token 의 aud(string|array) 중 하나라도 allowed 에 있으면 통과8. 검증 (E2E) — 실제 POST 를 쏴라
크립토 검증만 코드로 끝내지 말고, 실제 logout_token 을 RP 에 POST 해서 §5(WAF)까지 통과하는지 확인하세요.
logi 서버에서 실제 토큰을 발행해 RP 콜백에 보냅니다:
# logi rails runner 로 실제 logout_token 발행 (throwaway user 권장)
tok=$(... Oauth::LogoutTokenIssuer.issue(user: <user>, oauth_application: <rp_app>) ...)
# RP 콜백에 POST
curl -X POST https://<rp-host>/<your-path>/backchannel-logout \
-H "User-Agent;" \
--data-urlencode "logout_token=$tok"
# 200 빈 본문 = 크립토(JWKS+RS256+iss/aud/events) + WAF 전부 통과
# 400 = 검증/계약 실패
# 403 = WAF 차단 (§5 — 콜백 경로 exempt 누락)⚠️ Warning: 부수효과 주의 발행에 쓴 사용자가 RP 에 활성 세션을 갖고 있으면 그 세션이 실제로 1회 로그아웃됩니다(복구 가능). 완전 무중단 검증을 원하면 throwaway 유저로 발행하세요.
음성 테스트도 반드시 추가하세요: alg=none / HS256 / aud 불일치 / jti 누락 / iat 누락(또는 만료) / nonce 포함 / malformed events / (canonical RP면) pairwise-sub-present-but-canonical-absent → 전부 거부(400 또는 no-op). jti replay → 두 번째 요청은 200 이지만 세션 재폐기/2차 write 없음.
9. 레퍼런스 구현
| RP | 매칭 방식 | 엔드포인트 | 수신 코드 |
|---|---|---|---|
| ainote | pairwise sub (OauthProvider.uid) | POST /auth/1pass/backchannel-logout | Auth::OnePassBackchannelLogoutController + OnePassSsoService#verify_logout_token |
| easybracket | canonical_sub (User.logi_user_sub) | POST /api/v1/webhooks/logi/backchannel_logout | Api::V1::Webhooks::LogiController#backchannel_logout + OnePassSsoService#verify_logout_token (aud allowlist) |
관련 문서: Sub 정책 (sub / canonical_sub) · 권장 아키텍처 (RP 통합) · Token Introspection & JWKS