익명 계정 스왑 (Anonymous Account Swap)
logi 모바일 앱은 첫 실행 시 사용자 가입 절차 없이 익명 (device-bound) 사용자 A 를 발급합니다. 이 사용자가 나중에 Apple/Google 로 로그인했는데 그 sub 가 이미 존재하는 정규 계정 B 에 묶여 있다면, A 의 자산을 B 로 옮기고 A 를 hard-delete 한 뒤 같은 디바이스가 B 로 행세하게 만드는 "스왑" 이 필요합니다. 이 endpoint 가 그것을 수행합니다.
핵심
- 익명 JWT 전용: 호출자가 익명 사용자가 아니면
non_anonymous_caller반환. - B-wins 머지: 중복 RP grant / agent approval 은 B 의 기존 row 가 이김.
- 단일 트랜잭션: 자산 이전 + PAK 발급 + A hard-delete 가 같은 ApplicationRecord.transaction.
- 응답 = DeviceBootstrapResponse +
merged통계: 클라이언트가 "{N}개 항목이 통합됐어요" 토스트를 띄울 수 있도록 카운트 echo.
언제 호출하나
일반 흐름은 POST /api/v1/me/connected_identities (provider 추가) 입니다. 그러나 다음 조건이 모두 충족될 때 그 endpoint 는 409 identity_owned_by_another_account 를 반환하며, 클라이언트는 이 anonymous-swap endpoint 로 라우팅합니다:
- 현재 로그인 사용자가 익명 (
current_user.anonymous?== true) - 새 Apple/Google
identity_token의sub가 다른 정규 사용자 B 에 매핑됨
sequenceDiagram
autonumber
participant App as iOS LogiCore<br/>(AccountSwitchSheet)
participant API as logi 서버
participant DB as DB
App->>App: 익명 사용자 A 로 부트스트랩 완료
Note over App: 사용자가 "Apple 로 로그인" 탭
App->>API: POST /api/v1/me/connected_identities<br/>{provider, identity_token, raw_nonce}
API->>DB: sub 조회 → 이미 다른 user(B) 가 소유
API-->>App: 409 identity_owned_by_another_account
App->>App: AccountSwitchSheet 표시<br/>(자산 머지 항목 미리보기 + 확인)
App->>API: POST /api/v1/me/anonymous_swap<br/>{provider, identity_token, raw_nonce, merge_options}
API->>DB: A → B 자산 이전 (단일 트랜잭션)
API->>DB: A.destroy!
API->>DB: B 에 새 PAK + device_secret 발급
API-->>App: 200 OK (DeviceBootstrapResponse + merged 통계)iOS 클라이언트 측 진입점은 AccountSwitchSheet (LogiCore, commit 5700c24b4) — 사용자에게 무엇이 옮겨지는지 미리보기를 보여주고, merge_options 로 어떤 RP 들을 옮길지 선택할 수 있게 합니다.
Endpoint
| Method | Path | 인증 |
|---|---|---|
POST | /api/v1/me/anonymous_swap | Bearer (PAK, anonymous user only) · scope profile:write |
라우트 정의 — config/routes.rb (~line 682):
post "me/anonymous_swap" => "me/anonymous_swaps#create", as: :me_anonymous_swap요청
POST /api/v1/me/anonymous_swap HTTP/1.1
Host: api.1pass.dev
Authorization: Bearer <익명 사용자 A 의 PAK>
Content-Type: application/json
{
"provider": "apple",
"identity_token": "eyJhbGciOiJSUzI1NiIs...",
"raw_nonce": "Zk9pT2g...",
"full_name": {
"given_name": "다혜",
"family_name": "김"
},
"merge_options": {
"rps": ["logi_a1b2...", "logi_c3d4..."],
"agent_approvals": true,
"notifications": true
},
"device_uuid": "00000000-0000-0000-0000-000000000000",
"platform": "ios"
}| 필드 | 타입 | 필수 | 설명 |
|---|---|---|---|
provider | string | ✅ | "apple" 또는 "google". 그 외 값은 unknown_provider. |
identity_token | string | ✅ | Apple/Google 이 발급한 OIDC id_token. JWKS 로 검증 + aud / iss / nonce 검증. |
raw_nonce | string | ✅ | 클라이언트가 authorize 요청 시 생성한 raw nonce. 토큰의 nonce claim 과 SHA256(raw_nonce) 비교. |
full_name | object | (Apple 한정) | {given_name, family_name}. Apple 은 첫 로그인 시에만 이름을 보내므로 ASAuthorization 응답에서 받은 값을 그대로 전달. permit 후 hash 로 정규화. |
merge_options | object | (선택) | 어떤 자산을 옮길지 선택. 생략 시 모두 이전. |
merge_options.rps | array<string> | (선택) | 옮길 RP client_id 목록. 누락 시 모든 RP grant 이전. |
merge_options.agent_approvals | bool | (선택) | false 면 agent approval row 이전 안 함. |
merge_options.notifications | bool | (선택) | false 면 푸시 인박스 (push_approval_requests) 이전 안 함. |
device_uuid | string | (선택) | PAK label / 감사 로그용. |
platform | string | (선택) | "ios" / "android" / "web". PAK label / 감사 로그용. |
성공 응답 (200 OK)
{
"user": {
"id": 123,
"contact_email": "user@example.com",
"name": "김다혜",
"anonymous": false
},
"access_token": "lpk_live_...",
"token_type": "Bearer",
"scopes": [
"profile:read",
"profile:write",
"login_history:read",
"account:delete",
"agent_approvals:read",
"agent_approvals:manage"
],
"merged": {
"rps": {
"transferred": 2,
"skipped_duplicate": 1
},
"agent_approvals": {
"transferred": 5,
"skipped_duplicate": 0
},
"notifications": {
"transferred": 3
}
},
"needs_onboarding": false,
"device": {
"id": 99,
"device_uuid": "00000000-0000-0000-0000-000000000000",
"platform": "ios",
"attestation_verified": true,
"first_seen_at": "2026-05-12T07:00:00Z",
"last_seen_at": "2026-05-27T09:14:22Z"
},
"device_secret": "ds_..."
}응답 모양은 /auth/{apple,google}/session 의 DeviceBootstrapResponse 와 동일하며, 거기에 merged 블록만 추가됩니다.
| 필드 | 설명 |
|---|---|
user | 스왑 후의 정규 사용자 B (User#api_payload). |
access_token | B 명의로 새로 발급된 PAK. A 의 토큰은 destroy 와 함께 폐기. |
scopes | 스왑 직후 PAK 의 기본 scope set (위 6개 고정). |
merged.rps.transferred | A → B 로 옮겨진 RP grant 행 수. |
merged.rps.skipped_duplicate | B 가 이미 동일 RP grant 를 갖고 있어 스킵된 행 수 (B wins). |
merged.agent_approvals.transferred / skipped_duplicate | 동일 의미를 agent approval row 에 적용. |
merged.notifications.transferred | 옮겨진 푸시 승인 요청 row 수 (스킵 개념 없음 — union merge). |
needs_onboarding | result.user.contact_email.blank? — B 에 연락처 이메일이 비어 있으면 true 로, 클라이언트가 onboarding 시트를 띄움. |
device | 현재 디바이스가 B 로 reassign 된 후의 DeviceCredential snapshot. |
device_secret | 회전된 device_secret plaintext. 이 응답에서만 노출되므로 클라이언트가 즉시 Keychain 에 저장해야 함. |
에러 응답
모든 에러는 HTTP 422 Unprocessable Content (또는 invalid_request 일 때 400) 로, JSON body 는 { "error": "<code>" } 모양입니다.
| HTTP | error code | 원인 |
|---|---|---|
| 400 | invalid_request | identity_token 또는 raw_nonce 가 비어 있음. |
| 422 | non_anonymous_caller | 호출자가 익명이 아님. 정규 사용자는 POST /api/v1/me/connected_identities 를 사용해야 함. |
| 422 | unknown_provider | provider 가 apple / google 외의 값. |
| 422 | invalid_identity_token | JWKS / nonce / aud / iss 검증 실패. error_description 에 사유 echo. |
| 422 | identity_does_not_resolve_to_existing_account | sub 가 어떤 정규 사용자에도 매핑되지 않음. 이 경우 클라이언트는 스왑이 아닌 link 흐름 (POST /api/v1/me/connected_identities) 으로 라우팅해야 함. |
| 422 | identity_belongs_to_another_anonymous_user | sub 가 또 다른 익명 사용자에 묶여 있음 (sanity check — 익명 ↔ 익명 스왑은 허용 안 함). |
컨트롤러 매핑 (anonymous_swaps_controller.rb:51-65):
rescue AnonymousSwapService::NonAnonymousCaller
render json: { error: "non_anonymous_caller" }, status: :unprocessable_content
rescue AnonymousSwapService::UnknownProvider
render json: { error: "unknown_provider" }, status: :unprocessable_content
rescue AnonymousSwapService::IdentityDoesNotResolveToExistingAccount
render json: { error: "identity_does_not_resolve_to_existing_account" },
status: :unprocessable_content
rescue AnonymousSwapService::IdentityBelongsToAnotherAnonymousUser
render json: { error: "identity_belongs_to_another_anonymous_user" },
status: :unprocessable_content
rescue AnonymousSwapService::InvalidIdentityToken => e
render json: { error: "invalid_identity_token", error_description: e.message },
status: :unprocessable_content보안 모델
- 호출 가능 조건: 익명 사용자의 PAK + scope
profile:write.before_action :require_anonymous_user!가 강제 (라인 23, 72-76). - 단일 트랜잭션: 자산 이전 → 디바이스 reassign → PAK/device_secret 발급 →
A.destroy!가 모두 같은ApplicationRecord.transaction안에서 실행됩니다. 어느 단계든 raise 하면 전체 롤백되어 A 가 보존됩니다 (서비스 라인 66-99). - PushApprovalRequest defense-in-depth: union merge 후에도 잔여 FK 가 있을 수 있으므로
requested_user_id/approved_user_id를update_all로 다시 sweep 합니다 (서비스 라인 81-84). - MergeService 와 별개: 이 endpoint 는 동일 물리 디바이스에서 익명 → 실계정 스왑 전용입니다. cross-host identity-graph 머지가 필요한 경우는
MergeService가 처리하며, 거기서는user.merged웹훅이 발사됩니다. 익명 사용자는 RP 입장에서 자체 grant 가 없으므로 anonymous-swap 은 웹훅을 발사하지 않습니다.
Related
- Connected Identities (provider 추가) — 정규 사용자가 새 provider 를 link 할 때.
- Account Merge 개요 — cross-host identity-graph 머지.
- Merge Idempotency — 머지 재시도 안전성.
- Anonymous Grants — 익명 사용자가 RP grant 를 가질 수 있는 조건.