authn

package module
v0.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 2, 2024 License: Apache-2.0 Imports: 7 Imported by: 16

README

authn

Build Report Card GoDoc Slack

connectrpc.com/authn provides authentication middleware for Connect. It works with any authentication scheme (including HTTP basic authentication, cookies, bearer tokens, and mutual TLS), and it's carefully designed to minimize the resource consumption of unauthenticated RPCs. Middleware built with authn covers both unary and streaming RPCs made with the Connect, gRPC, and gRPC-Web protocols.

For more on Connect, see the announcement blog post, the documentation on connectrpc.com (especially the Getting Started guide for Go), the demo service, or the protocol specification.

A small example

Curious what all this looks like in practice? From a Protobuf schema, we generate a small RPC package. Using that package, we can build a server and wrap it with some basic authentication:

package main

import (
  "context"
  "crypto/subtle"
  "net/http"

  "connectrpc.com/authn"
  "connectrpc.com/authn/internal/gen/authn/ping/v1/pingv1connect"
)

func authenticate(_ context.Context, req *http.Request) (any, error) {
  username, password, ok := req.BasicAuth()
  if !ok {
    return nil, authn.Errorf("invalid authorization")
  }
  if !equal(password, "open-sesame") {
    return nil, authn.Errorf("invalid password")
  }
  // The request is authenticated! We can propagate the authenticated user to
  // Connect interceptors and services by returning it: the middleware we're
  // about to construct will attach it to the context automatically.
  return username, nil
}

func equal(left, right string) bool {
  // Using subtle prevents some timing attacks.
  return subtle.ConstantTimeCompare([]byte(left), []byte(right)) == 1
}

func main() {
  mux := http.NewServeMux()
  service := &pingv1connect.UnimplementedPingServiceHandler{}
  mux.Handle(pingv1connect.NewPingServiceHandler(service))

  middleware := authn.NewMiddleware(authenticate)
  handler := middleware.Wrap(mux)
  http.ListenAndServe("localhost:8080", handler)
}

Cookie and token-based authentication is similar. Mutual TLS is a bit more complex, but pkg.go.dev includes a complete example.

Ecosystem

  • connect-go: the Go implementation of Connect's RPC runtime
  • examples-go: service powering demo.connectrpc.com, including bidi streaming
  • grpchealth: gRPC-compatible health checks
  • grpcreflect: gRPC-compatible server reflection
  • cors: CORS support for Connect servers
  • connect-es: Type-safe APIs with Protobuf and TypeScript
  • conformance: Connect, gRPC, and gRPC-Web interoperability tests

Status: Unstable

This module isn't stable yet, but it's fairly small — we expect to reach a stable release quickly.

It supports the three most recent major releases of Go. Keep in mind that only the last two releases receive security patches.

Within those parameters, authn follows semantic versioning. We will not make breaking changes in the 1.x series of releases.

Offered under the Apache 2 license.

Documentation

Overview

Package authn provides authentication middleware for connect.

Example (BasicAuth)
package main

import (
	"context"
	"crypto/subtle"
	"encoding/base64"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"

	"connectrpc.com/authn"
	pingv1 "connectrpc.com/authn/internal/gen/authn/ping/v1"
	"connectrpc.com/authn/internal/gen/authn/ping/v1/pingv1connect"
	"connectrpc.com/connect"
)

func main() {
	// This example shows how to use this package with HTTP basic authentication.
	// Any header-based authentication (including cookies and bearer tokens)
	// works similarly.

	// First, we define our authentication logic and use it to build middleware.
	authenticate := func(_ context.Context, req *http.Request) (any, error) {
		username, password, ok := req.BasicAuth()
		if !ok {
			return nil, authn.Errorf("invalid authorization")
		}
		if !equal(password, "open-sesame") {
			return nil, authn.Errorf("invalid password")
		}
		// The request is authenticated! We can propagate the authenticated user to
		// Connect interceptors and services by returning it: the middleware we're
		// about to construct will attach it to the context automatically.
		fmt.Println("authenticated request from", username)
		return username, nil
	}
	middleware := authn.NewMiddleware(authenticate)

	// Next, we build our Connect handler.
	mux := http.NewServeMux()
	service := &pingHandler{}
	mux.Handle(pingv1connect.NewPingServiceHandler(service))

	// Finally, we wrap the handler with our middleware and start our server.
	handler := middleware.Wrap(mux)
	server := httptest.NewServer(handler)
	defer server.Close()

	// Clients authenticate by setting the standard Authorization header.
	client := pingv1connect.NewPingServiceClient(http.DefaultClient, server.URL)
	req := connect.NewRequest(&pingv1.PingRequest{})
	req.Header().Set(
		"Authorization",
		"Basic "+base64.StdEncoding.EncodeToString([]byte("Aladdin:open-sesame")),
	)
	_, err := client.Ping(context.Background(), req)
	if err != nil {
		fmt.Printf("unexpected error: %v\n", err)
		return
	}
	fmt.Println("client received response")

}

func equal(left, right string) bool {

	return subtle.ConstantTimeCompare([]byte(left), []byte(right)) == 1
}

type pingHandler struct {
	pingv1connect.UnimplementedPingServiceHandler
}

func (pingHandler) Ping(_ context.Context, req *connect.Request[pingv1.PingRequest]) (*connect.Response[pingv1.PingResponse], error) {
	return connect.NewResponse(&pingv1.PingResponse{Text: req.Msg.Text}), nil
}

func (pingHandler) Echo(_ context.Context, req *connect.Request[pingv1.EchoRequest]) (*connect.Response[pingv1.EchoResponse], error) {
	return connect.NewResponse(&pingv1.EchoResponse{Text: req.Msg.Text}), nil
}

func (pingHandler) PingStream(_ context.Context, stream *connect.BidiStream[pingv1.PingStreamRequest, pingv1.PingStreamResponse]) error {
	for {
		req, err := stream.Receive()
		if err != nil {
			if errors.Is(err, io.EOF) {
				return nil
			}
			return err
		}
		if err := stream.Send(&pingv1.PingStreamResponse{Text: req.Text}); err != nil {
			return err
		}
	}
}
Output:
authenticated request from Aladdin
client received response
Example (BearerToken)
package main

import (
	"context"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"

	"connectrpc.com/authn"
	pingv1 "connectrpc.com/authn/internal/gen/authn/ping/v1"
	"connectrpc.com/authn/internal/gen/authn/ping/v1/pingv1connect"
	"connectrpc.com/connect"
)

func main() {
	// This example shows how to use this package with bearer token authentication.
	// Any header-based authentication (including cookies and HTTP basic auth)
	// works similarly.

	// We'll use a simple allow list to demonstrate how to add authorization logic
	// conditionally based on the request's procedure.
	allowList := map[string]struct{}{
		// Procedure constants are available in the generated code.
		pingv1connect.PingServicePingProcedure: {},
	}
	// And a simple token-to-user map to demonstrate how to authenticate
	// requests based on a bearer token.
	tokenToUser := map[string]string{
		"open-sesame": "Aladdin",
	}

	// First, we define our authentication logic and use it to build middleware.
	authenticate := func(_ context.Context, req *http.Request) (any, error) {
		// Infer the procedure from the request URL.
		procedure, _ := authn.InferProcedure(req.URL)
		// Extract the bearer token from the Authorization header.
		token, ok := authn.BearerToken(req)
		if !ok {
			// We'll allow unauthenticated access to the ping procedure.
			if _, ok := allowList[procedure]; ok {
				fmt.Println("no authentication required for", procedure)
				return nil, nil // no authentication required
			}
			fmt.Println("authentication required for", procedure)
			err := authn.Errorf("invalid authorization")
			err.Meta().Set("WWW-Authenticate", "Bearer")
			return nil, err
		}
		user, ok := tokenToUser[token]
		if !ok {
			return nil, authn.Errorf("invalid token")
		}
		// The request is authenticated!
		fmt.Println("authenticated request from", user, "for", procedure)
		return user, nil
	}
	middleware := authn.NewMiddleware(authenticate)

	// Next, we build our Connect handler.
	mux := http.NewServeMux()
	service := &pingHandler{}
	mux.Handle(pingv1connect.NewPingServiceHandler(service))

	// Finally, we wrap the handler with our middleware and start our server.
	handler := middleware.Wrap(mux)
	server := httptest.NewServer(handler)
	defer server.Close()

	// Create an unauthenticated call to the ping procedure.
	client := pingv1connect.NewPingServiceClient(http.DefaultClient, server.URL)
	if _, err := client.Ping(context.Background(), connect.NewRequest(
		&pingv1.PingRequest{Text: "hello"},
	)); err != nil {
		fmt.Printf("unexpected error: %v\n", err)
		return
	}
	fmt.Println("client received response")

	// Create an unauthenticated call to the echo procedure.
	if _, err := client.Echo(context.Background(), connect.NewRequest(
		&pingv1.EchoRequest{Text: "hello"},
	)); connect.CodeOf(err) != connect.CodeUnauthenticated {
		fmt.Printf("unexpected error: %v\n", err)
		return
	}
	fmt.Println("client unauthorized")

	// Create an authenticated call to the echo procedure.
	req := connect.NewRequest(&pingv1.EchoRequest{Text: "hello"})
	req.Header().Set("Authorization", "Bearer open-sesame")
	if _, err := client.Echo(context.Background(), req); err != nil {
		fmt.Printf("unexpected error: %v\n", err)
		return
	}
	fmt.Println("client received response")

}

type pingHandler struct {
	pingv1connect.UnimplementedPingServiceHandler
}

func (pingHandler) Ping(_ context.Context, req *connect.Request[pingv1.PingRequest]) (*connect.Response[pingv1.PingResponse], error) {
	return connect.NewResponse(&pingv1.PingResponse{Text: req.Msg.Text}), nil
}

func (pingHandler) Echo(_ context.Context, req *connect.Request[pingv1.EchoRequest]) (*connect.Response[pingv1.EchoResponse], error) {
	return connect.NewResponse(&pingv1.EchoResponse{Text: req.Msg.Text}), nil
}

func (pingHandler) PingStream(_ context.Context, stream *connect.BidiStream[pingv1.PingStreamRequest, pingv1.PingStreamResponse]) error {
	for {
		req, err := stream.Receive()
		if err != nil {
			if errors.Is(err, io.EOF) {
				return nil
			}
			return err
		}
		if err := stream.Send(&pingv1.PingStreamResponse{Text: req.Text}); err != nil {
			return err
		}
	}
}
Output:
no authentication required for /authn.ping.v1.PingService/Ping
client received response
authentication required for /authn.ping.v1.PingService/Echo
client unauthorized
authenticated request from Aladdin for /authn.ping.v1.PingService/Echo
client received response
Example (MutualTLS)
package main

import (
	"context"
	"crypto/rand"
	"crypto/rsa"
	"crypto/subtle"
	"crypto/tls"
	"crypto/x509"
	"crypto/x509/pkix"
	"encoding/pem"
	"errors"
	"fmt"
	"math/big"
	"net"
	"net/http"
	"net/http/httptest"
	"time"

	"connectrpc.com/authn"
	pingv1 "connectrpc.com/authn/internal/gen/authn/ping/v1"
	"connectrpc.com/authn/internal/gen/authn/ping/v1/pingv1connect"
	"connectrpc.com/connect"
)

func main() {
	// This example shows how to use this package with mutual TLS.
	// First, we define our authentication logic and use it to build middleware.
	authenticate := func(_ context.Context, req *http.Request) (any, error) {
		tls := req.TLS
		if tls == nil {
			return nil, authn.Errorf("TLS required")
		}
		if len(tls.VerifiedChains) == 0 || len(tls.VerifiedChains[0]) == 0 {
			return nil, authn.Errorf("could not verify peer certificate")
		}
		name := tls.VerifiedChains[0][0].Subject.CommonName
		if !equal(name, "Aladdin") { // hardcode example credentials
			return nil, authn.Errorf("invalid subject common name %q", name)
		}
		// The request is authenticated! We can propagate the authenticated user to
		// Connect interceptors and services by returning it: the middleware we're
		// about to construct will attach it to the context automatically.
		fmt.Println("authenticated request from", name)
		return name, nil
	}
	middleware := authn.NewMiddleware(authenticate)

	// Next, we build our Connect handler.
	mux := http.NewServeMux()
	service := &pingv1connect.UnimplementedPingServiceHandler{}
	mux.Handle(pingv1connect.NewPingServiceHandler(service))

	// Finally, we wrap the handler with our middleware and start the server.
	// Creating server and client TLS configurations is particularly verbose in
	// examples, where we need to set up a complete self-signed chain of trust.
	clientTLS, serverTLS, err := newTLSConfigs("Aladdin", "Cave of Wonders")
	if err != nil {
		fmt.Printf("error creating TLS configs: %v\n", err)
		return
	}
	handler := middleware.Wrap(mux)
	server := httptest.NewUnstartedServer(handler)
	server.TLS = serverTLS
	server.StartTLS()
	defer server.Close()

	// Clients must configure their underlying HTTP clients to present a valid
	// certificate.
	httpClient := &http.Client{
		Transport: &http.Transport{TLSClientConfig: clientTLS},
	}
	client := pingv1connect.NewPingServiceClient(httpClient, server.URL)
	_, err = client.Ping(
		context.Background(),
		connect.NewRequest(&pingv1.PingRequest{}),
	)

	// We're using the UnimplementedPingServiceHandler stub, so authenticated
	// clients should receive an error with CodeUnimplemented.
	if connect.CodeOf(err) == connect.CodeUnimplemented {
		fmt.Println("client received response")
	} else {
		fmt.Printf("unexpected error: %v\n", err)
	}

}

func newTLSConfigs(clientName, serverName string) (client *tls.Config, server *tls.Config, _ error) {
	caCertPEM, caKeyPEM, err := createCertificateAuthority()
	if err != nil {
		return nil, nil, fmt.Errorf("create certificate authority: %w", err)
	}
	certPool := x509.NewCertPool()
	if ok := certPool.AppendCertsFromPEM(caCertPEM); !ok {
		return nil, nil, errors.New("failed to append certs to pool")
	}
	serverCertificate, err := newCertificate(caCertPEM, caKeyPEM, serverName)
	if err != nil {
		return nil, nil, fmt.Errorf("create server certificate: %w", err)
	}
	clientCertificate, err := newCertificate(caCertPEM, caKeyPEM, clientName)
	if err != nil {
		return nil, nil, fmt.Errorf("create client certificate: %w", err)
	}
	clientTLS := &tls.Config{
		Certificates: []tls.Certificate{clientCertificate},
		RootCAs:      certPool,
		MinVersion:   tls.VersionTLS12,
	}
	serverTLS := &tls.Config{
		ClientAuth:   tls.RequireAndVerifyClientCert,
		Certificates: []tls.Certificate{serverCertificate},
		ClientCAs:    certPool,
		MinVersion:   tls.VersionTLS12,
	}
	return clientTLS, serverTLS, nil
}

func createCertificateAuthority() ([]byte, []byte, error) {
	caCert := &x509.Certificate{
		SerialNumber: big.NewInt(2021),
		Subject: pkix.Name{
			Organization: []string{"Acme Co"},
		},
		NotBefore:             time.Now().AddDate(-1, 0, 0),
		NotAfter:              time.Now().AddDate(10, 0, 0),
		IsCA:                  true,
		ExtKeyUsage:           []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth},
		KeyUsage:              x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
		BasicConstraintsValid: true,
	}
	caPrivKey, err := rsa.GenerateKey(rand.Reader, 4096)
	if err != nil {
		return nil, nil, err
	}
	caBytes, err := x509.CreateCertificate(rand.Reader, caCert, caCert, &caPrivKey.PublicKey, caPrivKey)
	if err != nil {
		return nil, nil, err
	}
	caPEM := pem.EncodeToMemory(&pem.Block{
		Type:  "CERTIFICATE",
		Bytes: caBytes,
	})
	caPrivKeyPEM := pem.EncodeToMemory(&pem.Block{
		Type:  "RSA PRIVATE KEY",
		Bytes: x509.MarshalPKCS1PrivateKey(caPrivKey),
	})
	return caPEM, caPrivKeyPEM, nil
}

func newCertificate(caCertPEM, caKeyPEM []byte, commonName string) (tls.Certificate, error) {
	keyPEMBlock, _ := pem.Decode(caKeyPEM)
	privateKey, err := x509.ParsePKCS1PrivateKey(keyPEMBlock.Bytes)
	if err != nil {
		return tls.Certificate{}, err
	}
	certPEMBlock, _ := pem.Decode(caCertPEM)
	parent, err := x509.ParseCertificate(certPEMBlock.Bytes)
	if err != nil {
		return tls.Certificate{}, err
	}
	cert := &x509.Certificate{
		SerialNumber: big.NewInt(1658),
		Subject: pkix.Name{
			Organization: []string{"Acme Co"},
			CommonName:   commonName,
		},
		IPAddresses: []net.IP{
			net.IPv4(127, 0, 0, 1),
			net.IPv6loopback,
			net.IPv4(0, 0, 0, 0),
			net.IPv6zero,
		},
		NotBefore:    time.Now().AddDate(-1, 0, 0),
		NotAfter:     time.Now().AddDate(10, 0, 0),
		SubjectKeyId: []byte{1, 2, 3, 4, 6},
		ExtKeyUsage:  []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth},
		KeyUsage:     x509.KeyUsageDigitalSignature,
	}
	certPrivKey, err := rsa.GenerateKey(rand.Reader, 4096)
	if err != nil {
		return tls.Certificate{}, err
	}
	certBytes, err := x509.CreateCertificate(rand.Reader, cert, parent, &certPrivKey.PublicKey, privateKey)
	if err != nil {
		return tls.Certificate{}, err
	}
	certPEM := pem.EncodeToMemory(&pem.Block{
		Type:  "CERTIFICATE",
		Bytes: certBytes,
	})
	certPrivKeyPEM := pem.EncodeToMemory(&pem.Block{
		Type:  "RSA PRIVATE KEY",
		Bytes: x509.MarshalPKCS1PrivateKey(certPrivKey),
	})
	return tls.X509KeyPair(certPEM, certPrivKeyPEM)
}

func equal(left, right string) bool {

	return subtle.ConstantTimeCompare([]byte(left), []byte(right)) == 1
}
Output:
authenticated request from Aladdin
client received response

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

func BearerToken added in v0.2.0

func BearerToken(request *http.Request) (string, bool)

BearerToken returns the bearer token provided in the request's Authorization header, if any.

func Errorf

func Errorf(template string, args ...any) *connect.Error

Errorf is a convenience function that returns an error coded with connect.CodeUnauthenticated.

func GetInfo

func GetInfo(ctx context.Context) any

GetInfo retrieves authentication information, if any, from the request context.

func InferProcedure added in v0.2.0

func InferProcedure(url *url.URL) (string, bool)

InferProcedure returns the inferred RPC procedure. It's returned in the form "/service/method" if a valid suffix is found. If the request doesn't contain a service and method, the entire path and false is returned.

func InferProtocol added in v0.2.0

func InferProtocol(request *http.Request) (string, bool)

InferProtocol returns the inferred RPC protocol. It is one of connect.ProtocolConnect, connect.ProtocolGRPC, or connect.ProtocolGRPCWeb.

func SetInfo

func SetInfo(ctx context.Context, info any) context.Context

SetInfo attaches authentication information to the context. It's often useful in tests.

AuthFunc implementations do not need to call SetInfo explicitly. Any returned authentication information is automatically added to the context by Middleware.

func WithoutInfo

func WithoutInfo(ctx context.Context) context.Context

WithoutInfo strips the authentication information, if any, from the provided context.

Types

type AuthFunc

type AuthFunc func(ctx context.Context, req *http.Request) (any, error)

An AuthFunc authenticates an RPC. The function must return an error if the request cannot be authenticated. The error is typically produced with Errorf, but any error will do.

If requests are successfully authenticated, the authentication function may return some information about the authenticated caller (or nil). If non-nil, the information is automatically attached to the context using SetInfo.

Implementations must be safe to call concurrently.

type Middleware

type Middleware struct {
	// contains filtered or unexported fields
}

Middleware is server-side HTTP middleware that authenticates RPC requests. In addition to rejecting unauthenticated requests, it can optionally attach arbitrary information about the authenticated identity to the context.

Middleware operates at a lower level than Connect interceptors, so the server doesn't decompress and unmarshal the request until the caller has been authenticated.

func NewMiddleware

func NewMiddleware(auth AuthFunc, opts ...connect.HandlerOption) *Middleware

NewMiddleware constructs HTTP middleware using the supplied authentication function. If authentication succeeds, the authentication information (if any) will be attached to the context. Subsequent HTTP middleware, all RPC interceptors, and application code may access it with GetInfo.

In order to properly marshal errors, applications must pass NewMiddleware the same handler options used when constructing Connect handlers.

func (*Middleware) Wrap

func (m *Middleware) Wrap(handler http.Handler) http.Handler

Wrap returns an HTTP handler that authenticates requests before forwarding them to handler.

Directories

Path Synopsis
internal

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL