Skip to content

Support mixed OAuth2 scopes with configurable scope policy #4144

Closed

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 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.
  9. 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.
  10. 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.
  11. 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.
  12. 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.
  13. 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.
  14. 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.
  15. 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.
  16. 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.
  17. 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.
  18. 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. See #4146 for scope naming quality checks.

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. The passthrough mode does not require an AuthZen endpoint.

Scope policy evaluation

Three modes:

  • profile-only: if other scopes are present, return an error. Only the credential profile scope is granted.
  • passthrough: all requested scopes are granted without evaluation. On the client side, forward all scopes in the token request. On the server side, include all requested scopes in the token. No external PDP required. Useful for demos and environments where scope authorization is handled elsewhere.
  • 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 (e.g., allowlist) 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": "organization",
    "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.

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.
  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, empty string, whitespace
  • Scope policy evaluation: unit tests for each mode — profile-only with and without extra scopes, passthrough grants all 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
  • Prior art: existing tests in policy/local_test.go, auth/api/iam/s2s_vptoken_test.go

Out of Scope

  • Allowlist scope policy mode: achievable via dynamic mode. Can be added as a built-in mode 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) can be added as a built-in mode when the switch becomes unwieldy or when operational simplicity warrants a middle ground between passthrough and dynamic.

Implementation Plan

# Description PR Status Depends on
1 Scope parsing, classification & policy config #4176 Ready for Review —
2 AuthZen PDP client #4177 Ready for Review #4176
3 Client-side multi-scope token flow #4178 Ready for Review #4176
4 Server-side multi-scope flow & scope policy evaluation #4179 Ready for Review #4176, #4177
5 Token introspection verification & integration tests #4180 Ready for Review #4178, #4179

Activity

  1. reinkrul commented on Apr 2, 2026

    @reinkrul
    Member

    User story: 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.

    Don't we want to error when the client specifies scopes that can't be granted, for a more deterministic integration? Otherwise, clients would have to check which scopes were granted and enable/disable specific features given the granted scopes. While both client and server agreed to a certain set of scopes through standardization.

  2. reinkrul commented on Apr 2, 2026

    @reinkrul
    Member

    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.

    I'm not sure whether this actually helps, if there's a name clash, the node already fails to start. And the Node operator didn't define the use case, and they can't change the scope since that breaks integration. I think we should make this part of a use case quality check. I'll make an issue for this (something like a document for use case quality or even tooling/organization): #4146

  3. reinkrul commented on Apr 2, 2026

    @reinkrul
    Member

    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.

    I think we can just say "it returns the granted scopes", no need to repeat here the specifics of each option since they're already specified earlier.

  4. reinkrul commented on Apr 2, 2026

    @reinkrul
    Member

    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.

    shouldn't it then be id instead of @id?

  5. stevenvegt commented on Apr 13, 2026

    @stevenvegt
    MemberAuthor

    Re: Should denied scopes cause an error?

    RFC 6749 Section 3.3 explicitly allows an AS to grant a subset of requested scopes:

    "The authorization server MAY fully or partially ignore the scope requested by the client, based on the authorization server policy or the resource owner's instructions."

    When the granted scope differs from what was requested, the AS MUST include the scope parameter in the token response (Section 5.1), giving the client all the information it needs to detect the difference.

    This applies to both flows:

    • Server side (Nuts node as AS): the PDP may deny scopes. The token is issued with only granted scopes, and scope in the response reflects that.
    • Client side (Nuts node as OAuth client): the remote AS may reduce scopes. The Nuts node proxies the token response back to the vendor, including the scope field. The vendor compares what they requested vs what was granted.

    In both cases, the API client (vendor) already has the information to detect discrepancies — they know what they requested and can read what was granted. Adding an error at the Nuts node level would be non-standard OAuth behavior and would prevent legitimate use cases where a subset of scopes is acceptable.

    The current PRD design (denied scopes excluded from token, not an error) aligns with the OAuth spec. User story #2 already covers the client-side detection need.

    The exception is correct as specified: if the PDP denies the credential profile scope itself, no meaningful token can be issued, so that remains an error.

  6. stevenvegt commented on Apr 13, 2026

    @stevenvegt
    MemberAuthor

    Addressing remaining review feedback from @reinkrul:

    Un-namespaced scope warning (user story 8): Removed from this PRD. Tracked separately in #4146 as a use-case quality concern, not a node runtime concern.

    Token response section: Simplified — just states "the token response contains the granted scopes." The per-policy specifics are already covered in the scope policy evaluation section.

    @id → id in AuthZen request format: Fixed. Since the AuthZen request uses flat JSON (not JSON-LD), the field should be id, not @id.

  7. stevenvegt commented on Apr 13, 2026

    @stevenvegt
    MemberAuthor

    Future consideration: circuit breaker for AuthZen PDP client to avoid repeated timeout waits when the PDP is down. Tracked in #4175.

  8. stevenvegt commented on Apr 13, 2026

    @stevenvegt
    MemberAuthor

    Moved passthrough scope policy mode into scope.

    Previously listed under "Out of Scope" as achievable via dynamic mode. Moved in-scope because it enables demos and environments without an external PDP dependency. It's a trivial addition to the scope policy switch — just grants all requested scopes without evaluation.

    Changes to the PRD:

    • Scope policy evaluation: added passthrough as third mode
    • Scope policy configuration: noted passthrough doesn't require AuthZen endpoint
    • Testing: added passthrough test cases
    • Out of Scope: removed passthrough (kept allowlist)
  9. linked a pull request that will close this issueClient-side multi-scope token flow #4178on Apr 13, 2026
  10. linked a pull request that will close this issueAuthZen PDP client #4177on Apr 13, 2026
  11. stevenvegt commented on Apr 14, 2026

    @stevenvegt
    MemberAuthor

    Changed subject.type in AuthZen request from \"token_request\" to \"organization\".

    The subject type should identify the kind of entity represented by the DID, not the action being performed. Currently the subject is always an organization (single VP). When #4080 adds two-VP support, the subject type will match whichever properties bucket is populated (organization, user, or a future service-provider).

  12. self-assigned this
    on May 1, 2026
  13. added a commit that references this issue on May 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Fields

Priority

None yet

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions