447 lines
11 KiB
TypeScript
447 lines
11 KiB
TypeScript
/**
|
|
* Generic Authentication Service
|
|
* Handles authentication flows for any OAuth provider
|
|
*/
|
|
|
|
declare const JAN_API_BASE: string
|
|
|
|
import { User, AuthState, AuthBroadcastMessage } from './types'
|
|
import {
|
|
AUTH_STORAGE_KEYS,
|
|
AUTH_ENDPOINTS,
|
|
TOKEN_EXPIRY_BUFFER,
|
|
AUTH_EVENTS,
|
|
} from './const'
|
|
import { logoutUser, refreshToken, guestLogin } from './api'
|
|
import { AuthProviderRegistry } from './registry'
|
|
import { AuthBroadcast } from './broadcast'
|
|
import type { ProviderType } from './providers'
|
|
|
|
const authProviderRegistry = new AuthProviderRegistry()
|
|
|
|
export class JanAuthService {
|
|
private accessToken: string | null = null
|
|
private tokenExpiryTime: number = 0
|
|
private refreshPromise: Promise<void> | null = null
|
|
private authBroadcast: AuthBroadcast
|
|
private currentUser: User | null = null
|
|
private initPromise: Promise<void> | null = null
|
|
|
|
constructor() {
|
|
this.authBroadcast = new AuthBroadcast()
|
|
this.setupBroadcastHandlers()
|
|
this.initPromise = this.initialize().catch(console.error)
|
|
}
|
|
|
|
/**
|
|
* Ensure initialization is complete before proceeding
|
|
*/
|
|
private async ensureInitialized(): Promise<void> {
|
|
if (this.initPromise) {
|
|
await this.initPromise
|
|
this.initPromise = null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initialize the auth service
|
|
* Called on app load to check existing session
|
|
*/
|
|
async initialize(): Promise<void> {
|
|
// Ensure refreshtoken is valid (in case of expired session or secret change)
|
|
try {
|
|
await refreshToken()
|
|
} catch (error) {
|
|
console.log('Failed to refresh token on init:', error)
|
|
// If refresh fails, logout to clear any invalid state
|
|
console.log('Logging out and clearing auth state to clear invalid session...')
|
|
await logoutUser()
|
|
this.clearAuthState()
|
|
this.authBroadcast.broadcastLogout()
|
|
}
|
|
// Authentication state check
|
|
try {
|
|
if (!this.isAuthenticated()) {
|
|
// Not authenticated - ensure guest access
|
|
await this.ensureGuestAccess()
|
|
return
|
|
}
|
|
|
|
// Authenticated - ensure we have a valid token
|
|
await this.refreshAccessToken()
|
|
} catch (error) {
|
|
console.error('Failed to initialize auth:', error)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Start OAuth login flow with specified provider
|
|
*/
|
|
async loginWithProvider(providerId: ProviderType): Promise<void> {
|
|
await this.ensureInitialized()
|
|
|
|
const provider = authProviderRegistry.getProvider(providerId)
|
|
if (!provider) {
|
|
throw new Error(`Provider ${providerId} is not available`)
|
|
}
|
|
|
|
try {
|
|
await provider.initiateLogin()
|
|
} catch (error) {
|
|
console.error(`Failed to initiate ${providerId} login:`, error)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle OAuth callback for any provider
|
|
*/
|
|
async handleProviderCallback(
|
|
providerId: ProviderType,
|
|
code: string,
|
|
state?: string
|
|
): Promise<void> {
|
|
await this.ensureInitialized()
|
|
|
|
const provider = authProviderRegistry.getProvider(providerId)
|
|
if (!provider) {
|
|
throw new Error(`Provider ${providerId} is not supported`)
|
|
}
|
|
|
|
try {
|
|
// Use provider to handle the callback - this returns tokens
|
|
const tokens = await provider.handleCallback(code, state)
|
|
|
|
// Store tokens and set authenticated state
|
|
this.accessToken = tokens.access_token
|
|
this.tokenExpiryTime = Date.now() + tokens.expires_in * 1000
|
|
this.setAuthProvider(providerId)
|
|
|
|
this.authBroadcast.broadcastLogin()
|
|
} catch (error) {
|
|
console.error(`Failed to handle ${providerId} callback:`, error)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get a valid access token
|
|
* Handles both authenticated and guest tokens
|
|
*/
|
|
async getValidAccessToken(): Promise<string> {
|
|
await this.ensureInitialized()
|
|
|
|
if (
|
|
this.accessToken &&
|
|
Date.now() < this.tokenExpiryTime - TOKEN_EXPIRY_BUFFER
|
|
) {
|
|
return this.accessToken
|
|
}
|
|
if (!this.refreshPromise) {
|
|
this.refreshPromise = this.refreshAccessToken().finally(() => {
|
|
this.refreshPromise = null
|
|
})
|
|
}
|
|
|
|
await this.refreshPromise
|
|
|
|
if (!this.accessToken) {
|
|
throw new Error('Failed to obtain access token')
|
|
}
|
|
|
|
return this.accessToken
|
|
}
|
|
|
|
async refreshAccessToken(): Promise<void> {
|
|
try {
|
|
const tokens = await refreshToken()
|
|
|
|
this.accessToken = tokens.access_token
|
|
this.tokenExpiryTime = Date.now() + tokens.expires_in * 1000
|
|
} catch (error) {
|
|
console.error('Failed to refresh access token:', error)
|
|
if (error instanceof Error && error.message.includes('401')) {
|
|
await this.handleSessionExpired()
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get current authenticated user
|
|
*/
|
|
async getCurrentUser(forceRefresh: boolean = false): Promise<User | null> {
|
|
await this.ensureInitialized()
|
|
|
|
const authType = this.getAuthState()
|
|
if (authType !== AuthState.AUTHENTICATED) {
|
|
return null
|
|
}
|
|
|
|
// Force refresh if requested or if cache is cleared
|
|
if (!forceRefresh && this.currentUser) {
|
|
return this.currentUser
|
|
}
|
|
|
|
const userProfile = await this.fetchUserProfile()
|
|
if (userProfile) {
|
|
const user: User = {
|
|
id: userProfile.id,
|
|
email: userProfile.email,
|
|
name: userProfile.name,
|
|
picture: userProfile.picture,
|
|
object: userProfile.object || 'user',
|
|
}
|
|
this.currentUser = user
|
|
}
|
|
|
|
return this.currentUser
|
|
}
|
|
|
|
/**
|
|
* Logout the current user
|
|
*/
|
|
async logout(): Promise<void> {
|
|
await this.ensureInitialized()
|
|
|
|
try {
|
|
const authType = this.getAuthState()
|
|
|
|
if (authType === AuthState.AUTHENTICATED) {
|
|
await logoutUser()
|
|
}
|
|
|
|
this.clearAuthState()
|
|
|
|
// Ensure guest access after logout
|
|
await this.ensureGuestAccess()
|
|
|
|
this.authBroadcast.broadcastLogout()
|
|
|
|
if (window.location.pathname !== '/') {
|
|
window.location.href = '/'
|
|
}
|
|
} catch (error) {
|
|
console.error('Logout failed:', error)
|
|
this.clearAuthState()
|
|
// Try to ensure guest access even on error
|
|
this.ensureGuestAccess().catch(console.error)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get enabled authentication providers
|
|
*/
|
|
getAllProviders(): Array<{ id: string; name: string; icon: string }> {
|
|
return authProviderRegistry.getAllProviders().map((provider) => ({
|
|
id: provider.id,
|
|
name: provider.name,
|
|
icon: provider.icon,
|
|
}))
|
|
}
|
|
|
|
/**
|
|
* Check if user is authenticated with any provider
|
|
*/
|
|
isAuthenticated(): boolean {
|
|
return this.getAuthState() === AuthState.AUTHENTICATED
|
|
}
|
|
|
|
/**
|
|
* Check if user is authenticated with specific provider
|
|
*/
|
|
isAuthenticatedWithProvider(providerId: ProviderType): boolean {
|
|
const authType = this.getAuthState()
|
|
if (authType !== AuthState.AUTHENTICATED) {
|
|
return false
|
|
}
|
|
|
|
return this.getAuthProvider() === providerId
|
|
}
|
|
|
|
/**
|
|
* Get current auth type derived from provider
|
|
*/
|
|
getAuthState(): AuthState {
|
|
const provider = this.getAuthProvider()
|
|
if (!provider) return AuthState.UNAUTHENTICATED
|
|
if (provider === 'guest') return AuthState.GUEST
|
|
return AuthState.AUTHENTICATED
|
|
}
|
|
|
|
/**
|
|
* Get auth headers for API requests
|
|
*/
|
|
async getAuthHeader(): Promise<{ Authorization: string }> {
|
|
await this.ensureInitialized()
|
|
|
|
const token = await this.getValidAccessToken()
|
|
return {
|
|
Authorization: `Bearer ${token}`,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Make authenticated API request
|
|
*/
|
|
async makeAuthenticatedRequest<T>(
|
|
url: string,
|
|
options: RequestInit = {}
|
|
): Promise<T> {
|
|
await this.ensureInitialized()
|
|
|
|
try {
|
|
const authHeader = await this.getAuthHeader()
|
|
|
|
const response = await fetch(url, {
|
|
...options,
|
|
credentials: 'include',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
...authHeader,
|
|
...options.headers,
|
|
},
|
|
})
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text()
|
|
throw new Error(
|
|
`API request failed: ${response.status} ${response.statusText} - ${errorText}`
|
|
)
|
|
}
|
|
|
|
return response.json()
|
|
} catch (error) {
|
|
console.error('API request failed:', error)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the broadcast channel for external listeners
|
|
*/
|
|
getBroadcastChannel(): BroadcastChannel | null {
|
|
return this.authBroadcast.getBroadcastChannel()
|
|
}
|
|
|
|
/**
|
|
* Subscribe to auth events
|
|
*/
|
|
onAuthEvent(
|
|
callback: (event: MessageEvent<{ type: AuthBroadcastMessage }>) => void
|
|
): () => void {
|
|
return this.authBroadcast.onAuthEvent(callback)
|
|
}
|
|
|
|
/**
|
|
* Clear all auth state
|
|
*/
|
|
private clearAuthState(): void {
|
|
this.accessToken = null
|
|
this.tokenExpiryTime = 0
|
|
this.currentUser = null
|
|
|
|
localStorage.removeItem(AUTH_STORAGE_KEYS.AUTH_PROVIDER)
|
|
}
|
|
|
|
/**
|
|
* Ensure guest access is available
|
|
*/
|
|
private async ensureGuestAccess(): Promise<void> {
|
|
try {
|
|
this.setAuthProvider('guest')
|
|
if (!this.accessToken || Date.now() > this.tokenExpiryTime) {
|
|
const tokens = await guestLogin()
|
|
this.accessToken = tokens.access_token
|
|
this.tokenExpiryTime = Date.now() + tokens.expires_in * 1000
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to ensure guest access:', error)
|
|
// Remove provider (unauthenticated state)
|
|
localStorage.removeItem(AUTH_STORAGE_KEYS.AUTH_PROVIDER)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle session expired
|
|
*/
|
|
private async handleSessionExpired(): Promise<void> {
|
|
this.logout().catch(console.error)
|
|
this.ensureGuestAccess().catch(console.error)
|
|
}
|
|
|
|
/**
|
|
* Setup broadcast event handlers
|
|
*/
|
|
private setupBroadcastHandlers(): void {
|
|
this.authBroadcast.onAuthEvent((event) => {
|
|
switch (event.data.type) {
|
|
case AUTH_EVENTS.LOGIN:
|
|
// Another tab logged in, clear cached data to force refresh
|
|
// Clear current user cache so next getCurrentUser() call fetches fresh data
|
|
this.currentUser = null
|
|
// Clear token cache so next getValidAccessToken() call refreshes
|
|
this.accessToken = null
|
|
this.tokenExpiryTime = 0
|
|
break
|
|
|
|
case AUTH_EVENTS.LOGOUT:
|
|
// Another tab logged out, clear our state
|
|
this.clearAuthState()
|
|
this.ensureGuestAccess().catch(console.error)
|
|
break
|
|
}
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Get current auth provider
|
|
*/
|
|
getAuthProvider(): string | null {
|
|
return localStorage.getItem(AUTH_STORAGE_KEYS.AUTH_PROVIDER)
|
|
}
|
|
|
|
/**
|
|
* Set auth provider
|
|
*/
|
|
private setAuthProvider(provider: string): void {
|
|
localStorage.setItem(AUTH_STORAGE_KEYS.AUTH_PROVIDER, provider)
|
|
}
|
|
|
|
/**
|
|
* Fetch user profile from server
|
|
*/
|
|
private async fetchUserProfile(): Promise<User | null> {
|
|
try {
|
|
return await this.makeAuthenticatedRequest<User>(
|
|
`${JAN_API_BASE}${AUTH_ENDPOINTS.ME}`
|
|
)
|
|
} catch (error) {
|
|
console.error('Failed to fetch user profile:', error)
|
|
if (error instanceof Error && error.message.includes('401')) {
|
|
// Authentication failed - handle session expiry
|
|
await this.handleSessionExpired()
|
|
return null
|
|
}
|
|
return null
|
|
}
|
|
}
|
|
}
|
|
|
|
// Singleton instance management
|
|
declare global {
|
|
interface Window {
|
|
janAuthService?: JanAuthService
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get or create the shared JanAuthService instance
|
|
*/
|
|
export function getSharedAuthService(): JanAuthService {
|
|
if (!window.janAuthService) {
|
|
window.janAuthService = new JanAuthService()
|
|
}
|
|
return window.janAuthService
|
|
}
|