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폼 필드를 둘 다 받는다. 한 요청에 두 방식을 함께 보내면 400invalid_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 토큰은 멀쩡하다. 순서는 이렇다.
- API 에서 401 을 받으면 먼저 갱신을 시도한다.
- 갱신이 성공하면 새 access token 으로 원래 요청을 다시 보낸다.
- 갱신이 실패하면 실패의 종류를 가른다.
| 갱신 실패 | 대응 |
|---|---|
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는 보내지 않는다. 보내면 400invalid_request다. 응답의scope는 사용자가 대상 워크스페이스에서 이 앱에 동의한 범위다.- 대상 워크스페이스에 동의가 없거나, 사용자가 그 워크스페이스에 속하지 않거나,
subject_token이 유효하지 않으면 사유를 구분하지 않고 400invalid_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 를 넣는다.
-
디바이스 코드를 요청한다.
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 에서 너무 자주 요청하면 429slow_down이다. -
사용자에게
verification_uri와user_code를 보여 준다(또는verification_uri_complete를 QR 코드로). 사용자는 다른 기기의 브라우저에서 승인한다. -
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가 없으면 403insufficient_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초) |