@tatara/cli 0.4.0 → 0.5.0

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.
package/dist/bin.mjs CHANGED
@@ -837,7 +837,7 @@ const admin = {
837
837
  {
838
838
  file: "src/core/http/app.ts",
839
839
  slot: "imports",
840
- content: "import { requireRole } from \"#core/auth/middleware\";"
840
+ content: "import { requireRole } from \"@tatara/auth\";"
841
841
  },
842
842
  {
843
843
  file: "src/core/http/app.ts",
@@ -1077,6 +1077,7 @@ const auth = {
1077
1077
  id: "auth",
1078
1078
  kind: "auth",
1079
1079
  label: "Auth (shared)",
1080
+ dependencies: ["@tatara/auth"],
1080
1081
  slots: [
1081
1082
  {
1082
1083
  file: "src/core/http/app.ts",
@@ -1107,7 +1108,7 @@ const auth = {
1107
1108
 
1108
1109
  - \`c.var.principal\` is set for every request with valid credentials: \`{ userId, orgId, roles, kind }\`.
1109
1110
  - Protect routes with \`middleware: [requireAuth()]\` or \`requireRole("admin")\` from
1110
- \`#core/auth/middleware\`, and declare \`security: [{ bearerAuth: [] }]\` so the route is
1111
+ \`@tatara/auth\`, and declare \`security: [{ bearerAuth: [] }]\` so the route is
1111
1112
  documented as protected. Read the caller with \`currentPrincipal(c)\`.
1112
1113
  - In tests, \`asUser()\` from \`#tests/helpers/auth\` returns headers for a fresh authenticated user.
1113
1114
  `
@@ -1121,7 +1122,7 @@ const BETTER_AUTH_AGENTS = `### better-auth
1121
1122
  - Sessions work as cookies (browsers) and as \`Authorization: Bearer <session token>\` (mobile, APIs).
1122
1123
  - Outside production, every email and SMS is kept in an in-memory outbox:
1123
1124
  \`GET /api/dev/outbox?to=<address>\` returns the latest messages, including sign-in codes.
1124
- - To send real SMS, implement \`SmsSender\` in \`src/core/auth/sms.ts\`.
1125
+ - To send real SMS, implement \`SmsSender\` (from \`@tatara/auth\`) in \`src/core/auth/sms.ts\`.
1125
1126
  `;
1126
1127
  const drizzlePg = ["drizzle", "postgres"];
1127
1128
  const drizzleMy = ["drizzle", "mysql"];
@@ -1161,12 +1162,12 @@ const betterAuth = {
1161
1162
  {
1162
1163
  file: "src/core/http/app.ts",
1163
1164
  slot: "imports",
1164
- content: "import { outboxEnabled, readOutbox } from \"#core/auth/delivery\";"
1165
+ content: "import { outbox } from \"#core/auth/delivery\";"
1165
1166
  },
1166
1167
  {
1167
1168
  file: "src/core/http/app.ts",
1168
1169
  slot: "imports",
1169
- content: "import { mergeAuthOpenApi } from \"#core/auth/openapi\";"
1170
+ content: "import { mergeAuthOpenApi } from \"@tatara/auth\";"
1170
1171
  },
1171
1172
  {
1172
1173
  file: "src/core/http/app.ts",
@@ -1178,7 +1179,7 @@ const betterAuth = {
1178
1179
  slot: "openapi-options",
1179
1180
  content: [
1180
1181
  "async extend(document) {",
1181
- " if (name === \"api\") await mergeAuthOpenApi(document, `${API_PREFIX}/auth`);",
1182
+ " if (name === \"api\") await mergeAuthOpenApi(document, auth, `${API_PREFIX}/auth`);",
1182
1183
  "},"
1183
1184
  ].join("\n")
1184
1185
  },
@@ -1189,8 +1190,8 @@ const betterAuth = {
1189
1190
  content: [
1190
1191
  "if (name === \"api\") {",
1191
1192
  " app.on([\"GET\", \"POST\"], `${API_PREFIX}/auth/*`, (c) => auth.handler(c.req.raw));",
1192
- " if (outboxEnabled()) {",
1193
- " app.get(`${API_PREFIX}/dev/outbox`, (c) => c.json({ messages: readOutbox(c.req.query(\"to\")) }));",
1193
+ " if (outbox.enabled) {",
1194
+ " app.get(`${API_PREFIX}/dev/outbox`, (c) => c.json({ messages: outbox.read(c.req.query(\"to\")) }));",
1194
1195
  " }",
1195
1196
  "}"
1196
1197
  ].join("\n")
@@ -1468,64 +1469,11 @@ const authOrgs = {
1468
1469
  ...adminFields
1469
1470
  ]
1470
1471
  };
1471
- const KEYCLOAK_AGENTS = `### Keycloak
1472
-
1473
- - The API also accepts Keycloak access tokens (\`Authorization: Bearer <token>\`). The
1474
- \`keycloakTokens()\` better-auth plugin (\`src/core/auth/keycloak-plugin.ts\`) verifies signature,
1475
- issuer, audience \`__APP_NAME__-api\` and expiry; login happens in the frontend via OIDC (PKCE)
1476
- against the \`__APP_NAME__-web\` client.
1477
- - The token's subject is the user id. The user (and a \`keycloak\` account) is created in
1478
- better-auth's tables on first use; a token whose email already belongs to another account is
1479
- rejected. Service-account tokens become \`kind: "service"\` principals with no user row.
1480
- - Roles come from the token (realm roles, \`roles\`, and client roles for the API); a role named like
1481
- a built-in authz role grants it. The organization comes from the \`organization\` claim.
1482
- - Local dev realm: \`keycloak/realm.json\` (users alice/alice with admin, bob/bob).
1483
- \`__PM_EXEC__ tatara run keycloak:token --user alice\` prints a token for curl.
1484
- - Tests sign tokens with a local key: \`keycloakToken()\` from \`#tests/helpers/auth\`.
1485
- `;
1486
- const KEYCLOAK_TEST_HELPER = [
1487
- "const { publicKey, privateKey } = await generateKeyPair(\"RS256\");",
1488
- "setKeySource(async (header) => {",
1489
- " if (header.kid !== \"test\") throw new Error(\"unknown key\");",
1490
- " return publicKey;",
1491
- "});",
1492
- "",
1493
- "export interface KeycloakTokenOptions {",
1494
- " sub?: string;",
1495
- " email?: string;",
1496
- " emailVerified?: boolean;",
1497
- " roles?: string[];",
1498
- " organization?: string[] | Record<string, unknown>;",
1499
- " username?: string;",
1500
- " issuer?: string;",
1501
- " audience?: string;",
1502
- " expiresIn?: string | number;",
1503
- "}",
1504
- "",
1505
- "/** Signs an access token the API accepts, as if Keycloak issued it. */",
1506
- "export async function keycloakToken(options: KeycloakTokenOptions = {}): Promise<string> {",
1507
- " const sub = options.sub ?? newId();",
1508
- " return new SignJWT({",
1509
- " email: options.email ?? `user-${sub}@example.com`,",
1510
- " email_verified: options.emailVerified ?? true,",
1511
- " preferred_username: options.username ?? `user-${sub.slice(0, 8)}`,",
1512
- " roles: options.roles ?? [\"user\"],",
1513
- " ...(options.organization ? { organization: options.organization } : {}),",
1514
- " })",
1515
- " .setProtectedHeader({ alg: \"RS256\", kid: \"test\" })",
1516
- " .setSubject(sub)",
1517
- " .setIssuer(options.issuer ?? issuer)",
1518
- " .setAudience(options.audience ?? env.KEYCLOAK_AUDIENCE)",
1519
- " .setIssuedAt()",
1520
- " .setExpirationTime(options.expiresIn ?? \"5m\")",
1521
- " .sign(privateKey);",
1522
- "}"
1523
- ].join("\n");
1524
1472
  const authKeycloak = {
1525
1473
  id: "auth-keycloak",
1526
1474
  kind: "auth-method",
1527
1475
  label: "Keycloak tokens",
1528
- dependencies: ["jose"],
1476
+ dependencies: ["@tatara/keycloak", "jose"],
1529
1477
  env: [
1530
1478
  {
1531
1479
  key: "KEYCLOAK_PORT",
@@ -1574,53 +1522,56 @@ const authKeycloak = {
1574
1522
  {
1575
1523
  file: "src/core/auth/auth.ts",
1576
1524
  slot: "imports",
1577
- content: "import { keycloakTokens } from \"#core/auth/keycloak-plugin\";"
1525
+ content: "import { keycloakTokens } from \"@tatara/keycloak\";"
1526
+ },
1527
+ {
1528
+ file: "src/core/auth/auth.ts",
1529
+ slot: "imports",
1530
+ content: "import { keycloak } from \"#core/auth/keycloak\";"
1531
+ },
1532
+ {
1533
+ file: "src/core/auth/auth.ts",
1534
+ slot: "imports",
1535
+ content: "import { logger } from \"#core/logging/logger\";"
1578
1536
  },
1579
1537
  {
1580
1538
  file: "src/core/auth/auth.ts",
1581
1539
  slot: "plugins",
1582
1540
  unless: ["auth-orgs"],
1583
- content: "keycloakTokens(),",
1541
+ content: "keycloakTokens({ verifier: keycloak, logger }),",
1584
1542
  order: 800
1585
1543
  },
1586
1544
  {
1587
1545
  file: "src/core/auth/auth.ts",
1588
1546
  slot: "plugins",
1589
1547
  when: ["auth-orgs"],
1590
- content: "keycloakTokens({ organizations: true }),",
1548
+ content: "keycloakTokens({ verifier: keycloak, logger, organizations: true }),",
1591
1549
  order: 800
1592
1550
  },
1593
1551
  {
1594
1552
  file: "src/core/auth/principal.ts",
1595
- slot: "no-session",
1596
- content: [
1597
- "if (!session) {",
1598
- " const header = c.req.header(\"authorization\");",
1599
- " const token = header?.startsWith(\"Bearer \") ? header.slice(7) : undefined;",
1600
- " const principal = token ? await auth.api.verifyKeycloakToken({ body: { token } }) : null;",
1601
- " if (principal) c.set(\"principal\", principal);",
1602
- "}"
1603
- ].join("\n")
1553
+ slot: "resolvers",
1554
+ content: "(c) => keycloakPrincipal(auth, c.req.header(\"authorization\")),"
1604
1555
  },
1605
1556
  {
1606
- file: "tests/helpers/auth.ts",
1557
+ file: "src/core/auth/principal.ts",
1607
1558
  slot: "imports",
1608
- content: "import { generateKeyPair, SignJWT } from \"jose\";"
1559
+ content: "import { keycloakPrincipal } from \"@tatara/keycloak\";"
1609
1560
  },
1610
1561
  {
1611
1562
  file: "tests/helpers/auth.ts",
1612
1563
  slot: "imports",
1613
- content: "import { issuer, setKeySource } from \"#core/auth/keycloak\";"
1564
+ content: "import { useTestKeys } from \"@tatara/keycloak/testing\";"
1614
1565
  },
1615
1566
  {
1616
1567
  file: "tests/helpers/auth.ts",
1617
1568
  slot: "imports",
1618
- content: "import { env } from \"#core/config/env\";"
1569
+ content: "import { keycloak } from \"#core/auth/keycloak\";"
1619
1570
  },
1620
1571
  {
1621
1572
  file: "tests/helpers/auth.ts",
1622
1573
  slot: "helpers",
1623
- content: KEYCLOAK_TEST_HELPER
1574
+ content: ["/** Signs an access token the API accepts, as if Keycloak issued it. */", "export const keycloakToken = await useTestKeys(keycloak);"].join("\n")
1624
1575
  },
1625
1576
  {
1626
1577
  file: "tests/helpers/auth.ts",
@@ -1628,7 +1579,7 @@ const authKeycloak = {
1628
1579
  unless: ["auth-orgs"],
1629
1580
  content: [
1630
1581
  "if (options.roles) {",
1631
- " const sub = newId();",
1582
+ " const sub = crypto.randomUUID();",
1632
1583
  " const token = await keycloakToken({ sub, roles: options.roles, email: options.email });",
1633
1584
  " return { userId: sub, headers: { authorization: `Bearer ${token}` } };",
1634
1585
  "}"
@@ -1637,7 +1588,21 @@ const authKeycloak = {
1637
1588
  {
1638
1589
  file: "AGENTS.md",
1639
1590
  slot: "sections",
1640
- content: KEYCLOAK_AGENTS
1591
+ content: `### Keycloak
1592
+
1593
+ - The API also accepts Keycloak access tokens (\`Authorization: Bearer <token>\`). The
1594
+ \`keycloakTokens()\` better-auth plugin (\`@tatara/keycloak\`) verifies signature,
1595
+ issuer, audience \`__APP_NAME__-api\` and expiry; login happens in the frontend via OIDC (PKCE)
1596
+ against the \`__APP_NAME__-web\` client.
1597
+ - The token's subject is the user id. The user (and a \`keycloak\` account) is created in
1598
+ better-auth's tables on first use; a token whose email already belongs to another account is
1599
+ rejected. Service-account tokens become \`kind: "service"\` principals with no user row.
1600
+ - Roles come from the token (realm roles, \`roles\`, and client roles for the API); a role named like
1601
+ a built-in authz role grants it. The organization comes from the \`organization\` claim.
1602
+ - Local dev realm: \`keycloak/realm.json\` (users alice/alice with admin, bob/bob).
1603
+ \`__PM_EXEC__ tatara run keycloak:token --user alice\` prints a token for curl.
1604
+ - Tests sign tokens with a local key: \`keycloakToken()\` from \`#tests/helpers/auth\`.
1605
+ `
1641
1606
  }
1642
1607
  ]
1643
1608
  };
@@ -2491,7 +2456,7 @@ function guardSlots(project, n) {
2491
2456
  for (const [route, action] of Object.entries(ACTIONS)) add(`guard-${route}`, `middleware: [requirePermission("${n.kebab}:${action}")] as const,\nsecurity: [{ bearerAuth: ["${n.kebab}:${action}"] }],`);
2492
2457
  add("auth-errors", "...errorResponses(401, 403),");
2493
2458
  } else if (project.has("auth")) {
2494
- add("imports", "import { requireAuth } from \"#core/auth/middleware\";");
2459
+ add("imports", "import { requireAuth } from \"@tatara/auth\";");
2495
2460
  for (const route of Object.keys(ACTIONS)) add(`guard-${route}`, "middleware: [requireAuth()] as const,\nsecurity: [{ bearerAuth: [] }],");
2496
2461
  add("auth-errors", "...errorResponses(401),");
2497
2462
  }
@@ -3221,6 +3186,147 @@ function createBaseline(project, deps) {
3221
3186
  } };
3222
3187
  }
3223
3188
  //#endregion
3189
+ //#region src/upgrade/codemods/auth-package.ts
3190
+ const AUTH = "@tatara/auth";
3191
+ const KEYCLOAK = "@tatara/keycloak";
3192
+ const APP$1 = "src/core/http/app.ts";
3193
+ const HELPERS = "tests/helpers/auth.ts";
3194
+ function groups(keycloak) {
3195
+ const all = [
3196
+ {
3197
+ name: "OpenAPI merge and dev outbox",
3198
+ regenerate: [APP$1, "src/core/auth/delivery.ts"],
3199
+ remove: ["src/core/auth/openapi.ts"],
3200
+ exports: [{ specifier: "#core/auth/openapi" }, {
3201
+ specifier: "#core/auth/delivery",
3202
+ names: [
3203
+ "outboxEnabled",
3204
+ "readOutbox",
3205
+ "OutboxMessage"
3206
+ ]
3207
+ }]
3208
+ },
3209
+ {
3210
+ name: "SMS sender",
3211
+ regenerate: ["src/core/auth/sms.ts"],
3212
+ remove: [],
3213
+ exports: [{
3214
+ specifier: "#core/auth/sms",
3215
+ names: ["SmsSender"]
3216
+ }]
3217
+ },
3218
+ {
3219
+ name: "principal middleware",
3220
+ regenerate: ["src/core/auth/principal.ts"],
3221
+ remove: [],
3222
+ exports: []
3223
+ }
3224
+ ];
3225
+ if (!keycloak) {
3226
+ all.push({
3227
+ name: "test helpers",
3228
+ regenerate: [HELPERS],
3229
+ remove: [],
3230
+ exports: []
3231
+ });
3232
+ return all;
3233
+ }
3234
+ all.push({
3235
+ name: "Keycloak",
3236
+ regenerate: [
3237
+ "src/core/auth/auth.ts",
3238
+ "src/core/auth/keycloak.ts",
3239
+ "src/core/auth/commands/keycloak-token.ts",
3240
+ "src/core/auth/tests/keycloak-tokens.test.ts",
3241
+ HELPERS
3242
+ ],
3243
+ remove: ["src/core/auth/keycloak-plugin.ts"],
3244
+ exports: [
3245
+ { specifier: "#core/auth/keycloak-plugin" },
3246
+ {
3247
+ specifier: "#core/auth/keycloak",
3248
+ names: [
3249
+ "issuer",
3250
+ "setKeySource",
3251
+ "verifyAccessToken",
3252
+ "rolesOf",
3253
+ "organizationOf",
3254
+ "KeycloakClaims"
3255
+ ]
3256
+ },
3257
+ {
3258
+ specifier: "#tests/helpers/auth",
3259
+ names: ["KeycloakTokenOptions"]
3260
+ }
3261
+ ]
3262
+ });
3263
+ return all;
3264
+ }
3265
+ async function read$2(ctx, path) {
3266
+ return ctx.read(path).catch(() => void 0);
3267
+ }
3268
+ const escapeRegExp$1 = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3269
+ /** True when `text` imports `entry` (or one of its gone names). */
3270
+ function usesExport(text, entry) {
3271
+ const from = new RegExp(`import\\s+(?:type\\s+)?([^;]*?)\\s+from\\s+["']${escapeRegExp$1(entry.specifier)}["']`, "g");
3272
+ const gone = entry.names;
3273
+ for (const match of text.matchAll(from)) {
3274
+ if (!gone) return true;
3275
+ const braces = (match[1] ?? "").match(/\{([^}]*)\}/);
3276
+ if (!braces) return true;
3277
+ if ((braces[1] ?? "").split(",").map((part) => part.trim().replace(/^type\s+/, "").split(/\s+as\s+/)[0]?.trim()).some((name) => name && gone.includes(name))) return true;
3278
+ }
3279
+ return false;
3280
+ }
3281
+ /** Why the group can't move, or [] when it can. */
3282
+ async function blockersOf(ctx, group, sources) {
3283
+ const blockers = [];
3284
+ for (const path of [...group.regenerate, ...group.remove]) {
3285
+ const text = await read$2(ctx, path);
3286
+ if (text === void 0) continue;
3287
+ if (!await ctx.baseline.isUnchanged(path, text)) blockers.push(`${path} is customised`);
3288
+ }
3289
+ const own = /* @__PURE__ */ new Set([...group.regenerate, ...group.remove]);
3290
+ for (const path of sources) {
3291
+ if (own.has(path)) continue;
3292
+ const text = await ctx.read(path);
3293
+ if (group.exports.some((entry) => usesExport(text, entry))) blockers.push(`${path} imports what moves`);
3294
+ }
3295
+ return blockers;
3296
+ }
3297
+ const authPackage = {
3298
+ version: "0.5.0",
3299
+ id: "auth-package",
3300
+ description: "Use @tatara/auth (guards, principal, OpenAPI merge, outbox) and @tatara/keycloak",
3301
+ async run(ctx) {
3302
+ const { config } = ctx.project;
3303
+ if (config.auth === "none") return;
3304
+ const keycloak = config.authMethods.includes("keycloak");
3305
+ await ctx.addDependency(AUTH, versionOf(AUTH));
3306
+ if (keycloak) await ctx.addDependency(KEYCLOAK, versionOf(KEYCLOAK));
3307
+ const sources = await glob(["src/**/*.ts", "tests/**/*.ts"], {
3308
+ cwd: ctx.project.root,
3309
+ ignore: ["src/generated/**"]
3310
+ });
3311
+ const ready = [];
3312
+ for (const group of groups(keycloak)) {
3313
+ const blockers = await blockersOf(ctx, group, sources);
3314
+ if (blockers.length === 0) ready.push(group);
3315
+ else ctx.report.notes.push(`${group.name} was not moved onto ${group.name === "Keycloak" ? KEYCLOAK : AUTH}: ${blockers.join("; ")}. Compare these files with a newly generated app and move them by hand.`);
3316
+ }
3317
+ for (const group of ready) {
3318
+ for (const path of group.regenerate) {
3319
+ if (await read$2(ctx, path) === void 0) continue;
3320
+ const next = await ctx.renderCurrent(path);
3321
+ if (next !== void 0) await ctx.write(path, next);
3322
+ }
3323
+ for (const path of group.remove) await ctx.removeIfUnchanged(path);
3324
+ }
3325
+ if (await ctx.removeIfUnchanged("src/core/auth/middleware.ts") === "kept") ctx.report.notes.push("src/core/auth/middleware.ts is customised; its imports still point at it.");
3326
+ else await ctx.rewriteImports("#core/auth/middleware", AUTH);
3327
+ }
3328
+ };
3329
+ //#endregion
3224
3330
  //#region src/upgrade/codemods/core-package.ts
3225
3331
  const CORE = "@tatara/core";
3226
3332
  /** Files @tatara/core replaces, with the import specifier apps used for each. */
@@ -3494,7 +3600,8 @@ const CODEMODS = [
3494
3600
  ]) await ctx.rewriteImports(specifier, HTTP);
3495
3601
  }
3496
3602
  },
3497
- dbTesting
3603
+ dbTesting,
3604
+ authPackage
3498
3605
  ];
3499
3606
  //#endregion
3500
3607
  //#region src/upgrade/context.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tatara/cli",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Create and grow opinionated Hono backends",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,5 +1,5 @@
1
1
  import { createRoute } from "@hono/zod-openapi";
2
- import { currentPrincipal } from "#core/auth/middleware";
2
+ import { currentPrincipal } from "@tatara/auth";
3
3
  import { errorResponses, jsonContent, success, successSchema } from "@tatara/http";
4
4
  import { createRouter, defineRoutes } from "#core/http/routes";
5
5
  import { greetingSchema } from "#modules/example/validators/greeting.validators";
@@ -1,5 +1,5 @@
1
1
  import { createRoute, z } from "@hono/zod-openapi";
2
- import { currentPrincipal, requireAuth } from "#core/auth/middleware";
2
+ import { currentPrincipal, requireAuth } from "@tatara/auth";
3
3
  import { errorResponses, jsonContent, success, successSchema } from "@tatara/http";
4
4
  import { createRouter, defineRoutes } from "#core/http/routes";
5
5
 
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { issuer } from "#core/auth/keycloak";
2
+ import { keycloak } from "#core/auth/keycloak";
3
3
  import { defineCommand } from "@tatara/core";
4
4
  import { env } from "#core/config/env";
5
5
 
@@ -13,7 +13,7 @@ export default defineCommand({
13
13
  }),
14
14
  async run({ user, password, client }) {
15
15
  if (env.NODE_ENV === "production") throw new Error("keycloak:token is for development only");
16
- const res = await fetch(`${issuer}/protocol/openid-connect/token`, {
16
+ const res = await fetch(`${keycloak.issuer}/protocol/openid-connect/token`, {
17
17
  method: "POST",
18
18
  headers: { "content-type": "application/x-www-form-urlencoded" },
19
19
  body: new URLSearchParams({
@@ -1,58 +1,9 @@
1
- import { createRemoteJWKSet, type JWTPayload, type JWTVerifyGetKey, jwtVerify } from "jose";
1
+ import { keycloakVerifier } from "@tatara/keycloak";
2
2
  import { env } from "#core/config/env";
3
3
 
4
- export const issuer = `${env.KEYCLOAK_URL.replace(/\/$/, "")}/realms/${env.KEYCLOAK_REALM}`;
5
-
6
- let keySource: JWTVerifyGetKey = createRemoteJWKSet(
7
- new URL(`${issuer}/protocol/openid-connect/certs`),
8
- );
9
-
10
- /** Tests replace the realm's key set with a local one. */
11
- export function setKeySource(source: JWTVerifyGetKey): void {
12
- keySource = source;
13
- }
14
-
15
- export interface KeycloakClaims extends JWTPayload {
16
- sub: string;
17
- email?: string;
18
- name?: string;
19
- preferred_username?: string;
20
- azp?: string;
21
- roles?: string[];
22
- realm_access?: { roles?: string[] };
23
- resource_access?: Record<string, { roles?: string[] }>;
24
- organization?: string[] | Record<string, unknown>;
25
- }
26
-
27
- /** Verifies signature, issuer, audience and expiry. Throws when the token is not valid. */
28
- export async function verifyAccessToken(token: string): Promise<KeycloakClaims> {
29
- const { payload } = await jwtVerify(token, keySource, {
30
- issuer,
31
- audience: env.KEYCLOAK_AUDIENCE,
32
- });
33
- if (typeof payload.sub !== "string") throw new Error("token has no subject");
34
- return payload as KeycloakClaims;
35
- }
36
-
37
- export function rolesOf(claims: KeycloakClaims): string[] {
38
- const roles = new Set<string>([
39
- ...(claims.roles ?? []),
40
- ...(claims.realm_access?.roles ?? []),
41
- ...(claims.resource_access?.[env.KEYCLOAK_AUDIENCE]?.roles ?? []),
42
- ]);
43
- return [...roles];
44
- }
45
-
46
- /** Keycloak organizations put memberships in the `organization` claim; the first one wins. */
47
- export function organizationOf(claims: KeycloakClaims): string | null {
48
- const org = claims.organization;
49
- if (Array.isArray(org)) return org[0] ?? null;
50
- if (org && typeof org === "object") {
51
- const [first] = Object.entries(org);
52
- if (!first) return null;
53
- const [alias, value] = first;
54
- const id = (value as { id?: unknown } | undefined)?.id;
55
- return typeof id === "string" ? id : alias;
56
- }
57
- return null;
58
- }
4
+ /** Verifies access tokens from the realm; `keycloakTokens()` in auth.ts uses it. */
5
+ export const keycloak = keycloakVerifier({
6
+ url: env.KEYCLOAK_URL,
7
+ realm: env.KEYCLOAK_REALM,
8
+ audience: env.KEYCLOAK_AUDIENCE,
9
+ });
@@ -1,7 +1,7 @@
1
1
  import { generateKeyPair, SignJWT } from "jose";
2
2
  import { beforeEach, describe, expect, it } from "vitest";
3
3
  import { auth } from "#core/auth/auth";
4
- import { issuer } from "#core/auth/keycloak";
4
+ import { keycloak } from "#core/auth/keycloak";
5
5
  import { env } from "#core/config/env";
6
6
  import { newId } from "@tatara/db";
7
7
  import { api } from "#tests/helpers/api";
@@ -63,7 +63,7 @@ describe("Keycloak access tokens", () => {
63
63
  const forged = await new SignJWT({ roles: ["admin"] })
64
64
  .setProtectedHeader({ alg: "RS256", kid: "test" })
65
65
  .setSubject(newId())
66
- .setIssuer(issuer)
66
+ .setIssuer(keycloak.issuer)
67
67
  .setAudience(env.KEYCLOAK_AUDIENCE)
68
68
  .setExpirationTime("5m")
69
69
  .sign(privateKey);
@@ -1,5 +1,5 @@
1
1
  import { createRoute, z } from "@hono/zod-openapi";
2
- import { currentPrincipal, requireAuth } from "#core/auth/middleware";
2
+ import { currentPrincipal, requireAuth } from "@tatara/auth";
3
3
  import { requirePermission } from "#core/authz/middleware";
4
4
  import { effectivePermissions, orgOf } from "#core/authz/resolver";
5
5
  import { listAudit } from "#core/authz/services/audit";
@@ -1,36 +1,12 @@
1
+ import { createOutbox } from "@tatara/auth";
1
2
  import { smsSender } from "#core/auth/sms";
2
3
  import { env } from "#core/config/env";
3
4
  // tatara:slot imports
4
5
 
5
- export interface OutboxMessage {
6
- channel: "email" | "sms";
7
- to: string;
8
- subject?: string;
9
- text: string;
10
- /** one-time code, when the message carries one */
11
- code?: string;
12
- sentAt: string;
13
- }
14
-
15
- const outbox: OutboxMessage[] = [];
16
-
17
6
  /** Only with DEV_OUTBOX=true in development or test: the outbox exposes sign-in codes. */
18
- export function outboxEnabled(): boolean {
19
- return env.DEV_OUTBOX && (env.NODE_ENV === "development" || env.NODE_ENV === "test");
20
- }
21
-
22
- /** Messages are kept here so tests and local sign-in can read codes. */
23
- function record(message: Omit<OutboxMessage, "sentAt">) {
24
- if (!outboxEnabled()) return;
25
- outbox.push({ ...message, sentAt: new Date().toISOString() });
26
- if (outbox.length > 200) outbox.shift();
27
- }
28
-
29
- /** Newest first. Empty unless the outbox is enabled. */
30
- export function readOutbox(to?: string): OutboxMessage[] {
31
- if (!outboxEnabled()) return [];
32
- return outbox.filter((m) => !to || m.to === to).reverse();
33
- }
7
+ export const outbox = createOutbox({
8
+ enabled: env.DEV_OUTBOX && (env.NODE_ENV === "development" || env.NODE_ENV === "test"),
9
+ });
34
10
 
35
11
  export async function deliverEmail(message: {
36
12
  to: string;
@@ -38,11 +14,11 @@ export async function deliverEmail(message: {
38
14
  text: string;
39
15
  code?: string;
40
16
  }): Promise<void> {
41
- record({ channel: "email", ...message });
17
+ outbox.record({ channel: "email", ...message });
42
18
  // tatara:slot email-delivery
43
19
  }
44
20
 
45
21
  export async function deliverSms(message: { to: string; text: string; code?: string }): Promise<void> {
46
- record({ channel: "sms", ...message });
22
+ outbox.record({ channel: "sms", ...message });
47
23
  await smsSender.send(message.to, message.text);
48
24
  }
@@ -1,41 +1,10 @@
1
- import { createMiddleware } from "hono/factory";
1
+ import { principalMiddleware, sessionPrincipal } from "@tatara/auth";
2
2
  import { auth } from "#core/auth/auth";
3
3
  import type { AppEnv } from "#core/http/context";
4
+ // tatara:slot imports
4
5
 
5
- /**
6
- * The session's active organization, or null once the user stops being a member of it.
7
- * better-auth leaves `activeOrganizationId` on the removed member's session.
8
- */
9
- async function activeMembership(userId: string, orgId: string | null | undefined) {
10
- if (!orgId) return null;
11
- const { adapter } = await auth.$context;
12
- const member = await adapter.findOne({
13
- model: "member",
14
- where: [
15
- { field: "organizationId", value: orgId },
16
- { field: "userId", value: userId },
17
- ],
18
- });
19
- return member ? orgId : null;
20
- }
21
-
22
- /** Resolves the session (cookie or bearer token) into `c.var.principal`. */
23
- export const attachPrincipal = createMiddleware<AppEnv>(async (c, next) => {
24
- const session = await auth.api.getSession({ headers: c.req.raw.headers });
25
- if (session) {
26
- const user = session.user as typeof session.user & { role?: string | null };
27
- const s = session.session as typeof session.session & { activeOrganizationId?: string | null };
28
- c.set("principal", {
29
- userId: user.id,
30
- orgId: await activeMembership(user.id, s.activeOrganizationId),
31
- roles: (user.role ?? "user")
32
- .split(",")
33
- .map((role) => role.trim())
34
- .filter(Boolean),
35
- kind: "user",
36
- claims: {},
37
- });
38
- }
39
- // tatara:slot no-session
40
- await next();
41
- });
6
+ /** Sets `c.var.principal` from the first resolver that recognises the request's credentials. */
7
+ export const attachPrincipal = principalMiddleware<AppEnv>([
8
+ (c) => sessionPrincipal(auth, c.req.raw.headers),
9
+ // tatara:slot resolvers
10
+ ]);
@@ -1,14 +1,5 @@
1
+ import { consoleSmsSender, type SmsSender } from "@tatara/auth";
1
2
  import { logger } from "#core/logging/logger";
2
3
 
3
- /** Implement this for your SMS provider and return it from `smsSender`. */
4
- export interface SmsSender {
5
- send(to: string, text: string): Promise<void>;
6
- }
7
-
8
- const consoleSender: SmsSender = {
9
- async send(to) {
10
- logger.info({ to }, "sms not sent: no SMS provider configured (see src/core/auth/sms.ts)");
11
- },
12
- };
13
-
14
- export const smsSender: SmsSender = consoleSender;
4
+ /** Logs instead of sending. Replace with an SmsSender for your provider. */
5
+ export const smsSender: SmsSender = consoleSmsSender(logger);
@@ -1,5 +1,5 @@
1
+ import { createTestUser as createUser, sessionHeaders } from "@tatara/auth/testing";
1
2
  import { auth } from "#core/auth/auth";
2
- import { newId } from "@tatara/db";
3
3
  // tatara:slot imports
4
4
 
5
5
  export interface AsUserOptions {
@@ -12,14 +12,13 @@ export interface AsUserOptions {
12
12
 
13
13
  /** Creates a verified user and returns their id. */
14
14
  export async function createTestUser(options: AsUserOptions = {}): Promise<string> {
15
- const ctx = await auth.$context;
16
- const user = await ctx.internalAdapter.createUser({
17
- email: options.email ?? `user-${newId()}@example.com`,
18
- name: options.name ?? "Test User",
19
- emailVerified: true,
20
- // tatara:slot user-fields
21
- }, { method: "admin" });
22
- return user.id;
15
+ return createUser(auth, {
16
+ email: options.email,
17
+ name: options.name,
18
+ fields: {
19
+ // tatara:slot user-fields
20
+ },
21
+ });
23
22
  }
24
23
 
25
24
  /** A new user with a real session; the token is sent as a bearer token. */
@@ -28,9 +27,7 @@ export async function authenticate(
28
27
  ): Promise<{ userId: string; headers: Record<string, string> }> {
29
28
  // tatara:slot authenticate
30
29
  const userId = await createTestUser(options);
31
- const ctx = await auth.$context;
32
- const session = await ctx.internalAdapter.createSession(userId);
33
- return { userId, headers: { authorization: `Bearer ${session.token}` } };
30
+ return { userId, headers: await sessionHeaders(auth, userId) };
34
31
  }
35
32
 
36
33
  /** Headers that authenticate as a new user. */
@@ -34,5 +34,7 @@
34
34
  "@tatara/core": "0.1.0",
35
35
  "@tatara/http": "0.1.0",
36
36
  "@tatara/db": "0.1.0",
37
- "@tatara/testing": "0.1.0"
37
+ "@tatara/testing": "0.1.0",
38
+ "@tatara/auth": "0.1.0",
39
+ "@tatara/keycloak": "0.1.0"
38
40
  }
@@ -1,29 +0,0 @@
1
- import { createMiddleware } from "hono/factory";
2
- import type { Context } from "hono";
3
- import type { AppEnv, Principal } from "#core/http/context";
4
- import { ForbiddenError, UnauthorizedError } from "@tatara/http";
5
-
6
- /** 401 unless the request carries a valid session or token. */
7
- export function requireAuth() {
8
- return createMiddleware<AppEnv>(async (c, next) => {
9
- if (!c.get("principal")) throw new UnauthorizedError();
10
- await next();
11
- });
12
- }
13
-
14
- /** 401 without a principal, 403 unless it has at least one of the roles. */
15
- export function requireRole(...roles: string[]) {
16
- return createMiddleware<AppEnv>(async (c, next) => {
17
- const principal = c.get("principal");
18
- if (!principal) throw new UnauthorizedError();
19
- if (!principal.roles.some((role) => roles.includes(role))) throw new ForbiddenError();
20
- await next();
21
- });
22
- }
23
-
24
- /** The authenticated principal; use in handlers behind requireAuth(). */
25
- export function currentPrincipal(c: Context<AppEnv>): Principal {
26
- const principal = c.get("principal");
27
- if (!principal) throw new UnauthorizedError();
28
- return principal;
29
- }
@@ -1,154 +0,0 @@
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
- }
@@ -1,60 +0,0 @@
1
- import type { OpenAPIHono } from "@hono/zod-openapi";
2
- import { auth } from "#core/auth/auth";
3
-
4
- type JsonObject = Record<string, unknown>;
5
- type OpenApiDocument = ReturnType<OpenAPIHono["getOpenAPI31Document"]>;
6
- type Paths = NonNullable<OpenApiDocument["paths"]>;
7
- type Components = NonNullable<OpenApiDocument["components"]>;
8
-
9
- interface AuthDocument {
10
- paths?: Record<string, JsonObject>;
11
- components?: { schemas?: JsonObject; securitySchemes?: JsonObject };
12
- }
13
-
14
- const SCHEMA_REF = "#/components/schemas/";
15
-
16
- // better-auth names schemas like the app (User, Member, …); prefix them so neither side is overwritten
17
- function renameRefs(value: unknown, rename: (name: string) => string): AuthDocument {
18
- return JSON.parse(
19
- JSON.stringify(value).replaceAll(
20
- new RegExp(`"\\$ref":"${SCHEMA_REF}([^"]+)"`, "g"),
21
- (_match, name: string) => `"$ref":"${SCHEMA_REF}${rename(name)}"`,
22
- ),
23
- ) as AuthDocument;
24
- }
25
-
26
- function retag(paths: Record<string, JsonObject>, tag: string): Record<string, JsonObject> {
27
- for (const operations of Object.values(paths)) {
28
- for (const operation of Object.values(operations as Record<string, JsonObject>)) {
29
- if (operation && typeof operation === "object") operation.tags = [tag];
30
- }
31
- }
32
- return paths;
33
- }
34
-
35
- /** Adds better-auth's endpoints (served under `basePath`) to the app's OpenAPI document. */
36
- export async function mergeAuthOpenApi(document: OpenApiDocument, basePath: string): Promise<void> {
37
- const authDoc = renameRefs(await auth.api.generateOpenAPISchema(), (name) => `Auth${name}`);
38
-
39
- document.paths ??= {};
40
- for (const [path, operations] of Object.entries(retag(authDoc.paths ?? {}, "Auth"))) {
41
- document.paths[`${basePath}${path}`] = operations as Paths[string];
42
- }
43
-
44
- document.components ??= {};
45
- document.components.schemas ??= {};
46
- for (const [name, schema] of Object.entries(authDoc.components?.schemas ?? {})) {
47
- document.components.schemas[`Auth${name}`] = schema as NonNullable<
48
- Components["schemas"]
49
- >[string];
50
- }
51
- document.components.securitySchemes = {
52
- ...(authDoc.components?.securitySchemes as Components["securitySchemes"]),
53
- ...document.components.securitySchemes,
54
- };
55
-
56
- document.tags = [
57
- ...(document.tags ?? []),
58
- { name: "Auth", description: "Sign-up, sign-in, sessions, organizations (better-auth)" },
59
- ];
60
- }