@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
|
@@ -6,6 +6,15 @@ This guide is **AI-context-first**: it is written for an agent (or human) perfor
|
|
|
6
6
|
|
|
7
7
|
`modelRouter` `realtime`, `realtimeList`, `realtimeDocument`, and `setRealtimeSocket` are **deprecated** and **will be removed in Terreno 58**. `RealtimeApp` stays as the socket host for syncdb. Removal checklist: [`docs/tasks/remove-legacy-realtime.md`](https://github.com/TerrenoLabs/terreno/blob/master/docs/tasks/remove-legacy-realtime.md).
|
|
8
8
|
|
|
9
|
+
`installTerrenoDevConsoleLogger` moved to `@terreno/syncdb`. The `@terreno/rtk` export is a **deprecated** re-export kept for the deprecation window. Change the import and pass the base URL:
|
|
10
|
+
|
|
11
|
+
```typescript
|
|
12
|
+
import {baseUrl} from "@terreno/rtk";
|
|
13
|
+
import {installTerrenoDevConsoleLogger} from "@terreno/syncdb";
|
|
14
|
+
|
|
15
|
+
installTerrenoDevConsoleLogger({baseUrl});
|
|
16
|
+
```
|
|
17
|
+
|
|
9
18
|
## 1. Before you start
|
|
10
19
|
|
|
11
20
|
| Decision | Requirement | Why |
|
|
@@ -79,4 +79,4 @@ To set a password from your own server code (for example an admin action), call
|
|
|
79
79
|
|
|
80
80
|
Either reset path also ends the user's MCP credentials: `mcp_` service tokens and MCP OAuth grants issued before the reset stop working, because they are bound to `tokenEpoch`. With `CommsApp` registered, push also stops going to devices the user registered before the reset until each device registers again.
|
|
81
81
|
|
|
82
|
-
Better Auth password reset revokes that user's Better Auth sessions (`revokeSessionsOnPasswordReset`) and, when an app User exists, updates the JWT password and `tokenEpoch` so `POST /auth/login` cannot keep the old passport hash. JWT `POST /auth/resetPassword` does the reverse for Better Auth when `BetterAuthApp` is registered: it updates the Better Auth password and deletes Better Auth sessions. Console mail in development prints length only; check `CommsMessage` / console adapter logs for delivery, not the raw token in logger output. Request logs redact password, token, secret, and code fields (any key name, any depth) in bodies and in URL query strings.
|
|
82
|
+
Better Auth password reset revokes that user's Better Auth sessions (`revokeSessionsOnPasswordReset`) and, when an app User exists, updates the JWT password and `tokenEpoch` so `POST /auth/login` cannot keep the old passport hash. JWT `POST /auth/resetPassword` does the reverse for Better Auth when `BetterAuthApp` is registered: it updates the Better Auth password and deletes Better Auth sessions. With `BetterAuthApp` registered, the JWT routes only replace an existing JWT password, because `POST /auth/login` has no second factor: `POST /auth/forgotPassword` mails no link to an app User without one (the response is still 202), and `POST /auth/resetPassword` refuses that User's token with 400 `legacy-password-reset-unavailable`, setting no password. That account (for example a social-only one) resets through Better Auth. Without Better Auth, the JWT routes still let any user set a password. Console mail in development prints length only; check `CommsMessage` / console adapter logs for delivery, not the raw token in logger output. Request logs redact password, token, secret, and code fields (any key name, any depth) in bodies and in URL query strings.
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# Recover a locked-out user
|
|
2
|
+
|
|
3
|
+
Help a user who lost their authenticator, passkey, or social account get back in. Platform
|
|
4
|
+
staff use `/users/:id/security`; org-admins use `/orgs/:orgId/members/:userId/security`. Both
|
|
5
|
+
run the same handlers, and every change writes a security `AuditEvent`.
|
|
6
|
+
|
|
7
|
+
## Before you start
|
|
8
|
+
|
|
9
|
+
1. Register `AuditApp` and `AccountSecurityApp` on the `TerrenoApp`:
|
|
10
|
+
|
|
11
|
+
```typescript
|
|
12
|
+
import {AccountSecurityApp, AuditApp, BetterAuthApp, TerrenoApp} from "@terreno/api";
|
|
13
|
+
|
|
14
|
+
new TerrenoApp({accessControl: access, organizations: true, userModel: User})
|
|
15
|
+
.register(new BetterAuthApp({config: betterAuthConfig}))
|
|
16
|
+
.register(new AuditApp())
|
|
17
|
+
.register(new AccountSecurityApp())
|
|
18
|
+
.start();
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
2. For `disable` and `enable`, apply `isDisabledPlugin` to the User schema.
|
|
22
|
+
3. For `send-password-reset`, set `sendMail` and `publicAppUrl` in `BetterAuthConfig`.
|
|
23
|
+
4. For the org path, turn organizations on (`organizations: true`). `TerrenoApp` hands the
|
|
24
|
+
registered `AccountSecurityApp` to `OrgsApp`, which mounts the org routes.
|
|
25
|
+
|
|
26
|
+
## Who can do what
|
|
27
|
+
|
|
28
|
+
| Action | Route suffix | Platform staff (`user:manageSecurity`) | Org-admin of the user's org |
|
|
29
|
+
| --- | --- | --- | --- |
|
|
30
|
+
| Read the summary | `GET /security` | Yes | Yes |
|
|
31
|
+
| Reset two-factor | `POST /security/reset-two-factor` | Yes | Yes |
|
|
32
|
+
| Remove a passkey | `DELETE /security/passkeys/:passkeyId` | Yes | Yes |
|
|
33
|
+
| Unlink a provider | `DELETE /security/accounts/:providerId` | Yes | Yes |
|
|
34
|
+
| Sign out everywhere | `POST /security/revoke-sessions` | Yes | Yes |
|
|
35
|
+
| Send a password reset | `POST /security/send-password-reset` | Yes | Yes |
|
|
36
|
+
| Disable or enable the account | `POST /security/disable`, `/enable` | Yes | No |
|
|
37
|
+
| Suspend the membership | `POST /security/suspend-membership` | Through the org path | Yes |
|
|
38
|
+
| Issue a sign-in link | `POST /security/issue-sign-in-link` | `superadmin` only (`user:issueSignInLink`) | No |
|
|
39
|
+
|
|
40
|
+
`superadmin` and `operator` hold `user:manageSecurity`. An org-admin is a user whose active
|
|
41
|
+
`Membership` in that org has `roleName: "org-admin"`.
|
|
42
|
+
|
|
43
|
+
Nobody can act on themselves, or on someone who holds a permission they lack. An org-admin can't
|
|
44
|
+
reach platform staff or `admin: true` users, members of other orgs, or suspended members. Platform
|
|
45
|
+
staff on the org path are judged the same way as on the platform path, so an org-only permission
|
|
46
|
+
does not block them.
|
|
47
|
+
|
|
48
|
+
## Platform path
|
|
49
|
+
|
|
50
|
+
Prefix every route with `/users/<userId>`.
|
|
51
|
+
|
|
52
|
+
1. Read the summary to see the user's sign-in methods and the actions that apply:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
curl -H "Authorization: Bearer $TOKEN" https://api.example.com/users/<userId>/security
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`data.actions` lists only the actions that apply to this user and that you may run.
|
|
59
|
+
2. Run the action that fixes the problem:
|
|
60
|
+
- Lost authenticator: `POST .../reset-two-factor`. The user signs in with their password and
|
|
61
|
+
can enroll again.
|
|
62
|
+
- Lost device with a passkey: `DELETE .../passkeys/<passkeyId>` (ids are in the summary).
|
|
63
|
+
- Lost social account: `DELETE .../accounts/<providerId>`.
|
|
64
|
+
- Stolen device: `POST .../revoke-sessions`.
|
|
65
|
+
- Forgotten password: `POST .../send-password-reset`.
|
|
66
|
+
- Compromised account: `POST .../disable`. Later, `POST .../enable`.
|
|
67
|
+
- Legacy user with no password and no inbox: `POST .../issue-sign-in-link`, then give the
|
|
68
|
+
user the returned link yourself. Only a superadmin can do this.
|
|
69
|
+
3. Removing the user's last sign-in method answers `409` unless their email is verified. With a
|
|
70
|
+
verified email, the route also mails a password reset.
|
|
71
|
+
|
|
72
|
+
## Org path
|
|
73
|
+
|
|
74
|
+
Prefix every route with `/orgs/<orgId>/members/<userId>`. The user must be an active member of
|
|
75
|
+
`<orgId>`. They may belong to other orgs too.
|
|
76
|
+
|
|
77
|
+
1. Read the summary: `GET /orgs/<orgId>/members/<userId>/security`.
|
|
78
|
+
2. Run the same recovery actions as the platform path (2FA, passkey, provider, sessions, reset).
|
|
79
|
+
3. To cut the user off from your org, run `POST .../suspend-membership`. It suspends only their
|
|
80
|
+
membership in `<orgId>`. They still sign in and keep their other orgs.
|
|
81
|
+
4. To bring a suspended member back, use the membership route:
|
|
82
|
+
`PATCH /orgs/<orgId>/members/<memberId>` with `{"status": "active"}`. A suspended member is out
|
|
83
|
+
of reach of the security routes until then.
|
|
84
|
+
|
|
85
|
+
To stop an org-admin from attaching an existing user by email and acting on them right away, set
|
|
86
|
+
a minimum membership age:
|
|
87
|
+
|
|
88
|
+
```typescript
|
|
89
|
+
new TerrenoApp({
|
|
90
|
+
accessControl: access,
|
|
91
|
+
organizations: {orgRecovery: {minMembershipDays: 7}},
|
|
92
|
+
userModel: User,
|
|
93
|
+
});
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Check the audit trail
|
|
97
|
+
|
|
98
|
+
Every change is one `AuditEvent` with `source: "security"`. Org actions carry the org's id in
|
|
99
|
+
`organizationId`. When `sendMail` is configured, each change also emails the user (except
|
|
100
|
+
`send-password-reset`, whose reset email is the notice). For `issue-sign-in-link` the email says a
|
|
101
|
+
link was created, never the link itself. The email says "an administrator" and never names you;
|
|
102
|
+
see
|
|
103
|
+
[Notifications](../reference/account-security.md#notifications).
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
curl -H "Authorization: Bearer $TOKEN" \
|
|
107
|
+
"https://api.example.com/audit-events?source=security&recordId=<userId>"
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Related: [Account security reference](../reference/account-security.md),
|
|
111
|
+
[How organization tenancy works](../explanation/organizations.md#member-recovery),
|
|
112
|
+
[Enable the framework audit log](audit-log.md).
|
|
@@ -79,9 +79,10 @@ MONGO_URI="mongodb://127.0.0.1:27017/terreno-e2e?replicaSet=rs0" \
|
|
|
79
79
|
|
|
80
80
|
| CircleCI shard | Specs |
|
|
81
81
|
| --- | --- |
|
|
82
|
-
| `e2e` `auth` | `login` `signup` `consents` `forgot-password` `reset-password` `verify-email` |
|
|
82
|
+
| `e2e` `auth` | `login` `signup` `consents` `forgot-password` `reset-password` `verify-email` `two-factor` |
|
|
83
83
|
| `e2e` `app` | `todos` `profile` `realtime` `ai-chat` `pdf` |
|
|
84
|
-
| `e2e` `admin-core` | `admin` `admin-home` `admin-form` `admin-todo-crud` `admin-title-update-depth` |
|
|
84
|
+
| `e2e` `admin-core` | `admin` `admin-home` `admin-form` `admin-todo-crud` `admin-title-update-depth` `org-admin-security` `admin-user-security` |
|
|
85
|
+
| `e2e-cross-site` (not required) | the cross-site specs below, then `account-linking` with `E2E_ACCOUNT_LINKING=explicit` |
|
|
85
86
|
| `e2e` `admin-table` | `admin-table-search-filter` `admin-table-bulk-actions` `admin-custom-screens` `admin-comms-back` `harness-approvals` `harness-intake` |
|
|
86
87
|
| `e2e` `syncdb` | `syncdb-load-delta` `syncdb-offline` `syncdb-conflicts` `syncdb-storage` `syncdb-chaos` |
|
|
87
88
|
|
|
@@ -89,14 +90,42 @@ Full local suite: `bun run frontend:e2e`. Nightly load (`e2e-load` / `syncdb-loa
|
|
|
89
90
|
not PR-blocking; run `bunx playwright test e2e/syncdb-loadlab.spec.ts` only when that job
|
|
90
91
|
failed.
|
|
91
92
|
|
|
92
|
-
`
|
|
93
|
-
|
|
94
|
-
`
|
|
95
|
-
|
|
96
|
-
|
|
93
|
+
The cross-site specs (`CROSS_SITE_SPEC_FILES` in `playwright.config.ts`:
|
|
94
|
+
`e2e/crossSiteBearer.spec.ts`, `e2e/crossSiteOAuth.spec.ts`, `e2e/authMethods.spec.ts`,
|
|
95
|
+
`e2e/crossSiteChallenges.spec.ts`, `e2e/crossSiteLogin.spec.ts`,
|
|
96
|
+
`e2e/account-linking.spec.ts`, `e2e/account-security.spec.ts`) run in CircleCI's `e2e-cross-site` job, which is not a required
|
|
97
|
+
check, rather than in an `e2e` shard: they need
|
|
98
|
+
a web server built with `EXPO_PUBLIC_API_URL=http://127.0.0.1:4000` and
|
|
99
|
+
`EXPO_PUBLIC_BETTER_AUTH_WEB_BEARER=true`, a backend with
|
|
100
|
+
`BETTER_AUTH_URL=http://127.0.0.1:4000`, `BETTER_AUTH_OAUTH_HANDOFF=true` and
|
|
101
|
+
`MOCK_OIDC_ISSUER` (the login page shows the mock provider's button because
|
|
102
|
+
`/api/auth/terreno/methods` then lists `mock-oidc`), and the mock OIDC provider on
|
|
103
|
+
`127.0.0.1:4555`. Naming any of those spec files (or
|
|
97
104
|
setting `E2E_CROSS_SITE_BEARER=true`) makes `playwright.config.ts` start all three and run only
|
|
98
105
|
the cross-site specs, so stop any other backend on 4000 and web server on 8082 first:
|
|
99
|
-
`cd example-frontend && bunx playwright test
|
|
106
|
+
`cd example-frontend && E2E_CROSS_SITE_BEARER=true bunx playwright test`. CI serves a static
|
|
107
|
+
export instead of a Metro dev server, which gets OOM-killed bundling beside the backend and
|
|
108
|
+
Chromium. To run the specs exactly as `e2e-cross-site` does:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
cd example-frontend
|
|
112
|
+
EXPO_PUBLIC_API_URL=http://127.0.0.1:4000 EXPO_PUBLIC_BETTER_AUTH_WEB_BEARER=true \
|
|
113
|
+
bun expo export --platform web --output-dir dist-cross-site --clear
|
|
114
|
+
bunx serve dist-cross-site -s -l 8082 &
|
|
115
|
+
CI=true E2E_CROSS_SITE_BEARER=true MONGO_URI="mongodb://127.0.0.1:27017/terreno-e2e?replicaSet=rs0" \
|
|
116
|
+
bunx playwright test --config playwright.circleci.config.ts --reporter=list
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
`--clear` matters: Metro's cache keeps the `EXPO_PUBLIC_*` values of an earlier export. A new
|
|
120
|
+
spec that needs
|
|
121
|
+
the mock provider or cookie-less sessions joins `CROSS_SITE_SPEC_FILES` and the
|
|
122
|
+
`cross-site-bearer` shard in `scripts/ci/e2eAffected/shards.ts`.
|
|
123
|
+
|
|
124
|
+
`e2e/account-linking.spec.ts` has cases for the backend's `explicit` linking mode, which skip
|
|
125
|
+
unless the backend reports that mode. `E2E_ACCOUNT_LINKING=explicit` starts the backend with
|
|
126
|
+
`AUTH_ACCOUNT_LINKING=explicit`; run that spec alone, because the other specs expect the default
|
|
127
|
+
`verified-email` (`e2e-cross-site` does this as its second step):
|
|
128
|
+
`cd example-frontend && E2E_ACCOUNT_LINKING=explicit bunx playwright test e2e/account-linking.spec.ts`.
|
|
100
129
|
|
|
101
130
|
### Deployed bearer smoke (`deploy-frontend-preview`)
|
|
102
131
|
|
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
# Set up social login
|
|
2
|
+
|
|
3
|
+
Turn on Google, GitHub, and Sign in with Apple for a Terreno app, on local dev, on per-PR
|
|
4
|
+
previews, and on the stable deploy. Register each provider once, on the stable backend, and let
|
|
5
|
+
every preview sign in through it. The example app's deploy (`scripts/ci/gcp-deploy.sh`) is the
|
|
6
|
+
worked case; a consumer app follows the same steps with its own hosts and secret names.
|
|
7
|
+
|
|
8
|
+
Config fields used here (`googleOAuth`, `githubOAuth`, `appleOAuth`, `oauthProxy`, `passkey`) are
|
|
9
|
+
listed in [Configure Better Auth](configure-better-auth.md#auth-features). Agents can follow the
|
|
10
|
+
`set-up-social-login` skill, which walks the same steps.
|
|
11
|
+
|
|
12
|
+
## Before you start
|
|
13
|
+
|
|
14
|
+
1. **Point gcloud at the project** that holds the backend's secrets, once per machine:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
gcloud config set project <gcp-project-id>
|
|
18
|
+
gcloud config get-value project
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Every `gcloud` command below then uses it. Pass `--project=<id>` to override it for one
|
|
22
|
+
command.
|
|
23
|
+
2. **Share one `BETTER_AUTH_SECRET`** between stable and previews. oauth-proxy encrypts the
|
|
24
|
+
forwarded profile with it, and a preview refuses a profile encrypted under another secret.
|
|
25
|
+
3. **Choose the hosts** ([below](#choose-the-hosts)). The provider callbacks go on the stable
|
|
26
|
+
backend's final host, so decide it before registering.
|
|
27
|
+
|
|
28
|
+
## Choose the hosts
|
|
29
|
+
|
|
30
|
+
Put the stable web app and backend on subdomains of one domain you own, such as
|
|
31
|
+
`app.<domain>` and `api.<domain>`.
|
|
32
|
+
|
|
33
|
+
- **They are same-site.** A browser treats hosts as one site when they share a registrable
|
|
34
|
+
domain (the part just below a public suffix such as `.com` or `.app`). Safari blocks only
|
|
35
|
+
cross-site cookies, so the host-only session cookie on `api.<domain>` is sent on the web app's
|
|
36
|
+
requests, and the web app can use cookie mode.
|
|
37
|
+
- **Keep the cookie host-only.** Leave Better Auth's `crossSubDomainCookies` off; it would send
|
|
38
|
+
the session cookie to every subdomain.
|
|
39
|
+
- **Passkeys.** Set `PASSKEY_RP_ID=<domain>` so a passkey works on `app.<domain>` and any other
|
|
40
|
+
web origin you list on `<domain>`.
|
|
41
|
+
- **Safari's 7-day cap.** Safari caps a cookie at 7 days when the server that sets it answers
|
|
42
|
+
from an IP address unlike the web app's, as when a CDN serves `app.` and Cloud Run serves
|
|
43
|
+
`api.`. A user who stays away longer than 7 days signs in again. Serving both hosts from one
|
|
44
|
+
load balancer removes the cap.
|
|
45
|
+
|
|
46
|
+
**Previews:** leave them on their hosting domains (`pr-<n>--<site>.netlify.app` and the Cloud
|
|
47
|
+
Run tag URL `pr-<n>---<service>.run.app`). They are cross-site to their backend, so their web
|
|
48
|
+
build runs in [bearer mode](configure-better-auth.md#frontend-and-api-on-different-sites), which
|
|
49
|
+
needs no third-party cookies and works in Safari. Do not put previews under the stable domain:
|
|
50
|
+
a preview runs unreviewed PR code, and on the same site it could send requests that carry a
|
|
51
|
+
visitor's stable session cookie and request passkey assertions for the stable rpID. Same-site
|
|
52
|
+
previews need a separate domain of their own.
|
|
53
|
+
|
|
54
|
+
## Turn the providers on in the server
|
|
55
|
+
|
|
56
|
+
A provider turns on when its config field is set. The login and security screens read
|
|
57
|
+
`GET {basePath}/terreno/methods` and show a provider's button only once the server lists it.
|
|
58
|
+
|
|
59
|
+
```typescript
|
|
60
|
+
const config: BetterAuthConfig = {
|
|
61
|
+
enabled: true,
|
|
62
|
+
baseURL: process.env.BETTER_AUTH_URL, // this backend's public origin
|
|
63
|
+
secret: process.env.BETTER_AUTH_SECRET,
|
|
64
|
+
trustedOrigins: [...webOrigins, "https://appleid.apple.com", "myapp://"],
|
|
65
|
+
googleOAuth: {clientId: process.env.GOOGLE_CLIENT_ID!, clientSecret: process.env.GOOGLE_CLIENT_SECRET!},
|
|
66
|
+
githubOAuth: {clientId: process.env.GITHUB_CLIENT_ID!, clientSecret: process.env.GITHUB_CLIENT_SECRET!},
|
|
67
|
+
appleOAuth: {
|
|
68
|
+
clientId: process.env.APPLE_CLIENT_ID!, // the Services ID
|
|
69
|
+
keyId: process.env.APPLE_KEY_ID!,
|
|
70
|
+
privateKey: process.env.APPLE_PRIVATE_KEY!, // the .p8 contents
|
|
71
|
+
teamId: process.env.APPLE_TEAM_ID!,
|
|
72
|
+
},
|
|
73
|
+
accountLinking: {mode: "verified-email"},
|
|
74
|
+
oauthProxy: process.env.OAUTH_PROXY_PRODUCTION_URL
|
|
75
|
+
? {productionURL: process.env.OAUTH_PROXY_PRODUCTION_URL}
|
|
76
|
+
: undefined,
|
|
77
|
+
nativeApps: true,
|
|
78
|
+
};
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
- Add a provider's field only when its env vars are set; example-backend's
|
|
82
|
+
`src/utils/betterAuthConfig.ts` shows the pattern, including the preview placeholder secret.
|
|
83
|
+
- `https://appleid.apple.com` must be in `trustedOrigins` for Apple
|
|
84
|
+
([why](#sign-in-with-apple)).
|
|
85
|
+
- `accountLinking.mode` decides whether a Google sign-in with the same verified email joins an
|
|
86
|
+
existing account. See [Account linking](../explanation/account-linking.md).
|
|
87
|
+
- `signUp: {mode: "closed"}` stops social sign-in from creating users; stable decides this for
|
|
88
|
+
previews too.
|
|
89
|
+
|
|
90
|
+
## Callback URLs
|
|
91
|
+
|
|
92
|
+
Each provider gets one redirect URI per backend that exchanges codes:
|
|
93
|
+
|
|
94
|
+
```text
|
|
95
|
+
https://<backend that owns the callbacks>/api/auth/callback/<provider>
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
| Stage | Backend that owns the callbacks | Example |
|
|
99
|
+
| --- | --- | --- |
|
|
100
|
+
| Local dev (Google and GitHub only) | `http://localhost:4000` | `http://localhost:4000/api/auth/callback/github` |
|
|
101
|
+
| Preview first ([below](#register-on-a-preview-first)) | The preview's Cloud Run tag URL | `https://pr-1613---terreno-backend-example-7knxlrnpqq-uc.a.run.app/api/auth/callback/google` |
|
|
102
|
+
| Stable before the domain move | The Cloud Run URL | `https://terreno-backend-example-7knxlrnpqq-uc.a.run.app/api/auth/callback/google` |
|
|
103
|
+
| Stable after the move | `api.<domain>` | `https://api.<domain>/api/auth/callback/google` |
|
|
104
|
+
|
|
105
|
+
- During the move, register both stable URLs, and drop the Cloud Run one afterwards
|
|
106
|
+
([below](#move-stable-onto-one-domain)).
|
|
107
|
+
- Previews never get their own callback. They borrow stable's through oauth-proxy, unless one
|
|
108
|
+
preview owns them for a while.
|
|
109
|
+
- Apple accepts only https return URLs on a real host, so it has no local dev callback. Test
|
|
110
|
+
Apple on a preview or on stable.
|
|
111
|
+
- Keep local dev on its own OAuth apps (a "dev" GitHub app and Google client), so a dev secret
|
|
112
|
+
never sits next to the stable ones.
|
|
113
|
+
|
|
114
|
+
## Register on a preview first
|
|
115
|
+
|
|
116
|
+
Stable exchanges codes only once it runs a release with this setup. To try the providers before
|
|
117
|
+
then, let one preview own the callbacks:
|
|
118
|
+
|
|
119
|
+
1. **Name the preview.** In CircleCI, Organization Settings → Contexts → `terreno-gcp`, add
|
|
120
|
+
`OAUTH_CALLBACK_TAG` = `pr-<n>`.
|
|
121
|
+
2. **Register every provider** ([below](#register-each-provider)) with the preview's backend,
|
|
122
|
+
`https://pr-<n>---terreno-backend-example-7knxlrnpqq-uc.a.run.app`, as the callback host.
|
|
123
|
+
For Apple, list both preview hosts under Domains:
|
|
124
|
+
`pr-<n>---terreno-backend-example-7knxlrnpqq-uc.a.run.app` and
|
|
125
|
+
`pr-<n>--terreno-frontend.netlify.app`.
|
|
126
|
+
3. **Add the secrets** ([below](#store-the-secrets)).
|
|
127
|
+
4. **Redeploy the preview.** Approve the PR's `approve` hold in CircleCI (or rerun its
|
|
128
|
+
`gcp-cd-preview` job), so the new revision mounts the secrets.
|
|
129
|
+
5. **Sign in** at `https://pr-<n>--terreno-frontend.netlify.app`.
|
|
130
|
+
|
|
131
|
+
While the variable is set:
|
|
132
|
+
|
|
133
|
+
- That preview mounts every OAuth secret, including client secrets and the Apple key, and
|
|
134
|
+
exchanges codes itself. Only name a preview whose code you have reviewed.
|
|
135
|
+
- Other previews proxy through it.
|
|
136
|
+
- Stable gets only the client IDs, so stable has no social sign-in.
|
|
137
|
+
|
|
138
|
+
When stable runs the release, register the stable callbacks, delete `OAUTH_CALLBACK_TAG`,
|
|
139
|
+
redeploy stable, and remove the preview callbacks from each provider.
|
|
140
|
+
|
|
141
|
+
## Register each provider
|
|
142
|
+
|
|
143
|
+
### GitHub
|
|
144
|
+
|
|
145
|
+
1. GitHub → Settings → Developer settings → OAuth Apps → New OAuth App. For an organization app,
|
|
146
|
+
use the organization's Settings → Developer settings instead.
|
|
147
|
+
2. Homepage URL: the web app. Authorization callback URL:
|
|
148
|
+
`https://<callback host>/api/auth/callback/github`.
|
|
149
|
+
3. Generate a client secret. GitHub shows it once.
|
|
150
|
+
|
|
151
|
+
A GitHub OAuth app has one callback URL. To move it (preview to stable, or to `api.<domain>`),
|
|
152
|
+
edit it, or create a second app for the overlap.
|
|
153
|
+
|
|
154
|
+
### Google
|
|
155
|
+
|
|
156
|
+
1. Google Cloud console → APIs & Services → OAuth consent screen. Set the app name, support
|
|
157
|
+
email, and authorized domains. While the screen is in **Testing**, only the test users you
|
|
158
|
+
list can sign in; add yours.
|
|
159
|
+
2. Credentials → Create credentials → OAuth client ID → Web application.
|
|
160
|
+
3. Authorized redirect URI: `https://<callback host>/api/auth/callback/google`. A client can list
|
|
161
|
+
several, so the preview, Cloud Run, and `api.<domain>` URIs can sit side by side.
|
|
162
|
+
4. Copy the client ID and secret.
|
|
163
|
+
|
|
164
|
+
### Sign in with Apple
|
|
165
|
+
|
|
166
|
+
1. Apple Developer → Certificates, Identifiers & Profiles → Identifiers → add a **Services ID**
|
|
167
|
+
(this is the `clientId`, such as `com.example.web`). Turn on Sign in with Apple and configure
|
|
168
|
+
it:
|
|
169
|
+
- Domains: the backend and web hosts (`api.<domain>` and `app.<domain>` after the move).
|
|
170
|
+
- Return URL: `https://<callback host>/api/auth/callback/apple`.
|
|
171
|
+
2. Keys → add a key with Sign in with Apple, download the `.p8` file (once only), and note the
|
|
172
|
+
key ID. Your team ID is in the top right of the developer portal.
|
|
173
|
+
|
|
174
|
+
Apple needs `https://appleid.apple.com` in `trustedOrigins`. It returns with a cross-site
|
|
175
|
+
`POST` (`form_post`) from that origin, and a browser that holds a `SameSite=None` cookie for the
|
|
176
|
+
API sends it, which makes Better Auth check the POST's `Origin`.
|
|
177
|
+
|
|
178
|
+
If Apple refuses a preview's `run.app` host, try Google and GitHub on the preview and register
|
|
179
|
+
Apple on stable.
|
|
180
|
+
|
|
181
|
+
## Store the secrets
|
|
182
|
+
|
|
183
|
+
The example keeps each value in Secret Manager, in containers Terraform creates
|
|
184
|
+
(`infra/flourish/main.tf`, `backend_secret_oauth`). The source values live in OpenBao under
|
|
185
|
+
`terreno`; `secret terreno <KEY>` (from the ops dotfiles) prints one. With the default project
|
|
186
|
+
set, copy each into its Secret Manager container:
|
|
187
|
+
|
|
188
|
+
**Before the first Terraform apply** (until the change that adds them merges), the containers do
|
|
189
|
+
not exist and `put` fails with `NOT_FOUND`. Create them, with read access for the backend's
|
|
190
|
+
runtime account, once:
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
S=terreno-backend-example
|
|
194
|
+
for n in google-client-id google-client-secret github-client-id github-client-secret apple-client-id apple-team-id apple-key-id apple-private-key; do gcloud secrets create "$S-$n" --replication-policy=automatic --quiet && gcloud secrets add-iam-policy-binding "$S-$n" --member=serviceAccount:terreno-backend-runtime@<gcp-project-id>.iam.gserviceaccount.com --role=roles/secretmanager.secretAccessor --quiet >/dev/null && echo "created $S-$n"; done
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
`infra/flourish/imports.tf` adopts these containers into Terraform on its next apply, so it does
|
|
198
|
+
not fail on "already exists". Then copy the values:
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
S=terreno-backend-example
|
|
202
|
+
put() { local v; v="$(secret terreno "$1" </dev/null)" && [ -n "$v" ] && printf '%s' "$v" | gcloud secrets versions add "$S-$2" --data-file=- --quiet >/dev/null && echo "added $S-$2" || echo "skipped $1"; }
|
|
203
|
+
put GITHUB_OAUTH_CLIENT_ID github-client-id
|
|
204
|
+
put GITHUB_OAUTH_CLIENT_SECRET github-client-secret
|
|
205
|
+
put GOOGLE_OAUTH_CLIENT_ID google-client-id
|
|
206
|
+
put GOOGLE_OAUTH_CLIENT_SECRET google-client-secret
|
|
207
|
+
put APPLE_OAUTH_CLIENT_ID apple-client-id
|
|
208
|
+
put APPLE_OAUTH_TEAM_ID apple-team-id
|
|
209
|
+
put APPLE_OAUTH_KEY_ID apple-key-id
|
|
210
|
+
put APPLE_OAUTH_SECERT apple-private-key
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
Every line stands alone, so the block pastes into bash or zsh without a continuation prompt.
|
|
214
|
+
`</dev/null` and `--quiet` stop `secret` and `gcloud` from reading the rest of the paste as
|
|
215
|
+
input; a command that needs input fails and prints `skipped` instead. If every line skips, run
|
|
216
|
+
`secret terreno GITHUB_OAUTH_CLIENT_ID >/dev/null` alone first to sign in to OpenBao.
|
|
217
|
+
|
|
218
|
+
- `APPLE_OAUTH_SECERT` (spelled as stored) holds the `.p8` key's contents, `-----BEGIN PRIVATE
|
|
219
|
+
KEY-----` included. `APPLE_OAUTH_CLIENT_ID` is the Services ID, such as `com.example.web`.
|
|
220
|
+
- `put` never prints a value, and skips a key that is missing or empty instead of storing an
|
|
221
|
+
empty version, which would mount a blank secret.
|
|
222
|
+
- `printf '%s'` keeps a trailing newline out of the value. Check one with
|
|
223
|
+
`gcloud secrets versions access latest --secret=$S-github-client-id`.
|
|
224
|
+
- Without OpenBao, replace `secret terreno "$key"` with your own store's read command, or pipe
|
|
225
|
+
each value in by hand (`--data-file=AuthKey_<key id>.p8` for the Apple key).
|
|
226
|
+
|
|
227
|
+
The deploy mounts only the secrets that have a version, so a provider turns on when both of its
|
|
228
|
+
values exist (all four for Apple). **Redeploy after adding a version.** The example reads them as
|
|
229
|
+
`GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`,
|
|
230
|
+
`APPLE_CLIENT_ID`, `APPLE_TEAM_ID`, `APPLE_KEY_ID`, and `APPLE_PRIVATE_KEY`.
|
|
231
|
+
|
|
232
|
+
Previews get only the `*_CLIENT_ID` secrets: starting a sign-in needs the client ID, and the
|
|
233
|
+
callback owner exchanges every code. No client secret or `.p8` reaches a `pr-*` revision other
|
|
234
|
+
than the one `OAUTH_CALLBACK_TAG` names. Better Auth refuses to configure Google or GitHub
|
|
235
|
+
without some secret, so on a preview (`OAUTH_PROXY_PRODUCTION_URL` on another origin than
|
|
236
|
+
`BETTER_AUTH_URL`) the example fills in the placeholder `OAUTH_PROXY_PREVIEW_CLIENT_SECRET`
|
|
237
|
+
(`"unused-on-oauth-proxy-preview"`). It is never sent to a provider.
|
|
238
|
+
|
|
239
|
+
For local dev, put the dev app's values in `example-backend/.env` under the same names.
|
|
240
|
+
|
|
241
|
+
## Check it
|
|
242
|
+
|
|
243
|
+
1. **The server lists the providers:**
|
|
244
|
+
|
|
245
|
+
```bash
|
|
246
|
+
curl -s https://<backend>/api/auth/terreno/methods | jq .
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
Each configured provider appears. A missing one means its secrets have no version, or the
|
|
250
|
+
revision predates them: redeploy.
|
|
251
|
+
2. **Sign in** on the web app with each provider. The button sits on the login screen; the
|
|
252
|
+
Security screen's Linked accounts card links more providers to the same account.
|
|
253
|
+
3. **On a redirect mismatch,** compare the `redirect_uri` in the provider's error with the URI
|
|
254
|
+
you registered, character for character (scheme, host, no trailing slash).
|
|
255
|
+
|
|
256
|
+
## Rotate Apple's key and client secret
|
|
257
|
+
|
|
258
|
+
Apple's client secret is a JWT signed with your key, valid for at most six months.
|
|
259
|
+
|
|
260
|
+
- **Key form (the example's).** Terreno signs a fresh 180-day secret every time the server
|
|
261
|
+
starts, so the JWT rotates on every deploy and instance start. Nothing expires as long as some
|
|
262
|
+
deploy or restart happens within 180 days.
|
|
263
|
+
- **Rotating the key itself.** Create a second key in the developer portal, add its `.p8` and
|
|
264
|
+
key ID as new versions of `apple-private-key` and `apple-key-id`, and redeploy. Revoke the old
|
|
265
|
+
key once the new revision serves traffic. Users stay signed in: sessions do not depend on the
|
|
266
|
+
key.
|
|
267
|
+
- **Client-secret form** (`APPLE_CLIENT_SECRET`, a JWT you signed yourself). Re-sign it before
|
|
268
|
+
it expires, add it as a new version, and redeploy. The example uses this form only when the
|
|
269
|
+
key form is incomplete.
|
|
270
|
+
|
|
271
|
+
## How previews sign in
|
|
272
|
+
|
|
273
|
+
Every preview reaches the providers through the stable deploy's registered callback, using
|
|
274
|
+
Better Auth's oauth-proxy plugin:
|
|
275
|
+
|
|
276
|
+
1. The preview starts the sign-in. oauth-proxy points the provider's `redirect_uri` at stable,
|
|
277
|
+
and packs the preview's OAuth state into the `state` parameter, encrypted with
|
|
278
|
+
`BETTER_AUTH_SECRET`.
|
|
279
|
+
2. The provider returns to stable's `/api/auth/callback/<provider>`. Stable decrypts the state,
|
|
280
|
+
exchanges the code, and redirects to the preview's `/api/auth/callback/<provider>/oauth-proxy`
|
|
281
|
+
with the user's profile, encrypted, in the URL. Stable writes nothing to its database.
|
|
282
|
+
3. The preview checks the state against the row it stored in its own database, creates the user
|
|
283
|
+
and session there, and redirects to the web app. A bearer-mode web app gets `#ott=<token>`
|
|
284
|
+
on that redirect, as on any other social sign-in
|
|
285
|
+
([OAuth handoff](configure-better-auth.md#social-sign-in-in-bearer-mode)).
|
|
286
|
+
|
|
287
|
+
What this needs:
|
|
288
|
+
|
|
289
|
+
- **One shared secret.** Previews and stable use the same `BETTER_AUTH_SECRET`; previews keep
|
|
290
|
+
their own databases. A profile encrypted under a different secret is refused.
|
|
291
|
+
- **The plugin on both.** Previews set `oauthProxy.productionURL` to stable. Stable sets it to
|
|
292
|
+
its own URL, because its callback hook is what forwards a preview's return. Terreno then sets
|
|
293
|
+
oauth-proxy's `currentURL` on stable, so stable's own sign-ins skip the proxy and keep Better
|
|
294
|
+
Auth's state-cookie check, whichever host name (such as a Cloud Run tag URL) the client used.
|
|
295
|
+
In the example, `scripts/ci/backend-auth-env.sh` sets `OAUTH_PROXY_PRODUCTION_URL` on both.
|
|
296
|
+
- **Production's sign-up mode governs.** Stable decides whether the provider's user may be
|
|
297
|
+
created, so give previews the same `signUp.mode`.
|
|
298
|
+
- **No browser binding on the preview hop.** The preview's completion endpoint skips the state
|
|
299
|
+
cookie check (the cookie lives on the preview's host, and the browser arrives from stable). The
|
|
300
|
+
encrypted profile in the forwarded URL is the only proof: whoever holds that URL can finish the
|
|
301
|
+
sign-in, as the user, within 60 seconds, once (the preview consumes its state row). Treat it
|
|
302
|
+
like a password-reset link: previews are for testing, so sign in there with test accounts.
|
|
303
|
+
|
|
304
|
+
**Apple's `form_post`.** Apple returns with a cross-site `POST` to the callback rather than a
|
|
305
|
+
redirect. Stable's oauth-proxy reads `code` and `state` from the form body and forwards the
|
|
306
|
+
browser to the preview with a normal redirect, so no `SameSite=Lax` cookie is needed on stable.
|
|
307
|
+
The api test `betterAuthOAuthProxy.test.ts` proves this with the mock provider; the live check
|
|
308
|
+
against Apple is T28 in the auth-experience plan.
|
|
309
|
+
|
|
310
|
+
## Native
|
|
311
|
+
|
|
312
|
+
The Expo app signs in with the same providers through the system browser, using Better Auth's
|
|
313
|
+
Expo plugin. The provider still returns to the backend's own
|
|
314
|
+
`/api/auth/callback/<provider>`, so a native app needs no extra redirect URI at the provider.
|
|
315
|
+
|
|
316
|
+
1. **Turn on `nativeApps: true`** in the server config. Terreno adds `expo()` from
|
|
317
|
+
`@better-auth/expo`, which serves `GET {basePath}/expo-authorization-proxy`. The app opens
|
|
318
|
+
that URL in the browser; it sets the OAuth state cookie there and redirects to the provider.
|
|
319
|
+
2. **Give the app a scheme.** Set `expo.scheme` in `app.json` (`frontend` in the example) and
|
|
320
|
+
pass the same value to `createBetterAuthClient({scheme})` from `@terreno/rtk`.
|
|
321
|
+
3. **List the scheme in `trustedOrigins`**, as `frontend://`. After the callback, the backend
|
|
322
|
+
redirects the browser to the app's `callbackURL` (`frontend://.../login`), with the session in
|
|
323
|
+
a `cookie` query parameter. Better Auth refuses a callback URL that matches no trusted origin.
|
|
324
|
+
An entry with only the scheme matches every URL on that scheme, so `frontend://` also covers a
|
|
325
|
+
development build's `frontend://localhost:8081/login`. The example lists `frontend://`,
|
|
326
|
+
`terreno://` and `exp://`.
|
|
327
|
+
4. **Pass a path as `callbackURL`** (`/login`). The Expo client turns it into the deep link, opens
|
|
328
|
+
the browser, waits for that link, and stores the session from it in `expo-secure-store`.
|
|
329
|
+
|
|
330
|
+
`expo-web-browser` must be in the native build. The example gets it through `@terreno/ui`;
|
|
331
|
+
check with `bunx expo-modules-autolinking resolve -p android`.
|
|
332
|
+
|
|
333
|
+
Two limits:
|
|
334
|
+
|
|
335
|
+
- **No oauth-proxy.** The Expo client sends `x-skip-oauth-proxy`, so a native sign-in uses the
|
|
336
|
+
backend it talks to as the provider's callback. Point native builds at the stable deploy, whose
|
|
337
|
+
callback is the registered one, not at a preview.
|
|
338
|
+
- **https providers only.** The authorization proxy refuses an `http:` authorization URL. Real
|
|
339
|
+
providers are all https. The example's mock OIDC provider is plain http, so example-backend
|
|
340
|
+
serves the proxy for that one origin itself (`mock-oidc-native-proxy` in
|
|
341
|
+
`src/utils/betterAuthConfig.ts`). It exists only while `MOCK_OIDC_ISSUER` is set, which
|
|
342
|
+
production refuses.
|
|
343
|
+
|
|
344
|
+
### Prove it on an Android emulator
|
|
345
|
+
|
|
346
|
+
`example-frontend/maestro/native-social-login.yaml` signs in with the mock provider and lands on
|
|
347
|
+
the todos screen. `localhost` has to mean the same machine to the backend and to the device's
|
|
348
|
+
browser, so every server is reached through `adb reverse`:
|
|
349
|
+
|
|
350
|
+
```bash
|
|
351
|
+
# Mongo as a replica set, then the mock provider and the backend
|
|
352
|
+
MOCK_OIDC_SERVE=true MOCK_OIDC_PORT=4655 MOCK_OIDC_ISSUER=http://localhost:4655 \
|
|
353
|
+
MOCK_OIDC_REDIRECT_URI=http://localhost:4000/api/auth/callback/mock-oidc \
|
|
354
|
+
bun example-frontend/e2e/helpers/mockOidcProvider.ts
|
|
355
|
+
AUTH_PROVIDER=better-auth BETTER_AUTH_URL=http://localhost:4000 \
|
|
356
|
+
MOCK_OIDC_ISSUER=http://localhost:4655 MONGO_URI=... PORT=4000 bun run backend:dev
|
|
357
|
+
|
|
358
|
+
# The development build and Metro
|
|
359
|
+
cd example-frontend && bunx expo run:android # or install an EAS development build
|
|
360
|
+
EXPO_PUBLIC_API_URL=http://localhost:4000 bunx expo start --dev-client --port 8081
|
|
361
|
+
|
|
362
|
+
adb -s 127.0.0.1:5555 reverse tcp:8081 tcp:8081
|
|
363
|
+
adb -s 127.0.0.1:5555 reverse tcp:4000 tcp:4000
|
|
364
|
+
adb -s 127.0.0.1:5555 reverse tcp:4655 tcp:4655
|
|
365
|
+
maestro --device 127.0.0.1:5555 test example-frontend/maestro/native-social-login.yaml
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
To run beside a Playwright run that holds ports 4000 and 4555, start the backend on another host
|
|
369
|
+
port and reverse to it (`adb reverse tcp:4000 tcp:4100`); the app and `BETTER_AUTH_URL` keep
|
|
370
|
+
`localhost:4000`. `MOCK_OIDC_PORT`, `MOCK_OIDC_ISSUER` and `MOCK_OIDC_REDIRECT_URI` move the mock
|
|
371
|
+
provider; unset, it keeps the Playwright defaults (`127.0.0.1:4555`, callback on
|
|
372
|
+
`127.0.0.1:4000`).
|
|
373
|
+
|
|
374
|
+
## Passkeys on previews and stable
|
|
375
|
+
|
|
376
|
+
A passkey is bound to an rpID, a domain the browser checks against the page's host.
|
|
377
|
+
|
|
378
|
+
- **Previews** use their own host (`pr-<n>--terreno-frontend.netlify.app`). `netlify.app` is a
|
|
379
|
+
public suffix, so it can't be the rpID, and a passkey made on one preview works only on that
|
|
380
|
+
preview.
|
|
381
|
+
- **Stable before the move** uses `terreno-frontend.netlify.app`, for the same reason.
|
|
382
|
+
- **Stable after the move** uses the parent domain: `backend-auth-env.sh` sets
|
|
383
|
+
`PASSKEY_RP_ID=<domain>` when `STABLE_FRONTEND_URL` is `app.<domain>` and `STABLE_BACKEND_URL`
|
|
384
|
+
is on `<domain>` too. It never derives a public suffix (`netlify.app`, `run.app`) or a country
|
|
385
|
+
second level (`co.uk`, `com.au`); set `STABLE_PASSKEY_RP_ID` to choose the rpID yourself. A
|
|
386
|
+
passkey made on `app.<domain>` then works on the pages that list it as an origin: the
|
|
387
|
+
`FRONTEND_URL` origin and each `CORS_ORIGINS` origin on `<domain>`, not on every subdomain.
|
|
388
|
+
|
|
389
|
+
Session cookies stay host-only on `api.<domain>`. Same-site subdomains send them on fetches
|
|
390
|
+
without Better Auth's `crossSubDomainCookies`, which would expose the session to every other
|
|
391
|
+
subdomain.
|
|
392
|
+
|
|
393
|
+
## Move stable onto one domain
|
|
394
|
+
|
|
395
|
+
Moving the stable web app to `app.<domain>` and the backend to `api.<domain>` makes them
|
|
396
|
+
same-site, so Safari's third-party cookie blocking no longer applies on stable. Previews stay on
|
|
397
|
+
`netlify.app` and `run.app`, and keep using bearer mode.
|
|
398
|
+
|
|
399
|
+
1. **Netlify custom domain.** In the `terreno-frontend` site, add `app.<domain>` under Domain
|
|
400
|
+
management and point a `CNAME` at the Netlify site. Wait for the certificate.
|
|
401
|
+
2. **Cloud Run domain mapping.** Map `api.<domain>` to `terreno-backend-example`:
|
|
402
|
+
|
|
403
|
+
```bash
|
|
404
|
+
gcloud beta run domain-mappings create --service=terreno-backend-example \
|
|
405
|
+
--domain=api.<domain> --region=us-central1
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
Add the DNS records it prints, and wait until `gcloud beta run domain-mappings describe`
|
|
409
|
+
reports the certificate ready.
|
|
410
|
+
3. **Re-register the callbacks.** Add `https://api.<domain>/api/auth/callback/<provider>` to
|
|
411
|
+
GitHub, Google, and Apple (and both new hosts under Apple's Domains). Keep the Cloud Run
|
|
412
|
+
callback until the next step has deployed.
|
|
413
|
+
4. **Set two CI variables** in the CircleCI project: `STABLE_BACKEND_URL=https://api.<domain>`
|
|
414
|
+
and `STABLE_FRONTEND_URL=https://app.<domain>`, then redeploy stable and the web app. The
|
|
415
|
+
deploy sets `BETTER_AUTH_URL`, `FRONTEND_URL`, `OAUTH_PROXY_PRODUCTION_URL`, and
|
|
416
|
+
`PASSKEY_RP_ID` from them, previews proxy to `api.<domain>`, and the web build calls
|
|
417
|
+
`api.<domain>` (by default it calls the untagged Cloud Run URL, the same one the deploy names). The old Netlify host stays in `CORS_ORIGINS` (and so in `trustedOrigins`)
|
|
418
|
+
during the move.
|
|
419
|
+
5. **Drop the old callbacks** from each provider once sign-in works on the new hosts.
|
|
420
|
+
|
|
421
|
+
**Passkeys made before the move stop working.** They are bound to
|
|
422
|
+
`terreno-frontend.netlify.app`, and the new rpID is `<domain>`, which the browser refuses to
|
|
423
|
+
match. Users sign in another way (password or a provider) and add a new passkey from the security
|
|
424
|
+
screen. The example has no real users; a consumer app should tell its users before moving.
|