api: Move OAuth2.0 client definitions to config file, from MongoDB
This commit is contained in:
parent
470dfe339d
commit
4779965377
|
@ -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"
|
||||||
}
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 = [
|
||||||
|
|
|
@ -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,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
|
|
@ -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);
|
||||||
|
|
|
@ -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(
|
||||||
{
|
{
|
||||||
|
|
|
@ -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')
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue