Skip to content

Commit

Permalink
Add introspection endpoint for s2s flow (#2567)
Browse files Browse the repository at this point in the history
* add introspection endpoint

* remove comment

* pr feedback

* pr feedback

* rebase fix
  • Loading branch information
gerardsn authored Nov 16, 2023
1 parent dfe92dd commit 34b3fa9
Show file tree
Hide file tree
Showing 8 changed files with 531 additions and 15 deletions.
80 changes: 80 additions & 0 deletions auth/api/iam/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ package iam
import (
"context"
"embed"
"encoding/json"
"errors"
"github.com/labstack/echo/v4"
"github.com/nuts-foundation/go-did/did"
Expand All @@ -36,6 +37,7 @@ import (
"html/template"
"net/http"
"strings"
"time"
)

var _ core.Routable = &Wrapper{}
Expand Down Expand Up @@ -144,6 +146,84 @@ 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) {
// Validate token
if request.Body.Token == "" {
// Return 200 + 'Active = false' when token is invalid or malformed
return IntrospectAccessToken200JSONResponse{}, nil
}

token := AccessToken{}
if err := r.s2sAccessTokenStore().Get(request.Body.Token, &token); err != nil {
// Return 200 + 'Active = false' when token is invalid or malformed
return IntrospectAccessToken200JSONResponse{}, err
}

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

// Create and return introspection response
iat := int(token.IssuedAt.Unix())
exp := int(token.Expiration.Unix())
response := IntrospectAccessToken200JSONResponse{
Active: true,
Iat: &iat,
Exp: &exp,
Iss: &token.Issuer,
Sub: &token.Issuer,
ClientId: &token.ClientId,
Scope: &token.Scope,
InputDescriptorConstraintIdMap: &token.InputDescriptorConstraintIdMap,
PresentationDefinition: nil,
PresentationSubmission: nil,
Vps: &token.VPToken,

// TODO: user authentication, used in OpenID4VP flow
FamilyName: nil,
Prefix: nil,
Initials: nil,
AssuranceLevel: nil,
Email: nil,
UserRole: nil,
Username: nil,
}

// set presentation definition if in token
var err error
response.PresentationDefinition, err = toAnyMap(token.PresentationDefinition)
if err != nil {
return IntrospectAccessToken200JSONResponse{}, err
}

// set presentation submission if in token
response.PresentationSubmission, err = toAnyMap(token.PresentationSubmission)
if err != nil {
return IntrospectAccessToken200JSONResponse{}, err
}
return response, nil
}

// toAnyMap marshals and unmarshals input into *map[string]any. Useful to generate OAPI response objects.
func toAnyMap(input any) (*map[string]any, error) {
if input == nil {
return nil, nil
}
bs, err := json.Marshal(input)
if err != nil {
return nil, err
}
result := make(map[string]any)
err = json.Unmarshal(bs, &result)
if err != nil {
return nil, err
}
return &result, nil
}

// HandleAuthorizeRequest handles calls to the authorization endpoint for starting an authorization code flow.
func (r Wrapper) HandleAuthorizeRequest(ctx context.Context, request HandleAuthorizeRequestRequestObject) (HandleAuthorizeRequestResponseObject, error) {
ownDID := idToDID(request.Id)
Expand Down
91 changes: 91 additions & 0 deletions auth/api/iam/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ package iam

import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
Expand All @@ -29,18 +30,21 @@ import (
"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/audit"
"github.com/nuts-foundation/nuts-node/auth"
"github.com/nuts-foundation/nuts-node/auth/oauth"
oauthServices "github.com/nuts-foundation/nuts-node/auth/services/oauth"
"github.com/nuts-foundation/nuts-node/core"
"github.com/nuts-foundation/nuts-node/jsonld"
"github.com/nuts-foundation/nuts-node/storage"
"github.com/nuts-foundation/nuts-node/vcr/pe"
"github.com/nuts-foundation/nuts-node/vdr"
"github.com/nuts-foundation/nuts-node/vdr/resolver"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
"time"
)

var nutsDID = did.MustParseDID("did:nuts:123")
Expand Down Expand Up @@ -246,6 +250,93 @@ func TestWrapper_HandleTokenRequest(t *testing.T) {
})
}

func TestWrapper_IntrospectAccessToken(t *testing.T) {
// mvp to store access token
ctx := newTestClient(t)

// validate all fields are there after introspection
t.Run("error - no token provided", func(t *testing.T) {
res, err := ctx.client.IntrospectAccessToken(context.Background(), IntrospectAccessTokenRequestObject{Body: &TokenIntrospectionRequest{Token: ""}})
require.NoError(t, err)
assert.Equal(t, res, IntrospectAccessToken200JSONResponse{})
})
t.Run("error - does not exist", func(t *testing.T) {
res, err := ctx.client.IntrospectAccessToken(context.Background(), IntrospectAccessTokenRequestObject{Body: &TokenIntrospectionRequest{Token: "does not exist"}})
require.ErrorIs(t, err, storage.ErrNotFound)
assert.Equal(t, res, IntrospectAccessToken200JSONResponse{})
})
t.Run("error - expired token", func(t *testing.T) {
token := AccessToken{Expiration: time.Now().Add(-time.Second)}
require.NoError(t, ctx.client.s2sAccessTokenStore().Put("token", token))

res, err := ctx.client.IntrospectAccessToken(context.Background(), IntrospectAccessTokenRequestObject{Body: &TokenIntrospectionRequest{Token: "token"}})

require.NoError(t, err)
assert.Equal(t, res, IntrospectAccessToken200JSONResponse{})
})
t.Run("ok", func(t *testing.T) {
token := AccessToken{Expiration: time.Now().Add(time.Second)}
require.NoError(t, ctx.client.s2sAccessTokenStore().Put("token", token))

res, err := ctx.client.IntrospectAccessToken(context.Background(), IntrospectAccessTokenRequestObject{Body: &TokenIntrospectionRequest{Token: "token"}})

require.NoError(t, err)
tokenResponse, ok := res.(IntrospectAccessToken200JSONResponse)
require.True(t, ok)
assert.True(t, tokenResponse.Active)
})

t.Run(" ok - s2s flow", func(t *testing.T) {
// TODO: this should be an integration test to make sure all fields are set
credential, err := vc.ParseVerifiableCredential(jsonld.TestOrganizationCredential)
require.NoError(t, err)
presentation := vc.VerifiablePresentation{
VerifiableCredential: []vc.VerifiableCredential{*credential},
}
tNow := time.Now()
token := AccessToken{
Token: "token",
Issuer: "resource-owner",
ClientId: "client",
IssuedAt: tNow,
Expiration: tNow.Add(time.Minute),
Scope: "test",
InputDescriptorConstraintIdMap: map[string]any{"key": "value"},
VPToken: []VerifiablePresentation{presentation},
PresentationSubmission: &pe.PresentationSubmission{},
PresentationDefinition: &pe.PresentationDefinition{},
}

require.NoError(t, ctx.client.s2sAccessTokenStore().Put(token.Token, token))
expectedResponse, err := json.Marshal(IntrospectAccessToken200JSONResponse{
Active: true,
ClientId: ptrTo("client"),
Exp: ptrTo(int(tNow.Add(time.Minute).Unix())),
Iat: ptrTo(int(tNow.Unix())),
Iss: ptrTo("resource-owner"),
Scope: ptrTo("test"),
Sub: ptrTo("resource-owner"),
Vps: &[]VerifiablePresentation{presentation},
InputDescriptorConstraintIdMap: ptrTo(map[string]any{"key": "value"}),
PresentationSubmission: ptrTo(map[string]interface{}{"definition_id": "", "descriptor_map": nil, "id": ""}),
PresentationDefinition: ptrTo(map[string]interface{}{"id": "", "input_descriptors": nil}),
})
require.NoError(t, err)

res, err := ctx.client.IntrospectAccessToken(context.Background(), IntrospectAccessTokenRequestObject{Body: &TokenIntrospectionRequest{Token: token.Token}})

require.NoError(t, err)
tokenResponse, err := json.Marshal(res)
assert.NoError(t, err)
assert.JSONEq(t, string(expectedResponse), string(tokenResponse))
})
}

// OG pointer function. Returns a pointer to any input.
func ptrTo[T any](v T) *T {
return &v
}

func requireOAuthError(t *testing.T, err error, errorCode oauth.ErrorCode, errorDescription string) {
var oauthErr oauth.OAuth2Error
require.ErrorAs(t, err, &oauthErr)
Expand Down
Loading

0 comments on commit 34b3fa9

Please sign in to comment.