Skip to content

Commit

Permalink
finish up
Browse files Browse the repository at this point in the history
  • Loading branch information
woutslakhorst committed Dec 8, 2023
1 parent 0574925 commit 6519fdf
Show file tree
Hide file tree
Showing 28 changed files with 432 additions and 114 deletions.
6 changes: 2 additions & 4 deletions auth/api/auth/v1/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -310,15 +310,13 @@ func (w Wrapper) RequestAccessToken(ctx context.Context, request RequestAccessTo
func (w Wrapper) CreateAccessToken(ctx context.Context, request CreateAccessTokenRequestObject) (CreateAccessTokenResponseObject, error) {

if request.Body.GrantType != client.JwtBearerGrantType {
errDesc := fmt.Sprintf("grant_type must be: '%s'", client.JwtBearerGrantType)
errorResponse := oauth.ErrorResponse{Error: errOauthUnsupportedGrant, Description: &errDesc}
errorResponse := oauth.OAuth2Error{Code: errOauthUnsupportedGrant, Description: fmt.Sprintf("grant_type must be: '%s'", client.JwtBearerGrantType)}
return CreateAccessToken400JSONResponse(errorResponse), nil
}

const jwtPattern = `^[A-Za-z0-9-_=]+\.[A-Za-z0-9-_=]+\.?[A-Za-z0-9-_.+/=]*$`
if matched, err := regexp.Match(jwtPattern, []byte(request.Body.Assertion)); !matched || err != nil {
errDesc := "Assertion must be a valid encoded jwt"
errorResponse := AccessTokenRequestFailedResponse{Error: errOauthInvalidGrant, Description: &errDesc}
errorResponse := AccessTokenRequestFailedResponse{Code: errOauthInvalidGrant, Description: "Assertion must be a valid encoded jwt"}
return CreateAccessToken400JSONResponse(errorResponse), nil
}

Expand Down
21 changes: 15 additions & 6 deletions auth/api/auth/v1/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ type TestContext struct {
contractClientMock *services.MockContractNotary
authzServerMock *oauth.MockAuthorizationServer
relyingPartyMock *oauth.MockRelyingParty
verifierMock *oauth.MockVerifier
wrapper Wrapper
cedentialResolverMock *vcr.MockResolver
audit context.Context
Expand All @@ -65,6 +66,7 @@ type mockAuthClient struct {
contractNotary *services.MockContractNotary
authzServer *oauth.MockAuthorizationServer
relyingParty *oauth.MockRelyingParty
verifier *oauth.MockVerifier
}

func (m *mockAuthClient) V2APIEnabled() bool {
Expand All @@ -79,6 +81,10 @@ func (m *mockAuthClient) RelyingParty() oauth.RelyingParty {
return m.relyingParty
}

func (m *mockAuthClient) Verifier() oauth.Verifier {
return m.verifier
}

func (m *mockAuthClient) ContractNotary() services.ContractNotary {
return m.contractNotary
}
Expand All @@ -97,13 +103,15 @@ func createContext(t *testing.T) *TestContext {
contractNotary := services.NewMockContractNotary(ctrl)
authzServer := oauth.NewMockAuthorizationServer(ctrl)
relyingParty := oauth.NewMockRelyingParty(ctrl)
verifier := oauth.NewMockVerifier(ctrl)
mockCredentialResolver := vcr.NewMockResolver(ctrl)

authMock := &mockAuthClient{
ctrl: ctrl,
contractNotary: contractNotary,
authzServer: authzServer,
relyingParty: relyingParty,
verifier: verifier,
}

requestCtx := audit.TestContext()
Expand All @@ -115,6 +123,7 @@ func createContext(t *testing.T) *TestContext {
contractClientMock: contractNotary,
authzServerMock: authzServer,
relyingPartyMock: relyingParty,
verifierMock: verifier,
cedentialResolverMock: mockCredentialResolver,
wrapper: Wrapper{Auth: authMock, CredentialResolver: mockCredentialResolver},
audit: requestCtx,
Expand Down Expand Up @@ -554,7 +563,7 @@ func TestWrapper_CreateAccessToken(t *testing.T) {
params := CreateAccessTokenRequest{GrantType: "unknown type"}

errorDescription := "grant_type must be: 'urn:ietf:params:oauth:grant-type:jwt-bearer'"
expectedResponse := CreateAccessToken400JSONResponse{Description: &errorDescription, Error: errOauthUnsupportedGrant}
expectedResponse := CreateAccessToken400JSONResponse{Description: errorDescription, Code: errOauthUnsupportedGrant}

response, err := ctx.wrapper.CreateAccessToken(ctx.audit, CreateAccessTokenRequestObject{Body: &params})

Expand All @@ -568,7 +577,7 @@ func TestWrapper_CreateAccessToken(t *testing.T) {
params := CreateAccessTokenRequest{GrantType: "urn:ietf:params:oauth:grant-type:jwt-bearer", Assertion: "invalid jwt"}

errorDescription := "Assertion must be a valid encoded jwt"
expectedResponse := CreateAccessToken400JSONResponse{Description: &errorDescription, Error: errOauthInvalidGrant}
expectedResponse := CreateAccessToken400JSONResponse{Description: errorDescription, Code: errOauthInvalidGrant}

response, err := ctx.wrapper.CreateAccessToken(ctx.audit, CreateAccessTokenRequestObject{Body: &params})

Expand All @@ -582,11 +591,11 @@ func TestWrapper_CreateAccessToken(t *testing.T) {
params := CreateAccessTokenRequest{GrantType: "urn:ietf:params:oauth:grant-type:jwt-bearer", Assertion: validJwt}

errorDescription := "oh boy"
expectedResponse := CreateAccessToken400JSONResponse{Description: &errorDescription, Error: errOauthInvalidRequest}
expectedResponse := CreateAccessToken400JSONResponse{Description: errorDescription, Code: errOauthInvalidRequest}

ctx.authzServerMock.EXPECT().CreateAccessToken(ctx.audit, services.CreateAccessTokenRequest{RawJwtBearerToken: validJwt}).Return(nil, &oauth2.ErrorResponse{
Description: &errorDescription,
Error: errOauthInvalidRequest,
ctx.authzServerMock.EXPECT().CreateAccessToken(ctx.audit, services.CreateAccessTokenRequest{RawJwtBearerToken: validJwt}).Return(nil, &oauth2.OAuth2Error{
Description: errorDescription,
Code: errOauthInvalidRequest,
})

response, err := ctx.wrapper.CreateAccessToken(ctx.audit, CreateAccessTokenRequestObject{Body: &params})
Expand Down
2 changes: 1 addition & 1 deletion auth/api/auth/v1/client/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,4 @@ type VerifiablePresentation = vc.VerifiablePresentation
type AccessTokenResponse = oauth.TokenResponse

// AccessTokenRequestFailedResponse is an alias to use from within the API
type AccessTokenRequestFailedResponse = oauth.ErrorResponse
type AccessTokenRequestFailedResponse = oauth.OAuth2Error
2 changes: 1 addition & 1 deletion auth/api/auth/v1/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,4 @@ type VerifiablePresentation = vc.VerifiablePresentation
// AccessTokenResponse is an alias to use from within the API
type AccessTokenResponse = oauth.TokenResponse

type AccessTokenRequestFailedResponse = oauth.ErrorResponse
type AccessTokenRequestFailedResponse = oauth.OAuth2Error
3 changes: 2 additions & 1 deletion auth/api/iam/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import (
"github.com/nuts-foundation/nuts-node/auth/oauth"
"github.com/nuts-foundation/nuts-node/core"
"github.com/nuts-foundation/nuts-node/storage"
"github.com/nuts-foundation/nuts-node/test"
"github.com/nuts-foundation/nuts-node/vcr"
"github.com/nuts-foundation/nuts-node/vdr"
"github.com/nuts-foundation/nuts-node/vdr/didweb"
Expand Down Expand Up @@ -293,7 +294,7 @@ func (r Wrapper) HandleAuthorizeRequest(ctx context.Context, request HandleAutho
// TODO: This should be a redirect?
return nil, oauth.OAuth2Error{
Code: oauth.UnsupportedResponseType,
RedirectURI: session.RedirectURI,
RedirectURI: test.MustParseURL(session.RedirectURI),
}
}
}
Expand Down
10 changes: 9 additions & 1 deletion auth/api/iam/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"context"
"encoding/json"
"errors"
"github.com/nuts-foundation/nuts-node/test"
"net/http"
"net/http/httptest"
"net/url"
Expand Down Expand Up @@ -213,7 +214,8 @@ func TestWrapper_HandleAuthorizeRequest(t *testing.T) {
t.Run("ok - from holder", func(t *testing.T) {
ctx := newTestClient(t)
ctx.vdr.EXPECT().IsOwner(gomock.Any(), verifierDID).Return(true, nil)
ctx.relyingParty.EXPECT().AuthorizationServerMetadata(gomock.Any(), holderDID).Return(&metadata, nil)
ctx.verifier.EXPECT().AuthorizationServerMetadata(gomock.Any(), holderDID).Return(&metadata, nil)
ctx.verifier.EXPECT().ClientMetadataURL(verifierDID).Return(test.MustParseURL("https://example.com/.well-known/authorization-server/iam/verifier"), nil)

res, err := ctx.client.HandleAuthorizeRequest(requestContext(map[string]string{
clientIDParam: holderDID.String(),
Expand All @@ -229,6 +231,8 @@ func TestWrapper_HandleAuthorizeRequest(t *testing.T) {
location := res.(HandleAuthorizeRequest302Response).Headers.Location
assert.Contains(t, location, "https://example.com/holder/authorize")
assert.Contains(t, location, "client_id=did%3Aweb%3Aexample.com%3Aiam%3Averifier")
assert.Contains(t, location, "client_id_scheme=did")
assert.Contains(t, location, "client_metadata_uri=https%3A%2F%2Fexample.com%2F.well-known%2Fauthorization-server%2Fiam%2Fverifier")
assert.Contains(t, location, "nonce=")
assert.Contains(t, location, "presentation_definition_uri=https%3A%2F%2Fexample.com%2Fiam%2Fverifier%2Fpresentation_definition%3Fscope%3Dtest")
assert.Contains(t, location, "response_uri=https%3A%2F%2Fexample.com%2Fiam%2Fverifier%2Fresponse")
Expand Down Expand Up @@ -401,6 +405,7 @@ type testCtx struct {
vdr *vdr.MockVDR
resolver *resolver.MockDIDResolver
relyingParty *oauthServices.MockRelyingParty
verifier *oauthServices.MockVerifier
}

func newTestClient(t testing.TB) *testCtx {
Expand All @@ -412,15 +417,18 @@ func newTestClient(t testing.TB) *testCtx {
authnServices.EXPECT().PublicURL().Return(publicURL).AnyTimes()
resolver := resolver.NewMockDIDResolver(ctrl)
relyingPary := oauthServices.NewMockRelyingParty(ctrl)
verifier := oauthServices.NewMockVerifier(ctrl)
vdr := vdr.NewMockVDR(ctrl)

authnServices.EXPECT().PublicURL().Return(publicURL).AnyTimes()
authnServices.EXPECT().RelyingParty().Return(relyingPary).AnyTimes()
authnServices.EXPECT().Verifier().Return(verifier).AnyTimes()
vdr.EXPECT().Resolver().Return(resolver).AnyTimes()

return &testCtx{
authnServices: authnServices,
relyingParty: relyingPary,
verifier: verifier,
resolver: resolver,
vdr: vdr,
client: &Wrapper{
Expand Down
9 changes: 0 additions & 9 deletions auth/api/iam/metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,6 @@ import (
"strings"
)

const (
// authzServerWellKnown is the well-known base path for the oauth authorization server metadata as defined in RFC8414
authzServerWellKnown = "/.well-known/oauth-authorization-server"
// openidCredIssuerWellKnown is the well-known base path for the openID credential issuer metadata as defined in OpenID4VCI specification
openidCredIssuerWellKnown = "/.well-known/openid-credential-issuer"
// openidCredWalletWellKnown is the well-known path element we created for openid4vci to retrieve the oauth client metadata
openidCredWalletWellKnown = "/.well-known/openid-credential-wallet"
)

// 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) {
Expand Down
2 changes: 1 addition & 1 deletion auth/api/iam/metadata_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ func TestIssuerIdToWellKnown(t *testing.T) {
t.Run("no IP allowed", func(t *testing.T) {
issuer := "https://127.0.0.1/iam/id"

u, err := IssuerIdToWellKnown(issuer, authzServerWellKnown, true)
u, err := IssuerIdToWellKnown(issuer, oauth.AuthzServerWellKnown, true)

assert.ErrorContains(t, err, "hostname is IP")
assert.Nil(t, u)
Expand Down
63 changes: 47 additions & 16 deletions auth/api/iam/openid4vp.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,28 +55,47 @@ func (r Wrapper) handleAuthorizeRequestFromHolder(ctx context.Context, verifier
// The following parameters are expected
// response_type, REQUIRED. Value MUST be set to "token".
// client_id, REQUIRED. This must be a did:web
// redirect_uri, OPTIONAL. This must be the client or other node url (client for regular flow, node for popup)
// redirect_uri, REQUIRED. This must be the client or other node url (client for regular flow, node for popup)
// scope, OPTIONAL. The scope that maps to a presentation definition, if not set we just want an empty VP
// state, RECOMMENDED. Opaque value used to maintain state between the request and the callback.

// first we check the redirect URL because later errors will redirect to this URL
// from RFC6749:
// If the request fails due to a missing, invalid, or mismatching
// redirection URI, or if the client identifier is missing or invalid,
// the authorization server SHOULD inform the resource owner of the
// error and MUST NOT automatically redirect the user-agent to the
// invalid redirection URI.
redirectURI, ok := params[redirectURIParam]
if !ok {
// todo render error page instead of technical error
return nil, oauth.OAuth2Error{Code: oauth.InvalidRequest, Description: "missing redirect_uri parameter"}
}
redirectURL, err := url.Parse(redirectURI)
if err != nil {
// todo render error page instead of technical error
return nil, oauth.OAuth2Error{Code: oauth.InvalidRequest, Description: "invalid redirect_uri parameter"}
}
// now we have a valid redirectURL, so all future errors will redirect to this URL using the Oauth2ErrorWriter

// GET authorization server metadata for wallet
walletID, ok := params[clientIDParam]
if !ok {
return nil, oauthError(oauth.InvalidRequest, "missing client_id parameter")
return nil, oauthError(oauth.InvalidRequest, "missing client_id parameter", redirectURL)
}
// the walletDID must be a did:web
walletDID, err := did.ParseDID(walletID)
if err != nil || walletDID.Method != "web" {
return nil, oauthError(oauth.InvalidRequest, "invalid client_id parameter")
return nil, oauthError(oauth.InvalidRequest, "invalid client_id parameter", redirectURL)
}
metadata, err := r.auth.RelyingParty().AuthorizationServerMetadata(ctx, *walletDID)
metadata, err := r.auth.Verifier().AuthorizationServerMetadata(ctx, *walletDID)
if err != nil {
return nil, oauthError(oauth.ServerError, "failed to get authorization server metadata (holder)")
return nil, oauthError(oauth.ServerError, "failed to get authorization server metadata (holder)", redirectURL)
}
// own generic endpoint
ownURL, err := didweb.DIDToURL(verifier)
if err != nil {
return nil, oauthError(oauth.ServerError, "failed to translate own did to URL")
return nil, oauthError(oauth.ServerError, "failed to translate own did to URL", redirectURL)
}
// generate presentation_definition_uri based on own presentation_definition endpoint + scope
pdURL := ownURL.JoinPath("presentation_definition")
Expand All @@ -89,26 +108,37 @@ func (r Wrapper) handleAuthorizeRequestFromHolder(ctx context.Context, verifier
// GET /authorize?
// response_type=vp_token
// &client_id=did:web:example.com:iam:123
// &redirect_uri=https%3A%2F%2Fexample.com%2Fiam%2F123%2F%2Fresponse
// &client_id_scheme=did
// &client_metadata_uri=https%3A%2F%2Fexample.com%2F.well-known%2Fauthorization-server%2Fiam%2F123%2F%2F
// &response_uri=https%3A%2F%2Fexample.com%2Fiam%2F123%2F%2Fresponse
// &presentation_definition_uri=...
// &response_mode=direct_post
// &nonce=n-0S6_WzA2Mj HTTP/1.1
walletURL, err := url.Parse(metadata.AuthorizationEndpoint)
if err != nil || len(metadata.AuthorizationEndpoint) == 0 {
return nil, oauthError(oauth.InvalidRequest, "invalid authorization_endpoint (holder)")
return nil, oauthError(oauth.InvalidRequest, "invalid authorization_endpoint (holder)", redirectURL)
}
nonce := crypto.GenerateNonce()
callbackURL := ownURL
callbackURL := *ownURL
callbackURL.Path, err = url.JoinPath(callbackURL.Path, "response")
if err != nil {
return nil, oauthError(oauth.ServerError, "failed to construct redirect path")
return nil, oauthError(oauth.ServerError, "failed to construct redirect path", redirectURL)
}

redirectURL := AddQueryParams(*walletURL, map[string]string{
metadataURL, err := r.auth.Verifier().ClientMetadataURL(verifier)
if err != nil {
return nil, oauthError(oauth.ServerError, "failed to construct metadata URL", redirectURL)
}

// todo: because of the did scheme, the request needs to be signed using JAR according to §5.7 of the openid4vp spec

authServerURL := AddQueryParams(*walletURL, map[string]string{
responseTypeParam: responseTypeVPToken,
clientIDParam: verifier.String(),
clientIDSchemeParam: didScheme,
responseURIParam: callbackURL.String(),
presentationDefUriParam: presentationDefinitionURI.String(),
clientMetadataURIParam: metadataURL.String(),
responseModeParam: responseModeDirectPost,
nonceParam: nonce,
})
Expand All @@ -121,12 +151,12 @@ func (r Wrapper) handleAuthorizeRequestFromHolder(ctx context.Context, verifier
}
// use nonce to store authorization request in session store
if err = r.oauthNonceStore().Put(nonce, openid4vpRequest); err != nil {
return nil, oauthError(oauth.ServerError, "failed to store server state")
return nil, oauthError(oauth.ServerError, "failed to store server state", redirectURL)
}

return HandleAuthorizeRequest302Response{
Headers: HandleAuthorizeRequest302ResponseHeaders{
Location: redirectURL.String(),
Location: authServerURL.String(),
},
}, nil
}
Expand Down Expand Up @@ -180,7 +210,7 @@ func (r Wrapper) handlePresentationRequest(params map[string]string, session *Se
return nil, oauth.OAuth2Error{
Code: oauth.InvalidRequest,
Description: "response_mode must be direct_post",
RedirectURI: session.RedirectURI,
RedirectURI: session.redirectURI(),
}
}

Expand All @@ -191,7 +221,7 @@ func (r Wrapper) handlePresentationRequest(params map[string]string, session *Se
return nil, oauth.OAuth2Error{
Code: oauth.InvalidRequest,
Description: fmt.Sprintf("unsupported scope for presentation exchange: %s", params[scopeParam]),
RedirectURI: session.RedirectURI,
RedirectURI: session.redirectURI(),
}
}

Expand Down Expand Up @@ -373,9 +403,10 @@ func assertParamNotPresent(params map[string]string, param ...string) error {
return nil
}

func oauthError(code oauth.ErrorCode, description string) oauth.OAuth2Error {
func oauthError(code oauth.ErrorCode, description string, redirectURL *url.URL) oauth.OAuth2Error {
return oauth.OAuth2Error{
Code: code,
Description: description,
RedirectURI: redirectURL,
}
}
6 changes: 3 additions & 3 deletions auth/api/iam/openid4vp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ func TestWrapper_handleAuthorizeRequestFromHolder(t *testing.T) {
})
t.Run("error on authorization server metadata", func(t *testing.T) {
ctx := newTestClient(t)
ctx.relyingParty.EXPECT().AuthorizationServerMetadata(gomock.Any(), holderDID).Return(nil, assert.AnError)
ctx.verifier.EXPECT().AuthorizationServerMetadata(gomock.Any(), holderDID).Return(nil, assert.AnError)

_, err := ctx.client.handleAuthorizeRequestFromHolder(context.Background(), verifierDID, defaultParams())

Expand All @@ -82,15 +82,15 @@ func TestWrapper_handleAuthorizeRequestFromHolder(t *testing.T) {
t.Run("failed to generate verifier web url", func(t *testing.T) {
ctx := newTestClient(t)
verifierDID := did.MustParseDID("did:notweb:example.com:verifier")
ctx.relyingParty.EXPECT().AuthorizationServerMetadata(gomock.Any(), holderDID).Return(&oauth.AuthorizationServerMetadata{}, nil)
ctx.verifier.EXPECT().AuthorizationServerMetadata(gomock.Any(), holderDID).Return(&oauth.AuthorizationServerMetadata{}, nil)

_, err := ctx.client.handleAuthorizeRequestFromHolder(context.Background(), verifierDID, defaultParams())

requireOAuthError(t, err, oauth.ServerError, "failed to translate own did to URL")
})
t.Run("incorrect holder AuthorizationEndpoint URL", func(t *testing.T) {
ctx := newTestClient(t)
ctx.relyingParty.EXPECT().AuthorizationServerMetadata(gomock.Any(), holderDID).Return(&oauth.AuthorizationServerMetadata{
ctx.verifier.EXPECT().AuthorizationServerMetadata(gomock.Any(), holderDID).Return(&oauth.AuthorizationServerMetadata{
AuthorizationEndpoint: "://example.com",
}, nil)

Expand Down
5 changes: 5 additions & 0 deletions auth/api/iam/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,8 @@ func (s Session) CreateRedirectURI(params map[string]string) string {
r := AddQueryParams(*redirectURI, params)
return r.String()
}

func (s Session) redirectURI() *url.URL {
redirectURL, _ := url.Parse(s.RedirectURI)
return redirectURL
}
2 changes: 2 additions & 0 deletions auth/api/iam/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ const (
var responseTypesSupported = []string{responseTypeCode, responseTypeVPToken, responseTypeVPIDToken}

const (
// didScheme is the client_id_scheme value for DIDs
didScheme = "did"
// responseModeParam is the name of the OAuth2 response_mode parameter.
// Specified by https://openid.net/specs/oauth-v2-multiple-response-types-1_0.html
responseModeParam = "response_mode"
Expand Down
Loading

0 comments on commit 6519fdf

Please sign in to comment.