Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

added e2e test for OpenID4VP s2s flow #2617

Merged
merged 2 commits into from
Nov 27, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions auth/api/iam/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"embed"
"encoding/json"
"errors"
"fmt"
"github.com/labstack/echo/v4"
"github.com/nuts-foundation/go-did/did"
"github.com/nuts-foundation/nuts-node/audit"
Expand Down Expand Up @@ -81,6 +82,9 @@ func (r Wrapper) Routes(router core.EchoRouter) {
return r.middleware(ctx, request, operationID, f)
}
},
func(f StrictHandlerFunc, operationID string) StrictHandlerFunc {
return audit.StrictMiddleware(f, apiModuleName, operationID)
},
}))
auditMiddleware := audit.Middleware(apiModuleName)
// The following handler is of the OpenID4VCI wallet which is called by the holder (wallet owner)
Expand Down Expand Up @@ -110,12 +114,12 @@ func (r Wrapper) middleware(ctx echo.Context, request interface{}, operationID s
if strings.HasPrefix(ctx.Request().URL.Path, "/iam/") {
ctx.Set(core.ErrorWriterContextKey, &oauth.Oauth2ErrorWriter{})
}
audit.StrictMiddleware(f, apiModuleName, operationID)

return f(ctx, request)
}

// 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) {
func (r Wrapper) HandleTokenRequest(_ context.Context, request HandleTokenRequestRequestObject) (HandleTokenRequestResponseObject, error) {
switch request.Body.GrantType {
case "authorization_code":
// Options:
Expand All @@ -125,7 +129,7 @@ func (r Wrapper) HandleTokenRequest(ctx context.Context, request HandleTokenRequ
Code: oauth.UnsupportedGrantType,
Description: "not implemented yet",
}
case "vp_token":
case "vp_token-bearer":
// Options:
// - service-to-service vp_token flow
return nil, oauth.OAuth2Error{
Expand All @@ -141,7 +145,8 @@ func (r Wrapper) HandleTokenRequest(ctx context.Context, request HandleTokenRequ
}
default:
return nil, oauth.OAuth2Error{
Code: oauth.UnsupportedGrantType,
Code: oauth.UnsupportedGrantType,
Description: fmt.Sprintf("grant_type '%s' is not supported", request.Body.GrantType),
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion auth/api/iam/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ func TestWrapper_HandleTokenRequest(t *testing.T) {
},
})

requireOAuthError(t, err, oauth.UnsupportedGrantType, "")
requireOAuthError(t, err, oauth.UnsupportedGrantType, "grant_type 'unsupported' is not supported")
assert.Nil(t, res)
})
}
Expand Down
9 changes: 8 additions & 1 deletion auth/client/iam/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ func (hb HTTPClient) AccessToken(ctx context.Context, tokenEndpoint string, vp v
data.Set(oauth.PresentationSubmissionParam, string(presentationSubmission))
data.Set(oauth.ScopeParam, scopes)
request, err := http.NewRequestWithContext(ctx, http.MethodPost, presentationDefinitionURL.String(), strings.NewReader(data.Encode()))
request.Header.Add("Accept", "application/json")
request.Header.Add("Content-Type", "application/x-www-form-urlencoded")
if err != nil {
return token, err
Expand All @@ -158,9 +159,15 @@ func (hb HTTPClient) AccessToken(ctx context.Context, tokenEndpoint string, vp v
if innerErr := core.TestResponseCode(http.StatusBadRequest, response); innerErr != nil {
// a non oauth error, the response body could contain a lot of stuff. We'll log and return the entire error
log.Logger().Debugf("authorization server token endpoint returned non oauth error (statusCode=%d)", response.StatusCode)
return token, err
}
httpErr := err.(core.HttpError)
oauthError := oauth.OAuth2Error{}
if err := json.Unmarshal(httpErr.ResponseBody, &oauthError); err != nil {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might just be that the error response is not an OAuth2 error? In that case just return the HTTP error as is?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It should be (according to spec). If it's a different JSON return, it would become an empty 400 response. If it's not JSON then an error is logged and the original httpErr is returned.

return token, fmt.Errorf("unable to unmarshal OAuth error response: %w", err)
}

return token, err
return token, oauthError
}

var responseData []byte
Expand Down
6 changes: 3 additions & 3 deletions auth/client/iam/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -219,10 +219,10 @@ func TestHTTPClient_AccessToken(t *testing.T) {
_, err := client.AccessToken(ctx, tlsServer.URL, vc.VerifiablePresentation{}, pe.PresentationSubmission{}, "test")

require.Error(t, err)
// check if the error is a http error
httpError, ok := err.(core.HttpError)
// check if the error is an OAuth error
oauthError, ok := err.(oauth.OAuth2Error)
require.True(t, ok)
assert.Equal(t, "{\"error\":\"invalid_scope\"}", string(httpError.ResponseBody))
assert.Equal(t, oauth.InvalidScope, oauthError.Code)
})
t.Run("error - generic server error", func(t *testing.T) {
ctx := context.Background()
Expand Down
20 changes: 12 additions & 8 deletions auth/oauth/error.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,19 +106,23 @@ func (p Oauth2ErrorWriter) Write(echoContext echo.Context, _ int, _ string, err
}
redirectURI, _ := url.Parse(oauthErr.RedirectURI)
if oauthErr.RedirectURI == "" || redirectURI == nil {
// Can't redirect the user-agent back, render error as JSON or plain text (depending on content-type)
// Can't redirect the user-agent back, render error as JSON or plain text (depending on accept/content-type)
accept := echoContext.Request().Header.Get("Accept")
if strings.Contains(accept, "application/json") {
// Return JSON response
return echoContext.JSON(oauthErr.StatusCode(), oauthErr)
}
contentType := echoContext.Request().Header.Get("Content-Type")
if strings.Contains(contentType, "application/json") {
// Return JSON response
return echoContext.JSON(oauthErr.StatusCode(), oauthErr)
} else {
// Return plain text response
parts := []string{string(oauthErr.Code)}
if oauthErr.Description != "" {
parts = append(parts, oauthErr.Description)
}
return echoContext.String(oauthErr.StatusCode(), strings.Join(parts, " - "))
}
// Return plain text response
parts := []string{string(oauthErr.Code)}
if oauthErr.Description != "" {
parts = append(parts, oauthErr.Description)
}
return echoContext.String(oauthErr.StatusCode(), strings.Join(parts, " - "))
}
// Redirect the user-agent back to the client
query := redirectURI.Query()
Expand Down
4 changes: 2 additions & 2 deletions auth/services/oauth/relying_party.go
Original file line number Diff line number Diff line change
Expand Up @@ -197,11 +197,11 @@ func determineFormat(formats map[string]map[string][]string) (string, error) {
for format := range formats {
switch format {
case openid4vc.VerifiablePresentationJWTFormat:
fallthrough
return format, nil
case openid4vc.VerifiablePresentationJSONLDFormat:
fallthrough
case "jwt_vp_json":
return format, nil
return openid4vc.VerifiablePresentationJSONLDFormat, nil
woutslakhorst marked this conversation as resolved.
Show resolved Hide resolved
default:
continue
}
Expand Down
6 changes: 2 additions & 4 deletions auth/services/oauth/relying_party_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ import (
"fmt"
"github.com/nuts-foundation/nuts-node/audit"
"github.com/nuts-foundation/nuts-node/auth/oauth"
"github.com/nuts-foundation/nuts-node/core"
http2 "github.com/nuts-foundation/nuts-node/test/http"
"net/http"
"net/http/httptest"
Expand Down Expand Up @@ -154,10 +153,9 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) {
_, err := ctx.relyingParty.RequestRFC021AccessToken(context.Background(), walletDID, ctx.verifierDID, scopes)

require.Error(t, err)
httpError, ok := err.(core.HttpError)
oauthError, ok := err.(oauth.OAuth2Error)
require.True(t, ok)
assert.Equal(t, http.StatusBadRequest, httpError.StatusCode)
assert.Equal(t, oauthErrorBytes, httpError.ResponseBody)
assert.Equal(t, oauth.InvalidScope, oauthError.Code)
})
t.Run("error - no matching credentials", func(t *testing.T) {
ctx := createOAuthRPContext(t)
Expand Down
40 changes: 40 additions & 0 deletions e2e-tests/oauth-flow/openid4vp/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
version: "3.7"
services:
nodeA-backend:
image: "${IMAGE_NODE_A:-nutsfoundation/nuts-node:master}"
ports:
- "11323:1323"
environment:
NUTS_CONFIGFILE: /opt/nuts/nuts.yaml
volumes:
- "./node-A/nuts.yaml:/opt/nuts/nuts.yaml:ro"
- "./node-A/data:/opt/nuts/data:rw"
- "../../tls-certs/nodeA-backend-certificate.pem:/opt/nuts/certificate-and-key.pem:ro"
- "../../tls-certs/truststore.pem:/opt/nuts/truststore.pem:ro"
- "./node-A/presentationexchangemapping.json:/opt/nuts/presentationexchangemapping.json:ro"
healthcheck:
interval: 1s # Make test run quicker by checking health status more often
nodeA:
image: nginx:1.25.1
ports:
- "10443:443"
volumes:
- "./node-A/nginx.conf:/etc/nginx/nginx.conf:ro"
- "../../tls-certs/nodeA-certificate.pem:/etc/nginx/ssl/server.pem:ro"
- "../../tls-certs/nodeA-certificate.pem:/etc/nginx/ssl/key.pem:ro"
- "../../tls-certs/truststore.pem:/etc/nginx/ssl/truststore.pem:ro"
- "./node-A/html:/etc/nginx/html:ro"
nodeB:
image: "${IMAGE_NODE_B:-nutsfoundation/nuts-node:master}"
ports:
- "21323:1323"
environment:
NUTS_CONFIGFILE: /opt/nuts/nuts.yaml
volumes:
- "./node-B/data:/opt/nuts/data:rw"
- "./node-B/nuts.yaml:/opt/nuts/nuts.yaml:ro"
- "../../tls-certs/nodeB-certificate.pem:/opt/nuts/certificate-and-key.pem:ro"
- "../../tls-certs/truststore.pem:/opt/nuts/truststore.pem:ro"
- "../../tls-certs/truststore.pem:/etc/ssl/certs/truststore.pem:ro"
healthcheck:
interval: 1s # Make test run quicker by checking health status more often
62 changes: 62 additions & 0 deletions e2e-tests/oauth-flow/openid4vp/node-A/nginx.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
user nginx;
worker_processes 1;

error_log /var/log/nginx/error.log debug;
pid /var/run/nginx.pid;


events {
worker_connections 1024;
}


http {
include /etc/nginx/mime.types;
default_type application/octet-stream;

log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';

access_log /var/log/nginx/access.log main;

keepalive_timeout 65;

include /etc/nginx/conf.d/*.conf;

upstream nodeA-backend {
server nodeA-backend:1323;
}

server {
server_name nodeA;
listen 443 ssl;
http2 on;
ssl_certificate /etc/nginx/ssl/server.pem;
ssl_certificate_key /etc/nginx/ssl/key.pem;
ssl_client_certificate /etc/nginx/ssl/truststore.pem;
ssl_verify_client optional;
ssl_verify_depth 1;
ssl_protocols TLSv1.3;

location / {
proxy_set_header X-Ssl-Client-Cert $ssl_client_escaped_cert;
proxy_pass http://nodeA-backend;
}

location /ping {
auth_request /delegated;
auth_request_set $auth_status $upstream_status;
}

location = /delegated {
woutslakhorst marked this conversation as resolved.
Show resolved Hide resolved
internal;
proxy_pass http://nodeA-backend/internal/auth/v1/accesstoken/verify;
proxy_method HEAD;
proxy_pass_request_body off;
proxy_set_header X-Ssl-Client-Cert $ssl_client_escaped_cert;
proxy_set_header Content-Length "";
proxy_set_header X-Original-URI $request_uri;
}
}
}
19 changes: 19 additions & 0 deletions e2e-tests/oauth-flow/openid4vp/node-A/nuts.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
verbosity: debug
strictmode: false
internalratelimiter: false
datadir: /opt/nuts/data
http:
default:
address: :1323
auth:
publicurl: https://nodeA
v2apienabled: true
presentationexchangemappingfile: /opt/nuts/presentationexchangemapping.json
contractvalidators:
- dummy
irma:
autoupdateschemas: false
tls:
truststorefile: /opt/nuts/truststore.pem
certfile: /opt/nuts/certificate-and-key.pem
certkeyfile: /opt/nuts/certificate-and-key.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
{
"test": {
"format": {
"ldp_vp": {
"proof_type": ["JsonWebSignature2020"]
},
"ldp_vc": {
"proof_type": ["JsonWebSignature2020"]
}
},
"id": "pd_any_care_organization",
"name": "Care organization",
"purpose": "Finding a care organization for authorizing access to medical metadata",
"input_descriptors": [
{
"id": "id_nuts_care_organization_cred",
"constraints": {
"fields": [
{
"path": ["$.type"],
"filter": {
"type": "string",
"const": "NutsOrganizationCredential"
}
},
{
"path": ["$.credentialSubject.organization.name"],
"filter": {
"type": "string"
}
},
{
"path": ["$.credentialSubject.organization.city"],
"filter": {
"type": "string"
}
}
]
}
}
]
}
}
20 changes: 20 additions & 0 deletions e2e-tests/oauth-flow/openid4vp/node-B/nuts.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
verbosity: debug
strictmode: false
internalratelimiter: false
datadir: /opt/nuts/data
http:
default:
address: :1323
auth:
tlsenabled: true
v2apienabled: true
publicurl: https://nodeB
contractvalidators:
- dummy
irma:
autoupdateschemas: false
tls:
truststorefile: /opt/nuts/truststore.pem
certfile: /opt/nuts/certificate-and-key.pem
certkeyfile: /opt/nuts/certificate-and-key.pem

Loading
Loading