-
Notifications
You must be signed in to change notification settings - Fork 18
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
OIDC private key JWT / client assertion #155
Conversation
for signing JWTs to send as client_assertions ref: https://oauth.net/private-key-jwt/
to send a signed JWT as request's client_assertion ref: https://oauth.net/private-key-jwt/
ErrMissingFuncNow = errors.New("missing now func; please use New()") | ||
) | ||
|
||
// New sets up a new ClientAssertion to sign private key JWTs |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It doesn't look like we actually have any callers for clientassertion.New
here in the cap
library. And New
is the only non-test code path that calls Validate
. I do get that we pulled this out to a package for testability.
But from the perspective of an application consuming this API, I'd question why my application needs to call clientassertion.New
instead of passing the appropriate configuration values to oidc.NewConfig
. It seems like we're introducing more surface area here (and therefore more ways to get it wrong in the application).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
My read of oidc.Config
is to configure the Provider
, which seems to include some minimum required features of OIDC. Other things (like PKCE, which I followed for my implementation) are more features of individual parts of the process. For client assertions (as with PKCE), it's part of a Request
.
I could put more of this logic into the oidc.Option
, which is passed in oidc.NewRequest
-- currently oidc.WithClientAssertionJWT()
just takes a string
, but it could instead take all the things that this thing takes, and produce the string itself internally. I'm just not sure how much that buys us, and I figured leaving this open would allow applications to produce the JWT however they may want, making this package convenient but optional.
@johanbrandhorst suggests below that we could pass the ClientAssertion
(perhaps renamed to JWT
to avoid stuttery package.struct names) instead of a string, then the option or Request constructor could do the signing (if I'm understanding that suggestion right). That would make this extra package a lot less optional for this feature, but definitely harder to hold wrong.
Maybe an in-between would be to accept an interface, which ClientAssertion
/JWT
implements, so folks could build their own, if they wanted. Harder to hold wrong, but still flexible.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@johanbrandhorst suggests below that we could pass the ClientAssertion (perhaps renamed to JWT to avoid stuttery package.struct names) instead of a string, then the option or Request constructor could do the signing (if I'm understanding that suggestion right). That would make this extra package a lot less optional for this feature, but definitely harder to hold wrong.
Maybe an in-between would be to accept an interface, which ClientAssertion/JWT implements, so folks could build their own, if they wanted. Harder to hold wrong, but still flexible.
Yeah, either of those options sounds pretty solid.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Have you been able to integration test this against any third party OAuth2 servers to see that it works as expected?
Regarding your top-level comment @johanbrandhorst
Yep! I have tested it using KeyCloak (easy to run on laptop) and Azure EntraID (what our customer who requested this has said they use). I wanted to test with Auth0 too, but this feature is an enterprise feature over there, and I didn't want to jump through those hoops. I haven't checked Okta or others. Happy to take requests! |
oidc/clientassertion/doc.go
Outdated
@@ -0,0 +1,13 @@ | |||
package clientassertion |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What about a package doc string?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
good point, I'll do this tomorrow when I make an example_test.go
v4 is more sensitive to HMAC length
instead of a string, so it's harder to use incorrectly. JWT implements Serializer, but folks may provider their own.
@johanbrandhorst I believe I've addressed the bulk of your feedback. Let me know what ya think? I'll fix the |
Alrighty folks, ready for another pass! |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ty for the thoughtful contribution. Just a few minor suggestions.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the update, it made me think of some new feedback, hope it makes sense!
// 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 | ||
} |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 😁.
There was a problem hiding this comment.
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:
- I don't love funcs that take multiple params where any of them are expected to always be zero values like
""
/nil
- We could do similar to what
jose
does, and accept anany
but assert supported types. Personally this kind of annoys me aboutjose
- 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.
There was a problem hiding this comment.
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!
There was a problem hiding this comment.
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 :)
it is getting late in the day!
if i'd just run my own tests (:
Thanks y'all! I think I've addressed all your comments, with a couple small exceptions that I could still be convinced of. :) Let me know what ya think! |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ty for all the changes. I had just 2 more I hoping you'll consider.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ty again for the contribution.
@johanbrandhorst let me know if you'd like to press on any of your unresolved comments, or if there's anything else I can do. :) |
@gulducat looks like there are still some unresolved replies on the existing comments that I replied to |
My bad, I got ahead of myself seeing only the new ones. ❤️ |
i was a bit overzealous with my tiny methods. i couldn't quite bring myself to delete signer() :P
Got a big ol refactor for ya @johanbrandhorst, spurred by #155 (comment). I do think it's a nice improvement to the ergonomics. I think I covered your other smaller concerns, too? The diff is pretty large (including completely rewritten tests), but the public interface is only slightly so, as demonstrated by the barely-changed |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This looks great, thank you so much for being so responsive and accepting of feedback! I just had a few nits left but I'm happy to merge as is if you want.
Thanks for helping me make this thing the best it can be! Latest feedback commit is both easier and harder to read ignoring whitespace: 8051410?w=1 -- Conveniently, I could just turn my code comments into subtest names. :) |
Hello! I'm a Nomad engineer, implementing "Private Key JWT" a.k.a.
client_assertion
to our OIDC authentication options, as an alternative to Client Secret auth (although technically, one may sign an asertion JWT with a client secret instead of a private key. this supports that, too).Reference: https://oauth.net/private-key-jwt/
This comprises two main components:
clientassertion
package to isolate this logicstringSerializer
, which can be generated using the new package# 1 does not necessarily need to be in this library, but it seemed reasonable to me. # 2 is required for Nomad's existing usage of
hashicorp/cap
(example in Nomad repo here)Nomad will use this sign client assertion JWTs with any of:
Here is a stripped-down example usage of it:
Test coverage of the
clientassertion
package is high, near 100%. I added tests to the outeroidc
library as best I could figure out how it's arranged.Hopefully this may also make it easier for folks to implement this feature in other products, as they see fit.