Skip to content

Commit

Permalink
feat(core): adds authn interceptor to grpc server (#296)
Browse files Browse the repository at this point in the history
* feat: verify and validate access tokens on service calls

* add autnInterceptor

* save

* save progress

* remove testIDP var

* cleanup

* comments

* comment

* unit tests for access token verification and validation

* updated configuration docs

* registered authn check as handler in mux chain

* rename authN config field and remove left over log line

* move authn to internal

* fix authn test

* only set issuer in platform welknown config

* fix loading authn with handler

* fix grpccurl step

* fix healthcheck grpccurl call

* didn't save

* disable auth for service extension test

* need to set mux to handler on server start

* try nohub

* try just go start to see errors

* pause on starting opentdf

* disable auth in example config

* Update internal/auth/authn.go

Co-authored-by: Paul Flynn <43211074+pflynn-virtru@users.noreply.github.com>

* Update internal/server/server.go

Co-authored-by: Dave Mihalcik <dmihalcik@virtru.com>

* fix lint errors

---------

Co-authored-by: Paul Flynn <43211074+pflynn-virtru@users.noreply.github.com>
Co-authored-by: Dave Mihalcik <dmihalcik@virtru.com>
Co-authored-by: Tyler Biscoe <biscoe@virtru.com>
  • Loading branch information
4 people committed Mar 13, 2024
1 parent 5d90ffd commit 39cba1c
Show file tree
Hide file tree
Showing 13 changed files with 699 additions and 36 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/checks.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ jobs:
wait-for: 90s
- run: go install github.com/fullstorydev/grpcurl/cmd/grpcurl@v1.8.9
- run: grpcurl -plaintext localhost:9000 list
- run: grpcurl -plaintext localhost:9000 list policy.attributes.AttributesService
- run: grpcurl -plaintext localhost:9000 grpc.health.v1.Health.Check

image:
name: image build
Expand Down
12 changes: 12 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ The server configuration is used to define how the application runs its server.
- `enabled`: Enable tls. `(default: false)`
- `cert`: The path to the tls certificate.
- `key`: The path to the tls key.
- `auth`: The configuration for your trusted IDP.
- `enabled`: Enable authentication. `(default: true)`
- `audience`: The audience for the IDP.
- `issuer`: The issuer for the IDP.
- `clients`: A list of client id's that are allowed

Example:

Expand All @@ -56,6 +61,13 @@ server:
enabled: true
cert: /path/to/cert
key: /path/to/key
auth:
enabled: true
audience: https://example.com
issuer: https://example.com
clients:
- client_id
- client_id2
```

## Database Configuration
Expand Down
6 changes: 6 additions & 0 deletions example-opentdf.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ services:
- "msExchMailboxGuid"
- "msExchMailboxSecurityDescriptor"
server:
auth:
enabled: false
audience: "opentdf"
issuer: http://localhost:8888/auth/realms/opentdf
clients:
- "opentdf"
grpc:
port: 9000
reflectionEnabled: true # Default is false
Expand Down
239 changes: 239 additions & 0 deletions internal/auth/authn.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
package auth

import (
"context"
"fmt"
"log/slog"
"net/http"
"slices"
"strings"
"time"

"github.com/lestrrat-go/jwx/v2/jwk"
"github.com/lestrrat-go/jwx/v2/jwt"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
)

var (
// Set of allowed gRPC endpoints that do not require authentication
allowedGRPCEndpoints = [...]string{
"/grpc.health.v1.Health/Check",
"/wellknownconfiguration.WellKnownService/GetWellKnownConfiguration",
}
// Set of allowed HTTP endpoints that do not require authentication
allowedHTTPEndpoints = [...]string{
"/healthz",
"/.well-known/opentdf-configuration",
}
)

// Authentication holds a jwks cache and information about the openid configuration
type authentication struct {
// cache holds the jwks cache
cache *jwk.Cache
// openidConfigurations holds the openid configuration for each issuer
oidcConfigurations map[string]AuthNConfig
}

// Creates new authN which is used to verify tokens for a set of given issuers
func NewAuthenticator(cfg AuthNConfig) (*authentication, error) {
a := &authentication{}
a.oidcConfigurations = make(map[string]AuthNConfig)

ctx := context.Background()

a.cache = jwk.NewCache(ctx)

// Build new cache
// Discover OIDC Configuration
oidcConfig, err := DiscoverOIDCConfiguration(ctx, cfg.Issuer)
if err != nil {
return nil, err
}

cfg.OIDCConfiguration = *oidcConfig

// Register the jwks_uri with the cache
if err := a.cache.Register(cfg.JwksURI, jwk.WithMinRefreshInterval(15*time.Minute)); err != nil {
return nil, err
}

// Need to refresh the cache to verify jwks is available
_, err = a.cache.Refresh(ctx, cfg.JwksURI)
if err != nil {
return nil, err
}

a.oidcConfigurations[cfg.Issuer] = cfg

return a, nil
}

// verifyTokenHandler is a http handler that verifies the token
func (a authentication) VerifyTokenHandler(handler http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if slices.Contains(allowedHTTPEndpoints[:], r.URL.Path) {
handler.ServeHTTP(w, r)
return
}
// Verify the token
header := r.Header["Authorization"]
if len(header) < 1 {
http.Error(w, "missing authorization header", http.StatusUnauthorized)
return
}
err := checkToken(r.Context(), header, a)
if err != nil {
slog.WarnContext(r.Context(), "failed to validate token", slog.String("error", err.Error()))
http.Error(w, "unauthenticated", http.StatusUnauthorized)
return
}

handler.ServeHTTP(w, r)
})
}

// verifyTokenInterceptor is a grpc interceptor that verifies the token in the metadata
func (a authentication) VerifyTokenInterceptor(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
// Allow health checks to pass through
if slices.Contains(allowedGRPCEndpoints[:], info.FullMethod) {
return handler(ctx, req)
}

// Get the metadata from the context
// The keys within metadata.MD are normalized to lowercase.
// See: https://godoc.org/google.golang.org/grpc/metadata#New
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return nil, status.Error(codes.Unauthenticated, "missing metadata")
}

// Verify the token
header := md["authorization"]
if len(header) < 1 {
return nil, status.Error(codes.Unauthenticated, "missing authorization header")
}

err := checkToken(ctx, header, a)
if err != nil {
slog.Warn("failed to validate token", slog.String("error", err.Error()))
return nil, status.Errorf(codes.Unauthenticated, "unauthenticated")
}

return handler(ctx, req)
}

// checkToken is a helper function to verify the token.
func checkToken(ctx context.Context, authHeader []string, auth authentication) error {
var (
tokenRaw string
tokenType string
)

// If we don't get a DPoP/Bearer token type, we can't proceed
switch {
case strings.HasPrefix(authHeader[0], "DPoP "):
tokenType = "DPoP"
tokenRaw = strings.TrimPrefix(authHeader[0], "DPoP ")
case strings.HasPrefix(authHeader[0], "Bearer "):
tokenType = "Bearer"
tokenRaw = strings.TrimPrefix(authHeader[0], "Bearer ")
default:
return fmt.Errorf("not of type bearer or dpop")
}

// Future work is to validate DPoP proof if token type is DPoP
//nolint:staticcheck
if tokenType == "DPoP" {
// Implement in the future here or as separate interceptor
}

// We have to get iss from the token first to verify the signature
unverifiedToken, err := jwt.Parse([]byte(tokenRaw), jwt.WithVerify(false))
if err != nil {
return err
}

// Get issuer from unverified token
issuer, exists := unverifiedToken.Get("iss")
if !exists {
return fmt.Errorf("missing issuer")
}

// Get the openid configuration for the issuer
// Because we get an interface we need to cast it to a string
// and jwx expects it as a string so we should never hit this error if the token is valid
issuerStr, ok := issuer.(string)
if !ok {
return fmt.Errorf("invalid issuer")
}
oidc, exists := auth.oidcConfigurations[issuerStr]
if !exists {
return fmt.Errorf("invalid issuer")
}

// Get key set from cache that matches the jwks_uri
keySet, err := auth.cache.Get(ctx, oidc.JwksURI)
if err != nil {
return fmt.Errorf("failed to get jwks from cache")
}

// Now we verify the token signature
_, err = jwt.Parse([]byte(tokenRaw),
jwt.WithKeySet(keySet),
jwt.WithValidate(true),
jwt.WithIssuer(issuerStr),
jwt.WithAudience(oidc.Audience),
jwt.WithValidator(jwt.ValidatorFunc(auth.claimsValidator)),
)
if err != nil {
return err
}

return nil
}

// claimsValidator is a custom validator to check extra claims in the token.
// right now it only checks for client_id
func (a authentication) claimsValidator(ctx context.Context, token jwt.Token) jwt.ValidationError {
var (
clientID string
)

// Need to check for cid and client_id as this claim seems to be different between idp's
cidClaim, cidExists := token.Get("cid")
clientIDClaim, clientIDExists := token.Get("client_id")

// Check to see if we have a client id claim
switch {
case cidExists:
if cid, ok := cidClaim.(string); ok {
clientID = cid
break
}
case clientIDExists:
if cid, ok := clientIDClaim.(string); ok {
clientID = cid
break
}
default:
return jwt.NewValidationError(fmt.Errorf("client id required"))
}

// Check if the client id is allowed in list of clients
foundClientID := false
for _, c := range a.oidcConfigurations[token.Issuer()].Clients {
if c == clientID {
foundClientID = true
break
}
}
if !foundClientID {
return jwt.NewValidationError(fmt.Errorf("invalid client id"))
}

return nil
}
Loading

0 comments on commit 39cba1c

Please sign in to comment.