OAuth Integration Guide
Build an app that calls the dillie API on a user’s behalf. Follow seven steps from registering the app to revoking tokens, and use the device flow on devices without a browser.
Prerequisites
PKCE is not optional. dillie follows OAuth 2.1, so every client must send a code_challenge — including confidential clients that hold a secret. Only S256 is accepted for code_challenge_method; plain offers no protection because an interceptor can replay the challenge as-is.
Redirect URIs must match exactly. No prefix or partial matching. The server parses the URI and compares scheme, host, port, path, and query (only host case and default-port notation are normalized). There is a single exception: 127.0.0.1 and [::1] with the http scheme ignore the port only, because native apps open an arbitrary port at run time. localhost is rejected at registration, so use 127.0.0.1 for loopback.
One token belongs to one workspace. Even if the user is a member of several, a token is valid in exactly one. Get a token for another workspace without a browser through token exchange (step 6). If the user has not yet consented to your app in that workspace, authorize again with workspace_hint (step 2).
Send the right body format. /oauth/token, /oauth/revoke, /oauth/introspect, and /oauth/device_authorization accept only application/x-www-form-urlencoded. A JSON body gets 400 invalid_request. Only /oauth/register takes JSON.
1. Registering the app
Register via POST /oauth/register (RFC 7591 Dynamic Client Registration).
POST /oauth/register HTTP/1.1
Host: dillie.work
Content-Type: application/json
{
"client_name": "My App",
"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"
}
Keep these from the response:
| Field | Used for |
|---|---|
client_id |
Sent with every later request |
registration_access_token |
Reading, updating, or deleting the registration later (registration_client_uri). Returned only in this response |
client_secret |
Issued to confidential clients only. You cannot view it again |
Registering with token_endpoint_auth_method: none, as above, means the response has no client_secret — that is expected, not an omission. If you did receive a secret, client_secret_expires_at of 0 means it does not expire.
Do not omit scope and grant_types. These two values are the ceiling on what the app can request later.
- If you omit
scope, the allowed set is empty and every authorization fails withinvalid_scope. OIDC scopes (openidand so on) must be listed here too. - If you omit
grant_types, onlyauthorization_codeis registered, and you get no refresh token. To use refresh, the device flow, or token exchange, addrefresh_token,urn:ietf:params:oauth:grant-type:device_code, orurn:ietf:params:oauth:grant-type:token-exchangerespectively.
Constraints on registration values:
- Up to 10
redirect_uris. Onlyhttps,http://127.0.0.1/http://[::1], and reverse-domain private-use schemes (com.example.myapp:/callback) are accepted. Fragments (#) and*are not allowed. client_nameis at most 100 characters.logo_uri,client_uri,policy_uri, andtos_urimust behttps.- Registering too often from the same IP returns 429.
- A registration that never receives an authorization code is deleted after a period (14 days by default).
Choosing public or confidential
token_endpoint_auth_method decides it.
none(public) — browser SPAs, mobile apps, desktop apps: anywhere that cannot keep a secret. No secret is issued; PKCE stops code interception.client_secret_basic(confidential) — apps running on a server. This is the default when omitted. The registration response always reportsclient_secret_basicfor confidential clients, but token requests accept bothAuthorization: Basicand theclient_secretform field. Sending both in one request is 400invalid_request.
A secret on the user’s device is not a secret. If unsure, choose none and rely on PKCE.
Updating the registration
Call GET, PUT, or DELETE on registration_client_uri with Authorization: Bearer <registration_access_token>.
PUT is a full replacement. Fields you leave out are deleted, so read the registration, change only what you need, and send the whole thing back.
client_idis required.- Including
registration_access_token,registration_client_uri,client_secret_expires_at, orclient_id_issued_atis rejected. client_secretmay be included only if it equals the current value.
Removing a scope or grant from the registration does not narrow tokens already issued — it rejects them. The next refresh fails with invalid_scope (scope) or unauthorized_client (grant).
2. Sending the user to authorize
Create a code_verifier and send its hash as code_challenge.
// base64url is not built into browsers. Encode URL-safe, without padding (`=`).
function base64url(bytes) {
const binary = String.fromCharCode(...new Uint8Array(bytes));
return btoa(binary)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
// 43–128 chars, [A-Za-z0-9-._~] only (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)),
);
Keep the code_verifier until the token exchange. Without it the code cannot be exchanged.
GET /oauth/authorize
?client_id=<your client_id>
&redirect_uri=https://myapp.example.com/callback
&response_type=code
&scope=g8%3Aoauth%3Aprojects%3Aproject%3Aread%20g8%3Aoauth%3Atasks%3Atask%3Aread
&state=<random value for CSRF protection>
&code_challenge=<value created above>
&code_challenge_method=S256
scope is space-separated (%20 when URL-encoded). You can request only scopes listed in the registration’s scope. state lets you confirm the returning request is one you sent, so always verify it. If you request openid, also send a nonce and compare it with the nonce in the id_token.
The user sees your app name and the requested permissions on a consent screen and decides. Permissions are allowed or denied as a whole. The user has 10 minutes to decide.
- If allowed, the user returns to
redirect_uriwithcodeandstate. - If denied, the user returns with
error=access_deniedandstate. - If every requested scope was already consented to, the
codecomes back without a consent screen. - If you request additional scopes, the consent screen appears again and the new consent is merged with what was already granted.
New apps that have not been verified show an unverified-app warning on the consent screen.
A bad
client_idorredirect_uridoes not redirect. If either fails validation, dillie does not return toredirect_uri; it responds with HTTP 400 and a JSON body carryingerroranderror_description. An unregistered or disabled client getsunauthorized_client; aredirect_urithat differs from the registration getsinvalid_request. Sending an error to an unvalidated address would itself make dillie an open redirector (RFC 6749 §4.1.2.1). If your callback never fires, suspect this case.
Choosing the workspace
To pre-select the workspace the token will be bound to, put its ID in workspace_hint.
GET /oauth/authorize
?client_id=<client_id>
...
&workspace_hint=<workspace ID (UUID)>
- The value is a UUID (case-insensitive). A malformed value returns
error=invalid_requesttoredirect_uri. - The consent screen pre-selects that workspace. The user can change it in the consent screen’s workspace picker.
- If the hint differs from the user’s current workspace, the consent screen appears even for scopes already consented to.
- If the user does not belong to that workspace, the flow continues in the current workspace without an error.
So your app must check which workspace the token is bound to. The tid claim in the access token (a JWT) or the id_token is that workspace ID. Get the list of workspace IDs by calling GET /api/v1/access-service/me/tenants with the g8:oauth:workspaces:workspace:read scope.
3. Exchanging the code for tokens
POST /oauth/token HTTP/1.1
Host: dillie.work
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=<the code you received>
&redirect_uri=https://myapp.example.com/callback
&code_verifier=<value kept from step 2>
&client_id=<client_id>
Confidential clients send client_secret either in the form or as Authorization: Basic (one, not both).
Use an authorization code once, within 60 seconds. Submitting the same code again is not only rejected — every token already issued from that code is revoked. Do not retry a code exchange.
redirect_uri must be byte-identical to what the server stored. The server normalizes URIs at registration (lowercase host, / appended when there is no path), and for loopback addresses it remembers the authorization request’s value normalized the same way. Sending http://127.0.0.1:53123 without a path therefore stores http://127.0.0.1:53123/, and exchanging with the same original string fails with invalid_grant. Register the URI in normalized form from the start (give loopback URIs a path such as /callback) and use that exact string in both the authorization and token requests.
Response:
{
"access_token": "...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "...",
"scope": "g8:oauth:projects:project:read g8:oauth:tasks:task:read"
}
refresh_tokenis returned only to apps that registeredrefresh_tokeningrant_types.expires_inis the access token’s remaining lifetime in seconds. The default is one hour, but read the response instead of hard-coding it.- If you requested the
openidscope, anid_tokenis included.
Act on the scope in the response. A token from a refresh that requested a narrower scope, or from token exchange, can carry a different range than you asked for. The value in the response, not the one you requested, is that token’s range.
4. Calling the API
API paths take the form /api/v1/<service>-service/....
GET /api/v1/public-api-service/projects HTTP/1.1
Host: dillie.work
Authorization: Bearer <access_token>
Effective permission is scope ∩ user permission. The scopes the user allowed are a ceiling; a permission the user never had is one the app cannot have either. So the same scope can yield 403 for some users — that is expected and not a token problem.
Staying within rate limits
When the app exceeds its limit, it gets 429. Responses carry Retry-After and the RateLimit-Limit, RateLimit-Remaining, and RateLimit-Reset headers. Slow down as RateLimit-Remaining drops, and on 429 wait for Retry-After before sending again.
5. Refreshing tokens
When the access_token expires (expires_in), get a new one with the refresh token.
POST /oauth/token HTTP/1.1
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token
&refresh_token=<stored value>
&client_id=<client_id>
Confidential clients must send the secret here too (client_secret form field or Authorization: Basic). Omitting it yields 401 invalid_client — client authentication fails even though the token is fine.
Always replace it with the new
refresh_tokenin the response. dillie rotates refresh tokens on every refresh, and a token that has been used cannot be used again. Skip the replacement and your next refresh sends the old token.
Refresh tokens last 14 days by default, counted again from each rotation.
When an old token comes back, the server does not immediately treat it as theft. Legitimate clients send the same token twice through duplicate sends, timeout retries, and parallel calls. With default settings:
| Situation | Result |
|---|---|
| Within 30 seconds of rotation, ≤ 5 reuses | Treated as a normal retry; new tokens are issued (200) |
| Within 30 seconds but more than 5 reuses | 400 invalid_grant. The connection is kept |
| Reuse after 30 seconds | 400 invalid_grant. Treated as theft; every token issued on that connection is revoked |
So one or two retries will not drop the connection. Still, replace atomically — store the new value before sending the next request. If several processes share the same credential, route refreshes through one place. If you get invalid_grant while the connection is still alive, another concurrent refresh already received new tokens — use those instead of reauthorizing.
Sending scope narrows the range (RFC 6749 §6). The narrowed range is stored in the new refresh token and cannot be widened again on that connection. A scope beyond the original range, or an empty scope, is invalid_scope.
Removing a scope from the registration makes refresh fail. If a granted scope disappears from the app’s allowed list, the server does not quietly narrow it — it rejects with
invalid_scope. Reauthorization is required; do not retry that failure as transient.
Handling 401
Do not read a 401 as “reauthorize” right away. The most common cause is an expired access_token, and the refresh token is still fine. Follow this order:
- On a 401 from the API, try a refresh first.
- If the refresh succeeds, resend the original request with the new access token.
- If the refresh fails, classify the failure.
| Refresh failure | Response |
|---|---|
invalid_grant — token revoked or expired, or the user disconnected |
Reauthorize. If refreshes ran concurrently, check the table above first |
invalid_scope — a scope was removed from the registration |
Reauthorize |
server_error (503), network error, timeout, 429 |
Retry. Not reauthorization |
Do not turn transient errors into reauthorization. Sending the user to the consent screen because the server hiccupped throws away a healthy connection.
6. Getting a token for another workspace
Use a refresh token you already hold to get a token for another workspace of the same user, without a browser (RFC 8693 token exchange). The registration’s grant_types must include urn:ietf:params:oauth:grant-type:token-exchange; otherwise you get 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 token>
&subject_token_type=urn:ietf:params:oauth:token-type:refresh_token
&audience=<target workspace ID (UUID)>
&client_id=<client_id>
- Do not send
scope; doing so is 400invalid_request. The response’sscopeis what the user consented to for this app in the target workspace. - If the target workspace has no consent, the user is not a member, or
subject_tokenis invalid, you get 400invalid_targetwithout distinguishing the reason. Then authorize again with that workspace ID inworkspace_hint(step 2). subject_tokenis not consumed. The exchange creates an additional connection, so revoke the original one (step 7) if you no longer need it.
Get workspace IDs from GET /api/v1/access-service/me/tenants (g8:oauth:workspaces:workspace:read).
7. Revoking tokens
POST /oauth/revoke HTTP/1.1
Content-Type: application/x-www-form-urlencoded
token=<access or refresh token>
&token_type_hint=refresh_token
&client_id=<client_id>
Confidential clients send the secret here as well.
For an app registered with the refresh_token grant, only that token’s connection is revoked; the user’s consent remains. Calling /oauth/authorize again with the same scopes returns a code without a consent screen.
An app registered without the refresh_token grant is different. There is no separate connection to cut, so revoking an access token withdraws the consent the user gave this app in that workspace and revokes every token of this app issued under it. To use the app again, the user has to go through the consent screen and authorize again.
The user can also withdraw consent by disconnecting the app in dillie settings. Your app is not notified when that happens.
If client authentication passes and the request is well-formed, you get 200. Even if the token never existed, was already revoked, or belongs to someone else (RFC 7009 §2.2) — so the response cannot reveal whether a token exists. Do not treat 200 as proof that your token was revoked; record only that you sent the request. An empty token or a body that is not form-encoded is 400 invalid_request.
After revoking, discard that connection’s access token in your app too. An access token already issued may still be accepted until it expires.
Inspecting tokens
POST /oauth/introspect is available to confidential clients only (public clients get 401). Client authentication is client_secret_basic or client_secret_post. Only tokens issued to the calling client are reported as active: true. GET is not accepted — it would leave tokens in server access logs.
Connecting devices without a browser
Clients that cannot open a browser — CLIs, TVs, embedded devices — use the device flow (RFC 8628). Register urn:ietf:params:oauth:grant-type:device_code in grant_types.
-
Request a device code.
workspace_hintis accepted here as well.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:readThe response carries
device_code,user_code,verification_uri(https://dillie.work/device),verification_uri_complete,expires_in(600), andinterval(5). Requesting too often from the same IP returns 429slow_down. -
Show the user
verification_urianduser_code(orverification_uri_completeas a QR code). The user approves in a browser on another device. -
Poll for the token every
intervalseconds.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=<the device_code> &client_id=<client_id>
| Polling response | Action |
|---|---|
authorization_pending |
The user has not approved yet. Keep polling at the same interval |
slow_down |
You polled too often. Increase the interval by 5 seconds |
access_denied |
The user denied. Stop polling |
expired_token |
expires_in has passed. Start again from step 1 |
| 200 | The same token response as in step 3 |
Getting user information
Use OIDC with the openid scope. Read its configuration from /.well-known/openid-configuration.
- The
id_token’saudis yourclient_id, and itstidcarries the ID of the workspace the token is bound to. Verify the signature with the public keys at/.well-known/jwks.json. profile,email, andphoneclaims come from UserInfo, not theid_token: callGETorPOST /oauth/userinfowithAuthorization: Bearer <access_token>. A token withoutopenidgets 403insufficient_scope.
Scope list
These are the dillie API scopes. You may also request the standard OIDC scopes openid, profile, email, and phone — they unlock identity claims only, not API access.
Any scope can be requested only if it is listed in the registration’s scope. A scope in the list below but missing from the registration is invalid_scope.
| Scope | Grants |
|---|---|
g8:oauth:profile:identity:read |
Basic profile such as name and email |
g8:oauth:workspaces:workspace:read |
Names and identifiers of the user’s workspaces |
g8:oauth:projects:project:read / :write |
Read / manage projects |
g8:oauth:tasks:task:read / :write |
Read / manage tasks |
g8:oauth:tasks:task-channel:promote |
Make a task channel public |
g8:oauth:chat:message:read / :write |
Read / send messages |
g8:oauth:chat:channel:manage |
Manage channels and participants |
g8:oauth:docs:canvas:read / :write |
Read / edit documents |
g8:oauth:docs:share:manage |
Manage document sharing |
g8:oauth:docs:share:publish |
Publish public document links |
g8:oauth:files:file:read / :write |
Read / manage files |
g8:oauth:files:file:share |
Grant file and folder sharing |
g8:oauth:calendar:event:read / :write |
Read / manage events |
g8:oauth:contacts:contact:read / :write |
Read / manage contacts |
g8:oauth:contacts:external-contact:invite |
Send external invitations |
g8:oauth:meetings:meeting:read / :write |
Read / manage meetings |
Request only what you need. The consent screen shows exactly what you ask for, so the wider the request, the more reasons the user has to decline.
Endpoint summary
Server configuration is machine-readable at https://dillie.work/.well-known/oauth-authorization-server. Read it instead of hard-coding URLs.
| Purpose | Method · path |
|---|---|
| Server metadata | GET /.well-known/oauth-authorization-server |
| OIDC configuration | GET /.well-known/openid-configuration |
| Register an app | POST /oauth/register |
| Read / update / delete | GET·PUT·DELETE /oauth/register/{client_id} |
| Authorize | GET /oauth/authorize |
| Device authorization | POST /oauth/device_authorization |
| Issue / refresh / exchange | POST /oauth/token |
| Revoke | POST /oauth/revoke |
| Introspect | POST /oauth/introspect |
| User information | GET·POST /oauth/userinfo |
| Public keys | GET /.well-known/jwks.json |
Troubleshooting
Branch on the error code. error_description is human-readable text and its wording can change.
/oauth/authorize — returned as HTTP 400 JSON, without a redirect
| Code | Cause | Action |
|---|---|---|
unauthorized_client |
client_id is unknown, or the app is disabled or suspended |
Check client_id and the registration status |
invalid_request |
redirect_uri differs from the registration |
Send the registered string exactly |
/oauth/authorize — returned to redirect_uri
| Code | Cause | Action |
|---|---|---|
invalid_request |
Missing PKCE or not S256, malformed parameters, malformed workspace_hint |
Fix the request parameters |
unsupported_response_type |
response_type is not code |
Send response_type=code |
invalid_scope |
A scope not in the registration was requested | Add it to the registration’s scope or drop it |
unauthorized_client |
The request is not allowed for this app | Check the registration’s grant_types and response_types |
access_denied |
The user denied | Give the user a way to try again |
/oauth/token
| Code | Status | Cause | Action |
|---|---|---|---|
invalid_request |
400 | Missing parameter, JSON body, both client authentication methods | Fix the request |
invalid_client |
401 | Client authentication failed, or the app is disabled, suspended, or deleted | Check the secret and the app status |
invalid_grant |
400 | Code or refresh token expired, revoked, or reused, or redirect_uri mismatch |
Reauthorize (except the concurrent-refresh case in step 5) |
unauthorized_client |
400 | A grant not in the registration was used | Add it to the registration’s grant_types |
unsupported_grant_type |
400 | Unsupported grant_type |
Check the list in the server metadata |
invalid_scope |
400 | Scope removed from the registration, narrowing beyond the original, empty scope |
Reauthorize or fix the request |
invalid_target |
400 | No consent or membership in the token exchange target, or invalid subject | Reauthorize with workspace_hint |
server_error |
503 | The server could not decide temporarily | Retry shortly. Do not discard tokens |
401 responses carry a WWW-Authenticate header. Device-flow polling codes (authorization_pending, slow_down, expired_token, access_denied) follow the table in the device section above.
Time limits
| Item | Limit |
|---|---|
| Authorization code | 60 seconds, single use |
| Consent screen | Decide within 10 minutes |
| Access token | expires_in in the response (1 hour by default) |
| Refresh token | 14 days by default, counted again from each rotation |
| Device code | expires_in in the response (600 seconds) |