Skip to content

[Proposal] Authorization Provider #690

@thgaskell

Description

@thgaskell

JavaScript Authentication Library Technical Specification

Design Philosophy: This specification prioritizes security, developer experience, and extensibility. Key considerations include preventing XSS token theft, supporting page refresh recovery, and providing a plugin architecture for custom authentication schemes.

Core Architecture

Application Class

Design Consideration: The Application class serves as the main entry point, exposing async/await patterns preferred by developers while maintaining callback support internally. The configuration approach allows for future expansion without breaking existing implementations.

interface ApplicationConfig {
  auth?: AuthConfig;
  // Other app configurations (including existing storage config)
}

interface AuthConfig {
  provider: AuthProvider;
  refreshThreshold?: number; // Minutes before expiry to auto-refresh
  autoRefresh?: boolean;
}

class Application {
  constructor(config: ApplicationConfig);
  
  // Authentication methods
  authenticate(credentials: unknown): Promise<AuthResult>;
  refresh(token?: RefreshToken): Promise<AuthResult>;
  logout(): Promise<void>;
  
  // Credential access
  getCredentials(options?: CredentialOptions): Promise<Credentials | null>;
  isAuthenticated(): boolean;
  
  // Event handling
  on(event: AuthEvent, callback: Function): void;
  off(event: AuthEvent, callback: Function): void;
}

Authentication Provider Interface

Plugin Architecture Rationale: This interface was designed to support the planned OIDC plugin while allowing developers to create custom authentication schemes. The common interface ensures consistent behavior across different authentication methods. Storage responsibility is delegated to plugin authors, allowing them to choose appropriate storage mechanisms for their specific authentication schemes.

interface AuthProvider {
  name: string;
  version: string;
  
  // Core authentication methods
  authenticate(credentials: unknown, config: AuthConfig): Promise<AuthResult>;
  refresh(refreshToken: RefreshToken, config: AuthConfig): Promise<AuthResult>;
  logout(credentials: Credentials, config: AuthConfig): Promise<void>;
  
  // Validation and utilities
  validateCredentials(credentials: unknown): boolean;
  getTokenExpiry(credentials: Credentials): Date | null;
  extractCredentials(authResult: AuthResult): Credentials;
  
  // Storage management (plugin responsibility)
  saveState(state: AuthState): Promise<void>;
  loadState(): Promise<AuthState | null>;
  clearState(): Promise<void>;
}

interface AuthResult {
  success: boolean;
  credentials?: Credentials;
  refreshToken?: RefreshToken;
  expiresAt?: Date;
  metadata?: Record<string, unknown>;
  error?: AuthError;
}

interface Credentials {
  type: string; // 'bearer', 'basic', 'custom', etc.
  value: string | Record<string, unknown>;
  expiresAt?: Date;
  metadata?: Record<string, unknown>;
}

interface RefreshToken {
  value: string;
  expiresAt?: Date;
  metadata?: Record<string, unknown>;
}

State Management

Security vs. Usability Balance: The state management strategy addresses the core requirement of page refresh persistence while maintaining security. Plugin authors are responsible for implementing appropriate storage mechanisms for their authentication schemes, allowing for scheme-specific security and persistence requirements.

Authentication State Store

User Abstraction: Currently excludes user concepts as requested, focusing on credentials and authentication state. The structure allows for future user support without major refactoring.

interface AuthState {
  isAuthenticated: boolean;
  credentials: Credentials | null;
  refreshToken: RefreshToken | null;
  lastAuthTime: Date | null;
  error: AuthError | null;
}

// Core library only manages in-memory state
interface AuthStateManager {
  // Memory-only storage for active credentials (security requirement)
  setCredentials(credentials: Credentials): void;
  getCredentials(): Credentials | null;
  clearCredentials(): void;
  
  // Delegates persistent storage to auth provider
  saveState(): Promise<void>; // Calls provider.saveState()
  loadState(): Promise<void>; // Calls provider.loadState()
  clearState(): Promise<void>; // Calls provider.clearState()
}

Storage Strategy

Plugin-Managed Storage: Storage mechanisms are now the responsibility of individual authentication providers, allowing each plugin to implement storage strategies appropriate for their security requirements and authentication flow. The core library only manages in-memory credentials for security.

  • Memory Storage: Active credentials always stored in memory only (core library responsibility)
  • Persistent Storage: Refresh tokens and recovery state managed by auth providers
  • Storage Flexibility: Plugins can choose localStorage, sessionStorage, IndexedDB, or encrypted storage
  • Security Control: Each plugin implements security measures appropriate for their authentication method

Authentication Flow

Page Refresh Recovery: This flow addresses the key requirement that authentication state should persist across page refreshes without compromising security. The refresh token mechanism provides seamless recovery while the memory-only credential storage prevents token theft.

Initial Authentication

// 1. Developer calls authenticate
const result = await app.authenticate({
  username: 'user@example.com',
  password: 'password'
});

// 2. Library flow:
// - Validates credentials via provider
// - Stores credentials in memory
// - Stores refresh token persistently (if provided)
// - Sets up auto-refresh timer
// - Emits 'authenticated' event

Page Refresh Recovery

Automatic Recovery Strategy: This initialization process ensures users don't need to re-authenticate after page refreshes, addressing the core usability requirement. Recovery mechanisms are implemented by auth providers, allowing for authentication-scheme-specific recovery strategies.

// On application initialization:
// 1. Ask auth provider to load any persistent state
// 2. If state found and valid, attempt refresh
// 3. If refresh succeeds, restore authenticated state
// 4. If refresh fails, clear state and require re-authentication

class Application {
  async initialize(): Promise<void> {
    const persistentState = await this.authProvider.loadState();
    if (persistentState?.refreshToken && !this.isExpired(persistentState.refreshToken)) {
      try {
        await this.refresh(persistentState.refreshToken);
      } catch (error) {
        await this.authProvider.clearState();
        this.emit('authenticationRequired');
      }
    }
  }
}

Token Refresh Mechanism

Proactive Token Management: The refresh mechanism supports explicit refresh calls as requested (await app.refresh(token)) while also providing background refresh capabilities. The timer-based approach prevents authentication failures from expired tokens.

interface RefreshManager {
  scheduleRefresh(credentials: Credentials): void;
  cancelScheduledRefresh(): void;
  executeRefresh(): Promise<void>;
}

// Auto-refresh implementation
class TokenRefreshManager implements RefreshManager {
  private refreshTimer: number | null = null;
  
  scheduleRefresh(credentials: Credentials): void {
    if (!credentials.expiresAt) return;
    
    const refreshTime = new Date(credentials.expiresAt.getTime() - (this.config.refreshThreshold * 60000));
    const delay = refreshTime.getTime() - Date.now();
    
    if (delay > 0) {
      this.refreshTimer = setTimeout(() => this.executeRefresh(), delay);
    }
  }
}

Plugin System

Extensibility Focus: The plugin system addresses the requirement for custom authentication schemes while preparing for the planned OIDC plugin. This design allows the library to support authentication methods not officially provided.

Plugin Registration

interface PluginRegistry {
  register(provider: AuthProvider): void;
  get(name: string): AuthProvider | null;
  list(): string[];
}

// Usage
const oidcProvider = new OIDCAuthProvider({
  issuer: 'https://auth.example.com',
  clientId: 'your-client-id'
});

const app = new Application({
  auth: {
    provider: oidcProvider
  }
});

Example OIDC Plugin Structure

Plugin Implementation Guide: This example demonstrates how the planned official OIDC plugin would integrate with the core library, including its own storage management. This shows how plugins can implement storage strategies appropriate for their specific security requirements.

class OIDCAuthProvider implements AuthProvider {
  name = 'oidc';
  version = '1.0.0';
  
  constructor(private config: OIDCConfig) {
    // Plugin-specific storage configuration
    this.storageKey = `${this.name}_auth_state`;
    this.storageEngine = config.storage || 'localStorage';
  }
  
  async authenticate(credentials: OIDCCredentials): Promise<AuthResult> {
    // Implement OIDC authentication flow
    const tokenResponse = await this.exchangeCredentials(credentials);
    const result = {
      success: true,
      credentials: {
        type: 'bearer',
        value: tokenResponse.access_token,
        expiresAt: new Date(Date.now() + tokenResponse.expires_in * 1000)
      },
      refreshToken: tokenResponse.refresh_token ? {
        value: tokenResponse.refresh_token,
        expiresAt: tokenResponse.refresh_expires_in ? 
          new Date(Date.now() + tokenResponse.refresh_expires_in * 1000) : undefined
      } : undefined
    };
    
    // Plugin manages its own persistence
    await this.saveState({
      isAuthenticated: true,
      credentials: result.credentials,
      refreshToken: result.refreshToken,
      lastAuthTime: new Date(),
      error: null
    });
    
    return result;
  }
  
  async refresh(refreshToken: RefreshToken): Promise<AuthResult> {
    // Implement token refresh
  }
  
  async logout(credentials: Credentials): Promise<void> {
    // Implement logout/revocation and clear storage
    await this.clearState();
  }
  
  // Plugin-specific storage implementation
  async saveState(state: AuthState): Promise<void> {
    const stateToSave = {
      refreshToken: state.refreshToken,
      lastAuthTime: state.lastAuthTime
      // Don't persist active credentials for security
    };
    
    if (this.config.encryptStorage) {
      const encrypted = await this.encrypt(JSON.stringify(stateToSave));
      this.getStorageEngine().setItem(this.storageKey, encrypted);
    } else {
      this.getStorageEngine().setItem(this.storageKey, JSON.stringify(stateToSave));
    }
  }
  
  async loadState(): Promise<AuthState | null> {
    const stored = this.getStorageEngine().getItem(this.storageKey);
    if (!stored) return null;
    
    try {
      const data = this.config.encryptStorage ? 
        JSON.parse(await this.decrypt(stored)) : 
        JSON.parse(stored);
      
      return {
        isAuthenticated: false, // Will be set after successful refresh
        credentials: null, // Never persisted
        refreshToken: data.refreshToken,
        lastAuthTime: data.lastAuthTime ? new Date(data.lastAuthTime) : null,
        error: null
      };
    } catch (error) {
      await this.clearState();
      return null;
    }
  }
  
  async clearState(): Promise<void> {
    this.getStorageEngine().removeItem(this.storageKey);
  }
  
  private getStorageEngine() {
    return this.storageEngine === 'sessionStorage' ? sessionStorage : localStorage;
  }
}

Event System

Reactive Architecture: The event system enables the existing React integration to respond to authentication changes and supports future framework integrations. Events provide loose coupling between authentication logic and UI components.

type AuthEvent = 
  | 'authenticated'
  | 'refreshed'
  | 'logout'
  | 'authenticationRequired'
  | 'refreshFailed'
  | 'error';

interface EventEmitter {
  on(event: AuthEvent, callback: (data?: any) => void): void;
  off(event: AuthEvent, callback: (data?: any) => void): void;
  emit(event: AuthEvent, data?: any): void;
}

// Usage
app.on('authenticated', (credentials) => {
  console.log('User authenticated successfully');
});

app.on('authenticationRequired', () => {
  // Redirect to login page
});

Error Handling

Async/Await Compatibility: Error handling supports both promise rejection and callback patterns as specified, with standardized error codes for consistent error handling across different authentication providers.

class AuthError extends Error {
  constructor(
    message: string,
    public code: string,
    public details?: Record<string, unknown>
  ) {
    super(message);
    this.name = 'AuthError';
  }
}

// Standard error codes
enum AuthErrorCode {
  INVALID_CREDENTIALS = 'INVALID_CREDENTIALS',
  TOKEN_EXPIRED = 'TOKEN_EXPIRED',
  REFRESH_FAILED = 'REFRESH_FAILED',
  NETWORK_ERROR = 'NETWORK_ERROR',
  PROVIDER_ERROR = 'PROVIDER_ERROR'
}

Usage Examples

Developer Experience: These examples demonstrate the app.getCredentials() pattern requested for accessing authentication proof in HTTP requests, showing integration with the fetch API as currently expected.

Basic Authentication Setup

import { Application } from '@yourlib/core';
import { BasicAuthProvider } from '@yourlib/auth-basic';

const basicAuth = new BasicAuthProvider({
  endpoint: 'https://api.example.com/auth'
});

const app = new Application({
  auth: {
    provider: basicAuth,
    refreshThreshold: 5, // Refresh 5 minutes before expiry
    autoRefresh: true
  }
});

// Authenticate
await app.authenticate({
  username: 'user@example.com',
  password: 'password'
});

// Use credentials in requests
const credentials = await app.getCredentials();
if (credentials) {
  const response = await fetch('/api/protected', {
    headers: {
      'Authorization': `${credentials.type} ${credentials.value}`
    }
  });
}

React Integration

Framework Integration Strategy: This example shows how the existing React integration library can consume the authentication events and state, demonstrating the event-driven architecture's benefits for UI frameworks.

// React hook example
function useAuth() {
  const app = useContext(ApplicationContext);
  const [isAuthenticated, setIsAuthenticated] = useState(app.isAuthenticated());
  
  useEffect(() => {
    const handleAuth = () => setIsAuthenticated(true);
    const handleLogout = () => setIsAuthenticated(false);
    
    app.on('authenticated', handleAuth);
    app.on('logout', handleLogout);
    
    return () => {
      app.off('authenticated', handleAuth);
      app.off('logout', handleLogout);
    };
  }, [app]);
  
  return {
    isAuthenticated,
    login: app.authenticate.bind(app),
    logout: app.logout.bind(app),
    getCredentials: app.getCredentials.bind(app)
  };
}

Future Considerations

Multi-Session Architecture: While not required for the initial version, this structure addresses the mentioned possibility of supporting multiple concurrent sessions with user switching capabilities. The design uses the internal map approach suggested during our discussion.

Multi-Session Support Structure

Cross-tab Synchronization Note: Cross-tab synchronization was considered but not included due to potential security risks. The current design prioritizes security over convenience for multi-tab scenarios.

interface UserSession {
  id: string;
  credentials: Credentials;
  refreshToken?: RefreshToken;
  metadata?: Record<string, unknown>;
}

interface SessionManager {
  addSession(session: UserSession): void;
  removeSession(id: string): void;
  switchSession(id: string): void;
  getCurrentSession(): UserSession | null;
  getAllSessions(): UserSession[];
}

This specification provides a solid foundation that can be extended as requirements evolve, while maintaining security best practices and developer experience.

Key Design Decisions Summary

Security-First Approach: Credentials stay in memory while only refresh tokens are persisted, minimizing XSS attack surface while maintaining usability across page refreshes.

Plugin Architecture: Clean provider interface allows for custom authentication schemes while maintaining consistent API surface for the planned OIDC plugin and community extensions.

State Recovery: Automatic refresh token validation on initialization ensures seamless user experience after page refreshes without compromising security.

TypeScript-Native: Full type definitions provide excellent developer experience and compile-time safety as requested.

Event-Driven: Reactive architecture allows both your React integration and other frameworks to respond to authentication state changes cleanly.

Extensible Foundation: The session manager structure is ready for multi-user support when needed, and the configuration system can accommodate additional features without breaking changes.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions