Skip to content

Authentication & Sign-in

Auth is Better Auth, configured once in packages/auth/src/lib/auth.ts and mounted at /api/auth/* (owned entirely by Better Auth — don’t add routes there). Three ways in:

  • Email + password — classic; password reset emails go through SMTP.
  • Googlenative Sign-In via an id token (no web redirect, no Firebase).
  • Applenative Sign in with Apple via an identity token (iOS only).
flowchart LR
  A["native SDK<br/>(GoogleSignin / AppleAuthentication)"] -->|idToken| B["authClient.signIn.social<br/>{ provider, idToken }"]
  B --> C["Better Auth verifies<br/>signature + issuer + audience"]
  C --> D["session + user row"]

Client side (apps/client/app/(auth)/welcome.tsx):

// Google
const { data } = await GoogleSignin.signIn();
await authClient.signIn.social({ provider: "google", idToken: { token: data.idToken } });
// Apple (iOS only)
const cred = await AppleAuthentication.signInAsync({ requestedScopes: [FULL_NAME, EMAIL] });
await authClient.signIn.social({ provider: "apple", idToken: { token: cred.identityToken } });

Server side (packages/auth/src/lib/auth.ts) — what each provider verifies the token’s audience against:

Provider clientId Verified audience Why
Google EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID (the web id) the web client id @react-native-google-signin mints the id token with the web client as audience when webClientId is set, so the backend can verify it
Apple APPLE_CLIENT_ID appBundleIdentifier (dev.frgtn.musubi) a native Apple identity token’s aud is the app’s bundle id — Better Auth checks against appBundleIdentifier first (see @better-auth/core apple.ts)

clientSecret/the web redirect endpoints are never exercised by the native flow. Apple’s appBundleIdentifier is the load-bearing field; APPLE_CLIENT_ID mostly acts as the on/off toggle (see discovery below).

Self-hosted servers may have only some providers configured. GET /api/v1/server is public and returns:

{ "minClientVersion": "0.0.16", "socials": ["google", "apple"] }

socials is derived from config presence (enabledSocials() in apps/api/src/handlers/server.ts): google when GOOGLE_WEB_CLIENT_ID is set, apple when APPLE_CLIENT_ID is set. The welcome screen fetches this on mount (and whenever the user points at a new server) and renders only the buttons that will work — and gates the Apple button to Platform.OS === "ios" on top. A provider that isn’t configured never shows, so nobody taps a dead button.

Every new user (any method) fires the databaseHooks.user.create.after hook in auth.ts, which materializes two things:

  1. A personal calendarcreateCalendar({ isDefault: true, … }). Undeletable, non-transferable, the default home.
  2. A settings row — via getUserSettings(user.id) (insert-if-missing), so user_settings exists immediately with onboarded = false.

The client loads settings first and independently in useRefreshData — the onboarding decision (and theme) must not be held hostage to the events/calendar pipeline throwing later in the same refresh.

Other Better Auth options in use: account linking (allowDifferentEmails: true, so a signed-in user can connect a second Google account with a different email), and user deletion (deleteUser.enabled).

The sign-in screen awaits authClient.requestPasswordReset inside the shared TextInputModal; the modal remains open and renders a friendly error when the request fails. Success uses non-enumerating copy (“If an account exists…”). The SMTP email links to {BETTER_AUTH_URL}/reset-password — a self-contained page the API serves from its own origin (apps/api/src/handlers/pages.ts), so self-hosters don’t depend on the central website. That page submits the token and new password to Better Auth on the same origin.

Account deletion is email-confirmed. From Settings the app requires the exact display name and calls DELETE /api/v1/users while the session is live; that only triggers Better Auth’s sendDeleteAccountVerification — no data is removed yet, and the user stays signed in. The email links to {BETTER_AUTH_URL}/delete-account (also served by the API), whose confirm button POSTs the token to /api/v1/users/delete/confirm, which then deletes the user, sessions, and accounts. The token is the credential (no session needed), so the link works in any browser.

The public musubi.pro/delete-account route on the marketing site is a separate, manual fallback for someone who cannot access the app at all. It submits to the website’s /account/delete-request endpoint, which emails a request to support through Resend. It does not delete by unauthenticated email address; support first verifies ownership, then completes deletion within the published 30-day window. Configure ACCOUNT_REQUEST_FROM_EMAIL and ACCOUNT_REQUEST_TO_EMAIL in the website environment alongside RESEND_API_KEY.

Signing in with Google gives you an account — it does not grant calendar access. Connecting a Google Calendar for sync is a separate, explicit action (SyncCalendarModalauthClient.linkSocial({ provider: "google", scopes: ["…/calendar.events", "…/calendar.calendarlist", "…/calendar.calendars"] })) that runs the OAuth authorization-code redirect with access_type=offline + prompt=consent to obtain a refresh token. Musubi requests the three narrowest scopes its sync needs rather than the broad …/auth/calendar. The sync engine only syncs Google accounts that have both the calendar scope and a refresh token — see getOAuthAccountIDs. Outlook connects the same way (linkSocial({ provider: "microsoft", scopes: ["Calendars.ReadWrite"] })): offline_access is in Microsoft’s default scopes so the refresh token comes for free, and the provider sits in accountLinking.trustedProviders because Microsoft omits the email_verified claim — better-auth would otherwise refuse the link. Apple/iCloud calendars connect over CalDAV with an app-specific password, not Sign in with Apple.

Server (.env, read by packages/config):

Variable For
GOOGLE_WEB_CLIENT_ID, GOOGLE_CLIENT_SECRET Google sign-in (id-token audience) + Calendar sync (token refresh)
GOOGLE_IOS_CLIENT_ID reserved; native Google verifies against the web id
APPLE_CLIENT_ID enables Sign in with Apple — set to the app bundle id dev.frgtn.musubi

Client (baked into the app at build timeEXPO_PUBLIC_*):

Variable For
EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID GoogleSignin.configure({ webClientId }) — id-token audience, both platforms
EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID GoogleSignin.configure({ iosClientId }) and the derived iOS URL scheme — required for Google sign-in on iOS

Create two OAuth clients in the same project: a Web client (its id is the id-token audience + used for Calendar token exchange, so it also needs the secret) and an iOS client for bundle dev.frgtn.musubi. The iOS client’s reversed form (com.googleusercontent.apps.<id>) becomes the URL scheme — app.config.ts derives it from EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID automatically.

Enable Sign In with Apple on the App ID. Setting ios.usesAppleSignIn: true in app.config.ts adds the com.apple.developer.applesignin entitlement, and EAS syncs the capability to the App ID during the build — no manual portal step, no Service ID or .p8 key for the native-only flow.

iOS build & dev — things to watch out for

Section titled “iOS build & dev — things to watch out for”

The iOS path has several sharp edges that don’t exist on Android. If you’re new to the codebase, read this before your first iOS build or dev-client session.

  1. Expo Go won’t work. The app has custom native modules (@react-native-google-signin, expo-apple-authentication, expo-sqlite). Use a dev client (eas build --profile development), then iterate JS over Metro. Only native changes (config-plugin/entitlement/native dep/EXPO_PUBLIC_*) need a rebuild; JS hot-reloads.

  2. Google sign-in needs the iOS client id + URL scheme. Without them the native module throws “failed to determine clientID — GoogleService-Info.plist was not found and iosClientId was not provided.” The bare @react-native-google-signin/google-signin plugin takes a Firebase-plist path; passing { iosUrlScheme } (derived in app.config.ts) switches it to the no-Firebase path that injects the URL scheme.

  3. CocoaPods modular headers. @react-native-google-signin pulls AppCheckCore (Swift), which imports GoogleUtilities/RecaptchaInterop — pods that ship no module map, so a static-lib build fails. expo-build-propertiesios.extraPods forces modular_headers: true for those two.

  4. App Transport Security blocks cleartext HTTP. iOS refuses http:// and its “local networking” ATS exception covers LAN (192.168.x) but not Tailscale’s 100.x. For dev over a VPN/tunnel, serve Metro/the API over https (expo start --tunnel, or tailscale serve), or point the app at an https server.

  5. expo-sqlite is strict about bind values. On iOS runSync throws InvalidConvertibleException for an undefined or a Date bind (Android silently coerces both). Two traps this actually caused: event columns that weren’t defaulted (toRow now coalesces every column), and the sync cursor — Better Auth’s fetch revives ISO strings to Date objects, so serverTime arrived as a Date and blew up setLastSync (now normalized to an ISO string). One bad bind aborts the entire refresh, which manifested as empty events + skipped onboarding. Rule of thumb: never bind undefined; convert Dates to ISO text.

  6. Status bar. Runtime status-bar theming (the root Stack statusBarStyle, driven by the app theme) needs ios.infoPlist.UIViewControllerBasedStatusBarAppearance: true.

An invite is an https://<server>/invite/<token> link. Android opens the app directly via an autoVerify intent filter. iOS needs universal links:

The client does not discard an invite when authentication is required. app/invite/[token].tsx saves its token and optional origin server in SecureStore, sends the user through welcome/sign-in/sign-up, and all supported auth methods restore that invite after success. The stored value is consumed on return so normal future sign-ins still open the tabs.

  • ios.associatedDomains in app.config.ts (applinks:musubi.pro, applinks:dev.musubi.pro) — EAS syncs the Associated Domains capability to the App ID at build time.
  • An apple-app-site-association file served at https://<server>/.well-known/apple-app-site-association — the API’s handlerAppleAppSiteAssociation builds it from APPLE_TEAM_ID (<team>.dev.frgtn.musubi/invite/*). It 404s until APPLE_TEAM_ID is set; must be HTTPS, application/json, no redirect.