@valentinkolb/cloud 0.5.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@valentinkolb/cloud",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Modular Hono+SolidJS framework for building per-app docker services behind a dynamic gateway. Powers cloud.stuve-ulm.de.",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/api/auth.ts CHANGED
@@ -49,8 +49,8 @@ const app = new Hono<AuthContext>()
49
49
 
50
50
  const loginResult = await authFlows.ipa.login({ username, password });
51
51
  if (!loginResult.ok && loginResult.reason === "password_expired") {
52
- log.info("Login failed", { uid: username, reason: "password_expired" });
53
- return c.json({ message: "Password expired", passwordExpired: true }, 401);
52
+ log.info("Login failed", { uid: loginResult.uid, reason: "password_expired" });
53
+ return c.json({ message: "Password expired", passwordExpired: true, ipaUid: loginResult.uid }, 401);
54
54
  }
55
55
  if (!loginResult.ok) {
56
56
  log.info("Login failed", {
@@ -63,7 +63,7 @@ const app = new Hono<AuthContext>()
63
63
  // Store minimal session in Redis
64
64
  const sessionToken = await auth.session.create(c, loginResult.userId);
65
65
 
66
- log.info("Login successful", { uid: username });
66
+ log.info("Login successful", { uid: loginResult.user.uid });
67
67
  return c.json({
68
68
  session_token: sessionToken,
69
69
  user: loginResult.user,
@@ -1,10 +1,11 @@
1
1
  import { sql } from "bun";
2
2
  import { accounts } from "../accounts";
3
+ import { logger } from "../logging";
3
4
  import { providers } from "../providers";
4
5
  import type { User } from "../../contracts/shared";
5
6
 
6
7
  type IpaLoginFailure =
7
- | { ok: false; status: 401; reason: "password_expired"; message: string }
8
+ | { ok: false; status: 401; reason: "password_expired"; message: string; uid: string }
8
9
  | { ok: false; status: 401; reason: "invalid_credentials"; message: string }
9
10
  | { ok: false; status: 400; reason: "user_not_synced"; message: string }
10
11
  | { ok: false; status: 400; reason: "user_not_found"; message: string }
@@ -20,6 +21,42 @@ type IpaLoginSuccess = {
20
21
 
21
22
  export type IpaLoginFlowResult = IpaLoginSuccess | IpaLoginFailure;
22
23
 
24
+ const log = logger("auth:ipa");
25
+ const DUMMY_LOGIN_UID = "__cloud_invalid_ipa_email_login__";
26
+
27
+ const normalizeEmail = (value: string): string => value.trim().toLowerCase();
28
+
29
+ const resolveIpaLoginUid = async (identifier: string): Promise<string | null> => {
30
+ const trimmed = identifier.trim();
31
+ if (!trimmed) return null;
32
+ if (!trimmed.includes("@")) return trimmed;
33
+
34
+ const rows = await sql<{ uid: string }[]>`
35
+ SELECT uid
36
+ FROM auth.users
37
+ WHERE provider = 'ipa'
38
+ AND lower(btrim(mail)) = ${normalizeEmail(trimmed)}
39
+ `;
40
+
41
+ if (rows.length !== 1) {
42
+ if (rows.length > 1) {
43
+ log.warn("FreeIPA email login skipped: ambiguous email", {
44
+ email: normalizeEmail(trimmed),
45
+ matches: rows.length,
46
+ });
47
+ }
48
+ return null;
49
+ }
50
+ return rows[0]!.uid;
51
+ };
52
+
53
+ const failInvalidCredentials = async (params: { identifier: string; password: string }): Promise<IpaLoginFailure> => {
54
+ if (params.identifier.trim().includes("@")) {
55
+ await providers.ipa.auth.login(DUMMY_LOGIN_UID, params.password).catch(() => undefined);
56
+ }
57
+ return { ok: false, status: 401, reason: "invalid_credentials", message: "Invalid username or password" };
58
+ };
59
+
23
60
  const loadSyncedIpaUser = async (uid: string): Promise<{ ok: true; userId: string; user: User } | IpaLoginFailure> => {
24
61
  const userRows = await sql`
25
62
  SELECT id FROM auth.users
@@ -50,9 +87,14 @@ const loadSyncedIpaUser = async (uid: string): Promise<{ ok: true; userId: strin
50
87
  };
51
88
 
52
89
  export const login = async (params: { username: string; password: string }): Promise<IpaLoginFlowResult> => {
53
- const loginResult = await providers.ipa.auth.login(params.username, params.password);
90
+ const uid = await resolveIpaLoginUid(params.username);
91
+ if (!uid) {
92
+ return failInvalidCredentials({ identifier: params.username, password: params.password });
93
+ }
94
+
95
+ const loginResult = await providers.ipa.auth.login(uid, params.password);
54
96
  if (loginResult.status === "password_expired") {
55
- return { ok: false, status: 401, reason: "password_expired", message: "Password expired" };
97
+ return { ok: false, status: 401, reason: "password_expired", message: "Password expired", uid };
56
98
  }
57
99
  if (loginResult.status !== "success") {
58
100
  return { ok: false, status: 401, reason: "invalid_credentials", message: "Invalid username or password" };
@@ -61,7 +103,7 @@ export const login = async (params: { username: string; password: string }): Pro
61
103
  // Must reach a "synced" outcome before granting a session. Stale mirror rows
62
104
  // (expired remotely, dropped from sync scope, or fetch failures) must never
63
105
  // grant a fresh local session on the back of successful FreeIPA credentials.
64
- const syncOutcome = await providers.ipa.sync.user(params.username);
106
+ const syncOutcome = await providers.ipa.sync.user(uid);
65
107
  switch (syncOutcome.status) {
66
108
  case "synced":
67
109
  break;
@@ -97,7 +139,7 @@ export const login = async (params: { username: string; password: string }): Pro
97
139
  };
98
140
  }
99
141
 
100
- const userResult = await loadSyncedIpaUser(params.username);
142
+ const userResult = await loadSyncedIpaUser(uid);
101
143
  if (!userResult.ok) return userResult;
102
144
 
103
145
  return {
@@ -1,4 +1,4 @@
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";
@@ -6,33 +6,95 @@ import * as settings from "../settings";
6
6
  import { renderTemplate } from "../settings/templates";
7
7
  import type { User } from "../../contracts/shared";
8
8
  import { createAuthLoginUrl } from "../../shared/redirect";
9
+ import { logger } from "../logging";
10
+
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
+ };
9
69
 
10
70
  export const request = async (params: { email: string; redirectTo?: string }): Promise<
11
71
  | { ok: true }
12
72
  | { ok: false; status: 400; message: string }
13
73
  > => {
14
- const userRows = await sql`SELECT uid, provider FROM auth.users WHERE mail = ${params.email}`;
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}`;
15
77
  const hasLocalUser = userRows.some((row: { provider: string | null }) => row.provider === "local");
16
- const hasIpaUser = userRows.some((row: { provider: string | null }) => row.provider === "ipa");
17
78
  const allowSelfRegistration = await settings.get<boolean>("user.allow_self_registration");
18
79
 
19
- if (!hasLocalUser && !allowSelfRegistration) {
20
- return {
21
- ok: false,
22
- status: 400,
23
- message: "Only existing local accounts can sign in with email. Contact an administrator if you need access.",
24
- };
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 };
25
90
  }
26
91
 
27
- if (!hasLocalUser && hasIpaUser) {
28
- // Return ok without sending email to prevent account enumeration.
29
- // IPA-only users must authenticate via Kerberos, not magic-link.
92
+ if (!hasLocalUser && !allowSelfRegistration) {
30
93
  return { ok: true };
31
94
  }
32
95
 
33
- const token = await providers.local.auth.createMagicLinkToken({ email: params.email, ttlSeconds: 300 });
34
- const rawAppUrl = await settings.get<string>("app.url");
35
- const appUrl = rawAppUrl.startsWith("http") ? rawAppUrl : `https://${rawAppUrl}`;
96
+ const token = await providers.local.auth.createMagicLinkToken({ email, ttlSeconds: 300 });
97
+ const appUrl = await getAppUrl();
36
98
  const magicLink = createAuthLoginUrl(appUrl, { token, redirectTo: params.redirectTo });
37
99
 
38
100
  const appName = await settings.get<string>("app.name");
@@ -40,7 +102,7 @@ export const request = async (params: { email: string; redirectTo?: string }): P
40
102
 
41
103
  await notifications.send({
42
104
  type: "email",
43
- recipient: params.email,
105
+ recipient: email,
44
106
  subject: `${appName} Login Code`,
45
107
  rawHtml: renderTemplate(template, {
46
108
  TOKEN: token,
@@ -63,13 +125,22 @@ export const verify = async (params: { token: string }): Promise<
63
125
  }
64
126
 
65
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
+
66
137
  // Reject expired accounts at login time, not just during cleanup. Without
67
138
  // this, an expired local user / guest could still authenticate in the
68
139
  // window between expiry and the next lifecycle run.
69
140
  const userRows = await sql`
70
141
  SELECT id, account_expires
71
142
  FROM auth.users
72
- WHERE mail = ${email} AND provider = 'local'
143
+ WHERE lower(btrim(mail)) = ${normalizedEmail} AND provider = 'local'
73
144
  AND (account_expires IS NULL OR account_expires > now())
74
145
  ORDER BY profile = 'user' DESC
75
146
  LIMIT 1
@@ -84,7 +155,7 @@ export const verify = async (params: { token: string }): Promise<
84
155
  const expiredRows = await sql`
85
156
  SELECT id
86
157
  FROM auth.users
87
- WHERE mail = ${email} AND provider = 'local'
158
+ WHERE lower(btrim(mail)) = ${normalizedEmail} AND provider = 'local'
88
159
  AND account_expires IS NOT NULL AND account_expires <= now()
89
160
  LIMIT 1
90
161
  `;
@@ -1,13 +1,14 @@
1
1
  import { sql } from "bun";
2
- import { sendEmail } from "./email";
3
2
  import type { PaginationParams } from "../../contracts/shared";
4
- import { escapeLikePattern } from "../postgres";
5
3
  import { logger } from "../logging";
4
+ import { escapeLikePattern } from "../postgres";
5
+ import { sendEmail } from "./email";
6
6
 
7
7
  const log = logger("notifications");
8
8
 
9
9
  export type NotificationType = "email";
10
10
  export type NotificationStatus = "sent" | "pending" | "error";
11
+ export type NotificationStatusSummary = Record<NotificationStatus, number>;
11
12
 
12
13
  /**
13
14
  * Computes notification delivery status from sent/error timestamps.
@@ -56,6 +57,12 @@ export type NotificationMessage = {
56
57
  status: NotificationStatus;
57
58
  };
58
59
 
60
+ const emptyStatusSummary = (): NotificationStatusSummary => ({
61
+ sent: 0,
62
+ pending: 0,
63
+ error: 0,
64
+ });
65
+
59
66
  type DbNotificationRow = {
60
67
  id: string;
61
68
  type: NotificationType;
@@ -143,23 +150,26 @@ export const sendToUser = async (params: SendToUserParams): Promise<{ ok: true;
143
150
  */
144
151
  export const list = async (
145
152
  pagination: PaginationParams,
146
- options?: { sentBy?: string; isAdmin?: boolean; search?: string },
153
+ options?: { sentBy?: string; isAdmin?: boolean; search?: string; status?: NotificationStatus },
147
154
  ): Promise<{ notifications: NotificationMessage[]; total: number }> => {
148
155
  const { offset, perPage } = pagination;
149
- const { sentBy, isAdmin, search } = options ?? {};
156
+ const { sentBy, isAdmin, search, status } = options ?? {};
150
157
 
151
158
  // Build query based on permissions
152
159
  let countRows: Array<{ count: number | string }> = [];
153
160
  let dataRows: DbNotificationRow[] = [];
154
161
 
155
162
  const searchPattern = search ? `%${escapeLikePattern(search)}%` : null;
163
+ const statusFilter = status ?? null;
156
164
 
157
165
  if (isAdmin) {
158
166
  // Admins see all notifications
159
167
  if (searchPattern) {
160
168
  countRows = await sql`
161
169
  SELECT COUNT(*)::int as count FROM notifications.messages
162
- WHERE subject ILIKE ${searchPattern} ESCAPE '\' OR content ILIKE ${searchPattern} ESCAPE '\' OR recipient ILIKE ${searchPattern} ESCAPE '\'
170
+ WHERE
171
+ (${statusFilter}::text IS NULL OR CASE WHEN sent_at IS NOT NULL THEN 'sent' WHEN error IS NOT NULL THEN 'error' ELSE 'pending' END = ${statusFilter})
172
+ AND (subject ILIKE ${searchPattern} ESCAPE '\' OR content ILIKE ${searchPattern} ESCAPE '\' OR recipient ILIKE ${searchPattern} ESCAPE '\')
163
173
  `;
164
174
  dataRows = await sql`
165
175
  SELECT
@@ -168,12 +178,17 @@ export const list = async (
168
178
  u.display_name as sent_by_name
169
179
  FROM notifications.messages m
170
180
  LEFT JOIN auth.users u ON m.sent_by = u.id
171
- WHERE m.subject ILIKE ${searchPattern} ESCAPE '\' OR m.content ILIKE ${searchPattern} ESCAPE '\' OR m.recipient ILIKE ${searchPattern} ESCAPE '\'
181
+ WHERE
182
+ (${statusFilter}::text IS NULL OR CASE WHEN m.sent_at IS NOT NULL THEN 'sent' WHEN m.error IS NOT NULL THEN 'error' ELSE 'pending' END = ${statusFilter})
183
+ AND (m.subject ILIKE ${searchPattern} ESCAPE '\' OR m.content ILIKE ${searchPattern} ESCAPE '\' OR m.recipient ILIKE ${searchPattern} ESCAPE '\')
172
184
  ORDER BY m.created_at DESC
173
185
  LIMIT ${perPage} OFFSET ${offset}
174
186
  `;
175
187
  } else {
176
- countRows = await sql`SELECT COUNT(*)::int as count FROM notifications.messages`;
188
+ countRows = await sql`
189
+ SELECT COUNT(*)::int as count FROM notifications.messages
190
+ WHERE ${statusFilter}::text IS NULL OR CASE WHEN sent_at IS NOT NULL THEN 'sent' WHEN error IS NOT NULL THEN 'error' ELSE 'pending' END = ${statusFilter}
191
+ `;
177
192
  dataRows = await sql`
178
193
  SELECT
179
194
  m.id, m.type, m.recipient, m.subject, m.content,
@@ -181,6 +196,7 @@ export const list = async (
181
196
  u.display_name as sent_by_name
182
197
  FROM notifications.messages m
183
198
  LEFT JOIN auth.users u ON m.sent_by = u.id
199
+ WHERE ${statusFilter}::text IS NULL OR CASE WHEN m.sent_at IS NOT NULL THEN 'sent' WHEN m.error IS NOT NULL THEN 'error' ELSE 'pending' END = ${statusFilter}
184
200
  ORDER BY m.created_at DESC
185
201
  LIMIT ${perPage} OFFSET ${offset}
186
202
  `;
@@ -190,7 +206,10 @@ export const list = async (
190
206
  if (searchPattern) {
191
207
  countRows = await sql`
192
208
  SELECT COUNT(*)::int as count FROM notifications.messages
193
- WHERE sent_by = ${sentBy} AND (subject ILIKE ${searchPattern} ESCAPE '\' OR content ILIKE ${searchPattern} ESCAPE '\' OR recipient ILIKE ${searchPattern} ESCAPE '\')
209
+ WHERE
210
+ sent_by = ${sentBy}
211
+ AND (${statusFilter}::text IS NULL OR CASE WHEN sent_at IS NOT NULL THEN 'sent' WHEN error IS NOT NULL THEN 'error' ELSE 'pending' END = ${statusFilter})
212
+ AND (subject ILIKE ${searchPattern} ESCAPE '\' OR content ILIKE ${searchPattern} ESCAPE '\' OR recipient ILIKE ${searchPattern} ESCAPE '\')
194
213
  `;
195
214
  dataRows = await sql`
196
215
  SELECT
@@ -199,12 +218,20 @@ export const list = async (
199
218
  u.display_name as sent_by_name
200
219
  FROM notifications.messages m
201
220
  LEFT JOIN auth.users u ON m.sent_by = u.id
202
- WHERE m.sent_by = ${sentBy} AND (m.subject ILIKE ${searchPattern} ESCAPE '\' OR m.content ILIKE ${searchPattern} ESCAPE '\' OR m.recipient ILIKE ${searchPattern} ESCAPE '\')
221
+ WHERE
222
+ m.sent_by = ${sentBy}
223
+ AND (${statusFilter}::text IS NULL OR CASE WHEN m.sent_at IS NOT NULL THEN 'sent' WHEN m.error IS NOT NULL THEN 'error' ELSE 'pending' END = ${statusFilter})
224
+ AND (m.subject ILIKE ${searchPattern} ESCAPE '\' OR m.content ILIKE ${searchPattern} ESCAPE '\' OR m.recipient ILIKE ${searchPattern} ESCAPE '\')
203
225
  ORDER BY m.created_at DESC
204
226
  LIMIT ${perPage} OFFSET ${offset}
205
227
  `;
206
228
  } else {
207
- countRows = await sql`SELECT COUNT(*)::int as count FROM notifications.messages WHERE sent_by = ${sentBy}`;
229
+ countRows = await sql`
230
+ SELECT COUNT(*)::int as count FROM notifications.messages
231
+ WHERE
232
+ sent_by = ${sentBy}
233
+ AND (${statusFilter}::text IS NULL OR CASE WHEN sent_at IS NOT NULL THEN 'sent' WHEN error IS NOT NULL THEN 'error' ELSE 'pending' END = ${statusFilter})
234
+ `;
208
235
  dataRows = await sql`
209
236
  SELECT
210
237
  m.id, m.type, m.recipient, m.subject, m.content,
@@ -212,7 +239,9 @@ export const list = async (
212
239
  u.display_name as sent_by_name
213
240
  FROM notifications.messages m
214
241
  LEFT JOIN auth.users u ON m.sent_by = u.id
215
- WHERE m.sent_by = ${sentBy}
242
+ WHERE
243
+ m.sent_by = ${sentBy}
244
+ AND (${statusFilter}::text IS NULL OR CASE WHEN m.sent_at IS NOT NULL THEN 'sent' WHEN m.error IS NOT NULL THEN 'error' ELSE 'pending' END = ${statusFilter})
216
245
  ORDER BY m.created_at DESC
217
246
  LIMIT ${perPage} OFFSET ${offset}
218
247
  `;
@@ -246,6 +275,47 @@ export const list = async (
246
275
  return { notifications, total };
247
276
  };
248
277
 
278
+ /**
279
+ * Count current notification statuses for recent entries.
280
+ */
281
+ export const getStatusSummary = async (options?: {
282
+ sentBy?: string;
283
+ isAdmin?: boolean;
284
+ days?: number;
285
+ }): Promise<NotificationStatusSummary> => {
286
+ const { sentBy, isAdmin, days = 7 } = options ?? {};
287
+ const windowDays = Math.max(1, Math.floor(days));
288
+ const summary = emptyStatusSummary();
289
+
290
+ let rows: Array<{ status: NotificationStatus; count: number | string }> = [];
291
+ if (isAdmin) {
292
+ rows = await sql`
293
+ SELECT
294
+ CASE WHEN sent_at IS NOT NULL THEN 'sent' WHEN error IS NOT NULL THEN 'error' ELSE 'pending' END as status,
295
+ COUNT(*)::int as count
296
+ FROM notifications.messages
297
+ WHERE created_at >= now() - (${windowDays}::int * interval '1 day')
298
+ GROUP BY status
299
+ `;
300
+ } else if (sentBy) {
301
+ rows = await sql`
302
+ SELECT
303
+ CASE WHEN sent_at IS NOT NULL THEN 'sent' WHEN error IS NOT NULL THEN 'error' ELSE 'pending' END as status,
304
+ COUNT(*)::int as count
305
+ FROM notifications.messages
306
+ WHERE sent_by = ${sentBy} AND created_at >= now() - (${windowDays}::int * interval '1 day')
307
+ GROUP BY status
308
+ `;
309
+ }
310
+
311
+ for (const row of rows) {
312
+ const count = typeof row.count === "string" ? Number.parseInt(row.count, 10) : row.count;
313
+ summary[row.status] = Number.isFinite(count) ? count : 0;
314
+ }
315
+
316
+ return summary;
317
+ };
318
+
249
319
  /**
250
320
  * Get a single notification by ID.
251
321
  */
@@ -410,4 +480,5 @@ export const notifications = {
410
480
  update,
411
481
  getPendingSystemCount,
412
482
  sendAllPendingSystem,
483
+ getStatusSummary,
413
484
  };
@@ -438,6 +438,21 @@ export const SETTINGS: SettingDef[] = [
438
438
  group: "mail",
439
439
  templateVars: ["TOKEN", "MAGIC_LINK", "APP_NAME"],
440
440
  },
441
+ {
442
+ key: "mail.ipa_email_login_hint",
443
+ label: "FreeIPA Email Login Hint Template",
444
+ kind: "template",
445
+ default: `<p>A sign-in link was requested for <code style="background:#f4f4f5;padding:2px 6px;border-radius:4px;">{{EMAIL}}</code>.</p>
446
+ <p>This email address belongs to a FreeIPA-managed account. Please sign in with your FreeIPA username and password. If your email address is unique in FreeIPA, you can also use it instead of your username.</p>
447
+ <p style="text-align:center;margin:24px 0;">
448
+ <a href="{{LOGIN_URL}}" target="_blank" style="color:#3b82f6;text-decoration:underline;">Open FreeIPA sign-in</a>
449
+ </p>
450
+ <p style="color:#71717a;font-size:12px;margin:0 0 8px 0;">No email login code was created. If you didn't request this, please ignore this email.</p>
451
+ {{#CONTACT_EMAIL}}<p style="color:#71717a;font-size:12px;margin:0;">If you need help, contact <a href="mailto:{{CONTACT_EMAIL}}">{{CONTACT_EMAIL}}</a>.</p>{{/CONTACT_EMAIL}}`,
452
+ description: "FreeIPA email-login hint template (HTML). Subject: {{APP_NAME}} FreeIPA Sign In",
453
+ group: "mail",
454
+ templateVars: ["EMAIL", "LOGIN_URL", "CONTACT_EMAIL", "APP_NAME"],
455
+ },
441
456
  {
442
457
  key: "mail.password_reset",
443
458
  label: "Password Reset Template",
@@ -33,6 +33,15 @@ describe("redirect helpers", () => {
33
33
  expect(externalUrl).toBe("https://cloud.example/auth/login?token=token-id");
34
34
  });
35
35
 
36
+ test("builds method-specific login links with safe redirects only", () => {
37
+ const url = createAuthLoginUrl("https://cloud.example", {
38
+ method: "ipa",
39
+ redirectTo: "/app/dashboard",
40
+ });
41
+
42
+ expect(url).toBe("https://cloud.example/auth/login?method=ipa&redirectTo=%2Fapp%2Fdashboard");
43
+ });
44
+
36
45
  test("builds password reset links with safe redirects only", () => {
37
46
  const safeUrl = createAuthPasswordResetUrl("https://cloud.example", {
38
47
  token: "token-id",
@@ -30,9 +30,13 @@ export const createLoginRedirectUrl = (requestUrl: string): string => {
30
30
  };
31
31
 
32
32
  /** Build an absolute auth login URL while preserving only safe local redirects. */
33
- export const createAuthLoginUrl = (appUrl: string, params: { token?: string; redirectTo?: string | null | undefined } = {}): string => {
33
+ export const createAuthLoginUrl = (
34
+ appUrl: string,
35
+ params: { token?: string; method?: "email" | "ipa" | "admin"; redirectTo?: string | null | undefined } = {},
36
+ ): string => {
34
37
  const url = new URL(`${appUrl.replace(/\/$/, "")}/auth/login`);
35
38
  if (params.token) url.searchParams.set("token", params.token);
39
+ if (params.method) url.searchParams.set("method", params.method);
36
40
 
37
41
  const redirectTo = normalizeRedirectTo(params.redirectTo);
38
42
  if (redirectTo) url.searchParams.set("redirectTo", redirectTo);