nuts-foundation/nuts-node

View on GitHub
auth/api/iam/dpop.go

Summary

Maintainability
A
0 mins
Test Coverage
A
90%
/*
 * Nuts node
 * Copyright (C) 2024 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 iam

import (
    "context"
    "encoding/base64"
    "errors"
    "fmt"
    "net/http"

    "github.com/nuts-foundation/go-did/did"
    "github.com/nuts-foundation/nuts-node/auth/log"
    "github.com/nuts-foundation/nuts-node/auth/oauth"
    "github.com/nuts-foundation/nuts-node/core"
    "github.com/nuts-foundation/nuts-node/crypto/dpop"
    nutsHash "github.com/nuts-foundation/nuts-node/crypto/hash"
    "github.com/nuts-foundation/nuts-node/storage"
    "github.com/nuts-foundation/nuts-node/vdr/resolver"
)

func (r Wrapper) CreateDPoPProof(ctx context.Context, request CreateDPoPProofRequestObject) (CreateDPoPProofResponseObject, error) {
    // check method and url
    if request.Body.Htm == "" {
        return nil, core.InvalidInputError("missing method")
    }
    if request.Body.Htu == "" {
        return nil, core.InvalidInputError("missing url")
    }
    // check access token status
    if request.Body.Token == "" {
        return nil, core.InvalidInputError("missing token")
    }

    // extract DID from request path
    ownDID, err := r.toOwnedDID(ctx, request.Did)
    if err != nil {
        return nil, err
    }
    // create new DPoP header
    httpRequest, err := http.NewRequest(request.Body.Htm, request.Body.Htu, nil)
    if err != nil {
        return nil, core.InvalidInputError(err.Error())
    }
    dpop, err := r.DPoPProof(ctx, *ownDID, *httpRequest, request.Body.Token)
    return CreateDPoPProof200JSONResponse{Dpop: dpop}, err
}

func (r Wrapper) ValidateDPoPProof(_ context.Context, request ValidateDPoPProofRequestObject) (ValidateDPoPProofResponseObject, error) {
    dpopToken, err := dpop.Parse(request.Body.DpopProof)
    if err != nil {
        reason := fmt.Sprintf("failed to parse DPoP header: %s", err.Error())
        return ValidateDPoPProof200JSONResponse{Reason: &reason}, nil
    }
    if ok, err := dpopToken.Match(request.Body.Thumbprint, request.Body.Method, request.Body.Url); !ok {
        reason := err.Error()
        return ValidateDPoPProof200JSONResponse{Reason: &reason}, nil
    }
    // check if ath claim matches hash of access_token
    ath, ok := dpopToken.Token.Get(dpop.ATHKey)
    if !ok {
        reason := "missing ath claim"
        return ValidateDPoPProof200JSONResponse{Reason: &reason}, nil
    }
    hash := nutsHash.SHA256Sum([]byte(request.Body.Token))
    if ath != base64.RawURLEncoding.EncodeToString(hash.Slice()) {
        reason := "ath/token claim mismatch"
        return ValidateDPoPProof200JSONResponse{Reason: &reason}, nil
    }
    // check if the jti is already used, if not add it to the store for the duration of the access token lifetime
    var target struct{}
    if err := r.useNonceOnceStore().Get(dpopToken.Token.JwtID(), &target); err != nil {
        if !errors.Is(err, storage.ErrNotFound) {
            log.Logger().WithError(err).Error("ValidateDPoPProof: failed to retrieve jti usage state")
            return nil, err
        }
        if err := r.useNonceOnceStore().Put(dpopToken.Token.JwtID(), target); err != nil {
            log.Logger().WithError(err).Error("ValidateDPoPProof: failed to store jti usage state")
            return nil, err
        }
    } else {
        // jti already used
        reason := "jti already used"
        return ValidateDPoPProof200JSONResponse{Reason: &reason}, nil
    }

    return ValidateDPoPProof200JSONResponse{Valid: true}, nil
}

func (r *Wrapper) DPoPProof(ctx context.Context, requester did.DID, request http.Request, accessToken string) (string, error) {
    // find the key to sign the DPoP token with
    keyResolver := resolver.DIDKeyResolver{Resolver: r.vdr.Resolver()}
    keyID, _, err := keyResolver.ResolveKey(requester, nil, resolver.AssertionMethod)
    if err != nil {
        return "", err
    }

    token := dpop.New(request)
    token.GenerateProof(accessToken)
    return r.jwtSigner.SignDPoP(ctx, *token, keyID.String())
}

func dpopFromRequest(httpRequest http.Request) (*dpop.DPoP, error) {
    dpopHeader := httpRequest.Header.Get("DPoP")
    // optional header
    if dpopHeader == "" {
        return nil, nil
    }
    // parse and validate DPoP header
    dpopProof, err := dpop.Parse(dpopHeader)
    if err != nil {
        return nil, oauth.OAuth2Error{
            Code:          oauth.InvalidDPopProof,
            Description:   "DPoP header is invalid",
            InternalError: err,
        }
    }
    return dpopProof, nil
}

// useNonceOnceStore is used to store nonces that are used once, e.g. DPoP jti
// it uses the access token validity as the expiration time
func (r Wrapper) useNonceOnceStore() storage.SessionStore {
    return r.storageEngine.GetSessionDatabase().GetStore(accessTokenValidity, "nonceonce")
}