Authentication Flows

Step-by-step guide for implementing OAuth, admin login, device flow, and token management

Updated Dec 16, 2025
Edit on GitHub

This guide walks you through implementing all authentication flows supported by the AuthOS SDK. You’ll learn how to handle end-user OAuth login, admin authentication, device flows for CLIs, and token lifecycle management.

Prerequisites

Before you begin, make sure you have:

  • Installed the SDK: npm install @drmhse/sso-sdk
  • An AuthOS instance URL
  • Organization and service configured in the platform

End-User OAuth Login (Web Redirect Flow)

The most common authentication flow for web applications. Users authenticate using OAuth providers (GitHub, Google, Microsoft) configured by your organization.

Step 1: Initialize the SDK

import { SsoClient } from '@drmhse/sso-sdk';

const sso = new SsoClient({
  baseURL: 'https://sso.example.com'
});

Step 2: Redirect User to OAuth Provider

// Generate the OAuth login URL
const loginUrl = sso.auth.getLoginUrl('github', {
  org: 'acme-corp',
  service: 'main-app',
  redirect_uri: 'https://app.acme.com/callback'
});

// Redirect the user to authenticate
window.location.href = loginUrl;

Supported Providers:

  • github - GitHub OAuth
  • google - Google OAuth
  • microsoft - Microsoft OAuth

Step 3: Handle the Callback

After successful authentication, the user is redirected back to your redirect_uri with tokens in the URL query parameters.

// In your callback route handler (e.g., /callback)
function handleOAuthCallback() {
  const params = new URLSearchParams(window.location.search);
  const accessToken = params.get('access_token');
  const refreshToken = params.get('refresh_token');

  if (!accessToken || !refreshToken) {
    console.error('Authentication failed - no tokens received');
    window.location.href = '/login';
    return;
  }

  // Store tokens securely
  localStorage.setItem('sso_access_token', accessToken);
  localStorage.setItem('sso_refresh_token', refreshToken);

  // Set the token in the SDK for future requests
  sso.setAuthToken(accessToken);

  // Redirect to the main application
  window.location.href = '/dashboard';
}

Step 4: Verify Authentication

// Fetch the authenticated user's profile
try {
  const profile = await sso.user.getProfile();
  console.log('Logged in as:', profile.email);
  console.log('Organization:', profile.org);
  console.log('Service:', profile.service);
} catch (error) {
  console.error('Failed to fetch profile:', error);
  // Token invalid - redirect to login
  window.location.href = '/login';
}

Complete Example: React Login Component

import { useEffect } from 'react';
import { SsoClient, SsoApiError } from '@drmhse/sso-sdk';

const sso = new SsoClient({
  baseURL: process.env.REACT_APP_SSO_URL
});

export function LoginPage() {
  const handleLogin = (provider: 'github' | 'google' | 'microsoft') => {
    const loginUrl = sso.auth.getLoginUrl(provider, {
      org: 'acme-corp',
      service: 'main-app',
      redirect_uri: `${window.location.origin}/callback`
    });
    window.location.href = loginUrl;
  };

  return (
    <div className="login-page">
      <h1>Sign In</h1>
      <button onClick={() => handleLogin('github')}>
        Sign in with GitHub
      </button>
      <button onClick={() => handleLogin('google')}>
        Sign in with Google
      </button>
      <button onClick={() => handleLogin('microsoft')}>
        Sign in with Microsoft
      </button>
    </div>
  );
}

export function CallbackPage() {
  useEffect(() => {
    const params = new URLSearchParams(window.location.search);
    const accessToken = params.get('access_token');
    const refreshToken = params.get('refresh_token');

    if (accessToken && refreshToken) {
      localStorage.setItem('sso_access_token', accessToken);
      localStorage.setItem('sso_refresh_token', refreshToken);
      sso.setAuthToken(accessToken);
      window.location.href = '/dashboard';
    } else {
      window.location.href = '/login';
    }
  }, []);

  return <div>Completing authentication...</div>;
}

Admin Login (Platform & Organization Admins)

Platform owners and organization admins use a separate authentication flow with dedicated OAuth credentials managed by the platform.

When to Use Admin Login

  • Platform owner dashboard
  • Organization administration panel
  • Service configuration interfaces
  • Any interface that requires elevated privileges

Implementation

// Generate admin login URL
const adminLoginUrl = sso.auth.getAdminLoginUrl('github', {
  org_slug: 'acme-corp' // Optional: redirect to specific org after login
});

// Redirect admin to authenticate
window.location.href = adminLoginUrl;

// The callback handling is identical to end-user flow
// Tokens are returned in the same way

Admin Login with Organization Context

// Admin dashboard component
export function AdminDashboard() {
  const handleAdminLogin = () => {
    // After login, user will be directed to acme-corp dashboard
    const loginUrl = sso.auth.getAdminLoginUrl('github', {
      org_slug: 'acme-corp'
    });
    window.location.href = loginUrl;
  };

  return (
    <div>
      <h1>Admin Panel</h1>
      <button onClick={handleAdminLogin}>Sign in as Admin</button>
    </div>
  );
}

Key Differences from End-User Flow:

  • Uses platform OAuth credentials instead of tenant credentials
  • Grants admin-level permissions
  • Can access platform-wide resources
  • JWT contains admin role claims

Device Flow (CLIs and Mobile Apps)

The device authorization flow (RFC 8628) enables authentication on devices that don’t have a browser or have limited input capabilities, such as CLIs, IoT devices, or smart TVs.

How Device Flow Works

  1. Device requests a user code
  2. Device displays the code and verification URL to the user
  3. User visits the URL on another device (phone/computer)
  4. User enters the code and authenticates
  5. Device polls for the token
  6. Token is granted after user approves

CLI Implementation

import { SsoClient } from '@drmhse/sso-sdk';

const sso = new SsoClient({
  baseURL: 'https://sso.example.com'
});

async function loginCLI() {
  // Step 1: Request device code
  const deviceAuth = await sso.auth.deviceCode.request({
    client_id: 'acme-cli',
    org: 'acme-corp',
    service: 'acme-cli'
  });

  console.log('\n=================================');
  console.log('To authenticate, visit:');
  console.log(deviceAuth.verification_uri);
  console.log('\nAnd enter code:');
  console.log(deviceAuth.user_code);
  console.log('=================================\n');

  // Step 2: Poll for token
  const token = await pollForToken(deviceAuth.device_code, deviceAuth.interval);

  if (token) {
    // Save token to config file
    console.log('Authentication successful!');
    return token.access_token;
  }
}

async function pollForToken(deviceCode: string, interval: number) {
  const maxAttempts = 60; // 5 minutes with 5-second intervals
  let attempts = 0;

  while (attempts < maxAttempts) {
    await sleep(interval * 1000);
    attempts++;

    try {
      const tokenResponse = await sso.auth.deviceCode.exchangeToken({
        grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
        device_code: deviceCode,
        client_id: 'acme-cli'
      });

      return tokenResponse;
    } catch (error) {
      if (error instanceof SsoApiError) {
        if (error.errorCode === 'authorization_pending') {
          // User hasn't authorized yet, keep polling
          process.stdout.write('.');
          continue;
        } else if (error.errorCode === 'slow_down') {
          // Increase interval if requested
          interval += 5;
          continue;
        } else if (error.errorCode === 'expired_token') {
          console.error('\nDevice code expired. Please try again.');
          return null;
        } else if (error.errorCode === 'access_denied') {
          console.error('\nUser denied the request.');
          return null;
        }
      }
      throw error;
    }
  }

  console.error('\nAuthentication timed out.');
  return null;
}

function sleep(ms: number): Promise<void> {
  return new Promise(resolve => setTimeout(resolve, ms));
}

Web Application: Device Code Verification

Your web application needs a route to handle device code verification (typically at /activate).

// Activation page component
import { useState } from 'react';
import { SsoClient } from '@drmhse/sso-sdk';

const sso = new SsoClient({
  baseURL: process.env.REACT_APP_SSO_URL
});

export function DeviceActivationPage() {
  const [userCode, setUserCode] = useState('');
  const [error, setError] = useState('');

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setError('');

    try {
      // Verify the user code
      const context = await sso.auth.deviceCode.verify(userCode.trim().toUpperCase());

      // Redirect to OAuth flow with user_code parameter
      const loginUrl = sso.auth.getLoginUrl('github', {
        org: context.org_slug,
        service: context.service_slug,
        user_code: userCode.trim().toUpperCase(), // CRITICAL: pass user_code
        redirect_uri: `${window.location.origin}/device-callback`
      });

      window.location.href = loginUrl;
    } catch (err) {
      if (err instanceof SsoApiError) {
        setError(err.message);
      } else {
        setError('Failed to verify code');
      }
    }
  };

  return (
    <div className="activation-page">
      <h1>Device Activation</h1>
      <p>Enter the code displayed on your device</p>

      <form onSubmit={handleSubmit}>
        <input
          type="text"
          value={userCode}
          onChange={(e) => setUserCode(e.target.value)}
          placeholder="XXXX-XXXX"
          maxLength={9}
          style={{ textTransform: 'uppercase' }}
        />
        <button type="submit">Continue</button>
      </form>

      {error && <div className="error">{error}</div>}
    </div>
  );
}

Complete Device Flow: Full Example

// cli.ts - Complete CLI tool with device flow
import { SsoClient, SsoApiError } from '@drmhse/sso-sdk';
import * as fs from 'fs';
import * as path from 'path';

const CONFIG_PATH = path.join(process.env.HOME!, '.acme-cli', 'config.json');

class AcmeCLI {
  private sso: SsoClient;

  constructor() {
    this.sso = new SsoClient({
      baseURL: 'https://sso.example.com'
    });
  }

  async login() {
    console.log('Starting authentication...\n');

    const deviceAuth = await this.sso.auth.deviceCode.request({
      client_id: 'acme-cli',
      org: 'acme-corp',
      service: 'acme-cli'
    });

    console.log('To authenticate, visit:');
    console.log(`  ${deviceAuth.verification_uri}`);
    console.log('\nAnd enter code:');
    console.log(`  ${deviceAuth.user_code}`);
    console.log('\nWaiting for authentication', { flush: true });

    const token = await this.pollForToken(
      deviceAuth.device_code,
      deviceAuth.interval
    );

    if (token) {
      this.saveToken(token.access_token, token.refresh_token);
      console.log('\nAuthentication successful!');
      return true;
    }

    return false;
  }

  private async pollForToken(deviceCode: string, interval: number) {
    const maxTime = 300; // 5 minutes
    const startTime = Date.now();

    while (Date.now() - startTime < maxTime * 1000) {
      await this.sleep(interval * 1000);

      try {
        const tokenResponse = await this.sso.auth.deviceCode.exchangeToken({
          grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
          device_code: deviceCode,
          client_id: 'acme-cli'
        });

        return tokenResponse;
      } catch (error) {
        if (error instanceof SsoApiError) {
          if (error.errorCode === 'authorization_pending') {
            process.stdout.write('.');
            continue;
          } else if (error.errorCode === 'slow_down') {
            interval += 5;
            continue;
          } else if (error.errorCode === 'expired_token') {
            console.error('\n\nDevice code expired. Please try again.');
            return null;
          } else if (error.errorCode === 'access_denied') {
            console.error('\n\nAuthentication denied.');
            return null;
          }
        }
        throw error;
      }
    }

    console.error('\n\nAuthentication timed out.');
    return null;
  }

  private saveToken(accessToken: string, refreshToken: string) {
    const dir = path.dirname(CONFIG_PATH);
    if (!fs.existsSync(dir)) {
      fs.mkdirSync(dir, { recursive: true });
    }

    fs.writeFileSync(
      CONFIG_PATH,
      JSON.stringify({ accessToken, refreshToken }, null, 2)
    );
  }

  private sleep(ms: number): Promise<void> {
    return new Promise(resolve => setTimeout(resolve, ms));
  }
}

// CLI entry point
const cli = new AcmeCLI();
cli.login();

Token Refresh Workflow

Access tokens expire after a period of time (typically 1 hour). Use refresh tokens to obtain new access tokens without requiring the user to log in again.

When to Refresh Tokens

  • When an API call returns a 401 Unauthorized error
  • Proactively before the access token expires (if you track expiration)
  • On application startup if the stored token might be expired

Basic Token Refresh

async function refreshAccessToken() {
  const refreshToken = localStorage.getItem('sso_refresh_token');

  if (!refreshToken) {
    // No refresh token - user needs to log in
    window.location.href = '/login';
    return null;
  }

  try {
    const tokens = await sso.auth.refreshToken(refreshToken);

    // Store new tokens (both are rotated)
    localStorage.setItem('sso_access_token', tokens.access_token);
    localStorage.setItem('sso_refresh_token', tokens.refresh_token);

    // Update SDK
    sso.setAuthToken(tokens.access_token);

    return tokens.access_token;
  } catch (error) {
    console.error('Token refresh failed:', error);
    // Refresh token invalid or expired - user must log in
    localStorage.removeItem('sso_access_token');
    localStorage.removeItem('sso_refresh_token');
    window.location.href = '/login';
    return null;
  }
}

Automatic Token Refresh with Interceptor

Implement automatic token refresh by intercepting 401 errors:

import { SsoClient, SsoApiError } from '@drmhse/sso-sdk';

class AuthenticatedSsoClient {
  private sso: SsoClient;
  private isRefreshing: boolean = false;
  private refreshPromise: Promise<string | null> | null = null;

  constructor(baseURL: string) {
    const accessToken = localStorage.getItem('sso_access_token');
    this.sso = new SsoClient({ baseURL, token: accessToken || undefined });
  }

  async request<T>(fn: () => Promise<T>): Promise<T> {
    try {
      return await fn();
    } catch (error) {
      if (error instanceof SsoApiError && error.statusCode === 401) {
        // Token expired, try to refresh
        const newToken = await this.refreshToken();

        if (newToken) {
          // Retry the original request with new token
          return await fn();
        }
      }
      throw error;
    }
  }

  private async refreshToken(): Promise<string | null> {
    // Prevent multiple simultaneous refresh attempts
    if (this.isRefreshing && this.refreshPromise) {
      return this.refreshPromise;
    }

    this.isRefreshing = true;
    this.refreshPromise = this.performRefresh();

    const result = await this.refreshPromise;
    this.isRefreshing = false;
    this.refreshPromise = null;

    return result;
  }

  private async performRefresh(): Promise<string | null> {
    const refreshToken = localStorage.getItem('sso_refresh_token');

    if (!refreshToken) {
      this.redirectToLogin();
      return null;
    }

    try {
      const tokens = await this.sso.auth.refreshToken(refreshToken);

      localStorage.setItem('sso_access_token', tokens.access_token);
      localStorage.setItem('sso_refresh_token', tokens.refresh_token);
      this.sso.setAuthToken(tokens.access_token);

      return tokens.access_token;
    } catch (error) {
      console.error('Token refresh failed:', error);
      this.redirectToLogin();
      return null;
    }
  }

  private redirectToLogin() {
    localStorage.removeItem('sso_access_token');
    localStorage.removeItem('sso_refresh_token');
    window.location.href = '/login';
  }

  getClient(): SsoClient {
    return this.sso;
  }
}

// Usage
const authClient = new AuthenticatedSsoClient('https://sso.example.com');

// Wrap all API calls with automatic retry
const profile = await authClient.request(() =>
  authClient.getClient().user.getProfile()
);

React Hook for Token Management

import { useEffect, useState } from 'react';
import { SsoClient } from '@drmhse/sso-sdk';

export function useAuth() {
  const [sso, setSso] = useState<SsoClient | null>(null);
  const [isAuthenticated, setIsAuthenticated] = useState(false);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    initializeAuth();
  }, []);

  const initializeAuth = async () => {
    const accessToken = localStorage.getItem('sso_access_token');
    const refreshToken = localStorage.getItem('sso_refresh_token');

    if (!accessToken || !refreshToken) {
      setIsLoading(false);
      return;
    }

    const client = new SsoClient({
      baseURL: process.env.REACT_APP_SSO_URL!,
      token: accessToken
    });

    try {
      // Try to fetch profile to verify token
      await client.user.getProfile();
      setSso(client);
      setIsAuthenticated(true);
    } catch (error) {
      // Token invalid, try to refresh
      try {
        const tokens = await client.auth.refreshToken(refreshToken);
        localStorage.setItem('sso_access_token', tokens.access_token);
        localStorage.setItem('sso_refresh_token', tokens.refresh_token);
        client.setAuthToken(tokens.access_token);
        setSso(client);
        setIsAuthenticated(true);
      } catch (refreshError) {
        // Refresh failed, clear tokens
        localStorage.removeItem('sso_access_token');
        localStorage.removeItem('sso_refresh_token');
      }
    } finally {
      setIsLoading(false);
    }
  };

  const logout = async () => {
    if (sso) {
      try {
        await sso.auth.logout();
      } catch (error) {
        console.error('Logout error:', error);
      }
    }

    localStorage.removeItem('sso_access_token');
    localStorage.removeItem('sso_refresh_token');
    setSso(null);
    setIsAuthenticated(false);
  };

  return {
    sso,
    isAuthenticated,
    isLoading,
    logout
  };
}

// Usage in components
function App() {
  const { sso, isAuthenticated, isLoading } = useAuth();

  if (isLoading) {
    return <div>Loading...</div>;
  }

  if (!isAuthenticated) {
    return <LoginPage />;
  }

  return <Dashboard sso={sso!} />;
}

Risk-Based Authentication

AuthOS includes an intelligent Risk Engine that evaluates login attempts in real-time. When suspicious activity is detected, the platform may require additional verification through MFA, even if the user’s credentials are correct.

Understanding Risk Assessment

The Risk Engine evaluates multiple signals during every authentication attempt:

  • Geographic Location: Logins from new countries or impossible travel patterns
  • Device Trust: Logins from previously unseen devices
  • Velocity Detection: Rapid login attempts or suspicious patterns
  • User Agent Analysis: Unusual browsers or automation tools

Based on the risk score (0-100), the platform takes one of these actions:

Risk Score Action What Happens
0-40 Allow Login succeeds normally
41-60 Log Only Login succeeds, event logged for review
61-80 Challenge MFA Additional MFA verification required
81-100 Block Login denied

Handling MFA Challenges

When the Risk Engine determines additional verification is needed, authentication responses will include special indicators:

Password Login with Risk Challenge

async function handlePasswordLogin(email: string, password: string) {
  try {
    const response = await sso.auth.login({ email, password });

    // Check if MFA is required due to risk assessment
    if (response.expires_in === 300) {
      // This is a pre-auth token (5 minutes expiry)
      // User needs to provide MFA code
      return {
        type: 'mfa_required',
        preauthToken: response.access_token,
        message: 'Additional verification required'
      };
    }

    // Normal login - no MFA required
    sso.setAuthToken(response.access_token);
    localStorage.setItem('sso_access_token', response.access_token);
    localStorage.setItem('sso_refresh_token', response.refresh_token);

    return { type: 'success' };
  } catch (error) {
    return { type: 'error', error };
  }
}
async function handleMagicLinkVerification(token: string) {
  try {
    const response = await sso.magicLinks.verify(token);

    // Check if response indicates MFA is required
    if (response.requires_mfa) {
      return {
        type: 'mfa_required',
        preauthToken: response.preauth_token,
        message: response.message || 'Additional verification required due to unusual activity'
      };
    }

    // Normal verification - store tokens
    sso.setAuthToken(response.access_token);
    localStorage.setItem('sso_access_token', response.access_token);
    localStorage.setItem('sso_refresh_token', response.refresh_token);

    return { type: 'success' };
  } catch (error) {
    return { type: 'error', error };
  }
}

Completing MFA Verification

Once you have a pre-auth token from a risk challenge, prompt the user for their MFA code:

async function completeMfaChallenge(preauthToken: string, mfaCode: string) {
  try {
    const tokens = await sso.auth.verifyMfa(preauthToken, mfaCode);

    // MFA verification successful - store full tokens
    sso.setAuthToken(tokens.access_token);
    localStorage.setItem('sso_access_token', tokens.access_token);
    localStorage.setItem('sso_refresh_token', tokens.refresh_token);

    return { type: 'success' };
  } catch (error) {
    if (error instanceof SsoApiError) {
      if (error.statusCode === 401) {
        return {
          type: 'invalid_code',
          message: 'Invalid MFA code. Please try again.'
        };
      } else if (error.statusCode === 410) {
        return {
          type: 'expired',
          message: 'Verification expired. Please log in again.'
        };
      }
    }
    return { type: 'error', error };
  }
}

Complete React Risk-Based Authentication

import { useState } from 'react';
import { SsoClient, SsoApiError } from '@drmhse/sso-sdk';

const sso = new SsoClient({
  baseURL: process.env.REACT_APP_SSO_URL!
});

type AuthState =
  | { step: 'credentials' }
  | { step: 'mfa_required'; preauthToken: string; message: string }
  | { step: 'success' };

export function RiskAwareLogin() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [mfaCode, setMfaCode] = useState('');
  const [authState, setAuthState] = useState<AuthState>({ step: 'credentials' });
  const [error, setError] = useState('');
  const [isLoading, setIsLoading] = useState(false);

  const handleLogin = async (e: React.FormEvent) => {
    e.preventDefault();
    setError('');
    setIsLoading(true);

    try {
      const response = await sso.auth.login({ email, password });

      // Check for pre-auth token (MFA required)
      if (response.expires_in === 300) {
        setAuthState({
          step: 'mfa_required',
          preauthToken: response.access_token,
          message: 'We detected unusual activity. Please verify your identity with MFA.'
        });
        setIsLoading(false);
      } else {
        // Normal login
        sso.setAuthToken(response.access_token);
        localStorage.setItem('sso_access_token', response.access_token);
        localStorage.setItem('sso_refresh_token', response.refresh_token);
        setAuthState({ step: 'success' });
        window.location.href = '/dashboard';
      }
    } catch (err) {
      setIsLoading(false);
      if (err instanceof SsoApiError) {
        setError(err.message);
      } else {
        setError('Login failed. Please try again.');
      }
    }
  };

  const handleMfaVerification = async (e: React.FormEvent) => {
    e.preventDefault();
    if (authState.step !== 'mfa_required') return;

    setError('');
    setIsLoading(true);

    try {
      const tokens = await sso.auth.verifyMfa(authState.preauthToken, mfaCode);

      sso.setAuthToken(tokens.access_token);
      localStorage.setItem('sso_access_token', tokens.access_token);
      localStorage.setItem('sso_refresh_token', tokens.refresh_token);

      setAuthState({ step: 'success' });
      window.location.href = '/dashboard';
    } catch (err) {
      setIsLoading(false);
      if (err instanceof SsoApiError) {
        if (err.statusCode === 401) {
          setError('Invalid MFA code. Please try again.');
        } else if (err.statusCode === 410) {
          setError('Verification expired. Please log in again.');
          setAuthState({ step: 'credentials' });
        } else {
          setError(err.message);
        }
      }
    }
  };

  // Step 1: Credentials
  if (authState.step === 'credentials') {
    return (
      <div className="login-form">
        <h2>Sign In</h2>
        <form onSubmit={handleLogin}>
          <div className="form-group">
            <label>Email</label>
            <input
              type="email"
              value={email}
              onChange={(e) => setEmail(e.target.value)}
              required
              autoFocus
            />
          </div>

          <div className="form-group">
            <label>Password</label>
            <input
              type="password"
              value={password}
              onChange={(e) => setPassword(e.target.value)}
              required
            />
          </div>

          <button type="submit" disabled={isLoading}>
            {isLoading ? 'Signing in...' : 'Sign In'}
          </button>
        </form>

        {error && <div className="alert alert-error">{error}</div>}
      </div>
    );
  }

  // Step 2: MFA Challenge
  if (authState.step === 'mfa_required') {
    return (
      <div className="mfa-challenge">
        <h2>Additional Verification Required</h2>

        <div className="alert alert-warning">
          <p>{authState.message}</p>
        </div>

        <p>Please enter your 6-digit MFA code from your authenticator app:</p>

        <form onSubmit={handleMfaVerification}>
          <div className="form-group">
            <label>MFA Code</label>
            <input
              type="text"
              value={mfaCode}
              onChange={(e) => setMfaCode(e.target.value.replace(/\D/g, ''))}
              placeholder="000000"
              maxLength={6}
              pattern="\d{6}"
              required
              autoFocus
            />
          </div>

          <button type="submit" disabled={isLoading || mfaCode.length !== 6}>
            {isLoading ? 'Verifying...' : 'Verify'}
          </button>
        </form>

        {error && <div className="alert alert-error">{error}</div>}

        <div className="help-text">
          <p>
            This additional step is required because we detected unusual activity,
            such as a login from a new location or device.
          </p>
        </div>

        <button
          className="secondary-button"
          onClick={() => setAuthState({ step: 'credentials' })}
        >
          Cancel and try again
        </button>
      </div>
    );
  }

  return null;
}

OAuth Flow with Risk Assessment

OAuth flows can also trigger MFA challenges. Handle this in your callback:

export function OAuthCallback() {
  const [status, setStatus] = useState<'processing' | 'mfa_required' | 'success'>('processing');
  const [preauthToken, setPreauthToken] = useState('');
  const [mfaCode, setMfaCode] = useState('');
  const [error, setError] = useState('');

  useEffect(() => {
    handleCallback();
  }, []);

  const handleCallback = async () => {
    const params = new URLSearchParams(window.location.search);
    const accessToken = params.get('access_token');
    const requiresMfa = params.get('requires_mfa');

    if (requiresMfa === 'true' && accessToken) {
      // Risk assessment triggered MFA requirement
      setPreauthToken(accessToken);
      setStatus('mfa_required');
      return;
    }

    if (accessToken) {
      const refreshToken = params.get('refresh_token');
      localStorage.setItem('sso_access_token', accessToken);
      if (refreshToken) {
        localStorage.setItem('sso_refresh_token', refreshToken);
      }
      sso.setAuthToken(accessToken);
      setStatus('success');
      window.location.href = '/dashboard';
    }
  };

  const handleMfaSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    try {
      const tokens = await sso.auth.verifyMfa(preauthToken, mfaCode);
      localStorage.setItem('sso_access_token', tokens.access_token);
      localStorage.setItem('sso_refresh_token', tokens.refresh_token);
      sso.setAuthToken(tokens.access_token);
      window.location.href = '/dashboard';
    } catch (err) {
      setError('MFA verification failed');
    }
  };

  if (status === 'mfa_required') {
    return (
      <div className="mfa-challenge">
        <h2>Additional Verification Required</h2>
        <p>Please enter your MFA code to complete login:</p>
        <form onSubmit={handleMfaSubmit}>
          <input
            type="text"
            value={mfaCode}
            onChange={(e) => setMfaCode(e.target.value)}
            placeholder="000000"
            maxLength={6}
            required
          />
          <button type="submit">Verify</button>
        </form>
        {error && <div className="error">{error}</div>}
      </div>
    );
  }

  return <div>Processing authentication...</div>;
}

User Experience Best Practices

  1. Clear Communication: Explain why additional verification is needed
  2. Contextual Messages: Show location/device information that triggered the challenge
  3. Backup Options: Allow users to use backup codes if they don’t have their authenticator
  4. Session Persistence: Remember trusted devices to reduce friction for returning users
  5. Graceful Fallbacks: Provide alternative authentication methods if MFA fails

Detecting Risk Challenges

Pre-auth tokens have a short expiration (5 minutes) to indicate MFA is required:

function isPreAuthToken(response: RefreshTokenResponse): boolean {
  // Pre-auth tokens expire in 5 minutes (300 seconds)
  // Normal tokens expire in 15 minutes (900 seconds)
  return response.expires_in === 300;
}

function isMfaRequired(response: any): boolean {
  return response.requires_mfa === true || isPreAuthToken(response);
}

Handling Backup Codes

Users can use backup codes instead of TOTP codes during MFA challenges:

async function verifyWithBackupCode(preauthToken: string, backupCode: string) {
  try {
    // Backup codes are treated the same as TOTP codes
    const tokens = await sso.auth.verifyMfa(preauthToken, backupCode);
    return { success: true, tokens };
  } catch (error) {
    if (error instanceof SsoApiError && error.statusCode === 401) {
      return {
        success: false,
        error: 'Invalid backup code or code already used'
      };
    }
    throw error;
  }
}

Testing Risk-Based Flows

When testing risk-based authentication:

  1. New Device: Clear cookies to simulate a new device
  2. New Location: Use a VPN to test geographic anomaly detection
  3. Rapid Attempts: Test velocity detection with multiple quick login attempts
  4. Monitor Mode: Set organization risk settings to “monitor” mode during development
// Set organization to monitor mode for testing
await sso.organizations.updateRiskSettings('acme-corp', {
  enabled: true,
  mode: 'monitor',  // Logs risk but doesn't enforce
  block_threshold: 80,
  mfa_threshold: 60
});

Best Practices

Token Storage

Web Applications:

  • Use localStorage for single-tab persistence
  • Use sessionStorage for single-session security
  • Consider using secure HTTP-only cookies for sensitive applications

Mobile Applications:

  • Use secure storage APIs (iOS Keychain, Android Keystore)
  • Never store tokens in plain text files

CLI Applications:

  • Store tokens in user home directory with restricted permissions
  • Use OS-specific secure storage when available

Security Considerations

  1. HTTPS Only: Always use HTTPS in production
  2. Token Rotation: The platform implements automatic refresh token rotation
  3. XSS Protection: Sanitize all user inputs to prevent token theft
  4. CSRF Protection: Use state parameters for OAuth flows
  5. Logout Everywhere: Call sso.auth.logout() to revoke tokens server-side

Error Handling

import { SsoApiError } from '@drmhse/sso-sdk';

try {
  await sso.user.getProfile();
} catch (error) {
  if (error instanceof SsoApiError) {
    switch (error.statusCode) {
      case 401:
        // Token expired or invalid
        await refreshAccessToken();
        break;
      case 403:
        // Insufficient permissions
        console.error('Access denied');
        break;
      case 404:
        // Resource not found
        console.error('Resource not found');
        break;
      case 500:
        // Server error
        console.error('Server error, please try again later');
        break;
      default:
        console.error(`Error: ${error.message}`);
    }
  } else {
    // Network or other error
    console.error('Network error:', error);
  }
}

Redirect URI Configuration

Always whitelist your redirect URIs in the service configuration:

// When creating a service via API or admin dashboard
const service = await sso.services.create('acme-corp', {
  slug: 'main-app',
  name: 'Main Application',
  service_type: 'web',
  redirect_uris: [
    'https://app.acme.com/callback',
    'https://app.acme.com/device-callback',
    'http://localhost:3000/callback' // for development
  ]
});

Next Steps