# 덕링 통합로그인 구현 지시서 (AI 에이전트용)

이 문서는 **코드를 작성하는 AI 에이전트에게 주는 작업 지시서**입니다.
사람이 읽는 설명·배경은 `/developers/unified-login.md` 에 있습니다. 둘 중 하나만 읽어야
한다면 **이 문서**를 읽고 구현하세요. 배경이 필요할 때만 다른 문서를 참조하세요.

- 최종 갱신: 2026-10-29
- Issuer: `https://www.duckring.com`
- 프로토콜: OAuth 2.0 Authorization Code + PKCE (S256 필수), OIDC 호환

---

## 0. 당신이 만드는 것

플레이어가 **스팀(PC)에서든 모바일에서든 같은 계정·같은 세이브로 들어오게** 만드는 것.

덕링은 "이 사람이 누구인가"(`sub`, UUID)만 알려줍니다. 세이브·아이템·결제는 **당신의
서버에 그대로 둡니다.** 덕링으로 옮기지 마세요.

```
스팀 로그인  ─┐
구글 로그인  ─┤→  덕링 sub (UUID 하나)  →  게임 서버는 이 값으로 유저를 찾는다
애플/카카오 ─┘
```

**핵심 작업은 한 줄입니다: 내부 유저 테이블의 키를 `sub` 로 잡는 것.**

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

덕링은 퍼블리셔라 게임이 여러 개입니다. **게임 1개 = `client_id` 1개**이고, 아래가
모두 게임별로 따로 관리됩니다.

| 게임별로 다름 | 계정별 (모든 게임 공유, 하지만 외부로 안 나감) |
| --- | --- |
| `client_id` / `client_secret` | 덕링 내부 계정 (당신에게 노출되지 않음) |
| `redirect_uri` 목록 | 연결된 로그인 수단 |
| 스팀 `appid` | 스팀 연결 1개 |
| 연동 동의 기록 | — |
| 발급된 토큰 | — |
| `game_user_tag` | — |
| **`sub` — 게임마다 다른 UUID** | — |

⚠️ **`sub` 는 게임마다 다릅니다 (OIDC pairwise subject).** 같은 플레이어라도 다른 게임에
가는 `sub` 와 값이 다릅니다. 그래서:

- 다른 게임/다른 개발사의 `sub` 와 **대조할 수 없습니다.** 그런 기능을 요구받으면
  구현하지 말고 덕링에 문의하라고 안내하세요.
- 같은 게임의 개발/스테이징/운영 클라이언트는 **같은 `sub`** 를 봅니다.
- `steam_id` 는 **스팀 `appid` 가 등록된 클라이언트에만** 옵니다. 모바일 전용
  클라이언트에서는 항상 `null` 이므로 그 값에 의존하는 코드를 쓰지 마세요.

게임을 여러 개 담당한다면 **게임마다 별도 환경변수 세트**를 두세요
(`DUCKRING_CLIENT_ID_GAME_A`, `..._GAME_B` 등). 하나를 다른 게임에 재사용하면
`redirect_uri` 불일치로 거부됩니다.

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

---

## 1. 절대 하지 말 것 (먼저 읽기)

| 금지 | 이유 |
| --- | --- |
| 게임 클라이언트(앱/실행파일)에 `client_secret` 넣기 | 디컴파일로 추출됩니다. 서버 전용. 서버가 없으면 퍼블릭 클라이언트로 발급받으세요 |
| 클라이언트가 보낸 `sub`/`steam_id`/이메일을 그대로 신뢰 | 위조 가능. 서버가 `/token` 또는 `/userinfo` 로 직접 받은 값만 신뢰 |
| 브라우저 JS 에서 `/token`·`/userinfo`·`/steam/ticket` 호출 | CORS 미개방이고 시크릿이 노출됩니다. 서버-서버 전용 |
| 인앱 웹뷰로 로그인 띄우기 | 구글이 차단합니다. **시스템 브라우저**(또는 스팀 오버레이)를 사용 |
| 플랫폼별로 유저 행 나누기 (steam_users / google_users) | 통합의 의미가 사라집니다. 행은 `sub` 하나당 하나 |
| `redirect_uri` 를 와일드카드/부분일치로 가정 | 덕링은 **정확 일치**만 허용합니다. 슬래시 하나만 달라도 거부 |
| `state`·`code_verifier` 재사용 | 요청마다 새로 생성하고 교환 후 폐기 |
| PKCE 생략 | 필수입니다. 없으면 `invalid_request` |
| 다른 게임/서비스의 ID 와 `sub` 대조 | `sub` 는 게임마다 다른 값이라 애초에 불가능합니다. 그런 요구는 덕링에 문의 |
| `steam_id` 가 항상 온다고 가정 | 스팀 appid 가 등록된 클라이언트에만 옵니다 |
| 덕링 토큰을 게임 클라이언트에 전달 | 게임 서버가 보관하고 **자체 세션**을 내려주세요 |

---

## 2. 사전 준비 (사람에게 요청할 값)

덕링에서 발급받아야 합니다. 없으면 **여기서 멈추고 요청하세요.**

```
DUCKRING_CLIENT_ID=dr_xxxxxxxxxxxx
DUCKRING_CLIENT_SECRET=...            # 퍼블릭 클라이언트면 없음
DUCKRING_REDIRECT_URI=https://api.example.com/auth/duckring/callback
DUCKRING_ISSUER=https://www.duckring.com
```

발급 요청 시 알려줄 것: 게임 이름 / `redirect_uri` 전체 목록 / 스팀판 여부와 `appid`.

전부 **서버 환경변수**에 둡니다. 저장소·클라이언트 빌드에 넣지 마세요.

---

## 3. DB 작업 (가장 먼저)

```sql
ALTER TABLE users ADD COLUMN duckring_user_id UUID UNIQUE;
CREATE INDEX idx_users_duckring ON users(duckring_user_id);
```

이미 유저가 있는 게임이면, 최초 1회 "계정 연결" 화면으로 기존 유저에 채운 뒤
그 다음부터는 `duckring_user_id` 로만 조회합니다.

게임이 여러 개여도 **게임마다 자기 DB 에 이 컬럼을 두면 됩니다.** 단 `sub` 는 게임마다
다른 값이므로, 게임 A 의 `sub` 로 게임 B 의 유저를 찾으려 하지 마세요 (찾을 수 없습니다).
게임 간 데이터 연계가 필요하면 덕링에 문의하세요.

---

## 4. 구현 A — 표준 흐름 (서버가 있는 모든 경우)

### 4-1. 인가 URL 생성 (게임 서버)

```js
import { randomBytes, createHash } from 'node:crypto'

const b64url = (buf) =>
  buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')

export function buildAuthUrl() {
  const state = b64url(randomBytes(16))
  const codeVerifier = b64url(randomBytes(32))          // 43~128자
  const codeChallenge = b64url(createHash('sha256').update(codeVerifier).digest())

  // state -> codeVerifier 를 서버 세션/캐시에 10분간 저장 (클라이언트에 주지 말 것)
  savePending(state, codeVerifier)

  const url = new URL(`${process.env.DUCKRING_ISSUER}/api/v1/auth/authorize`)
  url.searchParams.set('client_id', process.env.DUCKRING_CLIENT_ID)
  url.searchParams.set('redirect_uri', process.env.DUCKRING_REDIRECT_URI)
  url.searchParams.set('response_type', 'code')
  url.searchParams.set('scope', 'openid profile game')
  url.searchParams.set('state', state)
  url.searchParams.set('code_challenge', codeChallenge)
  url.searchParams.set('code_challenge_method', 'S256')
  return url.toString()
}
```

게임 클라이언트는 이 URL 을 **시스템 브라우저**로 엽니다.

### 4-2. 콜백 → 토큰 교환 (게임 서버)

```js
app.get('/auth/duckring/callback', async (req, res) => {
  const { code, state, error, error_description } = req.query

  if (error) return res.status(400).send(`덕링 로그인 실패: ${error_description || error}`)

  const codeVerifier = takePending(state)        // 없으면 위조/만료 → 중단
  if (!codeVerifier) return res.status(400).send('잘못된 요청입니다.')

  const body = new URLSearchParams({
    grant_type: 'authorization_code',
    code,
    redirect_uri: process.env.DUCKRING_REDIRECT_URI,
    code_verifier: codeVerifier,
    client_id: process.env.DUCKRING_CLIENT_ID,
    client_secret: process.env.DUCKRING_CLIENT_SECRET,   // 퍼블릭이면 생략
  })

  const response = await fetch(`${process.env.DUCKRING_ISSUER}/api/v1/auth/token`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body,
  })
  const tokens = await response.json()
  if (!response.ok) return res.status(401).json(tokens)   // { error, error_description }

  const claims = await verifyClaims(tokens)               // 4-3
  const user = await upsertUser(claims)                   // 4-4
  // 여기서 당신의 게임 세션을 발급하세요. 덕링 토큰을 클라이언트에 넘기지 마세요.
  res.redirect(`yourgame://login?token=${issueGameSession(user)}`)
})
```

### 4-3. 검증 (기밀 클라이언트 / 퍼블릭 클라이언트)

**기밀 클라이언트 — `id_token` 을 로컬 검증 (HS256, 키 = 당신의 `client_secret`)**

```js
import jwt from 'jsonwebtoken'

function verifyClaims(tokens) {
  return jwt.verify(tokens.id_token, process.env.DUCKRING_CLIENT_SECRET, {
    algorithms: ['HS256'],                 // 반드시 고정. 'none' 허용 금지
    issuer: process.env.DUCKRING_ISSUER,
    audience: process.env.DUCKRING_CLIENT_ID,
  })
}
```

**퍼블릭 클라이언트 — `id_token` 이 없으므로 `/userinfo` 로 확인**

```js
async function verifyClaims(tokens) {
  const r = await fetch(`${process.env.DUCKRING_ISSUER}/api/v1/auth/userinfo`, {
    headers: { Authorization: `Bearer ${tokens.access_token}` },
  })
  if (!r.ok) throw new Error('userinfo 실패')
  return r.json()
}
```

클레임:

| 필드 | scope | 내용 |
| --- | --- | --- |
| `sub` | openid | **이 게임에서의 플레이어 식별자 (UUID) — 이게 유저 키다.** 게임마다 다른 값 |
| `name`, `picture` | profile | 닉네임, 아바타 URL |
| `linked_providers` | profile | 예: `["google","steam"]` |
| `steam_id` | profile | SteamID64. **스팀 appid 가 등록된 클라이언트에만** 옴. 그 외는 항상 `null` |
| `nonce` | — | 인가 요청에 `nonce` 를 보냈다면 `id_token` 에 그대로 돌아온다 |
| `game_user_tag` | game | 덕링에 등록된 이 게임의 유저 식별자 (없으면 `null`) |

이메일·전화번호는 **전달되지 않습니다**. 필요하다고 가정하고 설계하지 마세요.

### 4-4. 유저 upsert (여기가 핵심)

```js
async function upsertUser(claims) {
  // 1) sub 로 찾는다 — 스팀/모바일 구분 없이 같은 행이 나온다
  let user = await db.users.findOne({ duckring_user_id: claims.sub })
  if (user) return user

  // 2) 병합된 플레이어: sub 는 새것이지만 game_user_tag 는 그대로다
  if (claims.game_user_tag) {
    const byTag = await db.users.findOne({ game_user_tag: claims.game_user_tag })
    if (byTag) {
      await db.users.update(byTag.id, { duckring_user_id: claims.sub })
      return byTag
    }
  }

  // 3) 신규
  return db.users.insert({
    duckring_user_id: claims.sub,
    nickname: claims.name,
    game_user_tag: claims.game_user_tag ?? null,
  })
}
```

**2번 분기를 반드시 넣으세요.** 플레이어가 덕링에서 계정을 병합하면 같은 사람이
**다른 `sub`** 로 옵니다. 이 분기가 없으면 캐릭터가 사라진 것처럼 보입니다.

### 4-5. 토큰 갱신

```js
const body = new URLSearchParams({
  grant_type: 'refresh_token',
  refresh_token: saved.refresh_token,
  client_id: process.env.DUCKRING_CLIENT_ID,
  client_secret: process.env.DUCKRING_CLIENT_SECRET,
})
```

`refresh_token` 은 **쓰면 즉시 폐기되고 새 값이 나옵니다(로테이션).** 응답의 새 값을
반드시 저장하세요. 옛 값을 다시 쓰면 그 플레이어의 토큰이 전부 끊깁니다.

`access_token` 1시간 / `refresh_token` 60일. 플레이어 기기에 평문 저장하지 말고
게임 서버가 보관한 뒤 자체 세션을 내려주세요.

---

## 5. 구현 B — 스팀 무화면 로그인 (스팀판이 있고 서버가 있는 경우)

### 5-1. 게임 클라이언트 (Unity, Steamworks.NET)

```csharp
// 티켓 발급 → 게임 서버로 전송. 클라이언트는 SteamID 를 "주장"하지 않는다.
var ticket = new byte[1024];
var identity = new SteamNetworkingIdentity();
SteamUser.GetAuthSessionTicket(ticket, ticket.Length, out uint length, ref identity);

var hex = BitConverter.ToString(ticket, 0, (int)length).Replace("-", "");
StartCoroutine(PostToGameServer("/auth/steam", hex));   // 당신의 서버 엔드포인트
```

### 5-2. 게임 서버

```js
app.post('/auth/steam', async (req, res) => {
  const r = await fetch(`${process.env.DUCKRING_ISSUER}/api/v1/auth/steam/ticket`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      client_id: process.env.DUCKRING_CLIENT_ID,
      client_secret: process.env.DUCKRING_CLIENT_SECRET,   // 필수. 퍼블릭 불가
      ticket: req.body.ticket,
      scope: 'openid profile game',
    }),
  })

  const tokens = await r.json()
  if (!r.ok) return res.status(401).json(tokens)

  const claims = await verifyClaims(tokens)     // 4-3 과 동일
  const user = await upsertUser(claims)         // 4-4 와 동일
  res.json({ token: issueGameSession(user) })
})
```

덕링이 스팀 웹 API 로 티켓을 검증하고, 그 스팀에 연결된 통합계정(없으면 생성)의
토큰을 돌려줍니다. 응답에 `steam_id` 가 추가로 들어옵니다.

---

## 6. 모바일 로그인 수단 유도 (빼먹지 말 것)

`linked_providers` 가 `["steam"]` 하나뿐이면, 그 플레이어는 **휴대폰에서 편하게 들어올
수단이 없습니다**(스팀 로그인은 폰에서 비밀번호 + Steam Guard 입력이 필요).

게임 안에서 한 번 안내하세요.

```js
if (claims.linked_providers?.length === 1 && claims.linked_providers[0] === 'steam') {
  showNotice('휴대폰에서도 이어서 하시려면 구글 또는 애플 계정을 연결해 주세요.',
             'https://www.duckring.com/account')
}
```

반대로 **모바일에서 구글로 먼저 가입한 플레이어가 스팀판을 실행하면 새 계정이 자동
생성됩니다.** 스팀판 첫 실행 시 "이미 덕링 계정이 있나요?" 분기를 넣어 4번 흐름으로
로그인시키세요. 안 넣으면 "내 캐릭터가 없다" 문의가 옵니다.

---

## 7. 병합 처리 (게임 서버가 알아야 할 전부)

플레이어가 실수로 계정을 2개 만들면 덕링에서 병합합니다. 규칙:

- 양쪽에 **같은 게임 캐릭터가 둘 다** 있으면 덕링이 병합을 **거부**합니다
  (플레이어에게 "방향 바꾸기" 또는 "게임에서 캐릭터 삭제"를 안내)
- 한쪽에만 있으면 옮겨집니다. **게임당 평생 1회**
- 병합 후 **옛 계정은 게임 로그인이 차단**됩니다 (`authorize`/`token`/`userinfo` 전부 거부)

```
병합 전   sub=A, game_user_tag=char-1   ← 플레이 중
병합 후   sub=B, game_user_tag=char-1   ← 같은 캐릭터, 새 sub
          → users.duckring_user_id 를 A → B 로 갱신  (4-4 의 2번 분기)
```

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

---

## 8. 에러 처리

| HTTP | error | 원인 / 당신이 할 일 |
| --- | --- | --- |
| 400 | `invalid_request` | PKCE 누락/파라미터 오류 → 요청 재점검 |
| 400 | `invalid_grant` | 코드 만료(10분)·재사용, `redirect_uri` 불일치, `code_verifier` 불일치, **또는 병합으로 은퇴한 계정** → 재로그인 유도 |
| 401 | `invalid_client` | `client_id`/`client_secret` 오류 |
| 401 | `invalid_token` | `access_token` 만료(1시간) → refresh |
| 403 | `unauthorized_client` | 퍼블릭 클라이언트로 `/steam/ticket` 호출 |
| 400 평문 | — | `client_id` 미등록 또는 `redirect_uri` 불일치 (리다이렉트 없이 평문 응답) |

재사용된 인가 코드를 보내면 **그 플레이어의 토큰이 전부 폐기**됩니다. 콜백을 두 번
처리하지 않도록 `state` 를 단회용으로 소비하세요.

---

## 9. 자체 검증 절차 (구현 후 이 순서로 확인)

1. `curl https://www.duckring.com/.well-known/openid-configuration` → 200, 엔드포인트 확인
2. 인가 URL 을 브라우저로 열기 → 로그인/동의 화면 → `redirect_uri` 로 `code` 수신
3. `/token` 교환 → `access_token` + (기밀이면) `id_token` 수신
4. `id_token` 디코드 → `iss`/`aud`/`exp`/`sub` 확인, **서명 검증이 통과하는지**
5. 같은 `code` 로 한 번 더 `/token` → `invalid_grant` 가 나와야 정상
6. `code_verifier` 를 일부러 틀리게 → `invalid_grant` 가 나와야 정상
7. `redirect_uri` 를 한 글자 바꿔 인가 요청 → 평문 400 이 나와야 정상
8. `/userinfo` 를 `access_token` 으로 호출 → `sub` 가 4번과 같은지
9. 다른 소셜(구글 ↔ 스팀)로 다시 로그인 → **`sub` 가 같은지** ← 통합의 핵심
10. DB 확인: 유저 행이 **1개**인지 (플랫폼별로 늘어나지 않았는지)
11. `nonce` 를 보냈다면 `id_token` 의 `nonce` 가 그 값인지

9번과 10번이 통과하지 않으면 구현이 틀렸습니다. 다른 게 다 돌아도 의미가 없습니다.

---

## 10. 완료 조건 체크리스트

- [ ] `client_secret` 이 서버 환경변수에만 있다 (저장소·클라이언트 빌드에 없음)
- [ ] `redirect_uri` 가 등록값과 글자 단위로 같다
- [ ] 요청마다 `state`·`code_verifier` 를 새로 만들고, 콜백에서 `state` 를 단회 소비한다
- [ ] 기밀: `id_token` 을 HS256 + `iss`/`aud`/`exp` 로 검증한다 (`algorithms` 고정)
- [ ] 퍼블릭: `id_token` 대신 `/userinfo` 로 확인한다
- [ ] `users.duckring_user_id` UNIQUE 컬럼을 만들고 그걸로 조회한다
- [ ] 플랫폼별로 유저 행을 나누지 않았다
- [ ] `game_user_tag` 로 기존 유저를 찾아 `sub` 를 갱신하는 분기가 있다 (병합 대응)
- [ ] `refresh_token` 로테이션 결과(새 토큰)를 저장한다
- [ ] 스팀판: 티켓 검증을 **서버에서만** 호출한다
- [ ] `linked_providers` 가 스팀 하나뿐인 플레이어에게 소셜 추가 연결을 안내한다
- [ ] 스팀판 첫 실행 시 "이미 덕링 계정이 있나요?" 분기가 있다
- [ ] 신규/이벤트 보상을 계정당으로 주지 않는다 (다계정 농사 차단)
- [ ] 9장 검증 10단계를 전부 통과했다

---

## 11. 막혔을 때

덕링 홈페이지 `/contact` 으로 `client_id` + 실패한 요청 시각 + 받은 `error` 를 보내면
서버 로그로 추적합니다. 추측해서 우회 구현하지 말고 물어보세요.
