Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .agents/skills/verify-fullstack-template/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ Each line starts with `ok` or `FAIL`. Exit code 0 means the stack is worth drivi
- The current run and its `stack.json` exist.
- The AppHost pid of this checkout is the pid that `launch.sh` recorded. Thus the stack is ours.
- Each resource is `Running` and `Healthy`.
- The web URL is unchanged, `/api/v1/weather` is 200, `/bff/user` is 401 or 200, and the server `/health` is 200.
- The web URL is unchanged, `/api/v1/weather` is 200, `/bff/user` is 204 or 200, and the server `/health` is 200.
- If the stack uses `DuendeDemo`, the Duende demo IdP is reachable. If it is not, no login recipe can pass. A local provider, such as ZeroFactor, is a resource of the stack, so the health check above examines it.
- `HEAD` is the commit of the launch. If it moved, the doctor prints `warn`. Frontend code hot-reloads. For server or BFF code, run `dotnet aspire resource server rebuild --apphost FullstackTemplate.AppHost` (or `bff`).

Expand Down Expand Up @@ -144,7 +144,7 @@ Proof standards:
- Capture the action and the result. A recipe needs a capture before and after the change of state, not only the last screen.
- Prove side effects next to the visible result. Examples: the HTTP status and the JSON of the API response, the `__Host-bff` cookie, the `ui-theme` value in `localStorage`.
- Compare the screen with the data. For example, each weather card must show the `summary` and the temperature of the API row, not only "5 cards".
- Read `console.log` and `network.log` after each run. These lines are normal: one `401 /bff/user` for each anonymous page load, the 401 lines from the anonymous probes that a recipe makes on purpose, and, with DuendeDemo, the iframe `sandbox` warning from the Duende pages. Report each other error, even if the recipe passed.
- Read `console.log` and `network.log` after each run. These lines are normal: the 401 lines from the anonymous probes that a recipe makes on purpose, and, with DuendeDemo, the iframe `sandbox` warning from the Duende pages. Report each other error, even if the recipe passed.
- Look at the screenshots. If something looks wrong (clipped, misaligned, unreadable in dark mode), report it, even if it is not part of your change.
- Do not mock the app. ZeroFactor is a mock IdP, but it gives real OIDC tokens. The BFF and the server validate them as they validate the tokens of any other provider. The Duende demo IdP is the only external system.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ A visitor logs in with the `Login` button. The BFF sends the browser to the IdP

## Sub-features

- `auth-anonymous` shows `Login`, and `GET /bff/user` is 401.
- `auth-anonymous` shows `Login`, and `GET /bff/user` is 204.
- `auth-login` goes through `/bff/login`, the login page of the IdP, and `/signin-oidc`, back to `/`.
- `auth-session` shows `Welcome, <name>` and `Logout`. The session survives a reload.
- `auth-logout` goes through the BFF logout and the IdP logout, back to `/` with `Login`.
Expand All @@ -22,20 +22,20 @@ Preconditions:
- `doctor.sh` exits with 0.
- The browser context is new, so nobody is logged in.

- **Anonymous state.** Open `/`. Run `page.goto('/')` after `waitForResponse` on `/bff/user`. The response is 401 and the button `Login` is visible.
- **Anonymous state.** Open `/`. Run `page.goto('/')` after `waitForResponse` on `/bff/user`. The response is 204 and the button `Login` is visible.
- **Log in.** Choose `Login`, then sign in at the IdP. Run `const { name } = await login()`. The helper clicks `getByRole('button', { name: 'Login' })`. At ZeroFactor, it clicks the user `[data-testid="user-admin@example.com"]`. At DuendeDemo, it fills `#Input_Username` and `#Input_Password` with `bob` and clicks `button[value="login"]`. The browser returns to the `web` origin, and the button `Logout` is visible. `name` is the `name` claim from `GET /bff/user`.
- **Session.** Read the greeting. Run ``page.getByText(`Welcome, ${name}`)``. The greeting is visible. `context.cookies()` contains `__Host-bff`.
- **Reload.** Run `page.reload()`. The greeting is visible again.
- **Log out.** Choose `Logout`. Run `logout()`. The helper clicks `Logout`. ZeroFactor sends the browser back at once. At DuendeDemo, the helper clicks the link `a[href*="signout-callback-oidc"]`. The button `Login` is visible on `/`.
- **Logged-out state.** Run `fetch('/bff/user', { headers: { 'X-CSRF': '1' } })` in the page. The status is 401.
- **Logged-out state.** Run `fetch('/bff/user', { headers: { 'X-CSRF': '1' } })` in the page. The status is 204.
- **Proof.** Run `node $S/scripts/browse.mjs $S/recipes/login-logout.mjs`. The captures `anonymous`, `logged-in`, and `logged-out` show the three states.

## Gotchas

- With DuendeDemo, the IdP is a public server. If it is down, login fails. The doctor examines it.
- The Duende demo server sometimes sends its CSS and scripts very slowly (17 to 29 s were seen). Thus the page `load` event can take more than 30 s. In a new login script, wait for `domcontentloaded` and for `#Input_Username`, never for `load`. `login()` does this.
- Each call to `/bff/*` and `/api/*` needs the header `X-CSRF: 1`. Without it, the BFF answers 401 even with a valid session.
- Each anonymous page load logs a `401 /bff/user` line in `network.log` and `console.log`. This is normal.
- An anonymous visitor is a normal state. `/bff/user` answers 204, so `network.log` and `console.log` get no line for it.
- The greeting shows the `name` claim (`Admin User`, or `Bob Smith` at DuendeDemo), not the user name.
- Duende shows a "return to the application" page after logout. If the recipe does not follow its link, the page stays on the IdP.
- `launch.sh` sets the provider, whatever the default in `appsettings.json` is. ZeroFactor is the default. `login()` supports only ZeroFactor and DuendeDemo. Other providers use other forms and users. See `frontend/e2e/idps/` for their selectors.
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
// Feature: login-logout. BFF login through the IdP of the stack. See features/login-logout.md.
export default async ({ page, context, step, capture, expect, login, logout }) => {
await step('anonymous: /bff/user is 401 and the page shows Login', async () => {
await step('anonymous: /bff/user is 204 and the page shows Login', async () => {
const res = page.waitForResponse((r) => r.url().endsWith('/bff/user'))
await page.goto('/')
expect((await res).status()).toBe(401)
expect((await res).status()).toBe(204)
await expect(page.getByRole('button', { name: 'Login' })).toBeVisible()
})
await capture('anonymous')
Expand All @@ -24,9 +24,9 @@ export default async ({ page, context, step, capture, expect, login, logout }) =

await step('log out', () => logout())

await step('logged out: /bff/user is 401 again', async () => {
await step('logged out: /bff/user is 204 again', async () => {
const status = await page.evaluate(async () => (await fetch('/bff/user', { headers: { 'X-CSRF': '1' } })).status)
expect(status).toBe(401)
expect(status).toBe(204)
await expect(page.getByText(`Welcome, ${name}`)).toHaveCount(0)
})
await capture('logged-out')
Expand Down
2 changes: 1 addition & 1 deletion .agents/skills/verify-fullstack-template/scripts/doctor.sh
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ server="$(jq -r .urls.server "$stack")"
code="$(curl -s -o /dev/null -w '%{http_code}' -H 'X-CSRF: 1' "$web/api/v1/weather")"
[[ "$code" == "200" ]] && ok "GET $web/api/v1/weather -> 200" || fail "GET $web/api/v1/weather -> $code"
code="$(curl -s -o /dev/null -w '%{http_code}' -H 'X-CSRF: 1' "$web/bff/user")"
[[ "$code" == "401" || "$code" == "200" ]] && ok "GET /bff/user -> $code" || fail "GET /bff/user -> $code"
[[ "$code" == "204" || "$code" == "200" ]] && ok "GET /bff/user -> $code" || fail "GET /bff/user -> $code"
code="$(curl -sk -o /dev/null -w '%{http_code}' "$server/health")"
[[ "$code" == "200" ]] && ok "GET $server/health -> 200" || fail "GET $server/health -> $code"

Expand Down
5 changes: 3 additions & 2 deletions FullstackTemplate.Bff/User/UserEndpoint.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ namespace FullstackTemplate.Bff.User;

/// <summary>
/// <c>/bff/user</c> returns the claims of the BFF session to the SPA. It is a BFF API endpoint, so it needs
/// <c>X-CSRF: 1</c>. An anonymous request gets a 401 with an empty body.
/// <c>X-CSRF: 1</c>. An anonymous request gets a 204 with an empty body. A signed-out visitor is a normal state, and a
/// browser logs each 4xx response as an error in the console.
/// </summary>
public static class UserEndpoint
{
Expand All @@ -27,7 +28,7 @@ public static async Task HandleAsync(HttpContext context, TimeProvider timeProvi
var result = await context.AuthenticateAsync(CookieAuthenticationDefaults.AuthenticationScheme);
if (!result.Succeeded)
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
context.Response.StatusCode = StatusCodes.Status204NoContent;
return;
}

Expand Down
4 changes: 2 additions & 2 deletions docs/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ The design is in [docs/specs/home-rolled-bff.md](specs/home-rolled-bff.md). The
|---|---|---|---|
| `/bff/login` | any | no | Starts a login |
| `/bff/logout` | GET | no | Ends the BFF session and starts the logout at the provider |
| `/bff/user` | GET | yes | The claims of the BFF session, or 401 |
| `/bff/user` | GET | yes | The claims of the BFF session, or 204 when no one is signed in |
| `/bff/backchannel` | POST | no | The back-channel logout receiver. Only the provider calls it. |
| `/signin-oidc` | GET | no | The login callback |
| `/signout-callback-oidc` | GET | no | The return from the logout at the provider |
Expand Down Expand Up @@ -330,7 +330,7 @@ The script also gives the tests the client id and the client secret of the BFF,
- The refresh rotation test uses the client of the BFF for its own code flow. It proves that the provider rejects the old refresh token after one refresh. It runs if `sessionFeatures.refreshRotation` is true in the IdP file: Keycloak, FusionAuth, and Authentik.
- The BFF refresh test makes two API calls through the BFF, and each call refreshes. The second call succeeds only if the BFF stored the rotated refresh token of the first call. Then the test logs out, and the `id_token_hint` proves that the BFF stored the ID token of the second refresh. It runs if `sessionFeatures.refreshRotation` is true in the IdP file: Keycloak, FusionAuth, and Authentik. On a provider that does not reject a reused refresh token, the test cannot prove the rotation.
- The refresh lock test makes two API calls through the BFF at the same time, and both need the same refresh. Both calls must succeed, and the BFF session must still work after them. With no refresh lock, both calls send the same refresh token, and the provider rejects the second one. It runs on the same providers as the BFF refresh test.
- The back-channel logout test ends the session at the provider through its admin API, and expects a 401 from `/bff/user`. It runs if `sessionFeatures.backChannelLogoutReachesBff` is true in the IdP file: on Keycloak, with the bootstrap admin, and on Authentik, with the `authentik-bootstrap-token` parameter of the AppHost. Authentik creates this token only on the first start of its database.
- The back-channel logout test ends the session at the provider through its admin API, and expects a 204 from `/bff/user`. It runs if `sessionFeatures.backChannelLogoutReachesBff` is true in the IdP file: on Keycloak, with the bootstrap admin, and on Authentik, with the `authentik-bootstrap-token` parameter of the AppHost. Authentik creates this token only on the first start of its database.

## Conformance

Expand Down
2 changes: 1 addition & 1 deletion docs/frontend/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ An error body must have the shape that ASP.NET sends. Make it with `aProblem()`.

### Identity

The SPA knows the user only from `GET /bff/user`. The `actor` fixture gives the claims of that answer, so a test never drives a login form. The default actor is anonymous, and `/bff/user` answers 401. To act as a signed-in user, add this line to a `describe` block:
The SPA knows the user only from `GET /bff/user`. The `actor` fixture gives the claims of that answer, so a test never drives a login form. The default actor is anonymous, and `/bff/user` answers 204. To act as a signed-in user, add this line to a `describe` block:

```ts
test.use({ actor: as.user('Bob Smith') })
Expand Down
9 changes: 5 additions & 4 deletions docs/specs/home-rolled-bff.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ These paths go away on purpose ([Duende BFF features to drop](https://github.com
### `/bff/user`

- Without `X-CSRF: 1`, the response is 401 with an empty body.
- Anonymous: 401 with an empty body.
- Anonymous: 204 with an empty body. Change: Duende BFF answered 401. A browser logs each 4xx response as a console error, and a signed-out visitor is a normal state.
- Signed in: 200 `application/json`. The body is an array of `{ "type": string, "value": string, "valueType": null }`. The array has these items, in this order:
1. Each claim of the BFF session principal, in the stored order.
2. `bff:logout_url`, if the principal does not have it: `PathBase + /bff/logout`, plus `?sid=<url-encoded sid>` if a `sid` claim exists.
Expand Down Expand Up @@ -401,7 +401,7 @@ If a check fails, the refresh fails with the error `bff_invalid_refresh_id_token

The checks and the revocation do not use the cancellation of `Bff:Refresh:TokenRequestTimeout`, because that cancellation gives a temporary failure and keeps the BFF session. Each gets its own limit of `Bff:Refresh:TokenRequestTimeout` instead. If the signing keys do not load within the limit, the check fails. A revocation that fails or reaches its limit is logged, and the refresh still fails with `bff_invalid_refresh_id_token`.

Each failed refresh adds to `bff.refresh.failures` with `kind=permanent` or `kind=temporary`. After a permanent failure, the next `/bff/user` gives a 401, so the SPA shows the user as signed out. The SPA does not change. The refresh has no retry inside the call. The next request that needs a token tries again. A retry inside the call can send a rotated refresh token two times, after the identity provider used it but the response got lost, and then the session ends. A retry also holds the refresh lock and its `bffdb` connection longer. The refresh goes through the backchannel `HttpClient` of the OIDC handler, which does not come from `IHttpClientFactory`, so the standard resilience handler of the service defaults does not apply to it.
Each failed refresh adds to `bff.refresh.failures` with `kind=permanent` or `kind=temporary`. After a permanent failure, the next `/bff/user` gives a 204, so the SPA shows the user as signed out. The SPA does not change. The refresh has no retry inside the call. The next request that needs a token tries again. A retry inside the call can send a rotated refresh token two times, after the identity provider used it but the response got lost, and then the session ends. A retry also holds the refresh lock and its `bffdb` connection longer. The refresh goes through the backchannel `HttpClient` of the OIDC handler, which does not come from `IHttpClientFactory`, so the standard resilience handler of the service defaults does not apply to it.

**The refresh lock.** Our own `IUserTokenRequestConcurrencyControl` replaces the default. It has two layers:

Expand Down Expand Up @@ -491,7 +491,8 @@ Each behavior of the current BFF, from [the inventory](https://github.com/pdevit
| 6 | `/bff/logout`: local `returnUrl` | Kept | Open-redirect defense | 3.4 |
| 7 | `/bff/logout`: refresh token revocation | Changed | `Auth:Features:Revocation` replaces `RevokeRefreshTokenOnLogout`. A missing endpoint is safe. [#18](https://github.com/pdevito3/FullstackTemplate/issues/18) | 3.3, 3.4 |
| 8 | `/bff/logout`: RP-initiated logout with `id_token_hint` | Kept | A missing `end_session_endpoint` now falls back to the local `returnUrl` | 3.4 |
| 9 | `/bff/user`: `X-CSRF`, 401 empty body when anonymous | Kept | Wire contract | 2 |
| 9 | `/bff/user`: `X-CSRF`, 401 empty body without it | Kept | Wire contract | 2 |
| 9a | `/bff/user`: 204 empty body when anonymous, not 401 | Changed | A browser logs each 4xx response as a console error, and a signed-out visitor is a normal state | 2 |
| 10 | `/bff/user`: claim array, `bff:logout_url`, `bff:session_state` | Kept | Wire contract | 2 |
| 11 | `/bff/user`: `bff:session_expires_in` as a number | Changed | It now also reflects the absolute limit | 2, 3.1 |
| 12 | `/bff/user?slide=false` | Kept | [#22](https://github.com/pdevito3/FullstackTemplate/issues/22) | 2 |
Expand Down Expand Up @@ -621,7 +622,7 @@ These tests come from the tickets. Each one is in addition to the tests of secti
| Unit | Each `Auth:Features:PushedAuthorization` value maps to the correct `PushedAuthorizationBehavior`. | [#13](https://github.com/pdevito3/FullstackTemplate/issues/13) |
| Integration | A forged `X-Forwarded-Host` on `/bff/login` still gives the configured `redirect_uri`. | [#16](https://github.com/pdevito3/FullstackTemplate/issues/16) |
| Integration | A `Host` that is not on the list gets a 400. | [#16](https://github.com/pdevito3/FullstackTemplate/issues/16) |
| Integration | `invalid_grant`: the BFF session row is gone, the request goes with no token, and `/bff/user` returns 401. | [#20](https://github.com/pdevito3/FullstackTemplate/issues/20) |
| Integration | `invalid_grant`: the BFF session row is gone, the request goes with no token, and `/bff/user` returns 204. | [#20](https://github.com/pdevito3/FullstackTemplate/issues/20) |
| Integration | A 503 while the token is still valid: the request goes with the current token, and the BFF session stays. | [#20](https://github.com/pdevito3/FullstackTemplate/issues/20) |
| Integration | A 503 after the token expired: the request goes with no token, the BFF session stays, and the next call tries the refresh again. | [#20](https://github.com/pdevito3/FullstackTemplate/issues/20) |
| Integration | A browser `Authorization` header never reaches the API, for an anonymous request too. | [#21](https://github.com/pdevito3/FullstackTemplate/issues/21) |
Expand Down
2 changes: 1 addition & 1 deletion frontend/conformance/run-conformance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ async function drive(browser: Browser, bff: string, flow: Flow, screenshot: stri
}
const user = async () => {
const response = await context.request.get(`${bff}/bff/user`, { headers: { 'X-CSRF': '1' } })
return { status: response.status(), claims: response.ok() ? ((await response.json()) as { type: string; value: string }[]) : [] }
return { status: response.status(), claims: response.status() === 200 ? ((await response.json()) as { type: string; value: string }[]) : [] }
}

await step('login', login)
Expand Down
2 changes: 1 addition & 1 deletion frontend/e2e/auth-flows.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -591,7 +591,7 @@ test.describe(`Back-channel logout - ${currentIdp.name}`, () => {
}

// The BFF deletes the session row when the logout token arrives. Authentik sends it from a worker task.
await expect.poll(() => bffUserStatus(page), { timeout: 30000 }).toBe(401)
await expect.poll(() => bffUserStatus(page), { timeout: 30000 }).toBe(204)
})
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ export function FilterControlDialog({
}}
className={FILTER_CONTROLS[config.controlType].dialogClassName ?? DEFAULT_DIALOG_CLASS_NAME}
>
<div className="flex flex-col gap-4">
{/* A control pads its sides for the submenu popover. The dialog pads its own body, so here the control does not. */}
<div className="flex flex-col gap-4 *:px-0">
<ResponsiveDialogHeader>
<ResponsiveDialogTitle>
{initialFilter ? 'Edit' : 'Add'} {config.propertyLabel} Filter
Expand Down
Loading
Loading