@streamplace/atproto-oauth-client-react-native 0.0.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,18 @@
1
+ Copyright (c) 2025 Streamplace.
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
4
+ this software and associated documentation files (the "Software"), to deal in
5
+ the Software without restriction, including without limitation the rights to
6
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
7
+ the Software, and to permit persons to whom the Software is furnished to do so,
8
+ subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all
11
+ copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
15
+ FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
16
+ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
17
+ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
18
+ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,89 @@
1
+ # atproto OAuth Client for React Native
2
+
3
+ This package implements an atproto OAuth client usable on the React Native
4
+ platform. It uses [react-native-quick-crypto] for cryptographic operations and
5
+ [expo-sqlite] for persistence. Its usage is very similar to the atproto OAuth
6
+ client for the browser, so refer to that [README] and [example] for general
7
+ usage. Some differences are noted below.
8
+
9
+ ## expo-sqlite
10
+
11
+ This library uses [expo-sqlite] to store the OAuth state and session data in a
12
+ SQLite database. The schema is automatically created when the client is
13
+ instantiated.
14
+
15
+ Because this database is storing sensitive cryptographic keys, it is highly
16
+ reccomended to use the optional SQLCipher extension. This can be accomplished in
17
+ your app.json file:
18
+
19
+ ```json
20
+ {
21
+ "expo": {
22
+ "plugins": [
23
+ [
24
+ "expo-sqlite",
25
+ {
26
+ "useSQLCipher": true
27
+ }
28
+ ]
29
+ ]
30
+ }
31
+ }
32
+ ```
33
+
34
+ ## Login and session restore flow
35
+
36
+ The basic login flow will involve popping up a web browser and allowing users to
37
+ authenticate with their selected PDS. This can be accomplished with the
38
+ `expo-web-browser` library:
39
+
40
+ ```tsx
41
+ import { openAuthSessionAsync } from "expo-web-browser";
42
+
43
+ // inside your login onPress, perhaps:
44
+ const loginUrl = await oauthClient.authorize(pds);
45
+ const res = await openAuthSessionAsync(loginUrl);
46
+ if (res.type === "success") {
47
+ const params = new URLSearchParams(url.split("?")[1]);
48
+ const { session, state } = await oauthClient.callback(params);
49
+ console.log(`logged in as ${session.sub}`);
50
+ }
51
+ ```
52
+
53
+ ## Development on localhost
54
+
55
+ The atproto OAuth specification has a special case for development on localhost,
56
+ but it is required to use a redirectUrl that returns to `127.0.0.1` or `[::1]`.
57
+ This prevents the localhost OAuth flow from returning you directly to your app.
58
+ As a workaround, you can host a static HTML server on 127.0.0.1 that recieves
59
+ the incoming OAuth callback and then redirects to your app. (If you have a web
60
+ version of your React Native app, you can just use that.) Such a redirect page
61
+ might look something like this:
62
+
63
+ ```tsx
64
+ import { useEffect } from "react";
65
+ import { View, Text } from "react-native";
66
+
67
+ export default function AppReturnScreen({ route }) {
68
+ useEffect(() => {
69
+ document.location.href = `com.example.app:/app-return${document.location.search}`;
70
+ }, []);
71
+ return (
72
+ <View style={{ flex: 1, alignItems: "center", justifyContent: "center" }}>
73
+ <Text>Redirecting you back to the app...</Text>
74
+ </View>
75
+ );
76
+ }
77
+ ```
78
+
79
+ This flow will work on the iOS simulator and on Android devices provided you've
80
+ forwarded the port with `adb reverse`. For testing on iOS hardware, you'll
81
+ instead need to set up TLS.
82
+
83
+ [react-native-quick-crypto]:
84
+ https://github.com/margelo/react-native-quick-crypto
85
+ [expo-sqlite]: https://docs.expo.dev/versions/latest/sdk/sqlite/
86
+ [README]:
87
+ https://github.com/bluesky-social/atproto/tree/main/packages/oauth/oauth-client-browser
88
+ [example]:
89
+ https://github.com/bluesky-social/atproto/tree/main/packages/oauth/oauth-client-browser-example
@@ -0,0 +1,3 @@
1
+ import "./polyfills";
2
+ export * from "@atproto/oauth-client";
3
+ export * from "./oauth-client-react-native";
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ import "./polyfills";
2
+ export * from "@atproto/oauth-client";
3
+ export * from "./oauth-client-react-native";
@@ -0,0 +1,2 @@
1
+ import { BrowserOAuthClient } from "@atproto/oauth-client-browser";
2
+ export { BrowserOAuthClient as ReactNativeOAuthClient };
@@ -0,0 +1,4 @@
1
+ // browser fallback
2
+ // export * from "@atproto/oauth-client-browser";
3
+ import { BrowserOAuthClient } from "@atproto/oauth-client-browser";
4
+ export { BrowserOAuthClient as ReactNativeOAuthClient };
@@ -0,0 +1,24 @@
1
+ import { SimpleStore } from "@atproto-labs/simple-store";
2
+ import { OAuthClient, OAuthClientFetchMetadataOptions, OAuthClientOptions, OAuthSession, SessionStore, StateStore } from "@atproto/oauth-client";
3
+ import { SubtleAlgorithm } from "react-native-quick-crypto/lib/typescript/src/keys";
4
+ export type ReactNativeOAuthClientOptions = Omit<OAuthClientOptions, "runtimeImplementation" | "sessionStore" | "stateStore"> & {
5
+ sessionStore?: SessionStore;
6
+ stateStore?: StateStore;
7
+ didStore?: SimpleStore<string, string>;
8
+ };
9
+ export type ReactNativeOAuthClientFromMetadataOptions = OAuthClientFetchMetadataOptions & Omit<ReactNativeOAuthClientOptions, "clientMetadata">;
10
+ export declare class ReactNativeOAuthClient extends OAuthClient {
11
+ didStore: SimpleStore<string, string>;
12
+ static fromClientId(options: ReactNativeOAuthClientFromMetadataOptions): Promise<ReactNativeOAuthClient>;
13
+ constructor({ fetch, responseMode, ...options }: ReactNativeOAuthClientOptions);
14
+ init(refresh?: boolean): Promise<{
15
+ session: OAuthSession;
16
+ } | undefined>;
17
+ callback(params: URLSearchParams): Promise<{
18
+ session: OAuthSession;
19
+ state: string | null;
20
+ }>;
21
+ }
22
+ export declare function toSubtleAlgorithm(alg: string, crv?: string, options?: {
23
+ modulusLength?: number;
24
+ }): SubtleAlgorithm;
@@ -0,0 +1,123 @@
1
+ import { jwkValidator } from "@atproto/jwk";
2
+ import { JoseKey } from "@atproto/jwk-jose";
3
+ import { OAuthClient, } from "@atproto/oauth-client";
4
+ import QuickCrypto from "react-native-quick-crypto";
5
+ import { JoseKeyStore, SQLiteKVStore } from "./sqlite-keystore";
6
+ export class ReactNativeOAuthClient extends OAuthClient {
7
+ static async fromClientId(options) {
8
+ const clientMetadata = await OAuthClient.fetchMetadata(options);
9
+ return new ReactNativeOAuthClient({ ...options, clientMetadata });
10
+ }
11
+ constructor({ fetch, responseMode = "query", ...options }) {
12
+ if (!options.stateStore) {
13
+ options.stateStore = new JoseKeyStore(new SQLiteKVStore("state"));
14
+ }
15
+ if (!options.sessionStore) {
16
+ options.sessionStore = new JoseKeyStore(new SQLiteKVStore("session"));
17
+ }
18
+ if (!options.didStore) {
19
+ options.didStore = new SQLiteKVStore("did");
20
+ }
21
+ super({
22
+ ...options,
23
+ sessionStore: options.sessionStore,
24
+ stateStore: options.stateStore,
25
+ fetch,
26
+ responseMode,
27
+ runtimeImplementation: {
28
+ createKey: async (algs) => {
29
+ console.log("GOT HEREEEE!");
30
+ const errors = [];
31
+ for (const alg of algs) {
32
+ try {
33
+ let subtle = QuickCrypto?.webcrypto?.subtle;
34
+ const subalg = toSubtleAlgorithm(alg);
35
+ const keyPair = (await subtle.generateKey(subalg, true, [
36
+ "sign",
37
+ "verify",
38
+ ]));
39
+ const ex = (await subtle.exportKey("jwk", keyPair.privateKey));
40
+ ex.alg = alg;
41
+ // these have trailing periods sometimes for some reason
42
+ for (const k of ["x", "y", "d"]) {
43
+ if (ex[k].endsWith(".")) {
44
+ ex[k] = ex[k].slice(0, -1);
45
+ }
46
+ }
47
+ // RNQC doesn't give us a kid, so let's do a quick hash of the key
48
+ const kid = QuickCrypto.createHash("sha256")
49
+ .update(JSON.stringify(ex))
50
+ .digest("hex");
51
+ const use = "sig";
52
+ return new JoseKey(jwkValidator.parse({ ...ex, kid, use }));
53
+ }
54
+ catch (err) {
55
+ errors.push(err);
56
+ }
57
+ }
58
+ throw new Error("None of the algorithms worked");
59
+ },
60
+ getRandomValues: (length) => new Uint8Array(QuickCrypto.randomBytes(length)),
61
+ digest: (bytes, algorithm) => QuickCrypto.createHash(algorithm.name)
62
+ .update(bytes)
63
+ .digest(),
64
+ },
65
+ clientMetadata: options.clientMetadata,
66
+ });
67
+ this.didStore = options.didStore;
68
+ }
69
+ async init(refresh) {
70
+ const sub = await this.didStore.get(`(sub)`);
71
+ if (sub) {
72
+ try {
73
+ const session = await this.restore(sub, refresh);
74
+ return { session };
75
+ }
76
+ catch (err) {
77
+ this.didStore.del(`(sub)`);
78
+ throw err;
79
+ }
80
+ }
81
+ }
82
+ async callback(params) {
83
+ const { session, state } = await super.callback(params);
84
+ await this.didStore.set(`(sub)`, session.sub);
85
+ return { session, state };
86
+ }
87
+ }
88
+ export function toSubtleAlgorithm(alg, crv, options) {
89
+ switch (alg) {
90
+ case "PS256":
91
+ case "PS384":
92
+ case "PS512":
93
+ return {
94
+ name: "RSA-PSS",
95
+ hash: `SHA-${alg.slice(-3)}`,
96
+ modulusLength: options?.modulusLength ?? 2048,
97
+ publicExponent: new Uint8Array([0x01, 0x00, 0x01]),
98
+ };
99
+ case "RS256":
100
+ case "RS384":
101
+ case "RS512":
102
+ return {
103
+ name: "RSASSA-PKCS1-v1_5",
104
+ hash: `SHA-${alg.slice(-3)}`,
105
+ modulusLength: options?.modulusLength ?? 2048,
106
+ publicExponent: new Uint8Array([0x01, 0x00, 0x01]),
107
+ };
108
+ case "ES256":
109
+ case "ES384":
110
+ return {
111
+ name: "ECDSA",
112
+ namedCurve: `P-${alg.slice(-3)}`,
113
+ };
114
+ case "ES512":
115
+ return {
116
+ name: "ECDSA",
117
+ namedCurve: "P-521",
118
+ };
119
+ default:
120
+ // https://github.com/w3c/webcrypto/issues/82#issuecomment-849856773
121
+ throw new TypeError(`Unsupported alg "${alg}"`);
122
+ }
123
+ }
File without changes
File without changes
@@ -0,0 +1 @@
1
+ import "abortcontroller-polyfill/dist/polyfill-patch-fetch";
@@ -0,0 +1,32 @@
1
+ import { Event, EventTarget } from "event-target-shim";
2
+ import { install as installRNQC } from "react-native-quick-crypto";
3
+ // Polyfill for the `throwIfAborted` method of the AbortController
4
+ // used in @atproto/oauth-client
5
+ import "abortcontroller-polyfill/dist/polyfill-patch-fetch";
6
+ // Polyfill for jose. It tries to detect whether it's been passed a CryptoKey
7
+ // instance, and isn't willing to accept RNQC's equivalent. So, this ensures that
8
+ // `key instanceof CryptoKey` will always be true.
9
+ // @ts-ignore
10
+ global.CryptoKey = Object;
11
+ // This is needed to populate the `crypto` global for jose's export here
12
+ // https://github.com/panva/jose/blob/1e8b430b08a18a18883a69e7991832c9c602ca1a/src/runtime/browser/webcrypto.ts#L1
13
+ installRNQC();
14
+ // These two are needed for @atproto/oauth-client's `CustomEventTarget` to work.
15
+ // @ts-ignore
16
+ global.EventTarget = EventTarget;
17
+ // @ts-ignore
18
+ global.Event = Event;
19
+ // And finally, this happens on React Native with every possible input:
20
+ // URL.canParse("http://example.com") => false
21
+ // I do not know why. Used in @atproto/oauth and @atproto/common-web
22
+ if (!URL.canParse("http://example.com")) {
23
+ URL.canParse = (url, base) => {
24
+ try {
25
+ new URL(url, base);
26
+ return true;
27
+ }
28
+ catch (e) {
29
+ return false;
30
+ }
31
+ };
32
+ }
@@ -0,0 +1,29 @@
1
+ import { SimpleStore } from "@atproto-labs/simple-store";
2
+ import { Key } from "@atproto/jwk";
3
+ interface HasDPoPKey {
4
+ dpopKey: Key | undefined;
5
+ }
6
+ /**
7
+ * An expo-sqlite store that handles serializing and deserializing
8
+ * our Jose DPoP keys. Wraps SQLiteKVStore or whatever other SimpleStore
9
+ * that a user might provide.
10
+ */
11
+ export declare class JoseKeyStore<T extends HasDPoPKey> {
12
+ private store;
13
+ constructor(store: SimpleStore<string, string>);
14
+ get(key: string): Promise<T | undefined>;
15
+ set(key: string, value: T): Promise<void>;
16
+ del(key: string): Promise<void>;
17
+ }
18
+ /**
19
+ * Simple wrapper around expo-sqlite's KVStore. Default implementation
20
+ * unless a user brings their own KV store.
21
+ */
22
+ export declare class SQLiteKVStore implements SimpleStore<string, string> {
23
+ private namespace;
24
+ constructor(namespace: string);
25
+ get(key: string): Promise<string | undefined>;
26
+ set(key: string, value: string): Promise<void>;
27
+ del(key: string): Promise<void>;
28
+ }
29
+ export {};
@@ -0,0 +1,54 @@
1
+ import { jwkValidator } from "@atproto/jwk";
2
+ import { JoseKey } from "@atproto/jwk-jose";
3
+ import Storage from "expo-sqlite/kv-store";
4
+ const NAMESPACE = `@@atproto/oauth-client-react-native`;
5
+ /**
6
+ * An expo-sqlite store that handles serializing and deserializing
7
+ * our Jose DPoP keys. Wraps SQLiteKVStore or whatever other SimpleStore
8
+ * that a user might provide.
9
+ */
10
+ export class JoseKeyStore {
11
+ constructor(store) {
12
+ this.store = store;
13
+ }
14
+ async get(key) {
15
+ const itemStr = await this.store.get(key);
16
+ if (!itemStr)
17
+ return undefined;
18
+ const item = JSON.parse(itemStr);
19
+ if (item.dpopKey) {
20
+ item.dpopKey = new JoseKey(jwkValidator.parse(item.dpopKey));
21
+ }
22
+ return item;
23
+ }
24
+ async set(key, value) {
25
+ if (value.dpopKey) {
26
+ value = {
27
+ ...value,
28
+ dpopKey: value.dpopKey.privateJwk,
29
+ };
30
+ }
31
+ return await this.store.set(key, JSON.stringify(value));
32
+ }
33
+ async del(key) {
34
+ return await this.store.del(key);
35
+ }
36
+ }
37
+ /**
38
+ * Simple wrapper around expo-sqlite's KVStore. Default implementation
39
+ * unless a user brings their own KV store.
40
+ */
41
+ export class SQLiteKVStore {
42
+ constructor(namespace) {
43
+ this.namespace = `${NAMESPACE}:${namespace}`;
44
+ }
45
+ async get(key) {
46
+ return (await Storage.getItem(`${this.namespace}:${key}`)) ?? undefined;
47
+ }
48
+ async set(key, value) {
49
+ return await Storage.setItem(`${this.namespace}:${key}`, value);
50
+ }
51
+ async del(key) {
52
+ return await Storage.removeItem(`${this.namespace}:${key}`);
53
+ }
54
+ }
package/package.json CHANGED
@@ -1,13 +1,58 @@
1
1
  {
2
2
  "name": "@streamplace/atproto-oauth-client-react-native",
3
- "version": "0.0.1",
4
- "description": "",
5
- "main": "package.json",
3
+ "version": "0.7.0",
4
+ "license": "MIT",
5
+ "description": "ATProto OAuth client for React Native",
6
+ "keywords": [
7
+ "atproto",
8
+ "oauth",
9
+ "client",
10
+ "node"
11
+ ],
12
+ "homepage": "https://atproto.com",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "https://github.com/bluesky-social/atproto",
16
+ "directory": "packages/oauth/oauth-client-react-native"
17
+ },
18
+ "type": "commonjs",
19
+ "main": "dist/index.js",
20
+ "types": "dist/index.d.ts",
21
+ "exports": {
22
+ ".": {
23
+ "types": "./dist/index.d.ts",
24
+ "default": "./dist/index.js"
25
+ }
26
+ },
27
+ "files": [
28
+ "dist",
29
+ "src"
30
+ ],
31
+ "dependencies": {
32
+ "@atproto-labs/did-resolver": "0.1.12",
33
+ "@atproto-labs/handle-resolver-node": "0.1.15",
34
+ "@atproto-labs/simple-store": "0.2.0",
35
+ "@atproto-labs/simple-store-memory": "0.1.3",
36
+ "@atproto/did": "0.1.5",
37
+ "@atproto/jwk": "0.1.5",
38
+ "@atproto/jwk-jose": "0.1.6",
39
+ "@atproto/jwk-webcrypto": "0.1.6",
40
+ "@atproto/oauth-client": "0.3.16",
41
+ "@atproto/oauth-client-browser": "0.3.16",
42
+ "@atproto/oauth-types": "0.2.7",
43
+ "abortcontroller-polyfill": "^1.7.6",
44
+ "event-target-shim": "^6.0.2",
45
+ "expo-sqlite": "^15.0.3",
46
+ "jose": "^5.2.0",
47
+ "react-native-quick-crypto": "^0.7.7"
48
+ },
49
+ "devDependencies": {
50
+ "@types/node": "^22.10.1",
51
+ "typescript": "^5.6.3"
52
+ },
6
53
  "scripts": {
7
- "test": "echo \"Error: no test specified\" && exit 1"
54
+ "build": "tsc --build tsconfig.build.json",
55
+ "postinstall": "pnpm run build"
8
56
  },
9
- "keywords": [],
10
- "author": "",
11
- "license": "ISC",
12
- "packageManager": "pnpm@9.14.4+sha512.c8180b3fbe4e4bca02c94234717896b5529740a6cbadf19fa78254270403ea2f27d4e1d46a08a0f56c89b63dc8ebfd3ee53326da720273794e6200fcf0d184ab"
57
+ "gitHead": "c0b9266fbc2cb2a643203e8c0450980c1bd29635"
13
58
  }
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ import "./polyfills";
2
+
3
+ export * from "@atproto/oauth-client";
4
+ export * from "./oauth-client-react-native";
@@ -0,0 +1,188 @@
1
+ import { SimpleStore } from "@atproto-labs/simple-store";
2
+ import { jwkValidator } from "@atproto/jwk";
3
+ import { JoseKey } from "@atproto/jwk-jose";
4
+ import {
5
+ InternalStateData,
6
+ OAuthClient,
7
+ OAuthClientFetchMetadataOptions,
8
+ OAuthClientOptions,
9
+ OAuthSession,
10
+ Session,
11
+ SessionStore,
12
+ StateStore,
13
+ } from "@atproto/oauth-client";
14
+ import { JWK } from "jose";
15
+ import QuickCrypto from "react-native-quick-crypto";
16
+ import {
17
+ CryptoKey,
18
+ SubtleAlgorithm,
19
+ } from "react-native-quick-crypto/lib/typescript/src/keys";
20
+ import { JoseKeyStore, SQLiteKVStore } from "./sqlite-keystore";
21
+
22
+ export type ReactNativeOAuthClientOptions = Omit<
23
+ OAuthClientOptions,
24
+ // Provided by this lib
25
+ | "runtimeImplementation"
26
+ // Provided by this lib but can be overridden
27
+ | "sessionStore"
28
+ | "stateStore"
29
+ > & {
30
+ sessionStore?: SessionStore;
31
+ stateStore?: StateStore;
32
+ didStore?: SimpleStore<string, string>;
33
+ };
34
+
35
+ export type ReactNativeOAuthClientFromMetadataOptions =
36
+ OAuthClientFetchMetadataOptions &
37
+ Omit<ReactNativeOAuthClientOptions, "clientMetadata">;
38
+
39
+ export class ReactNativeOAuthClient extends OAuthClient {
40
+ didStore: SimpleStore<string, string>;
41
+
42
+ static async fromClientId(
43
+ options: ReactNativeOAuthClientFromMetadataOptions,
44
+ ) {
45
+ const clientMetadata = await OAuthClient.fetchMetadata(options);
46
+ return new ReactNativeOAuthClient({ ...options, clientMetadata });
47
+ }
48
+
49
+ constructor({
50
+ fetch,
51
+ responseMode = "query",
52
+
53
+ ...options
54
+ }: ReactNativeOAuthClientOptions) {
55
+ if (!options.stateStore) {
56
+ options.stateStore = new JoseKeyStore<InternalStateData>(
57
+ new SQLiteKVStore("state"),
58
+ );
59
+ }
60
+ if (!options.sessionStore) {
61
+ options.sessionStore = new JoseKeyStore<Session>(
62
+ new SQLiteKVStore("session"),
63
+ );
64
+ }
65
+ if (!options.didStore) {
66
+ options.didStore = new SQLiteKVStore("did");
67
+ }
68
+ super({
69
+ ...options,
70
+
71
+ sessionStore: options.sessionStore,
72
+ stateStore: options.stateStore,
73
+ fetch,
74
+ responseMode,
75
+ runtimeImplementation: {
76
+ createKey: async (algs): Promise<JoseKey> => {
77
+ console.log("GOT HEREEEE!");
78
+ const errors: unknown[] = [];
79
+ for (const alg of algs) {
80
+ try {
81
+ let subtle = QuickCrypto?.webcrypto?.subtle;
82
+ const subalg = toSubtleAlgorithm(alg);
83
+ const keyPair = (await subtle.generateKey(subalg, true, [
84
+ "sign",
85
+ "verify",
86
+ ])) as CryptoKeyPair;
87
+
88
+ const ex = (await subtle.exportKey(
89
+ "jwk",
90
+ keyPair.privateKey as unknown as CryptoKey,
91
+ )) as JWK;
92
+ ex.alg = alg;
93
+ // these have trailing periods sometimes for some reason
94
+ for (const k of ["x", "y", "d"]) {
95
+ if (ex[k].endsWith(".")) {
96
+ ex[k] = ex[k].slice(0, -1);
97
+ }
98
+ }
99
+
100
+ // RNQC doesn't give us a kid, so let's do a quick hash of the key
101
+ const kid = QuickCrypto.createHash("sha256")
102
+ .update(JSON.stringify(ex))
103
+ .digest("hex");
104
+ const use = "sig";
105
+
106
+ return new JoseKey(jwkValidator.parse({ ...ex, kid, use }));
107
+ } catch (err) {
108
+ errors.push(err);
109
+ }
110
+ }
111
+ throw new Error("None of the algorithms worked");
112
+ },
113
+ getRandomValues: (length) =>
114
+ new Uint8Array(QuickCrypto.randomBytes(length)),
115
+ digest: (bytes, algorithm) =>
116
+ QuickCrypto.createHash(algorithm.name)
117
+ .update(bytes as unknown as ArrayBuffer)
118
+ .digest(),
119
+ },
120
+ clientMetadata: options.clientMetadata,
121
+ });
122
+ this.didStore = options.didStore;
123
+ }
124
+
125
+ async init(refresh?: boolean) {
126
+ const sub = await this.didStore.get(`(sub)`);
127
+ if (sub) {
128
+ try {
129
+ const session = await this.restore(sub, refresh);
130
+ return { session };
131
+ } catch (err) {
132
+ this.didStore.del(`(sub)`);
133
+ throw err;
134
+ }
135
+ }
136
+ }
137
+
138
+ async callback(params: URLSearchParams): Promise<{
139
+ session: OAuthSession;
140
+ state: string | null;
141
+ }> {
142
+ const { session, state } = await super.callback(params);
143
+ await this.didStore.set(`(sub)`, session.sub);
144
+ return { session, state };
145
+ }
146
+ }
147
+
148
+ export function toSubtleAlgorithm(
149
+ alg: string,
150
+ crv?: string,
151
+ options?: { modulusLength?: number },
152
+ ): SubtleAlgorithm {
153
+ switch (alg) {
154
+ case "PS256":
155
+ case "PS384":
156
+ case "PS512":
157
+ return {
158
+ name: "RSA-PSS",
159
+ hash: `SHA-${alg.slice(-3) as "256" | "384" | "512"}`,
160
+ modulusLength: options?.modulusLength ?? 2048,
161
+ publicExponent: new Uint8Array([0x01, 0x00, 0x01]),
162
+ };
163
+ case "RS256":
164
+ case "RS384":
165
+ case "RS512":
166
+ return {
167
+ name: "RSASSA-PKCS1-v1_5",
168
+ hash: `SHA-${alg.slice(-3) as "256" | "384" | "512"}`,
169
+ modulusLength: options?.modulusLength ?? 2048,
170
+ publicExponent: new Uint8Array([0x01, 0x00, 0x01]),
171
+ };
172
+ case "ES256":
173
+ case "ES384":
174
+ return {
175
+ name: "ECDSA",
176
+ namedCurve: `P-${alg.slice(-3) as "256" | "384"}`,
177
+ };
178
+ case "ES512":
179
+ return {
180
+ name: "ECDSA",
181
+ namedCurve: "P-521",
182
+ };
183
+ default:
184
+ // https://github.com/w3c/webcrypto/issues/82#issuecomment-849856773
185
+
186
+ throw new TypeError(`Unsupported alg "${alg}"`);
187
+ }
188
+ }
@@ -0,0 +1,4 @@
1
+ // browser fallback
2
+ // export * from "@atproto/oauth-client-browser";
3
+ import { BrowserOAuthClient } from "@atproto/oauth-client-browser";
4
+ export { BrowserOAuthClient as ReactNativeOAuthClient };
@@ -0,0 +1,36 @@
1
+ import { Event, EventTarget } from "event-target-shim";
2
+ import { install as installRNQC } from "react-native-quick-crypto";
3
+
4
+ // Polyfill for the `throwIfAborted` method of the AbortController
5
+ // used in @atproto/oauth-client
6
+ import "abortcontroller-polyfill/dist/polyfill-patch-fetch";
7
+
8
+ // Polyfill for jose. It tries to detect whether it's been passed a CryptoKey
9
+ // instance, and isn't willing to accept RNQC's equivalent. So, this ensures that
10
+ // `key instanceof CryptoKey` will always be true.
11
+ // @ts-ignore
12
+ global.CryptoKey = Object;
13
+
14
+ // This is needed to populate the `crypto` global for jose's export here
15
+ // https://github.com/panva/jose/blob/1e8b430b08a18a18883a69e7991832c9c602ca1a/src/runtime/browser/webcrypto.ts#L1
16
+ installRNQC();
17
+
18
+ // These two are needed for @atproto/oauth-client's `CustomEventTarget` to work.
19
+ // @ts-ignore
20
+ global.EventTarget = EventTarget;
21
+ // @ts-ignore
22
+ global.Event = Event;
23
+
24
+ // And finally, this happens on React Native with every possible input:
25
+ // URL.canParse("http://example.com") => false
26
+ // I do not know why. Used in @atproto/oauth and @atproto/common-web
27
+ if (!URL.canParse("http://example.com")) {
28
+ URL.canParse = (url: string | URL, base?: string) => {
29
+ try {
30
+ new URL(url, base);
31
+ return true;
32
+ } catch (e) {
33
+ return false;
34
+ }
35
+ };
36
+ }
File without changes
@@ -0,0 +1,69 @@
1
+ import { SimpleStore } from "@atproto-labs/simple-store";
2
+ import { jwkValidator, Key } from "@atproto/jwk";
3
+ import { JoseKey } from "@atproto/jwk-jose";
4
+ import Storage from "expo-sqlite/kv-store";
5
+
6
+ interface HasDPoPKey {
7
+ dpopKey: Key | undefined;
8
+ }
9
+
10
+ const NAMESPACE = `@@atproto/oauth-client-react-native`;
11
+
12
+ /**
13
+ * An expo-sqlite store that handles serializing and deserializing
14
+ * our Jose DPoP keys. Wraps SQLiteKVStore or whatever other SimpleStore
15
+ * that a user might provide.
16
+ */
17
+ export class JoseKeyStore<T extends HasDPoPKey> {
18
+ private store: SimpleStore<string, string>;
19
+ constructor(store: SimpleStore<string, string>) {
20
+ this.store = store;
21
+ }
22
+
23
+ async get(key: string): Promise<T | undefined> {
24
+ const itemStr = await this.store.get(key);
25
+ if (!itemStr) return undefined;
26
+ const item = JSON.parse(itemStr) as T;
27
+ if (item.dpopKey) {
28
+ item.dpopKey = new JoseKey(jwkValidator.parse(item.dpopKey));
29
+ }
30
+ return item;
31
+ }
32
+
33
+ async set(key: string, value: T): Promise<void> {
34
+ if (value.dpopKey) {
35
+ value = {
36
+ ...value,
37
+ dpopKey: (value.dpopKey as JoseKey).privateJwk,
38
+ };
39
+ }
40
+ return await this.store.set(key, JSON.stringify(value));
41
+ }
42
+
43
+ async del(key: string): Promise<void> {
44
+ return await this.store.del(key);
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Simple wrapper around expo-sqlite's KVStore. Default implementation
50
+ * unless a user brings their own KV store.
51
+ */
52
+ export class SQLiteKVStore implements SimpleStore<string, string> {
53
+ private namespace: string;
54
+ constructor(namespace: string) {
55
+ this.namespace = `${NAMESPACE}:${namespace}`;
56
+ }
57
+
58
+ async get(key: string): Promise<string | undefined> {
59
+ return (await Storage.getItem(`${this.namespace}:${key}`)) ?? undefined;
60
+ }
61
+
62
+ async set(key: string, value: string): Promise<void> {
63
+ return await Storage.setItem(`${this.namespace}:${key}`, value);
64
+ }
65
+
66
+ async del(key: string): Promise<void> {
67
+ return await Storage.removeItem(`${this.namespace}:${key}`);
68
+ }
69
+ }