DillieDocs
dillie.work

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 with invalid_scope. OIDC scopes (openid and so on) must be listed here too.
  • If you omit grant_types, only authorization_code is registered, and you get no refresh token. To use refresh, the device flow, or token exchange, add refresh_token, urn:ietf:params:oauth:grant-type:device_code, or urn:ietf:params:oauth:grant-type:token-exchange respectively.

Constraints on registration values:

  • Up to 10 redirect_uris. Only https, 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_name is at most 100 characters. logo_uri, client_uri, policy_uri, and tos_uri must be https.
  • 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 reports client_secret_basic for confidential clients, but token requests accept both Authorization: Basic and the client_secret form field. Sending both in one request is 400 invalid_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_id is required.
  • Including registration_access_token, registration_client_uri, client_secret_expires_at, or client_id_issued_at is rejected.
  • client_secret may 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_uri with code and state.
  • If denied, the user returns with error=access_denied and state.
  • If every requested scope was already consented to, the code comes 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_id or redirect_uri does not redirect. If either fails validation, dillie does not return to redirect_uri; it responds with HTTP 400 and a JSON body carrying error and error_description. An unregistered or disabled client gets unauthorized_client; a redirect_uri that differs from the registration gets invalid_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_request to redirect_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_token is returned only to apps that registered refresh_token in grant_types.
  • expires_in is 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 openid scope, an id_token is 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_token in 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:

  1. On a 401 from the API, try a refresh first.
  2. If the refresh succeeds, resend the original request with the new access token.
  3. 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 400 invalid_request. The response’s scope is 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_token is invalid, you get 400 invalid_target without distinguishing the reason. Then authorize again with that workspace ID in workspace_hint (step 2).
  • subject_token is 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.

  1. Request a device code. workspace_hint is 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:read

    The response carries device_code, user_code, verification_uri (https://dillie.work/device), verification_uri_complete, expires_in (600), and interval (5). Requesting too often from the same IP returns 429 slow_down.

  2. Show the user verification_uri and user_code (or verification_uri_complete as a QR code). The user approves in a browser on another device.

  3. Poll for the token every interval seconds.

    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’s aud is your client_id, and its tid carries the ID of the workspace the token is bound to. Verify the signature with the public keys at /.well-known/jwks.json.
  • profile, email, and phone claims come from UserInfo, not the id_token: call GET or POST /oauth/userinfo with Authorization: Bearer <access_token>. A token without openid gets 403 insufficient_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)