DillieDocs
dillie.work

OAuth 연동 가이드

dillie 사용자를 대신해 API 를 호출하는 앱을 만든다. 앱 등록부터 토큰 폐기까지 일곱 단계를 따르고, 브라우저가 없는 기기라면 디바이스 흐름을 쓴다.

사전 요구사항

PKCE 는 선택이 아니다. dillie 는 OAuth 2.1 을 따르므로 모든 클라이언트가 code_challenge 를 보내야 한다 — 시크릿을 가진 confidential 클라이언트도 예외가 아니다. 그리고 code_challenge_method 는 S256 만 받는다. plain 은 중간자가 challenge 를 그대로 재사용할 수 있어 보호가 되지 않는다.

리다이렉트 주소는 정확히 일치해야 한다. 접두 매칭이나 부분 매칭은 없다. 서버는 주소를 URL 로 파싱한 뒤 스킴·호스트·포트·경로·쿼리를 비교한다(호스트 대소문자와 기본 포트 표기만 정규화된다). 예외는 하나뿐이다 — http 스킴의 127.0.0.1 과 [::1] 은 포트만 무시한다. 네이티브 앱이 실행 시점에 임의 포트를 열기 때문이다. localhost 는 등록 자체가 거부되므로 루프백에는 127.0.0.1 을 쓴다.

한 토큰은 한 워크스페이스에 속한다. 사용자가 여러 워크스페이스에 속해 있어도 토큰 하나는 그중 하나에서만 유효하다. 다른 워크스페이스의 토큰은 브라우저 없이 토큰 교환으로 받는다(6단계). 그 워크스페이스에서 아직 앱에 동의하지 않았다면 workspace_hint 를 붙여 다시 인가한다(2단계).

요청 본문 형식을 맞춘다. /oauth/token·/oauth/revoke·/oauth/introspect·/oauth/device_authorization 은 application/x-www-form-urlencoded 만 받는다. JSON 으로 보내면 400 invalid_request 다. /oauth/register 만 JSON 을 받는다.

1. 앱 등록하기

POST /oauth/register 로 등록한다(RFC 7591 동적 클라이언트 등록).

POST /oauth/register HTTP/1.1
Host: dillie.work
Content-Type: application/json

{
  "client_name": "내 앱",
  "redirect_uris": ["https://myapp.example.com/callback"],
  "token_endpoint_auth_method": "none",
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"],
  "scope": "g8:oauth:projects:project:read g8:oauth:tasks:task:read"
}

응답에서 저장할 것:

필드 쓰임
client_id 이후 모든 요청에 실린다
registration_access_token 등록 정보를 나중에 조회·수정·삭제할 때 쓴다(registration_client_uri). 이 응답에서 한 번만 온다
client_secret confidential 클라이언트만 받는다. 다시 볼 수 없다

위 예시처럼 token_endpoint_auth_method: none 으로 등록하면 응답에 client_secret 이 없다 — 누락이 아니라 정상이다. 시크릿을 받은 경우 client_secret_expires_at 이 0 이면 만료가 없다는 뜻이다.

scope 와 grant_types 는 생략하지 않는다. 이 두 값이 앱이 나중에 요청할 수 있는 상한이다.

  • scope 를 생략하면 허용 스코프가 비어 모든 인가가 invalid_scope 로 거절된다. OIDC 스코프(openid 등)도 여기에 넣어야 요청할 수 있다.
  • grant_types 를 생략하면 authorization_code 만 등록된다. 그러면 refresh 토큰을 받지 못한다. 갱신·디바이스 흐름·토큰 교환을 쓰려면 refresh_token·urn:ietf:params:oauth:grant-type:device_code·urn:ietf:params:oauth:grant-type:token-exchange 를 각각 넣는다.

등록 값의 제약:

  • redirect_uris 는 최대 10개. https, http://127.0.0.1·http://[::1], 역도메인 형식의 전용 스킴(com.example.myapp:/callback)만 받는다. fragment(#)와 * 는 쓸 수 없다.
  • client_name 은 100자 이하. logo_uri·client_uri·policy_uri·tos_uri 는 https 여야 한다.
  • 같은 IP 에서 등록을 너무 자주 하면 429 다.
  • 한 번도 인가 코드를 받지 못한 등록은 일정 기간(기본값 14일)이 지나면 삭제된다.

public 과 confidential 고르기

token_endpoint_auth_method 로 정한다.

  • none (public) — 브라우저 SPA·모바일 앱·데스크톱 앱처럼 시크릿을 숨길 수 없는 곳. 시크릿을 발급하지 않는다. 코드 탈취는 PKCE 가 막는다.
  • client_secret_basic (confidential) — 서버에서 도는 앱. 생략하면 이 값으로 등록된다. 등록 응답의 token_endpoint_auth_method 는 confidential 이면 늘 client_secret_basic 이지만, 토큰 요청에서는 Authorization: Basic 과 client_secret 폼 필드를 둘 다 받는다. 한 요청에 두 방식을 함께 보내면 400 invalid_request 다.

시크릿을 사용자 기기에 두는 순간 그것은 시크릿이 아니다. 판단이 서지 않으면 none 을 고르고 PKCE 에 의존한다.

등록 정보 수정하기

registration_client_uri 에 Authorization: Bearer <registration_access_token> 을 붙여 GET·PUT·DELETE 한다.

PUT 은 전체 교체다. 보내지 않은 필드는 지워진다. 그래서 조회한 등록 정보에서 바꿀 필드만 고쳐 통째로 보낸다.

  • client_id 는 반드시 넣는다.
  • registration_access_token·registration_client_uri·client_secret_expires_at·client_id_issued_at 을 넣으면 거부된다.
  • client_secret 을 넣으려면 현재 값과 같아야 한다.

등록에서 스코프나 grant 를 빼면 이미 받은 토큰도 그 범위로 좁혀지지 않고 거절된다 — 다음 갱신이 invalid_scope(스코프) 또는 unauthorized_client(grant) 로 실패한다.

2. 인가 화면으로 보내기

code_verifier 를 만들고 그 해시를 code_challenge 로 보낸다.

// base64url 은 브라우저 내장이 아니다. 패딩(`=`) 없이 URL-safe 로 인코딩한다.
function base64url(bytes) {
  const binary = String.fromCharCode(...new Uint8Array(bytes));
  return btoa(binary)
    .replace(/\+/g, "-")
    .replace(/\//g, "_")
    .replace(/=+$/, "");
}

// 43~128자, [A-Za-z0-9-._~] 만 쓴다 (RFC 7636 §4.1)
const codeVerifier = base64url(crypto.getRandomValues(new Uint8Array(32)));
const codeChallenge = base64url(
  await crypto.subtle.digest("SHA-256", new TextEncoder().encode(codeVerifier)),
);

code_verifier 는 토큰 교환까지 보관한다. 잃어버리면 코드를 토큰으로 바꿀 수 없다.

GET /oauth/authorize
  ?client_id=<발급받은 client_id>
  &redirect_uri=https://myapp.example.com/callback
  &response_type=code
  &scope=g8%3Aoauth%3Aprojects%3Aproject%3Aread%20g8%3Aoauth%3Atasks%3Atask%3Aread
  &state=<CSRF 방지용 난수>
  &code_challenge=<위에서 만든 값>
  &code_challenge_method=S256

scope 는 공백으로 구분한다(URL 인코딩하면 %20). 등록할 때 scope 에 넣은 것만 요청할 수 있다. state 는 돌아온 요청이 내가 보낸 것인지 확인하는 값이라 반드시 검증한다. openid 를 요청한다면 nonce 도 함께 보내고 id_token 의 nonce 와 대조한다.

사용자는 동의 화면에서 앱 이름과 요청한 권한을 보고 허용 여부를 정한다. 권한은 전체를 허용하거나 전체를 거부한다. 동의 화면은 10분 안에 결정해야 한다.

  • 허용하면 redirect_uri 로 code 와 state 가 붙어 돌아온다.
  • 거부하면 error=access_denied 와 state 가 붙어 돌아온다.
  • 이미 동의한 스코프만 요청하면 동의 화면 없이 바로 code 가 돌아온다.
  • 추가 스코프를 요청하면 동의 화면이 다시 뜨고, 허용한 동의는 기존 부여분과 합쳐진다.

검증을 거치지 않은 새 앱은 동의 화면에 미검증 앱 경고가 붙는다.

client_id 나 redirect_uri 가 틀리면 리다이렉트되지 않는다. 이 둘이 검증에 실패하면 dillie 는 redirect_uri 로 돌아가지 않고 HTTP 400 과 error·error_description 을 담은 JSON 으로 응답한다. 등록되지 않았거나 사용 중지된 클라이언트는 unauthorized_client, 등록 값과 다른 redirect_uri 는 invalid_request 다. 검증되지 않은 주소로 에러를 보내는 것 자체가 오픈 리다이렉터가 되기 때문이다(RFC 6749 §4.1.2.1). 콜백이 아예 안 오면 이 경우를 의심한다.

워크스페이스 지정하기

토큰이 묶일 워크스페이스를 미리 고르려면 workspace_hint 에 워크스페이스 ID 를 넣는다.

GET /oauth/authorize
  ?client_id=<client_id>
  ...
  &workspace_hint=<워크스페이스 ID (UUID)>
  • 값은 UUID 다(대소문자 무관). 형식이 틀리면 redirect_uri 로 error=invalid_request 가 돌아온다.
  • 동의 화면이 그 워크스페이스를 기본으로 선택한다. 사용자는 동의 화면의 워크스페이스 선택기에서 바꿀 수 있다.
  • 힌트가 사용자의 현재 워크스페이스와 다르면, 이미 동의한 스코프여도 동의 화면을 띄운다.
  • 사용자가 그 워크스페이스에 속하지 않으면 오류 없이 현재 워크스페이스로 진행한다.

그래서 토큰이 어느 워크스페이스에 묶였는지는 앱이 확인한다. access token(JWT) 또는 id_token 의 tid 클레임이 그 워크스페이스 ID 다. 워크스페이스 ID 목록은 g8:oauth:workspaces:workspace:read 스코프로 GET /api/v1/access-service/me/tenants 를 호출해 얻는다.

3. 코드를 토큰으로 교환하기

POST /oauth/token HTTP/1.1
Host: dillie.work
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=<받은 코드>
&redirect_uri=https://myapp.example.com/callback
&code_verifier=<2단계에서 보관한 값>
&client_id=<client_id>

confidential 클라이언트는 client_secret 을 폼에 넣거나 Authorization: Basic 으로 보낸다(둘 중 하나만).

인가 코드는 60초 안에 한 번만 쓴다. 같은 코드를 다시 제출하면 거절될 뿐 아니라 그 코드로 이미 발급된 토큰도 전부 폐기된다. 코드 교환은 재시도하지 않는다.

redirect_uri 는 등록한 문자열과 바이트 단위로 같아야 한다. 서버는 등록할 때 주소를 정규화해 저장하고(호스트 소문자, 경로가 없으면 / 를 붙인다), 루프백 주소는 인가 요청 값을 같은 방식으로 정규화해 기억한다. 그래서 http://127.0.0.1:53123 처럼 경로 없이 보내면 서버가 기억한 값은 http://127.0.0.1:53123/ 이 되어, 같은 원문으로 교환해도 invalid_grant 다. 주소는 처음부터 정규형으로 등록하고(루프백에도 /callback 같은 경로를 붙인다) 인가 요청과 토큰 요청에 그 문자열을 그대로 쓴다.

응답:

{
  "access_token": "...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "...",
  "scope": "g8:oauth:projects:project:read g8:oauth:tasks:task:read"
}
  • refresh_token 은 등록할 때 grant_types 에 refresh_token 을 넣은 앱만 받는다.
  • expires_in 은 access token 의 남은 초다. 기본값은 1시간이지만 값을 고정해 두지 말고 응답을 읽는다.
  • openid 스코프를 요청했다면 id_token 도 함께 온다.

응답의 scope 를 기준으로 동작한다. 갱신할 때 축소를 요청했거나 토큰 교환으로 받은 토큰은 요청과 다른 범위를 가질 수 있다. 요청한 값이 아니라 응답에 실린 값이 그 토큰의 범위다.

4. API 호출하기

API 경로는 /api/v1/<서비스>-service/... 형태다.

GET /api/v1/public-api-service/projects HTTP/1.1
Host: dillie.work
Authorization: Bearer <access_token>

실효 권한은 스코프 ∩ 사용자 권한 이다. 사용자가 허용한 스코프가 상한일 뿐, 그 사용자가 애초에 갖지 못한 권한은 앱도 갖지 못한다. 그래서 같은 스코프라도 사용자에 따라 403 이 날 수 있다 — 이것은 정상이며 토큰 문제가 아니다.

호출 한도 지키기

앱의 호출이 한도를 넘으면 429 가 온다. 응답에는 Retry-After 와 RateLimit-Limit·RateLimit-Remaining·RateLimit-Reset 헤더가 붙는다. RateLimit-Remaining 을 보고 속도를 줄이고, 429 를 받으면 Retry-After 만큼 기다린 뒤 다시 보낸다.

5. 토큰 갱신하기

access_token 이 만료되면(expires_in) refresh 토큰으로 새로 받는다.

POST /oauth/token HTTP/1.1
Content-Type: application/x-www-form-urlencoded

grant_type=refresh_token
&refresh_token=<저장해 둔 값>
&client_id=<client_id>

confidential 클라이언트는 여기에도 시크릿을 함께 보낸다(client_secret 폼 필드 또는 Authorization: Basic). 빠뜨리면 401 invalid_client 다 — 토큰이 멀쩡해도 클라이언트 인증에서 걸린다.

응답에 담긴 새 refresh_token 으로 반드시 교체한다. dillie 는 갱신할 때마다 refresh 토큰을 회전시키고, 한 번 쓴 토큰은 다시 쓸 수 없게 된다. 교체를 빠뜨리면 다음 갱신이 옛 토큰을 보내게 된다.

refresh 토큰의 유효 기간은 기본값 14일이고, 회전할 때마다 그 시점부터 다시 센다.

옛 토큰이 다시 오면 서버는 바로 탈취로 보지 않는다. 정상 클라이언트도 중복 전송·타임아웃 재시도·병렬 호출로 같은 토큰을 두 번 보내기 때문이다. 기본값으로는 다음과 같다.

상황 결과
회전 후 30초 안, 재사용 5회 이하 정상 재시도로 보고 새 토큰을 발급한다(200)
30초 안이지만 5회 초과 400 invalid_grant. 연결은 유지된다
30초 밖 재사용 400 invalid_grant. 탈취로 판정해 그 연결에서 발급된 모든 토큰을 폐기한다

즉 한두 번의 재시도로 연결이 끊기지는 않는다. 그래도 교체는 원자적으로 한다 — 새 값을 저장한 뒤에 다음 요청을 보낸다. 여러 프로세스가 같은 자격증명을 공유한다면 갱신을 한 곳으로 모은다. 연결이 유지된 채 invalid_grant 를 받았다면 동시에 보낸 다른 갱신이 이미 새 토큰을 받은 것이다 — 재인가하지 말고 그 토큰을 쓴다.

scope 를 함께 보내면 더 좁은 범위로 줄일 수 있다(RFC 6749 §6). 줄인 범위는 새 refresh 토큰에 저장되어 그 연결에서는 다시 넓힐 수 없다. 원래 범위를 넘는 스코프나 빈 scope 를 보내면 invalid_scope 다.

등록 정보에서 스코프를 뺐다면 갱신이 실패한다. 이미 부여받은 스코프가 앱의 허용 목록에서 사라지면 서버는 조용히 좁혀 주지 않고 invalid_scope 로 거절한다. 그 경우 재인가가 필요하다 — 갱신 실패를 일시 오류로 재시도하지 않는다.

401 처리하기

401 을 곧바로 재인가로 해석하지 않는다. 가장 흔한 원인은 access_token 만료이고 그때 refresh 토큰은 멀쩡하다. 순서는 이렇다.

  1. API 에서 401 을 받으면 먼저 갱신을 시도한다.
  2. 갱신이 성공하면 새 access token 으로 원래 요청을 다시 보낸다.
  3. 갱신이 실패하면 실패의 종류를 가른다.
갱신 실패 대응
invalid_grant — 토큰이 폐기·만료됐거나 사용자가 연결을 끊었다 재인가. 단 동시 갱신이 있었다면 위 표를 먼저 확인한다
invalid_scope — 등록에서 스코프가 빠졌다 재인가
server_error(503)·네트워크 오류·타임아웃·429 재시도. 재인가가 아니다

일시 오류를 재인가로 몰지 않는다. 서버가 잠깐 흔들렸을 뿐인데 사용자를 동의 화면으로 보내면 멀쩡한 연결을 버리는 것이다.

6. 다른 워크스페이스 토큰 받기

이미 가진 refresh 토큰으로 같은 사용자의 다른 워크스페이스 토큰을 브라우저 없이 받는다(RFC 8693 토큰 교환). 등록할 때 grant_types 에 urn:ietf:params:oauth:grant-type:token-exchange 를 넣어야 한다 — 없으면 unauthorized_client 다.

POST /oauth/token HTTP/1.1
Content-Type: application/x-www-form-urlencoded

grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&subject_token=<refresh 토큰>
&subject_token_type=urn:ietf:params:oauth:token-type:refresh_token
&audience=<대상 워크스페이스 ID (UUID)>
&client_id=<client_id>
  • scope 는 보내지 않는다. 보내면 400 invalid_request 다. 응답의 scope 는 사용자가 대상 워크스페이스에서 이 앱에 동의한 범위다.
  • 대상 워크스페이스에 동의가 없거나, 사용자가 그 워크스페이스에 속하지 않거나, subject_token 이 유효하지 않으면 사유를 구분하지 않고 400 invalid_target 이다. 그때는 workspace_hint 에 그 워크스페이스 ID 를 넣어 다시 인가한다(2단계).
  • subject_token 은 소비되지 않는다. 교환은 연결을 하나 더 만들므로 원래 연결이 더 필요 없으면 7단계로 폐기한다.

워크스페이스 ID 는 GET /api/v1/access-service/me/tenants(g8:oauth:workspaces:workspace:read)로 얻는다.

7. 토큰 폐기하기

POST /oauth/revoke HTTP/1.1
Content-Type: application/x-www-form-urlencoded

token=<access 또는 refresh 토큰>
&token_type_hint=refresh_token
&client_id=<client_id>

여기서도 confidential 클라이언트는 시크릿을 함께 보낸다.

refresh_token grant 로 등록한 앱이면 폐기되는 것은 그 토큰의 연결뿐이고 사용자의 동의는 남는다. 같은 스코프로 다시 /oauth/authorize 를 부르면 동의 화면 없이 코드가 나온다.

refresh_token grant 없이 등록한 앱은 다르다. 끊을 연결이 따로 없어서, access token 을 폐기하면 그 사용자가 그 워크스페이스에서 이 앱에 준 동의가 철회되고 그 동의로 발급된 이 앱의 토큰도 모두 폐기된다. 다시 쓰려면 동의 화면을 거쳐 인가를 다시 받아야 한다.

사용자도 dillie 설정에서 앱 연결을 끊어 동의를 철회할 수 있다. 그 경우 앱에 통보가 가지 않는다.

클라이언트 인증을 통과하고 요청 형식이 맞으면 200 이 온다. 토큰이 없었거나 이미 폐기됐거나 남의 것이어도 마찬가지다(RFC 7009 §2.2) — 응답으로 토큰의 존재 여부를 알아낼 수 없게 하기 위해서다. 그러니 200 을 “내 토큰이 폐기됐다” 는 증거로 쓰지 말고, 폐기 요청을 보냈다는 사실만 기록한다. token 이 비어 있거나 폼 인코딩이 아니면 400 invalid_request 다.

폐기한 뒤에는 그 연결의 access token 도 앱에서 버린다. 이미 발급된 access token 이 만료 전까지 받아들여질 수 있다.

토큰 검사하기

POST /oauth/introspect 는 confidential 클라이언트만 쓸 수 있다(public 클라이언트는 401). 클라이언트 인증은 client_secret_basic 또는 client_secret_post 다. 자기에게 발급된 토큰만 active: true 로 답한다. GET 은 받지 않는다 — 토큰이 서버 접근 로그에 남기 때문이다.

브라우저 없는 기기 연결하기

CLI·TV·임베디드 기기처럼 브라우저를 열 수 없는 클라이언트는 디바이스 흐름(RFC 8628)을 쓴다. 등록할 때 grant_types 에 urn:ietf:params:oauth:grant-type:device_code 를 넣는다.

  1. 디바이스 코드를 요청한다. workspace_hint 도 받는다.

    POST /oauth/device_authorization HTTP/1.1
    Host: dillie.work
    Content-Type: application/x-www-form-urlencoded
    
    client_id=<client_id>
    &scope=g8:oauth:tasks:task:read

    응답에는 device_code·user_code·verification_uri(https://dillie.work/device)·verification_uri_complete·expires_in(600)·interval(5) 이 온다. 같은 IP 에서 너무 자주 요청하면 429 slow_down 이다.

  2. 사용자에게 verification_uri 와 user_code 를 보여 준다(또는 verification_uri_complete 를 QR 코드로). 사용자는 다른 기기의 브라우저에서 승인한다.

  3. interval 초마다 토큰을 폴링한다.

    POST /oauth/token HTTP/1.1
    Content-Type: application/x-www-form-urlencoded
    
    grant_type=urn:ietf:params:oauth:grant-type:device_code
    &device_code=<받은 device_code>
    &client_id=<client_id>
폴링 응답 대응
authorization_pending 사용자가 아직 승인하지 않았다. 같은 간격으로 계속 폴링
slow_down 너무 자주 폴링했다. 간격을 5초 늘린다
access_denied 사용자가 거부했다. 폴링을 멈춘다
expired_token expires_in 이 지났다. 1단계부터 다시
200 3단계와 같은 토큰 응답이다

사용자 정보 받기

openid 스코프로 OIDC 를 쓴다. 설정은 /.well-known/openid-configuration 에서 읽는다.

  • id_token 의 aud 는 client_id 이고, 토큰이 묶인 워크스페이스 ID 를 tid 에 싣는다. 서명은 /.well-known/jwks.json 의 공개 키로 검증한다.
  • profile·email·phone 클레임은 id_token 이 아니라 UserInfo 에서 받는다: GET 또는 POST /oauth/userinfo 에 Authorization: Bearer <access_token> 을 붙인다. 토큰에 openid 가 없으면 403 insufficient_scope 다.

스코프 목록

아래는 dillie API 스코프다. 여기에 더해 OIDC 표준 스코프 openid·profile·email·phone 을 요청할 수 있다 — 그것들은 신원 클레임만 열고 API 접근은 주지 않는다.

어느 스코프든 등록할 때 scope 에 넣은 것만 요청할 수 있다. 아래 목록에 있어도 등록에 없으면 invalid_scope 다.

스코프 범위
g8:oauth:profile:identity:read 이름·이메일 등 기본 프로필
g8:oauth:workspaces:workspace:read 소속 워크스페이스의 이름·식별자 목록
g8:oauth:projects:project:read / :write 프로젝트 조회 / 관리
g8:oauth:tasks:task:read / :write 작업 조회 / 관리
g8:oauth:tasks:task-channel:promote 작업 채널 공개 전환
g8:oauth:chat:message:read / :write 메시지 읽기 / 보내기
g8:oauth:chat:channel:manage 채널·참여자 관리
g8:oauth:docs:canvas:read / :write 문서 조회 / 편집
g8:oauth:docs:share:manage 문서 공유 관리
g8:oauth:docs:share:publish 문서 공개 링크 발행
g8:oauth:files:file:read / :write 파일 조회 / 관리
g8:oauth:files:file:share 파일·폴더 공유 부여
g8:oauth:calendar:event:read / :write 일정 조회 / 관리
g8:oauth:contacts:contact:read / :write 연락처 조회 / 관리
g8:oauth:contacts:external-contact:invite 외부 초대 발송
g8:oauth:meetings:meeting:read / :write 회의 조회 / 관리

필요한 것만 요청한다. 동의 화면은 요청한 권한을 그대로 보여주므로, 넓게 요청할수록 사용자가 거절할 이유가 늘어난다.

엔드포인트 요약

서버 설정은 https://dillie.work/.well-known/oauth-authorization-server 에서 기계로 읽을 수 있다. 주소를 코드에 박지 말고 이 문서를 읽는다.

용도 메서드 · 경로
서버 메타데이터 GET /.well-known/oauth-authorization-server
OIDC 설정 GET /.well-known/openid-configuration
앱 등록 POST /oauth/register
등록 조회·수정·삭제 GET·PUT·DELETE /oauth/register/{client_id}
인가 GET /oauth/authorize
디바이스 인가 POST /oauth/device_authorization
토큰 발급·갱신·교환 POST /oauth/token
토큰 폐기 POST /oauth/revoke
토큰 검사 POST /oauth/introspect
사용자 정보 GET·POST /oauth/userinfo
공개 키 GET /.well-known/jwks.json

문제 해결

오류는 error 코드로 분기한다. error_description 은 사람이 읽는 설명이고 문구가 바뀔 수 있다.

/oauth/authorize — 리다이렉트 없이 HTTP 400 JSON 으로 오는 것

코드 원인 대응
unauthorized_client client_id 가 없거나, 사용 중지·정지된 앱이다 client_id 와 등록 상태를 확인한다
invalid_request redirect_uri 가 등록 값과 다르다 등록한 문자열을 그대로 보낸다

/oauth/authorize — redirect_uri 로 돌아오는 것

코드 원인 대응
invalid_request PKCE 누락·S256 이 아님, 파라미터 형식, workspace_hint 형식 요청 파라미터를 고친다
unsupported_response_type response_type 이 code 가 아니다 response_type=code 를 보낸다
invalid_scope 등록하지 않은 스코프를 요청했다 등록 scope 에 넣거나 요청에서 뺀다
unauthorized_client 이 앱에 허용되지 않은 요청이다 등록의 grant_types·response_types 를 확인
access_denied 사용자가 거부했다 사용자에게 다시 시도할 수단을 준다

/oauth/token

코드 상태 원인 대응
invalid_request 400 필수 파라미터 누락, JSON 본문, 인증 방식 중복 요청을 고친다
invalid_client 401 클라이언트 인증 실패, 또는 사용 중지·정지·삭제된 앱 시크릿과 앱 상태를 확인한다
invalid_grant 400 코드·refresh 토큰이 만료·폐기·재사용됐거나 redirect_uri 불일치 재인가(5단계의 동시 갱신 경우는 예외)
unauthorized_client 400 등록하지 않은 grant 를 썼다 등록 grant_types 에 추가한다
unsupported_grant_type 400 지원하지 않는 grant_type 서버 메타데이터의 목록을 확인한다
invalid_scope 400 등록에서 빠진 스코프, 원래 범위를 넘는 축소 요청, 빈 scope 재인가 또는 요청을 고친다
invalid_target 400 토큰 교환 대상 워크스페이스에 동의·소속이 없거나 subject 가 무효 workspace_hint 를 붙여 재인가한다
server_error 503 서버가 일시적으로 판정하지 못했다 잠시 뒤 재시도. 토큰을 버리지 않는다

401 응답에는 WWW-Authenticate 헤더가 붙는다. 디바이스 흐름의 폴링 코드(authorization_pending·slow_down·expired_token·access_denied)는 위 디바이스 절의 표를 따른다.

시간 제한

대상 제한
인가 코드 60초, 한 번만
동의 화면 10분 안에 결정
access token 응답의 expires_in(기본값 1시간)
refresh 토큰 기본값 14일, 회전할 때마다 다시 센다
디바이스 코드 응답의 expires_in(600초)