this repo has no description
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}