nuts-foundation/nuts-node

View on GitHub
auth/api/iam/api.go

Summary

Maintainability
C
1 day
Test Coverage
B
82%
/*
 * Copyright (C) 2023 Nuts community
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 *
 */

package iam

import (
    "bytes"
    "context"
    "crypto"
    "embed"
    "encoding/base64"
    "errors"
    "fmt"
    "github.com/nuts-foundation/nuts-node/http/cache"
    "github.com/nuts-foundation/nuts-node/http/user"
    "html/template"
    "net/http"
    "net/url"
    "strings"
    "time"

    "github.com/labstack/echo/v4"
    "github.com/lestrrat-go/jwx/v2/jwt"
    "github.com/nuts-foundation/go-did/did"
    "github.com/nuts-foundation/nuts-node/audit"
    "github.com/nuts-foundation/nuts-node/auth"
    "github.com/nuts-foundation/nuts-node/auth/api/iam/assets"
    "github.com/nuts-foundation/nuts-node/auth/log"
    "github.com/nuts-foundation/nuts-node/auth/oauth"
    "github.com/nuts-foundation/nuts-node/core"
    nutsCrypto "github.com/nuts-foundation/nuts-node/crypto"
    nutsHttp "github.com/nuts-foundation/nuts-node/http"
    "github.com/nuts-foundation/nuts-node/jsonld"
    "github.com/nuts-foundation/nuts-node/policy"
    "github.com/nuts-foundation/nuts-node/storage"
    "github.com/nuts-foundation/nuts-node/vcr"
    "github.com/nuts-foundation/nuts-node/vcr/pe"
    vcrTypes "github.com/nuts-foundation/nuts-node/vcr/types"
    "github.com/nuts-foundation/nuts-node/vdr"
    "github.com/nuts-foundation/nuts-node/vdr/didweb"
    "github.com/nuts-foundation/nuts-node/vdr/resolver"
)

var _ core.Routable = &Wrapper{}
var _ StrictServerInterface = &Wrapper{}

var oauthRequestObjectKey = []string{"oauth", "requestobject"}

const apiPath = "iam"
const apiModuleName = auth.ModuleName + "/" + apiPath

type httpRequestContextKey struct{}

// accessTokenValidity defines how long access tokens are valid.
// TODO: Might want to make this configurable at some point
const accessTokenValidity = 15 * time.Minute

const oid4vciSessionValidity = 15 * time.Minute

// cacheControlMaxAgeURLs holds API endpoints that should have a max-age cache control header set.
var cacheControlMaxAgeURLs = []string{
    "/.well-known/did.json",
    "/iam/:id/did.json",
    "/oauth2/:did/presentation_definition",
    "/.well-known/oauth-authorization-server/iam/:id",
    "/.well-known/oauth-authorization-server",
    "/oauth2/:did/oauth-client",
    "/statuslist/:did/:page",
}

// cacheControlNoCacheURLs holds API endpoints that should have a no-cache cache control header set.
var cacheControlNoCacheURLs = []string{
    "/oauth2/:did/token",
}

type TokenIntrospectionResponse = ExtendedTokenIntrospectionResponse

//go:embed assets
var assetsFS embed.FS

// Wrapper handles OAuth2 flows.
type Wrapper struct {
    auth          auth.AuthenticationServices
    policyBackend policy.PDPBackend
    storageEngine storage.Engine
    jsonldManager jsonld.JSONLD
    vcr           vcr.VCR
    vdr           vdr.VDR
    jwtSigner     nutsCrypto.JWTSigner
    keyResolver   resolver.KeyResolver
    jar           JAR
}

func New(
    authInstance auth.AuthenticationServices, vcrInstance vcr.VCR, vdrInstance vdr.VDR, storageEngine storage.Engine,
    policyBackend policy.PDPBackend, jwtSigner nutsCrypto.JWTSigner, jsonldManager jsonld.JSONLD) *Wrapper {
    templates := template.New("oauth2 templates")
    _, err := templates.ParseFS(assetsFS, "assets/*.html")
    if err != nil {
        panic(err)
    }
    return &Wrapper{
        auth:          authInstance,
        policyBackend: policyBackend,
        storageEngine: storageEngine,
        vcr:           vcrInstance,
        vdr:           vdrInstance,
        jsonldManager: jsonldManager,
        jwtSigner:     jwtSigner,
        keyResolver:   resolver.DIDKeyResolver{Resolver: vdrInstance.Resolver()},
        jar: &jar{
            auth:        authInstance,
            jwtSigner:   jwtSigner,
            keyResolver: resolver.DIDKeyResolver{Resolver: vdrInstance.Resolver()},
        },
    }
}

func (r Wrapper) Routes(router core.EchoRouter) {
    RegisterHandlers(router, NewStrictHandler(r, []StrictMiddlewareFunc{
        func(f StrictHandlerFunc, operationID string) StrictHandlerFunc {
            return func(ctx echo.Context, request interface{}) (response interface{}, err error) {
                return r.strictMiddleware(ctx, request, operationID, f)
            }
        },
        func(f StrictHandlerFunc, operationID string) StrictHandlerFunc {
            return audit.StrictMiddleware(f, apiModuleName, operationID)
        },
    }))
    // The following handlers are used for the user facing OAuth2 flows.
    router.GET("/oauth2/:did/user", r.handleUserLanding, func(next echo.HandlerFunc) echo.HandlerFunc {
        return func(c echo.Context) error {
            middleware(c, "handleUserLanding")
            return next(c)
        }
    }, audit.Middleware(apiModuleName))
    router.Use(cache.MaxAge(5*time.Minute, cacheControlMaxAgeURLs...).Handle)
    router.Use(cache.NoCache(cacheControlNoCacheURLs...).Handle)
    router.Use(user.SessionMiddleware{
        Skipper: func(c echo.Context) bool {
            // The following URLs require a user session:
            paths := []string{
                "/oauth2/:did/user",
                "/oauth2/:did/authorize",
                "/oauth2/:did/callback",
            }
            for _, path := range paths {
                if c.Path() == path {
                    return false
                }
            }
            return true
        },
        TimeOut: time.Hour,
        Store:   r.storageEngine.GetSessionDatabase().GetStore(time.Hour, "user", "session"),
        CookiePath: func(tenantDID did.DID) string {
            baseURL, _ := createOAuth2BaseURL(tenantDID)
            // error only happens on invalid did:web DID, which can't happen here
            return baseURL.Path
        },
    }.Handle)
}

func (r Wrapper) strictMiddleware(ctx echo.Context, request interface{}, operationID string, f StrictHandlerFunc) (interface{}, error) {
    middleware(ctx, operationID)
    return f(ctx, request)
}

func middleware(ctx echo.Context, operationID string) {
    ctx.Set(core.OperationIDContextKey, operationID)
    ctx.Set(core.ModuleNameContextKey, apiModuleName)

    // Add http.Request to context, to allow reading URL query parameters
    requestCtx := context.WithValue(ctx.Request().Context(), httpRequestContextKey{}, ctx.Request())
    ctx.SetRequest(ctx.Request().WithContext(requestCtx))
    if strings.HasPrefix(ctx.Request().URL.Path, "/oauth2/") {
        ctx.Set(core.ErrorWriterContextKey, &oauth.Oauth2ErrorWriter{
            HtmlPageTemplate: assets.ErrorTemplate,
        })
    }
}

// ResolveStatusCode maps errors returned by this API to specific HTTP status codes.
func (r Wrapper) ResolveStatusCode(err error) int {
    return core.ResolveStatusCode(err, map[error]int{
        vcrTypes.ErrNotFound:                http.StatusNotFound,
        resolver.ErrDIDNotManagedByThisNode: http.StatusBadRequest,
    })
}

// HandleTokenRequest handles calls to the token endpoint for exchanging a grant (e.g authorization code or pre-authorized code) for an access token.
func (r Wrapper) HandleTokenRequest(ctx context.Context, request HandleTokenRequestRequestObject) (HandleTokenRequestResponseObject, error) {
    ownDID, err := r.toOwnedDIDForOAuth2(ctx, request.Did)
    if err != nil {
        return nil, err
    }
    switch request.Body.GrantType {
    case oauth.AuthorizationCodeGrantType:
        // Options:
        // - OpenID4VCI
        // - OpenID4VP
        // verifier DID is taken from code->oauthSession storage
        return r.handleAccessTokenRequest(ctx, *request.Body)
    case oauth.PreAuthorizedCodeGrantType:
        // Options:
        // - OpenID4VCI
        // todo: add to grantTypesSupported in AS metadata once supported
        return nil, oauth.OAuth2Error{
            Code:        oauth.UnsupportedGrantType,
            Description: "not implemented yet",
        }
    case oauth.VpTokenGrantType:
        // Nuts RFC021 vp_token bearer flow
        if request.Body.PresentationSubmission == nil || request.Body.Scope == nil || request.Body.Assertion == nil {
            return nil, oauth.OAuth2Error{
                Code:        oauth.InvalidRequest,
                Description: "missing required parameters",
            }
        }
        return r.handleS2SAccessTokenRequest(ctx, *ownDID, *request.Body.Scope, *request.Body.PresentationSubmission, *request.Body.Assertion)
    default:
        return nil, oauth.OAuth2Error{
            Code:        oauth.UnsupportedGrantType,
            Description: fmt.Sprintf("grant_type '%s' is not supported", request.Body.GrantType),
        }
    }
}

func (r Wrapper) Callback(ctx context.Context, request CallbackRequestObject) (CallbackResponseObject, error) {
    // validate request
    // check did in path
    ownDID, err := r.toOwnedDID(ctx, request.Did)
    if err != nil {
        return nil, err
    }
    // check if state is present and resolves to a client state
    if request.Params.State == nil || *request.Params.State == "" {
        // without state it is an invalid request, but try to provide as much useful information as possible
        if request.Params.Error != nil && *request.Params.Error != "" {
            callbackError := callbackRequestToError(request, nil)
            callbackError.InternalError = errors.New("missing state parameter")
            return nil, callbackError
        }
        return nil, oauthError(oauth.InvalidRequest, "missing state parameter")
    }
    oauthSession := new(OAuthSession)
    if err = r.oauthClientStateStore().Get(*request.Params.State, oauthSession); err != nil {
        return nil, oauthError(oauth.InvalidRequest, "invalid or expired state", err)
    }
    if !ownDID.Equals(*oauthSession.OwnDID) {
        // TODO: this is a manipulated request, add error logging?
        return nil, withCallbackURI(oauthError(oauth.InvalidRequest, "session DID does not match request"), oauthSession.redirectURI())
    }

    // if error is present, redirect error back to application initiating the flow
    if request.Params.Error != nil && *request.Params.Error != "" {
        return nil, callbackRequestToError(request, oauthSession.redirectURI())
    }

    // check if code is present
    if request.Params.Code == nil || *request.Params.Code == "" {
        return nil, withCallbackURI(oauthError(oauth.InvalidRequest, "missing code parameter"), oauthSession.redirectURI())
    }

    // continue flow
    switch oauthSession.ClientFlow {
    case credentialRequestClientFlow:
        return r.handleOpenID4VCICallback(ctx, *request.Params.Code, oauthSession)
    case accessTokenRequestClientFlow:
        return r.handleCallback(ctx, *request.Params.Code, oauthSession)
    default:
        // programming error, should never happen
        return nil, withCallbackURI(oauthError(oauth.ServerError, "unknown client flow for callback: '"+oauthSession.ClientFlow+"'"), oauthSession.redirectURI())
    }
}

// callbackRequestToError should only be used if request.params.Error is present
func callbackRequestToError(request CallbackRequestObject, redirectURI *url.URL) oauth.OAuth2Error {
    requestErr := oauth.OAuth2Error{
        Code:        oauth.ErrorCode(*request.Params.Error),
        RedirectURI: redirectURI,
    }
    if request.Params.ErrorDescription != nil {
        requestErr.Description = *request.Params.ErrorDescription
    }
    return requestErr
}

func (r Wrapper) RetrieveAccessToken(_ context.Context, request RetrieveAccessTokenRequestObject) (RetrieveAccessTokenResponseObject, error) {
    // get access token from store
    var token TokenResponse
    err := r.accessTokenClientStore().Get(request.SessionID, &token)
    if err != nil {
        if errors.Is(err, storage.ErrNotFound) {
            return nil, core.NotFoundError("session not found")
        }
        return nil, err
    }
    if token.Get("status") == oauth.AccessTokenRequestStatusPending {
        // return pending status
        return RetrieveAccessToken200JSONResponse(token), nil
    }
    // access token is active, return to caller and delete access token from store
    // change this when tokens can be cached
    err = r.accessTokenClientStore().Delete(request.SessionID)
    if err != nil {
        log.Logger().WithError(err).Warn("Failed to delete access token")
    }
    // return access token
    return RetrieveAccessToken200JSONResponse(token), nil
}

// IntrospectAccessToken allows the resource server (XIS/EHR) to introspect details of an access token issued by this node
func (r Wrapper) IntrospectAccessToken(_ context.Context, request IntrospectAccessTokenRequestObject) (IntrospectAccessTokenResponseObject, error) {
    input := request.Body.Token
    response, err := r.introspectAccessToken(input)
    if err != nil {
        return nil, err
    } else if response == nil {
        return IntrospectAccessToken200JSONResponse{}, nil
    }
    response.Vps = nil
    response.PresentationDefinitions = nil
    response.PresentationSubmissions = nil
    return IntrospectAccessToken200JSONResponse(*response), nil
}

// IntrospectAccessTokenExtended allows the resource server (XIS/EHR) to introspect details of an access token issued by this node.
// It returns the same information as IntrospectAccessToken, but with additional information.
func (r Wrapper) IntrospectAccessTokenExtended(_ context.Context, request IntrospectAccessTokenExtendedRequestObject) (IntrospectAccessTokenExtendedResponseObject, error) {
    input := request.Body.Token
    response, err := r.introspectAccessToken(input)
    if err != nil {
        return nil, err
    } else if response == nil {
        return IntrospectAccessTokenExtended200JSONResponse{}, nil
    }
    return IntrospectAccessTokenExtended200JSONResponse(*response), nil
}

func (r Wrapper) introspectAccessToken(input string) (*ExtendedTokenIntrospectionResponse, error) {
    // Validate token
    if input == "" {
        // Return 200 + 'Active = false' when token is invalid or malformed
        log.Logger().Debug("IntrospectAccessToken: missing token")
        return nil, nil
    }

    token := AccessToken{}
    if err := r.accessTokenServerStore().Get(input, &token); err != nil {
        // Return 200 + 'Active = false' when token is invalid or malformed
        if errors.Is(err, storage.ErrNotFound) {
            log.Logger().Debug("IntrospectAccessToken: token not found (unknown or expired)")
            return nil, nil
        }
        log.Logger().WithError(err).Error("IntrospectAccessToken: failed to retrieve token")
        return nil, err
    }

    if token.Expiration.Before(time.Now()) {
        // Return 200 + 'Active = false' when token is invalid or malformed
        // can happen between token expiration and pruning of database
        log.Logger().Debug("IntrospectAccessToken: token is expired")
        return nil, nil
    }

    // Optional:
    // Use DPoP from token to generate JWK thumbprint for public key
    // deserialization of the DPoP struct from the accessTokenServerStore triggers validation of the DPoP header
    // SHA256 hashing won't fail.
    var cnf *Cnf
    if token.DPoP != nil {
        hash, _ := token.DPoP.Headers.JWK().Thumbprint(crypto.SHA256)
        base64Hash := base64.RawURLEncoding.EncodeToString(hash)
        cnf = &Cnf{Jkt: base64Hash}
    }

    // Create and return introspection response
    iat := int(token.IssuedAt.Unix())
    exp := int(token.Expiration.Unix())
    response := ExtendedTokenIntrospectionResponse{
        Active:                  true,
        Cnf:                     cnf,
        Iat:                     &iat,
        Exp:                     &exp,
        Iss:                     &token.Issuer,
        Sub:                     &token.Issuer,
        ClientId:                &token.ClientId,
        Scope:                   &token.Scope,
        Vps:                     &token.VPToken,
        PresentationDefinitions: &token.PresentationDefinitions,
        PresentationSubmissions: &token.PresentationSubmissions,
    }

    if token.InputDescriptorConstraintIdMap != nil {
        for _, reserved := range []string{"iss", "sub", "exp", "iat", "active", "client_id", "scope"} {
            if _, isReserved := token.InputDescriptorConstraintIdMap[reserved]; isReserved {
                return nil, fmt.Errorf("IntrospectAccessToken: InputDescriptorConstraintIdMap contains reserved claim name: %s", reserved)
            }
        }
        response.AdditionalProperties = token.InputDescriptorConstraintIdMap
    }
    return &response, nil
}

// HandleAuthorizeRequest handles calls to the authorization endpoint for starting an authorization code flow.
func (r Wrapper) HandleAuthorizeRequest(ctx context.Context, request HandleAuthorizeRequestRequestObject) (HandleAuthorizeRequestResponseObject, error) {
    ownDID, err := r.toOwnedDIDForOAuth2(ctx, request.Did)
    if err != nil {
        return nil, err
    }

    // Workaround: deepmap codegen doesn't support dynamic query parameters.
    //             See https://github.com/deepmap/oapi-codegen/issues/1129
    httpRequest := ctx.Value(httpRequestContextKey{}).(*http.Request)
    return r.handleAuthorizeRequest(ctx, *ownDID, *httpRequest.URL)
}

// handleAuthorizeRequest handles calls to the authorization endpoint for starting an authorization code flow.
// The caller must ensure ownDID is actually owned by this node.
func (r Wrapper) handleAuthorizeRequest(ctx context.Context, ownDID did.DID, request url.URL) (HandleAuthorizeRequestResponseObject, error) {
    // parse and validate as JAR (RFC9101, JWT Authorization Request)
    requestObject, err := r.jar.Parse(ctx, ownDID, request.Query())
    if err != nil {
        // already an oauth.OAuth2Error
        return nil, err
    }

    switch requestObject.get(oauth.ResponseTypeParam) {
    case oauth.CodeResponseType:
        // Options:
        // - Regular authorization code flow for EHR data access through access token, authentication of end-user using OpenID4VP.
        // - OpenID4VCI; authorization code flow for credential issuance to (end-user) wallet

        // TODO: officially flow switching has to be determined by the client_id
        // registered client_ids should list which flow they support
        // client registration could be done via rfc7591....
        // for now we switch on client_id format.
        // when client_id is a did:web, it is a cloud/server wallet
        // otherwise it's a normal registered client which we do not support yet
        // Note: this is the user facing OpenID4VP flow with a "vp_token" responseType, the demo uses the "vp_token id_token" responseType
        clientId := requestObject.get(oauth.ClientIDParam)
        if strings.HasPrefix(clientId, "did:web:") {
            // client is a cloud wallet with user
            return r.handleAuthorizeRequestFromHolder(ctx, ownDID, requestObject)
        } else {
            return nil, oauth.OAuth2Error{
                Code:        oauth.InvalidRequest,
                Description: "client_id must be a did:web",
            }
        }
    case oauth.VPTokenResponseType:
        // Options:
        // - OpenID4VP flow, vp_token is sent in Authorization Response
        // non-spec: if the scheme is openid4vp (URL starts with openid4vp:), the OpenID4VP request should be handled by a user wallet, rather than an organization wallet.
        //           Requests to user wallets can then be rendered as QR-code (or use a cloud wallet).
        //           Note that it can't be called from the outside, but only by internal dispatch (since Echo doesn't handle openid4vp:, obviously).
        walletOwnerType := pe.WalletOwnerOrganization
        if strings.HasPrefix(request.String(), "openid4vp:") {
            walletOwnerType = pe.WalletOwnerUser
        }
        return r.handleAuthorizeRequestFromVerifier(ctx, ownDID, requestObject, walletOwnerType)
    default:
        // TODO: This should be a redirect?
        redirectURI, _ := url.Parse(requestObject.get(oauth.RedirectURIParam))
        return nil, oauth.OAuth2Error{
            Code:        oauth.UnsupportedResponseType,
            RedirectURI: redirectURI,
        }
    }
}

// RequestJWTByGet returns the Request Object referenced as 'request_uri' in an authorization request.
// RFC9101: The OAuth 2.0 Authorization Framework: JWT-Secured Authorization Request (JAR).
func (r Wrapper) RequestJWTByGet(ctx context.Context, request RequestJWTByGetRequestObject) (RequestJWTByGetResponseObject, error) {
    ro := new(jarRequest)
    err := r.authzRequestObjectStore().GetAndDelete(request.Id, ro)
    if err != nil {
        return nil, oauth.OAuth2Error{
            Code:        oauth.InvalidRequest,
            Description: "request object not found",
        }
    }
    // compare raw strings, don't waste a db call to see if we own the request.Did.
    if ro.Client.String() != request.Did {
        return nil, oauth.OAuth2Error{
            Code:        oauth.InvalidRequest,
            Description: "client_id does not match request",
        }
    }
    if ro.RequestURIMethod != "get" { // case sensitive
        // TODO: wallet does not support `request_uri_method=post`. Spec is unclear if this should fail, or fallback to using staticAuthorizationServerMetadata().
        return nil, oauth.OAuth2Error{
            Code:          oauth.InvalidRequest,
            Description:   "used request_uri_method 'get' on a 'post' request_uri",
            InternalError: errors.New("wrong 'request_uri_method' authorization server or wallet probably does not support 'request_uri_method'"),
        }
    }

    // TODO: supported signature types should be checked
    token, err := r.jar.Sign(ctx, ro.Claims)
    if err != nil {
        return nil, oauth.OAuth2Error{
            Code:          oauth.ServerError,
            Description:   "unable to create Request Object",
            InternalError: fmt.Errorf("failed to sign authorization Request Object: %w", err),
        }
    }
    return RequestJWTByGet200ApplicationoauthAuthzReqJwtResponse{
        Body:          bytes.NewReader([]byte(token)),
        ContentLength: int64(len(token)),
    }, nil
}

// RequestJWTByPost returns the Request Object referenced as 'request_uri' in an authorization request.
// Extension of OpenID 4 Verifiable Presentations (OpenID4VP) on
// RFC9101: The OAuth 2.0 Authorization Framework: JWT-Secured Authorization Request (JAR).
func (r Wrapper) RequestJWTByPost(ctx context.Context, request RequestJWTByPostRequestObject) (RequestJWTByPostResponseObject, error) {
    ro := new(jarRequest)
    err := r.authzRequestObjectStore().GetAndDelete(request.Id, ro)
    if err != nil {
        return nil, oauth.OAuth2Error{
            Code:        oauth.InvalidRequest,
            Description: "request object not found",
        }
    }
    // compare raw strings, don't waste a db call to see if we own the request.Did.
    if ro.Client.String() != request.Did {
        return nil, oauth.OAuth2Error{
            Code:        oauth.InvalidRequest,
            Description: "client_id does not match request",
        }
    }
    if ro.RequestURIMethod != "post" { // case sensitive
        return nil, oauth.OAuth2Error{
            Code:        oauth.InvalidRequest,
            Description: "used request_uri_method 'post' on a 'get' request_uri",
        }
    }

    walletMetadata := staticAuthorizationServerMetadata()
    if request.Body != nil {
        if request.Body.WalletMetadata != nil {
            walletMetadata = *request.Body.WalletMetadata
        }
        if request.Body.WalletNonce != nil {
            ro.Claims[oauth.WalletNonceParam] = *request.Body.WalletNonce
        }
    }
    ro.Claims[jwt.AudienceKey] = walletMetadata.Issuer

    // TODO: supported signature types should be checked
    token, err := r.jar.Sign(ctx, ro.Claims)
    if err != nil {
        return nil, oauth.OAuth2Error{
            Code:          oauth.ServerError,
            Description:   "unable to create Request Object",
            InternalError: fmt.Errorf("failed to sign authorization Request Object: %w", err),
        }
    }
    return RequestJWTByPost200ApplicationoauthAuthzReqJwtResponse{
        Body:          bytes.NewReader([]byte(token)),
        ContentLength: int64(len(token)),
    }, nil
}

// OAuthAuthorizationServerMetadata returns the Authorization Server's metadata
func (r Wrapper) OAuthAuthorizationServerMetadata(ctx context.Context, request OAuthAuthorizationServerMetadataRequestObject) (OAuthAuthorizationServerMetadataResponseObject, error) {
    didAsString := r.requestedDID(request.Id).String()
    md, err := r.oauthAuthorizationServerMetadata(ctx, didAsString)
    if err != nil {
        return nil, err
    }
    return OAuthAuthorizationServerMetadata200JSONResponse(*md), nil
}

func (r Wrapper) RootOAuthAuthorizationServerMetadata(ctx context.Context, request RootOAuthAuthorizationServerMetadataRequestObject) (RootOAuthAuthorizationServerMetadataResponseObject, error) {
    md, err := r.oauthAuthorizationServerMetadata(ctx, r.requestedDID("").String())
    if err != nil {
        return nil, err
    }
    return RootOAuthAuthorizationServerMetadata200JSONResponse(*md), nil
}

func (r Wrapper) oauthAuthorizationServerMetadata(ctx context.Context, didAsString string) (*oauth.AuthorizationServerMetadata, error) {
    ownDID, err := r.toOwnedDID(ctx, didAsString)
    if err != nil {
        return nil, err
    }
    return authorizationServerMetadata(*ownDID)
}

func (r Wrapper) GetTenantWebDID(_ context.Context, request GetTenantWebDIDRequestObject) (GetTenantWebDIDResponseObject, error) {
    ownDID := r.requestedDID(request.Id)
    document, err := r.vdr.ResolveManaged(ownDID)
    if err != nil {
        if resolver.IsFunctionalResolveError(err) {
            return GetTenantWebDID404Response{}, nil
        }
        log.Logger().WithError(err).Errorf("Could not resolve tenant did:web: %s", ownDID.String())
        return nil, errors.New("unable to resolve DID")
    }
    return GetTenantWebDID200JSONResponse(*document), nil
}

func (r Wrapper) GetRootWebDID(ctx context.Context, _ GetRootWebDIDRequestObject) (GetRootWebDIDResponseObject, error) {
    ownDID := r.requestedDID("")
    document, err := r.vdr.ResolveManaged(ownDID)
    if err != nil {
        if resolver.IsFunctionalResolveError(err) {
            return GetRootWebDID404Response{}, nil
        }
        log.Logger().WithError(err).Errorf("Could not resolve root did:web: %s", ownDID.String())
        return nil, errors.New("unable to resolve DID")
    }
    return GetRootWebDID200JSONResponse(*document), nil
}

// OAuthClientMetadata returns the OAuth2 Client metadata for the request.Id if it is managed by this node.
func (r Wrapper) OAuthClientMetadata(ctx context.Context, request OAuthClientMetadataRequestObject) (OAuthClientMetadataResponseObject, error) {
    ownedDID, err := r.toOwnedDID(ctx, request.Did)
    if err != nil {
        return nil, err
    }

    identityURL, err := createOAuth2BaseURL(*ownedDID)
    if err != nil {
        return nil, err
    }

    return OAuthClientMetadata200JSONResponse(clientMetadata(*identityURL)), nil
}
func (r Wrapper) PresentationDefinition(ctx context.Context, request PresentationDefinitionRequestObject) (PresentationDefinitionResponseObject, error) {
    if len(request.Params.Scope) == 0 {
        return PresentationDefinition200JSONResponse(PresentationDefinition{}), nil
    }

    authorizer, err := r.toOwnedDIDForOAuth2(ctx, request.Did)
    if err != nil {
        return nil, err
    }

    mapping, err := r.policyBackend.PresentationDefinitions(ctx, *authorizer, request.Params.Scope)
    if err != nil {
        return nil, oauth.OAuth2Error{
            Code:        oauth.InvalidScope,
            Description: err.Error(),
        }
    }

    walletOwnerType := pe.WalletOwnerOrganization
    if request.Params.WalletOwnerType != nil {
        walletOwnerType = *request.Params.WalletOwnerType
    }
    result, exists := mapping[walletOwnerType]
    if !exists {
        return nil, oauthError(oauth.InvalidRequest, fmt.Sprintf("no presentation definition found for '%s' wallet", walletOwnerType))
    }

    return PresentationDefinition200JSONResponse(result), nil
}

// toOwnedDIDForOAuth2 is like toOwnedDID but wraps the errors in oauth.OAuth2Error to make sure they're returned as specified by the OAuth2 RFC.
func (r Wrapper) toOwnedDIDForOAuth2(ctx context.Context, didAsString string) (*did.DID, error) {
    result, err := r.toOwnedDID(ctx, didAsString)
    if err != nil {
        if strings.HasPrefix(err.Error(), "DID resolution failed") {
            return nil, oauth.OAuth2Error{
                Code:        oauth.ServerError,
                Description: err.Error(),
            }
        } else {
            return nil, oauth.OAuth2Error{
                Code:        oauth.InvalidRequest,
                Description: err.Error(),
            }
        }
    }
    return result, nil
}

func (r Wrapper) toOwnedDID(ctx context.Context, didAsString string) (*did.DID, error) {
    ownDID, err := did.ParseDID(didAsString)
    if err != nil {
        return nil, fmt.Errorf("invalid DID: %s", err)
    }
    owned, err := r.vdr.IsOwner(ctx, *ownDID)
    if err != nil {
        if resolver.IsFunctionalResolveError(err) {
            return nil, fmt.Errorf("invalid issuer DID: %s", err)
        }
        return nil, fmt.Errorf("DID resolution failed: %w", err)
    }
    if !owned {
        return nil, resolver.ErrDIDNotManagedByThisNode
    }
    return ownDID, nil
}

func (r Wrapper) RequestServiceAccessToken(ctx context.Context, request RequestServiceAccessTokenRequestObject) (RequestServiceAccessTokenResponseObject, error) {
    requestHolder, err := r.toOwnedDID(ctx, request.Did)
    if err != nil {
        return nil, err
    }

    var credentials []VerifiableCredential
    if request.Body.Credentials != nil {
        credentials = *request.Body.Credentials
    }

    // resolve verifier metadata
    requestVerifier, err := did.ParseDID(request.Body.Verifier)
    if err != nil {
        return nil, core.InvalidInputError("invalid verifier: %w", err)
    }

    useDPoP := true
    if request.Body.TokenType != nil && strings.EqualFold(string(*request.Body.TokenType), AccessTokenTypeBearer) {
        useDPoP = false
    }
    tokenResult, err := r.auth.IAMClient().RequestRFC021AccessToken(ctx, *requestHolder, *requestVerifier, request.Body.Scope, useDPoP, credentials)
    if err != nil {
        // this can be an internal server error, a 400 oauth error or a 412 precondition failed if the wallet does not contain the required credentials
        return nil, err
    }
    return RequestServiceAccessToken200JSONResponse(*tokenResult), nil
}

func (r Wrapper) RequestUserAccessToken(ctx context.Context, request RequestUserAccessTokenRequestObject) (RequestUserAccessTokenResponseObject, error) {
    requestHolder, err := r.toOwnedDID(ctx, request.Did)
    if err != nil {
        return nil, err
    }

    // Note: When we support authentication at an external IdP,
    //       the properties below become conditionally required.
    if request.Body.PreauthorizedUser == nil {
        return nil, core.InvalidInputError("missing preauthorized_user")
    }
    if request.Body.PreauthorizedUser.Id == "" {
        return nil, core.InvalidInputError("missing preauthorized_user.id")
    }
    if request.Body.PreauthorizedUser.Name == "" {
        return nil, core.InvalidInputError("missing preauthorized_user.name")
    }
    if request.Body.PreauthorizedUser.Role == "" {
        return nil, core.InvalidInputError("missing preauthorized_user.role")
    }

    if request.Body.RedirectUri == "" {
        return nil, core.InvalidInputError("missing redirect_uri")
    }

    // session ID for calling app (supports polling for token)
    sessionID := nutsCrypto.GenerateNonce()

    // generate a redirect token valid for 5 seconds
    token := nutsCrypto.GenerateNonce()
    err = r.userRedirectStore().Put(token, RedirectSession{
        AccessTokenRequest: request,
        SessionID:          sessionID,
        OwnDID:             *requestHolder,
    })
    if err != nil {
        return nil, err
    }
    tokenResponse := (&TokenResponse{}).With("status", oauth.AccessTokenRequestStatusPending)
    if err = r.accessTokenClientStore().Put(sessionID, tokenResponse); err != nil {
        return nil, err
    }

    // generate a link to the redirect endpoint
    webURL, err := createOAuth2BaseURL(*requestHolder)
    if err != nil {
        return nil, err
    }
    webURL = webURL.JoinPath("user")
    // redirect to generic user page, context of token will render correct page
    redirectURL := nutsHttp.AddQueryParams(*webURL, map[string]string{
        "token": token,
    })
    return RequestUserAccessToken200JSONResponse{
        RedirectUri: redirectURL.String(),
        SessionId:   sessionID,
    }, nil
}

func (r Wrapper) StatusList(ctx context.Context, request StatusListRequestObject) (StatusListResponseObject, error) {
    requestDID, err := did.ParseDID(request.Did)
    if err != nil {
        return nil, err
    }
    cred, err := r.vcr.Issuer().StatusList(ctx, *requestDID, request.Page)
    if err != nil {
        return nil, err
    }

    return StatusList200JSONResponse(*cred), nil
}

func (r Wrapper) openid4vciMetadata(ctx context.Context, issuerDid did.DID) (*oauth.OpenIDCredentialIssuerMetadata, *oauth.AuthorizationServerMetadata, error) {
    oauthIssuer, err := didweb.DIDToURL(issuerDid)
    if err != nil {
        return nil, nil, fmt.Errorf("invalid issuer: %w", err)
    }
    credentialIssuerMetadata, err := r.auth.IAMClient().OpenIdCredentialIssuerMetadata(ctx, oauthIssuer.String())
    if err != nil {
        return nil, nil, err
    }

    // OpenID4VCI allows multiple AuthorizationServers in credentialIssuerMetadata for a single issuer. (allows delegating issuance per VC type)
    // TODO: smart select the correct authorization server based on the metadata
    //         https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-issuer-metadata-p
    // For now we just accept the first successful result, and lookup the metadata.
    var ASMetadata *oauth.AuthorizationServerMetadata
    for _, serverURL := range credentialIssuerMetadata.AuthorizationServers {
        ASMetadata, err = r.auth.IAMClient().AuthorizationServerMetadata(ctx, serverURL)
        if err == nil {
            break
        }
    }
    if ASMetadata == nil {
        // authorization_servers is an optional field. When no authorization servers are listed, the oauth Issuer is the authorization server.
        // also try issuer in case all others fail
        ASMetadata, err = r.auth.IAMClient().AuthorizationServerMetadata(ctx, oauthIssuer.String())
        if err != nil {
            return nil, nil, err
        }
    }
    return credentialIssuerMetadata, ASMetadata, nil
}

// createAuthorizationRequest creates an OAuth2.0 authorizationRequest redirect URL that redirects to the authorization server.
// It can create both regular OAuth2 requests and OpenID4VP requests due to the requestObjectModifier.
// This modifier is used by JAR.Create to generate a (JAR) request object that is added as 'request_uri' parameter.
// It's able to create an unsigned request and a signed request (JAR) based on the OAuth Server Metadata.
func (r Wrapper) createAuthorizationRequest(ctx context.Context, client did.DID, server *did.DID, modifier requestObjectModifier) (*url.URL, error) {
    metadata := new(oauth.AuthorizationServerMetadata)
    if server != nil {
        // we want to make a call according to ยง4.1.1 of RFC6749, https://www.rfc-editor.org/rfc/rfc6749.html#section-4.1.1
        // The URL should be listed in the verifier metadata under the "authorization_endpoint" key
        oauthIssuer, err := didweb.DIDToURL(*server)
        if err != nil {
            return nil, err
        }
        metadata, err = r.auth.IAMClient().AuthorizationServerMetadata(ctx, oauthIssuer.String())
        if err != nil {
            return nil, fmt.Errorf("failed to retrieve remote OAuth Authorization Server metadata: %w", err)
        }
        if len(metadata.AuthorizationEndpoint) == 0 {
            return nil, fmt.Errorf("no authorization endpoint found in metadata for %s", *server)
        }
    } else {
        // if the server is unknown/nil we are talking to a wallet.
        // use static configuration while we try to determine the wallet that will answer the authorization request. (user wallet / QR code flow)
        *metadata = staticAuthorizationServerMetadata()
        // TODO: metadata.RequireSignedRequestObject == false.
        //         This means we send both a request_uri and add all params to the authorization request as query params.
        //        The resulting url is too long and will be rejected by mobile devices.
    }
    endpoint, err := url.Parse(metadata.AuthorizationEndpoint)
    if err != nil {
        return nil, fmt.Errorf("failed to parse authorization endpoint URL: %w", err)
    }

    // request_uri
    requestURIID := nutsCrypto.GenerateNonce()
    requestObj := r.jar.Create(client, server, modifier)
    if err := r.authzRequestObjectStore().Put(requestURIID, requestObj); err != nil {
        return nil, err
    }
    baseURL, err := createOAuth2BaseURL(client)
    if err != nil {
        return nil, err
    }
    requestURI := baseURL.JoinPath("request.jwt", requestURIID)

    // JAR request
    params := map[string]string{
        oauth.ClientIDParam:         client.String(),
        oauth.RequestURIMethodParam: requestObj.RequestURIMethod,
        oauth.RequestURIParam:       requestURI.String(),
    }
    if metadata.RequireSignedRequestObject {
        redirectURL := nutsHttp.AddQueryParams(*endpoint, params)
        return &redirectURL, nil
    }
    // else; unclear if AS has support for RFC9101, so also add all modifiers to the query itself
    // left here for completeness, node 2 node interaction always uses JAR since the AS metadata has it hardcoded
    // TODO: in the user flow we have no AS metadata, meaning that we add all params to the query.
    //         This is most likely going to fail on mobile devices due to request url length.
    modifier(params)
    redirectURL := nutsHttp.AddQueryParams(*endpoint, params)
    return &redirectURL, nil
}

// requestedDID constructs a did:web DID as it was requested by the API caller. It can be a DID with or without user path, e.g.:
// - did:web:example.com
// - did:web:example:iam:1234
// When userID is given, it's appended to the DID as `:iam:<userID>`. If it's absent, the DID is returned as is.
func (r Wrapper) requestedDID(userID string) did.DID {
    identityURL := r.identityURL(userID)
    result, _ := didweb.URLToDID(*identityURL)
    return *result
}

// identityURL is like requestedDID() but returns the base URL for the DID.
// It is used for resolving metadata and its did:web DID, using the configured Nuts node URL.
func (r Wrapper) identityURL(userID string) *url.URL {
    baseURL := r.auth.PublicURL()
    if userID == "" {
        return baseURL
    }
    return baseURL.JoinPath("iam", userID)
}

// accessTokenClientStore is used by the client to store pending access tokens and return them to the calling app.
func (r Wrapper) accessTokenClientStore() storage.SessionStore {
    return r.storageEngine.GetSessionDatabase().GetStore(accessTokenValidity, "clientaccesstoken")
}

// accessTokenServerStore is used by the Auth server to store issued access tokens
func (r Wrapper) accessTokenServerStore() storage.SessionStore {
    return r.storageEngine.GetSessionDatabase().GetStore(accessTokenValidity, "serveraccesstoken")
}

// accessTokenServerStore is used by the Auth server to store issued access tokens
func (r Wrapper) authzRequestObjectStore() storage.SessionStore {
    return r.storageEngine.GetSessionDatabase().GetStore(accessTokenValidity, oauthRequestObjectKey...)
}

// createOAuth2BaseURL creates an OAuth2 base URL for an owned did:web DID
// It creates a URL in the following format: https://<did:web host>/oauth2/<did>
func createOAuth2BaseURL(webDID did.DID) (*url.URL, error) {
    didURL, err := didweb.DIDToURL(webDID)
    if err != nil {
        return nil, fmt.Errorf("failed to convert DID to URL: %w", err)
    }
    return didURL.Parse("/oauth2/" + webDID.String())
}