diff --git a/api/src/config/oauth2orize.js b/api/src/config/oauth2orize.js
new file mode 100644
index 0000000..e69de29
diff --git a/api/src/config/passport.js b/api/src/config/passport.js
index 8d97068..f3daf41 100644
--- a/api/src/config/passport.js
+++ b/api/src/config/passport.js
@@ -8,6 +8,31 @@ const { User, AccessToken, RefreshToken } = require('../models');
const secret = require('../config').secret;
+// used to serialize the user for the session
+passport.serializeUser(function (user, done) {
+ done(null, user._id);
+});
+
+// used to deserialize the user
+passport.deserializeUser(function (id, done) {
+ User.findById(id, function (err, user) {
+ done(err, user);
+ });
+});
+
+async function loginWithPassword(email, password, done) {
+ try {
+ const user = await User.findOne({ email: email });
+ if (!user || !user.validPassword(password)) {
+ return done(null, false, { errors: { 'email or password': 'is invalid' } });
+ }
+
+ return done(null, user);
+ } catch (err) {
+ done(err);
+ }
+}
+
passport.use(
'usernameAndPassword',
new LocalStrategy(
@@ -16,18 +41,19 @@ passport.use(
passwordField: 'user[password]',
session: false,
},
- async function (email, password, done) {
- try {
- const user = await User.findOne({ email: email });
- if (!user || !user.validPassword(password)) {
- return done(null, false, { errors: { 'email or password': 'is invalid' } });
- }
+ loginWithPassword,
+ ),
+);
- return done(null, user);
- } catch (err) {
- done(err);
- }
+passport.use(
+ 'usernameAndPasswordSession',
+ new LocalStrategy(
+ {
+ usernameField: 'email',
+ passwordField: 'password',
+ session: true,
},
+ loginWithPassword,
),
);
@@ -57,7 +83,7 @@ passport.use(
async function (token, done) {
try {
// we used to put the user ID into the token directly :(
- const {id} = token
+ const { id } = token;
const user = await User.findById(id);
return done(null, user || false);
} catch (err) {
@@ -74,7 +100,7 @@ passport.use(
const accessToken = await AccessToken.findOne({ token }).populate('user');
if (accessToken && accessToken.user) {
// TODO: scope
- return done(null, user, { scope: accessToken.scope });
+ return done(null, accessToken.user, { scope: accessToken.scope });
} else {
return done(null, false);
}
@@ -134,12 +160,12 @@ passport.use(
/**
* This function creates a middleware that does a passport authentication.
*/
-function createMiddleware(strategies, required) {
+function createMiddleware(strategies, required = true, session = false) {
return (req, res, next) => {
- passport.authenticate(strategies, { session: false }, (err, user, info) => {
+ passport.authenticate(strategies, { session }, (err, user, info) => {
// If this authentication produced an error, throw it. In a chain of
// multiple strategies, errors are ignored, unless every strategy errors.
- if (required && err) {
+ if (err) {
return next(err);
}
@@ -154,9 +180,9 @@ function createMiddleware(strategies, required) {
return res.status(403).json({ errors: { 'E-Mail-Bestätigung': 'noch nicht erfolgt' } });
}
- req.user = user
- req.authInfo = info
- req.scope = (info && info.scope) || '*'
+ req.user = user;
+ req.authInfo = info;
+ req.scope = (info && info.scope) || '*';
return next();
})(req, res, next);
@@ -173,6 +199,8 @@ module.exports = {
// on the /users/login route, and later on oauth routes
usernameAndPassword: createMiddleware('usernameAndPassword', true),
+ usernameAndPasswordSession: createMiddleware('usernameAndPasswordSession', false, true),
+
// will be used to verify a refresh token on the route that will exchange the
// refresh token for a new access token (not in use yet)
refreshToken: createMiddleware('refreshToken', true),
diff --git a/api/src/index.js b/api/src/index.js
index fc371a6..8a8b5cc 100644
--- a/api/src/index.js
+++ b/api/src/index.js
@@ -6,16 +6,14 @@ const cors = require('cors');
const errorhandler = require('errorhandler');
const passport = require('passport');
-require('./config/passport')
+require('./config/passport');
const isProduction = process.env.NODE_ENV === 'production';
// Create global app object
const app = express();
-
app.use(cors());
-app.use(passport.initialize());
// Normal express config defaults
app.use(require('morgan')('dev'));
@@ -26,6 +24,8 @@ app.use(require('method-override')());
app.use(express.static(path.join(__dirname, 'public')));
app.use(session({ secret: 'obsobs', cookie: { maxAge: 60000 }, resave: false, saveUninitialized: false }));
+app.use(passport.initialize());
+app.use(passport.session());
if (!isProduction) {
app.use(errorhandler());
diff --git a/api/src/models/AccessToken.js b/api/src/models/AccessToken.js
index 9cb4552..f658743 100644
--- a/api/src/models/AccessToken.js
+++ b/api/src/models/AccessToken.js
@@ -6,6 +6,7 @@ const schema = new mongoose.Schema(
{
token: { index: true, type: String, required: true, unique: true },
user: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
+ client: { type: mongoose.Schema.Types.ObjectId, ref: 'Client', required: true },
expiresAt: { type: Date, required: true },
scope: { type: String, required: true, defaultValue: '*' },
},
@@ -30,12 +31,13 @@ class AccessToken extends mongoose.Model {
return 'Bearer ' + this.token;
}
- static generate(user, scope = '*', expiresInSeconds = 24 * 60 * 60) {
+ static generate(client, user, scope = '*', expiresInSeconds = 24 * 60 * 60) {
const token = crypto.randomBytes(32).toString('hex');
return new AccessToken({
token,
user,
+ client,
expiresAt: new Date(new Date().getTime() + 1000 * expiresInSeconds),
scope,
});
diff --git a/api/src/models/AuthorizationCode.js b/api/src/models/AuthorizationCode.js
new file mode 100644
index 0000000..ea109d2
--- /dev/null
+++ b/api/src/models/AuthorizationCode.js
@@ -0,0 +1,33 @@
+const mongoose = require('mongoose');
+const crypto = require('crypto');
+
+const schema = new mongoose.Schema(
+ {
+ code: { type: String, unique: true, required: true },
+ user: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
+ client: { type: mongoose.Schema.Types.ObjectId, ref: 'Client', required: true },
+ scope: { type: String, required: true, defaultValue: '*' },
+ redirectUri: {type: String, required: true},
+ expiresAt: {type: Date, required: true},
+ },
+ { timestamps: true },
+);
+
+class AuthorizationCode extends mongoose.Model {
+ static generate(client, user, redirectUri, scope = '*', expiresInSeconds = 60) {
+ const code = crypto.randomBytes(8).toString('hex');
+
+ return new AuthorizationCode({
+ code,
+ user,
+ client,
+ redirectUri,
+ expiresAt: new Date(new Date().getTime() + 1000 * expiresInSeconds),
+ scope,
+ });
+ }
+}
+
+mongoose.model(AuthorizationCode, schema);
+
+module.exports = AuthorizationCode;
diff --git a/api/src/models/Client.js b/api/src/models/Client.js
new file mode 100644
index 0000000..3beb310
--- /dev/null
+++ b/api/src/models/Client.js
@@ -0,0 +1,20 @@
+const mongoose = require('mongoose');
+
+const schema = new mongoose.Schema(
+ {
+ clientId: { type: String, required: true }, // this is external, so we do not use the ObjectID
+ validRedirectUris: [{ type: String }],
+
+ // this implementation deals with public clients only, so the following fields are not required:
+ // scope: {type: String, required: true, default: '*'}, // max possible scope
+ // confidential: {type: Boolean}, // whether this is a non-public, aka confidential client
+ // clientSecret: { type: String },
+ },
+ { timestamps: true },
+);
+
+class Client extends mongoose.Model {}
+
+mongoose.model(Client, schema);
+
+module.exports = Client;
diff --git a/api/src/models/RefreshToken.js b/api/src/models/RefreshToken.js
index f57b27e..dfe463c 100644
--- a/api/src/models/RefreshToken.js
+++ b/api/src/models/RefreshToken.js
@@ -8,6 +8,7 @@ const schema = new mongoose.Schema(
{
token: { index: true, type: String, required: true, unique: true },
user: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
+ client: { type: mongoose.Schema.Types.ObjectId, ref: 'Client', required: true },
expiresAt: { type: Date, required: false },
scope: { type: String, required: true, defaultValue: '*' },
},
@@ -28,11 +29,12 @@ class RefreshToken extends mongoose.Model {
return this.expiresAt == null || this.expiresAt < new Date()
}
- static generate(user, scope = '*', expiresInSeconds = 24 * 60 * 60) {
+ static generate(client, user, scope = '*', expiresInSeconds = 24 * 60 * 60) {
const token = crypto.randomBytes(32).toString('hex');
return new RefreshToken({
token,
+ client,
user,
expiresAt: new Date(new Date().getTime() + 1000 * expiresInSeconds),
scope,
diff --git a/api/src/models/index.js b/api/src/models/index.js
index 640fda2..332a4a1 100644
--- a/api/src/models/index.js
+++ b/api/src/models/index.js
@@ -1,4 +1,6 @@
module.exports.AccessToken = require('./AccessToken')
+module.exports.AuthorizationCode = require('./AuthorizationCode')
+module.exports.Client = require('./Client')
module.exports.Comment = require('./Comment')
module.exports.RefreshToken = require('./RefreshToken')
module.exports.Track = require('./Track')
diff --git a/api/src/routes/api/users.js b/api/src/routes/api/users.js
index 3138f68..8451217 100644
--- a/api/src/routes/api/users.js
+++ b/api/src/routes/api/users.js
@@ -42,6 +42,7 @@ router.put(
}),
);
+// Remove this at some point
router.post('/users/login',
auth.usernameAndPassword,
wrapRoute((req, res) => {
diff --git a/api/src/routes/auth.js b/api/src/routes/auth.js
index 9c6d486..59b1e7a 100644
--- a/api/src/routes/auth.js
+++ b/api/src/routes/auth.js
@@ -1,45 +1,287 @@
-const passport = require('passport')
-const {LocalStrategy} = require('passport-local')
-const secret = require('../config').secret;
-const User = require('../models/User');
+const router = require('express').Router();
+const passport = require('passport');
+const { URL } = require('url');
+const querystring = require('querystring');
-function getTokenFromHeader(req) {
- const authorization = req.headers.authorization;
- const [tokenType, token] = (authorization && authorization.split(' ')) || [];
+const { AuthorizationCode, AccessToken, RefreshToken, Client } = require('../models');
+const wrapRoute = require('../_helpers/wrapRoute');
- if (tokenType === 'Token' || tokenType === 'Bearer') {
- return token;
- }
-
- return null;
+// Check whether the "bigScope" fully includes the "smallScope".
+function scopeIncludes(smallScope, bigScope) {
+ const smallScopeParts = smallScope.split(/\s/);
+ const bigScopeParts = bigScope.split(/\s/);
+ return bigScopeParts.includes('*') || smallScopeParts.every((part) => bigScopeParts.includes(part));
}
-const jwtOptional = jwt({
- secret: secret,
- userProperty: 'authInfo',
- credentialsRequired: false,
- getToken: getTokenFromHeader,
- algorithms: ['HS256'],
-});
+function returnError(res, error, errorDescription = undefined, status = 400) {
+ return res
+ .status(status)
+ .json({ error, ...(errorDescription != null ? { error_description: errorDescription } : {}) });
+}
-async function getUserIdMiddleware(req, res, next) {
- try {
- const authorization = req.headers.authorization;
- const [tokenType, token] = (authorization && authorization.split(' ')) || [];
+function redirectWithParams(res, redirectUri, params) {
+ const targetUrl = new URL(redirectUri);
+ for (const [key, value] of Object.entries(params)) {
+ targetUrl.searchParams.append(key, value);
+ }
+ return res.redirect(targetUrl.toString());
+}
- if (tokenType === 'Token' || tokenType === 'Bearer') {
+const ALL_SCOPE_NAMES = `
+ tracks.create
+ tracks.update
+ tracks.list
+ tracks.show
+ tracks.delete
+ users.update
+ users.show
+ tracks.comments.create
+ tracks.comments.update
+ tracks.comments.list
+ tracks.comments.show
+`.split(/\s/);
- // only parse the token as jwt if it looks like one, otherwise we get an error
- return jwtOptional(req, res, next);
+function isValidScope(scope) {
+ return scope === '*' || scopeIncludes(scope, ALL_SCOPE_NAMES.join(' '));
+}
- } else if (tokenType === 'OBSUserId') {
- req.authInfo = { id: token.trim() };
- next();
- req.authInfo = null;
- next();
+router.post(
+ '/login',
+ passport.authenticate('usernameAndPasswordSession'),
+ wrapRoute((req, res, next) => {
+ if (!req.user) {
+ return res.redirect('/login');
}
- } catch (err) {
- next(err);
- }
-}
+ if (req.session.next) {
+ res.redirect(req.session.next);
+ req.session.next = null;
+ return;
+ }
+ return res.type('html').end('You are logged in.');
+ }),
+);
+
+router.get(
+ '/login',
+ wrapRoute(async (req, res) => {
+ if (req.user) {
+ return res.type('html').end('Already logged in, nothing to do.');
+ }
+
+ res
+ .type('html')
+ .end(
+ '
',
+ );
+ }),
+);
+
+router.get(
+ '/authorize',
+ passport.authenticate('session'),
+ wrapRoute(async (req, res) => {
+ if (!req.user) {
+ console.log(req);
+ req.session.next = req.url;
+ return res.redirect('/login');
+ }
+
+ try {
+ const {
+ client_id: clientId,
+ redirect_uri: redirectUri,
+ response_type: responseType,
+ scope = '*', // fallback to "all" scope
+ } = req.query;
+
+ // 1. Find our client and check if it exists
+ if (!clientId) {
+ return returnError(res, 'invalid_request', 'client_id parameter required');
+ }
+
+ const client = await Client.findOne({ clientId });
+ if (!client) {
+ return returnError(res, 'invalid_client', 'unknown client');
+ }
+
+ // 2. Check that we have a redirect_uri. In addition to [RFC6749] we
+ // *always* require a redirect_uri.
+ if (!redirectUri) {
+ return returnError(res, 'invalid_request', 'redirect_uri parameter required');
+ }
+
+ // We enforce that the redirectUri exactly matches one of the provided URIs
+ if (!client.validRedirectUris.includes(redirectUri)) {
+ return returnError(res, 'invalid_request', 'invalid redirect_uri');
+ }
+
+ // 3. Find out which type of response to use. [RFC6749] requires one of
+ // "code" or "token", but "token" is implicit grant and we do not support
+ // that.
+
+ if (responseType !== 'code') {
+ return redirectWithParams(res, redirectUri, {
+ error: 'unsupported_grant_type',
+ error_description: 'only authorization code flow with PKCE is supported by this server',
+ });
+ }
+
+ // 4. Get the scope.
+ if (!isValidScope(scope)) {
+ return redirectWithParams(res, redirectUri, {
+ error: 'invalid_scope',
+ error_description: 'the requested scope is not known',
+ });
+ }
+
+ // Ok, let's save all this in the session, and show a dialog for the
+ // decision to the user.
+
+ req.session.authorizationTransaction = {
+ responseType,
+ clientId,
+ redirectUri,
+ scope,
+ expiresAt: new Date().getTime() + 1000 * 60 * 2, // 2 minute decision time
+ };
+
+ res.type('html').end(`
+
+ You are about to confirm a login to client ${clientId}
+ with redirectUri ${redirectUri}
and scope ${scope}
.
+ You have 2 minutes time for your decision.
+
+
+
+
+ `);
+ } catch (err) {
+ console.error(err);
+ res.status(400).json({ error: 'invalid_request', error_description: 'unknown error' });
+ }
+ }),
+);
+
+router.post(
+ ['/authorize/approve', '/authorize/decline'],
+ passport.authenticate('session'),
+ wrapRoute(async (req, res) => {
+ if (!req.session.authorizationTransaction) {
+ return res.sendStatus(400);
+ }
+
+ if (!req.user) {
+ return res.sendStatus(400);
+ }
+
+ const { clientId, redirectUri, scope, expiresAt } = req.session.authorizationTransaction;
+
+ if (expiresAt < new Date().getTime()) {
+ return res.status(400).type('html').end(`Your authorization has expired. Please go back and retry the process.`);
+ }
+
+ const client = await Client.findOne({ clientId });
+
+ // invalidate the transaction
+ req.session.authorizationTransaction = null;
+
+ if (req.path === '/authorize/approve') {
+ const code = AuthorizationCode.generate(client, req.user, redirectUri, scope);
+ await code.save();
+
+ return redirectWithParams(res, redirectUri, { code: code.code, scope });
+ } else {
+ return redirectWithParams(res, redirectUri, { error: 'access_denied' });
+ }
+ }),
+);
+
+/**
+ * This function is called when the client presents an authorization code
+ * (generated above) and wants it turned into an access (and possibly refresh)
+ * token.
+ */
+router.get(
+ '/token',
+ wrapRoute(async (req, res) => {
+ const {
+ grant_type: grantType,
+ code,
+ client_id: clientId,
+ redirect_uri: redirectUri,
+ //
+ } = req.query;
+
+ if (!grantType || grantType !== 'authorization_code') {
+ return returnError(
+ res,
+ 'unsupported_grant_type',
+ 'only authorization code flow with PKCE is supported by this server',
+ );
+ }
+
+ if (!code) {
+ return returnError(res, 'invalid_request', 'code parameter required');
+ }
+
+ if (!code) {
+ return returnError(res, 'invalid_client', 'client_id parameter required');
+ }
+
+ if (!redirectUri) {
+ return returnError(res, 'invalid_request', 'redirect_uri parameter required');
+ }
+
+ const client = await Client.findOne({ clientId });
+
+ if (!client) {
+ return returnError(res, 'invalid_client', 'invalid client_id');
+ }
+
+ const authorizationCode = await AuthorizationCode.findOne({ code });
+ if ( !authorizationCode ) {
+ console.log('no code found')
+ return returnError(res, 'invalid_grant', 'invalid authorization code');
+ }
+ if (authorizationCode.redirectUri !== redirectUri) {
+ console.log('redirect_uri mismatch')
+ return returnError(res, 'invalid_grant', 'invalid authorization code');
+ }
+ if (authorizationCode.expiresAt <= new Date().getTime()) {
+ console.log('expired')
+ return returnError(res, 'invalid_grant', 'invalid authorization code');
+ }
+ if (!client._id.equals(authorizationCode.client)) {
+ console.log('client mismatch', authorizationCode.client, client._id)
+ return returnError(res, 'invalid_grant', 'invalid authorization code');
+ }
+
+ // invalidate auth code now, before generating tokens
+ await AuthorizationCode.deleteOne({ _id: authorizationCode._id });
+
+ const accessToken = AccessToken.generate(authorizationCode.client, authorizationCode.user, authorizationCode.scope);
+
+ const refreshToken = RefreshToken.generate(
+ authorizationCode.client,
+ authorizationCode.user,
+ authorizationCode.scope,
+ );
+
+ await Promise.all([accessToken.save(), refreshToken.save()]);
+
+ return res.json({
+ access_token: accessToken.token,
+ token_type: 'Bearer',
+ expires_in: Math.round((accessToken.expiresAt - new Date().getTime()) / 1000),
+ refresh_token: refreshToken.token,
+ scope: accessToken.scope,
+ });
+ }),
+);
+
+module.exports = router;
diff --git a/api/src/routes/index.js b/api/src/routes/index.js
index 1b24dcf..5a39f4d 100644
--- a/api/src/routes/index.js
+++ b/api/src/routes/index.js
@@ -2,4 +2,7 @@ const router = require('express').Router();
router.use('/api', require('./api'));
+// no prefix
+router.use(require('./auth'));
+
module.exports = router;
diff --git a/frontend/src/App.js b/frontend/src/App.js
index 7b03ada..1453b0c 100644
--- a/frontend/src/App.js
+++ b/frontend/src/App.js
@@ -4,27 +4,19 @@ import {Icon, Button} from 'semantic-ui-react'
import {BrowserRouter as Router, Switch, Route, Link} from 'react-router-dom'
import styles from './App.module.scss'
-import api from './api'
import {
- LoginPage,
LogoutPage,
NotFoundPage,
TracksPage,
TrackPage,
HomePage,
UploadPage,
- RegistrationPage,
-} from './pages'
+ LoginRedirectPage,
+} from 'pages'
+import {LoginButton} from 'components'
const App = connect((state) => ({login: state.login}))(function App({login}) {
- // update the API header on each render, the App is rerendered when the login changes
- if (login) {
- api.setAuthorizationHeader('Token ' + login.token)
- } else {
- api.setAuthorizationHeader(null)
- }
-
return (
@@ -68,9 +60,7 @@ const App = connect((state) => ({login: state.login}))(function App({login}) {
) : (
<>
-
- Login
-
+
>
)}
@@ -92,11 +82,8 @@ const App = connect((state) => ({login: state.login}))(function App({login}) {
-
-
-
-
-
+
+
diff --git a/frontend/src/api.js b/frontend/src/api.js
index d4327fe..e04cd99 100644
--- a/frontend/src/api.js
+++ b/frontend/src/api.js
@@ -1,19 +1,124 @@
import {stringifyParams} from 'query'
+import globalStore from 'store'
+import {setAuth, invalidateAccessToken, resetAuth} from 'reducers/auth'
+import {setLogin} from 'reducers/login'
+import config from 'config.json'
class API {
- setAuthorizationHeader(authorization) {
- this.authorization = authorization
+ constructor(store) {
+ this.store = store
+ this._getValidAccessTokenPromise = null
+ }
+
+ /**
+ * Return an access token, if it is (still) valid. If not, and a refresh
+ * token exists, use the refresh token to issue a new access token. If that
+ * fails, or neither is available, return `null`. This should usually result
+ * in a redirect to login.
+ */
+ async getValidAccessToken() {
+ // prevent multiple parallel refresh processes
+ if (this._getValidAccessTokenPromise) {
+ return await this._getValidAccessTokenPromise
+ } else {
+ this._getValidAccessTokenPromise = this._getValidAccessToken()
+ const result = await this._getValidAccessTokenPromise
+ this._getValidAccessTokenPromise = null
+ return result
+ }
+ }
+
+ async _getValidAccessToken() {
+ let {auth} = this.store.getState()
+
+ if (!auth) {
+ return null
+ }
+
+ const {tokenType, accessToken, refreshToken, expiresAt} = auth
+
+ // access token is valid
+ if (accessToken && expiresAt > new Date().getTime()) {
+ return `${tokenType} ${accessToken}`
+ }
+
+ if (!refreshToken) {
+ return null
+ }
+
+ // Try to use the refresh token
+ const url = new URL(config.auth.tokenEndpoint)
+ url.searchParams.append('refresh_token', refreshToken)
+ url.searchParams.append('grant_type', 'refresh_token')
+ url.searchParams.append('scope', config.auth.scope)
+ const response = await window.fetch(url.toString())
+ const json = await response.json()
+
+ if (response.status === 200 && json != null && json.error == null) {
+ auth = this.getAuthFromTokenResponse(json)
+ this.store.dispatch(setAuth(auth))
+ return `${auth.tokenType} ${auth.accessToken}`
+ } else {
+ console.warn('Could not use refresh token, error response:', json)
+ this.store.dispatch(resetAuth())
+ return null
+ }
+ }
+
+ async exchangeAuthorizationCode(code) {
+ const url = new URL(config.auth.tokenEndpoint)
+ url.searchParams.append('code', code)
+ url.searchParams.append('grant_type', 'authorization_code')
+ url.searchParams.append('client_id', config.auth.clientId)
+ url.searchParams.append('redirect_uri', config.auth.redirectUri)
+ const response = await window.fetch(url.toString())
+ const json = await response.json()
+
+ if (json.error) {
+ return json
+ }
+
+ const auth = api.getAuthFromTokenResponse(json)
+ this.store.dispatch(setAuth(auth))
+
+ const {user} = await this.get('/user')
+ this.store.dispatch(setLogin(user))
+
+ return true
+ }
+
+ getLoginUrl() {
+ const loginUrl = new URL(config.auth.authorizationEndpoint)
+ loginUrl.searchParams.append('client_id', config.auth.clientId)
+ loginUrl.searchParams.append('scope', config.auth.scope)
+ loginUrl.searchParams.append('redirect_uri', config.auth.redirectUri)
+ loginUrl.searchParams.append('response_type', 'code')
+
+ // TODO: Implement PKCE
+
+ return loginUrl.toString()
}
async fetch(url, options = {}) {
+ const accessToken = await this.getValidAccessToken()
+
const response = await window.fetch('/api' + url, {
...options,
headers: {
...(options.headers || {}),
- Authorization: this.authorization,
+ Authorization: accessToken,
},
})
+ if (response.status === 401) {
+ // Unset login, since 401 means that we're not logged in. On the next
+ // request with `getValidAccessToken()`, this will be detected and the
+ // refresh token is used (if still valid).
+ this.store.dispatch(invalidateAccessToken())
+
+ throw new Error('401 Unauthorized')
+ }
+
if (response.status === 200) {
return await response.json()
} else {
@@ -26,15 +131,15 @@ class API {
let headers = {...(options.headers || {})}
if (!(typeof body === 'string' || body instanceof FormData)) {
- body = JSON.stringify(body)
- headers['Content-Type'] = 'application/json'
+ body = JSON.stringify(body)
+ headers['Content-Type'] = 'application/json'
}
return await this.fetch(url, {
...options,
body,
method: 'post',
- headers
+ headers,
})
}
@@ -46,8 +151,18 @@ class API {
async delete(url, options = {}) {
return await this.get(url, {...options, method: 'delete'})
}
+
+ getAuthFromTokenResponse(tokenResponse) {
+ return {
+ tokenType: tokenResponse.token_type,
+ accessToken: tokenResponse.access_token,
+ refreshToken: tokenResponse.refresh_token,
+ expiresAt: new Date().getTime() + tokenResponse.expires_in * 1000,
+ scope: tokenResponse.scope,
+ }
+ }
}
-const api = new API()
+const api = new API(globalStore)
export default api
diff --git a/frontend/src/components/LoginButton.js b/frontend/src/components/LoginButton.js
new file mode 100644
index 0000000..dbe3c4a
--- /dev/null
+++ b/frontend/src/components/LoginButton.js
@@ -0,0 +1,10 @@
+import {Button} from 'semantic-ui-react'
+
+import api from 'api'
+
+export default function LoginButton(props) {
+ // TODO: Implement PKCE, generate login URL when clicked (with challenge),
+ // and then redirect there.
+ const href = api.getLoginUrl()
+ return Login
+}
diff --git a/frontend/src/components/LoginForm.js b/frontend/src/components/LoginForm.js
deleted file mode 100644
index c3a90bd..0000000
--- a/frontend/src/components/LoginForm.js
+++ /dev/null
@@ -1,58 +0,0 @@
-import React from 'react'
-import {connect} from 'react-redux'
-import {Form, Button} from 'semantic-ui-react'
-
-import {login as loginAction} from '../reducers/login'
-
-async function fetchLogin(email, password) {
- const response = await window.fetch('/api/users/login', {
- body: JSON.stringify({user: {email, password}}),
- method: 'post',
- headers: {
- 'Content-Type': 'application/json',
- Accept: 'application/json',
- },
- })
-
- const result = await response.json()
-
- if (result.user) {
- return result.user
- } else {
- throw new Error('invalid credentials')
- }
-}
-
-const LoginForm = connect(
- (state) => ({loggedIn: Boolean(state.login)}),
- (dispatch) => ({
- dispatchLogin: (user) => dispatch(loginAction(user)),
- })
-)(function LoginForm({loggedIn, dispatchLogin, className}) {
- const [email, setEmail] = React.useState('')
- const [password, setPassword] = React.useState('')
- const onChangeEmail = React.useCallback((e) => setEmail(e.target.value), [])
- const onChangePassword = React.useCallback((e) => setPassword(e.target.value), [])
-
- const onSubmit = React.useCallback(() => fetchLogin(email, password).then(dispatchLogin), [
- email,
- password,
- dispatchLogin,
- ])
-
- return loggedIn ? null : (
-
- e-Mail
-
-
-
- Password
-
-
- Submit
-
- )
-})
-
-export default LoginForm
diff --git a/frontend/src/components/RegistrationForm.tsx b/frontend/src/components/RegistrationForm.tsx
deleted file mode 100644
index 6bd54d6..0000000
--- a/frontend/src/components/RegistrationForm.tsx
+++ /dev/null
@@ -1,37 +0,0 @@
-import React from 'react'
-import {Form, Button} from 'semantic-ui-react'
-
-export default function RegistrationForm({onSubmit: onSubmitOuter}) {
- const [username, setUsername] = React.useState(null)
- const [email, setEmail] = React.useState(null)
- const [password, setPassword] = React.useState(null)
- const [password2, setPassword2] = React.useState(null)
-
- const onChangeUsername = React.useCallback((e) => setUsername(e.target.value), [])
- const onChangeEmail = React.useCallback((e) => setEmail(e.target.value), [])
- const onChangePassword = React.useCallback((e) => setPassword(e.target.value), [])
- const onChangePassword2 = React.useCallback((e) => setPassword2(e.target.value), [])
-
- const onSubmit = React.useCallback(() => {
- if (username && email && password && password2 === password) {
- onSubmitOuter({username, email, password})
- }
- }, [username, email, password, password2, onSubmitOuter])
-
- return (
-
-
-
-
- Submit
-
- )
-}
diff --git a/frontend/src/components/index.js b/frontend/src/components/index.js
index b06eb9f..533d682 100644
--- a/frontend/src/components/index.js
+++ b/frontend/src/components/index.js
@@ -1,7 +1,6 @@
export {default as FileDrop} from './FileDrop'
export {default as FormattedDate} from './FormattedDate'
-export {default as LoginForm} from './LoginForm'
+export {default as LoginButton} from './LoginButton'
export {default as Map} from './Map'
export {default as Page} from './Page'
-export {default as RegistrationForm} from './RegistrationForm'
export {default as StripMarkdown} from './StripMarkdown'
diff --git a/frontend/src/config.json b/frontend/src/config.json
new file mode 100644
index 0000000..679f41e
--- /dev/null
+++ b/frontend/src/config.json
@@ -0,0 +1,9 @@
+{
+ "auth": {
+ "authorizationEndpoint": "http://localhost:3000/authorize",
+ "tokenEndpoint": "http://localhost:3000/token",
+ "clientId": "123",
+ "scope": "*",
+ "redirectUri": "http://localhost:3001/redirect"
+ }
+}
diff --git a/frontend/src/index.js b/frontend/src/index.js
index 3b32f09..00c01fc 100644
--- a/frontend/src/index.js
+++ b/frontend/src/index.js
@@ -6,14 +6,8 @@ import './index.css'
import App from './App'
import {Provider} from 'react-redux'
-import {compose, createStore} from 'redux'
-import persistState from 'redux-localstorage'
-import rootReducer from './reducers'
-
-const enhancer = compose(persistState(['login']))
-
-const store = createStore(rootReducer, undefined, enhancer)
+import store from './store'
// TODO: remove
Settings.defaultLocale = 'de-DE'
diff --git a/frontend/src/pages/HomePage.js b/frontend/src/pages/HomePage.js
index cb5b97a..951dc19 100644
--- a/frontend/src/pages/HomePage.js
+++ b/frontend/src/pages/HomePage.js
@@ -1,14 +1,14 @@
import _ from 'lodash'
import React from 'react'
-import {connect} from 'react-redux'
-import {Message, Grid, Loader, Statistic, Segment, Header, Item} from 'semantic-ui-react'
+import {Tab, Message, Grid, Loader, Statistic, Segment, Header, Item} from 'semantic-ui-react'
import {useObservable} from 'rxjs-hooks'
import {of, pipe, from} from 'rxjs'
import {map, switchMap, distinctUntilChanged} from 'rxjs/operators'
+import {fromLonLat} from 'ol/proj'
import {Duration} from 'luxon'
import api from '../api'
-import {Map, Page, LoginForm} from '../components'
+import {Map, Page} from '../components'
import {TrackListItem} from './TracksPage'
@@ -20,8 +20,9 @@ function formatDuration(seconds) {
function WelcomeMap() {
return (
-
+
+
)
}
@@ -41,7 +42,7 @@ function Stats() {
-
+
{Number(stats?.publicTrackLength / 1000).toFixed(1)}
km track length
@@ -64,19 +65,6 @@ function Stats() {
)
}
-const LoginState = connect((state) => ({login: state.login}))(function LoginState({login}) {
- return login ? (
- <>
- Logged in as {login.username}
- >
- ) : (
- <>
-
-
- >
- )
-})
-
function MostRecentTrack() {
const track: Track | null = useObservable(
() =>
@@ -88,8 +76,6 @@ function MostRecentTrack() {
[]
)
- console.log(track)
-
return (
<>
Most recent track
@@ -110,19 +96,15 @@ export default function HomePage() {
-
+
-
-
-
+
-
-
-
+
)
diff --git a/frontend/src/pages/LoginPage.js b/frontend/src/pages/LoginPage.js
deleted file mode 100644
index 39bae4b..0000000
--- a/frontend/src/pages/LoginPage.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import React from 'react'
-import {connect} from 'react-redux'
-import {Redirect} from 'react-router-dom'
-
-import {Page, LoginForm} from '../components'
-
-const LoginPage = connect((state) => ({loggedIn: Boolean(state.login)}))(function LoginPage({loggedIn}) {
- return loggedIn ? (
-
- ) : (
-
- Login
-
-
- )
-})
-
-export default LoginPage
diff --git a/frontend/src/pages/LoginRedirectPage.tsx b/frontend/src/pages/LoginRedirectPage.tsx
new file mode 100644
index 0000000..1953025
--- /dev/null
+++ b/frontend/src/pages/LoginRedirectPage.tsx
@@ -0,0 +1,97 @@
+import React from 'react'
+import {connect} from 'react-redux'
+import {Redirect, useLocation, useHistory} from 'react-router-dom'
+import {Icon, Message} from 'semantic-ui-react'
+import {useObservable} from 'rxjs-hooks'
+import {switchMap, pluck, distinctUntilChanged} from 'rxjs/operators'
+
+import {Page} from 'components'
+import api from 'api'
+
+const LoginRedirectPage = connect((state) => ({loggedIn: Boolean(state.login)}))(function LoginRedirectPage({
+ loggedIn,
+}) {
+ const location = useLocation()
+ const history = useHistory()
+ const {search} = location
+
+ /* eslint-disable react-hooks/exhaustive-deps */
+
+ // Hook dependency arrays in this block are intentionally left blank, we want
+ // to keep the initial state, but reset the url once, ASAP, to not leak the
+ // query parameters. This is considered good practice by OAuth.
+ const searchParams = React.useMemo(() => Object.fromEntries(new URLSearchParams(search).entries()), [])
+
+ React.useEffect(() => {
+ history.replace({...location, search: ''})
+ }, [])
+ /* eslint-enable react-hooks/exhaustive-deps */
+
+ if (loggedIn) {
+ return
+ }
+
+ const {error, error_description: errorDescription, code} = searchParams
+
+ if (error) {
+ return (
+
+
+
+
+ Login error
+ The login server reported: {errorDescription || error}.
+
+
+
+ )
+ }
+
+ return
+})
+
+function ExchangeAuthCode({code}) {
+ const result = useObservable(
+ (_$, args$) =>
+ args$.pipe(
+ pluck(0),
+ distinctUntilChanged(),
+ switchMap((code) => api.exchangeAuthorizationCode(code))
+ ),
+ null,
+ [code]
+ )
+
+ let content
+ if (result === null) {
+ content = (
+
+
+
+ Logging you in
+ Hang tight...
+
+
+ )
+ } else if (result === true) {
+ content =
+ } else {
+ const {error, error_description: errorDescription} = result
+ content = (
+ <>
+
+
+
+ Login error
+ The login server reported: {errorDescription || error}.
+
+
+ {JSON.stringify(result, null, 2)}
+ >
+ )
+ }
+
+ return {content}
+}
+
+export default LoginRedirectPage
diff --git a/frontend/src/pages/LogoutPage.js b/frontend/src/pages/LogoutPage.js
index 829728d..2b908ae 100644
--- a/frontend/src/pages/LogoutPage.js
+++ b/frontend/src/pages/LogoutPage.js
@@ -2,16 +2,14 @@ import React from 'react'
import {connect} from 'react-redux'
import {Redirect} from 'react-router-dom'
-import {logout as logoutAction} from '../reducers/login'
+import {resetAuth} from 'reducers/auth'
const LogoutPage = connect(
(state) => ({loggedIn: Boolean(state.login)}),
- (dispatch) => ({
- dispatchLogout: () => dispatch(logoutAction()),
- })
-)(function LogoutPage({loggedIn, dispatchLogout}) {
+ {resetAuth}
+)(function LogoutPage({loggedIn, resetAuth}) {
React.useEffect(() => {
- dispatchLogout()
+ resetAuth()
})
return loggedIn ? null :
diff --git a/frontend/src/pages/RegistrationPage.tsx b/frontend/src/pages/RegistrationPage.tsx
deleted file mode 100644
index 171bd2c..0000000
--- a/frontend/src/pages/RegistrationPage.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-import React from 'react'
-import {connect} from 'react-redux'
-import {Redirect} from 'react-router-dom'
-
-import {Page, RegistrationForm} from '../components'
-
-const RegistrationPage = connect((state) => ({loggedIn: Boolean(state.login)}))(function RegistrationPage({loggedIn}) {
- return loggedIn ? (
-
- ) : (
-
- Register
-
-
- )
-})
-
-export default RegistrationPage
diff --git a/frontend/src/pages/TracksPage.tsx b/frontend/src/pages/TracksPage.tsx
index 7b64007..1cd1084 100644
--- a/frontend/src/pages/TracksPage.tsx
+++ b/frontend/src/pages/TracksPage.tsx
@@ -37,7 +37,6 @@ function TracksPageTabs() {
function TrackList({privateFeed}: {privateFeed: boolean}) {
const [page, setPage] = useQueryParam('page', 1, Number)
- console.log('page', page)
const pageSize = 10
@@ -86,7 +85,7 @@ function TrackList({privateFeed}: {privateFeed: boolean}) {
}
function maxLength(t, max) {
- if (t.length > max) {
+ if (t && t.length > max) {
return t.substring(0, max) + ' ...'
} else {
return t
@@ -99,7 +98,7 @@ export function TrackListItem({track, privateFeed = false}) {
- {track.title}
+ {track.title || 'Unnamed track'}
Created by {track.author.username} on {track.createdAt}
diff --git a/frontend/src/pages/UploadPage.tsx b/frontend/src/pages/UploadPage.tsx
index 0367053..b29b28f 100644
--- a/frontend/src/pages/UploadPage.tsx
+++ b/frontend/src/pages/UploadPage.tsx
@@ -58,13 +58,11 @@ function FileUploadStatus({
const xhr = new XMLHttpRequest()
const onProgress = (e) => {
- console.log('progress', e)
const progress = (e.loaded || 0) / (e.total || 1)
setProgress(progress)
}
const onLoad = (e) => {
- console.log('loaded', e)
onComplete(id, xhr.response)
}
@@ -72,17 +70,18 @@ function FileUploadStatus({
xhr.onload = onLoad
xhr.upload.onprogress = onProgress
xhr.open('POST', '/api/tracks')
- xhr.setRequestHeader('Authorization', api.authorization)
- xhr.send(formData)
+
+ api.getValidAccessToken().then((accessToken) => {
+ xhr.setRequestHeader('Authorization', accessToken)
+ xhr.send(formData)
+ })
return () => xhr.abort()
}, [file])
return (
-
- {' '}
- {progress < 1 ? (progress * 100).toFixed(0) + ' %' : 'Processing...'}
+ {progress < 1 ? (progress * 100).toFixed(0) + ' %' : 'Processing...'}
)
}
@@ -113,8 +112,6 @@ export default function UploadPage() {
}, [labelRef.current])
function onSelectFiles(fileList) {
- console.log('UPLOAD', fileList)
-
const newFiles = Array.from(fileList).map((file) => ({
id: 'file-' + String(Math.floor(Math.random() * 1000000)),
file,
diff --git a/frontend/src/pages/index.js b/frontend/src/pages/index.js
index 50f90b9..84669f4 100644
--- a/frontend/src/pages/index.js
+++ b/frontend/src/pages/index.js
@@ -1,8 +1,7 @@
export {default as HomePage} from './HomePage'
-export {default as LoginPage} from './LoginPage'
export {default as LogoutPage} from './LogoutPage'
export {default as NotFoundPage} from './NotFoundPage'
-export {default as RegistrationPage} from './RegistrationPage'
+export {default as LoginRedirectPage} from './LoginRedirectPage'
export {default as TrackPage} from './TrackPage'
export {default as TracksPage} from './TracksPage'
export {default as UploadPage} from './UploadPage'
diff --git a/frontend/src/reducers/auth.js b/frontend/src/reducers/auth.js
new file mode 100644
index 0000000..428338e
--- /dev/null
+++ b/frontend/src/reducers/auth.js
@@ -0,0 +1,30 @@
+const initialState = null
+
+export function setAuth(auth) {
+ return {type: 'AUTH.SET', payload: {auth}}
+}
+
+export function resetAuth() {
+ return {type: 'AUTH.RESET'}
+}
+
+export function invalidateAccessToken() {
+ return {type: 'AUTH.INVALIDATE_ACCESS_TOKEN'}
+}
+
+export default function loginReducer(state = initialState, action) {
+ switch (action.type) {
+ case 'AUTH.SET':
+ return action.payload.auth
+ case 'AUTH.INVALIDATE_ACCESS_TOKEN':
+ return state && {
+ ...state,
+ accessToken: null,
+ expiresAt: 0,
+ }
+ case 'AUTH.RESET':
+ return null
+ default:
+ return state
+ }
+}
diff --git a/frontend/src/reducers/index.js b/frontend/src/reducers/index.js
index 0cfe682..88a63d5 100644
--- a/frontend/src/reducers/index.js
+++ b/frontend/src/reducers/index.js
@@ -1,5 +1,6 @@
import {combineReducers} from 'redux'
import login from './login'
+import auth from './auth'
-export default combineReducers({login})
+export default combineReducers({login, auth})
diff --git a/frontend/src/reducers/login.js b/frontend/src/reducers/login.js
index 32b8777..9137112 100644
--- a/frontend/src/reducers/login.js
+++ b/frontend/src/reducers/login.js
@@ -1,19 +1,17 @@
const initialState = null
-export function login(user) {
- return {type: 'LOGIN.LOGIN', payload: {user}}
-}
-
-export function logout() {
- return {type: 'LOGIN.LOGOUT'}
+export function setLogin(user) {
+ return {type: 'LOGIN.SET', payload: {user}}
}
export default function loginReducer(state = initialState, action) {
switch (action.type) {
- case 'LOGIN.LOGIN':
+ case 'LOGIN.SET':
return action.payload.user
- case 'LOGIN.LOGOUT':
+
+ case 'AUTH.RESET': // cross reducer action :)
return null
+
default:
return state
}
diff --git a/frontend/src/store.js b/frontend/src/store.js
new file mode 100644
index 0000000..f5e54b6
--- /dev/null
+++ b/frontend/src/store.js
@@ -0,0 +1,10 @@
+import {compose, createStore} from 'redux'
+import persistState from 'redux-localstorage'
+
+import rootReducer from './reducers'
+
+const enhancer = compose(persistState(['login', 'auth']))
+
+const store = createStore(rootReducer, undefined, enhancer)
+
+export default store