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 22 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
74 changes: 74 additions & 0 deletions oidc/clientassertion/algorithms.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0

package clientassertion

import (
"crypto/rsa"
"fmt"
)

type (
// HSAlgorithm is an HMAC signature algorithm
HSAlgorithm string
// RSAlgorithm is an RSA signature algorithm
RSAlgorithm string
)

const (
// JOSE asymmetric signing algorithm values as defined by RFC 7518.
// See: https://tools.ietf.org/html/rfc7518#section-3.1

HS256 HSAlgorithm = "HS256" // HMAC using SHA-256
HS384 HSAlgorithm = "HS384" // HMAC using SHA-384
HS512 HSAlgorithm = "HS512" // HMAC using SHA-512
RS256 RSAlgorithm = "RS256" // RSASSA-PKCS-v1.5 using SHA-256
RS384 RSAlgorithm = "RS384" // RSASSA-PKCS-v1.5 using SHA-384
RS512 RSAlgorithm = "RS512" // RSASSA-PKCS-v1.5 using SHA-512
)

// Validate checks that the secret is a supported algorithm and that it's
// the proper length for the HSAlgorithm:
// - HS256: >= 32 bytes
// - HS384: >= 48 bytes
// - HS512: >= 64 bytes
func (a HSAlgorithm) Validate(secret string) error {
const op = "HSAlgorithm.Validate"
if secret == "" {
return fmt.Errorf("%w: empty", ErrInvalidSecretLength)
}
// 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("%s: %w %q for client secret", op, ErrUnsupportedAlgorithm, a)
}
if len(secret) < expectLen {
return fmt.Errorf("%s: %w: %q must be %d bytes long", op, ErrInvalidSecretLength, a, expectLen)
}
return nil
}

// Validate checks that the key is a supported algorithm and is valid per
// rsa.PrivateKey's Validate() method.
func (a RSAlgorithm) Validate(key *rsa.PrivateKey) error {
const op = "RSAlgorithm.Validate"
if key == nil {
return fmt.Errorf("%s: %w", op, ErrNilPrivateKey)
}
switch a {
case RS256, RS384, RS512:
if err := key.Validate(); err != nil {
return fmt.Errorf("%s: %w", op, err)
}
return nil
default:
return fmt.Errorf("%s: %w %q for for RSA key", op, ErrUnsupportedAlgorithm, a)
}
}
202 changes: 202 additions & 0 deletions oidc/clientassertion/client_assertion.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0

// Package clientassertion signs JWTs with a Private Key or Client Secret
// for use in OIDC client_assertion requests, A.K.A. private_key_jwt.
// reference: https://oauth.net/private-key-jwt/
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 (
// JWTTypeParam is the proper value for client_assertion_type.
// https://www.rfc-editor.org/rfc/rfc7523.html#section-2.2
JWTTypeParam = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
)

// NewJWT creates a new JWT which will be signed with either a private key or
// client secret.
//
// Supported Options:
// * WithClientSecret
// * WithRSAKey
// * WithKeyID
// * WithHeaders
//
// Either WithRSAKey or WithClientSecret must be used, but not both.
func NewJWT(clientID string, audience []string, opts ...Option) (*JWT, error) {
const op = "NewJWT"
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("%s: %w", op, err)
}

// 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 nil, fmt.Errorf("%s: %w", op, err)
}

return j, nil
}

// JWT is used to create a client assertion JWT, a special JWT used by an OAuth
// 2.0 or OIDC client to authenticate themselves to an authorization server
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
}

// Serialize returns client assertion JWT which can be used by an OAuth 2.0 or
// OIDC client to authenticate themselves to an authorization server
func (j *JWT) Serialize() (string, error) {
const op = "JWT.Serialize"
builder, err := j.builder()
if err != nil {
return "", fmt.Errorf("%s: %w", op, err)
}
token, err := builder.Serialize()
if err != nil {
return "", fmt.Errorf("%s: failed to sign token: %w", op, err)
}
return token, nil
}

func (j *JWT) validate() error {
const op = "JWT.validate"
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 fmt.Errorf("%s: %w", op, 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 fmt.Errorf("%s: %w", op, errors.Join(errs...))
}

return nil
}

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

func (j *JWT) signer() (jose.Signer, error) {
const op = "signer"
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)),
}
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("%s: %w: %w", op, 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