├── .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 | --------------------------------------------------------------------------------