From f7f54be0709bc9e94049e86a79e183a740c12b2a Mon Sep 17 00:00:00 2001 From: Wout Slakhorst Date: Fri, 8 Dec 2023 10:15:46 +0100 Subject: [PATCH] wip --- auth/api/auth/v1/api_test.go | 7 ++ auth/api/iam/api.go | 4 +- auth/api/iam/openid4vp.go | 167 ++++++++++++++++++++++++++- auth/api/iam/user.go | 10 +- auth/auth.go | 12 +- auth/client/iam/client.go | 97 ++++++++++++++++ auth/interface.go | 2 + auth/mock.go | 14 +++ auth/oauth/error.go | 2 + auth/oauth/types.go | 15 +++ auth/services/oauth/holder.go | 124 ++++++++++++++++++++ auth/services/oauth/interface.go | 22 +++- auth/services/oauth/mock.go | 101 ++++++++++++++++ auth/services/oauth/relying_party.go | 21 +++- vcr/pe/format.go | 36 ++++++ 15 files changed, 615 insertions(+), 19 deletions(-) create mode 100644 auth/services/oauth/holder.go create mode 100644 vcr/pe/format.go diff --git a/auth/api/auth/v1/api_test.go b/auth/api/auth/v1/api_test.go index 7d4491fc13..95439944c4 100644 --- a/auth/api/auth/v1/api_test.go +++ b/auth/api/auth/v1/api_test.go @@ -67,6 +67,7 @@ type mockAuthClient struct { authzServer *oauth.MockAuthorizationServer relyingParty *oauth.MockRelyingParty verifier *oauth.MockVerifier + holder *oauth.MockHolder } func (m *mockAuthClient) V2APIEnabled() bool { @@ -85,6 +86,10 @@ func (m *mockAuthClient) Verifier() oauth.Verifier { return m.verifier } +func (m *mockAuthClient) Holder() oauth.Holder { + return m.holder +} + func (m *mockAuthClient) ContractNotary() services.ContractNotary { return m.contractNotary } @@ -105,6 +110,7 @@ func createContext(t *testing.T) *TestContext { relyingParty := oauth.NewMockRelyingParty(ctrl) verifier := oauth.NewMockVerifier(ctrl) mockCredentialResolver := vcr.NewMockResolver(ctrl) + holder := oauth.NewMockHolder(ctrl) authMock := &mockAuthClient{ ctrl: ctrl, @@ -112,6 +118,7 @@ func createContext(t *testing.T) *TestContext { authzServer: authzServer, relyingParty: relyingParty, verifier: verifier, + holder: holder, } requestCtx := audit.TestContext() diff --git a/auth/api/iam/api.go b/auth/api/iam/api.go index 9cfe7455c0..6b8a5a0932 100644 --- a/auth/api/iam/api.go +++ b/auth/api/iam/api.go @@ -295,9 +295,7 @@ func (r Wrapper) HandleAuthorizeRequest(ctx context.Context, request HandleAutho case responseTypeVPToken: // Options: // - OpenID4VP flow, vp_token is sent in Authorization Response - // TODO: Check parameters for right flow - // TODO: Do we actually need this? (probably not) - panic("not implemented") + return r.handleAuthorizeRequestFromVerifier(ctx, *ownDID, params) case responseTypeVPIDToken: // Options: // - OpenID4VP+SIOP flow, vp_token is sent in Authorization Response diff --git a/auth/api/iam/openid4vp.go b/auth/api/iam/openid4vp.go index 2c92bb7bc6..ab9787baf7 100644 --- a/auth/api/iam/openid4vp.go +++ b/auth/api/iam/openid4vp.go @@ -24,22 +24,24 @@ import ( "encoding/json" "errors" "fmt" - "net/http" - "net/url" - "strings" - "github.com/google/uuid" "github.com/labstack/echo/v4" ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/auth/oauth" + oauth2 "github.com/nuts-foundation/nuts-node/auth/services/oauth" "github.com/nuts-foundation/nuts-node/crypto" httpNuts "github.com/nuts-foundation/nuts-node/http" + "github.com/nuts-foundation/nuts-node/network/log" "github.com/nuts-foundation/nuts-node/storage" "github.com/nuts-foundation/nuts-node/vcr/credential" "github.com/nuts-foundation/nuts-node/vcr/holder" + "github.com/nuts-foundation/nuts-node/vcr/pe" "github.com/nuts-foundation/nuts-node/vdr/didweb" + "net/http" + "net/url" + "strings" ) var oauthNonceKey = []string{"oauth", "nonce"} @@ -104,6 +106,8 @@ func (r Wrapper) handleAuthorizeRequestFromHolder(ctx context.Context, verifier // like this: // GET /authorize? // response_type=vp_token + // &client_id_scheme=did + // &client_metadata_uri=https%3A%2F%2Fexample.com%2Fiam%2F123%2F%2Fclient_metadata // &client_id=did:web:example.com:iam:123 // &client_id_scheme=did // &client_metadata_uri=https%3A%2F%2Fexample.com%2F.well-known%2Fauthorization-server%2Fiam%2F123%2F%2F @@ -131,8 +135,8 @@ func (r Wrapper) handleAuthorizeRequestFromHolder(ctx context.Context, verifier authServerURL := httpNuts.AddQueryParams(*walletURL, map[string]string{ responseTypeParam: responseTypeVPToken, - clientIDParam: verifier.String(), clientIDSchemeParam: didScheme, + clientIDParam: verifier.String(), responseURIParam: callbackURL.String(), presentationDefUriParam: presentationDefinitionURI.String(), clientMetadataURIParam: metadataURL.String(), @@ -158,6 +162,157 @@ func (r Wrapper) handleAuthorizeRequestFromHolder(ctx context.Context, verifier }, nil } +func (r Wrapper) handleAuthorizeRequestFromVerifier(ctx context.Context, walletDID did.DID, params map[string]string) (HandleAuthorizeRequestResponseObject, error) { + // we expect an OpenID4VP request like this + // GET /iam/456/authorize?response_type=vp_token&client_id=did:web:example.com:iam:123&nonce=xyz + // &response_mode=direct_post&response_uri=https%3A%2F%2Fclient%2Eexample%2Ecom%2Fcb&presentation_definition_uri=example.com%2Fiam%2F123%2Fpresentation_definition?scope=a+b HTTP/1.1 + // Host: server.com + // The following parameters are expected + // response_type, REQUIRED. Value MUST be set to "vp_token". + // client_id, REQUIRED. This must be a did:web + // response_uri, REQUIRED. This must be the verifier node url + // response_mode, REQUIRED. Value MUST be "direct_post" + // state, REQUIRED. Original client state from the holder (in the client role) + // presentation_definition_uri, REQUIRED. For getting the presentation definition + + // check the response URL because later errors will redirect to this URL + responseURI, responseOK := params[responseURIParam] + + // get the original authorization request of the client, if something fails we need the redirectURI from this request + // get the state parameter + state, ok := params[stateParam] + if !ok && !responseOK { + // log and render error page + log.Logger().Error("handleAuthorizeRequestFromVerifier is missing state and response_uri parameter") + // this goes to the user-agent :( + return nil, oauthError(oauth.ServerError, "something went wrong", nil) + } + if !ok { + // post error to responseURI, if it fails, it'll render error page + return r.sendAndHandleDirectPostError(ctx, oauthError(oauth.InvalidRequest, "missing state parameter", nil), responseURI) + } + + // check client state + // if no state, post error + var session OAuthSession + err := r.oauthClientStateStore().Get(state, &session) + if err != nil { + return r.sendAndHandleDirectPostError(ctx, oauthError(oauth.InvalidRequest, "state has expired", nil), responseURI) + } + clientRedirectURL := session.redirectURI() + + verifierID, ok := params[clientIDParam] + if !ok { + return r.sendAndHandleDirectPostError(ctx, oauthError(oauth.InvalidRequest, "missing client_id parameter", clientRedirectURL), responseURI) + } + // the verifier must be a did:web + verifierDID, err := did.ParseDID(verifierID) + if err != nil || verifierDID.Method != "web" { + return r.sendAndHandleDirectPostError(ctx, oauthError(oauth.InvalidRequest, "invalid client_id parameter", clientRedirectURL), responseURI) + } + // get verifier metadata + clientMetadataURI, ok := params[clientMetadataURIParam] + if !ok { + return r.sendAndHandleDirectPostError(ctx, oauthError(oauth.InvalidRequest, "missing client_metadata_uri parameter", clientRedirectURL), responseURI) + } + // we ignore any client_metadata, but officially an error must be returned when that param is present. + metadata, err := r.auth.Holder().ClientMetadata(ctx, clientMetadataURI) + if err != nil { + return r.sendAndHandleDirectPostError(ctx, oauthError(oauth.ServerError, "failed to get client metadata (verifier)", clientRedirectURL), responseURI) + } + // get presentation_definition from presentation_definition_uri + presentationDefinitionURI, ok := params[presentationDefUriParam] + if !ok { + return r.sendAndHandleDirectPostError(ctx, oauthError(oauth.InvalidRequest, "missing presentation_definition_uri parameter", clientRedirectURL), responseURI) + } + presentationDefinition, err := r.auth.RelyingParty().PresentationDefinition(ctx, presentationDefinitionURI) + if err != nil { + return r.sendAndHandleDirectPostError(ctx, oauthError(oauth.InvalidPresentationDefinitionURI, fmt.Sprintf("failed to retrieve presentation definition on %s", presentationDefinitionURI), clientRedirectURL), responseURI) + } + + // check nonce + nonce, ok := params[nonceParam] + if !ok { + return r.sendAndHandleDirectPostError(ctx, oauthError(oauth.InvalidRequest, "missing nonce parameter", clientRedirectURL), responseURI) + } + + // at this point in the flow it would be possible to ask the user to confirm the credentials to use + + // all params checked, delegate responsibility to the holder + vp, submission, err := r.auth.Holder().BuildPresentation(ctx, walletDID, *presentationDefinition, *metadata, nonce) + if err != nil { + if errors.Is(err, oauth2.ErrNoCredentials) { + return r.sendAndHandleDirectPostError(ctx, oauthError(oauth.InvalidRequest, "no credentials available", clientRedirectURL), responseURI) + } + return r.sendAndHandleDirectPostError(ctx, oauthError(oauth.ServerError, err.Error(), clientRedirectURL), responseURI) + } + + return r.sendAndHandleDirectPost(ctx, *vp, *submission, responseURI, *clientRedirectURL) +} + +// sendAndHandleDirectPost sends OpenID4VP direct_post to the verifier. The verifier responds with a redirect to the client (including error fields if needed). +// If the direct post fails, the user-agent will be redirected back to the client with an error. (Original redirect_uri). +// If no redirect_uri is present, the user-agent will be redirected to the error page. +func (r Wrapper) sendAndHandleDirectPost(ctx context.Context, vp vc.VerifiablePresentation, presentationSubmission pe.PresentationSubmission, verifierResponseURI string, clientRedirectURL url.URL) (HandleAuthorizeRequestResponseObject, error) { + redirectURI, err := r.auth.Holder().PostAuthorizationResponse(ctx, vp, presentationSubmission, verifierResponseURI) + if err == nil { + return HandleAuthorizeRequest302Response{ + HandleAuthorizeRequest302ResponseHeaders{ + Location: redirectURI, + }, + }, nil + } + + msg := fmt.Sprintf("failed to post authorization response to verifier @ %s", verifierResponseURI) + log.Logger().WithError(err).Error(msg) + + // clientRedirectURI has been checked earlier in te process. + clientRedirectURL = httpNuts.AddQueryParams(clientRedirectURL, map[string]string{ + oauth.ErrorParam: string(oauth.ServerError), + oauth.ErrorDescriptionParam: msg, + }) + return HandleAuthorizeRequest302Response{ + HandleAuthorizeRequest302ResponseHeaders{ + Location: clientRedirectURL.String(), + }, + }, nil +} + +// sendAndHandleDirectPostError sends errors from handleAuthorizeRequestFromVerifier as direct_post to the verifier. The verifier responds with a redirect to the client (including error fields). +// If the direct post fails, the user-agent will be redirected back to the client with an error. (Original redirect_uri). +// If no redirect_uri is present, the user-agent will be redirected to the error page. +func (r Wrapper) sendAndHandleDirectPostError(ctx context.Context, auth2Error oauth.OAuth2Error, verifierResponseURI string) (HandleAuthorizeRequestResponseObject, error) { + redirectURI, err := r.auth.Holder().PostError(ctx, auth2Error, verifierResponseURI) + if err == nil { + return HandleAuthorizeRequest302Response{ + HandleAuthorizeRequest302ResponseHeaders{ + Location: redirectURI, + }, + }, nil + } + + msg := fmt.Sprintf("failed to post error to verifier @ %s", verifierResponseURI) + log.Logger().WithError(err).Error(msg) + + if auth2Error.RedirectURI == nil { + // render error page because all else failed, in a correct flow this should never happen + // it could be the case that the client state has just expired, so no redirectURI is present and the verifier is not responding + log.Logger().WithError(err).Error("failed to post error to verifier and no clientRedirectURI present") + return nil, oauthError(oauth.ServerError, "something went wrong", nil) + } + + // clientRedirectURL has been checked earlier in te process. + clientRedirectURL := httpNuts.AddQueryParams(*auth2Error.RedirectURI, map[string]string{ + oauth.ErrorParam: string(oauth.ServerError), + oauth.ErrorDescriptionParam: msg, + }) + return HandleAuthorizeRequest302Response{ + HandleAuthorizeRequest302ResponseHeaders{ + Location: clientRedirectURL.String(), + }, + }, nil +} + // createPresentationRequest creates a new Authorization Request as specified by OpenID4VP: https://openid.net/specs/openid-4-verifiable-presentations-1_0.html. // It is sent by a verifier to a wallet, to request one or more verifiable credentials as verifiable presentation from the wallet. func (r Wrapper) sendPresentationRequest(ctx context.Context, response http.ResponseWriter, scope string, @@ -181,7 +336,7 @@ func (r Wrapper) sendPresentationRequest(ctx context.Context, response http.Resp // handlePresentationRequest handles an Authorization Request as specified by OpenID4VP: https://openid.net/specs/openid-4-verifiable-presentations-1_0.html. // It is handled by a wallet, called by a verifier who wants the wallet to present one or more verifiable credentials. -func (r *Wrapper) handlePresentationRequest(params map[string]string, session *OAuthSession) (HandleAuthorizeRequestResponseObject, error) { +func (r Wrapper) handlePresentationRequest(params map[string]string, session *OAuthSession) (HandleAuthorizeRequestResponseObject, error) { ctx := context.TODO() // Presentation definition is always derived from the scope. // Later on, we might support presentation_definition and/or presentation_definition_uri parameters instead of scope as well. diff --git a/auth/api/iam/user.go b/auth/api/iam/user.go index 1cfda26719..d89d859818 100644 --- a/auth/api/iam/user.go +++ b/auth/api/iam/user.go @@ -48,7 +48,7 @@ var oauthClientStateKey = []string{"oauth", "client_state"} var userRedirectSessionKey = []string{"user", "redirect"} var userSessionKey = []string{"user", "session"} -func (r *Wrapper) requestUserAccessToken(_ context.Context, requester did.DID, request RequestAccessTokenRequestObject) (RequestAccessTokenResponseObject, error) { +func (r Wrapper) requestUserAccessToken(_ context.Context, requester did.DID, request RequestAccessTokenRequestObject) (RequestAccessTokenResponseObject, error) { // generate a redirect token valid for 5 seconds token := crypto.GenerateNonce() store := r.userRedirectStore() @@ -78,7 +78,7 @@ func (r *Wrapper) requestUserAccessToken(_ context.Context, requester did.DID, r // handleUserLanding is the handler for the landing page of the user. // It renders the page with the correct context based on the token. -func (r *Wrapper) handleUserLanding(echoCtx echo.Context) error { +func (r Wrapper) handleUserLanding(echoCtx echo.Context) error { // todo: user authentication is currently not implemented, user consent is not implemented // This means that this handler succeeds if the token is valid // It only checks for an existing RequestAccessTokenRequestObject in the store @@ -136,14 +136,14 @@ func (r *Wrapper) handleUserLanding(echoCtx echo.Context) error { return echoCtx.Redirect(http.StatusFound, redirectURL.String()) } -func (r *Wrapper) userRedirectStore() storage.SessionStore { +func (r Wrapper) userRedirectStore() storage.SessionStore { return r.storageEngine.GetSessionDatabase().GetStore(userRedirectTimeout, userRedirectSessionKey...) } -func (r *Wrapper) userSessionStore() storage.SessionStore { +func (r Wrapper) userSessionStore() storage.SessionStore { return r.storageEngine.GetSessionDatabase().GetStore(userSessionTimeout, userSessionKey...) } -func (r *Wrapper) oauthClientStateStore() storage.SessionStore { +func (r Wrapper) oauthClientStateStore() storage.SessionStore { return r.storageEngine.GetSessionDatabase().GetStore(oAuthFlowTimeout, oauthClientStateKey...) } diff --git a/auth/auth.go b/auth/auth.go index 4b7ed93c22..04dccb5081 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -50,6 +50,7 @@ type Auth struct { authzServer oauth.AuthorizationServer relyingParty oauth.RelyingParty verifier oauth.Verifier + holder oauth.Holder contractNotary services.ContractNotary serviceResolver didman.CompoundServiceResolver keyStore crypto.KeyStore @@ -119,6 +120,10 @@ func (auth *Auth) Verifier() oauth.Verifier { return auth.verifier } +func (auth *Auth) Holder() oauth.Holder { + return auth.holder +} + // Configure the Auth struct by creating a validator and create an Irma server func (auth *Auth) Configure(config core.ServerConfig) error { if auth.config.Irma.SchemeManager == "" { @@ -165,13 +170,14 @@ func (auth *Auth) Configure(config core.ServerConfig) error { return fmt.Errorf("failed to load presentation exchange mapping file: %w", err) } } - + clientTimeout := time.Duration(auth.config.HTTPTimeout) * time.Second accessTokenLifeSpan := time.Duration(auth.config.AccessTokenLifeSpan) * time.Second auth.authzServer = oauth.NewAuthorizationServer(auth.vdrInstance.Resolver(), auth.vcr, auth.vcr.Verifier(), auth.serviceResolver, auth.keyStore, auth.contractNotary, auth.jsonldManager, accessTokenLifeSpan) auth.relyingParty = oauth.NewRelyingParty(auth.vdrInstance.Resolver(), auth.serviceResolver, - auth.keyStore, auth.vcr.Wallet(), time.Duration(auth.config.HTTPTimeout)*time.Second, tlsConfig, config.Strictmode) - auth.verifier = oauth.NewVerifier(config.Strictmode, time.Duration(auth.config.HTTPTimeout)*time.Second, tlsConfig) + auth.keyStore, auth.vcr.Wallet(), clientTimeout, tlsConfig, config.Strictmode) + auth.verifier = oauth.NewVerifier(config.Strictmode, clientTimeout, tlsConfig) + auth.holder = oauth.NewHolder(auth.vcr.Wallet(), config.Strictmode, clientTimeout, tlsConfig) if err := auth.authzServer.Configure(auth.config.ClockSkew, config.Strictmode); err != nil { return err diff --git a/auth/client/iam/client.go b/auth/client/iam/client.go index 508306205d..f16d20ca16 100644 --- a/auth/client/iam/client.go +++ b/auth/client/iam/client.go @@ -90,6 +90,40 @@ func (hb HTTPClient) OAuthAuthorizationServerMetadata(ctx context.Context, webDI return &metadata, nil } +// ClientMetadata retrieves the client metadata from the client metadata endpoint given in the authorization request. +// We use the AuthorizationServerMetadata struct since it overlaps greatly with the client metadata. +func (hb HTTPClient) ClientMetadata(ctx context.Context, endpoint string) (*oauth.AuthorizationServerMetadata, error) { + _, err := core.ParsePublicURL(endpoint, hb.strictMode) + if err != nil { + return nil, err + } + + // create a GET request + request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + response, err := hb.httpClient.Do(request.WithContext(ctx)) + if err != nil { + return nil, fmt.Errorf("failed to call endpoint: %w", err) + } + if httpErr := core.TestResponseCode(http.StatusOK, response); httpErr != nil { + return nil, httpErr + } + + var metadata oauth.AuthorizationServerMetadata + var data []byte + + if data, err = io.ReadAll(response.Body); err != nil { + return nil, fmt.Errorf("unable to read response: %w", err) + } + if err = json.Unmarshal(data, &metadata); err != nil { + return nil, fmt.Errorf("unable to unmarshal response: %w", err) + } + + return &metadata, nil +} + // PresentationDefinition retrieves the presentation definition from the presentation definition endpoint (as specified by RFC021) for the given scope. func (hb HTTPClient) PresentationDefinition(ctx context.Context, definitionEndpoint string, scopes string) (*pe.PresentationDefinition, error) { presentationDefinitionURL, err := core.ParsePublicURL(definitionEndpoint, hb.strictMode) @@ -185,3 +219,66 @@ func (hb HTTPClient) AccessToken(ctx context.Context, tokenEndpoint string, vp v } return token, nil } + +// PostError posts an OAuth error to the redirect URL and returns the redirect URL with the error as query parameter. +func (hb HTTPClient) PostError(ctx context.Context, auth2Error oauth.OAuth2Error, verifierCallbackURL string) (string, error) { + // initiate http client, create a POST request with x-www-form-urlencoded body and send it to the redirect URL + data := url.Values{} + data.Set(oauth.ErrorParam, string(auth2Error.Code)) + data.Set(oauth.ErrorDescriptionParam, auth2Error.Description) + request, err := http.NewRequestWithContext(ctx, http.MethodPost, verifierCallbackURL, strings.NewReader(data.Encode())) + request.Header.Add("Accept", "application/json") + request.Header.Add("Content-Type", "application/x-www-form-urlencoded") + if err != nil { + return "", err + } + response, err := hb.httpClient.Do(request.WithContext(ctx)) + if err != nil { + return "", err + } + if err = core.TestResponseCode(http.StatusOK, response); err != nil { + return "", err + } + // take the redirectURL from the response body and return it + var responseData []byte + if responseData, err = io.ReadAll(response.Body); err != nil { + return "", fmt.Errorf("unable to read response: %w", err) + } + var redirect oauth.Redirect + if err = json.Unmarshal(responseData, &redirect); err != nil { + return "", fmt.Errorf("unable to unmarshal response: %w, %s", err, string(responseData)) + } + return redirect.RedirectURI, nil +} + +func (hb HTTPClient) PostAuthorizationResponse(ctx context.Context, vp vc.VerifiablePresentation, presentationSubmission pe.PresentationSubmission, verifierResponseURI string) (string, error) { + // initiate http client, create a POST request with x-www-form-urlencoded body and send it to the redirect URL + psBytes, _ := json.Marshal(presentationSubmission) + data := url.Values{} + data.Set(oauth.VpTokenParam, vp.Raw()) + data.Set(oauth.PresentationSubmissionParam, string(psBytes)) + request, err := http.NewRequestWithContext(ctx, http.MethodPost, verifierResponseURI, strings.NewReader(data.Encode())) + if err != nil { + return "", err + } + request.Header.Add("Accept", "application/json") + request.Header.Add("Content-Type", "application/x-www-form-urlencoded") + + response, err := hb.httpClient.Do(request.WithContext(ctx)) + if err != nil { + return "", err + } + if err = core.TestResponseCode(http.StatusOK, response); err != nil { + return "", err + } + // take the redirectURL from the response body and return it + var responseData []byte + if responseData, err = io.ReadAll(response.Body); err != nil { + return "", fmt.Errorf("unable to read response: %w", err) + } + var redirect oauth.Redirect + if err = json.Unmarshal(responseData, &redirect); err != nil { + return "", fmt.Errorf("unable to unmarshal response: %w, %s", err, string(responseData)) + } + return redirect.RedirectURI, nil +} diff --git a/auth/interface.go b/auth/interface.go index 1749b017a1..d70510ad76 100644 --- a/auth/interface.go +++ b/auth/interface.go @@ -32,6 +32,8 @@ const ModuleName = "Auth" type AuthenticationServices interface { // AuthzServer returns the oauth.AuthorizationServer AuthzServer() oauth.AuthorizationServer + // Holder returens the oauth.Holder + Holder() oauth.Holder // RelyingParty returns the oauth.RelyingParty RelyingParty() oauth.RelyingParty // Verifier returns the oauth.Verifier service provider diff --git a/auth/mock.go b/auth/mock.go index a1af1bdd49..c7e48ae3bb 100644 --- a/auth/mock.go +++ b/auth/mock.go @@ -69,6 +69,20 @@ func (mr *MockAuthenticationServicesMockRecorder) ContractNotary() *gomock.Call return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ContractNotary", reflect.TypeOf((*MockAuthenticationServices)(nil).ContractNotary)) } +// Holder mocks base method. +func (m *MockAuthenticationServices) Holder() oauth.Holder { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Holder") + ret0, _ := ret[0].(oauth.Holder) + return ret0 +} + +// Holder indicates an expected call of Holder. +func (mr *MockAuthenticationServicesMockRecorder) Holder() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Holder", reflect.TypeOf((*MockAuthenticationServices)(nil).Holder)) +} + // PresentationDefinitions mocks base method. func (m *MockAuthenticationServices) PresentationDefinitions() *pe.DefinitionResolver { m.ctrl.T.Helper() diff --git a/auth/oauth/error.go b/auth/oauth/error.go index 1ea92ae377..cbf89dbaed 100644 --- a/auth/oauth/error.go +++ b/auth/oauth/error.go @@ -44,6 +44,8 @@ const ( ServerError ErrorCode = "server_error" // InvalidScope is returned when the requested scope is invalid, unknown or malformed. InvalidScope = ErrorCode("invalid_scope") + // InvalidPresentationDefinitionURI is returned when the requested presentation definition URI is invalid or can't be reached. + InvalidPresentationDefinitionURI = ErrorCode("invalid_presentation_definition_uri") ) // Make sure the error implements core.HTTPStatusCodeError, so the HTTP request logger can log the correct status code. diff --git a/auth/oauth/types.go b/auth/oauth/types.go index a95422bb85..a8ab2d32d0 100644 --- a/auth/oauth/types.go +++ b/auth/oauth/types.go @@ -50,10 +50,19 @@ const ( ScopeParam = "scope" // PresentationSubmissionParam is the parameter name for the presentation_submission parameter PresentationSubmissionParam = "presentation_submission" + // VpTokenParam is the parameter name for the vp_token parameter + VpTokenParam = "vp_token" // VpTokenGrantType is the grant_type for the vp_token-bearer grant type VpTokenGrantType = "vp_token-bearer" ) +const ( + // ErrorParam is the parameter name for the error parameter + ErrorParam = "error" + // ErrorDescriptionParam is the parameter name for the error_description parameter + ErrorDescriptionParam = "error_description" +) + // IssuerIdToWellKnown converts the OAuth2 Issuer identity to the specified well-known endpoint by inserting the well-known at the root of the path. // It returns no url and an error when issuer is not a valid URL. func IssuerIdToWellKnown(issuer string, wellKnown string, strictmode bool) (*url.URL, error) { @@ -130,3 +139,9 @@ type AuthorizationServerMetadata struct { // If omitted, the default value is `pre-registered` (referring to the client), which is currently not supported. ClientIdSchemesSupported []string `json:"client_id_schemes_supported,omitempty"` } + +// Redirect is the response from the verifier on the direct_post authorization response. +type Redirect struct { + // RedirectURI is the URI to redirect the user-agent to. + RedirectURI string `json:"redirect_uri"` +} diff --git a/auth/services/oauth/holder.go b/auth/services/oauth/holder.go new file mode 100644 index 0000000000..77dd3191cb --- /dev/null +++ b/auth/services/oauth/holder.go @@ -0,0 +1,124 @@ +/* + * Nuts node + * 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 . + * + */ + +package oauth + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "github.com/nuts-foundation/go-did/did" + "github.com/nuts-foundation/go-did/vc" + "github.com/nuts-foundation/nuts-node/auth/client/iam" + "github.com/nuts-foundation/nuts-node/auth/oauth" + "github.com/nuts-foundation/nuts-node/vcr/holder" + "github.com/nuts-foundation/nuts-node/vcr/pe" + "github.com/nuts-foundation/nuts-node/vcr/signature/proof" + "time" +) + +var _ Holder = (*HolderService)(nil) + +var ErrNoCredentials = errors.New("no matching credentials") + +type HolderService struct { + strictMode bool + httpClientTimeout time.Duration + httpClientTLS *tls.Config + wallet holder.Wallet +} + +// NewHolder returns an implementation of Holder +func NewHolder(wallet holder.Wallet, strictMode bool, httpClientTimeout time.Duration, httpClientTLS *tls.Config) *HolderService { + return &HolderService{ + wallet: wallet, + strictMode: strictMode, + httpClientTimeout: httpClientTimeout, + httpClientTLS: httpClientTLS, + } +} + +func (v *HolderService) BuildPresentation(ctx context.Context, walletDID did.DID, presentationDefinition pe.PresentationDefinition, verifierMetadata oauth.AuthorizationServerMetadata, nonce string) (*vc.VerifiablePresentation, *pe.PresentationSubmission, error) { + // get VCs from own wallet + credentials, err := v.wallet.List(ctx, walletDID) + if err != nil { + return nil, nil, errors.New("failed to retrieve wallet credentials") + } + + expires := time.Now().Add(time.Minute * 15) //todo + // build VP + submissionBuilder := presentationDefinition.PresentationSubmissionBuilder() + submissionBuilder.AddWallet(walletDID, credentials) + format := pe.ChooseVPFormat(verifierMetadata.VPFormats) + presentationSubmission, signInstructions, err := submissionBuilder.Build(format) + if err != nil { + return nil, nil, fmt.Errorf("failed to build presentation submission: %w", err) + } + if signInstructions.Empty() { + return nil, nil, ErrNoCredentials + } + + // todo: support multiple wallets + vp, err := v.wallet.BuildPresentation(ctx, signInstructions[0].VerifiableCredentials, holder.PresentationOptions{ + Format: format, + ProofOptions: proof.ProofOptions{ + Created: time.Now(), + Challenge: &nonce, + Expires: &expires, + }, + }, &walletDID, false) + if err != nil { + return nil, nil, fmt.Errorf("failed to create verifiable presentation: %w", err) + } + return vp, &presentationSubmission, nil +} + +func (v *HolderService) ClientMetadata(ctx context.Context, endpoint string) (*oauth.AuthorizationServerMetadata, error) { + iamClient := iam.NewHTTPClient(v.strictMode, v.httpClientTimeout, v.httpClientTLS) + // The verifier has become the client + + metadata, err := iamClient.ClientMetadata(ctx, endpoint) + if err != nil { + return nil, fmt.Errorf("failed to retrieve remote OAuth Authorization Server metadata: %w", err) + } + return metadata, nil +} + +func (v *HolderService) PostError(ctx context.Context, auth2Error oauth.OAuth2Error, verifierResponseURI string) (string, error) { + iamClient := iam.NewHTTPClient(v.strictMode, v.httpClientTimeout, v.httpClientTLS) + + redirectURL, err := iamClient.PostError(ctx, auth2Error, verifierResponseURI) + if err == nil { + return redirectURL, nil + } + + return "", fmt.Errorf("failed to post error to verifier: %w", err) +} + +func (v *HolderService) PostAuthorizationResponse(ctx context.Context, vp vc.VerifiablePresentation, presentationSubmission pe.PresentationSubmission, verifierResponseURI string) (string, error) { + iamClient := iam.NewHTTPClient(v.strictMode, v.httpClientTimeout, v.httpClientTLS) + + redirectURL, err := iamClient.PostAuthorizationResponse(ctx, vp, presentationSubmission, verifierResponseURI) + if err == nil { + return redirectURL, nil + } + + return "", fmt.Errorf("failed to post authorization response to verifier: %w", err) +} diff --git a/auth/services/oauth/interface.go b/auth/services/oauth/interface.go index 28c237c7a6..072bd32eaa 100644 --- a/auth/services/oauth/interface.go +++ b/auth/services/oauth/interface.go @@ -20,10 +20,13 @@ package oauth import ( "context" + "github.com/nuts-foundation/go-did/vc" + "net/url" + "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/auth/services" - "net/url" + "github.com/nuts-foundation/nuts-node/vcr/pe" ) // RelyingParty implements the OAuth2 relying party role. @@ -31,6 +34,11 @@ type RelyingParty interface { CreateJwtGrant(ctx context.Context, request services.CreateJwtGrantRequest) (*services.JwtBearerTokenResult, error) // CreateAuthorizationRequest creates an OAuth2.0 authorizationRequest redirect URL that redirects to the authorization server. CreateAuthorizationRequest(ctx context.Context, requestHolder did.DID, verifier did.DID, scopes string, clientState string) (*url.URL, error) + + // PresentationDefinition returns the presentation definition from the given endpoint. + // the presentationDefinitionURL contains the full path including the query parameters. + PresentationDefinition(ctx context.Context, presentationDefinitionURL string) (*pe.PresentationDefinition, error) + // RequestRFC003AccessToken is called by the local EHR node to request an access token from a remote Nuts node using Nuts RFC003. RequestRFC003AccessToken(ctx context.Context, jwtGrantToken string, authServerEndpoint url.URL) (*oauth.TokenResponse, error) // RequestRFC021AccessToken is called by the local EHR node to request an access token from a remote Nuts node using Nuts RFC021. @@ -55,3 +63,15 @@ type Verifier interface { // ClientMetadataURL constructs the URL to the client metadata of the local verifier. ClientMetadataURL(webdid did.DID) (*url.URL, error) } + +// Holder implements the OpenID4VP Holder role which acts as Authorization server in the OpenID4VP flow. +type Holder interface { + // BuildPresentation builds a Verifiable Presentation based on the given presentation definition. + BuildPresentation(ctx context.Context, walletDID did.DID, presentationDefinition pe.PresentationDefinition, verifierMetadata oauth.AuthorizationServerMetadata, nonce string) (*vc.VerifiablePresentation, *pe.PresentationSubmission, error) + // ClientMetadata returns the metadata of the remote verifier. + ClientMetadata(ctx context.Context, endpoint string) (*oauth.AuthorizationServerMetadata, error) + // PostError posts an error to the verifier. If it fails, an error is returned. + PostError(ctx context.Context, auth2Error oauth.OAuth2Error, verifierResponseURI string) (string, error) + // PostAuthorizationResponse posts the authorization response to the verifier. If it fails, an error is returned. + PostAuthorizationResponse(ctx context.Context, vp vc.VerifiablePresentation, presentationSubmission pe.PresentationSubmission, verifierResponseURI string) (string, error) +} diff --git a/auth/services/oauth/mock.go b/auth/services/oauth/mock.go index babd76913b..7954f8b52d 100644 --- a/auth/services/oauth/mock.go +++ b/auth/services/oauth/mock.go @@ -14,8 +14,10 @@ import ( reflect "reflect" did "github.com/nuts-foundation/go-did/did" + vc "github.com/nuts-foundation/go-did/vc" oauth "github.com/nuts-foundation/nuts-node/auth/oauth" services "github.com/nuts-foundation/nuts-node/auth/services" + pe "github.com/nuts-foundation/nuts-node/vcr/pe" gomock "go.uber.org/mock/gomock" ) @@ -72,6 +74,21 @@ func (mr *MockRelyingPartyMockRecorder) CreateJwtGrant(ctx, request any) *gomock return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateJwtGrant", reflect.TypeOf((*MockRelyingParty)(nil).CreateJwtGrant), ctx, request) } +// PresentationDefinition mocks base method. +func (m *MockRelyingParty) PresentationDefinition(ctx context.Context, presentationDefinitionURL string) (*pe.PresentationDefinition, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PresentationDefinition", ctx, presentationDefinitionURL) + ret0, _ := ret[0].(*pe.PresentationDefinition) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PresentationDefinition indicates an expected call of PresentationDefinition. +func (mr *MockRelyingPartyMockRecorder) PresentationDefinition(ctx, presentationDefinitionURL any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PresentationDefinition", reflect.TypeOf((*MockRelyingParty)(nil).PresentationDefinition), ctx, presentationDefinitionURL) +} + // RequestRFC003AccessToken mocks base method. func (m *MockRelyingParty) RequestRFC003AccessToken(ctx context.Context, jwtGrantToken string, authServerEndpoint url.URL) (*oauth.TokenResponse, error) { m.ctrl.T.Helper() @@ -221,3 +238,87 @@ func (mr *MockVerifierMockRecorder) ClientMetadataURL(webdid any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClientMetadataURL", reflect.TypeOf((*MockVerifier)(nil).ClientMetadataURL), webdid) } + +// MockHolder is a mock of Holder interface. +type MockHolder struct { + ctrl *gomock.Controller + recorder *MockHolderMockRecorder +} + +// MockHolderMockRecorder is the mock recorder for MockHolder. +type MockHolderMockRecorder struct { + mock *MockHolder +} + +// NewMockHolder creates a new mock instance. +func NewMockHolder(ctrl *gomock.Controller) *MockHolder { + mock := &MockHolder{ctrl: ctrl} + mock.recorder = &MockHolderMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockHolder) EXPECT() *MockHolderMockRecorder { + return m.recorder +} + +// BuildPresentation mocks base method. +func (m *MockHolder) BuildPresentation(ctx context.Context, walletDID did.DID, presentationDefinition pe.PresentationDefinition, verifierMetadata oauth.AuthorizationServerMetadata, nonce string) (*vc.VerifiablePresentation, *pe.PresentationSubmission, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "BuildPresentation", ctx, walletDID, presentationDefinition, verifierMetadata, nonce) + ret0, _ := ret[0].(*vc.VerifiablePresentation) + ret1, _ := ret[1].(*pe.PresentationSubmission) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// BuildPresentation indicates an expected call of BuildPresentation. +func (mr *MockHolderMockRecorder) BuildPresentation(ctx, walletDID, presentationDefinition, verifierMetadata, nonce any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BuildPresentation", reflect.TypeOf((*MockHolder)(nil).BuildPresentation), ctx, walletDID, presentationDefinition, verifierMetadata, nonce) +} + +// ClientMetadata mocks base method. +func (m *MockHolder) ClientMetadata(ctx context.Context, endpoint string) (*oauth.AuthorizationServerMetadata, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ClientMetadata", ctx, endpoint) + ret0, _ := ret[0].(*oauth.AuthorizationServerMetadata) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ClientMetadata indicates an expected call of ClientMetadata. +func (mr *MockHolderMockRecorder) ClientMetadata(ctx, endpoint any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClientMetadata", reflect.TypeOf((*MockHolder)(nil).ClientMetadata), ctx, endpoint) +} + +// PostAuthorizationResponse mocks base method. +func (m *MockHolder) PostAuthorizationResponse(ctx context.Context, vp vc.VerifiablePresentation, presentationSubmission pe.PresentationSubmission, verifierResponseURI string) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PostAuthorizationResponse", ctx, vp, presentationSubmission, verifierResponseURI) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PostAuthorizationResponse indicates an expected call of PostAuthorizationResponse. +func (mr *MockHolderMockRecorder) PostAuthorizationResponse(ctx, vp, presentationSubmission, verifierResponseURI any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PostAuthorizationResponse", reflect.TypeOf((*MockHolder)(nil).PostAuthorizationResponse), ctx, vp, presentationSubmission, verifierResponseURI) +} + +// PostError mocks base method. +func (m *MockHolder) PostError(ctx context.Context, auth2Error oauth.OAuth2Error, verifierResponseURI string) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PostError", ctx, auth2Error, verifierResponseURI) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PostError indicates an expected call of PostError. +func (mr *MockHolderMockRecorder) PostError(ctx, auth2Error, verifierResponseURI any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PostError", reflect.TypeOf((*MockHolder)(nil).PostError), ctx, auth2Error, verifierResponseURI) +} diff --git a/auth/services/oauth/relying_party.go b/auth/services/oauth/relying_party.go index 9da8482646..3cb78f10ff 100644 --- a/auth/services/oauth/relying_party.go +++ b/auth/services/oauth/relying_party.go @@ -23,12 +23,12 @@ import ( "crypto/tls" "errors" "fmt" + "github.com/lestrrat-go/jwx/v2/jwt" "net/http" "net/url" "strings" "time" - "github.com/lestrrat-go/jwx/v2/jwt" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/auth/api/auth/v1/client" @@ -41,6 +41,7 @@ import ( nutsHttp "github.com/nuts-foundation/nuts-node/http" "github.com/nuts-foundation/nuts-node/vcr/credential" "github.com/nuts-foundation/nuts-node/vcr/holder" + "github.com/nuts-foundation/nuts-node/vcr/pe" "github.com/nuts-foundation/nuts-node/vcr/signature/proof" "github.com/nuts-foundation/nuts-node/vdr/resolver" ) @@ -257,6 +258,24 @@ func chooseVPFormat(formats map[string]map[string][]string) string { return "" } +func (s *relyingParty) PresentationDefinition(ctx context.Context, presentationDefinitionURL string) (*pe.PresentationDefinition, error) { + parsedURL, err := url.Parse(presentationDefinitionURL) + if err != nil { + return nil, fmt.Errorf("failed to parse presentation definition URL: %w", err) + } + presentationDefinitionEndpoint := *parsedURL + presentationDefinitionEndpoint.RawQuery = "" + presentationDefinitionEndpoint.Fragment = "" + scope := parsedURL.Query().Get("scope") + + iamClient := iam.NewHTTPClient(s.strictMode, s.httpClientTimeout, s.httpClientTLS) + presentationDefinition, err := iamClient.PresentationDefinition(ctx, presentationDefinitionEndpoint.String(), scope) + if err != nil { + return nil, fmt.Errorf("failed to retrieve presentation definition: %w", err) + } + return presentationDefinition, nil +} + var timeFunc = time.Now // standalone func for easier testing diff --git a/vcr/pe/format.go b/vcr/pe/format.go new file mode 100644 index 0000000000..e0986e43a1 --- /dev/null +++ b/vcr/pe/format.go @@ -0,0 +1,36 @@ +/* + * 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 . + * + */ + +package pe + +import "github.com/nuts-foundation/go-did/vc" + +// ChooseVPFormat determines the format of the Verifiable Presentation based on the authorization server metadata. +func ChooseVPFormat(formats map[string]map[string][]string) string { + // They are in preferred order + if _, ok := formats[vc.JWTPresentationProofFormat]; ok { + return vc.JWTPresentationProofFormat + } + if _, ok := formats["jwt_vp_json"]; ok { + return vc.JWTPresentationProofFormat + } + if _, ok := formats[vc.JSONLDPresentationProofFormat]; ok { + return vc.JSONLDPresentationProofFormat + } + return "" +}