api: Move OAuth2.0 client definitions to config file, from MongoDB

This commit is contained in:
Paul Bienkowski 2021-02-28 20:19:08 +01:00
parent 470dfe339d
commit 4779965377
11 changed files with 93 additions and 76 deletions

View file

@ -7,5 +7,21 @@
"mongodb": { "mongodb": {
"url": "mongodb://mongo/obsTest", "url": "mongodb://mongo/obsTest",
"debug": true "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"
}
]
} }

View file

@ -15,5 +15,21 @@
"mongodb": { "mongodb": {
"url": "mongodb://user:pass@host/obs", "url": "mongodb://user:pass@host/obs",
"debug": false "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"
}
]
} }

View file

@ -25,6 +25,30 @@ const configSchema = Joi.object({
url: Joi.string().required(), url: Joi.string().required(),
debug: Joi.boolean().default(process.env.NODE_ENV !== 'production'), debug: Joi.boolean().default(process.env.NODE_ENV !== 'production'),
}).required(), }).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(); }).required();
const configFiles = [ const configFiles = [

View file

@ -6,7 +6,7 @@ const schema = new mongoose.Schema(
{ {
token: { index: true, type: String, required: true, unique: true }, token: { index: true, type: String, required: true, unique: true },
user: { type: mongoose.Schema.Types.ObjectId, ref: 'User', 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 },
expiresAt: { type: Date, required: true }, expiresAt: { type: Date, required: true },
scope: { type: String, required: true, defaultValue: '*' }, scope: { type: String, required: true, defaultValue: '*' },
}, },
@ -31,15 +31,13 @@ class AccessToken extends mongoose.Model {
return 'Bearer ' + this.token; 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'); const token = crypto.randomBytes(32).toString('hex');
return new AccessToken({ return new AccessToken({
...options,
token, token,
user,
client,
expiresAt: new Date(new Date().getTime() + 1000 * expiresInSeconds), expiresAt: new Date(new Date().getTime() + 1000 * expiresInSeconds),
scope,
}); });
} }
} }

View file

@ -5,7 +5,7 @@ const schema = new mongoose.Schema(
{ {
code: { type: String, unique: true, required: true }, code: { type: String, unique: true, required: true },
user: { type: mongoose.Schema.Types.ObjectId, ref: 'User', 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: '*' }, scope: { type: String, required: true, defaultValue: '*' },
redirectUri: { type: String, required: true }, redirectUri: { type: String, required: true },
expiresAt: { type: Date, required: true }, expiresAt: { type: Date, required: true },
@ -15,17 +15,13 @@ const schema = new mongoose.Schema(
); );
class AuthorizationCode extends mongoose.Model { 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'); const code = crypto.randomBytes(8).toString('hex');
return new AuthorizationCode({ return new AuthorizationCode({
...options,
code, code,
user,
client,
redirectUri,
expiresAt: new Date(new Date().getTime() + 1000 * expiresInSeconds), expiresAt: new Date(new Date().getTime() + 1000 * expiresInSeconds),
scope,
codeChallenge,
}); });
} }
} }

View file

@ -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;

View file

@ -8,7 +8,7 @@ const schema = new mongoose.Schema(
{ {
token: { index: true, type: String, required: true, unique: true }, token: { index: true, type: String, required: true, unique: true },
user: { type: mongoose.Schema.Types.ObjectId, ref: 'User', 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 },
expiresAt: { type: Date, required: false }, expiresAt: { type: Date, required: false },
scope: { type: String, required: true, defaultValue: '*' }, scope: { type: String, required: true, defaultValue: '*' },
}, },
@ -29,21 +29,15 @@ class RefreshToken extends mongoose.Model {
return this.expiresAt == null || this.expiresAt < new Date() 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'); const token = crypto.randomBytes(32).toString('hex');
return new RefreshToken({ return new RefreshToken({
...options,
token, token,
client,
user,
expiresAt: new Date(new Date().getTime() + 1000 * expiresInSeconds), expiresAt: new Date(new Date().getTime() + 1000 * expiresInSeconds),
scope,
}); });
} }
genererateAccessToken(expiresInSeconds = undefined) {
return AccessToken.generate(this.user, this.scope, expiresInSeconds)
}
} }
mongoose.model(RefreshToken, schema); mongoose.model(RefreshToken, schema);

View file

@ -2,6 +2,7 @@ const mongoose = require('mongoose');
const uniqueValidator = require('mongoose-unique-validator'); const uniqueValidator = require('mongoose-unique-validator');
const crypto = require('crypto'); const crypto = require('crypto');
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const config = require('../config')
const schema = new mongoose.Schema( const schema = new mongoose.Schema(
{ {

View file

@ -1,6 +1,5 @@
module.exports.AccessToken = require('./AccessToken') module.exports.AccessToken = require('./AccessToken')
module.exports.AuthorizationCode = require('./AuthorizationCode') module.exports.AuthorizationCode = require('./AuthorizationCode')
module.exports.Client = require('./Client')
module.exports.Comment = require('./Comment') module.exports.Comment = require('./Comment')
module.exports.RefreshToken = require('./RefreshToken') module.exports.RefreshToken = require('./RefreshToken')
module.exports.Track = require('./Track') module.exports.Track = require('./Track')

View file

@ -3,10 +3,10 @@ const passport = require('passport');
const { URL } = require('url'); const { URL } = require('url');
const { createChallenge } = require('pkce'); const { createChallenge } = require('pkce');
const { AuthorizationCode, AccessToken, RefreshToken, Client } = require('../models'); const { AuthorizationCode, AccessToken, RefreshToken } = require('../models');
const auth = require('../passport'); const auth = require('../passport');
const wrapRoute = require('../_helpers/wrapRoute'); const wrapRoute = require('../_helpers/wrapRoute');
const config = require('../config') const config = require('../config');
// Check whether the "bigScope" fully includes the "smallScope". // Check whether the "bigScope" fully includes the "smallScope".
function scopeIncludes(smallScope, bigScope) { function scopeIncludes(smallScope, bigScope) {
@ -49,7 +49,7 @@ function isValidScope(scope) {
router.use((req, res, next) => { router.use((req, res, next) => {
res.locals.user = req.user; res.locals.user = req.user;
res.locals.mainFrontendUrl = config.mainFrontendUrl res.locals.mainFrontendUrl = config.mainFrontendUrl;
next(); next();
}); });
@ -170,7 +170,7 @@ router.get(
return returnError(res, 'invalid_request', 'client_id parameter required'); 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) { if (!client) {
return returnError(res, 'invalid_client', 'unknown client'); return returnError(res, 'invalid_client', 'unknown client');
} }
@ -269,13 +269,17 @@ router.post(
}); });
} }
const client = await Client.findOne({ clientId });
// invalidate the transaction // invalidate the transaction
req.session.authorizationTransaction = null; req.session.authorizationTransaction = null;
if (req.path === '/authorize/approve') { 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(); await code.save();
return redirectWithParams(res, redirectUri, { code: code.code, scope }); return redirectWithParams(res, redirectUri, { code: code.code, scope });
@ -336,7 +340,7 @@ router.get(
return returnError(res, 'invalid_request', 'code_verifier parameter required'); 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) { if (!client) {
await destroyAuthCode(); await destroyAuthCode();
@ -356,7 +360,7 @@ router.get(
await destroyAuthCode(); await destroyAuthCode();
return returnError(res, 'invalid_grant', 'invalid authorization code'); return returnError(res, 'invalid_grant', 'invalid authorization code');
} }
if (!client._id.equals(authorizationCode.client)) { if (clientId !== authorizationCode.clientId) {
await destroyAuthCode(); await destroyAuthCode();
return returnError(res, 'invalid_grant', 'invalid authorization code'); return returnError(res, 'invalid_grant', 'invalid authorization code');
} }
@ -368,15 +372,21 @@ router.get(
// invalidate auth code now, before generating tokens // invalidate auth code now, before generating tokens
await AuthorizationCode.deleteOne({ _id: authorizationCode._id }); 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(); await accessToken.save();
let refreshToken; let refreshToken;
if (client.refreshTokenExpirySeconds != null) { if (client.refreshTokenExpirySeconds != null) {
refreshToken = RefreshToken.generate( refreshToken = RefreshToken.generate(
authorizationCode.client, {
authorizationCode.user, clientId: authorizationCode.clientId,
authorizationCode.scope, user: authorizationCode.user,
scope: authorizationCode.scope,
},
client.refreshTokenExpirySeconds, client.refreshTokenExpirySeconds,
); );
await refreshToken.save(); await refreshToken.save();
@ -399,7 +409,7 @@ router.get(
router.get( router.get(
'/.well-known/oauth-authorization-server', '/.well-known/oauth-authorization-server',
wrapRoute(async (req, res) => { wrapRoute(async (req, res) => {
const baseUrl = config.baseUrl.replace(/\/+$/, '') const baseUrl = config.baseUrl.replace(/\/+$/, '');
return res.json({ return res.json({
issuer: baseUrl, issuer: baseUrl,

View file

@ -1,7 +1,7 @@
{ {
"auth": { "auth": {
"server": "http://localhost:3000", "server": "http://localhost:3000",
"clientId": "123", "clientId": "b730f8d2-d93c-4c68-9ff0-dfac8da76ee2",
"scope": "*", "scope": "*",
"redirectUri": "http://localhost:3001/redirect" "redirectUri": "http://localhost:3001/redirect"
} }