full redux oauth implementation, with basic error handling

This commit is contained in:
f0x 2022-09-10 22:12:31 +02:00
parent a4bb869d0f
commit 5f80099eee
13 changed files with 312 additions and 397 deletions

View file

@ -46,6 +46,7 @@ $blue3: #89caff; /* hover/selected accent to $blue2, can be used with $gray1 (7.
$error1: #860000; /* Error border/foreground text, can be used with $error2 (5.0), $white1 (10), $white2 (5.1) */
$error2: #ff9796; /* Error background text, can be used with $error1 (5.0), $gray1 (6.6), $gray2 (5.3), $gray3 (4.8) */
$error-link: #185F8C; /* Error link text, can be used with $error2 (5.54) */
$fg: $white1;
$bg: $gray1;
@ -92,4 +93,7 @@ $settings-nav-bg-hover: $orange2;
$settings-nav-fg-hover: $gray1;
$settings-nav-bg-active: $orange1;
$settings-nav-fg-active: $gray1;
$settings-nav-fg-active: $gray1;
$error-fg: $error1;
$error-bg: $error2;

View file

@ -16,6 +16,7 @@
"bluebird": "^3.7.2",
"browserify": "^17.0.0",
"browserlist": "^1.0.1",
"create-error": "^0.3.1",
"css-extract": "^2.0.0",
"eslint-plugin-react": "^7.24.0",
"express": "^4.18.1",

View file

@ -1,97 +0,0 @@
/*
GoToSocial
Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
"use strict";
const Promise = require("bluebird");
const React = require("react");
const oauthLib = require("../lib/oauth");
module.exports = function Auth({setOauth}) {
const [ instance, setInstance ] = React.useState("");
React.useEffect(() => {
let isStillMounted = true;
// check if current domain runs an instance
let thisUrl = new URL(window.location.origin);
thisUrl.pathname = "/api/v1/instance";
Promise.try(() => {
return fetch(thisUrl.href);
}).then((res) => {
if (res.status == 200) {
return res.json();
}
}).then((json) => {
if (json && json.uri && isStillMounted) {
setInstance(json.uri);
}
}).catch((e) => {
console.log("error checking instance response:", e);
});
return () => {
// cleanup function
isStillMounted = false;
};
}, []);
function doAuth() {
return Promise.try(() => {
return new URL(instance);
}).catch(TypeError, () => {
return new URL(`https://${instance}`);
}).then((parsedURL) => {
let url = parsedURL.toString();
let oauth = oauthLib({
instance: url,
client_name: "GotoSocial",
scope: ["admin"],
website: window.location.href
});
setOauth(oauth);
setInstance(url);
return oauth.register().then(() => {
return oauth;
});
}).then((oauth) => {
return oauth.authorize();
}).catch((e) => {
console.log("error authenticating:", e);
});
}
function updateInstance(e) {
if (e.key == "Enter") {
doAuth();
} else {
setInstance(e.target.value);
}
}
return (
<section className="login">
<h1>OAUTH Login:</h1>
<form onSubmit={(e) => e.preventDefault()}>
<label htmlFor="instance">Instance: </label>
<input value={instance} onChange={updateInstance} id="instance"/>
<button onClick={doAuth}>Authenticate</button>
</form>
</section>
);
};

View file

@ -22,10 +22,10 @@ const Promise = require("bluebird");
const React = require("react");
const Redux = require("react-redux");
const { setInstance } = require("../redux/reducers/instances").actions;
const { updateInstance, updateRegistration } = require("../lib/api");
const { setInstance } = require("../redux/reducers/oauth").actions;
const api = require("../lib/api");
module.exports = function Login() {
module.exports = function Login({error}) {
const dispatch = Redux.useDispatch();
const [ instanceField, setInstanceField ] = React.useState("");
const [ errorMsg, setErrorMsg ] = React.useState();
@ -35,7 +35,7 @@ module.exports = function Login() {
// check if current domain runs an instance
Promise.try(() => {
console.log("trying", window.location.origin);
return dispatch(updateInstance(window.location.origin));
return dispatch(api.instance.fetch(window.location.origin));
}).then((json) => {
if (instanceFieldRef.current.length == 0) { // user hasn't started typing yet
dispatch(setInstance(json.uri));
@ -49,13 +49,20 @@ module.exports = function Login() {
function tryInstance() {
Promise.try(() => {
return dispatch(updateInstance(instanceFieldRef.current)).catch((e) => {
return dispatch(api.instance.fetch(instanceFieldRef.current)).catch((e) => {
// TODO: clearer error messages for common errors
console.log(e);
throw e;
});
}).then((instance) => {
// return dispatch(updateRegistration);
dispatch(setInstance(instance.uri));
return dispatch(api.oauth.register()).catch((e) => {
console.log(e);
throw e;
});
}).then(() => {
return dispatch(api.oauth.authorize()); // will send user off-page
}).catch((e) => {
setErrorMsg(
<>
@ -78,6 +85,7 @@ module.exports = function Login() {
return (
<section className="login">
<h1>OAUTH Login:</h1>
{error}
<form onSubmit={(e) => e.preventDefault()}>
<label htmlFor="instance">Instance: </label>
<input value={instanceField} onChange={updateInstanceField} id="instance"/>

View file

@ -27,11 +27,9 @@ const { Provider } = require("react-redux");
const { PersistGate } = require("redux-persist/integration/react");
const { store, persistor } = require("./redux");
const api = require("./lib/api");
const Login = require("./components/login");
const ErrorFallback = require("./components/error");
const oauthLib = require("./lib/oauth");
require("./style.css");
@ -59,49 +57,99 @@ const nav = {
const { sidebar, panelRouter } = require("./lib/generate-views")(nav);
function App() {
const { loggedIn } = Redux.useSelector((state) => state.oauth);
const dispatch = Redux.useDispatch();
const { loginState } = Redux.useSelector((state) => state.oauth);
const reduxTempStatus = Redux.useSelector((state) => state.temporary.status);
const [ errorMsg, setErrorMsg ] = React.useState();
const [ tokenChecked, setTokenChecked ] = React.useState(false);
// const [oauth, setOauth] = React.useState();
// const [hasAuth, setAuth] = React.useState(false);
// const [oauthState, _setOauthState] = React.useState(localStorage.getItem("oauth"));
React.useEffect(() => {
Promise.try(() => {
// Process OAUTH authorization token from URL if available
if (loginState == "callback") {
let urlParams = new URLSearchParams(window.location.search);
let code = urlParams.get("code");
if (code == undefined) {
setErrorMsg(new Error("Waiting for OAUTH callback but no ?code= provided. You can try logging in again:"));
} else {
return dispatch(api.oauth.fetchToken(code));
}
}
}).then(() => {
// Check currently stored auth token for validity if available
if (loginState == "callback" || loginState == "login") {
return dispatch(api.oauth.verify());
}
}).then(() => {
setTokenChecked(true);
}).catch((e) => {
setErrorMsg(e);
console.error(e.message);
});
}, []);
// React.useEffect(() => {
// let state = localStorage.getItem("oauth");
// if (state != undefined) {
// state = JSON.parse(state);
// let restoredOauth = oauthLib(state.config, state);
// Promise.try(() => {
// return restoredOauth.callback();
// }).then(() => {
// setAuth(true);
// });
// setOauth(restoredOauth);
// }
// }, [setAuth, setOauth]);
let ErrorElement = null;
if (errorMsg != undefined) {
ErrorElement = (
<div className="error">
<b>{errorMsg.type}</b>
<span>{errorMsg.message}</span>
</div>
);
}
// if (!hasAuth && oauth && oauth.isAuthorized()) {
// setAuth(true);
// }
const LogoutElement = (
<button className="logout" onClick={() => {dispatch(api.oauth.logout());}}>
Log out
</button>
);
if (loggedIn) {
if (reduxTempStatus != undefined) {
return (
<section>
{reduxTempStatus}
</section>
);
} else if (tokenChecked && loginState == "login") {
return (
<>
<div className="sidebar">
{sidebar}
{/* <button className="logout" onClick={oauth.logout}>Log out</button> */}
{LogoutElement}
</div>
<section className="with-sidebar">
{ErrorElement}
<Switch>
{panelRouter}
</Switch>
</section>
</>
);
} else {
} else if (loginState == "none") {
return (
<Login />
<Login error={ErrorElement}/>
);
} else {
let status;
if (loginState == "login") {
status = "Verifying stored login...";
} else if (loginState == "callback") {
status = "Processing OAUTH callback...";
}
return (
<section>
<div>
{status}
</div>
{ErrorElement}
{LogoutElement}
</section>
);
}
}
function Main() {

View file

@ -19,10 +19,17 @@
"use strict";
const Promise = require("bluebird");
const { setRegistration } = require("../redux/reducers/oauth").actions;
const { APIError, OAUTHError } = require("./errors");
const oauth = require("../redux/reducers/oauth").actions;
const temporary = require("../redux/reducers/temporary").actions;
const { setInstanceInfo } = require("../redux/reducers/instances").actions;
function apiCall(base, method, route, {payload, headers={}}) {
function apiCall(state, method, route, payload) {
let base = state.oauth.instance;
let auth = state.oauth.token;
console.log(method, base, route, auth);
return Promise.try(() => {
let url = new URL(base);
url.pathname = route;
@ -32,21 +39,34 @@ function apiCall(base, method, route, {payload, headers={}}) {
body = JSON.stringify(payload);
}
let fetchHeaders = {
"Content-Type": "application/json",
...headers
let headers = {
"Accept": "application/json",
"Content-Type": "application/json"
};
if (auth != undefined) {
headers["Authorization"] = auth;
}
return fetch(url.toString(), {
method: method,
headers: fetchHeaders,
body: body
method,
headers,
body
});
}).then((res) => {
if (res.status == 200) {
return res.json();
let ok = res.ok;
// try parse json even with error
let json = res.json().catch((e) => {
throw new APIError(`JSON parsing error: ${e.message}`);
});
return Promise.all([ok, json]);
}).then(([ok, json]) => {
if (!ok) {
throw new APIError(json.error, {json});
} else {
throw res;
return json;
}
});
}
@ -55,40 +75,122 @@ function getCurrentUrl() {
return `${window.location.origin}${window.location.pathname}`;
}
function updateInstance(domain) {
function fetchInstance(domain) {
return function(dispatch, getState) {
/* check if domain is valid instance, then register client if needed */
return Promise.try(() => {
return apiCall(domain, "GET", "/api/v1/instance", {
headers: {
"Content-Type": "text/plain"
}
});
let lookup = getState().instances.info[domain];
if (lookup != undefined) {
return lookup;
}
// apiCall expects to pull the domain from state,
// but we don't want to store it there yet
// so we mock the API here with our function argument
let fakeState = {
oauth: {instance: domain}
};
return apiCall(fakeState, "GET", "/api/v1/instance");
}).then((json) => {
if (json && json.uri) { // TODO: validate instance json more?
dispatch(setInstanceInfo(json.uri, json));
dispatch(setInstanceInfo([json.uri, json]));
return json;
}
});
};
}
function updateRegistration() {
function fetchRegistration(scopes=[]) {
return function(dispatch, getState) {
let base = getState().oauth.instance;
return Promise.try(() => {
return apiCall(base, "POST", "/api/v1/apps", {
return apiCall(getState(), "POST", "/api/v1/apps", {
client_name: "GoToSocial Settings",
scopes: "write admin",
scopes: scopes.join(" "),
redirect_uris: getCurrentUrl(),
website: getCurrentUrl()
});
}).then((json) => {
console.log(json);
dispatch(setRegistration(base, json));
json.scopes = scopes;
dispatch(oauth.setRegistration(json));
});
};
}
module.exports = { updateInstance, updateRegistration };
function startAuthorize() {
return function(dispatch, getState) {
let state = getState();
let reg = state.oauth.registration;
let base = new URL(state.oauth.instance);
base.pathname = "/oauth/authorize";
base.searchParams.set("client_id", reg.client_id);
base.searchParams.set("redirect_uri", getCurrentUrl());
base.searchParams.set("response_type", "code");
base.searchParams.set("scope", reg.scopes.join(" "));
dispatch(oauth.setLoginState("callback"));
dispatch(temporary.setStatus("Redirecting to instance login..."));
// send user to instance's login flow
window.location.assign(base.href);
};
}
function fetchToken(code) {
return function(dispatch, getState) {
let reg = getState().oauth.registration;
return Promise.try(() => {
if (reg == undefined || reg.client_id == undefined) {
throw new OAUTHError("Callback code present, but no client registration is available from localStorage. \nNote: localStorage is unavailable in Private Browsing.");
}
return apiCall(getState(), "POST", "/oauth/token", {
client_id: reg.client_id,
client_secret: reg.client_secret,
redirect_uri: getCurrentUrl(),
grant_type: "authorization_code",
code: code
});
}).then((json) => {
console.log(json);
window.history.replaceState({}, document.title, window.location.pathname);
return dispatch(oauth.login(json));
});
};
}
function verifyAuth() {
return function(dispatch, getState) {
console.log(getState());
return Promise.try(() => {
return apiCall(getState(), "GET", "/api/v1/accounts/verify_credentials");
}).then((account) => {
console.log(account);
}).catch((e) => {
dispatch(oauth.remove());
throw e;
});
};
}
function oauthLogout() {
return function(dispatch, _getState) {
// TODO: GoToSocial does not have a logout API route yet
return dispatch(oauth.remove());
};
}
module.exports = {
instance: {
fetch: fetchInstance
},
oauth: {
register: fetchRegistration,
authorize: startAuthorize,
fetchToken,
verify: verifyAuth,
logout: oauthLogout
}
};

View file

@ -0,0 +1,26 @@
/*
GoToSocial
Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
"use strict";
const createError = require("create-error");
module.exports = {
APIError: createError("APIError"),
OAUTHError: createError("OAUTHError"),
};

View file

@ -1,227 +0,0 @@
/*
GoToSocial
Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
"use strict";
const Promise = require("bluebird");
function getCurrentUrl() {
return window.location.origin + window.location.pathname; // strips ?query=string and #hash
}
module.exports = function oauthClient(config, initState) {
/* config:
instance: instance domain (https://testingtesting123.xyz)
client_name: "GoToSocial Admin Panel"
scope: []
website:
*/
let state = initState;
if (initState == undefined) {
state = localStorage.getItem("oauth");
if (state == undefined) {
state = {
config
};
storeState();
} else {
state = JSON.parse(state);
}
}
function storeState() {
localStorage.setItem("oauth", JSON.stringify(state));
}
/* register app
/api/v1/apps
*/
function register() {
if (state.client_id != undefined) {
return true; // we already have a registration
}
let url = new URL(config.instance);
url.pathname = "/api/v1/apps";
return fetch(url.href, {
method: "POST",
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
client_name: config.client_name,
redirect_uris: getCurrentUrl(),
scopes: config.scope.join(" "),
website: getCurrentUrl()
})
}).then((res) => {
if (res.status != 200) {
throw res;
}
return res.json();
}).then((json) => {
state.client_id = json.client_id;
state.client_secret = json.client_secret;
storeState();
});
}
/* authorize:
/oauth/authorize
?client_id=CLIENT_ID
&redirect_uri=window.location.href
&response_type=code
&scope=admin
*/
function authorize() {
let url = new URL(config.instance);
url.pathname = "/oauth/authorize";
url.searchParams.set("client_id", state.client_id);
url.searchParams.set("redirect_uri", getCurrentUrl());
url.searchParams.set("response_type", "code");
url.searchParams.set("scope", config.scope.join(" "));
window.location.assign(url.href);
}
function callback() {
if (state.access_token != undefined) {
return; // we're already done :)
}
let params = (new URL(window.location)).searchParams;
let token = params.get("code");
if (token != null) {
console.log("got token callback:", token);
}
return authorizeToken(token)
.catch((e) => {
console.log("Error processing oauth callback:", e);
logout(); // just to be sure
});
}
function authorizeToken(token) {
let url = new URL(config.instance);
url.pathname = "/oauth/token";
return fetch(url.href, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
client_id: state.client_id,
client_secret: state.client_secret,
redirect_uri: getCurrentUrl(),
grant_type: "authorization_code",
code: token
})
}).then((res) => {
if (res.status != 200) {
throw res;
}
return res.json();
}).then((json) => {
state.access_token = json.access_token;
storeState();
window.location = getCurrentUrl(); // clear ?token=
});
}
function isAuthorized() {
return (state.access_token != undefined);
}
function apiRequest(path, method, data, type="json", accept="json") {
if (!isAuthorized()) {
throw new Error("Not Authenticated");
}
let url = new URL(config.instance);
let [p, s] = path.split("?");
url.pathname = p;
if (s != undefined) {
url.search = s;
}
let headers = {
"Authorization": `Bearer ${state.access_token}`,
"Accept": accept == "json" ? "application/json" : "*/*"
};
let body = data;
if (type == "json" && body != undefined) {
headers["Content-Type"] = "application/json";
body = JSON.stringify(data);
}
return fetch(url.href, {
method,
headers,
body
}).then((res) => {
return Promise.all([res.json(), res]);
}).then(([json, res]) => {
if (res.status != 200) {
if (json.error) {
throw new Error(json.error);
} else {
throw new Error(`${res.status}: ${res.statusText}`);
}
} else {
return json;
}
}).catch(e => {
if (e instanceof SyntaxError) {
throw new Error("Error: The GtS API returned a non-json error. This usually means a network problem, or an issue with your instance's reverse proxy configuration.", {cause: e});
} else {
throw e;
}
});
}
function logout() {
let url = new URL(config.instance);
url.pathname = "/oauth/revoke";
return fetch(url.href, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
client_id: state.client_id,
client_secret: state.client_secret,
token: state.access_token,
})
}).then((res) => {
if (res.status != 200) {
// GoToSocial doesn't actually implement this route yet,
// so error is to be expected
return;
}
return res.json();
}).catch(() => {
// see above
}).then(() => {
localStorage.removeItem("oauth");
window.location = getCurrentUrl();
});
}
return {
register, authorize, callback, isAuthorized, apiRequest, logout
};
};

View file

@ -27,12 +27,14 @@ const persistConfig = {
key: "gotosocial-settings",
storage: require("redux-persist/lib/storage").default,
stateReconciler: require("redux-persist/lib/stateReconciler/autoMergeLevel2").default,
whitelist: ['oauth']
whitelist: ["oauth"],
blacklist: ["temporary"]
};
const combinedReducers = combineReducers({
oauth: require("./reducers/oauth").reducer,
instances: require("./reducers/instances").reducer,
temporary: require("./reducers/temporary").reducer,
});
const persistedReducer = persistReducer(persistConfig, combinedReducers);

View file

@ -24,12 +24,8 @@ module.exports = createSlice({
name: "instances",
initialState: {
info: {},
current: undefined
},
reducers: {
setInstance: (state, {payload}) => {
state.current = payload;
},
setInstanceInfo: (state, {payload}) => {
let [key, info] = payload;
state.info[key] = info;

View file

@ -23,13 +23,26 @@ const {createSlice} = require("@reduxjs/toolkit");
module.exports = createSlice({
name: "oauth",
initialState: {
loggedIn: false,
registrations: {}
loginState: 'none'
},
reducers: {
setInstance: (state, {payload}) => {
state.instance = payload;
},
setRegistration: (state, {payload}) => {
let [key, info] = payload;
state.instanceRegistration[key] = info;
state.registration = payload;
},
setLoginState: (state, {payload}) => {
state.loginState = payload;
},
login: (state, {payload}) => {
state.token = `${payload.token_type} ${payload.access_token}`;
state.loginState = "login";
},
remove: (state, {_payload}) => {
delete state.token;
delete state.registration;
state.loginState = "none";
}
}
});

View file

@ -0,0 +1,32 @@
/*
GoToSocial
Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
"use strict";
const {createSlice} = require("@reduxjs/toolkit");
module.exports = createSlice({
name: "temporary",
initialState: {
},
reducers: {
setStatus: function(state, {payload}) {
state.status = payload;
}
}
});

View file

@ -31,6 +31,8 @@ section {
#root {
display: grid;
grid-template-columns: 1fr min(92%, 90ch) 1fr;
width: 100vw;
max-width: 100vw;
section.with-sidebar {
border-left: none;
@ -128,12 +130,17 @@ input, select, textarea {
}
.error {
background: $error2;
border: 1px solid $error1;
color: $error-fg;
background: $error-bg;
border: 0.02rem solid $error-fg;
border-radius: $br;
color: $error1;
font-weight: bold;
padding: 0.5rem;
white-space: pre-wrap; // to show \n in json errors
a {
color: $error-link;
}
pre {
background: $bg;