# Logintoo Authorization Server — OpenAPI 3.1 specification.
#
# Hand-maintained against the server source (gitlab.com/logintoo/logintoo-authorization-server:
# aws-cdk/lib/api.js + lib/constants.js define the request schemas, the lambda/*.js
# handlers define the responses). The human-readable reference lives at
# https://logintoo.com/api-reference/ — keep both in sync when the API changes.

openapi: 3.1.0
info:
  title: Logintoo Authorization Server API
  version: "2026-06"
  summary: Passwordless OAuth 2.0 authorization server with PKCE and one-time email codes.
  description: |
    An OAuth 2.0 authorization server (RFC 6749) with PKCE (RFC 7636) for passwordless
    authentication: instead of a password, the server emails a one-time code for each login
    and issues RS256-signed JWT access and refresh tokens.

    Logintoo is self-hosted — the server URL below is your own deployment. The first path
    segment is the `apiVersion` you deploy.

    Clients are public (no client secret); every login is protected by PKCE (`S256`).
    CORS is open (`Access-Control-Allow-Origin: *`). All responses are `Cache-Control:
    no-store` except the JWKS endpoint. `/auth` and `/otp` are front-channel endpoints,
    normally called by the hosted login page rather than by your application.

    Human-readable reference: https://logintoo.com/api-reference/
  contact:
    name: Logintoo
    url: https://logintoo.com/contact-us/
    email: info@logintoo.com
  license:
    name: MIT
    identifier: MIT

externalDocs:
  description: API reference on logintoo.com
  url: https://logintoo.com/api-reference/

servers:
  - url: "https://api.{domain}/{apiVersion}"
    description: Your self-hosted deployment (the api-router Worker in front of the AWS stack).
    variables:
      domain:
        default: example.com
        description: The domain your api-router Worker is deployed on.
      apiVersion:
        default: "2026-06"
        description: The apiVersion configured for the deployed stack.

tags:
  - name: front-channel
    description: Called by the hosted login page during the interactive login.
  - name: token
    description: Back-channel token operations called by your application.
  - name: keys
    description: Public verification keys for relying parties.

paths:
  /auth:
    post:
      operationId: startLogin
      tags: [front-channel]
      summary: Start a login (send a one-time code)
      description: |
        Validates the authorization request, stores it, and emails the user a one-time code.
        The code is generated and emailed asynchronously: `200` means the request was
        accepted, not that the email was delivered. If the `client_id` is unknown or the
        `redirect_uri` is not allow-listed for the client, the request is dropped after
        acceptance (no email) so the endpoint cannot be used to probe registered clients.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AuthRequest"
      responses:
        "200":
          description: Request accepted. The one-time code is emailed asynchronously.
          content:
            application/json:
              schema:
                type: object
                properties:
                  statusCode: { type: integer, const: 200 }
                  statusMessage: { type: string, const: "200 OK" }
        "400":
          $ref: "#/components/responses/BadRequest"
        "500":
          $ref: "#/components/responses/ServerError"

  /otp:
    post:
      operationId: verifyOtp
      tags: [front-channel]
      summary: Exchange the one-time code for an authorization code
      description: |
        Verifies the emailed code and mints a single-use authorization code (120-second
        lifetime). The default response is a `302` redirect back to the client's
        `redirect_uri` with `code`, `state`, and `iss` (RFC 9207) query parameters — plus
        `language`/`locale` when they were sent to `/auth`. When the request carries
        `Accept: application/json`, the same target is returned as `200 {"location": …}`
        instead, because a browser `fetch()` cannot follow a cross-origin redirect.
      parameters:
        - name: Accept
          in: header
          required: false
          schema: { type: string }
          description: Send `application/json` to receive `200 {"location"}` instead of a `302`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/OtpRequest"
      responses:
        "302":
          description: Redirect to the client's `redirect_uri` with `code`, `state`, and `iss`.
          headers:
            Location:
              schema: { type: string, format: uri }
              description: "`https://app.example.com/?code=…&state=…&iss=…`"
        "200":
          description: Returned instead of `302` when the request accepts JSON.
          content:
            application/json:
              schema:
                type: object
                properties:
                  location:
                    type: string
                    format: uri
                    description: The redirect target to navigate to.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          description: |
            `invalid_client` — the code is wrong, expired, or out of attempts, or no login
            is pending for this address. Deliberately identical in all of these cases; each
            wrong entry burns one attempt.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "403":
          $ref: "#/components/responses/AccessDenied"
        "500":
          $ref: "#/components/responses/ServerError"

  /token:
    post:
      operationId: issueTokens
      tags: [token]
      summary: Exchange the authorization code for tokens
      description: |
        Verifies the single-use authorization code and the PKCE `code_verifier`
        (`SHA-256(code_verifier)` must equal the `code_challenge` of the login), then issues
        an RS256-signed access token and a rotating refresh token.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AccessTokenRequest"
      responses:
        "200":
          description: Tokens issued.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TokenResponse" }
        "400":
          description: |
            `invalid_grant` — the code is expired, already redeemed, bound to a different
            `redirect_uri`/`client_id`, or PKCE verification failed. `invalid_request` —
            malformed body.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "403":
          $ref: "#/components/responses/AccessDenied"
        "500":
          $ref: "#/components/responses/ServerError"

    patch:
      operationId: refreshTokens
      tags: [token]
      summary: Refresh the tokens (rotates the refresh token)
      description: |
        Verifies the refresh token, issues a new access + refresh token pair, and rotates
        the refresh token — the presented token stops working immediately. Whether `rt_exp`
        moves forward on each refresh depends on the client's `extendRefreshToken`
        registration setting. Presenting a rotated-out token fails with `invalid_grant` and
        is logged server-side as suspected token theft.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RefreshTokenRequest"
      responses:
        "200":
          description: New token pair issued.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TokenResponse" }
        "400":
          description: |
            `invalid_grant` — the token is expired, malformed, fails signature/issuer
            checks, was rotated out, or the session no longer exists. `invalid_request` —
            malformed body.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "403":
          $ref: "#/components/responses/AccessDenied"
        "500":
          $ref: "#/components/responses/ServerError"

    delete:
      operationId: logout
      tags: [token]
      summary: Log out (revoke the session)
      description: |
        Deletes the server-side session, so the refresh token (and any rotation of it) can
        no longer be used. Already-issued access tokens are stateless JWTs and stay valid
        until their `exp` — discard both tokens client-side and keep access-token lifetimes
        short.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                refresh_token:
                  $ref: "#/components/schemas/Jwt"
              required: [refresh_token]
      responses:
        "200":
          description: Session deleted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  statusMessage: { type: string, const: "200 OK" }
        "400":
          $ref: "#/components/responses/BadRequest"
        "500":
          $ref: "#/components/responses/ServerError"

  /.well-known/jwks.json:
    get:
      operationId: getJwks
      tags: [keys]
      summary: JSON Web Key Set (public verification keys)
      description: |
        The public half of the RSA key(s) the server signs tokens with. Select the key whose
        `kid` matches the token header's `kid`; reject unknown `kid` values. Normally one
        key; several during a signing-key rotation. Cacheable (`Cache-Control: public,
        max-age=3600`) — use a verifier that refetches on an unknown `kid`.
      responses:
        "200":
          description: The key set.
          content:
            application/json:
              schema:
                type: object
                properties:
                  keys:
                    type: array
                    items:
                      type: object
                      properties:
                        kty: { type: string, const: RSA }
                        use: { type: string, const: sig }
                        alg: { type: string, const: RS256 }
                        kid: { type: string, description: KMS key id; matches the JWT header `kid`. }
                        n: { type: string, description: Modulus (base64url). }
                        e: { type: string, description: Exponent (base64url). }
        "500":
          $ref: "#/components/responses/ServerError"

components:
  schemas:
    ClientId:
      type: string
      pattern: "^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-4[0-9A-Fa-f]{3}-[89ABab][0-9A-Fa-f]{3}-[0-9A-Fa-f]{12}$"
      description: The application's ID (UUID v4), as registered on the server.
      examples: ["00000000-0000-4000-a000-000000000000"]

    Base64Url43to128:
      type: string
      pattern: "^[a-zA-Z0-9-_]{43,128}$"

    Email:
      type: string
      maxLength: 254
      pattern: "^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Za-z0-9-]+(?:\\.[A-Za-z0-9-]+)*$"

    RedirectUri:
      type: string
      pattern: "^https://[^\\s\"\\\\<>]+$"
      description: Must be `https://` and exactly match a redirect URI registered for the client.

    Jwt:
      type: string
      pattern: "^[a-zA-Z0-9-_]+\\.[a-zA-Z0-9-_]+\\.[a-zA-Z0-9-_]+$"

    AuthRequest:
      type: object
      properties:
        client_id: { $ref: "#/components/schemas/ClientId" }
        code_challenge:
          allOf: [{ $ref: "#/components/schemas/Base64Url43to128" }]
          description: Base64url-encoded SHA-256 hash of the `code_verifier`.
        code_challenge_method: { type: string, enum: [S256] }
        email:
          allOf: [{ $ref: "#/components/schemas/Email" }]
          description: The address the one-time code is emailed to.
        redirect_uri: { $ref: "#/components/schemas/RedirectUri" }
        response_type: { type: string, enum: [code] }
        state:
          allOf: [{ $ref: "#/components/schemas/Base64Url43to128" }]
          description: Opaque anti-CSRF value, echoed back on the redirect.
        language:
          type: string
          pattern: "^[a-zA-Z]{2}$"
          description: Preferred language of the one-time-code email; carried to redirect and token claims.
        locale:
          type: string
          pattern: "^[a-zA-Z]{2}-[a-zA-Z]{2}$"
          description: Regional locale (e.g. en-CA); carried like `language`.
        cf_turnstile_response:
          type: string
          description: Cloudflare Turnstile token — required and verified at the edge only when the deployment enables Turnstile.
      required: [client_id, code_challenge, code_challenge_method, email, redirect_uri, response_type, state]

    OtpRequest:
      type: object
      properties:
        client_id: { $ref: "#/components/schemas/ClientId" }
        code_challenge:
          allOf: [{ $ref: "#/components/schemas/Base64Url43to128" }]
          description: Same value as sent to `/auth` — identifies the pending login together with `email`.
        email: { $ref: "#/components/schemas/Email" }
        otp:
          type: string
          pattern: "^[0-9]{6,8}$"
          description: The one-time code from the email.
      required: [client_id, code_challenge, email, otp]

    AccessTokenRequest:
      type: object
      properties:
        grant_type: { type: string, enum: [authorization_code] }
        code:
          allOf: [{ $ref: "#/components/schemas/Base64Url43to128" }]
          description: The authorization code from the redirect (single-use, 120-second lifetime).
        redirect_uri: { $ref: "#/components/schemas/RedirectUri" }
        client_id: { $ref: "#/components/schemas/ClientId" }
        code_verifier:
          allOf: [{ $ref: "#/components/schemas/Base64Url43to128" }]
          description: The plain PKCE verifier whose SHA-256 hash was sent as `code_challenge`.
      required: [grant_type, code, redirect_uri, client_id, code_verifier]

    RefreshTokenRequest:
      type: object
      properties:
        grant_type: { type: string, enum: [refresh_token] }
        refresh_token: { $ref: "#/components/schemas/Jwt" }
      required: [grant_type, refresh_token]

    TokenResponse:
      type: object
      properties:
        statusMessage: { type: string, const: "200 OK" }
        access_token:
          allOf: [{ $ref: "#/components/schemas/Jwt" }]
          description: |
            RS256-signed JWT. Claims: `iss`, `token_use: access`, `sub` (normalized email),
            `aud` (only when the client registers `tokenAud`), `iat`, `exp`, `jti`, `email`,
            `email_verified: true`, `email_normalized`, `hd`, and optional `language`/`locale`.
        token_type: { type: string, const: Bearer }
        expires_in:
          type: integer
          description: Access-token lifetime in seconds (per-client setting, default 3600).
        refresh_token:
          allOf: [{ $ref: "#/components/schemas/Jwt" }]
          description: "Rotating refresh token (`token_use: refresh`)."
        exp:
          type: integer
          description: Access-token expiry, Unix seconds.
        rt_exp:
          type: integer
          description: Refresh-token expiry, Unix seconds.
        state:
          type: string
          description: The `state` of this login, echoed once more.
        language: { type: string }
        locale: { type: string }
      required: [statusMessage, access_token, token_type, expires_in, refresh_token, exp, rt_exp]

    Error:
      type: object
      description: |
        OAuth 2.0 (RFC 6749 §5.2) error body. Requests rejected by gateway schema
        validation return `{"message": …, "error": "invalid_request"}` instead.
      properties:
        statusMessage:
          type: string
          examples: ["400 Bad Request"]
        error:
          type: string
          enum: [invalid_request, invalid_grant, invalid_client, access_denied, temporarily_unavailable, server_error]
        error_description:
          type: string

  responses:
    BadRequest:
      description: "`invalid_request` — a parameter is missing or malformed (or, on `/auth`, Turnstile verification failed at the edge)."
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    AccessDenied:
      description: "`access_denied` — the client or redirect URI is not registered."
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    ServerError:
      description: "`server_error` (500) or `temporarily_unavailable` (503) — retry with backoff."
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
