snapshot of https://github.com/mountain-loop/yaak.git (main, 2026-09-23; blobs > 131072B omitted)
This commit is contained in:
137
plugins/auth-oauth2/tests/authorizationCode.test.ts
Normal file
137
plugins/auth-oauth2/tests/authorizationCode.test.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import type { HttpRequest } from "@yaakapp/api";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { readCustomParams } from "../src/customParams";
|
||||
import { getAuthorizationCode } from "../src/grants/authorizationCode";
|
||||
|
||||
const REDIRECT_URI = "https://app.example.com/callback";
|
||||
|
||||
/**
|
||||
* Drives the embedded-browser flow: records the authorization URL the plugin
|
||||
* opens, then navigates straight to the redirect with a code.
|
||||
*/
|
||||
function createMockContext() {
|
||||
const opened: string[] = [];
|
||||
const sent: Partial<HttpRequest>[] = [];
|
||||
const values = new Map<string, unknown>();
|
||||
|
||||
const ctx = {
|
||||
store: {
|
||||
async set<T>(key: string, value: T) {
|
||||
values.set(key, value);
|
||||
},
|
||||
async get<T>(key: string) {
|
||||
return values.get(key) as T | undefined;
|
||||
},
|
||||
},
|
||||
window: {
|
||||
async openUrl({
|
||||
url,
|
||||
onNavigate,
|
||||
}: {
|
||||
url: string;
|
||||
onNavigate: (e: { url: string }) => Promise<void>;
|
||||
}) {
|
||||
opened.push(url);
|
||||
// Deferred because onNavigate closes the window it is handed back from
|
||||
setTimeout(() => onNavigate({ url: `${REDIRECT_URI}?code=code-123` }), 0);
|
||||
return { close() {} };
|
||||
},
|
||||
},
|
||||
httpRequest: {
|
||||
async send({ httpRequest }: { httpRequest: Partial<HttpRequest> }) {
|
||||
sent.push(httpRequest);
|
||||
return {
|
||||
httpResponse: { status: 200, error: null },
|
||||
body: {
|
||||
async text() {
|
||||
return JSON.stringify({ access_token: "token-123" });
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
} as never;
|
||||
|
||||
return { ctx, opened, sent };
|
||||
}
|
||||
|
||||
const baseArgs = {
|
||||
authorizationUrl: "https://auth.example.com/authorize",
|
||||
accessTokenUrl: "https://auth.example.com/token",
|
||||
clientId: "client-123",
|
||||
clientSecret: "secret",
|
||||
redirectUri: REDIRECT_URI,
|
||||
scope: "openid",
|
||||
state: null,
|
||||
audience: null,
|
||||
credentialsInBody: true,
|
||||
pkce: null,
|
||||
tokenName: "access_token" as const,
|
||||
};
|
||||
|
||||
function pairs(...rows: { name: string; value: string; enabled?: boolean }[]) {
|
||||
return JSON.stringify(rows.map((r) => ({ enabled: true, ...r })));
|
||||
}
|
||||
|
||||
describe("authorization code custom parameters", () => {
|
||||
test("puts custom authorization params on the authorize URL", async () => {
|
||||
const { ctx, opened } = createMockContext();
|
||||
|
||||
await getAuthorizationCode(ctx, "request-1", {
|
||||
...baseArgs,
|
||||
customParams: readCustomParams({
|
||||
authorizationParams: pairs(
|
||||
{ name: "prompt", value: "consent" },
|
||||
{ name: "realm", value: "employees" },
|
||||
{ name: "skipped", value: "nope", enabled: false },
|
||||
),
|
||||
}),
|
||||
});
|
||||
|
||||
const url = new URL(opened[0]!);
|
||||
expect(url.searchParams.get("prompt")).toBe("consent");
|
||||
expect(url.searchParams.get("realm")).toBe("employees");
|
||||
expect(url.searchParams.has("skipped")).toBe(false);
|
||||
// Generated params are still there
|
||||
expect(url.searchParams.get("response_type")).toBe("code");
|
||||
expect(url.searchParams.get("client_id")).toBe("client-123");
|
||||
expect(url.searchParams.get("scope")).toBe("openid");
|
||||
expect(url.searchParams.get("redirect_uri")).toBe(REDIRECT_URI);
|
||||
});
|
||||
|
||||
test("keeps the flow's redirect_uri when a custom param tries to change it", async () => {
|
||||
const { ctx, opened } = createMockContext();
|
||||
|
||||
await getAuthorizationCode(ctx, "request-1", {
|
||||
...baseArgs,
|
||||
customParams: readCustomParams({
|
||||
authorizationParams: pairs({ name: "redirect_uri", value: "https://evil.example.com" }),
|
||||
}),
|
||||
});
|
||||
|
||||
expect(new URL(opened[0]!).searchParams.get("redirect_uri")).toBe(REDIRECT_URI);
|
||||
});
|
||||
|
||||
test("sends custom token entries with the exchange that follows", async () => {
|
||||
const { ctx, sent } = createMockContext();
|
||||
|
||||
await getAuthorizationCode(ctx, "request-1", {
|
||||
...baseArgs,
|
||||
customParams: readCustomParams({
|
||||
tokenHeaders: pairs(
|
||||
{ name: "Origin", value: "https://app.example.com" },
|
||||
{ name: "X-Skipped", value: "nope", enabled: false },
|
||||
),
|
||||
tokenBodyParams: pairs({ name: "realm", value: "employees" }),
|
||||
}),
|
||||
});
|
||||
|
||||
const headers = sent[0]!.headers ?? [];
|
||||
expect(headers).toContainEqual({ name: "Origin", value: "https://app.example.com" });
|
||||
expect(headers.map((h: { name: string }) => h.name)).not.toContain("X-Skipped");
|
||||
|
||||
const form = sent[0]!.body?.form ?? [];
|
||||
expect(form).toContainEqual({ name: "realm", value: "employees" });
|
||||
expect(form).toContainEqual({ name: "code", value: "code-123" });
|
||||
});
|
||||
});
|
||||
164
plugins/auth-oauth2/tests/customParams.test.ts
Normal file
164
plugins/auth-oauth2/tests/customParams.test.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import {
|
||||
applyQueryParams,
|
||||
mergeFormParams,
|
||||
mergeHeaders,
|
||||
parsePairs,
|
||||
readCustomParams,
|
||||
} from "../src/customParams";
|
||||
|
||||
function pairs(...rows: { name: string; value: string; enabled?: boolean }[]) {
|
||||
return JSON.stringify(rows.map((r) => ({ enabled: true, ...r })));
|
||||
}
|
||||
|
||||
describe("parsePairs", () => {
|
||||
test("reads enabled rows", () => {
|
||||
expect(parsePairs(pairs({ name: "Origin", value: "https://app.example.com" }))).toEqual([
|
||||
{ name: "Origin", value: "https://app.example.com" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("skips disabled rows", () => {
|
||||
const value = pairs(
|
||||
{ name: "Origin", value: "https://app.example.com" },
|
||||
{ name: "X-Debug", value: "1", enabled: false },
|
||||
);
|
||||
|
||||
expect(parsePairs(value).map((p) => p.name)).toEqual(["Origin"]);
|
||||
});
|
||||
|
||||
test("skips rows without a name", () => {
|
||||
expect(parsePairs(pairs({ name: " ", value: "ignored" }))).toEqual([]);
|
||||
});
|
||||
|
||||
test("trims names and keeps empty values", () => {
|
||||
expect(parsePairs(pairs({ name: " realm ", value: "" }))).toEqual([
|
||||
{ name: "realm", value: "" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("returns nothing for missing or malformed input", () => {
|
||||
expect(parsePairs(undefined)).toEqual([]);
|
||||
expect(parsePairs("")).toEqual([]);
|
||||
expect(parsePairs("not json")).toEqual([]);
|
||||
expect(parsePairs('{"name":"Origin"}')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("merge precedence", () => {
|
||||
const generated = [
|
||||
{ name: "User-Agent", value: "yaak" },
|
||||
{ name: "Content-Type", value: "application/x-www-form-urlencoded" },
|
||||
];
|
||||
|
||||
test("keeps generated entries that are not overridden", () => {
|
||||
const merged = mergeHeaders(generated, [{ name: "Origin", value: "https://app.example.com" }]);
|
||||
|
||||
expect(merged).toEqual([...generated, { name: "Origin", value: "https://app.example.com" }]);
|
||||
});
|
||||
|
||||
test("replaces generated headers by name, ignoring case", () => {
|
||||
const merged = mergeHeaders(generated, [{ name: "content-type", value: "application/json" }]);
|
||||
|
||||
expect(merged).toEqual([
|
||||
{ name: "User-Agent", value: "yaak" },
|
||||
{ name: "content-type", value: "application/json" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("keeps repeated custom entries of the same name", () => {
|
||||
const merged = mergeHeaders(generated, [
|
||||
{ name: "X-Trace", value: "a" },
|
||||
{ name: "X-Trace", value: "b" },
|
||||
]);
|
||||
|
||||
expect(merged.filter((h) => h.name === "X-Trace")).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("matches form params case-sensitively", () => {
|
||||
const merged = mergeFormParams(
|
||||
[{ name: "scope", value: "openid" }],
|
||||
[{ name: "Scope", value: "other" }],
|
||||
);
|
||||
|
||||
expect(merged).toEqual([
|
||||
{ name: "scope", value: "openid" },
|
||||
{ name: "Scope", value: "other" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyQueryParams", () => {
|
||||
test("appends custom params and overrides generated ones", () => {
|
||||
const url = new URL("https://auth.example.com/authorize?client_id=abc&scope=openid");
|
||||
|
||||
applyQueryParams(url, [
|
||||
{ name: "scope", value: "openid email" },
|
||||
{ name: "realm", value: "employees" },
|
||||
]);
|
||||
|
||||
expect(url.searchParams.get("client_id")).toBe("abc");
|
||||
expect(url.searchParams.getAll("scope")).toEqual(["openid email"]);
|
||||
expect(url.searchParams.get("realm")).toBe("employees");
|
||||
});
|
||||
|
||||
test("keeps repeated custom params", () => {
|
||||
const url = new URL("https://auth.example.com/authorize");
|
||||
|
||||
applyQueryParams(url, [
|
||||
{ name: "resource", value: "one" },
|
||||
{ name: "resource", value: "two" },
|
||||
]);
|
||||
|
||||
expect(url.searchParams.getAll("resource")).toEqual(["one", "two"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readCustomParams", () => {
|
||||
test("sends token entries on the refresh request too", () => {
|
||||
const custom = readCustomParams({
|
||||
tokenHeaders: pairs({ name: "Origin", value: "https://app.example.com" }),
|
||||
tokenBodyParams: pairs({ name: "realm", value: "employees" }),
|
||||
});
|
||||
|
||||
expect(custom.refresh.headers).toEqual([{ name: "Origin", value: "https://app.example.com" }]);
|
||||
expect(custom.refresh.body).toEqual([{ name: "realm", value: "employees" }]);
|
||||
});
|
||||
|
||||
test("lets a refresh entry override the token entry of the same name", () => {
|
||||
const custom = readCustomParams({
|
||||
tokenHeaders: pairs({ name: "Origin", value: "https://app.example.com" }),
|
||||
refreshHeaders: pairs({ name: "origin", value: "https://refresh.example.com" }),
|
||||
tokenBodyParams: pairs({ name: "realm", value: "employees" }),
|
||||
refreshBodyParams: pairs({ name: "realm", value: "service" }),
|
||||
});
|
||||
|
||||
// The token request is unaffected by the refresh-only entries
|
||||
expect(custom.token.headers).toEqual([{ name: "Origin", value: "https://app.example.com" }]);
|
||||
expect(custom.token.body).toEqual([{ name: "realm", value: "employees" }]);
|
||||
|
||||
expect(custom.refresh.headers).toEqual([
|
||||
{ name: "origin", value: "https://refresh.example.com" },
|
||||
]);
|
||||
expect(custom.refresh.body).toEqual([{ name: "realm", value: "service" }]);
|
||||
});
|
||||
|
||||
test("reads authorization params separately", () => {
|
||||
const custom = readCustomParams({
|
||||
authorizationParams: pairs({ name: "prompt", value: "consent" }),
|
||||
});
|
||||
|
||||
expect(custom.authorizationQuery).toEqual([{ name: "prompt", value: "consent" }]);
|
||||
expect(custom.token.headers).toEqual([]);
|
||||
});
|
||||
|
||||
test("is empty when nothing is configured", () => {
|
||||
const custom = readCustomParams({ clientId: "abc" });
|
||||
|
||||
expect(custom).toEqual({
|
||||
authorizationQuery: [],
|
||||
token: { headers: [], body: [] },
|
||||
refresh: { headers: [], body: [] },
|
||||
});
|
||||
});
|
||||
});
|
||||
198
plugins/auth-oauth2/tests/fetchAccessToken.test.ts
Normal file
198
plugins/auth-oauth2/tests/fetchAccessToken.test.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import type { HttpRequest } from "@yaakapp/api";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { fetchAccessToken } from "../src/fetchAccessToken";
|
||||
|
||||
/**
|
||||
* Captures the request handed to ctx.httpRequest.send so tests can assert on the
|
||||
* form body, and replies with a minimal successful token response.
|
||||
*/
|
||||
function createMockContext() {
|
||||
const sent: Partial<HttpRequest>[] = [];
|
||||
|
||||
const ctx = {
|
||||
httpRequest: {
|
||||
async send({ httpRequest }: { httpRequest: Partial<HttpRequest> }) {
|
||||
sent.push(httpRequest);
|
||||
return {
|
||||
httpResponse: { status: 200, error: null },
|
||||
body: {
|
||||
async text() {
|
||||
return JSON.stringify({ access_token: "token-123" });
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
} as never;
|
||||
|
||||
return { ctx, sent };
|
||||
}
|
||||
|
||||
function formNames(httpRequest: Partial<HttpRequest>) {
|
||||
return (httpRequest.body?.form ?? []).map((p: { name: string }) => p.name);
|
||||
}
|
||||
|
||||
function formValue(httpRequest: Partial<HttpRequest>, name: string) {
|
||||
return (httpRequest.body?.form ?? []).find((p: { name: string }) => p.name === name)?.value;
|
||||
}
|
||||
|
||||
function headerValue(httpRequest: Partial<HttpRequest>, name: string) {
|
||||
return (httpRequest.headers ?? []).find(
|
||||
(h: { name: string }) => h.name.toLowerCase() === name.toLowerCase(),
|
||||
)?.value;
|
||||
}
|
||||
|
||||
const baseArgs = {
|
||||
clientId: "client-123",
|
||||
accessTokenUrl: "https://auth.example.com/token",
|
||||
scope: "openid profile",
|
||||
audience: null,
|
||||
clientSecret: "secret",
|
||||
credentialsInBody: true,
|
||||
params: [],
|
||||
};
|
||||
|
||||
describe("fetchAccessToken scope handling", () => {
|
||||
test("omits scope for the authorization code grant", async () => {
|
||||
const { ctx, sent } = createMockContext();
|
||||
|
||||
await fetchAccessToken(ctx, {
|
||||
...baseArgs,
|
||||
grantType: "authorization_code",
|
||||
params: [{ name: "code", value: "abc" }],
|
||||
});
|
||||
|
||||
expect(formNames(sent[0]!)).not.toContain("scope");
|
||||
// The rest of the request is untouched
|
||||
expect(formValue(sent[0]!, "grant_type")).toBe("authorization_code");
|
||||
expect(formValue(sent[0]!, "code")).toBe("abc");
|
||||
});
|
||||
|
||||
test("sends scope for the client credentials grant", async () => {
|
||||
const { ctx, sent } = createMockContext();
|
||||
|
||||
await fetchAccessToken(ctx, { ...baseArgs, grantType: "client_credentials" });
|
||||
|
||||
expect(formValue(sent[0]!, "scope")).toBe("openid profile");
|
||||
});
|
||||
|
||||
test("sends scope for the password grant", async () => {
|
||||
const { ctx, sent } = createMockContext();
|
||||
|
||||
await fetchAccessToken(ctx, { ...baseArgs, grantType: "password" });
|
||||
|
||||
expect(formValue(sent[0]!, "scope")).toBe("openid profile");
|
||||
});
|
||||
|
||||
test("still sends audience for the authorization code grant", async () => {
|
||||
const { ctx, sent } = createMockContext();
|
||||
|
||||
await fetchAccessToken(ctx, {
|
||||
...baseArgs,
|
||||
grantType: "authorization_code",
|
||||
audience: "https://api.example.com",
|
||||
});
|
||||
|
||||
expect(formValue(sent[0]!, "audience")).toBe("https://api.example.com");
|
||||
expect(formNames(sent[0]!)).not.toContain("scope");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchAccessToken custom parameters", () => {
|
||||
test("sends a custom header with the token request", async () => {
|
||||
const { ctx, sent } = createMockContext();
|
||||
|
||||
await fetchAccessToken(ctx, {
|
||||
...baseArgs,
|
||||
grantType: "authorization_code",
|
||||
custom: {
|
||||
headers: [{ name: "Origin", value: "https://app.example.com" }],
|
||||
body: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(headerValue(sent[0]!, "Origin")).toBe("https://app.example.com");
|
||||
});
|
||||
|
||||
test("keeps the generated headers that are not overridden", async () => {
|
||||
const { ctx, sent } = createMockContext();
|
||||
|
||||
await fetchAccessToken(ctx, {
|
||||
...baseArgs,
|
||||
grantType: "client_credentials",
|
||||
credentialsInBody: false,
|
||||
custom: {
|
||||
headers: [{ name: "Origin", value: "https://app.example.com" }],
|
||||
body: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(headerValue(sent[0]!, "User-Agent")).toBe("yaak");
|
||||
expect(headerValue(sent[0]!, "Content-Type")).toBe("application/x-www-form-urlencoded");
|
||||
expect(headerValue(sent[0]!, "Accept")).toBe(
|
||||
"application/x-www-form-urlencoded, application/json",
|
||||
);
|
||||
// Basic credentials still go out untouched
|
||||
expect(headerValue(sent[0]!, "Authorization")).toMatch(/^Basic /);
|
||||
});
|
||||
|
||||
test("replaces a generated header of the same name", async () => {
|
||||
const { ctx, sent } = createMockContext();
|
||||
|
||||
await fetchAccessToken(ctx, {
|
||||
...baseArgs,
|
||||
grantType: "client_credentials",
|
||||
credentialsInBody: false,
|
||||
custom: {
|
||||
headers: [
|
||||
{ name: "content-type", value: "application/json" },
|
||||
{ name: "Authorization", value: "Custom abc123" },
|
||||
],
|
||||
body: [],
|
||||
},
|
||||
});
|
||||
|
||||
const contentTypes = (sent[0]!.headers ?? []).filter(
|
||||
(h: { name: string }) => h.name.toLowerCase() === "content-type",
|
||||
);
|
||||
expect(contentTypes).toEqual([{ name: "content-type", value: "application/json" }]);
|
||||
expect(headerValue(sent[0]!, "Authorization")).toBe("Custom abc123");
|
||||
});
|
||||
|
||||
test("sends custom body params in the form-encoded body", async () => {
|
||||
const { ctx, sent } = createMockContext();
|
||||
|
||||
await fetchAccessToken(ctx, {
|
||||
...baseArgs,
|
||||
grantType: "password",
|
||||
params: [{ name: "username", value: "alice" }],
|
||||
custom: {
|
||||
headers: [],
|
||||
body: [{ name: "realm", value: "employees" }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(sent[0]!.bodyType).toBe("application/x-www-form-urlencoded");
|
||||
expect(formValue(sent[0]!, "realm")).toBe("employees");
|
||||
// Generated params survive alongside it
|
||||
expect(formValue(sent[0]!, "grant_type")).toBe("password");
|
||||
expect(formValue(sent[0]!, "username")).toBe("alice");
|
||||
expect(formValue(sent[0]!, "client_id")).toBe("client-123");
|
||||
});
|
||||
|
||||
test("replaces a generated body param of the same name", async () => {
|
||||
const { ctx, sent } = createMockContext();
|
||||
|
||||
await fetchAccessToken(ctx, {
|
||||
...baseArgs,
|
||||
grantType: "client_credentials",
|
||||
custom: {
|
||||
headers: [],
|
||||
body: [{ name: "scope", value: "custom-scope" }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(formNames(sent[0]!).filter((n: string) => n === "scope")).toHaveLength(1);
|
||||
expect(formValue(sent[0]!, "scope")).toBe("custom-scope");
|
||||
});
|
||||
});
|
||||
141
plugins/auth-oauth2/tests/getOrRefreshAccessToken.test.ts
Normal file
141
plugins/auth-oauth2/tests/getOrRefreshAccessToken.test.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import type { HttpRequest } from "@yaakapp/api";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { getOrRefreshAccessToken } from "../src/getOrRefreshAccessToken";
|
||||
import type { TokenStoreArgs } from "../src/store";
|
||||
import { storeToken } from "../src/store";
|
||||
|
||||
/**
|
||||
* Captures the refresh request handed to ctx.httpRequest.send and replies with
|
||||
* a minimal successful token response.
|
||||
*/
|
||||
function createMockContext() {
|
||||
const sent: Partial<HttpRequest>[] = [];
|
||||
const values = new Map<string, unknown>();
|
||||
|
||||
const ctx = {
|
||||
store: {
|
||||
async set<T>(key: string, value: T) {
|
||||
values.set(key, value);
|
||||
},
|
||||
async get<T>(key: string) {
|
||||
return values.get(key) as T | undefined;
|
||||
},
|
||||
async delete(key: string) {
|
||||
return values.delete(key);
|
||||
},
|
||||
},
|
||||
httpRequest: {
|
||||
async send({ httpRequest }: { httpRequest: Partial<HttpRequest> }) {
|
||||
sent.push(httpRequest);
|
||||
return {
|
||||
httpResponse: { status: 200, error: null },
|
||||
body: {
|
||||
async text() {
|
||||
return JSON.stringify({ access_token: "refreshed-token" });
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
} as never;
|
||||
|
||||
return { ctx, sent };
|
||||
}
|
||||
|
||||
const tokenArgs: TokenStoreArgs = {
|
||||
contextId: "request-1",
|
||||
clientId: "client-123",
|
||||
accessTokenUrl: "https://auth.example.com/token",
|
||||
authorizationUrl: null,
|
||||
};
|
||||
|
||||
const refreshArgs = {
|
||||
accessTokenUrl: "https://auth.example.com/token",
|
||||
scope: "openid",
|
||||
clientId: "client-123",
|
||||
clientSecret: "secret",
|
||||
credentialsInBody: true,
|
||||
forceRefresh: true,
|
||||
};
|
||||
|
||||
function headerValue(httpRequest: Partial<HttpRequest>, name: string) {
|
||||
return (httpRequest.headers ?? []).find(
|
||||
(h: { name: string }) => h.name.toLowerCase() === name.toLowerCase(),
|
||||
)?.value;
|
||||
}
|
||||
|
||||
function formValue(httpRequest: Partial<HttpRequest>, name: string) {
|
||||
return (httpRequest.body?.form ?? []).find((p: { name: string }) => p.name === name)?.value;
|
||||
}
|
||||
|
||||
async function seedToken(ctx: never) {
|
||||
await storeToken(ctx, tokenArgs, {
|
||||
access_token: "old-token",
|
||||
refresh_token: "refresh-123",
|
||||
});
|
||||
}
|
||||
|
||||
describe("getOrRefreshAccessToken custom parameters", () => {
|
||||
test("sends custom headers and body params with the refresh request", async () => {
|
||||
const { ctx, sent } = createMockContext();
|
||||
await seedToken(ctx);
|
||||
|
||||
const token = await getOrRefreshAccessToken(ctx, tokenArgs, {
|
||||
...refreshArgs,
|
||||
custom: {
|
||||
headers: [{ name: "Origin", value: "https://app.example.com" }],
|
||||
body: [{ name: "realm", value: "employees" }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(token?.response.access_token).toBe("refreshed-token");
|
||||
expect(headerValue(sent[0]!, "Origin")).toBe("https://app.example.com");
|
||||
expect(formValue(sent[0]!, "realm")).toBe("employees");
|
||||
// Generated entries survive
|
||||
expect(headerValue(sent[0]!, "User-Agent")).toBe("yaak");
|
||||
expect(formValue(sent[0]!, "grant_type")).toBe("refresh_token");
|
||||
expect(formValue(sent[0]!, "refresh_token")).toBe("refresh-123");
|
||||
expect(formValue(sent[0]!, "client_secret")).toBe("secret");
|
||||
});
|
||||
|
||||
test("replaces generated entries of the same name", async () => {
|
||||
const { ctx, sent } = createMockContext();
|
||||
await seedToken(ctx);
|
||||
|
||||
await getOrRefreshAccessToken(ctx, tokenArgs, {
|
||||
...refreshArgs,
|
||||
custom: {
|
||||
headers: [{ name: "user-agent", value: "custom-agent" }],
|
||||
body: [{ name: "scope", value: "custom-scope" }],
|
||||
},
|
||||
});
|
||||
|
||||
const userAgents = (sent[0]!.headers ?? []).filter(
|
||||
(h: { name: string }) => h.name.toLowerCase() === "user-agent",
|
||||
);
|
||||
expect(userAgents).toEqual([{ name: "user-agent", value: "custom-agent" }]);
|
||||
|
||||
const scopes = (sent[0]!.body?.form ?? []).filter((p: { name: string }) => p.name === "scope");
|
||||
expect(scopes).toEqual([{ name: "scope", value: "custom-scope" }]);
|
||||
});
|
||||
|
||||
test("sends the generated request untouched with no custom parameters", async () => {
|
||||
const { ctx, sent } = createMockContext();
|
||||
await seedToken(ctx);
|
||||
|
||||
await getOrRefreshAccessToken(ctx, tokenArgs, refreshArgs);
|
||||
|
||||
expect((sent[0]!.headers ?? []).map((h: { name: string }) => h.name)).toEqual([
|
||||
"User-Agent",
|
||||
"Accept",
|
||||
"Content-Type",
|
||||
]);
|
||||
expect((sent[0]!.body?.form ?? []).map((p: { name: string }) => p.name)).toEqual([
|
||||
"grant_type",
|
||||
"refresh_token",
|
||||
"scope",
|
||||
"client_id",
|
||||
"client_secret",
|
||||
]);
|
||||
});
|
||||
});
|
||||
56
plugins/auth-oauth2/tests/store.test.ts
Normal file
56
plugins/auth-oauth2/tests/store.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import type { TokenStoreArgs } from "../src/store";
|
||||
import { getToken, storeToken } from "../src/store";
|
||||
|
||||
function createMockContext() {
|
||||
const values = new Map<string, unknown>();
|
||||
|
||||
return {
|
||||
store: {
|
||||
async set<T>(key: string, value: T) {
|
||||
values.set(key, value);
|
||||
},
|
||||
async get<T>(key: string) {
|
||||
return values.get(key) as T | undefined;
|
||||
},
|
||||
},
|
||||
} as never;
|
||||
}
|
||||
|
||||
describe("token store", () => {
|
||||
test("separates password grant tokens when the username changes", async () => {
|
||||
const ctx = createMockContext();
|
||||
|
||||
const aliceArgs: TokenStoreArgs = {
|
||||
contextId: "request-1",
|
||||
clientId: "client-123",
|
||||
accessTokenUrl: "https://auth.example.com/token",
|
||||
authorizationUrl: null,
|
||||
username: "alice@example.com",
|
||||
};
|
||||
const bobArgs: TokenStoreArgs = { ...aliceArgs, username: "bob@example.com" };
|
||||
|
||||
await storeToken(ctx, aliceArgs, { access_token: "alice-token" });
|
||||
|
||||
expect((await getToken(ctx, aliceArgs))?.response.access_token).toBe("alice-token");
|
||||
expect(await getToken(ctx, bobArgs)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("keeps the same key for grants without a username", async () => {
|
||||
const ctx = createMockContext();
|
||||
|
||||
const args: TokenStoreArgs = {
|
||||
contextId: "request-1",
|
||||
clientId: "client-123",
|
||||
accessTokenUrl: "https://auth.example.com/token",
|
||||
authorizationUrl: null,
|
||||
};
|
||||
|
||||
await storeToken(ctx, args, { access_token: "cc-token" });
|
||||
|
||||
expect((await getToken(ctx, { ...args, username: null }))?.response.access_token).toBe(
|
||||
"cc-token",
|
||||
);
|
||||
expect((await getToken(ctx, { ...args, username: "" }))?.response.access_token).toBe("cc-token");
|
||||
});
|
||||
});
|
||||
162
plugins/auth-oauth2/tests/util.test.ts
Normal file
162
plugins/auth-oauth2/tests/util.test.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import jwt from "jsonwebtoken";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { extractCode, isTokenExpired, jwtExpiresAt } from "../src/util";
|
||||
|
||||
describe("extractCode", () => {
|
||||
test("extracts code from query when same origin + path", () => {
|
||||
const url = "https://app.example.com/cb?code=abc123&state=xyz";
|
||||
const redirect = "https://app.example.com/cb";
|
||||
expect(extractCode(url, redirect)).toBe("abc123");
|
||||
});
|
||||
|
||||
test("extracts code from query with weird path", () => {
|
||||
const url = "https://app.example.com/cbwithextra?code=abc123&state=xyz";
|
||||
const redirect = "https://app.example.com/cb";
|
||||
expect(extractCode(url, redirect)).toBeNull();
|
||||
});
|
||||
|
||||
test("allows trailing slash differences", () => {
|
||||
expect(extractCode("https://app.example.com/cb/?code=abc", "https://app.example.com/cb")).toBe(
|
||||
"abc",
|
||||
);
|
||||
expect(extractCode("https://app.example.com/cb?code=abc", "https://app.example.com/cb/")).toBe(
|
||||
"abc",
|
||||
);
|
||||
});
|
||||
|
||||
test("treats default ports as equal (https:443, http:80)", () => {
|
||||
expect(
|
||||
extractCode("https://app.example.com/cb?code=abc", "https://app.example.com:443/cb"),
|
||||
).toBe("abc");
|
||||
expect(extractCode("http://app.example.com/cb?code=abc", "http://app.example.com:80/cb")).toBe(
|
||||
"abc",
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects different port", () => {
|
||||
expect(
|
||||
extractCode("https://app.example.com/cb?code=abc", "https://app.example.com:8443/cb"),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test("rejects different hostname (including subdomain changes)", () => {
|
||||
expect(
|
||||
extractCode("https://evil.example.com/cb?code=abc", "https://app.example.com/cb"),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test("requires path to start with redirect path (ignoring query/hash)", () => {
|
||||
// same origin but wrong path -> null
|
||||
expect(
|
||||
extractCode("https://app.example.com/other?code=abc", "https://app.example.com/cb"),
|
||||
).toBeNull();
|
||||
|
||||
// deeper subpath under the redirect path -> allowed (prefix match)
|
||||
expect(
|
||||
extractCode("https://app.example.com/cb/deep?code=abc", "https://app.example.com/cb"),
|
||||
).toBe("abc");
|
||||
});
|
||||
|
||||
test("works with custom schemes", () => {
|
||||
expect(extractCode("myapp://cb?code=abc", "myapp://cb")).toBe("abc");
|
||||
});
|
||||
|
||||
test("prefers query over fragment when both present", () => {
|
||||
const url = "https://app.example.com/cb?code=queryCode#code=hashCode";
|
||||
const redirect = "https://app.example.com/cb";
|
||||
expect(extractCode(url, redirect)).toBe("queryCode");
|
||||
});
|
||||
|
||||
test("extracts code from fragment when query lacks code", () => {
|
||||
const url = "https://app.example.com/cb#code=fromHash&state=xyz";
|
||||
const redirect = "https://app.example.com/cb";
|
||||
expect(extractCode(url, redirect)).toBe("fromHash");
|
||||
});
|
||||
|
||||
test("returns null if no code present (query or fragment)", () => {
|
||||
const url = "https://app.example.com/cb?state=only";
|
||||
const redirect = "https://app.example.com/cb";
|
||||
expect(extractCode(url, redirect)).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null when provider reports an error", () => {
|
||||
const url = "https://app.example.com/cb?error=access_denied&error_description=oopsy";
|
||||
const redirect = "https://app.example.com/cb";
|
||||
expect(() => extractCode(url, redirect)).toThrow("Failed to authorize: access_denied");
|
||||
});
|
||||
|
||||
test("when redirectUri is null, extracts code from any URL", () => {
|
||||
expect(extractCode("https://random.example.com/whatever?code=abc", null)).toBe("abc");
|
||||
});
|
||||
|
||||
test("handles extra params gracefully", () => {
|
||||
const url = "https://app.example.com/cb?foo=1&bar=2&code=abc&baz=3";
|
||||
const redirect = "https://app.example.com/cb";
|
||||
expect(extractCode(url, redirect)).toBe("abc");
|
||||
});
|
||||
|
||||
test("ignores fragment noise when code is in query", () => {
|
||||
const url = "https://app.example.com/cb?code=abc#some=thing";
|
||||
const redirect = "https://app.example.com/cb";
|
||||
expect(extractCode(url, redirect)).toBe("abc");
|
||||
});
|
||||
|
||||
// If you decide NOT to support fragment-based codes, flip these to expect null or mark as .skip
|
||||
test("supports fragment-only code for response_mode=fragment providers", () => {
|
||||
const url = "https://app.example.com/cb#state=xyz&code=abc";
|
||||
const redirect = "https://app.example.com/cb";
|
||||
expect(extractCode(url, redirect)).toBe("abc");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isTokenExpired", () => {
|
||||
const jwtWithExp = (expSecondsFromNow: number) =>
|
||||
jwt.sign({ exp: Math.floor(Date.now() / 1000) + expSecondsFromNow }, "test-secret");
|
||||
|
||||
test("uses stored expiresAt when present", () => {
|
||||
expect(isTokenExpired({ response: { access_token: "x" }, expiresAt: Date.now() - 1000 })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isTokenExpired({ response: { access_token: "x" }, expiresAt: Date.now() + 10000 })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("falls back to JWT exp claim when expiresAt is null", () => {
|
||||
expect(
|
||||
isTokenExpired({ response: { access_token: jwtWithExp(-60) }, expiresAt: null }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isTokenExpired({ response: { access_token: jwtWithExp(60) }, expiresAt: null }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("treats opaque tokens without expiresAt as non-expiring", () => {
|
||||
expect(isTokenExpired({ response: { access_token: "opaque-token" }, expiresAt: null })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("checks the token that is used as the credential", () => {
|
||||
// Expired id_token credential is not masked by an opaque access_token
|
||||
const token = {
|
||||
response: { access_token: "opaque-token", id_token: jwtWithExp(-60) },
|
||||
expiresAt: null,
|
||||
};
|
||||
expect(isTokenExpired(token, "id_token")).toBe(true);
|
||||
expect(isTokenExpired(token, "access_token")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("jwtExpiresAt", () => {
|
||||
test("extracts exp claim in milliseconds", () => {
|
||||
const exp = Math.floor(Date.now() / 1000) + 300;
|
||||
expect(jwtExpiresAt(jwt.sign({ exp }, "test-secret"))).toBe(exp * 1000);
|
||||
});
|
||||
|
||||
test("returns null for non-JWT or missing tokens", () => {
|
||||
expect(jwtExpiresAt("not-a-jwt")).toBeNull();
|
||||
expect(jwtExpiresAt(undefined)).toBeNull();
|
||||
expect(jwtExpiresAt(jwt.sign({ foo: "bar" }, "test-secret"))).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user