diff --git a/src/reducers/auth.ts b/src/reducers/auth.ts index f066f3dbb..d5bc456a1 100644 --- a/src/reducers/auth.ts +++ b/src/reducers/auth.ts @@ -1,15 +1,16 @@ -import { List as ImmutableList, Map as ImmutableMap, Record as ImmutableRecord, fromJS } from 'immutable'; -import trim from 'lodash/trim'; +import { AxiosError } from 'axios'; +import { produce } from 'immer'; +import { z } from 'zod'; -import { MASTODON_PRELOAD_IMPORT } from 'soapbox/actions/preload'; -import * as BuildConfig from 'soapbox/build-config'; -import KVStore from 'soapbox/storage/kv-store'; -import { validId, isURL } from 'soapbox/utils/auth'; +import { Account, accountSchema } from 'soapbox/schemas'; +import { Application, applicationSchema } from 'soapbox/schemas/application'; +import { AuthUser, SoapboxAuth, soapboxAuthSchema } from 'soapbox/schemas/soapbox/soapbox-auth'; +import { Token, tokenSchema } from 'soapbox/schemas/token'; +import { jsonSchema } from 'soapbox/schemas/utils'; import { AUTH_APP_CREATED, AUTH_LOGGED_IN, - AUTH_APP_AUTHORIZED, AUTH_LOGGED_OUT, SWITCH_ACCOUNT, VERIFY_CREDENTIALS_SUCCESS, @@ -17,393 +18,184 @@ import { } from '../actions/auth'; import { ME_FETCH_SKIP } from '../actions/me'; -import type { AxiosError } from 'axios'; -import type { AnyAction } from 'redux'; -import type { APIEntity, Account as AccountEntity } from 'soapbox/types/entities'; +import type { UnknownAction } from 'redux'; -export const AuthAppRecord = ImmutableRecord({ - access_token: null as string | null, - client_id: null as string | null, - client_secret: null as string | null, - id: null as string | null, - name: null as string | null, - redirect_uri: null as string | null, - token_type: null as string | null, - vapid_key: null as string | null, - website: null as string | null, -}); +const STORAGE_KEY = 'soapbox:auth'; +const SESSION_KEY = 'soapbox:auth:me'; -export const AuthTokenRecord = ImmutableRecord({ - access_token: '', - account: null as string | null, - created_at: 0, - expires_in: null as number | null, - id: null as number | null, - me: null as string | null, - refresh_token: null as string | null, - scope: '', - token_type: '', -}); - -export const AuthUserRecord = ImmutableRecord({ - access_token: '', - id: '', - url: '', -}); - -export const ReducerRecord = ImmutableRecord({ - app: AuthAppRecord(), - tokens: ImmutableMap(), - users: ImmutableMap(), - me: null as string | null, -}); - -type AuthToken = ReturnType; -type AuthUser = ReturnType; -type State = ReturnType; - -const buildKey = (parts: string[]) => parts.join(':'); - -// For subdirectory support -const NAMESPACE = trim(BuildConfig.FE_SUBDIRECTORY, '/') ? `soapbox@${BuildConfig.FE_SUBDIRECTORY}` : 'soapbox'; - -const STORAGE_KEY = buildKey([NAMESPACE, 'auth']); -const SESSION_KEY = buildKey([NAMESPACE, 'auth', 'me']); - -const getSessionUser = () => { - const id = sessionStorage.getItem(SESSION_KEY); - return validId(id) ? id : undefined; -}; - -const getLocalState = () => { - const state = JSON.parse(localStorage.getItem(STORAGE_KEY)!); - - if (!state) return undefined; - - return ReducerRecord({ - app: AuthAppRecord(state.app), - tokens: ImmutableMap(Object.entries(state.tokens).map(([key, value]) => [key, AuthTokenRecord(value as any)])), - users: ImmutableMap(Object.entries(state.users).map(([key, value]) => [key, AuthUserRecord(value as any)])), - me: state.me, - }); -}; - -const sessionUser = getSessionUser(); -export const localState = getLocalState(); fromJS(JSON.parse(localStorage.getItem(STORAGE_KEY)!)); - -// Checks if the user has an ID and access token -const validUser = (user?: AuthUser) => { +/** Get current user's URL from session storage. */ +function getSessionUser(): string | undefined { + const value = sessionStorage.getItem(SESSION_KEY); try { - return !!(user && validId(user.id) && validId(user.access_token)); - } catch (e) { - return false; - } -}; - -// Finds the first valid user in the state -const firstValidUser = (state: State) => state.users.find(validUser); - -// For legacy purposes. IDs get upgraded to URLs further down. -const getUrlOrId = (user?: AuthUser): string | null => { - try { - const { id, url } = user!.toJS(); - return (url || id) as string; + return z.string().url().parse(value); } catch { - return null; + return undefined; } -}; +} -// If `me` doesn't match an existing user, attempt to shift it. -const maybeShiftMe = (state: State) => { - const me = state.me!; - const user = state.users.get(me); +/** Retrieve state from browser storage. */ +function getLocalState(): SoapboxAuth | undefined { + const data = localStorage.getItem(STORAGE_KEY); + const result = jsonSchema.pipe(soapboxAuthSchema).safeParse(data); - if (!validUser(user)) { - const nextUser = firstValidUser(state); - return state.set('me', getUrlOrId(nextUser)); - } else { - return state; + if (!result.success) { + return undefined; } -}; -// Set the user from the session or localStorage, whichever is valid first -const setSessionUser = (state: State) => state.update('me', me => { - const user = ImmutableList([ - state.users.get(sessionUser!)!, - state.users.get(me!)!, - ]).find(validUser); + return result.data; +} - return getUrlOrId(user); -}); +/** Serialize and save the auth into localStorage. */ +function persistAuth(auth: SoapboxAuth): void { + const value = JSON.stringify(auth); + localStorage.setItem(STORAGE_KEY, value); -// Upgrade the initial state -const migrateLegacy = (state: State) => { - if (localState) return state; - return state.withMutations(state => { - const app = AuthAppRecord(JSON.parse(localStorage.getItem('soapbox:auth:app')!)); - const user = fromJS(JSON.parse(localStorage.getItem('soapbox:auth:user')!)) as ImmutableMap; - if (!user) return; - state.set('me', '_legacy'); // Placeholder account ID - state.set('app', app); - state.set('tokens', ImmutableMap({ - [user.get('access_token')]: AuthTokenRecord(user.set('account', '_legacy')), - })); - state.set('users', ImmutableMap({ - '_legacy': AuthUserRecord({ - id: '_legacy', - access_token: user.get('access_token'), - }), - })); - }); -}; - -const isUpgradingUrlId = (state: State) => { - const me = state.me; - const user = state.users.get(me!); - return validId(me) && user && !isURL(me); -}; - -// Checks the state and makes it valid -const sanitizeState = (state: State) => { - // Skip sanitation during ID to URL upgrade - if (isUpgradingUrlId(state)) return state; - - return state.withMutations(state => { - // Remove invalid users, ensure ID match - state.update('users', users => ( - users.filter((user, url) => ( - validUser(user) && user.get('url') === url - )) - )); - // Remove mismatched tokens - state.update('tokens', tokens => ( - tokens.filter((token, id) => ( - validId(id) && token.get('access_token') === id - )) - )); - }); -}; - -const persistAuth = (state: State) => localStorage.setItem(STORAGE_KEY, JSON.stringify(state.toJS())); - -const persistSession = (state: State) => { - const me = state.me; - if (me && typeof me === 'string') { - sessionStorage.setItem(SESSION_KEY, me); + if (auth.me) { + sessionStorage.setItem(SESSION_KEY, auth.me); } -}; +} -const persistState = (state: State) => { - persistAuth(state); - persistSession(state); -}; +/** Hydrate the initial state, or create a new state. */ +function initialize(): SoapboxAuth { + const auth = getLocalState() || { tokens: {}, users: {} }; + auth.me = getSessionUser() || auth.me; -const initialize = (state: State) => { - return state.withMutations(state => { - maybeShiftMe(state); - setSessionUser(state); - migrateLegacy(state); - sanitizeState(state); - persistState(state); + maybeShiftMe(auth); + persistAuth(auth); + + return auth; +} + +/** Initial state of the reducer. */ +const initialState = initialize(); + +/** Import a Token into the state. */ +function importToken(auth: SoapboxAuth, token: Token): SoapboxAuth { + return produce(auth, draft => { + draft.tokens[token.access_token] = token; }); -}; +} -const initialState = initialize(ReducerRecord().merge(localState as any)); - -const importToken = (state: State, token: APIEntity) => { - return state.setIn(['tokens', token.access_token], AuthTokenRecord(token)); -}; - -// Upgrade the `_legacy` placeholder ID with a real account -const upgradeLegacyId = (state: State, account: APIEntity) => { - if (localState) return state; - return state.withMutations(state => { - state.update('me', me => me === '_legacy' ? account.url : me); - state.deleteIn(['users', '_legacy']); +/** Import Application into the state. */ +function importApplication(auth: SoapboxAuth, app: Application): SoapboxAuth { + return produce(auth, draft => { + draft.app = app; }); - // TODO: Delete `soapbox:auth:app` and `soapbox:auth:user` localStorage? - // By this point it's probably safe, but we'll leave it just in case. -}; +} -// Users are now stored by their ActivityPub ID instead of their -// primary key to support auth against multiple hosts. -const upgradeNonUrlId = (state: State, account: APIEntity) => { - const me = state.me; - if (isURL(me)) return state; +/** If the user is not set, set it to the first available user. This mutates the object. */ +function maybeShiftMe(auth: SoapboxAuth): void { + if (!auth.me || !auth.users[auth.me]) { + auth.me = Object.keys(auth.users)[0]; + } +} - return state.withMutations(state => { - state.update('me', me => me === account.id ? account.url : me); - state.deleteIn(['users', account.id]); - }); -}; - -// Returns a predicate function for filtering a mismatched user/token -const userMismatch = (token: string, account: APIEntity) => { - return (user: AuthUser, url: string) => { - const sameToken = user.get('access_token') === token; - const differentUrl = url !== account.url || user.get('url') !== account.url; - const differentId = user.get('id') !== account.id; - - return sameToken && (differentUrl || differentId); +/** Import an Account into the state as an auth user. */ +function importCredentials(auth: SoapboxAuth, accessToken: string, account: Account): SoapboxAuth { + const authUser: AuthUser = { + id: account.id, + access_token: accessToken, + url: account.url, }; -}; -const importCredentials = (state: State, token: string, account: APIEntity) => { - return state.withMutations(state => { - state.setIn(['users', account.url], AuthUserRecord({ - id: account.id, - access_token: token, - url: account.url, - })); - state.setIn(['tokens', token, 'account'], account.id); - state.setIn(['tokens', token, 'me'], account.url); - state.update('users', users => users.filterNot(userMismatch(token, account))); - state.update('me', me => me || account.url); - upgradeLegacyId(state, account); - upgradeNonUrlId(state, account); + return produce(auth, draft => { + draft.users[account.url] = authUser; + maybeShiftMe(draft); }); -}; +} -const deleteToken = (state: State, token: string) => { - return state.withMutations(state => { - state.update('tokens', tokens => tokens.delete(token)); - state.update('users', users => users.filterNot(user => user.get('access_token') === token)); - maybeShiftMe(state); - }); -}; +function deleteToken(auth: SoapboxAuth, accessToken: string): SoapboxAuth { + return produce(auth, draft => { + delete draft.tokens[accessToken]; -const deleteUser = (state: State, account: Pick) => { - const accountUrl = account.url; - - return state.withMutations(state => { - state.update('users', users => users.delete(accountUrl)); - state.update('tokens', tokens => tokens.filterNot(token => token.get('me') === accountUrl)); - maybeShiftMe(state); - }); -}; - -const importMastodonPreload = (state: State, data: ImmutableMap) => { - return state.withMutations(state => { - const accountId = data.getIn(['meta', 'me']) as string; - const accountUrl = data.getIn(['accounts', accountId, 'url']) as string; - const accessToken = data.getIn(['meta', 'access_token']) as string; - - if (validId(accessToken) && validId(accountId) && isURL(accountUrl)) { - state.setIn(['tokens', accessToken], AuthTokenRecord({ - access_token: accessToken, - account: accountId, - me: accountUrl, - scope: 'read write follow push', - token_type: 'Bearer', - })); - - state.setIn(['users', accountUrl], AuthUserRecord({ - id: accountId, - access_token: accessToken, - url: accountUrl, - })); + for (const url in draft.users) { + if (draft.users[url].access_token === accessToken) { + delete draft.users[url]; + } } - maybeShiftMe(state); + maybeShiftMe(draft); }); -}; +} -const persistAuthAccount = (account: APIEntity) => { - if (account && account.url) { - const key = `authAccount:${account.url}`; - if (!account.pleroma) account.pleroma = {}; - KVStore.getItem(key).then((oldAccount: any) => { - const settings = oldAccount?.pleroma?.settings_store || {}; - if (!account.pleroma.settings_store) { - account.pleroma.settings_store = settings; - } - KVStore.setItem(key, account); - }) - .catch(console.error); - } -}; +function deleteUser(auth: SoapboxAuth, accountUrl: string): SoapboxAuth { + return produce(auth, draft => { + const accessToken = draft.users[accountUrl]?.access_token; -const deleteForbiddenToken = (state: State, error: AxiosError, token: string) => { + delete draft.tokens[accessToken]; + delete draft.users[accountUrl]; + + maybeShiftMe(draft); + }); +} + +function deleteForbiddenToken(auth: SoapboxAuth, error: AxiosError, token: string): SoapboxAuth { if ([401, 403].includes(error.response?.status!)) { - return deleteToken(state, token); + return deleteToken(auth, token); } else { - return state; + return auth; } -}; +} -const reducer = (state: State, action: AnyAction) => { +function reducer(state: SoapboxAuth, action: UnknownAction): SoapboxAuth { switch (action.type) { - case AUTH_APP_CREATED: - return state.set('app', AuthAppRecord(action.app)); - case AUTH_APP_AUTHORIZED: - return state.update('app', app => app.merge(action.token)); - case AUTH_LOGGED_IN: - return importToken(state, action.token); - case AUTH_LOGGED_OUT: - return deleteUser(state, action.account); - case VERIFY_CREDENTIALS_SUCCESS: - persistAuthAccount(action.account); - return importCredentials(state, action.token, action.account); - case VERIFY_CREDENTIALS_FAIL: - return deleteForbiddenToken(state, action.error, action.token); - case SWITCH_ACCOUNT: - return state.set('me', action.account.url); + case AUTH_APP_CREATED: { + const result = applicationSchema.safeParse(action.app); + return result.success ? importApplication(state, result.data) : state; + } + case AUTH_LOGGED_IN: { + const result = tokenSchema.safeParse(action.token); + return result.success ? importToken(state, result.data) : state; + } + case AUTH_LOGGED_OUT: { + const result = accountSchema.safeParse(action.account); + return result.success ? deleteUser(state, result.data.url) : state; + } + case VERIFY_CREDENTIALS_SUCCESS: { + const result = accountSchema.safeParse(action.account); + if (result.success && typeof action.token === 'string') { + return importCredentials(state, action.token, result.data); + } else { + return state; + } + } + case VERIFY_CREDENTIALS_FAIL: { + if (action.error instanceof AxiosError && typeof action.token === 'string') { + return deleteForbiddenToken(state, action.error, action.token); + } else { + return state; + } + } + case SWITCH_ACCOUNT: { + const result = accountSchema.safeParse(action.account); + if (!result.success) { + return state; + } + // Middle-click to switch profiles updates the user in the new tab but leaves the current tab alone. + if (action.background === true) { + sessionStorage.setItem(SESSION_KEY, result.data.url); + return state; + } + return { ...state, me: result.data.url }; + } case ME_FETCH_SKIP: - return state.set('me', null); - case MASTODON_PRELOAD_IMPORT: - return importMastodonPreload(state, fromJS(action.data) as ImmutableMap); + return { ...state, me: undefined }; default: return state; } -}; +} -const reload = () => location.replace('/'); - -// `me` is a user ID string -const validMe = (state: State) => { - const me = state.me; - return typeof me === 'string' && me !== '_legacy'; -}; - -// `me` has changed from one valid ID to another -const userSwitched = (oldState: State, state: State) => { - const me = state.me; - const oldMe = oldState.me; - - const stillValid = validMe(oldState) && validMe(state); - const didChange = oldMe !== me; - const userUpgradedUrl = state.users.get(me!)?.id === oldMe; - - return stillValid && didChange && !userUpgradedUrl; -}; - -const maybeReload = (oldState: State, state: State, action: AnyAction) => { - const shouldRefresh = action.type === AUTH_LOGGED_OUT && action.refresh; - const switched = userSwitched(oldState, state); - - if (switched || shouldRefresh) { - reload(); - } -}; - -export default function auth(oldState: State = initialState, action: AnyAction) { +export default function auth(oldState: SoapboxAuth = initialState, action: UnknownAction): SoapboxAuth { const state = reducer(oldState, action); - if (!state.equals(oldState)) { - // Persist the state in localStorage + // Persist the state in localStorage when it changes. + if (state !== oldState) { persistAuth(state); + } - // When middle-clicking a profile, we want to save the - // user in localStorage, but not update the reducer - if (action.background === true) { - return oldState; - } - - // Persist the session - persistSession(state); - - // Reload the page under some conditions - maybeReload(oldState, state, action); + // Reload the page when the user logs out or switches accounts. + if (action.type === AUTH_LOGGED_OUT || oldState.me !== state.me) { + location.replace('/'); } return state; diff --git a/src/schemas/soapbox/soapbox-auth.ts b/src/schemas/soapbox/soapbox-auth.ts index b80d71bba..9fa3ef798 100644 --- a/src/schemas/soapbox/soapbox-auth.ts +++ b/src/schemas/soapbox/soapbox-auth.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; +import { applicationSchema } from 'soapbox/schemas/application'; import { tokenSchema } from 'soapbox/schemas/token'; const authUserSchema = z.object({ @@ -9,9 +10,10 @@ const authUserSchema = z.object({ }); const soapboxAuthSchema = z.object({ + app: applicationSchema.optional(), tokens: z.record(z.string(), tokenSchema), users: z.record(z.string(), authUserSchema), - me: z.string().url().optional().catch(undefined), + me: z.string().url().optional(), }); type AuthUser = z.infer;