A shared authentication library based on Better Auth + Legend State.
┌─────────────────────────────────────────────────────────────┐
│ createAuth() / initAuth() │
│ (core.ts) │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌─────────────┐ │
│ │ Store │ │ Service │ │ API │ │ Hooks │ │
│ │ Layer │ │ Layer │ │ Layer │ │ Layer │ │
│ │ │ │ │ │ │ │ │ │
│ │authStore │ │authServ. │ │apiClient │ │useAuthSync │ │
│ │storeAct. │ │callback │ │authApi │ │useProtected │ │
│ │computed │ │companion │ │orgApi │ │useEmailVer. │ │
│ └──────────┘ └──────────┘ └──────────┘ └─────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
| Layer | Responsibility | Files |
|---|---|---|
| Store Layer | Reactive state management | store/state.ts, store/actions.ts, store/computed.ts, store/utils.ts |
| Service Layer | Business logic | services/auth-service.ts, services/callback-service.ts, services/companion-team.ts |
| API Layer | HTTP requests | api/client.ts, api/auth-api.ts, api/organization-api.ts, api/team-api.ts |
| Hooks Layer | React-specific | hooks/use-auth-sync.ts, hooks/use-protected-route.ts, hooks/use-email-verification.ts |
pnpm add @choiceform/shared-auth{
"@legendapp/state": "v3.0.0-beta.30",
"better-auth": "^1.4.4",
"react": ">=18.0.0",
"react-dom": ">=18.0.0"
}Quick initialization with default configuration, automatically includes magicLinkClient and organizationClient plugins:
import{initAuth}from"@choiceform/shared-auth"constauth=initAuth({baseURL: "https://api.example.com",// Optional configurationtokenStorageKey: "auth-token",// localStorage key, defaults to "auth-token"skipTokenCleanupOnError: false,// Set to true for development})export{auth}Use when you need custom Better Auth plugins:
import{createAuth}from"@choiceform/shared-auth"import{magicLinkClient,organizationClient}from"better-auth/client/plugins"constauth=createAuth({baseURL: "https://api.example.com",plugins: [magicLinkClient(),organizationClient({teams: {enabled: true}}),// Other plugins...],tokenStorageKey: "auth-token",})export{auth}The instance returned by createAuth() / initAuth() includes:
constauth=initAuth({baseURL: "..."})// API Layerauth.apiClient// HTTP clientauth.authApi// Auth APIauth.organizationApi// Organization APIauth.teamApi// Team API// Store Layerauth.authStore// Legend State Observableauth.authComputed// Computed propertiesauth.tokenStorage// Token storageauth.storeActions// State actions// Service Layerauth.authService// Auth business logic// Active State Managementauth.setActiveOrganization(request)auth.setActiveTeam(request)auth.setActiveOrganizationAndTeam(orgId,teamId)// Shortcut Methodsauth.getCurrentUser()// Get current userauth.getCurrentUserId()// Get current user IDauth.isAuthenticated()// Check if authenticatedauth.isLoading()// Check if loadingauth.isLoaded()// Check if loadedauth.getAuthToken()// Get tokenauth.getAuthHeaders()// Get auth headersauth.waitForAuth()// Wait for auth to completeauth.userManager// User manager// Better Auth Client (Advanced)auth.authClient// Raw Better Auth clientSync authentication state, replaces manual Better Auth session sync:
import{useAuthSync}from"@choiceform/shared-auth"functionApp(){useAuthSync(auth,{skipCompanionTeamPaths: ['/auth/callback','/auth/delete-success'],onAuthChange: (isAuthenticated)=>{if(isAuthenticated){syncTheme()syncLanguage()}}})return<YourApp/>}Route protection, checks authentication status and email verification:
import{useProtectedRoute}from"@choiceform/shared-auth"functionProtectedRoute({ children }){constnavigate=useNavigate()constlocation=useLocation()const{ status, shouldRender, redirectPath }=useProtectedRoute(auth,{pathname: location.pathname,publicRoutePrefixes: ['/resources','/public'],requireEmailVerified: true,lang: 'us'})useEffect(()=>{if(redirectPath){navigate(redirectPath,{replace: true})}},[redirectPath])if(!shouldRender){return<Loading/>}return<>{children}</>}Email verification flow:
import{useEmailVerification}from"@choiceform/shared-auth"functionVerifyEmailPage({ email, lang }){constnavigate=useNavigate()const{
isLoading,
isAlreadyVerified,
isCountingDown,
countdown,
resendVerification,
changeEmail
}=useEmailVerification(auth,{
email,
lang,onRedirect: (path)=>navigate(path),onSendSuccess: (email)=>toast.success(`Verification email sent to ${email}`),onSendError: ()=>toast.error('Failed to send'),onAlreadyVerified: ()=>navigate('/community')})return(<div>{isAlreadyVerified ? (<p>Emailverified,redirecting...</p>) : (<><buttononClick={resendVerification}disabled={isLoading||isCountingDown}>{isCountingDown ? `Retry in ${countdown}s` : 'Resend'}</button><buttononClick={changeEmail}>Usedifferentemail</button></>)}</div>)}Initialize authentication state:
import{useAuthInit,initializeAuth}from"@choiceform/shared-auth"// Hook approachfunctionApp(){useAuthInit(auth)return<YourApp/>}// Or manual callawaitinitializeAuth(auth)Auth business logic wrapper:
// OAuth sign inawaitauth.authService.signInWithOAuth("github",callbackURL,newUserCallbackURL,errorCallbackURL)// Magic Link sign inawaitauth.authService.signInWithMagicLink(email,callbackURL,name,newUserCallbackURL)// Email/password sign inconstresult=awaitauth.authService.signInWithEmail(email,password)// Email/password sign upconstresult=awaitauth.authService.signUpWithEmail(email,password,name,callbackURL)// Sign outawaitauth.authService.signOut("/sign-in")// Delete accountawaitauth.authService.deleteUser(callbackURL,password)// Fetch session with tokenawaitauth.authService.fetchAndSetSession(token)Handle various auth callbacks (OAuth, email verification, user deletion, etc.):
import{createCallbackService}from"@choiceform/shared-auth"constcallbackService=createCallbackService(auth,{lang: 'us',defaultRedirect: '/',signInPath: '/sign-in',linkExpiredPath: '/auth/link-expired',deleteSuccessPath: '/auth/delete-success',})// Handle OAuth callbackconstresult=awaitcallbackService.handleOAuthCallback(token,isNewUser)// Handle email verification callbackconstresult=awaitcallbackService.handleEmailVerificationCallback(token)// Handle delete user callbackconstresult=awaitcallbackService.handleDeleteUserCallback(token,userEmail)// Unified handlerconstresult=awaitcallbackService.handleCallback(type,token,{ isNewUser, userEmail, invitationId })Companion team setup:
import{setupCompanionTeam}from"@choiceform/shared-auth"awaitsetupCompanionTeam(auth,options)Reactive state, can be used in React components:
import{use$}from"@legendapp/state/react"functionMyComponent(){constuser=use$(auth.authStore.user)constisAuthenticated=use$(auth.authStore.isAuthenticated)constloading=use$(auth.authStore.loading)consterror=use$(auth.authStore.error)constisLoaded=use$(auth.authStore.isLoaded)// Computed stateconstisReady=use$(auth.authComputed.isReady)constactiveOrganizationId=use$(auth.authComputed.activeOrganizationId)}State management actions:
// Readauth.storeActions.getUser()auth.storeActions.getUserId()auth.storeActions.isAuthenticated()auth.storeActions.isLoading()auth.storeActions.isLoaded()// Updateauth.storeActions.setUser(user)auth.storeActions.updateUser({name: "New Name"})auth.storeActions.setLoading(true)auth.storeActions.setError("Error message")auth.storeActions.clearAuth()auth.storeActions.handleUnauthorized()// Active stateauth.storeActions.setActiveOrganizationId(orgId)auth.storeActions.setActiveTeamId(teamId)Standalone utility functions for non-component scenarios:
import{getCurrentUser,getCurrentUserId,isAuthenticated,isLoading,isLoaded,waitForAuth,getAuthToken,getAuthTokenSync,getAuthHeaders,getAuthHeadersSync,handle401Response,createUserManager,}from"@choiceform/shared-auth"// Wait for auth to completeawaitwaitForAuth(auth.authStore)// Get auth headers (sync)constheaders=getAuthHeadersSync(auth.tokenStorage)// Handle 401 responsehandle401Response(response,auth.storeActions)import{// EnvironmentgetEnvVar,getAuthBaseUrl,// ValidationisValidEmail,// Error parsingparseAuthError,isTokenExpiredError,AUTH_ERROR_CODES,// URL utilitiesgetNameFromEmail,buildAuthUrl,buildAuthPath,clearAuthParams,// User mappingextractSessionUser,}from"@choiceform/shared-auth"// Validate emailif(isValidEmail(email)){// ...}// Parse errorconst{ code, message, isKnownError }=parseAuthError(error)// Check if token expiredif(isTokenExpiredError(error)){// Re-authenticate}// Build URLconsturl=buildAuthPath('/verify-email',{lang: 'us', email })Low-level API access:
import{createApiClient,createAuthApi,createOrganizationApi,createTeamApi,parseErrorResponse,}from"@choiceform/shared-auth"// Use APIs directlyconstresponse=awaitauth.authApi.getSession()constorgs=awaitauth.organizationApi.listOrganizations()constteams=awaitauth.teamApi.listTeams()src/
├── api/ # API Layer
│ ├── client.ts # HTTP client
│ ├── auth-api.ts # Auth API
│ ├── organization-api.ts # Organization API
│ ├── team-api.ts # Team API
│ └── index.ts
├── services/ # Service Layer
│ ├── auth-service.ts # Auth business logic
│ ├── callback-service.ts # Callback handling
│ ├── companion-team.ts # Companion team setup
│ └── index.ts
├── store/ # Store Layer
│ ├── state.ts # State definition
│ ├── actions.ts # State actions
│ ├── computed.ts # Computed properties
│ ├── utils.ts # Utility functions
│ └── index.ts
├── hooks/ # React Hooks
│ ├── use-auth-init.ts # Auth initialization
│ ├── use-auth-sync.ts # State sync
│ ├── use-protected-route.ts
│ ├── use-email-verification.ts
│ └── index.ts
├── utils/ # Utilities
│ ├── auth-utils.ts # Auth utilities
│ ├── user-mapper.ts # User mapping
│ ├── date.ts # Date utilities
│ ├── env.ts # Environment variables
│ └── index.ts
├── types/ # Type definitions
│ ├── auth.ts
│ ├── callback.ts
│ ├── organization.ts
│ ├── team.ts
│ ├── user.ts
│ └── index.ts
├── lib/ # Better Auth client
│ └── auth-client.ts
├── core.ts # Core entry (createAuth)
├── init.ts # Quick init (initAuth)
├── config.ts # Default config
└── index.ts # Export entry
importtype{// CoreAuthInstance,AuthState,AuthConfig,SessionUser,SessionUserMetadata,Session,// ServicesAuthService,AuthServiceConfig,CallbackService,CallbackType,CallbackResult,CallbackConfig,// HooksUseAuthSyncConfig,UseAuthSyncResult,UseProtectedRouteConfig,UseProtectedRouteResult,ProtectionStatus,UseEmailVerificationConfig,UseEmailVerificationResult,// OrganizationOrganization,OrganizationMetadata,FullOrganization,CreateOrganizationRequest,UpdateOrganizationRequest,Member,MemberWithUser,MemberRole,Invitation,InvitationDetail,InvitationStatus,// TeamTeam,TeamMetadata,TeamMember,CreateTeamRequest,UpdateTeamRequest,// APIApiClient,ApiClientConfig,ApiResponse,TokenStorage,AuthApi,OrganizationApi,TeamApi,// StoreStoreActions,// UtilitiesAuthErrorCode,ParsedAuthError,}from"@choiceform/shared-auth"# Install dependencies
pnpm install
# Development mode
pnpm dev
# Build
pnpm build
# Test
pnpm test# Watch tests
pnpm test:watchMIT