# 덕링 통합로그인 연동 가이드 (Duckring Account)

> **이 문서는 사람이 읽는 설명서입니다.**
> 구조와 이유, 설계상 알아야 할 것을 다룹니다.
>
> **코드를 쓰는 AI 에이전트에게는 `/developers/unified-login-ai.md` 를 주세요.**
> 그쪽은 순서대로 따라가면 되는 작업 지시서(금지사항, 복붙 코드, 자체 검증 10단계,
> 완료 체크리스트)입니다. 사람이 읽을 필요는 없습니다.
>
> 이 문서로 얻는 것: 덕링 통합로그인이 무엇이고, 왜 이렇게 생겼고, 우리 게임에
> 붙일 때 어떤 결정을 해야 하는지.

- **최종 갱신**: 2026-10-29
- **Base URL**: `https://www.duckring.com`
- **프로토콜**: OAuth 2.0 Authorization Code + PKCE (OIDC 호환)
- **디스커버리**: `https://www.duckring.com/.well-known/openid-configuration`

---

## 1. 왜 이게 필요한가

플레이어는 PC(스팀)와 휴대폰을 섞어 씁니다. 각 플랫폼 계정을 그대로 쓰면 이렇게 깨집니다.

```
스팀에서 플레이  → 스팀 계정으로 세이브 A
휴대폰에서 플레이 → 구글 계정으로 세이브 B
            → 플레이어: "내 캐릭터 어디 갔어요?"  (CS 1순위 문의)
```

덕링 통합계정을 **가장 앞단에 한 겹** 두면 이 문제가 사라집니다.

```
스팀 로그인  ─┐
구글 로그인  ─┤→  덕링 통합계정 (duckring_user_id 하나)  →  게임 서버는 이 ID 로만 세이브를 찾는다
애플/카카오 ─┘
```

개발사가 할 일은 **내부 유저 테이블의 키를 `duckring_user_id` 로 잡는 것**, 그게 전부입니다.
그 뒤로는 플레이어가 어느 플랫폼으로 들어오든 같은 행을 찾습니다.

---

## 2. 역할 분담 (중요)

| 항목 | 보관 위치 |
| --- | --- |
| 통합계정, 연결된 로그인 수단(구글/애플/카카오/깃허브/페이스북/스팀) | **덕링** |
| 계정 병합, 로그인 수단 추가·해제, 탈퇴 | **덕링** |
| 세이브, 진행도, 인벤토리, 랭킹, 결제, 로그 | **개발사 서버** |
| 통합계정 ↔ 게임 내 유저 식별자 매핑 한 줄 | 양쪽 (쿠폰·공지·CS 처리에 필요) |

덕링은 게임 데이터를 요구하지 않고, 게임 서버는 플레이어의 이메일·전화번호를 받지 않습니다
(개인정보 최소 수집). 넘어가는 것은 `sub`(통합계정 UUID), 닉네임, 아바타 URL 뿐입니다.

### 게임 단위로 분리됩니다

덕링은 퍼블리셔라 게임이 여러 개 올라갑니다. **게임 1개 = 클라이언트(`client_id`) 1개**이고,
아래가 게임별로 완전히 분리됩니다.

| 게임별로 따로 | 계정별 (모든 게임 공유) |
| --- | --- |
| `client_id` / `client_secret` | 통합계정 `sub` |
| `redirect_uri` 목록 | 연결된 로그인 수단 (구글/애플/카카오/스팀) |
| 스팀 `appid` | 스팀 연결 |
| 연동 동의 기록 | — |
| 발급된 토큰 | — |
| `game_user_tag` — 그 게임에서의 유저 식별자 | — |

- **`sub` 는 게임마다 다른 값입니다.** 같은 플레이어라도 우리 게임에 오는 `sub` 와
  다른 게임에 가는 `sub` 는 서로 다른 UUID 입니다 (OIDC pairwise subject).
  그래서 다른 개발사와 `sub` 를 대조해도 같은 사람인지 알 수 없습니다 —
  의도된 설계입니다. 교차 게임 기능이 필요하면 덕링을 통해 협의하세요.
- 같은 게임의 개발/스테이징/운영 클라이언트는 **같은 `sub`** 를 봅니다
  (환경마다 유저가 갈라지면 운영이 안 되므로).
- 다른 게임의 시크릿·토큰·`game_user_tag` 는 넘어오지 않습니다. 한 게임의 키가
  유출돼도 다른 게임은 영향받지 않습니다.
- `steam_id` 는 **스팀판으로 등록된 게임에만** 전달됩니다. SteamID64 는 게임을 넘어
  같은 값이라, 모든 게임에 주면 위의 격리가 그쪽으로 뚫립니다. 스팀판 개발사는
  Steamworks 로 이미 아는 값이므로 새로 드리는 정보가 아닙니다.
- 한 게임에 **여러 클라이언트**를 발급받을 수 있습니다 (개발/스테이징/운영 분리,
  또는 서버용 기밀 + 클라이언트용 퍼블릭). 단 동의 기록은 클라이언트별이라
  스테이징에서 한 동의가 운영으로 넘어오지는 않습니다.

> `game_user_tag` 는 **그 게임에서의 유저(계정) 식별자**입니다. 한 유저가 게임 안에서
> 캐릭터를 여러 개 운용한다면 그건 게임 서버 내부 구조이고 덕링은 거기까지 모릅니다.
> 이 문서에서 "캐릭터"라고 쓴 곳은 모두 "그 게임의 유저 계정"을 뜻합니다.

---

## 3. 어느 방식을 쓸지

| 상황 | 쓸 방식 | 클라이언트 종류 |
| --- | --- | --- |
| 게임 전용 서버가 있다 (대부분) | `5. 표준 흐름` | 기밀(client_secret 보관) |
| 스팀판이 있고, 로그인 화면을 아예 안 띄우고 싶다 | `6. 스팀 전용 흐름` | 기밀 (필수) |
| 서버가 없는 싱글/인디 빌드 | `5. 표준 흐름` + 퍼블릭 클라이언트 | 퍼블릭(시크릿 없음) |

> 퍼블릭 클라이언트는 `client_secret` 이 없고 PKCE 로만 증명합니다. 이 경우 `id_token`
> 서명을 검증할 수 없으므로 **반드시 `/userinfo` 를 호출**해 사용자를 확인하세요.
> 게임 클라이언트 바이너리에 `client_secret` 을 넣는 것은 금지입니다 (디컴파일로 추출됩니다).

client_id / client_secret 은 덕링에 요청해 발급받습니다. 발급 시 다음을 알려주세요.

- 게임 이름
- `redirect_uri` 전체 목록 (**정확 일치**만 허용 — 오타·슬래시 차이도 거부됩니다)
- 스팀판 여부와 스팀 `appid`

---

## 4. 엔드포인트

| 용도 | 메서드 | 경로 |
| --- | --- | --- |
| 인가 요청 (브라우저) | GET | `/api/v1/auth/authorize` |
| 토큰 교환·갱신 (서버) | POST | `/api/v1/auth/token` |
| 사용자 정보 (서버) | GET | `/api/v1/auth/userinfo` |
| 스팀 티켓 로그인 (서버) | POST | `/api/v1/auth/steam/ticket` |
| 디스커버리 문서 | GET | `/.well-known/openid-configuration` |

**scope**

| scope | 전달되는 것 |
| --- | --- |
| `openid` | `sub` — **이 게임에서의** 플레이어 식별자 (게임마다 다른 UUID, 항상 포함) |
| `profile` | `name`, `picture`, `linked_providers`, `steam_id`(스팀판만) |
| `game` | `game_user_tag` — 덕링에 등록된 이 게임의 유저 식별자 |

---

## 5. 표준 흐름 (Authorization Code + PKCE)

```
게임 클라이언트                개발사 서버                     덕링
     │                            │                             │
     │ ① 로그인 버튼               │                             │
     │─── 시스템 브라우저로 ──────────────────────────────────────▶ /authorize
     │                            │                   (로그인 + 동의 화면)
     │                            │◀── ② code (redirect_uri) ────│
     │                            │─── ③ code + verifier ───────▶ /token
     │                            │◀── ④ id_token + tokens ──────│
     │◀── ⑤ 게임 세션 발급 ────────│                             │
```

### ① 인가 URL 열기

`code_verifier` 는 43~128자 랜덤 문자열, `code_challenge` 는 그것의 SHA-256 을
base64url 인코딩한 값입니다. `state` 는 CSRF 방지용 랜덤값이며 ②에서 반드시 대조합니다.

```
https://www.duckring.com/api/v1/auth/authorize
  ?client_id=dr_xxxxxxxxxxxx
  &redirect_uri=https%3A%2F%2Fapi.example.com%2Fauth%2Fduckring%2Fcallback
  &response_type=code
  &scope=openid%20profile%20game
  &state=<랜덤>
  &code_challenge=<S256(code_verifier)>
  &code_challenge_method=S256
```

게임 클라이언트는 **시스템 브라우저**(또는 스팀 오버레이 브라우저)로 이 URL 을 엽니다.
인앱 웹뷰에 소셜 로그인을 태우면 구글이 차단합니다.

### ② 콜백

```
https://api.example.com/auth/duckring/callback?code=...&state=...
```

`state` 가 ①에서 보낸 값과 다르면 즉시 중단하세요.
거부/실패 시에는 `?error=access_denied&error_description=...` 형태로 돌아옵니다.

### ③④ 토큰 교환 (서버에서만)

```bash
curl -X POST https://www.duckring.com/api/v1/auth/token \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  -d grant_type=authorization_code \
  -d code=<②의 code> \
  -d redirect_uri=https://api.example.com/auth/duckring/callback \
  -d code_verifier=<①의 code_verifier> \
  -d client_id=dr_xxxxxxxxxxxx \
  -d client_secret=<발급받은 시크릿>
```

```json
{
  "access_token": "...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "...",
  "scope": "openid profile game",
  "id_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...."
}
```

`id_token` 은 **HS256, 서명 키는 당신의 `client_secret`** 입니다. JWKS 를 받아올 필요가
없습니다. 검증 항목: 서명, `iss === "https://www.duckring.com"`, `aud === client_id`, `exp`.

```js
// Node 예시 (jsonwebtoken)
const claims = jwt.verify(id_token, process.env.DUCKRING_CLIENT_SECRET, {
  algorithms: ['HS256'],
  issuer: 'https://www.duckring.com',
  audience: process.env.DUCKRING_CLIENT_ID,
})
// claims.sub ← 이 게임에서의 플레이어 식별자. 이 값 하나로 유저를 찾는다
//   (다른 게임의 sub 와는 다른 값이므로, 다른 게임과 대조할 수는 없다)
```

### ⑤ 토큰 갱신

```bash
curl -X POST https://www.duckring.com/api/v1/auth/token \
  -d grant_type=refresh_token \
  -d refresh_token=<저장해둔 refresh_token> \
  -d client_id=dr_xxxxxxxxxxxx \
  -d client_secret=<시크릿>
```

refresh_token 은 **쓰면 즉시 폐기되고 새 값이 발급**됩니다(로테이션). 응답의 새 값을
반드시 저장하세요. 재사용된 토큰이 감지되면 해당 플레이어의 토큰이 전부 끊깁니다.

---

## 6. 스팀 전용 흐름 (로그인 화면 없음)

스팀에서 게임을 실행한 플레이어에게는 아무 화면도 띄우지 않고 로그인시킬 수 있습니다.

```
게임(Steamworks)          개발사 서버                      덕링                 스팀
   │ GetAuthSessionTicket   │                               │                    │
   │──── ticket(hex) ──────▶│──── ticket + 시크릿 ─────────▶│── 티켓 검증 ──────▶│
   │                        │◀── id_token + steam_id ───────│◀── SteamID ────────│
   │◀─ 게임 세션 ────────────│                               │                    │
```

```bash
curl -X POST https://www.duckring.com/api/v1/auth/steam/ticket \
  -H 'Content-Type: application/json' \
  -d '{
        "client_id": "dr_xxxxxxxxxxxx",
        "client_secret": "<시크릿>",
        "ticket": "140000...<hex>",
        "scope": "openid profile game"
      }'
```

응답은 `/token` 과 같은 형태에 `steam_id` 가 추가됩니다.

- 티켓 검증은 덕링이 스팀 웹 API(`AuthenticateUserTicket`)로 직접 수행합니다.
  **게임이 보낸 SteamID 는 신뢰하지 않습니다** — 위조 가능하기 때문입니다.
- 이 경로는 기밀 클라이언트만 쓸 수 있습니다(퍼블릭 클라이언트는 403).
- 해당 스팀 계정으로 덕링 계정이 없으면 **자동 생성**됩니다. 플레이어가 나중에
  덕링 홈페이지에서 구글/애플/카카오를 추가 연결하면 `sub` 는 그대로 유지되므로,
  휴대폰에서 그 소셜로 로그인했을 때 같은 세이브로 들어옵니다.

---

## 7. 스팀만 연결하면 모바일에서도 되나? (가장 많이 받는 질문)

**결론: 기술적으로는 됩니다. 하지만 그것만 믿지 마세요.**

플레이어가 스팀을 덕링 계정에 연결하면, 그 계정은 휴대폰에서도 **"Steam으로 로그인"**
으로 들어올 수 있습니다. 같은 `sub` 가 나오고 세이브도 그대로입니다.
문제는 거기에 드는 품입니다.

| 플레이어가 휴대폰에서 하는 일 | 스팀으로 로그인 | 구글/애플로 로그인 |
| --- | --- | --- |
| 입력 | 스팀 ID + 비밀번호 + **Steam Guard 인증번호** | 버튼 1번 (기기에 이미 로그인됨) |
| 체감 | 스팀 앱/메일 열어 코드 확인 → 복귀 | 즉시 |
| 이탈 | 높음 | 거의 없음 |

그래서 **권장 설계**는 이렇습니다.

```
PC(스팀)  → 스팀으로 자동 로그인 (화면 없음, 6번 방식)
모바일     → 구글 / 애플 / 카카오 버튼  ← 여기에 스팀을 주 수단으로 두지 않는다
```

### 게임이 해야 할 일: 소셜 추가 연결 유도

`profile` scope 를 요청하면 응답에 `linked_providers` 가 옵니다.

```json
{
  "sub": "8f1c...uuid",
  "name": "덕덕이",
  "linked_providers": ["steam"],
  "steam_id": "76561198000000001"
}
```

이 값에 `steam` 말고 아무것도 없으면, 그 플레이어는 **휴대폰에서 들어올 편한 수단이
없는 상태**입니다. 게임 안에서 한 번 안내해 주세요.

> 휴대폰에서도 이어서 하시려면 구글 또는 애플 계정을 연결해 주세요.
> [계정 연결하러 가기] → `https://www.duckring.com/account`

덕링 계정 페이지에도 같은 안내가 뜨지만, 플레이어는 보통 게임 안에만 있습니다.
**이 안내를 게임 쪽에서 한 번 더 띄우는 것이 교차 플레이 전환율을 가장 크게 올립니다.**

### 반대 방향도 같다

모바일에서 구글로 먼저 가입한 플레이어가 PC 스팀판을 사면, 스팀 쪽에서는 로그인 화면이
안 뜨고 **새 계정이 자동 생성됩니다**(그 스팀 계정은 아직 아무 덕링 계정에도 안 붙어
있으므로). 이때 플레이어가 "내 캐릭터가 없다"고 하면 처리는 둘 중 하나입니다.

1. (권장) 스팀판 첫 실행 시 "이미 덕링 계정이 있나요?" 화면을 한 번 띄워
   `5. 표준 흐름`으로 로그인시킨다 → 그 계정에 스팀이 연결되고 세이브가 이어진다
2. 이미 두 계정이 만들어졌다면 덕링 홈페이지 → 계정 → **계정 병합**으로 합친다
   (병합 후 옛 계정의 토큰은 폐기되고, 다음 로그인부터 남은 `sub` 가 전달됩니다)

> iOS 앱으로도 낼 거라면: 다른 소셜 로그인을 제공하는 앱은 Apple 로그인도 함께
> 제공해야 합니다 (App Store 심사 지침 4.8). `apple` 은 덕링에서 이미 지원합니다.

---

## 8. 내 DB 에 어떻게 저장할지

```sql
-- 권장: 내부 유저 테이블에 덕링 식별자를 유니크 키로 둔다
-- (이 값은 "우리 게임에서의" 식별자다. 다른 게임과 공유되지 않는다)
ALTER TABLE users ADD COLUMN duckring_user_id UUID UNIQUE;
```

```
로그인 처리:
  claims.sub 로 users 조회
    있으면  → 그 유저로 세션 발급            (스팀/모바일 구분 없이 같은 행)
    없으면  → 신규 생성 (duckring_user_id = claims.sub)
```

- 플랫폼별(스팀ID, 구글ID)로 행을 나누지 마세요. 그 순간 통합의 의미가 사라집니다.
- 기존 게임에 이미 유저가 있다면: 최초 1회 "계정 연결" 화면을 띄워 기존 유저에
  `duckring_user_id` 를 채우고, 그 뒤로는 `sub` 로만 조회합니다.
- `sub` 는 **우리 게임 안에서** 영구 식별자입니다. 플레이어가 로그인 수단을 바꿔도
  변하지 않습니다. 단 계정 병합 후에는 바뀝니다 (아래 9장).
- `nonce` 를 보내면 `id_token` 에 그대로 돌아옵니다. 기성 OIDC 라이브러리를 쓰면
  자동으로 검증됩니다.

### 계정 병합을 만났을 때

플레이어가 실수로 계정을 두 개 만든 경우, 덕링에서 두 계정을 하나로 합칠 수 있습니다
(홈페이지 → 계정 → 계정 병합). 병합되면 **없어진 쪽 계정의 토큰은 전부 폐기**되고,
다음 로그인부터 남은 쪽 `sub` 가 전달됩니다. 게임 서버는 이 경우를 대비해
"다른 `sub` 로 들어온 플레이어의 데이터 합치기" CS 창구만 열어두면 됩니다
(자동 병합은 세이브 충돌 판단이 필요하므로 게임 쪽 정책입니다).

---

## 9. 한 사람이 계정을 여러 개 만들 수 있나 (중복 계정)

**기본적으로는 안 갈라집니다.** 덕링은 소셜 로그인의 **이메일이 같으면 자동으로 한 계정에
묶습니다**. 구글(`me@gmail.com`)로 가입한 사람이 나중에 페이스북(`me@gmail.com`)으로
로그인하면 새 계정이 생기지 않고 같은 `sub` 가 나옵니다.

**갈라지는 경우는 "이메일이 다를 때"** 입니다. 실제로 자주 생기는 건 이 네 가지입니다.

| 상황 | 결과 |
| --- | --- |
| Apple **"이메일 가리기"** 선택 (`...@privaterelay.appleid.com`) | 다른 이메일 → 별개 계정 |
| 카카오 로그인에서 이메일 제공에 동의하지 않음 | 이메일 없음 → 별개 계정 |
| 소셜마다 다른 이메일을 쓰는 유저 (회사/개인 계정 분리 등) | 별개 계정 |
| 스팀으로 먼저 들어옴 (스팀은 이메일을 주지 않음) | 별개 계정 (연결 전까지) |

그래서 **"소셜 5개 = 계정 5개"가 되려면 이메일을 5개 쓰는, 의도적인 행동이 필요합니다.**
그리고 그건 어떤 로그인 시스템에서도 막을 수 없습니다 — 구글 계정 5개를 만들면 어느
게임에서든 5개 계정이 됩니다. 로그인 단계에서 막을 수 있는 문제가 아닙니다.

### 게임이 해야 할 일

**보상 설계를 계정 수가 아닌 것에 묶으세요.** 신규 가입 보상·이벤트 보상을 계정당으로
주면 다계정 농사가 성립합니다. 실무에서 쓰이는 기준은 보통 이쪽입니다.

- 결제 이력 (가장 강력)
- 기기 단위 식별 (모바일) / 스팀 소유 여부
- 플레이 시간·레벨 게이트 (보상 수령 조건)

덕링이 도와줄 수 있는 부분은 `profile` scope 의 두 값입니다.

```json
{ "linked_providers": ["google", "steam"], "steam_id": "76561198000000001" }
```

- `steam_id` 가 있으면 **스팀에서 그 게임을 실제로 소유·실행한 계정**입니다
  (6번 티켓 경로로 들어왔다면 덕링이 스팀 웹 API 로 검증한 값입니다).
  "스팀 소유 확인된 계정만 ○○ 보상" 같은 조건을 걸 수 있습니다.
- `linked_providers` 가 여러 개면 그 사람이 실제로 쓰는 본 계정일 가능성이 높습니다
  (다계정 농사용 계정은 보통 수단을 하나만 붙입니다).

### 반대로, 갈라진 계정을 합치고 싶을 때

플레이어가 실수로 두 계정을 만들었다면 덕링 홈페이지 → 계정 → **계정 병합** 입니다.
단 **진행도는 합쳐지지 않습니다** — 같은 게임에 양쪽 캐릭터가 있으면 플레이어가
어느 쪽을 쓸지 고르고, 선택한 캐릭터의 식별자가 `game_user_tag` 로 전달됩니다.
(진행도를 더해주면 "두 계정 키워서 합치기" 복제가 성립하므로 의도적으로 막았습니다.)

**선택되지 않은 캐릭터는 삭제되지 않습니다.** 두 계정이 그 게임의 식별자를 맞바꾸므로,
고르지 않은 캐릭터는 옛 계정에 그대로 남고 플레이어가 그 계정으로 로그인하면 접근할 수
있습니다.

```
병합 전   계정A: 캐릭터-A        계정B: 캐릭터-B
"A를 쓴다"  →  A: 캐릭터-A        B: 캐릭터-B
"B를 쓴다"  →  A: 캐릭터-B        B: 캐릭터-A   (맞바꿈)
```

게임 서버가 신경 쓸 것은 하나뿐입니다: **같은 `sub` 가 다음 로그인에서 다른
`game_user_tag` 를 들고 올 수 있다.** 그러니 캐릭터를 `sub` 에 영구 고정하지 말고,
로그인 때 전달된 `game_user_tag` 를 기준으로 바인딩하세요. 식별자가 중복되는 일은
없습니다(맞바꿈이므로 각 태그는 항상 한 계정에만 존재합니다).

### 병합은 어떻게 동작하나 (게임 서버 관점)

덕링의 병합은 **신원 병합**입니다. 진행도를 합치지 않습니다.

| 상황 | 덕링 처리 |
| --- | --- |
| 양쪽 계정에 같은 게임 캐릭터가 둘 다 있다 | **병합 거부.** 플레이어에게 "남길 캐릭터가 있는 계정에서 실행" 또는 "안 쓰는 캐릭터를 게임에서 삭제 후 재시도"를 안내 |
| 한쪽에만 있다 | 그 게임 연동을 남길 계정으로 옮긴다 (**게임당 평생 1회**) |
| 병합 완료 | 옛 계정은 **게임 로그인이 차단**된다 (`authorize`/`token`/`userinfo` 전부 거부) |

게임 서버가 처리해야 할 것은 **하나**입니다.

> **같은 사람이 다음 로그인에 다른 `sub` 로 올 수 있다.**

병합 전 옛 계정으로 플레이했다면 그 캐릭터는 옛 `sub` 에 묶여 있습니다. 병합 후에는
새 `sub` + 같은 `game_user_tag` 로 들어오므로, **`game_user_tag` 를 기준으로 캐릭터를
찾고 `duckring_user_id` 를 갱신**하면 그대로 이어집니다.

```
병합 전   sub=A, game_user_tag=char-1   ← 플레이 중
병합 후   sub=B, game_user_tag=char-1   ← 같은 캐릭터, 새 sub
          → users 테이블의 duckring_user_id 를 A → B 로 갱신
```

옛 `sub`(A)로는 더 이상 로그인 요청이 오지 않습니다(덕링이 차단). 혹시 오면
거부해도 안전합니다.

### 거래 목적으로 악용될 수 있지 않나

캐릭터 이전은 **게임당 평생 1회**이고, 계정 단위로도 한도가 있습니다.

| 한도 | 기본값 | 막는 것 |
| --- | --- | --- |
| 게임당 계정 이전 | **평생 1회** | 같은 캐릭터 반복 전매 |
| 계정당 병합 횟수 | 30일 2회 (주는 쪽·받는 쪽 합산) | 한 계정을 거래 중계소로 쓰기 |
| 같은 두 계정 재병합 | 90일 | 왕복으로 주고받기 |

양쪽에 캐릭터가 있을 때 "골라서 교환"하는 기능은 **의도적으로 넣지 않았습니다** —
그 경로가 열려 있는 것 자체가 캐릭터 거래 시장을 만듭니다. 게임 쪽에서 추가로
제한하려면 `game_user_tag` 의 `duckring_user_id` 가 바뀐 시점을 기록해 두고 자체 정책
(거래 제한 기간, 길드 탈퇴 처리 등)을 적용하세요.

---

## 10. 에러

| HTTP | error | 원인 / 조치 |
| --- | --- | --- |
| 400 | `invalid_request` | PKCE 누락, scope 오류 → 요청 파라미터 확인 |
| 400 | `invalid_grant` | 코드 만료(10분)·재사용, redirect_uri 불일치, verifier 불일치 |
| 401 | `invalid_client` | client_id/secret 오류 → 시크릿 재발급 필요 여부 확인 |
| 401 | `invalid_token` | access_token 만료(1시간) → refresh_token 으로 갱신 |
| 403 | `unauthorized_client` | 퍼블릭 클라이언트로 스팀 티켓 경로 호출 |
| 400 (텍스트) | — | client_id 미등록 또는 redirect_uri 불일치 (리다이렉트 없이 평문 응답) |

---

## 11. 보안 규칙

- `client_secret` 은 **서버 환경변수**에만. 게임 바이너리·Git·클라이언트 설정 파일 금지.
- `/token`, `/userinfo`, `/steam/ticket` 은 브라우저에서 직접 호출하지 마세요(CORS 미개방).
- `redirect_uri` 는 정확 일치입니다. 와일드카드·하위 경로를 받지 않습니다.
- `code_verifier` 는 요청마다 새로 생성하고, 교환 후 버립니다.
- `access_token` 1시간 / `refresh_token` 60일. 플레이어 기기에 평문 저장하지 말고
  게임 서버가 보관한 뒤 자체 세션을 내려주세요.

---

## 12. 체크리스트

- [ ] `client_id` / `client_secret` 발급받고 서버 환경변수에 넣었다
- [ ] `redirect_uri` 를 덕링에 등록한 값과 글자 단위로 맞췄다
- [ ] 요청마다 `state` 와 `code_verifier` 를 새로 만들고 콜백에서 `state` 를 대조한다
- [ ] `id_token` 을 `client_secret` 으로 HS256 검증한다 (`iss`/`aud`/`exp` 포함)
- [ ] 내부 유저 테이블에 `duckring_user_id` 유니크 컬럼을 추가하고 그걸로 조회한다
- [ ] 플랫폼별로 유저 행을 나누지 않았다
- [ ] `refresh_token` 로테이션 결과(새 토큰)를 저장한다
- [ ] 스팀판: `appid` 를 덕링에 등록하고 티켓 검증 경로를 서버에서만 호출한다
- [ ] 퍼블릭 클라이언트인 경우 `id_token` 대신 `/userinfo` 로 확인한다
- [ ] `linked_providers` 가 `['steam']` 뿐인 플레이어에게 소셜 추가 연결을 안내한다
- [ ] 스팀판 첫 실행 시 "이미 덕링 계정이 있나요?" 분기를 넣었다 (계정 갈라짐 방지)
- [ ] 신규/이벤트 보상을 계정당으로 주지 않는다 (다계정 농사 차단 — 9장)
- [ ] 같은 `game_user_tag` 가 다른 `sub` 로 올 수 있다는 것을 처리한다 (병합 — 9장)

---

## 13. 문의

연동 중 막히면 덕링 홈페이지 문의(`/contact`)로 `client_id` 와 실패한 요청 시각을 함께
알려주세요. 서버 로그로 추적합니다.
