# Signed Buyer Handoff

Verify that a buyer arriving at your signup page was really just sent there by Fours.

---

## The problem this solves

When a buyer purchases your product on Google Cloud Marketplace, Fours creates the
entitlement and redirects the buyer to your signup URL with the entitlement ID in the
query string. Your page reads that ID and binds the new account to the purchase.

By default that ID is the only thing your page has to go on — and it is not a secret. It
appears in browser history, support tickets, forwarded confirmation emails and webhook
logs. Anyone who obtains one can open your signup page with it and bind *their* account
to *someone else's* purchase.

Turning on **Signed Buyer Handoff** changes the redirect so it carries a short-lived
token that only Fours can produce **instead of** the plain `sugerEntitlementId`,
`gcpAccountId` and `offerType` parameters — those are removed from the URL, and their
values move inside the token. Your page verifies it before trusting anything, and "knows
the entitlement ID" stops being the same thing as "is the buyer".

```d2
direction: right
buyer: "Buyer"
gcp: "Google Cloud\nMarketplace"
fours: "Fours"
page: "Your signup page"
jwks: "Fours handoff JWKS\n/public/handoff/jwks.json"
account: "Buyer's account\nin your product"
buyer -> gcp: "purchases"
gcp -> fours: "entitlement created"
fours -> page: "redirect:\ntoken + partner=gcp"
page -> jwks: "fetch public key"
page -> account: "verify the token,\nthen bind the entitlement"
```

:::info
This is opt-in per integration and off by default. Ship your verification code first,
then flip the toggle — if you enable it before your page can read a token, your signup
page will reject every buyer.
:::

## Turning it on

Go to **Integrations → GCP → Edit** and enable **Signed Buyer Handoff**.

## What changes in the URL

**Before (default):**

```
https://app.yourcompany.com/gcp-signup
  ?sugerEntitlementId=ent_EXAMPLE1
  &partner=gcp
  &gcpAccountId=016AAA-111111-222222
  &offerType=PRIVATE
```

**After:**

```
https://app.yourcompany.com/gcp-signup
  ?token=eyJhbGciOiJSUzI1NiIsImtpZCI6…
  &partner=gcp
```

Every field that used to be a query parameter is now a signed claim inside the token.
Read them from the verified payload — never by decoding the token without verifying it.

## What's inside the token

| Claim | Example | Meaning |
|---|---|---|
| `iss` | `https://apiv2.suger.cloud` | Issuer. Verify this. |
| `aud` | `app.yourcompany.com` | Audience. Must equal your own domain. Verify this. |
| `iat` | `1727136000` | Issued at, as a Unix timestamp. |
| `exp` | `1727136300` | Expires 5 minutes after issue. Verify this. |
| `jti` | `9f1c…` | Unique token ID (UUID). Useful for your own logging. |
| `scenario` | `seller_handoff` | Token type. Fours signs other tokens with the same key, so check that this is `seller_handoff`. |
| `ent` | `ent_EXAMPLE1` | Fours entitlement ID. |
| `buyer` | `buyer_EXAMPLE1` | Fours buyer ID. |
| `product` | `prod_EXAMPLE1` | Fours product ID. |
| `org` | `org_EXAMPLE1` | Fours organization ID. |
| `gcp_account` | `016AAA-111111-222222` | GCP billing account. |
| `partner` | `gcp` | Marketplace. |
| `offer_type` | `PRIVATE` | Offer type. |

### About `aud`

The audience is the **host Fours actually redirects the buyer to**. Normally that is the
host of your product's Fulfillment URL. If you use a `BeforeSignupRedirect` hook to send
buyers somewhere else, the audience follows the hook — so it always matches the page
receiving the request.

## How to verify

Fetch Fours' public keys once at startup and cache them:

```
https://apiv2.suger.cloud/public/handoff/jwks.json
```

:::caution
This is a dedicated JWKS for handoff tokens. It is **not** the same as the OAuth JWKS at
`/.well-known/jwks.json`, which publishes different keys and will not verify these
tokens.
:::

Then on every handoff, check five things:

1. **Signature** — proves Fours issued the token.
2. **`iss`** — proves it came from the Fours environment you expect:
   `https://apiv2.suger.cloud` in production.
3. **`exp`** — proves it is less than 5 minutes old.
4. **`aud`** — proves it was minted for **your** domain, not another seller's.
5. **`scenario`** — proves it is a handoff token at all. Fours signs other tokens with the
   same key, so require exactly `seller_handoff`.

If all five pass, every claim is trustworthy. Allow about 30 seconds of clock skew on
`exp`. Each example below checks all five.

### Node (`jose`)

```js
import { jwtVerify, createRemoteJWKSet } from "jose";

const JWKS = createRemoteJWKSet(
  new URL("https://apiv2.suger.cloud/public/handoff/jwks.json")
);

export async function verifyHandoffToken(token) {
  const { payload } = await jwtVerify(token, JWKS, {
    issuer: "https://apiv2.suger.cloud",
    audience: "app.yourcompany.com",
    clockTolerance: "30s",
  });
  if (payload.scenario !== "seller_handoff") {
    throw new Error("Not a seller handoff token");
  }
  return payload; // payload.ent, payload.buyer, payload.product, …
}
```

### Go (`jwx`)

```go
import (
    "context"
    "time"

    "github.com/lestrrat-go/jwx/v2/jwk"
    "github.com/lestrrat-go/jwx/v2/jwt"
)

const jwksURL = "https://apiv2.suger.cloud/public/handoff/jwks.json"

var jwksCache *jwk.Cache

// Call once at startup.
func initHandoffKeys(ctx context.Context) error {
    jwksCache = jwk.NewCache(ctx)
    return jwksCache.Register(jwksURL)
}

func verifyHandoff(ctx context.Context, token string) (jwt.Token, error) {
    keyset, err := jwksCache.Get(ctx, jwksURL)
    if err != nil {
        return nil, err
    }
    return jwt.Parse([]byte(token),
        jwt.WithKeySet(keyset),
        jwt.WithIssuer("https://apiv2.suger.cloud"),
        jwt.WithAudience("app.yourcompany.com"),
        jwt.WithClaimValue("scenario", "seller_handoff"),
        jwt.WithAcceptableSkew(30*time.Second),
    )
}
```

### Python (`PyJWT`)

```python
import jwt
from jwt import PyJWKClient

jwks_client = PyJWKClient("https://apiv2.suger.cloud/public/handoff/jwks.json")

def verify_handoff(token: str) -> dict:
    signing_key = jwks_client.get_signing_key_from_jwt(token).key
    claims = jwt.decode(
        token,
        signing_key,
        algorithms=["RS256"],
        issuer="https://apiv2.suger.cloud",
        audience="app.yourcompany.com",
        leeway=30,  # seconds of clock skew
    )
    if claims.get("scenario") != "seller_handoff":
        raise jwt.InvalidTokenError("Not a seller handoff token")
    return claims
```

## Server-side verification (optional)

If implementing JWT verification is awkward in your stack — Salesforce Apex, for
instance — you can POST the token to Fours instead and get the decoded claims back only
if the signature, issuer, expiry, audience and `scenario` all check out.

```
POST https://apiv2.suger.cloud/public/handoff/introspect
Content-Type: application/json

{
  "token": "eyJ…",
  "audience": "app.yourcompany.com"
}
```

`audience` is **required**, and must be your own domain. Fours checks the token against
it for you, so this endpoint gives you the same guarantee as verifying offline. Without
it we could only tell you a token is validly signed — not that it was meant for *you*,
which is the part that stops another seller's token being replayed against your signup
page.

Response (`200`), with `exp` as a Unix timestamp, the same value the token carries:

```json
{
  "ent": "ent_EXAMPLE1",
  "buyer": "buyer_EXAMPLE1",
  "product": "prod_EXAMPLE1",
  "org": "org_EXAMPLE1",
  "partner": "gcp",
  "offer_type": "PRIVATE",
  "gcp_account": "016AAA-111111-222222",
  "jti": "9f1c…",
  "exp": 1727136300
}
```

Other responses: `400` (missing `token` or `audience`), `401` (invalid signature, wrong
issuer, expired, wrong audience, or not a handoff token).

Offline verification is still the recommended path — it is a few lines in any language
and costs you no network round-trip on your signup page.

## Worked example

A buyer purchases your product. Fours verifies Google's signed token, creates the
entitlement, and redirects:

```
https://app.yourcompany.com/gcp-signup?token=eyJhbGciOiJSUzI1NiIsImtpZCI6…&partner=gcp
```

Your handler:

```js
app.get("/gcp-signup", async (req, res) => {
  let payload;
  try {
    payload = await verifyHandoffToken(req.query.token);
  } catch (err) {
    // Invalid signature, wrong issuer, expired, wrong audience, not a handoff
    // token, or tampered.
    return res.status(400).send("This signup link is invalid or has expired.");
  }

  // payload.ent is now trusted.
  const entitlement = await sugerClient.getEntitlement(payload.org, payload.ent);
  renderSignup({ entitlement });
});
```

## Good to know

**Tokens last 5 minutes.** That is long enough for a redirect and short enough that a
URL recovered from browser history the next morning is useless. Verify on first load and
establish your own session; do not ask the buyer to re-use the link.

**Tokens are not single-use.** Within those 5 minutes the same token verifies more than
once. If your flow needs strict single-use, dedupe on the `jti` you receive.

**The token proves identity, nothing more.** It is not a bearer credential for the Fours
API and cannot be used to call us on the buyer's behalf.

**Existing integrations are unaffected.** Until you enable the toggle, your signup URL
keeps receiving the same plain query parameters it always has.

## Troubleshooting

| Symptom | Cause | Fix |
|---|---|---|
| `JWTExpired` | More than 5 minutes between redirect and verification, or server clock drift | Allow 30s clock tolerance; check NTP sync. |
| `JWTClaimValidationFailed: aud` | The audience in the token is not the string you passed | Match the host Fours redirects to. If you use a `BeforeSignupRedirect` hook, that is the hook's host, not your Fulfillment URL's. |
| `JWSSignatureVerificationFailed` | Wrong JWKS, tampered token, or a stale cache after key rotation | Confirm you are using `/public/handoff/jwks.json` and not `/.well-known/jwks.json`. Then refresh the cache. |
| No `token` parameter arrives | The toggle is off, or this redirect fell back to plain parameters | Check the toggle. Fours falls back to unsigned parameters rather than failing a purchase, so handle both while you migrate. |
| `400` on introspect | Body is missing `token` or `audience` | Both fields are required. |
| `401` on introspect | Signature invalid, wrong issuer, expired, wrong audience, or not a handoff token | Same debugging path as the offline errors above. |

## Testing against dev

Everything above works in the dev environment with the hostname swapped:

```
https://apiv2.dev.suger.cloud/public/handoff/jwks.json
https://apiv2.dev.suger.cloud/public/handoff/introspect
```

Tokens issued there carry `iss: https://apiv2.dev.suger.cloud`, so point your verifier at
the matching issuer when testing.
