Skip to content

Support mixed OAuth2 scopes with configurable scope policy #4144

@stevenvegt

Description

@stevenvegt

Supersedes #4038. Design decisions resolved through detailed design review of the original issue's discussion.

Problem Statement

The Nuts node currently supports only a single OAuth2 scope per access token request, mapped 1:1 to a Presentation Definition (PD) in the policy configuration. Unknown scopes result in an invalid_scope error. This prevents integration with systems that use additional scopes alongside credential-based authentication — for example, SMART on FHIR scopes like patient/Observation.read that express fine-grained resource access. As the Nuts network integrates with national infrastructures, the node needs to support mixed scope requests where one scope drives credential selection and others express authorization intent.

The node is an authentication server, not an authorization server. Its token issuance verifies identity via credentials; it does not make access control decisions about specific resources. This distinction matters when integrating with systems like SMART on FHIR, where fine-grained scopes imply authorization decisions that belong at the resource server or PDP level.

Solution

Extend the access token request to support multiple scopes, distinguishing between a credential profile scope (which maps to a PD and drives VP/credential selection) and other scopes (which don't map to a PD). The credential profile scope is identified by looking up each requested scope against the policy configuration.

Other scopes are handled according to a configurable scope policy per credential profile:

  • profile-only (default): only the credential profile scope is accepted. Extra scopes cause an error. This preserves current behavior.
  • dynamic: extra scopes are forwarded (client side) or evaluated by an external AuthZen-compatible PDP (server side) that applies business rules and returns per-scope allow/deny decisions.
User Stories
  1. As an EHR developer, I want to request an access token with both a credential profile scope and SMART on FHIR scopes, so that the resulting token can be used with resource servers that expect SMART scopes.
  2. As an EHR developer, I want to know which scopes were granted in the token response, so that I can enable or disable functionality based on the granted scopes.
  3. As an EHR developer, I want the node to error when my request contains multiple credential profile scopes, so that ambiguous requests are caught early.
  4. As an EHR developer, I want the node to error when my request contains no credential profile scope, so that I'm informed when the scope configuration doesn't match my request.
  5. As a Nuts node operator, I want a safe default (profile-only) that rejects unrecognized scopes, so that tokens don't contain scopes that weren't authorized.
  6. As a Nuts node operator, I want to configure dynamic scope policy for specific credential profiles, so that extra scopes can be evaluated by an external PDP.
  7. As a Nuts node operator, I want the node to fail at startup when a credential profile has scope_policy: "dynamic" but no AuthZen endpoint is configured, so that misconfigurations are caught early.
  8. As a Nuts node operator, I want to be warned at policy load time about un-namespaced credential profile scope names, so that I can mitigate name clash risks.
  9. As a Nuts node acting as client, I want to identify the credential profile scope from a mixed scope string, so that I can select the correct PD for building the VP.
  10. As a Nuts node acting as client with dynamic scope policy, I want to forward all requested scopes to the authorization server, so that the AS can evaluate them.
  11. As a Nuts node acting as authorization server, I want to identify the credential profile scope from a mixed scope string in incoming token requests, so that I can validate the presented VP against the correct PD.
  12. As a Nuts node acting as authorization server with dynamic scope policy, I want to call an AuthZen-compatible PDP to evaluate requested scopes, so that business rules determine which scopes are granted.
  13. As a Nuts node acting as authorization server, I want to return only granted scopes in the access token, so that the token accurately reflects what was authorized.
  14. As a PDP implementer, I want to receive the requester's credential claims grouped by role (client, organization, user) alongside the requested scopes, so that I can make informed authorization decisions.
  15. As a PDP implementer, I want to deny the credential profile scope itself based on business rules, so that I can enforce policies beyond what cryptographic credential validation covers.
  16. As a PDP implementer, I want denied scopes to be excluded from the token (not cause an error), so that the token contains the maximum set of authorized scopes.
  17. As a PDP implementer, I want the credential profile name to be included in the AuthZen request context, so that I can route to the correct policy rules.
  18. As a use-case designer, I want to define credential profiles independently from fine-grained resource scopes, so that I can design authentication requirements separately from authorization requirements.
  19. As a use-case designer, I want credential profile scope names to be namespaced (e.g., urn:nuts:medication-overview), so that they don't clash with resource scopes from other specifications.

Implementation Decisions

Scope parsing and classification

The scope string is split into individual scopes. Each is looked up in the policy configuration's PD mappings. Scopes with a PD mapping are credential profile scopes. The request must contain exactly one credential profile scope — zero or more than one is an error. Remaining scopes are classified as "other scopes."

Credential profile scope names should be namespaced (e.g., urn:nuts:medication-overview) to avoid clashes with resource scopes from other specifications. The node warns at policy load time when un-namespaced scope names are detected.

This classification logic enhances the existing PDPBackend.PresentationDefinitions() — the current implementation does a direct map lookup with the full scope string and needs to be extended to handle multi-scope parsing.

Scope policy configuration

The scope policy is configured per credential profile in the policy configuration files, alongside the PD:

{
  "urn:nuts:medication-overview": {
    "organization": { /* PD */ },
    "scope_policy": "profile-only"
  }
}

Default is profile-only. The AuthZen endpoint URL is configured in the node's local configuration (not in the shared policy), since it is operator-specific. The node fails at startup if any credential profile has scope_policy: "dynamic" without an AuthZen endpoint configured.

Scope policy evaluation

Two modes:

  • profile-only: if other scopes are present, return an error. Only the credential profile scope is granted.
  • dynamic: on the client side, forward all scopes in the token request. On the server side, call the AuthZen PDP to evaluate all scopes. If the PDP denies the credential profile scope, no token is issued (error). If the PDP denies other scopes, they are excluded from the token.

Implemented as a simple switch, not a strategy interface. Future modes (allowlist, passthrough) slot into the same switch when needed.

AuthZen integration

When scope_policy is dynamic, the server calls a globally configured AuthZen-compatible PDP endpoint using the batch Access Evaluations API (POST /access/v1/evaluations).

The node performs all cryptographic and VP validation before calling the PDP. The PDP applies business rules and can override the node's validation by denying the credential profile scope.

Policy routing uses convention: the context.policy field contains the namespaced credential profile name. The PDP uses this to select the appropriate rules internally.

AuthZen request format:

{
  "subject": {
    "type": "token_request",
    "id": "<DID from assertion VP>",
    "properties": {
      "client": {
        "@id": "did:web:serviceprovider.example.com",
        "name": "Software Vendor B.V."
      },
      "organization": {
        "@id": "did:web:hospital.example.com",
        "name": "Hospital B.V.",
        "ura": "12345678"
      },
      "user": {
        "@id": "did:web:hospital.example.com#practitioner-123",
        "roleCode": "01.015"
      }
    }
  },
  "action": {"name": "request_scope"},
  "resource": {"type": "scope", "id": "patient/Observation.read"},
  "context": {
    "policy": "urn:nuts:medication-overview"
  }
}

The subject properties contain claims extracted from validated VCs, grouped by role (client, organization, user). This is flat JSON — no JSON-LD expansion. The PDP author knows the credential profile structure they're writing rules for, so semantic disambiguation is unnecessary.

Initially only the organization bucket is populated (single VP). Issue #4080 (server-side JWT bearer with two VPs) will enrich this with the client and user buckets.

AuthZen response format:

{
  "evaluations": [
    {"decision": true},
    {"decision": false, "context": {"reason": "..."}}
  ]
}

Failure handling: if the PDP is unreachable or times out, the token request fails with a 503 response. Timeouts reuse existing node timeout configuration.

Client-side behavior

The client parses the scope string, identifies the credential profile scope, and uses it for PD lookup and credential selection (via credential_selection from #4067 — independent concern). If scope_policy is profile-only and extra scopes are present, the request fails. If dynamic, all scopes are forwarded in the token request to the authorization server.

Server-side behavior

The server parses the scope string, identifies the credential profile scope, and validates the VP against its PD. Then applies the scope policy: profile-only errors on extra scopes, dynamic calls the AuthZen PDP. The token is issued with only the granted scopes.

Token response

The scope field in the token response contains the granted scopes. For profile-only, this is just the credential profile scope. For dynamic, this is the credential profile scope plus PDP-approved scopes.

Token introspection

The existing introspection response format supports a space-delimited scope field. Multi-scope tokens should work without modification — this needs to be verified, not implemented.

Credential selection interaction

Scope classification and credential selection (#4067) are orthogonal. The scope determines which PD to use; credential_selection determines which VCs to pick within that PD. Extra scopes do not influence credential selection.

Backwards compatibility

Existing credential profiles with a single scope and no scope_policy field default to profile-only behavior, which is identical to the current behavior: one scope, one PD, unknown scopes rejected. No changes are required to existing policy configuration files, PDs, or use cases.

Clients that send a single scope will continue to work exactly as before. The multi-scope parsing only produces different behavior when multiple scopes are present in the request, which is not possible today.

The token introspection response format is unchanged — the scope field already supports space-delimited values per the OAuth2 specification.

Versioning

This feature introduces no breaking changes to the API, policy configuration, or token format. It can be released as a minor version update. The scope_policy field is optional and defaults to profile-only, so existing configurations are valid without modification. Nodes that upgrade will behave identically to before until an operator explicitly configures scope_policy: "dynamic" on a credential profile.

Modules to build/modify

  1. Scope parsing & classification (modify LocalPDP): enhance existing PresentationDefinitions() to parse multi-scope strings, identify the credential profile scope, return it separately from other scopes. Warn at load time on un-namespaced scopes.
  2. Scope policy evaluation (new): profile-only errors on extra scopes, dynamic extracts claims and calls AuthZen. Simple switch, no strategy interface.
  3. AuthZen client (new): HTTP client for batch Access Evaluations API. Builds request with role-grouped claims, parses per-scope decisions, handles timeouts.
  4. Policy configuration (modify): add scope_policy field per credential profile. Add global AuthZen endpoint to node config. Validate at load time.
  5. Token request flow — client side (modify): parse mixed scopes, use profile scope for PD lookup. Forward all scopes when dynamic.
  6. Token request flow — server side (modify): parse mixed scopes, validate VP, apply scope policy, issue token with granted scopes.
  7. Token introspection (verify): confirm multi-scope works without modification.

Testing Decisions

A good test verifies external behavior: given a scope string, policy configuration, and VP state, what scopes end up in the token (or what error is returned). Tests should not assert on internal data structures.

Modules to test:

  • Scope classification: unit tests for single/multiple/zero credential profile scopes, un-namespaced scope warnings, empty string, whitespace
  • Scope policy evaluation: unit tests for each mode — profile-only with and without extra scopes, dynamic with PDP approval/denial/partial denial/PDP denies profile scope
  • AuthZen client: unit tests with HTTP mock for request format validation, response parsing, error handling (unreachable, timeout, malformed response)
  • Token request flow: integration tests for full client-side and server-side flow with both scope policies
  • Token introspection: verify multi-scope tokens return all granted scopes
  • Policy configuration: validate scope_policy field parsing, startup failure when dynamic lacks endpoint, un-namespaced scope warning
  • Prior art: existing tests in policy/local_test.go, auth/api/iam/s2s_vptoken_test.go

Out of Scope

  • Allowlist and passthrough scope policy modes: achievable via dynamic mode. Can be added as built-in modes later if demand warrants.
  • Multiple credential profile scopes per request: limited to exactly one.
  • Client and user claim buckets in AuthZen request: initially only organization is populated. Enriched by Server-side RFC 7523 JWT Bearer grant with two VPs (PSA 10.10) #4080 (server-side JWT bearer with two VPs).
  • Distributable policy bundles: the dynamic mode and AuthZen integration lay groundwork for future Rego-based policy bundles, but bundle format and distribution are not addressed here.
  • JSON-LD expansion of claims: considered and rejected. Flat JSON grouped by role is sufficient since PDP authors know the credential profile structure.
  • Strategy interface for scope policy: two modes don't warrant an interface. A switch suffices; refactor to interface when more modes are added.
  • Per-profile AuthZen endpoint configuration: single global endpoint. Per-profile routing via context.policy convention.
  • Policy versioning: noted as future concern. Convention is context.policy = credential profile name, no override mechanism.

Further Notes

Relationship to #4080 (server-side JWT bearer)

Issue #4080 depends on this issue for scope parsing and AuthZen integration. Some code from this issue will be reworked by #4080 to support the two-VP flow and enrich the AuthZen request with client and user claim buckets. This is expected and acceptable — forward progress over perfect sequencing.

Relationship to #4067 (credential selection)

Credential selection and scope classification are orthogonal. #4067 determines which VCs to pick within a PD; this issue determines which PD to use based on the credential profile scope.

Future: Rego-based policy bundles

The dynamic scope policy mode and AuthZen integration are stepping stones toward distributable bundles containing credential profiles and authorization policies (Rego). These bundles would contain both AT-issuance-time scope rules and request-time PDP checks. Bundle design is out of scope but should be kept in mind.

Future: additional scope policy modes

allowlist (static list of permitted extra scopes) and passthrough (accept all extra scopes) can be added as built-in modes when the switch becomes unwieldy or when operational simplicity warrants not running a PDP for simple cases.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions