Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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)

```ts
Expand Down
25 changes: 25 additions & 0 deletions README_ZH.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 目前有效期 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
Expand Down
6 changes: 3 additions & 3 deletions src/http.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -26,7 +26,7 @@ type ApiEnvelope = {
message?: unknown
} & Record<string, unknown>

function normalizeBaseUrl(value: string): string {
export function normalizeBaseUrl(value: string): string {
let url: URL
try {
url = new URL(value)
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
194 changes: 194 additions & 0 deletions src/oauth.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
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<OAuthAuthorizationRequest> {
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<OAuthTokens & { refreshToken: string }> {
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<OAuthTokens> {
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<void> {
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<string>)): () => Promise<string> {
let cached: OAuthTokens | undefined
let pending: Promise<OAuthTokens> | 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<string, string>, signal?: AbortSignal): Promise<OAuthTokens> {
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<string, string>, signal?: AbortSignal): Promise<unknown> {
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
}
}
Loading
Loading