PKCE and Native App Auth
Overview
Lumio supports OAuth2 + PKCE (Proof Key for Code Exchange) for native applications including desktop apps, mobile apps, and CLI tools. The ID app (apps/id) acts as the authorization server.
Native apps never see user credentials. Instead, they receive an authorization code via a custom URL scheme redirect, then exchange that code for a JWT. PKCE ensures that even if the authorization code is intercepted, it cannot be used without the original code_verifier that only the native app holds.
Supported Platforms
| Platform | Redirect URI | Examples |
|---|---|---|
| Desktop | lumio://callback | Electron, Tauri, native Win/Mac/Linux apps |
| Mobile | lumio-app://callback | iOS (Swift), Android (Kotlin), Flutter, React Native |
| CLI | http://localhost:{port} | Command-line tools, local dev tools |
PKCE Flow (Step by Step)
Step 1: Generate PKCE Parameters
The native app generates three values before opening the browser:
code_verifier: A cryptographically random string of 43–128 characters using onlyA-Z,a-z,0-9,-,.,_,~code_challenge:base64url(SHA256(code_verifier))— this is sent to the server, never the verifier itselfstate: A random UUID used for CSRF protection
import crypto from "crypto";
function generateCodeVerifier(): string {
return crypto.randomBytes(64).toString("base64url").slice(0, 128);
}
function generateCodeChallenge(verifier: string): string {
return crypto.createHash("sha256").update(verifier).digest("base64url");
}
function generateState(): string {
return crypto.randomUUID();
}
Step 2: Open System Browser to ID App
The native app opens the system browser pointing at the ID app login page with PKCE parameters in the query string:
https://id.lumio.vision/login?
redirect_uri=lumio://callback
&client_type=desktop
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256
&state=random-uuid
The ID app (apps/id/src/app/login/page.tsx) extracts and stores these parameters in an auth state cookie via apps/id/src/app/login/login-client.tsx.
Step 3: User Authenticates
The user signs in via an OAuth provider (Twitch, Discord, etc.) inside the browser. The ID app handles the provider OAuth flow entirely — the native app waits passively.
Step 4: ID App Creates an Authorization Code
Once the user authenticates, the ID app calls the Rust API:
POST https://api.lumio.vision/v1/auth/authorize
The API stores the code_challenge in Redis with a 5-minute TTL and returns a short-lived authorization code in the format lumio_auth_xxxx.
The PKCE branch in apps/id/src/auth.ts (the if (authState?.code_challenge) block, around lines 240–273) handles this logic.
Step 5: Browser Redirects to Native App
The browser follows a redirect to the custom URL scheme, passing the auth code and state:
lumio://callback?code=lumio_auth_xxxx&state=random-uuid
The operating system routes this URI to the registered native app.
Step 6: Native App Validates State
The native app checks that the state in the callback URL exactly matches the state generated in step 1. If they differ, abort — this indicates a CSRF attack or a stale request.
Step 7: Exchange Code for JWT
The native app sends the authorization code and the original code_verifier (not the challenge) to the API:
POST https://api.lumio.vision/v1/auth/token/exchange
Content-Type: application/json
{
"code": "lumio_auth_xxxx",
"code_verifier": "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
}
Step 8: API Validates PKCE and Issues Tokens
The API performs the following checks:
- Looks up the authorization code in Redis
- Computes
SHA256(code_verifier)from the request - Compares the result against the stored
code_challenge - If they match: issues a JWT and refresh token, deletes the code from Redis
- If they do not match: returns
401 Unauthorized
Because the code is deleted after use, authorization codes are strictly single-use.
Step 9: Store Tokens Securely
The native app receives the JWT and refresh token and stores them in secure OS storage (see platform-specific sections below). The JWT is included in subsequent API requests as a Bearer token.
Query Parameters Reference
| Parameter | Required | Description |
|---|---|---|
redirect_uri | Yes | Custom URL scheme callback (e.g., lumio://callback) |
client_type | Yes | desktop or mobile |
code_challenge | Yes | Base64url-encoded SHA256 of the code_verifier |
code_challenge_method | No | Always S256 (default; plain is not supported) |
state | Recommended | Random UUID for CSRF protection |
API Endpoints
| Endpoint | Method | Description |
|---|---|---|
/v1/auth/authorize | POST | Exchange provider token for auth code (internal — called by ID app) |
/v1/auth/token/exchange | POST | Exchange auth code + code_verifier for JWT |
/v1/auth/refresh | POST | Refresh an expired JWT using a refresh token |
Mobile App Considerations
- Register the
lumio-app://custom URL scheme in:- iOS:
Info.plistunderCFBundleURLTypes - Android:
AndroidManifest.xmlunder<intent-filter>withandroid.intent.action.VIEW
- iOS:
- Use ASWebAuthenticationSession (iOS) or Chrome Custom Tabs (Android) to open the browser — do not use an embedded WebView, as app stores may reject it
- Handle the deep link callback in the app delegate (iOS) or the activity that registered the intent filter (Android)
- Store tokens in:
- iOS: Keychain via
SecItemAdd/SecItemCopyMatching - Android:
EncryptedSharedPreferencesor Android Keystore - Flutter:
flutter_secure_storage - React Native:
react-native-keychain
- iOS: Keychain via
Desktop App Considerations
- Register the
lumio://custom URL scheme at the OS level:- Electron: use
app.setAsDefaultProtocolClient("lumio")and handleopen-url(macOS) /second-instance(Windows/Linux) - Tauri: configure the
deep-linkplugin intauri.conf.json - Native macOS: register in
Info.plistunderCFBundleURLTypes - Native Windows: register in the Windows Registry under
HKEY_CLASSES_ROOT\lumio
- Electron: use
- Store tokens in the OS keychain:
- Electron/Node.js:
keytar(cross-platform keychain bindings) - Tauri:
tauri-plugin-strongholdor the OS credential store - macOS: Keychain Services
- Windows: Windows Credential Manager
- Electron/Node.js:
Environment Setup
The ID app requires the custom URL schemes to be listed in ALLOWED_REDIRECT_ORIGINS so the redirect allowlist (apps/id/src/lib/redirect-allowlist.ts) accepts them:
ALLOWED_REDIRECT_ORIGINS=http://localhost:4000,http://localhost:4001,lumio://callback,lumio-app://callback
Add any additional custom schemes (e.g., for white-labeled apps) to this list.
Security Notes
- Auth codes expire after 5 minutes — enforced by the Redis TTL set at code creation
- Auth codes are single-use — the code is deleted from Redis immediately after a successful exchange
- The
code_verifieris never sent to the server during the authorization step — only thecode_challenge(a one-way hash) is stored - Always use
S256— plaincode_challenge_methodis not supported and will be rejected - Always validate
state— skip this check at your own risk; a missing or mismatched state allows CSRF attacks - Never store tokens in localStorage or sessionStorage in a browser context; use OS-level secure storage for native apps
Key Files
| File | Purpose |
|---|---|
apps/id/src/app/login/page.tsx | Extracts PKCE params from the query string on page load |
apps/id/src/app/login/login-client.tsx | Stores PKCE params in the auth state cookie |
apps/id/src/auth.ts | PKCE branch in the signIn callback (if (authState?.code_challenge), ~lines 240–273) |
apps/id/src/lib/redirect-allowlist.ts | Validates that redirect_uri is an allowed custom URL scheme |
shared/api/src/auth.ts | authorize() and exchangeAuthCode() client functions |