@bffless/skills 1.8.0 → 1.8.2
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/package.json
CHANGED
|
@@ -5,46 +5,57 @@ description: Cross-domain authentication using the admin login relay pattern, bu
|
|
|
5
5
|
|
|
6
6
|
# Authentication
|
|
7
7
|
|
|
8
|
-
BFFless
|
|
8
|
+
BFFless authenticates users on a single admin host (`admin.<primary-domain>`) and reuses that session across every site it serves. For the common case — a primary domain plus its subdomains — the SuperTokens session cookie (`sAccessToken`) is shared on `.<primary-domain>` and works directly. For the edge case of additional cross-origin custom domains, BFFless relays the session into a per-domain `bffless_access` JWT via a one-time bounce through the admin. Both cases expose the same content-side surface at `/_bffless/auth/*` — built into BFFless nginx, no proxy rules required.
|
|
9
9
|
|
|
10
10
|
## How Authentication Works
|
|
11
11
|
|
|
12
|
-
###
|
|
12
|
+
### The Common Case: Primary Domain (+ Subdomains)
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
A self-hosted BFFless install has **one primary domain** (e.g., `foo.com` — the root you pick during setup). The admin lives at `admin.foo.com` and content can be served from `foo.com` itself or any subdomain (`bar.foo.com`, `app.foo.com`, etc.). Almost every install runs entirely in this mode.
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
All of these share `.foo.com` as a parent, so the SuperTokens session cookie (`sAccessToken`) reaches every one of them automatically. There is no `bffless_access` cookie in this mode — the session is always validated against `sAccessToken`. (BFFless multi-tenant hosting at `*.workspace.bffless.app` is mechanically the same setup, with `workspace.bffless.app` playing the role of the primary domain.)
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
When a user hits a private deployment on the primary domain and isn't authenticated:
|
|
19
|
+
|
|
20
|
+
1. Backend redirects to `https://admin.foo.com/login?redirect=<original-path>&tryRefresh=true`
|
|
21
|
+
2. The login page tries a session refresh first (the `tryRefresh` param), in case the cookie is just expired
|
|
20
22
|
3. If refresh fails, the user logs in normally
|
|
21
23
|
4. After login, the user is redirected back to the original path
|
|
22
|
-
5. The `sAccessToken` cookie
|
|
24
|
+
5. The `sAccessToken` cookie now travels with every request to either the content or admin host
|
|
25
|
+
|
|
26
|
+
### Edge Case: Additional Cross-Origin Custom Domains (`customDomainRelay`)
|
|
27
|
+
|
|
28
|
+
A single BFFless install can also serve content from **additional registered domains** that aren't under the primary — e.g., attaching `bat.com` to a `foo.com` install. This is uncommon for OSS users; reach for it only when you genuinely need to serve content from a separately-owned root domain.
|
|
23
29
|
|
|
24
|
-
|
|
30
|
+
Because `bat.com` and `foo.com` are different origins, the SuperTokens cookie can't reach `bat.com`. SuperTokens itself has no multi-domain cookie support, so BFFless mints its own short-lived JWT and sets it as `bffless_access` on `bat.com` via a one-time relay through `admin.foo.com`. The `bffless_access` cookie **only exists on these cross-origin custom domains** — it is never set on the primary or any of its subdomains.
|
|
25
31
|
|
|
26
|
-
|
|
32
|
+
The relay flow:
|
|
27
33
|
|
|
28
|
-
1. User visits a private page on `
|
|
29
|
-
2. Frontend detects
|
|
34
|
+
1. User visits a private page on `bat.com/portal/`
|
|
35
|
+
2. Frontend detects no auth (via `/_bffless/auth/session`)
|
|
30
36
|
3. Frontend redirects to the admin login with relay params:
|
|
31
37
|
```
|
|
32
|
-
https://admin.
|
|
38
|
+
https://admin.foo.com/login?customDomainRelay=true&targetDomain=bat.com&redirect=%2Fportal%2F
|
|
33
39
|
```
|
|
34
40
|
4. User logs in on the admin domain (or is already logged in via SuperTokens session)
|
|
35
41
|
5. After login, the frontend calls `POST /api/auth/domain-token` with:
|
|
36
42
|
```json
|
|
37
|
-
{ "targetDomain": "
|
|
43
|
+
{ "targetDomain": "bat.com", "redirectPath": "/portal/" }
|
|
38
44
|
```
|
|
39
|
-
6. Backend validates that `targetDomain` is a registered domain for this workspace, then
|
|
40
|
-
7. Backend returns a `redirectUrl` pointing to the callback on the
|
|
45
|
+
6. Backend validates that `targetDomain` is a registered domain for this workspace, then mints a short-lived JWT (the "domain token")
|
|
46
|
+
7. Backend returns a `redirectUrl` pointing to the callback on the content domain: `https://bat.com/_bffless/auth/callback?token=...&redirect=/portal/`
|
|
41
47
|
8. The callback endpoint validates the token, sets `bffless_access` and `bffless_refresh` HttpOnly cookies, and redirects to the original path
|
|
42
48
|
|
|
43
|
-
###
|
|
49
|
+
### Default: Use `/_bffless/auth/*`
|
|
50
|
+
|
|
51
|
+
The `/_bffless/auth/*` endpoints are **built into BFFless nginx** and handled by a dedicated controller — they work on every domain without any configuration. They are separate from the SuperTokens `/api/auth/*` endpoints (which only exist on the admin host).
|
|
52
|
+
|
|
53
|
+
Reach for `/_bffless/auth/*` first. The two situations where it isn't enough are:
|
|
44
54
|
|
|
45
|
-
|
|
55
|
+
- You need to **clear the SuperTokens session** (true logout) on the primary domain or one of its subdomains.
|
|
56
|
+
- You need an endpoint not in the built-in surface (OAuth start/callback, `session/refresh`, etc.).
|
|
46
57
|
|
|
47
|
-
|
|
58
|
+
For those, set up the [reverse-proxy rule](#advanced-reverse-proxy-to-supertokens-endpoints) and call the proxied `/auth/*` path. The proxy only works on the primary domain (the SuperTokens cookie has to reach the request). On an additional cross-origin custom domain like `bat.com`, stick with `/_bffless/auth/*`.
|
|
48
59
|
|
|
49
60
|
## Auth Endpoints (Built-in)
|
|
50
61
|
|
|
@@ -52,20 +63,113 @@ All auth endpoints are available at `/_bffless/auth/*` on any domain served by B
|
|
|
52
63
|
|
|
53
64
|
| Endpoint | Method | Purpose |
|
|
54
65
|
| --------------------------- | ------ | -------------------------------------------------------- |
|
|
55
|
-
| `/_bffless/auth/session` | GET | Check current session
|
|
66
|
+
| `/_bffless/auth/session` | GET | Check current session — see response shape below |
|
|
56
67
|
| `/_bffless/auth/refresh` | POST | Refresh an expired access token using the refresh cookie |
|
|
57
68
|
| `/_bffless/auth/callback` | GET | Exchange a domain relay token for auth cookies |
|
|
58
|
-
| `/_bffless/auth/logout` | POST | Clear
|
|
69
|
+
| `/_bffless/auth/logout` | POST | Clear `bffless_access` / `bffless_refresh` cookies (does NOT clear SuperTokens session — see [Advanced](#advanced-reverse-proxy-to-supertokens-endpoints)) |
|
|
70
|
+
| `/_bffless/auth/signin` | POST | In-page email+password sign-in (mints `bffless_access`) |
|
|
71
|
+
| `/_bffless/auth/signup` | POST | In-page email+password sign-up |
|
|
72
|
+
| `/_bffless/auth/forgot-password` | POST | Trigger password-reset email |
|
|
73
|
+
| `/_bffless/auth/reset-password` | POST | Complete password reset with token |
|
|
74
|
+
| `/_bffless/auth/verify-email` | POST | Verify email with token |
|
|
75
|
+
| `/_bffless/auth/send-verification-email` | POST | Resend the verification email |
|
|
76
|
+
| `/_bffless/auth/login-methods` | GET | Enabled auth providers / signup gates |
|
|
77
|
+
| `/_bffless/auth/check-email` | POST | Test if an email exists in the workspace |
|
|
78
|
+
|
|
79
|
+
### Session Endpoint Response Shape
|
|
80
|
+
|
|
81
|
+
`GET /_bffless/auth/session` has **three** possible outcomes — make sure your client distinguishes all three:
|
|
82
|
+
|
|
83
|
+
| Outcome | Status | Body | Meaning |
|
|
84
|
+
| ------- | ------ | ---- | ------- |
|
|
85
|
+
| Logged in | `200` | `{ "authenticated": true, "user": { id, email, role } }` | Use the user object |
|
|
86
|
+
| **Guest** | **`200`** | **`{ "authenticated": false, "user": null }`** | **Not logged in — do NOT trust `res.ok` alone** |
|
|
87
|
+
| Expired | `401` | `"try refresh token"` | Call `/_bffless/auth/refresh`, then retry session |
|
|
88
|
+
|
|
89
|
+
**Common bug**: writing `if (res.ok) return res.json()` and treating guests as authenticated. The body's `authenticated` field is the source of truth, not the HTTP status.
|
|
59
90
|
|
|
60
91
|
### Session Check Priority
|
|
61
92
|
|
|
62
93
|
The `/_bffless/auth/session` endpoint checks auth in this order:
|
|
63
94
|
|
|
64
|
-
1. **`bffless_access` cookie** —
|
|
65
|
-
2. **`sAccessToken` cookie** — SuperTokens session (fallback for workspace subdomains)
|
|
95
|
+
1. **`bffless_access` cookie** — domain-relay JWT issued by the callback flow (cross-origin custom domains)
|
|
96
|
+
2. **`sAccessToken` cookie** — SuperTokens session (fallback for shared-parent topologies: primary domain & enterprise workspace subdomains)
|
|
66
97
|
|
|
67
98
|
If the access token is expired, it returns `401` with `"try refresh token"` to signal the client should call `/_bffless/auth/refresh`.
|
|
68
99
|
|
|
100
|
+
## Advanced: Reverse-Proxy to SuperTokens Endpoints
|
|
101
|
+
|
|
102
|
+
The built-in `/_bffless/auth/*` controller covers the read path (`session`) and the in-page sign-in / sign-up / password-reset flows, but it is **not a complete proxy to the underlying SuperTokens routes**. Two things specifically are missing:
|
|
103
|
+
|
|
104
|
+
1. **It can't clear the SuperTokens session.** `/_bffless/auth/logout` only deletes the `bffless_access` / `bffless_refresh` cookies that were minted by the domain-relay callback. The real `sAccessToken` cookie lives on the parent admin domain (`.bffless.app`, `.yourdomain.com`) and is managed by SuperTokens' `signOut()`. There's no built-in endpoint on the content domain that can revoke it.
|
|
105
|
+
2. **It exposes a curated subset of endpoints.** OAuth callbacks, provider lists (`/api/auth/oauth/*`), `session/refresh` (the SuperTokens-format refresh, distinct from `/_bffless/auth/refresh`), and a few other internal routes are only available under the admin's `/api/auth/*` namespace.
|
|
106
|
+
|
|
107
|
+
When you need any of these from a content domain, set up a **reverse proxy rule** from a prefix path on the content domain to the admin backend's `/api/auth` namespace. This is the same pattern the admin UI itself uses.
|
|
108
|
+
|
|
109
|
+
### The Proxy Rule (canonical example)
|
|
110
|
+
|
|
111
|
+
For a workspace whose admin lives at `admin.<workspace>`, add an **External Proxy** rule to the content alias:
|
|
112
|
+
|
|
113
|
+
| Field | Value |
|
|
114
|
+
| --------------------------- | ---------------------------------------------- |
|
|
115
|
+
| Path Pattern | `/auth/*` (or `/api/auth/*` — choose one) |
|
|
116
|
+
| Method | Any |
|
|
117
|
+
| Rule Type | External Proxy |
|
|
118
|
+
| Target URL | `http://localhost:3000/api/auth` (same-instance backend) **or** `https://admin.<workspace>/api/auth` (cross-instance) |
|
|
119
|
+
| Strip matched path prefix | ON |
|
|
120
|
+
| Preserve original Host | OFF |
|
|
121
|
+
| Forward cookies to target | **ON** (required — the session cookie has to travel with the request) |
|
|
122
|
+
|
|
123
|
+
`localhost:3000` is the internal CE backend on the same node; BFFless allows HTTP targets only for `*.svc` / `localhost`. For cross-instance setups, point at the admin's HTTPS URL.
|
|
124
|
+
|
|
125
|
+
With the rule above:
|
|
126
|
+
|
|
127
|
+
```
|
|
128
|
+
GET j5s.dev/auth/session → http://localhost:3000/api/auth/session
|
|
129
|
+
POST j5s.dev/auth/signout → http://localhost:3000/api/auth/signout
|
|
130
|
+
GET j5s.dev/auth/oauth/... → http://localhost:3000/api/auth/oauth/...
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### Response Shape Differs From `_bffless/auth/session`
|
|
134
|
+
|
|
135
|
+
The proxied SuperTokens session endpoint returns a **richer object** than the BFFless one — both `emailVerified` and the session handle, with `user: null` instead of `authenticated: false` for guests:
|
|
136
|
+
|
|
137
|
+
```json
|
|
138
|
+
// GET /auth/session (proxied to /api/auth/session)
|
|
139
|
+
{
|
|
140
|
+
"session": { "userId": "...", "handle": "..." },
|
|
141
|
+
"user": { "id": "...", "email": "...", "role": "admin" },
|
|
142
|
+
"emailVerified": true,
|
|
143
|
+
"emailVerificationRequired": false
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Compare with the BFFless built-in (covered above):
|
|
148
|
+
|
|
149
|
+
```json
|
|
150
|
+
// GET /_bffless/auth/session
|
|
151
|
+
{ "authenticated": true, "user": { "id": "...", "email": "...", "role": "admin" } }
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Pick one shape per client and stick with it; mixing causes the same "treated guest as authed" bug described earlier. If you need `emailVerified` on the content domain, use the proxied endpoint.
|
|
155
|
+
|
|
156
|
+
### When to Use Each
|
|
157
|
+
|
|
158
|
+
| Need | Use |
|
|
159
|
+
| ---------------------------------------------------- | ---------------------------------------------------------- |
|
|
160
|
+
| Cheap session check, no SuperTokens dep | `/_bffless/auth/session` |
|
|
161
|
+
| In-page sign-in / sign-up / forgot-password dialog | `/_bffless/auth/signin` etc. (works on true custom domains too) |
|
|
162
|
+
| Custom-domain relay callback | `/_bffless/auth/callback` (built-in, can't be proxied) |
|
|
163
|
+
| **Clearing the SuperTokens session** (real logout) | Proxied `/auth/signout` **or** bounce through `admin.<workspace>/logout` (see [Logout](#logout)) |
|
|
164
|
+
| OAuth / SSO flows started from the content domain | Proxied `/auth/oauth/*` |
|
|
165
|
+
| `emailVerified`, session handle, pending invitations | Proxied `/auth/session` |
|
|
166
|
+
|
|
167
|
+
### Caveat: This Only Works When the Cookie Reaches the Proxy
|
|
168
|
+
|
|
169
|
+
The reverse-proxy approach depends on the browser sending the SuperTokens session cookie to the content domain so BFFless can forward it. That works on the **primary domain and its subdomains** (`foo.com`, `bar.foo.com`, …) because `sAccessToken` is on `.foo.com`. It also works on `*.workspace.bffless.app` since that is mechanically the same setup.
|
|
170
|
+
|
|
171
|
+
It does **not** work on additional cross-origin custom domains (a `bat.com` attached to a `foo.com` install): the `sAccessToken` cookie never reaches `bat.com`, so the proxy has nothing to forward. Use `_bffless/auth/*` + the admin-bounce logout on those.
|
|
172
|
+
|
|
69
173
|
## Frontend Integration
|
|
70
174
|
|
|
71
175
|
### Checking Session (with automatic token refresh)
|
|
@@ -73,44 +177,54 @@ If the access token is expired, it returns `401` with `"try refresh token"` to s
|
|
|
73
177
|
Use a shared promise pattern to avoid duplicate session checks across components:
|
|
74
178
|
|
|
75
179
|
```typescript
|
|
76
|
-
|
|
180
|
+
type Session =
|
|
181
|
+
| { authenticated: true; user: { id: string; email?: string; role?: string } }
|
|
182
|
+
| { authenticated: false };
|
|
183
|
+
|
|
184
|
+
async function checkSession(): Promise<Session> {
|
|
77
185
|
// Reuse shared session promise so multiple components don't duplicate requests
|
|
78
186
|
if (!(window as any).__bfflessSession) {
|
|
79
|
-
(window as any).__bfflessSession = (async () => {
|
|
80
|
-
const
|
|
81
|
-
if (res.ok) return res.json();
|
|
187
|
+
(window as any).__bfflessSession = (async (): Promise<Session> => {
|
|
188
|
+
const get = () => fetch('/_bffless/auth/session', { credentials: 'include' });
|
|
82
189
|
|
|
190
|
+
let res = await get();
|
|
83
191
|
if (res.status === 401) {
|
|
84
|
-
// Token expired — try refreshing
|
|
192
|
+
// Token expired — try refreshing, then retry the session check
|
|
85
193
|
const refreshRes = await fetch('/_bffless/auth/refresh', {
|
|
86
194
|
method: 'POST',
|
|
87
195
|
credentials: 'include',
|
|
88
196
|
});
|
|
89
|
-
if (refreshRes.ok)
|
|
90
|
-
// Retry session check with new token
|
|
91
|
-
const retryRes = await fetch('/_bffless/auth/session', { credentials: 'include' });
|
|
92
|
-
if (retryRes.ok) return retryRes.json();
|
|
93
|
-
}
|
|
197
|
+
if (refreshRes.ok) res = await get();
|
|
94
198
|
}
|
|
95
|
-
|
|
96
|
-
|
|
199
|
+
|
|
200
|
+
if (!res.ok) return { authenticated: false };
|
|
201
|
+
|
|
202
|
+
// IMPORTANT: a 200 can still be a guest — the body decides.
|
|
203
|
+
const body = await res.json();
|
|
204
|
+
if (body?.authenticated === false || body?.user == null) {
|
|
205
|
+
return { authenticated: false };
|
|
206
|
+
}
|
|
207
|
+
return { authenticated: true, user: body.user ?? body };
|
|
208
|
+
})().catch(() => ({ authenticated: false }) as Session);
|
|
97
209
|
}
|
|
98
210
|
|
|
99
211
|
return (window as any).__bfflessSession;
|
|
100
212
|
}
|
|
101
|
-
|
|
102
|
-
// Returns: { authenticated: true, user: { id, email, role } } or null
|
|
103
213
|
```
|
|
104
214
|
|
|
105
|
-
The flow is: session check → if 401, refresh
|
|
215
|
+
The flow is: session check → if 401, refresh and retry → inspect `body.authenticated`. Do NOT treat any 200 as authenticated; the guest response is also a 200 (see the response shape table above).
|
|
106
216
|
|
|
107
217
|
### Redirecting to Login
|
|
108
218
|
|
|
109
|
-
When unauthenticated, redirect the user to the admin
|
|
219
|
+
When unauthenticated, redirect the user to the admin on the **primary domain** (e.g., `admin.foo.com`):
|
|
110
220
|
|
|
111
221
|
```typescript
|
|
112
222
|
function getLoginUrl(adminLoginUrl: string, redirectPath: string): string {
|
|
113
|
-
|
|
223
|
+
// Use `host`, NOT `hostname` — host includes the port (e.g. `localhost:5173`).
|
|
224
|
+
// Using `hostname` strips the port, and the backend builds a callback URL
|
|
225
|
+
// like `https://localhost/_bffless/auth/callback?...` (no port, wrong scheme)
|
|
226
|
+
// which is unreachable in local dev.
|
|
227
|
+
const targetDomain = window.location.host;
|
|
114
228
|
const params = new URLSearchParams({
|
|
115
229
|
customDomainRelay: 'true',
|
|
116
230
|
targetDomain,
|
|
@@ -119,11 +233,11 @@ function getLoginUrl(adminLoginUrl: string, redirectPath: string): string {
|
|
|
119
233
|
return `${adminLoginUrl}?${params.toString()}`;
|
|
120
234
|
}
|
|
121
235
|
|
|
122
|
-
// Example: redirect to admin login, then relay back to /portal/
|
|
236
|
+
// Example: redirect to admin login on the primary domain, then relay back to /portal/
|
|
123
237
|
const session = await checkSession();
|
|
124
238
|
if (!session) {
|
|
125
239
|
window.location.href = getLoginUrl(
|
|
126
|
-
'https://admin.
|
|
240
|
+
'https://admin.foo.com/login',
|
|
127
241
|
'/portal/',
|
|
128
242
|
);
|
|
129
243
|
}
|
|
@@ -131,10 +245,38 @@ if (!session) {
|
|
|
131
245
|
|
|
132
246
|
### Logout
|
|
133
247
|
|
|
248
|
+
Logout is symmetric to login: the admin host owns the SuperTokens session, so on the primary domain (and its subdomains) you have to bounce through `admin.foo.com/logout` to actually revoke. Calling `/_bffless/auth/logout` on its own is **not enough** — it only clears `bffless_access`, which isn't even set on the primary domain. See the dedicated troubleshooting entry below.
|
|
249
|
+
|
|
250
|
+
> **Alternative for the common case:** if you've configured the [reverse-proxy rule](#advanced-reverse-proxy-to-supertokens-endpoints) (e.g., `/auth/*` → admin `/api/auth`), you can `POST /auth/signout` directly from the content domain instead of bouncing through the admin page. The proxy forwards the SuperTokens session cookie and SuperTokens clears it on `.foo.com`. The admin-bounce below is the universal fallback (and the only option on additional cross-origin custom domains, where the proxy can't reach the cookie).
|
|
251
|
+
|
|
134
252
|
```typescript
|
|
135
|
-
|
|
253
|
+
async function logout(adminLogoutUrl: string) {
|
|
254
|
+
// 1. Clear the bffless_access / bffless_refresh cookies that live on this
|
|
255
|
+
// domain. No-op on the primary domain (those cookies are never set
|
|
256
|
+
// there), but required for additional cross-origin custom domains.
|
|
257
|
+
try {
|
|
258
|
+
await fetch('/_bffless/auth/logout', {
|
|
259
|
+
method: 'POST',
|
|
260
|
+
credentials: 'include',
|
|
261
|
+
});
|
|
262
|
+
} catch {
|
|
263
|
+
// ignore — the admin bounce below is the source of truth
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// 2. Bounce through the admin logout page so SuperTokens revokes the
|
|
267
|
+
// session and clears `sAccessToken` on the parent domain. The admin
|
|
268
|
+
// page validates `redirect` (same base-domain only) and sends the
|
|
269
|
+
// user back.
|
|
270
|
+
const redirect = window.location.origin + window.location.pathname;
|
|
271
|
+
window.location.href = `${adminLogoutUrl}?redirect=${encodeURIComponent(redirect)}`;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Example:
|
|
275
|
+
// logout('https://admin.foo.com/logout');
|
|
136
276
|
```
|
|
137
277
|
|
|
278
|
+
Mirrors the login flow — same admin URL pattern, just `/logout` instead of `/login`. If you derive both URLs from a single env var, do it explicitly rather than munging the login URL with regex, so the intent is obvious to the next reader.
|
|
279
|
+
|
|
138
280
|
### Updating UI Based on Auth State (Header example)
|
|
139
281
|
|
|
140
282
|
```typescript
|
|
@@ -151,15 +293,96 @@ window.__bfflessSession.then((data) => {
|
|
|
151
293
|
});
|
|
152
294
|
```
|
|
153
295
|
|
|
296
|
+
## Local Development
|
|
297
|
+
|
|
298
|
+
There is no auth backend running on `localhost`, so `/_bffless/auth/*` 404s out of the box. There are two patterns for working around this:
|
|
299
|
+
|
|
300
|
+
### 1. Proxy `/_bffless` to a deployed workspace (real auth)
|
|
301
|
+
|
|
302
|
+
Best when you want to exercise the real cookie/relay flow. Configure your dev server to proxy `/_bffless` (and usually `/api`) to a real BFFless deployment:
|
|
303
|
+
|
|
304
|
+
```ts
|
|
305
|
+
// vite.config.ts
|
|
306
|
+
export default defineConfig({
|
|
307
|
+
server: {
|
|
308
|
+
proxy: {
|
|
309
|
+
'/api': { target: 'https://foo.com', changeOrigin: true },
|
|
310
|
+
'/_bffless': { target: 'https://foo.com', changeOrigin: true },
|
|
311
|
+
},
|
|
312
|
+
},
|
|
313
|
+
});
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
Caveats:
|
|
317
|
+
- Login redirects you to the admin domain. The `targetDomain` you send must be a registered domain in that workspace — `localhost:5173` will get rejected with "Domain not registered" unless an admin adds it (or you point at a workspace that does). Most teams add their dev host to a sandbox workspace's domain mappings for this purpose.
|
|
318
|
+
- The session endpoint will return the **guest** shape (`200 { authenticated: false, user: null }`) until you complete the login + callback round trip, which is why the body-inspection pattern above is required.
|
|
319
|
+
|
|
320
|
+
### 2. Mock `/_bffless/auth/*` with MSW (no backend)
|
|
321
|
+
|
|
322
|
+
Best when you want to iterate on auth-gated UI without leaving localhost. [MSW](https://mswjs.io) intercepts at the service-worker layer so the production `fetch` calls stay untouched:
|
|
323
|
+
|
|
324
|
+
```ts
|
|
325
|
+
// src/mocks/handlers.ts
|
|
326
|
+
import { http, HttpResponse, passthrough } from 'msw';
|
|
327
|
+
|
|
328
|
+
const STORAGE_KEY = 'bffless:mockAuth';
|
|
329
|
+
|
|
330
|
+
function readMock() {
|
|
331
|
+
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}'); }
|
|
332
|
+
catch { return {}; }
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export const handlers = [
|
|
336
|
+
http.get('/_bffless/auth/session', () => {
|
|
337
|
+
const m = readMock();
|
|
338
|
+
if (!m.enabled) return passthrough();
|
|
339
|
+
if (!m.authenticated) {
|
|
340
|
+
return HttpResponse.json({ authenticated: false, user: null });
|
|
341
|
+
}
|
|
342
|
+
return HttpResponse.json({ authenticated: true, user: m.user });
|
|
343
|
+
}),
|
|
344
|
+
http.post('/_bffless/auth/refresh', () => {
|
|
345
|
+
const m = readMock();
|
|
346
|
+
if (!m.enabled) return passthrough();
|
|
347
|
+
return new HttpResponse(null, { status: m.authenticated ? 200 : 401 });
|
|
348
|
+
}),
|
|
349
|
+
http.post('/_bffless/auth/logout', () => {
|
|
350
|
+
const m = readMock();
|
|
351
|
+
if (!m.enabled) return passthrough();
|
|
352
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify({ ...m, authenticated: false }));
|
|
353
|
+
return new HttpResponse(null, { status: 204 });
|
|
354
|
+
}),
|
|
355
|
+
];
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
Then boot the worker only in dev, before render:
|
|
359
|
+
|
|
360
|
+
```ts
|
|
361
|
+
// src/main.tsx
|
|
362
|
+
async function enableMocks() {
|
|
363
|
+
if (!import.meta.env.DEV) return;
|
|
364
|
+
const { setupWorker } = await import('msw/browser');
|
|
365
|
+
const { handlers } = await import('./mocks/handlers');
|
|
366
|
+
await setupWorker(...handlers).start({ onUnhandledRequest: 'bypass' });
|
|
367
|
+
}
|
|
368
|
+
enableMocks().then(() => { /* createRoot(...).render(...) */ });
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
Add a small dev-only panel (rendered when `import.meta.env.DEV`) that writes `{ enabled, authenticated, user }` to localStorage and dispatches a `CustomEvent` so your session hook can refetch. With this setup you can toggle authed/guest and swap user attributes live without restarting the dev server.
|
|
372
|
+
|
|
373
|
+
Caveats:
|
|
374
|
+
- Returning `passthrough()` when `enabled === false` lets you fall back to a proxied real backend (pattern #1) on demand.
|
|
375
|
+
- MSW requires `public/mockServiceWorker.js`; generate it once with `npx msw init public/ --save`.
|
|
376
|
+
|
|
154
377
|
## Auth Flow Diagram
|
|
155
378
|
|
|
156
379
|
```
|
|
157
|
-
Custom Domain Flow:
|
|
380
|
+
Additional Custom Domain Flow (edge case):
|
|
158
381
|
┌──────────────────┐ JS redirect ┌──────────────────────────┐
|
|
159
|
-
│
|
|
382
|
+
│ bat.com │ ──────────────────→ │ admin.foo.com/login │
|
|
160
383
|
│ (private page) │ customDomainRelay= │ ?customDomainRelay=true │
|
|
161
|
-
│ │ true&targetDomain= │ &targetDomain=
|
|
162
|
-
└──────────────────┘
|
|
384
|
+
│ │ true&targetDomain= │ &targetDomain=bat.com │
|
|
385
|
+
└──────────────────┘ bat.com └────────────┬─────────────┘
|
|
163
386
|
▲ │
|
|
164
387
|
│ User logs in (SuperTokens)
|
|
165
388
|
│ │
|
|
@@ -169,7 +392,7 @@ Custom Domain Flow:
|
|
|
169
392
|
│ │
|
|
170
393
|
│ 302 redirect │
|
|
171
394
|
│ ←─────────────────────────────────────────────┘
|
|
172
|
-
│ to:
|
|
395
|
+
│ to: bat.com/_bffless/auth/callback?token=...
|
|
173
396
|
│
|
|
174
397
|
▼
|
|
175
398
|
┌──────────────────┐
|
|
@@ -181,9 +404,9 @@ Custom Domain Flow:
|
|
|
181
404
|
|
|
182
405
|
## Troubleshooting
|
|
183
406
|
|
|
184
|
-
**User gets stuck in a redirect loop?**
|
|
407
|
+
**User gets stuck in a redirect loop on an additional custom domain (e.g., `bat.com`)?**
|
|
185
408
|
|
|
186
|
-
- **Most common cause:**
|
|
409
|
+
- **Most common cause:** Calling `/api/auth/session` instead of `/_bffless/auth/session`. The relay flow sets `bffless_access`, which only `/_bffless/auth/*` recognizes — and `/api/auth/*` doesn't even exist on `bat.com` without a reverse-proxy rule (which wouldn't help anyway, since the `sAccessToken` cookie can't reach `bat.com`).
|
|
187
410
|
- Verify the custom domain is registered in `domain_mappings` with `isActive = true`
|
|
188
411
|
- Ensure cookies are being set (requires HTTPS for `Secure` flag)
|
|
189
412
|
|
|
@@ -194,11 +417,14 @@ Custom Domain Flow:
|
|
|
194
417
|
|
|
195
418
|
**Session check returns 401 but user just logged in?**
|
|
196
419
|
|
|
197
|
-
- On
|
|
198
|
-
- On
|
|
420
|
+
- On the primary domain or one of its subdomains: verify `COOKIE_DOMAIN` is set to `.foo.com` so `sAccessToken` is shared across them
|
|
421
|
+
- On an additional cross-origin custom domain (`bat.com` attached to a `foo.com` install): verify the `/_bffless/auth/callback` was reached and `bffless_access` was set
|
|
199
422
|
- Check that the `bffless_access` or `sAccessToken` cookie is present in the request
|
|
200
423
|
|
|
201
|
-
**
|
|
424
|
+
**Logout returns 200 "Logged out successfully" but the next session check still returns `authenticated: true`?**
|
|
425
|
+
|
|
426
|
+
This is the most-reported logout footgun on the primary domain. `/_bffless/auth/logout` only clears `bffless_access` / `bffless_refresh`, but on the primary domain those cookies never existed — the session is in `sAccessToken` on `.foo.com`, which `/_bffless/auth/logout` cannot touch.
|
|
427
|
+
|
|
428
|
+
Fix: either configure the [reverse-proxy rule](#advanced-reverse-proxy-to-supertokens-endpoints) and `POST /auth/signout` directly, or navigate to `admin.foo.com/logout?redirect=<current-page>`. The admin page calls SuperTokens `signOut()`, which revokes the session and clears the shared cookie, then redirects back. See the [Logout](#logout) section for the full pattern.
|
|
202
429
|
|
|
203
|
-
-
|
|
204
|
-
- The workspace subdomain format still works but the promoted domain is cleaner
|
|
430
|
+
On a cross-origin custom domain (`bat.com`) `/_bffless/auth/logout` actually does clear the relevant cookies (`bffless_access` / `bffless_refresh`) — no admin bounce required there.
|