@atproto/oauth-client-browser
Version:
ATPROTO OAuth client for the browser (relies on WebCrypto & Indexed DB)
160 lines • 5.84 kB
JavaScript
import { WebcryptoKey } from '@atproto/jwk-webcrypto';
import { DB } from './indexed-db/index.js';
function encodeKey(key) {
if (!(key instanceof WebcryptoKey) || !key.kid) {
throw new Error('Invalid key object');
}
return {
keyId: key.kid,
keyPair: key.cryptoKeyPair,
};
}
async function decodeKey(encoded) {
return WebcryptoKey.fromKeypair(encoded.keyPair, encoded.keyId);
}
const STORES = [
'state',
'session',
'didCache',
'dpopNonceCache',
'handleCache',
'authorizationServerMetadataCache',
'protectedResourceMetadataCache',
];
export class BrowserOAuthDatabase {
#dbPromise;
#cleanupInterval;
constructor(options) {
this.#dbPromise = DB.open(options?.name ?? '@atproto-oauth-client', [
(db) => {
for (const name of STORES) {
const store = db.createObjectStore(name, { autoIncrement: true });
store.createIndex('expiresAt', 'expiresAt', { unique: false });
}
},
], { durability: options?.durability ?? 'strict' });
this.#cleanupInterval = setInterval(() => {
void this.cleanup();
}, options?.cleanupInterval ?? 30e3);
}
async run(storeName, mode, fn) {
const db = await this.#dbPromise;
return await db.transaction([storeName], mode, (tx) => fn(tx.objectStore(storeName)));
}
createStore(name, { encode, decode, expiresAt, }) {
return {
get: async (key) => {
// Find item in store
const item = await this.run(name, 'readonly', (store) => store.get(key));
// Not found
if (item === undefined)
return undefined;
// Too old (delete)
if (item.expiresAt != null && new Date(item.expiresAt) < new Date()) {
await this.run(name, 'readwrite', (store) => store.delete(key));
return undefined;
}
// Item found and valid. Decode
return decode(item.value);
},
set: async (key, value) => {
// Create encoded item record
const item = {
value: await encode(value),
expiresAt: expiresAt(value)?.toISOString(),
};
// Store item record
await this.run(name, 'readwrite', (store) => store.put(item, key));
},
del: async (key) => {
// Delete
await this.run(name, 'readwrite', (store) => store.delete(key));
},
};
}
getSessionStore() {
return this.createStore('session', {
expiresAt: ({ tokenSet }) => tokenSet.refresh_token || tokenSet.expires_at == null
? null
: new Date(tokenSet.expires_at),
encode: ({ dpopKey, ...session }) => ({
...session,
dpopKey: encodeKey(dpopKey),
}),
decode: async ({ dpopKey, ...encoded }) => ({
...encoded,
dpopKey: await decodeKey(dpopKey),
}),
});
}
getStateStore() {
return this.createStore('state', {
expiresAt: (_value) => new Date(Date.now() + 10 * 60e3),
encode: ({ dpopKey, ...session }) => ({
...session,
dpopKey: encodeKey(dpopKey),
}),
decode: async ({ dpopKey, ...encoded }) => ({
...encoded,
dpopKey: await decodeKey(dpopKey),
}),
});
}
getDpopNonceCache() {
return this.createStore('dpopNonceCache', {
expiresAt: (_value) => new Date(Date.now() + 600e3),
encode: (value) => value,
decode: (encoded) => encoded,
});
}
getDidCache() {
return this.createStore('didCache', {
expiresAt: (_value) => new Date(Date.now() + 60e3),
encode: (value) => value,
decode: (encoded) => encoded,
});
}
getHandleCache() {
return this.createStore('handleCache', {
expiresAt: (_value) => new Date(Date.now() + 60e3),
encode: (value) => value,
decode: (encoded) => encoded,
});
}
getAuthorizationServerMetadataCache() {
return this.createStore('authorizationServerMetadataCache', {
expiresAt: (_value) => new Date(Date.now() + 60e3),
encode: (value) => value,
decode: (encoded) => encoded,
});
}
getProtectedResourceMetadataCache() {
return this.createStore('protectedResourceMetadataCache', {
expiresAt: (_value) => new Date(Date.now() + 60e3),
encode: (value) => value,
decode: (encoded) => encoded,
});
}
async cleanup() {
const db = await this.#dbPromise;
for (const name of STORES) {
await db.transaction([name], 'readwrite', (tx) => tx
.objectStore(name)
.index('expiresAt')
.deleteAll(IDBKeyRange.upperBound(Date.now())));
}
}
async [Symbol.asyncDispose]() {
clearInterval(this.#cleanupInterval);
this.#cleanupInterval = undefined;
const dbPromise = this.#dbPromise;
this.#dbPromise = Promise.reject(new Error('Database has been disposed'));
// Avoid "unhandled promise rejection"
this.#dbPromise.catch(() => null);
// Spec recommends not to throw errors in dispose
const db = await dbPromise.catch(() => null);
if (db)
await db[Symbol.asyncDispose]();
}
}
//# sourceMappingURL=browser-oauth-database.js.map