UNPKG

@ley0x/better-auth-lastfm

Version:

Last.fm authentication plugin for BetterAuth

164 lines (161 loc) 5.76 kB
// src/server.ts import { createAuthEndpoint } from "better-auth/api"; import { z } from "zod"; // src/utils/api-signature.ts import { createHash } from "crypto"; function createLastfmApiSignature(params, secret) { const sortedParams = Object.keys(params).sort().map((key) => `${key}${params[key]}`).join(""); return createHash("md5").update(sortedParams + secret).digest("hex"); } // src/server.ts var lastfmResponseSchema = z.object({ session: z.object({ key: z.string(), name: z.string(), subscriber: z.number() }) }); var userSchema = z.object({ id: z.string(), name: z.string(), email: z.string(), emailVerified: z.boolean(), image: z.string().nullish(), createdAt: z.date(), updatedAt: z.date() }); var accountSchema = z.object({ id: z.string(), accountId: z.string(), providerId: z.string(), userId: z.string(), accessToken: z.string().nullish(), refreshToken: z.string().nullish(), idToken: z.string().nullish(), accessTokenExpiresAt: z.date().nullish(), refreshTokenExpiresAt: z.date().nullish(), scope: z.string().nullish(), password: z.string().nullish(), createdAt: z.date(), updatedAt: z.date() }); function lastfmPlugin(options) { const { apiKey, sharedSecret, baseUrl = process.env.BETTER_AUTH_URL || "http://localhost:3000", redirectTo = "/dashboard" } = options; if (!apiKey || !sharedSecret) { throw new Error("Last.fm plugin requires both apiKey and sharedSecret"); } return { id: "lastfm", endpoints: { "/lastfm/signin": createAuthEndpoint("/lastfm/signin", { method: "GET" }, async (ctx) => { const callbackUrl = `${baseUrl}/api/auth/lastfm/callback`; const authUrl = `https://www.last.fm/api/auth/?api_key=${apiKey}&cb=${encodeURIComponent(callbackUrl)}`; return ctx.redirect(authUrl); }), "/lastfm/callback": createAuthEndpoint("/lastfm/callback", { method: "GET" }, async (ctx) => { const { token } = ctx.query; if (!token) { ctx.context.logger?.error("Last.fm callback: Missing token parameter"); return ctx.json({ error: "Authentication failed: Missing token" }, { status: 400 }); } try { const sessionData = await exchangeTokenForSession(token, apiKey, sharedSecret); const { username, sessionKey } = sessionData; const existingAccount = await ctx.context.adapter.findOne({ model: "account", where: [ { field: "providerId", value: "lastfm" }, { field: "accountId", value: username } ] }); let user; if (existingAccount) { const validatedAccount = accountSchema.parse(existingAccount); await ctx.context.adapter.update({ model: "account", where: [{ field: "id", value: validatedAccount.id }], update: { accessToken: sessionKey, updatedAt: /* @__PURE__ */ new Date() } }); const existingUser = await ctx.context.adapter.findOne({ model: "user", where: [{ field: "id", value: validatedAccount.userId }] }); if (!existingUser) { return ctx.json({ error: "User not found" }, { status: 404 }); } user = userSchema.parse(existingUser); } else { const newUser = await ctx.context.adapter.create({ model: "user", data: { name: username, email: `${username}@lastfm.local`, emailVerified: true, image: null } }); user = userSchema.parse(newUser); await ctx.context.adapter.create({ model: "account", data: { accountId: username, providerId: "lastfm", userId: user.id, accessToken: sessionKey, createdAt: /* @__PURE__ */ new Date(), updatedAt: /* @__PURE__ */ new Date() } }); } const session = await ctx.context.internalAdapter.createSession(user.id); const cookieName = ctx.context.authCookies.sessionToken.name; const cookieOptions = ctx.context.authCookies.sessionToken.attributes; await ctx.setSignedCookie(cookieName, session.token, ctx.context.secret, { ...cookieOptions, maxAge: cookieOptions.maxAge || void 0 }); return ctx.redirect(redirectTo); } catch (error) { ctx.context.logger?.error("Last.fm authentication error:", error); return ctx.json({ error: "Authentication failed" }, { status: 500 }); } }) } }; } async function exchangeTokenForSession(token, apiKey, sharedSecret) { const params = { api_key: apiKey, method: "auth.getSession", token }; const apiSignature = createLastfmApiSignature(params, sharedSecret); const url = new URL("https://ws.audioscrobbler.com/2.0/"); url.searchParams.set("method", "auth.getSession"); url.searchParams.set("api_key", apiKey); url.searchParams.set("token", token); url.searchParams.set("api_sig", apiSignature); url.searchParams.set("format", "json"); const response = await fetch(url.toString()); if (!response.ok) { throw new Error(`Last.fm API error: ${response.status} ${response.statusText}`); } const data = await response.json(); const validatedData = lastfmResponseSchema.parse(data); return { username: validatedData.session.name, sessionKey: validatedData.session.key }; } export { createLastfmApiSignature, lastfmPlugin };