@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.
- package/LICENSE.md +172 -0
- package/README.md +59 -0
- package/commands.ts +61 -0
- package/components/client.tsx +130 -0
- package/components/index.ts +77 -0
- package/components/styles.css +62 -0
- package/database.ts +56 -0
- package/host.ts +108 -0
- package/index.plugin.ts +83 -0
- package/package.json +35 -0
- package/patterns/setup.ts +30 -0
- package/providers/database.ts +96 -0
- package/providers/development.ts +144 -0
- package/providers/env.ts +54 -0
- package/providers/index.ts +12 -0
- package/providers/magic-link.ts +226 -0
- package/providers/types.ts +10 -0
|
@@ -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
|