openapi: 3.0.3
info:
  title: Trupocket API - OAuth One-Click Connect
  version: 1.0.0
  description: |
    Trupocket runs an **OAuth 2.1 authorization server** so AI assistants (Claude,
    ChatGPT, Copilot, Gemini CLI, and any MCP-compatible client) can connect to a user's
    finances with a single click — no manually-issued token required.

    These endpoints exist for **MCP clients**, not for the Trupocket web or mobile apps.
    A client discovers them automatically: an unauthenticated call to the MCP endpoint
    returns `401` pointing at the protected-resource metadata, which in turn points here,
    at the authorization-server metadata.

    ## How the flow works
    1. **Discover** — the client fetches `/.well-known/oauth-authorization-server` to learn
       the endpoint URLs and capabilities (Dynamic Client Registration, Client ID Metadata
       Documents, and PKCE).
    2. **Identify** — the client identifies itself either by hosting a Client ID Metadata
       Document at an HTTPS URL it uses as its `client_id` (CIMD), or by registering
       dynamically at `/oauth/register` (DCR) to obtain a `client_id`.
    3. **Authorize** — the client sends the user's browser to `/oauth/authorize` with a
       PKCE challenge. Trupocket signs the user in with their Trupocket account, then shows
       a consent screen naming the app before redirecting back to the client.
    4. **Exchange** — the client redeems the returned authorization code at `/oauth/token`
       for an access token (and a refresh token), which it then uses as a bearer token
       against the MCP endpoint.

    ## What you can connect to
    The issued access token carries the `trupocket-api/read` scope, which grants read-only
    access to the household's finances through the MCP endpoint. The same Personal Access
    Token flow remains available for clients that prefer to use a manually-issued token.

    ## Security properties (what clients can rely on)
    - **PKCE with S256 is mandatory** — the authorization code is bound to the client's
      code verifier, so an intercepted code is useless on its own.
    - **Redirect URIs are allowlisted** — only HTTPS URLs (or loopback HTTP, for native
      apps) are accepted, with no wildcards. CIMD redirect URIs must match the document's
      own origin.
    - **Per-app consent** — every distinct client is named on its own consent screen, so a
      user always sees exactly which app is being authorized.
    - **Single-use, short-lived codes** — authorization codes are redeemable once and
      expire quickly.

servers:
  - url: https://api.trupocket.app
    description: Production API

tags:
  - name: OAuth
    description: OAuth 2.1 authorization server for MCP one-click connect

paths:
  /.well-known/oauth-authorization-server:
    get:
      tags: [OAuth]
      summary: Authorization Server Metadata (discovery)
      description: >
        Public discovery document (RFC 8414) describing the authorization server's
        endpoints and capabilities. Clients read endpoint URLs from here rather than
        hard-coding them. No authentication required.
      operationId: getAuthorizationServerMetadata
      security: []
      responses:
        "200":
          description: Authorization server metadata
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AuthorizationServerMetadata"

  /oauth/register:
    post:
      tags: [OAuth]
      summary: Dynamic Client Registration
      description: >
        Registers a public client and returns a `client_id` (RFC 7591). Intended for
        clients that cannot host a Client ID Metadata Document. Registration is rate
        limited and only HTTPS or loopback HTTP redirect URIs are accepted. The returned
        client is public (PKCE; no client secret).
      operationId: registerOAuthClient
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ClientRegistrationRequest"
      responses:
        "201":
          description: The client was registered.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ClientRegistrationResponse"
        "400":
          description: >
            The registration request was rejected — typically an invalid or disallowed
            `redirect_uris` value, or a malformed request body.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OAuthError"
              example:
                error: invalid_redirect_uri
                error_description: redirect_uris must be HTTPS URLs or loopback HTTP URLs, with no wildcards or fragments.

  /oauth/authorize:
    get:
      tags: [OAuth]
      summary: Authorization endpoint (consent + sign-in)
      description: >
        Begins the authorization code flow. The client sends the user's browser here with
        a PKCE challenge. Trupocket validates the client and redirect URI, signs the user in,
        then shows a consent screen naming the app; on approval it redirects an authorization
        code back to the client's `redirect_uri`. This endpoint is browser-facing — it drives
        an interactive sign-in and consent flow, not a JSON API.
      operationId: authorize
      security: []
      parameters:
        - name: response_type
          in: query
          required: true
          schema:
            type: string
            enum: [code]
        - name: client_id
          in: query
          required: true
          description: A registered client_id (DCR) or a Client ID Metadata Document URL (CIMD).
          schema:
            type: string
        - name: redirect_uri
          in: query
          required: true
          description: Must exactly match a redirect URI the client declared.
          schema:
            type: string
        - name: code_challenge
          in: query
          required: true
          description: PKCE code challenge.
          schema:
            type: string
        - name: code_challenge_method
          in: query
          required: true
          schema:
            type: string
            enum: [S256]
        - name: state
          in: query
          required: false
          description: Opaque value echoed back to the client on the redirect.
          schema:
            type: string
      responses:
        "302":
          description: >
            The browser is taken through Trupocket sign-in and a consent screen, and is
            ultimately redirected back to the client's `redirect_uri` carrying either a
            `code` (and `state`) on approval or an `error` (and `state`) on denial.
        "400":
          description: >
            The client or redirect URI could not be validated. An HTML error page is shown
            rather than redirecting to an unverified location.
          content:
            text/html:
              schema:
                type: string

  /oauth/token:
    post:
      tags: [OAuth]
      summary: Token endpoint
      description: >
        Redeems an authorization code for tokens (`authorization_code` grant) or refreshes
        an access token (`refresh_token` grant). The client authenticates the request with
        its PKCE code verifier; no client secret is used. The request body is
        `application/x-www-form-urlencoded`.
      operationId: token
      security: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              $ref: "#/components/schemas/TokenRequest"
      responses:
        "200":
          description: The issued tokens.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TokenResponse"
        "400":
          description: >
            The grant could not be honored — an invalid, expired, or already-redeemed code,
            a failed PKCE check, or an unsupported grant type.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OAuthError"
              example:
                error: invalid_grant
                error_description: The authorization code is invalid or has expired.

components:
  schemas:
    AuthorizationServerMetadata:
      type: object
      description: OAuth 2.0 Authorization Server Metadata (RFC 8414).
      properties:
        issuer:
          type: string
          example: https://api.trupocket.app
        authorization_endpoint:
          type: string
          example: https://api.trupocket.app/oauth/authorize
        token_endpoint:
          type: string
          example: https://api.trupocket.app/oauth/token
        registration_endpoint:
          type: string
          example: https://api.trupocket.app/oauth/register
        scopes_supported:
          type: array
          items:
            type: string
          example: ["openid", "profile", "email", "trupocket-api/read"]
        response_types_supported:
          type: array
          items:
            type: string
          example: ["code"]
        grant_types_supported:
          type: array
          items:
            type: string
          example: ["authorization_code", "refresh_token"]
        token_endpoint_auth_methods_supported:
          type: array
          items:
            type: string
          example: ["none"]
        code_challenge_methods_supported:
          type: array
          items:
            type: string
          example: ["S256"]
        client_id_metadata_document_supported:
          type: boolean
          example: true

    ClientRegistrationRequest:
      type: object
      required: [redirect_uris]
      properties:
        redirect_uris:
          type: array
          items:
            type: string
          description: One or more HTTPS (or loopback HTTP) redirect URIs.
          example: ["https://claude.ai/api/mcp/callback"]
        client_name:
          type: string
          example: Claude

    ClientRegistrationResponse:
      type: object
      properties:
        client_id:
          type: string
          example: tpc_3f9c1a2b4d5e6f708192a3b4c5d6e7f8
        client_id_issued_at:
          type: integer
          format: int64
          example: 1733788800
        redirect_uris:
          type: array
          items:
            type: string
          example: ["https://claude.ai/api/mcp/callback"]
        token_endpoint_auth_method:
          type: string
          example: none
        grant_types:
          type: array
          items:
            type: string
          example: ["authorization_code", "refresh_token"]
        response_types:
          type: array
          items:
            type: string
          example: ["code"]
        client_name:
          type: string
          example: Claude

    TokenRequest:
      type: object
      required: [grant_type]
      properties:
        grant_type:
          type: string
          enum: [authorization_code, refresh_token]
        code:
          type: string
          description: Required for the authorization_code grant.
        code_verifier:
          type: string
          description: PKCE verifier; required for the authorization_code grant.
        redirect_uri:
          type: string
          description: Required for the authorization_code grant; must match the authorize request.
        refresh_token:
          type: string
          description: Required for the refresh_token grant.
        client_id:
          type: string

    TokenResponse:
      type: object
      properties:
        access_token:
          type: string
        token_type:
          type: string
          example: Bearer
        expires_in:
          type: integer
          example: 3600
        refresh_token:
          type: string
        scope:
          type: string
          example: openid email profile trupocket-api/read

    OAuthError:
      type: object
      description: Standard OAuth 2.0 error response (RFC 6749 §5.2).
      properties:
        error:
          type: string
          example: invalid_grant
        error_description:
          type: string
