Services: Admin-Compliance, Backend-Compliance, AI-Compliance-SDK, Consent-SDK, Developer-Portal, PCA-Platform, DSMS Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
296 lines
7.5 KiB
TypeScript
296 lines
7.5 KiB
TypeScript
/**
|
|
* Authentication Provider
|
|
*
|
|
* Manages authentication state and token lifecycle
|
|
*/
|
|
|
|
import type { AuthTokenResponse } from '@breakpilot/compliance-sdk-types'
|
|
import { ComplianceClient } from './client'
|
|
|
|
// =============================================================================
|
|
// TYPES
|
|
// =============================================================================
|
|
|
|
export interface AuthProviderOptions {
|
|
client: ComplianceClient
|
|
clientId: string
|
|
clientSecret?: string
|
|
storage?: Storage
|
|
onAuthStateChange?: (state: AuthState) => void
|
|
}
|
|
|
|
export interface AuthState {
|
|
isAuthenticated: boolean
|
|
accessToken: string | null
|
|
refreshToken: string | null
|
|
expiresAt: Date | null
|
|
user?: {
|
|
id: string
|
|
email: string
|
|
name: string
|
|
role: string
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// CONSTANTS
|
|
// =============================================================================
|
|
|
|
const STORAGE_KEY = 'breakpilot-sdk-auth'
|
|
const TOKEN_REFRESH_BUFFER = 5 * 60 * 1000 // 5 minutes before expiry
|
|
|
|
// =============================================================================
|
|
// AUTH PROVIDER
|
|
// =============================================================================
|
|
|
|
export class AuthProvider {
|
|
private client: ComplianceClient
|
|
private clientId: string
|
|
private clientSecret?: string
|
|
private storage: Storage | null
|
|
private state: AuthState
|
|
private refreshTimeout: ReturnType<typeof setTimeout> | null = null
|
|
private onAuthStateChange?: (state: AuthState) => void
|
|
|
|
constructor(options: AuthProviderOptions) {
|
|
this.client = options.client
|
|
this.clientId = options.clientId
|
|
this.clientSecret = options.clientSecret
|
|
this.storage = options.storage ?? (typeof localStorage !== 'undefined' ? localStorage : null)
|
|
this.onAuthStateChange = options.onAuthStateChange
|
|
|
|
this.state = {
|
|
isAuthenticated: false,
|
|
accessToken: null,
|
|
refreshToken: null,
|
|
expiresAt: null,
|
|
}
|
|
|
|
this.loadFromStorage()
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Private Methods
|
|
// ---------------------------------------------------------------------------
|
|
|
|
private loadFromStorage(): void {
|
|
if (!this.storage) return
|
|
|
|
try {
|
|
const stored = this.storage.getItem(STORAGE_KEY)
|
|
if (stored) {
|
|
const data = JSON.parse(stored)
|
|
if (data.expiresAt) {
|
|
data.expiresAt = new Date(data.expiresAt)
|
|
}
|
|
|
|
// Check if token is still valid
|
|
if (data.expiresAt && data.expiresAt > new Date()) {
|
|
this.state = data
|
|
this.client.setAccessToken(data.accessToken)
|
|
this.scheduleTokenRefresh()
|
|
this.notifyStateChange()
|
|
} else if (data.refreshToken) {
|
|
// Try to refresh
|
|
this.refreshToken().catch(() => {
|
|
this.clearAuth()
|
|
})
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load auth from storage:', error)
|
|
}
|
|
}
|
|
|
|
private saveToStorage(): void {
|
|
if (!this.storage) return
|
|
|
|
try {
|
|
this.storage.setItem(STORAGE_KEY, JSON.stringify(this.state))
|
|
} catch (error) {
|
|
console.error('Failed to save auth to storage:', error)
|
|
}
|
|
}
|
|
|
|
private clearStorage(): void {
|
|
if (!this.storage) return
|
|
|
|
try {
|
|
this.storage.removeItem(STORAGE_KEY)
|
|
} catch (error) {
|
|
console.error('Failed to clear auth from storage:', error)
|
|
}
|
|
}
|
|
|
|
private updateState(tokenResponse: AuthTokenResponse): void {
|
|
const expiresAt = new Date(Date.now() + tokenResponse.expiresIn * 1000)
|
|
|
|
this.state = {
|
|
isAuthenticated: true,
|
|
accessToken: tokenResponse.accessToken,
|
|
refreshToken: tokenResponse.refreshToken ?? this.state.refreshToken,
|
|
expiresAt,
|
|
}
|
|
|
|
this.client.setAccessToken(tokenResponse.accessToken)
|
|
this.saveToStorage()
|
|
this.scheduleTokenRefresh()
|
|
this.notifyStateChange()
|
|
}
|
|
|
|
private scheduleTokenRefresh(): void {
|
|
if (this.refreshTimeout) {
|
|
clearTimeout(this.refreshTimeout)
|
|
}
|
|
|
|
if (!this.state.expiresAt || !this.state.refreshToken) return
|
|
|
|
const timeUntilRefresh = this.state.expiresAt.getTime() - Date.now() - TOKEN_REFRESH_BUFFER
|
|
|
|
if (timeUntilRefresh > 0) {
|
|
this.refreshTimeout = setTimeout(() => {
|
|
this.refreshToken().catch(() => {
|
|
this.clearAuth()
|
|
})
|
|
}, timeUntilRefresh)
|
|
}
|
|
}
|
|
|
|
private notifyStateChange(): void {
|
|
this.onAuthStateChange?.(this.state)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Public Methods
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Authenticate using client credentials (server-to-server)
|
|
*/
|
|
async authenticateWithCredentials(): Promise<void> {
|
|
if (!this.clientSecret) {
|
|
throw new Error('Client secret is required for credentials authentication')
|
|
}
|
|
|
|
const response = await this.client.authenticate({
|
|
grantType: 'client_credentials',
|
|
clientId: this.clientId,
|
|
clientSecret: this.clientSecret,
|
|
})
|
|
|
|
this.updateState(response)
|
|
}
|
|
|
|
/**
|
|
* Authenticate using authorization code (OAuth flow)
|
|
*/
|
|
async authenticateWithCode(code: string, redirectUri: string): Promise<void> {
|
|
const response = await this.client.authenticate({
|
|
grantType: 'authorization_code',
|
|
clientId: this.clientId,
|
|
clientSecret: this.clientSecret,
|
|
code,
|
|
redirectUri,
|
|
})
|
|
|
|
this.updateState(response)
|
|
}
|
|
|
|
/**
|
|
* Refresh the access token
|
|
*/
|
|
async refreshToken(): Promise<void> {
|
|
if (!this.state.refreshToken) {
|
|
throw new Error('No refresh token available')
|
|
}
|
|
|
|
const response = await this.client.refreshToken(this.state.refreshToken)
|
|
this.updateState(response)
|
|
}
|
|
|
|
/**
|
|
* Set access token manually (e.g., from API key)
|
|
*/
|
|
setAccessToken(token: string, expiresIn?: number): void {
|
|
const expiresAt = expiresIn ? new Date(Date.now() + expiresIn * 1000) : null
|
|
|
|
this.state = {
|
|
isAuthenticated: true,
|
|
accessToken: token,
|
|
refreshToken: null,
|
|
expiresAt,
|
|
}
|
|
|
|
this.client.setAccessToken(token)
|
|
this.saveToStorage()
|
|
this.notifyStateChange()
|
|
}
|
|
|
|
/**
|
|
* Clear authentication state
|
|
*/
|
|
clearAuth(): void {
|
|
if (this.refreshTimeout) {
|
|
clearTimeout(this.refreshTimeout)
|
|
}
|
|
|
|
this.state = {
|
|
isAuthenticated: false,
|
|
accessToken: null,
|
|
refreshToken: null,
|
|
expiresAt: null,
|
|
}
|
|
|
|
this.client.clearAccessToken()
|
|
this.clearStorage()
|
|
this.notifyStateChange()
|
|
}
|
|
|
|
/**
|
|
* Get current authentication state
|
|
*/
|
|
getState(): AuthState {
|
|
return { ...this.state }
|
|
}
|
|
|
|
/**
|
|
* Check if currently authenticated
|
|
*/
|
|
isAuthenticated(): boolean {
|
|
if (!this.state.isAuthenticated || !this.state.accessToken) {
|
|
return false
|
|
}
|
|
|
|
if (this.state.expiresAt && this.state.expiresAt <= new Date()) {
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* Get the authorization URL for OAuth flow
|
|
*/
|
|
getAuthorizationUrl(redirectUri: string, scope?: string, state?: string): string {
|
|
const params = new URLSearchParams({
|
|
client_id: this.clientId,
|
|
redirect_uri: redirectUri,
|
|
response_type: 'code',
|
|
...(scope && { scope }),
|
|
...(state && { state }),
|
|
})
|
|
|
|
// This would be configured based on the API endpoint
|
|
return `/oauth/authorize?${params.toString()}`
|
|
}
|
|
|
|
/**
|
|
* Destroy the auth provider
|
|
*/
|
|
destroy(): void {
|
|
if (this.refreshTimeout) {
|
|
clearTimeout(this.refreshTimeout)
|
|
}
|
|
}
|
|
}
|