jwtv

package module
v1.0.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 29, 2023 License: MIT Imports: 16 Imported by: 0

README

JWT Validation Convenience Library

Convenience routines to extract and validate a JWT and add middleware to various web server frameworks.

go get github.com/dsggregory/jwtv

For reference, JWT validation flow is:

  • parse the JWT token from the request's Authorization header, cookie, or query param
  • download and cache the signing key from the issuer of the JWT using a JWKS service provided by the OIDC
  • verify the JWT signature, expiration, etc.
  • present the custom Claims from the JWT to the caller for app-specific validation (is this the correct role, etc.)

Mocks For Testing

Provides a convenient mock of JWT token and JWKS response to use in your tests. The mock does not require a private key and will create one if necessary. See mock_jwt.go and example use in jwt_test.go.

Usage

To validate a JWT, the following steps are taken:

  • create a JWTValidator object with options
  • wrap your auth-required endpoints with the validator middleware or,
  • call the validator directly in any auth-required endpoints

Furthermore, your code should validate the JWT claims as required by the application.

Create a JWTValidator

The following Functional Options may be used to tailor the validator:

OptionDiscoverJWKSCertsURI

Calls any IDP's OIDC discovery endpoint to look up the JWKS URI. The argument passed to this func is the canonical base for the IDP (e.g. what gets set in the iss claim of a JWT). For instance, with Keycloak, one may specify https://keycloak.local/auth/realms/{realm} as the argument.

OptionSetJWKSWellKnownURI

The recommendation is to instead use OptionDiscoverJWKSCertsURI(). This option only needs to be used when the default does not work for the OIDC that issues the JWT, or when a connection is undesirable to discover the JWKS endpoint.

The default is to infer the issuer from a known list. It will reference the JWT's 'iss' claim to determine the proper JWKS endpoint, and currently handles JWTs issued from IdentityServer4 and KeyCloak.

The argument to this option will either:

  • set a URI to use in combination with a JWT claim's iss (issuer) claim
  • or specify the full URL to the OIDC's JWKS endpoint
// uses this to append to the JWT claims 'iss'
jv, err := NewJWTValidator(OptionSetJWKSWellKnownURI("/well-known/jwks"))
// or, specify the full JWKS certs endpoint
jv, err := NewJWTValidator(OptionSetJWKSWellKnownURI("https://myoidc.local/well-known/jwks"))
OptionSetJWKSFetcher

Use this option if you will have multiple validators and want to share a global cache of JWKS keys. This option would be rarely used unless you have the situation where for some reason you require multiple validators for the same JWT issuer.

fetcher := NewSharedFetcher()
jv1, err := NewJWTValidator(
    OptionSetJWKSFetcher(fetcher),
    OptionSetJWKSWellKnownURI("/well-known/jwks")
)
jv2, err := NewJWTValidator(
    OptionSetJWKSFetcher(fetcher),
    OptionDiscoverJWKSCertsURI("https://myoidc.local")
)
OptionSetPublicKey

Use this option when you have access to the public key used in signing JWTs. This keeps the system from having to call out to the OIDC's JWKS URL to acquire the key. It is only valid when you know that the OIDC signs every JWT with only one RSA key and that a key rotation event can be supported existentially.

jv, err := NewJWTValidator(OptionSetPublicKey("./path/to/RSAPublicKey.pem"))

When using the PhishLabs APIGW with configuration that resigns the original JWT, you can load the public key in k8s by specifying the following environment variable in your deployment manifest:

   spec.template.spec.containers[0].env:
     - name: "CLAIMS_SIGNING_PUBKEY"
     value: "vault:secrets/data/apigw/claims-sign#APIGW_SIGNING_PUBKEY"
Parsing a Token From the Request

Normally, Validate() method is used to read the token from the HTTP request, verify its integrity, and provide the claims for your further validation. See the examples below for details.

If you require the claims and know the token has been validated upstream, use ParseWithoutValidation() to get the claims.

Working With Claims

Some extra functionality was needed to allow clients to work with JWT claims in light of different OIDC vendor implementations. One glaring example is that of the scope claim. IdentityServer4 delivers scope claims as StrOrArray while the RFC states space-separated string which is what Keycloak does. Another is that of the aud claim; Not because of vendor differences but due to ambiguity allowed by the relevant RFCs.

The Validate() (and GetClaims()) method returns a Claims type. This type responds to all methods provided by jwt.MapClaims and includes some helpful methods and fields on its own:

  • VerifyScope() - tests that a given scope is referenced in the claim and supports the various format types we expect
  • Get() - returns a claim value as an interface{}
  • GetString() - returns a claim value if it exists and is a string
  • MapClaims - accessor of the jwt.MapClaims representation so you may index on it yourself
  • VerifyAudience() - as per jwt.MapClaims.VerifyAudience()

Examples

Standard net/http Mux Example

This example uses the provided middleware functions to wrap routes that require auth.

package main

import (
	"log"
	"net/http"
	"github.com/dsggregory/jwtv"
)

// we share this so we only fetch the JWK keys as needed
var jv *jwtv.JWTValidator

func ShowAdminDashboard(w http.ResponseWriter, r *http.Request) {
	// get the claims that are present in the JWT
	claims := jv.GetClaims(r)
	if claims == nil {
		log.Print("expected JWT on request but didn't get one")
		w.WriteHeader(http.StatusUnauthorized)
		return
	}
	
	if !claims.VerifyScope("dashboard", true) {
		log.Print("dashboard scope not included in token")
		w.WriteHeader(http.StatusForbidden)
		return
    }
	// We have claims that need validating
	user := claims.GetString("user")
	if user == "" {
		log.Print("expected user claim to exist")
		w.WriteHeader(http.StatusForbidden)
		return
	}
	// validate user ...
}

func main() {
	// create an instance of the JWT validator
	jv, _ = jwtv.NewJWTValidator()
	
	r := http.NewServeMux()

	// add JWT middleware to wrap our handler that needs auth
	r.Handle("/admin", jv.Middleware(http.HandlerFunc(ShowAdminDashboard)))
	// we don't want auth for this route
	r.HandleFunc("/", ShowIndex)
	// ...    
}

Use Without the Included Middleware

You may use the validator without going through HTTP mux middleware.

package main

import (
	"log"
	"net/http"
	"github.com/dsggregory/jwtv"
)

// we share this so we only fetch the JWK keys as needed
var jv *jwtv.JWTValidator

func someHandler(w http.ResponseWriter, r *http.Request) {
	claims, err := jv.Validate(r)
	if err != nil {
		// JWT failed verification
		w.WriteHeader(http.StatusUnauthorized)
		return
	}
	if claims == nil {
		// no JWT was sent on request
		w.WriteHeader(http.StatusUnauthorized)
		return
	}
	
	// you must verify the claims
	if !claims.VerifyScope("foo", true) {
		// the claim does not have permission to do this function
		w.WriteHeader(http.StatusForbidden)
		return
	}
	
	// request is fully validated and safe to continue processing. ...
}

func main() {
	// create an instance of the JWT validator
	jv, _ = jwtv.NewJWTValidator()
	
	// rest of your code to setup a web server ...
}
Use Before ALL Routes

Some web frameworks allow you to have a function that is run on the request before any other handlers. Following is an example using the echo framework. This approach (as opposed to individually wrapping each handler) means you are required to manually exclude those matching routes that do not require auth.

package main

import (
	"log"
	"net/http"
	"github.com/dsggregory/jwtv"
	"github.com/labstack/echo/v4"
)

// we share this so we only fetch the JWK keys as needed
var jv *jwtv.JWTValidator

func AuthenticationMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
	return func(c echo.Context) error {
		path := c.Request().URL.Path
		// manually ignore routes not requiring auth
		if strings.HasPrefix(path, "/health") {
			return next(c)
		}

		// validate a JWT on the request if present:
		//   * err indicates an invalid JWT
		//   * claims not nil are claims from the JWT
		//   * nil claims and nil error means no JWT was present in the request
		claims, err := jv.Validate(c.Request())
		
		// remainder is the same procedure as the "Without Middleware" example ...
	}
}
func main() {
	// create an instance of the JWT validator
	jv, _ = jwtv.NewJWTValidator()
	
	e := echo.New()
	// Use AuthenticationMiddleware function
	e.Pre(AuthenticationMiddleware)
	
	// create other routes and start the server ...
}

References

Documentation

Overview

Package jwtv provides convenience routines to extract and validate a JWT and add middleware to various web server frameworks.

Example (Unwrapped)

Example_unwrapped provides an example where the HTTP request's JWT is validated inside select routes

jv, _ := NewJWTValidator()

mux := http.NewServeMux()
mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
	// validate the JWT is signed, not expired, et.al.
	claims, err := jv.Validate(r)
	if err != nil {
		// JWT failed verification
		w.WriteHeader(http.StatusUnauthorized)
		return
	}
	if claims == nil {
		// no JWT was sent on request
		w.WriteHeader(http.StatusUnauthorized)
		return
	}

	// you must verify the claims
	if !claims.VerifyScope("foo", true) {
		// the claim does not have permission to perform this function
		w.WriteHeader(http.StatusForbidden)
		_, _ = w.Write([]byte("scope foo required"))
		return
	}

	if !claims.VerifyAudience("audience-1", true) {
		// the claim is not a member of audience-1 so cannot perform this function
		w.WriteHeader(http.StatusForbidden)
		_, _ = w.Write([]byte("aud audience-1 required"))
		return
	}
}))

// start the server, do the rest ...
Output:

Index

Examples

Constants

View Source
const (
	// OIDCWellKnownURI is the OIDC standard URI to discover configuration. To use in Keycloak, prepend the Keycloak HREF `https://{keycloak-addr}/auth/realms/{realm}/protocol`
	OIDCWellKnownURI = "/.well-known/openid-configuration"
	// JWKSWellKnowCertURI default JWKS URI of JWT issuer to get signing public keys. Only use if this is for the IdentityServer4 issuer.
	JWKSWellKnowCertURI = OIDCWellKnownURI + "/jwks"
)
View Source
const (
	TokenTypeNone = iota
	TokenTypeBearer
	TokenTypeBasicAuth
)
View Source
const (
	TokenLocHeader = iota
	TokenLocQuery
	TokenLocCookie
)
View Source
const ClaimsContextKey = "claims"

Variables

This section is empty.

Functions

func NewSharedFetcher

func NewSharedFetcher(ctx context.Context) *jwk.AutoRefresh

NewSharedFetcher a convenience to return a jwk.AutoRefresh key fetcher and can be used with OptionSetJWKSFetcher().

Types

type Claims

type Claims struct {
	// MapClaims the embedded jwt.MapClaims one can access in order to index as a map[string]interface{}
	jwt.MapClaims
}

Claims embedded jwt.MapClaims with additional functionality. This type supplies all methods available to jwt.MapClaims, however, it cannot be indexed directly. The Golang way of extending a type.

func NewMapClaims

func NewMapClaims(claims jwt.MapClaims) *Claims

NewMapClaims creates a subclass-like of jwt.MapClaims having all of its functionality including additions. You just can't index on it as a map; You'd need to use Claims.MapClaims directly for that.

func (*Claims) Get

func (m *Claims) Get(key string) interface{}

Get since you cannot index Claims, you need this to get a key value from the embedded map.

func (*Claims) GetString

func (m *Claims) GetString(key string) string

GetString get a key as a string. If the key exists, but is not a string, an empty string is returned.

func (*Claims) VerifyScope

func (m *Claims) VerifyScope(cmp string, required bool) bool

VerifyScope Compares the scope claim against cmp. If required is false, this method will return true if the value matches or is unset.

This function normalizes the ambiguous return types by various OIDC providers. Ex:

  • IdentityServer4 presents: "scope": ["cps.app.client", "dng-filter.api.app", "kwscorer.api.app"]
  • Keycloak presents: "scope": "cps.app.client dng-filter.api.app kwscorer.api.app"
Example

ExampleClaims_VerifyScope demonstrate that Claims.VerifyScope() can handle differing formats from various JWT issuers.

// OIDC returns standard claims that include a scope
claimsScopeSpaceSep := Claims{
	MapClaims: jwt.MapClaims{
		"scope": "one two three",
	},
}

if !claimsScopeSpaceSep.VerifyScope("one", true) {
	return
}

// OIDC returns a different format
claimsScopeArray := Claims{
	MapClaims: jwt.MapClaims{
		"scope": []string{"one", "two", "three"},
	},
}

// VerifyScope is able to handle the different format of scope
if !claimsScopeArray.VerifyScope("one", true) {
	return
}
Output:

type Discoverer

type Discoverer struct {
	// contains filtered or unexported fields
}

type JWTEndpoints

type JWTEndpoints struct {
	Issuer                string
	AuthorizationEndpoint string `json:"authorization_endpoint"`
	TokenEndpoint         string `json:"token_endpoint"`
	IntrospectionEndpoint string `json:"introspection_endpoint"` // https://datatracker.ietf.org/doc/html/rfc7662
	UserinfoEndpoint      string `json:"userinfo_endpoint"`
	EndSessionEndpoint    string `json:"end_session_endpoint"`
	JwksURI               string `json:"jwks_uri"`
	RegistrationEndpoint  string `json:"registration_endpoint"`
	RevocationEndpoint    string `json:"revocation_endpoint"` // https://datatracker.ietf.org/doc/html/rfc7009
}

func DiscoverOidcEndpoints

func DiscoverOidcEndpoints(hclient *http.Client, oidcBaseURL string) (*JWTEndpoints, error)

DiscoverOidcEndpoints calls the OIDC server's endpoint to discover all other endpoints provided by the server

type JWTValidator

type JWTValidator struct {
	// JwksURI endpoint of JWT issuer to get signing public keys.
	// This may represent:
	//   * a full HREF to the OIDC's well-known endpoint
	//   * or an endpoint URI used in combination with the JWT's `iss` claim HREF
	// When empty at validation time, it will use 'iss' claim to lookup a predefined endpoint.
	JwksURI string
	// PublicKey specifies the public key to be used to verify all JWTs. This being defined supersedes the need to lookup the key from JwksURI.
	PublicKey interface{}
	// OnlineValidation if true, the OIDC server will be asked to validate the token. Otherwise, offline validation is done by checking the token signature against the OIDC server's public key.
	// WARN: if true, this always causes an extra connection to the OIDC server to validate the token.
	OnlineValidation bool
	// OIDCEndpoints if doing discovery against the OIDC server, this will be a map of idpURL to it's discovered endpoints
	OIDCEndpoints map[string]*JWTEndpoints
	// contains filtered or unexported fields
}

JWTValidator the instance of the JWT validator

func NewJWTValidator

func NewJWTValidator(opts ...ValidatorOption) (*JWTValidator, error)

NewJWTValidator create an instance of the validator for JWTs from a single issuer (e.g. OIDC).

The reason for the single issuer restriction is that the validator needs to know the JWKS endpoint to download keys. The 'iss' claim cannot be relied upon as there is no standard. JWT issuers may specify only the base URL of the IDP and not include the JWKS endpoint. Furthermore, there is no standard for the path to the JWKS endpoint.

With respect to the above, a JWKS fetcher can be shared by multiple validators when specifying OptionSetJWKSFetcher for `opts`.

Other options include OptionSetJWKSFetcher, OptionSetPublicKey

Example
var jv *JWTValidator

// validator will reference the JWT's 'iss' claim to determine the well-known JWKS endpoint to verify signing
jv, _ = NewJWTValidator()

// force the JWKS endpoint to claims["iss"] + "/well-known/jwks"
jv, _ = NewJWTValidator(
	OptionSetJWKSWellKnownURI("/well-known/jwks"),
)

// force the JWKS endpoint to the one specified
jv, _ = NewJWTValidator(
	OptionSetJWKSWellKnownURI("https://login.local/well-known/jwks"),
)

_ = jv
Output:

func (*JWTValidator) DiscoverEndpoints

func (jv *JWTValidator) DiscoverEndpoints(oidcBaseURL string) error

func (*JWTValidator) GetClaims

func (jv *JWTValidator) GetClaims(r *http.Request) *Claims

GetClaims returns the claims from a validated JWT of an HTTP request which is set during the Middleware handler.

One would call this from handlers that require authorization. For those handlers, if nil is returned from this call, you should respond with a 401 because a JWT was not present in the request. In the case when a JWT is present but does not validate, the Middleware handler would have already responded 401 and your handler would not be called.

When non-nil is returned, it is the responsibility of the caller to inspect and validate the individual claims.

func (*JWTValidator) Middleware

func (jv *JWTValidator) Middleware(next http.Handler) http.Handler

Middleware wraps the HTTP handler 'next' to validate a JWT on the request. It validates a JWT from the header and responds with 401 if not valid. Otherwise, the claims from the JWT are added to the request context for later handlers to access via GetClaims().

WARN: This DOES NOT validate individual claims as that is the responsibility of the caller after getting results from GetClaims().

Example

ExampleJWTValidator_Middleware provides an example where all routes automatically validate the request's JWT

jv, _ := NewJWTValidator()

mux := http.NewServeMux()

// wrap this endpoint with a JWT validator middleware
mux.Handle("/getClients", jv.Middleware(http.HandlerFunc(
	func(w http.ResponseWriter, r *http.Request) {
		// the middleware has successfully verified the JWT's integrity, thus all we do is validate the claims
		claims := jv.GetClaims(r)

		// you must verify the claims
		if !claims.VerifyScope("foo", true) {
			// the claim does not have permission to perform this function
			w.WriteHeader(http.StatusForbidden)
			_, _ = w.Write([]byte("scope foo required"))
			return
		}

		if !claims.VerifyAudience("audience-1", true) {
			// the claim is not a member of audience-1 so cannot perform this function
			w.WriteHeader(http.StatusForbidden)
			_, _ = w.Write([]byte("aud audience-1 required"))
			return
		}
	},
)))

// start the server, do the rest ...
Output:

func (*JWTValidator) ParseIfAPIGWValidated

func (jv *JWTValidator) ParseIfAPIGWValidated(r *http.Request) (*Claims, error)

ParseIfAPIGWValidated Use this method to get the claims from the JWT if the request could have been routed through the APIGW. If it did come through APIGW, then don't verify the JWT signature (saving a network call) because APIGW already did that. Otherwise, it validates the JWT signature against the issuing OIDC server (cached).

WARNING: Only use this method if access to your service is locked behind APIGW.

func (*JWTValidator) ParseTokenWithoutValidation

func (jv *JWTValidator) ParseTokenWithoutValidation(token string) (*Claims, error)

ParseTokenWithoutValidation parse the JWT token and return the claims. Does not validate the token signature.

See also ParseWithoutValidation() for the same functionality but taking the token from an HTTP request.

func (*JWTValidator) ParseWithoutValidation

func (jv *JWTValidator) ParseWithoutValidation(r *http.Request) (*Claims, error)

ParseWithoutValidation parses the JWT token from an HTTP request and return the claims. Useful only if upstream has already validated the token.

WARNING: THIS DOES NOT VALIDATE THE TOKEN!

See also ParseTokenWithoutValidation().

func (*JWTValidator) Validate

func (jv *JWTValidator) Validate(r *http.Request) (*Claims, error)

Validate validates an HTTP request's JWT token. The token may come from a cookie named 'access_token', a query param named 'access-token', or from the 'Authorization' header. If one exists and can be validated, this returns the claims from the token. If validation fails, this returns an error. If no JWT is present in the request, this returns nil for the claims and a nil error.

See also ValidateToken().

func (*JWTValidator) ValidateToken

func (jv *JWTValidator) ValidateToken(token string) (*Claims, error)

ValidateToken validates a JWT token and returns claims.

See also Validate() to validate a token from an HTTP request.

type OIDCConfiguration

type OIDCConfiguration struct {
	Issuer      string `json:"issuer"`
	AuthURL     string `json:"authorization_endpoint"`
	TokenURL    string `json:"token_endpoint"`
	JWKSURL     string `json:"jwks_uri"`
	UserInfoURL string `json:"userinfo_endpoint"`
}

TODO this subsumed by discover.go OIDCConfiguration from response to IDP's /.well-known/openid-configuration

type TokenInfo

type TokenInfo struct {
	// contains filtered or unexported fields
}

TokenInfo defines how the JWT was found in an HTTP request

type ValidatorContextKey

type ValidatorContextKey string

type ValidatorOption

type ValidatorOption func(validator *JWTValidator) error

ValidatorOption signature for an option to NewJWTValidator

func OptionDiscoverJWKSCertsURI

func OptionDiscoverJWKSCertsURI(idpHREF string) ValidatorOption

OptionDiscoverJWKSCertsURI is a NewJWTValidator Functional Option to call any IDP's OIDC discovery endpoint to look up the JWKS URI.

func OptionEnableOnlineValidation

func OptionEnableOnlineValidation() ValidatorOption

OptionEnableOnlineValidation use the issuing OIDC server to validate each token. The only value in setting this is when you cannot tolerate a token that has not yet expired but has been revoked by some other service. For instance, on session logout, the token may be revoked by the OIDC server.

With short validity periods on tokens, the risk that a user logs off and someone gains access to their token to use afterwards to call an API service.

func OptionSetJWKSFetcher

func OptionSetJWKSFetcher(fetcher *jwk.AutoRefresh) ValidatorOption

OptionSetJWKSFetcher is a NewJWTValidator Functional Option that sets the shared JWK key fetcher which may be used by other validators. The 'fetcher' argument can be acquired from a call to NewSharedFetcher().

The use case for this option began with APIGW whose config could possibly declare the same IDP for different proxy services. Thus, we'd like to share the JWK cache globally.

func OptionSetJWKSWellKnownURI

func OptionSetJWKSWellKnownURI(uri string) ValidatorOption

OptionSetJWKSWellKnownURI is a NewJWTValidator Functional Option to set the endpoint URI of the JWT's issuer so that public keys can be acquired to validate a JWT. This may specify a full HREF to the OIDC's endpoint. Otherwise, it is expected that this is a URI and will be appended to the JWT claims `iss` (issuer) HREF to form the full URL to acquire JWKS keys from an OIDC server.

The default is JWKSWellKnowCertURI.

Use OptionDiscoverJWKSCertsURI() for the preferred method.

func OptionSetPublicKey

func OptionSetPublicKey(pemOrFile string) ValidatorOption

OptionSetPublicKey is a NewJWTValidator Functional Option to specify the RSA public key to use to validate JWTs and is only useful when you know the JWT signer uses only one public key. The pemOrFile argument may be PEM data or a path to a PEM public key or certificate file.

This supersedes OptionSetJWKSWellKnownURI().

type WellKnownIssuer

type WellKnownIssuer struct {
	// IssRE a regex to match a potential JWT 'iss' claim
	IssRE *regexp.Regexp
	// CertURI the URI to be appended to the JWT 'iss' claim to form the well-known cert endpoint
	CertURI string
}

WellKnownIssuers struct to define well-known issuers JWKS certs URIs used to form JWKS certs endpoint

Directories

Path Synopsis
examples
pkg
mock
Package mock provides a convenient mock of JWT token and JWKS response to use in your tests.
Package mock provides a convenient mock of JWT token and JWKS response to use in your tests.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL