Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/clients-pkce-nonce.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"seamless-cli": minor
---

`seamless clients` shows whether an application requires PKCE and the nonce, and `create` and `update` accept `--pkce`/`--no-pkce` and `--nonce`/`--no-nonce`. Only a confidential client can turn PKCE off, and the nonce can be turned off only once PKCE is off. An application with both off relies only on `state` and client authentication against authorization code injection, so `list`, `get`, `create` and `update` warn about it. Pins `@seamless-auth/types` 0.34.0-next.4.
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@
"dependencies": {
"@clack/prompts": "^1.0.1",
"@napi-rs/keyring": "^1.3.0",
"@seamless-auth/types": "0.34.0-next.3",
"@seamless-auth/types": "0.34.0-next.4",
"adm-zip": "^0.6.1",
"kleur": "^4.1.5",
"zod": "^4.6.5"
Expand Down
96 changes: 96 additions & 0 deletions src/commands/clients.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,102 @@ describe("clients update", () => {
});
});

describe("clients PKCE and nonce", () => {
const exposed = { pkceRequired: false, nonceRequired: false };
const warning = "relies only on state and client authentication";

it("shows both settings and warns on get only when both are off", async () => {
vi.mocked(getApplication).mockResolvedValue(apiApplication());
await runClients(["get", "client_docs"]);
expect(logs()).toContain("PKCE: required");
expect(logs()).toContain("nonce: required");
expect(logs()).not.toContain(warning);

vi.mocked(getApplication).mockResolvedValue(apiApplication({ pkceRequired: false }));
await runClients(["get", "client_docs"]);
expect(logs()).toContain("PKCE: not required");
expect(logs()).not.toContain(warning);

vi.mocked(getApplication).mockResolvedValue(apiApplication(exposed));
await runClients(["get", "client_docs"]);
expect(logs()).toContain("nonce: not required");
expect(logs()).toContain(warning);
});

it("flags each exposed client in the list and counts them", async () => {
vi.mocked(listApplications).mockResolvedValue([
apiApplication(),
apiApplication({ id: "client_cert", ...exposed }),
]);
await runClients(["list"]);
const lines = logs().split("\n");
expect(lines.find((line) => line.includes("client_cert"))).toContain("no PKCE or nonce");
expect(lines.find((line) => line.includes("client_docs"))).not.toContain("no PKCE");
expect(logs()).toContain("1 application has PKCE and the nonce both off, so it relies only");

vi.mocked(listApplications).mockResolvedValue([
apiApplication({ id: "a", ...exposed }),
apiApplication({ id: "b", ...exposed }),
]);
await runClients(["list"]);
expect(logs()).toContain("2 applications have PKCE and the nonce both off, so they rely only");
});

it("sends the opt-outs on create and warns about the result", async () => {
vi.mocked(createApplication).mockResolvedValue({ application: apiApplication(exposed) });
await runClients([
"create",
"Cert",
"--redirect-uri",
"https://cert.example.com/callback",
"--jwks-uri",
"https://cert.example.com/jwks",
"--no-pkce",
"--no-nonce",
]);
expect(vi.mocked(createApplication).mock.calls[0][1]).toMatchObject(exposed);
expect(logs()).toContain(warning);
});

it("sends the settings on update and warns about the result", async () => {
vi.mocked(updateApplication).mockResolvedValue(apiApplication(exposed));
await runClients(["update", "client_docs", "--no-nonce"]);
expect(updateApplication).toHaveBeenCalledWith(fakeClient, "client_docs", {
nonceRequired: false,
});
expect(logs()).toContain(warning);

vi.mocked(updateApplication).mockResolvedValue(apiApplication());
await runClients(["update", "client_docs", "--pkce", "--nonce"]);
expect(vi.mocked(updateApplication).mock.calls[1][2]).toEqual({
pkceRequired: true,
nonceRequired: true,
});
});

it("refuses the opt-outs the API would refuse before asking it", async () => {
await exits([
"create",
"Desk",
"--public",
"--redirect-uri",
"http://127.0.0.1/callback",
"--no-pkce",
]);
expect(errors()).toContain("public clients must use PKCE");

await exits([
"create",
"Docs",
"--redirect-uri",
"https://docs.example.com/callback",
"--no-nonce",
]);
expect(errors()).toContain("only be turned off once PKCE is waived");
expect(createApplication).not.toHaveBeenCalled();
});
});

describe("clients rotate-secret", () => {
it("confirms, then prints the new secret once", async () => {
vi.mocked(rotateApplicationSecret).mockResolvedValue({
Expand Down
38 changes: 34 additions & 4 deletions src/commands/clients.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ import { parseList, reportAdminError } from "./adminShared.js";
const USAGE =
"Usage: seamless clients <list|get|create|update|rotate-secret|keys|resources|delete>";

const NO_CODE_BINDING_WARNING =
"PKCE and the nonce are both off: this application relies only on state and client authentication against authorization code injection. Turn the nonce back on with --nonce unless a certification test needs both off.";

const SHARED_SECRET_NOTE =
"Shared secrets are weaker than registered keys: anyone who reads one can act as the client. Prefer --auth-method private_key_jwt where the client can hold a key.";

Expand Down Expand Up @@ -183,6 +186,12 @@ function readSettings(args: string[]): SettingsFlags {
const notices = takeSwitch("notices", "no-notices");
if (notices !== undefined) settings.noticesEnabled = notices;

const pkce = takeSwitch("pkce", "no-pkce");
if (pkce !== undefined) settings.pkceRequired = pkce;

const nonce = takeSwitch("nonce", "no-nonce");
if (nonce !== undefined) settings.nonceRequired = nonce;

const logout = take("backchannel-logout-uri");
if (logout !== undefined) {
settings.backchannelLogoutUri = logout === "none" ? null : logout;
Expand All @@ -200,15 +209,23 @@ function readSettings(args: string[]): SettingsFlags {
return { settings, rest };
}

function lacksCodeBinding(app: OidcApplication): boolean {
return !app.pkceRequired && !app.nonceRequired;
}

function warnIfNoCodeBinding(app: OidcApplication): void {
if (lacksCodeBinding(app)) console.log(kleur.yellow(NO_CODE_BINDING_WARNING));
}

function printRow(app: OidcApplication): void {
const type = `${app.clientType}, ${app.applicationType}`;
const method = isSharedSecret(app.tokenEndpointAuthMethod)
? `${app.tokenEndpointAuthMethod} ${kleur.yellow("(weaker)")}`
: app.tokenEndpointAuthMethod;
const status = app.disabled ? kleur.red("disabled") : "";
console.log(
`${kleur.bold(app.id)} ${app.name} ${kleur.dim(type)} ${method} ${status}`.trimEnd(),
);
const parts = [kleur.bold(app.id), app.name, kleur.dim(type), method];
if (lacksCodeBinding(app)) parts.push(kleur.yellow("no PKCE or nonce"));
if (app.disabled) parts.push(kleur.red("disabled"));
console.log(parts.join(" "));
}

function printApplication(app: OidcApplication): void {
Expand Down Expand Up @@ -238,6 +255,8 @@ function printApplication(app: OidcApplication): void {
const grants = [...app.grantTypes, ...(app.deviceFlowEnabled ? ["device_code"] : [])];
console.log(` grants: ${grants.join(", ") || "none"}`);
console.log(` notices: ${app.noticesEnabled ? "on" : "off"}`);
console.log(` PKCE: ${app.pkceRequired ? "required" : kleur.yellow("not required")}`);
console.log(` nonce: ${app.nonceRequired ? "required" : kleur.yellow("not required")}`);
if (app.backchannelLogoutUri) {
console.log(` logout URI: ${app.backchannelLogoutUri}`);
}
Expand All @@ -261,6 +280,14 @@ async function clientsList(client: AuthClient, rest: string[]): Promise<void> {
return;
}
for (const app of applications) printRow(app);
const exposed = applications.filter(lacksCodeBinding).length;
if (exposed) {
console.log(
kleur.yellow(
`${exposed} application${exposed === 1 ? " has" : "s have"} PKCE and the nonce both off, so ${exposed === 1 ? "it relies" : "they rely"} only on state and client authentication against authorization code injection.`,
),
);
}
}

async function clientsGet(client: AuthClient, rest: string[]): Promise<void> {
Expand All @@ -273,6 +300,7 @@ async function clientsGet(client: AuthClient, rest: string[]): Promise<void> {
return;
}
printApplication(app);
warnIfNoCodeBinding(app);
}

async function clientsCreate(client: AuthClient, args: string[]): Promise<void> {
Expand Down Expand Up @@ -308,6 +336,7 @@ async function clientsCreate(client: AuthClient, args: string[]): Promise<void>
const { application, clientSecret } = created;
console.log(kleur.green(`Created application ${application.id}.`));
printApplication(application);
warnIfNoCodeBinding(application);
if (isSharedSecret(application.tokenEndpointAuthMethod)) {
console.log(kleur.yellow(SHARED_SECRET_NOTE));
}
Expand All @@ -334,6 +363,7 @@ async function clientsUpdate(client: AuthClient, args: string[]): Promise<void>
}
console.log(kleur.green(`Updated application ${id}.`));
printApplication(app);
warnIfNoCodeBinding(app);
}

async function clientsRotateSecret(client: AuthClient, rest: string[]): Promise<void> {
Expand Down
7 changes: 7 additions & 0 deletions src/commands/helpTopics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -810,6 +810,13 @@ Flags for create and update
--first-party | --third-party first-party clients skip consent
--device-flow | --no-device-flow first-party clients only
--notices | --no-notices lets the client send account notices
--pkce | --no-pkce PKCE is required by default. Only a
confidential client can turn it off
--nonce | --no-nonce the nonce is required by default. It can
be turned off only once PKCE is off
• With both off the client relies only on state and client
authentication against code injection, and list, get, create and
update warn about it. Turn PKCE back on only after the nonce
--backchannel-logout-uri <url|none>
--access-token-ttl, --id-token-ttl, --refresh-token-ttl <seconds|default>

Expand Down
2 changes: 2 additions & 0 deletions src/core/admin.fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ export function apiApplication(
grantTypes: ["authorization_code", "refresh_token"],
deviceFlowEnabled: false,
noticesEnabled: false,
pkceRequired: true,
nonceRequired: true,
jwks: null,
jwksUri: null,
hasSecret: false,
Expand Down
Loading