@terreno/mcp 57.19.2 → 57.20.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.
- package/CHANGELOG.md +393 -0
- package/README.md +32 -269
- package/dist/bootstrap.d.ts +12 -0
- package/dist/bootstrap.d.ts.map +1 -1
- package/dist/bootstrap.js +77 -122
- package/dist/bootstrap.js.map +1 -1
- package/dist/docs/guidelines/ui/guidelines/core.md +1 -1
- package/dist/docs/resources/overview.md +4 -2
- package/dist/docs/resources/patterns.md +237 -176
- package/dist/docs/resources/ui.md +48 -17
- package/dist/docs/templates/bootstrap/frontend/app/(tabs)/profile.template.tsx +0 -26
- package/dist/docs/templates/bootstrap/frontend/app/_layout.template.tsx +3 -2
- package/dist/docs/templates/bootstrap/frontend/app/login.template.tsx +1 -1
- package/dist/docs/upgrades/57.20.0.md +238 -0
- package/dist/docs/versioned/next/approval-rubric.md +30 -12
- package/dist/docs/versioned/next/explanation/README.md +5 -0
- package/dist/docs/versioned/next/explanation/account-linking.md +169 -0
- package/dist/docs/versioned/next/explanation/admin-interface.md +1 -1
- package/dist/docs/versioned/next/explanation/agent-guidelines.md +173 -0
- package/dist/docs/versioned/next/explanation/ai-development-loop.md +196 -0
- package/dist/docs/versioned/next/explanation/authentication.md +45 -1
- package/dist/docs/versioned/next/explanation/consent-forms.md +105 -0
- package/dist/docs/versioned/next/explanation/example-coverage.md +1 -0
- package/dist/docs/versioned/next/explanation/local-first-data.md +1 -1
- package/dist/docs/versioned/next/explanation/organizations.md +37 -0
- package/dist/docs/versioned/next/explanation/planning-plugins.md +4 -0
- package/dist/docs/versioned/next/explanation/responsive-design.md +143 -0
- package/dist/docs/versioned/next/explanation/roadmap-seed-issues.md +11 -0
- package/dist/docs/versioned/next/explanation/schema-metadata.md +5 -0
- package/dist/docs/versioned/next/how-to/README.md +7 -2
- package/dist/docs/versioned/next/how-to/audit-log.md +22 -3
- package/dist/docs/versioned/next/how-to/automate-terreno-upgrades.md +169 -0
- package/dist/docs/versioned/next/how-to/build-list-detail-screens.md +160 -0
- package/dist/docs/versioned/next/how-to/circleci.md +15 -5
- package/dist/docs/versioned/next/how-to/configure-better-auth.md +673 -53
- package/dist/docs/versioned/next/how-to/create-a-terreno-app.md +8 -5
- package/dist/docs/versioned/next/how-to/debug-with-mcp.md +393 -23
- package/dist/docs/versioned/next/how-to/enable-mcp-oauth.md +54 -3
- package/dist/docs/versioned/next/how-to/migrate-rtk-to-syncdb.md +9 -0
- package/dist/docs/versioned/next/how-to/password-reset.md +1 -1
- package/dist/docs/versioned/next/how-to/recover-locked-out-user.md +112 -0
- package/dist/docs/versioned/next/how-to/run-tests-locally.md +37 -8
- package/dist/docs/versioned/next/how-to/set-up-social-login.md +424 -0
- package/dist/docs/versioned/next/how-to/set-up-terreno-mcp.md +241 -36
- package/dist/docs/versioned/next/how-to/sign-consent-on-behalf.md +139 -0
- package/dist/docs/versioned/next/how-to/upgrade-terreno.md +4 -0
- package/dist/docs/versioned/next/implementationPlans/ai-dev-loop-boost.md +47 -32
- package/dist/docs/versioned/next/implementationPlans/auth-experience.md +952 -0
- package/dist/docs/versioned/next/implementationPlans/consent-forms.md +8 -0
- package/dist/docs/versioned/next/implementationPlans/list-detail-layout.md +297 -0
- package/dist/docs/versioned/next/implementationPlans/login-redesign/HANDOFF.md +211 -0
- package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/AnotherWay.html +121 -0
- package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/BackupCode.html +72 -0
- package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/Cancelled.html +87 -0
- package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/ContinueEmail.html +64 -0
- package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/FirstVisit.html +81 -0
- package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/Main.html +88 -0
- package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/ManageAccounts.html +77 -0
- package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/NotLinked.html +92 -0
- package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/SigningIn.html +60 -0
- package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/TwoFactor.html +76 -0
- package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/TwoFactorError.html +81 -0
- package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/WrongPassword.html +69 -0
- package/dist/docs/versioned/next/implementationPlans/mcp-boost-parity.md +1 -1
- package/dist/docs/versioned/next/plans/2026-10-23-provisional-consent.md +297 -0
- package/dist/docs/versioned/next/plans/2026-10-29-fix-mcp-oauth-native-and-machine.md +68 -0
- package/dist/docs/versioned/next/reference/README.md +1 -0
- package/dist/docs/versioned/next/reference/account-security.md +700 -0
- package/dist/docs/versioned/next/reference/admin-backend.md +3 -1
- package/dist/docs/versioned/next/reference/admin-frontend.md +225 -1
- package/dist/docs/versioned/next/reference/api.md +461 -35
- package/dist/docs/versioned/next/reference/cli.md +1 -1
- package/dist/docs/versioned/next/reference/comms.md +8 -0
- package/dist/docs/versioned/next/reference/create-terreno-app.md +3 -1
- package/dist/docs/versioned/next/reference/environment-variables.md +9 -3
- package/dist/docs/versioned/next/reference/legacy/rtk.md +19 -3
- package/dist/docs/versioned/next/reference/lifecycle-plugin.md +6 -0
- package/dist/docs/versioned/next/reference/mcp-server.md +1158 -441
- package/dist/docs/versioned/next/reference/syncdb.md +8 -1
- package/dist/docs/versioned/next/reference/ui.md +1271 -27
- package/dist/docs/versioned/next/tasks/ai-dev-loop-boost.md +28 -16
- package/dist/docs/versioned/next/tutorials/README.md +4 -0
- package/dist/httpApp.d.ts +14 -0
- package/dist/httpApp.d.ts.map +1 -0
- package/dist/httpApp.js +99 -0
- package/dist/httpApp.js.map +1 -0
- package/dist/index.js +8 -90
- package/dist/index.js.map +1 -1
- package/dist/local/localTools.d.ts.map +1 -1
- package/dist/local/localTools.js +44 -14
- package/dist/local/localTools.js.map +1 -1
- package/dist/local/metro/metroDevSession.d.ts +18 -0
- package/dist/local/metro/metroDevSession.d.ts.map +1 -1
- package/dist/local/metro/metroDevSession.js +42 -3
- package/dist/local/metro/metroDevSession.js.map +1 -1
- package/dist/local/tools/databaseQuery.d.ts.map +1 -1
- package/dist/local/tools/databaseQuery.js +3 -13
- package/dist/local/tools/databaseQuery.js.map +1 -1
- package/dist/local/tools/databaseSchema.d.ts.map +1 -1
- package/dist/local/tools/databaseSchema.js +3 -13
- package/dist/local/tools/databaseSchema.js.map +1 -1
- package/dist/local/tools/localMongo.d.ts +10 -0
- package/dist/local/tools/localMongo.d.ts.map +1 -0
- package/dist/local/tools/localMongo.js +30 -0
- package/dist/local/tools/localMongo.js.map +1 -0
- package/dist/local/tools/runtime.d.ts +18 -2
- package/dist/local/tools/runtime.d.ts.map +1 -1
- package/dist/local/tools/runtime.js +75 -20
- package/dist/local/tools/runtime.js.map +1 -1
- package/dist/local/tools/syncDb.d.ts +11 -0
- package/dist/local/tools/syncDb.d.ts.map +1 -1
- package/dist/local/tools/syncDb.js +15 -3
- package/dist/local/tools/syncDb.js.map +1 -1
- package/dist/rateLimit.d.ts +35 -0
- package/dist/rateLimit.d.ts.map +1 -0
- package/dist/rateLimit.js +112 -0
- package/dist/rateLimit.js.map +1 -0
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +42 -60
- package/dist/tools.js.map +1 -1
- package/package.json +4 -3
- package/src/docs/guidelines/ui/guidelines/core.md +1 -1
- package/src/docs/resources/overview.md +4 -2
- package/src/docs/resources/patterns.md +237 -176
- package/src/docs/resources/ui.md +48 -17
- package/src/docs/templates/bootstrap/frontend/app/(tabs)/profile.template.tsx +0 -26
- package/src/docs/templates/bootstrap/frontend/app/_layout.template.tsx +3 -2
- package/src/docs/templates/bootstrap/frontend/app/login.template.tsx +1 -1
- package/src/docs/upgrades/57.20.0.md +238 -0
- package/src/docs/versioned/next/approval-rubric.md +30 -12
- package/src/docs/versioned/next/explanation/README.md +5 -0
- package/src/docs/versioned/next/explanation/account-linking.md +169 -0
- package/src/docs/versioned/next/explanation/admin-interface.md +1 -1
- package/src/docs/versioned/next/explanation/agent-guidelines.md +173 -0
- package/src/docs/versioned/next/explanation/ai-development-loop.md +196 -0
- package/src/docs/versioned/next/explanation/authentication.md +45 -1
- package/src/docs/versioned/next/explanation/consent-forms.md +105 -0
- package/src/docs/versioned/next/explanation/example-coverage.md +1 -0
- package/src/docs/versioned/next/explanation/local-first-data.md +1 -1
- package/src/docs/versioned/next/explanation/organizations.md +37 -0
- package/src/docs/versioned/next/explanation/planning-plugins.md +4 -0
- package/src/docs/versioned/next/explanation/responsive-design.md +143 -0
- package/src/docs/versioned/next/explanation/roadmap-seed-issues.md +11 -0
- package/src/docs/versioned/next/explanation/schema-metadata.md +5 -0
- package/src/docs/versioned/next/how-to/README.md +7 -2
- package/src/docs/versioned/next/how-to/audit-log.md +22 -3
- package/src/docs/versioned/next/how-to/automate-terreno-upgrades.md +169 -0
- package/src/docs/versioned/next/how-to/build-list-detail-screens.md +160 -0
- package/src/docs/versioned/next/how-to/circleci.md +15 -5
- package/src/docs/versioned/next/how-to/configure-better-auth.md +673 -53
- package/src/docs/versioned/next/how-to/create-a-terreno-app.md +8 -5
- package/src/docs/versioned/next/how-to/debug-with-mcp.md +393 -23
- package/src/docs/versioned/next/how-to/enable-mcp-oauth.md +54 -3
- package/src/docs/versioned/next/how-to/migrate-rtk-to-syncdb.md +9 -0
- package/src/docs/versioned/next/how-to/password-reset.md +1 -1
- package/src/docs/versioned/next/how-to/recover-locked-out-user.md +112 -0
- package/src/docs/versioned/next/how-to/run-tests-locally.md +37 -8
- package/src/docs/versioned/next/how-to/set-up-social-login.md +424 -0
- package/src/docs/versioned/next/how-to/set-up-terreno-mcp.md +241 -36
- package/src/docs/versioned/next/how-to/sign-consent-on-behalf.md +139 -0
- package/src/docs/versioned/next/how-to/upgrade-terreno.md +4 -0
- package/src/docs/versioned/next/implementationPlans/ai-dev-loop-boost.md +47 -32
- package/src/docs/versioned/next/implementationPlans/auth-experience.md +952 -0
- package/src/docs/versioned/next/implementationPlans/consent-forms.md +8 -0
- package/src/docs/versioned/next/implementationPlans/list-detail-layout.md +297 -0
- package/src/docs/versioned/next/implementationPlans/login-redesign/HANDOFF.md +211 -0
- package/src/docs/versioned/next/implementationPlans/login-redesign/boards/AnotherWay.html +121 -0
- package/src/docs/versioned/next/implementationPlans/login-redesign/boards/BackupCode.html +72 -0
- package/src/docs/versioned/next/implementationPlans/login-redesign/boards/Cancelled.html +87 -0
- package/src/docs/versioned/next/implementationPlans/login-redesign/boards/ContinueEmail.html +64 -0
- package/src/docs/versioned/next/implementationPlans/login-redesign/boards/FirstVisit.html +81 -0
- package/src/docs/versioned/next/implementationPlans/login-redesign/boards/Main.html +88 -0
- package/src/docs/versioned/next/implementationPlans/login-redesign/boards/ManageAccounts.html +77 -0
- package/src/docs/versioned/next/implementationPlans/login-redesign/boards/NotLinked.html +92 -0
- package/src/docs/versioned/next/implementationPlans/login-redesign/boards/SigningIn.html +60 -0
- package/src/docs/versioned/next/implementationPlans/login-redesign/boards/TwoFactor.html +76 -0
- package/src/docs/versioned/next/implementationPlans/login-redesign/boards/TwoFactorError.html +81 -0
- package/src/docs/versioned/next/implementationPlans/login-redesign/boards/WrongPassword.html +69 -0
- package/src/docs/versioned/next/implementationPlans/mcp-boost-parity.md +1 -1
- package/src/docs/versioned/next/plans/2026-10-23-provisional-consent.md +297 -0
- package/src/docs/versioned/next/plans/2026-10-29-fix-mcp-oauth-native-and-machine.md +68 -0
- package/src/docs/versioned/next/reference/README.md +1 -0
- package/src/docs/versioned/next/reference/account-security.md +700 -0
- package/src/docs/versioned/next/reference/admin-backend.md +3 -1
- package/src/docs/versioned/next/reference/admin-frontend.md +225 -1
- package/src/docs/versioned/next/reference/api.md +461 -35
- package/src/docs/versioned/next/reference/cli.md +1 -1
- package/src/docs/versioned/next/reference/comms.md +8 -0
- package/src/docs/versioned/next/reference/create-terreno-app.md +3 -1
- package/src/docs/versioned/next/reference/environment-variables.md +9 -3
- package/src/docs/versioned/next/reference/legacy/rtk.md +19 -3
- package/src/docs/versioned/next/reference/lifecycle-plugin.md +6 -0
- package/src/docs/versioned/next/reference/mcp-server.md +1158 -441
- package/src/docs/versioned/next/reference/syncdb.md +8 -1
- package/src/docs/versioned/next/reference/ui.md +1271 -27
- package/src/docs/versioned/next/tasks/ai-dev-loop-boost.md +28 -16
- package/src/docs/versioned/next/tutorials/README.md +4 -0
|
@@ -93,14 +93,24 @@ const buildBetterAuthConfig = (): BetterAuthConfig | undefined => {
|
|
|
93
93
|
``````
|
|
94
94
|
|
|
95
95
|
Set `publicAppUrl`, `sendMail`, and `renderAuthMail` from `@terreno/comms` so password-reset and
|
|
96
|
-
email-verification mail match the JWT recovery templates.
|
|
96
|
+
email-verification mail match the JWT recovery templates. With `AccountSecurityApp` registered,
|
|
97
|
+
`sendMail`, `publicAppUrl`, and `authMailTemplates` also send security notification emails, which
|
|
98
|
+
use their own renderer rather than `renderAuthMail` (see
|
|
99
|
+
[Account security: Notifications](../reference/account-security.md#notifications)). The hooks throw 501 and do not send when
|
|
97
100
|
`publicAppUrl` is missing. JWT `POST /auth/resetPassword` updates the Better Auth password and
|
|
98
|
-
deletes Better Auth sessions when this plugin is registered.
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
`
|
|
103
|
-
|
|
101
|
+
deletes Better Auth sessions when this plugin is registered. With this plugin registered, the JWT
|
|
102
|
+
recovery routes only replace an existing JWT password: `POST /auth/forgotPassword` mails no link
|
|
103
|
+
to an app User without one (it still answers 202), and `POST /auth/resetPassword` refuses that
|
|
104
|
+
User's token with 400 `legacy-password-reset-unavailable`. Such an account resets through Better
|
|
105
|
+
Auth (`request-password-reset` / `reset-password`). Better Auth password reset updates
|
|
106
|
+
the JWT password of a matching app User that already has one, so dual-enrolled accounts cannot
|
|
107
|
+
keep the old passport hash, and bumps `tokenEpoch` for every matching app User. Terreno reads the new password from Better Auth's parsed request body, so
|
|
108
|
+
this works over HTTP (`POST {basePath}/reset-password`). Better Auth `change-password` does the
|
|
109
|
+
same for an app User that already has a JWT password, and bumps `tokenEpoch` for every app User.
|
|
110
|
+
`revoke-sessions` and `revoke-other-sessions` bump `tokenEpoch`, so JWT refresh tokens issued
|
|
111
|
+
before them stop working. An account with only a Better Auth password does not gain a JWT
|
|
112
|
+
password, and neither does a social-only account that resets its password or adds its first one
|
|
113
|
+
with `terreno/set-password`: `POST /auth/login` has no second factor.
|
|
104
114
|
|
|
105
115
|
```typescript
|
|
106
116
|
import {getCommsService, renderAuthMail} from "@terreno/comms";
|
|
@@ -129,8 +139,9 @@ Better Auth enables its limiter in production, which 429s the fourth seeded user
|
|
|
129
139
|
optional. It is deep-merged over the options Terreno builds. Arrays (such as `trustedOrigins`)
|
|
130
140
|
replace Terreno's instead of merging. An explicit value wins; an `undefined` value is ignored,
|
|
131
141
|
so it cannot clear a default.
|
|
132
|
-
- `plugins` are appended after Terreno's `bearer()`
|
|
133
|
-
`basePath`, like the
|
|
142
|
+
- `plugins` are appended after Terreno's `bearer()`, `terreno-methods`, and feature plugins
|
|
143
|
+
(see [Auth features](#auth-features)). Their endpoints mount under `basePath`, like the
|
|
144
|
+
built-in ones.
|
|
134
145
|
|
|
135
146
|
``````typescript
|
|
136
147
|
const config: BetterAuthConfig = {
|
|
@@ -159,12 +170,158 @@ const config: BetterAuthConfig = {
|
|
|
159
170
|
| `session.cookieCache` | `{enabled: true, maxAge: 300}` | The 5-minute session cookie cache |
|
|
160
171
|
| `advanced.defaultCookieAttributes` | `crossDomainCookies` | `SameSite=None; Secure` cookies for cross-site frontends |
|
|
161
172
|
| `rateLimit` | `disableRateLimit` | Turning Better Auth's limiter off |
|
|
162
|
-
| `socialProviders` | `googleOAuth`, `githubOAuth`, `appleOAuth` | The configured social providers (`options` adds to them per provider) |
|
|
173
|
+
| `socialProviders` | `googleOAuth`, `githubOAuth`, `appleOAuth`, `signUp` | The configured social providers (`options` adds to them per provider) |
|
|
174
|
+
| `account.accountLinking` | `accountLinking` | The linking mode Terreno maps (`options` wins field by field) |
|
|
175
|
+
| `emailAndPassword.disableSignUp` | `signUp` | Closed email sign-up |
|
|
163
176
|
| `trustedOrigins` | `trustedOrigins` | The origins listed in the config |
|
|
164
177
|
|
|
165
178
|
Endpoints added by `plugins` are not part of the `auth.api` type. Call them over HTTP, or cast
|
|
166
179
|
`auth.api` where you call them on the server.
|
|
167
180
|
|
|
181
|
+
#### Auth features
|
|
182
|
+
|
|
183
|
+
Switch auth features on with these `BetterAuthConfig` fields instead of adding Better Auth
|
|
184
|
+
plugins yourself. Every feature is off until you set its field. Terreno adds the plugins after
|
|
185
|
+
`bearer()` and `terreno-methods` and before `config.plugins`, so your own plugins still run
|
|
186
|
+
after them. `GET {basePath}/terreno/methods` reports `twoFactor`, `passkey`, `multiSession`,
|
|
187
|
+
`signUp`, and `accountLinking.mode` from the final configuration.
|
|
188
|
+
|
|
189
|
+
| Field | Maps to | Default |
|
|
190
|
+
| --- | --- | --- |
|
|
191
|
+
| `accountLinking: {mode, trustedProviders?}` | `account.accountLinking`: `verified-email` = linking on; `explicit` = `disableImplicitLinking: true`; `never` = `enabled: false`. `requireLocalEmailVerified` is `true` in every mode. `trustedProviders` skip the verified-email check, including on `/link-social`: list only providers that prove email ownership | Better Auth's default (`verified-email`) |
|
|
192
|
+
| `twoFactor: {issuer, backupCodes?: {amount?, length?}, trustDeviceDays?}` | `twoFactor()` from `better-auth/plugins` (TOTP + backup codes; adds the `twoFactor` collection and `user.twoFactorEnabled`) | Off. When on: 10 backup codes of 10 characters, 30 trusted days |
|
|
193
|
+
| `passkey: {rpID, rpName, origin: string[]}` | `passkey()` from `@better-auth/passkey` (adds the `passkey` collection; registering needs a session). Each origin must be a bare origin on `rpID` or a subdomain | Off |
|
|
194
|
+
| `signUp: {mode: "open" \| "closed"}` | `closed`: `emailAndPassword.disableSignUp`, plus `disableSignUp` and `disableImplicitSignUp` on every social and `genericOAuth` provider (not on other sign-in plugins) | `open` |
|
|
195
|
+
| `lastLoginMethod: true` | `lastLoginMethod({storeInDatabase: true})` (a cookie plus `user.lastLoginMethod`) | Off |
|
|
196
|
+
| `multiSession: {maximumSessions?}` | `multiSession()` | Off. When on: 5 sessions |
|
|
197
|
+
| `oauthProxy: {productionURL}` | `oAuthProxy()`. Turn it on for previews and for the production server (naming itself), with one shared `secret`. When `productionURL` is on the server's own `baseURL` origin, Terreno also sets `currentURL`, so the production server's own sign-ins skip the proxy and keep the state-cookie check. See [Set up social login](set-up-social-login.md#how-previews-sign-in) | Off |
|
|
198
|
+
| `nativeApps: true` | `expo()` from `@better-auth/expo` (`GET {basePath}/expo-authorization-proxy` for native social sign-in) | Off |
|
|
199
|
+
| `appleOAuth: {clientId, teamId, keyId, privateKey, appBundleIdentifier?}` | `socialProviders.apple`, with the ES256 client secret signed from the key at startup | The `{clientId, clientSecret}` form still works |
|
|
200
|
+
|
|
201
|
+
```typescript
|
|
202
|
+
const config: BetterAuthConfig = {
|
|
203
|
+
enabled: true,
|
|
204
|
+
accountLinking: {mode: "explicit"},
|
|
205
|
+
lastLoginMethod: true,
|
|
206
|
+
multiSession: {maximumSessions: 3},
|
|
207
|
+
passkey: {origin: ["https://app.example.com"], rpID: "example.com", rpName: "Example"},
|
|
208
|
+
signUp: {mode: "closed"},
|
|
209
|
+
twoFactor: {issuer: "Example", trustDeviceDays: 14},
|
|
210
|
+
};
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
Notes:
|
|
214
|
+
|
|
215
|
+
- **Closed sign-up.** `POST {basePath}/sign-up/email` answers 400, and a social or generic
|
|
216
|
+
OAuth sign-in for an unknown email redirects to the error URL with `error=signup_disabled`
|
|
217
|
+
(an ID-token sign-in answers 401 `signup disabled`). A client's `requestSignUp: true` does not
|
|
218
|
+
reopen it. Terreno reaches providers registered through `config.plugins` (such as
|
|
219
|
+
`genericOAuth`) with a `terreno-sign-up-closed` plugin that runs after them. Closed mode also
|
|
220
|
+
refuses `auth.api.signUpEmail` on the same instance, so seed users with a separate instance
|
|
221
|
+
that leaves `signUp` unset.
|
|
222
|
+
- It covers email and password and OAuth providers only. Other sign-in plugins you add
|
|
223
|
+
through `plugins` (`magicLink`, `emailOTP`, `phoneNumber`, `anonymous`, ...) create users on
|
|
224
|
+
their own terms: set their own `disableSignUp` (or equivalent) option too.
|
|
225
|
+
- With `oauthProxy`, the production server finishes a preview's OAuth sign-in and decides
|
|
226
|
+
whether it may create a user, so the preview follows production's sign-up mode. Give both
|
|
227
|
+
the same `signUp.mode`.
|
|
228
|
+
- **Trusted providers.** Better Auth skips the verified-email check for `trustedProviders` on
|
|
229
|
+
sign-in, on an explicit `/link-social`, and on its callback. List only providers that prove
|
|
230
|
+
the user owns the email they report. A provider that lets users set any email lets an
|
|
231
|
+
attacker link into someone else's account.
|
|
232
|
+
- **Passkey origins.** `createBetterAuth` throws `passkey config is invalid` unless `origin`
|
|
233
|
+
lists at least one bare `http(s)` origin (no path or trailing slash) whose host is `rpID` or
|
|
234
|
+
a subdomain of it.
|
|
235
|
+
- **Apple key.** `privateKey` is the `.p8` file's PEM contents; escaped `\n` sequences from an env
|
|
236
|
+
var are turned back into newlines. The signed secret is valid for 180 days, so redeploy at
|
|
237
|
+
least that often. Web sign-in needs `https://appleid.apple.com` in `trustedOrigins`, since
|
|
238
|
+
Apple posts the callback from there.
|
|
239
|
+
- **Schema.** Better Auth stores `twoFactorEnabled` and `lastLoginMethod` on its own `user`
|
|
240
|
+
collection, not on your app `User` model, so a `strict: "throw"` User schema needs no new
|
|
241
|
+
fields.
|
|
242
|
+
- **Rate limits.** With `TerrenoApp` `rateLimit` on, `{basePath}/two-factor/*`,
|
|
243
|
+
`/passkey/*`, `/link-social`, `/unlink-account`, `/multi-session/*`,
|
|
244
|
+
`/oauth-proxy-callback`, and `/expo-authorization-proxy` count against the `auth` bucket
|
|
245
|
+
(oauth-proxy's `/callback/:id/oauth-proxy` is under `/callback/*`, which already does).
|
|
246
|
+
- **Linking.** `never` (or `options.account.accountLinking.enabled: false`) adds a
|
|
247
|
+
`terreno-linking-disabled` plugin that refuses `POST {basePath}/link-social` with 403
|
|
248
|
+
`LINKING_DISABLED`. In every mode an unverified sign-up never claims an existing app `User`
|
|
249
|
+
with the same email (409 `VERIFY_EMAIL_TO_USE_ACCOUNT`). See
|
|
250
|
+
[Account linking](../explanation/account-linking.md).
|
|
251
|
+
- `options` still wins: for example `options.account.accountLinking.enabled` overrides the
|
|
252
|
+
mode `accountLinking` sets.
|
|
253
|
+
|
|
254
|
+
#### Auth features in example-backend
|
|
255
|
+
|
|
256
|
+
example-backend (`src/utils/betterAuthConfig.ts`) turns every feature on; `oauthProxy` follows
|
|
257
|
+
`OAUTH_PROXY_PRODUCTION_URL`, which only deploys set. These env vars tune it:
|
|
258
|
+
|
|
259
|
+
| Env var | Sets | Default |
|
|
260
|
+
| --- | --- | --- |
|
|
261
|
+
| `AUTH_ACCOUNT_LINKING` | `accountLinking.mode`: `verified-email`, `explicit`, or `never` | `verified-email` |
|
|
262
|
+
| `AUTH_SIGN_UP` | `signUp.mode`: `open` or `closed` | `open` |
|
|
263
|
+
| `FRONTEND_URL` | The first `passkey.origin` (its origin) and the default `passkey.rpID` (its host); also the auth mail links | `http://localhost:8082` |
|
|
264
|
+
| `PASSKEY_RP_ID` | `passkey.rpID`. Must be the `FRONTEND_URL` host or a parent domain of it, such as `example.com` for `https://app.example.com` | The `FRONTEND_URL` host |
|
|
265
|
+
| `OAUTH_PROXY_PRODUCTION_URL` | `oauthProxy.productionURL` (its origin): the stable backend that owns the registered OAuth callbacks | Unset (off) |
|
|
266
|
+
| `APPLE_CLIENT_ID` with `APPLE_TEAM_ID`, `APPLE_KEY_ID`, `APPLE_PRIVATE_KEY` | `appleOAuth` in the key form, and `https://appleid.apple.com` in `trustedOrigins` | Unset (Apple off) |
|
|
267
|
+
| `APPLE_CLIENT_ID` with `APPLE_CLIENT_SECRET` | `appleOAuth` in the client-secret form, used when the key form is incomplete | Unset |
|
|
268
|
+
|
|
269
|
+
The rest is fixed: `twoFactor.issuer` and `passkey.rpName` are `Terreno Example`, and
|
|
270
|
+
`lastLoginMethod`, `multiSession` (5 sessions) and `nativeApps` are on. Email verification
|
|
271
|
+
stays optional at sign-up; linking by email needs it.
|
|
272
|
+
|
|
273
|
+
`passkey.origin` is the `FRONTEND_URL` origin plus each `CORS_ORIGINS` (and dev) origin whose
|
|
274
|
+
host is the rpID or one of its subdomains, because a browser refuses a passkey on any other
|
|
275
|
+
host. `localhost` and `127.0.0.1` cannot share an rpID, so with the default `FRONTEND_URL`
|
|
276
|
+
passkeys work on `http://localhost:8082` and not on `http://127.0.0.1:8082`. Set
|
|
277
|
+
`FRONTEND_URL=http://127.0.0.1:8082` to use the loopback address instead. On a deploy where the
|
|
278
|
+
web app is `app.<domain>`, set `PASSKEY_RP_ID=<domain>` so a passkey works on every subdomain.
|
|
279
|
+
A passkey made under one rpID cannot be used under another.
|
|
280
|
+
|
|
281
|
+
With `NODE_ENV=production` and a loopback `FRONTEND_URL` (the default when a deploy does not set
|
|
282
|
+
it), example-backend leaves `passkey` out, logs one warning, and `/terreno/methods` reports
|
|
283
|
+
`passkey: false`: a passkey bound to `localhost` can never work for a deployed web app.
|
|
284
|
+
|
|
285
|
+
The seed (`seedBetterAuthUserInProcess`) and the e2e setup (`e2e/auth.setup.ts`) mark their
|
|
286
|
+
users' Better Auth email verified, so `verified-email` linking treats them as the email's
|
|
287
|
+
owner. The seed still works with `AUTH_SIGN_UP=closed`: it builds its own instance with sign-up
|
|
288
|
+
open.
|
|
289
|
+
|
|
290
|
+
#### Auth features in generated apps
|
|
291
|
+
|
|
292
|
+
[create-terreno-app](../reference/create-terreno-app.md) generates the same suite in
|
|
293
|
+
`backend/src/utils/betterAuthConfig.ts`, with the same env vars (`AUTH_ACCOUNT_LINKING`,
|
|
294
|
+
`AUTH_SIGN_UP`, `FRONTEND_URL`, `PASSKEY_RP_ID`) and the same production rule for passkeys on a
|
|
295
|
+
loopback `FRONTEND_URL`, and `getWebOrigins` adds the `FRONTEND_URL` origin to CORS and
|
|
296
|
+
`trustedOrigins`. It differs from example-backend in five ways:
|
|
297
|
+
|
|
298
|
+
- `twoFactor.issuer` and `passkey.rpName` are the app's display name (`APP_NAME`), and
|
|
299
|
+
`twoFactor.trustDeviceDays` is 30, matching the login screen's `trustDeviceDays`.
|
|
300
|
+
- It configures no social providers and no `oauthHandoff`: the web client stays in cookie mode.
|
|
301
|
+
Add providers as in [OAuth Provider Setup](#oauth-provider-setup); the login and security
|
|
302
|
+
screens show them once `/terreno/methods` lists them.
|
|
303
|
+
- `server.ts` registers `AuditApp` and then `AccountSecurityApp({accessControl: access})`, so
|
|
304
|
+
operators get the recovery routes at `/users/:id/security/*`.
|
|
305
|
+
- The seed builds its own instance with `signUp` unset, so `AUTH_SIGN_UP=closed` still seeds.
|
|
306
|
+
- It sets no `sendMail`. Until the app passes `@terreno/comms` `sendMail` and `renderAuthMail`
|
|
307
|
+
(as example-backend does), nothing that sends mail works: forgot-password, the operator
|
|
308
|
+
recovery actions `send-password-reset` and `issue-sign-in-link` (they answer 501), any flow
|
|
309
|
+
that depends on a reset or set-password link, and
|
|
310
|
+
[security notification emails](../reference/account-security.md#notifications).
|
|
311
|
+
|
|
312
|
+
On the frontend of a generated app (not the example app, whose screen is described under
|
|
313
|
+
[Linking in cookie mode on web](#6-drop-in-security-screen)), `lib/betterAuth.ts` passes
|
|
314
|
+
`features: {lastLoginMethod, multiSession, passkey, twoFactor}` and exports
|
|
315
|
+
`rememberedAccounts` with `rememberIdentity: false`, so the device keeps only how each account
|
|
316
|
+
signed in. `app/login.tsx` renders the [drop-in login screen](#5-drop-in-login-screen) with
|
|
317
|
+
`callbackURL="/login"`, and `app/settings/security.tsx` renders the
|
|
318
|
+
[drop-in security screen](#6-drop-in-security-screen) with the bare path
|
|
319
|
+
`callbackURL="/settings/security"` (the example app passes a full URL on web instead). The
|
|
320
|
+
generated Profile tab's **Security** button (`profile-security-button`; the example's is
|
|
321
|
+
`profile-security-link`) opens it, and passwords are changed there: the Profile tab edits only
|
|
322
|
+
name and email. Its email field changes the app `User`'s email through `PATCH /auth/me`, not the
|
|
323
|
+
Better Auth sign-in email, so the user keeps signing in with the old one.
|
|
324
|
+
|
|
168
325
|
### 3. Register BetterAuthApp Plugin
|
|
169
326
|
|
|
170
327
|
Use the TerrenoApp plugin system:
|
|
@@ -242,6 +399,54 @@ safe methods, and clients that send no `Origin`, `Referer`, or cross-site `Sec-F
|
|
|
242
399
|
(native apps, server scripts) are not affected. Better Auth's `advanced.disableCSRFCheck` turns
|
|
243
400
|
the check off. A `trustedOrigins` function is not consulted; list the origins.
|
|
244
401
|
|
|
402
|
+
#### Client features
|
|
403
|
+
|
|
404
|
+
When the server turns on [auth features](#auth-features), pass `features` so the client gets
|
|
405
|
+
the matching Better Auth client plugins and their typed methods:
|
|
406
|
+
|
|
407
|
+
``````typescript
|
|
408
|
+
import {router} from "expo-router";
|
|
409
|
+
import {createBetterAuthClient} from "@terreno/rtk";
|
|
410
|
+
|
|
411
|
+
export const authClient = createBetterAuthClient({
|
|
412
|
+
baseURL: process.env.EXPO_PUBLIC_API_URL || "http://localhost:4000",
|
|
413
|
+
features: {
|
|
414
|
+
lastLoginMethod: true,
|
|
415
|
+
multiSession: true,
|
|
416
|
+
passkey: true,
|
|
417
|
+
twoFactor: {onTwoFactorRedirect: () => router.push("/twoFactor")},
|
|
418
|
+
},
|
|
419
|
+
scheme: "yourapp",
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
await authClient.twoFactor.enable({password});
|
|
423
|
+
await authClient.passkey.addPasskey({name: "Laptop"});
|
|
424
|
+
const sessions = await authClient.multiSession.listDeviceSessions();
|
|
425
|
+
``````
|
|
426
|
+
|
|
427
|
+
| Feature | Client plugin | Adds |
|
|
428
|
+
| --- | --- | --- |
|
|
429
|
+
| `twoFactor: true \| {onTwoFactorRedirect}` | `twoFactorClient` | `client.twoFactor.*` (`enable`, `disable`, `getTotpUri`, `verifyTotp`, `generateBackupCodes`, `verifyBackupCode`, ...). `onTwoFactorRedirect({twoFactorMethods})` runs when a sign-in answers `twoFactorRedirect` instead of a session. In [bearer mode](#frontend-and-api-on-different-sites) the 2FA challenge and trusted-device value travel in the `X-Terreno-Auth-Challenge` header (see [Challenges without cookies](../explanation/authentication.md#challenges-without-cookies)) |
|
|
430
|
+
| `passkey: true` | `passkeyClient` from `@better-auth/passkey/client` | `client.passkey.*` (`addPasskey`, `listUserPasskeys`, `deletePasskey`, `updatePasskey`) and `client.signIn.passkey()`. Web only: React Native has no WebAuthn. In bearer mode the passkey challenge travels in the `X-Terreno-Auth-Challenge` header |
|
|
431
|
+
| `multiSession: true` | `multiSessionClient` | `client.multiSession.*` (`listDeviceSessions`, `setActive`, `revoke`). Not in bearer mode: the server tracks device sessions in `session_token_multi-*` cookies, so `listDeviceSessions` answers `[]`; use [remembered accounts](#4-remember-accounts-on-this-device) for switching there |
|
|
432
|
+
| `lastLoginMethod: true` | `lastLoginMethodClient` | `client.getLastUsedLoginMethod()`, `isLastUsedLoginMethod(method)`, `clearLastUsedLoginMethod()`. They read the server's cookie, so they return `null` on native and in [bearer mode](#frontend-and-api-on-different-sites); use [remembered accounts](#4-remember-accounts-on-this-device) there |
|
|
433
|
+
| `oneTimeToken: true` | `oneTimeTokenClient` | `client.oneTimeToken.generate()` and `verify({token})`. The OAuth handoff does not need it |
|
|
434
|
+
|
|
435
|
+
Notes:
|
|
436
|
+
|
|
437
|
+
- **Types.** Each enabled feature's namespace is typed from the plugin, with no casts, when
|
|
438
|
+
`features` is written out at the call. Without `features`, or when the config's type does not
|
|
439
|
+
say which features are on (a config typed `BetterAuthClientConfig`, or a wrapper that forwards
|
|
440
|
+
`features?: BetterAuthClientFeatures`), the client is `BetterAuthClient`, exactly what it was
|
|
441
|
+
before, and `ReturnType<typeof createBetterAuthClient>` is still `BetterAuthClient`. `false`
|
|
442
|
+
or a missing key adds nothing; a `boolean` read at runtime (`{twoFactor: isOn}`) is typed as
|
|
443
|
+
on. Name the typed client with `BetterAuthClientWithFeatures<{twoFactor: true}>`.
|
|
444
|
+
- **Native.** The feature plugins go after the Expo plugin. Better-fetch runs plugin hooks in
|
|
445
|
+
order, so on native the Expo plugin stores the 2FA challenge cookie before
|
|
446
|
+
`onTwoFactorRedirect` runs, and every feature call sends the stored cookies.
|
|
447
|
+
- **Server first.** A client feature only adds client methods. Turn the feature on in
|
|
448
|
+
`BetterAuthConfig` too, or its calls answer 404.
|
|
449
|
+
|
|
245
450
|
### 2. Add Session Redux Slice
|
|
246
451
|
|
|
247
452
|
``````typescript
|
|
@@ -310,6 +515,350 @@ const LoginScreen = () => {
|
|
|
310
515
|
};
|
|
311
516
|
``````
|
|
312
517
|
|
|
518
|
+
### 4. Remember accounts on this device
|
|
519
|
+
|
|
520
|
+
The login screen can show how each person signed in last and offer a "Continue as" card per
|
|
521
|
+
account. `createRememberedAccountsStore` keeps that list on the device. It is not a session and
|
|
522
|
+
holds no credential: a remembered account still signs in with its method. Use it alongside
|
|
523
|
+
Better Auth's `lastLoginMethod`, which reads a backend cookie that a cross-site web page cannot
|
|
524
|
+
see.
|
|
525
|
+
|
|
526
|
+
``````typescript
|
|
527
|
+
import {createRememberedAccountsStore, toLoginAccountOptions, useRememberedAccounts} from "@terreno/rtk";
|
|
528
|
+
import {LoginScreen} from "@terreno/ui";
|
|
529
|
+
|
|
530
|
+
// One store per app, at module scope.
|
|
531
|
+
export const rememberedAccounts = createRememberedAccountsStore({rememberIdentity: false});
|
|
532
|
+
|
|
533
|
+
// After a successful sign-in, record the user and the method they used:
|
|
534
|
+
// a provider id ("google", "mock-oidc"), "email", or "passkey".
|
|
535
|
+
await rememberedAccounts.record({
|
|
536
|
+
email: user.email, // ignored unless rememberIdentity is true
|
|
537
|
+
image: user.image,
|
|
538
|
+
method: "google",
|
|
539
|
+
name: user.name,
|
|
540
|
+
userId: user.id,
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
const Login = (): React.ReactElement => {
|
|
544
|
+
const {accounts, lastUsedMethod, forget} = useRememberedAccounts(rememberedAccounts);
|
|
545
|
+
return (
|
|
546
|
+
<LoginScreen
|
|
547
|
+
lastUsedMethod={lastUsedMethod}
|
|
548
|
+
onForget={(account) => forget(account.id)}
|
|
549
|
+
rememberedAccounts={toLoginAccountOptions(accounts)}
|
|
550
|
+
// ...fields, providers, onContinue
|
|
551
|
+
/>
|
|
552
|
+
);
|
|
553
|
+
};
|
|
554
|
+
``````
|
|
555
|
+
|
|
556
|
+
What is stored:
|
|
557
|
+
|
|
558
|
+
| Field | Stored when | Notes |
|
|
559
|
+
| --- | --- | --- |
|
|
560
|
+
| `userId`, `method`, `lastUsedAt` | Always | `lastUsedAt` is an ISO UTC timestamp |
|
|
561
|
+
| `name` | `rememberIdentity: true` | Stored in clear, not masked. Trimmed, at most 64 characters |
|
|
562
|
+
| `email` | `rememberIdentity: true` | Always masked first: `josh@nang.io` becomes `j***@n***.io` |
|
|
563
|
+
| `image` | `rememberIdentity: true` | `https://` URLs up to 256 characters only |
|
|
564
|
+
|
|
565
|
+
The store keeps the newest 5 accounts (`max`), one entry per user, and stays under SecureStore's
|
|
566
|
+
2048-byte value limit by dropping the oldest. Storage by platform:
|
|
567
|
+
|
|
568
|
+
- **iOS:** SecureStore (the keychain) with `WHEN_UNLOCKED_THIS_DEVICE_ONLY`, so the entry is not
|
|
569
|
+
restored to a new device from a backup.
|
|
570
|
+
- **Android:** SecureStore encrypts the value with an Android Keystore key and keeps it in the
|
|
571
|
+
app's `SecureStore` shared preferences. The `keychainAccessible` setting does not apply. The
|
|
572
|
+
`expo-secure-store` config plugin (on by default, `configureAndroidBackup: true`) excludes those
|
|
573
|
+
preferences from Android Auto Backup. Without the plugin, or when the app already has its own
|
|
574
|
+
backup rules, the encrypted value can be backed up; the Keystore key does not leave the device,
|
|
575
|
+
so a restored copy cannot be decrypted. Add the plugin to `app.json` `plugins`.
|
|
576
|
+
- **Web:** localStorage, shared by every tab on the origin. Each change re-reads storage first and
|
|
577
|
+
each store follows `storage` events, so a stale tab never brings back an account another tab
|
|
578
|
+
forgot. Call `rememberedAccounts.dispose()` if you create a store that does not live for the
|
|
579
|
+
whole page.
|
|
580
|
+
|
|
581
|
+
Native storage loads asynchronously: `useRememberedAccounts` returns `isHydrated: false` until it
|
|
582
|
+
has, so wait for it before deciding there is no last used method. Stored data the store cannot
|
|
583
|
+
read resets to an empty list instead of throwing.
|
|
584
|
+
|
|
585
|
+
**Privacy.** Apps that handle health or other sensitive data often run on shared devices: a
|
|
586
|
+
family tablet, a clinic kiosk. Showing who has an account there can itself disclose something,
|
|
587
|
+
so identity is opt-in:
|
|
588
|
+
|
|
589
|
+
- `rememberIdentity` is required. Pass `false` to remember only the method unless the app's
|
|
590
|
+
users own their devices. Cards then show only the method.
|
|
591
|
+
- With `rememberIdentity: true`, the name is stored and shown in clear. Masking hides most of the
|
|
592
|
+
email, but the first letters and top-level domain can still hint at an employer or clinic
|
|
593
|
+
(`j***@f***.health`), so treat the masked email as identifying too.
|
|
594
|
+
- Turning `rememberIdentity` off later scrubs names, emails, and avatars already stored, the next
|
|
595
|
+
time the store loads.
|
|
596
|
+
- Offer a way to forget. Pass `onForget` to `LoginScreen`, and on a "Sign out and forget me"
|
|
597
|
+
action call `rememberedAccounts.forget(userId)` after signing out. Call
|
|
598
|
+
`rememberedAccounts.clear()` to remove every account, for example from a "Not your device?"
|
|
599
|
+
link.
|
|
600
|
+
|
|
601
|
+
### 5. Drop-in login screen
|
|
602
|
+
|
|
603
|
+
`TerrenoLoginScreen` is a complete login screen. It renders `@terreno/ui`'s `LoginScreen` from
|
|
604
|
+
the server's [`/terreno/methods`](../reference/api.md#better-auth-authentication), so it shows
|
|
605
|
+
only the sign-in methods the server enables, and it records each sign-in in the
|
|
606
|
+
[remembered accounts](#4-remember-accounts-on-this-device) store.
|
|
607
|
+
|
|
608
|
+
``````typescript
|
|
609
|
+
import {createRememberedAccountsStore, TerrenoLoginScreen} from "@terreno/rtk";
|
|
610
|
+
import {useLocalSearchParams, useRouter} from "expo-router";
|
|
611
|
+
|
|
612
|
+
export const rememberedAccounts = createRememberedAccountsStore({rememberIdentity: false});
|
|
613
|
+
|
|
614
|
+
const Login = (): React.ReactElement => {
|
|
615
|
+
const router = useRouter();
|
|
616
|
+
const {error} = useLocalSearchParams<{error?: string}>();
|
|
617
|
+
return (
|
|
618
|
+
<TerrenoLoginScreen
|
|
619
|
+
callbackURL="/login" // where a social sign-in returns
|
|
620
|
+
client={authClient} // createBetterAuthClient({features: {multiSession, passkey, twoFactor}})
|
|
621
|
+
error={error} // the ?error= an OAuth return brings back
|
|
622
|
+
onForgotPasswordPress={() => router.push("/forgotPassword")}
|
|
623
|
+
onSignUpPress={() => router.push("/signup")}
|
|
624
|
+
onSignedIn={async () => {
|
|
625
|
+
await betterAuth.syncSession(store.dispatch);
|
|
626
|
+
router.replace("/");
|
|
627
|
+
}}
|
|
628
|
+
providerLabels={{"mock-oidc": "Mock OIDC"}}
|
|
629
|
+
rememberedAccounts={rememberedAccounts}
|
|
630
|
+
trustDeviceDays={30}
|
|
631
|
+
/>
|
|
632
|
+
);
|
|
633
|
+
};
|
|
634
|
+
``````
|
|
635
|
+
|
|
636
|
+
`example-frontend/app/login.tsx` is a working copy. The example opts into `rememberIdentity: true`
|
|
637
|
+
(its store is in `lib/betterAuth.ts`), shows the two-factor challenge inline, and labels its e2e
|
|
638
|
+
mock provider with `providerLabels`.
|
|
639
|
+
|
|
640
|
+
What it does:
|
|
641
|
+
|
|
642
|
+
- **Live session.** On mount it checks for a session. A valid one calls `onSignedIn` and the
|
|
643
|
+
form never shows. In bearer mode it first finishes any OAuth handoff the URL carries
|
|
644
|
+
(`completeBetterAuthOAuthHandoff`, safe to call twice), then reads the token; with no stored
|
|
645
|
+
token it asks the server nothing. Pass `skipWhenSignedIn={false}` on an "add another account"
|
|
646
|
+
route. If `onSignedIn` throws, the screen comes back with "Sign in failed. Please try again."
|
|
647
|
+
- **"Continue as" cards.** Sessions signed in on this device (`multiSession`) come first and
|
|
648
|
+
switch with one tap (`multiSession.setActive`). Remembered accounts follow, minus any user
|
|
649
|
+
who already has a session card, and sign in again with their method: a provider, a passkey,
|
|
650
|
+
or email. The store masks emails, so an email card asks for the email and password instead
|
|
651
|
+
of filling them in.
|
|
652
|
+
- **Last used method.** Read from the remembered-accounts store, not from Better Auth's
|
|
653
|
+
`lastLoginMethod` cookie, which a page on another site cannot read.
|
|
654
|
+
- **Passkeys.** With the server's `passkey` on and the client's `passkey` feature, the screen
|
|
655
|
+
shows a passkey button on web only: React Native has no WebAuthn, so native shows none, and a
|
|
656
|
+
remembered passkey card there says the method is not available. On web it also offers saved
|
|
657
|
+
passkeys in the email field's autofill (`passkeyAutofill`, default `true`) when the browser
|
|
658
|
+
supports it. Autofill starts again whenever the screen returns to the login step.
|
|
659
|
+
- **Social sign-in.** Bearer mode uses [the OAuth handoff](#social-sign-in-in-bearer-mode)
|
|
660
|
+
(`signInSocialWithHandoff`); cookie mode and native use `signIn.social`. Pass `callbackURL`
|
|
661
|
+
set to the login route: the page returns there, the controller finds the new session, and it
|
|
662
|
+
records the provider. In bearer mode that path must be one of the server's
|
|
663
|
+
`oauthHandoff.callbackPaths`. In cookie mode on web, when the app and the API are on different
|
|
664
|
+
origins, pass the full URL (`${window.location.origin}/login`): Better Auth resolves a bare path
|
|
665
|
+
against the API's host, and list the web page's origin in the server's `trustedOrigins`, or
|
|
666
|
+
Better Auth refuses the URL. Native takes the path, which the Expo plugin turns into a deep link.
|
|
667
|
+
Providers Google, GitHub and Apple get branded buttons; any other provider id (a generic OIDC
|
|
668
|
+
provider) gets a plain button labelled from `providerLabels`.
|
|
669
|
+
If your root layout sends signed-in users away from the login route, let that route through:
|
|
670
|
+
the screen has to see the returned session to record it, and it navigates on by itself. Pass
|
|
671
|
+
`onStepChange` to catch the screen landing on its `login` step while your app already holds a
|
|
672
|
+
session (the live-session check failed), and navigate away there; example-frontend's
|
|
673
|
+
`app/login.tsx` does both, and skips re-syncing Redux in `onSignedIn` when it already holds the
|
|
674
|
+
user.
|
|
675
|
+
- **Signing out.** Dispatch `generateBetterAuthSlice`'s `logout()`. It keeps the session until
|
|
676
|
+
`authClient.signOut()` answers, then clears it, so the login screen never opens while the old
|
|
677
|
+
session is still live. Meanwhile `selectBetterAuthIsSigningOut` is true: show it on the
|
|
678
|
+
sign-out control. `logout()` leaves `isLoading` alone, so the screen stays put; if your
|
|
679
|
+
navigator shows a spinner on `selectBetterAuthIsLoading`, show it only while no user is
|
|
680
|
+
loaded (`isLoading && !userId`), or a session refresh would remount the navigator on its
|
|
681
|
+
first tab. A sign-out that cannot reach the server leaves a cookie or native session in place
|
|
682
|
+
and sets the slice's `error` (`SIGN_OUT_FAILED_MESSAGE`); show it. Stop anything that calls the
|
|
683
|
+
API with the session first: example-frontend's `logout` in `store/index.ts` stops syncdb,
|
|
684
|
+
then dispatches `logout()`, and its profile screen shows both.
|
|
685
|
+
- **Sign-up.** The sign-up link hides when the server's sign-up is closed (`signUp: false`).
|
|
686
|
+
- **No email and password.** When the server has `emailPassword` off, the form, the Sign in
|
|
687
|
+
button, and the forgot-password link are left out (`LoginScreen` with `fields={[]}`), and a
|
|
688
|
+
remembered email row says the method is not available.
|
|
689
|
+
- **Keyboard and password managers.** On web the fields sit in a real `<form>`
|
|
690
|
+
([`FormContainer`](../reference/ui.md#formcontainer)), so browsers stop warning "Password field
|
|
691
|
+
is not contained in a form" and password managers fill and save the email and password. The
|
|
692
|
+
email field is `autocomplete="username"` (`"username webauthn"` with passkey autofill) and the
|
|
693
|
+
password `"current-password"`; on iOS and Android the same hints become `textContentType` and
|
|
694
|
+
the Android autofill hint. Enter in the email field moves to the password, and Enter in the
|
|
695
|
+
password signs in through the form's submit, once. From the email field, Tab reaches forgot
|
|
696
|
+
password, the password field, show/hide password, Sign in, then sign up. Method pills sit above
|
|
697
|
+
the form. Pass your own `fields` with an
|
|
698
|
+
`autoComplete` to change a hint.
|
|
699
|
+
|
|
700
|
+
`multiSession` does not work in bearer mode: the server tracks device sessions in cookies that a
|
|
701
|
+
bearer client never sends. Remembered accounts are the only cards there.
|
|
702
|
+
|
|
703
|
+
On native, a refused social sign-in shows nothing: Better Auth's Expo plugin takes the
|
|
704
|
+
`?error=` from the deep link it returns on, so the controller sees only "no session". If your
|
|
705
|
+
app reads that error itself, pass it as `error`.
|
|
706
|
+
|
|
707
|
+
#### Two-factor step
|
|
708
|
+
|
|
709
|
+
When a password sign-in needs a second factor, the screen shows `TwoFactorChallengeScreen`
|
|
710
|
+
inline and finishes the sign-in itself. "Trust this device" starts checked for an authenticator
|
|
711
|
+
code. Pass `initialTrustDevice={false}` to start it unchecked. The same option is on
|
|
712
|
+
`useLoginController` and comes back as `twoFactor.initialTrustDevice`. To use your own route
|
|
713
|
+
instead, pass `onTwoFactorRequired`:
|
|
714
|
+
|
|
715
|
+
``````typescript
|
|
716
|
+
<TerrenoLoginScreen
|
|
717
|
+
onTwoFactorRequired={({twoFactorMethods}) => router.push("/twoFactor")}
|
|
718
|
+
// ...
|
|
719
|
+
/>
|
|
720
|
+
``````
|
|
721
|
+
|
|
722
|
+
Your screen then calls `authClient.twoFactor.verifyTotp` or `verifyBackupCode`, and after it
|
|
723
|
+
succeeds calls `recordRememberedSignIn({store: rememberedAccounts, user, method: "email"})` so the
|
|
724
|
+
device still remembers the account. Don't also set the client's
|
|
725
|
+
`features.twoFactor.onTwoFactorRedirect`, or both run.
|
|
726
|
+
|
|
727
|
+
#### Messages
|
|
728
|
+
|
|
729
|
+
Server refusals become guidance above the form. Text from the URL is never shown: an unknown
|
|
730
|
+
`?error=` gets "Sign in failed. Please try again."
|
|
731
|
+
|
|
732
|
+
| Server says | The screen shows |
|
|
733
|
+
| --- | --- |
|
|
734
|
+
| `account_not_linked` (redirect) or "account not linked" (ID token), linking mode `verified-email` | "An account with this email already exists. Verify your email address, then sign in again to link this sign-in method." |
|
|
735
|
+
| The same, mode `explicit` | "... Sign in with {last used method}, then link this sign-in method from Security." |
|
|
736
|
+
| The same, mode `never` | "This email already has an account. Sign in with {last used method} instead." |
|
|
737
|
+
| `VERIFY_EMAIL_TO_USE_ACCOUNT` (409 or redirect) | "Verify your email to use this account. ..." |
|
|
738
|
+
| `signup_disabled` (redirect), "signup disabled" (ID token), or `EMAIL_PASSWORD_SIGN_UP_DISABLED` | "Sign-up is closed for this app. Ask your administrator for access." The sign-up link hides |
|
|
739
|
+
| `provider_not_found` | "That sign-in provider is not available." |
|
|
740
|
+
| Any other refused request | The server's message, such as "Invalid email or password" |
|
|
741
|
+
|
|
742
|
+
See [Account linking](../explanation/account-linking.md) for why each refusal happens.
|
|
743
|
+
|
|
744
|
+
#### Your own screen
|
|
745
|
+
|
|
746
|
+
`useLoginController` takes the same options and returns `screenProps` for `LoginScreen`, the
|
|
747
|
+
inline challenge state (`twoFactor`), the current `step`, and the handlers (`signInEmail`,
|
|
748
|
+
`signInSocial`, `signInPasskey`, `continueAs`, `forget`):
|
|
749
|
+
|
|
750
|
+
``````typescript
|
|
751
|
+
const login = useLoginController({client: authClient, onSignedIn, rememberedAccounts});
|
|
752
|
+
if (login.step === "login") {
|
|
753
|
+
return <LoginScreen fields={fields} logo={<Logo />} {...login.screenProps} />;
|
|
754
|
+
}
|
|
755
|
+
``````
|
|
756
|
+
|
|
757
|
+
Social providers need their OAuth apps and callback URLs set up first; see
|
|
758
|
+
[Set up social login](set-up-social-login.md).
|
|
759
|
+
|
|
760
|
+
### 6. Drop-in security screen
|
|
761
|
+
|
|
762
|
+
`TerrenoAccountSecurityScreen` is a complete account security screen for the signed-in user. It
|
|
763
|
+
renders `@terreno/ui`'s [`AccountSecurityScreen`](../reference/ui.md#account-security) and wires
|
|
764
|
+
every card to the Better Auth client. Each card shows only when the server's
|
|
765
|
+
[`/terreno/methods`](../reference/api.md#better-auth-authentication) turns its feature on.
|
|
766
|
+
|
|
767
|
+
``````typescript
|
|
768
|
+
import {TerrenoAccountSecurityScreen} from "@terreno/rtk";
|
|
769
|
+
import {useLocalSearchParams} from "expo-router";
|
|
770
|
+
|
|
771
|
+
const Security = (): React.ReactElement => {
|
|
772
|
+
const {error} = useLocalSearchParams<{error?: string}>();
|
|
773
|
+
return (
|
|
774
|
+
<TerrenoAccountSecurityScreen
|
|
775
|
+
callbackURL="/security" // where linking a provider returns
|
|
776
|
+
client={authClient} // createBetterAuthClient({features: {passkey, twoFactor}})
|
|
777
|
+
linkError={error} // the ?error= a failed link brings back
|
|
778
|
+
providerLabels={{"mock-oidc": "Mock OIDC"}}
|
|
779
|
+
/>
|
|
780
|
+
);
|
|
781
|
+
};
|
|
782
|
+
``````
|
|
783
|
+
|
|
784
|
+
| Card | Shown when | Calls |
|
|
785
|
+
| --- | --- | --- |
|
|
786
|
+
| Linked accounts | The server has at least one provider | `listAccounts`; Link: `linkSocialWithHandoff` (the [bearer handoff](#linking-a-provider-in-bearer-mode) in bearer mode, `linkSocial` otherwise); Unlink: `unlinkAccount({accountId})`. No Link button under `accountLinking: {mode: "never"}` |
|
|
787
|
+
| Password | `emailPassword` is on | Change: `changePassword`. Set (no password yet, as after a social sign-up): `POST {basePath}/terreno/set-password` |
|
|
788
|
+
| Passkeys | `passkey` is on, the client has the `passkey` feature, and the app runs on web | `passkey.listUserPasskeys`, `addPasskey`, `updatePasskey`, `deletePasskey` |
|
|
789
|
+
| Two-factor | `twoFactor` is on and the client has the `twoFactor` feature | `twoFactor.enable` (its backup codes go to the card), `verifyTotp`, `generateBackupCodes`, `disable`. On or off comes from the session user's `twoFactorEnabled` |
|
|
790
|
+
| Sessions | Always | `listSessions`, `revokeSession({token})`, `revokeOtherSessions` ("Sign out everywhere else") |
|
|
791
|
+
|
|
792
|
+
How it behaves:
|
|
793
|
+
|
|
794
|
+
- **Errors.** Every Better Auth call resolves `{data, error}`; the hook turns an `error` into a
|
|
795
|
+
thrown `Error` with the server's message, so the card that called shows it. A section that
|
|
796
|
+
fails to load shows its error on its own card; only a failed methods or session load replaces
|
|
797
|
+
the cards with a page error and **Try again**.
|
|
798
|
+
- **Refetching.** After each change only the affected section reloads: unlinking or setting a
|
|
799
|
+
password reloads the accounts, a passkey change the passkeys, revoking the sessions. Turning
|
|
800
|
+
two-factor on or off, or changing the password with "Sign out of other devices", replaces the
|
|
801
|
+
current session on the server, so those reload the session and the sessions list. The session
|
|
802
|
+
is read with `disableCookieCache`, so a two-factor change shows at once.
|
|
803
|
+
- **Last sign-in method.** The screen never asks the server to unlink the user's only account,
|
|
804
|
+
or to remove a passkey that is their only sign-in method. It answers "You can't remove your
|
|
805
|
+
only sign-in method. Add another one first." Better Auth refuses unlinking the last account
|
|
806
|
+
too; the screen does not offer it.
|
|
807
|
+
- **Recent sign-in.** Better Auth asks for a session signed in within `session.freshAge` (a day
|
|
808
|
+
by default) before listing sessions, unlinking, or setting a password. The screen shows its
|
|
809
|
+
`SESSION_NOT_FRESH` refusal as "Sign in again to manage this."
|
|
810
|
+
- **A credential row without a password.** Better Auth's account list does not say whether the
|
|
811
|
+
`credential` row holds a password. If a change is refused with `CREDENTIAL_ACCOUNT_NOT_FOUND`,
|
|
812
|
+
the password card switches to "Set a password" and says "You don't have a password yet. Set
|
|
813
|
+
one below."
|
|
814
|
+
- **Linking in bearer mode.** The page leaves for the provider and comes back to `callbackURL`,
|
|
815
|
+
which may be any page on one of the server's `oauthHandoff.webOrigins`. A failed link comes
|
|
816
|
+
back with `?error=`; pass it as `linkError`. Known codes read as guidance (such as "That
|
|
817
|
+
account is already linked to another user."); any other value reads "Couldn't link that
|
|
818
|
+
account. Please try again." Text from the URL is never shown.
|
|
819
|
+
- **Linking in cookie mode on web.** When the app and the API are on different origins, pass
|
|
820
|
+
the full URL (`${window.location.origin}/settings/security`), as for the login screen: Better
|
|
821
|
+
Auth resolves a bare path against the API's host. The example app's
|
|
822
|
+
`app/settings/security.tsx` passes the full URL on web and the path on native
|
|
823
|
+
(`getSecurityCallbackURL` in `lib/betterAuth.ts`), and the Profile tab opens it from its
|
|
824
|
+
**Security** button (`profile-security-link`).
|
|
825
|
+
- **Sessions in bearer mode.** `listSessions` and `revokeSession` authenticate with the bearer,
|
|
826
|
+
so the sessions card works on another site. (`multiSession` does not.)
|
|
827
|
+
- **Passkeys on native.** React Native has no WebAuthn, so the passkeys card is web-only.
|
|
828
|
+
|
|
829
|
+
Pass `methods` when the app already loaded them with `useAuthMethods`, so the screen does not
|
|
830
|
+
request them again. `minPasswordLength` sets the password card's minimum to match the server's.
|
|
831
|
+
The screen also takes `AccountSecurityScreen`'s `title`, `description`, `header`, `children`,
|
|
832
|
+
`withPage`, `maxWidth`, and `testID`.
|
|
833
|
+
|
|
834
|
+
#### Set a password for a social-only user
|
|
835
|
+
|
|
836
|
+
A user who signed up with a provider has no password. Better Auth's own `setPassword` is
|
|
837
|
+
server-only, so `@terreno/api` serves `POST {basePath}/terreno/set-password` (`{newPassword}`)
|
|
838
|
+
for the signed-in user. It needs a fresh session (signed in within Better Auth's
|
|
839
|
+
`session.freshAge`, a day by default; otherwise 403 `SESSION_NOT_FRESH`), refuses with 409
|
|
840
|
+
`PASSWORD_ALREADY_SET` when the user already has a password (changing one goes through
|
|
841
|
+
`/change-password`, which checks the current password), follows Better Auth's password length
|
|
842
|
+
rules, and counts against the `auth` rate-limit bucket. With a `userModel`, an app User linked by
|
|
843
|
+
`betterAuthId` that already has a JWT password gets the new one too (and a `tokenEpoch` bump); a
|
|
844
|
+
social-only account does not gain a JWT password, since `POST /auth/login` has no second factor.
|
|
845
|
+
It records a `password-set` security event, which emails the user when
|
|
846
|
+
[security notifications](../reference/account-security.md#notifications) are on. See the
|
|
847
|
+
[API reference](../reference/api.md#better-auth-authentication).
|
|
848
|
+
|
|
849
|
+
With a `userModel`, a successful `/change-password` also replaces the app User's JWT password (when
|
|
850
|
+
it has one) and bumps its `tokenEpoch`, so the old password stops working at `POST /auth/login`.
|
|
851
|
+
|
|
852
|
+
#### Your own security screen
|
|
853
|
+
|
|
854
|
+
`useAccountSecurity` takes the same options and returns `screenProps` for
|
|
855
|
+
`AccountSecurityScreen`, plus `methods`, `user`, and `refetch()`:
|
|
856
|
+
|
|
857
|
+
``````typescript
|
|
858
|
+
const security = useAccountSecurity({callbackURL: "/security", client: authClient});
|
|
859
|
+
return <AccountSecurityScreen header={<ProfileHeader />} {...security.screenProps} />;
|
|
860
|
+
``````
|
|
861
|
+
|
|
313
862
|
## Frontend and API on different sites
|
|
314
863
|
|
|
315
864
|
By default the web client relies on Better Auth's HttpOnly session cookie. When the web app
|
|
@@ -339,6 +888,10 @@ With `webAuth: "bearer"`, on web only:
|
|
|
339
888
|
a usable token on the device.
|
|
340
889
|
- A successful `getSession` that returns no session clears the token, unless a newer token was
|
|
341
890
|
stored while that request was in flight. A failed or 5xx `getSession` keeps it.
|
|
891
|
+
- Two-factor, trust-device, and passkey challenges travel in the `X-Terreno-Auth-Challenge`
|
|
892
|
+
header, stored under `${storagePrefix}_auth_challenge` beside the token and sent only on
|
|
893
|
+
`/two-factor/*`, `/passkey/*`, and `/sign-in/*` calls, so 2FA and passkey sign-in work with no
|
|
894
|
+
cookies. See [Challenges without cookies](../explanation/authentication.md#challenges-without-cookies).
|
|
342
895
|
|
|
343
896
|
Native apps ignore `webAuth`: the Expo plugin already keeps the session without browser cookies.
|
|
344
897
|
|
|
@@ -375,7 +928,10 @@ same token. `e2e/crossSiteBearer.spec.ts` signs in from `localhost:8082` to an A
|
|
|
375
928
|
|
|
376
929
|
Better Auth skips this check when `NODE_ENV=test`, so test it against a non-test build.
|
|
377
930
|
- No CORS change is needed. Bearer mode sends no cookies, so the default `corsOrigin` (`"*"`)
|
|
378
|
-
works, and so does an explicit list that includes the web origin.
|
|
931
|
+
works, and so does an explicit list that includes the web origin. `cors()` allows any request header the
|
|
932
|
+
browser asks for, which covers `X-Terreno-Auth-Challenge`. A custom CORS layer with an explicit
|
|
933
|
+
`allowedHeaders` list must include it, and must not drop the `Access-Control-Expose-Headers`
|
|
934
|
+
Better Auth sets.
|
|
379
935
|
- No header change is needed. Better Auth's bearer plugin, which `BetterAuthApp` enables, lists
|
|
380
936
|
`set-auth-token` in `Access-Control-Expose-Headers` itself, so the page can read it.
|
|
381
937
|
|
|
@@ -459,8 +1015,12 @@ example-frontend uses `/login` as the return page. With `BETTER_AUTH_OAUTH_HANDO
|
|
|
459
1015
|
example-backend sets `oauthHandoff` to its web origins and `callbackPaths: ["/login"]`. With
|
|
460
1016
|
`NODE_ENV=production` it drops loopback origins (`localhost`, `127.0.0.1`, `[::1]`, any port) from
|
|
461
1017
|
the handoff only, so a token never goes to a page on the visitor's own machine, and it refuses to
|
|
462
|
-
start when no other origin is left in `CORS_ORIGINS`. CORS and `trustedOrigins` keep them.
|
|
463
|
-
`
|
|
1018
|
+
start when no other origin is left in `CORS_ORIGINS`. CORS and `trustedOrigins` keep them.
|
|
1019
|
+
`app/login.tsx` renders `TerrenoLoginScreen` with `callbackURL="/login"` in bearer mode, so its
|
|
1020
|
+
social buttons start with `signInSocialWithHandoff` and come back to the same screen, which finds
|
|
1021
|
+
the session and records the method. `app/_layout.tsx` syncs the session only after the exchange
|
|
1022
|
+
resolves, and leaves a signed-in user on `/login` so the screen can record the sign-in before it
|
|
1023
|
+
navigates on.
|
|
464
1024
|
|
|
465
1025
|
In an Expo Router app, run the exchange from the entry file, not from a layout. Expo Router
|
|
466
1026
|
copies the URL, fragment included, as soon as it is imported, and writes that copy back into
|
|
@@ -470,12 +1030,81 @@ first imports `lib/oauthHandoffUrl.ts`, which has no imports and hides `handoff`
|
|
|
470
1030
|
the URL, then `lib/oauthHandoff.ts`, which puts the URL back and calls
|
|
471
1031
|
`completeBetterAuthOAuthHandoff` in the same synchronous step, then `expo-router/entry`.
|
|
472
1032
|
|
|
473
|
-
The login screen
|
|
1033
|
+
The login screen (`TerrenoLoginScreen`) turns `?error=` into guidance in `login-screen-notice` or
|
|
1034
|
+
a short message in `login-screen-error`, never the raw text. `e2e/crossSiteOAuth.spec.ts` signs in
|
|
474
1035
|
cross-site through a local mock OIDC provider (`e2e/helpers/mockOidcProvider.ts`), which
|
|
475
1036
|
example-backend registers as the `mock-oidc` generic OAuth provider only when `MOCK_OIDC_ISSUER`
|
|
476
1037
|
is set. example-backend refuses to start when `MOCK_OIDC_ISSUER` is set with
|
|
477
1038
|
`NODE_ENV=production` or names a host other than `127.0.0.1`, `localhost` or `[::1]`.
|
|
478
1039
|
|
|
1040
|
+
By default the mock provider signs in one fixed verified user (`MOCK_OIDC_USER`). A test can
|
|
1041
|
+
name a different identity per sign-in with `setMockOidcIdentity(context, {email, emailVerified,
|
|
1042
|
+
name?, sub?})`, which sets a `mock_oidc_identity` cookie on the provider's host for that browser
|
|
1043
|
+
context only, so parallel tests do not collide. An authorize request with a `login_hint` and no
|
|
1044
|
+
cookie signs in that email, verified. Use an unverified identity to drive the linking cases that
|
|
1045
|
+
must refuse.
|
|
1046
|
+
|
|
1047
|
+
### Linking a provider in bearer mode
|
|
1048
|
+
|
|
1049
|
+
A signed-in user links Google, GitHub, or a generic OAuth provider from the Security screen. In
|
|
1050
|
+
cookie mode `authClient.linkSocial` does this. In bearer mode it can't: its
|
|
1051
|
+
`POST /link-social` fetch comes from another site, so the OAuth state cookie it needs on the
|
|
1052
|
+
API's origin is never set. Use `linkSocialWithHandoff` from `@terreno/rtk` instead. It needs
|
|
1053
|
+
the same `oauthHandoff` server config as social sign-in, and account linking must be on
|
|
1054
|
+
(`accountLinking.mode` other than `never`).
|
|
1055
|
+
|
|
1056
|
+
``````typescript
|
|
1057
|
+
import {linkSocialWithHandoff} from "@terreno/rtk";
|
|
1058
|
+
|
|
1059
|
+
const {error} = await linkSocialWithHandoff({
|
|
1060
|
+
callbackURL: "/settings/security", // any page on a web origin; sent as an absolute URL
|
|
1061
|
+
client: authClient,
|
|
1062
|
+
errorCallbackURL: "/settings/security?link=failed", // optional; defaults to callbackURL
|
|
1063
|
+
provider: "google",
|
|
1064
|
+
});
|
|
1065
|
+
if (error?.code === "LINKING_DISABLED") {
|
|
1066
|
+
// Linking is off on this server; hide the "Link" buttons.
|
|
1067
|
+
}
|
|
1068
|
+
``````
|
|
1069
|
+
|
|
1070
|
+
On web in bearer mode it takes two steps:
|
|
1071
|
+
|
|
1072
|
+
1. It posts `{provider, callbackURL, errorCallbackURL}` with the bearer to
|
|
1073
|
+
`POST {basePath}/oauth-handoff/link-intent`, with both URLs resolved to absolute ones. The
|
|
1074
|
+
server accepts any page on one of `oauthHandoff.webOrigins` that `trustedOrigins` allows.
|
|
1075
|
+
`callbackPaths` do not apply, because no token travels on a link's return. It answers
|
|
1076
|
+
`{token, startURL}`. The token is single-use, expires in
|
|
1077
|
+
three minutes, and stands for the caller's session.
|
|
1078
|
+
2. It submits the token in a hidden HTML form (method POST) to `startURL`. That page navigation
|
|
1079
|
+
keeps the token out of the URL and lets the server set the state cookie first-party. The
|
|
1080
|
+
server uses the token up, starts Better Auth's link flow as that session, and redirects to
|
|
1081
|
+
the provider.
|
|
1082
|
+
|
|
1083
|
+
The provider sends the page back to `callbackURL`. The account is linked, and the URL carries
|
|
1084
|
+
no `#ott`, because linking creates no session. `completeBetterAuthOAuthHandoff` finds nothing
|
|
1085
|
+
to do there. A failure comes back to `errorCallbackURL` (default `callbackURL`) with `?error=`:
|
|
1086
|
+
|
|
1087
|
+
- `session_expired`: the session was revoked or expired between the two steps.
|
|
1088
|
+
- `provider_not_found`: the server has no such provider.
|
|
1089
|
+
- `link_failed`: any other failure.
|
|
1090
|
+
- Better Auth's own callback codes, such as `email_does_not_match`, `unable_to_link_account`
|
|
1091
|
+
(the provider did not verify the email), or `account_already_linked_to_different_user`.
|
|
1092
|
+
|
|
1093
|
+
The intent answers 400 for a return URL that is not on one of `oauthHandoff.webOrigins` (403
|
|
1094
|
+
when `trustedOrigins` does not allow it), and 403 `LINKING_DISABLED` when linking is off. The function resolves with that
|
|
1095
|
+
`{error}` and submits nothing. The start answers 400, with no redirect, for an unknown, used, or
|
|
1096
|
+
expired token. It also answers 400 when `Sec-Fetch-Dest` is set and is not `document`, and 403
|
|
1097
|
+
when `Origin` is missing, `null`, or not one of `webOrigins`. Those two refusals leave the token
|
|
1098
|
+
unused.
|
|
1099
|
+
|
|
1100
|
+
Don't serve the web app with `Referrer-Policy: no-referrer` or `Referrer-Policy: same-origin`.
|
|
1101
|
+
With either, the browser sends `Origin: null` on the cross-site form post, and every link start
|
|
1102
|
+
answers 403. The default, `strict-origin-when-cross-origin`, works.
|
|
1103
|
+
|
|
1104
|
+
Better Auth's same-email rule still applies: the provider's verified email must match the
|
|
1105
|
+
user's, unless `options.account.accountLinking.allowDifferentEmails` is set. In cookie mode and
|
|
1106
|
+
on native, `linkSocialWithHandoff` calls `client.linkSocial` as before.
|
|
1107
|
+
|
|
479
1108
|
### Trade-offs
|
|
480
1109
|
|
|
481
1110
|
A bearer token in web storage is readable by any script on the frontend's origin, so an XSS
|
|
@@ -507,35 +1136,18 @@ export const authClient = createBetterAuthClient({
|
|
|
507
1136
|
|
|
508
1137
|
## OAuth Provider Setup
|
|
509
1138
|
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
2. Create a new project or select existing
|
|
514
|
-
3. Enable Google+ API
|
|
515
|
-
4. Create OAuth 2.0 credentials
|
|
516
|
-
5. Add authorized redirect URIs:
|
|
517
|
-
- `http://localhost:4000/api/auth/callback/google` (development)
|
|
518
|
-
- `https://yourapp.com/api/auth/callback/google` (production)
|
|
519
|
-
6. Copy Client ID and Client Secret to `.env`
|
|
520
|
-
|
|
521
|
-
### GitHub OAuth
|
|
522
|
-
|
|
523
|
-
1. Go to [GitHub Developer Settings](https://github.com/settings/developers)
|
|
524
|
-
2. Create a new OAuth App
|
|
525
|
-
3. Set Authorization callback URL:
|
|
526
|
-
- `http://localhost:4000/api/auth/callback/github` (development)
|
|
527
|
-
- `https://yourapp.com/api/auth/callback/github` (production)
|
|
528
|
-
4. Copy Client ID and generate Client Secret
|
|
529
|
-
5. Add to `.env`
|
|
530
|
-
|
|
531
|
-
### Apple Sign In
|
|
1139
|
+
[Set up social login](set-up-social-login.md) registers Google, GitHub, and Sign in with Apple
|
|
1140
|
+
step by step: the callback URL for local dev, previews, and the stable deploy, the secrets to
|
|
1141
|
+
store, how to try the providers on one preview first, and how to check the result. In short:
|
|
532
1142
|
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
1143
|
+
- **Callback:** `https://<backend>/api/auth/callback/<provider>` (`http://localhost:4000/...`
|
|
1144
|
+
in development; Apple accepts https only).
|
|
1145
|
+
- **Google:** an OAuth client ID of type Web application, with that redirect URI.
|
|
1146
|
+
- **GitHub:** an OAuth App with that authorization callback URL.
|
|
1147
|
+
- **Apple:** a Services ID with the backend and web hosts under Domains and that return URL,
|
|
1148
|
+
plus a Sign in with Apple key. Pass the Services ID, team ID, key ID, and key contents as
|
|
1149
|
+
`appleOAuth: {clientId, teamId, keyId, privateKey}`; Terreno signs the client secret at
|
|
1150
|
+
startup (see [Auth features](#auth-features)).
|
|
539
1151
|
|
|
540
1152
|
## Generated Endpoints
|
|
541
1153
|
|
|
@@ -584,17 +1196,25 @@ Better Auth runs **in parallel** with JWT auth. You don't need to migrate existi
|
|
|
584
1196
|
2. Keep existing JWT endpoints available (they still work)
|
|
585
1197
|
3. New users can sign up via Better Auth
|
|
586
1198
|
4. Existing users continue using JWT tokens
|
|
587
|
-
5.
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
1199
|
+
5. Existing users who sign up through Better Auth with the same email are linked to their app
|
|
1200
|
+
`User` when the password they sign up with matches their existing passport password, or when
|
|
1201
|
+
they sign in with a provider that verified the email. Otherwise sign-up answers 409 "Verify
|
|
1202
|
+
your email to use this account" and nothing is created. Users without a password (GitHub JWT
|
|
1203
|
+
sign-in) use a verified provider sign-in or JWT forgot-password first, so keep the JWT routes
|
|
1204
|
+
mounted during the move.
|
|
1205
|
+
|
|
1206
|
+
See [Account linking](../explanation/account-linking.md) for the linking modes, why both emails
|
|
1207
|
+
must be verified, and the full migration path for JWT apps.
|
|
1208
|
+
|
|
1209
|
+
Without a matching password, an existing JWT user who signs up through Better Auth with the same
|
|
1210
|
+
email is linked to their app `User` only once Better Auth has verified that email. Configure
|
|
1211
|
+
`sendMail` so verification mail goes out, and consider
|
|
1212
|
+
`options.emailAndPassword.requireEmailVerification: true`. If the app `User` never verified its
|
|
1213
|
+
email (`emailVerified` is not `true`), linking through a verified email removes its legacy
|
|
1214
|
+
password and bumps `tokenEpoch`, so the user signs in through Better Auth or resets the password
|
|
1215
|
+
from then on. A row that already has a different `betterAuthId` is never relinked (409
|
|
1216
|
+
`ACCOUNT_ALREADY_EXISTS`). Set `SIGNUP_DISABLED=true` once Better Auth is the only sign-up path,
|
|
1217
|
+
so new accounts cannot be created through the legacy `/auth/signup`.
|
|
598
1218
|
|
|
599
1219
|
## Learn More
|
|
600
1220
|
|