Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changes/auth0-management-api.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changes/auth0-user-metadata.md
Original file line number Diff line number Diff line change
@@ -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.
33 changes: 33 additions & 0 deletions packages/auth0/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand All @@ -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:
Expand All @@ -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://<simulator host>/api/v2/`. Scopes are not checked.

- `POST /api/v2/users` — `409` if the email is taken. The id is `auth0|<user_id>` (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.
2 changes: 1 addition & 1 deletion packages/auth0/src/handlers/auth0-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
};
Expand Down
13 changes: 12 additions & 1 deletion packages/auth0/src/handlers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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());

Expand All @@ -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);
Expand Down
249 changes: 249 additions & 0 deletions packages/auth0/src/handlers/management-api-handlers.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;

const jwks = createLocalJWKSet(JWKS as unknown as Parameters<typeof createLocalJWKSet>[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<ManagementRoutes, RequestHandler> => {
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() }),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
...(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."));
},
};
};
10 changes: 8 additions & 2 deletions packages/auth0/src/handlers/oauth-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down Expand Up @@ -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");
Expand All @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions packages/auth0/src/rules/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ export interface RuleUser {
family_name?: string | undefined;
name?: string | undefined;
identities: IdentityProvider[] | undefined;
user_metadata?: Record<string, unknown> | undefined;
app_metadata?: Record<string, unknown> | undefined;
}

type IdentityProvider = {
Expand Down
Loading
Loading