├── .gitignore
├── LICENSE
├── README.md
├── package.json
├── pnpm-lock.yaml
├── src
├── index.ts
├── oauth-client-react-native.native.ts
├── oauth-client-react-native.ts
├── polyfills.native.ts
├── polyfills.ts
└── sqlite-keystore.ts
├── tsconfig.build.json
├── tsconfig.json
└── update.sh
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist
3 | tsconfig.build.tsbuildinfo
4 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy
4 | of this software and associated documentation files (the "Software"), to deal
5 | in the Software without restriction, including without limitation the rights
6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | copies of the Software, and to permit persons to whom the Software is
8 | furnished to do so, 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,
15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | SOFTWARE.
20 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
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 |
73 | Redirecting you back to the app...
74 |
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
90 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@aquareum/atproto-oauth-client-react-native",
3 | "version": "0.0.1",
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 | ],
30 | "dependencies": {
31 | "@atproto-labs/did-resolver": "0.1.5",
32 | "@atproto-labs/handle-resolver-node": "0.1.7",
33 | "@atproto-labs/simple-store": "0.1.1",
34 | "@atproto-labs/simple-store-memory": "0.1.1",
35 | "@atproto/did": "0.1.3",
36 | "@atproto/jwk": "0.1.1",
37 | "@atproto/jwk-jose": "0.1.2",
38 | "@atproto/jwk-webcrypto": "0.1.2",
39 | "@atproto/oauth-client": "0.3.2",
40 | "@atproto/oauth-client-browser": "0.3.2",
41 | "@atproto/oauth-types": "0.2.1",
42 | "abortcontroller-polyfill": "^1.7.6",
43 | "event-target-shim": "^6.0.2",
44 | "expo-sqlite": "^15.0.3",
45 | "jose": "^5.2.0",
46 | "react-native-quick-crypto": "^0.7.7"
47 | },
48 | "devDependencies": {
49 | "@types/node": "^22.10.1",
50 | "typescript": "^5.6.3"
51 | },
52 | "scripts": {
53 | "build": "tsc --build tsconfig.build.json"
54 | },
55 | "packageManager": "pnpm@9.14.4+sha512.c8180b3fbe4e4bca02c94234717896b5529740a6cbadf19fa78254270403ea2f27d4e1d46a08a0f56c89b63dc8ebfd3ee53326da720273794e6200fcf0d184ab"
56 | }
57 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import './polyfills'
2 |
3 | export * from '@atproto/oauth-client'
4 | export * from './oauth-client-react-native'
5 |
--------------------------------------------------------------------------------
/src/oauth-client-react-native.native.ts:
--------------------------------------------------------------------------------
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
33 | }
34 |
35 | export type ReactNativeOAuthClientFromMetadataOptions =
36 | OAuthClientFetchMetadataOptions &
37 | Omit
38 |
39 | export class ReactNativeOAuthClient extends OAuthClient {
40 | didStore: SimpleStore
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(
57 | new SQLiteKVStore('state'),
58 | )
59 | }
60 | if (!options.sessionStore) {
61 | options.sessionStore = new JoseKeyStore(
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 => {
77 | const errors: unknown[] = []
78 | for (const alg of algs) {
79 | try {
80 | let subtle = QuickCrypto?.webcrypto?.subtle
81 | const subalg = toSubtleAlgorithm(alg)
82 | const keyPair = (await subtle.generateKey(subalg, true, [
83 | 'sign',
84 | 'verify',
85 | ])) as CryptoKeyPair
86 |
87 | const ex = (await subtle.exportKey(
88 | 'jwk',
89 | keyPair.privateKey as unknown as CryptoKey,
90 | )) as JWK
91 | ex.alg = alg
92 |
93 | // RNQC doesn't give us a kid, so let's do a quick hash of the key
94 | const kid = QuickCrypto.createHash('sha256')
95 | .update(JSON.stringify(ex))
96 | .digest('hex')
97 | const use = 'sig'
98 |
99 | return new JoseKey(jwkValidator.parse({ ...ex, kid, use }))
100 | } catch (err) {
101 | errors.push(err)
102 | }
103 | }
104 | throw new AggregateError(errors, 'None of the algorithms worked')
105 | },
106 | getRandomValues: (length) =>
107 | new Uint8Array(QuickCrypto.randomBytes(length)),
108 | digest: (bytes, algorithm) =>
109 | QuickCrypto.createHash(algorithm.name).update(bytes).digest(),
110 | },
111 | clientMetadata: options.clientMetadata,
112 | })
113 | this.didStore = options.didStore
114 | }
115 |
116 | async init(refresh?: boolean) {
117 | const sub = await this.didStore.get(`(sub)`)
118 | if (sub) {
119 | try {
120 | const session = await this.restore(sub, refresh)
121 | return { session }
122 | } catch (err) {
123 | this.didStore.del(`(sub)`)
124 | throw err
125 | }
126 | }
127 | }
128 |
129 | async callback(params: URLSearchParams): Promise<{
130 | session: OAuthSession
131 | state: string | null
132 | }> {
133 | const { session, state } = await super.callback(params)
134 | await this.didStore.set(`(sub)`, session.sub)
135 | return { session, state }
136 | }
137 | }
138 |
139 | export function toSubtleAlgorithm(
140 | alg: string,
141 | crv?: string,
142 | options?: { modulusLength?: number },
143 | ): SubtleAlgorithm {
144 | switch (alg) {
145 | case 'PS256':
146 | case 'PS384':
147 | case 'PS512':
148 | return {
149 | name: 'RSA-PSS',
150 | hash: `SHA-${alg.slice(-3) as '256' | '384' | '512'}`,
151 | modulusLength: options?.modulusLength ?? 2048,
152 | publicExponent: new Uint8Array([0x01, 0x00, 0x01]),
153 | }
154 | case 'RS256':
155 | case 'RS384':
156 | case 'RS512':
157 | return {
158 | name: 'RSASSA-PKCS1-v1_5',
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 'ES256':
164 | case 'ES384':
165 | return {
166 | name: 'ECDSA',
167 | namedCurve: `P-${alg.slice(-3) as '256' | '384'}`,
168 | }
169 | case 'ES512':
170 | return {
171 | name: 'ECDSA',
172 | namedCurve: 'P-521',
173 | }
174 | default:
175 | // https://github.com/w3c/webcrypto/issues/82#issuecomment-849856773
176 |
177 | throw new TypeError(`Unsupported alg "${alg}"`)
178 | }
179 | }
180 |
--------------------------------------------------------------------------------
/src/oauth-client-react-native.ts:
--------------------------------------------------------------------------------
1 | // browser fallback
2 | export * from '@atproto/oauth-client-browser'
3 | export { BrowserOAuthClient as ReactNativeOAuthClient }
4 | import { BrowserOAuthClient } from '@atproto/oauth-client-browser'
5 |
--------------------------------------------------------------------------------
/src/polyfills.native.ts:
--------------------------------------------------------------------------------
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 | }
37 |
--------------------------------------------------------------------------------
/src/polyfills.ts:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/streamplace/atproto-oauth-client-react-native/4aefbe969573d9404b3e1bc4dfae7fcf31a5ee2d/src/polyfills.ts
--------------------------------------------------------------------------------
/src/sqlite-keystore.ts:
--------------------------------------------------------------------------------
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 {
18 | private store: SimpleStore
19 | constructor(store: SimpleStore) {
20 | this.store = store
21 | }
22 |
23 | async get(key: string): Promise {
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 {
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 {
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 {
53 | private namespace: string
54 | constructor(namespace: string) {
55 | this.namespace = `${NAMESPACE}:${namespace}`
56 | }
57 |
58 | async get(key: string): Promise {
59 | return (await Storage.getItem(`${this.namespace}:${key}`)) ?? undefined
60 | }
61 |
62 | async set(key: string, value: string): Promise {
63 | return await Storage.setItem(`${this.namespace}:${key}`, value)
64 | }
65 |
66 | async del(key: string): Promise {
67 | return await Storage.removeItem(`${this.namespace}:${key}`)
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/tsconfig.build.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../atproto/tsconfig/isomorphic.json",
3 | "compilerOptions": {
4 | "rootDir": "./src",
5 | "outDir": "./dist"
6 | },
7 | "include": ["./src"]
8 | }
9 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": [],
3 | "references": [{ "path": "./tsconfig.build.json" }]
4 | }
5 |
--------------------------------------------------------------------------------
/update.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | set -euo pipefail
4 |
5 | # script for pulling new versions of this repo out of atproto for publishing
6 |
7 | DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
8 | cd "$DIR/../atproto/packages/oauth/oauth-client-react-native"
9 | pnpm build && pnpm pack
10 | rsync -arv . "$DIR"
11 | cd "$DIR"
12 | rm -rf dist node_modules tsconfig.build.tsbuildinfo
13 | tar xzvf atproto-oauth-client-react-native-*.tgz
14 | rm -rf atproto-oauth-client-react-native-*.tgz
15 | mv ./package/package.json ./package.json
16 | rm -rf ./package
17 | cat > tsconfig.build.json < package.json
32 |
--------------------------------------------------------------------------------