@objectstack/plugin-auth 4.0.5 → 4.1.1
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/index.d.mts +112 -1
- package/dist/index.d.ts +112 -1
- package/dist/index.js +444 -37
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +442 -43
- package/dist/index.mjs.map +1 -1
- package/package.json +8 -8
package/dist/index.mjs
CHANGED
|
@@ -3,12 +3,7 @@ import {
|
|
|
3
3
|
SETUP_APP,
|
|
4
4
|
SystemOverviewDashboard,
|
|
5
5
|
SecurityOverviewDashboard,
|
|
6
|
-
|
|
7
|
-
OrganizationsView,
|
|
8
|
-
RolesView,
|
|
9
|
-
SessionsView,
|
|
10
|
-
AuditLogsView,
|
|
11
|
-
PackageInstallationsView
|
|
6
|
+
SetupAppTranslations
|
|
12
7
|
} from "@objectstack/platform-objects/apps";
|
|
13
8
|
|
|
14
9
|
// src/objectql-adapter.ts
|
|
@@ -23,6 +18,41 @@ var AUTH_MODEL_TO_PROTOCOL = {
|
|
|
23
18
|
function resolveProtocolName(model) {
|
|
24
19
|
return AUTH_MODEL_TO_PROTOCOL[model] ?? model;
|
|
25
20
|
}
|
|
21
|
+
var LEGACY_DATETIME_FIELDS_BY_MODEL = {
|
|
22
|
+
user: ["created_at", "updated_at"],
|
|
23
|
+
session: ["expires_at", "created_at", "updated_at"],
|
|
24
|
+
account: [
|
|
25
|
+
"access_token_expires_at",
|
|
26
|
+
"refresh_token_expires_at",
|
|
27
|
+
"created_at",
|
|
28
|
+
"updated_at"
|
|
29
|
+
],
|
|
30
|
+
verification: ["expires_at", "created_at", "updated_at"]
|
|
31
|
+
};
|
|
32
|
+
var NUMERIC_STRING_RE = /^-?\d+(\.\d+)?$/;
|
|
33
|
+
function normaliseLegacyDate(value) {
|
|
34
|
+
if (typeof value !== "string") return value;
|
|
35
|
+
if (!NUMERIC_STRING_RE.test(value)) return value;
|
|
36
|
+
const n = parseFloat(value);
|
|
37
|
+
if (!Number.isFinite(n)) return value;
|
|
38
|
+
if (Math.abs(n) < 1e10) return value;
|
|
39
|
+
const d = new Date(n);
|
|
40
|
+
if (Number.isNaN(d.getTime())) return value;
|
|
41
|
+
return d.toISOString();
|
|
42
|
+
}
|
|
43
|
+
function normaliseLegacyDates(model, record) {
|
|
44
|
+
if (!record) return record;
|
|
45
|
+
const cols = LEGACY_DATETIME_FIELDS_BY_MODEL[model];
|
|
46
|
+
if (!cols) return record;
|
|
47
|
+
for (const col of cols) {
|
|
48
|
+
if (col in record) {
|
|
49
|
+
record[col] = normaliseLegacyDate(
|
|
50
|
+
record[col]
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return record;
|
|
55
|
+
}
|
|
26
56
|
function convertWhere(where) {
|
|
27
57
|
const filter = {};
|
|
28
58
|
for (const condition of where) {
|
|
@@ -51,20 +81,24 @@ function createObjectQLAdapterFactory(dataEngine) {
|
|
|
51
81
|
return createAdapterFactory({
|
|
52
82
|
config: {
|
|
53
83
|
adapterId: "objectql",
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
|
|
84
|
+
// We let better-auth handle Date↔string and boolean↔0/1 conversion so
|
|
85
|
+
// that values land in the underlying SQL driver as primitive strings
|
|
86
|
+
// and integers. Some drivers (e.g. libsql over the HTTP transport)
|
|
87
|
+
// otherwise mangle `Date` objects into `"<epoch>.0"` strings that
|
|
88
|
+
// break the client-side session parser.
|
|
89
|
+
supportsBooleans: false,
|
|
90
|
+
supportsDates: false,
|
|
57
91
|
supportsJSON: true
|
|
58
92
|
},
|
|
59
93
|
adapter: () => ({
|
|
60
94
|
create: async ({ model, data, select: _select }) => {
|
|
61
95
|
const result = await dataEngine.insert(model, data);
|
|
62
|
-
return result;
|
|
96
|
+
return normaliseLegacyDates(model, result);
|
|
63
97
|
},
|
|
64
98
|
findOne: async ({ model, where, select, join: _join }) => {
|
|
65
99
|
const filter = convertWhere(where);
|
|
66
100
|
const result = await dataEngine.findOne(model, { where: filter, fields: select });
|
|
67
|
-
return result ? result : null;
|
|
101
|
+
return result ? normaliseLegacyDates(model, result) : null;
|
|
68
102
|
},
|
|
69
103
|
findMany: async ({ model, where, limit, offset, sortBy, join: _join }) => {
|
|
70
104
|
const filter = where ? convertWhere(where) : {};
|
|
@@ -75,7 +109,7 @@ function createObjectQLAdapterFactory(dataEngine) {
|
|
|
75
109
|
offset,
|
|
76
110
|
orderBy
|
|
77
111
|
});
|
|
78
|
-
return results;
|
|
112
|
+
return results.map((r) => normaliseLegacyDates(model, r));
|
|
79
113
|
},
|
|
80
114
|
count: async ({ model, where }) => {
|
|
81
115
|
const filter = where ? convertWhere(where) : {};
|
|
@@ -86,7 +120,7 @@ function createObjectQLAdapterFactory(dataEngine) {
|
|
|
86
120
|
const record = await dataEngine.findOne(model, { where: filter });
|
|
87
121
|
if (!record) return null;
|
|
88
122
|
const result = await dataEngine.update(model, { ...update, id: record.id });
|
|
89
|
-
return result ? result : null;
|
|
123
|
+
return result ? normaliseLegacyDates(model, result) : null;
|
|
90
124
|
},
|
|
91
125
|
updateMany: async ({ model, where, update }) => {
|
|
92
126
|
const filter = convertWhere(where);
|
|
@@ -283,6 +317,13 @@ var AUTH_TWO_FACTOR_SCHEMA = {
|
|
|
283
317
|
var AUTH_TWO_FACTOR_USER_FIELDS = {
|
|
284
318
|
twoFactorEnabled: "two_factor_enabled"
|
|
285
319
|
};
|
|
320
|
+
var AUTH_ADMIN_USER_FIELDS = {
|
|
321
|
+
banReason: "ban_reason",
|
|
322
|
+
banExpires: "ban_expires"
|
|
323
|
+
};
|
|
324
|
+
var AUTH_ADMIN_SESSION_FIELDS = {
|
|
325
|
+
impersonatedBy: "impersonated_by"
|
|
326
|
+
};
|
|
286
327
|
var AUTH_OAUTH_CLIENT_SCHEMA = {
|
|
287
328
|
modelName: SystemObjectName2.OAUTH_APPLICATION,
|
|
288
329
|
// 'sys_oauth_application'
|
|
@@ -366,6 +407,16 @@ function buildTwoFactorPluginSchema() {
|
|
|
366
407
|
}
|
|
367
408
|
};
|
|
368
409
|
}
|
|
410
|
+
function buildAdminPluginSchema() {
|
|
411
|
+
return {
|
|
412
|
+
user: {
|
|
413
|
+
fields: AUTH_ADMIN_USER_FIELDS
|
|
414
|
+
},
|
|
415
|
+
session: {
|
|
416
|
+
fields: AUTH_ADMIN_SESSION_FIELDS
|
|
417
|
+
}
|
|
418
|
+
};
|
|
419
|
+
}
|
|
369
420
|
function buildOrganizationPluginSchema() {
|
|
370
421
|
return {
|
|
371
422
|
organization: AUTH_ORGANIZATION_SCHEMA,
|
|
@@ -447,7 +498,41 @@ var AuthManager = class {
|
|
|
447
498
|
...AUTH_USER_CONFIG
|
|
448
499
|
},
|
|
449
500
|
account: {
|
|
450
|
-
...AUTH_ACCOUNT_CONFIG
|
|
501
|
+
...AUTH_ACCOUNT_CONFIG,
|
|
502
|
+
// Allow OIDC/OAuth callbacks to implicitly link the incoming
|
|
503
|
+
// identity to a pre-existing local user when the emails match.
|
|
504
|
+
//
|
|
505
|
+
// ObjectStack's platform SSO ("objectstack-cloud" provider) is the
|
|
506
|
+
// canonical case: cloud is the IdP for every project, so a user
|
|
507
|
+
// arriving via SSO is — by construction — the same person who was
|
|
508
|
+
// auto-seeded as the project owner when the project was created.
|
|
509
|
+
// Without trusting the provider, better-auth's safety check rejects
|
|
510
|
+
// the link with `error=account_not_linked` because the seeded user
|
|
511
|
+
// row has `emailVerified=false` (no actual verification ever runs
|
|
512
|
+
// in the IdP-mediated flow). See packages/plugins/plugin-auth/
|
|
513
|
+
// node_modules/better-auth/dist/oauth2/link-account.mjs:22.
|
|
514
|
+
//
|
|
515
|
+
// Custom-deployment consumers can extend the trusted set via
|
|
516
|
+
// `config.account.accountLinking.trustedProviders`; we always
|
|
517
|
+
// include `objectstack-cloud` because it is the platform IdP.
|
|
518
|
+
accountLinking: {
|
|
519
|
+
enabled: true,
|
|
520
|
+
// better-auth's account-linking gate has TWO independent clauses
|
|
521
|
+
// (see link-account.mjs:22). Trusting the provider only satisfies
|
|
522
|
+
// the first clause; the second — `requireLocalEmailVerified &&
|
|
523
|
+
// !dbUser.user.emailVerified` — still blocks linking when the
|
|
524
|
+
// pre-existing local user row has `emailVerified=false` (the
|
|
525
|
+
// default for owner-seeded rows). Disabling the local-email gate
|
|
526
|
+
// is safe here because the OAuth side is what we actually trust:
|
|
527
|
+
// the incoming identity was verified by the IdP. Consumers who
|
|
528
|
+
// need the stricter behavior can override via config.
|
|
529
|
+
requireLocalEmailVerified: false,
|
|
530
|
+
...this.config?.account?.accountLinking ?? {},
|
|
531
|
+
trustedProviders: Array.from(/* @__PURE__ */ new Set([
|
|
532
|
+
"objectstack-cloud",
|
|
533
|
+
...this.config?.account?.accountLinking?.trustedProviders ?? []
|
|
534
|
+
]))
|
|
535
|
+
}
|
|
451
536
|
},
|
|
452
537
|
verification: {
|
|
453
538
|
...AUTH_VERIFICATION_CONFIG
|
|
@@ -463,15 +548,71 @@ var AuthManager = class {
|
|
|
463
548
|
...this.config.emailAndPassword?.maxPasswordLength != null ? { maxPasswordLength: this.config.emailAndPassword.maxPasswordLength } : {},
|
|
464
549
|
...this.config.emailAndPassword?.resetPasswordTokenExpiresIn != null ? { resetPasswordTokenExpiresIn: this.config.emailAndPassword.resetPasswordTokenExpiresIn } : {},
|
|
465
550
|
...this.config.emailAndPassword?.autoSignIn != null ? { autoSignIn: this.config.emailAndPassword.autoSignIn } : {},
|
|
466
|
-
...this.config.emailAndPassword?.revokeSessionsOnPasswordReset != null ? { revokeSessionsOnPasswordReset: this.config.emailAndPassword.revokeSessionsOnPasswordReset } : {}
|
|
551
|
+
...this.config.emailAndPassword?.revokeSessionsOnPasswordReset != null ? { revokeSessionsOnPasswordReset: this.config.emailAndPassword.revokeSessionsOnPasswordReset } : {},
|
|
552
|
+
sendResetPassword: async ({ user, url, token }) => {
|
|
553
|
+
const email = this.getEmailService();
|
|
554
|
+
if (!email) {
|
|
555
|
+
console.warn(
|
|
556
|
+
`[AuthManager] Password-reset requested for ${user.email} but no email service is wired. URL: ${url}`
|
|
557
|
+
);
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
const ttlSec = this.config.emailAndPassword?.resetPasswordTokenExpiresIn ?? 60 * 60;
|
|
561
|
+
try {
|
|
562
|
+
await email.sendTemplate({
|
|
563
|
+
template: "auth.password_reset",
|
|
564
|
+
to: { address: user.email, ...user.name ? { name: user.name } : {} },
|
|
565
|
+
data: {
|
|
566
|
+
user: { name: user.name || user.email, email: user.email, id: user.id },
|
|
567
|
+
resetUrl: url,
|
|
568
|
+
token,
|
|
569
|
+
expiresInMinutes: Math.round(ttlSec / 60),
|
|
570
|
+
appName: this.getAppName()
|
|
571
|
+
},
|
|
572
|
+
relatedObject: "sys_user",
|
|
573
|
+
relatedId: user.id
|
|
574
|
+
});
|
|
575
|
+
} catch (err) {
|
|
576
|
+
console.error(`[AuthManager] sendResetPassword failed: ${err?.message ?? err}`);
|
|
577
|
+
throw err;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
467
580
|
},
|
|
468
581
|
// Email verification
|
|
469
|
-
...this.config.emailVerification ? {
|
|
582
|
+
...this.config.emailVerification || this.config.emailService ? {
|
|
470
583
|
emailVerification: {
|
|
471
|
-
...this.config.emailVerification
|
|
472
|
-
...this.config.emailVerification
|
|
473
|
-
...this.config.emailVerification
|
|
474
|
-
...this.config.emailVerification
|
|
584
|
+
...this.config.emailVerification?.sendOnSignUp != null ? { sendOnSignUp: this.config.emailVerification.sendOnSignUp } : {},
|
|
585
|
+
...this.config.emailVerification?.sendOnSignIn != null ? { sendOnSignIn: this.config.emailVerification.sendOnSignIn } : {},
|
|
586
|
+
...this.config.emailVerification?.autoSignInAfterVerification != null ? { autoSignInAfterVerification: this.config.emailVerification.autoSignInAfterVerification } : {},
|
|
587
|
+
...this.config.emailVerification?.expiresIn != null ? { expiresIn: this.config.emailVerification.expiresIn } : {},
|
|
588
|
+
sendVerificationEmail: async ({ user, url, token }) => {
|
|
589
|
+
const email = this.getEmailService();
|
|
590
|
+
if (!email) {
|
|
591
|
+
console.warn(
|
|
592
|
+
`[AuthManager] Verification email requested for ${user.email} but no email service is wired. URL: ${url}`
|
|
593
|
+
);
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
const ttlSec = this.config.emailVerification?.expiresIn ?? 60 * 60;
|
|
597
|
+
try {
|
|
598
|
+
await email.sendTemplate({
|
|
599
|
+
template: "auth.verify_email",
|
|
600
|
+
to: { address: user.email, ...user.name ? { name: user.name } : {} },
|
|
601
|
+
data: {
|
|
602
|
+
user: { name: user.name || user.email, email: user.email, id: user.id },
|
|
603
|
+
verificationUrl: url,
|
|
604
|
+
token,
|
|
605
|
+
expiresInMinutes: Math.round(ttlSec / 60),
|
|
606
|
+
appName: this.getAppName()
|
|
607
|
+
},
|
|
608
|
+
relatedObject: "sys_user",
|
|
609
|
+
relatedId: user.id
|
|
610
|
+
});
|
|
611
|
+
} catch (err) {
|
|
612
|
+
console.error(`[AuthManager] sendVerificationEmail failed: ${err?.message ?? err}`);
|
|
613
|
+
throw err;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
475
616
|
}
|
|
476
617
|
} : {},
|
|
477
618
|
// Session configuration
|
|
@@ -496,6 +637,8 @@ var AuthManager = class {
|
|
|
496
637
|
}
|
|
497
638
|
if (!origins.length && (!corsOrigin || corsOrigin === "*")) {
|
|
498
639
|
origins.push("http://localhost:*");
|
|
640
|
+
origins.push("http://*.localhost:*");
|
|
641
|
+
origins.push("https://*.localhost:*");
|
|
499
642
|
}
|
|
500
643
|
return origins.length ? { trustedOrigins: origins } : {};
|
|
501
644
|
})(),
|
|
@@ -527,12 +670,34 @@ var AuthManager = class {
|
|
|
527
670
|
passkeys: pluginConfig.passkeys ?? false,
|
|
528
671
|
magicLink: pluginConfig.magicLink ?? false,
|
|
529
672
|
oidcProvider: pluginConfig.oidcProvider ?? false,
|
|
530
|
-
deviceAuthorization: pluginConfig.deviceAuthorization ?? false
|
|
673
|
+
deviceAuthorization: pluginConfig.deviceAuthorization ?? false,
|
|
674
|
+
admin: pluginConfig.admin ?? false
|
|
531
675
|
};
|
|
532
676
|
const { bearer } = await import("better-auth/plugins/bearer");
|
|
533
677
|
plugins.push(bearer());
|
|
534
678
|
if (enabled.organization) {
|
|
535
679
|
const { organization } = await import("better-auth/plugins/organization");
|
|
680
|
+
let customOrgRoles;
|
|
681
|
+
const extra = this.config.additionalOrgRoles;
|
|
682
|
+
if (extra && extra.length > 0) {
|
|
683
|
+
try {
|
|
684
|
+
const accessMod = await import("better-auth/plugins/organization/access");
|
|
685
|
+
const { defaultAc, memberAc, defaultRoles: importedDefaultRoles } = accessMod;
|
|
686
|
+
const defaultRoles = importedDefaultRoles || null;
|
|
687
|
+
if (defaultAc && memberAc && typeof memberAc.statements === "object") {
|
|
688
|
+
const built = defaultRoles ? { ...defaultRoles } : {};
|
|
689
|
+
const stmts = memberAc.statements;
|
|
690
|
+
for (const name of extra) {
|
|
691
|
+
if (!name) continue;
|
|
692
|
+
if (built[name]) continue;
|
|
693
|
+
built[name] = defaultAc.newRole(stmts);
|
|
694
|
+
}
|
|
695
|
+
customOrgRoles = built;
|
|
696
|
+
}
|
|
697
|
+
} catch {
|
|
698
|
+
customOrgRoles = void 0;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
536
701
|
plugins.push(organization({
|
|
537
702
|
schema: buildOrganizationPluginSchema(),
|
|
538
703
|
// Enable the team sub-feature so the framework's `sys_team` /
|
|
@@ -543,15 +708,95 @@ var AuthManager = class {
|
|
|
543
708
|
// portal exposes a Teams page; without this flag those endpoints
|
|
544
709
|
// 404 and the section silently breaks.
|
|
545
710
|
teams: { enabled: true },
|
|
711
|
+
// Without a mailer wired in framework, requiring email verification
|
|
712
|
+
// before accepting invitations dead-ends every invite flow with
|
|
713
|
+
// FORBIDDEN EMAIL_VERIFICATION_REQUIRED…. Default-off here keeps
|
|
714
|
+
// the built-in /accept-invitation route usable for pilots; operators
|
|
715
|
+
// who wire a real mailer can re-enable downstream.
|
|
716
|
+
requireEmailVerificationOnInvitation: false,
|
|
717
|
+
...customOrgRoles ? { roles: customOrgRoles } : {},
|
|
718
|
+
// ── Slug-change guard ─────────────────────────────────────
|
|
719
|
+
// An org's slug is baked into every env hostname at creation
|
|
720
|
+
// time (see service-tenant `project-provisioning.ts`). Renaming
|
|
721
|
+
// it while live envs exist would silently desync the URL from
|
|
722
|
+
// the org identity. Block the change here; the cloud Console
|
|
723
|
+
// surfaces this as an actionable error and points users to
|
|
724
|
+
// `change_hostname` or archiving the env. Org `name` (display
|
|
725
|
+
// label) is unaffected — only `slug` is guarded.
|
|
726
|
+
//
|
|
727
|
+
// We resolve the data engine lazily so non-cloud apps (which
|
|
728
|
+
// never seed `sys_environment`) keep working: any lookup error
|
|
729
|
+
// is treated as "no envs to protect".
|
|
730
|
+
organizationHooks: {
|
|
731
|
+
beforeUpdateOrganization: async ({ organization: organization2, member }) => {
|
|
732
|
+
const newSlug = organization2?.slug;
|
|
733
|
+
const orgId = member?.organizationId;
|
|
734
|
+
if (!newSlug || !orgId) return;
|
|
735
|
+
const dataEngine2 = this.config.dataEngine;
|
|
736
|
+
if (!dataEngine2) return;
|
|
737
|
+
let currentSlug;
|
|
738
|
+
try {
|
|
739
|
+
const current = await dataEngine2.findOne("sys_organization", {
|
|
740
|
+
where: { id: orgId }
|
|
741
|
+
});
|
|
742
|
+
currentSlug = current?.slug;
|
|
743
|
+
} catch {
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
if (!currentSlug || currentSlug === newSlug) return;
|
|
747
|
+
let activeEnvs = 0;
|
|
748
|
+
try {
|
|
749
|
+
const envs = await dataEngine2.find("sys_environment", {
|
|
750
|
+
where: { organization_id: orgId }
|
|
751
|
+
});
|
|
752
|
+
activeEnvs = (envs ?? []).filter(
|
|
753
|
+
(e) => e?.status !== "archived" && e?.status !== "failed"
|
|
754
|
+
).length;
|
|
755
|
+
} catch {
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
if (activeEnvs > 0) {
|
|
759
|
+
const { APIError } = await import("better-auth/api");
|
|
760
|
+
throw new APIError("FORBIDDEN", {
|
|
761
|
+
message: `Cannot change organization slug while ${activeEnvs} active environment(s) still reference it. Archive those environments or rename their hostnames first.`
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
},
|
|
546
766
|
// No mailer is wired in framework yet — log the accept URL so
|
|
547
767
|
// operators / UI can fall back to copy-paste flows. Replace this
|
|
548
768
|
// with a real mail integration when available.
|
|
549
|
-
sendInvitationEmail: async ({ email, invitation, organization: org, inviter }) => {
|
|
769
|
+
sendInvitationEmail: async ({ email: recipientEmail, invitation, organization: org, inviter }) => {
|
|
550
770
|
const baseUrl = (this.config.baseUrl ?? "").replace(/\/$/, "");
|
|
551
771
|
const acceptUrl = `${baseUrl}/accept-invitation/${invitation.id}`;
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
772
|
+
const emailService = this.getEmailService();
|
|
773
|
+
if (!emailService) {
|
|
774
|
+
console.warn(
|
|
775
|
+
`[AuthManager] Invitation email not configured. To: ${recipientEmail} (org: ${org?.name ?? invitation.organizationId}, role: ${invitation.role}, inviter: ${inviter?.user?.email ?? "unknown"}) URL: ${acceptUrl}`
|
|
776
|
+
);
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
try {
|
|
780
|
+
await emailService.sendTemplate({
|
|
781
|
+
template: "auth.invitation",
|
|
782
|
+
to: recipientEmail,
|
|
783
|
+
data: {
|
|
784
|
+
inviter: {
|
|
785
|
+
name: inviter?.user?.name ?? inviter?.user?.email ?? "A teammate",
|
|
786
|
+
email: inviter?.user?.email ?? ""
|
|
787
|
+
},
|
|
788
|
+
organization: { name: org?.name ?? invitation.organizationId },
|
|
789
|
+
role: invitation.role || "",
|
|
790
|
+
acceptUrl,
|
|
791
|
+
appName: this.getAppName()
|
|
792
|
+
},
|
|
793
|
+
relatedObject: "sys_invitation",
|
|
794
|
+
relatedId: invitation.id
|
|
795
|
+
});
|
|
796
|
+
} catch (err) {
|
|
797
|
+
console.error(`[AuthManager] sendInvitationEmail failed: ${err?.message ?? err}`);
|
|
798
|
+
throw err;
|
|
799
|
+
}
|
|
555
800
|
}
|
|
556
801
|
}));
|
|
557
802
|
}
|
|
@@ -561,13 +806,38 @@ var AuthManager = class {
|
|
|
561
806
|
schema: buildTwoFactorPluginSchema()
|
|
562
807
|
}));
|
|
563
808
|
}
|
|
809
|
+
if (enabled.admin) {
|
|
810
|
+
const { admin } = await import("better-auth/plugins/admin");
|
|
811
|
+
plugins.push(admin({
|
|
812
|
+
schema: buildAdminPluginSchema()
|
|
813
|
+
}));
|
|
814
|
+
}
|
|
564
815
|
if (enabled.magicLink) {
|
|
565
816
|
const { magicLink } = await import("better-auth/plugins/magic-link");
|
|
566
817
|
plugins.push(magicLink({
|
|
567
|
-
sendMagicLink: async ({ email, url }) => {
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
818
|
+
sendMagicLink: async ({ email: recipientEmail, url, token }) => {
|
|
819
|
+
const emailService = this.getEmailService();
|
|
820
|
+
if (!emailService) {
|
|
821
|
+
console.warn(
|
|
822
|
+
`[AuthManager] Magic-link requested for ${recipientEmail} but no email service is wired. URL: ${url}`
|
|
823
|
+
);
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
try {
|
|
827
|
+
await emailService.sendTemplate({
|
|
828
|
+
template: "auth.magic_link",
|
|
829
|
+
to: recipientEmail,
|
|
830
|
+
data: {
|
|
831
|
+
magicLinkUrl: url,
|
|
832
|
+
token,
|
|
833
|
+
expiresInMinutes: 10,
|
|
834
|
+
appName: this.getAppName()
|
|
835
|
+
}
|
|
836
|
+
});
|
|
837
|
+
} catch (err) {
|
|
838
|
+
console.error(`[AuthManager] sendMagicLink failed: ${err?.message ?? err}`);
|
|
839
|
+
throw err;
|
|
840
|
+
}
|
|
571
841
|
}
|
|
572
842
|
}));
|
|
573
843
|
}
|
|
@@ -608,6 +878,55 @@ var AuthManager = class {
|
|
|
608
878
|
schema: buildDeviceAuthorizationPluginSchema()
|
|
609
879
|
}));
|
|
610
880
|
}
|
|
881
|
+
const dataEngine = this.config.dataEngine;
|
|
882
|
+
if (dataEngine) {
|
|
883
|
+
const { customSession } = await import("better-auth/plugins/custom-session");
|
|
884
|
+
plugins.push(customSession(async ({ user, session }) => {
|
|
885
|
+
if (!user?.id) return { user, session };
|
|
886
|
+
const isPlatformAdmin = async () => {
|
|
887
|
+
try {
|
|
888
|
+
const links = await dataEngine.find("sys_user_permission_set", {
|
|
889
|
+
where: { user_id: user.id },
|
|
890
|
+
limit: 50
|
|
891
|
+
});
|
|
892
|
+
const platformLinks = (Array.isArray(links) ? links : []).filter(
|
|
893
|
+
(l) => !l.organization_id
|
|
894
|
+
);
|
|
895
|
+
if (platformLinks.length === 0) return false;
|
|
896
|
+
const sets = await dataEngine.find("sys_permission_set", { limit: 50 });
|
|
897
|
+
const adminSet = (Array.isArray(sets) ? sets : []).find(
|
|
898
|
+
(r) => r.name === "admin_full_access"
|
|
899
|
+
);
|
|
900
|
+
if (!adminSet) return false;
|
|
901
|
+
return platformLinks.some(
|
|
902
|
+
(l) => l.permission_set_id === adminSet.id
|
|
903
|
+
);
|
|
904
|
+
} catch {
|
|
905
|
+
return false;
|
|
906
|
+
}
|
|
907
|
+
};
|
|
908
|
+
const isActiveOrgAdmin = async () => {
|
|
909
|
+
try {
|
|
910
|
+
const orgId = session?.activeOrganizationId;
|
|
911
|
+
if (!orgId) return false;
|
|
912
|
+
const members = await dataEngine.find("sys_member", {
|
|
913
|
+
where: { user_id: user.id, organization_id: orgId },
|
|
914
|
+
limit: 5
|
|
915
|
+
});
|
|
916
|
+
return (Array.isArray(members) ? members : []).some((m) => {
|
|
917
|
+
const raw = typeof m?.role === "string" ? m.role : "";
|
|
918
|
+
const roles = raw.split(",").map((s) => s.trim().toLowerCase());
|
|
919
|
+
return roles.includes("owner") || roles.includes("admin");
|
|
920
|
+
});
|
|
921
|
+
} catch {
|
|
922
|
+
return false;
|
|
923
|
+
}
|
|
924
|
+
};
|
|
925
|
+
const promote = await isPlatformAdmin() || await isActiveOrgAdmin();
|
|
926
|
+
if (!promote) return { user, session };
|
|
927
|
+
return { user: { ...user, role: "admin" }, session };
|
|
928
|
+
}));
|
|
929
|
+
}
|
|
611
930
|
return plugins;
|
|
612
931
|
}
|
|
613
932
|
/**
|
|
@@ -664,6 +983,26 @@ var AuthManager = class {
|
|
|
664
983
|
}
|
|
665
984
|
this.config = { ...this.config, baseUrl: url };
|
|
666
985
|
}
|
|
986
|
+
/**
|
|
987
|
+
* Inject (or replace) the outbound email service used by better-auth
|
|
988
|
+
* callbacks. Safe to call after construction but BEFORE the first
|
|
989
|
+
* request hits the auth handler — callbacks read this via
|
|
990
|
+
* {@link getEmailService} when invoked.
|
|
991
|
+
*
|
|
992
|
+
* AuthPlugin calls this on `kernel:ready` once `ctx.getService('email')`
|
|
993
|
+
* resolves. For tests / serverless, callers may invoke directly.
|
|
994
|
+
*/
|
|
995
|
+
setEmailService(email) {
|
|
996
|
+
this.config.emailService = email;
|
|
997
|
+
}
|
|
998
|
+
/** @internal Used by callback closures. */
|
|
999
|
+
getEmailService() {
|
|
1000
|
+
return this.config.emailService;
|
|
1001
|
+
}
|
|
1002
|
+
/** @internal `{{appName}}` placeholder value for built-in templates. */
|
|
1003
|
+
getAppName() {
|
|
1004
|
+
return this.config.appName ?? "ObjectStack";
|
|
1005
|
+
}
|
|
667
1006
|
/**
|
|
668
1007
|
* Get the underlying better-auth instance
|
|
669
1008
|
* Useful for advanced use cases
|
|
@@ -863,24 +1202,22 @@ var AuthPlugin = class {
|
|
|
863
1202
|
ctx.registerService("auth", this.authManager);
|
|
864
1203
|
ctx.getService("manifest").register({
|
|
865
1204
|
...authPluginManifestHeader,
|
|
1205
|
+
...this.options.manifestDatasource ? { defaultDatasource: this.options.manifestDatasource } : {},
|
|
866
1206
|
objects: authIdentityObjects,
|
|
867
1207
|
// The platform Setup App is a static metadata artifact (lives in
|
|
868
1208
|
// @objectstack/platform-objects/apps). plugin-auth is the natural
|
|
869
1209
|
// owner of its registration since it loads first among the trio
|
|
870
1210
|
// (auth + security + audit) that supplies the underlying objects.
|
|
871
1211
|
apps: [SETUP_APP],
|
|
872
|
-
//
|
|
873
|
-
//
|
|
874
|
-
//
|
|
875
|
-
//
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
AuditLogsView,
|
|
882
|
-
PackageInstallationsView
|
|
883
|
-
],
|
|
1212
|
+
// List views for each Setup-nav object are defined on the schema
|
|
1213
|
+
// itself via the canonical `listViews` map (e.g.
|
|
1214
|
+
// sys_user.listViews.{all_users,unverified,two_factor}). Registering
|
|
1215
|
+
// top-level views here is the legacy pre-M10.30c pattern — it caused
|
|
1216
|
+
// duplicate "Users"/"Roles"/"Sessions" tabs to appear alongside the
|
|
1217
|
+
// schema-derived ones, sometimes referencing nonexistent fields
|
|
1218
|
+
// (e.g. legacy `users.view` had phone/status/active columns that do
|
|
1219
|
+
// not exist on sys_user). Schema-embedded listViews is the single
|
|
1220
|
+
// source of truth.
|
|
884
1221
|
dashboards: [SystemOverviewDashboard, SecurityOverviewDashboard]
|
|
885
1222
|
});
|
|
886
1223
|
ctx.logger.info("Auth Plugin initialized successfully");
|
|
@@ -890,8 +1227,43 @@ var AuthPlugin = class {
|
|
|
890
1227
|
if (!this.authManager) {
|
|
891
1228
|
throw new Error("Auth manager not initialized");
|
|
892
1229
|
}
|
|
1230
|
+
ctx.hook("kernel:ready", async () => {
|
|
1231
|
+
try {
|
|
1232
|
+
const i18n = ctx.getService("i18n");
|
|
1233
|
+
let loaded = 0;
|
|
1234
|
+
for (const [locale, data] of Object.entries(SetupAppTranslations)) {
|
|
1235
|
+
if (data && typeof data === "object") {
|
|
1236
|
+
try {
|
|
1237
|
+
i18n.loadTranslations(locale, data);
|
|
1238
|
+
loaded++;
|
|
1239
|
+
} catch (err) {
|
|
1240
|
+
ctx.logger.warn(
|
|
1241
|
+
`Auth: failed to load Setup App translations for '${locale}': ${err?.message ?? err}`
|
|
1242
|
+
);
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
if (loaded > 0) {
|
|
1247
|
+
ctx.logger.info(
|
|
1248
|
+
`Auth: contributed Setup App translations (${loaded} locale${loaded > 1 ? "s" : ""})`
|
|
1249
|
+
);
|
|
1250
|
+
}
|
|
1251
|
+
} catch {
|
|
1252
|
+
}
|
|
1253
|
+
});
|
|
893
1254
|
if (this.options.registerRoutes) {
|
|
894
1255
|
ctx.hook("kernel:ready", async () => {
|
|
1256
|
+
if (this.authManager) {
|
|
1257
|
+
try {
|
|
1258
|
+
const emailSvc = ctx.getService("email");
|
|
1259
|
+
if (emailSvc) {
|
|
1260
|
+
this.authManager.setEmailService(emailSvc);
|
|
1261
|
+
ctx.logger.info("Auth: email service wired (transactional mail enabled)");
|
|
1262
|
+
}
|
|
1263
|
+
} catch {
|
|
1264
|
+
ctx.logger.info("Auth: no email service registered \u2014 auth callbacks will log instead of sending");
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
895
1267
|
let httpServer = null;
|
|
896
1268
|
try {
|
|
897
1269
|
httpServer = ctx.getService("http-server");
|
|
@@ -997,6 +1369,19 @@ var AuthPlugin = class {
|
|
|
997
1369
|
ctx.logger.error("[AuthPlugin] better-auth returned server error", new Error(`HTTP ${response.status}: (unable to read body)`));
|
|
998
1370
|
}
|
|
999
1371
|
}
|
|
1372
|
+
try {
|
|
1373
|
+
const url = c.req.url;
|
|
1374
|
+
if (response.ok && /\/jwks(\?|$)/.test(url)) {
|
|
1375
|
+
const existing = response.headers.get("cache-control");
|
|
1376
|
+
if (!existing) {
|
|
1377
|
+
response.headers.set(
|
|
1378
|
+
"cache-control",
|
|
1379
|
+
"public, max-age=300, stale-while-revalidate=86400"
|
|
1380
|
+
);
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
} catch {
|
|
1384
|
+
}
|
|
1000
1385
|
return response;
|
|
1001
1386
|
} catch (error) {
|
|
1002
1387
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
@@ -1031,8 +1416,19 @@ var AuthPlugin = class {
|
|
|
1031
1416
|
const { oauthProviderAuthServerMetadata, oauthProviderOpenIdConfigMetadata } = await import("@better-auth/oauth-provider");
|
|
1032
1417
|
const authServerHandler = oauthProviderAuthServerMetadata(auth);
|
|
1033
1418
|
const openidConfigHandler = oauthProviderOpenIdConfigMetadata(auth);
|
|
1034
|
-
|
|
1035
|
-
|
|
1419
|
+
const DISCOVERY_CACHE = "public, max-age=300, stale-while-revalidate=86400";
|
|
1420
|
+
const withDiscoveryCache = async (handler, req) => {
|
|
1421
|
+
const resp = await handler(req);
|
|
1422
|
+
try {
|
|
1423
|
+
if (resp.ok && !resp.headers.get("cache-control")) {
|
|
1424
|
+
resp.headers.set("cache-control", DISCOVERY_CACHE);
|
|
1425
|
+
}
|
|
1426
|
+
} catch {
|
|
1427
|
+
}
|
|
1428
|
+
return resp;
|
|
1429
|
+
};
|
|
1430
|
+
rawApp.get("/.well-known/oauth-authorization-server", (c) => withDiscoveryCache(authServerHandler, c.req.raw));
|
|
1431
|
+
rawApp.get("/.well-known/openid-configuration", (c) => withDiscoveryCache(openidConfigHandler, c.req.raw));
|
|
1036
1432
|
ctx.logger.info(
|
|
1037
1433
|
"OIDC discovery endpoints mounted at /.well-known/{oauth-authorization-server,openid-configuration}"
|
|
1038
1434
|
);
|
|
@@ -1040,6 +1436,8 @@ var AuthPlugin = class {
|
|
|
1040
1436
|
};
|
|
1041
1437
|
export {
|
|
1042
1438
|
AUTH_ACCOUNT_CONFIG,
|
|
1439
|
+
AUTH_ADMIN_SESSION_FIELDS,
|
|
1440
|
+
AUTH_ADMIN_USER_FIELDS,
|
|
1043
1441
|
AUTH_DEVICE_CODE_SCHEMA,
|
|
1044
1442
|
AUTH_INVITATION_SCHEMA,
|
|
1045
1443
|
AUTH_JWKS_SCHEMA,
|
|
@@ -1061,6 +1459,7 @@ export {
|
|
|
1061
1459
|
AUTH_VERIFICATION_CONFIG,
|
|
1062
1460
|
AuthManager,
|
|
1063
1461
|
AuthPlugin,
|
|
1462
|
+
buildAdminPluginSchema,
|
|
1064
1463
|
buildDeviceAuthorizationPluginSchema,
|
|
1065
1464
|
buildJwtPluginSchema,
|
|
1066
1465
|
buildOauthProviderPluginSchema,
|