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
14 changes: 9 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<n>` 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.
Expand All @@ -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:<index-digest>`,
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,
Expand All @@ -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:<image-index-digest>
bun hack/publish-entry.ts await --version 0.1.8 --digest sha256:<image-index-digest>
bun hack/publish-entry.ts submit --version 0.1.9 --digest sha256:<image-index-digest>
bun hack/publish-entry.ts await --version 0.1.9 --digest sha256:<image-index-digest>
```

Writers are serialized by `tracker-catalog-publish`; the workflow is guarded to
Expand All @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion cmd/server/internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions cmd/server/internal/api/authlog.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"regexp"
"strings"
)

Expand Down Expand Up @@ -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
}
20 changes: 20 additions & 0 deletions cmd/server/internal/api/authlog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package api
import (
"encoding/base64"
"errors"
"net/http"
"net/http/httptest"
"testing"

"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -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)
}
2 changes: 1 addition & 1 deletion web/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@appthrust/tracker",
"version": "0.1.8",
"version": "0.1.9",
"private": true,
"type": "module",
"scripts": {
Expand Down