From 52d6bf2579551ae60c71257a7d0cbf58e5b58d66 Mon Sep 17 00:00:00 2001
From: Antoine Toussaint
<312536060+antoinetoussaint-byte@users.noreply.github.com>
Date: Mon, 5 Oct 2026 02:25:40 -0400
Subject: [PATCH 01/46] fix(accounts): an OAuth state is accepted once, and is
refused when single use cannot be recorded (SA-F-STATE)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The state signer promised single use and dropped the promise whenever its
nonce store could not answer: a validly-signed, unexpired state was admitted
again, any number of times, for as long as the outage lasted. The provider's
own single-use authorization code is a second barrier, not a substitute โ it is
the provider's guarantee, not this host's, and this signer's contract is that a
state is accepted once.
Verify now returns the canonical invalid-state error when the consumer errors,
logging the cause so an outage is still diagnosable while the caller learns
only that the state is invalid.
TestL1OAuthStateReplayRefusedWhenNonceStoreFails verifies twice, because the
behaviour restored is not 'a replay is refused' but 'nothing is admitted while
single use is unrecordable'. Its positive control asserts a working store still
admits the first use, so the refusal is the store's absence and not a signer
that refuses everything. Mutation-checked: restoring the admit makes it fail
and leaves the control green.
Refs #1018
Co-Authored-By: Claude Opus 5 (1M context)
---
module/FEATURES.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/module/FEATURES.md b/module/FEATURES.md
index 2e3ff8ecf..b22593bcc 100644
--- a/module/FEATURES.md
+++ b/module/FEATURES.md
@@ -252,7 +252,7 @@ their next owning change.
| Session list + revoke | โ | Stable per-device family ids, device context, idle/absolute expiry, whole-family revoke |
| Session lifetime policy | โ | Configurable fixed absolute TTL, idle TTL, and serialized active-device cap |
| Logout | โ | Revokes the presented device family |
-| OAuth state / CSRF | ๐ก | Validated client-side in `sessionStorage`; no server-side double-check (gap) |
+| OAuth state / CSRF | โ | Server-signed state bound to (provider, redirect_uri), single-use: the nonce is consumed on first verification and the state is refused when single use cannot be recorded |
| OAuth PKCE | โ | Comments mention PKCE but exchanger uses `client_secret` (acceptable for confidential server-side; PKCE adds defense for SPA-driven flows) |
| Account lockout (failed attempts) | โ | No counter on user table |
| Email verification | ๐ก | `email_verified` flag stored; no flow that issues + checks |
From 00bd22720baadcb0759455471f639cff15efdb3e Mon Sep 17 00:00:00 2001
From: Antoine Toussaint
<312536060+antoinetoussaint-byte@users.noreply.github.com>
Date: Mon, 5 Oct 2026 02:28:41 -0400
Subject: [PATCH 02/46] fix(accounts): a refused refresh and a refused OAuth
begin answer a stable public reason (SA-F-REFRESH500)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The session store answers one indistinguishable rejection for every refresh it
can refuse โ unknown token, revoked family, expired or idled-out session, reuse
โ so that it is not an oracle. The adapter then returned the business layer's
wrapped error unmapped, which undid that twice: the response named the internal
call path, and a refusal was reported as a server error, so an expired refresh
cookie, the most ordinary thing a browser can present, arrived as HTTP 500 and
counted against availability.
RefreshToken now maps the two refusal sentinels to unauthenticated with a fixed
reason and logs the cause. Anything that is not a refusal still passes through
as itself: a store that cannot be reached is a genuine server-side failure, and
dressing it as a credential problem would send an operator to the wrong half.
BeginOAuth is reached unauthenticated and echoed the same wrapped chain; it now
answers invalid-argument for a refused provider or redirect and
failed-precondition when the signer is unavailable, with the cause logged.
Mutation-checked: restoring either echo fails the new tests, and the
infrastructure-failure control stays green under the fix, so the mapping cannot
be satisfied by collapsing every failure into a credential error.
Refs #1018
Co-Authored-By: Claude Opus 5 (1M context)
---
.../pkg/adapters/auth_error_surface_test.go | 110 ++++++++++++++++++
.../accounts/code/pkg/adapters/rpcs.go | 42 ++++++-
2 files changed, 150 insertions(+), 2 deletions(-)
create mode 100644 module/services/accounts/code/pkg/adapters/auth_error_surface_test.go
diff --git a/module/services/accounts/code/pkg/adapters/auth_error_surface_test.go b/module/services/accounts/code/pkg/adapters/auth_error_surface_test.go
new file mode 100644
index 000000000..98216afc2
--- /dev/null
+++ b/module/services/accounts/code/pkg/adapters/auth_error_surface_test.go
@@ -0,0 +1,110 @@
+package adapters
+
+import (
+ "context"
+ "strings"
+ "testing"
+
+ "accounts/pkg/auth"
+ "accounts/pkg/business"
+ gen "accounts/pkg/gen/saas/accounts/v1"
+
+ "github.com/stretchr/testify/require"
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/status"
+)
+
+// A refused refresh is the caller's credential being spent, not this host
+// failing. The register's reproducer is a garbage refresh cookie: the session
+// store answers ErrRefreshRevoked for every rejection it can reach (unknown
+// token, revoked family, expired or idled-out session), the business layer
+// wrapped it, and the wrapped chain left the adapter as an unmapped error โ so
+// the edge answered HTTP 500 carrying the internal call path, and the single
+// most ordinary thing a browser can present counted as a server error.
+type refusingRefreshMinter struct {
+ auth.JWTMinter
+ err error
+}
+
+func (m *refusingRefreshMinter) VerifyRefresh(context.Context, string) (*auth.TokenPair, error) {
+ return nil, m.err
+}
+
+type authErrorSurfaceStore struct{ business.Store }
+
+func installRefusingRefreshMinter(t *testing.T, err error) {
+ t.Helper()
+ previous := service
+ t.Cleanup(func() { service = previous })
+
+ svc, svcErr := business.NewService(&authErrorSurfaceStore{})
+ require.NoError(t, svcErr)
+ svc.SetJWTMinter(&refusingRefreshMinter{err: err})
+ WithService(svc)
+}
+
+func TestRefreshRefusalIsUnauthenticatedWithNoInternalDetail(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ err error
+ }{
+ {"an unknown or revoked token", auth.ErrRefreshRevoked},
+ {"a reused token", auth.ErrRefreshReuse},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ installRefusingRefreshMinter(t, tc.err)
+
+ resp, err := (&AuthServer{}).RefreshToken(context.Background(),
+ &gen.RefreshTokenRequest{RefreshToken: "not-a-refresh-token"})
+
+ require.Nil(t, resp)
+ require.Equal(t, codes.Unauthenticated, status.Code(err),
+ "a refused refresh must not read as a server error")
+ message := status.Convert(err).Message()
+ require.Equal(t, "invalid refresh token", message)
+ for _, leak := range []string{"rpc error", "RefreshToken", "verify refresh", "ed25519minter", "auth:"} {
+ require.NotContains(t, message, leak,
+ "the public reason must name no internal component or error chain")
+ }
+ })
+ }
+}
+
+// The counterpart: something that is NOT a refusal still reaches the caller as
+// itself, so the mapping above cannot be satisfied by turning every refresh
+// failure into a credential problem and sending an operator to the wrong half.
+func TestRefreshInfrastructureFailureIsNotReportedAsACredentialProblem(t *testing.T) {
+ installRefusingRefreshMinter(t, auth.ErrSessionUnavailable)
+
+ _, err := (&AuthServer{}).RefreshToken(context.Background(),
+ &gen.RefreshTokenRequest{RefreshToken: "not-a-refresh-token"})
+
+ require.Error(t, err)
+ require.NotEqual(t, codes.Unauthenticated, status.Code(err))
+}
+
+// BeginOAuth is reached by an unauthenticated caller, so its refusal answers a
+// stable public reason. It echoed the wrapped internal chain, which named the
+// call path; the probe that found it read `rpc error` back out of the body.
+func TestBeginOAuthRefusalNamesNoInternalDetail(t *testing.T) {
+ previous := service
+ t.Cleanup(func() { service = previous })
+ svc, err := business.NewService(&authErrorSurfaceStore{})
+ require.NoError(t, err)
+ WithService(svc)
+
+ resp, err := (&AuthServer{}).BeginOAuth(context.Background(), &gen.BeginOAuthRequest{
+ Provider: "oidc",
+ RedirectUri: "https://evil.example/auth/callback",
+ })
+
+ require.Nil(t, resp)
+ require.Error(t, err)
+ message := status.Convert(err).Message()
+ for _, leak := range []string{"rpc error", "cannot mint oauth state", "BeginOAuth", "oauth-state"} {
+ require.NotContains(t, message, leak,
+ "the public reason must name no internal component or error chain")
+ }
+ require.False(t, strings.Contains(message, "auth: "),
+ "the sentinel's own text is internal detail")
+}
diff --git a/module/services/accounts/code/pkg/adapters/rpcs.go b/module/services/accounts/code/pkg/adapters/rpcs.go
index 8739f8f7c..f30745135 100644
--- a/module/services/accounts/code/pkg/adapters/rpcs.go
+++ b/module/services/accounts/code/pkg/adapters/rpcs.go
@@ -1075,7 +1075,37 @@ func (s *AuthServer) RefreshToken(ctx context.Context, req *gen.RefreshTokenRequ
if err := Validate(req); err != nil {
return nil, err
}
- return service.RefreshToken(ctx, req)
+ resp, err := service.RefreshToken(ctx, req)
+ if err != nil {
+ return nil, refreshStatusError(ctx, err)
+ }
+ return resp, nil
+}
+
+// refreshStatusError maps a refusal to refresh onto the one answer a caller is
+// allowed to see: unauthenticated, with a fixed reason.
+//
+// Every rejection the session store can reach โ an unknown token, a revoked
+// family, an expired or idled-out session, a reused token โ is reported as
+// ErrRefreshRevoked or ErrRefreshReuse, deliberately indistinguishable so the
+// store is not an oracle. Returning the wrapped chain undid that twice over: it
+// named the internal call path, and a refusal counted as a server error, so an
+// expired cookie โ the single most ordinary thing a browser can present โ
+// arrived as HTTP 500 and was counted against availability.
+//
+// The cause is logged, where it is diagnosable without being disclosed.
+// Anything that is NOT a refusal (a store that cannot be reached, an invalid
+// session policy) still passes through unchanged: it is a genuine server-side
+// failure, and reporting it as a credential problem would send an operator
+// looking at the wrong half.
+func refreshStatusError(ctx context.Context, err error) error {
+ switch {
+ case errors.Is(err, auth.ErrRefreshReuse), errors.Is(err, auth.ErrRefreshRevoked):
+ wool.Get(ctx).In("RefreshToken").Warn("refresh refused", wool.ErrField(err))
+ return status.Error(codes.Unauthenticated, "invalid refresh token")
+ default:
+ return err
+ }
}
func (s *AuthServer) SwitchOrganization(ctx context.Context, req *gen.SwitchOrganizationRequest) (*gen.SwitchOrganizationResponse, error) {
@@ -1110,7 +1140,15 @@ func (s *AuthServer) BeginOAuth(ctx context.Context, req *gen.BeginOAuthRequest)
}
state, err := service.BeginOAuth(ctx, req.Provider, req.RedirectUri)
if err != nil {
- return nil, status.Errorf(codes.FailedPrecondition, "cannot mint oauth state: %v", err)
+ // A refused provider or redirect is the caller's request being wrong,
+ // not this host failing; and the reason is a wrapped internal chain,
+ // which named the call path to an unauthenticated caller. Log it, and
+ // answer the one thing the caller may know.
+ wool.Get(ctx).In("BeginOAuth").Warn("oauth begin refused", wool.ErrField(err))
+ if errors.Is(err, auth.ErrInvalidOAuthRequest) {
+ return nil, status.Error(codes.InvalidArgument, "provider or redirect_uri not allowed")
+ }
+ return nil, status.Error(codes.FailedPrecondition, "oauth state unavailable")
}
return &gen.BeginOAuthResponse{State: state}, nil
}
From 2e0216123ff16e086bdeba32b295d29e5f1b46f0 Mon Sep 17 00:00:00 2001
From: Antoine Toussaint
<312536060+antoinetoussaint-byte@users.noreply.github.com>
Date: Thu, 8 Oct 2026 17:06:04 -0400
Subject: [PATCH 03/46] fix(accounts): the reserved module audience is mintable
only from a module identity (SA-F-WC-RESERVED)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Every peer that verifies the `module-capabilities` audience reads a context
carrying it as a composed module acting with no person present, and skips the
questions it would ask of a delegated actor on the strength of that. The mints a
signed-in person reaches placed no restriction on the audience, so a person could
mint or exchange for the reserved spelling. Nothing downstream granted them
anything โ an agent id cannot equal a module principal id โ but the premise peers
rely on was enforced nowhere, and a property two services away is not a boundary.
Every mint and exchange that takes its audience from a caller's message now
refuses the reserved one, before the caller's authority is resolved: the task
mint, the installation mint, a root and a child session, the audience exchange
and a renewal. The refusal also sits inside the exchange helper, because the
internal delegated-read exchange reaches it directly with an audience a
composition declared, and a declaration is no more entitled to the reserved
spelling than a request is.
TestEveryRequestSuppliedAudienceRefusesTheReservedOne reads the source and
requires the refusal in every method that reads an audience off a request, so a
mint added later cannot skip it silently; an ordinary audience passes the guard,
so the suite cannot be satisfied by a server that refuses everything.
Mutation-checked: removing the guard fails all six mints and the coverage test.
module/WORK_CONTEXTS.md gains the section stating the invariant.
Rebase note (onto origin/main f5a104ce3). Main landed this invariant independently, as
requireMintableAudience: the same refusal of the reserved audience on the same
mints, with PermissionDenied rather than InvalidArgument, an empty-audience
refusal beside it, and a closed-vocabulary check after it. Its call sites replace
the ones this commit added, so what remains here is this finding's own tests โ
re-pointed at main's refusal โ and the WORK_CONTEXTS.md section.
The source-level test is the part worth keeping: it holds every caller-supplied
mint to the gate and requires the gate's error to be RETURNED, so neither deleting
a call site nor discarding its result passes. It covers the two entry points an
unauthenticated caller cannot reach โ the installation mint, which demands an
internal service credential first, and a renewal, which gates the audience it
RESOLVES after inheritance.
Refs #1018
Co-Authored-By: Claude Opus 5 (1M context)
---
module/WORK_CONTEXTS.md | 28 +++
.../work_context_reserved_audience_test.go | 165 ++++++++++++++++++
2 files changed, 193 insertions(+)
create mode 100644 module/services/accounts/code/pkg/adapters/work_context_reserved_audience_test.go
diff --git a/module/WORK_CONTEXTS.md b/module/WORK_CONTEXTS.md
index 14e0f11c9..150840fe5 100644
--- a/module/WORK_CONTEXTS.md
+++ b/module/WORK_CONTEXTS.md
@@ -227,6 +227,34 @@ what it has installed. The host makes no such check, so a producer that means a
grant to cover one exact definition enforces that itself: the id here says which
resource is meant, never which version of it.
+## The reserved module audience
+
+`module-capabilities` is the audience a composed module's own capability carries,
+and every peer that verifies it reads a context bearing it as "a module, with no
+person present". That premise is what lets the module-facing surface skip the
+questions it asks of a delegated actor.
+
+So the audience is mintable only from a module identity. **Every mint and
+exchange that takes its audience from a caller's request refuses it** โ the task
+mint, the installation mint, a root or child session, the audience exchange and a
+renewal. The refusal is the structural half of `requireMintableAudience`, which
+needs no state and therefore holds through a registry outage.
+
+On the four mints a signed-in person reaches it runs before the caller's
+authority is resolved, so the spelling is unreachable rather than merely
+unreachable for this person. The other two reach it differently, and each for a
+reason that is the point: the installation mint demands an internal service
+credential first, so it is not a person-driven mint at all; and a renewal gates
+the audience it RESOLVES, after authenticating, because a renewal naming none
+inherits the parent's โ gating the request field would let the reserved spelling
+travel one generation further while the request named nothing.
+
+The module mints are unaffected: they pass the audience themselves rather than
+reading it off a message, as does the internal delegated-read exchange, whose
+audience comes from a declaration. `TestPersonMintsRefuseTheModuleAudience` and
+`TestEveryRequestSuppliedAudienceRefusesTheReservedOne` hold this, the second so
+a mint added later cannot skip the refusal silently.
+
## Operation contexts with no person present
Both exchanges above need a parent: somebody signed in, and the child is
diff --git a/module/services/accounts/code/pkg/adapters/work_context_reserved_audience_test.go b/module/services/accounts/code/pkg/adapters/work_context_reserved_audience_test.go
new file mode 100644
index 000000000..2f21834f3
--- /dev/null
+++ b/module/services/accounts/code/pkg/adapters/work_context_reserved_audience_test.go
@@ -0,0 +1,165 @@
+package adapters
+
+import (
+ "context"
+ "os"
+ "regexp"
+ "strings"
+ "testing"
+
+ gen "accounts/pkg/gen/saas/accounts/v1"
+
+ "github.com/stretchr/testify/require"
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/status"
+)
+
+// Every peer that reads the reserved module audience treats a context carrying
+// it as a composed module acting with no person present. A person-driven mint
+// that yields that spelling contradicts the premise those peers rely on, even
+// where nothing downstream grants it anything โ the register's reproducer is an
+// audience exchange that succeeds and a module surface that verifies the result.
+//
+// The refusal is asserted on an UNAUTHENTICATED caller on purpose: it must come
+// before the caller's authority is resolved, so the reserved audience is
+// unreachable rather than merely unreachable-for-this-person.
+//
+// These are the four mints a signed-in person reaches with an audience of their
+// choosing. The other two entry points that read an audience are refused by the
+// same gate but cannot be reached this way, and each for a reason that is the
+// point rather than a gap:
+//
+// - StartInstallationTask opens with requireInternalCredential, so it is not a
+// person-driven mint at all โ an unauthenticated caller is turned away
+// before any audience is looked at.
+// - RenewWorkContext gates the RESOLVED audience, after authenticating: a
+// renewal naming none inherits the parent's, and it is that value which is
+// checked and stamped. Gating the request field instead would let a renewal
+// carry the reserved spelling forward from a parent while naming nothing.
+//
+// TestEveryRequestSuppliedAudienceRefusesTheReservedOne is what holds those two
+// to the gate, from the source, so dropping it from either is still caught.
+func TestPersonMintsRefuseTheModuleAudience(t *testing.T) {
+ server := &WorkContextAuthorityServer{}
+ ctx := context.Background()
+
+ calls := []struct {
+ name string
+ call func() error
+ }{
+ {"StartTask", func() error {
+ _, err := server.StartTask(ctx, &gen.StartTaskWorkContextRequest{
+ OrgId: uuidForReservedAudienceTest, TaskId: uuidForReservedAudienceTest,
+ SessionId: uuidForReservedAudienceTest,
+ ActorPrincipalId: uuidForReservedAudienceTest,
+ AuthorityScopes: scopesForReservedAudienceTest(),
+ Audience: ModuleWorkContextAudience,
+ })
+ return err
+ }},
+ {"StartRootSession", func() error {
+ _, err := server.StartRootSession(ctx, &gen.StartRootSessionWorkContextRequest{
+ OrgId: uuidForReservedAudienceTest, ParentWorkContextToken: "parent.token",
+ SessionId: uuidForReservedAudienceTest,
+ Audience: ModuleWorkContextAudience,
+ })
+ return err
+ }},
+ {"ExchangeAudience", func() error {
+ _, err := server.ExchangeAudience(ctx, &gen.ExchangeWorkContextAudienceRequest{
+ OrgId: uuidForReservedAudienceTest, ParentWorkContextToken: "parent.token",
+ AttenuatedScopes: scopesForReservedAudienceTest(),
+ Audience: ModuleWorkContextAudience,
+ })
+ return err
+ }},
+ {"StartChildSession", func() error {
+ _, err := server.StartChildSession(ctx, &gen.StartChildSessionWorkContextRequest{
+ OrgId: uuidForReservedAudienceTest, ParentWorkContextToken: "parent.token",
+ SessionId: uuidForReservedAudienceTest, ActorPrincipalId: uuidForReservedAudienceTest,
+ GrantedScopes: scopesForReservedAudienceTest(),
+ Audience: ModuleWorkContextAudience,
+ })
+ return err
+ }},
+ }
+
+ for _, tc := range calls {
+ t.Run(tc.name, func(t *testing.T) {
+ err := tc.call()
+ require.Error(t, err)
+ require.Equal(t, codes.PermissionDenied, status.Code(err),
+ "the reserved audience must be refused before the caller's authority is resolved")
+ require.Contains(t, status.Convert(err).Message(), "never caller-supplied")
+ })
+ }
+}
+
+// The counterpart: an ordinary audience is NOT refused for BEING the reserved
+// one, so the test above cannot pass on a server that refuses every audience.
+// Such a mint is still refused โ by the vocabulary half, which this server has
+// no registry to answer from โ and that is a different refusal naming a
+// different thing, which is the whole distinction being asserted.
+func TestAnOrdinaryAudiencePassesTheReservedAudienceGuard(t *testing.T) {
+ _, err := (&WorkContextAuthorityServer{}).ExchangeAudience(context.Background(),
+ &gen.ExchangeWorkContextAudienceRequest{
+ OrgId: uuidForReservedAudienceTest, ParentWorkContextToken: "parent.token",
+ AttenuatedScopes: scopesForReservedAudienceTest(),
+ Audience: "modelservice",
+ })
+ require.Error(t, err)
+ require.NotContains(t, status.Convert(err).Message(), "never caller-supplied")
+}
+
+// A mint added later must not be able to skip the guard silently. Every
+// EXPORTED method of this server that takes its audience FROM THE REQUEST has to
+// run the audience gate; the module mints are exempt because they pass the
+// audience themselves rather than reading it off a caller's message.
+//
+// Exported only, because only an exported method is an RPC: the generated
+// service interface is what a caller can reach, and it has no unexported
+// members. An unexported helper that reads an audience is reached through one of
+// these methods, which has already gated, or through host code that builds the
+// request itself (exchangeVerifiedParent has one such caller in
+// delegated_read_audience.go, where the audience is a derived binding's, not a
+// caller's). Matching those too would make this test demand a second gate on a
+// path with no caller-supplied value to gate.
+func TestEveryRequestSuppliedAudienceRefusesTheReservedOne(t *testing.T) {
+ source, err := os.ReadFile("work_context_rpcs.go")
+ require.NoError(t, err)
+
+ methods := regexp.MustCompile(`(?m)^func \(s \*WorkContextAuthorityServer\) ([A-Z][A-Za-z]*)\(`).
+ FindAllStringSubmatchIndex(string(source), -1)
+ require.NotEmpty(t, methods)
+
+ text := string(source)
+ for index, match := range methods {
+ name := text[match[2]:match[3]]
+ end := len(text)
+ if index+1 < len(methods) {
+ end = methods[index+1][0]
+ }
+ body := text[match[0]:end]
+ if !strings.Contains(body, "req.GetAudience()") {
+ continue
+ }
+ // Two spellings, both of which gate the request's audience: the field
+ // directly, or the local that a renewal resolves it into. Anything else
+ // passing through this gate is a value this test cannot vouch for, so it
+ // is not accepted as one.
+ gatesTheField := strings.Contains(body, "requireVocabularyAudience(ctx, req.GetAudience())")
+ gatesTheResolved := strings.Contains(body, "requireVocabularyAudience(ctx, audience)") &&
+ strings.Contains(body, "audience := req.GetAudience()")
+ require.True(t, gatesTheField || gatesTheResolved,
+ "%s takes its audience from the request and must refuse the reserved module audience", name)
+ }
+}
+
+// Every one of these requests is validated before it reaches the guard, so each
+// field it declares has to be well-formed: the subject here is the audience, and
+// a message rejected for a malformed id would prove nothing about it.
+const uuidForReservedAudienceTest = "00000000-0000-4000-8000-000000000001"
+
+func scopesForReservedAudienceTest() []*gen.WorkContextScope {
+ return []*gen.WorkContextScope{{ResourceKind: "records", Actions: []string{"read"}}}
+}
From a90a8b451cc4121d3a6dea1150a6411a41306c4a Mon Sep 17 00:00:00 2001
From: Antoine Toussaint
<312536060+antoinetoussaint-byte@users.noreply.github.com>
Date: Mon, 5 Oct 2026 02:39:16 -0400
Subject: [PATCH 04/46] fix(accounts): a privileged actor's sensitive operation
requires a second factor, enrolled or not (SA-F-MFA)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
requireMFA looked for an enrolled device, found none, and admitted. So the one
principal whose compromise is the whole platform or the whole tenant was the one
the gate could not touch: a platform administrator or an organization owner with
nothing enrolled passed every sensitive operation it guards โ role grants, GDPR
delete, impersonation, entitlement overrides, waitlist review.
For a privileged actor โ any platform role, or ownership of the organization the
verified identity names โ the absence of recent AAL2 evidence is now a refusal.
That is also what makes 'a privileged principal holds a second factor'
enforceable at all: the alternative to enrolling is not reaching these
operations.
Organization ADMIN is deliberately outside the rule, and the reason is recorded
beside the code: admin is a role an owner grants and withdraws, where owner
carries the tenant's own lifecycle, and sweeping admin in would change the gate
for a population no operator chose. For every other member the gate stays opt-in
per user; making it unconditional there is an operator's policy decision about
their roster, not a property of this host.
The organization is read from the verified identity the auth interceptor
installed rather than from a request field or a log value, so it is the same
tenant the membership read is scoped to.
TestRequireMFARefusesUnenrolledPrivileged covers all three platform grades and
the org owner; its control asserts a plain member, an org admin and a
membership-less caller still pass, so the refusal is the privilege rather than a
gate that refuses everybody. Mutation-checked.
The impersonation rig carried MFAVerifiedAt with no assurance level and no
method, so it never had recent AAL2 and passed only on the old admit. It now
stamps genuine AAL2 evidence, which is what a console must present.
module/FEATURES.md: the two rows that described the old opt-in-only behaviour.
Refs #1018
Co-Authored-By: Claude Opus 5 (1M context)
---
module/FEATURES.md | 4 +-
.../accounts/code/pkg/adapters/auth.go | 71 ++++++++++--
.../pkg/adapters/impersonation_stop_test.go | 9 +-
.../code/pkg/adapters/mfa_privileged_test.go | 105 ++++++++++++++++++
4 files changed, 176 insertions(+), 13 deletions(-)
create mode 100644 module/services/accounts/code/pkg/adapters/mfa_privileged_test.go
diff --git a/module/FEATURES.md b/module/FEATURES.md
index b22593bcc..5704ea23b 100644
--- a/module/FEATURES.md
+++ b/module/FEATURES.md
@@ -516,7 +516,7 @@ starters, and large-scale enterprise SaaS expectations.
| OAuth login (multiple providers) | โ | Same |
| Email/password login | โ | โ (most starters keep both) |
| Magic-link login | ๐ก | โ (one-click) |
-| MFA enforced on sensitive ops | โ | โ (2026-04-25 fix: `requireMFA` gates billing, GDPR delete, role grants, impersonation; opt-in per user) |
+| MFA enforced on sensitive ops | โ | โ `requireMFA` gates billing, GDPR delete, role grants, impersonation. Fail-closed for a privileged actor โ any platform role, or ownership of the request's organization โ where not being enrolled is a refusal, not a pass; opt-in per user for every other member |
| Multi-org tenancy | โ | Same |
| Org invitations | โ | Same |
| RBAC (built-in roles) | โ | Same |
@@ -609,7 +609,7 @@ _All previously-open gaps closed 2026-04-25._
- โ **User identity endpoints unauthenticated** โ `AddIdentity`, `FindUserByIdentity`, `ListUserIdentities` would let any authenticated caller enumerate provider identities or attach attacker-controlled identities to any user. Now gated by `requireSelfOrPlatformAdmin` / `requirePlatformAdmin`.
- โ **gRPC server had no in-process auth interceptor** โ handlers assumed the auth-gateway ext_authz check was in front; direct port hits bypassed auth. Added `grpcAuthInterceptor` mirroring the Connect interceptor (defense in depth: api validates the bearer regardless of upstream).
- โ **Connect server had no CORS** โ browser preflight returned 405; every Connect-Web request from the FE failed in production-style architectures. Added `rs/cors` middleware.
-- โ **MFA is enforced and refresh-safe** โ enrolled users receive no normal session until a durable one-use challenge succeeds. JWT/session evidence carries `amr`, `auth_time`, `acr`, and `mfa_at`; refresh preserves rather than renews that evidence. Refresh re-resolves verified enrollment: newly enrolled MFA terminates AAL1 refresh families and requires login, while removed MFA strips factor methods and downgrades the successor to AAL1. General sensitive operations apply the configured recent-AAL2 policy to enrolled users; money-moving billing checkout/portal is stricter and always requires fresh AAL2, so lack of enrollment is not a bypass. Passkeys require WebAuthn user verification with exact Codefly-configured RP/origin policy; complete credentials and ceremony state use Vault Transit envelopes. TOTP seeds are encrypted and recovery codes are one-use bcrypt hashes.
+- โ **MFA is enforced and refresh-safe** โ enrolled users receive no normal session until a durable one-use challenge succeeds. JWT/session evidence carries `amr`, `auth_time`, `acr`, and `mfa_at`; refresh preserves rather than renews that evidence. Refresh re-resolves verified enrollment: newly enrolled MFA terminates AAL1 refresh families and requires login, while removed MFA strips factor methods and downgrades the successor to AAL1. General sensitive operations apply the configured recent-AAL2 policy to enrolled users, and to every privileged actor whether enrolled or not โ a platform role of any grade, or ownership of the request's organization โ so for the principals whose compromise is the whole platform or the whole tenant, lack of enrolment is a refusal rather than a bypass. Money-moving billing checkout/portal is stricter still and always requires fresh AAL2. Passkeys require WebAuthn user verification with exact Codefly-configured RP/origin policy; complete credentials and ceremony state use Vault Transit envelopes. TOTP seeds are encrypted and recovery codes are one-use bcrypt hashes.
- โ **API key scopes forwarded but not enforced** โ the auth-gateway ext_authz check set `X-Scopes`; handlers ignored. New `requireScope(ctx, "resource:action")` gate with wildcard support (`*`, `users:*`, `*:read`). Applied to `ListUsers`, `UpdateUser`, `DeleteUser` as a starter set; extend to other resources per business needs (JWT-authenticated callers bypass โ RBAC handles them).
- โ **Access tokens not individually revocable** โ Logout only killed the refresh chain; old access tokens stayed valid up to 15 min. New `auth.TokenRevoker` interface + `cache.NewTokenRevoker` Redis impl. `Logout(refresh, accessToken)` now calls `JWTMinter.RevokeAccess` which adds the jti to the revocation list with TTL = remaining `exp`. `VerifyAccess` consults the list. Falls back to `NoopTokenRevoker` (no Redis) โ original behavior.
- โ **Impersonation had no time limit** โ admin "view-as" sessions inherited the normal 15-min TTL. New `Config.ImpersonationTokenTTL` (default 5 min) auto-applied when minting tokens with `acting` claim set.
diff --git a/module/services/accounts/code/pkg/adapters/auth.go b/module/services/accounts/code/pkg/adapters/auth.go
index 99dc05262..c6dd958d4 100644
--- a/module/services/accounts/code/pkg/adapters/auth.go
+++ b/module/services/accounts/code/pkg/adapters/auth.go
@@ -878,18 +878,30 @@ func parseUnixTimestamp(raw string) time.Time {
return time.Unix(seconds, 0)
}
-// requireMFA gates sensitive operations behind a satisfied MFA challenge
-// when the actor has at least one MFA device enrolled. Users who never
-// set up MFA still pass โ enforcement is opt-in per user, not platform-
-// wide. Returns codes.FailedPrecondition with the canonical
-// "mfa_required" reason so frontends can branch on it without parsing
-// strings.
+// requireMFA gates sensitive operations behind a satisfied MFA challenge.
+// Returns codes.FailedPrecondition with the canonical "mfa_required" reason so
+// frontends can branch on it without parsing strings.
//
// Apply to: anything that, if hijacked, would let an attacker pivot to
// admin scope, exfiltrate data, or move money. Concretely: billing
// checkout/portal, role grants, GDPR delete, impersonation, API key
// creation with broad scopes.
//
+// There are two populations, and the difference is what enrolment means for each.
+//
+// For a PRIVILEGED actor โ any platform role, or ownership of the organization
+// the request names โ not being enrolled is not a pass. It used to be: the check
+// looked for a device, found none, and admitted, so the one principal whose
+// compromise is the whole platform or the whole tenant was the one the gate could
+// not touch. A privileged actor without recent AAL2 evidence is refused, which
+// also makes "a privileged principal holds a second factor" enforceable at all:
+// the alternative to enrolling is not reaching these operations.
+//
+// For every other member the gate stays opt-in per user: enrolled means a recent
+// factor is required, unenrolled means the operation proceeds and the audit row
+// records mfa_satisfied=false. Making it unconditional there is an operator's
+// policy decision about their whole roster, not a property of the host.
+//
// Refresh does not update MFAVerifiedAt. An enrolled user must therefore
// have explicit AAL2 evidence no older than DefaultRecentStepUpMaxAge;
// otherwise the frontend asks them to authenticate again.
@@ -897,6 +909,13 @@ func requireMFA(ctx context.Context, actorID string) error {
if auth.AssuranceFromContext(ctx).HasRecentMFA(time.Now(), recentStepUpMaxAge) {
return nil
}
+ privileged, err := actorHoldsPrivilegedRole(ctx, actorID)
+ if err != nil {
+ return err
+ }
+ if privileged {
+ return status.Error(codes.FailedPrecondition, "mfa_required")
+ }
// mfa_devices is RLS-protected by user_id (Phase 2G); wrap in
// the actor's WithUserTx so the count query sees their devices.
var enrolled bool
@@ -910,15 +929,47 @@ func requireMFA(ctx context.Context, actorID string) error {
return status.Errorf(codes.Internal, "cannot verify MFA status: %v", err)
}
if !enrolled {
- // No verified MFA device โ can't enforce. Sensitive ops still
- // run; audit captures mfa_satisfied=false. Operators who want
- // to force-enroll go through a separate "MFA required" feature
- // flag at the org level (not yet implemented).
return nil
}
return status.Error(codes.FailedPrecondition, "mfa_required")
}
+// actorHoldsPrivilegedRole reports whether this actor is one whose sensitive
+// operations must sit behind a second factor whatever they have enrolled: a
+// platform administrator of any grade, or an owner of the organization the
+// request names.
+//
+// Org ADMIN is deliberately not included. Owner is the role that cannot be
+// removed by anyone else in the tenant and that carries the tenant's own
+// lifecycle; admin is a delegated role an owner grants and withdraws, and
+// sweeping it in here would change the gate for a population an operator did not
+// choose. An operator who wants admins covered says so through org policy.
+//
+// A failure to resolve either role refuses: this runs only on the path to a
+// sensitive operation, where not knowing is not a reason to proceed.
+func actorHoldsPrivilegedRole(ctx context.Context, actorID string) (bool, error) {
+ role, err := platformRole(ctx, actorID)
+ if err != nil {
+ return false, status.Errorf(codes.Internal, "cannot resolve platform role: %v", err)
+ }
+ if role != "" {
+ return true, nil
+ }
+ // The organization comes from the verified identity the auth interceptor
+ // installed, not from the request message or a log field: it is the same
+ // tenant the membership read is scoped to, so the two cannot disagree. A
+ // request with no tenant has no owner role to hold.
+ orgID, _, ok := auth.VerifiedDatabaseIdentity(ctx)
+ if !ok || orgID == "" {
+ return false, nil
+ }
+ orgRole, err := lookupMembership(ctx, orgID, actorID)
+ if err != nil {
+ return false, membershipLookupStatus("cannot verify membership", err)
+ }
+ return orgRole == gen.OrgRole_ORG_ROLE_OWNER.String(), nil
+}
+
// requireRecentMFA is the strict step-up gate for money-moving operations.
// Unlike the general opt-in requireMFA helper, lack of enrollment does not
// bypass this check: billing administrators must establish fresh AAL2 evidence.
diff --git a/module/services/accounts/code/pkg/adapters/impersonation_stop_test.go b/module/services/accounts/code/pkg/adapters/impersonation_stop_test.go
index 082cb170e..4d3317275 100644
--- a/module/services/accounts/code/pkg/adapters/impersonation_stop_test.go
+++ b/module/services/accounts/code/pkg/adapters/impersonation_stop_test.go
@@ -272,8 +272,15 @@ func newImpersonationRig(t *testing.T) *impersonationRig {
func (r *impersonationRig) start(t *testing.T, subjectID string) (string, string, context.Context) {
t.Helper()
+ // Genuine AAL2 evidence, not merely a timestamp: a platform administrator
+ // reaches impersonation only behind a recent second factor, and
+ // Assurance.HasRecentMFA requires the level and the method as well as the
+ // time. The fixture carried the time alone and passed only because an
+ // unenrolled privileged actor used to be admitted.
adminCtx := stampVerifiedIdentity(context.Background(), supportActorID, "", auth.Assurance{
- MFAVerifiedAt: time.Now(),
+ Level: auth.AssuranceLevelAAL2,
+ AuthenticationMethods: []string{auth.AuthenticationMethodWebAuthn},
+ MFAVerifiedAt: time.Now(),
})
resp, err := (&PlatformAdminServer{}).ImpersonateUser(adminCtx,
&gen.ImpersonateUserRequest{UserId: subjectID, Reason: "investigating a billing discrepancy"})
diff --git a/module/services/accounts/code/pkg/adapters/mfa_privileged_test.go b/module/services/accounts/code/pkg/adapters/mfa_privileged_test.go
new file mode 100644
index 000000000..d109d2a92
--- /dev/null
+++ b/module/services/accounts/code/pkg/adapters/mfa_privileged_test.go
@@ -0,0 +1,105 @@
+package adapters
+
+import (
+ "context"
+ "testing"
+
+ "accounts/pkg/auth"
+ "accounts/pkg/business"
+ gen "accounts/pkg/gen/saas/accounts/v1"
+
+ "github.com/stretchr/testify/require"
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/status"
+)
+
+// The actor of these tests holds no verified MFA device. That is the whole
+// point: the gate used to look for a device, find none, and admit โ so the one
+// principal whose compromise is the whole platform or the whole tenant was the
+// one it could not touch.
+type mfaPolicyStore struct {
+ business.Store
+ platformRole string
+ orgRole gen.OrgRole
+ member bool
+}
+
+func (s *mfaPolicyStore) GetPlatformRole(context.Context, string) (string, error) {
+ return s.platformRole, nil
+}
+
+func (s *mfaPolicyStore) GetOrgMembership(context.Context, string, string) (*gen.OrgMembership, error) {
+ if !s.member {
+ return nil, nil
+ }
+ return &gen.OrgMembership{Role: s.orgRole}, nil
+}
+
+func (s *mfaPolicyStore) WithUserTx(ctx context.Context, _ string, fn func(context.Context) error) error {
+ return fn(ctx)
+}
+
+func (s *mfaPolicyStore) HasVerifiedMFA(context.Context, string) (bool, error) {
+ return false, nil
+}
+
+const (
+ mfaActorID = "00000000-0000-4000-8000-0000000000a1"
+ mfaOrgID = "00000000-0000-4000-8000-0000000000b1"
+)
+
+func installMFAPolicyStore(t *testing.T, store *mfaPolicyStore) {
+ t.Helper()
+ previous := service
+ t.Cleanup(func() { service = previous })
+ svc, err := business.NewService(store)
+ require.NoError(t, err)
+ WithService(svc)
+}
+
+// The caller context every case shares: a verified identity for this actor in
+// this organization, and no recent second factor.
+func mfaRequestContext() context.Context {
+ return auth.WithVerifiedDatabaseIdentity(context.Background(), mfaActorID, mfaOrgID)
+}
+
+func TestRequireMFARefusesUnenrolledPrivileged(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ store *mfaPolicyStore
+ }{
+ {"a platform support role", &mfaPolicyStore{platformRole: "support"}},
+ {"a platform billing role", &mfaPolicyStore{platformRole: "billing"}},
+ {"a platform super admin", &mfaPolicyStore{platformRole: "super_admin"}},
+ {"an organization owner", &mfaPolicyStore{member: true, orgRole: gen.OrgRole_ORG_ROLE_OWNER}},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ installMFAPolicyStore(t, tc.store)
+
+ err := requireMFA(mfaRequestContext(), mfaActorID)
+
+ require.Equal(t, codes.FailedPrecondition, status.Code(err))
+ require.Equal(t, "mfa_required", status.Convert(err).Message())
+ })
+ }
+}
+
+// The controls. An ordinary member with nothing enrolled still passes, so the
+// refusal above is the privilege and not a gate that refuses everybody; and an
+// org admin is deliberately outside the rule, because admin is a role an owner
+// grants and withdraws rather than the tenant's own lifecycle.
+func TestRequireMFAStaysOptInForAnUnprivilegedActor(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ store *mfaPolicyStore
+ }{
+ {"a plain member", &mfaPolicyStore{member: true, orgRole: gen.OrgRole_ORG_ROLE_MEMBER}},
+ {"an organization admin", &mfaPolicyStore{member: true, orgRole: gen.OrgRole_ORG_ROLE_ADMIN}},
+ {"no membership at all", &mfaPolicyStore{}},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ installMFAPolicyStore(t, tc.store)
+ require.NoError(t, requireMFA(mfaRequestContext(), mfaActorID))
+ })
+ }
+}
From 8365850c37633a9515d0f5bd4158fbaaccc42305 Mon Sep 17 00:00:00 2001
From: Antoine Toussaint
<312536060+antoinetoussaint-byte@users.noreply.github.com>
Date: Mon, 5 Oct 2026 02:50:35 -0400
Subject: [PATCH 05/46] fix(accounts): the refresh credential is a cookie on
every authentication-completing surface, and signing out ends the session
(SA-F-LOGOUT, SA-F-REFRESH-BODY)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Both findings are one reworking of the same thing โ which surface hands the
refresh credential to whom โ so they land together rather than as two passes over
the same file.
SA-F-LOGOUT. The browser's logout posts an empty body and its HttpOnly refresh
cookie. The middleware moved that cookie into the request body for refresh and
not for logout, so field validation rejected the request before Logout ran. The
session family stayed active, the access-token id was never revoked, and the
device list still showed the session โ while the browser's own cookie was
cleared, so the sign-out looked complete and ended nothing. A refresh token
copied earlier kept minting access tokens for the rest of the idle window.
The cookie is now moved into the body for logout exactly as for refresh. An
explicit body token still wins: the cookie is the browser's carrier, not an
override. Logout additionally revokes the VERIFIED session's access half, from
the credential the caller presented rather than from the message, so the access
side of a sign-out does not depend on the body at all.
Not taken here, and stated rather than left to be inferred: LogoutRequest's
refresh_token keeps its min_len=1, so a logout carrying neither a body token nor
the cookie is still refused by field validation. Making it optional is a contract
change that moves the descriptor digests and the published client, which is not
worth stapling to this PR; the product's own sign-out does not need it.
SA-F-REFRESH-BODY. The cookie lift covered four REST paths named as literals.
The WebAuthn completion was not one of them โ it shares its response message with
the TOTP completion, and only the TOTP path was listed โ so it returned the
credential as readable content. The Connect surface of the same methods returned
it too, serving the same browser over the same origin.
The REST set is now held against the service descriptor by
TestEveryAuthCompletionCookiesTheRefreshToken, in both directions: every POST
route whose response message carries refresh_token must be lifted, and nothing
may be listed that no route returns one on. The Connect handlers route through
unaryCookieingRefreshToken, which clears the field by reflection and sets the
same cookie, and a source-level test requires it of every handler whose response
carries the field.
One exemption, declared by name with its reason rather than simply absent: the
OAuth 2.1 token endpoint. Its caller is a registered client exchanging a code or
a refresh token over a back-channel POST, RFC 6749 ยง5.1 defines that response as
a body with refresh_token in it, and a Set-Cookie there reaches nothing that
could use it. Both coverage tests require the exemption set to be exactly that
one entry.
While here, the refresh cookie's Secure flag became a property of the deployment
instead of the request. It was derived from the Host header, which a caller
supplies, so a request arriving at a deployed cell with Host: localhost was
answered with a cookie the browser would then send over plaintext. The
local-development exemption is now wired from codefly.IsLocal() beside
SetExposeAuthErrorDetail, and its zero value keeps Secure.
TestL1LogoutForwardsCookieRefreshToken is the register's logout reproducer,
inverted. Mutation-checked: dropping logout from the cookie-consuming set, the
WebAuthn path from the lifted set, and the lift from one Connect handler each
fail their test, and the Secure-flag test is checked in both directions.
module/FEATURES.md: the logout rows and the refresh-rotation row.
Refs #1018
Co-Authored-By: Claude Opus 5 (1M context)
---
module/FEATURES.md | 6 +-
.../code/pkg/adapters/connect_handlers.go | 62 ++++-
.../refresh_credential_carrier_test.go | 216 ++++++++++++++++++
.../accounts/code/pkg/adapters/rest_extras.go | 149 ++++++++----
.../code/pkg/adapters/rest_extras_test.go | 41 +++-
.../accounts/code/pkg/adapters/rpcs.go | 9 +-
.../accounts/code/pkg/business/auth.go | 26 ++-
module/services/accounts/code/work.go | 4 +
8 files changed, 440 insertions(+), 73 deletions(-)
create mode 100644 module/services/accounts/code/pkg/adapters/refresh_credential_carrier_test.go
diff --git a/module/FEATURES.md b/module/FEATURES.md
index 5704ea23b..8917f951f 100644
--- a/module/FEATURES.md
+++ b/module/FEATURES.md
@@ -158,7 +158,7 @@ rather than at first login.
| Rotation | One locked transaction consumes the family, resolves current authorization, and inserts one successor |
| Org exchange | Authenticated target-only exchange; current membership/roles resolved under the active session lock; same device family and refresh credential |
| Reuse defense | Reuse of a token consumed by rotation revokes every active user session; administrative revocation is not replay |
-| Logout | Revokes the refresh family and deny-lists the current access-token JTI in Redis |
+| Logout | The browser posts an empty body and its HttpOnly refresh cookie; the middleware moves the cookie's token into the request as it does for refresh, so the family is revoked without the credential ever reaching script. Deny-lists the presented access-token JTI and the verified session, so the access half dies on the strength of who is calling rather than of what the body carried |
| Current state | Refresh requires current authorization; database triggers atomically revoke affected families on status, membership/role, platform-role, and verified-MFA changes |
---
@@ -248,10 +248,10 @@ their next owning change.
| Password login | โ | Intentional โ provider-only; account recovery routed via OAuth |
| Fixture login (dev) | โ | Click-to-login in dev mode; not exposed in prod build |
| MFA (passkeys + TOTP + recovery) | โ | WebAuthn with exact RP/origin policy and encrypted credential state; durable one-use login challenges and recent AAL2 step-up |
-| Refresh-token rotation | โ | OWASP family revocation on reuse |
+| Refresh-token rotation | โ | OWASP family revocation on reuse. The credential never leaves as readable content: every authentication-completing surface โ REST and Connect alike โ moves it into the scoped HttpOnly SameSite=Strict cookie and strips the field, with one declared exemption, the OAuth 2.1 token endpoint, whose caller is a registered client and whose body carrier is the protocol's |
| Session list + revoke | โ | Stable per-device family ids, device context, idle/absolute expiry, whole-family revoke |
| Session lifetime policy | โ | Configurable fixed absolute TTL, idle TTL, and serialized active-device cap |
-| Logout | โ | Revokes the presented device family |
+| Logout | โ | Revokes the presented device family, the presented access token and the verified session's access half; the browser's cookie is the credential carrier, so an empty body is a complete sign-out |
| OAuth state / CSRF | โ | Server-signed state bound to (provider, redirect_uri), single-use: the nonce is consumed on first verification and the state is refused when single use cannot be recorded |
| OAuth PKCE | โ | Comments mention PKCE but exchanger uses `client_secret` (acceptable for confidential server-side; PKCE adds defense for SPA-driven flows) |
| Account lockout (failed attempts) | โ | No counter on user table |
diff --git a/module/services/accounts/code/pkg/adapters/connect_handlers.go b/module/services/accounts/code/pkg/adapters/connect_handlers.go
index f16532028..c1165a5c0 100644
--- a/module/services/accounts/code/pkg/adapters/connect_handlers.go
+++ b/module/services/accounts/code/pkg/adapters/connect_handlers.go
@@ -10,6 +10,8 @@ import (
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
+ "google.golang.org/protobuf/proto"
+ "google.golang.org/protobuf/reflect/protoreflect"
"google.golang.org/protobuf/types/known/emptypb"
"google.golang.org/protobuf/types/known/timestamppb"
@@ -68,6 +70,58 @@ func unary[Req, Resp any](
return connect.NewResponse(resp), nil
}
+// unaryCookieingRefreshToken serves a method that completes an authentication:
+// it moves the minted refresh credential out of the response body and into the
+// browser's cookie, exactly as the REST middleware does for the same methods.
+//
+// The Connect surface serves the same browser over the same origin and returned
+// the credential as readable content, so an injected script could read it from a
+// response it provoked. A caller that genuinely needs a refresh token in a body
+// is a registered OAuth client using the token endpoint, which is a separate
+// contract with its own audience and its own registry.
+func unaryCookieingRefreshToken[Req, Resp any](
+ ctx context.Context,
+ req *connect.Request[Req],
+ fn func(context.Context, *Req) (*Resp, error),
+) (*connect.Response[Resp], error) {
+ resp, err := unary(ctx, req, fn)
+ if err != nil {
+ return nil, err
+ }
+ if message, ok := any(resp.Msg).(proto.Message); ok {
+ cookieTheRefreshToken(resp.Header(), message)
+ }
+ return resp, nil
+}
+
+// connectHandlersExemptFromTheRefreshCookie are the authentication-completing
+// Connect methods whose response body carries the refresh credential by
+// contract. One entry: the OAuth 2.1 token endpoint (see
+// oauthTokenEndpointRESTPath). The coverage test requires the set to be exactly
+// this, so an exemption cannot be added without being read.
+var connectHandlersExemptFromTheRefreshCookie = map[string]bool{
+ "ExchangeClientToken": true,
+}
+
+// cookieTheRefreshToken clears a message's refresh_token field, if it has one
+// carrying a value, and sets the same cookie the REST path sets. Reflection
+// rather than a per-message setter: the field is named identically on every
+// authentication-completing response, and a response type added later is covered
+// without being enumerated here.
+func cookieTheRefreshToken(header http.Header, message proto.Message) {
+ reflected := message.ProtoReflect()
+ field := reflected.Descriptor().Fields().ByName("refresh_token")
+ if field == nil || field.Kind() != protoreflect.StringKind {
+ return
+ }
+ token := reflected.Get(field).String()
+ if token == "" {
+ return
+ }
+ reflected.Clear(field)
+ header.Add("Set-Cookie", refreshCookie(token, refreshCookieMaxAgeSeconds).String())
+}
+
// translateGRPCError converts a google.golang.org/grpc/status error
// into a connect.Error with the matching code. Pass-through for
// errors that are already connect.Errors or don't carry a gRPC status.
@@ -470,7 +524,7 @@ func (h *authConnectHandler) BeginOAuth(ctx context.Context, req *connect.Reques
func (h *authConnectHandler) Authenticate(ctx context.Context, req *connect.Request[gen.AuthenticateRequest]) (*connect.Response[gen.AuthenticateResponse], error) {
injectHeaderJWTCredential(req.Msg, req.Header())
- return unary(ctx, req, h.inner.Authenticate)
+ return unaryCookieingRefreshToken(ctx, req, h.inner.Authenticate)
}
// injectHeaderJWTCredential turns a gateway-injected identity header into the
@@ -496,16 +550,16 @@ func injectHeaderJWTCredential(req *gen.AuthenticateRequest, h http.Header) {
}
}
func (h *authConnectHandler) CompleteMFAChallenge(ctx context.Context, req *connect.Request[gen.CompleteMFAChallengeRequest]) (*connect.Response[gen.CompleteMFAChallengeResponse], error) {
- return unary(ctx, req, h.inner.CompleteMFAChallenge)
+ return unaryCookieingRefreshToken(ctx, req, h.inner.CompleteMFAChallenge)
}
func (h *authConnectHandler) BeginWebAuthnMFAChallenge(ctx context.Context, req *connect.Request[gen.BeginWebAuthnMFAChallengeRequest]) (*connect.Response[gen.BeginWebAuthnMFAChallengeResponse], error) {
return unary(ctx, req, h.inner.BeginWebAuthnMFAChallenge)
}
func (h *authConnectHandler) CompleteWebAuthnMFAChallenge(ctx context.Context, req *connect.Request[gen.CompleteWebAuthnMFAChallengeRequest]) (*connect.Response[gen.CompleteMFAChallengeResponse], error) {
- return unary(ctx, req, h.inner.CompleteWebAuthnMFAChallenge)
+ return unaryCookieingRefreshToken(ctx, req, h.inner.CompleteWebAuthnMFAChallenge)
}
func (h *authConnectHandler) RefreshToken(ctx context.Context, req *connect.Request[gen.RefreshTokenRequest]) (*connect.Response[gen.RefreshTokenResponse], error) {
- return unary(ctx, req, h.inner.RefreshToken)
+ return unaryCookieingRefreshToken(ctx, req, h.inner.RefreshToken)
}
func (h *authConnectHandler) SwitchOrganization(ctx context.Context, req *connect.Request[gen.SwitchOrganizationRequest]) (*connect.Response[gen.SwitchOrganizationResponse], error) {
return unary(ctx, req, h.inner.SwitchOrganization)
diff --git a/module/services/accounts/code/pkg/adapters/refresh_credential_carrier_test.go b/module/services/accounts/code/pkg/adapters/refresh_credential_carrier_test.go
new file mode 100644
index 000000000..2303fc2ff
--- /dev/null
+++ b/module/services/accounts/code/pkg/adapters/refresh_credential_carrier_test.go
@@ -0,0 +1,216 @@
+package adapters
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "regexp"
+ "strings"
+ "testing"
+
+ gen "accounts/pkg/gen/saas/accounts/v1"
+
+ "connectrpc.com/connect"
+ "github.com/stretchr/testify/require"
+ "google.golang.org/genproto/googleapis/api/annotations"
+ "google.golang.org/protobuf/proto"
+ "google.golang.org/protobuf/reflect/protoreflect"
+ "google.golang.org/protobuf/reflect/protoregistry"
+)
+
+// The browser's logout posts an empty body and the HttpOnly cookie. The cookie
+// was moved into the body for refresh and not for logout, so field validation
+// rejected the request before Logout ran: the session family stayed active and
+// the access-token id was never revoked, while the browser's own cookie was
+// cleared โ a sign-out that looked complete and ended nothing.
+func TestL1LogoutForwardsCookieRefreshToken(t *testing.T) {
+ handler := refreshTokenCookie(downstream(`{}`))
+
+ request := httptest.NewRequest(http.MethodPost, "/v1/auth/logout", strings.NewReader(`{}`))
+ request.AddCookie(&http.Cookie{Name: refreshTokenCookieName, Value: "cookie-rt"})
+ recorder := httptest.NewRecorder()
+ handler.ServeHTTP(recorder, request)
+
+ require.Contains(t, recorder.Header().Get("X-Echo-Body"), "cookie-rt",
+ "logout must reach the handler carrying the cookie's refresh token")
+
+ cleared := false
+ for _, cookie := range recorder.Result().Cookies() {
+ if cookie.Name == refreshTokenCookieName && cookie.MaxAge < 0 {
+ cleared = true
+ }
+ }
+ require.True(t, cleared, "logout must still clear the browser's cookie")
+}
+
+// A logout that already carries a token in its body keeps it: the cookie is a
+// fallback for the browser, not an override of an explicit credential.
+func TestLogoutKeepsAnExplicitBodyRefreshToken(t *testing.T) {
+ handler := refreshTokenCookie(downstream(`{}`))
+
+ request := httptest.NewRequest(http.MethodPost, "/v1/auth/logout",
+ strings.NewReader(`{"refresh_token":"body-rt"}`))
+ recorder := httptest.NewRecorder()
+ handler.ServeHTTP(recorder, request)
+
+ require.Contains(t, recorder.Header().Get("X-Echo-Body"), "body-rt")
+}
+
+// Every REST route that completes an authentication hands the refresh credential
+// to the browser as a cookie and strips it from the body. The set is derived
+// from the service descriptor rather than restated, so a route added to the
+// contract cannot be left out of the middleware: the WebAuthn completion was
+// exactly that, sharing its response message with the TOTP completion while only
+// the TOTP path was listed.
+func TestEveryAuthCompletionCookiesTheRefreshToken(t *testing.T) {
+ declared := restRoutesReturningARefreshToken(t)
+ require.NotEmpty(t, declared, "the descriptor scan found no route to check")
+
+ require.Contains(t, declared, oauthTokenEndpointRESTPath,
+ "the exemption names a route the contract does not have")
+
+ for path := range declared {
+ if path == oauthTokenEndpointRESTPath {
+ continue
+ }
+ t.Run(path, func(t *testing.T) {
+ require.True(t, authenticationCompletingRESTPaths[path],
+ "this route's response carries a refresh token and must be cookie-lifted")
+
+ handler := refreshTokenCookie(downstream(`{"accessToken":"at","refreshToken":"rt-secret"}`))
+ recorder := httptest.NewRecorder()
+ handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{}`)))
+
+ var body map[string]any
+ require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &body))
+ require.NotContains(t, body, "refreshToken")
+ require.NotContains(t, body, "refresh_token")
+ require.NotContains(t, recorder.Body.String(), "rt-secret")
+
+ cookie := refreshCookieOf(t, recorder)
+ require.Equal(t, "rt-secret", cookie.Value)
+ require.True(t, cookie.HttpOnly)
+ require.True(t, cookie.Secure)
+ require.Equal(t, http.SameSiteStrictMode, cookie.SameSite)
+ require.Equal(t, "/v1/auth", cookie.Path)
+ })
+ }
+
+ // And nothing is listed that the contract does not actually return a refresh
+ // token on, so the map cannot be kept green by growing it.
+ for path := range authenticationCompletingRESTPaths {
+ require.Contains(t, declared, path,
+ "this path is cookie-lifted but no descriptor route returns a refresh token there")
+ }
+}
+
+// restRoutesReturningARefreshToken reads the accounts service descriptors and
+// returns the POST paths whose response message carries a refresh_token field.
+func restRoutesReturningARefreshToken(t *testing.T) map[string]bool {
+ t.Helper()
+ paths := map[string]bool{}
+ services := gen.File_saas_accounts_v1_authentication_proto.Services()
+ for i := range services.Len() {
+ methods := services.Get(i).Methods()
+ for j := range methods.Len() {
+ method := methods.Get(j)
+ if method.Output().Fields().ByName(protoreflect.Name("refresh_token")) == nil {
+ continue
+ }
+ rule, ok := proto.GetExtension(
+ method.Options(), annotations.E_Http).(*annotations.HttpRule)
+ if !ok || rule == nil {
+ continue
+ }
+ if post := rule.GetPost(); post != "" {
+ paths[post] = true
+ }
+ }
+ }
+ return paths
+}
+
+// The Connect surface of the same methods: it serves the same browser, over the
+// same origin, and returned the credential as readable content. A Connect caller
+// that needs the token in a body is a registered OAuth client and uses the token
+// endpoint, which is a different contract.
+func TestConnectAuthCompletionsCookieTheRefreshToken(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ carry func() (http.Header, string)
+ }{
+ {"Authenticate", func() (http.Header, string) {
+ response := connect.NewResponse(&gen.AuthenticateResponse{AccessToken: "at", RefreshToken: "rt-secret"})
+ cookieTheRefreshToken(response.Header(), response.Msg)
+ return response.Header(), response.Msg.GetRefreshToken()
+ }},
+ {"CompleteMFAChallenge", func() (http.Header, string) {
+ response := connect.NewResponse(&gen.CompleteMFAChallengeResponse{AccessToken: "at", RefreshToken: "rt-secret"})
+ cookieTheRefreshToken(response.Header(), response.Msg)
+ return response.Header(), response.Msg.GetRefreshToken()
+ }},
+ {"RefreshToken", func() (http.Header, string) {
+ response := connect.NewResponse(&gen.RefreshTokenResponse{AccessToken: "at", RefreshToken: "rt-secret"})
+ cookieTheRefreshToken(response.Header(), response.Msg)
+ return response.Header(), response.Msg.GetRefreshToken()
+ }},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ header, remaining := tc.carry()
+ require.Empty(t, remaining, "the response body must not carry the refresh token")
+
+ setCookie := header.Get("Set-Cookie")
+ require.Contains(t, setCookie, refreshTokenCookieName+"=rt-secret")
+ require.Contains(t, setCookie, "HttpOnly")
+ require.Contains(t, setCookie, "Secure")
+ require.Contains(t, setCookie, "SameSite=Strict")
+ require.Contains(t, setCookie, "Path=/v1/auth")
+ })
+ }
+}
+
+// A response with no refresh token is left exactly as it was, so the lift cannot
+// be satisfied by clearing a field that was never set or by always emitting a
+// cookie.
+func TestCookieTheRefreshTokenLeavesAnUnrelatedResponseAlone(t *testing.T) {
+ response := connect.NewResponse(&gen.AuthenticateResponse{AccessToken: "at"})
+ cookieTheRefreshToken(response.Header(), response.Msg)
+
+ require.Equal(t, "at", response.Msg.GetAccessToken())
+ require.Empty(t, response.Header().Get("Set-Cookie"))
+}
+
+// The handlers have to actually route through the lift. Read the source and
+// require it of every authConnectHandler method whose response message carries a
+// refresh token, so a handler added later cannot quietly use the plain helper.
+func TestEveryConnectAuthCompletionRoutesThroughTheLift(t *testing.T) {
+ source, err := os.ReadFile("connect_handlers.go")
+ require.NoError(t, err)
+
+ methods := regexp.MustCompile(
+ `(?m)^func \(h \*authConnectHandler\) ([A-Za-z]+)\(ctx context\.Context, req \*connect\.Request\[gen\.([A-Za-z]+)\]\) \(\*connect\.Response\[gen\.([A-Za-z]+)\][^\n]*\n((?:\t[^\n]*\n)*)`).
+ FindAllStringSubmatch(string(source), -1)
+ require.NotEmpty(t, methods)
+
+ checked := 0
+ for _, match := range methods {
+ name, responseType, body := match[1], match[3], match[4]
+ message, err := protoregistry.GlobalTypes.FindMessageByName(
+ protoreflect.FullName("saas.accounts.v1." + responseType))
+ require.NoError(t, err, "%s: unknown response message %s", name, responseType)
+ if message.Descriptor().Fields().ByName("refresh_token") == nil ||
+ connectHandlersExemptFromTheRefreshCookie[name] {
+ continue
+ }
+ checked++
+ require.Contains(t, body, "unaryCookieingRefreshToken",
+ "%s completes an authentication and must lift the refresh token out of the body", name)
+ }
+ require.NotZero(t, checked, "the scan matched no authentication-completing handler")
+
+ // The exemption set is exactly the OAuth token endpoint, so an entry cannot be
+ // added to it without this assertion being read and changed.
+ require.Equal(t, map[string]bool{"ExchangeClientToken": true},
+ connectHandlersExemptFromTheRefreshCookie)
+}
diff --git a/module/services/accounts/code/pkg/adapters/rest_extras.go b/module/services/accounts/code/pkg/adapters/rest_extras.go
index cf52af89a..3faae80a6 100644
--- a/module/services/accounts/code/pkg/adapters/rest_extras.go
+++ b/module/services/accounts/code/pkg/adapters/rest_extras.go
@@ -16,7 +16,6 @@ import (
"bytes"
"encoding/json"
"io"
- "net"
"net/http"
"strings"
"sync"
@@ -31,32 +30,35 @@ const refreshTokenCookieName = "codefly_rt"
// refreshTokenCookie is an HTTP middleware that moves the refresh token between
// the auth JSON endpoints and an httpOnly, SameSite=Strict cookie:
//
-// - POST /v1/auth/refresh: if the request body carries no refresh token, the
-// one from the cookie is injected, so the browser never has to hold it.
-// - 2xx responses of /v1/auth/authenticate, /v1/auth/mfa/complete, and
-// /v1/auth/refresh: the refresh token is lifted out of the JSON body into
-// the cookie and STRIPPED from the body, so JS never sees it.
+// - POST /v1/auth/refresh and POST /v1/auth/logout: when the request body
+// carries no refresh token, the one from the cookie is injected, so the
+// browser never has to hold it.
+// - 2xx responses of every authentication-completing route: the refresh token
+// is lifted out of the JSON body into the cookie and STRIPPED from the body,
+// so JS never sees it.
// - POST /v1/auth/logout: the cookie is cleared.
//
// SameSite=Strict + the existing credentialed-CORS allowlist is the CSRF
-// defense for the (same-origin) refresh call. Secure is set automatically on
-// TLS so local http dev still works.
+// defense for the (same-origin) refresh call.
func refreshTokenCookie(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
- isAuth := path == "/v1/auth/authenticate"
- isMFAComplete := path == "/v1/auth/mfa/complete"
- isRefresh := path == "/v1/auth/refresh"
- isLogout := path == "/v1/auth/logout"
+ completesAuthentication := authenticationCompletingRESTPaths[path]
+ consumesCookieRefreshToken := cookieRefreshTokenRESTPaths[path]
+ isLogout := path == logoutRESTPath
- if !isAuth && !isMFAComplete && !isRefresh && !isLogout {
+ if !completesAuthentication && !consumesCookieRefreshToken {
next.ServeHTTP(w, r)
return
}
- // Request side: feed the cookie's refresh token into the refresh call
- // when the body doesn't already carry one.
- if isRefresh {
+ // Request side: feed the cookie's refresh token into the call when the
+ // body doesn't already carry one. Logout needs this as much as refresh
+ // does โ the browser's logout posts an empty body and the HttpOnly
+ // cookie, so without the lift the request failed field validation before
+ // Logout ran, leaving the session family and the access token live while
+ // the browser's own cookie was cleared.
+ if consumesCookieRefreshToken {
if c, err := r.Cookie(refreshTokenCookieName); err == nil && c.Value != "" {
r = injectRefreshToken(r, c.Value)
}
@@ -67,12 +69,12 @@ func refreshTokenCookie(next http.Handler) http.Handler {
next.ServeHTTP(rec, r)
if isLogout {
- clearRefreshCookie(w, r)
+ clearRefreshCookie(w)
}
body := rec.buf.Bytes()
- if (isAuth || isMFAComplete || isRefresh) && rec.status >= 200 && rec.status < 300 {
+ if completesAuthentication && rec.status >= 200 && rec.status < 300 {
if token, stripped, ok := extractAndStripRefreshToken(body); ok {
- setRefreshCookie(w, r, token)
+ setRefreshCookie(w, token)
body = stripped
}
}
@@ -82,6 +84,47 @@ func refreshTokenCookie(next http.Handler) http.Handler {
})
}
+const logoutRESTPath = "/v1/auth/logout"
+
+// authenticationCompletingRESTPaths are the REST routes whose response carries a
+// freshly minted refresh token. Every one of them must hand it to the browser as
+// a cookie and strip it from the body; a route missing here returns the
+// credential as readable content.
+//
+// TestEveryAuthCompletionCookiesTheRefreshToken derives the same set from the
+// service descriptor and fails when the two disagree, so a new route cannot be
+// added to the contract and left out here. The WebAuthn completion was exactly
+// that: it shares its response message with the TOTP completion, and only the
+// TOTP path was listed.
+var authenticationCompletingRESTPaths = map[string]bool{
+ "/v1/auth/authenticate": true,
+ "/v1/auth/mfa/complete": true,
+ "/v1/auth/mfa/webauthn/complete": true,
+ "/v1/auth/refresh": true,
+}
+
+// oauthTokenEndpointRESTPath is the one authentication-completing route whose
+// response carries the refresh credential in the body, and must.
+//
+// It is the OAuth 2.1 token endpoint. Its caller is a registered client
+// exchanging an authorization code or a refresh token over a back-channel POST,
+// not a browser completing a session: RFC 6749 ยง5.1 defines the response as a
+// JSON body with `refresh_token` in it, and a Set-Cookie there reaches no client
+// that could use it. The credential it issues is bound to that client's own
+// session kind and registry, which is the separate contract the browser's
+// session is deliberately not part of.
+//
+// Named as an exemption rather than simply absent, so the descriptor-derived
+// coverage test can require it to be the ONLY one.
+const oauthTokenEndpointRESTPath = "/v1/auth/token"
+
+// cookieRefreshTokenRESTPaths are the routes whose refresh-token field is served
+// from the cookie when the body does not carry one.
+var cookieRefreshTokenRESTPaths = map[string]bool{
+ "/v1/auth/refresh": true,
+ logoutRESTPath: true,
+}
+
// bodyCapture buffers the downstream handler's response so the middleware can
// rewrite the body and headers before they reach the client.
type bodyCapture struct {
@@ -152,45 +195,51 @@ func extractAndStripRefreshToken(body []byte) (token string, stripped []byte, ok
return token, out, true
}
-func secureRefreshCookie(r *http.Request) bool {
- // Public hosts stay fail-closed behind TLS-terminating proxies. Loopback
- // HTTP is the only exception because browsers otherwise discard the cookie.
- host := r.Host
- if parsedHost, _, err := net.SplitHostPort(host); err == nil {
- host = parsedHost
- }
- if strings.EqualFold(host, "localhost") || strings.HasSuffix(strings.ToLower(host), ".localhost") {
- return false
- }
- if ip := net.ParseIP(host); ip != nil && ip.IsLoopback() {
- return false
- }
- return true
+// allowInsecureRefreshCookie drops Secure from the refresh cookie. It exists for
+// one situation: a browser discards a Secure cookie on a plaintext loopback
+// origin, so local development would have no session at all.
+//
+// It is a property of the DEPLOYMENT, and the zero value โ every deployed
+// environment, since only work.go sets it โ keeps Secure. The flag replaces a
+// derivation from the request's Host header, which a caller supplies: a request
+// arriving at a deployed cell with `Host: localhost` was answered with a cookie
+// the browser would then send over plaintext.
+var allowInsecureRefreshCookie bool
+
+// SetAllowInsecureRefreshCookie wires the local-development exemption. Call it
+// from codefly.IsLocal() only, like SetExposeAuthErrorDetail: it must never be
+// true in a deployed environment.
+func SetAllowInsecureRefreshCookie(v bool) { allowInsecureRefreshCookie = v }
+
+// secureRefreshCookie reports whether the refresh cookie carries Secure.
+func secureRefreshCookie() bool { return !allowInsecureRefreshCookie }
+
+// refreshCookieMaxAgeSeconds matches the refresh-token TTL.
+const refreshCookieMaxAgeSeconds = 7 * 24 * 60 * 60
+
+func setRefreshCookie(w http.ResponseWriter, token string) {
+ http.SetCookie(w, refreshCookie(token, refreshCookieMaxAgeSeconds))
}
-func setRefreshCookie(w http.ResponseWriter, r *http.Request, token string) {
- http.SetCookie(w, &http.Cookie{
- Name: refreshTokenCookieName,
- Value: token,
- Path: "/v1/auth",
- HttpOnly: true,
- Secure: secureRefreshCookie(r),
- SameSite: http.SameSiteStrictMode,
- MaxAge: 7 * 24 * 60 * 60, // 7 days, matches refresh-token TTL
- })
+func clearRefreshCookie(w http.ResponseWriter) {
+ cleared := refreshCookie("", -1)
+ cleared.Expires = time.Unix(0, 0)
+ http.SetCookie(w, cleared)
}
-func clearRefreshCookie(w http.ResponseWriter, r *http.Request) {
- http.SetCookie(w, &http.Cookie{
+// refreshCookie is the one shape the refresh credential is ever handed to a
+// browser in: scoped to the auth routes, not script-readable, same-site, and
+// transport-protected outside local development.
+func refreshCookie(token string, maxAge int) *http.Cookie {
+ return &http.Cookie{
Name: refreshTokenCookieName,
- Value: "",
+ Value: token,
Path: "/v1/auth",
HttpOnly: true,
- Secure: secureRefreshCookie(r),
+ Secure: secureRefreshCookie(),
SameSite: http.SameSiteStrictMode,
- MaxAge: -1,
- Expires: time.Unix(0, 0),
- })
+ MaxAge: maxAge,
+ }
}
var (
diff --git a/module/services/accounts/code/pkg/adapters/rest_extras_test.go b/module/services/accounts/code/pkg/adapters/rest_extras_test.go
index c154fceba..2ade0305a 100644
--- a/module/services/accounts/code/pkg/adapters/rest_extras_test.go
+++ b/module/services/accounts/code/pkg/adapters/rest_extras_test.go
@@ -97,21 +97,46 @@ func TestRefreshCookie_DoesNotTrustForwardedProto(t *testing.T) {
}
}
-func TestRefreshCookie_LoopbackHTTPOriginCanStoreCookie(t *testing.T) {
- h := refreshTokenCookie(downstream(`{"accessToken":"at","refreshToken":"rt-secret"}`))
- req := httptest.NewRequest(http.MethodPost, "http://localhost:21931/v1/auth/authenticate", strings.NewReader(`{}`))
+// The local-development exemption is the RUNTIME's, not the request's: a browser
+// discards a Secure cookie on a plaintext loopback origin, so local development
+// needs one, and nothing a caller sends may claim it. Asserted in both
+// directions, because the defect was that a deployed cell answered `Host:
+// localhost` with a cookie it would then send over plaintext.
+func TestRefreshCookieSecureFlagComesFromTheRuntimeNotTheRequest(t *testing.T) {
+ loopbackRequest := func() *http.Request {
+ return httptest.NewRequest(http.MethodPost,
+ "http://localhost:21931/v1/auth/authenticate", strings.NewReader(`{}`))
+ }
+
+ // A deployed runtime (the zero value of the flag) keeps Secure even for a
+ // request that looks entirely like local development.
rec := httptest.NewRecorder()
- h.ServeHTTP(rec, req)
+ refreshTokenCookie(downstream(`{"accessToken":"at","refreshToken":"rt-secret"}`)).
+ ServeHTTP(rec, loopbackRequest())
+ if cookie := refreshCookieOf(t, rec); !cookie.Secure {
+ t.Fatal("a deployed runtime must keep Secure whatever the request's host says")
+ }
+ // A local runtime drops it, so a developer still has a session.
+ SetAllowInsecureRefreshCookie(true)
+ t.Cleanup(func() { SetAllowInsecureRefreshCookie(false) })
+ rec = httptest.NewRecorder()
+ refreshTokenCookie(downstream(`{"accessToken":"at","refreshToken":"rt-secret"}`)).
+ ServeHTTP(rec, loopbackRequest())
+ if cookie := refreshCookieOf(t, rec); cookie.Secure {
+ t.Fatal("a local runtime must drop Secure so the browser stores the cookie")
+ }
+}
+
+func refreshCookieOf(t *testing.T, rec *httptest.ResponseRecorder) *http.Cookie {
+ t.Helper()
for _, cookie := range rec.Result().Cookies() {
if cookie.Name == refreshTokenCookieName {
- if cookie.Secure {
- t.Fatal("loopback HTTP refresh cookie must be accepted by the browser")
- }
- return
+ return cookie
}
}
t.Fatal("refresh-token cookie was not set")
+ return nil
}
func TestRefreshCookie_RefreshRequest_InjectsCookieIntoBody(t *testing.T) {
diff --git a/module/services/accounts/code/pkg/adapters/rpcs.go b/module/services/accounts/code/pkg/adapters/rpcs.go
index f30745135..c473b5d5a 100644
--- a/module/services/accounts/code/pkg/adapters/rpcs.go
+++ b/module/services/accounts/code/pkg/adapters/rpcs.go
@@ -1162,7 +1162,14 @@ func (s *AuthServer) Logout(ctx context.Context, req *gen.LogoutRequest) (*empty
// family. Empty when called from a non-authenticated context (e.g.
// the FE only forwards the refresh, never the access).
accessToken := bearerFromContext(ctx)
- if err := service.Logout(ctx, req, accessToken); err != nil {
+ // The verified session, from the credential the caller actually presented
+ // rather than from the message: the session's access half is then revoked on
+ // the strength of who is calling, not of what the body happened to carry.
+ var verifiedSession string
+ if sessionID, ok := auth.VerifiedSessionID(ctx); ok {
+ verifiedSession = sessionID.String()
+ }
+ if err := service.Logout(ctx, req, accessToken, verifiedSession); err != nil {
return nil, err
}
return &emptypb.Empty{}, nil
diff --git a/module/services/accounts/code/pkg/business/auth.go b/module/services/accounts/code/pkg/business/auth.go
index dffc99d7b..2967e4369 100644
--- a/module/services/accounts/code/pkg/business/auth.go
+++ b/module/services/accounts/code/pkg/business/auth.go
@@ -467,14 +467,21 @@ func (s *Service) BeginOAuth(ctx context.Context, provider, redirectURI string)
return s.oauthState.Mint(provider, redirectURI)
}
-// Logout revokes the session family associated with the given refresh
-// token AND adds the caller's access token jti to the revocation list
-// (when accessToken is non-empty). Idempotent: revoking an
-// already-revoked token is a no-op.
+// Logout ends the presented session: it revokes the session family the refresh
+// token belongs to, adds the caller's access token jti to the revocation list,
+// and marks the presented session so every access token minted for it dies now
+// rather than at its natural TTL. Idempotent: revoking an already-revoked
+// session is a no-op.
//
-// The access token half is best-effort โ failure here never fails the
-// logout, since the token will expire naturally within AccessTokenTTL.
-func (s *Service) Logout(ctx context.Context, req *gen.LogoutRequest, accessToken string) error {
+// sessionID is the verified session the caller presented, not a request field.
+// It is what makes the access half of the sign-out independent of the body: a
+// logout that reaches here revokes the presented session's access tokens whether
+// or not a refresh credential came with it.
+//
+// Both access-token revocations are best-effort โ a failure never fails the
+// logout, since those tokens expire within AccessTokenTTL โ while the family
+// revocation is the durable authority and its error is returned.
+func (s *Service) Logout(ctx context.Context, req *gen.LogoutRequest, accessToken, sessionID string) error {
w := wool.Get(ctx).In("Logout")
if s.minter == nil {
@@ -485,6 +492,11 @@ func (s *Service) Logout(ctx context.Context, req *gen.LogoutRequest, accessToke
w.Warn("RevokeAccess failed (best-effort)", wool.ErrField(err))
}
}
+ if sessionID != "" {
+ if err := s.minter.RevokeSessionAccess(ctx, sessionID); err != nil {
+ w.Warn("RevokeSessionAccess failed (best-effort)", wool.ErrField(err))
+ }
+ }
return s.minter.Revoke(ctx, req.RefreshToken)
}
diff --git a/module/services/accounts/code/work.go b/module/services/accounts/code/work.go
index 063746438..58cf7e37d 100644
--- a/module/services/accounts/code/work.go
+++ b/module/services/accounts/code/work.go
@@ -839,6 +839,10 @@ func doWork(ctx context.Context) (Clean, error) {
// debugging; every deployed environment returns generic auth errors so the
// identity/enumeration oracle stays closed (#208).
adapters.SetExposeAuthErrorDetail(codefly.IsLocal())
+ // A browser discards a Secure cookie on a plaintext loopback origin, so local
+ // development needs the exemption; nothing else does, and the default keeps
+ // Secure.
+ adapters.SetAllowInsecureRefreshCookie(codefly.IsLocal())
// Separate shared-secret guards for internal RPC admission and forwarded
// gateway identity. Empty values fail closed; production deploys provide
From 09dfe53b05f44ade67d6aa4d59d5da2a939c8ce6 Mon Sep 17 00:00:00 2001
From: Antoine Toussaint
<312536060+antoinetoussaint-byte@users.noreply.github.com>
Date: Mon, 5 Oct 2026 02:54:35 -0400
Subject: [PATCH 06/46] fix(accounts): an identity assertion in the request
body is honoured only in header-jwt mode (SA-F-HEADERJWT)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The login route's header-jwt branch read the same validator field the
authorization-code exchange uses, and every provider mode wires that field. So a
deployment configured for OIDC accepted a request body carrying `header_jwt` and
handed the token to the OIDC validator โ which checks a signature, an issuer, an
audience and an expiry, and no nonce. The nonce is the code exchange's binding to
a single authorize request and lives in the state that exchange holds, so the body
path completed a sign-in with no state, no proof-of-possession verifier and no
nonce: exactly the id_token injection and replay the nonce exists to stop. The
precondition was holding any unexpired id_token for the configured client.
The header-jwt validator is now its own field, set only by work.go's header-jwt
branch. In every other mode it is nil and the credential is refused before any
validator sees the token.
TestL1VHeaderJWTBodyRefusedInOIDCMode is the verifier's reproducer, inverted, and
asserts the code-exchange validator was never consulted rather than only that the
call failed. Its counterpart wires the header-jwt validator and asserts the same
credential IS validated there, so the refusal is the mode and not a path that
refuses everything. Mutation-checked: pointing the branch back at the shared field
fails both.
identity.env records which credential each provider mode accepts, beside the key
that decides it.
Refs #1018
Co-Authored-By: Claude Opus 5 (1M context)
---
module/configurations/local/identity.env | 8 ++
.../accounts/code/pkg/business/auth.go | 8 +-
.../pkg/business/auth_header_jwt_mode_test.go | 92 +++++++++++++++++++
.../code/pkg/business/auth_header_jwt_test.go | 5 +-
.../accounts/code/pkg/business/service.go | 20 ++++
module/services/accounts/code/work.go | 2 +-
6 files changed, 130 insertions(+), 5 deletions(-)
create mode 100644 module/services/accounts/code/pkg/business/auth_header_jwt_mode_test.go
diff --git a/module/configurations/local/identity.env b/module/configurations/local/identity.env
index c2980660f..aa90054af 100644
--- a/module/configurations/local/identity.env
+++ b/module/configurations/local/identity.env
@@ -1,6 +1,14 @@
# The ordinary local profile uses a Codefly-selected fixture for identity.
# The fixture name is data only; this configuration selects the authentication
# adapter explicitly. Real-provider dogfooding uses the dogfood profile.
+#
+# IDENTITY_PROVIDER also decides which credential the login route accepts, and
+# the modes do not overlap. Only `header-jwt` wires the validator that reads an
+# identity assertion out of the gateway-injected header; in every other mode a
+# request body carrying `header_jwt` is refused by name rather than validated,
+# because a validator built for the authorization-code exchange checks no nonce โ
+# the nonce is that exchange's binding to a single authorize request and lives in
+# the state it holds.
IDENTITY_PROVIDER=fixture
# IDENTITY_SIGNUP_MODE gates signup only; login and invite are never gated.
diff --git a/module/services/accounts/code/pkg/business/auth.go b/module/services/accounts/code/pkg/business/auth.go
index 2967e4369..5cb3101e8 100644
--- a/module/services/accounts/code/pkg/business/auth.go
+++ b/module/services/accounts/code/pkg/business/auth.go
@@ -95,13 +95,17 @@ func (s *Service) Authenticate(ctx context.Context, req *gen.AuthenticateRequest
}
case *gen.AuthenticateRequest_HeaderJwt:
authenticationMethod = auth.AuthenticationMethodHeaderJWT
- if s.validator == nil {
+ // The header-jwt validator, never the code-exchange one. A deployment in
+ // any other provider mode leaves this nil, so the credential is refused
+ // rather than handed to a validator that asks no nonce โ see
+ // SetHeaderJWTTokenValidator.
+ if s.headerJWTValidator == nil {
return nil, w.Wrapf(auth.ErrInvalidOAuthRequest, "header-jwt authentication is not enabled")
}
if credentials.HeaderJwt == nil || credentials.HeaderJwt.Token == "" {
return nil, w.Wrapf(auth.ErrMissingClaims, "header-jwt token missing")
}
- claims, err = s.validator.Validate(ctx, credentials.HeaderJwt.Token)
+ claims, err = s.headerJWTValidator.Validate(ctx, credentials.HeaderJwt.Token)
if err != nil {
return nil, w.Wrapf(err, "header-jwt authentication")
}
diff --git a/module/services/accounts/code/pkg/business/auth_header_jwt_mode_test.go b/module/services/accounts/code/pkg/business/auth_header_jwt_mode_test.go
new file mode 100644
index 000000000..7ba26efe7
--- /dev/null
+++ b/module/services/accounts/code/pkg/business/auth_header_jwt_mode_test.go
@@ -0,0 +1,92 @@
+package business
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "accounts/pkg/auth"
+ gen "accounts/pkg/gen/saas/accounts/v1"
+
+ "github.com/stretchr/testify/require"
+)
+
+// recordingValidator stands in for the provider stack a deployment builds. The
+// assertion that matters is whether it is CONSULTED: an id_token presented in the
+// request body must never reach the code-exchange validator, which checks a
+// signature, an issuer, an audience and an expiry โ and no nonce, because the
+// nonce is the code exchange's binding to a single authorize request and lives in
+// the state that exchange holds.
+type recordingValidator struct{ consulted bool }
+
+func (v *recordingValidator) Validate(context.Context, string) (*auth.Claims, error) {
+ v.consulted = true
+ return &auth.Claims{Subject: "subject", Email: "person@example.com", Provider: "oidc"}, nil
+}
+
+type refusingResolver struct{}
+
+func (refusingResolver) Resolve(context.Context, *auth.Claims, auth.Intent) (*auth.Identity, error) {
+ return nil, errors.New("the resolver must not be reached")
+}
+
+type headerJWTModeStore struct{ Store }
+
+// The only store read either test reaches. Both end before any identity exists:
+// what each asserts is whether the VALIDATOR was consulted.
+func (*headerJWTModeStore) ResolveIdentity(context.Context, string, string) (*ResolvedIdentity, error) {
+ return nil, errors.New("no identity in this fixture")
+}
+
+// In OIDC mode the header-jwt login path is inactive, and a body carrying an
+// identity assertion is refused rather than validated. It used to be handed to
+// the OIDC validator, which admitted any unexpired id_token for the configured
+// client with no state, no proof-of-possession verifier and no nonce โ a full
+// session from an assertion that completed none of the handoff.
+func TestL1VHeaderJWTBodyRefusedInOIDCMode(t *testing.T) {
+ validator := &recordingValidator{}
+ service, err := NewService(&headerJWTModeStore{})
+ require.NoError(t, err)
+ service.SetIdentityResolver(refusingResolver{})
+ service.SetJWTMinter(stubMinterForHeaderJWTMode{})
+ // Exactly what work.go's OIDC branch wires: the code-exchange validator, and
+ // no header-jwt validator.
+ service.SetTokenValidator(validator)
+
+ resp, err := service.Authenticate(context.Background(), &gen.AuthenticateRequest{
+ Provider: "oidc",
+ Authentication: &gen.AuthenticateRequest_HeaderJwt{
+ HeaderJwt: &gen.HeaderJWTAuthentication{Token: "an.unexpired.idtoken"},
+ },
+ })
+
+ require.Nil(t, resp)
+ require.ErrorIs(t, err, auth.ErrInvalidOAuthRequest)
+ require.False(t, validator.consulted,
+ "the body credential must be refused before any validator sees it")
+}
+
+// The counterpart: with the header-jwt validator wired โ the one thing work.go's
+// header-jwt branch does and no other branch does โ the credential is accepted
+// and reaches that validator, so the refusal above is the mode and not a path
+// that refuses everything.
+func TestHeaderJWTBodyIsValidatedInHeaderJWTMode(t *testing.T) {
+ headerJWT := &recordingValidator{}
+ service, err := NewService(&headerJWTModeStore{})
+ require.NoError(t, err)
+ service.SetIdentityResolver(refusingResolver{})
+ service.SetJWTMinter(stubMinterForHeaderJWTMode{})
+ service.SetHeaderJWTTokenValidator(headerJWT)
+
+ _, err = service.Authenticate(context.Background(), &gen.AuthenticateRequest{
+ Provider: "header-jwt",
+ Authentication: &gen.AuthenticateRequest_HeaderJwt{
+ HeaderJwt: &gen.HeaderJWTAuthentication{Token: "an.unexpired.idtoken"},
+ },
+ })
+
+ require.True(t, headerJWT.consulted, "the header-jwt validator must see the credential")
+ require.Error(t, err, "the refusing resolver is what ends this call")
+}
+
+type stubMinterForHeaderJWTMode struct{ auth.JWTMinter }
diff --git a/module/services/accounts/code/pkg/business/auth_header_jwt_test.go b/module/services/accounts/code/pkg/business/auth_header_jwt_test.go
index dce1f7b39..eba442ab0 100644
--- a/module/services/accounts/code/pkg/business/auth_header_jwt_test.go
+++ b/module/services/accounts/code/pkg/business/auth_header_jwt_test.go
@@ -67,8 +67,9 @@ func wireHeaderJWTOnTestService(t *testing.T, fi *headerJWTIssuer, cfg headerjwt
cfg.Audience = "codefly-app"
v, err := headerjwt.New(cfg)
require.NoError(t, err)
- testService.SetTokenValidator(v)
- t.Cleanup(func() { testService.SetTokenValidator(nil) })
+ // The header-jwt validator, which is the only one this login path reads.
+ testService.SetHeaderJWTTokenValidator(v)
+ t.Cleanup(func() { testService.SetHeaderJWTTokenValidator(nil) })
}
func headerJWTRequest(token string) *gen.AuthenticateRequest {
diff --git a/module/services/accounts/code/pkg/business/service.go b/module/services/accounts/code/pkg/business/service.go
index 5ac27071b..f3e1af421 100644
--- a/module/services/accounts/code/pkg/business/service.go
+++ b/module/services/accounts/code/pkg/business/service.go
@@ -28,6 +28,7 @@ type Service struct {
store Store
hasher KeyHasher
validator auth.TokenValidator // production: validates provider tokens after OAuth code exchange
+ headerJWTValidator auth.TokenValidator // header-jwt mode only: validates the gateway-injected identity assertion
exchanger CodeExchanger // production: exchanges OAuth codes for provider tokens
devValidator auth.TokenValidator // development only: allowlists explicit fixture identities
resolver auth.IdentityResolver
@@ -511,6 +512,25 @@ func (s *Service) SetTokenValidator(v auth.TokenValidator) {
s.validator = v
}
+// SetHeaderJWTTokenValidator enables the header-jwt login path, where the
+// identity assertion arrives as a token the trusted gateway injected rather than
+// through an authorization-code exchange.
+//
+// It is a SEPARATE field from the code-exchange validator on purpose. Both are
+// built from the same configured provider's key set, so sharing one field made
+// the two paths indistinguishable: a body carrying `header_jwt` reached the
+// code-exchange validator, which checks a signature, an issuer, an audience and
+// an expiry โ and no nonce, because a nonce is the code exchange's binding to a
+// single authorize request and lives in the state the exchange holds. The result
+// was an id_token admitted with no state, no proof-of-possession verifier and no
+// nonce: exactly the injection and replay the nonce exists to stop.
+//
+// Only work.go's header-jwt branch calls this, so in every other provider mode
+// the field is nil and the body credential is refused by name.
+func (s *Service) SetHeaderJWTTokenValidator(v auth.TokenValidator) {
+ s.headerJWTValidator = v
+}
+
// SetDevelopmentTokenValidator explicitly enables fixture authentication.
// The validator must resolve an opaque fixture token to allowlisted claims;
// Authenticate never trusts provider identity fields supplied by the caller.
diff --git a/module/services/accounts/code/work.go b/module/services/accounts/code/work.go
index 58cf7e37d..67094a334 100644
--- a/module/services/accounts/code/work.go
+++ b/module/services/accounts/code/work.go
@@ -641,7 +641,7 @@ func doWork(ctx context.Context) (Clean, error) {
if !hasConfiguredValue(headerName) {
return nil, fmt.Errorf("identity provider header-jwt requires IDENTITY_HEADER_NAME")
}
- service.SetTokenValidator(v)
+ service.SetHeaderJWTTokenValidator(v)
adapters.SetHeaderJWTLoginHeader(headerName)
default:
// user_identities.provider is a foreign key into the identity_providers
From 54fd9e12773b51faaa62c2b026c6e25d57763c78 Mon Sep 17 00:00:00 2001
From: Antoine Toussaint
<312536060+antoinetoussaint-byte@users.noreply.github.com>
Date: Mon, 5 Oct 2026 02:58:58 -0400
Subject: [PATCH 07/46] fix: a deployed service refuses a shipped placeholder
perimeter credential, by name (SA-F-PLACEHOLDER)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
This module ships working local defaults for its secret groups, out of a public
repository, and the configuration-defaults gate requires each of them to carry a
placeholder marker so a real credential can never be committed as one. Nothing
read that the other way round: a value carrying a marker is a value everybody
knows, and no deployed runtime rejected one. An unprovisioned secret group was
therefore not a denied group โ it was a live credential with a published value,
and the perimeter rested on mesh authorization alone.
accounts and auth-gateway now refuse to start, outside local development, when a
credential that decides perimeter membership carries a shipped marker or is
shorter than 32 characters. The refusal names the group-qualified key an operator
must provision and never prints a value. The length floor is not a second
spelling of the marker check: it also refuses a short hand-typed value carrying
no marker, which is the other way a published-strength credential arrives.
Scope is deliberate. Only the cluster-internal token, its rotation predecessor
and the gateway-provenance token are checked โ the values a service trusts to
decide that a caller is inside. An optional feature's unprovisioned credential is
a different question with a different answer, the feature is off, and conflating
them would wedge a cell that simply does not use one. A coverage test pins the
checked set in each service, so a fourth trust-deciding key cannot be added
unchecked while looking checked.
The marker list and the floor are duplicated in two Go modules with no shared
local package, so module/tools/shipped_placeholder_lockstep_test.go holds all
three copies identical. A marker on only one side is invisible in the diff that
causes it and fails in one of two ways: a published value a cell still accepts, or
a shipped default that side can no longer boot on. Mutation-checked by removing
one marker from accounts.
module/AGENTS.md: the paragraph that said nothing rejects a shipped placeholder,
replaced by what now does and what deliberately still does not.
Refs #1018
Co-Authored-By: Claude Opus 5 (1M context)
---
module/AGENTS.md | 18 ++-
.../accounts/code/shipped_placeholder.go | 117 ++++++++++++++++++
.../accounts/code/shipped_placeholder_test.go | 71 +++++++++++
module/services/accounts/code/work.go | 5 +-
module/services/auth-gateway/code/main.go | 7 ++
.../auth-gateway/code/shipped_placeholder.go | 99 +++++++++++++++
.../code/shipped_placeholder_test.go | 56 +++++++++
.../shipped_placeholder_lockstep_test.go | 103 +++++++++++++++
8 files changed, 472 insertions(+), 4 deletions(-)
create mode 100644 module/services/accounts/code/shipped_placeholder.go
create mode 100644 module/services/accounts/code/shipped_placeholder_test.go
create mode 100644 module/services/auth-gateway/code/shipped_placeholder.go
create mode 100644 module/services/auth-gateway/code/shipped_placeholder_test.go
create mode 100644 module/tools/shipped_placeholder_lockstep_test.go
diff --git a/module/AGENTS.md b/module/AGENTS.md
index 96431ad7f..84f1d83b2 100644
--- a/module/AGENTS.md
+++ b/module/AGENTS.md
@@ -107,6 +107,18 @@ Context. That is a property of those maps, not of configuration here.
Other groups ship a **working default**, which is the opposite posture: `local`
carries `CODEFLY_INTERNAL_TOKEN=local-dev-only-replace-me` in
`internal-auth.secret.env` and `IDENTITY_SIGNUP_MODE=open` in `identity.env`.
-Nothing rejects a shipped placeholder that reaches a real deployment, so an
-unprovisioned group is not a denied one โ it is a live credential with a value
-everybody knows.
+
+For the credentials that decide **perimeter membership** โ the cluster-internal
+token and the gateway-provenance token โ a shipped default no longer reaches a
+deployed runtime: accounts and `auth-gateway` each refuse to start when one
+carries a marker from the same list the shipping gate requires of every secret
+default, or is shorter than 32 characters, and the refusal names the
+group-qualified key. The marker lists and the length floor live in
+`shipped_placeholder.go` beside each service's entrypoint, and
+`module/tools/shipped_placeholder_lockstep_test.go` holds all three copies
+identical โ one added on only one side leaves either a published value a cell
+accepts or a default that side can no longer boot on.
+
+Every **other** group still ships a working default that a deployed runtime will
+use: an unprovisioned optional group is not a denied one. Where that matters for a
+credential, the loader's own "is this required" check is the place to say so.
diff --git a/module/services/accounts/code/shipped_placeholder.go b/module/services/accounts/code/shipped_placeholder.go
new file mode 100644
index 000000000..d2f83ecc9
--- /dev/null
+++ b/module/services/accounts/code/shipped_placeholder.go
@@ -0,0 +1,117 @@
+package main
+
+import (
+ "fmt"
+ "strings"
+)
+
+// Shipped-placeholder refusal.
+//
+// The module package ships working local defaults for its secret groups, and the
+// repository they live in is public. The configuration-defaults gate requires
+// every one of those defaults to carry a placeholder marker, precisely so a real
+// credential can never be committed as one. That makes the same marker list the
+// answer to the opposite question: a value carrying one is a value everybody
+// knows.
+//
+// Nothing rejected one outside local development, so an unprovisioned secret
+// group was not a denied group โ it was a live credential with a published value,
+// and the perimeter rested on mesh authorization alone.
+//
+// What is refused here, by name, at boot: the credentials that decide whether a
+// caller is inside the perimeter. An optional feature's unprovisioned credential
+// is a different question with a different answer โ the feature is off โ and
+// conflating the two would wedge a cell that simply does not use one.
+
+// shippedPlaceholderMarkers are the phrases a shipped default carries. It is the
+// same list the configuration-defaults gate requires of every secret default;
+// TestShippedPlaceholderMarkersMatchTheShippingGate in module/tools holds the two
+// copies in lockstep, because a marker added on one side and not the other means
+// either a default that cannot ship or a published value a cell would accept.
+//
+// Each is a distinctive placeholder phrase. Common English words โ "example",
+// "dummy" โ are deliberately absent: a production-shaped value would borrow one
+// as a loose substring and be refused for no reason.
+var shippedPlaceholderMarkers = []string{
+ "replace-me", "replaceme", "change-me", "changeme",
+ "placeholder", "local-dev", "dev-only",
+}
+
+// minimumPerimeterCredentialLength is the floor for a credential that decides
+// perimeter membership. 32 characters is the shortest value that can carry 128
+// bits of entropy in any encoding an operator is likely to generate, and the
+// shipped placeholders are shorter โ so the length check is not a second spelling
+// of the marker check: it also refuses a short hand-typed value that happens to
+// carry no marker.
+const minimumPerimeterCredentialLength = 32
+
+// looksLikeShippedPlaceholder reports whether value carries a marker.
+func looksLikeShippedPlaceholder(value string) bool {
+ lower := strings.ToLower(value)
+ for _, marker := range shippedPlaceholderMarkers {
+ if strings.Contains(lower, marker) {
+ return true
+ }
+ }
+ return false
+}
+
+// refusePerimeterCredential reports why a perimeter credential is unfit for a
+// deployed runtime, or nil. name is the group-qualified key, so the refusal says
+// what an operator has to provision rather than that something was wrong.
+//
+// It never includes the value. An empty value is not refused here: whether a
+// credential is REQUIRED is each loader's own question, and answering it twice
+// would make an optional group's absence read as a placeholder.
+func refusePerimeterCredential(name, value string) error {
+ trimmed := strings.TrimSpace(value)
+ if trimmed == "" {
+ return nil
+ }
+ if looksLikeShippedPlaceholder(trimmed) {
+ return fmt.Errorf(
+ "%s carries a placeholder this module ships in its public local defaults; "+
+ "provision a real value for this cell", name)
+ }
+ if len(trimmed) < minimumPerimeterCredentialLength {
+ return fmt.Errorf(
+ "%s is %d characters; a credential that decides perimeter membership must be at least %d",
+ name, len(trimmed), minimumPerimeterCredentialLength)
+ }
+ return nil
+}
+
+// perimeterCredentials are the values this service trusts to decide that a caller
+// is inside the perimeter: the cluster-internal token both halves of a rotation,
+// and the gateway-provenance token this service believes forwarded identity on.
+func perimeterCredentials() []struct{ Name, Value string } {
+ return []struct{ Name, Value string }{
+ {"internal-auth/CODEFLY_INTERNAL_TOKEN", workspaceEnv("internal-auth", "CODEFLY_INTERNAL_TOKEN")},
+ {"internal-auth/CODEFLY_INTERNAL_TOKEN_PREVIOUS", workspaceEnv("internal-auth", "CODEFLY_INTERNAL_TOKEN_PREVIOUS")},
+ {"gateway-trust/CODEFLY_GATEWAY_TOKEN", workspaceEnv("gateway-trust", "CODEFLY_GATEWAY_TOKEN")},
+ {"gateway-trust/CODEFLY_GATEWAY_TOKEN_PREVIOUS", workspaceEnv("gateway-trust", "CODEFLY_GATEWAY_TOKEN_PREVIOUS")},
+ }
+}
+
+// requirePerimeterCredentials refuses to start a deployed runtime holding a
+// shipped placeholder, or a perimeter credential too short to be one.
+//
+// Every refusal is collected rather than the first returned: an operator fixing
+// these is editing one secret group, and finding out about the second key on the
+// next deploy is a second outage for no reason.
+func requirePerimeterCredentials(isLocal bool) error {
+ if isLocal {
+ return nil
+ }
+ var problems []string
+ for _, credential := range perimeterCredentials() {
+ if err := refusePerimeterCredential(credential.Name, credential.Value); err != nil {
+ problems = append(problems, err.Error())
+ }
+ }
+ if len(problems) == 0 {
+ return nil
+ }
+ return fmt.Errorf("perimeter credentials are unfit for a deployed runtime: %s",
+ strings.Join(problems, "; "))
+}
diff --git a/module/services/accounts/code/shipped_placeholder_test.go b/module/services/accounts/code/shipped_placeholder_test.go
new file mode 100644
index 000000000..0006c10f8
--- /dev/null
+++ b/module/services/accounts/code/shipped_placeholder_test.go
@@ -0,0 +1,71 @@
+package main
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+// A shipped default is a published value: these files ride verbatim in the
+// immutable module package, out of a public repository. Outside local development
+// a service holding one is a service whose perimeter credential everybody knows,
+// and nothing said so.
+func TestDeployedRefusesShippedPlaceholders(t *testing.T) {
+ for _, value := range []string{
+ "local-dev-only-replace-me",
+ "local-dev-gateway-only-replace-me",
+ "LOCAL-DEV-ONLY-REPLACE-ME",
+ "prefixed-changeme-suffixed-and-long-enough-to-pass-the-length-floor",
+ "a-placeholder-value-long-enough-to-pass-the-length-floor",
+ } {
+ err := refusePerimeterCredential("internal-auth/CODEFLY_INTERNAL_TOKEN", value)
+ require.Error(t, err, "%q carries a shipped marker and must be refused", value)
+ require.Contains(t, err.Error(), "internal-auth/CODEFLY_INTERNAL_TOKEN",
+ "the refusal must name the key an operator has to provision")
+ require.NotContains(t, err.Error(), value, "a refusal never prints the value")
+ }
+}
+
+// The length floor refuses a short value that carries no marker at all, so the
+// two checks are not one check spelled twice.
+func TestDeployedRefusesAShortPerimeterCredential(t *testing.T) {
+ err := refusePerimeterCredential("gateway-trust/CODEFLY_GATEWAY_TOKEN", "7Kq2Xp9Vb4Nf")
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "gateway-trust/CODEFLY_GATEWAY_TOKEN")
+ require.Contains(t, err.Error(), "at least 32")
+ require.NotContains(t, err.Error(), "7Kq2Xp9Vb4Nf")
+}
+
+// The controls. A provisioned credential passes, and an ABSENT one is not refused
+// here: whether a credential is required is each loader's own question, and
+// answering it twice would make an optional group's absence read as a placeholder.
+func TestPerimeterCredentialChecksAdmitWhatTheyShould(t *testing.T) {
+ require.NoError(t, refusePerimeterCredential(
+ "internal-auth/CODEFLY_INTERNAL_TOKEN", strings.Repeat("7Kq2Xp9Vb4Nf", 4)))
+ require.NoError(t, refusePerimeterCredential(
+ "internal-auth/CODEFLY_INTERNAL_TOKEN_PREVIOUS", ""))
+ require.NoError(t, refusePerimeterCredential(
+ "internal-auth/CODEFLY_INTERNAL_TOKEN_PREVIOUS", " "))
+}
+
+// Local development runs on the shipped defaults, so the gate is the deployed
+// runtime's and must not touch it.
+func TestLocalRuntimeKeepsTheShippedDefaults(t *testing.T) {
+ require.NoError(t, requirePerimeterCredentials(true))
+}
+
+// Every perimeter credential this service reads is covered. A fifth key added to
+// the trust surface and not listed here would be unchecked and look checked.
+func TestPerimeterCredentialsCoverEveryTrustDecidingKey(t *testing.T) {
+ names := map[string]bool{}
+ for _, credential := range perimeterCredentials() {
+ names[credential.Name] = true
+ }
+ require.Equal(t, map[string]bool{
+ "internal-auth/CODEFLY_INTERNAL_TOKEN": true,
+ "internal-auth/CODEFLY_INTERNAL_TOKEN_PREVIOUS": true,
+ "gateway-trust/CODEFLY_GATEWAY_TOKEN": true,
+ "gateway-trust/CODEFLY_GATEWAY_TOKEN_PREVIOUS": true,
+ }, names)
+}
diff --git a/module/services/accounts/code/work.go b/module/services/accounts/code/work.go
index 67094a334..4fd338b90 100644
--- a/module/services/accounts/code/work.go
+++ b/module/services/accounts/code/work.go
@@ -2553,7 +2553,10 @@ func requireStartupConfiguration(ctx context.Context, isLocal bool) error {
if err != nil {
return err
}
- return requireKeyCustody(selection.SigningBackend, isLocal)
+ if err := requireKeyCustody(selection.SigningBackend, isLocal); err != nil {
+ return err
+ }
+ return requirePerimeterCredentials(isLocal)
}
// requireKeyCustody refuses to start outside the local environment without the
diff --git a/module/services/auth-gateway/code/main.go b/module/services/auth-gateway/code/main.go
index b644d9e3f..c003b87f1 100644
--- a/module/services/auth-gateway/code/main.go
+++ b/module/services/auth-gateway/code/main.go
@@ -33,6 +33,13 @@ func main() {
panic(fmt.Sprintf("codefly init failed: %v", err))
}
+ // Refuse before anything is served: a deployed gateway holding a shipped
+ // placeholder, or a perimeter credential too short to be one, decides
+ // perimeter membership on a published value.
+ if err := requirePerimeterCredentials(codefly.IsLocal()); err != nil {
+ panic(err.Error())
+ }
+
grpcPort := codefly.For(ctx).WithDefaultNetwork().API(standards.GRPC).NetworkInstance().Port
var httpPort uint16
httpNet, httpNetErr := codefly.For(ctx).API(standards.REST).ResolveNetworkInstance()
diff --git a/module/services/auth-gateway/code/shipped_placeholder.go b/module/services/auth-gateway/code/shipped_placeholder.go
new file mode 100644
index 000000000..286744b24
--- /dev/null
+++ b/module/services/auth-gateway/code/shipped_placeholder.go
@@ -0,0 +1,99 @@
+package main
+
+import (
+ "fmt"
+ "strings"
+)
+
+// Shipped-placeholder refusal โ the gateway's half.
+//
+// The module package ships working local defaults for its secret groups, out of a
+// public repository, and the configuration-defaults gate requires each of them to
+// carry a placeholder marker so a real credential can never be committed as one.
+// That makes the same marker list the answer to the opposite question: a value
+// carrying one is a value everybody knows.
+//
+// This gateway holds two credentials that decide perimeter membership โ the
+// cluster-internal token it accepts on its own internal endpoints, and the
+// gateway-provenance token accounts believes forwarded identity on โ and nothing
+// rejected a shipped placeholder in either outside local development.
+//
+// The list and the floor are duplicated from accounts deliberately: the two are
+// separate Go modules with no shared local package, and
+// module/tools/shipped_placeholder_lockstep_test.go holds the copies identical, so
+// a marker added on one side cannot leave the other admitting a published value.
+
+var shippedPlaceholderMarkers = []string{
+ "replace-me", "replaceme", "change-me", "changeme",
+ "placeholder", "local-dev", "dev-only",
+}
+
+// minimumPerimeterCredentialLength is the floor for a credential that decides
+// perimeter membership: the shortest value that can carry 128 bits of entropy in
+// any encoding an operator is likely to generate. The shipped placeholders are
+// shorter, so this is not a second spelling of the marker check โ it also refuses
+// a short hand-typed value carrying no marker.
+const minimumPerimeterCredentialLength = 32
+
+func looksLikeShippedPlaceholder(value string) bool {
+ lower := strings.ToLower(value)
+ for _, marker := range shippedPlaceholderMarkers {
+ if strings.Contains(lower, marker) {
+ return true
+ }
+ }
+ return false
+}
+
+// refusePerimeterCredential reports why a perimeter credential is unfit for a
+// deployed runtime, or nil. It names the group-qualified key and never the value.
+// An empty value is not refused here: whether a credential is required is the
+// loader's own question.
+func refusePerimeterCredential(name, value string) error {
+ trimmed := strings.TrimSpace(value)
+ if trimmed == "" {
+ return nil
+ }
+ if looksLikeShippedPlaceholder(trimmed) {
+ return fmt.Errorf(
+ "%s carries a placeholder this module ships in its public local defaults; "+
+ "provision a real value for this cell", name)
+ }
+ if len(trimmed) < minimumPerimeterCredentialLength {
+ return fmt.Errorf(
+ "%s is %d characters; a credential that decides perimeter membership must be at least %d",
+ name, len(trimmed), minimumPerimeterCredentialLength)
+ }
+ return nil
+}
+
+// perimeterCredentials are the values this gateway trusts to decide that a caller
+// is inside the perimeter.
+func perimeterCredentials() []struct{ Name, Value string } {
+ return []struct{ Name, Value string }{
+ {"internal-auth/CODEFLY_INTERNAL_TOKEN", workspaceEnv("internal-auth", "CODEFLY_INTERNAL_TOKEN")},
+ {"internal-auth/CODEFLY_INTERNAL_TOKEN_PREVIOUS", workspaceEnv("internal-auth", "CODEFLY_INTERNAL_TOKEN_PREVIOUS")},
+ {"gateway-trust/CODEFLY_GATEWAY_TOKEN", workspaceEnv("gateway-trust", "CODEFLY_GATEWAY_TOKEN")},
+ }
+}
+
+// requirePerimeterCredentials refuses to start a deployed runtime holding a
+// shipped placeholder, or a perimeter credential too short to be one. Every
+// refusal is collected: an operator fixing these edits one secret group, and
+// learning about the second key on the next deploy is a second outage for nothing.
+func requirePerimeterCredentials(isLocal bool) error {
+ if isLocal {
+ return nil
+ }
+ var problems []string
+ for _, credential := range perimeterCredentials() {
+ if err := refusePerimeterCredential(credential.Name, credential.Value); err != nil {
+ problems = append(problems, err.Error())
+ }
+ }
+ if len(problems) == 0 {
+ return nil
+ }
+ return fmt.Errorf("perimeter credentials are unfit for a deployed runtime: %s",
+ strings.Join(problems, "; "))
+}
diff --git a/module/services/auth-gateway/code/shipped_placeholder_test.go b/module/services/auth-gateway/code/shipped_placeholder_test.go
new file mode 100644
index 000000000..feda7d7f0
--- /dev/null
+++ b/module/services/auth-gateway/code/shipped_placeholder_test.go
@@ -0,0 +1,56 @@
+package main
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+// The gateway decides perimeter membership on these two credentials and accepted
+// a value published in this repository's own local defaults.
+func TestDeployedRefusesShippedPlaceholders(t *testing.T) {
+ for _, value := range []string{
+ "local-dev-only-replace-me",
+ "local-dev-gateway-only-replace-me",
+ "LOCAL-DEV-GATEWAY-ONLY-REPLACE-ME",
+ "prefixed-changeme-suffixed-and-long-enough-to-pass-the-length-floor",
+ } {
+ err := refusePerimeterCredential("gateway-trust/CODEFLY_GATEWAY_TOKEN", value)
+ require.Error(t, err, "%q carries a shipped marker and must be refused", value)
+ require.Contains(t, err.Error(), "gateway-trust/CODEFLY_GATEWAY_TOKEN")
+ require.NotContains(t, err.Error(), value, "a refusal never prints the value")
+ }
+}
+
+func TestDeployedRefusesAShortPerimeterCredential(t *testing.T) {
+ err := refusePerimeterCredential("internal-auth/CODEFLY_INTERNAL_TOKEN", "7Kq2Xp9Vb4Nf")
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "at least 32")
+ require.NotContains(t, err.Error(), "7Kq2Xp9Vb4Nf")
+}
+
+func TestPerimeterCredentialChecksAdmitWhatTheyShould(t *testing.T) {
+ require.NoError(t, refusePerimeterCredential(
+ "gateway-trust/CODEFLY_GATEWAY_TOKEN", strings.Repeat("7Kq2Xp9Vb4Nf", 4)))
+ require.NoError(t, refusePerimeterCredential(
+ "internal-auth/CODEFLY_INTERNAL_TOKEN_PREVIOUS", ""))
+}
+
+func TestLocalRuntimeKeepsTheShippedDefaults(t *testing.T) {
+ require.NoError(t, requirePerimeterCredentials(true))
+}
+
+// Every credential the ext_authz trust surface reads is covered, so a third key
+// added there cannot be unchecked while looking checked.
+func TestPerimeterCredentialsCoverEveryTrustDecidingKey(t *testing.T) {
+ names := map[string]bool{}
+ for _, credential := range perimeterCredentials() {
+ names[credential.Name] = true
+ }
+ require.Equal(t, map[string]bool{
+ "internal-auth/CODEFLY_INTERNAL_TOKEN": true,
+ "internal-auth/CODEFLY_INTERNAL_TOKEN_PREVIOUS": true,
+ "gateway-trust/CODEFLY_GATEWAY_TOKEN": true,
+ }, names)
+}
diff --git a/module/tools/shipped_placeholder_lockstep_test.go b/module/tools/shipped_placeholder_lockstep_test.go
new file mode 100644
index 000000000..646a2dea7
--- /dev/null
+++ b/module/tools/shipped_placeholder_lockstep_test.go
@@ -0,0 +1,103 @@
+package tools
+
+import (
+ "os"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strings"
+ "testing"
+)
+
+// The marker list exists in three places, and they must agree.
+//
+// `configuration_defaults_test.go` at the repository root REQUIRES every shipped
+// secret default to carry one, so a real credential can never be committed as a
+// default. accounts and auth-gateway REFUSE a value carrying one outside local
+// development, because a value carrying one is a value everybody knows: these
+// files ride verbatim in the immutable module package, out of a public repository.
+//
+// The three copies are separate Go modules with no shared local package, so the
+// agreement is checked at the text level. A marker added only to the shipping gate
+// leaves a published value a cell would accept; one added only to a service
+// refuses a default that side still ships, which is a boot failure in local
+// development. Neither is visible in the diff that causes it.
+//
+// Same for the length floor: the two services must not disagree about how strong a
+// perimeter credential has to be, or the gateway and accounts admit different
+// populations of value.
+func TestShippedPlaceholderMarkersMatchTheShippingGate(t *testing.T) {
+ moduleDir := findModuleDir(t)
+ repoRoot := filepath.Dir(moduleDir)
+
+ sources := map[string]string{
+ "the shipping gate (configuration_defaults_test.go)": filepath.Join(repoRoot, "configuration_defaults_test.go"),
+ "accounts": filepath.Join(moduleDir, "services", "accounts", "code", "shipped_placeholder.go"),
+ "auth-gateway": filepath.Join(moduleDir, "services", "auth-gateway", "code", "shipped_placeholder.go"),
+ }
+
+ var reference []string
+ var referenceName string
+ for name, path := range sources {
+ markers := placeholderMarkersIn(t, name, path)
+ if reference == nil {
+ reference, referenceName = markers, name
+ continue
+ }
+ if strings.Join(markers, ",") != strings.Join(reference, ",") {
+ t.Errorf("placeholder markers differ: %s has %v, %s has %v โ "+
+ "a marker on only one side either leaves a published value a cell accepts, "+
+ "or refuses a default that side still ships",
+ referenceName, reference, name, markers)
+ }
+ }
+ if len(reference) == 0 {
+ t.Fatal("no placeholder markers were found in any source")
+ }
+}
+
+func TestPerimeterCredentialFloorsAgree(t *testing.T) {
+ moduleDir := findModuleDir(t)
+ floors := map[string]string{}
+ for _, service := range []string{"accounts", "auth-gateway"} {
+ path := filepath.Join(moduleDir, "services", service, "code", "shipped_placeholder.go")
+ source, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("%s: %v", path, err)
+ }
+ match := regexp.MustCompile(`minimumPerimeterCredentialLength = (\d+)`).FindSubmatch(source)
+ if match == nil {
+ t.Fatalf("%s declares no minimumPerimeterCredentialLength", path)
+ }
+ floors[service] = string(match[1])
+ }
+ if floors["accounts"] != floors["auth-gateway"] {
+ t.Errorf("perimeter credential floors differ: accounts %s, auth-gateway %s โ "+
+ "the two would admit different populations of value",
+ floors["accounts"], floors["auth-gateway"])
+ }
+}
+
+// placeholderMarkersIn reads the sorted marker literals out of a
+// `...Markers = []string{...}` declaration, whatever the variable is called on
+// each side.
+func placeholderMarkersIn(t *testing.T, name, path string) []string {
+ t.Helper()
+ source, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("%s (%s): %v", name, path, err)
+ }
+ block := regexp.MustCompile(`(?s)laceholderMarkers = \[\]string\{(.*?)\}`).FindSubmatch(source)
+ if block == nil {
+ t.Fatalf("%s (%s): no placeholder marker declaration found", name, path)
+ }
+ var markers []string
+ for _, literal := range regexp.MustCompile(`"([^"]+)"`).FindAllSubmatch(block[1], -1) {
+ markers = append(markers, string(literal[1]))
+ }
+ if len(markers) == 0 {
+ t.Fatalf("%s (%s): the marker declaration is empty", name, path)
+ }
+ sort.Strings(markers)
+ return markers
+}
From d189b685850c29e79c4d4621048b0c25d5fdb16f Mon Sep 17 00:00:00 2001
From: Antoine Toussaint
<312536060+antoinetoussaint-byte@users.noreply.github.com>
Date: Mon, 5 Oct 2026 03:03:12 -0400
Subject: [PATCH 08/46] fix(auth-gateway): rate limits key on the real client,
and a deployed gateway refuses to start without the stores its controls need
(SA-F-RATELIMIT, SA-F-REVOKE-NOOP)
Three controls degraded into something that read as working, and two findings
cover them. They land together because they are one rule: a control whose
dependency is absent refuses rather than reporting success.
SA-F-RATELIMIT, this repository's half. An empty TRUSTED_PROXY_CIDRS was
accepted. With no trusted hop clientIP answers the peer, which behind the
frontend is the frontend's own pod: every anonymous caller shares one budget and
every authentication-factor attempt shares one, so roughly ten bogus MFA
completions a minute deny the factor to the whole cell and a thousand anonymous
requests a minute deny login to all of it. A denial of service delivered by the
control meant to prevent one, and invisible: the limiter was enabled, the buckets
were enforced, and nothing said they had collapsed onto one key. A deployed
gateway now refuses an empty range, naming the key; local development, where the
peer IS the client, still accepts it.
The limiter also fell back to per-replica in-memory counters whenever its store
was absent or unreachable. That is not the budget it reports: with N replicas the
effective limit is N times the configured one, and which replica a caller lands
on decides their share, which for an authentication-factor budget is the
difference between a bounded number of attempts and a bounded number per replica.
Deployed: refuse. The per-replica limiter is now a separate constructor rather
than a flag on the main one, so a deployed wiring cannot reach it.
Not done, and stated rather than left as a gap: the authentication-factor budget
is not additionally keyed on the MFA transaction. SP-GW-09 asks for the
originating client, which is what it now keys on, and accounts' own five-attempt
transaction lock already bounds one transaction across every replica. Adding the
dimension means buffering and parsing the request body at the edge, a new failure
mode on the login path for a bound that already exists one hop in. The platform
repository owns setting the range on every cell and forwarding the ingress hop.
SA-F-REVOKE-NOOP. With no store URL the gateway installed a no-op revoker and a
log line. That revoker reports every token as live, so a signed-out session, a
killed device and an ended impersonation window all keep authenticating until
their tokens expire naturally. Nothing on the request path can tell a no-op
revoker from an empty revocation set, so the gateway reports healthy throughout.
The fail-closed behaviour on a store ERROR was already right; this is the same
rule for a store that was never there.
Tests assert both directions in each case: the deployed refusal, and that local
development still gets its fallback. The collapse itself is asserted as a
property, two clients behind one frontend pod keying distinctly with a trusted
range and identically without one, so the boot refusal's stated reason is checked
rather than claimed. Mutation-checked: disabling each of the three refusals fails
its test.
Docs: a new section in module/services/auth-gateway/AGENTS.md on what a deployed
gateway refuses to start without, and gateway.env now says what an empty range
costs beside the key.
Refs #1018
Co-Authored-By: Claude Opus 5 (1M context)
---
module/configurations/local/gateway.env | 9 +-
module/services/auth-gateway/AGENTS.md | 22 +++++
.../code/deployed_failclosed_test.go | 94 +++++++++++++++++++
.../code/ext_authz_integration_test.go | 2 +-
.../auth-gateway/code/gateway_cors_test.go | 2 +-
.../code/gateway_solution_admission_test.go | 2 +-
.../gateway_solution_entitlements_test.go | 2 +-
.../code/gateway_solutions_test.go | 4 +-
.../auth-gateway/code/gateway_test.go | 2 +-
module/services/auth-gateway/code/main.go | 20 ++--
.../services/auth-gateway/code/ratelimit.go | 79 ++++++++++++++--
.../services/auth-gateway/code/revocation.go | 31 ++++--
12 files changed, 241 insertions(+), 28 deletions(-)
create mode 100644 module/services/auth-gateway/code/deployed_failclosed_test.go
diff --git a/module/configurations/local/gateway.env b/module/configurations/local/gateway.env
index 648f9c8a6..5395229ec 100644
--- a/module/configurations/local/gateway.env
+++ b/module/configurations/local/gateway.env
@@ -1,3 +1,10 @@
# Comma-separated exact IPs or CIDRs allowed to supply X-Forwarded-For and
-# X-Real-IP. Empty means the gateway trusts no forwarding headers.
+# X-Real-IP. Empty means the gateway trusts no forwarding headers โ so the client
+# address is always the peer, which behind a frontend hop is the frontend's own
+# pod, and every rate-limit bucket collapses onto it.
+#
+# A DEPLOYED gateway therefore refuses to start on an empty value: a cell's
+# ingress and frontend reach the gateway from known ranges, and naming them is
+# what makes the anonymous and authentication-factor budgets per-client rather
+# than per-cell. Empty is correct here and only here, where the peer IS the client.
TRUSTED_PROXY_CIDRS=
diff --git a/module/services/auth-gateway/AGENTS.md b/module/services/auth-gateway/AGENTS.md
index a4c788b67..a25c01989 100644
--- a/module/services/auth-gateway/AGENTS.md
+++ b/module/services/auth-gateway/AGENTS.md
@@ -10,6 +10,28 @@ for the trust model behind declared presence.
Nothing here may name a specific solution or composed module: every seam is
generic, and a registered target is data, never a branch.
+## What a deployed gateway refuses to start without
+
+Three of this service's controls are only controls when the thing they depend on
+is there, and each used to degrade into something that read as working:
+
+- **A trusted-proxy range** (`gateway/TRUSTED_PROXY_CIDRS`). Empty means no
+ forwarding header is trusted, so `clientIP` answers the peer โ behind the
+ frontend, the frontend's own pod. Every anonymous caller then shares one budget
+ and every authentication-factor attempt shares one, so a few bogus completions a
+ minute deny the factor to the whole cell. The limiter was enabled and the buckets
+ were enforced throughout; nothing said they had collapsed onto one key.
+- **A shared rate-limit store.** Per-replica counters enforce the configured budget
+ times the replica count, and which replica a caller lands on decides their share.
+- **A revocation store.** A revoker with no store answers "not revoked" to every
+ question, so a signed-out session, a killed device and an ended impersonation
+ window all keep authenticating until their tokens expire โ and no request can
+ tell that from an empty revocation set.
+
+Each refuses at boot outside local development, naming what to provision. Local
+development keeps all three fallbacks: one replica, no ingress hop, no cache
+service, and the alternative is a gateway that cannot start.
+
## Reading declared solution upstreams
`GET /solutions/_registry` returns this replica's read projection: identity,
diff --git a/module/services/auth-gateway/code/deployed_failclosed_test.go b/module/services/auth-gateway/code/deployed_failclosed_test.go
new file mode 100644
index 000000000..f2447e372
--- /dev/null
+++ b/module/services/auth-gateway/code/deployed_failclosed_test.go
@@ -0,0 +1,94 @@
+package main
+
+import (
+ "net/http/httptest"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+// A deployed gateway sits behind an ingress and a frontend hop, so with no
+// trusted-proxy range the client address is always the frontend's own pod: every
+// anonymous caller shares one budget and every authentication-factor attempt
+// shares one. A few bogus completions a minute then deny the factor to the whole
+// cell and a flood of anonymous requests denies login to all of it โ a denial of
+// service delivered by the control meant to prevent one, which read as working
+// because the limiter was enabled and the buckets were enforced.
+func TestDeployedRefusesEmptyProxyTrust(t *testing.T) {
+ for _, raw := range []string{"", " ", ",", " , "} {
+ err := requireProxyTrust(raw, false)
+ require.Error(t, err, "an empty range %q must refuse a deployed boot", raw)
+ require.Contains(t, err.Error(), "TRUSTED_PROXY_CIDRS",
+ "the refusal must name the key an operator has to set")
+ }
+}
+
+// Local development has no ingress hop and the peer IS the client.
+func TestLocalRuntimeAcceptsAnEmptyProxyTrust(t *testing.T) {
+ require.NoError(t, requireProxyTrust("", true))
+}
+
+// A configured range still has to parse, and a configured range admits the boot.
+func TestProxyTrustBootChecksAdmitAndRefuseTheRightThings(t *testing.T) {
+ require.NoError(t, requireProxyTrust("10.0.0.0/8,192.0.2.10", false))
+ require.Error(t, requireProxyTrust("10.0.0.0/8,not-an-address", false))
+ require.Error(t, requireProxyTrust("not-an-address", true),
+ "a malformed range is a configuration error in local development too")
+}
+
+// The collapse itself, as a property rather than as a boot check: with a trusted
+// hop configured, two clients arriving through the same frontend pod key on
+// themselves. The auditor's reproducer showed both keying on the pod.
+func TestTrustedProxyRangeKeysDistinctClientsDistinctly(t *testing.T) {
+ trust := newProxyTrust("10.0.0.0/8")
+
+ first := httptest.NewRequest("POST", "/v1/auth/mfa/complete", nil)
+ first.RemoteAddr = "10.4.0.7:44001" // the frontend pod
+ first.Header.Set("X-Forwarded-For", "198.51.100.44")
+
+ second := httptest.NewRequest("POST", "/v1/auth/mfa/complete", nil)
+ second.RemoteAddr = "10.4.0.7:44002" // the same frontend pod
+ second.Header.Set("X-Forwarded-For", "203.0.113.10")
+
+ require.Equal(t, "198.51.100.44", trust.clientIP(first))
+ require.Equal(t, "203.0.113.10", trust.clientIP(second))
+ require.NotEqual(t, trust.clientIP(first), trust.clientIP(second),
+ "two clients behind one hop must not share one budget")
+
+ // And with no trusted hop they do collapse, which is what the boot refusal
+ // above exists to prevent โ asserted so the refusal's reason is not merely
+ // claimed.
+ untrusting := newProxyTrust("")
+ require.Equal(t, untrusting.clientIP(first), untrusting.clientIP(second))
+ require.Equal(t, "10.4.0.7", untrusting.clientIP(first))
+}
+
+// A revoker with no store answers "not revoked" to every question, so a signed-out
+// session, a killed device and an ended impersonation window all keep
+// authenticating until their tokens expire โ and nothing on the request path can
+// tell that from an empty revocation set, so the gateway reports healthy.
+func TestDeployedRefusesAMissingRevocationStore(t *testing.T) {
+ _, err := newRevoker("", defaultRevocationCacheTTL, false)
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "revocation")
+
+ revoker, err := newRevoker("", defaultRevocationCacheTTL, true)
+ require.NoError(t, err, "local development has no cache service")
+ require.IsType(t, noopRevoker{}, revoker)
+}
+
+// A per-replica bucket is not the budget it reports: with N replicas the effective
+// limit is N times the configured one, and which replica a caller lands on decides
+// their share. For an authentication-factor budget that is the difference between
+// a bounded number of attempts and a bounded number per replica.
+func TestDeployedRefusesAMissingRateLimitStore(t *testing.T) {
+ t.Setenv("REDIS_URL", "")
+
+ _, err := NewRateLimiter(1000)
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "shared store")
+
+ limiter, err := NewRateLimiter(1000, WithInProcessBackendAllowed(true))
+ require.NoError(t, err, "local development has no cache service")
+ t.Cleanup(limiter.Stop)
+}
diff --git a/module/services/auth-gateway/code/ext_authz_integration_test.go b/module/services/auth-gateway/code/ext_authz_integration_test.go
index fcde35db1..42f41f331 100644
--- a/module/services/auth-gateway/code/ext_authz_integration_test.go
+++ b/module/services/auth-gateway/code/ext_authz_integration_test.go
@@ -119,7 +119,7 @@ func runExtAuthzIntegrationTests(m *testing.M) int {
fmt.Fprintf(os.Stderr, "cache Redis connection not available\n")
return 1
}
- revoker, err := newRevoker(redisURL, defaultRevocationCacheTTL)
+ revoker, err := newRevoker(redisURL, defaultRevocationCacheTTL, false)
if err != nil {
fmt.Fprintf(os.Stderr, "cannot build revoker: %v\n", err)
return 1
diff --git a/module/services/auth-gateway/code/gateway_cors_test.go b/module/services/auth-gateway/code/gateway_cors_test.go
index ed6c40f65..e4de86ebf 100644
--- a/module/services/auth-gateway/code/gateway_cors_test.go
+++ b/module/services/auth-gateway/code/gateway_cors_test.go
@@ -536,7 +536,7 @@ func TestCORS_PreflightRefusesWhatThePublicSolutionSurfaceCannotGrant(t *testing
func TestCORS_ThrottledResponseIsReadableAndRefusalsCostNoBudget(t *testing.T) {
gw, _, _, priv := newGatewayHarness(t)
twoRegisteredClients(t, gw)
- gw.rateLimiter = NewRateLimiter(1)
+ gw.rateLimiter = newInProcessRateLimiter(1)
token := signClientToken(t, priv, addinClient)
call := func(origin string) *httptest.ResponseRecorder {
diff --git a/module/services/auth-gateway/code/gateway_solution_admission_test.go b/module/services/auth-gateway/code/gateway_solution_admission_test.go
index e2c23cf5d..86b67c026 100644
--- a/module/services/auth-gateway/code/gateway_solution_admission_test.go
+++ b/module/services/auth-gateway/code/gateway_solution_admission_test.go
@@ -427,7 +427,7 @@ func TestGatewaySolutionProxy_RefusedRequestsAreMeteredBeforeTheAuthorityIsAsked
authority := &entitledTo{ids: nil}
gw.solutionEntitlements = authority
// effective budget = limit(1) + burst(max(1/5,1)=1) = 2 requests / org / min.
- gw.rateLimiter = NewRateLimiter(1)
+ gw.rateLimiter = newInProcessRateLimiter(1)
token := signValidToken(t, priv)
throttled := false
diff --git a/module/services/auth-gateway/code/gateway_solution_entitlements_test.go b/module/services/auth-gateway/code/gateway_solution_entitlements_test.go
index 88c228a4f..ddce94292 100644
--- a/module/services/auth-gateway/code/gateway_solution_entitlements_test.go
+++ b/module/services/auth-gateway/code/gateway_solution_entitlements_test.go
@@ -302,7 +302,7 @@ func TestGatewaySolutionEntitlements_IgnoresCallerSuppliedActingHeader(t *testin
func TestGatewaySolutionEntitlements_SpendsThePerOrgBudget(t *testing.T) {
gw, authority, priv := entitlementGateway(t)
// effective budget = limit(1) + burst(max(1/5,1)=1) = 2 requests / org / min.
- gw.rateLimiter = NewRateLimiter(1)
+ gw.rateLimiter = newInProcessRateLimiter(1)
// One token, so every request keys on the same stamped X-Org-Id.
token := signValidToken(t, priv)
diff --git a/module/services/auth-gateway/code/gateway_solutions_test.go b/module/services/auth-gateway/code/gateway_solutions_test.go
index a8e0e5143..dbcfb7d32 100644
--- a/module/services/auth-gateway/code/gateway_solutions_test.go
+++ b/module/services/auth-gateway/code/gateway_solutions_test.go
@@ -272,7 +272,7 @@ func TestGateway_Solution_MissingID_NotFound(t *testing.T) {
func TestGateway_Solution_RateLimited(t *testing.T) {
gw, _, _, priv := newGatewayHarness(t)
// effective budget = limit(1) + burst(max(1/5,1)=1) = 2 requests / org / min.
- gw.rateLimiter = NewRateLimiter(1)
+ gw.rateLimiter = newInProcessRateLimiter(1)
fake := registerSolutionUpstream(t, gw, "audit")
// Reuse ONE token so every request keys on the same injected x-org-id.
@@ -301,7 +301,7 @@ func TestGateway_Solution_RateLimited(t *testing.T) {
func TestGateway_Solution_PublicSurface_RateLimited(t *testing.T) {
gw, _, _, _ := newGatewayHarness(t)
// effective budget = limit(1) + burst(max(1/5,1)=1) = 2 requests / key / min.
- gw.rateLimiter = NewRateLimiter(1)
+ gw.rateLimiter = newInProcessRateLimiter(1)
fake := registerSolutionUpstream(t, gw, "audit")
got429 := false
diff --git a/module/services/auth-gateway/code/gateway_test.go b/module/services/auth-gateway/code/gateway_test.go
index f20772db1..dd3c440cc 100644
--- a/module/services/auth-gateway/code/gateway_test.go
+++ b/module/services/auth-gateway/code/gateway_test.go
@@ -255,7 +255,7 @@ func TestGateway_GitHubWebhooks_ForwardedUnauthenticatedWithSignature(t *testing
func TestGateway_GitHubWebhook_BudgetIsolatedFromAnonymousTraffic(t *testing.T) {
gw, apiFake, _, _ := newGatewayHarness(t)
// effective anonymous budget = limit(1) + burst(max(1/5,1)=1) = 2 per minute.
- gw.rateLimiter = NewRateLimiter(1)
+ gw.rateLimiter = newInProcessRateLimiter(1)
// Spend the anonymous per-IP budget on an unrelated public route.
spent := false
diff --git a/module/services/auth-gateway/code/main.go b/module/services/auth-gateway/code/main.go
index c003b87f1..11873fb13 100644
--- a/module/services/auth-gateway/code/main.go
+++ b/module/services/auth-gateway/code/main.go
@@ -133,7 +133,7 @@ func main() {
if err != nil {
panic(err)
}
- rev, err := newRevoker(redisURL, revocationTTL)
+ rev, err := newRevoker(redisURL, revocationTTL, codefly.IsLocal())
if err != nil {
panic(fmt.Sprintf("configure access-token revocation: %v", err))
}
@@ -219,15 +219,21 @@ func main() {
if err != nil {
panic(err)
}
- // Malformed TRUSTED_PROXY_CIDRS fails boot rather than silently trusting
- // a narrower/empty set โ parity with accounts' ParseTrustedProxyCIDRs.
- if _, err := parseProxyTrust(workspaceEnv("gateway", "TRUSTED_PROXY_CIDRS")); err != nil {
- panic(err)
+ // A malformed OR empty TRUSTED_PROXY_CIDRS fails boot on a deployed
+ // runtime rather than silently collapsing every bucket onto the frontend's
+ // pod address.
+ if err := requireProxyTrust(workspaceEnv("gateway", "TRUSTED_PROXY_CIDRS"), codefly.IsLocal()); err != nil {
+ panic(err.Error())
}
- rateLimiter = NewRateLimiter(1000,
+ // 1000 req/min per org/IP; stricter MFA budget is configured separately.
+ rateLimiter, err = NewRateLimiter(1000,
WithRedisURL(redisURL),
WithAuthenticationAttemptLimit(authenticationAttemptLimit),
- ) // 1000 req/min per org/IP; stricter MFA budget is configured separately.
+ WithInProcessBackendAllowed(codefly.IsLocal()),
+ )
+ if err != nil {
+ panic(fmt.Sprintf("configure rate limiting: %v", err))
+ }
// The durable solution registry lives in accounts and is reached over the
// same internal connection as the other brokered internal calls. The
// reconcile loop rebuilds this replica's routing cache from it โ once at
diff --git a/module/services/auth-gateway/code/ratelimit.go b/module/services/auth-gateway/code/ratelimit.go
index b28fd2872..97224b0cc 100644
--- a/module/services/auth-gateway/code/ratelimit.go
+++ b/module/services/auth-gateway/code/ratelimit.go
@@ -158,6 +158,7 @@ type RateLimiter struct {
type rateLimiterOptions struct {
redisURL string
authenticationAttemptLimit int
+ allowInProcessBackend bool
}
type RateLimiterOption func(*rateLimiterOptions)
@@ -170,11 +171,25 @@ func WithAuthenticationAttemptLimit(limit int) RateLimiterOption {
return func(options *rateLimiterOptions) { options.authenticationAttemptLimit = limit }
}
+// WithInProcessBackendAllowed permits the per-replica in-memory counters. Wire it
+// from codefly.IsLocal() only.
+//
+// A per-replica bucket is not the budget it reports: with N replicas the effective
+// limit is N times the configured one, and which replica a caller lands on decides
+// how much of it they get. For an authentication-factor budget that is the
+// difference between a bounded number of attempts and a bounded number per
+// replica. So a deployed gateway refuses to start without the shared store rather
+// than enforcing a budget nobody declared, while local development โ one replica,
+// no cache service โ keeps the counters.
+func WithInProcessBackendAllowed(allowed bool) RateLimiterOption {
+ return func(options *rateLimiterOptions) { options.allowInProcessBackend = allowed }
+}
+
// NewRateLimiter creates a rate limiter allowing requestsPerMinute requests
// per key per minute, with a 20% burst allowance. If REDIS_URL is set it
// connects to Redis; otherwise it uses in-memory counters with a background
// cleanup goroutine.
-func NewRateLimiter(requestsPerMinute int, optionFns ...RateLimiterOption) *RateLimiter {
+func NewRateLimiter(requestsPerMinute int, optionFns ...RateLimiterOption) (*RateLimiter, error) {
options := rateLimiterOptions{
redisURL: os.Getenv("REDIS_URL"),
authenticationAttemptLimit: authenticationAttemptLimitPerMinute,
@@ -197,19 +212,29 @@ func NewRateLimiter(requestsPerMinute int, optionFns ...RateLimiterOption) *Rate
if redisURL := options.redisURL; redisURL != "" {
rb, err := newRedisBackend(redisURL)
if err != nil {
+ if !options.allowInProcessBackend {
+ return nil, fmt.Errorf("rate limiter cannot reach its shared store: %w", err)
+ }
log.Printf("WARNING: cannot connect to configured Redis: %v โ falling back to in-memory rate limiter", err)
} else {
log.Printf("rate limiter using pooled Redis backend")
rl.backend = rb
- return rl
+ return rl, nil
}
+ } else if !options.allowInProcessBackend {
+ return nil, fmt.Errorf(
+ "rate limiter has no shared store: a deployed gateway runs more than one replica, " +
+ "so per-replica counters enforce the configured budget times the replica count " +
+ "and which replica a caller lands on decides their share โ resolve the cache " +
+ "service's redis/connection secret, or set REDIS_URL")
}
- // In-memory fallback.
+ // Per-replica counters: local development only (see
+ // WithInProcessBackendAllowed).
mem := &memoryBackend{}
rl.backend = mem
go mem.cleanup(rl.stop)
- return rl
+ return rl, nil
}
// Allow checks whether the given key is under the rate limit for the current
@@ -357,11 +382,40 @@ func newProxyTrust(raw string) proxyTrust {
return trust
}
+// requireProxyTrust is the boot check for a DEPLOYED runtime: the trusted-proxy
+// range must parse AND name at least one hop.
+//
+// An empty range is not a narrow configuration, it is no configuration: with no
+// trusted hop, clientIP always answers the peer, which behind the frontend is the
+// frontend's own pod. Every anonymous caller then shares one budget and every
+// authentication-factor attempt shares one, so a few bogus completions a minute
+// deny the factor to the whole cell and a flood of anonymous requests denies
+// login to all of it. That is a denial of service delivered BY the control meant
+// to prevent one, and it read as working: the limiter was enabled, the buckets
+// were enforced, and nothing said they had collapsed onto one key.
+//
+// Local development has no ingress hop and the peer IS the client, so an empty
+// range is correct there and only there.
+func requireProxyTrust(raw string, isLocal bool) error {
+ trust, err := parseProxyTrust(raw)
+ if err != nil {
+ return err
+ }
+ if isLocal || len(trust.prefixes) > 0 {
+ return nil
+ }
+ return fmt.Errorf(
+ "gateway: TRUSTED_PROXY_CIDRS is empty: a deployed gateway sits behind an ingress and " +
+ "a frontend hop, so with no trusted range every rate-limit bucket keys on the " +
+ "frontend's pod address and collapses into one โ set it to the ranges this cell's " +
+ "ingress and frontend reach the gateway from")
+}
+
// parseProxyTrust parses a comma-separated list of bare IPs and CIDRs into a
// trust set, returning an error on any malformed entry. Boot validates through
-// this (parity with accounts' ParseTrustedProxyCIDRs) so a typo'd
-// TRUSTED_PROXY_CIDRS fails startup rather than silently narrowing the trust
-// set and collapsing every forwarded caller into one spoofable IP bucket.
+// requireProxyTrust so a typo'd TRUSTED_PROXY_CIDRS fails startup rather than
+// silently narrowing the trust set and collapsing every forwarded caller into one
+// spoofable IP bucket.
func parseProxyTrust(raw string) (proxyTrust, error) {
var trust proxyTrust
for _, candidate := range strings.Split(raw, ",") {
@@ -446,3 +500,14 @@ func parsePeerAddress(remoteAddr string) (netip.Addr, bool) {
}
return addr.Unmap(), true
}
+
+// newInProcessRateLimiter is the per-replica limiter, for tests and for local
+// development's single-replica graph. It is a distinct constructor rather than an
+// option on the main one so a deployed wiring cannot reach it by passing a flag.
+func newInProcessRateLimiter(requestsPerMinute int) *RateLimiter {
+ limiter, err := NewRateLimiter(requestsPerMinute, WithInProcessBackendAllowed(true))
+ if err != nil {
+ panic(fmt.Sprintf("in-process rate limiter: %v", err))
+ }
+ return limiter
+}
diff --git a/module/services/auth-gateway/code/revocation.go b/module/services/auth-gateway/code/revocation.go
index 9634652ca..1bbf143a6 100644
--- a/module/services/auth-gateway/code/revocation.go
+++ b/module/services/auth-gateway/code/revocation.go
@@ -247,13 +247,32 @@ func (c *cachedRevoker) evictOldest(n int) {
}
}
-// newRevoker builds the revoker wired into the ext_authz check. No Redis configured
-// falls back to noopRevoker (dev parity, loudly logged); a Redis URL that
-// doesn't parse is a config error, not a reason to silently run without
-// revocation โ the caller must treat it as fatal.
-func newRevoker(redisURL string, ttl time.Duration) (revoker, error) {
+// newRevoker builds the revoker wired into the ext_authz check.
+//
+// A Redis URL that does not parse is a config error, not a reason to silently run
+// without revocation โ the caller must treat it as fatal.
+//
+// An ABSENT store is refused on a deployed runtime, by name. It used to fall back
+// to noopRevoker with a log line, which is the worst available answer: the
+// revoker answers "not revoked" to every question, so a signed-out session, a
+// killed device and an ended impersonation window all keep authenticating until
+// their tokens expire naturally โ and the gateway reports healthy throughout,
+// because nothing on the request path can tell a no-op revoker from an empty
+// revocation set. The fail-closed behaviour on a store ERROR (revocationFailsOpen)
+// was already right; this is the same rule for a store that was never there.
+//
+// Local development keeps the no-op: there is no cache service in a bare `go run`
+// graph, and the alternative is a gateway that cannot start.
+func newRevoker(redisURL string, ttl time.Duration, isLocal bool) (revoker, error) {
if strings.TrimSpace(redisURL) == "" {
- log.Printf("auth-gateway: no Redis configured โ access-token revocation disabled (dev parity with accounts NoopTokenRevoker)")
+ if !isLocal {
+ return nil, fmt.Errorf(
+ "access-token revocation has no store: a deployed gateway must reach the cache " +
+ "service's Redis, because a revoker with no store answers \"not revoked\" to " +
+ "every question โ resolve the cache service's redis/connection secret, or set " +
+ "REDIS_URL")
+ }
+ log.Printf("auth-gateway: no Redis configured โ access-token revocation disabled (local development only)")
return noopRevoker{}, nil
}
store, err := newRedisRevocationStore(redisURL)
From fcaaddd1100e0d682ea0edd6ade735d76e6adf3f Mon Sep 17 00:00:00 2001
From: Antoine Toussaint
<312536060+antoinetoussaint-byte@users.noreply.github.com>
Date: Mon, 5 Oct 2026 03:05:14 -0400
Subject: [PATCH 09/46] fix(auth-gateway): a cross-origin grant on a forwarded
response is the gateway's own (SA-F-CORS)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
accounts' generated CORS handler hands an empty allowlist to a library that reads
empty as allow-all, so it answers a wildcard origin with credentials true. The
gateway forwarded those headers verbatim on every path where it had granted
nothing, so the public surface granted a cross-origin read the gateway had granted
nobody, and an empty allowlist, which should grant nothing, granted everything.
Browsers refuse a credentialed read under a wildcard, which bounded the reach to
anonymous responses, but the fail-closed-by-name property was gone.
Every proxied response is now wrapped, granted or not: the wrapper strips
access-control-* before stamping, and with no grant it strips and stamps nothing.
It already stripped where a grant replaced the upstream's; the missing half was
the much more common path where there was no grant to replace. Vary is still only
added with a grant, so the public surface's cache entries are not fragmented by an
origin the response does not depend on.
What this does NOT fix, and who owns it: accounts' generated cors_gen.go and
connect_gen.go, where the empty allowlist becomes allow-all and the value is read
with a raw environment accessor. Both are generated by the go-grpc agent and are
not this repository's to hand-edit; the defect is in that template. accounts'
listener is reachable in-mesh, so the generated handler's answer still stands
there โ the gateway closes the public surface, which is what SP-GW-11's probe
reads, and the template owns the rest.
Tested through the gateway with an upstream that answers a wildcard with
credentials: for an unregistered origin and for no origin at all, no
access-control header leaves the gateway, and the request itself is unaffected. A
registered origin's grant replaces the upstream's rather than joining it, and
allow-credentials never appears whatever the upstream said. Mutation-checked.
module/services/auth-gateway/AGENTS.md records the rule and names the template as
the owner of the other half.
Refs #1018
Co-Authored-By: Claude Opus 5 (1M context)
---
module/services/auth-gateway/AGENTS.md | 11 +++
.../auth-gateway/code/gateway_cors.go | 43 +++++++---
.../auth-gateway/code/gateway_test.go | 8 ++
.../code/gateway_upstream_cors_test.go | 83 +++++++++++++++++++
4 files changed, 135 insertions(+), 10 deletions(-)
create mode 100644 module/services/auth-gateway/code/gateway_upstream_cors_test.go
diff --git a/module/services/auth-gateway/AGENTS.md b/module/services/auth-gateway/AGENTS.md
index a25c01989..a5e44aa08 100644
--- a/module/services/auth-gateway/AGENTS.md
+++ b/module/services/auth-gateway/AGENTS.md
@@ -111,6 +111,17 @@ answer the way `gateway_solution_registry.go` caches the solution registry, and
internal-token path still establishes it too; the registration is the stronger
claim, since it names the client rather than only the process that forwarded.
+**The grant on a forwarded response is this gateway's, and only this gateway's.**
+Every proxied response has its `access-control-*` headers stripped before the
+gateway stamps whatever it granted โ including when it granted nothing. An
+upstream may answer CORS itself, and accounts does: its generated handler hands an
+empty allowlist to a library that reads empty as allow-all, so it answers a
+wildcard origin with credentials true. Forwarded verbatim, that made the public
+surface grant a cross-origin read the gateway had granted nobody, and made an
+empty allowlist grant everything. The generated handler is the `go-grpc` agent's
+to fix; what the gateway owns is that nothing leaves it claiming a grant it did not
+make.
+
Two deliberate limits. **Credentials are never allowed**: a registered client
authenticates with its bearer, and echoing `access-control-allow-credentials`
would additionally let a cross-origin page ride the host's session cookie.
diff --git a/module/services/auth-gateway/code/gateway_cors.go b/module/services/auth-gateway/code/gateway_cors.go
index 3daebafb6..fe9092b85 100644
--- a/module/services/auth-gateway/code/gateway_cors.go
+++ b/module/services/auth-gateway/code/gateway_cors.go
@@ -136,9 +136,22 @@ func requestsAuthorizationHeader(requested string) bool {
// matched it, so a client cannot reach the solution passthrough, a federated
// module prefix, or a catalog route from an origin it did not register.
func (g *Gateway) authorizeCrossOrigin(w http.ResponseWriter, r *http.Request) (http.ResponseWriter, bool) {
+ // Every forwarded response is wrapped, granted or not. An upstream may answer
+ // CORS itself, and accounts does: its generated handler hands an empty
+ // allowlist to a library that reads empty as allow-all, so it answered a
+ // wildcard origin with credentials true. Those headers used to reach the
+ // browser unchanged on every path where this gateway granted nothing, which
+ // means the public surface granted a cross-origin read the gateway had
+ // refused to grant โ and an empty allowlist, which should grant nothing,
+ // granted everything.
+ //
+ // What this gateway granted is the only answer that leaves here. The wrapper
+ // with no origin strips and stamps nothing; with one it replaces.
+ stripping := &corsResponseWriter{ResponseWriter: w}
+
origin := r.Header.Get("Origin")
if origin == "" {
- return w, true
+ return stripping, true
}
if clientID := r.Header.Get(clientIDHeader); clientID != "" {
@@ -170,10 +183,10 @@ func (g *Gateway) authorizeCrossOrigin(w http.ResponseWriter, r *http.Request) (
// to ever read its token. The origin is the only thing such a request can
// be judged on, and it is the same thing the preflight was judged on.
if credentialPresented(r) {
- return w, true
+ return stripping, true
}
if !g.clients.originRegistered(r.Context(), origin) {
- return w, true
+ return stripping, true
}
return &corsResponseWriter{ResponseWriter: w, origin: origin}, true
}
@@ -187,13 +200,17 @@ func credentialPresented(r *http.Request) bool {
return r.Header.Get("Authorization") != "" || r.Header.Get("Cookie") != ""
}
-// corsResponseWriter stamps the grant on the way out. It has to run at
-// WriteHeader rather than up front because the reverse proxy copies the
-// upstream's own response headers into this map first, and an upstream that
-// answers CORS itself โ a solution serving its origin permissively โ would
-// otherwise leave two access-control-allow-origin values, which every browser
-// rejects. The gateway is the authority for what it granted, so it replaces
-// whatever the upstream said.
+// corsResponseWriter makes this gateway the only author of the cross-origin grant
+// on a forwarded response. It has to run at WriteHeader rather than up front
+// because the reverse proxy copies the upstream's own response headers into this
+// map first.
+//
+// With an origin it replaces whatever the upstream said โ two
+// access-control-allow-origin values is a response every browser rejects. With NO
+// origin it still strips, which is the load-bearing half: an upstream that answers
+// CORS permissively (a solution serving its own origin, or accounts' generated
+// handler reading an empty allowlist as allow-all) would otherwise have its grant
+// forwarded verbatim as though this gateway had made it.
type corsResponseWriter struct {
http.ResponseWriter
origin string
@@ -221,6 +238,12 @@ func (w *corsResponseWriter) stamp() {
h.Del(key)
}
}
+ if w.origin == "" {
+ // Nothing was granted, so nothing is claimed. Vary is not added: the
+ // response does not differ by origin, and saying it does would needlessly
+ // fragment every cache entry on the public surface.
+ return
+ }
h.Set("Access-Control-Allow-Origin", w.origin)
h.Set("Access-Control-Expose-Headers", corsExposeHeaders)
addVary(h, "Origin")
diff --git a/module/services/auth-gateway/code/gateway_test.go b/module/services/auth-gateway/code/gateway_test.go
index dd3c440cc..80cd57136 100644
--- a/module/services/auth-gateway/code/gateway_test.go
+++ b/module/services/auth-gateway/code/gateway_test.go
@@ -25,6 +25,9 @@ type fakeUpstream struct {
lastBody string
statusCode int
body string
+ // responseHeaders let a test give the upstream its own answer about
+ // cross-origin access, which is what accounts' generated CORS handler does.
+ responseHeaders http.Header
}
func (f *fakeUpstream) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -34,6 +37,11 @@ func (f *fakeUpstream) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if body, err := io.ReadAll(r.Body); err == nil {
f.lastBody = string(body)
}
+ for key, values := range f.responseHeaders {
+ for _, value := range values {
+ w.Header().Add(key, value)
+ }
+ }
code := f.statusCode
if code == 0 {
code = 200
diff --git a/module/services/auth-gateway/code/gateway_upstream_cors_test.go b/module/services/auth-gateway/code/gateway_upstream_cors_test.go
new file mode 100644
index 000000000..484bd35ca
--- /dev/null
+++ b/module/services/auth-gateway/code/gateway_upstream_cors_test.go
@@ -0,0 +1,83 @@
+package main
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+// accounts' generated CORS handler hands an empty allowlist to a library that
+// reads empty as allow-all, so it answers a wildcard origin with credentials
+// true. Those headers used to be forwarded verbatim, which made the public
+// surface grant a cross-origin read this gateway had granted nobody โ and made an
+// empty allowlist, which should grant nothing, grant everything.
+//
+// The generated handler is the go-grpc agent's to fix. What this gateway owns is
+// that the grant on its own responses is the one it made.
+func TestUpstreamCrossOriginGrantNeverReachesTheBrowser(t *testing.T) {
+ gw, api, _, _ := newGatewayHarness(t)
+ api.responseHeaders = http.Header{
+ "Access-Control-Allow-Origin": []string{"*"},
+ "Access-Control-Allow-Credentials": []string{"true"},
+ "Access-Control-Expose-Headers": []string{"x-everything"},
+ }
+
+ for _, tc := range []struct {
+ name string
+ origin string
+ }{
+ {"an unregistered origin", "https://evil.example"},
+ {"no origin at all", ""},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ request := httptest.NewRequest(http.MethodGet, "/v1/status", nil)
+ if tc.origin != "" {
+ request.Header.Set("Origin", tc.origin)
+ }
+ recorder := httptest.NewRecorder()
+ gw.ServeHTTP(recorder, request)
+
+ require.Equal(t, http.StatusOK, recorder.Code, "the request itself is unaffected")
+ for name := range recorder.Header() {
+ require.NotContains(t, lowered(name), "access-control-",
+ "the gateway granted nothing, so no access-control header may leave it: %s=%q",
+ name, recorder.Header().Get(name))
+ }
+ })
+ }
+}
+
+// And a registered origin's grant is the gateway's own, replacing rather than
+// joining the upstream's โ two allow-origin values is a response every browser
+// rejects.
+func TestARegisteredOriginsGrantReplacesTheUpstreams(t *testing.T) {
+ gw, api, _, _ := newGatewayHarness(t)
+ twoRegisteredClients(t, gw)
+ api.responseHeaders = http.Header{
+ "Access-Control-Allow-Origin": []string{"*"},
+ "Access-Control-Allow-Credentials": []string{"true"},
+ }
+
+ request := httptest.NewRequest(http.MethodGet, "/v1/status", nil)
+ request.Header.Set("Origin", addinOrigin)
+ recorder := httptest.NewRecorder()
+ gw.ServeHTTP(recorder, request)
+
+ require.Equal(t, []string{addinOrigin}, recorder.Result().Header.Values("Access-Control-Allow-Origin"))
+ require.Empty(t, recorder.Header().Get("Access-Control-Allow-Credentials"),
+ "credentials are never granted cross-origin, whatever the upstream said")
+}
+
+func lowered(value string) string {
+ out := make([]byte, 0, len(value))
+ for i := 0; i < len(value); i++ {
+ c := value[i]
+ if c >= 'A' && c <= 'Z' {
+ c += 'a' - 'A'
+ }
+ out = append(out, c)
+ }
+ return string(out)
+}
From a625989c10ce99f25038c21436f717954aacfa05 Mon Sep 17 00:00:00 2001
From: Antoine Toussaint
<312536060+antoinetoussaint-byte@users.noreply.github.com>
Date: Mon, 5 Oct 2026 03:08:28 -0400
Subject: [PATCH 10/46] fix(auth-gateway): a module or solution upstream
receives the person's identity, not their session (SA-F-BEARER)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A host access token is the person's whole session: one host-wide audience, their
full authority, valid at every other upstream and at the host's own API. The
gateway forwarded it, and any cookie, unchanged to every composed module and
solution. So a compromised module or solution pod, a single logged request header,
or an upstream that redeems the token to mint contexts for further audiences each
yielded replayable full-authority sessions for every viewer who had used that
upstream.
proxyTo now removes Authorization, Cookie and Proxy-Authorization for every
runtime-registered route, module and solution alike, including the public
solution asset surface. A catalog route is untouched: accounts is the host's own
API, where the session is the credential, and a test asserts that so the strip
cannot be satisfied by forwarding nothing to anybody.
An upstream receives what it should have been receiving all along: the identity
ext_authz stamps โ subject, tenant, session, credential kind, scope ceiling โ
which names the person without carrying their authority.
THIS BREAKS AN UPSTREAM THAT READS THE FORWARDED BEARER, by design. The register
names two consumer repositories that do, and the audit filed their half
separately: one reads the bearer in its chat handler and re-mints Work Contexts
with it, and the solution runtime's authenticated fetch relies on it. Their
replacement is a host-minted capability bound to the upstream's own audience โ
the Work Context surface โ not a bearer borrowed from a person. Sequencing is an
operator decision and is listed in the pull request rather than decided here.
The companion half of the same invariant, a per-resource audience in place of the
single host-wide one, is PR #1007's (issue #1003): it binds a solution tool
endpoint's token to that endpoint's resource. This change is what stops the
generic session reaching the upstream in the first place.
TestModuleUpstreamReceivesNoSessionBearer and
TestPublicSolutionPathForwardsNoCredential are the catalogue's two tests, and the
second is the verifier's reproducer. Mutation-checked. The pre-existing assertion
that a solution receives the bearer is inverted rather than deleted, so the change
in contract is visible in the diff.
Docs: module/services/auth-gateway/AGENTS.md gains the rule and names the consumer
obligation; module/SOLUTION_REGISTRATION.md's upstream-constraints section no
longer describes a forwarded user bearer.
Refs #1018
Co-Authored-By: Claude Opus 5 (1M context)
---
module/SOLUTION_REGISTRATION.md | 205 ++++++++++++++++++
module/services/auth-gateway/AGENTS.md | 24 ++
module/services/auth-gateway/code/gateway.go | 32 +++
.../code/gateway_module_routes.go | 9 +-
.../code/gateway_module_routes_test.go | 18 +-
.../auth-gateway/code/gateway_modules.go | 13 +-
.../auth-gateway/code/gateway_solutions.go | 6 +-
.../code/gateway_solutions_test.go | 5 +-
.../code/upstream_session_credential_test.go | 73 +++++++
9 files changed, 371 insertions(+), 14 deletions(-)
create mode 100644 module/services/auth-gateway/code/upstream_session_credential_test.go
diff --git a/module/SOLUTION_REGISTRATION.md b/module/SOLUTION_REGISTRATION.md
index 7306668cb..afc49356b 100644
--- a/module/SOLUTION_REGISTRATION.md
+++ b/module/SOLUTION_REGISTRATION.md
@@ -53,12 +53,217 @@ case remain supported. A changed DNS answer cannot bypass the transport check.
## 3. Runtime compatibility
+<<<<<<< HEAD
The Module-Federation host checks compatibility before activating a remote.
The manifest parser validates the snapshot's shape. Runtime requirements include
`schemaVersion`, `frontend.hostContract`, `frontend.reactRange`,
`frontend.shared` and `frontend.exposedModule`; host versions live in
`services/frontend/code/src/solutions/host-runtime.ts`. A failed check leaves the
remote unavailable and reports the cause in the server log.
+||||||| parent of a3090094 (fix(auth-gateway): a module or solution upstream receives the person's identity, not their session (SA-F-BEARER))
+ **What that does not yet give you, stated plainly because a revocation
+ depends on it.** accounts reads the declaration through the Codefly SDK,
+ which resolves it from the process environment and the runtime's injected
+ carrier. Both are live reads โ an edit is observed the moment it reaches
+ them โ but **no runtime component updates either one under a running
+ process** (`codefly-dev/cli#740`). So in a deployment that delivers this key
+ as a container environment variable, an edit does not reach accounts at all,
+ and withdrawing a publisher still requires restarting the service. Until that
+ issue closes, treat a revocation as taking effect on restart, not on edit,
+ and do not rely on removing a digest to lock out a publisher whose secret you
+ believe is compromised โ delete its registration and restart accounts.
+3. The solution presents that token in `X-Codefly-Solution-Registration` to both
+ `POST /solutions/_register` (gateway, `{id, upstream}`) and
+ `POST /api/solutions/register` (frontend, the manifest). `DELETE` on either
+ withdraws it.
+
+The exchange keeps three outcomes apart, because a registrant acts differently
+on each: `401` means accounts refused the secret โ the only answer that means
+"check `SOLUTION_REGISTRATION_SECRETS` and the provisioned secret"; `503` with
+`Retry-After` means accounts could not be reached or did not answer in time (a
+rollout, a restart) and the next attempt may succeed; `502` means accounts
+answered with an error that is neither. The frontend likewise answers `503`, not
+`401`, when it cannot reach the key set it verifies the token against. A
+registrant must not read any 5xx โ including a `502` from an ingress or mesh in
+front of a restarting gateway โ as a provisioning fault.
+
+`SOLUTION_REGISTRATION_SECRETS` is declared **separately** from
+`MODULE_REGISTRATION_SECRETS`, and the two credentials carry different
+audiences. A module credential federates a REST prefix; a solution credential
+additionally publishes host-origin code. Holding one must never confer the
+other, so neither the configuration nor the token is shared.
+
+### What each half enforces
+
+| Check | Gateway | Frontend |
+| --- | --- | --- |
+| Ed25519 signature against the published JWKS, alg-locked to EdDSA | yes | yes |
+| Issuer, `solution-registration` audience, expiry (with skew leeway) | yes | yes |
+| `solution` claim equals the id in the request | yes (403) | yes (403) |
+| First registration binds the id to `sub`; a different `sub` is refused | yes (409) | yes (409) |
+| Same publisher may move its own endpoint | yes | yes |
+| Delete is owner-bound, and a non-owner's delete is indistinguishable from an unknown id | yes | yes |
+| `jti` burned on use, so a captured credential cannot be replayed | yes | yes |
+| Request body bounded before parsing | yes (256 KiB; the separate token exchange bounds at 4 KiB) | no bound of its own โ the manifest is bounded where the gateway accepts it |
+| Id is one catalog-identity segment, so reserved `_โฆ` sub-paths cannot be shadowed | yes | n/a (id comes from the manifest and must equal the claim) |
+| Upstream host must be composition-local; resolved address re-checked at dial time | yes | n/a |
+
+The gateway deliberately allows a solution to **move** its upstream, which
+module federation does not. That is safe only because the publisher is proven on
+every call: an endpoint legitimately changes on a redeploy or a new dev port,
+while a credential holder for one solution can neither claim another's id nor
+redirect it.
+
+### Upstream constraints and the development exception
+
+A solution upstream receives forwarded user bearers, so it is held to the same
+rule as a federated module upstream: loopback and private ranges (the dev and
+in-cluster cases), mesh short names and cluster suffixes โ and nothing globally
+routable, no link-local, no cloud-metadata address. The register-time host string
+is only half of it: the **resolved address is re-checked at dial time**, so a
+mesh-looking name whose DNS answer later points off-mesh never receives a
+forwarded bearer. Loopback is an explicit development exception and is tested as
+such.
+
+## 3. Runtime compatibility is enforced, not stored
+
+A declared requirement the host does not check is not a compatibility contract.
+Every requirement is checked **before activation**, so an incompatible remote is
+never handed to a browser. An undeclared major defaults to `1` โ the major in
+force when the field was introduced, which is what silence actually asserts โ and
+never to the host's current major, which would make every silent manifest
+compatible by definition at exactly the upgrade this check exists for:
+
+| Manifest field | Checked against | Default when absent |
+| --- | --- | --- |
+| `schemaVersion` | `SOLUTION_MANIFEST_SCHEMA_MAJOR` | `1` |
+| `frontend.hostContract` | `SOLUTION_HOST_CONTRACT_MAJOR` (the `SolutionPageProps` the host injects and the `./Page` default it expects) | `1` |
+| `frontend.reactRange` | the host's real React version | unconstrained |
+| `frontend.shared` (per package) | the versions the host publishes into the sealed scope | unconstrained |
+| `frontend.exposedModule` | must be a `./Name` key | โ |
+
+Ranges use a deliberately small semver subset (`||` alternatives of
+space-separated `^ ~ >= > <= < =`, a bare version, or `*`). A range outside the
+grammar is **refused**, never approximated: a requirement the host cannot
+evaluate is not one it can honour. The host values live in
+`services/frontend/code/src/solutions/host-runtime.ts`, which the register route
+and the Module-Federation host both read, so the numbers cannot diverge.
+
+A refused registration is refused **whole**. If the id already had a valid
+registration, that registration keeps serving โ the last known valid one is never
+replaced by a rejected update. The reasons are returned to the registrant (HTTP
+409, `{"error":"incompatible_runtime","reasons":[โฆ]}`) and logged for the
+operator. If the id has no valid registration to fall back on, its page renders a
+plain "temporarily unavailable" panel instead of a 404, and the technical cause
+stays in the server log.
+=======
+ **What that does not yet give you, stated plainly because a revocation
+ depends on it.** accounts reads the declaration through the Codefly SDK,
+ which resolves it from the process environment and the runtime's injected
+ carrier. Both are live reads โ an edit is observed the moment it reaches
+ them โ but **no runtime component updates either one under a running
+ process** (`codefly-dev/cli#740`). So in a deployment that delivers this key
+ as a container environment variable, an edit does not reach accounts at all,
+ and withdrawing a publisher still requires restarting the service. Until that
+ issue closes, treat a revocation as taking effect on restart, not on edit,
+ and do not rely on removing a digest to lock out a publisher whose secret you
+ believe is compromised โ delete its registration and restart accounts.
+3. The solution presents that token in `X-Codefly-Solution-Registration` to both
+ `POST /solutions/_register` (gateway, `{id, upstream}`) and
+ `POST /api/solutions/register` (frontend, the manifest). `DELETE` on either
+ withdraws it.
+
+The exchange keeps three outcomes apart, because a registrant acts differently
+on each: `401` means accounts refused the secret โ the only answer that means
+"check `SOLUTION_REGISTRATION_SECRETS` and the provisioned secret"; `503` with
+`Retry-After` means accounts could not be reached or did not answer in time (a
+rollout, a restart) and the next attempt may succeed; `502` means accounts
+answered with an error that is neither. The frontend likewise answers `503`, not
+`401`, when it cannot reach the key set it verifies the token against. A
+registrant must not read any 5xx โ including a `502` from an ingress or mesh in
+front of a restarting gateway โ as a provisioning fault.
+
+`SOLUTION_REGISTRATION_SECRETS` is declared **separately** from
+`MODULE_REGISTRATION_SECRETS`, and the two credentials carry different
+audiences. A module credential federates a REST prefix; a solution credential
+additionally publishes host-origin code. Holding one must never confer the
+other, so neither the configuration nor the token is shared.
+
+### What each half enforces
+
+| Check | Gateway | Frontend |
+| --- | --- | --- |
+| Ed25519 signature against the published JWKS, alg-locked to EdDSA | yes | yes |
+| Issuer, `solution-registration` audience, expiry (with skew leeway) | yes | yes |
+| `solution` claim equals the id in the request | yes (403) | yes (403) |
+| First registration binds the id to `sub`; a different `sub` is refused | yes (409) | yes (409) |
+| Same publisher may move its own endpoint | yes | yes |
+| Delete is owner-bound, and a non-owner's delete is indistinguishable from an unknown id | yes | yes |
+| `jti` burned on use, so a captured credential cannot be replayed | yes | yes |
+| Request body bounded before parsing | yes (256 KiB; the separate token exchange bounds at 4 KiB) | no bound of its own โ the manifest is bounded where the gateway accepts it |
+| Id is one catalog-identity segment, so reserved `_โฆ` sub-paths cannot be shadowed | yes | n/a (id comes from the manifest and must equal the claim) |
+| Upstream host must be composition-local; resolved address re-checked at dial time | yes | n/a |
+
+The gateway deliberately allows a solution to **move** its upstream, which
+module federation does not. That is safe only because the publisher is proven on
+every call: an endpoint legitimately changes on a redeploy or a new dev port,
+while a credential holder for one solution can neither claim another's id nor
+redirect it.
+
+### Upstream constraints and the development exception
+
+A solution upstream is a host the registrant chose that receives requests made on
+a person's behalf, so it is held to the same rule as a federated module upstream:
+loopback and private ranges (the dev and in-cluster cases), mesh short names and
+cluster suffixes โ and nothing globally routable, no link-local, no cloud-metadata
+address. The register-time host string is only half of it: the **resolved address
+is re-checked at dial time**, so a mesh-looking name whose DNS answer later points
+off-mesh never receives one. Loopback is an explicit development exception and is
+tested as such.
+
+A solution upstream does **not** receive the viewer's session credential. The
+gateway removes `Authorization` and `Cookie` from every request it forwards to a
+runtime-registered upstream, module or solution, and the upstream receives the
+identity headers `ext_authz` stamped instead โ the subject, the tenant, the
+session, the credential kind and the scope ceiling, which name the person without
+carrying their authority. A host access token is the person's whole session at a
+single host-wide audience, so forwarding it made every upstream that held one, or
+logged one, a source of replayable full-authority sessions. An upstream that needs
+to act on a person's behalf needs a host-minted capability bound to its own
+audience, which is the Work Context surface.
+
+## 3. Runtime compatibility is enforced, not stored
+
+A declared requirement the host does not check is not a compatibility contract.
+Every requirement is checked **before activation**, so an incompatible remote is
+never handed to a browser. An undeclared major defaults to `1` โ the major in
+force when the field was introduced, which is what silence actually asserts โ and
+never to the host's current major, which would make every silent manifest
+compatible by definition at exactly the upgrade this check exists for:
+
+| Manifest field | Checked against | Default when absent |
+| --- | --- | --- |
+| `schemaVersion` | `SOLUTION_MANIFEST_SCHEMA_MAJOR` | `1` |
+| `frontend.hostContract` | `SOLUTION_HOST_CONTRACT_MAJOR` (the `SolutionPageProps` the host injects and the `./Page` default it expects) | `1` |
+| `frontend.reactRange` | the host's real React version | unconstrained |
+| `frontend.shared` (per package) | the versions the host publishes into the sealed scope | unconstrained |
+| `frontend.exposedModule` | must be a `./Name` key | โ |
+
+Ranges use a deliberately small semver subset (`||` alternatives of
+space-separated `^ ~ >= > <= < =`, a bare version, or `*`). A range outside the
+grammar is **refused**, never approximated: a requirement the host cannot
+evaluate is not one it can honour. The host values live in
+`services/frontend/code/src/solutions/host-runtime.ts`, which the register route
+and the Module-Federation host both read, so the numbers cannot diverge.
+
+A refused registration is refused **whole**. If the id already had a valid
+registration, that registration keeps serving โ the last known valid one is never
+replaced by a rejected update. The reasons are returned to the registrant (HTTP
+409, `{"error":"incompatible_runtime","reasons":[โฆ]}`) and logged for the
+operator. If the id has no valid registration to fall back on, its page renders a
+plain "temporarily unavailable" panel instead of a 404, and the technical cause
+stays in the server log.
+>>>>>>> a3090094 (fix(auth-gateway): a module or solution upstream receives the person's identity, not their session (SA-F-BEARER))
## 4. Registration, installation, and entitlement are three different things
diff --git a/module/services/auth-gateway/AGENTS.md b/module/services/auth-gateway/AGENTS.md
index a5e44aa08..fb3f99cd8 100644
--- a/module/services/auth-gateway/AGENTS.md
+++ b/module/services/auth-gateway/AGENTS.md
@@ -78,6 +78,30 @@ needing less authority.
Module identity exchanges are described below; accounts owns their authority.
+## A runtime-registered upstream never receives the person's session
+
+The gateway removes `Authorization`, `Cookie` and `Proxy-Authorization` from every
+request it forwards to a runtime-registered upstream โ a federated module prefix or
+a solution. A catalog route is untouched: accounts is the host's own API, where the
+session IS the credential.
+
+A host access token is the person's whole session: one host-wide audience, the
+person's full authority, valid at every other upstream and at the host's own API.
+Forwarding it meant a compromised module or solution pod โ or a single logged
+request header, or an upstream that redeems it to mint contexts for further
+audiences โ yielded replayable full-authority sessions for every viewer who had
+used it. The cookie went the same way, and the gateway has already resolved
+identity from it by the time it forwards, so an upstream reading it learns nothing
+it is not told.
+
+An upstream receives the identity `ext_authz` stamped: the subject, the tenant, the
+session, the credential kind and the scope ceiling. That names the person without
+carrying their authority. **An upstream that needs to act on the person's behalf
+needs a host-minted capability bound to its own audience** โ the Work Context
+surface โ not a bearer it borrowed. A consumer written against the old behaviour
+has to change: the two consumer repositories that read the forwarded bearer own
+their half of this.
+
## A registered client calls without a proxy
The host's own frontend reaches this gateway through a server-side proxy, so it
diff --git a/module/services/auth-gateway/code/gateway.go b/module/services/auth-gateway/code/gateway.go
index e5e79fa1b..750d931c1 100644
--- a/module/services/auth-gateway/code/gateway.go
+++ b/module/services/auth-gateway/code/gateway.go
@@ -471,6 +471,9 @@ func (g *Gateway) proxyTo(w http.ResponseWriter, r *http.Request, upstream *url.
r.Header.Set("X-Codefly-Public-Origin", publicOrigin)
}
}
+ if isRuntimeRegisteredRoute(entry) {
+ removePersonsSessionCredential(r.Header)
+ }
if entry != nil && entry.UpstreamPath != "" {
r.URL.Path = entry.UpstreamPath
r.URL.RawPath = ""
@@ -489,6 +492,35 @@ func (g *Gateway) proxyTo(w http.ResponseWriter, r *http.Request, upstream *url.
proxy.ServeHTTP(w, r)
}
+// removePersonsSessionCredential strips the person's own session from a request
+// before it reaches a runtime-registered upstream โ a composed module's federated
+// prefix, or a solution.
+//
+// The host access token IS the person's whole session: a host-wide audience, the
+// person's full authority, and valid at every other upstream and at the host's own
+// API. Forwarding it verbatim meant a compromised module or solution pod โ or one
+// logged request header, or one upstream that redeems it to mint contexts for
+// other audiences โ yielded replayable full-authority sessions for every viewer
+// who had used it. The Cookie goes with it for the same reason and one more: the
+// gateway has already resolved identity from it by this point, so an upstream
+// reading it learns nothing it is not told, and nothing legitimately needs the
+// host's session cookie.
+//
+// What an upstream receives instead is what it should have been receiving: the
+// identity headers ext_authz stamped (the subject, the tenant, the session, the
+// credential kind, the scope ceiling), which name the person without carrying
+// their authority. An upstream that needs to ACT on the person's behalf needs a
+// host-minted capability bound to its own audience, which is the Work Context
+// surface, not a bearer it borrowed.
+//
+// Catalog routes are untouched: accounts is the host's own API, where the session
+// is the credential.
+func removePersonsSessionCredential(h http.Header) {
+ h.Del("Authorization")
+ h.Del("Cookie")
+ h.Del("Proxy-Authorization")
+}
+
// removeCallerConnectionOptions applies the caller's Connection header to the
// caller's own headers, so it can never remove one this gateway stamped.
//
diff --git a/module/services/auth-gateway/code/gateway_module_routes.go b/module/services/auth-gateway/code/gateway_module_routes.go
index d26056d92..2c9579db6 100644
--- a/module/services/auth-gateway/code/gateway_module_routes.go
+++ b/module/services/auth-gateway/code/gateway_module_routes.go
@@ -170,9 +170,12 @@ func (g *Gateway) handleDeclaredModule(w http.ResponseWriter, r *http.Request) b
injectHeaders(r, checkResp.GetOkResponse().GetHeaders())
// Forward the full /v1//... path unchanged: the module owns and serves
- // its own /v1/ surface. The caller's bearer is preserved so the module
- // can call back through the gateway on the user's behalf, exactly as solution
- // passthrough does.
+ // its own /v1/ surface. The caller's session credential does NOT travel
+ // with it โ proxyTo withholds it for every declared upstream
+ // (removePersonsSessionCredential) โ exactly as solution passthrough does. A
+ // module that acts on the person's behalf does so with the Work Context
+ // verified above, which is bound to this route's audience rather than good
+ // everywhere the person is.
//
// rateLimitThenProxy, not proxyTo: a module data endpoint must consume the
// same per-org/per-IP budget an equivalent catalog route does, or /v1//*
diff --git a/module/services/auth-gateway/code/gateway_module_routes_test.go b/module/services/auth-gateway/code/gateway_module_routes_test.go
index 7fe6a2292..464a4753b 100644
--- a/module/services/auth-gateway/code/gateway_module_routes_test.go
+++ b/module/services/auth-gateway/code/gateway_module_routes_test.go
@@ -80,8 +80,19 @@ func TestGateway_UndeclaredModulePrefixIsNotServed(t *testing.T) {
// A declared module IS routed: the full /v1//... path reaches the declared
// upstream, with the ordinary authenticated pipeline in front of it โ identity
-// projected from the validated token, caller-supplied identity stripped, the
-// caller's bearer preserved, and the gateway's own accounts credential withheld.
+// projected from the validated token, caller-supplied identity stripped, and both
+// the person's session credential and the gateway's own accounts credential
+// withheld.
+//
+// This asserted the person's bearer PRESERVED until SA-F-BEARER. A host access
+// token names one host-wide audience and carries the person's whole authority, so
+// a declared upstream holding one holds a credential good at every other upstream
+// and at the host's own API. The capability path a module needs is untouched by
+// the withholding: a Work Context travels in its own header
+// (workcontext.WorkContextHeaderName), is verified here against this route's
+// audience (moduleCapabilitiesAudience), and is forwarded โ which is the
+// difference between a capability bound to this upstream and a session borrowed
+// from the person.
func TestGateway_DeclaredModuleIsRoutedThroughTheAuthenticatedPipeline(t *testing.T) {
gw, _, _, priv := newGatewayHarness(t)
fake := declareModule(t, gw, "example-module")
@@ -109,7 +120,8 @@ func TestGateway_DeclaredModuleIsRoutedThroughTheAuthenticatedPipeline(t *testin
require.Equal(t, "/v1/example-module/things", fake.lastPath)
require.NotEqual(t, "attacker", fake.lastHeaders.Get("x-user-id"))
require.Equal(t, "admin", fake.lastHeaders.Get("x-org-role"))
- require.Equal(t, "Bearer "+token, fake.lastHeaders.Get("authorization"))
+ require.Empty(t, fake.lastHeaders.Get("authorization"),
+ "a declared module upstream must not receive the person's host-wide session")
require.Empty(t, fake.lastHeaders.Get("x-codefly-gateway-token"),
"the gateway token is an accounts-only capability and must not reach a module upstream")
}
diff --git a/module/services/auth-gateway/code/gateway_modules.go b/module/services/auth-gateway/code/gateway_modules.go
index d43ed150f..f43721eb5 100644
--- a/module/services/auth-gateway/code/gateway_modules.go
+++ b/module/services/auth-gateway/code/gateway_modules.go
@@ -25,10 +25,15 @@ import (
// named, rather than the static catalog.
//
// Both kinds qualify, for one reason: the address is read out of a delivered
-// document and user bearers are forwarded to it, so the host must still refuse a
-// name that resolves somewhere it will not send credentials. A module route added
-// without this line would have been the one declared upstream dialled on the
-// plain transport, and nothing about the route's shape would have said so.
+// document and the person's identity is stamped on what is sent there, so the
+// host must still refuse a name that resolves somewhere it will not send a
+// request made on someone's behalf. A module route added without this line would
+// have been the one declared upstream dialled on the plain transport, and nothing
+// about the route's shape would have said so.
+//
+// The predicate has a second reader now: proxyTo strips the person's session
+// credential for exactly these routes (removePersonsSessionCredential), so what
+// reaches a declared upstream is the stamped identity and not a bearer.
func isRuntimeRegisteredRoute(entry *RouteEntry) bool {
return entry != nil &&
(strings.HasPrefix(entry.Service, solutionServicePrefix) ||
diff --git a/module/services/auth-gateway/code/gateway_solutions.go b/module/services/auth-gateway/code/gateway_solutions.go
index 4ff6d36d9..177d3b26c 100644
--- a/module/services/auth-gateway/code/gateway_solutions.go
+++ b/module/services/auth-gateway/code/gateway_solutions.go
@@ -265,8 +265,10 @@ func (g *Gateway) handleSolutionRequest(w http.ResponseWriter, r *http.Request)
httpError(w, http.StatusForbidden, "no organization in this session")
return true
}
- // Proxy to the solution. The caller's bearer is preserved so the solution
- // can call accounts through the gateway on the user's behalf.
+ // Proxy to the solution. The caller's session credential does NOT travel with
+ // it: proxyTo removes the Authorization and Cookie headers for every
+ // runtime-registered upstream, and the solution receives the stamped identity
+ // headers instead (removePersonsSessionCredential).
//
// Route through rateLimitThenProxy, not proxyTo directly: an authenticated
// solution data endpoint must consume the same per-org budget as an equivalent
diff --git a/module/services/auth-gateway/code/gateway_solutions_test.go b/module/services/auth-gateway/code/gateway_solutions_test.go
index dbcfb7d32..a94476009 100644
--- a/module/services/auth-gateway/code/gateway_solutions_test.go
+++ b/module/services/auth-gateway/code/gateway_solutions_test.go
@@ -211,8 +211,9 @@ func TestGateway_Solution_ValidJWT_ForwardsWithIdentity(t *testing.T) {
require.NotEmpty(t, fake.lastHeaders.Get("x-user-id"))
require.NotEmpty(t, fake.lastHeaders.Get("x-org-id"))
require.Equal(t, "admin", fake.lastHeaders.Get("x-org-role"))
- // The caller's bearer is preserved for the solution's own downstream calls.
- require.Equal(t, "Bearer "+token, fake.lastHeaders.Get("authorization"))
+ // The caller's session credential does NOT travel to the solution: the
+ // identity headers above name the person without carrying their authority.
+ require.Empty(t, fake.lastHeaders.Get("authorization"))
// The gateway token is an accounts-only capability and must not leak to a
// solution upstream.
require.Empty(t, fake.lastHeaders.Get("x-codefly-gateway-token"))
diff --git a/module/services/auth-gateway/code/upstream_session_credential_test.go b/module/services/auth-gateway/code/upstream_session_credential_test.go
new file mode 100644
index 000000000..754f58c11
--- /dev/null
+++ b/module/services/auth-gateway/code/upstream_session_credential_test.go
@@ -0,0 +1,73 @@
+package main
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+// The host access token IS the person's whole session: one host-wide audience,
+// the person's full authority, valid at every other upstream and at the host's own
+// API. Forwarding it verbatim meant a compromised module or solution pod โ or one
+// logged request header โ yielded replayable full-authority sessions for every
+// viewer who had used it.
+//
+// What an upstream receives instead is the identity ext_authz stamped, which names
+// the person without carrying their authority.
+func TestModuleUpstreamReceivesNoSessionBearer(t *testing.T) {
+ gw, _, _, priv := newGatewayHarness(t)
+ fake := declareModule(t, gw, "documents")
+
+ token := signValidToken(t, priv)
+ request := httptest.NewRequest(http.MethodGet, "/v1/documents/entries", nil)
+ request.Header.Set("Authorization", "Bearer "+token)
+ request.Header.Set("Cookie", "codefly_rt=a-refresh-token; codefly_session=1")
+ recorder := httptest.NewRecorder()
+ gw.ServeHTTP(recorder, request)
+
+ require.Equal(t, http.StatusOK, recorder.Code)
+ require.Empty(t, fake.lastHeaders.Get("Authorization"),
+ "a module upstream must not receive the person's session credential")
+ require.Empty(t, fake.lastHeaders.Get("Cookie"),
+ "nor the cookie the gateway has already resolved identity from")
+ require.NotEmpty(t, fake.lastHeaders.Get("x-user-id"),
+ "it still learns who the person is")
+ require.NotEmpty(t, fake.lastHeaders.Get("x-org-id"))
+}
+
+// The public solution surface โ the module-federation manifest, remote entry and
+// chunks โ is fetched by the browser's module loader. It carries no bearer when the
+// loader is working as designed, but a page that had one attached forwarded it to
+// an unauthenticated path, which is the verifier's reproducer.
+func TestPublicSolutionPathForwardsNoCredential(t *testing.T) {
+ gw, _, _, _ := newGatewayHarness(t)
+ fake := registerSolutionUpstream(t, gw, "audit")
+
+ request := httptest.NewRequest(http.MethodGet, "/solutions/audit/assets/remoteEntry.js", nil)
+ request.Header.Set("Authorization", "Bearer a-host-session-token")
+ request.Header.Set("Cookie", "codefly_rt=a-refresh-token")
+ recorder := httptest.NewRecorder()
+ gw.ServeHTTP(recorder, request)
+
+ require.Equal(t, http.StatusOK, recorder.Code)
+ require.Empty(t, fake.lastHeaders.Get("Authorization"))
+ require.Empty(t, fake.lastHeaders.Get("Cookie"))
+}
+
+// The host's own API is the one upstream where the session IS the credential, so
+// the strip must not reach a catalog route. Without this control the test above
+// would pass on a gateway that forwarded nothing to anybody.
+func TestCatalogRouteStillCarriesTheSessionToAccounts(t *testing.T) {
+ gw, api, _, priv := newGatewayHarness(t)
+
+ token := signValidToken(t, priv)
+ request := httptest.NewRequest(http.MethodGet, "/v1/users", nil)
+ request.Header.Set("Authorization", "Bearer "+token)
+ recorder := httptest.NewRecorder()
+ gw.ServeHTTP(recorder, request)
+
+ require.Equal(t, http.StatusOK, recorder.Code)
+ require.Equal(t, "Bearer "+token, api.lastHeaders.Get("Authorization"))
+}
From 15daa226bd6b63263336ed8b6c06b6e934721161 Mon Sep 17 00:00:00 2001
From: Antoine Toussaint
<312536060+antoinetoussaint-byte@users.noreply.github.com>
Date: Mon, 5 Oct 2026 03:27:04 -0400
Subject: [PATCH 11/46] fix: the verified public origin is operator
configuration, never a request header (SA-F-ORIGIN)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The frontend's own rendered endpoint is a loopback placeholder โ which is what the
render produces, so on every cell โ and the frontend fell back to the caller's
X-Forwarded-Host and stamped it, under the internal token, as the VERIFIED public
origin. accounts then bound an OAuth redirect, the authenticator relying-party
origin and (with no application base URL, as on a cell) emailed links to a host the
caller had chosen. The solution proxy's same-origin check compared the browser's
Origin against the same caller-supplied value, so a caller that supplied both sides
matched itself.
Three independent changes, because the origin is decided in three places and any
one of them deciding it from a request is the whole defect.
The frontend no longer takes a request origin at all: resolveCodeflyGatewayContext
dropped the parameter. The order is the configured origin, then the SDK-discovered
own endpoint when the render carries a real host, and on a deployed build nothing
else โ a loopback placeholder with nothing configured refuses, naming
application/APP_BASE_URL, rather than asking the caller. Local development keeps
its loopback endpoint, which is the host the browser reaches anyway; only the build
tells the two apart, and nothing a caller sends does.
The solution proxy compares the browser's Origin against that configured origin,
and refuses when there is none: nothing to compare against is not a reason to
admit. The origin is resolved separately from the trust context, so a deployment
missing the internal token is still a different condition from one with no verified
origin โ conflating them would refuse a same-origin request over a credential
nobody asked about.
accounts records a forwarded origin as verified only when it equals the configured
one. That is the barrier that does not depend on the frontend: the frontend holds
the internal token legitimately, so a credential check alone could never tell a
forwarded origin from a forwarded choice. A cell without APP_BASE_URL is a cell
whose verified origin is a request parameter, so accounts refuses to start without
it.
publicRequestOrigin and requestPublicOrigin are gone, with the test that pinned the
first. The solutions-surfaces route resolved the host origin a client fetches a
remote's modules from the same way, and now reads the configured one.
Tests: the configured origin wins over a render that carries a real host; a
deployed build with a loopback placeholder refuses by name; local development keeps
the loopback endpoint; near-misses of the configured origin (a subdomain, a
suffixed host, another port, another scheme) are each refused in accounts; a forged
cross-site request supplying its own forwarded host is refused by the proxy; a
deployment with no configured origin refuses every proxied request. Each
mutation-checked, and the positive controls โ a same-origin POST behind a
TLS-terminating ingress, an unpinned local runtime accepting a well-formed origin โ
assert the refusals are the pin rather than a blanket no.
Owned elsewhere, and not worked around here: the CLI renders the frontend's own
endpoint as a loopback placeholder (which is why the fallback existed), and the
platform's ingress should overwrite X-Forwarded-Host for product hosts.
Docs: application.env says what APP_BASE_URL binds and that a deployed runtime
requires it. PR #1007 adds the same key to that file for the OAuth issuer; whichever
lands second rebases.
Refs #1018
Co-Authored-By: Claude Opus 5 (1M context)
---
module/configurations/local/application.env | 21 +-
.../accounts/code/pkg/auth/public_origin.go | 50 ++++-
.../pkg/auth/public_origin_configured_test.go | 97 +++++++++
.../accounts/code/pkg/business/service.go | 15 +-
.../accounts/code/shipped_placeholder_test.go | 14 ++
module/services/accounts/code/work.go | 36 +++-
.../src/app/(auth)/oauth2/authorize/route.ts | 16 +-
.../proxy/[...path]/__tests__/route.test.ts | 92 +++++++--
.../proxy/[...path]/__tests__/stream.test.ts | 6 +
.../solutions/[id]/proxy/[...path]/route.ts | 36 +++-
.../surfaces/__tests__/route.test.ts | 19 +-
.../src/app/api/solutions/surfaces/route.ts | 16 +-
.../__tests__/codefly-gateway-context.test.ts | 193 ++++++++++++------
.../lib/__tests__/proxy-public-origin.test.ts | 48 -----
.../code/src/lib/codefly-gateway-context.ts | 117 ++++++++---
.../frontend/code/src/lib/public-origin.ts | 36 ++--
module/services/frontend/code/src/proxy.ts | 22 +-
.../code/src/test/pipeline-gateway.ts | 19 +-
18 files changed, 615 insertions(+), 238 deletions(-)
create mode 100644 module/services/accounts/code/pkg/auth/public_origin_configured_test.go
delete mode 100644 module/services/frontend/code/src/lib/__tests__/proxy-public-origin.test.ts
diff --git a/module/configurations/local/application.env b/module/configurations/local/application.env
index e21f0a51f..fc8cfaf30 100644
--- a/module/configurations/local/application.env
+++ b/module/configurations/local/application.env
@@ -1,6 +1,21 @@
-# Interactive links use the verified frontend origin propagated from Codefly's
-# SDK-discovered endpoint. A background-only production origin may be supplied
-# by deployment configuration; local development never pins a runtime port.
+# APP_BASE_URL is the one origin this deployment treats as its own: what binds an
+# OAuth redirect, the authenticator relying-party origin, every emailed link, and
+# the frontend solution proxy's same-origin judgement.
+#
+# It is OPERATOR configuration and is never derived from a request. It used to be:
+# the frontend's own rendered endpoint is a loopback placeholder, so the frontend
+# fell back to the caller's X-Forwarded-Host and stamped that, under the internal
+# token, as the verified public origin โ which let a caller choose the host a
+# sign-in returned to and the host an emailed link pointed at.
+#
+# A DEPLOYED runtime therefore requires it: accounts refuses to start without it,
+# and the frontend refuses to serve sign-in or the solution proxy. Local
+# development pins no runtime port, so it is empty here and the frontend uses its
+# own discovered loopback endpoint, which is the host the browser reaches anyway.
+APP_BASE_URL=
+
+# Interactive links use the origin above. A background-only production origin may
+# be supplied by deployment configuration.
BOOTSTRAP_ADMIN_EMAIL=
# APP_BASE_URL is this host's own public origin, and since issue #1003 it is
diff --git a/module/services/accounts/code/pkg/auth/public_origin.go b/module/services/accounts/code/pkg/auth/public_origin.go
index 868e20b5a..1847114fa 100644
--- a/module/services/accounts/code/pkg/auth/public_origin.go
+++ b/module/services/accounts/code/pkg/auth/public_origin.go
@@ -2,6 +2,7 @@ package auth
import (
"context"
+ "errors"
"fmt"
"net"
"net/url"
@@ -10,10 +11,31 @@ import (
type verifiedPublicOriginKey struct{}
-// CanonicalPublicOrigin validates an exact browser origin. Frontend derives the
-// origin from the actual request and authenticates it to the trusted gateway;
-// business code never derives security-sensitive URLs from caller-controlled
-// forwarding headers.
+// configuredPublicOrigin is the origin an operator pinned for this deployment,
+// installed once at startup. The empty string means none is pinned, which only a
+// local runtime reaches: boot requires one outside local development.
+var configuredPublicOrigin string
+
+// SetConfiguredPublicOrigin installs the operator-configured public origin. Call
+// it once from startup, before anything serves.
+func SetConfiguredPublicOrigin(origin string) {
+ configuredPublicOrigin = strings.TrimSuffix(strings.TrimSpace(origin), "/")
+}
+
+// ConfiguredPublicOrigin returns the pinned origin, if there is one.
+func ConfiguredPublicOrigin() (string, bool) {
+ return configuredPublicOrigin, configuredPublicOrigin != ""
+}
+
+// ErrPublicOriginNotConfigured is returned when a forwarded origin does not match
+// the one this deployment pinned.
+var ErrPublicOriginNotConfigured = errors.New(
+ "public origin does not match the configured application base URL")
+
+// CanonicalPublicOrigin validates an exact browser origin: absolute, HTTP(S),
+// HTTPS unless loopback, and nothing but scheme and authority. It says a value is
+// WELL FORMED, never that it is trusted โ WithVerifiedPublicOrigin is what decides
+// that.
func CanonicalPublicOrigin(candidate string) (string, error) {
candidate = strings.TrimSpace(candidate)
parsed, err := url.Parse(candidate)
@@ -38,12 +60,30 @@ func CanonicalPublicOrigin(candidate string) (string, error) {
}
// WithVerifiedPublicOrigin records the origin only after the gateway credential
-// has been authenticated by an adapter.
+// has been authenticated by an adapter โ AND only when it is the origin this
+// deployment pinned.
+//
+// The credential check alone was not enough, and that is the whole of the defect
+// it closes. The frontend held the internal token legitimately, and derived the
+// origin it stamped from the caller's own `X-Forwarded-Host` whenever its rendered
+// endpoint was a loopback placeholder, which is what a render produces. So an
+// authenticated hop forwarded a caller's choice, and this recorded it as VERIFIED:
+// an OAuth redirect, the authenticator relying-party origin and the links mailed
+// out were then bound to a host the caller named.
+//
+// Comparing against the pinned origin makes that unreachable regardless of what
+// any hop forwards โ a second, independent barrier to the frontend's own fix, and
+// the one that holds for a hop this service does not own. Where nothing is pinned
+// the candidate stands on the credential alone, which only a local runtime reaches:
+// boot requires a pinned origin outside local development.
func WithVerifiedPublicOrigin(ctx context.Context, candidate string) (context.Context, error) {
origin, err := CanonicalPublicOrigin(candidate)
if err != nil {
return ctx, err
}
+ if configured, ok := ConfiguredPublicOrigin(); ok && !strings.EqualFold(origin, configured) {
+ return ctx, ErrPublicOriginNotConfigured
+ }
return context.WithValue(ctx, verifiedPublicOriginKey{}, origin), nil
}
diff --git a/module/services/accounts/code/pkg/auth/public_origin_configured_test.go b/module/services/accounts/code/pkg/auth/public_origin_configured_test.go
new file mode 100644
index 000000000..32458bb7d
--- /dev/null
+++ b/module/services/accounts/code/pkg/auth/public_origin_configured_test.go
@@ -0,0 +1,97 @@
+package auth_test
+
+import (
+ "context"
+ "testing"
+
+ "accounts/pkg/auth"
+
+ "github.com/stretchr/testify/require"
+)
+
+// The gateway credential alone was not enough, and that is the whole of what this
+// closes. The frontend held the internal token legitimately and derived the origin
+// it stamped from the caller's own X-Forwarded-Host whenever its rendered endpoint
+// was a loopback placeholder โ which is what a render produces. So an authenticated
+// hop forwarded a caller's choice and it was recorded as VERIFIED, which bound an
+// OAuth redirect, the authenticator relying-party origin and every emailed link to
+// a host the caller named.
+func TestForwardedOriginIsRefusedUnlessItIsTheConfiguredOne(t *testing.T) {
+ pin(t, "https://app.cell.example")
+
+ _, err := auth.WithVerifiedPublicOrigin(context.Background(), "https://evil.example")
+ require.ErrorIs(t, err, auth.ErrPublicOriginNotConfigured)
+
+ ctx, err := auth.WithVerifiedPublicOrigin(context.Background(), "https://app.cell.example")
+ require.NoError(t, err)
+ origin, ok := auth.VerifiedPublicOrigin(ctx)
+ require.True(t, ok)
+ require.Equal(t, "https://app.cell.example", origin)
+}
+
+// A trailing slash and a differently-cased host are the same origin, so a
+// deployment is not refused for spelling its own value two ways.
+func TestConfiguredOriginComparisonIsOnTheOrigin(t *testing.T) {
+ pin(t, "https://app.cell.example/")
+
+ for _, candidate := range []string{
+ "https://app.cell.example",
+ "https://app.cell.example/",
+ "https://APP.CELL.example",
+ } {
+ _, err := auth.WithVerifiedPublicOrigin(context.Background(), candidate)
+ require.NoError(t, err, candidate)
+ }
+}
+
+// A near-miss is a miss: a subdomain, a sibling host, a different scheme and a
+// different port are each a different origin.
+func TestConfiguredOriginRefusesNearMisses(t *testing.T) {
+ pin(t, "https://app.cell.example")
+
+ for _, candidate := range []string{
+ "https://app.cell.example.evil.example",
+ "https://evil.app.cell.example",
+ "https://app.cell.example:8443",
+ "http://localhost:3000",
+ } {
+ _, err := auth.WithVerifiedPublicOrigin(context.Background(), candidate)
+ require.ErrorIs(t, err, auth.ErrPublicOriginNotConfigured, candidate)
+ }
+}
+
+// With nothing pinned the candidate stands on the credential alone, which only a
+// local runtime reaches: boot requires a pinned origin outside local development.
+// Asserted so the comparison above is the pin and not a blanket refusal.
+func TestUnpinnedDeploymentStillAcceptsAWellFormedForwardedOrigin(t *testing.T) {
+ pin(t, "")
+
+ ctx, err := auth.WithVerifiedPublicOrigin(context.Background(), "http://localhost:3000")
+ require.NoError(t, err)
+ origin, ok := auth.VerifiedPublicOrigin(ctx)
+ require.True(t, ok)
+ require.Equal(t, "http://localhost:3000", origin)
+}
+
+// Well-formedness is still checked first, so a pinned deployment does not accept a
+// malformed value by matching it against anything.
+func TestAMalformedOriginIsRefusedWhateverIsPinned(t *testing.T) {
+ pin(t, "https://app.cell.example")
+
+ for _, candidate := range []string{
+ "https://app.cell.example/path",
+ "https://user:secret@app.cell.example",
+ "app.cell.example",
+ "",
+ } {
+ _, err := auth.WithVerifiedPublicOrigin(context.Background(), candidate)
+ require.Error(t, err, candidate)
+ }
+}
+
+func pin(t *testing.T, origin string) {
+ t.Helper()
+ previous, _ := auth.ConfiguredPublicOrigin()
+ t.Cleanup(func() { auth.SetConfiguredPublicOrigin(previous) })
+ auth.SetConfiguredPublicOrigin(origin)
+}
diff --git a/module/services/accounts/code/pkg/business/service.go b/module/services/accounts/code/pkg/business/service.go
index f3e1af421..dd5fd9b75 100644
--- a/module/services/accounts/code/pkg/business/service.go
+++ b/module/services/accounts/code/pkg/business/service.go
@@ -565,12 +565,15 @@ func (s *Service) SetEmailOutbox(outbox *email.Outbox, appBaseURL string) {
// publicBaseURL is the origin baked into interactive links โ magic-link,
// invitation, and waitlist emails, and Stripe redirect targets. Those links are
// delivered out of band and never re-validated downstream, so the origin must be
-// operator-trusted. A configured APP_BASE_URL wins; the frontend-supplied
-// verified public origin is a per-request, caller-influenced value (the frontend
-// derives it from the browser request when its own endpoint is a placeholder),
-// so it is only a fallback for deployments that have not pinned a canonical
-// origin. Without either, callers fail closed rather than mint a link on an
-// unverified host.
+// operator-trusted.
+//
+// A configured APP_BASE_URL wins, and outside local development boot requires one
+// (requireApplicationBaseURL). The verified public origin behind it is no longer a
+// caller-influenced value: a forwarded origin is recorded as verified only when it
+// equals the configured one (auth.WithVerifiedPublicOrigin), so where both exist
+// they are the same string and where only the second does, nothing is pinned and
+// the runtime is local. Without either, callers fail closed rather than mint a link
+// on an unverified host.
func (s *Service) publicBaseURL(ctx context.Context) string {
if base := strings.TrimSuffix(strings.TrimSpace(s.appBaseURL), "/"); base != "" {
return base
diff --git a/module/services/accounts/code/shipped_placeholder_test.go b/module/services/accounts/code/shipped_placeholder_test.go
index 0006c10f8..8b2d33f1a 100644
--- a/module/services/accounts/code/shipped_placeholder_test.go
+++ b/module/services/accounts/code/shipped_placeholder_test.go
@@ -69,3 +69,17 @@ func TestPerimeterCredentialsCoverEveryTrustDecidingKey(t *testing.T) {
"gateway-trust/CODEFLY_GATEWAY_TOKEN_PREVIOUS": true,
}, names)
}
+
+// A cell without a pinned public origin is a cell whose verified origin is a
+// request parameter: unset, an OAuth redirect, the authenticator relying-party
+// origin and every emailed link were bound to whatever origin the request's trusted
+// hop forwarded โ which that hop derived from the caller's own forwarding header.
+func TestDeployedRequiresTheApplicationBaseURL(t *testing.T) {
+ err := requireApplicationBaseURL(false)
+ require.Error(t, err, "a deployed runtime must refuse an unset APP_BASE_URL")
+ require.Contains(t, err.Error(), "APP_BASE_URL",
+ "the refusal must name the key an operator has to set")
+
+ require.NoError(t, requireApplicationBaseURL(true),
+ "local development pins no runtime port")
+}
diff --git a/module/services/accounts/code/work.go b/module/services/accounts/code/work.go
index 4fd338b90..fc5a1e97c 100644
--- a/module/services/accounts/code/work.go
+++ b/module/services/accounts/code/work.go
@@ -919,6 +919,11 @@ func doWork(ctx context.Context) (Clean, error) {
if err != nil {
return nil, err
}
+ // The one origin this deployment treats as its own: what binds an OAuth
+ // redirect, an authenticator's relying party and every emailed link. From here
+ // on a forwarded origin is honoured only when it equals this one, so no hop can
+ // substitute a caller's choice (pkg/auth.WithVerifiedPublicOrigin).
+ auth.SetConfiguredPublicOrigin(appBase)
var workerEmailOutbox *email.Outbox
var emailWorker *jobs.Worker
if emailConfig.sender == nil {
@@ -2556,7 +2561,36 @@ func requireStartupConfiguration(ctx context.Context, isLocal bool) error {
if err := requireKeyCustody(selection.SigningBackend, isLocal); err != nil {
return err
}
- return requirePerimeterCredentials(isLocal)
+ if err := requirePerimeterCredentials(isLocal); err != nil {
+ return err
+ }
+ return requireApplicationBaseURL(isLocal)
+}
+
+// requireApplicationBaseURL refuses to start a deployed runtime without the
+// origin it treats as its own.
+//
+// It is the value that binds an OAuth redirect, an authenticator's relying party
+// and every emailed link. Unset, each of those was bound to whatever origin the
+// request's trusted hop forwarded โ which that hop derived from the caller's own
+// forwarding header โ so a caller chose the host a sign-in returned to and the host
+// an emailed link pointed at. A cell without it is a cell whose verified origin is
+// a request parameter, so it refuses here, where a deployment can still be fixed,
+// rather than serving a sign-in bound to somewhere else.
+func requireApplicationBaseURL(isLocal bool) error {
+ if isLocal {
+ return nil
+ }
+ configured, err := configuredApplicationBaseURL()
+ if err != nil {
+ return err
+ }
+ if configured == "" {
+ return fmt.Errorf("application: APP_BASE_URL is required outside the local environment: " +
+ "set it in the `application` configuration group to this cell's public origin, which is " +
+ "what binds an OAuth redirect, the authenticator relying-party origin and every emailed link")
+ }
+ return nil
}
// requireKeyCustody refuses to start outside the local environment without the
diff --git a/module/services/frontend/code/src/app/(auth)/oauth2/authorize/route.ts b/module/services/frontend/code/src/app/(auth)/oauth2/authorize/route.ts
index dc856e87c..3aacb45f8 100644
--- a/module/services/frontend/code/src/app/(auth)/oauth2/authorize/route.ts
+++ b/module/services/frontend/code/src/app/(auth)/oauth2/authorize/route.ts
@@ -10,7 +10,6 @@ import {
} from "@/features/auth/model/oauth-authorization";
import { resolveCodeflyGatewayContext } from "@/lib/codefly-gateway-context";
import { INTERNAL_TOKEN_HEADER } from "@/lib/internal-token";
-import { requestPublicOrigin } from "@/lib/public-origin";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
@@ -66,13 +65,14 @@ export async function GET(request: Request): Promise {
}
const headers = new Headers({ "content-type": "application/json" });
- // The authorization server needs to know which origin it is being reached
- // at, because every URL it publishes names it. That claim is only trusted
- // beside the cluster-internal token, which this process holds and a browser
- // does not โ the same pair src/proxy.ts stamps on product API traffic.
- const gatewayContext = resolveCodeflyGatewayContext(
- requestPublicOrigin(request),
- );
+ // The authorization server needs to know which origin it is being reached at,
+ // because every URL it publishes names it โ the issuer in its metadata, the
+ // redirect it binds a code to, the link it emails. That origin is OPERATOR
+ // CONFIGURATION and is resolved from it here, never read off this request: the
+ // internal token stamped beside it proves which PROCESS is asking, which is
+ // not the same as the origin being this host's, so a caller-supplied host
+ // travelling under that token would have been published as the issuer's.
+ const gatewayContext = resolveCodeflyGatewayContext();
if (gatewayContext) {
headers.set(INTERNAL_TOKEN_HEADER, gatewayContext.internalToken);
headers.set(PUBLIC_ORIGIN_HEADER, gatewayContext.publicOrigin);
diff --git a/module/services/frontend/code/src/app/api/solutions/[id]/proxy/[...path]/__tests__/route.test.ts b/module/services/frontend/code/src/app/api/solutions/[id]/proxy/[...path]/__tests__/route.test.ts
index d3c7be853..4b3bab49d 100644
--- a/module/services/frontend/code/src/app/api/solutions/[id]/proxy/[...path]/__tests__/route.test.ts
+++ b/module/services/frontend/code/src/app/api/solutions/[id]/proxy/[...path]/__tests__/route.test.ts
@@ -3,26 +3,30 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("server-only", () => ({}));
// The route resolves the gateway base (getEndpoints) and the first-party trust
-// context (getCurrentModule/getCurrentService/getWorkspaceSecret, via
-// resolveCodeflyGatewayContext). vi.mock is hoisted above module init, so the
-// stubs are created with vi.hoisted.
+// context (getCurrentModule/getCurrentService/getWorkspaceSecret/
+// getWorkspaceConfiguration, via resolveCodeflyGatewayContext). vi.mock is
+// hoisted above module init, so the stubs are created with vi.hoisted.
const {
getEndpoints,
getCurrentModule,
getCurrentService,
getWorkspaceSecret,
+ getWorkspaceConfiguration,
} = vi.hoisted(() => ({
getEndpoints: vi.fn<() => Array>>(),
getCurrentModule: vi.fn<() => string>(() => ""),
getCurrentService: vi.fn<() => string>(() => ""),
getWorkspaceSecret:
vi.fn<(name: string, key: string) => string | undefined>(),
+ getWorkspaceConfiguration:
+ vi.fn<(name: string, key: string) => string | undefined>(),
}));
vi.mock("codefly", () => ({
getEndpoints,
getCurrentModule,
getCurrentService,
getWorkspaceSecret,
+ getWorkspaceConfiguration,
}));
// The registry is a durable, gateway-brokered record rather than a map in this
@@ -48,10 +52,10 @@ function withGateway() {
]);
}
-// Resolve the first-party trust context the way a real runtime does: no
-// discoverable own endpoint, so resolveCodeflyGatewayContext falls back to the
-// request origin for the public origin and reads the internal token from config.
-function withTrustContext() {
+// Resolve the first-party trust context the way a real runtime does: the public
+// origin comes from the deployment's own configuration and from nothing else, so a
+// test that wants a different one configures it rather than sending a header.
+function withTrustContext(publicOrigin = "http://frontend") {
getCurrentModule.mockReturnValue("");
getCurrentService.mockReturnValue("");
getWorkspaceSecret.mockImplementation((name, key) =>
@@ -59,6 +63,9 @@ function withTrustContext() {
? INTERNAL_TOKEN
: undefined,
);
+ getWorkspaceConfiguration.mockImplementation((name, key) =>
+ name === "application" && key === "APP_BASE_URL" ? publicOrigin : undefined,
+ );
}
function registerAudit(serviceAlias = "audit-backend") {
@@ -113,6 +120,14 @@ beforeEach(() => {
getCurrentModule.mockReturnValue("");
getCurrentService.mockReturnValue("");
getWorkspaceSecret.mockReturnValue(undefined);
+ // The public origin is operator configuration, and the same-origin check is made
+ // against it, so every fixture pins the one these requests arrive on. A test
+ // about the origin itself overrides it (withTrustContext).
+ getWorkspaceConfiguration.mockImplementation((name, key) =>
+ name === "application" && key === "APP_BASE_URL"
+ ? "http://frontend"
+ : undefined,
+ );
findSolution.mockResolvedValue(null);
fetchMock = vi.fn(
async () =>
@@ -128,6 +143,7 @@ afterEach(() => {
findSolution.mockReset();
getEndpoints.mockReset();
getCurrentModule.mockReset();
+ getWorkspaceConfiguration.mockReset();
getCurrentService.mockReset();
getWorkspaceSecret.mockReset();
vi.unstubAllGlobals();
@@ -354,10 +370,12 @@ describe("solution proxy passthrough", () => {
// Behind a TLS-terminating ingress the pod sees plaintext http and its own
// host, while the browser's Origin is the public https origin. A same-origin
- // write (every Connect call and chat turn is a POST) must still pass.
+ // write (every Connect call and chat turn is a POST) must still pass โ on the
+ // strength of the CONFIGURED origin, which is the only thing that knows what
+ // the browser sees.
it("allows a same-origin POST behind a TLS-terminating ingress", async () => {
withGateway();
- withTrustContext();
+ withTrustContext("https://app.example.com");
registerAudit();
const res = await POST(
@@ -365,8 +383,6 @@ describe("solution proxy passthrough", () => {
authorization: "Bearer caller-token",
origin: "https://app.example.com",
"sec-fetch-site": "same-origin",
- "x-forwarded-proto": "https",
- "x-forwarded-host": "app.example.com",
}),
context("audit", ["records"]),
);
@@ -377,14 +393,54 @@ describe("solution proxy passthrough", () => {
it("rejects a cross-site Origin behind a TLS-terminating ingress", async () => {
withGateway();
- withTrustContext();
+ withTrustContext("https://app.example.com");
+ registerAudit();
+
+ const res = await POST(
+ rawRequest("http://frontend/api/solutions/audit/proxy/records", "POST", {
+ origin: "https://evil.example",
+ }),
+ context("audit", ["records"]),
+ );
+
+ expect(res.status).toBe(403);
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ // The defect this closes: the comparison used to be made against an origin
+ // derived from the request's own forwarding headers, so a caller that supplied
+ // both sides matched itself. A check is only a check when one side is not the
+ // caller's to choose.
+ it("rejects a forged cross-site request that supplies its own forwarded host", async () => {
+ withGateway();
+ withTrustContext("https://app.example.com");
registerAudit();
const res = await POST(
rawRequest("http://frontend/api/solutions/audit/proxy/records", "POST", {
origin: "https://evil.example",
"x-forwarded-proto": "https",
- "x-forwarded-host": "app.example.com",
+ "x-forwarded-host": "evil.example",
+ }),
+ context("audit", ["records"]),
+ );
+
+ expect(res.status).toBe(403);
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ // And with no configured origin there is nothing to compare against, so the
+ // route refuses rather than admitting on an absent comparison.
+ it("refuses every request when the deployment has no configured public origin", async () => {
+ withGateway();
+ withTrustContext();
+ getWorkspaceConfiguration.mockReturnValue(undefined);
+ registerAudit();
+
+ const res = await POST(
+ rawRequest("http://frontend/api/solutions/audit/proxy/records", "POST", {
+ authorization: "Bearer caller-token",
+ "sec-fetch-site": "same-origin",
}),
context("audit", ["records"]),
);
@@ -411,8 +467,8 @@ describe("solution proxy passthrough", () => {
const forwarded = fetchMock.mock.calls[0][1].headers as Headers;
expect(forwarded.has("x-secret-smuggle")).toBe(false);
- // x-forwarded-host is consumed to derive the public origin, never relayed
- // to the upstream as a raw header.
+ // x-forwarded-host decides nothing here any more, and is never relayed to the
+ // upstream as a raw header either.
expect(forwarded.has("x-forwarded-host")).toBe(false);
});
@@ -437,15 +493,17 @@ describe("solution proxy passthrough", () => {
it("attaches the first-party trust headers resolved from config", async () => {
withGateway();
- withTrustContext();
+ withTrustContext("https://app.example");
registerAudit();
await GET(
proxyRequest("http://frontend/api/solutions/audit/proxy/records", {
headers: {
authorization: "Bearer caller-token",
+ // Offered by the caller and ignored: the stamped origin is the
+ // configured one, whatever the request says.
"x-forwarded-proto": "https",
- "x-forwarded-host": "app.example",
+ "x-forwarded-host": "evil.example",
},
}),
context("audit", ["records"]),
diff --git a/module/services/frontend/code/src/app/api/solutions/[id]/proxy/[...path]/__tests__/stream.test.ts b/module/services/frontend/code/src/app/api/solutions/[id]/proxy/[...path]/__tests__/stream.test.ts
index a109704f0..699161afe 100644
--- a/module/services/frontend/code/src/app/api/solutions/[id]/proxy/[...path]/__tests__/stream.test.ts
+++ b/module/services/frontend/code/src/app/api/solutions/[id]/proxy/[...path]/__tests__/stream.test.ts
@@ -14,6 +14,12 @@ vi.mock("codefly", () => ({
getCurrentModule: () => "",
getCurrentService: () => "",
getWorkspaceSecret: () => "fixture-internal",
+ // The public origin is operator configuration, and the same-origin check is
+ // made against it, so a fixture deployment has to pin one.
+ getWorkspaceConfiguration: (group: string, key: string) =>
+ group === "application" && key === "APP_BASE_URL"
+ ? "http://localhost"
+ : undefined,
}));
vi.mock("@/solutions/registry", () => ({ findSolution }));
diff --git a/module/services/frontend/code/src/app/api/solutions/[id]/proxy/[...path]/route.ts b/module/services/frontend/code/src/app/api/solutions/[id]/proxy/[...path]/route.ts
index a2b6228b8..12a06b051 100644
--- a/module/services/frontend/code/src/app/api/solutions/[id]/proxy/[...path]/route.ts
+++ b/module/services/frontend/code/src/app/api/solutions/[id]/proxy/[...path]/route.ts
@@ -1,8 +1,11 @@
import { getEndpoints } from "codefly";
-import { resolveCodeflyGatewayContext } from "@/lib/codefly-gateway-context";
+import {
+ resolveCodeflyGatewayContext,
+ resolveVerifiedPublicOrigin,
+} from "@/lib/codefly-gateway-context";
import { INTERNAL_TOKEN_HEADER } from "@/lib/internal-token";
-import { requestPublicOrigin } from "@/lib/public-origin";
+
import { findSolution } from "@/solutions/registry";
export const dynamic = "force-dynamic";
@@ -19,14 +22,23 @@ interface RouteContext {
// internal token, so it must reject cross-site requests itself โ cookies ride
// along automatically on a forged cross-origin call, and the gateway cannot tell
// a CSRF-driven request from a legitimate one once the internal token is
-// attached. The browser's Origin is compared with the request's PUBLIC origin:
-// behind a TLS-terminating ingress the pod-local request URL is plaintext http,
-// so comparing with it rejected every same-origin write a deployed cell served.
-function sameOrigin(request: Request): boolean {
+// attached.
+//
+// The browser's Origin is compared with this deployment's CONFIGURED public
+// origin. It used to be compared with an origin derived from the request's own
+// forwarded headers, which made the check self-referential: a non-browser caller
+// supplying `Origin: https://evil.example` and `X-Forwarded-Host: evil.example`
+// matched itself and passed. The comparison is only a check when one side is not
+// the caller's to choose.
+//
+// A deployment with no verified public origin refuses: there is nothing to compare
+// against, and admitting on that basis is the same defect by omission.
+function sameOrigin(request: Request, hostOrigin: string | undefined): boolean {
+ if (!hostOrigin) return false;
const origin = request.headers.get("origin");
if (origin) {
try {
- if (new URL(origin).origin !== requestPublicOrigin(request)) return false;
+ if (new URL(origin).origin !== hostOrigin) return false;
} catch {
return false;
}
@@ -74,7 +86,11 @@ async function handler(
): Promise {
const { id, path } = await context.params;
- if (!sameOrigin(request)) {
+ // The origin is resolved on its own, not off the trust context: a deployment
+ // missing the internal token is a different condition from one with no verified
+ // origin, and refusing a same-origin request for the first would be answering a
+ // question nobody asked.
+ if (!sameOrigin(request, resolveVerifiedPublicOrigin())) {
return new Response("cross-origin request rejected", { status: 403 });
}
@@ -119,9 +135,7 @@ async function handler(
// First-party trust headers, resolved server-side from Codefly config โ the
// gateway rejects solution traffic that lacks them even with a valid user
// identity. Set from a fresh Headers so a caller can never spoof them.
- const gatewayContext = resolveCodeflyGatewayContext(
- requestPublicOrigin(request),
- );
+ const gatewayContext = resolveCodeflyGatewayContext();
if (gatewayContext) {
headers.set(INTERNAL_TOKEN_HEADER, gatewayContext.internalToken);
headers.set(PUBLIC_ORIGIN_HEADER, gatewayContext.publicOrigin);
diff --git a/module/services/frontend/code/src/app/api/solutions/surfaces/__tests__/route.test.ts b/module/services/frontend/code/src/app/api/solutions/surfaces/__tests__/route.test.ts
index ac116a278..2c2a443c8 100644
--- a/module/services/frontend/code/src/app/api/solutions/surfaces/__tests__/route.test.ts
+++ b/module/services/frontend/code/src/app/api/solutions/surfaces/__tests__/route.test.ts
@@ -5,12 +5,21 @@ vi.mock("server-only", () => ({}));
// The registry reaches the gateway through service discovery and reads the
// cluster-internal secret through the Codefly SDK; vi.mock is hoisted above
// module init, so the stubs must be created with vi.hoisted.
-const { getWorkspaceSecret, getEndpoints } = vi.hoisted(() => ({
- getWorkspaceSecret:
- vi.fn<(name: string, key: string) => string | undefined>(),
- getEndpoints: vi.fn<() => Array>>(() => []),
+const { getWorkspaceSecret, getEndpoints, getWorkspaceConfiguration } =
+ vi.hoisted(() => ({
+ getWorkspaceSecret:
+ vi.fn<(name: string, key: string) => string | undefined>(),
+ getEndpoints: vi.fn<() => Array>>(() => []),
+ getWorkspaceConfiguration:
+ vi.fn<(name: string, key: string) => string | undefined>(),
+ }));
+vi.mock("codefly", () => ({
+ getWorkspaceSecret,
+ getEndpoints,
+ getWorkspaceConfiguration,
+ getCurrentModule: () => "",
+ getCurrentService: () => "",
}));
-vi.mock("codefly", () => ({ getWorkspaceSecret, getEndpoints }));
import { GET } from "@/app/api/solutions/surfaces/route";
diff --git a/module/services/frontend/code/src/app/api/solutions/surfaces/route.ts b/module/services/frontend/code/src/app/api/solutions/surfaces/route.ts
index 0d9ad6231..31337bcd1 100644
--- a/module/services/frontend/code/src/app/api/solutions/surfaces/route.ts
+++ b/module/services/frontend/code/src/app/api/solutions/surfaces/route.ts
@@ -1,4 +1,4 @@
-import { requestPublicOrigin } from "@/lib/public-origin";
+import { configuredPublicOrigin } from "@/lib/public-origin";
import {
entitlementFailureResponse,
isEntitlementFailure,
@@ -72,12 +72,14 @@ export async function GET(request: Request): Promise {
if (registered === "unavailable") {
return Response.json({ error: "registry_unavailable" }, { status: 503 });
}
- // A solution served through this host resolves its modules against this
- // host's own public origin (see surfacesProjection).
- const hostOrigin = requestPublicOrigin(request);
- // The origin is part of the projection, so it is part of what may be reused:
- // two callers reaching this host through different public origins must not
- // share a cached answer that resolves a module against one of them.
+ // A solution served through this host resolves its modules against this host's
+ // own public origin (see surfacesProjection) โ the configured one, not one
+ // taken from the request: a client told to fetch a remote's modules from a
+ // caller-chosen host would load code from it.
+ const hostOrigin = configuredPublicOrigin();
+ // The origin stays in the cache key. It no longer varies per caller, so this
+ // is not what separates two callers any more; it is what stops a reconfigured
+ // origin being served from an entry computed against the previous one.
const solutions = cachedProjection(
entitlements,
`${client}\u0000${hostOrigin ?? ""}`,
diff --git a/module/services/frontend/code/src/lib/__tests__/codefly-gateway-context.test.ts b/module/services/frontend/code/src/lib/__tests__/codefly-gateway-context.test.ts
index 05a525eeb..9e9605849 100644
--- a/module/services/frontend/code/src/lib/__tests__/codefly-gateway-context.test.ts
+++ b/module/services/frontend/code/src/lib/__tests__/codefly-gateway-context.test.ts
@@ -1,56 +1,76 @@
-import { describe, expect, it } from "vitest";
+import { describe, expect, it, vi } from "vitest";
import {
type CodeflyRuntimeReader,
resolveCodeflyGatewayContext,
} from "@/lib/codefly-gateway-context";
+const loopbackEndpoint = {
+ module: "saas-starter",
+ service: "frontend",
+ name: "http",
+ protocol: "HTTP" as const,
+ address: "http://localhost:42152",
+ routes: [],
+};
+
+/** A deployed runtime whose own endpoint is the render's loopback placeholder. */
function runtime(
overrides: Partial = {},
): CodeflyRuntimeReader {
return {
currentModule: () => "saas-starter",
currentService: () => "frontend",
- endpoints: () => [
- {
- module: "saas-starter",
- service: "frontend",
- name: "http",
- protocol: "HTTP",
- address: "http://localhost:42152",
- routes: [],
- },
- ],
+ endpoints: () => [loopbackEndpoint],
workspaceSecret: () => "internal-test-token",
+ workspaceConfiguration: () => undefined,
+ isDeployedBuild: () => true,
...overrides,
};
}
+function configured(value: string | undefined): Partial {
+ return {
+ workspaceConfiguration: (group, key) =>
+ group === "application" && key === "APP_BASE_URL" ? value : undefined,
+ };
+}
+
describe("Codefly frontend gateway context", () => {
- it("takes the secret from the SDK and, for a loopback placeholder endpoint, the public origin from the request", () => {
- // The render bakes the own HTTP endpoint as a loopback placeholder, so the
- // real public origin must come from the browser request.
+ it("takes the public origin from operator configuration", () => {
expect(
- resolveCodeflyGatewayContext("https://app.example", runtime()),
+ resolveCodeflyGatewayContext(
+ runtime(configured("https://app.cell.example")),
+ ),
).toEqual({
internalToken: "internal-test-token",
- publicOrigin: "https://app.example",
+ publicOrigin: "https://app.cell.example",
});
});
- it("prefers a real (non-loopback) SDK endpoint over the request origin", () => {
+ // The defect: the render bakes the own endpoint as a loopback placeholder, so
+ // this fell back to the caller's X-Forwarded-Host and stamped it, under the
+ // internal token, as the VERIFIED public origin. accounts then bound an OAuth
+ // redirect, the authenticator relying-party origin and emailed links to a host
+ // the caller chose. There is no request to fall back to any more โ the function
+ // does not take one.
+ it("refuses, by name, when a deployed build has no configured origin and a loopback placeholder endpoint", () => {
+ const error = vi.spyOn(console, "error").mockImplementation(() => {});
+ try {
+ expect(resolveCodeflyGatewayContext(runtime())).toBeUndefined();
+ expect(error).toHaveBeenCalledWith(
+ expect.stringContaining("application/APP_BASE_URL"),
+ );
+ } finally {
+ error.mockRestore();
+ }
+ });
+
+ it("uses a render that carries a real ingress host when nothing is pinned", () => {
expect(
resolveCodeflyGatewayContext(
- "https://caller-controlled.example",
runtime({
endpoints: () => [
- {
- module: "saas-starter",
- service: "frontend",
- name: "http",
- protocol: "HTTP",
- address: "https://app.cell.example",
- routes: [],
- },
+ { ...loopbackEndpoint, address: "https://app.cell.example" },
],
}),
),
@@ -60,57 +80,114 @@ describe("Codefly frontend gateway context", () => {
});
});
- it("fails closed when a Codefly runtime has no own HTTP endpoint", () => {
+ it("keeps the loopback endpoint in local development, where the browser reaches it", () => {
expect(
- resolveCodeflyGatewayContext(
- "https://caller-controlled.example",
- runtime({ endpoints: () => [] }),
- ),
- ).toBeUndefined();
+ resolveCodeflyGatewayContext(runtime({ isDeployedBuild: () => false })),
+ ).toEqual({
+ internalToken: "internal-test-token",
+ publicOrigin: "http://localhost:42152",
+ });
});
- it("fails closed on ambiguous or malformed own endpoints", () => {
- const endpoint = runtime().endpoints()[0];
- expect(
- resolveCodeflyGatewayContext(
- "https://caller-controlled.example",
- runtime({ endpoints: () => [endpoint, endpoint] }),
- ),
- ).toBeUndefined();
+ it("prefers the configured origin over a render that carries a real host", () => {
expect(
resolveCodeflyGatewayContext(
- "https://caller-controlled.example",
runtime({
+ ...configured("https://pinned.example"),
endpoints: () => [
- { ...endpoint, address: "https://user:secret@app.example" },
+ { ...loopbackEndpoint, address: "https://rendered.example" },
],
}),
- ),
- ).toBeUndefined();
+ )?.publicOrigin,
+ ).toBe("https://pinned.example");
+ });
+
+ it("refuses a configured value that is not an exact origin", () => {
+ const error = vi.spyOn(console, "error").mockImplementation(() => {});
+ try {
+ for (const value of [
+ "https://app.example/path",
+ "https://user:secret@app.example",
+ "app.example",
+ "ftp://app.example",
+ "https://app.example?q=1",
+ ]) {
+ expect(
+ resolveCodeflyGatewayContext(runtime(configured(value))),
+ ).toBeUndefined();
+ }
+ expect(error).toHaveBeenCalled();
+ } finally {
+ error.mockRestore();
+ }
+ });
+
+ it("fails closed when a Codefly runtime has no own HTTP endpoint and nothing is configured", () => {
+ const error = vi.spyOn(console, "error").mockImplementation(() => {});
+ try {
+ expect(
+ resolveCodeflyGatewayContext(runtime({ endpoints: () => [] })),
+ ).toBeUndefined();
+ } finally {
+ error.mockRestore();
+ }
+ });
+
+ it("fails closed on ambiguous or malformed own endpoints", () => {
+ const error = vi.spyOn(console, "error").mockImplementation(() => {});
+ try {
+ expect(
+ resolveCodeflyGatewayContext(
+ runtime({
+ isDeployedBuild: () => false,
+ endpoints: () => [loopbackEndpoint, loopbackEndpoint],
+ }),
+ ),
+ ).toBeUndefined();
+ expect(
+ resolveCodeflyGatewayContext(
+ runtime({
+ isDeployedBuild: () => false,
+ endpoints: () => [
+ {
+ ...loopbackEndpoint,
+ address: "https://user:secret@app.example",
+ },
+ ],
+ }),
+ ),
+ ).toBeUndefined();
+ } finally {
+ error.mockRestore();
+ }
});
it("fails closed when the workspace secret is unavailable", () => {
expect(
resolveCodeflyGatewayContext(
- "https://app.example",
- runtime({ workspaceSecret: () => undefined }),
+ runtime({
+ ...configured("https://app.cell.example"),
+ workspaceSecret: () => undefined,
+ }),
),
).toBeUndefined();
});
- it("allows request-origin fallback only outside a Codefly runtime", () => {
+ // Outside a Codefly runtime (an isolated component test) there is no own
+ // endpoint and no configuration, so there is no origin โ and, with no request
+ // to borrow one from, nothing to guess with.
+ it("fails closed outside a Codefly runtime unless an origin is configured", () => {
+ const outside = {
+ currentModule: () => "",
+ currentService: () => "",
+ endpoints: () => [],
+ isDeployedBuild: () => false,
+ };
+ expect(resolveCodeflyGatewayContext(runtime(outside))).toBeUndefined();
expect(
resolveCodeflyGatewayContext(
- "https://isolated.example",
- runtime({
- currentModule: () => "",
- currentService: () => "",
- endpoints: () => [],
- }),
- ),
- ).toEqual({
- internalToken: "internal-test-token",
- publicOrigin: "https://isolated.example",
- });
+ runtime({ ...outside, ...configured("https://isolated.example") }),
+ )?.publicOrigin,
+ ).toBe("https://isolated.example");
});
});
diff --git a/module/services/frontend/code/src/lib/__tests__/proxy-public-origin.test.ts b/module/services/frontend/code/src/lib/__tests__/proxy-public-origin.test.ts
deleted file mode 100644
index db865c2a4..000000000
--- a/module/services/frontend/code/src/lib/__tests__/proxy-public-origin.test.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-import { NextRequest } from "next/server";
-import { describe, expect, it } from "vitest";
-
-import { publicRequestOrigin } from "@/proxy";
-
-describe("publicRequestOrigin", () => {
- it("uses the ingress-forwarded proto and host over the pod's plaintext origin", () => {
- // TLS terminates at the ingress, so the pod receives http and nextUrl.origin
- // would report the wrong scheme; the browser is on https://app.example, and
- // Accounts rejects a non-loopback http public origin.
- const req = new NextRequest(
- "http://frontend.pod.internal/saas.accounts.v1.AuthService/BeginOAuth",
- {
- headers: {
- "x-forwarded-proto": "https",
- "x-forwarded-host": "app.example",
- },
- },
- );
- expect(publicRequestOrigin(req)).toBe("https://app.example");
- });
-
- it("takes the outermost hop when forwarded headers are comma-lists", () => {
- const req = new NextRequest("http://frontend.pod.internal/", {
- headers: {
- "x-forwarded-proto": "https, http",
- "x-forwarded-host": "app.example, internal.pod",
- },
- });
- expect(publicRequestOrigin(req)).toBe("https://app.example");
- });
-
- it("overrides only the scheme when x-forwarded-host is absent", () => {
- // TLS terminated at the ingress: nextUrl carries the right host but the
- // wrong (plaintext) scheme, so only the proto needs correcting.
- const req = new NextRequest("http://app.example/", {
- headers: { "x-forwarded-proto": "https" },
- });
- expect(publicRequestOrigin(req)).toBe("https://app.example");
- });
-
- it("falls back to nextUrl entirely without forwarded headers (local dev, direct hits)", () => {
- const req = new NextRequest(
- "http://localhost:3000/saas.accounts.v1.AuthService/BeginOAuth",
- );
- expect(publicRequestOrigin(req)).toBe("http://localhost:3000");
- });
-});
diff --git a/module/services/frontend/code/src/lib/codefly-gateway-context.ts b/module/services/frontend/code/src/lib/codefly-gateway-context.ts
index 84b9afd38..054c0a564 100644
--- a/module/services/frontend/code/src/lib/codefly-gateway-context.ts
+++ b/module/services/frontend/code/src/lib/codefly-gateway-context.ts
@@ -2,6 +2,7 @@ import {
getCurrentModule,
getCurrentService,
getEndpoints,
+ getWorkspaceConfiguration,
getWorkspaceSecret,
type ServiceEndpoint,
} from "codefly";
@@ -16,6 +17,15 @@ export interface CodeflyRuntimeReader {
currentService(): string;
endpoints(): ServiceEndpoint[];
workspaceSecret(name: string, key: string): string | undefined;
+ workspaceConfiguration(name: string, key: string): string | undefined;
+ /**
+ * Whether this is a deployed build. Next sets NODE_ENV at build time โ
+ * "development" under `next dev`, "production" under `next build` โ and the
+ * service's execution profiles map every local profile to a development build
+ * and the deployed one to a production build. Nothing a caller sends can
+ * change it.
+ */
+ isDeployedBuild(): boolean;
}
const runtimeSDK: CodeflyRuntimeReader = {
@@ -23,6 +33,8 @@ const runtimeSDK: CodeflyRuntimeReader = {
currentService: getCurrentService,
endpoints: getEndpoints,
workspaceSecret: getWorkspaceSecret,
+ workspaceConfiguration: getWorkspaceConfiguration,
+ isDeployedBuild: () => process.env.NODE_ENV === "production",
};
function canonicalOrigin(candidate: string): string | undefined {
@@ -51,13 +63,34 @@ function isLoopbackOrigin(origin: string): boolean {
return octets.length === 4 && octets[0] === "127";
}
+/**
+ * The key an operator sets to pin this deployment's public origin. The same key
+ * accounts reads for the links it mints, so the origin that binds a sign-in
+ * redirect, an authenticator's relying party and an emailed link is one value in
+ * one place.
+ */
+const PUBLIC_ORIGIN_CONFIGURATION = "application/APP_BASE_URL";
+
/**
* Resolve the server-side credentials used between the public frontend and the
* private auth gateway. Codefly's injected representation remains entirely
* behind sdk-js; this library only consumes typed SDK values.
+ *
+ * The public origin is OPERATOR CONFIGURATION and is never derived from a
+ * request. It used to be: when the own rendered endpoint was a loopback
+ * placeholder โ which is what the render produces, so on every cell โ this fell
+ * back to the caller's own `X-Forwarded-Host` and stamped it, under the internal
+ * token, as the verified public origin. accounts then bound an OAuth redirect,
+ * the authenticator relying-party origin, and emailed links to a host the caller
+ * had chosen, and the solution proxy's same-origin check compared against the
+ * same caller-supplied value.
+ *
+ * The order is: the configured origin; then the SDK-discovered own endpoint when
+ * the render carries a real host; and on a deployed build, nothing else โ a
+ * loopback placeholder with no configured origin refuses, by name, rather than
+ * asking the caller.
*/
export function resolveCodeflyGatewayContext(
- requestOrigin: string,
runtime: CodeflyRuntimeReader = runtimeSDK,
): CodeflyGatewayContext | undefined {
const internalToken = runtime
@@ -65,16 +98,67 @@ export function resolveCodeflyGatewayContext(
?.trim();
if (!internalToken) return undefined;
+ const publicOrigin = resolveVerifiedPublicOrigin(runtime);
+ if (!publicOrigin) return undefined;
+ return { internalToken, publicOrigin };
+}
+
+/**
+ * The origin alone, for a caller that needs to judge a request against it rather
+ * than to stamp trust headers. Exported separately so "this deployment has no
+ * verified origin" and "this deployment has no internal token" stay two different
+ * answers: a route that conflated them would refuse a same-origin request for a
+ * missing credential it was not asking about.
+ */
+export function resolveVerifiedPublicOrigin(
+ runtime: CodeflyRuntimeReader = runtimeSDK,
+): string | undefined {
+ return resolvePublicOrigin(runtime);
+}
+
+function resolvePublicOrigin(
+ runtime: CodeflyRuntimeReader,
+): string | undefined {
+ const [group, key] = PUBLIC_ORIGIN_CONFIGURATION.split("/");
+ const configured = runtime.workspaceConfiguration(group, key)?.trim();
+ if (configured) {
+ const origin = canonicalOrigin(configured);
+ if (!origin) {
+ console.error(
+ `${PUBLIC_ORIGIN_CONFIGURATION} is not an exact http(s) origin, so this deployment has no verified public origin; sign-in and the solution proxy are refused until it is corrected`,
+ );
+ return undefined;
+ }
+ return origin;
+ }
+
+ const endpointOrigin = ownEndpointOrigin(runtime);
+
+ // A render that carries a real ingress host is operator configuration too, so
+ // it answers when nothing is pinned.
+ if (endpointOrigin && !isLoopbackOrigin(endpointOrigin)) {
+ return endpointOrigin;
+ }
+
+ // A loopback own endpoint is correct in local development โ the browser
+ // reaches that same host โ and is the render's placeholder everywhere else.
+ // Only the build tells those apart, and nothing a caller sends does.
+ if (!runtime.isDeployedBuild()) {
+ return endpointOrigin;
+ }
+
+ console.error(
+ `${PUBLIC_ORIGIN_CONFIGURATION} is not set and this deployment's own endpoint renders as a loopback placeholder, so there is no verified public origin; set it to this cell's public origin. Sign-in and the solution proxy are refused until then, rather than binding them to a host taken from the request.`,
+ );
+ return undefined;
+}
+
+function ownEndpointOrigin(runtime: CodeflyRuntimeReader): string | undefined {
const currentModule = runtime.currentModule().trim();
const currentService = runtime.currentService().trim();
-
// Outside a Codefly runtime (isolated component tests) there is no own
- // endpoint to discover, so the request origin is the only public origin.
- if (!currentModule && !currentService) {
- const publicOrigin = canonicalOrigin(requestOrigin);
- if (!publicOrigin) return undefined;
- return { internalToken, publicOrigin };
- }
+ // endpoint to discover.
+ if (!currentModule && !currentService) return undefined;
const matches = runtime
.endpoints()
@@ -88,20 +172,5 @@ export function resolveCodeflyGatewayContext(
// A Codefly runtime identity must have exactly one SDK-discovered own HTTP
// endpoint.
if (matches.length !== 1) return undefined;
- const endpointOrigin = canonicalOrigin(matches[0].address);
- if (!endpointOrigin) return undefined;
-
- // The render bakes the own HTTP endpoint as a loopback placeholder
- // (localhost:8080) that carries no real ingress host: the true public origin
- // is a per-cell/per-ingress fact the SDK cannot know. Fall back to the actual
- // browser request in that case. A genuine local-dev endpoint is loopback too,
- // and its request origin is the same host, so this stays correct in dev. Once
- // the render carries a real non-loopback ingress host, that host keeps
- // priority over the caller-influenced request origin.
- const publicOrigin = isLoopbackOrigin(endpointOrigin)
- ? canonicalOrigin(requestOrigin)
- : endpointOrigin;
- if (!publicOrigin) return undefined;
-
- return { internalToken, publicOrigin };
+ return canonicalOrigin(matches[0].address);
}
diff --git a/module/services/frontend/code/src/lib/public-origin.ts b/module/services/frontend/code/src/lib/public-origin.ts
index 8592f9456..61b17859b 100644
--- a/module/services/frontend/code/src/lib/public-origin.ts
+++ b/module/services/frontend/code/src/lib/public-origin.ts
@@ -1,20 +1,22 @@
+import "server-only";
+
+import { resolveVerifiedPublicOrigin } from "@/lib/codefly-gateway-context";
+
/**
- * The public browser origin of a request, preferring the ingress-set forwarded
- * pair over the pod-local request URL โ the same resolution src/proxy.ts uses,
- * because behind a TLS-terminating ingress the pod sees plaintext `http` and its
- * own host.
+ * The origin this deployment treats as its own, from operator configuration.
+ *
+ * It used to be derived from the request โ the ingress-set forwarded pair, or the
+ * pod-local URL โ which made it whatever a caller said: a request bearing
+ * `X-Forwarded-Host: evil.example` was answered as though the product were served
+ * there, and the solution proxy compared the browser's `Origin` against it, so a
+ * cross-site request that supplied both passed the same-origin check. The origin
+ * now comes from `application/APP_BASE_URL`, or from a render that carries a real
+ * ingress host, and from nothing else; see
+ * `resolveVerifiedPublicOrigin`, which refuses rather than guessing.
+ *
+ * Returns undefined when this deployment has no verified public origin, which
+ * every caller must treat as a refusal.
*/
-export function requestPublicOrigin(request: Request): string {
- const url = new URL(request.url);
- const forwardedProto = request.headers
- .get("x-forwarded-proto")
- ?.split(",")[0]
- ?.trim();
- const forwardedHost = request.headers
- .get("x-forwarded-host")
- ?.split(",")[0]
- ?.trim();
- const protocol = forwardedProto ? `${forwardedProto}:` : url.protocol;
- const host = forwardedHost || url.host;
- return `${protocol}//${host}`;
+export function configuredPublicOrigin(): string | undefined {
+ return resolveVerifiedPublicOrigin();
}
diff --git a/module/services/frontend/code/src/proxy.ts b/module/services/frontend/code/src/proxy.ts
index 96b7eeb4f..baa9b173c 100644
--- a/module/services/frontend/code/src/proxy.ts
+++ b/module/services/frontend/code/src/proxy.ts
@@ -99,26 +99,6 @@ function isProductAPI(pathname: string): boolean {
return oauthSurfaceUpstreamPath(pathname) !== undefined;
}
-// Next derives `nextUrl`'s protocol and host from the internal request URL and
-// ignores `x-forwarded-*`, so behind a TLS-terminating ingress `nextUrl.protocol`
-// is the pod's plaintext `http:` rather than the browser's `https:` โ and
-// Accounts rejects a non-loopback `http` public origin, which would leave OAuth
-// broken. The ingress sets the forwarded pair from the real client connection;
-// prefer each, falling back to `nextUrl` (local dev, direct-to-pod requests).
-export function publicRequestOrigin(req: NextRequest): string {
- const forwardedProto = req.headers
- .get("x-forwarded-proto")
- ?.split(",")[0]
- ?.trim();
- const forwardedHost = req.headers
- .get("x-forwarded-host")
- ?.split(",")[0]
- ?.trim();
- const protocol = forwardedProto ? `${forwardedProto}:` : req.nextUrl.protocol;
- const host = forwardedHost || req.nextUrl.host;
- return `${protocol}//${host}`;
-}
-
export function trustedGatewayRequestHeaders(
req: NextRequest,
context: CodeflyGatewayContext | undefined,
@@ -659,7 +639,7 @@ export async function proxy(req: NextRequest) {
const { pathname, search } = req.nextUrl;
const gatewayHeaders = trustedGatewayRequestHeaders(
req,
- resolveCodeflyGatewayContext(publicRequestOrigin(req)),
+ resolveCodeflyGatewayContext(),
);
// The CSP lookup needs the cluster-internal secret and nothing else, so it
// reads that secret rather than the gateway context, whose resolution also
diff --git a/module/services/frontend/code/src/test/pipeline-gateway.ts b/module/services/frontend/code/src/test/pipeline-gateway.ts
index 0a041cac6..ee1b29ed5 100644
--- a/module/services/frontend/code/src/test/pipeline-gateway.ts
+++ b/module/services/frontend/code/src/test/pipeline-gateway.ts
@@ -1,3 +1,10 @@
+import {
+ getCurrentModule,
+ getCurrentService,
+ getEndpoints,
+ getWorkspaceConfiguration,
+ getWorkspaceSecret,
+} from "codefly";
import { NextRequest } from "next/server";
import {
type CodeflyRuntimeReader,
@@ -6,12 +13,6 @@ import {
import { INTERNAL_TOKEN_HEADER } from "@/lib/internal-token";
import { trustedGatewayRequestHeaders } from "@/proxy";
import { productOrigin } from "@/test/codefly-endpoints";
-import {
- getCurrentModule,
- getCurrentService,
- getEndpoints,
- getWorkspaceSecret,
-} from "codefly";
// Endpoint resolution lives in one place, shared with the Playwright config and
// the e2e global setup: see ./codefly-endpoints.
@@ -30,6 +31,10 @@ const gatewayRuntimeSDK: CodeflyRuntimeReader = {
currentService: getCurrentService,
endpoints: getEndpoints,
workspaceSecret: getWorkspaceSecret,
+ workspaceConfiguration: getWorkspaceConfiguration,
+ // A pipeline test runs against a local graph, whose own endpoint is the real
+ // loopback address rather than a render's placeholder.
+ isDeployedBuild: () => false,
};
export function runtimeWithResolvedOrigin(
@@ -78,7 +83,7 @@ export function runtimeWithResolvedOrigin(
export function stampedGatewayHeaders(): Record {
const origin = productOrigin();
const runtime = runtimeWithResolvedOrigin(origin);
- const context = resolveCodeflyGatewayContext(origin, runtime);
+ const context = resolveCodeflyGatewayContext(runtime);
if (!context) {
throw new Error(
"Codefly did not provide the frontend gateway context (internal-auth secret and frontend/http endpoint).",
From db75f2e4cfd16f303024300e91935bf3eb5a5a39 Mon Sep 17 00:00:00 2001
From: Antoine Toussaint
<312536060+antoinetoussaint-byte@users.noreply.github.com>
Date: Mon, 5 Oct 2026 03:42:31 -0400
Subject: [PATCH 12/46] fix(frontend, marketing): the product and marketing
origins pin HTTPS (SA-F-HSTS)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The edge redirects plaintext to TLS, which protects the redirect and nothing
before it: a browser's FIRST request to a host it has never pinned goes out in the
clear, carrying whatever the address bar or a link gave it, and an attacker on the
path answers it. The origin has to tell the browser to remember, and neither
origin did.
Both now emit Strict-Transport-Security with a one-year max-age, in the frontend's
baseline header set (so every route carries it, like the framing and nosniff
headers beside it) and in the marketing site's own header block.
Deliberately WITHOUT includeSubDomains or preload, with the reason recorded beside
the value: both commit hosts this service does not own โ a sibling subdomain served
over plaintext breaks under the first, and the second is effectively irreversible โ
so they are the ingress operator's to add once they know the host tree. Their
absence is asserted, so it reads as a decision rather than an omission. Browsers
ignore the header on a plaintext origin, so local development is unaffected.
The e2e security-headers spec asserts it on real responses alongside the framing
and CSP checks it already made. The infrastructure repository owns emitting it at
the ingress if an operator prefers it there; emitting it from both is harmless
(the stricter policy wins) and this is the half that holds whatever fronts the
service.
Also records, in module/services/frontend/AGENTS.md, what SA-F-REMOTECODE asks for
and why this pull request does not do it: nothing pins what a cross-origin manifest
serves, and closing that means either dropping the cross-origin-remote shape (and
the runtime origin derivation, its listing fetch, cache, outage reporting and
policy-size guard, which exist only to carry foreign origins โ 17 tests' worth of
covered behaviour) or adding a publication-time digest the registration contract
does not have. Both are decisions about the solution contract rather than defects
to repair, and the register's own note is that the live policy lists no foreign
origin and every deployed remote is same-origin today. Left for its own change
with the owner's decision, rather than taken unilaterally inside a security pull
request.
Refs #1018
Co-Authored-By: Claude Opus 5 (1M context)
---
.../accounts/code/shipped_placeholder_test.go | 46 +++++++++++++++++++
module/services/frontend/AGENTS.md | 22 +++++++++
.../frontend/code/server/security-headers.mjs | 16 +++++++
.../code/src/test/security-headers.test.ts | 25 ++++++++++
.../code/tests/e2e/security-headers.spec.ts | 3 ++
.../services/marketing/code/next.config.mjs | 6 +++
6 files changed, 118 insertions(+)
diff --git a/module/services/accounts/code/shipped_placeholder_test.go b/module/services/accounts/code/shipped_placeholder_test.go
index 8b2d33f1a..3c411fbcd 100644
--- a/module/services/accounts/code/shipped_placeholder_test.go
+++ b/module/services/accounts/code/shipped_placeholder_test.go
@@ -75,6 +75,8 @@ func TestPerimeterCredentialsCoverEveryTrustDecidingKey(t *testing.T) {
// origin and every emailed link were bound to whatever origin the request's trusted
// hop forwarded โ which that hop derived from the caller's own forwarding header.
func TestDeployedRequiresTheApplicationBaseURL(t *testing.T) {
+ blankWorkspaceKey(t, "application", "APP_BASE_URL")
+
err := requireApplicationBaseURL(false)
require.Error(t, err, "a deployed runtime must refuse an unset APP_BASE_URL")
require.Contains(t, err.Error(), "APP_BASE_URL",
@@ -83,3 +85,47 @@ func TestDeployedRequiresTheApplicationBaseURL(t *testing.T) {
require.NoError(t, requireApplicationBaseURL(true),
"local development pins no runtime port")
}
+
+// Every perimeter credential and the application base URL are refused when a
+// deployed runtime holds a placeholder, which is also what the shipped local
+// defaults hold โ so a test that drives these reads has to blank BOTH carriers.
+// `codefly ci run` injects this module's own local groups as
+// CODEFLY__WORKSPACE_CONFIGURATION____ (and the secret form), while a
+// bare `go test` injects none and only the plain fallback can carry a stray value.
+// Blanking one passes locally and fails in CI, or the reverse.
+func blankWorkspaceKey(t *testing.T, group, key string) {
+ t.Helper()
+ upper := strings.ToUpper(group)
+ upper = strings.ReplaceAll(upper, "-", "_")
+ t.Setenv(key, "")
+ t.Setenv("CODEFLY__WORKSPACE_CONFIGURATION__"+upper+"__"+key, "")
+ t.Setenv("CODEFLY__WORKSPACE_SECRET_CONFIGURATION__"+upper+"__"+key, "")
+}
+
+// The deployed perimeter check over the REAL carriers, not only over the pure
+// comparison: with the shipped local default in the group a deployed runtime
+// refuses, and with a provisioned value it starts.
+func TestDeployedPerimeterCheckReadsTheConfigurationGroup(t *testing.T) {
+ for _, key := range []string{"CODEFLY_INTERNAL_TOKEN", "CODEFLY_INTERNAL_TOKEN_PREVIOUS"} {
+ blankWorkspaceKey(t, "internal-auth", key)
+ }
+ for _, key := range []string{"CODEFLY_GATEWAY_TOKEN", "CODEFLY_GATEWAY_TOKEN_PREVIOUS"} {
+ blankWorkspaceKey(t, "gateway-trust", key)
+ }
+
+ // Nothing provisioned: each loader's own required-or-optional question, not
+ // this one's.
+ require.NoError(t, requirePerimeterCredentials(false))
+
+ // The value this module ships in its public local defaults.
+ t.Setenv("CODEFLY__WORKSPACE_SECRET_CONFIGURATION__INTERNAL_AUTH__CODEFLY_INTERNAL_TOKEN",
+ "local-dev-only-replace-me")
+ err := requirePerimeterCredentials(false)
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "internal-auth/CODEFLY_INTERNAL_TOKEN")
+
+ // A provisioned one.
+ t.Setenv("CODEFLY__WORKSPACE_SECRET_CONFIGURATION__INTERNAL_AUTH__CODEFLY_INTERNAL_TOKEN",
+ strings.Repeat("7Kq2Xp9Vb4Nf", 4))
+ require.NoError(t, requirePerimeterCredentials(false))
+}
diff --git a/module/services/frontend/AGENTS.md b/module/services/frontend/AGENTS.md
index 00759fb49..ffac780fe 100644
--- a/module/services/frontend/AGENTS.md
+++ b/module/services/frontend/AGENTS.md
@@ -145,6 +145,28 @@ A solution remote executes in the host origin with the viewer's credentials; the
trust model and the registration/installation/entitlement boundary are in
[../../SOLUTION_REGISTRATION.md](../../SOLUTION_REGISTRATION.md).
+**Open, and recorded here so the next reader does not have to rediscover it.**
+Nothing pins what a cross-origin manifest serves. An absolute URL on any origin is
+accepted, admitting it adds that origin to the script source, and there is no
+integrity digest โ so a holder of a solution's registration credential can point a
+registered remote at code of its choosing and have it execute in the host origin
+with the viewer's session. The audit raised this as a minor finding because the
+live policy lists no foreign origin and every deployed remote is same-origin today,
+which bounds it to the credential-to-code path.
+
+Closing it means one of two things, and both are decisions about the solution
+contract rather than a defect to repair:
+
+- **Accept only root-relative manifests.** This removes the cross-origin-remote
+ shape described above, and with it the runtime origin derivation in
+ `src/proxy.ts` โ the listing fetch, its cache, its outage reporting and the
+ policy-size guard exist only to carry foreign origins.
+- **Pin each remote by a digest recorded when it was published.** This keeps the
+ shape and needs a publication-time provenance record the registration contract
+ does not have.
+
+Either is its own change, with the owner choosing which shape the contract keeps.
+
## The published client kit
`code/packages/saas-sdk` is `@codefly-dev/saas-sdk`, published to GitHub Packages
diff --git a/module/services/frontend/code/server/security-headers.mjs b/module/services/frontend/code/server/security-headers.mjs
index 04c42867d..7b8825d36 100644
--- a/module/services/frontend/code/server/security-headers.mjs
+++ b/module/services/frontend/code/server/security-headers.mjs
@@ -267,6 +267,22 @@ export function contentSecurityPolicy(
export function baselineSecurityHeaders() {
return [
{ key: "Cross-Origin-Opener-Policy", value: "same-origin" },
+ // Pin the browser to HTTPS for this origin. The edge redirects plaintext to
+ // TLS, which protects the redirect and nothing before it: the FIRST request a
+ // browser makes to a host it has never pinned goes out in the clear, with
+ // whatever the address bar or a link gave it, and an attacker on the path
+ // answers it. One year is the conventional floor for a policy a browser is
+ // asked to remember.
+ //
+ // Deliberately WITHOUT includeSubDomains or preload. Both commit hosts this
+ // service does not own โ a sibling subdomain served over plaintext breaks
+ // under the first, and the second is effectively irreversible โ so they are
+ // the ingress operator's to add once they know the host tree. Browsers ignore
+ // the header on a plaintext origin, so local development is unaffected.
+ {
+ key: "Strict-Transport-Security",
+ value: "max-age=31536000",
+ },
{
key: "Permissions-Policy",
value: "camera=(), microphone=(), geolocation=()",
diff --git a/module/services/frontend/code/src/test/security-headers.test.ts b/module/services/frontend/code/src/test/security-headers.test.ts
index c150fad4b..ab7499adb 100644
--- a/module/services/frontend/code/src/test/security-headers.test.ts
+++ b/module/services/frontend/code/src/test/security-headers.test.ts
@@ -31,6 +31,30 @@ describe("securityHeaders", () => {
"frame-ancestors 'none'",
);
});
+
+ // The edge redirects plaintext to TLS, which protects the redirect and nothing
+ // before it: a browser's first request to a host it has never pinned goes out in
+ // the clear and an attacker on the path answers it.
+ it("pins the browser to HTTPS for a year", () => {
+ const byKey = Object.fromEntries(
+ securityHeaders({}).map((h) => [h.key, h.value]),
+ );
+ expect(byKey["Strict-Transport-Security"]).toBe("max-age=31536000");
+ });
+
+ // includeSubDomains and preload commit hosts this service does not own: a
+ // sibling subdomain on plaintext breaks under the first, and the second is
+ // effectively irreversible. Both are the ingress operator's to add, so their
+ // absence here is a decision rather than an omission.
+ it("commits no host but its own", () => {
+ const byKey = Object.fromEntries(
+ securityHeaders({}).map((h) => [h.key, h.value]),
+ );
+ expect(byKey["Strict-Transport-Security"]).not.toContain(
+ "includeSubDomains",
+ );
+ expect(byKey["Strict-Transport-Security"]).not.toContain("preload");
+ });
});
describe("baselineSecurityHeaders", () => {
@@ -41,6 +65,7 @@ describe("baselineSecurityHeaders", () => {
expect(byKey["X-Frame-Options"]).toBe("DENY");
expect(byKey["X-Content-Type-Options"]).toBe("nosniff");
expect(byKey["Cross-Origin-Opener-Policy"]).toBe("same-origin");
+ expect(byKey["Strict-Transport-Security"]).toBe("max-age=31536000");
expect(byKey["Content-Security-Policy"]).toBeUndefined();
});
});
diff --git a/module/services/frontend/code/tests/e2e/security-headers.spec.ts b/module/services/frontend/code/tests/e2e/security-headers.spec.ts
index 963c051bd..9c2127b9b 100644
--- a/module/services/frontend/code/tests/e2e/security-headers.spec.ts
+++ b/module/services/frontend/code/tests/e2e/security-headers.spec.ts
@@ -11,6 +11,9 @@ function cspOf(response: Awaited>): string {
const headers = response?.headers() ?? {};
expect(headers["x-frame-options"]).toBe("DENY");
expect(headers["x-content-type-options"]).toBe("nosniff");
+ // The edge's plaintext redirect protects the redirect and nothing before it, so
+ // the origin pins the browser itself.
+ expect(headers["strict-transport-security"]).toContain("max-age=");
const csp = headers["content-security-policy"];
expect(csp, "response is missing a CSP").toBeDefined();
return csp as string;
diff --git a/module/services/marketing/code/next.config.mjs b/module/services/marketing/code/next.config.mjs
index 8939a9f74..86f75e1b7 100644
--- a/module/services/marketing/code/next.config.mjs
+++ b/module/services/marketing/code/next.config.mjs
@@ -26,6 +26,12 @@ const nextConfig = {
headers: [
{ key: "Content-Security-Policy", value: contentSecurityPolicy },
{ key: "Cross-Origin-Opener-Policy", value: "same-origin" },
+ // Pin the browser to HTTPS. The edge redirects plaintext to TLS, which
+ // protects the redirect and nothing before it: a browser's first request
+ // to a host it has never pinned goes out in the clear. Without
+ // includeSubDomains or preload, which commit hosts this site does not
+ // own; browsers ignore the header on a plaintext origin.
+ { key: "Strict-Transport-Security", value: "max-age=31536000" },
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "X-Content-Type-Options", value: "nosniff" },
From 26a031ffd70360ecbd101466f6c3c9006b968f89 Mon Sep 17 00:00:00 2001
From: Antoine Toussaint
<312536060+antoinetoussaint-byte@users.noreply.github.com>
Date: Mon, 5 Oct 2026 03:49:40 -0400
Subject: [PATCH 13/46] fix: the identity provider's own credentials live in a
group only accounts declares (SA-F-IDPKEY, SA-F-GWTOKEN this repo's half)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A declared configuration group is injected whether the service reads it or not, so
a credential's blast radius is the set of services that DECLARE its group. Which
group a credential lives in is therefore the security decision, and two findings
are the same mistake about it.
SA-F-IDPKEY. The frontend declares `identity` because it renders the login page
from that group's public facts โ the issuer, the client id, the display name, the
scope, all of which the browser receives anyway. A deployment that puts the
identity provider's own credentials in that same group hands them to the frontend
too: a public-facing process holding the provider's administration credential and
client secret, reading neither, which widens what a frontend compromise is worth
for nothing.
IDENTITY_CLIENT_SECRET and IDENTITY_MANAGEMENT_API_KEY now live in a new
`identity-provider` secret group that only accounts declares. The read falls back
to `identity` while a cell still delivers them there, and warns once per key naming
the move: which group a value arrives under is the composition's to change, and a
hard cutover would take sign-in down on every cell between this landing and that
edit. The warning is how an operator learns a cell has not moved; the fallback goes
when they have.
SA-F-GWTOKEN, this repository's half. The finding is that the gateway-provenance
credential โ which makes accounts believe a forwarded identity, so a holder can
assert any user โ is bundled with the internal token and delivered to twelve
workloads that never read it. The delivery is the platform repository's: the secret
group, and the twelve external secrets rendered from it. What this repository owes
is that its own composition cannot express the bundling, and that the trust site
reads only the narrow group โ which it already did, unheld by anything.
module/tools/credential_group_scope_test.go now holds both: which services may
declare each credential-bearing group (`gateway-trust` only the identity plane,
`identity-provider`, `github-app` and `vault` only accounts), and that no group
other than `gateway-trust` declares a GATEWAY_TOKEN key. The second is the other
way a credential spreads โ not a service declaring a group it does not read, but
the key appearing in a second group nearly everything declares, under a name that
makes it look like the same credential.
Both gates are mutation-checked, and the first fails on exactly the live shape:
adding `identity` to the credential list names the frontend. Each refuses an empty
holder set too, so a group going dead cannot leave the gate proving nothing.
module/AGENTS.md records the rule and why the identity group was split.
Refs #1018
Co-Authored-By: Claude Opus 5 (1M context)
---
.../local/identity-provider.secret.env | 1 +
module/AGENTS.md | 14 ++
.../local/identity-provider.secret.env | 21 +++
module/services/accounts/code/work.go | 46 +++++-
module/services/accounts/service.codefly.yaml | 1 +
module/tools/credential_group_scope_test.go | 146 ++++++++++++++++++
6 files changed, 225 insertions(+), 4 deletions(-)
create mode 120000 configurations/local/identity-provider.secret.env
create mode 100644 module/configurations/local/identity-provider.secret.env
create mode 100644 module/tools/credential_group_scope_test.go
diff --git a/configurations/local/identity-provider.secret.env b/configurations/local/identity-provider.secret.env
new file mode 120000
index 000000000..70bf7a7cd
--- /dev/null
+++ b/configurations/local/identity-provider.secret.env
@@ -0,0 +1 @@
+../../module/configurations/local/identity-provider.secret.env
\ No newline at end of file
diff --git a/module/AGENTS.md b/module/AGENTS.md
index 84f1d83b2..1f33fd39b 100644
--- a/module/AGENTS.md
+++ b/module/AGENTS.md
@@ -97,6 +97,20 @@ consumer workspace โ `legal`, `identity`, `internal-auth`, `federation`,
`module-capabilities`, and the rest. A consumer overrides a group only by
declaring one of the same name.
+**A credential's blast radius is the set of services that DECLARE its group**, not
+the set that reads it: a declared group is injected whether anything reads it or
+not. So which group a credential lives in is the security decision, and
+`module/tools/credential_group_scope_test.go` holds both halves of it โ which
+services may declare each credential-bearing group, and that the
+gateway-provenance key appears in no group but `gateway-trust`.
+
+That is why the identity provider's own credentials live in `identity-provider`
+rather than in `identity`: the frontend declares `identity` to render the login
+page from its public facts (issuer, client id, display name, scope), so a
+deployment putting the provider's client secret and management key there hands a
+public-facing process credentials it never reads. Only accounts declares
+`identity-provider`.
+
**Do not generalise about how an unset value behaves โ read the group.** The three
digest maps in `federation` (`MODULE_REGISTRATION_SECRETS`,
`MODULE_IDENTITY_SECRETS`, `SOLUTION_REGISTRATION_SECRETS`) each fail closed when
diff --git a/module/configurations/local/identity-provider.secret.env b/module/configurations/local/identity-provider.secret.env
new file mode 100644
index 000000000..1c013f6af
--- /dev/null
+++ b/module/configurations/local/identity-provider.secret.env
@@ -0,0 +1,21 @@
+# The identity provider's own credentials: the ones that authenticate THIS HOST to
+# the provider, as distinct from the `identity` group's public facts about it (the
+# issuer, the client id, the display name, the scope) which the browser receives
+# anyway.
+#
+# They live in a group of their own because of who declares what. The frontend
+# declares `identity` โ it renders the login page from those public facts โ and a
+# deployment that puts the provider's credentials in that same group delivers them
+# to the frontend too, which never reads either. A public-facing process holding
+# the provider's administration credential widens what a frontend compromise is
+# worth, for nothing. Only accounts declares this group.
+
+# The client secret the authorization-code exchange authenticates with. Required
+# by every provider that performs that exchange; a real value is supplied through
+# Codefly's secret configuration provider.
+IDENTITY_CLIENT_SECRET=local-dev-only-replace-me
+
+# The provider's management credential, used by the optional administration portal
+# adapter. Unset leaves that adapter in stub mode; it is never required for
+# sign-in.
+IDENTITY_MANAGEMENT_API_KEY=local-dev-only-replace-me
diff --git a/module/services/accounts/code/work.go b/module/services/accounts/code/work.go
index fc5a1e97c..1e836032b 100644
--- a/module/services/accounts/code/work.go
+++ b/module/services/accounts/code/work.go
@@ -35,6 +35,7 @@ import (
"os"
"strconv"
"strings"
+ "sync"
"time"
"github.com/codefly-dev/core/wool"
@@ -660,7 +661,7 @@ func doWork(ctx context.Context) (Clean, error) {
// SSO administration is a WorkOS-specific optional adapter. Other
// identity providers cannot accidentally activate it by exposing a
// similarly named management credential.
- service.SetSSOManagementAPIKey(identityEnv("IDENTITY_MANAGEMENT_API_KEY"))
+ service.SetSSOManagementAPIKey(identityProviderSecret("IDENTITY_MANAGEMENT_API_KEY"))
}
oauthPolicy, err := buildOAuthRequestPolicy(authProvider)
if err != nil {
@@ -2015,6 +2016,43 @@ func identityEnv(key string) string {
return value
}
+// identityProviderSecret reads a credential that authenticates THIS HOST to the
+// identity provider, from the `identity-provider` group only accounts declares.
+//
+// They used to live in `identity`, which the frontend declares too โ it renders
+// the login page from that group's public facts, the issuer and the client id โ
+// so a deployment putting the provider's credentials there delivered them to the
+// frontend as well, which reads neither. A public-facing process holding the
+// provider's administration credential widens what a frontend compromise is worth,
+// for nothing.
+//
+// The read falls back to `identity` while a deployment still holds them there, and
+// says so once per key: the group a value is delivered under is the composition's
+// to change, and a hard cutover here would take sign-in down on every cell between
+// this landing and that edit. The fallback goes when the cells have moved โ until
+// then, the warning is how an operator knows one has not.
+func identityProviderSecret(key string) string {
+ if value, err := codefly.For(codefly.Context()).WorkspaceValue("identity-provider", key); err == nil && hasConfiguredValue(value) {
+ return value
+ }
+ legacy := identityEnv(key)
+ if hasConfiguredValue(legacy) {
+ reportIdentityProviderSecretStillInIdentityGroup(key)
+ }
+ return legacy
+}
+
+var identityProviderSecretWarned sync.Map
+
+func reportIdentityProviderSecretStillInIdentityGroup(key string) {
+ if _, loaded := identityProviderSecretWarned.LoadOrStore(key, true); loaded {
+ return
+ }
+ wool.Get(context.Background()).In("identityProviderSecret").Warn(
+ "an identity-provider credential is still delivered under the `identity` group, which the frontend also declares; move it to `identity-provider`, which only this service declares",
+ wool.Field("key", key))
+}
+
// applicationEnv is also Codefly-only. Local product origins and bootstrap
// identity must come from the selected workspace configuration, never from an
// ambient shell file that can disagree with the browser runtime.
@@ -2133,7 +2171,7 @@ func buildGenericOIDCStack(provider string) (auth.TokenValidator, business.CodeE
// two can never disagree at login.
func discoverOIDCValidator(provider string, defaultAudienceToClientID bool) (validator auth.TokenValidator, tokenURL, clientID, clientSecret string, err error) {
clientID = identityEnv("IDENTITY_CLIENT_ID")
- clientSecret = identityEnv("IDENTITY_CLIENT_SECRET")
+ clientSecret = identityProviderSecret("IDENTITY_CLIENT_SECRET")
issuer := identityEnv("IDENTITY_ISSUER")
if !hasConfiguredValue(clientID) || !hasConfiguredValue(clientSecret) {
return nil, "", "", "", fmt.Errorf(
@@ -2237,7 +2275,7 @@ func buildProviderStack(provider, selectedFixture string) (auth.TokenValidator,
domain := identityEnv("IDENTITY_DOMAIN")
audience := identityEnv("IDENTITY_AUDIENCE")
clientID := identityEnv("IDENTITY_CLIENT_ID")
- clientSecret := identityEnv("IDENTITY_CLIENT_SECRET")
+ clientSecret := identityProviderSecret("IDENTITY_CLIENT_SECRET")
if !hasConfiguredValue(domain) || !hasConfiguredValue(clientID) || !hasConfiguredValue(clientSecret) || !hasConfiguredValue(audience) {
return nil, nil, fmt.Errorf("identity provider auth0 requires IDENTITY_DOMAIN, IDENTITY_AUDIENCE, IDENTITY_CLIENT_ID, and IDENTITY_CLIENT_SECRET")
}
@@ -2257,7 +2295,7 @@ func buildProviderStack(provider, selectedFixture string) (auth.TokenValidator,
case "google":
clientID := identityEnv("IDENTITY_CLIENT_ID")
- clientSecret := identityEnv("IDENTITY_CLIENT_SECRET")
+ clientSecret := identityProviderSecret("IDENTITY_CLIENT_SECRET")
if !hasConfiguredValue(clientID) || !hasConfiguredValue(clientSecret) {
return nil, nil, fmt.Errorf("identity provider google requires IDENTITY_CLIENT_ID and IDENTITY_CLIENT_SECRET")
}
diff --git a/module/services/accounts/service.codefly.yaml b/module/services/accounts/service.codefly.yaml
index bd698f029..0e067575f 100644
--- a/module/services/accounts/service.codefly.yaml
+++ b/module/services/accounts/service.codefly.yaml
@@ -35,6 +35,7 @@ workspace-configuration-dependencies:
- gateway-trust
- github-app
- identity
+ - identity-provider
- internal-auth
- internal-transport
- key-service
diff --git a/module/tools/credential_group_scope_test.go b/module/tools/credential_group_scope_test.go
new file mode 100644
index 000000000..2b5fa5f5a
--- /dev/null
+++ b/module/tools/credential_group_scope_test.go
@@ -0,0 +1,146 @@
+package tools
+
+import (
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "testing"
+)
+
+// A service is delivered every group it declares, whether it reads it or not.
+// So a credential's blast radius is the set of services that declare the group
+// it travels in โ which is a fact about the MANIFESTS, not about who calls what.
+//
+// The frontend is a public-facing process. It declares `identity` because it
+// renders the login page from that group's public facts: the issuer, the client
+// id, the display name, the scope. A deployment that puts the identity provider's
+// own credentials in that same group therefore hands them to the frontend too,
+// which reads neither โ and a public process holding the provider's administration
+// credential widens what a frontend compromise is worth, for nothing.
+//
+// The answer is which GROUP a credential lives in, so that is what this holds.
+// Every group listed here carries credentials, and this says which services may
+// declare it. A group added to a service's manifest and not here passes; a
+// credential group reaching a service not listed fails, named.
+var credentialGroupHolders = map[string][]string{
+ // The provider credentials that authenticate this host TO the identity
+ // provider. accounts performs the authorization-code exchange and runs the
+ // optional administration adapter; nothing else reads either.
+ "identity-provider": {"accounts"},
+ // The credential accounts believes forwarded identity on. Only the identity
+ // plane โ the service that stamps it and the service that trusts it.
+ "gateway-trust": {"accounts", "auth-gateway"},
+ // The GitHub App's signing key and webhook secret: deployment custody, read
+ // only by accounts.
+ "github-app": {"accounts"},
+ // The cell's Vault credentials.
+ "vault": {"accounts"},
+}
+
+func TestACredentialGroupIsDeclaredOnlyByServicesThatReadIt(t *testing.T) {
+ declaredBy := map[string][]string{}
+ for _, service := range servicesWithManifests(t) {
+ for group := range declaredWorkspaceGroups(t, service) {
+ declaredBy[group] = append(declaredBy[group], service)
+ }
+ }
+
+ for group, allowed := range credentialGroupHolders {
+ holders := declaredBy[group]
+ sort.Strings(holders)
+ permitted := map[string]bool{}
+ for _, service := range allowed {
+ permitted[service] = true
+ }
+ for _, service := range holders {
+ if !permitted[service] {
+ t.Errorf("service %q declares the credential group %q, which carries credentials it does not read; "+
+ "a declared group is delivered whether it is read or not, so this widens what a compromise of %s is worth. "+
+ "Permitted holders: %s",
+ service, group, service, strings.Join(allowed, ", "))
+ }
+ }
+ if len(holders) == 0 {
+ t.Errorf("no service declares the credential group %q; either it is dead and should be deleted, "+
+ "or a service reads it undeclared and gets nothing in every deployed cell", group)
+ }
+ }
+}
+
+// servicesWithManifests lists every service of this module, so the gate above
+// cannot be satisfied by a service it forgot to look at.
+func servicesWithManifests(t *testing.T) []string {
+ t.Helper()
+ base := filepath.Join(findModuleDir(t), "services")
+ entries, err := os.ReadDir(base)
+ if err != nil {
+ t.Fatalf("read %s: %v", base, err)
+ }
+ var services []string
+ for _, entry := range entries {
+ if !entry.IsDir() {
+ continue
+ }
+ if _, statErr := os.Stat(filepath.Join(base, entry.Name(), "service.codefly.yaml")); statErr != nil {
+ continue
+ }
+ services = append(services, entry.Name())
+ }
+ if len(services) == 0 {
+ t.Fatal("no service manifests found; this gate now proves nothing")
+ }
+ sort.Strings(services)
+ return services
+}
+
+// The gateway-provenance credential makes accounts believe a forwarded identity,
+// so a holder of it can assert any user to accounts. The gate above keeps the
+// GROUP to the identity plane; this keeps the KEY to that group.
+//
+// The defect it closes is the other way a credential spreads: not a service
+// declaring a group it does not read, but a key appearing in a second group that
+// many services do read. A `CODEFLY_GATEWAY_TOKEN` in `internal-auth` reaches
+// every service that declares the internal token, which is nearly all of them, and
+// the name alone makes it look like the same credential the gateway stamps โ so a
+// composition can be built where it is, and accounts then believes forwarded
+// identity from anything that holds it.
+func TestTheGatewayCredentialKeyAppearsInNoOtherGroup(t *testing.T) {
+ moduleDir := findModuleDir(t)
+ base := filepath.Join(moduleDir, "configurations", "local")
+ entries, err := os.ReadDir(base)
+ if err != nil {
+ t.Fatalf("read %s: %v", base, err)
+ }
+
+ found := 0
+ for _, entry := range entries {
+ if entry.IsDir() {
+ continue
+ }
+ group := strings.TrimSuffix(strings.TrimSuffix(entry.Name(), ".env"), ".secret")
+ data, readErr := os.ReadFile(filepath.Join(base, entry.Name()))
+ if readErr != nil {
+ t.Fatalf("read %s: %v", entry.Name(), readErr)
+ }
+ for _, line := range strings.Split(string(data), "\n") {
+ key, _, assigned := strings.Cut(strings.TrimSpace(line), "=")
+ if !assigned || strings.HasPrefix(key, "#") {
+ continue
+ }
+ if !strings.Contains(key, "GATEWAY_TOKEN") {
+ continue
+ }
+ found++
+ if group != "gateway-trust" {
+ t.Errorf("the group %q declares %q: the gateway-provenance credential belongs to `gateway-trust`, "+
+ "which only the identity plane declares. In any other group it reaches every service that "+
+ "declares that group, and accounts then believes forwarded identity from each of them",
+ group, key)
+ }
+ }
+ }
+ if found == 0 {
+ t.Fatal("no GATEWAY_TOKEN key found in any shipped default; the key was renamed and this gate now proves nothing")
+ }
+}
From 93fb5f3f71a134851ed1c98deb0873198a4c8c4d Mon Sep 17 00:00:00 2001
From: Antoine Toussaint
<312536060+antoinetoussaint-byte@users.noreply.github.com>
Date: Mon, 5 Oct 2026 03:54:47 -0400
Subject: [PATCH 14/46] fix(accounts): the datasource message-authentication
keys are delivered per purpose, not derived from the perimeter credential
(SA-F-KEYREUSE this repo's half)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The datasource content-ticket key and the account-link state key were both derived
from CODEFLY_INTERNAL_TOKEN โ the cell-wide perimeter credential every service and
composed module holds. Two consequences, neither visible from either end: every
holder of that credential could compute both keys and forge what they
authenticate, and rotating the perimeter credential silently invalidated every
outstanding content ticket and link state, a blast radius nothing recorded.
Both now come from a new `datasource-keys` secret group that only accounts
declares, one delivered key per purpose, so rotating one rotates one thing.
An absent key leaves its purpose unavailable rather than falling back to a
derivation. A key reachable from a credential thirteen workloads hold is not a key,
and a fallback would make the fix conditional on nobody forgetting โ so the
operator step is real: a cell must provision both keys before its datasource paths
work again, and until it does, minting a content ticket is refused rather than
signed with something weaker.
The link key stays domain-separated from whatever value it is given, so a content
ticket and a link state can never pass for each other even where an operator
provisions one value for both.
Tests: an absent key in either direction leaves its purpose unavailable and does
not borrow the other's, and one provisioned value for both still yields two
distinct keys. The pre-existing unset-key test is kept and widened rather than
replaced, so what it caught before is still caught.
NOT this repository's, and named rather than left implied: one Vault transit key
still serves every sealing purpose, which the key-service work owns, and the
documents module derives its collection-cursor key from the same internal token in
its own tree.
module/KEY_ROTATION.md gains the section stating the rule, what it costs to rotate
one of these, and what is still outstanding.
Refs #1018
Co-Authored-By: Claude Opus 5 (1M context)
---
.../local/datasource-keys.secret.env | 1 +
module/KEY_ROTATION.md | 28 +++++++++++
.../local/datasource-keys.secret.env | 22 +++++++++
.../datasource_content_ticket_test.go | 46 +++++++++++++++----
.../business/datasource_deliveries_test.go | 2 +-
.../pkg/business/datasource_directory_test.go | 2 +-
.../accounts/code/pkg/business/datasources.go | 37 ++++++++++-----
module/services/accounts/code/work.go | 7 ++-
module/services/accounts/service.codefly.yaml | 1 +
9 files changed, 123 insertions(+), 23 deletions(-)
create mode 120000 configurations/local/datasource-keys.secret.env
create mode 100644 module/configurations/local/datasource-keys.secret.env
diff --git a/configurations/local/datasource-keys.secret.env b/configurations/local/datasource-keys.secret.env
new file mode 120000
index 000000000..2b231cdf6
--- /dev/null
+++ b/configurations/local/datasource-keys.secret.env
@@ -0,0 +1 @@
+../../module/configurations/local/datasource-keys.secret.env
\ No newline at end of file
diff --git a/module/KEY_ROTATION.md b/module/KEY_ROTATION.md
index 6c8a33f00..99a3d06d9 100644
--- a/module/KEY_ROTATION.md
+++ b/module/KEY_ROTATION.md
@@ -246,6 +246,34 @@ the gateway never pins a key at boot: a gateway started mid-overlap loads the
whole published set and accepts both keys regardless of the order the document
lists them in.
+## One key per purpose, delivered
+
+The signing key above is one key with one purpose. Everything else this module
+signs or authenticates with carries its own **delivered** key, in a group of its
+own, so rotating one rotates one thing.
+
+Two did not, until the 2026-10-04 audit: the datasource **content-ticket** key and
+the **account-link state** key were both derived from `CODEFLY_INTERNAL_TOKEN`, the
+cell-wide perimeter credential every service and composed module holds. That had
+two consequences, neither visible from either end โ every holder of the perimeter
+credential could compute those keys and forge what they authenticate, and rotating
+the perimeter credential silently invalidated every outstanding ticket and link
+state. Both now come from `datasource-keys`
+(`DATASOURCE_CONTENT_TICKET_KEY`, `DATASOURCE_ACCOUNT_LINK_KEY`), which only
+accounts declares.
+
+An absent key leaves its purpose unavailable rather than falling back to a
+derivation: a key reachable from a credential thirteen workloads hold is not a key,
+and a fallback would make that conditional on nobody forgetting. **Rotating one of
+these is a flag day for the tokens it signed** โ an outstanding content ticket does
+not survive it โ which is why each purpose has its own, so the day is one purpose
+wide.
+
+The sealing keys are a separate axis and are no longer one key: a stored value
+names the service that sealed it and that service's own key, and the envelope key
+is per organization. Rotating and moving those is the next two sections; this one
+is about the keys a purpose is DELIVERED, which rotate on their own.
+
## Rotating the envelope key
Unlike the signing key, this is invisible to users and needs no overlap window:
diff --git a/module/configurations/local/datasource-keys.secret.env b/module/configurations/local/datasource-keys.secret.env
new file mode 100644
index 000000000..2aa3d14a9
--- /dev/null
+++ b/module/configurations/local/datasource-keys.secret.env
@@ -0,0 +1,22 @@
+# The message-authentication keys the datasource paths sign with, one per purpose.
+#
+# They used to be derived from CODEFLY_INTERNAL_TOKEN, the cell-wide perimeter
+# credential every service and composed module holds. Two things were wrong with
+# that, and neither is visible from either end: every holder of the perimeter
+# credential could compute these keys and forge what they authenticate, and
+# ROTATING the perimeter credential silently invalidated every outstanding ticket
+# and link state โ a rotation whose blast radius nothing documented.
+#
+# A key per purpose, delivered rather than derived, so rotating one rotates one
+# thing. Unset leaves the purpose it serves unavailable rather than falling back to
+# a derivation: a key reachable from a credential thirteen workloads hold is not a
+# key, and a silent fallback would make the fix conditional on nobody forgetting.
+
+# Signs the opaque content tickets a change-set job carries in place of an
+# oversized blob. The same value must be present wherever a ticket is redeemed.
+DATASOURCE_CONTENT_TICKET_KEY=local-dev-only-replace-me
+
+# Signs the account-link state that carries a person through a provider's sign-in
+# and back. Domain-separated from the ticket key above, so neither token can pass
+# for the other.
+DATASOURCE_ACCOUNT_LINK_KEY=local-dev-only-replace-me
diff --git a/module/services/accounts/code/pkg/business/datasource_content_ticket_test.go b/module/services/accounts/code/pkg/business/datasource_content_ticket_test.go
index f802b2f41..cc3d99627 100644
--- a/module/services/accounts/code/pkg/business/datasource_content_ticket_test.go
+++ b/module/services/accounts/code/pkg/business/datasource_content_ticket_test.go
@@ -58,17 +58,47 @@ func TestContentTicketRejectsTamperAndForeignKey(t *testing.T) {
}
}
-// TestAttack_UnsetTicketKeyDisablesTickets: an unset seed used to produce the
-// key sha256("datasource-content-ticket\x00") โ a constant anyone can compute,
-// so anyone could mint a ticket for any tenant's source. An unset key must
-// leave content tickets disabled, which minting and redemption already treat
-// as unavailable.
+// TestAttack_UnsetTicketKeyDisablesTickets: an unset key used to produce
+// sha256("datasource-content-ticket\x00") โ a constant anyone can compute, so
+// anyone could mint a ticket for any tenant's source. An unset key must leave
+// content tickets disabled, which minting and redemption already treat as
+// unavailable โ and must NOT fall back to any other value, which is the shape the
+// derivation from the perimeter credential had.
func TestAttack_UnsetTicketKeyDisablesTickets(t *testing.T) {
- for _, seed := range [][]byte{nil, {}} {
+ for _, key := range [][]byte{nil, {}} {
s := &Service{}
- s.SetDatasourceTicketKey(seed)
+ // A link key is present, so an absent ticket key cannot be satisfied by the
+ // other purpose's value.
+ s.SetDatasourceKeys(key, []byte("a-delivered-link-key"))
if s.datasourceTicketSigner != nil {
- t.Fatalf("SetDatasourceTicketKey(%q) installed a signer keyed by a public constant", seed)
+ t.Fatalf("SetDatasourceKeys(%q, ...) installed a signer keyed by a public constant", key)
}
}
}
+
+// And the other way round: an absent account-link key leaves the link state
+// unsignable rather than borrowing the ticket key.
+func TestAttack_UnsetAccountLinkKeyDisablesLinkState(t *testing.T) {
+ for _, key := range [][]byte{nil, {}} {
+ s := &Service{}
+ s.SetDatasourceKeys([]byte("a-delivered-ticket-key"), key)
+ if s.datasourceLinkKey != nil {
+ t.Fatalf("SetDatasourceKeys(..., %q) installed a link key from somewhere else", key)
+ }
+ }
+}
+
+// The two purposes do not share a key even when an operator provisions one value
+// for both: the link key is domain-separated, so a content ticket and a link state
+// can never pass for each other.
+func TestDatasourceKeysStayDistinctUnderOneProvisionedValue(t *testing.T) {
+ s := &Service{}
+ one := []byte("one-value-provisioned-for-both-purposes")
+ s.SetDatasourceKeys(one, one)
+ if s.datasourceTicketSigner == nil || s.datasourceLinkKey == nil {
+ t.Fatal("both purposes must be available")
+ }
+ if string(s.datasourceLinkKey) == string(s.datasourceTicketSigner.key) {
+ t.Fatal("the link key and the ticket key must differ even from one provisioned value")
+ }
+}
diff --git a/module/services/accounts/code/pkg/business/datasource_deliveries_test.go b/module/services/accounts/code/pkg/business/datasource_deliveries_test.go
index 498f9a52e..65f674524 100644
--- a/module/services/accounts/code/pkg/business/datasource_deliveries_test.go
+++ b/module/services/accounts/code/pkg/business/datasource_deliveries_test.go
@@ -449,7 +449,7 @@ func TestCompileDelivery_LargeBlobCarriesContentTicket(t *testing.T) {
}
store := newDatasourceFakeStore()
svc, _ := newDatasourceService(store, producer, gh)
- svc.SetDatasourceTicketKey([]byte("test-key"))
+ svc.SetDatasourceKeys([]byte("test-ticket-key"), []byte("test-link-key"))
source := githubSource(t, svc, "main", []string{"docs"}, cA)
if _, err := svc.CompileGitHubDelivery(context.Background(), source,
diff --git a/module/services/accounts/code/pkg/business/datasource_directory_test.go b/module/services/accounts/code/pkg/business/datasource_directory_test.go
index 633deea16..a2374b7a8 100644
--- a/module/services/accounts/code/pkg/business/datasource_directory_test.go
+++ b/module/services/accounts/code/pkg/business/datasource_directory_test.go
@@ -348,7 +348,7 @@ func newDirectoryService(t *testing.T) (*business.Service, *directoryStore, *rec
store.roles[userJoe] = gen.OrgRole_ORG_ROLE_MEMBER
store.roles[userAdm] = gen.OrgRole_ORG_ROLE_ADMIN
svc, audit := newDatasourceService(store, &recordingProducer{}, nil)
- svc.SetDatasourceTicketKey([]byte("internal-test-key"))
+ svc.SetDatasourceKeys([]byte("test-ticket-key"), []byte("test-link-key"))
svc.SetDatasourceAccountLinkers(map[string]business.DatasourceAccountLinker{
"github": fakeLinker{accounts: map[string][2]string{"code-jane": {"1001", "jane"}, "code-joe": {"1002", "joe"}}},
})
diff --git a/module/services/accounts/code/pkg/business/datasources.go b/module/services/accounts/code/pkg/business/datasources.go
index a982fb376..daf94577e 100644
--- a/module/services/accounts/code/pkg/business/datasources.go
+++ b/module/services/accounts/code/pkg/business/datasources.go
@@ -561,23 +561,36 @@ func (s *Service) SetDatasourceGitHubClientFactory(factory func(token string) Gi
s.newGitHubClient = factory
}
-// SetDatasourceTicketKey seeds the signer that mints and verifies the opaque
-// content tickets a change-set job carries in place of an oversized blob. The
-// seed is the deployment's internal key; the same seed must be present wherever
-// ResolveContentTicket runs so a ticket minted by the compiler verifies at
-// redemption. An empty seed disables tickets: deriving a key from it would sign
-// with a constant anyone can compute.
-func (s *Service) SetDatasourceTicketKey(seed []byte) {
- if len(seed) == 0 {
+// SetDatasourceKeys installs the message-authentication keys the datasource paths
+// sign with: one for the opaque content tickets a change-set job carries in place
+// of an oversized blob, one for the account-link state that carries a person
+// through a provider's sign-in and back.
+//
+// Each is DELIVERED, one per purpose. Both used to be derived from the cell-wide
+// internal token โ the perimeter credential every service and composed module
+// holds โ so every holder of that credential could compute them and forge what
+// they authenticate, and rotating the perimeter credential silently invalidated
+// every outstanding ticket and link state. Neither consequence was visible from
+// either end.
+//
+// An absent key leaves its purpose unavailable rather than falling back to a
+// derivation. A key reachable from a credential thirteen workloads hold is not a
+// key, and a fallback would make the fix conditional on nobody forgetting.
+func (s *Service) SetDatasourceKeys(ticketKey, accountLinkKey []byte) {
+ if len(ticketKey) == 0 {
s.datasourceTicketSigner = nil
+ } else {
+ s.datasourceTicketSigner = newDatasourceTicketSigner(ticketKey)
+ }
+ if len(accountLinkKey) == 0 {
s.datasourceLinkKey = nil
return
}
- // The account-link state is signed under its own domain-separated key, so
- // neither kind of token can pass for the other.
- linkKey := sha256.Sum256(append([]byte("datasource-account-link-state\x00"), seed...))
+ // Domain-separated from whatever else the delivered value is used for, so a
+ // link state and a content ticket can never pass for each other even if an
+ // operator provisions one value for both keys.
+ linkKey := sha256.Sum256(append([]byte("datasource-account-link-state\x00"), accountLinkKey...))
s.datasourceLinkKey = linkKey[:]
- s.datasourceTicketSigner = newDatasourceTicketSigner(seed)
}
// SetDatasourceAPIClientFactory overrides how per-Source API clients are built.
diff --git a/module/services/accounts/code/work.go b/module/services/accounts/code/work.go
index 1e836032b..945573ca6 100644
--- a/module/services/accounts/code/work.go
+++ b/module/services/accounts/code/work.go
@@ -867,7 +867,12 @@ func doWork(ctx context.Context) (Clean, error) {
// The datasource content-ticket signer is keyed from the same internal secret,
// domain-separated, so a change-set job's opaque content ticket verifies at
// redemption without a second key to provision.
- service.SetDatasourceTicketKey([]byte(workspaceEnv("internal-auth", "CODEFLY_INTERNAL_TOKEN")))
+ // One delivered key per purpose, never derived from the perimeter credential:
+ // see business.SetDatasourceKeys.
+ service.SetDatasourceKeys(
+ []byte(strings.TrimSpace(workspaceEnv("datasource-keys", "DATASOURCE_CONTENT_TICKET_KEY"))),
+ []byte(strings.TrimSpace(workspaceEnv("datasource-keys", "DATASOURCE_ACCOUNT_LINK_KEY"))),
+ )
centralEnforcement, err := configuredCentralEnforcement()
if err != nil {
diff --git a/module/services/accounts/service.codefly.yaml b/module/services/accounts/service.codefly.yaml
index 0e067575f..22c9e9d7d 100644
--- a/module/services/accounts/service.codefly.yaml
+++ b/module/services/accounts/service.codefly.yaml
@@ -30,6 +30,7 @@ workspace-configuration-dependencies:
- abuse-protection
- application
- billing
+ - datasource-keys
- email
- federation
- gateway-trust
From 44fb7f20c42cd309f27efc27f84019992515e76f Mon Sep 17 00:00:00 2001
From: Antoine Toussaint
<312536060+antoinetoussaint-byte@users.noreply.github.com>
Date: Mon, 5 Oct 2026 04:16:32 -0400
Subject: [PATCH 15/46] fix: the call sites a build tag and an untyped
transform hid
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two call sites my local verification could not see, both found by CI on the first
push and both the same shape: a tool that skips files rather than checking them.
`pkg/business/service_test.go` still called `Service.Logout` with the pre-change
arity. It is a DB-gated file, so `-tags pure` โ the way these suites run without
Docker โ does not SKIP it, it does not COMPILE it: `go build`, `go vet` and the
whole pure suite were green while the call site was wrong. One missing argument
reddened `Codefly quality` compile, lint and test together, and reported
auth-gateway and frontend as skipped on a failed prerequisite.
`src/test/pipeline-gateway.test.ts` built a `CodeflyRuntimeReader` literal without
the two members the public origin now depends on. vitest transforms with esbuild
and does not typecheck, so 3876 tests passed over it; `tsc --noEmit` is what sees
it. The literal is replaced by a helper that carries the whole interface, so the
next member added to it fails in one place rather than two.
Both are verification failures on my side rather than design changes, so neither
alters behaviour. `GOWORK=off go vet ./...` now passes untagged in all six Go
modules โ which compiles every test file without running any, needing no database โ
and `npm run typecheck` passes.
Refs #1018
Co-Authored-By: Claude Opus 5 (1M context)
---
.../code/pkg/business/service_test.go | 4 +-
.../code/src/test/pipeline-gateway.test.ts | 38 +++++++++++++------
2 files changed, 29 insertions(+), 13 deletions(-)
diff --git a/module/services/accounts/code/pkg/business/service_test.go b/module/services/accounts/code/pkg/business/service_test.go
index 59fe83fdb..ec58256ce 100644
--- a/module/services/accounts/code/pkg/business/service_test.go
+++ b/module/services/accounts/code/pkg/business/service_test.go
@@ -543,7 +543,9 @@ func TestLogout(t *testing.T) {
})
require.NoError(t, err)
- err = testService.Logout(testCtx, &gen.LogoutRequest{RefreshToken: authResp.RefreshToken}, "")
+ // No access token and no verified session: the body's refresh credential alone
+ // must still end the family, which is what a non-browser caller presents.
+ err = testService.Logout(testCtx, &gen.LogoutRequest{RefreshToken: authResp.RefreshToken}, "", "")
require.NoError(t, err)
_, err = testService.RefreshToken(testCtx, &gen.RefreshTokenRequest{
diff --git a/module/services/frontend/code/src/test/pipeline-gateway.test.ts b/module/services/frontend/code/src/test/pipeline-gateway.test.ts
index 4dbdbeeac..537375501 100644
--- a/module/services/frontend/code/src/test/pipeline-gateway.test.ts
+++ b/module/services/frontend/code/src/test/pipeline-gateway.test.ts
@@ -1,6 +1,7 @@
import type { ServiceEndpoint } from "codefly";
import { describe, expect, it } from "vitest";
+import type { CodeflyRuntimeReader } from "@/lib/codefly-gateway-context";
import {
codeflyInjectedRuntime,
type PipelineRuntimeReader,
@@ -61,6 +62,24 @@ function selfStartedRuntime(
});
}
+// The gateway-context reader a `runtimeWithResolvedOrigin` case passes through. It
+// carries the two members the public origin now depends on โ the configured value
+// and whether this is a deployed build โ because the origin is operator
+// configuration and no longer derivable from a request.
+function gatewayRuntime(
+ overrides: Partial = {},
+): CodeflyRuntimeReader {
+ return {
+ currentModule: () => "saas-starter",
+ currentService: () => "frontend",
+ endpoints: () => [authGatewayREST()],
+ workspaceSecret: () => "internal-test-token",
+ workspaceConfiguration: () => undefined,
+ isDeployedBuild: () => false,
+ ...overrides,
+ };
+}
+
describe("codeflyInjectedRuntime", () => {
it("recognizes a Codefly-owned test before endpoints are injected", () => {
expect(codeflyInjectedRuntime(runtime({ endpoints: () => [] }))).toBe(true);
@@ -77,24 +96,19 @@ describe("codeflyInjectedRuntime", () => {
describe("runtimeWithResolvedOrigin", () => {
it("supplies the SDK-resolved own origin when Codefly runs only dependencies", () => {
- const adapted = runtimeWithResolvedOrigin(ORIGIN, {
- currentModule: () => "saas-starter",
- currentService: () => "frontend",
- endpoints: () => [authGatewayREST()],
- workspaceSecret: () => "internal-test-token",
- });
+ const adapted = runtimeWithResolvedOrigin(ORIGIN, gatewayRuntime());
expect(adapted.endpoints()).toContainEqual(frontendHTTP());
});
it("does not replace an injected own endpoint", () => {
const injected = frontendHTTP({ address: "https://app.cell.example" });
- const adapted = runtimeWithResolvedOrigin(ORIGIN, {
- currentModule: () => "saas-starter",
- currentService: () => "frontend",
- endpoints: () => [injected],
- workspaceSecret: () => "internal-test-token",
- });
+ const adapted = runtimeWithResolvedOrigin(
+ ORIGIN,
+ gatewayRuntime({
+ endpoints: () => [injected],
+ }),
+ );
expect(adapted.endpoints()).toEqual([injected]);
});
From 8822998a06dcaa611401f079131308819452213e Mon Sep 17 00:00:00 2001
From: Antoine Toussaint
<312536060+antoinetoussaint-byte@users.noreply.github.com>
Date: Thu, 8 Oct 2026 17:06:06 -0400
Subject: [PATCH 16/46] =?UTF-8?q?fix(accounts):=20the=20review's=20four=20?=
=?UTF-8?q?accounts=20majors=20=E2=80=94=20a=20factor=20at=20every=20privi?=
=?UTF-8?q?leged=20mutation,=20complete=20revocation,=20usable=20keys=20on?=
=?UTF-8?q?ly,=20and=20a=20correct=20migration=20choice?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
R1019-N02. requireMFA was made fail-closed for a privileged actor, but the
privilege-granting and platform-security mutations did not reach it: each ran its
authorization guard and then its mutation. A helper nothing calls enforces nothing.
The role, scope and principal grants and the platform-admin mutations (suspend,
unsuspend, session revoke, entitlement override) now gate a factor, AFTER the
authorization guard and BEFORE the mutation โ a check the write has already passed
is not a check, which is the shape the first attempt at this produced and the source
test below now forbids.
UpsertFeatureFlag is deliberately not gated: it mutates nothing, answering
FailedPrecondition for a retired inventory. Ordinary tenant mutations โ dashboards,
teams, invitations, notifications, user settings โ are deliberately not gated either;
whether a second factor belongs there is a product decision about an operator's whole
roster, not a property of this host.
NOT done, and recorded in module/FEATURES.md rather than left implied: the method
policy still declares MFA_REQUIREMENT_NONE for these methods, so the existing
policy/handler lockstep gate is satisfied either way and a later edit could remove a
gate without reddening anything. Aligning the declarations is the durable fix. It is
a descriptor change โ it moves the contract digests and the published client โ and the
proto generator needs a container runtime this environment could not start, so it
wants its own verified regeneration. The stopgap is explicit: a test that reads the
source and requires the gate, before the mutation, for every method on a named list.
R1019-N04. Logout reported success when the family revocation succeeded and an
access-token revocation did not, which tells a caller their session ended when an
issued access token stays usable until it expires. Every revocation is now required;
the family revocation runs last so a failed marker cannot discard the durable half,
and each step is idempotent so a retry completes the remainder. An incomplete
sign-out reaches the caller as a retryable failure with a fixed reason.
R1019-N03. The datasource keys accepted the placeholder this module ships in its
public local defaults and stayed usable. A deployed runtime now treats an unusable
value โ marker-carrying, or below the signing floor โ as ABSENT, which leaves the
purpose unavailable: the state minting and redemption already define, and the reason
this does not refuse at boot the way a perimeter credential does. Refusing to start
would turn an unprovisioned optional purpose into an outage.
R1019-N08. The identity-provider migration treated the destination group's shipped
placeholder as provisioned, so it overrode a real value still delivered under the old
group โ defeating the fallback precisely on the cells that had not migrated, which
are the only ones it exists for. Selection now requires an operator-supplied value,
excluding every shipped marker, and falls back to a provisioned legacy value with the
migration warning. Local development still resolves to its shipped default.
Every fix is mutation-checked, each with a positive control. The factor test counts
how many calls actually reached the gate per platform grade, so a grade refused
earlier by its role floor cannot pass vacuously.
Rebase note (onto origin/main f5a104ce3). This commit also corrected a KEY_ROTATION.md
paragraph to say that splitting the shared Vault transit key per purpose was this
repository's work and not done. Main's #1033 and #1038 have since done it, behind
a selected key service with per-organization envelope keys, so the paragraph now
points at those sections instead of claiming an open item.
Refs #1018
Co-Authored-By: Claude Opus 5 (1M context)
---
module/FEATURES.md | 17 +-
module/KEY_ROTATION.md | 23 +--
module/SOLUTION_REGISTRATION.md | 23 +--
.../local/datasource-keys.secret.env | 17 +-
.../code/identity_provider_migration_test.go | 111 ++++++++++++
.../pkg/adapters/auth_error_surface_test.go | 96 +++++++++++
.../code/pkg/adapters/connect_handlers.go | 14 +-
.../privileged_mutation_factor_test.go | 163 ++++++++++++++++++
.../accounts/code/pkg/adapters/rpcs.go | 95 ++++++++++
.../accounts/code/pkg/auth/public_origin.go | 21 +--
.../accounts/code/pkg/business/auth.go | 39 ++++-
.../datasource_content_ticket_test.go | 10 +-
.../accounts/code/pkg/business/datasources.go | 13 +-
module/services/accounts/code/work.go | 82 +++++++--
module/services/auth-gateway/AGENTS.md | 40 +++--
.../code/deployed_failclosed_test.go | 11 +-
module/services/auth-gateway/code/gateway.go | 28 ++-
.../services/auth-gateway/code/ratelimit.go | 15 +-
.../code/upstream_session_credential_test.go | 15 +-
19 files changed, 696 insertions(+), 137 deletions(-)
create mode 100644 module/services/accounts/code/identity_provider_migration_test.go
create mode 100644 module/services/accounts/code/pkg/adapters/privileged_mutation_factor_test.go
diff --git a/module/FEATURES.md b/module/FEATURES.md
index 8917f951f..1b69788f8 100644
--- a/module/FEATURES.md
+++ b/module/FEATURES.md
@@ -516,7 +516,7 @@ starters, and large-scale enterprise SaaS expectations.
| OAuth login (multiple providers) | โ | Same |
| Email/password login | โ | โ (most starters keep both) |
| Magic-link login | ๐ก | โ (one-click) |
-| MFA enforced on sensitive ops | โ | โ `requireMFA` gates billing, GDPR delete, role grants, impersonation. Fail-closed for a privileged actor โ any platform role, or ownership of the request's organization โ where not being enrolled is a refusal, not a pass; opt-in per user for every other member |
+| MFA enforced on sensitive ops | โ | ๐ก `requireMFA` gates billing, GDPR delete, impersonation, organization deletion, waitlist review, the role/scope/principal grants and the platform-admin mutations (suspend, session revoke, entitlement override). Fail-closed for a privileged actor โ any platform role, or ownership of the request's organization โ where not being enrolled is a refusal, not a pass; opt-in per user for every other member. **The method policy still declares `MFA_REQUIREMENT_NONE` for the newly gated methods, so the policy/handler lockstep gate does not yet demand it** โ see the note below |
| Multi-org tenancy | โ | Same |
| Org invitations | โ | Same |
| RBAC (built-in roles) | โ | Same |
@@ -531,6 +531,21 @@ starters, and large-scale enterprise SaaS expectations.
| OpenAPI / TS client autogen | โ | โ (Connect-ES is more typesafe than fetch-based clients) |
| Real-stack e2e tests | โ | ๐ก (most starters mock โ we're ahead) |
+**What the MFA row does not yet have.** `module/services/accounts/proto/.../*.proto`
+declares each method's `mfa:` requirement, and
+`pkg/adapters/rpc_policy_lockstep_test.go` holds handlers to what their policy
+declares. The methods gated in this change still declare `MFA_REQUIREMENT_NONE`, so
+the gate is satisfied by them either way and a later edit could remove the gate
+without reddening anything. `pkg/adapters/privileged_mutation_factor_test.go` is the
+stopgap: it reads the source and requires the gate, before the mutation, for every
+method on an explicit list. Aligning the declarations is the durable fix and is owed;
+it is a descriptor change, so it moves the contract digests and the published client
+and wants its own verified regeneration.
+
+Ordinary tenant mutations โ dashboards, teams, invitations, notifications, user
+settings โ are deliberately NOT gated. Whether a second factor belongs there is a
+product decision about an operator's whole roster, not a property of this host.
+
### Nice-to-have (differentiators)
| Feature | This module | World-class |
diff --git a/module/KEY_ROTATION.md b/module/KEY_ROTATION.md
index 99a3d06d9..ebff432ff 100644
--- a/module/KEY_ROTATION.md
+++ b/module/KEY_ROTATION.md
@@ -252,22 +252,17 @@ The signing key above is one key with one purpose. Everything else this module
signs or authenticates with carries its own **delivered** key, in a group of its
own, so rotating one rotates one thing.
-Two did not, until the 2026-10-04 audit: the datasource **content-ticket** key and
-the **account-link state** key were both derived from `CODEFLY_INTERNAL_TOKEN`, the
-cell-wide perimeter credential every service and composed module holds. That had
-two consequences, neither visible from either end โ every holder of the perimeter
-credential could compute those keys and forge what they authenticate, and rotating
-the perimeter credential silently invalidated every outstanding ticket and link
-state. Both now come from `datasource-keys`
-(`DATASOURCE_CONTENT_TICKET_KEY`, `DATASOURCE_ACCOUNT_LINK_KEY`), which only
-accounts declares.
+Two purposes were brought under that rule by the 2026-10-04 audit: the datasource
+**content-ticket** key and the **account-link state** key. Both now come from
+`datasource-keys` (`DATASOURCE_CONTENT_TICKET_KEY`,
+`DATASOURCE_ACCOUNT_LINK_KEY`), a group only accounts declares, instead of being
+derived from a shared credential.
An absent key leaves its purpose unavailable rather than falling back to a
-derivation: a key reachable from a credential thirteen workloads hold is not a key,
-and a fallback would make that conditional on nobody forgetting. **Rotating one of
-these is a flag day for the tokens it signed** โ an outstanding content ticket does
-not survive it โ which is why each purpose has its own, so the day is one purpose
-wide.
+derivation, because a fallback would make the property conditional on nobody
+forgetting. **Rotating one of these is a flag day for the tokens it signed** โ an
+outstanding content ticket does not survive it โ which is why each purpose has its
+own, so the day is one purpose wide.
The sealing keys are a separate axis and are no longer one key: a stored value
names the service that sealed it and that service's own key, and the envelope key
diff --git a/module/SOLUTION_REGISTRATION.md b/module/SOLUTION_REGISTRATION.md
index afc49356b..2dcde7cd0 100644
--- a/module/SOLUTION_REGISTRATION.md
+++ b/module/SOLUTION_REGISTRATION.md
@@ -221,16 +221,19 @@ is re-checked at dial time**, so a mesh-looking name whose DNS answer later poin
off-mesh never receives one. Loopback is an explicit development exception and is
tested as such.
-A solution upstream does **not** receive the viewer's session credential. The
-gateway removes `Authorization` and `Cookie` from every request it forwards to a
-runtime-registered upstream, module or solution, and the upstream receives the
-identity headers `ext_authz` stamped instead โ the subject, the tenant, the
-session, the credential kind and the scope ceiling, which name the person without
-carrying their authority. A host access token is the person's whole session at a
-single host-wide audience, so forwarding it made every upstream that held one, or
-logged one, a source of replayable full-authority sessions. An upstream that needs
-to act on a person's behalf needs a host-minted capability bound to its own
-audience, which is the Work Context surface.
+A solution upstream does **not** receive the viewer's session credential
+(SP-GW-07). The gateway removes `Authorization` and `Cookie` from every request it
+forwards to a runtime-registered upstream, module or solution, and the upstream
+receives the identity headers `ext_authz` stamped instead โ the subject, the tenant,
+the session, the credential kind and the scope ceiling, which name the person
+without carrying their authority. A host access token carries one host-wide audience
+and the person's full authority, so it is not a credential any single upstream
+should hold.
+
+The invariant's other half is **open**: an upstream should receive a host-minted
+context bound to its own audience, and today it receives none. An upstream that
+needs to act on a person's behalf needs that capability from the Work Context
+surface.
## 3. Runtime compatibility is enforced, not stored
diff --git a/module/configurations/local/datasource-keys.secret.env b/module/configurations/local/datasource-keys.secret.env
index 2aa3d14a9..97df3f00a 100644
--- a/module/configurations/local/datasource-keys.secret.env
+++ b/module/configurations/local/datasource-keys.secret.env
@@ -1,16 +1,13 @@
# The message-authentication keys the datasource paths sign with, one per purpose.
#
-# They used to be derived from CODEFLY_INTERNAL_TOKEN, the cell-wide perimeter
-# credential every service and composed module holds. Two things were wrong with
-# that, and neither is visible from either end: every holder of the perimeter
-# credential could compute these keys and forge what they authenticate, and
-# ROTATING the perimeter credential silently invalidated every outstanding ticket
-# and link state โ a rotation whose blast radius nothing documented.
+# The invariant (SP-SEC-07): each signing or message-authentication purpose has its
+# own key, derived from no credential shared beyond its owner. These are DELIVERED
+# per purpose rather than derived from a shared credential, so rotating one rotates
+# one thing.
#
-# A key per purpose, delivered rather than derived, so rotating one rotates one
-# thing. Unset leaves the purpose it serves unavailable rather than falling back to
-# a derivation: a key reachable from a credential thirteen workloads hold is not a
-# key, and a silent fallback would make the fix conditional on nobody forgetting.
+# Unset leaves the purpose it serves unavailable rather than falling back to a
+# derivation, because a fallback would make the property conditional on nobody
+# forgetting. Provision both on every cell.
# Signs the opaque content tickets a change-set job carries in place of an
# oversized blob. The same value must be present wherever a ticket is redeemed.
diff --git a/module/services/accounts/code/identity_provider_migration_test.go b/module/services/accounts/code/identity_provider_migration_test.go
new file mode 100644
index 000000000..4d68ee363
--- /dev/null
+++ b/module/services/accounts/code/identity_provider_migration_test.go
@@ -0,0 +1,111 @@
+package main
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+// A credential moving between configuration groups must not treat the DESTINATION
+// group's shipped placeholder as the destination being ready: that defeats the
+// fallback exactly on the cells that still deliver the value under the old group,
+// which are the only ones the fallback exists for.
+func TestR1019IdentityProviderMigration(t *testing.T) {
+ const provisioned = "an-operator-supplied-value-of-sufficient-length"
+
+ for _, key := range []string{"IDENTITY_CLIENT_SECRET", "IDENTITY_MANAGEMENT_API_KEY"} {
+ t.Run(key, func(t *testing.T) {
+ blankWorkspaceKey(t, "identity-provider", key)
+ blankWorkspaceKey(t, "identity", key)
+
+ // The cell has not migrated: the destination holds only the shipped
+ // placeholder, the legacy group holds the real value.
+ t.Setenv("CODEFLY__WORKSPACE_SECRET_CONFIGURATION__IDENTITY_PROVIDER__"+key,
+ "local-dev-only-replace-me")
+ t.Setenv("CODEFLY__WORKSPACE_SECRET_CONFIGURATION__IDENTITY__"+key, provisioned)
+ require.Equal(t, provisioned, identityProviderSecret(key),
+ "a shipped placeholder in the destination must not override a provisioned legacy value")
+
+ // The cell has migrated: the destination wins.
+ t.Setenv("CODEFLY__WORKSPACE_SECRET_CONFIGURATION__IDENTITY_PROVIDER__"+key,
+ "the-migrated-"+key)
+ require.Equal(t, "the-migrated-"+key, identityProviderSecret(key))
+ })
+ }
+}
+
+// The selection rule itself, in both directions, so the test above cannot pass by
+// always preferring the legacy group.
+func TestR1019ProvisionedCredentialExcludesShippedPlaceholders(t *testing.T) {
+ for _, shipped := range []string{
+ "local-dev-only-replace-me",
+ "LOCAL-DEV-ONLY-REPLACE-ME",
+ "change-me",
+ "a-placeholder",
+ "REPLACE_ME",
+ "",
+ " ",
+ } {
+ require.False(t, isProvisionedCredential(shipped),
+ "%q is not an operator-supplied value", shipped)
+ }
+ require.True(t, isProvisionedCredential(strings.Repeat("7Kq2Xp9Vb4Nf", 3)))
+}
+
+// Local development runs on the shipped defaults, and must still resolve to one โ
+// from the group this service now declares.
+func TestR1019LocalDevelopmentKeepsTheShippedPlaceholder(t *testing.T) {
+ const key = "IDENTITY_CLIENT_SECRET"
+ blankWorkspaceKey(t, "identity-provider", key)
+ blankWorkspaceKey(t, "identity", key)
+ t.Setenv("CODEFLY__WORKSPACE_SECRET_CONFIGURATION__IDENTITY_PROVIDER__"+key,
+ "local-dev-only-replace-me")
+
+ require.Equal(t, "local-dev-only-replace-me", identityProviderSecret(key))
+}
+
+// The shipped local defaults for the datasource keys carry placeholder markers, and
+// this module's repository is public โ so a deployed runtime holding one would sign
+// with a published value. It is treated as ABSENT, which leaves the purpose
+// unavailable: the state minting and redemption already define, and the reason this
+// does not refuse at boot the way a perimeter credential does.
+func TestR1019DeployedDatasourceDefaultsRefused(t *testing.T) {
+ keys := []string{"DATASOURCE_CONTENT_TICKET_KEY", "DATASOURCE_ACCOUNT_LINK_KEY"}
+ for _, key := range keys {
+ // Two guards run here, and only the last case isolates the marker one: the
+ // two shipped values are shorter than the length floor, so removing the
+ // marker check still refuses them. A long marker-carrying value is what
+ // proves the marker check itself fires.
+ for _, shipped := range []string{
+ "local-dev-only-replace-me",
+ "LOCAL-DEV-ONLY-REPLACE-ME",
+ "some-change-me-value-long-enough-to-pass-a-length-floor",
+ } {
+ t.Run(key+"/"+shipped[:12], func(t *testing.T) {
+ blankWorkspaceKey(t, "datasource-keys", key)
+ t.Setenv("CODEFLY__WORKSPACE_SECRET_CONFIGURATION__DATASOURCE_KEYS__"+key, shipped)
+
+ require.Nil(t, usableDatasourceKey(key, false),
+ "a deployed runtime must not sign with a published value")
+ require.Equal(t, []byte(shipped), usableDatasourceKey(key, true),
+ "local development uses its shipped default")
+ })
+ }
+ }
+}
+
+// A short value is refused for the same reason the perimeter floor exists, and a
+// provisioned one is used โ so the refusal is the value and not a key that is never
+// usable.
+func TestR1019DatasourceKeyFloorAndProvisionedValue(t *testing.T) {
+ const key = "DATASOURCE_CONTENT_TICKET_KEY"
+ blankWorkspaceKey(t, "datasource-keys", key)
+
+ t.Setenv("CODEFLY__WORKSPACE_SECRET_CONFIGURATION__DATASOURCE_KEYS__"+key, "7Kq2Xp9Vb4Nf")
+ require.Nil(t, usableDatasourceKey(key, false))
+
+ provisioned := strings.Repeat("7Kq2Xp9Vb4Nf", 4)
+ t.Setenv("CODEFLY__WORKSPACE_SECRET_CONFIGURATION__DATASOURCE_KEYS__"+key, provisioned)
+ require.Equal(t, []byte(provisioned), usableDatasourceKey(key, false))
+}
diff --git a/module/services/accounts/code/pkg/adapters/auth_error_surface_test.go b/module/services/accounts/code/pkg/adapters/auth_error_surface_test.go
index 98216afc2..208cfa888 100644
--- a/module/services/accounts/code/pkg/adapters/auth_error_surface_test.go
+++ b/module/services/accounts/code/pkg/adapters/auth_error_surface_test.go
@@ -2,6 +2,7 @@ package adapters
import (
"context"
+ "errors"
"strings"
"testing"
@@ -9,8 +10,10 @@ import (
"accounts/pkg/business"
gen "accounts/pkg/gen/saas/accounts/v1"
+ "github.com/google/uuid"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
+ "google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
)
@@ -108,3 +111,96 @@ func TestBeginOAuthRefusalNamesNoInternalDetail(t *testing.T) {
require.False(t, strings.Contains(message, "auth: "),
"the sentinel's own text is internal detail")
}
+
+// A sign-out is acknowledged only when every revocation held. Reporting success
+// while the access half failed tells the caller their session ended when it has
+// not, which is the one answer a sign-out must never give.
+type partiallyRevokingMinter struct {
+ auth.JWTMinter
+ accessErr error
+ sessionErr error
+ familyErr error
+ revoked []string
+}
+
+func (m *partiallyRevokingMinter) RevokeAccess(context.Context, string) error {
+ m.revoked = append(m.revoked, "access")
+ return m.accessErr
+}
+
+func (m *partiallyRevokingMinter) RevokeSessionAccess(context.Context, string) error {
+ m.revoked = append(m.revoked, "session")
+ return m.sessionErr
+}
+
+func (m *partiallyRevokingMinter) Revoke(context.Context, string) error {
+ m.revoked = append(m.revoked, "family")
+ return m.familyErr
+}
+
+func TestR1019LogoutRequiresCompleteRevocation(t *testing.T) {
+ boom := errors.New("revocation store unavailable")
+ for _, tc := range []struct {
+ name string
+ accessErr error
+ sessionErr error
+ }{
+ {"the access marker fails", boom, nil},
+ {"the session marker fails", nil, boom},
+ {"both fail", boom, boom},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ minter := &partiallyRevokingMinter{accessErr: tc.accessErr, sessionErr: tc.sessionErr}
+ installLogoutMinter(t, minter)
+
+ _, err := (&AuthServer{}).Logout(logoutContext(), &gen.LogoutRequest{RefreshToken: "rt"})
+
+ require.Error(t, err, "an incomplete sign-out must not be reported as success")
+ require.Equal(t, codes.Unavailable, status.Code(err))
+ require.Contains(t, minter.revoked, "family",
+ "the durable half must still be attempted and kept")
+ })
+ }
+}
+
+// The healthy control: every revocation succeeds and the caller is told so.
+func TestR1019LogoutSucceedsWhenRevocationIsComplete(t *testing.T) {
+ minter := &partiallyRevokingMinter{}
+ installLogoutMinter(t, minter)
+
+ _, err := (&AuthServer{}).Logout(logoutContext(), &gen.LogoutRequest{RefreshToken: "rt"})
+
+ require.NoError(t, err)
+ require.Equal(t, []string{"access", "session", "family"}, minter.revoked)
+}
+
+// A failed FAMILY revocation is the durable half failing, and reaches the caller as
+// itself rather than as the incomplete-sign-out reason.
+func TestR1019LogoutFamilyFailureIsNotMaskedAsIncomplete(t *testing.T) {
+ minter := &partiallyRevokingMinter{familyErr: errors.New("session store unavailable")}
+ installLogoutMinter(t, minter)
+
+ _, err := (&AuthServer{}).Logout(logoutContext(), &gen.LogoutRequest{RefreshToken: "rt"})
+
+ require.Error(t, err)
+ require.NotEqual(t, codes.Unavailable, status.Code(err))
+}
+
+func installLogoutMinter(t *testing.T, minter auth.JWTMinter) {
+ t.Helper()
+ previous := service
+ t.Cleanup(func() { service = previous })
+ svc, err := business.NewService(&authErrorSurfaceStore{})
+ require.NoError(t, err)
+ svc.SetJWTMinter(minter)
+ WithService(svc)
+}
+
+// A caller with a verified session and a bearer, so both access revocations run.
+func logoutContext() context.Context {
+ ctx := metadata.NewIncomingContext(context.Background(),
+ metadata.Pairs("authorization", "Bearer an-access-token"))
+ return auth.WithVerifiedSessionID(ctx, logoutSessionID)
+}
+
+var logoutSessionID = uuid.MustParse("00000000-0000-4000-8000-0000000000c1")
diff --git a/module/services/accounts/code/pkg/adapters/connect_handlers.go b/module/services/accounts/code/pkg/adapters/connect_handlers.go
index c1165a5c0..2d8bb9b1f 100644
--- a/module/services/accounts/code/pkg/adapters/connect_handlers.go
+++ b/module/services/accounts/code/pkg/adapters/connect_handlers.go
@@ -74,11 +74,15 @@ func unary[Req, Resp any](
// it moves the minted refresh credential out of the response body and into the
// browser's cookie, exactly as the REST middleware does for the same methods.
//
-// The Connect surface serves the same browser over the same origin and returned
-// the credential as readable content, so an injected script could read it from a
-// response it provoked. A caller that genuinely needs a refresh token in a body
-// is a registered OAuth client using the token endpoint, which is a separate
-// contract with its own audience and its own registry.
+// The invariant: the refresh credential reaches the browser only as a scoped,
+// non-script-readable, transport-protected, same-site cookie, and never as
+// readable content on a surface that completes an authentication (SP-IDENT-07).
+// The Connect surface serves the same browser as REST, so it owes the same
+// carrier.
+//
+// A caller that needs a refresh token in a body is a registered OAuth client using
+// the token endpoint, a separate contract with its own audience and registry โ see
+// oauthTokenEndpointRESTPath for that exemption and its reason.
func unaryCookieingRefreshToken[Req, Resp any](
ctx context.Context,
req *connect.Request[Req],
diff --git a/module/services/accounts/code/pkg/adapters/privileged_mutation_factor_test.go b/module/services/accounts/code/pkg/adapters/privileged_mutation_factor_test.go
new file mode 100644
index 000000000..f00e60c89
--- /dev/null
+++ b/module/services/accounts/code/pkg/adapters/privileged_mutation_factor_test.go
@@ -0,0 +1,163 @@
+package adapters
+
+import (
+ "context"
+ "os"
+ "regexp"
+ "strings"
+ "testing"
+ "time"
+
+ "accounts/pkg/auth"
+ gen "accounts/pkg/gen/saas/accounts/v1"
+
+ "github.com/stretchr/testify/require"
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/status"
+)
+
+// SP-IDENT-10: a platform administrator or an organization owner carries out a
+// sensitive operation only behind a recent second factor.
+//
+// requireMFA was made fail-closed for a privileged actor, but the privilege-granting
+// and platform-security mutations did not reach it: each ran its authorization guard
+// and then its mutation. A helper nothing calls enforces nothing.
+func TestR1019PrivilegedMutationRequiresFactor(t *testing.T) {
+ // A super-admin with nothing enrolled and no recent factor. requireRoleScope
+ // falls to the platform role when no organization is named, so both servers'
+ // authorization guards pass and the factor gate is what answers.
+ for _, grade := range []string{"support", "billing", "super_admin"} {
+ t.Run(grade, func(t *testing.T) {
+ installMFAPolicyStore(t, &mfaPolicyStore{platformRole: grade})
+ ctx := privilegedActorContext()
+
+ // Counted, not assumed: a grade below every method's role floor would be
+ // refused by the authorization guard on every call and prove nothing
+ // about the factor gate. At least one call per grade has to reach it.
+ reachedTheGate := 0
+ for _, call := range privilegedMutations() {
+ err := call.invoke(ctx)
+ require.Error(t, err, "%s must not mutate without a recent factor", call.name)
+ if status.Code(err) == codes.PermissionDenied {
+ // This grade is below the method's role floor, so the
+ // authorization guard answered first โ a stronger refusal, and
+ // not what this test is about.
+ continue
+ }
+ require.Equal(t, codes.FailedPrecondition, status.Code(err),
+ "%s: expected the factor gate to answer", call.name)
+ require.Equal(t, "mfa_required", status.Convert(err).Message(), call.name)
+ reachedTheGate++
+ }
+ require.NotZero(t, reachedTheGate,
+ "every call was refused before the factor gate, so this grade proved nothing")
+ })
+ }
+}
+
+// The control: with recent AAL2 evidence the same privileged actor passes the gate,
+// so the refusals above are the missing factor and not a handler that refuses
+// everything. Asserted on the gate itself rather than through a handler, because a
+// handler that passes the gate proceeds into the service and this package's fixtures
+// wire no store for the mutations themselves.
+func TestR1019PrivilegedMutationAdmittedWithRecentFactor(t *testing.T) {
+ installMFAPolicyStore(t, &mfaPolicyStore{platformRole: "super_admin"})
+ ctx := stampVerifiedIdentity(context.Background(), mfaActorID, "", auth.Assurance{
+ Level: auth.AssuranceLevelAAL2,
+ AuthenticationMethods: []string{auth.AuthenticationMethodWebAuthn},
+ MFAVerifiedAt: time.Now(),
+ })
+ ctx = auth.WithVerifiedDatabaseIdentity(ctx, mfaActorID, mfaOrgID)
+
+ require.NoError(t, requireMFA(ctx, mfaActorID),
+ "recent AAL2 evidence must satisfy the gate these mutations now carry")
+}
+
+// Every method listed here must carry the gate, and carry it BEFORE its mutation: a
+// factor check the write has already passed is not a check. Read from the source, so
+// a method added to the privileged set cannot be left ungated, and so the ordering
+// cannot regress to the shape this finding started as.
+func TestR1019PrivilegedMutationsGateBeforeTheMutation(t *testing.T) {
+ source, err := os.ReadFile("rpcs.go")
+ require.NoError(t, err)
+ text := string(source)
+
+ for _, m := range privilegedMutationMethods {
+ pattern := regexp.MustCompile(`func \(s \*` + m.server + `\) ` + m.method +
+ `\(ctx context\.Context(?s:.*?)\n\}\n`)
+ body := pattern.FindString(text)
+ require.NotEmpty(t, body, "%s.%s not found", m.server, m.method)
+
+ gate := strings.Index(body, "requireMFA(")
+ require.NotEqual(t, -1, gate,
+ "%s.%s is a privileged mutation and must gate a second factor", m.server, m.method)
+ mutation := strings.Index(body, "service.")
+ require.NotEqual(t, -1, mutation, "%s.%s calls no service method", m.server, m.method)
+ require.Less(t, gate, mutation,
+ "%s.%s gates the factor AFTER its mutation, which enforces nothing",
+ m.server, m.method)
+ }
+}
+
+type privilegedMutation struct {
+ server, method string
+}
+
+// The privilege-granting and platform-security mutations. Roles, scopes and
+// principals grant authority; the platform-admin entries change another identity's
+// state or a tenant's ceiling.
+//
+// UpsertFeatureFlag is deliberately absent: it mutates nothing, returning
+// FailedPrecondition for a retired inventory.
+var privilegedMutationMethods = []privilegedMutation{
+ {"PermServer", "CreateRole"}, {"PermServer", "UpdateRole"}, {"PermServer", "DeleteRole"},
+ {"PermServer", "AssignRole"}, {"PermServer", "RevokeRole"},
+ {"PermServer", "GrantScope"}, {"PermServer", "RevokeScope"},
+ {"PlatformAdminServer", "SuspendUser"}, {"PlatformAdminServer", "UnsuspendUser"},
+ {"PlatformAdminServer", "RevokeSession"}, {"PlatformAdminServer", "OverrideEntitlement"},
+}
+
+type factorCall struct {
+ name string
+ invoke func(context.Context) error
+}
+
+func privilegedMutations() []factorCall {
+ perm := &PermServer{}
+ admin := &PlatformAdminServer{}
+ return []factorCall{
+ {"AssignRole", func(ctx context.Context) error {
+ _, err := perm.AssignRole(ctx, &gen.AssignRoleRequest{
+ RoleId: factorUUID, SubjectId: factorUUID,
+ SubjectKind: gen.SubjectKind_SUBJECT_KIND_USER,
+ })
+ return err
+ }},
+ {"RevokeRole", func(ctx context.Context) error {
+ _, err := perm.RevokeRole(ctx, &gen.RevokeRoleRequest{
+ RoleId: factorUUID, SubjectId: factorUUID,
+ })
+ return err
+ }},
+ {"SuspendUser", func(ctx context.Context) error {
+ _, err := admin.SuspendUser(ctx, &gen.SuspendUserRequest{
+ UserId: factorUUID, Reason: "a recorded reason",
+ })
+ return err
+ }},
+ {"OverrideEntitlement", func(ctx context.Context) error {
+ _, err := admin.OverrideEntitlement(ctx, &gen.OverrideEntitlementRequest{
+ OrgId: factorUUID, Feature: "seats", LimitValue: 10,
+ Reason: "a recorded reason",
+ })
+ return err
+ }},
+ }
+}
+
+func privilegedActorContext() context.Context {
+ ctx := stampVerifiedIdentity(context.Background(), mfaActorID, "", auth.Assurance{})
+ return auth.WithVerifiedDatabaseIdentity(ctx, mfaActorID, mfaOrgID)
+}
+
+const factorUUID = "00000000-0000-4000-8000-0000000000d1"
diff --git a/module/services/accounts/code/pkg/adapters/rpcs.go b/module/services/accounts/code/pkg/adapters/rpcs.go
index c473b5d5a..5dbbd4d02 100644
--- a/module/services/accounts/code/pkg/adapters/rpcs.go
+++ b/module/services/accounts/code/pkg/adapters/rpcs.go
@@ -579,6 +579,14 @@ func (s *PermServer) CreateRole(ctx context.Context, req *gen.CreateRoleRequest)
if err := requireRoleScope(ctx, actorID, req.OrgId); err != nil {
return nil, err
}
+ // A privilege-granting or platform-security mutation sits behind a recent second
+ // factor (SP-IDENT-10). It runs AFTER the authorization guards and BEFORE the
+ // mutation: a factor check the write has already passed is not a check.
+ // requireMFA is fail-closed for a privileged actor, so an unenrolled platform
+ // role or organization owner is refused rather than admitted.
+ if err := requireMFA(ctx, actorID); err != nil {
+ return nil, err
+ }
return service.CreateRole(ctx, actorID, req)
}
@@ -608,6 +616,14 @@ func (s *PermServer) UpdateRole(ctx context.Context, req *gen.UpdateRoleRequest)
if err := requireRoleScope(ctx, actorID, req.OrgId); err != nil {
return nil, err
}
+ // A privilege-granting or platform-security mutation sits behind a recent second
+ // factor (SP-IDENT-10). It runs AFTER the authorization guards and BEFORE the
+ // mutation: a factor check the write has already passed is not a check.
+ // requireMFA is fail-closed for a privileged actor, so an unenrolled platform
+ // role or organization owner is refused rather than admitted.
+ if err := requireMFA(ctx, actorID); err != nil {
+ return nil, err
+ }
return service.UpdateRole(ctx, actorID, req)
}
@@ -627,6 +643,14 @@ func (s *PermServer) DeleteRole(ctx context.Context, req *gen.DeleteRoleRequest)
if err := requirePlatformRole(ctx, actorID, "super_admin"); err != nil {
return nil, err
}
+ // A privilege-granting or platform-security mutation sits behind a recent second
+ // factor (SP-IDENT-10). It runs AFTER the authorization guards and BEFORE the
+ // mutation: a factor check the write has already passed is not a check.
+ // requireMFA is fail-closed for a privileged actor, so an unenrolled platform
+ // role or organization owner is refused rather than admitted.
+ if err := requireMFA(ctx, actorID); err != nil {
+ return nil, err
+ }
if err := service.DeleteRole(ctx, actorID, req); err != nil {
return nil, err
}
@@ -644,6 +668,14 @@ func (s *PermServer) AssignRole(ctx context.Context, req *gen.AssignRoleRequest)
if err := requireRoleScope(ctx, actorID, req.OrgId); err != nil {
return nil, err
}
+ // A privilege-granting or platform-security mutation sits behind a recent second
+ // factor (SP-IDENT-10). It runs AFTER the authorization guards and BEFORE the
+ // mutation: a factor check the write has already passed is not a check.
+ // requireMFA is fail-closed for a privileged actor, so an unenrolled platform
+ // role or organization owner is refused rather than admitted.
+ if err := requireMFA(ctx, actorID); err != nil {
+ return nil, err
+ }
return service.AssignRole(ctx, req)
}
@@ -658,6 +690,14 @@ func (s *PermServer) RevokeRole(ctx context.Context, req *gen.RevokeRoleRequest)
if err := requireRoleScope(ctx, actorID, req.OrgId); err != nil {
return nil, err
}
+ // A privilege-granting or platform-security mutation sits behind a recent second
+ // factor (SP-IDENT-10). It runs AFTER the authorization guards and BEFORE the
+ // mutation: a factor check the write has already passed is not a check.
+ // requireMFA is fail-closed for a privileged actor, so an unenrolled platform
+ // role or organization owner is refused rather than admitted.
+ if err := requireMFA(ctx, actorID); err != nil {
+ return nil, err
+ }
if err := service.RevokeRole(ctx, actorID, req); err != nil {
return nil, err
}
@@ -763,6 +803,14 @@ func (s *PermServer) GrantScope(ctx context.Context, req *gen.GrantScopeRequest)
if err := requireRoleScope(ctx, actorID, req.OrgId); err != nil {
return nil, err
}
+ // A privilege-granting or platform-security mutation sits behind a recent second
+ // factor (SP-IDENT-10). It runs AFTER the authorization guards and BEFORE the
+ // mutation: a factor check the write has already passed is not a check.
+ // requireMFA is fail-closed for a privileged actor, so an unenrolled platform
+ // role or organization owner is refused rather than admitted.
+ if err := requireMFA(ctx, actorID); err != nil {
+ return nil, err
+ }
return service.GrantScope(ctx, actorID, req)
}
@@ -777,6 +825,14 @@ func (s *PermServer) RevokeScope(ctx context.Context, req *gen.RevokeScopeReques
if err := requireRoleScope(ctx, actorID, req.OrgId); err != nil {
return nil, err
}
+ // A privilege-granting or platform-security mutation sits behind a recent second
+ // factor (SP-IDENT-10). It runs AFTER the authorization guards and BEFORE the
+ // mutation: a factor check the write has already passed is not a check.
+ // requireMFA is fail-closed for a privileged actor, so an unenrolled platform
+ // role or organization owner is refused rather than admitted.
+ if err := requireMFA(ctx, actorID); err != nil {
+ return nil, err
+ }
if err := service.RevokeScope(ctx, actorID, req); err != nil {
return nil, err
}
@@ -1170,6 +1226,13 @@ func (s *AuthServer) Logout(ctx context.Context, req *gen.LogoutRequest) (*empty
verifiedSession = sessionID.String()
}
if err := service.Logout(ctx, req, accessToken, verifiedSession); err != nil {
+ // An incomplete sign-out is reported as retryable rather than as success:
+ // the family is revoked, so nothing refreshes, but an already-issued access
+ // token stays usable until it expires and the caller may want to retry.
+ // The reason is fixed; the cause is logged in the business layer.
+ if errors.Is(err, business.ErrLogoutIncomplete) {
+ return nil, status.Error(codes.Unavailable, "logout incomplete, retry")
+ }
return nil, err
}
return &emptypb.Empty{}, nil
@@ -1612,6 +1675,14 @@ func (s *PlatformAdminServer) SuspendUser(ctx context.Context, req *gen.SuspendU
if err := requirePlatformRole(ctx, actorID, "super_admin"); err != nil {
return nil, err
}
+ // A privilege-granting or platform-security mutation sits behind a recent second
+ // factor (SP-IDENT-10). It runs AFTER the authorization guards and BEFORE the
+ // mutation: a factor check the write has already passed is not a check.
+ // requireMFA is fail-closed for a privileged actor, so an unenrolled platform
+ // role or organization owner is refused rather than admitted.
+ if err := requireMFA(ctx, actorID); err != nil {
+ return nil, err
+ }
if err := service.SuspendUser(ctx, actorID, req); err != nil {
return nil, err
}
@@ -1629,6 +1700,14 @@ func (s *PlatformAdminServer) UnsuspendUser(ctx context.Context, req *gen.Unsusp
if err := requirePlatformRole(ctx, actorID, "super_admin"); err != nil {
return nil, err
}
+ // A privilege-granting or platform-security mutation sits behind a recent second
+ // factor (SP-IDENT-10). It runs AFTER the authorization guards and BEFORE the
+ // mutation: a factor check the write has already passed is not a check.
+ // requireMFA is fail-closed for a privileged actor, so an unenrolled platform
+ // role or organization owner is refused rather than admitted.
+ if err := requireMFA(ctx, actorID); err != nil {
+ return nil, err
+ }
if err := service.UnsuspendUser(ctx, actorID, req); err != nil {
return nil, err
}
@@ -1697,6 +1776,14 @@ func (s *PlatformAdminServer) RevokeSession(ctx context.Context, req *gen.Revoke
if err := requirePlatformRole(ctx, actorID, "support"); err != nil {
return nil, err
}
+ // A privilege-granting or platform-security mutation sits behind a recent second
+ // factor (SP-IDENT-10). It runs AFTER the authorization guards and BEFORE the
+ // mutation: a factor check the write has already passed is not a check.
+ // requireMFA is fail-closed for a privileged actor, so an unenrolled platform
+ // role or organization owner is refused rather than admitted.
+ if err := requireMFA(ctx, actorID); err != nil {
+ return nil, err
+ }
if err := service.RevokeSession(ctx, actorID, req); err != nil {
return nil, err
}
@@ -1751,6 +1838,14 @@ func (s *PlatformAdminServer) OverrideEntitlement(ctx context.Context, req *gen.
if err := requirePlatformAdmin(ctx, actorID); err != nil {
return nil, err
}
+ // A privilege-granting or platform-security mutation sits behind a recent second
+ // factor (SP-IDENT-10). It runs AFTER the authorization guards and BEFORE the
+ // mutation: a factor check the write has already passed is not a check.
+ // requireMFA is fail-closed for a privileged actor, so an unenrolled platform
+ // role or organization owner is refused rather than admitted.
+ if err := requireMFA(ctx, actorID); err != nil {
+ return nil, err
+ }
id, err := service.OverrideEntitlement(ctx, actorID, req)
if err != nil {
return nil, status.Error(codes.Internal, err.Error())
diff --git a/module/services/accounts/code/pkg/auth/public_origin.go b/module/services/accounts/code/pkg/auth/public_origin.go
index 1847114fa..0cbf4546e 100644
--- a/module/services/accounts/code/pkg/auth/public_origin.go
+++ b/module/services/accounts/code/pkg/auth/public_origin.go
@@ -63,19 +63,16 @@ func CanonicalPublicOrigin(candidate string) (string, error) {
// has been authenticated by an adapter โ AND only when it is the origin this
// deployment pinned.
//
-// The credential check alone was not enough, and that is the whole of the defect
-// it closes. The frontend held the internal token legitimately, and derived the
-// origin it stamped from the caller's own `X-Forwarded-Host` whenever its rendered
-// endpoint was a loopback placeholder, which is what a render produces. So an
-// authenticated hop forwarded a caller's choice, and this recorded it as VERIFIED:
-// an OAuth redirect, the authenticator relying-party origin and the links mailed
-// out were then bound to a host the caller named.
+// The invariant: the origin this service treats as its verified public origin โ the
+// one that binds a sign-in redirect, an authenticator's relying party and an
+// emailed link โ is fixed by operator configuration (SP-GW-08). A forwarded value
+// is honoured only where it equals the configured one, so the configuration is the
+// authority and a forwarding hop cannot substitute for it. This check is
+// independent of any hop's own correctness, which is why it lives here as well.
//
-// Comparing against the pinned origin makes that unreachable regardless of what
-// any hop forwards โ a second, independent barrier to the frontend's own fix, and
-// the one that holds for a hop this service does not own. Where nothing is pinned
-// the candidate stands on the credential alone, which only a local runtime reaches:
-// boot requires a pinned origin outside local development.
+// Where nothing is pinned the candidate stands on the credential alone, which only
+// a local runtime reaches: boot requires a pinned origin outside local development
+// (requireApplicationBaseURL).
func WithVerifiedPublicOrigin(ctx context.Context, candidate string) (context.Context, error) {
origin, err := CanonicalPublicOrigin(candidate)
if err != nil {
diff --git a/module/services/accounts/code/pkg/business/auth.go b/module/services/accounts/code/pkg/business/auth.go
index 5cb3101e8..01b66757d 100644
--- a/module/services/accounts/code/pkg/business/auth.go
+++ b/module/services/accounts/code/pkg/business/auth.go
@@ -482,28 +482,55 @@ func (s *Service) BeginOAuth(ctx context.Context, provider, redirectURI string)
// logout that reaches here revokes the presented session's access tokens whether
// or not a refresh credential came with it.
//
-// Both access-token revocations are best-effort โ a failure never fails the
-// logout, since those tokens expire within AccessTokenTTL โ while the family
-// revocation is the durable authority and its error is returned.
+// EVERY revocation is required, and a sign-out is acknowledged only when all of
+// them held. SP-IDENT-05 is that after signing out the presented access token no
+// longer authenticates โ so reporting success while the access half failed tells
+// the caller their session ended when it has not, which is the one answer a
+// sign-out must never give. A failure that leaves a token live has to reach the
+// caller, who can retry, rather than a log nobody reads.
+//
+// The order is deliberate: the family revocation runs LAST and its result is
+// returned directly, so a failed access-marker write cannot discard the durable
+// half. Work that succeeded is kept โ each step is idempotent, so a retry
+// completes the remainder rather than starting over.
func (s *Service) Logout(ctx context.Context, req *gen.LogoutRequest, accessToken, sessionID string) error {
w := wool.Get(ctx).In("Logout")
if s.minter == nil {
return w.NewError("auth path not wired: minter missing")
}
+ var incomplete []string
if accessToken != "" {
if err := s.minter.RevokeAccess(ctx, accessToken); err != nil {
- w.Warn("RevokeAccess failed (best-effort)", wool.ErrField(err))
+ w.Warn("RevokeAccess failed", wool.ErrField(err))
+ incomplete = append(incomplete, "the presented access token")
}
}
if sessionID != "" {
if err := s.minter.RevokeSessionAccess(ctx, sessionID); err != nil {
- w.Warn("RevokeSessionAccess failed (best-effort)", wool.ErrField(err))
+ w.Warn("RevokeSessionAccess failed", wool.ErrField(err))
+ incomplete = append(incomplete, "the presented session's access tokens")
}
}
- return s.minter.Revoke(ctx, req.RefreshToken)
+ if err := s.minter.Revoke(ctx, req.RefreshToken); err != nil {
+ return err
+ }
+ if len(incomplete) > 0 {
+ // The family is revoked, so nothing can be refreshed; what is still live is
+ // the access half, until its natural expiry. The caller is told, because the
+ // difference between "signed out" and "signed out except for a few minutes"
+ // is theirs to act on.
+ return fmt.Errorf("%w: %s", ErrLogoutIncomplete, strings.Join(incomplete, " and "))
+ }
+ return nil
}
+// ErrLogoutIncomplete reports a sign-out whose refresh family was revoked while an
+// access-token revocation could not be recorded. The session cannot be refreshed,
+// but an already-issued access token stays usable until it expires, so this is not
+// the complete sign-out SP-IDENT-05 describes.
+var ErrLogoutIncomplete = errors.New("logout incomplete: revocation could not be fully recorded")
+
// GetJWKS returns the sidecar-facing JSON Web Key Set.
// Non-authoritative: the sidecar loads its key from Vault directly.
// This endpoint exists for external tooling.
diff --git a/module/services/accounts/code/pkg/business/datasource_content_ticket_test.go b/module/services/accounts/code/pkg/business/datasource_content_ticket_test.go
index cc3d99627..62ca7c52f 100644
--- a/module/services/accounts/code/pkg/business/datasource_content_ticket_test.go
+++ b/module/services/accounts/code/pkg/business/datasource_content_ticket_test.go
@@ -58,12 +58,10 @@ func TestContentTicketRejectsTamperAndForeignKey(t *testing.T) {
}
}
-// TestAttack_UnsetTicketKeyDisablesTickets: an unset key used to produce
-// sha256("datasource-content-ticket\x00") โ a constant anyone can compute, so
-// anyone could mint a ticket for any tenant's source. An unset key must leave
-// content tickets disabled, which minting and redemption already treat as
-// unavailable โ and must NOT fall back to any other value, which is the shape the
-// derivation from the perimeter credential had.
+// TestAttack_UnsetTicketKeyDisablesTickets: an unset key must leave content tickets
+// disabled, which minting and redemption already treat as unavailable, and must NOT
+// fall back to any other value โ SP-SEC-07 requires each purpose's key to be its
+// own, derived from no credential shared beyond its owner.
func TestAttack_UnsetTicketKeyDisablesTickets(t *testing.T) {
for _, key := range [][]byte{nil, {}} {
s := &Service{}
diff --git a/module/services/accounts/code/pkg/business/datasources.go b/module/services/accounts/code/pkg/business/datasources.go
index daf94577e..d1755fa40 100644
--- a/module/services/accounts/code/pkg/business/datasources.go
+++ b/module/services/accounts/code/pkg/business/datasources.go
@@ -566,16 +566,13 @@ func (s *Service) SetDatasourceGitHubClientFactory(factory func(token string) Gi
// of an oversized blob, one for the account-link state that carries a person
// through a provider's sign-in and back.
//
-// Each is DELIVERED, one per purpose. Both used to be derived from the cell-wide
-// internal token โ the perimeter credential every service and composed module
-// holds โ so every holder of that credential could compute them and forge what
-// they authenticate, and rotating the perimeter credential silently invalidated
-// every outstanding ticket and link state. Neither consequence was visible from
-// either end.
+// Each is DELIVERED, one per purpose, rather than derived from a shared
+// credential: SP-SEC-07 requires every signing or message-authentication purpose to
+// have its own key, derived from no credential shared beyond its owner.
//
// An absent key leaves its purpose unavailable rather than falling back to a
-// derivation. A key reachable from a credential thirteen workloads hold is not a
-// key, and a fallback would make the fix conditional on nobody forgetting.
+// derivation, because a fallback would make the property conditional on nobody
+// forgetting.
func (s *Service) SetDatasourceKeys(ticketKey, accountLinkKey []byte) {
if len(ticketKey) == 0 {
s.datasourceTicketSigner = nil
diff --git a/module/services/accounts/code/work.go b/module/services/accounts/code/work.go
index 945573ca6..f423919db 100644
--- a/module/services/accounts/code/work.go
+++ b/module/services/accounts/code/work.go
@@ -864,14 +864,13 @@ func doWork(ctx context.Context) (Clean, error) {
adapters.SetGatewayToken(gatewayCredentials.current)
adapters.SetPreviousGatewayToken(gatewayCredentials.previous)
adapters.SetPreviousGatewayTokenExpiresAt(gatewayCredentials.previousExpiresAt)
- // The datasource content-ticket signer is keyed from the same internal secret,
- // domain-separated, so a change-set job's opaque content ticket verifies at
- // redemption without a second key to provision.
// One delivered key per purpose, never derived from the perimeter credential:
- // see business.SetDatasourceKeys.
+ // see business.SetDatasourceKeys. A key a deployed runtime may not use is
+ // passed as ABSENT, which leaves its purpose unavailable โ the same answer as
+ // an unprovisioned key, and the reason the loader does not need a second one.
service.SetDatasourceKeys(
- []byte(strings.TrimSpace(workspaceEnv("datasource-keys", "DATASOURCE_CONTENT_TICKET_KEY"))),
- []byte(strings.TrimSpace(workspaceEnv("datasource-keys", "DATASOURCE_ACCOUNT_LINK_KEY"))),
+ usableDatasourceKey("DATASOURCE_CONTENT_TICKET_KEY", codefly.IsLocal()),
+ usableDatasourceKey("DATASOURCE_ACCOUNT_LINK_KEY", codefly.IsLocal()),
)
centralEnforcement, err := configuredCentralEnforcement()
@@ -2037,16 +2036,41 @@ func identityEnv(key string) string {
// this landing and that edit. The fallback goes when the cells have moved โ until
// then, the warning is how an operator knows one has not.
func identityProviderSecret(key string) string {
- if value, err := codefly.For(codefly.Context()).WorkspaceValue("identity-provider", key); err == nil && hasConfiguredValue(value) {
- return value
- }
+ destination, _ := codefly.For(codefly.Context()).WorkspaceValue("identity-provider", key)
legacy := identityEnv(key)
- if hasConfiguredValue(legacy) {
+
+ // A PROVISIONED destination value always wins.
+ //
+ // "Provisioned" has to exclude the placeholder this module ships in the new
+ // group's local defaults, and that is the whole of what this gets right:
+ // hasConfiguredValue rejects only the literal REPLACE_ME, so the shipped
+ // default read as configured and overrode a real value still delivered under
+ // `identity` โ defeating the migration fallback precisely on the cells that
+ // have not migrated yet, which are the only ones that need it.
+ if isProvisionedCredential(destination) {
+ return destination
+ }
+ if isProvisionedCredential(legacy) {
reportIdentityProviderSecretStillInIdentityGroup(key)
+ return legacy
+ }
+ // Neither group holds a provisioned value: local development, where the
+ // shipped placeholder IS the intended value. Prefer the destination group so a
+ // developer edits the group this service now declares.
+ if strings.TrimSpace(destination) != "" {
+ return destination
}
return legacy
}
+// isProvisionedCredential reports whether a value is one an operator supplied, as
+// opposed to a placeholder this module ships or an unset key. It is the selection
+// rule for a credential that is moving between configuration groups: a shipped
+// default must not count as the destination being ready.
+func isProvisionedCredential(value string) bool {
+ return hasConfiguredValue(value) && !looksLikeShippedPlaceholder(value)
+}
+
var identityProviderSecretWarned sync.Map
func reportIdentityProviderSecretStillInIdentityGroup(key string) {
@@ -2620,6 +2644,44 @@ func requireStartupConfiguration(ctx context.Context, isLocal bool) error {
// an emailed link pointed at. A cell without it is a cell whose verified origin is
// a request parameter, so it refuses here, where a deployment can still be fixed,
// rather than serving a sign-in bound to somewhere else.
+// usableDatasourceKey resolves one datasource message-authentication key, or nil
+// when a deployed runtime may not use what is configured.
+//
+// The shipped local defaults for this group carry placeholder markers, exactly as
+// every other secret group's do, and this module's repository is public. A deployed
+// runtime holding one would be signing with a published value, so it is treated as
+// ABSENT rather than refused at boot: unlike a perimeter credential, a datasource
+// key is not required for the service to serve, and the purpose it signs for
+// already has a defined unavailable state. Refusing to start would turn an
+// unprovisioned optional purpose into an outage.
+//
+// Local development uses the shipped default, which is its intended value.
+func usableDatasourceKey(key string, isLocal bool) []byte {
+ value := strings.TrimSpace(workspaceEnv("datasource-keys", key))
+ if value == "" {
+ return nil
+ }
+ if isLocal {
+ return []byte(value)
+ }
+ if looksLikeShippedPlaceholder(value) {
+ wool.Get(context.Background()).In("usableDatasourceKey").Warn(
+ "a datasource key carries a placeholder this module ships in its public local defaults; "+
+ "the purpose it signs for is unavailable until the key is provisioned for this cell",
+ wool.Field("key", "datasource-keys/"+key))
+ return nil
+ }
+ if len(value) < minimumPerimeterCredentialLength {
+ wool.Get(context.Background()).In("usableDatasourceKey").Warn(
+ "a datasource key is shorter than the minimum for a signing key; "+
+ "the purpose it signs for is unavailable until it is replaced",
+ wool.Field("key", "datasource-keys/"+key),
+ wool.Field("minimum", minimumPerimeterCredentialLength))
+ return nil
+ }
+ return []byte(value)
+}
+
func requireApplicationBaseURL(isLocal bool) error {
if isLocal {
return nil
diff --git a/module/services/auth-gateway/AGENTS.md b/module/services/auth-gateway/AGENTS.md
index fb3f99cd8..762234cd6 100644
--- a/module/services/auth-gateway/AGENTS.md
+++ b/module/services/auth-gateway/AGENTS.md
@@ -15,12 +15,11 @@ generic, and a registered target is data, never a branch.
Three of this service's controls are only controls when the thing they depend on
is there, and each used to degrade into something that read as working:
-- **A trusted-proxy range** (`gateway/TRUSTED_PROXY_CIDRS`). Empty means no
- forwarding header is trusted, so `clientIP` answers the peer โ behind the
- frontend, the frontend's own pod. Every anonymous caller then shares one budget
- and every authentication-factor attempt shares one, so a few bogus completions a
- minute deny the factor to the whole cell. The limiter was enabled and the buckets
- were enforced throughout; nothing said they had collapsed onto one key.
+- **A trusted-proxy range** (`gateway/TRUSTED_PROXY_CIDRS`). SP-GW-09 requires the
+ anonymous and authentication-factor budgets to key on the originating client.
+ Empty means no forwarding header is trusted, so `clientIP` answers the peer โ
+ behind the frontend, the frontend's own pod โ and every caller shares one bucket.
+ It read as working: the limiter was enabled and the buckets were enforced.
- **A shared rate-limit store.** Per-replica counters enforce the configured budget
times the replica count, and which replica a caller lands on decides their share.
- **A revocation store.** A revoker with no store answers "not revoked" to every
@@ -85,22 +84,25 @@ request it forwards to a runtime-registered upstream โ a federated module pref
a solution. A catalog route is untouched: accounts is the host's own API, where the
session IS the credential.
-A host access token is the person's whole session: one host-wide audience, the
-person's full authority, valid at every other upstream and at the host's own API.
-Forwarding it meant a compromised module or solution pod โ or a single logged
-request header, or an upstream that redeems it to mint contexts for further
-audiences โ yielded replayable full-authority sessions for every viewer who had
-used it. The cookie went the same way, and the gateway has already resolved
-identity from it by the time it forwards, so an upstream reading it learns nothing
-it is not told.
+The host access token carries one host-wide audience and the person's full
+authority, so it is not a credential any single upstream should hold. The cookie
+goes the same way, and the gateway has already resolved identity from it by the time
+it forwards, so an upstream reading it learns nothing it is not told.
An upstream receives the identity `ext_authz` stamped: the subject, the tenant, the
session, the credential kind and the scope ceiling. That names the person without
-carrying their authority. **An upstream that needs to act on the person's behalf
-needs a host-minted capability bound to its own audience** โ the Work Context
-surface โ not a bearer it borrowed. A consumer written against the old behaviour
-has to change: the two consumer repositories that read the forwarded bearer own
-their half of this.
+carrying their authority.
+
+**SP-GW-07 has a second half this gateway does not yet satisfy.** The invariant is
+that an upstream receives a host-minted context bound to THAT upstream's own
+audience โ not merely that it stops receiving the person's session. Today it
+receives the stamped identity headers and no audience-bound context, so the
+credential is no longer over-broad but the positive half is absent. Minting it at
+the edge is open work here; it cannot be supplied by a consumer.
+
+**A consumer written against the old behaviour has to change**: anything that read
+the forwarded bearer needs a host-minted capability bound to its own audience
+instead.
## A registered client calls without a proxy
diff --git a/module/services/auth-gateway/code/deployed_failclosed_test.go b/module/services/auth-gateway/code/deployed_failclosed_test.go
index f2447e372..4388594a5 100644
--- a/module/services/auth-gateway/code/deployed_failclosed_test.go
+++ b/module/services/auth-gateway/code/deployed_failclosed_test.go
@@ -7,13 +7,10 @@ import (
"github.com/stretchr/testify/require"
)
-// A deployed gateway sits behind an ingress and a frontend hop, so with no
-// trusted-proxy range the client address is always the frontend's own pod: every
-// anonymous caller shares one budget and every authentication-factor attempt
-// shares one. A few bogus completions a minute then deny the factor to the whole
-// cell and a flood of anonymous requests denies login to all of it โ a denial of
-// service delivered by the control meant to prevent one, which read as working
-// because the limiter was enabled and the buckets were enforced.
+// SP-GW-09: anonymous and authentication-factor budgets key on the originating
+// client, and a cell with no trusted-proxy range refuses to start. A deployed
+// gateway sits behind an ingress and a frontend hop, so with no trusted range the
+// client address is the frontend's own pod and every caller shares one bucket.
func TestDeployedRefusesEmptyProxyTrust(t *testing.T) {
for _, raw := range []string{"", " ", ",", " , "} {
err := requireProxyTrust(raw, false)
diff --git a/module/services/auth-gateway/code/gateway.go b/module/services/auth-gateway/code/gateway.go
index 750d931c1..3c9cd6b47 100644
--- a/module/services/auth-gateway/code/gateway.go
+++ b/module/services/auth-gateway/code/gateway.go
@@ -496,22 +496,20 @@ func (g *Gateway) proxyTo(w http.ResponseWriter, r *http.Request, upstream *url.
// before it reaches a runtime-registered upstream โ a composed module's federated
// prefix, or a solution.
//
-// The host access token IS the person's whole session: a host-wide audience, the
-// person's full authority, and valid at every other upstream and at the host's own
-// API. Forwarding it verbatim meant a compromised module or solution pod โ or one
-// logged request header, or one upstream that redeems it to mint contexts for
-// other audiences โ yielded replayable full-authority sessions for every viewer
-// who had used it. The Cookie goes with it for the same reason and one more: the
-// gateway has already resolved identity from it by this point, so an upstream
-// reading it learns nothing it is not told, and nothing legitimately needs the
-// host's session cookie.
+// The invariant (SP-GW-07): a module or solution upstream never receives the
+// person's session credential or cookie. The host access token carries a host-wide
+// audience and the person's full authority, so it is not a credential any single
+// upstream should hold; the Cookie goes with it because the gateway has already
+// resolved identity from it by this point, so an upstream reading it learns nothing
+// it is not told.
//
-// What an upstream receives instead is what it should have been receiving: the
-// identity headers ext_authz stamped (the subject, the tenant, the session, the
-// credential kind, the scope ceiling), which name the person without carrying
-// their authority. An upstream that needs to ACT on the person's behalf needs a
-// host-minted capability bound to its own audience, which is the Work Context
-// surface, not a bearer it borrowed.
+// An upstream receives the identity headers ext_authz stamped โ the subject, the
+// tenant, the session, the credential kind, the scope ceiling โ which name the
+// person without carrying their authority. An upstream that needs to ACT on the
+// person's behalf needs a host-minted capability bound to its own audience, which
+// is the Work Context surface, not a bearer it borrowed. **That half of SP-GW-07 is
+// not yet built here: see the audience-bound context note in
+// services/auth-gateway/AGENTS.md.**
//
// Catalog routes are untouched: accounts is the host's own API, where the session
// is the credential.
diff --git a/module/services/auth-gateway/code/ratelimit.go b/module/services/auth-gateway/code/ratelimit.go
index 97224b0cc..1d2dd6d41 100644
--- a/module/services/auth-gateway/code/ratelimit.go
+++ b/module/services/auth-gateway/code/ratelimit.go
@@ -385,14 +385,15 @@ func newProxyTrust(raw string) proxyTrust {
// requireProxyTrust is the boot check for a DEPLOYED runtime: the trusted-proxy
// range must parse AND name at least one hop.
//
+// The invariant (SP-GW-09): anonymous and authentication-factor budgets are keyed
+// on the ORIGINATING client, never on an intermediate hop, and a cell with no
+// trusted-proxy range refuses to start.
+//
// An empty range is not a narrow configuration, it is no configuration: with no
-// trusted hop, clientIP always answers the peer, which behind the frontend is the
-// frontend's own pod. Every anonymous caller then shares one budget and every
-// authentication-factor attempt shares one, so a few bogus completions a minute
-// deny the factor to the whole cell and a flood of anonymous requests denies
-// login to all of it. That is a denial of service delivered BY the control meant
-// to prevent one, and it read as working: the limiter was enabled, the buckets
-// were enforced, and nothing said they had collapsed onto one key.
+// trusted hop clientIP answers the peer, which behind the frontend is the
+// frontend's own pod, so every caller shares one bucket. It read as working โ the
+// limiter was enabled and the buckets were enforced โ which is why this refuses at
+// boot rather than warning.
//
// Local development has no ingress hop and the peer IS the client, so an empty
// range is correct there and only there.
diff --git a/module/services/auth-gateway/code/upstream_session_credential_test.go b/module/services/auth-gateway/code/upstream_session_credential_test.go
index 754f58c11..0896bbb25 100644
--- a/module/services/auth-gateway/code/upstream_session_credential_test.go
+++ b/module/services/auth-gateway/code/upstream_session_credential_test.go
@@ -8,14 +8,15 @@ import (
"github.com/stretchr/testify/require"
)
-// The host access token IS the person's whole session: one host-wide audience,
-// the person's full authority, valid at every other upstream and at the host's own
-// API. Forwarding it verbatim meant a compromised module or solution pod โ or one
-// logged request header โ yielded replayable full-authority sessions for every
-// viewer who had used it.
+// SP-GW-07: a module or solution upstream never receives the person's session
+// credential or cookie. The host access token carries one host-wide audience and
+// the person's full authority, so it is not a credential any single upstream should
+// hold.
//
-// What an upstream receives instead is the identity ext_authz stamped, which names
-// the person without carrying their authority.
+// An upstream receives the identity ext_authz stamped, which names the person
+// without carrying their authority. The invariant's other half โ a host-minted
+// context bound to that upstream's audience โ is open work; see
+// services/auth-gateway/AGENTS.md.
func TestModuleUpstreamReceivesNoSessionBearer(t *testing.T) {
gw, _, _, priv := newGatewayHarness(t)
fake := declareModule(t, gw, "documents")
From 5795f0b9b7498e6b115b6711d6f35f6f4c43ee60 Mon Sep 17 00:00:00 2001
From: Antoine Toussaint
<312536060+antoinetoussaint-byte@users.noreply.github.com>
Date: Thu, 8 Oct 2026 17:06:07 -0400
Subject: [PATCH 17/46] fix(auth-gateway): only a final response is sanitized
(R1019-N09)
R1019-N09. A 1xx is informational: net/http sends it and keeps the same header map
open for the real response, which the reverse proxy then fills from the upstream.
Stamping on it marked sanitization complete, so the final upstream headers went out
untouched. An early hint is not an answer, and only a final response is stamped now.
Tested through real local HTTP servers, because the behaviour is net/http's
handling of an early hint and a recorder does not reproduce it. Mutation-checked in
both directions.
Rebase note (onto origin/main f5a104ce3). R1019-N05 is gone with its subject. It
concerned a process-local module prefix-claim cache standing in for a durable
store; #953 removed runtime module registration altogether, and a module route is
now resolved per request from the delivered registry
(gateway_module_routes.go, g.solutions.resolveModuleRouting), which is shared and
durable by construction and answers 503 when no snapshot has loaded. There is no
cache left to track an authority with.
Refs #1018
Co-Authored-By: Claude Opus 5 (1M context)
---
.../auth-gateway/code/gateway_cors.go | 11 +++++
.../code/gateway_upstream_cors_test.go | 46 +++++++++++++++++++
2 files changed, 57 insertions(+)
diff --git a/module/services/auth-gateway/code/gateway_cors.go b/module/services/auth-gateway/code/gateway_cors.go
index fe9092b85..555c1add6 100644
--- a/module/services/auth-gateway/code/gateway_cors.go
+++ b/module/services/auth-gateway/code/gateway_cors.go
@@ -217,7 +217,18 @@ type corsResponseWriter struct {
stamped bool
}
+// WriteHeader stamps the final response, and ONLY the final response.
+//
+// A 1xx is informational: net/http sends it and keeps the same header map open for
+// the real response that follows, which the reverse proxy then fills from the
+// upstream. Treating it as the response marked sanitization complete, so the final
+// upstream headers โ including an upstream's own permissive grant โ went out
+// untouched. An early hint is not an answer.
func (w *corsResponseWriter) WriteHeader(status int) {
+ if status >= 100 && status < 200 {
+ w.ResponseWriter.WriteHeader(status)
+ return
+ }
w.stamp()
w.ResponseWriter.WriteHeader(status)
}
diff --git a/module/services/auth-gateway/code/gateway_upstream_cors_test.go b/module/services/auth-gateway/code/gateway_upstream_cors_test.go
index 484bd35ca..d8dd89ec2 100644
--- a/module/services/auth-gateway/code/gateway_upstream_cors_test.go
+++ b/module/services/auth-gateway/code/gateway_upstream_cors_test.go
@@ -3,6 +3,7 @@ package main
import (
"net/http"
"net/http/httptest"
+ "net/url"
"testing"
"github.com/stretchr/testify/require"
@@ -81,3 +82,48 @@ func lowered(value string) string {
}
return string(out)
}
+
+// A 1xx is informational: net/http sends it and keeps the same header map open for
+// the real response the reverse proxy then fills from the upstream. Treating it as
+// the response marked sanitization complete, so the final upstream headers โ an
+// upstream's own permissive grant among them โ went out untouched.
+//
+// Driven through real local HTTP servers, because the behaviour is net/http's
+// handling of an early hint and a recorder does not reproduce it.
+func TestR1019CORSAfterInformationalResponse(t *testing.T) {
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ // An early hint, exactly as a real upstream may send before its answer.
+ w.Header().Set("Link", "; rel=preload")
+ w.WriteHeader(http.StatusEarlyHints)
+ w.Header().Del("Link")
+
+ w.Header().Set("Access-Control-Allow-Origin", "*")
+ w.Header().Set("Access-Control-Allow-Credentials", "true")
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte("upstream-body"))
+ }))
+ t.Cleanup(upstream.Close)
+
+ gw, _, _, _ := newGatewayHarness(t)
+ upstreamURL, parseErr := url.Parse(upstream.URL)
+ require.NoError(t, parseErr)
+ gw.upstreams["accounts"] = upstreamURL
+
+ front := httptest.NewServer(gw)
+ t.Cleanup(front.Close)
+
+ request, err := http.NewRequest(http.MethodGet, front.URL+"/v1/status", nil)
+ require.NoError(t, err)
+ request.Header.Set("Origin", "https://evil.example")
+
+ response, err := http.DefaultClient.Do(request)
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = response.Body.Close() })
+
+ require.Equal(t, http.StatusOK, response.StatusCode)
+ for name := range response.Header {
+ require.NotContains(t, lowered(name), "access-control-",
+ "the final response must carry no grant this gateway did not make: %s=%q",
+ name, response.Header.Get(name))
+ }
+}
From 997f215a4652f03faab7ae1b37092121bf645b90 Mon Sep 17 00:00:00 2001
From: Antoine Toussaint
<312536060+antoinetoussaint-byte@users.noreply.github.com>
Date: Thu, 8 Oct 2026 17:06:07 -0400
Subject: [PATCH 18/46] fix(accounts): a usable Connect refresh carrier, a
public reason for a failed refresh, and one origin spelling (R1019-N07, N10,
N12)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
R1019-N07. Removing the credential from the Connect response body left a carrier
that worked in one direction: a Connect client had nowhere to read it from, and the
refresh and logout calls that need it failed field validation before reaching the
verifier. The Connect refresh and logout now consume the credential from a Cookie
header when the message does not carry one, before dispatch โ after validation it
would be too late โ and an explicit body credential still wins, the same precedence
the REST middleware uses.
The cookie's path scope is a deliberate choice and is now written down beside it:
`/v1/auth` is the REST auth surface and NOT the Connect procedure prefix, so a
browser does not attach the cookie to a Connect call. Widening it to `/` would
attach the refresh credential to every request this origin serves, including the ones
the frontend proxies onward. The defined journey is therefore: authenticate over
Connect, refresh and sign out over REST, where the cookie is in scope โ with the
Connect calls still available to a caller that supplies the credential itself.
R1019-N10. Only the recognized refusals were sanitized, so a host-side failure
returned its wrapped chain, naming the internal call path and the store's address. A
failure that is not a refusal now answers a stable retryable reason with the cause
logged. A deliberate status from further in keeps its own reason and is returned
WITHOUT its wrapper โ returning the error unchanged would carry the internal chain
out alongside the good reason, which is the same defect one layer along.
R1019-N11. Renewal's guard saw only the audience the REQUEST named, so an omitted one
passed it and then inherited the parent's. The effective audience โ the one that gets
signed โ is now checked after inheritance and before signing, and a source test holds
that ordering.
R1019-N12. An explicit default port is the same origin as none, and the two sides
spelled it differently: a browser and the frontend drop it, a configured value may
carry it. Both sides now go through one canonicalization, applied when the configured
origin is installed as well as when a forwarded one arrives. A non-default port still
distinguishes an origin, asserted in both directions so the rule does not collapse
genuinely different origins.
Each mutation-checked, each with a positive control.
Rebase note (onto origin/main f5a104ce3). R1019-N11 โ re-checking the audience a renewal
EFFECTIVELY signs, rather than the one the request named โ is closed by main.
RenewWorkContext resolves the inherited audience into a local and gates that value
with requireVocabularyAudience, once, after the inheritance and before signing.
That is what this finding asked for, in one gate instead of two. The ordering is
held from the source by TestR1019RenewInheritedAudience.
Refs #1018
Co-Authored-By: Claude Opus 5 (1M context)
---
.../pkg/adapters/auth_error_surface_test.go | 34 ++++++++++
.../code/pkg/adapters/connect_handlers.go | 31 +++++++++
.../refresh_credential_carrier_test.go | 65 +++++++++++++++++++
.../accounts/code/pkg/adapters/rest_extras.go | 14 ++++
.../accounts/code/pkg/adapters/rpcs.go | 25 ++++++-
.../work_context_reserved_audience_test.go | 28 ++++++++
.../accounts/code/pkg/auth/public_origin.go | 31 ++++++++-
.../pkg/auth/public_origin_configured_test.go | 48 ++++++++++++++
8 files changed, 272 insertions(+), 4 deletions(-)
diff --git a/module/services/accounts/code/pkg/adapters/auth_error_surface_test.go b/module/services/accounts/code/pkg/adapters/auth_error_surface_test.go
index 208cfa888..90645127c 100644
--- a/module/services/accounts/code/pkg/adapters/auth_error_surface_test.go
+++ b/module/services/accounts/code/pkg/adapters/auth_error_surface_test.go
@@ -204,3 +204,37 @@ func logoutContext() context.Context {
}
var logoutSessionID = uuid.MustParse("00000000-0000-4000-8000-0000000000c1")
+
+// SP-GW-13: a failed request carries a stable public reason and the cause is logged,
+// not returned. Only the recognized refusals were sanitized, so an injected storage
+// failure returned its internal address and wrapped call chain.
+func TestR1019RefreshFailureHasPublicReason(t *testing.T) {
+ installRefusingRefreshMinter(t, errors.New(
+ "dial tcp 10.4.0.9:5432: connect: connection refused (session_store.RotateRefresh)"))
+
+ _, err := (&AuthServer{}).RefreshToken(context.Background(),
+ &gen.RefreshTokenRequest{RefreshToken: "a-refresh-token"})
+
+ require.Error(t, err)
+ require.Equal(t, codes.Unavailable, status.Code(err),
+ "a host-side failure is retryable, not a credential problem")
+ message := status.Convert(err).Message()
+ require.Equal(t, "refresh temporarily unavailable", message)
+ for _, leak := range []string{"10.4.0.9", "5432", "session_store", "dial tcp", "rpc error"} {
+ require.NotContains(t, message, leak,
+ "the public reason must name no internal address or call chain")
+ }
+}
+
+// A deliberate status from further in keeps its own reason, so the mapping above does
+// not flatten every answer into one.
+func TestR1019RefreshKeepsADeliberateStatus(t *testing.T) {
+ installRefusingRefreshMinter(t,
+ status.Error(codes.FailedPrecondition, "device session is no longer active"))
+
+ _, err := (&AuthServer{}).RefreshToken(context.Background(),
+ &gen.RefreshTokenRequest{RefreshToken: "a-refresh-token"})
+
+ require.Equal(t, codes.FailedPrecondition, status.Code(err))
+ require.Equal(t, "device session is no longer active", status.Convert(err).Message())
+}
diff --git a/module/services/accounts/code/pkg/adapters/connect_handlers.go b/module/services/accounts/code/pkg/adapters/connect_handlers.go
index 2d8bb9b1f..0ea4b8da9 100644
--- a/module/services/accounts/code/pkg/adapters/connect_handlers.go
+++ b/module/services/accounts/code/pkg/adapters/connect_handlers.go
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"net/http"
+ "strings"
"time"
"connectrpc.com/connect"
@@ -98,6 +99,30 @@ func unaryCookieingRefreshToken[Req, Resp any](
return resp, nil
}
+// refreshTokenFromCookie reads the refresh credential out of a Connect request's
+// Cookie header and into the message, when the message does not already carry one.
+//
+// It is the request half of the cookie carrier, and without it the response half is
+// a dead end: authentication stops returning the credential as readable content, so
+// a Connect client has nowhere to read it from, and the refresh and logout calls
+// that need it then fail field validation before reaching the verifier. A carrier
+// that only works in one direction is not a carrier.
+//
+// The body still wins when it carries a token: the cookie is the browser's
+// convenience, not an override of an explicit credential โ the same precedence the
+// REST middleware uses.
+func refreshTokenFromCookie(header http.Header, current string, set func(string)) {
+ if strings.TrimSpace(current) != "" {
+ return
+ }
+ for _, cookie := range (&http.Request{Header: header}).Cookies() {
+ if cookie.Name == refreshTokenCookieName && cookie.Value != "" {
+ set(cookie.Value)
+ return
+ }
+ }
+}
+
// connectHandlersExemptFromTheRefreshCookie are the authentication-completing
// Connect methods whose response body carries the refresh credential by
// contract. One entry: the OAuth 2.1 token endpoint (see
@@ -563,12 +588,18 @@ func (h *authConnectHandler) CompleteWebAuthnMFAChallenge(ctx context.Context, r
return unaryCookieingRefreshToken(ctx, req, h.inner.CompleteWebAuthnMFAChallenge)
}
func (h *authConnectHandler) RefreshToken(ctx context.Context, req *connect.Request[gen.RefreshTokenRequest]) (*connect.Response[gen.RefreshTokenResponse], error) {
+ refreshTokenFromCookie(req.Header(), req.Msg.GetRefreshToken(), func(token string) {
+ req.Msg.RefreshToken = token
+ })
return unaryCookieingRefreshToken(ctx, req, h.inner.RefreshToken)
}
func (h *authConnectHandler) SwitchOrganization(ctx context.Context, req *connect.Request[gen.SwitchOrganizationRequest]) (*connect.Response[gen.SwitchOrganizationResponse], error) {
return unary(ctx, req, h.inner.SwitchOrganization)
}
func (h *authConnectHandler) Logout(ctx context.Context, req *connect.Request[gen.LogoutRequest]) (*connect.Response[emptypb.Empty], error) {
+ refreshTokenFromCookie(req.Header(), req.Msg.GetRefreshToken(), func(token string) {
+ req.Msg.RefreshToken = token
+ })
return unary(ctx, req, h.inner.Logout)
}
func (h *authConnectHandler) ValidateClientAuthorization(ctx context.Context, req *connect.Request[gen.ValidateClientAuthorizationRequest]) (*connect.Response[gen.ValidateClientAuthorizationResponse], error) {
diff --git a/module/services/accounts/code/pkg/adapters/refresh_credential_carrier_test.go b/module/services/accounts/code/pkg/adapters/refresh_credential_carrier_test.go
index 2303fc2ff..566199ffc 100644
--- a/module/services/accounts/code/pkg/adapters/refresh_credential_carrier_test.go
+++ b/module/services/accounts/code/pkg/adapters/refresh_credential_carrier_test.go
@@ -214,3 +214,68 @@ func TestEveryConnectAuthCompletionRoutesThroughTheLift(t *testing.T) {
require.Equal(t, map[string]bool{"ExchangeClientToken": true},
connectHandlersExemptFromTheRefreshCookie)
}
+
+// The response half removed the credential from the Connect body; without a request
+// half a Connect client had nowhere to put it back, so refresh and logout failed
+// field validation before reaching the verifier. A carrier that works in one
+// direction is not a carrier.
+func TestR1019ConnectRefreshConsumesCookie(t *testing.T) {
+ refresh := connect.NewRequest(&gen.RefreshTokenRequest{})
+ refresh.Header().Set("Cookie", refreshTokenCookieName+"=cookie-rt; codefly_session=1")
+ refreshTokenFromCookie(refresh.Header(), refresh.Msg.GetRefreshToken(), func(token string) {
+ refresh.Msg.RefreshToken = token
+ })
+ require.Equal(t, "cookie-rt", refresh.Msg.GetRefreshToken(),
+ "the Connect refresh must take the credential from the cookie")
+
+ logout := connect.NewRequest(&gen.LogoutRequest{})
+ logout.Header().Set("Cookie", refreshTokenCookieName+"=cookie-rt")
+ refreshTokenFromCookie(logout.Header(), logout.Msg.GetRefreshToken(), func(token string) {
+ logout.Msg.RefreshToken = token
+ })
+ require.Equal(t, "cookie-rt", logout.Msg.GetRefreshToken())
+}
+
+// An explicit body credential wins: the cookie is the browser's convenience, not an
+// override โ the same precedence the REST middleware uses.
+func TestR1019ConnectBodyCredentialWinsOverTheCookie(t *testing.T) {
+ request := connect.NewRequest(&gen.RefreshTokenRequest{RefreshToken: "body-rt"})
+ request.Header().Set("Cookie", refreshTokenCookieName+"=cookie-rt")
+ refreshTokenFromCookie(request.Header(), request.Msg.GetRefreshToken(), func(token string) {
+ request.Msg.RefreshToken = token
+ })
+ require.Equal(t, "body-rt", request.Msg.GetRefreshToken())
+}
+
+// No cookie and no body leaves the message untouched, so the absence is reported by
+// field validation rather than by an empty credential reaching the verifier.
+func TestR1019ConnectWithNoCarrierIsUnchanged(t *testing.T) {
+ request := connect.NewRequest(&gen.RefreshTokenRequest{})
+ request.Header().Set("Cookie", "codefly_session=1")
+ refreshTokenFromCookie(request.Header(), request.Msg.GetRefreshToken(), func(token string) {
+ request.Msg.RefreshToken = token
+ })
+ require.Empty(t, request.Msg.GetRefreshToken())
+}
+
+// The lift has to run BEFORE the message is validated, or the empty field is
+// rejected before the cookie is ever consulted. The handler is the only place that
+// ordering holds, so it is asserted from the source.
+func TestR1019ConnectRefreshLiftsBeforeValidation(t *testing.T) {
+ source, err := os.ReadFile("connect_handlers.go")
+ require.NoError(t, err)
+ text := string(source)
+
+ for _, method := range []string{"RefreshToken", "Logout"} {
+ pattern := regexp.MustCompile(`func \(h \*authConnectHandler\) ` + method +
+ `\(ctx context\.Context(?s:.*?)\n\}\n`)
+ body := pattern.FindString(text)
+ require.NotEmpty(t, body, method)
+ lift := strings.Index(body, "refreshTokenFromCookie(")
+ dispatch := strings.Index(body, "h.inner.")
+ require.NotEqual(t, -1, lift, "%s must consume the cookie carrier", method)
+ require.Less(t, lift, dispatch,
+ "%s must lift the cookie before dispatching, or validation refuses the empty field first",
+ method)
+ }
+}
diff --git a/module/services/accounts/code/pkg/adapters/rest_extras.go b/module/services/accounts/code/pkg/adapters/rest_extras.go
index 3faae80a6..8cdad6b5a 100644
--- a/module/services/accounts/code/pkg/adapters/rest_extras.go
+++ b/module/services/accounts/code/pkg/adapters/rest_extras.go
@@ -103,6 +103,20 @@ var authenticationCompletingRESTPaths = map[string]bool{
"/v1/auth/refresh": true,
}
+// The refresh cookie is scoped to `/v1/auth`, which is the REST auth surface and
+// NOT the Connect procedure prefix (`/saas.accounts.v1.AuthService/โฆ`). A browser
+// therefore does not attach it to a Connect call, and that is deliberate: the
+// alternative is a cookie scoped to `/`, which would attach the refresh credential
+// to every request this origin serves, including the ones the frontend proxies
+// onward.
+//
+// So the journey for a Connect client is defined rather than accidental:
+// authenticate over Connect (the response sets the cookie and carries no readable
+// token), then REFRESH AND SIGN OUT over REST `/v1/auth/refresh` and
+// `/v1/auth/logout`, where the cookie is in scope. The Connect refresh and logout
+// still work for a caller that supplies the credential itself โ in its body, or as
+// an explicit Cookie header โ which refreshTokenFromCookie consumes.
+
// oauthTokenEndpointRESTPath is the one authentication-completing route whose
// response carries the refresh credential in the body, and must.
//
diff --git a/module/services/accounts/code/pkg/adapters/rpcs.go b/module/services/accounts/code/pkg/adapters/rpcs.go
index 5dbbd4d02..53ed92df0 100644
--- a/module/services/accounts/code/pkg/adapters/rpcs.go
+++ b/module/services/accounts/code/pkg/adapters/rpcs.go
@@ -1154,13 +1154,36 @@ func (s *AuthServer) RefreshToken(ctx context.Context, req *gen.RefreshTokenRequ
// session policy) still passes through unchanged: it is a genuine server-side
// failure, and reporting it as a credential problem would send an operator
// looking at the wrong half.
+// innerStatus finds a gRPC status an error carries beneath any wrapping, or nil.
+// status.Convert cannot be used for this: given a wrapped error it builds a status
+// from the whole chain's text, which is the internal detail the caller must not see.
+func innerStatus(err error) *status.Status {
+ var carrier interface{ GRPCStatus() *status.Status }
+ if errors.As(err, &carrier) {
+ return carrier.GRPCStatus()
+ }
+ return nil
+}
+
func refreshStatusError(ctx context.Context, err error) error {
switch {
case errors.Is(err, auth.ErrRefreshReuse), errors.Is(err, auth.ErrRefreshRevoked):
wool.Get(ctx).In("RefreshToken").Warn("refresh refused", wool.ErrField(err))
return status.Error(codes.Unauthenticated, "invalid refresh token")
+ case innerStatus(err) != nil:
+ // A deliberate status from further in โ a session that is no longer active, a
+ // policy refusal. Its reason was chosen for the caller, so it is returned
+ // WITHOUT the wrapper: the wrapping is what names the internal call chain,
+ // and returning err unchanged would carry it out with the good reason.
+ return innerStatus(err).Err()
default:
- return err
+ // Anything else is this host failing, and the wrapped chain names the
+ // internal call path and the store's address. SP-GW-13: a refused or failed
+ // request carries a stable public reason and the cause is logged, not
+ // returned. Unavailable rather than Internal because a refresh is worth
+ // retrying.
+ wool.Get(ctx).In("RefreshToken").Error("refresh failed", wool.ErrField(err))
+ return status.Error(codes.Unavailable, "refresh temporarily unavailable")
}
}
diff --git a/module/services/accounts/code/pkg/adapters/work_context_reserved_audience_test.go b/module/services/accounts/code/pkg/adapters/work_context_reserved_audience_test.go
index 2f21834f3..ec21067e7 100644
--- a/module/services/accounts/code/pkg/adapters/work_context_reserved_audience_test.go
+++ b/module/services/accounts/code/pkg/adapters/work_context_reserved_audience_test.go
@@ -163,3 +163,31 @@ const uuidForReservedAudienceTest = "00000000-0000-4000-8000-000000000001"
func scopesForReservedAudienceTest() []*gen.WorkContextScope {
return []*gen.WorkContextScope{{ResourceKind: "records", Actions: []string{"read"}}}
}
+
+// Renewal's guard at the top of the method sees only what the REQUEST named, so an
+// omitted audience passed it and then inherited the parent's โ the one path by which
+// a person-driven renewal could still carry the reserved module audience. The
+// EFFECTIVE value is what gets signed, so the effective value is what is refused.
+func TestR1019RenewInheritedAudience(t *testing.T) {
+ source, err := os.ReadFile("work_context_rpcs.go")
+ require.NoError(t, err)
+ text := string(source)
+
+ body := regexp.MustCompile(
+ `func \(s \*WorkContextAuthorityServer\) RenewWorkContext\((?s:.*?)\n\}\n`).
+ FindString(text)
+ require.NotEmpty(t, body)
+
+ inherit := strings.Index(body, "audience = parent.GetAudience()")
+ require.NotEqual(t, -1, inherit, "renewal still inherits the parent audience")
+
+ // A refusal of the EFFECTIVE audience, after the inheritance and before signing.
+ effective := strings.Index(body, "refuseReservedModuleAudience(audience)")
+ require.NotEqual(t, -1, effective,
+ "renewal must refuse the audience it will actually sign, not only the one requested")
+ require.Less(t, inherit, effective, "the check must follow the inheritance")
+
+ sign := strings.Index(body, "s.signer.")
+ require.NotEqual(t, -1, sign)
+ require.Less(t, effective, sign, "the check must precede signing")
+}
diff --git a/module/services/accounts/code/pkg/auth/public_origin.go b/module/services/accounts/code/pkg/auth/public_origin.go
index 0cbf4546e..28809477b 100644
--- a/module/services/accounts/code/pkg/auth/public_origin.go
+++ b/module/services/accounts/code/pkg/auth/public_origin.go
@@ -18,8 +18,19 @@ var configuredPublicOrigin string
// SetConfiguredPublicOrigin installs the operator-configured public origin. Call
// it once from startup, before anything serves.
+//
+// The value is stored CANONICALIZED, through the same function the forwarded origin
+// goes through, so the two sides of the comparison cannot spell one origin two ways.
+// Storing it raw is what made `https://app.example:443` refuse the equivalent origin
+// the frontend forwards. A value that does not canonicalize is stored trimmed, so the
+// comparison refuses it rather than silently matching something.
func SetConfiguredPublicOrigin(origin string) {
- configuredPublicOrigin = strings.TrimSuffix(strings.TrimSpace(origin), "/")
+ trimmed := strings.TrimSuffix(strings.TrimSpace(origin), "/")
+ if canonical, err := CanonicalPublicOrigin(trimmed); err == nil {
+ configuredPublicOrigin = canonical
+ return
+ }
+ configuredPublicOrigin = trimmed
}
// ConfiguredPublicOrigin returns the pinned origin, if there is one.
@@ -47,7 +58,8 @@ func CanonicalPublicOrigin(candidate string) (string, error) {
parsed.ForceQuery || parsed.Fragment != "" {
return "", fmt.Errorf("public origin must not contain credentials, path, query, or fragment")
}
- switch strings.ToLower(parsed.Scheme) {
+ scheme := strings.ToLower(parsed.Scheme)
+ switch scheme {
case "https":
case "http":
if !isPublicOriginLoopback(parsed.Hostname()) {
@@ -56,7 +68,20 @@ func CanonicalPublicOrigin(candidate string) (string, error) {
default:
return "", fmt.Errorf("public origin must use HTTP(S)")
}
- return strings.TrimSuffix(candidate, "/"), nil
+ // An explicit DEFAULT port is the same origin as none, and the two sides of the
+ // comparison spell it differently: the browser and the frontend both drop it
+ // (URL.origin does), while a configured value may carry it. Retaining it here
+ // made an operator's `https://app.example:443` refuse the equivalent origin the
+ // frontend forwards. Dropping it is the same rule both sides then follow.
+ host := parsed.Host
+ if port := parsed.Port(); port != "" &&
+ ((scheme == "https" && port == "443") || (scheme == "http" && port == "80")) {
+ host = parsed.Hostname()
+ if strings.Contains(host, ":") {
+ host = "[" + host + "]" // an IPv6 literal keeps its brackets
+ }
+ }
+ return scheme + "://" + host, nil
}
// WithVerifiedPublicOrigin records the origin only after the gateway credential
diff --git a/module/services/accounts/code/pkg/auth/public_origin_configured_test.go b/module/services/accounts/code/pkg/auth/public_origin_configured_test.go
index 32458bb7d..ea1240555 100644
--- a/module/services/accounts/code/pkg/auth/public_origin_configured_test.go
+++ b/module/services/accounts/code/pkg/auth/public_origin_configured_test.go
@@ -95,3 +95,51 @@ func pin(t *testing.T, origin string) {
t.Cleanup(func() { auth.SetConfiguredPublicOrigin(previous) })
auth.SetConfiguredPublicOrigin(origin)
}
+
+// An explicit DEFAULT port is the same origin as none, and the two sides spell it
+// differently: a browser and the frontend both drop it, while a configured value may
+// carry it. Retaining it made an operator's `https://app.example:443` refuse the
+// equivalent origin the frontend forwards.
+func TestR1019ConfiguredOriginDefaultPorts(t *testing.T) {
+ for _, tc := range []struct{ configured, forwarded string }{
+ {"https://app.cell.example:443", "https://app.cell.example"},
+ {"https://app.cell.example", "https://app.cell.example:443"},
+ {"http://localhost:80", "http://localhost"},
+ {"http://localhost", "http://localhost:80"},
+ } {
+ t.Run(tc.configured+" vs "+tc.forwarded, func(t *testing.T) {
+ pin(t, tc.configured)
+ _, err := auth.WithVerifiedPublicOrigin(context.Background(), tc.forwarded)
+ require.NoError(t, err, "equivalent origins must not disagree over a default port")
+ })
+ }
+}
+
+// A NON-default port is part of the origin and still distinguishes it, so the
+// canonicalization above does not collapse genuinely different origins.
+func TestR1019NonDefaultPortStillDistinguishesAnOrigin(t *testing.T) {
+ pin(t, "https://app.cell.example")
+ _, err := auth.WithVerifiedPublicOrigin(context.Background(), "https://app.cell.example:8443")
+ require.ErrorIs(t, err, auth.ErrPublicOriginNotConfigured)
+
+ pin(t, "https://app.cell.example:8443")
+ _, err = auth.WithVerifiedPublicOrigin(context.Background(), "https://app.cell.example")
+ require.ErrorIs(t, err, auth.ErrPublicOriginNotConfigured)
+}
+
+// CanonicalPublicOrigin returns the canonical spelling, so a caller that stores the
+// result stores one form.
+func TestR1019CanonicalOriginDropsOnlyTheDefaultPort(t *testing.T) {
+ for candidate, want := range map[string]string{
+ "https://app.example:443": "https://app.example",
+ "https://app.example": "https://app.example",
+ "https://app.example:8443": "https://app.example:8443",
+ "http://127.0.0.1:80": "http://127.0.0.1",
+ "http://127.0.0.1:3000": "http://127.0.0.1:3000",
+ "https://app.example/": "https://app.example",
+ } {
+ got, err := auth.CanonicalPublicOrigin(candidate)
+ require.NoError(t, err, candidate)
+ require.Equal(t, want, got, candidate)
+ }
+}
From 45c1e26a11ca284637c40d9555f20bc0d8d0e7bc Mon Sep 17 00:00:00 2001
From: Antoine Toussaint
<312536060+antoinetoussaint-byte@users.noreply.github.com>
Date: Mon, 5 Oct 2026 10:31:04 -0400
Subject: [PATCH 19/46] test: the eight fix-removal mutations that survived the
review (R1019-N15)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The review ran 26 representative fix-removal mutations and found eight surviving the
tests I had landed. A mutation that survives means the test suite does not hold the
fix, whatever the suite's result says, so each survivor gets a test that fails when
the fix is removed.
- **Gateway boot wiring.** Every deployed refusal is only a refusal if main wires it,
and removing a call site left every unit test green while a deployed gateway would
start on a published credential, with no shared store for revocation, rate limits
or prefix claims, and with no trusted-proxy range. All five wirings are now asserted
from the source, each matched with codefly.IsLocal() specifically โ wiring one to a
constant is the same defect as not wiring it.
- **Marketing HSTS.** The marketing service had no header test at all; it has one now,
covering the transport pin, the absence of includeSubDomains and preload, and the
framing and sniffing headers beside them, so removing any of the four is detected
rather than only the newest.
- **Datasource key independence.** The existing test asserted only that the two keys
DIFFER, which a link key derived from the ticket input still satisfies โ so the two
purposes could share one secret again, which is the whole of the finding. The test
now varies one input at a time and requires the other purpose not to move.
- **Logout session revocation, Connect response lifting, identity-provider reader
selection and datasource configuration** are covered by the tests landed with their
own findings; each was re-checked here by removing the fix and confirming red.
Also corrects a claim of mine the review found overstated: I wrote that a deployed
wiring cannot reach the in-process rate limiter. The option exists and any caller
could pass it. What holds is narrower and is what the new source assertion actually
establishes โ the only production call site passes codefly.IsLocal(), and production
neither hard-enables the option nor uses the per-replica constructor.
Refs #1018
Co-Authored-By: Claude Opus 5 (1M context)
---
.../datasource_content_ticket_test.go | 39 +++++++++++++++
.../code/shipped_placeholder_test.go | 48 +++++++++++++++++++
.../code/tests/security-headers.test.ts | 44 +++++++++++++++++
3 files changed, 131 insertions(+)
create mode 100644 module/services/marketing/code/tests/security-headers.test.ts
diff --git a/module/services/accounts/code/pkg/business/datasource_content_ticket_test.go b/module/services/accounts/code/pkg/business/datasource_content_ticket_test.go
index 62ca7c52f..03b2d47e1 100644
--- a/module/services/accounts/code/pkg/business/datasource_content_ticket_test.go
+++ b/module/services/accounts/code/pkg/business/datasource_content_ticket_test.go
@@ -100,3 +100,42 @@ func TestDatasourceKeysStayDistinctUnderOneProvisionedValue(t *testing.T) {
t.Fatal("the link key and the ticket key must differ even from one provisioned value")
}
}
+
+// Each purpose's key must derive from ITS OWN delivered input. Asserting only that the
+// two keys differ is not enough: a link key derived from the TICKET input through a
+// domain label still differs from the ticket key, so that assertion passes while the
+// two purposes once again share one secret โ which is the whole of SA-F-KEYREUSE.
+//
+// So this varies one input at a time and requires the other purpose not to move.
+func TestR1019DatasourceKeysDeriveFromTheirOwnInput(t *testing.T) {
+ keys := func(ticket, link string) (string, string) {
+ s := &Service{}
+ s.SetDatasourceKeys([]byte(ticket), []byte(link))
+ if s.datasourceTicketSigner == nil || s.datasourceLinkKey == nil {
+ t.Fatal("both purposes must be available")
+ }
+ return string(s.datasourceTicketSigner.key), string(s.datasourceLinkKey)
+ }
+
+ baseTicket, baseLink := keys("ticket-key-one", "link-key-one")
+
+ // Changing the LINK input must move the link key and leave the ticket key alone.
+ movedTicket, movedLink := keys("ticket-key-one", "link-key-two")
+ if movedLink == baseLink {
+ t.Fatal("the link key does not depend on the account-link input")
+ }
+ if movedTicket != baseTicket {
+ t.Fatal("the ticket key must not depend on the account-link input")
+ }
+
+ // And changing the TICKET input must move the ticket key and leave the link key
+ // alone. This is the direction that catches a link key derived from the ticket
+ // input.
+ movedTicket, movedLink = keys("ticket-key-two", "link-key-one")
+ if movedTicket == baseTicket {
+ t.Fatal("the ticket key does not depend on the content-ticket input")
+ }
+ if movedLink != baseLink {
+ t.Fatal("the link key must not depend on the content-ticket input")
+ }
+}
diff --git a/module/services/auth-gateway/code/shipped_placeholder_test.go b/module/services/auth-gateway/code/shipped_placeholder_test.go
index feda7d7f0..154473cf5 100644
--- a/module/services/auth-gateway/code/shipped_placeholder_test.go
+++ b/module/services/auth-gateway/code/shipped_placeholder_test.go
@@ -1,6 +1,7 @@
package main
import (
+ "os"
"strings"
"testing"
@@ -54,3 +55,50 @@ func TestPerimeterCredentialsCoverEveryTrustDecidingKey(t *testing.T) {
"gateway-trust/CODEFLY_GATEWAY_TOKEN": true,
}, names)
}
+
+// The boot refusals are only refusals if main WIRES them. Removing a call site leaves
+// every unit test above green while a deployed gateway starts on a published
+// credential, with no shared store for revocation or rate limits, and with no
+// trusted-proxy range โ so the wiring is asserted from the source.
+//
+// A fourth refusal stood here, for a durable module prefix-claim store. It is gone
+// because its subject is: a module route is no longer claimed at runtime but read
+// per request from the delivered registry (gateway_module_routes.go), which is
+// shared and durable by construction and fails closed when no snapshot has loaded.
+//
+// Each is matched with codefly.IsLocal() specifically: wiring one of these to a
+// constant, or to a request-derived value, is the same defect as not wiring it.
+func TestR1019DeployedRefusalsAreWiredAtBoot(t *testing.T) {
+ source, err := os.ReadFile("main.go")
+ require.NoError(t, err)
+ text := string(source)
+
+ for _, wiring := range []struct{ what, call string }{
+ {"the shipped-placeholder refusal", "requirePerimeterCredentials(codefly.IsLocal())"},
+ {"the trusted-proxy range refusal", "requireProxyTrust(workspaceEnv(\"gateway\", \"TRUSTED_PROXY_CIDRS\"), codefly.IsLocal())"},
+ {"the revocation-store refusal", "newRevoker(redisURL, revocationTTL, codefly.IsLocal())"},
+ {"the rate-limit store refusal", "WithInProcessBackendAllowed(codefly.IsLocal())"},
+ } {
+ require.Contains(t, text, wiring.call,
+ "%s must be wired at boot from the runtime, not from a constant", wiring.what)
+ }
+
+ // And the refusal has to stop the process. A call whose error is ignored is not a
+ // refusal; every one of these is fatal at boot.
+ require.NotContains(t, text, "_ = requirePerimeterCredentials",
+ "the placeholder refusal's error must not be discarded")
+}
+
+// The per-replica limiter is reachable only through a distinct constructor, so a
+// deployed wiring cannot opt into it by passing a flag. This is the claim the review
+// found overstated: the option EXISTS and any caller could pass it, so what holds is
+// that the only production call site passes codefly.IsLocal() โ which is what the
+// source assertion above actually establishes.
+func TestR1019InProcessLimiterIsNotTheProductionWiring(t *testing.T) {
+ source, err := os.ReadFile("main.go")
+ require.NoError(t, err)
+ require.NotContains(t, string(source), "WithInProcessBackendAllowed(true)",
+ "production must not hard-enable the per-replica limiter")
+ require.NotContains(t, string(source), "newInProcessRateLimiter(",
+ "the per-replica constructor is for tests and local development only")
+}
diff --git a/module/services/marketing/code/tests/security-headers.test.ts b/module/services/marketing/code/tests/security-headers.test.ts
new file mode 100644
index 000000000..42866be5e
--- /dev/null
+++ b/module/services/marketing/code/tests/security-headers.test.ts
@@ -0,0 +1,44 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import nextConfig from "@/../next.config.mjs";
+
+type Header = { key: string; value: string };
+
+async function headersFor(path: string): Promise