Skip to content

Configurable OAuth client authentication to external authorization servers (OpenID4VCI auth-code flow) #4316

Description

@reinkrul

Related: #4310, #3197

Experimental. This feature may change or be removed without further notice. Must be flagged as experimental in both code (doc comments) and documentation, and lives under auth.experimental.

Problem

As an OpenID4VCI wallet in the auth-code flow, the node authenticates as a public client using its did:web base URL as client_id + client_id_scheme=entity_id:

  • Authorization request: auth/api/iam/openid4vci.go:131-140
  • Token request: auth/client/iam/openid4vp.go:227 (client_id only, no secret)

entity_id is Nuts-specific. Third-party issuers expect a registered client_id + secret. AET (OpenIddict) requires client auth -- a public client_id is refused (invalid_client) and client_secret_post is accepted. No way to configure this today, so the auth-code flow can't complete against any non-entity_id AS.

Solution

Per-server OAuth client config under auth.experimental. On a match, present the configured client_id + client_secret (client_secret_post) instead of the did:web + entity_id defaults.

auth:
  experimental:
    clients:
      - serverurl: https://issuer.example.com/oauth   # OAuth AS identifier
        clientid: nuts-wallet-123
        clientsecret: s3cr3t        # optional
NUTS_AUTH_EXPERIMENTAL_CLIENTS_0_SERVERURL=https://issuer.example.com/oauth
NUTS_AUTH_EXPERIMENTAL_CLIENTS_0_CLIENTID=nuts-wallet-123
NUTS_AUTH_EXPERIMENTAL_CLIENTS_0_CLIENTSECRET=s3cr3t

Keys are single tokens (serverurl, not server_url): env vars map _ to the . delimiter (core/config.go:60), so ..._SERVER_URL would nest as server.url. No match -> current behavior unchanged (additive).

Implementation Decisions

Config

Add to the existing ExperimentalConfig (auth/config.go), which already documents "subject to change without notice and may be removed":

type OAuthClientConfig struct {
    ServerURL    string `koanf:"serverurl"`
    ClientID     string `koanf:"clientid"`
    ClientSecret string `koanf:"clientsecret"`
}
// ExperimentalConfig: Clients []OAuthClientConfig `koanf:"clients"`

Validate at Configure: serverurl required + valid URL (HTTPS in strict mode); clientid required; reject duplicate serverurl.

Match key

Exact match of serverurl against the AS identifier authzServerMetadata.Issuer (not credential_issuer) -- credentials are an AS concept and the token request targets the AS. AET splits them: credential_issuer = .../aet, AS = .../aet-oauth. Operators configure the AS URL from authorization_servers. One lookup helper, shared by both request sites.

Auth method

client_secret_post only for now.

TODO: derive method from AS metadata token_endpoint_auth_methods_supported (fallback post when absent) and support client_secret_basic. AET omits the field, so deriving buys nothing yet.

Request changes

Site On match
Authorization request (api/iam/openid4vci.go:131) configured client_id, omit client_id_scheme
Token request (client/iam/openid4vp.go:227) configured client_id; secret as client_secret form field, or public client if empty

Modules

  1. auth/config.go: type + ExperimentalConfig.Clients field + validation + experimental doc comment.
  2. auth/auth.go:135: thread into iam.ClientConfig + reach the api/iam Wrapper.
  3. auth/api/iam/openid4vci.go: authorization request.
  4. auth/client/iam/openid4vp.go: token request.

Testing

Assert wire output via httptest.Server: on match, redirect carries configured client_id and no client_id_scheme, token request carries client_id + client_secret; no match = today's entity_id shape.

  • auth/config.go: validation + NUTS_AUTH_EXPERIMENTAL_CLIENTS_0_* array binding.
  • auth/api/iam: authorization-request client_id/scheme, match vs no-match.
  • auth/client/iam: token-request public client + client_secret_post.

Impact

  • Compat: additive; no configured clients -> unchanged.
  • Versioning: minor. Experimental: may change or be removed without notice.
  • Security: clientsecret redacted in /config output and logs, never logged in the token request body. Strict mode = HTTPS-only token endpoint.

Out of Scope

  • Other protocols/flows (plain OAuth2, OpenID4VP, pre-authorized code). Config shape anticipates them; only OpenID4VCI auth-code is wired here.
  • Metadata-derived auth method + client_secret_basic (TODO above).
  • Asymmetric client auth (client_secret_jwt, private_key_jwt); dynamic client registration.
  • Replacing did:web+entity_id for Nuts-to-Nuts flows (Auth: add Authorization Server URL in Service Access Token flow (v2) #3197).

Further Notes

Only target now is the AET OpenID4VCI issuer; shape kept protocol-neutral for later reuse.

AET probe (via nuts-services.nl bridge): public client_id -> invalid_client; client_secret_post accepted (localhost/password reached invalid_grant on a dummy code). token_endpoint_auth_methods_supported absent; grant_types_supported: ["authorization_code"].

"Send no client_id" is not covered: an entry requires clientid.

Implementation Plan

# Description PR Status Depends on
1 Config type (under experimental), validation, env binding, lookup helper -- -- --
2 Authorization request: configured client_id, omit entity_id scheme -- -- #1
3 Token request: configured client_id + client_secret_post -- -- #1
4 Config-reference docs regen (with experimental warning) + clientsecret redaction -- -- #1

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions