@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.
- package/dist/bin.mjs +152 -51
- package/package.json +10 -1
- package/templates/features/auth-keycloak/files/src/core/auth/keycloak-plugin.ts +154 -0
- package/templates/features/auth-keycloak/files/src/core/auth/tests/keycloak-tokens.test.ts +109 -0
- package/templates/features/auth-keycloak/files+auth-orgs/src/core/auth/tests/keycloak-organizations.test.ts +36 -0
- package/templates/features/auth-keycloak/files+authz/src/core/auth/tests/keycloak-authz.test.ts +23 -0
- package/templates/features/base/files/README.md +1 -1
- package/templates/features/better-auth/files/src/core/auth/principal.ts +1 -0
- package/templates/features/better-auth/files/tests/helpers/auth.ts +2 -0
- package/templates/features/keycloak/files/src/core/auth/principal.ts +0 -27
- package/templates/features/keycloak/files/tests/helpers/auth.ts +0 -52
- package/templates/features/keycloak/files+drizzle/src/core/auth/users.ts +0 -18
- package/templates/features/keycloak/files+drizzle+mysql/src/core/auth/db/user.schema.ts +0 -10
- package/templates/features/keycloak/files+drizzle+postgres/src/core/auth/db/user.schema.ts +0 -10
- package/templates/features/keycloak/files+prisma/prisma/models/user.prisma +0 -10
- package/templates/features/keycloak/files+prisma/src/core/auth/users.ts +0 -13
- /package/templates/features/{keycloak → auth-keycloak}/files/keycloak/realm.json +0 -0
- /package/templates/features/{keycloak → auth-keycloak}/files/src/core/auth/commands/keycloak-token.ts +0 -0
- /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
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
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/
|
|
1505
|
-
slot: "
|
|
1506
|
-
|
|
1507
|
-
|
|
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: "
|
|
1511
|
-
slot: "
|
|
1512
|
-
|
|
1513
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
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,
|
|
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
|
|
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.
|
|
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
|
+
});
|
package/templates/features/auth-keycloak/files+authz/src/core/auth/tests/keycloak-authz.test.ts
ADDED
|
@@ -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
|
+
});
|
|
@@ -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
|
-
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|