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 { ReactNativeKey } from './react-native-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<ReactNativeKey> {
49 console.log(encoded)
50 return new ReactNativeKey(encoded.keyPair.privateKey)
51}
52
53export type Schema = {
54 state: Item<{
55 dpopKey: EncodedKey
56 iss: string
57 verifier?: string
58 appState?: string
59 }>
60 session: Item<{
61 dpopKey: EncodedKey
62 tokenSet: TokenSet
63 }>
64 didCache: Item<DidDocument>
65 dpopNonceCache: Item<string>
66 handleCache: Item<ResolvedHandle>
67 authorizationServerMetadataCache: Item<OAuthAuthorizationServerMetadata>
68 protectedResourceMetadataCache: Item<OAuthProtectedResourceMetadata>
69}
70
71export type DatabaseStore<V extends Value> = SimpleStore<string, V> & {
72 getKeys: () => Promise<string[]>
73}
74
75const STORES = [
76 'state',
77 'session',
78 'didCache',
79 'dpopNonceCache',
80 'handleCache',
81 'authorizationServerMetadataCaache',
82 'protectedResourceMetadataCache',
83]
84
85export type ReactNativeOAuthDatabaseOptions = {
86 name?: string
87 durability?: 'strict' | 'relaxed'
88 cleanupInterval?: number
89}
90
91export class ReactNativeOAuthDatabase {
92 #cleanupInterval?: ReturnType<typeof setInterval>
93 #mmkv?: MMKV
94
95 constructor(options?: ReactNativeOAuthDatabaseOptions) {
96 this.#cleanupInterval = setInterval(() => {
97 this.cleanup()
98 }, options?.cleanupInterval ?? 30e3)
99 this.#mmkv = new MMKV({ id: 'react-native-oauth-client' })
100 }
101
102 delete = async (key: string) => {
103 this.#mmkv?.delete(key)
104 this.#mmkv?.delete(`${key}.expiresAt`)
105 }
106
107 protected createStore<N extends keyof Schema, V extends Value>(
108 name: N,
109 {
110 encode,
111 decode,
112 expiresAt,
113 }: {
114 encode: (value: V) => Schema[N]['value'] | PromiseLike<Schema[N]['value']>
115 decode: (encoded: Schema[N]['value']) => V | PromiseLike<V>
116 expiresAt: (value: V) => null | number
117 }
118 ): DatabaseStore<V> {
119 return {
120 get: async (key) => {
121 console.log(`getting ${name}.${key}`)
122 const item = this.#mmkv?.getString(`${name}.${key}`)
123
124 if (item === undefined) return undefined
125
126 const storedExpiresAt = this.#mmkv?.getNumber(
127 `${name}.${key}.expiresAt`
128 )
129 if (storedExpiresAt && storedExpiresAt < Date.now()) {
130 await this.delete(`${name}.${key}`)
131 return undefined
132 }
133
134 const res = decode(JSON.parse(item))
135 console.log(res)
136 return res
137 },
138
139 getKeys: async () => {
140 const keys = this.#mmkv?.getAllKeys() ?? []
141 return keys.filter((key) => key.startsWith(`${name}.`))
142 },
143
144 set: async (key, value) => {
145 let encoded = await encode(value)
146 encoded = JSON.stringify(encoded)
147
148 const _expiresAt = expiresAt(value)
149
150 this.#mmkv?.set(`${name}.${key}`, encoded)
151 if (_expiresAt) {
152 this.#mmkv?.set(`${name}.${key}.expiresAt`, _expiresAt)
153 }
154 },
155 del: async (key) => {
156 await this.delete(`${name}.${key}`)
157 },
158 }
159 }
160
161 getSessionStore(): DatabaseStore<Session> {
162 return this.createStore('session', {
163 expiresAt: ({ tokenSet }) =>
164 tokenSet.refresh_token || tokenSet.expires_at == null
165 ? null
166 : new Date(tokenSet.expires_at).valueOf(),
167 encode: ({ dpopKey, ...session }) => ({
168 ...session,
169 dpopKey: encodeKey(dpopKey),
170 }),
171 // @ts-expect-error
172 decode: async ({ dpopKey, ...encoded }) => ({
173 ...encoded,
174 dpopKey: await decodeKey(dpopKey),
175 }),
176 })
177 }
178
179 getStateStore(): DatabaseStore<InternalStateData> {
180 return this.createStore('state', {
181 expiresAt: (_value) => Date.now() + 10 * 60e3,
182 encode: ({ dpopKey, ...session }) => ({
183 ...session,
184 dpopKey: encodeKey(dpopKey),
185 }),
186 // @ts-expect-error
187 decode: async ({ dpopKey, ...encoded }) => ({
188 ...encoded,
189 dpopKey: await decodeKey(dpopKey),
190 }),
191 })
192 }
193
194 getDpopNonceCache(): undefined | DatabaseStore<string> {
195 return this.createStore('dpopNonceCache', {
196 expiresAt: (_value) => Date.now() + 600e3,
197 encode: (value) => value,
198 decode: (encoded) => encoded,
199 })
200 }
201
202 getDidCache(): undefined | DatabaseStore<DidDocument> {
203 return this.createStore('didCache', {
204 expiresAt: (_value) => Date.now() + 60e3,
205 encode: (value) => value,
206 decode: (encoded) => encoded,
207 })
208 }
209
210 getHandleCache(): undefined | DatabaseStore<ResolvedHandle> {
211 return this.createStore('handleCache', {
212 expiresAt: (_value) => Date.now() + 60e3,
213 encode: (value) => value,
214 decode: (encoded) => encoded,
215 })
216 }
217
218 getAuthorizationServerMetadataCache():
219 | undefined
220 | DatabaseStore<OAuthAuthorizationServerMetadata> {
221 return this.createStore('authorizationServerMetadataCache', {
222 expiresAt: (_value) => Date.now() + 60e3,
223 encode: (value) => value,
224 decode: (encoded) => encoded,
225 })
226 }
227
228 getProtectedResourceMetadataCache():
229 | undefined
230 | DatabaseStore<OAuthProtectedResourceMetadata> {
231 return this.createStore('protectedResourceMetadataCache', {
232 expiresAt: (_value) => Date.now() + 60e3,
233 encode: (value) => value,
234 decode: (encoded) => encoded,
235 })
236 }
237
238 async cleanup() {
239 for (const name of STORES) {
240 const keys = this.#mmkv?.getAllKeys() ?? []
241 for (const key of keys) {
242 if (key.startsWith(`${name}.`)) {
243 const expiresAt = this.#mmkv?.getNumber(`${name}.${key}.expiresAt`)
244 if (expiresAt && Number(expiresAt) < Date.now()) {
245 this.#mmkv?.delete(key)
246 this.#mmkv?.delete(`${name}.${key}.expiresAt`)
247 }
248 }
249 }
250 }
251 }
252
253 async [Symbol.asyncDispose]() {
254 clearInterval(this.#cleanupInterval)
255 }
256}