@omx-sdk/core
Version:
Core module for OMX SDK with authentication and shared utilities
309 lines • 11.7 kB
JavaScript
import { AuthenticationError, ConfigurationError, InvalidCredentialsError, NetworkError, RateLimitError, TokenExpiredError, } from './errors.js';
/**
* Core authentication manager for OMX SDK
* Handles JWT token fetching, caching, and automatic refresh with Supabase Edge Function
*/
export class CoreAuth {
constructor(config) {
this.supabaseFnUrl = 'https://blhilidnsybhfdmwqsrx.supabase.co/functions/v1/create-jwt-token';
this.cachedToken = null;
this.refreshPromise = null;
this.validateConfig(config);
this.config = {
tokenCacheTtl: 55 * 60 * 1000, // 55 minutes default
maxRetries: 3,
retryDelay: 1000,
...config,
};
}
/**
* Validate the authentication configuration
*/
validateConfig(config) {
if (!config.clientId || typeof config.clientId !== 'string') {
throw new ConfigurationError('clientId is required and must be a string');
}
if (!config.secretKey || typeof config.secretKey !== 'string') {
throw new ConfigurationError('secretKey is required and must be a string');
}
}
/**
* Get a valid JWT token, fetching or refreshing as needed
*/
async getToken(forceRefresh = false) {
// If we have a refresh in progress, wait for it
if (this.refreshPromise) {
const token = await this.refreshPromise;
return token.access_token;
}
// Check if we have a valid cached token
if (!forceRefresh && this.isTokenValid()) {
return this.cachedToken.token.access_token;
}
// Fetch a new token
try {
this.refreshPromise = this.fetchNewToken();
const token = await this.refreshPromise;
this.refreshPromise = null;
return token.access_token;
}
catch (error) {
this.refreshPromise = null;
throw error;
}
}
/**
* Check if the current cached token is valid
*/
isTokenValid() {
if (!this.cachedToken) {
return false;
}
const now = Date.now();
const bufferTime = 60 * 1000; // 1 minute buffer before expiration
return now < this.cachedToken.expiresAt - bufferTime;
}
/**
* Fetch a new JWT token from Supabase Edge Function
*/
async fetchNewToken() {
try {
const response = await fetch(this.supabaseFnUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
clientId: this.config.clientId,
secretKey: this.config.secretKey,
}),
});
if (!response.ok) {
throw new AuthenticationError(`HTTP_${response.status}`, `Authentication failed: ${response.statusText}`, response.status);
}
const data = (await response.json());
if (data.error) {
this.handleSupabaseError(data);
}
// Handle both 'token' and 'access_token' field names
const accessToken = data.token || data.access_token;
if (!accessToken) {
throw new AuthenticationError('NO_TOKEN_RESPONSE', 'No token received from authentication server');
}
const token = this.createJWTToken({
access_token: accessToken,
token_type: data.token_type || 'Bearer',
expires_in: data.expires_in || 3600, // Default to 1 hour
});
this.cacheToken(token);
return token;
}
catch (error) {
if (error instanceof AuthenticationError) {
throw error;
}
// Handle network or other errors
throw new NetworkError(`Failed to fetch JWT token: ${error instanceof Error ? error.message : 'Unknown error'}`, error);
}
}
/**
* Create a JWTToken object from response
*/
createJWTToken(response) {
const now = Date.now();
const expiresAt = now + response.expires_in * 1000;
return {
access_token: response.access_token,
token_type: response.token_type,
expires_in: response.expires_in,
expires_at: expiresAt,
};
}
/**
* Cache the JWT token with expiration info
*/
cacheToken(token) {
const now = Date.now();
const cacheTtl = Math.min(this.config.tokenCacheTtl, token.expires_in * 1000);
this.cachedToken = {
token,
cachedAt: now,
expiresAt: now + cacheTtl,
};
}
/**
* Handle Supabase Edge Function errors
*/
handleSupabaseError(error) {
const errorMessage = error.error || 'Authentication failed';
// Map common error patterns to our error types
if (errorMessage.toLowerCase().includes('invalid') &&
errorMessage.toLowerCase().includes('credential')) {
throw new InvalidCredentialsError(errorMessage);
}
if (errorMessage.toLowerCase().includes('not found')) {
throw new AuthenticationError('FUNCTION_NOT_FOUND', 'Authentication function not found. Please ensure the Edge Function is deployed.', 404);
}
throw new AuthenticationError('EDGE_FUNCTION_ERROR', errorMessage, undefined, error);
}
/**
* Make an authenticated API request with automatic token refresh
*/
async makeAuthenticatedRequest(url, options = {}) {
const { method = 'GET', headers = {}, body, timeout = 30000, retries = this.config.maxRetries, } = options;
let lastError = null;
for (let attempt = 0; attempt <= retries; attempt++) {
try {
// Get fresh token for each attempt
const token = await this.getToken(attempt > 0);
// Prepare request headers
const requestHeaders = {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
...headers,
};
// Prepare request options
const requestOptions = {
method,
headers: requestHeaders,
signal: AbortSignal.timeout(timeout),
};
// Add body for non-GET requests
if (body && method !== 'GET') {
requestOptions.body =
typeof body === 'string' ? body : JSON.stringify(body);
}
// Make the request
const response = await fetch(url, requestOptions);
// Handle response
return await this.handleResponse(response);
}
catch (error) {
lastError = error;
// Don't retry on certain errors
if (error instanceof InvalidCredentialsError ||
error instanceof ConfigurationError ||
(error instanceof AuthenticationError &&
error.code === 'RPC_NOT_FOUND')) {
break;
}
// Handle rate limiting with exponential backoff
if (error instanceof RateLimitError && attempt < retries) {
const retryAfter = (error.details?.retryAfter || this.config.retryDelay) *
Math.pow(2, attempt);
await this.sleep(retryAfter);
continue;
}
// Handle token expiration
if (error instanceof TokenExpiredError && attempt < retries) {
this.cachedToken = null; // Clear cached token
await this.sleep(this.config.retryDelay * Math.pow(2, attempt));
continue;
}
// Handle network errors with exponential backoff
if (error instanceof NetworkError && attempt < retries) {
await this.sleep(this.config.retryDelay * Math.pow(2, attempt));
continue;
}
// If it's the last attempt or non-retryable error, break
if (attempt === retries) {
break;
}
}
}
// All retries failed, throw the last error
throw lastError || new NetworkError('All retry attempts failed');
}
/**
* Handle HTTP response and convert to ApiResponse
*/
async handleResponse(response) {
const { status, headers } = response;
try {
// Handle different status codes
if (status === 401) {
// Clear cached token on 401
this.cachedToken = null;
throw new TokenExpiredError('Authentication token expired or invalid');
}
if (status === 429) {
const retryAfter = parseInt(response.headers.get('Retry-After') || '60', 10) * 1000;
throw new RateLimitError('Rate limit exceeded', retryAfter);
}
if (status >= 400) {
const errorText = await response.text();
let errorData;
try {
errorData = JSON.parse(errorText);
}
catch {
errorData = { message: errorText };
}
throw new AuthenticationError(`HTTP_${status}`, errorData.message || `HTTP ${status} error`, status, errorData);
}
// Parse successful response
const contentType = response.headers.get('content-type');
let data;
if (contentType && contentType.includes('application/json')) {
data = await response.json();
}
else {
data = (await response.text());
}
return {
success: true,
data,
status,
headers,
};
}
catch (error) {
if (error instanceof AuthenticationError) {
return {
success: false,
error: error.toAuthError(),
status,
headers,
};
}
throw new NetworkError(`Failed to process response: ${error instanceof Error ? error.message : 'Unknown error'}`, error);
}
}
/**
* Utility method to sleep for a given number of milliseconds
*/
sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Clear cached token (useful for logout or token invalidation)
*/
clearToken() {
this.cachedToken = null;
}
/**
* Get current token info (without the actual token for security)
*/
getTokenInfo() {
return {
isValid: this.isTokenValid(),
expiresAt: this.cachedToken?.expiresAt || null,
cachedAt: this.cachedToken?.cachedAt || null,
};
}
/**
* Update configuration (will clear cached token)
*/
updateConfig(updates) {
this.config = { ...this.config, ...updates };
this.clearToken();
}
/**
* Dispose of resources and clear cache
*/
dispose() {
this.clearToken();
this.refreshPromise = null;
}
}
//# sourceMappingURL=core.js.map