تخطَّ إلى المحتوى

@dukkan.one/app-sdk/tokens

The TokenStore contract the client refreshes through, the in-memory and key-value adapters, and the AES-GCM sealer for tokens at rest.

الحزمة
@dukkan.one/app-sdk@0.3.0
مراجعة الواجهة
2026-09-10
الاستيراد
import { … } from "@dukkan.one/app-sdk/tokens"
في هذه الصفحة

الدوال

createAesGcmSealer#

function createAesGcmSealer(options: { currentKey: string; previousKey?: string | null; }): SecretSealer

AES-256-GCM sealer with zero-downtime key rotation: seal with the current key, open with current then previous. Keys are 32 bytes, base64. The wire format (iv.tag.ciphertext, each base64) matches the first-party apps' lib/crypto.mjs, so migrating an existing token table needs no rewrite.

createKeyValueTokenStore#

function createKeyValueTokenStore(options: KeyValueTokenStoreOptions): TokenStore

Token store over a key-value backend. Token rows are sealed before they reach the backend; the refresh lock is a short lease taken with putIfAbsent and polled by waiters.

tokensFromResponse#

function tokensFromResponse(installId: string, previous: Pick<StoredTokens, "storeId" | "scopes"> | null, response: TokenResponse, now?: () => number): StoredTokens

Builds the StoredTokens row from a token response, computing accessExpiresAt from expires_in and keeping the previous store id and scopes when the response omits them.

الأصناف

MemoryTokenStore#

class MemoryTokenStore implements TokenStore

new MemoryTokenStore(initial?

Single-process store for tests, CLIs and scripts. The lock is a per-install promise chain, which is exactly what one process needs and nothing a second process could rely on: never use this behind more than one instance.

الأعضاء
الاسمالنوعالوصف
loadمطلوب(installId: string): Promise<StoredTokens | null>
saveمطلوب(tokens: StoredTokens): Promise<void>
withRefreshLockمطلوب<T>(installId: string, fn: (locked: TokenStore) => Promise<T>): Promise<T>

Run fn while holding an exclusive per-install lock (row lock, lease, mutex). fn receives a store bound to that lock (for Postgres, the transaction that holds the row); every load and save inside the callback must go through it, never through the outer store.

markReconnectRequiredمطلوب(installId: string, reason: string): Promise<void>

Record that the install must go through consent again; subsequent loads carry the reason.

snapshotمطلوب(installId: string): StoredTokens | null

TokenManager#

class TokenManager

new TokenManager(options

Hands out a valid access token for one install.

Refresh discipline (ADR 0012): one refresh at a time per install in this process (single flight), and one at a time across processes through the store's lock. Inside the lock the row is re-read: if a sibling already rotated, its pair is used and no request is made. An invalid_grant gets one more re-read (the platform answers it for a race it detected within its 30 s leeway). If the row still holds the same refresh token, the lock is released, the manager waits past the leeway, and presents the token once more: a sibling that really rotated has saved by then, while a thief who rotated our token is caught by the platform's reuse detection, which revokes the whole family instead of leaving the stolen pair alive. Only then is the install marked reconnect-required.

الأعضاء
الاسمالنوعالوصف
installIdمطلوبstring
getAccessTokenمطلوب(options?: { forceRefresh?: boolean; }): Promise<string>

A token that is valid for at least the leeway; refreshes when needed.

getTokensمطلوب(): Promise<StoredTokens>

The stored row, refreshed when stale.

الواجهات

KeyValueBackend#

interface KeyValueBackend

The smallest surface a key-value backend needs (Cloudflare KV, Redis, Deno KV, Upstash...). putIfAbsent is what makes the refresh lock real across instances; without it the adapter falls back to an in-process mutex and says so at construction.

الأعضاء
الاسمالنوعالوصف
getمطلوب(key: string): Promise<string | null>
putمطلوب(key: string, value: string, options?: { ttlSeconds?: number; }): Promise<void>
deleteمطلوب(key: string): Promise<void>
putIfAbsent((key: string, value: string, options: { ttlSeconds: number; }) => Promise<boolean>) | undefined

Atomic "set if not present"; returns false when the key already exists.

KeyValueTokenStoreOptions#

interface KeyValueTokenStoreOptions

Configuration of createKeyValueTokenStore: the backend, an optional key prefix, the sealer, and the lock lease.

الأعضاء
الاسمالنوعالوصف
backendمطلوبKeyValueBackend
prefixstring | undefined

Key prefix (default dukkan:tokens:).

sealerSecretSealer | undefined
lockTtlSecondsnumber | undefined

Lease length for the refresh lock (default 20 s; refreshes take well under that).

lockWaitMsnumber | undefined

How long to wait for a held lock before giving up (default 25 s).

onNoAtomicLock((message: string) => void) | undefined
onNoSealer((message: string) => void) | undefined

Called once when no sealer is configured (default: console.warn).

SecretSealer#

interface SecretSealer

Envelope encryption for tokens at rest (AES-256-GCM helper shipped in ./sealer).

الأعضاء
الاسمالنوعالوصف
sealمطلوب(plaintext: string): Promise<string>
openمطلوب(envelope: string): Promise<string>

StoredTokens#

interface StoredTokens

What an app persists per install. Tokens are stored by the app (encrypted at rest through a SecretSealer), never by the SDK: the SDK only reads, refreshes and writes back through the TokenStore contract.

الأعضاء
الاسمالنوعالوصف
installIdمطلوبstring
storeIdمطلوبstring
accessTokenمطلوبstring
accessExpiresAtمطلوبstring

RFC 3339 instant after which the access token is refused.

refreshTokenمطلوبstring
refreshExpiresAtstring | null | undefined
scopesArray<string> | undefined
reconnectRequiredstring | null | undefined

Set when the platform said the install needs re-consent; calls throw until re-authorized.

TokenManagerOptions#

interface TokenManagerOptions

Configuration of TokenManager: the install, the store, the credentials, and the refresh leeway.

الأعضاء
الاسمالنوعالوصف
installIdمطلوبstring
storeمطلوبTokenStore
credentialsمطلوبOAuthClientCredentials
apiUrlمطلوبstring
fetch((input: RequestInfo | URL, init?: RequestInit) => Promise<Response>) | undefined
refreshLeewaySecondsnumber | undefined

Refresh this many seconds before the access token expires (default 60).

raceRetryDelayMsnumber | undefined

How long to wait before presenting the same refresh token a second time after an invalid_grant that left the stored row unchanged (default 35 s plus jitter, past the platform's 30 s race leeway). See #refreshLocked.

timeoutMsnumber | undefined
loggerDukkanLogger | undefined
now(() => number) | undefined

TokenStore#

interface TokenStore

Persistence contract every app implements once (adapters for Postgres and key-value stores ship with the SDK).

withRefreshLock is the important one: the platform revokes an install when a rotated refresh token is presented again (theft detection), so two instances must never refresh the same install concurrently. The lock serialises refreshes across processes; the SDK re-reads inside it and skips the refresh when a sibling already rotated.

الأعضاء
الاسمالنوعالوصف
loadمطلوب(installId: string): Promise<StoredTokens | null>
saveمطلوب(tokens: StoredTokens): Promise<void>
withRefreshLockمطلوب<T>(installId: string, fn: (locked: TokenStore) => Promise<T>): Promise<T>

Run fn while holding an exclusive per-install lock (row lock, lease, mutex). fn receives a store bound to that lock (for Postgres, the transaction that holds the row); every load and save inside the callback must go through it, never through the outer store.

markReconnectRequiredمطلوب(installId: string, reason: string): Promise<void>

Record that the install must go through consent again; subsequent loads carry the reason.

الثوابت

IDENTITY_SEALER#

const IDENTITY_SEALER: SecretSealer

A sealer that stores plaintext. Only for tests and throwaway tools; production stores use createAesGcmSealer or their own.