From b63d22b25cc4a0d00c12f56c0b5e14e306360106 Mon Sep 17 00:00:00 2001 From: Alessandro De Blasis Date: Mon, 17 Aug 2026 09:17:33 +0300 Subject: [PATCH 1/2] feat(adapters): salesforce JWT bearer grant (RFC 7523) The token endpoint accepts grant_type=urn:ietf:params:oauth:grant-type: jwt-bearer with an RS256 assertion, verified like the real endpoint: 3 base64url segments, alg RS256, RSA-SHA256 signature against the adapter's fixed connected-app certificate, iss non-empty, sub (or legacy prn) non-empty, aud a Salesforce login host, exp in the future. A valid assertion mints a normal session for the sub user with no refresh_token (fresh assertions replace refresh); failures return the real invalid_grant 400. Closes the last item of the salesforce sweep issue (SOQL v0.22.0, SObject Collections v0.37.0). --- adapters/salesforce-style/README.md | 42 +++-- adapters/salesforce-style/scripts/oauth.star | 133 +++++++++++++++- internal/engine/salesforce_style_test.go | 153 +++++++++++++++++++ 3 files changed, 313 insertions(+), 15 deletions(-) diff --git a/adapters/salesforce-style/README.md b/adapters/salesforce-style/README.md index ce95a678..0f7dd8da 100644 --- a/adapters/salesforce-style/README.md +++ b/adapters/salesforce-style/README.md @@ -13,14 +13,15 @@ data is synthetic — no real API data is included. A faithful behavioral mock of the Salesforce REST API surface, designed to unblock CRM integrations during local development: -- **OAuth2:** `POST /services/oauth2/token` (password, authorization_code, or - refresh_token grants) → `{access_token:"00D...", instance_url, token_type:"Bearer", - id, issued_at, signature, refresh_token}`. Refresh tokens are long-lived and - **reusable**, exactly as in real Salesforce: redeeming one never invalidates - it, and the refresh grant response omits `refresh_token` (the caller keeps - the one it has). Access tokens rotate on every grant and expire with the - 2-hour session TTL — an expired token 401s with `INVALID_SESSION_ID` until - the client refreshes again with the same refresh token. +- **OAuth2:** `POST /services/oauth2/token` (password, authorization_code, + refresh_token, or JWT bearer grants) → `{access_token:"00D...", instance_url, + token_type:"Bearer", id, issued_at, signature, refresh_token}`. Refresh tokens + are long-lived and **reusable**, exactly as in real Salesforce: redeeming one + never invalidates it, and the refresh grant response omits `refresh_token` + (the caller keeps the one it has). Access tokens rotate on every grant and + expire with the 2-hour session TTL — an expired token 401s with + `INVALID_SESSION_ID` until the client refreshes again with the same refresh + token. - **sObjects describe global:** `GET /services/data/v60.0/sobjects` → list of available objects (Account, Contact, Opportunity, Lead, User). - **sObjects describe object:** `GET /services/data/v60.0/sobjects/Account` → @@ -88,11 +89,34 @@ access tokens rotate on every grant and expire after the 2-hour session TTL, after which protected routes 401 with `INVALID_SESSION_ID` until the client refreshes again. +## JWT bearer grant (server-to-server) + +The token endpoint also implements the RFC 7523 flow real connected apps use: + +``` +POST /services/oauth2/token +grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer +assertion= +``` + +The assertion is verified the way the real endpoint does: three +base64url segments, `alg: RS256`, the RSA-SHA256 signature checked +against the adapter's fixed "connected-app certificate" (the public key +in `scripts/oauth.star` — sign with its private half, the same throwaway +repo material the other JWT adapters use), `iss` non-empty (the consumer +key), `sub` (or legacy `prn`) non-empty, `aud` one of +`https://login.salesforce.com` / `https://test.salesforce.com`, and `exp` +in the future. A valid assertion mints a normal session token for the +`sub` user; the response carries **no** `refresh_token` — a JWT-bearer +client mints a fresh assertion instead of refreshing. Failures return +`400 {"error": "invalid_grant", "error_description": "invalid assertion"}` +(a missing `assertion` is `invalid_request`). + ## Endpoints | Method | Route | Handler | Description | |--------|-------|---------|-------------| -| POST | `/services/oauth2/token` | `oauth.star#on_token` | OAuth2 token (password/code/refresh) | +| POST | `/services/oauth2/token` | `oauth.star#on_token` | OAuth2 token (password/code/refresh/JWT bearer) | | GET | `/services/data/v60.0/sobjects` | `sobjects.star#on_describe_global` | Describe global | | GET | `/services/data/v60.0/sobjects/Account` | `sobjects.star#on_describe_object` | Describe object | | POST | `/services/data/v60.0/sobjects/Account` | `sobjects.star#on_create` | Create record | diff --git a/adapters/salesforce-style/scripts/oauth.star b/adapters/salesforce-style/scripts/oauth.star index d445eb7f..20a1f30d 100644 --- a/adapters/salesforce-style/scripts/oauth.star +++ b/adapters/salesforce-style/scripts/oauth.star @@ -1,8 +1,9 @@ # OAuth2 handler — Salesforce token endpoint. # # POST /services/oauth2/token -# (form: grant_type=password|authorization_code|refresh_token, -# client_id, client_secret, username, password) +# (form: grant_type=password|authorization_code|refresh_token| +# urn:ietf:params:oauth:grant-type:jwt-bearer, +# client_id, client_secret, username, password, assertion) # -> { access_token:"00D...", instance_url, token_type:"Bearer", # id, issued_at, signature, refresh_token } # @@ -11,7 +12,9 @@ # rotate on every grant and expire after the session TTL (2h), after which # the client refreshes again with the same refresh token. The refresh_token # grant response omits refresh_token entirely (the caller keeps the one it -# has); password/code grants mint and return a new one. +# has); password/code grants mint and return a new one. The JWT bearer +# grant (see the bottom of this file) issues an access token only — the +# client mints a fresh assertion instead of refreshing. # Shared helpers from lib.star (_SESSION_TTL lives there). @@ -23,6 +26,18 @@ def on_token(req): client_id = body.get("client_id", "") client_secret = body.get("client_secret", "") + if grant_type == _JWT_GRANT: + assertion = body.get("assertion", "") + if assertion == "" or type(assertion) != "string": + return _oauth_error("invalid_request", "assertion is required") + claims = _verify_assertion(assertion) + if claims == None: + return _oauth_error("invalid_grant", "invalid assertion") + username = claims.get("sub", "") + if username == "": + username = claims.get("prn", "") # legacy claim name + return _issue_token(username, claims.get("iss", ""), None, False) + if grant_type == "password": username = body.get("username", "") password = body.get("password", "") @@ -49,8 +64,9 @@ def on_token(req): # _issue_token issues a Salesforce-style session token. `refresh` is the # existing refresh token on a refresh grant (reused, not echoed) or None to -# mint a fresh one (password/code grants). -def _issue_token(username, client_id, refresh=None): +# mint a fresh one (password/code grants). `with_refresh=False` (JWT bearer +# grant) skips refresh tokens entirely. +def _issue_token(username, client_id, refresh=None, with_refresh=True): seq = store_kv_incr("salesforce", "token_seq") # Session IDs are 00D-prefixed (org key prefix). access = "00D" + _pad_b62(seq, 15) @@ -77,7 +93,7 @@ def _issue_token(username, client_id, refresh=None): "signature": "mock-signature-base64", } - if refresh == None or refresh == "": + if with_refresh and (refresh == None or refresh == ""): refresh = "refresh_" + _pad_b62(seq, 25) store_kv_set("salesforce", "refresh_" + refresh, username) result["refresh_token"] = refresh @@ -110,3 +126,108 @@ def _pad_b62(n, width): while len(s) < width: s = "0" + s return s + +# ==================================================================== +# JWT bearer grant (RFC 7523) — server-to-server auth the way real +# Salesforce connected apps do it: the client signs an RS256 assertion +# with a private key whose certificate is uploaded to the app; the token +# endpoint verifies the signature and claims, then issues a session. +# ==================================================================== + +_JWT_GRANT = "urn:ietf:params:oauth:grant-type:jwt-bearer" + +# The assertion's aud must name a Salesforce login host (production or +# sandbox); anything else is a client misconfiguration. +_JWT_AUDS = ["https://login.salesforce.com", "https://test.salesforce.com"] + +# The fixed synthetic public half of the "connected-app certificate" — +# the private half lives only in tests, mirroring how a real app's cert +# is registered out-of-band. Same throwaway repo material as the other +# JWT adapters. Assert with the matching key; anything else is rejected. +_JWT_PUBLIC_KEY = """-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvBDsZejhK5crr0/kWSHt +hMSxv42QviE9IYlSQf9lZG4AjBymTX4q6UTuYoFnppDoLA0Llm2k8Ybj6GBpPFq1 +DzRuOF0/Iee8+qB+FCJb1hA4O1FLBSoGHnyzx8PmvDth4LTKMgft9mtuozUe04WL +0Cf/cx96wjo4BeO72jYZDYOI2kpCH8lahdwYyykqnIdEALoTdIpCHd4P0cgBHz+s +S3UCPcBF1yt61vUJrKcoJCFqQ9oQ0t+aOHfIQpvoAgedjeJL9x2v9IUZN5lKfV2i +2ShaeiCTe8444oLHYHKh59tiMdL3DiJSdtMyrfJNT5+rvAqzYurkyR4eajWLTlxo +6wIDAQAB +-----END PUBLIC KEY-----""" + +# _B64URL is the base64url alphabet (- and _ replace + and /). +_B64URL = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + "0123" + "45678" + "9-_" + +# _b64url_ok reports whether seg is a syntactically valid unpadded +# base64url segment (alphabet chars only, length not == 1 mod 4). Guards +# the crypto.base64url_decode / crypto.rsa_verify builtins, which error +# (surfacing as a 500) on malformed input. +def _b64url_ok(seg): + if seg == "": + return False + if len(seg) % 4 == 1: + return False + for i in range(len(seg)): + if _B64URL.find(seg[i]) < 0: + return False + return True + +# _jwt_json decodes a JWT segment (0=header, 1=payload) into a Starlark +# dict via crypto.base64url_decode + json_safe_decode, or None when +# malformed. Shape guards keep the decoders from erroring on garbage. +def _jwt_json(token, seg): + parts = token.split(".") + if len(parts) != 3: + return None + if not _b64url_ok(parts[0]) or not _b64url_ok(parts[1]) or not _b64url_ok(parts[2]): + return None + txt = crypto.base64url_decode(parts[seg]) + if txt == "" or txt[:1] != "{": + return None + out = json_safe_decode(txt) + if type(out) != "dict": + return None + return out + +# _claim_int coerces a claim value to int (JSON numbers decode as int). +# Returns None when absent or non-numeric. +def _claim_int(v): + if v == None: + return None + if type(v) == "int": + return v + return None + +# _verify_assertion fully verifies a jwt-bearer assertion the way the real +# token endpoint does (modulo the fixed mock certificate): +# - 3 dot-separated, base64url-valid segments +# - JOSE header alg=="RS256" +# - RSA-SHA256 signature over header.payload verified against the mock +# connected-app certificate +# - iss non-empty (the consumer key), sub or legacy prn non-empty (the +# user the token is for), aud a Salesforce login host, exp in the +# future +# Returns the claims dict, or None when the assertion fails any check. +def _verify_assertion(assertion): + header = _jwt_json(assertion, 0) + if header == None: + return None + if header.get("alg", "") != "RS256": + return None + parts = assertion.split(".") + if not crypto.rsa_verify(_JWT_PUBLIC_KEY, parts[0] + "." + parts[1], parts[2], encoding="base64url"): + return None + claims = _jwt_json(assertion, 1) + if claims == None: + return None + if claims.get("iss", "") == "": + return None + if claims.get("sub", "") == "" and claims.get("prn", "") == "": + return None + exp = _claim_int(claims.get("exp", None)) + if exp == None: + return None + if clock.now_unix() >= exp: + return None + if claims.get("aud", "") not in _JWT_AUDS: + return None + return claims diff --git a/internal/engine/salesforce_style_test.go b/internal/engine/salesforce_style_test.go index 0e50dfd1..5abd5c87 100644 --- a/internal/engine/salesforce_style_test.go +++ b/internal/engine/salesforce_style_test.go @@ -3,7 +3,14 @@ package engine import ( "bytes" "context" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" "encoding/json" + "encoding/pem" "io" "net/http" "net/url" @@ -1453,3 +1460,149 @@ func sfAuthDeleteWithQuery(t *testing.T, rawurl, token string) (string, int) { b, _ := io.ReadAll(resp.Body) return string(b), resp.StatusCode } + +// sfPrivateKey parses the fixed mock keypair the JWT adapters share: the +// private half lives here in tests (google_iam_style_test.go carries the +// PEM), the Salesforce adapter verifies assertions against its public +// half — the "connected-app certificate" a real client registers out of +// band. +func sfPrivateKey(t *testing.T) *rsa.PrivateKey { + t.Helper() + block, _ := pem.Decode([]byte(googleIAMPrivateKeyPEM)) + if block == nil { + t.Fatal("bad test key PEM") + } + priv, err := x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + t.Fatalf("parse test key: %v", err) + } + return priv +} + +// sfSignAssertion builds a real RS256 jwt-bearer assertion with +// Salesforce's claim set: iss = the connected app's consumer key, +// sub = the user the session is for, aud = a Salesforce login host. +func sfSignAssertion(t *testing.T, key *rsa.PrivateKey, iss, sub, aud string, iat, exp int64) string { + t.Helper() + header := `{"alg":"RS256","typ":"JWT"}` + payload := `{"iss":"` + iss + `","sub":"` + sub + `","aud":"` + aud + `",` + + `"iat":` + strconv.FormatInt(iat, 10) + `,"exp":` + strconv.FormatInt(exp, 10) + `}` + h := base64.RawURLEncoding.EncodeToString([]byte(header)) + p := base64.RawURLEncoding.EncodeToString([]byte(payload)) + digest := sha256.Sum256([]byte(h + "." + p)) + sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, digest[:]) + if err != nil { + t.Fatalf("sign: %v", err) + } + return h + "." + p + "." + base64.RawURLEncoding.EncodeToString(sig) +} + +// TestSalesforceStyleJWTBearerGrant exercises the RFC 7523 flow: a valid +// RS256 assertion mints a working session (no refresh token — the client +// mints a fresh assertion instead), and forged, expired, wrong-audience, +// and malformed assertions get the real invalid_grant 400. +func TestSalesforceStyleJWTBearerGrant(t *testing.T) { + base := sfStart(t) + priv := sfPrivateKey(t) + now := time.Now().Unix() + + postGrant := func(assertion string) (string, int) { + v := url.Values{ + "grant_type": {"urn:ietf:params:oauth:grant-type:jwt-bearer"}, + } + if assertion != "" { + v.Set("assertion", assertion) + } + return sfPostForm(t, base+"/services/oauth2/token", v) + } + + // ===== Valid assertion → 200, access_token, no refresh_token ===== + + assertion := sfSignAssertion(t, priv, + "3MVG9mockConsumerKey", "jwt-user@example.com", + "https://login.salesforce.com", now, now+300) + body, status := postGrant(assertion) + if status != 200 { + t.Fatalf("jwt-bearer grant -> %d, want 200; body %s", status, body) + } + var tok map[string]any + if err := json.Unmarshal([]byte(body), &tok); err != nil { + t.Fatalf("unmarshal token resp: %v (body %s)", err, body) + } + accessToken, _ := tok["access_token"].(string) + if accessToken == "" { + t.Fatalf("access_token = %v, want non-empty", tok["access_token"]) + } + if tok["token_type"] != "Bearer" { + t.Fatalf("token_type = %v, want Bearer", tok["token_type"]) + } + if _, has := tok["refresh_token"]; has { + t.Fatalf("jwt-bearer response has refresh_token = %v, want absent", tok["refresh_token"]) + } + + // The minted token is a real session — it authorizes API calls. + res := sfQuery(t, base, accessToken, "query", "SELECT Id FROM Account LIMIT 1", nil) + if _, ok := res["totalSize"]; !ok { + t.Fatalf("query with jwt-bearer token = %v, want a result envelope", res) + } + + // ===== Forged assertion (wrong signing key) → 400 invalid_grant ===== + + forgedKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + forged := sfSignAssertion(t, forgedKey, + "3MVG9mockConsumerKey", "jwt-user@example.com", + "https://login.salesforce.com", now, now+300) + body, status = postGrant(forged) + if status != 400 { + t.Fatalf("forged assertion -> %d, want 400; body %s", status, body) + } + if !strings.Contains(body, "invalid_grant") { + t.Fatalf("forged assertion -> body %q, want invalid_grant", body) + } + + // ===== Properly signed but expired assertion → 400 invalid_grant ===== + + expired := sfSignAssertion(t, priv, + "3MVG9mockConsumerKey", "jwt-user@example.com", + "https://login.salesforce.com", now-600, now-300) + body, status = postGrant(expired) + if status != 400 { + t.Fatalf("expired assertion -> %d, want 400; body %s", status, body) + } + if !strings.Contains(body, "invalid_grant") { + t.Fatalf("expired assertion -> body %q, want invalid_grant", body) + } + + // ===== Wrong audience → 400 invalid_grant ===== + + wrongAud := sfSignAssertion(t, priv, + "3MVG9mockConsumerKey", "jwt-user@example.com", + "https://evil.example.com/token", now, now+300) + body, status = postGrant(wrongAud) + if status != 400 { + t.Fatalf("wrong aud -> %d, want 400; body %s", status, body) + } + if !strings.Contains(body, "invalid_grant") { + t.Fatalf("wrong aud -> body %q, want invalid_grant", body) + } + + // ===== Garbage assertion (not a JWT) → 400 invalid_grant ===== + + body, status = postGrant("not-a-jwt-at-all") + if status != 400 { + t.Fatalf("garbage assertion -> %d, want 400; body %s", status, body) + } + + // ===== Missing assertion → 400 invalid_request ===== + + body, status = postGrant("") + if status != 400 { + t.Fatalf("missing assertion -> %d, want 400; body %s", status, body) + } + if !strings.Contains(body, "invalid_request") { + t.Fatalf("missing assertion -> body %q, want invalid_request", body) + } +} From d195abf1a734ed20be4618b7b24d861bbd9b0f34 Mon Sep 17 00:00:00 2001 From: Alessandro De Blasis Date: Mon, 17 Aug 2026 09:38:09 +0300 Subject: [PATCH 2/2] fix(review): type-strict JWT string claims + real exp window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings on PR #61, all fixed: - iss/sub/prn claims must be actual strings: JSON null decodes to Starlark None and None == "" is False, so a signed {"sub":null} assertion minted a session with a null username past the emptiness guards. _claim_str (mirrors _claim_int) now gates iss, sub, prn and the username extraction. Same latent hole fixed in google-iam's iss check, with a pinned null-iss negative. - exp must be within ~5 minutes of now (plus the documented clock-skew allowance) — the real endpoint rejects long-lived assertions, so a client minting them should fail against stunt too. - tests now pin: prn legacy fallback, sandbox aud, alg!=RS256, null sub/iss, float exp, exp==now boundary, 1h exp rejection. --- adapters/google-iam-style/scripts/lib.star | 12 ++- adapters/salesforce-style/README.md | 4 +- adapters/salesforce-style/scripts/oauth.star | 28 ++++-- internal/engine/google_iam_style_test.go | 27 +++++ internal/engine/salesforce_style_test.go | 100 +++++++++++++++++-- 5 files changed, 156 insertions(+), 15 deletions(-) diff --git a/adapters/google-iam-style/scripts/lib.star b/adapters/google-iam-style/scripts/lib.star index 293a44f2..55922e95 100644 --- a/adapters/google-iam-style/scripts/lib.star +++ b/adapters/google-iam-style/scripts/lib.star @@ -159,6 +159,16 @@ def _claim_int(v): return v return None +# _claim_str coerces a claim value to string. JSON null decodes to None, +# and None == "" is False in Starlark — so an `iss: null` would sail +# past a plain `get(...) == ""` guard. Type-strict instead. +def _claim_str(v): + if v == None: + return "" + if type(v) == "string": + return v + return "" + # _verify_assertion fully verifies a service-account JWT-bearer assertion # the way Google's token endpoint does (modulo the fixed mock key): # - 3 dot-separated, base64url-valid segments @@ -179,7 +189,7 @@ def _verify_assertion(assertion): claims = _jwt_json(assertion, 1) if claims == None: return None - if claims.get("iss", "") == "": + if _claim_str(claims.get("iss", None)) == "": return None exp = _claim_int(claims.get("exp", None)) if exp == None: diff --git a/adapters/salesforce-style/README.md b/adapters/salesforce-style/README.md index 0f7dd8da..59f07fc5 100644 --- a/adapters/salesforce-style/README.md +++ b/adapters/salesforce-style/README.md @@ -106,7 +106,9 @@ in `scripts/oauth.star` — sign with its private half, the same throwaway repo material the other JWT adapters use), `iss` non-empty (the consumer key), `sub` (or legacy `prn`) non-empty, `aud` one of `https://login.salesforce.com` / `https://test.salesforce.com`, and `exp` -in the future. A valid assertion mints a normal session token for the +in the future and no more than ~5 minutes out (the real endpoint's +window, plus its documented clock-skew allowance — long-lived assertions +are an `invalid_grant`). A valid assertion mints a normal session token for the `sub` user; the response carries **no** `refresh_token` — a JWT-bearer client mints a fresh assertion instead of refreshing. Failures return `400 {"error": "invalid_grant", "error_description": "invalid assertion"}` diff --git a/adapters/salesforce-style/scripts/oauth.star b/adapters/salesforce-style/scripts/oauth.star index 20a1f30d..ed997ce9 100644 --- a/adapters/salesforce-style/scripts/oauth.star +++ b/adapters/salesforce-style/scripts/oauth.star @@ -33,10 +33,10 @@ def on_token(req): claims = _verify_assertion(assertion) if claims == None: return _oauth_error("invalid_grant", "invalid assertion") - username = claims.get("sub", "") + username = _claim_str(claims.get("sub", None)) if username == "": - username = claims.get("prn", "") # legacy claim name - return _issue_token(username, claims.get("iss", ""), None, False) + username = _claim_str(claims.get("prn", None)) # legacy claim + return _issue_token(username, _claim_str(claims.get("iss", None)), None, False) if grant_type == "password": username = body.get("username", "") @@ -197,6 +197,16 @@ def _claim_int(v): return v return None +# _claim_str coerces a claim value to string. JSON null decodes to None, +# and None == "" is False in Starlark — so a `sub: null` would sail past +# a plain `get(...) == ""` guard. Type-strict instead. +def _claim_str(v): + if v == None: + return "" + if type(v) == "string": + return v + return "" + # _verify_assertion fully verifies a jwt-bearer assertion the way the real # token endpoint does (modulo the fixed mock certificate): # - 3 dot-separated, base64url-valid segments @@ -205,7 +215,8 @@ def _claim_int(v): # connected-app certificate # - iss non-empty (the consumer key), sub or legacy prn non-empty (the # user the token is for), aud a Salesforce login host, exp in the -# future +# future and no more than ~5 minutes out (the real window, plus the +# documented clock-skew allowance) # Returns the claims dict, or None when the assertion fails any check. def _verify_assertion(assertion): header = _jwt_json(assertion, 0) @@ -219,15 +230,20 @@ def _verify_assertion(assertion): claims = _jwt_json(assertion, 1) if claims == None: return None - if claims.get("iss", "") == "": + if _claim_str(claims.get("iss", None)) == "": return None - if claims.get("sub", "") == "" and claims.get("prn", "") == "": + if _claim_str(claims.get("sub", None)) == "" and _claim_str(claims.get("prn", None)) == "": return None exp = _claim_int(claims.get("exp", None)) if exp == None: return None if clock.now_unix() >= exp: return None + # Real Salesforce rejects assertions whose exp is more than ~5 + # minutes out (plus the documented clock-skew allowance) — clients + # can't mint long-lived JWTs. + if exp > clock.now_unix() + 480: + return None if claims.get("aud", "") not in _JWT_AUDS: return None return claims diff --git a/internal/engine/google_iam_style_test.go b/internal/engine/google_iam_style_test.go index 0214b3f1..6eedc6ce 100644 --- a/internal/engine/google_iam_style_test.go +++ b/internal/engine/google_iam_style_test.go @@ -222,6 +222,33 @@ func TestGoogleIAMStyleAdapter(t *testing.T) { t.Fatalf("expired assertion -> body %q, want invalid_grant", body) } + // ===== Properly signed but iss:null assertion → 400 invalid_grant ===== + // JSON null decodes to Starlark None and None == "" is False, so a + // plain emptiness guard passes it; the check must be type-strict. + + nullIssPayload := `{"iss":null,` + + `"scope":"openid https://www.googleapis.com/auth/cloud-platform",` + + `"aud":"https://oauth2.googleapis.com/token",` + + `"iat":` + strconv.FormatInt(now, 10) + `,"exp":` + strconv.FormatInt(now+3600, 10) + `}` + h := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256","typ":"JWT"}`)) + p := base64.RawURLEncoding.EncodeToString([]byte(nullIssPayload)) + digest := sha256.Sum256([]byte(h + "." + p)) + sig, err := rsa.SignPKCS1v15(rand.Reader, privKey, crypto.SHA256, digest[:]) + if err != nil { + t.Fatalf("sign null-iss: %v", err) + } + nullIss := h + "." + p + "." + base64.RawURLEncoding.EncodeToString(sig) + body, status = iamPostForm(t, base+"/oauth2/v4/token", url.Values{ + "grant_type": {"urn:ietf:params:oauth:grant-type:jwt-bearer"}, + "assertion": {nullIss}, + }) + if status != 400 { + t.Fatalf("iss:null assertion -> status %d, want 400; body %s", status, body) + } + if !strings.Contains(body, "invalid_grant") { + t.Fatalf("iss:null assertion -> body %q, want invalid_grant", body) + } + // ===== List service accounts (seeded) ===== body, status = iamGetAuth(t, base+"/v1/projects/mock-project/serviceAccounts", token) diff --git a/internal/engine/salesforce_style_test.go b/internal/engine/salesforce_style_test.go index 5abd5c87..cc1d5654 100644 --- a/internal/engine/salesforce_style_test.go +++ b/internal/engine/salesforce_style_test.go @@ -1479,14 +1479,9 @@ func sfPrivateKey(t *testing.T) *rsa.PrivateKey { return priv } -// sfSignAssertion builds a real RS256 jwt-bearer assertion with -// Salesforce's claim set: iss = the connected app's consumer key, -// sub = the user the session is for, aud = a Salesforce login host. -func sfSignAssertion(t *testing.T, key *rsa.PrivateKey, iss, sub, aud string, iat, exp int64) string { +// sfSignRaw signs an arbitrary JOSE header + claims payload as RS256. +func sfSignRaw(t *testing.T, key *rsa.PrivateKey, header, payload string) string { t.Helper() - header := `{"alg":"RS256","typ":"JWT"}` - payload := `{"iss":"` + iss + `","sub":"` + sub + `","aud":"` + aud + `",` + - `"iat":` + strconv.FormatInt(iat, 10) + `,"exp":` + strconv.FormatInt(exp, 10) + `}` h := base64.RawURLEncoding.EncodeToString([]byte(header)) p := base64.RawURLEncoding.EncodeToString([]byte(payload)) digest := sha256.Sum256([]byte(h + "." + p)) @@ -1497,6 +1492,16 @@ func sfSignAssertion(t *testing.T, key *rsa.PrivateKey, iss, sub, aud string, ia return h + "." + p + "." + base64.RawURLEncoding.EncodeToString(sig) } +// sfSignAssertion builds a real RS256 jwt-bearer assertion with +// Salesforce's claim set: iss = the connected app's consumer key, +// sub = the user the session is for, aud = a Salesforce login host. +func sfSignAssertion(t *testing.T, key *rsa.PrivateKey, iss, sub, aud string, iat, exp int64) string { + t.Helper() + payload := `{"iss":"` + iss + `","sub":"` + sub + `","aud":"` + aud + `",` + + `"iat":` + strconv.FormatInt(iat, 10) + `,"exp":` + strconv.FormatInt(exp, 10) + `}` + return sfSignRaw(t, key, `{"alg":"RS256","typ":"JWT"}`, payload) +} + // TestSalesforceStyleJWTBearerGrant exercises the RFC 7523 flow: a valid // RS256 assertion mints a working session (no refresh token — the client // mints a fresh assertion instead), and forged, expired, wrong-audience, @@ -1589,6 +1594,87 @@ func TestSalesforceStyleJWTBearerGrant(t *testing.T) { t.Fatalf("wrong aud -> body %q, want invalid_grant", body) } + // ===== Legacy prn claim (pre-sub JWTs) → 200 ===== + + prnOnly := sfSignRaw(t, priv, `{"alg":"RS256","typ":"JWT"}`, + `{"iss":"3MVG9mockConsumerKey","prn":"legacy-user@example.com",`+ + `"aud":"https://login.salesforce.com","iat":`+strconv.FormatInt(now, 10)+ + `,"exp":`+strconv.FormatInt(now+300, 10)+`}`) + body, status = postGrant(prnOnly) + if status != 200 { + t.Fatalf("prn-only assertion -> %d, want 200; body %s", status, body) + } + + // ===== Sandbox audience → 200 ===== + + sandbox := sfSignAssertion(t, priv, + "3MVG9mockConsumerKey", "jwt-user@example.com", + "https://test.salesforce.com", now, now+300) + body, status = postGrant(sandbox) + if status != 200 { + t.Fatalf("sandbox aud -> %d, want 200; body %s", status, body) + } + + // ===== alg != RS256 (properly signed, wrong header) → 400 ===== + + hsHeader := sfSignRaw(t, priv, `{"alg":"HS256","typ":"JWT"}`, + `{"iss":"3MVG9mockConsumerKey","sub":"jwt-user@example.com",`+ + `"aud":"https://login.salesforce.com","iat":`+strconv.FormatInt(now, 10)+ + `,"exp":`+strconv.FormatInt(now+300, 10)+`}`) + body, status = postGrant(hsHeader) + if status != 400 { + t.Fatalf("alg HS256 -> %d, want 400; body %s", status, body) + } + + // ===== Non-string claims (null sub / null iss) → 400 ===== + // JSON null decodes to Starlark None, and None == "" is False — a + // plain emptiness guard passes these. The verifier is type-strict. + + nullSub := sfSignRaw(t, priv, `{"alg":"RS256","typ":"JWT"}`, + `{"iss":"3MVG9mockConsumerKey","sub":null,`+ + `"aud":"https://login.salesforce.com","iat":`+strconv.FormatInt(now, 10)+ + `,"exp":`+strconv.FormatInt(now+300, 10)+`}`) + body, status = postGrant(nullSub) + if status != 400 { + t.Fatalf("null sub -> %d, want 400; body %s", status, body) + } + nullIss := sfSignRaw(t, priv, `{"alg":"RS256","typ":"JWT"}`, + `{"iss":null,"sub":"jwt-user@example.com",`+ + `"aud":"https://login.salesforce.com","iat":`+strconv.FormatInt(now, 10)+ + `,"exp":`+strconv.FormatInt(now+300, 10)+`}`) + body, status = postGrant(nullIss) + if status != 400 { + t.Fatalf("null iss -> %d, want 400; body %s", status, body) + } + + // ===== exp boundary and window ===== + // exp == now is already expired (the check is >=). A float exp is + // not an int claim. And an assertion valid for an hour is outside + // the real endpoint's ~5-minute window. + + expNow := sfSignAssertion(t, priv, + "3MVG9mockConsumerKey", "jwt-user@example.com", + "https://login.salesforce.com", now-10, now) + body, status = postGrant(expNow) + if status != 400 { + t.Fatalf("exp == now -> %d, want 400; body %s", status, body) + } + floatExp := sfSignRaw(t, priv, `{"alg":"RS256","typ":"JWT"}`, + `{"iss":"3MVG9mockConsumerKey","sub":"jwt-user@example.com",`+ + `"aud":"https://login.salesforce.com","iat":`+strconv.FormatInt(now, 10)+ + `,"exp":`+strconv.FormatFloat(float64(now+300), 'f', 1, 64)+`}`) + body, status = postGrant(floatExp) + if status != 400 { + t.Fatalf("float exp -> %d, want 400; body %s", status, body) + } + longLived := sfSignAssertion(t, priv, + "3MVG9mockConsumerKey", "jwt-user@example.com", + "https://login.salesforce.com", now, now+3600) + body, status = postGrant(longLived) + if status != 400 { + t.Fatalf("1h exp -> %d, want 400 (real window is ~5 min); body %s", status, body) + } + // ===== Garbage assertion (not a JWT) → 400 invalid_grant ===== body, status = postGrant("not-a-jwt-at-all")