Repository navigation
Support a dedicated backend (BFF) - #132
Open
brionmario wants to merge 1 commit into
Open
brionmario wants to merge 1 commit into
brionmario wants to merge 1 commit into
Conversation
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars.
|
🔀 Cross-SDK feature parity, ports raisedThis pull request is labelled Ports (DO NOT EDIT)
3 of 3 sibling SDKs accounted for. Every sibling SDK is accounted for. The contract is in the SDK development specification. |
6 of 13 tasks
brionmario
force-pushed
the
feat-bff-mode
branch
from
October 22, 2026 12:33
b3234a5 to
e0ce885
Compare
This was referenced Oct 22, 2026
brionmario
force-pushed
the
feat-bff-mode
branch
2 times, most recently
from
October 23, 2026 10:04
664621c to
c335341
Compare
brionmario
force-pushed
the
feat-bff-mode
branch
from
October 24, 2026 07:34
c335341 to
595ba10
Compare
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>
brionmario
force-pushed
the
feat-bff-mode
branch
from
October 24, 2026 07:34
595ba10 to
d2b8ec2
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Purpose
Browser apps built on
@thunderid/browser,@thunderid/reactand@thunderid/vuekeep 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 anHttpOnlycookie, and the SDK never holds one.Setting
endpoints.backendturns it on. Unset paths default under/api/auth:Apps that do not set it keep working exactly as today.
Sample config
The token endpoint answers
POSTwith 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 onceThe 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
endpoints.backendtakessignIn,session,checkSession,refresh,signOut,proxy,tokenandbypass.resolveBackendEndpointsfills in the defaults. The key stays out of the OIDC discovery metadata.BackendClienttalks to the backend.BackendHttpClientis registered as the instance's HTTP client, so every existinghttp.requestcall and the provider's/users/mefetch go through the backend's proxy with the session cookie and anX-Requested-Withheader, and never with a token. A request to a URL outside the API base is refused rather than sent without credentials.bypass.urlslists their base URLs. A request under one goes straight there with the cookies (credentials: 'include') and withoutX-Requested-With. Whilebypass.attachTokenis on, which is the default, it also carriesAuthorization: Bearer; with it off, only the cookies. The token comes from the backend'stokenendpoint and is handled like aclient_credentialstoken in SPA mode: one per client, stored in the configured session storage withcreated_at, reused until 30 s before expiry, then requested again, with concurrent callers sharing one request. On a401from the API the SDK gets a new token and retries once; a second401is returned as is. Asession_expiredfrom the token endpoint refreshes the session and asks again, andlogin_requiredsigns the app out. The stored token is removed on sign-out and when the session ends elsewhere. Withoutbypass, nothing changes.signIn,signOut,isSignedIn,getUserandgetDecodedIdTokengo through the backend.getAccessTokenreturns the token forbypassfrom the backend'stokenendpoint.getIdToken,exchangeTokenandrevokeAccessTokenreject with a clear error, because the browser holds no ID or refresh token.initializeand 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.401 session_expired. Any other401is the API's own answer and is returned as is.sign-outhook when the proxy answers401 login_required, when a check on page focus finds the session gone, or when pollingcheckSessiondoes. Polling runs everysession.checkIntervalseconds, a new key on the existingsessionobject.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.Supersedes
Replaces #118, which was cut against an older
mainand was framed as a dependency of the Console's own backend. This branch is rebased on currentmain— 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
Checklist
main, thesign-outhook change and bypassed requests are covered by the unit tests; the manual round will be repeated before merge.breaking changelabel added.parity/prs-raisedorparity/prs-not-neededadded.parity/prs-raised, the port links are posted as a reply on the parity check's comment.(#5868, specified in
thunderid#5991):
ios-sdks,android-sdksandflutter-sdksimplement 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,nextjsandnuxtneed nothing: they are the backend side of this contract.Security checks