Skip to content

Commit 66e426a

Browse files
committed
wip: OAuth.
1 parent 90d6a2a commit 66e426a

17 files changed

+1111
-24
lines changed

.gitignore

+2
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,5 @@ node_modules/
55
.idea/
66
build/
77
out/
8+
src/typings/vscode.d.ts
9+
src/typings/vscode.proposed.d.ts

package.json

+16-6
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
{
2-
"name": "cat-coding",
2+
"name": "coding-plugin",
33
"description": "Cat Coding - A Webview API Sample",
44
"version": "0.0.1",
5-
"publisher": "cangzhang",
5+
"publisher": "alcheung",
66
"license": "MIT",
7+
"enableProposedApi": true,
78
"engines": {
89
"vscode": "^1.47.0"
910
},
@@ -12,6 +13,7 @@
1213
],
1314
"activationEvents": [
1415
"onCommand:catCoding.show",
16+
"onCommand:catCoding.login",
1517
"onWebviewPanel:catCoding",
1618
"onView:treeviewSample"
1719
],
@@ -26,14 +28,19 @@
2628
"command": "catCoding.show",
2729
"title": "Start cat coding session",
2830
"category": "Cat Coding"
31+
},
32+
{
33+
"command": "catCoding.login",
34+
"title": "Login coding.net",
35+
"category": "Cat Coding"
2936
}
3037
],
3138
"viewsContainers": {
3239
"activitybar": [
3340
{
3441
"id": "treeview-sample",
3542
"title": "Treeview Sample",
36-
"icon": "src/coding.svg"
43+
"icon": "src/assets/coding.svg"
3744
}
3845
]
3946
},
@@ -42,13 +49,14 @@
4249
{
4350
"id": "treeviewSample",
4451
"name": "Treeview Sample",
45-
"icon": "src/coding.svg",
52+
"icon": "src/assets/coding.svg",
4653
"contextualTitle": "List"
4754
}
4855
]
4956
}
5057
},
5158
"scripts": {
59+
"postinstall": "cd src/typings && npx vscode-dts master && npx vscode-dts dev master",
5260
"vscode:prepublish": "npm run compile",
5361
"compile": "npm-run-all -p compile:*",
5462
"compile:extension": "tsc -p ./src",
@@ -61,14 +69,16 @@
6169
"dependencies": {
6270
"@risingstack/react-easy-state": "^6.3.0",
6371
"got": "^11.7.0",
72+
"keytar": "^7.0.0",
6473
"ky": "^0.24.0",
74+
"nanoid": "^3.1.16",
6575
"react": "^17.0.0",
66-
"react-dom": "^17.0.0"
76+
"react-dom": "^17.0.0",
77+
"simple-git": "^2.21.0"
6778
},
6879
"devDependencies": {
6980
"@types/react": "^16.9.53",
7081
"@types/react-dom": "^16.9.8",
71-
"@types/vscode": "^1.47.0",
7282
"@typescript-eslint/eslint-plugin": "^3.0.2",
7383
"@typescript-eslint/parser": "^3.0.2",
7484
"css-loader": "^5.0.0",
File renamed without changes.
File renamed without changes.
File renamed without changes.

src/coding.ts

+178
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import * as vscode from 'vscode';
2+
import { nanoid } from 'nanoid';
3+
4+
import { keychain } from './common/keychain';
5+
import Logger from './common/logger'
6+
import { CodingServer } from './codingServer';
7+
8+
interface SessionData {
9+
id: string;
10+
account?: {
11+
label?: string;
12+
displayName?: string;
13+
id: string;
14+
}
15+
scopes: string[];
16+
accessToken: string;
17+
}
18+
19+
export const ScopeList = [
20+
`user`,
21+
`user:email`,
22+
`project`,
23+
`project:depot`,
24+
];
25+
const SCOPES = ScopeList.join(`,`);
26+
const NETWORK_ERROR = 'network error';
27+
28+
export const onDidChangeSessions = new vscode.EventEmitter<vscode.AuthenticationProviderAuthenticationSessionsChangeEvent>();
29+
30+
export class CodingAuthenticationProvider {
31+
private _sessions: vscode.AuthenticationSession[] = [];
32+
private _codingServer = new CodingServer();
33+
private _team: string;
34+
35+
public constructor(team: string) {
36+
this._team = team;
37+
}
38+
39+
public async initialize(context: vscode.ExtensionContext): Promise<void> {
40+
try {
41+
this._sessions = await this._readSessions();
42+
} catch (e) {
43+
// Ignore, network request failed
44+
}
45+
46+
context.subscriptions.push(vscode.authentication.onDidChangePassword(() => this._checkForUpdates()));
47+
}
48+
49+
private async _checkForUpdates() {
50+
let storedSessions: vscode.AuthenticationSession[];
51+
try {
52+
storedSessions = await this._readSessions();
53+
} catch (e) {
54+
// Ignore, network request failed
55+
return;
56+
}
57+
58+
const added: string[] = [];
59+
const removed: string[] = [];
60+
61+
storedSessions.forEach(session => {
62+
const matchesExisting = this._sessions.some(s => s.id === session.id);
63+
// Another window added a session to the keychain, add it to our state as well
64+
if (!matchesExisting) {
65+
Logger.info('Adding session found in keychain');
66+
this._sessions.push(session);
67+
added.push(session.id);
68+
}
69+
});
70+
71+
this._sessions.map(session => {
72+
const matchesExisting = storedSessions.some(s => s.id === session.id);
73+
// Another window has logged out, remove from our state
74+
if (!matchesExisting) {
75+
Logger.info('Removing session no longer found in keychain');
76+
const sessionIndex = this._sessions.findIndex(s => s.id === session.id);
77+
if (sessionIndex > -1) {
78+
this._sessions.splice(sessionIndex, 1);
79+
}
80+
81+
removed.push(session.id);
82+
}
83+
});
84+
85+
if (added.length || removed.length) {
86+
onDidChangeSessions.fire({ added, removed, changed: [] });
87+
}
88+
}
89+
90+
private async _readSessions(): Promise<vscode.AuthenticationSession[]> {
91+
const storedSessions = await keychain.getToken() || await keychain.tryMigrate();
92+
if (storedSessions) {
93+
try {
94+
const sessionData: SessionData[] = JSON.parse(storedSessions);
95+
const sessionPromises = sessionData.map(async (session: SessionData): Promise<vscode.AuthenticationSession> => {
96+
const needsUserInfo = !session.account;
97+
let userInfo: { id: string, accountName: string };
98+
if (needsUserInfo) {
99+
userInfo = await this._codingServer.getUserInfo(this._team, session.accessToken);
100+
}
101+
102+
return {
103+
id: session.id,
104+
account: {
105+
label: session.account
106+
? session.account.label || session.account.displayName!
107+
: userInfo!.accountName,
108+
id: session.account?.id ?? userInfo!.id
109+
},
110+
scopes: session.scopes,
111+
accessToken: session.accessToken
112+
};
113+
});
114+
115+
return Promise.all(sessionPromises);
116+
} catch (e) {
117+
if (e === NETWORK_ERROR) {
118+
return [];
119+
}
120+
121+
Logger.error(`Error reading sessions: ${e}`);
122+
await keychain.deleteToken();
123+
}
124+
}
125+
126+
return [];
127+
}
128+
129+
private async _tokenToSession(team: string, token: string, scopes: string[]): Promise<vscode.AuthenticationSession> {
130+
const userInfo = await this._codingServer.getUserInfo(team, token);
131+
132+
return {
133+
id: nanoid(),
134+
accessToken: token,
135+
account: {
136+
label: userInfo.name,
137+
id: userInfo.global_key,
138+
},
139+
scopes,
140+
};
141+
}
142+
143+
public async login(team: string, scopes: string = SCOPES): Promise<vscode.AuthenticationSession> {
144+
const { access_token: token } = await this._codingServer.login(team, scopes);
145+
const session = await this._tokenToSession(team, token, scopes.split(' '));
146+
await this._setToken(session);
147+
return session;
148+
}
149+
150+
private async _storeSessions(): Promise<void> {
151+
await keychain.setToken(JSON.stringify(this._sessions));
152+
}
153+
154+
private async _setToken(session: vscode.AuthenticationSession): Promise<void> {
155+
const sessionIndex = this._sessions.findIndex(s => s.id === session.id);
156+
if (sessionIndex > -1) {
157+
this._sessions.splice(sessionIndex, 1, session);
158+
} else {
159+
this._sessions.push(session);
160+
}
161+
162+
await this._storeSessions();
163+
}
164+
165+
// @ts-ignore
166+
get sessions(): vscode.AuthenticationSession[] {
167+
return this._sessions;
168+
}
169+
170+
public async logout(id: string) {
171+
const sessionIndex = this._sessions.findIndex(session => session.id === id);
172+
if (sessionIndex > -1) {
173+
this._sessions.splice(sessionIndex, 1);
174+
}
175+
176+
await this._storeSessions();
177+
}
178+
}

src/codingServer.ts

+100
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import * as vscode from 'vscode';
2+
import { nanoid } from 'nanoid'
3+
import got from 'got';
4+
5+
import { PromiseAdapter, promiseFromEvent, parseQuery } from './common/utils';
6+
import { AuthFailResult, AuthSuccessResult, UserResponse } from './typings/ResponseResult';
7+
8+
const AUTH_SERVER = `http://127.0.0.1:5000`;
9+
const ClientId = `ff768664c96d04235b1cc4af1e3b37a8`;
10+
const ClientSecret = `d29ebb32cab8b5f0a643b5da7dcad8d1469312c7`;
11+
12+
class UriEventHandler extends vscode.EventEmitter<vscode.Uri> implements vscode.UriHandler {
13+
public handleUri(uri: vscode.Uri) {
14+
this.fire(uri);
15+
}
16+
}
17+
18+
export const uriHandler = new UriEventHandler;
19+
20+
const onDidManuallyProvideToken = new vscode.EventEmitter<string>();
21+
22+
export class CodingServer {
23+
private _pendingStates = new Map<string, string[]>();
24+
private _codeExchangePromises = new Map<string, Promise<AuthSuccessResult>>();
25+
26+
public async login(team: string, scopes: string) {
27+
const state = nanoid();
28+
const { name, publisher } = require('../package.json');
29+
const callbackUri = await vscode.env.asExternalUri(vscode.Uri.parse(`${vscode.env.uriScheme}://${publisher}.${name}/on-did-authenticate`));
30+
31+
const existingStates = this._pendingStates.get(scopes) || [];
32+
this._pendingStates.set(scopes, [...existingStates, state]);
33+
34+
const uri = vscode.Uri.parse(`${AUTH_SERVER}?callbackUri=${encodeURIComponent(callbackUri.toString())}&scope=${scopes}&state=${state}&responseType=code`);
35+
await vscode.env.openExternal(uri);
36+
37+
let existingPromise = this._codeExchangePromises.get(scopes);
38+
if (!existingPromise) {
39+
existingPromise = promiseFromEvent(uriHandler.event, this._exchangeCodeForToken(team, scopes));
40+
this._codeExchangePromises.set(scopes, existingPromise);
41+
}
42+
43+
return Promise.race([
44+
existingPromise,
45+
// promiseFromEvent<string, string>(onDidManuallyProvideToken.event)
46+
]).finally(() => {
47+
this._pendingStates.delete(scopes);
48+
this._codeExchangePromises.delete(scopes);
49+
});
50+
}
51+
52+
private _exchangeCodeForToken: (team: string, scopes: string) => PromiseAdapter<vscode.Uri, AuthSuccessResult> = (team, scopes) => async (uri, resolve, reject) => {
53+
const query = parseQuery(uri);
54+
const { code } = query;
55+
56+
const acceptedStates = this._pendingStates.get(scopes) || [];
57+
if (!acceptedStates.includes(query.state)) {
58+
console.error(`Received mismatched state`);
59+
reject({});
60+
return;
61+
}
62+
63+
try {
64+
const result = await got.post(
65+
`https://${team}.coding.net/api/oauth/access_token`,
66+
{
67+
searchParams: {
68+
code,
69+
client_id: ClientId,
70+
client_secret: ClientSecret,
71+
grant_type: `authorization_code`,
72+
}
73+
}
74+
).json();
75+
76+
if ((result as AuthFailResult).code) {
77+
reject({} as AuthSuccessResult);
78+
} else {
79+
resolve(result as AuthSuccessResult);
80+
}
81+
} catch (err) {
82+
reject(err);
83+
}
84+
};
85+
86+
public async getUserInfo(team: string, token: string) {
87+
try {
88+
const result: UserResponse = await got.get(`https://${team}.coding.net/api/me`, {
89+
searchParams: {
90+
access_token: token,
91+
}
92+
}).json();
93+
return result;
94+
} catch (err) {
95+
return err;
96+
}
97+
}
98+
99+
}
100+

src/common/gitService.ts

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import * as vscode from 'vscode';
2+
import { API as BuiltInGitApi, GitExtension } from '../typings/git';
3+
4+
export class GitService {
5+
static async getBuiltInGitApi(): Promise<BuiltInGitApi | undefined> {
6+
try {
7+
const extension = vscode.extensions.getExtension('vscode.git') as vscode.Extension<GitExtension>;
8+
if (extension !== undefined) {
9+
const gitExtension = extension.isActive ? extension.exports : await extension.activate();
10+
11+
return gitExtension.getAPI(1);
12+
}
13+
} catch { }
14+
15+
return undefined;
16+
}
17+
}

0 commit comments

Comments
 (0)