From 08812b30ee0c1ed9e9592a378dee38b3451db947 Mon Sep 17 00:00:00 2001 From: Kriya Date: Sat, 11 Apr 2026 18:32:32 +0530 Subject: [PATCH 1/2] 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/2] 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):