diff --git a/Dockerfile b/Dockerfile index 371e8402c..029918f60 100644 --- a/Dockerfile +++ b/Dockerfile @@ -42,6 +42,14 @@ ENV SENTRY_AUTH_TOKEN=${SENTRY_AUTH_TOKEN:-unknown} ARG EXPO_PUBLIC_SENTRY_DSN ENV EXPO_PUBLIC_SENTRY_DSN=$EXPO_PUBLIC_SENTRY_DSN +# +# OAuth +# +ARG EXPO_PUBLIC_OAUTH_BASE_URL +ENV EXPO_PUBLIC_OAUTH_BASE_URL=${EXPO_PUBLIC_OAUTH_BASE_URL:-https://witchsky.app} +ARG EXPO_PUBLIC_OAUTH_CLIENT_NAME +ENV EXPO_PUBLIC_OAUTH_CLIENT_NAME=${EXPO_PUBLIC_OAUTH_CLIENT_NAME:-Witchsky} + # # Copy everything into the container # @@ -65,6 +73,8 @@ RUN \. "$NVM_DIR/nvm.sh" && \ echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$EXPO_PUBLIC_BUNDLE_IDENTIFIER" >> .env && \ echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env && \ echo "EXPO_PUBLIC_SENTRY_DSN=$EXPO_PUBLIC_SENTRY_DSN" >> .env && \ + echo "EXPO_PUBLIC_OAUTH_BASE_URL=$EXPO_PUBLIC_OAUTH_BASE_URL" >> .env && \ + echo "EXPO_PUBLIC_OAUTH_CLIENT_NAME=$EXPO_PUBLIC_OAUTH_CLIENT_NAME" >> .env && \ npm install --global yarn && \ yarn && \ yarn intl:build 2>&1 | tee i18n.log && \ diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go index 8ad88ceb5..a2b4dc7e5 100644 --- a/bskyweb/cmd/bskyweb/server.go +++ b/bskyweb/cmd/bskyweb/server.go @@ -240,6 +240,12 @@ func serve(cctx *cli.Context) error { e.GET("/robots.txt", echo.WrapHandler(staticHandler)) } + // OAuth client metadata (generated dynamically from request host) + e.GET("/oauth-client-metadata.json", server.OAuthClientMetadata) + + // OAuth callback (serves SPA so React handles it client-side) + e.GET("/auth/web/callback", server.WebGeneric) + e.GET("/iframe/*", echo.WrapHandler(staticHandler)) e.GET("/static/*", echo.WrapHandler(http.StripPrefix("/static/", staticHandler)), func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { @@ -469,6 +475,29 @@ func (srv *Server) LinkProxyMiddleware(url *url.URL) echo.MiddlewareFunc { } // handler for endpoint that have no specific server-side handling +func (srv *Server) OAuthClientMetadata(c echo.Context) error { + scheme := "https" + if c.Request().TLS == nil && strings.HasPrefix(c.Request().Host, "localhost") { + scheme = "http" + } + baseURL := fmt.Sprintf("%s://%s", scheme, c.Request().Host) + + metadata := map[string]interface{}{ + "client_id": baseURL + "/oauth-client-metadata.json", + "client_name": "Witchsky", + "client_uri": baseURL, + "redirect_uris": []string{baseURL + "/auth/web/callback"}, + "scope": "atproto transition:generic transition:email transition:chat.bsky identity:handle account:email?action=manage account:status?action=manage", + "token_endpoint_auth_method": "none", + "response_types": []string{"code"}, + "grant_types": []string{"authorization_code", "refresh_token"}, + "application_type": "web", + "dpop_bound_access_tokens": true, + } + + return c.JSON(http.StatusOK, metadata) +} + func (srv *Server) WebGeneric(c echo.Context) error { data := srv.NewTemplateContext() return c.Render(http.StatusOK, "base.html", data) diff --git a/bskyweb/static/oauth-client-metadata.json b/bskyweb/static/oauth-client-metadata.json new file mode 100644 index 000000000..42c6aa394 --- /dev/null +++ b/bskyweb/static/oauth-client-metadata.json @@ -0,0 +1,12 @@ +{ + "client_id": "https://witchsky.app/oauth-client-metadata.json", + "client_name": "Witchsky", + "client_uri": "https://witchsky.app", + "redirect_uris": ["https://witchsky.app/auth/web/callback"], + "scope": "atproto transition:generic transition:email transition:chat.bsky identity:handle account:email?action=manage account:status?action=manage", + "token_endpoint_auth_method": "none", + "response_types": ["code"], + "grant_types": ["authorization_code", "refresh_token"], + "application_type": "web", + "dpop_bound_access_tokens": true +} diff --git a/package.json b/package.json index f4bbcb818..46f75c2b3 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,7 @@ }, "dependencies": { "@atproto/api": "^0.19.6", + "@atproto/oauth-client-browser": "^0.3.41", "@bitdrift/react-native": "^0.6.8", "@braintree/sanitize-url": "^6.0.2", "@bsky.app/alf": "^0.1.7", diff --git a/src/App.web.tsx b/src/App.web.tsx index a840437a1..a7cc3b210 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -39,6 +39,7 @@ import { useSession, useSessionApi, } from '#/state/session' +import {getWebOAuthClient} from '#/state/session/oauth-web-client' import {readLastActiveAccount} from '#/state/session/util' import {Provider as ShellStateProvider} from '#/state/shell' import {Provider as ComposerProvider} from '#/state/shell/composer' @@ -79,6 +80,26 @@ import {Splash} from '#/Splash' import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' import {Provider as HideBottomBarBorderProvider} from './lib/hooks/useHideBottomBarBorder' +// For local development: the OAuth loopback spec requires IP-based origins +// (127.0.0.1), not "localhost". The auth server redirects to 127.0.0.1, but +// IndexedDB is per-origin, so PKCE state stored on "localhost" is unreachable +// from "127.0.0.1". Redirect immediately so both signIn() and the callback +// use the same origin. +if (typeof window !== 'undefined' && window.location.hostname === 'localhost') { + const url = new URL(window.location.href) + url.hostname = '127.0.0.1' + window.location.replace(url.href) +} + +function hasOAuthCallbackParams(): boolean { + // OAuth callback params come in the hash fragment (response_mode=fragment) + // or query string. Check both for "state" + ("code" or "error"). + const hash = new URLSearchParams(window.location.hash.slice(1)) + const query = new URLSearchParams(window.location.search) + const params = hash.has('state') ? hash : query + return params.has('state') && (params.has('code') || params.has('error')) +} + /** * Begin geolocation ASAP */ @@ -90,15 +111,43 @@ void prefetchAppConfig() function InnerApp() { const [isReady, setIsReady] = useState(false) const {currentAccount} = useSession() - const {resumeSession} = useSessionApi() + const {resumeSession, login} = useSessionApi() const theme = useColorModeTheme() const {t: l} = useLingui() const hasCheckedReferrer = useStarterPackEntry() // init useEffect(() => { + // Safety valve: if onLaunch hangs (e.g. stale IndexedDB blocking an + // upgrade, or a never-settling promise), the app will still load after + // this timeout fires. + const safetyTimeout = setTimeout(() => { + logger.warn('session: onLaunch safety timeout fired, forcing ready state') + setIsReady(true) + }, 15_000) + async function onLaunch(account?: SessionAccount) { try { + // Check for OAuth callback params first (loopback redirects to /) + if (hasOAuthCallbackParams()) { + const client = getWebOAuthClient() + const result = await client.init() + if (result?.session) { + await login( + { + service: '', + identifier: '', + password: '', + oauthSession: result.session, + }, + 'LoginForm', + ) + // Clear hash fragment after processing + window.history.replaceState(null, '', window.location.pathname) + return + } + } + if (account) { await resumeSession(account) } else { @@ -107,12 +156,13 @@ function InnerApp() { } catch (e) { logger.error('session: resumeSession failed', {message: e}) } finally { + clearTimeout(safetyTimeout) setIsReady(true) } } const account = readLastActiveAccount() void onLaunch(account) - }, [resumeSession]) + }, [resumeSession, login]) useEffect(() => { return listenSessionDropped(() => { diff --git a/src/Navigation.tsx b/src/Navigation.tsx index f49f3afb2..a37a839d3 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -77,6 +77,7 @@ import {SharedPreferencesTesterScreen} from '#/screens/E2E/SharedPreferencesTest import {FindContactsFlowScreen} from '#/screens/FindContactsFlowScreen' import HashtagScreen from '#/screens/Hashtag' import {LogScreen} from '#/screens/Log' +import {AuthCallback} from '#/screens/Login/AuthCallback' import {MessagesScreen} from '#/screens/Messages/ChatList' import {MessagesConversationScreen} from '#/screens/Messages/Conversation' import {MessagesInboxScreen} from '#/screens/Messages/Inbox' @@ -190,6 +191,11 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) { getComponent={() => NotFoundScreen} options={{title: title(msg`Not Found`)}} /> + AuthCallback} + options={{title: title(msg`Signing in...`)}} + /> ({ Home: ['/', '/download'], + AuthCallback: '/auth/web/callback', Search: '/search', Feeds: '/feeds', Notifications: '/notifications', diff --git a/src/screens/Login/AuthCallback.tsx b/src/screens/Login/AuthCallback.tsx new file mode 100644 index 000000000..0de2a37bc --- /dev/null +++ b/src/screens/Login/AuthCallback.tsx @@ -0,0 +1,38 @@ +import {useEffect} from 'react' +import {useNavigation} from '@react-navigation/native' + +import {type NavigationProp} from '#/lib/routes/types' +import {logger} from '#/logger' +import {useSessionApi} from '#/state/session' +import {getWebOAuthClient} from '#/state/session/oauth-web-client' + +export function AuthCallback() { + const {login} = useSessionApi() + const navigation = useNavigation() + + useEffect(() => { + ;(async () => { + try { + const client = getWebOAuthClient() + const result = await client.init() + if (result?.session) { + await login( + { + service: '', + identifier: '', + password: '', + oauthSession: result.session, + }, + 'LoginForm', + ) + } + navigation.replace('Home') + } catch (e: any) { + logger.error('OAuth callback failed', {error: e.message}) + navigation.replace('Home') + } + })() + }, [login, navigation]) + + return null +} diff --git a/src/screens/Login/ChooseAccountForm.tsx b/src/screens/Login/ChooseAccountForm.tsx index 041e2e3b2..1faaf6991 100644 --- a/src/screens/Login/ChooseAccountForm.tsx +++ b/src/screens/Login/ChooseAccountForm.tsx @@ -36,7 +36,7 @@ export const ChooseAccountForm = ({ // The session API isn't resilient to race conditions so let's just ignore this. return } - if (!account.accessJwt) { + if (!account.isOauthSession && !account.accessJwt) { // Move to login form. onSelectAccount(account) return @@ -48,7 +48,15 @@ export const ChooseAccountForm = ({ } try { setPendingDid(account.did) - await resumeSession(account, true) + await Promise.race([ + resumeSession(account, true), + new Promise((_, reject) => + setTimeout( + () => reject(new Error('Session resume timed out')), + 15_000, + ), + ), + ]) ax.metric('account:loggedIn', { logContext: 'ChooseAccountForm', withPassword: false, @@ -58,6 +66,7 @@ export const ChooseAccountForm = ({ logger.error('choose account: initSession failed', { message: e instanceof Error ? e.message : 'Unknown error', }) + Toast.show(_(msg`Sign in failed. Please try again.`)) // Move to login form. onSelectAccount(account) } finally { diff --git a/src/screens/Login/LoginForm.web.tsx b/src/screens/Login/LoginForm.web.tsx new file mode 100644 index 000000000..cb0d1ce41 --- /dev/null +++ b/src/screens/Login/LoginForm.web.tsx @@ -0,0 +1,152 @@ +import {useRef, useState} from 'react' +import {Keyboard, LayoutAnimation, View} from 'react-native' +import {type ComAtprotoServerDescribeServer} from '@atproto/api' +import {msg} from '@lingui/core/macro' +import {useLingui} from '@lingui/react' +import {Trans} from '@lingui/react/macro' + +import {cleanError, isNetworkError} from '#/lib/strings/errors' +import {logger} from '#/logger' +import {getWebOAuthClient} from '#/state/session/oauth-web-client' +import {atoms as a} from '#/alf' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {FormError} from '#/components/forms/FormError' +import * as TextField from '#/components/forms/TextField' +import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At' +import {Loader} from '#/components/Loader' +import {FormContainer} from './FormContainer' + +type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema + +/** + * Web-specific LoginForm that uses OAuth handle-only flow. + * On web, users enter their handle and are redirected to their PDS + * authorization server for approval. + * + * Accepts the same props as the native LoginForm for compatibility with + * Login/index.tsx, but only uses a subset of them. + */ +export const LoginForm = ({ + error, + initialHandle, + setError, + onPressBack, +}: { + error: string + serviceUrl?: string | undefined + serviceDescription: ServiceDescription | undefined + initialHandle: string + setError: (v: string) => void + setServiceUrl: (v: string) => void + onPressRetryConnect: () => void + onPressBack: () => void + onPressForgotPassword: () => void + onAttemptSuccess: () => void + onAttemptFailed: () => void + debouncedResolveService: (identifier: string) => void + isResolvingService: boolean +}) => { + const [isProcessing, setIsProcessing] = useState(false) + const identifierValueRef = useRef(initialHandle || '') + const {_} = useLingui() + + const onPressNext = async () => { + if (isProcessing) return + Keyboard.dismiss() + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) + setError('') + + const identifier = identifierValueRef.current.trim() + + if (!identifier) { + setError(_(msg`Please enter your username or handle`)) + return + } + + setIsProcessing(true) + + try { + const client = getWebOAuthClient() + await client.signIn(identifier) + // Browser will redirect to authorization server + } catch (e: any) { + const errMsg = e.toString() + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) + setIsProcessing(false) + if (isNetworkError(e)) { + logger.warn('Failed to start OAuth sign-in due to network error', { + error: errMsg, + }) + setError( + _( + msg`Unable to contact your service. Please check your Internet connection.`, + ), + ) + } else { + logger.warn('Failed to start OAuth sign-in', {error: errMsg}) + setError(cleanError(errMsg)) + } + } + } + + return ( + Sign in}> + + + Account + + + + + { + identifierValueRef.current = v + }} + onSubmitEditing={onPressNext} + blurOnSubmit={false} + editable={!isProcessing} + accessibilityHint={_( + msg`Enter your handle (e.g. alice.bsky.social)`, + )} + /> + + + + + + + + + + + ) +} diff --git a/src/screens/Login/index.tsx b/src/screens/Login/index.tsx index 9275ff038..f3e7e5b9b 100644 --- a/src/screens/Login/index.tsx +++ b/src/screens/Login/index.tsx @@ -20,6 +20,7 @@ import {SetNewPasswordForm} from '#/screens/Login/SetNewPasswordForm' import {atoms as a, native} from '#/alf' import {ScreenTransition} from '#/components/ScreenTransition' import {useAnalytics} from '#/analytics' +import {IS_WEB} from '#/env' import {ChooseAccountForm} from './ChooseAccountForm' import * as AuthLayout from './components/AuthLayout' import {AuthLayoutNavigationContext} from './components/AuthLayout/context' @@ -186,7 +187,9 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => { switch (currentForm) { case Forms.Login: title = _(msg`Sign in`) - description = _(msg`Enter your username and password`) + description = IS_WEB + ? _(msg`Enter your handle to sign in`) + : _(msg`Enter your username and password`) goBack = () => accounts.length ? gotoForm(Forms.ChooseAccount) : handlePressBack() content = ( diff --git a/src/screens/Settings/components/ChangeHandleDialog.tsx b/src/screens/Settings/components/ChangeHandleDialog.tsx index 8be12f898..1d3c1d941 100644 --- a/src/screens/Settings/components/ChangeHandleDialog.tsx +++ b/src/screens/Settings/components/ChangeHandleDialog.tsx @@ -64,13 +64,13 @@ export function ChangeHandleDialog({ function ChangeHandleDialogInner() { const control = Dialog.useDialogContext() const {_} = useLingui() - const agent = useAgent() + const {currentAccount} = useSession() const enableSquareButtons = useEnableSquareButtons() const { data: serviceInfo, error: serviceInfoError, refetch, - } = useServiceQuery(agent.serviceUrl.toString()) + } = useServiceQuery(currentAccount!.service) const [page, setPage] = useState<'provided-handle' | 'own-handle'>( 'provided-handle', @@ -175,7 +175,11 @@ function ProvidedHandlePage({ queryKey: RQKEY_PROFILE(currentAccount.did), }) } - agent.resumeSession(agent.session!).then(() => control.close()) + if ('resumeSession' in agent && agent.session) { + agent.resumeSession(agent.session).then(() => control.close()) + } else { + control.close() + } }, }) @@ -330,7 +334,11 @@ function OwnHandlePage({goToServiceHandle}: {goToServiceHandle: () => void}) { queryKey: RQKEY_PROFILE(currentAccount.did), }) } - agent.resumeSession(agent.session!).then(() => control.close()) + if ('resumeSession' in agent && agent.session) { + agent.resumeSession(agent.session).then(() => control.close()) + } else { + control.close() + } }, }) diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts index 16b44ef0c..e5a393e66 100644 --- a/src/state/persisted/schema.ts +++ b/src/state/persisted/schema.ts @@ -30,6 +30,7 @@ const accountSchema = z.object({ status: z.string().optional(), pdsUrl: z.string().optional(), isSelfHosted: z.boolean().optional(), + isOauthSession: z.boolean().optional(), }) export type PersistedAccount = z.infer diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx index 4fc67be71..06cb382d3 100644 --- a/src/state/session/index.tsx +++ b/src/state/session/index.tsx @@ -25,6 +25,11 @@ import { pdsAgent, sessionAccountToSession, } from './agent' +import { + type OauthBskyAppAgent, + oauthCreateAgent, + oauthResumeSession, +} from './oauth-agent' import {type Action, getInitialState, reducer, type State} from './reducer' export {isSignupQueued} from './util' import {addSessionDebugLog} from './logging' @@ -158,10 +163,17 @@ export function Provider({children}: React.PropsWithChildren<{}>) { async (params, logContext) => { addSessionDebugLog({type: 'method:start', method: 'login'}) const signal = cancelPendingTask() - const {agent, account} = await createAgentAndLogin( - params, - onAgentSessionChange, - ) + + let agentAccount: { + agent: BskyAppAgent | OauthBskyAppAgent + account: persisted.PersistedAccount + } + if (params.oauthSession) { + agentAccount = await oauthCreateAgent(params.oauthSession) + } else { + agentAccount = await createAgentAndLogin(params, onAgentSessionChange) + } + const {agent, account} = agentAccount if (signal.aborted) { return @@ -173,7 +185,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { }) ax.metric( 'account:loggedIn', - {logContext, withPassword: true}, + {logContext, withPassword: !params.oauthSession}, {session: utils.accountToSessionMetadata(account)}, ) addSessionDebugLog({type: 'method:end', method: 'login', account}) @@ -253,10 +265,20 @@ export function Provider({children}: React.PropsWithChildren<{}>) { account: storedAccount, }) const signal = cancelPendingTask() - const {agent, account} = await createAgentAndResume( - storedAccount, - onAgentSessionChange, - ) + + let agentAccount: { + agent: BskyAppAgent | OauthBskyAppAgent + account: persisted.PersistedAccount + } + if (storedAccount.isOauthSession) { + agentAccount = await oauthResumeSession(storedAccount) + } else { + agentAccount = await createAgentAndResume( + storedAccount, + onAgentSessionChange, + ) + } + const {agent, account} = agentAccount if (signal.aborted) { return @@ -467,6 +489,6 @@ export function useBlankPrefAuthedAgent(): BskyAgent { } return useMemo(() => { - return (agent as BskyAppAgent).cloneWithoutProxy() + return (agent as BskyAppAgent | OauthBskyAppAgent).cloneWithoutProxy() }, [agent]) } diff --git a/src/state/session/moderation.ts b/src/state/session/moderation.ts index 693281998..6b67384bb 100644 --- a/src/state/session/moderation.ts +++ b/src/state/session/moderation.ts @@ -1,3 +1,4 @@ +import {type Agent} from '@atproto/api' import {BSKY_LABELER_DID, BskyAgent} from '@atproto/api' import {IS_TEST_USER} from '#/lib/constants' @@ -14,7 +15,7 @@ export function configureModerationForGuest() { } export async function configureModerationForAccount( - agent: BskyAgent, + agent: Agent | BskyAgent, account: SessionAccount, ) { // This global mutation is *only* OK because this code is only relevant for testing. @@ -44,7 +45,7 @@ function switchToBskyAppLabeler() { }) } -async function trySwitchToTestAppLabeler(agent: BskyAgent) { +async function trySwitchToTestAppLabeler(agent: Agent | BskyAgent) { const did = ( await agent .resolveHandle({handle: 'mod-authority.test'}) diff --git a/src/state/session/oauth-agent.ts b/src/state/session/oauth-agent.ts new file mode 100644 index 000000000..e318e39d0 --- /dev/null +++ b/src/state/session/oauth-agent.ts @@ -0,0 +1,140 @@ +import {Agent, type AtpSessionData} from '@atproto/api' +import {type OutputSchema} from '@atproto/api/dist/client/types/com/atproto/server/getSession' +import {type OAuthSession} from '@atproto/oauth-client-browser' + +import {BLUESKY_PROXY_HEADER, BSKY_SERVICE} from '#/lib/constants' +import {logger} from '#/logger' +import {sessionAccountToSession} from './agent' +import {configureModerationForAccount} from './moderation' +import {getWebOAuthClient} from './oauth-web-client' +import {type SessionAccount} from './types' + +export async function oauthCreateAgent(session: OAuthSession) { + const agent = new OauthBskyAppAgent(session) + const account = await oauthAgentAndSessionToSessionAccountOrThrow( + agent, + session, + ) + const gates = Promise.resolve() + const moderation = configureModerationForAccount(agent, account) + return agent.prepare(account, gates, moderation) +} + +const OAUTH_RESTORE_TIMEOUT_MS = 10_000 + +export async function oauthResumeSession(account: SessionAccount) { + const client = getWebOAuthClient() + let session: OAuthSession + try { + session = await Promise.race([ + client.restore(account.did), + new Promise((_, reject) => + setTimeout( + () => reject(new Error('OAuth session restore timed out')), + OAUTH_RESTORE_TIMEOUT_MS, + ), + ), + ]) + } catch (e) { + logger.error('oauthResumeSession: restore failed', { + did: account.did, + error: e instanceof Error ? e.message : String(e), + }) + throw e + } + return await oauthCreateAgent(session) +} + +export async function oauthAgentAndSessionToSessionAccountOrThrow( + agent: Agent, + session: OAuthSession, +): Promise { + const account = await oauthAgentAndSessionToSessionAccount(agent, session) + if (!account) { + throw Error('Expected an active session') + } + return account +} + +export async function oauthAgentAndSessionToSessionAccount( + agent: Agent, + session: OAuthSession, +): Promise { + let data: OutputSchema + try { + const res = await Promise.race([ + agent.com.atproto.server.getSession(), + new Promise((_, reject) => + setTimeout( + () => reject(new Error('getSession timed out')), + OAUTH_RESTORE_TIMEOUT_MS, + ), + ), + ]) + data = res.data + } catch (e: any) { + logger.error('oauthAgentAndSessionToSessionAccount: getSession failed', e) + return undefined + } + let aud: string + try { + const tokenInfo = await Promise.race([ + session.getTokenInfo(false), + new Promise((_, reject) => + setTimeout( + () => reject(new Error('getTokenInfo timed out')), + OAUTH_RESTORE_TIMEOUT_MS, + ), + ), + ]) + aud = tokenInfo.aud + } catch (e: any) { + logger.error('oauthAgentAndSessionToSessionAccount: getTokenInfo failed', e) + return undefined + } + return { + service: session.serverMetadata.issuer, + did: session.did, + handle: data.handle, + email: data.email, + emailConfirmed: data.emailConfirmed, + emailAuthFactor: data.emailAuthFactor, + active: data.active, + status: data.status, + pdsUrl: aud, + isSelfHosted: !session.server.issuer.startsWith(BSKY_SERVICE), + isOauthSession: true, + } +} + +export class OauthBskyAppAgent extends Agent { + session?: AtpSessionData + dispatchUrl?: string + + constructor(session: OAuthSession) { + super(session) + } + + async prepare( + account: SessionAccount, + gates: Promise, + moderation: Promise, + ) { + this.session = sessionAccountToSession(account) + this.dispatchUrl = account.pdsUrl + this.configureProxy(BLUESKY_PROXY_HEADER.get()) + + await Promise.all([gates, moderation]) + + return {account, agent: this} + } + + dispose() {} + + cloneWithoutProxy(): OauthBskyAppAgent { + const cloned = new OauthBskyAppAgent(this.sessionManager as OAuthSession) + cloned.session = this.session + cloned.configureProxy(null) + return cloned + } +} diff --git a/src/state/session/oauth-web-client.ts b/src/state/session/oauth-web-client.ts new file mode 100644 index 000000000..5c2db8560 --- /dev/null +++ b/src/state/session/oauth-web-client.ts @@ -0,0 +1,72 @@ +import {BrowserOAuthClient} from '@atproto/oauth-client-browser' + +const OAUTH_BASE_URL: string = + process.env.EXPO_PUBLIC_OAUTH_BASE_URL || 'https://witchsky.app' + +const OAUTH_CLIENT_NAME: string = + process.env.EXPO_PUBLIC_OAUTH_CLIENT_NAME || 'Witchsky' + +const OAUTH_SCOPE = + 'atproto transition:generic transition:email transition:chat.bsky identity:handle account:email?action=manage account:status?action=manage' + +function isLoopback() { + if (typeof window === 'undefined') return false + const host = window.location.hostname + return ( + host === 'localhost' || + host === '127.0.0.1' || + host === '[::1]' || + host === '::1' + ) +} + +const BSKY_OAUTH_CLIENT = createWebOAuthClient() + +function createWebOAuthClient() { + if (isLoopback()) { + // Loopback client: encode scope and redirect_uri in the client_id URL. + // The authorization server uses hardcoded metadata for http://localhost + // client_ids. Without explicit scope, only "atproto" is granted, which + // lacks the transition:* scopes needed for appview/chat APIs. + const port = window.location.port ? `:${window.location.port}` : '' + const redirectUri = `http://127.0.0.1${port}/` + const clientId = + `http://localhost` + + `?redirect_uri=${encodeURIComponent(redirectUri)}` + + `&scope=${encodeURIComponent(OAUTH_SCOPE)}` + + return new BrowserOAuthClient({ + clientMetadata: { + client_id: clientId, + redirect_uris: [redirectUri], + scope: OAUTH_SCOPE, + token_endpoint_auth_method: 'none', + response_types: ['code'], + grant_types: ['authorization_code', 'refresh_token'], + application_type: 'web', + dpop_bound_access_tokens: true, + }, + handleResolver: 'https://bsky.social', + }) + } + + return new BrowserOAuthClient({ + clientMetadata: { + client_id: `${OAUTH_BASE_URL}/oauth-client-metadata.json`, + client_name: OAUTH_CLIENT_NAME, + client_uri: OAUTH_BASE_URL, + redirect_uris: [`${OAUTH_BASE_URL}/auth/web/callback`], + scope: OAUTH_SCOPE, + token_endpoint_auth_method: 'none', + response_types: ['code'], + grant_types: ['authorization_code', 'refresh_token'], + application_type: 'web', + dpop_bound_access_tokens: true, + }, + handleResolver: 'https://bsky.social', + }) +} + +export function getWebOAuthClient() { + return BSKY_OAUTH_CLIENT +} diff --git a/src/state/session/reducer.ts b/src/state/session/reducer.ts index d22dd4a02..9e2415be8 100644 --- a/src/state/session/reducer.ts +++ b/src/state/session/reducer.ts @@ -10,7 +10,7 @@ import {createTemporaryAgentsAndResume} from './util' // A hack so that the reducer can't read anything from the agent. // From the reducer's point of view, it should be a completely opaque object. type OpaqueBskyAgent = { - readonly service: URL + readonly service?: URL | undefined readonly api: unknown readonly app: unknown readonly com: unknown diff --git a/src/state/session/types.ts b/src/state/session/types.ts index 8a9afba42..792724e25 100644 --- a/src/state/session/types.ts +++ b/src/state/session/types.ts @@ -1,3 +1,5 @@ +import {type OAuthSession} from '@atproto/oauth-client-browser' + import {type PersistedAccount} from '#/state/persisted' import {type Metrics} from '#/analytics/metrics' @@ -29,6 +31,7 @@ export type SessionApiContext = { identifier: string password: string authFactorToken?: string | undefined + oauthSession?: OAuthSession }, logContext: Metrics['account:loggedIn']['logContext'], ) => Promise diff --git a/yarn.lock b/yarn.lock index 9551860c1..075619a22 100644 --- a/yarn.lock +++ b/yarn.lock @@ -20,6 +20,61 @@ "@jridgewell/gen-mapping" "^0.3.0" "@jridgewell/trace-mapping" "^0.3.9" +"@atproto-labs/did-resolver@0.2.6", "@atproto-labs/did-resolver@^0.2.6": + version "0.2.6" + resolved "https://registry.yarnpkg.com/@atproto-labs/did-resolver/-/did-resolver-0.2.6.tgz#15f0beab797187a67279389f6503f87a257cd898" + integrity sha512-2K1bC04nI2fmgNcvof+yA28IhGlpWn2JKYlPa7To9JTKI45FINCGkQSGiL2nyXlyzDJJ34fZ1aq6/IRFIOIiqg== + dependencies: + "@atproto-labs/fetch" "0.2.3" + "@atproto-labs/pipe" "0.1.1" + "@atproto-labs/simple-store" "0.3.0" + "@atproto-labs/simple-store-memory" "0.1.4" + "@atproto/did" "0.3.0" + zod "^3.23.8" + +"@atproto-labs/fetch@0.2.3", "@atproto-labs/fetch@^0.2.3": + version "0.2.3" + resolved "https://registry.yarnpkg.com/@atproto-labs/fetch/-/fetch-0.2.3.tgz#d47afec078f630c50e291c56264cc0ff13d0c6cc" + integrity sha512-NZtbJOCbxKUFRFKMpamT38PUQMY0hX0p7TG5AEYOPhZKZEP7dHZ1K2s1aB8MdVH0qxmqX7nQleNrrvLf09Zfdw== + dependencies: + "@atproto-labs/pipe" "0.1.1" + +"@atproto-labs/handle-resolver@0.3.6", "@atproto-labs/handle-resolver@^0.3.6": + version "0.3.6" + resolved "https://registry.yarnpkg.com/@atproto-labs/handle-resolver/-/handle-resolver-0.3.6.tgz#bb2a5435995c4c4ddf75065a47417975c4b4a003" + integrity sha512-qnSTXvOBNj1EHhp2qTWSX8MS5q3AwYU5LKlt5fBvSbCjgmTr2j0URHCv+ydrwO55KvsojIkTMgeMOh4YuY4fCA== + dependencies: + "@atproto-labs/simple-store" "0.3.0" + "@atproto-labs/simple-store-memory" "0.1.4" + "@atproto/did" "0.3.0" + zod "^3.23.8" + +"@atproto-labs/identity-resolver@^0.3.6": + version "0.3.6" + resolved "https://registry.yarnpkg.com/@atproto-labs/identity-resolver/-/identity-resolver-0.3.6.tgz#bdb33099bda7c2eed64a8b1f92b8e493f960965b" + integrity sha512-qoWqBDRobln0NR8L8dQjSp79E0chGkBhibEgxQa2f9WD+JbJdjQ0YvwwO5yeQn05pJoJmAwmI2wyJ45zjU7aWg== + dependencies: + "@atproto-labs/did-resolver" "0.2.6" + "@atproto-labs/handle-resolver" "0.3.6" + +"@atproto-labs/pipe@0.1.1": + version "0.1.1" + resolved "https://registry.yarnpkg.com/@atproto-labs/pipe/-/pipe-0.1.1.tgz#1c4232d16bf95f251e993cb6ee440f9aa4e87ce6" + integrity sha512-hdNw2oUs2B6BN1lp+32pF7cp8EMKuIN5Qok2Vvv/aOpG/3tNSJ9YkvfI0k6Zd188LeDDYRUpYpxcoFIcGH/FNg== + +"@atproto-labs/simple-store-memory@0.1.4", "@atproto-labs/simple-store-memory@^0.1.4": + version "0.1.4" + resolved "https://registry.yarnpkg.com/@atproto-labs/simple-store-memory/-/simple-store-memory-0.1.4.tgz#e38c7b27e0f77c0bdba1329deb89593fbec27316" + integrity sha512-3mKY4dP8I7yKPFj9VKpYyCRzGJOi5CEpOLPlRhoJyLmgs3J4RzDrjn323Oakjz2Aj2JzRU/AIvWRAZVhpYNJHw== + dependencies: + "@atproto-labs/simple-store" "0.3.0" + lru-cache "^10.2.0" + +"@atproto-labs/simple-store@0.3.0", "@atproto-labs/simple-store@^0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@atproto-labs/simple-store/-/simple-store-0.3.0.tgz#65c0a5c949fe6c8dc3bdaf13ab40848f20073593" + integrity sha512-nOb6ONKBRJHRlukW1sVawUkBqReLlLx6hT35VS3imaNPwiXDxLnTK7lxw3Lrl9k5yugSBDQAkZAq3MPTEFSUBQ== + "@atproto/api@^0.19.6": version "0.19.6" resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.19.6.tgz#c8fae3d792fe429c900ac0ba2609d60b9a89e28b" @@ -44,6 +99,38 @@ "@atproto/syntax" "^0.5.1" zod "^3.23.8" +"@atproto/did@0.3.0", "@atproto/did@^0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@atproto/did/-/did-0.3.0.tgz#0f6b11a5119672a41075fa58e97956b296ac171c" + integrity sha512-raUPzUGegtW/6OxwCmM8bhZvuIMzxG5t9oWsth6Tp91Kb5fTnHV2h/KKNF1C82doeA4BdXCErTyg7ISwLbQkzA== + dependencies: + zod "^3.23.8" + +"@atproto/jwk-jose@0.1.11": + version "0.1.11" + resolved "https://registry.yarnpkg.com/@atproto/jwk-jose/-/jwk-jose-0.1.11.tgz#ef64bce940a66e267fc3cf0db8df4dbd062bb28a" + integrity sha512-i4Fnr2sTBYmMmHXl7NJh8GrCH+tDQEVWrcDMDnV5DjJfkgT17wIqvojIw9SNbSL4Uf0OtfEv6AgG0A+mgh8b5Q== + dependencies: + "@atproto/jwk" "0.6.0" + jose "^5.2.0" + +"@atproto/jwk-webcrypto@^0.2.0": + version "0.2.0" + resolved "https://registry.yarnpkg.com/@atproto/jwk-webcrypto/-/jwk-webcrypto-0.2.0.tgz#31c88f350843b1a8e8d0cb422c1cb02f5dd59137" + integrity sha512-UmgRrrEAkWvxwhlwe30UmDOdTEFidlIzBC7C3cCbeJMcBN1x8B3KH+crXrsTqfWQBG58mXgt8wgSK3Kxs2LhFg== + dependencies: + "@atproto/jwk" "0.6.0" + "@atproto/jwk-jose" "0.1.11" + zod "^3.23.8" + +"@atproto/jwk@0.6.0", "@atproto/jwk@^0.6.0": + version "0.6.0" + resolved "https://registry.yarnpkg.com/@atproto/jwk/-/jwk-0.6.0.tgz#e813f77d9c89c025d4074340777fafaa2fba08a5" + integrity sha512-bDoJPvt7TrQVi/rBfBrSSpGykhtIriKxeYCYQTiPRKFfyRhbgpElF0wPXADjIswnbzZdOwbY63az4E/CFVT3Tw== + dependencies: + multiformats "^9.9.0" + zod "^3.23.8" + "@atproto/lex-data@^0.0.14": version "0.0.14" resolved "https://registry.yarnpkg.com/@atproto/lex-data/-/lex-data-0.0.14.tgz#2f2f3c64699925a0d4785e5afd0e7731ba1d46c0" @@ -73,6 +160,49 @@ multiformats "^9.9.0" zod "^3.23.8" +"@atproto/oauth-client-browser@^0.3.41": + version "0.3.41" + resolved "https://registry.yarnpkg.com/@atproto/oauth-client-browser/-/oauth-client-browser-0.3.41.tgz#b740d8a194059cfddae49b284a79089ab24b0efe" + integrity sha512-4QTm8zPgm08vl53flrVmL+MS5IOhvWWctNZmEnPbvQ2t1ISw9Q5m815m2Sszi5ULMFjOqvT7lhKB7zQUn5gq5g== + dependencies: + "@atproto-labs/did-resolver" "^0.2.6" + "@atproto-labs/handle-resolver" "^0.3.6" + "@atproto-labs/simple-store" "^0.3.0" + "@atproto/did" "^0.3.0" + "@atproto/jwk" "^0.6.0" + "@atproto/jwk-webcrypto" "^0.2.0" + "@atproto/oauth-client" "^0.6.0" + "@atproto/oauth-types" "^0.6.3" + core-js "^3" + +"@atproto/oauth-client@^0.6.0": + version "0.6.0" + resolved "https://registry.yarnpkg.com/@atproto/oauth-client/-/oauth-client-0.6.0.tgz#efc729825ed0a6464dd9da0f75cd62b9fb069a19" + integrity sha512-F7ZTKzFptXgyihMkd7QTdRSkrh4XqrS+qTw+V81k5Q6Bh3MB1L3ypvfSJ6v7SSUJa6XxoZYJTCahHC1e+ndE6Q== + dependencies: + "@atproto-labs/did-resolver" "^0.2.6" + "@atproto-labs/fetch" "^0.2.3" + "@atproto-labs/handle-resolver" "^0.3.6" + "@atproto-labs/identity-resolver" "^0.3.6" + "@atproto-labs/simple-store" "^0.3.0" + "@atproto-labs/simple-store-memory" "^0.1.4" + "@atproto/did" "^0.3.0" + "@atproto/jwk" "^0.6.0" + "@atproto/oauth-types" "^0.6.3" + "@atproto/xrpc" "^0.7.7" + core-js "^3" + multiformats "^9.9.0" + zod "^3.23.8" + +"@atproto/oauth-types@^0.6.3": + version "0.6.3" + resolved "https://registry.yarnpkg.com/@atproto/oauth-types/-/oauth-types-0.6.3.tgz#4fc996d0af61874830079d1b1bddc7c0774ad6b2" + integrity sha512-jdKuoPknJuh/WjI+mYk7agSbx9mNVMbS6Dr3k1z2YMY2oRiCQjxYBuo4MLKATbxj05nMQaZRWlHRUazoAu5Cng== + dependencies: + "@atproto/did" "^0.3.0" + "@atproto/jwk" "^0.6.0" + zod "^3.23.8" + "@atproto/syntax@^0.5.0", "@atproto/syntax@^0.5.1": version "0.5.2" resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.5.2.tgz#d4b32c9feb421ceeb5ade1fa80bc42764d51e52e" @@ -7417,6 +7547,11 @@ core-js-pure@^3.23.3: resolved "https://registry.yarnpkg.com/core-js-pure/-/core-js-pure-3.32.1.tgz#5775b88f9062885f67b6d7edce59984e89d276f3" integrity sha512-f52QZwkFVDPf7UEQZGHKx6NYxsxmVGJe5DIvbzOdRMJlmT6yv0KDjR8rmy3ngr/t5wU54c7Sp/qIJH0ppbhVpQ== +core-js@^3: + version "3.49.0" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.49.0.tgz#8b4d520ac034311fa21aa616f017ada0e0dbbddd" + integrity sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg== + core-util-is@~1.0.0: version "1.0.3" resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85" @@ -11473,6 +11608,11 @@ jiti@^2.5.1: resolved "https://registry.yarnpkg.com/jiti/-/jiti-2.6.1.tgz#178ef2fc9a1a594248c20627cd820187a4d78d92" integrity sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ== +jose@^5.2.0: + version "5.10.0" + resolved "https://registry.yarnpkg.com/jose/-/jose-5.10.0.tgz#c37346a099d6467c401351a9a0c2161e0f52c4be" + integrity sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg== + js-sha256@^0.10.1: version "0.10.1" resolved "https://registry.yarnpkg.com/js-sha256/-/js-sha256-0.10.1.tgz#b40104ba1368e823fdd5f41b66b104b15a0da60d"