diff --git a/CHANGELOG.md b/CHANGELOG.md index 143c950..81b0636 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht ## [Unreleased] +### Fixed + +- `@authplane/sdk` — the configured issuer is stored and compared byte-for-byte instead of having its trailing slash stripped. An authorization server whose issuer identifier legitimately ends in `/` mints tokens whose `iss` carries that slash (RFC 9068); the SDK compared them against the stripped form and **rejected every otherwise-valid token**. The RFC 8414 §3.3 metadata comparison is likewise exact on both sides now — §4 specifies it code-point-for-code-point — so a document whose `issuer` differs from the configured one only by a trailing slash is a mismatch rather than something the SDK silently reconciles. Deriving the `.well-known` URL still drops the terminating slash (RFC 8414 §3.1); that is derivation, not identity, and is unchanged. **Migration:** if your configured issuer differs from your authorization server's actual identifier by a trailing slash, correct the config — the SDK no longer reconciles them. + ## [0.3.0] - 2026-07-24 ### Added diff --git a/packages/sdk/src/core/client.ts b/packages/sdk/src/core/client.ts index d782987..8f2c7c6 100644 --- a/packages/sdk/src/core/client.ts +++ b/packages/sdk/src/core/client.ts @@ -83,7 +83,14 @@ export class AuthplaneClient { dpopProvider?: DPoPProvider | undefined; }): Promise { const client = new AuthplaneClient(); - client.issuer = options.issuer.replace(/\/+$/g, ""); + // RFC 8414 §2/§3.3: the issuer is an identity, not a location. Store it + // byte-for-byte — it is passed to the token verifier as the expected `iss` + // and compared against the AS metadata `issuer`. Silently stripping a + // trailing slash here desynchronizes the configured issuer from the token's + // `iss`, causing every otherwise-valid token to be rejected. Derivation of + // the `.well-known` URL (which does drop a terminating slash) happens in + // `buildMetadataUrl`, not here. + client.issuer = options.issuer; client.authProvider = toAuthProvider(options.auth); const resolvedDevMode = options.devMode ?? false; diff --git a/packages/sdk/src/core/fetching/documentCache.ts b/packages/sdk/src/core/fetching/documentCache.ts index 327d574..dc812a7 100644 --- a/packages/sdk/src/core/fetching/documentCache.ts +++ b/packages/sdk/src/core/fetching/documentCache.ts @@ -243,7 +243,10 @@ export class MetadataCache extends DocumentCache> { ...config, }); - this.expectedIssuer = (options.expectedIssuer ?? "").replace(/\/+$/g, ""); + // RFC 8414 §3.3: the issuer is compared for identity. Keep the expected + // value verbatim so the comparison in `validateMetadata` is byte-for-byte; + // a trailing-slash difference must surface as a mismatch, not be reconciled. + this.expectedIssuer = options.expectedIssuer ?? ""; this.allowHttp = options.allowHttp ?? false; } @@ -271,9 +274,11 @@ export class MetadataCache extends DocumentCache> { private validateMetadata( metadata: Record, ): Record { - const rawIssuer = - typeof metadata.issuer === "string" ? metadata.issuer : ""; - const issuer = rawIssuer.replace(/\/+$/g, ""); + // RFC 8414 §3.3: compare the raw issuer identifier for exact equality. + // Do NOT strip a trailing slash — a document whose issuer differs from the + // expected identifier only by a trailing slash is a different identity and + // must be rejected. + const issuer = typeof metadata.issuer === "string" ? metadata.issuer : ""; if (!issuer) { throw new MetadataFetchError( "AS metadata missing required 'issuer' field.", diff --git a/packages/sdk/tests/core/issuerIdentity.test.ts b/packages/sdk/tests/core/issuerIdentity.test.ts new file mode 100644 index 0000000..602db9e --- /dev/null +++ b/packages/sdk/tests/core/issuerIdentity.test.ts @@ -0,0 +1,159 @@ +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; + +import { + exportJWK, + generateKeyPair, + SignJWT, + type JWK, + type KeyLike, +} from "jose"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { AuthplaneClient, MetadataFetchError } from "../../src/core/index.js"; + +interface IssuerIdentityServer { + server: Server; + /** Base origin without trailing slash, e.g. `http://127.0.0.1:PORT`. */ + base: string; + /** + * The value the AS advertises in the metadata `issuer` field. Controlled + * independently of `base` so tests can force a trailing-slash difference. + */ + metadataIssuer: string; + resource: string; + privateKey: KeyLike; +} + +/** + * Start a minimal RFC 8414 authorization server whose advertised metadata + * `issuer` is `metadataIssuer` (which may differ from the origin by a trailing + * slash). The `.well-known` document is served at the RFC-derived location + * regardless of the trailing slash on the issuer identity. + */ +async function startServer(options: { + metadataIssuer?: (base: string) => string; +}): Promise { + const { privateKey, publicKey } = await generateKeyPair("RS256"); + const jwk = (await exportJWK(publicKey)) as JWK; + jwk.kid = "kid_1"; + jwk.alg = "RS256"; + jwk.use = "sig"; + + const server = createServer(); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const addr = server.address() as AddressInfo; + const base = `http://127.0.0.1:${addr.port}`; + const metadataIssuer = (options.metadataIssuer ?? ((b) => b))(base); + + server.on("request", (req, res) => { + if (req.url === "/.well-known/oauth-authorization-server") { + res.setHeader("content-type", "application/json"); + res.end( + JSON.stringify({ + issuer: metadataIssuer, + jwks_uri: `${base}/.well-known/jwks.json`, + }), + ); + return; + } + if (req.url === "/.well-known/jwks.json") { + res.setHeader("content-type", "application/json"); + res.end(JSON.stringify({ keys: [jwk] })); + return; + } + res.statusCode = 404; + res.end(); + }); + + return { server, base, metadataIssuer, resource: `${base}/mcp`, privateKey }; +} + +async function mintToken(options: { + privateKey: KeyLike; + issuer: string; + audience: string; +}): Promise { + const now = Math.floor(Date.now() / 1000); + return await new SignJWT({ + client_id: "client_1", + scope: "tools/query", + jti: "jti_1", + }) + .setProtectedHeader({ alg: "RS256", typ: "at+jwt", kid: "kid_1" }) + .setSubject("user_1") + .setIssuer(options.issuer) + .setAudience(options.audience) + .setIssuedAt(now) + .setExpirationTime(now + 300) + .sign(options.privateKey); +} + +async function closeServer(server: Server): Promise { + await new Promise((resolve, reject) => + server.close((err) => (err ? reject(err) : resolve())), + ); +} + +describe("issuer identity (RFC 8414 §3.3) is preserved byte-for-byte", () => { + // Scenario (a): the AS identity legitimately carries a trailing slash. The + // configured issuer, the metadata `issuer`, and the token `iss` all carry it. + // Regression for the outage: the SDK used to strip the configured issuer's + // trailing slash and hand the stripped value to the verifier as the expected + // `iss`, so every token whose `iss` carried the slash was rejected. + describe("token whose iss carries the configured trailing slash", () => { + let s: IssuerIdentityServer; + let trailingSlashIssuer: string; + + beforeAll(async () => { + s = await startServer({ metadataIssuer: (base) => `${base}/` }); + trailingSlashIssuer = `${s.base}/`; + }); + + afterAll(async () => { + await closeServer(s.server); + }); + + it("verifies successfully", async () => { + const client = await AuthplaneClient.create({ + issuer: trailingSlashIssuer, + devMode: true, + }); + try { + const resource = client.resource({ + resource: s.resource, + scopes: ["tools/query"], + }); + const token = await mintToken({ + privateKey: s.privateKey, + issuer: trailingSlashIssuer, + audience: s.resource, + }); + + const claims = await resource.verify(token); + expect(claims.sub).toBe("user_1"); + expect(claims.issuer).toBe(trailingSlashIssuer); + } finally { + await client.close(); + } + }); + }); + + // Scenario (b): the configured issuer has no trailing slash but the metadata + // document advertises one (or vice-versa). RFC 8414 §3.3 requires an exact + // identity match — the SDK must reject the document rather than reconcile the + // difference. + describe("metadata document whose issuer differs by a trailing slash", () => { + it("is rejected with MetadataFetchError", async () => { + const s = await startServer({ metadataIssuer: (base) => `${base}/` }); + try { + // Configured issuer has NO trailing slash; metadata advertises one. + await expect( + AuthplaneClient.create({ issuer: s.base, devMode: true }), + ).rejects.toBeInstanceOf(MetadataFetchError); + } finally { + await closeServer(s.server); + } + }); + }); +});