Skip to content

Support a dedicated backend (BFF) - #132

Open
brionmario wants to merge 1 commit into
thunder-id:mainfrom
brionmario:feat-bff-mode
Open

brionmario wants to merge 1 commit into
thunder-id:mainfrom
brionmario:feat-bff-mode

Conversation

@brionmario

@brionmario brionmario commented Oct 22, 2026 •

Copy link
Copy Markdown
Member

Purpose

Browser apps built on @thunderid/browser, @thunderid/react and @thunderid/vue keep their tokens in browser storage, where any script that runs on the page can read them. This adds an opt-in mode where sign-in runs on a backend the app owns, on the app's own origin (the Backend for Frontend pattern from RFC 10017). The backend keeps the tokens behind an HttpOnly cookie, and the SDK never holds one.

Setting endpoints.backend turns it on. Unset paths default under /api/auth:

<ThunderIDProvider
  baseUrl="https://auth.example.com"
  endpoints={{backend: {signIn: '/api/auth/signin'}}}
>

Apps that do not set it keep working exactly as today.

Sample config

<ThunderIDProvider
  baseUrl="https://api.example.com"            // the API the proxy fronts
  endpoints={{
    backend: {
      // Sign-in and the session. Each defaults under /api/auth; set only what differs.
      signIn: '/api/auth/signin',
      session: '/api/auth/session',
      checkSession: '/api/auth/checksession',
      refresh: '/api/auth/refresh',
      signOut: '/api/auth/signout',
      proxy: '/api/auth/proxy',                // https://api.example.com/* is sent here

      // Optional: requests that skip the proxy, such as calls to a data plane on another origin.
      bypass: {
        urls: ['https://dp.example.com/api'],
        attachToken: true,                     // default; false sends only the cookies
      },
      token: '/api/auth/token',                // issues the access token for bypass
    },
  }}
  session={{checkInterval: 30}}                // poll checkSession every 30 s while visible
>
  <App />
</ThunderIDProvider>
const {http} = useThunderID();

await http.request({url: 'https://api.example.com/users'});        // via the proxy, cookie only
await http.request({url: 'https://dp.example.com/api/orders'});    // bypass, Bearer token + cookies

The token endpoint answers POST with a plain OAuth token response and no refresh token:

{"access_token": "eyJhbGciOi...", "token_type": "Bearer", "expires_in": 300, "scope": "orders:read"}

How requests flow

sequenceDiagram
    autonumber
    participant App as Browser app (SDK)
    participant BFF as App backend (BFF)
    participant IdP as ThunderID
    participant API as API behind the proxy
    participant DP as Direct API (data plane)

    App->>BFF: GET /api/auth/signin
    BFF->>IdP: Authorization code + PKCE
    IdP-->>BFF: Tokens (kept on the backend)
    BFF-->>App: Set-Cookie (HttpOnly session)

    Note over App,API: URL under baseUrl → proxy
    App->>BFF: http.request → /api/auth/proxy/users (cookie, X-Requested-With)
    BFF->>API: GET /users + Authorization: Bearer
    API-->>BFF: 200
    BFF-->>App: 200

    Note over App,DP: URL under bypass.urls → straight there
    App->>BFF: POST /api/auth/token (cookie, X-Requested-With)
    BFF-->>App: {access_token, expires_in} (no refresh token)
    Note over App: Stored like a client credentials token,<br/>reused until it nears expiry
    App->>DP: GET /api/orders + Authorization: Bearer (cookies included)
    DP-->>App: 200
    Note over App,DP: On 401 → new token from /api/auth/token, retry once
Loading

The backend is any server that implements the contract — the app's own Node, Go or Java backend, or a gateway in front of it. Nothing here assumes a particular implementation.

Approach

  • Core. endpoints.backend takes signIn, session, checkSession, refresh, signOut, proxy, token and bypass. resolveBackendEndpoints fills in the defaults. The key stays out of the OIDC discovery metadata.
  • Browser. BackendClient talks to the backend. BackendHttpClient is registered as the instance's HTTP client, so every existing http.request call and the provider's /users/me fetch go through the backend's proxy with the session cookie and an X-Requested-With header, and never with a token. A request to a URL outside the API base is refused rather than sent without credentials.
  • Bypass. Some APIs cannot sit behind the proxy, such as a data plane on another origin. bypass.urls lists their base URLs. A request under one goes straight there with the cookies (credentials: 'include') and without X-Requested-With. While bypass.attachToken is on, which is the default, it also carries Authorization: Bearer; with it off, only the cookies. The token comes from the backend's token endpoint and is handled like a client_credentials token in SPA mode: one per client, stored in the configured session storage with created_at, reused until 30 s before expiry, then requested again, with concurrent callers sharing one request. On a 401 from the API the SDK gets a new token and retries once; a second 401 is returned as is. A session_expired from the token endpoint refreshes the session and asks again, and login_required signs the app out. The stored token is removed on sign-out and when the session ends elsewhere. Without bypass, nothing changes.
  • Client surface. signIn, signOut, isSignedIn, getUser and getDecodedIdToken go through the backend. getAccessToken returns the token for bypass from the backend's token endpoint. getIdToken, exchangeToken and revokeAccessToken reject with a clear error, because the browser holds no ID or refresh token.
  • Loading. The client reads the session during initialize and reports loading until that read settles. Without this, route guards saw "not loading, not signed in" and started a sign-in, which the SSO session completed straight away, in a loop.
  • Refresh. The SDK refreshes only when the backend answers 401 session_expired. Any other 401 is the API's own answer and is returned as is.
  • Sign-out elsewhere. The SDK fires its sign-out hook when the proxy answers 401 login_required, when a check on page focus finds the session gone, or when polling checkSession does. Polling runs every session.checkInterval seconds, a new key on the existing session object. on('sign-out') now also keeps its callbacks, so an emitted sign-out reaches them; it kept only the "returned from a sign-out redirect" case before, and nothing emitted it. The React and Vue providers listen to the hook and clear the user, so a route guard sends the user to sign in. A sign-out the app starts itself does not fire it.
  • React. The embedded sign-in, sign-up and recovery flows are refused in this mode for now.

Supersedes

Replaces #118, which was cut against an older main and was framed as a dependency of the Console's own backend. This branch is rebased on current main — where the OIDC session-management iframe has since been removed, so backend mode no longer has to opt out of it — and stands on its own: the capability is the SDK's, with no product-side counterpart required.

Related Issues

Related PRs

  • The backend contract is being written up in the product repository's docs. Link to follow.

Checklist

  • Followed the contribution guidelines.
  • Manual test round performed and verified, on Support a dedicated backend (BFF) #118's commit before the rebase: a console built against it signed in, made API calls, reloaded, created a user and signed out against a local server running a backend that implements the contract. The rebase onto current main, the sign-out hook change and bypassed requests are covered by the unit tests; the manual round will be repeated before merge.
  • Documentation provided. (Add links if there are any)
  • Tests provided. (Add links if there are any)
    • Unit Tests
    • Integration Tests
  • Breaking changes. (Fill if applicable)
    • Breaking changes section filled.
    • breaking change label added.
  • Cross-SDK parity. Exactly one of parity/prs-raised or parity/prs-not-needed added.
    • If parity/prs-raised, the port links are posted as a reply on the parity check's comment.
    • Decision: ports are needed. The capability now covers the mobile SDKs as well
      (#5868, specified in
      thunderid#5991): ios-sdks, android-sdks and
      flutter-sdks implement the same contract, with the platform differences the specification fixes.
      They are raised as their own pull requests, tracked on #5868:
      ios-sdks#34,
      android-sdks#42 and
      flutter-sdks#52.
      node, express, nextjs and nuxt need nothing: they are the backend side of this contract.

Security checks

  • Followed secure coding standards.
  • Confirmed that this PR doesn't commit any keys, passwords, tokens, usernames, or other secrets.

@coderabbitai

coderabbitai Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

Run configuration
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

🔀 Cross-SDK feature parity, ports raised

This pull request is labelled parity/prs-raised, so the capability it introduces is expected in the other ThunderID SDKs as well.

Ports (DO NOT EDIT)

  • thunder-id/ios-sdks, iOS => #34
  • thunder-id/android-sdks, Android => #42
  • thunder-id/flutter-sdks, Flutter => #52

3 of 3 sibling SDKs accounted for.

Every sibling SDK is accounted for.

The contract is in the SDK development specification.

Browser apps keep their tokens in browser storage, where any script on the
page can read them. Setting `endpoints.backend` hands sign-in to a backend the
app owns, on the app's own origin: the backend runs the OAuth flow, keeps the
tokens, and proxies the app's API calls, so the browser only ever holds an
`HttpOnly` cookie. It follows the Backend for Frontend pattern of RFC 10017.

The backend is any server that implements the contract: `signIn`, `session`,
`checkSession`, `refresh`, `signOut`, `proxy` and `token`, each defaulting to
a path under `/api/auth`. The key stays out of the OIDC discovery metadata.

`BackendClient` talks to the backend and `BackendHttpClient` is registered as
the instance's HTTP client, so every `http.request` call goes through the
proxy with the session cookie and an `X-Requested-With` header, and never with
a token. A request to a URL outside the API base is refused rather than sent
without credentials. `signIn`, `signOut`, `isSignedIn`, `getUser` and
`getDecodedIdToken` go through the backend; `getIdToken`, `exchangeToken` and
`revokeAccessToken` reject, because the browser holds no ID or refresh token.

Some APIs cannot sit behind the proxy, such as a data plane on another origin.
`endpoints.backend.bypass` lists their base URLs in `urls`. A request under one
goes straight there with the cookies and, while `attachToken` is on (the
default), `Authorization: Bearer`; on a 401 from the API it gets a new token
and retries once. The token comes from the backend's
`token` endpoint, as an OAuth token response without a refresh token, and is
handled like a client credentials token in SPA mode: one per client, kept in
the configured session storage with `created_at`, reused until it nears
expiry, then requested again, with concurrent callers sharing one request.
`getAccessToken` returns it. A `session_expired` from the token endpoint
refreshes the session and asks again; `login_required` signs the app out. The
stored token is removed on sign-out and when the session ends elsewhere.

The client reports loading until the first session read settles, so route
guards do not start a sign-in before it is known whether the user is signed
in. The SDK refreshes only when the backend answers `401 session_expired`; any
other `401` is the API's own answer and is returned as is.

A session that ends elsewhere fires the `sign-out` hook: a `401
login_required` from the proxy, a check when the page regains focus, or
polling `checkSession` every `session.checkInterval` seconds. The hook now also keeps its
callbacks, so an emitted sign-out reaches them, and the React and Vue
providers clear the user when it fires. A sign-out the app starts itself does
not fire it.

Apps that do not set `endpoints.backend` keep working exactly as today.

Signed-off-by: Brion <info@brionmario.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

parity/prs-raised Ports exist in the other SDKs, or are recorded as not applying there

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support a BFF mode in the browser and mobile SDKs

1 participant