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.
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.
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.
@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.
johanbrandhorst
left a comment
There was a problem hiding this comment.
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.
What about a package doc string?
There was a problem hiding this comment.
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! |
jimlambrt
left a comment
There was a problem hiding this comment.
Ty for the thoughtful contribution. Just a few minor suggestions.
johanbrandhorst
left a comment
There was a problem hiding this comment.
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.
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.
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.
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.
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
josedoes, and accept ananybut 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.
I like option 3 a lot!
There was a problem hiding this comment.
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! |
jimlambrt
left a comment
There was a problem hiding this comment.
Ty for all the changes. I had just 2 more I hoping you'll consider.
jimlambrt
left a comment
There was a problem hiding this comment.
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 |
johanbrandhorst
left a comment
There was a problem hiding this comment.
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_assertionto 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:
clientassertionpackage 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
clientassertionpackage is high, near 100%. I added tests to the outeroidclibrary 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.