From 51fd91e3dba61c289a5b71096321ccca90b5c04b Mon Sep 17 00:00:00 2001 From: Evert Prants Date: Fri, 5 Jun 2020 18:18:23 +0300 Subject: [PATCH] Clean up OAuth2 provider code, support multiple request types in preparation for OIDC --- server/api/oauth2/controller/authorization.js | 161 +++++++++--------- server/api/oauth2/controller/code/code.js | 9 +- server/api/oauth2/controller/code/implicit.js | 9 +- server/api/oauth2/controller/introspection.js | 14 +- server/api/oauth2/controller/token.js | 18 +- .../controller/tokens/clientCredentials.js | 4 +- server/api/oauth2/middleware.js | 13 +- server/api/oauth2/model.js | 9 +- server/api/oauth2/response.js | 4 +- server/api/oauth2/wrap.js | 9 + static/image/icynet-icon-pleroma.svg | 103 +++++++++++ views/authorization.pug | 2 +- 12 files changed, 235 insertions(+), 120 deletions(-) create mode 100644 server/api/oauth2/wrap.js create mode 100644 static/image/icynet-icon-pleroma.svg diff --git a/server/api/oauth2/controller/authorization.js b/server/api/oauth2/controller/authorization.js index 9aaa5bd..bf8e50c 100644 --- a/server/api/oauth2/controller/authorization.js +++ b/server/api/oauth2/controller/authorization.js @@ -2,151 +2,152 @@ import error from '../error' import response from '../response' import model from '../model' import authorization from './code' -import wrap from '../../../../scripts/asyncRoute' - -const usermodel = model.user +import wrap from '../wrap' module.exports = wrap(async (req, res, next) => { let clientId = null let redirectUri = null let responseType = null - let grantType = null + let grantTypes = [] let scope = null let user = null if (!req.query.redirect_uri) { - return response.error(req, res, new error.InvalidRequest('redirect_uri field is mandatory for authorization endpoint'), redirectUri) + throw new error.InvalidRequest('redirect_uri field is mandatory for authorization endpoint') } redirectUri = req.query.redirect_uri console.debug('Parameter redirect uri is', redirectUri) if (!req.query.client_id) { - return response.error(req, res, new error.InvalidRequest('client_id field is mandatory for authorization endpoint'), redirectUri) + throw new error.InvalidRequest('client_id field is mandatory for authorization endpoint') } // Check for client_secret (prevent passing it) if (req.query.client_secret) { - return response.error(req, res, new error.InvalidRequest('client_secret field should not be passed to the authorization endpoint'), redirectUri) + throw new error.InvalidRequest('client_secret field should not be passed to the authorization endpoint') } clientId = req.query.client_id console.debug('Parameter client_id is', clientId) if (!req.query.response_type) { - return response.error(req, res, new error.InvalidRequest('response_type field is mandatory for authorization endpoint'), redirectUri) + throw new error.InvalidRequest('response_type field is mandatory for authorization endpoint') } responseType = req.query.response_type console.debug('Parameter response_type is', responseType) - switch (responseType) { - case 'code': - grantType = 'authorization_code' - break - case 'token': - grantType = 'implicit' - break - default: - return response.error(req, res, new error.UnsupportedResponseType('Unknown response_type parameter passed'), redirectUri) + // Support multiple types + const responseTypes = responseType.split(' ') + for (const i in responseTypes) { + switch (responseTypes[i]) { + case 'code': + grantTypes.push('authorization_code') + break + case 'token': + grantTypes.push('implicit') + break + // case 'id_token': + case 'none': + grantTypes.push(responseTypes[i]) + break + default: + throw new error.UnsupportedResponseType('Unknown response_type parameter passed') + } } - console.debug('Parameter response_type is', responseType) + + // Filter out duplicates + grantTypes = grantTypes.filter(function (value, index, self) { + return self.indexOf(value) === index + }) + + // "None" type cannot be combined with others + if (grantTypes.length > 1 && grantTypes.indexOf('none') !== -1) { + throw new error.InvalidRequest('Grant type "none" cannot be combined with other grant types') + } + + console.debug('Parameter grant_type is', grantTypes.join(' ')) const client = await req.oauth2.model.client.fetchById(clientId) if (!client) { - return response.error(req, res, new error.InvalidClient('Client not found'), redirectUri) + throw new error.InvalidClient('Client not found') } + // TODO: multiple redirect URI if (!req.oauth2.model.client.getRedirectUri(client)) { - return response.error(req, res, new error.UnsupportedResponseType('The client has not set a redirect uri'), redirectUri) + throw new error.UnsupportedResponseType('The client has not set a redirect uri') } else if (!req.oauth2.model.client.checkRedirectUri(client, redirectUri)) { - return response.error(req, res, new error.InvalidRequest('Wrong RedirectUri provided'), redirectUri) - } else { - console.debug('redirect_uri check passed') + throw new error.InvalidRequest('Wrong RedirectUri provided') } + console.debug('redirect_uri check passed') - if (!req.oauth2.model.client.checkGrantType(client, grantType)) { - return response.error(req, res, new error.UnauthorizedClient('This client does not support this grant type'), redirectUri) - } else { - console.debug('Grant type check passed') + // The client needs to support all grant types + for (const i in grantTypes) { + if (!req.oauth2.model.client.checkGrantType(client, grantTypes[i]) && grantTypes[i] !== 'none') { + throw new error.UnauthorizedClient('This client does not support grant type ' + grantTypes[i]) + } } + console.debug('Grant type check passed') scope = req.oauth2.model.client.transformScope(req.query.scope) scope = req.oauth2.model.client.checkScope(client, scope) if (!scope) { - return response.error(req, res, new error.InvalidScope('Client does not allow access to this scope'), redirectUri) - } else { - console.debug('Scope check passed') + throw new error.InvalidScope('Client does not allow access to this scope') } + console.debug('Scope check passed') user = await req.oauth2.model.user.fetchFromRequest(req) if (!user) { - return response.error(req, res, new error.InvalidRequest('There is no currently logged in user'), redirectUri) + throw new error.InvalidRequest('There is no currently logged in user') } else { if (!user.username) { - return response.error(req, res, new error.Forbidden(user), redirectUri) + throw new error.Forbidden(user) } console.debug('User fetched from request') } - let data = null + let resObj = {} + let consented = false if (req.method === 'GET') { - let hasAuthorizedAlready = await usermodel.clientAllowed(user.id, client.id, scope) if (client.verified === 1) { - hasAuthorizedAlready = true + consented = true + } else { + consented = await model.user.consented(user.id, client.id, scope) } + } - if (hasAuthorizedAlready) { - if (grantType === 'authorization_code') { - try { - data = await authorization.Code(req, res, client, scope, user, redirectUri, false) - } catch (err) { - return response.error(req, res, err, redirectUri) - } + // Ask for consent + if (!consented) return req.oauth2.decision(req, res, client, scope, user, redirectUri) - return response.data(req, res, { code: data }, redirectUri) - } else if (grantType === 'implicit') { - try { - data = await authorization.Implicit(req, res, client, scope, user, redirectUri, false) - } catch (err) { - return response.error(req, res, err, redirectUri) - } + for (const i in grantTypes) { + let data = null + switch (grantTypes[i]) { + case 'authorization_code': + data = await authorization.Code(req, res, client, scope, user, redirectUri, !consented) - return response.data(req, res, { + resObj = Object.assign({ code: data }, resObj) + + break + case 'implicit': + data = await authorization.Implicit(req, res, client, scope, user, redirectUri, !consented) + + resObj = Object.assign({ token_type: 'bearer', access_token: data, expires_in: req.oauth2.model.accessToken.ttl - }, redirectUri) - } - } else { - return req.oauth2.decision(req, res, client, scope, user, redirectUri) - } + }, resObj) - return response.error(req, res, new error.InvalidRequest('Invalid request method'), redirectUri) + break + case 'none': + resObj = {} + break + default: + throw new error.UnsupportedResponseType('Unknown response_type parameter passed') + } } - if (grantType === 'authorization_code') { - try { - data = await authorization.Code(req, res, client, scope, user, redirectUri, true) - } catch (err) { - return response.error(req, res, err, redirectUri) - } - - return response.data(req, res, { code: data }, redirectUri) - } else if (grantType === 'implicit') { - try { - data = await authorization.Implicit(req, res, client, scope, user, redirectUri, true) - } catch (err) { - return response.error(req, res, err, redirectUri) - } - - return response.data(req, res, { - token_type: 'bearer', - access_token: data, - expires_in: req.oauth2.model.accessToken.ttl - }, redirectUri) - } else { - return response.error(req, res, new error.InvalidRequest('Invalid request method'), redirectUri) - } -}) + // Return non-code response types as fragment instead of query + return response.data(req, res, resObj, redirectUri, responseType !== 'code') +}, true) diff --git a/server/api/oauth2/controller/code/code.js b/server/api/oauth2/controller/code/code.js index e2cfdb2..ea55347 100644 --- a/server/api/oauth2/controller/code/code.js +++ b/server/api/oauth2/controller/code/code.js @@ -1,23 +1,22 @@ import error from '../../error' import model from '../../model' -module.exports = async (req, res, client, scope, user, redirectUri, createAllowFuture) => { +module.exports = async (req, res, client, scope, user, redirectUri, consentRequested) => { let codeValue = null if (req.method === 'POST' && req.session.csrf && !(req.body.csrf && req.body.csrf === req.session.csrf)) { throw new error.InvalidRequest('Invalid session') } - if (createAllowFuture) { + if (consentRequested) { if (!req.body || (typeof req.body.decision) === 'undefined') { throw new error.InvalidRequest('No decision parameter passed') } else if (req.body.decision === '0') { throw new error.AccessDenied('User denied access to the resource') - } else { - console.debug('Decision check passed') } + console.debug('Decision check passed') - await model.user.allowClient(user.id, client.id, scope) + await model.user.consent(user.id, client.id, scope) } try { diff --git a/server/api/oauth2/controller/code/implicit.js b/server/api/oauth2/controller/code/implicit.js index 26cb6f2..99076a2 100644 --- a/server/api/oauth2/controller/code/implicit.js +++ b/server/api/oauth2/controller/code/implicit.js @@ -1,23 +1,22 @@ import error from '../../error' import model from '../../model' -module.exports = async (req, res, client, scope, user, redirectUri, createAllowFuture) => { +module.exports = async (req, res, client, scope, user, redirectUri, consentRequested) => { let accessTokenValue = null if (req.method === 'POST' && req.session.csrf && !(req.body.csrf && req.body.csrf === req.session.csrf)) { throw new error.InvalidRequest('Invalid session') } - if (createAllowFuture) { + if (consentRequested) { if (!req.body || (typeof req.body.decision) === 'undefined') { throw new error.InvalidRequest('No decision parameter passed') } else if (req.body.decision === '0') { throw new error.AccessDenied('User denied access to the resource') - } else { - console.debug('Decision check passed') } + console.debug('Decision check passed') - await model.user.allowClient(user.id, client.id, scope) + await model.user.consent(user.id, client.id, scope) } try { diff --git a/server/api/oauth2/controller/introspection.js b/server/api/oauth2/controller/introspection.js index 0234c41..5f45e62 100644 --- a/server/api/oauth2/controller/introspection.js +++ b/server/api/oauth2/controller/introspection.js @@ -1,6 +1,6 @@ import error from '../error' import response from '../response' -import wrap from '../../../../scripts/asyncRoute' +import wrap from '../wrap' module.exports = wrap(async function (req, res) { let clientId = null @@ -12,21 +12,21 @@ module.exports = wrap(async function (req, res) { console.debug('Client credentials parsed from body parameters ', clientId, clientSecret) } else { if (!req.headers || !req.headers.authorization) { - return response.error(req, res, new error.InvalidRequest('No authorization header passed')) + throw new error.InvalidRequest('No authorization header passed') } let pieces = req.headers.authorization.split(' ', 2) if (!pieces || pieces.length !== 2) { - return response.error(req, res, new error.InvalidRequest('Authorization header is corrupted')) + throw new error.InvalidRequest('Authorization header is corrupted') } if (pieces[0] !== 'Basic') { - return response.error(req, res, new error.InvalidRequest('Unsupported authorization method:', pieces[0])) + throw new error.InvalidRequest('Unsupported authorization method:', pieces[0]) } pieces = Buffer.from(pieces[1], 'base64').toString('ascii').split(':', 2) if (!pieces || pieces.length !== 2) { - return response.error(req, res, new error.InvalidRequest('Authorization header has corrupted data')) + throw new error.InvalidRequest('Authorization header has corrupted data') } clientId = pieces[0] @@ -35,12 +35,12 @@ module.exports = wrap(async function (req, res) { } if (!req.body.token) { - return response.error(req, res, new error.InvalidRequest('Token not provided in request body')) + throw new error.InvalidRequest('Token not provided in request body') } const token = await req.oauth2.model.accessToken.fetchByToken(req.body.token) if (!token) { - return response.error(req, res, new error.InvalidRequest('Token does not exist')) + throw new error.InvalidRequest('Token does not exist') } const ttl = req.oauth2.model.accessToken.getTTL(token) diff --git a/server/api/oauth2/controller/token.js b/server/api/oauth2/controller/token.js index 1938286..e69c406 100644 --- a/server/api/oauth2/controller/token.js +++ b/server/api/oauth2/controller/token.js @@ -1,7 +1,7 @@ import token from './tokens' import error from '../error' import response from '../response' -import wrap from '../../../../scripts/asyncRoute' +import wrap from '../wrap' module.exports = wrap(async (req, res) => { let clientId = null @@ -14,21 +14,21 @@ module.exports = wrap(async (req, res) => { console.debug('Client credentials parsed from body parameters', clientId, clientSecret) } else { if (!req.headers || !req.headers.authorization) { - return response.error(req, res, new error.InvalidRequest('No authorization header passed')) + throw new error.InvalidRequest('No authorization header passed') } let pieces = req.headers.authorization.split(' ', 2) if (!pieces || pieces.length !== 2) { - return response.error(req, res, new error.InvalidRequest('Authorization header is corrupted')) + throw new error.InvalidRequest('Authorization header is corrupted') } if (pieces[0] !== 'Basic') { - return response.error(req, res, new error.InvalidRequest('Unsupported authorization method:', pieces[0])) + throw new error.InvalidRequest('Unsupported authorization method:', pieces[0]) } pieces = Buffer.from(pieces[1], 'base64').toString('ascii').split(':', 2) if (!pieces || pieces.length !== 2) { - return response.error(req, res, new error.InvalidRequest('Authorization header has corrupted data')) + throw new error.InvalidRequest('Authorization header has corrupted data') } clientId = pieces[0] @@ -37,7 +37,7 @@ module.exports = wrap(async (req, res) => { } if (!req.body.grant_type) { - return response.error(req, res, new error.InvalidRequest('Request body does not contain grant_type parameter')) + throw new error.InvalidRequest('Request body does not contain grant_type parameter') } grantType = req.body.grant_type @@ -46,16 +46,16 @@ module.exports = wrap(async (req, res) => { const client = await req.oauth2.model.client.fetchById(clientId) if (!client) { - return response.error(req, res, new error.InvalidClient('Client not found')) + throw new error.InvalidClient('Client not found') } const valid = req.oauth2.model.client.checkSecret(client, clientSecret) if (!valid) { - return response.error(req, res, new error.UnauthorizedClient('Invalid client secret')) + throw new error.UnauthorizedClient('Invalid client secret') } if (!req.oauth2.model.client.checkGrantType(client, grantType) && grantType !== 'refresh_token') { - return response.error(req, res, new error.UnauthorizedClient('Invalid grant type for the client')) + throw new error.UnauthorizedClient('Invalid grant type for the client') } else { console.debug('Grant type check passed') } diff --git a/server/api/oauth2/controller/tokens/clientCredentials.js b/server/api/oauth2/controller/tokens/clientCredentials.js index 3b1de85..17cf35a 100644 --- a/server/api/oauth2/controller/tokens/clientCredentials.js +++ b/server/api/oauth2/controller/tokens/clientCredentials.js @@ -12,10 +12,10 @@ module.exports = async (oauth2, client, wantScope) => { if (!scope) { throw new error.InvalidScope('Client does not allow access to this scope') - } else { - console.debug('Scope check passed ', scope) } + console.debug('Scope check passed ', scope) + try { resObj.access_token = await oauth2.model.accessToken.create(null, oauth2.model.client.getId(client), scope, oauth2.model.accessToken.ttl) } catch (err) { diff --git a/server/api/oauth2/middleware.js b/server/api/oauth2/middleware.js index c592063..92cdfbd 100644 --- a/server/api/oauth2/middleware.js +++ b/server/api/oauth2/middleware.js @@ -1,6 +1,5 @@ -import response from './response' import error from './error' -import wrap from '../../../scripts/asyncRoute' +import wrap from './wrap' const middleware = wrap(async function (req, res, next) { console.debug('Parsing bearer token') @@ -12,12 +11,12 @@ const middleware = wrap(async function (req, res, next) { // Check authorization header if (!pieces || pieces.length !== 2) { - return response.error(req, res, new error.AccessDenied('Wrong authorization header')) + throw new error.AccessDenied('Wrong authorization header') } // Only bearer auth is supported if (pieces[0].toLowerCase() !== 'bearer') { - return response.error(req, res, new error.AccessDenied('Unsupported authorization method in header')) + throw new error.AccessDenied('Unsupported authorization method in header') } token = pieces[1] @@ -29,15 +28,15 @@ const middleware = wrap(async function (req, res, next) { token = req.body.access_token console.debug('Bearer token parsed from body params:', token) } else { - return response.error(req, res, new error.AccessDenied('Bearer token not found')) + throw new error.AccessDenied('Bearer token not found') } // Try to fetch access token const object = await req.oauth2.model.accessToken.fetchByToken(token) if (!object) { - response.error(req, res, new error.Forbidden('Token not found or has expired')) + throw new error.Forbidden('Token not found or has expired') } else if (!req.oauth2.model.accessToken.checkTTL(object)) { - response.error(req, res, new error.Forbidden('Token is expired')) + throw new error.Forbidden('Token is expired') } else { req.oauth2.accessToken = object console.debug('AccessToken fetched', object) diff --git a/server/api/oauth2/model.js b/server/api/oauth2/model.js index 656fb61..91b16a4 100644 --- a/server/api/oauth2/model.js +++ b/server/api/oauth2/model.js @@ -4,6 +4,11 @@ import Users from '../index' import crypto from 'crypto' const OAuthDB = { + scopes: { + email: ['See your Email address', 'View the user\'s email address'], + image: ['', 'View the user\'s profile picture'], + privilege: ['', 'See the user\'s privilege level'] + }, accessToken: { ttl: config.oauth2.access_token_life, getToken: (object) => { @@ -237,7 +242,7 @@ const OAuthDB = { return req.session.user }, - clientAllowed: async (userId, clientId, scope) => { + consented: async (userId, clientId, scope) => { if (typeof scope === 'object') { scope = scope.join(' ') } @@ -265,7 +270,7 @@ const OAuthDB = { return correct }, - allowClient: async (userId, clientId, scope) => { + consent: async (userId, clientId, scope) => { if (!config.oauth2.save_decision) return true if (typeof scope === 'object') { scope = scope.join(' ') diff --git a/server/api/oauth2/response.js b/server/api/oauth2/response.js index 0af5e90..b916171 100644 --- a/server/api/oauth2/response.js +++ b/server/api/oauth2/response.js @@ -40,9 +40,9 @@ module.exports.error = function (req, res, err, redirectUri) { } } -module.exports.data = function (req, res, obj, redirectUri, anchor) { +module.exports.data = function (req, res, obj, redirectUri, fragment) { if (redirectUri) { - if (anchor) { + if (fragment) { redirectUri += '#' } else { redirectUri += (redirectUri.indexOf('?') === -1 ? '?' : '&') diff --git a/server/api/oauth2/wrap.js b/server/api/oauth2/wrap.js new file mode 100644 index 0000000..59aa2d1 --- /dev/null +++ b/server/api/oauth2/wrap.js @@ -0,0 +1,9 @@ +import response from './response' + +module.exports = function (fn, redir) { + return function (req, res, next) { + fn(req, res, next).catch(e => { + return response.error(req, res, e, redir ? req.query.redirect_uri : null) + }) + } +} diff --git a/static/image/icynet-icon-pleroma.svg b/static/image/icynet-icon-pleroma.svg new file mode 100644 index 0000000..94843f1 --- /dev/null +++ b/static/image/icynet-icon-pleroma.svg @@ -0,0 +1,103 @@ + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + diff --git a/views/authorization.pug b/views/authorization.pug index 3a17eb2..c4aba3a 100644 --- a/views/authorization.pug +++ b/views/authorization.pug @@ -54,7 +54,7 @@ block body i.fas.fa-fw.fa-lock |See your Password li - i.fas.fa-fw.fa-gears + i.fas.fa-fw.fa-cogs |Change your Account Settings .alert.alert-info b Note!