@draftlab/auth 0.3.0 → 0.4.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/plugin/plugin.js +1 -0
- package/dist/provider/magiclink.d.ts +89 -0
- package/dist/provider/magiclink.js +87 -0
- package/dist/types.js +1 -0
- package/dist/ui/magiclink.d.ts +41 -0
- package/dist/ui/magiclink.js +146 -0
- package/package.json +7 -7
package/dist/plugin/plugin.js
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { };
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { Provider } from "./provider.js";
|
|
2
|
+
|
|
3
|
+
//#region src/provider/magiclink.d.ts
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Configuration options for the Magic Link authentication provider.
|
|
7
|
+
*
|
|
8
|
+
* @template Claims - Type of claims collected during authentication (email, phone, etc.)
|
|
9
|
+
*/
|
|
10
|
+
interface MagicLinkConfig<Claims extends Record<string, string> = Record<string, string>> {
|
|
11
|
+
/**
|
|
12
|
+
* Token expiration time in seconds.
|
|
13
|
+
* After this time, the magic link becomes invalid.
|
|
14
|
+
*
|
|
15
|
+
* @default 900 (15 minutes)
|
|
16
|
+
*/
|
|
17
|
+
readonly expiry?: number;
|
|
18
|
+
/**
|
|
19
|
+
* Request handler for rendering the magic link UI.
|
|
20
|
+
* Handles both the initial claim collection and "check your email" screens.
|
|
21
|
+
*
|
|
22
|
+
* @param req - The HTTP request object
|
|
23
|
+
* @param state - Current authentication state
|
|
24
|
+
* @param form - Form data from POST requests (if any)
|
|
25
|
+
* @param error - Authentication error to display (if any)
|
|
26
|
+
* @returns Promise resolving to the authentication page response
|
|
27
|
+
*/
|
|
28
|
+
request: (req: Request, state: MagicLinkState, form?: FormData, error?: MagicLinkError) => Promise<Response>;
|
|
29
|
+
/**
|
|
30
|
+
* Callback for sending magic links to users.
|
|
31
|
+
* Should handle delivery via email, SMS, or other communication channels.
|
|
32
|
+
*
|
|
33
|
+
* @param claims - User claims containing contact information
|
|
34
|
+
* @param magicUrl - The magic link URL to send
|
|
35
|
+
* @returns Promise resolving to undefined on success, or error object on failure
|
|
36
|
+
*/
|
|
37
|
+
sendLink: (claims: Claims, magicUrl: string) => Promise<MagicLinkError | undefined>;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Authentication flow states for the magic link provider.
|
|
41
|
+
* The provider transitions between these states during authentication.
|
|
42
|
+
*/
|
|
43
|
+
type MagicLinkState = {
|
|
44
|
+
/** Initial state: user enters their claims (email, phone, etc.) */
|
|
45
|
+
readonly type: "start";
|
|
46
|
+
} | {
|
|
47
|
+
/** Link sent state: user checks their email/phone */
|
|
48
|
+
readonly type: "sent";
|
|
49
|
+
/** Whether this is a resend request */
|
|
50
|
+
readonly resend?: boolean;
|
|
51
|
+
/** The secure token for verification */
|
|
52
|
+
readonly token: string;
|
|
53
|
+
/** User claims collected during the start phase */
|
|
54
|
+
readonly claims: Record<string, string>;
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* Possible errors during magic link authentication.
|
|
58
|
+
*/
|
|
59
|
+
type MagicLinkError = {
|
|
60
|
+
/** The magic link is invalid or expired */
|
|
61
|
+
readonly type: "invalid_link";
|
|
62
|
+
} | {
|
|
63
|
+
/** A user claim is invalid or missing */
|
|
64
|
+
readonly type: "invalid_claim";
|
|
65
|
+
/** The claim field that failed validation */
|
|
66
|
+
readonly key: string;
|
|
67
|
+
/** The invalid value or error description */
|
|
68
|
+
readonly value: string;
|
|
69
|
+
};
|
|
70
|
+
/**
|
|
71
|
+
* User data returned by successful magic link authentication.
|
|
72
|
+
*
|
|
73
|
+
* @template Claims - Type of claims collected during authentication
|
|
74
|
+
*/
|
|
75
|
+
interface MagicLinkUserData<Claims extends Record<string, string> = Record<string, string>> {
|
|
76
|
+
/** The verified claims collected during authentication */
|
|
77
|
+
readonly claims: Claims;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Creates a Magic Link authentication provider.
|
|
81
|
+
* Implements a flexible claim-based authentication flow with magic link verification.
|
|
82
|
+
*
|
|
83
|
+
* @template Claims - Type of claims to collect (email, phone, username, etc.)
|
|
84
|
+
* @param config - Magic Link provider configuration
|
|
85
|
+
* @returns Provider instance implementing magic link authentication
|
|
86
|
+
*/
|
|
87
|
+
declare const MagicLinkProvider: <Claims extends Record<string, string> = Record<string, string>>(config: MagicLinkConfig<Claims>) => Provider<MagicLinkUserData<Claims>>;
|
|
88
|
+
//#endregion
|
|
89
|
+
export { MagicLinkConfig, MagicLinkError, MagicLinkProvider, MagicLinkState, MagicLinkUserData };
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { generateUnbiasedDigits, timingSafeCompare } from "../random.js";
|
|
2
|
+
|
|
3
|
+
//#region src/provider/magiclink.ts
|
|
4
|
+
/**
|
|
5
|
+
* Creates a Magic Link authentication provider.
|
|
6
|
+
* Implements a flexible claim-based authentication flow with magic link verification.
|
|
7
|
+
*
|
|
8
|
+
* @template Claims - Type of claims to collect (email, phone, username, etc.)
|
|
9
|
+
* @param config - Magic Link provider configuration
|
|
10
|
+
* @returns Provider instance implementing magic link authentication
|
|
11
|
+
*/
|
|
12
|
+
const MagicLinkProvider = (config) => {
|
|
13
|
+
/**
|
|
14
|
+
* Generates a cryptographically secure token.
|
|
15
|
+
*/
|
|
16
|
+
const generateToken = () => {
|
|
17
|
+
return generateUnbiasedDigits(32);
|
|
18
|
+
};
|
|
19
|
+
return {
|
|
20
|
+
type: "magiclink",
|
|
21
|
+
init(routes, ctx) {
|
|
22
|
+
/**
|
|
23
|
+
* Transitions between authentication states and renders the appropriate UI.
|
|
24
|
+
*/
|
|
25
|
+
const transition = async (c, nextState, formData, error) => {
|
|
26
|
+
await ctx.set(c, "provider", 3600 * 24, nextState);
|
|
27
|
+
const response = await config.request(c.request, nextState, formData, error);
|
|
28
|
+
return ctx.forward(c, response);
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* GET /authorize - Display initial claim collection form
|
|
32
|
+
*/
|
|
33
|
+
routes.get("/authorize", async (c) => {
|
|
34
|
+
return transition(c, { type: "start" });
|
|
35
|
+
});
|
|
36
|
+
/**
|
|
37
|
+
* POST /authorize - Handle form submissions and state transitions
|
|
38
|
+
*/
|
|
39
|
+
routes.post("/authorize", async (c) => {
|
|
40
|
+
const formData = await c.formData();
|
|
41
|
+
const action = formData.get("action")?.toString();
|
|
42
|
+
if (action === "request" || action === "resend") {
|
|
43
|
+
const token = generateToken();
|
|
44
|
+
const formEntries = Object.fromEntries(formData);
|
|
45
|
+
const { action: _,...claims } = formEntries;
|
|
46
|
+
const baseUrl = new URL(c.request.url).origin;
|
|
47
|
+
const magicUrl = new URL(`/auth/${ctx.name}/verify`, baseUrl);
|
|
48
|
+
magicUrl.searchParams.set("token", token);
|
|
49
|
+
for (const [key, value] of Object.entries(claims)) if (typeof value === "string") magicUrl.searchParams.set(key, value);
|
|
50
|
+
const sendError = await config.sendLink(claims, magicUrl.toString());
|
|
51
|
+
if (sendError) return transition(c, { type: "start" }, formData, sendError);
|
|
52
|
+
return transition(c, {
|
|
53
|
+
type: "sent",
|
|
54
|
+
resend: action === "resend",
|
|
55
|
+
claims,
|
|
56
|
+
token
|
|
57
|
+
}, formData);
|
|
58
|
+
}
|
|
59
|
+
return transition(c, { type: "start" });
|
|
60
|
+
});
|
|
61
|
+
/**
|
|
62
|
+
* GET /verify - Handle magic link clicks
|
|
63
|
+
*/
|
|
64
|
+
routes.get("/verify", async (c) => {
|
|
65
|
+
const url = new URL(c.request.url);
|
|
66
|
+
const token = url.searchParams.get("token");
|
|
67
|
+
const storedState = await ctx.get(c, "provider");
|
|
68
|
+
if (!token || !storedState || storedState.type !== "sent") return transition(c, { type: "start" }, void 0, { type: "invalid_link" });
|
|
69
|
+
if (!timingSafeCompare(storedState.token, token)) return transition(c, { type: "start" }, void 0, { type: "invalid_link" });
|
|
70
|
+
const urlClaims = {};
|
|
71
|
+
for (const [key, value] of url.searchParams) if (key !== "token" && value) urlClaims[key] = value;
|
|
72
|
+
const claimsMatch = Object.keys(storedState.claims).every((key) => {
|
|
73
|
+
const urlValue = urlClaims[key];
|
|
74
|
+
const storedValue = storedState.claims[key];
|
|
75
|
+
if (!urlValue || !storedValue) return false;
|
|
76
|
+
return timingSafeCompare(storedValue, urlValue);
|
|
77
|
+
});
|
|
78
|
+
if (!claimsMatch) return transition(c, { type: "start" }, void 0, { type: "invalid_link" });
|
|
79
|
+
await ctx.unset(c, "provider");
|
|
80
|
+
return await ctx.success(c, { claims: storedState.claims });
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
//#endregion
|
|
87
|
+
export { MagicLinkProvider };
|
package/dist/types.js
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { MagicLinkConfig } from "../provider/magiclink.js";
|
|
2
|
+
|
|
3
|
+
//#region src/ui/magiclink.d.ts
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Type for customizable UI copy text
|
|
7
|
+
*/
|
|
8
|
+
interface MagicLinkUICopy {
|
|
9
|
+
readonly email_placeholder: string;
|
|
10
|
+
readonly email_invalid: string;
|
|
11
|
+
readonly button_continue: string;
|
|
12
|
+
readonly link_info: string;
|
|
13
|
+
readonly link_sent: string;
|
|
14
|
+
readonly link_resent: string;
|
|
15
|
+
readonly link_didnt_get: string;
|
|
16
|
+
readonly link_resend: string;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Input mode for the contact field
|
|
20
|
+
*/
|
|
21
|
+
type MagicLinkUIMode = "email" | "phone";
|
|
22
|
+
/**
|
|
23
|
+
* Configuration options for the MagicLinkUI component
|
|
24
|
+
*/
|
|
25
|
+
interface MagicLinkUIOptions<Claims extends Record<string, string> = Record<string, string>> extends Pick<MagicLinkConfig<Claims>, "sendLink"> {
|
|
26
|
+
/**
|
|
27
|
+
* Input mode determining the type of contact information to collect
|
|
28
|
+
* @default "email"
|
|
29
|
+
*/
|
|
30
|
+
readonly mode?: MagicLinkUIMode;
|
|
31
|
+
/**
|
|
32
|
+
* Custom text copy for UI labels, messages, and errors
|
|
33
|
+
*/
|
|
34
|
+
readonly copy?: Partial<MagicLinkUICopy>;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Creates a complete UI configuration for Magic Link authentication
|
|
38
|
+
*/
|
|
39
|
+
declare const MagicLinkUI: <Claims extends Record<string, string> = Record<string, string>>(options: MagicLinkUIOptions<Claims>) => MagicLinkConfig<Claims>;
|
|
40
|
+
//#endregion
|
|
41
|
+
export { MagicLinkUI, MagicLinkUICopy, MagicLinkUIMode, MagicLinkUIOptions };
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { Layout, renderToHTML } from "./base.js";
|
|
2
|
+
import { FormAlert } from "./form.js";
|
|
3
|
+
import { jsx, jsxs } from "preact/jsx-runtime";
|
|
4
|
+
|
|
5
|
+
//#region src/ui/magiclink.tsx
|
|
6
|
+
/**
|
|
7
|
+
* Default text copy for the Magic Link authentication UI
|
|
8
|
+
*/
|
|
9
|
+
const DEFAULT_COPY = {
|
|
10
|
+
email_placeholder: "Email",
|
|
11
|
+
email_invalid: "Email address is not valid",
|
|
12
|
+
button_continue: "Send Magic Link",
|
|
13
|
+
link_info: "We'll send a secure link to your email.",
|
|
14
|
+
link_sent: "Magic link sent to ",
|
|
15
|
+
link_resent: "Magic link resent to ",
|
|
16
|
+
link_didnt_get: "Didn't get the email?",
|
|
17
|
+
link_resend: "Resend Magic Link"
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Gets the appropriate error message for display
|
|
21
|
+
*/
|
|
22
|
+
const getErrorMessage = (error, copy) => {
|
|
23
|
+
if (!error?.type) return void 0;
|
|
24
|
+
switch (error.type) {
|
|
25
|
+
case "invalid_link": return "This magic link is invalid or expired";
|
|
26
|
+
case "invalid_claim": return copy.email_invalid;
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Gets the appropriate success message for display
|
|
31
|
+
*/
|
|
32
|
+
const getSuccessMessage = (state, copy, mode) => {
|
|
33
|
+
if (state.type === "start" || !state.claims) return void 0;
|
|
34
|
+
const contact = state.claims[mode] || "";
|
|
35
|
+
const prefix = state.resend ? copy.link_resent : copy.link_sent;
|
|
36
|
+
return {
|
|
37
|
+
message: `${prefix}${contact}`,
|
|
38
|
+
contact
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Creates a complete UI configuration for Magic Link authentication
|
|
43
|
+
*/
|
|
44
|
+
const MagicLinkUI = (options) => {
|
|
45
|
+
const copy = {
|
|
46
|
+
...DEFAULT_COPY,
|
|
47
|
+
...options.copy
|
|
48
|
+
};
|
|
49
|
+
const mode = options.mode || "email";
|
|
50
|
+
/**
|
|
51
|
+
* Renders the start form for collecting contact information
|
|
52
|
+
*/
|
|
53
|
+
const renderStart = (form, error, state) => {
|
|
54
|
+
const success = getSuccessMessage(state || { type: "start" }, copy, mode);
|
|
55
|
+
return /* @__PURE__ */ jsx(Layout, { children: /* @__PURE__ */ jsxs("form", {
|
|
56
|
+
"data-component": "form",
|
|
57
|
+
method: "post",
|
|
58
|
+
children: [
|
|
59
|
+
success ? /* @__PURE__ */ jsx(FormAlert, {
|
|
60
|
+
message: success.message,
|
|
61
|
+
color: "success"
|
|
62
|
+
}) : /* @__PURE__ */ jsx(FormAlert, { message: getErrorMessage(error, copy) }),
|
|
63
|
+
/* @__PURE__ */ jsx("input", {
|
|
64
|
+
"data-component": "input",
|
|
65
|
+
type: mode === "email" ? "email" : "tel",
|
|
66
|
+
name: mode,
|
|
67
|
+
placeholder: copy.email_placeholder,
|
|
68
|
+
value: form?.get(mode)?.toString() || "",
|
|
69
|
+
autoComplete: mode,
|
|
70
|
+
required: true
|
|
71
|
+
}),
|
|
72
|
+
/* @__PURE__ */ jsx("input", {
|
|
73
|
+
type: "hidden",
|
|
74
|
+
name: "action",
|
|
75
|
+
value: "request"
|
|
76
|
+
}),
|
|
77
|
+
/* @__PURE__ */ jsx("button", {
|
|
78
|
+
"data-component": "button",
|
|
79
|
+
type: "submit",
|
|
80
|
+
children: copy.button_continue
|
|
81
|
+
}),
|
|
82
|
+
/* @__PURE__ */ jsx("p", {
|
|
83
|
+
"data-component": "description",
|
|
84
|
+
children: copy.link_info
|
|
85
|
+
})
|
|
86
|
+
]
|
|
87
|
+
}) });
|
|
88
|
+
};
|
|
89
|
+
/**
|
|
90
|
+
* Renders the "check your email" page after magic link is sent
|
|
91
|
+
*/
|
|
92
|
+
const renderSent = (_form, error, state) => {
|
|
93
|
+
const success = getSuccessMessage(state, copy, mode);
|
|
94
|
+
const contact = state.type === "sent" ? state.claims?.[mode] || "" : "";
|
|
95
|
+
return /* @__PURE__ */ jsx(Layout, { children: /* @__PURE__ */ jsxs("form", {
|
|
96
|
+
"data-component": "form",
|
|
97
|
+
method: "post",
|
|
98
|
+
children: [
|
|
99
|
+
/* @__PURE__ */ jsx("h2", {
|
|
100
|
+
"data-component": "title",
|
|
101
|
+
children: "Check your email"
|
|
102
|
+
}),
|
|
103
|
+
success ? /* @__PURE__ */ jsx(FormAlert, {
|
|
104
|
+
message: success.message,
|
|
105
|
+
color: "success"
|
|
106
|
+
}) : /* @__PURE__ */ jsx(FormAlert, { message: getErrorMessage(error, copy) }),
|
|
107
|
+
/* @__PURE__ */ jsx("p", {
|
|
108
|
+
"data-component": "description",
|
|
109
|
+
children: "Click the link in your email to sign in."
|
|
110
|
+
}),
|
|
111
|
+
/* @__PURE__ */ jsx("input", {
|
|
112
|
+
name: "action",
|
|
113
|
+
type: "hidden",
|
|
114
|
+
value: "resend"
|
|
115
|
+
}),
|
|
116
|
+
/* @__PURE__ */ jsx("input", {
|
|
117
|
+
name: mode,
|
|
118
|
+
type: "hidden",
|
|
119
|
+
value: contact
|
|
120
|
+
}),
|
|
121
|
+
/* @__PURE__ */ jsx("div", {
|
|
122
|
+
"data-component": "form-footer",
|
|
123
|
+
children: /* @__PURE__ */ jsxs("span", { children: [
|
|
124
|
+
copy.link_didnt_get,
|
|
125
|
+
" ",
|
|
126
|
+
/* @__PURE__ */ jsx("button", {
|
|
127
|
+
type: "submit",
|
|
128
|
+
"data-component": "link",
|
|
129
|
+
children: copy.link_resend
|
|
130
|
+
})
|
|
131
|
+
] })
|
|
132
|
+
})
|
|
133
|
+
]
|
|
134
|
+
}) });
|
|
135
|
+
};
|
|
136
|
+
return {
|
|
137
|
+
sendLink: options.sendLink,
|
|
138
|
+
request: async (_req, state, form, error) => {
|
|
139
|
+
const html = renderToHTML(state.type === "start" ? renderStart(form, error, state) : renderSent(form, error, state));
|
|
140
|
+
return new Response(html, { headers: { "Content-Type": "text/html" } });
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
//#endregion
|
|
146
|
+
export { MagicLinkUI };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@draftlab/auth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Core implementation for @draftlab/auth",
|
|
6
6
|
"author": "Matheus Pergoli",
|
|
@@ -37,10 +37,10 @@
|
|
|
37
37
|
],
|
|
38
38
|
"license": "MIT",
|
|
39
39
|
"devDependencies": {
|
|
40
|
-
"@types/node": "^24.
|
|
40
|
+
"@types/node": "^24.3.0",
|
|
41
41
|
"@types/qrcode": "^1.5.5",
|
|
42
|
-
"tsdown": "^0.
|
|
43
|
-
"typescript": "^5.
|
|
42
|
+
"tsdown": "^0.14.1",
|
|
43
|
+
"typescript": "^5.9.2",
|
|
44
44
|
"@draftlab/tsconfig": "0.1.0"
|
|
45
45
|
},
|
|
46
46
|
"peerDependencies": {
|
|
@@ -59,11 +59,11 @@
|
|
|
59
59
|
"@simplewebauthn/server": "^13.1.2",
|
|
60
60
|
"@standard-schema/spec": "^1.0.0",
|
|
61
61
|
"jose": "^6.0.12",
|
|
62
|
-
"otpauth": "^9.4.
|
|
63
|
-
"preact": "^10.27.
|
|
62
|
+
"otpauth": "^9.4.1",
|
|
63
|
+
"preact": "^10.27.1",
|
|
64
64
|
"preact-render-to-string": "^6.5.13",
|
|
65
65
|
"qrcode": "^1.5.4",
|
|
66
|
-
"@draftlab/auth-router": "0.0.
|
|
66
|
+
"@draftlab/auth-router": "0.0.6"
|
|
67
67
|
},
|
|
68
68
|
"engines": {
|
|
69
69
|
"node": ">=18"
|