Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement OAuth method #7257

Closed
wants to merge 17 commits into from
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -85,6 +85,9 @@ Jump directly to a version:
| | [2.0.8](#208) |
</details>

__BREAKING CHANGES:__
- NEW: Added a OAuth 2.0 method to authentication. [#7248](https://github.com/parse-community/parse-server/issues/7248).
- NEW: Added file upload restriction. File upload is now only allowed for authenticated users by default for improved security. To allow file upload also for Anonymous Users or Public, set the `fileUpload` parameter in the [Parse Server Options](https://parseplatform.org/parse-server/api/master/ParseServerOptions.html). [#7071](https://github.com/parse-community/parse-server/pull/7071). Thanks to [dblythy](https://github.com/dblythy), [Manuel Trezza](https://github.com/mtrezza).
___
## Unreleased (Master Branch)
[Full Changelog](https://github.com/parse-community/parse-server/compare/4.5.0...master)
3 changes: 1 addition & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -32,6 +32,7 @@
"body-parser": "1.19.0",
"commander": "5.1.0",
"cors": "2.8.5",
"crypto-js": "4.0.0",
"deepcopy": "2.1.0",
"express": "4.17.1",
"follow-redirects": "1.13.2",
64 changes: 34 additions & 30 deletions spec/Auth.spec.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
'use strict';

describe('Auth', () => {
const { Auth, getAuthForSessionToken } = require('../lib/Auth.js');
const {
Auth,
getAuthForSessionToken,
createJWT,
validJWT,
decodeJWT,
} = require('../lib/Auth.js');
const Config = require('../lib/Config');
describe('getUserRoles', () => {
let auth;
@@ -123,35 +129,6 @@ describe('Auth', () => {
expect(userAuth.user.id).toBe(user.id);
});

it('should load auth without a config', async () => {
const user = new Parse.User();
await user.signUp({
username: 'hello',
password: 'password',
});
expect(user.getSessionToken()).not.toBeUndefined();
const userAuth = await getAuthForSessionToken({
sessionToken: user.getSessionToken(),
});
expect(userAuth.user instanceof Parse.User).toBe(true);
expect(userAuth.user.id).toBe(user.id);
});

it('should load auth with a config', async () => {
const user = new Parse.User();
await user.signUp({
username: 'hello',
password: 'password',
});
expect(user.getSessionToken()).not.toBeUndefined();
const userAuth = await getAuthForSessionToken({
sessionToken: user.getSessionToken(),
config: Config.get('test'),
});
expect(userAuth.user instanceof Parse.User).toBe(true);
expect(userAuth.user.id).toBe(user.id);
});

describe('getRolesForUser', () => {
const rolesNumber = 100;

@@ -241,4 +218,31 @@ describe('Auth', () => {
expect(cloudRoles2.length).toBe(rolesNumber);
});
});

describe('OAuth2.0 JWT', () => {
it('should handle jwt', async () => {
const oauthKey = 'jwt-secret';
const oauthTTL = 100;
const user = new Parse.User();
await user.signUp({
username: 'jwt-test',
password: 'jwt-password',
});
const sessionToken = user.getSessionToken();

const jwt = createJWT(sessionToken, oauthKey, oauthTTL);
expect(jwt.accessToken).toBeDefined();
expect(jwt.expires_in).toBeDefined();

const isValid = validJWT('invalid', oauthKey);
expect(isValid).toBe(false);

const result = validJWT(jwt.accessToken, oauthKey);
expect(result.sub).toBe(sessionToken);
expect(result.exp).toBeDefined();

const decoded = decodeJWT(jwt.accessToken);
expect(result).toEqual(decoded);
});
});
});
140 changes: 140 additions & 0 deletions spec/ParseUser.spec.js
Original file line number Diff line number Diff line change
@@ -3929,6 +3929,146 @@ describe('Parse.User testing', () => {
}
});

it('user signup with JWT', async () => {
const oauthKey = 'jwt-secret';
const oauthTTL = 100;
await reconfigureServer({
oauth20: true,
oauthKey,
oauthTTL,
});
let response = await request({
method: 'POST',
url: 'http://localhost:8378/1/users',
headers: {
'X-Parse-Application-Id': Parse.applicationId,
'X-Parse-REST-API-Key': 'rest',
'Content-Type': 'application/json',
},
body: {
username: 'jwt-test',
password: 'jwt-password',
},
});
const { accessToken, refreshToken, expiresAt, sessionToken } = response.data;
expect(accessToken).toBeDefined();
expect(refreshToken).toBeDefined();
expect(expiresAt).toBeDefined();
expect(sessionToken).toBeUndefined();

response = await request({
method: 'POST',
url: 'http://localhost:8378/1/users/refresh',
headers: {
'X-Parse-Application-Id': Parse.applicationId,
'X-Parse-REST-API-Key': 'rest',
'Content-Type': 'application/json',
},
body: {
refreshToken,
},
});
const jwt = response.data;
expect(jwt.accessToken).toBe(accessToken);
expect(jwt.expiresAt).toBeDefined();
expect(jwt.refreshToken).not.toBe(refreshToken);

const query = new Parse.Query('_Session');
query.equalTo('refreshToken', jwt.refreshToken);
let session = await query.first({ useMasterKey: true });
expect(session).toBeDefined();

await request({
method: 'POST',
url: 'http://localhost:8378/1/users/revoke',
headers: {
'X-Parse-Application-Id': Parse.applicationId,
'X-Parse-REST-API-Key': 'rest',
'Content-Type': 'application/json',
},
body: {
refreshToken: jwt.refreshToken,
},
});
session = await query.first({ useMasterKey: true });
expect(session).toBeUndefined();

try {
await request({
method: 'POST',
url: 'http://localhost:8378/1/users/refresh',
headers: {
'X-Parse-Application-Id': Parse.applicationId,
'X-Parse-REST-API-Key': 'rest',
'Content-Type': 'application/json',
},
body: {
refreshToken: jwt.refreshToken,
},
});
fail();
} catch (response) {
const { code, error } = response.data;
expect(code).toBe(Parse.Error.INVALID_SESSION_TOKEN);
expect(error).toBe('Invalid refresh token');
}

response = await request({
method: 'POST',
url: 'http://localhost:8378/1/users/revoke',
headers: {
'X-Parse-Application-Id': Parse.applicationId,
'X-Parse-REST-API-Key': 'rest',
'Content-Type': 'application/json',
},
body: {
refreshToken: jwt.refreshToken,
},
});
expect(response.data).toEqual({});
});

it('handle JWT errors', async () => {
try {
await request({
method: 'POST',
url: 'http://localhost:8378/1/users/refresh',
headers: {
'X-Parse-Application-Id': Parse.applicationId,
'X-Parse-REST-API-Key': 'rest',
'Content-Type': 'application/json',
},
body: {
refreshToken: null,
},
});
fail();
} catch (response) {
const { code, error } = response.data;
expect(code).toBe(Parse.Error.INVALID_SESSION_TOKEN);
expect(error).toBe('Invalid refresh token');
}
try {
await request({
method: 'POST',
url: 'http://localhost:8378/1/users/revoke',
headers: {
'X-Parse-Application-Id': Parse.applicationId,
'X-Parse-REST-API-Key': 'rest',
'Content-Type': 'application/json',
},
body: {
refreshToken: null,
},
});
fail();
} catch (response) {
const { code, error } = response.data;
expect(code).toBe(Parse.Error.INVALID_SESSION_TOKEN);
expect(error).toBe('Invalid refresh token');
}
});

describe('issue #4897', () => {
it_only_db('mongo')('should be able to login with a legacy user (no ACL)', async () => {
// This issue is a side effect of the locked users and legacy users which don't have ACL's
71 changes: 71 additions & 0 deletions src/Auth.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
const CryptoJS = require('crypto-js');
const cryptoUtils = require('./cryptoUtils');
const jwt = require('jsonwebtoken');
const RestQuery = require('./RestQuery');
const Parse = require('parse/node');
const SHA256 = require('crypto-js/sha256');

// An Auth object tells you who is requesting something and whether
// the master key was used.
@@ -27,6 +30,59 @@ function Auth({
this.rolePromise = null;
}

const base64url = source => {
let encodedSource = CryptoJS.enc.Base64.stringify(source);
encodedSource = encodedSource.replace(/=+$/, '');
encodedSource = encodedSource.replace(/\+/g, '-');
encodedSource = encodedSource.replace(/\//g, '_');
return encodedSource;
};

const generateRefreshToken = () => {
return SHA256(CryptoJS.lib.WordArray.random(256)).toString();
};

const createJWT = (sessionToken, oauthKey, oauthTTL) => {
const header = {
alg: 'HS256',
typ: 'JWT',
};
const stringifiedHeader = CryptoJS.enc.Utf8.parse(JSON.stringify(header));
const encodedHeader = base64url(stringifiedHeader);
const currentTime = new Date();
const timestamp = Math.floor(currentTime.getTime() / 1000);
const expiration = timestamp + oauthTTL;
const data = {
sub: sessionToken,
iat: timestamp,
exp: expiration,
};
const stringifiedData = CryptoJS.enc.Utf8.parse(JSON.stringify(data));
const encodedData = base64url(stringifiedData);
const token = encodedHeader + '.' + encodedData;

let signature = CryptoJS.HmacSHA256(token, oauthKey);
signature = base64url(signature);
currentTime.setSeconds(currentTime.getSeconds() + oauthTTL);

return {
accessToken: token + '.' + signature,
expires_in: { __type: 'Date', iso: currentTime.toISOString() },
};
};

const validJWT = (token, secret) => {
try {
return jwt.verify(token, secret);
} catch (err) {
return false;
}
};

const decodeJWT = token => {
return jwt.decode(token);
};

// Whether this auth could possibly modify the given user id.
// It still could be forbidden via ACLs even if this returns true.
Auth.prototype.isUnauthenticated = function () {
@@ -63,6 +119,13 @@ const getAuthForSessionToken = async function ({
}) {
cacheController = cacheController || (config && config.cacheController);
if (cacheController) {
if (config.oauth20 === true) {
if (validJWT(sessionToken, config.oauthKey) === false) {
throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, 'Invalid session token');
}
const decoded = decodeJWT(sessionToken);
sessionToken = decoded.sub;
}
const userJSON = await cacheController.user.get(sessionToken);
if (userJSON) {
const cachedUser = Parse.Object.fromJSON(userJSON);
@@ -321,6 +384,10 @@ const createSession = function (
sessionData.installationId = installationId;
}

if (config.oauth20 === true) {
sessionData.refreshToken = generateRefreshToken();
}

Object.assign(sessionData, additionalSessionData);
// We need to import RestWrite at this point for the cyclic dependency it has to it
const RestWrite = require('./RestWrite');
@@ -339,5 +406,9 @@ module.exports = {
readOnly,
getAuthForSessionToken,
getAuthForLegacySessionToken,
generateRefreshToken,
createSession,
createJWT,
validJWT,
decodeJWT,
};
16 changes: 16 additions & 0 deletions src/Options/Definitions.js
Original file line number Diff line number Diff line change
@@ -353,6 +353,22 @@ module.exports.ParseServerOptions = {
env: 'PARSE_SERVER_REST_API_KEY',
help: 'Key for REST calls',
},
oauth20: {
env: 'PARSE_SERVER_OAUTH_20',
help: 'Sets whether to use the OAuth protocol',
action: parsers.booleanParser,
default: false,
},
oauthKey: {
env: 'PARSE_SERVER_OAUTH_KEY',
help: 'Key for OAuth protocol',
},
oauthTTL: {
env: 'PARSE_SERVER_OAUTH_TTL',
help: 'The JSON Web Token (JWT) expiration TTL',
action: parsers.numberParser('oauthTTL'),
default: 1800,
},
revokeSessionOnPasswordReset: {
env: 'PARSE_SERVER_REVOKE_SESSION_ON_PASSWORD_RESET',
help:
11 changes: 11 additions & 0 deletions src/Options/index.js
Original file line number Diff line number Diff line change
@@ -84,6 +84,17 @@ export interface ParseServerOptions {
/* Key for REST calls
:ENV: PARSE_SERVER_REST_API_KEY */
restAPIKey: ?string;
/* Enable (or disable) the addition of OAuth 2.0
:ENV: PARSE_SERVER_OAUTH_20
:DEFAULT: false */
oauth20: ?boolean;
/* Key for OAuth 2.0
:ENV: PARSE_SERVER_OAUTH_KEY */
oauthKey: ?string;
/* The TTL for Access Token
:ENV: PARSE_SERVER_OAUTH_TTL
:DEFAULT: 1800 - 30 minutes */
oauthTTL: ?number;
/* Read-only key, which has the same capabilities as MasterKey without writes */
readOnlyMasterKey: ?string;
/* Key sent with outgoing webhook calls */
145 changes: 141 additions & 4 deletions src/Routers/UsersRouter.js
Original file line number Diff line number Diff line change
@@ -139,11 +139,115 @@ export class UsersRouter extends ClassesRouter {
});
}

async handleCreate(req) {
const res = await rest.create(
req.config,
req.auth,
this.className(req),
req.body,
req.info.clientSDK,
req.info.context
);
if (req.config.oauth20 === true) {
const sessionToken = res.response.sessionToken;
const result = await rest.find(
req.config,
Auth.master(req.config),
'_Session',
{ sessionToken },
{ include: 'user' },
req.info.clientSDK,
req.info.context
);
const user = result.results[0];
const token = Auth.createJWT(sessionToken, req.config.oauthKey, req.config.oauthTTL);
res.response.accessToken = token.accessToken;
res.response.refreshToken = user.refreshToken;
res.response.expiresAt = token.expires_in;
delete res.response.sessionToken;
}
return res;
}

async handleRefresh(req) {
const { refreshToken } = req.body;
if (!refreshToken) {
throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, 'Invalid refresh token');
}
const res = await rest.find(
req.config,
Auth.master(req.config),
'_Session',
{ refreshToken },
{ include: 'user' },
req.info.clientSDK,
req.info.context
);
if (!res.results || res.results.length == 0 || !res.results[0].user) {
throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, 'Invalid refresh token');
} else {
const data = res.results[0];
const newCode = Auth.generateRefreshToken();
const sessionId = data.objectId;
const token = Auth.createJWT(data.sessionToken, req.config.oauthKey, req.config.oauthTTL);
await req.config.database.update(
'_Session',
{ objectId: sessionId },
{ refreshToken: newCode }
);
return {
response: {
accessToken: token.accessToken,
refreshToken: newCode,
expiresAt: token.expires_in,
},
};
}
}

async handleRevoke(req) {
const { refreshToken } = req.body;
if (!refreshToken) {
throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, 'Invalid refresh token');
}
const res = await rest.find(
req.config,
Auth.master(req.config),
'_Session',
{ refreshToken: refreshToken },
undefined,
req.info.clientSDK,
req.info.context
);
if (res.results && res.results.length) {
await rest.del(
req.config,
Auth.master(req.config),
'_Session',
res.results[0].objectId,
req.info.context
);
this._runAfterLogoutTrigger(req, res.results[0]);
}
return { response: {} };
}

handleMe(req) {
if (!req.info || !req.info.sessionToken) {
throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, 'Invalid session token');
}
const sessionToken = req.info.sessionToken;
let sessionToken = req.info.sessionToken;
const originalToken = req.info.sessionToken;

// Check if you use OAuth to retrieve the sessionToken from within the JWT
if (req.config.oauth20 === true) {
if (Auth.validJWT(sessionToken, req.config.oauthKey) === false) {
throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, 'Invalid session token');
}
const decoded = Auth.decodeJWT(sessionToken);
sessionToken = decoded.sub;
}

return rest
.find(
req.config,
@@ -159,8 +263,14 @@ export class UsersRouter extends ClassesRouter {
throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, 'Invalid session token');
} else {
const user = response.results[0].user;
// Send token back on the login, because SDKs expect that.
user.sessionToken = sessionToken;
if (req.config.oauth20 === true) {
const decoded = Auth.decodeJWT(originalToken);
const expiresDate = new Date(decoded.exp * 1000);
user.accessToken = originalToken;
user.expiresAt = { __type: 'Date', iso: expiresDate.toISOString() };
} else {
user.sessionToken = sessionToken;
}

// Remove hidden properties.
UsersRouter.removeHiddenProperties(user);
@@ -227,7 +337,22 @@ export class UsersRouter extends ClassesRouter {
installationId: req.info.installationId,
});

user.sessionToken = sessionData.sessionToken;
// Check if you use OAuth to generate a JWT to return
if (req.config.oauth20 === true) {
var signedToken = Auth.createJWT(
sessionData.sessionToken,
req.config.oauthKey,
req.config.oauthTTL
);

user.accessToken = signedToken.accessToken;
user.refreshToken = sessionData.refreshToken;
user.expiresAt = signedToken.expires_in;

delete user.sessionToken;
} else {
user.sessionToken = sessionData.sessionToken;
}

await createSession();

@@ -259,6 +384,12 @@ export class UsersRouter extends ClassesRouter {
handleLogOut(req) {
const success = { response: {} };
if (req.info && req.info.sessionToken) {
// Check if you use OAuth to retrieve the sessionToken from within the JWT
if (req.config.oauth20 === true) {
const decoded = Auth.decodeJWT(req.info.sessionToken);
req.info.sessionToken = decoded.sub;
}

return rest
.find(
req.config,
@@ -402,6 +533,12 @@ export class UsersRouter extends ClassesRouter {
this.route('GET', '/users/me', req => {
return this.handleMe(req);
});
this.route('POST', '/users/refresh', req => {
return this.handleRefresh(req);
});
this.route('POST', '/users/revoke', req => {
return this.handleRevoke(req);
});
this.route('GET', '/users/:objectId', req => {
return this.handleGet(req);
});