Repository navigation
Preview: user metadata + Management API #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
daaain
wants to merge
4
commits into
main
Choose a base branch
from
feat/auth0-management-api
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
879b0f4
auth0: model user_metadata and app_metadata, expose them to rules
daaain ca63c71
auth0: serve a store-backed subset of the Management API
daaain fa4a920
auth0: tighten Management API per review
daaain 5323e33
auth0: Management API takes M2M tokens only, validates patched users
daaain File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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() }), | ||
| ...(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.")); | ||
| }, | ||
| }; | ||
| }; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.