151 lines
5.6 KiB
TypeScript
151 lines
5.6 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
import { type AuthorizationPrompt, loginWithBrowser, refreshOAuthCredentials } 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.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("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" },
|
|
});
|
|
}
|