Skip to content

Commit

Permalink
refactor(authn): Simplify OIDC config to use issuer and audience, enh…
Browse files Browse the repository at this point in the history
…ance JWKS handling
  • Loading branch information
tolgaOzen committed Feb 23, 2024
1 parent d79624c commit 5cc9e06
Show file tree
Hide file tree
Showing 13 changed files with 610 additions and 507 deletions.
3 changes: 3 additions & 0 deletions go.mod
Expand Up @@ -65,6 +65,7 @@ require (
require (
dario.cat/mergo v1.0.0 // indirect
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect
github.com/MicahParks/keyfunc v1.9.0 // indirect
github.com/Microsoft/go-winio v0.6.1 // indirect
github.com/Microsoft/hcsshim v0.11.4 // indirect
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
Expand All @@ -90,7 +91,9 @@ require (
github.com/gorilla/schema v1.2.0 // indirect
github.com/gorilla/securecookie v1.1.1 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
github.com/hashicorp/go-retryablehttp v0.7.5 // indirect
github.com/hashicorp/golang-lru v0.5.4 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
Expand Down
8 changes: 8 additions & 0 deletions go.sum
Expand Up @@ -52,6 +52,8 @@ github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym
github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs=
github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM=
github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10=
github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o=
github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw=
github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow=
github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM=
github.com/Microsoft/hcsshim v0.11.4 h1:68vKo2VN8DE9AdN4tnkWnmdhqdbpUFM8OF3Airm7fz8=
Expand Down Expand Up @@ -142,6 +144,7 @@ github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4
github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
Expand Down Expand Up @@ -232,13 +235,18 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.18.1/go.mod h1:YvJ2f6MplWDhfxiUC3Kp
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ=
github.com/hashicorp/go-immutable-radix v1.3.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc=
github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
github.com/hashicorp/go-memdb v1.3.4 h1:XSL3NR682X/cVk2IeV0d70N4DZ9ljI885xAEU8IoK3c=
github.com/hashicorp/go-memdb v1.3.4/go.mod h1:uBTr1oQbtuMgd1SSGoR8YV27eT3sBHbYiNm53bMpgSg=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/hashicorp/go-retryablehttp v0.7.5 h1:bJj+Pj19UZMIweq/iie+1u5YCdGrnxCT9yvm0e+Nd5M=
github.com/hashicorp/go-retryablehttp v0.7.5/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8=
github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
Expand Down
247 changes: 211 additions & 36 deletions internal/authn/oidc/authn.go
Expand Up @@ -2,18 +2,18 @@ package oidc

import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"

"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"

grpcAuth "github.com/grpc-ecosystem/go-grpc-middleware/auth"
"github.com/zitadel/oidc/pkg/client"
"github.com/zitadel/oidc/pkg/client/rp"
"github.com/zitadel/oidc/pkg/oidc"
"github.com/MicahParks/keyfunc"
"github.com/golang-jwt/jwt/v4"
grpcauth "github.com/grpc-ecosystem/go-grpc-middleware/auth"
"github.com/hashicorp/go-retryablehttp"

"github.com/Permify/permify/internal/config"
base "github.com/Permify/permify/pkg/pb/base/v1"
Expand All @@ -24,58 +24,233 @@ type Authenticator interface {
Authenticate(ctx context.Context) error
}

// Authn - Oidc verifier structure
// Authn holds configuration for OIDC authentication, including issuer, audience, and key details.
type Authn struct {
verifier rp.IDTokenVerifier
// IssuerURL is the URL of the OIDC issuer.
IssuerURL string

// Audience is the intended audience of the tokens, typically the client ID.
Audience string

// JwksURI is the URL to fetch the JSON Web Key Set (JWKS) from.
JwksURI string

// JWKs holds the JWKS fetched from JwksURI for validating tokens.
JWKs *keyfunc.JWKS

// httpClient is used to make HTTP requests, e.g., to fetch the JWKS.
httpClient *http.Client
}

// NewOidcAuthn - Create new Oidc verifier
func NewOidcAuthn(_ context.Context, cfg config.Oidc) (*Authn, error) {
dis, err := client.Discover(cfg.Issuer, http.DefaultClient)
// NewOidcAuthn creates a new instance of Authn configured for OIDC authentication.
// It initializes the HTTP client with retry capabilities, sets up the OIDC issuer and audience,
// and attempts to fetch the JWKS keys from the issuer's JWKsURI.
func NewOidcAuthn(_ context.Context, audience config.Oidc) (*Authn, error) {
// Initialize a new retryable HTTP client to handle transient network errors
// by retrying failed HTTP requests. The logger is disabled for cleaner output.
client := retryablehttp.NewClient()
client.Logger = nil // Disabling logging for the HTTP client

// Create a new instance of Authn with the provided issuer URL and audience.
// The httpClient is set to the standard net/http client wrapped with retry logic.
oidc := &Authn{
IssuerURL: audience.Issuer,
Audience: audience.Audience,
httpClient: client.StandardClient(), // Wrap retryable client as a standard http.Client
}

// Attempt to fetch the JWKS keys from the OIDC provider.
// This is crucial for setting up OIDC authentication as it enables token validation.
err := oidc.fetchKeys()
if err != nil {
// If fetching keys fails, return an error to prevent initialization of a non-functional Authn instance.
return nil, err
}
remoteKeySet := rp.NewRemoteKeySet(http.DefaultClient, dis.JwksURI)
verifier := rp.NewIDTokenVerifier(dis.Issuer, cfg.ClientID, remoteKeySet,
rp.WithSupportedSigningAlgorithms(dis.IDTokenSigningAlgValuesSupported...))

return &Authn{verifier: verifier}, nil
// Return the initialized Authn instance, ready for use in OIDC authentication.
return oidc, nil
}

// Authenticate - Checking whether JWT token is signed by the provider and is valid
func (t *Authn) Authenticate(ctx context.Context) error {
rawToken, err := grpcAuth.AuthFromMD(ctx, "Bearer")
// Authenticate validates the authentication token from the request context.
func (oidc *Authn) Authenticate(requestContext context.Context) error {
// Extract the authentication header from the metadata in the request context.
authHeader, err := grpcauth.AuthFromMD(requestContext, "Bearer")
if err != nil {
// Return an error if the bearer token is missing from the authentication header.
return errors.New(base.ErrorCode_ERROR_CODE_MISSING_BEARER_TOKEN.String())
}

claims, err := rp.VerifyIDToken(ctx, rawToken, t.verifier)
// Initialize a new JWT parser with the RS256 signing method.
jwtParser := jwt.NewParser(jwt.WithValidMethods([]string{"RS256"}))

// Parse and validate the JWT from the authentication header.
token, err := jwtParser.Parse(authHeader, func(token *jwt.Token) (any, error) {
// Use the JWKS from oidc to validate the JWT's signature.
return oidc.JWKs.Keyfunc(token)
})
if err != nil {
return status.Error(codes.Unauthenticated, err.Error())
// Return an error if the token is invalid (e.g., expired, wrong signature).
return errors.New(base.ErrorCode_ERROR_CODE_INVALID_BEARER_TOKEN.String())
}

// Check if the parsed token is valid.
if !token.Valid {
// Return an error if the token is not valid.
return errors.New(base.ErrorCode_ERROR_CODE_INVALID_BEARER_TOKEN.String())
}

// Extract the claims from the token.
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
// Return an error if the claims in the token are in an invalid format.
return errors.New(base.ErrorCode_ERROR_CODE_INVALID_CLAIMS.String())
}

// Verify the issuer of the token matches the expected issuer.
if ok := claims.VerifyIssuer(oidc.IssuerURL, true); !ok {
// Return an error if the token's issuer is invalid.
return errors.New(base.ErrorCode_ERROR_CODE_INVALID_ISSUER.String())
}

if err := t.validateOtherClaims(claims); err != nil {
return status.Error(codes.Unauthenticated, err.Error())
// Verify the audience of the token matches the expected audience.
if ok := claims.VerifyAudience(oidc.Audience, true); !ok {
// Return an error if the token's audience is invalid.
return errors.New(base.ErrorCode_ERROR_CODE_INVALID_AUDIENCE.String())
}

// If all checks pass, the token is considered valid, and the function returns nil.
return nil
}

// validateOtherClaims - Validate claims that are not validated by the oidc client library
func (t *Authn) validateOtherClaims(claims oidc.IDTokenClaims) error {
return checkNotBefore(claims, t.verifier.Offset())
func (oidc *Authn) fetchKeys() error {
oidcConfig, err := oidc.fetchOIDCConfiguration()
if err != nil {
return fmt.Errorf("error fetching OIDC configuration: %w", err)
}

oidc.JwksURI = oidcConfig.JWKsURI

jwks, err := oidc.GetKeys()
if err != nil {
return fmt.Errorf("error fetching OIDC keys: %w", err)
}

oidc.JWKs = jwks

return nil
}

// checkNotBefore - Validate current time to be not before the notBefore claim value
// Use the same logic for time comparisons as in the zitadel oidc verifier
func checkNotBefore(claims oidc.IDTokenClaims, offset time.Duration) error {
notBefore := claims.GetNotBefore().Round(time.Second)
if notBefore.IsZero() {
return nil
// GetKeys fetches the JSON Web Key Set (JWKS) from the configured JWKS URI.
func (oidc *Authn) GetKeys() (*keyfunc.JWKS, error) {
// Use the keyfunc package to fetch the JWKS from the JWKS URI.
// The keyfunc.Options struct is used to configure the HTTP client used for the request
// and set a refresh interval for the keys.
jwks, err := keyfunc.Get(oidc.JwksURI, keyfunc.Options{
Client: oidc.httpClient, // Use the HTTP client configured in the Authn struct.
RefreshInterval: 48 * time.Hour, // Set the interval to refresh the keys every 48 hours.
})
if err != nil {
// Return a formatted error message if there's an issue fetching the JWKS.
// This includes the JWKS URI for clearer debugging information.
return nil, fmt.Errorf("failed to fetch keys from '%s': %s", oidc.JwksURI, err)
}

nowWithOffset := time.Now().UTC().Add(offset).Round(time.Second)
if nowWithOffset.Before(notBefore) {
return fmt.Errorf("token's notBefore date is %s, should be used after that time", notBefore)
// Return the fetched JWKS and nil for the error if successful.
return jwks, nil
}

// Config holds OpenID Connect (OIDC) configuration details.
type Config struct {
// Issuer is the OIDC provider's unique identifier URL.
Issuer string `json:"issuer"`
// JWKsURI is the URL to the JSON Web Key Set (JWKS) provided by the OIDC issuer.
JWKsURI string `json:"jwks_uri"`
}

// Fetches OIDC configuration using the well-known endpoint.
func (oidc *Authn) fetchOIDCConfiguration() (*Config, error) {
wellKnownURL := oidc.getWellKnownURL()
body, err := oidc.doHTTPRequest(wellKnownURL)
if err != nil {
return nil, err
}
return nil

oidcConfig, err := parseOIDCConfiguration(body)
if err != nil {
return nil, err
}

return oidcConfig, nil
}

// Constructs the well-known URL for fetching OIDC configuration.
func (oidc *Authn) getWellKnownURL() string {
return strings.TrimSuffix(oidc.IssuerURL, "/") + "/.well-known/openid-configuration"
}

// doHTTPRequest makes an HTTP GET request to the specified URL and returns the response body.
func (oidc *Authn) doHTTPRequest(url string) ([]byte, error) {
// Create a new HTTP GET request.
req, err := http.NewRequest("GET", url, nil)
if err != nil {
// Return an error if there's an issue creating the HTTP request.
return nil, fmt.Errorf("failed to create HTTP request for OIDC configuration: %s", err)
}

// Send the request using the configured HTTP client.
res, err := oidc.httpClient.Do(req)
if err != nil {
// Return an error if the request fails to execute.
return nil, fmt.Errorf("failed to execute HTTP request for OIDC configuration: %s", err)
}
// Ensure the response body is closed after reading.
defer res.Body.Close()

// Check if the HTTP status code indicates success.
if res.StatusCode != http.StatusOK {
// Return an error if the status code is not 200 OK.
return nil, fmt.Errorf("received unexpected status code (%d) while fetching OIDC configuration", res.StatusCode)
}

// Read the response body.
body, err := io.ReadAll(res.Body)
if err != nil {
// Return an error if reading the response body fails.
return nil, fmt.Errorf("failed to read response body from OIDC configuration request: %s", err)
}

// Return the response body.
return body, nil
}

// parseOIDCConfiguration decodes the OIDC configuration from the given JSON body.
func parseOIDCConfiguration(body []byte) (*Config, error) {
var oidcConfig Config
// Attempt to unmarshal the JSON body into the oidcConfig struct.
if err := json.Unmarshal(body, &oidcConfig); err != nil {
// Provide a specific error message indicating failure in JSON parsing.
return nil, fmt.Errorf("failed to decode OIDC configuration: %s", err)
}

// Validate that the Issuer field is not empty.
if oidcConfig.Issuer == "" {
// Return an error highlighting the absence of the issuer in the configuration.
return nil, errors.New("issuer value is required but missing in OIDC configuration")
}

// Validate that the JWKsURI field is not empty.
if oidcConfig.JWKsURI == "" {
// Return an error highlighting the absence of the jwks_uri in the configuration.
return nil, errors.New("jwks_uri value is required but missing in OIDC configuration")
}

// Return the successfully parsed configuration.
return &oidcConfig, nil
}

// Close gracefully shuts down the Authn instance by terminating background processes.
func (oidc *Authn) Close() {
// EndBackground stops the background refresh process for the JWKS keys,
// ensuring no more go routines are left running for key refresh.
oidc.JWKs.EndBackground()
}

0 comments on commit 5cc9e06

Please sign in to comment.