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-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.
19 changes: 19 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 Down
8 changes: 7 additions & 1 deletion 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 @@ -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");
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
2 changes: 2 additions & 0 deletions packages/auth0/src/store/entities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
24 changes: 24 additions & 0 deletions packages/auth0/test/entities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({});
});
});
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"enabled": true,
"order": 1,
"stage": "login_success"
}
67 changes: 66 additions & 1 deletion packages/auth0/test/rules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
app_metadata?: Record<string, unknown>;
} = {
name: "Paul Waters",
email: "paulwaters.white@yahoo.com",
password: "12345",
Expand Down Expand Up @@ -162,6 +169,64 @@ describe("rules", () => {
});
});

describe("user and app metadata", () => {
let code: string;
let server: FoundationSimulatorListening<unknown>;

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 = {
Expand Down
Loading