diff --git a/src/screens/Login/LoginForm.tsx b/src/screens/Login/LoginForm.tsx index aaebbee3a9..44d7880fad 100644 --- a/src/screens/Login/LoginForm.tsx +++ b/src/screens/Login/LoginForm.tsx @@ -14,12 +14,15 @@ import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useRequestNotificationsPermission} from '#/lib/notifications/notifications' +import {useGate} from '#/lib/statsig/statsig' import {isNetworkError} from '#/lib/strings/errors' import {cleanError} from '#/lib/strings/errors' import {createFullHandle} from '#/lib/strings/handles' import {logger} from '#/logger' +import {isWeb} from '#/platform/detection' import {useSetHasCheckedForStarterPack} from '#/state/preferences/used-starter-packs' import {useSessionApi} from '#/state/session' +import {getNativeOAuthClient, getWebOAuthClient} from '#/state/session/oauth' import {useLoggedOutViewControls} from '#/state/shell/logged-out' import {atoms as a, useTheme} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' @@ -32,9 +35,6 @@ import {Ticket_Stroke2_Corner0_Rounded as Ticket} from '#/components/icons/Ticke import {Loader} from '#/components/Loader' import {Text} from '#/components/Typography' import {FormContainer} from './FormContainer' -import {useGate} from '#/lib/statsig/statsig' -import {isWeb} from '#/platform/detection' -import {getNativeOAuthClient, getWebOAuthClient} from '#/state/session/oauth' type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema @@ -61,11 +61,18 @@ export function LoginForm(props: LoginFormProps) { } } -function OAuthLoginForm({error, initialHandle, onPressBack}: LoginFormProps) { +function OAuthLoginForm({ + error, + initialHandle, + onPressBack, + setError, +}: LoginFormProps) { const {_} = useLingui() const [isProcessing, setIsProcessing] = React.useState(false) const identifierValueRef = useRef(initialHandle || '') + const {loginOauth} = useSessionApi() + const onPressNext = async () => { setIsProcessing(true) if (isWeb) { @@ -74,8 +81,14 @@ function OAuthLoginForm({error, initialHandle, onPressBack}: LoginFormProps) { } else { const client = getNativeOAuthClient() const res = await client.signIn(identifierValueRef.current) - // redirect after result + if (res.status === 'success') { + await loginOauth(res.session, 'LoginForm') + } else { + logger.error(`Invalid OAuth status: ${res.status}`) + setError(_(msg`An error occurred during authentication.`)) + } } + setIsProcessing(false) } return ( diff --git a/src/state/session/agent.ts b/src/state/session/agent.ts index b37089acb3..3898e54db2 100644 --- a/src/state/session/agent.ts +++ b/src/state/session/agent.ts @@ -1,5 +1,12 @@ -import {AtpSessionData, AtpSessionEvent, BskyAgent} from '@atproto/api' +import { + Agent, + type AtpSessionData, + type AtpSessionEvent, + BskyAgent, +} from '@atproto/api' +import {type OutputSchema} from '@atproto/api/dist/client/types/com/atproto/server/getSession' import {TID} from '@atproto/common-web' +import {type OAuthSession} from '@atproto/oauth-client-browser' import {networkRetry} from '#/lib/async/retry' import { @@ -19,10 +26,9 @@ import { configureModerationForAccount, configureModerationForGuest, } from './moderation' -import {SessionAccount} from './types' -import {isSessionExpired, isSignupQueued} from './util' import {BSKY_OAUTH_CLIENT} from './oauth' -import {ExpoOAuthClient} from 'expo-atproto-auth' +import {type SessionAccount} from './types' +import {isSessionExpired, isSignupQueued} from './util' export function createPublicAgent() { configureModerationForGuest() // Side effect but only relevant for tests @@ -66,6 +72,22 @@ export async function createAgentAndResume( return agent.prepare(gates, moderation, onSessionChange) } +export async function createAgentOauth(session: OAuthSession) { + const agent = new Agent(session) + const account = await oauthAgentAndSessionToSessionAccountOrThrow( + agent, + session, + ) + tryFetchGates(account.did, 'prefer-fresh-gates') + configureModerationForAccount(agent, account) + return {agent, account} +} + +export async function resumeAgentOauth(account: SessionAccount) { + const session = await BSKY_OAUTH_CLIENT.restore(account.did) + return await createAgentOauth(session) +} + export async function createAgentAndLogin( { service, @@ -185,6 +207,17 @@ export async function createAgentAndCreateAccount( return agent.prepare(gates, moderation, onSessionChange) } +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 function agentToSessionAccountOrThrow(agent: BskyAgent): SessionAccount { const account = agentToSessionAccount(agent) if (!account) { @@ -193,6 +226,32 @@ export function agentToSessionAccountOrThrow(agent: BskyAgent): SessionAccount { return account } +export async function oauthAgentAndSessionToSessionAccount( + agent: Agent, + session: OAuthSession, +): Promise { + let data: OutputSchema + try { + const res = await agent.com.atproto.server.getSession() + data = res.data + } catch (e: any) { + logger.error(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: session.serverMetadata.issuer, + isSelfHosted: !session.server.issuer.startsWith(BSKY_SERVICE), // TODO: is this entryway? + } +} + export function agentToSessionAccount( agent: BskyAgent, ): SessionAccount | undefined { diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx index 45384c4f52..f74c82ed8b 100644 --- a/src/state/session/index.tsx +++ b/src/state/session/index.tsx @@ -12,6 +12,8 @@ import { createAgentAndCreateAccount, createAgentAndLogin, createAgentAndResume, + createAgentOauth, + resumeAgentOauth, sessionAccountToSession, } from './agent' import {getInitialState, reducer} from './reducer' @@ -36,9 +38,11 @@ const AgentContext = React.createContext(null) const ApiContext = React.createContext({ createAccount: async () => {}, login: async () => {}, + loginOauth: async () => {}, logoutCurrentAccount: async () => {}, logoutEveryAccount: async () => {}, resumeSession: async () => {}, + resumeSessionOauth: async () => {}, removeAccount: () => {}, }) @@ -118,6 +122,28 @@ export function Provider({children}: React.PropsWithChildren<{}>) { [onAgentSessionChange, cancelPendingTask], ) + const loginOauth = React.useCallback( + async (session, logContext) => { + const signal = cancelPendingTask() + const {agent, account} = await createAgentOauth(session) + if (signal.aborted) { + return + } + dispatch({ + type: 'switched-to-account', + newAgent: agent, + newAccount: account, + }) + logger.metric( + 'account:loggedIn', + {logContext, withPassword: true}, + {statsig: true}, + ) + addSessionDebugLog({type: 'method:end', method: 'login', account}) + }, + [cancelPendingTask], + ) + const logoutCurrentAccount = React.useCallback< SessionApiContext['logoutEveryAccount'] >( @@ -182,6 +208,25 @@ export function Provider({children}: React.PropsWithChildren<{}>) { [onAgentSessionChange, cancelPendingTask], ) + const resumeSessionOauth = React.useCallback< + SessionApiContext['resumeSessionOauth'] + >( + async storedAccount => { + const signal = cancelPendingTask() + const {agent, account} = await resumeAgentOauth(storedAccount) + if (signal.aborted) { + return + } + dispatch({ + type: 'switched-to-account', + newAgent: agent, + newAccount: account, + }) + addSessionDebugLog({type: 'method:end', method: 'resumeSession', account}) + }, + [cancelPendingTask], + ) + const removeAccount = React.useCallback( account => { addSessionDebugLog({ @@ -258,17 +303,21 @@ export function Provider({children}: React.PropsWithChildren<{}>) { () => ({ createAccount, login, + loginOauth, logoutCurrentAccount, logoutEveryAccount, resumeSession, + resumeSessionOauth, removeAccount, }), [ createAccount, login, + loginOauth, logoutCurrentAccount, logoutEveryAccount, resumeSession, + resumeSessionOauth, removeAccount, ], ) diff --git a/src/state/session/moderation.ts b/src/state/session/moderation.ts index 01684fe0ba..f831069fdd 100644 --- a/src/state/session/moderation.ts +++ b/src/state/session/moderation.ts @@ -1,9 +1,9 @@ -import {BSKY_LABELER_DID, BskyAgent} from '@atproto/api' +import {type Agent, BSKY_LABELER_DID, BskyAgent} from '@atproto/api' import {IS_TEST_USER} from '#/lib/constants' import {configureAdditionalModerationAuthorities} from './additional-moderation-authorities' import {readLabelers} from './agent-config' -import {SessionAccount} from './types' +import {type SessionAccount} from './types' export function configureModerationForGuest() { // This global mutation is *only* OK because this code is only relevant for testing. @@ -13,7 +13,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. @@ -41,7 +41,7 @@ function switchToBskyAppLabeler() { BskyAgent.configure({appLabelers: [BSKY_LABELER_DID]}) } -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/reducer.ts b/src/state/session/reducer.ts index 22ba47162a..d638f4d535 100644 --- a/src/state/session/reducer.ts +++ b/src/state/session/reducer.ts @@ -1,13 +1,13 @@ -import {AtpSessionEvent} from '@atproto/api' +import {type AtpSessionEvent} from '@atproto/api' import {createPublicAgent} from './agent' import {wrapSessionReducerForLogging} from './logging' -import {SessionAccount} from './types' +import {type SessionAccount} from './types' // 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 // TODO: do we need service at all? what are implications if we rm? readonly api: unknown readonly app: unknown readonly com: unknown diff --git a/src/state/session/types.ts b/src/state/session/types.ts index aa8b9a99e0..cd0eaf2051 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 LogEvents} from '#/lib/statsig/statsig' import {type PersistedAccount} from '#/state/persisted' @@ -32,6 +34,10 @@ export type SessionApiContext = { }, logContext: LogEvents['account:loggedIn']['logContext'], ) => Promise + loginOauth: ( + session: OAuthSession, + logContext: LogEvents['account:loggedIn']['logContext'], + ) => Promise logoutCurrentAccount: ( logContext: LogEvents['account:loggedOut']['logContext'], ) => void @@ -39,5 +45,6 @@ export type SessionApiContext = { logContext: LogEvents['account:loggedOut']['logContext'], ) => void resumeSession: (account: SessionAccount) => Promise + resumeSessionOauth: (account: SessionAccount) => Promise removeAccount: (account: SessionAccount) => void }