Skip to content

Sign in through the app's backend in the Android SDK - #42

Merged
brionmario merged 1 commit into
thunder-id:mainfrom
brionmario:feat-bff-mode
Oct 26, 2026
Merged

brionmario merged 1 commit into
thunder-id:mainfrom
brionmario:feat-bff-mode

Conversation

@brionmario

@brionmario brionmario commented Oct 24, 2026 •

Copy link
Copy Markdown
Member

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.backend turns it on. Apps that do not set it keep working exactly as today.

Sample config

val config = ThunderIDConfig(
    baseUrl = "https://api.example.com",            // API base: requests under it go through the proxy
    afterSignInUrl = "com.example.app://callback",  // redirect_uri for the sign-in handoff
    endpoints = ThunderIDEndpoints(
        backend = ThunderIDBackendEndpoints(
            baseUrl = "https://app.example.com",    // the backend; HTTPS required
            // signIn, session, checkSession, refresh, signOut, proxy and token default under /api/auth
            bypass = ThunderIDBackendBypass(urls = listOf("https://dp.example.com/api")), // attachToken defaults to true
        ),
    ),
    session = ThunderIDSessionConfig(checkInterval = 60),  // poll checkSession every 60 s
    storage = EncryptedStorageAdapter(context),
)

val url = client.buildSignInUrl()                  // open in a Custom Tab
val user = client.handleRedirectCallback(callbackUri)

client.httpRequest(ThunderIDHttpRequest(url = "https://api.example.com/users"))        // via the proxy
client.httpRequest(ThunderIDHttpRequest(url = "https://dp.example.com/api/orders"))    // bypass, Bearer token

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 once
Loading

Approach

  • Config. ThunderIDEndpoints gains backend: ThunderIDBackendEndpoints? with baseUrl (HTTPS, required), the seven endpoint paths defaulting under /api/auth, and bypass: ThunderIDBackendBypass(urls, attachToken). ThunderIDConfig gains session: ThunderIDSessionConfig(checkInterval). Endpoint entries must be paths and bypass URLs must be HTTPS; both are checked at initialize. The backend endpoints never reach OIDC discovery.
  • Sign-in handoff. buildSignInUrl returns the backend's signIn URL with redirect_uri, code_challenge and code_challenge_method=S256; the app opens it in a Custom Tab as it does today. handleRedirectCallback reads session_code (or error), posts {session_code, code_verifier} to session, and stores the returned handle through the SDK's storage adapter (Keystore-backed by default), keyed by vendor and instanceId. Every later backend call carries it in X-Session-Handle. The handle is never logged.
  • Requests. The new httpRequest(ThunderIDHttpRequest) sends URLs under baseUrl through proxy, refuses URLs outside it and outside bypass.urls, and returns any status rather than throwing, so the API's own 401 reaches 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.
  • 401 handling. session_expired refreshes once (one shared refresh) and retries; a second failure, or login_required, signs the app out and emits on sessionEnded.
  • Bypass. A request under bypass.urls goes straight to its URL. While attachToken is on (the default), it carries Authorization: Bearer with the token from POST token, handled like a client credentials token: one per client, stored through TokenStore without refresh or ID tokens, reused until 30 s before expiry, one shared request. On a 401 from the API it gets a new token and retries once. With attachToken = false, it carries no token and never calls token.
  • Client surface. isLoading stays true until the first session read settles. isSignedIn and getUser come from the backend session. getAccessToken returns the bypass token. exchangeToken and the embedded signIn/signUp throw UNSUPPORTED_OPERATION. signOut calls the backend, clears local state even when that fails (then reports the failure), and returns the end-session URL.
  • Session ended elsewhere. watchSession() checks on start (at most once every 5 s) and polls every session.checkInterval seconds. The Compose provider runs it while the app is started and clears the user when sessionEnded emits.
  • Playground. An optional THUNDERID_BACKEND_URL and a "Sign in on a backend" check that calls /users/me through the proxy.

Related Issues

Related PRs

Checklist

  • Followed the contribution guidelines.
  • Manual test round performed and verified. Not yet: it needs a backend that implements the contract and a device; covered by unit tests against a stubbed backend.
  • Documentation provided. (Add links if there are any)
  • Tests provided. (Add links if there are any)
    • Unit Tests: 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: BackendModeTest 30/30, BackendEndpointsTest 7/7, plus the existing ThunderIDClientTest 19/19 and ManagementApiTest 9/9. ktlint clean. The Compose and playground changes rely on CI.
    • 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.

Security checks

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

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>
@github-actions

github-actions Bot commented Oct 24, 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/javascript-sdks, JavaScript (React, Vue, Nuxt, Next.js, Express, React Router, TanStack Router, Browser, Node) => #132
  • thunder-id/ios-sdks, iOS => #34
  • 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.

@coderabbitai

coderabbitai Bot commented Oct 24, 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.

@brionmario
brionmario merged commit f713b4e into thunder-id:main Oct 26, 2026
10 of 12 checks passed
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.

2 participants