@camstack/addon-auth 1.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.
@@ -0,0 +1,240 @@
1
+ Object.defineProperties(exports, {
2
+ __esModule: { value: true },
3
+ [Symbol.toStringTag]: { value: "Module" }
4
+ });
5
+ require("../chunk-Cek0wNdY.js");
6
+ const require_dist = require("../dist-CXCR7sEk.js");
7
+ //#region src/magic-link/auth-magic-link.addon.ts
8
+ /**
9
+ * Magic-link authentication addon.
10
+ *
11
+ * Passwordless flow:
12
+ * 1. Operator types their email on the login page → clicks "Send link".
13
+ * 2. The addon's `/request` route accepts the email, looks up the user
14
+ * via `user-management.listUsers()`, mints a short-lived HMAC bridge
15
+ * token (via `sso-bridge` cap), and delivers a URL of the form
16
+ * `https://<hub>/addon/auth-magic-link/login?bridge=<token>` to the
17
+ * user's email.
18
+ * 3. Email click hits `/login?bridge=…` → 302 to `/api/auth/sso/finish?bridge=…`
19
+ * → hub verifies the bridge token and mints the real session.
20
+ *
21
+ * Delivery is pluggable. This addon ships a "log-only" mode by default
22
+ * (writes the URL to the addon log so operators can copy it during
23
+ * setup before SMTP is configured). Production deployments configure
24
+ * the SMTP relay via global settings.
25
+ *
26
+ * Open follow-ups:
27
+ * - Wire `nodemailer` for real SMTP delivery (peer dep; the framework
28
+ * declares `smtpProvider` cap if/when we ship an SMTP addon).
29
+ * - Throttle requests per-email (rate-limit window).
30
+ * - Auto-provision unknown emails (currently rejected for safety).
31
+ */
32
+ var DEFAULT_CONFIG = {
33
+ displayName: "Email magic link",
34
+ icon: "mail",
35
+ publicOrigin: "",
36
+ deliveryMode: "log-only",
37
+ defaultRole: "viewer",
38
+ ttlSec: 600
39
+ };
40
+ var AuthMagicLinkAddon = class extends require_dist.BaseAddon {
41
+ kind = "magic-link";
42
+ hasRedirectFlow = false;
43
+ hasCredentialFlow = true;
44
+ displayName;
45
+ icon;
46
+ constructor() {
47
+ super({ ...DEFAULT_CONFIG });
48
+ this.displayName = DEFAULT_CONFIG.displayName;
49
+ this.icon = DEFAULT_CONFIG.icon;
50
+ }
51
+ async onInitialize() {
52
+ Object.assign(this, {
53
+ displayName: this.config.displayName || DEFAULT_CONFIG.displayName,
54
+ icon: this.config.icon || DEFAULT_CONFIG.icon
55
+ });
56
+ const authProvider = {
57
+ validateCredentials: async () => null,
58
+ validateToken: async () => null,
59
+ getLoginUrl: async () => {
60
+ throw new Error("Magic link does not support direct getLoginUrl — POST email to /addon/auth-magic-link/request");
61
+ },
62
+ handleCallback: async () => {
63
+ throw new Error("Magic link does not use callback — the email link redirects to /api/auth/sso/finish directly");
64
+ }
65
+ };
66
+ const routes = [{
67
+ method: "POST",
68
+ path: "/request",
69
+ access: "public",
70
+ description: "Request a magic link be delivered to the given email",
71
+ handler: async (req, reply) => this.handleRequest(req, reply)
72
+ }, {
73
+ method: "GET",
74
+ path: "/login",
75
+ access: "public",
76
+ description: "Click target embedded in the magic-link email",
77
+ handler: async (req, reply) => this.handleLogin(req, reply)
78
+ }];
79
+ const routeProvider = {
80
+ id: "auth-magic-link",
81
+ getRoutes: () => routes
82
+ };
83
+ this.ctx.logger.info("Magic-link auth provider initialized", { meta: { deliveryMode: this.config.deliveryMode } });
84
+ return [{
85
+ capability: require_dist.authProviderCapability,
86
+ provider: authProvider
87
+ }, {
88
+ capability: require_dist.addonRoutesCapability,
89
+ provider: routeProvider
90
+ }];
91
+ }
92
+ globalSettingsSchema() {
93
+ return this.schema({ sections: [{
94
+ id: "magic-link",
95
+ title: "Magic Link Settings",
96
+ description: "Passwordless email login. The user types an email; the hub mails them a one-time link valid for the configured TTL.",
97
+ columns: 1,
98
+ fields: [
99
+ this.field({
100
+ type: "text",
101
+ key: "displayName",
102
+ label: "Display name",
103
+ default: DEFAULT_CONFIG.displayName
104
+ }),
105
+ this.field({
106
+ type: "text",
107
+ key: "icon",
108
+ label: "Icon",
109
+ default: DEFAULT_CONFIG.icon
110
+ }),
111
+ this.field({
112
+ type: "text",
113
+ key: "publicOrigin",
114
+ label: "Public origin",
115
+ description: "Where the email link points. Empty = read CAMSTACK_PUBLIC_ORIGIN env var.",
116
+ default: ""
117
+ }),
118
+ this.field({
119
+ type: "select",
120
+ key: "deliveryMode",
121
+ label: "Delivery mode",
122
+ description: "log-only writes the URL to the addon log (setup only). smtp uses the configured SMTP relay.",
123
+ default: DEFAULT_CONFIG.deliveryMode,
124
+ options: [{
125
+ value: "log-only",
126
+ label: "Log-only (no email sent)"
127
+ }, {
128
+ value: "smtp",
129
+ label: "SMTP (when configured)"
130
+ }]
131
+ }),
132
+ this.field({
133
+ type: "select",
134
+ key: "defaultRole",
135
+ label: "Default role",
136
+ default: DEFAULT_CONFIG.defaultRole,
137
+ options: [{
138
+ value: "viewer",
139
+ label: "Viewer"
140
+ }, {
141
+ value: "admin",
142
+ label: "Admin"
143
+ }]
144
+ }),
145
+ this.field({
146
+ type: "number",
147
+ key: "ttlSec",
148
+ label: "Link TTL (seconds)",
149
+ description: "How long the magic link stays valid. Default 600 (10 min).",
150
+ default: DEFAULT_CONFIG.ttlSec
151
+ })
152
+ ]
153
+ }] });
154
+ }
155
+ buildPublicOrigin() {
156
+ const configured = this.config.publicOrigin?.trim();
157
+ if (configured) return configured.replace(/\/+$/, "");
158
+ return (process.env["CAMSTACK_PUBLIC_ORIGIN"] || "").replace(/\/+$/, "") || "https://localhost:4443";
159
+ }
160
+ async findUserByEmail(email) {
161
+ let users;
162
+ try {
163
+ users = await this.ctx.api.userManagement.listUsers.query();
164
+ } catch (err) {
165
+ this.ctx.logger.warn("Magic-link user lookup failed (user-management cap unavailable)", { meta: { message: require_dist.errMsg(err) } });
166
+ return null;
167
+ }
168
+ const needle = email.trim().toLowerCase();
169
+ for (const u of users) if (u.username.toLowerCase() === needle) return u;
170
+ return null;
171
+ }
172
+ async handleRequest(req, reply) {
173
+ try {
174
+ const body = req.body ?? {};
175
+ const email = typeof body.email === "string" ? body.email.trim() : "";
176
+ if (!email || !email.includes("@")) {
177
+ reply.code(400);
178
+ reply.send({ error: "A valid email address is required" });
179
+ return;
180
+ }
181
+ const ok = { sent: true };
182
+ const user = await this.findUserByEmail(email);
183
+ if (!user) {
184
+ reply.code(200);
185
+ reply.send(ok);
186
+ return;
187
+ }
188
+ let token;
189
+ try {
190
+ ({token} = await this.ctx.api.ssoBridge.signBridgeToken.query({
191
+ claims: {
192
+ userId: user.id,
193
+ username: user.username,
194
+ isAdmin: user.isAdmin,
195
+ provider: "auth-magic-link",
196
+ email
197
+ },
198
+ ttlSec: this.config.ttlSec || DEFAULT_CONFIG.ttlSec
199
+ }));
200
+ } catch (err) {
201
+ this.ctx.logger.error("Magic-link bridge-token minting failed (sso-bridge unavailable)", { meta: { message: require_dist.errMsg(err) } });
202
+ reply.code(503);
203
+ reply.send({ error: "sso-bridge capability unavailable" });
204
+ return;
205
+ }
206
+ const link = `${this.buildPublicOrigin()}/addon/auth-magic-link/login?bridge=${encodeURIComponent(token)}`;
207
+ if (this.config.deliveryMode === "log-only") this.ctx.logger.warn("Magic link generated (log-only mode — NOT delivered via email)", { meta: {
208
+ email,
209
+ link,
210
+ ttlSec: this.config.ttlSec
211
+ } });
212
+ else this.ctx.logger.warn("Magic link delivery: SMTP mode requested but no smtp-provider cap registered. Falling back to log.", { meta: {
213
+ email,
214
+ link
215
+ } });
216
+ reply.code(200);
217
+ reply.send(ok);
218
+ } catch (err) {
219
+ reply.code(500);
220
+ reply.send({
221
+ error: "Magic-link request failed",
222
+ message: require_dist.errMsg(err)
223
+ });
224
+ }
225
+ }
226
+ async handleLogin(req, reply) {
227
+ const bridge = req.query["bridge"];
228
+ if (!bridge) {
229
+ reply.code(400);
230
+ reply.send({ error: "Missing bridge token" });
231
+ return;
232
+ }
233
+ reply.code(302);
234
+ reply.header("Location", `/api/auth/sso/finish?bridge=${encodeURIComponent(bridge)}`);
235
+ reply.send("");
236
+ }
237
+ };
238
+ //#endregion
239
+ exports.AuthMagicLinkAddon = AuthMagicLinkAddon;
240
+ exports.default = AuthMagicLinkAddon;
@@ -0,0 +1,234 @@
1
+ import { a as BaseAddon, i as errMsg, n as authProviderCapability, t as addonRoutesCapability } from "../dist-JmuFs-U5.mjs";
2
+ //#region src/magic-link/auth-magic-link.addon.ts
3
+ /**
4
+ * Magic-link authentication addon.
5
+ *
6
+ * Passwordless flow:
7
+ * 1. Operator types their email on the login page → clicks "Send link".
8
+ * 2. The addon's `/request` route accepts the email, looks up the user
9
+ * via `user-management.listUsers()`, mints a short-lived HMAC bridge
10
+ * token (via `sso-bridge` cap), and delivers a URL of the form
11
+ * `https://<hub>/addon/auth-magic-link/login?bridge=<token>` to the
12
+ * user's email.
13
+ * 3. Email click hits `/login?bridge=…` → 302 to `/api/auth/sso/finish?bridge=…`
14
+ * → hub verifies the bridge token and mints the real session.
15
+ *
16
+ * Delivery is pluggable. This addon ships a "log-only" mode by default
17
+ * (writes the URL to the addon log so operators can copy it during
18
+ * setup before SMTP is configured). Production deployments configure
19
+ * the SMTP relay via global settings.
20
+ *
21
+ * Open follow-ups:
22
+ * - Wire `nodemailer` for real SMTP delivery (peer dep; the framework
23
+ * declares `smtpProvider` cap if/when we ship an SMTP addon).
24
+ * - Throttle requests per-email (rate-limit window).
25
+ * - Auto-provision unknown emails (currently rejected for safety).
26
+ */
27
+ var DEFAULT_CONFIG = {
28
+ displayName: "Email magic link",
29
+ icon: "mail",
30
+ publicOrigin: "",
31
+ deliveryMode: "log-only",
32
+ defaultRole: "viewer",
33
+ ttlSec: 600
34
+ };
35
+ var AuthMagicLinkAddon = class extends BaseAddon {
36
+ kind = "magic-link";
37
+ hasRedirectFlow = false;
38
+ hasCredentialFlow = true;
39
+ displayName;
40
+ icon;
41
+ constructor() {
42
+ super({ ...DEFAULT_CONFIG });
43
+ this.displayName = DEFAULT_CONFIG.displayName;
44
+ this.icon = DEFAULT_CONFIG.icon;
45
+ }
46
+ async onInitialize() {
47
+ Object.assign(this, {
48
+ displayName: this.config.displayName || DEFAULT_CONFIG.displayName,
49
+ icon: this.config.icon || DEFAULT_CONFIG.icon
50
+ });
51
+ const authProvider = {
52
+ validateCredentials: async () => null,
53
+ validateToken: async () => null,
54
+ getLoginUrl: async () => {
55
+ throw new Error("Magic link does not support direct getLoginUrl — POST email to /addon/auth-magic-link/request");
56
+ },
57
+ handleCallback: async () => {
58
+ throw new Error("Magic link does not use callback — the email link redirects to /api/auth/sso/finish directly");
59
+ }
60
+ };
61
+ const routes = [{
62
+ method: "POST",
63
+ path: "/request",
64
+ access: "public",
65
+ description: "Request a magic link be delivered to the given email",
66
+ handler: async (req, reply) => this.handleRequest(req, reply)
67
+ }, {
68
+ method: "GET",
69
+ path: "/login",
70
+ access: "public",
71
+ description: "Click target embedded in the magic-link email",
72
+ handler: async (req, reply) => this.handleLogin(req, reply)
73
+ }];
74
+ const routeProvider = {
75
+ id: "auth-magic-link",
76
+ getRoutes: () => routes
77
+ };
78
+ this.ctx.logger.info("Magic-link auth provider initialized", { meta: { deliveryMode: this.config.deliveryMode } });
79
+ return [{
80
+ capability: authProviderCapability,
81
+ provider: authProvider
82
+ }, {
83
+ capability: addonRoutesCapability,
84
+ provider: routeProvider
85
+ }];
86
+ }
87
+ globalSettingsSchema() {
88
+ return this.schema({ sections: [{
89
+ id: "magic-link",
90
+ title: "Magic Link Settings",
91
+ description: "Passwordless email login. The user types an email; the hub mails them a one-time link valid for the configured TTL.",
92
+ columns: 1,
93
+ fields: [
94
+ this.field({
95
+ type: "text",
96
+ key: "displayName",
97
+ label: "Display name",
98
+ default: DEFAULT_CONFIG.displayName
99
+ }),
100
+ this.field({
101
+ type: "text",
102
+ key: "icon",
103
+ label: "Icon",
104
+ default: DEFAULT_CONFIG.icon
105
+ }),
106
+ this.field({
107
+ type: "text",
108
+ key: "publicOrigin",
109
+ label: "Public origin",
110
+ description: "Where the email link points. Empty = read CAMSTACK_PUBLIC_ORIGIN env var.",
111
+ default: ""
112
+ }),
113
+ this.field({
114
+ type: "select",
115
+ key: "deliveryMode",
116
+ label: "Delivery mode",
117
+ description: "log-only writes the URL to the addon log (setup only). smtp uses the configured SMTP relay.",
118
+ default: DEFAULT_CONFIG.deliveryMode,
119
+ options: [{
120
+ value: "log-only",
121
+ label: "Log-only (no email sent)"
122
+ }, {
123
+ value: "smtp",
124
+ label: "SMTP (when configured)"
125
+ }]
126
+ }),
127
+ this.field({
128
+ type: "select",
129
+ key: "defaultRole",
130
+ label: "Default role",
131
+ default: DEFAULT_CONFIG.defaultRole,
132
+ options: [{
133
+ value: "viewer",
134
+ label: "Viewer"
135
+ }, {
136
+ value: "admin",
137
+ label: "Admin"
138
+ }]
139
+ }),
140
+ this.field({
141
+ type: "number",
142
+ key: "ttlSec",
143
+ label: "Link TTL (seconds)",
144
+ description: "How long the magic link stays valid. Default 600 (10 min).",
145
+ default: DEFAULT_CONFIG.ttlSec
146
+ })
147
+ ]
148
+ }] });
149
+ }
150
+ buildPublicOrigin() {
151
+ const configured = this.config.publicOrigin?.trim();
152
+ if (configured) return configured.replace(/\/+$/, "");
153
+ return (process.env["CAMSTACK_PUBLIC_ORIGIN"] || "").replace(/\/+$/, "") || "https://localhost:4443";
154
+ }
155
+ async findUserByEmail(email) {
156
+ let users;
157
+ try {
158
+ users = await this.ctx.api.userManagement.listUsers.query();
159
+ } catch (err) {
160
+ this.ctx.logger.warn("Magic-link user lookup failed (user-management cap unavailable)", { meta: { message: errMsg(err) } });
161
+ return null;
162
+ }
163
+ const needle = email.trim().toLowerCase();
164
+ for (const u of users) if (u.username.toLowerCase() === needle) return u;
165
+ return null;
166
+ }
167
+ async handleRequest(req, reply) {
168
+ try {
169
+ const body = req.body ?? {};
170
+ const email = typeof body.email === "string" ? body.email.trim() : "";
171
+ if (!email || !email.includes("@")) {
172
+ reply.code(400);
173
+ reply.send({ error: "A valid email address is required" });
174
+ return;
175
+ }
176
+ const ok = { sent: true };
177
+ const user = await this.findUserByEmail(email);
178
+ if (!user) {
179
+ reply.code(200);
180
+ reply.send(ok);
181
+ return;
182
+ }
183
+ let token;
184
+ try {
185
+ ({token} = await this.ctx.api.ssoBridge.signBridgeToken.query({
186
+ claims: {
187
+ userId: user.id,
188
+ username: user.username,
189
+ isAdmin: user.isAdmin,
190
+ provider: "auth-magic-link",
191
+ email
192
+ },
193
+ ttlSec: this.config.ttlSec || DEFAULT_CONFIG.ttlSec
194
+ }));
195
+ } catch (err) {
196
+ this.ctx.logger.error("Magic-link bridge-token minting failed (sso-bridge unavailable)", { meta: { message: errMsg(err) } });
197
+ reply.code(503);
198
+ reply.send({ error: "sso-bridge capability unavailable" });
199
+ return;
200
+ }
201
+ const link = `${this.buildPublicOrigin()}/addon/auth-magic-link/login?bridge=${encodeURIComponent(token)}`;
202
+ if (this.config.deliveryMode === "log-only") this.ctx.logger.warn("Magic link generated (log-only mode — NOT delivered via email)", { meta: {
203
+ email,
204
+ link,
205
+ ttlSec: this.config.ttlSec
206
+ } });
207
+ else this.ctx.logger.warn("Magic link delivery: SMTP mode requested but no smtp-provider cap registered. Falling back to log.", { meta: {
208
+ email,
209
+ link
210
+ } });
211
+ reply.code(200);
212
+ reply.send(ok);
213
+ } catch (err) {
214
+ reply.code(500);
215
+ reply.send({
216
+ error: "Magic-link request failed",
217
+ message: errMsg(err)
218
+ });
219
+ }
220
+ }
221
+ async handleLogin(req, reply) {
222
+ const bridge = req.query["bridge"];
223
+ if (!bridge) {
224
+ reply.code(400);
225
+ reply.send({ error: "Missing bridge token" });
226
+ return;
227
+ }
228
+ reply.code(302);
229
+ reply.header("Location", `/api/auth/sso/finish?bridge=${encodeURIComponent(bridge)}`);
230
+ reply.send("");
231
+ }
232
+ };
233
+ //#endregion
234
+ export { AuthMagicLinkAddon, AuthMagicLinkAddon as default };