Skip to content

Feature/4144 add support for more scope policies#4219

Draft
stevenvegt wants to merge 90 commits into
project-gffrom
feature/4144-mixed-scopes
Draft

Feature/4144 add support for more scope policies#4219
stevenvegt wants to merge 90 commits into
project-gffrom
feature/4144-mixed-scopes

Conversation

@stevenvegt
Copy link
Copy Markdown
Member

No description provided.

stevenvegt and others added 30 commits April 13, 2026 12:37
Rename the PDPBackend interface method and introduce new types
(CredentialProfileMatch, ScopePolicy, credentialProfileConfig)
to support mixed OAuth2 scopes. The policy config struct now uses
explicit fields for organization/user PDs and scope_policy,
defaulting to profile-only. All callers and mocks updated.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Tests cover: multi-scope with one profile scope + other scopes,
multiple profile scopes (error), no profile scope (error),
and empty scope string (error).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Tests cover: scope_policy parsed from JSON config (dynamic, passthrough),
invalid scope_policy rejected at load time, dynamic without AuthZen
endpoint fails at startup, passthrough without endpoint succeeds.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Introduce ErrAmbiguousScope for multiple credential profile scopes
  (instead of wrapping ErrNotFound which was semantically wrong)
- Use strings.Fields instead of strings.Split for robust whitespace handling
- Add nil-check: credential profile must define at least one of organization/user
- Add doc comments on Config, ErrNotFound, FindCredentialProfile implementation
- Use value receiver on toWalletOwnerMapping (small non-mutating struct)
- Add test for consecutive spaces in scope string
- Assert ScopePolicy in multi-scope test
- Make Configure tests load single files instead of whole directory

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Implements the HTTP client for the AuthZen Access Evaluations API
(POST /access/v1/evaluations). Request uses AuthZen batch format:
shared subject/action/context with per-scope evaluations array.
Returns scope→decision map.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Tests cover: partial denial, HTTP 500, PDP unreachable, context
cancellation/timeout, evaluation count mismatch, malformed response.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Truncate PDP error body in error messages (prevent log injection)
- Validate duplicate resource IDs before sending request
- Add Accept: application/json header
- Add package doc comment
- Fix require.NoError inside httptest handler (capture request, assert outside)
- Rename context cancellation test for accuracy
- Add duplicate resource ID test
- Response body limiting delegated to StrictHTTPClient (caller responsibility)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Introduces PresentationDefinitionResolver that abstracts PD resolution.
When the remote AS metadata advertises a PD endpoint, the PD is fetched
remotely and the full scope string is returned for the token request.
Local fallback path is stubbed for the next cycle.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When no remote PD endpoint exists, the resolver calls FindCredentialProfile
locally. Profile-only rejects extra scopes, passthrough/dynamic forward all.
Tests cover both remote and local paths with all scope policies.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace direct PD fetch in RequestRFC021AccessToken with the
PresentationDefinitionResolver. The resolver is a dependency on
OpenID4VPClient, wired through Auth → NewClient. The policy backend
is passed through Auth to enable local PD fallback.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add nil guard on policyBackend in resolveLocal
- Return canonical credential profile scope for profile-only (not raw input)
- Add comment explaining dynamic treated same as passthrough on client side
- Add tests: nil policy backend, missing org PD, remote endpoint error
- Fix import grouping in test file

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Returns the full CredentialProfileMatch instead of only WalletOwnerMapping.
Callers that only need WalletOwnerMapping access match.WalletOwnerMapping.
Prepares for scope policy enforcement on the server side.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Profile-only scope policy rejects token requests with extra scopes
beyond the credential profile scope. Check happens early, before
expensive VP signature verification.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Verifies that passthrough scope policy grants all requested scopes.
No implementation change needed — existing code already passes the
full scope string through when not rejected by profile-only.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace the implicit pass-through of the raw input scope with an
explicit grantedScopesForPolicy switch. Profile-only grants only
the credential profile scope. Passthrough grants the profile scope
plus other scopes. Dynamic returns an error (not yet implemented).

Prevents accidental scope pass-through when a new policy is added.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
LocalPDP creates an authzen.Client during Configure when an AuthZen
endpoint is configured. PDPBackend exposes it via AuthZenEvaluator(),
returning nil when no endpoint is set.

This keeps AuthZen client ownership in the policy module (which owns
the config) and avoids wiring through cmd/root.go before config is loaded.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When scope_policy is 'dynamic', the server builds an AuthZen batch
evaluation request from the validated credentials (claims extracted
via resolveInputDescriptorValues, matching introspection behavior)
and calls the PDP. The credential profile scope must be approved
by the PDP or the request is denied. Other scopes are granted only
when the PDP approves them.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…rror

- Partial denial: denied other scopes excluded, approved ones granted
- PDP denies credential profile scope: request rejected (access_denied)
- PDP call fails: server_error returned with details

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Use StrictHTTPClient (timeout + response body limit) for AuthZen client
  instead of http.DefaultClient (memory #4185)
- Wrap credentialMap() / resolveInputDescriptorValues errors as OAuth2Error
  to preserve the spec-compliant error response contract
- Use generic Description for PDP errors, keep details in InternalError
  to avoid leaking PDP internals to the OAuth2 client
- Tighten dynamic-approves-all test to verify AuthZen request shape
  (subject.type, action.name, context.policy, evaluations layout)
- Fix AuthZenEvaluator interface doc comment
- Apply gofmt

Follow-up issues:
- #4202: apply scope policy to OpenID4VP / auth-code flow
- Claim role-bucket mismatch deferred to #4080 (two-VP flow)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Exercises the server-side token handler with a real AuthZen HTTP client
talking to an httptest server. Unlike unit tests that mock the evaluator,
this validates the full HTTP roundtrip: request serialization, response
parsing, and error propagation.

Tests cover: PDP approves all, partial denial, HTTP 500 error.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Verify that tokens with space-delimited scope strings (from multi-scope
requests) are returned unchanged via both IntrospectAccessToken and
IntrospectAccessTokenExtended. Also cover backwards compatibility for
single-scope legacy tokens.

No production code changes needed — the existing introspection passes
AccessToken.Scope through as-is, which correctly handles the OAuth2
space-delimited scope format.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Verifies that tokens issued via dynamic scope policy carry their
validated credential claims through to the introspection response as
AdditionalProperties, enabling resource servers to make authorization
decisions without re-processing VPs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
JorisHeadease and others added 22 commits May 7, 2026 20:56
Methods are reachable via auth.OpenID4VCIClient() now (introduced in
the previous commits). Keeping them on the OpenID4VP client conflated
two protocols on the same type.
The generated files were stale relative to docs/_static/auth/v2.yaml and
docs/_static/auth/iam.partial.yaml. Re-running oapi-codegen syncs comment
text (no schema or behavior change).
Resolves a typing concern from Rein's review of PR #4057: the field was
[]map[string]interface{} and indexed by string keys at the call site.
Now a typed AuthorizationDetail schema covering the fields used by the
OpenID4VCI flow.
Resolves a question from Rein's review of PR #4057: clarify that the
retry-once-with-fresh-nonce behavior is rooted in §8.3.1.2 (the spec
mandates fetching a new c_nonce; retrying once is local recovery policy).
The header comment claimed v1.0 conformance which was misleading: this
package is an internal node-to-node draft-11 flow used to issue
NutsAuthorizationCredentials between Nuts nodes (HTTP replacement of
the v5 gRPC network), not a Wallet implementation. Replace the header
with package godoc that names the divergences from v1.0 and points to
auth/openid4vci for the user/browser flow.

Resolves a documentation request from Rein's review of PR #4057.
Per OpenID4VCI 1.0 §12.2.4, mismatched issuer metadata MUST NOT be used.
Reject responses where credential_issuer does not match the URL the
metadata was retrieved for. Test covers the rejection path.
OpenID4VCI 1.0 §8.3 allows the credential field to be either a JSON
string (JWT-VC, SD-JWT-VC) or a JSON object (JSON-LD). Stringifying a
json.RawMessage that holds a JSON string yields the value with surrounding
quotes, which vc.ParseVerifiableCredential cannot consume. Unmarshal as
a string first; on failure, treat the raw bytes as a JSON-LD object.
OpenID4VCI 1.0 §3.3.4 / §8.2 require the wallet to send credential_identifier
(not credential_configuration_id) in the Credential Request when the Token
Response carries authorization_details with credential_identifiers. The
two parameters are mutually exclusive.

- TokenResponse.GetAny exposes structured extension parameters
- RequestCredentialOpts adds CredentialIdentifier; Client.RequestCredential
  picks credential_identifier when set and omits credential_configuration_id
- handleOpenID4VCICallback extracts credential_identifier from the token
  response's authorization_details and threads it through

Falls back to credential_configuration_id when the AS does not return
authorization_details, matching the §3.3.4 scope-flow alternative.
…6.2, §8.2

Three related strictness fixes for the OpenID4VCI v1.0 authorization_details
flow:

- OpenAPI: AuthorizationDetail requires credential_configuration_id and
  type=openid_credential (enum) per §5.1.1. The generated CredentialConfigurationId
  is now a non-optional string.
- Authorization Request handler: credentialConfigID assignment simplified
  (no nil check needed thanks to schema enforcement).
- Token Response processing: extractCredentialIdentifier now returns an error
  when authorization_details is present but does not yield a credential_identifier
  matching the requested credential_configuration_id (§6.2 makes
  credential_identifiers REQUIRED in that case; silently falling back to
  credential_configuration_id was incorrect).
The Credential Issuer URL is user-controlled (via the OpenAPI request
body), and the Nonce/Credential Endpoints come transitively from the
issuer's metadata. CodeQL flagged this as uncontrolled data flowing into
a network request.

Two layers of protection, mirroring the master security stack:

- NewClient now takes a core.HTTPRequestDoer (in production:
  httpclient.StrictHTTPClient via NewWithCache). That gives us the
  HTTPS-in-strict check, User-Agent, and 1MB response body limit for free.
- A strictMode flag on the client validates each target URL via
  core.ParsePublicURL on entry to OpenIDCredentialIssuerMetadata,
  RequestNonce, and RequestCredential. Strict mode adds rejection of
  IP hosts and reserved hostnames on top of HTTPS-only.

The validateURL helper localises the validation pattern; if rules ever
change, one place to update.
Bumps golang from 1.26.2-alpine to 1.26.3-alpine.

---
updated-dependencies:
- dependency-name: golang
  dependency-version: 1.26.3-alpine
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Per OpenID4VCI 1.0 §8.2 a Credential Request needs either
credential_configuration_id or credential_identifier. The OpenAPI
schema required the authorization_details field but allowed an empty
array, which would let a request reach the issuer with neither
identifier set. Add minItems:1 to the array so the empty case is
rejected at request validation time.
…tifier

Per OpenID4VCI 1.0 §12.2.1, the Credential Issuer Identifier is a URL
that "contains scheme, host and, optionally, port number and path
components, but no query or fragment components".

core.ParsePublicURL did not enforce this; an issuer URL with a query or
fragment slipped through and was forwarded into the constructed
.well-known URL via url.URL.String(). Reject these explicitly in
OpenIDCredentialIssuerMetadata.
Per OpenID4VCI 1.0 §F.1, the proof JWT aud claim MUST be the Credential
Issuer Identifier. The handler stored authzServerMetadata.Issuer (the
Authorization Server issuer) and used it as the proof audience, which is
only correct when the metadata's authorization_servers field is empty
(implicit AS == CI). When the issuer delegates to a separate AS, the AS
issuer URL differs from the Credential Issuer Identifier and the proof
is rejected.

Persist credentialIssuerMetadata.CredentialIssuer separately on the
session and use it for the proof audience. IssuerURL keeps its meaning
as the AS issuer (used elsewhere in the OpenID4VP flow).
Drop the local Error/ErrorCode types in auth/openid4vci. The OpenID4VCI
spec error response shape (`{"error": ..., "error_description": ...}`)
matches RFC 6749 / oauth.OAuth2Error one-to-one. Add the OpenID4VCI-only
extension code (invalid_nonce) to oauth.ErrorCode and let the client and
its callers use the existing oauth types directly.

Per Rein's review of PR #4057.
…lient

Per Rein's review: this validation conceptually belongs on the shared
HTTP transport (httpclient.StrictHTTPClient) so every outbound call gets
the IP/reserved-host check, not just OpenID4VCI. The current placement
preserves parity with master (which validated via oauth.IssuerIdToWellKnown)
and addresses the CodeQL SSRF finding for this PR. Mark as a follow-up to
consolidate the check in the shared client.
Make the comment on the credential parsing block explicit about the
reason: json.RawMessage keeps the raw JSON encoding, which for a JWT
includes the surrounding quotes. ParseVerifiableCredential rejects
that as invalid base64, so the unmarshal-as-string step is required
to strip the quotes (and is a no-op for JSON-LD objects via the
error fallback).
…known URL

credentialIssuerWellKnown was assigning u.EscapedPath() (already encoded)
back into u.Path; url.URL.String reescapes via EscapedPath, turning %2F
into %252F. Prepend the well-known segment to u.Path (decoded) and to
u.RawPath when the latter is set, so original encoding is preserved
without double-escaping.
The OpenAPI schema declares minItems: 1 but the StrictServer middleware
does not enforce minItems at runtime, so an empty array passed through
silently. The handler used a defensive 'if len > 0' guard that left
IssuerCredentialConfigurationID empty, which would later produce a
malformed Credential Request.

Replace the guard with an explicit core.InvalidInputError positioned
before the issuer/auth-server metadata fetches, so an invalid local
request does not trigger any outbound work. Update requestCredentials
test helper to populate AuthorizationDetails, and add a regression test.
The handler only consumes the first entry, so accepting an array with
multiple entries silently dropped extras. Add maxItems: 1 to the schema
to make the contract explicit, and tighten the handler guard from
'len > 0' to 'len == 1' so both bounds are checked at runtime (the
StrictServer middleware enforces neither).
Audit pass against the spec. Several section pointers in code and YAML
descriptions were imprecise or wrong:

- 'Section 8.2.1.1' for the JWT typ value -> Appendix F.1 (jwt Proof Type)
- 'Section 8.2.1' for proofs parameter -> Section 8.2 (with proof type
  formats listed in Appendix F)
- '§10' for notification_id -> §11 (Notification Endpoint)
- '§12.2' for the .well-known retrieval -> §12.2.2 (Credential Issuer
  Metadata Retrieval)
- '§5.1' for authorization_details usage in YAML descriptions -> §5.1.1
  (Using Authorization Details Parameter)

Documentation only; no behaviour change.
- Move scope-policy evaluation out of the API layer into policy.ScopeGranter
  with per-policy implementations. Addresses reinkrul's comment that the
  AuthZen request construction should not live in auth/api/iam/.
- Introduce policy.ScopeEvaluator + ScopeEvaluationInput as the generic
  abstraction over PDP backends; AuthZen becomes an adapter wired up via
  policy.NewAuthZenScopeEvaluator and exposed by LocalPDP.ScopeEvaluator().
- Fail fast in NewScopeGranter for profile-only + extra scopes, dynamic
  without an evaluator, and unsupported policies — these errors surface
  before VP cryptographic verification work is done.
- Rename `match` to `credentialProfile` in s2s_vptoken.go and the granter.
- Note startup failure in the policy.authzen.endpoint flag description.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@stevenvegt stevenvegt force-pushed the feature/4144-mixed-scopes branch from 25c35af to bd2ccb4 Compare May 11, 2026 10:00
JorisHeadease and others added 7 commits May 11, 2026 12:31
List OpenID4VCI 1.0 among the implemented specs and document the wallet
flow: Authorization Code with authorization_details, PKCE, Nonce
Endpoint, proof JWT, and invalid_nonce recovery. Note the unimplemented
operational features (deferred issuance, Notification Endpoint, multiple
credentials per call) and the unrelated draft-11 internal flow in
vcr/openid4vci.
Retraction markers are stored on the timeline so Get() can replicate
them, but must not surface from Search(). With an empty query, no
credential join was applied, so retraction VPs (which have no
credentials) were returned to API callers.

Fixes #4192

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Echo's BodyDump middleware (used when http.log: metadata-and-body) calls
c.Error(err) on its way out and still returns the err, so echo's server
loop invokes HTTPErrorHandler a second time for the same request. The
first invocation wrote the response correctly; the second logged the
operation error a second time and warned "Unable to send error back to
client, response already committed" — visible noise without a real
problem. echo.DefaultHTTPErrorHandler short-circuits on Committed=true;
ours did not. Mirror that behavior.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…scopes

# Conflicts:
#	auth/api/iam/api_test.go
#	auth/auth.go
#	auth/client/iam/openid4vp.go
Fixes HTTP/2 transport infinite loop on bad SETTINGS_MAX_FRAME_SIZE
in net/http/internal/http2 (reached via http.Transport.RoundTrip from
http/client/caching.go).

Assisted by AI
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants