토큰
DJ 의 동의로 받은 code 를 access token 으로 바꾸고, 만료 전에 갱신하고, 필요하면 폐기해요.
봇 API 를 부르려면 먼저 여기서 토큰을 받아야 해요.
RFC 6749(OAuth 2.0) 와 RFC 7009(폐기)를 따라요.
공통 규칙
POST $SPOON_BASE_URL/v1/oauth/token
POST $SPOON_BASE_URL/v1/oauth/revoke
| 이름 | 위치 | 필수 | 설명 |
|---|---|---|---|
Content-Type | header | ✔ | application/x-www-form-urlencoded 이에요. JSON 이 아니에요. |
클라이언트 인증
두 방법이 있고 Basic 헤더가 우선이에요.
Authorization: Basic base64(client_id:client_secret)
헤더가 없으면 form 의 client_id · client_secret 으로 넘어가요. 둘 다 없으면
401 invalid_client 이에요. client_id 는 UUID 형식이어야 하고, 아니면 역시
401 invalid_client 이에요.
401 응답에는 WWW-Authenticate: Basic realm="oauth" 헤더가 붙어요.
Client Secret 을 쓰는 호출이라 서버에서만 불러요. 브라우저나 모바일 앱 배포본에서 부르면 Secret 이 노출돼요.
토큰 발급
동의 후 받은 code 를 토큰 한 쌍으로 바꿔요.
POST /v1/oauth/token
| form 필드 | 필수 | 설명 |
|---|---|---|
grant_type | ✔ | authorization_code |
code | ✔ | 동의 후 받은 1회용 값이에요. 60초 안에 써야 해요. |
redirect_uri | ✔ | 동의 요청에 쓴 것과 같아야 해요. |
curl -X POST "$SPOON_BASE_URL/v1/oauth/token" \
-u "{Client ID}:{Client Secret}" \
-d "grant_type=authorization_code" \
-d "code={받은 code}" \
-d "redirect_uri={로그인 리디렉션 URL}"
// refresh.ts — node --experimental-strip-types refresh.ts
import { writeFile } from 'node:fs/promises';
interface Token {
access_token: string;
token_type: string;
expires_in: number;
refresh_token: string;
scope: string;
}
interface OauthError {
error: string;
error_description: string;
}
const basic = Buffer.from(
`${process.env.SPOON_CLIENT_ID}:${process.env.SPOON_CLIENT_SECRET}`,
).toString('base64');
const res = await fetch(`${process.env.SPOON_BASE_URL}/v1/oauth/token`, {
method: 'POST',
headers: {
Authorization: `Basic ${basic}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: process.env.SPOON_REFRESH_TOKEN ?? '',
}),
});
if (!res.ok) {
const { error, error_description } = (await res.json()) as OauthError;
throw new Error(`갱신 실패 ${error}: ${error_description}`);
}
const token = (await res.json()) as Token;
await writeFile('token.json', JSON.stringify(token, null, 2)); // 새 refresh_token 을 반드시 저장
console.log(`scope: ${token.scope}`); // 갱신 때마다 확인
export SPOON_CLIENT_ID="{Client ID}"
export SPOON_CLIENT_SECRET="{Client Secret}"
export SPOON_REFRESH_TOKEN="{받은 refresh_token}"
node --experimental-strip-types refresh.ts
code 를 두 번 쓰면 그 연동의 토큰이 전부 폐기돼요같은 code 로 두 번 요청하면 탈취 신호로 보고, 그 연동으로 발급된 토큰을 전량 폐기해요.
응답은 첫 실패와 똑같은 invalid_grant 라서 겉으로는 구분되지 않지만, 결과는 훨씬 무거워요.
네트워크 오류로 응답을 못 받았을 때 같은 code 로 재시도하지 마세요. 그 code 는
버리고 동의부터 다시 받아요.
토큰 갱신
access token 이 만료되기 전에 새 한 쌍을 받아요.
POST /v1/oauth/token
| form 필드 | 필수 | 설명 |
|---|---|---|
grant_type | ✔ | refresh_token |
refresh_token | ✔ | 마지막으로 받은 refresh token 이에요. |
curl -X POST "$SPOON_BASE_URL/v1/oauth/token" \
-u "{Client ID}:{Client Secret}" \
-d "grant_type=refresh_token" \
-d "refresh_token={받은 refresh_token}"
갱신하면 옛 refresh token 은 즉시 폐기되고 새 access·refresh 한 쌍이 나와요.
응답의 새 refresh_token 을 저장하지 않으면, 다음 갱신에서 400 invalid_grant 로
봇이 죽어요. 갱신은 성공했는데 그 다음이 안 되는 형태라 원인을 찾기 어려워요.
옛 access token 은 함께 폐기하지 않고 자연 만료에 맡겨요. 그래서 갱신 직후 잠깐은 두 access token 이 모두 유효해요.
갱신할 때마다 scope 를 확인해요. 응답의 scope 는 그 시점 DJ 의 동의 기준이에요. DJ 가 동의를 줄이면 다음 갱신에
자동으로 반영돼요. 발급 때의 스코프를 계속 갖고 있다고 가정하면, 이미 빠진 권한으로
API 를 불러 403 을 받아요.
갱신 시점을 여유 있게 잡아요
만료 직전에 갱신하면 워커가 잠깐 멈췄다 돌아왔을 때 이미 늦어요. 만료 1시간 전처럼 마진을 크게 두고, 갱신 결과는 재시작해도 남도록 저장해요. 메모리에만 두면 프로세스를 다시 띄울 때마다 DJ 에게 다시 동의를 받아야 해요.
응답 (발급 · 갱신 공통)
필드 이름은 snake_case 로 고정이에요.
{
"access_token": "at_3f9c1e7b8d2a4056...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "rt_7b2e5a9c4f10d386...",
"scope": "events.chat chat.send"
}
| 필드 | 타입 | 설명 |
|---|---|---|
access_token | string | 봇 API 호출에 쓰는 값이에요. |
token_type | string | 항상 Bearer 예요. |
expires_in | number | access token 의 남은 수명이에요. 초 단위이고 기본 1시간(3600) 이에요. |
refresh_token | string | 다음 갱신에 쓰는 값이에요. 기본 수명은 30일 이에요. |
scope | string | DJ 가 동의한 권한이에요. 공백으로 구분돼요. |
이후 모든 봇 API 는 이렇게 불러요.
Authorization: Bearer {access_token}
토큰 폐기
발급받은 토큰을 무효화해요.
POST /v1/oauth/revoke
| form 필드 | 필수 | 설명 |
|---|---|---|
token | ✔ | access token 이든 refresh token 이든 돼요. |
token_type_hint | 받기는 하지만 무시해요. 토큰 문자열로 직접 판별해요. |
curl -X POST "$SPOON_BASE_URL/v1/oauth/revoke" \
-u "{Client ID}:{Client Secret}" \
-d "token={폐기할 토큰}"
성공하면 본문 없이 200 이에요.
- 없는 토큰이나 남의 토큰을 보내도
200이에요. 토큰이 존재하는지를 밖으로 알리지 않기 위한 규칙이에요(RFC 7009). 그러니200이 "그 토큰이 있었다" 를 뜻하지 않아요. - refresh token 을 폐기하면 같은 연동의 access token 도 함께 폐기돼요.
폐기는 "연동 해제" 가 아니에요. DJ 의 동의(grant)는 그대로 남아요. 토큰만 무효가 되므로, 다시 동의를 받지 않아도 동의 화면 을 거쳐 새 토큰을 받을 수 있어요.
DJ 가 연동 자체를 끊는 것은 별개예요. 그때는 토큰이 무효가 되고 재동의도 필요해요.
에러
형식이 다른 봇 API 와 달라요. 봇 런타임 API 는 {"status", "detailCode", "message"} 인데, OAuth 엔드포인트는
RFC 6749 §5.2 형식이에요. 에러 코드 문서의 detailCode 는 여기 안 나와요.
{
"error": "invalid_grant",
"error_description": "invalid, expired or already used code"
}
error | HTTP | 언제 나나요 | 어떻게 하나요 |
|---|---|---|---|
invalid_request | 400 | 필수 form 필드가 빠졌어요 (grant_type · code · redirect_uri · refresh_token · token). | 요청 형식을 고쳐요. |
invalid_client | 401 | 클라이언트 인증에 실패했거나, client_id 형식이 틀렸거나, 앱이 정지됐어요. | 자격 증명과 앱 상태를 확인해요. |
invalid_grant | 400 | code 가 만료·이미 사용됨 · 다른 앱에 발급된 code · redirect_uri 불일치 · refresh token 이 무효·만료됐어요. | code 는 동의부터 다시 받아요. refresh 가 무효면 DJ 에게 재연동을 안내해요. |
unsupported_grant_type | 400 | authorization_code · refresh_token 외의 값을 보냈어요. | 둘 중 하나로 고쳐요. |
error_description 으로 분기하지 마세요. 사람이 읽는 설명이라 문구가 바뀔 수 있어요.
분기는 error 로 해요.
invalid_scope 는 여기가 아니라 동의 단계 에서 나와요.