Skip to content

Commit

Permalink
Add a config subcommand, revoke API keys on logout, check authentication
Browse files Browse the repository at this point in the history
This generally increases the level of detail in debug logging, bases off of our
currently unreleased backend to do things like verify credentials and revoke
API keys, and adds a `sindri config list` command to facilitate debugging.

Merges #14
  • Loading branch information
sangaline authored Nov 26, 2023
1 parent b991013 commit a4cfc58
Show file tree
Hide file tree
Showing 22 changed files with 198 additions and 151 deletions.
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,18 @@ This will fetch the latest OpenAPI schema for the Sindri API and autogenerate an
yarn generate-api
```

To develop against unreleased API features, you can use these variants to target a local development server:

```bash
# If you're not using Docker:
yarn generate-api:dev

# Or...

# If you are using Docker:
yarn denerate-api:docker
```

### Linting

To lint the project with Eslint and Prettier:
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"build:watch": "NODE_ENV=development tsup --watch --env.NODE_ENV $NODE_ENV --env.npm_package_version $npm_package_version",
"generate-api": "rm -rf src/lib/api/ && openapi --client axios --input https://forge.sindri.app/api/openapi.json --output src/lib/api/ && prettier --write src/lib/api/**/*",
"generate-api:dev": "rm -rf src/lib/api/ && openapi --client axios --input http://localhost/api/openapi.json --output src/lib/api/ && prettier --write src/lib/api/**/*",
"generate-api:docker": "rm -rf src/lib/api/ && openapi --client axios --input http://host.docker.internal/api/openapi.json --output src/lib/api/ && prettier --write src/lib/api/**/*",
"lint": "eslint '**/*.{js,ts}'",
"format": "prettier --write '**/*.{js,json,md,ts}'",
"type-check": "tsc --noEmit"
Expand Down
28 changes: 27 additions & 1 deletion src/cli/config.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import fs from "fs";
import path from "path";

import { Command } from "@commander-js/extra-typings";
import envPaths from "env-paths";
import { cloneDeep, merge } from "lodash";
import { z } from "zod";

import { logger } from "cli/logging";
import { logger, print } from "cli/logging";
import { OpenAPI } from "lib/api";

const paths = envPaths("sindri", {
suffix: "",
Expand All @@ -17,6 +19,8 @@ const ConfigSchema = z.object({
.nullable(
z.object({
apiKey: z.string(),
apiKeyId: z.string(),
apiKeyName: z.string(),
baseUrl: z.string().url(),
teamId: z.number(),
teamSlug: z.string(),
Expand Down Expand Up @@ -61,6 +65,11 @@ export class Config {
if (!Config.instance) {
this._config = loadConfig();
Config.instance = this;
// Prepare API the client with the loaded credentials.
if (this._config.auth) {
OpenAPI.BASE = this._config.auth.baseUrl;
OpenAPI.TOKEN = this._config.auth.apiKey;
}
}
return Config.instance;
}
Expand All @@ -69,6 +78,10 @@ export class Config {
return cloneDeep(this._config.auth);
}

get config(): ConfigSchema {
return cloneDeep(this._config);
}

update(configData: Partial<ConfigSchema>) {
// Merge and validate the configs.
logger.debug("Merging in config update:");
Expand All @@ -90,3 +103,16 @@ export class Config {
});
}
}

export const configListCommand = new Command()
.name("list")
.description("Show the current config.")
.action(async () => {
const config = new Config();
print(config.config);
});

export const configCommand = new Command()
.name("config")
.description("Commands related to configuration and config files.")
.addCommand(configListCommand);
3 changes: 2 additions & 1 deletion src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { argv, exit } from "process";

import { Command } from "@commander-js/extra-typings";

import { Config } from "cli/config";
import { Config, configCommand } from "cli/config";
import { logger } from "cli/logging";
import { loginCommand } from "cli/login";
import { logoutCommand } from "cli/logout";
Expand All @@ -19,6 +19,7 @@ const program = new Command()
"Disable all logging aside from direct command outputs for programmatic consumption.",
false,
)
.addCommand(configCommand)
.addCommand(loginCommand)
.addCommand(logoutCommand)
.addCommand(whoamiCommand)
Expand Down
66 changes: 53 additions & 13 deletions src/cli/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
import { Config } from "cli/config";
import { logger } from "cli/logging";
import {
ApiError,
AuthorizationService,
InternalService,
OpenAPI,
Expand All @@ -27,20 +28,42 @@ export const loginCommand = new Command()
OpenAPI.BASE,
)
.action(async ({ baseUrl }) => {
// Check if they're already authenticated, and prompt for confirmation if so.
const config = new Config();
const auth = config.auth;
if (auth) {
const proceed = await confirm({
message:
`You are already logged in as ${auth.teamSlug} on ${auth.baseUrl}, ` +
"are you sure you want to proceed?",
default: false,
});
if (!proceed) {
logger.info("Aborting.");
return;
let authenticated: boolean = false;
try {
const teamMeResult = await InternalService.teamMe();
logger.debug("/api/v1/team/me/ response:");
logger.debug(teamMeResult);
authenticated = true;
} catch (error) {
if (error instanceof ApiError && error.status === 401) {
logger.warn(
"Existing credentials found, but invalid. Please continue logging in to update them.",
);
} else {
logger.fatal("An unknown error occurred.");
logger.error(error);
return process.exit(1);
}
}

if (authenticated) {
const proceed = await confirm({
message:
`You are already logged in as ${auth.teamSlug} on ${auth.baseUrl}, ` +
"are you sure you want to proceed?",
default: false,
});
if (!proceed) {
logger.info("Aborting.");
return;
}
}
}

// Collect details for generating an API key.
const username = await input({ message: "Username:" });
const password = await passwordInput({ mask: true, message: "Password:" });
Expand All @@ -57,10 +80,14 @@ export const loginCommand = new Command()
username,
password,
});
logger.debug("/api/token/ response:");
logger.debug(tokenResult);
OpenAPI.TOKEN = tokenResult.access;

// Fetch their teams and have the user select one.
const userResult = await InternalService.userMeWithJwtAuth();
logger.debug("/api/v1/user/me/ response:");
logger.debug(userResult);
const teamId = await select({
message: "Select a Team:",
choices: userResult.teams.map(({ id, slug }) => ({
Expand All @@ -74,18 +101,31 @@ export const loginCommand = new Command()
}

// Generate an API key.
const result = await AuthorizationService.apikeyGenerate({
const apiKeyResult = await AuthorizationService.apikeyGenerate({
username,
password,
name,
});
const apiKey = result.api_key;
if (!apiKey) {
logger.debug("/api/apikey/generate/ response:");
logger.debug(apiKeyResult);
const apiKey = apiKeyResult.api_key;
const apiKeyId = apiKeyResult.id;
const apiKeyName = apiKeyResult.name;
if (!apiKey || !apiKeyId || !apiKeyName) {
throw new Error("Error generating API key.");
}

// Store the new auth information.
config.update({ auth: { apiKey, baseUrl, teamId, teamSlug: team.slug } });
config.update({
auth: {
apiKey,
apiKeyId,
apiKeyName,
baseUrl,
teamId,
teamSlug: team.slug,
},
});
logger.info(
"You have successfully authorized the client with your Sindri account.",
);
Expand Down
26 changes: 25 additions & 1 deletion src/cli/logout.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,44 @@
import { Command } from "@commander-js/extra-typings";
import { confirm } from "@inquirer/prompts";

import { Config } from "cli/config";
import { logger } from "cli/logging";
import { AuthorizationService } from "lib/api";

export const logoutCommand = new Command()
.name("logout")
.description("Remove the current client authorization credentials.")
.action(async () => {
// Authorize the API client.
// Check whether we're currently authenticated.
const config = new Config();
const auth = config.auth;
if (!auth) {
logger.error("You must log in first with `sindri login`.");
return;
}

// Optionally revoke the current key.
const revokeKey = await confirm({
message: `Would you like to also revoke the "${auth.apiKeyName}" API key? (recommended)`,
default: true,
});
if (revokeKey) {
try {
const response = await AuthorizationService.apikeyDelete(auth.apiKeyId);
logger.info(`Successfully revoked "${auth.apiKeyName}" key.`);
logger.debug(`/api/v1/apikey/${auth.apiKeyId}/delete/ response:`);
logger.debug(response);
} catch (error) {
logger.warn(
`Error revoking "${auth.apiKeyName}" key, proceeding to clear credentials anyway.`,
);
logger.error(error);
}
} else {
logger.warn("Skipping revocation of existing key.");
}

// Clear the existing credentials.
config.update({ auth: null });
logger.info("You have successfully logged out.");
});
21 changes: 17 additions & 4 deletions src/cli/whoami.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Command } from "@commander-js/extra-typings";

import { Config } from "cli/config";
import { logger, print } from "cli/logging";
import { ApiError, InternalService } from "lib/api";

export const whoamiCommand = new Command()
.name("whoami")
Expand All @@ -17,8 +18,20 @@ export const whoamiCommand = new Command()
return process.exit(1);
}

// TODO: Use the new "team-me" endpoint to test authentication and fetch the current team.
// This should be deployed soon, and we'll update this method then. For now, we just rely on
// whatever the team slug was when we logged in last.
print(auth.teamSlug);
try {
const response = await InternalService.teamMe();
logger.debug("/api/v1/team/me/ response:");
logger.debug(response);
print(response.team.slug);
} catch (error) {
if (error instanceof ApiError && error.status === 401) {
logger.error(
"Your credentials are invalid. Please log in again with `sindri login`.",
);
} else {
logger.fatal("An unknown error occurred.");
logger.error(error);
return process.exit(1);
}
}
});
5 changes: 1 addition & 4 deletions src/lib/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,15 @@ export type { APIKeyDoesNotExistResponse } from './models/APIKeyDoesNotExistResp
export type { APIKeyErrorResponse } from './models/APIKeyErrorResponse';
export type { APIKeyResponse } from './models/APIKeyResponse';
export type { CircomCircuitInfoResponse } from './models/CircomCircuitInfoResponse';
export { CircomCircuitType } from './models/CircomCircuitType';
export type { CircuitDoesNotExistResponse } from './models/CircuitDoesNotExistResponse';
export { CircuitStatus } from './models/CircuitStatus';
export { CircuitType } from './models/CircuitType';
export type { ComingSoonResponse } from './models/ComingSoonResponse';
export type { ForgeInternalErrorResponse } from './models/ForgeInternalErrorResponse';
export type { ForgeInvalidUploadResponse } from './models/ForgeInvalidUploadResponse';
export type { GnarkCircuitInfoResponse } from './models/GnarkCircuitInfoResponse';
export { GnarkCircuitType } from './models/GnarkCircuitType';
export type { Halo2CircuitInfoResponse } from './models/Halo2CircuitInfoResponse';
export { Halo2CircuitType } from './models/Halo2CircuitType';
export type { NoirCircuitInfoResponse } from './models/NoirCircuitInfoResponse';
export { NoirCircuitType } from './models/NoirCircuitType';
export type { ObtainApikeyInput } from './models/ObtainApikeyInput';
export type { ProofCannotBeCreatedResponse } from './models/ProofCannotBeCreatedResponse';
export type { ProofDoesNotExistResponse } from './models/ProofDoesNotExistResponse';
Expand All @@ -33,6 +29,7 @@ export type { ProofInfoResponse } from './models/ProofInfoResponse';
export { ProofStatus } from './models/ProofStatus';
export type { Schema } from './models/Schema';
export type { TeamDetail } from './models/TeamDetail';
export type { TeamMeResponse } from './models/TeamMeResponse';
export type { TokenObtainPairInputSchema } from './models/TokenObtainPairInputSchema';
export type { TokenObtainPairOutputSchema } from './models/TokenObtainPairOutputSchema';
export type { TokenRefreshInputSchema } from './models/TokenRefreshInputSchema';
Expand Down
9 changes: 5 additions & 4 deletions src/lib/api/models/CircomCircuitInfoResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@
/* tslint:disable */
/* eslint-disable */

import type { CircomCircuitType } from "./CircomCircuitType";
import type { CircuitStatus } from "./CircuitStatus";
import type { CircuitType } from "./CircuitType";

/**
* Response for getting Circom circuit info.
*/
export type CircomCircuitInfoResponse = {
circuit_id: string;
circuit_type: CircuitType;
circuit_name: string;
date_created: string;
status: CircuitStatus;
Expand All @@ -21,14 +22,14 @@ export type CircomCircuitInfoResponse = {
worker_hardware?: Record<string, any>;
verification_key?: Record<string, any>;
error?: string;
circuit_type: CircomCircuitType;
curve?: string;
curve: string;
degree?: number;
num_constraints?: number;
num_outputs?: number;
num_private_inputs?: number;
num_public_inputs?: number;
num_wires?: number;
proving_scheme: string;
trusted_setup_file?: string;
witness_executable?: string;
witness_compiler: string;
};
11 changes: 0 additions & 11 deletions src/lib/api/models/CircomCircuitType.ts

This file was deleted.

4 changes: 2 additions & 2 deletions src/lib/api/models/CircuitDoesNotExistResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
/* eslint-disable */

/**
* Action: Attempt to fetch a circuit with circuit_name.
* Error: A circuit with circuit_name does not exist.
* Action: Attempt to fetch a circuit with circuit_id.
* Error: A circuit with circuit_id does not exist.
*/
export type CircuitDoesNotExistResponse = {
error: string;
Expand Down
1 change: 0 additions & 1 deletion src/lib/api/models/CircuitStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
* CircuitStatus choices
*/
export enum CircuitStatus {
CREATED = "Created",
QUEUED = "Queued",
IN_PROGRESS = "In Progress",
READY = "Ready",
Expand Down
Loading

0 comments on commit a4cfc58

Please sign in to comment.