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

Configure Feed

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

OAuth implementation from Blacksky Community

authored by

uwx and committed by tangled.org 05374c6b 29ddf30a

+742 -23
+10
Dockerfile
··· 43 43 ENV EXPO_PUBLIC_SENTRY_DSN=$EXPO_PUBLIC_SENTRY_DSN 44 44 45 45 # 46 + # OAuth 47 + # 48 + ARG EXPO_PUBLIC_OAUTH_BASE_URL 49 + ENV EXPO_PUBLIC_OAUTH_BASE_URL=${EXPO_PUBLIC_OAUTH_BASE_URL:-https://witchsky.app} 50 + ARG EXPO_PUBLIC_OAUTH_CLIENT_NAME 51 + ENV EXPO_PUBLIC_OAUTH_CLIENT_NAME=${EXPO_PUBLIC_OAUTH_CLIENT_NAME:-Witchsky} 52 + 53 + # 46 54 # Copy everything into the container 47 55 # 48 56 COPY . . ··· 65 73 echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$EXPO_PUBLIC_BUNDLE_IDENTIFIER" >> .env && \ 66 74 echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env && \ 67 75 echo "EXPO_PUBLIC_SENTRY_DSN=$EXPO_PUBLIC_SENTRY_DSN" >> .env && \ 76 + echo "EXPO_PUBLIC_OAUTH_BASE_URL=$EXPO_PUBLIC_OAUTH_BASE_URL" >> .env && \ 77 + echo "EXPO_PUBLIC_OAUTH_CLIENT_NAME=$EXPO_PUBLIC_OAUTH_CLIENT_NAME" >> .env && \ 68 78 npm install --global yarn && \ 69 79 yarn && \ 70 80 yarn intl:build 2>&1 | tee i18n.log && \
+29
bskyweb/cmd/bskyweb/server.go
··· 240 240 e.GET("/robots.txt", echo.WrapHandler(staticHandler)) 241 241 } 242 242 243 + // OAuth client metadata (generated dynamically from request host) 244 + e.GET("/oauth-client-metadata.json", server.OAuthClientMetadata) 245 + 246 + // OAuth callback (serves SPA so React handles it client-side) 247 + e.GET("/auth/web/callback", server.WebGeneric) 248 + 243 249 e.GET("/iframe/*", echo.WrapHandler(staticHandler)) 244 250 e.GET("/static/*", echo.WrapHandler(http.StripPrefix("/static/", staticHandler)), func(next echo.HandlerFunc) echo.HandlerFunc { 245 251 return func(c echo.Context) error { ··· 469 475 } 470 476 471 477 // handler for endpoint that have no specific server-side handling 478 + func (srv *Server) OAuthClientMetadata(c echo.Context) error { 479 + scheme := "https" 480 + if c.Request().TLS == nil && strings.HasPrefix(c.Request().Host, "localhost") { 481 + scheme = "http" 482 + } 483 + baseURL := fmt.Sprintf("%s://%s", scheme, c.Request().Host) 484 + 485 + metadata := map[string]interface{}{ 486 + "client_id": baseURL + "/oauth-client-metadata.json", 487 + "client_name": "Witchsky", 488 + "client_uri": baseURL, 489 + "redirect_uris": []string{baseURL + "/auth/web/callback"}, 490 + "scope": "atproto transition:generic transition:email transition:chat.bsky identity:handle account:email?action=manage account:status?action=manage", 491 + "token_endpoint_auth_method": "none", 492 + "response_types": []string{"code"}, 493 + "grant_types": []string{"authorization_code", "refresh_token"}, 494 + "application_type": "web", 495 + "dpop_bound_access_tokens": true, 496 + } 497 + 498 + return c.JSON(http.StatusOK, metadata) 499 + } 500 + 472 501 func (srv *Server) WebGeneric(c echo.Context) error { 473 502 data := srv.NewTemplateContext() 474 503 return c.Render(http.StatusOK, "base.html", data)
+12
bskyweb/static/oauth-client-metadata.json
··· 1 + { 2 + "client_id": "https://witchsky.app/oauth-client-metadata.json", 3 + "client_name": "Witchsky", 4 + "client_uri": "https://witchsky.app", 5 + "redirect_uris": ["https://witchsky.app/auth/web/callback"], 6 + "scope": "atproto transition:generic transition:email transition:chat.bsky identity:handle account:email?action=manage account:status?action=manage", 7 + "token_endpoint_auth_method": "none", 8 + "response_types": ["code"], 9 + "grant_types": ["authorization_code", "refresh_token"], 10 + "application_type": "web", 11 + "dpop_bound_access_tokens": true 12 + }
+1
package.json
··· 82 82 }, 83 83 "dependencies": { 84 84 "@atproto/api": "^0.19.6", 85 + "@atproto/oauth-client-browser": "^0.3.41", 85 86 "@bitdrift/react-native": "^0.6.8", 86 87 "@braintree/sanitize-url": "^6.0.2", 87 88 "@bsky.app/alf": "^0.1.7",
+52 -2
src/App.web.tsx
··· 39 39 useSession, 40 40 useSessionApi, 41 41 } from '#/state/session' 42 + import {getWebOAuthClient} from '#/state/session/oauth-web-client' 42 43 import {readLastActiveAccount} from '#/state/session/util' 43 44 import {Provider as ShellStateProvider} from '#/state/shell' 44 45 import {Provider as ComposerProvider} from '#/state/shell/composer' ··· 79 80 import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' 80 81 import {Provider as HideBottomBarBorderProvider} from './lib/hooks/useHideBottomBarBorder' 81 82 83 + // For local development: the OAuth loopback spec requires IP-based origins 84 + // (127.0.0.1), not "localhost". The auth server redirects to 127.0.0.1, but 85 + // IndexedDB is per-origin, so PKCE state stored on "localhost" is unreachable 86 + // from "127.0.0.1". Redirect immediately so both signIn() and the callback 87 + // use the same origin. 88 + if (typeof window !== 'undefined' && window.location.hostname === 'localhost') { 89 + const url = new URL(window.location.href) 90 + url.hostname = '127.0.0.1' 91 + window.location.replace(url.href) 92 + } 93 + 94 + function hasOAuthCallbackParams(): boolean { 95 + // OAuth callback params come in the hash fragment (response_mode=fragment) 96 + // or query string. Check both for "state" + ("code" or "error"). 97 + const hash = new URLSearchParams(window.location.hash.slice(1)) 98 + const query = new URLSearchParams(window.location.search) 99 + const params = hash.has('state') ? hash : query 100 + return params.has('state') && (params.has('code') || params.has('error')) 101 + } 102 + 82 103 /** 83 104 * Begin geolocation ASAP 84 105 */ ··· 90 111 function InnerApp() { 91 112 const [isReady, setIsReady] = useState(false) 92 113 const {currentAccount} = useSession() 93 - const {resumeSession} = useSessionApi() 114 + const {resumeSession, login} = useSessionApi() 94 115 const theme = useColorModeTheme() 95 116 const {t: l} = useLingui() 96 117 const hasCheckedReferrer = useStarterPackEntry() 97 118 98 119 // init 99 120 useEffect(() => { 121 + // Safety valve: if onLaunch hangs (e.g. stale IndexedDB blocking an 122 + // upgrade, or a never-settling promise), the app will still load after 123 + // this timeout fires. 124 + const safetyTimeout = setTimeout(() => { 125 + logger.warn('session: onLaunch safety timeout fired, forcing ready state') 126 + setIsReady(true) 127 + }, 15_000) 128 + 100 129 async function onLaunch(account?: SessionAccount) { 101 130 try { 131 + // Check for OAuth callback params first (loopback redirects to /) 132 + if (hasOAuthCallbackParams()) { 133 + const client = getWebOAuthClient() 134 + const result = await client.init() 135 + if (result?.session) { 136 + await login( 137 + { 138 + service: '', 139 + identifier: '', 140 + password: '', 141 + oauthSession: result.session, 142 + }, 143 + 'LoginForm', 144 + ) 145 + // Clear hash fragment after processing 146 + window.history.replaceState(null, '', window.location.pathname) 147 + return 148 + } 149 + } 150 + 102 151 if (account) { 103 152 await resumeSession(account) 104 153 } else { ··· 107 156 } catch (e) { 108 157 logger.error('session: resumeSession failed', {message: e}) 109 158 } finally { 159 + clearTimeout(safetyTimeout) 110 160 setIsReady(true) 111 161 } 112 162 } 113 163 const account = readLastActiveAccount() 114 164 void onLaunch(account) 115 - }, [resumeSession]) 165 + }, [resumeSession, login]) 116 166 117 167 useEffect(() => { 118 168 return listenSessionDropped(() => {
+6
src/Navigation.tsx
··· 77 77 import {FindContactsFlowScreen} from '#/screens/FindContactsFlowScreen' 78 78 import HashtagScreen from '#/screens/Hashtag' 79 79 import {LogScreen} from '#/screens/Log' 80 + import {AuthCallback} from '#/screens/Login/AuthCallback' 80 81 import {MessagesScreen} from '#/screens/Messages/ChatList' 81 82 import {MessagesConversationScreen} from '#/screens/Messages/Conversation' 82 83 import {MessagesInboxScreen} from '#/screens/Messages/Inbox' ··· 189 190 name="NotFound" 190 191 getComponent={() => NotFoundScreen} 191 192 options={{title: title(msg`Not Found`)}} 193 + /> 194 + <Stack.Screen 195 + name="AuthCallback" 196 + getComponent={() => AuthCallback} 197 + options={{title: title(msg`Signing in...`)}} 192 198 /> 193 199 <Stack.Screen 194 200 name="Lists"
+3 -1
src/components/AccountList.tsx
··· 125 125 onSelect(account) 126 126 }, [account, onSelect]) 127 127 128 - const isLoggedOut = !account.refreshJwt || isJwtExpired(account.refreshJwt) 128 + const isLoggedOut = account.isOauthSession 129 + ? false // OAuth sessions are managed by the OAuth client, not refreshJwt 130 + : !account.refreshJwt || isJwtExpired(account.refreshJwt) 129 131 130 132 return ( 131 133 <Button
+9
src/lib/hooks/useCleanError.ts
··· 66 66 } 67 67 } 68 68 69 + if (raw.includes('OAuth credentials are not supported')) { 70 + return { 71 + raw, 72 + clean: _( 73 + msg`This feature is not available when signed in with OAuth. Please manage your account through your hosting provider's website.`, 74 + ), 75 + } 76 + } 77 + 69 78 if (raw.includes('Rate Limit Exceeded')) { 70 79 return { 71 80 raw,
+1
src/lib/routes/types.ts
··· 7 7 8 8 export type CommonNavigatorParams = { 9 9 NotFound: undefined 10 + AuthCallback: undefined 10 11 Lists: undefined 11 12 Moderation: undefined 12 13 ModerationModlists: undefined
+9
src/lib/strings/errors.ts
··· 30 30 if (str.includes('Bad token scope') || str.includes('Bad token method')) { 31 31 return t`This feature is not available while using an App Password. Please sign in with your main password.` 32 32 } 33 + if (str.includes('OAuth credentials are not supported')) { 34 + return t`This feature is not available when signed in with OAuth. Please manage your account through your hosting provider's website.` 35 + } 36 + if ( 37 + str.includes('ScopeMissingError') || 38 + str.includes('Missing required scope') 39 + ) { 40 + return t`This feature is not available with your current session. Please manage your account through your hosting provider's website, or sign out and sign back in to refresh your permissions.` 41 + } 33 42 if (str.includes('Account has been suspended')) { 34 43 return t`Account has been suspended` 35 44 }
+1
src/routes.ts
··· 8 8 9 9 export const router = new Router<AllNavigatableRoutes>({ 10 10 Home: ['/', '/download'], 11 + AuthCallback: '/auth/web/callback', 11 12 Search: '/search', 12 13 Feeds: '/feeds', 13 14 Notifications: '/notifications',
+38
src/screens/Login/AuthCallback.tsx
··· 1 + import {useEffect} from 'react' 2 + import {useNavigation} from '@react-navigation/native' 3 + 4 + import {type NavigationProp} from '#/lib/routes/types' 5 + import {logger} from '#/logger' 6 + import {useSessionApi} from '#/state/session' 7 + import {getWebOAuthClient} from '#/state/session/oauth-web-client' 8 + 9 + export function AuthCallback() { 10 + const {login} = useSessionApi() 11 + const navigation = useNavigation<NavigationProp>() 12 + 13 + useEffect(() => { 14 + ;(async () => { 15 + try { 16 + const client = getWebOAuthClient() 17 + const result = await client.init() 18 + if (result?.session) { 19 + await login( 20 + { 21 + service: '', 22 + identifier: '', 23 + password: '', 24 + oauthSession: result.session, 25 + }, 26 + 'LoginForm', 27 + ) 28 + } 29 + navigation.replace('Home') 30 + } catch (e: any) { 31 + logger.error('OAuth callback failed', {error: e.message}) 32 + navigation.replace('Home') 33 + } 34 + })() 35 + }, [login, navigation]) 36 + 37 + return null 38 + }
+11 -2
src/screens/Login/ChooseAccountForm.tsx
··· 36 36 // The session API isn't resilient to race conditions so let's just ignore this. 37 37 return 38 38 } 39 - if (!account.accessJwt) { 39 + if (!account.isOauthSession && !account.accessJwt) { 40 40 // Move to login form. 41 41 onSelectAccount(account) 42 42 return ··· 48 48 } 49 49 try { 50 50 setPendingDid(account.did) 51 - await resumeSession(account, true) 51 + await Promise.race([ 52 + resumeSession(account, true), 53 + new Promise<never>((_, reject) => 54 + setTimeout( 55 + () => reject(new Error('Session resume timed out')), 56 + 15_000, 57 + ), 58 + ), 59 + ]) 52 60 ax.metric('account:loggedIn', { 53 61 logContext: 'ChooseAccountForm', 54 62 withPassword: false, ··· 58 66 logger.error('choose account: initSession failed', { 59 67 message: e instanceof Error ? e.message : 'Unknown error', 60 68 }) 69 + Toast.show(_(msg`Sign in failed. Please try again.`)) 61 70 // Move to login form. 62 71 onSelectAccount(account) 63 72 } finally {
+152
src/screens/Login/LoginForm.web.tsx
··· 1 + import {useRef, useState} from 'react' 2 + import {Keyboard, LayoutAnimation, View} from 'react-native' 3 + import {type ComAtprotoServerDescribeServer} from '@atproto/api' 4 + import {msg} from '@lingui/core/macro' 5 + import {useLingui} from '@lingui/react' 6 + import {Trans} from '@lingui/react/macro' 7 + 8 + import {cleanError, isNetworkError} from '#/lib/strings/errors' 9 + import {logger} from '#/logger' 10 + import {getWebOAuthClient} from '#/state/session/oauth-web-client' 11 + import {atoms as a} from '#/alf' 12 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 13 + import {FormError} from '#/components/forms/FormError' 14 + import * as TextField from '#/components/forms/TextField' 15 + import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At' 16 + import {Loader} from '#/components/Loader' 17 + import {FormContainer} from './FormContainer' 18 + 19 + type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema 20 + 21 + /** 22 + * Web-specific LoginForm that uses OAuth handle-only flow. 23 + * On web, users enter their handle and are redirected to their PDS 24 + * authorization server for approval. 25 + * 26 + * Accepts the same props as the native LoginForm for compatibility with 27 + * Login/index.tsx, but only uses a subset of them. 28 + */ 29 + export const LoginForm = ({ 30 + error, 31 + initialHandle, 32 + setError, 33 + onPressBack, 34 + }: { 35 + error: string 36 + serviceUrl?: string | undefined 37 + serviceDescription: ServiceDescription | undefined 38 + initialHandle: string 39 + setError: (v: string) => void 40 + setServiceUrl: (v: string) => void 41 + onPressRetryConnect: () => void 42 + onPressBack: () => void 43 + onPressForgotPassword: () => void 44 + onAttemptSuccess: () => void 45 + onAttemptFailed: () => void 46 + debouncedResolveService: (identifier: string) => void 47 + isResolvingService: boolean 48 + }) => { 49 + const [isProcessing, setIsProcessing] = useState<boolean>(false) 50 + const identifierValueRef = useRef<string>(initialHandle || '') 51 + const {_} = useLingui() 52 + 53 + const onPressNext = async () => { 54 + if (isProcessing) return 55 + Keyboard.dismiss() 56 + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) 57 + setError('') 58 + 59 + const identifier = identifierValueRef.current.trim() 60 + 61 + if (!identifier) { 62 + setError(_(msg`Please enter your username or handle`)) 63 + return 64 + } 65 + 66 + setIsProcessing(true) 67 + 68 + try { 69 + const client = getWebOAuthClient() 70 + await client.signIn(identifier) 71 + // Browser will redirect to authorization server 72 + } catch (e: any) { 73 + const errMsg = e.toString() 74 + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) 75 + setIsProcessing(false) 76 + if (isNetworkError(e)) { 77 + logger.warn('Failed to start OAuth sign-in due to network error', { 78 + error: errMsg, 79 + }) 80 + setError( 81 + _( 82 + msg`Unable to contact your service. Please check your Internet connection.`, 83 + ), 84 + ) 85 + } else { 86 + logger.warn('Failed to start OAuth sign-in', {error: errMsg}) 87 + setError(cleanError(errMsg)) 88 + } 89 + } 90 + } 91 + 92 + return ( 93 + <FormContainer testID="loginForm" titleText={<Trans>Sign in</Trans>}> 94 + <View> 95 + <TextField.LabelText> 96 + <Trans>Account</Trans> 97 + </TextField.LabelText> 98 + <View style={[a.gap_sm]}> 99 + <TextField.Root> 100 + <TextField.Icon icon={At} /> 101 + <TextField.Input 102 + testID="loginUsernameInput" 103 + label={_(msg`Username or handle`)} 104 + autoCapitalize="none" 105 + autoFocus 106 + autoCorrect={false} 107 + autoComplete="username" 108 + returnKeyType="done" 109 + textContentType="username" 110 + defaultValue={initialHandle || ''} 111 + onChangeText={v => { 112 + identifierValueRef.current = v 113 + }} 114 + onSubmitEditing={onPressNext} 115 + blurOnSubmit={false} 116 + editable={!isProcessing} 117 + accessibilityHint={_( 118 + msg`Enter your handle (e.g. alice.bsky.social)`, 119 + )} 120 + /> 121 + </TextField.Root> 122 + </View> 123 + </View> 124 + <FormError error={error} /> 125 + <View style={[a.flex_row, a.align_center, a.pt_md]}> 126 + <Button 127 + label={_(msg`Back`)} 128 + variant="solid" 129 + color="secondary" 130 + size="large" 131 + onPress={onPressBack}> 132 + <ButtonText> 133 + <Trans>Back</Trans> 134 + </ButtonText> 135 + </Button> 136 + <View style={a.flex_1} /> 137 + <Button 138 + testID="loginNextButton" 139 + label={_(msg`Sign in`)} 140 + accessibilityHint={_(msg`Redirects to your authorization server`)} 141 + color="primary" 142 + size="large" 143 + onPress={onPressNext}> 144 + <ButtonText> 145 + <Trans>Sign in</Trans> 146 + </ButtonText> 147 + {isProcessing && <ButtonIcon icon={Loader} />} 148 + </Button> 149 + </View> 150 + </FormContainer> 151 + ) 152 + }
+4 -1
src/screens/Login/index.tsx
··· 20 20 import {atoms as a, native} from '#/alf' 21 21 import {ScreenTransition} from '#/components/ScreenTransition' 22 22 import {useAnalytics} from '#/analytics' 23 + import {IS_WEB} from '#/env' 23 24 import {ChooseAccountForm} from './ChooseAccountForm' 24 25 import * as AuthLayout from './components/AuthLayout' 25 26 import {AuthLayoutNavigationContext} from './components/AuthLayout/context' ··· 186 187 switch (currentForm) { 187 188 case Forms.Login: 188 189 title = _(msg`Sign in`) 189 - description = _(msg`Enter your username and password`) 190 + description = IS_WEB 191 + ? _(msg`Enter your handle to sign in`) 192 + : _(msg`Enter your username and password`) 190 193 goBack = () => 191 194 accounts.length ? gotoForm(Forms.ChooseAccount) : handlePressBack() 192 195 content = (
+12 -4
src/screens/Settings/components/ChangeHandleDialog.tsx
··· 64 64 function ChangeHandleDialogInner() { 65 65 const control = Dialog.useDialogContext() 66 66 const {_} = useLingui() 67 - const agent = useAgent() 67 + const {currentAccount} = useSession() 68 68 const enableSquareButtons = useEnableSquareButtons() 69 69 const { 70 70 data: serviceInfo, 71 71 error: serviceInfoError, 72 72 refetch, 73 - } = useServiceQuery(agent.serviceUrl.toString()) 73 + } = useServiceQuery(currentAccount!.service) 74 74 75 75 const [page, setPage] = useState<'provided-handle' | 'own-handle'>( 76 76 'provided-handle', ··· 175 175 queryKey: RQKEY_PROFILE(currentAccount.did), 176 176 }) 177 177 } 178 - agent.resumeSession(agent.session!).then(() => control.close()) 178 + if ('resumeSession' in agent && agent.session) { 179 + agent.resumeSession(agent.session).then(() => control.close()) 180 + } else { 181 + control.close() 182 + } 179 183 }, 180 184 }) 181 185 ··· 330 334 queryKey: RQKEY_PROFILE(currentAccount.did), 331 335 }) 332 336 } 333 - agent.resumeSession(agent.session!).then(() => control.close()) 337 + if ('resumeSession' in agent && agent.session) { 338 + agent.resumeSession(agent.session).then(() => control.close()) 339 + } else { 340 + control.close() 341 + } 334 342 }, 335 343 }) 336 344
+1
src/state/persisted/schema.ts
··· 30 30 status: z.string().optional(), 31 31 pdsUrl: z.string().optional(), 32 32 isSelfHosted: z.boolean().optional(), 33 + isOauthSession: z.boolean().optional(), 33 34 }) 34 35 export type PersistedAccount = z.infer<typeof accountSchema> 35 36
+32 -10
src/state/session/index.tsx
··· 25 25 pdsAgent, 26 26 sessionAccountToSession, 27 27 } from './agent' 28 + import { 29 + type OauthBskyAppAgent, 30 + oauthCreateAgent, 31 + oauthResumeSession, 32 + } from './oauth-agent' 28 33 import {type Action, getInitialState, reducer, type State} from './reducer' 29 34 export {isSignupQueued} from './util' 30 35 import {addSessionDebugLog} from './logging' ··· 158 163 async (params, logContext) => { 159 164 addSessionDebugLog({type: 'method:start', method: 'login'}) 160 165 const signal = cancelPendingTask() 161 - const {agent, account} = await createAgentAndLogin( 162 - params, 163 - onAgentSessionChange, 164 - ) 166 + 167 + let agentAccount: { 168 + agent: BskyAppAgent | OauthBskyAppAgent 169 + account: persisted.PersistedAccount 170 + } 171 + if (params.oauthSession) { 172 + agentAccount = await oauthCreateAgent(params.oauthSession) 173 + } else { 174 + agentAccount = await createAgentAndLogin(params, onAgentSessionChange) 175 + } 176 + const {agent, account} = agentAccount 165 177 166 178 if (signal.aborted) { 167 179 return ··· 173 185 }) 174 186 ax.metric( 175 187 'account:loggedIn', 176 - {logContext, withPassword: true}, 188 + {logContext, withPassword: !params.oauthSession}, 177 189 {session: utils.accountToSessionMetadata(account)}, 178 190 ) 179 191 addSessionDebugLog({type: 'method:end', method: 'login', account}) ··· 253 265 account: storedAccount, 254 266 }) 255 267 const signal = cancelPendingTask() 256 - const {agent, account} = await createAgentAndResume( 257 - storedAccount, 258 - onAgentSessionChange, 259 - ) 268 + 269 + let agentAccount: { 270 + agent: BskyAppAgent | OauthBskyAppAgent 271 + account: persisted.PersistedAccount 272 + } 273 + if (storedAccount.isOauthSession) { 274 + agentAccount = await oauthResumeSession(storedAccount) 275 + } else { 276 + agentAccount = await createAgentAndResume( 277 + storedAccount, 278 + onAgentSessionChange, 279 + ) 280 + } 281 + const {agent, account} = agentAccount 260 282 261 283 if (signal.aborted) { 262 284 return ··· 467 489 } 468 490 469 491 return useMemo(() => { 470 - return (agent as BskyAppAgent).cloneWithoutProxy() 492 + return (agent as BskyAppAgent | OauthBskyAppAgent).cloneWithoutProxy() 471 493 }, [agent]) 472 494 }
+3 -2
src/state/session/moderation.ts
··· 1 + import {type Agent} from '@atproto/api' 1 2 import {BSKY_LABELER_DID, BskyAgent} from '@atproto/api' 2 3 3 4 import {IS_TEST_USER} from '#/lib/constants' ··· 14 15 } 15 16 16 17 export async function configureModerationForAccount( 17 - agent: BskyAgent, 18 + agent: Agent | BskyAgent, 18 19 account: SessionAccount, 19 20 ) { 20 21 // This global mutation is *only* OK because this code is only relevant for testing. ··· 44 45 }) 45 46 } 46 47 47 - async function trySwitchToTestAppLabeler(agent: BskyAgent) { 48 + async function trySwitchToTestAppLabeler(agent: Agent | BskyAgent) { 48 49 const did = ( 49 50 await agent 50 51 .resolveHandle({handle: 'mod-authority.test'})
+140
src/state/session/oauth-agent.ts
··· 1 + import {Agent, type AtpSessionData} from '@atproto/api' 2 + import {type OutputSchema} from '@atproto/api/dist/client/types/com/atproto/server/getSession' 3 + import {type OAuthSession} from '@atproto/oauth-client-browser' 4 + 5 + import {BLUESKY_PROXY_HEADER, BSKY_SERVICE} from '#/lib/constants' 6 + import {logger} from '#/logger' 7 + import {sessionAccountToSession} from './agent' 8 + import {configureModerationForAccount} from './moderation' 9 + import {getWebOAuthClient} from './oauth-web-client' 10 + import {type SessionAccount} from './types' 11 + 12 + export async function oauthCreateAgent(session: OAuthSession) { 13 + const agent = new OauthBskyAppAgent(session) 14 + const account = await oauthAgentAndSessionToSessionAccountOrThrow( 15 + agent, 16 + session, 17 + ) 18 + const gates = Promise.resolve() 19 + const moderation = configureModerationForAccount(agent, account) 20 + return agent.prepare(account, gates, moderation) 21 + } 22 + 23 + const OAUTH_RESTORE_TIMEOUT_MS = 10_000 24 + 25 + export async function oauthResumeSession(account: SessionAccount) { 26 + const client = getWebOAuthClient() 27 + let session: OAuthSession 28 + try { 29 + session = await Promise.race([ 30 + client.restore(account.did), 31 + new Promise<never>((_, reject) => 32 + setTimeout( 33 + () => reject(new Error('OAuth session restore timed out')), 34 + OAUTH_RESTORE_TIMEOUT_MS, 35 + ), 36 + ), 37 + ]) 38 + } catch (e) { 39 + logger.error('oauthResumeSession: restore failed', { 40 + did: account.did, 41 + error: e instanceof Error ? e.message : String(e), 42 + }) 43 + throw e 44 + } 45 + return await oauthCreateAgent(session) 46 + } 47 + 48 + export async function oauthAgentAndSessionToSessionAccountOrThrow( 49 + agent: Agent, 50 + session: OAuthSession, 51 + ): Promise<SessionAccount> { 52 + const account = await oauthAgentAndSessionToSessionAccount(agent, session) 53 + if (!account) { 54 + throw Error('Expected an active session') 55 + } 56 + return account 57 + } 58 + 59 + export async function oauthAgentAndSessionToSessionAccount( 60 + agent: Agent, 61 + session: OAuthSession, 62 + ): Promise<SessionAccount | undefined> { 63 + let data: OutputSchema 64 + try { 65 + const res = await Promise.race([ 66 + agent.com.atproto.server.getSession(), 67 + new Promise<never>((_, reject) => 68 + setTimeout( 69 + () => reject(new Error('getSession timed out')), 70 + OAUTH_RESTORE_TIMEOUT_MS, 71 + ), 72 + ), 73 + ]) 74 + data = res.data 75 + } catch (e: any) { 76 + logger.error('oauthAgentAndSessionToSessionAccount: getSession failed', e) 77 + return undefined 78 + } 79 + let aud: string 80 + try { 81 + const tokenInfo = await Promise.race([ 82 + session.getTokenInfo(false), 83 + new Promise<never>((_, reject) => 84 + setTimeout( 85 + () => reject(new Error('getTokenInfo timed out')), 86 + OAUTH_RESTORE_TIMEOUT_MS, 87 + ), 88 + ), 89 + ]) 90 + aud = tokenInfo.aud 91 + } catch (e: any) { 92 + logger.error('oauthAgentAndSessionToSessionAccount: getTokenInfo failed', e) 93 + return undefined 94 + } 95 + return { 96 + service: session.serverMetadata.issuer, 97 + did: session.did, 98 + handle: data.handle, 99 + email: data.email, 100 + emailConfirmed: data.emailConfirmed, 101 + emailAuthFactor: data.emailAuthFactor, 102 + active: data.active, 103 + status: data.status, 104 + pdsUrl: aud, 105 + isSelfHosted: !session.server.issuer.startsWith(BSKY_SERVICE), 106 + isOauthSession: true, 107 + } 108 + } 109 + 110 + export class OauthBskyAppAgent extends Agent { 111 + session?: AtpSessionData 112 + dispatchUrl?: string 113 + 114 + constructor(session: OAuthSession) { 115 + super(session) 116 + } 117 + 118 + async prepare( 119 + account: SessionAccount, 120 + gates: Promise<void>, 121 + moderation: Promise<void>, 122 + ) { 123 + this.session = sessionAccountToSession(account) 124 + this.dispatchUrl = account.pdsUrl 125 + this.configureProxy(BLUESKY_PROXY_HEADER.get()) 126 + 127 + await Promise.all([gates, moderation]) 128 + 129 + return {account, agent: this} 130 + } 131 + 132 + dispose() {} 133 + 134 + cloneWithoutProxy(): OauthBskyAppAgent { 135 + const cloned = new OauthBskyAppAgent(this.sessionManager as OAuthSession) 136 + cloned.session = this.session 137 + cloned.configureProxy(null) 138 + return cloned 139 + } 140 + }
+72
src/state/session/oauth-web-client.ts
··· 1 + import {BrowserOAuthClient} from '@atproto/oauth-client-browser' 2 + 3 + const OAUTH_BASE_URL: string = 4 + process.env.EXPO_PUBLIC_OAUTH_BASE_URL || 'https://witchsky.app' 5 + 6 + const OAUTH_CLIENT_NAME: string = 7 + process.env.EXPO_PUBLIC_OAUTH_CLIENT_NAME || 'Witchsky' 8 + 9 + const OAUTH_SCOPE = 10 + 'atproto transition:generic transition:email transition:chat.bsky identity:handle account:email?action=manage account:status?action=manage' 11 + 12 + function isLoopback() { 13 + if (typeof window === 'undefined') return false 14 + const host = window.location.hostname 15 + return ( 16 + host === 'localhost' || 17 + host === '127.0.0.1' || 18 + host === '[::1]' || 19 + host === '::1' 20 + ) 21 + } 22 + 23 + const BSKY_OAUTH_CLIENT = createWebOAuthClient() 24 + 25 + function createWebOAuthClient() { 26 + if (isLoopback()) { 27 + // Loopback client: encode scope and redirect_uri in the client_id URL. 28 + // The authorization server uses hardcoded metadata for http://localhost 29 + // client_ids. Without explicit scope, only "atproto" is granted, which 30 + // lacks the transition:* scopes needed for appview/chat APIs. 31 + const port = window.location.port ? `:${window.location.port}` : '' 32 + const redirectUri = `http://127.0.0.1${port}/` 33 + const clientId = 34 + `http://localhost` + 35 + `?redirect_uri=${encodeURIComponent(redirectUri)}` + 36 + `&scope=${encodeURIComponent(OAUTH_SCOPE)}` 37 + 38 + return new BrowserOAuthClient({ 39 + clientMetadata: { 40 + client_id: clientId, 41 + redirect_uris: [redirectUri], 42 + scope: OAUTH_SCOPE, 43 + token_endpoint_auth_method: 'none', 44 + response_types: ['code'], 45 + grant_types: ['authorization_code', 'refresh_token'], 46 + application_type: 'web', 47 + dpop_bound_access_tokens: true, 48 + }, 49 + handleResolver: 'https://bsky.social', 50 + }) 51 + } 52 + 53 + return new BrowserOAuthClient({ 54 + clientMetadata: { 55 + client_id: `${OAUTH_BASE_URL}/oauth-client-metadata.json`, 56 + client_name: OAUTH_CLIENT_NAME, 57 + client_uri: OAUTH_BASE_URL, 58 + redirect_uris: [`${OAUTH_BASE_URL}/auth/web/callback`], 59 + scope: OAUTH_SCOPE, 60 + token_endpoint_auth_method: 'none', 61 + response_types: ['code'], 62 + grant_types: ['authorization_code', 'refresh_token'], 63 + application_type: 'web', 64 + dpop_bound_access_tokens: true, 65 + }, 66 + handleResolver: 'https://bsky.social', 67 + }) 68 + } 69 + 70 + export function getWebOAuthClient() { 71 + return BSKY_OAUTH_CLIENT 72 + }
+1 -1
src/state/session/reducer.ts
··· 10 10 // A hack so that the reducer can't read anything from the agent. 11 11 // From the reducer's point of view, it should be a completely opaque object. 12 12 type OpaqueBskyAgent = { 13 - readonly service: URL 13 + readonly service?: URL | undefined 14 14 readonly api: unknown 15 15 readonly app: unknown 16 16 readonly com: unknown
+3
src/state/session/types.ts
··· 1 + import {type OAuthSession} from '@atproto/oauth-client-browser' 2 + 1 3 import {type PersistedAccount} from '#/state/persisted' 2 4 import {type Metrics} from '#/analytics/metrics' 3 5 ··· 29 31 identifier: string 30 32 password: string 31 33 authFactorToken?: string | undefined 34 + oauthSession?: OAuthSession 32 35 }, 33 36 logContext: Metrics['account:loggedIn']['logContext'], 34 37 ) => Promise<void>
+140
yarn.lock
··· 20 20 "@jridgewell/gen-mapping" "^0.3.0" 21 21 "@jridgewell/trace-mapping" "^0.3.9" 22 22 23 + "@atproto-labs/did-resolver@0.2.6", "@atproto-labs/did-resolver@^0.2.6": 24 + version "0.2.6" 25 + resolved "https://registry.yarnpkg.com/@atproto-labs/did-resolver/-/did-resolver-0.2.6.tgz#15f0beab797187a67279389f6503f87a257cd898" 26 + integrity sha512-2K1bC04nI2fmgNcvof+yA28IhGlpWn2JKYlPa7To9JTKI45FINCGkQSGiL2nyXlyzDJJ34fZ1aq6/IRFIOIiqg== 27 + dependencies: 28 + "@atproto-labs/fetch" "0.2.3" 29 + "@atproto-labs/pipe" "0.1.1" 30 + "@atproto-labs/simple-store" "0.3.0" 31 + "@atproto-labs/simple-store-memory" "0.1.4" 32 + "@atproto/did" "0.3.0" 33 + zod "^3.23.8" 34 + 35 + "@atproto-labs/fetch@0.2.3", "@atproto-labs/fetch@^0.2.3": 36 + version "0.2.3" 37 + resolved "https://registry.yarnpkg.com/@atproto-labs/fetch/-/fetch-0.2.3.tgz#d47afec078f630c50e291c56264cc0ff13d0c6cc" 38 + integrity sha512-NZtbJOCbxKUFRFKMpamT38PUQMY0hX0p7TG5AEYOPhZKZEP7dHZ1K2s1aB8MdVH0qxmqX7nQleNrrvLf09Zfdw== 39 + dependencies: 40 + "@atproto-labs/pipe" "0.1.1" 41 + 42 + "@atproto-labs/handle-resolver@0.3.6", "@atproto-labs/handle-resolver@^0.3.6": 43 + version "0.3.6" 44 + resolved "https://registry.yarnpkg.com/@atproto-labs/handle-resolver/-/handle-resolver-0.3.6.tgz#bb2a5435995c4c4ddf75065a47417975c4b4a003" 45 + integrity sha512-qnSTXvOBNj1EHhp2qTWSX8MS5q3AwYU5LKlt5fBvSbCjgmTr2j0URHCv+ydrwO55KvsojIkTMgeMOh4YuY4fCA== 46 + dependencies: 47 + "@atproto-labs/simple-store" "0.3.0" 48 + "@atproto-labs/simple-store-memory" "0.1.4" 49 + "@atproto/did" "0.3.0" 50 + zod "^3.23.8" 51 + 52 + "@atproto-labs/identity-resolver@^0.3.6": 53 + version "0.3.6" 54 + resolved "https://registry.yarnpkg.com/@atproto-labs/identity-resolver/-/identity-resolver-0.3.6.tgz#bdb33099bda7c2eed64a8b1f92b8e493f960965b" 55 + integrity sha512-qoWqBDRobln0NR8L8dQjSp79E0chGkBhibEgxQa2f9WD+JbJdjQ0YvwwO5yeQn05pJoJmAwmI2wyJ45zjU7aWg== 56 + dependencies: 57 + "@atproto-labs/did-resolver" "0.2.6" 58 + "@atproto-labs/handle-resolver" "0.3.6" 59 + 60 + "@atproto-labs/pipe@0.1.1": 61 + version "0.1.1" 62 + resolved "https://registry.yarnpkg.com/@atproto-labs/pipe/-/pipe-0.1.1.tgz#1c4232d16bf95f251e993cb6ee440f9aa4e87ce6" 63 + integrity sha512-hdNw2oUs2B6BN1lp+32pF7cp8EMKuIN5Qok2Vvv/aOpG/3tNSJ9YkvfI0k6Zd188LeDDYRUpYpxcoFIcGH/FNg== 64 + 65 + "@atproto-labs/simple-store-memory@0.1.4", "@atproto-labs/simple-store-memory@^0.1.4": 66 + version "0.1.4" 67 + resolved "https://registry.yarnpkg.com/@atproto-labs/simple-store-memory/-/simple-store-memory-0.1.4.tgz#e38c7b27e0f77c0bdba1329deb89593fbec27316" 68 + integrity sha512-3mKY4dP8I7yKPFj9VKpYyCRzGJOi5CEpOLPlRhoJyLmgs3J4RzDrjn323Oakjz2Aj2JzRU/AIvWRAZVhpYNJHw== 69 + dependencies: 70 + "@atproto-labs/simple-store" "0.3.0" 71 + lru-cache "^10.2.0" 72 + 73 + "@atproto-labs/simple-store@0.3.0", "@atproto-labs/simple-store@^0.3.0": 74 + version "0.3.0" 75 + resolved "https://registry.yarnpkg.com/@atproto-labs/simple-store/-/simple-store-0.3.0.tgz#65c0a5c949fe6c8dc3bdaf13ab40848f20073593" 76 + integrity sha512-nOb6ONKBRJHRlukW1sVawUkBqReLlLx6hT35VS3imaNPwiXDxLnTK7lxw3Lrl9k5yugSBDQAkZAq3MPTEFSUBQ== 77 + 23 78 "@atproto/api@^0.19.6": 24 79 version "0.19.6" 25 80 resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.19.6.tgz#c8fae3d792fe429c900ac0ba2609d60b9a89e28b" ··· 44 99 "@atproto/syntax" "^0.5.1" 45 100 zod "^3.23.8" 46 101 102 + "@atproto/did@0.3.0", "@atproto/did@^0.3.0": 103 + version "0.3.0" 104 + resolved "https://registry.yarnpkg.com/@atproto/did/-/did-0.3.0.tgz#0f6b11a5119672a41075fa58e97956b296ac171c" 105 + integrity sha512-raUPzUGegtW/6OxwCmM8bhZvuIMzxG5t9oWsth6Tp91Kb5fTnHV2h/KKNF1C82doeA4BdXCErTyg7ISwLbQkzA== 106 + dependencies: 107 + zod "^3.23.8" 108 + 109 + "@atproto/jwk-jose@0.1.11": 110 + version "0.1.11" 111 + resolved "https://registry.yarnpkg.com/@atproto/jwk-jose/-/jwk-jose-0.1.11.tgz#ef64bce940a66e267fc3cf0db8df4dbd062bb28a" 112 + integrity sha512-i4Fnr2sTBYmMmHXl7NJh8GrCH+tDQEVWrcDMDnV5DjJfkgT17wIqvojIw9SNbSL4Uf0OtfEv6AgG0A+mgh8b5Q== 113 + dependencies: 114 + "@atproto/jwk" "0.6.0" 115 + jose "^5.2.0" 116 + 117 + "@atproto/jwk-webcrypto@^0.2.0": 118 + version "0.2.0" 119 + resolved "https://registry.yarnpkg.com/@atproto/jwk-webcrypto/-/jwk-webcrypto-0.2.0.tgz#31c88f350843b1a8e8d0cb422c1cb02f5dd59137" 120 + integrity sha512-UmgRrrEAkWvxwhlwe30UmDOdTEFidlIzBC7C3cCbeJMcBN1x8B3KH+crXrsTqfWQBG58mXgt8wgSK3Kxs2LhFg== 121 + dependencies: 122 + "@atproto/jwk" "0.6.0" 123 + "@atproto/jwk-jose" "0.1.11" 124 + zod "^3.23.8" 125 + 126 + "@atproto/jwk@0.6.0", "@atproto/jwk@^0.6.0": 127 + version "0.6.0" 128 + resolved "https://registry.yarnpkg.com/@atproto/jwk/-/jwk-0.6.0.tgz#e813f77d9c89c025d4074340777fafaa2fba08a5" 129 + integrity sha512-bDoJPvt7TrQVi/rBfBrSSpGykhtIriKxeYCYQTiPRKFfyRhbgpElF0wPXADjIswnbzZdOwbY63az4E/CFVT3Tw== 130 + dependencies: 131 + multiformats "^9.9.0" 132 + zod "^3.23.8" 133 + 47 134 "@atproto/lex-data@^0.0.14": 48 135 version "0.0.14" 49 136 resolved "https://registry.yarnpkg.com/@atproto/lex-data/-/lex-data-0.0.14.tgz#2f2f3c64699925a0d4785e5afd0e7731ba1d46c0" ··· 71 158 "@atproto/syntax" "^0.5.0" 72 159 iso-datestring-validator "^2.2.2" 73 160 multiformats "^9.9.0" 161 + zod "^3.23.8" 162 + 163 + "@atproto/oauth-client-browser@^0.3.41": 164 + version "0.3.41" 165 + resolved "https://registry.yarnpkg.com/@atproto/oauth-client-browser/-/oauth-client-browser-0.3.41.tgz#b740d8a194059cfddae49b284a79089ab24b0efe" 166 + integrity sha512-4QTm8zPgm08vl53flrVmL+MS5IOhvWWctNZmEnPbvQ2t1ISw9Q5m815m2Sszi5ULMFjOqvT7lhKB7zQUn5gq5g== 167 + dependencies: 168 + "@atproto-labs/did-resolver" "^0.2.6" 169 + "@atproto-labs/handle-resolver" "^0.3.6" 170 + "@atproto-labs/simple-store" "^0.3.0" 171 + "@atproto/did" "^0.3.0" 172 + "@atproto/jwk" "^0.6.0" 173 + "@atproto/jwk-webcrypto" "^0.2.0" 174 + "@atproto/oauth-client" "^0.6.0" 175 + "@atproto/oauth-types" "^0.6.3" 176 + core-js "^3" 177 + 178 + "@atproto/oauth-client@^0.6.0": 179 + version "0.6.0" 180 + resolved "https://registry.yarnpkg.com/@atproto/oauth-client/-/oauth-client-0.6.0.tgz#efc729825ed0a6464dd9da0f75cd62b9fb069a19" 181 + integrity sha512-F7ZTKzFptXgyihMkd7QTdRSkrh4XqrS+qTw+V81k5Q6Bh3MB1L3ypvfSJ6v7SSUJa6XxoZYJTCahHC1e+ndE6Q== 182 + dependencies: 183 + "@atproto-labs/did-resolver" "^0.2.6" 184 + "@atproto-labs/fetch" "^0.2.3" 185 + "@atproto-labs/handle-resolver" "^0.3.6" 186 + "@atproto-labs/identity-resolver" "^0.3.6" 187 + "@atproto-labs/simple-store" "^0.3.0" 188 + "@atproto-labs/simple-store-memory" "^0.1.4" 189 + "@atproto/did" "^0.3.0" 190 + "@atproto/jwk" "^0.6.0" 191 + "@atproto/oauth-types" "^0.6.3" 192 + "@atproto/xrpc" "^0.7.7" 193 + core-js "^3" 194 + multiformats "^9.9.0" 195 + zod "^3.23.8" 196 + 197 + "@atproto/oauth-types@^0.6.3": 198 + version "0.6.3" 199 + resolved "https://registry.yarnpkg.com/@atproto/oauth-types/-/oauth-types-0.6.3.tgz#4fc996d0af61874830079d1b1bddc7c0774ad6b2" 200 + integrity sha512-jdKuoPknJuh/WjI+mYk7agSbx9mNVMbS6Dr3k1z2YMY2oRiCQjxYBuo4MLKATbxj05nMQaZRWlHRUazoAu5Cng== 201 + dependencies: 202 + "@atproto/did" "^0.3.0" 203 + "@atproto/jwk" "^0.6.0" 74 204 zod "^3.23.8" 75 205 76 206 "@atproto/syntax@^0.5.0", "@atproto/syntax@^0.5.1": ··· 7417 7547 resolved "https://registry.yarnpkg.com/core-js-pure/-/core-js-pure-3.32.1.tgz#5775b88f9062885f67b6d7edce59984e89d276f3" 7418 7548 integrity sha512-f52QZwkFVDPf7UEQZGHKx6NYxsxmVGJe5DIvbzOdRMJlmT6yv0KDjR8rmy3ngr/t5wU54c7Sp/qIJH0ppbhVpQ== 7419 7549 7550 + core-js@^3: 7551 + version "3.49.0" 7552 + resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.49.0.tgz#8b4d520ac034311fa21aa616f017ada0e0dbbddd" 7553 + integrity sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg== 7554 + 7420 7555 core-util-is@~1.0.0: 7421 7556 version "1.0.3" 7422 7557 resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85" ··· 11472 11607 version "2.6.1" 11473 11608 resolved "https://registry.yarnpkg.com/jiti/-/jiti-2.6.1.tgz#178ef2fc9a1a594248c20627cd820187a4d78d92" 11474 11609 integrity sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ== 11610 + 11611 + jose@^5.2.0: 11612 + version "5.10.0" 11613 + resolved "https://registry.yarnpkg.com/jose/-/jose-5.10.0.tgz#c37346a099d6467c401351a9a0c2161e0f52c4be" 11614 + integrity sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg== 11475 11615 11476 11616 js-sha256@^0.10.1: 11477 11617 version "0.10.1"