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 authorize endpoint as specified by rfc6549 authorization code #2626

Merged
merged 11 commits into from
Dec 12, 2023
6 changes: 2 additions & 4 deletions auth/api/auth/v1/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -311,15 +311,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
45 changes: 26 additions & 19 deletions auth/api/iam/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ func New(authInstance auth.AuthenticationServices, vcrInstance vcr.VCR, vdrInsta
}
}

func (r *Wrapper) Routes(router core.EchoRouter) {
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) {
Expand Down Expand Up @@ -166,7 +166,7 @@ func (r Wrapper) HandleTokenRequest(ctx context.Context, request HandleTokenRequ
}

// IntrospectAccessToken allows the resource server (XIS/EHR) to introspect details of an access token issued by this node
func (r Wrapper) IntrospectAccessToken(ctx context.Context, request IntrospectAccessTokenRequestObject) (IntrospectAccessTokenResponseObject, error) {
func (r Wrapper) IntrospectAccessToken(_ context.Context, request IntrospectAccessTokenRequestObject) (IntrospectAccessTokenResponseObject, error) {
// Validate token
if request.Body.Token == "" {
// Return 200 + 'Active = false' when token is invalid or malformed
Expand Down Expand Up @@ -249,7 +249,6 @@ func (r Wrapper) HandleAuthorizeRequest(ctx context.Context, request HandleAutho
if err != nil {
return nil, err
}
// Create session object to be passed to handler

// Workaround: deepmap codegen doesn't support dynamic query parameters.
// See https://github.com/deepmap/oapi-codegen/issues/1129
Expand All @@ -268,15 +267,31 @@ func (r Wrapper) HandleAuthorizeRequest(ctx context.Context, request HandleAutho
Description: "redirect_uri is required",
}
}
// todo: store session in database?

switch session.ResponseType {
case responseTypeCode:
// 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
// - OpenID4VP, vp_token is sent in Token Response; authorization code flow for presentation exchange (not required a.t.m.)
// TODO: Switch on parameters to right flow
panic("not implemented")

// 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 := session.ClientID
reinkrul marked this conversation as resolved.
Show resolved Hide resolved
if strings.HasPrefix(clientId, "did:web:") {
// client is a cloud wallet with user
return r.handleAuthorizeRequestFromHolder(ctx, *ownDID, params)
} else {
return nil, oauth.OAuth2Error{
Code: oauth.InvalidRequest,
Description: "client_id must be a did:web",
}
}
case responseTypeVPToken:
// Options:
// - OpenID4VP flow, vp_token is sent in Authorization Response
Expand All @@ -289,28 +304,20 @@ func (r Wrapper) HandleAuthorizeRequest(ctx context.Context, request HandleAutho
return r.handlePresentationRequest(params, session)
default:
// TODO: This should be a redirect?
redirectURI, _ := url.Parse(session.RedirectURI)
return nil, oauth.OAuth2Error{
Code: oauth.UnsupportedResponseType,
RedirectURI: session.RedirectURI,
RedirectURI: redirectURI,
}
}
}

// OAuthAuthorizationServerMetadata returns the Authorization Server's metadata
func (r Wrapper) OAuthAuthorizationServerMetadata(ctx context.Context, request OAuthAuthorizationServerMetadataRequestObject) (OAuthAuthorizationServerMetadataResponseObject, error) {
ownDID := r.idToDID(request.Id)
owned, err := r.vdr.IsOwner(ctx, ownDID)
_, err := r.idToOwnedDID(ctx, request.Id)
if err != nil {
if resolver.IsFunctionalResolveError(err) {
return nil, core.NotFoundError("authz server metadata: %w", err)
}
log.Logger().WithField("did", ownDID.String()).Errorf("authz server metadata: failed to assert ownership of did: %s", err.Error())
return nil, core.Error(500, "authz server metadata: %w", err)
}
if !owned {
return nil, core.NotFoundError("authz server metadata: did not owned")
return nil, err
}

identity := r.auth.PublicURL().JoinPath("iam", request.Id)

return OAuthAuthorizationServerMetadata200JSONResponse(authorizationServerMetadata(*identity)), nil
Expand Down Expand Up @@ -432,6 +439,6 @@ func (r Wrapper) identityURL(id string) *url.URL {
return r.auth.PublicURL().JoinPath("iam", id)
}

func (r *Wrapper) accessTokenStore() storage.SessionStore {
func (r Wrapper) accessTokenStore() storage.SessionStore {
return r.storageEngine.GetSessionDatabase().GetStore(accessTokenValidity, "accesstoken")
}
75 changes: 65 additions & 10 deletions 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 @@ -50,6 +51,7 @@ import (

var webDID = did.MustParseDID("did:web:example.com:iam:123")
var webIDPart = "123"
var verifierDID = did.MustParseDID("did:web:example.com:iam:verifier")

func TestWrapper_OAuthAuthorizationServerMetadata(t *testing.T) {
t.Run("ok", func(t *testing.T) {
Expand Down Expand Up @@ -93,14 +95,12 @@ func TestWrapper_OAuthAuthorizationServerMetadata(t *testing.T) {
res, err := ctx.client.OAuthAuthorizationServerMetadata(nil, OAuthAuthorizationServerMetadataRequestObject{Id: webIDPart})

assert.Equal(t, 500, statusCodeFrom(err))
assert.EqualError(t, err, "authz server metadata: unknown error")
assert.EqualError(t, err, "server_error - failed to assert ownership of did")
assert.Nil(t, res)
})
}

func TestWrapper_GetWebDID(t *testing.T) {
webDID := did.MustParseDID("did:web:example.com:iam:123")
id := "123"
ctx := audit.TestContext()
expectedWebDIDDoc := did.Document{
ID: webDID,
Expand All @@ -113,7 +113,7 @@ func TestWrapper_GetWebDID(t *testing.T) {
test := newTestClient(t)
test.vdr.EXPECT().ResolveManaged(webDID).Return(&expectedWebDIDDoc, nil)

response, err := test.client.GetWebDID(ctx, GetWebDIDRequestObject{id})
response, err := test.client.GetWebDID(ctx, GetWebDIDRequestObject{webIDPart})

assert.NoError(t, err)
assert.Equal(t, expectedWebDIDDoc, did.Document(response.(GetWebDID200JSONResponse)))
Expand All @@ -122,7 +122,7 @@ func TestWrapper_GetWebDID(t *testing.T) {
test := newTestClient(t)
test.vdr.EXPECT().ResolveManaged(webDID).Return(nil, resolver.ErrNotFound)

response, err := test.client.GetWebDID(ctx, GetWebDIDRequestObject{id})
response, err := test.client.GetWebDID(ctx, GetWebDIDRequestObject{webIDPart})

assert.NoError(t, err)
assert.IsType(t, GetWebDID404Response{}, response)
Expand All @@ -131,7 +131,7 @@ func TestWrapper_GetWebDID(t *testing.T) {
test := newTestClient(t)
test.vdr.EXPECT().ResolveManaged(webDID).Return(nil, errors.New("failed"))

response, err := test.client.GetWebDID(ctx, GetWebDIDRequestObject{id})
response, err := test.client.GetWebDID(ctx, GetWebDIDRequestObject{webIDPart})

assert.EqualError(t, err, "unable to resolve DID")
assert.Nil(t, response)
Expand All @@ -148,6 +148,25 @@ func TestWrapper_GetOAuthClientMetadata(t *testing.T) {
require.NoError(t, err)
assert.IsType(t, OAuthClientMetadata200JSONResponse{}, res)
})
t.Run("error - did not managed by this node", func(t *testing.T) {
ctx := newTestClient(t)
ctx.vdr.EXPECT().IsOwner(nil, webDID)

res, err := ctx.client.OAuthClientMetadata(nil, OAuthClientMetadataRequestObject{Id: webIDPart})

assert.Equal(t, 404, statusCodeFrom(err))
assert.Nil(t, res)
})
t.Run("error - internal error 500", func(t *testing.T) {
ctx := newTestClient(t)
ctx.vdr.EXPECT().IsOwner(nil, webDID).Return(false, errors.New("unknown error"))

res, err := ctx.client.OAuthClientMetadata(nil, OAuthClientMetadataRequestObject{Id: webIDPart})

assert.Equal(t, 500, statusCodeFrom(err))
assert.EqualError(t, err, "server_error - failed to assert ownership of did")
assert.Nil(t, res)
})
}
func TestWrapper_PresentationDefinition(t *testing.T) {
webDID := did.MustParseDID("did:web:example.com:iam:123")
Expand Down Expand Up @@ -189,6 +208,38 @@ func TestWrapper_PresentationDefinition(t *testing.T) {
}

func TestWrapper_HandleAuthorizeRequest(t *testing.T) {
metadata := oauth.AuthorizationServerMetadata{
AuthorizationEndpoint: "https://example.com/holder/authorize",
}
t.Run("ok - from holder", func(t *testing.T) {
ctx := newTestClient(t)
ctx.vdr.EXPECT().IsOwner(gomock.Any(), verifierDID).Return(true, nil)
ctx.verifierRole.EXPECT().AuthorizationServerMetadata(gomock.Any(), holderDID).Return(&metadata, nil)
ctx.verifierRole.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(),
redirectURIParam: "https://example.com",
responseTypeParam: "code",
scopeParam: "test",
}), HandleAuthorizeRequestRequestObject{
Id: "verifier",
})

require.NoError(t, err)
assert.IsType(t, HandleAuthorizeRequest302Response{}, res)
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")
assert.Contains(t, location, "response_mode=direct_post")
assert.Contains(t, location, "response_type=vp_token")

})
t.Run("missing redirect_uri", func(t *testing.T) {
ctx := newTestClient(t)
ctx.vdr.EXPECT().IsOwner(gomock.Any(), webDID).Return(true, nil)
Expand Down Expand Up @@ -356,8 +407,9 @@ type testCtx struct {
vdr *vdr.MockVDR
resolver *resolver.MockDIDResolver
relyingParty *oauthServices.MockRelyingParty
verifier *verifier.MockVerifier
vcVerifier *verifier.MockVerifier
vcr *vcr.MockVCR
verifierRole *oauthServices.MockVerifier
}

func newTestClient(t testing.TB) *testCtx {
Expand All @@ -373,22 +425,25 @@ func newTestClient(t testing.TB) *testCtx {
authnServices.EXPECT().PresentationDefinitions().Return(pe.TestDefinitionResolver(t)).AnyTimes()
resolver := resolver.NewMockDIDResolver(ctrl)
relyingPary := oauthServices.NewMockRelyingParty(ctrl)
verifier := verifier.NewMockVerifier(ctrl)
vcVerifier := verifier.NewMockVerifier(ctrl)
verifierRole := oauthServices.NewMockVerifier(ctrl)
vdr := vdr.NewMockVDR(ctrl)
vcr := vcr.NewMockVCR(ctrl)

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

return &testCtx{
ctrl: ctrl,
authnServices: authnServices,
relyingParty: relyingPary,
vcVerifier: vcVerifier,
resolver: resolver,
vdr: vdr,
verifier: mockVerifier,
verifierRole: verifierRole,
vcr: mockVCR,
client: &Wrapper{
auth: authnServices,
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
Loading
Loading