Skip to content
Open
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 8 additions & 1 deletion packages/sdk/src/core/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,14 @@ export class AuthplaneClient {
dpopProvider?: DPoPProvider | undefined;
}): Promise<AuthplaneClient> {
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;
Expand Down
13 changes: 9 additions & 4 deletions packages/sdk/src/core/fetching/documentCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,10 @@ export class MetadataCache extends DocumentCache<Record<string, unknown>> {
...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;
}

Expand Down Expand Up @@ -271,9 +274,11 @@ export class MetadataCache extends DocumentCache<Record<string, unknown>> {
private validateMetadata(
metadata: Record<string, unknown>,
): Record<string, unknown> {
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.",
Expand Down
159 changes: 159 additions & 0 deletions packages/sdk/tests/core/issuerIdentity.test.ts
Original file line number Diff line number Diff line change
@@ -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<IssuerIdentityServer> {
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<void>((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<string> {
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<void> {
await new Promise<void>((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);
}
});
});
});
Loading