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/.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..49c15cca 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: @@ -100,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 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` +- `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..7cfb6455 --- /dev/null +++ b/packages/auth0/src/handlers/management-api-handlers.ts @@ -0,0 +1,249 @@ +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 { + // the key is public, so this is Auth0 parity rather than security: login tokens are refused + 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"); + } + 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, + // 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, + }); + 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 parsed = auth0UserSchema.safeParse({ + ...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), + }); + 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)); + }, + + "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 4c206b4d..3f2592db 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`) @@ -151,11 +151,14 @@ 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, 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..6bc0e9c5 100644 --- a/packages/auth0/src/store/entities.ts +++ b/packages/auth0/src/store/entities.ts @@ -8,7 +8,10 @@ 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({}), }) .transform((user) => { if (!user.email) user.email = faker.internet.email({ firstName: user.name }); @@ -25,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/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/management-api.test.ts b/packages/auth0/test/management-api.test.ts new file mode 100644 index 00000000..feba5ab4 --- /dev/null +++ b/packages/auth0/test/management-api.test.ts @@ -0,0 +1,328 @@ +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 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 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); + }); + }); + + 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("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"); + }); + + 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("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("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" }); + + 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", + 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); + }); + }); +}); 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 = {