diff --git a/auth/api/iam/api.go b/auth/api/iam/api.go index f00af3399f..57998c3bec 100644 --- a/auth/api/iam/api.go +++ b/auth/api/iam/api.go @@ -21,6 +21,7 @@ package iam import ( "context" "embed" + "encoding/json" "errors" "github.com/labstack/echo/v4" "github.com/nuts-foundation/go-did/did" @@ -36,6 +37,7 @@ import ( "html/template" "net/http" "strings" + "time" ) var _ core.Routable = &Wrapper{} @@ -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) diff --git a/auth/api/iam/api_test.go b/auth/api/iam/api_test.go index 171108af15..ffe9bcb30f 100644 --- a/auth/api/iam/api_test.go +++ b/auth/api/iam/api_test.go @@ -20,6 +20,7 @@ package iam import ( "context" + "encoding/json" "errors" "net/http" "net/http/httptest" @@ -29,11 +30,13 @@ 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" @@ -41,6 +44,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" + "time" ) var nutsDID = did.MustParseDID("did:nuts:123") @@ -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) diff --git a/auth/api/iam/generated.go b/auth/api/iam/generated.go index 665271d4b1..8c0961f8f1 100644 --- a/auth/api/iam/generated.go +++ b/auth/api/iam/generated.go @@ -15,6 +15,86 @@ import ( strictecho "github.com/oapi-codegen/runtime/strictmiddleware/echo" ) +const ( + JwtBearerAuthScopes = "jwtBearerAuth.Scopes" +) + +// Defines values for TokenIntrospectionResponseAssuranceLevel. +const ( + High TokenIntrospectionResponseAssuranceLevel = "high" + Low TokenIntrospectionResponseAssuranceLevel = "low" + Substantial TokenIntrospectionResponseAssuranceLevel = "substantial" +) + +// TokenIntrospectionRequest Token introspection request as described in RFC7662 section 2.1 +type TokenIntrospectionRequest struct { + Token string `json:"token"` +} + +// TokenIntrospectionResponse Token introspection response as described in RFC7662 section 2.2 +type TokenIntrospectionResponse struct { + // Active True if the token is active, false if the token is expired, malformed etc. Required per RFC7662 + Active bool `json:"active"` + + // AssuranceLevel Assurance level of the identity of the End-User. + AssuranceLevel *TokenIntrospectionResponseAssuranceLevel `json:"assurance_level,omitempty"` + + // Aud RFC7662 - Service-specific string identifier or list of string identifiers representing the intended audience for this token, as defined in JWT [RFC7519]. + Aud *string `json:"aud,omitempty"` + + // ClientId The client (DID) the access token was issued to + ClientId *string `json:"client_id,omitempty"` + + // Email End-User's preferred e-mail address. Should be a personal email and can be used to uniquely identify a user. Just like the email used for an account. + Email *string `json:"email,omitempty"` + + // Exp Expiration date in seconds since UNIX epoch + Exp *int `json:"exp,omitempty"` + + // FamilyName Surname(s) or last name(s) of the End-User. + FamilyName *string `json:"family_name,omitempty"` + + // Iat Issuance time in seconds since UNIX epoch + Iat *int `json:"iat,omitempty"` + + // Initials Initials of the End-User. + Initials *string `json:"initials,omitempty"` + + // InputDescriptorConstraintIdMap Mapping from the ID field of a 'presentation_definition' input descriptor constraints to the value provided in the 'vps' for the constraints. + // The Policy Decision Point can use this map to make decisions without having to deal with PEX/VCs/VPs/SignatureValidation + InputDescriptorConstraintIdMap *map[string]interface{} `json:"input_descriptor_constraint_id_map,omitempty"` + + // Iss Contains the DID of the authorizer. Should be equal to 'sub' + Iss *string `json:"iss,omitempty"` + + // Prefix Surname prefix + Prefix *string `json:"prefix,omitempty"` + + // PresentationDefinition presentation definition, as described in presentation exchange specification, fulfilled to obtain the access token + PresentationDefinition *map[string]interface{} `json:"presentation_definition,omitempty"` + + // PresentationSubmission mapping of 'vps' contents to the 'presentation_definition' + PresentationSubmission *map[string]interface{} `json:"presentation_submission,omitempty"` + + // Scope granted scopes + Scope *string `json:"scope,omitempty"` + + // Sub Contains the DID of the resource owner + Sub *string `json:"sub,omitempty"` + + // UserRole Role of the End-User. + UserRole *string `json:"user_role,omitempty"` + + // Username Identifier uniquely identifying the End-User's account in the issuing system. + Username *string `json:"username,omitempty"` + + // Vps The Verifiable Presentations that were used to request the access token using the same encoding as used in the access token request. + Vps *[]VerifiablePresentation `json:"vps,omitempty"` +} + +// TokenIntrospectionResponseAssuranceLevel Assurance level of the identity of the End-User. +type TokenIntrospectionResponseAssuranceLevel string + // PresentationDefinitionParams defines parameters for PresentationDefinition. type PresentationDefinitionParams struct { Scope string `form:"scope" json:"scope"` @@ -42,6 +122,9 @@ type RequestAccessTokenJSONBody struct { // HandleTokenRequestFormdataRequestBody defines body for HandleTokenRequest for application/x-www-form-urlencoded ContentType. type HandleTokenRequestFormdataRequestBody HandleTokenRequestFormdataBody +// IntrospectAccessTokenFormdataRequestBody defines body for IntrospectAccessToken for application/x-www-form-urlencoded ContentType. +type IntrospectAccessTokenFormdataRequestBody = TokenIntrospectionRequest + // RequestAccessTokenJSONRequestBody defines body for RequestAccessToken for application/json ContentType. type RequestAccessTokenJSONRequestBody RequestAccessTokenJSONBody @@ -144,6 +227,9 @@ type ServerInterface interface { // Used by to request access- or refresh tokens. // (POST /iam/{id}/token) HandleTokenRequest(ctx echo.Context, id string) error + // Introspection endpoint to retrieve information from an Access Token as described by RFC7662 + // (POST /internal/auth/v2/accesstoken/introspect) + IntrospectAccessToken(ctx echo.Context) error // Requests an access token using the vp_token-bearer grant. // (POST /internal/auth/v2/{did}/request-access-token) RequestAccessToken(ctx echo.Context, did string) error @@ -165,6 +251,8 @@ func (w *ServerInterfaceWrapper) OAuthAuthorizationServerMetadata(ctx echo.Conte return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter id: %s", err)) } + ctx.Set(JwtBearerAuthScopes, []string{}) + // Invoke the callback with all the unmarshaled arguments err = w.Handler.OAuthAuthorizationServerMetadata(ctx, id) return err @@ -181,6 +269,8 @@ func (w *ServerInterfaceWrapper) PresentationDefinition(ctx echo.Context) error return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter did: %s", err)) } + ctx.Set(JwtBearerAuthScopes, []string{}) + // Parameter object where we will unmarshal all parameters from the context var params PresentationDefinitionParams // ------------- Required query parameter "scope" ------------- @@ -206,6 +296,8 @@ func (w *ServerInterfaceWrapper) HandleAuthorizeRequest(ctx echo.Context) error return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter id: %s", err)) } + ctx.Set(JwtBearerAuthScopes, []string{}) + // Parameter object where we will unmarshal all parameters from the context var params HandleAuthorizeRequestParams // ------------- Optional query parameter "params" ------------- @@ -231,6 +323,8 @@ func (w *ServerInterfaceWrapper) GetWebDID(ctx echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter id: %s", err)) } + ctx.Set(JwtBearerAuthScopes, []string{}) + // Invoke the callback with all the unmarshaled arguments err = w.Handler.GetWebDID(ctx, id) return err @@ -247,6 +341,8 @@ func (w *ServerInterfaceWrapper) OAuthClientMetadata(ctx echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter id: %s", err)) } + ctx.Set(JwtBearerAuthScopes, []string{}) + // Invoke the callback with all the unmarshaled arguments err = w.Handler.OAuthClientMetadata(ctx, id) return err @@ -263,11 +359,24 @@ func (w *ServerInterfaceWrapper) HandleTokenRequest(ctx echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter id: %s", err)) } + ctx.Set(JwtBearerAuthScopes, []string{}) + // Invoke the callback with all the unmarshaled arguments err = w.Handler.HandleTokenRequest(ctx, id) return err } +// IntrospectAccessToken converts echo context to params. +func (w *ServerInterfaceWrapper) IntrospectAccessToken(ctx echo.Context) error { + var err error + + ctx.Set(JwtBearerAuthScopes, []string{}) + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.IntrospectAccessToken(ctx) + return err +} + // RequestAccessToken converts echo context to params. func (w *ServerInterfaceWrapper) RequestAccessToken(ctx echo.Context) error { var err error @@ -279,6 +388,8 @@ func (w *ServerInterfaceWrapper) RequestAccessToken(ctx echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter did: %s", err)) } + ctx.Set(JwtBearerAuthScopes, []string{}) + // Invoke the callback with all the unmarshaled arguments err = w.Handler.RequestAccessToken(ctx, did) return err @@ -318,6 +429,7 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL router.GET(baseURL+"/iam/:id/did.json", wrapper.GetWebDID) router.GET(baseURL+"/iam/:id/oauth-client", wrapper.OAuthClientMetadata) router.POST(baseURL+"/iam/:id/token", wrapper.HandleTokenRequest) + router.POST(baseURL+"/internal/auth/v2/accesstoken/introspect", wrapper.IntrospectAccessToken) router.POST(baseURL+"/internal/auth/v2/:did/request-access-token", wrapper.RequestAccessToken) } @@ -543,6 +655,31 @@ func (response HandleTokenRequestdefaultApplicationProblemPlusJSONResponse) Visi return json.NewEncoder(w).Encode(response.Body) } +type IntrospectAccessTokenRequestObject struct { + Body *IntrospectAccessTokenFormdataRequestBody +} + +type IntrospectAccessTokenResponseObject interface { + VisitIntrospectAccessTokenResponse(w http.ResponseWriter) error +} + +type IntrospectAccessToken200JSONResponse TokenIntrospectionResponse + +func (response IntrospectAccessToken200JSONResponse) VisitIntrospectAccessTokenResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type IntrospectAccessToken401Response struct { +} + +func (response IntrospectAccessToken401Response) VisitIntrospectAccessTokenResponse(w http.ResponseWriter) error { + w.WriteHeader(401) + return nil +} + type RequestAccessTokenRequestObject struct { Did string `json:"did"` Body *RequestAccessTokenJSONRequestBody @@ -602,6 +739,9 @@ type StrictServerInterface interface { // Used by to request access- or refresh tokens. // (POST /iam/{id}/token) HandleTokenRequest(ctx context.Context, request HandleTokenRequestRequestObject) (HandleTokenRequestResponseObject, error) + // Introspection endpoint to retrieve information from an Access Token as described by RFC7662 + // (POST /internal/auth/v2/accesstoken/introspect) + IntrospectAccessToken(ctx context.Context, request IntrospectAccessTokenRequestObject) (IntrospectAccessTokenResponseObject, error) // Requests an access token using the vp_token-bearer grant. // (POST /internal/auth/v2/{did}/request-access-token) RequestAccessToken(ctx context.Context, request RequestAccessTokenRequestObject) (RequestAccessTokenResponseObject, error) @@ -781,6 +921,39 @@ func (sh *strictHandler) HandleTokenRequest(ctx echo.Context, id string) error { return nil } +// IntrospectAccessToken operation middleware +func (sh *strictHandler) IntrospectAccessToken(ctx echo.Context) error { + var request IntrospectAccessTokenRequestObject + + if form, err := ctx.FormParams(); err == nil { + var body IntrospectAccessTokenFormdataRequestBody + if err := runtime.BindForm(&body, form, nil, nil); err != nil { + return err + } + request.Body = &body + } else { + return err + } + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.IntrospectAccessToken(ctx.Request().Context(), request.(IntrospectAccessTokenRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "IntrospectAccessToken") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(IntrospectAccessTokenResponseObject); ok { + return validResponse.VisitIntrospectAccessTokenResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} + // RequestAccessToken operation middleware func (sh *strictHandler) RequestAccessToken(ctx echo.Context, did string) error { var request RequestAccessTokenRequestObject diff --git a/auth/api/iam/s2s_vptoken.go b/auth/api/iam/s2s_vptoken.go index 81048adcc7..4fb1bd6807 100644 --- a/auth/api/iam/s2s_vptoken.go +++ b/auth/api/iam/s2s_vptoken.go @@ -32,6 +32,7 @@ import ( "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/core" "github.com/nuts-foundation/nuts-node/storage" + "github.com/nuts-foundation/nuts-node/vcr/pe" "github.com/nuts-foundation/nuts-node/vdr/resolver" ) @@ -116,12 +117,20 @@ func (r Wrapper) RequestAccessToken(ctx context.Context, request RequestAccessTo func (r Wrapper) createAccessToken(issuer did.DID, issueTime time.Time, presentation vc.VerifiablePresentation, scope string) (*oauth.TokenResponse, error) { accessToken := AccessToken{ - Token: crypto.GenerateNonce(), - Issuer: issuer.String(), - Expiration: issueTime.Add(accessTokenValidity), - Presentation: presentation, + Token: crypto.GenerateNonce(), + Issuer: issuer.String(), + // TODO: set ClientId + ClientId: "", + IssuedAt: issueTime, + Expiration: issueTime.Add(accessTokenValidity), + Scope: scope, + // TODO: set values + InputDescriptorConstraintIdMap: nil, + VPToken: []VerifiablePresentation{presentation}, + PresentationDefinition: nil, + PresentationSubmission: nil, } - err := r.accessTokenStore(issuer).Put(accessToken.Token, accessToken) + err := r.s2sAccessTokenStore().Put(accessToken.Token, accessToken) if err != nil { return nil, fmt.Errorf("unable to store access token: %w", err) } @@ -134,13 +143,33 @@ func (r Wrapper) createAccessToken(issuer did.DID, issueTime time.Time, presenta }, nil } -func (r Wrapper) accessTokenStore(issuer did.DID) storage.SessionStore { - return r.storageEngine.GetSessionDatabase().GetStore(accessTokenValidity, "s2s", issuer.String(), "accesstoken") +func (r Wrapper) s2sAccessTokenStore() storage.SessionStore { + return r.storageEngine.GetSessionDatabase().GetStore(accessTokenValidity, "s2s", "accesstoken") } type AccessToken struct { - Token string - Issuer string - Expiration time.Time - Presentation vc.VerifiablePresentation + Token string + // Issuer and Subject of a token are always the same. + Issuer string + // TODO: should client_id be extracted to the PDPMap using the presentation definition? + // ClientId is the DID of the entity requesting the access token. The Client needs to proof its id through proof-of-possession of the key for the DID. + ClientId string + // IssuedAt is the time the token is issued + IssuedAt time.Time + // Expiration is the time the token expires + Expiration time.Time + // Scope the token grants access to. Not necessarily the same as the requested scope + Scope string + // InputDescriptorConstraintIdMap maps the ID field of a PresentationDefinition input descriptor constraint to the value provided in the VPToken for the constraint. + // The Policy Decision Point can use this map to make decisions without having to deal with PEX/VCs/VPs/SignatureValidation + InputDescriptorConstraintIdMap map[string]any + + // additional fields to support unforeseen policy decision requirements + + // VPToken contains the VPs provided in the 'assertion' field of the s2s AT request. + VPToken []VerifiablePresentation + // PresentationSubmission as provided in the 'presentation_submission' field of the s2s AT request. + PresentationSubmission *pe.PresentationSubmission + // PresentationDefinition fulfilled to obtain the AT in the s2s flow. + PresentationDefinition *pe.PresentationDefinition } diff --git a/auth/api/iam/s2s_vptoken_test.go b/auth/api/iam/s2s_vptoken_test.go index c53a46c8dd..c034a4e505 100644 --- a/auth/api/iam/s2s_vptoken_test.go +++ b/auth/api/iam/s2s_vptoken_test.go @@ -123,11 +123,11 @@ func TestWrapper_createAccessToken(t *testing.T) { assert.Equal(t, "everything", *accessToken.Scope) var storedToken AccessToken - err = ctx.client.accessTokenStore(issuerDID).Get(accessToken.AccessToken, &storedToken) + err = ctx.client.s2sAccessTokenStore().Get(accessToken.AccessToken, &storedToken) require.NoError(t, err) assert.Equal(t, accessToken.AccessToken, storedToken.Token) expectedVPJSON, _ := presentation.MarshalJSON() - actualVPJSON, _ := storedToken.Presentation.MarshalJSON() + actualVPJSON, _ := storedToken.VPToken[0].MarshalJSON() assert.JSONEq(t, string(expectedVPJSON), string(actualVPJSON)) assert.Equal(t, issuerDID.String(), storedToken.Issuer) assert.NotEmpty(t, storedToken.Expiration) diff --git a/auth/api/iam/types.go b/auth/api/iam/types.go index d805d6a070..a0f9e48897 100644 --- a/auth/api/iam/types.go +++ b/auth/api/iam/types.go @@ -20,6 +20,7 @@ package iam import ( "github.com/nuts-foundation/go-did/did" + "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/vcr/pe" "github.com/nuts-foundation/nuts-node/vdr/resolver" @@ -31,6 +32,12 @@ type DIDDocument = did.Document // DIDDocumentMetadata is an alias type DIDDocumentMetadata = resolver.DocumentMetadata +// VerifiablePresentation is an alias +type VerifiablePresentation = vc.VerifiablePresentation + +// ErrorResponse is an alias +type ErrorResponse = oauth.OAuth2Error + // PresentationDefinition is an alias type PresentationDefinition = pe.PresentationDefinition diff --git a/codegen/configs/auth_iam.yaml b/codegen/configs/auth_iam.yaml index 86fc29a911..9c0cc4278b 100644 --- a/codegen/configs/auth_iam.yaml +++ b/codegen/configs/auth_iam.yaml @@ -11,3 +11,4 @@ output-options: - OAuthClientMetadata - PresentationDefinition - TokenResponse + - VerifiablePresentation diff --git a/docs/_static/auth/iam.yaml b/docs/_static/auth/iam.yaml index 977f7303e6..d25d49324e 100644 --- a/docs/_static/auth/iam.yaml +++ b/docs/_static/auth/iam.yaml @@ -285,7 +285,7 @@ paths: description: The DID of the requester, a Wallet owner at this node. schema: type: string - example: did:nuts:123 + example: did:web:example.com requestBody: required: true content: @@ -297,7 +297,7 @@ paths: properties: verifier: type: string - example: did:nuts:123 + example: did:web:example.com scope: type: string description: The scope that will be The service for which this access token can be used. @@ -311,10 +311,37 @@ paths: $ref: '#/components/schemas/TokenResponse' default: $ref: '../common/error_response.yaml' + /internal/auth/v2/accesstoken/introspect: + post: + operationId: introspectAccessToken + summary: Introspection endpoint to retrieve information from an Access Token as described by RFC7662 + tags: + - auth + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + $ref: "#/components/schemas/TokenIntrospectionRequest" + responses: + '200': + description: | + An Introspection response as described in RFC7662 section 2.2. The Irma, Dummy and Employee identity means all return 'username', 'initials', 'prefix', 'family_name' and 'assurance_level'. + 'username' should be used as unique identifier for the user. + content: + application/json: + schema: + $ref: "#/components/schemas/TokenIntrospectionResponse" + '401': + description: | + This is returned when an OAuth2 Client is unauthorized to talk to the introspection endpoint. + Note: introspection of an invalid or malformed token returns a 200 where with field 'active'=false components: schemas: DIDDocument: $ref: '../common/ssi_types.yaml#/components/schemas/DIDDocument' + VerifiablePresentation: + $ref: '../common/ssi_types.yaml#/components/schemas/VerifiablePresentation' TokenResponse: type: object description: | @@ -360,3 +387,111 @@ components: description: | A presentation definition is a JSON object that describes the desired verifiable credentials and presentation formats. Specified at https://identity.foundation/presentation-exchange/spec/v2.0.0/ + type: object + ErrorResponse: + type: object + required: + - error + properties: + error: + type: string + description: Code identifying the error that occurred. + example: "invalid_request" + TokenIntrospectionRequest: + description: Token introspection request as described in RFC7662 section 2.1 + required: + - token + properties: + token: + type: string + example: + eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhaWQiOiJ1cm46b2lkOjIuMTYuODQwLjEuMTEzODgzLjIuNC42LjE6MDAwMDAwMDAiLCJleHAiOjE1ODE0MTI2NjcsImlhdCI6MTU4MTQxMTc2NywiaXNzIjoidXJuOm9pZDoyLjE2Ljg0MC4xLjExMzg4My4yLjQuNi4xOjAwMDAwMDAxIiwic2lkIjoidXJuOm9pZDoyLjE2Ljg0MC4xLjExMzg4My4yLjQuNi4zOjk5OTk5OTk5MCIsInN1YiI6IiJ9.OhniTJcPS45nhJVqXfxsngG5eYS_0BvqFg-96zaWFO90I_5_N9Eg_k7NmIF5eNZ9Xutl1aqSxlSp80EX07Gmk8uzZO9PEReo0YZxnNQV-Zeq1njCMmfdwusmiczFlwcBi5Bl1xYGmLrxP7NcAoljmDgMgmLH0xaKfP4VVim6snPkPHqBdSzAgSrrc-cgVDLl-9V2obPB1HiVsFMYfbHEIb4MPsnPRnSGavYHTxt34mHbRsS8BvoBy3v6VNYaewLr6yz-_Zstrnr4I_wxtYbSiPJUeVQHcD-a9Ck53BdjspnhVHZ4IFVvuNrpflVaB1A7P3A2xZ7G_a8gF_SHMynYSA + TokenIntrospectionResponse: + description: Token introspection response as described in RFC7662 section 2.2 + required: + - active + properties: + active: + type: boolean + description: True if the token is active, false if the token is expired, malformed etc. Required per RFC7662 + iss: + type: string + description: Contains the DID of the authorizer. Should be equal to 'sub' + example: did:web:example.com:resource-owner + sub: + type: string + description: Contains the DID of the resource owner + example: did:web:example.com:resource-owner + aud: + type: string + description: RFC7662 - Service-specific string identifier or list of string identifiers representing the intended audience for this token, as defined in JWT [RFC7519]. + example: "https://target_token_endpoint" + client_id: + type: string + description: The client (DID) the access token was issued to + example: did:web:example.com:client + exp: + type: integer + description: Expiration date in seconds since UNIX epoch + iat: + type: integer + description: Issuance time in seconds since UNIX epoch + scope: + type: string + description: granted scopes + input_descriptor_constraint_id_map: + type: object + description: | + Mapping from the ID field of a 'presentation_definition' input descriptor constraints to the value provided in the 'vps' for the constraints. + The Policy Decision Point can use this map to make decisions without having to deal with PEX/VCs/VPs/SignatureValidation + presentation_definition: + type: object + description: presentation definition, as described in presentation exchange specification, fulfilled to obtain the access token + items: + $ref: '#/components/schemas/PresentationDefinition' + presentation_submission: + type: object + description: mapping of 'vps' contents to the 'presentation_definition' + vps: + type: array + items: + $ref: '#/components/schemas/VerifiablePresentation' + description: The Verifiable Presentations that were used to request the access token using the same encoding as used in the access token request. +# TODO: below is copied from introspection/v1. Remove anything unused when flows are finalized. +# TODO: existing contract validator responses + family_name: + type: string + description: Surname(s) or last name(s) of the End-User. + example: Bruijn + prefix: + type: string + description: Surname prefix + example: de + initials: + type: string + description: Initials of the End-User. + example: I. + username: + type: string + description: Identifier uniquely identifying the End-User's account in the issuing system. + assurance_level: + type: string + description: Assurance level of the identity of the End-User. + format: enum + enum: [low, substantial, high] +# TODO: ??? + email: + type: string + description: End-User's preferred e-mail address. Should be a personal email and can be used to uniquely identify a user. Just like the email used for an account. + example: w.debruijn@example.org + user_role: + type: string + description: Role of the End-User. + securitySchemes: + jwtBearerAuth: + type: http + scheme: bearer + +security: + - {} + - jwtBearerAuth: []