diff --git a/app/soapbox/actions/external_auth.js b/app/soapbox/actions/external_auth.js index b3258ab70..c6fc5d340 100644 --- a/app/soapbox/actions/external_auth.js +++ b/app/soapbox/actions/external_auth.js @@ -13,7 +13,9 @@ import { authLoggedIn, verifyCredentials, switchAccount } from 'soapbox/actions/ import { obtainOAuthToken } from 'soapbox/actions/oauth'; import { parseBaseURL } from 'soapbox/utils/auth'; import sourceCode from 'soapbox/utils/code'; +import { getWalletAndSign } from 'soapbox/utils/ethereum'; import { getFeatures } from 'soapbox/utils/features'; +import { getQuirks } from 'soapbox/utils/quirks'; import { baseClient } from '../api'; @@ -32,36 +34,86 @@ const fetchExternalInstance = baseURL => { }); }; -export function createAppAndRedirect(host) { +function createExternalApp(instance, baseURL) { + return (dispatch, getState) => { + // Mitra: skip creating the auth app + if (getQuirks(instance).noApps) return new Promise(f => f({})); + + const { scopes } = getFeatures(instance); + + const params = { + client_name: sourceCode.displayName, + redirect_uris: `${window.location.origin}/auth/external`, + website: sourceCode.homepage, + scopes, + }; + + return dispatch(createApp(params, baseURL)); + }; +} + +function externalAuthorize(instance, baseURL) { + return (dispatch, getState) => { + const { scopes } = getFeatures(instance); + + return dispatch(createExternalApp(instance, baseURL)).then(app => { + const { client_id, redirect_uri } = app; + + const query = new URLSearchParams({ + client_id, + redirect_uri, + response_type: 'code', + scope: scopes, + }); + + localStorage.setItem('soapbox:external:app', JSON.stringify(app)); + localStorage.setItem('soapbox:external:baseurl', baseURL); + localStorage.setItem('soapbox:external:scopes', scopes); + + window.location.href = `${baseURL}/oauth/authorize?${query.toString()}`; + }); + }; +} + +export function externalEthereumLogin(instance, baseURL) { + return (dispatch, getState) => { + const loginMessage = instance.get('login_message'); + + return getWalletAndSign(loginMessage).then(({ wallet, signature }) => { + return dispatch(createExternalApp(instance, baseURL)).then(app => { + const params = { + grant_type: 'ethereum', + wallet_address: wallet.toLowerCase(), + client_id: app.client_id, + client_secret: app.client_secret, + password: signature, + redirect_uri: 'urn:ietf:wg:oauth:2.0:oob', + scope: getFeatures(instance).scopes, + }; + + return dispatch(obtainOAuthToken(params, baseURL)) + .then(token => dispatch(authLoggedIn(token))) + .then(({ access_token }) => dispatch(verifyCredentials(access_token, baseURL))) + .then(account => dispatch(switchAccount(account.id))) + .then(() => window.location.href = '/'); + }); + }); + }; +} + +export function externalLogin(host) { return (dispatch, getState) => { const baseURL = parseBaseURL(host) || parseBaseURL(`https://${host}`); return fetchExternalInstance(baseURL).then(instance => { - const { scopes } = getFeatures(instance); + const features = getFeatures(instance); + const quirks = getQuirks(instance); - const params = { - client_name: sourceCode.displayName, - redirect_uris: `${window.location.origin}/auth/external`, - website: sourceCode.homepage, - scopes, - }; - - return dispatch(createApp(params, baseURL)).then(app => { - const { client_id, redirect_uri } = app; - - const query = new URLSearchParams({ - client_id, - redirect_uri, - response_type: 'code', - scope: scopes, - }); - - localStorage.setItem('soapbox:external:app', JSON.stringify(app)); - localStorage.setItem('soapbox:external:baseurl', baseURL); - localStorage.setItem('soapbox:external:scopes', scopes); - - window.location.href = `${baseURL}/oauth/authorize?${query.toString()}`; - }); + if (features.ethereumLogin && quirks.noOAuthForm) { + return dispatch(externalEthereumLogin(instance, baseURL)); + } else { + return dispatch(externalAuthorize(instance, baseURL)); + } }); }; } diff --git a/app/soapbox/features/auth_login/components/login_form.js b/app/soapbox/features/auth_login/components/login_form.js index 62b80f0af..549ebb338 100644 --- a/app/soapbox/features/auth_login/components/login_form.js +++ b/app/soapbox/features/auth_login/components/login_form.js @@ -15,11 +15,10 @@ const messages = defineMessages({ const mapStateToProps = state => { const instance = state.get('instance'); - const features = getFeatures(instance); return { baseURL: getBaseURL(state), - hasResetPasswordAPI: features.resetPasswordAPI, + features: getFeatures(instance), }; }; @@ -28,7 +27,7 @@ export default @connect(mapStateToProps) class LoginForm extends ImmutablePureComponent { render() { - const { intl, isLoading, handleSubmit, baseURL, hasResetPasswordAPI } = this.props; + const { intl, isLoading, handleSubmit, baseURL, features } = this.props; return (
@@ -57,12 +56,8 @@ class LoginForm extends ImmutablePureComponent { autoCapitalize='off' required /> - {/*
- -
*/}

- {hasResetPasswordAPI ? ( + {features.resetPasswordAPI ? ( diff --git a/app/soapbox/features/external_login/components/external_login_form.js b/app/soapbox/features/external_login/components/external_login_form.js index c5da61efb..83a9ccf87 100644 --- a/app/soapbox/features/external_login/components/external_login_form.js +++ b/app/soapbox/features/external_login/components/external_login_form.js @@ -3,7 +3,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component'; import { injectIntl, FormattedMessage, defineMessages } from 'react-intl'; import { connect } from 'react-redux'; -import { createAppAndRedirect, loginWithCode } from 'soapbox/actions/external_auth'; +import { externalLogin, loginWithCode } from 'soapbox/actions/external_auth'; import LoadingIndicator from 'soapbox/components/loading_indicator'; import { SimpleForm, FieldsGroup, TextInput } from 'soapbox/features/forms'; @@ -31,7 +31,7 @@ class ExternalLoginForm extends ImmutablePureComponent { this.setState({ isLoading: true }); - dispatch(createAppAndRedirect(host)) + dispatch(externalLogin(host)) .then(() => this.setState({ isLoading: false })) .catch(() => this.setState({ isLoading: false })); } diff --git a/app/soapbox/reducers/instance.js b/app/soapbox/reducers/instance.js index 25a04562e..b0dd8594f 100644 --- a/app/soapbox/reducers/instance.js +++ b/app/soapbox/reducers/instance.js @@ -50,7 +50,7 @@ const initialState = ImmutableMap({ // Build Mastodon configuration from Pleroma instance const pleromaToMastodonConfig = instance => { - return { + return ImmutableMap({ statuses: ImmutableMap({ max_characters: instance.get('max_toot_chars'), }), @@ -60,7 +60,7 @@ const pleromaToMastodonConfig = instance => { min_expiration: instance.getIn(['poll_limits', 'min_expiration']), max_expiration: instance.getIn(['poll_limits', 'max_expiration']), }), - }; + }); }; // Use new value only if old value is undefined diff --git a/app/soapbox/utils/ethereum.js b/app/soapbox/utils/ethereum.js new file mode 100644 index 000000000..3bd8db7b1 --- /dev/null +++ b/app/soapbox/utils/ethereum.js @@ -0,0 +1,26 @@ +export const ethereum = () => window.ethereum; + +export const hasEthereum = () => Boolean(ethereum()); + +// Requests an Ethereum wallet from the browser +// Returns a Promise containing the Ethereum wallet address (string). +export const getWallet = () => { + return ethereum().request({ method: 'eth_requestAccounts' }) + .then(wallets => wallets[0]); +}; + +// Asks the browser to sign a message with Ethereum. +// Returns a Promise containing the signature (string). +export const signMessage = (wallet, message) => { + return ethereum().request({ method: 'personal_sign', params: [message, wallet] }); +}; + +// Combines the above functions. +// Returns an object with the `wallet` and `signature` +export const getWalletAndSign = message => { + return getWallet().then(wallet => { + return signMessage(wallet, message).then(signature => { + return { wallet, signature }; + }); + }); +}; diff --git a/app/soapbox/utils/features.js b/app/soapbox/utils/features.js index e55ca455c..2df8e1c7a 100644 --- a/app/soapbox/utils/features.js +++ b/app/soapbox/utils/features.js @@ -9,6 +9,7 @@ const any = arr => arr.some(Boolean); // For uglification export const MASTODON = 'Mastodon'; export const PLEROMA = 'Pleroma'; +export const MITRA = 'Mitra'; export const getFeatures = createSelector([ instance => parseVersion(instance.get('version')), @@ -83,6 +84,7 @@ export const getFeatures = createSelector([ accountEndorsements: v.software === PLEROMA && gte(v.version, '2.4.50'), quotePosts: v.software === PLEROMA && gte(v.version, '2.4.50'), birthdays: v.software === PLEROMA && gte(v.version, '2.4.50'), + ethereumLogin: v.software === MITRA, }; }); diff --git a/app/soapbox/utils/quirks.js b/app/soapbox/utils/quirks.js index 764b10e26..c64631078 100644 --- a/app/soapbox/utils/quirks.js +++ b/app/soapbox/utils/quirks.js @@ -1,12 +1,17 @@ -import { parseVersion } from './features'; +import { createSelector } from 'reselect'; + +import { parseVersion, PLEROMA, MITRA } from './features'; // For solving bugs between API implementations -export const getQuirks = instance => { - const v = parseVersion(instance.get('version')); +export const getQuirks = createSelector([ + instance => parseVersion(instance.get('version')), +], (v) => { return { - invertedPagination: v.software === 'Pleroma', + invertedPagination: v.software === PLEROMA, + noApps: v.software === MITRA, + noOAuthForm: v.software === MITRA, }; -}; +}); export const getNextLinkName = getState => getQuirks(getState().get('instance')).invertedPagination ? 'prev' : 'next';