Bluesky app fork with some witchin' additions 💫
0
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"