Skip to content

Commit d173d83

Browse files
authored
Implement the old token auth method (#2769)
1 parent 64b905a commit d173d83

31 files changed

+2054
-89
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
- Added endpoints for performing OAuth Device Authorization Grant with Posit Cloud login. (#2692)
1313
- Added support to publishing schema for Connect Cloud. (#2729, #2747)
14+
- Added support for one-click token authentication with Connect. (#2769)
1415

1516
### Changed
1617

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
// Copyright (C) 2025 by Posit Software, PBC.
2+
3+
import { describe, expect, test, vi, beforeEach } from "vitest";
4+
import { AxiosInstance } from "axios";
5+
import { Credentials } from "./Credentials";
6+
import { ServerType } from "../types/contentRecords";
7+
8+
// Simple mock for the axios client
9+
const mockAxiosPost = vi.fn();
10+
const mockAxiosClient = {
11+
post: mockAxiosPost,
12+
get: vi.fn(),
13+
delete: vi.fn(),
14+
};
15+
16+
describe("Credentials API client", () => {
17+
let credentials: Credentials;
18+
19+
beforeEach(() => {
20+
// Reset mocks before each test
21+
vi.clearAllMocks();
22+
credentials = new Credentials(mockAxiosClient as unknown as AxiosInstance);
23+
});
24+
25+
test("create supports token authentication parameters", async () => {
26+
// Setup mock response
27+
mockAxiosPost.mockResolvedValue({ data: { guid: "test-guid" } });
28+
29+
// Call create with token parameters
30+
await credentials.create(
31+
"Test Credential",
32+
"https://connect.example.com",
33+
"",
34+
"",
35+
"",
36+
"",
37+
"",
38+
"",
39+
ServerType.CONNECT,
40+
"test-token-123",
41+
"test-private-key-123",
42+
);
43+
44+
// Verify correct parameters were passed to axios post
45+
expect(mockAxiosPost).toHaveBeenCalledWith(
46+
"credentials",
47+
{
48+
name: "Test Credential",
49+
url: "https://connect.example.com",
50+
apiKey: "",
51+
snowflakeConnection: "",
52+
accountId: "",
53+
accountName: "",
54+
refreshToken: "",
55+
accessToken: "",
56+
serverType: ServerType.CONNECT,
57+
token: "test-token-123",
58+
privateKey: "test-private-key-123",
59+
},
60+
{
61+
headers: {
62+
"Connect-Cloud-Environment": "production",
63+
},
64+
},
65+
);
66+
});
67+
68+
test("generateToken calls the correct endpoint with server URL", async () => {
69+
// Setup mock response
70+
mockAxiosPost.mockResolvedValue({
71+
data: {
72+
token: "test-token-123",
73+
claimUrl: "https://connect.example.com/claim/123",
74+
privateKey: "test-private-key-123",
75+
},
76+
});
77+
78+
// Call generateToken
79+
await credentials.generateToken("https://connect.example.com");
80+
81+
// Verify correct parameters were passed to axios post
82+
expect(mockAxiosPost).toHaveBeenCalledWith("connect/token", {
83+
serverUrl: "https://connect.example.com",
84+
});
85+
});
86+
87+
test("verifyToken calls the correct endpoint with token parameters", async () => {
88+
// Setup mock response
89+
mockAxiosPost.mockResolvedValue({
90+
data: { username: "testuser", guid: "user-123" },
91+
});
92+
93+
// Call verifyToken
94+
await credentials.verifyToken(
95+
"https://connect.example.com",
96+
"test-token-123",
97+
"test-private-key-123",
98+
);
99+
100+
// Verify correct parameters were passed to axios post
101+
expect(mockAxiosPost).toHaveBeenCalledWith("connect/token/user", {
102+
serverUrl: "https://connect.example.com",
103+
token: "test-token-123",
104+
privateKey: "test-private-key-123",
105+
});
106+
});
107+
});

extensions/vscode/src/api/resources/Credentials.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ export class Credentials {
3434
refreshToken: string,
3535
accessToken: string,
3636
serverType: ServerType,
37+
token?: string,
38+
privateKey?: string,
3739
) {
3840
return this.client.post<Credential>(
3941
`credentials`,
@@ -47,6 +49,8 @@ export class Credentials {
4749
refreshToken,
4850
accessToken,
4951
serverType,
52+
token,
53+
privateKey,
5054
},
5155
{ headers: CONNECT_CLOUD_ENV_HEADER },
5256
);
@@ -92,4 +96,30 @@ export class Credentials {
9296
insecure,
9397
});
9498
}
99+
100+
// Generates a new token for Connect authentication
101+
// Returns token ID, claim URL, and private key
102+
generateToken(serverUrl: string) {
103+
return this.client.post<{
104+
token: string;
105+
claimUrl: string;
106+
privateKey: string;
107+
}>(`connect/token`, {
108+
serverUrl,
109+
});
110+
}
111+
112+
// Verifies if a token has been claimed
113+
// Returns the user information if the token has been claimed
114+
verifyToken(serverUrl: string, token: string, privateKey: string) {
115+
return this.client.post<{
116+
username?: string;
117+
guid?: string;
118+
[key: string]: unknown;
119+
}>(`connect/token/user`, {
120+
serverUrl,
121+
token,
122+
privateKey,
123+
});
124+
}
95125
}
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
// Copyright (C) 2025 by Posit Software, PBC.
2+
3+
import { describe, expect, test, vi, beforeEach } from "vitest";
4+
import { window, env } from "vscode";
5+
import { ConnectAuthTokenActivator } from "./ConnectAuthTokenActivator";
6+
import { useApi } from "src/api";
7+
8+
// Mock dependencies
9+
vi.mock("vscode", () => ({
10+
window: {
11+
createOutputChannel: vi.fn(() => ({
12+
appendLine: vi.fn(),
13+
})),
14+
showInformationMessage: vi.fn(),
15+
showErrorMessage: vi.fn(),
16+
},
17+
Uri: {
18+
parse: vi.fn(),
19+
},
20+
env: {
21+
openExternal: vi.fn(),
22+
},
23+
}));
24+
25+
vi.mock("src/api", () => ({
26+
useApi: vi.fn(),
27+
}));
28+
29+
vi.mock("src/utils/progress", () => ({
30+
showProgress: vi.fn((_, __, callback) => callback()),
31+
}));
32+
33+
vi.mock("src/utils/errors", () => ({
34+
getMessageFromError: vi.fn((error) => error?.message || "Unknown error"),
35+
}));
36+
37+
// Type guards for mocked functions
38+
const mockUseApi = vi.mocked(useApi);
39+
const mockShowInformationMessage = vi.mocked(window.showInformationMessage);
40+
const mockShowErrorMessage = vi.mocked(window.showErrorMessage);
41+
const mockOpenExternal = vi.mocked(env.openExternal);
42+
43+
describe("ConnectAuthTokenActivator", () => {
44+
let activator: ConnectAuthTokenActivator;
45+
let mockApi: {
46+
credentials: {
47+
generateToken: ReturnType<typeof vi.fn>;
48+
verifyToken: ReturnType<typeof vi.fn>;
49+
};
50+
};
51+
52+
beforeEach(() => {
53+
vi.clearAllMocks();
54+
55+
mockApi = {
56+
credentials: {
57+
generateToken: vi.fn(),
58+
verifyToken: vi.fn(),
59+
},
60+
};
61+
mockUseApi.mockResolvedValue(
62+
mockApi as unknown as Awaited<ReturnType<typeof useApi>>,
63+
);
64+
65+
activator = new ConnectAuthTokenActivator(
66+
"https://connect.example.com",
67+
"test-view-id",
68+
);
69+
});
70+
71+
test("constructor initializes properties correctly", () => {
72+
expect(activator).toBeDefined();
73+
});
74+
75+
test("initialize() calls useApi and sets up the API client", async () => {
76+
await activator.initialize();
77+
expect(mockUseApi).toHaveBeenCalledOnce();
78+
});
79+
80+
test("activateToken() completes the full token authentication flow", async () => {
81+
// Setup mocks
82+
mockApi.credentials.generateToken.mockResolvedValue({
83+
data: {
84+
token: "test-token-123",
85+
claimUrl: "https://connect.example.com/claim/123",
86+
privateKey: "test-private-key-123",
87+
},
88+
});
89+
90+
mockApi.credentials.verifyToken.mockResolvedValue({
91+
status: 200,
92+
data: { username: "testuser" },
93+
});
94+
95+
// Initialize and run
96+
await activator.initialize();
97+
const result = await activator.activateToken();
98+
99+
// Verify the flow
100+
expect(mockApi.credentials.generateToken).toHaveBeenCalledWith(
101+
"https://connect.example.com",
102+
);
103+
expect(mockOpenExternal).toHaveBeenCalledWith(
104+
expect.objectContaining({}), // Uri.parse result
105+
);
106+
expect(mockApi.credentials.verifyToken).toHaveBeenCalledWith(
107+
"https://connect.example.com",
108+
"test-token-123",
109+
"test-private-key-123",
110+
);
111+
expect(mockShowInformationMessage).toHaveBeenCalledWith(
112+
"Successfully authenticated as testuser",
113+
);
114+
115+
// Verify result
116+
expect(result).toEqual({
117+
token: "test-token-123",
118+
privateKey: "test-private-key-123",
119+
userName: "testuser",
120+
});
121+
});
122+
123+
test("activateToken() handles token verification polling with retries", async () => {
124+
// Setup mocks
125+
mockApi.credentials.generateToken.mockResolvedValue({
126+
data: {
127+
token: "test-token-123",
128+
claimUrl: "https://connect.example.com/claim/123",
129+
privateKey: "test-private-key-123",
130+
},
131+
});
132+
133+
// Mock verification to fail a few times, then succeed
134+
mockApi.credentials.verifyToken
135+
.mockRejectedValueOnce(new Error("401 Unauthorized"))
136+
.mockRejectedValueOnce(new Error("401 Unauthorized"))
137+
.mockResolvedValueOnce({
138+
status: 200,
139+
data: { username: "testuser" },
140+
});
141+
142+
// Initialize and run
143+
await activator.initialize();
144+
const result = await activator.activateToken();
145+
146+
// Verify multiple verification attempts
147+
expect(mockApi.credentials.verifyToken).toHaveBeenCalledTimes(3);
148+
expect(result.userName).toBe("testuser");
149+
});
150+
151+
test("activateToken() throws error when not initialized", async () => {
152+
// Don't call initialize()
153+
await expect(activator.activateToken()).rejects.toThrow(
154+
"ConnectAuthTokenActivator must be initialized before use",
155+
);
156+
});
157+
158+
test("activateToken() handles token generation failure", async () => {
159+
mockApi.credentials.generateToken.mockRejectedValue(
160+
new Error("Failed to generate token"),
161+
);
162+
163+
await activator.initialize();
164+
165+
await expect(activator.activateToken()).rejects.toThrow(
166+
"Failed to generate token",
167+
);
168+
expect(mockShowErrorMessage).toHaveBeenCalledWith(
169+
"Failed to complete token authentication: Failed to generate token",
170+
);
171+
});
172+
173+
test("activateToken() handles timeout when token claim takes too long", async () => {
174+
// Setup mocks
175+
mockApi.credentials.generateToken.mockResolvedValue({
176+
data: {
177+
token: "test-token-123",
178+
claimUrl: "https://connect.example.com/claim/123",
179+
privateKey: "test-private-key-123",
180+
},
181+
});
182+
183+
// Mock verification to always fail (simulating timeout)
184+
mockApi.credentials.verifyToken.mockRejectedValue(
185+
new Error("401 Unauthorized"),
186+
);
187+
188+
// Create activator with reduced maxAttempts for faster testing
189+
const fastActivator = new ConnectAuthTokenActivator(
190+
"https://connect.example.com",
191+
"test-view-id",
192+
2, // maxAttempts
193+
);
194+
await fastActivator.initialize();
195+
196+
await expect(fastActivator.activateToken()).rejects.toThrow(
197+
"Token claim process timed out or was cancelled",
198+
);
199+
});
200+
});

0 commit comments

Comments
 (0)