본문으로 건너뛰기

토큰

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-Typeheader✔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 은 회전해요 — 응답의 새 값을 반드시 저장해요

갱신하면 옛 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_tokenstring봇 API 호출에 쓰는 값이에요.
token_typestring항상 Bearer 예요.
expires_innumberaccess token 의 남은 수명이에요. 초 단위이고 기본 1시간(3600) 이에요.
refresh_tokenstring다음 갱신에 쓰는 값이에요. 기본 수명은 30일 이에요.
scopestringDJ 가 동의한 권한이에요. 공백으로 구분돼요.

이후 모든 봇 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"
}
errorHTTP언제 나나요어떻게 하나요
invalid_request400필수 form 필드가 빠졌어요 (grant_type · code · redirect_uri · refresh_token · token).요청 형식을 고쳐요.
invalid_client401클라이언트 인증에 실패했거나, client_id 형식이 틀렸거나, 앱이 정지됐어요.자격 증명과 앱 상태를 확인해요.
invalid_grant400code 가 만료·이미 사용됨 · 다른 앱에 발급된 code · redirect_uri 불일치 · refresh token 이 무효·만료됐어요.code 는 동의부터 다시 받아요. refresh 가 무효면 DJ 에게 재연동을 안내해요.
unsupported_grant_type400authorization_code · refresh_token 외의 값을 보냈어요.둘 중 하나로 고쳐요.

error_description 으로 분기하지 마세요. 사람이 읽는 설명이라 문구가 바뀔 수 있어요. 분기는 error 로 해요.

invalid_scope 는 여기가 아니라 동의 단계 에서 나와요.

관련 문서​