324 lines
11 KiB
TypeScript
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,
|
|
};
|
|
}
|