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
48 changes: 45 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
"fs-extra": "^11.3.0",
"get-port": "^7.1.0",
"livereload": "^0.9.3",
"neverthrow": "^8.2.0",
"open": "^8.4.0",
"prettier": "^2.8.8",
"simple-git": "^3.27.0",
Expand All @@ -72,6 +73,7 @@
"treeify": "^1.1.0",
"tslib": "^2.8.1",
"unzipper": "^0.12.3",
"uuid": "^11.1.0",
"yaml": "^2.8.0"
},
"devDependencies": {
Expand Down
73 changes: 73 additions & 0 deletions src/actions/auth/login.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { AuthService } from "../../infrastructure/services/auth-service.js";
import { ApiService } from "../../infrastructure/services/api-service.js";
import { DirectoryPath } from "../../types/file/directoryPath.js";
import { err, ok, Result } from "neverthrow";
import { v4 as uuid } from "uuid";
import open from "open";
import { setAuthInfo } from "../../client-utils/auth-manager.js";
import { LoginPrompts } from "../../prompts/auth/login.js";
import { getErrorMessage, ServiceError } from "../../infrastructure/api-utils.js";

export class LoginAction {
private readonly authService = new AuthService();
private readonly apiService = new ApiService();
private readonly prompts = new LoginPrompts();

constructor(private readonly configDir: DirectoryPath) {}

public async execute(apiKey: string | undefined = undefined): Promise<Result<string, string>> {
if (!apiKey) {
const result = await this.poolDeviceToken();
return (
await result.asyncMap(async (token) => {
return await this.verifyKeyAndSave(token);
})
).andThen((r) => r);
} else {
return await this.verifyKeyAndSave(apiKey);
}
}

private async poolDeviceToken(): Promise<Result<string, string>> {
const state = uuid();
this.prompts.openBrowser();
await open(this.authService.getDeviceLoginUrl(state));

const timeoutDuration = 5 * 60 * 1000; // 5 minutes in milliseconds
const startTime = Date.now();
const delayMs = 3 * 1000;

while (true) {
if (Date.now() - startTime > timeoutDuration) {
return err("Authentication timed out. Please try again.");
}

const result = await this.authService.getDeviceLoginToken(state);
const token = result.match(
(res) => res.apiKey,
() => {
/* ignore errors */
}
);
if (token) return ok(token);

// eslint-disable-next-line no-undef
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}

private async verifyKeyAndSave(apiKey: string): Promise<Result<string, string>> {
const result = await this.apiService.getAccountInfo(this.configDir, apiKey);
return result.asyncMap(async (info) => {
await setAuthInfo(info.Email, apiKey, false, this.configDir);
return info.Email;
}).mapErr(e => {
switch (e) {
case ServiceError.UnAuthorized:
return "The provided auth key is invalid"
default:
return getErrorMessage(e);
}
});
}
}
6 changes: 3 additions & 3 deletions src/actions/portal/copilot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,10 @@ export class CopilotAction {
return ActionResult.error("Exiting without making any change.");

const response = await this.apiService.getAccountInfo(this.configDir, this.authKey);
if (!response.isSuccess()) {
return ActionResult.error(response.error!);
if (!response.isErr()) {
return ActionResult.error(response._unsafeUnwrapErr());
}
const apiCopilotKey = await this.selectCopilotKey(response.value);
const apiCopilotKey = await this.selectCopilotKey(response._unsafeUnwrap());
if (apiCopilotKey === null) {
return ActionResult.error("No copilot key found for the current subscription. Please contact support at support@apimatic.io.");
}
Expand Down
7 changes: 4 additions & 3 deletions src/client-utils/auth-manager.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as path from "path";
import fs from "fs-extra";
import { DirectoryPath } from "../types/file/directoryPath.js";

export type AuthInfo = {
email: string;
Expand Down Expand Up @@ -32,14 +33,14 @@ export async function setAuthInfo(
email: string,
authKey: string,
isTelemetryOptedOut: boolean,
configDir: string
configDir: DirectoryPath
): Promise<void> {
const credentials: AuthInfo = {
email,
authKey,
APIMATIC_CLI_TELEMETRY_OPTOUT: isTelemetryOptedOut ? "1" : "0"
};
const configFilePath = path.join(configDir, "config.json");
const configFilePath = path.join(configDir.toString(), "config.json");

if (!fs.existsSync(configFilePath)) fs.createFileSync(configFilePath);

Expand All @@ -56,4 +57,4 @@ export async function removeAuthInfo(configDir: string): Promise<void> {
if (!fs.existsSync(configFilePath)) fs.createFileSync(configFilePath);

return await fs.writeFile(configFilePath, JSON.stringify(credentials));
}
}
18 changes: 5 additions & 13 deletions src/client-utils/sdk-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import axios, { AxiosResponse } from "axios";

import { Client } from "@apimatic/sdk";
import { baseURL } from "../config/env.js";
import { setAuthInfo, getAuthInfo, AuthInfo, removeAuthInfo } from "./auth-manager.js";
import { AuthInfo, getAuthInfo, removeAuthInfo, setAuthInfo } from "./auth-manager.js";
import { DirectoryPath } from "../types/file/directoryPath.js";

/**
* The Singleton class defines the `getInstance` method that lets clients access
* the unique singleton instance.
Expand Down Expand Up @@ -41,14 +43,13 @@ export class SDKClient {
const authKey: string = await this.getAuthKey(credentials);
const isTelemetryOptedOut = process.env.APIMATIC_CLI_TELEMETRY_OPTOUT === "1";

await setAuthInfo(email, authKey, isTelemetryOptedOut, configDir);
await setAuthInfo(email, authKey, isTelemetryOptedOut, new DirectoryPath(configDir));

return "✅ Logged in successfully as " + email;
}

public async logout(configDir: string): Promise<string> {
await removeAuthInfo(configDir);

return "Logged out";
}

Expand Down Expand Up @@ -105,15 +106,6 @@ export class SDKClient {
}
};
const response: AxiosResponse = await axios.get(SDKClient.authAPI, config);
const authKey: string = response.data.EncryptedValue;
return authKey;
return response.data.EncryptedValue;
}

public setAuthKey = (authKey: string, configDir: string) => {
const isTelemetryOptedOut = process.env.APIMATIC_CLI_TELEMETRY_OPTOUT === "1";

setAuthInfo("", authKey, isTelemetryOptedOut, configDir);

return "Authentication key successfully set";
};
}
97 changes: 22 additions & 75 deletions src/commands/auth/login.ts
Original file line number Diff line number Diff line change
@@ -1,88 +1,35 @@
import { AxiosError } from "axios";
import { Flags, Command } from "@oclif/core";
import { outro, password, text } from "@clack/prompts";
import { getMessageInRedColor, replaceHTML } from "../../utils/utils.js";
import { SDKClient } from "../../client-utils/sdk-client.js";
import { getAuthInfo } from "../../client-utils/auth-manager.js";
import { Command, Flags } from "@oclif/core";
import { DirectoryPath } from "../../types/file/directoryPath.js";
import { LoginPrompts } from "../../prompts/auth/login.js";
import { LoginAction } from "../../actions/auth/login.js";

export default class Login extends Command {
static description = "Login using your APIMatic credentials or an API Key";

static examples = [`$ apimatic auth:login`, `$ apimatic auth:login --auth-key=xxxxxx`];
static examples = [`apimatic auth:login`, `apimatic auth:login --auth-key={api-key}`];

static flags = {
"auth-key": Flags.string({ default: "", description: "Set authentication key for all commands" })
"auth-key": Flags.string({
description: "Sets authentication key for all commands.",
})
};

async run() {
const { flags } = await this.parse(Login);
const configDir: string = this.config.configDir;
try {
// Check if already logged in
const storedAuthInfo = await getAuthInfo(configDir);
if (storedAuthInfo && storedAuthInfo.authKey) {
if (storedAuthInfo.email) {
return this.log(
`You are already logged in as '${storedAuthInfo.email}'. Use 'auth:logout' to logout before logging in again.`
);
}
return this.log(
`You are already logged in with authentication key. Use 'auth:logout' to logout before logging in again.`
);
}

const client: SDKClient = SDKClient.getInstance();
// If user is setting auth key
if (flags["auth-key"]) {
const response = client.setAuthKey(flags["auth-key"], configDir);
return this.log(response);
}

// If user logs in with email and password
const email = await text({
message: "Enter your registered email:",
validate: (input) => {
if (!input) {
return getMessageInRedColor("Email is required.");
}

const emailRegex =
/^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;

if (!emailRegex.test(input)) {
return getMessageInRedColor("Please enter a valid email address.");
}
}
});
private readonly prompts = new LoginPrompts();

const pass = await password({
message: "Please enter your password:",
validate: (input) => {
if (!input) {
return getMessageInRedColor("Password is required.");
}
}
});

const response: string = await client.login(email as string, pass as string, configDir);

outro(response);
} catch (error) {
if (error && (error as AxiosError).response) {
const apiError = error as AxiosError;
const apiResponse = apiError.response;

if (apiResponse) {
const responseData = apiResponse.data;
async run() {
const {
flags: { "auth-key": authKey }
} = await this.parse(Login);

if (apiResponse.status === 403 && responseData) {
return this.error(replaceHTML(JSON.stringify(responseData)));
} else {
return this.error(apiError.message);
}
}
}
this.error((error as Error).message);
if (authKey === "") {
this.error("Flag --auth-key must not be empty when provided.");
}

const loginAction = new LoginAction(new DirectoryPath(this.config.configDir));
const result = await loginAction.execute(authKey);
result.match(
(email) => this.prompts.loginSuccessful(email),
(error) => this.prompts.logError(error)
);
}
}
Loading