this repo has no description

Compare changes

Choose any two refs to compare.

+1 -1
.eslintrc.js
···
// @generated by expo-module-scripts
-
module.exports = require('expo-module-scripts/eslintrc.base.js')
+
module.exports = require('expo-module-scripts/eslintrc.base.js');
+21
LICENSE
···
+
MIT License
+
+
Copyright (c) 2025 me@haileyok.com
+
+
Permission is hereby granted, free of charge, to any person obtaining a copy
+
of this software and associated documentation files (the "Software"), to deal
+
in the Software without restriction, including without limitation the rights
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+
copies of the Software, and to permit persons to whom the Software is
+
furnished to do so, subject to the following conditions:
+
+
The above copyright notice and this permission notice shall be included in all
+
copies or substantial portions of the Software.
+
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+
SOFTWARE.
+137 -16
README.md
···
-
# expo-atproto-auth
+
# Expo Atproto OAuth
+
+
This is an Expo client library for Atproto OAuth. It implements the required native crypto functions for supporting JWTs in React Native and uses
+
the base `OAuthClient` interface found in [the Atproto repository](https://github.com/bluesky-social/atproto/tree/main/packages/oauth/oauth-client).
+
+
## Prerequisites
+
+
Before using this library, there are a few additional libraries that you must install within your Expo application.
+
+
- [react-native-mmkv](https://www.npmjs.com/package/react-native-mmkv)
+
- [expo-web-browser](https://www.npmjs.com/package/expo-web-browser)
+
- [@atproto/oauth-client](https://www.npmjs.com/package/@atproto/oauth-client)
+
- [event-target-polyfill](https://www.npmjs.com/package/event-target-polyfill) (or similar)
+
- [abortcontroller-polyfill](https://www.npmjs.com/package/abortcontroller-polyfill) (or similar)
+
+
Apply the two polyfills inside your application's entrypoint (usually `index.ts`). They should be placed _before_ anything else in the file, and particularly before `registerRootComponent(App)`.
+
+
> [!CAUTION]
+
> As of current (Expo 53), you _must_ apply an Expo patch for this library to work. You may use the patch found [here](https://github.com/haileyok/expo-atproto-auth/blob/main/patches/expo%2B53.0.19.patch).
+
A fix for this has been submitted up stream and merged, so will hopefully be fixed in Expo 54 (see the PR [here](https://github.com/expo/expo/pull/38122)).
+
+
### In bare React Native projects
+
+
For bare React Native projects, you must ensure that you have [installed and configured the `expo` package](https://docs.expo.dev/bare/installing-expo-modules/)
+
before continuing.
+
+
## Installation
+
+
Once you have satisfied the prerequisites, you can simply install the library with `yarn add expo-atproto-auth`.
+
+
## Usage
+
+
### Serve your `oauth-client-metadata.json`
+
+
You will need to server an `oauth-client-metadata.json` from your application's website. An example of this metadata
+
would look like this:
+
+
```
+
{
+
"client_id": "https://hailey.at/oauth-client-metadata.json",
+
"client_name": "React Native OAuth Client Demo",
+
"client_uri": "https://hailey.at",
+
"redirect_uris": [
+
"at.hailey:/auth/callback"
+
],
+
"scope": "atproto transition:generic",
+
"token_endpoint_auth_method": "none",
+
"response_types": [
+
"code"
+
],
+
"grant_types": [
+
"authorization_code",
+
"refresh_token"
+
],
+
"application_type": "native",
+
"dpop_bound_access_tokens": true
+
}
+
```
+
+
- The `client_id` should be the same URL as where you are serving your `oauth-client-metadata.json` from
+
- The `client_uri` can be the home page of where you are serving your metadata from
+
- Your `redirect_uris` should contain the native redirect URI in the first position. Additionally, the scheme must be
+
formatted as the _reverse_ of the domain you are serving the metadata from. Since I am serving mine from `hailey.at`,
+
I use `at.hailey` as the scheme. If my domain were `atproto.expo.dev`, I would use `dev.expo.atproto`. Additionally, the scheme _must_ contain _only one trailing slash_ after the `:`. `at.hailey://` would be invalid.
+
- The `application_type` must be `native`
+
+
For a real-world example, see [Skylight's client metadata](https://skylight.expo.app/oauth/client-metadata.json).
-
Atproto OAuth for Expo applications
+
For more information about client metadata, see [the Atproto documentation](https://atproto.com/specs/oauth#client-id-metadata-document).
+
+
### Create a client
-
# API documentation
+
Next, you want to create an `ExpoOAuthClient`. You will need to pass in the same client metadata to the client as you are serving in your `oauth-client-metadata.json`.
-
- [Documentation for the latest stable release](https://docs.expo.dev/versions/latest/sdk/atproto-auth/)
-
- [Documentation for the main branch](https://docs.expo.dev/versions/unversioned/sdk/atproto-auth/)
+
```ts
+
const client = new ExpoOAuthClient({
+
clientMetadata: {
+
client_id: 'https://hailey.at/oauth-client-metadata.json',
+
client_name: 'React Native OAuth Client Demo',
+
client_uri: 'https://hailey.at',
+
redirect_uris: ['at.hailey:/auth/callback'],
+
scope: 'atproto transition:generic',
+
token_endpoint_auth_method: 'none',
+
response_types: ['code'],
+
grant_types: ['authorization_code', 'refresh_token'],
+
application_type: 'native',
+
dpop_bound_access_tokens: true,
+
},
+
handleResolver: 'https://bsky.social',
+
})
+
```
-
# Installation in managed Expo projects
+
### Sign a user in
-
For [managed](https://docs.expo.dev/archive/managed-vs-bare/) Expo projects, please follow the installation instructions in the [API documentation for the latest stable release](#api-documentation). If you follow the link and there is no documentation available then this library is not yet usable within managed projects — it is likely to be included in an upcoming Expo SDK release.
+
Whenever you are ready, you can initiate a sign in attempt for the user using the client using `client.signIn(input)`
-
# Installation in bare React Native projects
+
`input` must be one of the following:
+
- A valid Atproto user handle, e.g. `hailey.bsky.team` or `hailey.at`
+
- A valid DID, e.g. `did:web:hailey.at` or `did:plc:oisofpd7lj26yvgiivf3lxsi`
+
- A valid PDS host, e.g. `https://cocoon.hailey.at` or `https://bsky.social`
-
For bare React Native projects, you must ensure that you have [installed and configured the `expo` package](https://docs.expo.dev/bare/installing-expo-modules/) before continuing.
+
> [!NOTE]
+
> If you wish to allow a user to _create_ an account instead of signing in, simply use a valid PDS hostname rather than
+
> a handle. They will be presented the option to either Sign In with an existing account, or create a new one.
-
### Add the package to your npm dependencies
+
The response of `signIn` will be a promise resolving to the following:
+
```ts
+
| { status: WebBrowserResultType } // See Expo Web Browser documentation
+
| { status: 'error'; error: unknown }
+
| { status: 'success'; session: OAuthSession }
```
-
npm install expo-atproto-auth
+
+
For example:
+
+
```ts
+
const res = await client.signIn(input ?? '')
+
if (res.status === 'success') {
+
setSession(res.session)
+
const newAgent = new Agent(res.session)
+
setAgent(newAgent)
+
} else if (res.status === 'error') {
+
Alert.alert('Error', (res.error as any).toString())
+
} else {
+
Alert.alert(
+
'Error',
+
`Received unknown WebResultType: ${res.status}`
+
)
+
}
```
-
### Configure for Android
+
### Create an `Agent`
+
To interface with the various Atproto APIs, you will need to create an `Agent`. You will pass your `OAuthSession` to the `Agent`.
+
```ts
+
const newAgent = new Agent(res.session)
+
```
+
Session refreshes will be handled for you for the lifetime of the agent.
+
+
### Restoring a session
-
### Configure for iOS
+
After, for example, closing the application, you will probably need to restore the user's session. You can do this by using the user's DID on the `ExpoOAuthClient`.
+
+
```ts
+
const restoreRes = await client.restore('did:plc:oisofpd7lj26yvgiivf3lxsi')
+
const newAgent = new Agent(restoreRes)
+
```
-
Run `npx pod-install` after installing the npm package.
+
If the session needs to be refreshed, `.restore()` will do this for you before returning a session.
-
# Contributing
+
## Additional Reading
-
Contributions are very welcome! Please refer to guidelines described in the [contributing guide]( https://github.com/expo/expo#contributing).
+
- [Atproto OAuth Spec](https://atproto.com/specs/oauth)
+
- [Atproto Web OAuth Example](https://github.com/bluesky-social/atproto/tree/main/packages/oauth/oauth-client-browser-example)
+2
android/.editorconfig
···
+
[*.{kt,kts}]
+
indent_size=2
+4
android/build.gradle
···
abortOnError false
}
}
+
+
dependencies {
+
implementation "com.nimbusds:nimbus-jose-jwt:10.3.1"
+
}
+70
android/src/main/java/expo/modules/atprotoauth/Crypto.kt
···
+
package expo.modules.expoatprotoauth
+
+
import com.nimbusds.jose.Algorithm
+
import com.nimbusds.jose.jwk.Curve
+
import com.nimbusds.jose.jwk.ECKey
+
import com.nimbusds.jose.jwk.KeyUse
+
import com.nimbusds.jose.util.Base64URL
+
import expo.modules.atprotoauth.EncodedJWK
+
import java.security.KeyPairGenerator
+
import java.security.MessageDigest
+
import java.security.interfaces.ECPrivateKey
+
import java.security.interfaces.ECPublicKey
+
import java.util.UUID
+
+
class Crypto {
+
fun digest(data: ByteArray): ByteArray {
+
val instance = MessageDigest.getInstance("sha256")
+
return instance.digest(data)
+
}
+
+
fun getRandomValues(byteLength: Int): ByteArray {
+
val random = ByteArray(byteLength)
+
java.security.SecureRandom().nextBytes(random)
+
return random
+
}
+
+
fun generateJwk(): EncodedJWK {
+
val keyIdString = UUID.randomUUID().toString()
+
+
val keyPairGen = KeyPairGenerator.getInstance("EC")
+
keyPairGen.initialize(Curve.P_256.toECParameterSpec())
+
val keyPair = keyPairGen.generateKeyPair()
+
+
val publicKey = keyPair.public as ECPublicKey
+
val privateKey = keyPair.private as ECPrivateKey
+
+
val privateJwk =
+
ECKey
+
.Builder(Curve.P_256, publicKey)
+
.privateKey(privateKey)
+
.keyUse(KeyUse.SIGNATURE)
+
.keyID(keyIdString)
+
.algorithm(Algorithm.parse("ES256"))
+
.build()
+
+
return EncodedJWK().apply {
+
kty = privateJwk.keyType.value
+
use = "sig"
+
crv = privateJwk.curve.toString()
+
kid = keyIdString
+
x = privateJwk.x.toString()
+
y = privateJwk.y.toString()
+
d = privateJwk.d.toString()
+
alg = privateJwk.algorithm.name
+
}
+
}
+
+
fun decodeJwk(encodedJwk: EncodedJWK): ECKey {
+
val xb64url = Base64URL.from(encodedJwk.x)
+
val yb64url = Base64URL.from(encodedJwk.y)
+
val db64url = Base64URL.from(encodedJwk.d)
+
return ECKey
+
.Builder(Curve.P_256, xb64url, yb64url)
+
.d(db64url)
+
.keyUse(KeyUse.SIGNATURE)
+
.keyID(encodedJwk.kid)
+
.algorithm(Algorithm.parse(encodedJwk.alg))
+
.build()
+
}
+
}
+27 -36
android/src/main/java/expo/modules/atprotoauth/ExpoAtprotoAuthModule.kt
···
package expo.modules.atprotoauth
+
import expo.modules.expoatprotoauth.Crypto
+
import expo.modules.expoatprotoauth.Jose
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
-
import java.net.URL
class ExpoAtprotoAuthModule : Module() {
-
// Each module class must implement the definition function. The definition consists of components
-
// that describes the module's functionality and behavior.
-
// See https://docs.expo.dev/modules/module-api for more details about available components.
-
override fun definition() = ModuleDefinition {
-
// Sets the name of the module that JavaScript code will use to refer to the module. Takes a string as an argument.
-
// Can be inferred from module's class name, but it's recommended to set it explicitly for clarity.
-
// The module will be accessible from `requireNativeModule('ExpoAtprotoAuth')` in JavaScript.
-
Name("ExpoAtprotoAuth")
+
override fun definition() =
+
ModuleDefinition {
+
Name("ExpoAtprotoAuth")
-
// Sets constant properties on the module. Can take a dictionary or a closure that returns a dictionary.
-
Constants(
-
"PI" to Math.PI
-
)
+
Function("digest") { data: ByteArray, algo: String ->
+
if (algo != "sha256") {
+
throw IllegalArgumentException("Unsupported algorithm: $algo")
+
}
+
return@Function Crypto().digest(data)
+
}
-
// Defines event names that the module can send to JavaScript.
-
Events("onChange")
+
Function("getRandomValues") { byteLength: Int ->
+
return@Function Crypto().getRandomValues(byteLength)
+
}
-
// Defines a JavaScript synchronous function that runs the native code on the JavaScript thread.
-
Function("hello") {
-
"Hello world! ๐Ÿ‘‹"
-
}
+
Function("generatePrivateJwk") { algo: String ->
+
if (algo != "ES256") {
+
throw IllegalArgumentException("Unsupported algorithm: $algo")
+
}
+
return@Function Crypto().generateJwk()
+
}
-
// Defines a JavaScript function that always returns a Promise and whose native code
-
// is by default dispatched on the different thread than the JavaScript runtime runs on.
-
AsyncFunction("setValueAsync") { value: String ->
-
// Send an event to JavaScript.
-
sendEvent("onChange", mapOf(
-
"value" to value
-
))
-
}
+
Function("createJwt") { header: String, payload: String, encodedJwk: EncodedJWK ->
+
val jwk = Crypto().decodeJwk(encodedJwk)
+
return@Function Jose().createJwt(header, payload, jwk)
+
}
-
// Enables the module to be used as a native view. Definition components that are accepted as part of
-
// the view definition: Prop, Events.
-
View(ExpoAtprotoAuthView::class) {
-
// Defines a setter for the `url` prop.
-
Prop("url") { view: ExpoAtprotoAuthView, url: URL ->
-
view.webView.loadUrl(url.toString())
+
Function("verifyJwt") { token: String, encodedJwk: EncodedJWK, options: VerifyOptions ->
+
val jwk = Crypto().decodeJwk(encodedJwk)
+
return@Function Jose().verifyJwt(token, jwk, options)
}
-
// Defines an event that the view can send to JavaScript.
-
Events("onLoad")
}
-
}
}
-30
android/src/main/java/expo/modules/atprotoauth/ExpoAtprotoAuthView.kt
···
-
package expo.modules.atprotoauth
-
-
import android.content.Context
-
import android.webkit.WebView
-
import android.webkit.WebViewClient
-
import expo.modules.kotlin.AppContext
-
import expo.modules.kotlin.viewevent.EventDispatcher
-
import expo.modules.kotlin.views.ExpoView
-
-
class ExpoAtprotoAuthView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
-
// Creates and initializes an event dispatcher for the `onLoad` event.
-
// The name of the event is inferred from the value and needs to match the event name defined in the module.
-
private val onLoad by EventDispatcher()
-
-
// Defines a WebView that will be used as the root subview.
-
internal val webView = WebView(context).apply {
-
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
-
webViewClient = object : WebViewClient() {
-
override fun onPageFinished(view: WebView, url: String) {
-
// Sends an event to JavaScript. Triggers a callback defined on the view component in JavaScript.
-
onLoad(mapOf("url" to url))
-
}
-
}
-
}
-
-
init {
-
// Adds the WebView to the view hierarchy.
-
addView(webView)
-
}
-
}
+116
android/src/main/java/expo/modules/atprotoauth/Jose.kt
···
+
package expo.modules.expoatprotoauth
+
+
import com.nimbusds.jose.JWSHeader
+
import com.nimbusds.jose.crypto.ECDSASigner
+
import com.nimbusds.jose.crypto.ECDSAVerifier
+
import com.nimbusds.jose.jwk.ECKey
+
import com.nimbusds.jwt.JWTClaimsSet
+
import com.nimbusds.jwt.SignedJWT
+
import expo.modules.atprotoauth.VerifyOptions
+
import expo.modules.atprotoauth.VerifyResult
+
+
class InvalidPayloadException(
+
message: String,
+
) : Exception(message)
+
+
class Jose {
+
fun createJwt(
+
header: String,
+
payload: String,
+
jwk: ECKey,
+
): String {
+
val parsedHeader = JWSHeader.parse(header)
+
val parsedPayload = JWTClaimsSet.parse(payload)
+
+
val signer = ECDSASigner(jwk)
+
val jwt = SignedJWT(parsedHeader, parsedPayload)
+
jwt.sign(signer)
+
+
return jwt.serialize()
+
}
+
+
fun verifyJwt(
+
token: String,
+
jwk: ECKey,
+
options: VerifyOptions,
+
): VerifyResult {
+
val jwt = SignedJWT.parse(token)
+
val verifier = ECDSAVerifier(jwk)
+
+
if (!jwt.verify(verifier)) {
+
throw InvalidPayloadException("invalid JWT signature")
+
}
+
+
val protectedHeader = emptyMap<String, Any>().toMutableMap()
+
protectedHeader["alg"] = jwt.header.algorithm
+
+
jwt.header.getCustomParam("jku")?.let {
+
protectedHeader["jku"] = it.toString()
+
}
+
jwt.header.keyID?.let {
+
protectedHeader["kid"] = it
+
}
+
jwt.header.type?.let {
+
protectedHeader["typ"] = it.toString()
+
}
+
jwt.header.contentType?.let {
+
protectedHeader["cty"] = it
+
}
+
jwt.header.criticalParams?.let {
+
protectedHeader["crit"] = it.toList()
+
}
+
+
options.typ?.let {
+
if (jwt.header.type.toString() != it) {
+
throw InvalidPayloadException("typ mismatch")
+
}
+
}
+
+
val claims = jwt.jwtClaimsSet
+
+
options.requiredClaims?.let { requiredClaims ->
+
requiredClaims.forEach { claim ->
+
if (!claims.claims.containsKey(claim)) {
+
throw InvalidPayloadException("required claim '$claim' missing")
+
}
+
}
+
}
+
+
options.audience?.let {
+
if (!claims.audience.contains(it)) {
+
throw InvalidPayloadException("audience mismatch")
+
}
+
}
+
+
options.subject?.let {
+
if (claims.subject != it) {
+
throw InvalidPayloadException("subject mismatch")
+
}
+
}
+
+
options.checkTolerance?.let {
+
val currentTime = options.currentDate ?: (System.currentTimeMillis() / 1000.0)
+
if (claims.issueTime.time / 1000.0 + it < currentTime) {
+
throw InvalidPayloadException("token expired")
+
}
+
}
+
+
options.maxTokenAge?.let {
+
val currentTime = options.currentDate ?: (System.currentTimeMillis() / 1000.0)
+
if (claims.issueTime.time / 1000.0 + it < currentTime) {
+
throw InvalidPayloadException("token expired")
+
}
+
}
+
+
options.issuer?.let {
+
if (claims.issuer != it) {
+
throw InvalidPayloadException("issuer mismatch")
+
}
+
}
+
+
return VerifyResult().apply {
+
payload = jwt.payload.toString()
+
this.protectedHeader = protectedHeader
+
}
+
}
+
}
+64
android/src/main/java/expo/modules/atprotoauth/Records.kt
···
+
package expo.modules.atprotoauth
+
+
import expo.modules.kotlin.records.Field
+
import expo.modules.kotlin.records.Record
+
+
class EncodedJWK : Record {
+
@Field
+
var kty: String = ""
+
+
@Field
+
var use: String = ""
+
+
@Field
+
var crv: String = ""
+
+
@Field
+
var kid: String = ""
+
+
@Field
+
var x: String = ""
+
+
@Field
+
var y: String = ""
+
+
@Field
+
var d: String = ""
+
+
@Field
+
var alg: String = ""
+
}
+
+
class VerifyOptions : Record {
+
@Field
+
var audience: String? = null
+
+
@Field
+
var checkTolerance: Double? = null
+
+
@Field
+
var issuer: String? = null
+
+
@Field
+
var maxTokenAge: Double? = null
+
+
@Field
+
var subject: String? = null
+
+
@Field
+
var typ: String? = null
+
+
@Field
+
var currentDate: Double? = null
+
+
@Field
+
var requiredClaims: Array<String>? = null
+
}
+
+
class VerifyResult : Record {
+
@Field
+
var payload: String = ""
+
+
@Field
+
var protectedHeader: Map<String, Any> = emptyMap()
+
}
+26 -35
example/App.tsx
···
import React from 'react'
-
import { Text, View, StyleSheet, Button, Alert, TextInput } from 'react-native'
+
import {
+
Text,
+
View,
+
StyleSheet,
+
Button,
+
Alert,
+
TextInput,
+
Platform,
+
} from 'react-native'
import {
digest,
getRandomValues,
createJwt,
generateJwk,
-
ReactNativeOAuthClient,
+
ExpoOAuthClient,
} from 'expo-atproto-auth'
import { OAuthSession } from '@atproto/oauth-client'
import { Agent } from '@atproto/api'
-
import type { ReactNativeKey } from 'expo-atproto-auth'
-
import * as Browser from 'expo-web-browser'
+
import type { ExpoKey } from 'expo-atproto-auth'
-
const client = new ReactNativeOAuthClient({
+
const client = new ExpoOAuthClient({
clientMetadata: {
client_id: 'https://hailey.at/oauth-client-metadata.json',
client_name: 'React Native OAuth Client Demo',
···
const [values, setValues] = React.useState<Uint8Array>()
const [sha, setSha] = React.useState<Uint8Array>()
const [jwt, setJwt] = React.useState<string>()
-
const [privateJwk, setPrivateJwk] = React.useState<
-
ReactNativeKey | undefined
-
>()
+
const [privateJwk, setPrivateJwk] = React.useState<ExpoKey | undefined>()
const [session, setSession] = React.useState<OAuthSession>()
const [input, setInput] = React.useState<string>()
const [agent, setAgent] = React.useState<Agent>()
···
<Button
title="Create JWK"
onPress={() => {
-
let newJwk: ReactNativeKey | undefined
+
let newJwk: ExpoKey | undefined
try {
newJwk = generateJwk('ES256')
} catch (e: any) {
···
<Button
title="Open Sign In"
onPress={async () => {
-
let url: URL
-
try {
-
url = await client.authorize(input ?? '')
-
} catch (e: any) {
-
Alert.alert('Error', e.toString())
-
return
-
}
-
const res = await Browser.openAuthSessionAsync(
-
url.toString(),
-
'at.hailey://auth/callback'
-
)
-
-
if (res.type === 'success') {
-
const resUrl = new URL(res.url)
-
try {
-
const params = new URLSearchParams(resUrl.hash.substring(1))
-
const callbackRes = await client.callback(params)
-
setSession(callbackRes.session)
-
-
const newAgent = new Agent(callbackRes.session)
-
setAgent(newAgent)
-
} catch (e: any) {
-
Alert.alert('Error', e.toString())
-
}
+
const res = await client.signIn(input ?? '')
+
if (res.status === 'success') {
+
setSession(res.session)
+
const newAgent = new Agent(res.session)
+
setAgent(newAgent)
+
} else if (res.status === 'error') {
+
Alert.alert('Error', (res.error as any).toString())
} else {
-
Alert.alert('Error', `Received non-success status: ${res.type}`)
+
Alert.alert(
+
'Error',
+
`Received unknown WebResultType: ${res.status}`
+
)
}
}}
/>
···
onPress={async () => {
try {
await agent?.post({
-
text: 'Test post from Expo Atproto Auth example',
+
text: `Test post from Expo Atproto Auth example using platform ${Platform.OS}`,
})
} catch (e: any) {
Alert.alert('Error', e.toString())
+2 -2
example/ios/expoatprotoauthexample.xcodeproj/project.pbxproj
···
);
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
PRODUCT_BUNDLE_IDENTIFIER = expo.modules.atprotoauth.example;
-
PRODUCT_NAME = expoatprotoauthexample;
+
PRODUCT_NAME = "expoatprotoauthexample";
SWIFT_OBJC_BRIDGING_HEADER = "expoatprotoauthexample/expoatprotoauthexample-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
···
);
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
PRODUCT_BUNDLE_IDENTIFIER = expo.modules.atprotoauth.example;
-
PRODUCT_NAME = expoatprotoauthexample;
+
PRODUCT_NAME = "expoatprotoauthexample";
SWIFT_OBJC_BRIDGING_HEADER = "expoatprotoauthexample/expoatprotoauthexample-Bridging-Header.h";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
+3 -3
ios/Crypto.swift
···
return Data(bytes)
}
-
static func generateJwk() -> JWK {
+
static func generateJwk() -> EncodedJWK {
let kid = UUID().uuidString
let privKey = P256.Signing.PrivateKey()
···
let y = pubKey.x963Representation[33...].base64URLEncodedString()
let d = privKey.rawRepresentation.base64URLEncodedString()
-
let jwk = JWK()
+
let jwk = EncodedJWK()
jwk.kty = "EC"
jwk.use = "sig"
jwk.crv = "P-256"
···
return jwk
}
-
static func importJwk(x: String, y: String, d: String) throws -> SecKey {
+
static func decodeJwk(x: String, y: String, d: String) throws -> SecKey {
func base64UrlDecode(_ string: String) -> Data? {
var base64 = string
.replacingOccurrences(of: "-", with: "+")
+7 -13
ios/ExpoAtprotoAuthModule.swift
···
return CryptoUtil.getRandomValues(byteLength: byteLength)
}
-
Function("generatePrivateJwk") { (algo: String) throws -> JWK in
+
Function("generatePrivateJwk") { (algo: String) throws -> EncodedJWK in
if algo != "ES256" {
throw ExpoAtprotoAuthError.unsupportedAlgorithm(algo)
}
return CryptoUtil.generateJwk()
}
-
Function("createJwt") { (header: String, payload: String, jwk: JWK) throws -> String in
-
let key = try CryptoUtil.importJwk(x: jwk.x, y: jwk.y, d: jwk.d)
-
let jwt = try JoseUtil.createJwt(header: header, payload: payload, jwk: key)
+
Function("createJwt") { (header: String, payload: String, jwk: EncodedJWK) throws -> String in
+
let jwk = try CryptoUtil.decodeJwk(x: jwk.x, y: jwk.y, d: jwk.d)
+
let jwt = try JoseUtil.createJwt(header: header, payload: payload, jwk: jwk)
return jwt
}
-
Function("verifyJwt") { (token: String, jwk: JWK, options: VerifyOptions) throws -> VerifyResponse in
-
let key = try CryptoUtil.importJwk(x: jwk.x, y: jwk.y, d: jwk.d)
-
let res = try JoseUtil.verifyJwt(token: token, jwk: key, options: options)
+
Function("verifyJwt") { (token: String, jwk: EncodedJWK, options: VerifyOptions) throws -> VerifyResult in
+
let jwk = try CryptoUtil.decodeJwk(x: jwk.x, y: jwk.y, d: jwk.d)
+
let res = try JoseUtil.verifyJwt(token: token, jwk: jwk, options: options)
return res
-
}
-
-
AsyncFunction("setValueAsync") { (value: String) in
-
self.sendEvent("onChange", [
-
"value": value
-
])
}
}
}
+2 -2
ios/Jose.swift
···
return jws.compactSerializedString
}
-
static func verifyJwt(token: String, jwk: SecKey, options: VerifyOptions) throws -> VerifyResponse {
+
static func verifyJwt(token: String, jwk: SecKey, options: VerifyOptions) throws -> VerifyResult {
guard let jws = try? JWS(compactSerialization: token),
let verifier = Verifier(verifyingAlgorithm: .ES256, key: jwk),
let validation = try? jws.validate(using: verifier)
···
}
}
-
let res = VerifyResponse()
+
let res = VerifyResult()
res.payload = payload
res.protectedHeader = protectedHeader
+2 -2
ios/Records.swift
···
import ExpoModulesCore
-
struct JWK: Record {
+
struct EncodedJWK: Record {
@Field
var kty: String
···
var requiredClaims: [String]?
}
-
struct VerifyResponse: Record {
+
struct VerifyResult: Record {
@Field
var payload: String
+3 -1
package.json
···
"build": "expo-module build",
"clean": "expo-module clean",
"typecheck": "tsc",
-
"lint": "eslint \"**/*.{js,ts,tsx}\"",
+
"lint": "eslint \"**/*.{ts,tsx}\"",
"test": "expo-module test",
"prepare": "expo-module prepare",
"prepublishOnly": "expo-module prepublishOnly",
···
"eslint-plugin-prettier": "^5.2.3",
"expo": "~53.0.0",
"expo-module-scripts": "^4.1.9",
+
"expo-web-browser": "^14.2.0",
"jest": "^29.7.0",
"patch-package": "^8.0.0",
"postinstall-postinstall": "^2.1.0",
···
"peerDependencies": {
"@atproto/oauth-client": "*",
"expo": "*",
+
"expo-web-browser": "*",
"react": "*",
"react-native": "*",
"react-native-mmkv": "*"
+1 -1
src/ExpoAtprotoAuth.types.ts
···
requiredClaims?: string[]
}
-
export type VerifyResponse = {
+
export type VerifyResult = {
payload: string
protectedHeader: JwtHeader
}
+132
src/expo-key.ts
···
+
import {
+
type Key,
+
type JwtHeader,
+
type JwtPayload,
+
type SignedJwt,
+
type VerifyOptions,
+
type VerifyResult,
+
} from '@atproto/oauth-client'
+
import type { JWK } from './ExpoAtprotoAuth.types'
+
import { default as NativeModule } from './ExpoAtprotoAuthModule'
+
+
export function getRandomValues(byteLength: number): Uint8Array {
+
return NativeModule.getRandomValues(byteLength)
+
}
+
+
export function digest(data: Uint8Array, algorithim: string): Uint8Array {
+
return NativeModule.digest(data, algorithim)
+
}
+
+
export function isECKey(jwk: any): jwk is JWK {
+
return jwk?.kty === 'EC' && jwk.crv && jwk.y
+
}
+
+
export function createJwt(
+
header: string,
+
payload: string,
+
key: ExpoKey
+
): string {
+
if (!key.privateJwk || !isECKey(key.privateJwk)) {
+
throw new Error('Invalid key')
+
}
+
return NativeModule.createJwt(header, payload, key.privateJwk)
+
}
+
+
export function verifyJwt(
+
token: string,
+
jwk: JWK,
+
options: VerifyOptions
+
): VerifyResult {
+
return NativeModule.verifyJwt(token, jwk, options)
+
}
+
+
// @ts-expect-error
+
export class ExpoKey implements Key {
+
#jwk: Readonly<JWK>
+
+
constructor(jwk: Readonly<JWK>) {
+
this.#jwk = jwk
+
if (!isECKey(jwk)) {
+
throw new Error('Invalid key type')
+
}
+
if (!jwk.use) {
+
throw new Error(`Missing "use" parameter value`)
+
}
+
}
+
+
get jwk(): Readonly<JWK> {
+
return this.#jwk
+
}
+
+
get isPrivate() {
+
return this.jwk.d !== undefined
+
}
+
+
get privateJwk(): JWK {
+
return this.jwk
+
}
+
+
get publicJwk() {
+
if (this.isSymetric) return undefined
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
+
const { d, ...publicKey } = this.jwk
+
return publicKey as Readonly<JWK & { d?: never }>
+
}
+
+
get use() {
+
return this.jwk.use as NonNullable<'sig' | 'enc' | undefined>
+
}
+
+
get alg() {
+
return this.jwk.alg
+
}
+
+
get kid() {
+
return this.jwk.kid
+
}
+
+
get crv() {
+
return this.jwk.crv
+
}
+
+
get algorithms() {
+
return [this.jwk.alg]
+
}
+
+
get bareJwk() {
+
return {
+
kty: this.jwk.kty,
+
crv: this.jwk.crv,
+
x: this.jwk.x,
+
y: this.jwk.y,
+
}
+
}
+
+
get isSymetric() {
+
return this.jwk.kty === 'oct' && 'k' in this.jwk && this.jwk.k !== undefined
+
}
+
+
async createJwt(header: JwtHeader, payload: JwtPayload): Promise<SignedJwt> {
+
return createJwt(
+
JSON.stringify(header),
+
JSON.stringify(payload),
+
this
+
) as '${string}.${string}.${string}'
+
}
+
+
async verifyJwt<C extends string = never>(
+
token: SignedJwt,
+
options?: VerifyOptions<C>
+
): Promise<VerifyResult<C>> {
+
return verifyJwt(
+
token,
+
this.jwk,
+
(options ?? {}) as VerifyOptions
+
) as VerifyResult<C>
+
}
+
}
+
+
export function generateJwk(algoritihim: string): ExpoKey {
+
const privJwk = NativeModule.generatePrivateJwk(algoritihim)
+
return new ExpoKey(privJwk)
+
}
+103
src/expo-oauth-client.ts
···
+
import {
+
type Fetch,
+
type OAuthClientMetadataInput,
+
type OAuthClientOptions,
+
type OAuthResponseMode,
+
atprotoLoopbackClientMetadata,
+
OAuthClient,
+
OAuthSession,
+
} from '@atproto/oauth-client'
+
import { ExpoRuntimeImplementation } from './expo-runtime-implementation'
+
import { ExpoOAuthDatabase } from './expo-oauth-database'
+
import { openAuthSessionAsync, WebBrowserResultType } from 'expo-web-browser'
+
+
export type Simplify<T> = { [K in keyof T]: T[K] } & NonNullable<unknown>
+
+
export type ExpoOAuthClientOptions = Simplify<
+
{
+
clientMetadata?: Readonly<OAuthClientMetadataInput>
+
responseMode?: Exclude<OAuthResponseMode, 'form_post'>
+
fetch?: Fetch
+
} & Omit<
+
OAuthClientOptions,
+
| 'clientMetadata'
+
| 'responseMode'
+
| 'keyset'
+
| 'fetch'
+
| 'runtimeImplementation'
+
| 'sessionStore'
+
| 'stateStore'
+
| 'didCache'
+
| 'handleCache'
+
| 'dpopNonceCache'
+
| 'authorizationServerMetadataCache'
+
| 'protectedResourceMetadataCache'
+
>
+
>
+
+
export class ExpoOAuthClient extends OAuthClient {
+
constructor({
+
responseMode = 'fragment',
+
...options
+
}: ExpoOAuthClientOptions) {
+
const database = new ExpoOAuthDatabase()
+
+
if (!['query', 'fragment'].includes(responseMode)) {
+
throw new TypeError(`Invalid response mode: ${responseMode}`)
+
}
+
+
if (!options.clientMetadata) {
+
throw new TypeError(`No client metadata provided`)
+
}
+
+
super({
+
...options,
+
clientMetadata:
+
options.clientMetadata ?? atprotoLoopbackClientMetadata('localhost'), // HACK: this fixes a type error for now, look into it later
+
responseMode,
+
keyset: undefined,
+
runtimeImplementation: new ExpoRuntimeImplementation(),
+
sessionStore: database.getSessionStore(),
+
stateStore: database.getStateStore(),
+
didCache: database.getDidCache(),
+
handleCache: database.getHandleCache(),
+
dpopNonceCache: database.getDpopNonceCache(),
+
authorizationServerMetadataCache:
+
database.getAuthorizationServerMetadataCache(),
+
protectedResourceMetadataCache:
+
database.getProtectedResourceMetadataCache(),
+
})
+
}
+
+
async signIn(
+
input: string
+
): Promise<
+
| { status: WebBrowserResultType }
+
| { status: 'error'; error: unknown }
+
| { status: 'success'; session: OAuthSession }
+
> {
+
let url: URL
+
try {
+
url = await this.authorize(input)
+
} catch (e: unknown) {
+
return { status: 'error', error: e }
+
}
+
+
const res = await openAuthSessionAsync(
+
url.toString(),
+
this.clientMetadata.redirect_uris[0],
+
{
+
createTask: false,
+
}
+
)
+
+
if (res.type === 'success') {
+
const resUrl = new URL(res.url)
+
const params = new URLSearchParams(resUrl.hash.substring(1))
+
const callbackRes = await this.callback(params)
+
return { status: 'success', session: callbackRes.session }
+
} else {
+
return { status: res.type }
+
}
+
}
+
}
+253
src/expo-oauth-database.ts
···
+
import type {
+
DidDocument,
+
InternalStateData,
+
OAuthAuthorizationServerMetadata,
+
OAuthProtectedResourceMetadata,
+
ResolvedHandle,
+
Session,
+
TokenSet,
+
Key,
+
} from '@atproto/oauth-client'
+
import { type SimpleStore, type Value } from '@atproto-labs/simple-store'
+
import { MMKV } from 'react-native-mmkv'
+
import { JWK } from './ExpoAtprotoAuth.types'
+
import { ExpoKey } from './expo-key'
+
+
type Item<V> = {
+
value: V
+
expiresAt: null | number
+
}
+
+
type CryptoKeyPair = {
+
publicKey: JWK
+
privateKey: JWK
+
}
+
+
type EncodedKey = {
+
keyId: string
+
keyPair: CryptoKeyPair
+
}
+
+
function encodeKey(key: Key): EncodedKey {
+
if (!key.privateJwk || !key.publicJwk || !key.kid) {
+
throw new Error('Invalid key object')
+
}
+
+
const encodedKey = {
+
keyId: key.kid,
+
keyPair: {
+
publicKey: key.publicJwk,
+
privateKey: key.privateJwk,
+
},
+
}
+
+
// @ts-expect-error
+
return encodedKey
+
}
+
+
async function decodeKey(encoded: EncodedKey): Promise<ExpoKey> {
+
return new ExpoKey(encoded.keyPair.privateKey)
+
}
+
+
export type Schema = {
+
state: Item<{
+
dpopKey: EncodedKey
+
iss: string
+
verifier?: string
+
appState?: string
+
}>
+
session: Item<{
+
dpopKey: EncodedKey
+
tokenSet: TokenSet
+
}>
+
didCache: Item<DidDocument>
+
dpopNonceCache: Item<string>
+
handleCache: Item<ResolvedHandle>
+
authorizationServerMetadataCache: Item<OAuthAuthorizationServerMetadata>
+
protectedResourceMetadataCache: Item<OAuthProtectedResourceMetadata>
+
}
+
+
export type DatabaseStore<V extends Value> = SimpleStore<string, V> & {
+
getKeys: () => Promise<string[]>
+
}
+
+
const STORES = [
+
'state',
+
'session',
+
'didCache',
+
'dpopNonceCache',
+
'handleCache',
+
'authorizationServerMetadataCaache',
+
'protectedResourceMetadataCache',
+
]
+
+
export type ExpoOAuthDatabaseOptions = {
+
name?: string
+
durability?: 'strict' | 'relaxed'
+
cleanupInterval?: number
+
}
+
+
export class ExpoOAuthDatabase {
+
#cleanupInterval?: ReturnType<typeof setInterval>
+
#mmkv?: MMKV
+
+
constructor(options?: ExpoOAuthDatabaseOptions) {
+
this.#cleanupInterval = setInterval(() => {
+
this.cleanup()
+
}, options?.cleanupInterval ?? 30e3)
+
this.#mmkv = new MMKV({ id: 'react-native-oauth-client' })
+
}
+
+
delete = async (key: string) => {
+
this.#mmkv?.delete(key)
+
this.#mmkv?.delete(`${key}.expiresAt`)
+
}
+
+
protected createStore<N extends keyof Schema, V extends Value>(
+
name: N,
+
{
+
encode,
+
decode,
+
expiresAt,
+
}: {
+
encode: (value: V) => Schema[N]['value'] | PromiseLike<Schema[N]['value']>
+
decode: (encoded: Schema[N]['value']) => V | PromiseLike<V>
+
expiresAt: (value: V) => null | number
+
}
+
): DatabaseStore<V> {
+
return {
+
get: async (key) => {
+
const item = this.#mmkv?.getString(`${name}.${key}`)
+
+
if (item === undefined) return undefined
+
+
const storedExpiresAt = this.#mmkv?.getNumber(
+
`${name}.${key}.expiresAt`
+
)
+
if (storedExpiresAt && storedExpiresAt < Date.now()) {
+
await this.delete(`${name}.${key}`)
+
return undefined
+
}
+
+
const res = decode(JSON.parse(item))
+
return res
+
},
+
+
getKeys: async () => {
+
const keys = this.#mmkv?.getAllKeys() ?? []
+
return keys.filter((key) => key.startsWith(`${name}.`))
+
},
+
+
set: async (key, value) => {
+
let encoded = await encode(value)
+
encoded = JSON.stringify(encoded)
+
+
const _expiresAt = expiresAt(value)
+
+
this.#mmkv?.set(`${name}.${key}`, encoded)
+
if (_expiresAt) {
+
this.#mmkv?.set(`${name}.${key}.expiresAt`, _expiresAt)
+
}
+
},
+
del: async (key) => {
+
await this.delete(`${name}.${key}`)
+
},
+
}
+
}
+
+
getSessionStore(): DatabaseStore<Session> {
+
return this.createStore('session', {
+
expiresAt: ({ tokenSet }) =>
+
tokenSet.refresh_token || tokenSet.expires_at == null
+
? null
+
: new Date(tokenSet.expires_at).valueOf(),
+
encode: ({ dpopKey, ...session }) => ({
+
...session,
+
dpopKey: encodeKey(dpopKey),
+
}),
+
// @ts-expect-error
+
decode: async ({ dpopKey, ...encoded }) => ({
+
...encoded,
+
dpopKey: await decodeKey(dpopKey),
+
}),
+
})
+
}
+
+
getStateStore(): DatabaseStore<InternalStateData> {
+
return this.createStore('state', {
+
expiresAt: (_value) => Date.now() + 10 * 60e3,
+
encode: ({ dpopKey, ...session }) => ({
+
...session,
+
dpopKey: encodeKey(dpopKey),
+
}),
+
// @ts-expect-error
+
decode: async ({ dpopKey, ...encoded }) => ({
+
...encoded,
+
dpopKey: await decodeKey(dpopKey),
+
}),
+
})
+
}
+
+
getDpopNonceCache(): undefined | DatabaseStore<string> {
+
return this.createStore('dpopNonceCache', {
+
expiresAt: (_value) => Date.now() + 600e3,
+
encode: (value) => value,
+
decode: (encoded) => encoded,
+
})
+
}
+
+
getDidCache(): undefined | DatabaseStore<DidDocument> {
+
return this.createStore('didCache', {
+
expiresAt: (_value) => Date.now() + 60e3,
+
encode: (value) => value,
+
decode: (encoded) => encoded,
+
})
+
}
+
+
getHandleCache(): undefined | DatabaseStore<ResolvedHandle> {
+
return this.createStore('handleCache', {
+
expiresAt: (_value) => Date.now() + 60e3,
+
encode: (value) => value,
+
decode: (encoded) => encoded,
+
})
+
}
+
+
getAuthorizationServerMetadataCache():
+
| undefined
+
| DatabaseStore<OAuthAuthorizationServerMetadata> {
+
return this.createStore('authorizationServerMetadataCache', {
+
expiresAt: (_value) => Date.now() + 60e3,
+
encode: (value) => value,
+
decode: (encoded) => encoded,
+
})
+
}
+
+
getProtectedResourceMetadataCache():
+
| undefined
+
| DatabaseStore<OAuthProtectedResourceMetadata> {
+
return this.createStore('protectedResourceMetadataCache', {
+
expiresAt: (_value) => Date.now() + 60e3,
+
encode: (value) => value,
+
decode: (encoded) => encoded,
+
})
+
}
+
+
async cleanup() {
+
for (const name of STORES) {
+
const keys = this.#mmkv?.getAllKeys() ?? []
+
for (const key of keys) {
+
if (key.startsWith(`${name}.`)) {
+
const expiresAt = this.#mmkv?.getNumber(`${name}.${key}.expiresAt`)
+
if (expiresAt && Number(expiresAt) < Date.now()) {
+
this.#mmkv?.delete(key)
+
this.#mmkv?.delete(`${name}.${key}.expiresAt`)
+
}
+
}
+
}
+
}
+
}
+
+
async [Symbol.asyncDispose]() {
+
clearInterval(this.#cleanupInterval)
+
}
+
}
+28
src/expo-runtime-implementation.ts
···
+
import type { Key, RuntimeImplementation } from '@atproto/oauth-client'
+
import { default as NativeModule } from './ExpoAtprotoAuthModule'
+
import { generateJwk } from './expo-key'
+
+
export class ExpoRuntimeImplementation implements RuntimeImplementation {
+
async createKey(algs: string[]): Promise<Key> {
+
if (!algs.includes('ES256')) {
+
throw TypeError('ES256 is the only supported algo')
+
}
+
// @ts-expect-error TODO:
+
return generateJwk('ES256')
+
}
+
+
getRandomValues(length: number): Uint8Array | PromiseLike<Uint8Array> {
+
return NativeModule.getRandomValues(length)
+
}
+
+
digest(
+
bytes: Uint8Array,
+
algorithim: { name: string }
+
): Uint8Array | PromiseLike<Uint8Array> {
+
if (algorithim.name === 'sha256') {
+
return NativeModule.digest(bytes, algorithim.name)
+
}
+
+
throw new TypeError(`Unsupported algorithim: ${algorithim.name}`)
+
}
+
}
+3 -3
src/index.ts
···
createJwt,
getRandomValues,
verifyJwt,
-
ReactNativeKey,
-
} from './react-native-key'
-
export { ReactNativeOAuthClient } from './react-native-oauth-client'
+
ExpoKey,
+
} from './expo-key'
+
export { ExpoOAuthClient } from './expo-oauth-client'
-132
src/react-native-key.ts
···
-
import {
-
type Key,
-
type JwtHeader,
-
type JwtPayload,
-
type SignedJwt,
-
type VerifyOptions,
-
type VerifyResult,
-
} from '@atproto/oauth-client'
-
import type { JWK } from './ExpoAtprotoAuth.types'
-
import { default as NativeModule } from './ExpoAtprotoAuthModule'
-
-
export function getRandomValues(byteLength: number): Uint8Array {
-
return NativeModule.getRandomValues(byteLength)
-
}
-
-
export function digest(data: Uint8Array, algorithim: string): Uint8Array {
-
return NativeModule.digest(data, algorithim)
-
}
-
-
export function isECKey(jwk: any): jwk is JWK {
-
return jwk?.kty === 'EC' && jwk.crv && jwk.y
-
}
-
-
export function createJwt(
-
header: string,
-
payload: string,
-
key: ReactNativeKey
-
): string {
-
if (!key.privateJwk || !isECKey(key.privateJwk)) {
-
throw new Error('Invalid key')
-
}
-
return NativeModule.createJwt(header, payload, key.privateJwk)
-
}
-
-
export function verifyJwt(
-
token: string,
-
jwk: JWK,
-
options: VerifyOptions
-
): VerifyResult {
-
return NativeModule.verifyJwt(token, jwk, options)
-
}
-
-
// @ts-expect-error
-
export class ReactNativeKey implements Key {
-
#jwk: Readonly<JWK>
-
-
constructor(jwk: Readonly<JWK>) {
-
this.#jwk = jwk
-
if (!isECKey(jwk)) {
-
throw new Error('Invalid key type')
-
}
-
if (!jwk.use) {
-
throw new Error(`Missing "use" parameter value`)
-
}
-
}
-
-
get jwk(): Readonly<JWK> {
-
return this.#jwk
-
}
-
-
get isPrivate() {
-
return this.jwk.d !== undefined
-
}
-
-
get privateJwk(): JWK {
-
return this.jwk
-
}
-
-
get publicJwk() {
-
if (this.isSymetric) return undefined
-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
-
const { d, ...publicKey } = this.jwk
-
return publicKey as Readonly<JWK & { d?: never }>
-
}
-
-
get use() {
-
return this.jwk.use as NonNullable<'sig' | 'enc' | undefined>
-
}
-
-
get alg() {
-
return this.jwk.alg
-
}
-
-
get kid() {
-
return this.jwk.kid
-
}
-
-
get crv() {
-
return this.jwk.crv
-
}
-
-
get algorithms() {
-
return [this.jwk.alg]
-
}
-
-
get bareJwk() {
-
return {
-
kty: this.jwk.kty,
-
crv: this.jwk.crv,
-
x: this.jwk.x,
-
y: this.jwk.y,
-
}
-
}
-
-
get isSymetric() {
-
return this.jwk.kty === 'oct' && 'k' in this.jwk && this.jwk.k !== undefined
-
}
-
-
async createJwt(header: JwtHeader, payload: JwtPayload): Promise<SignedJwt> {
-
return createJwt(
-
JSON.stringify(header),
-
JSON.stringify(payload),
-
this
-
) as '${string}.${string}.${string}'
-
}
-
-
async verifyJwt<C extends string = never>(
-
token: SignedJwt,
-
options?: VerifyOptions<C>
-
): Promise<VerifyResult<C>> {
-
return verifyJwt(
-
token,
-
this.jwk,
-
(options ?? {}) as VerifyOptions
-
) as VerifyResult<C>
-
}
-
}
-
-
export function generateJwk(algoritihim: string): ReactNativeKey {
-
const privJwk = NativeModule.generatePrivateJwk(algoritihim)
-
return new ReactNativeKey(privJwk)
-
}
-69
src/react-native-oauth-client.ts
···
-
import {
-
type Fetch,
-
type OAuthClientMetadataInput,
-
type OAuthClientOptions,
-
type OAuthResponseMode,
-
atprotoLoopbackClientMetadata,
-
OAuthClient,
-
} from '@atproto/oauth-client'
-
import { ReactNativeRuntimeImplementation } from './react-native-runtime-implementation'
-
import { ReactNativeOAuthDatabase } from './react-native-oauth-database'
-
-
export type Simplify<T> = { [K in keyof T]: T[K] } & NonNullable<unknown>
-
-
export type ReactNativeOAuthClientOptions = Simplify<
-
{
-
clientMetadata?: Readonly<OAuthClientMetadataInput>
-
responseMode?: Exclude<OAuthResponseMode, 'form_post'>
-
fetch?: Fetch
-
} & Omit<
-
OAuthClientOptions,
-
| 'clientMetadata'
-
| 'responseMode'
-
| 'keyset'
-
| 'fetch'
-
| 'runtimeImplementation'
-
| 'sessionStore'
-
| 'stateStore'
-
| 'didCache'
-
| 'handleCache'
-
| 'dpopNonceCache'
-
| 'authorizationServerMetadataCache'
-
| 'protectedResourceMetadataCache'
-
>
-
>
-
-
export class ReactNativeOAuthClient extends OAuthClient {
-
constructor({
-
responseMode = 'fragment',
-
...options
-
}: ReactNativeOAuthClientOptions) {
-
const database = new ReactNativeOAuthDatabase()
-
-
if (!['query', 'fragment'].includes(responseMode)) {
-
throw new TypeError(`Invalid response mode: ${responseMode}`)
-
}
-
-
if (!options.clientMetadata) {
-
throw new TypeError(`No client metadata provided`)
-
}
-
-
super({
-
...options,
-
clientMetadata:
-
options.clientMetadata ?? atprotoLoopbackClientMetadata('localhost'), // HACK: this fixes a type error for now, look into it later
-
responseMode,
-
keyset: undefined,
-
runtimeImplementation: new ReactNativeRuntimeImplementation(),
-
sessionStore: database.getSessionStore(),
-
stateStore: database.getStateStore(),
-
didCache: database.getDidCache(),
-
handleCache: database.getHandleCache(),
-
dpopNonceCache: database.getDpopNonceCache(),
-
authorizationServerMetadataCache:
-
database.getAuthorizationServerMetadataCache(),
-
protectedResourceMetadataCache:
-
database.getProtectedResourceMetadataCache(),
-
})
-
}
-
}
-256
src/react-native-oauth-database.ts
···
-
import type {
-
DidDocument,
-
InternalStateData,
-
OAuthAuthorizationServerMetadata,
-
OAuthProtectedResourceMetadata,
-
ResolvedHandle,
-
Session,
-
TokenSet,
-
Key,
-
} from '@atproto/oauth-client'
-
import { type SimpleStore, type Value } from '@atproto-labs/simple-store'
-
import { MMKV } from 'react-native-mmkv'
-
import { JWK } from './ExpoAtprotoAuth.types'
-
import { ReactNativeKey } from './react-native-key'
-
-
type Item<V> = {
-
value: V
-
expiresAt: null | number
-
}
-
-
type CryptoKeyPair = {
-
publicKey: JWK
-
privateKey: JWK
-
}
-
-
type EncodedKey = {
-
keyId: string
-
keyPair: CryptoKeyPair
-
}
-
-
function encodeKey(key: Key): EncodedKey {
-
if (!key.privateJwk || !key.publicJwk || !key.kid) {
-
throw new Error('Invalid key object')
-
}
-
-
const encodedKey = {
-
keyId: key.kid,
-
keyPair: {
-
publicKey: key.publicJwk,
-
privateKey: key.privateJwk,
-
},
-
}
-
-
// @ts-expect-error
-
return encodedKey
-
}
-
-
async function decodeKey(encoded: EncodedKey): Promise<ReactNativeKey> {
-
console.log(encoded)
-
return new ReactNativeKey(encoded.keyPair.privateKey)
-
}
-
-
export type Schema = {
-
state: Item<{
-
dpopKey: EncodedKey
-
iss: string
-
verifier?: string
-
appState?: string
-
}>
-
session: Item<{
-
dpopKey: EncodedKey
-
tokenSet: TokenSet
-
}>
-
didCache: Item<DidDocument>
-
dpopNonceCache: Item<string>
-
handleCache: Item<ResolvedHandle>
-
authorizationServerMetadataCache: Item<OAuthAuthorizationServerMetadata>
-
protectedResourceMetadataCache: Item<OAuthProtectedResourceMetadata>
-
}
-
-
export type DatabaseStore<V extends Value> = SimpleStore<string, V> & {
-
getKeys: () => Promise<string[]>
-
}
-
-
const STORES = [
-
'state',
-
'session',
-
'didCache',
-
'dpopNonceCache',
-
'handleCache',
-
'authorizationServerMetadataCaache',
-
'protectedResourceMetadataCache',
-
]
-
-
export type ReactNativeOAuthDatabaseOptions = {
-
name?: string
-
durability?: 'strict' | 'relaxed'
-
cleanupInterval?: number
-
}
-
-
export class ReactNativeOAuthDatabase {
-
#cleanupInterval?: ReturnType<typeof setInterval>
-
#mmkv?: MMKV
-
-
constructor(options?: ReactNativeOAuthDatabaseOptions) {
-
this.#cleanupInterval = setInterval(() => {
-
this.cleanup()
-
}, options?.cleanupInterval ?? 30e3)
-
this.#mmkv = new MMKV({ id: 'react-native-oauth-client' })
-
}
-
-
delete = async (key: string) => {
-
this.#mmkv?.delete(key)
-
this.#mmkv?.delete(`${key}.expiresAt`)
-
}
-
-
protected createStore<N extends keyof Schema, V extends Value>(
-
name: N,
-
{
-
encode,
-
decode,
-
expiresAt,
-
}: {
-
encode: (value: V) => Schema[N]['value'] | PromiseLike<Schema[N]['value']>
-
decode: (encoded: Schema[N]['value']) => V | PromiseLike<V>
-
expiresAt: (value: V) => null | number
-
}
-
): DatabaseStore<V> {
-
return {
-
get: async (key) => {
-
console.log(`getting ${name}.${key}`)
-
const item = this.#mmkv?.getString(`${name}.${key}`)
-
-
if (item === undefined) return undefined
-
-
const storedExpiresAt = this.#mmkv?.getNumber(
-
`${name}.${key}.expiresAt`
-
)
-
if (storedExpiresAt && storedExpiresAt < Date.now()) {
-
await this.delete(`${name}.${key}`)
-
return undefined
-
}
-
-
const res = decode(JSON.parse(item))
-
console.log(res)
-
return res
-
},
-
-
getKeys: async () => {
-
const keys = this.#mmkv?.getAllKeys() ?? []
-
return keys.filter((key) => key.startsWith(`${name}.`))
-
},
-
-
set: async (key, value) => {
-
let encoded = await encode(value)
-
encoded = JSON.stringify(encoded)
-
-
const _expiresAt = expiresAt(value)
-
-
this.#mmkv?.set(`${name}.${key}`, encoded)
-
if (_expiresAt) {
-
this.#mmkv?.set(`${name}.${key}.expiresAt`, _expiresAt)
-
}
-
},
-
del: async (key) => {
-
await this.delete(`${name}.${key}`)
-
},
-
}
-
}
-
-
getSessionStore(): DatabaseStore<Session> {
-
return this.createStore('session', {
-
expiresAt: ({ tokenSet }) =>
-
tokenSet.refresh_token || tokenSet.expires_at == null
-
? null
-
: new Date(tokenSet.expires_at).valueOf(),
-
encode: ({ dpopKey, ...session }) => ({
-
...session,
-
dpopKey: encodeKey(dpopKey),
-
}),
-
// @ts-expect-error
-
decode: async ({ dpopKey, ...encoded }) => ({
-
...encoded,
-
dpopKey: await decodeKey(dpopKey),
-
}),
-
})
-
}
-
-
getStateStore(): DatabaseStore<InternalStateData> {
-
return this.createStore('state', {
-
expiresAt: (_value) => Date.now() + 10 * 60e3,
-
encode: ({ dpopKey, ...session }) => ({
-
...session,
-
dpopKey: encodeKey(dpopKey),
-
}),
-
// @ts-expect-error
-
decode: async ({ dpopKey, ...encoded }) => ({
-
...encoded,
-
dpopKey: await decodeKey(dpopKey),
-
}),
-
})
-
}
-
-
getDpopNonceCache(): undefined | DatabaseStore<string> {
-
return this.createStore('dpopNonceCache', {
-
expiresAt: (_value) => Date.now() + 600e3,
-
encode: (value) => value,
-
decode: (encoded) => encoded,
-
})
-
}
-
-
getDidCache(): undefined | DatabaseStore<DidDocument> {
-
return this.createStore('didCache', {
-
expiresAt: (_value) => Date.now() + 60e3,
-
encode: (value) => value,
-
decode: (encoded) => encoded,
-
})
-
}
-
-
getHandleCache(): undefined | DatabaseStore<ResolvedHandle> {
-
return this.createStore('handleCache', {
-
expiresAt: (_value) => Date.now() + 60e3,
-
encode: (value) => value,
-
decode: (encoded) => encoded,
-
})
-
}
-
-
getAuthorizationServerMetadataCache():
-
| undefined
-
| DatabaseStore<OAuthAuthorizationServerMetadata> {
-
return this.createStore('authorizationServerMetadataCache', {
-
expiresAt: (_value) => Date.now() + 60e3,
-
encode: (value) => value,
-
decode: (encoded) => encoded,
-
})
-
}
-
-
getProtectedResourceMetadataCache():
-
| undefined
-
| DatabaseStore<OAuthProtectedResourceMetadata> {
-
return this.createStore('protectedResourceMetadataCache', {
-
expiresAt: (_value) => Date.now() + 60e3,
-
encode: (value) => value,
-
decode: (encoded) => encoded,
-
})
-
}
-
-
async cleanup() {
-
for (const name of STORES) {
-
const keys = this.#mmkv?.getAllKeys() ?? []
-
for (const key of keys) {
-
if (key.startsWith(`${name}.`)) {
-
const expiresAt = this.#mmkv?.getNumber(`${name}.${key}.expiresAt`)
-
if (expiresAt && Number(expiresAt) < Date.now()) {
-
this.#mmkv?.delete(key)
-
this.#mmkv?.delete(`${name}.${key}.expiresAt`)
-
}
-
}
-
}
-
}
-
}
-
-
async [Symbol.asyncDispose]() {
-
clearInterval(this.#cleanupInterval)
-
}
-
}
-28
src/react-native-runtime-implementation.ts
···
-
import type { Key, RuntimeImplementation } from '@atproto/oauth-client'
-
import { default as NativeModule } from './ExpoAtprotoAuthModule'
-
import { generateJwk } from './react-native-key'
-
-
export class ReactNativeRuntimeImplementation implements RuntimeImplementation {
-
async createKey(algs: string[]): Promise<Key> {
-
if (!algs.includes('ES256')) {
-
throw TypeError('ES256 is the only supported algo')
-
}
-
// @ts-expect-error TODO:
-
return generateJwk('ES256')
-
}
-
-
getRandomValues(length: number): Uint8Array | PromiseLike<Uint8Array> {
-
return NativeModule.getRandomValues(length)
-
}
-
-
digest(
-
bytes: Uint8Array,
-
algorithim: { name: string }
-
): Uint8Array | PromiseLike<Uint8Array> {
-
if (algorithim.name === 'sha256') {
-
return NativeModule.digest(bytes, algorithim.name)
-
}
-
-
throw new TypeError(`Unsupported algorithim: ${algorithim.name}`)
-
}
-
}
+5
yarn.lock
···
dependencies:
invariant "^2.2.4"
+
expo-web-browser@^14.2.0:
+
version "14.2.0"
+
resolved "https://registry.yarnpkg.com/expo-web-browser/-/expo-web-browser-14.2.0.tgz#d8fb521ae349aebbf5c0ca32448877480124c06c"
+
integrity sha512-6S51d8pVlDRDsgGAp8BPpwnxtyKiMWEFdezNz+5jVIyT+ctReW42uxnjRgtsdn5sXaqzhaX+Tzk/CWaKCyC0hw==
+
expo@~53.0.0:
version "53.0.19"
resolved "https://registry.yarnpkg.com/expo/-/expo-53.0.19.tgz#69f8ed224efadf517d3ff66dde3d536fb3e8f00a"