/*
RFC-1187: minimal EdDSA JWS (JWT) sign/verify for the worker-hosted agent
authorization server. Compact serialization `b64u(header).b64u(payload).b64u(sig)`,
alg fixed to "EdDSA" — no "none", no algorithm agility. The site passport
Ed25519 key signs tokens (one identity); resource-side verification needs only
the public key. Hand-rolled over @noble/ed25519 primitives — no JWT dependency.
- Do not implement JWK parsing or key loading — callers supply raw keys.
- Do not implement claims beyond RFC-1187's contract (iss/sub/iat/exp/scope/jti).
- Do not support other algorithms — alg is closed, by design.
- RFC-1187: initial EdDSA JWT primitives for the agent authorization server.
- RFC-1187: phases 1-3 — contracts, Redis registry, AS core + Bearer pipeline
- RFC-1187: phases 5-7 — OAuth route/discovery generators, env schema, operator commands
- RFC-1187: fo-review — kid lands in the JWT header (AC-2), sourced from the active passport key version
*/
import { signBytes, verifyBytes } from "../signing/sign.ts";
/** RFC-1187: access-token claim contract issued by the agent authorization server. */
export interface AgentTokenClaims {
/** Site origin, e.g. "https://warpgogol.com" — also the resource's expected issuer. */
iss: string;
/** Registered agent id (sub = agent identity). */
sub: string;
/** Issued-at, unix seconds. */
iat: number;
/** Expiry, unix seconds — MUST be at most iat + 3600. */
exp: number;
/** Space-delimited granted scopes (e.g. "agent.actions.elevated agent.registration"). */
scope?: string;
/** Unique token id for audit/traceability. */
jti?: string;
}
export const AGENT_TOKEN_MAX_TTL_SECONDS = 3600;
/** JWS header shape — `kid` identifies the passport key version that signed. */
interface AgentJwtHeader {
alg: "EdDSA";
typ: "JWT";
kid: string;
}
/** base64url encode (no padding) — UTF-8 for strings. */
export function b64uEncode(input: Uint8Array | string): string {
const bytes = typeof input === "string" ? new TextEncoder().encode(input) : input;
return Buffer.from(bytes)
.toString("base64")
.replaceAll("+", "-")
.replaceAll("/", "_")
.replaceAll("=", "");
}
/** base64url decode — accepts the unpadded form JWS emits. */
export function b64uDecode(input: string): Uint8Array | null {
if (!/^[A-Za-z0-9_-]*$/.test(input)) return null;
const padded = input + "=".repeat((4 - (input.length % 4)) % 4);
return new Uint8Array(Buffer.from(padded.replaceAll("-", "+").replaceAll("_", "/"), "base64"));
}
/**
* Sign claims into a compact EdDSA JWT. `exp - iat` MUST NOT exceed 3600s.
* `keyVersion` lands in the header `kid` (AC-2) — the same value the JWKS
* directory advertises, so verifiers can select the right key across
* `passport.key.rotate` transitions.
*/
export async function signAgentToken(
claims: AgentTokenClaims,
privateKey: Uint8Array,
keyVersion: string,
): Promise {
if (claims.exp - claims.iat > AGENT_TOKEN_MAX_TTL_SECONDS) {
throw new Error(`AGENT-TOKEN-TTL: exp - iat exceeds ${AGENT_TOKEN_MAX_TTL_SECONDS}s`);
}
const header: AgentJwtHeader = { alg: "EdDSA", typ: "JWT", kid: keyVersion };
const signingInput = `${b64uEncode(JSON.stringify(header))}.${b64uEncode(
JSON.stringify(claims),
)}`;
const signature = await signBytes(privateKey, new TextEncoder().encode(signingInput));
return `${signingInput}.${b64uEncode(signature)}`;
}
export type AgentTokenVerifyFailure =
"malformed" | "header" | "signature" | "claims" | "expired" | "issuer";
export type AgentTokenVerifyResult =
{ ok: true; claims: AgentTokenClaims } | { ok: false; reason: AgentTokenVerifyFailure };
/**
* Verify a compact EdDSA JWT: signature over the signing input, fixed
* `alg: "EdDSA"`/`typ: "JWT"` header with a non-empty `kid` (no "none"
* downgrade, no header smuggling), `exp` not elapsed, `iss` equals the
* expected issuer. The `kid` check is structural — key selection across
* rotation is the JWKS directory's job, and with a single active key the
* signature is the binding check. Returns a discriminated failure — callers
* map every failure to the same 401; the reason is for logs only.
*/
export async function verifyAgentToken(
token: string,
publicKey: Uint8Array,
expectedIssuer: string,
nowSeconds: number = Math.floor(Date.now() / 1000),
): Promise {
const parts = token.split(".");
if (parts.length !== 3) return { ok: false, reason: "malformed" };
const [headerB64, payloadB64, signatureB64] = parts;
const headerBytes = b64uDecode(headerB64);
if (!headerBytes) return { ok: false, reason: "header" };
try {
const header = JSON.parse(new TextDecoder().decode(headerBytes)) as {
alg?: unknown;
typ?: unknown;
kid?: unknown;
};
if (header.alg !== "EdDSA") return { ok: false, reason: "header" };
if (header.typ !== "JWT") return { ok: false, reason: "header" };
if (typeof header.kid !== "string" || header.kid.length === 0) {
return { ok: false, reason: "header" };
}
} catch {
return { ok: false, reason: "header" };
}
const signature = b64uDecode(signatureB64);
if (!signature || signature.length !== 64) return { ok: false, reason: "signature" };
const signingInput = `${headerB64}.${payloadB64}`;
const valid = await verifyBytes(publicKey, new TextEncoder().encode(signingInput), signature);
if (!valid) return { ok: false, reason: "signature" };
const payloadBytes = b64uDecode(payloadB64);
if (!payloadBytes) return { ok: false, reason: "claims" };
let claims: AgentTokenClaims;
try {
const parsed = JSON.parse(new TextDecoder().decode(payloadBytes)) as Record;
if (
typeof parsed.iss !== "string" ||
typeof parsed.sub !== "string" ||
typeof parsed.iat !== "number" ||
typeof parsed.exp !== "number"
) {
return { ok: false, reason: "claims" };
}
claims = parsed as unknown as AgentTokenClaims;
} catch {
return { ok: false, reason: "claims" };
}
if (claims.exp <= nowSeconds) return { ok: false, reason: "expired" };
if (claims.iss !== expectedIssuer) return { ok: false, reason: "issuer" };
return { ok: true, claims };
}