Websites quickstart
A website doesn’t need a Bage account or a client ID to accept agents. It needs two things: a file that tells agents it accepts Bage, and a check on each request.
1. Say you accept Bage
Section titled “1. Say you accept Bage”Serve this at /.well-known/agent-login, with your own origin as audience:
{ "audience": "https://shop.example", "providers": [{ "name": "Bage", "issuer": "https://api.trybage.com" }], "token_header": "agent-token", "signature": "web-bot-auth"}2. Check each request
Section titled “2. Check each request”// npm install @trybage/sdkimport http from "node:http";import { createVerifier } from "@trybage/sdk/site";
const origin = process.env.ORIGIN ?? "https://shop.example"; // your site, as agents see itconst bage = createVerifier({ issuers: [process.env.BAGE_ISSUER ?? "https://api.trybage.com"], audience: origin });
const send = (response: http.ServerResponse, status: number, body: object) => { response.writeHead(status, { "content-type": "application/json" }); response.end(JSON.stringify(body));};
async function handle(request: http.IncomingMessage, response: http.ServerResponse) { // Tells agents that this site accepts Sign in with Bage. if (request.url === "/.well-known/agent-login") return send(response, 200, bage.discovery());
try { const who = await bage.verify({ method: request.method ?? "GET", authority: request.headers.host ?? "", path: request.url ?? "/", headers: request.headers, }); if (!who) return send(response, 403, { error: "This page needs Sign in with Bage" }); // no agent token: an ordinary bot or visitor if (!who.scopes.includes("browse")) return send(response, 403, { error: "browse wasn't approved" }); return send(response, 200, { hello: `${who.agent.name} (${who.agent.operator}), acting for ${who.userName ?? who.userId}` }); } catch (error) { // A bad, expired or revoked token, or a request not signed with the agent's key. return send(response, 401, { error: error instanceof Error ? error.message : "invalid agent" }); }}
http.createServer((request, response) => void handle(request, response)).listen(Number(process.env.PORT ?? 3000));# pip install "pyjwt[crypto]" requestsimport base64, hashlib, json, os, re, timefrom http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import jwt, requestsfrom cryptography.hazmat.primitives import hashesfrom cryptography.hazmat.primitives.asymmetric import ecfrom cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature
ISSUER = os.environ.get("BAGE_ISSUER", "https://api.trybage.com")ORIGIN = os.environ.get("ORIGIN", "https://shop.example") # your site, as agents see itLEGACY = '("@method" "@authority" "@path" "agent-token")' # the first Bage format, where @path had the queryjwks = jwt.PyJWKClient(ISSUER + "/.well-known/jwks.json", cache_keys=True, lifespan=300)revoked = {"jtis": set(), "fetched_at": 0}
def is_revoked(jti): if time.time() - revoked["fetched_at"] > 15: revoked["jtis"] = set(requests.get(ISSUER + "/v1/revoked", timeout=5).json()["revoked"]) revoked["fetched_at"] = time.time() return jti in revoked["jtis"]
def thumbprint(jwk): names = ("crv", "kty", "x") if jwk["kty"] == "OKP" else ("crv", "kty", "x", "y") digest = hashlib.sha256(json.dumps({n: jwk[n] for n in names}, separators=(",", ":")).encode()).digest() return base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
def verify(method, host, path, headers): """Returns the token's claims, None without an agent token, and raises for anything wrong.""" token = headers.get("agent-token") if not token: return None if jwt.get_unverified_header(token).get("typ") != "agent+jwt": raise ValueError("not an agent token") claims = jwt.decode(token, jwks.get_signing_key_from_jwt(token).key, algorithms=["EdDSA"], audience=ORIGIN, issuer=ISSUER) if is_revoked(claims["jti"]): raise ValueError("revoked by the person") if claims.get("test"): raise ValueError("agent is in test mode") # allow these on staging only
# The request must be signed with the key the token is bound to (cnf). agent_key = claims["cnf"]["jwk"] if claims["cnf"]["jkt"] != thumbprint(agent_key): raise ValueError("bad key binding") # Web Bot Auth (RFC 9421): find the signature made with that key, rebuild what it covers, and check it. params = headers.get("signature-input", "").removeprefix("sig1=") param = lambda name: (re.search(rf';{name}="?([^;"]*)"?', params) or [None, None])[1] if param("tag") != "web-bot-auth" or param("keyid") != thumbprint(agent_key): raise ValueError("request is not signed for Bage") if not float(param("expires") or 0) >= time.time() or float(param("expires")) - float(param("created") or 0) > 300: raise ValueError("signature expired") covered = re.findall(r'"([^"]+)"(;key="[^"]*")?', params[: params.index(")") + 1]) if ("@authority", "") not in covered or ("signature-agent" in headers and not any(name == "signature-agent" for name, _ in covered)): raise ValueError("signature must cover @authority and signature-agent") pathname, _, query = path.partition("?") values = {"@method": method, "@authority": host.lower(), "@path": path if params.startswith(LEGACY) else pathname, "@query": "?" + query} lines = [] for name, key in covered: if key: # a member of a dictionary header, e.g. "signature-agent";key="sig1" label = key[len(';key="') : -1] value = next(m.split("=", 1)[1] for m in headers.get(name, "").split(", ") if m.startswith(label + "=")) else: value = values[name] if name.startswith("@") else headers[name] lines.append(f'"{name}"{key}: {value}') base = "\n".join(lines + [f'"@signature-params": {params}']) signature = base64.b64decode(headers.get("signature", "").removeprefix("sig1=:").removesuffix(":")) public_key = jwt.PyJWK(agent_key).key if agent_key["kty"] == "OKP": public_key.verify(signature, base.encode()) else: der = encode_dss_signature(int.from_bytes(signature[:32], "big"), int.from_bytes(signature[32:], "big")) public_key.verify(der, base.encode(), ec.ECDSA(hashes.SHA256())) return claims
class Handler(BaseHTTPRequestHandler): def send(self, status, body): self.send_response(status) self.send_header("content-type", "application/json") self.end_headers() self.wfile.write(json.dumps(body).encode())
def do_GET(self): # Tells agents that this site accepts Sign in with Bage. if self.path == "/.well-known/agent-login": return self.send(200, {"audience": ORIGIN, "providers": [{"name": "Bage", "issuer": ISSUER}], "token_header": "agent-token", "signature": "web-bot-auth"}) try: who = verify("GET", self.headers["host"], self.path, {k.lower(): v for k, v in self.headers.items()}) except Exception as error: return self.send(401, {"error": str(error) or "invalid agent"}) if who is None: return self.send(403, {"error": "This page needs Sign in with Bage"}) if "browse" not in who["scope"].split(): return self.send(403, {"error": "browse wasn't approved"}) agent = who["act"] self.send(200, {"hello": f"{agent['name']} ({agent['operator']}), acting for {who.get('name') or who['sub']}"})
def log_message(self, *args): pass
ThreadingHTTPServer(("", int(os.environ.get("PORT", "3000"))), Handler).serve_forever()verify gives you the agent (agent.name, agent.operator), the person (userId, the same each time, and userName) and what they allowed (scopes). The verifier fetches Bage’s public keys every 5 minutes and the revoked list every 15 seconds, so when a person cuts off an agent, it’s cut off on your site within seconds.
Without the SDK: check the token as a JWT (typ: agent+jwt, EdDSA) against the keys at https://api.trybage.com/.well-known/jwks.json, with your origin as aud. Reject any jti listed at /v1/revoked, then check the request signature with the key in cnf.jwk. Or call POST /v1/introspect with the token.
Matching an account
Section titled “Matching an account”Every token says who the person is: userId is the same each time. To find their existing account on your site, agents ask for the email scope and the token carries the person’s verified email, which the person sees on the approval screen. The SDK returns it as userEmail. Every site accepts email, since without it a site can’t match anyone.
Optionally, start free and add your site in the console to choose which scopes agents may ask for, let in only the agents you allow, cut one off, and see every agent sign-in. Settings apply once you prove you run the domain, with a file at /.well-known/bage-site-verification.txt or a TXT record at _bage-verification.yourdomain.com.
Scopes
Section titled “Scopes”A new site starts with nothing turned on, so agents can only ask for email. In the console’s Scopes card you add what agents may do on your site:
- Your own scopes. Name it the way your agents will ask for it (
returns,gift_cards: lowercase letters, digits and_, 2 to 40 characters), give it a title and a line on what it lets an agent do. People see that title and description, marked as your site’s own, when they approve. - Standard scopes, Bage’s shared ones (
browse,cart,checkout,read_account,messages,bookings), with one click.
Agents can’t ask your site for anything else (invalid_scope), and your own scopes mean nothing on other sites. Removing a scope ends the sign-ins that carry it at their next refresh. Your scopes appear in the token’s scope (and the SDK’s scopes), and in the site list so agents can see what to ask for.
For pages rather than APIs, turn the agent into your normal logged-in session once, and hear from Bage when it’s revoked: see Sessions, revocation and the site list.