Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
52d6bf2
fix(accounts): an OAuth state is accepted once, and is refused when s…
antoinetoussaint-byte Oct 5, 2026
00bd227
fix(accounts): a refused refresh and a refused OAuth begin answer a s…
antoinetoussaint-byte Oct 5, 2026
2e02161
fix(accounts): the reserved module audience is mintable only from a m…
antoinetoussaint-byte Oct 8, 2026
a90a8b4
fix(accounts): a privileged actor's sensitive operation requires a se…
antoinetoussaint-byte Oct 5, 2026
8365850
fix(accounts): the refresh credential is a cookie on every authentica…
antoinetoussaint-byte Oct 5, 2026
09dfe53
fix(accounts): an identity assertion in the request body is honoured …
antoinetoussaint-byte Oct 5, 2026
54fd9e1
fix: a deployed service refuses a shipped placeholder perimeter crede…
antoinetoussaint-byte Oct 5, 2026
d189b68
fix(auth-gateway): rate limits key on the real client, and a deployed…
antoinetoussaint-byte Oct 5, 2026
fcaaddd
fix(auth-gateway): a cross-origin grant on a forwarded response is th…
antoinetoussaint-byte Oct 5, 2026
a625989
fix(auth-gateway): a module or solution upstream receives the person'…
antoinetoussaint-byte Oct 5, 2026
15daa22
fix: the verified public origin is operator configuration, never a re…
antoinetoussaint-byte Oct 5, 2026
db75f2e
fix(frontend, marketing): the product and marketing origins pin HTTPS…
antoinetoussaint-byte Oct 5, 2026
26a031f
fix: the identity provider's own credentials live in a group only acc…
antoinetoussaint-byte Oct 5, 2026
93fb5f3
fix(accounts): the datasource message-authentication keys are deliver…
antoinetoussaint-byte Oct 5, 2026
44fb7f2
fix: the call sites a build tag and an untyped transform hid
antoinetoussaint-byte Oct 5, 2026
8822998
fix(accounts): the review's four accounts majors — a factor at every …
antoinetoussaint-byte Oct 8, 2026
5795f0b
fix(auth-gateway): only a final response is sanitized (R1019-N09)
antoinetoussaint-byte Oct 8, 2026
997f215
fix(accounts): a usable Connect refresh carrier, a public reason for …
antoinetoussaint-byte Oct 8, 2026
45c1e26
test: the eight fix-removal mutations that survived the review (R1019…
antoinetoussaint-byte Oct 5, 2026
727407d
fix: a required perimeter credential must be present, and the marketi…
antoinetoussaint-byte Oct 6, 2026
e3bd8d2
fix: one spelling of an origin on both sides, a refusal that cannot r…
antoinetoussaint-byte Oct 6, 2026
2de4f2f
fix(accounts): a factor at principal creation and revocation, and a s…
antoinetoussaint-byte Oct 6, 2026
b17c944
test: the eight mutations that still survived, and the stripper that …
antoinetoussaint-byte Oct 6, 2026
74e572c
docs: the precise blocker behind each finding this change leaves open…
antoinetoussaint-byte Oct 6, 2026
0b94255
fix: one validator for the public origin, and a resource-bound tool t…
antoinetoussaint-byte Oct 8, 2026
7c25b6d
merge main, and confine a resource token instead of trusting the path…
antoinetoussaint-byte Oct 11, 2026
112a4ba
credential issuance needs a factor, a sign-out ends the family, and a…
antoinetoussaint-byte Oct 11, 2026
4a69182
fix: one APP_BASE_URL declaration, and a gate that names a duplicated…
antoinetoussaint-byte Oct 11, 2026
236f682
merge main (188f2fe91), regenerating rather than hand-merging the gen…
antoinetoussaint-byte Oct 11, 2026
fe4c142
test: the tenancy test satisfies the factor its mutation sits behind
antoinetoussaint-byte Oct 11, 2026
6b2dfc9
fix(frontend): the sign-out page reports its outcome through a callba…
antoinetoussaint-byte Oct 11, 2026
fc9dfc7
merge main (264d41a26), and the new subject-tenancy test satisfies it…
antoinetoussaint-byte Oct 11, 2026
8e10c2e
merge main (cdae1aa9f); generated artifacts taken from main, to be re…
antoinetoussaint-byte Oct 11, 2026
b919de6
chore: regenerate the contract artifacts from the merged inputs
antoinetoussaint-byte Oct 11, 2026
96db2d3
style(auth-gateway): gofmt ext_authz.go
antoinetoussaint-byte Oct 11, 2026
a3c2cfd
merge main (03184f91b); the same eight generated artifacts, regenerat…
antoinetoussaint-byte Oct 11, 2026
c99a6f9
chore: regenerate the contract artifacts after the second merge
antoinetoussaint-byte Oct 11, 2026
3a0425d
merge main (07a5db32b); the generated artifacts regenerated next
antoinetoussaint-byte Oct 11, 2026
b64cc9c
chore: regenerate the contract artifacts after the third merge
antoinetoussaint-byte Oct 11, 2026
7cfda8a
fix(frontend): every sign-out control answers for whether the sign-ou…
antoinetoussaint-byte Oct 11, 2026
80d0204
fix(gateway): the host's public origin is the host's, and a confined …
antoinetoussaint-byte Oct 11, 2026
7a6c621
docs(gateway): the guidance describes the credentials an upstream act…
antoinetoussaint-byte Oct 11, 2026
d9909ea
merge main (b13125408); the generated artifacts regenerated next
antoinetoussaint-byte Oct 11, 2026
1262958
chore: regenerate the contract artifacts after merging main
antoinetoussaint-byte Oct 11, 2026
cb0c016
test(accounts): the merged OAuth suite delivers one key per authentic…
antoinetoussaint-byte Oct 11, 2026
2fa096b
Merge remote-tracking branch 'origin/main' into issue-1018-security-p…
antoinetoussaint-byte Oct 11, 2026
3fac9ca
chore(frontend): the published client carries a new patch version
antoinetoussaint-byte Oct 11, 2026
9848f67
Merge remote-tracking branch 'origin/main' into issue-1018-security-p…
antoinetoussaint-byte Oct 11, 2026
ff56575
chore(frontend): regenerate the lock from the merged manifest
antoinetoussaint-byte Oct 11, 2026
6fd0d92
fix(gateway,accounts): a consumer's credential reaches its own module…
antoinetoussaint-byte Oct 11, 2026
4785a54
fix(frontend): the account menu's sign-out takes the shared journey, …
antoinetoussaint-byte Oct 11, 2026
8be8aa0
test(gateway): a caller cannot assert its own confinement at the rout…
antoinetoussaint-byte Oct 11, 2026
3b4506d
fix(accounts): an undeclared ceiling is nothing for a credential deli…
antoinetoussaint-byte Oct 11, 2026
1709f26
test(gateway): the ceiling headers on both hops, an ordinary session,…
antoinetoussaint-byte Oct 11, 2026
3036a26
fix(frontend): the sign-out sweep sees every spelling, and the impers…
antoinetoussaint-byte Oct 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions configurations/local/datasource-keys.secret.env
1 change: 1 addition & 0 deletions configurations/local/identity-provider.secret.env
32 changes: 29 additions & 3 deletions module/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,20 @@ No group declares module authority: the module principal registry is derived
from delivered authority inside the module envelope a platform super_admin
writes ([services/accounts/AGENTS.md](./services/accounts/AGENTS.md#the-composed-module-service-principal)).

**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.**
`federation` fails closed when its declared-presence keys are empty: no
coordinate means no delivery surface and no execution binding, so no module may
Expand All @@ -146,6 +160,18 @@ 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.
44 changes: 38 additions & 6 deletions module/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

---
Expand Down Expand Up @@ -248,11 +248,11 @@ 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 |
| OAuth state / CSRF | 🟡 | Validated client-side in `sessionStorage`; no server-side double-check (gap) |
| 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 |
| Email verification | 🟡 | `email_verified` flag stored; no flow that issues + checks |
Expand Down Expand Up @@ -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, impersonation, organization deletion, waitlist review, the role/scope/principal grants (creation and revocation) and the platform-admin mutations (suspend, unsuspend, 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, which an ENROLLED organization admin now meets on the grant paths (see below). **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 |
Expand All @@ -531,6 +531,38 @@ 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.

**Who the gated set actually reaches, which is wider than "privileged actor".** The
role, scope and principal grants admit an organization ADMIN as well as an owner, and
`requireMFA` is fail-closed only for a privileged actor — for everyone else it keeps
the pre-existing opt-in rule, which refuses an ENROLLED member without recent
evidence. So an enrolled org admin who has not stepped up recently is now refused on
`AssignRole`, `RevokeRole`, `GrantScope`, `RevokeScope`, the role CRUD and
`CreateAgentPrincipal`, where before this change nothing asked. That is intended —
granting authority inside a tenant is not an ordinary tenant mutation, whoever does
it — and it is stated here because an operator reading only the paragraph above would
not predict the refusal. An admin who has enrolled nothing is still admitted, so no
roster is forced to enrol by this change alone.

Containment actions carry the same requirement: `SuspendUser`, `RevokeSession` and
`RevokePrincipal` need fresh factor evidence, so a responder acting on a compromise
steps up first. A runbook for those operations should say so, because the refusal
arrives at the least convenient moment by design.

### Nice-to-have (differentiators)

| Feature | This module | World-class |
Expand Down Expand Up @@ -609,7 +641,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.
Expand Down
23 changes: 23 additions & 0 deletions module/KEY_ROTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,29 @@ 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 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, 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
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:
Expand Down
28 changes: 28 additions & 0 deletions module/WORK_CONTEXTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,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
Expand Down
Loading
Loading