> ## Documentation Index
> Fetch the complete documentation index at: https://docs.digitalseaservice.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Authentication

> OAuth 2.0 Authorization Code flow with PKCE — full reference, sample code, and the failure modes.

The DSS Public API uses [OAuth 2.0 Authorization Code with
PKCE](https://datatracker.ietf.org/doc/html/rfc7636). Every partner uses the
same flow. We do not issue API keys and we do not offer client credentials.

## Why this flow

OAuth Authorization Code + PKCE is the right shape for "user X grants third-party
app B access to their data on service A." It's what Google, Stripe, Plaid,
Slack, and every dashboard-style consumer API uses. PKCE protects the
authorization code if it's ever intercepted in transit.

We use **opaque tokens** stored server-side, not JWTs. Revocation is instant
— when a user disconnects, every token we've issued to your client for that
user stops working on the next request. There's no token expiry window where
revoked-but-not-expired tokens still work.

## Endpoints

| Method | Path | Auth | Purpose |
| - | - | - | - |
| GET | `/oauth/authorize` | DSS user session | Render the consent screen |
| POST | `/oauth/authorize` | DSS user session | Record consent, issue authorization code |
| POST | `/oauth/token` | `client_secret` | Exchange code or refresh for tokens |
| POST | `/oauth/revoke` | `client_secret` | RFC 7009 token revocation |
| GET | `/oauth/userinfo` | bearer | OIDC-style basic profile |

## The flow at a glance

```mermaid theme={null}
sequenceDiagram
  autonumber
  participant U as Crew member
  participant P as Your app
  participant DSS as DSS auth server
  participant API as DSS API

  U->>P: Clicks "Connect Digital Sea Service"
  P->>P: Generate state + PKCE pair, store verifier
  P->>U: Redirect to /oauth/authorize (challenge, state)
  U->>DSS: Browser hits /oauth/authorize
  DSS->>U: Show consent screen
  U->>DSS: Approve
  DSS->>P: Redirect to redirect_uri with code + state
  P->>P: Validate state matches
  P->>DSS: POST /oauth/token (code, client_secret, verifier)
  DSS-->>P: { access_token, refresh_token, expires_in: 3600, scope }
  P->>API: GET /v1/me with Bearer access_token
  API-->>P: 200 { user_id, name, role, ... }
```

## Scopes

| Scope | Grants |
| - | - |
| `profile:read` | Name, role, country, photo URL |
| `seatime:read` | Aggregate sea-time totals and 12-month trend |
| `vessels:read` | Vessel history |

Scopes are additive — request only what your UI actually renders. Granular
scope requests get higher consent rates and a tighter blast radius if anything
ever leaks.

The set of scopes you may request is configured per partner at onboarding.
Asking for a scope you weren't granted at onboarding returns `invalid_scope`
on the authorize call.

## PKCE

PKCE (Proof Key for Code Exchange) closes the loophole where an intercepted
authorization code could be redeemed by an attacker. Every request needs a
fresh, single-use pair:

* **`code_verifier`** — a high-entropy random string, 43-128 chars, kept on
  your server.
* **`code_challenge`** — `base64url(sha256(code_verifier))`, sent on the
  authorize call.

We require `code_challenge_method=S256`. `plain` is rejected.

### Generating a PKCE pair

<CodeGroup>
  ```bash cURL theme={null}
  CODE_VERIFIER=$(python -c 'import secrets;print(secrets.token_urlsafe(64)[:64])')
  CODE_CHALLENGE=$(python -c "
  import base64, hashlib, sys
  v = sys.argv[1]
  print(base64.urlsafe_b64encode(hashlib.sha256(v.encode()).digest()).rstrip(b'=').decode())
  " "$CODE_VERIFIER")
  ```

  ```python Python theme={null}
  import base64
  import hashlib
  import secrets

  def generate_pkce_pair() -> tuple[str, str]:
      verifier = secrets.token_urlsafe(64)[:64]
      challenge = base64.urlsafe_b64encode(
          hashlib.sha256(verifier.encode()).digest()
      ).rstrip(b"=").decode()
      return verifier, challenge
  ```

  ```javascript Node theme={null}
  import { createHash, randomBytes } from "node:crypto";

  export function generatePkcePair() {
    const verifier = randomBytes(48).toString("base64url").slice(0, 64);
    const challenge = createHash("sha256").update(verifier).digest("base64url");
    return { verifier, challenge };
  }
  ```

  ```php PHP theme={null}
  <?php
  function generate_pkce_pair(): array {
      $verifier = rtrim(strtr(base64_encode(random_bytes(48)), '+/', '-_'), '=');
      $verifier = substr($verifier, 0, 64);
      $challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
      return ['verifier' => $verifier, 'challenge' => $challenge];
  }
  ```

  ```go Go theme={null}
  package pkce

  import (
      "crypto/rand"
      "crypto/sha256"
      "encoding/base64"
  )

  func Generate() (verifier, challenge string, err error) {
      b := make([]byte, 48)
      if _, err = rand.Read(b); err != nil {
          return "", "", err
      }
      verifier = base64.RawURLEncoding.EncodeToString(b)[:64]
      sum := sha256.Sum256([]byte(verifier))
      challenge = base64.RawURLEncoding.EncodeToString(sum[:])
      return verifier, challenge, nil
  }
  ```
</CodeGroup>

<Note>
  The reference vectors come from [RFC 7636
  §4.4](https://datatracker.ietf.org/doc/html/rfc7636#appendix-B). Our
  verification is tested against them — if your implementation matches the
  spec, it'll match ours.
</Note>

## Step 1 — Send the user to /oauth/authorize

This is a browser redirect, not a server-to-server call. The user has to
sign in to DSS and approve consent in their own browser session.

```
GET https://api.digitalseaservice.com/oauth/authorize?response_type=code
  &client_id=YOUR_CLIENT_ID
  &redirect_uri=https://yourapp.example.com/integrations/dss/callback
  &scope=profile:read+seatime:read+vessels:read
  &state=RANDOM_PER_REQUEST_CSRF_TOKEN
  &code_challenge=GENERATED_CHALLENGE
  &code_challenge_method=S256
```

### Parameters

| Parameter | Required | Notes |
| - | - | - |
| `response_type` | yes | Always `code`. |
| `client_id` | yes | The client\_id we issued. |
| `redirect_uri` | yes | Must exactly match one of the URIs you registered. HTTPS required outside `http://localhost*`. |
| `scope` | yes | Space-separated. Subset of your allowed scopes. |
| `state` | yes | Anti-CSRF token. We round-trip it unchanged. Validate on callback. |
| `code_challenge` | yes | From PKCE step. |
| `code_challenge_method` | yes | Always `S256`. |

### What the user sees

A consent screen listing your partner name and the requested scopes in plain
English. On approval the browser is redirected back to your `redirect_uri`:

```
https://yourapp.example.com/integrations/dss/callback?code=ONE_SHOT_CODE&state=YOUR_STATE_VALUE
```

On denial, the same redirect with `?error=access_denied&state=...`. Render a
useful message and offer to retry.

<Warning>
  **Validate `state` first thing on the callback.** If it doesn't match the
  value you stashed against the user's session, drop the request — treat it
  as a CSRF attempt, not a flow you can recover.
</Warning>

## Step 2 — Exchange the code for tokens

Authorization codes are **single-use** and **expire after 60 seconds**.
Exchange immediately on receipt.

<CodeGroup>
  ```bash cURL theme={null}
  curl -sS -X POST https://api.digitalseaservice.com/oauth/token \
    -d grant_type=authorization_code \
    -d code="$CODE" \
    -d redirect_uri=https://yourapp.example.com/integrations/dss/callback \
    -d client_id="$CLIENT_ID" \
    -d client_secret="$CLIENT_SECRET" \
    -d code_verifier="$CODE_VERIFIER"
  ```

  ```python Python theme={null}
  import httpx

  def exchange_code(code: str, code_verifier: str) -> dict:
      r = httpx.post(
          "https://api.digitalseaservice.com/oauth/token",
          data={
              "grant_type": "authorization_code",
              "code": code,
              "redirect_uri": REDIRECT_URI,
              "client_id": CLIENT_ID,
              "client_secret": CLIENT_SECRET,
              "code_verifier": code_verifier,
          },
          timeout=10,
      )
      r.raise_for_status()
      return r.json()
  ```

  ```javascript Node theme={null}
  export async function exchangeCode(code, codeVerifier) {
    const res = await fetch(
      "https://api.digitalseaservice.com/oauth/token",
      {
        method: "POST",
        headers: { "content-type": "application/x-www-form-urlencoded" },
        body: new URLSearchParams({
          grant_type: "authorization_code",
          code,
          redirect_uri: REDIRECT_URI,
          client_id: CLIENT_ID,
          client_secret: CLIENT_SECRET,
          code_verifier: codeVerifier,
        }),
      }
    );
    if (!res.ok) throw new Error(`token exchange failed: ${res.status}`);
    return res.json();
  }
  ```

  ```php PHP theme={null}
  <?php
  function exchange_code(string $code, string $verifier): array {
      $ch = curl_init('https://api.digitalseaservice.com/oauth/token');
      curl_setopt_array($ch, [
          CURLOPT_POST => true,
          CURLOPT_RETURNTRANSFER => true,
          CURLOPT_POSTFIELDS => http_build_query([
              'grant_type' => 'authorization_code',
              'code' => $code,
              'redirect_uri' => REDIRECT_URI,
              'client_id' => CLIENT_ID,
              'client_secret' => CLIENT_SECRET,
              'code_verifier' => $verifier,
          ]),
      ]);
      $body = curl_exec($ch);
      $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
      curl_close($ch);
      if ($status >= 400) {
          throw new RuntimeException("token exchange failed: $status $body");
      }
      return json_decode($body, true, flags: JSON_THROW_ON_ERROR);
  }
  ```

  ```go Go theme={null}
  package dss

  import (
      "encoding/json"
      "fmt"
      "net/http"
      "net/url"
      "strings"
  )

  type TokenResponse struct {
      AccessToken  string `json:"access_token"`
      TokenType    string `json:"token_type"`
      ExpiresIn    int    `json:"expires_in"`
      RefreshToken string `json:"refresh_token"`
      Scope        string `json:"scope"`
  }

  func ExchangeCode(code, verifier string) (*TokenResponse, error) {
      form := url.Values{
          "grant_type":    {"authorization_code"},
          "code":          {code},
          "redirect_uri":  {RedirectURI},
          "client_id":     {ClientID},
          "client_secret": {ClientSecret},
          "code_verifier": {verifier},
      }
      resp, err := http.Post(
          "https://api.digitalseaservice.com/oauth/token",
          "application/x-www-form-urlencoded",
          strings.NewReader(form.Encode()),
      )
      if err != nil {
          return nil, err
      }
      defer resp.Body.Close()
      if resp.StatusCode >= 400 {
          return nil, fmt.Errorf("token exchange: %s", resp.Status)
      }
      out := &TokenResponse{}
      return out, json.NewDecoder(resp.Body).Decode(out)
  }
  ```
</CodeGroup>

### Response

```json theme={null}
{
  "access_token": "Aq3...redacted",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "Vf9...redacted",
  "scope": "profile:read seatime:read vessels:read"
}
```

Both tokens are opaque. Don't try to parse them — they have no meaning
client-side.

## Step 3 — Call the API

```http theme={null}
GET /v1/me HTTP/1.1
Host: api.digitalseaservice.com
Authorization: Bearer Aq3...redacted
```

Every authenticated response carries `X-RateLimit-Limit`,
`X-RateLimit-Remaining`, and `X-RateLimit-Reset` headers. See
[Rate limits](/rate-limits).

## Refreshing tokens

Access tokens live for 1 hour. Refresh them with `grant_type=refresh_token`
to get a new pair.

<CodeGroup>
  ```bash cURL theme={null}
  curl -sS -X POST https://api.digitalseaservice.com/oauth/token \
    -d grant_type=refresh_token \
    -d refresh_token="$REFRESH_TOKEN" \
    -d client_id="$CLIENT_ID" \
    -d client_secret="$CLIENT_SECRET"
  ```

  ```python Python theme={null}
  def refresh(refresh_token: str) -> dict:
      r = httpx.post(
          "https://api.digitalseaservice.com/oauth/token",
          data={
              "grant_type": "refresh_token",
              "refresh_token": refresh_token,
              "client_id": CLIENT_ID,
              "client_secret": CLIENT_SECRET,
          },
          timeout=10,
      )
      r.raise_for_status()
      return r.json()
  ```

  ```javascript Node theme={null}
  export async function refresh(refreshToken) {
    const res = await fetch(
      "https://api.digitalseaservice.com/oauth/token",
      {
        method: "POST",
        headers: { "content-type": "application/x-www-form-urlencoded" },
        body: new URLSearchParams({
          grant_type: "refresh_token",
          refresh_token: refreshToken,
          client_id: CLIENT_ID,
          client_secret: CLIENT_SECRET,
        }),
      }
    );
    if (!res.ok) throw new Error(`refresh failed: ${res.status}`);
    return res.json();
  }
  ```
</CodeGroup>

```mermaid theme={null}
sequenceDiagram
  autonumber
  participant P as Your app
  participant DSS as DSS auth server
  participant Store as Your token store

  P->>Store: Read current refresh_token
  P->>DSS: POST /oauth/token grant_type=refresh_token
  DSS->>DSS: Revoke old access + refresh pair (atomic)
  DSS-->>P: { access_token, refresh_token (new), expires_in: 3600 }
  P->>Store: Replace refresh_token with new value
```

### Rules

* **Refresh tokens rotate.** Every successful refresh revokes both the old
  access token and the old refresh token, and issues a new pair. Always
  swap your stored value for the new one immediately.
* **Refresh cannot widen scope.** You can request a narrower `scope`
  parameter to issue a more limited token, but not a broader one.
* **Refresh tokens are 90-day TTL.** If a user hasn't been seen for 90 days
  the refresh token expires and they need to reconsent.
* **One concurrent refresh per token.** If you race two refreshes with the
  same `refresh_token`, only one wins and the other returns `invalid_grant`.
  Treat that as "use the value the winning call returned" — don't reconsent.

## Revoking a token

You can revoke either the access token or the refresh token via
[RFC 7009](https://datatracker.ietf.org/doc/html/rfc7009). Revoking one
revokes the paired other.

<CodeGroup>
  ```bash cURL theme={null}
  curl -sS -X POST https://api.digitalseaservice.com/oauth/revoke \
    -d token="$ACCESS_TOKEN_OR_REFRESH" \
    -d client_id="$CLIENT_ID" \
    -d client_secret="$CLIENT_SECRET"
  ```

  ```python Python theme={null}
  httpx.post(
      "https://api.digitalseaservice.com/oauth/revoke",
      data={
          "token": token,
          "client_id": CLIENT_ID,
          "client_secret": CLIENT_SECRET,
      },
      timeout=10,
  )
  ```

  ```javascript Node theme={null}
  await fetch("https://api.digitalseaservice.com/oauth/revoke", {
    method: "POST",
    headers: { "content-type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      token,
      client_id: CLIENT_ID,
      client_secret: CLIENT_SECRET,
    }),
  });
  ```
</CodeGroup>

Per RFC 7009 the response is always `200 OK` with no body — revoking an
unknown token is a no-op, not an error.

## What happens when the user disconnects

A user can disconnect your integration from their DSS dashboard at any time.
When they do:

1. Every access and refresh token issued to your client for that user is
   revoked immediately. Subsequent calls return
   [`invalid_token`](/errors/invalid_token) → `401`.
2. A signed `user.consent.revoked` webhook fires to your registered webhook
   URL — see [Webhooks](/webhooks/events#userconsentrevoked).
3. Under our Data Processing Agreement you have 30 days to delete any cached
   DSS data for that user.

To trigger the same flow from your side (e.g. the user disconnects from
your settings UI), call `POST /oauth/revoke` with the user's token. Because
revoking either token in a pair revokes both, a single call tears down that
user's connection on our side — no separate cleanup call is needed.

## Token storage on your side

* Store both tokens encrypted at rest, keyed by your user record.
* Never log them. The audit log on our side has everything we need; logging
  on your side just creates a leak risk.
* Treat the access token as bearer credentials. Anyone with it can read
  whatever the granted scopes allow until it expires or is revoked.

## Failure modes

| OAuth error | When you'll see it |
| - | - |
| `invalid_request` | Missing or malformed parameter (e.g. no `code_challenge`). |
| `invalid_client` | Wrong `client_id` / `client_secret` pair. |
| `invalid_grant` | Expired code, replayed code, wrong `redirect_uri`, mismatched PKCE verifier, or revoked refresh. |
| `unsupported_grant_type` | Anything other than `authorization_code` or `refresh_token`. |
| `invalid_scope` | Requested scope isn't in your allowed set. |
| `access_denied` | The user clicked Deny on the consent screen. |

OAuth errors follow [RFC 6749 §5.2](https://datatracker.ietf.org/doc/html/rfc6749#section-5.2)
— shape is `{ "error": "...", "error_description": "..." }`, **not** RFC 7807
Problem Details. Resource endpoint errors do use Problem Details — see
[Errors](/errors).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.