@pathmx/auth 0.5.0

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,226 @@
1
+ import { betterAuth, type BetterAuthOptions } from "better-auth"
2
+ import { getMigrations } from "better-auth/db/migration"
3
+ import { magicLink } from "better-auth/plugins"
4
+ import type { CredentialDirectory, CredentialProvider } from "@pathmx/core"
5
+ import { betterAuthDatabase } from "./database.ts"
6
+ import type { AuthProviderFactory } from "./types.ts"
7
+
8
+ export type MagicLinkDelivery = Readonly<{
9
+ email: string
10
+ confirmUrl: string
11
+ expiresAt: Date
12
+ }>
13
+
14
+ export type MagicLinkMailer = Readonly<{
15
+ send(delivery: MagicLinkDelivery): Promise<void>
16
+ }>
17
+
18
+ export type MagicLinkProviderOptions = Readonly<{
19
+ origin: string
20
+ secret: string
21
+ mailer: MagicLinkMailer
22
+ signInHref?: string
23
+ confirmHref?: string
24
+ }>
25
+
26
+ const INTERNAL_BASE = "/api/auth/internal"
27
+ const EXPIRES_IN_SECONDS = 5 * 60
28
+
29
+ export const authRoutes = Object.freeze({
30
+ requestLink: "/api/auth/magic-link/request",
31
+ confirm: "/api/auth/magic-link/confirm",
32
+ signOut: "/api/auth/sign-out",
33
+ })
34
+
35
+ function normalizeEmail(value: string) {
36
+ return value.trim().toLowerCase()
37
+ }
38
+
39
+ function exactOrigin(value: string) {
40
+ const url = new URL(value)
41
+ if (url.pathname !== "/" || url.search || url.hash) {
42
+ throw new Error("PATHMX_AUTH_ORIGIN must be one exact origin.")
43
+ }
44
+ return url.origin
45
+ }
46
+
47
+ function sameOriginPost(request: Request, origin: string) {
48
+ if (request.method !== "POST") return false
49
+ const supplied = request.headers.get("origin")
50
+ if (supplied) return supplied === origin
51
+ const referer = request.headers.get("referer")
52
+ if (!referer) return false
53
+ try {
54
+ return new URL(referer).origin === origin
55
+ } catch {
56
+ return false
57
+ }
58
+ }
59
+
60
+ function redirectResponse(response: Response, location = "/") {
61
+ if (response.status >= 300 && response.status < 400) return response
62
+ const headers = new Headers(response.headers)
63
+ headers.set("Location", location)
64
+ return new Response(null, { status: 303, headers })
65
+ }
66
+
67
+ export function magicLinkProvider(
68
+ options: MagicLinkProviderOptions,
69
+ ): AuthProviderFactory {
70
+ const origin = exactOrigin(options.origin)
71
+ if (options.secret.length < 32) {
72
+ throw new Error("Magic-link secret must be at least 32 characters.")
73
+ }
74
+ const confirmHref = options.confirmHref ?? "/auth/confirm"
75
+ return ({ database, log }): CredentialProvider => {
76
+ const auth = betterAuth({
77
+ appName: "PathMX",
78
+ baseURL: origin,
79
+ basePath: INTERNAL_BASE,
80
+ secret: options.secret,
81
+ database: betterAuthDatabase(database) as NonNullable<
82
+ BetterAuthOptions["database"]
83
+ >,
84
+ trustedOrigins: [origin],
85
+ advanced: {
86
+ useSecureCookies: new URL(origin).protocol === "https:",
87
+ disableCSRFCheck: false,
88
+ disableOriginCheck: false,
89
+ },
90
+ plugins: [
91
+ magicLink({
92
+ expiresIn: EXPIRES_IN_SECONDS,
93
+ disableSignUp: true,
94
+ storeToken: "hashed",
95
+ rateLimit: { window: 60, max: 5 },
96
+ async sendMagicLink({ email, token }) {
97
+ await options.mailer.send({
98
+ email,
99
+ confirmUrl: `${origin}${confirmHref}#token=${encodeURIComponent(token)}`,
100
+ expiresAt: new Date(Date.now() + EXPIRES_IN_SECONDS * 1000),
101
+ })
102
+ },
103
+ }),
104
+ ],
105
+ })
106
+
107
+ return {
108
+ signIn: { method: "get", href: options.signInHref ?? "/sign-in" },
109
+ async ready(directory) {
110
+ const migrations = await getMigrations(auth.options)
111
+ if (migrations.toBeCreated.length || migrations.toBeAdded.length) {
112
+ throw new Error("The @pathmx/auth database migration is not current.")
113
+ }
114
+ const { internalAdapter } = await auth.$context
115
+ for (const candidate of directory.candidates()) {
116
+ const email = candidate.identity.email
117
+ ? normalizeEmail(candidate.identity.email)
118
+ : undefined
119
+ if (!email) continue
120
+ const existing = await internalAdapter.findUserByEmail(email)
121
+ const avatar = candidate.profile.avatar ?? null
122
+ if (!existing) {
123
+ await internalAdapter.createUser({
124
+ name: candidate.profile.name,
125
+ email,
126
+ emailVerified: false,
127
+ ...(candidate.profile.avatar
128
+ ? { image: candidate.profile.avatar }
129
+ : {}),
130
+ })
131
+ } else if (
132
+ existing.user.name !== candidate.profile.name ||
133
+ existing.user.image !== avatar
134
+ ) {
135
+ await internalAdapter.updateUser(existing.user.id, {
136
+ name: candidate.profile.name,
137
+ image: avatar,
138
+ })
139
+ }
140
+ }
141
+ },
142
+ async resolve(request) {
143
+ try {
144
+ const session = await auth.api.getSession({
145
+ headers: request.headers,
146
+ })
147
+ if (!session?.user) return
148
+ return {
149
+ identity: {
150
+ provider: "magic-link",
151
+ subject: session.user.id,
152
+ email: normalizeEmail(session.user.email),
153
+ },
154
+ signOut: { method: "post", href: authRoutes.signOut },
155
+ }
156
+ } catch (error) {
157
+ log.warn("magic-link.session", {
158
+ error: error instanceof Error ? error.message : String(error),
159
+ })
160
+ return
161
+ }
162
+ },
163
+ async fetch(request, directory: CredentialDirectory) {
164
+ const url = new URL(request.url)
165
+ if (url.origin !== origin || !sameOriginPost(request, origin)) {
166
+ return new Response("Not Found", { status: 404 })
167
+ }
168
+ if (url.pathname === authRoutes.requestLink) {
169
+ const form = await request.formData()
170
+ const rawEmail = form.get("email")
171
+ const email =
172
+ typeof rawEmail === "string" ? normalizeEmail(rawEmail) : ""
173
+ if (
174
+ email &&
175
+ (await directory.allows({ provider: "magic-link", email }))
176
+ ) {
177
+ try {
178
+ await auth.api.signInMagicLink({
179
+ body: { email, callbackURL: "/" },
180
+ headers: request.headers,
181
+ })
182
+ } catch (error) {
183
+ log.error("magic-link.request", {
184
+ error: error instanceof Error ? error.message : String(error),
185
+ })
186
+ // Preserve an enumeration-safe public response.
187
+ }
188
+ }
189
+ return new Response(null, { status: 202 })
190
+ }
191
+ if (url.pathname === authRoutes.confirm) {
192
+ const form = await request.formData()
193
+ const token = form.get("token")
194
+ if (typeof token !== "string" || !token || token.length > 2048) {
195
+ return new Response("Invalid or expired link.", { status: 400 })
196
+ }
197
+ try {
198
+ return redirectResponse(
199
+ await auth.api.magicLinkVerify({
200
+ query: { token, callbackURL: "/" },
201
+ headers: request.headers,
202
+ asResponse: true,
203
+ }),
204
+ )
205
+ } catch {
206
+ return new Response("Invalid or expired link.", { status: 400 })
207
+ }
208
+ }
209
+ if (url.pathname === authRoutes.signOut) {
210
+ try {
211
+ return redirectResponse(
212
+ await auth.api.signOut({
213
+ headers: request.headers,
214
+ asResponse: true,
215
+ }),
216
+ )
217
+ } catch {
218
+ return new Response("Not Found", { status: 404 })
219
+ }
220
+ }
221
+ return new Response("Not Found", { status: 404 })
222
+ },
223
+ async close() {},
224
+ }
225
+ }
226
+ }
@@ -0,0 +1,10 @@
1
+ import type { CredentialProvider, PluginLog, SqlDatabase } from "@pathmx/core"
2
+
3
+ export type AuthProviderContext = Readonly<{
4
+ database: SqlDatabase
5
+ log: PluginLog
6
+ }>
7
+
8
+ export type AuthProviderFactory = (
9
+ context: AuthProviderContext,
10
+ ) => CredentialProvider