Readest Android OAuth (Deep-Link Login)
Getting the official Readest Android APK to log in against the self-hosted stack. The web flow
already worked (see docs/deployment/readest.md); the mobile app adds two hard constraints:
- The APK bundles its own minified JS — the init-container patching only affects the web
image. The APK always sends
provider=discord. - The app's WebView is cross-origin with the API (Tauri apps load from
tauri.localhost, not fromreadest.grigri.cloud), so every supabase-js call is subject to CORS preflight.
Symptom Progression
| Phase | Symptom | Cause |
|---|---|---|
| 1 | App opens browser, login completes, app stays in browser | redirect_to=readest://auth-callback dropped by the nginx provider rewrite |
| 2 | Same as 1 | GoTrue rejected readest://auth-callback (not in GOTRUE_URI_ALLOW_LIST) |
| 3 | App returns from browser but shows "go to login" | CORS preflight blocked setSession → /auth/v1/user call (missing apikey header) |
The Full OAuth Flow (Android)
1. App (WebView): GET /auth/v1/authorize?provider=discord&redirect_to=readest://auth-callback
2. nginx: 302 → /auth/v1/authorize?provider=custom:kanidm&redirect_to=readest://auth-callback
3. GoTrue: 302 → Kanidm (redirect_uri=https://readest.grigri.cloud/callback,
passes redirect_to through)
4. Kanidm: user authenticates → 302 → /callback?code=...
5. nginx: 301 /callback → /auth/v1/callback
6. GoTrue: exchanges code, 302 → readest://auth-callback#access_token=...&refresh_token=...
7. Android: intent → NativeBridgePlugin resolves pending authWithCustomTab invoke
8. App (WebView): supabase.auth.setSession(...) → GET /auth/v1/user ← CORS preflight here
9. App: session persisted, navigate to library
Steps 1–7 worked after the nginx/GoTrue config fixes. Step 8 was the silent killer: the preflight failed, so no request ever reached GoTrue, and the app concluded there was no session.
Root Causes
1. Nginx provider rewrite dropped redirect_to
The original configuration-snippet replaced the whole query string with
provider=custom:kanidm, discarding the app's redirect_to=readest://auth-callback. GoTrue then
fell back to GOTRUE_SITE_URL and redirected the browser to the web UI after login.
Pitfalls hit while fixing this (nginx "if is evil"):
mapis only valid inhttpcontext — cannot be used in ingress snippets.- Nested
ifblocks are not allowed; combine conditions via flag variables and a "gate" string. rewrite ... laston the authorize URI re-enters the same location → rewrite loop (500). Usereturn 302instead.- Variables set inside an
ifthat doesn't match are empty — default them first (set $readest_redirect_to "..."before theif).
Working snippet (on both internal and external API ingresses):
set $readest_redirect_to "https://readest.grigri.cloud/auth/callback";
if ($arg_redirect_to != "") {
set $readest_redirect_to $arg_redirect_to;
}
set $readest_authorize "no";
if ($uri ~* "^/auth/v1/authorize$") {
set $readest_authorize "yes";
}
set $readest_rewrite "no";
if ($args ~* "provider=(google|apple|github|discord)") {
set $readest_rewrite "yes";
}
if ($args = "") {
set $readest_rewrite "yes";
}
set $readest_gate "$readest_authorize:$readest_rewrite";
if ($readest_gate = "yes:yes") {
return 302 $uri?provider=custom:kanidm&redirect_to=$readest_redirect_to;
}
2. GOTRUE_URI_ALLOW_LIST must include the custom scheme
GoTrue silently ignores redirect_to values that don't match GOTRUE_URI_ALLOW_LIST and falls
back to GOTRUE_SITE_URL. Add the deep-link target:
GOTRUE_URI_ALLOW_LIST: https://readest.grigri.cloud/**,https://readest.internal.grigri.cloud/**,readest://auth-callback
3. GoTrue CORS does not allow the apikey header (the hard one)
supabase-js sends apikey: <anon-key> on every request. GoTrue's built-in CORS middleware
(rs/cors, internal/api/api.go) only allows:
Accept, Authorization, Content-Type, X-Client-IP, X-Client-Info, X-JWT-AUD, x-use-cookie, X-Supabase-Api-Version
A preflight that includes apikey gets a 204 with no Access-Control-Allow-* headers → the
browser blocks the actual request. On Supabase Cloud Kong sits in front and handles this; when you
expose GoTrue directly through ingress-nginx, cross-origin clients break.
Why the web app worked: it is served from the same origin as the API → no preflight. The Tauri
WebView (origin tauri.localhost / https://tauri.localhost) is cross-origin, so after the deep
link delivered the tokens, supabase.auth.setSession() → GET /auth/v1/user died at preflight.
setSession always makes a network call (_getUser when the token is valid, refresh grant
when expired) — the absence of that request in GoTrue logs is the diagnostic tell.
Fix (merged with the defaults via AllAllowedHeaders in gotrue conf):
GOTRUE_CORS_ALLOWED_HEADERS: apikey
How to Diagnose
Verify the redirect chain end-to-end with curl (no real login needed until the last hop):
# Step 2: nginx rewrite preserves redirect_to
curl -sI "https://readest.grigri.cloud/auth/v1/authorize?provider=discord&redirect_to=readest://auth-callback" | grep -i location
# Step 3: GoTrue → Kanidm passes redirect_to
curl -s -D - -o /dev/null "https://readest.grigri.cloud/auth/v1/authorize?provider=custom:kanidm&redirect_to=readest://auth-callback" | grep -i location
# Step 5: /callback routes to GoTrue
curl -sI "https://readest.grigri.cloud/callback?code=x&state=y" | grep -i location
# Step 8: CORS preflight as the WebView would send it
curl -s -D - -o /dev/null -X OPTIONS "https://readest.grigri.cloud/auth/v1/user" \
-H "Origin: https://tauri.localhost" \
-H "Access-Control-Request-Method: GET" \
-H "Access-Control-Request-Headers: apikey,authorization,x-client-info" | grep -i access-control
# FAIL: 204 with no access-control-* headers / OK: allow-headers includes apikey
Android side (adb logcat, tag NativeBridgePlugin):
Launching OAuth URL: ...— what the app sends (shows hardcodedprovider=discord).Received intent: ... data=readest://auth-callback#access_token=...— deep link arrived with tokens in the fragment. If this line exists but the session still fails, the problem is in the WebView JS (CORS, storage) — not in intent delivery.
Server side:
- GoTrue logs every request with
remote_addr. List the paths a device IP called:kubectl logs deploy/readest-auth | grep <ip> | grep -oE '"path":"[^"]*"' | sort | uniq -c. The device calling only/authorize+/callbackand never/tokenor/userafter login means the client-side session call is being blocked (CORS) or never issued. - A token from an intercepted deep link can be replayed to prove the server side accepts it:
curl -H "Authorization: Bearer <access_token>" https://readest.grigri.cloud/auth/v1/user.
Known Limitations / Follow-ups
- JWT claims nesting: our GoTrue puts
plan,storage_quota,translation_quotainsideuser_metadata(inherited from Kanidm claims), not as top-level JWT claims. The app'sgetUserProfilePlan()reads the top-levelplanclaim, so mobile users resolve as "free" (redirected to/userafter login; premium-gated UI may be limited). Server-side quotas are still enforced viaSTORAGE_FIXED_QUOTA/TRANSLATION_FIXED_QUOTA. A GoTrue custom access-token hook (or upstream change) would fix it. provider_refresh_tokenleakage: GoTrue passes Kanidm's refresh token through the URL fragment to the app. Harmless here (self-hosted, HTTPS, single user) but worth knowing.- Custom OAuth provider row: the
custom:kanidmprovider inauth.custom_oauth_providersis auto-inserted by thedb-migrateinit container (was manual; now GitOps-managed).
When It Breaks Again (Regression Checklist)
Mobile login only depends on server config + the APK's expectations, so regressions come from gotrue upgrades, ingress config drift, or new APK versions. Check in this order:
- Replay the redirect chain (each
locationmust match):curl -sI "https://readest.grigri.cloud/auth/v1/authorize?provider=discord&redirect_to=readest://auth-callback" # expect: /auth/v1/authorize?provider=custom:kanidm&redirect_to=readest://auth-callback curl -s -D - -o /dev/null "https://readest.grigri.cloud/auth/v1/authorize?provider=custom:kanidm&redirect_to=readest://auth-callback" # expect: idm.grigri.cloud/ui/oauth2?...&redirect_to=readest%3A%2F%2Fauth-callback&redirect_uri=... curl -sI "https://readest.grigri.cloud/callback?code=x&state=y" # expect: /auth/v1/callback?code=x&state=y - CORS preflight (deep link works but app says "go to login"):
curl -s -D - -o /dev/null -X OPTIONS "https://readest.grigri.cloud/auth/v1/user" \ -H "Origin: https://tauri.localhost" -H "Access-Control-Request-Method: GET" \ -H "Access-Control-Request-Headers: apikey,authorization,x-client-info" | grep -i access-control # allow-headers MUST include apikey; a bare 204 = preflight fails - GoTrue env:
GOTRUE_URI_ALLOW_LISTincludesreadest://auth-callback,GOTRUE_CORS_ALLOWED_HEADERSincludesapikey. - Device-side:
adb logcat -s NativeBridgePlugin— compareLaunching OAuth URL:andReceived intent:against the expected shapes above. Token in intent but no session ⇒ step 2/3. NoReceived intent⇒ intent filter/deep-link scheme changed in the APK. - Server-side: device IP should hit
/authorize,/callback, then/user(or/token). Missing/user⇒ client blocked (CORS);/authorizeshowsprovider=discord⇒ nginx rewrite broken (seedocs/deployment/readest.mdfailure runbook).
For non-mobile failures (init-container patch, /callback 404, lost provider row after DB
recreate), see the "Failure Modes and Recovery" runbook in docs/deployment/readest.md.
References
apps/readest/values.yaml— nginx snippets, GoTrue env (GOTRUE_URI_ALLOW_LIST,GOTRUE_CORS_ALLOWED_HEADERS)docs/deployment/readest.md— full deployment incl. web OAuth integrationdocs/troubleshooting/gotrue-callback-url-routing.md—/callback→/auth/v1/callbackredirectdocs/troubleshooting/gotrue-ssrf-protection.md— custom provider manual DB insert- gotrue source:
internal/api/api.go(CORS options),internal/conf/configuration.go(CORS.AllowedHeaders→GOTRUE_CORS_ALLOWED_HEADERS) - readest app source:
src/app/auth/page.tsx(authWithCustomTab,handleOAuthUrl),src/helpers/auth.ts(parseOAuthCallbackUrl,setSessionflow)