From 08812b30ee0c1ed9e9592a378dee38b3451db947 Mon Sep 17 00:00:00 2001 From: Kriya Date: Sat, 11 Apr 2026 18:32:32 +0530 Subject: [PATCH 1/4] feat(auth0): switch from cosmiconfig to configliere (#362) --- packages/auth0/README.md | 102 +++++++++++++++--- packages/auth0/bin/start.cjs | 34 ++++-- packages/auth0/example/index.mts | 16 ++- packages/auth0/package.json | 2 +- packages/auth0/src/config/get-config.ts | 89 +++++++-------- packages/auth0/src/handlers/auth0-handlers.ts | 20 ++-- packages/auth0/src/handlers/login-redirect.ts | 2 +- packages/auth0/src/handlers/oauth-handlers.ts | 16 +-- packages/auth0/src/handlers/utils.ts | 18 ++-- packages/auth0/src/index.ts | 8 +- packages/auth0/src/rules/types.ts | 2 +- packages/auth0/src/types.ts | 98 ++++++++++++----- packages/auth0/src/views/login.ts | 10 +- packages/auth0/test/auth0.test.ts | 26 ++--- packages/auth0/tsdown.config.ts | 6 +- pnpm-lock.yaml | 49 ++++----- 16 files changed, 315 insertions(+), 183 deletions(-) diff --git a/packages/auth0/README.md b/packages/auth0/README.md index f89cfb90..e8120f83 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 @@ -17,16 +18,23 @@ Read about this simulator on our blog: [Simplified Local Development and Testing 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,7 +44,8 @@ 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 { @@ -63,29 +72,92 @@ 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, 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 `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. +Run with `--help` to see all available 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 --help +``` + +### 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..ae28bdcb 100755 --- a/packages/auth0/bin/start.cjs +++ b/packages/auth0/bin/start.cjs @@ -1,13 +1,33 @@ #!/usr/bin/env node -const auth0APIsimulator = require("../dist/index.cjs"); +const { auth0Program, simulation, defaultUser } = require("../dist/index.cjs"); -const app = auth0APIsimulator.simulation(); -app.listen(4400, () => +const args = process.argv.slice(2); + +if (args[0] === "--help" || args[0] === "-h") { + console.log(auth0Program.help()); + process.exit(0); +} + +if (args[0] === "--version" || args[0] === "-v") { + // @ts-ignore + const pkg = require("../package.json"); + console.log(pkg.version); + process.exit(0); +} + +const app = simulation({ args }); + +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\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: ${auth0APIsimulator.defaultUser.email}\nPassword: ${auth0APIsimulator.defaultUser.password}\n` + + `Email: ${defaultUser.email}\nPassword: ${defaultUser.password}\n` + `\nPress Ctrl+C to stop the server`, - ), -); + ); +}); 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..efe8f8a0 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.3.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..d1bfd1e9 100644 --- a/packages/auth0/src/config/get-config.ts +++ b/packages/auth0/src/config/get-config.ts @@ -1,56 +1,39 @@ -import { cosmiconfigSync } from "cosmiconfig"; -import type { Auth0Configuration, ConfigSchema } from "../types.ts"; -import { configurationSchema } from "../types.ts"; - -const DefaultAuth0Port = 4400; - -export const DefaultArgs: ConfigSchema = { - clientID: "00000000000000000000000000000000", - audience: "https://thefrontside.auth0.com/api/v1/", - scope: "openid profile email offline_access", +import { createRequire } from "node:module"; +import { program, object, field, type Attrs } from "configliere"; +import type { Auth0Configuration, ConfigFieldDef } from "../types.ts"; +import { configFields } from "../types.ts"; + +const pkg = createRequire(import.meta.url)("../../package.json") as { + name: string; + version: string; }; -type Explorer = ReturnType; - -function getPort({ domain, port }: Auth0Configuration): number { - if (typeof port === "number") { - return port; - } - - if (domain) { - const parts = domain.split(":"); - if (parts.length === 2) { - return parseInt(parts[1]!); - } - } - - return DefaultAuth0Port; +export const auth0Program = program({ + name: pkg.name, + version: pkg.version, + config: object( + 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 function getConfig( + options?: Partial, + args: string[] = [], +): Auth0Configuration { + const envs = [{ name: "env", value: process.env as Record }]; + const values = [{ name: "options", value: options }]; + const result = auth0Program.parse({ args, envs, values }); + + if (!result.ok) throw result.error; + + return result.value.config; } - -// 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; - - let strippedOptions = options ?? {}; - - let configuration = { - ...DefaultArgs, - ...config, - ...strippedOptions, - } as Auth0Configuration; - - configuration.port = getPort(configuration); - - configurationSchema.parse(configuration); - - return configuration; - }; -} - -const explorer = cosmiconfigSync("auth0Simulator"); - -export const getConfig = getConfigCreator(explorer); diff --git a/packages/auth0/src/handlers/auth0-handlers.ts b/packages/auth0/src/handlers/auth0-handlers.ts index 89cb3ffc..5e7122f1 100644 --- a/packages/auth0/src/handlers/auth0-handlers.ts +++ b/packages/auth0/src/handlers/auth0-handlers.ts @@ -43,7 +43,7 @@ export const createAuth0Handlers = ( options: Auth0Configuration, debug: boolean, ): Record => { - let { audience, scope, clientID, rulesDirectory } = options; + let { audience, scope, clientId, rulesDirectory } = options; let personQuery = createPersonQuery(simulationStore); let authorizeHandlers: Record = { @@ -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,16 +127,16 @@ 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`); + assert(!!clientId, `no clientId assigned`); let html = loginView({ 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/handlers/login-redirect.ts b/packages/auth0/src/handlers/login-redirect.ts index 96576364..0f3cb89d 100644 --- a/packages/auth0/src/handlers/login-redirect.ts +++ b/packages/auth0/src/handlers/login-redirect.ts @@ -22,7 +22,7 @@ export const createLoginRedirectHandler = (options: Auth0Configuration): Request `/login?${stringify({ state, redirect_uri, - client: client_id || options.clientID, + client: client_id || options.clientId, protocol: "oauth2", scope, response_type, diff --git a/packages/auth0/src/handlers/oauth-handlers.ts b/packages/auth0/src/handlers/oauth-handlers.ts index 6174594d..96d225c1 100644 --- a/packages/auth0/src/handlers/oauth-handlers.ts +++ b/packages/auth0/src/handlers/oauth-handlers.ts @@ -21,7 +21,7 @@ import { type Auth0User } from "../store/entities.ts"; export const createTokens = async ({ body, iss, - clientID, + clientId, audience, rulesDirectory, scope: scopeConfig, @@ -29,14 +29,14 @@ export const createTokens = async ({ }: { body: Request["body"]; iss: string; - clientID: string; + clientId: string; audience: string; rulesDirectory: string | undefined; scope: ScopeConfig; simulationStore: ExtendedSimulationStore; }) => { let { grant_type }: { grant_type: GrantType } = body; - let scope = deriveScope({ scopeConfig, clientID, audience }); + let scope = deriveScope({ scopeConfig, clientId, audience }); let accessToken = getBaseAccessToken({ iss, grant_type, scope, audience }); let user: Auth0User | undefined; @@ -73,12 +73,12 @@ export const createTokens = async ({ body, iss, user, - clientID, + clientId, nonce, }); let context: RuleContext, IdTokenData> = { - clientID, + clientId, accessToken: { scope, sub: idTokenData.sub }, idToken: idTokenData, }; @@ -113,13 +113,13 @@ export const getIdToken = ({ body, iss, user, - clientID, + clientId, nonce, }: { body: Request["body"]; iss: string; user: Auth0User; - clientID: string; + clientId: string; nonce: string | undefined; }) => { let userData: RuleUser = { @@ -141,7 +141,7 @@ export const getIdToken = ({ exp: expiresAt(), iat: epochTime(), email: user.email, - aud: clientID, + aud: clientId, sub: user.id, }; diff --git a/packages/auth0/src/handlers/utils.ts b/packages/auth0/src/handlers/utils.ts index 6a5b1ee2..ed5cc23f 100644 --- a/packages/auth0/src/handlers/utils.ts +++ b/packages/auth0/src/handlers/utils.ts @@ -13,29 +13,29 @@ export const createPersonQuery = export const deriveScope = ({ scopeConfig, - clientID, + clientId, audience, }: { scopeConfig: ScopeConfig; - clientID: string; + clientId: string; audience: string; }) => { if (typeof scopeConfig === "string") return scopeConfig; - let defaultScope = scopeConfig.find((application) => application.clientID === "default"); + let defaultScope = scopeConfig.find((application) => application.clientId === "default"); - assert(!!clientID, `500::Did not have a clientID to derive the scope`); + assert(!!clientId, `500::Did not have a clientId to derive the scope`); let application = scopeConfig.find( (application) => - application.clientID === clientID && + application.clientId === clientId && (application.audience ? application.audience === audience : true), ); if (!application) { - let ignoreAudience = scopeConfig.find((application) => application.clientID === clientID); + let ignoreAudience = scopeConfig.find((application) => application.clientId === clientId); assert( ignoreAudience === undefined, - `500::Found application matching clientID, ${ignoreAudience?.clientID}, but incorrect audience, configured: ${ignoreAudience?.audience} :: passed: ${audience}`, + `500::Found application matching clientId, ${ignoreAudience?.clientId}, but incorrect audience, configured: ${ignoreAudience?.audience} :: passed: ${audience}`, ); } @@ -43,9 +43,9 @@ export const deriveScope = ({ application = defaultScope; } - assert(!!application, `500::Could not find application with clientID: ${clientID}`); + assert(!!application, `500::Could not find application with clientId: ${clientId}`); - assert(!!application.scope, `500::${application.clientID} is expected to have a scope`); + assert(!!application.scope, `500::${application.clientId} is expected to have a scope`); return application.scope; }; diff --git a/packages/auth0/src/index.ts b/packages/auth0/src/index.ts index 98a91cdf..703b25ad 100644 --- a/packages/auth0/src/index.ts +++ b/packages/auth0/src/index.ts @@ -21,19 +21,21 @@ export type Auth0Simulator = (args?: { extendRouter?: (router: Router, simulationStore: ExtendedSimulationStore) => void; }; options?: Partial; + args?: string[]; }) => FoundationSimulator; export const simulation: Auth0Simulator = (args = {}) => { - const config = getConfig(args.options); + const config = getConfig(args.options, args.args); 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 { auth0Program } from "./config/get-config.ts"; export { auth0UserSchema, defaultUser } from "./store/entities.ts"; diff --git a/packages/auth0/src/rules/types.ts b/packages/auth0/src/rules/types.ts index f3836f71..d8586e0c 100644 --- a/packages/auth0/src/rules/types.ts +++ b/packages/auth0/src/rules/types.ts @@ -24,7 +24,7 @@ type IdentityProvider = { }; export interface RuleContext { - clientID: string; + clientId: string; accessToken: { scope: string | string[]; } & A; diff --git a/packages/auth0/src/types.ts b/packages/auth0/src/types.ts index 0d5c02cd..d51a65cc 100644 --- a/packages/auth0/src/types.ts +++ b/packages/auth0/src/types.ts @@ -1,33 +1,83 @@ 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; -type ReadonlyFields = "audience" | "clientID" | "scope" | "port"; +type ReadonlyFields = "audience" | "clientId" | "scope" | "port"; // grant_type list as defined by auth0 // https://auth0.com/docs/get-started/applications/application-grant-types#spec-conforming-grants @@ -40,7 +90,7 @@ export type GrantType = export type ScopeConfig = | string - | { audience?: string | undefined; clientID: string; scope: string }[]; + | { audience?: string | undefined; clientId: string; scope: string }[]; export type Auth0Configuration = Required> & Omit; diff --git a/packages/auth0/src/views/login.ts b/packages/auth0/src/views/login.ts index 8c294855..9c8966b5 100644 --- a/packages/auth0/src/views/login.ts +++ b/packages/auth0/src/views/login.ts @@ -5,7 +5,7 @@ interface LoginViewProps { domain: string; scope: ScopeConfig; redirectUri: string; - clientID: string; + clientId: string; audience: string; loginFailed: boolean; } @@ -14,7 +14,7 @@ export const loginView = ({ domain, scope: scopeConfig, redirectUri, - clientID, + clientId, audience, loginFailed = false, }: LoginViewProps): string => { @@ -27,7 +27,7 @@ export const loginView = ({ href="https://unpkg.com/tailwindcss@^2/dist/tailwind.min.css" rel="stylesheet" /> - + login @@ -116,7 +116,7 @@ export const loginView = ({ document.addEventListener("DOMContentLoaded", function () { var webAuth = new window.auth0.default.WebAuth({ domain: "${domain}", - clientID: "${clientID}", + clientID: "${clientId}", redirectUri: "${redirectUri}", audience: "${audience}", responseType: "token id_token", @@ -136,7 +136,7 @@ export const loginView = ({ username: username.value, password: password.value, realm: "Username-Password-Authentication", - scope: "${deriveScope({ scopeConfig, clientID, audience })}", + scope: "${deriveScope({ scopeConfig, clientId, audience })}", nonce: params.get("nonce"), state: params.get("state"), }, diff --git a/packages/auth0/test/auth0.test.ts b/packages/auth0/test/auth0.test.ts index f2893afe..15ae7e79 100644 --- a/packages/auth0/test/auth0.test.ts +++ b/packages/auth0/test/auth0.test.ts @@ -530,15 +530,15 @@ describe("Auth0 simulator", () => { const app = simulation({ options: { scope: [ - { clientID: "client-one", scope: "custom:access" }, - { clientID: "client-two", scope: "more-custom:access" }, + { clientId: "client-one", scope: "custom:access" }, + { clientId: "client-two", scope: "more-custom:access" }, { - clientID: "client-three", + clientId: "client-three", audience: "https://vip", scope: "custom:special-access", }, { - clientID: "default", + clientId: "default", scope: "openid profile email offline_access", }, ], @@ -551,7 +551,7 @@ describe("Auth0 simulator", () => { await server2.ensureClose(); }); - it("based on clientID in req.body", async () => { + it("based on clientId in req.body", async () => { let res: Response = await fetch(`${auth0Url}/oauth/token`, { method: "POST", headers: { @@ -576,7 +576,7 @@ describe("Auth0 simulator", () => { expect(accessToken.payload.scope).toBe("custom:access"); }); - it("based on different clientID in req.body", async () => { + it("based on different clientId in req.body", async () => { let res: Response = await fetch(`${auth0Url}/oauth/token`, { method: "POST", headers: { @@ -604,7 +604,7 @@ describe("Auth0 simulator", () => { expect(accessToken.payload.scope).toBe("more-custom:access"); }); - it("based on clientID and specific audience in req.body", async () => { + it("based on clientId and specific audience in req.body", async () => { let res: Response = await fetch(`${auth0Url}/oauth/token`, { method: "POST", headers: { @@ -662,10 +662,10 @@ describe("Auth0 simulator", () => { const app = simulation({ options: { scope: [ - { clientID: "client-one", scope: "custom:access" }, - { clientID: "client-two", scope: "more-custom:access" }, + { clientId: "client-one", scope: "custom:access" }, + { clientId: "client-two", scope: "more-custom:access" }, { - clientID: "client-three", + clientId: "client-three", audience: "https://vip", scope: "custom:special-access", }, @@ -679,7 +679,7 @@ describe("Auth0 simulator", () => { await server2.ensureClose(); }); - it("on missing scope based on clientID", async () => { + it("on missing scope based on clientId", async () => { let res: Response = await fetch(`${auth0Url}/oauth/token`, { method: "POST", headers: { @@ -696,7 +696,7 @@ describe("Auth0 simulator", () => { let text = await res.text(); - expect(text).toBe("Could not find application with clientID: non-existent-client"); + expect(text).toBe("Could not find application with clientId: non-existent-client"); }); it("on missing scope based on audience", async () => { @@ -718,7 +718,7 @@ describe("Auth0 simulator", () => { let text = await res.text(); expect(text).toBe( - "Found application matching clientID, client-three, but incorrect audience, configured: https://vip :: passed: https://bad-audience", + "Found application matching clientId, client-three, but incorrect audience, configured: https://vip :: passed: https://bad-audience", ); }); }); 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..4dc60acf 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.3.0 + version: 0.3.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.3.0: + resolution: {integrity: sha512-4W3UipMaFyoMdt6ZQtx0tPsuEKdVJD1kPmrJuHSrakyJ0XPxJ1QR5HC+vGb/FSY+OGCeO50eEUq8I5faJs2Oqw==} + 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==} @@ -2947,6 +2941,9 @@ packages: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true + ts-case-convert@2.1.0: + resolution: {integrity: sha512-Ye79el/pHYXfoew6kqhMwCoxp4NWjKNcm2kBzpmEMIU9dd9aBmHNNFtZ+WTm0rz1ngyDmfqDXDlyUnBXayiD0w==} + ts-log@2.2.7: resolution: {integrity: sha512-320x5Ggei84AxzlXp91QkIGSw5wgaLT6GeAH0KsqDmRZdVWW2OiSeVvElVoatk3f7nicwXlElXsoFkARiGE2yg==} @@ -4406,6 +4403,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 +4804,11 @@ snapshots: common-tags@1.8.2: {} + configliere@0.3.0: + dependencies: + '@standard-schema/spec': 1.1.0 + ts-case-convert: 2.1.0 + constant-case@3.0.4: dependencies: no-case: 3.0.4 @@ -4851,15 +4855,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 +4940,6 @@ snapshots: encodeurl@2.0.0: {} - env-paths@2.2.1: {} - error-ex@1.3.4: dependencies: is-arrayish: 0.2.1 @@ -6100,6 +6093,8 @@ snapshots: tree-kill@1.2.2: {} + ts-case-convert@2.1.0: {} + ts-log@2.2.7: {} tsdown@0.21.7(@arethetypeswrong/core@0.18.2)(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(publint@0.3.18)(typescript@5.8.3): From 35e0d00ac4ba0077ae83b2b47c4f6ced1ae909f8 Mon Sep 17 00:00:00 2001 From: Jacob Bolda Date: Fri, 1 May 2026 17:13:43 -0500 Subject: [PATCH 2/4] adjust config handling in CLI, allow passing config to programmatic, update configliere --- packages/auth0/README.md | 30 +++- packages/auth0/bin/start.cjs | 91 ++++++++---- packages/auth0/package.json | 2 +- packages/auth0/src/config/get-config.ts | 132 +++++++++++++++--- packages/auth0/src/handlers/auth0-handlers.ts | 20 +-- packages/auth0/src/handlers/login-redirect.ts | 2 +- packages/auth0/src/handlers/oauth-handlers.ts | 16 +-- packages/auth0/src/handlers/utils.ts | 18 +-- packages/auth0/src/index.ts | 8 +- packages/auth0/src/rules/types.ts | 2 +- packages/auth0/src/types.ts | 10 +- packages/auth0/src/views/login.ts | 8 +- packages/auth0/test/auth0.test.ts | 26 ++-- packages/auth0/test/config.test.ts | 109 +++++++++++++++ pnpm-lock.yaml | 16 +-- 15 files changed, 374 insertions(+), 116 deletions(-) create mode 100644 packages/auth0/test/config.test.ts diff --git a/packages/auth0/README.md b/packages/auth0/README.md index e8120f83..ffc7bbf3 100644 --- a/packages/auth0/README.md +++ b/packages/auth0/README.md @@ -12,6 +12,10 @@ Simulation](https://frontside.com/blog/2022-01-13-auth0-simulator/). - [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) @@ -50,7 +54,7 @@ simulation instead of the Auth0 endpoint. ```json { "domain": "https://localhost:4400", - "clientId": "00000000000000000000000000000000", + "clientID": "00000000000000000000000000000000", "audience": "https://thefrontside.auth0.com/api/v1/" } ``` @@ -80,7 +84,7 @@ defaults as noted above, and run the Auth0 simulation server with `npx auth0-sim ## Configuration The Auth0 Simulator uses [configliere](https://github.com/thefrontside/configliere) to parse -configuration from CLI flags, environment variables, and programmatic options. +configuration from CLI flags, environment variables, JSON config files, and programmatic options. ### CLI Flags @@ -90,12 +94,28 @@ When running from the command line, you can pass configuration as flags: 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 + +You can stage configuration through a JSON file with `-c` and then override values with CLI flags: + +```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 @@ -131,16 +151,16 @@ match the fields in the client application that is calling the auth0 server. | `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 | +| `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 +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: +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 diff --git a/packages/auth0/bin/start.cjs b/packages/auth0/bin/start.cjs index ae28bdcb..af95a749 100755 --- a/packages/auth0/bin/start.cjs +++ b/packages/auth0/bin/start.cjs @@ -1,33 +1,74 @@ #!/usr/bin/env node -const { auth0Program, simulation, defaultUser } = require("../dist/index.cjs"); +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) }]; -if (args[0] === "--help" || args[0] === "-h") { - console.log(auth0Program.help()); - process.exit(0); -} +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 }); -if (args[0] === "--version" || args[0] === "-v") { - // @ts-ignore - const pkg = require("../package.json"); - console.log(pkg.version); - process.exit(0); + /** @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}`); + } } -const app = simulation({ args }); - -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://${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/package.json b/packages/auth0/package.json index efe8f8a0..fd33878f 100644 --- a/packages/auth0/package.json +++ b/packages/auth0/package.json @@ -72,7 +72,7 @@ "@simulacrum/server": "workspace:^", "assert-ts": "^0.3.4", "base64-url": "^2.3.3", - "configliere": "^0.3.0", + "configliere": "^0.4.0", "cookie-session": "^2.1.0", "cors": "^2.8.6", "express": "^5.2.1", diff --git a/packages/auth0/src/config/get-config.ts b/packages/auth0/src/config/get-config.ts index d1bfd1e9..ca835f7c 100644 --- a/packages/auth0/src/config/get-config.ts +++ b/packages/auth0/src/config/get-config.ts @@ -1,39 +1,131 @@ +import { readFileSync } from "node:fs"; import { createRequire } from "node:module"; -import { program, object, field, type Attrs } from "configliere"; +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 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 auth0Program = program({ name: pkg.name, version: pkg.version, - config: object( - 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, + 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 function getConfig( - options?: Partial, - args: string[] = [], -): Auth0Configuration { - const envs = [{ name: "env", value: process.env as Record }]; - const values = [{ name: "options", value: options }]; - const result = auth0Program.parse({ args, envs, values }); +type ConfigValue = { + name: string; + value: Partial | undefined; +}; + +type ParseAuth0ConfigOptions = { + args?: string[]; + envs?: { name: string; value: Record }[]; + values?: ConfigValue[]; +}; + +function getDomainPort(domain: string): number | undefined { + if (domain.includes("://")) { + let url = new URL(domain); + return url.port ? Number(url.port) : undefined; + } + + 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 domain.replace(/:\d+$/, "") + `:${port}`; +} + +function normalizeConfig(config: Auth0Configuration): Auth0Configuration { + let port = config.port; + + if (port === undefined) { + return config; + } + + if (config.domain === undefined) { + return { + ...config, + domain: `localhost:${port}`, + }; + } + + let domainPort = getDomainPort(config.domain); + + if (domainPort !== undefined && domainPort !== port) { + throw new TypeError(`Configured domain ${config.domain} conflicts with port ${port}`); + } + + return { + ...config, + domain: withDomainPort(config.domain, port), + }; +} + +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; - return result.value.config; + 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 5e7122f1..39bc6cb6 100644 --- a/packages/auth0/src/handlers/auth0-handlers.ts +++ b/packages/auth0/src/handlers/auth0-handlers.ts @@ -43,7 +43,7 @@ export const createAuth0Handlers = ( options: Auth0Configuration, debug: boolean, ): Record => { - let { audience, scope, clientId, rulesDirectory } = options; + let { audience, scope, clientID, rulesDirectory } = options; let personQuery = createPersonQuery(simulationStore); let authorizeHandlers: Record = { @@ -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,16 +127,16 @@ 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`); + assert(!!clientID, `no clientID assigned`); let html = loginView({ 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/handlers/login-redirect.ts b/packages/auth0/src/handlers/login-redirect.ts index 0f3cb89d..96576364 100644 --- a/packages/auth0/src/handlers/login-redirect.ts +++ b/packages/auth0/src/handlers/login-redirect.ts @@ -22,7 +22,7 @@ export const createLoginRedirectHandler = (options: Auth0Configuration): Request `/login?${stringify({ state, redirect_uri, - client: client_id || options.clientId, + client: client_id || options.clientID, protocol: "oauth2", scope, response_type, diff --git a/packages/auth0/src/handlers/oauth-handlers.ts b/packages/auth0/src/handlers/oauth-handlers.ts index 96d225c1..6174594d 100644 --- a/packages/auth0/src/handlers/oauth-handlers.ts +++ b/packages/auth0/src/handlers/oauth-handlers.ts @@ -21,7 +21,7 @@ import { type Auth0User } from "../store/entities.ts"; export const createTokens = async ({ body, iss, - clientId, + clientID, audience, rulesDirectory, scope: scopeConfig, @@ -29,14 +29,14 @@ export const createTokens = async ({ }: { body: Request["body"]; iss: string; - clientId: string; + clientID: string; audience: string; rulesDirectory: string | undefined; scope: ScopeConfig; simulationStore: ExtendedSimulationStore; }) => { let { grant_type }: { grant_type: GrantType } = body; - let scope = deriveScope({ scopeConfig, clientId, audience }); + let scope = deriveScope({ scopeConfig, clientID, audience }); let accessToken = getBaseAccessToken({ iss, grant_type, scope, audience }); let user: Auth0User | undefined; @@ -73,12 +73,12 @@ export const createTokens = async ({ body, iss, user, - clientId, + clientID, nonce, }); let context: RuleContext, IdTokenData> = { - clientId, + clientID, accessToken: { scope, sub: idTokenData.sub }, idToken: idTokenData, }; @@ -113,13 +113,13 @@ export const getIdToken = ({ body, iss, user, - clientId, + clientID, nonce, }: { body: Request["body"]; iss: string; user: Auth0User; - clientId: string; + clientID: string; nonce: string | undefined; }) => { let userData: RuleUser = { @@ -141,7 +141,7 @@ export const getIdToken = ({ exp: expiresAt(), iat: epochTime(), email: user.email, - aud: clientId, + aud: clientID, sub: user.id, }; diff --git a/packages/auth0/src/handlers/utils.ts b/packages/auth0/src/handlers/utils.ts index ed5cc23f..6a5b1ee2 100644 --- a/packages/auth0/src/handlers/utils.ts +++ b/packages/auth0/src/handlers/utils.ts @@ -13,29 +13,29 @@ export const createPersonQuery = export const deriveScope = ({ scopeConfig, - clientId, + clientID, audience, }: { scopeConfig: ScopeConfig; - clientId: string; + clientID: string; audience: string; }) => { if (typeof scopeConfig === "string") return scopeConfig; - let defaultScope = scopeConfig.find((application) => application.clientId === "default"); + let defaultScope = scopeConfig.find((application) => application.clientID === "default"); - assert(!!clientId, `500::Did not have a clientId to derive the scope`); + assert(!!clientID, `500::Did not have a clientID to derive the scope`); let application = scopeConfig.find( (application) => - application.clientId === clientId && + application.clientID === clientID && (application.audience ? application.audience === audience : true), ); if (!application) { - let ignoreAudience = scopeConfig.find((application) => application.clientId === clientId); + let ignoreAudience = scopeConfig.find((application) => application.clientID === clientID); assert( ignoreAudience === undefined, - `500::Found application matching clientId, ${ignoreAudience?.clientId}, but incorrect audience, configured: ${ignoreAudience?.audience} :: passed: ${audience}`, + `500::Found application matching clientID, ${ignoreAudience?.clientID}, but incorrect audience, configured: ${ignoreAudience?.audience} :: passed: ${audience}`, ); } @@ -43,9 +43,9 @@ export const deriveScope = ({ application = defaultScope; } - assert(!!application, `500::Could not find application with clientId: ${clientId}`); + assert(!!application, `500::Could not find application with clientID: ${clientID}`); - assert(!!application.scope, `500::${application.clientId} is expected to have a scope`); + assert(!!application.scope, `500::${application.clientID} is expected to have a scope`); return application.scope; }; diff --git a/packages/auth0/src/index.ts b/packages/auth0/src/index.ts index 703b25ad..017ff9c4 100644 --- a/packages/auth0/src/index.ts +++ b/packages/auth0/src/index.ts @@ -21,11 +21,13 @@ export type Auth0Simulator = (args?: { extendRouter?: (router: Router, simulationStore: ExtendedSimulationStore) => void; }; options?: Partial; - args?: string[]; + config?: Auth0Configuration; }) => FoundationSimulator; export const simulation: Auth0Simulator = (args = {}) => { - const config = getConfig(args.options, args.args); + // 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); @@ -37,5 +39,5 @@ export const simulation: Auth0Simulator = (args = {}) => { })(); }; -export { auth0Program } from "./config/get-config.ts"; +export { auth0ConfigParser, auth0Program, getConfig, readJsonConfig } from "./config/get-config.ts"; export { auth0UserSchema, defaultUser } from "./store/entities.ts"; diff --git a/packages/auth0/src/rules/types.ts b/packages/auth0/src/rules/types.ts index d8586e0c..f3836f71 100644 --- a/packages/auth0/src/rules/types.ts +++ b/packages/auth0/src/rules/types.ts @@ -24,7 +24,7 @@ type IdentityProvider = { }; export interface RuleContext { - clientId: string; + clientID: string; accessToken: { scope: string | string[]; } & A; diff --git a/packages/auth0/src/types.ts b/packages/auth0/src/types.ts index d51a65cc..bd7a5b9a 100644 --- a/packages/auth0/src/types.ts +++ b/packages/auth0/src/types.ts @@ -25,7 +25,7 @@ export const configFields = { description: "auth0 audience", default: "https://thefrontside.auth0.com/api/v1/" as const, }, - clientId: { + clientID: { schema: z.optional(z.string().max(32, "must be 32 characters long")), description: "auth0 client ID", default: "00000000000000000000000000000000" as const, @@ -39,7 +39,7 @@ export const configFields = { z.string().min(1, "scope is required"), z.array( z.object({ - clientId: z.string().max(32, "must be 32 characters long"), + 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"), }), @@ -67,7 +67,7 @@ export const configurationSchema = z.object({ port: configFields.port.schema, domain: configFields.domain.schema, audience: configFields.audience.schema, - clientId: configFields.clientId.schema, + clientID: configFields.clientID.schema, clientSecret: configFields.clientSecret.schema, scope: configFields.scope.schema, rulesDirectory: configFields.rulesDirectory.schema, @@ -77,7 +77,7 @@ export const configurationSchema = z.object({ export type ConfigSchema = z.infer; -type ReadonlyFields = "audience" | "clientId" | "scope" | "port"; +type ReadonlyFields = "audience" | "clientID" | "scope" | "port"; // grant_type list as defined by auth0 // https://auth0.com/docs/get-started/applications/application-grant-types#spec-conforming-grants @@ -90,7 +90,7 @@ export type GrantType = export type ScopeConfig = | string - | { audience?: string | undefined; clientId: string; scope: string }[]; + | { audience?: string | undefined; clientID: string; scope: string }[]; export type Auth0Configuration = Required> & Omit; diff --git a/packages/auth0/src/views/login.ts b/packages/auth0/src/views/login.ts index 9c8966b5..247792b5 100644 --- a/packages/auth0/src/views/login.ts +++ b/packages/auth0/src/views/login.ts @@ -5,7 +5,7 @@ interface LoginViewProps { domain: string; scope: ScopeConfig; redirectUri: string; - clientId: string; + clientID: string; audience: string; loginFailed: boolean; } @@ -14,7 +14,7 @@ export const loginView = ({ domain, scope: scopeConfig, redirectUri, - clientId, + clientID, audience, loginFailed = false, }: LoginViewProps): string => { @@ -116,7 +116,7 @@ export const loginView = ({ document.addEventListener("DOMContentLoaded", function () { var webAuth = new window.auth0.default.WebAuth({ domain: "${domain}", - clientID: "${clientId}", + clientID: "${clientID}", redirectUri: "${redirectUri}", audience: "${audience}", responseType: "token id_token", @@ -136,7 +136,7 @@ export const loginView = ({ username: username.value, password: password.value, realm: "Username-Password-Authentication", - scope: "${deriveScope({ scopeConfig, clientId, audience })}", + scope: "${deriveScope({ scopeConfig, clientID, audience })}", nonce: params.get("nonce"), state: params.get("state"), }, diff --git a/packages/auth0/test/auth0.test.ts b/packages/auth0/test/auth0.test.ts index 15ae7e79..f2893afe 100644 --- a/packages/auth0/test/auth0.test.ts +++ b/packages/auth0/test/auth0.test.ts @@ -530,15 +530,15 @@ describe("Auth0 simulator", () => { const app = simulation({ options: { scope: [ - { clientId: "client-one", scope: "custom:access" }, - { clientId: "client-two", scope: "more-custom:access" }, + { clientID: "client-one", scope: "custom:access" }, + { clientID: "client-two", scope: "more-custom:access" }, { - clientId: "client-three", + clientID: "client-three", audience: "https://vip", scope: "custom:special-access", }, { - clientId: "default", + clientID: "default", scope: "openid profile email offline_access", }, ], @@ -551,7 +551,7 @@ describe("Auth0 simulator", () => { await server2.ensureClose(); }); - it("based on clientId in req.body", async () => { + it("based on clientID in req.body", async () => { let res: Response = await fetch(`${auth0Url}/oauth/token`, { method: "POST", headers: { @@ -576,7 +576,7 @@ describe("Auth0 simulator", () => { expect(accessToken.payload.scope).toBe("custom:access"); }); - it("based on different clientId in req.body", async () => { + it("based on different clientID in req.body", async () => { let res: Response = await fetch(`${auth0Url}/oauth/token`, { method: "POST", headers: { @@ -604,7 +604,7 @@ describe("Auth0 simulator", () => { expect(accessToken.payload.scope).toBe("more-custom:access"); }); - it("based on clientId and specific audience in req.body", async () => { + it("based on clientID and specific audience in req.body", async () => { let res: Response = await fetch(`${auth0Url}/oauth/token`, { method: "POST", headers: { @@ -662,10 +662,10 @@ describe("Auth0 simulator", () => { const app = simulation({ options: { scope: [ - { clientId: "client-one", scope: "custom:access" }, - { clientId: "client-two", scope: "more-custom:access" }, + { clientID: "client-one", scope: "custom:access" }, + { clientID: "client-two", scope: "more-custom:access" }, { - clientId: "client-three", + clientID: "client-three", audience: "https://vip", scope: "custom:special-access", }, @@ -679,7 +679,7 @@ describe("Auth0 simulator", () => { await server2.ensureClose(); }); - it("on missing scope based on clientId", async () => { + it("on missing scope based on clientID", async () => { let res: Response = await fetch(`${auth0Url}/oauth/token`, { method: "POST", headers: { @@ -696,7 +696,7 @@ describe("Auth0 simulator", () => { let text = await res.text(); - expect(text).toBe("Could not find application with clientId: non-existent-client"); + expect(text).toBe("Could not find application with clientID: non-existent-client"); }); it("on missing scope based on audience", async () => { @@ -718,7 +718,7 @@ describe("Auth0 simulator", () => { let text = await res.text(); expect(text).toBe( - "Found application matching clientId, client-three, but incorrect audience, configured: https://vip :: passed: https://bad-audience", + "Found application matching clientID, client-three, but incorrect audience, configured: https://vip :: passed: https://bad-audience", ); }); }); 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/pnpm-lock.yaml b/pnpm-lock.yaml index 4dc60acf..6a99c2d2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,8 +45,8 @@ importers: specifier: ^2.3.3 version: 2.3.3 configliere: - specifier: ^0.3.0 - version: 0.3.0 + specifier: ^0.4.0 + version: 0.4.0 cookie-session: specifier: ^2.1.0 version: 2.1.1 @@ -1722,8 +1722,8 @@ packages: resolution: {integrity: sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==} engines: {node: '>=4.0.0'} - configliere@0.3.0: - resolution: {integrity: sha512-4W3UipMaFyoMdt6ZQtx0tPsuEKdVJD1kPmrJuHSrakyJ0XPxJ1QR5HC+vGb/FSY+OGCeO50eEUq8I5faJs2Oqw==} + configliere@0.4.0: + resolution: {integrity: sha512-4bxK3+L+FHr9Xm/d69Syvvpvkj7lj7a4zz3B+tchuohg5WKeudyBS+4Oob5Zdgoh8I7+n2lj0lfa8I6cUXfcEg==} engines: {node: '>= 16'} constant-case@3.0.4: @@ -2941,9 +2941,6 @@ packages: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true - ts-case-convert@2.1.0: - resolution: {integrity: sha512-Ye79el/pHYXfoew6kqhMwCoxp4NWjKNcm2kBzpmEMIU9dd9aBmHNNFtZ+WTm0rz1ngyDmfqDXDlyUnBXayiD0w==} - ts-log@2.2.7: resolution: {integrity: sha512-320x5Ggei84AxzlXp91QkIGSw5wgaLT6GeAH0KsqDmRZdVWW2OiSeVvElVoatk3f7nicwXlElXsoFkARiGE2yg==} @@ -4804,10 +4801,9 @@ snapshots: common-tags@1.8.2: {} - configliere@0.3.0: + configliere@0.4.0: dependencies: '@standard-schema/spec': 1.1.0 - ts-case-convert: 2.1.0 constant-case@3.0.4: dependencies: @@ -6093,8 +6089,6 @@ snapshots: tree-kill@1.2.2: {} - ts-case-convert@2.1.0: {} - ts-log@2.2.7: {} tsdown@0.21.7(@arethetypeswrong/core@0.18.2)(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(publint@0.3.18)(typescript@5.8.3): From b4a2d6ffa3b0a44ef4ffd0e48a7f567353e67f47 Mon Sep 17 00:00:00 2001 From: Jacob Bolda Date: Wed, 5 Aug 2026 23:55:07 -0500 Subject: [PATCH 3/4] abstract out config capture into helper fn --- packages/auth0/bin/start.mjs | 81 +++++------------- packages/auth0/src/config/get-config.ts | 106 +++++++++++++++++++++++- packages/auth0/src/index.ts | 9 +- packages/auth0/src/types.ts | 1 - packages/auth0/test/config.test.ts | 96 ++++++++------------- 5 files changed, 168 insertions(+), 125 deletions(-) diff --git a/packages/auth0/bin/start.mjs b/packages/auth0/bin/start.mjs index c3a19ad9..834a2c39 100755 --- a/packages/auth0/bin/start.mjs +++ b/packages/auth0/bin/start.mjs @@ -1,71 +1,36 @@ #!/usr/bin/env node -import { createContext } from "configliere"; -import { auth0Program, readJsonConfig, simulation, defaultUser } from "@simulacrum/auth0-simulator"; +import { + getCLIConfig, + simulation, + defaultUser, +} from "@simulacrum/auth0-simulator"; const args = process.argv.slice(2); const envs = [{ name: "env", value: /** @type {Record} */ (process.env) }]; async function main() { - let parser = auth0Program.parse({ args, envs }); + const result = getCLIConfig({ args, envs }); - if (!parser.ok) { - throw parser.error; - } - - if (parser.value.help) { - console.log(auth0Program.help({ args })); + if (result.type === "help" || result.type === "version") { + console.log(result.text); 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}`); - } + 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://${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) => { diff --git a/packages/auth0/src/config/get-config.ts b/packages/auth0/src/config/get-config.ts index ca835f7c..05faf8d1 100644 --- a/packages/auth0/src/config/get-config.ts +++ b/packages/auth0/src/config/get-config.ts @@ -1,7 +1,18 @@ 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 { + 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"; @@ -22,7 +33,29 @@ const auth0ConfigAttrs = Object.fromEntries( ]), ) as unknown as Attrs; -export const auth0ConfigParser = object(auth0ConfigAttrs); +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, @@ -76,11 +109,22 @@ function withDomainPort(domain: string, port: number): string { return domain.replace(/:\d+$/, "") + `:${port}`; } +const DefaultAuth0Port = 4400; + function normalizeConfig(config: Auth0Configuration): Auth0Configuration { let port = config.port; if (port === undefined) { - return config; + 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), + }; } if (config.domain === undefined) { @@ -123,9 +167,63 @@ export function parseAuth0Config({ if (!result.ok) throw result.error; - return normalizeConfig(result.value); + 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/index.ts b/packages/auth0/src/index.ts index 017ff9c4..d3c31ab4 100644 --- a/packages/auth0/src/index.ts +++ b/packages/auth0/src/index.ts @@ -39,5 +39,12 @@ export const simulation: Auth0Simulator = (args = {}) => { })(); }; -export { auth0ConfigParser, auth0Program, getConfig, readJsonConfig } from "./config/get-config.ts"; +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 2360b68e..25b69869 100644 --- a/packages/auth0/src/types.ts +++ b/packages/auth0/src/types.ts @@ -13,7 +13,6 @@ export const configFields = { z.number().gt(2999, "port must be greater than 2999").lt(10000, "must be less than 10000"), ), description: "port to listen on", - default: 4400 as const, aliases: ["-p"], }, domain: { diff --git a/packages/auth0/test/config.test.ts b/packages/auth0/test/config.test.ts index 8f392f69..f39a206d 100644 --- a/packages/auth0/test/config.test.ts +++ b/packages/auth0/test/config.test.ts @@ -2,56 +2,13 @@ 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; -} +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 }); @@ -60,9 +17,13 @@ describe("CLI config parsing", () => { }); it("parses config directly from argv", () => { - let result = parseCliConfig(["--port", "4567"]); + let result = getCLIConfig({ args: ["--port", "4567"], envs }); + + expect(result.type).toBe("config"); - expect(result.value.port).toBe(4567); + if (result.type === "config") { + expect(result.value.port).toBe(4567); + } }); it("loads a JSON config file before parsing remaining args", () => { @@ -71,28 +32,34 @@ describe("CLI config parsing", () => { writeFileSync(configPath, JSON.stringify({ port: 4567 }), "utf8"); let clientID = "client-id-value-for-cli-merge-01"; - let result = parseCliConfig(["-c", configPath, "--client-id", clientID]); + let result = getCLIConfig({ args: ["-c", configPath, "--client-id", clientID], envs }); - expect(result.value.port).toBe(4567); - expect(result.value.clientID).toBe(clientID); + 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 from the staged CLI parser", () => { - let parser = auth0Program.parse({ args: ["start", "--help"], envs: [] }); + it("returns command help when requested", () => { + let result = getCLIConfig({ args: ["start", "--help"], envs: [] }); - expect(parser.ok).toBe(true); + expect(result.type).toBe("help"); - if (!parser.ok) { - throw parser.error; + if (result.type === "help") { + expect(result.text).toContain("start [OPTIONS]"); } + }); - let command = parser.value.config; + it("returns the program version when requested", () => { + let result = getCLIConfig({ args: ["--version"], envs: [] }); - if (!command.help) { - throw new Error("expected help response"); - } + expect(result.type).toBe("version"); - expect(command.text).toContain("start [OPTIONS]"); + if (result.type === "version") { + expect(result.text).toMatch(/\d+\.\d+\.\d+/); + } }); it("derives domain from port for programmatic config", () => { @@ -101,6 +68,13 @@ describe("CLI config parsing", () => { 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", From dc394305187a1513a8958c14bafbcb6df6bfc120 Mon Sep 17 00:00:00 2001 From: Jacob Bolda Date: Sun, 27 Sep 2026 01:32:17 -0500 Subject: [PATCH 4/4] recongigure parsing and schema for latest configliere --- packages/auth0/README.md | 6 - packages/auth0/bin/start.mjs | 5 +- packages/auth0/package.json | 6 +- packages/auth0/src/config/get-config.ts | 321 +++++++++++++--------- packages/auth0/src/index.ts | 21 +- packages/auth0/src/types.ts | 94 +------ packages/auth0/test/config.test.ts | 69 ++++- packages/github-api/package.json | 2 +- packages/github-api/src/store/entities.ts | 2 +- pnpm-lock.yaml | 24 +- pnpm-workspace.yaml | 6 +- todo.md | 104 +++++++ 12 files changed, 410 insertions(+), 250 deletions(-) create mode 100644 todo.md diff --git a/packages/auth0/README.md b/packages/auth0/README.md index ffc7bbf3..6dba27e8 100644 --- a/packages/auth0/README.md +++ b/packages/auth0/README.md @@ -94,12 +94,6 @@ When running from the command line, you can pass configuration as flags: 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 diff --git a/packages/auth0/bin/start.mjs b/packages/auth0/bin/start.mjs index 834a2c39..98457cd3 100755 --- a/packages/auth0/bin/start.mjs +++ b/packages/auth0/bin/start.mjs @@ -11,8 +11,11 @@ const envs = [{ name: "env", value: /** @type {Record} */ (proce async function main() { const result = getCLIConfig({ args, envs }); - if (result.type === "help" || result.type === "version") { + if (result.type !== "config") { console.log(result.text); + if (result.type === "error") { + process.exitCode = 1; + } return; } diff --git a/packages/auth0/package.json b/packages/auth0/package.json index c1166b39..f470599f 100644 --- a/packages/auth0/package.json +++ b/packages/auth0/package.json @@ -55,7 +55,7 @@ "lint": "oxlint", "prepack": "pnpm run build", "start": "node --experimental-transform-types ./example/index.mts", - "start:bin": "node ./bin/start.cjs", + "start:bin": "node ./bin/start.mjs", "test": "NODE_EXTRA_CA_CERTS=\"$(mkcert -CAROOT)/rootCA.pem\" vitest run --fileParallelism=false", "test:watch": "NODE_EXTRA_CA_CERTS=\"$(mkcert -CAROOT)/rootCA.pem\" vitest watch --fileParallelism=false", "tsc": "tsc --noEmit" @@ -65,14 +65,14 @@ "@simulacrum/foundation-simulator": "^0.8.0", "assert-ts": "^0.3.4", "base64-url": "^2.3.3", - "configliere": "^0.4.0", + "configliere": "^0.6.0", "cookie-session": "^2.1.0", "cors": "^2.8.6", "express": "^5.2.1", "html-entities": "^2.5.2", "jose": "^5.9.6", "jsesc": "^3.1.0", - "zod": "^3.24.1" + "zod": "^4.4.3" }, "devDependencies": { "@simulacrum/server": "workspace:^", diff --git a/packages/auth0/src/config/get-config.ts b/packages/auth0/src/config/get-config.ts index 05faf8d1..b1d5f9c3 100644 --- a/packages/auth0/src/config/get-config.ts +++ b/packages/auth0/src/config/get-config.ts @@ -1,93 +1,79 @@ import { readFileSync } from "node:fs"; -import { createRequire } from "node:module"; import { resolve } from "node:path"; +import { createRequire } from "node:module"; import { - createContext, - commands, - inject, - program, - object, - field, - type Attrs, - type ObjectInfo, - type Parser, - type ParseResult, + checkpoint, + cli, + command, + description, + env, + type EnvSource, + name, + option, + parse as configliereParse, + printErrors, + printHelp, + printVersion, + type Result, + schema as optionSchema, + transform, + type ValueSource, + version, } from "configliere"; -import type { Auth0Configuration, ConfigFieldDef } from "../types.ts"; -import { configFields } from "../types.ts"; -import z from "zod"; +import { z } from "zod"; +import type { Auth0Configuration } from "../types.ts"; 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; +const fieldDefaults = { + audience: "https://thefrontside.auth0.com/api/v1/", + clientID: "00000000000000000000000000000000", + scope: "openid profile email offline_access", + protocol: "https", +} satisfies Partial; - 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 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 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), +const configFields = { + port: z.optional( + z.number().gt(2999, "port must be greater than 2999").lt(10000, "port 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")), + clientSecret: z.optional(z.string()), + scope: z.optional( + 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"), }), - }, - }, - { default: "start" }, + ), + ]), ), -}); - -type ConfigValue = { - name: string; - value: Partial | undefined; + rulesDirectory: z.optional(z.string()), + connection: z.optional(z.string()), + protocol: z.optional(z.enum(["http", "https"])), }; -type ParseAuth0ConfigOptions = { - args?: string[]; - envs?: { name: string; value: Record }[]; - values?: ConfigValue[]; -}; +const DefaultAuth0Port = 4400; + +type NormalizedConfig = Auth0Configuration & Record; function getDomainPort(domain: string): number | undefined { if (domain.includes("://")) { @@ -109,9 +95,11 @@ function withDomainPort(domain: string, port: number): string { return domain.replace(/:\d+$/, "") + `:${port}`; } -const DefaultAuth0Port = 4400; - -function normalizeConfig(config: Auth0Configuration): Auth0Configuration { +function normalizeConfig(input: Record): NormalizedConfig { + let defined = Object.fromEntries( + Object.entries(input).filter(([key, value]) => key in configFields && value !== undefined), + ); + let config = { ...fieldDefaults, ...defined } as NormalizedConfig; let port = config.port; if (port === undefined) { @@ -136,8 +124,9 @@ function normalizeConfig(config: Auth0Configuration): Auth0Configuration { let domainPort = getDomainPort(config.domain); + // Preserve a conflicting pair for the model schema to report as an issue. if (domainPort !== undefined && domainPort !== port) { - throw new TypeError(`Configured domain ${config.domain} conflicts with port ${port}`); + return config; } return { @@ -146,84 +135,154 @@ function normalizeConfig(config: Auth0Configuration): Auth0Configuration { }; } -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`); +const configSchema = z.object(configFields).transform((input, context): Auth0Configuration => { + if (input.domain !== undefined && input.port !== undefined) { + let domainPort = getDomainPort(input.domain); + if (domainPort !== undefined && domainPort !== input.port) { + context.addIssue({ + code: "custom", + message: `Configured domain ${input.domain} conflicts with port ${input.port}`, + }); + return z.NEVER; + } } - return parsed as Record; -} + return normalizeConfig(input); +}); + +// the config file path binds ahead of the values checkpoint so it is visible +// on the suspended model. everything after the checkpoint binds only once the +// checkpoint is resumed with the file's values, which sit below arguments and +// environment variables in the resolution order. +export const auth0App = command( + name(pkg.name), + description("Simulate the Auth0 API."), + version(pkg.version), + option( + name("config"), + description("path to a JSON config file"), + cli(["--config", "-c"]), + // An empty key disables the inferred CONFIG lookup. The file path is + // resolved from arguments only. + env(""), + optionSchema(z.optional(z.string())), + ), + checkpoint(), + transform( + configSchema, + option( + name("port"), + description("port to listen on"), + cli(["--port", "-p"]), + optionSchema(configFields.port), + ), + option(name("domain"), description("server domain"), optionSchema(configFields.domain)), + option(name("audience"), description("auth0 audience"), optionSchema(configFields.audience)), + option( + name("clientID"), + description("auth0 client ID"), + cli(["--client-id"]), + optionSchema(configFields.clientID), + ), + option( + name("clientSecret"), + description("client secret"), + cli(["--client-secret"]), + optionSchema(configFields.clientSecret), + ), + option(name("scope"), description("auth0 scope"), optionSchema(configFields.scope)), + option( + name("rulesDirectory"), + description("directory containing auth0 rules"), + cli(["--rules-directory"]), + optionSchema(configFields.rulesDirectory), + ), + option( + name("connection"), + description("auth0 connection"), + optionSchema(configFields.connection), + ), + option(name("protocol"), description("server protocol"), optionSchema(configFields.protocol)), + ), +); + +function parseConfig( + input: Parameters[1], +): ReturnType { + let first = configliereParse(auth0App, input); + + if (!first.ok || !first.resume) { + return first; + } -export function parseAuth0Config({ - args = [], - envs = [], - values = [], -}: ParseAuth0ConfigOptions = {}): Auth0Configuration { - const input = { args, envs, values }; - const result = auth0ConfigParser.parse(input, createContext(input)); + let configPath = (first.model as { config?: unknown } | undefined)?.config; + let values: Result = + configPath === undefined ? { ok: true, value: [] } : loadConfig(String(configPath)); - if (!result.ok) throw result.error; + return first.resume(values); +} - return result.value; +function loadConfig(path: string): Result { + try { + return { + ok: true, + value: [{ name: path, value: readJsonConfig(path) }], + }; + } catch (error) { + return { + ok: false, + issues: [ + { + message: error instanceof Error ? error.message : String(error), + }, + ], + }; + } } export function getConfig(options?: Partial): Auth0Configuration { - return parseAuth0Config({ values: [{ name: "options", value: options }] }); + let result = getCLIConfig({ + args: [], + values: options ? [{ name: "options", value: options }] : [], + }); + + if (result.type === "config") { + return result.value; + } + + if (result.type === "error") { + throw new Error(result.text); + } + + throw new Error(`unexpected config result: ${result.type}`); } type CLIConfigInput = { args: string[]; - envs: { name: string; value: Record }[]; + envs?: readonly EnvSource[] | undefined; + values?: readonly ValueSource[] | undefined; }; export type CLIConfigResult = | { type: "help"; text: string } | { type: "version"; text: string } + | { type: "error"; 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 }) }; - } +export function getCLIConfig({ args, envs, values }: CLIConfigInput): CLIConfigResult { + let settled = parseConfig({ argv: args, envs: envs ?? [], values: values ?? [] }); - if (parser.value.version) { - return { type: "version", text: parser.value.version }; + if (!settled.ok) { + return { type: "error", text: printErrors(settled as never) }; } - let command = parser.value.config; - - if (command.help) { - return { type: "help", text: command.text }; + if (settled.method === "help") { + return { type: "help", text: printHelp(settled as never) }; } - 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; + if (settled.method === "version") { + return { type: "version", text: printVersion(settled as never) }; } - return { type: "config", value: result.value }; + return { type: "config", value: settled.model as Auth0Configuration }; } diff --git a/packages/auth0/src/index.ts b/packages/auth0/src/index.ts index d3c31ab4..9a84e7ac 100644 --- a/packages/auth0/src/index.ts +++ b/packages/auth0/src/index.ts @@ -24,24 +24,29 @@ export type Auth0Simulator = (args?: { config?: Auth0Configuration; }) => FoundationSimulator; -export const simulation: Auth0Simulator = (args = {}) => { +export const simulation: Auth0Simulator = ({ + debug, + initialState, + extend, + options, + config: suppliedConfig, +} = {}) => { // 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 + const config = suppliedConfig ?? getConfig(options); + const parsedInitialState = initialState === undefined ? undefined - : auth0InitialStoreSchema.parse(args?.initialState); + : auth0InitialStoreSchema.parse(initialState); return createFoundationSimulationServer({ ...(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), + extendStore: extendStore(parsedInitialState, extend?.extendStore), + extendRouter: extendRouter(config, extend?.extendRouter, debug), })(); }; export { - auth0ConfigParser, - auth0Program, + auth0App, getCLIConfig, getConfig, readJsonConfig, diff --git a/packages/auth0/src/types.ts b/packages/auth0/src/types.ts index 25b69869..6d1152bd 100644 --- a/packages/auth0/src/types.ts +++ b/packages/auth0/src/types.ts @@ -1,83 +1,3 @@ -import { z } from "zod"; - -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"), - ), - 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; - -type ReadonlyFields = "audience" | "clientID" | "scope" | "port"; - // grant_type list as defined by auth0 // https://auth0.com/docs/get-started/applications/application-grant-types#spec-conforming-grants export type GrantType = @@ -91,8 +11,18 @@ export type ScopeConfig = | string | { audience?: string | undefined; clientID: string; scope: string }[]; -export type Auth0Configuration = Required> & - Omit; +export interface Auth0Configuration { + port: number; + audience: string; + clientID: string; + scope: ScopeConfig; + domain?: string | undefined; + clientSecret?: string | undefined; + rulesDirectory?: string | undefined; + connection?: string | undefined; + protocol?: "http" | "https" | undefined; +} + export type ResponseModes = "query" | "web_message"; export type QueryParams = { diff --git a/packages/auth0/test/config.test.ts b/packages/auth0/test/config.test.ts index f39a206d..4c45ea60 100644 --- a/packages/auth0/test/config.test.ts +++ b/packages/auth0/test/config.test.ts @@ -43,12 +43,12 @@ describe("CLI config parsing", () => { }); it("returns command help when requested", () => { - let result = getCLIConfig({ args: ["start", "--help"], envs: [] }); + let result = getCLIConfig({ args: ["--help"], envs: [] }); expect(result.type).toBe("help"); if (result.type === "help") { - expect(result.text).toContain("start [OPTIONS]"); + expect(result.text).toContain("[OPTIONS]"); } }); @@ -62,6 +62,71 @@ describe("CLI config parsing", () => { } }); + it("returns configuration errors as printable text", () => { + let result = getCLIConfig({ + args: ["--domain", "localhost:9999", "--port", "4567"], + envs: [], + }); + + expect(result.type).toBe("error"); + + if (result.type === "error") { + expect(result.text).toContain("conflicts with port 4567"); + } + }); + + it("falls back to environment variables for unbound parameters", () => { + let result = getCLIConfig({ + args: [], + envs: [{ name: "env", value: { PORT: "4588" } }], + }); + + expect(result.type).toBe("config"); + + if (result.type === "config") { + expect(result.value.port).toBe(4588); + } + }); + + it("resolves environment variables above config file values", () => { + tempDirectory = mkdtempSync(join(tmpdir(), "auth0-config-")); + let configPath = join(tempDirectory, "config.json"); + writeFileSync(configPath, JSON.stringify({ port: 4567 }), "utf8"); + + let result = getCLIConfig({ + args: ["-c", configPath], + envs: [{ name: "env", value: { PORT: "4588" } }], + }); + + expect(result.type).toBe("config"); + + if (result.type === "config") { + expect(result.value.port).toBe(4588); + } + }); + + it("resolves arguments above environment variables", () => { + let result = getCLIConfig({ + args: ["--port", "4567"], + envs: [{ name: "env", value: { PORT: "4588" } }], + }); + + expect(result.type).toBe("config"); + + if (result.type === "config") { + expect(result.value.port).toBe(4567); + } + }); + + it("does not read a config path from the environment", () => { + let result = getCLIConfig({ + args: [], + envs: [{ name: "env", value: { CONFIG: "/nonexistent/auth0-config.json" } }], + }); + + expect(result.type).toBe("config"); + }); + it("derives domain from port for programmatic config", () => { let config = getConfig({ port: 4567 }); diff --git a/packages/github-api/package.json b/packages/github-api/package.json index 79bfe3bf..b11151ce 100644 --- a/packages/github-api/package.json +++ b/packages/github-api/package.json @@ -71,7 +71,7 @@ "express": "^5.2.1", "graphql": "^16.9.0", "graphql-yoga": "^5.15.1", - "zod": "^3.24.1" + "zod": "^4.4.3" }, "devDependencies": { "@graphql-codegen/cli": "^5.0.7", diff --git a/packages/github-api/src/store/entities.ts b/packages/github-api/src/store/entities.ts index 2d4d84c9..e1d7f8ba 100644 --- a/packages/github-api/src/store/entities.ts +++ b/packages/github-api/src/store/entities.ts @@ -292,7 +292,7 @@ export const githubOrganizationSchema = z .default(() => faker.date.recent().toISOString()) .optional(), - teams: z.union([z.array(z.string()), z.undefined()]), + teams: z.array(z.string()).optional(), avatar_url: z.string().optional().default("https://github.com/images/error/octocat_happy.gif"), gravatar_id: z.string().optional().default(""), site_admin: z.boolean().optional().default(true), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 79242253..029088b5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -57,8 +57,8 @@ importers: specifier: ^2.3.3 version: 2.3.3 configliere: - specifier: ^0.4.0 - version: 0.4.0 + specifier: ^0.6.0 + version: 0.6.0 cookie-session: specifier: ^2.1.0 version: 2.1.1 @@ -78,8 +78,8 @@ importers: specifier: ^3.1.0 version: 3.1.0 zod: - specifier: ^3.24.1 - version: 3.25.76 + specifier: ^4.4.3 + version: 4.5.4 devDependencies: '@simulacrum/server': specifier: workspace:^ @@ -169,8 +169,8 @@ importers: specifier: ^5.15.1 version: 5.18.1(graphql@16.13.2) zod: - specifier: ^3.24.1 - version: 3.25.76 + specifier: ^4.4.3 + version: 4.5.4 devDependencies: '@graphql-codegen/cli': specifier: ^5.0.7 @@ -1913,8 +1913,8 @@ 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==} + configliere@0.6.0: + resolution: {integrity: sha512-7c7Soc2hbumAZQYVmhjs56X9mngz+moui7yjog4CfSus/cXY2/qwkXLKsvOLM8NM7foELnaWCSeXbwOYFQfW0g==} engines: {node: '>= 16'} constant-case@3.0.4: @@ -3416,8 +3416,8 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} - zod@3.25.76: - resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.5.4: + resolution: {integrity: sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==} snapshots: @@ -5092,7 +5092,7 @@ snapshots: common-tags@1.8.2: {} - configliere@0.4.0: + configliere@0.6.0: dependencies: '@standard-schema/spec': 1.1.0 @@ -6621,4 +6621,4 @@ snapshots: yocto-queue@0.1.0: {} - zod@3.25.76: {} + zod@4.5.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 4bf38520..8b3db6ae 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,9 +1,9 @@ packages: - packages/* blockExoticSubdeps: true -# don't use workspace: protocol as covector will handle versions -linkWorkspacePackages: deep -disallowWorkspaceCycles: true catalog: effection: ^4.0.3 +disallowWorkspaceCycles: true +# don't use workspace: protocol as covector will handle versions +linkWorkspacePackages: deep diff --git a/todo.md b/todo.md new file mode 100644 index 00000000..9b83ef0f --- /dev/null +++ b/todo.md @@ -0,0 +1,104 @@ +# Auth0 simulator config work — tracking + +## Resolved +- [x] `normalized()` wrapper fixes `inject()` bypass (old configliere's `inject` calls `inspect` directly). +- [x] CLI precedence documented to match old configliere: env vars > CLI flags > config file. +- [x] `paseo.json` added to `.gitignore`. +- [x] Migrated packages/auth0 to configliere preview @6935534 (two-phase parse, `fromValues()` reader, + `auth0App({values})` factory; `normalizeConfig`/`getConfig` untouched). Envs accepted in + `getCLIConfig` input but not read (forward-compat placeholder). +- [x] **Migrated packages/auth0 to configliere preview @064f111 (full phase-protocol rework).** + Tokenizer `claimPair` patch no longer needed (fixed upstream; patch machinery removed). + New shape in `get-config.ts`: + - `auth0App()` — config option (with `env("")` so an ambient `CONFIG` var can't be read as + a file path) → `checkpoint()` → nine field options → `routes(start)` with the same fields. + Values/envs are native parse inputs now; `fromValues()` reader wrapper is gone. + - `getCLIConfig` — parse suspends at the checkpoint; help/version are detected from raw + argv (`requestedMethod`) so a config file is never loaded for them; execute resumes with + the file values (`Result`) → CLI > env > config-file precedence natively. + - `getConfig(options)` — options threaded as parse `values` + empty resume. + - Merge of root + subcommand models strips child `undefined`s (child re-claims params with + distinct claim IDs and would otherwise shadow root-settled values). This also fixes a + latent old-code bug where a pre-`start` CLI value was clobbered by a file claim. + - Tests: 67 pass (62 prior + 5 new: env fallback, env>file, cli>env, start+file, no + `CONFIG` env leak). tsc, oxlint, build/attw/publint all clean. +- [x] All 62 auth0 tests pass (`--no-file-parallelism` + `NODE_OPTIONS=--use-system-ca`, both pre-existing). +- [x] Dropped `compose()` — fields bundle into one `auth0Fields(values)` element; both pipelines + are plain positional `route()` calls within the typed arity. +- [x] Zod schema moved out of types.ts into get-config.ts: `fieldDefs` table is now the single + source of truth (description/aliases/schema); `Auth0Configuration` is a hand-written type. + Validation happens once at binding (CLI strings decoded via schema; file values validated + too), so `finalize()` no longer re-parses. Phase-two parse can legitimately fail on bad + config files → both entry points surface binding issues instead of "unreachable" guards. + +## Configliere preview "Project Tom Hagen" (@6935534) +Ground-up rebuild as typed request router. Now the live dependency of @simulacrum/auth0-simulator. + +### Upstream bug found & patched +- `Tokenizer.claimPair` never advanced `previous` after a failed match, so pairs were tested as + `(first-token, tk)` — any `--flag value` preceded by another token failed ("requires a value" + + "unexpected"), and a leading flag could mis-pair with a non-adjacent word. Fix: advance + `previous = token` on failed match (strict adjacency). Carried in `patches/configliere.patch` + via `patchedDependencies` in pnpm-workspace.yaml (pnpm ≥10 ignores it in sub-package.json). + **Should be reported/fixed upstream** (configliere PR #20 / repo issues). + +### Costs / caveats +- [x] **Variadic-tuple alternative to the 30 overloads investigated & benchmarked** (bench harness + in /var/folders/.../opencode/pipe-bench): checked-chaining via `Chained`/`OutputOf` collapses + whenever an element's own generics must be inferred inline (e.g. `option("port")` inside the + pipeline) — TS can't do arg-inference + tuple-inference + deferred conditional in one pass; + pre-bound elements work but output types degrade to constraint instantiations and cost + ~4.6x more instantiations (827K vs 180K for 240 pipelines, ~2x check time). +- [x] **Delta design found that DOES work** (pipe-bench/delta.ts): elements are callable but carry + a type-level delta tag resolved eagerly at their own call site (`{k:"param", s:name}` etc.); + `route()` folds concrete deltas in one linear pass — no nested inference, no deferred + conditional in the inference path. Inline usage compiles with full precision (exact param + keys/methods/children threading, wrong keys rejected), and costs ~1.7x ladder instantiations + / ~1.2x check time (313K vs 180K, 0.69s vs 0.58s for 240 pipelines). +- [x] **Delta scaling measured + TS2589 fixed** (pipe-bench/scale.mjs): first fold version died at + ~100 elements (`Type instantiation is excessively deep`) because re-wrapping the accumulated + Route forced a conditional (`ParamsOf`) to resolve the whole lineage each step. Threading + a flat State record (indexed accesses only, Route built once at the end) removed it: + 5→500 elements all compile; wall ~1000-1500 (TS tail-call budget). Per-call cost grows + superlinearly (params intersections accumulate): depth 10 ≈ 0.7K inst / 3ms; depth 50 ≈ + 5.4K / 9ms; depth 100 ≈ 15.7K / 23ms. Ladder stays linear but hard-caps at 31. + Branch order in `Apply` mattered ~1.9x on the old design; method-first won. +- [x] **Envs wired natively** — `parse(app, { argv, envs })` landed in @562cb6a+; `getCLIConfig` + passes envs straight through (env keys: `PORT`, `CLIENT_ID`, `DOMAIN`, … upper-snake, + un-prefixed; child routes get `START_PORT`-style keys). Placeholder removed. +- [ ] Extension surface (`Param.cli`, `ReadCLI`, `CLIRead`) is exported but young. +- [ ] Package still published as `configliere@0.4.0-pr…` (README says `@frontside/configliere`). +- [x] Absent optionals arrive as explicit `undefined` (not omitted) — `finalize()` strips them + so field defaults aren't shadowed. +- [ ] **Upstream TS2589 budget (064f111): nine options exceed the instantiation budget** in the + delta `Fold`/`Materialize` pipeline — fails in every construction style (variadic inline, + tuple spread, manual chaining) once the type is actually consumed; plain `z.string()` + schemas too. Workaround: runtime app is built with shallow `AnyRoute` intermediates and + `auth0App()` anchors its return to a hand-written structural `Route` type (phases carry + the checkpoint resolver + typed models, so suspended/execute intents stay typed). + **Worth reporting upstream** — affects any ~8+ option CLI. +- [ ] **Upstream behavior change:** config-file values are claimed per-route-path, so subcommand + routes read *nested* keys (`{ start: { port } }`) from a root-mounted value source; our + merge instead relies on the root binding (which reads flat keys post-checkpoint). + README documents the nested shape as intended. + +## Pre-existing test environment issues (not from migration) +- [ ] Self-signed cert: Node 24 rejects leaf → tests need `NODE_OPTIONS=--use-system-ca`. + Fails identically at pristine HEAD. +- [ ] Suites share a fixed port-3000 server (test/helpers.ts); parallel file runs collide → + run vitest with `--no-file-parallelism` (or make ports dynamic per suite). + +## Upstream implementation handoff +- [ ] `delta-pipeline-handoff.md` (repo root) — self-contained doc for an agent to implement the + delta fold in configliere upstream: reference impl, integration notes (schema-typed param + values, multi-mount ParamsDelta generalization), gotchas (TS2589/State fix, branch order, + spread footgun), error-gallery parity, perf baselines, acceptance checklist. + +## Open design notes (current implementation) +- [ ] **`simulation({ config })` bypasses normalization** — raw object skips domain/port derivation + and conflict check; undefined port falls back to foundation's 9000 silently. +- [ ] **`port` typed required but parseable-as-undefined** until `normalizeConfig` runs. +- [ ] **README doesn't document `simulation({ config })` or `getCLIConfig`** — decide whether/how. +- [ ] **Passing both `config` and `options`** — `config` wins silently. Document or assert. +- [ ] **Duplicate flags now error instead of last-wins** — behavior change vs old configliere; + confirm intentional for release notes.