Skip to content

@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.

Package
@dukkan.one/app-sdk@0.3.0
API revision
2026-09-10
Import
import { … } from "@dukkan.one/app-sdk/tokens"
On this page

Functions

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.

Classes

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.

Members
NameTypeDescription
loadRequired(installId: string): Promise<StoredTokens | null>
saveRequired(tokens: StoredTokens): Promise<void>
withRefreshLockRequired<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.

markReconnectRequiredRequired(installId: string, reason: string): Promise<void>

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

snapshotRequired(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.

Members
NameTypeDescription
installIdRequiredstring
getAccessTokenRequired(options?: { forceRefresh?: boolean; }): Promise<string>

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

getTokensRequired(): Promise<StoredTokens>

The stored row, refreshed when stale.

Interfaces

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.

Members
NameTypeDescription
getRequired(key: string): Promise<string | null>
putRequired(key: string, value: string, options?: { ttlSeconds?: number; }): Promise<void>
deleteRequired(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.

Members
NameTypeDescription
backendRequiredKeyValueBackend
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).

Members
NameTypeDescription
sealRequired(plaintext: string): Promise<string>
openRequired(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.

Members
NameTypeDescription
installIdRequiredstring
storeIdRequiredstring
accessTokenRequiredstring
accessExpiresAtRequiredstring

RFC 3339 instant after which the access token is refused.

refreshTokenRequiredstring
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.

Members
NameTypeDescription
installIdRequiredstring
storeRequiredTokenStore
credentialsRequiredOAuthClientCredentials
apiUrlRequiredstring
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.

Members
NameTypeDescription
loadRequired(installId: string): Promise<StoredTokens | null>
saveRequired(tokens: StoredTokens): Promise<void>
withRefreshLockRequired<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.

markReconnectRequiredRequired(installId: string, reason: string): Promise<void>

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

Constants

IDENTITY_SEALER#

const IDENTITY_SEALER: SecretSealer

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