fix(auth): bound OIDC initiation and transport
This commit is contained in:
@@ -122,9 +122,28 @@ test.each([
|
||||
expect(() => loadAuthenticationConfig(writeFixture(value))).toThrow("authentication configuration is invalid");
|
||||
});
|
||||
|
||||
test("accepts the explicit loopback HTTP OIDC exception", () => {
|
||||
expect(loadAuthenticationConfig(writeFixture(oidcConfig({ publicUrl: "http://127.0.0.1:8787" }))).value.mode)
|
||||
.toBe("oidc");
|
||||
test.each([
|
||||
"http://127.0.0.1:8787",
|
||||
"http://127.255.255.254:8787",
|
||||
"http://[::1]:8787",
|
||||
])("accepts the literal loopback HTTP OIDC exception %s", (publicUrl) => {
|
||||
expect(loadAuthenticationConfig(writeFixture(oidcConfig({ publicUrl }))).value.mode).toBe("oidc");
|
||||
});
|
||||
|
||||
test.each([
|
||||
"http://localhost:8787",
|
||||
"http://loopback.example.test:8787",
|
||||
"http://user@127.0.0.1:8787",
|
||||
"http://127.1:8787",
|
||||
"http://127.0.0.01:8787",
|
||||
"http://0177.0.0.1:8787",
|
||||
"http://0x7f000001:8787",
|
||||
"http://2130706433:8787",
|
||||
"http://[::ffff:127.0.0.1]:8787",
|
||||
"http://128.0.0.1:8787",
|
||||
])("rejects non-canonical or non-loopback HTTP public URL %s", (publicUrl) => {
|
||||
expect(() => loadAuthenticationConfig(writeFixture(oidcConfig({ publicUrl }))))
|
||||
.toThrow("authentication configuration is invalid");
|
||||
});
|
||||
|
||||
test("rejects unknown roles and requires exactly one admin group", () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Fastify from "fastify";
|
||||
import cookie from "@fastify/cookie";
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { registerAuthRoutes } from "../src/auth/routes.js";
|
||||
import type { LoadedAuthConfig, OidcStateRecord } from "../src/auth/types.js";
|
||||
import type { AuthSessionStore } from "../src/auth/session-store.js";
|
||||
@@ -76,8 +76,10 @@ function fixture(options: {
|
||||
const creates: Array<Record<string, unknown>> = [];
|
||||
const createTimes: Array<Date | undefined> = [];
|
||||
const callbacks: URL[] = [];
|
||||
let authorizationRequests = 0;
|
||||
const protocol: OidcProtocol = {
|
||||
authorizationUrl: async ({ state: received, nonce: receivedNonce, codeVerifier }) => {
|
||||
authorizationRequests += 1;
|
||||
expect(received).toBe(state);
|
||||
expect(receivedNonce).toHaveLength(43);
|
||||
expect(codeVerifier).toHaveLength(43);
|
||||
@@ -134,6 +136,7 @@ function fixture(options: {
|
||||
createdApps.push(app);
|
||||
return {
|
||||
app, creates, createTimes, callbacks, stateInputs,
|
||||
authorizationRequests: () => authorizationRequests,
|
||||
setConfig(next: LoadedAuthConfig) { loaded = next; },
|
||||
setProtocolAvailable(available: boolean) { protocolAvailable = available; },
|
||||
stateWasConsumed: () => storedState === undefined,
|
||||
@@ -161,6 +164,42 @@ async function finishOidcLogin(
|
||||
});
|
||||
}
|
||||
|
||||
test("rate limits OIDC initiation before state creation and provider discovery", async () => {
|
||||
const subject = fixture();
|
||||
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
expect((await beginOidcLogin(subject)).response.statusCode).toBe(302);
|
||||
}
|
||||
const limited = await beginOidcLogin(subject);
|
||||
|
||||
expect(limited.response.statusCode).toBe(429);
|
||||
expect(limited.response.json()).toEqual({
|
||||
code: "login_rate_limited",
|
||||
error: "Too many login attempts",
|
||||
});
|
||||
expect(subject.stateInputs).toHaveLength(20);
|
||||
expect(subject.authorizationRequests()).toBe(20);
|
||||
});
|
||||
|
||||
test("OIDC initiation rate-limit capacity expires after ten minutes", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2030-01-02T03:04:05.000Z"));
|
||||
const subject = fixture();
|
||||
try {
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
expect((await beginOidcLogin(subject)).response.statusCode).toBe(302);
|
||||
}
|
||||
expect((await beginOidcLogin(subject)).response.statusCode).toBe(429);
|
||||
|
||||
vi.advanceTimersByTime(10 * 60_000 + 1);
|
||||
|
||||
expect((await beginOidcLogin(subject)).response.statusCode).toBe(302);
|
||||
expect(subject.authorizationRequests()).toBe(21);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test("creates digest-only bound state, maps exact groups, creates a cookie session, and redirects safely", async () => {
|
||||
const subject = fixture();
|
||||
const { response: start, cookie } = await beginOidcLogin(subject);
|
||||
@@ -298,6 +337,20 @@ test.each([
|
||||
expect(subject.callbacks).toEqual([]);
|
||||
});
|
||||
|
||||
test("boundedly burns a canonical state from an oversized callback URL", async () => {
|
||||
const subject = fixture();
|
||||
const { cookie } = await beginOidcLogin(subject);
|
||||
const oversized = `state=${state}&code=good&padding=${"x".repeat(4096)}`;
|
||||
|
||||
const failed = await finishOidcLogin(subject, cookie, oversized);
|
||||
|
||||
expect(failed.statusCode).toBe(401);
|
||||
expectTransactionCleared(failed);
|
||||
expect(subject.stateWasConsumed()).toBe(true);
|
||||
expect(subject.callbacks).toEqual([]);
|
||||
expect((await finishOidcLogin(subject, cookie)).statusCode).toBe(401);
|
||||
});
|
||||
|
||||
test("rejects an empty direct groups claim without creating a session cookie", async () => {
|
||||
const subject = fixture({ identity: {
|
||||
issuer, subject: "user-123", groups: [], tokenExpiresAt: new Date(Date.now() + 60_000),
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
realpathSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
@@ -60,6 +61,7 @@ import {
|
||||
createFileAuthSessionStore,
|
||||
deriveCsrfToken,
|
||||
type AuthSessionStore,
|
||||
type FileAuthSessionStoreOptions,
|
||||
type SessionCreateInput,
|
||||
} from "../src/auth/session-store.js";
|
||||
import { createWindowsAuthStorageBridge } from "../src/auth/windows-auth-storage.js";
|
||||
@@ -104,11 +106,11 @@ function claimPath(rootPath: string, rawState: string): string {
|
||||
return join(rootPath, "oidc", `${createHash("sha256").update(rawState).digest("hex")}.claim`);
|
||||
}
|
||||
|
||||
function validStore(storageRoot: string): AuthSessionStore {
|
||||
function validStore(storageRoot: string, options: FileAuthSessionStoreOptions = {}): AuthSessionStore {
|
||||
return createFileAuthSessionStore(storageRoot, {
|
||||
currentAuthConfigRevision: () => revision,
|
||||
findLocalUser: async () => validLocalUser,
|
||||
});
|
||||
}, options);
|
||||
}
|
||||
|
||||
async function create(
|
||||
@@ -180,6 +182,52 @@ async function isolatedOidcConsumer(storageRoot: string, state: string): Promise
|
||||
return { start: () => child.send("consume"), result };
|
||||
}
|
||||
|
||||
async function isolatedOidcCreator(storageRoot: string, attempts: number): Promise<{
|
||||
start(): void;
|
||||
result: Promise<number>;
|
||||
}> {
|
||||
const child = fork(new URL("./fixtures/oidc-state-creator.mts", import.meta.url), [], {
|
||||
cwd: process.cwd(),
|
||||
execArgv: ["--import", "tsx"],
|
||||
env: {
|
||||
...process.env,
|
||||
THT_TEST_SESSION_ROOT: storageRoot,
|
||||
THT_TEST_OIDC_ATTEMPTS: String(attempts),
|
||||
THT_TEST_OIDC_NOW: base.toISOString(),
|
||||
THT_TEST_OIDC_CAPACITY: "8",
|
||||
},
|
||||
silent: true,
|
||||
});
|
||||
const ready = new Promise<void>((resolve, reject) => {
|
||||
child.once("message", (message) => {
|
||||
if (message === "ready") resolve();
|
||||
else reject(new Error("OIDC creator did not become ready"));
|
||||
});
|
||||
child.once("error", reject);
|
||||
child.once("exit", (code) => {
|
||||
if (code !== null && code !== 0) reject(new Error("OIDC creator exited before ready"));
|
||||
});
|
||||
});
|
||||
const result = new Promise<number>((resolve, reject) => {
|
||||
let outcome: { created: unknown; failed?: unknown; error?: unknown } | undefined;
|
||||
child.on("message", (message) => {
|
||||
if (message && typeof message === "object" && "created" in message) {
|
||||
outcome = message as { created: unknown; failed?: unknown; error?: unknown };
|
||||
}
|
||||
});
|
||||
child.on("error", reject);
|
||||
child.on("exit", (code) => {
|
||||
if (code !== 0 || outcome?.failed === true || typeof outcome?.created !== "number") {
|
||||
reject(new Error(`OIDC creator failed: ${String(outcome?.error)}`));
|
||||
} else {
|
||||
resolve(outcome.created);
|
||||
}
|
||||
});
|
||||
});
|
||||
await ready;
|
||||
return { start: () => child.send("create"), result };
|
||||
}
|
||||
|
||||
describe("file-backed auth session store", () => {
|
||||
test("fails closed and revokes a session when constructed without validity dependencies", async () => {
|
||||
const storageRoot = root();
|
||||
@@ -301,6 +349,49 @@ describe("file-backed auth session store", () => {
|
||||
.resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("rejects an OIDC-state flood without deleting any valid live state", async () => {
|
||||
const storageRoot = root();
|
||||
const store = validStore(storageRoot, { oidcStateCapacity: 8 });
|
||||
const created = await Promise.all(Array.from({ length: 8 }, (_unused, index) =>
|
||||
store.createOidcState(oidcInput(`n${String(index).padStart(42, "0")}`, `v${String(index).padStart(42, "0")}`), base)));
|
||||
|
||||
await expect(store.createOidcState(oidcInput("x".repeat(43), "y".repeat(43)), base))
|
||||
.rejects.toThrow("auth_oidc_state_capacity");
|
||||
await expect(store.consumeOidcState(created[0].state, new Date(base.getTime() + 60_000)))
|
||||
.resolves.toMatchObject({ nonce: "n" + "0".repeat(42) });
|
||||
expect(created.slice(1).every(({ state }) => existsSync(digestPath(storageRoot, "oidc", state)))).toBe(true);
|
||||
});
|
||||
|
||||
test("prunes expired OIDC states before admitting a new transaction at capacity", async () => {
|
||||
const storageRoot = root();
|
||||
const store = validStore(storageRoot, { oidcStateCapacity: 8 });
|
||||
const expired = await Promise.all(Array.from({ length: 8 }, (_unused, index) =>
|
||||
store.createOidcState(oidcInput(`n${String(index).padStart(42, "0")}`, `v${String(index).padStart(42, "0")}`), base)));
|
||||
|
||||
const admitted = await store.createOidcState(
|
||||
oidcInput("x".repeat(43), "y".repeat(43)),
|
||||
new Date(base.getTime() + 10 * 60_000),
|
||||
);
|
||||
|
||||
expect(expired.every(({ state }) => !existsSync(digestPath(storageRoot, "oidc", state)))).toBe(true);
|
||||
expect(existsSync(digestPath(storageRoot, "oidc", admitted.state))).toBe(true);
|
||||
});
|
||||
|
||||
test("bounds OIDC state creation across concurrent Node processes", async () => {
|
||||
const storageRoot = root();
|
||||
const creators = await Promise.all([
|
||||
isolatedOidcCreator(storageRoot, 6),
|
||||
isolatedOidcCreator(storageRoot, 6),
|
||||
]);
|
||||
|
||||
creators.forEach(({ start }) => start());
|
||||
const winners = await Promise.all(creators.map(({ result }) => result));
|
||||
|
||||
expect(winners.reduce((sum, value) => sum + value, 0)).toBe(8);
|
||||
const names = readdirSync(join(storageRoot, "oidc"));
|
||||
expect(names.filter((name) => /^[a-f0-9]{64}\.json$/.test(name))).toHaveLength(8);
|
||||
});
|
||||
|
||||
test("fails closed when an OIDC state already has an atomic filesystem claim", async () => {
|
||||
const storageRoot = root();
|
||||
const store = validStore(storageRoot);
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
import { createFileAuthSessionStore } from "../../src/auth/session-store.js";
|
||||
|
||||
const root = process.env.THT_TEST_SESSION_ROOT;
|
||||
const attempts = Number(process.env.THT_TEST_OIDC_ATTEMPTS);
|
||||
const now = new Date(process.env.THT_TEST_OIDC_NOW ?? "invalid");
|
||||
const capacity = Number(process.env.THT_TEST_OIDC_CAPACITY);
|
||||
if (!root || !Number.isSafeInteger(attempts) || attempts < 1 || Number.isNaN(now.getTime())
|
||||
|| !Number.isSafeInteger(capacity) || capacity < 1) process.exit(2);
|
||||
|
||||
const store = createFileAuthSessionStore(root, undefined, { oidcStateCapacity: capacity });
|
||||
process.send?.("ready");
|
||||
process.once("message", async (message) => {
|
||||
if (message !== "create") process.exit(3);
|
||||
let created = 0;
|
||||
try {
|
||||
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
||||
try {
|
||||
await store.createOidcState({
|
||||
nonce: `n${String(process.pid).padStart(10, "0")}${String(attempt).padStart(32, "0")}`,
|
||||
codeVerifier: `v${String(process.pid).padStart(10, "0")}${String(attempt).padStart(32, "0")}`,
|
||||
returnTo: "/",
|
||||
authConfigRevision: "a".repeat(64),
|
||||
issuer: "https://issuer.example.test",
|
||||
browserTransactionDigest: "b".repeat(64),
|
||||
}, now);
|
||||
created += 1;
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error) || error.message !== "auth_oidc_state_capacity") throw error;
|
||||
}
|
||||
}
|
||||
process.send?.({ created });
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
process.send?.({
|
||||
created,
|
||||
failed: true,
|
||||
error: error instanceof Error ? error.message : "non-error failure",
|
||||
});
|
||||
process.exit(4);
|
||||
}
|
||||
});
|
||||
@@ -30,6 +30,7 @@ function protocol(options: {
|
||||
seen?: URL[];
|
||||
jwksResponse?: (init?: RequestInit) => Response | Promise<Response>;
|
||||
jwksTimeoutMs?: number;
|
||||
discoveryMetadata?: Record<string, unknown>;
|
||||
} = {}) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const claims = {
|
||||
@@ -56,6 +57,7 @@ function protocol(options: {
|
||||
grant_types_supported: ["authorization_code"],
|
||||
subject_types_supported: ["public"],
|
||||
id_token_signing_alg_values_supported: ["RS256"],
|
||||
...options.discoveryMetadata,
|
||||
});
|
||||
}
|
||||
if (url.pathname === "/jwks") return options.jwksResponse ? await options.jwksResponse(init) : Response.json({ keys: [jwk] });
|
||||
@@ -116,6 +118,47 @@ test("rejects non-HTTPS issuer configuration and a discovery issuer mismatch", a
|
||||
.rejects.toThrow(OidcProtocolError);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["authorization", { authorization_endpoint: "http://127.0.0.1/authorize" }],
|
||||
["token", { token_endpoint: "http://127.0.0.1/token" }],
|
||||
["JWKS", { jwks_uri: "http://127.0.0.1/jwks" }],
|
||||
])("keeps the discovered %s endpoint HTTPS-only", async (_label, discoveryMetadata) => {
|
||||
const subject = protocol({ discoveryMetadata });
|
||||
await expect(subject.authorizationUrl({ state, nonce, codeVerifier: verifier }))
|
||||
.rejects.toThrow(OidcProtocolError);
|
||||
});
|
||||
|
||||
test.each([
|
||||
"http://127.0.0.1:3000/api/auth/oidc/callback",
|
||||
"http://127.255.255.254/api/auth/oidc/callback",
|
||||
"http://[::1]:3000/api/auth/oidc/callback",
|
||||
"https://thothii.example.test/api/auth/oidc/callback",
|
||||
])("accepts the configured callback URL %s", (configuredCallbackUrl) => {
|
||||
expect(() => createOidcProtocol({
|
||||
issuer, clientId, clientSecret: "secret", callbackUrl: configuredCallbackUrl,
|
||||
scopes: ["openid"], groupsClaim: "groups",
|
||||
})).not.toThrow();
|
||||
});
|
||||
|
||||
test.each([
|
||||
"http://localhost/api/auth/oidc/callback",
|
||||
"http://loopback.example.test/api/auth/oidc/callback",
|
||||
"http://user@127.0.0.1/api/auth/oidc/callback",
|
||||
"http://127.1/api/auth/oidc/callback",
|
||||
"http://127.0.0.01/api/auth/oidc/callback",
|
||||
"http://0177.0.0.1/api/auth/oidc/callback",
|
||||
"http://0x7f000001/api/auth/oidc/callback",
|
||||
"http://2130706433/api/auth/oidc/callback",
|
||||
"http://[::ffff:127.0.0.1]/api/auth/oidc/callback",
|
||||
"http://128.0.0.1/api/auth/oidc/callback",
|
||||
"http://192.168.1.1/api/auth/oidc/callback",
|
||||
])("rejects the non-canonical or non-loopback HTTP callback URL %s", (configuredCallbackUrl) => {
|
||||
expect(() => createOidcProtocol({
|
||||
issuer, clientId, clientSecret: "secret", callbackUrl: configuredCallbackUrl,
|
||||
scopes: ["openid"], groupsClaim: "groups",
|
||||
})).toThrow(OidcProtocolError);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["state", new URL(`${callbackUrl}?code=good&state=wrong`), {}],
|
||||
["nonce", new URL(`${callbackUrl}?code=good&state=${state}`), { nonce: "wrong" }],
|
||||
@@ -151,6 +194,7 @@ test("aborts a hanging JWKS request at the configured timeout", async () => {
|
||||
|
||||
test("rejects an oversized JWKS Content-Length before reading the body", async () => {
|
||||
let pulls = 0;
|
||||
let cancelled = false;
|
||||
const body = new ReadableStream({
|
||||
type: "bytes",
|
||||
pull(controller) {
|
||||
@@ -158,12 +202,43 @@ test("rejects an oversized JWKS Content-Length before reading the body", async (
|
||||
controller.enqueue(new TextEncoder().encode("{}"));
|
||||
controller.close();
|
||||
},
|
||||
cancel() { cancelled = true; },
|
||||
});
|
||||
const subject = protocol({
|
||||
jwksResponse: () => new Response(body, { headers: { "content-length": String(1024 * 1024 + 1) } }),
|
||||
});
|
||||
await expect(callback(subject)).rejects.toThrow(OidcProtocolError);
|
||||
expect(pulls).toBe(0);
|
||||
expect(cancelled).toBe(true);
|
||||
expect(body.locked).toBe(false);
|
||||
});
|
||||
|
||||
test("cancels and releases a JWKS stream with an invalid Content-Length", async () => {
|
||||
let cancelled = false;
|
||||
const body = new ReadableStream({
|
||||
pull() { /* remains pending until the response is rejected and cancelled */ },
|
||||
cancel() { cancelled = true; },
|
||||
});
|
||||
const subject = protocol({
|
||||
jwksResponse: () => new Response(body, { headers: { "content-length": "not-a-number" } }),
|
||||
});
|
||||
|
||||
await expect(callback(subject)).rejects.toThrow(OidcProtocolError);
|
||||
expect(cancelled).toBe(true);
|
||||
expect(body.locked).toBe(false);
|
||||
});
|
||||
|
||||
test("cancels and releases a non-success JWKS response body", async () => {
|
||||
let cancelled = false;
|
||||
const body = new ReadableStream({
|
||||
pull() { /* remains pending until the response is rejected and cancelled */ },
|
||||
cancel() { cancelled = true; },
|
||||
});
|
||||
const subject = protocol({ jwksResponse: () => new Response(body, { status: 503 }) });
|
||||
|
||||
await expect(callback(subject)).rejects.toThrow(OidcProtocolError);
|
||||
expect(cancelled).toBe(true);
|
||||
expect(body.locked).toBe(false);
|
||||
});
|
||||
|
||||
test("stops streaming a JWKS response as soon as the byte limit is exceeded", async () => {
|
||||
@@ -186,6 +261,7 @@ test("stops streaming a JWKS response as soon as the byte limit is exceeded", as
|
||||
await expect(callback(subject)).rejects.toThrow(OidcProtocolError);
|
||||
expect(pulls).toBe(2);
|
||||
expect(cancelled).toBe(true);
|
||||
expect(body.locked).toBe(false);
|
||||
});
|
||||
|
||||
test.each([
|
||||
|
||||
@@ -80,6 +80,42 @@ function bridgeForChild(child: FakeBridgeChild) {
|
||||
}
|
||||
|
||||
describe("Windows auth-storage bridge", () => {
|
||||
test("permits reservation slots only for OIDC record operations", async () => {
|
||||
const requests: Array<Record<string, unknown>> = [];
|
||||
const bridge = createWindowsAuthStorageBridge({
|
||||
thtExecutable: "C:\\tht.exe",
|
||||
invoke: async ({ input }) => {
|
||||
const request = JSON.parse(input.toString("utf8")) as Record<string, unknown>;
|
||||
requests.push(request);
|
||||
const operation = request.operation;
|
||||
const body = operation === "create"
|
||||
? { created: true }
|
||||
: operation === "read"
|
||||
? { found: true, contentBase64: Buffer.from("slot").toString("base64") }
|
||||
: operation === "remove"
|
||||
? { removed: true }
|
||||
: { entries: [{ name: "slot-00.json", modifiedUnixMs: 1 }] };
|
||||
return {
|
||||
code: 0,
|
||||
stdout: Buffer.from(`${JSON.stringify({ version: 1, ok: true, ...body })}\n`),
|
||||
stderr: Buffer.alloc(0),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
await expect(bridge.create("C:\\auth", "oidc", "slot-00.json", Buffer.from("slot"))).resolves.toBe(true);
|
||||
await expect(bridge.read("C:\\auth", "oidc", "slot-00.json")).resolves.toEqual(Buffer.from("slot"));
|
||||
await expect(bridge.list("C:\\auth", "oidc")).resolves.toEqual([
|
||||
{ name: "slot-00.json", modifiedUnixMs: 1 },
|
||||
]);
|
||||
await expect(bridge.remove("C:\\auth", "oidc", "slot-00.json")).resolves.toBe(true);
|
||||
await expect(bridge.create("C:\\auth", "sessions", "slot-00.json", Buffer.from("slot")))
|
||||
.rejects.toThrow("auth_session_store_invalid");
|
||||
await expect(bridge.create("C:\\auth", "oidc", "slot-64.json", Buffer.from("slot")))
|
||||
.rejects.toThrow("auth_session_store_invalid");
|
||||
expect(requests).toHaveLength(4);
|
||||
});
|
||||
|
||||
test("uses hidden tht argv and sends record bytes only over bounded stdin", async () => {
|
||||
const calls: Array<{ executable: string; args: readonly string[]; input: Buffer; timeoutMs: number }> = [];
|
||||
const bridge = createWindowsAuthStorageBridge({
|
||||
|
||||
Reference in New Issue
Block a user