jwt

package
v1.0.20 Latest Latest
Warning

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

Go to latest
Published: Apr 27, 2024 License: MIT Imports: 10 Imported by: 0

README

ca-go/jwt

The jwt package wraps JWT & JWKs Encode and Decode in a simple to use singleton pattern that you can call directly. Only ECDSA and RSA public and private keys are currently supported (but this can easily be updated if needed in the future).

Environment Variables

To use the package level methods Encode and Decode you MUST set these:

  • AUTH_PUBLIC_JWK_KEYS = A JSON string containing the well known public keys for Decoding a token.
  • AUTH_PRIVATE_KEY = The private RSA PEM key used for Encoding a token.
  • AUTH_PRIVATE_KEY_ID = The "kid" (key_id) header to add to the token heading when Encoding.

Runtime Key Rotation

We want to be able to easily rotate keys without having to stop/start all services. The simplest approach is that the JWT Encoder and Decoder take a func() that clients must implement to provide a refreshed key.

If you are using the package level methods, then the DefaultJwtEncoder and DefaultJwtDecoder will check there environment variables every 60 minutes. So all you need to do is update them with os.Setenv() with new values if a key rotation occurs.

If you are managing Encoders and Decoders yourself, then you can provide a func of type EncoderKeyRetriever to the NewJwtEncoder constructor, and a func of type DecoderJwksRetriever to the NewJwtDecoder:

  • type EncoderKeyRetriever func() (string, string) // return your private PEM key + key_id
  • type DecoderJwksRetriever func() string // return your JSON JWKs

Managing Encoders and Decoders Yourself

While we recommend using the package level methods for their ease of use, you may desire to create and manage encoders or decoers yourself, which you can do by calling:

func privateKeyRetriever() (string, string) {
	// todo: check if keys have rotated (eg. re-read secrets manager)

	privKey := secrets.Get("my-private-key")
	keyId := secrets.Get("my-private-key-id")
	return privKey, keyId
}

func jwksRetriever() string {
	// todo: check if keys have rotated (eg. call well-known URL)

	resp, err := http.Get("http://well-known-example.com/list.jwks")
	// todo: handle error (eg. retry)
	defer resp.Body.Close()
	jwkKeys, err := io.ReadAll(resp.Body)
	return jwkKeys
}

func main() {

encoder, err := NewJwtEncoder(privateKeyRetriever)
decoder, err := NewJwtDecoder(jwksRetriever)
}

Claims

You MUST set the Issuer, Subject, and Audience claims along with the standard authentication claim AccountId. If the JWT is for authenticaton other than to the Public API, it MUST also include the RealUserId, and EffectiveUserId claims.

Please read the JWT Engineering Standard for more information and details.

Standard Claims

Encode and Decode use the in-built StandardClaims struct:

type StandardClaims struct {
	AccountId       string // uuid
	RealUserId      string // uuid
	EffectiveUserId string // uuid

	// Optional claims

	// the `iss` (Issuer) claim. See https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.1
	Issuer string
	// the `sub` (Subject) claim. See https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.2
	Subject string
	// the `aud` (Audience) claim. See https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.3
	Audience []string
	// the `exp` (Expiration Time) claim. See https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.4
	ExpiresAt time.Time // default on Encode is +1 hour from now
	// the `nbf` (Not Before) claim. See https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.5
	NotBefore time.Time // default on Encode is "now"
	// the `iat` (Issued At) claim. See https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.6
	IssuedAt time.Time // default on Encode is "now"
}

However, if you have wish to have customer claims then you can use the EncodeWithCustomClaims and DecodeWithCustomClaims methods. Just create a struct that supports the jwt.Claims interface:

type Claims interface {
	GetExpirationTime() (*NumericDate, error)
	GetIssuedAt() (*NumericDate, error)
	GetNotBefore() (*NumericDate, error)
	GetIssuer() (string, error)
	GetSubject() (string, error)
	GetAudience() (ClaimStrings, error)
}

Examples

package cago

import (
	"fmt"

	"github.com/cultureamp/ca-go/jwt"
)

func BasicExamples() {
	claims := &jwt.StandardClaims{
		AccountId:       "abc123",
		RealUserId:      "xyz234",
		EffectiveUserId: "xyz345",
		Issuer:          "name-of-the-encoder",
		Subject:         "name-of-this-jwt-token",
		Audience:        []string{"list-of-intended-decoders-1", "list-of-intended-decoders-2"},
	}

	// Encode this claim with the default "web-gateway" key and add the kid to the token header
	token, err := jwt.Encode(claims)
	fmt.Printf("The encoded token is '%s' (err='%v')\n", token, err)

	// Decode it back again using the key that matches the kid header using the default JWKS JSON keys
	sc, err := jwt.Decode(token)
	fmt.Printf("The decode token is '%v' (err='%+v')\n", sc, err)
}

Testing and Mocks

During tests you can override the package level DefaultJwtEncoder and/or DefaultJwtDecoder with a mock that supports the Encoder or Decoder interface.

  • Encode(claims *StandardClaims) (string, error)
  • EncodeWithCustomClaims(customClaims jwt.Claims) (string, error)
  • Decode(tokenString string) (*StandardClaims, error)
  • DecodeWithCustomClaims(tokenString string, customClaims jwt.Claims) error
import (
	"context"
	"testing"

	"github.com/cultureamp/ca-go/jwt"
	"github.com/stretchr/testify/mock"
)

func ExampleMocked_EncoderDecoder() {
	claims := &jwt.StandardClaims{
		AccountId:       "abc123",
		RealUserId:      "xyz234",
		EffectiveUserId: "xyz345",
		Issuer:          "encoder-name",
		Subject:         "test",
		Audience:        []string{"decoder-name"},
		ExpiresAt:       time.Unix(2211797532, 0), //  2/2/2040
		IssuedAt:        time.Unix(1580608922, 0), // 1/1/2020
		NotBefore:       time.Unix(1580608922, 0), // 1/1/2020
	}

	mockEncDec := newMockedEncoderDecoder()
	mockEncDec.On("Encode", mock.Anything).Return("eyJhbGciOiJSUzUxMiIsImtpZCI6IndlYi1nYXRld2F5IiwidHlwIjoiSldUIn0", nil)
	mockEncDec.On("Decode", mock.Anything).Return(claims, nil)

	// Overwrite the Default package level encoder and decoder
	oldEncoder := jwt.DefaultJwtEncoder
	oldDecoder := jwt.DefaultJwtDecoder
	jwt.DefaultJwtEncoder = mockEncDec
	jwt.DefaultJwtDecoder = mockEncDec
	defer func() {
		jwt.DefaultJwtEncoder = oldEncoder
		jwt.DefaultJwtDecoder = oldDecoder
	}()

	// Encode this claim with the default "web-gateway" key and add the kid to the token header
	token, err := jwt.Encode(claims)
	fmt.Printf("The encoded token is '%s' (err='%v')\n", token, err)

	// Decode it back again using the key that matches the kid header using the default JWKS JSON keys
	claim, err := jwt.Decode(token)
	fmt.Printf(
		"The decoded token is '%s %s %s %s %v %s %s' (err='%+v')\n",
		claim.AccountId, claim.RealUserId, claim.EffectiveUserId,
		claim.Issuer, claim.Subject, claim.Audience,
		claim.ExpiresAt.UTC().Format(time.RFC3339),
		err,
	)

	// Output:
	// The encoded token is 'eyJhbGciOiJSUzUxMiIsImtpZCI6IndlYi1nYXRld2F5IiwidHlwIjoiSldUIn0' (err='<nil>')
	// The decoded token is 'abc123 xyz234 xyz345 encoder-name test [decoder-name] 2040-02-02T12:12:12Z' (err='<nil>')
}

type mockedEncoderDecoder struct {
	mock.Mock
}

func newMockedEncoderDecoder() *mockedEncoderDecoder {
	return &mockedEncoderDecoder{}
}

func (m *mockedEncoderDecoder) Encode(claims *jwt.StandardClaims) (string, error) {
	args := m.Called(claims)
	output, _ := args.Get(0).(string)
	return output, args.Error(1)
}

// Decrypt on the test runner just returns the "encryptedStr" as the decrypted plainstr.
func (m *mockedEncoderDecoder) Decode(tokenString string) (*jwt.StandardClaims, error) {
	args := m.Called(tokenString)
	output, _ := args.Get(0).(*jwt.StandardClaims)
	return output, args.Error(1)
}

func (m *mockedEncoderDecoder) DecodeWithCustomClaims(tokenString string, customClaims gojwt.Claims) error {
	args := m.Called(tokenString, customClaims)
	return args.Error(0)
}

Documentation

Overview

Example
package main

import (
	"fmt"
	"os"
	"path/filepath"
	"time"

	"github.com/cultureamp/ca-go/jwt"
)

const webGatewayKid = "web-gateway"

func main() {
	claims := &jwt.StandardClaims{
		AccountId:       "abc123",
		RealUserId:      "xyz234",
		EffectiveUserId: "xyz345",
		Issuer:          "encoder-name",
		Subject:         "test",
		Audience:        []string{"decoder-name"},
		ExpiresAt:       time.Unix(2211797532, 0), //  2/2/2040
		IssuedAt:        time.Unix(1580608922, 0), // 1/1/2020
		NotBefore:       time.Unix(1580608922, 0), // 1/1/2020
	}

	// Encode this claim with the default "web-gateway" key and add the kid to the token header
	token, err := jwt.Encode(claims)
	fmt.Printf("The encoded token is '%s' (err='%v')\n", token, err)

	// Decode it back again using the key that matches the kid header using the default JWKS JSON keys
	claim, err := jwt.Decode(token)
	fmt.Printf(
		"The decoded token is '%s %s %s %s %v %s %s' (err='%+v')\n",
		claim.AccountId, claim.RealUserId, claim.EffectiveUserId,
		claim.Issuer, claim.Subject, claim.Audience,
		claim.ExpiresAt.UTC().Format(time.RFC3339),
		err,
	)

	// To create a specific instance of the encoder and decoder you can use the following
	privateKeyBytes, err := os.ReadFile(filepath.Clean("./testKeys/jwt-rsa256-test-webgateway.key"))
	encoder, err := jwt.NewJwtEncoder(func() (string, string) { return string(privateKeyBytes), webGatewayKid })

	token, err = encoder.Encode(claims)
	fmt.Printf("The encoded token is '%s' (err='%v')\n", token, err)

	b, err := os.ReadFile(filepath.Clean("./testKeys/development.jwks"))
	decoder, err := jwt.NewJwtDecoder(func() string { return string(b) })

	claim, err = decoder.Decode(token)
	fmt.Printf(
		"The decoded token is '%s %s %s %s %v %s %s' (err='%+v')\n",
		claim.AccountId, claim.RealUserId, claim.EffectiveUserId,
		claim.Issuer, claim.Subject, claim.Audience,
		claim.ExpiresAt.UTC().Format(time.RFC3339),
		err,
	)

}
Output:

The encoded token is 'eyJhbGciOiJSUzUxMiIsImtpZCI6IndlYi1nYXRld2F5IiwidHlwIjoiSldUIn0.eyJhY2NvdW50SWQiOiJhYmMxMjMiLCJlZmZlY3RpdmVVc2VySWQiOiJ4eXozNDUiLCJyZWFsVXNlcklkIjoieHl6MjM0IiwiaXNzIjoiZW5jb2Rlci1uYW1lIiwic3ViIjoidGVzdCIsImF1ZCI6WyJkZWNvZGVyLW5hbWUiXSwiZXhwIjoyMjExNzk3NTMyLCJuYmYiOjE1ODA2MDg5MjIsImlhdCI6MTU4MDYwODkyMn0.CH_UIzR_W1275ffAUES0EzsHNRYZyBbrLsKQBbfJ6DpsLW3HAxH5RSjzXL_yCGTrbcHytTYLIZKhN37lC9BZdhkxZtR9bMqqGu4K0zHNtztoC5u1P7kc81FX_dPi9aiR7B4hruSfOFHoWM1A_D_i55qPAJlB0LRFf4nwX9FIWt2IIMwSGUcxfjFYE7MKTlzP3heCYNVzIxLD5g5gcoIyttmltiD_bBvObvExuDsJSlxwrAYvKc2cpIsh1MZ1x16uhG-du2_YdfSK6Ykd6aAvVpq3IGkb99SKS3xUsCV3JkSDRIcWMKzPhEh_huDV4Z3AA3jA4sWvR20WOqzaW3dRAoYIYL7kP92PrXX8m0EtLPAlX471POgNREWqdmxrbdkZcYNHqrmHcAsMRPMXcZ15tH8_-jIDUvGpNbcetgmQRjcpLtyniN_Ag4kGoPhYzGLx6122DEBrYf0Os5TQcRAzAoSF1n_43hsfmuGw00ey3ye5siJle7LN8EHUAXjegrpC7WTFF_eIsOtkuXTJx6OMmuggRvlMaCughYP6IvoIXD7ME0DnzmuvANID9yo-X8DJpMiWbZ2_edCE7dmuqxIZOqJmTolswQs1p0hzFyaX5SrEgcGjHxwTpuCYfaQ7qrbz2D_OQfXbglbk4e8Hm63bGmmz9bKV4KDBVPJO1zOGLtM' (err='<nil>')
The decoded token is 'abc123 xyz234 xyz345 encoder-name test [decoder-name] 2040-02-02T12:12:12Z' (err='<nil>')
The encoded token is 'eyJhbGciOiJSUzUxMiIsImtpZCI6IndlYi1nYXRld2F5IiwidHlwIjoiSldUIn0.eyJhY2NvdW50SWQiOiJhYmMxMjMiLCJlZmZlY3RpdmVVc2VySWQiOiJ4eXozNDUiLCJyZWFsVXNlcklkIjoieHl6MjM0IiwiaXNzIjoiZW5jb2Rlci1uYW1lIiwic3ViIjoidGVzdCIsImF1ZCI6WyJkZWNvZGVyLW5hbWUiXSwiZXhwIjoyMjExNzk3NTMyLCJuYmYiOjE1ODA2MDg5MjIsImlhdCI6MTU4MDYwODkyMn0.CH_UIzR_W1275ffAUES0EzsHNRYZyBbrLsKQBbfJ6DpsLW3HAxH5RSjzXL_yCGTrbcHytTYLIZKhN37lC9BZdhkxZtR9bMqqGu4K0zHNtztoC5u1P7kc81FX_dPi9aiR7B4hruSfOFHoWM1A_D_i55qPAJlB0LRFf4nwX9FIWt2IIMwSGUcxfjFYE7MKTlzP3heCYNVzIxLD5g5gcoIyttmltiD_bBvObvExuDsJSlxwrAYvKc2cpIsh1MZ1x16uhG-du2_YdfSK6Ykd6aAvVpq3IGkb99SKS3xUsCV3JkSDRIcWMKzPhEh_huDV4Z3AA3jA4sWvR20WOqzaW3dRAoYIYL7kP92PrXX8m0EtLPAlX471POgNREWqdmxrbdkZcYNHqrmHcAsMRPMXcZ15tH8_-jIDUvGpNbcetgmQRjcpLtyniN_Ag4kGoPhYzGLx6122DEBrYf0Os5TQcRAzAoSF1n_43hsfmuGw00ey3ye5siJle7LN8EHUAXjegrpC7WTFF_eIsOtkuXTJx6OMmuggRvlMaCughYP6IvoIXD7ME0DnzmuvANID9yo-X8DJpMiWbZ2_edCE7dmuqxIZOqJmTolswQs1p0hzFyaX5SrEgcGjHxwTpuCYfaQ7qrbz2D_OQfXbglbk4e8Hm63bGmmz9bKV4KDBVPJO1zOGLtM' (err='<nil>')
The decoded token is 'abc123 xyz234 xyz345 encoder-name test [decoder-name] 2040-02-02T12:12:12Z' (err='<nil>')

Index

Examples

Constants

This section is empty.

Variables

View Source
var (
	// DefaultJwtEncoder used to package level methods.
	// This can be mocked during tests if required by supporting the Encoder interface.
	DefaultJwtEncoder Encoder = nil
	// DefaultJwtDecoder used for package level methods.
	// This can be mocked during tests if required by supporting the Decoder interface.
	DefaultJwtDecoder Decoder = nil
)

Functions

func DecodeWithCustomClaims added in v1.0.5

func DecodeWithCustomClaims(tokenString string, customClaims jwt.Claims) error

DecodeWithCustomClaims takes a jwt token string and populate the customClaims.

func Encode

func Encode(claims *StandardClaims) (string, error)

Encode the Standard Culture Amp Claims in a jwt token string.

func EncodeWithCustomClaims added in v1.0.10

func EncodeWithCustomClaims(customClaims jwt.Claims) (string, error)

EncodeWithCustomClaims encodes the Custom Claims in a jwt token string.

Types

type Decoder added in v0.0.41

type Decoder interface {
	Decode(tokenString string) (*StandardClaims, error)
	DecodeWithCustomClaims(tokenString string, customClaims jwt.Claims) error
}

Decoder interface allows for mocking of the Decoder.

type DecoderJwksRetriever added in v0.0.44

type DecoderJwksRetriever func() string

DecoderJwksRetriever defines the function signature required to retrieve JWKS json.

type Encoder added in v0.0.41

type Encoder interface {
	Encode(claims *StandardClaims) (string, error)
	EncodeWithCustomClaims(customClaims jwt.Claims) (string, error)
}

Encoder interface allows for mocking of the Encoder.

type EncoderKeyRetriever added in v0.0.44

type EncoderKeyRetriever func() (string, string)

EncoderKeyRetriever defines the function signature required to retrieve private PEM key.

type JwtDecoder

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

JwtDecoder can decode a jwt token string.

func NewJwtDecoder

func NewJwtDecoder(fetchJWKS DecoderJwksRetriever, options ...JwtDecoderOption) (*JwtDecoder, error)

NewJwtDecoder creates a new JwtDecoder with the set ECDSA and RSA public keys in the JWK string.

func (*JwtDecoder) Decode

func (d *JwtDecoder) Decode(tokenString string) (*StandardClaims, error)

Decode a jwt token string and return the Standard Culture Amp Claims.

func (*JwtDecoder) DecodeWithCustomClaims added in v1.0.5

func (d *JwtDecoder) DecodeWithCustomClaims(tokenString string, customClaims jwt.Claims) error

DecodeWithCustomClaims takes a jwt token string and populate the customClaims.

type JwtDecoderOption added in v0.0.44

type JwtDecoderOption func(*JwtDecoder)

JwtDecoderOption function signature for added JWT Decoder options.

func WithDecoderCacheExpiry added in v0.0.44

func WithDecoderCacheExpiry(defaultExpiration, cleanupInterval time.Duration) JwtDecoderOption

WithDecoderCacheExpiry sets the JwtDecoder JWKs cache expiry time. defaultExpiration defaults to 60 minutes. cleanupInterval defaults to every 1 minute. For no expiry (not recommended for production) use: defaultExpiration to NoExpiration (ie. time.Duration = -1).

type JwtEncoder

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

JwtEncoder can encode a claim to a jwt token string.

func NewJwtEncoder

func NewJwtEncoder(fetchPrivateKey EncoderKeyRetriever, options ...JwtEncoderOption) (*JwtEncoder, error)

NewJwtEncoder creates a new JwtEncoder.

func (*JwtEncoder) Encode

func (e *JwtEncoder) Encode(claims *StandardClaims) (string, error)

Encode the Standard Culture Amp Claims in a jwt token string.

func (*JwtEncoder) EncodeWithCustomClaims added in v1.0.10

func (e *JwtEncoder) EncodeWithCustomClaims(customClaims jwt.Claims) (string, error)

EncodeWithCustomClaims encodes the Custom Claims in a jwt token string.

type JwtEncoderOption added in v0.0.44

type JwtEncoderOption func(*JwtEncoder)

JwtEncoderOption function signature for added JWT Encoder options.

func WithEncoderCacheExpiry added in v0.0.44

func WithEncoderCacheExpiry(defaultExpiration, cleanupInterval time.Duration) JwtEncoderOption

WithEncoderCacheExpiry sets the JwtEncoder private key cache expiry time. defaultExpiration defaults to 60 minutes. cleanupInterval defaults to every 1 minute. For no expiry (not recommended for production) use: defaultExpiration to NoExpiration (ie. time.Duration = -1).

type StandardClaims

type StandardClaims struct {
	AccountId       string // uuid
	RealUserId      string // uuid
	EffectiveUserId string // uuid

	// the `iss` (Issuer) claim. See https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.1
	Issuer string
	// the `sub` (Subject) claim. See https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.2
	Subject string
	// the `aud` (Audience) claim. See https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.3
	Audience []string
	// the `exp` (Expiration Time) claim. See https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.4
	ExpiresAt time.Time // default on Encode is +1 hour from now
	// the `nbf` (Not Before) claim. See https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.5
	NotBefore time.Time // default on Encode is "now"
	// the `iat` (Issued At) claim. See https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.6
	IssuedAt time.Time // default on Encode is "now"
}

StandardClaims represent the standard Culture Amp JWT claims.

func Decode

func Decode(tokenString string) (*StandardClaims, error)

Decode a jwt token string and return the Standard Culture Amp Claims.

Jump to

Keyboard shortcuts

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