Skip to main content

OAuth Providers

Steward supports OAuth sign-in with Google, Discord, and Twitter/X. All flows use the PKCE (Proof Key for Code Exchange) extension for security, with no client secret exposed to the browser.

Supported Providers

ProviderSDK NameStatus
Google"google"✅ Supported
Discord"discord"✅ Supported
Twitter/X"twitter"✅ Supported

How It Works (PKCE Flow)

1. SDK generates a random code_verifier + code_challenge (SHA-256)
2. Opens a popup to /auth/oauth/{provider}/authorize with the challenge
3. User authorizes in the provider's UI
4. Provider redirects back with an authorization code
5. SDK exchanges code + code_verifier at /auth/oauth/{provider}/token
6. Steward returns a JWT session
The popup-based flow keeps the user on your page while authentication happens in a separate window.

SDK Usage

import { StewardAuth } from "@stwd/sdk";

const auth = new StewardAuth({
  baseUrl: "https://api.steward.fi",
  storage: localStorage,
});

// Opens a popup for the provider's sign-in page
const result = await auth.signInWithOAuth("google");
console.log(result);
// {
//   token: "eyJhbGci...",
//   refreshToken: "stwd_rt_...",
//   expiresIn: 900,
//   user: { id: "usr_...", email: "user@gmail.com", walletAddress: "0x..." },
//   provider: "google"
// }

Configuration Options

const result = await auth.signInWithOAuth("discord", {
  redirectUri: "https://myapp.com/auth/callback", // custom callback URL
  tenantId: "my-tenant",                           // specific tenant
  popupWidth: 500,                                  // popup dimensions
  popupHeight: 600,
});

Redirect Flow (Non-Popup)

For environments where popups are blocked, use the redirect flow:
// Step 1: Start the flow (this will throw with the authorization URL)
try {
  await auth.signInWithOAuth("google");
} catch (err) {
  // In non-browser environments, the error contains the redirect URL
  window.location.href = err.message.match(/Redirect to: (.+)/)?.[1];
}

// Step 2: On your callback page, complete the exchange
const params = Object.fromEntries(new URLSearchParams(window.location.search));
const result = await auth.handleOAuthCallback("google", params);

React Usage

The <StewardLogin> component renders OAuth buttons based on which providers are enabled on the server:
<StewardLogin
  showGoogle    // enabled by default
  showDiscord   // enabled by default
  showPasskey={false}
  showEmail={false}
  title="Sign in"
/>
The component automatically queries GET /auth/providers to discover which OAuth providers are available. Buttons are only shown for providers that are both enabled on the server and not disabled via props.

Setting Up OAuth Apps

Google

  1. Go to Google Cloud Console
  2. Create a new OAuth 2.0 Client ID (Web application)
  3. Add authorized redirect URI:
    https://your-steward-url/auth/oauth/google/callback
    
  4. Copy the Client ID and Client Secret
# Server environment variables
GOOGLE_CLIENT_ID=xxxx.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=GOCSPX-xxxx

Discord

  1. Go to Discord Developer Portal
  2. Create a new application
  3. Go to OAuth2 settings
  4. Add redirect URI:
    https://your-steward-url/auth/oauth/discord/callback
    
  5. Copy the Client ID and Client Secret
DISCORD_CLIENT_ID=1234567890
DISCORD_CLIENT_SECRET=xxxx

Twitter/X

  1. Go to Twitter Developer Portal
  2. Create a project and app
  3. Enable OAuth 2.0 with PKCE
  4. Add callback URL:
    https://your-steward-url/auth/oauth/twitter/callback
    
  5. Copy the Client ID
TWITTER_CLIENT_ID=xxxx
TWITTER_CLIENT_SECRET=xxxx

Redirect URIs

All OAuth providers require a registered redirect URI. The pattern is:
https://your-steward-url/auth/oauth/{provider}/callback
For local development:
http://localhost:3200/auth/oauth/google/callback
http://localhost:3200/auth/oauth/discord/callback
http://localhost:3200/auth/oauth/twitter/callback

API Endpoints

EndpointMethodDescription
/auth/providersGETDiscover enabled auth methods
/auth/oauth/{provider}/authorizeGETStart OAuth flow (redirect to provider)
/auth/oauth/{provider}/callbackGETProvider redirect target
/auth/oauth/{provider}/tokenPOSTExchange code for session (PKCE)
/auth/device/codePOSTIssue a short-lived device code and user code
/auth/device/verifyPOSTApprove or deny a user code with an authenticated browser session
/auth/device/tokenPOSTPoll urn:ietf:params:oauth:grant-type:device_code for a session

Device Authorization Flow

For CLIs, TVs, and other input-constrained clients, Steward supports a bounded RFC 8628-style device flow. Device codes expire after 10 minutes, polling starts at a 5 second interval, rapid polling returns slow_down, and an approved code is consumed on first successful token exchange.
const auth = new StewardAuth({
  baseUrl: "https://api.steward.fi",
  tenantId: "my-tenant",
});

const issued = await auth.requestDeviceCode({
  clientId: "device-cli",
});

console.log(`Open ${issued.verification_uri} and enter ${issued.user_code}`);

let interval = issued.interval;
while (true) {
  await new Promise((resolve) => setTimeout(resolve, interval * 1000));
  const result = await auth.pollDeviceToken({
    deviceCode: issued.device_code,
    clientId: "device-cli",
  });
  if (result.ok) break;
  if (result.error === "slow_down" && result.interval) interval = result.interval;
  if (result.error === "access_denied" || result.error === "expired_token") {
    throw new Error(result.error);
  }
}
The verification UI should authenticate the user normally, then approve or deny the displayed code:
await auth.verifyDeviceCode("ABCD-2345", "approve");
When clientId is supplied, issuance requires an enabled tenant app client and token polling must present the same client id. Polling returns RFC-style errors: authorization_pending, slow_down, access_denied, expired_token, invalid_client, invalid_request, and unsupported_grant_type. Native app clients can additionally send platform identifiers on device-flow requests:
X-Steward-Native-Bundle-Id: com.example.ios
X-Steward-Native-Package-Name: com.example.android
If either header or the matching JSON fields (native_bundle_id, native_package_name, nativeBundleId, nativePackageName) is supplied, Steward validates the identifier format and checks it against the enabled app client’s allowedBundleIds or allowedPackageNames. Accepted identifiers are bound to the issued device code, so /auth/device/token must present the same identifier. A different identifier returns invalid_client and leaves the approval state unchanged. This is deterministic server allowlist enforcement; full device attestation remains the responsibility of the OS, app store, or attestation provider used by the native app. Minimal iOS-bound device-code request:
curl -X POST https://api.steward.fi/auth/device/code \
  -H "Content-Type: application/json" \
  -H "X-Steward-Native-Bundle-Id: com.example.ios" \
  -d '{
    "tenantId": "my-tenant",
    "client_id": "ios-prod"
  }'
The response echoes the accepted identifier using snake_case:
{
  "device_code": "stwd_dc_...",
  "user_code": "ABCD-2345",
  "verification_uri": "https://app.steward.fi/auth/device",
  "expires_in": 600,
  "interval": 5,
  "client_id": "ios-prod",
  "native_bundle_id": "com.example.ios"
}
The token poll must repeat the same app client and native identifier, either as headers or JSON fields:
curl -X POST https://api.steward.fi/auth/device/token \
  -H "Content-Type: application/json" \
  -d '{
    "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
    "device_code": "stwd_dc_...",
    "client_id": "ios-prod",
    "native_bundle_id": "com.example.ios"
  }'

Security

  • All flows use PKCE (S256 challenge method) to prevent authorization code interception
  • State parameter prevents CSRF attacks
  • Code verifier is stored client-side and never sent to the provider
  • Popup polling includes a 5-minute timeout
  • Device authorization codes are tenant/app-client bound, short-lived, rate-limited, and single-use
  • Provider tokens are encrypted before storage; Steward sessions return short-lived access tokens and rotated refresh tokens