Repository: enricofoltran/hello-auth-grpc Branch: master Commit: e85f129d8bb7 Files: 29 Total size: 71.6 KB Directory structure: gitextract_3qqoywek/ ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── auth/ │ ├── auth.pb.go │ ├── auth.proto │ └── auth_grpc.pb.go ├── certs/ │ ├── auth-csr.json │ ├── ca-config.json │ ├── ca-csr.json │ ├── client-csr.json │ ├── hello-csr.json │ └── jwt-csr.json ├── cmd/ │ ├── auth-client/ │ │ └── main.go │ ├── auth-server/ │ │ ├── main.go │ │ ├── server.go │ │ └── server_test.go │ ├── hello-client/ │ │ └── main.go │ └── hello-server/ │ ├── main.go │ └── server.go ├── credentials/ │ └── jwt/ │ └── jwt.go ├── go.mod ├── go.sum ├── hello/ │ ├── hello.pb.go │ ├── hello.proto │ └── hello_grpc.pb.go └── pkg/ ├── config/ │ ├── config.go │ └── config_test.go └── logging/ └── logging.go ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ # Binaries bin/ *.exe *.exe~ *.dll *.so *.dylib # Test binary, built with `go test -c` *.test # Output of the go coverage tool *.out coverage.html # Dependency directories vendor/ # Go workspace file go.work # Environment files (contain secrets) .env .env.local # IDE / Editor files .vscode/ .idea/ *.swp *.swo *~ .DS_Store # Certificates and keys (sensitive) *.pem *.csr *.key .token # Deprecated dependency files Gopkg.toml Gopkg.lock ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2017 Enrico Foltran Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: Makefile ================================================ # Configuration directory for certificates and keys CONFIG_PATH=${HOME}/.hello/ # Default target all: init gencert build # Create configuration directory init: mkdir -p ${CONFIG_PATH} # Build all binaries and generate protobuf code build: go mod download protoc --go_out=. --go_opt=paths=source_relative \ --go-grpc_out=. --go-grpc_opt=paths=source_relative \ hello/hello.proto protoc --go_out=. --go_opt=paths=source_relative \ --go-grpc_out=. --go-grpc_opt=paths=source_relative \ auth/auth.proto mkdir -p bin go build -o bin/auth-client ./cmd/auth-client go build -o bin/auth-server ./cmd/auth-server go build -o bin/hello-client ./cmd/hello-client go build -o bin/hello-server ./cmd/hello-server # Generate all certificates and keys using CFSSL gencert: cfssl gencert \ -initca certs/ca-csr.json | cfssljson -bare ca cfssl gencert \ -ca=ca.pem \ -ca-key=ca-key.pem \ -config=certs/ca-config.json \ -profile=server \ certs/hello-csr.json | cfssljson -bare hello cfssl gencert \ -ca=ca.pem \ -ca-key=ca-key.pem \ -config=certs/ca-config.json \ -profile=server \ certs/auth-csr.json | cfssljson -bare auth cfssl gencert \ -ca=ca.pem \ -ca-key=ca-key.pem \ -config=certs/ca-config.json \ -profile=client \ certs/client-csr.json | cfssljson -bare client cfssl gencert \ -ca=ca.pem \ -ca-key=ca-key.pem \ -config=certs/ca-config.json \ -profile=signing \ certs/jwt-csr.json | cfssljson -bare jwt mv *.pem *.csr ${CONFIG_PATH} # Clean build artifacts clean: rm -rf bin/ rm -f hello/hello_grpc.pb.go hello/hello.pb.go rm -f auth/auth_grpc.pb.go auth/auth.pb.go # Clean everything including certificates clean-all: clean rm -rf ${CONFIG_PATH} # Run tests test: go test -v -race -coverprofile=coverage.out ./... # Show test coverage coverage: test go tool cover -html=coverage.out # Install protobuf compiler plugins install-tools: go install google.golang.org/protobuf/cmd/protoc-gen-go@latest go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest .PHONY: all init build gencert clean clean-all test coverage install-tools ================================================ FILE: README.md ================================================ # hello-auth-grpc A secure gRPC microservices demonstration implementing JWT-based authentication with mutual TLS encryption. ## Overview This project demonstrates production-ready security patterns for gRPC services in Go, including: - **JWT Authentication**: RSA-2048 signed tokens with comprehensive claims validation - **Mutual TLS (mTLS)**: Certificate-based encryption and authentication - **Rate Limiting**: Protection against brute force attacks - **Password Security**: bcrypt password hashing - **Input Validation**: Comprehensive validation of all user inputs - **Graceful Shutdown**: Proper signal handling for clean shutdowns - **Request Logging**: Structured logging with interceptors - **Health Checks**: Standard gRPC health checking protocol - **Panic Recovery**: Middleware to prevent crashes ## Architecture The project consists of two independent microservices: ### 1. Auth Service (Port 20000) Authenticates users and issues JWT tokens. - **Endpoint**: `Auth.Login` - **Input**: Username and password - **Output**: Signed JWT token (1-hour validity) - **Security Features**: - bcrypt password hashing - Rate limiting (1 request per 2 seconds per IP) - Input validation (username: 1-64 chars, password: 8-128 chars) - Constant-time comparison to prevent timing attacks ### 2. Hello Service (Port 10000) Provides greeting functionality secured by JWT authentication. - **Endpoint**: `Greeter.SayHello` - **Input**: Empty (username extracted from JWT) - **Output**: Personalized greeting - **Security Features**: - JWT signature verification (RSA) - Comprehensive claims validation (aud, iss, exp, nbf) - Token expiration checking ## Prerequisites - **Go**: 1.21 or later - **protoc**: Protocol Buffer compiler (optional, for regenerating proto files) - **cfssl**: CloudFlare PKI toolkit for certificate generation ### Installing Prerequisites ```bash # Install Go (if not already installed) # See: https://golang.org/doc/install # Install cfssl go install github.com/cloudflare/cfssl/cmd/...@latest # Install protoc (optional) # See: https://grpc.io/docs/protoc-installation/ # Install protoc plugins (optional) make install-tools ``` ## Quick Start ### 1. Setup Clone and initialize the project: ```bash git clone cd hello-auth-grpc make init # Create ~/.hello/ directory make gencert # Generate certificates and keys ``` ### 2. Set Credentials **IMPORTANT**: Use environment variables for credentials (not command-line flags): ```bash export AUTH_USERNAME="admin" export AUTH_PASSWORD="secureP@ssw0rd123" # Must be 8-128 characters ``` ### 3. Build ```bash make build ``` This will: - Download dependencies - Generate protobuf code (if protoc is installed) - Build all binaries to `bin/` directory ### 4. Run Services **Terminal 1 - Start Auth Service:** ```bash export AUTH_USERNAME="admin" export AUTH_PASSWORD="secureP@ssw0rd123" ./bin/auth-server ``` **Terminal 2 - Start Hello Service:** ```bash ./bin/hello-server ``` ### 5. Test the Services **Terminal 3 - Authenticate and Get Token:** ```bash export AUTH_USERNAME="admin" export AUTH_PASSWORD="secureP@ssw0rd123" ./bin/auth-client ``` **Terminal 4 - Call Authenticated Service:** ```bash ./bin/hello-client ``` Expected output: ``` hello-client: 2025/11/14 12:00:00 remote server says: Hello, admin! ``` ## Configuration ### Environment Variables | Variable | Description | Required | Default | |----------|-------------|----------|---------| | `AUTH_USERNAME` | Username for authentication | Yes | - | | `AUTH_PASSWORD` | Password (8-128 characters) | Yes | - | | `HELLO_CONFIG_DIR` | Configuration directory | No | `~/.hello` | ### Command-Line Flags #### Auth Server ```bash ./bin/auth-server \ --listen-addr 127.0.0.1:20000 \ --jwt-key ~/.hello/jwt-key.pem \ --tls-crt ~/.hello/auth.pem \ --tls-key ~/.hello/auth-key.pem \ --ca-crt ~/.hello/ca.pem ``` #### Hello Server ```bash ./bin/hello-server \ --listen-addr 127.0.0.1:10000 \ --jwt-key ~/.hello/jwt.pem \ --tls-crt ~/.hello/hello.pem \ --tls-key ~/.hello/hello-key.pem \ --ca-crt ~/.hello/ca.pem ``` #### Auth Client ```bash ./bin/auth-client \ --server-addr 127.0.0.1:20000 \ --jwt-token ~/.hello/.token \ --tls-crt ~/.hello/client.pem \ --tls-key ~/.hello/client-key.pem \ --ca-crt ~/.hello/ca.pem ``` #### Hello Client ```bash ./bin/hello-client \ --server-addr 127.0.0.1:10000 \ --jwt-token ~/.hello/.token \ --tls-crt ~/.hello/client.pem \ --tls-key ~/.hello/client-key.pem \ --ca-crt ~/.hello/ca.pem ``` ## Security Features ### TLS Configuration - **Minimum Version**: TLS 1.2 - **Cipher Suites**: Only strong ECDHE ciphers with AES-GCM - **Certificate Validation**: Mutual TLS with CA verification - **Key Size**: RSA 2048-bit (consider upgrading to 4096-bit for production) ### JWT Configuration - **Algorithm**: RS256 (RSA with SHA-256) - **Key Size**: 2048-bit RSA - **Token Lifetime**: 1 hour - **Claims Validated**: - `sub` (Subject): Username - `aud` (Audience): "hello.service" - `iss` (Issuer): "auth.service" - `exp` (Expiration): Automatic validation - `nbf` (Not Before): Automatic validation - `iat` (Issued At): Timestamp ### Rate Limiting - **Rate**: 0.5 requests/second (1 request every 2 seconds) - **Burst**: 3 requests - **Scope**: Per client IP address - **Response**: HTTP 429 (Resource Exhausted) ### Password Requirements - **Minimum Length**: 8 characters - **Maximum Length**: 128 characters - **Storage**: bcrypt hashed (cost factor: 10) - **Comparison**: Constant-time to prevent timing attacks ## Development ### Project Structure ``` hello-auth-grpc/ ├── auth/ # Auth service protobuf definitions │ ├── auth.proto │ └── auth.pb.go # Generated code ├── hello/ # Hello service protobuf definitions │ ├── hello.proto │ └── hello.pb.go # Generated code ├── cmd/ # Application entry points │ ├── auth-server/ # Auth server implementation │ ├── auth-client/ # Auth client implementation │ ├── hello-server/ # Hello server implementation │ └── hello-client/ # Hello client implementation ├── pkg/ # Shared packages │ ├── config/ # Configuration utilities │ └── logging/ # Logging and interceptors ├── credentials/ # Custom credential providers │ └── jwt/ # JWT credential implementation ├── certs/ # Certificate configuration files ├── Makefile # Build automation └── README.md # This file ``` ### Makefile Targets ```bash make all # Initialize, generate certificates, and build make init # Create configuration directory make build # Build all binaries make gencert # Generate certificates and keys make clean # Remove binaries and generated code make clean-all # Remove everything including certificates make test # Run tests with race detection and coverage make coverage # Generate and view coverage report make install-tools # Install protobuf compiler plugins ``` ### Running Tests ```bash # Run all tests make test # Run tests with coverage make coverage # Run specific package tests go test -v ./pkg/config/ go test -v ./cmd/auth-server/ ``` ### Adding New Services 1. Define protobuf service in `/.proto` 2. Generate Go code: `protoc --go_out=. --go-grpc_out=. /.proto` 3. Implement server in `cmd/-server/` 4. Implement client in `cmd/-client/` 5. Add to Makefile build targets ## Health Checks Both services implement the standard gRPC health checking protocol: ```bash # Using grpcurl (install: go install github.com/fullstorydev/grpcurl/cmd/grpcurl@latest) grpcurl -plaintext localhost:20000 grpc.health.v1.Health/Check grpcurl -plaintext localhost:10000 grpc.health.v1.Health/Check ``` Expected response: ```json { "status": "SERVING" } ``` ## Logging All services log to stderr with structured format: ``` auth-server: 2025/11/14 12:00:00 server is starting... auth-server: 2025/11/14 12:00:01 method=/auth.Auth/Login client=127.0.0.1:54321 code=OK duration=45ms ``` ## Graceful Shutdown Services handle `SIGTERM` and `SIGINT` signals: ```bash # Send SIGTERM kill -TERM # Or Ctrl+C (SIGINT) ``` Services will: 1. Stop accepting new connections 2. Wait for active requests to complete 3. Clean up resources 4. Exit ## Troubleshooting ### Common Issues **1. "could not load CA certificate"** - Run `make gencert` to generate certificates - Check that `~/.hello/` directory exists - Verify certificate files have correct permissions **2. "please provide AUTH_USERNAME and AUTH_PASSWORD"** - Set environment variables before running servers/clients - Don't use command-line flags for credentials (security risk) **3. "could not connect to remote server"** - Ensure server is running - Check firewall rules - Verify server address and port **4. "invalid credentials"** - Verify AUTH_USERNAME and AUTH_PASSWORD match server settings - Check password meets minimum length requirement (8 chars) **5. "too many login attempts"** - Rate limiting is active - Wait 2 seconds between attempts - Check for multiple clients from same IP **6. "invalid authentication token"** - Token may be expired (1-hour lifetime) - Run auth-client to get new token - Verify token file exists at `~/.hello/.token` ### Debugging Enable verbose logging: ```bash # Set Go's GODEBUG export GODEBUG=http2debug=2 # Run with race detector go run -race ./cmd/auth-server/ ``` ## Security Considerations ### Production Deployment For production use, consider these additional hardening measures: 1. **Certificates** - Use 4096-bit RSA keys or ECDSA P-384 - Implement certificate rotation - Use short-lived certificates (90 days or less) - Store private keys in hardware security modules (HSM) 2. **Authentication** - Implement multi-factor authentication (MFA) - Add account lockout after N failed attempts - Use external identity providers (OAuth2, OIDC) - Implement refresh token mechanism 3. **Authorization** - Add role-based access control (RBAC) - Implement fine-grained permissions - Audit all access attempts 4. **Network** - Deploy behind load balancer - Use firewall rules to restrict access - Enable DDoS protection - Implement network segmentation 5. **Monitoring** - Add Prometheus metrics - Set up alerting (PagerDuty, Opsgenie) - Enable distributed tracing (Jaeger, Zipkin) - Log aggregation (ELK, Loki) 6. **Token Management** - Implement token revocation/blacklisting - Reduce token lifetime (15-30 minutes) - Add refresh token rotation - Store tokens securely (encrypted) ## API Documentation ### Auth Service #### Login Authenticates a user and returns a JWT token. **Request:** ```protobuf message Request { string username = 1; // 1-64 characters string password = 2; // 8-128 characters } ``` **Response:** ```protobuf message Response { string token = 1; // JWT token (1-hour validity) } ``` **Errors:** - `InvalidArgument`: Invalid username or password format - `PermissionDenied`: Invalid credentials - `ResourceExhausted`: Rate limit exceeded - `Internal`: Server error ### Hello Service #### SayHello Returns a personalized greeting for the authenticated user. **Request:** ```protobuf message Request {} // Empty - username from JWT ``` **Response:** ```protobuf message Response { string message = 1; // Greeting message } ``` **Errors:** - `Unauthenticated`: Missing, invalid, or expired token - `Internal`: Server error **Authentication:** Include JWT token in request metadata: ``` authorization: ``` ## Contributing 1. Fork the repository 2. Create a feature branch (`git checkout -b feature/amazing-feature`) 3. Make your changes 4. Run tests (`make test`) 5. Commit your changes (`git commit -m 'Add amazing feature'`) 6. Push to the branch (`git push origin feature/amazing-feature`) 7. Open a Pull Request ## License This project is licensed under the MIT License - see the LICENSE file for details. ## Acknowledgments - Built with [gRPC](https://grpc.io/) - JWT handling with [golang-jwt](https://github.com/golang-jwt/jwt) - Certificate generation with [CFSSL](https://github.com/cloudflare/cfssl) ## Support For issues, questions, or contributions, please open an issue on GitHub. ================================================ FILE: auth/auth.pb.go ================================================ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.10 // protoc v6.30.2 // source: auth/auth.proto package auth import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" reflect "reflect" sync "sync" unsafe "unsafe" ) 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) ) // Request contains user credentials for authentication. type Request struct { state protoimpl.MessageState `protogen:"open.v1"` // username is the user's login name (8-64 characters) Username string `protobuf:"bytes,1,opt,name=username,proto3" json:"username,omitempty"` // password is the user's password (8-128 characters) Password string `protobuf:"bytes,2,opt,name=password,proto3" json:"password,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *Request) Reset() { *x = Request{} mi := &file_auth_auth_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *Request) String() string { return protoimpl.X.MessageStringOf(x) } func (*Request) ProtoMessage() {} func (x *Request) ProtoReflect() protoreflect.Message { mi := &file_auth_auth_proto_msgTypes[0] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use Request.ProtoReflect.Descriptor instead. func (*Request) Descriptor() ([]byte, []int) { return file_auth_auth_proto_rawDescGZIP(), []int{0} } func (x *Request) GetUsername() string { if x != nil { return x.Username } return "" } func (x *Request) GetPassword() string { if x != nil { return x.Password } return "" } // Response contains the authentication result. type Response struct { state protoimpl.MessageState `protogen:"open.v1"` // token is a signed JWT token valid for 1 hour. // The token should be included in the "authorization" metadata // header for authenticated requests to other services. Token string `protobuf:"bytes,1,opt,name=token,proto3" json:"token,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *Response) Reset() { *x = Response{} mi := &file_auth_auth_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *Response) String() string { return protoimpl.X.MessageStringOf(x) } func (*Response) ProtoMessage() {} func (x *Response) ProtoReflect() protoreflect.Message { mi := &file_auth_auth_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use Response.ProtoReflect.Descriptor instead. func (*Response) Descriptor() ([]byte, []int) { return file_auth_auth_proto_rawDescGZIP(), []int{1} } func (x *Response) GetToken() string { if x != nil { return x.Token } return "" } var File_auth_auth_proto protoreflect.FileDescriptor const file_auth_auth_proto_rawDesc = "" + "\n" + "\x0fauth/auth.proto\x12\x04auth\"A\n" + "\aRequest\x12\x1a\n" + "\busername\x18\x01 \x01(\tR\busername\x12\x1a\n" + "\bpassword\x18\x02 \x01(\tR\bpassword\" \n" + "\bResponse\x12\x14\n" + "\x05token\x18\x01 \x01(\tR\x05token2.\n" + "\x04Auth\x12&\n" + "\x05Login\x12\r.auth.Request\x1a\x0e.auth.ResponseB/Z-github.com/enricofoltran/hello-auth-grpc/authb\x06proto3" var ( file_auth_auth_proto_rawDescOnce sync.Once file_auth_auth_proto_rawDescData []byte ) func file_auth_auth_proto_rawDescGZIP() []byte { file_auth_auth_proto_rawDescOnce.Do(func() { file_auth_auth_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_auth_auth_proto_rawDesc), len(file_auth_auth_proto_rawDesc))) }) return file_auth_auth_proto_rawDescData } var file_auth_auth_proto_msgTypes = make([]protoimpl.MessageInfo, 2) var file_auth_auth_proto_goTypes = []any{ (*Request)(nil), // 0: auth.Request (*Response)(nil), // 1: auth.Response } var file_auth_auth_proto_depIdxs = []int32{ 0, // 0: auth.Auth.Login:input_type -> auth.Request 1, // 1: auth.Auth.Login:output_type -> auth.Response 1, // [1:2] is the sub-list for method output_type 0, // [0:1] 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_auth_auth_proto_init() } func file_auth_auth_proto_init() { if File_auth_auth_proto != nil { return } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_auth_auth_proto_rawDesc), len(file_auth_auth_proto_rawDesc)), NumEnums: 0, NumMessages: 2, NumExtensions: 0, NumServices: 1, }, GoTypes: file_auth_auth_proto_goTypes, DependencyIndexes: file_auth_auth_proto_depIdxs, MessageInfos: file_auth_auth_proto_msgTypes, }.Build() File_auth_auth_proto = out.File file_auth_auth_proto_goTypes = nil file_auth_auth_proto_depIdxs = nil } ================================================ FILE: auth/auth.proto ================================================ syntax = "proto3"; package auth; option go_package = "github.com/enricofoltran/hello-auth-grpc/auth"; // Auth service provides user authentication and JWT token issuance. // This service validates user credentials and returns signed JWT tokens // that can be used to authenticate requests to other services. service Auth { // Login authenticates a user with username and password. // On successful authentication, returns a JWT token valid for 1 hour. // Rate limited to prevent brute force attacks. rpc Login (Request) returns (Response); } // Request contains user credentials for authentication. message Request { // username is the user's login name (8-64 characters) string username = 1; // password is the user's password (8-128 characters) string password = 2; } // Response contains the authentication result. message Response { // token is a signed JWT token valid for 1 hour. // The token should be included in the "authorization" metadata // header for authenticated requests to other services. string token = 1; } ================================================ FILE: auth/auth_grpc.pb.go ================================================ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.5.1 // - protoc v6.30.2 // source: auth/auth.proto package auth import ( context "context" grpc "google.golang.org/grpc" codes "google.golang.org/grpc/codes" status "google.golang.org/grpc/status" ) // This is a compile-time assertion to ensure that this generated file // is compatible with the grpc package it is being compiled against. // Requires gRPC-Go v1.64.0 or later. const _ = grpc.SupportPackageIsVersion9 const ( Auth_Login_FullMethodName = "/auth.Auth/Login" ) // AuthClient is the client API for Auth service. // // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. // // Auth service provides user authentication and JWT token issuance. // This service validates user credentials and returns signed JWT tokens // that can be used to authenticate requests to other services. type AuthClient interface { // Login authenticates a user with username and password. // On successful authentication, returns a JWT token valid for 1 hour. // Rate limited to prevent brute force attacks. Login(ctx context.Context, in *Request, opts ...grpc.CallOption) (*Response, error) } type authClient struct { cc grpc.ClientConnInterface } func NewAuthClient(cc grpc.ClientConnInterface) AuthClient { return &authClient{cc} } func (c *authClient) Login(ctx context.Context, in *Request, opts ...grpc.CallOption) (*Response, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(Response) err := c.cc.Invoke(ctx, Auth_Login_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } return out, nil } // AuthServer is the server API for Auth service. // All implementations must embed UnimplementedAuthServer // for forward compatibility. // // Auth service provides user authentication and JWT token issuance. // This service validates user credentials and returns signed JWT tokens // that can be used to authenticate requests to other services. type AuthServer interface { // Login authenticates a user with username and password. // On successful authentication, returns a JWT token valid for 1 hour. // Rate limited to prevent brute force attacks. Login(context.Context, *Request) (*Response, error) mustEmbedUnimplementedAuthServer() } // UnimplementedAuthServer must be embedded to have // forward compatible implementations. // // NOTE: this should be embedded by value instead of pointer to avoid a nil // pointer dereference when methods are called. type UnimplementedAuthServer struct{} func (UnimplementedAuthServer) Login(context.Context, *Request) (*Response, error) { return nil, status.Errorf(codes.Unimplemented, "method Login not implemented") } func (UnimplementedAuthServer) mustEmbedUnimplementedAuthServer() {} func (UnimplementedAuthServer) testEmbeddedByValue() {} // UnsafeAuthServer may be embedded to opt out of forward compatibility for this service. // Use of this interface is not recommended, as added methods to AuthServer will // result in compilation errors. type UnsafeAuthServer interface { mustEmbedUnimplementedAuthServer() } func RegisterAuthServer(s grpc.ServiceRegistrar, srv AuthServer) { // If the following call pancis, it indicates UnimplementedAuthServer was // embedded by pointer and is nil. This will cause panics if an // unimplemented method is ever invoked, so we test this at initialization // time to prevent it from happening at runtime later due to I/O. if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { t.testEmbeddedByValue() } s.RegisterService(&Auth_ServiceDesc, srv) } func _Auth_Login_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(Request) if err := dec(in); err != nil { return nil, err } if interceptor == nil { return srv.(AuthServer).Login(ctx, in) } info := &grpc.UnaryServerInfo{ Server: srv, FullMethod: Auth_Login_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(AuthServer).Login(ctx, req.(*Request)) } return interceptor(ctx, in, info, handler) } // Auth_ServiceDesc is the grpc.ServiceDesc for Auth service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) var Auth_ServiceDesc = grpc.ServiceDesc{ ServiceName: "auth.Auth", HandlerType: (*AuthServer)(nil), Methods: []grpc.MethodDesc{ { MethodName: "Login", Handler: _Auth_Login_Handler, }, }, Streams: []grpc.StreamDesc{}, Metadata: "auth/auth.proto", } ================================================ FILE: certs/auth-csr.json ================================================ { "CN": "localhost", "hosts": [ "localhost", "127.0.0.1" ], "key": { "algo": "rsa", "size": 2048 }, "names": [ { "C": "US", "L": "CA", "ST": "San Francisco", "O": "My Own Company", "OU": "gRPC" } ] } ================================================ FILE: certs/ca-config.json ================================================ { "signing": { "default": { "expiry": "168h" }, "profiles": { "server": { "expiry": "8760h", "usages": [ "signing", "key encipherment", "server auth" ] }, "client": { "expiry": "8760h", "usages": [ "signing", "key encipherment", "client auth" ] }, "signing": { "expiry": "8760h", "usages": [ "signing", "key encipherment" ] } } } } ================================================ FILE: certs/ca-csr.json ================================================ { "CN": "gRPC CA", "key": { "algo": "rsa", "size": 2048 }, "names": [ { "C": "US", "L": "CA", "ST": "San Francisco", "O": "My Own Company", "OU": "gRPC" } ] } ================================================ FILE: certs/client-csr.json ================================================ { "CN": "client", "hosts": [""], "key": { "algo": "rsa", "size": 2048 }, "names": [ { "C": "US", "L": "CA", "ST": "San Francisco", "O": "My Own Company", "OU": "gRPC" } ] } ================================================ FILE: certs/hello-csr.json ================================================ { "CN": "localhost", "hosts": [ "localhost", "127.0.0.1" ], "key": { "algo": "rsa", "size": 2048 }, "names": [ { "C": "US", "L": "CA", "ST": "San Francisco", "O": "My Own Company", "OU": "gRPC" } ] } ================================================ FILE: certs/jwt-csr.json ================================================ { "CN": "client", "hosts": [""], "key": { "algo": "rsa", "size": 2048 }, "names": [ { "C": "US", "L": "CA", "ST": "San Francisco", "O": "My Own Company", "OU": "JWT" } ] } ================================================ FILE: cmd/auth-client/main.go ================================================ package main import ( "context" "crypto/tls" "crypto/x509" "flag" "log" "os" "time" pb "github.com/enricofoltran/hello-auth-grpc/auth" "github.com/enricofoltran/hello-auth-grpc/pkg/config" "google.golang.org/grpc" "google.golang.org/grpc/credentials" ) const ( // RequestTimeout is the maximum time to wait for a request RequestTimeout = 10 * time.Second ) func main() { // Define flags serverAddr := flag.String("server-addr", "127.0.0.1:20000", "remote auth server address") tlsCrt := flag.String("tls-crt", config.WithConfigDir("client.pem"), "client certificate file") tlsKey := flag.String("tls-key", config.WithConfigDir("client-key.pem"), "client private key file") caCrt := flag.String("ca-crt", config.WithConfigDir("ca.pem"), "CA certificate file") jwtToken := flag.String("jwt-token", config.WithConfigDir(".token"), "the jwt auth token file") flag.Parse() logger := log.New(os.Stderr, "auth-client: ", log.LstdFlags) // Get credentials from environment variables (more secure than CLI flags) username := os.Getenv("AUTH_USERNAME") password := os.Getenv("AUTH_PASSWORD") if username == "" || password == "" { logger.Fatalln("please provide AUTH_USERNAME and AUTH_PASSWORD environment variables") } // Load TLS certificate and key crt, err := tls.LoadX509KeyPair(*tlsCrt, *tlsKey) if err != nil { logger.Fatalf("could not load client key pair from file: %v", err) } // Load CA certificate rawCaCrt, err := os.ReadFile(*caCrt) if err != nil { logger.Fatalf("could not load CA certificate from file: %v", err) } caCrtPool := x509.NewCertPool() if ok := caCrtPool.AppendCertsFromPEM(rawCaCrt); !ok { logger.Fatalf("could not append CA certificate to cert pool") } // Hardened TLS configuration tlsConfig := &tls.Config{ Certificates: []tls.Certificate{crt}, RootCAs: caCrtPool, MinVersion: tls.VersionTLS12, } tlsCreds := credentials.NewTLS(tlsConfig) // Connect with dial options conn, err := grpc.NewClient( *serverAddr, grpc.WithTransportCredentials(tlsCreds), ) if err != nil { logger.Fatalf("could not create client: %v", err) } defer conn.Close() clt := pb.NewAuthClient(conn) // Use context with timeout ctx, cancel := context.WithTimeout(context.Background(), RequestTimeout) defer cancel() req := &pb.Request{Username: username, Password: password} res, err := clt.Login(ctx, req) if err != nil { logger.Fatalf("could not login: %v", err) } // Save token with restricted permissions (0600 = owner read/write only) err = os.WriteFile(*jwtToken, []byte(res.Token), 0600) if err != nil { logger.Fatalf("could not save auth token to disk: %v", err) } logger.Println("login succeeded!") } ================================================ FILE: cmd/auth-server/main.go ================================================ package main import ( "crypto/tls" "crypto/x509" "flag" "log" "net" "os" "os/signal" "syscall" pb "github.com/enricofoltran/hello-auth-grpc/auth" "github.com/enricofoltran/hello-auth-grpc/pkg/config" "github.com/enricofoltran/hello-auth-grpc/pkg/logging" "google.golang.org/grpc" "google.golang.org/grpc/credentials" "google.golang.org/grpc/health" healthpb "google.golang.org/grpc/health/grpc_health_v1" ) func main() { // Define flags listenAddr := flag.String("listen-addr", "127.0.0.1:20000", "auth server listen address") jwtKey := flag.String("jwt-key", config.WithConfigDir("jwt-key.pem"), "the private key to use for signing JWT tokens") tlsCrt := flag.String("tls-crt", config.WithConfigDir("auth.pem"), "auth server certificate file") tlsKey := flag.String("tls-key", config.WithConfigDir("auth-key.pem"), "auth server private key file") caCrt := flag.String("ca-crt", config.WithConfigDir("ca.pem"), "CA certificate file") flag.Parse() logger := log.New(os.Stderr, "auth: ", log.LstdFlags) logger.Printf("server is starting...") // Get credentials from environment variables (more secure than CLI flags) username := os.Getenv("AUTH_USERNAME") password := os.Getenv("AUTH_PASSWORD") if username == "" || password == "" { logger.Fatalln("please provide AUTH_USERNAME and AUTH_PASSWORD environment variables") } // Load TLS certificate and key crt, err := tls.LoadX509KeyPair(*tlsCrt, *tlsKey) if err != nil { logger.Fatalf("could not load server key pair from file: %v", err) } // Load CA certificate rawCaCrt, err := os.ReadFile(*caCrt) if err != nil { logger.Fatalf("could not load CA certificate from file: %v", err) } caCrtPool := x509.NewCertPool() if ok := caCrtPool.AppendCertsFromPEM(rawCaCrt); !ok { // Fixed: Don't reference wrong error variable logger.Fatalf("could not append CA certificate to cert pool") } // Hardened TLS configuration tlsConfig := &tls.Config{ Certificates: []tls.Certificate{crt}, ClientAuth: tls.RequireAndVerifyClientCert, ClientCAs: caCrtPool, MinVersion: tls.VersionTLS12, // Enforce minimum TLS 1.2 CipherSuites: []uint16{ // Strong cipher suites only tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, }, PreferServerCipherSuites: true, } tlsCreds := credentials.NewTLS(tlsConfig) // Create listener ln, err := net.Listen("tcp", *listenAddr) if err != nil { logger.Fatalf("could not listen: %v", err) } // Create gRPC server with interceptors for logging and panic recovery grpcServer := grpc.NewServer( grpc.Creds(tlsCreds), grpc.ChainUnaryInterceptor( logging.PanicRecoveryInterceptor(logger), logging.UnaryServerInterceptor(logger), ), ) // Create and register auth server authServer, err := NewAuthServer(*jwtKey, username, password) if err != nil { logger.Fatalf("%v", err) } pb.RegisterAuthServer(grpcServer, authServer) // Register health check service healthServer := health.NewServer() healthpb.RegisterHealthServer(grpcServer, healthServer) healthServer.SetServingStatus("auth.Auth", healthpb.HealthCheckResponse_SERVING) // Set up graceful shutdown sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) go func() { <-sigChan logger.Println("shutting down gracefully...") grpcServer.GracefulStop() }() logger.Printf("server is listening on %s...", *listenAddr) if err := grpcServer.Serve(ln); err != nil { logger.Fatalf("failed to serve: %v", err) } } ================================================ FILE: cmd/auth-server/server.go ================================================ package main import ( "context" "crypto/rsa" "fmt" "os" "strings" "sync" "time" pb "github.com/enricofoltran/hello-auth-grpc/auth" "github.com/golang-jwt/jwt/v5" "golang.org/x/crypto/bcrypt" "golang.org/x/time/rate" "google.golang.org/grpc/codes" "google.golang.org/grpc/peer" "google.golang.org/grpc/status" ) const ( // MaxUsernameLength is the maximum allowed username length MaxUsernameLength = 64 // MaxPasswordLength is the maximum allowed password length MaxPasswordLength = 128 // MinPasswordLength is the minimum required password length MinPasswordLength = 8 // TokenExpiration is the JWT token validity duration (1 hour) TokenExpiration = time.Hour // RateLimitBurst allows burst of login attempts RateLimitBurst = 3 // RateLimitPerSecond limits login attempts per second per IP RateLimitPerSecond = 0.5 // 1 request every 2 seconds ) // server implements the Auth service with security enhancements. type server struct { pb.UnimplementedAuthServer jwtKey *rsa.PrivateKey passwordHash []byte // bcrypt hash of the password username string rateLimiters map[string]*rate.Limiter rateLimitersMu sync.RWMutex } // NewAuthServer creates a new auth server instance with bcrypt password hashing. // The password parameter should be the plaintext password which will be hashed. func NewAuthServer(jwtKeyPath, username, password string) (*server, error) { // Validate inputs if err := validateUsername(username); err != nil { return nil, fmt.Errorf("invalid username: %w", err) } if err := validatePassword(password); err != nil { return nil, fmt.Errorf("invalid password: %w", err) } // Load JWT private key rawJwtKey, err := os.ReadFile(jwtKeyPath) if err != nil { return nil, fmt.Errorf("could not load jwt private key from file: %w", err) } parsedJwtKey, err := jwt.ParseRSAPrivateKeyFromPEM(rawJwtKey) if err != nil { return nil, fmt.Errorf("could not parse jwt private key: %w", err) } // Hash the password using bcrypt passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return nil, fmt.Errorf("could not hash password: %w", err) } return &server{ jwtKey: parsedJwtKey, username: username, passwordHash: passwordHash, rateLimiters: make(map[string]*rate.Limiter), }, nil } // validateUsername checks if the username meets requirements. func validateUsername(username string) error { username = strings.TrimSpace(username) if username == "" { return fmt.Errorf("username cannot be empty") } if len(username) > MaxUsernameLength { return fmt.Errorf("username exceeds maximum length of %d", MaxUsernameLength) } return nil } // validatePassword checks if the password meets requirements. func validatePassword(password string) error { if len(password) < MinPasswordLength { return fmt.Errorf("password must be at least %d characters", MinPasswordLength) } if len(password) > MaxPasswordLength { return fmt.Errorf("password exceeds maximum length of %d", MaxPasswordLength) } return nil } // getRateLimiter returns a rate limiter for the given client address. func (s *server) getRateLimiter(clientAddr string) *rate.Limiter { s.rateLimitersMu.Lock() defer s.rateLimitersMu.Unlock() limiter, exists := s.rateLimiters[clientAddr] if !exists { limiter = rate.NewLimiter(RateLimitPerSecond, RateLimitBurst) s.rateLimiters[clientAddr] = limiter } return limiter } // Login authenticates a user and returns a JWT token. // Implements rate limiting, input validation, and secure password verification. func (s *server) Login(ctx context.Context, r *pb.Request) (*pb.Response, error) { // Get client address for rate limiting clientAddr := "unknown" if p, ok := peer.FromContext(ctx); ok { clientAddr = p.Addr.String() } // Apply rate limiting per client IP limiter := s.getRateLimiter(clientAddr) if !limiter.Allow() { return nil, status.Error(codes.ResourceExhausted, "too many login attempts, please try again later") } // Validate input if err := validateUsername(r.Username); err != nil { return nil, status.Error(codes.InvalidArgument, "invalid username format") } if err := validatePassword(r.Password); err != nil { return nil, status.Error(codes.InvalidArgument, "invalid password format") } // Verify username if r.Username != s.username { // Use constant-time comparison pattern (check password even if username wrong) // to prevent timing attacks bcrypt.CompareHashAndPassword(s.passwordHash, []byte(r.Password)) return nil, status.Error(codes.PermissionDenied, "invalid credentials") } // Verify password using bcrypt if err := bcrypt.CompareHashAndPassword(s.passwordHash, []byte(r.Password)); err != nil { return nil, status.Error(codes.PermissionDenied, "invalid credentials") } // Generate JWT token now := time.Now() exp := now.Add(TokenExpiration) claims := jwt.MapClaims{ "sub": r.Username, "aud": "hello.service", "iss": "auth.service", "exp": exp.Unix(), "iat": now.Unix(), "nbf": now.Unix(), } token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) tokenStr, err := token.SignedString(s.jwtKey) if err != nil { // Don't leak internal error details to client return nil, status.Error(codes.Internal, "failed to generate token") } return &pb.Response{Token: tokenStr}, nil } ================================================ FILE: cmd/auth-server/server_test.go ================================================ package main import ( "testing" "time" ) func TestValidateUsername(t *testing.T) { tests := []struct { name string username string wantErr bool }{ {"valid username", "testuser", false}, {"empty username", "", true}, {"whitespace only", " ", true}, {"too long", string(make([]byte, 65)), true}, {"max length", string(make([]byte, 64)), false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := validateUsername(tt.username) if (err != nil) != tt.wantErr { t.Errorf("validateUsername() error = %v, wantErr %v", err, tt.wantErr) } }) } } func TestValidatePassword(t *testing.T) { tests := []struct { name string password string wantErr bool }{ {"valid password", "password123", false}, {"too short", "pass", true}, {"minimum length", "12345678", false}, {"too long", string(make([]byte, 129)), true}, {"max length", string(make([]byte, 128)), false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := validatePassword(tt.password) if (err != nil) != tt.wantErr { t.Errorf("validatePassword() error = %v, wantErr %v", err, tt.wantErr) } }) } } func TestLogin_InvalidCredentials(t *testing.T) { // Note: This test requires a temporary RSA key for testing // In a real scenario, you'd generate a test key or use a fixture t.Skip("Requires RSA key setup - integration test") } func TestLogin_ValidCredentials(t *testing.T) { // Note: This test requires a temporary RSA key and bcrypt setup t.Skip("Requires RSA key setup - integration test") } func TestLogin_RateLimiting(t *testing.T) { // Note: This test would verify rate limiting works t.Skip("Requires full server setup - integration test") } func TestJWTTokenExpiration(t *testing.T) { // Verify tokens expire in 1 hour now := time.Now() exp := now.Add(TokenExpiration) if exp.Sub(now) != time.Hour { t.Errorf("TokenExpiration should be 1 hour, got %v", exp.Sub(now)) } } func TestLogin_InputValidation(t *testing.T) { t.Skip("Requires full server setup - integration test") // This test would verify: // - Empty username returns InvalidArgument // - Empty password returns InvalidArgument // - Too long username returns InvalidArgument // - Too short password returns InvalidArgument } // Helper function to create a test server // func newTestServer(t *testing.T) *server { // // Generate test RSA key // privateKey, err := rsa.GenerateKey(rand.Reader, 2048) // if err != nil { // t.Fatalf("Failed to generate RSA key: %v", err) // } // // // Hash test password // passwordHash, err := bcrypt.GenerateFromPassword([]byte("testpass123"), bcrypt.DefaultCost) // if err != nil { // t.Fatalf("Failed to hash password: %v", err) // } // // return &server{ // jwtKey: privateKey, // username: "testuser", // passwordHash: passwordHash, // rateLimiters: make(map[string]*rate.Limiter), // } // } ================================================ FILE: cmd/hello-client/main.go ================================================ package main import ( "context" "crypto/tls" "crypto/x509" "flag" "log" "os" "time" "github.com/enricofoltran/hello-auth-grpc/credentials/jwt" pb "github.com/enricofoltran/hello-auth-grpc/hello" "github.com/enricofoltran/hello-auth-grpc/pkg/config" "google.golang.org/grpc" "google.golang.org/grpc/credentials" ) const ( // RequestTimeout is the maximum time to wait for a request RequestTimeout = 10 * time.Second ) func main() { // Define flags serverAddr := flag.String("server-addr", "127.0.0.1:10000", "remote hello server address") tlsCrt := flag.String("tls-crt", config.WithConfigDir("client.pem"), "client certificate file") tlsKey := flag.String("tls-key", config.WithConfigDir("client-key.pem"), "client private key file") caCrt := flag.String("ca-crt", config.WithConfigDir("ca.pem"), "CA certificate file") jwtToken := flag.String("jwt-token", config.WithConfigDir(".token"), "the jwt auth token file") flag.Parse() logger := log.New(os.Stderr, "hello-client: ", log.LstdFlags) // Load TLS certificate and key crt, err := tls.LoadX509KeyPair(*tlsCrt, *tlsKey) if err != nil { logger.Fatalf("could not load client key pair from file: %v", err) } // Load CA certificate rawCaCrt, err := os.ReadFile(*caCrt) if err != nil { logger.Fatalf("could not load CA certificate from file: %v", err) } caCrtPool := x509.NewCertPool() if ok := caCrtPool.AppendCertsFromPEM(rawCaCrt); !ok { logger.Fatalf("could not append CA certificate to cert pool") } // Hardened TLS configuration tlsConfig := &tls.Config{ Certificates: []tls.Certificate{crt}, RootCAs: caCrtPool, MinVersion: tls.VersionTLS12, } tlsCreds := credentials.NewTLS(tlsConfig) // Load JWT credentials jwtCreds, err := jwt.NewFromTokenFile(*jwtToken) if err != nil { logger.Fatalf("could not load jwt token from file: %v", err) } // Connect with dial options conn, err := grpc.NewClient( *serverAddr, grpc.WithTransportCredentials(tlsCreds), grpc.WithPerRPCCredentials(jwtCreds), ) if err != nil { logger.Fatalf("could not create client: %v", err) } defer conn.Close() clt := pb.NewGreeterClient(conn) // Use context with timeout ctx, cancel := context.WithTimeout(context.Background(), RequestTimeout) defer cancel() req := &pb.Request{} res, err := clt.SayHello(ctx, req) if err != nil { logger.Fatalf("could not say hello: %v", err) } logger.Printf("remote server says: %s", res.Message) } ================================================ FILE: cmd/hello-server/main.go ================================================ package main import ( "crypto/tls" "crypto/x509" "flag" "log" "net" "os" "os/signal" "syscall" pb "github.com/enricofoltran/hello-auth-grpc/hello" "github.com/enricofoltran/hello-auth-grpc/pkg/config" "github.com/enricofoltran/hello-auth-grpc/pkg/logging" "google.golang.org/grpc" "google.golang.org/grpc/credentials" "google.golang.org/grpc/health" healthpb "google.golang.org/grpc/health/grpc_health_v1" ) func main() { // Define flags listenAddr := flag.String("listen-addr", "127.0.0.1:10000", "hello server listen address") jwtKey := flag.String("jwt-key", config.WithConfigDir("jwt.pem"), "the public key to use for validating JWT tokens") tlsCrt := flag.String("tls-crt", config.WithConfigDir("hello.pem"), "hello server certificate file") tlsKey := flag.String("tls-key", config.WithConfigDir("hello-key.pem"), "hello server private key file") caCrt := flag.String("ca-crt", config.WithConfigDir("ca.pem"), "CA certificate file") flag.Parse() logger := log.New(os.Stderr, "hello: ", log.LstdFlags) logger.Printf("server is starting...") // Load TLS certificate and key crt, err := tls.LoadX509KeyPair(*tlsCrt, *tlsKey) if err != nil { logger.Fatalf("could not load server key pair from file: %v", err) } // Load CA certificate rawCaCrt, err := os.ReadFile(*caCrt) if err != nil { logger.Fatalf("could not load CA certificate from file: %v", err) } caCrtPool := x509.NewCertPool() if ok := caCrtPool.AppendCertsFromPEM(rawCaCrt); !ok { // Fixed: Don't reference wrong error variable logger.Fatalf("could not append CA certificate to cert pool") } // Hardened TLS configuration tlsConfig := &tls.Config{ Certificates: []tls.Certificate{crt}, ClientAuth: tls.RequireAndVerifyClientCert, ClientCAs: caCrtPool, MinVersion: tls.VersionTLS12, // Enforce minimum TLS 1.2 CipherSuites: []uint16{ // Strong cipher suites only tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, }, PreferServerCipherSuites: true, } tlsCreds := credentials.NewTLS(tlsConfig) // Create listener ln, err := net.Listen("tcp", *listenAddr) if err != nil { logger.Fatalf("could not listen: %v", err) } // Create gRPC server with interceptors for logging and panic recovery grpcServer := grpc.NewServer( grpc.Creds(tlsCreds), grpc.ChainUnaryInterceptor( logging.PanicRecoveryInterceptor(logger), logging.UnaryServerInterceptor(logger), ), ) // Create and register hello server helloServer, err := NewHelloServer(*jwtKey) if err != nil { logger.Fatalf("%v", err) } pb.RegisterGreeterServer(grpcServer, helloServer) // Register health check service healthServer := health.NewServer() healthpb.RegisterHealthServer(grpcServer, healthServer) healthServer.SetServingStatus("hello.Greeter", healthpb.HealthCheckResponse_SERVING) // Set up graceful shutdown sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) go func() { <-sigChan logger.Println("shutting down gracefully...") grpcServer.GracefulStop() }() logger.Printf("server is listening on %s...", *listenAddr) if err := grpcServer.Serve(ln); err != nil { logger.Fatalf("failed to serve: %v", err) } } ================================================ FILE: cmd/hello-server/server.go ================================================ package main import ( "context" "crypto/rsa" "fmt" "os" pb "github.com/enricofoltran/hello-auth-grpc/hello" "github.com/golang-jwt/jwt/v5" "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" ) const ( // ExpectedAudience is the expected audience claim for JWT tokens ExpectedAudience = "hello.service" // ExpectedIssuer is the expected issuer claim for JWT tokens ExpectedIssuer = "auth.service" ) // server implements the Greeter service with JWT authentication. type server struct { pb.UnimplementedGreeterServer jwtKey *rsa.PublicKey } // claims represents the JWT claims structure with validation. type claims struct { jwt.RegisteredClaims } // validateJwtToken validates a JWT token with comprehensive claims checking. func validateJwtToken(tokenString string, key *rsa.PublicKey) (*jwt.Token, *claims, error) { // Parse and validate the token jwtToken, err := jwt.ParseWithClaims(tokenString, &claims{}, func(t *jwt.Token) (interface{}, error) { // Verify signing method if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok { return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) } return key, nil }) if err != nil { return nil, nil, fmt.Errorf("token parsing failed: %w", err) } // Extract and validate claims claims, ok := jwtToken.Claims.(*claims) if !ok || !jwtToken.Valid { return nil, nil, fmt.Errorf("invalid token claims") } // Validate audience audiences, err := claims.GetAudience() if err != nil { return nil, nil, fmt.Errorf("invalid audience claim: %w", err) } validAudience := false for _, aud := range audiences { if aud == ExpectedAudience { validAudience = true break } } if !validAudience { return nil, nil, fmt.Errorf("invalid audience: expected %s", ExpectedAudience) } // Validate issuer issuer, err := claims.GetIssuer() if err != nil { return nil, nil, fmt.Errorf("invalid issuer claim: %w", err) } if issuer != ExpectedIssuer { return nil, nil, fmt.Errorf("invalid issuer: expected %s, got %s", ExpectedIssuer, issuer) } // Validate expiration (jwt library does this automatically, but we check explicitly) if exp, err := claims.GetExpirationTime(); err != nil || exp == nil { return nil, nil, fmt.Errorf("invalid expiration claim") } // Validate not before (jwt library does this automatically, but we check explicitly) if nbf, err := claims.GetNotBefore(); err != nil || nbf == nil { return nil, nil, fmt.Errorf("invalid not-before claim") } return jwtToken, claims, nil } // NewHelloServer creates a new hello server instance. func NewHelloServer(jwtKeyPath string) (*server, error) { rawJwtKey, err := os.ReadFile(jwtKeyPath) if err != nil { return nil, fmt.Errorf("could not load jwt public key from file: %w", err) } parsedJwtKey, err := jwt.ParseRSAPublicKeyFromPEM(rawJwtKey) if err != nil { return nil, fmt.Errorf("could not parse jwt public key: %w", err) } return &server{jwtKey: parsedJwtKey}, nil } // SayHello implements the Greeter service with JWT authentication. func (s *server) SayHello(ctx context.Context, r *pb.Request) (*pb.Response, error) { // Extract metadata from context md, ok := metadata.FromIncomingContext(ctx) if !ok { return nil, status.Error(codes.Unauthenticated, "missing authentication metadata") } // Get authorization token jwtTokens, ok := md["authorization"] if !ok || len(jwtTokens) == 0 { return nil, status.Error(codes.Unauthenticated, "missing authorization token") } // Validate the JWT token _, claims, err := validateJwtToken(jwtTokens[0], s.jwtKey) if err != nil { // Don't leak validation error details to client return nil, status.Error(codes.Unauthenticated, "invalid authentication token") } // Extract subject (username) from claims subject, err := claims.GetSubject() if err != nil || subject == "" { return nil, status.Error(codes.Unauthenticated, "invalid token subject") } return &pb.Response{Message: "Hello, " + subject + "!"}, nil } ================================================ FILE: credentials/jwt/jwt.go ================================================ // Package jwt provides gRPC per-RPC credentials for JWT token authentication. package jwt import ( "context" "fmt" "os" "google.golang.org/grpc/credentials" ) // jwt implements the credentials.PerRPCCredentials interface for JWT tokens. type jwt struct { token string } // NewFromTokenFile creates a JWT credential from a token file. // The token file should contain a valid JWT token string. func NewFromTokenFile(tokenPath string) (credentials.PerRPCCredentials, error) { data, err := os.ReadFile(tokenPath) if err != nil { return jwt{}, fmt.Errorf("could not read token file: %w", err) } if len(data) == 0 { return jwt{}, fmt.Errorf("token cannot be empty") } return jwt{string(data)}, nil } // GetRequestMetadata implements credentials.PerRPCCredentials. // It adds the JWT token to the request metadata under the "authorization" key. func (j jwt) GetRequestMetadata(ctx context.Context, uri ...string) (map[string]string, error) { return map[string]string{ "authorization": j.token, }, nil } // RequireTransportSecurity implements credentials.PerRPCCredentials. // It returns true to enforce that JWT tokens are only sent over secure connections. func (j jwt) RequireTransportSecurity() bool { return true } ================================================ FILE: go.mod ================================================ module github.com/enricofoltran/hello-auth-grpc go 1.24.7 require ( github.com/golang-jwt/jwt/v5 v5.3.0 github.com/golang/protobuf v1.5.4 golang.org/x/crypto v0.44.0 golang.org/x/net v0.46.0 golang.org/x/time v0.14.0 google.golang.org/grpc v1.76.0 ) require ( golang.org/x/sys v0.38.0 // indirect golang.org/x/text v0.31.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b // indirect google.golang.org/protobuf v1.36.10 // indirect ) ================================================ FILE: go.sum ================================================ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU= golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b h1:zPKJod4w6F1+nRGDI9ubnXYhU9NSWoFAijkHkUXeTK8= google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A= google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= ================================================ FILE: hello/hello.pb.go ================================================ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.10 // protoc v6.30.2 // source: hello/hello.proto package hello import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" reflect "reflect" sync "sync" unsafe "unsafe" ) 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) ) // Request for SayHello RPC. // Currently empty as the username is extracted from the JWT token. type Request struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *Request) Reset() { *x = Request{} mi := &file_hello_hello_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *Request) String() string { return protoimpl.X.MessageStringOf(x) } func (*Request) ProtoMessage() {} func (x *Request) ProtoReflect() protoreflect.Message { mi := &file_hello_hello_proto_msgTypes[0] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use Request.ProtoReflect.Descriptor instead. func (*Request) Descriptor() ([]byte, []int) { return file_hello_hello_proto_rawDescGZIP(), []int{0} } // Response contains the greeting message. type Response struct { state protoimpl.MessageState `protogen:"open.v1"` // message is a personalized greeting for the authenticated user Message string `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *Response) Reset() { *x = Response{} mi := &file_hello_hello_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *Response) String() string { return protoimpl.X.MessageStringOf(x) } func (*Response) ProtoMessage() {} func (x *Response) ProtoReflect() protoreflect.Message { mi := &file_hello_hello_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use Response.ProtoReflect.Descriptor instead. func (*Response) Descriptor() ([]byte, []int) { return file_hello_hello_proto_rawDescGZIP(), []int{1} } func (x *Response) GetMessage() string { if x != nil { return x.Message } return "" } var File_hello_hello_proto protoreflect.FileDescriptor const file_hello_hello_proto_rawDesc = "" + "\n" + "\x11hello/hello.proto\x12\x05hello\"\t\n" + "\aRequest\"$\n" + "\bResponse\x12\x18\n" + "\amessage\x18\x01 \x01(\tR\amessage26\n" + "\aGreeter\x12+\n" + "\bSayHello\x12\x0e.hello.Request\x1a\x0f.hello.ResponseB0Z.github.com/enricofoltran/hello-auth-grpc/hellob\x06proto3" var ( file_hello_hello_proto_rawDescOnce sync.Once file_hello_hello_proto_rawDescData []byte ) func file_hello_hello_proto_rawDescGZIP() []byte { file_hello_hello_proto_rawDescOnce.Do(func() { file_hello_hello_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_hello_hello_proto_rawDesc), len(file_hello_hello_proto_rawDesc))) }) return file_hello_hello_proto_rawDescData } var file_hello_hello_proto_msgTypes = make([]protoimpl.MessageInfo, 2) var file_hello_hello_proto_goTypes = []any{ (*Request)(nil), // 0: hello.Request (*Response)(nil), // 1: hello.Response } var file_hello_hello_proto_depIdxs = []int32{ 0, // 0: hello.Greeter.SayHello:input_type -> hello.Request 1, // 1: hello.Greeter.SayHello:output_type -> hello.Response 1, // [1:2] is the sub-list for method output_type 0, // [0:1] 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_hello_hello_proto_init() } func file_hello_hello_proto_init() { if File_hello_hello_proto != nil { return } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_hello_hello_proto_rawDesc), len(file_hello_hello_proto_rawDesc)), NumEnums: 0, NumMessages: 2, NumExtensions: 0, NumServices: 1, }, GoTypes: file_hello_hello_proto_goTypes, DependencyIndexes: file_hello_hello_proto_depIdxs, MessageInfos: file_hello_hello_proto_msgTypes, }.Build() File_hello_hello_proto = out.File file_hello_hello_proto_goTypes = nil file_hello_hello_proto_depIdxs = nil } ================================================ FILE: hello/hello.proto ================================================ syntax = "proto3"; package hello; option go_package = "github.com/enricofoltran/hello-auth-grpc/hello"; // Greeter service provides a simple greeting functionality. // All RPC methods require JWT authentication via the "authorization" // metadata header. The JWT token must be obtained from the Auth service. service Greeter { // SayHello returns a personalized greeting message. // Requires valid JWT token in request metadata. rpc SayHello (Request) returns (Response); } // Request for SayHello RPC. // Currently empty as the username is extracted from the JWT token. message Request {} // Response contains the greeting message. message Response { // message is a personalized greeting for the authenticated user string message = 1; } ================================================ FILE: hello/hello_grpc.pb.go ================================================ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.5.1 // - protoc v6.30.2 // source: hello/hello.proto package hello import ( context "context" grpc "google.golang.org/grpc" codes "google.golang.org/grpc/codes" status "google.golang.org/grpc/status" ) // This is a compile-time assertion to ensure that this generated file // is compatible with the grpc package it is being compiled against. // Requires gRPC-Go v1.64.0 or later. const _ = grpc.SupportPackageIsVersion9 const ( Greeter_SayHello_FullMethodName = "/hello.Greeter/SayHello" ) // GreeterClient is the client API for Greeter service. // // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. // // Greeter service provides a simple greeting functionality. // All RPC methods require JWT authentication via the "authorization" // metadata header. The JWT token must be obtained from the Auth service. type GreeterClient interface { // SayHello returns a personalized greeting message. // Requires valid JWT token in request metadata. SayHello(ctx context.Context, in *Request, opts ...grpc.CallOption) (*Response, error) } type greeterClient struct { cc grpc.ClientConnInterface } func NewGreeterClient(cc grpc.ClientConnInterface) GreeterClient { return &greeterClient{cc} } func (c *greeterClient) SayHello(ctx context.Context, in *Request, opts ...grpc.CallOption) (*Response, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(Response) err := c.cc.Invoke(ctx, Greeter_SayHello_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } return out, nil } // GreeterServer is the server API for Greeter service. // All implementations must embed UnimplementedGreeterServer // for forward compatibility. // // Greeter service provides a simple greeting functionality. // All RPC methods require JWT authentication via the "authorization" // metadata header. The JWT token must be obtained from the Auth service. type GreeterServer interface { // SayHello returns a personalized greeting message. // Requires valid JWT token in request metadata. SayHello(context.Context, *Request) (*Response, error) mustEmbedUnimplementedGreeterServer() } // UnimplementedGreeterServer must be embedded to have // forward compatible implementations. // // NOTE: this should be embedded by value instead of pointer to avoid a nil // pointer dereference when methods are called. type UnimplementedGreeterServer struct{} func (UnimplementedGreeterServer) SayHello(context.Context, *Request) (*Response, error) { return nil, status.Errorf(codes.Unimplemented, "method SayHello not implemented") } func (UnimplementedGreeterServer) mustEmbedUnimplementedGreeterServer() {} func (UnimplementedGreeterServer) testEmbeddedByValue() {} // UnsafeGreeterServer may be embedded to opt out of forward compatibility for this service. // Use of this interface is not recommended, as added methods to GreeterServer will // result in compilation errors. type UnsafeGreeterServer interface { mustEmbedUnimplementedGreeterServer() } func RegisterGreeterServer(s grpc.ServiceRegistrar, srv GreeterServer) { // If the following call pancis, it indicates UnimplementedGreeterServer was // embedded by pointer and is nil. This will cause panics if an // unimplemented method is ever invoked, so we test this at initialization // time to prevent it from happening at runtime later due to I/O. if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { t.testEmbeddedByValue() } s.RegisterService(&Greeter_ServiceDesc, srv) } func _Greeter_SayHello_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(Request) if err := dec(in); err != nil { return nil, err } if interceptor == nil { return srv.(GreeterServer).SayHello(ctx, in) } info := &grpc.UnaryServerInfo{ Server: srv, FullMethod: Greeter_SayHello_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(GreeterServer).SayHello(ctx, req.(*Request)) } return interceptor(ctx, in, info, handler) } // Greeter_ServiceDesc is the grpc.ServiceDesc for Greeter service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) var Greeter_ServiceDesc = grpc.ServiceDesc{ ServiceName: "hello.Greeter", HandlerType: (*GreeterServer)(nil), Methods: []grpc.MethodDesc{ { MethodName: "SayHello", Handler: _Greeter_SayHello_Handler, }, }, Streams: []grpc.StreamDesc{}, Metadata: "hello/hello.proto", } ================================================ FILE: pkg/config/config.go ================================================ // Package config provides shared configuration utilities for the hello-auth-grpc services. package config import ( "os" "path/filepath" ) // DefaultConfigDir returns the default configuration directory. // Can be overridden with the HELLO_CONFIG_DIR environment variable. func DefaultConfigDir() string { if dir := os.Getenv("HELLO_CONFIG_DIR"); dir != "" { return dir } return filepath.Join(os.Getenv("HOME"), ".hello") } // WithConfigDir returns the full path for a file within the configuration directory. func WithConfigDir(path string) string { return filepath.Join(DefaultConfigDir(), path) } ================================================ FILE: pkg/config/config_test.go ================================================ package config import ( "os" "path/filepath" "testing" ) func TestDefaultConfigDir(t *testing.T) { // Test with custom env var customDir := "/tmp/custom-config" os.Setenv("HELLO_CONFIG_DIR", customDir) defer os.Unsetenv("HELLO_CONFIG_DIR") got := DefaultConfigDir() if got != customDir { t.Errorf("DefaultConfigDir() = %v, want %v", got, customDir) } // Test with default (HOME/.hello) os.Unsetenv("HELLO_CONFIG_DIR") got = DefaultConfigDir() expected := filepath.Join(os.Getenv("HOME"), ".hello") if got != expected { t.Errorf("DefaultConfigDir() = %v, want %v", got, expected) } } func TestWithConfigDir(t *testing.T) { testFile := "test.pem" got := WithConfigDir(testFile) // Should combine config dir with file if !filepath.IsAbs(got) { t.Errorf("WithConfigDir() should return absolute path, got %v", got) } if filepath.Base(got) != testFile { t.Errorf("WithConfigDir() should preserve filename, got %v", got) } } ================================================ FILE: pkg/logging/logging.go ================================================ // Package logging provides structured logging utilities for gRPC services. package logging import ( "context" "log" "time" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/peer" "google.golang.org/grpc/status" ) // UnaryServerInterceptor returns a gRPC interceptor that logs all requests. func UnaryServerInterceptor(logger *log.Logger) grpc.UnaryServerInterceptor { return func( ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler, ) (interface{}, error) { start := time.Now() // Get client address if available clientAddr := "unknown" if p, ok := peer.FromContext(ctx); ok { clientAddr = p.Addr.String() } // Call the handler resp, err := handler(ctx, req) // Log the request duration := time.Since(start) code := codes.OK if err != nil { if st, ok := status.FromError(err); ok { code = st.Code() } else { code = codes.Unknown } } logger.Printf("method=%s client=%s code=%s duration=%v", info.FullMethod, clientAddr, code, duration) if err != nil { logger.Printf("error: %v", err) } return resp, err } } // PanicRecoveryInterceptor returns a gRPC interceptor that recovers from panics. func PanicRecoveryInterceptor(logger *log.Logger) grpc.UnaryServerInterceptor { return func( ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler, ) (resp interface{}, err error) { defer func() { if r := recover(); r != nil { logger.Printf("PANIC recovered: method=%s panic=%v", info.FullMethod, r) err = status.Errorf(codes.Internal, "internal server error") } }() return handler(ctx, req) } }