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.mjs b/packages/auth0/bin/start.mjs index 39527174..834a2c39 100755 --- a/packages/auth0/bin/start.mjs +++ b/packages/auth0/bin/start.mjs @@ -1,13 +1,39 @@ #!/usr/bin/env node -import { simulation, defaultUser } from "../dist/index.mjs"; +import { + getCLIConfig, + simulation, + defaultUser, +} from "@simulacrum/auth0-simulator"; -const app = simulation(); -app.listen(4400, () => +const args = process.argv.slice(2); +const envs = [{ name: "env", value: /** @type {Record} */ (process.env) }]; + +async function main() { + const result = getCLIConfig({ args, envs }); + + if (result.type === "help" || result.type === "version") { + console.log(result.text); + return; + } + + const app = simulation({ config: result.value }); + + 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://localhost:4400\n` + + `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`, - ), -); + ); +} + +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 33401894..c1166b39 100644 --- a/packages/auth0/package.json +++ b/packages/auth0/package.json @@ -65,9 +65,9 @@ "@simulacrum/foundation-simulator": "^0.8.0", "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", "jose": "^5.9.6", diff --git a/packages/auth0/src/config/get-config.ts b/packages/auth0/src/config/get-config.ts index a428ee3f..05faf8d1 100644 --- a/packages/auth0/src/config/get-config.ts +++ b/packages/auth0/src/config/get-config.ts @@ -1,56 +1,229 @@ -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, + type ObjectInfo, + type Parser, + type ParseResult, +} 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 = normalized(object(auth0ConfigAttrs)); + +function normalized( + parser: Parser>, +): Parser> { + let normalize = ( + result: ParseResult, + ): ParseResult => + result.ok + ? { ok: true, value: normalizeConfig(result.value), remainder: result.remainder } + : result; + + return { + ...parser, + parse(input, ctx) { + return normalize(parser.parse(input, ctx)); + }, + inspect(ctx) { + let info = parser.inspect(ctx); + return { ...info, result: normalize(info.result) }; + }, + }; +} + +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" }, + ), +}); -export const DefaultArgs: ConfigSchema = { - clientID: "00000000000000000000000000000000", - audience: "https://thefrontside.auth0.com/api/v1/", - scope: "openid profile email offline_access", +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(); +const DefaultAuth0Port = 4400; - let config: ConfigSchema = searchResult === null ? DefaultArgs : searchResult.config; +function normalizeConfig(config: Auth0Configuration): Auth0Configuration { + let port = config.port; - let strippedOptions = options ?? {}; + if (port === undefined) { + let domainPort = config.domain ? getDomainPort(config.domain) : undefined; + let resolvedPort = domainPort ?? DefaultAuth0Port; + return { + ...config, + port: resolvedPort, + domain: + config.domain === undefined + ? `localhost:${resolvedPort}` + : withDomainPort(config.domain, resolvedPort), + }; + } - 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 result.value; +} + +export function getConfig(options?: Partial): Auth0Configuration { + return parseAuth0Config({ values: [{ name: "options", value: options }] }); +} + +type CLIConfigInput = { + args: string[]; + envs: { name: string; value: Record }[]; +}; + +export type CLIConfigResult = + | { type: "help"; text: string } + | { type: "version"; text: string } + | { type: "config"; value: Auth0Configuration }; + +export function getCLIConfig({ args, envs }: CLIConfigInput): CLIConfigResult { + let parser = auth0Program.parse({ args, envs }); + + if (!parser.ok) { + throw parser.error; + } + + if (parser.value.help) { + return { type: "help", text: auth0Program.help({ args }) }; + } + + if (parser.value.version) { + return { type: "version", text: parser.value.version }; + } + + let command = parser.value.config; + + if (command.help) { + return { type: "help", text: command.text }; + } + + 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 { type: "config", value: result.value }; +} diff --git a/packages/auth0/src/handlers/auth0-handlers.ts b/packages/auth0/src/handlers/auth0-handlers.ts index 09a52bd9..d3d91248 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..d3c31ab4 100644 --- a/packages/auth0/src/index.ts +++ b/packages/auth0/src/index.ts @@ -21,19 +21,30 @@ 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, + getCLIConfig, + getConfig, + readJsonConfig, + type CLIConfigResult, +} 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 2171b376..25b69869 100644 --- a/packages/auth0/src/types.ts +++ b/packages/auth0/src/types.ts @@ -1,28 +1,77 @@ 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", + 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..f39a206d --- /dev/null +++ b/packages/auth0/test/config.test.ts @@ -0,0 +1,83 @@ +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 { getCLIConfig, getConfig } from "../src/index.ts"; + +describe("CLI config parsing", () => { + let tempDirectory: string | undefined; + + let envs = [{ name: "env", value: process.env as Record }]; + + afterEach(() => { + if (tempDirectory) { + rmSync(tempDirectory, { recursive: true, force: true }); + tempDirectory = undefined; + } + }); + + it("parses config directly from argv", () => { + let result = getCLIConfig({ args: ["--port", "4567"], envs }); + + expect(result.type).toBe("config"); + + if (result.type === "config") { + 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 = getCLIConfig({ args: ["-c", configPath, "--client-id", clientID], envs }); + + expect(result.type).toBe("config"); + + if (result.type === "config") { + expect(result.value.port).toBe(4567); + expect(result.value.clientID).toBe(clientID); + } + }); + + it("returns command help when requested", () => { + let result = getCLIConfig({ args: ["start", "--help"], envs: [] }); + + expect(result.type).toBe("help"); + + if (result.type === "help") { + expect(result.text).toContain("start [OPTIONS]"); + } + }); + + it("returns the program version when requested", () => { + let result = getCLIConfig({ args: ["--version"], envs: [] }); + + expect(result.type).toBe("version"); + + if (result.type === "version") { + expect(result.text).toMatch(/\d+\.\d+\.\d+/); + } + }); + + it("derives domain from port for programmatic config", () => { + let config = getConfig({ port: 4567 }); + + expect(config.domain).toBe("localhost:4567"); + }); + + it("derives port from domain for programmatic config", () => { + let config = getConfig({ domain: "localhost:4567" }); + + expect(config.port).toBe(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/pnpm-lock.yaml b/pnpm-lock.yaml index 25573c1e..79242253 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,15 +56,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 @@ -1558,6 +1558,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.2': resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} @@ -1910,6 +1913,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==} @@ -1957,15 +1964,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==} @@ -2081,10 +2079,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==} @@ -4705,6 +4699,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.62.0': optional: true + '@standard-schema/spec@1.1.0': {} + '@tybys/wasm-util@0.10.2': dependencies: tslib: 2.8.1 @@ -5096,6 +5092,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 @@ -5142,15 +5142,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 @@ -5232,8 +5223,6 @@ snapshots: encodeurl@2.0.0: {} - env-paths@2.2.1: {} - error-ex@1.3.4: dependencies: is-arrayish: 0.2.1