Repository navigation
Sign in through the app's backend in the Android SDK - #42
Merged
Merged
Conversation
An Android app that signs in directly keeps its refresh token and API credentials on the handset. Setting `endpoints.backend` hands sign-in to a backend the app owns: the backend runs the OAuth flow, keeps the tokens, and proxies the app's API calls, so the app holds only an opaque session handle. It follows the Backend for Frontend pattern of RFC 10017, with the same contract the browser SDKs use, so one backend serves both. `ThunderIDBackendEndpoints` names the backend's HTTPS base URL and its `signIn`, `session`, `checkSession`, `refresh`, `signOut`, `proxy` and `token` paths, each defaulting under `/api/auth`. A base URL that is not HTTPS, or an endpoint that is not a path under it, fails initialization before any request. `baseUrl` stays the API base, as in the browser SDKs. Sign-in hands the session over once. `buildSignInUrl` returns the backend's sign-in endpoint with `redirect_uri` and an S256 `code_challenge`, for the app to open in a Custom Tab as it does today. `handleRedirectCallback` reads the one-time `session_code`, or `error`, from the redirect and posts it with the PKCE verifier to `session`, which answers with the handle. The handle goes in the same `StorageAdapter` the tokens use, Keystore-backed by default, is never logged, and is sent as `X-Session-Handle` on every backend call. A callback for a sign-in this client did not start is refused. `BackendClient` talks to the backend and the HTTP client hands it every request, so `httpRequest` and the SDK's own calls under `baseUrl` go through the proxy with the handle and never with a token. A request to a URL outside the API base is refused rather than sent. Redirects are not followed on these requests. The SDK acts only on the backend's own `401`: `session_expired` refreshes once and retries, and a second failure signs out; `login_required` signs out. Any other `401` is the API's answer and is returned as is. Some APIs cannot sit behind the proxy, such as a data plane on another host. `bypass.urls` lists their base URLs. A request under one goes straight there without the handle and, while `attachToken` is on (the default), with `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: one per client, kept in the token store, reused until 30 seconds before it expires, 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. With `attachToken` off, bypassed requests carry no token and the token endpoint is never called. `isSignedIn` and `getUser` read the backend session, and `isLoading` stays true until the first session read settles, which initialization starts. `signOut` calls the backend, clears the handle and the token even when the backend fails, and returns the end-session URL it answers with. `exchangeToken` and the embedded sign-in and sign-up flows fail with the new `UNSUPPORTED_OPERATION` code while the mode is on. A session that ends elsewhere emits `sessionEnded`: a `401 login_required`, a `checkSession` that finds it gone, or `watchSession`, which checks when the app comes to the foreground and polls every `session.checkInterval` seconds. `ThunderIDProvider` runs it while the app is started, and `ThunderIDState` clears the user when it emits. A sign-out the app starts itself does not emit. The playground takes an optional `THUNDERID_BACKEND_URL` and checks the session through the proxy. Apps that do not set `endpoints.backend` keep working exactly as today. Signed-off-by: Brion <info@brionmario.com>
🔀 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. |
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars.
|
This was referenced Oct 24, 2026
DonOmalVindula
approved these changes
Oct 26, 2026
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
A mobile application that already has a backend keeps its refresh token and API credentials on the handset today. This adds an opt-in mode where sign-in runs on a backend the application owns (the Backend for Frontend pattern of RFC 10017): the backend runs the OAuth flow and keeps the tokens, and the app holds only an opaque session handle in the SDK's Keystore-backed storage. It is the Android port of javascript-sdks#132 and implements the mobile half of the contract in thunderid#5991, alongside ios-sdks#34.
Setting
endpoints.backendturns it on. Apps that do not set it keep working exactly as today.Sample config
How it works
sequenceDiagram autonumber participant App as Android app (SDK) participant Web as Custom Tab participant BFF as App backend participant IdP as ThunderID participant API as API behind the proxy participant DP as Bypassed API (data plane) App->>Web: open signIn?redirect_uri&code_challenge (S256) Web->>BFF: GET /api/auth/signin BFF->>IdP: Authorization code + PKCE IdP-->>BFF: Tokens (kept on the backend) BFF-->>Web: redirect_uri?session_code Web-->>App: session_code App->>BFF: POST /api/auth/session {session_code, code_verifier} BFF-->>App: {handle, isSignedIn, user, expiresAt} Note over App: handle → the SDK's Keystore-backed storage Note over App,API: URL under baseUrl → proxy App->>BFF: /api/auth/proxy/users + X-Session-Handle BFF->>API: GET /users + Authorization: Bearer API-->>App: 200 (through the backend) Note over App,DP: URL under bypass.urls → straight there App->>BFF: POST /api/auth/token + X-Session-Handle BFF-->>App: {access_token, expires_in} (no refresh token) App->>DP: GET /api/orders + Authorization: Bearer DP-->>App: 200 Note over App,DP: On 401 → new token, retry onceApproach
ThunderIDEndpointsgainsbackend: ThunderIDBackendEndpoints?withbaseUrl(HTTPS, required), the seven endpoint paths defaulting under/api/auth, andbypass: ThunderIDBackendBypass(urls, attachToken).ThunderIDConfiggainssession: ThunderIDSessionConfig(checkInterval). Endpoint entries must be paths and bypass URLs must be HTTPS; both are checked atinitialize. The backend endpoints never reach OIDC discovery.buildSignInUrlreturns the backend'ssignInURL withredirect_uri,code_challengeandcode_challenge_method=S256; the app opens it in a Custom Tab as it does today.handleRedirectCallbackreadssession_code(orerror), posts{session_code, code_verifier}tosession, and stores the returned handle through the SDK's storage adapter (Keystore-backed by default), keyed byvendorandinstanceId. Every later backend call carries it inX-Session-Handle. The handle is never logged.httpRequest(ThunderIDHttpRequest)sends URLs underbaseUrlthroughproxy, refuses URLs outside it and outsidebypass.urls, and returns any status rather than throwing, so the API's own401reaches the caller untouched. In backend mode the SDK's own requests (profile, schema, management, flow meta) go through the proxy too. Backend-mode requests do not follow redirects, so the handle or bearer token never reaches another host.session_expiredrefreshes once (one shared refresh) and retries; a second failure, orlogin_required, signs the app out and emits onsessionEnded.bypass.urlsgoes straight to its URL. WhileattachTokenis on (the default), it carriesAuthorization: Bearerwith the token from POSTtoken, handled like a client credentials token: one per client, stored throughTokenStorewithout refresh or ID tokens, reused until 30 s before expiry, one shared request. On a401from the API it gets a new token and retries once. WithattachToken = false, it carries no token and never callstoken.isLoadingstays true until the first session read settles.isSignedInandgetUsercome from the backend session.getAccessTokenreturns the bypass token.exchangeTokenand the embeddedsignIn/signUpthrowUNSUPPORTED_OPERATION.signOutcalls the backend, clears local state even when that fails (then reports the failure), and returns the end-session URL.watchSession()checks on start (at most once every 5 s) and polls everysession.checkIntervalseconds. The Compose provider runs it while the app is started and clears the user whensessionEndedemits.THUNDERID_BACKEND_URLand a "Sign in on a backend" check that calls/users/methrough the proxy.Related Issues
Related PRs
Checklist
src/test/kotlin/dev/thunderid/android/backend/(37 new). The Android SDK was not installed locally, so the core sources and tests were compiled and run on plain JVM 17:BackendModeTest30/30,BackendEndpointsTest7/7, plus the existingThunderIDClientTest19/19 andManagementApiTest9/9.ktlintclean. The Compose and playground changes rely on CI.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.Security checks