Sub 정책 (sub / canonical_sub / linked_subs)
Source:
oauth/sub-policy.md· Live: https://docs.1pass.dev/oauth/sub-policy LLM-sanitized: internal links absolutized, VitePress containers → admonitions, line numbers in the Jump-to Index reference this rendered file (1-indexed).
📍 Jump-to Index
- L33-L58: ## 왜 sub 와 canonical_sub 를 분리했나
- L59-L116: ## linked_subs 의 용도
- L117-L125: ## is_canonical 이 false 일 때
- L126-L134: ## previously_anonymous
- L135-L138: ## OIDC 호환성 caveat
- L139-L154: ## Pairwise sub — RP 간 격리 보장
- L155-L203: ## 멀티-client(멀티플랫폼) RP 의 사용자 식별
- L168-L188: ### 흔한 오해 — "merge 전 신규 사용자는 canonical_sub 도 갈리지 않나?"
- L189-L203: ### 잘못 구현 시 증상 (troubleshooting)
- L204-L212: ## 참고
Sub 정책
logi 가 /api/v1/oauth/userinfo 와 id_token 에 emit 하는 identity claim 은 다섯 가지입니다.
| Claim | 타입 | 의미 |
|---|---|---|
sub | string | 이 RP(client)별 pairwise subject identifier. user.id 자체가 아니라 RP별 솔트로 만든 opaque hash 입니다. 한 RP grant 안에서는 불변(stable) — RP grant 가 처음 발급된 시점의 user 에 고정되지만, 그 값은 user.id 가 아니라 pairwise hash 입니다. 자세한 내용은 아래 Pairwise sub 섹션 참고. |
canonical_sub | string | 같은 사람의 현재 살아있는(canonical) logi user.id. 통합 발생 시 변경됨. |
is_canonical | boolean | sub == canonical_sub 의 편의 alias. |
linked_subs | object[] | 이 canonical 에 흡수된 다른 user.id 들의 메타데이터 배열 (자기 자신 제외). 각 원소는 {sub, merged_canonical_sub, merged_via, occurred_at, source_event_id} 형태. 상세 스키마는 아래 참고. |
previously_anonymous | boolean | 이 user 가 익명 가입 후 SSO 로 promoted 된 적이 있는지. |
왜 sub 와 canonical_sub 를 분리했나
OIDC 표준은 sub 가 RP 별로 stable 해야 한다고 명시합니다 (RFC 6749 와 OIDC Core §2). 만약 통합이 발생할 때마다 sub 가 바뀌면:
- RP 가 이전
sub로 저장해둔 외래키가 모두 끊깁니다. - 캐시된 JWT 의
sub가 갑자기 의미를 잃습니다. - OIDC validator 가 토큰 회전 시 reject 할 수 있습니다.
logi 는 다음 규칙을 따릅니다:
sub은 절대 안 바뀝니다. RP grant 가 처음 만들어진 시점의 user 에 고정된 pairwise hash 값이 영구적으로 박힙니다 (값은 user.id 가 아닙니다 — 아래 Pairwise 섹션 참고). 그 user 가 나중에 다른 user 로 흡수되더라도 grant 의sub는 그대로.canonical_sub는 통합 시점에 갱신됩니다. 값은 현재 살아있는 logi user.id (user.canonical_id). RP 는 매 토큰 검증 시 이 값을 보고 "이 user 의 실제 정체성은 누구인가" 를 판단합니다.
따라서 권장 RP 패턴은:
RP DB 의 외래키 = sub (RP별 pairwise, 그대로 보존, 마이그레이션 불필요)
런타임 user lookup = canonical_sub 로 한 번 해석 → 실제 user row
⚠️ 단, 같은 RP 가 web/ios/android 등 여러 client_id 를 쓴다면
sub 은 client 마다 다르므로 사람 단위 식별은 canonical_sub 로 해야 함
(아래 Pairwise sub 섹션 참고)자세한 마이그레이션 절차는 RP Migration Guide 참고.
linked_subs 의 용도
linked_subs 는 canonical 한 user 에 흡수된 다른 user.id 들의 메타데이터 배열입니다. 각 원소는 단순 string 이 아니라 다음 필드를 가진 object 입니다:
| 필드 | 타입 | 의미 |
|---|---|---|
sub | string | 흡수된 user 의 id (RP 가 과거 외래키로 저장했을 값). |
merged_canonical_sub | string | 흡수 시점의 survivor user.id. 보통 현재 canonical_sub 와 동일. |
merged_via | string | 통합 트리거 종류 (예: "otp", "session_token", "sso_email_match"). |
occurred_at | string | null | 통합 발생 시각 (ISO 8601). |
source_event_id | string | null | 통합을 발생시킨 event_id (audit 추적용). 이벤트 백필 전 통합은 null. |
예를 들어 A → B 통합이 일어난 뒤 user B 의 userinfo 는:
{
"sub": "B",
"canonical_sub": "B",
"is_canonical": true,
"linked_subs": [
{
"sub": "A",
"merged_canonical_sub": "B",
"merged_via": "otp",
"occurred_at": "2026-05-11T12:34:56Z",
"source_event_id": "evt_01HE3..."
}
]
}A 가 더 일찍 grant 를 받았던 RP 가 token 을 새로 받으면:
{
"sub": "A",
"canonical_sub": "B",
"is_canonical": false,
"linked_subs": []
}여기서 linked_subs 가 빈 배열인 이유는 A 가 흡수된 쪽이지 흡수한 쪽이 아니기 때문입니다. 흡수한 쪽(survivor) 만 linked_subs 에 흡수된 user 들이 들어옵니다.
⚠️ Warning: RP 파싱 주의
linked_subs.forEach(s => map[s] = canonical)처럼 string 으로 가정해서 처리하면[object Object]key 가 됩니다. 반드시link.sub으로 꺼내 쓰세요:
linked_subs.forEach((link) => {
legacyToCanonical[link.sub] = link.merged_canonical_sub;
});RP 는 이 정보를 다음 용도로 씁니다:
- 자기 DB 의 두 row (sub=A, sub=B) 가 logi 입장에서는 같은 사람임을 인식.
- 두 row 의 도메인 데이터를 통합하거나, 적어도 같은 사용자가 양쪽 데이터를 모두 볼 수 있게 권한 분배.
source_event_id와occurred_at으로 자기 audit log 에서 해당 머지 이벤트 추적.
is_canonical 이 false 일 때
이 grant 는 흡수된 user 가 과거에 받은 것입니다. RP 는 두 가지 선택지가 있습니다:
- Canonical 으로 재해석 (권장) —
canonical_sub를 외래키 lookup 의 source 로 사용. RP 의 domain row 가 canonical user 와 연결되어 있다면 그대로 작동. 안 되어 있다면logi_identity_links를 통과한 resolver 가 필요. - 기존 sub 유지 —
sub로 lookup 하고 canonical 변경에 무관심. 단, 같은 사람의 다른 grant 가 들어왔을 때 두 row 로 인식하게 됨.
대부분의 RP 는 1번 패턴을 채택했고, logi 의 Rails/Swift/Kotlin SDK 도 1번을 기본값으로 가정합니다.
previously_anonymous
익명-우선 가입 흐름(v0.4)에서 user 가 처음에 익명 row 로 만들어지고 나중에 SSO 가 붙으면 previously_anonymous: true 로 마킹됩니다. 이 값은:
- 사용자가 자기 데이터에 대해 "익명 시절 데이터 포함" 여부를 인지하도록 RP UI 에 노출 가능.
- GDPR/개인정보처리방침상 익명-promoted user 와 처음부터 식별된 user 를 구분할 필요가 있을 때 사용.
previously_anonymous 는 한 번 true 가 되면 false 로 돌아가지 않습니다.
OIDC 호환성 caveat
순수 OIDC validator (예: Auth0 SDK, Keycloak 토큰 검사기) 는 canonical_sub / linked_subs 같은 logi-specific claim 을 모릅니다. 이는 의도된 동작 입니다 — 추가 claim 은 OIDC 표준의 "additional claims" 영역에 들어가며, 표준 validator 는 무시하고 sub 만 봅니다. 통합 직후 표준 validator 가 같은 사람을 두 row 로 보더라도 이는 RP 가 logi-specific claim 을 처리할 때까지의 일시적 갭이며, 다음 토큰 회전 또는 polling 동기화 후 정정됩니다.
Pairwise sub — RP 간 격리 보장
sub 는 OauthApplication 별로 다르게 발급됩니다 (OIDC §8.1 Pairwise Subject Identifier). 같은 logi user 가 두 RP 에 동시 로그인해도 RP A 와 RP B 가 받는 sub 는 서로 다른 값. 두 RP 가 사용자 데이터를 합쳐도 같은 사람인지 식별 불가 (correlation 공격면 차단).
구현: oauth_applications.pairwise_salt (앱별 48-byte 랜덤 솔트) + HMAC.
함정: 같은 RP 의 web client 와 mobile client 는 별개 OauthApplication 이므로 서로 다른 sub 를 받습니다. RP 측에서 한 사용자가 두 row 로 보이는데 identity_links 로 묶을 수 있습니다.
회귀 방지 spec: spec/integrations/krx_listing_rp_integration_spec.rb#"issues distinct pairwise-sub per RP for the same user".
멀티-client(멀티플랫폼) RP 의 사용자 식별
웹·iOS·Android 를 각각 별개 client_id 로 등록한 RP 가 가장 흔히 밟는 함정입니다. 앞 절에서 본 것처럼 sub 는 client(OauthApplication) 별 pairwise hash 라서, 같은 사람이 플랫폼을 바꿔 로그인하면 sub 가 갈립니다. 따라서:
💡 Tip: 결론: 식별 키는 pairwise
sub가 아니라canonical_sub멀티-client RP 가 사용자를 묶는 외래키는 반드시canonical_sub여야 합니다.sub로 사람을 식별하면 두 번째 플랫폼 로그인이 "다른 사람" 으로 들어옵니다.
userinfo 는 canonical_sub 와 편의 alias is_canonical 을 항상 emit 하므로, RP 는 별도 호출 없이 매 로그인에서 이 값을 받습니다.
흔한 오해 — "merge 전 신규 사용자는 canonical_sub 도 갈리지 않나?"
아닙니다. canonical_sub 는 client 와 무관하게 같은 logi user.id 입니다.
sub는 client 별 솔트로 해시되므로 web/iOS/Android 가 서로 다릅니다 (pairwise).canonical_sub는 그 user 의 살아있는 logiuser.id그 자체이며, 어느 client 로 로그인하든 동일한 값 입니다. merge 가 한 번도 일어난 적 없는 신규 멀티-client 사용자도 세 플랫폼 모두에서 같은canonical_sub를 받습니다.canonical_sub가 바뀌는 유일한 경우는 그 user 가 다른 user 로 흡수(merge)될 때뿐 입니다 (위 linked_subs 참고).
즉 "merge 전에는 식별이 안 되니까 sub 도 같이 봐야 한다" 는 보정 로직은 불필요 합니다. canonical_sub 단독 키잉이 merge 전·후 모두에서 충분한 정답 입니다.
멀티-client RP 의 사용자 외래키 = canonical_sub
(web/iOS/Android 어느 쪽 로그인이든 같은 값 → 한 사람 = 한 row)
sub 는 grant(=client)별 식별·audit 용으로만 보조 저장잘못 구현 시 증상 (troubleshooting)
RP 가 사람 단위 식별을 canonical_sub 가 아니라 pairwise sub 로 하면, 두 번째 플랫폼에서 로그인할 때 RP 가 "이미 다른 sub 로 연결된 사용자" 로 오인합니다. 전형적인 증상:
- RP 가
AlreadyLinkedError를 던지거나, 사용자에게 "이미 다른 계정과 연결되어 있습니다" 류의 메시지를 보여줌. - 첫 플랫폼(예: 웹)으로는 로그인되는데, 두 번째 플랫폼(예: iOS/Android)에서만 연결 실패가 재현됨.
- RP DB 에 같은 사람이 플랫폼 수만큼 분리된 row 로 쌓임.
→ 원인은 거의 항상 pairwise sub 로 사용자 키잉. 식별 키를 canonical_sub 로 바꾸면 해소됩니다. 이미 분리 저장된 row 는 canonical_sub 기준으로 병합하세요.