SecretStorage 与认证
调用 GitHub、GitLab 等第三方 API 时,令牌(Token)不能硬编码、不能写进配置。VSCode 提供了两层机制:SecretStorage 负责安全存储,authentication 负责认证会话的统一管理。两者配合,插件可以实现"登录一次、随处可用"的体验。
SecretStorage 敏感信息存储
context.secrets 是插件专属的安全存储区,数据由操作系统级凭据库(Windows 凭据管理器、macOS 钥匙串、Linux keyring)加密保管:
export async function activate(context: vscode.ExtensionContext) {
// 写入:键名建议带扩展名前缀避免冲突
await context.secrets.store('myext.apiToken', 'ghp_xxx...');
// 读取:不存在时返回 undefined
const token = await context.secrets.get('myext.apiToken');
// 删除
await context.secrets.delete('myext.apiToken');
}store 返回 Promise,写入结果不保证立即落盘(某些平台异步持久化),但读取端总是能拿到最新内存值。get 返回 string | undefined,用判空区分"从未存储"与"值为空"。
与配置存储的区别
| 维度 | context.secrets | workspace.getConfiguration |
|---|---|---|
| 加密 | 系统凭据库加密 | 明文写入 settings.json |
| 可见性 | 仅当前插件可读 | 用户可见可改 |
| 同步 | 不随设置同步(不漫游) | 支持云同步 |
| 覆盖范围 | 所有工作区共享 | 按用户/工作区/文件夹分级 |
| 适用场景 | API Token、密码 | 普通偏好、开关 |
判定标准很简单:内容泄露会造成损害就用 secrets;纯偏好设置用 configuration。把 Token 写进 settings.json 会明文出现在用户的配置文件里,还会被误同步到云端。
带令牌的请求封装
async function callApi(context: vscode.ExtensionContext) {
const token = await context.secrets.get('myext.apiToken');
if (!token) {
const action = await vscode.window.showErrorMessage(
'未配置 API Token',
'设置 Token'
);
if (action === '设置 Token') {
const input = await vscode.window.showInputBox({
prompt: '请输入 API Token',
password: true // 输入框内容打码显示
});
if (input) {
await context.secrets.store('myext.apiToken', input);
}
}
return;
}
// 携带令牌请求
const res = await fetch('https://api.example.com/data', {
headers: { 'Authorization': `Bearer ${token}` }
});
return res.json();
}authentication 获取认证会话
SecretStorage 解决了"存哪里",authentication 解决"怎么拿"。getSession 让插件安全地获取第三方服务的登录会话,无需自己处理 OAuth 细节:
const session = await vscode.authentication.getSession(
'github', // providerId:认证 Provider 的标识
['repo', 'user'], // scopes:申请的权限范围
{ createIfNone: true } // 没有会话时弹出登录
);
if (session) {
console.log('登录用户:', session.account.label);
console.log('访问令牌:', session.accessToken);
}三个参数的含义
| 参数 | 说明 |
|---|---|
providerId | 认证提供者 ID(内置有 github、microsoft;第三方扩展注册自己的) |
scopes | 权限范围数组,如 ['repo']、['user:email'] |
createIfNone | 无可用会话时:true 弹出登录 UI,false 直接返回 undefined |
静默获取与强制登录
// 静默获取:不打扰用户,无会话则返回 undefined
const cached = await vscode.authentication.getSession('github', ['repo'], {
createIfNone: false
});
// 强制重新认证:忽略缓存,走完整登录流程
const fresh = await vscode.authentication.getSession('github', ['repo'], {
createIfNone: true,
forceNewSession: { detail: '令牌已过期,请重新登录' }
});forceNewSession 传入对象时,登录弹窗会附带说明文字,适合令牌过期后的引导。
会话变更监听
会话的建立、修改、删除通过 onDidChangeSessions 通知插件:
context.subscriptions.push(
vscode.authentication.onDidChangeSessions((event) => {
// 只关心自己用到的 Provider
if (event.provider.id !== 'github') return;
for (const added of event.added) {
console.log('新增会话:', added.account.label);
}
for (const removed of event.removed) {
console.log('移除会话:', removed.account.label);
}
for (const changed of event.changed) {
console.log('会话变更:', changed.account.label);
}
// 会话变化后重新拉取数据
refreshRemoteData();
})
);事件对象 AuthenticationProviderAuthenticationSessionsChangeEvent 包含 added、removed、changed 三个会话数组。用户在其他插件里登录 GitHub 后,你的插件也会收到通知——这是统一会话体系的价值所在。
自定义 AuthenticationProvider
内置 Provider 不够时,插件可以注册自己的认证提供者。典型场景:对接企业内部 OAuth 服务。
核心接口
class MyAuthProvider implements vscode.AuthenticationProvider {
// 必须:当前所有会话
private sessions: vscode.AuthenticationSession[] = [];
// 必须:会话变更事件(VSCode 靠它刷新登录状态)
private _onDidChangeSessions =
new vscode.EventEmitter<vscode.AuthenticationProviderAuthenticationSessionsChangeEvent>();
readonly onDidChangeSessions = this._onDidChangeSessions.event;
// 必须:返回会话列表
async getSessions(scopes: string[]): Promise<vscode.AuthenticationSession[]> {
return this.sessions.filter(s =>
scopes.every(scope => s.scopes.includes(scope))
);
}
// 必须:创建新会话(触发登录)
async createSession(scopes: string[]): Promise<vscode.AuthenticationSession> {
const session = await this.performOAuth(scopes); // 走 OAuth 流程
this.sessions.push(session);
// 通知 VSCode 会话列表已变化
this._onDidChangeSessions.fire({
added: [session], removed: [], changed: []
});
return session;
}
// 必须:移除会话(登出)
async removeSession(sessionId: string): Promise<void> {
const index = this.sessions.findIndex(s => s.id === sessionId);
if (index >= 0) {
const [removed] = this.sessions.splice(index, 1);
this._onDidChangeSessions.fire({
added: [], removed: [removed], changed: []
});
}
}
}注册 Provider
const provider = new MyAuthProvider(context);
context.subscriptions.push(
vscode.authentication.registerAuthenticationProvider(
'myext', // providerId:插件内唯一
'My Service', // 显示名称
provider, // 实现
{ supportsMultipleAccounts: true } // 是否支持多账号
)
);registerAuthenticationProvider 返回的 Disposable 负责注销。supportsMultipleAccounts 为 true 时用户可登录多个账号并在会话间切换。
OAuth 流程集成
以授权码模式为例,createSession 内部实现:
async performOAuth(scopes: string[]): Promise<vscode.AuthenticationSession> {
// 1. 构造授权 URL,state 防止 CSRF
const state = crypto.randomUUID();
const authUrl = `https://my-service.com/oauth/authorize?` +
`client_id=${CLIENT_ID}&scope=${scopes.join(' ')}` +
`&state=${state}&redirect_uri=${vscode.env.uriScheme}://myext/auth`;
// 2. 打开浏览器让用户授权
await vscode.env.openExternal(vscode.Uri.parse(authUrl));
// 3. 通过 onDidReceiveAuthenticationData 事件接收回调
const code = await this.waitForCallback(state);
if (!code) {
throw new Error('授权已取消');
}
// 4. 用授权码换取令牌
const tokenRes = await fetch('https://my-service.com/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
code, client_id: CLIENT_ID,
redirect_uri: `${vscode.env.uriScheme}://myext/auth`
})
});
const { access_token, refresh_token, expires_in } = await tokenRes.json();
// 5. 构建 AuthenticationSession 并持久化令牌
const sessionId = crypto.randomUUID();
await context.secrets.store(`myext.token.${sessionId}`, access_token);
await context.secrets.store(`myext.refresh.${sessionId}`, refresh_token);
return {
id: sessionId,
accessToken: access_token,
account: { id: 'user-1', label: 'user@example.com' },
scopes
};
}回调地址使用 vscode.env.uriScheme(vscode 或 vscode-insiders),扩展的 package.json 中声明:
{
"contributes": {
"authentication": [
{
"id": "myext",
"label": "My Service"
}
]
}
}OAuth 客户端在服务端配置的重定向 URI 应同时登记 vscode://myext/auth 与 vscode-insiders://myext/auth,否则 Insider 版本登录会失败。
令牌刷新
access token 过期时用 refresh token 续期,并同步更新会话:
async refreshSession(sessionId: string) {
const refresh = await context.secrets.get(`myext.refresh.${sessionId}`);
if (!refresh) return;
const res = await fetch('https://my-service.com/oauth/token', {
method: 'POST',
body: JSON.stringify({ grant_type: 'refresh_token', refresh_token: refresh })
});
const { access_token } = await res.json();
await context.secrets.store(`myext.token.${sessionId}`, access_token);
const session = this.sessions.find(s => s.id === sessionId);
if (session) {
// 更新 accessToken 并触发变更事件
session.accessToken = access_token;
this._onDidChangeSessions.fire({
added: [], removed: [], changed: [session]
});
}
}完整登录流程编排
export async function ensureLogin(context: vscode.ExtensionContext): Promise<boolean> {
// 1. 优先用内置/已注册 Provider 获取会话
const session = await vscode.authentication.getSession('github', ['repo'], {
createIfNone: true
});
if (!session) {
vscode.window.showWarningMessage('未登录 GitHub,功能受限');
return false;
}
// 2. 令牌临时存于会话,不用写入 SecretStorage;
// SecretStorage 只留给自定义 Provider 的 refresh token 等内部数据
context.workspaceState.update('loggedInAccount', session.account.label);
// 3. 注册登出命令
const logout = vscode.commands.registerCommand('myext.logout', async () => {
await vscode.authentication.getSession('github', ['repo']);
// 触发登出流程(交给认证中心)
});
context.subscriptions.push(logout);
return true;
}SecretStorage 负责"藏",authentication 负责"取":前者是数据安全底线,后者是用户体验入口。使用内置 Provider 时令牌完全由 VSCode 托管;自定义 Provider 则要自行管理令牌与刷新逻辑,此时 SecretStorage 是令牌的唯一安全归宿。