Skip to content
This repository has been archived by the owner on Jun 18, 2024. It is now read-only.

Commit

Permalink
chore: add authng
Browse files Browse the repository at this point in the history
  • Loading branch information
zbindenren committed Jun 14, 2024
1 parent 22e84dd commit 558f1b7
Show file tree
Hide file tree
Showing 7 changed files with 592 additions and 0 deletions.
67 changes: 67 additions & 0 deletions internal/authng/auth.go
Original file line number Diff line number Diff line change
@@ -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
}
21 changes: 21 additions & 0 deletions internal/authng/client.go
Original file line number Diff line number Diff line change
@@ -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)
})
})
}
206 changes: 206 additions & 0 deletions internal/authng/interceptor.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading

0 comments on commit 558f1b7

Please sign in to comment.