@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
package/host.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import {
|
|
2
|
+
anonymousCredentialHost,
|
|
3
|
+
createCredentialHost,
|
|
4
|
+
type CredentialHost,
|
|
5
|
+
type CredentialProvider,
|
|
6
|
+
type PluginLog,
|
|
7
|
+
} from "@pathmx/core"
|
|
8
|
+
import type { AuthProviderFactory } from "./providers/types.ts"
|
|
9
|
+
import { providersFromEnv, type AuthEnvironment } from "./providers/env.ts"
|
|
10
|
+
import type { SqlDatabase } from "@pathmx/core"
|
|
11
|
+
import { developmentCredentialHost } from "./providers/development.ts"
|
|
12
|
+
|
|
13
|
+
function combinedProvider(
|
|
14
|
+
providers: readonly CredentialProvider[],
|
|
15
|
+
): CredentialProvider {
|
|
16
|
+
return {
|
|
17
|
+
signIn: providers.find((provider) => provider.signIn)?.signIn ?? null,
|
|
18
|
+
async ready(directory) {
|
|
19
|
+
await Promise.all(providers.map((provider) => provider.ready(directory)))
|
|
20
|
+
},
|
|
21
|
+
async resolve(request) {
|
|
22
|
+
for (const provider of providers) {
|
|
23
|
+
const identity = await provider.resolve(request)
|
|
24
|
+
if (identity) return identity
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
async fetch(request, directory) {
|
|
28
|
+
for (const provider of providers) {
|
|
29
|
+
const response = await provider.fetch(request, directory)
|
|
30
|
+
if (response.status !== 404) return response
|
|
31
|
+
}
|
|
32
|
+
return new Response("Not Found", { status: 404 })
|
|
33
|
+
},
|
|
34
|
+
async close() {
|
|
35
|
+
const results = await Promise.allSettled(
|
|
36
|
+
providers.map((provider) => provider.close()),
|
|
37
|
+
)
|
|
38
|
+
const errors = results.flatMap((result) =>
|
|
39
|
+
result.status === "rejected" ? [result.reason] : [],
|
|
40
|
+
)
|
|
41
|
+
if (errors.length) {
|
|
42
|
+
throw new AggregateError(
|
|
43
|
+
errors,
|
|
44
|
+
"Authentication provider shutdown failed.",
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function productionCredentialHost(options: {
|
|
52
|
+
database: SqlDatabase
|
|
53
|
+
log: PluginLog
|
|
54
|
+
providers?: readonly AuthProviderFactory[]
|
|
55
|
+
env?: AuthEnvironment
|
|
56
|
+
allowAnonymous?: boolean
|
|
57
|
+
}) {
|
|
58
|
+
const factories = options.providers
|
|
59
|
+
const env = options.env ?? process.env
|
|
60
|
+
if (!factories?.length && options.allowAnonymous && !env.PATHMX_AUTH_MODE) {
|
|
61
|
+
return anonymousCredentialHost()
|
|
62
|
+
}
|
|
63
|
+
const providers = factories?.length
|
|
64
|
+
? factories.map((provider) =>
|
|
65
|
+
provider({ database: options.database, log: options.log }),
|
|
66
|
+
)
|
|
67
|
+
: providersFromEnv({ database: options.database, log: options.log }, env)
|
|
68
|
+
return createCredentialHost(combinedProvider(providers))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function runtimeCredentialHost(options: {
|
|
72
|
+
development: boolean
|
|
73
|
+
database: SqlDatabase
|
|
74
|
+
log: PluginLog
|
|
75
|
+
providers?: readonly AuthProviderFactory[]
|
|
76
|
+
env?: AuthEnvironment
|
|
77
|
+
allowAnonymous?: boolean
|
|
78
|
+
}) {
|
|
79
|
+
return options.development
|
|
80
|
+
? developmentCredentialHost()
|
|
81
|
+
: productionCredentialHost(options)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function deferredCredentialHost() {
|
|
85
|
+
let current: CredentialHost | undefined
|
|
86
|
+
const required = () => {
|
|
87
|
+
if (!current) throw new Error("Authentication plugin is not initialized.")
|
|
88
|
+
return current
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
host: Object.freeze({
|
|
92
|
+
ready: (...args: Parameters<CredentialHost["ready"]>) =>
|
|
93
|
+
required().ready(...args),
|
|
94
|
+
resolve: (...args: Parameters<CredentialHost["resolve"]>) =>
|
|
95
|
+
required().resolve(...args),
|
|
96
|
+
fetch: (...args: Parameters<CredentialHost["fetch"]>) =>
|
|
97
|
+
required().fetch(...args),
|
|
98
|
+
async close() {
|
|
99
|
+
await current?.close()
|
|
100
|
+
current = undefined
|
|
101
|
+
},
|
|
102
|
+
}) satisfies CredentialHost,
|
|
103
|
+
set(host: CredentialHost) {
|
|
104
|
+
if (current) throw new Error("Authentication plugin already initialized.")
|
|
105
|
+
current = host
|
|
106
|
+
},
|
|
107
|
+
}
|
|
108
|
+
}
|
package/index.plugin.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import {
|
|
2
|
+
definePlugin,
|
|
3
|
+
privateResponse,
|
|
4
|
+
route,
|
|
5
|
+
type CredentialHost,
|
|
6
|
+
type Plugin,
|
|
7
|
+
} from "@pathmx/core"
|
|
8
|
+
import { authComponents } from "./components/index.ts"
|
|
9
|
+
import { registerAuthCommands } from "./commands.ts"
|
|
10
|
+
import { authDatabase } from "./database.ts"
|
|
11
|
+
import { deferredCredentialHost, runtimeCredentialHost } from "./host.ts"
|
|
12
|
+
import type { AuthEnvironment } from "./providers/env.ts"
|
|
13
|
+
import type { AuthProviderFactory } from "./providers/types.ts"
|
|
14
|
+
|
|
15
|
+
export type AuthPluginOptions = Readonly<{
|
|
16
|
+
providers?: readonly AuthProviderFactory[]
|
|
17
|
+
env?: AuthEnvironment
|
|
18
|
+
/** Permit an anonymous production host when no provider is configured. */
|
|
19
|
+
allowAnonymous?: boolean
|
|
20
|
+
/** Supply an already composed host for custom providers and tests. */
|
|
21
|
+
credentials?: CredentialHost
|
|
22
|
+
/** Replace the stock magic-link UI while retaining auth state and routes. */
|
|
23
|
+
components?: Plugin["components"]
|
|
24
|
+
}>
|
|
25
|
+
|
|
26
|
+
export function AuthPlugin(options: AuthPluginOptions = {}): Plugin {
|
|
27
|
+
const credentials = deferredCredentialHost()
|
|
28
|
+
if (options.credentials) credentials.set(options.credentials)
|
|
29
|
+
return definePlugin({
|
|
30
|
+
id: "auth",
|
|
31
|
+
name: "PathMX Auth",
|
|
32
|
+
database: authDatabase,
|
|
33
|
+
credentials: credentials.host,
|
|
34
|
+
components: options.components ?? authComponents,
|
|
35
|
+
commands(group, ctx) {
|
|
36
|
+
registerAuthCommands(group, ctx, options.env)
|
|
37
|
+
},
|
|
38
|
+
setup(ctx) {
|
|
39
|
+
if (options.credentials) return
|
|
40
|
+
if (!ctx.database) throw new Error("Auth database is unavailable.")
|
|
41
|
+
credentials.set(
|
|
42
|
+
runtimeCredentialHost({
|
|
43
|
+
development: ctx.runtime.mode === "development",
|
|
44
|
+
database: ctx.database,
|
|
45
|
+
log: ctx.log,
|
|
46
|
+
providers: options.providers,
|
|
47
|
+
env: options.env,
|
|
48
|
+
allowAnonymous: options.allowAnonymous,
|
|
49
|
+
}),
|
|
50
|
+
)
|
|
51
|
+
},
|
|
52
|
+
routes: [
|
|
53
|
+
route.all("*", async (ctx) =>
|
|
54
|
+
privateResponse(await ctx.handleCredentials()),
|
|
55
|
+
),
|
|
56
|
+
],
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export { authRoutes } from "./providers/magic-link.ts"
|
|
61
|
+
export {
|
|
62
|
+
Confirm as AuthConfirm,
|
|
63
|
+
Session as AuthSession,
|
|
64
|
+
SignIn as AuthSignIn,
|
|
65
|
+
authComponents,
|
|
66
|
+
} from "./components/index.ts"
|
|
67
|
+
export type { AuthEnvironment } from "./providers/env.ts"
|
|
68
|
+
export type {
|
|
69
|
+
AuthProviderContext,
|
|
70
|
+
AuthProviderFactory,
|
|
71
|
+
} from "./providers/types.ts"
|
|
72
|
+
export type {
|
|
73
|
+
MagicLinkDelivery,
|
|
74
|
+
MagicLinkMailer,
|
|
75
|
+
MagicLinkProviderOptions,
|
|
76
|
+
} from "./providers/magic-link.ts"
|
|
77
|
+
export { magicLinkProvider } from "./providers/magic-link.ts"
|
|
78
|
+
export { AuthSetupPattern } from "./patterns/setup.ts"
|
|
79
|
+
export {
|
|
80
|
+
developmentCredentialHost,
|
|
81
|
+
type DevelopmentCredentialHost,
|
|
82
|
+
} from "./providers/development.ts"
|
|
83
|
+
export default AuthPlugin
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pathmx/auth",
|
|
3
|
+
"description": "Authentication and account UI for PathMX applications.",
|
|
4
|
+
"version": "0.5.0",
|
|
5
|
+
"license": "SEE LICENSE IN LICENSE.md",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/pathmx/pathmx-beta.git",
|
|
9
|
+
"directory": "paths/auth"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/pathmx/pathmx-beta#readme",
|
|
12
|
+
"bugs": "https://github.com/pathmx/pathmx-beta/issues",
|
|
13
|
+
"publishConfig": {
|
|
14
|
+
"access": "public",
|
|
15
|
+
"registry": "https://registry.npmjs.org/",
|
|
16
|
+
"tag": "beta"
|
|
17
|
+
},
|
|
18
|
+
"type": "module",
|
|
19
|
+
"exports": {
|
|
20
|
+
".": "./index.plugin.ts",
|
|
21
|
+
"./providers": "./providers/index.ts"
|
|
22
|
+
},
|
|
23
|
+
"engines": {
|
|
24
|
+
"bun": ">=1.4.0"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"@pathmx/core": "^0.5.0",
|
|
28
|
+
"react": ">=19.0.0",
|
|
29
|
+
"react-dom": ">=19.0.0"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@pathmx/react": "0.5.0",
|
|
33
|
+
"better-auth": "1.6.29"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { definePattern } from "@pathmx/core"
|
|
2
|
+
|
|
3
|
+
export const AuthSetupPattern = definePattern({
|
|
4
|
+
id: "auth:setup",
|
|
5
|
+
description: "Add the stock authentication pages and environment example.",
|
|
6
|
+
generate() {
|
|
7
|
+
return [
|
|
8
|
+
{
|
|
9
|
+
root: "paths",
|
|
10
|
+
path: "sign-in/index.page.md",
|
|
11
|
+
content: "# Sign in\n\n<x-auth-sign-in />\n",
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
root: "paths",
|
|
15
|
+
path: "auth/confirm/index.page.md",
|
|
16
|
+
content: "# Confirm sign in\n\n<x-auth-confirm />\n",
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
root: "project",
|
|
20
|
+
path: ".env.example",
|
|
21
|
+
content: `PATHMX_AUTH_MODE=magic-link
|
|
22
|
+
PATHMX_AUTH_ORIGIN=http://localhost:3000
|
|
23
|
+
PATHMX_AUTH_SECRET=
|
|
24
|
+
PATHMX_AUTH_EMAIL_FROM=PathMX <auth@example.com>
|
|
25
|
+
RESEND_API_KEY=
|
|
26
|
+
`,
|
|
27
|
+
},
|
|
28
|
+
] as const
|
|
29
|
+
},
|
|
30
|
+
})
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { SqlDatabase, SqlValue } from "@pathmx/core"
|
|
2
|
+
|
|
3
|
+
function sqlValues(values: readonly unknown[]): SqlValue[] {
|
|
4
|
+
return values.map((value) => {
|
|
5
|
+
if (
|
|
6
|
+
value === null ||
|
|
7
|
+
typeof value === "string" ||
|
|
8
|
+
typeof value === "number" ||
|
|
9
|
+
typeof value === "bigint" ||
|
|
10
|
+
value instanceof Uint8Array
|
|
11
|
+
) {
|
|
12
|
+
return value
|
|
13
|
+
}
|
|
14
|
+
if (typeof value === "boolean") return value ? 1 : 0
|
|
15
|
+
throw new Error(
|
|
16
|
+
`Unsupported authentication database value: ${typeof value}`,
|
|
17
|
+
)
|
|
18
|
+
})
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function mutates(sql: string) {
|
|
22
|
+
return /^\s*(?:insert|update|delete|replace)\b/i.test(sql)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
class PathMXD1Statement {
|
|
26
|
+
private values: SqlValue[] = []
|
|
27
|
+
|
|
28
|
+
constructor(
|
|
29
|
+
private database: SqlDatabase,
|
|
30
|
+
private sql: string,
|
|
31
|
+
) {}
|
|
32
|
+
|
|
33
|
+
bind(...values: unknown[]) {
|
|
34
|
+
this.values = sqlValues(values)
|
|
35
|
+
return this
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
private async execute(database: SqlDatabase) {
|
|
39
|
+
const mutation = mutates(this.sql)
|
|
40
|
+
if (mutation && !/\breturning\b/i.test(this.sql)) {
|
|
41
|
+
const result = await database.run(this.sql, this.values)
|
|
42
|
+
return {
|
|
43
|
+
success: true,
|
|
44
|
+
results: [],
|
|
45
|
+
meta: {
|
|
46
|
+
changes: result.changes,
|
|
47
|
+
last_row_id: result.lastInsertRowId,
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const results = await database.all(this.sql, this.values)
|
|
52
|
+
if (!mutation) {
|
|
53
|
+
return { success: true, results, meta: { changes: 0 } }
|
|
54
|
+
}
|
|
55
|
+
const meta = await database.get<{ changes: number; lastRowId: number }>(
|
|
56
|
+
"SELECT changes() AS changes, last_insert_rowid() AS lastRowId",
|
|
57
|
+
)
|
|
58
|
+
return {
|
|
59
|
+
success: true,
|
|
60
|
+
results,
|
|
61
|
+
meta: {
|
|
62
|
+
changes: meta?.changes ?? 0,
|
|
63
|
+
last_row_id: meta?.lastRowId,
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
all() {
|
|
69
|
+
return mutates(this.sql) && /\breturning\b/i.test(this.sql)
|
|
70
|
+
? this.database.transaction((database) => this.execute(database))
|
|
71
|
+
: this.execute(this.database)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
executeIn(database: SqlDatabase) {
|
|
75
|
+
return this.execute(database)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Adapt PathMX's portable SQL contract to Better Auth's supported D1 shape. */
|
|
80
|
+
export function betterAuthDatabase(database: SqlDatabase) {
|
|
81
|
+
return {
|
|
82
|
+
prepare: (sql: string) => new PathMXD1Statement(database, sql),
|
|
83
|
+
batch: (statements: PathMXD1Statement[]) =>
|
|
84
|
+
database.transaction(async (transaction) => {
|
|
85
|
+
const results = []
|
|
86
|
+
for (const statement of statements) {
|
|
87
|
+
results.push(await statement.executeIn(transaction))
|
|
88
|
+
}
|
|
89
|
+
return results
|
|
90
|
+
}),
|
|
91
|
+
async exec(sql: string) {
|
|
92
|
+
await database.exec(sql)
|
|
93
|
+
return { count: 1, duration: 0 }
|
|
94
|
+
},
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import type { CredentialHost, CredentialSession, UserActor } from "@pathmx/core"
|
|
2
|
+
|
|
3
|
+
const COOKIE = "pathmx-dev-session"
|
|
4
|
+
|
|
5
|
+
function escapeHtml(value: string) {
|
|
6
|
+
return value
|
|
7
|
+
.replaceAll("&", "&")
|
|
8
|
+
.replaceAll("<", "<")
|
|
9
|
+
.replaceAll(">", ">")
|
|
10
|
+
.replaceAll('"', """)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function cookieValue(header: string | null) {
|
|
14
|
+
if (!header) return
|
|
15
|
+
for (const part of header.split(";")) {
|
|
16
|
+
const equals = part.indexOf("=")
|
|
17
|
+
if (equals === -1 || part.slice(0, equals).trim() !== COOKIE) continue
|
|
18
|
+
try {
|
|
19
|
+
return decodeURIComponent(part.slice(equals + 1).trim())
|
|
20
|
+
} catch {
|
|
21
|
+
return
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function loopback(hostname: string) {
|
|
27
|
+
return (
|
|
28
|
+
hostname === "localhost" ||
|
|
29
|
+
hostname === "::1" ||
|
|
30
|
+
hostname === "[::1]" ||
|
|
31
|
+
hostname.startsWith("127.")
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function devRequest(request: Request) {
|
|
36
|
+
const url = new URL(request.url)
|
|
37
|
+
if (!loopback(url.hostname)) return
|
|
38
|
+
const origin = request.headers.get("origin")
|
|
39
|
+
if (origin && origin !== url.origin) return
|
|
40
|
+
return url
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function authenticated(actor: UserActor): CredentialSession {
|
|
44
|
+
return {
|
|
45
|
+
type: "authenticated",
|
|
46
|
+
actor,
|
|
47
|
+
signOut: { method: "post", href: "/api/auth/dev/sign-out" },
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type DevelopmentCredentialHost = CredentialHost &
|
|
52
|
+
Readonly<{
|
|
53
|
+
createSession(actorId: string): string
|
|
54
|
+
cookie(sessionId: string): string
|
|
55
|
+
}>
|
|
56
|
+
|
|
57
|
+
export function developmentCredentialHost(): DevelopmentCredentialHost {
|
|
58
|
+
const sessions = new Map<string, string>()
|
|
59
|
+
const createSession = (actorId: string) => {
|
|
60
|
+
const sessionId = crypto.randomUUID()
|
|
61
|
+
sessions.set(sessionId, actorId)
|
|
62
|
+
return sessionId
|
|
63
|
+
}
|
|
64
|
+
const cookie = (sessionId: string) =>
|
|
65
|
+
`${COOKIE}=${encodeURIComponent(sessionId)}`
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
async ready() {},
|
|
69
|
+
createSession,
|
|
70
|
+
cookie,
|
|
71
|
+
async resolve(request, actors) {
|
|
72
|
+
const url = devRequest(request)
|
|
73
|
+
if (!url) return { type: "anonymous", signIn: null }
|
|
74
|
+
const sessionId = cookieValue(request.headers.get("cookie"))
|
|
75
|
+
const actorId = sessionId ? sessions.get(sessionId) : undefined
|
|
76
|
+
const actor = actorId ? actors.findActorById(actorId) : undefined
|
|
77
|
+
return actor?.type === "user"
|
|
78
|
+
? authenticated(actor)
|
|
79
|
+
: {
|
|
80
|
+
type: "anonymous",
|
|
81
|
+
signIn: { method: "get", href: "/api/auth/dev" },
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
async fetch(request, actors) {
|
|
85
|
+
const url = devRequest(request)
|
|
86
|
+
if (!url) return new Response("Not Found", { status: 404 })
|
|
87
|
+
if (request.method === "GET" && url.pathname === "/api/auth/dev") {
|
|
88
|
+
const users = actors
|
|
89
|
+
.all()
|
|
90
|
+
.filter((actor): actor is UserActor => actor.type === "user")
|
|
91
|
+
.map(
|
|
92
|
+
(actor) =>
|
|
93
|
+
`<form method="post" action="/api/auth/dev/session"><input type="hidden" name="actor" value="${escapeHtml(actor.id)}"><button type="submit">${escapeHtml(actor.profile.name)}</button></form>`,
|
|
94
|
+
)
|
|
95
|
+
.join("\n")
|
|
96
|
+
return new Response(
|
|
97
|
+
`<!doctype html><title>Choose Actor</title>${users}`,
|
|
98
|
+
{
|
|
99
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
100
|
+
},
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
if (
|
|
104
|
+
request.method === "POST" &&
|
|
105
|
+
url.pathname === "/api/auth/dev/session"
|
|
106
|
+
) {
|
|
107
|
+
const actorId = (await request.formData()).get("actor")
|
|
108
|
+
const actor =
|
|
109
|
+
typeof actorId === "string"
|
|
110
|
+
? actors.findActorById(actorId)
|
|
111
|
+
: undefined
|
|
112
|
+
if (!actor || actor.type !== "user") {
|
|
113
|
+
return new Response("Unknown Actor", { status: 400 })
|
|
114
|
+
}
|
|
115
|
+
const sessionId = createSession(actor.id)
|
|
116
|
+
return new Response(null, {
|
|
117
|
+
status: 303,
|
|
118
|
+
headers: {
|
|
119
|
+
Location: "/",
|
|
120
|
+
"Set-Cookie": `${cookie(sessionId)}; Path=/; HttpOnly; SameSite=Lax`,
|
|
121
|
+
},
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
if (
|
|
125
|
+
request.method === "POST" &&
|
|
126
|
+
url.pathname === "/api/auth/dev/sign-out"
|
|
127
|
+
) {
|
|
128
|
+
const sessionId = cookieValue(request.headers.get("cookie"))
|
|
129
|
+
if (sessionId) sessions.delete(sessionId)
|
|
130
|
+
return new Response(null, {
|
|
131
|
+
status: 303,
|
|
132
|
+
headers: {
|
|
133
|
+
Location: "/",
|
|
134
|
+
"Set-Cookie": `${COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`,
|
|
135
|
+
},
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
return new Response("Not Found", { status: 404 })
|
|
139
|
+
},
|
|
140
|
+
async close() {
|
|
141
|
+
sessions.clear()
|
|
142
|
+
},
|
|
143
|
+
}
|
|
144
|
+
}
|
package/providers/env.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { magicLinkProvider, type MagicLinkMailer } from "./magic-link.ts"
|
|
2
|
+
import type { AuthProviderContext, AuthProviderFactory } from "./types.ts"
|
|
3
|
+
|
|
4
|
+
export type AuthEnvironment = Readonly<Record<string, string | undefined>>
|
|
5
|
+
|
|
6
|
+
function required(env: AuthEnvironment, name: string) {
|
|
7
|
+
const value = env[name]?.trim()
|
|
8
|
+
if (!value) throw new Error(`${name} is required for magic-link auth.`)
|
|
9
|
+
return value
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function resendMailer(apiKey: string, from: string): MagicLinkMailer {
|
|
13
|
+
return {
|
|
14
|
+
async send({ email, confirmUrl, expiresAt }) {
|
|
15
|
+
const response = await fetch("https://api.resend.com/emails", {
|
|
16
|
+
method: "POST",
|
|
17
|
+
headers: {
|
|
18
|
+
Authorization: `Bearer ${apiKey}`,
|
|
19
|
+
"Content-Type": "application/json",
|
|
20
|
+
},
|
|
21
|
+
body: JSON.stringify({
|
|
22
|
+
from,
|
|
23
|
+
to: [email],
|
|
24
|
+
subject: "Sign in to PathMX",
|
|
25
|
+
text: `Confirm your PathMX sign in:\n\n${confirmUrl}\n\nThis link expires at ${expiresAt.toISOString()}.`,
|
|
26
|
+
}),
|
|
27
|
+
})
|
|
28
|
+
if (!response.ok) throw new Error("Magic-link delivery failed.")
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function providersFromEnv(
|
|
34
|
+
context: AuthProviderContext,
|
|
35
|
+
env: AuthEnvironment = process.env,
|
|
36
|
+
): ReturnType<AuthProviderFactory>[] {
|
|
37
|
+
const mode = required(env, "PATHMX_AUTH_MODE")
|
|
38
|
+
if (mode !== "magic-link") {
|
|
39
|
+
throw new Error("PATHMX_AUTH_MODE must be magic-link.")
|
|
40
|
+
}
|
|
41
|
+
const secret = required(env, "PATHMX_AUTH_SECRET")
|
|
42
|
+
if (secret.length < 32) {
|
|
43
|
+
throw new Error("PATHMX_AUTH_SECRET must be at least 32 characters.")
|
|
44
|
+
}
|
|
45
|
+
const provider = magicLinkProvider({
|
|
46
|
+
origin: required(env, "PATHMX_AUTH_ORIGIN"),
|
|
47
|
+
secret,
|
|
48
|
+
mailer: resendMailer(
|
|
49
|
+
required(env, "RESEND_API_KEY"),
|
|
50
|
+
required(env, "PATHMX_AUTH_EMAIL_FROM"),
|
|
51
|
+
),
|
|
52
|
+
})
|
|
53
|
+
return [provider(context)]
|
|
54
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export {
|
|
2
|
+
authRoutes,
|
|
3
|
+
magicLinkProvider,
|
|
4
|
+
type MagicLinkDelivery,
|
|
5
|
+
type MagicLinkMailer,
|
|
6
|
+
type MagicLinkProviderOptions,
|
|
7
|
+
} from "./magic-link.ts"
|
|
8
|
+
export type { AuthProviderContext, AuthProviderFactory } from "./types.ts"
|
|
9
|
+
export {
|
|
10
|
+
developmentCredentialHost,
|
|
11
|
+
type DevelopmentCredentialHost,
|
|
12
|
+
} from "./development.ts"
|