Cargo Accounts · One Login · Native Mobile (Flutter) Guide
CargoApp — Flutter Integration Guide
How CargoApp (mob.cargo.app) delegates authentication to Cargo Account using supabase_flutter, a Custom OIDC Provider on api.cargo.app, PKCE, and a deep-link return — the same Model-2 federation as CargoWorks AI, with native-mobile mechanics.
CargoAppmob.cargo.appdata: api.cargo.appdeep link: app.cargo.mob://login-callbackNative mobile, in one picture — read this first
CargoApp never shows a password field. The user signs in on one.cargo.ac in the system browser and returns via a deep link. There are TWO code exchanges: the app talks to its own backend (api.cargo.app) as a PUBLIC PKCE client shipping only the anon key, and api.cargo.app talks to Cargo as a CONFIDENTIAL client whose secret lives only on the server. The app binary holds no secret.
CargoApp (Flutter) — public client
- ✅ supabase_flutter default PKCE flow
- ✅ Ships only the api.cargo.app anon key
- ✅ One button + deep-link return
- ❌ No client secret on the device
- ❌ No password / signup / reset UI
api.cargo.app — confidential RP
- ✅ Custom OIDC Provider 'custom:cargo-ac'
- ✅ Server-side upstream code exchange
- ✅ Mints its OWN native session (RLS works)
- ✅ Secret stored server-side only
- ❌ No local login / Third-Party Auth
The ecosystem at a glance
┌──────────────────┐ passwords live ONLY here
│ one.cargo.ac │ login · signup · consent
│ (account home) │◄───────────────┐
└────────┬─────────┘ │
branded hop │ │ in-app browser
▼ │ (system browser)
┌──────────────────┐ ┌───────┴────────┐
│ auth.cargo.ac │ │ api.cargo.app │ server-side
│ authorize·token │◄──────►│ (data backend │ OIDC exchange
│ userinfo · JWKS │ conf. │ = RP, conf.) │ (secret stays
└──────────────────┘ client └───────┬────────┘ on server)
│ deep link
▼ ?code=… (app↔backend PKCE)
┌──────────────────┐
│ CargoApp (Flutter)
│ mob.cargo.app │ holds NO secret
└──────────────────┘
1 button · system browser · deep-link return — no password on device.What your users see — they sign in & create accounts on one.cargo.ac
Read this first. CargoApp shows ONE button ("Continue with your Cargo Account"). Tapping it opens the system browser (an ASWebAuthenticationSession / Chrome Custom Tab) to the Cargo Account console at one.cargo.ac — the ONLY place a human ever types a password or creates an account. They sign in (or sign up), approve access once, and are sent straight back INTO the app via a deep link, already signed in. The app NEVER shows a password field and NEVER talks to the admin console. Do not hardcode one.cargo.ac — calling signInWithOAuth routes there automatically through an invisible, branded auth.cargo.ac hop.
What the human sees, step by step:
┌ Sign in → one.cargo.ac/login (email + password, or Google)
├ New account → one.cargo.ac/signup (linked from the login page)
└ First time → one.cargo.ac/oauth/consent ("Allow CargoApp to access
your Cargo Account")
Full journey (one tap, system browser, automatic deep-link return):
① Tap button → signInWithOAuth(OAuthProvider('custom:cargo-ac'))
② auth.cargo.ac/auth/v1/oauth/authorize (invisible branded hop)
③ one.cargo.ac/login → /signup (if new) → /oauth/consent
④ api.cargo.app/auth/v1/callback?code=… (server-side exchange)
⑤ deep link back to the app: app.cargo.mob://login-callback?code=…
⑥ supabase_flutter mints its OWN native session → user is in the appHow mobile actually works — one.cargo.ac stays a WEB app (read if confused)
The common worry: "one.cargo.ac is a web app that redirects back to a web URL — how can a mobile app use it?" Answer: one.cargo.ac does NOT become a mobile app and needs ZERO CargoApp-specific code. On a phone its /login, /signup and /oauth/consent pages render INSIDE the phone's in-app/system browser (the ASWebAuthenticationSession / Custom Tab supabase_flutter opens) — the user types their Cargo password there, on the phone, exactly like the web flow. The ONLY mobile-specific difference from a web spoke (e.g. CargoWorks AI) is the FINAL redirect target: instead of an https://…/auth/callback URL, the flow ends at a custom-scheme DEEP LINK (app.cargo.mob://login-callback) that the phone OS catches and hands back to the app. Also note: auth.cargo.ac is a BACKEND endpoint, not a page — its /authorize 302-redirects the browser onward to one.cargo.ac; opening it bare errors (that's the CargoWorks AI symptom, caused by a hub setting, not your code).
Web spoke (CargoWorks AI): … → one.cargo.ac sign-in → https://exim.cargoworks.ai/auth/callback?code=…
Mobile (CargoApp): … → one.cargo.ac sign-in → app.cargo.mob://login-callback?code=…
(same web pages, in the phone's in-app browser)
Who owns the final redirect:
• app.cargo.mob://login-callback → api.cargo.app allowlists it (NOT one.cargo.ac, NOT the hub)
• api.cargo.app/auth/v1/callback → the hub allowlists it (upstream OIDC callback)
So one.cargo.ac needs NO mobile build and NO deep-link knowledge.1. The mental model — TWO code exchanges, the app holds NO secret
This is the part people get wrong on mobile. There are two separate PKCE/OAuth relationships. (A) APP ↔ YOUR BACKEND: the Flutter app is a PUBLIC client of your own Supabase backend (api.cargo.app) using supabase_flutter's default PKCE flow — it ships only the publishable/anon key, never a client secret. (B) YOUR BACKEND ↔ CARGO: api.cargo.app is a CONFIDENTIAL OAuth client of Cargo; it performs the upstream code exchange on its server and holds the client secret THERE. Because that secret never leaves the server, your app binary stays secret-free even though the Cargo-side client is confidential. You register the provider once on api.cargo.app — you do NOT put any Cargo client_id/secret in the Flutter code.
(A) CargoApp (Flutter) → api.cargo.app : PUBLIC client + PKCE
ships: publishable/anon key only ❌ no client secret on device
(B) api.cargo.app → auth.cargo.ac : CONFIDENTIAL client
client_secret_post, secret stored SERVER-SIDE only on api.cargo.app
// You configure (B) ONCE in the api.cargo.app dashboard (Step 4).
// The Flutter app only ever does (A) — it never sees the Cargo secret.2. Keep api.cargo.app as the DATA backend — auth moves to Cargo
CargoApp already uses api.cargo.app (a branded native Supabase custom domain) as its data backend — keep all your tables, storage, functions and RLS exactly as they are. What changes: api.cargo.app stops authenticating people itself and instead federates to Cargo, then mints its OWN native session so auth.uid() and every RLS policy keep working. Do NOT route api.cargo.app through a hand-rolled reverse proxy — only the backend's native Custom Domains feature is supported (a proxy breaks the upstream /auth/v1/callback exchange).
✅ Tables · storage · RLS · business rows on api.cargo.app — unchanged ✅ api.cargo.app mints its OWN native session (RLS via auth.uid()) ❌ No local login / signup / reset / OTP / social on api.cargo.app ❌ No reverse proxy in front of the backend (native custom domain only)
3. Turn OFF every local auth method on api.cargo.app
In the api.cargo.app dashboard → Authentication → Providers, disable Email/password, Phone, magic links/OTP and every social provider. Remove all password / signup / reset UI from the Flutter app. The only entry point becomes the single "Continue with Cargo" button. Do NOT use the "Third-Party Auth" screen — it is the wrong feature and would require sharing Cargo's JWT secret.
// DELETE from the Flutter app: supabase.auth.signInWithPassword(...) // ❌ no password sign-in supabase.auth.signUp(...) // ❌ no signup in the app supabase.auth.resetPasswordForEmail(...)// ❌ no reset in the app supabase.auth.signInWithOtp(...) // ❌ no magic links / OTP TextField(obscureText: true) for passwords // ❌ remove entirely // Dashboard: Authentication → Providers → Email/Phone/Google = OFF. // ⚠️ Do NOT touch Authentication → Third-Party Auth.
4. Register Cargo as a Custom OIDC Provider on api.cargo.app (Auto-discovery OFF)
On api.cargo.app go to Authentication → Providers → Add provider → "Custom OIDC Provider" (NOT "Third-Party Auth"), turn Auto-discovery OFF and enter the endpoints manually. RULE A (permanent, by design): the Issuer is the identity provider's backend host, NOT auth.cargo.ac. The Cargo Account team has CONFIRMED the live values below — use the Issuer exactly as shown (still re-verify it against https://auth.cargo.ac/auth/v1/.well-known/openid-configuration before go-live; if live discovery ever differs, the live value wins). Point the visible Authorization/Token/Userinfo/JWKS endpoints at auth.cargo.ac. Paste the confidential Client ID + Secret Cargo sends you out of band — these stay SERVER-SIDE on api.cargo.app and never go into the Flutter app. Keep "Allow users without email" OFF. CRITICAL: set Token endpoint auth method = client_secret_post (the backend defaults to client_secret_basic, which Cargo rejects as "invalid client credentials" → HTTP 400 and a failed callback).
// ✅ Confirmed by the Cargo Account team (verified against live discovery): // Discovery URL = https://auth.cargo.ac/auth/v1/.well-known/openid-configuration // Issuer = https://pjdzgqajsnsyxvikaims.supabase.co/auth/v1 Provider type = Custom OIDC Provider Provider name = custom:cargo-ac Auto-discovery = OFF Issuer = https://pjdzgqajsnsyxvikaims.supabase.co/auth/v1 Authorization endpoint = https://auth.cargo.ac/auth/v1/oauth/authorize Token endpoint = https://auth.cargo.ac/auth/v1/oauth/token Userinfo endpoint = https://auth.cargo.ac/auth/v1/oauth/userinfo JWKS endpoint = https://auth.cargo.ac/auth/v1/.well-known/jwks.json Client ID = <sent privately by Cargo — confidential> Client Secret = <sent privately by Cargo — server-side only> Token endpoint auth method = client_secret_post // ⚠️ MUST set Scopes = openid profile email Allow users w/o email = OFF // Verify before go-live: curl https://auth.cargo.ac/auth/v1/.well-known/openid-configuration // The "issuer" field must equal the value above — never type auth.cargo.ac.
5. Two allowlists — the upstream callback (Cargo) AND the deep link (api.cargo.app)
Mobile has TWO redirect targets, do not confuse them. (1) UPSTREAM CALLBACK — the provider form on api.cargo.app shows a read-only Callback URL (https://api.cargo.app/auth/v1/callback). Copy it verbatim and send it to Cargo so it is added to the HUB allowlist; this is where Cargo returns the code for the server-side exchange. (2) APP DEEP LINK — in api.cargo.app → Authentication → URL Configuration, add your deep link (e.g. app.cargo.mob://login-callback) to "Additional Redirect URLs" so the backend is allowed to redirect the user back into the app after it mints the session. Set Site URL to a real https host (not localhost). Matching is EXACT on both sides.
(1) Send to CARGO (hub allowlist) — the upstream OIDC callback:
https://api.cargo.app/auth/v1/callback
(2) Add on api.cargo.app → Authentication → URL Configuration:
Additional Redirect URLs: app.cargo.mob://login-callback
Site URL: https://mob.cargo.app // not localhost
// (1) is the server-to-server code-exchange endpoint Cargo allowlists.
// (2) is the deep link your app receives — your backend allowlists it.6. Register the deep link natively (iOS Info.plist + Android intent-filter)
Pick ONE unique scheme (reverse-domain of your app id is best, e.g. app.cargo.mob) and register it on both platforms so the OS hands the app.cargo.mob://login-callback redirect back to CargoApp. supabase_flutter listens for this deep link automatically and finishes the session — you do not need to parse the URL yourself.
ios/Runner/Info.plist → inside the top-level <dict>:
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key><string>Editor</string>
<key>CFBundleURLSchemes</key>
<array><string>app.cargo.mob</string></array>
</dict>
</array>
android/app/src/main/AndroidManifest.xml → inside <activity .MainActivity>:
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="app.cargo.mob" android:host="login-callback" />
</intent-filter>7. Initialize supabase_flutter (PKCE is the default) and wire ONE button
Initialize Supabase with api.cargo.app + its publishable/anon key. The PKCE flow is the default on supabase_flutter (AuthFlowType.pkce) — that is the app↔backend public-client PKCE; nothing else to configure. Replace your old login UI with one button that calls signInWithOAuth(OAuthProvider('custom:cargo-ac')) with redirectTo set to your deep link and authScreenLaunchMode = externalApplication so it opens the system browser. Listen to onAuthStateChange for the signedIn event after the deep-link return.
// main.dart
await Supabase.initialize(
url: 'https://api.cargo.app',
anonKey: '<api.cargo.app publishable/anon key>', // public — safe in app
// authFlowType: AuthFlowType.pkce is the DEFAULT — no secret on device
);
final supabase = Supabase.instance.client;
// The single sign-in button:
await supabase.auth.signInWithOAuth(
OAuthProvider('custom:cargo-ac'),
redirectTo: 'app.cargo.mob://login-callback',
authScreenLaunchMode: LaunchMode.externalApplication,
);
// supabase_flutter catches the deep link and mints the session for you:
supabase.auth.onAuthStateChange.listen((data) {
if (data.event == AuthChangeEvent.signedIn) { /* go to home */ }
});8. Users are provisioned automatically — write NO provisioning code
The first time a permitted Cargo user signs in, api.cargo.app AUTO-CREATES their row in auth.users + an auth.identities row linking provider "custom:cargo-ac" to their canonical Cargo id (the id_token "sub"). This is standard Just-In-Time provisioning — exactly like first-time "Sign in with Google". Later logins reuse the same user. If you keep a handle_new_user trigger, your profiles row is created in the same moment. Your RLS keeps using auth.uid() (your local id); use the identity's provider_id (the sub) to correlate back to Cargo.
// Automatic on first login — nothing for you to build: auth.users ← new row (api.cargo.app's OWN local uuid) auth.identities ← provider="custom:cargo-ac", provider_id = Cargo "sub" public.profiles ← created by your handle_new_user trigger (recommended)
9. Softly verify entitlement with the app_access claim — DO NOT hard-block yet
Entitlement is enforced by Cargo at the consent screen; the in-app check is only a deferred backstop. Your exact key is the hub's registered apps.key (snake_case) — for CargoApp it is "cargo_app", NOT the hyphenated "cargo-app". CRITICAL: the "app_access" array is legitimately EMPTY for everyone today because the hub has not provisioned org→app grants yet. If you sign the user out on an empty/missing claim you lock out EVERY user. Treat empty/missing as ALLOW and only log for now — Cargo will announce when hard enforcement goes live.
final session = Supabase.instance.client.auth.currentSession;
final meta = session?.user.appMetadata ?? {};
final access = (meta['app_access'] as List?) ?? const [];
// SOFT backstop — never block while the hub is still provisioning grants.
if (access.isNotEmpty && !access.contains('cargo_app')) {
debugPrint('user not entitled to cargo_app (soft mode, proceeding)');
}
// ❌ Do NOT call supabase.auth.signOut() here — it logs everyone out today.10. Sign-out (and best-effort global logout)
signOut() ends CargoApp's local native session — that is the important one on mobile. Because the sign-in ran in the system browser, a Cargo session cookie may also live there; best-effort hit the Cargo global logout endpoint so the next sign-in re-authenticates. Keep all your tables, storage and RLS unchanged.
await Supabase.instance.client.auth.signOut(); // ends the app's session // Best-effort: clear the shared Cargo session in the system browser too. // Open https://auth.cargo.ac/auth/v1/logout in an external browser tab, or POST to it.
Worked example — CargoApp on a real device (Flutter)
A user opens CargoApp (mob.cargo.app) with no session and taps "Continue with your Cargo Account". The system browser opens to one.cargo.ac (via the invisible auth.cargo.ac hop) where they sign in — or open one.cargo.ac/signup — and approve access once. api.cargo.app finishes the code exchange server-side, JIT-provisions the user, mints its OWN native session, and deep-links the user back into CargoApp.
① Tap → signInWithOAuth(OAuthProvider('custom:cargo-ac'),
redirectTo: 'app.cargo.mob://login-callback', externalApplication)
② System browser → https://auth.cargo.ac/auth/v1/oauth/authorize (branded hop)
③ one.cargo.ac → /login (or /signup) → /oauth/consent
④ Code → https://api.cargo.app/auth/v1/callback (server-side exchange,
verifies id_token vs hub JWKS, JIT-creates the user, mints native session)
⑤ Deep link app.cargo.mob://login-callback?code=… → supabase_flutter sets the session
⑥ App SOFT-checks app_access for "cargo_app" → lands in the app ✅
✅ user signs in on one.cargo.ac ❌ never a screen inside the app
✅ app holds only the anon key ❌ no client secret on the deviceWhat to delete, keep and add
- DELETE: all in-app login / signup / reset screens and password fields
- DELETE: signInWithPassword / signUp / resetPasswordForEmail / OTP / social calls
- DELETE: every local auth provider (Email/Phone/Google) on api.cargo.app
- DON'T: use "Third-Party Auth", a shared JWT secret, or any reverse proxy in front of api.cargo.app
- DON'T: put any Cargo client_id/secret in the Flutter app — the app holds NO secret
- KEEP: api.cargo.app data backend, tables, storage and RLS — exactly as-is
- KEEP: supabase_flutter default PKCE flow (app↔backend public client) — ships only the anon key
- ADD: Cargo as a Custom OIDC Provider on api.cargo.app (Auto-discovery OFF, manual endpoints, issuer = live-discovery backend host, client_secret_post, Allow-users-without-email OFF)
- ADD: the confidential Client ID + Secret Cargo sent you — SERVER-SIDE on api.cargo.app only
- ADD: a unique deep-link scheme (e.g. app.cargo.mob://login-callback) in iOS Info.plist + Android intent-filter
- ADD: the deep link to api.cargo.app → URL Configuration (Additional Redirect URLs) + a real https Site URL
- SEND TO CARGO: the upstream callback https://api.cargo.app/auth/v1/callback to allowlist
- ADD: one button → signInWithOAuth(OAuthProvider('custom:cargo-ac'), redirectTo: deep link, externalApplication)
- ADD: a SOFT app_access check (key "cargo_app") — log only; never signOut() while the claim is empty hub-side
Pitfalls & FAQ
- Where do the Client ID and Client Secret actually come from?
- They don't exist until CargoApp's OAuth client is REGISTERED on the hub. A Cargo admin opens admin.cargo.ac → Integration → OAuth → OAuth Clients and registers CargoApp as a confidential client (token_endpoint_auth_method = client_secret_post, redirect_uri = https://api.cargo.app/auth/v1/callback, scopes = openid profile email). That registration mints the real client_id and a one-time client_secret, which Cargo hands you out of band. The secret is shown once — store it server-side on api.cargo.app only, never in the app binary.
- Does supabase_flutter support a custom OIDC provider like custom:cargo-ac?
- Yes. OAuthProvider is a class (not just a fixed enum), so you call signInWithOAuth(OAuthProvider('custom:cargo-ac'), ...). Pass redirectTo with your deep link and authScreenLaunchMode: LaunchMode.externalApplication on mobile so it opens the system browser.
- If it's a public PKCE client, why is the Cargo-side OAuth client confidential?
- Two different relationships. The APP is a public PKCE client of its OWN backend (api.cargo.app) and ships only the anon key. api.cargo.app is in turn a confidential OAuth client of Cargo — it runs the upstream code exchange server-side and stores the client secret THERE, never on the device. So the app stays secret-free even though the Cargo client is confidential. This is how Supabase Custom OIDC Providers work.
- Do I need to write PKCE / code-verifier logic in Flutter?
- No. supabase_flutter handles app↔backend PKCE automatically (it's the default flow), and the upstream PKCE between api.cargo.app and Cargo is managed entirely server-side by the auth server. You write neither.
- What's the difference between the upstream callback and the deep link?
- Two redirect targets. The upstream callback (https://api.cargo.app/auth/v1/callback) is where Cargo returns the code for api.cargo.app's server-side exchange — Cargo allowlists this. The deep link (app.cargo.mob://login-callback) is where api.cargo.app sends the user back INTO the app after minting the session — api.cargo.app allowlists this in its own URL Configuration. Send the first to Cargo; add the second on your backend.
- Where does the sign-in button take my users?
- To one.cargo.ac (in the system browser), via an invisible branded auth.cargo.ac hop — never the admin console and never a screen inside CargoApp. They sign in or create an account there, approve once, and are deep-linked back into the app signed in.
- Sign-in fails with "invalid client credentials" (callback 400/500). Why?
- Almost always the Token endpoint auth method on the Custom OIDC Provider. Cargo registers the client as client_secret_post but Supabase defaults to client_secret_basic. Set Token endpoint auth method = client_secret_post on api.cargo.app, confirm the Client ID matches, and repaste the secret with no whitespace.
- After sign-in the browser doesn't return to the app. Why?
- Either the native deep-link config is missing/typo'd (iOS CFBundleURLTypes or Android intent-filter scheme must equal your redirectTo scheme), or the deep link isn't in api.cargo.app's Additional Redirect URLs. Both must use the EXACT same value (e.g. app.cargo.mob://login-callback).
- What exact string is my app_access key?
- It is the hub's registered apps.key (snake_case): "cargo_app" — NOT the hyphenated "cargo-app" and NOT the domain. The claim is empty for everyone right now, so keep the check SOFT (log only) and treat empty/missing as allow.
13. Test checklist
Confirm each item on a real device before flipping production traffic to cargo.ac.
- api.cargo.app is the data backend via its native custom domain (no reverse proxy)
- Every local auth provider (Email/Phone/Google) is OFF on api.cargo.app
- No password field, signup form, or reset screen anywhere in the Flutter app
- Cargo registered as a Custom OIDC Provider (Auto-discovery OFF), NOT Third-Party Auth
- Issuer = the EXACT live-discovery value (backend host); endpoints point at auth.cargo.ac
- Token endpoint auth method = client_secret_post; Allow-users-without-email OFF; Scopes = openid profile email
- Confidential Client ID + Secret entered on api.cargo.app ONLY (never in the app binary)
- Deep-link scheme registered in iOS Info.plist AND Android intent-filter (e.g. app.cargo.mob://login-callback)
- Deep link added to api.cargo.app → URL Configuration; Site URL is a real https host
- Upstream callback https://api.cargo.app/auth/v1/callback sent to Cargo and allowlisted
- supabase_flutter initialized with api.cargo.app + anon key (PKCE default); app ships NO secret
- Button calls signInWithOAuth(OAuthProvider('custom:cargo-ac'), redirectTo: deep link, externalApplication)
- First login JIT-provisions the user and mints api.cargo.app's OWN native session (RLS still works)
- app_access claim ("cargo_app") checked SOFTLY — logged only, never signOut() while empty hub-side
- Identity-contract claims present after login, refresh, rotation + org switch: username, cargo_account_number, identity_type, user_type, permissions, orgs, active_org, app_roles, amr, scope
- End-to-end verified on a real device: button → system browser → one.cargo.ac → deep link back, signed in

