diff --git a/README.md b/README.md index b2691fa..a835de1 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,10 @@ reason (`issuer`, `audience`, `expired_or_not_yet_valid`, `signature_or_keys`, and unverified token metadata (`alg`, `kid`, `iss`, `aud`, `azp`, `iat`, `nbf`, `exp`, the JSON type of `groups`). It never contains the token, its signature, or a personal claim. A 401 with no such line was answered by the edge. +The same 401 expires the edge's session cookies for this host +(`edge_cookies_expired=` in the line), so the SPA's navigation to `/` runs a +real sign-in and receives a newly minted token instead of replaying the refused +one. The SSO session and other apps' sessions are not touched. PostgreSQL integration tests are opt-in: they skip when both `BUILTIN_APPSERVER_TEST_DATABASE_URL` and `BUILDER_CONSOLE_TEST_DATABASE_URL` are unset. @@ -98,14 +102,14 @@ a required BuildKit secret, never through build arguments or committed files. ## Catalog and release -The next app release is **0.1.8**, also recorded in `web/package.json`. +The next app release is **0.1.9**, also recorded in `web/package.json`. `catalog/entry.yaml` carries no committed version: the first tag (`v0.1.0`) created `versions[]` with `ghcr.io/appthrust/tracker@sha256:`, stream `0.1`; later tags append at publication time. Do not add a version with an unbuilt or placeholder image digest. OpenAPI's `info.version` describes the API schema, not the app release. This repository has no hand-maintained changelog. -After the release prerequisites are available, push the SemVer tag `v0.1.8`. +After the release prerequisites are available, push the SemVer tag `v0.1.9`. The tag workflow: 1. Performs one multi-platform build on `ubuntu-24.04` for Linux amd64 and arm64, @@ -125,8 +129,8 @@ The tag workflow: Publication commands used by the workflow: ```sh -bun hack/publish-entry.ts submit --version 0.1.8 --digest sha256: -bun hack/publish-entry.ts await --version 0.1.8 --digest sha256: +bun hack/publish-entry.ts submit --version 0.1.9 --digest sha256: +bun hack/publish-entry.ts await --version 0.1.9 --digest sha256: ``` Writers are serialized by `tracker-catalog-publish`; the workflow is guarded to @@ -147,7 +151,7 @@ After publication, each estate's `AppCatalogSource` syncs the signed catalog. Install Issues from the Project Marketplace (Directory is an automatic dependency). Existing `follow` installations on stream `0.1` roll to the new version; `pinned` installations show `UpdateAvailable` and require an explicit -version update. Confirm the installation is Ready, `/releasez` reports `0.1.8`, +version update. Confirm the installation is Ready, `/releasez` reports `0.1.9`, and a signed-in Tracker page lists the Epics page in the navigation. These are release steps, not actions performed by a source-only version bump. diff --git a/cmd/server/internal/api/api.go b/cmd/server/internal/api/api.go index 2e44cc9..9e41bfb 100644 --- a/cmd/server/internal/api/api.go +++ b/cmd/server/internal/api/api.go @@ -169,7 +169,7 @@ func (a *API) authenticate(w http.ResponseWriter, r *http.Request) (identity.Ide } else { // The edge has already accepted this session, so a 401 here is the app // disagreeing with the edge about the token; record why. - log.Printf("tracker auth rejected: %s %s request_id=%q %s", r.Method, r.URL.Path, r.Header.Get("X-Request-Id"), describeRejection(r.Header.Get("Authorization"), err)) + log.Printf("tracker auth rejected: %s %s request_id=%q %s edge_cookies_expired=%d", r.Method, r.URL.Path, r.Header.Get("X-Request-Id"), describeRejection(r.Header.Get("Authorization"), err), expireEdgeSession(w, r)) httpx.WriteError(w, httpx.Fail(401, "unauthenticated", "A valid bearer token is required"), "tracker") } return identity.Identity{}, "", false diff --git a/cmd/server/internal/api/authlog.go b/cmd/server/internal/api/authlog.go index fa8d3cc..4d1126e 100644 --- a/cmd/server/internal/api/authlog.go +++ b/cmd/server/internal/api/authlog.go @@ -4,6 +4,8 @@ import ( "encoding/base64" "encoding/json" "fmt" + "net/http" + "regexp" "strings" ) @@ -96,3 +98,24 @@ func jsonType(value json.RawMessage) string { return "scalar" } } + +// edgeSessionCookie matches the cookies the edge OIDC filter keeps for this +// host: its tokens, their HMAC and expiry, and the in-flight sign-in state. +var edgeSessionCookie = regexp.MustCompile(`^(IdToken|AccessToken|RefreshToken|OauthHMAC|OauthExpires|OauthNonce|CodeVerifier)(-[0-9a-f]+)?$`) + +// expireEdgeSession drops the edge's session cookies from the browser and +// reports how many it expired. The edge only checks its own HMAC and expiry, +// so it keeps replaying a token this server refuses; without the cookies the +// next navigation runs sign-in again and mints a new token from the still +// valid SSO session, which a plain reload does not. Other apps' sessions and +// the SSO session itself are untouched. +func expireEdgeSession(w http.ResponseWriter, r *http.Request) int { + expired := 0 + for _, cookie := range r.Cookies() { + if edgeSessionCookie.MatchString(cookie.Name) { + http.SetCookie(w, &http.Cookie{Name: cookie.Name, Path: "/", MaxAge: -1, Secure: true, HttpOnly: true}) + expired++ + } + } + return expired +} diff --git a/cmd/server/internal/api/authlog_test.go b/cmd/server/internal/api/authlog_test.go index 02efb57..e64e75e 100644 --- a/cmd/server/internal/api/authlog_test.go +++ b/cmd/server/internal/api/authlog_test.go @@ -3,6 +3,8 @@ package api import ( "encoding/base64" "errors" + "net/http" + "net/http/httptest" "testing" "github.com/stretchr/testify/require" @@ -37,3 +39,21 @@ func TestDescribeRejection(t *testing.T) { }) } } + +func TestExpireEdgeSession(t *testing.T) { + request := httptest.NewRequest(http.MethodGet, "/api/tracker/v1/me", nil) + for _, name := range []string{"AccessToken-a2389e50", "IdToken-a2389e50", "RefreshToken-a2389e50", "OauthHMAC-a2389e50", "OauthExpires-a2389e50", "tracker-locale", "AccessTokenOther", "KEYCLOAK_SESSION"} { + request.AddCookie(&http.Cookie{Name: name, Value: "value"}) + } + recorder := httptest.NewRecorder() + require.Equal(t, 5, expireEdgeSession(recorder, request)) + names := []string{} + for _, cookie := range recorder.Result().Cookies() { + require.Equal(t, "/", cookie.Path) + require.Negative(t, cookie.MaxAge) + require.Empty(t, cookie.Domain) + require.Empty(t, cookie.Value) + names = append(names, cookie.Name) + } + require.ElementsMatch(t, []string{"AccessToken-a2389e50", "IdToken-a2389e50", "RefreshToken-a2389e50", "OauthHMAC-a2389e50", "OauthExpires-a2389e50"}, names) +} diff --git a/web/package.json b/web/package.json index 9175426..fa73952 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "@appthrust/tracker", - "version": "0.1.8", + "version": "0.1.9", "private": true, "type": "module", "scripts": {