Skip to content

Port the Authress login SDK; rework the theme picker and onboarding - #4

Merged
wparad merged 6 commits into
mainfrom
claude/email-app-malware-warning-yh6de6
Aug 14, 2026
Merged

Port the Authress login SDK; rework the theme picker and onboarding#4
wparad merged 6 commits into
mainfrom
claude/email-app-malware-warning-yh6de6

Conversation

@wparad

@wparad wparad commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Authentication

The app treated Authress as a plain OAuth provider and drove it through AppAuth against /authorize and /oauth/token. Neither path exists — both came from b3f6eff, the commit that invented the Numaeel product and its domains.

Authress is not an OAuth provider in that shape, so correcting the URLs would not have helped either. @authress/login-react-native is now ported directly:

POST /api/authentication          -> authenticationUrl + authenticationRequestId
open authenticationUrl in the browser
redirect to ch.rhosys.email://auth/callback  -> code + authenticationRequestId
POST /api/authentication/{id}/tokens         -> session cookies

The session is held in the authorization and user cookies, not an access/refresh pair.

Ported file by file:

File From
JwtManager jwtManager.ts — PKCE S256, payload decode, the 10s exp skew buffer
AuthStorageManager authStorageManager.ts — pending PKCE state in encrypted storage
AuthressCookieJar authStorageManager.ts — live jar plus encrypted mirror, with backupCookies / restoreCookies at the SDK's own call sites
AuthressLoginClient loginClient.ts — the flow, getToken, waitForToken, userIsLoggedIn, logout

Session upkeep uses the SDK's own methods rather than an invented mechanism: userIsLoggedIn() refreshes via PATCH /session and is called on every route change, as its documentation recommends, and waitForToken() supplies the Authorization header so a request issued mid-refresh waits rather than being rejected.

AppAuth is replaced by androidx.browser. The hosted page opens in a Custom Tab — the real browser, so passkeys and the password manager work. The browser does not share cookies with the app and does not need to: the token exchange is made by the app's own HTTP client, so the session cookie lands there.

This also resolves the duplicate redirect claim. AppAuth's RedirectUriReceiverActivity no longer competes with MainActivity for the scheme, and MainActivity forwards the redirect through onNewIntent as the SDK's Android setup describes.

The application id now defaults to app_2EAWGEdtzaeCj7b45DsDtt, matching the web app's VITE_AUTHRESS_APPLICATION_ID.

Theme picker

Five FilterChips all drew from the active theme, so every option rendered identically and none previewed what selecting it would do.

Each option is now a 168dp tile painted from its own palette — base as the ground, mantle for the footer, the text/subtext ramp, the seven accents, and two miniature mail rows. The two rows are what distinguishes the dark flavours from each other, which a flat swatch would not show. System splits between the two palettes it resolves to.

Selecting a flavour now sets it outright; previously tapping the selected chip reverted to system, which was undiscoverable and is redundant now that System is a tile.

Used by both Settings and onboarding's "Pick a look" step, which had the same problem.

Onboarding

Drops the biometric-lock step — a security preference belongs in Settings, not in the way of a first launch. Four steps instead of five.

Verification

compileDebugKotlin, lintDebug and testDebugUnitTest pass locally and in CI. No warnings.

Not verified: nothing has run against a live backend, and no test covers the new auth code. The parts to watch on first real login are the PKCE encoding, the iss comparison against https://login.rhosys.cloud, and the deep-link parsing.

claude added 6 commits August 13, 2026 12:12
The picker was five FilterChips that all drew from the active theme, so every
option looked identical and none of them showed what selecting it would do —
the one thing a theme picker exists to communicate.

Each option is now a tile painted from its own palette: its base as the
background, mantle as the footer, the text and subtext ramp, the seven accent
colours, and a pair of miniature mail rows standing in for an inbox. That covers
what actually changes when a flavour is applied, so the choice can be made by
looking rather than by trying each one.

System has no palette of its own, so its tile is split between the two palettes
it resolves to, Latte and Mocha, labelled light and dark.

Selecting now sets a flavour outright instead of toggling back to system on a
second tap. System is an explicit tile, which makes the old toggle both
redundant and surprising.

Tiles are laid out two per row by chunking rather than with a lazy grid, so the
picker can sit inside the scrolling settings column without a nested-scroll
conflict, and a trailing odd tile keeps its width instead of stretching.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VjmqdZdfZaN7gary9eEBsL
The /authorize and /oauth/token paths came from b3f6eff, the same commit that
invented the Numaeel product and its domains, and neither exists. Authress
publishes discovery at the issuer:

  authorization_endpoint  https://login.rhosys.cloud   (the issuer root)
  token_endpoint          https://login.rhosys.cloud/api/authentication/oauth/tokens

AuthressAuthManager now resolves both through
AuthorizationServiceConfiguration.fetchFromIssuer rather than hardcoding paths,
so a change on Authress's side cannot silently break sign-in the way a wrong
guess already had. The config is fetched once per process behind a mutex.

Discovery is a network call, so launchSignIn suspends and returns a Result;
LoginScreen clears its loading state if discovery fails instead of hanging on a
spinner.

The requested scope drops `email` and `offline_access`. Neither appears in
scopes_supported, which advertises only openid and profile; refresh tokens come
from the refresh_token grant, which is advertised.

The application id defaults to app_2EAWGEdtzaeCj7b45DsDtt, matching the web
app's VITE_AUTHRESS_APPLICATION_ID, rather than the invented numaeel_android.

Separately, onboarding no longer asks about biometric lock. It is a security
preference someone sets when they want it, not a decision worth interrupting a
first launch for, and Settings already has the toggle. Onboarding drops to four
steps, and its "Pick a look" step now uses the same previewing ThemePicker as
Settings — it had the same all-identical-chips problem. ThemePicker moves to
presentation/components now that two screens share it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VjmqdZdfZaN7gary9eEBsL
The previous implementation treated Authress as a plain OAuth provider and drove
it through AppAuth. That was wrong in shape, not just in URLs, which is why
guessing endpoints and then discovering them both produced something that could
not work.

@authress/login-react-native does this instead:

  1. POST /api/authentication with applicationId, redirectUrl and a PKCE S256
     challenge; the response carries an authenticationUrl and an
     authenticationRequestId.
  2. Open that URL in the device browser.
  3. Authress redirects to the app's deep link with code and
     authenticationRequestId.
  4. POST /api/authentication/{authenticationRequestId}/tokens with the code, the
     stored code verifier and the redirect URI.

There is no authorize endpoint and no token endpoint in the OAuth sense, and the
session is not an access/refresh pair — it lives in the `authorization` cookie,
with identity claims in `user`. That is why the discovered endpoints would not
have helped either.

Ported file by file from the SDK: JwtManager for PKCE and payload decoding
including its ten-second exp buffer, AuthStorageManager for the pending PKCE
state, AuthressCookieJar for the session cookies, and AuthressLoginClient for
the flow. The 4xx-on-token-exchange case is treated as success and cleanup, as
the SDK does, because it usually means the code was already redeemed.

AppAuth is dropped for androidx.browser, since the SDK opens the hosted page in
the browser rather than running an OAuth library. TokenStore no longer holds
tokens — only the selected account — and AuthInterceptor takes the bearer from
the session cookie.

This also resolves the duplicate redirect claim: AppAuth's
RedirectUriReceiverActivity is gone, so MainActivity is the only component
claiming the scheme, and it now overrides onNewIntent to forward the redirect
exactly as the SDK's own Android setup instructions describe. The redirect URI
moves to the documented scheme://host/path form.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VjmqdZdfZaN7gary9eEBsL
The port was missing the two methods that make sessions self-maintaining, so a
token would go stale and every API call would keep sending it.

waitForToken is what the SDK documents for an Authorization header. It returns
immediately when a valid session exists and otherwise waits for one being
established, so a request issued mid-refresh waits instead of being rejected.
AuthInterceptor now goes through it rather than reading the cookie directly.

userIsLoggedIn is what actually refreshes: it calls PATCH /session when the
current token is gone or expired. Its own documentation recommends calling it on
every route change, so AppNavHost now does exactly that.

Neither an OkHttp Authenticator reacting to 401 nor a pre-emptive exp check was
needed — both would have been a parallel mechanism competing with the one the
SDK already defines.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VjmqdZdfZaN7gary9eEBsL
The jar had been merged into a single always-persisted store on the reasoning
that the SDK's two-layer arrangement was redundant on Android. That was a
judgement call substituted for the reference implementation, which is exactly
the kind of deviation worth not making.

authStorageManager.ts keeps the live cookie store separate from its encrypted
mirror and moves between them at defined points. That structure is restored
here, with the call sites matching the SDK one for one:

  restoreCookies  in the constructor, before anything reads a token
  backupCookies   after a successful token exchange
  backupCookies   after a successful session check
  clear           on logout, clearing both layers together

restoreCookies keeps the SDK's early return when the live jar already holds
cookies, so a stale mirror can never overwrite an active session.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VjmqdZdfZaN7gary9eEBsL
The OAuth endpoint entry described discovering authorize/token endpoints, which
the port made irrelevant — Authress has no such exchange. Replaced with the
actual flow, and the duplicate-redirect entry is folded in as resolved, since
dropping AppAuth left MainActivity as the only claimant.

Also corrects the MFA line. Device management is absent from the email API, but
the login service does expose GET and DELETE /api/session/devices, which the SDK
wraps as getDevices and deleteDevice — so the Settings tab could be rebuilt
against those rather than needing a backend change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VjmqdZdfZaN7gary9eEBsL
@wparad wparad changed the title Make the theme picker preview the themes it offers Port the Authress login SDK; rework the theme picker and onboarding Aug 13, 2026
@wparad
wparad merged commit 8e74dad into main Aug 14, 2026
2 checks passed
@wparad
wparad deleted the claude/email-app-malware-warning-yh6de6 branch August 14, 2026 09:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants