-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(core): adds authn interceptor to grpc server (#296)
* 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 <[email protected]> * Update internal/server/server.go Co-authored-by: Dave Mihalcik <[email protected]> * fix lint errors --------- Co-authored-by: Paul Flynn <[email protected]> Co-authored-by: Dave Mihalcik <[email protected]> Co-authored-by: Tyler Biscoe <[email protected]>
- Loading branch information
1 parent
5d90ffd
commit 39cba1c
Showing
13 changed files
with
699 additions
and
36 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -114,7 +114,7 @@ jobs: | |
wait-for: 90s | ||
- run: go install github.com/fullstorydev/grpcurl/cmd/[email protected] | ||
- 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 | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
Oops, something went wrong.