@proveanything/smartlinks-auth-ui 0.5.21 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,262 @@
1
+ # Authentication State Management
2
+
3
+ ## Overview
4
+
5
+ The Smartlinks Auth UI module provides comprehensive authentication state management with real-time synchronization across browser tabs and multiple ways for your application to respond to authentication changes.
6
+
7
+ ## Accessing Authentication State
8
+
9
+ ### 1. Using the `useAuth` Hook
10
+
11
+ The primary way to access authentication state in React components:
12
+
13
+ ```tsx
14
+ import { useAuth } from '@smartlinks/auth-ui';
15
+
16
+ function MyComponent() {
17
+ const { user, token, accountData, isAuthenticated, isLoading } = useAuth();
18
+
19
+ if (isLoading) return <div>Loading...</div>;
20
+ if (!isAuthenticated) return <div>Please log in</div>;
21
+
22
+ return (
23
+ <div>
24
+ <h1>Welcome, {user.displayName}!</h1>
25
+ <p>Email: {user.email}</p>
26
+ </div>
27
+ );
28
+ }
29
+ ```
30
+
31
+ **Properties:**
32
+ - `user: AuthUser | null` - Current authenticated user object
33
+ - `token: string | null` - Current authentication token
34
+ - `accountData: Record<string, any> | null` - Additional account metadata
35
+ - `isAuthenticated: boolean` - Whether a user is currently logged in
36
+ - `isLoading: boolean` - Whether initial auth state is still loading
37
+
38
+ ### 2. Using `onAuthStateChange` Callback
39
+
40
+ Subscribe to all authentication state changes including login, logout, cross-tab synchronization, and token refresh:
41
+
42
+ ```tsx
43
+ import { useAuth } from '@smartlinks/auth-ui';
44
+ import { useEffect } from 'react';
45
+ import { useNavigate } from 'react-router-dom';
46
+
47
+ function App() {
48
+ const { onAuthStateChange } = useAuth();
49
+ const navigate = useNavigate();
50
+
51
+ useEffect(() => {
52
+ // Subscribe to auth state changes
53
+ const unsubscribe = onAuthStateChange((event) => {
54
+ console.log('Auth state changed:', event.type);
55
+
56
+ if (event.type === 'LOGIN' || event.type === 'CROSS_TAB_SYNC') {
57
+ if (event.user) {
58
+ // User logged in (in this tab or another)
59
+ navigate('/dashboard');
60
+ }
61
+ } else if (event.type === 'LOGOUT') {
62
+ // User logged out
63
+ navigate('/login');
64
+ }
65
+ });
66
+
67
+ // Cleanup subscription on unmount
68
+ return unsubscribe;
69
+ }, [onAuthStateChange, navigate]);
70
+
71
+ return <YourAppComponents />;
72
+ }
73
+ ```
74
+
75
+ **Event Types:**
76
+ - `LOGIN` - User logged in via the auth UI
77
+ - `LOGOUT` - User logged out
78
+ - `CROSS_TAB_SYNC` - Authentication state synchronized from another browser tab
79
+ - `TOKEN_REFRESH` - Authentication token was refreshed (future feature)
80
+ - `ACCOUNT_REFRESH` - Account information was fetched/refreshed
81
+
82
+ **Event Object:**
83
+ ```typescript
84
+ {
85
+ type: 'LOGIN' | 'LOGOUT' | 'TOKEN_REFRESH' | 'CROSS_TAB_SYNC' | 'ACCOUNT_REFRESH';
86
+ user: AuthUser | null;
87
+ token: string | null;
88
+ accountData: Record<string, any> | null;
89
+ accountInfo?: Record<string, any> | null;
90
+ }
91
+ ```
92
+
93
+ ### 3. Using Auth Operation Callbacks
94
+
95
+ Handle specific authentication operations with callbacks:
96
+
97
+ ```tsx
98
+ <SmartlinksAuthUI
99
+ clientId="your-client-id"
100
+ apiEndpoint="https://smartlinks.app/api/v1"
101
+ onAuthSuccess={(response) => {
102
+ console.log('User authenticated:', response.user);
103
+ // Redirect or update UI
104
+ window.location.href = '/dashboard';
105
+ }}
106
+ onAuthError={(error) => {
107
+ console.error('Authentication failed:', error);
108
+ // Show error message to user
109
+ }}
110
+ />
111
+ ```
112
+
113
+ ## Cross-Tab Synchronization
114
+
115
+ The auth module automatically synchronizes authentication state across all open browser tabs:
116
+
117
+ - **Login in one tab** → All tabs automatically update to authenticated state
118
+ - **Logout in one tab** → All tabs automatically clear authentication state
119
+ - **Session expiration** → All tabs receive the update simultaneously
120
+
121
+ This is implemented using:
122
+ - **IndexedDB** for persistent token storage
123
+ - **BroadcastChannel API** for cross-tab messaging
124
+ - **Storage events** for fallback synchronization
125
+
126
+ ## Protected Routes
127
+
128
+ Use the `ProtectedRoute` component to restrict access to authenticated users:
129
+
130
+ ```tsx
131
+ import { ProtectedRoute } from '@smartlinks/auth-ui';
132
+
133
+ function App() {
134
+ return (
135
+ <Routes>
136
+ <Route path="/login" element={<LoginPage />} />
137
+ <Route
138
+ path="/dashboard"
139
+ element={
140
+ <ProtectedRoute fallback={<LoginPage />}>
141
+ <DashboardPage />
142
+ </ProtectedRoute>
143
+ }
144
+ />
145
+ </Routes>
146
+ );
147
+ }
148
+ ```
149
+
150
+ **Props:**
151
+ - `children` - Protected content to render when authenticated
152
+ - `fallback` - Component to render when not authenticated (optional)
153
+ - `redirectTo` - URL to redirect to when not authenticated (optional)
154
+
155
+ ## Complete Example: App with Auth State Management
156
+
157
+ ```tsx
158
+ import React, { useEffect } from 'react';
159
+ import { BrowserRouter, Routes, Route, useNavigate } from 'react-router-dom';
160
+ import { AuthProvider, useAuth, SmartlinksAuthUI, ProtectedRoute } from '@smartlinks/auth-ui';
161
+
162
+ // Login page component
163
+ function LoginPage() {
164
+ const navigate = useNavigate();
165
+ const { isAuthenticated } = useAuth();
166
+
167
+ // Redirect if already authenticated
168
+ useEffect(() => {
169
+ if (isAuthenticated) {
170
+ navigate('/dashboard');
171
+ }
172
+ }, [isAuthenticated, navigate]);
173
+
174
+ return (
175
+ <div>
176
+ <h1>Sign In</h1>
177
+ <SmartlinksAuthUI
178
+ clientId="your-client-id"
179
+ apiEndpoint="https://smartlinks.app/api/v1"
180
+ onAuthSuccess={() => navigate('/dashboard')}
181
+ onAuthError={(error) => alert(error.message)}
182
+ />
183
+ </div>
184
+ );
185
+ }
186
+
187
+ // Protected dashboard component
188
+ function DashboardPage() {
189
+ const { user, logout } = useAuth();
190
+
191
+ return (
192
+ <div>
193
+ <h1>Dashboard</h1>
194
+ <p>Welcome, {user?.displayName}!</p>
195
+ <button onClick={logout}>Sign Out</button>
196
+ </div>
197
+ );
198
+ }
199
+
200
+ // App root with auth state monitoring
201
+ function AppContent() {
202
+ const { onAuthStateChange } = useAuth();
203
+ const navigate = useNavigate();
204
+
205
+ useEffect(() => {
206
+ const unsubscribe = onAuthStateChange((event) => {
207
+ if (event.type === 'LOGOUT') {
208
+ navigate('/login');
209
+ } else if (event.type === 'LOGIN' || event.type === 'CROSS_TAB_SYNC') {
210
+ if (event.user) {
211
+ navigate('/dashboard');
212
+ }
213
+ }
214
+ });
215
+
216
+ return unsubscribe;
217
+ }, [onAuthStateChange, navigate]);
218
+
219
+ return (
220
+ <Routes>
221
+ <Route path="/login" element={<LoginPage />} />
222
+ <Route
223
+ path="/dashboard"
224
+ element={
225
+ <ProtectedRoute fallback={<LoginPage />}>
226
+ <DashboardPage />
227
+ </ProtectedRoute>
228
+ }
229
+ />
230
+ </Routes>
231
+ );
232
+ }
233
+
234
+ // App root
235
+ export default function App() {
236
+ return (
237
+ <AuthProvider>
238
+ <BrowserRouter>
239
+ <AppContent />
240
+ </BrowserRouter>
241
+ </AuthProvider>
242
+ );
243
+ }
244
+ ```
245
+
246
+ ## Best Practices
247
+
248
+ 1. **Always wrap your app with `AuthProvider`** at the root level
249
+ 2. **Use `onAuthStateChange`** for app-level routing and state synchronization
250
+ 3. **Use `useAuth` hook** for component-level auth state access
251
+ 4. **Use `ProtectedRoute`** to guard authenticated routes
252
+ 5. **Handle loading states** with `isLoading` to avoid flickering
253
+ 6. **Subscribe to `onAuthStateChange` early** in your app initialization to catch cross-tab events
254
+
255
+ ## Session Persistence
256
+
257
+ Sessions are automatically persisted using:
258
+ - **Primary:** IndexedDB (survives browser restart, shared across tabs)
259
+ - **Fallback:** localStorage (for browsers without IndexedDB support)
260
+ - **In-memory:** Last resort for restricted environments
261
+
262
+ Tokens are stored with expiration metadata and automatically validated on load. Expired tokens are automatically cleared.
@@ -0,0 +1,244 @@
1
+ # Capacitor Native Auth (Google & Apple)
2
+
3
+ This guide shows how to enable **native** Google and Apple sign-in for apps that
4
+ wrap the auth UI in a [Capacitor](https://capacitorjs.com/) shell (including apps
5
+ shipped with [Capgo](https://capgo.app/) OTA updates).
6
+
7
+ Unlike the legacy [`window.AuthKit` bridge](./ANDROID_NATIVE_BRIDGE.md) — which
8
+ uses a stringified-JSON + `callbackId` callback structure — Capacitor plugins are
9
+ **Promise-based**. So instead of that bridge, you inject a small **`nativeAuth`
10
+ adapter** and the auth UI `await`s it directly.
11
+
12
+ > The library stays plugin-agnostic: it depends on **no** Capacitor packages. Your
13
+ > app owns the plugin and wires it into the adapter shape below.
14
+
15
+ ---
16
+
17
+ ## 1. Install a native social-login plugin
18
+
19
+ Any plugin works as long as you can produce a Google **ID token** and an Apple
20
+ **identity token**. The natural fit for Capgo apps is
21
+ [`@capgo/capacitor-social-login`](https://github.com/Cap-go/capacitor-social-login):
22
+
23
+ ```bash
24
+ npm install @capgo/capacitor-social-login
25
+ npx cap sync
26
+ ```
27
+
28
+ Configure native projects per that plugin's README (iOS: add the Sign in with
29
+ Apple capability + URL scheme; Android: add your Google **Web Client ID** /
30
+ `serverClientId` and SHA-1). See [ANDROID_NATIVE_BRIDGE.md](./ANDROID_NATIVE_BRIDGE.md#prerequisites)
31
+ for the Google Cloud Console setup — the client IDs are identical.
32
+
33
+ ---
34
+
35
+ ## 2. Build the adapter
36
+
37
+ The adapter implements the `NativeAuthAdapter` interface exported by this package.
38
+ Each method resolves a `NativeAuthResult` whose `idToken` is the token your backend
39
+ verifies (`googleLogin` / the Apple endpoint).
40
+
41
+ ```ts
42
+ import { SocialLogin } from '@capgo/capacitor-social-login';
43
+ import { Capacitor } from '@capacitor/core';
44
+ import type { NativeAuthAdapter } from '@proveanything/smartlinks-auth-ui';
45
+
46
+ const GOOGLE_WEB_CLIENT_ID = '696509063554-xxx.apps.googleusercontent.com';
47
+ const APPLE_SERVICES_ID = 'com.yourapp.signin';
48
+
49
+ let initialized = false;
50
+ async function ensureInit() {
51
+ if (initialized) return;
52
+ await SocialLogin.initialize({
53
+ google: { webClientId: GOOGLE_WEB_CLIENT_ID },
54
+ apple: { clientId: APPLE_SERVICES_ID },
55
+ });
56
+ initialized = true;
57
+ }
58
+
59
+ export const capacitorNativeAuth: NativeAuthAdapter = {
60
+ async signInWithGoogle() {
61
+ await ensureInit();
62
+ const { result } = await SocialLogin.login({
63
+ provider: 'google',
64
+ options: { scopes: ['email', 'profile'] },
65
+ });
66
+ // Field names vary slightly by plugin version — log `result` once to confirm.
67
+ return {
68
+ idToken: (result as any).idToken,
69
+ email: (result as any).profile?.email,
70
+ name: (result as any).profile?.name,
71
+ picture: (result as any).profile?.imageUrl,
72
+ };
73
+ },
74
+
75
+ async signInWithApple() {
76
+ await ensureInit();
77
+ const { result } = await SocialLogin.login({
78
+ provider: 'apple',
79
+ options: { scopes: ['email', 'name'] },
80
+ });
81
+ return {
82
+ idToken: (result as any).idToken, // Apple identity token (JWT)
83
+ authorizationCode: (result as any).accessToken,
84
+ email: (result as any).profile?.email,
85
+ name: (result as any).profile?.name,
86
+ };
87
+ },
88
+
89
+ async signOut() {
90
+ try { await SocialLogin.logout({ provider: 'google' }); } catch {}
91
+ },
92
+ };
93
+
94
+ // Only attach native auth when actually running in a native shell —
95
+ // on the web, leave it undefined so the normal browser OAuth flow is used.
96
+ export const nativeAuth = Capacitor.isNativePlatform() ? capacitorNativeAuth : undefined;
97
+ ```
98
+
99
+ ---
100
+
101
+ ## 3. Pass it to the auth UI
102
+
103
+ ```tsx
104
+ import { SmartlinksAuthUI } from '@proveanything/smartlinks-auth-ui';
105
+ import { nativeAuth } from './capacitorNativeAuth';
106
+
107
+ <SmartlinksAuthUI
108
+ clientId="your-client-id"
109
+ enabledProviders={['email', 'google', 'apple']}
110
+ nativeAuth={nativeAuth}
111
+ enableSilentNativeSignIn // optional: auto sign-in if a native session exists
112
+ customization={{ appleClientId: APPLE_SERVICES_ID }}
113
+ onAuthSuccess={(token, user) => console.log('Authenticated:', user.email)}
114
+ />
115
+ ```
116
+
117
+ That's it. When `nativeAuth` is present:
118
+
119
+ - **Google** → uses `nativeAuth.signInWithGoogle()` (highest priority, before the
120
+ legacy `window.AuthKit` bridge, proxy popup, and in-browser GIS flow).
121
+ - **Apple** → uses `nativeAuth.signInWithApple()`. Apple is **native-only** in this
122
+ kit; without an adapter the Apple button reports it's unavailable.
123
+ - On the **web** build (`nativeAuth` undefined) everything falls back to the
124
+ existing browser flows automatically.
125
+
126
+ ---
127
+
128
+ ## 4. Mobile session persistence ("stay logged in forever")
129
+
130
+ Mobile users expect to open the app weeks later and still be signed in. The web
131
+ defaults (IndexedDB, 7-day token cache, refresh on next page load) are not enough.
132
+ Wire up the three pieces below in your Capacitor shell.
133
+
134
+ ### 4a. Use OS-secure storage for tokens
135
+
136
+ By default the kit persists tokens in IndexedDB inside the WebView. That survives
137
+ app restarts and Capgo OTA updates, but it is **not encrypted at rest**. For any
138
+ app that handles sensitive data, swap in the OS keychain via the
139
+ `setStorageAdapter` hook exported from the kit. Install a secure-storage plugin
140
+ (e.g. [`capacitor-secure-storage-plugin`](https://github.com/martinkasa/capacitor-secure-storage-plugin)
141
+ or `@capacitor/preferences` for non-sensitive cases) and implement the
142
+ `PersistentStorage` interface against it:
143
+
144
+ ```ts
145
+ import { SecureStoragePlugin } from 'capacitor-secure-storage-plugin';
146
+ import { setStorageAdapter, type PersistentStorage } from '@proveanything/smartlinks-auth-ui';
147
+
148
+ const keychainStorage: PersistentStorage = {
149
+ async setItem(key, value) { await SecureStoragePlugin.set({ key, value: JSON.stringify(value) }); },
150
+ async getItem(key) { try { const { value } = await SecureStoragePlugin.get({ key }); return JSON.parse(value); } catch { return null; } },
151
+ async removeItem(key) { try { await SecureStoragePlugin.remove({ key }); } catch {} },
152
+ async clear() { const { keys } = await SecureStoragePlugin.keys(); await Promise.all(keys.map(k => SecureStoragePlugin.remove({ key: k }))); },
153
+ async isAvailable() { return true; },
154
+ getBackend() { return 'none'; }, // not one of the built-ins
155
+ };
156
+
157
+ // Call ONCE, before <SmartlinksAuthUI /> mounts (e.g. in main.tsx).
158
+ if (Capacitor.isNativePlatform()) setStorageAdapter(keychainStorage);
159
+ ```
160
+
161
+ ### 4b. Refresh the session on app resume
162
+
163
+ Capacitor fires `appStateChange` when the OS hands the app back. Call
164
+ `useAuth().refreshToken()` there so a cold-open after days re-validates the
165
+ session before the user touches anything. Today this re-verifies the existing
166
+ JWT and extends the local cache window; once the optional refresh-token endpoint
167
+ (see §5) is deployed it will also rotate to a fresh long-lived token.
168
+
169
+ ```tsx
170
+ import { App } from '@capacitor/app';
171
+ import { useAuth } from '@proveanything/smartlinks-auth-ui';
172
+
173
+ function ResumeRefresh() {
174
+ const { refreshToken, token } = useAuth();
175
+ useEffect(() => {
176
+ const sub = App.addListener('appStateChange', ({ isActive }) => {
177
+ if (isActive && token) refreshToken().catch(() => {});
178
+ });
179
+ return () => { sub.remove(); };
180
+ }, [refreshToken, token]);
181
+ return null;
182
+ }
183
+ ```
184
+
185
+ ### 4c. Wire logout to the native provider
186
+
187
+ `useAuth().logout()` clears the SmartLinks session. If the user signed in with
188
+ Google or Apple natively, also call `nativeAuth.signOut()` so the OS-level
189
+ session is dropped — otherwise the next "Continue with Google" silently
190
+ re-uses the previous account.
191
+
192
+ ### 4d. Biometric gate (host responsibility)
193
+
194
+ Best mobile UX is "session lives forever, Face ID unlocks the UI on cold start".
195
+ That belongs in the host app (e.g.
196
+ [`@capgo/capacitor-native-biometric`](https://github.com/Cap-go/capacitor-native-biometric)) —
197
+ gate your protected routes behind a biometric prompt and leave the SmartLinks
198
+ session alone.
199
+
200
+ ### 4e. Deep links (magic-link & email verify)
201
+
202
+ Magic-link and email-verification URLs must open the app, not Safari/Chrome.
203
+ Configure your custom scheme + Universal/App Links in `capacitor.config.ts`,
204
+ then forward the URL into the SDK:
205
+
206
+ ```ts
207
+ import { App } from '@capacitor/app';
208
+
209
+ App.addListener('appUrlOpen', ({ url }) => {
210
+ // Strip the scheme/host and replace the WebView's hash so the kit's
211
+ // existing deep-link detector (mode=verifyEmail|magicLink, token=...) picks it up.
212
+ const u = new URL(url);
213
+ window.location.hash = u.hash || `#${u.pathname}${u.search}`;
214
+ });
215
+ ```
216
+
217
+ ---
218
+
219
+ ## 5. Backend: long-lived tokens & refresh
220
+
221
+ The kit's `tokenStorage` caches the JWT for 7 days by default; the real ceiling
222
+ is the JWT's own `exp` claim. Two backend options if you want true "never log out"
223
+ sessions:
224
+
225
+ 1. **Issue longer JWTs for native clients.** Detect a `X-Client-Platform: native`
226
+ header on `/auth/*` endpoints and mint a 90-day token. Simplest path — no new
227
+ endpoints, no rotation logic. Trade-off: longer revocation window.
228
+ 2. **Add a refresh-token endpoint** (recommended for production). Spec lives at
229
+ [`backend-reference/REFRESH_TOKEN_BACKEND_SPEC.md`](../backend-reference/REFRESH_TOKEN_BACKEND_SPEC.md).
230
+ The kit's `useAuth().refreshToken()` will start calling it transparently once
231
+ the endpoint ships.
232
+
233
+ ---
234
+
235
+ ## Apple backend (already shipped)
236
+
237
+ Sign in with Apple is delegated to `smartlinks.authKit.appleLogin()` in the SDK
238
+ (≥ 1.14.15) which posts to `POST /authkit/{clientId}/auth/apple`. No additional
239
+ work needed in this kit.
240
+
241
+ > **App Store note:** Apple's [App Store Review Guideline 4.8](https://developer.apple.com/app-store/review/guidelines/#sign-in-with-apple)
242
+ > requires offering Sign in with Apple when you offer Google sign-in in an iOS app,
243
+ > which is the main reason Apple support is bundled here.
244
+