From 726bfedc17f0b9abda2ca8c4248eca4fb8f16d42 Mon Sep 17 00:00:00 2001 From: Matias Schaab Date: Wed, 19 Aug 2026 14:50:08 -0300 Subject: [PATCH 1/3] feat: support requested_token_type and pre-signed jwt-bearer assertions Neither parameter that Cross-App Access needs was reachable. Token exchange could not ask for an ID-JAG, and jwt-bearer always signed its own assertion from --assertion, which holds claims to sign rather than a token. requested_token_type is left unvalidated, unlike the adjacent subject and actor token types: RFC 8693 section 2.1 makes it an open URI namespace, so a oneof would mean editing this tool whenever a draft mints a new type. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 2 + cmd/oauth2.go | 2 + internal/oauth2/oauth2.go | 12 +++- internal/oauth2/request_test.go | 102 ++++++++++++++++++++++++++++++++ 4 files changed, 117 insertions(+), 1 deletion(-) 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/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/internal/oauth2/oauth2.go b/internal/oauth2/oauth2.go index dc830d9..d962e28 100644 --- a/internal/oauth2/oauth2.go +++ b/internal/oauth2/oauth2.go @@ -75,8 +75,10 @@ type ClientConfig struct { Password string RefreshToken string Assertion string `validate:"omitempty,json"` + AssertionJWT string SigningKey string `validate:"omitempty,uri|file"` EncryptionKey string `validate:"omitempty,uri|file"` + RequestedTokenType string SubjectToken string SubjectTokenType string `validate:"omitempty,oneof=urn:ietf:params:oauth:token-type:access_token"` ActorToken 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_test.go b/internal/oauth2/request_test.go index caf1d3d..c2859ae 100644 --- a/internal/oauth2/request_test.go +++ b/internal/oauth2/request_test.go @@ -100,3 +100,105 @@ func TestRequestTokenResource(t *testing.T) { }) } } + +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) { + 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":"N_A","expires_in":3600}`)) + })) + defer srv.Close() + + 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, got["requested_token_type"]) + }) + } +} + +// An ID-JAG is signed by the identity provider, so the redeeming client has a token and no key to +// sign one with. The absence of a signing key here is the point: it proves SignJWT is bypassed +// rather than merely overridden. +func TestRequestTokenAssertionJWT(t *testing.T) { + const grant = "eyJ0eXAiOiJvYXV0aC1pZC1qYWcrand0IiwiYWxnIjoiUlMyNTYifQ.eyJpc3MiOiJodHRwczovL2lkcC5leGFtcGxlLmNvbSJ9.signature" + + 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() + + cconfig := oauth2.ClientConfig{ + ClientID: "test-client", + ClientSecret: "test-secret", + GrantType: oauth2.JWTBearerGrantType, + AuthMethod: oauth2.ClientSecretPostAuthMethod, + AssertionJWT: grant, + } + sconfig := oauth2.ServerConfig{TokenEndpoint: srv.URL} + + _, _, err := oauth2.RequestToken(context.Background(), cconfig, sconfig, &http.Client{}) + require.NoError(t, err) + + require.Equal(t, []string{grant}, got["assertion"]) +} + +func TestRequestTokenAssertionJWTUnsetStillSigns(t *testing.T) { + cconfig := oauth2.ClientConfig{ + ClientID: "test-client", + ClientSecret: "test-secret", + GrantType: oauth2.JWTBearerGrantType, + AuthMethod: oauth2.ClientSecretPostAuthMethod, + } + sconfig := oauth2.ServerConfig{TokenEndpoint: "http://localhost:0"} + + _, _, err := oauth2.RequestToken(context.Background(), cconfig, sconfig, &http.Client{}) + require.Error(t, err) +} From 94ffe88f73e2085968334baeeb3f9e135ff3fd94 Mon Sep 17 00:00:00 2001 From: Matias Schaab Date: Wed, 19 Aug 2026 15:11:45 -0300 Subject: [PATCH 2/3] fix: only log a signing key when the assertion was signed locally A pre-signed assertion leaves request.SigningKey nil, so the flow printed a bare "Signing key" heading with nothing under it. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/log.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cmd/log.go b/cmd/log.go index fafe6db..f8530e4 100644 --- a/cmd/log.go +++ b/cmd/log.go @@ -365,7 +365,11 @@ func LogAssertion(request oauth2.Request, title string, name string) { LogJson(claims) pterm.Println("") - LogKey("Signing key", request.SigningKey) + // A pre-signed assertion has no key of ours behind it, and the guard matches how the + // signing key is logged elsewhere. + if request.SigningKey != nil { + LogKey("Signing key", request.SigningKey) + } } func LogKey(name string, key interface{}) { From a712227167675c6d63bbf962b6c31798d58abd35 Mon Sep 17 00:00:00 2001 From: Matias Schaab Date: Wed, 19 Aug 2026 15:46:18 -0300 Subject: [PATCH 3/3] fix: address review feedback on assertion key logging and flag validation Give the client assertion its own key field so it can no longer be attributed to a grant assertion the client never signed, move the nil handling into LogKey, validate the two new flags before any request is sent, and widen the subject/actor token type lists to cover the ID-JAG draft's id_token subject. Tests: share the form-capturing server, and assert that an unset --assertion-jwt really signs locally - the previous test passed on any error, including a dial failure. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/log.go | 22 +++--- cmd/oauth2_authorize_code.go | 2 +- cmd/oauth2_token.go | 4 +- internal/oauth2/oauth2.go | 8 +-- internal/oauth2/request.go | 11 +-- internal/oauth2/request_test.go | 122 +++++++++++++++++++------------- 6 files changed, 96 insertions(+), 73 deletions(-) diff --git a/cmd/log.go b/cmd/log.go index f8530e4..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,14 +361,14 @@ func LogAssertion(request oauth2.Request, title string, name string) { LogJson(claims) pterm.Println("") - // A pre-signed assertion has no key of ours behind it, and the guard matches how the - // signing key is logged elsewhere. - if request.SigningKey != nil { - 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_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 d962e28..41d9df8 100644 --- a/internal/oauth2/oauth2.go +++ b/internal/oauth2/oauth2.go @@ -75,14 +75,14 @@ type ClientConfig struct { Password string RefreshToken string Assertion string `validate:"omitempty,json"` - AssertionJWT string + AssertionJWT string `validate:"omitempty,jwt"` SigningKey string `validate:"omitempty,uri|file"` EncryptionKey string `validate:"omitempty,uri|file"` - RequestedTokenType string + 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 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 c2859ae..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,7 @@ 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"]) }) } } @@ -122,19 +110,7 @@ func TestRequestTokenRequestedTokenType(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":"N_A","expires_in":3600}`)) - })) - defer srv.Close() + srv, form := formCaptureServer(t) cconfig := oauth2.ClientConfig{ ClientID: "test-client", @@ -150,30 +126,17 @@ func TestRequestTokenRequestedTokenType(t *testing.T) { _, _, err := oauth2.RequestToken(context.Background(), cconfig, sconfig, &http.Client{}) require.NoError(t, err) - require.Equal(t, tc.expected, got["requested_token_type"]) + require.Equal(t, tc.expected, form()["requested_token_type"]) }) } } -// An ID-JAG is signed by the identity provider, so the redeeming client has a token and no key to -// sign one with. The absence of a signing key here is the point: it proves SignJWT is bypassed -// rather than merely overridden. +// 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" - 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", @@ -184,21 +147,82 @@ func TestRequestTokenAssertionJWT(t *testing.T) { } sconfig := oauth2.ServerConfig{TokenEndpoint: srv.URL} - _, _, err := oauth2.RequestToken(context.Background(), cconfig, sconfig, &http.Client{}) + request, _, err := oauth2.RequestToken(context.Background(), cconfig, sconfig, &http.Client{}) require.NoError(t, err) - require.Equal(t, []string{grant}, got["assertion"]) + 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: "http://localhost:0"} + 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) - _, _, err := oauth2.RequestToken(context.Background(), cconfig, sconfig, &http.Client{}) - require.Error(t, err) + return srv, func() url.Values { return got } }