From 29d52a01e685acbbed006829f262cd0dd6cc5bea Mon Sep 17 00:00:00 2001 From: Edward McFarlane Date: Tue, 31 Oct 2023 18:48:04 -0400 Subject: [PATCH 01/10] Add authn --- README.md | 6 + auth_test.go | 104 +++++ authn-go.go | 15 - authn.go | 169 ++++++++ buf.gen.yaml | 12 + buf.work.yaml | 3 + go.mod | 9 + go.sum | 17 + internal/gen/authn/ping/v1/ping.pb.go | 376 ++++++++++++++++++ .../ping/v1/pingv1connect/ping.connect.go | 155 ++++++++ internal/proto/authn/ping/v1/ping.proto | 36 ++ internal/proto/buf.yaml | 7 + mtls_test.go | 357 +++++++++++++++++ 13 files changed, 1251 insertions(+), 15 deletions(-) create mode 100644 auth_test.go delete mode 100644 authn-go.go create mode 100644 authn.go create mode 100644 buf.gen.yaml create mode 100644 buf.work.yaml create mode 100644 go.sum create mode 100644 internal/gen/authn/ping/v1/ping.pb.go create mode 100644 internal/gen/authn/ping/v1/pingv1connect/ping.connect.go create mode 100644 internal/proto/authn/ping/v1/ping.proto create mode 100644 internal/proto/buf.yaml create mode 100644 mtls_test.go diff --git a/README.md b/README.md index 06d690c..23c31db 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,12 @@ authn-go =============== +TODO + +## Example + +TODO + ## Status: Alpha This project is currently in alpha. The API should be considered unstable and likely to change. diff --git a/auth_test.go b/auth_test.go new file mode 100644 index 0000000..9db38dc --- /dev/null +++ b/auth_test.go @@ -0,0 +1,104 @@ +// Copyright 2023 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package authn_test + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/bufbuild/authn-go" + "github.com/stretchr/testify/assert" +) + +const ( + hero = "Ali Baba" + passphrase = "opensesame" +) + +func TestMiddleware(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Check-Info") != "" { + assertInfo(t, r.Context()) + } + io.WriteString(w, "ok") + }) + handler := authn.NewMiddleware(authenticate).Wrap(mux) + server := httptest.NewServer(handler) + + assertResponse := func(headers http.Header, expectCode int) { + req, err := http.NewRequest( + http.MethodPost, + server.URL+"/empty.v1/GetEmpty", + strings.NewReader("{}"), + ) + assert.Nil(t, err) + for k, vals := range headers { + for _, v := range vals { + req.Header.Add(k, v) + } + } + res, err := server.Client().Do(req) + assert.Nil(t, err) + assert.Equal(t, res.StatusCode, expectCode) + } + // Middleware should ignore non-RPC requests. + assertResponse(http.Header{}, 200) + // RPCs without the right bearer token should be rejected. + assertResponse( + http.Header{"Content-Type": []string{"application/json"}}, + http.StatusUnauthorized, + ) + // RPCs with the right token should be allowed. + assertResponse( + http.Header{ + "Content-Type": []string{"application/json"}, + "Authorization": []string{"Bearer " + passphrase}, + "Check-Info": []string{"1"}, // verify that auth info is attached to context + }, + http.StatusOK, + ) +} + +func assertInfo(tb testing.TB, ctx context.Context) { + tb.Helper() + info := authn.GetInfo(ctx) + if info == nil { + tb.Fatal("no authentication info") + } + name, ok := info.(string) + assert.True(tb, ok, "got info of type %T, expected string", info) + assert.Equal(tb, name, hero) + if id := authn.GetInfo(authn.WithoutInfo(ctx)); id != nil { + tb.Fatalf("got info %v after WithoutInfo", id) + } +} + +func authenticate(ctx context.Context, req authn.Request) (any, error) { + parts := strings.SplitN(req.Header.Get("Authorization"), " ", 2) + if len(parts) < 2 || parts[0] != "Bearer" { + err := authn.Errorf("expected Bearer authentication scheme") + err.Meta().Set("WWW-Authenticate", "Bearer") + return nil, err + } + if tok := parts[1]; tok != passphrase { + return nil, authn.Errorf("%q is not the magic passphrase", tok) + } + return hero, nil +} diff --git a/authn-go.go b/authn-go.go deleted file mode 100644 index 3cb330f..0000000 --- a/authn-go.go +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright 2023 Buf Technologies, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package authn-go diff --git a/authn.go b/authn.go new file mode 100644 index 0000000..5fe58e9 --- /dev/null +++ b/authn.go @@ -0,0 +1,169 @@ +// Copyright 2023 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package authn provides authentication middleware for [connect]. +package authn + +import ( + "context" + "crypto/tls" + "fmt" + "net/http" + "strings" + + "connectrpc.com/connect" +) + +type key int + +const infoKey key = iota + +// 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). +// Implementations must be safe to call concurrently. +type AuthFunc func(ctx context.Context, req Request) (any, error) + +// SetInfo attaches authentication information to the context. It's often +// useful in tests. +func SetInfo(ctx context.Context, info any) context.Context { + if info == nil { + return ctx + } + return context.WithValue(ctx, infoKey, info) +} + +// GetInfo retrieves authentication information, if any, from the request +// context. +func GetInfo(ctx context.Context) any { + return ctx.Value(infoKey) +} + +// WithoutInfo strips the authentication information, if any, from the provided +// context. +func WithoutInfo(ctx context.Context) context.Context { + return context.WithValue(ctx, infoKey, nil) +} + +// Errorf is a convenience function that returns an error coded with +// [connect.CodeUnauthenticated]. +func Errorf(template string, args ...any) *connect.Error { + return connect.NewError(connect.CodeUnauthenticated, fmt.Errorf(template, args...)) +} + +// Request describes a single RPC invocation. +type Request struct { + // Procedure is the RPC procedure name, in the form "/service/method". + Procedure string + // ClientAddr is the client address, in IP:port format. + ClientAddr string + // Protocol is the RPC protocol. It is one of connect.ProtocolConnect, + // connect.ProtocolGRPC, or connect.ProtocolGRPCWeb. + Protocol string + // Header is the HTTP request headers. + Header http.Header + // TLS is the TLS connection state, if any. It may be nil if the connection + // is not using TLS. + TLS *tls.ConnectionState +} + +// Middleware is server-side HTTP middleware that authenticates RPC requests. +// In addition to rejecting unauthenticated requests, it can optionally attach +// arbitrary information to the context of authenticated requests. Any non-RPC +// requests (as determined by their Content-Type) are forwarded directly to the +// wrapped handler without authentication. +// +// Middleware operates at a lower level than [Interceptor]. For most +// applications, Middleware is preferable because it defers decompressing and +// unmarshaling the request until after the caller has been authenticated. +type Middleware struct { + auth AuthFunc + errW *connect.ErrorWriter +} + +// 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 identify RPC requests and marshal errors, applications +// must pass NewMiddleware the same handler options used when constructing +// Connect handlers. +func NewMiddleware(auth AuthFunc, opts ...connect.HandlerOption) *Middleware { + return &Middleware{ + auth: auth, + errW: connect.NewErrorWriter(opts...), + } +} + +// Wrap returns an HTTP handler that authenticates RPC requests before +// forwarding them to handler. If handler is not an RPC request, it is forwarded +// directly, without authentication. +func (m *Middleware) Wrap(handler http.Handler) http.Handler { + return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if !m.errW.IsSupported(request) { + handler.ServeHTTP(writer, request) + return // not an RPC request + } + ctx := request.Context() + info, err := m.auth(ctx, Request{ + Procedure: procedureFromHTTP(request), + ClientAddr: request.RemoteAddr, + Protocol: protocolFromHTTP(request), + Header: request.Header, + TLS: request.TLS, + }) + if err != nil { + m.errW.Write(writer, request, err) + return + } + if info != nil { + ctx = SetInfo(ctx, info) + request = request.WithContext(ctx) + } + handler.ServeHTTP(writer, request) + }) +} + +func procedureFromHTTP(r *http.Request) string { + path := strings.TrimSuffix(r.URL.Path, "/") + ultimate := strings.LastIndex(path, "/") + if ultimate < 0 { + return "" + } + penultimate := strings.LastIndex(path[:ultimate], "/") + if penultimate < 0 { + return "" + } + procedure := path[penultimate:] + if len(procedure) < 4 { // two slashes + service + method + return "" + } + return procedure +} + +func protocolFromHTTP(r *http.Request) string { + ct := r.Header.Get("Content-Type") + switch { + case strings.HasPrefix(ct, "application/grpc-web"): + return connect.ProtocolGRPCWeb + case strings.HasPrefix(ct, "application/grpc"): + return connect.ProtocolGRPC + default: + return connect.ProtocolConnect + } +} diff --git a/buf.gen.yaml b/buf.gen.yaml new file mode 100644 index 0000000..2c1cb92 --- /dev/null +++ b/buf.gen.yaml @@ -0,0 +1,12 @@ +version: v1 +managed: + enabled: true + go_package_prefix: + default: github.com/bufbuild/authn-go/internal/gen +plugins: + - name: go + out: internal/gen + opt: paths=source_relative + - name: connect-go + out: internal/gen + opt: paths=source_relative diff --git a/buf.work.yaml b/buf.work.yaml new file mode 100644 index 0000000..30f1e1f --- /dev/null +++ b/buf.work.yaml @@ -0,0 +1,3 @@ +version: v1 +directories: + - internal/proto diff --git a/go.mod b/go.mod index 48153f9..38c8c14 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,12 @@ module github.com/bufbuild/authn-go go 1.19 + +require ( + connectrpc.com/connect v1.12.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/testify v1.8.4 // indirect + google.golang.org/protobuf v1.31.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..8c7d241 --- /dev/null +++ b/go.sum @@ -0,0 +1,17 @@ +connectrpc.com/connect v1.12.0 h1:HwKdOY0lGhhoHdsza+hW55aqHEC64pYpObRNoAgn70g= +connectrpc.com/connect v1.12.0/go.mod h1:3AGaO6RRGMx5IKFfqbe3hvK1NqLosFNP2BxDYTPmNPo= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/gen/authn/ping/v1/ping.pb.go b/internal/gen/authn/ping/v1/ping.pb.go new file mode 100644 index 0000000..d0b1941 --- /dev/null +++ b/internal/gen/authn/ping/v1/ping.pb.go @@ -0,0 +1,376 @@ +// Copyright 2021-2023 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// The canonical location for this file is +// https://github.com/bufbuild/authn-go/blob/main/internal/proto/authn/ping/v1/ping.proto. + +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.31.0 +// protoc (unknown) +// source: authn/ping/v1/ping.proto + +// The authn.ping.v1 package contains a ping service designed to test the +// authn-go implementation. + +package pingv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type PingRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Text string `protobuf:"bytes,1,opt,name=text,proto3" json:"text,omitempty"` +} + +func (x *PingRequest) Reset() { + *x = PingRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_authn_ping_v1_ping_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *PingRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PingRequest) ProtoMessage() {} + +func (x *PingRequest) ProtoReflect() protoreflect.Message { + mi := &file_authn_ping_v1_ping_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PingRequest.ProtoReflect.Descriptor instead. +func (*PingRequest) Descriptor() ([]byte, []int) { + return file_authn_ping_v1_ping_proto_rawDescGZIP(), []int{0} +} + +func (x *PingRequest) GetText() string { + if x != nil { + return x.Text + } + return "" +} + +type PingResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Text string `protobuf:"bytes,1,opt,name=text,proto3" json:"text,omitempty"` +} + +func (x *PingResponse) Reset() { + *x = PingResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_authn_ping_v1_ping_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *PingResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PingResponse) ProtoMessage() {} + +func (x *PingResponse) ProtoReflect() protoreflect.Message { + mi := &file_authn_ping_v1_ping_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PingResponse.ProtoReflect.Descriptor instead. +func (*PingResponse) Descriptor() ([]byte, []int) { + return file_authn_ping_v1_ping_proto_rawDescGZIP(), []int{1} +} + +func (x *PingResponse) GetText() string { + if x != nil { + return x.Text + } + return "" +} + +type PingStreamRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Text string `protobuf:"bytes,1,opt,name=text,proto3" json:"text,omitempty"` +} + +func (x *PingStreamRequest) Reset() { + *x = PingStreamRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_authn_ping_v1_ping_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *PingStreamRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PingStreamRequest) ProtoMessage() {} + +func (x *PingStreamRequest) ProtoReflect() protoreflect.Message { + mi := &file_authn_ping_v1_ping_proto_msgTypes[2] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PingStreamRequest.ProtoReflect.Descriptor instead. +func (*PingStreamRequest) Descriptor() ([]byte, []int) { + return file_authn_ping_v1_ping_proto_rawDescGZIP(), []int{2} +} + +func (x *PingStreamRequest) GetText() string { + if x != nil { + return x.Text + } + return "" +} + +type PingStreamResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Text string `protobuf:"bytes,1,opt,name=text,proto3" json:"text,omitempty"` +} + +func (x *PingStreamResponse) Reset() { + *x = PingStreamResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_authn_ping_v1_ping_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *PingStreamResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PingStreamResponse) ProtoMessage() {} + +func (x *PingStreamResponse) ProtoReflect() protoreflect.Message { + mi := &file_authn_ping_v1_ping_proto_msgTypes[3] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PingStreamResponse.ProtoReflect.Descriptor instead. +func (*PingStreamResponse) Descriptor() ([]byte, []int) { + return file_authn_ping_v1_ping_proto_rawDescGZIP(), []int{3} +} + +func (x *PingStreamResponse) GetText() string { + if x != nil { + return x.Text + } + return "" +} + +var File_authn_ping_v1_ping_proto protoreflect.FileDescriptor + +var file_authn_ping_v1_ping_proto_rawDesc = []byte{ + 0x0a, 0x18, 0x61, 0x75, 0x74, 0x68, 0x6e, 0x2f, 0x70, 0x69, 0x6e, 0x67, 0x2f, 0x76, 0x31, 0x2f, + 0x70, 0x69, 0x6e, 0x67, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0d, 0x61, 0x75, 0x74, 0x68, + 0x6e, 0x2e, 0x70, 0x69, 0x6e, 0x67, 0x2e, 0x76, 0x31, 0x22, 0x21, 0x0a, 0x0b, 0x50, 0x69, 0x6e, + 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x65, 0x78, 0x74, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x65, 0x78, 0x74, 0x22, 0x22, 0x0a, 0x0c, + 0x50, 0x69, 0x6e, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x12, 0x0a, 0x04, + 0x74, 0x65, 0x78, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x65, 0x78, 0x74, + 0x22, 0x27, 0x0a, 0x11, 0x50, 0x69, 0x6e, 0x67, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x65, 0x78, 0x74, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x65, 0x78, 0x74, 0x22, 0x28, 0x0a, 0x12, 0x50, 0x69, 0x6e, + 0x67, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, + 0x12, 0x0a, 0x04, 0x74, 0x65, 0x78, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, + 0x65, 0x78, 0x74, 0x32, 0xac, 0x01, 0x0a, 0x0b, 0x50, 0x69, 0x6e, 0x67, 0x53, 0x65, 0x72, 0x76, + 0x69, 0x63, 0x65, 0x12, 0x44, 0x0a, 0x04, 0x50, 0x69, 0x6e, 0x67, 0x12, 0x1a, 0x2e, 0x61, 0x75, + 0x74, 0x68, 0x6e, 0x2e, 0x70, 0x69, 0x6e, 0x67, 0x2e, 0x76, 0x31, 0x2e, 0x50, 0x69, 0x6e, 0x67, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6e, 0x2e, + 0x70, 0x69, 0x6e, 0x67, 0x2e, 0x76, 0x31, 0x2e, 0x50, 0x69, 0x6e, 0x67, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x03, 0x90, 0x02, 0x01, 0x12, 0x57, 0x0a, 0x0a, 0x50, 0x69, 0x6e, + 0x67, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x12, 0x20, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6e, 0x2e, + 0x70, 0x69, 0x6e, 0x67, 0x2e, 0x76, 0x31, 0x2e, 0x50, 0x69, 0x6e, 0x67, 0x53, 0x74, 0x72, 0x65, + 0x61, 0x6d, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x61, 0x75, 0x74, 0x68, + 0x6e, 0x2e, 0x70, 0x69, 0x6e, 0x67, 0x2e, 0x76, 0x31, 0x2e, 0x50, 0x69, 0x6e, 0x67, 0x53, 0x74, + 0x72, 0x65, 0x61, 0x6d, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x28, 0x01, + 0x30, 0x01, 0x42, 0xb4, 0x01, 0x0a, 0x11, 0x63, 0x6f, 0x6d, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6e, + 0x2e, 0x70, 0x69, 0x6e, 0x67, 0x2e, 0x76, 0x31, 0x42, 0x09, 0x50, 0x69, 0x6e, 0x67, 0x50, 0x72, + 0x6f, 0x74, 0x6f, 0x50, 0x01, 0x5a, 0x3e, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, + 0x6d, 0x2f, 0x62, 0x75, 0x66, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x2f, 0x61, 0x75, 0x74, 0x68, 0x6e, + 0x2d, 0x67, 0x6f, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x67, 0x65, 0x6e, + 0x2f, 0x61, 0x75, 0x74, 0x68, 0x6e, 0x2f, 0x70, 0x69, 0x6e, 0x67, 0x2f, 0x76, 0x31, 0x3b, 0x70, + 0x69, 0x6e, 0x67, 0x76, 0x31, 0xa2, 0x02, 0x03, 0x41, 0x50, 0x58, 0xaa, 0x02, 0x0d, 0x41, 0x75, + 0x74, 0x68, 0x6e, 0x2e, 0x50, 0x69, 0x6e, 0x67, 0x2e, 0x56, 0x31, 0xca, 0x02, 0x0d, 0x41, 0x75, + 0x74, 0x68, 0x6e, 0x5c, 0x50, 0x69, 0x6e, 0x67, 0x5c, 0x56, 0x31, 0xe2, 0x02, 0x19, 0x41, 0x75, + 0x74, 0x68, 0x6e, 0x5c, 0x50, 0x69, 0x6e, 0x67, 0x5c, 0x56, 0x31, 0x5c, 0x47, 0x50, 0x42, 0x4d, + 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0xea, 0x02, 0x0f, 0x41, 0x75, 0x74, 0x68, 0x6e, 0x3a, + 0x3a, 0x50, 0x69, 0x6e, 0x67, 0x3a, 0x3a, 0x56, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x33, +} + +var ( + file_authn_ping_v1_ping_proto_rawDescOnce sync.Once + file_authn_ping_v1_ping_proto_rawDescData = file_authn_ping_v1_ping_proto_rawDesc +) + +func file_authn_ping_v1_ping_proto_rawDescGZIP() []byte { + file_authn_ping_v1_ping_proto_rawDescOnce.Do(func() { + file_authn_ping_v1_ping_proto_rawDescData = protoimpl.X.CompressGZIP(file_authn_ping_v1_ping_proto_rawDescData) + }) + return file_authn_ping_v1_ping_proto_rawDescData +} + +var file_authn_ping_v1_ping_proto_msgTypes = make([]protoimpl.MessageInfo, 4) +var file_authn_ping_v1_ping_proto_goTypes = []interface{}{ + (*PingRequest)(nil), // 0: authn.ping.v1.PingRequest + (*PingResponse)(nil), // 1: authn.ping.v1.PingResponse + (*PingStreamRequest)(nil), // 2: authn.ping.v1.PingStreamRequest + (*PingStreamResponse)(nil), // 3: authn.ping.v1.PingStreamResponse +} +var file_authn_ping_v1_ping_proto_depIdxs = []int32{ + 0, // 0: authn.ping.v1.PingService.Ping:input_type -> authn.ping.v1.PingRequest + 2, // 1: authn.ping.v1.PingService.PingStream:input_type -> authn.ping.v1.PingStreamRequest + 1, // 2: authn.ping.v1.PingService.Ping:output_type -> authn.ping.v1.PingResponse + 3, // 3: authn.ping.v1.PingService.PingStream:output_type -> authn.ping.v1.PingStreamResponse + 2, // [2:4] is the sub-list for method output_type + 0, // [0:2] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_authn_ping_v1_ping_proto_init() } +func file_authn_ping_v1_ping_proto_init() { + if File_authn_ping_v1_ping_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_authn_ping_v1_ping_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*PingRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_authn_ping_v1_ping_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*PingResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_authn_ping_v1_ping_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*PingStreamRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_authn_ping_v1_ping_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*PingStreamResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_authn_ping_v1_ping_proto_rawDesc, + NumEnums: 0, + NumMessages: 4, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_authn_ping_v1_ping_proto_goTypes, + DependencyIndexes: file_authn_ping_v1_ping_proto_depIdxs, + MessageInfos: file_authn_ping_v1_ping_proto_msgTypes, + }.Build() + File_authn_ping_v1_ping_proto = out.File + file_authn_ping_v1_ping_proto_rawDesc = nil + file_authn_ping_v1_ping_proto_goTypes = nil + file_authn_ping_v1_ping_proto_depIdxs = nil +} diff --git a/internal/gen/authn/ping/v1/pingv1connect/ping.connect.go b/internal/gen/authn/ping/v1/pingv1connect/ping.connect.go new file mode 100644 index 0000000..73be4d2 --- /dev/null +++ b/internal/gen/authn/ping/v1/pingv1connect/ping.connect.go @@ -0,0 +1,155 @@ +// Copyright 2021-2023 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// The canonical location for this file is +// https://github.com/bufbuild/authn-go/blob/main/internal/proto/authn/ping/v1/ping.proto. + +// Code generated by protoc-gen-connect-go. DO NOT EDIT. +// +// Source: authn/ping/v1/ping.proto + +// The authn.ping.v1 package contains a ping service designed to test the +// authn-go implementation. +package pingv1connect + +import ( + connect "connectrpc.com/connect" + context "context" + errors "errors" + v1 "github.com/bufbuild/authn-go/internal/gen/authn/ping/v1" + http "net/http" + strings "strings" +) + +// This is a compile-time assertion to ensure that this generated file and the connect package are +// compatible. If you get a compiler error that this constant is not defined, this code was +// generated with a version of connect newer than the one compiled into your binary. You can fix the +// problem by either regenerating this code with an older version of connect or updating the connect +// version compiled into your binary. +const _ = connect.IsAtLeastVersion1_7_0 + +const ( + // PingServiceName is the fully-qualified name of the PingService service. + PingServiceName = "authn.ping.v1.PingService" +) + +// These constants are the fully-qualified names of the RPCs defined in this package. They're +// exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route. +// +// Note that these are different from the fully-qualified method names used by +// google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to +// reflection-formatted method names, remove the leading slash and convert the remaining slash to a +// period. +const ( + // PingServicePingProcedure is the fully-qualified name of the PingService's Ping RPC. + PingServicePingProcedure = "/authn.ping.v1.PingService/Ping" + // PingServicePingStreamProcedure is the fully-qualified name of the PingService's PingStream RPC. + PingServicePingStreamProcedure = "/authn.ping.v1.PingService/PingStream" +) + +// PingServiceClient is a client for the authn.ping.v1.PingService service. +type PingServiceClient interface { + // Ping is a unary RPC that returns the same text that was sent. + Ping(context.Context, *connect.Request[v1.PingRequest]) (*connect.Response[v1.PingResponse], error) + // PingStream is a bidirectional stream of pings. + PingStream(context.Context) *connect.BidiStreamForClient[v1.PingStreamRequest, v1.PingStreamResponse] +} + +// NewPingServiceClient constructs a client for the authn.ping.v1.PingService service. By default, +// it uses the Connect protocol with the binary Protobuf Codec, asks for gzipped responses, and +// sends uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the connect.WithGRPC() +// or connect.WithGRPCWeb() options. +// +// The URL supplied here should be the base URL for the Connect or gRPC server (for example, +// http://api.acme.com or https://acme.com/grpc). +func NewPingServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) PingServiceClient { + baseURL = strings.TrimRight(baseURL, "/") + return &pingServiceClient{ + ping: connect.NewClient[v1.PingRequest, v1.PingResponse]( + httpClient, + baseURL+PingServicePingProcedure, + connect.WithIdempotency(connect.IdempotencyNoSideEffects), + connect.WithClientOptions(opts...), + ), + pingStream: connect.NewClient[v1.PingStreamRequest, v1.PingStreamResponse]( + httpClient, + baseURL+PingServicePingStreamProcedure, + opts..., + ), + } +} + +// pingServiceClient implements PingServiceClient. +type pingServiceClient struct { + ping *connect.Client[v1.PingRequest, v1.PingResponse] + pingStream *connect.Client[v1.PingStreamRequest, v1.PingStreamResponse] +} + +// Ping calls authn.ping.v1.PingService.Ping. +func (c *pingServiceClient) Ping(ctx context.Context, req *connect.Request[v1.PingRequest]) (*connect.Response[v1.PingResponse], error) { + return c.ping.CallUnary(ctx, req) +} + +// PingStream calls authn.ping.v1.PingService.PingStream. +func (c *pingServiceClient) PingStream(ctx context.Context) *connect.BidiStreamForClient[v1.PingStreamRequest, v1.PingStreamResponse] { + return c.pingStream.CallBidiStream(ctx) +} + +// PingServiceHandler is an implementation of the authn.ping.v1.PingService service. +type PingServiceHandler interface { + // Ping is a unary RPC that returns the same text that was sent. + Ping(context.Context, *connect.Request[v1.PingRequest]) (*connect.Response[v1.PingResponse], error) + // PingStream is a bidirectional stream of pings. + PingStream(context.Context, *connect.BidiStream[v1.PingStreamRequest, v1.PingStreamResponse]) error +} + +// NewPingServiceHandler builds an HTTP handler from the service implementation. It returns the path +// on which to mount the handler and the handler itself. +// +// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf +// and JSON codecs. They also support gzip compression. +func NewPingServiceHandler(svc PingServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) { + pingServicePingHandler := connect.NewUnaryHandler( + PingServicePingProcedure, + svc.Ping, + connect.WithIdempotency(connect.IdempotencyNoSideEffects), + connect.WithHandlerOptions(opts...), + ) + pingServicePingStreamHandler := connect.NewBidiStreamHandler( + PingServicePingStreamProcedure, + svc.PingStream, + opts..., + ) + return "/authn.ping.v1.PingService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case PingServicePingProcedure: + pingServicePingHandler.ServeHTTP(w, r) + case PingServicePingStreamProcedure: + pingServicePingStreamHandler.ServeHTTP(w, r) + default: + http.NotFound(w, r) + } + }) +} + +// UnimplementedPingServiceHandler returns CodeUnimplemented from all methods. +type UnimplementedPingServiceHandler struct{} + +func (UnimplementedPingServiceHandler) Ping(context.Context, *connect.Request[v1.PingRequest]) (*connect.Response[v1.PingResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("authn.ping.v1.PingService.Ping is not implemented")) +} + +func (UnimplementedPingServiceHandler) PingStream(context.Context, *connect.BidiStream[v1.PingStreamRequest, v1.PingStreamResponse]) error { + return connect.NewError(connect.CodeUnimplemented, errors.New("authn.ping.v1.PingService.PingStream is not implemented")) +} diff --git a/internal/proto/authn/ping/v1/ping.proto b/internal/proto/authn/ping/v1/ping.proto new file mode 100644 index 0000000..68ffad4 --- /dev/null +++ b/internal/proto/authn/ping/v1/ping.proto @@ -0,0 +1,36 @@ +// Copyright 2021-2023 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// The canonical location for this file is +// https://github.com/bufbuild/authn-go/blob/main/internal/proto/authn/ping/v1/ping.proto. +syntax = "proto3"; + +// The authn.ping.v1 package contains a ping service designed to test the +// authn-go implementation. +package authn.ping.v1; + +message PingRequest { string text = 1; } +message PingResponse { string text = 1; } +message PingStreamRequest { string text = 1; } +message PingStreamResponse { string text = 1; } + +service PingService { + // Ping is a unary RPC that returns the same text that was sent. + rpc Ping(PingRequest) returns (PingResponse) { + option idempotency_level = NO_SIDE_EFFECTS; + } + // PingStream is a bidirectional stream of pings. + rpc PingStream(stream PingStreamRequest) returns (stream PingStreamResponse) { + } +} diff --git a/internal/proto/buf.yaml b/internal/proto/buf.yaml new file mode 100644 index 0000000..efda402 --- /dev/null +++ b/internal/proto/buf.yaml @@ -0,0 +1,7 @@ +version: v1 +lint: + use: + - DEFAULT +breaking: + use: + - WIRE_JSON diff --git a/mtls_test.go b/mtls_test.go new file mode 100644 index 0000000..0a24f97 --- /dev/null +++ b/mtls_test.go @@ -0,0 +1,357 @@ +// Copyright 2023 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package authn_test + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "errors" + "fmt" + "io" + "log" + "math/big" + "net" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "connectrpc.com/connect" + "github.com/bufbuild/authn-go" + pingv1 "github.com/bufbuild/authn-go/internal/gen/authn/ping/v1" + "github.com/bufbuild/authn-go/internal/gen/authn/ping/v1/pingv1connect" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Example_mutualTLS() { + log := log.New(os.Stdout, "" /* prefix */, 0 /* flags */) + certPool := x509.NewCertPool() + caCertPEM, caKeyPEM, err := createCertificateAuthority() + if err != nil { + log.Fatal(err) + } + if ok := certPool.AppendCertsFromPEM(caCertPEM); !ok { + log.Fatal("failed to append client certs") + } + + // Create the server certificate. + certPEM, keyPEM, err := createCertificate(caCertPEM, caKeyPEM, "Server") + if err != nil { + log.Fatal(err) + } + certificate, err := tls.X509KeyPair(certPEM, keyPEM) + if err != nil { + log.Fatal(err) + } + tlsConfig := &tls.Config{ + ClientAuth: tls.RequireAndVerifyClientCert, + Certificates: []tls.Certificate{certificate}, + ClientCAs: certPool, + } + + mux := http.NewServeMux() + mux.Handle(pingv1connect.NewPingServiceHandler(pingService{})) + + // Wrap the server with authn middleware. + auth := authn.NewMiddleware( + func(ctx context.Context, req authn.Request) (any, error) { + if req.TLS == nil { + return nil, fmt.Errorf("no TLS connection state") + } + if len(req.TLS.VerifiedChains) == 0 || len(req.TLS.VerifiedChains[0]) == 0 { + return nil, authn.Errorf("could not verify peer certificate") + } + // Check subject common name against configured username + commonName := req.TLS.VerifiedChains[0][0].Subject.CommonName + if commonName != "Client" { + return nil, authn.Errorf("invalid subject common name") + } + log.Printf("verified peer certificate: %s", commonName) + return commonName, nil + }, + ) + handler := auth.Wrap(mux) + + // Start the server with TLS. + server := httptest.NewUnstartedServer(handler) + server.TLS = tlsConfig + server.StartTLS() + defer server.Close() + + // Create the client certificate. + certPEM, keyPEM, err = createCertificate(caCertPEM, caKeyPEM, "Client") + if err != nil { + log.Fatal(err) + } + certificate, err = tls.X509KeyPair(certPEM, keyPEM) + if err != nil { + log.Fatal(err) + } + tlsClientConfig := &tls.Config{ + Certificates: []tls.Certificate{certificate}, + RootCAs: certPool, + } + + // Create the client. + client := pingv1connect.NewPingServiceClient( + &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: tlsClientConfig, + }, + }, + server.URL, + ) + + // Make a request. + req := connect.NewRequest(&pingv1.PingRequest{ + Text: "hello", + }) + rsp, err := client.Ping(context.Background(), req) + if err != nil { + log.Fatal(err) + } + fmt.Printf("got response: %s", rsp.Msg.Text) + // Output: + // verified peer certificate: Client + // got response: hello +} + +func TestTLSServer(t *testing.T) { + ctx := context.Background() + + // certPool + certPool := x509.NewCertPool() + caCertPEM, caKeyPEM, err := createCertificateAuthority() + if err != nil { + t.Fatal(err) + } + if ok := certPool.AppendCertsFromPEM(caCertPEM); !ok { + t.Fatal("failed to append client certs") + } + + // Create the server certificate. + certPEM, keyPEM, err := createCertificate(caCertPEM, caKeyPEM, "Server") + if err != nil { + t.Fatal(err) + } + certificate, err := tls.X509KeyPair(certPEM, keyPEM) + if err != nil { + t.Fatal(err) + } + tlsConfig := &tls.Config{ + ClientAuth: tls.RequireAndVerifyClientCert, + Certificates: []tls.Certificate{certificate}, + ClientCAs: certPool, + } + + mux := http.NewServeMux() + mux.Handle(pingv1connect.NewPingServiceHandler(pingService{})) + + auth := authn.NewMiddleware( + func(ctx context.Context, req authn.Request) (any, error) { + if req.TLS == nil { + return nil, fmt.Errorf("no TLS connection state") + } + if len(req.TLS.VerifiedChains) == 0 || len(req.TLS.VerifiedChains[0]) == 0 { + return nil, authn.Errorf("could not verify peer certificate") + } + // Check subject common name against configured username + commonName := req.TLS.VerifiedChains[0][0].Subject.CommonName + if commonName != "Client" { + return nil, authn.Errorf("invalid subject common name") + } + return commonName, nil + }, + ) + handler := auth.Wrap(mux) + + server := httptest.NewUnstartedServer(handler) + server.TLS = tlsConfig + server.StartTLS() + t.Cleanup(server.Close) + + certPEM, keyPEM, err = createCertificate(caCertPEM, caKeyPEM, "Client") + if err != nil { + t.Fatal(err) + } + certificate, err = tls.X509KeyPair(certPEM, keyPEM) + if err != nil { + t.Fatal(err) + } + tlsConfig = &tls.Config{ + Certificates: []tls.Certificate{certificate}, + RootCAs: certPool, + } + tlsInsecure := &tls.Config{ + InsecureSkipVerify: true, + } + + t.Run("secure", func(t *testing.T) { + client := pingv1connect.NewPingServiceClient( + &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: tlsConfig, + }, + }, + server.URL, + ) + req := connect.NewRequest(&pingv1.PingRequest{ + Text: "hello", + }) + rsp, err := client.Ping(ctx, req) + require.NoError(t, err) + assert.Equal(t, "hello", rsp.Msg.Text) + }) + t.Run("insecure", func(t *testing.T) { + client := pingv1connect.NewPingServiceClient( + &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: tlsInsecure, + }, + }, + server.URL, + ) + req := connect.NewRequest(&pingv1.PingRequest{ + Text: "hello", + }) + _, err := client.Ping(ctx, req) + require.ErrorContains(t, err, "tls: certificate required") + }) +} + +func createCertificateAuthority() ([]byte, []byte, error) { + ca := &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, ca, ca, &caPrivKey.PublicKey, caPrivKey) + if err != nil { + return nil, nil, err + } + caPEM := new(bytes.Buffer) + if err := pem.Encode(caPEM, &pem.Block{ + Type: "CERTIFICATE", + Bytes: caBytes, + }); err != nil { + return nil, nil, err + } + caPrivKeyPEM := new(bytes.Buffer) + if err := pem.Encode(caPrivKeyPEM, &pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(caPrivKey), + }); err != nil { + return nil, nil, err + } + return caPEM.Bytes(), caPrivKeyPEM.Bytes(), nil +} + +func createCertificate(caCertPEM, caKeyPEM []byte, commonName string) ([]byte, []byte, error) { + keyPEMBlock, _ := pem.Decode(caKeyPEM) + privateKey, err := x509.ParsePKCS1PrivateKey(keyPEMBlock.Bytes) + if err != nil { + return nil, nil, err + } + certPEMBlock, _ := pem.Decode(caCertPEM) + parent, err := x509.ParseCertificate(certPEMBlock.Bytes) + if err != nil { + return nil, nil, 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 nil, nil, err + } + certBytes, err := x509.CreateCertificate(rand.Reader, cert, parent, &certPrivKey.PublicKey, privateKey) + if err != nil { + return nil, nil, err + } + certPEM := new(bytes.Buffer) + if err := pem.Encode(certPEM, &pem.Block{ + Type: "CERTIFICATE", + Bytes: certBytes, + }); err != nil { + return nil, nil, err + } + certPrivKeyPEM := new(bytes.Buffer) + if err := pem.Encode(certPrivKeyPEM, &pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(certPrivKey), + }); err != nil { + return nil, nil, err + } + return certPEM.Bytes(), certPrivKeyPEM.Bytes(), nil +} + +type pingService struct{} + +func (pingService) Ping(_ context.Context, req *connect.Request[pingv1.PingRequest]) (*connect.Response[pingv1.PingResponse], error) { + return connect.NewResponse(&pingv1.PingResponse{ + Text: req.Msg.Text, + }), nil +} + +func (pingService) 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 + } + } +} From 0b436f7a43a90c3c92339b0545ad5cf131cf1b1b Mon Sep 17 00:00:00 2001 From: Edward McFarlane Date: Wed, 1 Nov 2023 09:37:49 -0400 Subject: [PATCH 02/10] Rename file to authn prefix --- auth_test.go => authn_test.go | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename auth_test.go => authn_test.go (100%) diff --git a/auth_test.go b/authn_test.go similarity index 100% rename from auth_test.go rename to authn_test.go From e9ee9b349d21dd5d53318381f4d620005bf48510 Mon Sep 17 00:00:00 2001 From: Edward McFarlane Date: Wed, 1 Nov 2023 10:15:08 -0400 Subject: [PATCH 03/10] Use methods on the Request object --- authn.go | 104 +++++++++------- authn_test.go | 2 +- mtls_test.go => examples_test.go | 206 +++++++++++++------------------ 3 files changed, 144 insertions(+), 168 deletions(-) rename mtls_test.go => examples_test.go (64%) diff --git a/authn.go b/authn.go index 5fe58e9..a07694e 100644 --- a/authn.go +++ b/authn.go @@ -67,18 +67,61 @@ func Errorf(template string, args ...any) *connect.Error { // Request describes a single RPC invocation. type Request struct { - // Procedure is the RPC procedure name, in the form "/service/method". - Procedure string - // ClientAddr is the client address, in IP:port format. - ClientAddr string - // Protocol is the RPC protocol. It is one of connect.ProtocolConnect, - // connect.ProtocolGRPC, or connect.ProtocolGRPCWeb. - Protocol string - // Header is the HTTP request headers. - Header http.Header - // TLS is the TLS connection state, if any. It may be nil if the connection - // is not using TLS. - TLS *tls.ConnectionState + request *http.Request +} + +// BasicAuth returns the username and password provided in the request's +// Authorization header, if any. +func (r Request) BasicAuth() (username string, password string, ok bool) { + return r.request.BasicAuth() +} + +// Procedure returns the RPC procedure name, in the form "/service/method". +func (r Request) Procedure() string { + path := strings.TrimSuffix(r.request.URL.Path, "/") + ultimate := strings.LastIndex(path, "/") + if ultimate < 0 { + return "" + } + penultimate := strings.LastIndex(path[:ultimate], "/") + if penultimate < 0 { + return "" + } + procedure := path[penultimate:] + if len(procedure) < 4 { // two slashes + service + method + return "" + } + return procedure +} + +// ClientAddr returns the client address, in IP:port format. +func (r Request) ClientAddr() string { + return r.request.RemoteAddr +} + +// Protocol returns the RPC protocol. It is one of connect.ProtocolConnect, +// connect.ProtocolGRPC, or connect.ProtocolGRPCWeb. +func (r Request) Protocol() string { + ct := r.request.Header.Get("Content-Type") + switch { + case strings.HasPrefix(ct, "application/grpc-web"): + return connect.ProtocolGRPCWeb + case strings.HasPrefix(ct, "application/grpc"): + return connect.ProtocolGRPC + default: + return connect.ProtocolConnect + } +} + +// Header returns the HTTP request headers. +func (r Request) Header() http.Header { + return r.request.Header +} + +// TLS returns the TLS connection state, if any. It may be nil if the connection +// is not using TLS. +func (r Request) TLS() *tls.ConnectionState { + return r.request.TLS } // Middleware is server-side HTTP middleware that authenticates RPC requests. @@ -120,13 +163,7 @@ func (m *Middleware) Wrap(handler http.Handler) http.Handler { return // not an RPC request } ctx := request.Context() - info, err := m.auth(ctx, Request{ - Procedure: procedureFromHTTP(request), - ClientAddr: request.RemoteAddr, - Protocol: protocolFromHTTP(request), - Header: request.Header, - TLS: request.TLS, - }) + info, err := m.auth(ctx, Request{request: request}) if err != nil { m.errW.Write(writer, request, err) return @@ -138,32 +175,3 @@ func (m *Middleware) Wrap(handler http.Handler) http.Handler { handler.ServeHTTP(writer, request) }) } - -func procedureFromHTTP(r *http.Request) string { - path := strings.TrimSuffix(r.URL.Path, "/") - ultimate := strings.LastIndex(path, "/") - if ultimate < 0 { - return "" - } - penultimate := strings.LastIndex(path[:ultimate], "/") - if penultimate < 0 { - return "" - } - procedure := path[penultimate:] - if len(procedure) < 4 { // two slashes + service + method - return "" - } - return procedure -} - -func protocolFromHTTP(r *http.Request) string { - ct := r.Header.Get("Content-Type") - switch { - case strings.HasPrefix(ct, "application/grpc-web"): - return connect.ProtocolGRPCWeb - case strings.HasPrefix(ct, "application/grpc"): - return connect.ProtocolGRPC - default: - return connect.ProtocolConnect - } -} diff --git a/authn_test.go b/authn_test.go index 9db38dc..c550f17 100644 --- a/authn_test.go +++ b/authn_test.go @@ -91,7 +91,7 @@ func assertInfo(tb testing.TB, ctx context.Context) { } func authenticate(ctx context.Context, req authn.Request) (any, error) { - parts := strings.SplitN(req.Header.Get("Authorization"), " ", 2) + parts := strings.SplitN(req.Header().Get("Authorization"), " ", 2) if len(parts) < 2 || parts[0] != "Bearer" { err := authn.Errorf("expected Bearer authentication scheme") err.Meta().Set("WWW-Authenticate", "Bearer") diff --git a/mtls_test.go b/examples_test.go similarity index 64% rename from mtls_test.go rename to examples_test.go index 0a24f97..6c41073 100644 --- a/mtls_test.go +++ b/examples_test.go @@ -19,6 +19,7 @@ import ( "context" "crypto/rand" "crypto/rsa" + "crypto/subtle" "crypto/tls" "crypto/x509" "crypto/x509/pkix" @@ -31,132 +32,102 @@ import ( "net" "net/http" "net/http/httptest" - "os" - "testing" "time" "connectrpc.com/connect" "github.com/bufbuild/authn-go" pingv1 "github.com/bufbuild/authn-go/internal/gen/authn/ping/v1" "github.com/bufbuild/authn-go/internal/gen/authn/ping/v1/pingv1connect" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) -func Example_mutualTLS() { - log := log.New(os.Stdout, "" /* prefix */, 0 /* flags */) - certPool := x509.NewCertPool() - caCertPEM, caKeyPEM, err := createCertificateAuthority() - if err != nil { - log.Fatal(err) - } - if ok := certPool.AppendCertsFromPEM(caCertPEM); !ok { - log.Fatal("failed to append client certs") - } - - // Create the server certificate. - certPEM, keyPEM, err := createCertificate(caCertPEM, caKeyPEM, "Server") - if err != nil { - log.Fatal(err) - } - certificate, err := tls.X509KeyPair(certPEM, keyPEM) - if err != nil { - log.Fatal(err) - } - tlsConfig := &tls.Config{ - ClientAuth: tls.RequireAndVerifyClientCert, - Certificates: []tls.Certificate{certificate}, - ClientCAs: certPool, - } - +func Example_basicAuth() { + // This example demonstrates how to use basic auth with the authn middleware. + // The example uses the ping service from the authn-go/internal/gen/authn/ping/v1 + // package, but the same approach can be used with any service. mux := http.NewServeMux() mux.Handle(pingv1connect.NewPingServiceHandler(pingService{})) // Wrap the server with authn middleware. auth := authn.NewMiddleware( - func(ctx context.Context, req authn.Request) (any, error) { - if req.TLS == nil { - return nil, fmt.Errorf("no TLS connection state") + func(_ context.Context, req authn.Request) (any, error) { + username, password, ok := req.BasicAuth() + if !ok { + // If authentication fails, we return an error. authn.Errorf is a + // convenient shortcut to produce an error coded with + // connect.CodeUnauthenticated. + return nil, authn.Errorf("invalid authorization") } - if len(req.TLS.VerifiedChains) == 0 || len(req.TLS.VerifiedChains[0]) == 0 { - return nil, authn.Errorf("could not verify peer certificate") + // Check username and password against a database. In this example, we + // hardcode the credentials. + if subtle.ConstantTimeCompare([]byte(username), []byte("Aladdin")) != 1 { + return nil, authn.Errorf("invalid username") } - // Check subject common name against configured username - commonName := req.TLS.VerifiedChains[0][0].Subject.CommonName - if commonName != "Client" { - return nil, authn.Errorf("invalid subject common name") + if subtle.ConstantTimeCompare([]byte(password), []byte("open sesame")) != 1 { + return nil, authn.Errorf("invalid password") } - log.Printf("verified peer certificate: %s", commonName) - return commonName, nil + // Once we've authenticated the request, we can return some information about + // the client. That information gets attached to the context passed to + // subsequent interceptors and our service implementation. + fmt.Printf("verified user: %s\n", username) + return username, nil }, ) handler := auth.Wrap(mux) - // Start the server with TLS. - server := httptest.NewUnstartedServer(handler) - server.TLS = tlsConfig - server.StartTLS() + // Start the server. + server := httptest.NewServer(handler) defer server.Close() - // Create the client certificate. - certPEM, keyPEM, err = createCertificate(caCertPEM, caKeyPEM, "Client") - if err != nil { - log.Fatal(err) - } - certificate, err = tls.X509KeyPair(certPEM, keyPEM) - if err != nil { - log.Fatal(err) - } - tlsClientConfig := &tls.Config{ - Certificates: []tls.Certificate{certificate}, - RootCAs: certPool, - } - - // Create the client. + // Create a client for the server. client := pingv1connect.NewPingServiceClient( - &http.Client{ - Transport: &http.Transport{ - TLSClientConfig: tlsClientConfig, - }, - }, + server.Client(), server.URL, ) - - // Make a request. req := connect.NewRequest(&pingv1.PingRequest{ Text: "hello", }) + // Attach a basic auth to the request. + req.Header().Add("Authorization", "Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==") // "Aladdin:open sesame" rsp, err := client.Ping(context.Background(), req) if err != nil { log.Fatal(err) } - fmt.Printf("got response: %s", rsp.Msg.Text) + fmt.Printf("got response: %s\n", rsp.Msg.Text) // Output: - // verified peer certificate: Client + // verified user: Aladdin // got response: hello } -func TestTLSServer(t *testing.T) { - ctx := context.Background() +func Example_mutualTLS() { + // This example demonstrates how to use mutual TLS with the authn middleware. + // The example uses the ping service from the authn-go/internal/gen/authn/ping/v1 + // package, but the same approach can be used with any service. - // certPool + // Create the certificate authority. The server and client will both use this + // certificate authority to verify each other's certificates. + // + // This example uses a self-signed certificate, so + // we need to use a custom root CA pool. In production, you would use a + // certificate signed by a trusted CA. certPool := x509.NewCertPool() caCertPEM, caKeyPEM, err := createCertificateAuthority() if err != nil { - t.Fatal(err) + log.Fatal(err) } if ok := certPool.AppendCertsFromPEM(caCertPEM); !ok { - t.Fatal("failed to append client certs") + log.Fatal("failed to append certs to pool") } - // Create the server certificate. + // Create the server certificate. The server will use this certificate to + // authenticate itself to the client. We will need to create a custom TLS + // configuration for the server to use this certificate. certPEM, keyPEM, err := createCertificate(caCertPEM, caKeyPEM, "Server") if err != nil { - t.Fatal(err) + log.Fatal(err) } certificate, err := tls.X509KeyPair(certPEM, keyPEM) if err != nil { - t.Fatal(err) + log.Fatal(err) } tlsConfig := &tls.Config{ ClientAuth: tls.RequireAndVerifyClientCert, @@ -167,76 +138,73 @@ func TestTLSServer(t *testing.T) { mux := http.NewServeMux() mux.Handle(pingv1connect.NewPingServiceHandler(pingService{})) + // Wrap the server with authn middleware. auth := authn.NewMiddleware( - func(ctx context.Context, req authn.Request) (any, error) { - if req.TLS == nil { - return nil, fmt.Errorf("no TLS connection state") + func(_ context.Context, req authn.Request) (any, error) { + // Get the TLS connection state from the request. + tls := req.TLS() + if tls == nil { + return nil, authn.Errorf("requires TLS certificate") } - if len(req.TLS.VerifiedChains) == 0 || len(req.TLS.VerifiedChains[0]) == 0 { + if len(tls.VerifiedChains) == 0 || len(tls.VerifiedChains[0]) == 0 { return nil, authn.Errorf("could not verify peer certificate") } - // Check subject common name against configured username - commonName := req.TLS.VerifiedChains[0][0].Subject.CommonName + // Check subject common name against configured username. + // In this example, we hardcode the username. + commonName := tls.VerifiedChains[0][0].Subject.CommonName if commonName != "Client" { return nil, authn.Errorf("invalid subject common name") } + fmt.Printf("verified peer certificate: %s\n", commonName) return commonName, nil }, ) handler := auth.Wrap(mux) + // Start the server with TLS. server := httptest.NewUnstartedServer(handler) server.TLS = tlsConfig server.StartTLS() - t.Cleanup(server.Close) + defer server.Close() + // Create the client certificate. The client will use this certificate to + // authenticate itself to the server. We will need to create a custom TLS + // configuration for the client to use this certificate. certPEM, keyPEM, err = createCertificate(caCertPEM, caKeyPEM, "Client") if err != nil { - t.Fatal(err) + log.Fatal(err) } certificate, err = tls.X509KeyPair(certPEM, keyPEM) if err != nil { - t.Fatal(err) + log.Fatal(err) } - tlsConfig = &tls.Config{ + tlsClientConfig := &tls.Config{ Certificates: []tls.Certificate{certificate}, RootCAs: certPool, } - tlsInsecure := &tls.Config{ - InsecureSkipVerify: true, - } - t.Run("secure", func(t *testing.T) { - client := pingv1connect.NewPingServiceClient( - &http.Client{ - Transport: &http.Transport{ - TLSClientConfig: tlsConfig, - }, - }, - server.URL, - ) - req := connect.NewRequest(&pingv1.PingRequest{ - Text: "hello", - }) - rsp, err := client.Ping(ctx, req) - require.NoError(t, err) - assert.Equal(t, "hello", rsp.Msg.Text) - }) - t.Run("insecure", func(t *testing.T) { - client := pingv1connect.NewPingServiceClient( - &http.Client{ - Transport: &http.Transport{ - TLSClientConfig: tlsInsecure, - }, + // Create the client with the client certificate. + client := pingv1connect.NewPingServiceClient( + &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: tlsClientConfig, }, - server.URL, - ) - req := connect.NewRequest(&pingv1.PingRequest{ - Text: "hello", - }) - _, err := client.Ping(ctx, req) - require.ErrorContains(t, err, "tls: certificate required") + }, + server.URL, + ) + + // Make a request with the created client. + req := connect.NewRequest(&pingv1.PingRequest{ + Text: "hello", }) + rsp, err := client.Ping(context.Background(), req) + if err != nil { + log.Fatal(err) + } + fmt.Printf("got response: %s\n", rsp.Msg.Text) + // Output: + // verified peer certificate: Client + // got response: hello } func createCertificateAuthority() ([]byte, []byte, error) { From 6ca13ebbd4a41524380feb9da23af5ac05e01495 Mon Sep 17 00:00:00 2001 From: Edward McFarlane Date: Wed, 1 Nov 2023 10:32:36 -0400 Subject: [PATCH 04/10] Fix lint --- .golangci.yml | 5 +++++ authn.go | 2 +- authn_test.go | 13 ++++++++----- examples_test.go | 10 +++++++--- 4 files changed, 21 insertions(+), 9 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index ae5b395..ca3c42d 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -46,8 +46,13 @@ linters: - varcheck # abandoned - wrapcheck # don't _always_ need to wrap errors - wsl # generous whitespace violates house style + - nonamedreturns # named returns help document return types issues: exclude: # Don't ban use of fmt.Errorf to create new errors, but the remaining # checks from err113 are useful. - "err113: do not define dynamic errors.*" + exclude-rules: + # We need to init a global in-mem HTTP server for testable examples. + - path: examples_test.go + linters: [gocritic, gochecknoglobals, gosec, exhaustruct] diff --git a/authn.go b/authn.go index a07694e..ddf40e5 100644 --- a/authn.go +++ b/authn.go @@ -165,7 +165,7 @@ func (m *Middleware) Wrap(handler http.Handler) http.Handler { ctx := request.Context() info, err := m.auth(ctx, Request{request: request}) if err != nil { - m.errW.Write(writer, request, err) + _ = m.errW.Write(writer, request, err) return } if info != nil { diff --git a/authn_test.go b/authn_test.go index c550f17..2d936ed 100644 --- a/authn_test.go +++ b/authn_test.go @@ -32,18 +32,20 @@ const ( ) func TestMiddleware(t *testing.T) { + t.Parallel() mux := http.NewServeMux() mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("Check-Info") != "" { - assertInfo(t, r.Context()) + assertInfo(r.Context(), t) } - io.WriteString(w, "ok") + _, _ = io.WriteString(w, "ok") }) handler := authn.NewMiddleware(authenticate).Wrap(mux) server := httptest.NewServer(handler) assertResponse := func(headers http.Header, expectCode int) { - req, err := http.NewRequest( + req, err := http.NewRequestWithContext( + context.Background(), http.MethodPost, server.URL+"/empty.v1/GetEmpty", strings.NewReader("{}"), @@ -57,6 +59,7 @@ func TestMiddleware(t *testing.T) { res, err := server.Client().Do(req) assert.Nil(t, err) assert.Equal(t, res.StatusCode, expectCode) + assert.Nil(t, res.Body.Close()) } // Middleware should ignore non-RPC requests. assertResponse(http.Header{}, 200) @@ -76,7 +79,7 @@ func TestMiddleware(t *testing.T) { ) } -func assertInfo(tb testing.TB, ctx context.Context) { +func assertInfo(ctx context.Context, tb testing.TB) { tb.Helper() info := authn.GetInfo(ctx) if info == nil { @@ -90,7 +93,7 @@ func assertInfo(tb testing.TB, ctx context.Context) { } } -func authenticate(ctx context.Context, req authn.Request) (any, error) { +func authenticate(_ context.Context, req authn.Request) (any, error) { parts := strings.SplitN(req.Header().Get("Authorization"), " ", 2) if len(parts) < 2 || parts[0] != "Bearer" { err := authn.Errorf("expected Bearer authentication scheme") diff --git a/examples_test.go b/examples_test.go index 6c41073..e6872ee 100644 --- a/examples_test.go +++ b/examples_test.go @@ -23,6 +23,7 @@ import ( "crypto/tls" "crypto/x509" "crypto/x509/pkix" + "encoding/base64" "encoding/pem" "errors" "fmt" @@ -87,7 +88,8 @@ func Example_basicAuth() { Text: "hello", }) // Attach a basic auth to the request. - req.Header().Add("Authorization", "Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==") // "Aladdin:open sesame" + authToken := base64.StdEncoding.EncodeToString([]byte("Aladdin:open sesame")) + req.Header().Add("Authorization", "Basic "+authToken) rsp, err := client.Ping(context.Background(), req) if err != nil { log.Fatal(err) @@ -133,6 +135,7 @@ func Example_mutualTLS() { ClientAuth: tls.RequireAndVerifyClientCert, Certificates: []tls.Certificate{certificate}, ClientCAs: certPool, + MinVersion: tls.VersionTLS12, } mux := http.NewServeMux() @@ -181,6 +184,7 @@ func Example_mutualTLS() { tlsClientConfig := &tls.Config{ Certificates: []tls.Certificate{certificate}, RootCAs: certPool, + MinVersion: tls.VersionTLS12, } // Create the client with the client certificate. @@ -208,7 +212,7 @@ func Example_mutualTLS() { } func createCertificateAuthority() ([]byte, []byte, error) { - ca := &x509.Certificate{ + caCert := &x509.Certificate{ SerialNumber: big.NewInt(2021), Subject: pkix.Name{ Organization: []string{"Acme Co"}, @@ -224,7 +228,7 @@ func createCertificateAuthority() ([]byte, []byte, error) { if err != nil { return nil, nil, err } - caBytes, err := x509.CreateCertificate(rand.Reader, ca, ca, &caPrivKey.PublicKey, caPrivKey) + caBytes, err := x509.CreateCertificate(rand.Reader, caCert, caCert, &caPrivKey.PublicKey, caPrivKey) if err != nil { return nil, nil, err } From 9996a77a67b4882a7f320fe6f016d0cbb00484d1 Mon Sep 17 00:00:00 2001 From: Edward McFarlane Date: Wed, 1 Nov 2023 10:36:24 -0400 Subject: [PATCH 05/10] Update readme --- README.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/README.md b/README.md index 23c31db..e7d9b84 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,8 @@ authn-go =============== -TODO +Authn provides authentication middleware for [connect](https://connectrpc.com/). It is designed to work with any authentication scheme, including HTTP Basic Authentication, OAuth2, and custom schemes. It covers both Unary and Streaming RPCs and works with both gRPC and Connect protocols. -## Example - -TODO ## Status: Alpha From fbf265a65daedcc5c80dacf49bb6cad842eb2559 Mon Sep 17 00:00:00 2001 From: Edward McFarlane Date: Wed, 1 Nov 2023 10:40:43 -0400 Subject: [PATCH 06/10] Fix license header --- internal/gen/authn/ping/v1/ping.pb.go | 2 +- internal/gen/authn/ping/v1/pingv1connect/ping.connect.go | 2 +- internal/proto/authn/ping/v1/ping.proto | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/gen/authn/ping/v1/ping.pb.go b/internal/gen/authn/ping/v1/ping.pb.go index d0b1941..d2a6eff 100644 --- a/internal/gen/authn/ping/v1/ping.pb.go +++ b/internal/gen/authn/ping/v1/ping.pb.go @@ -1,4 +1,4 @@ -// Copyright 2021-2023 The Connect Authors +// Copyright 2023 Buf Technologies, Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/internal/gen/authn/ping/v1/pingv1connect/ping.connect.go b/internal/gen/authn/ping/v1/pingv1connect/ping.connect.go index 73be4d2..3904b03 100644 --- a/internal/gen/authn/ping/v1/pingv1connect/ping.connect.go +++ b/internal/gen/authn/ping/v1/pingv1connect/ping.connect.go @@ -1,4 +1,4 @@ -// Copyright 2021-2023 The Connect Authors +// Copyright 2023 Buf Technologies, Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/internal/proto/authn/ping/v1/ping.proto b/internal/proto/authn/ping/v1/ping.proto index 68ffad4..c42c10f 100644 --- a/internal/proto/authn/ping/v1/ping.proto +++ b/internal/proto/authn/ping/v1/ping.proto @@ -1,4 +1,4 @@ -// Copyright 2021-2023 The Connect Authors +// Copyright 2023 Buf Technologies, Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. From b3b094d958121ef1d9bb8705a8c004b01f0eadad Mon Sep 17 00:00:00 2001 From: Edward McFarlane Date: Fri, 3 Nov 2023 13:17:00 -0400 Subject: [PATCH 07/10] Return full URL for procedure name --- authn.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/authn.go b/authn.go index ddf40e5..7b18f6e 100644 --- a/authn.go +++ b/authn.go @@ -76,20 +76,22 @@ func (r Request) BasicAuth() (username string, password string, ok bool) { return r.request.BasicAuth() } -// Procedure returns the RPC procedure name, in the form "/service/method". +// Procedure returns the RPC procedure name, in the form "/service/method". If +// the request path does not contain a procedure name, the entire path is +// returned. func (r Request) Procedure() string { path := strings.TrimSuffix(r.request.URL.Path, "/") ultimate := strings.LastIndex(path, "/") if ultimate < 0 { - return "" + return r.request.URL.Path } penultimate := strings.LastIndex(path[:ultimate], "/") if penultimate < 0 { - return "" + return r.request.URL.Path } procedure := path[penultimate:] if len(procedure) < 4 { // two slashes + service + method - return "" + return r.request.URL.Path } return procedure } From 7029b85b878d905ea71cdfd0a23553650b515fec Mon Sep 17 00:00:00 2001 From: Edward McFarlane Date: Fri, 3 Nov 2023 14:19:59 -0400 Subject: [PATCH 08/10] Use Ali Baba for examples --- examples_test.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/examples_test.go b/examples_test.go index e6872ee..095651a 100644 --- a/examples_test.go +++ b/examples_test.go @@ -60,10 +60,10 @@ func Example_basicAuth() { } // Check username and password against a database. In this example, we // hardcode the credentials. - if subtle.ConstantTimeCompare([]byte(username), []byte("Aladdin")) != 1 { + if subtle.ConstantTimeCompare([]byte(username), []byte("Ali Baba")) != 1 { return nil, authn.Errorf("invalid username") } - if subtle.ConstantTimeCompare([]byte(password), []byte("open sesame")) != 1 { + if subtle.ConstantTimeCompare([]byte(password), []byte("opensesame")) != 1 { return nil, authn.Errorf("invalid password") } // Once we've authenticated the request, we can return some information about @@ -87,8 +87,8 @@ func Example_basicAuth() { req := connect.NewRequest(&pingv1.PingRequest{ Text: "hello", }) - // Attach a basic auth to the request. - authToken := base64.StdEncoding.EncodeToString([]byte("Aladdin:open sesame")) + // Attach a basic auth authorization header to the request. + authToken := base64.StdEncoding.EncodeToString([]byte("Ali Baba:opensesame")) req.Header().Add("Authorization", "Basic "+authToken) rsp, err := client.Ping(context.Background(), req) if err != nil { @@ -96,7 +96,7 @@ func Example_basicAuth() { } fmt.Printf("got response: %s\n", rsp.Msg.Text) // Output: - // verified user: Aladdin + // verified user: Ali Baba // got response: hello } From 3a43784c47e0c87614ce2c29f3cb74e7d70a0d46 Mon Sep 17 00:00:00 2001 From: Edward McFarlane Date: Wed, 8 Nov 2023 15:21:18 -0500 Subject: [PATCH 09/10] Drop readme line --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index e7d9b84..92c4534 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,6 @@ authn-go Authn provides authentication middleware for [connect](https://connectrpc.com/). It is designed to work with any authentication scheme, including HTTP Basic Authentication, OAuth2, and custom schemes. It covers both Unary and Streaming RPCs and works with both gRPC and Connect protocols. - ## Status: Alpha This project is currently in alpha. The API should be considered unstable and likely to change. From d0330c8c9c00ac85aab3f139afa20730a468cfb7 Mon Sep 17 00:00:00 2001 From: Edward McFarlane Date: Wed, 8 Nov 2023 15:33:43 -0500 Subject: [PATCH 10/10] Use pem.EncodeToMemory --- examples_test.go | 33 ++++++++++----------------------- 1 file changed, 10 insertions(+), 23 deletions(-) diff --git a/examples_test.go b/examples_test.go index 095651a..5f98e88 100644 --- a/examples_test.go +++ b/examples_test.go @@ -15,7 +15,6 @@ package authn_test import ( - "bytes" "context" "crypto/rand" "crypto/rsa" @@ -232,21 +231,15 @@ func createCertificateAuthority() ([]byte, []byte, error) { if err != nil { return nil, nil, err } - caPEM := new(bytes.Buffer) - if err := pem.Encode(caPEM, &pem.Block{ + caPEM := pem.EncodeToMemory(&pem.Block{ Type: "CERTIFICATE", Bytes: caBytes, - }); err != nil { - return nil, nil, err - } - caPrivKeyPEM := new(bytes.Buffer) - if err := pem.Encode(caPrivKeyPEM, &pem.Block{ + }) + caPrivKeyPEM := pem.EncodeToMemory(&pem.Block{ Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(caPrivKey), - }); err != nil { - return nil, nil, err - } - return caPEM.Bytes(), caPrivKeyPEM.Bytes(), nil + }) + return caPEM, caPrivKeyPEM, nil } func createCertificate(caCertPEM, caKeyPEM []byte, commonName string) ([]byte, []byte, error) { @@ -286,21 +279,15 @@ func createCertificate(caCertPEM, caKeyPEM []byte, commonName string) ([]byte, [ if err != nil { return nil, nil, err } - certPEM := new(bytes.Buffer) - if err := pem.Encode(certPEM, &pem.Block{ + certPEM := pem.EncodeToMemory(&pem.Block{ Type: "CERTIFICATE", Bytes: certBytes, - }); err != nil { - return nil, nil, err - } - certPrivKeyPEM := new(bytes.Buffer) - if err := pem.Encode(certPrivKeyPEM, &pem.Block{ + }) + certPrivKeyPEM := pem.EncodeToMemory(&pem.Block{ Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(certPrivKey), - }); err != nil { - return nil, nil, err - } - return certPEM.Bytes(), certPrivKeyPEM.Bytes(), nil + }) + return certPEM, certPrivKeyPEM, nil } type pingService struct{}