From 70d60c0a1c159ef5a5e21506fc539e1d08720bf2 Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Sat, 12 Aug 2023 11:18:20 +0200 Subject: [PATCH] Flexible OAuth2 Server POC --- cmd/root.go | 2 + codegen/configs/vcr_oauth2_v0.yaml | 5 + docs/_static/vcr/oauth2_v0.yaml | 183 +++++++ makefile | 1 + vcr/api/oauth2/v0/api.go | 211 ++++++++ vcr/api/oauth2/v0/assets/authz_en.html | 14 + vcr/api/oauth2/v0/authorized_code.go | 52 ++ vcr/api/oauth2/v0/generated.go | 693 +++++++++++++++++++++++++ vcr/api/oauth2/v0/interface.go | 46 ++ vcr/api/oauth2/v0/openid4vp.go | 46 ++ vcr/api/oauth2/v0/s2s_vptoken.go | 50 ++ vcr/openid4vci/error.go | 17 +- 12 files changed, 1317 insertions(+), 3 deletions(-) create mode 100644 codegen/configs/vcr_oauth2_v0.yaml create mode 100644 docs/_static/vcr/oauth2_v0.yaml create mode 100644 vcr/api/oauth2/v0/api.go create mode 100644 vcr/api/oauth2/v0/assets/authz_en.html create mode 100644 vcr/api/oauth2/v0/authorized_code.go create mode 100644 vcr/api/oauth2/v0/generated.go create mode 100644 vcr/api/oauth2/v0/interface.go create mode 100644 vcr/api/oauth2/v0/openid4vp.go create mode 100644 vcr/api/oauth2/v0/s2s_vptoken.go diff --git a/cmd/root.go b/cmd/root.go index d0882732c5..52f0267a01 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -25,6 +25,7 @@ import ( "fmt" "github.com/nuts-foundation/nuts-node/golden_hammer" goldenHammerCmd "github.com/nuts-foundation/nuts-node/golden_hammer/cmd" + oauth2API "github.com/nuts-foundation/nuts-node/vcr/api/oauth2/v0" "github.com/nuts-foundation/nuts-node/vdr/didnuts" "github.com/nuts-foundation/nuts-node/vdr/didnuts/didstore" "github.com/nuts-foundation/nuts-node/vdr/didservice" @@ -205,6 +206,7 @@ func CreateSystem(shutdownCallback context.CancelFunc) *core.System { Resolver: vdrInstance.Resolver(), }}) system.RegisterRoutes(&vcrAPI.Wrapper{VCR: credentialInstance, ContextManager: jsonld}) + system.RegisterRoutes(oauth2API.New()) system.RegisterRoutes(&openid4vciAPI.Wrapper{ VCR: credentialInstance, DocumentOwner: vdrInstance, diff --git a/codegen/configs/vcr_oauth2_v0.yaml b/codegen/configs/vcr_oauth2_v0.yaml new file mode 100644 index 0000000000..c764b68645 --- /dev/null +++ b/codegen/configs/vcr_oauth2_v0.yaml @@ -0,0 +1,5 @@ +package: v0 +generate: + echo-server: true + models: true + strict-server: true \ No newline at end of file diff --git a/docs/_static/vcr/oauth2_v0.yaml b/docs/_static/vcr/oauth2_v0.yaml new file mode 100644 index 0000000000..ebbbf3f521 --- /dev/null +++ b/docs/_static/vcr/oauth2_v0.yaml @@ -0,0 +1,183 @@ +openapi: 3.0.0 +info: + title: OAuth2 API + version: 0.0.0 +servers: + - url: "http://localhost:1323" +paths: + "/public/auth/{did}/token": + post: + summary: Used by to request access- or refresh tokens. + description: Specified by https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-token-endpoint + operationId: handleTokenRequest + parameters: + - name: did + in: path + required: true + schema: + type: string + example: did:nuts:123 + requestBody: + content: + application/x-www-form-urlencoded: + schema: + type: object + required: + - grant_type + - code + properties: + grant_type: + type: string + example: urn:ietf:params:oauth:grant-type:authorized_code + code: + type: string + example: secret + additionalProperties: + type: string + responses: + "200": + description: OK + content: + application/json: + schema: + "$ref": "#/components/schemas/TokenResponse" + "404": + description: Unknown issuer + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + "400": + description: > + Invalid request. Code can be "invalid_request", "invalid_client", "invalid_grant", "unauthorized_client", "unsupported_grant_type" or "invalid_scope". + Specified by https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-token-error-response + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + "/public/auth/{did}/authorize": + get: + summary: Used by clients to initiate the authorization code flow. + description: Specified by https://datatracker.ietf.org/doc/html/rfc6749#section-3.1 + operationId: handleAuthorizeRequest + parameters: + - name: did + in: path + required: true + schema: + type: string + example: did:nuts:123 + requestBody: + content: + application/x-www-form-urlencoded: + schema: + description: See https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1 + type: object + required: + - response_type + - client_id + properties: + response_type: + type: string + example: code + client_id: + type: string + redirect_uri: + type: string + scope: + type: string + state: + type: string + additionalProperties: + type: string + responses: + "200": + description: Authorization request accepted, user is asked for consent + content: + text/html: + schema: + type: string + "302": + description: > + If an error occurs, the user-agent is redirected, the authorization server redirects the user-agent to the provided redirect URI. + headers: + Location: + schema: + type: string + format: uri + "/public/auth/{did}/authz_consent": + post: + summary: Invoked by the user-agent to authorize/consent to authorization requests. + description: TODO + operationId: handleUserConsentRequest + parameters: + - name: did + in: path + required: true + schema: + type: string + example: did:nuts:123 + requestBody: + content: + application/x-www-form-urlencoded: + schema: + type: object + required: + - sessionID + properties: + sessionID: + type: string + example: 12345678 + responses: + "302": + description: > + After authorization, whether successful or unsuccessful, + the authorization server redirects the user-agent back to the resource owner. + headers: + Location: + description: Redirect URI of the resource owner. + schema: + type: string + format: uri +components: + schemas: + TokenResponse: + type: object + description: | + Token Responses are made as defined in [RFC6749] + required: + - access_token + - token_type + properties: + access_token: + type: string + description: | + The access token issued by the authorization server. + example: "eyJhbGciOiJSUzI1NiIsInR5cCI6Ikp..sHQ" + token_type: + type: string + description: | + The type of the token issued as described in [RFC6749]. + example: "bearer" + expires_in: + type: integer + description: | + The lifetime in seconds of the access token. + example: 3600 + additionalProperties: + type: string + example: + { + "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6Ikp..sHQ", + "token_type": "bearer", + "expires_in": 3600, + } + ErrorResponse: + type: object + required: + - error + properties: + error: + type: string + description: Code identifying the error that occurred. + example: "invalid_request" diff --git a/makefile b/makefile index 70029de704..d20284b970 100644 --- a/makefile +++ b/makefile @@ -56,6 +56,7 @@ gen-api: oapi-codegen --config codegen/configs/network_v1.yaml docs/_static/network/v1.yaml | gofmt > network/api/v1/generated.go oapi-codegen --config codegen/configs/vcr_v2.yaml docs/_static/vcr/vcr_v2.yaml | gofmt > vcr/api/vcr/v2/generated.go oapi-codegen --config codegen/configs/vcr_openid4vci_v0.yaml docs/_static/vcr/openid4vci_v0.yaml | gofmt > vcr/api/openid4vci/v0/generated.go + oapi-codegen --config codegen/configs/vcr_oauth2_v0.yaml docs/_static/vcr/oauth2_v0.yaml | gofmt > vcr/api/oauth2/v0/generated.go oapi-codegen --config codegen/configs/auth_v1.yaml docs/_static/auth/v1.yaml | gofmt > auth/api/auth/v1/generated.go oapi-codegen --config codegen/configs/auth_client_v1.yaml docs/_static/auth/v1.yaml | gofmt > auth/api/auth/v1/client/generated.go oapi-codegen --config codegen/configs/auth_employeeid.yaml auth/services/selfsigned/web/spec.yaml | gofmt > auth/services/selfsigned/web/generated.go diff --git a/vcr/api/oauth2/v0/api.go b/vcr/api/oauth2/v0/api.go new file mode 100644 index 0000000000..559c93ff61 --- /dev/null +++ b/vcr/api/oauth2/v0/api.go @@ -0,0 +1,211 @@ +package v0 + +import ( + "bytes" + "context" + "crypto/rand" + "embed" + "encoding/base64" + "errors" + "fmt" + "github.com/labstack/echo/v4" + "github.com/nuts-foundation/nuts-node/audit" + "github.com/nuts-foundation/nuts-node/core" + "github.com/nuts-foundation/nuts-node/vcr" + "github.com/nuts-foundation/nuts-node/vcr/openid4vci" + "html/template" + "net/http" + "net/url" + "sync" +) + +var _ core.Routable = &Wrapper{} +var _ StrictServerInterface = &Wrapper{} + +//go:embed assets +var assets embed.FS + +// Wrapper handles OAuth2 flows. It registers flows registering the same endpoints as a chain in reverse order, +// e.g., if openID4VP and OpenID4VCI both register an `/authorize` endpoint (in this order), the following call order applies: +// 1. OpenID4VCI +// 2. openID4VP +// 3. Default error handler (invalid parameters) +type Wrapper struct { + VCR vcr.VCR + protocols []protocol + authzTemplate *template.Template + sessions *SessionManager +} + +func New() *Wrapper { + sessionManager := &SessionManager{sessions: new(sync.Map)} + authzTemplate, _ := template.ParseFS(assets, "assets/authz_en.html") + return &Wrapper{ + // Order can be important: the first authorization call handler that returns true will be used. + protocols: []protocol{ + &serviceToService{}, + &authorizedCodeFlow{sessions: sessionManager}, + }, + authzTemplate: authzTemplate, + sessions: sessionManager, + } +} + +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) { + ctx.Set(core.OperationIDContextKey, operationID) + ctx.Set(core.ModuleNameContextKey, vcr.ModuleName+"/OAuth2") + // TODO: Do we need a generic error handler? + // ctx.Set(core.ErrorWriterContextKey, &protocolErrorWriter{}) + return f(ctx, request) + } + }, + func(f StrictHandlerFunc, operationID string) StrictHandlerFunc { + return audit.StrictMiddleware(f, vcr.ModuleName+"/OAuth2", operationID) + }, + })) + for _, currProtocol := range r.protocols { + // TODO: Middleware + currProtocol.Routes(router) + } +} + +// 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) { + // Find handler in registered protocols for the grant type + var handler grantHandler + for _, currProtocol := range r.protocols { + grantHandlers := currProtocol.grantHandlers() + var ok bool + if handler, ok = grantHandlers[request.Body.GrantType]; ok { + break + } + } + + if handler == nil { + return nil, openid4vci.Error{ + Code: openid4vci.InvalidRequest, + StatusCode: http.StatusBadRequest, + Description: "invalid grant type", + } + } + scope, err := handler(request.Body.AdditionalProperties) + if err != nil { + return nil, err + } + // TODO: Generate access token with scope + return HandleTokenRequest200JSONResponse(TokenResponse{ + AccessToken: scope, + }), 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) { + if request.Body.ResponseType != "code" { + // TODO: This should be a redirect? + return nil, openid4vci.Error{ + Code: openid4vci.InvalidRequest, + StatusCode: http.StatusBadRequest, + Description: "invalid response type", + } + } + + // Create session object to be passed to handler + session := &Session{ + // TODO: Validate client ID + ClientID: request.Body.ClientId, + } + // TODO: Validate scope? + if request.Body.Scope != nil { + session.Scope = *request.Body.Scope + } + if request.Body.State != nil { + session.ClientState = *request.Body.State + } + // TODO: Validate redirect URI + if request.Body.RedirectUri != nil { + // TODO: Validate according to https://datatracker.ietf.org/doc/html/rfc6749#section-3.1.2 + session.RedirectURI = *request.Body.RedirectUri + } else { + // TODO: Spec says that the redirect URI is optional, but it's not clear what to do if it's not provided. + // See https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1 + return nil, errors.New("missing redirect URI") + } + + var handled bool + var err error + for _, currProtocol := range r.protocols { + for _, handler := range currProtocol.authzHandlers() { + handled, err = handler(request.Body.AdditionalProperties, session) + if err != nil { + // TODO: This should be a redirect? + return nil, err + } + if handled { + break + } + } + } + + if !handled { + // No handler could handle the request + // TODO: This should be a redirect? + return nil, openid4vci.Error{ + Code: openid4vci.InvalidRequest, + StatusCode: http.StatusBadRequest, + Description: "missing or invalid parameters", + } + } + + // TODO: Session expiration + // TODO: Session storage + // TODO: Session pinning and other safety measures (see OAuth2 Threat Model) + sessionId := r.sessions.Create(*session) + + authzPageHTML, err := r.renderAuthzPage(sessionId, session) + return HandleAuthorizeRequest200TexthtmlResponse{Body: bytes.NewReader(authzPageHTML), ContentLength: int64(len(authzPageHTML))}, nil + +} + +func (r Wrapper) HandleUserConsentRequest(ctx context.Context, request HandleUserConsentRequestRequestObject) (HandleUserConsentRequestResponseObject, error) { + session := r.sessions.Get(request.Body.SessionID) + if session == nil { + return nil, errors.New("invalid session") + } + + redirectURI, _ := url.Parse(session.RedirectURI) // Validated on session creation, can't fail + query := redirectURI.Query() + query.Add("code", generateCode()) + redirectURI.RawQuery = query.Encode() + + return HandleUserConsentRequest302Response{ + HandleUserConsentRequest302ResponseHeaders{Location: redirectURI.String()}, + }, nil +} + +func (r Wrapper) renderAuthzPage(sessionID string, session *Session) ([]byte, error) { + type Params struct { + SessionID string + Session + } + buf := new(bytes.Buffer) + err := r.authzTemplate.Execute(buf, Params{ + SessionID: sessionID, + Session: *session, + }) + if err != nil { + return nil, fmt.Errorf("unable to render authorization page: %w", err) + } + return buf.Bytes(), nil +} + +func generateCode() string { + buf := make([]byte, 128/8) + _, err := rand.Read(buf) + if err != nil { + panic(err) + } + return base64.URLEncoding.EncodeToString(buf) +} diff --git a/vcr/api/oauth2/v0/assets/authz_en.html b/vcr/api/oauth2/v0/assets/authz_en.html new file mode 100644 index 0000000000..c65c7a7d4e --- /dev/null +++ b/vcr/api/oauth2/v0/assets/authz_en.html @@ -0,0 +1,14 @@ + + + + Authorization required + + +

Authorization required

+

You need to authorize this application to access your account.

+
+ + +
+ + \ No newline at end of file diff --git a/vcr/api/oauth2/v0/authorized_code.go b/vcr/api/oauth2/v0/authorized_code.go new file mode 100644 index 0000000000..dfbabb4f11 --- /dev/null +++ b/vcr/api/oauth2/v0/authorized_code.go @@ -0,0 +1,52 @@ +package v0 + +import ( + "github.com/nuts-foundation/nuts-node/core" + "github.com/nuts-foundation/nuts-node/vcr/openid4vci" + "net/http" +) + +var _ protocol = &authorizedCodeFlow{} + +// authorizedCodeFlow implements the grant type as specified by https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3. +type authorizedCodeFlow struct { + sessions *SessionManager +} + +func (a authorizedCodeFlow) Routes(_ core.EchoRouter) { + // Authorize endpoint is implemented as baseline feature, no need to register it here. +} + +func (a authorizedCodeFlow) authzHandlers() []authzHandler { + return []authzHandler{ + func(m map[string]string, session *Session) (bool, error) { + return true, nil + }, + } +} + +func (a authorizedCodeFlow) grantHandlers() map[string]grantHandler { + return map[string]grantHandler{ + "authorization_code": a.validateCode, + } +} + +func (a authorizedCodeFlow) validateCode(params map[string]string) (string, error) { + code, ok := params["code"] + if !ok { + return "", openid4vci.Error{ + Code: openid4vci.InvalidRequest, + StatusCode: http.StatusBadRequest, + Description: "missing or invalid code parameter", + } + } + session := a.sessions.Get(code) + if session == nil { + return "", openid4vci.Error{ + Code: openid4vci.InvalidRequest, + StatusCode: http.StatusBadRequest, + Description: "invalid code", + } + } + return session.Scope, nil +} diff --git a/vcr/api/oauth2/v0/generated.go b/vcr/api/oauth2/v0/generated.go new file mode 100644 index 0000000000..553f4ccc40 --- /dev/null +++ b/vcr/api/oauth2/v0/generated.go @@ -0,0 +1,693 @@ +// Package v0 provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/deepmap/oapi-codegen version v1.13.4 DO NOT EDIT. +package v0 + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/deepmap/oapi-codegen/pkg/runtime" + "github.com/labstack/echo/v4" +) + +// ErrorResponse defines model for ErrorResponse. +type ErrorResponse struct { + // Error Code identifying the error that occurred. + Error string `json:"error"` +} + +// TokenResponse Token Responses are made as defined in [RFC6749] +type TokenResponse struct { + // AccessToken The access token issued by the authorization server. + AccessToken string `json:"access_token"` + + // ExpiresIn The lifetime in seconds of the access token. + ExpiresIn *int `json:"expires_in,omitempty"` + + // TokenType The type of the token issued as described in [RFC6749]. + TokenType string `json:"token_type"` + AdditionalProperties map[string]string `json:"-"` +} + +// HandleAuthorizeRequestFormdataBody defines parameters for HandleAuthorizeRequest. +type HandleAuthorizeRequestFormdataBody struct { + ClientId string `form:"client_id" json:"client_id"` + RedirectUri *string `form:"redirect_uri,omitempty" json:"redirect_uri,omitempty"` + ResponseType string `form:"response_type" json:"response_type"` + Scope *string `form:"scope,omitempty" json:"scope,omitempty"` + State *string `form:"state,omitempty" json:"state,omitempty"` + AdditionalProperties map[string]string `json:"-"` +} + +// HandleUserConsentRequestFormdataBody defines parameters for HandleUserConsentRequest. +type HandleUserConsentRequestFormdataBody struct { + SessionID string `form:"sessionID" json:"sessionID"` +} + +// HandleTokenRequestFormdataBody defines parameters for HandleTokenRequest. +type HandleTokenRequestFormdataBody struct { + Code string `form:"code" json:"code"` + GrantType string `form:"grant_type" json:"grant_type"` + AdditionalProperties map[string]string `json:"-"` +} + +// HandleAuthorizeRequestFormdataRequestBody defines body for HandleAuthorizeRequest for application/x-www-form-urlencoded ContentType. +type HandleAuthorizeRequestFormdataRequestBody HandleAuthorizeRequestFormdataBody + +// HandleUserConsentRequestFormdataRequestBody defines body for HandleUserConsentRequest for application/x-www-form-urlencoded ContentType. +type HandleUserConsentRequestFormdataRequestBody HandleUserConsentRequestFormdataBody + +// HandleTokenRequestFormdataRequestBody defines body for HandleTokenRequest for application/x-www-form-urlencoded ContentType. +type HandleTokenRequestFormdataRequestBody HandleTokenRequestFormdataBody + +// Getter for additional properties for HandleAuthorizeRequestFormdataBody. Returns the specified +// element and whether it was found +func (a HandleAuthorizeRequestFormdataBody) Get(fieldName string) (value string, found bool) { + if a.AdditionalProperties != nil { + value, found = a.AdditionalProperties[fieldName] + } + return +} + +// Setter for additional properties for HandleAuthorizeRequestFormdataBody +func (a *HandleAuthorizeRequestFormdataBody) Set(fieldName string, value string) { + if a.AdditionalProperties == nil { + a.AdditionalProperties = make(map[string]string) + } + a.AdditionalProperties[fieldName] = value +} + +// Override default JSON handling for HandleAuthorizeRequestFormdataBody to handle AdditionalProperties +func (a *HandleAuthorizeRequestFormdataBody) UnmarshalJSON(b []byte) error { + object := make(map[string]json.RawMessage) + err := json.Unmarshal(b, &object) + if err != nil { + return err + } + + if raw, found := object["client_id"]; found { + err = json.Unmarshal(raw, &a.ClientId) + if err != nil { + return fmt.Errorf("error reading 'client_id': %w", err) + } + delete(object, "client_id") + } + + if raw, found := object["redirect_uri"]; found { + err = json.Unmarshal(raw, &a.RedirectUri) + if err != nil { + return fmt.Errorf("error reading 'redirect_uri': %w", err) + } + delete(object, "redirect_uri") + } + + if raw, found := object["response_type"]; found { + err = json.Unmarshal(raw, &a.ResponseType) + if err != nil { + return fmt.Errorf("error reading 'response_type': %w", err) + } + delete(object, "response_type") + } + + if raw, found := object["scope"]; found { + err = json.Unmarshal(raw, &a.Scope) + if err != nil { + return fmt.Errorf("error reading 'scope': %w", err) + } + delete(object, "scope") + } + + if raw, found := object["state"]; found { + err = json.Unmarshal(raw, &a.State) + if err != nil { + return fmt.Errorf("error reading 'state': %w", err) + } + delete(object, "state") + } + + if len(object) != 0 { + a.AdditionalProperties = make(map[string]string) + for fieldName, fieldBuf := range object { + var fieldVal string + err := json.Unmarshal(fieldBuf, &fieldVal) + if err != nil { + return fmt.Errorf("error unmarshaling field %s: %w", fieldName, err) + } + a.AdditionalProperties[fieldName] = fieldVal + } + } + return nil +} + +// Override default JSON handling for HandleAuthorizeRequestFormdataBody to handle AdditionalProperties +func (a HandleAuthorizeRequestFormdataBody) MarshalJSON() ([]byte, error) { + var err error + object := make(map[string]json.RawMessage) + + object["client_id"], err = json.Marshal(a.ClientId) + if err != nil { + return nil, fmt.Errorf("error marshaling 'client_id': %w", err) + } + + if a.RedirectUri != nil { + object["redirect_uri"], err = json.Marshal(a.RedirectUri) + if err != nil { + return nil, fmt.Errorf("error marshaling 'redirect_uri': %w", err) + } + } + + object["response_type"], err = json.Marshal(a.ResponseType) + if err != nil { + return nil, fmt.Errorf("error marshaling 'response_type': %w", err) + } + + if a.Scope != nil { + object["scope"], err = json.Marshal(a.Scope) + if err != nil { + return nil, fmt.Errorf("error marshaling 'scope': %w", err) + } + } + + if a.State != nil { + object["state"], err = json.Marshal(a.State) + if err != nil { + return nil, fmt.Errorf("error marshaling 'state': %w", err) + } + } + + for fieldName, field := range a.AdditionalProperties { + object[fieldName], err = json.Marshal(field) + if err != nil { + return nil, fmt.Errorf("error marshaling '%s': %w", fieldName, err) + } + } + return json.Marshal(object) +} + +// Getter for additional properties for HandleTokenRequestFormdataBody. Returns the specified +// element and whether it was found +func (a HandleTokenRequestFormdataBody) Get(fieldName string) (value string, found bool) { + if a.AdditionalProperties != nil { + value, found = a.AdditionalProperties[fieldName] + } + return +} + +// Setter for additional properties for HandleTokenRequestFormdataBody +func (a *HandleTokenRequestFormdataBody) Set(fieldName string, value string) { + if a.AdditionalProperties == nil { + a.AdditionalProperties = make(map[string]string) + } + a.AdditionalProperties[fieldName] = value +} + +// Override default JSON handling for HandleTokenRequestFormdataBody to handle AdditionalProperties +func (a *HandleTokenRequestFormdataBody) UnmarshalJSON(b []byte) error { + object := make(map[string]json.RawMessage) + err := json.Unmarshal(b, &object) + if err != nil { + return err + } + + if raw, found := object["code"]; found { + err = json.Unmarshal(raw, &a.Code) + if err != nil { + return fmt.Errorf("error reading 'code': %w", err) + } + delete(object, "code") + } + + if raw, found := object["grant_type"]; found { + err = json.Unmarshal(raw, &a.GrantType) + if err != nil { + return fmt.Errorf("error reading 'grant_type': %w", err) + } + delete(object, "grant_type") + } + + if len(object) != 0 { + a.AdditionalProperties = make(map[string]string) + for fieldName, fieldBuf := range object { + var fieldVal string + err := json.Unmarshal(fieldBuf, &fieldVal) + if err != nil { + return fmt.Errorf("error unmarshaling field %s: %w", fieldName, err) + } + a.AdditionalProperties[fieldName] = fieldVal + } + } + return nil +} + +// Override default JSON handling for HandleTokenRequestFormdataBody to handle AdditionalProperties +func (a HandleTokenRequestFormdataBody) MarshalJSON() ([]byte, error) { + var err error + object := make(map[string]json.RawMessage) + + object["code"], err = json.Marshal(a.Code) + if err != nil { + return nil, fmt.Errorf("error marshaling 'code': %w", err) + } + + object["grant_type"], err = json.Marshal(a.GrantType) + if err != nil { + return nil, fmt.Errorf("error marshaling 'grant_type': %w", err) + } + + for fieldName, field := range a.AdditionalProperties { + object[fieldName], err = json.Marshal(field) + if err != nil { + return nil, fmt.Errorf("error marshaling '%s': %w", fieldName, err) + } + } + return json.Marshal(object) +} + +// Getter for additional properties for TokenResponse. Returns the specified +// element and whether it was found +func (a TokenResponse) Get(fieldName string) (value string, found bool) { + if a.AdditionalProperties != nil { + value, found = a.AdditionalProperties[fieldName] + } + return +} + +// Setter for additional properties for TokenResponse +func (a *TokenResponse) Set(fieldName string, value string) { + if a.AdditionalProperties == nil { + a.AdditionalProperties = make(map[string]string) + } + a.AdditionalProperties[fieldName] = value +} + +// Override default JSON handling for TokenResponse to handle AdditionalProperties +func (a *TokenResponse) UnmarshalJSON(b []byte) error { + object := make(map[string]json.RawMessage) + err := json.Unmarshal(b, &object) + if err != nil { + return err + } + + if raw, found := object["access_token"]; found { + err = json.Unmarshal(raw, &a.AccessToken) + if err != nil { + return fmt.Errorf("error reading 'access_token': %w", err) + } + delete(object, "access_token") + } + + if raw, found := object["expires_in"]; found { + err = json.Unmarshal(raw, &a.ExpiresIn) + if err != nil { + return fmt.Errorf("error reading 'expires_in': %w", err) + } + delete(object, "expires_in") + } + + if raw, found := object["token_type"]; found { + err = json.Unmarshal(raw, &a.TokenType) + if err != nil { + return fmt.Errorf("error reading 'token_type': %w", err) + } + delete(object, "token_type") + } + + if len(object) != 0 { + a.AdditionalProperties = make(map[string]string) + for fieldName, fieldBuf := range object { + var fieldVal string + err := json.Unmarshal(fieldBuf, &fieldVal) + if err != nil { + return fmt.Errorf("error unmarshaling field %s: %w", fieldName, err) + } + a.AdditionalProperties[fieldName] = fieldVal + } + } + return nil +} + +// Override default JSON handling for TokenResponse to handle AdditionalProperties +func (a TokenResponse) MarshalJSON() ([]byte, error) { + var err error + object := make(map[string]json.RawMessage) + + object["access_token"], err = json.Marshal(a.AccessToken) + if err != nil { + return nil, fmt.Errorf("error marshaling 'access_token': %w", err) + } + + if a.ExpiresIn != nil { + object["expires_in"], err = json.Marshal(a.ExpiresIn) + if err != nil { + return nil, fmt.Errorf("error marshaling 'expires_in': %w", err) + } + } + + object["token_type"], err = json.Marshal(a.TokenType) + if err != nil { + return nil, fmt.Errorf("error marshaling 'token_type': %w", err) + } + + for fieldName, field := range a.AdditionalProperties { + object[fieldName], err = json.Marshal(field) + if err != nil { + return nil, fmt.Errorf("error marshaling '%s': %w", fieldName, err) + } + } + return json.Marshal(object) +} + +// ServerInterface represents all server handlers. +type ServerInterface interface { + // Used by clients to initiate the authorization code flow. + // (GET /public/auth/{did}/authorize) + HandleAuthorizeRequest(ctx echo.Context, did string) error + // Invoked by the user-agent to authorize/consent to authorization requests. + // (POST /public/auth/{did}/authz_consent) + HandleUserConsentRequest(ctx echo.Context, did string) error + // Used by to request access- or refresh tokens. + // (POST /public/auth/{did}/token) + HandleTokenRequest(ctx echo.Context, did string) error +} + +// ServerInterfaceWrapper converts echo contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface +} + +// HandleAuthorizeRequest converts echo context to params. +func (w *ServerInterfaceWrapper) HandleAuthorizeRequest(ctx echo.Context) error { + var err error + // ------------- Path parameter "did" ------------- + var did string + + err = runtime.BindStyledParameterWithLocation("simple", false, "did", runtime.ParamLocationPath, ctx.Param("did"), &did) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter did: %s", err)) + } + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.HandleAuthorizeRequest(ctx, did) + return err +} + +// HandleUserConsentRequest converts echo context to params. +func (w *ServerInterfaceWrapper) HandleUserConsentRequest(ctx echo.Context) error { + var err error + // ------------- Path parameter "did" ------------- + var did string + + err = runtime.BindStyledParameterWithLocation("simple", false, "did", runtime.ParamLocationPath, ctx.Param("did"), &did) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter did: %s", err)) + } + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.HandleUserConsentRequest(ctx, did) + return err +} + +// HandleTokenRequest converts echo context to params. +func (w *ServerInterfaceWrapper) HandleTokenRequest(ctx echo.Context) error { + var err error + // ------------- Path parameter "did" ------------- + var did string + + err = runtime.BindStyledParameterWithLocation("simple", false, "did", runtime.ParamLocationPath, ctx.Param("did"), &did) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter did: %s", err)) + } + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.HandleTokenRequest(ctx, did) + return err +} + +// This is a simple interface which specifies echo.Route addition functions which +// are present on both echo.Echo and echo.Group, since we want to allow using +// either of them for path registration +type EchoRouter interface { + CONNECT(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + DELETE(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + GET(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + HEAD(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + OPTIONS(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + PATCH(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + POST(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + PUT(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + TRACE(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route +} + +// RegisterHandlers adds each server route to the EchoRouter. +func RegisterHandlers(router EchoRouter, si ServerInterface) { + RegisterHandlersWithBaseURL(router, si, "") +} + +// Registers handlers, and prepends BaseURL to the paths, so that the paths +// can be served under a prefix. +func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL string) { + + wrapper := ServerInterfaceWrapper{ + Handler: si, + } + + router.GET(baseURL+"/public/auth/:did/authorize", wrapper.HandleAuthorizeRequest) + router.POST(baseURL+"/public/auth/:did/authz_consent", wrapper.HandleUserConsentRequest) + router.POST(baseURL+"/public/auth/:did/token", wrapper.HandleTokenRequest) + +} + +type HandleAuthorizeRequestRequestObject struct { + Did string `json:"did"` + Body *HandleAuthorizeRequestFormdataRequestBody +} + +type HandleAuthorizeRequestResponseObject interface { + VisitHandleAuthorizeRequestResponse(w http.ResponseWriter) error +} + +type HandleAuthorizeRequest200TexthtmlResponse struct { + Body io.Reader + ContentLength int64 +} + +func (response HandleAuthorizeRequest200TexthtmlResponse) VisitHandleAuthorizeRequestResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "text/html") + if response.ContentLength != 0 { + w.Header().Set("Content-Length", fmt.Sprint(response.ContentLength)) + } + w.WriteHeader(200) + + if closer, ok := response.Body.(io.ReadCloser); ok { + defer closer.Close() + } + _, err := io.Copy(w, response.Body) + return err +} + +type HandleAuthorizeRequest302ResponseHeaders struct { + Location string +} + +type HandleAuthorizeRequest302Response struct { + Headers HandleAuthorizeRequest302ResponseHeaders +} + +func (response HandleAuthorizeRequest302Response) VisitHandleAuthorizeRequestResponse(w http.ResponseWriter) error { + w.Header().Set("Location", fmt.Sprint(response.Headers.Location)) + w.WriteHeader(302) + return nil +} + +type HandleUserConsentRequestRequestObject struct { + Did string `json:"did"` + Body *HandleUserConsentRequestFormdataRequestBody +} + +type HandleUserConsentRequestResponseObject interface { + VisitHandleUserConsentRequestResponse(w http.ResponseWriter) error +} + +type HandleUserConsentRequest302ResponseHeaders struct { + Location string +} + +type HandleUserConsentRequest302Response struct { + Headers HandleUserConsentRequest302ResponseHeaders +} + +func (response HandleUserConsentRequest302Response) VisitHandleUserConsentRequestResponse(w http.ResponseWriter) error { + w.Header().Set("Location", fmt.Sprint(response.Headers.Location)) + w.WriteHeader(302) + return nil +} + +type HandleTokenRequestRequestObject struct { + Did string `json:"did"` + Body *HandleTokenRequestFormdataRequestBody +} + +type HandleTokenRequestResponseObject interface { + VisitHandleTokenRequestResponse(w http.ResponseWriter) error +} + +type HandleTokenRequest200JSONResponse TokenResponse + +func (response HandleTokenRequest200JSONResponse) VisitHandleTokenRequestResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type HandleTokenRequest400JSONResponse ErrorResponse + +func (response HandleTokenRequest400JSONResponse) VisitHandleTokenRequestResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + +type HandleTokenRequest404JSONResponse ErrorResponse + +func (response HandleTokenRequest404JSONResponse) VisitHandleTokenRequestResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(404) + + return json.NewEncoder(w).Encode(response) +} + +// StrictServerInterface represents all server handlers. +type StrictServerInterface interface { + // Used by clients to initiate the authorization code flow. + // (GET /public/auth/{did}/authorize) + HandleAuthorizeRequest(ctx context.Context, request HandleAuthorizeRequestRequestObject) (HandleAuthorizeRequestResponseObject, error) + // Invoked by the user-agent to authorize/consent to authorization requests. + // (POST /public/auth/{did}/authz_consent) + HandleUserConsentRequest(ctx context.Context, request HandleUserConsentRequestRequestObject) (HandleUserConsentRequestResponseObject, error) + // Used by to request access- or refresh tokens. + // (POST /public/auth/{did}/token) + HandleTokenRequest(ctx context.Context, request HandleTokenRequestRequestObject) (HandleTokenRequestResponseObject, error) +} + +type StrictHandlerFunc = runtime.StrictEchoHandlerFunc +type StrictMiddlewareFunc = runtime.StrictEchoMiddlewareFunc + +func NewStrictHandler(ssi StrictServerInterface, middlewares []StrictMiddlewareFunc) ServerInterface { + return &strictHandler{ssi: ssi, middlewares: middlewares} +} + +type strictHandler struct { + ssi StrictServerInterface + middlewares []StrictMiddlewareFunc +} + +// HandleAuthorizeRequest operation middleware +func (sh *strictHandler) HandleAuthorizeRequest(ctx echo.Context, did string) error { + var request HandleAuthorizeRequestRequestObject + + request.Did = did + + if form, err := ctx.FormParams(); err == nil { + var body HandleAuthorizeRequestFormdataRequestBody + 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.HandleAuthorizeRequest(ctx.Request().Context(), request.(HandleAuthorizeRequestRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "HandleAuthorizeRequest") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(HandleAuthorizeRequestResponseObject); ok { + return validResponse.VisitHandleAuthorizeRequestResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("Unexpected response type: %T", response) + } + return nil +} + +// HandleUserConsentRequest operation middleware +func (sh *strictHandler) HandleUserConsentRequest(ctx echo.Context, did string) error { + var request HandleUserConsentRequestRequestObject + + request.Did = did + + if form, err := ctx.FormParams(); err == nil { + var body HandleUserConsentRequestFormdataRequestBody + 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.HandleUserConsentRequest(ctx.Request().Context(), request.(HandleUserConsentRequestRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "HandleUserConsentRequest") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(HandleUserConsentRequestResponseObject); ok { + return validResponse.VisitHandleUserConsentRequestResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("Unexpected response type: %T", response) + } + return nil +} + +// HandleTokenRequest operation middleware +func (sh *strictHandler) HandleTokenRequest(ctx echo.Context, did string) error { + var request HandleTokenRequestRequestObject + + request.Did = did + + if form, err := ctx.FormParams(); err == nil { + var body HandleTokenRequestFormdataRequestBody + 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.HandleTokenRequest(ctx.Request().Context(), request.(HandleTokenRequestRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "HandleTokenRequest") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(HandleTokenRequestResponseObject); ok { + return validResponse.VisitHandleTokenRequestResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("Unexpected response type: %T", response) + } + return nil +} diff --git a/vcr/api/oauth2/v0/interface.go b/vcr/api/oauth2/v0/interface.go new file mode 100644 index 0000000000..1677c9dd35 --- /dev/null +++ b/vcr/api/oauth2/v0/interface.go @@ -0,0 +1,46 @@ +package v0 + +import ( + "github.com/google/uuid" + "github.com/nuts-foundation/nuts-node/core" + "sync" +) + +type protocol interface { + core.Routable + authzHandlers() []authzHandler + grantHandlers() map[string]grantHandler +} + +// authzHandler defines a function for checking authorization requests given the input parameters, used to initiate the authorization code flow. +type authzHandler func(map[string]string, *Session) (bool, error) + +// grantHandler defines a function for checking a grant given the input parameters, used to validate token requests. +// It returns the requested scopes if the validation succeeds. +type grantHandler func(map[string]string) (string, error) + +type SessionManager struct { + sessions *sync.Map +} + +func (s *SessionManager) Create(session Session) string { + id := uuid.NewString() + s.sessions.Store(id, session) + return id +} + +func (s *SessionManager) Get(id string) *Session { + session, ok := s.sessions.Load(id) + if !ok { + return nil + } + result := session.(Session) + return &result +} + +type Session struct { + ClientID string + Scope string + ClientState string + RedirectURI string +} diff --git a/vcr/api/oauth2/v0/openid4vp.go b/vcr/api/oauth2/v0/openid4vp.go new file mode 100644 index 0000000000..85397319e6 --- /dev/null +++ b/vcr/api/oauth2/v0/openid4vp.go @@ -0,0 +1,46 @@ +package v0 + +import ( + "github.com/nuts-foundation/nuts-node/core" +) + +var _ protocol = (*openID4VP)(nil) + +// openID4VP implements verifiable presentation exchanges as specified by https://openid.net/specs/openid-4-verifiable-presentations-1_0.html. +type openID4VP struct { +} + +func (o openID4VP) Routes(router core.EchoRouter) { + //TODO implement me + panic("implement me") +} + +func (o openID4VP) authzHandlers() []authzHandler { + return []authzHandler{ + o.handleAuthzRequest, + } +} + +func (o openID4VP) handleAuthzRequest(params map[string]string, session *Session) (bool, error) { + presentationDef := params["presentation_definition"] + presentationDefUri := params["presentation_definition_uri"] + clientIdScheme := params["client_id_scheme"] + clientMetadata := params["client_metadata"] + clientMetadataUri := params["client_metadata_uri"] + + if presentationDef == "" && + presentationDefUri == "" && + clientIdScheme == "" && + clientMetadata == "" && + clientMetadataUri == "" { + // Not an OpenID4VP Authorization Request + return false, nil + } + // TODO: Handle the request + return true, nil +} + +func (o openID4VP) grantHandlers() map[string]grantHandler { + // OpenID4VP does not define new grant types + return nil +} diff --git a/vcr/api/oauth2/v0/s2s_vptoken.go b/vcr/api/oauth2/v0/s2s_vptoken.go new file mode 100644 index 0000000000..941d651012 --- /dev/null +++ b/vcr/api/oauth2/v0/s2s_vptoken.go @@ -0,0 +1,50 @@ +package v0 + +import ( + "errors" + "github.com/labstack/echo/v4" + "github.com/nuts-foundation/nuts-node/core" + "net/http" +) + +var _ protocol = (*serviceToService)(nil) + +// serviceToService adds support for service-to-service OAuth2 flows, +// which uses a custom vp_token grant to authenticate calls to the token endpoint. +// Clients first call the presentation definition endpoint to get a presentation definition for the desired scope, +// then create a presentation submission given the definition which is posted to the token endpoint as vp_token. +// The AS then returns an access token with the requested scope. +// Requires: +// - GET /presentation_definition?scope=... (returns a presentation definition) +// - POST /token (with vp_token grant) +type serviceToService struct { +} + +func (s serviceToService) Routes(router core.EchoRouter) { + router.Add("GET", "/public/auth/{did}/presentation_definition", func(echoCtx echo.Context) error { + // TODO: Read scope, map to presentation definition, return + return echoCtx.JSON(http.StatusOK, map[string]string{}) + }) +} + +func (s serviceToService) authzHandlers() []authzHandler { + return nil +} + +func (s serviceToService) grantHandlers() map[string]grantHandler { + return map[string]grantHandler{ + "vp_token": s.validateVPToken, + } +} + +func (s serviceToService) validateVPToken(params map[string]string) (string, error) { + submission := params["presentation_submission"] + scope := params["scope"] + vp_token := params["vp_token"] + if submission == "" || scope == "" || vp_token == "" { + // TODO: right error response + return "", errors.New("missing required parameters") + } + // TODO: verify parameters + return scope, nil +} diff --git a/vcr/openid4vci/error.go b/vcr/openid4vci/error.go index 6c61c3dbc1..679f065b5f 100644 --- a/vcr/openid4vci/error.go +++ b/vcr/openid4vci/error.go @@ -18,6 +18,8 @@ package openid4vci +import "strings" + // ErrorCode specifies error codes as defined by the OpenID4VCI spec. type ErrorCode string @@ -60,6 +62,10 @@ type Error struct { CNonceExpiresIn *int `json:"c_nonce_expires_in,omitempty"` // Code is the error code as defined by the OpenID4VCI spec. Code ErrorCode `json:"error"` + // Description an optional, is a human-readable ASCII [USASCII] text providing additional information, + // used to assist the client developer in understanding the error that occurred. + // It is sent back to the client. + Description string `json:"error_description,omitempty"` // Err is the underlying error, may be omitted. It is not intended to be returned to the client. Err error `json:"-"` // StatusCode is the HTTP status code that should be returned to the client. @@ -68,8 +74,13 @@ type Error struct { // Error returns the error message, which is either the underlying error or the code if there is no underlying error func (e Error) Error() string { - if e.Err == nil { - return string(e.Code) + var parts []string + parts = append(parts, string(e.Code)) + if e.Err != nil { + parts = append(parts, e.Err.Error()) + } + if e.Description != "" { + parts = append(parts, e.Description) } - return string(e.Code) + " - " + e.Err.Error() + return strings.Join(parts, " - ") }