AtAuth
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';