From 47799653779728489e04aa74b9484be8f0adb095 Mon Sep 17 00:00:00 2001 From: Paul Bienkowski Date: Sun, 28 Feb 2021 20:19:08 +0100 Subject: [PATCH] api: Move OAuth2.0 client definitions to config file, from MongoDB --- api/config.dev.json | 18 +++++++++++++- api/config.json.example | 18 +++++++++++++- api/src/config.js | 24 ++++++++++++++++++ api/src/models/AccessToken.js | 8 +++--- api/src/models/AuthorizationCode.js | 10 +++----- api/src/models/Client.js | 37 ---------------------------- api/src/models/RefreshToken.js | 12 +++------ api/src/models/User.js | 1 + api/src/models/index.js | 1 - api/src/routes/auth.js | 38 ++++++++++++++++++----------- frontend/src/config.json | 2 +- 11 files changed, 93 insertions(+), 76 deletions(-) delete mode 100644 api/src/models/Client.js diff --git a/api/config.dev.json b/api/config.dev.json index cef279d..6e4e958 100644 --- a/api/config.dev.json +++ b/api/config.dev.json @@ -7,5 +7,21 @@ "mongodb": { "url": "mongodb://mongo/obsTest", "debug": true - } + }, + "oAuth2Clients": [ + { + "clientId": "b730f8d2-d93c-4c68-9ff0-dfac8da76ee2", + "validRedirectUris": ["http://localhost:3001/redirect"], + "refreshTokenExpirySeconds": 604800, + "maxScope": "*", + "title": "OBS Portal" + }, + { + "clientId": "a2958209-4045-4ec9-8cb3-1156abba7de3", + "validRedirectUris": ["__LOCAL__"], + "maxScope": "track.upload", + "refreshTokenExpirySeconds": 86400000, + "title": "OpenBikeSensor" + } + ] } diff --git a/api/config.json.example b/api/config.json.example index 7a97bc5..4f0989e 100644 --- a/api/config.json.example +++ b/api/config.json.example @@ -15,5 +15,21 @@ "mongodb": { "url": "mongodb://user:pass@host/obs", "debug": false - } + }, + "oAuth2Clients": [ + { + "clientId": "CHANGEME", + "validRedirectUris": ["https://site.example.com/redirect"], + "refreshTokenExpirySeconds": 604800, + "maxScope": "*", + "title": "OBS Portal" + }, + { + "clientId": "CHANGEME", + "validRedirectUris": ["__LOCAL__"], + "maxScope": "track.upload", + "refreshTokenExpirySeconds": 86400000, + "title": "OpenBikeSensor" + } + ] } diff --git a/api/src/config.js b/api/src/config.js index ef150be..aa9351b 100644 --- a/api/src/config.js +++ b/api/src/config.js @@ -25,6 +25,30 @@ const configSchema = Joi.object({ url: Joi.string().required(), debug: Joi.boolean().default(process.env.NODE_ENV !== 'production'), }).required(), + + oAuth2Clients: Joi.array() + .default([]) + .items( + Joi.object({ + title: Joi.string().required(), + clientId: Joi.string().required(), + validRedirectUris: Joi.array().required().items(Joi.string()), + + // Set `refreshTokenExpirySeconds` to null to issue no refresh tokens. Set + // to a number of seconds to issue refresh tokens with that duration. No + // infinite tokens are ever issued, set to big number to simulate that. + refreshTokenExpirySeconds: Joi.number() + .default(null) + .min(1) // 0 would make no sense, use `null` to issue no token + .max(1000 * 24 * 60 * 60), // 1000 days, nearly 3 years + + // Set to a scope which cannot be exceeded when requesting client tokens. + // Clients must manually request a scope that is smaller or equal to this + // scope to get a valid response. Scopes are not automatically truncated. + // Leave empty or set to `"*"` for unlimited scopes in this client. + maxScope: Joi.string().required(), + }), + ), }).required(); const configFiles = [ diff --git a/api/src/models/AccessToken.js b/api/src/models/AccessToken.js index f658743..d8e7899 100644 --- a/api/src/models/AccessToken.js +++ b/api/src/models/AccessToken.js @@ -6,7 +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 }, + clientId: { type: String, required: true }, expiresAt: { type: Date, required: true }, scope: { type: String, required: true, defaultValue: '*' }, }, @@ -31,15 +31,13 @@ class AccessToken extends mongoose.Model { return 'Bearer ' + this.token; } - static generate(client, user, scope = '*', expiresInSeconds = 24 * 60 * 60) { + static generate(options, expiresInSeconds = 24 * 60 * 60) { const token = crypto.randomBytes(32).toString('hex'); return new AccessToken({ + ...options, 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 index f17ee0f..ea91043 100644 --- a/api/src/models/AuthorizationCode.js +++ b/api/src/models/AuthorizationCode.js @@ -5,7 +5,7 @@ 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 }, + clientId: { type: String, required: true }, scope: { type: String, required: true, defaultValue: '*' }, redirectUri: { type: String, required: true }, expiresAt: { type: Date, required: true }, @@ -15,17 +15,13 @@ const schema = new mongoose.Schema( ); class AuthorizationCode extends mongoose.Model { - static generate(client, user, redirectUri, scope, codeChallenge, expiresInSeconds = 60) { + static generate(options, expiresInSeconds = 60) { const code = crypto.randomBytes(8).toString('hex'); return new AuthorizationCode({ + ...options, code, - user, - client, - redirectUri, expiresAt: new Date(new Date().getTime() + 1000 * expiresInSeconds), - scope, - codeChallenge, }); } } diff --git a/api/src/models/Client.js b/api/src/models/Client.js deleted file mode 100644 index 184af39..0000000 --- a/api/src/models/Client.js +++ /dev/null @@ -1,37 +0,0 @@ -const mongoose = require('mongoose'); - -const schema = new mongoose.Schema( - { - title: { type: String, required: true }, - 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: - // confidential: {type: Boolean}, // whether this is a non-public, aka confidential client - // clientSecret: { type: String }, - - // Set `refreshTokenExpirySeconds` to null to issue no refresh tokens. Set - // to a number of seconds to issue refresh tokens with that duration. No - // infinite tokens are ever issued, set to big number to simulate that. - refreshTokenExpirySeconds: { - type: Number, - required: false, - defaultValue: null, - min: 1, // 0 would make no sense, use `null` to issue no token - max: 1000 * 24 * 60 * 60, // 1000 days, nearly 3 years - }, - - // Set to a scope which cannot be exceeded when requesting client tokens. - // Clients must manually request a scope that is smaller or equal to this - // scope to get a valid response. Scopes are not automatically truncated. - // Leave empty or set to `"*"` for unlimited scopes in this client. - maxScope: { type: String, required: false }, - }, - { 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 dfe463c..7847c9b 100644 --- a/api/src/models/RefreshToken.js +++ b/api/src/models/RefreshToken.js @@ -8,7 +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 }, + clientId: { type: String, required: true }, expiresAt: { type: Date, required: false }, scope: { type: String, required: true, defaultValue: '*' }, }, @@ -29,21 +29,15 @@ class RefreshToken extends mongoose.Model { return this.expiresAt == null || this.expiresAt < new Date() } - static generate(client, user, scope = '*', expiresInSeconds = 24 * 60 * 60) { + static generate(options, expiresInSeconds = 24 * 60 * 60) { const token = crypto.randomBytes(32).toString('hex'); return new RefreshToken({ + ...options, token, - client, - user, expiresAt: new Date(new Date().getTime() + 1000 * expiresInSeconds), - scope, }); } - - genererateAccessToken(expiresInSeconds = undefined) { - return AccessToken.generate(this.user, this.scope, expiresInSeconds) - } } mongoose.model(RefreshToken, schema); diff --git a/api/src/models/User.js b/api/src/models/User.js index 91f9930..9235d4e 100644 --- a/api/src/models/User.js +++ b/api/src/models/User.js @@ -2,6 +2,7 @@ const mongoose = require('mongoose'); const uniqueValidator = require('mongoose-unique-validator'); const crypto = require('crypto'); const jwt = require('jsonwebtoken'); +const config = require('../config') const schema = new mongoose.Schema( { diff --git a/api/src/models/index.js b/api/src/models/index.js index 332a4a1..50ac273 100644 --- a/api/src/models/index.js +++ b/api/src/models/index.js @@ -1,6 +1,5 @@ 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/auth.js b/api/src/routes/auth.js index 502b19b..0c361f8 100644 --- a/api/src/routes/auth.js +++ b/api/src/routes/auth.js @@ -3,10 +3,10 @@ const passport = require('passport'); const { URL } = require('url'); const { createChallenge } = require('pkce'); -const { AuthorizationCode, AccessToken, RefreshToken, Client } = require('../models'); +const { AuthorizationCode, AccessToken, RefreshToken } = require('../models'); const auth = require('../passport'); const wrapRoute = require('../_helpers/wrapRoute'); -const config = require('../config') +const config = require('../config'); // Check whether the "bigScope" fully includes the "smallScope". function scopeIncludes(smallScope, bigScope) { @@ -49,7 +49,7 @@ function isValidScope(scope) { router.use((req, res, next) => { res.locals.user = req.user; - res.locals.mainFrontendUrl = config.mainFrontendUrl + res.locals.mainFrontendUrl = config.mainFrontendUrl; next(); }); @@ -170,7 +170,7 @@ router.get( return returnError(res, 'invalid_request', 'client_id parameter required'); } - const client = await Client.findOne({ clientId }); + const client = await config.oAuth2Clients.find((c) => c.clientId === clientId); if (!client) { return returnError(res, 'invalid_client', 'unknown client'); } @@ -269,13 +269,17 @@ router.post( }); } - 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, codeChallenge); + const code = AuthorizationCode.generate({ + clientId, + user: req.user, + redirectUri, + scope, + codeChallenge, + }); await code.save(); return redirectWithParams(res, redirectUri, { code: code.code, scope }); @@ -336,7 +340,7 @@ router.get( return returnError(res, 'invalid_request', 'code_verifier parameter required'); } - const client = await Client.findOne({ clientId }); + const client = await config.oAuth2Clients.find((c) => c.clientId === clientId); if (!client) { await destroyAuthCode(); @@ -356,7 +360,7 @@ router.get( await destroyAuthCode(); return returnError(res, 'invalid_grant', 'invalid authorization code'); } - if (!client._id.equals(authorizationCode.client)) { + if (clientId !== authorizationCode.clientId) { await destroyAuthCode(); return returnError(res, 'invalid_grant', 'invalid authorization code'); } @@ -368,15 +372,21 @@ router.get( // invalidate auth code now, before generating tokens await AuthorizationCode.deleteOne({ _id: authorizationCode._id }); - const accessToken = AccessToken.generate(authorizationCode.client, authorizationCode.user, authorizationCode.scope); + const accessToken = AccessToken.generate({ + clientId: authorizationCode.clientId, + user: authorizationCode.user, + scope: authorizationCode.scope, + }); await accessToken.save(); let refreshToken; if (client.refreshTokenExpirySeconds != null) { refreshToken = RefreshToken.generate( - authorizationCode.client, - authorizationCode.user, - authorizationCode.scope, + { + clientId: authorizationCode.clientId, + user: authorizationCode.user, + scope: authorizationCode.scope, + }, client.refreshTokenExpirySeconds, ); await refreshToken.save(); @@ -399,7 +409,7 @@ router.get( router.get( '/.well-known/oauth-authorization-server', wrapRoute(async (req, res) => { - const baseUrl = config.baseUrl.replace(/\/+$/, '') + const baseUrl = config.baseUrl.replace(/\/+$/, ''); return res.json({ issuer: baseUrl, diff --git a/frontend/src/config.json b/frontend/src/config.json index 5f4a87d..9b356af 100644 --- a/frontend/src/config.json +++ b/frontend/src/config.json @@ -1,7 +1,7 @@ { "auth": { "server": "http://localhost:3000", - "clientId": "123", + "clientId": "b730f8d2-d93c-4c68-9ff0-dfac8da76ee2", "scope": "*", "redirectUri": "http://localhost:3001/redirect" }