Bluesky app fork with some witchin' additions 💫 witchsky.app
bluesky fork client
117
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix(auth): better identity resolution at login (support did:web!)

works with any #bsky_appview, or standalone! meow

+504 -41
+38 -39
src/screens/Login/index.tsx
··· 8 8 9 9 import {DEFAULT_SERVICE} from '#/lib/constants' 10 10 import {logger} from '#/logger' 11 - import {resolvePdsServiceUrl} from '#/state/queries/resolve-identity' 12 11 import {useServiceQuery} from '#/state/queries/service' 13 - import {type SessionAccount, useAgent, useSession} from '#/state/session' 12 + import {type SessionAccount, useSession} from '#/state/session' 13 + import { 14 + getPdsServiceUrlFromIdentityInfo, 15 + resolveIdentityUsingAppView, 16 + } from '#/state/session/identity-resolver' 14 17 import {useLoggedOutView} from '#/state/shell/logged-out' 15 18 import {LoggedOutLayout} from '#/view/com/util/layouts/LoggedOutLayout' 16 19 import {ForgotPasswordForm} from '#/screens/Login/ForgotPasswordForm' ··· 46 49 const failedAttemptCountRef = useRef(0) 47 50 const startTimeRef = useRef(Date.now()) 48 51 49 - const agent = useAgent() 50 52 const {accounts} = useSession() 51 53 const {requestedAccountSwitchTo} = useLoggedOutView() 52 54 const requestedAccount = accounts.find( ··· 109 111 } else { 110 112 setError('') 111 113 } 112 - }, [serviceError, serviceUrl, _]) 114 + }, [serviceError, serviceUrl, _, ax]) 113 115 114 - const resolveIdentity = useCallback( 115 - async (identifier: string) => { 116 - setIsResolvingService(true) 116 + const resolveIdentity = useCallback(async (identifier: string) => { 117 + setIsResolvingService(true) 117 118 118 - try { 119 - const getDid = async () => { 120 - if (identifier.startsWith('did:')) return identifier 121 - else 122 - return ( 123 - await agent.resolveHandle({ 124 - handle: identifier, 125 - }) 126 - ).data.did 127 - } 119 + try { 120 + const identity = await resolveIdentityUsingAppView(identifier) 121 + const did = identity.did as Did 122 + const pdsUrl = getPdsServiceUrlFromIdentityInfo(identity) 128 123 129 - const did = (await getDid()) as Did 130 - const pdsUrl = await resolvePdsServiceUrl(did) 124 + if (!pdsUrl) { 125 + throw new Error(`No PDS service found in DID document for ${did}`) 126 + } 131 127 132 - if (!pdsUrl) { 133 - throw new Error(`No PDS service found in DID document for ${did}`) 134 - } 135 - 136 - if (pdsUrl.endsWith('.bsky.network')) { 137 - setServiceUrl('https://bsky.social') 138 - } else { 139 - setServiceUrl(pdsUrl) 140 - } 141 - } catch (err) { 142 - logger.error( 143 - `Service auto-resolution failed: ${err instanceof Error ? err.message : String(err)}`, 144 - ) 145 - } finally { 146 - setIsResolvingService(false) 128 + if (pdsUrl.endsWith('.bsky.network')) { 129 + setServiceUrl('https://bsky.social') 130 + } else { 131 + setServiceUrl(pdsUrl) 147 132 } 148 - }, 149 - [agent], 150 - ) 133 + } catch (err) { 134 + logger.error( 135 + `Service auto-resolution failed: ${err instanceof Error ? err.message : String(err)}`, 136 + ) 137 + } finally { 138 + setIsResolvingService(false) 139 + } 140 + }, []) 151 141 152 142 const debouncedResolveService = useMemo( 153 143 () => debounce(resolveIdentity, 400), 154 144 [resolveIdentity], 145 + ) 146 + const onPressRetryConnect = useCallback(() => { 147 + void refetchService() 148 + }, [refetchService]) 149 + const onDebouncedResolveService = useCallback( 150 + (identifier: string) => { 151 + void debouncedResolveService(identifier) 152 + }, 153 + [debouncedResolveService], 155 154 ) 156 155 157 156 const onPressForgotPassword = () => { ··· 204 203 setServiceUrl={setServiceUrl} 205 204 onPressBack={goBack} 206 205 onPressForgotPassword={onPressForgotPassword} 207 - onPressRetryConnect={refetchService} 208 - debouncedResolveService={debouncedResolveService} 206 + onPressRetryConnect={onPressRetryConnect} 207 + debouncedResolveService={onDebouncedResolveService} 209 208 isResolvingService={isResolvingService} 210 209 /> 211 210 )
+193
src/state/session/__tests__/identity-resolver-test.ts
··· 1 + import {beforeEach, describe, expect, it, jest} from '@jest/globals' 2 + 3 + const mockResolveHandle: jest.MockedFunction< 4 + ( 5 + params: {handle: string}, 6 + options?: {signal?: AbortSignal}, 7 + ) => Promise<{ 8 + data: { 9 + did: string 10 + } 11 + }> 12 + > = jest.fn() 13 + const mockDispose: jest.MockedFunction<() => void> = jest.fn() 14 + 15 + jest.mock('../agent', () => ({ 16 + createPublicAgent() { 17 + return { 18 + resolveHandle: mockResolveHandle, 19 + dispose: mockDispose, 20 + } 21 + }, 22 + })) 23 + 24 + import { 25 + createIdentityResolver, 26 + getPdsServiceUrlFromIdentityInfo, 27 + resolveIdentityUsingAppView, 28 + } from '../identity-resolver' 29 + 30 + describe('appview identity resolver', () => { 31 + beforeEach(() => { 32 + mockResolveHandle.mockReset() 33 + mockDispose.mockReset() 34 + jest.restoreAllMocks() 35 + }) 36 + 37 + it('resolves handles through the current appview and DID docs', async () => { 38 + const signal = new AbortController().signal 39 + mockResolveHandle.mockResolvedValueOnce({ 40 + data: { 41 + did: 'did:plc:alice12345678901234567890', 42 + }, 43 + }) 44 + jest.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ 45 + ok: true, 46 + json: () => 47 + Promise.resolve({ 48 + id: 'did:plc:alice12345678901234567890', 49 + alsoKnownAs: ['at://xan.lol'], 50 + service: [ 51 + { 52 + id: '#atproto_pds', 53 + type: 'AtprotoPersonalDataServer', 54 + serviceEndpoint: 'https://pds.alice.example', 55 + }, 56 + ], 57 + }), 58 + } as Response) 59 + 60 + const identity = await resolveIdentityUsingAppView('xan.lol', signal) 61 + 62 + expect(mockResolveHandle).toHaveBeenCalledWith( 63 + {handle: 'xan.lol'}, 64 + {signal}, 65 + ) 66 + expect(mockDispose).toHaveBeenCalled() 67 + expect(identity).toEqual({ 68 + did: 'did:plc:alice12345678901234567890', 69 + handle: 'xan.lol', 70 + didDoc: { 71 + id: 'did:plc:alice12345678901234567890', 72 + alsoKnownAs: ['at://xan.lol'], 73 + service: [ 74 + { 75 + id: '#atproto_pds', 76 + type: 'AtprotoPersonalDataServer', 77 + serviceEndpoint: 'https://pds.alice.example', 78 + }, 79 + ], 80 + }, 81 + }) 82 + }) 83 + 84 + it('falls back to client-side handle resolution when appview resolution fails', async () => { 85 + const signal = new AbortController().signal 86 + mockResolveHandle.mockRejectedValueOnce(new Error('appview down')) 87 + jest 88 + .spyOn(globalThis, 'fetch') 89 + .mockResolvedValueOnce({ 90 + ok: true, 91 + json: () => 92 + Promise.resolve({ 93 + Answer: [ 94 + { 95 + type: 16, 96 + data: '"did=did:plc:alice12345678901234567890"', 97 + }, 98 + ], 99 + }), 100 + } as Response) 101 + .mockResolvedValueOnce({ 102 + ok: true, 103 + json: () => 104 + Promise.resolve({ 105 + id: 'did:plc:alice12345678901234567890', 106 + alsoKnownAs: ['at://xan.lol'], 107 + service: [ 108 + { 109 + id: '#atproto_pds', 110 + type: 'AtprotoPersonalDataServer', 111 + serviceEndpoint: 'https://pds.alice.example', 112 + }, 113 + ], 114 + }), 115 + } as Response) 116 + 117 + const identity = await resolveIdentityUsingAppView('xan.lol', signal) 118 + 119 + expect(mockResolveHandle).toHaveBeenCalledWith( 120 + {handle: 'xan.lol'}, 121 + {signal}, 122 + ) 123 + expect(identity.did).toBe('did:plc:alice12345678901234567890') 124 + expect(identity.handle).toBe('xan.lol') 125 + }) 126 + 127 + it('resolves did:web identities without requiring appview resolveIdentity', async () => { 128 + mockResolveHandle.mockResolvedValueOnce({ 129 + data: { 130 + did: 'did:web:alice.example', 131 + }, 132 + }) 133 + jest.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ 134 + ok: true, 135 + json: () => 136 + Promise.resolve({ 137 + id: 'did:web:alice.example', 138 + alsoKnownAs: ['at://alice.example'], 139 + service: [ 140 + { 141 + id: '#atproto_pds', 142 + type: 'AtprotoPersonalDataServer', 143 + serviceEndpoint: 'https://pds.alice.example', 144 + }, 145 + ], 146 + }), 147 + } as Response) 148 + 149 + const resolver = createIdentityResolver() 150 + const identity = await resolver.resolve('did:web:alice.example') 151 + 152 + expect(mockResolveHandle).toHaveBeenCalledWith( 153 + {handle: 'alice.example'}, 154 + {signal: undefined}, 155 + ) 156 + expect(identity).toEqual({ 157 + did: 'did:web:alice.example', 158 + handle: 'alice.example', 159 + didDoc: { 160 + id: 'did:web:alice.example', 161 + alsoKnownAs: ['at://alice.example'], 162 + service: [ 163 + { 164 + id: '#atproto_pds', 165 + type: 'AtprotoPersonalDataServer', 166 + serviceEndpoint: 'https://pds.alice.example', 167 + }, 168 + ], 169 + }, 170 + }) 171 + }) 172 + 173 + it('extracts the pds service url from resolved identity info', () => { 174 + expect( 175 + getPdsServiceUrlFromIdentityInfo({ 176 + didDoc: { 177 + service: [ 178 + { 179 + id: '#bsky_appview', 180 + type: 'BskyAppView', 181 + serviceEndpoint: 'https://appview.example', 182 + }, 183 + { 184 + id: '#atproto_pds', 185 + type: 'AtprotoPersonalDataServer', 186 + serviceEndpoint: 'https://pds.example', 187 + }, 188 + ], 189 + }, 190 + }), 191 + ).toBe('https://pds.example') 192 + }) 193 + })
+269
src/state/session/identity-resolver.ts
··· 1 + import {type ComAtprotoIdentityDefs, isDid} from '@atproto/api' 2 + 3 + import {createPublicAgent} from './agent' 4 + 5 + type AtprotoDid = `did:plc:${string}` | `did:web:${string}` 6 + type DidDocument = { 7 + id?: string 8 + alsoKnownAs?: string[] 9 + service?: Service[] 10 + } 11 + 12 + type Service = { 13 + id?: string 14 + type?: string 15 + serviceEndpoint?: string 16 + } 17 + 18 + type IdentityResolver = { 19 + resolve( 20 + input: string, 21 + options?: {signal?: AbortSignal}, 22 + ): Promise<{ 23 + did: AtprotoDid 24 + didDoc: DidDocument 25 + handle: string 26 + }> 27 + } 28 + 29 + const HANDLE_INVALID = 'handle.invalid' 30 + const DOH_ENDPOINT = 'https://cloudflare-dns.com/dns-query' 31 + 32 + function asNormalizedHandle(input: string) { 33 + const handle = input.toLowerCase() 34 + return /^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]([a-z0-9-]{0,61}[a-z0-9])?$/.test( 35 + handle, 36 + ) 37 + ? handle 38 + : undefined 39 + } 40 + 41 + function extractNormalizedHandle(document: DidDocument) { 42 + if (!Array.isArray(document.alsoKnownAs)) return 43 + 44 + for (const value of document.alsoKnownAs) { 45 + if (value.startsWith('at://')) { 46 + return asNormalizedHandle(value.slice(5)) 47 + } 48 + } 49 + } 50 + 51 + function findService(doc: DidDocument, id: string, type?: string) { 52 + if (!Array.isArray(doc?.service)) return 53 + return doc.service.find( 54 + service => 55 + service?.serviceEndpoint && 56 + service?.id === id && 57 + (!type || service?.type === type), 58 + ) 59 + } 60 + 61 + async function resolveHandleUsingAppView( 62 + handle: string, 63 + signal?: AbortSignal, 64 + ): Promise<AtprotoDid> { 65 + const agent = createPublicAgent() 66 + 67 + try { 68 + const res = await agent.resolveHandle({handle}, {signal}) 69 + return res.data.did as AtprotoDid 70 + } finally { 71 + agent.dispose() 72 + } 73 + } 74 + 75 + async function resolveHandleUsingDoh( 76 + handle: string, 77 + signal?: AbortSignal, 78 + ): Promise<AtprotoDid | null> { 79 + const url = new URL(DOH_ENDPOINT) 80 + url.searchParams.set('type', 'TXT') 81 + url.searchParams.set('name', `_atproto.${handle}`) 82 + 83 + const response = await fetch(url, { 84 + headers: { 85 + accept: 'application/dns-json', 86 + }, 87 + redirect: 'follow', 88 + signal, 89 + }) 90 + 91 + if (!response.ok) { 92 + return null 93 + } 94 + 95 + const result = (await response.json()) as { 96 + Answer?: Array<{type?: number; data?: string}> 97 + } 98 + const txtRecords = 99 + result.Answer?.filter( 100 + answer => answer.type === 16 && typeof answer.data === 'string', 101 + ).map(answer => answer.data!.replace(/^"|"$/g, '').replace(/\\"/g, '"')) ?? 102 + [] 103 + 104 + let did: AtprotoDid | null = null 105 + for (const record of txtRecords) { 106 + if (!record.startsWith('did=')) continue 107 + 108 + const nextDid = record.slice(4) 109 + if (!isDid(nextDid)) { 110 + return null 111 + } 112 + 113 + if (did && did !== nextDid) { 114 + return null 115 + } 116 + 117 + did = nextDid as AtprotoDid 118 + } 119 + 120 + return did 121 + } 122 + 123 + async function resolveHandleUsingWellKnown( 124 + handle: string, 125 + signal?: AbortSignal, 126 + ): Promise<AtprotoDid | null> { 127 + try { 128 + const response = await fetch(`https://${handle}/.well-known/atproto-did`, { 129 + redirect: 'error', 130 + signal, 131 + }) 132 + const text = await response.text() 133 + const firstLine = text.split('\n')[0]?.trim() 134 + return firstLine && isDid(firstLine) ? (firstLine as AtprotoDid) : null 135 + } catch { 136 + signal?.throwIfAborted() 137 + return null 138 + } 139 + } 140 + 141 + async function resolveHandleClientSide( 142 + handle: string, 143 + signal?: AbortSignal, 144 + ): Promise<AtprotoDid | null> { 145 + try { 146 + const did = await resolveHandleUsingDoh(handle, signal) 147 + if (did) return did 148 + } catch { 149 + signal?.throwIfAborted() 150 + } 151 + 152 + return resolveHandleUsingWellKnown(handle, signal) 153 + } 154 + 155 + async function resolveHandle( 156 + handle: string, 157 + signal?: AbortSignal, 158 + ): Promise<AtprotoDid> { 159 + try { 160 + return await resolveHandleUsingAppView(handle, signal) 161 + } catch (appViewError) { 162 + const fallbackDid = await resolveHandleClientSide(handle, signal) 163 + if (fallbackDid) { 164 + return fallbackDid 165 + } 166 + 167 + throw appViewError 168 + } 169 + } 170 + 171 + async function resolveDidDocument( 172 + did: AtprotoDid, 173 + signal?: AbortSignal, 174 + ): Promise<DidDocument> { 175 + const docUrl = did.startsWith('did:plc:') 176 + ? `https://plc.directory/${did}` 177 + : `https://${did.substring(8)}/.well-known/did.json` 178 + 179 + const res = await fetch(docUrl, { 180 + headers: { 181 + accept: 'application/did+ld+json, application/json', 182 + }, 183 + signal, 184 + }) 185 + 186 + if (!res.ok) { 187 + throw new Error(`Failed to resolve DID document for ${did}`) 188 + } 189 + 190 + return (await res.json()) as DidDocument 191 + } 192 + 193 + async function getValidatedHandleFromDidDocument( 194 + did: AtprotoDid, 195 + didDoc: DidDocument, 196 + signal?: AbortSignal, 197 + ) { 198 + const handle = extractNormalizedHandle(didDoc) 199 + if (!handle) return HANDLE_INVALID 200 + 201 + try { 202 + const resolvedDid = await resolveHandle(handle, signal) 203 + return resolvedDid === did ? handle : HANDLE_INVALID 204 + } catch { 205 + return HANDLE_INVALID 206 + } 207 + } 208 + 209 + export async function resolveIdentityUsingAppView( 210 + identifier: string, 211 + signal?: AbortSignal, 212 + ): Promise<ComAtprotoIdentityDefs.IdentityInfo> { 213 + if (isDid(identifier)) { 214 + const did = identifier as AtprotoDid 215 + const didDoc = await resolveDidDocument(did, signal) 216 + const handle = await getValidatedHandleFromDidDocument(did, didDoc, signal) 217 + 218 + return { 219 + did, 220 + didDoc, 221 + handle, 222 + } 223 + } 224 + 225 + const handle = asNormalizedHandle(identifier) 226 + if (!handle) { 227 + throw new Error(`Invalid handle "${identifier}" provided.`) 228 + } 229 + 230 + const did = await resolveHandle(handle, signal) 231 + const didDoc = await resolveDidDocument(did, signal) 232 + 233 + return { 234 + did, 235 + didDoc, 236 + handle: extractNormalizedHandle(didDoc) ?? HANDLE_INVALID, 237 + } 238 + } 239 + 240 + export function createIdentityResolver(): IdentityResolver { 241 + return { 242 + async resolve( 243 + input: string, 244 + options?: {signal?: AbortSignal}, 245 + ): Promise<{ 246 + did: AtprotoDid 247 + didDoc: DidDocument 248 + handle: string 249 + }> { 250 + const identity = await resolveIdentityUsingAppView(input, options?.signal) 251 + 252 + return { 253 + did: identity.did as AtprotoDid, 254 + didDoc: identity.didDoc as DidDocument, 255 + handle: identity.handle, 256 + } 257 + }, 258 + } 259 + } 260 + 261 + export function getPdsServiceUrlFromIdentityInfo( 262 + identity: Pick<ComAtprotoIdentityDefs.IdentityInfo, 'didDoc'>, 263 + ) { 264 + return findService( 265 + identity.didDoc as DidDocument, 266 + '#atproto_pds', 267 + 'AtprotoPersonalDataServer', 268 + )?.serviceEndpoint 269 + }
+4 -2
src/state/session/oauth-web-client.ts
··· 1 1 import {BrowserOAuthClient} from '@atproto/oauth-client-browser' 2 2 3 + import {createIdentityResolver} from './identity-resolver' 4 + 3 5 const OAUTH_BASE_URL: string = 4 6 process.env.EXPO_PUBLIC_OAUTH_BASE_URL || 'https://witchsky.app' 5 7 ··· 177 179 application_type: 'web', 178 180 dpop_bound_access_tokens: true, 179 181 }, 180 - handleResolver: 'https://bsky.social', 182 + identityResolver: createIdentityResolver(), 181 183 }) 182 184 } 183 185 ··· 194 196 application_type: 'web', 195 197 dpop_bound_access_tokens: true, 196 198 }, 197 - handleResolver: 'https://bsky.social', 199 + identityResolver: createIdentityResolver(), 198 200 }) 199 201 } 200 202