@ldraney/mcp-linkedin 0.2.3 → 0.3.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/CLAUDE.md CHANGED
@@ -7,7 +7,7 @@ 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 # 18 tool implementations
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
@@ -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 (97 passing)
77
+ npm test # Run tests (118 passing)
77
78
  ```
78
79
 
79
80
  ## Roadmap
package/README.md CHANGED
@@ -1,115 +1,82 @@
1
1
  # mcp-linkedin
2
2
 
3
- MCP server for managing LinkedIn posts - create text, image, video, document, poll, and multi-image posts. Schedule posts, add comments and reactions.
3
+ **Post to LinkedIn from Claude Desktop.**
4
4
 
5
- ## Installation
5
+ Install in 30 seconds. Authenticate once. Post forever.
6
6
 
7
- ### MCPB Bundle (One-Click)
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
- ### npm / npx
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
- ### Manual MCP Config
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
- ## Tools (20 Total)
35
-
36
- ### Content Creation
37
- | Tool | Description |
38
- |------|-------------|
39
- | `linkedin_create_post` | Create text posts |
40
- | `linkedin_create_post_with_link` | Posts with article/link preview |
41
- | `linkedin_create_post_with_image` | Upload image + create post |
42
- | `linkedin_create_post_with_video` | Upload video + create post |
43
- | `linkedin_create_post_with_document` | Upload PDF/PPT/DOC + create post |
44
- | `linkedin_create_post_with_multi_images` | Upload 2-20 images + create post |
45
- | `linkedin_create_poll` | Create poll posts (2-4 options) |
46
-
47
- ### Content Management
48
- | Tool | Description |
49
- |------|-------------|
50
- | `linkedin_get_my_posts` | Retrieve recent posts (paginated) |
51
- | `linkedin_update_post` | Edit existing posts |
52
- | `linkedin_delete_post` | Delete by URN |
53
-
54
- ### Social Interactions
55
- | Tool | Description |
56
- |------|-------------|
57
- | `linkedin_add_comment` | Add comment to a post |
58
- | `linkedin_add_reaction` | React to a post (LIKE, PRAISE, etc.) |
59
-
60
- ### Scheduling
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
- This runs a daemon that checks every minute for posts due to publish.
50
+ Your credentials stay on your machine. The OAuth relay only handles the initial handshake.
97
51
 
98
- ## Development
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 # 118 tests
103
- npm run test:coverage # Coverage report
104
- npm start # Start MCP server
105
- npm run scheduler # Start scheduler daemon
76
+ npm test # 118 tests
106
77
  ```
107
78
 
108
- ## Documentation
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,65 @@
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
+ - Tokens are **never** stored in plain text files, environment variables are only used as a fallback for development
19
+ - The OAuth relay server (`fly.io`) only handles the initial handshake redirect -- it never sees or stores your access token
20
+
21
+ ### Data Flow
22
+
23
+ ```
24
+ Your Machine External
25
+ ┌─────────────────┐ ┌──────────────┐
26
+ │ Claude Desktop │ │ │
27
+ │ ├─ mcp-linkedin│──────────►│ LinkedIn API │
28
+ │ └─ OS Keychain│ │ │
29
+ └─────────────────┘ └──────────────┘
30
+
31
+ │ (OAuth only)
32
+
33
+ ┌─────────────────┐
34
+ │ OAuth Relay │
35
+ │ (fly.io) │
36
+ │ Redirect only │
37
+ └─────────────────┘
38
+ ```
39
+
40
+ - All LinkedIn API calls go **directly** from your machine to LinkedIn
41
+ - No telemetry, analytics, or data collection
42
+ - Scheduled posts are stored in a **local** SQLite database on your machine
43
+
44
+ ### OAuth Security
45
+
46
+ - CSRF protection via cryptographic nonces on every OAuth flow
47
+ - Tokens are captured by a local callback server and stored directly in your keychain
48
+ - The `w_member_social` scope is the only permission requested (post and interact on your behalf)
49
+
50
+ ### What We Don't Do
51
+
52
+ - We never store your credentials on any remote server
53
+ - We never log or transmit your LinkedIn data
54
+ - We never access your LinkedIn connections, messages, or profile data beyond what you explicitly post
55
+
56
+ ## Supported Versions
57
+
58
+ | Version | Supported |
59
+ |---------|-----------|
60
+ | 0.2.x | Yes |
61
+ | < 0.2 | No |
62
+
63
+ ## Dependencies
64
+
65
+ 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
+ });
@@ -13,6 +13,22 @@ const LinkedInAPI = require('../src/linkedin-api');
13
13
  // Mock the database module
14
14
  jest.mock('../src/database');
15
15
 
16
+ // Mock the auth modules
17
+ jest.mock('../src/auth/local-server', () => ({
18
+ startCallbackServer: jest.fn().mockResolvedValue({
19
+ port: 8880,
20
+ nonce: 'test-nonce-1234567890abcdef',
21
+ waitForCallback: jest.fn().mockReturnValue(new Promise(() => {})) // Never resolves in tests
22
+ })
23
+ }));
24
+
25
+ jest.mock('../src/auth/token-storage', () => ({
26
+ storeCredentials: jest.fn(),
27
+ getCredentials: jest.fn().mockReturnValue(null),
28
+ deleteCredentials: jest.fn(),
29
+ hasCredentials: jest.fn().mockReturnValue(false)
30
+ }));
31
+
16
32
  // Mock environment variables
17
33
  process.env.LINKEDIN_CLIENT_ID = 'test_client_id';
18
34
  process.env.LINKEDIN_CLIENT_SECRET = 'test_secret';
@@ -285,17 +301,19 @@ describe('LinkedIn MCP Tools - TDD', () => {
285
301
  expect(result).toHaveProperty('authUrl');
286
302
  expect(result).toHaveProperty('state');
287
303
  expect(result).toHaveProperty('instructions');
288
- expect(result.authUrl).toContain('linkedin.com/oauth');
289
304
  });
290
305
 
291
- it('should include required OAuth parameters in URL', async () => {
306
+ it('should return OAuth relay URL with local callback params', async () => {
292
307
  const result = await tools.linkedin_get_auth_url();
293
308
 
294
- const url = new URL(result.authUrl);
295
- expect(url.searchParams.get('response_type')).toBe('code');
296
- expect(url.searchParams.get('client_id')).toBe('test_client_id');
297
- expect(url.searchParams.get('redirect_uri')).toBe('https://localhost:3000/callback');
298
- expect(url.searchParams.get('scope')).toContain('w_member_social');
309
+ // Uses OAuth relay service with local callback server
310
+ expect(result.authUrl).toContain('/auth/linkedin');
311
+ expect(result.authUrl).toContain('port=');
312
+ expect(result.authUrl).toContain('nonce=');
313
+ // State is now a cryptographic nonce, not a static string
314
+ expect(result.state).toBeTruthy();
315
+ expect(result.state.length).toBeGreaterThan(20);
316
+ expect(result.instructions).toContain('authenticate with LinkedIn');
299
317
  });
300
318
  });
301
319