api/v0/api.go
/*
* Nuts auth
* Copyright (C) 2020. Nuts community
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package v0
import (
"errors"
"fmt"
"net/http"
"net/url"
"regexp"
"strings"
"time"
"github.com/nuts-foundation/nuts-auth/logging"
"github.com/nuts-foundation/nuts-auth/pkg/services/irma"
"github.com/nuts-foundation/nuts-auth/pkg/services/validator"
nutsRegistry "github.com/nuts-foundation/nuts-registry/pkg"
"github.com/labstack/echo/v4"
core "github.com/nuts-foundation/nuts-go-core"
"github.com/nuts-foundation/nuts-auth/pkg"
"github.com/nuts-foundation/nuts-auth/pkg/contract"
"github.com/nuts-foundation/nuts-auth/pkg/services"
)
// Wrapper bridges the generated api types and http logic to the internal types and logic.
// It checks required parameters and message body. It converts data from api to internal types.
// Then passes the internal formats to the AuthClient. Converts internal results back to the generated
// Api types. Handles errors and returns the correct http response. It does not perform any business logic.
//
// This wrapper handles the unversioned, so called v0, API requests. Most of them wil be deprecated and moved to a v1 version
type Wrapper struct {
Auth pkg.AuthClient
}
const errOauthInvalidRequest = "invalid_request"
const errOauthInvalidGrant = "invalid_grant"
const errOauthUnsupportedGrant = "unsupported_grant_type"
// CreateSession translates http params to internal format, creates a IRMA signing session
// and returns the session pointer to the HTTP stack.
func (api *Wrapper) CreateSession(ctx echo.Context) error {
// bind params to a generated api format struct
var params ContractSigningRequest
if err := ctx.Bind(¶ms); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Could not parse request body: %s", err))
}
validFrom, _, validDuration, err := parsePeriodParams(params)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
orgID, err := core.ParsePartyID(string(params.LegalEntity))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid value for param legalEntity: '%s', make sure its in the form 'urn:oid:1.2.3.4:foo'", params.LegalEntity))
}
template := contract.StandardContractTemplates.Get(contract.Type(params.Type), contract.Language(params.Language), contract.Version(params.Version))
if template == nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Unable to find contract: %s", params.Type))
}
drawnUpContract, err := api.Auth.ContractNotary().DrawUpContract(*template, orgID, validFrom, validDuration)
if err != nil {
if errors.Is(err, validator.ErrMissingOrganizationKey) {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Unknown legalEntity, this Nuts node does not seem to be managing '%s'", orgID))
}
if errors.Is(err, nutsRegistry.ErrOrganizationNotFound) {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("No organization registered for legalEntity: %s", orgID))
}
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Unable to draw up contract: %s", err.Error()))
}
sessionRequest := services.CreateSessionRequest{SigningMeans: "irma", Message: drawnUpContract.RawContractText}
// Initiate the actual session
result, err := api.Auth.ContractClient().CreateSigningSession(sessionRequest)
if err != nil {
logging.Log().WithError(err).Error("error while creating contract session")
return err
}
// backwards compatibility
irmaResult := result.(irma.SessionPtr)
// convert internal result back to generated api format
answer := CreateSessionResult{
QrCodeInfo: IrmaQR{U: irmaResult.QrCodeInfo.URL, Irmaqr: string(irmaResult.QrCodeInfo.Type)},
SessionId: result.SessionID(),
}
return ctx.JSON(http.StatusCreated, answer)
}
func parsePeriodParams(params ContractSigningRequest) (time.Time, time.Time, time.Duration, error) {
var (
validFrom, validTo time.Time
d time.Duration
err error
)
if params.ValidFrom != nil {
validFrom, err = time.Parse(time.RFC3339, *params.ValidFrom)
if err != nil {
return time.Time{}, time.Time{}, 0, fmt.Errorf("could not parse validFrom: %v", err)
}
}
if params.ValidTo != nil {
validTo, err = time.Parse(time.RFC3339, *params.ValidTo)
if err != nil {
return time.Time{}, time.Time{}, 0, fmt.Errorf("could not parse validTo: %v", err)
}
d = validTo.Sub(validFrom)
}
return validFrom, validTo, d, nil
}
// SessionRequestStatus gets the current status or the IRMA signing session,
// it translates the result to the api format and returns it to the HTTP stack
// If the session is not found it returns a 404
func (api *Wrapper) SessionRequestStatus(ctx echo.Context, sessionID string) error {
sessionStatus, err := api.Auth.ContractClient().ContractSessionStatus(sessionID)
if err != nil {
if errors.Is(err, services.ErrSessionNotFound) {
return echo.NewHTTPError(http.StatusNotFound, err.Error())
}
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
// convert internal result back to generated api format
var disclosedAttributes []DisclosedAttribute
if len(sessionStatus.Disclosed) > 0 {
for _, attr := range sessionStatus.Disclosed[0] {
value := make(map[string]interface{})
for key, val := range map[string]string(attr.Value) {
value[key] = val
}
disclosedAttributes = append(disclosedAttributes, DisclosedAttribute{
Identifier: attr.Identifier.String(),
Value: value,
Rawvalue: attr.RawValue,
Status: string(attr.Status),
})
}
}
nutsAuthToken := sessionStatus.NutsAuthToken
proofStatus := string(sessionStatus.ProofStatus)
answer := SessionResult{
Disclosed: &disclosedAttributes,
Status: string(sessionStatus.Status),
Token: sessionStatus.Token,
Type: string(sessionStatus.Type),
}
if nutsAuthToken != "" {
answer.NutsAuthToken = &nutsAuthToken
}
if proofStatus != "" {
answer.ProofStatus = &proofStatus
}
return ctx.JSON(http.StatusOK, answer)
}
// ValidateContract first translates the request params to an internal format, it then
// calls the engine's validator and translates the results to the API format and returns
// the answer to the HTTP stack
func (api *Wrapper) ValidateContract(ctx echo.Context) error {
params := &ValidationRequest{}
if err := ctx.Bind(params); err != nil {
return err
}
logging.Log().Debug(params)
validationRequest := services.ValidationRequest{
ContractFormat: services.ContractFormat(params.ContractFormat),
ContractString: params.ContractString,
ActingPartyCN: params.ActingPartyCn,
}
validationResponse, err := api.Auth.ContractClient().ValidateContract(validationRequest)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
// convert internal result back to API format
signerAttributes := make(map[string]interface{})
for k, v := range validationResponse.DisclosedAttributes {
signerAttributes[k] = v
}
answer := ValidationResult{
ContractFormat: string(validationResponse.ContractFormat),
SignerAttributes: signerAttributes,
ValidationResult: string(validationResponse.ValidationResult),
}
return ctx.JSON(http.StatusOK, answer)
}
// GetContractByType calls the engines GetContractByType and translate the answer to
// the API format and returns the the answer back to the HTTP stack
func (api *Wrapper) GetContractByType(ctx echo.Context, contractType string, params GetContractByTypeParams) error {
// convert generated data types to internal types
var (
contractLanguage contract.Language
contractVersion contract.Version
)
if params.Language != nil {
contractLanguage = contract.Language(*params.Language)
}
if params.Version != nil {
contractVersion = contract.Version(*params.Version)
}
// get contract
authContract := contract.StandardContractTemplates.Get(contract.Type(contractType), contractLanguage, contractVersion)
if authContract == nil {
return echo.NewHTTPError(http.StatusNotFound, "could not found contract template")
}
// convert internal data types to generated api types
answer := Contract{
Language: Language(authContract.Language),
Template: &authContract.Template,
TemplateAttributes: &authContract.TemplateAttributes,
Type: Type(authContract.Type),
Version: Version(authContract.Version),
}
return ctx.JSON(http.StatusOK, answer)
}
// CreateAccessToken handles the api call to create an access token.
// It consumes and checks the JWT and returns a smaller sessionToken
func (api *Wrapper) CreateAccessToken(ctx echo.Context, params CreateAccessTokenParams) (err error) {
// Can't use echo.Bind() here since it requires extra tags on generated code
request := new(CreateAccessTokenRequest)
request.Assertion = ctx.FormValue("assertion")
request.GrantType = ctx.FormValue("grant_type")
if request.GrantType != pkg.JwtBearerGrantType {
errDesc := fmt.Sprintf("grant_type must be: '%s'", pkg.JwtBearerGrantType)
errorResponse := AccessTokenRequestFailedResponse{Error: errOauthUnsupportedGrant, ErrorDescription: errDesc}
return ctx.JSON(http.StatusBadRequest, errorResponse)
}
const jwtPattern = `^[A-Za-z0-9-_=]+\.[A-Za-z0-9-_=]+\.?[A-Za-z0-9-_.+/=]*$`
if matched, err := regexp.Match(jwtPattern, []byte(request.Assertion)); !matched || err != nil {
errDesc := "Assertion must be a valid encoded jwt"
errorResponse := AccessTokenRequestFailedResponse{Error: errOauthInvalidGrant, ErrorDescription: errDesc}
return ctx.JSON(http.StatusBadRequest, errorResponse)
}
if params.XSslClientCert == "" {
errDesc := "Client certificate missing in header"
errorResponse := AccessTokenRequestFailedResponse{Error: errOauthInvalidRequest, ErrorDescription: errDesc}
return ctx.JSON(http.StatusBadRequest, errorResponse)
}
cert, err := url.PathUnescape(params.XSslClientCert)
if err != nil {
errDesc := "corrupted client certificate header"
errorResponse := AccessTokenRequestFailedResponse{Error: errOauthInvalidRequest, ErrorDescription: errDesc}
return ctx.JSON(http.StatusBadRequest, errorResponse)
}
catRequest := services.CreateAccessTokenRequest{RawJwtBearerToken: request.Assertion, VendorIdentifier: params.XNutsLegalEntity, ClientCert: cert}
acResponse, err := api.Auth.OAuthClient().CreateAccessToken(catRequest)
if err != nil {
errDesc := err.Error()
errorResponse := AccessTokenRequestFailedResponse{Error: errOauthInvalidRequest, ErrorDescription: errDesc}
return ctx.JSON(http.StatusBadRequest, errorResponse)
}
response := AccessTokenResponse{AccessToken: acResponse.AccessToken}
return ctx.JSON(http.StatusOK, response)
}
// CreateJwtBearerToken fills a CreateJwtBearerTokenRequest from the request body and passes it to the auth module.
func (api *Wrapper) CreateJwtBearerToken(ctx echo.Context) error {
requestBody := &CreateJwtBearerTokenRequest{}
if err := ctx.Bind(requestBody); err != nil {
return err
}
request := services.CreateJwtBearerTokenRequest{
Actor: requestBody.Actor,
Custodian: requestBody.Custodian,
IdentityToken: &requestBody.Identity,
Subject: requestBody.Subject,
}
response, err := api.Auth.OAuthClient().CreateJwtBearerToken(request)
if err != nil {
return ctx.JSON(http.StatusBadRequest, err.Error())
}
return ctx.JSON(http.StatusOK, JwtBearerTokenResponse{BearerToken: response.BearerToken})
}
// IntrospectAccessToken takes the access token from the request form value and passes it to the auth client.
func (api *Wrapper) IntrospectAccessToken(ctx echo.Context) error {
token := ctx.FormValue("token")
introspectionResponse := TokenIntrospectionResponse{
Active: false,
}
if len(token) == 0 {
return ctx.JSON(http.StatusOK, introspectionResponse)
}
claims, err := api.Auth.OAuthClient().IntrospectAccessToken(token)
if err != nil {
logging.Log().WithError(err).Debug("Error while inspecting access token")
return ctx.JSON(http.StatusOK, introspectionResponse)
}
exp := int(claims.ExpiresAt)
iat := int(claims.IssuedAt)
introspectionResponse = TokenIntrospectionResponse{
Active: true,
Sub: &claims.Subject,
Iss: &claims.Issuer,
Aud: &claims.Audience,
Exp: &exp,
Iat: &iat,
Sid: claims.SubjectID,
Scope: &claims.Scope,
Name: &claims.Name,
GivenName: &claims.GivenName,
Prefix: &claims.Prefix,
FamilyName: &claims.FamilyName,
Email: &claims.Email,
}
return ctx.JSON(http.StatusOK, introspectionResponse)
}
const bearerPrefix = "bearer "
// VerifyAccessToken verifies if a request contains a valid bearer token issued by this server
func (api *Wrapper) VerifyAccessToken(ctx echo.Context, params VerifyAccessTokenParams) error {
if len(params.Authorization) == 0 {
logging.Log().Warn("No authorization header given")
return ctx.NoContent(http.StatusForbidden)
}
index := strings.Index(strings.ToLower(params.Authorization), bearerPrefix)
if index != 0 {
logging.Log().Warn("Authorization does not contain bearer token")
return ctx.NoContent(http.StatusForbidden)
}
token := params.Authorization[len(bearerPrefix):]
_, err := api.Auth.OAuthClient().IntrospectAccessToken(token)
if err != nil {
logging.Log().WithError(err).Warn("Error while inspecting access token")
return ctx.NoContent(http.StatusForbidden)
}
return ctx.NoContent(200)
}