@ley0x/better-auth-lastfm 1.0.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/README.md ADDED
@@ -0,0 +1,191 @@
1
+ # BetterAuth Last.fm Plugin
2
+
3
+ A clean, modern Last.fm authentication plugin for [BetterAuth](https://better-auth.com).
4
+
5
+ ## Features
6
+
7
+ - 🔐 Complete Last.fm authentication flow implementation
8
+ - 🎵 Session key storage for Last.fm API calls
9
+ - 🌟 TypeScript support with full type safety
10
+ - ⚛️ React hooks for easy integration
11
+ - 🛠️ Modern architecture following BetterAuth patterns
12
+ - 🚀 Zero additional dependencies (except peer dependencies)
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ npm install @ley0x/better-auth-lastfm
18
+ # or
19
+ pnpm add @ley0x/better-auth-lastfm
20
+ # or
21
+ yarn add @ley0x/better-auth-lastfm
22
+ ```
23
+
24
+ ## Setup
25
+
26
+ ### 1. Get Last.fm API Credentials
27
+
28
+ 1. Visit [Last.fm API Account Creation](https://www.last.fm/api/account/create)
29
+ 2. Create an application to get your API Key and Shared Secret
30
+
31
+ ### 2. Server Configuration
32
+
33
+ ```typescript
34
+ import { betterAuth } from 'better-auth'
35
+ import { lastfmPlugin } from '@ley0x/better-auth-lastfm'
36
+
37
+ export const auth = betterAuth({
38
+ // ... your other config
39
+ plugins: [
40
+ lastfmPlugin({
41
+ apiKey: process.env.LASTFM_API_KEY!,
42
+ sharedSecret: process.env.LASTFM_SHARED_SECRET!,
43
+ // Optional: customize redirect URL after successful auth
44
+ redirectTo: '/dashboard', // default: '/dashboard'
45
+ // Optional: set base URL (usually auto-detected)
46
+ baseUrl: process.env.BETTER_AUTH_URL
47
+ })
48
+ ]
49
+ })
50
+ ```
51
+
52
+ ### 3. Client Configuration
53
+
54
+ ```typescript
55
+ import { createAuthClient } from 'better-auth/react'
56
+ import { lastfmClientPlugin } from '@ley0x/better-auth-lastfm/client'
57
+
58
+ export const authClient = createAuthClient({
59
+ plugins: [lastfmClientPlugin()]
60
+ })
61
+
62
+ export const { useSession, signOut, signIn, signUp } = authClient
63
+ ```
64
+
65
+ ## Usage
66
+
67
+ ### Basic Sign-In
68
+
69
+ ```typescript
70
+ import { authClient } from './auth-client'
71
+
72
+ // Redirect to Last.fm authentication
73
+ await authClient.signInWithLastfm()
74
+ ```
75
+
76
+ ### React Hook
77
+
78
+ ```typescriptreact
79
+ import { useLastfm } from '@ley0x/better-auth-lastfm/react'
80
+ import { authClient } from './auth-client'
81
+
82
+ function LoginButton() {
83
+ const { signInWithLastfm } = useLastfm(authClient)
84
+
85
+ return (
86
+ <button onClick={signInWithLastfm}>
87
+ Sign in with Last.fm
88
+ </button>
89
+ )
90
+ }
91
+ ```
92
+
93
+ ### Getting Last.fm Session Key
94
+
95
+ After authentication, you can retrieve the Last.fm session key to make API calls:
96
+
97
+ ```typescript
98
+ import { authClient } from './auth-client'
99
+
100
+ // Get the Last.fm session key for API calls
101
+ const sessionKey = await authClient.getLastfmSessionKey()
102
+
103
+ if (sessionKey) {
104
+ // Use session key to call Last.fm API
105
+ // Example: get user's recent tracks, scrobble, etc.
106
+ }
107
+ ```
108
+
109
+ ### Making Last.fm API Calls
110
+
111
+ ```typescript
112
+ import { createLastfmApiSignature } from '@ley0x/better-auth-lastfm'
113
+
114
+ async function getRecentTracks(username: string, sessionKey: string) {
115
+ const params = {
116
+ method: 'user.getRecentTracks',
117
+ user: username,
118
+ api_key: process.env.LASTFM_API_KEY!,
119
+ sk: sessionKey,
120
+ format: 'json'
121
+ }
122
+
123
+ const signature = createLastfmApiSignature(params, process.env.LASTFM_SHARED_SECRET!)
124
+
125
+ const url = new URL('https://ws.audioscrobbler.com/2.0/')
126
+ Object.entries({ ...params, api_sig: signature }).forEach(([key, value]) => {
127
+ url.searchParams.set(key, value)
128
+ })
129
+
130
+ const response = await fetch(url)
131
+ return response.json()
132
+ }
133
+ ```
134
+
135
+ ## Environment Variables
136
+
137
+ Add these to your `.env` file:
138
+
139
+ ```env
140
+ LASTFM_API_KEY=your_api_key_here
141
+ LASTFM_SHARED_SECRET=your_shared_secret_here
142
+ BETTER_AUTH_URL=http://localhost:3000 # Your app's base URL
143
+ BETTER_AUTH_SECRET=your_secret_here # BetterAuth secret
144
+ ```
145
+
146
+ ## Database Schema
147
+
148
+ The plugin uses BetterAuth's standard user and account tables. Last.fm session keys are stored in the `accessToken` field of the account record.
149
+
150
+ ## API Reference
151
+
152
+ ### `lastfmPlugin(options)`
153
+
154
+ Server-side plugin configuration.
155
+
156
+ #### Options
157
+
158
+ - `apiKey` (required): Your Last.fm API key
159
+ - `sharedSecret` (required): Your Last.fm shared secret
160
+ - `baseUrl` (optional): Your app's base URL for callbacks
161
+ - `redirectTo` (optional): Path to redirect after successful auth
162
+
163
+ ### `lastfmClientPlugin()`
164
+
165
+ Client-side plugin for browser environments.
166
+
167
+ ### Client Methods
168
+
169
+ - `signInWithLastfm()`: Initiates Last.fm authentication flow
170
+ - `getLastfmSessionKey()`: Returns the user's Last.fm session key
171
+
172
+ ## TypeScript Support
173
+
174
+ Full TypeScript support with exported types:
175
+
176
+ ```typescript
177
+ import type {
178
+ LastfmPluginOptions,
179
+ LastfmSession,
180
+ LastfmAuthResponse,
181
+ LastfmUserProfile
182
+ } from '@ley0x/better-auth-lastfm'
183
+ ```
184
+
185
+ ## Contributing
186
+
187
+ Contributions are welcome! Please feel free to submit a Pull Request.
188
+
189
+ ## License
190
+
191
+ MIT
@@ -0,0 +1,50 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/client/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ lastfmClientPlugin: () => lastfmClientPlugin
24
+ });
25
+ module.exports = __toCommonJS(index_exports);
26
+
27
+ // src/client/plugin.ts
28
+ var lastfmClientPlugin = () => {
29
+ return {
30
+ id: "lastfm",
31
+ $InferServerPlugin: {},
32
+ getActions: ($fetch) => {
33
+ return {
34
+ signInWithLastfm: async () => {
35
+ window.location.href = "/api/auth/lastfm/login";
36
+ },
37
+ getLastfmSession: async () => {
38
+ const response = await $fetch("/api/auth/session", {
39
+ method: "GET"
40
+ });
41
+ return response;
42
+ }
43
+ };
44
+ }
45
+ };
46
+ };
47
+ // Annotate the CommonJS export names for ESM import in node:
48
+ 0 && (module.exports = {
49
+ lastfmClientPlugin
50
+ });
@@ -0,0 +1,56 @@
1
+ import * as better_auth_client from 'better-auth/client';
2
+ import { BetterAuthPlugin } from 'better-auth';
3
+
4
+ interface LastfmPluginOptions {
5
+ /**
6
+ * Last.fm API key
7
+ */
8
+ apiKey: string;
9
+ /**
10
+ * Last.fm shared secret for API signature generation
11
+ */
12
+ sharedSecret: string;
13
+ /**
14
+ * Base URL for your application (used for callback URL generation)
15
+ * @default process.env.BETTER_AUTH_URL || 'http://localhost:3000'
16
+ */
17
+ baseUrl?: string;
18
+ /**
19
+ * Custom redirect path after successful authentication
20
+ * @default '/dashboard'
21
+ */
22
+ redirectTo?: string;
23
+ }
24
+
25
+ /**
26
+ * Last.fm authentication plugin for BetterAuth
27
+ *
28
+ * Provides Last.fm authentication using their custom API flow (not OAuth)
29
+ * Creates a session with the Last.fm session key for subsequent API calls
30
+ */
31
+ declare function lastfmPlugin(options: LastfmPluginOptions): BetterAuthPlugin;
32
+
33
+ /**
34
+ * Client-side plugin for Last.fm authentication
35
+ * Provides methods to interact with Last.fm authentication flow
36
+ */
37
+ declare const lastfmClientPlugin: () => {
38
+ id: "lastfm";
39
+ $InferServerPlugin: ReturnType<typeof lastfmPlugin>;
40
+ getActions: ($fetch: better_auth_client.BetterFetch) => {
41
+ signInWithLastfm: () => Promise<void>;
42
+ getLastfmSession: () => Promise<{
43
+ data: unknown;
44
+ error: null;
45
+ } | {
46
+ data: null;
47
+ error: {
48
+ message?: string | undefined;
49
+ status: number;
50
+ statusText: string;
51
+ };
52
+ }>;
53
+ };
54
+ };
55
+
56
+ export { lastfmClientPlugin };
@@ -0,0 +1,56 @@
1
+ import * as better_auth_client from 'better-auth/client';
2
+ import { BetterAuthPlugin } from 'better-auth';
3
+
4
+ interface LastfmPluginOptions {
5
+ /**
6
+ * Last.fm API key
7
+ */
8
+ apiKey: string;
9
+ /**
10
+ * Last.fm shared secret for API signature generation
11
+ */
12
+ sharedSecret: string;
13
+ /**
14
+ * Base URL for your application (used for callback URL generation)
15
+ * @default process.env.BETTER_AUTH_URL || 'http://localhost:3000'
16
+ */
17
+ baseUrl?: string;
18
+ /**
19
+ * Custom redirect path after successful authentication
20
+ * @default '/dashboard'
21
+ */
22
+ redirectTo?: string;
23
+ }
24
+
25
+ /**
26
+ * Last.fm authentication plugin for BetterAuth
27
+ *
28
+ * Provides Last.fm authentication using their custom API flow (not OAuth)
29
+ * Creates a session with the Last.fm session key for subsequent API calls
30
+ */
31
+ declare function lastfmPlugin(options: LastfmPluginOptions): BetterAuthPlugin;
32
+
33
+ /**
34
+ * Client-side plugin for Last.fm authentication
35
+ * Provides methods to interact with Last.fm authentication flow
36
+ */
37
+ declare const lastfmClientPlugin: () => {
38
+ id: "lastfm";
39
+ $InferServerPlugin: ReturnType<typeof lastfmPlugin>;
40
+ getActions: ($fetch: better_auth_client.BetterFetch) => {
41
+ signInWithLastfm: () => Promise<void>;
42
+ getLastfmSession: () => Promise<{
43
+ data: unknown;
44
+ error: null;
45
+ } | {
46
+ data: null;
47
+ error: {
48
+ message?: string | undefined;
49
+ status: number;
50
+ statusText: string;
51
+ };
52
+ }>;
53
+ };
54
+ };
55
+
56
+ export { lastfmClientPlugin };
@@ -0,0 +1,23 @@
1
+ // src/client/plugin.ts
2
+ var lastfmClientPlugin = () => {
3
+ return {
4
+ id: "lastfm",
5
+ $InferServerPlugin: {},
6
+ getActions: ($fetch) => {
7
+ return {
8
+ signInWithLastfm: async () => {
9
+ window.location.href = "/api/auth/lastfm/login";
10
+ },
11
+ getLastfmSession: async () => {
12
+ const response = await $fetch("/api/auth/session", {
13
+ method: "GET"
14
+ });
15
+ return response;
16
+ }
17
+ };
18
+ }
19
+ };
20
+ };
21
+ export {
22
+ lastfmClientPlugin
23
+ };
package/dist/index.cjs ADDED
@@ -0,0 +1,201 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ createLastfmApiSignature: () => createLastfmApiSignature,
24
+ lastfmPlugin: () => lastfmPlugin
25
+ });
26
+ module.exports = __toCommonJS(index_exports);
27
+
28
+ // src/server/plugin.ts
29
+ var import_api = require("better-auth/api");
30
+ var import_zod = require("zod");
31
+
32
+ // src/utils/api-signature.ts
33
+ var import_crypto = require("crypto");
34
+ function createLastfmApiSignature(params, secret) {
35
+ const sortedParams = Object.keys(params).sort().map((key) => `${key}${params[key]}`).join("");
36
+ return (0, import_crypto.createHash)("md5").update(sortedParams + secret).digest("hex");
37
+ }
38
+
39
+ // src/server/plugin.ts
40
+ var lastfmResponseSchema = import_zod.z.object({
41
+ session: import_zod.z.object({
42
+ key: import_zod.z.string(),
43
+ name: import_zod.z.string(),
44
+ subscriber: import_zod.z.number()
45
+ })
46
+ });
47
+ var userSchema = import_zod.z.object({
48
+ id: import_zod.z.string(),
49
+ name: import_zod.z.string(),
50
+ email: import_zod.z.string(),
51
+ emailVerified: import_zod.z.boolean(),
52
+ image: import_zod.z.string().nullish(),
53
+ createdAt: import_zod.z.date(),
54
+ updatedAt: import_zod.z.date()
55
+ });
56
+ var accountSchema = import_zod.z.object({
57
+ id: import_zod.z.string(),
58
+ accountId: import_zod.z.string(),
59
+ providerId: import_zod.z.string(),
60
+ userId: import_zod.z.string(),
61
+ accessToken: import_zod.z.string().nullish(),
62
+ refreshToken: import_zod.z.string().nullish(),
63
+ idToken: import_zod.z.string().nullish(),
64
+ accessTokenExpiresAt: import_zod.z.date().nullish(),
65
+ refreshTokenExpiresAt: import_zod.z.date().nullish(),
66
+ scope: import_zod.z.string().nullish(),
67
+ password: import_zod.z.string().nullish(),
68
+ createdAt: import_zod.z.date(),
69
+ updatedAt: import_zod.z.date()
70
+ });
71
+ function lastfmPlugin(options) {
72
+ const {
73
+ apiKey,
74
+ sharedSecret,
75
+ baseUrl = process.env.BETTER_AUTH_URL || "http://localhost:3000",
76
+ redirectTo = "/dashboard"
77
+ } = options;
78
+ if (!apiKey || !sharedSecret) {
79
+ throw new Error("Last.fm plugin requires both apiKey and sharedSecret");
80
+ }
81
+ return {
82
+ id: "lastfm",
83
+ endpoints: {
84
+ "/lastfm/signin": (0, import_api.createAuthEndpoint)("/lastfm/signin", { method: "GET" }, async (ctx) => {
85
+ const callbackUrl = `${baseUrl}/api/auth/lastfm/callback`;
86
+ const authUrl = `https://www.last.fm/api/auth/?api_key=${apiKey}&cb=${encodeURIComponent(callbackUrl)}`;
87
+ return ctx.redirect(authUrl);
88
+ }),
89
+ "/lastfm/callback": (0, import_api.createAuthEndpoint)(
90
+ "/lastfm/callback",
91
+ { method: "GET" },
92
+ async (ctx) => {
93
+ const { token } = ctx.query;
94
+ if (!token) {
95
+ ctx.context.logger?.error("Last.fm callback: Missing token parameter");
96
+ return ctx.json({ error: "Authentication failed: Missing token" }, { status: 400 });
97
+ }
98
+ try {
99
+ const sessionData = await exchangeTokenForSession(token, apiKey, sharedSecret);
100
+ const { username, sessionKey } = sessionData;
101
+ const existingAccount = await ctx.context.adapter.findOne({
102
+ model: "account",
103
+ where: [
104
+ { field: "providerId", value: "lastfm" },
105
+ { field: "accountId", value: username }
106
+ ]
107
+ });
108
+ let user;
109
+ if (existingAccount) {
110
+ const validatedAccount = accountSchema.parse(existingAccount);
111
+ await ctx.context.adapter.update({
112
+ model: "account",
113
+ where: [{ field: "id", value: validatedAccount.id }],
114
+ update: {
115
+ accessToken: sessionKey,
116
+ updatedAt: /* @__PURE__ */ new Date()
117
+ }
118
+ });
119
+ const existingUser = await ctx.context.adapter.findOne({
120
+ model: "user",
121
+ where: [{ field: "id", value: validatedAccount.userId }]
122
+ });
123
+ if (!existingUser) {
124
+ return ctx.json({ error: "User not found" }, { status: 404 });
125
+ }
126
+ user = userSchema.parse(existingUser);
127
+ } else {
128
+ const newUser = await ctx.context.adapter.create({
129
+ model: "user",
130
+ data: {
131
+ name: username,
132
+ email: `${username}@lastfm.local`,
133
+ emailVerified: true,
134
+ image: null
135
+ }
136
+ });
137
+ user = userSchema.parse(newUser);
138
+ await ctx.context.adapter.create({
139
+ model: "account",
140
+ data: {
141
+ accountId: username,
142
+ providerId: "lastfm",
143
+ userId: user.id,
144
+ accessToken: sessionKey
145
+ }
146
+ });
147
+ }
148
+ const session = await ctx.context.internalAdapter.createSession(user.id, ctx);
149
+ const cookieName = ctx.context.authCookies.sessionToken.name;
150
+ const cookieOptions = ctx.context.authCookies.sessionToken.options;
151
+ await ctx.setSignedCookie(
152
+ cookieName,
153
+ session.token,
154
+ ctx.context.secret,
155
+ {
156
+ ...cookieOptions,
157
+ maxAge: void 0
158
+ }
159
+ );
160
+ return ctx.redirect(redirectTo);
161
+ } catch (error) {
162
+ ctx.context.logger?.error("Last.fm authentication error:", error);
163
+ return ctx.json(
164
+ { error: "Authentication failed" },
165
+ { status: 500 }
166
+ );
167
+ }
168
+ }
169
+ )
170
+ }
171
+ };
172
+ }
173
+ async function exchangeTokenForSession(token, apiKey, sharedSecret) {
174
+ const params = {
175
+ api_key: apiKey,
176
+ method: "auth.getSession",
177
+ token
178
+ };
179
+ const apiSignature = createLastfmApiSignature(params, sharedSecret);
180
+ const url = new URL("https://ws.audioscrobbler.com/2.0/");
181
+ url.searchParams.set("method", "auth.getSession");
182
+ url.searchParams.set("api_key", apiKey);
183
+ url.searchParams.set("token", token);
184
+ url.searchParams.set("api_sig", apiSignature);
185
+ url.searchParams.set("format", "json");
186
+ const response = await fetch(url.toString());
187
+ if (!response.ok) {
188
+ throw new Error(`Last.fm API error: ${response.status} ${response.statusText}`);
189
+ }
190
+ const data = await response.json();
191
+ const validatedData = lastfmResponseSchema.parse(data);
192
+ return {
193
+ username: validatedData.session.name,
194
+ sessionKey: validatedData.session.key
195
+ };
196
+ }
197
+ // Annotate the CommonJS export names for ESM import in node:
198
+ 0 && (module.exports = {
199
+ createLastfmApiSignature,
200
+ lastfmPlugin
201
+ });
@@ -0,0 +1,52 @@
1
+ import { BetterAuthPlugin } from 'better-auth';
2
+
3
+ interface LastfmPluginOptions {
4
+ /**
5
+ * Last.fm API key
6
+ */
7
+ apiKey: string;
8
+ /**
9
+ * Last.fm shared secret for API signature generation
10
+ */
11
+ sharedSecret: string;
12
+ /**
13
+ * Base URL for your application (used for callback URL generation)
14
+ * @default process.env.BETTER_AUTH_URL || 'http://localhost:3000'
15
+ */
16
+ baseUrl?: string;
17
+ /**
18
+ * Custom redirect path after successful authentication
19
+ * @default '/dashboard'
20
+ */
21
+ redirectTo?: string;
22
+ }
23
+ interface LastfmSession {
24
+ key: string;
25
+ name: string;
26
+ subscriber: number;
27
+ }
28
+ interface LastfmAuthResponse {
29
+ session: LastfmSession;
30
+ }
31
+ interface LastfmUserProfile {
32
+ username: string;
33
+ sessionKey: string;
34
+ }
35
+
36
+ /**
37
+ * Last.fm authentication plugin for BetterAuth
38
+ *
39
+ * Provides Last.fm authentication using their custom API flow (not OAuth)
40
+ * Creates a session with the Last.fm session key for subsequent API calls
41
+ */
42
+ declare function lastfmPlugin(options: LastfmPluginOptions): BetterAuthPlugin;
43
+
44
+ /**
45
+ * Creates a Last.fm API signature as required by their authentication flow
46
+ * @param params - Object containing API parameters
47
+ * @param secret - Last.fm shared secret
48
+ * @returns MD5 hash of sorted parameters + secret
49
+ */
50
+ declare function createLastfmApiSignature(params: Record<string, string>, secret: string): string;
51
+
52
+ export { type LastfmAuthResponse, type LastfmPluginOptions, type LastfmSession, type LastfmUserProfile, createLastfmApiSignature, lastfmPlugin };
@@ -0,0 +1,52 @@
1
+ import { BetterAuthPlugin } from 'better-auth';
2
+
3
+ interface LastfmPluginOptions {
4
+ /**
5
+ * Last.fm API key
6
+ */
7
+ apiKey: string;
8
+ /**
9
+ * Last.fm shared secret for API signature generation
10
+ */
11
+ sharedSecret: string;
12
+ /**
13
+ * Base URL for your application (used for callback URL generation)
14
+ * @default process.env.BETTER_AUTH_URL || 'http://localhost:3000'
15
+ */
16
+ baseUrl?: string;
17
+ /**
18
+ * Custom redirect path after successful authentication
19
+ * @default '/dashboard'
20
+ */
21
+ redirectTo?: string;
22
+ }
23
+ interface LastfmSession {
24
+ key: string;
25
+ name: string;
26
+ subscriber: number;
27
+ }
28
+ interface LastfmAuthResponse {
29
+ session: LastfmSession;
30
+ }
31
+ interface LastfmUserProfile {
32
+ username: string;
33
+ sessionKey: string;
34
+ }
35
+
36
+ /**
37
+ * Last.fm authentication plugin for BetterAuth
38
+ *
39
+ * Provides Last.fm authentication using their custom API flow (not OAuth)
40
+ * Creates a session with the Last.fm session key for subsequent API calls
41
+ */
42
+ declare function lastfmPlugin(options: LastfmPluginOptions): BetterAuthPlugin;
43
+
44
+ /**
45
+ * Creates a Last.fm API signature as required by their authentication flow
46
+ * @param params - Object containing API parameters
47
+ * @param secret - Last.fm shared secret
48
+ * @returns MD5 hash of sorted parameters + secret
49
+ */
50
+ declare function createLastfmApiSignature(params: Record<string, string>, secret: string): string;
51
+
52
+ export { type LastfmAuthResponse, type LastfmPluginOptions, type LastfmSession, type LastfmUserProfile, createLastfmApiSignature, lastfmPlugin };
package/dist/index.js ADDED
@@ -0,0 +1,173 @@
1
+ // src/server/plugin.ts
2
+ import { createAuthEndpoint } from "better-auth/api";
3
+ import { z } from "zod";
4
+
5
+ // src/utils/api-signature.ts
6
+ import { createHash } from "crypto";
7
+ function createLastfmApiSignature(params, secret) {
8
+ const sortedParams = Object.keys(params).sort().map((key) => `${key}${params[key]}`).join("");
9
+ return createHash("md5").update(sortedParams + secret).digest("hex");
10
+ }
11
+
12
+ // src/server/plugin.ts
13
+ var lastfmResponseSchema = z.object({
14
+ session: z.object({
15
+ key: z.string(),
16
+ name: z.string(),
17
+ subscriber: z.number()
18
+ })
19
+ });
20
+ var userSchema = z.object({
21
+ id: z.string(),
22
+ name: z.string(),
23
+ email: z.string(),
24
+ emailVerified: z.boolean(),
25
+ image: z.string().nullish(),
26
+ createdAt: z.date(),
27
+ updatedAt: z.date()
28
+ });
29
+ var accountSchema = z.object({
30
+ id: z.string(),
31
+ accountId: z.string(),
32
+ providerId: z.string(),
33
+ userId: z.string(),
34
+ accessToken: z.string().nullish(),
35
+ refreshToken: z.string().nullish(),
36
+ idToken: z.string().nullish(),
37
+ accessTokenExpiresAt: z.date().nullish(),
38
+ refreshTokenExpiresAt: z.date().nullish(),
39
+ scope: z.string().nullish(),
40
+ password: z.string().nullish(),
41
+ createdAt: z.date(),
42
+ updatedAt: z.date()
43
+ });
44
+ function lastfmPlugin(options) {
45
+ const {
46
+ apiKey,
47
+ sharedSecret,
48
+ baseUrl = process.env.BETTER_AUTH_URL || "http://localhost:3000",
49
+ redirectTo = "/dashboard"
50
+ } = options;
51
+ if (!apiKey || !sharedSecret) {
52
+ throw new Error("Last.fm plugin requires both apiKey and sharedSecret");
53
+ }
54
+ return {
55
+ id: "lastfm",
56
+ endpoints: {
57
+ "/lastfm/signin": createAuthEndpoint("/lastfm/signin", { method: "GET" }, async (ctx) => {
58
+ const callbackUrl = `${baseUrl}/api/auth/lastfm/callback`;
59
+ const authUrl = `https://www.last.fm/api/auth/?api_key=${apiKey}&cb=${encodeURIComponent(callbackUrl)}`;
60
+ return ctx.redirect(authUrl);
61
+ }),
62
+ "/lastfm/callback": createAuthEndpoint(
63
+ "/lastfm/callback",
64
+ { method: "GET" },
65
+ async (ctx) => {
66
+ const { token } = ctx.query;
67
+ if (!token) {
68
+ ctx.context.logger?.error("Last.fm callback: Missing token parameter");
69
+ return ctx.json({ error: "Authentication failed: Missing token" }, { status: 400 });
70
+ }
71
+ try {
72
+ const sessionData = await exchangeTokenForSession(token, apiKey, sharedSecret);
73
+ const { username, sessionKey } = sessionData;
74
+ const existingAccount = await ctx.context.adapter.findOne({
75
+ model: "account",
76
+ where: [
77
+ { field: "providerId", value: "lastfm" },
78
+ { field: "accountId", value: username }
79
+ ]
80
+ });
81
+ let user;
82
+ if (existingAccount) {
83
+ const validatedAccount = accountSchema.parse(existingAccount);
84
+ await ctx.context.adapter.update({
85
+ model: "account",
86
+ where: [{ field: "id", value: validatedAccount.id }],
87
+ update: {
88
+ accessToken: sessionKey,
89
+ updatedAt: /* @__PURE__ */ new Date()
90
+ }
91
+ });
92
+ const existingUser = await ctx.context.adapter.findOne({
93
+ model: "user",
94
+ where: [{ field: "id", value: validatedAccount.userId }]
95
+ });
96
+ if (!existingUser) {
97
+ return ctx.json({ error: "User not found" }, { status: 404 });
98
+ }
99
+ user = userSchema.parse(existingUser);
100
+ } else {
101
+ const newUser = await ctx.context.adapter.create({
102
+ model: "user",
103
+ data: {
104
+ name: username,
105
+ email: `${username}@lastfm.local`,
106
+ emailVerified: true,
107
+ image: null
108
+ }
109
+ });
110
+ user = userSchema.parse(newUser);
111
+ await ctx.context.adapter.create({
112
+ model: "account",
113
+ data: {
114
+ accountId: username,
115
+ providerId: "lastfm",
116
+ userId: user.id,
117
+ accessToken: sessionKey
118
+ }
119
+ });
120
+ }
121
+ const session = await ctx.context.internalAdapter.createSession(user.id, ctx);
122
+ const cookieName = ctx.context.authCookies.sessionToken.name;
123
+ const cookieOptions = ctx.context.authCookies.sessionToken.options;
124
+ await ctx.setSignedCookie(
125
+ cookieName,
126
+ session.token,
127
+ ctx.context.secret,
128
+ {
129
+ ...cookieOptions,
130
+ maxAge: void 0
131
+ }
132
+ );
133
+ return ctx.redirect(redirectTo);
134
+ } catch (error) {
135
+ ctx.context.logger?.error("Last.fm authentication error:", error);
136
+ return ctx.json(
137
+ { error: "Authentication failed" },
138
+ { status: 500 }
139
+ );
140
+ }
141
+ }
142
+ )
143
+ }
144
+ };
145
+ }
146
+ async function exchangeTokenForSession(token, apiKey, sharedSecret) {
147
+ const params = {
148
+ api_key: apiKey,
149
+ method: "auth.getSession",
150
+ token
151
+ };
152
+ const apiSignature = createLastfmApiSignature(params, sharedSecret);
153
+ const url = new URL("https://ws.audioscrobbler.com/2.0/");
154
+ url.searchParams.set("method", "auth.getSession");
155
+ url.searchParams.set("api_key", apiKey);
156
+ url.searchParams.set("token", token);
157
+ url.searchParams.set("api_sig", apiSignature);
158
+ url.searchParams.set("format", "json");
159
+ const response = await fetch(url.toString());
160
+ if (!response.ok) {
161
+ throw new Error(`Last.fm API error: ${response.status} ${response.statusText}`);
162
+ }
163
+ const data = await response.json();
164
+ const validatedData = lastfmResponseSchema.parse(data);
165
+ return {
166
+ username: validatedData.session.name,
167
+ sessionKey: validatedData.session.key
168
+ };
169
+ }
170
+ export {
171
+ createLastfmApiSignature,
172
+ lastfmPlugin
173
+ };
package/package.json ADDED
@@ -0,0 +1,82 @@
1
+ {
2
+ "name": "@ley0x/better-auth-lastfm",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "description": "Last.fm authentication plugin for BetterAuth",
6
+ "main": "dist/index.js",
7
+ "module": "dist/index.mjs",
8
+ "types": "dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.mjs",
13
+ "require": "./dist/index.js"
14
+ },
15
+ "./client": {
16
+ "types": "./dist/client/index.d.ts",
17
+ "import": "./dist/client/index.mjs",
18
+ "require": "./dist/client/index.js"
19
+ }
20
+ },
21
+ "files": [
22
+ "dist",
23
+ "README.md"
24
+ ],
25
+ "keywords": [
26
+ "better-auth",
27
+ "lastfm",
28
+ "authentication",
29
+ "auth",
30
+ "plugin",
31
+ "typescript"
32
+ ],
33
+ "author": "ley0x <ley0x@pm.me>",
34
+ "license": "MIT",
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "https://github.com/ley0x/better-auth-lastfm.git"
38
+ },
39
+ "bugs": {
40
+ "url": "https://github.com/ley0x/better-auth-lastfm/issues"
41
+ },
42
+ "homepage": "https://github.com/ley0x/better-auth-lastfm#readme",
43
+ "peerDependencies": {
44
+ "better-auth": "^0.x.x",
45
+ "react": ">=16.8.0"
46
+ },
47
+ "peerDependenciesMeta": {
48
+ "react": {
49
+ "optional": true
50
+ }
51
+ },
52
+ "dependencies": {
53
+ "zod": "^3.25.76"
54
+ },
55
+ "devDependencies": {
56
+ "@eslint/js": "^9.33.0",
57
+ "@types/node": "^20.19.10",
58
+ "@types/react": "^18.3.23",
59
+ "better-auth": "latest",
60
+ "eslint": "^9.33.0",
61
+ "react": "^18.3.1",
62
+ "tsup": "^8.5.0",
63
+ "typescript": "^5.9.2",
64
+ "typescript-eslint": "^8.39.1",
65
+ "vitest": "^1.6.1"
66
+ },
67
+ "engines": {
68
+ "node": ">=18.0.0"
69
+ },
70
+ "publishConfig": {
71
+ "access": "public"
72
+ },
73
+ "scripts": {
74
+ "build": "tsup",
75
+ "dev": "tsup --watch",
76
+ "type-check": "tsc --noEmit",
77
+ "lint": "eslint src --max-warnings 0",
78
+ "lint:fix": "eslint src --fix",
79
+ "test": "vitest",
80
+ "test:ui": "vitest --ui"
81
+ }
82
+ }