Skip to content

feat(libp2phttp): More ergonomic auth #3188

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Feb 25, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions p2p/http/auth/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ func TestMutualAuth(t *testing.T) {
req.Host = "example.com"
serverID, resp, err = clientAuth.AuthenticatedDo(client, req)
require.NotEmpty(t, req.Header.Get("Authorization"))
require.True(t, HasAuthHeader(req))
require.NoError(t, err)
require.Equal(t, expectedServerID, serverID)
require.NotZero(t, clientAuth.tm.tokenMap["example.com"])
Expand Down
29 changes: 23 additions & 6 deletions p2p/http/auth/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,25 @@ type ClientPeerIDAuth struct {
tm tokenMap
}

type clientAsRoundTripper struct {
*http.Client
}

func (c clientAsRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
return c.Client.Do(req)
}

// AuthenticatedDo is like http.Client.Do, but it does the libp2p peer ID auth
// handshake if needed.
//
// It is recommended to pass in an http.Request with `GetBody` set, so that this
// method can retry sending the request in case a previously used token has
// expired.
func (a *ClientPeerIDAuth) AuthenticatedDo(client *http.Client, req *http.Request) (peer.ID, *http.Response, error) {
return a.AuthenticateWithRoundTripper(clientAsRoundTripper{client}, req)
}

func (a *ClientPeerIDAuth) AuthenticateWithRoundTripper(rt http.RoundTripper, req *http.Request) (peer.ID, *http.Response, error) {
hostname := req.Host
ti, hasToken := a.tm.get(hostname, a.TokenTTL)
handshake := handshake.PeerIDAuthHandshakeClient{
Expand All @@ -36,7 +48,7 @@ func (a *ClientPeerIDAuth) AuthenticatedDo(client *http.Client, req *http.Reques

if hasToken {
// We have a token. Attempt to use that, but fallback to server initiated challenge if it fails.
peer, resp, err := a.doWithToken(client, req, ti)
peer, resp, err := a.doWithToken(rt, req, ti)
switch {
case err == nil:
return peer, resp, nil
Expand All @@ -62,7 +74,7 @@ func (a *ClientPeerIDAuth) AuthenticatedDo(client *http.Client, req *http.Reques
handshake.SetInitiateChallenge()
}

serverPeerID, resp, err := a.runHandshake(client, req, clearBody(req), &handshake)
serverPeerID, resp, err := a.runHandshake(rt, req, clearBody(req), &handshake)
if err != nil {
return "", nil, fmt.Errorf("failed to run handshake: %w", err)
}
Expand All @@ -74,7 +86,12 @@ func (a *ClientPeerIDAuth) AuthenticatedDo(client *http.Client, req *http.Reques
return serverPeerID, resp, nil
}

func (a *ClientPeerIDAuth) runHandshake(client *http.Client, req *http.Request, b bodyMeta, hs *handshake.PeerIDAuthHandshakeClient) (peer.ID, *http.Response, error) {
func (a *ClientPeerIDAuth) HasToken(hostname string) bool {
_, hasToken := a.tm.get(hostname, a.TokenTTL)
return hasToken
}

func (a *ClientPeerIDAuth) runHandshake(rt http.RoundTripper, req *http.Request, b bodyMeta, hs *handshake.PeerIDAuthHandshakeClient) (peer.ID, *http.Response, error) {
maxSteps := 5 // Avoid infinite loops in case of buggy handshake. Shouldn't happen.
var resp *http.Response

Expand All @@ -92,7 +109,7 @@ func (a *ClientPeerIDAuth) runHandshake(client *http.Client, req *http.Request,
b.setBody(req)
}

resp, err = client.Do(req)
resp, err = rt.RoundTrip(req)
if err != nil {
return "", nil, err
}
Expand All @@ -119,10 +136,10 @@ func (a *ClientPeerIDAuth) runHandshake(client *http.Client, req *http.Request,

var errTokenRejected = errors.New("token rejected")

func (a *ClientPeerIDAuth) doWithToken(client *http.Client, req *http.Request, ti tokenInfo) (peer.ID, *http.Response, error) {
func (a *ClientPeerIDAuth) doWithToken(rt http.RoundTripper, req *http.Request, ti tokenInfo) (peer.ID, *http.Response, error) {
// Try to make the request with the token
req.Header.Set("Authorization", ti.token)
resp, err := client.Do(req)
resp, err := rt.RoundTrip(req)
if err != nil {
return "", nil, err
}
Expand Down
18 changes: 15 additions & 3 deletions p2p/http/auth/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"errors"
"hash"
"net/http"
"strings"
"sync"
"time"

Expand Down Expand Up @@ -60,6 +61,10 @@ type ServerPeerIDAuth struct {
// scheme. If a Next handler is set, it will be called on authenticated
// requests.
func (a *ServerPeerIDAuth) ServeHTTP(w http.ResponseWriter, r *http.Request) {
a.ServeHTTPWithNextHandler(w, r, a.Next)
}

func (a *ServerPeerIDAuth) ServeHTTPWithNextHandler(w http.ResponseWriter, r *http.Request, next func(peer.ID, http.ResponseWriter, *http.Request)) {
a.initHmac.Do(func() {
if a.HmacKey == nil {
key := make([]byte, 32)
Expand Down Expand Up @@ -130,7 +135,7 @@ func (a *ServerPeerIDAuth) ServeHTTP(w http.ResponseWriter, r *http.Request) {
TokenTTL: a.TokenTTL,
Hmac: hmac,
}
hs.Run()
_ = hs.Run() // First run will never err
hs.SetHeader(w.Header())
w.WriteHeader(http.StatusUnauthorized)

Expand All @@ -149,9 +154,16 @@ func (a *ServerPeerIDAuth) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}

if a.Next == nil {
if next == nil {
w.WriteHeader(http.StatusOK)
return
}
a.Next(peer, w, r)
next(peer, w, r)
}

// HasAuthHeader checks if the HTTP request contains an Authorization header
// that starts with the PeerIDAuthScheme prefix.
func HasAuthHeader(r *http.Request) bool {
h := r.Header.Get("Authorization")
return h != "" && strings.HasPrefix(h, handshake.PeerIDAuthScheme)
}
73 changes: 73 additions & 0 deletions p2p/http/example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,86 @@ import (
"net/http"
"regexp"
"strings"
"time"

"github.com/libp2p/go-libp2p"
"github.com/libp2p/go-libp2p/core/crypto"
"github.com/libp2p/go-libp2p/core/peer"
libp2phttp "github.com/libp2p/go-libp2p/p2p/http"
httpauth "github.com/libp2p/go-libp2p/p2p/http/auth"
ma "github.com/multiformats/go-multiaddr"
)

func ExampleHost_authenticatedHTTP() {
clientKey, _, err := crypto.GenerateKeyPair(crypto.Ed25519, 0)
if err != nil {
log.Fatal(err)
}
client := libp2phttp.Host{
ClientPeerIDAuth: &httpauth.ClientPeerIDAuth{
TokenTTL: time.Hour,
PrivKey: clientKey,
},
}

serverKey, _, err := crypto.GenerateKeyPair(crypto.Ed25519, 0)
if err != nil {
log.Fatal(err)
}
server := libp2phttp.Host{
ServerPeerIDAuth: &httpauth.ServerPeerIDAuth{
PrivKey: serverKey,
// No TLS for this example. In practice you want to use TLS.
NoTLS: true,
ValidHostnameFn: func(hostname string) bool {
return strings.HasPrefix(hostname, "127.0.0.1")
},
TokenTTL: time.Hour,
},
// No TLS for this example. In practice you want to use TLS.
InsecureAllowHTTP: true,
ListenAddrs: []ma.Multiaddr{ma.StringCast("/ip4/127.0.0.1/tcp/0/http")},
}

observedClientID := ""
server.SetHTTPHandler("/echo-id", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
observedClientID = libp2phttp.ClientPeerID(r).String()
w.WriteHeader(http.StatusOK)
}))

go server.Serve()
defer server.Close()

expectedServerID, err := peer.IDFromPrivateKey(serverKey)
if err != nil {
log.Fatal(err)
}

httpClient := http.Client{Transport: &client}
url := fmt.Sprintf("multiaddr:%s/p2p/%s/http-path/echo-id", server.Addrs()[0], expectedServerID)
resp, err := httpClient.Get(url)
if err != nil {
log.Fatal(err)
}
resp.Body.Close()

expectedClientID, err := peer.IDFromPrivateKey(clientKey)
if err != nil {
log.Fatal(err)
}
if observedClientID != expectedClientID.String() {
log.Fatal("observedClientID does not match expectedClientID")
}

observedServerID := libp2phttp.ServerPeerID(resp)
if observedServerID != expectedServerID {
log.Fatal("observedServerID does not match expectedServerID")
}

fmt.Println("Successfully authenticated HTTP request")
// Output: Successfully authenticated HTTP request
}

func ExampleHost_withAStockGoHTTPClient() {
server := libp2phttp.Host{
InsecureAllowHTTP: true, // For our example, we'll allow insecure HTTP
Expand Down
Loading
Loading