@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 +190 -83
- package/package.json +1 -1
- package/templates/features/admin/files/src/modules/example/routes/admin-greeting.routes.ts +1 -1
- package/templates/features/auth/files/src/core/auth/routes/me.routes.ts +1 -1
- package/templates/features/auth-keycloak/files/src/core/auth/commands/keycloak-token.ts +2 -2
- package/templates/features/auth-keycloak/files/src/core/auth/keycloak.ts +7 -56
- package/templates/features/auth-keycloak/files/src/core/auth/tests/keycloak-tokens.test.ts +2 -2
- package/templates/features/authz/files/src/core/authz/routes/authorization.routes.ts +1 -1
- package/templates/features/better-auth/files/src/core/auth/delivery.ts +6 -30
- package/templates/features/better-auth/files/src/core/auth/principal.ts +7 -38
- package/templates/features/better-auth/files/src/core/auth/sms.ts +3 -12
- package/templates/features/better-auth/files/tests/helpers/auth.ts +9 -12
- package/templates/versions.json +3 -1
- package/templates/features/auth/files/src/core/auth/middleware.ts +0 -29
- package/templates/features/auth-keycloak/files/src/core/auth/keycloak-plugin.ts +0 -154
- package/templates/features/better-auth/files/src/core/auth/openapi.ts +0 -60
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 \"
|
|
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
|
-
|
|
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 {
|
|
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 \"
|
|
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 (
|
|
1193
|
-
" app.get(`${API_PREFIX}/dev/outbox`, (c) => c.json({ messages:
|
|
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 \"
|
|
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: "
|
|
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: "
|
|
1557
|
+
file: "src/core/auth/principal.ts",
|
|
1607
1558
|
slot: "imports",
|
|
1608
|
-
content: "import {
|
|
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 {
|
|
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 {
|
|
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:
|
|
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 =
|
|
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:
|
|
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 \"
|
|
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,5 +1,5 @@
|
|
|
1
1
|
import { createRoute } from "@hono/zod-openapi";
|
|
2
|
-
import { currentPrincipal } from "
|
|
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 "
|
|
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 {
|
|
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 {
|
|
1
|
+
import { keycloakVerifier } from "@tatara/keycloak";
|
|
2
2
|
import { env } from "#core/config/env";
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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 {
|
|
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 "
|
|
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
|
|
19
|
-
|
|
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 {
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
/**
|
|
4
|
-
export
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
}
|
|
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
|
-
|
|
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. */
|
package/templates/versions.json
CHANGED
|
@@ -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
|
-
}
|