Skip to content

Back-Channel Logout (RP 통합)

사용자가 logi(IdP)에서 푸시 승인을 취소하거나, 계정을 삭제하거나, 연결된 앱을 해제하면 — logi 는 그 RP 의 서버 세션까지 OIDC Back-Channel Logout 1.0 으로 즉시 강제 종료합니다. RP 가 이 신호를 받으려면 서버에 콜백 엔드포인트 하나를 구현해 등록하면 됩니다.

이 문서는 RP(Relying Party) 측에서 수신부를 구현하는 방법을 다룹니다. logi 발행부(producer)는 이미 완성돼 있으므로 RP 코드만 추가하면 됩니다.

언제 필요한가

계정 탈취/피싱 후 "되돌리기"가 실제로 동작하려면 access token 폐기만으로는 부족합니다 — RP 가 자체 세션 쿠키 / API 토큰 / 디바이스 토큰을 들고 있으면 그 세션은 logi 와 무관하게 살아있습니다. Back-Channel Logout 이 그 마지막 조각(RP 세션 강제 종료)을 닫습니다.


1. 엔드포인트

RP 는 다음 형태의 엔드포인트 하나를 노출합니다.

text
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-urlencodedlogout_token 한 개. JSON 아님.
  • 경로는 RP 가 정합니다. 레퍼런스 구현:
    • ainote: POST https://app.ainote.dev/auth/1pass/backchannel-logout
    • easybracket: POST https://easybracket.org/api/v1/webhooks/logi/backchannel_logout

응답 규약(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 입니다. 다음을 전부 통과해야 신뢰합니다.

text
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": {} }
}

검증 순서:

  1. alg 하드핀algorithms: ["RS256"] 만 허용. alg: none / HS256 혼동 공격을 구조적으로 차단합니다. JWT 라이브러리의 기본 alg 추론에 절대 의존하지 마세요.
  2. JWKS 키 조회kid{issuer}/.well-known/jwks.json 에서 공개키를 가져와 서명 검증.
    • JWKS 는 캐시(예: 1시간)합니다. jku(헤더의 키 URL)는 무시 — issuer 도메인 고정 URL 만 사용(키 출처 스푸핑 방지).
    • unknown kid 가 와도 JWKS 강제 refetch 금지. 캐시에 없으면 그냥 검증 실패(400). 공격자가 임의 kid 로 refetch 루프를 돌려 DoS 하는 것을 막습니다. (키 회전은 캐시 TTL 안에서 자연 수렴.)
  3. issverify_iss: true, 기대값 = RP 의 issuer env(§7). logi OIDC_ISSUERbyte-identical 이어야 합니다.
  4. audverify_aud: true, 기대값 = RP 의 client_id. aud 는 string 또는 array 일 수 있으니 "포함 검사"로(대부분의 JWT 라이브러리가 자동 처리). client_id 가 여러 개(web/iOS/Android)면 allowlist 로(§7).
  5. iatverify_iat: true + max-age 검사(예: 10분 초과한 토큰 거부). 오래된 토큰 재사용 차단.
  6. typ — 헤더 typ == "logout+jwt" 확인.
  7. eventsevents 가 object 이고 http://schemas.openid.net/event/backchannel-logout 키를 포함하는지 확인. (이게 "이건 로그아웃 토큰이다"의 표식)
  8. nonce 거부nonce 클레임이 있으면 거부(BCL §2.6: logout_token 에 nonce 가 있으면 안 됨 — id_token 으로 오인된 토큰 차단).
  9. 본문 크기 상한logout_token 길이에 상한(예: 8KB)을 둬서 거대 입력 거부.

sub-only 로그아웃

logi 의 logout_token 은 sid(특정 세션 식별자) 없이 sub 만 담습니다. 즉 "이 사용자의 모든 세션을 종료"하는 의미입니다. post-revoke / 계정삭제 맥락에서는 이게 의도된 안전 동작입니다. RP 가 sid 기반 단일 세션 로그아웃을 원하면 별도 협의가 필요합니다(현재 미지원).

Ruby 참고 구현(ainote OnePassSsoService#verify_logout_token):

ruby
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}"
end

3. 🔴 어떤 클레임으로 사용자를 매칭할 것인가 — sub vs canonical_sub

이게 RP 통합에서 가장 중요한 결정입니다. logout_token 에는 두 개의 식별 클레임이 있습니다.

클레임성격
subpairwise — RP(client)마다 다른 불투명 값OIDC 표준. id_token / userinfo 의 sub 와 동일
canonical_sublogi 의 canonical user id — 모든 client 공통, 계정 병합 시 survivor 로 수렴logi 비표준 추가 클레임

RP 가 로그인 때 저장한 식별 키와 같은 것으로 매칭해야 합니다.

결정 가이드

  • 로그인 때 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 로 동일하게 채웁니다.

canonical RP 의 구조적 안전장치

canonical_sub 로 매칭하는 RP 의 검증 함수는 canonical_sub 만 반환하고 표준 sub(pairwise)는 절대 반환/사용하지 마세요. 호출자가 실수로 pairwise sub 로 조회하는 사고를 코드 레벨에서 차단합니다. "pairwise sub 는 있는데 canonical_sub 가 없는" 토큰은 매칭 실패(no-op 200)로 떨어지는 회귀 테스트를 두세요.


4. jti replay 방지 — 폐기 성공 후 마킹

logout_tokenjti 를 짧은 TTL 캐시(예: 10분 = iat max-age 와 동일)에 기록해 재처리를 막습니다. 핵심은 순서입니다.

text
1. verify_logout_token  → sub/canonical_sub, jti
2. jti 가 이미 캐시에 있으면 → 200 (no-op, DB write 없음)
3. force_logout!(user)   → 세션 폐기 (성공해야 함)
4. ✅ 성공한 뒤에 jti 캐시 기록   ← 반드시 "성공 후"

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 이고, 이런 차단의 표적이 될 수 있습니다.

실제 사고 (ainote)

ainote rack_attack.rbbad_user_agents blocklist 가 콜백 경로를 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 에 등록돼야 발사됩니다.

text
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 측
issuerOIDC_ISSUER (= https://api.1pass.dev)issuer env (ONE_PASS_URL / ONE_PASS_ISSUER) — byte-identical
audapp client_idclient_id (다중이면 allowlist env)
JWKS{issuer}/.well-known/jwks.json (RS256)같은 URL 에서 fetch + 캐시
events URIhttp://schemas.openid.net/event/backchannel-logout동일 키 확인
typlogout+jwt동일 확인

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 패턴):

ruby
# 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 콜백에 보냅니다:

bash
# 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 누락)

부수효과 주의

발행에 쓴 사용자가 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매칭 방식엔드포인트수신 코드
ainotepairwise sub (OauthProvider.uid)POST /auth/1pass/backchannel-logoutAuth::OnePassBackchannelLogoutController + OnePassSsoService#verify_logout_token
easybracketcanonical_sub (User.logi_user_sub)POST /api/v1/webhooks/logi/backchannel_logoutApi::V1::Webhooks::LogiController#backchannel_logout + OnePassSsoService#verify_logout_token (aud allowlist)

관련 문서: Sub 정책 (sub / canonical_sub) · 권장 아키텍처 (RP 통합) · Token Introspection & JWKS

Identity가 제품의 신뢰를 만듭니다.