@streamplace/atproto-oauth-client-react-native 0.0.1 → 0.6.37
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 +18 -0
- package/README.md +89 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/oauth-client-react-native.d.ts +2 -0
- package/dist/oauth-client-react-native.js +4 -0
- package/dist/oauth-client-react-native.native.d.ts +24 -0
- package/dist/oauth-client-react-native.native.js +123 -0
- package/dist/polyfills.d.ts +0 -0
- package/dist/polyfills.js +0 -0
- package/dist/polyfills.native.d.ts +1 -0
- package/dist/polyfills.native.js +32 -0
- package/dist/sqlite-keystore.d.ts +29 -0
- package/dist/sqlite-keystore.js +54 -0
- package/package.json +53 -8
- package/src/index.ts +4 -0
- package/src/oauth-client-react-native.native.ts +188 -0
- package/src/oauth-client-react-native.ts +4 -0
- package/src/polyfills.native.ts +36 -0
- package/src/polyfills.ts +0 -0
- package/src/sqlite-keystore.ts +69 -0
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
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.6.37",
|
|
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
|
-
"
|
|
54
|
+
"build": "tsc --build tsconfig.build.json",
|
|
55
|
+
"postinstall": "pnpm run build"
|
|
8
56
|
},
|
|
9
|
-
"
|
|
10
|
-
"author": "",
|
|
11
|
-
"license": "ISC",
|
|
12
|
-
"packageManager": "pnpm@9.14.4+sha512.c8180b3fbe4e4bca02c94234717896b5529740a6cbadf19fa78254270403ea2f27d4e1d46a08a0f56c89b63dc8ebfd3ee53326da720273794e6200fcf0d184ab"
|
|
57
|
+
"gitHead": "87a2627518e509fd364199cb71657a94eac51066"
|
|
13
58
|
}
|
package/src/index.ts
ADDED
|
@@ -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,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
|
+
}
|
package/src/polyfills.ts
ADDED
|
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
|
+
}
|