Skip to main content

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

PlatformRedirect URIExamples
Desktoplumio://callbackElectron, Tauri, native Win/Mac/Linux apps
Mobilelumio-app://callbackiOS (Swift), Android (Kotlin), Flutter, React Native
CLIhttp://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 only A-Z, a-z, 0-9, -, ., _, ~
  • code_challenge: base64url(SHA256(code_verifier)) — this is sent to the server, never the verifier itself
  • state: 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:

  1. Looks up the authorization code in Redis
  2. Computes SHA256(code_verifier) from the request
  3. Compares the result against the stored code_challenge
  4. If they match: issues a JWT and refresh token, deletes the code from Redis
  5. 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

ParameterRequiredDescription
redirect_uriYesCustom URL scheme callback (e.g., lumio://callback)
client_typeYesdesktop or mobile
code_challengeYesBase64url-encoded SHA256 of the code_verifier
code_challenge_methodNoAlways S256 (default; plain is not supported)
stateRecommendedRandom UUID for CSRF protection

API Endpoints

EndpointMethodDescription
/v1/auth/authorizePOSTExchange provider token for auth code (internal — called by ID app)
/v1/auth/token/exchangePOSTExchange auth code + code_verifier for JWT
/v1/auth/refreshPOSTRefresh an expired JWT using a refresh token

Mobile App Considerations

  • Register the lumio-app:// custom URL scheme in:
    • iOS: Info.plist under CFBundleURLTypes
    • Android: AndroidManifest.xml under <intent-filter> with android.intent.action.VIEW
  • 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: EncryptedSharedPreferences or Android Keystore
    • Flutter: flutter_secure_storage
    • React Native: react-native-keychain

Desktop App Considerations

  • Register the lumio:// custom URL scheme at the OS level:
    • Electron: use app.setAsDefaultProtocolClient("lumio") and handle open-url (macOS) / second-instance (Windows/Linux)
    • Tauri: configure the deep-link plugin in tauri.conf.json
    • Native macOS: register in Info.plist under CFBundleURLTypes
    • Native Windows: register in the Windows Registry under HKEY_CLASSES_ROOT\lumio
  • Store tokens in the OS keychain:
    • Electron/Node.js: keytar (cross-platform keychain bindings)
    • Tauri: tauri-plugin-stronghold or the OS credential store
    • macOS: Keychain Services
    • Windows: Windows Credential Manager

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_verifier is never sent to the server during the authorization step — only the code_challenge (a one-way hash) is stored
  • Always use S256 — plain code_challenge_method is 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

FilePurpose
apps/id/src/app/login/page.tsxExtracts PKCE params from the query string on page load
apps/id/src/app/login/login-client.tsxStores PKCE params in the auth state cookie
apps/id/src/auth.tsPKCE branch in the signIn callback (if (authState?.code_challenge), ~lines 240–273)
apps/id/src/lib/redirect-allowlist.tsValidates that redirect_uri is an allowed custom URL scheme
shared/api/src/auth.tsauthorize() and exchangeAuthCode() client functions