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 void 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: (
115 value: V,
116 ) => Schema[N]["value"] | PromiseLike<Schema[N]["value"]>;
117 decode: (encoded: Schema[N]["value"]) => V | PromiseLike<V>;
118 expiresAt: (value: V) => null | number;
119 },
120 ): DatabaseStore<V> {
121 return {
122 get: async (key) => {
123 console.log(`getting ${name}.${key}`);
124 const item = this.#mmkv?.getString(`${name}.${key}`);
125
126 if (item === undefined) return undefined;
127
128 const expiresAt = this.#mmkv?.getNumber(`${name}.${key}.expiresAt`);
129 if (expiresAt && expiresAt < 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}