Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,16 +60,23 @@ module.exports = {
"vitest.config.mts",
"public/**",
"build/**",
"**/__mocks__/**",
"**/__tests__/**",

"node_modules/**",
"esbuild.js",
"win",
"mac",
"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"],
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
19 changes: 15 additions & 4 deletions src/drivers/__tests__/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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",
Expand All @@ -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 () => {
Expand All @@ -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",
Expand All @@ -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 () => {
Expand Down
13 changes: 7 additions & 6 deletions src/drivers/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -279,10 +283,7 @@ export const fetchWithStreaming = async function* <T>(
debug,
});
} catch (error) {
if (
error instanceof TypeError &&
(error.message === "fetch failed" || error.message === "terminated")
) {
if (isNetworkError(error)) {
if (debug) {
print2("Network error: " + String(error));
}
Expand Down Expand Up @@ -362,7 +363,7 @@ const baseFetch = async <T>(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;
Expand Down
36 changes: 36 additions & 0 deletions src/plugins/duo/login.ts
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
**/
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<AuthorizeResponse, TokenResponse>(
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`,
};
})
);
2 changes: 2 additions & 0 deletions src/plugins/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion src/types/authUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 5 additions & 0 deletions src/types/org.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,13 @@ type CloudflareOidcPkceProvider = BaseOidcPkceProvider & {
clientSecret: string;
};

type DuoOidcPkceProvider = BaseOidcPkceProvider & {
providerType: "duo";
};

type OidcPkceProvider =
| CloudflareOidcPkceProvider
| DuoOidcPkceProvider
| OktaOidcPkceProvider
| PingIdOidcPkceProvider;

Expand Down
Loading