openapi: 3.1.0
info:
  title: Portal Authentication API
  version: "3.0"
  description: |
    The Portal Authentication API signs in your end users with an email magic
    link, Google, or Apple. A completed sign-in returns a Client Session Token
    that your app uses to create and operate the user's wallet.

    ## Base URL
    `https://api.portalhq.io/api/v3/auth`

    ## Authentication
    These endpoints do not use a Portal API Key or a Client Session Token.
    Identify your environment with the `x-portal-auth-environment-id` header,
    set to the Auth Environment ID shown in the Portal dashboard under
    **Authentication > Configure**.

    Requests are rejected with `401` if the header is missing, if it does not
    match an environment, or if authentication is not enabled for that
    environment.

    ## Response format
    Successful responses are wrapped in a `data` object, alongside a `metadata`
    field that is `null` for every endpoint in this API:

    ```json
    { "data": { "sent": true }, "metadata": null }
    ```

    ## Error responses
    Every authentication failure returns the same generic `401` body, with no
    indication of which check failed. This is deliberate, so a caller cannot
    distinguish an expired token from a forged one. Treat any `401` as "start
    the sign-in flow again".
servers:
  - url: https://api.portalhq.io/api/v3/auth
    description: Production
security:
  - authEnvironmentId: []
tags:
  - name: Auth Methods
    description: Discover which sign-in methods an environment has enabled
  - name: Magic Links
    description: Send and validate email magic links
  - name: OAuth
    description: Sign in with Google or Apple
  - name: Two-Factor Authentication
    description: Validate a time-based one-time password (TOTP) code

paths:
  # ── Auth Methods ───────────────────────────────────────────────────────────
  /methods:
    get:
      operationId: getAuthMethods
      summary: Get the enabled auth methods
      description: |
        Returns the sign-in methods enabled for the environment, so your app can
        render only the buttons that will work. Also returns whether the
        environment is configured to create a wallet for new users.

        Call this before rendering your sign-in screen.
      tags:
        - Auth Methods
      responses:
        "200":
          description: Auth methods retrieved successfully
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AuthMethodsResponse"
              example:
                data:
                  allowedAuthMethods:
                    - EMAIL_MAGIC_LINK
                    - GOOGLE
                    - APPLE
                  autoCreateWallet: true
                metadata: null
        "401":
          $ref: "#/components/responses/Unauthorized"

  # ── Magic Links ────────────────────────────────────────────────────────────
  /magic-links:
    post:
      operationId: sendMagicLink
      summary: Send a magic link
      description: |
        Emails a sign-in link to the end user. Portal sends the email from a
        verified sending domain using one of your email templates.

        Three of the fields must already be configured in the dashboard:

        - `redirectUrl` must exactly match one of the environment's Redirect URLs.
        - `fromEmail` must belong to a verified sending domain that is enabled
          for this environment.
        - `templateId` is the Portal template ID from
          **Authentication > Templates**.
      tags:
        - Magic Links
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SendMagicLinkRequest"
            example:
              email: "user@example.com"
              redirectUrl: "https://example.com/callback"
              fromEmail: "hello@auth.example.com"
              templateId: "clx1template000000000000"
      responses:
        "200":
          description: Magic link sent successfully
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SendMagicLinkResponse"
              example:
                data:
                  sent: true
                metadata: null
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          description: |
            Unauthorized. Returned when the environment header is invalid, when
            `redirectUrl` is not on the environment's allow list, when
            `fromEmail` does not belong to a sending domain enabled for this
            environment, or when the template does not belong to your custodian.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
              example:
                error: "Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalServerError"

  /magic-links/validations:
    post:
      operationId: validateMagicLink
      summary: Validate a magic link token
      description: |
        Exchanges the single-use `token` from a magic link for a session.

        The response depends on whether the environment requires two-factor
        authentication. See the `EndUserAuthResult` schema for the three shapes
        and how to branch on them.
      tags:
        - Magic Links
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/TokenValidationRequest"
            example:
              token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.example.token"
      responses:
        "200":
          description: Token validated successfully
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EndUserAuthResponse"
              examples:
                noTwoFactor:
                  summary: Two-factor not required
                  value:
                    data:
                      endUserId: "clx1enduser00000000000000"
                      clientSessionToken: "b7c1f0a2-3d4e-5f60-8a91-2b3c4d5e6f70"
                      userJwt: null
                      totpLink: null
                    metadata: null
                twoFactorEnrolled:
                  summary: Two-factor required, user already enrolled
                  value:
                    data:
                      endUserId: "clx1enduser00000000000000"
                      clientSessionToken: null
                      userJwt: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.example.userjwt"
                      totpLink: null
                    metadata: null
                twoFactorEnrollment:
                  summary: Two-factor required, first sign-in
                  value:
                    data:
                      endUserId: "clx1enduser00000000000000"
                      clientSessionToken: null
                      userJwt: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.example.userjwt"
                      totpLink: "otpauth://totp/Example%20App:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Example%20App"
                    metadata: null
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"

  # ── OAuth ──────────────────────────────────────────────────────────────────
  /oauth/urls:
    get:
      operationId: getOAuthUrls
      summary: Get the OAuth authorize URLs
      description: |
        Returns the provider authorize URLs to send the end user to. Open the
        returned URL in a browser to start the sign-in.

        The response contains a key only for each provider enabled on the
        environment, so an environment with Google enabled and Apple disabled
        returns `google` only. An environment with neither enabled returns an
        empty object.
      tags:
        - OAuth
      parameters:
        - name: redirectUrl
          in: query
          required: true
          description: |
            Where to send the user after a completed sign-in. Must exactly match
            one of the environment's Redirect URLs.
          schema:
            type: string
            format: uri
          example: "https://example.com/callback"
      responses:
        "200":
          description: Authorize URLs retrieved successfully
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OAuthUrlsResponse"
              example:
                data:
                  google: "https://accounts.google.com/o/oauth2/v2/auth?client_id=example.apps.googleusercontent.com&redirect_uri=https%3A%2F%2Fapi.portalhq.io%2Fapi%2Fv3%2Fauth%2Foauth%2Fcallback%2Fexample-auth-environment-id&response_type=code&scope=openid%20email%20profile"
                  apple: "https://appleid.apple.com/auth/authorize?client_id=com.example.auth&redirect_uri=https%3A%2F%2Fapi.portalhq.io%2Fapi%2Fv3%2Fauth%2Foauth%2Fcallback%2Fexample-auth-environment-id&response_type=code&response_mode=form_post&scope=name%20email"
                metadata: null
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          description: |
            Unauthorized. Returned when the environment header is invalid, when
            `redirectUrl` is not on the environment's allow list, or when a
            provider is enabled but its credentials have not been configured.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
              example:
                error: "Unauthorized"

  /oauth/tokens:
    post:
      operationId: validateOAuthToken
      summary: Validate an OAuth token
      description: |
        Exchanges the single-use `token` returned to your `redirectUrl` after a
        Google or Apple sign-in for a session.

        The response depends on whether the environment requires two-factor
        authentication. See the `EndUserAuthResult` schema for the three shapes
        and how to branch on them.
      tags:
        - OAuth
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/TokenValidationRequest"
            example:
              token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.example.token"
      responses:
        "200":
          description: Token validated successfully
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EndUserAuthResponse"
              example:
                data:
                  endUserId: "clx1enduser00000000000000"
                  clientSessionToken: "b7c1f0a2-3d4e-5f60-8a91-2b3c4d5e6f70"
                  userJwt: null
                  totpLink: null
                metadata: null
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"

  # ── Two-Factor Authentication ──────────────────────────────────────────────
  /totps/validations:
    post:
      operationId: validateTotp
      summary: Validate a TOTP code
      description: |
        Completes a sign-in that requires two-factor authentication by verifying
        the six-digit code from the user's authenticator app, and returns the
        Client Session Token.

        This endpoint needs two credentials: the `x-portal-auth-environment-id`
        header, and the `userJwt` from the magic link or OAuth validation
        response sent as a Bearer token. The `userJwt` may also be passed in the
        request body instead of the `Authorization` header.

        On the user's first successful verification, enrollment is completed and
        subsequent sign-ins no longer return a `totpLink`.
      tags:
        - Two-Factor Authentication
      security:
        - authEnvironmentId: []
          userJwt: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ValidateTotpRequest"
            example:
              code: "123456"
      responses:
        "200":
          description: TOTP code validated successfully
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ValidateTotpResponse"
              example:
                data:
                  clientSessionToken: "b7c1f0a2-3d4e-5f60-8a91-2b3c4d5e6f70"
                metadata: null
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          description: |
            Unauthorized. Returned when the `userJwt` is missing, invalid, or
            already used, when the code is incorrect, or when the user has no
            TOTP secret enrolled.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
              example:
                error: "Unauthorized"

components:
  securitySchemes:
    authEnvironmentId:
      type: apiKey
      in: header
      name: x-portal-auth-environment-id
      description: |
        The Auth Environment ID for the environment you are authenticating
        against. Find it in the Portal dashboard under
        **Authentication > Configure**.
    userJwt:
      type: http
      scheme: bearer
      description: |
        The `userJwt` returned by a magic link or OAuth validation when the
        environment requires two-factor authentication. Single use.

  responses:
    BadRequest:
      description: Bad request. A required field is missing or malformed.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
          example:
            error: "email must be a valid email address."
    Unauthorized:
      description: |
        Unauthorized. Returned for every authentication failure, with no
        indication of which check failed.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
          example:
            error: "Unauthorized"
    RateLimited:
      description: Too many requests. Retry after a short delay.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
          example:
            error: "Too many requests, please try again later"
    InternalServerError:
      description: Internal server error.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
          example:
            error: "Internal server error"

  schemas:
    ErrorResponse:
      type: object
      properties:
        error:
          type: string
          description: Error message describing what went wrong
        details:
          type: object
          description: Additional context about the failure, when available
        code:
          type: string
          description: Machine-readable error code, when available

    AuthMethod:
      type: string
      description: A sign-in method.
      enum:
        - EMAIL_MAGIC_LINK
        - GOOGLE
        - APPLE

    AuthMethodsResponse:
      type: object
      properties:
        data:
          type: object
          properties:
            allowedAuthMethods:
              type: array
              description: |
                The sign-in methods enabled for this environment. An empty array
                means no method has been enabled yet.
              items:
                $ref: "#/components/schemas/AuthMethod"
            autoCreateWallet:
              type: boolean
              description: |
                Whether this environment is configured to create a wallet for
                newly authenticated users. Portal does not generate the wallet
                itself. Use this as a signal for whether your app should call
                the SDK's wallet creation method after sign-in.
        metadata:
          type:
            - object
            - "null"
          description: Always `null` for this endpoint.

    SendMagicLinkRequest:
      type: object
      required:
        - email
        - redirectUrl
        - fromEmail
        - templateId
      properties:
        email:
          type: string
          format: email
          description: |
            The end user's email address. Must be lowercase. Addresses
            containing uppercase characters are rejected with `400`.
        redirectUrl:
          type: string
          format: uri
          description: |
            Where the magic link sends the user. Must exactly match one of the
            environment's Redirect URLs. Matching is exact string equality, so
            wildcards and path prefixes are not supported.
        fromEmail:
          type: string
          format: email
          description: |
            The address the email is sent from. Must belong to a verified
            sending domain that is enabled for this environment.
        templateId:
          type: string
          description: |
            The Portal template ID from **Authentication > Templates** in the
            dashboard.

    SendMagicLinkResponse:
      type: object
      properties:
        data:
          type: object
          properties:
            sent:
              type: boolean
              description: |
                Always `true`. Confirms Portal accepted the request and handed
                the email off for delivery. It does not confirm the user
                received it.
        metadata:
          type:
            - object
            - "null"
          description: Always `null` for this endpoint.

    TokenValidationRequest:
      type: object
      required:
        - token
      properties:
        token:
          type: string
          description: |
            The single-use `token` query parameter Portal appended to your
            redirect URL.

    EndUserAuthResult:
      type: object
      description: |
        The outcome of a sign-in. Which fields are set depends on whether the
        environment requires two-factor authentication:

        - **Two-factor not required.** `clientSessionToken` is set, `userJwt`
          and `totpLink` are `null`. The sign-in is complete.
        - **Two-factor required, user already enrolled.** `userJwt` is set,
          `clientSessionToken` and `totpLink` are `null`. Prompt for a code and
          call `POST /totps/validations`.
        - **Two-factor required, first sign-in.** `userJwt` and `totpLink` are
          both set, `clientSessionToken` is `null`. Render `totpLink` as a QR
          code for the user to scan, then prompt for a code and call
          `POST /totps/validations`.

        Branch on `clientSessionToken` being non-null to decide whether the
        sign-in is finished.
      properties:
        endUserId:
          type: string
          description: The Portal end user's ID. Stable across sign-ins.
        clientSessionToken:
          type:
            - string
            - "null"
          description: |
            The Client Session Token for this user's client, or `null` when a
            second factor is still outstanding.
        userJwt:
          type:
            - string
            - "null"
          description: |
            Single-use credential authorizing the TOTP step, or `null` when no
            second factor is required.
        totpLink:
          type:
            - string
            - "null"
          description: |
            An `otpauth://` URI to render as a QR code, returned only on the
            user's first two-factor sign-in. `null` otherwise.

    EndUserAuthResponse:
      type: object
      properties:
        data:
          $ref: "#/components/schemas/EndUserAuthResult"
        metadata:
          type:
            - object
            - "null"
          description: Always `null` for this endpoint.

    OAuthUrlsResponse:
      type: object
      properties:
        data:
          type: object
          description: |
            Contains a key for each enabled provider only. Empty when neither
            Google nor Apple is enabled.
          properties:
            google:
              type: string
              format: uri
              description: |
                The Google authorize URL. Present only when Google is enabled.
            apple:
              type: string
              format: uri
              description: |
                The Apple authorize URL. Present only when Apple is enabled.
        metadata:
          type:
            - object
            - "null"
          description: Always `null` for this endpoint.

    ValidateTotpRequest:
      type: object
      required:
        - code
      properties:
        code:
          type: string
          description: The six-digit code from the user's authenticator app.
        userJwt:
          type: string
          description: |
            The `userJwt` for this sign-in. Optional, and only needed if you are
            not sending it as a Bearer token in the `Authorization` header.

    ValidateTotpResponse:
      type: object
      properties:
        data:
          type: object
          properties:
            clientSessionToken:
              type: string
              description: The Client Session Token for this user's client.
        metadata:
          type:
            - object
            - "null"
          description: Always `null` for this endpoint.
