Skip to content

Implement OAuth method #7257

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

Closed
wants to merge 17 commits into from
Closed
Show file tree
Hide file tree
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
Expand Up @@ -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)
Expand Down
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
Expand Up @@ -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",
Expand Down
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;
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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
Expand Up @@ -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
Expand Down
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.
Expand All @@ -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 () {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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');
Expand All @@ -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
Expand Up @@ -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:
Expand Down
Loading