@ldraney/mcp-linkedin 0.2.3 → 0.3.1
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/CLAUDE.md +5 -4
- package/LICENSE +21 -0
- package/MCP_SETUP.md +1 -1
- package/README.md +61 -94
- package/SECURITY.md +68 -0
- package/__tests__/auth/local-server.test.js +177 -0
- package/__tests__/auth/token-storage.test.js +141 -0
- package/__tests__/tools.test.js +26 -8
- package/docs/architecture.html +385 -0
- package/docs/index.html +361 -0
- package/docs/logo-300x300.png +0 -0
- package/docs/logo-draft-1.webp +0 -0
- package/docs/og-image.png +0 -0
- package/docs/privacy.html +161 -0
- package/docs/roadmap.html +341 -0
- package/docs/user-story.html +299 -0
- package/manifest.json +1 -1
- package/oauth-relay/fly.toml +1 -1
- package/oauth-relay/index.js +87 -12
- package/package.json +2 -1
- package/src/auth/local-server.js +222 -0
- package/src/auth/token-storage.js +71 -0
- package/src/index.js +20 -13
- package/src/linkedin-api.js +1 -1
- package/src/scheduler.js +1 -1
- package/src/tools.js +64 -23
- package/.claude/settings.local.json +0 -28
- package/USER_STORY.md +0 -401
package/CLAUDE.md
CHANGED
|
@@ -7,13 +7,13 @@ MCP server for LinkedIn post management via Claude Desktop.
|
|
|
7
7
|
```
|
|
8
8
|
src/
|
|
9
9
|
index.js # MCP server entry point (stdio transport)
|
|
10
|
-
tools.js #
|
|
10
|
+
tools.js # 21 tool implementations
|
|
11
11
|
linkedin-api.js # LinkedIn REST API client
|
|
12
12
|
schemas.js # Zod validation schemas
|
|
13
13
|
types.js # JSDoc type definitions
|
|
14
14
|
database.js # SQLite wrapper for scheduled posts
|
|
15
15
|
scheduler.js # Background scheduler daemon
|
|
16
|
-
__tests__/ # Jest test suite (
|
|
16
|
+
__tests__/ # Jest test suite (135 tests)
|
|
17
17
|
```
|
|
18
18
|
|
|
19
19
|
## Current Tools (Phase 4 Complete)
|
|
@@ -33,6 +33,7 @@ __tests__/ # Jest test suite (118 tests)
|
|
|
33
33
|
| `linkedin_add_comment` | Add comment to a post |
|
|
34
34
|
| `linkedin_add_reaction` | React to a post (LIKE, PRAISE, EMPATHY, etc.) |
|
|
35
35
|
| `linkedin_get_auth_url` | Start OAuth flow |
|
|
36
|
+
| `linkedin_save_credentials` | Save credentials from OAuth callback |
|
|
36
37
|
| `linkedin_exchange_code` | Complete OAuth |
|
|
37
38
|
| `linkedin_refresh_token` | Refresh expired access token |
|
|
38
39
|
| `linkedin_get_user_info` | Get profile info |
|
|
@@ -73,7 +74,7 @@ Required in `.env`:
|
|
|
73
74
|
```bash
|
|
74
75
|
npm start # Start MCP server
|
|
75
76
|
npm scheduler # Start scheduler daemon (runs every minute)
|
|
76
|
-
npm test # Run tests (
|
|
77
|
+
npm test # Run tests (135 passing)
|
|
77
78
|
```
|
|
78
79
|
|
|
79
80
|
## Roadmap
|
|
@@ -220,5 +221,5 @@ npm run test:manual # Manual API test (test_post.js)
|
|
|
220
221
|
|
|
221
222
|
- [LinkedIn Posts API](https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/posts-api)
|
|
222
223
|
- [LinkedIn Authentication](https://learn.microsoft.com/en-us/linkedin/shared/authentication/authentication)
|
|
223
|
-
- [
|
|
224
|
+
- [User Story](https://ldraney.github.io/mcp-linkedin/user-story.html) - Full user stories
|
|
224
225
|
- [LINKEDIN_API_REFERENCE.md](./LINKEDIN_API_REFERENCE.md) - API details
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Lucas Draney
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/MCP_SETUP.md
CHANGED
|
@@ -236,7 +236,7 @@ Access tokens expire after 60 days. To refresh:
|
|
|
236
236
|
|
|
237
237
|
For issues or questions:
|
|
238
238
|
- Check the [main README](./README.md)
|
|
239
|
-
- Review [
|
|
239
|
+
- Review [User Story](https://ldraney.github.io/mcp-linkedin/user-story.html) for feature details
|
|
240
240
|
- Open an issue on GitHub
|
|
241
241
|
|
|
242
242
|
---
|
package/README.md
CHANGED
|
@@ -1,115 +1,82 @@
|
|
|
1
1
|
# mcp-linkedin
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**Post to LinkedIn from Claude Desktop.**
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Install in 30 seconds. Authenticate once. Post forever.
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
Download from [latest release](https://github.com/intelligent-staffing-systems/mcp-linkedin/releases/latest) and open `mcp-linkedin.mcpb` with Claude Desktop.
|
|
7
|
+
## Quick Start
|
|
9
8
|
|
|
10
|
-
###
|
|
9
|
+
### Option A: One-Click Install (Recommended)
|
|
10
|
+
1. **Download** [mcp-linkedin.mcpb](https://github.com/ldraney/mcp-linkedin/releases/latest/download/mcp-linkedin.mcpb)
|
|
11
|
+
2. **Open** with Claude Desktop
|
|
12
|
+
3. **Authorize** when prompted (one-time LinkedIn OAuth)
|
|
13
|
+
|
|
14
|
+
### Option B: Command Line
|
|
11
15
|
```bash
|
|
12
16
|
npx @ldraney/mcp-linkedin
|
|
13
17
|
```
|
|
14
18
|
|
|
15
|
-
|
|
16
|
-
```json
|
|
17
|
-
{
|
|
18
|
-
"mcpServers": {
|
|
19
|
-
"linkedin": {
|
|
20
|
-
"command": "npx",
|
|
21
|
-
"args": ["@ldraney/mcp-linkedin"],
|
|
22
|
-
"env": {
|
|
23
|
-
"LINKEDIN_CLIENT_ID": "your-client-id",
|
|
24
|
-
"LINKEDIN_CLIENT_SECRET": "your-client-secret",
|
|
25
|
-
"LINKEDIN_REDIRECT_URI": "https://localhost:8888/callback",
|
|
26
|
-
"LINKEDIN_PERSON_ID": "your-person-id",
|
|
27
|
-
"LINKEDIN_ACCESS_TOKEN": "your-access-token"
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
```
|
|
19
|
+
## What You Can Do
|
|
33
20
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
|
44
|
-
|
|
45
|
-
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
| Tool | Description |
|
|
62
|
-
|------|-------------|
|
|
63
|
-
| `linkedin_schedule_post` | Schedule for future publication |
|
|
64
|
-
| `linkedin_list_scheduled_posts` | List by status |
|
|
65
|
-
| `linkedin_cancel_scheduled_post` | Cancel pending post |
|
|
66
|
-
| `linkedin_get_scheduled_post` | Get scheduled post details |
|
|
67
|
-
|
|
68
|
-
### Authentication
|
|
69
|
-
| Tool | Description |
|
|
70
|
-
|------|-------------|
|
|
71
|
-
| `linkedin_get_auth_url` | Start OAuth flow |
|
|
72
|
-
| `linkedin_exchange_code` | Complete OAuth |
|
|
73
|
-
| `linkedin_refresh_token` | Refresh expired token |
|
|
74
|
-
| `linkedin_get_user_info` | Get profile info |
|
|
75
|
-
|
|
76
|
-
## Setup
|
|
77
|
-
|
|
78
|
-
### Option 1: Quick Setup (Recommended)
|
|
79
|
-
1. Install the extension
|
|
80
|
-
2. Ask Claude: "Help me set up LinkedIn authentication"
|
|
81
|
-
3. Follow the guided OAuth flow
|
|
82
|
-
|
|
83
|
-
### Option 2: Manual Setup
|
|
84
|
-
1. Create a LinkedIn Developer App at https://www.linkedin.com/developers/apps
|
|
85
|
-
2. Add products: "Sign In with LinkedIn using OpenID Connect" + "Share on LinkedIn"
|
|
86
|
-
3. Configure redirect URI: `https://localhost:8888/callback`
|
|
87
|
-
4. Set environment variables (see Installation above)
|
|
88
|
-
|
|
89
|
-
## Running the Scheduler
|
|
90
|
-
|
|
91
|
-
For scheduled posts to publish automatically:
|
|
92
|
-
```bash
|
|
93
|
-
npm run scheduler
|
|
21
|
+
Ask Claude things like:
|
|
22
|
+
- *"Post about my new GitHub project with a link"*
|
|
23
|
+
- *"Create a poll asking my network about AI trends"*
|
|
24
|
+
- *"Schedule a post for tomorrow at 9am"*
|
|
25
|
+
- *"Upload this PDF and share it with my network"*
|
|
26
|
+
- *"Add a comment to my latest post"*
|
|
27
|
+
|
|
28
|
+
## 21 Tools Available
|
|
29
|
+
|
|
30
|
+
| Category | Tools |
|
|
31
|
+
|----------|-------|
|
|
32
|
+
| **Posting** | Text, links, images, videos, documents, polls |
|
|
33
|
+
| **Multi-media** | Up to 20 images per post |
|
|
34
|
+
| **Scheduling** | Schedule posts for future publication |
|
|
35
|
+
| **Engagement** | Comment and react to posts |
|
|
36
|
+
| **Management** | Edit, delete, list your posts |
|
|
37
|
+
|
|
38
|
+
## How It Works
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
You install mcp-linkedin (npm or .mcpb)
|
|
42
|
+
│
|
|
43
|
+
└─► First use triggers OAuth
|
|
44
|
+
│
|
|
45
|
+
└─► Tokens stored locally on your machine
|
|
46
|
+
│
|
|
47
|
+
└─► Claude posts to LinkedIn for you
|
|
94
48
|
```
|
|
95
49
|
|
|
96
|
-
|
|
50
|
+
Your credentials stay on your machine. The OAuth relay only handles the initial handshake.
|
|
97
51
|
|
|
98
|
-
##
|
|
52
|
+
## Privacy
|
|
53
|
+
|
|
54
|
+
- Your LinkedIn access token is stored locally on your machine
|
|
55
|
+
- We never store or access your LinkedIn credentials
|
|
56
|
+
- All API calls go directly from your machine to LinkedIn
|
|
57
|
+
|
|
58
|
+
## Requirements
|
|
59
|
+
|
|
60
|
+
- [Claude Desktop](https://claude.ai/download)
|
|
61
|
+
- LinkedIn account
|
|
62
|
+
|
|
63
|
+
## Feedback
|
|
64
|
+
|
|
65
|
+
Found a bug? Have a feature request?
|
|
66
|
+
[Open an issue](https://github.com/ldraney/mcp-linkedin/issues/new)
|
|
67
|
+
|
|
68
|
+
For security vulnerabilities, see [SECURITY.md](./SECURITY.md).
|
|
69
|
+
|
|
70
|
+
## For Developers
|
|
99
71
|
|
|
100
72
|
```bash
|
|
73
|
+
git clone https://github.com/ldraney/mcp-linkedin.git
|
|
74
|
+
cd mcp-linkedin
|
|
101
75
|
npm install
|
|
102
|
-
npm test
|
|
103
|
-
npm run test:coverage # Coverage report
|
|
104
|
-
npm start # Start MCP server
|
|
105
|
-
npm run scheduler # Start scheduler daemon
|
|
76
|
+
npm test # 135 tests
|
|
106
77
|
```
|
|
107
78
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
- [CLAUDE.md](./CLAUDE.md) - Project structure and API details
|
|
111
|
-
- [MCP_SETUP.md](./MCP_SETUP.md) - Claude Desktop configuration
|
|
112
|
-
- [LINKEDIN_API_REFERENCE.md](./LINKEDIN_API_REFERENCE.md) - API documentation
|
|
79
|
+
See [CLAUDE.md](./CLAUDE.md) for architecture and API details.
|
|
113
80
|
|
|
114
81
|
## License
|
|
115
82
|
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
## Reporting a Vulnerability
|
|
4
|
+
|
|
5
|
+
If you discover a security vulnerability in mcp-linkedin, please report it responsibly:
|
|
6
|
+
|
|
7
|
+
1. **Do NOT open a public issue** for security vulnerabilities
|
|
8
|
+
2. **Email:** Open a [private security advisory](https://github.com/ldraney/mcp-linkedin/security/advisories/new) on GitHub
|
|
9
|
+
3. **Include:** Description of the vulnerability, steps to reproduce, and potential impact
|
|
10
|
+
|
|
11
|
+
You should receive a response within 48 hours. We'll work with you to understand the issue and coordinate a fix before any public disclosure.
|
|
12
|
+
|
|
13
|
+
## Security Model
|
|
14
|
+
|
|
15
|
+
### Credential Storage
|
|
16
|
+
|
|
17
|
+
- OAuth tokens are stored in your **operating system's secure keychain** (macOS Keychain, Windows Credential Manager, Linux Secret Service) via [@napi-rs/keyring](https://github.com/nicedoc/keyring)
|
|
18
|
+
- Environment variables (`.env`) are supported as a fallback for development setups
|
|
19
|
+
- Legacy plaintext credential file support (`~/.mcp-linkedin-credentials.json`) was removed in v0.3.x
|
|
20
|
+
- Token values are masked in all tool outputs to prevent accidental exposure in logs or chat history
|
|
21
|
+
- The OAuth relay server (`fly.io`) only handles the initial handshake redirect -- it never sees or stores your access token
|
|
22
|
+
|
|
23
|
+
### Data Flow
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
Your Machine External
|
|
27
|
+
┌─────────────────┐ ┌──────────────┐
|
|
28
|
+
│ Claude Desktop │ │ │
|
|
29
|
+
│ ├─ mcp-linkedin│──────────►│ LinkedIn API │
|
|
30
|
+
│ └─ OS Keychain│ │ │
|
|
31
|
+
└─────────────────┘ └──────────────┘
|
|
32
|
+
│
|
|
33
|
+
│ (OAuth only)
|
|
34
|
+
▼
|
|
35
|
+
┌─────────────────┐
|
|
36
|
+
│ OAuth Relay │
|
|
37
|
+
│ (fly.io) │
|
|
38
|
+
│ Redirect only │
|
|
39
|
+
└─────────────────┘
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
- All LinkedIn API calls go **directly** from your machine to LinkedIn
|
|
43
|
+
- No telemetry, analytics, or data collection
|
|
44
|
+
- Scheduled posts are stored in a **local** SQLite database on your machine
|
|
45
|
+
|
|
46
|
+
### OAuth Security
|
|
47
|
+
|
|
48
|
+
- CSRF protection via cryptographic nonces on every OAuth flow
|
|
49
|
+
- Tokens are captured by a local callback server and stored directly in your keychain
|
|
50
|
+
- The `w_member_social` scope is the only permission requested (post and interact on your behalf)
|
|
51
|
+
|
|
52
|
+
### What We Don't Do
|
|
53
|
+
|
|
54
|
+
- We never store your credentials on any remote server
|
|
55
|
+
- We never log or transmit your LinkedIn data
|
|
56
|
+
- We never access your LinkedIn connections, messages, or profile data beyond what you explicitly post
|
|
57
|
+
|
|
58
|
+
## Supported Versions
|
|
59
|
+
|
|
60
|
+
| Version | Supported |
|
|
61
|
+
|---------|-----------|
|
|
62
|
+
| 0.3.x | Yes |
|
|
63
|
+
| 0.2.x | Yes |
|
|
64
|
+
| < 0.2 | No |
|
|
65
|
+
|
|
66
|
+
## Dependencies
|
|
67
|
+
|
|
68
|
+
We keep dependencies minimal to reduce attack surface. Run `npm audit` to check for known vulnerabilities in dependencies.
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Tests for local callback server
|
|
3
|
+
* Tests the OAuth callback server functionality
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const http = require('http');
|
|
7
|
+
const { URL } = require('url');
|
|
8
|
+
|
|
9
|
+
// We need to test the actual module, not mock it
|
|
10
|
+
const {
|
|
11
|
+
startCallbackServer,
|
|
12
|
+
generateNonce,
|
|
13
|
+
findAvailablePort
|
|
14
|
+
} = require('../../src/auth/local-server');
|
|
15
|
+
|
|
16
|
+
describe('Local Callback Server', () => {
|
|
17
|
+
describe('generateNonce', () => {
|
|
18
|
+
it('should generate a base64url-encoded string', () => {
|
|
19
|
+
const nonce = generateNonce();
|
|
20
|
+
|
|
21
|
+
expect(typeof nonce).toBe('string');
|
|
22
|
+
expect(nonce.length).toBeGreaterThan(20);
|
|
23
|
+
// Base64url should not contain + or /
|
|
24
|
+
expect(nonce).not.toMatch(/[+\/]/);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('should generate unique nonces', () => {
|
|
28
|
+
const nonces = new Set();
|
|
29
|
+
for (let i = 0; i < 100; i++) {
|
|
30
|
+
nonces.add(generateNonce());
|
|
31
|
+
}
|
|
32
|
+
expect(nonces.size).toBe(100);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('findAvailablePort', () => {
|
|
37
|
+
it('should find a port in the expected range', async () => {
|
|
38
|
+
const port = await findAvailablePort();
|
|
39
|
+
|
|
40
|
+
expect(port).toBeGreaterThanOrEqual(8880);
|
|
41
|
+
expect(port).toBeLessThanOrEqual(8899);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('startCallbackServer', () => {
|
|
46
|
+
it('should return port, nonce, and waitForCallback function', async () => {
|
|
47
|
+
const serverInfo = await startCallbackServer();
|
|
48
|
+
|
|
49
|
+
expect(serverInfo).toHaveProperty('port');
|
|
50
|
+
expect(serverInfo).toHaveProperty('nonce');
|
|
51
|
+
expect(serverInfo).toHaveProperty('waitForCallback');
|
|
52
|
+
|
|
53
|
+
expect(typeof serverInfo.port).toBe('number');
|
|
54
|
+
expect(typeof serverInfo.nonce).toBe('string');
|
|
55
|
+
expect(typeof serverInfo.waitForCallback).toBe('function');
|
|
56
|
+
|
|
57
|
+
expect(serverInfo.port).toBeGreaterThanOrEqual(8880);
|
|
58
|
+
expect(serverInfo.port).toBeLessThanOrEqual(8899);
|
|
59
|
+
|
|
60
|
+
// Don't call waitForCallback - that would start the server listening
|
|
61
|
+
// Just verify the shape is correct
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('should receive credentials on successful callback', async () => {
|
|
65
|
+
const { port, nonce, waitForCallback } = await startCallbackServer();
|
|
66
|
+
|
|
67
|
+
// Start waiting for callback
|
|
68
|
+
const callbackPromise = waitForCallback();
|
|
69
|
+
|
|
70
|
+
// Simulate OAuth relay callback
|
|
71
|
+
const callbackUrl = `http://127.0.0.1:${port}/callback?` +
|
|
72
|
+
`nonce=${nonce}&` +
|
|
73
|
+
`access_token=test-access-token&` +
|
|
74
|
+
`person_id=test-person-id&` +
|
|
75
|
+
`expires_in=5184000`;
|
|
76
|
+
|
|
77
|
+
// Make HTTP request to callback endpoint
|
|
78
|
+
await new Promise((resolve, reject) => {
|
|
79
|
+
const url = new URL(callbackUrl);
|
|
80
|
+
const req = http.request({
|
|
81
|
+
hostname: '127.0.0.1',
|
|
82
|
+
port: port,
|
|
83
|
+
path: url.pathname + url.search,
|
|
84
|
+
method: 'GET'
|
|
85
|
+
}, (res) => {
|
|
86
|
+
expect(res.statusCode).toBe(200);
|
|
87
|
+
resolve();
|
|
88
|
+
});
|
|
89
|
+
req.on('error', reject);
|
|
90
|
+
req.end();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// Wait for credentials
|
|
94
|
+
const credentials = await callbackPromise;
|
|
95
|
+
|
|
96
|
+
expect(credentials).toEqual({
|
|
97
|
+
accessToken: 'test-access-token',
|
|
98
|
+
personId: 'test-person-id',
|
|
99
|
+
refreshToken: null,
|
|
100
|
+
expiresAt: expect.any(Number)
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('should reject on nonce mismatch', async () => {
|
|
105
|
+
const { port, waitForCallback } = await startCallbackServer();
|
|
106
|
+
|
|
107
|
+
// Start waiting FIRST and attach catch handler immediately
|
|
108
|
+
const callbackPromise = waitForCallback();
|
|
109
|
+
let rejection = null;
|
|
110
|
+
callbackPromise.catch(err => { rejection = err; });
|
|
111
|
+
|
|
112
|
+
// Callback with wrong nonce
|
|
113
|
+
const callbackUrl = `http://127.0.0.1:${port}/callback?` +
|
|
114
|
+
`nonce=wrong-nonce&` +
|
|
115
|
+
`access_token=test-token&` +
|
|
116
|
+
`person_id=test-person`;
|
|
117
|
+
|
|
118
|
+
// Make the request
|
|
119
|
+
await new Promise((resolve) => {
|
|
120
|
+
const url = new URL(callbackUrl);
|
|
121
|
+
const req = http.request({
|
|
122
|
+
hostname: '127.0.0.1',
|
|
123
|
+
port: port,
|
|
124
|
+
path: url.pathname + url.search,
|
|
125
|
+
method: 'GET'
|
|
126
|
+
}, (res) => {
|
|
127
|
+
expect(res.statusCode).toBe(400);
|
|
128
|
+
resolve();
|
|
129
|
+
});
|
|
130
|
+
req.on('error', resolve);
|
|
131
|
+
req.end();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// Wait a tick for the rejection to be captured
|
|
135
|
+
await new Promise(r => setTimeout(r, 10));
|
|
136
|
+
|
|
137
|
+
expect(rejection).not.toBeNull();
|
|
138
|
+
expect(rejection.message).toContain('Nonce mismatch');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('should reject on OAuth error', async () => {
|
|
142
|
+
const { port, nonce, waitForCallback } = await startCallbackServer();
|
|
143
|
+
|
|
144
|
+
// Start waiting FIRST and attach catch handler immediately
|
|
145
|
+
const callbackPromise = waitForCallback();
|
|
146
|
+
let rejection = null;
|
|
147
|
+
callbackPromise.catch(err => { rejection = err; });
|
|
148
|
+
|
|
149
|
+
// Callback with error
|
|
150
|
+
const callbackUrl = `http://127.0.0.1:${port}/callback?` +
|
|
151
|
+
`nonce=${nonce}&` +
|
|
152
|
+
`error=access_denied&` +
|
|
153
|
+
`error_description=User+denied+access`;
|
|
154
|
+
|
|
155
|
+
await new Promise((resolve) => {
|
|
156
|
+
const url = new URL(callbackUrl);
|
|
157
|
+
const req = http.request({
|
|
158
|
+
hostname: '127.0.0.1',
|
|
159
|
+
port: port,
|
|
160
|
+
path: url.pathname + url.search,
|
|
161
|
+
method: 'GET'
|
|
162
|
+
}, (res) => {
|
|
163
|
+
expect(res.statusCode).toBe(400);
|
|
164
|
+
resolve();
|
|
165
|
+
});
|
|
166
|
+
req.on('error', resolve);
|
|
167
|
+
req.end();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// Wait a tick for the rejection to be captured
|
|
171
|
+
await new Promise(r => setTimeout(r, 10));
|
|
172
|
+
|
|
173
|
+
expect(rejection).not.toBeNull();
|
|
174
|
+
expect(rejection.message).toContain('OAuth error');
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
});
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Tests for token storage module
|
|
3
|
+
* Tests the OS keychain credential storage functionality
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// Mock @napi-rs/keyring before requiring the module
|
|
7
|
+
const mockSetPassword = jest.fn();
|
|
8
|
+
const mockGetPassword = jest.fn();
|
|
9
|
+
const mockDeletePassword = jest.fn();
|
|
10
|
+
|
|
11
|
+
jest.mock('@napi-rs/keyring', () => ({
|
|
12
|
+
Entry: jest.fn().mockImplementation(() => ({
|
|
13
|
+
setPassword: mockSetPassword,
|
|
14
|
+
getPassword: mockGetPassword,
|
|
15
|
+
deletePassword: mockDeletePassword
|
|
16
|
+
}))
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
const { Entry } = require('@napi-rs/keyring');
|
|
20
|
+
const {
|
|
21
|
+
storeCredentials,
|
|
22
|
+
getCredentials,
|
|
23
|
+
deleteCredentials,
|
|
24
|
+
hasCredentials
|
|
25
|
+
} = require('../../src/auth/token-storage');
|
|
26
|
+
|
|
27
|
+
describe('Token Storage Module', () => {
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
jest.clearAllMocks();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('storeCredentials', () => {
|
|
33
|
+
it('should store credentials as JSON in keychain', () => {
|
|
34
|
+
const credentials = {
|
|
35
|
+
accessToken: 'test-token-123',
|
|
36
|
+
personId: 'test-person-456',
|
|
37
|
+
refreshToken: 'refresh-789',
|
|
38
|
+
expiresAt: Date.now() + 3600000
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
storeCredentials(credentials);
|
|
42
|
+
|
|
43
|
+
expect(Entry).toHaveBeenCalledWith('mcp-linkedin', 'oauth-credentials');
|
|
44
|
+
expect(mockSetPassword).toHaveBeenCalledWith(JSON.stringify(credentials));
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('should store minimal credentials', () => {
|
|
48
|
+
const credentials = {
|
|
49
|
+
accessToken: 'test-token',
|
|
50
|
+
personId: 'test-person'
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
storeCredentials(credentials);
|
|
54
|
+
|
|
55
|
+
expect(mockSetPassword).toHaveBeenCalledWith(JSON.stringify(credentials));
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('getCredentials', () => {
|
|
60
|
+
it('should retrieve and parse credentials from keychain', () => {
|
|
61
|
+
const storedData = {
|
|
62
|
+
accessToken: 'stored-token',
|
|
63
|
+
personId: 'stored-person',
|
|
64
|
+
refreshToken: null,
|
|
65
|
+
expiresAt: null
|
|
66
|
+
};
|
|
67
|
+
mockGetPassword.mockReturnValue(JSON.stringify(storedData));
|
|
68
|
+
|
|
69
|
+
const result = getCredentials();
|
|
70
|
+
|
|
71
|
+
expect(Entry).toHaveBeenCalledWith('mcp-linkedin', 'oauth-credentials');
|
|
72
|
+
expect(result).toEqual(storedData);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('should return null when no credentials stored', () => {
|
|
76
|
+
mockGetPassword.mockReturnValue(null);
|
|
77
|
+
|
|
78
|
+
const result = getCredentials();
|
|
79
|
+
|
|
80
|
+
expect(result).toBeNull();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('should return null on keychain access error', () => {
|
|
84
|
+
mockGetPassword.mockImplementation(() => {
|
|
85
|
+
throw new Error('Access denied');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const result = getCredentials();
|
|
89
|
+
|
|
90
|
+
expect(result).toBeNull();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('should return null for empty string', () => {
|
|
94
|
+
mockGetPassword.mockReturnValue('');
|
|
95
|
+
|
|
96
|
+
const result = getCredentials();
|
|
97
|
+
|
|
98
|
+
expect(result).toBeNull();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('deleteCredentials', () => {
|
|
103
|
+
it('should delete credentials from keychain', () => {
|
|
104
|
+
mockDeletePassword.mockReturnValue(undefined);
|
|
105
|
+
|
|
106
|
+
const result = deleteCredentials();
|
|
107
|
+
|
|
108
|
+
expect(Entry).toHaveBeenCalledWith('mcp-linkedin', 'oauth-credentials');
|
|
109
|
+
expect(mockDeletePassword).toHaveBeenCalled();
|
|
110
|
+
expect(result).toBe(true);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('should return false when credentials not found', () => {
|
|
114
|
+
mockDeletePassword.mockImplementation(() => {
|
|
115
|
+
throw new Error('Item not found');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const result = deleteCredentials();
|
|
119
|
+
|
|
120
|
+
expect(result).toBe(false);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe('hasCredentials', () => {
|
|
125
|
+
it('should return true when credentials exist', () => {
|
|
126
|
+
mockGetPassword.mockReturnValue(JSON.stringify({ accessToken: 'test' }));
|
|
127
|
+
|
|
128
|
+
const result = hasCredentials();
|
|
129
|
+
|
|
130
|
+
expect(result).toBe(true);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('should return false when no credentials', () => {
|
|
134
|
+
mockGetPassword.mockReturnValue(null);
|
|
135
|
+
|
|
136
|
+
const result = hasCredentials();
|
|
137
|
+
|
|
138
|
+
expect(result).toBe(false);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
});
|