@valentinkolb/cloud 0.4.0 → 0.5.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/package.json +18 -6
- package/scripts/preload.ts +78 -23
- package/src/_internal/define-app.ts +53 -46
- package/src/api/accounts-entities.ts +4 -0
- package/src/api/admin-core-settings.ts +98 -0
- package/src/api/announcements.ts +131 -0
- package/src/api/auth/schemas.ts +24 -0
- package/src/api/auth.ts +116 -13
- package/src/api/index.ts +7 -2
- package/src/api/me.ts +203 -14
- package/src/api/search/schemas.ts +1 -0
- package/src/api/search.ts +62 -8
- package/src/config/ssr.ts +2 -9
- package/src/contracts/announcements.test.ts +37 -0
- package/src/contracts/announcements.ts +121 -0
- package/src/contracts/app.ts +2 -0
- package/src/contracts/index.ts +3 -2
- package/src/contracts/registry.ts +2 -0
- package/src/contracts/shared.ts +108 -1
- package/src/desktop/index.ts +704 -0
- package/src/desktop/solid.tsx +938 -0
- package/src/server/api/index.ts +1 -1
- package/src/server/api/respond.ts +50 -10
- package/src/server/index.ts +44 -38
- package/src/server/middleware/auth.ts +98 -9
- package/src/server/middleware/index.ts +2 -1
- package/src/server/middleware/settings.ts +26 -0
- package/src/server/services/access.test.ts +197 -0
- package/src/server/services/access.ts +254 -6
- package/src/server/services/index.ts +14 -11
- package/src/server/services/pagination.ts +22 -0
- package/src/server/time.ts +45 -0
- package/src/services/account-lifecycle/index.ts +142 -18
- package/src/services/accounts/app.ts +658 -170
- package/src/services/accounts/authz.test.ts +77 -0
- package/src/services/accounts/authz.ts +22 -0
- package/src/services/accounts/entities.ts +84 -5
- package/src/services/accounts/groups.ts +30 -24
- package/src/services/accounts/model.test.ts +30 -0
- package/src/services/accounts/switching.test.ts +14 -0
- package/src/services/accounts/switching.ts +15 -6
- package/src/services/accounts/users.ts +75 -52
- package/src/services/announcements/index.test.ts +32 -0
- package/src/services/announcements/index.ts +224 -0
- package/src/services/audit/index.test.ts +84 -0
- package/src/services/audit/index.ts +431 -0
- package/src/services/auth-flows/index.ts +9 -2
- package/src/services/auth-flows/ipa.ts +47 -7
- package/src/services/auth-flows/magic-link.ts +92 -20
- package/src/services/auth-flows/password-reset.ts +284 -0
- package/src/services/auth-flows/proxy-return.test.ts +24 -0
- package/src/services/auth-flows/proxy-return.ts +49 -0
- package/src/services/gateway.ts +162 -0
- package/src/services/index.ts +44 -2
- package/src/services/ipa/effective-groups.test.ts +33 -0
- package/src/services/ipa/effective-groups.ts +70 -0
- package/src/services/ipa/profile.ts +45 -3
- package/src/services/ipa/search.ts +3 -5
- package/src/services/ipa/service-account.ts +15 -0
- package/src/services/ipa/sync-planning.test.ts +32 -0
- package/src/services/ipa/sync-planning.ts +22 -0
- package/src/services/ipa/sync.ts +110 -38
- package/src/services/notifications/index.ts +82 -11
- package/src/services/oauth-tokens.ts +104 -0
- package/src/services/postgres.ts +21 -6
- package/src/services/providers/local/auth.test.ts +22 -0
- package/src/services/providers/local/auth.ts +46 -3
- package/src/services/secrets.ts +10 -0
- package/src/services/service-account-credentials.test.ts +210 -0
- package/src/services/service-account-credentials.ts +715 -0
- package/src/services/service-accounts.ts +188 -0
- package/src/services/session/index.ts +7 -8
- package/src/services/settings/app.ts +4 -20
- package/src/services/settings/defaults.ts +79 -22
- package/src/services/settings/store.ts +47 -0
- package/src/services/weather/forecast.ts +40 -7
- package/src/services/webauthn.test.ts +36 -0
- package/src/services/webauthn.ts +384 -0
- package/src/shared/icons.ts +391 -100
- package/src/shared/index.ts +7 -0
- package/src/shared/markdown/extensions/code.ts +38 -1
- package/src/shared/markdown/extensions/images.ts +39 -3
- package/src/shared/markdown/extensions/info-blocks.ts +5 -5
- package/src/shared/markdown/extensions/mark.ts +48 -0
- package/src/shared/markdown/extensions/sub-sup.ts +60 -0
- package/src/shared/markdown/extensions/tables.ts +79 -58
- package/src/shared/markdown/formula.test.ts +1089 -0
- package/src/shared/markdown/formula.ts +1187 -0
- package/src/shared/markdown/index.ts +76 -2
- package/src/shared/mock-cover.ts +130 -0
- package/src/shared/redirect.test.ts +58 -0
- package/src/shared/redirect.ts +56 -0
- package/src/shared/theme.test.ts +24 -0
- package/src/shared/theme.ts +68 -0
- package/src/shared/time.ts +13 -0
- package/src/ssr/AdminLayout.tsx +7 -3
- package/src/ssr/AdminSidebar.tsx +115 -49
- package/src/ssr/AppLaunchpad.island.tsx +176 -0
- package/src/ssr/Footer.island.tsx +3 -8
- package/src/ssr/GlobalAnnouncements.island.tsx +141 -0
- package/src/ssr/GlobalSearchDialog.tsx +545 -117
- package/src/ssr/HotkeysHelpRail.island.tsx +3 -70
- package/src/ssr/Layout.tsx +74 -66
- package/src/ssr/LayoutBreadcrumbs.island.tsx +44 -0
- package/src/ssr/LayoutHelp.tsx +266 -0
- package/src/ssr/NavMenu.island.tsx +0 -39
- package/src/ssr/ThemeToggleRail.island.tsx +3 -3
- package/src/ssr/TimezoneCookie.island.tsx +23 -0
- package/src/ssr/islands/index.ts +13 -0
- package/src/styles/base-popover.css +5 -2
- package/src/styles/effects.css +87 -6
- package/src/styles/global.css +146 -9
- package/src/styles/input.css +3 -1
- package/src/styles/utilities-buttons.css +133 -27
- package/src/styles/utilities-code-display.css +67 -0
- package/src/styles/utilities-completion.css +223 -0
- package/src/styles/utilities-detail.css +73 -0
- package/src/styles/utilities-feedback.css +16 -15
- package/src/styles/utilities-layout.css +42 -2
- package/src/styles/utilities-markdown-editor.css +472 -0
- package/src/styles/utilities-navigation.css +63 -8
- package/src/styles/utilities-script.css +84 -0
- package/src/styles/utilities-table-tile.css +229 -0
- package/src/types/ambient.d.ts +9 -0
- package/src/ui/completion/behaviors.test.ts +95 -0
- package/src/ui/completion/behaviors.ts +205 -0
- package/src/ui/completion/engine.ts +368 -0
- package/src/ui/completion/index.ts +40 -0
- package/src/ui/completion/overlay.ts +92 -0
- package/src/ui/dialog-core.ts +173 -45
- package/src/ui/filter/FilterChip.tsx +42 -40
- package/src/ui/index.ts +11 -12
- package/src/ui/input/AutocompleteEditor.tsx +656 -0
- package/src/ui/input/CheckboxCard.tsx +91 -0
- package/src/ui/input/Combobox.tsx +375 -0
- package/src/ui/input/DatePicker.tsx +846 -0
- package/src/ui/input/DateTimeInput.tsx +29 -4
- package/src/ui/input/FileDropzone.tsx +116 -0
- package/src/ui/input/IconInput.tsx +116 -0
- package/src/ui/input/ImageInput.tsx +19 -2
- package/src/ui/input/MultiSelectInput.tsx +448 -0
- package/src/ui/input/NumberInput.tsx +417 -61
- package/src/ui/input/SegmentedControl.tsx +2 -2
- package/src/ui/input/Select.tsx +172 -10
- package/src/ui/input/Slider.tsx +3 -4
- package/src/ui/input/Switch.tsx +3 -2
- package/src/ui/input/TemplateEditor.tsx +212 -0
- package/src/ui/input/TextInput.tsx +144 -13
- package/src/ui/input/index.ts +53 -8
- package/src/ui/input/markdown/MarkdownEditor.tsx +774 -0
- package/src/ui/input/markdown/Toolbar.tsx +90 -0
- package/src/ui/input/markdown/actions.ts +233 -0
- package/src/ui/input/markdown/active-formats.ts +94 -0
- package/src/ui/input/markdown/behaviors.ts +193 -0
- package/src/ui/input/markdown/code-zone.ts +23 -0
- package/src/ui/input/markdown/highlight.ts +316 -0
- package/src/ui/layout.ts +22 -0
- package/src/ui/misc/AppOverview.tsx +105 -0
- package/src/ui/misc/AppWorkspace.tsx +607 -0
- package/src/ui/misc/Calendar.tsx +1291 -0
- package/src/ui/misc/Chart.tsx +162 -0
- package/src/ui/misc/CodeDisplay.tsx +54 -0
- package/src/ui/misc/ContextMenu.tsx +2 -2
- package/src/ui/misc/DataTable.tsx +269 -0
- package/src/ui/misc/DockWorkspace.tsx +425 -0
- package/src/ui/misc/Docs.tsx +153 -0
- package/src/ui/misc/Dropdown.tsx +2 -2
- package/src/ui/misc/EntitySearch.tsx +260 -129
- package/src/ui/misc/LinkCard.tsx +14 -2
- package/src/ui/misc/LogEntriesTable.tsx +34 -31
- package/src/ui/misc/Pagination.tsx +31 -12
- package/src/ui/misc/PanelDialog.tsx +109 -0
- package/src/ui/misc/Panes.tsx +873 -0
- package/src/ui/misc/PermissionEditor.tsx +358 -262
- package/src/ui/misc/Placeholder.tsx +40 -0
- package/src/ui/misc/ProgressBar.tsx +1 -1
- package/src/ui/misc/ResourceApiKeys.tsx +260 -0
- package/src/ui/misc/SettingsModal.tsx +150 -0
- package/src/ui/misc/StatCell.tsx +182 -40
- package/src/ui/misc/StatGrid.tsx +149 -0
- package/src/ui/misc/StructuredDataPreview.tsx +107 -0
- package/src/ui/misc/code-highlight.ts +213 -0
- package/src/ui/misc/index.ts +93 -12
- package/src/ui/prompts.tsx +362 -312
- package/src/ui/toast.ts +384 -0
- package/src/ui/widgets/Widget.tsx +12 -4
- package/src/ssr/MoreAppsDropdown.island.tsx +0 -61
- package/src/ui/ipa/GroupView.tsx +0 -36
- package/src/ui/ipa/LoginBtn.tsx +0 -16
- package/src/ui/ipa/UserView.tsx +0 -58
- package/src/ui/ipa/index.ts +0 -4
- package/src/ui/navigation.ts +0 -32
- package/src/ui/sidebar.tsx +0 -468
- /package/src/ui/{ipa → misc}/Avatar.tsx +0 -0
|
@@ -1,45 +1,108 @@
|
|
|
1
|
-
import { sql } from "bun";
|
|
1
|
+
import { redis, sql } from "bun";
|
|
2
2
|
import { accounts } from "../accounts";
|
|
3
3
|
import { notifications } from "../notifications";
|
|
4
4
|
import { providers } from "../providers";
|
|
5
5
|
import * as settings from "../settings";
|
|
6
6
|
import { renderTemplate } from "../settings/templates";
|
|
7
7
|
import type { User } from "../../contracts/shared";
|
|
8
|
+
import { createAuthLoginUrl } from "../../shared/redirect";
|
|
9
|
+
import { logger } from "../logging";
|
|
8
10
|
|
|
9
|
-
|
|
11
|
+
const log = logger("auth:magic-link");
|
|
12
|
+
const IPA_HINT_COOLDOWN_SECONDS = 300;
|
|
13
|
+
|
|
14
|
+
const normalizeEmail = (email: string): string => email.trim().toLowerCase();
|
|
15
|
+
const ipaHintCooldownKey = (email: string): string => `ipa-email-login-hint-cooldown:${email}`;
|
|
16
|
+
|
|
17
|
+
const getAppUrl = async (): Promise<string> => {
|
|
18
|
+
const rawAppUrl = await settings.get<string>("app.url");
|
|
19
|
+
return rawAppUrl.startsWith("http") ? rawAppUrl : `https://${rawAppUrl}`;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const hasIpaAccountForEmail = async (email: string): Promise<boolean> => {
|
|
23
|
+
const rows = await sql<{ exists: boolean }[]>`
|
|
24
|
+
SELECT EXISTS (
|
|
25
|
+
SELECT 1
|
|
26
|
+
FROM auth.users
|
|
27
|
+
WHERE provider = 'ipa'
|
|
28
|
+
AND lower(btrim(mail)) = ${email}
|
|
29
|
+
) AS exists
|
|
30
|
+
`;
|
|
31
|
+
return Boolean(rows[0]?.exists);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const claimIpaHintCooldown = async (email: string): Promise<boolean> => {
|
|
35
|
+
const result = await redis.send("SET", [
|
|
36
|
+
ipaHintCooldownKey(email),
|
|
37
|
+
"1",
|
|
38
|
+
"EX",
|
|
39
|
+
String(IPA_HINT_COOLDOWN_SECONDS),
|
|
40
|
+
"NX",
|
|
41
|
+
]);
|
|
42
|
+
return result === "OK";
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const sendIpaEmailLoginHint = async (params: { email: string; redirectTo?: string }): Promise<void> => {
|
|
46
|
+
const appUrl = await getAppUrl();
|
|
47
|
+
const loginUrl = createAuthLoginUrl(appUrl, {
|
|
48
|
+
method: "ipa",
|
|
49
|
+
redirectTo: params.redirectTo,
|
|
50
|
+
});
|
|
51
|
+
const [appName, contactEmail, template] = await Promise.all([
|
|
52
|
+
settings.get<string>("app.name"),
|
|
53
|
+
settings.get<string>("app.contact_email"),
|
|
54
|
+
settings.get<string>("mail.ipa_email_login_hint"),
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
await notifications.send({
|
|
58
|
+
type: "email",
|
|
59
|
+
recipient: params.email,
|
|
60
|
+
subject: `${appName} FreeIPA Sign In`,
|
|
61
|
+
rawHtml: renderTemplate(template, {
|
|
62
|
+
EMAIL: params.email,
|
|
63
|
+
LOGIN_URL: loginUrl,
|
|
64
|
+
APP_NAME: appName,
|
|
65
|
+
CONTACT_EMAIL: contactEmail?.trim() ?? "",
|
|
66
|
+
}),
|
|
67
|
+
});
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export const request = async (params: { email: string; redirectTo?: string }): Promise<
|
|
10
71
|
| { ok: true }
|
|
11
72
|
| { ok: false; status: 400; message: string }
|
|
12
73
|
> => {
|
|
13
|
-
const
|
|
74
|
+
const email = normalizeEmail(params.email);
|
|
75
|
+
const hasIpaUser = await hasIpaAccountForEmail(email);
|
|
76
|
+
const userRows = hasIpaUser ? [] : await sql`SELECT uid, provider FROM auth.users WHERE lower(btrim(mail)) = ${email}`;
|
|
14
77
|
const hasLocalUser = userRows.some((row: { provider: string | null }) => row.provider === "local");
|
|
15
|
-
const hasIpaUser = userRows.some((row: { provider: string | null }) => row.provider === "ipa");
|
|
16
78
|
const allowSelfRegistration = await settings.get<boolean>("user.allow_self_registration");
|
|
17
79
|
|
|
18
|
-
if (
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
80
|
+
if (hasIpaUser) {
|
|
81
|
+
if (await claimIpaHintCooldown(email)) {
|
|
82
|
+
void sendIpaEmailLoginHint({ email, redirectTo: params.redirectTo }).catch((error) => {
|
|
83
|
+
log.warn("Failed to send FreeIPA email-login hint", {
|
|
84
|
+
email,
|
|
85
|
+
error: error instanceof Error ? error.message : String(error),
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
return { ok: true };
|
|
24
90
|
}
|
|
25
91
|
|
|
26
|
-
if (!hasLocalUser &&
|
|
27
|
-
// Return ok without sending email to prevent account enumeration.
|
|
28
|
-
// IPA-only users must authenticate via Kerberos, not magic-link.
|
|
92
|
+
if (!hasLocalUser && !allowSelfRegistration) {
|
|
29
93
|
return { ok: true };
|
|
30
94
|
}
|
|
31
95
|
|
|
32
|
-
const token = await providers.local.auth.createMagicLinkToken({ email
|
|
33
|
-
const
|
|
34
|
-
const
|
|
35
|
-
const magicLink = `${appUrl}/auth/login?token=${token}`;
|
|
96
|
+
const token = await providers.local.auth.createMagicLinkToken({ email, ttlSeconds: 300 });
|
|
97
|
+
const appUrl = await getAppUrl();
|
|
98
|
+
const magicLink = createAuthLoginUrl(appUrl, { token, redirectTo: params.redirectTo });
|
|
36
99
|
|
|
37
100
|
const appName = await settings.get<string>("app.name");
|
|
38
101
|
const template = await settings.get<string>("mail.magic_link_login");
|
|
39
102
|
|
|
40
103
|
await notifications.send({
|
|
41
104
|
type: "email",
|
|
42
|
-
recipient:
|
|
105
|
+
recipient: email,
|
|
43
106
|
subject: `${appName} Login Code`,
|
|
44
107
|
rawHtml: renderTemplate(template, {
|
|
45
108
|
TOKEN: token,
|
|
@@ -62,13 +125,22 @@ export const verify = async (params: { token: string }): Promise<
|
|
|
62
125
|
}
|
|
63
126
|
|
|
64
127
|
const { email } = payload;
|
|
128
|
+
const normalizedEmail = normalizeEmail(email);
|
|
129
|
+
if (await hasIpaAccountForEmail(normalizedEmail)) {
|
|
130
|
+
return {
|
|
131
|
+
ok: false,
|
|
132
|
+
status: 401,
|
|
133
|
+
message: "This email address belongs to a FreeIPA-managed account. Sign in with FreeIPA.",
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
65
137
|
// Reject expired accounts at login time, not just during cleanup. Without
|
|
66
138
|
// this, an expired local user / guest could still authenticate in the
|
|
67
139
|
// window between expiry and the next lifecycle run.
|
|
68
140
|
const userRows = await sql`
|
|
69
141
|
SELECT id, account_expires
|
|
70
142
|
FROM auth.users
|
|
71
|
-
WHERE mail = ${
|
|
143
|
+
WHERE lower(btrim(mail)) = ${normalizedEmail} AND provider = 'local'
|
|
72
144
|
AND (account_expires IS NULL OR account_expires > now())
|
|
73
145
|
ORDER BY profile = 'user' DESC
|
|
74
146
|
LIMIT 1
|
|
@@ -83,7 +155,7 @@ export const verify = async (params: { token: string }): Promise<
|
|
|
83
155
|
const expiredRows = await sql`
|
|
84
156
|
SELECT id
|
|
85
157
|
FROM auth.users
|
|
86
|
-
WHERE mail = ${
|
|
158
|
+
WHERE lower(btrim(mail)) = ${normalizedEmail} AND provider = 'local'
|
|
87
159
|
AND account_expires IS NOT NULL AND account_expires <= now()
|
|
88
160
|
LIMIT 1
|
|
89
161
|
`;
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { redis, sql } from "bun";
|
|
2
|
+
import { notifications } from "../notifications";
|
|
3
|
+
import { providers } from "../providers";
|
|
4
|
+
import { session } from "../session";
|
|
5
|
+
import * as settings from "../settings";
|
|
6
|
+
import { renderTemplate } from "../settings/templates";
|
|
7
|
+
import { logger } from "../logging";
|
|
8
|
+
import { getServiceIpaSession } from "../ipa/service-account";
|
|
9
|
+
import { getFreeIpaConfig } from "../freeipa-config";
|
|
10
|
+
import type { User } from "../../contracts/shared";
|
|
11
|
+
import { createAuthPasswordResetUrl } from "../../shared/redirect";
|
|
12
|
+
import * as ipaFlow from "./ipa";
|
|
13
|
+
|
|
14
|
+
const log = logger("auth:password-reset");
|
|
15
|
+
|
|
16
|
+
const REQUEST_TTL_SECONDS = 900;
|
|
17
|
+
const REQUEST_COOLDOWN_SECONDS = 60;
|
|
18
|
+
const GENERIC_MESSAGE =
|
|
19
|
+
"If this account can reset a password, a reset link has been sent.";
|
|
20
|
+
|
|
21
|
+
type ResetTarget = {
|
|
22
|
+
userId: string;
|
|
23
|
+
uid: string;
|
|
24
|
+
email: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
type ResetAttemptSuccess = {
|
|
28
|
+
ok: true;
|
|
29
|
+
userId: string;
|
|
30
|
+
user: User;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
type ResetAttemptFailure =
|
|
34
|
+
| {
|
|
35
|
+
ok: false;
|
|
36
|
+
status: 400;
|
|
37
|
+
reason: "policy_failed";
|
|
38
|
+
message: string;
|
|
39
|
+
}
|
|
40
|
+
| {
|
|
41
|
+
ok: false;
|
|
42
|
+
status: 400 | 401;
|
|
43
|
+
reason: "invalid_or_expired";
|
|
44
|
+
message: string;
|
|
45
|
+
}
|
|
46
|
+
| {
|
|
47
|
+
ok: false;
|
|
48
|
+
status: number;
|
|
49
|
+
reason: "reset_failed" | "login_failed";
|
|
50
|
+
message: string;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const normalizeEmail = (email: string): string => email.trim().toLowerCase();
|
|
54
|
+
const cooldownKey = (email: string) => `password-reset-cooldown:${email}`;
|
|
55
|
+
|
|
56
|
+
const isInCooldown = async (email: string): Promise<boolean> => {
|
|
57
|
+
if (await redis.get(cooldownKey(email))) return true;
|
|
58
|
+
await redis.set(cooldownKey(email), "1", "EX", REQUEST_COOLDOWN_SECONDS);
|
|
59
|
+
return false;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const buildTarget = (row: { id: string; uid: string; mail: string }): ResetTarget => ({
|
|
63
|
+
userId: row.id,
|
|
64
|
+
uid: row.uid,
|
|
65
|
+
email: row.mail,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const resolveResetTarget = async (email: string): Promise<ResetTarget | null> => {
|
|
69
|
+
const rows = await sql<{ id: string; uid: string; mail: string }[]>`
|
|
70
|
+
SELECT id, uid, btrim(mail) AS mail
|
|
71
|
+
FROM auth.users
|
|
72
|
+
WHERE provider = 'ipa'
|
|
73
|
+
AND profile = 'user'
|
|
74
|
+
AND lower(btrim(mail)) = ${email}
|
|
75
|
+
AND (account_expires IS NULL OR account_expires > now())
|
|
76
|
+
`;
|
|
77
|
+
|
|
78
|
+
if (rows.length !== 1) {
|
|
79
|
+
if (rows.length > 1) {
|
|
80
|
+
log.warn("Password reset skipped: ambiguous IPA email", {
|
|
81
|
+
email,
|
|
82
|
+
matches: rows.length,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return buildTarget(rows[0]!);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const resolveResetTargetForToken = async (params: {
|
|
92
|
+
userId: string;
|
|
93
|
+
email: string;
|
|
94
|
+
}): Promise<ResetTarget | null> => {
|
|
95
|
+
const rows = await sql<{ id: string; uid: string; mail: string }[]>`
|
|
96
|
+
SELECT id, uid, btrim(mail) AS mail
|
|
97
|
+
FROM auth.users
|
|
98
|
+
WHERE id = ${params.userId}
|
|
99
|
+
AND provider = 'ipa'
|
|
100
|
+
AND profile = 'user'
|
|
101
|
+
AND lower(btrim(mail)) = ${params.email}
|
|
102
|
+
AND (account_expires IS NULL OR account_expires > now())
|
|
103
|
+
LIMIT 1
|
|
104
|
+
`;
|
|
105
|
+
|
|
106
|
+
return rows.length === 1 ? buildTarget(rows[0]!) : null;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const sendResetEmail = async (
|
|
110
|
+
params: ResetTarget & { redirectTo?: string }
|
|
111
|
+
): Promise<void> => {
|
|
112
|
+
const token = await providers.local.auth.createPasswordResetToken({
|
|
113
|
+
userId: params.userId,
|
|
114
|
+
uid: params.uid,
|
|
115
|
+
email: params.email,
|
|
116
|
+
ttlSeconds: REQUEST_TTL_SECONDS,
|
|
117
|
+
});
|
|
118
|
+
const rawAppUrl = await settings.get<string>("app.url");
|
|
119
|
+
const appUrl = rawAppUrl.startsWith("http")
|
|
120
|
+
? rawAppUrl
|
|
121
|
+
: `https://${rawAppUrl}`;
|
|
122
|
+
const resetLink = createAuthPasswordResetUrl(appUrl, {
|
|
123
|
+
token,
|
|
124
|
+
redirectTo: params.redirectTo,
|
|
125
|
+
});
|
|
126
|
+
const [appName, contactEmail, template] = await Promise.all([
|
|
127
|
+
settings.get<string>("app.name"),
|
|
128
|
+
settings.get<string>("app.contact_email"),
|
|
129
|
+
settings.get<string>("mail.password_reset"),
|
|
130
|
+
]);
|
|
131
|
+
|
|
132
|
+
await notifications.send({
|
|
133
|
+
type: "email",
|
|
134
|
+
recipient: params.email,
|
|
135
|
+
subject: `${appName} Password Reset`,
|
|
136
|
+
rawHtml: renderTemplate(template, {
|
|
137
|
+
RESET_LINK: resetLink,
|
|
138
|
+
APP_NAME: appName,
|
|
139
|
+
CONTACT_EMAIL: contactEmail?.trim() ?? "",
|
|
140
|
+
}),
|
|
141
|
+
});
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const changeTemporaryPassword = async (params: {
|
|
145
|
+
userId: string;
|
|
146
|
+
uid: string;
|
|
147
|
+
email: string;
|
|
148
|
+
temporaryPassword: string;
|
|
149
|
+
newPassword: string;
|
|
150
|
+
}): Promise<ResetAttemptSuccess | ResetAttemptFailure> => {
|
|
151
|
+
const changeResult = await ipaFlow.changeExpiredPassword({
|
|
152
|
+
username: params.uid,
|
|
153
|
+
currentPassword: params.temporaryPassword,
|
|
154
|
+
newPassword: params.newPassword,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
if (!changeResult.ok) {
|
|
158
|
+
if (changeResult.reason === "change_failed") {
|
|
159
|
+
return {
|
|
160
|
+
ok: false,
|
|
161
|
+
status: 400,
|
|
162
|
+
reason: "policy_failed",
|
|
163
|
+
message: `${changeResult.message} Request a new reset link and choose a stronger password.`,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
ok: false,
|
|
169
|
+
status: changeResult.status,
|
|
170
|
+
reason: "login_failed",
|
|
171
|
+
message: changeResult.message,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
await session.revokeAllForUser(changeResult.userId);
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
ok: true,
|
|
179
|
+
userId: changeResult.userId,
|
|
180
|
+
user: changeResult.user,
|
|
181
|
+
};
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
export const request = async (params: {
|
|
185
|
+
email: string;
|
|
186
|
+
redirectTo?: string;
|
|
187
|
+
}): Promise<{ ok: true; message: string }> => {
|
|
188
|
+
const email = normalizeEmail(params.email);
|
|
189
|
+
if (await isInCooldown(email)) {
|
|
190
|
+
log.info("Password reset request ignored during cooldown");
|
|
191
|
+
return { ok: true, message: GENERIC_MESSAGE };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const freeIpaConfig = await getFreeIpaConfig();
|
|
195
|
+
if (!freeIpaConfig.enabled || !freeIpaConfig.configured) {
|
|
196
|
+
log.info("Password reset request accepted while FreeIPA is unavailable");
|
|
197
|
+
return { ok: true, message: GENERIC_MESSAGE };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const target = await resolveResetTarget(email);
|
|
201
|
+
if (!target) {
|
|
202
|
+
log.info("Password reset request accepted without eligible target");
|
|
203
|
+
return { ok: true, message: GENERIC_MESSAGE };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
await sendResetEmail({ ...target, redirectTo: params.redirectTo });
|
|
207
|
+
log.info("Password reset email sent", { uid: target.uid });
|
|
208
|
+
return { ok: true, message: GENERIC_MESSAGE };
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
export const complete = async (params: {
|
|
212
|
+
token?: string;
|
|
213
|
+
newPassword: string;
|
|
214
|
+
}): Promise<ResetAttemptSuccess | ResetAttemptFailure> => {
|
|
215
|
+
if (!params.token) {
|
|
216
|
+
return {
|
|
217
|
+
ok: false,
|
|
218
|
+
status: 400,
|
|
219
|
+
reason: "invalid_or_expired",
|
|
220
|
+
message: "Missing password reset token.",
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const payload = await providers.local.auth.consumePasswordResetToken(
|
|
225
|
+
params.token
|
|
226
|
+
);
|
|
227
|
+
if (!payload) {
|
|
228
|
+
return {
|
|
229
|
+
ok: false,
|
|
230
|
+
status: 401,
|
|
231
|
+
reason: "invalid_or_expired",
|
|
232
|
+
message:
|
|
233
|
+
"This password reset link has expired. Request a new reset link.",
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const target = await resolveResetTargetForToken({
|
|
238
|
+
userId: payload.userId,
|
|
239
|
+
email: payload.email,
|
|
240
|
+
});
|
|
241
|
+
if (!target) {
|
|
242
|
+
return {
|
|
243
|
+
ok: false,
|
|
244
|
+
status: 401,
|
|
245
|
+
reason: "invalid_or_expired",
|
|
246
|
+
message:
|
|
247
|
+
"This password reset link has expired. Request a new reset link.",
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const serviceSession = await getServiceIpaSession();
|
|
252
|
+
if (!serviceSession.ok) {
|
|
253
|
+
return {
|
|
254
|
+
ok: false,
|
|
255
|
+
status: serviceSession.status,
|
|
256
|
+
reason: "reset_failed",
|
|
257
|
+
message: serviceSession.error,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const resetResult = await providers.ipa.users.resetPassword({
|
|
262
|
+
ipaSession: serviceSession.data,
|
|
263
|
+
id: target.userId,
|
|
264
|
+
});
|
|
265
|
+
if (!resetResult.ok) {
|
|
266
|
+
return {
|
|
267
|
+
ok: false,
|
|
268
|
+
status: resetResult.status,
|
|
269
|
+
reason: "reset_failed",
|
|
270
|
+
message: resetResult.error,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return changeTemporaryPassword({
|
|
275
|
+
...target,
|
|
276
|
+
temporaryPassword: resetResult.data.password,
|
|
277
|
+
newPassword: params.newPassword,
|
|
278
|
+
});
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
export const passwordReset = {
|
|
282
|
+
request,
|
|
283
|
+
complete,
|
|
284
|
+
} as const;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import * as proxyReturn from "./proxy-return";
|
|
3
|
+
|
|
4
|
+
describe("proxy auth return tokens", () => {
|
|
5
|
+
test("creates and consumes one-time return tokens", async () => {
|
|
6
|
+
const token = await proxyReturn.create({
|
|
7
|
+
clientId: "proxy-client",
|
|
8
|
+
url: "https://protected.example/path?query=1",
|
|
9
|
+
ttlSeconds: 30,
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
expect(token).toBeTruthy();
|
|
13
|
+
const consumed = await proxyReturn.consume({ token: token! });
|
|
14
|
+
expect(consumed).toEqual({
|
|
15
|
+
clientId: "proxy-client",
|
|
16
|
+
url: "https://protected.example/path?query=1",
|
|
17
|
+
});
|
|
18
|
+
expect(await proxyReturn.consume({ token: token! })).toBeNull();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("rejects non-http return URLs", async () => {
|
|
22
|
+
expect(await proxyReturn.create({ clientId: "proxy-client", url: "javascript:alert(1)" })).toBeNull();
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { redis } from "bun";
|
|
2
|
+
|
|
3
|
+
const KEY_PREFIX = "auth:proxy-return:";
|
|
4
|
+
const DEFAULT_TTL_SECONDS = 300;
|
|
5
|
+
|
|
6
|
+
type ProxyReturnPayload = {
|
|
7
|
+
clientId: string;
|
|
8
|
+
url: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const key = (token: string) => `${KEY_PREFIX}${token}`;
|
|
12
|
+
|
|
13
|
+
const normalizeReturnUrl = (value: string): string | null => {
|
|
14
|
+
try {
|
|
15
|
+
const url = new URL(value);
|
|
16
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") return null;
|
|
17
|
+
return url.toString();
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const create = async (params: { clientId: string; url: string; ttlSeconds?: number }): Promise<string | null> => {
|
|
24
|
+
const url = normalizeReturnUrl(params.url);
|
|
25
|
+
if (!url) return null;
|
|
26
|
+
|
|
27
|
+
const token = crypto.randomUUID();
|
|
28
|
+
const payload: ProxyReturnPayload = {
|
|
29
|
+
clientId: params.clientId,
|
|
30
|
+
url,
|
|
31
|
+
};
|
|
32
|
+
await redis.set(key(token), JSON.stringify(payload), "EX", params.ttlSeconds ?? DEFAULT_TTL_SECONDS);
|
|
33
|
+
return token;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const consume = async (params: { token: string }): Promise<ProxyReturnPayload | null> => {
|
|
37
|
+
const raw = await redis.getdel(key(params.token));
|
|
38
|
+
if (!raw) return null;
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const payload = JSON.parse(raw) as Partial<ProxyReturnPayload>;
|
|
42
|
+
if (typeof payload.clientId !== "string" || typeof payload.url !== "string") return null;
|
|
43
|
+
const url = normalizeReturnUrl(payload.url);
|
|
44
|
+
if (!url) return null;
|
|
45
|
+
return { clientId: payload.clientId, url };
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { ephemeral, topic } from "@valentinkolb/sync";
|
|
2
|
+
import { logger } from "./logging";
|
|
3
|
+
|
|
4
|
+
const SNAPSHOT_TTL_MS = 30_000;
|
|
5
|
+
const TOPIC_PREFIX = "cloud:gateway:telemetry";
|
|
6
|
+
const TOPIC_ID = "events";
|
|
7
|
+
const TOPIC_RETENTION_MS = 24 * 60 * 60 * 1000;
|
|
8
|
+
const TOPIC_TENANT = "default";
|
|
9
|
+
const DROP_LOG_INTERVAL_MS = 30_000;
|
|
10
|
+
|
|
11
|
+
const log = logger("gateway:telemetry");
|
|
12
|
+
|
|
13
|
+
export type GatewayRouteWarning = {
|
|
14
|
+
appId: string;
|
|
15
|
+
prefix: string;
|
|
16
|
+
reason: string;
|
|
17
|
+
detail?: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type GatewayRouteSnapshotInput = {
|
|
21
|
+
instanceId: string;
|
|
22
|
+
baseUrl: string;
|
|
23
|
+
startedAt: number;
|
|
24
|
+
routeHash: string;
|
|
25
|
+
routeWarnings: GatewayRouteWarning[];
|
|
26
|
+
table: {
|
|
27
|
+
version: number;
|
|
28
|
+
builtAt: number;
|
|
29
|
+
routeCount: number;
|
|
30
|
+
routes: Array<{ prefix: string; appId: string }>;
|
|
31
|
+
};
|
|
32
|
+
stats: {
|
|
33
|
+
totalRequests: number;
|
|
34
|
+
noRouteCount: number;
|
|
35
|
+
byApp: Map<string, { count: number; totalMs: number; errors: number }>;
|
|
36
|
+
byRoute: Map<string, { count: number; errors: number; lastSeen: number }>;
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type GatewayRouteSnapshot = {
|
|
41
|
+
instanceId: string;
|
|
42
|
+
baseUrl: string;
|
|
43
|
+
startedAt: number;
|
|
44
|
+
updatedAt: number;
|
|
45
|
+
tableVersion: number;
|
|
46
|
+
tableBuiltAt: number;
|
|
47
|
+
routeCount: number;
|
|
48
|
+
routeHash: string;
|
|
49
|
+
routeWarnings: GatewayRouteWarning[];
|
|
50
|
+
routes: Array<{ prefix: string; appId: string }>;
|
|
51
|
+
stats: {
|
|
52
|
+
totalRequests: number;
|
|
53
|
+
noRouteCount: number;
|
|
54
|
+
byApp: Array<{ appId: string; count: number; totalMs: number; errors: number }>;
|
|
55
|
+
byRoute: Array<{ prefix: string; count: number; errors: number; lastSeen: number }>;
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export type GatewayTelemetryEvent = {
|
|
60
|
+
v: 1;
|
|
61
|
+
kind: "request";
|
|
62
|
+
appId: string;
|
|
63
|
+
routePrefix: string;
|
|
64
|
+
method: string;
|
|
65
|
+
status: number;
|
|
66
|
+
durationMs: number;
|
|
67
|
+
errorKind: "upstream_unavailable" | "unmatched_route" | null;
|
|
68
|
+
occurredAt: string;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const snapshots = ephemeral<GatewayRouteSnapshot>({
|
|
72
|
+
id: "gateway-route-snapshots",
|
|
73
|
+
ttlMs: SNAPSHOT_TTL_MS,
|
|
74
|
+
limits: { maxPayloadBytes: 128_000 },
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
export const buildGatewayRouteSnapshot = (input: GatewayRouteSnapshotInput): GatewayRouteSnapshot => ({
|
|
78
|
+
instanceId: input.instanceId,
|
|
79
|
+
baseUrl: input.baseUrl,
|
|
80
|
+
startedAt: input.startedAt,
|
|
81
|
+
updatedAt: Date.now(),
|
|
82
|
+
tableVersion: input.table.version,
|
|
83
|
+
tableBuiltAt: input.table.builtAt,
|
|
84
|
+
routeCount: input.table.routeCount,
|
|
85
|
+
routeHash: input.routeHash,
|
|
86
|
+
routeWarnings: input.routeWarnings,
|
|
87
|
+
routes: input.table.routes,
|
|
88
|
+
stats: {
|
|
89
|
+
totalRequests: input.stats.totalRequests,
|
|
90
|
+
noRouteCount: input.stats.noRouteCount,
|
|
91
|
+
byApp: [...input.stats.byApp.entries()].map(([appId, value]) => ({ appId, ...value })),
|
|
92
|
+
byRoute: [...input.stats.byRoute.entries()].map(([prefix, value]) => ({ prefix, ...value })),
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
export const publishGatewayRouteSnapshot = async (snapshot: GatewayRouteSnapshot): Promise<void> => {
|
|
97
|
+
await snapshots.upsert({ key: `instances/${snapshot.instanceId}`, value: snapshot });
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export const removeGatewayRouteSnapshot = async (instanceId: string): Promise<void> => {
|
|
101
|
+
await snapshots.remove({ key: `instances/${instanceId}` });
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export const listGatewayRouteSnapshots = async (): Promise<GatewayRouteSnapshot[]> => {
|
|
105
|
+
const snap = await snapshots.snapshot({ prefix: "instances/" });
|
|
106
|
+
return snap.entries.map((entry) => entry.value).sort((a, b) => a.instanceId.localeCompare(b.instanceId));
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
export const latestGatewayRouteSnapshot = async (): Promise<GatewayRouteSnapshot | null> => {
|
|
110
|
+
const all = await listGatewayRouteSnapshots();
|
|
111
|
+
return all.sort((a, b) => b.updatedAt - a.updatedAt)[0] ?? null;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export const gatewayTelemetryTopic = topic<GatewayTelemetryEvent>({
|
|
115
|
+
id: TOPIC_ID,
|
|
116
|
+
prefix: TOPIC_PREFIX,
|
|
117
|
+
retentionMs: TOPIC_RETENTION_MS,
|
|
118
|
+
limits: { payloadBytes: 8_000 },
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
export const GATEWAY_TELEMETRY_TENANT = TOPIC_TENANT;
|
|
122
|
+
|
|
123
|
+
const normalizeMethod = (method: string): string => method.toUpperCase().slice(0, 16);
|
|
124
|
+
const normalizeStatus = (status: number): number => (Number.isFinite(status) ? Math.max(0, Math.min(999, Math.round(status))) : 0);
|
|
125
|
+
const normalizeDuration = (durationMs: number): number => (Number.isFinite(durationMs) ? Math.max(0, Math.round(durationMs)) : 0);
|
|
126
|
+
const normalizeText = (value: string, fallback: string): string => {
|
|
127
|
+
const trimmed = value.trim();
|
|
128
|
+
return (trimmed.length ? trimmed : fallback).slice(0, 200);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
export const publishRequestTelemetry = (event: Omit<GatewayTelemetryEvent, "v" | "kind" | "occurredAt">): void => {
|
|
132
|
+
const payload: GatewayTelemetryEvent = {
|
|
133
|
+
v: 1,
|
|
134
|
+
kind: "request",
|
|
135
|
+
appId: normalizeText(event.appId, "unknown"),
|
|
136
|
+
routePrefix: normalizeText(event.routePrefix, "(unknown)"),
|
|
137
|
+
method: normalizeMethod(event.method),
|
|
138
|
+
status: normalizeStatus(event.status),
|
|
139
|
+
durationMs: normalizeDuration(event.durationMs),
|
|
140
|
+
errorKind: event.errorKind,
|
|
141
|
+
occurredAt: new Date().toISOString(),
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
void gatewayTelemetryTopic
|
|
145
|
+
.pub({
|
|
146
|
+
tenantId: GATEWAY_TELEMETRY_TENANT,
|
|
147
|
+
orderingKey: payload.appId,
|
|
148
|
+
data: payload,
|
|
149
|
+
})
|
|
150
|
+
.catch((error) => {
|
|
151
|
+
const now = Date.now();
|
|
152
|
+
if (now - lastPublishErrorAt < DROP_LOG_INTERVAL_MS) return;
|
|
153
|
+
lastPublishErrorAt = now;
|
|
154
|
+
log.warn("Dropped gateway telemetry event", {
|
|
155
|
+
appId: payload.appId,
|
|
156
|
+
routePrefix: payload.routePrefix,
|
|
157
|
+
error: error instanceof Error ? error.message : String(error),
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
let lastPublishErrorAt = 0;
|