Issue every token to exactly one audience #7

Merged
jak merged 5 commits from review/single-audience into groups-capabilities 2026-10-20 19:40:02 -04:00
Owner

Problem

Every token Consent mints is signed with the issuer domain I (authorityDomain). I also did a second job: it was the audience of Consent's own API, so FinalizeAuthorization minted every app token with aud = [I, Ax]. Nothing keeps I apart from Consent's public host P or from app audiences Ax, which allowed two collisions:

  • P = I, the natural production setup. Consent's web UI accepted every app's tokens as logins, so an app's backend could act as any of its users on Consent, including on admin pages.
  • Ax = I, an app registered at the authority domain. App x accepted every other app's tokens. Its own tokens resolved as "Consent-only", so refresh skipped the group check and never revoked them.

The model

  • I is only the issuer. It appears in iss and nowhere else. Every validator checks iss == I and the signature, and nothing compares I with P or with any app audience, so any value of I is safe.
  • Every token has exactly one audience: the integration it was issued to. App x's tokens have aud = Ax. Consent's web UI tokens have aud = P, because the web UI is the internal consent integration.
  • Apps (tokens.InitClient) and the web UI accept a token only when aud equals their audience exactly.
  • Consent's API (refresh and userinfo) recognises its own tokens by issuer and signature, then looks up the single audience:
    • An unknown audience is denied, and refresh revokes the token.
    • consent needs no group check and gets no capabilities. Userinfo with the groups scope is denied. In practice consent tokens carry no scopes, so they fail the identity scope check.
    • Any other integration needs current group access.
  • The rule that skipped I and the "nothing left means Consent-only" fallback are gone. Service.issuerDomain now only feeds the capability budget, which counts the issuer once.

Commits

  1. fixed: issued every token to its integration's audience only covers the behaviour change, the regression tests, and the budget. It still uses []string audiences.
  2. changed: made a token's audience a single string is the pkg/tokens type change. It comes after the behaviour commit, not before it. With a string audience, FinalizeAuthorization can't mint [I, Ax], and userinfo's resource validator required I in aud, so a type-only first commit couldn't pass the tests. Each commit compiles and passes make test.
  3. added: documented the issuer and single-audience token model covers README, pkg/client docs, and the consent-integrate skills.

Public API changes in pkg/tokens and pkg/testing

  • RefreshTokenOptions.Audience and AccessTokenOptions.Audience are now string.
  • AccessToken.Audience() and RefreshToken.Audience() now return string.
  • Validator is now ValidateIssuer(string) bool, ValidateAudience(string) bool, and VerifySignature(...). ValidateIssuer replaces ValidateDomain. ValidateAudience replaces ValidateAudiences and ShouldValidateAudience. Client accepts only an exact, non-blank match. Server accepts any audience.
  • Issuing rejects a blank audience or one containing whitespace. Decoding rejects any token whose aud is missing, blank, contains whitespace (a space-separated list), or isn't a JSON string. This applies to both validators.
  • CapabilityBudget counts the issuer once in iss and the audience once in aud.
  • pkg/testing: TestEnv.IssueAccessTokenWithAudience and IssueRefreshTokenWithAudience now take a string audience.

New tests

  • Collision A, P == I:
    • internal/server/server_test.go uses the webClientOptions that buildProdAuthConfig builds, extracted for the test. App access and refresh tokens are rejected and the web UI's own tokens decode.
    • internal/integration/audience_test.go runs end to end with I set to the Consent host. App cookies on /admin redirect to login without a refresh call, and Consent cookies on the app are refused.
  • Collision B, Ax == I (internal/service/audience_test.go): the app's validator rejects other apps' and Consent's tokens. Refresh checks group access, and losing access denies refresh and userinfo and revokes the token.
  • Userinfo works with an app access token. A consent web token gets ErrInsufficientScope. A consent token gets no capabilities or groups, and one with the groups scope is denied.
  • Minting: FinalizeAuthorization and Login mint exactly one audience. Refresh of an unknown audience revokes the token.
  • Decoding rejects blank, missing, whitespace, space-separated, and array audiences with both validators (pkg/tokens/audience_internal_test.go). Existing issuance tests cover rejection of blank audiences.
  • Budget: TestCapabilityBudget_CountsIssuerAndAudienceOnce.

Existing tests edited

(a) Type-driven: []string to string, and the Validator method renames

  • Audience arguments at call sites only, with no assertion changes:
    • internal/api/auth_test.go: refresh and logout tests calling StoreTestRefreshToken and IssueTestRefreshToken.
    • internal/database/refresh_test.go: testAudience1/2 and call sites.
    • internal/service/auth_test.go: refresh and revoke tests.
    • internal/service/manifests_test.go: the IssueRefreshToken options in TestApplyIntegrationManifest_LongIssuerDomainStillIssuesLargestCatalog.
    • pkg/client/client_test.go: two issuance calls.
    • pkg/tokens tokens_test.go, logging_test.go, options_test.go, access_test.go, refresh_test.go: helper signatures and call sites.
    • pkg/tokens/budget_test.go.
  • TestAccessToken_Issue_EmptyAudience and TestRefreshToken_Issue_EmptyAudience: nil became "".
  • TestServer_IssueAccessToken_InvalidAudience and TestServer_IssueRefreshToken_InvalidAudience: the empty and whitespace cases now use string values.
  • Validator renames:
    • TestClient_ValidateDomain became TestClient_ValidateIssuer, and TestServer_ValidateDomain became TestServer_ValidateIssuer.
    • TestClient_ValidateAudiences_Single became TestClient_ValidateAudience_Single.
    • TestClient_ShouldValidateAudience became TestClient_ValidateAudience_Blank, which asserts that a blank audience is rejected.
    • TestServer_ShouldValidateAudience became TestServer_ValidateAudience, which asserts that any audience is accepted.
  • internal/integration/auth_test.go: the issueExpiredAccessToken parameter type. The harness now takes its issuer domain from newE2EHarnessWithIssuer. newE2EHarness keeps the old fixed issuer, and no assertions changed.

(b) Tests that encoded the old model: I in aud, multi-audience tokens, or the I-skip rule

  • internal/api/auth_test.go:
    • Dropped I (consentAudience) from aud in TestAPIUserInfo_IdentityOnly, TestAPIUserInfo_ProfileScope, TestAPIUserInfo_CapabilitiesAndGroupsAreLiveAndNotCached, TestAPIUserInfo_RequiresIdentityScope, and TestAPIUserInfo_BearerSchemeIsCaseInsensitive.
    • TestAPIUserInfo_RequiresConsentAudience became TestAPIUserInfo_RejectsUnknownAudience, and its expected status changed from 400 to 403. The token now decodes, and its unregistered audience is access denied.
  • internal/service/auth_test.go: dropped I from aud in TestRefreshAccessToken_AccessDeniedWhenGroupAccessLost, TestUserInfoGroupsRequireScopeAndListOnlyGrantingGroups, and the setupCapabilityAuth helper. The helper is used by TestCapabilitiesAreIssuedWithoutScopeAndReadLive and TestCapabilitiesFollowRemainingGrantingGroups.
  • internal/service/manifests_test.go:
    • TestApplyIntegrationManifest_LargestAcceptedCatalogStillIssuesTokens no longer puts I in aud.
    • TestApplyIntegrationManifest_LongIssuerDomainStillIssuesLargestCatalog no longer puts I in aud, and its comment no longer says the issuer is carried twice.
  • pkg/tokens/budget_test.go: TestCapabilityBudget_EncodedSizeMatchesIssuedToken and TestCapabilityBudget_CheckAgreesWithIssuer now issue the comparison token with one audience.
  • pkg/tokens/client_test.go: TestClient_ValidateAudiences_Multiple, now TestClient_ValidateAudience_Multiple, asserts that a list containing the audience is rejected.
  • pkg/tokens access_test.go and refresh_test.go:
    • *_Issue_BlankAudienceEntry now uses the list "aud other" instead of ["aud", ""], and expects rejection.
    • *_Fields now issues one audience instead of two.
  • pkg/tokens/server_test.go:
    • The blank case (["aud", ""]) in the InvalidAudience tables became a list case.
    • TestServer_MultipleAudiences became TestServer_AcceptsAnyAudience.

No other existing test failed.

Notes

  • Conflicts: this will conflict with PR #5, which moves the budget and scopes into pkg/tokens. service.BuildIntegrationCapabilityBudget stays where it is here. It will also conflict with PR #6 in internal/service/auth_test.go. New service tests are in audience_test.go to keep that conflict small. Resolve both when merging.
  • Dev mode: pkg/testing.TestVerifier validates with a server validator, so it still accepts any audience from its own key. This was already true, and dev mode issues aud = P only, so nothing there assumed multiple audiences. cmd/dev-client already used InitClient with its single audience.
  • Trade-off: a copied web-UI access token still works until it expires after logout (option C1 in the design). Admin rights are checked live.

🤖 Generated with Claude Code

## Problem Every token Consent mints is signed with the issuer domain **I** (`authorityDomain`). I also did a second job: it was the audience of Consent's own API, so `FinalizeAuthorization` minted every app token with `aud = [I, Ax]`. Nothing keeps I apart from Consent's public host **P** or from app audiences **Ax**, which allowed two collisions: - **P = I**, the natural production setup. Consent's web UI accepted every app's tokens as logins, so an app's backend could act as any of its users on Consent, including on admin pages. - **Ax = I**, an app registered at the authority domain. App x accepted every other app's tokens. Its own tokens resolved as "Consent-only", so refresh skipped the group check and never revoked them. ## The model - **I is only the issuer.** It appears in `iss` and nowhere else. Every validator checks `iss == I` and the signature, and nothing compares I with P or with any app audience, so any value of I is safe. - **Every token has exactly one audience: the integration it was issued to.** App x's tokens have `aud = Ax`. Consent's web UI tokens have `aud = P`, because the web UI is the internal `consent` integration. - Apps (`tokens.InitClient`) and the web UI accept a token only when `aud` equals their audience exactly. - Consent's API (refresh and userinfo) recognises its own tokens by issuer and signature, then looks up the single audience: - An unknown audience is denied, and refresh revokes the token. - `consent` needs no group check and gets no capabilities. Userinfo with the `groups` scope is denied. In practice `consent` tokens carry no scopes, so they fail the identity scope check. - Any other integration needs current group access. - The rule that skipped I and the "nothing left means Consent-only" fallback are gone. `Service.issuerDomain` now only feeds the capability budget, which counts the issuer once. ## Commits 1. `fixed: issued every token to its integration's audience only` covers the behaviour change, the regression tests, and the budget. It still uses `[]string` audiences. 2. `changed: made a token's audience a single string` is the pkg/tokens type change. It comes **after** the behaviour commit, not before it. With a `string` audience, `FinalizeAuthorization` can't mint `[I, Ax]`, and userinfo's resource validator required I in `aud`, so a type-only first commit couldn't pass the tests. Each commit compiles and passes `make test`. 3. `added: documented the issuer and single-audience token model` covers README, pkg/client docs, and the consent-integrate skills. ## Public API changes in pkg/tokens and pkg/testing - `RefreshTokenOptions.Audience` and `AccessTokenOptions.Audience` are now `string`. - `AccessToken.Audience()` and `RefreshToken.Audience()` now return `string`. - `Validator` is now `ValidateIssuer(string) bool`, `ValidateAudience(string) bool`, and `VerifySignature(...)`. `ValidateIssuer` replaces `ValidateDomain`. `ValidateAudience` replaces `ValidateAudiences` and `ShouldValidateAudience`. `Client` accepts only an exact, non-blank match. `Server` accepts any audience. - Issuing rejects a blank audience or one containing whitespace. Decoding rejects any token whose `aud` is missing, blank, contains whitespace (a space-separated list), or isn't a JSON string. This applies to both validators. - `CapabilityBudget` counts the issuer once in `iss` and the audience once in `aud`. - pkg/testing: `TestEnv.IssueAccessTokenWithAudience` and `IssueRefreshTokenWithAudience` now take a `string` audience. ## New tests - Collision A, P == I: - `internal/server/server_test.go` uses the `webClientOptions` that `buildProdAuthConfig` builds, extracted for the test. App access and refresh tokens are rejected and the web UI's own tokens decode. - `internal/integration/audience_test.go` runs end to end with I set to the Consent host. App cookies on `/admin` redirect to login without a refresh call, and Consent cookies on the app are refused. - Collision B, Ax == I (`internal/service/audience_test.go`): the app's validator rejects other apps' and Consent's tokens. Refresh checks group access, and losing access denies refresh and userinfo and revokes the token. - Userinfo works with an app access token. A `consent` web token gets `ErrInsufficientScope`. A `consent` token gets no capabilities or groups, and one with the groups scope is denied. - Minting: `FinalizeAuthorization` and `Login` mint exactly one audience. Refresh of an unknown audience revokes the token. - Decoding rejects blank, missing, whitespace, space-separated, and array audiences with both validators (`pkg/tokens/audience_internal_test.go`). Existing issuance tests cover rejection of blank audiences. - Budget: `TestCapabilityBudget_CountsIssuerAndAudienceOnce`. ## Existing tests edited **(a) Type-driven: `[]string` to `string`, and the Validator method renames** - Audience arguments at call sites only, with no assertion changes: - internal/api/auth_test.go: refresh and logout tests calling `StoreTestRefreshToken` and `IssueTestRefreshToken`. - internal/database/refresh_test.go: `testAudience1/2` and call sites. - internal/service/auth_test.go: refresh and revoke tests. - internal/service/manifests_test.go: the `IssueRefreshToken` options in `TestApplyIntegrationManifest_LongIssuerDomainStillIssuesLargestCatalog`. - pkg/client/client_test.go: two issuance calls. - pkg/tokens tokens_test.go, logging_test.go, options_test.go, access_test.go, refresh_test.go: helper signatures and call sites. - pkg/tokens/budget_test.go. - `TestAccessToken_Issue_EmptyAudience` and `TestRefreshToken_Issue_EmptyAudience`: `nil` became `""`. - `TestServer_IssueAccessToken_InvalidAudience` and `TestServer_IssueRefreshToken_InvalidAudience`: the `empty` and `whitespace` cases now use string values. - Validator renames: - `TestClient_ValidateDomain` became `TestClient_ValidateIssuer`, and `TestServer_ValidateDomain` became `TestServer_ValidateIssuer`. - `TestClient_ValidateAudiences_Single` became `TestClient_ValidateAudience_Single`. - `TestClient_ShouldValidateAudience` became `TestClient_ValidateAudience_Blank`, which asserts that a blank audience is rejected. - `TestServer_ShouldValidateAudience` became `TestServer_ValidateAudience`, which asserts that any audience is accepted. - internal/integration/auth_test.go: the `issueExpiredAccessToken` parameter type. The harness now takes its issuer domain from `newE2EHarnessWithIssuer`. `newE2EHarness` keeps the old fixed issuer, and no assertions changed. **(b) Tests that encoded the old model: I in `aud`, multi-audience tokens, or the I-skip rule** - internal/api/auth_test.go: - Dropped I (`consentAudience`) from `aud` in `TestAPIUserInfo_IdentityOnly`, `TestAPIUserInfo_ProfileScope`, `TestAPIUserInfo_CapabilitiesAndGroupsAreLiveAndNotCached`, `TestAPIUserInfo_RequiresIdentityScope`, and `TestAPIUserInfo_BearerSchemeIsCaseInsensitive`. - `TestAPIUserInfo_RequiresConsentAudience` became `TestAPIUserInfo_RejectsUnknownAudience`, and its expected status changed from 400 to 403. The token now decodes, and its unregistered audience is access denied. - internal/service/auth_test.go: dropped I from `aud` in `TestRefreshAccessToken_AccessDeniedWhenGroupAccessLost`, `TestUserInfoGroupsRequireScopeAndListOnlyGrantingGroups`, and the `setupCapabilityAuth` helper. The helper is used by `TestCapabilitiesAreIssuedWithoutScopeAndReadLive` and `TestCapabilitiesFollowRemainingGrantingGroups`. - internal/service/manifests_test.go: - `TestApplyIntegrationManifest_LargestAcceptedCatalogStillIssuesTokens` no longer puts I in `aud`. - `TestApplyIntegrationManifest_LongIssuerDomainStillIssuesLargestCatalog` no longer puts I in `aud`, and its comment no longer says the issuer is carried twice. - pkg/tokens/budget_test.go: `TestCapabilityBudget_EncodedSizeMatchesIssuedToken` and `TestCapabilityBudget_CheckAgreesWithIssuer` now issue the comparison token with one audience. - pkg/tokens/client_test.go: `TestClient_ValidateAudiences_Multiple`, now `TestClient_ValidateAudience_Multiple`, asserts that a list containing the audience is **rejected**. - pkg/tokens access_test.go and refresh_test.go: - `*_Issue_BlankAudienceEntry` now uses the list `"aud other"` instead of `["aud", ""]`, and expects rejection. - `*_Fields` now issues one audience instead of two. - pkg/tokens/server_test.go: - The `blank` case (`["aud", ""]`) in the InvalidAudience tables became a `list` case. - `TestServer_MultipleAudiences` became `TestServer_AcceptsAnyAudience`. No other existing test failed. ## Notes - **Conflicts:** this will conflict with PR #5, which moves the budget and scopes into pkg/tokens. `service.BuildIntegrationCapabilityBudget` stays where it is here. It will also conflict with PR #6 in `internal/service/auth_test.go`. New service tests are in `audience_test.go` to keep that conflict small. Resolve both when merging. - **Dev mode:** `pkg/testing.TestVerifier` validates with a server validator, so it still accepts any audience from its own key. This was already true, and dev mode issues `aud = P` only, so nothing there assumed multiple audiences. cmd/dev-client already used `InitClient` with its single audience. - **Trade-off:** a copied web-UI access token still works until it expires after logout (option C1 in the design). Admin rights are checked live. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Consent stamped its issuer domain into every app token's audience so its
API could accept them. Nothing kept the issuer domain apart from the
public host or from app audiences, which allowed two collisions. When the
issuer domain equalled the public host, Consent's web UI accepted every
app's tokens as logins, including on admin pages. When an app registered
the issuer domain as its audience, it accepted every other app's tokens,
and refresh treated its own tokens as Consent-only and skipped the group
check.

The issuer domain now appears only in iss. FinalizeAuthorization mints
the integration's audience alone, and client validators require an exact
audience match. Refresh and userinfo recognise Consent's tokens by issuer
and signature, then resolve the integration from the single audience. An
unknown audience is denied. The internal consent integration needs no
group access and gets no capabilities. Every other integration needs
current group access. The rules that skipped the issuer domain and fell
back to Consent-only are gone.

The capability budget now counts the issuer once, so catalogs get a
little more room.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Tokens already carried one audience, but pkg/tokens still modelled it as
a list. Options and accessors now use a single string, and the aud claim
is one JSON string. Issuing refuses a blank audience or one containing
whitespace. Decoding rejects any token whose aud is not a single
non-blank string, whichever validator is used.

We reshaped the Validator interface around that model. ValidateIssuer
replaces ValidateDomain. ValidateAudience replaces ValidateAudiences and
ShouldValidateAudience: a Client accepts only its exact audience, and a
Server accepts any audience because Consent recognises its own tokens by
issuer and signature.

Callers of pkg/tokens and pkg/testing need to pass an audience string
instead of a slice, and custom Validator implementations need the new
method set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The README, the pkg/client docs, and the integration skills now explain
that the issuer domain only identifies the signer, and that every token
has exactly one audience, its integration's. The README's capability
estimates reflect the budget counting the issuer once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jak force-pushed review/single-audience from 91caae5474 to 50f1fba727 2026-10-20 17:00:19 -04:00 Compare
Refresh checked access against the integration named by the token's
audience. Rotation then stored the replacement under the integration and
user recorded on the consumed row. Nothing tied the two together, so a
row stored for one integration could mint tokens for another.

RotateRefreshToken now consumes the row only when it belongs to the user
with the replacement's subject and the integration with the replacement's
audience. The match happens in the same delete, so a mismatched pair
cannot rotate. A mismatch reports ErrTokenNotFound and leaves the row
unchanged.

We kept the access check ahead of the store read. Losing access deletes a
user's refresh tokens, and refresh must still report the denial for a token
that is already gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jak force-pushed review/single-audience from c19e7a5481 to e1e6a4e70b 2026-10-20 18:10:34 -04:00 Compare
Token refresh and userinfo used a nil access to mean the internal consent
integration. Callers branched on nil to skip the group check and drop
capabilities. A nil returned by mistake for any other integration would
also have skipped the group check.

IntegrationAccess now carries an explicit AdmitsEveryone flag, and
Admitted is true when that flag is set or a group grants access. The zero
value admits no one. GetIntegrationAccess is the one place that gives the
consent integration access that admits everyone, with no groups or
capabilities. Every integration, including consent, now goes through the
same group-access check, and callers always get a non-nil access.

Userinfo still refuses the groups scope for consent tokens, now by
checking AdmitsEveryone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jak force-pushed review/single-audience from e76f0ea7f4 to d7f5c4780a 2026-10-20 18:26:49 -04:00 Compare
jak left a comment

two small comment changes,

two small comment changes,
@ -85,2 +86,2 @@
Subject string
Audience []string
Subject string
// Audience names the one integration the token is issued to.
Author
Owner

comment is unnecessary

comment is unnecessary
jak marked this conversation as resolved
@ -94,2 +96,2 @@
Subject string
Audience []string
Subject string
// Audience names the one integration the token is issued to.
Author
Owner

comment is unnecessary

comment is unnecessary
jak marked this conversation as resolved
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jak force-pushed review/single-audience from 794c517bf2 to 627ad17811 2026-10-20 19:35:10 -04:00 Compare
jak merged commit 627ad17811 into groups-capabilities 2026-10-20 19:40:02 -04:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
pollinator/consent!7
No description provided.