diff --git a/.eslintrc.js b/.eslintrc.js index 154c159c..89db56c3 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -60,8 +60,7 @@ module.exports = { "vitest.config.mts", "public/**", "build/**", - "**/__mocks__/**", - "**/__tests__/**", + "node_modules/**", "esbuild.js", "win", @@ -69,7 +68,15 @@ module.exports = { "preprocess-sea.cjs", "linux", ], - overrides: [], + overrides: [ + { + files: ["**/__tests__/**", "**/__mocks__/**"], + rules: { + // expect(mock.method).toHaveBeenCalled() is a false positive for this rule + "@typescript-eslint/unbound-method": "off", + }, + }, + ], parserOptions: { ecmaVersion: "latest", project: ["./tsconfig.json"], diff --git a/package.json b/package.json index 92ca58a9..62910182 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@p0security/cli", - "version": "0.26.4", + "version": "0.27.0", "description": "Execute infra CLI commands with P0 grants", "main": "index.ts", "repository": { diff --git a/src/drivers/__tests__/api.test.ts b/src/drivers/__tests__/api.test.ts index 7673c76b..206be741 100644 --- a/src/drivers/__tests__/api.test.ts +++ b/src/drivers/__tests__/api.test.ts @@ -18,6 +18,9 @@ vi.mock("../env"); vi.mock("../../version", () => ({ getUserAgent: () => "P0 CLI/1.0.0 (npm build; linux; x64; node/v20.0.0)", })); +vi.mock("../../util", () => ({ + sleep: vi.fn().mockResolvedValue(undefined), +})); describe("fetchWithStreaming", () => { const mockAuthn: Authn = { @@ -364,8 +367,10 @@ describe("fetchWithStreaming", () => { }).rejects.toBe("Something went wrong"); }); - it("should throw network error for terminated", async () => { - vi.spyOn(global, "fetch").mockRejectedValue(new TypeError("terminated")); + it("should retry and then throw network error for terminated", async () => { + const mockFetch = vi + .spyOn(global, "fetch") + .mockRejectedValue(new TypeError("terminated")); const generator = fetchWithStreaming(mockAuthn, { url: "/stream", @@ -377,6 +382,8 @@ describe("fetchWithStreaming", () => { // Should throw before yielding } }).rejects.toBe("Network error: Unable to reach the server."); + // 1 initial attempt + 8 retries + expect(mockFetch).toHaveBeenCalledTimes(9); }); it("should rethrow other errors", async () => { @@ -395,8 +402,10 @@ describe("fetchWithStreaming", () => { }).rejects.toBe(customError); }); - it("should throw network error for fetch failed", async () => { - vi.spyOn(global, "fetch").mockRejectedValue(new TypeError("fetch failed")); + it("should retry and then throw network error for fetch failed", async () => { + const mockFetch = vi + .spyOn(global, "fetch") + .mockRejectedValue(new TypeError("fetch failed")); const generator = fetchWithStreaming(mockAuthn, { url: "/stream", @@ -408,6 +417,8 @@ describe("fetchWithStreaming", () => { // Should throw before yielding } }).rejects.toBe("Network error: Unable to reach the server."); + // 1 initial attempt + 8 retries + expect(mockFetch).toHaveBeenCalledTimes(9); }); it("should throw error when no reader available", async () => { diff --git a/src/drivers/api.ts b/src/drivers/api.ts index 20ebd02b..149715c9 100644 --- a/src/drivers/api.ts +++ b/src/drivers/api.ts @@ -16,11 +16,15 @@ import { print2 } from "./stdio"; import * as path from "node:path"; import yargs from "yargs"; +const isNetworkError = (error: unknown) => + error instanceof TypeError && + (error.message === "fetch failed" || error.message === "terminated"); + // We retry with these delays: 1s, 2s, 4s, 8s, 16s, 30s, 30s, 30s // for a total of 121s wait time over 8 retries (ignoring jitter) const RETRY_OPTIONS = { shouldRetry: (error: unknown) => - error === "HTTP Error: 429 Too Many Requests", + error === "HTTP Error: 429 Too Many Requests" || isNetworkError(error), retries: 8, delayMs: 1_000, multiplier: 2.0, @@ -279,10 +283,7 @@ export const fetchWithStreaming = async function* ( debug, }); } catch (error) { - if ( - error instanceof TypeError && - (error.message === "fetch failed" || error.message === "terminated") - ) { + if (isNetworkError(error)) { if (debug) { print2("Network error: " + String(error)); } @@ -362,7 +363,7 @@ const baseFetch = async (args: { debug: args.debug, }); } catch (error) { - if (error instanceof TypeError && error.message === "fetch failed") { + if (isNetworkError(error)) { throw `Network error: Unable to reach the server at ${url}.`; } else { throw error; diff --git a/src/plugins/duo/login.ts b/src/plugins/duo/login.ts new file mode 100644 index 00000000..576e5d4f --- /dev/null +++ b/src/plugins/duo/login.ts @@ -0,0 +1,36 @@ +/** Copyright © 2024-present P0 Security + +This file is part of @p0security/cli + +@p0security/cli is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, version 3 of the License. + +@p0security/cli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + +You should have received a copy of the GNU General Public License along with @p0security/cli. If not, see . +**/ +import { + getClientId, + getProviderDomain, + getProviderType, +} from "../../types/authUtils"; +import { AuthorizeResponse, TokenResponse } from "../../types/oidc"; +import { OrgData } from "../../types/org"; +import { oidcLogin, oidcLoginSteps } from "../oidc/login"; + +/** Logs in to Duo via OIDC */ +export const duoLogin = async (org: OrgData) => + oidcLogin( + oidcLoginSteps(org, "openid email profile", () => { + const providerType = getProviderType(org); + const providerDomain = getProviderDomain(org); + const clientId = getClientId(org); + + if (providerType !== "duo" || !providerDomain || !clientId) { + throw `Invalid provider ${providerType} (expected duo OIDC provider)`; + } + return { + deviceAuthorizationUrl: `https://${providerDomain}/oidc/${clientId}/device/authorization`, + tokenUrl: `https://${providerDomain}/oidc/${clientId}/token`, + }; + }) + ); diff --git a/src/plugins/login.ts b/src/plugins/login.ts index d49fed46..1810c14c 100644 --- a/src/plugins/login.ts +++ b/src/plugins/login.ts @@ -12,6 +12,7 @@ import { getProviderType } from "../types/authUtils"; import { TokenResponse } from "../types/oidc"; import { OrgData } from "../types/org"; import { azureLogin } from "./azure/login"; +import { duoLogin } from "./duo/login"; import { emailPasswordLogin } from "./email/login"; import { googleLogin } from "./google/login"; import { oktaLogin } from "./okta/login"; @@ -37,6 +38,7 @@ export const pluginLoginMap: Record< google: googleLogin, okta: oktaLogin, ping: pingLogin, + duo: duoLogin, "google-oidc": googleLogin, "oidc-pkce": async (org) => { const providerType = getProviderType(org); diff --git a/src/types/authUtils.ts b/src/types/authUtils.ts index 56bb7a92..a6541f72 100644 --- a/src/types/authUtils.ts +++ b/src/types/authUtils.ts @@ -15,7 +15,7 @@ import type { OrgData } from "./org"; /** Get provider type from org data */ export const getProviderType = ( org: OrgData -): "cloudflare" | "okta" | "ping" | undefined => { +): "cloudflare" | "duo" | "okta" | "ping" | undefined => { return org.auth.type === "sso" && "providerType" in org.auth.provider ? org.auth.provider.providerType : undefined; diff --git a/src/types/org.ts b/src/types/org.ts index 78770aec..8813489d 100644 --- a/src/types/org.ts +++ b/src/types/org.ts @@ -84,8 +84,13 @@ type CloudflareOidcPkceProvider = BaseOidcPkceProvider & { clientSecret: string; }; +type DuoOidcPkceProvider = BaseOidcPkceProvider & { + providerType: "duo"; +}; + type OidcPkceProvider = | CloudflareOidcPkceProvider + | DuoOidcPkceProvider | OktaOidcPkceProvider | PingIdOidcPkceProvider;