test(auth): harden Task 15 OIDC smoke evidence
This commit is contained in:
+172
-37
@@ -7,7 +7,13 @@
|
||||
* needed to trust the provider from a spawned backend process.
|
||||
*/
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createHash, generateKeyPairSync, randomBytes, sign as signRsa } from "node:crypto";
|
||||
import {
|
||||
createHash,
|
||||
generateKeyPairSync,
|
||||
randomBytes,
|
||||
sign as signRsa,
|
||||
timingSafeEqual,
|
||||
} from "node:crypto";
|
||||
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { createServer } from "node:https";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -17,6 +23,9 @@ import { fileURLToPath } from "node:url";
|
||||
const LOOPBACK_HOST = "127.0.0.1";
|
||||
const ISSUER_PATH = "/application/o/thothii";
|
||||
const MAX_BODY_BYTES = 32 * 1024;
|
||||
const MAX_STATE_TTL_MS = 5 * 60 * 1_000;
|
||||
const MAX_STATE_LIMIT = 1_024;
|
||||
const MAX_DEVICE_POLLS = 32;
|
||||
const VALID_IDENTITIES = new Set([
|
||||
"ordinary",
|
||||
"admin",
|
||||
@@ -39,6 +48,43 @@ function safeError(code) {
|
||||
return new Error(code);
|
||||
}
|
||||
|
||||
function boundedInteger(value, fallback, maximum, code) {
|
||||
const selected = value ?? fallback;
|
||||
if (!Number.isSafeInteger(selected) || selected < 1 || selected > maximum) throw safeError(code);
|
||||
return selected;
|
||||
}
|
||||
|
||||
function boundedNonNegativeInteger(value, fallback, maximum, code) {
|
||||
const selected = value ?? fallback;
|
||||
if (!Number.isSafeInteger(selected) || selected < 0 || selected > maximum) throw safeError(code);
|
||||
return selected;
|
||||
}
|
||||
|
||||
function controlledString(value, code) {
|
||||
if (typeof value !== "string" || value.length < 1 || value.length > 512 || /[\r\n]/u.test(value)) {
|
||||
throw safeError(code);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function exactParameter(values, name) {
|
||||
const matches = values.getAll(name);
|
||||
return matches.length === 1 && matches[0].length > 0 ? matches[0] : undefined;
|
||||
}
|
||||
|
||||
function secretMatches(actual, expected) {
|
||||
if (typeof actual !== "string") return false;
|
||||
const actualDigest = createHash("sha256").update(actual).digest();
|
||||
const expectedDigest = createHash("sha256").update(expected).digest();
|
||||
return timingSafeEqual(actualDigest, expectedDigest);
|
||||
}
|
||||
|
||||
function pruneExpired(records, currentTime) {
|
||||
for (const [key, record] of records) {
|
||||
if (record.expiresAt <= currentTime) records.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
function ensurePrivateDirectory(directory) {
|
||||
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
||||
chmodSync(directory, 0o700);
|
||||
@@ -85,9 +131,9 @@ async function requestBody(request) {
|
||||
return Buffer.concat(chunks).toString("utf8");
|
||||
}
|
||||
|
||||
function jwt(privateKey, issuer, audience, nonce, identity) {
|
||||
function jwt(privateKey, issuer, audience, nonce, identity, currentTime) {
|
||||
const claims = identityClaims[identity];
|
||||
const now = Math.floor(Date.now() / 1_000);
|
||||
const now = Math.floor(currentTime / 1_000);
|
||||
const payload = {
|
||||
iss: issuer,
|
||||
sub: claims.subject,
|
||||
@@ -118,6 +164,42 @@ function authorizationIdentity(identity) {
|
||||
export async function startFakeOidcProvider(options = {}) {
|
||||
const host = options.host ?? LOOPBACK_HOST;
|
||||
if (host !== LOOPBACK_HOST) throw safeError("oidc_fixture_loopback_required");
|
||||
const registration = options.registration;
|
||||
if (!registration || typeof registration !== "object") throw safeError("oidc_fixture_registration_required");
|
||||
const clientId = controlledString(registration.clientId, "oidc_fixture_client_id_invalid");
|
||||
const clientSecret = controlledString(registration.clientSecret, "oidc_fixture_client_secret_invalid");
|
||||
const redirectUri = controlledString(registration.redirectUri, "oidc_fixture_redirect_invalid");
|
||||
let parsedRedirect;
|
||||
try {
|
||||
parsedRedirect = new URL(redirectUri);
|
||||
} catch {
|
||||
throw safeError("oidc_fixture_redirect_invalid");
|
||||
}
|
||||
if (parsedRedirect.protocol !== "http:" || parsedRedirect.hostname !== LOOPBACK_HOST
|
||||
|| parsedRedirect.username || parsedRedirect.password || parsedRedirect.hash) {
|
||||
throw safeError("oidc_fixture_redirect_invalid");
|
||||
}
|
||||
const apiToken = controlledString(options.apiToken, "oidc_fixture_api_token_required");
|
||||
const now = options.now ?? Date.now;
|
||||
if (typeof now !== "function") throw safeError("oidc_fixture_clock_invalid");
|
||||
const authorizationStateTtlMs = boundedInteger(
|
||||
options.authorizationStateTtlMs, 60_000, MAX_STATE_TTL_MS, "oidc_fixture_authorization_ttl_invalid",
|
||||
);
|
||||
const authorizationStateLimit = boundedInteger(
|
||||
options.authorizationStateLimit, 64, MAX_STATE_LIMIT, "oidc_fixture_authorization_limit_invalid",
|
||||
);
|
||||
const deviceStateTtlMs = boundedInteger(
|
||||
options.deviceStateTtlMs, 60_000, MAX_STATE_TTL_MS, "oidc_fixture_device_ttl_invalid",
|
||||
);
|
||||
const deviceStateLimit = boundedInteger(
|
||||
options.deviceStateLimit, 32, MAX_STATE_LIMIT, "oidc_fixture_device_limit_invalid",
|
||||
);
|
||||
const devicePendingPolls = boundedNonNegativeInteger(
|
||||
options.devicePendingPolls, 0, MAX_DEVICE_POLLS, "oidc_fixture_device_pending_polls_invalid",
|
||||
);
|
||||
const devicePollLimit = boundedInteger(
|
||||
options.devicePollLimit, 5, MAX_DEVICE_POLLS, "oidc_fixture_device_poll_limit_invalid",
|
||||
);
|
||||
const ownsDirectory = options.directory === undefined;
|
||||
const directory = options.directory ?? mkdtempSync(join(tmpdir(), "thothii-oidc-fixture-"));
|
||||
ensurePrivateDirectory(directory);
|
||||
@@ -134,6 +216,19 @@ export async function startFakeOidcProvider(options = {}) {
|
||||
let issuer = undefined;
|
||||
let baseUrl = undefined;
|
||||
|
||||
function currentTime() {
|
||||
const value = now();
|
||||
if (!Number.isSafeInteger(value) || value < 0) throw safeError("oidc_fixture_clock_invalid");
|
||||
return value;
|
||||
}
|
||||
|
||||
function authenticateClient(request, values) {
|
||||
if (request.headers.authorization !== undefined) return false;
|
||||
const suppliedClientId = exactParameter(values, "client_id");
|
||||
const suppliedClientSecret = exactParameter(values, "client_secret");
|
||||
return secretMatches(suppliedClientId, clientId) && secretMatches(suppliedClientSecret, clientSecret);
|
||||
}
|
||||
|
||||
const server = createServer({ key: certificate.key, cert: certificate.cert }, async (request, reply) => {
|
||||
try {
|
||||
if (!issuer || !baseUrl || !request.url) {
|
||||
@@ -154,7 +249,7 @@ export async function startFakeOidcProvider(options = {}) {
|
||||
response_types_supported: ["code"],
|
||||
subject_types_supported: ["public"],
|
||||
grant_types_supported: ["authorization_code", "urn:ietf:params:oauth:grant-type:device_code"],
|
||||
token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post"],
|
||||
token_endpoint_auth_methods_supported: ["client_secret_post"],
|
||||
code_challenge_methods_supported: ["S256"],
|
||||
id_token_signing_alg_values_supported: ["RS256"],
|
||||
});
|
||||
@@ -165,33 +260,33 @@ export async function startFakeOidcProvider(options = {}) {
|
||||
return;
|
||||
}
|
||||
if (request.method === "GET" && path === `${ISSUER_PATH}/authorize`) {
|
||||
const redirectUri = url.searchParams.get("redirect_uri");
|
||||
const clientId = url.searchParams.get("client_id");
|
||||
const state = url.searchParams.get("state");
|
||||
const nonce = url.searchParams.get("nonce");
|
||||
const challenge = url.searchParams.get("code_challenge");
|
||||
if (url.searchParams.get("response_type") !== "code" || !redirectUri || !clientId || !state || !nonce
|
||||
|| !challenge || url.searchParams.get("code_challenge_method") !== "S256") {
|
||||
const suppliedRedirectUri = exactParameter(url.searchParams, "redirect_uri");
|
||||
const suppliedClientId = exactParameter(url.searchParams, "client_id");
|
||||
const state = exactParameter(url.searchParams, "state");
|
||||
const nonce = exactParameter(url.searchParams, "nonce");
|
||||
const challenge = exactParameter(url.searchParams, "code_challenge");
|
||||
if (exactParameter(url.searchParams, "response_type") !== "code"
|
||||
|| !secretMatches(suppliedRedirectUri, redirectUri)
|
||||
|| !secretMatches(suppliedClientId, clientId) || !state || !nonce || !challenge
|
||||
|| exactParameter(url.searchParams, "code_challenge_method") !== "S256") {
|
||||
sendJson(reply, 400, { error: "invalid_request" });
|
||||
return;
|
||||
}
|
||||
let callback;
|
||||
try {
|
||||
callback = new URL(redirectUri);
|
||||
if (callback.protocol !== "http:" || callback.hostname !== LOOPBACK_HOST || callback.username || callback.password) {
|
||||
throw safeError("oidc_fixture_redirect_invalid");
|
||||
}
|
||||
} catch {
|
||||
sendJson(reply, 400, { error: "invalid_request" });
|
||||
const reservationTime = currentTime();
|
||||
pruneExpired(authorizations, reservationTime);
|
||||
if (authorizations.size >= authorizationStateLimit) {
|
||||
sendJson(reply, 503, { error: "temporarily_unavailable" });
|
||||
return;
|
||||
}
|
||||
const callback = new URL(redirectUri);
|
||||
const code = randomBytes(32).toString("base64url");
|
||||
authorizations.set(code, {
|
||||
challenge,
|
||||
clientId,
|
||||
redirectUri,
|
||||
identity: activeIdentity,
|
||||
nonce,
|
||||
used: false,
|
||||
expiresAt: reservationTime + authorizationStateTtlMs,
|
||||
});
|
||||
lastAuthorization = { identity: activeIdentity, codeChallengeMethod: "S256", pkceVerified: false };
|
||||
callback.searchParams.set("code", code);
|
||||
@@ -201,48 +296,80 @@ export async function startFakeOidcProvider(options = {}) {
|
||||
}
|
||||
if (request.method === "POST" && path === `${ISSUER_PATH}/device_authorization`) {
|
||||
const values = new URLSearchParams(await requestBody(request));
|
||||
const clientId = values.get("client_id");
|
||||
if (!clientId) {
|
||||
sendJson(reply, 400, { error: "invalid_request" });
|
||||
if (!authenticateClient(request, values)) {
|
||||
sendJson(reply, 401, { error: "invalid_client" });
|
||||
return;
|
||||
}
|
||||
const reservationTime = currentTime();
|
||||
pruneExpired(deviceCodes, reservationTime);
|
||||
if (deviceCodes.size >= deviceStateLimit) {
|
||||
sendJson(reply, 503, { error: "temporarily_unavailable" });
|
||||
return;
|
||||
}
|
||||
const deviceCode = randomBytes(32).toString("base64url");
|
||||
const userCode = "FIXTURE-CODE";
|
||||
deviceCodes.set(deviceCode, { clientId, identity: activeIdentity, nonce: "device", used: false });
|
||||
deviceCodes.set(deviceCode, {
|
||||
clientId,
|
||||
identity: activeIdentity,
|
||||
nonce: "device",
|
||||
expiresAt: reservationTime + deviceStateTtlMs,
|
||||
polls: 0,
|
||||
});
|
||||
sendJson(reply, 200, {
|
||||
device_code: deviceCode,
|
||||
user_code: userCode,
|
||||
verification_uri: `${issuer}device`,
|
||||
verification_uri_complete: `${issuer}device?user_code=${userCode}`,
|
||||
expires_in: 60,
|
||||
expires_in: Math.max(1, Math.floor(deviceStateTtlMs / 1_000)),
|
||||
interval: 1,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (request.method === "POST" && path === `${ISSUER_PATH}/token`) {
|
||||
const values = new URLSearchParams(await requestBody(request));
|
||||
const grantType = values.get("grant_type");
|
||||
if (!authenticateClient(request, values)) {
|
||||
sendJson(reply, 401, { error: "invalid_client" });
|
||||
return;
|
||||
}
|
||||
const tokenTime = currentTime();
|
||||
pruneExpired(authorizations, tokenTime);
|
||||
pruneExpired(deviceCodes, tokenTime);
|
||||
const grantType = exactParameter(values, "grant_type");
|
||||
let record;
|
||||
if (grantType === "authorization_code") {
|
||||
const code = values.get("code") ?? "";
|
||||
const code = exactParameter(values, "code") ?? "";
|
||||
record = authorizations.get(code);
|
||||
const verifier = values.get("code_verifier") ?? "";
|
||||
const verified = record !== undefined && !record.used
|
||||
&& createHash("sha256").update(verifier).digest("base64url") === record.challenge;
|
||||
if (record !== undefined) authorizations.delete(code);
|
||||
const verifier = exactParameter(values, "code_verifier") ?? "";
|
||||
const suppliedRedirectUri = exactParameter(values, "redirect_uri");
|
||||
const suppliedClientId = exactParameter(values, "client_id");
|
||||
const verified = record !== undefined
|
||||
&& secretMatches(suppliedClientId, record.clientId)
|
||||
&& secretMatches(suppliedRedirectUri, record.redirectUri)
|
||||
&& secretMatches(createHash("sha256").update(verifier).digest("base64url"), record.challenge);
|
||||
if (!verified) {
|
||||
sendJson(reply, 400, { error: "invalid_grant" });
|
||||
return;
|
||||
}
|
||||
record.used = true;
|
||||
if (lastAuthorization) lastAuthorization = { ...lastAuthorization, pkceVerified: true };
|
||||
} else if (grantType === "urn:ietf:params:oauth:grant-type:device_code") {
|
||||
const deviceCode = values.get("device_code") ?? "";
|
||||
const deviceCode = exactParameter(values, "device_code") ?? "";
|
||||
record = deviceCodes.get(deviceCode);
|
||||
if (record === undefined || record.used) {
|
||||
if (record === undefined) {
|
||||
sendJson(reply, 400, { error: "invalid_grant" });
|
||||
return;
|
||||
}
|
||||
record.used = true;
|
||||
record.polls += 1;
|
||||
if (record.polls >= devicePollLimit && record.polls <= devicePendingPolls) {
|
||||
deviceCodes.delete(deviceCode);
|
||||
sendJson(reply, 400, { error: "expired_token" });
|
||||
return;
|
||||
}
|
||||
if (record.polls <= devicePendingPolls) {
|
||||
sendJson(reply, 400, { error: "authorization_pending" });
|
||||
return;
|
||||
}
|
||||
deviceCodes.delete(deviceCode);
|
||||
} else {
|
||||
sendJson(reply, 400, { error: "unsupported_grant_type" });
|
||||
return;
|
||||
@@ -251,12 +378,13 @@ export async function startFakeOidcProvider(options = {}) {
|
||||
access_token: randomBytes(32).toString("base64url"),
|
||||
token_type: "Bearer",
|
||||
expires_in: 60,
|
||||
id_token: jwt(privateKey, issuer, record.clientId, record.nonce, record.identity),
|
||||
id_token: jwt(privateKey, issuer, record.clientId, record.nonce, record.identity, tokenTime),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (request.method === "GET" && path === "/api/v3/core/groups/") {
|
||||
if (!request.headers.authorization?.startsWith("Bearer ")) {
|
||||
const authorization = request.headers.authorization;
|
||||
if (!secretMatches(authorization, `Bearer ${apiToken}`)) {
|
||||
sendJson(reply, 401, { detail: "authentication required" });
|
||||
return;
|
||||
}
|
||||
@@ -317,7 +445,14 @@ export async function startFakeOidcProvider(options = {}) {
|
||||
|
||||
const currentFile = fileURLToPath(import.meta.url);
|
||||
if (process.argv[1] && resolve(process.argv[1]) === currentFile) {
|
||||
const provider = await startFakeOidcProvider();
|
||||
const provider = await startFakeOidcProvider({
|
||||
registration: {
|
||||
clientId: "fixture-standalone-client",
|
||||
clientSecret: "fixture-standalone-secret-not-production",
|
||||
redirectUri: "http://127.0.0.1:8787/api/auth/oidc/callback",
|
||||
},
|
||||
apiToken: "fixture-standalone-api-token-not-production",
|
||||
});
|
||||
process.stdout.write('{"status":"ready"}\n');
|
||||
const close = async () => {
|
||||
await provider.close();
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { request as httpsRequest } from "node:https";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { startFakeOidcProvider } from "./fixtures/oidc-provider.mjs";
|
||||
|
||||
const registration = Object.freeze({
|
||||
clientId: "fixture-client",
|
||||
clientSecret: "fixture-client-secret-not-production",
|
||||
redirectUri: "http://127.0.0.1:8787/api/auth/oidc/callback",
|
||||
});
|
||||
const apiToken = "fixture-api-token-not-production";
|
||||
const verifier = "fixture-pkce-verifier-0123456789-abcdefghijklmnopqrstuvwxyz";
|
||||
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
||||
|
||||
let provider;
|
||||
|
||||
afterEach(async () => {
|
||||
await provider?.close();
|
||||
provider = undefined;
|
||||
});
|
||||
|
||||
async function start(options = {}) {
|
||||
provider = await startFakeOidcProvider({ registration, apiToken, ...options });
|
||||
return provider;
|
||||
}
|
||||
|
||||
function exchange(target, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const body = options.body ?? "";
|
||||
const request = httpsRequest(target, {
|
||||
method: options.method ?? "GET",
|
||||
ca: readFileSync(provider.caFile),
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
...(body.length === 0 ? {} : {
|
||||
"content-length": String(Buffer.byteLength(body)),
|
||||
"content-type": "application/x-www-form-urlencoded",
|
||||
}),
|
||||
...options.headers,
|
||||
},
|
||||
}, (response) => {
|
||||
const chunks = [];
|
||||
response.on("data", (chunk) => chunks.push(chunk));
|
||||
response.once("error", reject);
|
||||
response.once("end", () => {
|
||||
const text = Buffer.concat(chunks).toString("utf8");
|
||||
const parsed = text.length === 0 ? {} : JSON.parse(text);
|
||||
if (parsed && typeof parsed === "object") {
|
||||
if (Object.hasOwn(parsed, "access_token")) parsed.access_token = "[redacted]";
|
||||
if (Object.hasOwn(parsed, "id_token")) parsed.id_token = "[redacted]";
|
||||
}
|
||||
resolve({
|
||||
status: response.statusCode ?? 0,
|
||||
location: response.headers.location,
|
||||
body: parsed,
|
||||
});
|
||||
});
|
||||
});
|
||||
request.once("error", reject);
|
||||
request.end(body);
|
||||
});
|
||||
}
|
||||
|
||||
function form(entries) {
|
||||
return new URLSearchParams(entries).toString();
|
||||
}
|
||||
|
||||
async function authorize(overrides = {}) {
|
||||
const target = new URL(`${provider.issuer}authorize`);
|
||||
const values = {
|
||||
response_type: "code",
|
||||
client_id: registration.clientId,
|
||||
redirect_uri: registration.redirectUri,
|
||||
state: "fixture-state",
|
||||
nonce: "fixture-nonce",
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
...overrides,
|
||||
};
|
||||
for (const [name, value] of Object.entries(values)) target.searchParams.set(name, value);
|
||||
return exchange(target);
|
||||
}
|
||||
|
||||
function codeFrom(response) {
|
||||
return new URL(response.location).searchParams.get("code");
|
||||
}
|
||||
|
||||
function tokenBody(code, overrides = {}) {
|
||||
return form({
|
||||
grant_type: "authorization_code",
|
||||
client_id: registration.clientId,
|
||||
client_secret: registration.clientSecret,
|
||||
code,
|
||||
redirect_uri: registration.redirectUri,
|
||||
code_verifier: verifier,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function deviceAuthorizationBody(overrides = {}) {
|
||||
return form({
|
||||
client_id: registration.clientId,
|
||||
client_secret: registration.clientSecret,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function deviceTokenBody(deviceCode, overrides = {}) {
|
||||
return form({
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
client_id: registration.clientId,
|
||||
client_secret: registration.clientSecret,
|
||||
device_code: deviceCode,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe("loopback OIDC fixture security contract", () => {
|
||||
test("advertises only production client_secret_post and rejects missing, wrong, basic, or duplicate client credentials", async () => {
|
||||
await start();
|
||||
const discovery = await exchange(`${provider.issuer}.well-known/openid-configuration`);
|
||||
expect(discovery.body.token_endpoint_auth_methods_supported).toEqual(["client_secret_post"]);
|
||||
|
||||
const endpoint = `${provider.issuer}token`;
|
||||
const requests = [
|
||||
form({ grant_type: "authorization_code", code: "unknown" }),
|
||||
form({ grant_type: "authorization_code", code: "unknown", client_id: registration.clientId, client_secret: "wrong" }),
|
||||
`${form({ grant_type: "authorization_code", code: "unknown", client_id: registration.clientId, client_secret: registration.clientSecret })}&client_secret=duplicate`,
|
||||
`${form({ grant_type: "authorization_code", code: "unknown", client_id: registration.clientId, client_secret: registration.clientSecret })}&client_id=duplicate`,
|
||||
form({ grant_type: "authorization_code", code: "unknown", client_id: "wrong", client_secret: registration.clientSecret }),
|
||||
];
|
||||
for (const body of requests) {
|
||||
const response = await exchange(endpoint, { method: "POST", body });
|
||||
expect(response).toMatchObject({ status: 401, body: { error: "invalid_client" } });
|
||||
}
|
||||
const basic = Buffer.from(`${registration.clientId}:${registration.clientSecret}`).toString("base64");
|
||||
await expect(exchange(endpoint, {
|
||||
method: "POST",
|
||||
body: form({ grant_type: "authorization_code", code: "unknown" }),
|
||||
headers: { authorization: `Basic ${basic}` },
|
||||
})).resolves.toMatchObject({ status: 401, body: { error: "invalid_client" } });
|
||||
|
||||
await expect(exchange(endpoint, {
|
||||
method: "POST",
|
||||
body: tokenBody("unknown"),
|
||||
})).resolves.toMatchObject({ status: 400, body: { error: "invalid_grant" } });
|
||||
});
|
||||
|
||||
test("requires the exact Authentik bearer token", async () => {
|
||||
await start();
|
||||
const target = `${provider.baseUrl}/api/v3/core/groups/?name=fixture-users`;
|
||||
await expect(exchange(target)).resolves.toMatchObject({ status: 401 });
|
||||
await expect(exchange(target, { headers: { authorization: "Bearer wrong" } }))
|
||||
.resolves.toMatchObject({ status: 401 });
|
||||
await expect(exchange(target, { headers: { authorization: `Bearer ${apiToken}` } }))
|
||||
.resolves.toMatchObject({ status: 200, body: { results: [{ name: "fixture-users" }] } });
|
||||
});
|
||||
|
||||
test("binds authorization codes to registration, redirect URI, and PKCE and consumes terminal attempts", async () => {
|
||||
await start();
|
||||
await expect(authorize({ client_id: "wrong" })).resolves.toMatchObject({ status: 400 });
|
||||
await expect(authorize({ redirect_uri: "http://127.0.0.1:8787/wrong" })).resolves.toMatchObject({ status: 400 });
|
||||
|
||||
const redirectCode = codeFrom(await authorize());
|
||||
await expect(exchange(`${provider.issuer}token`, {
|
||||
method: "POST",
|
||||
body: tokenBody(redirectCode, { redirect_uri: "http://127.0.0.1:8787/wrong" }),
|
||||
})).resolves.toMatchObject({ status: 400, body: { error: "invalid_grant" } });
|
||||
await expect(exchange(`${provider.issuer}token`, { method: "POST", body: tokenBody(redirectCode) }))
|
||||
.resolves.toMatchObject({ status: 400, body: { error: "invalid_grant" } });
|
||||
|
||||
const pkceCode = codeFrom(await authorize());
|
||||
await expect(exchange(`${provider.issuer}token`, {
|
||||
method: "POST",
|
||||
body: tokenBody(pkceCode, { code_verifier: "wrong-verifier" }),
|
||||
})).resolves.toMatchObject({ status: 400, body: { error: "invalid_grant" } });
|
||||
await expect(exchange(`${provider.issuer}token`, { method: "POST", body: tokenBody(pkceCode) }))
|
||||
.resolves.toMatchObject({ status: 400, body: { error: "invalid_grant" } });
|
||||
|
||||
const successfulCode = codeFrom(await authorize());
|
||||
await expect(exchange(`${provider.issuer}token`, { method: "POST", body: tokenBody(successfulCode) }))
|
||||
.resolves.toMatchObject({ status: 200 });
|
||||
await expect(exchange(`${provider.issuer}token`, { method: "POST", body: tokenBody(successfulCode) }))
|
||||
.resolves.toMatchObject({ status: 400, body: { error: "invalid_grant" } });
|
||||
});
|
||||
|
||||
test("prunes expired authorization state before enforcing a fail-closed cardinality cap", async () => {
|
||||
let now = 1_000;
|
||||
await start({ now: () => now, authorizationStateTtlMs: 1_000, authorizationStateLimit: 1 });
|
||||
const first = await authorize();
|
||||
expect(first.status).toBe(302);
|
||||
await expect(authorize({ state: "capacity" }))
|
||||
.resolves.toMatchObject({ status: 503, body: { error: "temporarily_unavailable" } });
|
||||
now += 1_001;
|
||||
await expect(authorize({ state: "after-expiry" })).resolves.toMatchObject({ status: 302 });
|
||||
await expect(exchange(`${provider.issuer}token`, { method: "POST", body: tokenBody(codeFrom(first)) }))
|
||||
.resolves.toMatchObject({ status: 400, body: { error: "invalid_grant" } });
|
||||
});
|
||||
|
||||
test("bounds device state, polling, expiry, and replay", async () => {
|
||||
let now = 5_000;
|
||||
await start({
|
||||
now: () => now,
|
||||
deviceStateTtlMs: 1_000,
|
||||
deviceStateLimit: 1,
|
||||
devicePendingPolls: 1,
|
||||
devicePollLimit: 3,
|
||||
});
|
||||
const device = await exchange(`${provider.issuer}device_authorization`, {
|
||||
method: "POST", body: deviceAuthorizationBody(),
|
||||
});
|
||||
expect(device.status).toBe(200);
|
||||
await expect(exchange(`${provider.issuer}device_authorization`, {
|
||||
method: "POST", body: deviceAuthorizationBody(),
|
||||
})).resolves.toMatchObject({ status: 503, body: { error: "temporarily_unavailable" } });
|
||||
await expect(exchange(`${provider.issuer}token`, {
|
||||
method: "POST", body: deviceTokenBody(device.body.device_code),
|
||||
})).resolves.toMatchObject({ status: 400, body: { error: "authorization_pending" } });
|
||||
await expect(exchange(`${provider.issuer}token`, {
|
||||
method: "POST", body: deviceTokenBody(device.body.device_code),
|
||||
})).resolves.toMatchObject({ status: 200 });
|
||||
await expect(exchange(`${provider.issuer}token`, {
|
||||
method: "POST", body: deviceTokenBody(device.body.device_code),
|
||||
})).resolves.toMatchObject({ status: 400, body: { error: "invalid_grant" } });
|
||||
|
||||
const expired = await exchange(`${provider.issuer}device_authorization`, {
|
||||
method: "POST", body: deviceAuthorizationBody(),
|
||||
});
|
||||
now += 1_001;
|
||||
await expect(exchange(`${provider.issuer}device_authorization`, {
|
||||
method: "POST", body: deviceAuthorizationBody(),
|
||||
})).resolves.toMatchObject({ status: 200 });
|
||||
await expect(exchange(`${provider.issuer}token`, {
|
||||
method: "POST", body: deviceTokenBody(expired.body.device_code),
|
||||
})).resolves.toMatchObject({ status: 400, body: { error: "invalid_grant" } });
|
||||
});
|
||||
|
||||
test("deletes a device grant when its polling limit is exhausted", async () => {
|
||||
await start({ devicePendingPolls: 10, devicePollLimit: 2 });
|
||||
const device = await exchange(`${provider.issuer}device_authorization`, {
|
||||
method: "POST", body: deviceAuthorizationBody(),
|
||||
});
|
||||
await expect(exchange(`${provider.issuer}token`, {
|
||||
method: "POST", body: deviceTokenBody(device.body.device_code),
|
||||
})).resolves.toMatchObject({ status: 400, body: { error: "authorization_pending" } });
|
||||
await expect(exchange(`${provider.issuer}token`, {
|
||||
method: "POST", body: deviceTokenBody(device.body.device_code),
|
||||
})).resolves.toMatchObject({ status: 400, body: { error: "expired_token" } });
|
||||
await expect(exchange(`${provider.issuer}token`, {
|
||||
method: "POST", body: deviceTokenBody(device.body.device_code),
|
||||
})).resolves.toMatchObject({ status: 400, body: { error: "invalid_grant" } });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user