this repo has no description
at main 6.5 kB view raw
1import type { 2 DidDocument, 3 InternalStateData, 4 OAuthAuthorizationServerMetadata, 5 OAuthProtectedResourceMetadata, 6 ResolvedHandle, 7 Session, 8 TokenSet, 9 Key, 10} from '@atproto/oauth-client' 11import { type SimpleStore, type Value } from '@atproto-labs/simple-store' 12import { MMKV } from 'react-native-mmkv' 13import { JWK } from './ExpoAtprotoAuth.types' 14import { ExpoKey } from './expo-key' 15 16type Item<V> = { 17 value: V 18 expiresAt: null | number 19} 20 21type CryptoKeyPair = { 22 publicKey: JWK 23 privateKey: JWK 24} 25 26type EncodedKey = { 27 keyId: string 28 keyPair: CryptoKeyPair 29} 30 31function encodeKey(key: Key): EncodedKey { 32 if (!key.privateJwk || !key.publicJwk || !key.kid) { 33 throw new Error('Invalid key object') 34 } 35 36 const encodedKey = { 37 keyId: key.kid, 38 keyPair: { 39 publicKey: key.publicJwk, 40 privateKey: key.privateJwk, 41 }, 42 } 43 44 // @ts-expect-error 45 return encodedKey 46} 47 48async function decodeKey(encoded: EncodedKey): Promise<ExpoKey> { 49 return new ExpoKey(encoded.keyPair.privateKey) 50} 51 52export type Schema = { 53 state: Item<{ 54 dpopKey: EncodedKey 55 iss: string 56 verifier?: string 57 appState?: string 58 }> 59 session: Item<{ 60 dpopKey: EncodedKey 61 tokenSet: TokenSet 62 }> 63 didCache: Item<DidDocument> 64 dpopNonceCache: Item<string> 65 handleCache: Item<ResolvedHandle> 66 authorizationServerMetadataCache: Item<OAuthAuthorizationServerMetadata> 67 protectedResourceMetadataCache: Item<OAuthProtectedResourceMetadata> 68} 69 70export type DatabaseStore<V extends Value> = SimpleStore<string, V> & { 71 getKeys: () => Promise<string[]> 72} 73 74const STORES = [ 75 'state', 76 'session', 77 'didCache', 78 'dpopNonceCache', 79 'handleCache', 80 'authorizationServerMetadataCaache', 81 'protectedResourceMetadataCache', 82] 83 84export type ExpoOAuthDatabaseOptions = { 85 name?: string 86 durability?: 'strict' | 'relaxed' 87 cleanupInterval?: number 88} 89 90export class ExpoOAuthDatabase { 91 #cleanupInterval?: ReturnType<typeof setInterval> 92 #mmkv?: MMKV 93 94 constructor(options?: ExpoOAuthDatabaseOptions) { 95 this.#cleanupInterval = setInterval(() => { 96 this.cleanup() 97 }, options?.cleanupInterval ?? 30e3) 98 this.#mmkv = new MMKV({ id: 'react-native-oauth-client' }) 99 } 100 101 delete = async (key: string) => { 102 this.#mmkv?.delete(key) 103 this.#mmkv?.delete(`${key}.expiresAt`) 104 } 105 106 protected createStore<N extends keyof Schema, V extends Value>( 107 name: N, 108 { 109 encode, 110 decode, 111 expiresAt, 112 }: { 113 encode: (value: V) => Schema[N]['value'] | PromiseLike<Schema[N]['value']> 114 decode: (encoded: Schema[N]['value']) => V | PromiseLike<V> 115 expiresAt: (value: V) => null | number 116 } 117 ): DatabaseStore<V> { 118 return { 119 get: async (key) => { 120 const item = this.#mmkv?.getString(`${name}.${key}`) 121 122 if (item === undefined) return undefined 123 124 const storedExpiresAt = this.#mmkv?.getNumber( 125 `${name}.${key}.expiresAt` 126 ) 127 if (storedExpiresAt && storedExpiresAt < Date.now()) { 128 await this.delete(`${name}.${key}`) 129 return undefined 130 } 131 132 const res = decode(JSON.parse(item)) 133 return res 134 }, 135 136 getKeys: async () => { 137 const keys = this.#mmkv?.getAllKeys() ?? [] 138 return keys.filter((key) => key.startsWith(`${name}.`)) 139 }, 140 141 set: async (key, value) => { 142 let encoded = await encode(value) 143 encoded = JSON.stringify(encoded) 144 145 const _expiresAt = expiresAt(value) 146 147 this.#mmkv?.set(`${name}.${key}`, encoded) 148 if (_expiresAt) { 149 this.#mmkv?.set(`${name}.${key}.expiresAt`, _expiresAt) 150 } 151 }, 152 del: async (key) => { 153 await this.delete(`${name}.${key}`) 154 }, 155 } 156 } 157 158 getSessionStore(): DatabaseStore<Session> { 159 return this.createStore('session', { 160 expiresAt: ({ tokenSet }) => 161 tokenSet.refresh_token || tokenSet.expires_at == null 162 ? null 163 : new Date(tokenSet.expires_at).valueOf(), 164 encode: ({ dpopKey, ...session }) => ({ 165 ...session, 166 dpopKey: encodeKey(dpopKey), 167 }), 168 // @ts-expect-error 169 decode: async ({ dpopKey, ...encoded }) => ({ 170 ...encoded, 171 dpopKey: await decodeKey(dpopKey), 172 }), 173 }) 174 } 175 176 getStateStore(): DatabaseStore<InternalStateData> { 177 return this.createStore('state', { 178 expiresAt: (_value) => Date.now() + 10 * 60e3, 179 encode: ({ dpopKey, ...session }) => ({ 180 ...session, 181 dpopKey: encodeKey(dpopKey), 182 }), 183 // @ts-expect-error 184 decode: async ({ dpopKey, ...encoded }) => ({ 185 ...encoded, 186 dpopKey: await decodeKey(dpopKey), 187 }), 188 }) 189 } 190 191 getDpopNonceCache(): undefined | DatabaseStore<string> { 192 return this.createStore('dpopNonceCache', { 193 expiresAt: (_value) => Date.now() + 600e3, 194 encode: (value) => value, 195 decode: (encoded) => encoded, 196 }) 197 } 198 199 getDidCache(): undefined | DatabaseStore<DidDocument> { 200 return this.createStore('didCache', { 201 expiresAt: (_value) => Date.now() + 60e3, 202 encode: (value) => value, 203 decode: (encoded) => encoded, 204 }) 205 } 206 207 getHandleCache(): undefined | DatabaseStore<ResolvedHandle> { 208 return this.createStore('handleCache', { 209 expiresAt: (_value) => Date.now() + 60e3, 210 encode: (value) => value, 211 decode: (encoded) => encoded, 212 }) 213 } 214 215 getAuthorizationServerMetadataCache(): 216 | undefined 217 | DatabaseStore<OAuthAuthorizationServerMetadata> { 218 return this.createStore('authorizationServerMetadataCache', { 219 expiresAt: (_value) => Date.now() + 60e3, 220 encode: (value) => value, 221 decode: (encoded) => encoded, 222 }) 223 } 224 225 getProtectedResourceMetadataCache(): 226 | undefined 227 | DatabaseStore<OAuthProtectedResourceMetadata> { 228 return this.createStore('protectedResourceMetadataCache', { 229 expiresAt: (_value) => Date.now() + 60e3, 230 encode: (value) => value, 231 decode: (encoded) => encoded, 232 }) 233 } 234 235 async cleanup() { 236 for (const name of STORES) { 237 const keys = this.#mmkv?.getAllKeys() ?? [] 238 for (const key of keys) { 239 if (key.startsWith(`${name}.`)) { 240 const expiresAt = this.#mmkv?.getNumber(`${name}.${key}.expiresAt`) 241 if (expiresAt && Number(expiresAt) < Date.now()) { 242 this.#mmkv?.delete(key) 243 this.#mmkv?.delete(`${name}.${key}.expiresAt`) 244 } 245 } 246 } 247 } 248 } 249 250 async [Symbol.asyncDispose]() { 251 clearInterval(this.#cleanupInterval) 252 } 253}