From e645c2b55026b8f8096ff7b7c72b25460b87e5dd Mon Sep 17 00:00:00 2001 From: dmahacker Date: Sat, 10 Oct 2026 11:18:03 +0800 Subject: [PATCH 1/2] :sparkles: Feature: add server-side OAuth token client --- README.md | 26 ++++++ README_ZH.md | 25 ++++++ src/http.ts | 6 +- src/index.ts | 2 + src/oauth.ts | 194 ++++++++++++++++++++++++++++++++++++++++++++ tests/oauth.test.ts | 113 ++++++++++++++++++++++++++ 6 files changed, 363 insertions(+), 3 deletions(-) create mode 100644 src/oauth.ts create mode 100644 tests/oauth.test.ts diff --git a/README.md b/README.md index 03a348b..3a8c646 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,32 @@ const client = new PicGoCloudClient({ API requests use native fetch; you can inject an implementation through the `fetch` option. File transfers use native XMLHttpRequest for upload progress and do not use the custom fetch implementation. Importing the module does not access the DOM. Uploads require the browser's Blob/File, XMLHttpRequest, Web Crypto, and AbortSignal.any APIs. Use an HTTPS or localhost secure context. +## Server-side OAuth for partners + +`PicGoCloudOAuth` runs on your backend (Node 20+ or any runtime with fetch and Web Crypto). It needs the client secret, so never ship it to a browser. + +```ts +import { PicGoCloudOAuth, PicGoCloudClient } from '@picgo/cloud-sdk' + +const oauth = new PicGoCloudOAuth({ baseUrl, clientId, clientSecret }) + +// 1. Start: store state + codeVerifier in the server session, redirect the user to url +const { url, state, codeVerifier } = await oauth.createAuthorization({ redirectUri, scopes: ['account:read', 'files:upload'] }) + +// 2. Callback: compare the returned state, then exchange the code +const tokens = await oauth.exchangeCode({ code, codeVerifier, redirectUri }) +await saveEncrypted(userId, tokens.refreshToken) + +// 3. Any time later, without the user present +const client = new PicGoCloudClient({ baseUrl, token: oauth.tokenProvider(() => loadRefreshToken(userId)) }) +await client.whoami() + +// 4. Unlink: revoke, then delete your copy +await oauth.revoke(refreshToken) +``` + +Access tokens last 10 minutes. Refresh tokens do not expire and are not rotated on refresh, so treat them as long-lived credentials: store them encrypted and never log them or send them to the browser. `tokenProvider()` caches the access token until 30 seconds before expiry and shares one refresh between concurrent requests; create one provider per user. `oauth.refresh(refreshToken)` returns tokens directly if you manage caching yourself. When the user revokes access in PicGo Cloud, refreshing throws `PicGoCloudError` with `kind: 'authentication'` and `code: 'invalid_grant'`: delete the stored refresh token and ask the user to authorize again. Browsers should get short-lived access tokens from your backend instead of calling these methods. + ## Redeeming membership vouchers (OAuth) ```ts diff --git a/README_ZH.md b/README_ZH.md index 9327d9c..7ad1f79 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -39,6 +39,31 @@ const client = new PicGoCloudClient({ API 使用原生 fetch,可通过 `fetch` 选项注入自定义实现。上传字节使用原生 XMLHttpRequest 以提供浏览器上传进度,不经过自定义 fetch。模块导入本身不访问 DOM;实际上传需要浏览器的 Blob/File、XMLHttpRequest、Web Crypto 和 AbortSignal.any,建议在 HTTPS 或 localhost 安全上下文使用。 +## 合作方服务端 OAuth + +`PicGoCloudOAuth` 运行在你的后端(Node 20+ 或任何提供 fetch 与 Web Crypto 的运行时)。它需要 client secret,切勿打包到浏览器。 + +```ts +import { PicGoCloudOAuth, PicGoCloudClient } from '@picgo/cloud-sdk' + +const oauth = new PicGoCloudOAuth({ baseUrl, clientId, clientSecret }) + +// 1. 发起:把 state 和 codeVerifier 存进服务端会话,再把用户重定向到 url +const { url, state, codeVerifier } = await oauth.createAuthorization({ redirectUri, scopes: ['account:read', 'files:upload'] }) + +// 2. 回调:先比对返回的 state,再用授权码换令牌 +const tokens = await oauth.exchangeCode({ code, codeVerifier, redirectUri }) +await saveEncrypted(userId, tokens.refreshToken) + +// 3. 之后任何时候,无需用户在场 +const client = new PicGoCloudClient({ baseUrl, token: oauth.tokenProvider(() => loadRefreshToken(userId)) }) +await client.whoami() + +// 4. 解除关联:先吊销,再删除本地记录 +await oauth.revoke(refreshToken) +``` + +access token 有效期 10 分钟。refresh token 不过期,刷新时也不轮换,请视为长期凭证:加密存储,不要写入日志或返回给浏览器。`tokenProvider()` 会缓存 access token 至过期前 30 秒,并让并发请求共享同一次刷新;每个用户创建一个 provider。如需自行缓存,可直接调用 `oauth.refresh(refreshToken)`。用户在 PicGo Cloud 撤销授权后,刷新会抛出 `kind: 'authentication'`、`code: 'invalid_grant'` 的 `PicGoCloudError`:请删除已存的 refresh token 并引导用户重新授权。浏览器应从你的后端获取短期 access token,而不是直接调用这些方法。 ## 兑换会员(OAuth) ```ts diff --git a/src/http.ts b/src/http.ts index 30627d3..cd189f0 100644 --- a/src/http.ts +++ b/src/http.ts @@ -1,8 +1,8 @@ import { PicGoCloudError, isPicGoCloudError } from './errors.js' import type { TokenProvider } from './types.js' -const DEFAULT_BASE_URL = 'https://api.picgo.app' -const DEFAULT_TIMEOUT_MS = 30_000 +export const DEFAULT_BASE_URL = 'https://api.picgo.app' +export const DEFAULT_TIMEOUT_MS = 30_000 export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' @@ -26,7 +26,7 @@ type ApiEnvelope = { message?: unknown } & Record -function normalizeBaseUrl(value: string): string { +export function normalizeBaseUrl(value: string): string { let url: URL try { url = new URL(value) diff --git a/src/index.ts b/src/index.ts index 8f419f3..68415e7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,8 @@ export { PicGoCloudClient } from './client.js' export { ApiErrorCode } from './api-error-codes.js' export type { PicGoCloudClientOptions } from './client.js' +export { PicGoCloudOAuth } from './oauth.js' +export type { PicGoCloudOAuthOptions, OAuthTokens, OAuthAuthorizationRequest } from './oauth.js' export { PicGoCloudError, isPicGoCloudError } from './errors.js' export type { PicGoCloudErrorKind, PicGoCloudErrorOptions } from './errors.js' export type * from './types.js' diff --git a/src/oauth.ts b/src/oauth.ts new file mode 100644 index 0000000..b77e51b --- /dev/null +++ b/src/oauth.ts @@ -0,0 +1,194 @@ +import { PicGoCloudError } from './errors.js' +import { DEFAULT_BASE_URL, DEFAULT_TIMEOUT_MS, normalizeBaseUrl } from './http.js' + +export interface PicGoCloudOAuthOptions { + clientId: string + /** Confidential credential: use this class only on your server. */ + clientSecret: string + baseUrl?: string + fetch?: typeof fetch + timeoutMs?: number +} + +export interface OAuthTokens { + accessToken: string + /** Seconds until the access token expires. */ + expiresIn: number + /** Epoch milliseconds computed when the response arrived. */ + expiresAt: number + scope: string + refreshToken?: string +} + +export interface OAuthAuthorizationRequest { + /** Absolute `/oauth/authorize` URL to send the user to. */ + url: string + /** Compare with the callback `state` before exchanging the code. */ + state: string + /** Keep server-side until the callback; required by `exchangeCode`. */ + codeVerifier: string +} + +const random = (): string => toBase64Url(crypto.getRandomValues(new Uint8Array(32))) + +function toBase64Url(bytes: Uint8Array): string { + let binary = '' + for (const byte of bytes) binary += String.fromCharCode(byte) + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function nonEmpty(value: string, name: string): string { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new PicGoCloudError(`${name} must be a non-empty string`, { kind: 'validation' }) + } + return value +} + +/** + * Server-side OAuth helpers for PicGo Cloud partners: authorization URL with PKCE, + * code exchange, refresh, revocation and a cached access-token provider for `PicGoCloudClient`. + */ +export class PicGoCloudOAuth { + readonly baseUrl: string + private readonly clientId: string + private readonly basic: string + private readonly fetchImpl: typeof fetch + private readonly timeoutMs: number + + constructor(options: PicGoCloudOAuthOptions) { + this.clientId = nonEmpty(options.clientId, 'clientId') + nonEmpty(options.clientSecret, 'clientSecret') + this.baseUrl = normalizeBaseUrl(options.baseUrl ?? DEFAULT_BASE_URL) + this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS + if (!Number.isFinite(this.timeoutMs) || this.timeoutMs <= 0) { + throw new PicGoCloudError('timeoutMs must be a positive number', { kind: 'validation' }) + } + const fetchImpl = options.fetch ?? globalThis.fetch + if (typeof fetchImpl !== 'function') { + throw new PicGoCloudError('This runtime does not provide fetch', { kind: 'validation' }) + } + this.fetchImpl = fetchImpl.bind(globalThis) + // client_secret_basic: RFC 6749 form-encodes both parts before Base64. + const encode = (value: string) => encodeURIComponent(value).replace(/%20/g, '+') + this.basic = btoa(String.fromCharCode(...new TextEncoder().encode(`${encode(this.clientId)}:${encode(options.clientSecret)}`))) + } + + async createAuthorization(options: { redirectUri: string; scopes: string[] | string; state?: string }): Promise { + const scope = Array.isArray(options.scopes) ? options.scopes.join(' ') : options.scopes + nonEmpty(options.redirectUri, 'redirectUri') + nonEmpty(scope, 'scopes') + const state = options.state ?? random() + const codeVerifier = random() + const challenge = toBase64Url(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(codeVerifier)))) + const query = new URLSearchParams({ + client_id: this.clientId, + response_type: 'code', + redirect_uri: options.redirectUri, + scope, + state, + code_challenge_method: 'S256', + code_challenge: challenge, + }) + return { url: `${this.baseUrl}/oauth/authorize?${query.toString()}`, state, codeVerifier } + } + + async exchangeCode(options: { code: string; codeVerifier: string; redirectUri: string; signal?: AbortSignal }): Promise { + const tokens = await this.token({ + grant_type: 'authorization_code', + code: nonEmpty(options.code, 'code'), + code_verifier: nonEmpty(options.codeVerifier, 'codeVerifier'), + redirect_uri: nonEmpty(options.redirectUri, 'redirectUri'), + }, options.signal) + if (!tokens.refreshToken) { + throw new PicGoCloudError('PicGo Cloud did not return a refresh token', { kind: 'protocol' }) + } + return tokens as OAuthTokens & { refreshToken: string } + } + + /** Throws `code: 'invalid_grant'` when the user revoked access or the token was revoked. */ + async refresh(refreshToken: string, options: { signal?: AbortSignal } = {}): Promise { + return this.token({ grant_type: 'refresh_token', refresh_token: nonEmpty(refreshToken, 'refreshToken') }, options.signal) + } + + /** Revokes a refresh token (and its access tokens) or a single access token. */ + async revoke(token: string, options: { signal?: AbortSignal } = {}): Promise { + await this.post('/oauth/revoke', { token: nonEmpty(token, 'token') }, options.signal) + } + + /** + * Token provider for `new PicGoCloudClient({ token })`: refreshes 30 seconds before expiry + * and shares one in-flight refresh between concurrent requests. + * shortcut: assumes refresh tokens do not rotate (true today); persist `refreshToken` from responses if that changes. + */ + tokenProvider(refreshToken: string | (() => string | Promise)): () => Promise { + let cached: OAuthTokens | undefined + let pending: Promise | undefined + return async () => { + if (cached && cached.expiresAt > Date.now() + 30_000) return cached.accessToken + pending ??= Promise.resolve(typeof refreshToken === 'function' ? refreshToken() : refreshToken) + .then(token => this.refresh(token)) + .finally(() => { pending = undefined }) + cached = await pending + return cached.accessToken + } + } + + private async token(params: Record, signal?: AbortSignal): Promise { + const payload = await this.post('/oauth/token', params, signal) + if (!isRecord(payload) || typeof payload.access_token !== 'string' || !payload.access_token + || typeof payload.expires_in !== 'number' || !(payload.expires_in > 0) || typeof payload.scope !== 'string') { + throw new PicGoCloudError('PicGo Cloud returned an invalid token response', { kind: 'protocol' }) + } + return { + accessToken: payload.access_token, + expiresIn: payload.expires_in, + expiresAt: Date.now() + payload.expires_in * 1000, + scope: payload.scope, + ...(typeof payload.refresh_token === 'string' && payload.refresh_token ? { refreshToken: payload.refresh_token } : {}), + } + } + + private async post(path: string, params: Record, signal?: AbortSignal): Promise { + if (signal?.aborted) throw new PicGoCloudError('The request was aborted', { kind: 'aborted', cause: signal.reason }) + const timeout = AbortSignal.timeout(this.timeoutMs) + let response: Response + try { + response = await this.fetchImpl(`${this.baseUrl}${path}`, { + method: 'POST', + headers: { + Accept: 'application/json', + Authorization: `Basic ${this.basic}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams(params).toString(), + credentials: 'omit', + redirect: 'error', + signal: signal ? AbortSignal.any([signal, timeout]) : timeout, + }) + } catch (cause) { + if (signal?.aborted) throw new PicGoCloudError('The request was aborted', { kind: 'aborted', cause }) + if (timeout.aborted) throw new PicGoCloudError(`PicGo Cloud OAuth request timed out after ${this.timeoutMs}ms`, { kind: 'timeout', cause }) + throw new PicGoCloudError('PicGo Cloud OAuth request failed', { kind: 'network', cause }) + } + const text = await response.text() + let payload: unknown + try { + payload = text ? JSON.parse(text) as unknown : undefined + } catch (cause) { + throw new PicGoCloudError('PicGo Cloud returned malformed JSON', { kind: 'protocol', status: response.status, cause }) + } + if (!response.ok) { + const code = isRecord(payload) && typeof payload.error === 'string' ? payload.error : undefined + throw new PicGoCloudError(`PicGo Cloud OAuth error: ${code ?? response.status}`, { + kind: code === 'invalid_client' || code === 'invalid_grant' ? 'authentication' : 'api', + status: response.status, + code, + }) + } + return payload + } +} diff --git a/tests/oauth.test.ts b/tests/oauth.test.ts new file mode 100644 index 0000000..5784780 --- /dev/null +++ b/tests/oauth.test.ts @@ -0,0 +1,113 @@ +import { createHash } from 'node:crypto' +import { describe, expect, it, vi } from 'vitest' +import { PicGoCloudClient, PicGoCloudOAuth } from '../src/index.js' + +const tokenResponse = (extra: Record = {}) => + Response.json({ access_token: 'pgoa_access', token_type: 'Bearer', expires_in: 600, scope: 'account:read', ...extra }) + +function oauth(fetch: typeof globalThis.fetch, secret = 'secret value') { + return new PicGoCloudOAuth({ baseUrl: 'https://api.example.test/', clientId: 'partner', clientSecret: secret, fetch }) +} + +describe('PicGoCloudOAuth', () => { + it('builds a PKCE S256 authorization URL', async () => { + const auth = await oauth(fetch).createAuthorization({ redirectUri: 'https://partner.test/callback', scopes: ['account:read', 'files:upload'] }) + const url = new URL(auth.url) + expect(url.origin + url.pathname).toBe('https://api.example.test/oauth/authorize') + expect(Object.fromEntries(url.searchParams)).toEqual({ + client_id: 'partner', response_type: 'code', redirect_uri: 'https://partner.test/callback', + scope: 'account:read files:upload', state: auth.state, code_challenge_method: 'S256', + code_challenge: createHash('sha256').update(auth.codeVerifier).digest('base64url'), + }) + expect(auth.codeVerifier).toMatch(/^[A-Za-z0-9_-]{43}$/) + expect(auth.state).not.toBe(auth.codeVerifier) + }) + + it('exchanges a code with client_secret_basic and a form body without client credentials', async () => { + const client = oauth(async (url, init) => { + expect(url).toBe('https://api.example.test/oauth/token') + const headers = new Headers(init?.headers) + expect(headers.get('Authorization')).toBe(`Basic ${Buffer.from('partner:secret+value').toString('base64')}`) + expect(headers.get('Content-Type')).toBe('application/x-www-form-urlencoded') + expect(init?.credentials).toBe('omit') + expect(Object.fromEntries(new URLSearchParams(String(init?.body)))).toEqual({ + grant_type: 'authorization_code', code: 'pgoc_code', code_verifier: 'v'.repeat(43), redirect_uri: 'https://partner.test/callback', + }) + return tokenResponse({ refresh_token: 'pgor_refresh' }) + }) + const before = Date.now() + const tokens = await client.exchangeCode({ code: 'pgoc_code', codeVerifier: 'v'.repeat(43), redirectUri: 'https://partner.test/callback' }) + expect(tokens).toMatchObject({ accessToken: 'pgoa_access', refreshToken: 'pgor_refresh', expiresIn: 600, scope: 'account:read' }) + expect(tokens.expiresAt).toBeGreaterThanOrEqual(before + 600_000) + }) + + it('rejects a code exchange without a refresh token', async () => { + await expect(oauth(async () => tokenResponse()).exchangeCode({ code: 'c', codeVerifier: 'v', redirectUri: 'r' })) + .rejects.toMatchObject({ kind: 'protocol' }) + }) + + it('refreshes without scope and surfaces invalid_grant as an authentication error', async () => { + const bodies: string[] = [] + const client = oauth(async (_url, init) => { + bodies.push(String(init?.body)) + return bodies.length === 1 ? tokenResponse() : Response.json({ error: 'invalid_grant' }, { status: 400 }) + }) + expect(await client.refresh('pgor_refresh')).toMatchObject({ accessToken: 'pgoa_access' }) + expect(bodies[0]).toBe('grant_type=refresh_token&refresh_token=pgor_refresh') + await expect(client.refresh('pgor_refresh')).rejects.toMatchObject({ kind: 'authentication', status: 400, code: 'invalid_grant' }) + }) + + it('revokes a token', async () => { + const fetch = vi.fn(async () => new Response(null, { status: 200 })) + await oauth(fetch).revoke('pgor_refresh') + expect(fetch).toHaveBeenCalledWith('https://api.example.test/oauth/revoke', expect.objectContaining({ body: 'token=pgor_refresh' })) + }) + + it('maps malformed, invalid and network responses', async () => { + await expect(oauth(async () => Response.json({ access_token: 'a' })).refresh('r')).rejects.toMatchObject({ kind: 'protocol' }) + await expect(oauth(async () => new Response('', { status: 502 })).refresh('r')).rejects.toMatchObject({ kind: 'protocol', status: 502 }) + await expect(oauth(async () => { throw new TypeError('offline') }).refresh('r')).rejects.toMatchObject({ kind: 'network' }) + await expect(oauth(async () => { throw new Error('must not send') }).refresh('r', { signal: AbortSignal.abort() })).rejects.toMatchObject({ kind: 'aborted' }) + }) + + it('validates options', () => { + expect(() => oauth(fetch, '')).toThrow('clientSecret') + expect(() => new PicGoCloudOAuth({ clientId: 'p', clientSecret: 's', baseUrl: 'https://u:p@api.test' })).toThrow() + }) + + it('provides cached access tokens to PicGoCloudClient and shares one refresh', async () => { + let refreshes = 0 + const oauthClient = oauth(async () => { + refreshes += 1 + return tokenResponse({ access_token: `pgoa_${refreshes}` }) + }) + const loadRefreshToken = vi.fn(async () => 'pgor_refresh') + const token = oauthClient.tokenProvider(loadRefreshToken) + expect(await Promise.all([token(), token(), token()])).toEqual(['pgoa_1', 'pgoa_1', 'pgoa_1']) + expect(await token()).toBe('pgoa_1') + expect(refreshes).toBe(1) + expect(loadRefreshToken).toHaveBeenCalledTimes(1) + + const api = new PicGoCloudClient({ + baseUrl: 'https://api.example.test', + token, + fetch: async (_url, init) => { + expect(new Headers(init?.headers).get('Authorization')).toBe('Bearer pgoa_1') + return Response.json({ success: true, data: { id: 'u1' } }) + }, + }) + await api.whoami() + }) + + it('refreshes again when the cached token is about to expire and retries after a failure', async () => { + let calls = 0 + const token = oauth(async () => { + calls += 1 + if (calls === 1) return Response.json({ error: 'server_error' }, { status: 500 }) + return tokenResponse({ access_token: `pgoa_${calls}`, expires_in: 20 }) + }).tokenProvider('pgor_refresh') + await expect(token()).rejects.toMatchObject({ kind: 'api', code: 'server_error' }) + expect(await token()).toBe('pgoa_2') + expect(await token()).toBe('pgoa_3') + }) +}) From 7db5a94c94f7cb767e28f43a936ca9092f11032d Mon Sep 17 00:00:00 2001 From: dmahacker Date: Sat, 10 Oct 2026 11:21:34 +0800 Subject: [PATCH 2/2] :pencil: Docs: document one-hour access token lifetime --- README.md | 2 +- README_ZH.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3a8c646..b6b30b9 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ await client.whoami() await oauth.revoke(refreshToken) ``` -Access tokens last 10 minutes. Refresh tokens do not expire and are not rotated on refresh, so treat them as long-lived credentials: store them encrypted and never log them or send them to the browser. `tokenProvider()` caches the access token until 30 seconds before expiry and shares one refresh between concurrent requests; create one provider per user. `oauth.refresh(refreshToken)` returns tokens directly if you manage caching yourself. When the user revokes access in PicGo Cloud, refreshing throws `PicGoCloudError` with `kind: 'authentication'` and `code: 'invalid_grant'`: delete the stored refresh token and ask the user to authorize again. Browsers should get short-lived access tokens from your backend instead of calling these methods. +Access tokens currently last one hour (always use `expiresIn`/`expiresAt` rather than assuming). Refresh tokens do not expire and are not rotated on refresh, so treat them as long-lived credentials: store them encrypted and never log them or send them to the browser. `tokenProvider()` caches the access token until 30 seconds before expiry and shares one refresh between concurrent requests; create one provider per user. `oauth.refresh(refreshToken)` returns tokens directly if you manage caching yourself. When the user revokes access in PicGo Cloud, refreshing throws `PicGoCloudError` with `kind: 'authentication'` and `code: 'invalid_grant'`: delete the stored refresh token and ask the user to authorize again. Browsers should get short-lived access tokens from your backend instead of calling these methods. ## Redeeming membership vouchers (OAuth) diff --git a/README_ZH.md b/README_ZH.md index 7ad1f79..64ce463 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -63,7 +63,7 @@ await client.whoami() await oauth.revoke(refreshToken) ``` -access token 有效期 10 分钟。refresh token 不过期,刷新时也不轮换,请视为长期凭证:加密存储,不要写入日志或返回给浏览器。`tokenProvider()` 会缓存 access token 至过期前 30 秒,并让并发请求共享同一次刷新;每个用户创建一个 provider。如需自行缓存,可直接调用 `oauth.refresh(refreshToken)`。用户在 PicGo Cloud 撤销授权后,刷新会抛出 `kind: 'authentication'`、`code: 'invalid_grant'` 的 `PicGoCloudError`:请删除已存的 refresh token 并引导用户重新授权。浏览器应从你的后端获取短期 access token,而不是直接调用这些方法。 +access token 目前有效期 1 小时(请以 `expiresIn`/`expiresAt` 为准,不要写死)。refresh token 不过期,刷新时也不轮换,请视为长期凭证:加密存储,不要写入日志或返回给浏览器。`tokenProvider()` 会缓存 access token 至过期前 30 秒,并让并发请求共享同一次刷新;每个用户创建一个 provider。如需自行缓存,可直接调用 `oauth.refresh(refreshToken)`。用户在 PicGo Cloud 撤销授权后,刷新会抛出 `kind: 'authentication'`、`code: 'invalid_grant'` 的 `PicGoCloudError`:请删除已存的 refresh token 并引导用户重新授权。浏览器应从你的后端获取短期 access token,而不是直接调用这些方法。 ## 兑换会员(OAuth) ```ts