diff --git a/README.md b/README.md index be71cb5..e66c7d4 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,7 @@ The available flags are: --actor-token string acting party token --actor-token-type string acting party token type --assertion string claims for jwt bearer assertion + --assertion-jwt string pre-signed jwt bearer assertion, passed as-is --audience strings requested audience --auth-method string token endpoint authentication method --authentication-code string authentication code used for passwordless authentication @@ -124,6 +125,7 @@ The available flags are: --redirect-url string client redirect url (default "http://localhost:9876/callback") --refresh-token string refresh token --request-object pass request parameters as jwt + --requested-token-type string requested token type --resource strings requested resource --response-mode string response mode --response-types strings response type diff --git a/cmd/log.go b/cmd/log.go index fafe6db..e4be58d 100644 --- a/cmd/log.go +++ b/cmd/log.go @@ -121,6 +121,7 @@ func LogInputData(cc oauth2.ClientConfig) { {"Password", cc.Password}, {"Refresh token", cc.RefreshToken}, {"Signing key", cc.SigningKey}, + {"Requested token type", cc.RequestedTokenType}, {"Subject token type", cc.SubjectTokenType}, {"Actors token type", cc.ActorTokenType}, {"TLS client cert", cc.TLSCert}, @@ -327,18 +328,13 @@ func LogRequestObject(r oauth2.Request) { LogJson(requestClaims) pterm.Println() - if r.SigningKey != nil { - LogKey("Signing key", r.SigningKey) - } - - if r.EncryptionKey != nil { - LogKey("Encryption key", r.EncryptionKey) - } + LogKey("Signing key", r.SigningKey) + LogKey("Encryption key", r.EncryptionKey) } } } -func LogAssertion(request oauth2.Request, title string, name string) { +func LogAssertion(request oauth2.Request, title string, name string, signingKey interface{}) { var ( assertion = request.Form.Get(name) token *jwt.JSONWebToken @@ -365,10 +361,14 @@ func LogAssertion(request oauth2.Request, title string, name string) { LogJson(claims) pterm.Println("") - LogKey("Signing key", request.SigningKey) + LogKey("Signing key", signingKey) } func LogKey(name string, key interface{}) { + if key == nil { + return + } + var err error pterm.Println(name) diff --git a/cmd/oauth2.go b/cmd/oauth2.go index d9fb5f6..c1b012d 100644 --- a/cmd/oauth2.go +++ b/cmd/oauth2.go @@ -64,8 +64,10 @@ func NewOAuth2Cmd(version, commit, date string) (cmd *OAuth2Cmd) { cmd.PersistentFlags().BoolVar(&cconfig.RequestObject, "request-object", false, "pass request parameters as jwt") cmd.PersistentFlags().BoolVar(&cconfig.EncryptedRequestObject, "encrypted-request-object", false, "pass request parameters as encrypted jwt") cmd.PersistentFlags().StringVar(&cconfig.Assertion, "assertion", "", "claims for jwt bearer assertion") + cmd.PersistentFlags().StringVar(&cconfig.AssertionJWT, "assertion-jwt", "", "pre-signed jwt bearer assertion, passed as-is") cmd.PersistentFlags().StringVar(&cconfig.SigningKey, "signing-key", "", "path or url to signing key in jwks format") cmd.PersistentFlags().StringVar(&cconfig.EncryptionKey, "encryption-key", "", "path or url to encryption key in jwks format") + cmd.PersistentFlags().StringVar(&cconfig.RequestedTokenType, "requested-token-type", "", "requested token type") cmd.PersistentFlags().StringVar(&cconfig.SubjectToken, "subject-token", "", "third party token") cmd.PersistentFlags().StringVar(&cconfig.SubjectTokenType, "subject-token-type", "", "third party token type") cmd.PersistentFlags().StringVar(&cconfig.ActorToken, "actor-token", "", "acting party token") diff --git a/cmd/oauth2_authorize_code.go b/cmd/oauth2_authorize_code.go index 2c4943a..bac828f 100644 --- a/cmd/oauth2_authorize_code.go +++ b/cmd/oauth2_authorize_code.go @@ -29,7 +29,7 @@ func (c *OAuth2Cmd) AuthorizationCodeGrantFlow(clientConfig oauth2.ClientConfig, return err } - LogAssertion(parRequest, "Client assertion", "client_assertion") + LogAssertion(parRequest, "Client assertion", "client_assertion", parRequest.ClientAssertionKey) LogAuthMethod(clientConfig) LogRequestObject(parRequest) LogRequestAndResponse(parRequest, parResponse) diff --git a/cmd/oauth2_token.go b/cmd/oauth2_token.go index 81750b8..5cd4f07 100644 --- a/cmd/oauth2_token.go +++ b/cmd/oauth2_token.go @@ -59,8 +59,8 @@ func (c *OAuth2Cmd) tokenEndpointFlow( return err } - LogAssertion(tokenRequest, "Assertion", "assertion") - LogAssertion(tokenRequest, "Client assertion", "client_assertion") + LogAssertion(tokenRequest, "Assertion", "assertion", tokenRequest.SigningKey) + LogAssertion(tokenRequest, "Client assertion", "client_assertion", tokenRequest.ClientAssertionKey) LogSubjectTokenAndActorToken(tokenRequest) LogAuthMethod(clientConfig) LogRequestAndResponse(tokenRequest, tokenResponse) diff --git a/internal/oauth2/oauth2.go b/internal/oauth2/oauth2.go index dc830d9..41d9df8 100644 --- a/internal/oauth2/oauth2.go +++ b/internal/oauth2/oauth2.go @@ -75,12 +75,14 @@ type ClientConfig struct { Password string RefreshToken string Assertion string `validate:"omitempty,json"` + AssertionJWT string `validate:"omitempty,jwt"` SigningKey string `validate:"omitempty,uri|file"` EncryptionKey string `validate:"omitempty,uri|file"` + RequestedTokenType string `validate:"omitempty,uri"` SubjectToken string - SubjectTokenType string `validate:"omitempty,oneof=urn:ietf:params:oauth:token-type:access_token"` + SubjectTokenType string `validate:"omitempty,oneof=urn:ietf:params:oauth:token-type:access_token urn:ietf:params:oauth:token-type:id_token"` ActorToken string - ActorTokenType string `validate:"omitempty,oneof=urn:ietf:params:oauth:token-type:access_token"` + ActorTokenType string `validate:"omitempty,oneof=urn:ietf:params:oauth:token-type:access_token urn:ietf:params:oauth:token-type:id_token"` IDTokenHint string LoginHint string IDPHint string @@ -531,7 +533,11 @@ func RequestToken( case JWTBearerGrantType: var assertion string - if assertion, request.SigningKey, err = SignJWT( + // A grant minted elsewhere - an ID-JAG, say - is presented as-is: signing our own claims + // over it would replace the very assertion the server is meant to verify. + if cconfig.AssertionJWT != "" { + assertion = cconfig.AssertionJWT + } else if assertion, request.SigningKey, err = SignJWT( AssertionClaims(sconfig, cconfig), JWKSigner(cconfig.SigningKey, hc), ); err != nil { @@ -543,6 +549,10 @@ func RequestToken( request.Form.Set("subject_token", cconfig.SubjectToken) request.Form.Set("subject_token_type", cconfig.SubjectTokenType) + if cconfig.RequestedTokenType != "" { + request.Form.Set("requested_token_type", cconfig.RequestedTokenType) + } + if cconfig.ActorToken != "" { request.Form.Set("actor_token", cconfig.ActorToken) request.Form.Set("actor_token_type", cconfig.ActorTokenType) diff --git a/internal/oauth2/request.go b/internal/oauth2/request.go index dbd52ae..ef24d61 100644 --- a/internal/oauth2/request.go +++ b/internal/oauth2/request.go @@ -21,8 +21,11 @@ type Request struct { JARM map[string]interface{} RequestObject string SigningKey interface{} - EncryptionKey interface{} - Cert *x509.Certificate + // The client assertion is signed independently of the grant assertion and the request + // object, so its key must not share a field with them. + ClientAssertionKey interface{} + EncryptionKey interface{} + Cert *x509.Certificate } func (r *Request) AuthorizeRequest( @@ -205,7 +208,7 @@ func (r *Request) AuthenticateClient( case ClientSecretJwtAuthMethod: var clientAssertion string - if clientAssertion, r.SigningKey, err = SignJWT( + if clientAssertion, r.ClientAssertionKey, err = SignJWT( ClientAssertionClaims(sconfig, cconfig), SecretSigner([]byte(cconfig.ClientSecret)), ); err != nil { @@ -217,7 +220,7 @@ func (r *Request) AuthenticateClient( case PrivateKeyJwtAuthMethod: var clientAssertion string - if clientAssertion, r.SigningKey, err = SignJWT( + if clientAssertion, r.ClientAssertionKey, err = SignJWT( ClientAssertionClaims(sconfig, cconfig), JWKSigner(cconfig.SigningKey, hc), ); err != nil { diff --git a/internal/oauth2/request_test.go b/internal/oauth2/request_test.go index caf1d3d..f5650a7 100644 --- a/internal/oauth2/request_test.go +++ b/internal/oauth2/request_test.go @@ -70,19 +70,7 @@ func TestRequestTokenResource(t *testing.T) { for name, tc := range tests { t.Run(name, func(t *testing.T) { - var got url.Values - - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - require.NoError(t, err) - - got, err = url.ParseQuery(string(body)) - require.NoError(t, err) - - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"access_token":"tok","token_type":"Bearer","expires_in":3600}`)) - })) - defer srv.Close() + srv, form := formCaptureServer(t) cconfig := oauth2.ClientConfig{ ClientID: "test-client", @@ -96,7 +84,145 @@ func TestRequestTokenResource(t *testing.T) { _, _, err := oauth2.RequestToken(context.Background(), cconfig, sconfig, &http.Client{}) require.NoError(t, err) - require.Equal(t, tc.expected, got["resource"]) + require.Equal(t, tc.expected, form()["resource"]) + }) + } +} + +func TestRequestTokenRequestedTokenType(t *testing.T) { + tests := map[string]struct { + requestedTokenType string + expected []string + }{ + "none": { + requestedTokenType: "", + expected: nil, + }, + "id-jag": { + requestedTokenType: "urn:ietf:params:oauth:token-type:id-jag", + expected: []string{"urn:ietf:params:oauth:token-type:id-jag"}, + }, + "access token": { + requestedTokenType: "urn:ietf:params:oauth:token-type:access_token", + expected: []string{"urn:ietf:params:oauth:token-type:access_token"}, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + srv, form := formCaptureServer(t) + + cconfig := oauth2.ClientConfig{ + ClientID: "test-client", + ClientSecret: "test-secret", + GrantType: oauth2.TokenExchangeGrantType, + AuthMethod: oauth2.ClientSecretPostAuthMethod, + SubjectToken: "subject-token", + SubjectTokenType: "urn:ietf:params:oauth:token-type:access_token", + RequestedTokenType: tc.requestedTokenType, + } + sconfig := oauth2.ServerConfig{TokenEndpoint: srv.URL} + + _, _, err := oauth2.RequestToken(context.Background(), cconfig, sconfig, &http.Client{}) + require.NoError(t, err) + + require.Equal(t, tc.expected, form()["requested_token_type"]) }) } } + +// An ID-JAG is signed by the identity provider, so the redeeming client presents the token it was +// given rather than signing one of its own. +func TestRequestTokenAssertionJWT(t *testing.T) { + const grant = "eyJ0eXAiOiJvYXV0aC1pZC1qYWcrand0IiwiYWxnIjoiUlMyNTYifQ.eyJpc3MiOiJodHRwczovL2lkcC5leGFtcGxlLmNvbSJ9.signature" + + srv, form := formCaptureServer(t) + + cconfig := oauth2.ClientConfig{ + ClientID: "test-client", + ClientSecret: "test-secret", + GrantType: oauth2.JWTBearerGrantType, + AuthMethod: oauth2.ClientSecretPostAuthMethod, + AssertionJWT: grant, + } + sconfig := oauth2.ServerConfig{TokenEndpoint: srv.URL} + + request, _, err := oauth2.RequestToken(context.Background(), cconfig, sconfig, &http.Client{}) + require.NoError(t, err) + + require.Equal(t, []string{grant}, form()["assertion"]) + require.Nil(t, request.SigningKey) +} + +func TestRequestTokenAssertionJWTUnsetStillSigns(t *testing.T) { + srv, form := formCaptureServer(t) + + cconfig := oauth2.ClientConfig{ + ClientID: "test-client", + ClientSecret: "test-secret", + GrantType: oauth2.JWTBearerGrantType, + AuthMethod: oauth2.ClientSecretPostAuthMethod, + SigningKey: "../../data/rsa/key.json", + Assertion: `{"sub":"jdoe@example.com"}`, + } + sconfig := oauth2.ServerConfig{TokenEndpoint: srv.URL} + + request, _, err := oauth2.RequestToken(context.Background(), cconfig, sconfig, &http.Client{}) + require.NoError(t, err) + + assertion := form().Get("assertion") + require.NotEmpty(t, assertion) + + token, claims, err := oauth2.UnsafeParseJWT(assertion) + require.NoError(t, err) + require.Equal(t, "RS256", token.Headers[0].Algorithm) + require.Equal(t, "jdoe@example.com", claims["sub"]) + require.NotNil(t, request.SigningKey) +} + +// Client authentication signs its own assertion, so its key must not be mistaken for one standing +// behind a pre-signed grant. +func TestRequestTokenAssertionJWTKeepsClientAuthKeySeparate(t *testing.T) { + const grant = "eyJ0eXAiOiJvYXV0aC1pZC1qYWcrand0IiwiYWxnIjoiUlMyNTYifQ.eyJpc3MiOiJodHRwczovL2lkcC5leGFtcGxlLmNvbSJ9.signature" + + srv, form := formCaptureServer(t) + + cconfig := oauth2.ClientConfig{ + ClientID: "test-client", + GrantType: oauth2.JWTBearerGrantType, + AuthMethod: oauth2.PrivateKeyJwtAuthMethod, + SigningKey: "../../data/rsa/key.json", + AssertionJWT: grant, + } + sconfig := oauth2.ServerConfig{TokenEndpoint: srv.URL} + + request, _, err := oauth2.RequestToken(context.Background(), cconfig, sconfig, &http.Client{}) + require.NoError(t, err) + + require.Equal(t, []string{grant}, form()["assertion"]) + require.NotEmpty(t, form().Get("client_assertion")) + + require.Nil(t, request.SigningKey) + require.NotNil(t, request.ClientAssertionKey) +} + +func formCaptureServer(t *testing.T) (*httptest.Server, func() url.Values) { + t.Helper() + + var got url.Values + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + + got, err = url.ParseQuery(string(body)) + require.NoError(t, err) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"access_token":"tok","token_type":"Bearer","expires_in":3600}`)) + })) + + t.Cleanup(srv.Close) + + return srv, func() url.Values { return got } +}