Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

OIDC private key JWT / client assertion #155

Merged
merged 28 commits into from
Feb 28, 2025
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
f8b3e76
add oidc/clientassertion package
gulducat Feb 24, 2025
d640633
add WithClientAssertionJWT Option
gulducat Feb 24, 2025
ca292a6
test WithClientAssertionJWT
gulducat Feb 24, 2025
8fca05f
add changelog, fix test
gulducat Feb 25, 2025
2fe4cae
s/ClientAssertion/JWT/g in clientassertion pkg
gulducat Feb 25, 2025
b40fd48
move options to options.go
gulducat Feb 25, 2025
4dfbc63
go-jose/v3->v4
gulducat Feb 25, 2025
d8b89d1
more and different option validation
gulducat Feb 26, 2025
a71134d
WithClientAssertionJWT accepts a Serializer
gulducat Feb 26, 2025
53388a4
Merge branch 'main' into oidc-client-assertion
gulducat Feb 26, 2025
25a0e5c
protect kid from headers
gulducat Feb 26, 2025
8f9aa91
add ExampleJWT test
gulducat Feb 26, 2025
1d1813c
brief package docstring, rename a const
gulducat Feb 26, 2025
cf24de8
add copyright headers
gulducat Feb 26, 2025
9f0b393
missed a rename
gulducat Feb 26, 2025
95b5c03
test the new method on test provider
gulducat Feb 26, 2025
c06d559
godocs and ops everywhere!
gulducat Feb 26, 2025
70ea140
privatize a couple things, const KeyIDHeader
gulducat Feb 26, 2025
c871e25
errors go in error.go
gulducat Feb 26, 2025
e6d416e
error if missing kid
gulducat Feb 26, 2025
62aaf97
fix obvious mistake
gulducat Feb 26, 2025
9981bce
fix less obvious but still pretty obvious error
gulducat Feb 26, 2025
d6932e0
add a couple missing err ops
gulducat Feb 27, 2025
cd236a5
pr feedback bits
gulducat Feb 27, 2025
97b3588
clarify hmac min len
gulducat Feb 27, 2025
e8dfe19
refactor to NewJWTWithRSAKey and NewJWTWithHMAC
gulducat Feb 28, 2025
a94da54
remove most single-use private methods
gulducat Feb 28, 2025
8051410
more lovely PR feedback
gulducat Feb 28, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ Canonical reference for changes, improvements, and bugfixes for cap.

## Next

* feat (oidc): add WithClientAssertionJWT to enable "private key JWT" ([PR #155](https://github.com/hashicorp/cap/pull/155))
* feat (oidc): add WithVerifier ([PR #141](https://github.com/hashicorp/cap/pull/141))
* feat (ldap): add an option to enable sAMAccountname logins when upndomain is set ([PR #146](https://github.com/hashicorp/cap/pull/146))
* feat (saml): enhancing signature validation in SAML Response ([PR #144](https://github.com/hashicorp/cap/pull/144))
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ go 1.21
require (
github.com/coreos/go-oidc/v3 v3.11.0
github.com/go-jose/go-jose/v3 v3.0.3
github.com/go-jose/go-jose/v4 v4.0.5
github.com/hashicorp/go-cleanhttp v0.5.2
github.com/hashicorp/go-hclog v1.6.3
github.com/hashicorp/go-multierror v1.1.1
Expand All @@ -20,7 +21,6 @@ require (
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/fatih/color v1.17.0 // indirect
github.com/go-jose/go-jose/v4 v4.0.5 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
Expand Down
55 changes: 55 additions & 0 deletions oidc/clientassertion/algorithms.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package clientassertion

import (
"crypto/rsa"
"errors"
"fmt"
)

type (
SignatureAlgorithm string
HSAlgorithm SignatureAlgorithm
RSAlgorithm SignatureAlgorithm
)

const (
HS256 HSAlgorithm = "HS256"
HS384 HSAlgorithm = "HS384"
HS512 HSAlgorithm = "HS512"
RS256 RSAlgorithm = "RS256"
RS384 RSAlgorithm = "RS384"
RS512 RSAlgorithm = "RS512"
)

var (
ErrUnsupportedAlgorithm = errors.New("unsupported algorithm")
ErrInvalidSecretLength = errors.New("invalid secret length for algorithm")
)

func (a HSAlgorithm) Validate(secret string) error {
// verify secret length based on alg
var expectLen int
switch a {
case HS256:
expectLen = 32
case HS384:
expectLen = 48
case HS512:
expectLen = 64
default:
return fmt.Errorf("%w %q for client secret", ErrUnsupportedAlgorithm, a)
}
if len(secret) < expectLen {
return fmt.Errorf("%w: %q must be %d bytes long", ErrInvalidSecretLength, a, expectLen)
}
return nil
}

func (a RSAlgorithm) Validate(key *rsa.PrivateKey) error {
switch a {
case RS256, RS384, RS512:
return key.Validate()
default:
return fmt.Errorf("%w %q for for RSA key", ErrUnsupportedAlgorithm, a)
}
}
195 changes: 195 additions & 0 deletions oidc/clientassertion/client_assertion.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
package clientassertion

import (
"errors"
"fmt"
"time"

"github.com/go-jose/go-jose/v4"
"github.com/go-jose/go-jose/v4/jwt"
"github.com/hashicorp/go-uuid"
)

const (
// ClientAssertionJWTType is the proper value for client_assertion_type.
// https://www.rfc-editor.org/rfc/rfc7523.html#section-2.2
ClientAssertionJWTType = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
)

var (
// these may happen due to user error
ErrMissingClientID = errors.New("missing client ID")
ErrMissingAudience = errors.New("missing audience")
ErrMissingAlgorithm = errors.New("missing signing algorithm")
ErrMissingKeyOrSecret = errors.New("missing private key or client secret")
ErrBothKeyAndSecret = errors.New("both private key and client secret provided")
// if these happen, either the user directly instantiated &JWT{}
// or there's a bug somewhere.
ErrMissingFuncIDGenerator = errors.New("missing IDgen func; please use NewJWT()")
ErrMissingFuncNow = errors.New("missing now func; please use NewJWT()")
ErrCreatingSigner = errors.New("error creating jwt signer")
)

// NewJWT sets up a new JWT to sign with a private key or client secret
func NewJWT(clientID string, audience []string, opts ...Option) (*JWT, error) {
j := &JWT{
clientID: clientID,
audience: audience,
headers: make(map[string]string),
genID: uuid.GenerateUUID,
now: time.Now,
}

var errs []error
for _, opt := range opts {
if err := opt(j); err != nil {
errs = append(errs, err)
}
}
if len(errs) > 0 {
return nil, errors.Join(errs...)
}

if err := j.Validate(); err != nil {
return nil, fmt.Errorf("new client assertion validation error: %w", err)
}
return j, nil
}

// JWT signs a JWT with either a private key or a secret
type JWT struct {
// for JWT claims
clientID string
audience []string
headers map[string]string

// for signer
alg jose.SignatureAlgorithm
// key may be any key type that jose.SigningKey accepts for its Key
key any
// secret may be used instead of key
secret string

// these are overwritten for testing
genID func() (string, error)
now func() time.Time
}

// Validate validates the expected fields
func (j *JWT) Validate() error {
var errs []error
if j.genID == nil {
errs = append(errs, ErrMissingFuncIDGenerator)
}
if j.now == nil {
errs = append(errs, ErrMissingFuncNow)
}
// bail early if any internal func errors
if len(errs) > 0 {
return errors.Join(errs...)
}

if j.clientID == "" {
errs = append(errs, ErrMissingClientID)
}
if len(j.audience) == 0 {
errs = append(errs, ErrMissingAudience)
}
if j.alg == "" {
errs = append(errs, ErrMissingAlgorithm)
}
if j.key == nil && j.secret == "" {
errs = append(errs, ErrMissingKeyOrSecret)
}
if j.key != nil && j.secret != "" {
errs = append(errs, ErrBothKeyAndSecret)
}
// if any of those fail, we have no hope.
if len(errs) > 0 {
return errors.Join(errs...)
}

// finally, make sure Serialize() works; we can't pre-validate everything,
// and this whole thing is useless if it can't Serialize()
if _, err := j.Serialize(); err != nil {
return fmt.Errorf("serialization error during validate: %w", err)
}

return nil
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we still need this function? I think if we can get this public API down to NewJWT and .Serialize(), that would be ideal. Can we move the checks that make sense from this function into NewJWT instead? If we're trying to help users using &JWT{} instead of NewJWT(), I don't think we can assume that they will call Validate if they already ignored the constructor, so I'm OK with that case just panicking or erroring in a bad way.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems reasonable enough to me -- Okay if I keep it as a separate function, just privatize it? I like small single-purpose functions.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, but I think some of this error checking doesn't really belong here anymore. E.g. the checking if j.key and j.secret are both set aren't necessary if you move the logic into the option as I suggested. Calling Serialize in New also feel really wrong IMO, since it's the primary thing the user wants to do. If that's where we're going with this - why do we need a whole struct (or even package) encapsulating this? We could just have NewSerializedJWT as an exported function (maybe we should?). I think New() and Serialize() can be separate, but if this logic was moved into New, it would be clear from the context of that function what should and shouldn't be necessary to validate. Ideally nothing other than the inputs to the function, which should be all the required parameters to create a JWT, should need to be validated. On that note, since either Key or Secret must be set, should it be a parameter rather than an option?

I'm personally not a huge fan of more smaller functions, because the context in which they are used is not clear and indirection always leads to overhead for the reader. I almost left a comment saying that the structure in Serialize was unnecessarily abstracted into smaller functions, but it's such a minor comment that I didn't in the end feel that it was important enough to bring up, but since we're talking about it now, there you go 😁.

Copy link
Member Author

@gulducat gulducat Feb 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the thoughtful reply!

I'll say up front that I'm down to remove the Serialize() call from New - it felt weird when I put it there, so easy to drop it. Also good to move validation into New.

the checking if j.key and j.secret are both set aren't necessary if you move the logic into the option

I like cross-field validation to happen in its own spot, but it amounts to the same result either way, so I'd be happy to put the checks in their respective options. except:

since either Key or Secret must be set, should it be a parameter rather than an option?

The current not-actually-optional-Option thing has been bugging me. Here's some (pardon the pun) other options:

  1. I don't love funcs that take multiple params where any of them are expected to always be zero values like "" / nil
  2. We could do similar to what jose does, and accept an any but assert supported types. Personally this kind of annoys me about jose
  3. Or, what if we have 2 constructors?
func NewJWTWithRSAKey(clientID string, audience []string, alg RSAlgorithm, key *rsa.RSAKey, opts ...Option) (*JWT, error) {
}
func NewJWTWithHMAC(clientID string, audience []string, alg HSAlgorithm, secret string, opts ...Option) (*JWT, error) {
}

That would leave a lot less validation to do, but both could set up and return a JWT, which is mostly shared logic internally.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like option 3 a lot!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe the algorithm will need to be a parameter too, and you could even make it RSAlgorithm and HSAlgorithm respectively :)


// Serialize returns a signed JWT string
func (j *JWT) Serialize() (string, error) {
builder, err := j.builder()
if err != nil {
return "", err
}
token, err := builder.Serialize()
if err != nil {
return "", fmt.Errorf("failed to sign token: %w", err)
}
return token, nil
}

func (j *JWT) builder() (jwt.Builder, error) {
signer, err := j.signer()
if err != nil {
return nil, err
}
id, err := j.genID()
if err != nil {
return nil, fmt.Errorf("failed to generate token id: %w", err)
}
claims := j.claims(id)
return jwt.Signed(signer).Claims(claims), nil
}

func (j *JWT) signer() (jose.Signer, error) {
sKey := jose.SigningKey{
Algorithm: j.alg,
}

// Validate() ensures these are mutually exclusive
if j.secret != "" {
sKey.Key = []byte(j.secret)
}
if j.key != nil {
sKey.Key = j.key
}

sOpts := &jose.SignerOptions{
ExtraHeaders: make(map[jose.HeaderKey]interface{}, len(j.headers)),
}
// note: extra headers can override "kid"
for k, v := range j.headers {
sOpts.ExtraHeaders[jose.HeaderKey(k)] = v
}

signer, err := jose.NewSigner(sKey, sOpts.WithType("JWT"))
if err != nil {
return nil, fmt.Errorf("%w: %w", ErrCreatingSigner, err)
}
return signer, nil
}

func (j *JWT) claims(id string) *jwt.Claims {
now := j.now().UTC()
return &jwt.Claims{
Issuer: j.clientID,
Subject: j.clientID,
Audience: j.audience,
Expiry: jwt.NewNumericDate(now.Add(5 * time.Minute)),
NotBefore: jwt.NewNumericDate(now.Add(-1 * time.Second)),
IssuedAt: jwt.NewNumericDate(now),
ID: id,
}
}

// Serializer is the primary interface impelmented by JWT.
type Serializer interface {
Serialize() (string, error)
}

// ensure JWT implements Serializer, which is accepted by the oidc option
// oidc.WithClientAssertionJWT.
var _ Serializer = &JWT{}
Loading
Loading