@ley0x/better-auth-lastfm
Version:
Last.fm authentication plugin for BetterAuth
164 lines (161 loc) • 5.76 kB
JavaScript
// 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
};