@tatara/cli 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (19) hide show
  1. package/dist/bin.mjs +152 -51
  2. package/package.json +10 -1
  3. package/templates/features/auth-keycloak/files/src/core/auth/keycloak-plugin.ts +154 -0
  4. package/templates/features/auth-keycloak/files/src/core/auth/tests/keycloak-tokens.test.ts +109 -0
  5. package/templates/features/auth-keycloak/files+auth-orgs/src/core/auth/tests/keycloak-organizations.test.ts +36 -0
  6. package/templates/features/auth-keycloak/files+authz/src/core/auth/tests/keycloak-authz.test.ts +23 -0
  7. package/templates/features/base/files/README.md +1 -1
  8. package/templates/features/better-auth/files/src/core/auth/principal.ts +1 -0
  9. package/templates/features/better-auth/files/tests/helpers/auth.ts +2 -0
  10. package/templates/features/keycloak/files/src/core/auth/principal.ts +0 -27
  11. package/templates/features/keycloak/files/tests/helpers/auth.ts +0 -52
  12. package/templates/features/keycloak/files+drizzle/src/core/auth/users.ts +0 -18
  13. package/templates/features/keycloak/files+drizzle+mysql/src/core/auth/db/user.schema.ts +0 -10
  14. package/templates/features/keycloak/files+drizzle+postgres/src/core/auth/db/user.schema.ts +0 -10
  15. package/templates/features/keycloak/files+prisma/prisma/models/user.prisma +0 -10
  16. package/templates/features/keycloak/files+prisma/src/core/auth/users.ts +0 -13
  17. /package/templates/features/{keycloak → auth-keycloak}/files/keycloak/realm.json +0 -0
  18. /package/templates/features/{keycloak → auth-keycloak}/files/src/core/auth/commands/keycloak-token.ts +0 -0
  19. /package/templates/features/{keycloak → auth-keycloak}/files/src/core/auth/keycloak.ts +0 -0
package/dist/bin.mjs CHANGED
@@ -527,7 +527,7 @@ async function dev(project, flags = {}) {
527
527
  if (project.has("admin")) urls.push(`Admin API http://localhost:${env.ADMIN_PORT ?? "3001"}/docs`);
528
528
  if (project.has("email")) urls.push(`Mailpit http://localhost:${env.MAILPIT_UI_PORT ?? "8025"}`);
529
529
  if (project.has("storage")) urls.push(`Storage UI http://localhost:${env.STORAGE_CONSOLE_PORT ?? "9001"}`);
530
- if (project.has("keycloak")) urls.push(`Keycloak http://localhost:${env.KEYCLOAK_PORT ?? "8080"}/admin (admin/admin)`);
530
+ if (project.has("auth-keycloak")) urls.push(`Keycloak http://localhost:${env.KEYCLOAK_PORT ?? "8080"}/admin (admin/admin)`);
531
531
  console.log(`\n${urls.map((u) => ` ${pc.green("➜")} ${u}`).join("\n")}\n`);
532
532
  return new Promise((resolve) => {
533
533
  const stop = () => {
@@ -553,7 +553,7 @@ const PACKAGES = {
553
553
  drizzle: ["drizzle-orm", "drizzle-kit"],
554
554
  prisma: ["@prisma/client", "prisma"],
555
555
  "better-auth": ["better-auth"],
556
- keycloak: ["jose"],
556
+ "auth-keycloak": ["jose"],
557
557
  jobs: ["bullmq", "ioredis"]
558
558
  };
559
559
  async function doctor(project) {
@@ -1450,10 +1450,63 @@ const authOrgs = {
1450
1450
  ...adminFields
1451
1451
  ]
1452
1452
  };
1453
- const keycloak = {
1454
- id: "keycloak",
1455
- kind: "auth",
1456
- label: "Keycloak",
1453
+ const KEYCLOAK_AGENTS = `### Keycloak
1454
+
1455
+ - The API also accepts Keycloak access tokens (\`Authorization: Bearer <token>\`). The
1456
+ \`keycloakTokens()\` better-auth plugin (\`src/core/auth/keycloak-plugin.ts\`) verifies signature,
1457
+ issuer, audience \`__APP_NAME__-api\` and expiry; login happens in the frontend via OIDC (PKCE)
1458
+ against the \`__APP_NAME__-web\` client.
1459
+ - The token's subject is the user id. The user (and a \`keycloak\` account) is created in
1460
+ better-auth's tables on first use; a token whose email already belongs to another account is
1461
+ rejected. Service-account tokens become \`kind: "service"\` principals with no user row.
1462
+ - Roles come from the token (realm roles, \`roles\`, and client roles for the API); a role named like
1463
+ a built-in authz role grants it. The organization comes from the \`organization\` claim.
1464
+ - Local dev realm: \`keycloak/realm.json\` (users alice/alice with admin, bob/bob).
1465
+ \`__PM_EXEC__ tatara run keycloak:token --user alice\` prints a token for curl.
1466
+ - Tests sign tokens with a local key: \`keycloakToken()\` from \`#tests/helpers/auth\`.
1467
+ `;
1468
+ const KEYCLOAK_TEST_HELPER = [
1469
+ "const { publicKey, privateKey } = await generateKeyPair(\"RS256\");",
1470
+ "setKeySource(async (header) => {",
1471
+ " if (header.kid !== \"test\") throw new Error(\"unknown key\");",
1472
+ " return publicKey;",
1473
+ "});",
1474
+ "",
1475
+ "export interface KeycloakTokenOptions {",
1476
+ " sub?: string;",
1477
+ " email?: string;",
1478
+ " emailVerified?: boolean;",
1479
+ " roles?: string[];",
1480
+ " organization?: string[] | Record<string, unknown>;",
1481
+ " username?: string;",
1482
+ " issuer?: string;",
1483
+ " audience?: string;",
1484
+ " expiresIn?: string | number;",
1485
+ "}",
1486
+ "",
1487
+ "/** Signs an access token the API accepts, as if Keycloak issued it. */",
1488
+ "export async function keycloakToken(options: KeycloakTokenOptions = {}): Promise<string> {",
1489
+ " const sub = options.sub ?? newId();",
1490
+ " return new SignJWT({",
1491
+ " email: options.email ?? `user-${sub}@example.com`,",
1492
+ " email_verified: options.emailVerified ?? true,",
1493
+ " preferred_username: options.username ?? `user-${sub.slice(0, 8)}`,",
1494
+ " roles: options.roles ?? [\"user\"],",
1495
+ " ...(options.organization ? { organization: options.organization } : {}),",
1496
+ " })",
1497
+ " .setProtectedHeader({ alg: \"RS256\", kid: \"test\" })",
1498
+ " .setSubject(sub)",
1499
+ " .setIssuer(options.issuer ?? issuer)",
1500
+ " .setAudience(options.audience ?? env.KEYCLOAK_AUDIENCE)",
1501
+ " .setIssuedAt()",
1502
+ " .setExpirationTime(options.expiresIn ?? \"5m\")",
1503
+ " .sign(privateKey);",
1504
+ "}"
1505
+ ].join("\n");
1506
+ const authKeycloak = {
1507
+ id: "auth-keycloak",
1508
+ kind: "auth-method",
1509
+ label: "Keycloak tokens",
1457
1510
  dependencies: ["jose"],
1458
1511
  env: [
1459
1512
  {
@@ -1501,30 +1554,72 @@ const keycloak = {
1501
1554
  } },
1502
1555
  slots: [
1503
1556
  {
1504
- file: "src/core/auth/users.ts",
1505
- slot: "upsert",
1506
- when: ["drizzle", "postgres"],
1507
- content: "await db\n .insert(users)\n .values(values)\n .onConflictDoUpdate({ target: users.id, set: { email: values.email, name: values.name, updatedAt: new Date() } });"
1557
+ file: "src/core/auth/auth.ts",
1558
+ slot: "imports",
1559
+ content: "import { keycloakTokens } from \"#core/auth/keycloak-plugin\";"
1560
+ },
1561
+ {
1562
+ file: "src/core/auth/auth.ts",
1563
+ slot: "plugins",
1564
+ unless: ["auth-orgs"],
1565
+ content: "keycloakTokens(),",
1566
+ order: 800
1567
+ },
1568
+ {
1569
+ file: "src/core/auth/auth.ts",
1570
+ slot: "plugins",
1571
+ when: ["auth-orgs"],
1572
+ content: "keycloakTokens({ organizations: true }),",
1573
+ order: 800
1574
+ },
1575
+ {
1576
+ file: "src/core/auth/principal.ts",
1577
+ slot: "no-session",
1578
+ content: [
1579
+ "if (!session) {",
1580
+ " const header = c.req.header(\"authorization\");",
1581
+ " const token = header?.startsWith(\"Bearer \") ? header.slice(7) : undefined;",
1582
+ " const principal = token ? await auth.api.verifyKeycloakToken({ body: { token } }) : null;",
1583
+ " if (principal) c.set(\"principal\", principal);",
1584
+ "}"
1585
+ ].join("\n")
1508
1586
  },
1509
1587
  {
1510
- file: "src/core/auth/users.ts",
1511
- slot: "upsert",
1512
- when: ["drizzle", "mysql"],
1513
- content: "await db\n .insert(users)\n .values(values)\n .onDuplicateKeyUpdate({ set: { email: values.email, name: values.name } });"
1588
+ file: "tests/helpers/auth.ts",
1589
+ slot: "imports",
1590
+ content: "import { generateKeyPair, SignJWT } from \"jose\";"
1591
+ },
1592
+ {
1593
+ file: "tests/helpers/auth.ts",
1594
+ slot: "imports",
1595
+ content: "import { issuer, setKeySource } from \"#core/auth/keycloak\";"
1596
+ },
1597
+ {
1598
+ file: "tests/helpers/auth.ts",
1599
+ slot: "imports",
1600
+ content: "import { env } from \"#core/config/env\";"
1601
+ },
1602
+ {
1603
+ file: "tests/helpers/auth.ts",
1604
+ slot: "helpers",
1605
+ content: KEYCLOAK_TEST_HELPER
1606
+ },
1607
+ {
1608
+ file: "tests/helpers/auth.ts",
1609
+ slot: "authenticate",
1610
+ unless: ["auth-orgs"],
1611
+ content: [
1612
+ "if (options.roles) {",
1613
+ " const sub = newId();",
1614
+ " const token = await keycloakToken({ sub, roles: options.roles, email: options.email });",
1615
+ " return { userId: sub, headers: { authorization: `Bearer ${token}` } };",
1616
+ "}"
1617
+ ].join("\n")
1514
1618
  },
1515
1619
  {
1516
1620
  file: "AGENTS.md",
1517
1621
  slot: "sections",
1518
- content: `### Keycloak
1519
-
1520
- - The API is a resource server: it verifies Keycloak access tokens (signature, issuer,
1521
- audience \`__APP_NAME__-api\`, expiry). Login happens in the frontend via OIDC (PKCE) against
1522
- the \`__APP_NAME__-web\` client.
1523
- - Roles come from the token's \`roles\` claim (realm roles) and client roles for the API.
1524
- - Local dev realm: \`keycloak/realm.json\` (users alice/alice with admin, bob/bob).
1525
- \`__PM_EXEC__ tatara run keycloak:token --user alice\` prints a token for curl.
1526
- - Tests sign tokens with a local key; see \`#tests/helpers/auth\`.
1527
- `
1622
+ content: KEYCLOAK_AGENTS
1528
1623
  }
1529
1624
  ]
1530
1625
  };
@@ -1914,7 +2009,7 @@ const FEATURES = Object.fromEntries([
1914
2009
  authPhoneOtp,
1915
2010
  authPassword,
1916
2011
  authOrgs,
1917
- keycloak,
2012
+ authKeycloak,
1918
2013
  auth,
1919
2014
  authz,
1920
2015
  jobs,
@@ -1938,12 +2033,12 @@ function validateOptions(input) {
1938
2033
  const hasDb = options.database !== "none";
1939
2034
  if (options.auth !== "none" && !hasDb) throw new OptionsError(`Auth (${options.auth}) needs a database.`);
1940
2035
  if (options.auth === "better-auth") {
1941
- if (options.authMethods.filter((m) => m !== "orgs").length === 0) throw new OptionsError("better-auth needs at least one sign-in method: email-otp, phone-otp or password.");
2036
+ if (options.authMethods.filter((m) => m !== "orgs").length === 0) throw new OptionsError("better-auth needs at least one sign-in method: email-otp, phone-otp, password or keycloak.");
1942
2037
  } else if (options.authMethods.length > 0) throw new OptionsError("Auth methods only apply to better-auth.");
1943
2038
  for (const addon of options.addons) {
1944
2039
  if ((addon === "authz" || addon === "admin") && options.auth === "none") throw new OptionsError(`The ${addon} add-on needs an auth provider.`);
1945
2040
  if (addon === "authz" && !hasDb) throw new OptionsError("The authz add-on needs a database.");
1946
- if (addon === "admin" && options.auth === "better-auth" && !options.authMethods.includes("orgs")) throw new OptionsError("The admin add-on with better-auth needs the \"orgs\" method (it provides user roles).");
2041
+ if (addon === "admin" && options.auth === "better-auth" && !options.authMethods.includes("orgs") && !options.authMethods.includes("keycloak")) throw new OptionsError("The admin add-on with better-auth needs the \"orgs\" or \"keycloak\" method (they provide user roles).");
1947
2042
  }
1948
2043
  return options;
1949
2044
  }
@@ -2211,7 +2306,7 @@ async function renderApp(options, generate = {}) {
2211
2306
  if (compose) files.set("compose.yaml", compose);
2212
2307
  if (options.packageManager === "pnpm") files.set("pnpm-workspace.yaml", pnpmWorkspace(features));
2213
2308
  const config = {
2214
- $schema: "https://raw.githubusercontent.com/mohammed-bageri/tatara/main/schema/tatara.json",
2309
+ $schema: "https://raw.githubusercontent.com/mohammed-bageri/tatara-cli/main/schema/tatara.json",
2215
2310
  version: cliVersion(),
2216
2311
  name: options.name,
2217
2312
  orm: options.orm,
@@ -2550,17 +2645,25 @@ const DATABASES = [
2550
2645
  "mysql",
2551
2646
  "none"
2552
2647
  ];
2553
- const AUTHS = [
2554
- "better-auth",
2555
- "keycloak",
2556
- "none"
2557
- ];
2648
+ const AUTHS = ["better-auth", "none"];
2558
2649
  const METHODS = [
2559
2650
  "email-otp",
2560
2651
  "phone-otp",
2561
2652
  "password",
2653
+ "keycloak",
2562
2654
  "orgs"
2563
2655
  ];
2656
+ /** `--auth keycloak` (before 0.1.2) is better-auth with the keycloak method. */
2657
+ function normalizeAuthFlags(flags) {
2658
+ if (flags.auth !== "keycloak") return flags;
2659
+ const methods = new Set((flags.authMethods ?? "").split(",").filter(Boolean));
2660
+ methods.add("keycloak");
2661
+ return {
2662
+ ...flags,
2663
+ auth: "better-auth",
2664
+ authMethods: [...methods].join(",")
2665
+ };
2666
+ }
2564
2667
  const ADDONS = [
2565
2668
  "authz",
2566
2669
  "jobs",
@@ -2628,22 +2731,14 @@ async function prompt(name, flags) {
2628
2731
  let auth = "none";
2629
2732
  if (database !== "none") auth = flags.auth ? oneOf(flags.auth, AUTHS, "auth") : answer(await p.select({
2630
2733
  message: "Auth",
2631
- options: [
2632
- {
2633
- value: "better-auth",
2634
- label: "better-auth",
2635
- hint: "sign-in handled by the API"
2636
- },
2637
- {
2638
- value: "keycloak",
2639
- label: "Keycloak",
2640
- hint: "API verifies Keycloak tokens"
2641
- },
2642
- {
2643
- value: "none",
2644
- label: "None"
2645
- }
2646
- ]
2734
+ options: [{
2735
+ value: "better-auth",
2736
+ label: "better-auth",
2737
+ hint: "sign-in handled by the API"
2738
+ }, {
2739
+ value: "none",
2740
+ label: "None"
2741
+ }]
2647
2742
  }));
2648
2743
  let authMethods = [];
2649
2744
  if (auth === "better-auth") authMethods = flags.authMethods ? listOf(flags.authMethods, METHODS, "auth-methods") : answer(await p.multiselect({
@@ -2662,6 +2757,11 @@ async function prompt(name, flags) {
2662
2757
  value: "password",
2663
2758
  label: "Email + password"
2664
2759
  },
2760
+ {
2761
+ value: "keycloak",
2762
+ label: "Keycloak tokens",
2763
+ hint: "API accepts Keycloak access tokens"
2764
+ },
2665
2765
  {
2666
2766
  value: "orgs",
2667
2767
  label: "Organizations + roles",
@@ -2736,7 +2836,8 @@ async function run(cmd, args, cwd, label) {
2736
2836
  }
2737
2837
  spinner?.stop(label);
2738
2838
  }
2739
- async function newApp(dir, flags) {
2839
+ async function newApp(dir, rawFlags) {
2840
+ const flags = normalizeAuthFlags(rawFlags);
2740
2841
  const interactive = !flags.yes && process.stdout.isTTY;
2741
2842
  if (interactive) p.intro(pc.bold("tatara"));
2742
2843
  let target = dir;
@@ -2917,7 +3018,7 @@ server.on("exit", () => process.exit(0));
2917
3018
  //#endregion
2918
3019
  //#region src/bin.ts
2919
3020
  const program = new Command("tatara").description("Create and grow opinionated Hono backends").version(cliVersion());
2920
- program.command("new").description("Create a new app").argument("[dir]", "directory to create").option("--orm <orm>", "drizzle | prisma | none").option("--db <database>", "postgres | mysql | none").option("--auth <provider>", "better-auth | keycloak | none").option("--auth-methods <list>", "email-otp,phone-otp,password,orgs (better-auth)").option("--addons <list>", "authz,jobs,admin,storage,email,i18n").option("--pm <pm>", "pnpm | npm | bun").option("-y, --yes", "skip prompts and use flags/defaults").option("--no-install", "do not install dependencies").option("--no-git", "do not initialise git").action(async (dir, flags) => {
3021
+ program.command("new").description("Create a new app").argument("[dir]", "directory to create").option("--orm <orm>", "drizzle | prisma | none").option("--db <database>", "postgres | mysql | none").option("--auth <provider>", "better-auth | none (keycloak = better-auth with --auth-methods keycloak)").option("--auth-methods <list>", "email-otp,phone-otp,password,orgs (better-auth)").option("--addons <list>", "authz,jobs,admin,storage,email,i18n").option("--pm <pm>", "pnpm | npm | bun").option("-y, --yes", "skip prompts and use flags/defaults").option("--no-install", "do not install dependencies").option("--no-git", "do not initialise git").action(async (dir, flags) => {
2921
3022
  await newApp(dir, flags);
2922
3023
  });
2923
3024
  program.command("sync").description("Regenerate src/generated from discovered module files").option("--quiet", "no output").action(async (flags) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tatara/cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Create and grow opinionated Hono backends",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -28,6 +28,15 @@
28
28
  "publishConfig": {
29
29
  "access": "public"
30
30
  },
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/mohammed-bageri/tatara-cli.git",
34
+ "directory": "packages/cli"
35
+ },
36
+ "homepage": "https://github.com/mohammed-bageri/tatara-cli#readme",
37
+ "bugs": {
38
+ "url": "https://github.com/mohammed-bageri/tatara-cli/issues"
39
+ },
31
40
  "scripts": {
32
41
  "build": "tsdown",
33
42
  "typecheck": "tsc --noEmit"
@@ -0,0 +1,154 @@
1
+ import type { BetterAuthPlugin } from "better-auth";
2
+ import { createAuthEndpoint } from "better-auth/api";
3
+ import { z } from "zod";
4
+ import {
5
+ type KeycloakClaims,
6
+ organizationOf,
7
+ rolesOf,
8
+ verifyAccessToken,
9
+ } from "#core/auth/keycloak";
10
+ import { logger } from "#core/logging/logger";
11
+
12
+ export interface KeycloakPrincipal {
13
+ userId: string;
14
+ orgId: string | null;
15
+ roles: string[];
16
+ kind: "user" | "service";
17
+ claims: KeycloakClaims;
18
+ }
19
+
20
+ export interface KeycloakTokensOptions {
21
+ /** Record the token's organization as a better-auth organization + membership. */
22
+ organizations?: boolean;
23
+ }
24
+
25
+ type Adapter = Parameters<NonNullable<BetterAuthPlugin["init"]>>[0]["adapter"];
26
+
27
+ const ACCOUNT_PROVIDER = "keycloak";
28
+
29
+ async function ensureUser(adapter: Adapter, claims: KeycloakClaims): Promise<boolean> {
30
+ // an unverified email could claim someone else's address, so it is never stored
31
+ const verified = claims.email_verified === true && Boolean(claims.email);
32
+ const email = verified
33
+ ? (claims.email as string).toLowerCase()
34
+ : `${claims.sub}@users.keycloak.invalid`;
35
+ const name = claims.name ?? claims.preferred_username ?? claims.sub;
36
+ const owner = await adapter.findOne<{ id: string }>({
37
+ model: "user",
38
+ where: [{ field: "email", value: email }],
39
+ });
40
+ const existing = await adapter.findOne<{ id: string }>({
41
+ model: "user",
42
+ where: [{ field: "id", value: claims.sub }],
43
+ });
44
+ if (existing) {
45
+ const emailFree = !owner || owner.id === claims.sub;
46
+ if (!emailFree) logger.warn({ sub: claims.sub }, "keycloak email belongs to another account");
47
+ await adapter.update({
48
+ model: "user",
49
+ where: [{ field: "id", value: claims.sub }],
50
+ update: {
51
+ name,
52
+ ...(emailFree ? { email, emailVerified: verified } : {}),
53
+ updatedAt: new Date(),
54
+ },
55
+ });
56
+ return true;
57
+ }
58
+ if (owner) {
59
+ // never attach a Keycloak identity to an account that signs in another way
60
+ logger.warn({ sub: claims.sub }, "keycloak token email belongs to another account");
61
+ return false;
62
+ }
63
+ const now = new Date();
64
+ await adapter.create({
65
+ model: "user",
66
+ data: { id: claims.sub, email, emailVerified: verified, name, createdAt: now, updatedAt: now },
67
+ forceAllowId: true,
68
+ });
69
+ await adapter.create({
70
+ model: "account",
71
+ data: {
72
+ userId: claims.sub,
73
+ accountId: claims.sub,
74
+ providerId: ACCOUNT_PROVIDER,
75
+ createdAt: now,
76
+ updatedAt: now,
77
+ },
78
+ });
79
+ return true;
80
+ }
81
+
82
+ async function ensureMembership(adapter: Adapter, orgId: string, userId: string): Promise<void> {
83
+ const org = await adapter.findOne({
84
+ model: "organization",
85
+ where: [{ field: "id", value: orgId }],
86
+ });
87
+ if (!org) {
88
+ await adapter.create({
89
+ model: "organization",
90
+ data: { id: orgId, name: orgId, slug: orgId, createdAt: new Date() },
91
+ forceAllowId: true,
92
+ });
93
+ }
94
+ const member = await adapter.findOne({
95
+ model: "member",
96
+ where: [
97
+ { field: "organizationId", value: orgId },
98
+ { field: "userId", value: userId },
99
+ ],
100
+ });
101
+ if (!member) {
102
+ await adapter.create({
103
+ model: "member",
104
+ data: { organizationId: orgId, userId, role: "member", createdAt: new Date() },
105
+ });
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Lets the API accept access tokens issued by Keycloak (a resource server).
111
+ * Verified tokens become principals; their users and organizations are kept in better-auth's tables.
112
+ */
113
+ export function keycloakTokens(options: KeycloakTokensOptions = {}) {
114
+ // subjects already synced by this process; tokens are re-verified on every request regardless
115
+ const synced = new Set<string>();
116
+ return {
117
+ id: "keycloak-tokens",
118
+ endpoints: {
119
+ // no path: callable only on the server as auth.api.verifyKeycloakToken
120
+ verifyKeycloakToken: createAuthEndpoint(
121
+ { method: "POST", body: z.object({ token: z.string() }) },
122
+ async (ctx): Promise<KeycloakPrincipal | null> => {
123
+ let claims: KeycloakClaims;
124
+ try {
125
+ claims = await verifyAccessToken(ctx.body.token);
126
+ } catch (error) {
127
+ logger.debug({ err: error }, "rejected keycloak token");
128
+ return null;
129
+ }
130
+ const isService = claims.preferred_username?.startsWith("service-account-") ?? false;
131
+ const orgId = organizationOf(claims);
132
+ if (!isService) {
133
+ const key = `${claims.sub}|${orgId ?? ""}|${claims.email ?? ""}`;
134
+ if (!synced.has(key)) {
135
+ const { adapter } = ctx.context;
136
+ if (!(await ensureUser(adapter, claims))) return null;
137
+ if (orgId && options.organizations)
138
+ await ensureMembership(adapter, orgId, claims.sub);
139
+ if (synced.size > 10_000) synced.clear();
140
+ synced.add(key);
141
+ }
142
+ }
143
+ return {
144
+ userId: claims.sub,
145
+ orgId,
146
+ roles: rolesOf(claims),
147
+ kind: isService ? "service" : "user",
148
+ claims,
149
+ };
150
+ },
151
+ ),
152
+ },
153
+ } satisfies BetterAuthPlugin;
154
+ }
@@ -0,0 +1,109 @@
1
+ import { generateKeyPair, SignJWT } from "jose";
2
+ import { beforeEach, describe, expect, it } from "vitest";
3
+ import { auth } from "#core/auth/auth";
4
+ import { issuer } from "#core/auth/keycloak";
5
+ import { env } from "#core/config/env";
6
+ import { newId } from "#core/db/id";
7
+ import { api } from "#tests/helpers/api";
8
+ import { createTestUser, keycloakToken } from "#tests/helpers/auth";
9
+ import { resetDatabase } from "#tests/helpers/db";
10
+
11
+ interface Me {
12
+ data: { userId: string; orgId: string | null; roles: string[]; kind: string };
13
+ }
14
+
15
+ const bearer = (token: string) => ({ authorization: `Bearer ${token}` });
16
+ const me = (token: string) => api.get<Me>("/api/me", { headers: bearer(token) });
17
+
18
+ // read through better-auth's adapter so the tests work with any ORM
19
+ async function find<T>(model: string, where: Record<string, string>): Promise<T | null> {
20
+ const { adapter } = await auth.$context;
21
+ return adapter.findOne<T>({
22
+ model,
23
+ where: Object.entries(where).map(([field, value]) => ({ field, value })),
24
+ });
25
+ }
26
+ type UserRow = { email: string; emailVerified: boolean };
27
+
28
+ beforeEach(async () => {
29
+ await resetDatabase();
30
+ });
31
+
32
+ describe("Keycloak access tokens", () => {
33
+ it("authenticates a valid token as the token's subject", async () => {
34
+ const sub = newId();
35
+ const res = await me(await keycloakToken({ sub, roles: ["teacher"] }));
36
+ expect(res.status).toBe(200);
37
+ expect(res.body.data).toMatchObject({ userId: sub, kind: "user", orgId: null });
38
+ expect(res.body.data.roles).toContain("teacher");
39
+ });
40
+
41
+ it("creates the local user and links a keycloak account on first use", async () => {
42
+ const sub = newId();
43
+ await me(await keycloakToken({ sub, email: "kc@example.com" }));
44
+ const user = await find<UserRow>("user", { id: sub });
45
+ expect(user?.email).toBe("kc@example.com");
46
+ const account = await find<{ accountId: string }>("account", {
47
+ userId: sub,
48
+ providerId: "keycloak",
49
+ });
50
+ expect(account?.accountId).toBe(sub);
51
+ });
52
+
53
+ it.each([
54
+ ["an expired token", { expiresIn: Math.floor(Date.now() / 1000) - 60 }],
55
+ ["a token from another issuer", { issuer: "https://evil.example.com/realms/app" }],
56
+ ["a token for another audience", { audience: "someone-else" }],
57
+ ])("rejects %s", async (_label, options) => {
58
+ expect((await me(await keycloakToken(options))).status).toBe(401);
59
+ });
60
+
61
+ it("rejects a token signed with an unknown key", async () => {
62
+ const { privateKey } = await generateKeyPair("RS256");
63
+ const forged = await new SignJWT({ roles: ["admin"] })
64
+ .setProtectedHeader({ alg: "RS256", kid: "test" })
65
+ .setSubject(newId())
66
+ .setIssuer(issuer)
67
+ .setAudience(env.KEYCLOAK_AUDIENCE)
68
+ .setExpirationTime("5m")
69
+ .sign(privateKey);
70
+ expect((await me(forged)).status).toBe(401);
71
+ });
72
+
73
+ it("rejects a bearer value that is not a token", async () => {
74
+ expect((await me("not-a-token")).status).toBe(401);
75
+ });
76
+
77
+ it("treats service-account tokens as services without a user row", async () => {
78
+ const sub = newId();
79
+ const res = await me(await keycloakToken({ sub, username: "service-account-billing" }));
80
+ expect(res.body.data).toMatchObject({ userId: sub, kind: "service" });
81
+ expect(await find<UserRow>("user", { id: sub })).toBeNull();
82
+ });
83
+
84
+
85
+ it("rejects a token whose email belongs to another account", async () => {
86
+ await createTestUser({ email: "taken@example.com" });
87
+ const sub = newId();
88
+ expect((await me(await keycloakToken({ sub, email: "taken@example.com" }))).status).toBe(401);
89
+ expect(await find<UserRow>("user", { id: sub })).toBeNull();
90
+ });
91
+
92
+
93
+ it("does not store an email Keycloak has not verified", async () => {
94
+ const sub = newId();
95
+ await me(await keycloakToken({ sub, email: "victim@example.com", emailVerified: false }));
96
+ const user = await find<UserRow>("user", { id: sub });
97
+ expect(user?.email).not.toBe("victim@example.com");
98
+ expect(user?.emailVerified).toBe(false);
99
+ });
100
+
101
+ it("keeps the old email when the token's new email belongs to another account", async () => {
102
+ await createTestUser({ email: "taken@example.com" });
103
+ const sub = newId();
104
+ await me(await keycloakToken({ sub, email: "first@example.com" }));
105
+ const res = await me(await keycloakToken({ sub, email: "taken@example.com" }));
106
+ expect(res.status).toBe(200);
107
+ expect((await find<UserRow>("user", { id: sub }))?.email).toBe("first@example.com");
108
+ });
109
+ });
@@ -0,0 +1,36 @@
1
+ import { beforeEach, describe, expect, it } from "vitest";
2
+ import { auth } from "#core/auth/auth";
3
+ import { newId } from "#core/db/id";
4
+ import { api } from "#tests/helpers/api";
5
+ import { keycloakToken } from "#tests/helpers/auth";
6
+ import { resetDatabase } from "#tests/helpers/db";
7
+
8
+ interface Me {
9
+ data: { orgId: string | null };
10
+ }
11
+
12
+ const me = (token: string) =>
13
+ api.get<Me>("/api/me", { headers: { authorization: `Bearer ${token}` } });
14
+
15
+ // read through better-auth's adapter so the tests work with any ORM
16
+ async function find<T>(model: string, where: Record<string, string>): Promise<T | null> {
17
+ const { adapter } = await auth.$context;
18
+ return adapter.findOne<T>({
19
+ model,
20
+ where: Object.entries(where).map(([field, value]) => ({ field, value })),
21
+ });
22
+ }
23
+
24
+ beforeEach(async () => {
25
+ await resetDatabase();
26
+ });
27
+
28
+ describe("Keycloak access tokens with organizations", () => {
29
+ it("takes the organization from the token and records the membership", async () => {
30
+ const sub = newId();
31
+ const res = await me(await keycloakToken({ sub, organization: ["riyadh-school"] }));
32
+ expect(res.body.data.orgId).toBe("riyadh-school");
33
+ expect(await find("organization", { id: "riyadh-school" })).not.toBeNull();
34
+ expect(await find("member", { organizationId: "riyadh-school", userId: sub })).not.toBeNull();
35
+ });
36
+ });
@@ -0,0 +1,23 @@
1
+ import { beforeEach, describe, expect, it } from "vitest";
2
+ import { syncSystemRoles } from "#core/authz/services/sync";
3
+ import { api } from "#tests/helpers/api";
4
+ import { keycloakToken } from "#tests/helpers/auth";
5
+ import { resetDatabase } from "#tests/helpers/db";
6
+
7
+ const bearer = (token: string) => ({ authorization: `Bearer ${token}` });
8
+
9
+ beforeEach(async () => {
10
+ await resetDatabase();
11
+ });
12
+
13
+ describe("Keycloak access tokens with authz", () => {
14
+ it("lets token roles match built-in authz roles", async () => {
15
+ await syncSystemRoles();
16
+ const admin = await keycloakToken({ roles: ["admin"] });
17
+ const user = await keycloakToken({ roles: ["user"] });
18
+ expect((await api.get("/api/authorization/roles", { headers: bearer(admin) })).status).toBe(
19
+ 200,
20
+ );
21
+ expect((await api.get("/api/authorization/roles", { headers: bearer(user) })).status).toBe(403);
22
+ });
23
+ });
@@ -1,6 +1,6 @@
1
1
  # __APP_NAME__
2
2
 
3
- A Hono API created with [tatara](https://github.com/mohammed-bageri/tatara).
3
+ A Hono API created with [tatara](https://github.com/mohammed-bageri/tatara-cli).
4
4
 
5
5
  ## Getting started
6
6
 
@@ -36,5 +36,6 @@ export const attachPrincipal = createMiddleware<AppEnv>(async (c, next) => {
36
36
  claims: {},
37
37
  });
38
38
  }
39
+ // tatara:slot no-session
39
40
  await next();
40
41
  });
@@ -26,6 +26,7 @@ export async function createTestUser(options: AsUserOptions = {}): Promise<strin
26
26
  export async function authenticate(
27
27
  options: AsUserOptions = {},
28
28
  ): Promise<{ userId: string; headers: Record<string, string> }> {
29
+ // tatara:slot authenticate
29
30
  const userId = await createTestUser(options);
30
31
  const ctx = await auth.$context;
31
32
  const session = await ctx.internalAdapter.createSession(userId);
@@ -38,3 +39,4 @@ export async function asUser(options: AsUserOptions = {}): Promise<Record<string
38
39
  // tatara:slot as-user
39
40
  return user.headers;
40
41
  }
42
+ // tatara:slot helpers
@@ -1,27 +0,0 @@
1
- import { createMiddleware } from "hono/factory";
2
- import { organizationOf, rolesOf, verifyAccessToken } from "#core/auth/keycloak";
3
- import { ensureLocalUser } from "#core/auth/users";
4
- import type { AppEnv } from "#core/http/context";
5
-
6
- /** Verifies `Authorization: Bearer <Keycloak access token>` into `c.var.principal`. */
7
- export const attachPrincipal = createMiddleware<AppEnv>(async (c, next) => {
8
- const header = c.req.header("authorization");
9
- const token = header?.startsWith("Bearer ") ? header.slice(7) : undefined;
10
- if (token) {
11
- try {
12
- const claims = await verifyAccessToken(token);
13
- const isService = claims.preferred_username?.startsWith("service-account-") ?? false;
14
- if (!isService) await ensureLocalUser(claims);
15
- c.set("principal", {
16
- userId: claims.sub,
17
- orgId: organizationOf(claims),
18
- roles: rolesOf(claims),
19
- kind: isService ? "service" : "user",
20
- claims,
21
- });
22
- } catch (error) {
23
- c.get("logger").debug({ err: error }, "rejected bearer token");
24
- }
25
- }
26
- await next();
27
- });
@@ -1,52 +0,0 @@
1
- import { generateKeyPair, SignJWT } from "jose";
2
- import { issuer, setKeySource } from "#core/auth/keycloak";
3
- import { env } from "#core/config/env";
4
- import { newId } from "#core/db/id";
5
- // tatara:slot imports
6
-
7
- const { publicKey, privateKey } = await generateKeyPair("RS256");
8
- setKeySource(async (header) => {
9
- if (header.kid !== "test") throw new Error("unknown key");
10
- return publicKey;
11
- });
12
-
13
- export interface AsUserOptions {
14
- sub?: string;
15
- email?: string;
16
- roles?: string[];
17
- orgId?: string;
18
- // tatara:slot options
19
- }
20
-
21
- /** Signs a token the API accepts, as if Keycloak issued it for a new user. */
22
- export async function tokenFor(options: AsUserOptions = {}): Promise<string> {
23
- const sub = options.sub ?? newId();
24
- return new SignJWT({
25
- email: options.email ?? `user-${sub}@example.com`,
26
- preferred_username: `user-${sub.slice(0, 8)}`,
27
- roles: options.roles ?? ["user"],
28
- ...(options.orgId ? { organization: [options.orgId] } : {}),
29
- })
30
- .setProtectedHeader({ alg: "RS256", kid: "test" })
31
- .setSubject(sub)
32
- .setIssuer(issuer)
33
- .setAudience(env.KEYCLOAK_AUDIENCE)
34
- .setIssuedAt()
35
- .setExpirationTime("5m")
36
- .sign(privateKey);
37
- }
38
-
39
- /** A new user (random subject) with a signed access token. */
40
- export async function authenticate(
41
- options: AsUserOptions = {},
42
- ): Promise<{ userId: string; headers: Record<string, string> }> {
43
- const sub = options.sub ?? newId();
44
- return { userId: sub, headers: { authorization: `Bearer ${await tokenFor({ ...options, sub })}` } };
45
- }
46
-
47
- /** Headers that authenticate as a new user. */
48
- export async function asUser(options: AsUserOptions = {}): Promise<Record<string, string>> {
49
- const user = await authenticate(options);
50
- // tatara:slot as-user
51
- return user.headers;
52
- }
@@ -1,18 +0,0 @@
1
- import type { KeycloakClaims } from "#core/auth/keycloak";
2
- import { users } from "#core/auth/db/user.schema";
3
- import { db } from "#core/db/client";
4
-
5
- const known = new Set<string>();
6
-
7
- /** Creates the local user row for a token subject the first time it is seen. */
8
- export async function ensureLocalUser(claims: KeycloakClaims): Promise<void> {
9
- if (known.has(claims.sub)) return;
10
- const values = {
11
- id: claims.sub,
12
- email: claims.email ?? null,
13
- name: claims.name ?? claims.preferred_username ?? null,
14
- };
15
- // tatara:slot upsert
16
- if (known.size > 10_000) known.clear();
17
- known.add(claims.sub);
18
- }
@@ -1,10 +0,0 @@
1
- import { mysqlTable, timestamp, varchar } from "drizzle-orm/mysql-core";
2
-
3
- /** Local copy of Keycloak users (id = token subject), created on first request. */
4
- export const users = mysqlTable("users", {
5
- id: varchar({ length: 36 }).primaryKey(),
6
- email: varchar({ length: 255 }),
7
- name: varchar({ length: 255 }),
8
- createdAt: timestamp({ fsp: 3, mode: "date" }).notNull().defaultNow(),
9
- updatedAt: timestamp({ fsp: 3, mode: "date" }).notNull().defaultNow(),
10
- });
@@ -1,10 +0,0 @@
1
- import { pgTable, text, timestamp } from "drizzle-orm/pg-core";
2
-
3
- /** Local copy of Keycloak users (id = token subject), created on first request. */
4
- export const users = pgTable("users", {
5
- id: text().primaryKey(),
6
- email: text(),
7
- name: text(),
8
- createdAt: timestamp({ withTimezone: true, mode: "date" }).notNull().defaultNow(),
9
- updatedAt: timestamp({ withTimezone: true, mode: "date" }).notNull().defaultNow(),
10
- });
@@ -1,10 +0,0 @@
1
- /// Local copy of Keycloak users (id = token subject), created on first request.
2
- model User {
3
- id String @id @db.VarChar(36)
4
- email String? @db.VarChar(255)
5
- name String? @db.VarChar(255)
6
- createdAt DateTime @default(now()) @map("created_at")
7
- updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
8
-
9
- @@map("users")
10
- }
@@ -1,13 +0,0 @@
1
- import type { KeycloakClaims } from "#core/auth/keycloak";
2
- import { db } from "#core/db/client";
3
-
4
- const known = new Set<string>();
5
-
6
- /** Creates the local user row for a token subject the first time it is seen. */
7
- export async function ensureLocalUser(claims: KeycloakClaims): Promise<void> {
8
- if (known.has(claims.sub)) return;
9
- const data = { email: claims.email ?? null, name: claims.name ?? claims.preferred_username ?? null };
10
- await db.user.upsert({ where: { id: claims.sub }, create: { id: claims.sub, ...data }, update: data });
11
- if (known.size > 10_000) known.clear();
12
- known.add(claims.sub);
13
- }