diff --git a/packages/auth0/README.md b/packages/auth0/README.md index f89cfb90..ffc7bbf3 100644 --- a/packages/auth0/README.md +++ b/packages/auth0/README.md @@ -1,6 +1,7 @@ # Auth0 simulator -Read about this simulator on our blog: [Simplified Local Development and Testing with Auth0 Simulation](https://frontside.com/blog/2022-01-13-auth0-simulator/). +Read about this simulator on our blog: [Simplified Local Development and Testing with Auth0 +Simulation](https://frontside.com/blog/2022-01-13-auth0-simulator/). ## Table of Contents @@ -11,22 +12,33 @@ Read about this simulator on our blog: [Simplified Local Development and Testing - [Code](#code) - [Example](#example) - [Configuration](#configuration) + - [CLI Flags](#cli-flags) + - [JSON Config Files](#json-config-files) + - [Environment Variables](#environment-variables) + - [Programmatic Options](#programmatic-options) - [Options](#options) - [Rules](#rules) - [Endpoints](#endpoints) Please read the [main README](../../README.md) for more background on simulacrum. -The auth0 simulator has been initially written to mimic the responses of a real auth0 server that is called from auth0 client libraries like [auth0/react](https://auth0.com/docs/quickstart/spa/react/01-login) and [auth0-spa-js](https://github.com/auth0/auth0-spa-js) that use the OpenID [authorization code flow](https://developer.okta.com/docs/concepts/oauth-openid/). +The auth0 simulator has been initially written to mimic the responses of a real auth0 server that is +called from auth0 client libraries like +[auth0/react](https://auth0.com/docs/quickstart/spa/react/01-login) and +[auth0-spa-js](https://github.com/auth0/auth0-spa-js) that use the OpenID [authorization code +flow](https://developer.okta.com/docs/concepts/oauth-openid/). -If this does not meet your needs then please create a github issue to start a conversation about adding new OpenID flows. +If this does not meet your needs then please create a github issue to start a conversation about +adding new OpenID flows. ## Quick Start This quick start assumes you have your own app with Auth0. -> [!IMPORTANT] -> The Auth0 clients expect the server to be served as `https`, and will throw an error if it is served as `http`. Currently, we rely on a certificate available in the home directory. On first run, you will see instructions on how to set up this certificate through `mkcert`. +> [!IMPORTANT] +> The Auth0 clients expect the server to be served as `https`, and will throw an error if it is +> served as `http`. Currently, we rely on a certificate available in the home directory. On first +> run, you will see instructions on how to set up this certificate through `mkcert`. ### Using Default User @@ -36,12 +48,13 @@ You may start a server directly from the command line. npx @simulacrum/auth0-simulator # this will start a simulation server at http://localhost:4400 ``` -Given no further input, it will use the default values as below. This will point your app at the simulation instead of the Auth0 endpoint. +Given no further input, it will use the default values as below. This will point your app at the +simulation instead of the Auth0 endpoint. ```json { "domain": "https://localhost:4400", - "clientId": "00000000000000000000000000000000", + "clientID": "00000000000000000000000000000000", "audience": "https://thefrontside.auth0.com/api/v1/" } ``` @@ -63,29 +76,108 @@ By passing an `initialState`, you may control the initial users in the store. ### Example -The folks at Auth0 maintain many samples such as [github.com/auth0-samples/auth0-react-samples](https://github.com/auth0-samples/auth0-react-samples). Follow the instructions to run the sample, set the configuration in `auth_config.json` to match the defaults as noted above, and run the Auth0 simulation server with `npx auth0-simulator`. +The folks at Auth0 maintain many samples such as +[github.com/auth0-samples/auth0-react-samples](https://github.com/auth0-samples/auth0-react-samples). +Follow the instructions to run the sample, set the configuration in `auth_config.json` to match the +defaults as noted above, and run the Auth0 simulation server with `npx auth0-simulator`. ## Configuration -The Auth0 Simulator uses [cosmiconfig](https://github.com/cosmiconfig/cosmiconfig) to load the configuration options. This provides many options in where to place your configuration. Using the module name, `auth0Simulator`, you could, for example, set your configuration in a `.auth0Simulatorrc.json` file. +The Auth0 Simulator uses [configliere](https://github.com/thefrontside/configliere) to parse +configuration from CLI flags, environment variables, JSON config files, and programmatic options. -### Options +### CLI Flags + +When running from the command line, you can pass configuration as flags: + +```bash +npx @simulacrum/auth0-simulator --port 5000 --audience https://myapp.com/api +``` + +The CLI also accepts an explicit `start` command, though it remains the default: + +```bash +npx @simulacrum/auth0-simulator start --port 5000 +``` + +Run with `--help` to see all available flags: + +```bash +npx @simulacrum/auth0-simulator --help +``` + +### JSON Config Files -The `options` field supports the [auth0 configuration fields](https://auth0.com/docs/quickstart/spa/vanillajs#configure-auth0). The option fields should match the fields in the client application that is calling the auth0 server. +You can stage configuration through a JSON file with `-c` and then override values with CLI flags: -The `scope` also accepts an array of objects containing `clientId`, `scope` and optionally `audience` to enable dynamic scopes from a single simulator. This should allow multiple clients to all use the same simulator. Additionally, setting the `clientId: "default"` will enable a default fallback scope so every client does not need to be included. +```bash +npx @simulacrum/auth0-simulator -c auth0-simulator.json --port 5000 +``` + +Values from CLI flags still take precedence over environment variables and config file values. + +### Environment Variables + +Configuration can also be set via environment variables. Field names are mapped to +`UPPER_SNAKE_CASE`: + +```bash +PORT=5000 AUDIENCE=https://myapp.com/api npx @simulacrum/auth0-simulator +``` + +### Programmatic Options + +When using the simulator as a library, pass options directly: + +```js +import { simulation } from "@simulacrum/auth0-simulator"; + +const app = simulation({ + options: { + port: 5000, + audience: "https://myapp.com/api", + }, +}); +``` + +### Options -An optional [`rulesDirectory` field](#rules) can specify a directory of [auth0 rules](https://auth0.com/docs/rules) code files, more on this [below](#rules). +The `options` field supports the [auth0 configuration +fields](https://auth0.com/docs/quickstart/spa/vanillajs#configure-auth0). The option fields should +match the fields in the client application that is calling the auth0 server. + +| Option | CLI Flag | Env Var | Description | +| ---------------- | ------------------- | ----------------- | -------------------------------- | +| `port` | `--port`, `-p` | `PORT` | Port to listen on | +| `domain` | `--domain` | `DOMAIN` | Server domain | +| `audience` | `--audience` | `AUDIENCE` | Auth0 audience | +| `clientID` | `--client-id` | `CLIENT_ID` | Auth0 client ID | +| `scope` | `--scope` | `SCOPE` | Auth0 scope | +| `clientSecret` | `--client-secret` | `CLIENT_SECRET` | Client secret | +| `rulesDirectory` | `--rules-directory` | `RULES_DIRECTORY` | Directory containing auth0 rules | +| `connection` | `--connection` | `CONNECTION` | Auth0 connection | +| `protocol` | `--protocol` | `PROTOCOL` | Server protocol (https/http) | + +The `scope` also accepts an array of objects containing `clientID`, `scope` and optionally +`audience` to enable dynamic scopes from a single simulator (programmatic usage only). This should +allow multiple clients to all use the same simulator. Additionally, setting the `clientID: +"default"` will enable a default fallback scope so every client does not need to be included. + +An optional [`rulesDirectory` field](#rules) can specify a directory of [auth0 +rules](https://auth0.com/docs/rules) code files, more on this [below](#rules). ### Rules -It is possible to run [auth0 rules](https://auth0.com/docs/rules) if the compiled code files are on disk and all located in the same directory. +It is possible to run [auth0 rules](https://auth0.com/docs/rules) if the compiled code files are on +disk and all located in the same directory. -Set the `rulesDirectory` of the [options field](#options) to a path relative to your current working directory. +Set the `rulesDirectory` of the [options field](#options) to a path relative to your current working +directory. For example, a [sample rules directory](./test/rules) is in the auth0 package for testing. -If we want to run these rules files then we would add the `rulesDirectory` field to the [options object](#options). +If we want to run these rules files then we would add the `rulesDirectory` field to the [options +object](#options). ## Endpoints diff --git a/packages/auth0/bin/start.cjs b/packages/auth0/bin/start.cjs index f331398b..af95a749 100755 --- a/packages/auth0/bin/start.cjs +++ b/packages/auth0/bin/start.cjs @@ -1,13 +1,74 @@ #!/usr/bin/env node -const auth0APIsimulator = require("../dist/index.cjs"); - -const app = auth0APIsimulator.simulation(); -app.listen(4400, () => - console.log( - `Auth0 simulation server started at https://localhost:4400\n` + - `Visit the root route to view all available routes.\n\n` + - `Point your configuration at this simulation server and use the default user below.\n` + - `Email: ${auth0APIsimulator.defaultUser.email}\nPassword: ${auth0APIsimulator.defaultUser.password}\n` + - `\nPress Ctrl+C to stop the server`, - ), -); +const { createContext } = require("configliere"); +const { auth0Program, readJsonConfig, simulation, defaultUser } = require("../dist/index.cjs"); + +const args = process.argv.slice(2); +const envs = [{ name: "env", value: /** @type {Record} */ (process.env) }]; + +async function main() { + let parser = auth0Program.parse({ args, envs }); + + if (!parser.ok) { + throw parser.error; + } + + if (parser.value.help) { + console.log(auth0Program.help({ args })); + return; + } + + if (parser.value.version) { + console.log(parser.value.version); + return; + } + + const command = parser.value.config; + + if (command.help) { + console.log(command.text); + return; + } + + switch (command.name) { + case "start": { + let configPath = command.config.config; + let values = configPath ? [{ name: configPath, value: readJsonConfig(configPath) }] : []; + let configParser = command.config.next(values[0]?.value ?? {}); + let input = { + args: parser.remainder.args ?? [], + envs: parser.remainder.envs ?? envs, + values, + }; + let result = configParser.parse(input, createContext(input)); + + if (!result.ok) { + throw result.error; + } + + const app = simulation({ config: result.value }); + + /** @param {{ server: any; port: any }} listening */ + const { server, port } = await app.listen(); + const info = server.address(); + const host = + typeof info === "object" && info?.address && !["::", "0.0.0.0"].includes(info.address) + ? info.address + : "localhost"; + console.log( + `Auth0 simulation server started at https://${host}:${port}\n` + + `Visit the root route to view all available routes.\n\n` + + `Point your configuration at this simulation server and use the default user below.\n` + + `Email: ${defaultUser.email}\nPassword: ${defaultUser.password}\n` + + `\nPress Ctrl+C to stop the server`, + ); + return; + } + default: + throw new TypeError(`Unknown command ${command.name}`); + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; +}); diff --git a/packages/auth0/example/index.mts b/packages/auth0/example/index.mts index 0dab9d50..05d7940f 100644 --- a/packages/auth0/example/index.mts +++ b/packages/auth0/example/index.mts @@ -9,8 +9,16 @@ let app = simulation({ }, }, }); -app.listen(undefined, () => + +app.listen().then(({ server, port }) => { + const info = server.address(); + const host = + typeof info === "object" && info?.address && !["::", "0.0.0.0"].includes(info.address) + ? info.address + : "localhost"; console.log( - `auth0 simulation server started at https://localhost:4400\nusername: default@example.com\npassword: 12345\n`, - ), -); + `Auth0 simulation server started at https://${host}:${port}\n` + + `username: default@example.com\n` + + `password: 12345\n`, + ); +}); diff --git a/packages/auth0/package.json b/packages/auth0/package.json index 27786821..fd33878f 100644 --- a/packages/auth0/package.json +++ b/packages/auth0/package.json @@ -72,9 +72,9 @@ "@simulacrum/server": "workspace:^", "assert-ts": "^0.3.4", "base64-url": "^2.3.3", + "configliere": "^0.4.0", "cookie-session": "^2.1.0", "cors": "^2.8.6", - "cosmiconfig": "^9.0.0", "express": "^5.2.1", "html-entities": "^2.5.2", "jsesc": "^3.1.0", diff --git a/packages/auth0/src/config/get-config.ts b/packages/auth0/src/config/get-config.ts index a428ee3f..ca835f7c 100644 --- a/packages/auth0/src/config/get-config.ts +++ b/packages/auth0/src/config/get-config.ts @@ -1,56 +1,131 @@ -import { cosmiconfigSync } from "cosmiconfig"; -import type { Auth0Configuration, ConfigSchema } from "../types.ts"; -import { configurationSchema } from "../types.ts"; +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { resolve } from "node:path"; +import { createContext, commands, inject, program, object, field, type Attrs } from "configliere"; +import type { Auth0Configuration, ConfigFieldDef } from "../types.ts"; +import { configFields } from "../types.ts"; +import z from "zod"; -const DefaultAuth0Port = 4400; +const pkg = createRequire(import.meta.url)("../../package.json") as { + name: string; + version: string; +}; + +const auth0ConfigAttrs = Object.fromEntries( + Object.entries(configFields).map(([key, f]: [string, ConfigFieldDef]) => [ + key, + { + description: f.description, + ...(f.aliases && { aliases: f.aliases }), + ...field(f.schema, ...(f.default !== undefined ? [field.default(f.default)] : [])), + }, + ]), +) as unknown as Attrs; + +export const auth0ConfigParser = object(auth0ConfigAttrs); -export const DefaultArgs: ConfigSchema = { - clientID: "00000000000000000000000000000000", - audience: "https://thefrontside.auth0.com/api/v1/", - scope: "openid profile email offline_access", +export const auth0Program = program({ + name: pkg.name, + version: pkg.version, + config: commands( + { + start: { + description: "start the Auth0 simulation server", + ...object({ + config: { + description: "path to a JSON config file", + aliases: ["-c"], + ...field(z.optional(z.string())), + }, + next: inject((_config: Partial) => auth0ConfigParser), + }), + }, + }, + { default: "start" }, + ), +}); + +type ConfigValue = { + name: string; + value: Partial | undefined; }; -type Explorer = ReturnType; +type ParseAuth0ConfigOptions = { + args?: string[]; + envs?: { name: string; value: Record }[]; + values?: ConfigValue[]; +}; -function getPort({ domain, port }: Auth0Configuration): number { - if (typeof port === "number") { - return port; +function getDomainPort(domain: string): number | undefined { + if (domain.includes("://")) { + let url = new URL(domain); + return url.port ? Number(url.port) : undefined; } - if (domain) { - const parts = domain.split(":"); - if (parts.length === 2) { - return parseInt(parts[1]!); - } + let match = domain.match(/:(\d+)$/); + return match ? Number(match[1]) : undefined; +} + +function withDomainPort(domain: string, port: number): string { + if (domain.includes("://")) { + let url = new URL(domain); + url.port = String(port); + return url.toString().replace(/\/$/, ""); } - return DefaultAuth0Port; + return domain.replace(/:\d+$/, "") + `:${port}`; } -// This higher order function would only be used for testing and -// allows different cosmiconfig instances to be used for testing -export function getConfigCreator(explorer: Explorer) { - return function getConfig(options?: Partial): Auth0Configuration { - let searchResult = explorer.search(); - - let config: ConfigSchema = searchResult === null ? DefaultArgs : searchResult.config; +function normalizeConfig(config: Auth0Configuration): Auth0Configuration { + let port = config.port; - let strippedOptions = options ?? {}; + if (port === undefined) { + return config; + } - let configuration = { - ...DefaultArgs, + if (config.domain === undefined) { + return { ...config, - ...strippedOptions, - } as Auth0Configuration; + domain: `localhost:${port}`, + }; + } - configuration.port = getPort(configuration); + let domainPort = getDomainPort(config.domain); - configurationSchema.parse(configuration); + if (domainPort !== undefined && domainPort !== port) { + throw new TypeError(`Configured domain ${config.domain} conflicts with port ${port}`); + } - return configuration; + return { + ...config, + domain: withDomainPort(config.domain, port), }; } -const explorer = cosmiconfigSync("auth0Simulator"); +export function readJsonConfig(path: string): Record { + const contents = readFileSync(resolve(path), "utf8"); + const parsed = JSON.parse(contents); + + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new TypeError(`Config file ${path} must contain a JSON object`); + } + + return parsed as Record; +} + +export function parseAuth0Config({ + args = [], + envs = [], + values = [], +}: ParseAuth0ConfigOptions = {}): Auth0Configuration { + const input = { args, envs, values }; + const result = auth0ConfigParser.parse(input, createContext(input)); + + if (!result.ok) throw result.error; -export const getConfig = getConfigCreator(explorer); + return normalizeConfig(result.value); +} + +export function getConfig(options?: Partial): Auth0Configuration { + return parseAuth0Config({ values: [{ name: "options", value: options }] }); +} diff --git a/packages/auth0/src/handlers/auth0-handlers.ts b/packages/auth0/src/handlers/auth0-handlers.ts index 89cb3ffc..39bc6cb6 100644 --- a/packages/auth0/src/handlers/auth0-handlers.ts +++ b/packages/auth0/src/handlers/auth0-handlers.ts @@ -92,15 +92,15 @@ export const createAuth0Handlers = ( ["/login"]: function (req, res) { logger.log({ "/login": { body: req.body, query: req.query } }); let query = req.query as QueryParams; - let responseClientId = query.client_id ?? clientID; + let responseClientID = query.client_id ?? clientID; let responseAudience = query.audience ?? audience; - assert(!!responseClientId, `no clientID assigned`); + assert(!!responseClientID, `no clientID assigned`); let html = loginView({ domain: new URL(serviceURL(req)).host, scope, redirectUri: query.redirect_uri, - clientID: responseClientId, + clientID: responseClientID, audience: responseAudience, loginFailed: false, }); @@ -127,7 +127,7 @@ export const createAuth0Handlers = ( if (!user) { let query = req.query as QueryParams; - let responseClientId = query.client_id ?? clientID; + let responseClientID = query.client_id ?? clientID; let responseAudience = query.audience ?? audience; assert(!!clientID, `no clientID assigned`); @@ -136,7 +136,7 @@ export const createAuth0Handlers = ( domain: new URL(serviceURL(req)).host, scope, redirectUri: query.redirect_uri, - clientID: responseClientId, + clientID: responseClientID, audience: responseAudience, loginFailed: true, }); @@ -188,16 +188,16 @@ export const createAuth0Handlers = ( try { let iss = serviceURL(req); - let responseClientId: string = (req?.body?.client_id as string) ?? clientID; + let responseClientID: string = (req?.body?.client_id as string) ?? clientID; let responseAudience: string = (req?.body?.audience as string) ?? audience; - assert(!!responseClientId, "500::no clientID in options or request body"); + assert(!!responseClientID, "500::no clientID in options or request body"); let tokens = await createTokens({ simulationStore, body: req.body, iss, - clientID: responseClientId, + clientID: responseClientID, audience: responseAudience, rulesDirectory, scope, diff --git a/packages/auth0/src/index.ts b/packages/auth0/src/index.ts index 98a91cdf..017ff9c4 100644 --- a/packages/auth0/src/index.ts +++ b/packages/auth0/src/index.ts @@ -21,19 +21,23 @@ export type Auth0Simulator = (args?: { extendRouter?: (router: Router, simulationStore: ExtendedSimulationStore) => void; }; options?: Partial; + config?: Auth0Configuration; }) => FoundationSimulator; export const simulation: Auth0Simulator = (args = {}) => { - const config = getConfig(args.options); + // if config is provided, use it. + // Otherwise, get the config from passed in options and defaults + const config = args.config ?? getConfig(args.options); const parsedInitialState = !args?.initialState ? undefined : auth0InitialStoreSchema.parse(args?.initialState); return createFoundationSimulationServer({ - port: config.port ?? 4400, // default port - protocol: "https", + ...(config.port !== undefined && { port: config.port }), + ...(config.protocol !== undefined && { protocol: config.protocol }), extendStore: extendStore(parsedInitialState, args?.extend?.extendStore), extendRouter: extendRouter(config, args.extend?.extendRouter, args.debug), })(); }; +export { auth0ConfigParser, auth0Program, getConfig, readJsonConfig } from "./config/get-config.ts"; export { auth0UserSchema, defaultUser } from "./store/entities.ts"; diff --git a/packages/auth0/src/types.ts b/packages/auth0/src/types.ts index 0d5c02cd..bd7a5b9a 100644 --- a/packages/auth0/src/types.ts +++ b/packages/auth0/src/types.ts @@ -1,28 +1,78 @@ import { z } from "zod"; -export const configurationSchema = z.object({ - port: z.optional( - z.number().gt(2999, "port must be greater than 2999").lt(10000, "must be less than 10000"), - ), - domain: z.optional(z.string().min(1, "domain is required")), - audience: z.optional(z.string().min(1, "audience is required")), - clientID: z.optional(z.string().max(32, "must be 32 characters long")), - scope: z.union([ - z.string().min(1, "scope is required"), - z.array( - z.object({ - clientID: z.string().max(32, "must be 32 characters long"), - audience: z.optional(z.string().min(1, "audience is required")), - scope: z.string().min(1, "scope is required"), - }), +export interface ConfigFieldDef { + schema: z.ZodType; + description: string; + default?: string | number; + aliases?: string[]; +} + +export const configFields = { + port: { + schema: z.optional( + z.number().gt(2999, "port must be greater than 2999").lt(10000, "must be less than 10000"), ), - ]), - clientSecret: z.optional(z.string()), - rulesDirectory: z.optional(z.string()), - auth0SessionCookieName: z.optional(z.string()), - auth0CookieSecret: z.optional(z.string()), - connection: z.optional(z.string()), - cookieSecret: z.optional(z.string()), + description: "port to listen on", + default: 4400 as const, + aliases: ["-p"], + }, + domain: { + schema: z.optional(z.string().min(1, "domain is required")), + description: "server domain", + }, + audience: { + schema: z.optional(z.string().min(1, "audience is required")), + description: "auth0 audience", + default: "https://thefrontside.auth0.com/api/v1/" as const, + }, + clientID: { + schema: z.optional(z.string().max(32, "must be 32 characters long")), + description: "auth0 client ID", + default: "00000000000000000000000000000000" as const, + }, + clientSecret: { + schema: z.optional(z.string()), + description: "client secret", + }, + scope: { + schema: z.union([ + z.string().min(1, "scope is required"), + z.array( + z.object({ + clientID: z.string().max(32, "must be 32 characters long"), + audience: z.optional(z.string().min(1, "audience is required")), + scope: z.string().min(1, "scope is required"), + }), + ), + ]), + description: "auth0 scope", + default: "openid profile email offline_access" as const, + }, + rulesDirectory: { + schema: z.optional(z.string()), + description: "directory containing auth0 rules", + }, + connection: { + schema: z.optional(z.string()), + description: "auth0 connection", + }, + protocol: { + schema: z.optional(z.enum(["http", "https"])), + description: "server protocol", + default: "https", + }, +} satisfies Record; + +export const configurationSchema = z.object({ + port: configFields.port.schema, + domain: configFields.domain.schema, + audience: configFields.audience.schema, + clientID: configFields.clientID.schema, + clientSecret: configFields.clientSecret.schema, + scope: configFields.scope.schema, + rulesDirectory: configFields.rulesDirectory.schema, + connection: configFields.connection.schema, + protocol: configFields.protocol.schema, }); export type ConfigSchema = z.infer; diff --git a/packages/auth0/src/views/login.ts b/packages/auth0/src/views/login.ts index 8c294855..247792b5 100644 --- a/packages/auth0/src/views/login.ts +++ b/packages/auth0/src/views/login.ts @@ -27,7 +27,7 @@ export const loginView = ({ href="https://unpkg.com/tailwindcss@^2/dist/tailwind.min.css" rel="stylesheet" /> - + login diff --git a/packages/auth0/test/config.test.ts b/packages/auth0/test/config.test.ts new file mode 100644 index 00000000..8f392f69 --- /dev/null +++ b/packages/auth0/test/config.test.ts @@ -0,0 +1,109 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { createContext } from "configliere"; +import { auth0Program, getConfig } from "../src/index.ts"; +import type { Auth0Configuration } from "../src/types.ts"; + +function readJsonConfig(path: string): Record { + return JSON.parse(require("node:fs").readFileSync(path, "utf8")) as Record; +} + +function parseCliConfig(args: string[]): { value: Auth0Configuration } { + let envs = [{ name: "env", value: process.env as Record }]; + let parser = auth0Program.parse({ args, envs }); + + if (!parser.ok) { + throw parser.error; + } + + if (parser.value.help || parser.value.version) { + throw new Error("expected config result"); + } + + let command = parser.value.config; + + if (command.help) { + throw new Error("expected config result"); + } + + if (command.name !== "start") { + throw new TypeError(`Unknown command ${command.name}`); + } + + let configPath = command.config.config; + let values = configPath ? [{ name: configPath, value: readJsonConfig(configPath) }] : []; + let configParser = command.config.next(values[0]?.value ?? {}); + let input = { + args: parser.remainder.args ?? [], + envs: parser.remainder.envs ?? envs, + values, + }; + let result = configParser.parse(input, createContext(input)); + + if (!result.ok) { + throw result.error; + } + + return result; +} + +describe("CLI config parsing", () => { + let tempDirectory: string | undefined; + + afterEach(() => { + if (tempDirectory) { + rmSync(tempDirectory, { recursive: true, force: true }); + tempDirectory = undefined; + } + }); + + it("parses config directly from argv", () => { + let result = parseCliConfig(["--port", "4567"]); + + expect(result.value.port).toBe(4567); + }); + + it("loads a JSON config file before parsing remaining args", () => { + tempDirectory = mkdtempSync(join(tmpdir(), "auth0-config-")); + let configPath = join(tempDirectory, "config.json"); + writeFileSync(configPath, JSON.stringify({ port: 4567 }), "utf8"); + let clientID = "client-id-value-for-cli-merge-01"; + + let result = parseCliConfig(["-c", configPath, "--client-id", clientID]); + + expect(result.value.port).toBe(4567); + expect(result.value.clientID).toBe(clientID); + }); + + it("returns command help from the staged CLI parser", () => { + let parser = auth0Program.parse({ args: ["start", "--help"], envs: [] }); + + expect(parser.ok).toBe(true); + + if (!parser.ok) { + throw parser.error; + } + + let command = parser.value.config; + + if (!command.help) { + throw new Error("expected help response"); + } + + expect(command.text).toContain("start [OPTIONS]"); + }); + + it("derives domain from port for programmatic config", () => { + let config = getConfig({ port: 4567 }); + + expect(config.domain).toBe("localhost:4567"); + }); + + it("throws when domain and port conflict", () => { + expect(() => getConfig({ domain: "localhost:9999", port: 4567 })).toThrow( + "conflicts with port 4567", + ); + }); +}); diff --git a/packages/auth0/tsdown.config.ts b/packages/auth0/tsdown.config.ts index a2fb8ed9..88493091 100644 --- a/packages/auth0/tsdown.config.ts +++ b/packages/auth0/tsdown.config.ts @@ -13,8 +13,10 @@ export default defineConfig({ minify: false, // don't bundle up as have some relative path imports for static assets unbundle: true, - // if we unbundle, we want to skip this as well - skipNodeModulesBundle: true, + deps: { + // if we unbundle, we want to skip this as well + skipNodeModulesBundle: true, + }, // runs with @arethetypeswrong/core which checks types attw: true, publint: true, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6f013a28..6a99c2d2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,15 +44,15 @@ importers: base64-url: specifier: ^2.3.3 version: 2.3.3 + configliere: + specifier: ^0.4.0 + version: 0.4.0 cookie-session: specifier: ^2.1.0 version: 2.1.1 cors: specifier: ^2.8.6 version: 2.8.6 - cosmiconfig: - specifier: ^9.0.0 - version: 9.0.1(typescript@5.8.3) express: specifier: ^5.2.1 version: 5.2.1 @@ -1362,6 +1362,9 @@ packages: cpu: [x64] os: [win32] + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} @@ -1719,6 +1722,10 @@ packages: resolution: {integrity: sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==} engines: {node: '>=4.0.0'} + configliere@0.4.0: + resolution: {integrity: sha512-4bxK3+L+FHr9Xm/d69Syvvpvkj7lj7a4zz3B+tchuohg5WKeudyBS+4Oob5Zdgoh8I7+n2lj0lfa8I6cUXfcEg==} + engines: {node: '>= 16'} + constant-case@3.0.4: resolution: {integrity: sha512-I2hSBi7Vvs7BEuJDr5dDHfzb/Ruj3FyvFyh7KLilAjNQw3Be+xgqUBA2W6scVEcL0hL1dwPRtIqEPVUCKkSsyQ==} @@ -1766,15 +1773,6 @@ packages: typescript: optional: true - cosmiconfig@9.0.1: - resolution: {integrity: sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==} - engines: {node: '>=14'} - peerDependencies: - typescript: '>=4.9.5' - peerDependenciesMeta: - typescript: - optional: true - cross-fetch@3.2.0: resolution: {integrity: sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==} @@ -1893,10 +1891,6 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} - env-paths@2.2.1: - resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} - engines: {node: '>=6'} - error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} @@ -4406,6 +4400,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.60.1': optional: true + '@standard-schema/spec@1.1.0': {} + '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 @@ -4805,6 +4801,10 @@ snapshots: common-tags@1.8.2: {} + configliere@0.4.0: + dependencies: + '@standard-schema/spec': 1.1.0 + constant-case@3.0.4: dependencies: no-case: 3.0.4 @@ -4851,15 +4851,6 @@ snapshots: optionalDependencies: typescript: 5.8.3 - cosmiconfig@9.0.1(typescript@5.8.3): - dependencies: - env-paths: 2.2.1 - import-fresh: 3.3.1 - js-yaml: 4.1.1 - parse-json: 5.2.0 - optionalDependencies: - typescript: 5.8.3 - cross-fetch@3.2.0: dependencies: node-fetch: 2.7.0 @@ -4945,8 +4936,6 @@ snapshots: encodeurl@2.0.0: {} - env-paths@2.2.1: {} - error-ex@1.3.4: dependencies: is-arrayish: 0.2.1