nextjs-django-client
Version:
A comprehensive, type-safe SDK for seamlessly integrating Next.js 15+ applications with Django REST Framework backends
1,449 lines (1,442 loc) • 185 kB
JavaScript
'use strict';
var jsxRuntime = require('react/jsx-runtime');
var React = require('react');
var Cookies = require('js-cookie');
const DEFAULT_ACCESS_TOKEN_KEY = 'access_token';
const DEFAULT_REFRESH_TOKEN_KEY = 'refresh_token';
const DEFAULT_CSRF_TOKEN_KEY = 'csrf_token';
/**
* Token storage utility that handles both localStorage and secure cookies
*/
class TokenStorage {
useSecureStorage;
options;
accessTokenKey;
refreshTokenKey;
csrfTokenKey;
constructor(useSecureStorage = true, options = {}) {
this.useSecureStorage = useSecureStorage;
this.options = {
secure: true,
sameSite: 'strict',
path: '/',
maxAge: 86400, // 24 hours default
prefix: '',
domain: '',
httpOnly: false,
...options,
};
// Set up token keys with optional prefix
const prefix = this.options.prefix;
this.accessTokenKey = prefix ? `${prefix}_${DEFAULT_ACCESS_TOKEN_KEY}` : DEFAULT_ACCESS_TOKEN_KEY;
this.refreshTokenKey = prefix ? `${prefix}_${DEFAULT_REFRESH_TOKEN_KEY}` : DEFAULT_REFRESH_TOKEN_KEY;
this.csrfTokenKey = prefix ? `${prefix}_${DEFAULT_CSRF_TOKEN_KEY}` : DEFAULT_CSRF_TOKEN_KEY;
}
/**
* Store authentication tokens
*/
setTokens(tokens) {
if (this.useSecureStorage && typeof window !== 'undefined') {
// Use secure cookies for production
const cookieOptions = {
secure: this.options.secure,
sameSite: this.options.sameSite,
domain: this.options.domain,
path: this.options.path,
};
Cookies.set(this.accessTokenKey, tokens.access, {
...cookieOptions,
maxAge: this.options.maxAge,
});
Cookies.set(this.refreshTokenKey, tokens.refresh, {
...cookieOptions,
maxAge: this.options.maxAge * 7, // Refresh token lasts 7x longer
});
}
else {
// Fallback to localStorage for development
if (typeof window !== 'undefined') {
localStorage.setItem(this.accessTokenKey, tokens.access);
localStorage.setItem(this.refreshTokenKey, tokens.refresh);
}
}
}
/**
* Get stored authentication tokens
*/
getTokens() {
let accessToken;
let refreshToken;
if (this.useSecureStorage && typeof window !== 'undefined') {
accessToken = Cookies.get(this.accessTokenKey);
refreshToken = Cookies.get(this.refreshTokenKey);
}
else {
if (typeof window !== 'undefined') {
accessToken = localStorage.getItem(this.accessTokenKey) ?? undefined;
refreshToken = localStorage.getItem(this.refreshTokenKey) ?? undefined;
}
}
if (accessToken && refreshToken) {
return {
access: accessToken,
refresh: refreshToken,
};
}
return null;
}
/**
* Get only the access token
*/
getAccessToken() {
if (this.useSecureStorage && typeof window !== 'undefined') {
return Cookies.get(this.accessTokenKey) ?? null;
}
else {
if (typeof window !== 'undefined') {
return localStorage.getItem(this.accessTokenKey);
}
}
return null;
}
/**
* Get only the refresh token
*/
getRefreshToken() {
if (this.useSecureStorage && typeof window !== 'undefined') {
return Cookies.get(this.refreshTokenKey) ?? null;
}
else {
if (typeof window !== 'undefined') {
return localStorage.getItem(this.refreshTokenKey);
}
}
return null;
}
/**
* Update only the access token (used during refresh)
*/
setAccessToken(token) {
if (this.useSecureStorage && typeof window !== 'undefined') {
Cookies.set(this.accessTokenKey, token, {
secure: this.options.secure,
sameSite: this.options.sameSite,
domain: this.options.domain,
path: this.options.path,
maxAge: this.options.maxAge,
});
}
else {
if (typeof window !== 'undefined') {
localStorage.setItem(this.accessTokenKey, token);
}
}
}
/**
* Clear all stored tokens
*/
clearTokens() {
if (this.useSecureStorage && typeof window !== 'undefined') {
const removeOptions = {
domain: this.options.domain,
path: this.options.path,
};
Cookies.remove(this.accessTokenKey, removeOptions);
Cookies.remove(this.refreshTokenKey, removeOptions);
Cookies.remove(this.csrfTokenKey, removeOptions);
}
else {
if (typeof window !== 'undefined') {
localStorage.removeItem(this.accessTokenKey);
localStorage.removeItem(this.refreshTokenKey);
localStorage.removeItem(this.csrfTokenKey);
}
}
}
/**
* Set CSRF token
*/
setCsrfToken(token) {
if (this.useSecureStorage && typeof window !== 'undefined') {
Cookies.set(this.csrfTokenKey, token, {
secure: this.options.secure,
sameSite: this.options.sameSite,
domain: this.options.domain,
path: this.options.path,
maxAge: this.options.maxAge,
});
}
else {
if (typeof window !== 'undefined') {
localStorage.setItem(this.csrfTokenKey, token);
}
}
}
/**
* Get CSRF token
*/
getCsrfToken() {
if (this.useSecureStorage && typeof window !== 'undefined') {
return Cookies.get(this.csrfTokenKey) ?? null;
}
else {
if (typeof window !== 'undefined') {
return localStorage.getItem(this.csrfTokenKey);
}
}
return null;
}
/**
* Check if tokens exist
*/
hasTokens() {
return this.getTokens() !== null;
}
}
/**
* Create secure token storage configuration for production
*/
function createSecureStorageConfig(options = {}) {
return {
secure: true,
sameSite: 'strict',
path: '/',
maxAge: 900, // 15 minutes for access token
httpOnly: false, // Can't be true for client-side access
...options,
};
}
/**
* Create development token storage configuration
*/
function createDevelopmentStorageConfig(options = {}) {
return {
secure: false,
sameSite: 'lax',
path: '/',
maxAge: 3600, // 1 hour for development
httpOnly: false,
...options,
};
}
/**
* Create token storage with environment-specific defaults
*/
function createTokenStorage(useSecureStorage, options) {
const isProduction = typeof process !== 'undefined' && process.env.NODE_ENV === 'production';
const shouldUseSecure = useSecureStorage ?? isProduction;
const defaultOptions = shouldUseSecure
? createSecureStorageConfig(options)
: createDevelopmentStorageConfig(options);
return new TokenStorage(shouldUseSecure, defaultOptions);
}
// Default instance
createTokenStorage();
/**
* Utility class for managing JWT tokens and their expiration
*/
class TokenManager {
config;
refreshTimer = null;
refreshPromise = null;
constructor(config) {
this.config = config;
}
/**
* Parse JWT token to extract expiration information
*/
parseToken(token) {
try {
const parts = token.split('.');
if (parts.length !== 3) {
return null;
}
const payload = JSON.parse(atob(parts[1]));
const now = Math.floor(Date.now() / 1000);
return {
token,
expiresAt: payload.exp || 0,
issuedAt: payload.iat || now,
};
}
catch (error) {
console.warn('Failed to parse JWT token:', error);
return null;
}
}
/**
* Check if token needs refresh based on expiration and threshold
*/
shouldRefreshToken(tokenInfo) {
const now = Math.floor(Date.now() / 1000);
const timeUntilExpiry = tokenInfo.expiresAt - now;
return timeUntilExpiry <= this.config.refreshThreshold;
}
/**
* Check if token is expired
*/
isTokenExpired(tokenInfo) {
const now = Math.floor(Date.now() / 1000);
return now >= tokenInfo.expiresAt;
}
/**
* Schedule automatic token refresh
*/
scheduleRefresh(accessToken, refreshCallback) {
this.clearRefreshTimer();
const tokenInfo = this.parseToken(accessToken);
if (!tokenInfo) {
console.warn('Cannot schedule refresh for invalid token');
return;
}
const now = Math.floor(Date.now() / 1000);
const timeUntilRefresh = Math.max(0, tokenInfo.expiresAt - now - this.config.refreshThreshold);
this.refreshTimer = setTimeout(async () => {
try {
await this.executeRefresh(refreshCallback);
}
catch (error) {
console.error('Scheduled token refresh failed:', error);
}
}, timeUntilRefresh * 1000);
}
/**
* Execute token refresh with retry logic
*/
async executeRefresh(refreshCallback) {
// If refresh is already in progress, return the existing promise
if (this.refreshPromise) {
return this.refreshPromise;
}
this.refreshPromise = this.performRefreshWithRetry(refreshCallback);
try {
const newToken = await this.refreshPromise;
return newToken;
}
finally {
this.refreshPromise = null;
}
}
/**
* Perform refresh with retry logic
*/
async performRefreshWithRetry(refreshCallback) {
let lastError = null;
for (let attempt = 1; attempt <= this.config.maxRetries; attempt++) {
try {
const newToken = await refreshCallback();
// Schedule next refresh for the new token
this.scheduleRefresh(newToken, refreshCallback);
return newToken;
}
catch (error) {
lastError = error;
console.warn(`Token refresh attempt ${attempt} failed:`, error);
if (attempt < this.config.maxRetries) {
await this.delay(this.config.retryDelay * attempt);
}
}
}
throw lastError || new Error('Token refresh failed after all retries');
}
/**
* Clear the refresh timer
*/
clearRefreshTimer() {
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
this.refreshTimer = null;
}
}
/**
* Get time until token expires (in seconds)
*/
getTimeUntilExpiry(token) {
const tokenInfo = this.parseToken(token);
if (!tokenInfo) {
return 0;
}
const now = Math.floor(Date.now() / 1000);
return Math.max(0, tokenInfo.expiresAt - now);
}
/**
* Utility method to delay execution
*/
delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Cleanup resources
*/
destroy() {
this.clearRefreshTimer();
this.refreshPromise = null;
}
}
/**
* Predefined endpoint configurations for popular auth providers
*/
const AUTH_PROVIDER_CONFIGS = {
'django-rest-framework': {
endpoints: {
login: '/auth/login/',
refresh: '/auth/refresh/',
logout: '/auth/logout/',
user: '/auth/user/',
register: '/auth/register/',
passwordReset: '/auth/password/reset/',
passwordResetConfirm: '/auth/password/reset/confirm/',
emailVerification: '/auth/email/verify/',
changePassword: '/auth/password/change/',
},
fieldMapping: {
username: 'username',
password: 'password',
email: 'email',
firstName: 'first_name',
lastName: 'last_name',
confirmPassword: 'password2',
refreshToken: 'refresh',
},
responseMapping: {
user: 'user',
accessToken: 'access',
refreshToken: 'refresh',
permissions: 'permissions',
roles: 'groups',
},
},
'django-allauth': {
endpoints: {
login: '/auth/login/',
refresh: '/auth/token/refresh/',
logout: '/auth/logout/',
user: '/auth/user/',
register: '/auth/registration/',
passwordReset: '/auth/password/reset/',
passwordResetConfirm: '/auth/password/reset/confirm/',
emailVerification: '/auth/registration/verify-email/',
changePassword: '/auth/password/change/',
},
fieldMapping: {
username: 'username',
password: 'password1',
email: 'email',
firstName: 'first_name',
lastName: 'last_name',
confirmPassword: 'password2',
refreshToken: 'refresh_token',
},
responseMapping: {
user: 'user',
accessToken: 'access_token',
refreshToken: 'refresh_token',
permissions: 'user.user_permissions',
roles: 'user.groups',
},
},
'firebase': {
endpoints: {
login: '/auth/signin',
refresh: '/auth/refresh',
logout: '/auth/signout',
user: '/auth/user',
register: '/auth/signup',
passwordReset: '/auth/password-reset',
passwordResetConfirm: '/auth/password-reset/confirm',
emailVerification: '/auth/email-verification',
changePassword: '/auth/change-password',
},
fieldMapping: {
username: 'email',
password: 'password',
email: 'email',
firstName: 'displayName',
lastName: 'displayName',
confirmPassword: 'confirmPassword',
refreshToken: 'refreshToken',
},
responseMapping: {
user: 'user',
accessToken: 'idToken',
refreshToken: 'refreshToken',
permissions: 'customClaims.permissions',
roles: 'customClaims.roles',
},
},
'auth0': {
endpoints: {
login: '/oauth/token',
refresh: '/oauth/token',
logout: '/v2/logout',
user: '/userinfo',
register: '/dbconnections/signup',
passwordReset: '/dbconnections/change_password',
passwordResetConfirm: '/dbconnections/change_password',
emailVerification: '/api/v2/jobs/verification-email',
changePassword: '/dbconnections/change_password',
},
fieldMapping: {
username: 'username',
password: 'password',
email: 'email',
firstName: 'given_name',
lastName: 'family_name',
confirmPassword: 'password_confirmation',
refreshToken: 'refresh_token',
},
responseMapping: {
user: 'user',
accessToken: 'access_token',
refreshToken: 'refresh_token',
permissions: 'permissions',
roles: 'roles',
},
},
'supabase': {
endpoints: {
login: '/auth/v1/token?grant_type=password',
refresh: '/auth/v1/token?grant_type=refresh_token',
logout: '/auth/v1/logout',
user: '/auth/v1/user',
register: '/auth/v1/signup',
passwordReset: '/auth/v1/recover',
passwordResetConfirm: '/auth/v1/recover',
emailVerification: '/auth/v1/verify',
changePassword: '/auth/v1/user',
},
fieldMapping: {
username: 'email',
password: 'password',
email: 'email',
firstName: 'user_metadata.first_name',
lastName: 'user_metadata.last_name',
confirmPassword: 'password_confirm',
refreshToken: 'refresh_token',
},
responseMapping: {
user: 'user',
accessToken: 'access_token',
refreshToken: 'refresh_token',
permissions: 'user.app_metadata.permissions',
roles: 'user.app_metadata.roles',
},
},
'custom': {
endpoints: {
login: '/login',
refresh: '/refresh',
logout: '/logout',
user: '/user',
register: '/register',
passwordReset: '/password-reset',
passwordResetConfirm: '/password-reset/confirm',
emailVerification: '/email-verification',
changePassword: '/change-password',
},
fieldMapping: {
username: 'username',
password: 'password',
email: 'email',
firstName: 'firstName',
lastName: 'lastName',
confirmPassword: 'confirmPassword',
refreshToken: 'refreshToken',
},
responseMapping: {
user: 'user',
accessToken: 'accessToken',
refreshToken: 'refreshToken',
permissions: 'permissions',
roles: 'roles',
},
},
};
/**
* Endpoint configuration manager
*/
class EndpointManager {
provider;
baseURL;
endpoints;
fieldMapping;
responseMapping;
constructor(provider = 'django-rest-framework', baseURL = '', customEndpoints, customFieldMapping, customResponseMapping) {
this.provider = provider;
this.baseURL = baseURL.replace(/\/$/, '');
const config = AUTH_PROVIDER_CONFIGS[provider];
this.endpoints = { ...config.endpoints, ...customEndpoints };
this.fieldMapping = { ...config.fieldMapping, ...customFieldMapping };
this.responseMapping = { ...config.responseMapping, ...customResponseMapping };
}
/**
* Get full URL for an endpoint
*/
getEndpointURL(endpoint) {
const path = this.endpoints[endpoint];
if (!path) {
throw new Error(`Endpoint '${endpoint}' not configured for provider '${this.provider}'`);
}
// If path is already a full URL, return as-is
if (path.startsWith('http')) {
return path;
}
return `${this.baseURL}${path}`;
}
/**
* Get field name for a logical field
*/
getFieldName(field) {
const fieldName = this.fieldMapping[field];
if (!fieldName) {
throw new Error(`Field '${field}' not configured for provider '${this.provider}'`);
}
return fieldName;
}
/**
* Get response field path for a logical field
*/
getResponseField(field) {
const fieldPath = this.responseMapping[field];
if (!fieldPath || typeof fieldPath !== 'string') {
throw new Error(`Response field '${field}' not configured for provider '${this.provider}'. Available fields: ${Object.keys(this.responseMapping).join(', ')}`);
}
return fieldPath;
}
/**
* Extract value from response using configured mapping
*/
extractFromResponse(response, field) {
try {
// Validate field parameter
if (!field || typeof field !== 'string') {
console.warn('extractFromResponse: invalid field parameter:', field);
return undefined;
}
const fieldPath = this.getResponseField(field);
// Validate fieldPath
if (!fieldPath || typeof fieldPath !== 'string') {
console.warn(`extractFromResponse: invalid fieldPath for field '${field}':`, fieldPath);
return undefined;
}
// Handle different response structures
let responseData = response;
// Validate response
if (!response) {
console.warn('extractFromResponse: response is null or undefined');
return undefined;
}
// If response has a 'data' property, use that (common API pattern)
if (response && typeof response === 'object' && 'data' in response) {
responseData = response.data;
}
// Validate responseData before extraction
if (!responseData) {
console.warn('extractFromResponse: responseData is null or undefined after processing');
return undefined;
}
const value = this.getNestedValue(responseData, fieldPath);
// Debug logging for token extraction issues
if ((field === 'accessToken' || field === 'refreshToken') && !value) {
console.warn(`Failed to extract ${field} from response:`, {
field,
fieldPath,
responseData: typeof responseData === 'object' ? JSON.stringify(responseData, null, 2) : responseData,
originalResponse: typeof response === 'object' ? JSON.stringify(response, null, 2) : response
});
}
return value;
}
catch (error) {
console.error(`Error extracting ${field} from response:`, error, {
field,
response: typeof response === 'object' ? JSON.stringify(response, null, 2) : response
});
return undefined;
}
}
/**
* Map request data using field mapping
*/
mapRequestData(data) {
const mapped = {};
for (const [logicalField, value] of Object.entries(data)) {
if (logicalField in this.fieldMapping) {
const actualField = this.getFieldName(logicalField);
mapped[actualField] = value;
}
else {
// Pass through unmapped fields
mapped[logicalField] = value;
}
}
return mapped;
}
/**
* Get all configured endpoints
*/
getAllEndpoints() {
return { ...this.endpoints };
}
/**
* Update endpoint configuration
*/
updateEndpoints(updates) {
this.endpoints = { ...this.endpoints, ...updates };
}
/**
* Update field mapping
*/
updateFieldMapping(updates) {
this.fieldMapping = { ...this.fieldMapping, ...updates };
}
/**
* Update response mapping
*/
updateResponseMapping(updates) {
this.responseMapping = { ...this.responseMapping, ...updates };
}
/**
* Get provider information
*/
getProvider() {
return this.provider;
}
/**
* Helper to get nested value from object using dot notation
*/
getNestedValue(obj, path) {
// Handle null/undefined object
if (!obj) {
return undefined;
}
// Handle non-string path
if (typeof path !== 'string') {
console.warn('getNestedValue: path must be a string, received:', typeof path, path);
return undefined;
}
// Handle empty path
if (!path) {
return obj;
}
try {
// Ensure path is a string before calling split
const pathString = String(path);
return pathString.split('.').reduce((current, key) => {
// Handle null/undefined current value
if (current === null || current === undefined) {
return undefined;
}
// Handle array access with numeric keys
if (Array.isArray(current) && /^\d+$/.test(key)) {
const index = parseInt(key, 10);
return index < current.length ? current[index] : undefined;
}
// Handle object property access
if (typeof current === 'object' && key in current) {
return current[key];
}
return undefined;
}, obj);
}
catch (error) {
console.error('Error in getNestedValue:', error, 'path:', path, 'obj:', obj);
return undefined;
}
}
}
/**
* Create auth configuration for Django REST Framework
*/
function createDjangoRestFrameworkConfig(baseURL, overrides) {
return {
provider: 'django-rest-framework',
baseAuthURL: baseURL,
useEmailAsUsername: false,
...overrides,
};
}
/**
* Create auth configuration for Django Allauth
*/
function createDjangoAllauthConfig(baseURL, overrides) {
return {
provider: 'django-allauth',
baseAuthURL: baseURL,
useEmailAsUsername: false,
...overrides,
};
}
/**
* Create auth configuration for Firebase
*/
function createFirebaseConfig(projectId, overrides) {
return {
provider: 'firebase',
baseAuthURL: `https://identitytoolkit.googleapis.com/v1/accounts`,
useEmailAsUsername: true,
endpoints: {
login: `:signInWithPassword?key=${projectId}`,
refresh: `:token?key=${projectId}`,
logout: '', // Firebase doesn't have a logout endpoint
user: `:lookup?key=${projectId}`,
register: `:signUp?key=${projectId}`,
passwordReset: `:sendOobCode?key=${projectId}`,
passwordResetConfirm: `:resetPassword?key=${projectId}`,
emailVerification: `:sendOobCode?key=${projectId}`,
changePassword: `:update?key=${projectId}`,
},
...overrides,
};
}
/**
* Create auth configuration for Auth0
*/
function createAuth0Config(domain, clientId, overrides) {
return {
provider: 'auth0',
baseAuthURL: `https://${domain}`,
useEmailAsUsername: true,
fieldMapping: {
username: 'username',
password: 'password',
email: 'email',
},
responseMapping: {
user: 'user',
accessToken: 'access_token',
refreshToken: 'refresh_token',
},
// Auth0 specific configuration
...overrides,
auth0: {
domain,
clientId,
...(overrides?.auth0?.audience && { audience: overrides.auth0.audience }),
scope: overrides?.auth0?.scope || 'openid profile email',
...overrides?.auth0,
},
};
}
/**
* Create auth configuration for Supabase
*/
function createSupabaseConfig(projectUrl, anonKey, overrides) {
return {
provider: 'supabase',
baseAuthURL: projectUrl,
useEmailAsUsername: true,
...overrides,
supabase: {
projectUrl,
anonKey,
...overrides?.supabase,
},
};
}
/**
* Create custom auth configuration
*/
function createCustomAuthConfig(baseURL, config) {
return {
provider: 'custom',
baseAuthURL: baseURL,
...config,
};
}
/**
* Create endpoint manager from auth config
*/
function createEndpointManager(config) {
return new EndpointManager(config.provider || 'django-rest-framework', config.baseAuthURL || '', config.endpoints, config.fieldMapping, config.responseMapping);
}
/**
* Validate auth configuration
*/
function validateAuthConfig(config) {
const errors = [];
if (!config.baseAuthURL && config.provider !== 'custom') {
errors.push('baseAuthURL is required');
}
if (config.provider === 'firebase') {
if (!config.endpoints?.login?.includes('key=')) {
errors.push('Firebase configuration requires API key in endpoints');
}
}
if (config.provider === 'auth0') {
if (!config.auth0?.domain || !config.auth0?.clientId) {
errors.push('Auth0 configuration requires domain and clientId');
}
}
if (config.provider === 'supabase') {
if (!config.supabase?.projectUrl || !config.supabase?.anonKey) {
errors.push('Supabase configuration requires projectUrl and anonKey');
}
}
return {
isValid: errors.length === 0,
errors,
};
}
/**
* Get provider-specific configuration examples
*/
function getProviderExamples() {
return {
'django-rest-framework': `
// Django REST Framework with JWT
const config = createDjangoRestFrameworkConfig('https://api.example.com', {
endpoints: {
login: '/api/auth/login/',
refresh: '/api/auth/refresh/',
}
});`,
'django-allauth': `
// Django Allauth
const config = createDjangoAllauthConfig('https://api.example.com', {
useEmailAsUsername: true,
});`,
'firebase': `
// Firebase Authentication
const config = createFirebaseConfig('your-api-key', {
// Additional Firebase config
});`,
'auth0': `
// Auth0
const config = createAuth0Config('your-domain.auth0.com', 'your-client-id', {
auth0: {
audience: 'https://your-api.com',
scope: 'openid profile email',
}
});`,
'supabase': `
// Supabase
const config = createSupabaseConfig(
'https://your-project.supabase.co',
'your-anon-key'
);`,
'custom': `
// Custom Authentication
const config = createCustomAuthConfig('https://api.example.com', {
endpoints: {
login: '/custom/login',
refresh: '/custom/refresh',
},
fieldMapping: {
username: 'email',
password: 'pwd',
}
});`,
};
}
/**
* Auto-detect auth provider from URL patterns
*/
function detectAuthProvider(baseURL) {
const url = baseURL.toLowerCase();
if (url.includes('supabase.co')) {
return 'supabase';
}
if (url.includes('auth0.com')) {
return 'auth0';
}
if (url.includes('googleapis.com') || url.includes('firebase')) {
return 'firebase';
}
// Default to Django REST Framework for unknown URLs
return 'django-rest-framework';
}
class AuthService {
apiClient;
config;
tokenStorage;
tokenManager;
endpointManager;
constructor(apiClient, config = {}) {
this.apiClient = apiClient;
this.config = this.normalizeConfig(config);
this.endpointManager = createEndpointManager(this.config);
// Handle tokenStorage configuration
const tokenStorageConfig = this.config.tokenStorage || {};
this.tokenStorage = createTokenStorage(this.config.secureTokens ?? tokenStorageConfig.secure ?? true, {
prefix: tokenStorageConfig.prefix || 'django_client',
maxAge: tokenStorageConfig.maxAge || this.config.accessTokenLifetime || 300,
...(tokenStorageConfig.secure !== undefined && { secure: tokenStorageConfig.secure }),
...(tokenStorageConfig.sameSite && { sameSite: tokenStorageConfig.sameSite }),
...(tokenStorageConfig.domain && { domain: tokenStorageConfig.domain }),
...(tokenStorageConfig.path && { path: tokenStorageConfig.path }),
});
// Handle autoRefresh configuration
const autoRefreshConfig = typeof this.config.autoRefresh === 'object'
? this.config.autoRefresh
: { enabled: this.config.autoRefresh !== false };
this.tokenManager = new TokenManager({
refreshThreshold: autoRefreshConfig.threshold || this.config.refreshThreshold || 60,
maxRetries: autoRefreshConfig.maxRetries || this.config.maxRefreshRetries || 3,
retryDelay: autoRefreshConfig.retryDelay || this.config.refreshRetryDelay || 1000,
});
}
/**
* Normalize configuration to handle both current and documented formats
*/
normalizeConfig(config) {
const normalized = this.getDefaultConfig(config);
// Handle fieldMappings (documented format) -> fieldMapping + responseMapping
if (config.fieldMappings) {
// Split fieldMappings into fieldMapping and responseMapping
const fieldMapping = {};
const responseMapping = {};
for (const [key, value] of Object.entries(config.fieldMappings)) {
if (['accessToken', 'refreshToken', 'permissions', 'roles'].includes(key)) {
responseMapping[key] = value;
}
else {
fieldMapping[key] = value;
}
}
normalized.fieldMapping = { ...normalized.fieldMapping, ...fieldMapping };
normalized.responseMapping = { ...normalized.responseMapping, ...responseMapping };
}
return normalized;
}
getDefaultConfig(config) {
return {
// Provider and endpoint configuration
provider: 'django-rest-framework',
baseAuthURL: '',
endpoints: {},
fieldMapping: {},
responseMapping: {},
// Legacy fields for backward compatibility
loginEndpoint: '/auth/login/',
refreshEndpoint: '/auth/refresh/',
logoutEndpoint: '/auth/logout/',
userEndpoint: '/auth/user/',
usernameField: 'username',
passwordField: 'password',
// Auth configuration
useEmailAsUsername: false,
tokenPrefix: 'Bearer',
accessTokenLifetime: 300,
refreshTokenLifetime: 86400,
autoRefresh: true,
refreshThreshold: 60,
maxRefreshRetries: 3,
refreshRetryDelay: 1000,
csrfEnabled: true,
secureTokens: true,
validateUser: () => true,
transformUser: (user) => user,
...config,
};
}
/**
* Login with credentials
*/
async login(credentials) {
// Map credentials using endpoint manager
const loginData = {
password: credentials.password,
};
// Use email or username based on configuration
if (this.config.useEmailAsUsername && credentials.email) {
loginData.username = credentials.email;
}
else if (credentials.username) {
loginData.username = credentials.username;
}
else if (credentials.email) {
loginData.username = credentials.email;
}
else {
throw new Error('Username or email is required');
}
// Map the request data using endpoint manager
const mappedData = this.endpointManager.mapRequestData(loginData);
try {
const response = await this.apiClient.post(this.endpointManager.getEndpointURL('login'), mappedData);
// Extract tokens and user data using endpoint manager
const accessToken = this.endpointManager.extractFromResponse(response, 'accessToken');
const refreshToken = this.endpointManager.extractFromResponse(response, 'refreshToken');
const userData = this.endpointManager.extractFromResponse(response, 'user');
// Validate tokens
if (!accessToken) {
throw new Error('Access token not found in login response. Check your auth configuration and server response format.');
}
if (!refreshToken) {
console.warn('Refresh token not found in login response. Auto-refresh may not work properly.');
}
const tokens = {
access: accessToken,
refresh: refreshToken,
};
// Validate and transform user data
if (this.config.validateUser && !this.config.validateUser(userData)) {
throw new Error('Invalid user data received from server');
}
const user = (this.config.transformUser ? this.config.transformUser(userData) : userData);
// Store tokens
this.tokenStorage.setTokens(tokens);
// Set authorization header for future requests
this.apiClient.setDefaultHeaders({
Authorization: `${this.config.tokenPrefix || 'Bearer'} ${tokens.access}`,
});
// Start auto-refresh if enabled
if (this.config.autoRefresh) {
this.startAutoRefresh(tokens.access);
}
return { user, tokens };
}
catch (error) {
// Enhanced error handling with proper HTTP status code extraction
let errorMessage = 'Login failed';
let statusCode;
if (error instanceof Error) {
// Check if it's an HTTP error with status code
const httpError = error;
if (httpError.response?.status) {
statusCode = httpError.response.status;
}
else if (httpError.status) {
statusCode = httpError.status;
}
else if (httpError.code) {
statusCode = parseInt(httpError.code, 10);
}
// Extract meaningful error message from response
if (httpError.response?.data) {
const responseData = httpError.response.data;
if (typeof responseData === 'string') {
errorMessage = responseData;
}
else if (responseData.detail) {
errorMessage = responseData.detail;
}
else if (responseData.message) {
errorMessage = responseData.message;
}
else if (responseData.error) {
errorMessage = responseData.error;
}
else if (responseData.non_field_errors && Array.isArray(responseData.non_field_errors)) {
errorMessage = responseData.non_field_errors.join(', ');
}
}
// Provide user-friendly messages for common HTTP status codes
if (statusCode === 401) {
errorMessage = 'Invalid email/username or password. Please check your credentials and try again.';
}
else if (statusCode === 429) {
errorMessage = 'Too many login attempts. Please wait before trying again.';
}
else if (statusCode === 403) {
errorMessage = 'Your account is not authorized to access this application.';
}
else if (statusCode === 404) {
errorMessage = 'Login service not found. Please contact support.';
}
else if (statusCode && statusCode >= 500) {
errorMessage = 'Server error. Please try again later or contact support.';
}
else if (!errorMessage || errorMessage === 'Login failed') {
errorMessage = error.message || 'Login failed. Please try again.';
}
}
// Create enhanced error with status code
const enhancedError = new Error(errorMessage);
if (statusCode) {
enhancedError.status = statusCode;
enhancedError.code = statusCode;
}
throw enhancedError;
}
}
/**
* Logout user
*/
async logout() {
try {
// Call logout endpoint if refresh token exists
const refreshToken = this.tokenStorage.getRefreshToken();
if (refreshToken) {
const logoutData = this.endpointManager.mapRequestData({
refreshToken: refreshToken,
});
await this.apiClient.post(this.endpointManager.getEndpointURL('logout'), logoutData);
}
}
catch (error) {
// Continue with logout even if server call fails
console.warn('Logout endpoint failed:', error);
}
finally {
// Always clear local tokens and headers
this.tokenStorage.clearTokens();
this.apiClient.setDefaultHeaders({
Authorization: '',
});
// Stop auto-refresh
this.stopAutoRefresh();
}
}
/**
* Refresh access token
*/
async refreshToken() {
const refreshToken = this.tokenStorage.getRefreshToken();
if (!refreshToken) {
throw new Error('No refresh token available');
}
try {
const refreshData = this.endpointManager.mapRequestData({
refreshToken: refreshToken,
});
const response = await this.apiClient.post(this.endpointManager.getEndpointURL('refresh'), refreshData);
// Extract new access token using endpoint manager
const newAccessToken = this.endpointManager.extractFromResponse(response, 'accessToken');
// Update stored access token
this.tokenStorage.setAccessToken(newAccessToken);
// Update authorization header
this.apiClient.setDefaultHeaders({
Authorization: `${this.config.tokenPrefix || 'Bearer'} ${newAccessToken}`,
});
// Restart auto-refresh with new token if enabled
if (this.config.autoRefresh) {
this.startAutoRefresh(newAccessToken);
}
return newAccessToken;
}
catch (error) {
// If refresh fails, clear all tokens
this.tokenStorage.clearTokens();
this.apiClient.setDefaultHeaders({
Authorization: '',
});
throw new Error(`Token refresh failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Get current user data
*/
async getCurrentUser() {
try {
const response = await this.apiClient.get(this.endpointManager.getEndpointURL('user'));
// Extract user data using endpoint manager
const userData = this.endpointManager.extractFromResponse(response, 'user');
if (this.config.validateUser && !this.config.validateUser(userData)) {
throw new Error('Invalid user data received from server');
}
return (this.config.transformUser ? this.config.transformUser(userData) : userData);
}
catch (error) {
throw new Error(`Failed to get user data: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Update user data
*/
async updateUser(userData) {
try {
// Map user data using endpoint manager
const mappedData = this.endpointManager.mapRequestData(userData);
const response = await this.apiClient.patch(this.endpointManager.getEndpointURL('user'), mappedData);
// Extract user data using endpoint manager
const updatedUserData = this.endpointManager.extractFromResponse(response, 'user');
if (this.config.validateUser && !this.config.validateUser(updatedUserData)) {
throw new Error('Invalid user data received from server');
}
return (this.config.transformUser ? this.config.transformUser(updatedUserData) : updatedUserData);
}
catch (error) {
throw new Error(`Failed to update user: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Check if user is authenticated
*/
isAuthenticated() {
return this.tokenStorage.hasTokens();
}
/**
* Get stored tokens
*/
getTokens() {
return this.tokenStorage.getTokens();
}
/**
* Get access token for server-side usage
*/
getServerAccessToken() {
return this.tokenStorage.getAccessToken();
}
/**
* Set CSRF token
*/
setCsrfToken(token) {
this.tokenStorage.setCsrfToken(token);
}
/**
* Get CSRF token
*/
getCsrfToken() {
return this.tokenStorage.getCsrfToken();
}
/**
* Initialize authentication state (check for existing tokens)
*/
async initialize() {
let tokens = this.tokenStorage.getTokens();
if (!tokens) {
return { user: null, tokens: null };
}
// Set authorization header
this.apiClient.setDefaultHeaders({
Authorization: `${this.config.tokenPrefix || 'Bearer'} ${tokens.access}`,
});
try {
// Check if token needs refresh before making API call
if (this.config.autoRefresh) {
await this.checkAndRefreshToken();
// Get updated tokens after potential refresh
tokens = this.tokenStorage.getTokens();
if (!tokens) {
return { user: null, tokens: null };
}
}
// Verify token is still valid by fetching user data
const user = await this.getCurrentUser();
// Start auto-refresh if enabled and not already started
if (this.config.autoRefresh && tokens) {
this.startAutoRefresh(tokens.access);
}
return { user, tokens };
}
catch (error) {
// Token might be expired, try to refresh
if (this.config.autoRefresh) {
try {
await this.refreshToken();
const user = await this.getCurrentUser();
const refreshedTokens = this.tokenStorage.getTokens();
if (refreshedTokens) {
this.startAutoRefresh(refreshedTokens.access);
}
return { user, tokens: refreshedTokens };
}
catch (refreshError) {
// Refresh failed, clear tokens
this.tokenStorage.clearTokens();
this.apiClient.setDefaultHeaders({
Authorization: '',
});
this.stopAutoRefresh();
return { user: null, tokens: null };
}
}
else {
// Auto-refresh disabled, clear tokens
this.tokenStorage.clearTokens();
this.apiClient.setDefaultHeaders({
Authorization: '',
});
return { user: null, tokens: null };
}
}
}
/**
* Start automatic token refresh
*/
startAutoRefresh(accessToken) {
this.tokenManager.scheduleRefresh(accessToken, () => this.refreshToken());
}
/**
* Stop automatic token refresh
*/
stopAutoRefresh() {
this.tokenManager.clearRefreshTimer();
}
/**
* Check if current token needs refresh
*/
async checkAndRefreshToken() {
const accessToken = this.tokenStorage.getAccessToken();
if (!accessToken) {
return false;
}
const tokenInfo = this.tokenManager.parseToken(accessToken);
if (!tokenInfo) {
return false;
}
if (this.tokenManager.isTokenExpired(tokenInfo)) {
try {
await this.refreshToken();
return true;
}
catch (error) {
// Token refresh failed, user needs to re-authenticate
await this.logout();
return false;
}
}
if (this.tokenManager.shouldRefreshToken(tokenInfo)) {
try {
await this.tokenManager.executeRefresh(() => this.refreshToken());
return true;
}
catch (error) {
console.warn('Proactive token refresh failed:', error);
// Don't logout on proactive refresh failure, token might still be valid
return false;
}
}
return false;
}
/**
* Get time until current token expires
*/
getTokenExpiryTime() {
const accessToken = this.tokenStorage.getAccessToken();
if (!accessToken) {
return 0;
}
return this.tokenManager.getTimeUntilExpiry(accessToken);
}
/**
* Get endpoint manager for external use
*/
getEndpointManager() {
return this.endpointManager;
}
/**
* Update endpoint configuration
*/
updateEndpoints(updates) {
if (updates.endpoints) {
this.endpointManager.updateEndpoints(updates.endpoints);
}
if (updates.fieldMapping) {
this.endpointManager.updateFieldMapping(updates.fieldMapping);
}
if (updates.responseMapping) {
this.endpointManager.updateResponseMapping(updates.responseMapping);
}
}
/**
* Cleanup resources
*/
destroy() {
this.tokenManager.destroy();
}
}
// Auth context
const AuthContext = React.createContext(null);
// Auth reducer
function authReducer(state, action) {
switch (action.type) {
case 'AUTH_START':
return {
...state,
isLoading: true,
error: null,
};
case 'AUTH_