@bffless/skills 1.8.1 → 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
|
|
|
@@ -55,7 +66,15 @@ All auth endpoints are available at `/_bffless/auth/*` on any domain served by B
|
|
|
55
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 |
|
|
59
78
|
|
|
60
79
|
### Session Endpoint Response Shape
|
|
61
80
|
|
|
@@ -73,11 +92,84 @@ All auth endpoints are available at `/_bffless/auth/*` on any domain served by B
|
|
|
73
92
|
|
|
74
93
|
The `/_bffless/auth/session` endpoint checks auth in this order:
|
|
75
94
|
|
|
76
|
-
1. **`bffless_access` cookie** —
|
|
77
|
-
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)
|
|
78
97
|
|
|
79
98
|
If the access token is expired, it returns `401` with `"try refresh token"` to signal the client should call `/_bffless/auth/refresh`.
|
|
80
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
|
+
|
|
81
173
|
## Frontend Integration
|
|
82
174
|
|
|
83
175
|
### Checking Session (with automatic token refresh)
|
|
@@ -124,7 +216,7 @@ The flow is: session check → if 401, refresh and retry → inspect `body.authe
|
|
|
124
216
|
|
|
125
217
|
### Redirecting to Login
|
|
126
218
|
|
|
127
|
-
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`):
|
|
128
220
|
|
|
129
221
|
```typescript
|
|
130
222
|
function getLoginUrl(adminLoginUrl: string, redirectPath: string): string {
|
|
@@ -141,11 +233,11 @@ function getLoginUrl(adminLoginUrl: string, redirectPath: string): string {
|
|
|
141
233
|
return `${adminLoginUrl}?${params.toString()}`;
|
|
142
234
|
}
|
|
143
235
|
|
|
144
|
-
// 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/
|
|
145
237
|
const session = await checkSession();
|
|
146
238
|
if (!session) {
|
|
147
239
|
window.location.href = getLoginUrl(
|
|
148
|
-
'https://admin.
|
|
240
|
+
'https://admin.foo.com/login',
|
|
149
241
|
'/portal/',
|
|
150
242
|
);
|
|
151
243
|
}
|
|
@@ -153,13 +245,15 @@ if (!session) {
|
|
|
153
245
|
|
|
154
246
|
### Logout
|
|
155
247
|
|
|
156
|
-
Logout is symmetric to login: the admin
|
|
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).
|
|
157
251
|
|
|
158
252
|
```typescript
|
|
159
253
|
async function logout(adminLogoutUrl: string) {
|
|
160
254
|
// 1. Clear the bffless_access / bffless_refresh cookies that live on this
|
|
161
|
-
// domain. No-op on
|
|
162
|
-
// there), but required for custom domains.
|
|
255
|
+
// domain. No-op on the primary domain (those cookies are never set
|
|
256
|
+
// there), but required for additional cross-origin custom domains.
|
|
163
257
|
try {
|
|
164
258
|
await fetch('/_bffless/auth/logout', {
|
|
165
259
|
method: 'POST',
|
|
@@ -178,7 +272,7 @@ async function logout(adminLogoutUrl: string) {
|
|
|
178
272
|
}
|
|
179
273
|
|
|
180
274
|
// Example:
|
|
181
|
-
// logout('https://admin.
|
|
275
|
+
// logout('https://admin.foo.com/logout');
|
|
182
276
|
```
|
|
183
277
|
|
|
184
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.
|
|
@@ -212,8 +306,8 @@ Best when you want to exercise the real cookie/relay flow. Configure your dev se
|
|
|
212
306
|
export default defineConfig({
|
|
213
307
|
server: {
|
|
214
308
|
proxy: {
|
|
215
|
-
'/api': { target: 'https://
|
|
216
|
-
'/_bffless': { target: 'https://
|
|
309
|
+
'/api': { target: 'https://foo.com', changeOrigin: true },
|
|
310
|
+
'/_bffless': { target: 'https://foo.com', changeOrigin: true },
|
|
217
311
|
},
|
|
218
312
|
},
|
|
219
313
|
});
|
|
@@ -283,12 +377,12 @@ Caveats:
|
|
|
283
377
|
## Auth Flow Diagram
|
|
284
378
|
|
|
285
379
|
```
|
|
286
|
-
Custom Domain Flow:
|
|
380
|
+
Additional Custom Domain Flow (edge case):
|
|
287
381
|
┌──────────────────┐ JS redirect ┌──────────────────────────┐
|
|
288
|
-
│
|
|
382
|
+
│ bat.com │ ──────────────────→ │ admin.foo.com/login │
|
|
289
383
|
│ (private page) │ customDomainRelay= │ ?customDomainRelay=true │
|
|
290
|
-
│ │ true&targetDomain= │ &targetDomain=
|
|
291
|
-
└──────────────────┘
|
|
384
|
+
│ │ true&targetDomain= │ &targetDomain=bat.com │
|
|
385
|
+
└──────────────────┘ bat.com └────────────┬─────────────┘
|
|
292
386
|
▲ │
|
|
293
387
|
│ User logs in (SuperTokens)
|
|
294
388
|
│ │
|
|
@@ -298,7 +392,7 @@ Custom Domain Flow:
|
|
|
298
392
|
│ │
|
|
299
393
|
│ 302 redirect │
|
|
300
394
|
│ ←─────────────────────────────────────────────┘
|
|
301
|
-
│ to:
|
|
395
|
+
│ to: bat.com/_bffless/auth/callback?token=...
|
|
302
396
|
│
|
|
303
397
|
▼
|
|
304
398
|
┌──────────────────┐
|
|
@@ -310,9 +404,9 @@ Custom Domain Flow:
|
|
|
310
404
|
|
|
311
405
|
## Troubleshooting
|
|
312
406
|
|
|
313
|
-
**User gets stuck in a redirect loop?**
|
|
407
|
+
**User gets stuck in a redirect loop on an additional custom domain (e.g., `bat.com`)?**
|
|
314
408
|
|
|
315
|
-
- **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`).
|
|
316
410
|
- Verify the custom domain is registered in `domain_mappings` with `isActive = true`
|
|
317
411
|
- Ensure cookies are being set (requires HTTPS for `Secure` flag)
|
|
318
412
|
|
|
@@ -323,21 +417,14 @@ Custom Domain Flow:
|
|
|
323
417
|
|
|
324
418
|
**Session check returns 401 but user just logged in?**
|
|
325
419
|
|
|
326
|
-
- On
|
|
327
|
-
- 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
|
|
328
422
|
- Check that the `bffless_access` or `sAccessToken` cookie is present in the request
|
|
329
423
|
|
|
330
|
-
**Admin login URL — use promoted domain, not workspace subdomain:**
|
|
331
|
-
|
|
332
|
-
- If the workspace has a promoted domain (e.g., `console.bffless.app`), use `admin.console.bffless.app`, NOT `admin.console.workspace.bffless.app`
|
|
333
|
-
- The workspace subdomain format still works but the promoted domain is cleaner
|
|
334
|
-
|
|
335
424
|
**Logout returns 200 "Logged out successfully" but the next session check still returns `authenticated: true`?**
|
|
336
425
|
|
|
337
|
-
This is the most-reported logout footgun.
|
|
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.
|
|
338
427
|
|
|
339
|
-
-
|
|
340
|
-
- The session endpoint falls back to the **SuperTokens session** (`sAccessToken`) when no `bffless_access` cookie is present. That cookie lives on the parent domain (`.bffless.app` / `.yourdomain.com`) and was set by the admin login — it is not cleared by `/_bffless/auth/logout`.
|
|
341
|
-
- On workspace subdomains the `bffless_access` cookie was never set in the first place, so `/_bffless/auth/logout` is effectively a no-op and the SuperTokens fallback re-authenticates the user immediately.
|
|
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.
|
|
342
429
|
|
|
343
|
-
|
|
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.
|