Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion adapters/google-iam-style/scripts/lib.star
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
44 changes: 35 additions & 9 deletions adapters/salesforce-style/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` →
Expand Down Expand Up @@ -88,11 +89,36 @@ 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=<RS256-signed JWT>
```

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 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"}`
(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 |
Expand Down
149 changes: 143 additions & 6 deletions adapters/salesforce-style/scripts/oauth.star
Original file line number Diff line number Diff line change
@@ -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 }
#
Expand All @@ -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).

Expand All @@ -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 = _claim_str(claims.get("sub", None))
if username == "":
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", "")
password = body.get("password", "")
Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -110,3 +126,124 @@ 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

# _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
# - 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 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)
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 _claim_str(claims.get("iss", None)) == "":
return None
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
27 changes: 27 additions & 0 deletions internal/engine/google_iam_style_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading