How it works
Continue with Apple and Continue with Google are built on web (/login, /signup) and in the app (P-Entrar, sign-up), and stay hidden until the server has credentials. appConfig.get lists a provider in socialProviders only when its id and secret are set (social-providers.ts). Without any credentials nothing changes on either surface.
| Surface | Flow |
|---|---|
| Web | The browser leaves for the provider, returns to https://<origin>/api/auth/callback/<provider>, then goes to the page that sent it to /login (?redirect=) or to /dashboard. A refusal returns to the same page with ?error=; the page explains account_not_linked and offers a retry for anything else. A cancel shows nothing. |
| App | Better Auth's Expo plugin opens the same server flow in a system auth session (ASWebAuthenticationSession on iOS, a Custom Tab on Android). The callback returns to the app scheme (autofolio:///, autofolio-dev:/// in the dev build) with the session, and useEnterApp lands on the garage. A refusal also returns to the scheme, so the sheet closes; the app shows no message for it (see below). |
On iOS Google shows only beside Apple (App Store guideline 4.8) and Apple shows only on iOS (auth.social.ts); the web offers both.
Account linking. The first sign-in with a new email creates the account and its Organization. An email that already has an account is joined only when the provider vouches for the email and the account's own email is verified (account.accountLinking with requireLocalEmailVerified, no trusted providers). Anything else answers account_not_linked, so a password sign-up squatting someone's address is never joined. Apple's Hide My Email addresses are new accounts. Linked accounts are managed from the app's Security → Apple and Google. social-sign-in.test.ts proves the redirect start, the app-scheme return, the refused foreign callbackURL and the three linking cases.
In Google Cloud console for the AutoFolio project:
OAuth consent screen (Google Auth Platform → Branding/Audience): user type External, app name AutoFolio, support email, authorized domain
its-live.dev, home pagehttps://autofolio.its-live.dev, privacy policyhttps://autofolio.its-live.dev/privacy, termshttps://autofolio.its-live.dev/terms. Scopes:openid,email,profileonly (no verification review needed). Publish the app to In production, or only test users can sign in.One OAuth client, type Web application (Clients → Create client):
- Authorized JavaScript origins:
https://autofolio.its-live.dev,http://localhost:3005 - Authorized redirect URIs:
https://autofolio.its-live.dev/api/auth/callback/google,http://localhost:3005/api/auth/callback/google
Its client id and secret become
GOOGLE_CLIENT_IDandGOOGLE_CLIENT_SECRET.- Authorized JavaScript origins:
No iOS or Android OAuth client is needed. The app signs in through the server's web client in a
system browser session, so Google only ever sees the web client and its
https://…/callback/google redirect. iOS and Android clients (bundle io.zietec.autofolio;
package io.zietec.autofolio with the EAS signing SHA-1) are for the native Google Sign-In SDK,
which this app does not use. Create them only if that changes; the server would then also have to
accept their ids as ID-token audiences.
Apple
In Apple Developer → Certificates, Identifiers & Profiles, team of the App Store account (its Team ID is at the top right):
App ID
io.zietec.autofolio: enable the Sign in with Apple capability, as a primary App ID.Services ID (Identifiers → + → Services IDs), e.g.
io.zietec.autofolio.signin. Enable Sign in with Apple → Configure: primary App IDio.zietec.autofolio; Domains and Subdomainsautofolio.its-live.dev; Return URLshttps://autofolio.its-live.dev/api/auth/callback/apple. Apple refuseslocalhostand plainhttp: test Apple on the production origin or an HTTPS tunnel registered here too. The Services ID becomesAPPLE_CLIENT_ID.Key (Keys → +): name it, enable Sign in with Apple, Configure → primary App ID
io.zietec.autofolio. DownloadAuthKey_<KEY_ID>.p8(Apple lets you download it once; keep it in the password manager, never in the repo) and note the Key ID.Client secret. Apple takes a JWT signed with that key as the client secret, valid for at most six months. Mint it with
scripts/apple-client-secret.mjs(180 days) and pipe it straight into the Worker secret, so it never lands in a file or the terminal history:node scripts/apple-client-secret.mjs --team-id <TEAM_ID> --key-id <KEY_ID> \ --client-id io.zietec.autofolio.signin --key ~/secure/AuthKey_<KEY_ID>.p8 \ | pnpm --filter web exec wrangler secret put APPLE_CLIENT_SECRET --name autofolio-webRe-run it before the 180 days run out (set a reminder); an expired secret makes every Apple sign-in fail with
invalid_client.Email relay (Services → Sign in with Apple for Email Communication → Configure): register the sending domain
its-live.devand theEMAIL_FROMaddressnoreply@its-live.dev. Without it, mail to a Hide My Email address (…@privaterelay.appleid.com) bounces: verification, sign-in alerts and reminders never arrive.
Worker secrets
The values go to the autofolio-web Worker as secrets. Run from the repository root; each command prompts for the value (or reads it from a pipe, as above). wrangler secret put deploys a new version at once, so the buttons appear as soon as both values of a provider are set.
| Name | Value |
|---|---|
GOOGLE_CLIENT_ID | The web client's id (….apps.googleusercontent.com) |
GOOGLE_CLIENT_SECRET | The web client's secret |
APPLE_CLIENT_ID | The Services ID (io.zietec.autofolio.signin) |
APPLE_CLIENT_SECRET | The JWT from scripts/apple-client-secret.mjs |
APPLE_APP_BUNDLE_IDENTIFIER | io.zietec.autofolio, the audience of a native Apple ID token (not secret) |
pnpm --filter web exec wrangler secret put GOOGLE_CLIENT_ID --name autofolio-web
pnpm --filter web exec wrangler secret put GOOGLE_CLIENT_SECRET --name autofolio-web
pnpm --filter web exec wrangler secret put APPLE_CLIENT_ID --name autofolio-web
pnpm --filter web exec wrangler secret put APPLE_APP_BUNDLE_IDENTIFIER --name autofolio-web
# APPLE_CLIENT_SECRET: the pipe in the Apple steps above.BETTER_AUTH_URL (https://autofolio.its-live.dev) and NATIVE_APP_SCHEME (autofolio) are already plain vars in apps/web/wrangler.jsonc; the callback URLs above derive from the first, the trusted app scheme from the second. Pull request previews get none of these secrets, so they show no social buttons (their origins are not registered with Google or Apple anyway).
Locally, add GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET to apps/web/.env (commented in .env.example) to try Google on http://localhost:3005.
Mobile (EAS)
Nothing to add. The app reads no Apple or Google value: it calls the server at EXPO_PUBLIC_SERVER_URL, already https://autofolio.its-live.dev in the production profile of apps/mobile/eas.json, and asks appConfig.get which providers to show. No EAS environment variable, secret or new binary is needed; the buttons appear in installed builds as soon as the Worker has the secrets. The browser flow needs no Sign in with Apple entitlement in the app.
Check it
curl -s -X POST https://autofolio.its-live.dev/api/rpc/appConfig/get \
-H 'content-type: application/json' -d '{"json":{"platform":"web"}}'socialProviders lists apple and google; /login shows both buttons above the email form, and each opens the provider's consent page. Then sign in once per provider on the web and in the app.
Known limits
- Native Sign in with Apple. On iOS, Apple runs in the system auth session, not the native Apple sheet. If App Review asks for the native sheet, add
expo-apple-authentication(new binary,usesAppleSignIn), pass its ID token asauthClient.signIn.social({ provider: "apple", idToken: { token } }); the server already accepts it throughAPPLE_APP_BUNDLE_IDENTIFIER. - Refusals in the app. The Expo client drops the URL the auth session returns, so the app cannot tell a refusal (
account_not_linked) from a cancel and shows nothing; the web explains it.