Files
nac 9f3d3ad5fc
CI / test (macos-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
feat: harden install and resumable OAuth login
2026-08-28 07:01:20 +00:00

324 lines
11 KiB
TypeScript

import assert from "node:assert/strict";
import test from "node:test";
import {
type AuthorizationPrompt,
beginBrowserLogin,
loginWithBrowser,
type PendingOAuthAuthorization,
pollBrowserLogin,
refreshOAuthCredentials,
validatePendingOAuthAuthorization,
} from "../src/oauth.js";
const discovery = {
issuer: "https://justconn.top/api",
device_authorization_endpoint: "https://justconn.top/api/idpOAuth/device/auth",
registration_endpoint: "https://justconn.top/api/idpOAuth/register",
token_endpoint: "https://justconn.top/api/idpOAuth/token",
grant_types_supported: [
"authorization_code",
"refresh_token",
"urn:ietf:params:oauth:grant-type:device_code",
],
scopes_supported: ["openid", "offline_access", "api", "mcp"],
token_endpoint_auth_methods_supported: ["none", "client_secret_basic"],
};
test("uses NocoBase dynamic registration and browser device authorization without exposing tokens", async () => {
const calls: Array<{ body: string; method: string; url: string }> = [];
let tokenPolls = 0;
const fetchMock = (async (input: string | URL | Request, init: RequestInit = {}) => {
const url = String(input);
const method = init.method ?? "GET";
const body =
typeof init.body === "string" || init.body instanceof URLSearchParams ? String(init.body) : "";
calls.push({ body, method, url });
if (url.endsWith("/.well-known/openid-configuration")) return jsonResponse(discovery);
if (url.endsWith("/idpOAuth/register")) {
return jsonResponse(
{
client_id: "desktop-client",
registration_access_token: "must-not-be-stored",
token_endpoint_auth_method: "none",
},
201,
);
}
if (url.endsWith("/idpOAuth/device/auth")) {
return jsonResponse({
device_code: "private-device-code",
user_code: "ABCD-EFGH",
verification_uri: "https://justconn.top/idpOAuth/device",
verification_uri_complete: "https://justconn.top/idpOAuth/device?user_code=ABCD-EFGH",
expires_in: 600,
interval: 5,
});
}
if (url.endsWith("/idpOAuth/token")) {
tokenPolls += 1;
if (tokenPolls === 1) return jsonResponse({ error: "authorization_pending" }, 400);
return jsonResponse({
access_token: "oauth-access-token",
refresh_token: "oauth-refresh-token",
token_type: "Bearer",
scope: "api offline_access",
expires_in: 900,
});
}
throw new Error(`Unexpected request: ${method} ${url}`);
}) as typeof fetch;
let prompt: AuthorizationPrompt | undefined;
const sleeps: number[] = [];
const credentials = await loginWithBrowser({
fetch: fetchMock,
now: () => 1_000,
openBrowser: async () => true,
onAuthorization: (value) => {
prompt = value;
},
sleep: async (milliseconds) => {
sleeps.push(milliseconds);
},
});
assert.equal(credentials.clientId, "desktop-client");
assert.equal(credentials.accessToken, "oauth-access-token");
assert.equal(credentials.refreshToken, "oauth-refresh-token");
assert.equal(credentials.expiresAt, 901_000);
assert.equal(calls.filter((call) => call.url.endsWith("/.well-known/openid-configuration")).length, 1);
assert.doesNotMatch(JSON.stringify(credentials), /must-not-be-stored/);
assert.deepEqual(prompt, {
opened: true,
url: "https://justconn.top/idpOAuth/device?user_code=ABCD-EFGH",
userCode: "ABCD-EFGH",
expiresIn: 600,
});
assert.deepEqual(sleeps, [5_000, 5_000]);
const registration = JSON.parse(calls.find((call) => call.url.endsWith("/register"))?.body ?? "{}");
assert.equal(registration.application_type, "native");
assert.equal(registration.token_endpoint_auth_method, "none");
assert.deepEqual(registration.response_types, []);
assert.deepEqual(registration.redirect_uris, []);
assert.equal(registration.client_secret, undefined);
const deviceForm = new URLSearchParams(calls.find((call) => call.url.endsWith("/device/auth"))?.body);
assert.equal(deviceForm.get("scope"), "api offline_access");
assert.equal(deviceForm.get("resource"), "https://justconn.top/api/");
});
test("resumes one persisted browser authorization without creating another link", async () => {
let currentTime = 1_000;
let deviceStarts = 0;
let tokenPolls = 0;
const fetchMock = (async (input: string | URL | Request) => {
const url = String(input);
if (url.endsWith("/.well-known/openid-configuration")) return jsonResponse(discovery);
if (url.endsWith("/idpOAuth/device/auth")) {
deviceStarts += 1;
return jsonResponse({
device_code: "private-resumable-device-code",
user_code: "RESUME-CODE",
verification_uri: "https://justconn.top/idpOAuth/device",
verification_uri_complete: "https://justconn.top/idpOAuth/device?user_code=RESUME-CODE",
expires_in: 600,
interval: 5,
});
}
if (url.endsWith("/idpOAuth/token")) {
tokenPolls += 1;
return jsonResponse({
access_token: "resumed-access-token",
refresh_token: "resumed-refresh-token",
token_type: "Bearer",
scope: "api offline_access",
expires_in: 900,
});
}
throw new Error(`Unexpected request: ${url}`);
}) as typeof fetch;
const authorization = await beginBrowserLogin({
clientId: "desktop-client",
fetch: fetchMock,
now: () => currentTime,
openBrowser: async () => false,
});
assert.equal(deviceStarts, 1);
assert.equal(authorization.deviceCode, "private-resumable-device-code");
assert.equal(authorization.nextPollAt, 6_000);
currentTime = 5_000;
const tooEarly = await pollBrowserLogin(authorization, { fetch: fetchMock, now: () => currentTime });
assert.equal(tooEarly.status, "authorization_pending");
assert.equal(tokenPolls, 0);
currentTime = 6_000;
const resumed = await pollBrowserLogin(authorization, { fetch: fetchMock, now: () => currentTime });
assert.equal(resumed.status, "authenticated");
if (resumed.status === "authenticated") {
assert.equal(resumed.credentials.accessToken, "resumed-access-token");
assert.equal(resumed.credentials.authorizationSessionId, authorization.sessionId);
}
assert.equal(tokenPolls, 1);
assert.equal(deviceStarts, 1);
});
test("normalizes pending and slow-down responses for resumable browser login", async () => {
for (const scenario of [
{ code: "authorization_pending", intervalSeconds: 5, nextPollAt: 11_000 },
{ code: "slow_down", intervalSeconds: 10, nextPollAt: 16_000 },
]) {
let discoveries = 0;
const fetchMock = (async (input: string | URL | Request) => {
const url = String(input);
if (url.endsWith("/.well-known/openid-configuration")) {
discoveries += 1;
return jsonResponse(discovery);
}
if (url.endsWith("/idpOAuth/token")) return jsonResponse({ error: scenario.code }, 400);
throw new Error(`Unexpected request: ${url}`);
}) as typeof fetch;
const result = await pollBrowserLogin(persistedAuthorization(), {
fetch: fetchMock,
now: () => 6_000,
});
assert.equal(result.status, "authorization_pending");
if (result.status === "authorization_pending") {
assert.equal(result.authorization.intervalSeconds, scenario.intervalSeconds);
assert.equal(result.authorization.nextPollAt, scenario.nextPollAt);
}
assert.equal(discoveries, 1);
}
});
test("maps cancelled and expired resumable browser login to stable errors", async () => {
for (const scenario of [
{ code: "access_denied", message: /cancelled/ },
{ code: "expired_token", message: /expired/ },
]) {
const fetchMock = (async (input: string | URL | Request) => {
const url = String(input);
if (url.endsWith("/.well-known/openid-configuration")) return jsonResponse(discovery);
if (url.endsWith("/idpOAuth/token")) return jsonResponse({ error: scenario.code }, 400);
throw new Error(`Unexpected request: ${url}`);
}) as typeof fetch;
await assert.rejects(
pollBrowserLogin(persistedAuthorization(), { fetch: fetchMock, now: () => 6_000 }),
scenario.message,
);
}
let calls = 0;
await assert.rejects(
pollBrowserLogin(persistedAuthorization({ expiresAt: 6_000 }), {
fetch: (async () => {
calls += 1;
throw new Error("must not fetch");
}) as typeof fetch,
now: () => 6_000,
}),
/expired/,
);
assert.equal(calls, 0);
});
test("rejects a persisted browser authorization that was redirected off origin", () => {
assert.throws(
() =>
validatePendingOAuthAuthorization({
schemaVersion: 1,
type: "oauth-device-authorization",
sessionId: "session",
issuer: "https://justconn.top/api",
clientId: "client",
deviceCode: "device-code",
prompt: {
opened: false,
url: "https://evil.example/device",
userCode: "CODE",
expiresIn: 600,
},
expiresAt: 601_000,
intervalSeconds: 5,
nextPollAt: 6_000,
}),
/untrusted browser URL/,
);
});
test("rejects OAuth discovery that points tokens at another origin", async () => {
const fetchMock = (async () =>
jsonResponse({ ...discovery, token_endpoint: "https://evil.example/token" })) as typeof fetch;
await assert.rejects(
loginWithBrowser({ fetch: fetchMock, openBrowser: async () => true }),
/untrusted endpoint/,
);
});
test("refreshes a public-client OAuth session and keeps a rotated refresh token optional", async () => {
const bodies: string[] = [];
const fetchMock = (async (input: string | URL | Request, init: RequestInit = {}) => {
const url = String(input);
if (url.endsWith("/.well-known/openid-configuration")) return jsonResponse(discovery);
bodies.push(String(init.body));
return jsonResponse({
access_token: "new-access",
token_type: "Bearer",
scope: "api offline_access",
expires_in: 600,
});
}) as typeof fetch;
const refreshed = await refreshOAuthCredentials(
{
type: "oauth",
issuer: "https://justconn.top/api",
clientId: "desktop-client",
accessToken: "old-access",
refreshToken: "old-refresh",
tokenType: "Bearer",
scope: "api offline_access",
expiresAt: 10,
},
{ fetch: fetchMock, now: () => 2_000 },
);
assert.equal(refreshed.accessToken, "new-access");
assert.equal(refreshed.refreshToken, "old-refresh");
assert.equal(refreshed.expiresAt, 602_000);
const form = new URLSearchParams(bodies[0]);
assert.equal(form.get("grant_type"), "refresh_token");
assert.equal(form.get("client_id"), "desktop-client");
assert.equal(form.get("refresh_token"), "old-refresh");
});
function jsonResponse(value: unknown, status = 200): Response {
return new Response(JSON.stringify(value), {
status,
headers: { "content-type": "application/json" },
});
}
function persistedAuthorization(
overrides: Partial<PendingOAuthAuthorization> = {},
): PendingOAuthAuthorization {
return {
schemaVersion: 1,
type: "oauth-device-authorization",
sessionId: "persisted-session",
issuer: "https://justconn.top/api",
clientId: "desktop-client",
deviceCode: "private-device-code",
prompt: {
opened: false,
url: "https://justconn.top/idpOAuth/device?user_code=RESUME-CODE",
userCode: "RESUME-CODE",
expiresIn: 600,
},
expiresAt: 601_000,
intervalSeconds: 5,
nextPollAt: 6_000,
...overrides,
};
}