From 879b0f4cec45a8a6161a92924ff013d94dd1abb2 Mon Sep 17 00:00:00 2001 From: Daniel Demmel Date: Fri, 25 Sep 2026 12:51:43 +0000 Subject: [PATCH 1/4] auth0: model user_metadata and app_metadata, expose them to rules Users seeded via initialState can carry user_metadata/app_metadata (default {}), and rules receive both on the user argument as Auth0 Rules do, so claims can be derived from metadata. Neither is copied into the tokens unless a rule adds it as a claim. --- .changes/auth0-user-metadata.md | 5 ++ packages/auth0/README.md | 19 ++++++ packages/auth0/src/handlers/oauth-handlers.ts | 8 ++- packages/auth0/src/rules/types.ts | 2 + packages/auth0/src/store/entities.ts | 2 + packages/auth0/test/entities.test.ts | 24 +++++++ .../rules-metadata/metadata-claims.js | 9 +++ .../rules-metadata/metadata-claims.json | 5 ++ packages/auth0/test/rules.test.ts | 67 ++++++++++++++++++- 9 files changed, 139 insertions(+), 2 deletions(-) create mode 100644 .changes/auth0-user-metadata.md create mode 100644 packages/auth0/test/fixtures/rules-metadata/metadata-claims.js create mode 100644 packages/auth0/test/fixtures/rules-metadata/metadata-claims.json diff --git a/.changes/auth0-user-metadata.md b/.changes/auth0-user-metadata.md new file mode 100644 index 00000000..90e32f1e --- /dev/null +++ b/.changes/auth0-user-metadata.md @@ -0,0 +1,5 @@ +--- +"@simulacrum/auth0-simulator": minor +--- + +Users can carry `user_metadata` and `app_metadata` (seeded via `initialState`), and rules receive both on the `user` argument, as Auth0 Rules do. diff --git a/packages/auth0/README.md b/packages/auth0/README.md index f89cfb90..6e2d4e4c 100644 --- a/packages/auth0/README.md +++ b/packages/auth0/README.md @@ -61,6 +61,23 @@ app.listen(4400, () => console.log(`auth0 simulation server started at https://l By passing an `initialState`, you may control the initial users in the store. +```js +const app = simulation({ + initialState: { + users: [ + { + id: "auth0|alice", + name: "Alice", + email: "alice@example.com", + password: "12345", + user_metadata: { theme: "dark" }, + app_metadata: { roles: ["admin"] }, + }, + ], + }, +}); +``` + ### 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`. @@ -87,6 +104,8 @@ For example, a [sample rules directory](./test/rules) is in the auth0 package fo If we want to run these rules files then we would add the `rulesDirectory` field to the [options object](#options). +As in Auth0, rules receive the stored user's `user_metadata` and `app_metadata` on the `user` argument. Neither is added to the tokens unless a rule copies a value into a claim. + ## Endpoints The following endpoints have been assigned handlers: diff --git a/packages/auth0/src/handlers/oauth-handlers.ts b/packages/auth0/src/handlers/oauth-handlers.ts index 4c206b4d..4dbe2f1e 100644 --- a/packages/auth0/src/handlers/oauth-handlers.ts +++ b/packages/auth0/src/handlers/oauth-handlers.ts @@ -115,7 +115,7 @@ export const createTokens = async ({ .setIssuedAt() .setExpirationTime(`${expiresInHours}h`) .sign(signingKey), - id_token: await new SignJWT({ ...userData, ...context.idToken }) + id_token: await new SignJWT({ ...profileClaims(userData), ...context.idToken }) .setProtectedHeader({ alg: "RS256", kid: JWKS.keys[0].kid }) .setIssuedAt() .setExpirationTime(`${expiresInHours}h`) @@ -156,6 +156,9 @@ export const getIdToken = ({ nickname: body?.nickname, picture: body?.picture ?? user.picture, identities: body?.identities, + // cloned so a rule mutating them can't write through to the store + user_metadata: structuredClone(user.user_metadata), + app_metadata: structuredClone(user.app_metadata), }; assert(!!user.email, "500::User in store requires an email"); @@ -178,6 +181,9 @@ export const getIdToken = ({ return { userData, idTokenData }; }; +// Rules see the metadata, but Auth0 only puts it in a token when a rule adds it as a claim. +const profileClaims = ({ user_metadata: _u, app_metadata: _a, ...claims }: RuleUser) => claims; + export const getBaseAccessToken = ({ iss, grant_type, diff --git a/packages/auth0/src/rules/types.ts b/packages/auth0/src/rules/types.ts index f3836f71..3d33023d 100644 --- a/packages/auth0/src/rules/types.ts +++ b/packages/auth0/src/rules/types.ts @@ -14,6 +14,8 @@ export interface RuleUser { family_name?: string | undefined; name?: string | undefined; identities: IdentityProvider[] | undefined; + user_metadata?: Record | undefined; + app_metadata?: Record | undefined; } type IdentityProvider = { diff --git a/packages/auth0/src/store/entities.ts b/packages/auth0/src/store/entities.ts index 9d94a1c9..bf254206 100644 --- a/packages/auth0/src/store/entities.ts +++ b/packages/auth0/src/store/entities.ts @@ -9,6 +9,8 @@ export const auth0UserSchema = z password: z.string().optional().default("12345"), email: z.string().email().optional(), picture: z.string().url().optional(), + user_metadata: z.record(z.unknown()).default({}), + app_metadata: z.record(z.unknown()).default({}), }) .transform((user) => { if (!user.email) user.email = faker.internet.email({ firstName: user.name }); diff --git a/packages/auth0/test/entities.test.ts b/packages/auth0/test/entities.test.ts index 90f3ab2c..5fe37424 100644 --- a/packages/auth0/test/entities.test.ts +++ b/packages/auth0/test/entities.test.ts @@ -35,4 +35,28 @@ describe("initialState user fields", () => { expect(user.id).toBeTruthy(); expect(user.email).toContain("@"); }); + + it("keeps user_metadata and app_metadata", () => { + const parsed = auth0InitialStoreSchema.parse({ + users: [ + { + name: "dev", + user_metadata: { theme: "dark" }, + app_metadata: { organisation_id: "org_123", roles: ["admin"] }, + }, + ], + }); + const user = Object.values(convertInitialStateToStoreState(parsed)!.users)[0]; + + expect(user.user_metadata).toEqual({ theme: "dark" }); + expect(user.app_metadata).toEqual({ organisation_id: "org_123", roles: ["admin"] }); + }); + + it("defaults metadata to empty objects", () => { + const parsed = auth0InitialStoreSchema.parse({ users: [{ name: "dev" }] }); + const user = Object.values(convertInitialStateToStoreState(parsed)!.users)[0]; + + expect(user.user_metadata).toEqual({}); + expect(user.app_metadata).toEqual({}); + }); }); diff --git a/packages/auth0/test/fixtures/rules-metadata/metadata-claims.js b/packages/auth0/test/fixtures/rules-metadata/metadata-claims.js new file mode 100644 index 00000000..f2eab330 --- /dev/null +++ b/packages/auth0/test/fixtures/rules-metadata/metadata-claims.js @@ -0,0 +1,9 @@ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function metadataClaims(user, context, callback) { + let namespace = "https://example.nl"; + + context.accessToken[`${namespace}/org`] = user.app_metadata.organisation_id; + context.idToken[`${namespace}/theme`] = user.user_metadata.theme; + + callback(null, user, context); +} diff --git a/packages/auth0/test/fixtures/rules-metadata/metadata-claims.json b/packages/auth0/test/fixtures/rules-metadata/metadata-claims.json new file mode 100644 index 00000000..468daa73 --- /dev/null +++ b/packages/auth0/test/fixtures/rules-metadata/metadata-claims.json @@ -0,0 +1,5 @@ +{ + "enabled": true, + "order": 1, + "stage": "login_success" +} diff --git a/packages/auth0/test/rules.test.ts b/packages/auth0/test/rules.test.ts index ce625f58..8552e356 100644 --- a/packages/auth0/test/rules.test.ts +++ b/packages/auth0/test/rules.test.ts @@ -19,13 +19,20 @@ let Fields = { type FixtureDirectories = | "user" + | "metadata" | "access-token" | "user-dependent" | "async-only" | "sync-wrapper-with-async"; type Fixtures = `test/fixtures/rules-${FixtureDirectories}`; -let person = { +let person: { + name: string; + email: string; + password: string; + user_metadata?: Record; + app_metadata?: Record; +} = { name: "Paul Waters", email: "paulwaters.white@yahoo.com", password: "12345", @@ -162,6 +169,64 @@ describe("rules", () => { }); }); + describe("user and app metadata", () => { + let code: string; + let server: FoundationSimulatorListening; + + beforeEach(async () => { + ({ code, server } = await createSimulation("test/fixtures/rules-metadata", { + user_metadata: { theme: "dark" }, + app_metadata: { organisation_id: "org_123" }, + })); + }); + afterEach(async () => { + await server.ensureClose(); + }); + + it("exposes the stored metadata to rules", async () => { + let res: Response = await fetch(`${auth0Url}/oauth/token`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + ...Fields, + code, + }), + }); + + expect(res.ok).toBe(true); + let token = (await res.json()) as unknown as { access_token: string; id_token: string }; + + let accessToken = decodeJwt(token.access_token); + let idToken = decodeJwt(token.id_token); + + expect(accessToken["https://example.nl/org"]).toBe("org_123"); + expect(idToken["https://example.nl/theme"]).toBe("dark"); + }); + + it("does not copy the metadata itself into the tokens", async () => { + let res: Response = await fetch(`${auth0Url}/oauth/token`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + ...Fields, + code, + }), + }); + + let token = (await res.json()) as unknown as { access_token: string; id_token: string }; + + for (let jwt of [token.access_token, token.id_token]) { + let claims = decodeJwt(jwt); + expect(claims).not.toHaveProperty("user_metadata"); + expect(claims).not.toHaveProperty("app_metadata"); + } + }); + }); + describe("rely on user data", () => { it("should trust Fred", async () => { const otherPerson = { From ca63c711c0292b2303f09502fe96d1c80ad8fe79 Mon Sep 17 00:00:00 2001 From: Daniel Demmel Date: Fri, 25 Sep 2026 12:56:41 +0000 Subject: [PATCH 2/4] auth0: serve a store-backed subset of the Management API Adds /api/v2/users (create/get/patch/delete), /api/v2/users-by-email and /api/v2/tickets/password-change, backed by the simulator's own user store so a created user can log in, a deleted one can't, and a metadata PATCH (top-level merge, null deletes, as in Auth0) reaches the next token. Password-change tickets honour ttl_sec, result_url and mark_email_as_verified, and are redeemed on a minimal /lo/reset page. Requests need a bearer token signed by the simulator. Users gain email_verified (default true for seeded users, so existing tokens are unchanged); API-created users default to false, like Auth0. --- .changes/auth0-management-api.md | 5 + packages/auth0/README.md | 14 + packages/auth0/src/handlers/auth0-handlers.ts | 2 +- packages/auth0/src/handlers/index.ts | 13 +- .../src/handlers/management-api-handlers.ts | 236 ++++++++++++++++ packages/auth0/src/handlers/oauth-handlers.ts | 2 +- packages/auth0/src/store/entities.ts | 9 + packages/auth0/src/store/index.ts | 3 + packages/auth0/src/views/password-reset.ts | 27 ++ packages/auth0/test/management-api.test.ts | 260 ++++++++++++++++++ 10 files changed, 568 insertions(+), 3 deletions(-) create mode 100644 .changes/auth0-management-api.md create mode 100644 packages/auth0/src/handlers/management-api-handlers.ts create mode 100644 packages/auth0/src/views/password-reset.ts create mode 100644 packages/auth0/test/management-api.test.ts diff --git a/.changes/auth0-management-api.md b/.changes/auth0-management-api.md new file mode 100644 index 00000000..d25c8af9 --- /dev/null +++ b/.changes/auth0-management-api.md @@ -0,0 +1,5 @@ +--- +"@simulacrum/auth0-simulator": minor +--- + +Add a store-backed subset of the Management API (`/api/v2/users`, `/api/v2/users-by-email`, `/api/v2/tickets/password-change`) plus a `/lo/reset` page to redeem password-change tickets. Users now carry `email_verified` (default `true` for seeded users), which the tokens and `/userinfo` report. diff --git a/packages/auth0/README.md b/packages/auth0/README.md index 6e2d4e4c..0d6bacba 100644 --- a/packages/auth0/README.md +++ b/packages/auth0/README.md @@ -119,3 +119,17 @@ The following endpoints have been assigned handlers: - `/v2/logout` - `/.well-known/jwks.json` - `/.well-known/openid-configuration` +- `/lo/reset` (password-change ticket page) + +### Management API + +A subset of the [Auth0 Management API](https://auth0.com/docs/api/management/v2) is served under `/api/v2`, backed by the same store the login flow reads, so a user created here can log in and a metadata update shows up in the next token. Requests need a bearer token signed by the simulator, e.g. from a `client_credentials` grant on `/oauth/token`. + +- `POST /api/v2/users` — `409` if the email is taken. The id is `auth0|` (generated when omitted), `email_verified` defaults to `false`, and `password` to the same default as seeded users. +- `GET /api/v2/users/:id` +- `PATCH /api/v2/users/:id` — `user_metadata` and `app_metadata` are merged at the top level, and a `null` value removes the key, as in Auth0. +- `DELETE /api/v2/users/:id` +- `GET /api/v2/users-by-email?email=` +- `POST /api/v2/tickets/password-change` — accepts `user_id` (or `email`), `result_url`, `ttl_sec` and `mark_email_as_verified`. The returned ticket URL opens a page on `/lo/reset` that sets the password and, if given, redirects to `result_url`. + +Errors use Auth0's `{ statusCode, error, message, errorCode }` shape. diff --git a/packages/auth0/src/handlers/auth0-handlers.ts b/packages/auth0/src/handlers/auth0-handlers.ts index 09a52bd9..6e984b0f 100644 --- a/packages/auth0/src/handlers/auth0-handlers.ts +++ b/packages/auth0/src/handlers/auth0-handlers.ts @@ -249,7 +249,7 @@ export const createAuth0Handlers = ( given_name: user.name, family_name: user.name, email: user.email, - email_verified: true, + email_verified: user.email_verified, locale: "en", hd: "okta.com", }; diff --git a/packages/auth0/src/handlers/index.ts b/packages/auth0/src/handlers/index.ts index 09e7ef50..e959c498 100644 --- a/packages/auth0/src/handlers/index.ts +++ b/packages/auth0/src/handlers/index.ts @@ -6,6 +6,7 @@ import { createSession } from "../middleware/session.ts"; import { defaultErrorHandler } from "../middleware/error-handling.ts"; import { createAuth0Handlers } from "./auth0-handlers.ts"; import { createOpenIdHandlers } from "./openid-handlers.ts"; +import { createManagementApiHandlers } from "./management-api-handlers.ts"; import path from "path"; import { type Auth0Configuration } from "../types.ts"; @@ -20,6 +21,7 @@ export const extendRouter = const serviceURL = (request: Request) => `${request.protocol}://${request.get("Host")}/`; const auth0 = createAuth0Handlers(simulationStore, serviceURL, config, debug); const openid = createOpenIdHandlers(serviceURL); + const management = createManagementApiHandlers(simulationStore, serviceURL); router.use(express.static(publicDir)).use(createSession()).use(createCors()).use(noCache()); @@ -42,7 +44,16 @@ export const extendRouter = .get("/userinfo", auth0["/userinfo"]) .get("/v2/logout", auth0["/v2/logout"]) .get("/.well-known/jwks.json", openid["/.well-known/jwks.json"]) - .get("/.well-known/openid-configuration", openid["/.well-known/openid-configuration"]); + .get("/.well-known/openid-configuration", openid["/.well-known/openid-configuration"]) + .get("/lo/reset", management["GET /lo/reset"]) + .post("/lo/reset", management["POST /lo/reset"]) + .use("/api/v2", management.authenticate) + .post("/api/v2/users", management["POST /api/v2/users"]) + .get("/api/v2/users/:id", management["GET /api/v2/users/:id"]) + .patch("/api/v2/users/:id", management["PATCH /api/v2/users/:id"]) + .delete("/api/v2/users/:id", management["DELETE /api/v2/users/:id"]) + .get("/api/v2/users-by-email", management["GET /api/v2/users-by-email"]) + .post("/api/v2/tickets/password-change", management["POST /api/v2/tickets/password-change"]); // needs to be the last middleware added router.use(defaultErrorHandler); diff --git a/packages/auth0/src/handlers/management-api-handlers.ts b/packages/auth0/src/handlers/management-api-handlers.ts new file mode 100644 index 00000000..cbf39f2b --- /dev/null +++ b/packages/auth0/src/handlers/management-api-handlers.ts @@ -0,0 +1,236 @@ +import { randomUUID } from "node:crypto"; +import { STATUS_CODES } from "node:http"; +import type { Request, RequestHandler, Response } from "express"; +import { createLocalJWKSet, jwtVerify } from "jose"; +import { faker } from "@faker-js/faker"; +import { JWKS } from "../auth/constants.ts"; +import { auth0UserSchema, type Auth0User, type PasswordTicket } from "../store/entities.ts"; +import type { AnyState } from "@simulacrum/foundation-simulator"; +import type { ExtendedSimulationStore } from "../store/index.ts"; +import { passwordResetForm, passwordResetMessage } from "../views/password-reset.ts"; + +export type ManagementRoutes = + | "authenticate" + | "POST /api/v2/users" + | "GET /api/v2/users/:id" + | "PATCH /api/v2/users/:id" + | "DELETE /api/v2/users/:id" + | "GET /api/v2/users-by-email" + | "POST /api/v2/tickets/password-change" + | "GET /lo/reset" + | "POST /lo/reset"; + +type Metadata = Record; + +const jwks = createLocalJWKSet(JWKS as unknown as Parameters[0]); +// Auth0 password-change tickets default to 5 days +const DEFAULT_TICKET_TTL_SEC = 432000; + +// Auth0's Management API error body +const sendError = (res: Response, statusCode: number, message: string, errorCode?: string) => { + res.status(statusCode).json({ statusCode, error: STATUS_CODES[statusCode], message, errorCode }); +}; + +const toApiUser = (user: Auth0User) => ({ + user_id: user.id, + email: user.email, + email_verified: user.email_verified, + name: user.name, + picture: user.picture, + user_metadata: user.user_metadata, + app_metadata: user.app_metadata, + identities: [ + { + connection: "Username-Password-Authentication", + provider: "auth0", + user_id: user.id.replace(/^auth0\|/, ""), + isSocial: false, + }, + ], +}); + +// Top-level merge as Auth0 does it: nested objects are replaced, and `null` removes a key. +const mergeMetadata = (current: Metadata, update: unknown): Metadata => { + if (!update || typeof update !== "object") return current; + let merged = { ...current }; + for (let [key, value] of Object.entries(update)) { + if (value === null) delete merged[key]; + else merged[key] = value; + } + return merged; +}; + +export const createManagementApiHandlers = ( + simulationStore: ExtendedSimulationStore, + serviceURL: (request: Request) => string, +): Record => { + let { schema, store, actions } = simulationStore; + + let update = (...updaters: ((s: AnyState) => void)[]) => + store.dispatch(actions.batchUpdater(updaters)); + let users = () => schema.users.selectTableAsList(store.getState()); + let findById = (id: string) => users().find((user) => user.id === id); + let findByEmail = (email: string) => + users().find((user) => user.email?.toLowerCase() === email.toLowerCase()); + + let validTicket = (id: unknown): PasswordTicket | undefined => { + if (typeof id !== "string") return undefined; + let ticket = schema.passwordTickets.selectById(store.getState(), { id }); + return ticket && ticket.expiresAt > Date.now() ? ticket : undefined; + }; + + return { + authenticate: async function (req, res, next) { + let [scheme, token] = req.headers.authorization?.split(" ") ?? []; + if (scheme !== "Bearer" || !token) { + return sendError(res, 401, "Missing authentication"); + } + try { + await jwtVerify(token, jwks); + } catch { + return sendError(res, 401, "Invalid token"); + } + next(); + }, + + "POST /api/v2/users": function (req, res) { + let { user_id, email, ...body } = req.body ?? {}; + if (typeof email !== "string" || !email) { + return sendError(res, 400, "Payload validation error: 'Missing required property: email'."); + } + if (findByEmail(email)) { + return sendError(res, 409, "The user already exists.", "auth0_idp_error"); + } + + let parsed = auth0UserSchema.safeParse({ + id: `auth0|${user_id ?? faker.database.mongodbObjectId()}`, + name: body.name ?? email, + email: email.toLowerCase(), + // Auth0 marks created users unverified unless told otherwise + email_verified: body.email_verified ?? false, + password: body.password, + picture: body.picture, + user_metadata: body.user_metadata, + app_metadata: body.app_metadata, + }); + if (!parsed.success) { + return sendError(res, 400, `Payload validation error: ${parsed.error.message}`); + } + if (findById(parsed.data.id)) { + return sendError(res, 409, "The user already exists.", "auth0_idp_error"); + } + + update(schema.users.add({ [parsed.data.id]: parsed.data })); + res.status(201).json(toApiUser(parsed.data)); + }, + + "GET /api/v2/users/:id": function (req, res) { + let user = findById(req.params.id as string); + if (!user) return sendError(res, 404, "The user does not exist.", "inexistent_user"); + res.status(200).json(toApiUser(user)); + }, + + "PATCH /api/v2/users/:id": function (req, res) { + let user = findById(req.params.id as string); + if (!user) return sendError(res, 404, "The user does not exist.", "inexistent_user"); + + let body = req.body ?? {}; + let updated: Auth0User = { + ...user, + ...(typeof body.name === "string" && { name: body.name }), + ...(typeof body.email === "string" && { email: body.email.toLowerCase() }), + ...(typeof body.email_verified === "boolean" && { email_verified: body.email_verified }), + ...(typeof body.password === "string" && { password: body.password }), + ...(typeof body.picture === "string" && { picture: body.picture }), + user_metadata: mergeMetadata(user.user_metadata, body.user_metadata), + app_metadata: mergeMetadata(user.app_metadata, body.app_metadata), + }; + + update(schema.users.add({ [user.id]: updated })); + res.status(200).json(toApiUser(updated)); + }, + + "DELETE /api/v2/users/:id": function (req, res) { + update(schema.users.remove([req.params.id as string])); + res.status(204).end(); + }, + + "GET /api/v2/users-by-email": function (req, res) { + let email = req.query.email; + if (typeof email !== "string" || !email) { + return sendError(res, 400, "Query validation error: 'Missing required property: email'."); + } + let user = findByEmail(email); + res.status(200).json(user ? [toApiUser(user)] : []); + }, + + "POST /api/v2/tickets/password-change": function (req, res) { + let body = req.body ?? {}; + let user = typeof body.user_id === "string" ? findById(body.user_id) : undefined; + user ??= typeof body.email === "string" ? findByEmail(body.email) : undefined; + if (!user) return sendError(res, 404, "The user does not exist.", "inexistent_user"); + + let ticket: PasswordTicket = { + id: randomUUID(), + userId: user.id, + expiresAt: Date.now() + (Number(body.ttl_sec) || DEFAULT_TICKET_TTL_SEC) * 1000, + resultUrl: typeof body.result_url === "string" ? body.result_url : undefined, + markEmailAsVerified: body.mark_email_as_verified === true, + }; + update(schema.passwordTickets.add({ [ticket.id]: ticket })); + + res.status(201).json({ ticket: `${serviceURL(req)}lo/reset?ticket=${ticket.id}#` }); + }, + + "GET /lo/reset": function (req, res) { + let ticket = validTicket(req.query.ticket); + let user = ticket && findById(ticket.userId); + res.set("Content-Type", "text/html"); + if (!ticket || !user) { + res + .status(400) + .send(passwordResetMessage("Link expired", "This link has expired or was already used.")); + return; + } + res + .status(200) + .send(passwordResetForm({ ticket: ticket.id, email: user.email ?? user.name })); + }, + + "POST /lo/reset": function (req, res) { + let { ticket: ticketId, password } = req.body ?? {}; + let ticket = validTicket(ticketId); + let user = ticket && findById(ticket.userId); + res.set("Content-Type", "text/html"); + if (!ticket || !user) { + res + .status(400) + .send(passwordResetMessage("Link expired", "This link has expired or was already used.")); + return; + } + if (typeof password !== "string" || !password) { + res + .status(400) + .send(passwordResetForm({ ticket: ticket.id, email: user.email ?? user.name })); + return; + } + + update( + schema.users.add({ + [user.id]: { + ...user, + password, + ...(ticket.markEmailAsVerified && { email_verified: true }), + }, + }), + schema.passwordTickets.remove([ticket.id]), + ); + + if (ticket.resultUrl) { + res.redirect(302, ticket.resultUrl); + return; + } + res.status(200).send(passwordResetMessage("Password changed", "You can now log in.")); + }, + }; +}; diff --git a/packages/auth0/src/handlers/oauth-handlers.ts b/packages/auth0/src/handlers/oauth-handlers.ts index 4dbe2f1e..3f2592db 100644 --- a/packages/auth0/src/handlers/oauth-handlers.ts +++ b/packages/auth0/src/handlers/oauth-handlers.ts @@ -151,7 +151,7 @@ export const getIdToken = ({ let userData: RuleUser = { name: body?.name ?? user.name, email: body?.email ?? user.email, - email_verified: true, + email_verified: user.email_verified, user_id: body?.id ?? user.id, nickname: body?.nickname, picture: body?.picture ?? user.picture, diff --git a/packages/auth0/src/store/entities.ts b/packages/auth0/src/store/entities.ts index bf254206..6bc0e9c5 100644 --- a/packages/auth0/src/store/entities.ts +++ b/packages/auth0/src/store/entities.ts @@ -8,6 +8,7 @@ export const auth0UserSchema = z name: z.string(), password: z.string().optional().default("12345"), email: z.string().email().optional(), + email_verified: z.boolean().default(true), picture: z.string().url().optional(), user_metadata: z.record(z.unknown()).default({}), app_metadata: z.record(z.unknown()).default({}), @@ -27,8 +28,16 @@ export const auth0InitialStoreSchema = z.object({ users: z.array(auth0UserSchema), }); export type AuthSession = { username: string; nonce: string }; +export type PasswordTicket = { + id: string; + userId: string; + expiresAt: number; + resultUrl?: string | undefined; + markEmailAsVerified: boolean; +}; export type Auth0Store = z.output & { sessions: AuthSession[]; + passwordTickets: PasswordTicket[]; }; export type Auth0InitialStore = z.input; diff --git a/packages/auth0/src/store/index.ts b/packages/auth0/src/store/index.ts index c4ccb5f5..5c3c4bda 100644 --- a/packages/auth0/src/store/index.ts +++ b/packages/auth0/src/store/index.ts @@ -17,12 +17,14 @@ import { defaultUser, type Auth0User, type AuthSession, + type PasswordTicket, type Auth0InitialStore, } from "./entities.ts"; export type ExtendedSchema = ({ slice }: ExtendSimulationSchema) => { sessions: (n: string) => TableOutput; users: (n: string) => TableOutput; + passwordTickets: (n: string) => TableOutput; }; type ExtendActions = typeof inputActions; type ExtendSelectors = typeof inputSelectors; @@ -40,6 +42,7 @@ const inputSchema = const extended = extendedSchema ? extendedSchema({ slice }) : {}; let slices = { sessions: slice.table(), + passwordTickets: slice.table(), users: slice.table( !storeInitialState ? { diff --git a/packages/auth0/src/views/password-reset.ts b/packages/auth0/src/views/password-reset.ts new file mode 100644 index 00000000..31f67412 --- /dev/null +++ b/packages/auth0/src/views/password-reset.ts @@ -0,0 +1,27 @@ +import { encode } from "html-entities"; + +const page = (title: string, body: string) => ` + + + + ${encode(title)} + + +

${encode(title)}

+ ${body} + +`; + +export const passwordResetForm = ({ ticket, email }: { ticket: string; email: string }) => + page( + "Change your password", + `
+

Set a new password for ${encode(email)}.

+ + + +
`, + ); + +export const passwordResetMessage = (title: string, message: string) => + page(title, `

${encode(message)}

`); diff --git a/packages/auth0/test/management-api.test.ts b/packages/auth0/test/management-api.test.ts new file mode 100644 index 00000000..ef934830 --- /dev/null +++ b/packages/auth0/test/management-api.test.ts @@ -0,0 +1,260 @@ +import { describe, it, beforeAll, afterAll, expect } from "vitest"; +import { simulation } from "../src/index.ts"; +import type { FoundationSimulatorListening } from "@simulacrum/foundation-simulator"; +import { decodeJwt } from "jose"; + +let basePort = 4430; +let auth0Url = `https://localhost:${basePort}`; +let clientId = "00000000000000000000000000000000"; + +let seeded = { + id: "auth0|seeded", + name: "Seeded", + email: "seeded@example.com", + password: "seeded-pw", + app_metadata: { organisation_id: "org_1" }, +}; + +describe("Management API", () => { + let server: FoundationSimulatorListening; + let token: string; + + let api = (path: string, init: { method?: string; body?: unknown; token?: string } = {}) => + fetch(`${auth0Url}/api/v2${path}`, { + method: init.method ?? "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${init.token ?? token}`, + }, + ...(init.body !== undefined && { body: JSON.stringify(init.body) }), + }); + + let login = (username: string, password: string) => + fetch(`${auth0Url}/oauth/token`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ grant_type: "password", client_id: clientId, username, password }), + }); + + let createUser = async (body: Record) => { + let res = await api("/users", { method: "POST", body }); + expect(res.status).toBe(201); + return (await res.json()) as Record; + }; + + beforeAll(async () => { + server = await simulation({ + initialState: { users: [seeded] }, + options: { rulesDirectory: "test/fixtures/rules-metadata" }, + }).listen(basePort); + + let res = await fetch(`${auth0Url}/oauth/token`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + grant_type: "client_credentials", + client_id: clientId, + client_secret: "x", + audience: `${auth0Url}/api/v2/`, + }), + }); + token = ((await res.json()) as { access_token: string }).access_token; + }); + afterAll(async () => { + await server.ensureClose(); + }); + + describe("authentication", () => { + it("rejects a request without a bearer token", async () => { + let res = await fetch(`${auth0Url}/api/v2/users/${encodeURIComponent(seeded.id)}`); + expect(res.status).toBe(401); + expect(await res.json()).toMatchObject({ statusCode: 401, error: "Unauthorized" }); + }); + + it("rejects a token the simulator did not sign", async () => { + let res = await api(`/users/${encodeURIComponent(seeded.id)}`, { token: "not.a.jwt" }); + expect(res.status).toBe(401); + }); + }); + + describe("users", () => { + it("creates a user in the store that can then log in", async () => { + let user = await createUser({ + email: "New.User@example.com", + password: "pw-1", + connection: "Username-Password-Authentication", + user_metadata: { theme: "dark" }, + }); + + expect(user.user_id).toMatch(/^auth0\|/); + expect(user.email).toBe("new.user@example.com"); + expect(user.email_verified).toBe(false); + expect(user.user_metadata).toEqual({ theme: "dark" }); + expect(user.app_metadata).toEqual({}); + expect(user).not.toHaveProperty("password"); + + let res = await login("new.user@example.com", "pw-1"); + expect(res.status).toBe(200); + let { id_token } = (await res.json()) as { id_token: string }; + expect(decodeJwt(id_token).sub).toBe(user.user_id); + }); + + it("prefixes a caller-supplied user_id", async () => { + let user = await createUser({ email: "custom-id@example.com", user_id: "custom-1" }); + expect(user.user_id).toBe("auth0|custom-1"); + }); + + it("answers 409 for an email that is already taken", async () => { + let res = await api("/users", { method: "POST", body: { email: "SEEDED@example.com" } }); + expect(res.status).toBe(409); + expect(await res.json()).toMatchObject({ + statusCode: 409, + message: "The user already exists.", + }); + }); + + it("answers 400 for a missing or invalid email", async () => { + expect((await api("/users", { method: "POST", body: {} })).status).toBe(400); + expect((await api("/users", { method: "POST", body: { email: "nope" } })).status).toBe(400); + }); + + it("gets a user by id and by email", async () => { + let res = await api(`/users/${encodeURIComponent(seeded.id)}`); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ user_id: seeded.id, email: seeded.email }); + + res = await api(`/users-by-email?email=${encodeURIComponent("Seeded@Example.com")}`); + expect(await res.json()).toMatchObject([{ user_id: seeded.id }]); + + res = await api(`/users-by-email?email=nobody%40example.com`); + expect(await res.json()).toEqual([]); + + res = await api(`/users/${encodeURIComponent("auth0|missing")}`); + expect(res.status).toBe(404); + }); + + it("merges metadata at the top level and deletes keys set to null", async () => { + let user = await createUser({ + email: "merge@example.com", + user_metadata: { a: 1, b: { nested: true }, c: 3 }, + }); + + let res = await api(`/users/${encodeURIComponent(user.user_id)}`, { + method: "PATCH", + body: { name: "Merged", user_metadata: { b: { replaced: true }, c: null, d: 4 } }, + }); + + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ + name: "Merged", + user_metadata: { a: 1, b: { replaced: true }, d: 4 }, + }); + }); + + it("shows a metadata update in the next token, including for seeded users", async () => { + let claim = async () => { + let res = await login(seeded.email, seeded.password); + let { access_token } = (await res.json()) as { access_token: string }; + return decodeJwt(access_token)["https://example.nl/org"]; + }; + + expect(await claim()).toBe("org_1"); + + let res = await api(`/users/${encodeURIComponent(seeded.id)}`, { + method: "PATCH", + body: { app_metadata: { organisation_id: "org_2" } }, + }); + expect(res.status).toBe(200); + + expect(await claim()).toBe("org_2"); + }); + + it("answers 404 when patching an unknown user", async () => { + let res = await api(`/users/${encodeURIComponent("auth0|missing")}`, { + method: "PATCH", + body: { name: "x" }, + }); + expect(res.status).toBe(404); + expect(await res.json()).toMatchObject({ message: "The user does not exist." }); + }); + + it("deletes a user, which revokes their login", async () => { + let user = await createUser({ email: "doomed@example.com", password: "pw" }); + + let res = await api(`/users/${encodeURIComponent(user.user_id)}`, { method: "DELETE" }); + expect(res.status).toBe(204); + + expect((await login("doomed@example.com", "pw")).status).toBe(401); + expect((await api(`/users/${encodeURIComponent(user.user_id)}`)).status).toBe(404); + }); + }); + + describe("password-change tickets", () => { + let createTicket = async (body: Record) => { + let res = await api("/tickets/password-change", { method: "POST", body }); + expect(res.status).toBe(201); + let { ticket } = (await res.json()) as { ticket: string }; + return new URL(ticket); + }; + + let redeem = (ticketUrl: URL, password: string) => + fetch(`${auth0Url}/lo/reset`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + ticket: ticketUrl.searchParams.get("ticket")!, + password, + }).toString(), + redirect: "manual", + }); + + it("sets the password through the ticket page, once", async () => { + let user = await createUser({ email: "invitee@example.com" }); + let ticketUrl = await createTicket({ + user_id: user.user_id, + mark_email_as_verified: true, + }); + + expect(ticketUrl.pathname).toBe("/lo/reset"); + let page = await fetch(ticketUrl); + expect(page.status).toBe(200); + expect(await page.text()).toContain('name="password"'); + + expect((await redeem(ticketUrl, "chosen-pw")).status).toBe(200); + + let res = await login("invitee@example.com", "chosen-pw"); + expect(res.status).toBe(200); + let { id_token } = (await res.json()) as { id_token: string }; + expect(decodeJwt(id_token).email_verified).toBe(true); + + expect((await redeem(ticketUrl, "again")).status).toBe(400); + }); + + it("redirects to result_url after redeeming", async () => { + let ticketUrl = await createTicket({ + user_id: seeded.id, + result_url: "https://app.example.com/welcome", + }); + + let res = await redeem(ticketUrl, "new-seeded-pw"); + expect(res.status).toBe(302); + expect(res.headers.get("location")).toBe("https://app.example.com/welcome"); + }); + + it("refuses an expired ticket", async () => { + let ticketUrl = await createTicket({ user_id: seeded.id, ttl_sec: 1 }); + await new Promise((resolve) => setTimeout(resolve, 1100)); + + expect((await fetch(ticketUrl)).status).toBe(400); + expect((await redeem(ticketUrl, "too-late")).status).toBe(400); + }); + + it("answers 404 for an unknown user", async () => { + let res = await api("/tickets/password-change", { + method: "POST", + body: { user_id: "auth0|missing" }, + }); + expect(res.status).toBe(404); + }); + }); +}); From fa4a920c35750e97eb709e6e4a26541a6b9e9154 Mon Sep 17 00:00:00 2001 From: Daniel Demmel Date: Fri, 25 Sep 2026 18:32:56 +0000 Subject: [PATCH 3/4] auth0: tighten Management API per review - PATCH rejects an invalid email (400) or one another user has (409) - users created without a password get a random one instead of the known default, so only a password-change ticket can open the account - tokens must be for the https:///api/v2/ audience, so login tokens are refused (Auth0 parity; the signing key is public) --- packages/auth0/README.md | 4 +- .../src/handlers/management-api-handlers.ts | 19 ++++++++- packages/auth0/test/management-api.test.ts | 41 +++++++++++++++++++ 3 files changed, 60 insertions(+), 4 deletions(-) diff --git a/packages/auth0/README.md b/packages/auth0/README.md index 0d6bacba..819aa155 100644 --- a/packages/auth0/README.md +++ b/packages/auth0/README.md @@ -123,9 +123,9 @@ The following endpoints have been assigned handlers: ### Management API -A subset of the [Auth0 Management API](https://auth0.com/docs/api/management/v2) is served under `/api/v2`, backed by the same store the login flow reads, so a user created here can log in and a metadata update shows up in the next token. Requests need a bearer token signed by the simulator, e.g. from a `client_credentials` grant on `/oauth/token`. +A subset of the [Auth0 Management API](https://auth0.com/docs/api/management/v2) is served under `/api/v2`, backed by the same store the login flow reads, so a user created here can log in and a metadata update shows up in the next token. Requests need a bearer token signed by the simulator for the audience `https:///api/v2/`, e.g. from a `client_credentials` grant on `/oauth/token`. Scopes are not checked. -- `POST /api/v2/users` — `409` if the email is taken. The id is `auth0|` (generated when omitted), `email_verified` defaults to `false`, and `password` to the same default as seeded users. +- `POST /api/v2/users` — `409` if the email is taken. The id is `auth0|` (generated when omitted), `email_verified` defaults to `false`, and a user created without a `password` gets a random one, so they can only log in once a password-change ticket has set it. - `GET /api/v2/users/:id` - `PATCH /api/v2/users/:id` — `user_metadata` and `app_metadata` are merged at the top level, and a `null` value removes the key, as in Auth0. - `DELETE /api/v2/users/:id` diff --git a/packages/auth0/src/handlers/management-api-handlers.ts b/packages/auth0/src/handlers/management-api-handlers.ts index cbf39f2b..e4d11dfc 100644 --- a/packages/auth0/src/handlers/management-api-handlers.ts +++ b/packages/auth0/src/handlers/management-api-handlers.ts @@ -3,6 +3,7 @@ import { STATUS_CODES } from "node:http"; import type { Request, RequestHandler, Response } from "express"; import { createLocalJWKSet, jwtVerify } from "jose"; import { faker } from "@faker-js/faker"; +import { z } from "zod"; import { JWKS } from "../auth/constants.ts"; import { auth0UserSchema, type Auth0User, type PasswordTicket } from "../store/entities.ts"; import type { AnyState } from "@simulacrum/foundation-simulator"; @@ -86,7 +87,8 @@ export const createManagementApiHandlers = ( return sendError(res, 401, "Missing authentication"); } try { - await jwtVerify(token, jwks); + // the key is public, so this is Auth0 parity rather than security: login tokens are refused + await jwtVerify(token, jwks, { audience: `${serviceURL(req)}api/v2/` }); } catch { return sendError(res, 401, "Invalid token"); } @@ -108,7 +110,8 @@ export const createManagementApiHandlers = ( email: email.toLowerCase(), // Auth0 marks created users unverified unless told otherwise email_verified: body.email_verified ?? false, - password: body.password, + // Auth0 requires one; a random one keeps the account closed until a ticket sets it + password: body.password ?? randomUUID(), picture: body.picture, user_metadata: body.user_metadata, app_metadata: body.app_metadata, @@ -135,6 +138,18 @@ export const createManagementApiHandlers = ( if (!user) return sendError(res, 404, "The user does not exist.", "inexistent_user"); let body = req.body ?? {}; + if (typeof body.email === "string") { + if (!z.string().email().safeParse(body.email).success) { + return sendError( + res, + 400, + "Payload validation error: 'Object didn't pass validation for format email'.", + ); + } + if (findByEmail(body.email) && findByEmail(body.email) !== user) { + return sendError(res, 409, "The specified new email already exists", "auth0_idp_error"); + } + } let updated: Auth0User = { ...user, ...(typeof body.name === "string" && { name: body.name }), diff --git a/packages/auth0/test/management-api.test.ts b/packages/auth0/test/management-api.test.ts index ef934830..a9c82cba 100644 --- a/packages/auth0/test/management-api.test.ts +++ b/packages/auth0/test/management-api.test.ts @@ -71,6 +71,18 @@ describe("Management API", () => { expect(await res.json()).toMatchObject({ statusCode: 401, error: "Unauthorized" }); }); + it("rejects a simulator token for another audience", async () => { + let res = await fetch(`${auth0Url}/oauth/token`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ grant_type: "client_credentials", client_id: clientId }), + }); + let { access_token } = (await res.json()) as { access_token: string }; + + let apiRes = await api(`/users/${encodeURIComponent(seeded.id)}`, { token: access_token }); + expect(apiRes.status).toBe(401); + }); + it("rejects a token the simulator did not sign", async () => { let res = await api(`/users/${encodeURIComponent(seeded.id)}`, { token: "not.a.jwt" }); expect(res.status).toBe(401); @@ -99,6 +111,11 @@ describe("Management API", () => { expect(decodeJwt(id_token).sub).toBe(user.user_id); }); + it("does not give a user created without a password a guessable one", async () => { + await createUser({ email: "no-password@example.com" }); + expect((await login("no-password@example.com", "12345")).status).toBe(401); + }); + it("prefixes a caller-supplied user_id", async () => { let user = await createUser({ email: "custom-id@example.com", user_id: "custom-1" }); expect(user.user_id).toBe("auth0|custom-1"); @@ -169,6 +186,30 @@ describe("Management API", () => { expect(await claim()).toBe("org_2"); }); + it("refuses to patch in an invalid email", async () => { + let res = await api(`/users/${encodeURIComponent(seeded.id)}`, { + method: "PATCH", + body: { email: "nope" }, + }); + expect(res.status).toBe(400); + }); + + it("refuses to patch in another user's email, but accepts the user's own", async () => { + let user = await createUser({ email: "taken@example.com" }); + + let res = await api(`/users/${encodeURIComponent(seeded.id)}`, { + method: "PATCH", + body: { email: "Taken@example.com" }, + }); + expect(res.status).toBe(409); + + res = await api(`/users/${encodeURIComponent(user.user_id)}`, { + method: "PATCH", + body: { email: "TAKEN@example.com" }, + }); + expect(res.status).toBe(200); + }); + it("answers 404 when patching an unknown user", async () => { let res = await api(`/users/${encodeURIComponent("auth0|missing")}`, { method: "PATCH", From 5323e3330463bc5bfae5a0f5bc2ca3125b9cbd89 Mon Sep 17 00:00:00 2001 From: Daniel Demmel Date: Fri, 25 Sep 2026 18:42:44 +0000 Subject: [PATCH 4/4] auth0: Management API takes M2M tokens only, validates patched users - tokens must come from a client_credentials grant; a user's token for the /api/v2/ audience no longer gets store-wide access - PATCH validates the whole merged user with auth0UserSchema (400) before the duplicate-email check (409) --- packages/auth0/README.md | 2 +- .../src/handlers/management-api-handlers.ts | 30 +++++++++---------- packages/auth0/test/management-api.test.ts | 27 +++++++++++++++++ 3 files changed, 42 insertions(+), 17 deletions(-) diff --git a/packages/auth0/README.md b/packages/auth0/README.md index 819aa155..49c15cca 100644 --- a/packages/auth0/README.md +++ b/packages/auth0/README.md @@ -123,7 +123,7 @@ The following endpoints have been assigned handlers: ### Management API -A subset of the [Auth0 Management API](https://auth0.com/docs/api/management/v2) is served under `/api/v2`, backed by the same store the login flow reads, so a user created here can log in and a metadata update shows up in the next token. Requests need a bearer token signed by the simulator for the audience `https:///api/v2/`, e.g. from a `client_credentials` grant on `/oauth/token`. Scopes are not checked. +A subset of the [Auth0 Management API](https://auth0.com/docs/api/management/v2) is served under `/api/v2`, backed by the same store the login flow reads, so a user created here can log in and a metadata update shows up in the next token. Requests need a bearer token signed by the simulator from a `client_credentials` grant on `/oauth/token` with the audience `https:///api/v2/`. Scopes are not checked. - `POST /api/v2/users` — `409` if the email is taken. The id is `auth0|` (generated when omitted), `email_verified` defaults to `false`, and a user created without a `password` gets a random one, so they can only log in once a password-change ticket has set it. - `GET /api/v2/users/:id` diff --git a/packages/auth0/src/handlers/management-api-handlers.ts b/packages/auth0/src/handlers/management-api-handlers.ts index e4d11dfc..7cfb6455 100644 --- a/packages/auth0/src/handlers/management-api-handlers.ts +++ b/packages/auth0/src/handlers/management-api-handlers.ts @@ -3,7 +3,6 @@ import { STATUS_CODES } from "node:http"; import type { Request, RequestHandler, Response } from "express"; import { createLocalJWKSet, jwtVerify } from "jose"; import { faker } from "@faker-js/faker"; -import { z } from "zod"; import { JWKS } from "../auth/constants.ts"; import { auth0UserSchema, type Auth0User, type PasswordTicket } from "../store/entities.ts"; import type { AnyState } from "@simulacrum/foundation-simulator"; @@ -88,7 +87,9 @@ export const createManagementApiHandlers = ( } try { // the key is public, so this is Auth0 parity rather than security: login tokens are refused - await jwtVerify(token, jwks, { audience: `${serviceURL(req)}api/v2/` }); + let { payload } = await jwtVerify(token, jwks, { audience: `${serviceURL(req)}api/v2/` }); + // user tokens only get self-service scopes on Auth0; store-wide access is for M2M + if (payload.gty !== "client-credentials") throw new Error("not a client_credentials token"); } catch { return sendError(res, 401, "Invalid token"); } @@ -138,19 +139,7 @@ export const createManagementApiHandlers = ( if (!user) return sendError(res, 404, "The user does not exist.", "inexistent_user"); let body = req.body ?? {}; - if (typeof body.email === "string") { - if (!z.string().email().safeParse(body.email).success) { - return sendError( - res, - 400, - "Payload validation error: 'Object didn't pass validation for format email'.", - ); - } - if (findByEmail(body.email) && findByEmail(body.email) !== user) { - return sendError(res, 409, "The specified new email already exists", "auth0_idp_error"); - } - } - let updated: Auth0User = { + let parsed = auth0UserSchema.safeParse({ ...user, ...(typeof body.name === "string" && { name: body.name }), ...(typeof body.email === "string" && { email: body.email.toLowerCase() }), @@ -159,7 +148,16 @@ export const createManagementApiHandlers = ( ...(typeof body.picture === "string" && { picture: body.picture }), user_metadata: mergeMetadata(user.user_metadata, body.user_metadata), app_metadata: mergeMetadata(user.app_metadata, body.app_metadata), - }; + }); + if (!parsed.success) { + return sendError(res, 400, `Payload validation error: ${parsed.error.message}`); + } + let updated = parsed.data; + + let owner = updated.email && findByEmail(updated.email); + if (owner && owner.id !== user.id) { + return sendError(res, 409, "The specified new email already exists", "auth0_idp_error"); + } update(schema.users.add({ [user.id]: updated })); res.status(200).json(toApiUser(updated)); diff --git a/packages/auth0/test/management-api.test.ts b/packages/auth0/test/management-api.test.ts index a9c82cba..feba5ab4 100644 --- a/packages/auth0/test/management-api.test.ts +++ b/packages/auth0/test/management-api.test.ts @@ -83,6 +83,25 @@ describe("Management API", () => { expect(apiRes.status).toBe(401); }); + it("rejects a user's token even for the Management API audience", async () => { + let res = await fetch(`${auth0Url}/oauth/token`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + grant_type: "password", + client_id: clientId, + username: seeded.email, + password: seeded.password, + audience: `${auth0Url}/api/v2/`, + }), + }); + let { access_token } = (await res.json()) as { access_token: string }; + expect(decodeJwt(access_token).aud).toBe(`${auth0Url}/api/v2/`); + + let apiRes = await api(`/users/${encodeURIComponent(seeded.id)}`, { token: access_token }); + expect(apiRes.status).toBe(401); + }); + it("rejects a token the simulator did not sign", async () => { let res = await api(`/users/${encodeURIComponent(seeded.id)}`, { token: "not.a.jwt" }); expect(res.status).toBe(401); @@ -194,6 +213,14 @@ describe("Management API", () => { expect(res.status).toBe(400); }); + it("validates the whole patched user", async () => { + let res = await api(`/users/${encodeURIComponent(seeded.id)}`, { + method: "PATCH", + body: { picture: "not-a-url" }, + }); + expect(res.status).toBe(400); + }); + it("refuses to patch in another user's email, but accepts the user's own", async () => { let user = await createUser({ email: "taken@example.com" });