@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 +191 -0
- package/dist/client/index.cjs +50 -0
- package/dist/client/index.d.cts +56 -0
- package/dist/client/index.d.ts +56 -0
- package/dist/client/index.js +23 -0
- package/dist/index.cjs +201 -0
- package/dist/index.d.cts +52 -0
- package/dist/index.d.ts +52 -0
- package/dist/index.js +173 -0
- package/package.json +82 -0
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
|
+
});
|
package/dist/index.d.cts
ADDED
|
@@ -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.d.ts
ADDED
|
@@ -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
|
+
}
|