---
openapi: 3.1.0
info:
  title: Howler API s2s/v1
  version: 1.0.0
  description: |
    Server-to-server Core API for Pulse and other platform services.

    ## Authentication

    1. Exchange `AccessToken` credentials via `POST /oauth/token` (`grant_type=client_credentials`).
    2. Call protected endpoints with `Authorization: Bearer <access_token>`.
    3. Access JWTs expire after 1800 seconds (30 minutes); refresh JWTs after 3600 seconds (1 hour).
    4. Renew with `grant_type=refresh_token` and the `refresh_token` from the previous response.

    ## User Token (acting as a specific user)

    Howler cannot act as a full OAuth/OIDC identity provider yet — there is no browser-redirect
    consent screen. Instead, acting *as* a specific user follows an OAuth 2.0 Device Authorization
    Grant (RFC 8628) shape:

    1. `POST /oauth/user_token/request` with the target user's email + your `serviceJwt`. Returns a
       `device_code`. The response is identical whether or not the email matches a real user — this
       endpoint must never be usable to enumerate accounts.
    2. A human (currently a super-admin, from the user's ActiveAdmin page) accepts or denies the
       pending request.
    3. Poll `POST /oauth/token` with `grant_type=urn:ietf:params:oauth:grant-type:device_code` and
       the `device_code`. Returns `authorization_pending` until accepted, then a signed RS256 user
       JWT + opaque refresh token (each `device_code` redeems exactly once).
    4. Present the user JWT via the `X-User-Token` header alongside your service `Authorization:
       Bearer` header on any endpoint that needs user context (almost everything except auth/refresh/
       discovery requires this by default).
    5. Renew with `POST /oauth/token`, `grant_type=refresh_token`, and the user refresh token —
       shares the same grant_type as service-credential refresh; both rotate and both return
       `401 unauthorized` on failure.

    User JWTs are signed RS256 (not the HS256 used for service JWTs) so they can be verified
    independently via `GET /.well-known/jwks.json`, without sharing Howler's signing secret.

    ## Versioning

    Additive changes within s2s/v1. Breaking changes require a new major version (e.g. `/api/s2s/v2`). See `docs/api/s2s/v1/versioning-policy.md`.

    *Last updated: 2026-08-14 10:21:30 +0200*
components:
  securitySchemes:
    serviceJwt:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: Service (access) JWT from POST /oauth/token — not the refresh token
  schemas:
    TokenResponse:
      type: object
      properties:
        access_token:
          type: string
          description: 'Signed service JWT (typ: service)'
        refresh_token:
          type: string
          description: 'Signed refresh JWT (typ: refresh)'
        token_type:
          type: string
          example: Bearer
        expires_in:
          type: integer
          example: 1800
          description: Seconds until access token expiry
        refresh_expires_in:
          type: integer
          example: 3600
          description: Seconds until refresh token expiry
      required:
      - access_token
      - refresh_token
      - token_type
      - expires_in
      - refresh_expires_in
    UnauthorizedError:
      type: object
      properties:
        error:
          type: string
          example: unauthorized
      required:
      - error
    HealthResponse:
      type: object
      properties:
        status:
          type: string
          example: ok
        service:
          type: string
          example: pulse-staging
          description: AccessToken name (source system)
      required:
      - status
      - service
    UserTokenRequestResponse:
      type: object
      properties:
        device_code:
          type: string
          description: Opaque code to poll with at POST /oauth/token
        expires_in:
          type: integer
          example: 900
          description: Seconds until the request expires unredeemed
      required:
      - device_code
      - expires_in
    Jwks:
      type: object
      properties:
        keys:
          type: array
          items:
            type: object
            properties:
              kty:
                type: string
                example: RSA
              use:
                type: string
                example: sig
              kid:
                type: string
              "n":
                type: string
              e:
                type: string
            required:
            - kty
      required:
      - keys
security:
- serviceJwt: []
tags:
- name: OAuth
  description: Service credential exchange and refresh
- name: User Token
  description: Device-authorization-style flow for obtaining and using a token that
    acts as a specific Howler user — see the "User Token" section above for the full
    flow
- name: Health
  description: Authenticated liveness
paths:
  "/oauth/token":
    post:
      summary: Issue service JWT
      tags:
      - OAuth
      security: []
      operationId: createServiceToken
      description: |2
                Four grant types share this one endpoint:

                - `client_credentials` — exchange `AccessToken` key/secret for a service JWT + refresh token.
                - `refresh_token` — rotate a service **or** user-token refresh pair. The value is checked
                  against the service-JWT refresh format first, then falls back to an opaque user
                  refresh token, so the same grant_type and 200 response shape (`TokenResponse`) cover
                  both — an invalid/expired/unknown value of either kind returns the same
                  `401 { "error": "unauthorized" }`.
                - `urn:ietf:params:oauth:grant-type:device_code` — redeem a `device_code` obtained from
                  `POST /oauth/user_token/request` once a super-admin has accepted it (see the "User
                  Token" section above). Unlike the other grants, this one uses RFC 8628's error
                  vocabulary directly since it has no legacy consumers to preserve:
                  - `authorization_pending` (`400`) — still waiting on approval.
                  - `access_denied` (`400`) — the request was denied.
                  - `expired_token` (`400`) — the request's validity window passed before it was
                    approved or redeemed.
                  - `invalid_grant` (`400`) — unknown `device_code`, or one that was already redeemed
                    (each `device_code` is single-use).

                  On success it returns the same `TokenResponse` shape as `client_credentials`, but for
                  the *user* — a signed RS256 user JWT (verifiable via `GET /.well-known/jwks.json`)
                  plus an opaque, rotating refresh token.
      parameters: []
      responses:
        '200':
          description: token issued
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/TokenResponse"
        '401':
          description: invalid credentials
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/UnauthorizedError"
        '400':
          description: unsupported grant type
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: string
                    example: unsupported_grant_type
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                grant_type:
                  type: string
                  example: client_credentials
                  description: client_credentials, refresh_token, or urn:ietf:params:oauth:grant-type:device_code
                access_key:
                  type: string
                  description: Required when grant_type is client_credentials
                access_secret:
                  type: string
                  description: Required when grant_type is client_credentials
                refresh_token:
                  type: string
                  description: Required when grant_type is refresh_token — accepts
                    either a service or a user refresh token
                device_code:
                  type: string
                  description: Required when grant_type is urn:ietf:params:oauth:grant-type:device_code
              required:
              - grant_type
  "/oauth/user_token/request":
    post:
      summary: Request a user-delegated token
      tags:
      - User Token
      security:
      - serviceJwt: []
      operationId: createUserTokenRequest
      description: |2
                Starts the device-authorization-style flow used to obtain a token that acts as a specific
                Howler user (see the "User Token" section above for the full flow). Creates a pending
                request and returns a `device_code` to poll with at `POST /oauth/token`.

                The response is intentionally **identical in shape whether or not `identifier` matches a
                real user** — this endpoint must never be usable to enumerate Howler accounts. A human
                (currently a super-admin, in ActiveAdmin) must accept the request before it can be
                redeemed; until then, polling returns `authorization_pending`.
      parameters: []
      responses:
        '202':
          description: request accepted for processing
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/UserTokenRequestResponse"
        '400':
          description: blank identifier
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: string
                    example: invalid_request
        '401':
          description: unauthorized caller
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/UnauthorizedError"
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                identifier:
                  type: string
                  example: someone@example.com
                  description: The target user's email
                origin:
                  type: string
                  example: pulse
                  description: Caller-supplied context, bound into the eventual token
                    claims
              required:
              - identifier
  "/.well-known/jwks.json":
    get:
      summary: Public keys for verifying user tokens
      tags:
      - User Token
      security: []
      operationId: getJwks
      description: JSON Web Key Set used to verify the RS256 signature on user tokens
        without a shared secret. No authentication required — this is the public half
        of the signing key.
      responses:
        '200':
          description: key set
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Jwks"
  "/health":
    get:
      summary: Health check
      tags:
      - Health
      security:
      - serviceJwt: []
      operationId: getHealth
      description: Authenticated liveness check. Returns the calling service name.
      responses:
        '200':
          description: healthy
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/HealthResponse"
        '401':
          description: unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/UnauthorizedError"
servers:
- url: https://{defaultHost}/api/s2s/v1
  variables:
    defaultHost:
      default: www.howler.co.za
  description: Howler server-to-server API s2s/v1
