AtAuth
at main 227 lines 5.8 kB view raw
1/** 2 * React hooks and utilities for AT Protocol authentication 3 * 4 * @example 5 * ```tsx 6 * import { useAuthStore, AuthProvider } from 'atauth/react'; 7 * 8 * function App() { 9 * const { isAuthenticated, user, login, logout } = useAuthStore(); 10 * 11 * if (!isAuthenticated) { 12 * return <button onClick={login}>Login with Bluesky</button>; 13 * } 14 * 15 * return ( 16 * <div> 17 * <p>Welcome, {user?.handle}!</p> 18 * <button onClick={logout}>Logout</button> 19 * </div> 20 * ); 21 * } 22 * ``` 23 */ 24 25import { create } from 'zustand'; 26import { persist } from 'zustand/middleware'; 27 28import type { TokenPayload, AuthStore, AtAuthConfig } from './types'; 29import { decodeToken, isTokenExpired, shouldRefreshToken } from './token'; 30import { getStoredToken, removeStoredToken, storeToken } from './storage'; 31import { redirectToAuth, handleCallback, isOAuthCallback, buildLogoutUrl } from './oauth'; 32 33/** 34 * Create an auth store with the given configuration. 35 * 36 * @param config - Auth configuration 37 * @returns Zustand store hook 38 */ 39export function createAuthStore(config: AtAuthConfig) { 40 const storageKey = config.storageKey || 'atauth_token'; 41 42 return create<AuthStore>()( 43 persist( 44 (set, _get) => ({ 45 // Initial state 46 isAuthenticated: false, 47 isLoading: true, 48 user: null, 49 token: null, 50 error: null, 51 52 // Actions 53 setToken: (token: string) => { 54 const user = decodeToken(token); 55 if (user && !isTokenExpired(user)) { 56 storeToken(token, storageKey, config.persistSession ?? true); 57 set({ 58 isAuthenticated: true, 59 isLoading: false, 60 user, 61 token, 62 error: null, 63 }); 64 } else { 65 set({ 66 isAuthenticated: false, 67 isLoading: false, 68 user: null, 69 token: null, 70 error: user ? 'Token expired' : 'Invalid token', 71 }); 72 } 73 }, 74 75 clearAuth: () => { 76 removeStoredToken(storageKey, config.persistSession ?? true); 77 set({ 78 isAuthenticated: false, 79 isLoading: false, 80 user: null, 81 token: null, 82 error: null, 83 }); 84 }, 85 86 setLoading: (loading: boolean) => { 87 set({ isLoading: loading }); 88 }, 89 90 setError: (error: string | null) => { 91 set({ error }); 92 }, 93 94 refreshFromStorage: () => { 95 const token = getStoredToken(storageKey, config.persistSession ?? true); 96 if (token) { 97 const user = decodeToken(token); 98 if (user && !isTokenExpired(user)) { 99 set({ 100 isAuthenticated: true, 101 isLoading: false, 102 user, 103 token, 104 error: null, 105 }); 106 return; 107 } 108 } 109 set({ 110 isAuthenticated: false, 111 isLoading: false, 112 user: null, 113 token: null, 114 error: null, 115 }); 116 }, 117 }), 118 { 119 name: storageKey, 120 partialize: (state) => ({ 121 token: state.token, 122 }), 123 } 124 ) 125 ); 126} 127 128/** 129 * Default auth store instance. 130 * 131 * Initialize with your config before using: 132 * ```ts 133 * initAuthStore({ gatewayUrl: 'https://auth.example.com' }); 134 * ``` 135 */ 136let defaultStore: ReturnType<typeof createAuthStore> | null = null; 137let defaultConfig: AtAuthConfig | null = null; 138 139/** 140 * Initialize the default auth store. 141 * 142 * @param config - Auth configuration 143 */ 144export function initAuthStore(config: AtAuthConfig): void { 145 defaultConfig = config; 146 defaultStore = createAuthStore(config); 147 148 // Auto-handle callback if on callback page 149 if (isOAuthCallback()) { 150 const result = handleCallback(config); 151 if (result.success && result.token) { 152 defaultStore.getState().setToken(result.token); 153 } else if (result.error) { 154 defaultStore.getState().setError(result.error); 155 } 156 } else { 157 // Load from storage 158 defaultStore.getState().refreshFromStorage(); 159 } 160} 161 162/** 163 * Get the auth store hook. 164 * 165 * Must call `initAuthStore` first. 166 */ 167export function useAuthStore(): AuthStore & { 168 login: (returnTo?: string) => void; 169 logout: () => void; 170} { 171 if (!defaultStore || !defaultConfig) { 172 throw new Error( 173 'Auth store not initialized. Call initAuthStore(config) first.' 174 ); 175 } 176 177 const state = defaultStore(); 178 179 return { 180 ...state, 181 login: (returnTo?: string) => { 182 redirectToAuth(defaultConfig!, { returnTo }); 183 }, 184 logout: () => { 185 state.clearAuth(); 186 // Optionally redirect to gateway logout 187 if (defaultConfig?.gatewayUrl) { 188 // Build logout URL - app can redirect if needed 189 void buildLogoutUrl(defaultConfig, window.location.origin); 190 } 191 }, 192 }; 193} 194 195/** 196 * Check if user needs to re-authenticate (token expiring soon). 197 * 198 * @param thresholdSeconds - Seconds before expiry to trigger refresh 199 * @returns True if token should be refreshed 200 */ 201export function useNeedsRefresh(thresholdSeconds = 300): boolean { 202 if (!defaultStore) return false; 203 204 const { user } = defaultStore(); 205 if (!user) return false; 206 207 return shouldRefreshToken(user, thresholdSeconds); 208} 209 210/** 211 * Hook to get just the current user. 212 */ 213export function useUser(): TokenPayload | null { 214 if (!defaultStore) return null; 215 return defaultStore().user; 216} 217 218/** 219 * Hook to check authentication status. 220 */ 221export function useIsAuthenticated(): boolean { 222 if (!defaultStore) return false; 223 return defaultStore().isAuthenticated; 224} 225 226// Re-export types for convenience 227export type { TokenPayload, AuthState, AuthStore, AtAuthConfig } from './types';