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 = {