From c97e439a7ce5af4186f920d9a6d99f04f01dd2cb Mon Sep 17 00:00:00 2001 From: Hailey Date: Sun, 14 Apr 2024 16:26:31 -0700 Subject: [PATCH 01/11] better layout --- modules/expo-bluesky-oauth-client/index.ts | 12 +- .../{src => src-old}/crypto-subtle.ts | 0 .../{src => src-old}/crypto-subtle.web.ts | 0 .../{src => src-old}/jose-key.ts | 0 .../{src => src-old}/native-types.ts | 0 .../{src => src-old}/rn-crypto-key.ts | 0 .../{src => src-old}/rn-crypto-key.web.ts | 0 .../rn-oauth-client-factory.native.ts | 0 .../rn-oauth-client-factory.ts | 0 .../rn-oauth-database.native.ts | 0 .../{src => src-old}/rn-oauth-database.ts | 0 .../{src => src-old}/store.ts | 0 .../{src => src-old}/store.web.ts | 0 .../{src => src-old}/util.web.ts | 0 .../src/oauth-client-react-native.ts | 44 +++++++ .../src/react-native-crypto-implementation.ts | 27 +++++ .../src/react-native-key.ts | 110 ++++++++++++++++++ .../src/react-native-store-with-key.ts | 51 ++++++++ .../src/react-native-store.ts | 25 ++++ 19 files changed, 263 insertions(+), 6 deletions(-) rename modules/expo-bluesky-oauth-client/{src => src-old}/crypto-subtle.ts (100%) rename modules/expo-bluesky-oauth-client/{src => src-old}/crypto-subtle.web.ts (100%) rename modules/expo-bluesky-oauth-client/{src => src-old}/jose-key.ts (100%) rename modules/expo-bluesky-oauth-client/{src => src-old}/native-types.ts (100%) rename modules/expo-bluesky-oauth-client/{src => src-old}/rn-crypto-key.ts (100%) rename modules/expo-bluesky-oauth-client/{src => src-old}/rn-crypto-key.web.ts (100%) rename modules/expo-bluesky-oauth-client/{src => src-old}/rn-oauth-client-factory.native.ts (100%) rename modules/expo-bluesky-oauth-client/{src => src-old}/rn-oauth-client-factory.ts (100%) rename modules/expo-bluesky-oauth-client/{src => src-old}/rn-oauth-database.native.ts (100%) rename modules/expo-bluesky-oauth-client/{src => src-old}/rn-oauth-database.ts (100%) rename modules/expo-bluesky-oauth-client/{src => src-old}/store.ts (100%) rename modules/expo-bluesky-oauth-client/{src => src-old}/store.web.ts (100%) rename modules/expo-bluesky-oauth-client/{src => src-old}/util.web.ts (100%) create mode 100644 modules/expo-bluesky-oauth-client/src/oauth-client-react-native.ts create mode 100644 modules/expo-bluesky-oauth-client/src/react-native-crypto-implementation.ts create mode 100644 modules/expo-bluesky-oauth-client/src/react-native-key.ts create mode 100644 modules/expo-bluesky-oauth-client/src/react-native-store-with-key.ts create mode 100644 modules/expo-bluesky-oauth-client/src/react-native-store.ts diff --git a/modules/expo-bluesky-oauth-client/index.ts b/modules/expo-bluesky-oauth-client/index.ts index ca28960b93..05f53f9803 100644 --- a/modules/expo-bluesky-oauth-client/index.ts +++ b/modules/expo-bluesky-oauth-client/index.ts @@ -1,6 +1,6 @@ -export * from './src/crypto-subtle' -export * from './src/jose-key' -export * from './src/rn-crypto-key' -export * from './src/rn-oauth-client-factory' -export * from './src/rn-oauth-database' -export * from './src/util.web' +export * from './src-old/crypto-subtle' +export * from './src-old/jose-key' +export * from './src-old/rn-crypto-key' +export * from './src-old/rn-oauth-client-factory' +export * from './src-old/rn-oauth-database' +export * from './src-old/util.web' diff --git a/modules/expo-bluesky-oauth-client/src/crypto-subtle.ts b/modules/expo-bluesky-oauth-client/src-old/crypto-subtle.ts similarity index 100% rename from modules/expo-bluesky-oauth-client/src/crypto-subtle.ts rename to modules/expo-bluesky-oauth-client/src-old/crypto-subtle.ts diff --git a/modules/expo-bluesky-oauth-client/src/crypto-subtle.web.ts b/modules/expo-bluesky-oauth-client/src-old/crypto-subtle.web.ts similarity index 100% rename from modules/expo-bluesky-oauth-client/src/crypto-subtle.web.ts rename to modules/expo-bluesky-oauth-client/src-old/crypto-subtle.web.ts diff --git a/modules/expo-bluesky-oauth-client/src/jose-key.ts b/modules/expo-bluesky-oauth-client/src-old/jose-key.ts similarity index 100% rename from modules/expo-bluesky-oauth-client/src/jose-key.ts rename to modules/expo-bluesky-oauth-client/src-old/jose-key.ts diff --git a/modules/expo-bluesky-oauth-client/src/native-types.ts b/modules/expo-bluesky-oauth-client/src-old/native-types.ts similarity index 100% rename from modules/expo-bluesky-oauth-client/src/native-types.ts rename to modules/expo-bluesky-oauth-client/src-old/native-types.ts diff --git a/modules/expo-bluesky-oauth-client/src/rn-crypto-key.ts b/modules/expo-bluesky-oauth-client/src-old/rn-crypto-key.ts similarity index 100% rename from modules/expo-bluesky-oauth-client/src/rn-crypto-key.ts rename to modules/expo-bluesky-oauth-client/src-old/rn-crypto-key.ts diff --git a/modules/expo-bluesky-oauth-client/src/rn-crypto-key.web.ts b/modules/expo-bluesky-oauth-client/src-old/rn-crypto-key.web.ts similarity index 100% rename from modules/expo-bluesky-oauth-client/src/rn-crypto-key.web.ts rename to modules/expo-bluesky-oauth-client/src-old/rn-crypto-key.web.ts diff --git a/modules/expo-bluesky-oauth-client/src/rn-oauth-client-factory.native.ts b/modules/expo-bluesky-oauth-client/src-old/rn-oauth-client-factory.native.ts similarity index 100% rename from modules/expo-bluesky-oauth-client/src/rn-oauth-client-factory.native.ts rename to modules/expo-bluesky-oauth-client/src-old/rn-oauth-client-factory.native.ts diff --git a/modules/expo-bluesky-oauth-client/src/rn-oauth-client-factory.ts b/modules/expo-bluesky-oauth-client/src-old/rn-oauth-client-factory.ts similarity index 100% rename from modules/expo-bluesky-oauth-client/src/rn-oauth-client-factory.ts rename to modules/expo-bluesky-oauth-client/src-old/rn-oauth-client-factory.ts diff --git a/modules/expo-bluesky-oauth-client/src/rn-oauth-database.native.ts b/modules/expo-bluesky-oauth-client/src-old/rn-oauth-database.native.ts similarity index 100% rename from modules/expo-bluesky-oauth-client/src/rn-oauth-database.native.ts rename to modules/expo-bluesky-oauth-client/src-old/rn-oauth-database.native.ts diff --git a/modules/expo-bluesky-oauth-client/src/rn-oauth-database.ts b/modules/expo-bluesky-oauth-client/src-old/rn-oauth-database.ts similarity index 100% rename from modules/expo-bluesky-oauth-client/src/rn-oauth-database.ts rename to modules/expo-bluesky-oauth-client/src-old/rn-oauth-database.ts diff --git a/modules/expo-bluesky-oauth-client/src/store.ts b/modules/expo-bluesky-oauth-client/src-old/store.ts similarity index 100% rename from modules/expo-bluesky-oauth-client/src/store.ts rename to modules/expo-bluesky-oauth-client/src-old/store.ts diff --git a/modules/expo-bluesky-oauth-client/src/store.web.ts b/modules/expo-bluesky-oauth-client/src-old/store.web.ts similarity index 100% rename from modules/expo-bluesky-oauth-client/src/store.web.ts rename to modules/expo-bluesky-oauth-client/src-old/store.web.ts diff --git a/modules/expo-bluesky-oauth-client/src/util.web.ts b/modules/expo-bluesky-oauth-client/src-old/util.web.ts similarity index 100% rename from modules/expo-bluesky-oauth-client/src/util.web.ts rename to modules/expo-bluesky-oauth-client/src-old/util.web.ts diff --git a/modules/expo-bluesky-oauth-client/src/oauth-client-react-native.ts b/modules/expo-bluesky-oauth-client/src/oauth-client-react-native.ts new file mode 100644 index 0000000000..4ce738202e --- /dev/null +++ b/modules/expo-bluesky-oauth-client/src/oauth-client-react-native.ts @@ -0,0 +1,44 @@ +import {requireNativeModule} from 'expo-modules-core' +import {Jwk, Jwt} from '@atproto/jwk' + +const NativeModule = requireNativeModule('ExpoBlueskyOAuthClient') + +const LINKING_ERROR = + 'The package ExpoBlueskyOAuthClient is not linked. Make sure you have run `expo install expo-bluesky-oauth-client` and rebuilt your app.' + +export const OauthClientReactNative = (NativeModule as null) || { + getRandomValues(_length: number): Uint8Array { + throw new Error(LINKING_ERROR) + }, + + /** + * @throws if the algorithm is not supported ("sha256" must be supported) + */ + digest(_bytes: Uint8Array, _algorithm: string): Uint8Array { + throw new Error(LINKING_ERROR) + }, + + /** + * Create a private JWK for the given algorithm. The JWK should have a "use" + * an does not need a "kid" property. + * + * @throws if the algorithm is not supported ("ES256" must be supported) + */ + generateJwk(_algo: string): Jwk { + throw new Error(LINKING_ERROR) + }, + + createJwt(_header: unknown, _payload: unknown, _jwk: unknown): Jwt { + throw new Error(LINKING_ERROR) + }, + + verifyJwt( + _token: Jwt, + _jwk: Jwk, + ): { + payload: Record + protectedHeader: Record + } { + throw new Error(LINKING_ERROR) + }, +} diff --git a/modules/expo-bluesky-oauth-client/src/react-native-crypto-implementation.ts b/modules/expo-bluesky-oauth-client/src/react-native-crypto-implementation.ts new file mode 100644 index 0000000000..757e5615a0 --- /dev/null +++ b/modules/expo-bluesky-oauth-client/src/react-native-crypto-implementation.ts @@ -0,0 +1,27 @@ +import {CryptoImplementaton, DigestAlgorithm, Key} from '@atproto/oauth-client' + +import {OauthClientReactNative} from './oauth-client-react-native' +import {ReactNativeKey} from './react-native-key' + +export class ReactNativeCryptoImplementation implements CryptoImplementaton { + async createKey(algs: string[]): Promise { + const bytes = await this.getRandomValues(12) + const kid = Array.from(bytes, byteToHex).join('') + return ReactNativeKey.generate(kid, algs) + } + + async getRandomValues(length: number): Promise { + return OauthClientReactNative.getRandomValues(length) + } + + async digest( + bytes: Uint8Array, + algorithm: DigestAlgorithm, + ): Promise { + return OauthClientReactNative.digest(bytes, algorithm.name) + } +} + +function byteToHex(b: number): string { + return b.toString(16).padStart(2, '0') +} diff --git a/modules/expo-bluesky-oauth-client/src/react-native-key.ts b/modules/expo-bluesky-oauth-client/src/react-native-key.ts new file mode 100644 index 0000000000..03d27db691 --- /dev/null +++ b/modules/expo-bluesky-oauth-client/src/react-native-key.ts @@ -0,0 +1,110 @@ +import { + jwkValidator, + Jwt, + JwtHeader, + jwtHeaderSchema, + JwtPayload, + jwtPayloadSchema, + Key, + VerifyOptions, + VerifyPayload, + VerifyResult, +} from '@atproto/jwk' + +import {OauthClientReactNative} from './oauth-client-react-native' + +export class ReactNativeKey extends Key { + static async generate(kid: string, allowedAlgos: string[]) { + for (const algo of allowedAlgos) { + try { + // Note: OauthClientReactNative.generatePrivateJwk should throw if it + // doesn't support the algorithm. + const jwk = await OauthClientReactNative.generateJwk(algo) + const use = jwk.use || 'sig' + return new ReactNativeKey(jwkValidator.parse({...jwk, use, kid})) + } catch { + // Ignore, try next one + } + } + + throw new Error('No supported algorithms') + } + + async createJwt(header: JwtHeader, payload: JwtPayload): Promise { + return OauthClientReactNative.createJwt(header, payload, this.jwk) + } + + async verifyJwt< + P extends VerifyPayload = JwtPayload, + C extends string = string, + >(token: Jwt, options?: VerifyOptions): Promise> { + const result = await OauthClientReactNative.verifyJwt(token, this.jwk) + + const payload = jwtPayloadSchema.parse(result.payload) + const protectedHeader = jwtHeaderSchema.parse(result.protectedHeader) + + if (options?.audience != null) { + const audience = Array.isArray(options.audience) + ? options.audience + : [options.audience] + if (!audience.includes(payload.aud)) { + throw new Error('Invalid audience') + } + } + + if (options?.issuer != null) { + const issuer = Array.isArray(options.issuer) + ? options.issuer + : [options.issuer] + if (!issuer.includes(payload.iss)) { + throw new Error('Invalid issuer') + } + } + + if (options?.subject != null && payload.sub !== options.subject) { + throw new Error('Invalid subject') + } + + if (options?.typ != null && protectedHeader.typ !== options.typ) { + throw new Error('Invalid type') + } + + if (options?.requiredClaims != null) { + for (const key of options.requiredClaims) { + if ( + !Object.hasOwn(payload, key) || + (payload as Record)[key] === undefined + ) { + throw new Error(`Missing claim: ${key}`) + } + } + } + + if (payload.iat == null) { + throw new Error('Missing issued at') + } + + const now = (options?.currentDate?.getTime() ?? Date.now()) / 1e3 + const clockTolerance = options?.clockTolerance ?? 0 + + if (options?.maxTokenAge != null) { + if (payload.iat < now - options.maxTokenAge + clockTolerance) { + throw new Error('Invalid issued at') + } + } + + if (payload.nbf != null) { + if (payload.nbf > now - clockTolerance) { + throw new Error('Invalid not before') + } + } + + if (payload.exp != null) { + if (payload.exp < now + clockTolerance) { + throw new Error('Invalid expiration') + } + } + + return {payload, protectedHeader} as VerifyResult + } +} diff --git a/modules/expo-bluesky-oauth-client/src/react-native-store-with-key.ts b/modules/expo-bluesky-oauth-client/src/react-native-store-with-key.ts new file mode 100644 index 0000000000..b65e9a46ed --- /dev/null +++ b/modules/expo-bluesky-oauth-client/src/react-native-store-with-key.ts @@ -0,0 +1,51 @@ +import {GenericStore, Value} from '@atproto/caching' +import {Jwk} from '@atproto/jwk' + +import {ReactNativeKey} from './react-native-key.js' +import {ReactNativeStore} from './react-native-store.js' + +type ExposedValue = Value & {dpopKey: ReactNativeKey} +type StoredValue = Omit & { + dpopKey: Jwk +} + +/** + * Uses a {@link ReactNativeStore} to store values that contain a + * {@link ReactNativeKey} as `dpopKey` property. This works by serializing the + * {@link Key} to a JWK before storing it, and deserializing it back to a + * {@link ReactNativeKey} when retrieving the value. + */ +export class ReactNativeStoreWithKey + implements GenericStore +{ + internalStore: ReactNativeStore> + + constructor( + protected valueExpiresAt: (value: StoredValue) => null | Date, + ) { + this.internalStore = new ReactNativeStore(valueExpiresAt) + } + + async set(key: string, value: V): Promise { + const {dpopKey, ...rest} = value + if (!dpopKey.privateJwk) throw new Error('dpopKey.privateJwk is required') + await this.internalStore.set(key, { + ...rest, + dpopKey: dpopKey.privateJwk, + }) + } + + async get(key: string): Promise { + const value = await this.internalStore.get(key) + if (!value) return undefined + + return { + ...value, + dpopKey: new ReactNativeKey(value.dpopKey), + } as V + } + + async del(key: string): Promise { + await this.internalStore.del(key) + } +} diff --git a/modules/expo-bluesky-oauth-client/src/react-native-store.ts b/modules/expo-bluesky-oauth-client/src/react-native-store.ts new file mode 100644 index 0000000000..518a72243d --- /dev/null +++ b/modules/expo-bluesky-oauth-client/src/react-native-store.ts @@ -0,0 +1,25 @@ +import {GenericStore, Value} from '@atproto/caching' +import Storage from '@react-native-async-storage/async-storage' + +export class ReactNativeStore + implements GenericStore +{ + constructor(protected valueExpiresAt: (value: V) => null | Date) { + throw new Error('Not implemented') + } + + async get(key: string): Promise { + const itemJson = await Storage.getItem(key) + if (itemJson == null) return undefined + + return JSON.parse(itemJson) as V + } + + async set(key: string, value: V): Promise { + await Storage.setItem(key, JSON.stringify(value)) + } + + async del(key: string): Promise { + await Storage.delete(key) + } +} From 71f1e44653cc44b56d92917aef8300c1d3e09545 Mon Sep 17 00:00:00 2001 From: Hailey Date: Sun, 14 Apr 2024 22:21:47 -0700 Subject: [PATCH 02/11] add jwt struct --- .../expo-bluesky-oauth-client/ios/JWK.swift | 56 ++++--- .../ios/JWTHeader.swift | 154 ++++++++++++++++++ 2 files changed, 185 insertions(+), 25 deletions(-) create mode 100644 modules/expo-bluesky-oauth-client/ios/JWTHeader.swift diff --git a/modules/expo-bluesky-oauth-client/ios/JWK.swift b/modules/expo-bluesky-oauth-client/ios/JWK.swift index 879d37a401..41d9522aa0 100644 --- a/modules/expo-bluesky-oauth-client/ios/JWK.swift +++ b/modules/expo-bluesky-oauth-client/ios/JWK.swift @@ -1,29 +1,35 @@ -struct JWK { - let kty: String - let use: String - let crv: String - let kid: String - let x: String - let y: String +import ExpoModulesCore + +struct JWK : Record { + @Field + var alg: String + @Field + var kty: String + @Field + var crv: String? + @Field + var x: String? + @Field + var y: String? + @Field + var e: String? + @Field + var n: String? + @Field var d: String? - let alg: String + @Field + var use: String? + @Field + var kid: String? - func toJson() -> String { - var dict: [String: Any] = [ - "kty": kty, - "use": use, - "crv": crv, - "kid": kid, - "x": x, - "y": y, - "alg": alg, - ] - - if let d = d { - dict["d"] = d - } - - let jsonData = try! JSONSerialization.data(withJSONObject: dict, options: []) - return String(data: jsonData, encoding: .utf8)! + func toField() -> Field { + return Field(wrappedValue: self) } } + +struct JWKPair : Record { + @Field + var privateKey: JWK + @Field + var publicKey: JWK +} diff --git a/modules/expo-bluesky-oauth-client/ios/JWTHeader.swift b/modules/expo-bluesky-oauth-client/ios/JWTHeader.swift new file mode 100644 index 0000000000..c80ce57f1e --- /dev/null +++ b/modules/expo-bluesky-oauth-client/ios/JWTHeader.swift @@ -0,0 +1,154 @@ +import ExpoModulesCore + +struct JWTHeader : Record { + @Field + var alg: String = "ES256" + @Field + var jku: String? + @Field + var jwk: JWK + @Field + var kid: String? + @Field + var x5u: String? + @Field + var x5c: String? + @Field + var x5t: String? + @Field + var typ: String? + @Field + var cty: String? + @Field + var crit: String? +} + +struct JWTPayload : Record { + @Field + var iss: String? + @Field + var aud: String? + @Field + var sub: String? + @Field + var exp: Int? + @Field + var nbr: Int? + @Field + var iat: Int? + @Field + var jti: String? + @Field + var htm: String? + @Field + var htu: String? + @Field + var ath: String? + @Field + var acr: String? + @Field + var azp: String? + @Field + var amr: String? + @Field + var cnf: JWTPayloadCNF? + @Field + var client_id: String? + @Field + var scope: String? + @Field + var nonce: String? + @Field + var at_hash: String? + @Field + var c_hash: String? + @Field + var s_hash: String? + @Field + var auth_time: Int? + @Field + var name: String? + @Field + var family_name: String? + @Field + var given_name: String? + @Field + var middle_name: String? + @Field + var nickname: String? + @Field + var preferred_username: String? + @Field + var gender: String? + @Field + var picture: String? + @Field + var profile: String? + @Field + var website: String? + @Field + var birthdate: String? + @Field + var zoneinfo: String? + @Field + var locale: String? + @Field + var updated_at: Int? + @Field + var email: String? + @Field + var email_verified: String? + @Field + var phone_number: String? + @Field + var phone_number_verified: Bool? + @Field + var address: JWTPayloadAddress? + @Field + var authorization_details: JWTPayloadAuthorizationDetails? +} + +struct JWTPayloadCNF : Record { + @Field + var kid: String? + @Field + var jwk: JWK? + @Field + var jwe: String? + @Field + var jku: String? + @Field + var jkt: String? + @Field + var osc: String? +} + +struct JWTPayloadAddress : Record { + @Field + var formatted: String? + @Field + var street_address: String? + @Field + var locality: String? + @Field + var region: String? + @Field + var postal_code: String? + @Field + var country: String? +} + +struct JWTPayloadAuthorizationDetails : Record { + @Field + var type: String + @Field + var locations: [String]? + @Field + var actions: [String]? + @Field + var datatypes: [String]? + @Field + var identifier: String? + @Field + var privileges: [String]? +} From 2ada0cbd0dd07432b7d9c76917d4672ed908738b Mon Sep 17 00:00:00 2001 From: Hailey Date: Sun, 14 Apr 2024 23:27:27 -0700 Subject: [PATCH 03/11] better implementation --- modules/expo-bluesky-oauth-client/index.ts | 10 ++--- .../ios/CryptoUtil.swift | 37 ++++++++++++++++--- .../ios/ExpoBlueskyOAuthClientModule.swift | 20 +++++----- .../expo-bluesky-oauth-client/ios/JWK.swift | 12 ++++++ .../ios/{JWTHeader.swift => JWT.swift} | 31 +++++++++++++++- .../ios/JWTUtil.swift | 27 +++----------- .../src/oauth-client-react-native.ts | 18 +++++---- .../src/react-native-crypto-implementation.ts | 6 +-- .../src/react-native-key.ts | 7 +++- .../src/react-native-store-with-key.ts | 4 +- .../src/react-native-store.ts | 2 +- src/view/screens/Home.tsx | 24 +++++++++--- 12 files changed, 136 insertions(+), 62 deletions(-) rename modules/expo-bluesky-oauth-client/ios/{JWTHeader.swift => JWT.swift} (76%) diff --git a/modules/expo-bluesky-oauth-client/index.ts b/modules/expo-bluesky-oauth-client/index.ts index 05f53f9803..79e975ebc9 100644 --- a/modules/expo-bluesky-oauth-client/index.ts +++ b/modules/expo-bluesky-oauth-client/index.ts @@ -1,6 +1,4 @@ -export * from './src-old/crypto-subtle' -export * from './src-old/jose-key' -export * from './src-old/rn-crypto-key' -export * from './src-old/rn-oauth-client-factory' -export * from './src-old/rn-oauth-database' -export * from './src-old/util.web' +export * from './src/oauth-client-react-native' +export * from './src/react-native-crypto-implementation' +export * from './src/react-native-key' +export * from './src/react-native-store-with-key' diff --git a/modules/expo-bluesky-oauth-client/ios/CryptoUtil.swift b/modules/expo-bluesky-oauth-client/ios/CryptoUtil.swift index c9b95f81b9..fd20643f9a 100644 --- a/modules/expo-bluesky-oauth-client/ios/CryptoUtil.swift +++ b/modules/expo-bluesky-oauth-client/ios/CryptoUtil.swift @@ -1,5 +1,6 @@ import CryptoKit import JOSESwift +import ExpoModulesCore class CryptoUtil { // The equivalent of crypto.subtle.digest() with JS on web @@ -13,8 +14,8 @@ class CryptoUtil { return Data(bytes) } - public static func generateKeyPair(kid: String?) throws -> (publicJWK: JWK, privateJWK: JWK)? { - let keyIdString = kid ?? UUID().uuidString + public static func generateKeyPair() throws -> JWKPair? { + let keyIdString = UUID().uuidString let privateKey = P256.Signing.PrivateKey() let publicKey = privateKey.publicKey @@ -23,10 +24,27 @@ class CryptoUtil { let y = publicKey.x963Representation[33...].base64URLEncodedString() let d = privateKey.rawRepresentation.base64URLEncodedString() - let publicJWK = JWK(kty: "EC", use: "sig", crv: "P-256", kid: keyIdString, x: x, y: y, alg: "ES256") - let privateJWK = JWK(kty: "EC", use: "sig", crv: "P-256", kid: keyIdString, x: x, y: y, d: d, alg: "ES256") + let publicJWK = JWK( + alg: "ES256".toField(), + kty: "EC".toField(), + crv: "P-256".toNullableField(), + x: x.toNullableField(), + y: y.toNullableField(), + use: "sig".toNullableField(), + kid: keyIdString.toNullableField() + ) + let privateJWK = JWK( + alg: "ES256".toField(), + kty: "EC".toField(), + crv: "P-256".toNullableField(), + x: x.toNullableField(), + y: y.toNullableField(), + d: d.toNullableField(), + use: "sig".toNullableField(), + kid: keyIdString.toNullableField() + ) - return (publicJWK, privateJWK) + return JWKPair(privateKey: privateJWK.toField(), publicKey: publicJWK.toField()) } } @@ -35,3 +53,12 @@ extension Data { return self.base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "") } } + +extension String { + func toField() -> Field { + return Field(wrappedValue: self) + } + func toNullableField() -> Field { + return Field(wrappedValue: self) + } +} diff --git a/modules/expo-bluesky-oauth-client/ios/ExpoBlueskyOAuthClientModule.swift b/modules/expo-bluesky-oauth-client/ios/ExpoBlueskyOAuthClientModule.swift index 729194916a..510d60fdc6 100644 --- a/modules/expo-bluesky-oauth-client/ios/ExpoBlueskyOAuthClientModule.swift +++ b/modules/expo-bluesky-oauth-client/ios/ExpoBlueskyOAuthClientModule.swift @@ -14,22 +14,24 @@ public class ExpoBlueskyOAuthClientModule: Module { return CryptoUtil.getRandomValues(byteLength: byteLength) } - AsyncFunction ("generateKeyPair") { (kid: String?, promise: Promise) in - let keypair = try? CryptoUtil.generateKeyPair(kid: kid) + AsyncFunction ("generateJwk") { (algo: String?, promise: Promise) in + if algo != "ES256" { + promise.reject("GenerateKeyError", "Algorithim not supported.") + return + } + + let keypair = try? CryptoUtil.generateKeyPair() - guard let keypair = keypair else { + guard keypair != nil else { promise.reject("GenerateKeyError", "Error generating JWK.") return } - promise.resolve([ - "publicKey": keypair.publicJWK.toJson(), - "privateKey": keypair.privateJWK.toJson() - ]) + promise.resolve(keypair) } - AsyncFunction("createJwt") { (jwk: String, header: String, payload: String, promise: Promise) in - guard let jwt = JWTUtil.createJwt(jwk, header: header, payload: payload) else { + AsyncFunction("createJwt") { (header: JWTHeader, payload: JWTPayload, jwk: JWK, promise: Promise) in + guard let jwt = JWTUtil.createJwt(header: header, payload: payload, jwk: jwk) else { promise.reject("JWTError", "Error creating JWT.") return } diff --git a/modules/expo-bluesky-oauth-client/ios/JWK.swift b/modules/expo-bluesky-oauth-client/ios/JWK.swift index 41d9522aa0..e9d80fe3b4 100644 --- a/modules/expo-bluesky-oauth-client/ios/JWK.swift +++ b/modules/expo-bluesky-oauth-client/ios/JWK.swift @@ -1,4 +1,5 @@ import ExpoModulesCore +import JOSESwift struct JWK : Record { @Field @@ -25,6 +26,17 @@ struct JWK : Record { func toField() -> Field { return Field(wrappedValue: self) } + + func toSecKey() throws -> SecKey? { + let jsonData = try JSONSerialization.data(withJSONObject: self.toDictionary()) + guard let jwk = try? JSONDecoder().decode(ECPrivateKey.self, from: jsonData), + let key = try? jwk.converted(to: SecKey.self) + else { + print("Error creating SecKey.") + return nil + } + return key + } } struct JWKPair : Record { diff --git a/modules/expo-bluesky-oauth-client/ios/JWTHeader.swift b/modules/expo-bluesky-oauth-client/ios/JWT.swift similarity index 76% rename from modules/expo-bluesky-oauth-client/ios/JWTHeader.swift rename to modules/expo-bluesky-oauth-client/ios/JWT.swift index c80ce57f1e..b6c27a8c66 100644 --- a/modules/expo-bluesky-oauth-client/ios/JWTHeader.swift +++ b/modules/expo-bluesky-oauth-client/ios/JWT.swift @@ -1,4 +1,5 @@ import ExpoModulesCore +import JOSESwift struct JWTHeader : Record { @Field @@ -6,8 +7,6 @@ struct JWTHeader : Record { @Field var jku: String? @Field - var jwk: JWK - @Field var kid: String? @Field var x5u: String? @@ -21,6 +20,14 @@ struct JWTHeader : Record { var cty: String? @Field var crit: String? + + func toField() -> Field { + return Field(wrappedValue: self) + } + + func toJWSHeader() throws -> JWSHeader? { + return JWSHeader(try JSONSerialization.data(withJSONObject: self.toDictionary())) + } } struct JWTPayload : Record { @@ -106,6 +113,14 @@ struct JWTPayload : Record { var address: JWTPayloadAddress? @Field var authorization_details: JWTPayloadAuthorizationDetails? + + func toField() -> Field { + return Field(wrappedValue: self) + } + + func toPayload() throws -> Payload { + return Payload(try JSONSerialization.data(withJSONObject: self.toDictionary())) + } } struct JWTPayloadCNF : Record { @@ -121,6 +136,10 @@ struct JWTPayloadCNF : Record { var jkt: String? @Field var osc: String? + + func toField() -> Field { + return Field(wrappedValue: self) + } } struct JWTPayloadAddress : Record { @@ -136,6 +155,10 @@ struct JWTPayloadAddress : Record { var postal_code: String? @Field var country: String? + + func toField() -> Field { + return Field(wrappedValue: self) + } } struct JWTPayloadAuthorizationDetails : Record { @@ -151,4 +174,8 @@ struct JWTPayloadAuthorizationDetails : Record { var identifier: String? @Field var privileges: [String]? + + func toField() -> Field { + return Field(wrappedValue: self) + } } diff --git a/modules/expo-bluesky-oauth-client/ios/JWTUtil.swift b/modules/expo-bluesky-oauth-client/ios/JWTUtil.swift index d3dcdcee3d..fc10136c86 100644 --- a/modules/expo-bluesky-oauth-client/ios/JWTUtil.swift +++ b/modules/expo-bluesky-oauth-client/ios/JWTUtil.swift @@ -1,21 +1,6 @@ import JOSESwift class JWTUtil { - static func jsonToPrivateKey(_ jwkString: String) throws -> SecKey? { - guard let jsonData = jwkString.data(using: .utf8), - let jwk = try? JSONDecoder().decode(ECPrivateKey.self, from: jsonData), - let key = try? jwk.converted(to: SecKey.self) - else { - let jsonData = jwkString.data(using: .utf8)! - let jwk = try! JSONDecoder().decode(ECPrivateKey.self, from: jsonData) -// let key = try! jwk.converted(to: SecKey.self) - print("Error creating JWK from JWK string \(jwkString).") - return nil - } - - return key - } - static func jsonToPublicKey(_ jwkString: String) throws -> SecKey? { guard let jsonData = jwkString.data(using: .utf8), let jwk = try? JSONDecoder().decode(ECPublicKey.self, from: jsonData), @@ -46,12 +31,12 @@ class JWTUtil { return JWSHeader(headerData) } - public static func createJwt(_ jwkString: String, header headerString: String, payload payloadString: String) -> String? { - guard let key = try? jsonToPrivateKey(jwkString), - let payload = payloadStringToPayload(payloadString), - let header = headerStringToPayload(headerString) - else - { + public static func createJwt(header: JWTHeader, payload: JWTPayload, jwk: JWK) -> String? { + guard let header = try? header.toJWSHeader(), + let payload = try? payload.toPayload(), + let key = try? jwk.toSecKey() + else { + print("didn't have one") return nil } diff --git a/modules/expo-bluesky-oauth-client/src/oauth-client-react-native.ts b/modules/expo-bluesky-oauth-client/src/oauth-client-react-native.ts index 4ce738202e..7fd1889d44 100644 --- a/modules/expo-bluesky-oauth-client/src/oauth-client-react-native.ts +++ b/modules/expo-bluesky-oauth-client/src/oauth-client-react-native.ts @@ -1,5 +1,5 @@ import {requireNativeModule} from 'expo-modules-core' -import {Jwk, Jwt} from '@atproto/jwk' +import {Jwk, Jwt, Key} from '@atproto/jwk' const NativeModule = requireNativeModule('ExpoBlueskyOAuthClient') @@ -14,7 +14,7 @@ export const OauthClientReactNative = (NativeModule as null) || { /** * @throws if the algorithm is not supported ("sha256" must be supported) */ - digest(_bytes: Uint8Array, _algorithm: string): Uint8Array { + async digest(_bytes: Uint8Array, _algorithm: string): Promise { throw new Error(LINKING_ERROR) }, @@ -24,21 +24,25 @@ export const OauthClientReactNative = (NativeModule as null) || { * * @throws if the algorithm is not supported ("ES256" must be supported) */ - generateJwk(_algo: string): Jwk { + async generateJwk(_algo: string): Promise<{publicKey: Key; privateKey: Key}> { throw new Error(LINKING_ERROR) }, - createJwt(_header: unknown, _payload: unknown, _jwk: unknown): Jwt { + async createJwt( + _header: unknown, + _payload: unknown, + _jwk: unknown, + ): Promise { throw new Error(LINKING_ERROR) }, - verifyJwt( + async verifyJwt( _token: Jwt, _jwk: Jwk, - ): { + ): Promise<{ payload: Record protectedHeader: Record - } { + }> { throw new Error(LINKING_ERROR) }, } diff --git a/modules/expo-bluesky-oauth-client/src/react-native-crypto-implementation.ts b/modules/expo-bluesky-oauth-client/src/react-native-crypto-implementation.ts index 757e5615a0..26f58dc7b2 100644 --- a/modules/expo-bluesky-oauth-client/src/react-native-crypto-implementation.ts +++ b/modules/expo-bluesky-oauth-client/src/react-native-crypto-implementation.ts @@ -1,13 +1,13 @@ -import {CryptoImplementaton, DigestAlgorithm, Key} from '@atproto/oauth-client' +import {CryptoImplementation, DigestAlgorithm, Key} from '@atproto/oauth-client' import {OauthClientReactNative} from './oauth-client-react-native' import {ReactNativeKey} from './react-native-key' -export class ReactNativeCryptoImplementation implements CryptoImplementaton { +export class ReactNativeCryptoImplementation implements CryptoImplementation { async createKey(algs: string[]): Promise { const bytes = await this.getRandomValues(12) const kid = Array.from(bytes, byteToHex).join('') - return ReactNativeKey.generate(kid, algs) + return await ReactNativeKey.generate(kid, algs) } async getRandomValues(length: number): Promise { diff --git a/modules/expo-bluesky-oauth-client/src/react-native-key.ts b/modules/expo-bluesky-oauth-client/src/react-native-key.ts index 03d27db691..d4b8ef355c 100644 --- a/modules/expo-bluesky-oauth-client/src/react-native-key.ts +++ b/modules/expo-bluesky-oauth-client/src/react-native-key.ts @@ -19,7 +19,12 @@ export class ReactNativeKey extends Key { try { // Note: OauthClientReactNative.generatePrivateJwk should throw if it // doesn't support the algorithm. - const jwk = await OauthClientReactNative.generateJwk(algo) + const res = await OauthClientReactNative.generateJwk(algo) + const jwk = jwkValidator.parse({ + ...res.privateKey, + key_ops: ['sign', 'verify'], + kid, + }) const use = jwk.use || 'sig' return new ReactNativeKey(jwkValidator.parse({...jwk, use, kid})) } catch { diff --git a/modules/expo-bluesky-oauth-client/src/react-native-store-with-key.ts b/modules/expo-bluesky-oauth-client/src/react-native-store-with-key.ts index b65e9a46ed..435e566cf5 100644 --- a/modules/expo-bluesky-oauth-client/src/react-native-store-with-key.ts +++ b/modules/expo-bluesky-oauth-client/src/react-native-store-with-key.ts @@ -1,8 +1,8 @@ import {GenericStore, Value} from '@atproto/caching' import {Jwk} from '@atproto/jwk' -import {ReactNativeKey} from './react-native-key.js' -import {ReactNativeStore} from './react-native-store.js' +import {ReactNativeKey} from './react-native-key' +import {ReactNativeStore} from './react-native-store' type ExposedValue = Value & {dpopKey: ReactNativeKey} type StoredValue = Omit & { diff --git a/modules/expo-bluesky-oauth-client/src/react-native-store.ts b/modules/expo-bluesky-oauth-client/src/react-native-store.ts index 518a72243d..0a3d186d07 100644 --- a/modules/expo-bluesky-oauth-client/src/react-native-store.ts +++ b/modules/expo-bluesky-oauth-client/src/react-native-store.ts @@ -20,6 +20,6 @@ export class ReactNativeStore } async del(key: string): Promise { - await Storage.delete(key) + await Storage.removeItem(key) } } diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index a97f2d2986..b201db08a9 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -19,7 +19,7 @@ import {Pager, PagerRef, RenderTabBarFnProps} from 'view/com/pager/Pager' import {CustomFeedEmptyState} from 'view/com/posts/CustomFeedEmptyState' import {FollowingEmptyState} from 'view/com/posts/FollowingEmptyState' import {FollowingEndOfFeed} from 'view/com/posts/FollowingEndOfFeed' -import {RnCryptoKey} from '../../../modules/expo-bluesky-oauth-client' +import {ReactNativeKey} from '../../../modules/expo-bluesky-oauth-client' import {HomeLoggedOutCTA} from '../com/auth/HomeLoggedOutCTA' import {HomeHeader} from '../com/home/HomeHeader' @@ -54,13 +54,27 @@ function HomeScreenReady({ }) { React.useEffect(() => { ;(async () => { - const key = await RnCryptoKey.generate(undefined, ['ES256'], false) - console.log('public', key.publicJwk) + const key = await ReactNativeKey.generate('test', ['ES256']) + console.log(key.privateJwk) + const jwt = await key.createJwt( - {alg: 'ES256', kid: key.kid}, - {sub: 'test'}, + { + alg: 'ES256', + kid: key.kid, + }, + { + sub: 'test', + }, ) + console.log(jwt) + + // console.log('public', key.publicJwk) + // const jwt = await key.createJwt( + // {alg: 'ES256', kid: key.kid}, + // {sub: 'test'}, + // ) + // console.log(jwt) })() }, []) From 2c1b3709afbf1486a8668cd4759ba18e05ff44ef Mon Sep 17 00:00:00 2001 From: Hailey Date: Mon, 15 Apr 2024 00:37:12 -0700 Subject: [PATCH 04/11] swift impl --- .../ios/ExpoBlueskyOAuthClientModule.swift | 4 +-- .../expo-bluesky-oauth-client/ios/JWK.swift | 13 ++++++- .../expo-bluesky-oauth-client/ios/JWT.swift | 15 ++++---- .../ios/JWTUtil.swift | 34 +++++++++++++++---- .../src/oauth-client-react-native.ts | 2 +- .../src/react-native-key.ts | 12 +++++-- src/view/screens/Home.tsx | 10 ++---- 7 files changed, 62 insertions(+), 28 deletions(-) diff --git a/modules/expo-bluesky-oauth-client/ios/ExpoBlueskyOAuthClientModule.swift b/modules/expo-bluesky-oauth-client/ios/ExpoBlueskyOAuthClientModule.swift index 510d60fdc6..ee0b4f5ce5 100644 --- a/modules/expo-bluesky-oauth-client/ios/ExpoBlueskyOAuthClientModule.swift +++ b/modules/expo-bluesky-oauth-client/ios/ExpoBlueskyOAuthClientModule.swift @@ -38,8 +38,8 @@ public class ExpoBlueskyOAuthClientModule: Module { promise.resolve(jwt) } - AsyncFunction("verifyJwt") { (jwk: String, token: String, options: String?, promise: Promise) in - promise.resolve(JWTUtil.verifyJwt(jwk, token: token, options: options)) + AsyncFunction("verifyJwt") { (token: String, jwk: JWK, promise: Promise) in + promise.resolve(JWTUtil.verifyJwt(token: token, jwk: jwk)) } } } diff --git a/modules/expo-bluesky-oauth-client/ios/JWK.swift b/modules/expo-bluesky-oauth-client/ios/JWK.swift index e9d80fe3b4..daa7206914 100644 --- a/modules/expo-bluesky-oauth-client/ios/JWK.swift +++ b/modules/expo-bluesky-oauth-client/ios/JWK.swift @@ -27,7 +27,7 @@ struct JWK : Record { return Field(wrappedValue: self) } - func toSecKey() throws -> SecKey? { + func toPrivateSecKey() throws -> SecKey? { let jsonData = try JSONSerialization.data(withJSONObject: self.toDictionary()) guard let jwk = try? JSONDecoder().decode(ECPrivateKey.self, from: jsonData), let key = try? jwk.converted(to: SecKey.self) @@ -37,6 +37,17 @@ struct JWK : Record { } return key } + + func toPublicSecKey() throws -> SecKey? { + let jsonData = try JSONSerialization.data(withJSONObject: self.toDictionary()) + guard let jwk = try? JSONDecoder().decode(ECPublicKey.self, from: jsonData), + let key = try? jwk.converted(to: SecKey.self) + else { + print("Error creating SecKey.") + return nil + } + return key + } } struct JWKPair : Record { diff --git a/modules/expo-bluesky-oauth-client/ios/JWT.swift b/modules/expo-bluesky-oauth-client/ios/JWT.swift index b6c27a8c66..dc3dbf9f54 100644 --- a/modules/expo-bluesky-oauth-client/ios/JWT.swift +++ b/modules/expo-bluesky-oauth-client/ios/JWT.swift @@ -7,14 +7,10 @@ struct JWTHeader : Record { @Field var jku: String? @Field + var jwk: JWK + @Field var kid: String? @Field - var x5u: String? - @Field - var x5c: String? - @Field - var x5t: String? - @Field var typ: String? @Field var cty: String? @@ -179,3 +175,10 @@ struct JWTPayloadAuthorizationDetails : Record { return Field(wrappedValue: self) } } + +struct JWTVerifyResponse : Record { + @Field + var protectedHeader: JWTHeader + @Field + var payload: String +} diff --git a/modules/expo-bluesky-oauth-client/ios/JWTUtil.swift b/modules/expo-bluesky-oauth-client/ios/JWTUtil.swift index fc10136c86..f14d2a113d 100644 --- a/modules/expo-bluesky-oauth-client/ios/JWTUtil.swift +++ b/modules/expo-bluesky-oauth-client/ios/JWTUtil.swift @@ -1,3 +1,4 @@ +import ExpoModulesCore import JOSESwift class JWTUtil { @@ -34,7 +35,7 @@ class JWTUtil { public static func createJwt(header: JWTHeader, payload: JWTPayload, jwk: JWK) -> String? { guard let header = try? header.toJWSHeader(), let payload = try? payload.toPayload(), - let key = try? jwk.toSecKey() + let key = try? jwk.toPrivateSecKey() else { print("didn't have one") return nil @@ -52,15 +53,34 @@ class JWTUtil { return jws.compactSerializedString } - public static func verifyJwt(_ jwkString: String, token tokenString: String, options optionsString: String?) -> Bool { - guard let key = try? jsonToPublicKey(jwkString), - let jws = try? JWS(compactSerialization: tokenString), + public static func verifyJwt(token: String, jwk: JWK) -> JWTVerifyResponse? { + guard let key = try? jwk.toPublicSecKey(), + let jws = try? JWS(compactSerialization: token), let verifier = Verifier(verifyingAlgorithm: .ES256, key: key), - let isVerified = try? jws.validate(using: verifier).isValid(for: verifier) + let validation = try? jws.validate(using: verifier) else { - return false + return nil } - return isVerified + let header = validation.header + let serializedHeader = JWTHeader( + alg: "ES256", + jku: Field(wrappedValue: header.jku?.absoluteString), + kid: Field(wrappedValue:header.kid), + typ: Field(wrappedValue: header.typ), + cty: Field(wrappedValue: header.cty), + crit: Field(wrappedValue: header.cty) + ) + + let payload = String(data: validation.payload.data(), encoding: .utf8) + + guard let payload = payload else { + return nil + } + + return JWTVerifyResponse( + protectedHeader: serializedHeader.toField(), + payload: payload.toField() + ) } } diff --git a/modules/expo-bluesky-oauth-client/src/oauth-client-react-native.ts b/modules/expo-bluesky-oauth-client/src/oauth-client-react-native.ts index 7fd1889d44..2816b7055a 100644 --- a/modules/expo-bluesky-oauth-client/src/oauth-client-react-native.ts +++ b/modules/expo-bluesky-oauth-client/src/oauth-client-react-native.ts @@ -40,7 +40,7 @@ export const OauthClientReactNative = (NativeModule as null) || { _token: Jwt, _jwk: Jwk, ): Promise<{ - payload: Record + payload: string // this is a JSON response to make Swift a bit easier to work with protectedHeader: Record }> { throw new Error(LINKING_ERROR) diff --git a/modules/expo-bluesky-oauth-client/src/react-native-key.ts b/modules/expo-bluesky-oauth-client/src/react-native-key.ts index d4b8ef355c..5b769d348d 100644 --- a/modules/expo-bluesky-oauth-client/src/react-native-key.ts +++ b/modules/expo-bluesky-oauth-client/src/react-native-key.ts @@ -2,7 +2,6 @@ import { jwkValidator, Jwt, JwtHeader, - jwtHeaderSchema, JwtPayload, jwtPayloadSchema, Key, @@ -45,8 +44,13 @@ export class ReactNativeKey extends Key { >(token: Jwt, options?: VerifyOptions): Promise> { const result = await OauthClientReactNative.verifyJwt(token, this.jwk) - const payload = jwtPayloadSchema.parse(result.payload) - const protectedHeader = jwtHeaderSchema.parse(result.protectedHeader) + let payloadParsed = JSON.parse(result.payload) + payloadParsed = Object.fromEntries( + Object.entries(payloadParsed as object).filter(([_, v]) => v !== null), + ) + + const payload = jwtPayloadSchema.parse(payloadParsed) + const protectedHeader = result.protectedHeader if (options?.audience != null) { const audience = Array.isArray(options.audience) @@ -85,6 +89,8 @@ export class ReactNativeKey extends Key { } } + console.log(payload) + if (payload.iat == null) { throw new Error('Missing issued at') } diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index b201db08a9..f50d9a8fca 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -64,17 +64,11 @@ function HomeScreenReady({ }, { sub: 'test', + iat: Math.floor(Date.now() / 1000), }, ) - console.log(jwt) - - // console.log('public', key.publicJwk) - // const jwt = await key.createJwt( - // {alg: 'ES256', kid: key.kid}, - // {sub: 'test'}, - // ) - // console.log(jwt) + const verified = await key.verifyJwt(jwt) })() }, []) From 9f6db0ef679cd8fffedd0b70e08e7d975b72ada1 Mon Sep 17 00:00:00 2001 From: Hailey Date: Mon, 15 Apr 2024 00:42:22 -0700 Subject: [PATCH 05/11] few fixes --- .../expo-bluesky-oauth-client/src/react-native-key.ts | 11 +++++------ src/view/screens/Home.tsx | 2 ++ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/modules/expo-bluesky-oauth-client/src/react-native-key.ts b/modules/expo-bluesky-oauth-client/src/react-native-key.ts index 5b769d348d..6d93b0cfdb 100644 --- a/modules/expo-bluesky-oauth-client/src/react-native-key.ts +++ b/modules/expo-bluesky-oauth-client/src/react-native-key.ts @@ -19,11 +19,7 @@ export class ReactNativeKey extends Key { // Note: OauthClientReactNative.generatePrivateJwk should throw if it // doesn't support the algorithm. const res = await OauthClientReactNative.generateJwk(algo) - const jwk = jwkValidator.parse({ - ...res.privateKey, - key_ops: ['sign', 'verify'], - kid, - }) + const jwk = res.privateKey const use = jwk.use || 'sig' return new ReactNativeKey(jwkValidator.parse({...jwk, use, kid})) } catch { @@ -44,12 +40,15 @@ export class ReactNativeKey extends Key { >(token: Jwt, options?: VerifyOptions): Promise> { const result = await OauthClientReactNative.verifyJwt(token, this.jwk) + // TODO see if we can make these `undefined` or maybe update zod to allow `nullable()` let payloadParsed = JSON.parse(result.payload) payloadParsed = Object.fromEntries( Object.entries(payloadParsed as object).filter(([_, v]) => v !== null), ) - const payload = jwtPayloadSchema.parse(payloadParsed) + + // We don't need to validate this, because the native types ensure it is correct. But this is a TODO + // for the same reason above const protectedHeader = result.protectedHeader if (options?.audience != null) { diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index f50d9a8fca..bb4e9e1aa8 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -69,6 +69,8 @@ function HomeScreenReady({ ) const verified = await key.verifyJwt(jwt) + + console.log(verified) })() }, []) From f4a236285022f8738574454ea959ce3fe41a038b Mon Sep 17 00:00:00 2001 From: Hailey Date: Mon, 15 Apr 2024 00:44:10 -0700 Subject: [PATCH 06/11] rm old files now that we have the skeleton ready --- .../src-old/crypto-subtle.ts | 24 -- .../src-old/crypto-subtle.web.ts | 48 ---- .../src-old/jose-key.ts | 106 ------- .../src-old/native-types.ts | 20 -- .../src-old/rn-crypto-key.ts | 86 ------ .../src-old/rn-crypto-key.web.ts | 78 ----- .../src-old/rn-oauth-client-factory.native.ts | 147 ---------- .../src-old/rn-oauth-client-factory.ts | 153 ---------- .../src-old/rn-oauth-database.native.ts | 214 -------------- .../src-old/rn-oauth-database.ts | 269 ------------------ .../src-old/store.ts | 0 .../src-old/store.web.ts | 0 .../src-old/util.web.ts | 41 --- 13 files changed, 1186 deletions(-) delete mode 100644 modules/expo-bluesky-oauth-client/src-old/crypto-subtle.ts delete mode 100644 modules/expo-bluesky-oauth-client/src-old/crypto-subtle.web.ts delete mode 100644 modules/expo-bluesky-oauth-client/src-old/jose-key.ts delete mode 100644 modules/expo-bluesky-oauth-client/src-old/native-types.ts delete mode 100644 modules/expo-bluesky-oauth-client/src-old/rn-crypto-key.ts delete mode 100644 modules/expo-bluesky-oauth-client/src-old/rn-crypto-key.web.ts delete mode 100644 modules/expo-bluesky-oauth-client/src-old/rn-oauth-client-factory.native.ts delete mode 100644 modules/expo-bluesky-oauth-client/src-old/rn-oauth-client-factory.ts delete mode 100644 modules/expo-bluesky-oauth-client/src-old/rn-oauth-database.native.ts delete mode 100644 modules/expo-bluesky-oauth-client/src-old/rn-oauth-database.ts delete mode 100644 modules/expo-bluesky-oauth-client/src-old/store.ts delete mode 100644 modules/expo-bluesky-oauth-client/src-old/store.web.ts delete mode 100644 modules/expo-bluesky-oauth-client/src-old/util.web.ts diff --git a/modules/expo-bluesky-oauth-client/src-old/crypto-subtle.ts b/modules/expo-bluesky-oauth-client/src-old/crypto-subtle.ts deleted file mode 100644 index c4375015a7..0000000000 --- a/modules/expo-bluesky-oauth-client/src-old/crypto-subtle.ts +++ /dev/null @@ -1,24 +0,0 @@ -import {requireNativeModule} from 'expo-modules-core' -import {CryptoImplementation, Key} from '@atproto/oauth-client' - -import {RnCryptoKey} from './rn-crypto-key' - -// It loads the native module object from the JSI or falls back to -// the bridge module (from NativeModulesProxy) if the remote debugger is on. -const NativeModule = requireNativeModule('ExpoBlueskyOAuthClient') - -export class CryptoSubtle implements CryptoImplementation { - // We won't use the `algos` parameter here, as we will always use `ES256`. - // eslint-disable-next-line @typescript-eslint/no-unused-vars - async createKey(algos: string[] = ['ES256']): Promise { - return await RnCryptoKey.generate(undefined, ['ES256']) - } - - getRandomValues(byteLength: number): Uint8Array { - return NativeModule.getRandomValues(byteLength) - } - - async digest(bytes: Uint8Array): Promise { - return await NativeModule.digest(bytes) - } -} diff --git a/modules/expo-bluesky-oauth-client/src-old/crypto-subtle.web.ts b/modules/expo-bluesky-oauth-client/src-old/crypto-subtle.web.ts deleted file mode 100644 index 14936f65df..0000000000 --- a/modules/expo-bluesky-oauth-client/src-old/crypto-subtle.web.ts +++ /dev/null @@ -1,48 +0,0 @@ -import {WebcryptoKey} from '@atproto/jwk-webcrypto' -import {CryptoImplementation, DigestAlgorithm, Key} from '@atproto/oauth-client' - -// @ts-ignore web only, this silences some warnings -const crypto = global.crypto - -export class CryptoSubtle implements CryptoImplementation { - constructor(_: any) { - if (!crypto?.subtle) { - throw new Error( - 'Crypto with CryptoSubtle is required. If running in a browser, make sure the current page is loaded over HTTPS.', - ) - } - } - - async createKey(algs: string[]): Promise { - return WebcryptoKey.generate(undefined, algs) - } - - getRandomValues(byteLength: number): Uint8Array { - const bytes = new Uint8Array(byteLength) - crypto.getRandomValues(bytes) - return bytes - } - - async digest( - bytes: Uint8Array, - algorithm: DigestAlgorithm, - ): Promise { - const buffer = await crypto.subtle.digest( - digestAlgorithmToSubtle(algorithm), - bytes, - ) - return new Uint8Array(buffer) - } -} - -// @ts-ignore web only type -function digestAlgorithmToSubtle({name}: DigestAlgorithm): AlgorithmIdentifier { - switch (name) { - case 'sha256': - case 'sha384': - case 'sha512': - return `SHA-${name.slice(-3)}` - default: - throw new Error(`Unknown hash algorithm ${name}`) - } -} diff --git a/modules/expo-bluesky-oauth-client/src-old/jose-key.ts b/modules/expo-bluesky-oauth-client/src-old/jose-key.ts deleted file mode 100644 index fcf401e1dc..0000000000 --- a/modules/expo-bluesky-oauth-client/src-old/jose-key.ts +++ /dev/null @@ -1,106 +0,0 @@ -import {requireNativeModule} from 'expo-modules-core' -import {jwkSchema} from '@atproto/jwk' -import {Key} from '@atproto/jwk' -import { - exportJWK, - importJWK, - importPKCS8, - JWK, - KeyLike, - VerifyOptions, -} from 'jose' -import {JwtHeader, JwtPayload} from 'jwt-decode' - -const NativeModule = requireNativeModule('ExpoBlueskyOAuthClient') - -export class JoseKey extends Key { - #keyObj?: KeyLike | Uint8Array - - protected async getKey() { - return (this.#keyObj ||= await importJWK(this.jwk as JWK)) - } - - async createJwt(header: JwtHeader, payload: JwtPayload): Promise { - if (header.kid && header.kid !== this.kid) { - throw new TypeError( - `Invalid "kid" (${header.kid}) used to sign with key "${this.kid}"`, - ) - } - - if (!header.alg || !this.algorithms.includes(header.alg)) { - throw new TypeError( - `Invalid "alg" (${header.alg}) used to sign with key "${this.kid}"`, - ) - } - - return await NativeModule.createJwt( - JSON.stringify(this.privateJwk), - JSON.stringify(header), - JSON.stringify(payload), - ) - } - - async verifyJwt< - P extends VerifyPayload = JwtPayload, - C extends string = string, - >(token: Jwt, options?: VerifyOptions): Promise> { - const result = await NativeModule.verifyJwt( - JSON.stringify(this.publicJwk), - token, - JSON.stringify(options), - ) - return result - // return result as VerifyResult - } - - static async fromImportable( - input: Importable, - kid?: string, - ): Promise { - if (typeof input === 'string') { - // PKCS8 - if (input.startsWith('-----')) { - return this.fromPKCS8(input, kid) - } - - // Jwk (string) - if (input.startsWith('{')) { - return this.fromJWK(input, kid) - } - - throw new TypeError('Invalid input') - } - - if (typeof input === 'object') { - // Jwk - if ('kty' in input || 'alg' in input) { - return this.fromJWK(input, kid) - } - - // KeyLike - return this.fromJWK(await exportJWK(input), kid) - } - - throw new TypeError('Invalid input') - } - - static async fromPKCS8(pem: string, kid?: string): Promise { - const keyLike = await importPKCS8(pem, '', {extractable: true}) - return this.fromJWK(await exportJWK(keyLike), kid) - } - - static async fromJWK( - input: string | Record, - inputKid?: string, - ): Promise { - const jwk = jwkSchema.parse( - typeof input === 'string' ? JSON.parse(input) : input, - ) - - const kid = either(jwk.kid, inputKid) - const alg = jwk.alg - const use = jwk.use || 'sig' - - return new JoseKey({...jwk, kid, alg, use}) - } -} diff --git a/modules/expo-bluesky-oauth-client/src-old/native-types.ts b/modules/expo-bluesky-oauth-client/src-old/native-types.ts deleted file mode 100644 index 2ac1d7fa40..0000000000 --- a/modules/expo-bluesky-oauth-client/src-old/native-types.ts +++ /dev/null @@ -1,20 +0,0 @@ -export interface CryptoKey { - algorithm: { - name: 'ECDSA' - namedCurve: 'P-256' - } - extractable: boolean - type: 'public' | 'private' - usages: ('sign' | 'verify')[] -} - -export interface NativeJWKKey { - crv: 'P-256' - ext: boolean - kty: 'EC' - x: string - y: string - use: 'sig' - alg: 'ES256' - kid: string -} diff --git a/modules/expo-bluesky-oauth-client/src-old/rn-crypto-key.ts b/modules/expo-bluesky-oauth-client/src-old/rn-crypto-key.ts deleted file mode 100644 index d5e4360889..0000000000 --- a/modules/expo-bluesky-oauth-client/src-old/rn-crypto-key.ts +++ /dev/null @@ -1,86 +0,0 @@ -import {requireNativeModule} from 'expo-modules-core' -import {Jwk, jwkSchema} from '@atproto/jwk' - -import {JoseKey} from './jose-key' -import {CryptoKey, NativeJWKKey} from './native-types' - -const NativeModule = requireNativeModule('ExpoBlueskyOAuthClient') - -interface CryptoKeyPair { - privateKey: CryptoKey - publicKey: CryptoKey -} - -interface NativeJWKKeyPair { - privateKey: NativeJWKKey - publicKey: NativeJWKKey -} - -export class RnCryptoKey extends JoseKey { - static async generate( - kid: string | undefined, - _: string[] = ['ES256'], - __ = false, - ) { - const {privateKey, publicKey} = await NativeModule.generateKeyPair(kid) - - const nativeKeyPair = { - privateKey: JSON.parse(privateKey), - publicKey: JSON.parse(publicKey), - } - - return this.fromKeypair(nativeKeyPair.privateKey.kid, nativeKeyPair) - } - - static fromKeypair(kid: string, cryptoKeyPair: NativeJWKKeyPair) { - const use = cryptoKeyPair.privateKey.use ?? 'sig' - const alg = cryptoKeyPair.privateKey.alg ?? 'ES256' - - if (use !== 'sig') { - throw new TypeError('Unsupported JWK use') - } - - const webCryptoKeyPair: CryptoKeyPair = { - privateKey: { - algorithm: { - name: 'ECDSA', - namedCurve: 'P-256', - }, - extractable: true, - type: 'private', - usages: ['sign'], - }, - publicKey: { - algorithm: { - name: 'ECDSA', - namedCurve: 'P-256', - }, - extractable: true, - type: 'public', - usages: ['verify'], - }, - } - - return new RnCryptoKey( - jwkSchema.parse({...cryptoKeyPair.privateKey, use, kid, alg}), - webCryptoKeyPair, - ) - } - - constructor(jwk: Jwk, readonly cryptoKeyPair: CryptoKeyPair) { - super(jwk) - } - - get isPrivate() { - return true - } - - get privateJwk(): Jwk | undefined { - if (super.isPrivate) return this.jwk - throw new Error('Private key is not exportable.') - } - - protected async getKey() { - return this.cryptoKeyPair.privateKey - } -} diff --git a/modules/expo-bluesky-oauth-client/src-old/rn-crypto-key.web.ts b/modules/expo-bluesky-oauth-client/src-old/rn-crypto-key.web.ts deleted file mode 100644 index b9fbd05758..0000000000 --- a/modules/expo-bluesky-oauth-client/src-old/rn-crypto-key.web.ts +++ /dev/null @@ -1,78 +0,0 @@ -import {Jwk, jwkSchema} from '@atproto/jwk' -import {JoseKey} from '@atproto/jwk-jose' - -import {CryptoKey} from './native-types' -import {generateKeyPair, isSignatureKeyPair} from './util.web' - -// @ts-ignore web only, stops warnings for crypto being missing -const crypto = global.crypto - -// Global has this, but this stops the warnings -interface CryptoKeyPair { - privateKey: CryptoKey - publicKey: CryptoKey -} - -export class RNCryptoKey extends JoseKey { - static async generate( - kid: string = crypto.randomUUID(), - allowedAlgos: string[] = ['ES256'], - exportable = false, - ) { - const cryptoKeyPair: CryptoKeyPair = await generateKeyPair( - allowedAlgos, - exportable, - ) - return this.fromKeypair(kid, cryptoKeyPair) - } - - static async fromKeypair( - kid: string, - cryptoKeyPair: CryptoKeyPair, - ): Promise { - if (!isSignatureKeyPair(cryptoKeyPair)) { - throw new TypeError('CryptoKeyPair must be compatible with sign/verify') - } - - // https://datatracker.ietf.org/doc/html/rfc7517 - // > The "use" and "key_ops" JWK members SHOULD NOT be used together; [...] - // > Applications should specify which of these members they use. - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const {key_ops: _, ...jwk} = await crypto.subtle.exportKey( - 'jwk', - cryptoKeyPair.privateKey.extractable - ? cryptoKeyPair.privateKey - : cryptoKeyPair.publicKey, - ) - - const use = jwk.use ?? 'sig' - const alg = jwk.alg ?? 'ES256' - - if (use !== 'sig') { - throw new TypeError('Unsupported JWK use') - } - - return new RNCryptoKey( - jwkSchema.parse({...jwk, use, kid, alg}), - cryptoKeyPair, - ) - } - - constructor(jwk: Jwk, readonly cryptoKeyPair: CryptoKeyPair) { - super(jwk) - } - - get isPrivate() { - return true - } - - get privateJwk(): Jwk | undefined { - if (super.isPrivate) return this.jwk - throw new Error('Private key is not exportable.') - } - - protected async getKey() { - return this.cryptoKeyPair.privateKey - } -} diff --git a/modules/expo-bluesky-oauth-client/src-old/rn-oauth-client-factory.native.ts b/modules/expo-bluesky-oauth-client/src-old/rn-oauth-client-factory.native.ts deleted file mode 100644 index 81713e140c..0000000000 --- a/modules/expo-bluesky-oauth-client/src-old/rn-oauth-client-factory.native.ts +++ /dev/null @@ -1,147 +0,0 @@ -import {Fetch} from '@atproto/fetch' -import {UniversalIdentityResolver} from '@atproto/identity-resolver' -import { - OAuthAuthorizeOptions, - OAuthClientFactory, - OAuthResponseMode, - OAuthResponseType, - Session, -} from '@atproto/oauth-client' -import {OAuthClientMetadata} from '@atproto/oauth-client-metadata' -import IsomorphicOAuthServerMetadataResolver from '@atproto/oauth-server-metadata-resolver' - -import {CryptoSubtle} from './crypto-subtle' -import {DatabaseStore, RNOAuthDatabase} from './rn-oauth-database' - -export type RNOAuthClientOptions = { - responseMode?: OAuthResponseMode - responseType?: OAuthResponseType - clientMetadata: OAuthClientMetadata - fetch?: Fetch - crypto?: Crypto -} - -const POPUP_KEY_PREFIX = '@@oauth-popup-callback:' - -export class RNOAuthClientFactory extends OAuthClientFactory { - readonly sessionStore: DatabaseStore - - constructor({ - clientMetadata, - // "fragment" is safer as it is not sent to the server - responseMode = 'fragment', - responseType, - crypto = {subtle: CryptoSubtle}, - fetch = globalThis.fetch, - }: RNOAuthClientOptions) { - const database = new RNOAuthDatabase() - - super({ - clientMetadata, - responseMode, - responseType, - fetch, - cryptoImplementation: new CryptoSubtle(crypto), - sessionStore: database.getSessionStore(), - stateStore: database.getStateStore(), - metadataResolver: new IsomorphicOAuthServerMetadataResolver({ - fetch, - cache: database.getMetadataCache(), - }), - identityResolver: UniversalIdentityResolver.from({ - fetch, - didCache: database.getDidCache(), - handleCache: database.getHandleCache(), - }), - dpopNonceCache: database.getDpopNonceCache(), - }) - - this.sessionStore = database.getSessionStore() - } - - async restoreAll() { - const sessionIds = await this.sessionStore.getKeys() - return Object.fromEntries( - await Promise.all( - sessionIds.map( - async sessionId => - [sessionId, await this.restore(sessionId, false)] as const, - ), - ), - ) - } - - async init(sessionId?: string, forceRefresh = false) { - const signInResult = await this.signInCallback() - if (signInResult) { - return signInResult - } else if (sessionId) { - const client = await this.restore(sessionId, forceRefresh) - return {client} - } else { - // TODO: we could restore any session from the store ? - } - } - - async signIn(input: string, options?: OAuthAuthorizeOptions) { - return await this.authorize(input, options) - } - - async signInCallback() { - const redirectUri = new URL(this.clientMetadata.redirect_uris[0]) - if (location.pathname !== redirectUri.pathname) return null - - const params = - this.responseMode === 'query' - ? new URLSearchParams(location.search) - : new URLSearchParams(location.hash.slice(1)) - - // Only if the query string contains oauth callback params - if ( - !params.has('iss') || - !params.has('state') || - !(params.has('code') || params.has('error')) - ) { - return null - } - - // Replace the current history entry without the query string (this will - // prevent this 'if' branch to run again if the user refreshes the page) - history.replaceState(null, '', location.pathname) - - return this.callback(params) - .then(async result => { - if (result.state?.startsWith(POPUP_KEY_PREFIX)) { - const stateKey = result.state.slice(POPUP_KEY_PREFIX.length) - - await this.popupStore.set(stateKey, { - status: 'fulfilled', - value: result.client.sessionId, - }) - - window.close() // continued in signInPopup - throw new Error('Login complete, please close the popup window.') - } - - return result - }) - .catch(async err => { - // TODO: Throw a proper error from parent class to actually detect - // oauth authorization errors - const state = typeof (err as any)?.state - if (typeof state === 'string' && state?.startsWith(POPUP_KEY_PREFIX)) { - const stateKey = state.slice(POPUP_KEY_PREFIX.length) - - await this.popupStore.set(stateKey, { - status: 'rejected', - reason: err, - }) - - window.close() // continued in signInPopup - throw new Error('Login complete, please close the popup window.') - } - - throw err - }) - } -} diff --git a/modules/expo-bluesky-oauth-client/src-old/rn-oauth-client-factory.ts b/modules/expo-bluesky-oauth-client/src-old/rn-oauth-client-factory.ts deleted file mode 100644 index a8e2dc179e..0000000000 --- a/modules/expo-bluesky-oauth-client/src-old/rn-oauth-client-factory.ts +++ /dev/null @@ -1,153 +0,0 @@ -import {Fetch} from '@atproto/fetch' -import {UniversalIdentityResolver} from '@atproto/identity-resolver' -import { - OAuthAuthorizeOptions, - OAuthClientFactory, - OAuthResponseMode, - OAuthResponseType, - Session, -} from '@atproto/oauth-client' -import {OAuthClientMetadata} from '@atproto/oauth-client-metadata' -import IsomorphicOAuthServerMetadataResolver from '@atproto/oauth-server-metadata-resolver' - -import {CryptoSubtle} from './crypto-subtle' -import { - DatabaseStore, - PopupStateData, - RNOAuthDatabase, -} from './rn-oauth-database' - -export type RNOAuthClientOptions = { - responseMode?: OAuthResponseMode - responseType?: OAuthResponseType - clientMetadata: OAuthClientMetadata - fetch?: Fetch - crypto?: Crypto -} - -const POPUP_KEY_PREFIX = '@@oauth-popup-callback:' - -export class RNOAuthClientFactory extends OAuthClientFactory { - readonly popupStore: DatabaseStore - readonly sessionStore: DatabaseStore - - constructor({ - clientMetadata, - // "fragment" is safer as it is not sent to the server - responseMode = 'fragment', - responseType, - crypto = globalThis.crypto, - fetch = globalThis.fetch, - }: RNOAuthClientOptions) { - const database = new RNOAuthDatabase() - - super({ - clientMetadata, - responseMode, - responseType, - fetch, - cryptoImplementation: new CryptoSubtle(crypto), - sessionStore: database.getSessionStore(), - stateStore: database.getStateStore(), - metadataResolver: new IsomorphicOAuthServerMetadataResolver({ - fetch, - cache: database.getMetadataCache(), - }), - identityResolver: UniversalIdentityResolver.from({ - fetch, - didCache: database.getDidCache(), - handleCache: database.getHandleCache(), - }), - dpopNonceCache: database.getDpopNonceCache(), - }) - - this.sessionStore = database.getSessionStore() - this.popupStore = database.getPopupStore() - } - - async restoreAll() { - const sessionIds = await this.sessionStore.getKeys() - return Object.fromEntries( - await Promise.all( - sessionIds.map( - async sessionId => - [sessionId, await this.restore(sessionId, false)] as const, - ), - ), - ) - } - - async init(sessionId?: string, forceRefresh = false) { - const signInResult = await this.signInCallback() - if (signInResult) { - return signInResult - } else if (sessionId) { - const client = await this.restore(sessionId, forceRefresh) - return {client} - } else { - // TODO: we could restore any session from the store ? - } - } - - async signIn(input: string, options?: OAuthAuthorizeOptions) { - return await this.authorize(input, options) - } - - async signInCallback() { - const redirectUri = new URL(this.clientMetadata.redirect_uris[0]) - if (location.pathname !== redirectUri.pathname) return null - - const params = - this.responseMode === 'query' - ? new URLSearchParams(location.search) - : new URLSearchParams(location.hash.slice(1)) - - // Only if the query string contains oauth callback params - if ( - !params.has('iss') || - !params.has('state') || - !(params.has('code') || params.has('error')) - ) { - return null - } - - // Replace the current history entry without the query string (this will - // prevent this 'if' branch to run again if the user refreshes the page) - history.replaceState(null, '', location.pathname) - - return this.callback(params) - .then(async result => { - if (result.state?.startsWith(POPUP_KEY_PREFIX)) { - const stateKey = result.state.slice(POPUP_KEY_PREFIX.length) - - await this.popupStore.set(stateKey, { - status: 'fulfilled', - value: result.client.sessionId, - }) - - window.close() // continued in signInPopup - throw new Error('Login complete, please close the popup window.') - } - - return result - }) - .catch(async err => { - // TODO: Throw a proper error from parent class to actually detect - // oauth authorization errors - const state = typeof (err as any)?.state - if (typeof state === 'string' && state?.startsWith(POPUP_KEY_PREFIX)) { - const stateKey = state.slice(POPUP_KEY_PREFIX.length) - - await this.popupStore.set(stateKey, { - status: 'rejected', - reason: err, - }) - - window.close() // continued in signInPopup - throw new Error('Login complete, please close the popup window.') - } - - throw err - }) - } -} diff --git a/modules/expo-bluesky-oauth-client/src-old/rn-oauth-database.native.ts b/modules/expo-bluesky-oauth-client/src-old/rn-oauth-database.native.ts deleted file mode 100644 index 96a7ae74d7..0000000000 --- a/modules/expo-bluesky-oauth-client/src-old/rn-oauth-database.native.ts +++ /dev/null @@ -1,214 +0,0 @@ -import {GenericStore, Value} from '@atproto/caching' -import {DidDocument} from '@atproto/did' -import {ResolvedHandle} from '@atproto/handle-resolver' -import {Key} from '@atproto/jwk' -import {WebcryptoKey} from '@atproto/jwk-webcrypto' -import {InternalStateData, Session, TokenSet} from '@atproto/oauth-client' -import {OAuthServerMetadata} from '@atproto/oauth-server-metadata' -import Storage from '@react-native-async-storage/async-storage' - -type Item = { - value: string - expiresAt: null | Date -} - -type EncodedKey = { - keyId: string - keyPair: CryptoKeyPair -} - -function encodeKey(key: Key): EncodedKey { - if (!(key instanceof WebcryptoKey) || !key.kid) { - throw new Error('Invalid key object') - } - return { - keyId: key.kid, - keyPair: key.cryptoKeyPair, - } -} - -async function decodeKey(encoded: EncodedKey): Promise { - return WebcryptoKey.fromKeypair(encoded.keyId, encoded.keyPair) -} - -export type Schema = { - state: Item<{ - dpopKey: EncodedKey - - iss: string - nonce: string - verifier?: string - appState?: string - }> - session: Item<{ - dpopKey: EncodedKey - - tokenSet: TokenSet - }> - - didCache: Item - dpopNonceCache: Item - handleCache: Item - metadataCache: Item -} - -export type DatabaseStore = GenericStore & { - getKeys: () => Promise -} - -const STORES = [ - 'state', - 'session', - - 'didCache', - 'dpopNonceCache', - 'handleCache', - 'metadataCache', -] as const - -export class RNOAuthDatabase { - async delete(key: string) { - await Storage.removeItem(key) - } - - protected createStore( - dbName: N, - { - encode, - decode, - maxAge, - }: { - encode: (value: V) => Schema[N]['value'] | PromiseLike - decode: (encoded: Schema[N]['value']) => V | PromiseLike - maxAge?: number - }, - ): DatabaseStore { - return { - get: async key => { - const itemJson = await Storage.getItem(`${dbName}.${key}`) - if (itemJson == null) return undefined - - const item = JSON.parse(itemJson) as Schema[N] - - // Too old, proactively delete - if (item.expiresAt != null && item.expiresAt < new Date()) { - await this.delete(`${dbName}.${key}`) - return undefined - } - - // Item found and valid. Decode - return decode(item.value) - }, - - getKeys: async () => { - const keys = await Storage.getAllKeys() - return keys.filter(key => key.startsWith(`${dbName}.`)) as string[] - }, - - set: async (key, value) => { - const item = { - value: await encode(value), - expiresAt: maxAge == null ? null : new Date(Date.now() + maxAge), - } as Schema[N] - - await Storage.setItem(`${dbName}.${key}`, JSON.stringify(item)) - }, - - del: async key => { - await this.delete(`${dbName}.${key}`) - }, - } - } - - getSessionStore(): DatabaseStore { - return this.createStore('session', { - encode: ({dpopKey, ...session}) => ({ - ...session, - dpopKey: encodeKey(dpopKey), - }), - decode: async ({dpopKey, ...encoded}) => ({ - ...encoded, - dpopKey: await decodeKey(dpopKey), - }), - }) - } - - getStateStore(): DatabaseStore { - return this.createStore('state', { - encode: ({dpopKey, ...session}) => ({ - ...session, - dpopKey: encodeKey(dpopKey), - }), - decode: async ({dpopKey, ...encoded}) => ({ - ...encoded, - dpopKey: await decodeKey(dpopKey), - }), - }) - } - - getDpopNonceCache(): undefined | DatabaseStore { - return this.createStore('dpopNonceCache', { - // No time limit. It is better to try with a potentially outdated nonce - // and potentially succeed rather than make requests without a nonce and - // 100% fail. - encode: value => value, - decode: encoded => encoded, - }) - } - - getDidCache(): undefined | DatabaseStore { - return this.createStore('didCache', { - maxAge: 60e3, - encode: value => value, - decode: encoded => encoded, - }) - } - - getHandleCache(): undefined | DatabaseStore { - return this.createStore('handleCache', { - maxAge: 60e3, - encode: value => value, - decode: encoded => encoded, - }) - } - - getMetadataCache(): undefined | DatabaseStore { - return this.createStore('metadataCache', { - maxAge: 60e3, - encode: value => value, - decode: encoded => encoded, - }) - } - - async cleanup() { - await Promise.all( - STORES.map( - async storeName => - [ - storeName, - await tx - .objectStore(storeName) - .index('expiresAt') - .getAllKeys(query), - ] as const, - ), - ) - - const storesWithInvalidKeys = res.filter(r => r[1].length > 0) - - await db.transaction( - storesWithInvalidKeys.map(r => r[0]), - 'readwrite', - tx => - Promise.all( - storesWithInvalidKeys.map(async ([name, keys]) => - tx.objectStore(name).delete(keys), - ), - ), - ) - } - - async [Symbol.asyncDispose]() { - await this.cleanup() - } -} diff --git a/modules/expo-bluesky-oauth-client/src-old/rn-oauth-database.ts b/modules/expo-bluesky-oauth-client/src-old/rn-oauth-database.ts deleted file mode 100644 index 8da68b8138..0000000000 --- a/modules/expo-bluesky-oauth-client/src-old/rn-oauth-database.ts +++ /dev/null @@ -1,269 +0,0 @@ -import {GenericStore, Value} from '@atproto/caching' -import {DidDocument} from '@atproto/did' -import {ResolvedHandle} from '@atproto/handle-resolver' -import {DB, DBObjectStore} from '@atproto/indexed-db' -import {Key} from '@atproto/jwk' -import {WebcryptoKey} from '@atproto/jwk-webcrypto' -import {InternalStateData, Session, TokenSet} from '@atproto/oauth-client' -import {OAuthServerMetadata} from '@atproto/oauth-server-metadata' - -type Item = { - value: V - expiresAt: null | Date -} - -type EncodedKey = { - keyId: string - keyPair: CryptoKeyPair -} - -function encodeKey(key: Key): EncodedKey { - if (!(key instanceof WebcryptoKey) || !key.kid) { - throw new Error('Invalid key object') - } - return { - keyId: key.kid, - keyPair: key.cryptoKeyPair, - } -} - -async function decodeKey(encoded: EncodedKey): Promise { - return WebcryptoKey.fromKeypair(encoded.keyId, encoded.keyPair) -} - -export type PopupStateData = - | PromiseRejectedResult - | PromiseFulfilledResult - -export type Schema = { - popup: Item - state: Item<{ - dpopKey: EncodedKey - - iss: string - nonce: string - verifier?: string - appState?: string - }> - session: Item<{ - dpopKey: EncodedKey - - tokenSet: TokenSet - }> - - didCache: Item - dpopNonceCache: Item - handleCache: Item - metadataCache: Item -} - -export type DatabaseStore = GenericStore & { - getKeys: () => Promise -} - -const STORES = [ - 'popup', - 'state', - 'session', - - 'didCache', - 'dpopNonceCache', - 'handleCache', - 'metadataCache', -] as const - -export class RNOAuthDatabase { - #dbPromise = DB.open( - '@atproto-oauth-client', - [ - db => { - for (const name of STORES) { - const store = db.createObjectStore(name) - store.createIndex('expiresAt', 'expiresAt', {unique: false}) - } - }, - ], - {durability: 'strict'}, - ) - - protected async run( - storeName: N, - mode: 'readonly' | 'readwrite', - fn: (s: DBObjectStore) => R | Promise, - ): Promise { - const db = await this.#dbPromise - return await db.transaction([storeName], mode, tx => - fn(tx.objectStore(storeName)), - ) - } - - protected createStore( - name: N, - { - encode, - decode, - maxAge, - }: { - encode: (value: V) => Schema[N]['value'] | PromiseLike - decode: (encoded: Schema[N]['value']) => V | PromiseLike - maxAge?: number - }, - ): DatabaseStore { - return { - get: async key => { - // Find item in store - const item = await this.run(name, 'readonly', dbStore => { - return dbStore.get(key) - }) - - // Not found - if (item === undefined) return undefined - - // Too old, proactively delete - if (item.expiresAt != null && item.expiresAt < new Date()) { - await this.run(name, 'readwrite', dbStore => { - return dbStore.delete(key) - }) - return undefined - } - - // Item found and valid. Decode - return decode(item.value) - }, - - getKeys: async () => { - const keys = await this.run(name, 'readonly', dbStore => { - return dbStore.getAllKeys() - }) - return keys.filter(key => typeof key === 'string') as string[] - }, - - set: async (key, value) => { - // Create encoded item record - const item = { - value: await encode(value), - expiresAt: maxAge == null ? null : new Date(Date.now() + maxAge), - } as Schema[N] - - // Store item record - await this.run(name, 'readwrite', dbStore => { - return dbStore.put(item, key) - }) - }, - - del: async key => { - // Delete - await this.run(name, 'readwrite', dbStore => { - return dbStore.delete(key) - }) - }, - } - } - - getSessionStore(): DatabaseStore { - return this.createStore('session', { - encode: ({dpopKey, ...session}) => ({ - ...session, - dpopKey: encodeKey(dpopKey), - }), - decode: async ({dpopKey, ...encoded}) => ({ - ...encoded, - dpopKey: await decodeKey(dpopKey), - }), - }) - } - - getStateStore(): DatabaseStore { - return this.createStore('state', { - encode: ({dpopKey, ...session}) => ({ - ...session, - dpopKey: encodeKey(dpopKey), - }), - decode: async ({dpopKey, ...encoded}) => ({ - ...encoded, - dpopKey: await decodeKey(dpopKey), - }), - }) - } - - getPopupStore(): DatabaseStore { - return this.createStore('popup', { - encode: value => value, - decode: encoded => encoded, - }) - } - - getDpopNonceCache(): undefined | DatabaseStore { - return this.createStore('dpopNonceCache', { - // No time limit. It is better to try with a potentially outdated nonce - // and potentially succeed rather than make requests without a nonce and - // 100% fail. - encode: value => value, - decode: encoded => encoded, - }) - } - - getDidCache(): undefined | DatabaseStore { - return this.createStore('didCache', { - maxAge: 60e3, - encode: value => value, - decode: encoded => encoded, - }) - } - - getHandleCache(): undefined | DatabaseStore { - return this.createStore('handleCache', { - maxAge: 60e3, - encode: value => value, - decode: encoded => encoded, - }) - } - - getMetadataCache(): undefined | DatabaseStore { - return this.createStore('metadataCache', { - maxAge: 60e3, - encode: value => value, - decode: encoded => encoded, - }) - } - - async cleanup() { - const db = await this.#dbPromise - const query = IDBKeyRange.lowerBound(new Date()) - const res = await db.transaction(STORES, 'readonly', tx => - Promise.all( - STORES.map( - async storeName => - [ - storeName, - await tx - .objectStore(storeName) - .index('expiresAt') - .getAllKeys(query), - ] as const, - ), - ), - ) - - const storesWithInvalidKeys = res.filter(r => r[1].length > 0) - - await db.transaction( - storesWithInvalidKeys.map(r => r[0]), - 'readwrite', - tx => - Promise.all( - storesWithInvalidKeys.map(async ([name, keys]) => - tx.objectStore(name).delete(keys), - ), - ), - ) - } - - async [Symbol.asyncDispose]() { - // TODO: call cleanup at a constant interval ? - await this.cleanup() - - const db = await this.#dbPromise - await (db[Symbol.asyncDispose] || db[Symbol.dispose]).call(db) - } -} diff --git a/modules/expo-bluesky-oauth-client/src-old/store.ts b/modules/expo-bluesky-oauth-client/src-old/store.ts deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/modules/expo-bluesky-oauth-client/src-old/store.web.ts b/modules/expo-bluesky-oauth-client/src-old/store.web.ts deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/modules/expo-bluesky-oauth-client/src-old/util.web.ts b/modules/expo-bluesky-oauth-client/src-old/util.web.ts deleted file mode 100644 index 654365aae5..0000000000 --- a/modules/expo-bluesky-oauth-client/src-old/util.web.ts +++ /dev/null @@ -1,41 +0,0 @@ -// @ts-ignore web only, this silences errors throughout the whole file for crypto being missing -const crypto = global.crypto - -export async function generateKeyPair(algs: string[], extractable = false) { - const errors: unknown[] = [] - try { - return await crypto.subtle.generateKey( - { - name: 'ECDSA', - namedCurve: `P-256`, - }, - extractable, - ['sign', 'verify'], - ) - } catch (err) { - errors.push(err) - } - - console.log(errors) - throw new AggregateError(errors, 'Failed to generate keypair') -} - -export function isSignatureKeyPair( - v: unknown, - extractable?: boolean, -): v is CryptoKeyPair { - return ( - typeof v === 'object' && - v !== null && - 'privateKey' in v && - v.privateKey instanceof CryptoKey && - v.privateKey.type === 'private' && - (extractable == null || v.privateKey.extractable === extractable) && - v.privateKey.usages.includes('sign') && - 'publicKey' in v && - v.publicKey instanceof CryptoKey && - v.publicKey.type === 'public' && - v.publicKey.extractable === true && - v.publicKey.usages.includes('verify') - ) -} From a07d29114364e48ac6ca0883c882aa15f62f1e06 Mon Sep 17 00:00:00 2001 From: Hailey Date: Mon, 15 Apr 2024 00:57:12 -0700 Subject: [PATCH 07/11] add structs --- .../android/src/main/java/JWK.kt | 34 ++++ .../expo/modules/blueskyoauthclient/JWT.kt | 158 ++++++++++++++++++ 2 files changed, 192 insertions(+) create mode 100644 modules/expo-bluesky-oauth-client/android/src/main/java/JWK.kt create mode 100644 modules/expo-bluesky-oauth-client/android/src/main/java/expo/modules/blueskyoauthclient/JWT.kt diff --git a/modules/expo-bluesky-oauth-client/android/src/main/java/JWK.kt b/modules/expo-bluesky-oauth-client/android/src/main/java/JWK.kt new file mode 100644 index 0000000000..5959ea8c00 --- /dev/null +++ b/modules/expo-bluesky-oauth-client/android/src/main/java/JWK.kt @@ -0,0 +1,34 @@ +package expo.modules.blueskyoauthclient + +import expo.modules.kotlin.records.Record +import expo.modules.kotlin.records.Field + +class JWK : Record { + @Field + var alg: String = "" + @Field + var kty: String = "" + @Field + var crv: String? = null + @Field + var x: String? = null + @Field + var y: String? = null + @Field + var e: String? = null + @Field + var n: String? = null + @Field + var d: String? = null + @Field + var use: String? = null + @Field + var kid: String? = null +} + +class JWKPair : Record { + @Field + val privateKey: JWK = JWK() + @Field + val publicKey: JWK = JWK() +} \ No newline at end of file diff --git a/modules/expo-bluesky-oauth-client/android/src/main/java/expo/modules/blueskyoauthclient/JWT.kt b/modules/expo-bluesky-oauth-client/android/src/main/java/expo/modules/blueskyoauthclient/JWT.kt new file mode 100644 index 0000000000..235ed07dba --- /dev/null +++ b/modules/expo-bluesky-oauth-client/android/src/main/java/expo/modules/blueskyoauthclient/JWT.kt @@ -0,0 +1,158 @@ +package expo.modules.blueskyoauthclient + +import expo.modules.kotlin.records.Record +import expo.modules.kotlin.records.Field + +class JWTHeader : Record { + @Field + val alg: String = "" + @Field + var jku: String? = null + @Field + var jwk: JWK = JWK() + @Field + var kid: String? = null + @Field + var typ: String? = null + @Field + var cty: String? = null + @Field + var crit: String? = null +} + +class JWTPayload : Record { + @Field + var iss: String? = null + @Field + var aud: String? = null + @Field + var sub: String? = null + @Field + var exp: Int? = null + @Field + var nbr: Int? = null + @Field + var iat: Int? = null + @Field + var jti: String? = null + @Field + var htm: String? = null + @Field + var htu: String? = null + @Field + var ath: String? = null + @Field + var acr: String? = null + @Field + var azp: String? = null + @Field + var amr: String? = null + @Field + var cnf: JWTPayloadCNF? = null + @Field + var client_id: String? = null + @Field + var scope: String? = null + @Field + var nonce: String? = null + @Field + var at_hash: String? = null + @Field + var c_hash: String? = null + @Field + var s_hash: String? = null + @Field + var auth_time: Int? = null + @Field + var name: String? = null + @Field + var family_name: String? = null + @Field + var given_name: String? = null + @Field + var middle_name: String? = null + @Field + var nickname: String? = null + @Field + var preferred_username: String? = null + @Field + var gender: String? = null + @Field + var picture: String? = null + @Field + var profile: String? = null + @Field + var website: String? = null + @Field + var birthdate: String? = null + @Field + var zoneinfo: String? = null + @Field + var locale: String? = null + @Field + var updated_at: Int? = null + @Field + var email: String? = null + @Field + var email_verified: String? = null + @Field + var phone_number: String? = null + @Field + var phone_number_verified: Boolean? = null + @Field + var address: JWTPayloadAddress? = null + @Field + var authorization_details: JWTPayloadAuthorizationDetails? = null +} + +class JWTPayloadCNF : Record { + @Field + var kid: String? = null + @Field + var jwk: JWK? = null + @Field + var jwe: String? = null + @Field + var jku: String? = null + @Field + var jkt: String? = null + @Field + var osc: String? = null +} + +class JWTPayloadAddress : Record { + @Field + var formatted: String? = null + @Field + var street_address: String? = null + @Field + var locality: String? = null + @Field + var region: String? = null + @Field + var postal_code: String? = null + @Field + var country: String? = null +} + +class JWTPayloadAuthorizationDetails : Record { + @Field + var type: String = "" + @Field + var locations: Array? = null + @Field + var actions: Array? = null + @Field + var datatypes: Array? = null + @Field + var identifier: String? = null + @Field + var privileges: Array? = null +} + +class JWTVerifyResponse : Record { + @Field + var protectedHeader: JWTHeader = JWTHeader() + @Field + var payload: String = "" +} From adab484fb756810f47cd07a9c4aa61222206494e Mon Sep 17 00:00:00 2001 From: Hailey Date: Mon, 15 Apr 2024 01:04:51 -0700 Subject: [PATCH 08/11] oops --- .../android/src/main/java/JWK.kt | 41 ++-- .../expo/modules/blueskyoauthclient/JWT.kt | 214 ++++++------------ 2 files changed, 79 insertions(+), 176 deletions(-) diff --git a/modules/expo-bluesky-oauth-client/android/src/main/java/JWK.kt b/modules/expo-bluesky-oauth-client/android/src/main/java/JWK.kt index 5959ea8c00..64835b370b 100644 --- a/modules/expo-bluesky-oauth-client/android/src/main/java/JWK.kt +++ b/modules/expo-bluesky-oauth-client/android/src/main/java/JWK.kt @@ -3,32 +3,17 @@ package expo.modules.blueskyoauthclient import expo.modules.kotlin.records.Record import expo.modules.kotlin.records.Field -class JWK : Record { - @Field - var alg: String = "" - @Field - var kty: String = "" - @Field - var crv: String? = null - @Field - var x: String? = null - @Field - var y: String? = null - @Field - var e: String? = null - @Field - var n: String? = null - @Field - var d: String? = null - @Field - var use: String? = null - @Field - var kid: String? = null -} +class JWK( + @Field var alg: String = "", + @Field var kty: String = "", + @Field var crv: String? = null, + @Field var x: String? = null, + @Field var y: String? = null, + @Field var e: String? = null, + @Field var n: String? = null, + @Field var d: String? = null, + @Field var use: String? = null, + @Field var kid: String? = null +) : Record -class JWKPair : Record { - @Field - val privateKey: JWK = JWK() - @Field - val publicKey: JWK = JWK() -} \ No newline at end of file +class JWKPair(@Field val privateKey: JWK, @Field val publicKey: JWK) : Record \ No newline at end of file diff --git a/modules/expo-bluesky-oauth-client/android/src/main/java/expo/modules/blueskyoauthclient/JWT.kt b/modules/expo-bluesky-oauth-client/android/src/main/java/expo/modules/blueskyoauthclient/JWT.kt index 235ed07dba..a36df26a3a 100644 --- a/modules/expo-bluesky-oauth-client/android/src/main/java/expo/modules/blueskyoauthclient/JWT.kt +++ b/modules/expo-bluesky-oauth-client/android/src/main/java/expo/modules/blueskyoauthclient/JWT.kt @@ -3,156 +3,74 @@ package expo.modules.blueskyoauthclient import expo.modules.kotlin.records.Record import expo.modules.kotlin.records.Field -class JWTHeader : Record { - @Field - val alg: String = "" - @Field - var jku: String? = null - @Field - var jwk: JWK = JWK() - @Field - var kid: String? = null - @Field - var typ: String? = null - @Field - var cty: String? = null - @Field - var crit: String? = null -} +class JWTHeader( + @Field var alg: String = "", + @Field var jku: String? = null, + @Field var jwk: JWK = JWK(), + @Field var kid: String? = null, + @Field var typ: String? = null, + @Field var cty: String? = null, + @Field var crit: String? = null +) : Record -class JWTPayload : Record { - @Field - var iss: String? = null - @Field - var aud: String? = null - @Field - var sub: String? = null - @Field - var exp: Int? = null - @Field - var nbr: Int? = null - @Field - var iat: Int? = null - @Field - var jti: String? = null - @Field - var htm: String? = null - @Field - var htu: String? = null - @Field - var ath: String? = null - @Field - var acr: String? = null - @Field - var azp: String? = null - @Field - var amr: String? = null - @Field - var cnf: JWTPayloadCNF? = null - @Field - var client_id: String? = null - @Field - var scope: String? = null - @Field - var nonce: String? = null - @Field - var at_hash: String? = null - @Field - var c_hash: String? = null - @Field - var s_hash: String? = null - @Field - var auth_time: Int? = null - @Field - var name: String? = null - @Field - var family_name: String? = null - @Field - var given_name: String? = null - @Field - var middle_name: String? = null - @Field - var nickname: String? = null - @Field - var preferred_username: String? = null - @Field - var gender: String? = null - @Field - var picture: String? = null - @Field - var profile: String? = null - @Field - var website: String? = null - @Field - var birthdate: String? = null - @Field - var zoneinfo: String? = null - @Field - var locale: String? = null - @Field - var updated_at: Int? = null - @Field - var email: String? = null - @Field - var email_verified: String? = null - @Field - var phone_number: String? = null - @Field - var phone_number_verified: Boolean? = null - @Field - var address: JWTPayloadAddress? = null - @Field - var authorization_details: JWTPayloadAuthorizationDetails? = null -} +class JWTPayload( + @Field var iss: String? = null, + @Field var aud: String? = null, + @Field var sub: String? = null, + @Field var exp: Int? = null, + @Field var nbr: Int? = null, + @Field var iat: Int? = null, + @Field var jti: String? = null, + @Field var htm: String? = null, + @Field var htu: String? = null, + @Field var ath: String? = null, + @Field var acr: String? = null, + @Field var azp: String? = null, + @Field var amr: String? = null, + @Field var cnf: JWTPayloadCNF? = null, + @Field var client_id: String? = null, + @Field var scope: String? = null, + @Field var nonce: String? = null, + @Field var at_hash: String? = null, + @Field var c_hash: String? = null, + @Field var s_hash: String? = null, + @Field var auth_time: Int? = null, + @Field var name: String? = null, + @Field var family_name: String? = null, + @Field var given_name: String? = null, + @Field var middle_name: String? = null, + @Field var nickname: String? = null, + @Field var preferred_username: String? = null, +) : Record -class JWTPayloadCNF : Record { - @Field - var kid: String? = null - @Field - var jwk: JWK? = null - @Field - var jwe: String? = null - @Field - var jku: String? = null - @Field - var jkt: String? = null - @Field - var osc: String? = null -} +class JWTPayloadCNF( + @Field var jwk: JWK? = null, + @Field var jwe: String? = null, + @Field var jku: String? = null, + @Field var jkt: String? = null, + @Field var osc: String? = null +) : Record -class JWTPayloadAddress : Record { - @Field - var formatted: String? = null - @Field - var street_address: String? = null - @Field - var locality: String? = null - @Field - var region: String? = null - @Field - var postal_code: String? = null - @Field - var country: String? = null -} +class JWTPayloadAddress( + @Field var formatted: String? = null, + @Field var street_address: String? = null, + @Field var locality: String? = null, + @Field var region: String? = null, + @Field var postal_code: String? = null, + @Field var country: String? = null +) : Record -class JWTPayloadAuthorizationDetails : Record { - @Field - var type: String = "" - @Field - var locations: Array? = null - @Field - var actions: Array? = null - @Field - var datatypes: Array? = null - @Field - var identifier: String? = null - @Field - var privileges: Array? = null -} +class JWTPayloadAuthorizationDetails( + @Field var type: String? = null, + @Field var locations: Array? = null, + @Field var actions: Array? = null, + @Field var datatypes: Array? = null, + @Field var identifier: String? = null, + @Field var privileges: Array? = null +) : Record -class JWTVerifyResponse : Record { - @Field - var protectedHeader: JWTHeader = JWTHeader() - @Field - var payload: String = "" -} +class JWTVerifyResponse( + @Field var header: JWTHeader = JWTHeader(), + @Field var payload: JWTPayload = JWTPayload(), + @Field var signature: String = "" +) : Record \ No newline at end of file From 37f584021469061e37794e5616a2e620d5bccbd6 Mon Sep 17 00:00:00 2001 From: Hailey Date: Mon, 15 Apr 2024 01:07:36 -0700 Subject: [PATCH 09/11] update genkeypair --- .../modules/blueskyoauthclient/CryptoUtil.kt | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/modules/expo-bluesky-oauth-client/android/src/main/java/expo/modules/blueskyoauthclient/CryptoUtil.kt b/modules/expo-bluesky-oauth-client/android/src/main/java/expo/modules/blueskyoauthclient/CryptoUtil.kt index 2abce43009..1c8ea0eaa5 100644 --- a/modules/expo-bluesky-oauth-client/android/src/main/java/expo/modules/blueskyoauthclient/CryptoUtil.kt +++ b/modules/expo-bluesky-oauth-client/android/src/main/java/expo/modules/blueskyoauthclient/CryptoUtil.kt @@ -22,8 +22,8 @@ class CryptoUtil { return random } - fun generateKeyPair(keyId: String?): Pair { - val keyIdString = keyId ?: UUID.randomUUID().toString() + fun generateKeyPair(): Any { + val keyIdString = UUID.randomUUID().toString() val keyPairGen = KeyPairGenerator.getInstance("EC") keyPairGen.initialize(Curve.P_256.toECParameterSpec()) @@ -44,9 +44,26 @@ class CryptoUtil { .algorithm(Algorithm.parse("ES256")) .build() - return Pair( - publicJwk.toString(), - privateJwk.toString() + + return JWKPair( + JWK( + alg = privateJwk.algorithm.toString(), + kty = privateJwk.keyType.toString(), + crv = privateJwk.curve.toString(), + x = privateJwk.x.toString(), + y = privateJwk.y.toString(), + use = privateJwk.keyUse.toString(), + kid = privateJwk.keyID + ), + JWK( + alg = publicJwk.algorithm.toString(), + kty = publicJwk.keyType.toString(), + crv = publicJwk.curve.toString(), + x = publicJwk.x.toString(), + y = publicJwk.y.toString(), + use = publicJwk.keyUse.toString(), + kid = publicJwk.keyID + ) ) } } From caddbeb227b35092e3bbd5550815bc29beefe0b7 Mon Sep 17 00:00:00 2001 From: Hailey Date: Mon, 15 Apr 2024 02:03:10 -0700 Subject: [PATCH 10/11] android impl --- .../android/src/main/java/JWK.kt | 19 --- .../modules/blueskyoauthclient/CryptoUtil.kt | 1 + .../ExpoBlueskyOAuthClientModule.kt | 21 ++-- .../expo/modules/blueskyoauthclient/JWK.kt | 34 +++++ .../expo/modules/blueskyoauthclient/JWT.kt | 117 ++++++++++++++++-- .../modules/blueskyoauthclient/JWTUtil.kt | 63 +++++++--- .../expo-bluesky-oauth-client/ios/JWT.swift | 2 +- src/view/screens/Home.tsx | 3 + 8 files changed, 206 insertions(+), 54 deletions(-) delete mode 100644 modules/expo-bluesky-oauth-client/android/src/main/java/JWK.kt create mode 100644 modules/expo-bluesky-oauth-client/android/src/main/java/expo/modules/blueskyoauthclient/JWK.kt diff --git a/modules/expo-bluesky-oauth-client/android/src/main/java/JWK.kt b/modules/expo-bluesky-oauth-client/android/src/main/java/JWK.kt deleted file mode 100644 index 64835b370b..0000000000 --- a/modules/expo-bluesky-oauth-client/android/src/main/java/JWK.kt +++ /dev/null @@ -1,19 +0,0 @@ -package expo.modules.blueskyoauthclient - -import expo.modules.kotlin.records.Record -import expo.modules.kotlin.records.Field - -class JWK( - @Field var alg: String = "", - @Field var kty: String = "", - @Field var crv: String? = null, - @Field var x: String? = null, - @Field var y: String? = null, - @Field var e: String? = null, - @Field var n: String? = null, - @Field var d: String? = null, - @Field var use: String? = null, - @Field var kid: String? = null -) : Record - -class JWKPair(@Field val privateKey: JWK, @Field val publicKey: JWK) : Record \ No newline at end of file diff --git a/modules/expo-bluesky-oauth-client/android/src/main/java/expo/modules/blueskyoauthclient/CryptoUtil.kt b/modules/expo-bluesky-oauth-client/android/src/main/java/expo/modules/blueskyoauthclient/CryptoUtil.kt index 1c8ea0eaa5..eab729a8b8 100644 --- a/modules/expo-bluesky-oauth-client/android/src/main/java/expo/modules/blueskyoauthclient/CryptoUtil.kt +++ b/modules/expo-bluesky-oauth-client/android/src/main/java/expo/modules/blueskyoauthclient/CryptoUtil.kt @@ -52,6 +52,7 @@ class CryptoUtil { crv = privateJwk.curve.toString(), x = privateJwk.x.toString(), y = privateJwk.y.toString(), + d = privateJwk.d.toString(), use = privateJwk.keyUse.toString(), kid = privateJwk.keyID ), diff --git a/modules/expo-bluesky-oauth-client/android/src/main/java/expo/modules/blueskyoauthclient/ExpoBlueskyOAuthClientModule.kt b/modules/expo-bluesky-oauth-client/android/src/main/java/expo/modules/blueskyoauthclient/ExpoBlueskyOAuthClientModule.kt index 93906a3ab1..15e6305b45 100644 --- a/modules/expo-bluesky-oauth-client/android/src/main/java/expo/modules/blueskyoauthclient/ExpoBlueskyOAuthClientModule.kt +++ b/modules/expo-bluesky-oauth-client/android/src/main/java/expo/modules/blueskyoauthclient/ExpoBlueskyOAuthClientModule.kt @@ -1,5 +1,6 @@ package expo.modules.blueskyoauthclient +import android.util.Log import expo.modules.kotlin.modules.Module import expo.modules.kotlin.modules.ModuleDefinition @@ -15,21 +16,19 @@ class ExpoBlueskyOAuthClientModule : Module() { return@Function CryptoUtil().getRandomValues(byteLength) } - AsyncFunction("generateKeyPair") { keyId: String? -> - val res = CryptoUtil().generateKeyPair(keyId) - - return@AsyncFunction mapOf( - "publicKey" to res.first, - "privateKey" to res.second - ) + AsyncFunction("generateJwk") { algorithim: String -> + if (algorithim != "ES256") { + throw Exception("Unsupported algorithm") + } + return@AsyncFunction CryptoUtil().generateKeyPair() } - AsyncFunction("createJwt") { jwkString: String, headerString: String, payloadString: String -> - return@AsyncFunction JWTUtil().createJwt(jwkString, headerString, payloadString) + AsyncFunction("createJwt") { header: JWTHeader, payload: JWTPayload, jwk: JWK -> + return@AsyncFunction JWTUtil().createJwt(header, payload, jwk) } - AsyncFunction("verifyJwt") { jwkString: String, tokenString: String, options: String? -> - return@AsyncFunction JWTUtil().verifyJwt(jwkString, tokenString, options) + AsyncFunction("verifyJwt") { token: String, jwk: JWK -> + return@AsyncFunction JWTUtil().verifyJwt(token, jwk) } } } diff --git a/modules/expo-bluesky-oauth-client/android/src/main/java/expo/modules/blueskyoauthclient/JWK.kt b/modules/expo-bluesky-oauth-client/android/src/main/java/expo/modules/blueskyoauthclient/JWK.kt new file mode 100644 index 0000000000..2ce71dd8fa --- /dev/null +++ b/modules/expo-bluesky-oauth-client/android/src/main/java/expo/modules/blueskyoauthclient/JWK.kt @@ -0,0 +1,34 @@ +package expo.modules.blueskyoauthclient + +import expo.modules.kotlin.records.Record +import expo.modules.kotlin.records.Field + +class JWK( + @Field var alg: String = "", + @Field var kty: String = "", + @Field var crv: String? = null, + @Field var x: String? = null, + @Field var y: String? = null, + @Field var e: String? = null, + @Field var n: String? = null, + @Field var d: String? = null, + @Field var use: String? = null, + @Field var kid: String? = null +) : Record { + fun toJson(): String { + val parts = mutableListOf() + if (alg.isNotEmpty()) parts.add("\"alg\": \"$alg\"") + if (kty.isNotEmpty()) parts.add("\"kty\": \"$kty\"") + if (crv != null) parts.add("\"crv\": \"$crv\"") + if (x != null) parts.add("\"x\": \"$x\"") + if (y != null) parts.add("\"y\": \"$y\"") + if (e != null) parts.add("\"e\": \"$e\"") + if (n != null) parts.add("\"n\": \"$n\"") + if (d != null) parts.add("\"d\": \"$d\"") + if (use != null) parts.add("\"use\": \"$use\"") + if (kid != null) parts.add("\"kid\": \"$kid\"") + return "{ ${parts.joinToString()} }" + } +} + +class JWKPair(@Field val privateKey: JWK, @Field val publicKey: JWK) : Record \ No newline at end of file diff --git a/modules/expo-bluesky-oauth-client/android/src/main/java/expo/modules/blueskyoauthclient/JWT.kt b/modules/expo-bluesky-oauth-client/android/src/main/java/expo/modules/blueskyoauthclient/JWT.kt index a36df26a3a..8b761615a0 100644 --- a/modules/expo-bluesky-oauth-client/android/src/main/java/expo/modules/blueskyoauthclient/JWT.kt +++ b/modules/expo-bluesky-oauth-client/android/src/main/java/expo/modules/blueskyoauthclient/JWT.kt @@ -6,12 +6,24 @@ import expo.modules.kotlin.records.Field class JWTHeader( @Field var alg: String = "", @Field var jku: String? = null, - @Field var jwk: JWK = JWK(), + @Field var jwk: JWK? = null, @Field var kid: String? = null, @Field var typ: String? = null, @Field var cty: String? = null, @Field var crit: String? = null -) : Record +) : Record { + fun toJson(): String { + val parts = mutableListOf() + if (alg.isNotEmpty()) parts.add("\"alg\": \"$alg\"") + if (jku != null) parts.add("\"jku\": \"$jku\"") + if (jwk != null) parts.add("\"jwk\": ${jwk?.toJson()}") + if (kid != null) parts.add("\"kid\": \"$kid\"") + if (typ != null) parts.add("\"typ\": \"$typ\"") + if (cty != null) parts.add("\"cty\": \"$cty\"") + if (crit != null) parts.add("\"crit\": \"$crit\"") + return "{ ${parts.joinToString()} }" + } +} class JWTPayload( @Field var iss: String? = null, @@ -41,7 +53,63 @@ class JWTPayload( @Field var middle_name: String? = null, @Field var nickname: String? = null, @Field var preferred_username: String? = null, -) : Record + @Field var gender: String? = null, + @Field var picture: String? = null, + @Field var profile: String? = null, + @Field var birthdate: String? = null, + @Field var zoneinfo: String? = null, + @Field var updated_at: Int? = null, + @Field var email: String? = null, + @Field var email_verified: Boolean? = null, + @Field var phone_number: String? = null, + @Field var phone_number_verified: Boolean? = null, + @Field var address: JWTPayloadAddress? = null, + @Field var authorization_details: JWTPayloadAuthorizationDetails? = null +) : Record { + fun toJson(): String { + val parts = mutableListOf() + if (iss != null) parts.add("\"iss\": \"$iss\"") + if (aud != null) parts.add("\"aud\": \"$aud\"") + if (sub != null) parts.add("\"sub\": \"$sub\"") + if (exp != null) parts.add("\"exp\": $exp") + if (nbr != null) parts.add("\"nbr\": $nbr") + if (iat != null) parts.add("\"iat\": $iat") + if (jti != null) parts.add("\"jti\": \"$jti\"") + if (htm != null) parts.add("\"htm\": \"$htm\"") + if (htu != null) parts.add("\"htu\": \"$htu\"") + if (ath != null) parts.add("\"ath\": \"$ath\"") + if (acr != null) parts.add("\"acr\": \"$acr\"") + if (azp != null) parts.add("\"azp\": \"$azp\"") + if (amr != null) parts.add("\"amr\": \"$amr\"") + if (cnf != null) parts.add("\"cnf\": ${cnf?.toJson()}") + if (client_id != null) parts.add("\"client_id\": \"$client_id\"") + if (scope != null) parts.add("\"scope\": \"$scope\"") + if (nonce != null) parts.add("\"nonce\": \"$nonce\"") + if (at_hash != null) parts.add("\"at_hash\": \"$at_hash\"") + if (c_hash != null) parts.add("\"c_hash\": \"$c_hash\"") + if (s_hash != null) parts.add("\"s_hash\": \"$s_hash\"") + if (auth_time != null) parts.add("\"auth_time\": $auth_time") + if (name != null) parts.add("\"name\": \"$name\"") + if (family_name != null) parts.add("\"family_name\": \"$family_name\"") + if (given_name != null) parts.add("\"given_name\": \"$given_name\"") + if (middle_name != null) parts.add("\"middle_name\": \"$middle_name\"") + if (nickname != null) parts.add("\"nickname\": \"$nickname\"") + if (preferred_username != null) parts.add("\"preferred_username\": \"$preferred_username\"") + if (gender != null) parts.add("\"gender\": \"$gender\"") + if (picture != null) parts.add("\"picture\": \"$picture\"") + if (profile != null) parts.add("\"profile\": \"$profile\"") + if (birthdate != null) parts.add("\"birthdate\": \"$birthdate\"") + if (zoneinfo != null) parts.add("\"zoneinfo\": \"$zoneinfo\"") + if (updated_at != null) parts.add("\"updated_at\": $updated_at") + if (email != null) parts.add("\"email\": \"$email\"") + if (email_verified != null) parts.add("\"email_verified\": $email_verified") + if (phone_number != null) parts.add("\"phone_number\": \"$phone_number\"") + if (phone_number_verified != null) parts.add("\"phone_number_verified\": $phone_number_verified") + if (address != null) parts.add("\"address\": ${address?.toJson()}") + if (authorization_details != null) parts.add("\"authorization_details\": ${authorization_details?.toJson()}") + return "{ ${parts.joinToString()} }" + } +} class JWTPayloadCNF( @Field var jwk: JWK? = null, @@ -49,7 +117,17 @@ class JWTPayloadCNF( @Field var jku: String? = null, @Field var jkt: String? = null, @Field var osc: String? = null -) : Record +) : Record { + fun toJson(): String { + val parts = mutableListOf() + if (jwk != null) parts.add("\"jwk\": ${jwk?.toJson()}") + if (jwe != null) parts.add("\"jwe\": \"$jwe\"") + if (jku != null) parts.add("\"jku\": \"$jku\"") + if (jkt != null) parts.add("\"jkt\": \"$jkt\"") + if (osc != null) parts.add("\"osc\": \"$osc\"") + return "{ ${parts.joinToString()} }" + } +} class JWTPayloadAddress( @Field var formatted: String? = null, @@ -58,7 +136,18 @@ class JWTPayloadAddress( @Field var region: String? = null, @Field var postal_code: String? = null, @Field var country: String? = null -) : Record +) : Record { + fun toJson(): String { + val parts = mutableListOf() + if (formatted != null) parts.add("\"formatted\": \"$formatted\"") + if (street_address != null) parts.add("\"street_address\": \"$street_address\"") + if (locality != null) parts.add("\"locality\": \"$locality\"") + if (region != null) parts.add("\"region\": \"$region\"") + if (postal_code != null) parts.add("\"postal_code\": \"$postal_code\"") + if (country != null) parts.add("\"country\": \"$country\"") + return "{ ${parts.joinToString()} }" + } +} class JWTPayloadAuthorizationDetails( @Field var type: String? = null, @@ -67,10 +156,20 @@ class JWTPayloadAuthorizationDetails( @Field var datatypes: Array? = null, @Field var identifier: String? = null, @Field var privileges: Array? = null -) : Record +) : Record { + fun toJson(): String { + val parts = mutableListOf() + if (type != null) parts.add("\"type\": \"$type\"") + if (locations != null) parts.add("\"locations\": [${locations?.joinToString()}]") + if (actions != null) parts.add("\"actions\": [${actions?.joinToString()}]") + if (datatypes != null) parts.add("\"datatypes\": [${datatypes?.joinToString()}]") + if (identifier != null) parts.add("\"identifier\": \"$identifier\"") + if (privileges != null) parts.add("\"privileges\": [${privileges?.joinToString()}]") + return "{ ${parts.joinToString()} }" + } +} class JWTVerifyResponse( - @Field var header: JWTHeader = JWTHeader(), - @Field var payload: JWTPayload = JWTPayload(), - @Field var signature: String = "" + @Field var protectedHeader: JWTHeader = JWTHeader(), + @Field var payload: String = "", ) : Record \ No newline at end of file diff --git a/modules/expo-bluesky-oauth-client/android/src/main/java/expo/modules/blueskyoauthclient/JWTUtil.kt b/modules/expo-bluesky-oauth-client/android/src/main/java/expo/modules/blueskyoauthclient/JWTUtil.kt index 8c099d198c..1fb9276e5d 100644 --- a/modules/expo-bluesky-oauth-client/android/src/main/java/expo/modules/blueskyoauthclient/JWTUtil.kt +++ b/modules/expo-bluesky-oauth-client/android/src/main/java/expo/modules/blueskyoauthclient/JWTUtil.kt @@ -7,29 +7,64 @@ import com.nimbusds.jose.jwk.ECKey import com.nimbusds.jwt.JWTClaimsSet import com.nimbusds.jwt.SignedJWT - class JWTUtil { - fun createJwt(jwkString: String, headerString: String, payloadString: String): String { - val key = ECKey.parse(jwkString) - val header = JWSHeader.parse(headerString) - val payload = JWTClaimsSet.parse(payloadString) + fun createJwt(header: JWTHeader, payload: JWTPayload, jwk: JWK): String { + val parsedKey = ECKey.parse(jwk.toJson()) + val parsedHeader = JWSHeader.parse(header.toJson()) + val parsedPayload = JWTClaimsSet.parse(payload.toJson()) - val signer = ECDSASigner(key) - val jwt = SignedJWT(header, payload) + val signer = ECDSASigner(parsedKey) + val jwt = SignedJWT(parsedHeader, parsedPayload) jwt.sign(signer) return jwt.serialize() } - fun verifyJwt(jwkString: String, tokenString: String, options: String?): Boolean { - return try { - val key = ECKey.parse(jwkString) - val jwt = SignedJWT.parse(tokenString) - val verifier = ECDSAVerifier(key) + fun verifyJwt(token: String, jwk: JWK): JWTVerifyResponse { + try { + val parsedKey = ECKey.parse(jwk.toJson()) + val jwt = SignedJWT.parse(token) + val verifier = ECDSAVerifier(parsedKey) - jwt.verify(verifier) + if (!jwt.verify(verifier)) { + throw Exception("Invalid signature") + } + + val header = jwt.header + val payload = jwt.payload + val ecKey = header.jwk?.toECKey() + val serializedJwk = if (ecKey != null) { + JWK( + alg = ecKey.algorithm.toString(), + kty = ecKey.keyType.toString(), + crv = ecKey.curve.toString(), + x = ecKey.x.toString(), + y = ecKey.y.toString(), + d = ecKey.d.toString(), + use = ecKey.keyUse.toString(), + kid = ecKey.keyID + ) + } else { + null + } + + val serializedHeader = JWTHeader( + alg = header.algorithm.toString(), + jku = header.jwkurl?.toString(), + jwk = serializedJwk, + kid = header.keyID, + typ = header.type?.toString(), + cty = header.contentType, + crit = header.criticalParams?.joinToString() + ) + val serializedPayload = payload.toString() + + return JWTVerifyResponse( + protectedHeader = serializedHeader, + payload = serializedPayload, + ) } catch(e: Exception) { - false + throw e } } } diff --git a/modules/expo-bluesky-oauth-client/ios/JWT.swift b/modules/expo-bluesky-oauth-client/ios/JWT.swift index dc3dbf9f54..8ce014048a 100644 --- a/modules/expo-bluesky-oauth-client/ios/JWT.swift +++ b/modules/expo-bluesky-oauth-client/ios/JWT.swift @@ -100,7 +100,7 @@ struct JWTPayload : Record { @Field var email: String? @Field - var email_verified: String? + var email_verified: Bool? @Field var phone_number: String? @Field diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index bb4e9e1aa8..cce0121328 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -68,6 +68,9 @@ function HomeScreenReady({ }, ) + console.log(jwt) + console.log(key.publicJwk) + const verified = await key.verifyJwt(jwt) console.log(verified) From e01a127166c61cd98745ffafd70409d5c3ab9a43 Mon Sep 17 00:00:00 2001 From: Hailey Date: Mon, 15 Apr 2024 02:04:17 -0700 Subject: [PATCH 11/11] rm log --- src/view/screens/Home.tsx | 26 -------------------------- 1 file changed, 26 deletions(-) diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index cce0121328..39bdac669c 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -19,7 +19,6 @@ import {Pager, PagerRef, RenderTabBarFnProps} from 'view/com/pager/Pager' import {CustomFeedEmptyState} from 'view/com/posts/CustomFeedEmptyState' import {FollowingEmptyState} from 'view/com/posts/FollowingEmptyState' import {FollowingEndOfFeed} from 'view/com/posts/FollowingEndOfFeed' -import {ReactNativeKey} from '../../../modules/expo-bluesky-oauth-client' import {HomeLoggedOutCTA} from '../com/auth/HomeLoggedOutCTA' import {HomeHeader} from '../com/home/HomeHeader' @@ -52,31 +51,6 @@ function HomeScreenReady({ preferences: UsePreferencesQueryResponse pinnedFeedInfos: FeedSourceInfo[] }) { - React.useEffect(() => { - ;(async () => { - const key = await ReactNativeKey.generate('test', ['ES256']) - console.log(key.privateJwk) - - const jwt = await key.createJwt( - { - alg: 'ES256', - kid: key.kid, - }, - { - sub: 'test', - iat: Math.floor(Date.now() / 1000), - }, - ) - - console.log(jwt) - console.log(key.publicJwk) - - const verified = await key.verifyJwt(jwt) - - console.log(verified) - })() - }, []) - const allFeeds = React.useMemo(() => { const feeds: FeedDescriptor[] = [] feeds.push('home')