diff --git a/internal/authng/auth.go b/internal/authng/auth.go new file mode 100644 index 0000000..8137630 --- /dev/null +++ b/internal/authng/auth.go @@ -0,0 +1,67 @@ +// Package auth provides helpers to authenticate and authorize RPC calls in a Kubernetes RBAC like approach. +// JWT tokens are used to transport identity information. Tokens can either be issued by methods +// provided by this package or originate from an openid connect provider. +package auth + +import ( + "context" + "slices" +) + +const ( + // MetadataHeader is the name of the header + MetadataHeader = "authorization" + // MetadataSchema is the authorization schema + MetadataSchema = "Bearer" +) + +// Verifier verifies a JWT token. +type Verifier interface { + Verify(ctx context.Context, rawIDToken string) (*User, error) +} + +// Config describes which roles are allowed (authorized) to access certain services with the given methods. +// Example config: +// --- +// - role: reader +// rules: +// - service: postfinance.burger.namespace.v1.NamespaceAPI +// methods: +// - Get +// - Read +// - service: postfinance.burger.namespace.v1.DeploymentAPI +// methods: +// - Get +// - Read +type Config struct { + Role string `yaml:"role"` + Rules []Rule `yaml:"rules"` +} + +// Rule is an authorization rule matching the given api group and method(s). +type Rule struct { + Service string `yaml:"service"` + Methods []string `yaml:"methods"` +} + +// Configs is a slice of authorization configurations. +type Configs []Config + +// IsAuthorized returns true if the user has the permission to access the service +func (ac Configs) IsAuthorized(service, method string, user User) bool { + for idx := range ac { + authz := ac[idx] + + if slices.Contains(user.Roles, authz.Role) { + for _, rule := range authz.Rules { + if rule.Service == service { + if slices.Contains(rule.Methods, method) { + return true + } + } + } + } + } + + return false +} diff --git a/internal/authng/client.go b/internal/authng/client.go new file mode 100644 index 0000000..8f2d83a --- /dev/null +++ b/internal/authng/client.go @@ -0,0 +1,21 @@ +package auth + +import ( + "context" + + "connectrpc.com/connect" +) + +// WithToken configures a token authenticator for use in connect.WithInterceptors(...). +func WithToken(token string) connect.UnaryInterceptorFunc { + return connect.UnaryInterceptorFunc(func(next connect.UnaryFunc) connect.UnaryFunc { + return connect.UnaryFunc(func( + ctx context.Context, + req connect.AnyRequest, + ) (connect.AnyResponse, error) { + req.Header().Set(MetadataHeader, MetadataSchema+" "+token) + + return next(ctx, req) + }) + }) +} diff --git a/internal/authng/interceptor.go b/internal/authng/interceptor.go new file mode 100644 index 0000000..6200446 --- /dev/null +++ b/internal/authng/interceptor.go @@ -0,0 +1,206 @@ +package auth + +import ( + "context" + "errors" + "fmt" + "strings" + + jwt "github.com/golang-jwt/jwt/v4" + + "connectrpc.com/connect" +) + +// Authorizer can be used to authenticate and authorize ConnectRPC unary calls through the use of the provided interceptors. +type Authorizer struct { + verifiers map[string]Verifier + parser *jwt.Parser + public map[string]bool + config Configs + authCallback AuthCallback +} + +// AuthCallback is a callback function which will be executed after successful authentication. +type AuthCallback func(context.Context, User) + +// NewAuthorizer returns a configures authorizer which can be used as an interceptor to authenticate and authorize ConnectRPC +// streaming and unary calls. +func NewAuthorizer(c Configs, opts ...func(*Authorizer)) *Authorizer { + a := Authorizer{ + verifiers: make(map[string]Verifier), + parser: new(jwt.Parser), + config: c, + } + + for _, opt := range opts { + opt(&a) + } + + if len(a.verifiers) < 1 { + panic("no token verifier(s) configured, use WithVerifier() option to configure one") + } + + return &a +} + +// WithPublicEndpoints configures public endpoints. The endpoint must be fully qualified, e.g.: /postfinance.echo.v1.EchoAPI/Echo +func WithPublicEndpoints(eps ...string) func(*Authorizer) { + return func(a *Authorizer) { + public := make(map[string]bool) + for _, ep := range eps { + public[ep] = true + } + + a.public = public + } +} + +// WithAuthCallback configures a callback function in the authorizer. +func WithAuthCallback(cb AuthCallback) func(*Authorizer) { + return func(a *Authorizer) { + a.authCallback = cb + } +} + +// WithVerifier configures a token verifier for the given issuer. Can be provided multiple times with +// different issuers to validate tokens from different sources (eg. self issued and oidc issued tokens). +func WithVerifier(issuer string, verifier Verifier) func(*Authorizer) { + return func(a *Authorizer) { + a.verifiers[issuer] = verifier + } +} + +// WithVerifierByIssuerAndClientID configures a token verifier for the given issuer with different ClientIDs +// to validate tokens from the same sources with different ClientIDs. +func WithVerifierByIssuerAndClientID(issuer, clientID string, verifier Verifier) func(*Authorizer) { + return func(a *Authorizer) { + a.verifiers[fmt.Sprintf("%s::%s", issuer, clientID)] = verifier + } +} + +// UnaryServerInterceptor returns a ConnectRPC server interceptor to authenticate and authorize unary calls. +func (a *Authorizer) UnaryServerInterceptor() connect.UnaryInterceptorFunc { + interceptor := func(next connect.UnaryFunc) connect.UnaryFunc { + return connect.UnaryFunc(func( + ctx context.Context, + req connect.AnyRequest, + ) (connect.AnyResponse, error) { + // public endpoint - needs no authentication or authorization + if a.public[req.Spec().Procedure] { + return next(ctx, req) + } + + // wrap authorization header in context to be compatible with grpcauth 1.2.1 + key := MetadataHeader + value := req.Header().Get(MetadataHeader) + vCtx := context.WithValue(context.Background(), key, value) //nolint:staticcheck // keep the logic from grpcauth 1.2.1 + + wrappedCtx, err := a.authenticate(vCtx) + if err != nil { + return nil, err + } + + if err := a.authorize(wrappedCtx, req.Spec().Procedure); err != nil { + return nil, err + } + + return next(wrappedCtx, req) + }) + } + + return connect.UnaryInterceptorFunc(interceptor) +} + +// Authenticate authenticates a user. The jwt token is taken out of the incoming context and the issuer (ISS) is parsed +// out of the token to determine which token verifier to call. If a verifier for the issuer is found, it will be called +// to verify the token and obtain a user object (if the token is valid). The user is then placed in the outgoing context +// and can safely be used later. +func (a *Authorizer) authenticate(ctx context.Context) (context.Context, error) { + val := ctx.Value(MetadataHeader).(string) + if val == "" { + return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("missing authorization header")) + } + + splits := strings.SplitN(val, " ", 2) + if len(splits) < 2 { + return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("malformed authorization string")) + } + + scheme := splits[0] + token := splits[1] + + if !strings.EqualFold(scheme, MetadataSchema) { + return nil, connect.NewError(connect.CodeUnauthenticated, fmt.Errorf("authentication scheme %s is not supported", scheme)) + } + + var claims jwt.RegisteredClaims + // Unverified parse, since we're only interested in the issuer of the token, so we can determine which verifier we + // must use to parse and verify the token correctly. + if _, _, err := a.parser.ParseUnverified(token, &claims); err != nil { + return nil, connect.NewError(connect.CodeUnauthenticated, fmt.Errorf("parse jwt: %w", err)) + } + + verifier := a.verifier(&claims) + if verifier == nil { + return nil, connect.NewError(connect.CodeUnauthenticated, fmt.Errorf("unknown issuer %s and issuer with audience %s::[%s]", claims.Issuer, claims.Issuer, strings.Join(claims.Audience, ","))) + } + + user, err := verifier.Verify(ctx, token) + if err != nil { + return nil, connect.NewError(connect.CodeUnauthenticated, fmt.Errorf("authentication failed: %w", err)) + } + + if a.authCallback != nil { + a.authCallback(ctx, *user) + } + + // warp the incoming context and put the user object into the new context + return context.WithValue(ctx, UserCtxKey, user), nil +} + +func (a *Authorizer) verifier(claims *jwt.RegisteredClaims) Verifier { + if claims.Audience == nil { + return a.verifiers[claims.Issuer] + } + + for _, audience := range claims.Audience { + for _, id := range []string{fmt.Sprintf("%s::%s", claims.Issuer, audience), claims.Issuer} { + verifier, ok := a.verifiers[id] + if ok { + return verifier + } + } + } + + return nil +} + +// Authorize authorizes a ConnectRPC call. The ConnectRPC method is splited into its group and method, the user information is +// extracted from the incoming context. With this information authorization is performed, based on the roles of a user +// and the interceptors authorization configurations. +func (a *Authorizer) authorize(ctx context.Context, fullMethod string) error { + // fullMethod in ConnectRPC is in the form /postfinance.burger.v1.NamespaceAPI/Create --> /service/method + splits := strings.SplitN(fullMethod, "/", 3) + if len(splits) != 3 { + return connect.NewError(connect.CodeFailedPrecondition, fmt.Errorf("malformed ConnectRPC method %s", fullMethod)) + } + + method := splits[2] + service := splits[1] + + user, ok := UserFromContext(ctx) + if !ok { + return connect.NewError(connect.CodeFailedPrecondition, errors.New("no user information found in metadata")) + } + + if !a.config.IsAuthorized(service, method, user) { + return connect.NewError(connect.CodePermissionDenied, fmt.Errorf("user %s with roles %s is not allowed to call %s in service %s", + user.Name, + strings.Join(user.Roles, ","), + method, + service, + )) + } + + return nil +} diff --git a/internal/authng/interceptor_test.go b/internal/authng/interceptor_test.go new file mode 100644 index 0000000..ee28674 --- /dev/null +++ b/internal/authng/interceptor_test.go @@ -0,0 +1,205 @@ +package auth_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "connectrpc.com/connect" + jwt "github.com/golang-jwt/jwt/v4" + auth "github.com/postfinance/discovery/internal/authng" + discoveryv1 "github.com/postfinance/discovery/pkg/discoverypb/postfinance/discovery/v1" + "github.com/postfinance/discovery/pkg/discoverypb/postfinance/discovery/v1/discoveryv1connect" + "github.com/stretchr/testify/require" + "golang.org/x/net/http2" + "golang.org/x/net/http2/h2c" +) + +const ( + testUser = "test" +) + +var ( + client *http.Client + authzConfig = auth.Configs{ + auth.Config{ + Role: "echo", + Rules: []auth.Rule{ + { + Service: discoveryv1connect.ServerAPIName, + Methods: []string{"ListServer", "RegisterServer", "UnregisterServer"}, + }, + }, + }, + } + + validToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiJ0ZXN0IiwiaWF0IjoxNjE0NjgwOTkzLjg1NzI4MSwiaXNzIjoiZHVtbXkiLCJuYmYiOjE2MTQ2ODA5OTMuODU3MjgxLCJ1c2VyIjp7Im5hbWUiOiJ0ZXN0Iiwicm9sZXMiOlsiZWNobyJdLCJraW5kIjowfX0.Jca_DpqEwkBLSvRyJxFGd7zZcKdsNTs32nTb2TDnou0" // iss = dummy (same as the verifier), no aud, does have role "echo" + validTokenAudience = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiJ0ZXN0IiwiaWF0IjoxNjE0NjgwOTkzLjg1NzI4MSwiaXNzIjoiZHVtbXkiLCJuYmYiOjE2MTQ2ODA5OTMuODU3MjgxLCJhdWQiOiJ5dW1teSIsInVzZXIiOnsibmFtZSI6InRlc3QiLCJyb2xlcyI6WyJlY2hvIl0sImtpbmQiOjB9fQ.KJGgxWnZNkW_p6-2KPAMUdtKuFY8c18qbgtBa9bJDRc" // iss = dummy (same as the verifier), aud = yummy, does have role "echo" + unauthorizedToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiJ0ZXN0IiwiaWF0IjoxNjE0NjgwOTkzLjg1NzI4MSwiaXNzIjoiZHVtbXkiLCJuYmYiOjE2MTQ2ODA5OTMuODU3MjgxLCJ1c2VyIjp7Im5hbWUiOiJ0ZXN0Iiwicm9sZXMiOlsicmVhZGVyIiwid3JpdGVyIl0sImtpbmQiOjB9fQ.1mMuHyEGPd44coov4iTx0ijNXuCDB0KEZ2FQEMt502g" // iss = dummy (same as the verifier), no aud, does not have role "echo" + unauthorizedTokenAudience = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiJ0ZXN0IiwiaWF0IjoxNjE0NjgwOTkzLjg1NzI4MSwiaXNzIjoiZHVtbXkiLCJuYmYiOjE2MTQ2ODA5OTMuODU3MjgxLCJhdWQiOiJ5dW1teSIsInVzZXIiOnsibmFtZSI6InRlc3QiLCJyb2xlcyI6WyJyZWFkZXIiLCJ3cml0ZXIiXSwia2luZCI6MH19.D7jvClVX3XoLhi4eXx7wac_YZsCtTPx2YqB7suHVusY" // iss = dummy (same as the verifier), aud = yummy, does not have role "echo" + invalidISSTok = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiJ0ZXN0IiwiaWF0IjoxNjE0NjgwOTkzLjg1NzI4MSwiaXNzIjoiaW52YWxpZCIsIm5iZiI6MTYxNDY4MDk5My44NTcyODEsInVzZXIiOnsibmFtZSI6InRlc3QiLCJyb2xlcyI6WyJyZWFkZXIiLCJ3cml0ZXIiXSwia2luZCI6MH19.A_0wNVWthu6JlPkP0JMjlYwRivEpwO0JodCNOje92Uka" // iss = invalid +) + +var _ discoveryv1connect.ServerAPIHandler = (*api)(nil) + +type api struct{} + +// ListServer implements discoveryv1connect.ServerAPIHandler. +func (a *api) ListServer(context.Context, *connect.Request[discoveryv1.ListServerRequest]) (*connect.Response[discoveryv1.ListServerResponse], error) { + resp := connect.NewResponse(&discoveryv1.ListServerResponse{ + Servers: []*discoveryv1.Server{ + { + Name: "server", + }, + }, + }) + + return resp, nil +} + +// RegisterServer implements discoveryv1connect.ServerAPIHandler. +func (a *api) RegisterServer(context.Context, *connect.Request[discoveryv1.RegisterServerRequest]) (*connect.Response[discoveryv1.RegisterServerResponse], error) { + panic("unimplemented") +} + +// UnregisterServer implements discoveryv1connect.ServerAPIHandler. +func (a *api) UnregisterServer(context.Context, *connect.Request[discoveryv1.UnregisterServerRequest]) (*connect.Response[discoveryv1.UnregisterServerResponse], error) { + panic("unimplemented") +} + +func testServer(a *auth.Authorizer, token string) (*httptest.Server, discoveryv1connect.ServerAPIClient) { + mux := http.NewServeMux() + + tfPath, tfHandler := discoveryv1connect.NewServerAPIHandler(&api{}, connect.WithInterceptors(a.UnaryServerInterceptor())) + mux.Handle(tfPath, tfHandler) + + ts := httptest.NewServer(h2c.NewHandler(mux, &http2.Server{})) + + client := discoveryv1connect.NewServerAPIClient(ts.Client(), ts.URL, connect.WithInterceptors(auth.WithToken(token))) + + return ts, client +} + +type dummyVerifier struct{} + +var _ auth.Verifier = &dummyVerifier{} + +func (d *dummyVerifier) Verify(ctx context.Context, token string) (*auth.User, error) { + p := jwt.NewParser() + + var claims auth.TokenClaims + + if _, _, err := p.ParseUnverified(token, &claims); err != nil { + return nil, err + } + + return &claims.User, nil +} + +func TestNewAuthorizer(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Errorf("observed no panic") + } + }() + + // must panic, because no verifier is configured + auth.NewAuthorizer(authzConfig) +} + +func TestAuthInterceptor(t *testing.T) { + t.Run("verifier by issuer without audience", func(t *testing.T) { + a := auth.NewAuthorizer(authzConfig, + auth.WithVerifier("dummy", &dummyVerifier{}), + auth.WithVerifierByIssuerAndClientID("dummy", testUser, nil), // would cause an error if chosen + ) + + tsOK, clientOK := testServer(a, validToken) + defer tsOK.Close() + + respOK, err := clientOK.ListServer(context.TODO(), connect.NewRequest(&discoveryv1.ListServerRequest{})) + + require.NoError(t, err) + require.Len(t, respOK.Msg.GetServers(), 1) + + tsNOK, clientNOK := testServer(a, unauthorizedToken) + defer tsNOK.Close() + + respNOK, err := clientNOK.ListServer(context.TODO(), connect.NewRequest(&discoveryv1.ListServerRequest{})) + + require.Error(t, err) + require.Equal(t, connect.CodePermissionDenied, connect.CodeOf(err)) + require.Nil(t, respNOK) + }) + + t.Run("verifier by issuer with audience", func(t *testing.T) { + a := auth.NewAuthorizer(authzConfig, + auth.WithVerifier("dummy", &dummyVerifier{}), + auth.WithVerifierByIssuerAndClientID("dummy", testUser, nil), // would cause an error if chosen + ) + + tsOK, clientOK := testServer(a, validTokenAudience) + defer tsOK.Close() + + respOK, err := clientOK.ListServer(context.TODO(), connect.NewRequest(&discoveryv1.ListServerRequest{})) + + require.NoError(t, err) + require.Len(t, respOK.Msg.GetServers(), 1) + + tsNOK, clientNOK := testServer(a, unauthorizedTokenAudience) + defer tsNOK.Close() + + respNOK, err := clientNOK.ListServer(context.TODO(), connect.NewRequest(&discoveryv1.ListServerRequest{})) + + require.Error(t, err) + require.Equal(t, connect.CodePermissionDenied, connect.CodeOf(err)) + require.Nil(t, respNOK) + }) + + t.Run("verifier by issuer and client id with audience", func(t *testing.T) { + a := auth.NewAuthorizer(authzConfig, + auth.WithVerifier("dummy", nil), // would cause an error if chosen + auth.WithVerifierByIssuerAndClientID("dummy", testUser, nil), // would cause an error if chosen + auth.WithVerifierByIssuerAndClientID("dummy", "yummy", &dummyVerifier{}), + ) + + ts, client := testServer(a, validTokenAudience) + defer ts.Close() + + resp, err := client.ListServer(context.TODO(), connect.NewRequest(&discoveryv1.ListServerRequest{})) + + require.NoError(t, err) + require.Len(t, resp.Msg.GetServers(), 1) + }) + + t.Run("invalid token", func(t *testing.T) { + a := auth.NewAuthorizer(authzConfig, + auth.WithVerifier("dummy", &dummyVerifier{}), + ) + + ts, client := testServer(a, invalidISSTok) + defer ts.Close() + + resp, err := client.ListServer(context.TODO(), connect.NewRequest(&discoveryv1.ListServerRequest{})) + + require.Error(t, err) + require.Equal(t, connect.CodeUnauthenticated, connect.CodeOf(err)) + require.Nil(t, resp) + }) + + t.Run("invalid token with public endpoint", func(t *testing.T) { + a := auth.NewAuthorizer(authzConfig, + auth.WithVerifier("dummy", &dummyVerifier{}), + auth.WithPublicEndpoints(discoveryv1connect.ServerAPIListServerProcedure), + ) + + ts, client := testServer(a, invalidISSTok) + defer ts.Close() + + resp, err := client.ListServer(context.TODO(), connect.NewRequest(&discoveryv1.ListServerRequest{})) + + require.NoError(t, err) + require.Len(t, resp.Msg.GetServers(), 1) + }) +} diff --git a/internal/authng/token.go b/internal/authng/token.go new file mode 100644 index 0000000..49e8e1d --- /dev/null +++ b/internal/authng/token.go @@ -0,0 +1,21 @@ +package auth + +import ( + jwt "github.com/golang-jwt/jwt/v4" +) + +// TokenKind represents the two different kind of tokens. +type TokenKind int + +const ( + // SelfIssuedToken are tokens which are issued by this library. + SelfIssuedToken TokenKind = iota + // ExternalToken are tokens which are issued by an identity provider by OpenID connect. + ExternalToken +) + +// TokenClaims represents the information asserted about a subject. +type TokenClaims struct { + User User `json:"user"` + jwt.RegisteredClaims +} diff --git a/internal/authng/user.go b/internal/authng/user.go new file mode 100644 index 0000000..9742ada --- /dev/null +++ b/internal/authng/user.go @@ -0,0 +1,29 @@ +package auth + +import "context" + +type contextKey int + +// UserCtxKey represents the context key +const ( + UserCtxKey contextKey = iota +) + +// User is an oidc user. +type User struct { + Name string `json:"name"` + Roles []string `json:"roles"` + Kind TokenKind `json:"kind"` +} + +// UserFromContext extracts the user information from the incoming context. +func UserFromContext(ctx context.Context) (User, bool) { + userPtr, ok := ctx.Value(UserCtxKey).(*User) + if ok { + return *userPtr, true + } + + user, ok := ctx.Value(UserCtxKey).(User) + + return user, ok +} diff --git a/internal/authng/user_test.go b/internal/authng/user_test.go new file mode 100644 index 0000000..af74365 --- /dev/null +++ b/internal/authng/user_test.go @@ -0,0 +1,43 @@ +package auth_test + +import ( + "context" + "testing" + + auth "github.com/postfinance/discovery/internal/authng" + "github.com/stretchr/testify/require" +) + +func TestUserFromContext(t *testing.T) { + tests := map[string]struct { + ctx context.Context + expect bool + }{ + "not found": { + ctx: context.WithValue(context.TODO(), auth.UserCtxKey, nil), + expect: false, + }, + "found": { + ctx: context.WithValue(context.TODO(), auth.UserCtxKey, auth.User{Name: "test"}), + expect: true, + }, + "found ptr": { + ctx: context.WithValue(context.TODO(), auth.UserCtxKey, &auth.User{Name: "test"}), + expect: true, + }, + } + + for name, tc := range tests { + t.Run("UserFromContext "+name, func(t *testing.T) { + r := require.New(t) + + user, found := auth.UserFromContext(tc.ctx) + r.Equal(tc.expect, found) + if tc.expect { + r.NotEmpty(user) + } else { + r.Empty(user) + } + }) + } +}