diff --git a/.gitignore b/.gitignore index 18f267f..83fb59b 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ lib *.DS_Store config yarn.lock +yarn-error.log diff --git a/src/constants.js b/src/constants.js new file mode 100644 index 0000000..89cac31 --- /dev/null +++ b/src/constants.js @@ -0,0 +1,3 @@ +/* eslint import/prefer-default-export: 0 */ +export const GRANT_TYPE_PASSWORD = "password"; +export const GRANT_TYPE_REFRESH_TOKEN = "refresh_token"; diff --git a/src/model/descriptor.js b/src/model/descriptor.js index e694ade..5d2f5a4 100644 --- a/src/model/descriptor.js +++ b/src/model/descriptor.js @@ -119,9 +119,21 @@ const descriptor = { access_token: { type: "string", }, + access_created: { + type: "#DateTime", + }, expires_in: { type: "integer", }, + refresh_token: { + type: "string", + }, + refresh_expires_in: { + type: "integer", + }, + refresh_created: { + type: "#DateTime", + }, scope: { type: "array", arraytype: "string", diff --git a/src/model/index.js b/src/model/index.js index 556b4d1..7dfc3db 100644 --- a/src/model/index.js +++ b/src/model/index.js @@ -6,6 +6,7 @@ */ import { StringTools, dbCreate } from "zoapp-core"; import descriptor from "./descriptor"; +import { GRANT_TYPE_PASSWORD, GRANT_TYPE_REFRESH_TOKEN } from "../constants"; export class ZOAuthModel { constructor(config = {}, database = null) { @@ -17,6 +18,7 @@ export class ZOAuthModel { } this.config = config; this.tokenExpiration = this.config.tokenExpiration || 3600; + this.refreshTokenExpiration = this.config.refreshTokenExpiration || 86400; } async open() { @@ -43,6 +45,10 @@ export class ZOAuthModel { return this.database.generateToken(48); } + generateRefreshToken() { + return this.database.generateToken(32); + } + generateId() { return this.database.generateToken(32); } @@ -300,39 +306,104 @@ export class ZOAuthModel { } async getAccessToken( + grantType, + refreshToken, clientId, userId, scope, expiration = this.tokenExpiration, sessions = this.getSessions(), ) { - let accessToken = null; - if (clientId && userId) { - const time = Date.now(); - let id = `${clientId}-${userId}`; - accessToken = await sessions.getItem(id); - if (!accessToken) { - accessToken = { - access_token: this.generateAccessToken(), - expires_in: expiration, - scope, - client_id: clientId, - user_id: userId, - id, - created: time, - }; - id = null; + let actualSession = null; + const time = Date.now(); + if (grantType === GRANT_TYPE_PASSWORD) { + if (clientId && userId) { + let id = `${clientId}-${userId}`; + actualSession = await sessions.getItem(id); + if (!actualSession) { + const newRefreshToken = await this.getRefreshToken(); + actualSession = await this.createSession(id, scope, { + expiration, + clientId, + userId, + newRefreshToken, + time, + }); + id = null; + } else { + actualSession.last = time; + // TODO handle token expiration + if (scope) { + actualSession.scope = scope; + } + } + await sessions.setItem(id, actualSession); } else { - accessToken.last = time; - // TODO handle token expiration - if (scope) { - accessToken.scope = scope; + actualSession = { error: "Require credentials" }; + } + } else if (grantType === GRANT_TYPE_REFRESH_TOKEN) { + if (refreshToken) { + actualSession = await sessions.getItem(`refresh_token=${refreshToken}`); + if (actualSession !== null) { + const sessionId = actualSession.id; + const newRefreshToken = await this.getRefreshToken(); + actualSession = await this.refreshSession({ + expiration, + newRefreshToken, + time, + }); + await sessions.setItem(sessionId, actualSession); + } else { + actualSession = { error: "Wrong refresh_token" }; } + } else { + actualSession = { error: "Require refresh_token" }; } - await sessions.setItem(id, accessToken); - // this.database.flush(); + } else { + actualSession = { error: "Request Failed, unknown grant_type" }; } - return accessToken; + return actualSession; + } + + async createSession(id, scope, params) { + let session = {}; + session = { + access_token: this.generateAccessToken(), + expires_in: params.expiration, + scope, + client_id: params.clientId, + user_id: params.userId, + id, + access_created: params.time, + created: params.time, + refresh_token: params.newRefreshToken.refresh_token, + refresh_expires_in: params.newRefreshToken.refresh_expires_in, + refresh_created: params.newRefreshToken.refresh_created, + }; + return session; + } + + async refreshSession(param) { + let session = {}; + session = { + access_token: this.generateAccessToken(), + expires_in: param.expiration, + access_created: param.time, + refresh_token: param.newRefreshToken.refresh_token, + refresh_expires_in: param.newRefreshToken.refresh_expires_in, + refresh_created: param.newRefreshToken.refresh_created, + }; + return session; + } + + async getRefreshToken(expiration = this.refreshTokenExpiration) { + const time = Date.now(); + const refreshToken = { + refresh_token: this.generateRefreshToken(), + refresh_expires_in: expiration, + refresh_created: time, + }; + return refreshToken; } async validateAccessToken(accessToken, sessions = this.getSessions()) { @@ -340,8 +411,12 @@ export class ZOAuthModel { if (accessToken) { await sessions.nextItem((a) => { if (a.access_token === accessToken) { - access = a; - return true; + const expireTime = a.expires_in * 1000; + const expirationDate = a.access_created + expireTime; + if (expirationDate > new Date().getTime()) { + access = a; + return true; + } } return false; }); @@ -349,6 +424,23 @@ export class ZOAuthModel { return access; } + async validateRefreshToken(refreshToken, sessions = this.getSessions()) { + let refresh = null; + if (refreshToken) { + await sessions.nextItem((a) => { + if (a.refresh_token === refreshToken) { + const expirationDate = a.created + a.refresh_expires_in * 1000; + if (expirationDate > new Date().getTime()) { + refresh = a; + return true; + } + } + return false; + }); + } + return refresh; + } + /* eslint-disable no-unused-vars */ /* eslint-disable class-methods-use-this */ validateScope(scope) { diff --git a/src/zoauthServer.js b/src/zoauthServer.js index 205fb89..00a6f7c 100644 --- a/src/zoauthServer.js +++ b/src/zoauthServer.js @@ -7,6 +7,7 @@ import { StringTools, Password } from "zoapp-core"; import createModel from "./model"; import Route from "./model/route"; +import { GRANT_TYPE_PASSWORD, GRANT_TYPE_REFRESH_TOKEN } from "./constants"; export class ZOAuthServer { /* static ErrorsMessages = { @@ -15,14 +16,18 @@ export class ZOAuthServer { WRONG_NAME: "Wrong name sent", CANT_SAVE_APP: "Can't save application", }; */ - constructor(config = {}, database = null) { this.config = { ...config }; this.model = createModel(this.config.database, database); this.permissionRoutes = []; } - static errorMessages() {} + /* eslint-disable class-methods-use-this */ + createResultResponse(message) { + return { + result: message, + }; + } async start() { await this.model.open(); @@ -67,8 +72,8 @@ export class ZOAuthServer { accessToken = null, appCredentials = null, ) { - const response = {}; const route = this.findRoute(routeName, method); + let response = {}; let access = null; if (route && accessToken) { access = await this.model.validateAccessToken(accessToken); @@ -83,6 +88,7 @@ export class ZOAuthServer { access_token, client_id, expires_in, + refresh_token, scope, user_id, } = access; @@ -90,45 +96,56 @@ export class ZOAuthServer { access_token, client_id, expires_in, + refresh_token, scope, user_id, }; /* eslint-enable camelcase */ } else { - response.result = { error: "Not allowed" }; + response = this.createResultResponse({ + error: "Not allowed", + }); } } else { - response.result = { error: "Not valid user account" }; + response = this.createResultResponse({ + error: "Not valid user account", + }); } } else { - response.result = { error: "Not valid access token" }; + response = this.createResultResponse({ + error: "Not valid access token", + }); } } else if (route && route.isOpen()) { - response.result = { access: "open" }; + response = this.createResultResponse({ + access: "open", + }); } else if ( route && route.isScopeValid("application") && (await this.validateApplicationCredentials(appCredentials)) ) { - response.result = { + response = this.createResultResponse({ client_id: appCredentials.id, scope: "application", - }; + }); } else { - response.result = { error: "No permission route" }; + response = this.createResultResponse({ + error: "No permission route", + }); } return response; } static validatePassword(params) { const { password } = params; - const response = {}; const strength = Password.strength(password); + let response = {}; if (strength > 0) { const hash = Password.generateSaltHash(password); response.result = { hash, strength }; } else { - response.result = { error: "Empty password" }; + response = this.createResultResponse({ error: "Empty password" }); } return response; } @@ -164,7 +181,7 @@ export class ZOAuthServer { domains, } = params; // logger.info("registerApplication"); - const response = {}; + let response = {}; let app = null; const wrongEmail = !StringTools.isEmail(email); if (ZOAuthServer.validateApplicationName(name) && !wrongEmail) { @@ -182,21 +199,32 @@ export class ZOAuthServer { } else { // logger.info("app exist !"); app = null; - response.result = { error: "Can't register this application name" }; + response = this.createResultResponse({ + error: "Can't register this application name", + }); } } else if (wrongEmail) { - response.result = { error: "Wrong email sent" }; + response = this.createResultResponse({ + error: "Wrong email sent", + }); } else { - response.result = { error: "Wrong name sent" }; + response = this.createResultResponse({ + error: "Wrong name sent", + }); } if (app) { app = await this.model.setApplication(app); // logger.info("app=", app); if (app) { - response.result = { client_id: app.id, client_secret: app.secret }; + response = this.createResultResponse({ + client_id: app.id, + client_secret: app.secret, + }); } else { - response.result = { error: "Can't save application" }; + response = this.createResultResponse({ + error: "Can't save application", + }); } } // TODO authorizedIps CORS params @@ -265,7 +293,7 @@ export class ZOAuthServer { } } if (!response) { - response = { error: "No client found" }; + response = this.createResultResponse({ error: "No client found" }); } return response; } @@ -285,10 +313,10 @@ export class ZOAuthServer { if (clientId) { app = await this.model.getApplication(clientId); } - const response = {}; + let response = {}; if (!app) { - return { error: "No client found" }; + return this.createResultResponse({ error: "No client found" }); } let user = null; const policies = app.policies || { userNeedEmail: true }; // TODO remove this default policies @@ -312,7 +340,9 @@ export class ZOAuthServer { anonymous_secret: extras.anonymous_secret, }; } else { - response.result = { error: "Wrong parameters sent" }; + response = this.createResultResponse({ + error: "Wrong parameters sent", + }); } } else if ( ZOAuthServer.validateCredentialsValue(username, email, password, policies) @@ -329,23 +359,27 @@ export class ZOAuthServer { } } else { user = null; - response.result = { error: `User exist: ${username}` }; + response = this.createResultResponse({ + error: `User exist: ${username}`, + }); } } else { - response.result = { error: "Wrong parameters sent" }; + response = this.createResultResponse({ + error: "Wrong parameters sent", + }); } if (user) { user = await this.model.setUser(user); if (user) { - response.result = { + response = this.createResultResponse({ id: user.id, username: user.username, - }; + }); if (user.email) { response.result.email = user.email; } } else { - response.result = { error: "Can't save user" }; + response = this.createResultResponse({ error: "Can't save user" }); } } return response; @@ -364,8 +398,8 @@ export class ZOAuthServer { redirect_uri: redirectUri, /* ...extras */ } = params; - const response = {}; const authentication = {}; + let response = {}; let app = null; let user = null; let storedAuth = null; @@ -388,72 +422,159 @@ export class ZOAuthServer { // TODO save extra params storedAuth = await this.model.setAuthentication(authentication); if (storedAuth) { - response.result = { redirect_uri: authentication.redirect_uri }; + response = this.createResultResponse({ + redirect_uri: authentication.redirect_uri, + }); } else { - response.result = { error: "Can't authenticate" }; + response = this.createResultResponse({ + error: "Can't authenticate", + }); } } else if (!app) { - response.result = { error: "No valid client_id" }; + response = this.createResultResponse({ + error: "No valid client_id", + }); } else if (user == null && userId) { - response.result = { error: "No valid user_id" }; + response = this.createResultResponse({ + error: "No valid user_id", + }); } else if (user == null && username && password) { - response.result = { error: "Wrong credentials" }; + response = this.createResultResponse({ + error: "Wrong credentials", + }); } else { - response.result = { error: "Not valid" }; + response = this.createResultResponse({ + error: "Not valid", + }); } return response; } /** * Request an access token + * Inspired by Offical Doc of OAuth2, resume here : + * https://docs.google.com/document/d/1yEzRcvOlHXoMmBmV49G4HxAPEmkWuW7CFGo2cuDYdfo/edit?usp=sharing */ async requestAccessToken(params) { const { + grant_type: grantType, + refresh_token: refreshToken, + client_id: clientId, username, password, - grant_type: grantType, /* redirect_uri: redirectUri, */ - client_id: clientId, /* ...extras */ } = params; - const response = {}; - let authentication = null; - let user = null; - // Only grantType = password for now - if (grantType === "password") { - // validate user - user = await this.model.validateCredentials(username, password); - if (user) { - // validate authentication - authentication = await this.model.getAuthentication(clientId, user.id); - if (!authentication) { - response.result = { error: "Not authentified" }; - } - // TODO extras, redirectUri + let response = {}; + if (clientId) { + if (grantType === GRANT_TYPE_PASSWORD) { + response = await this.requestGrantTypePassword( + clientId, + username, + password, + ); + } else if (grantType === GRANT_TYPE_REFRESH_TOKEN) { + response = await this.requestGrantTypeRefreshToken( + clientId, + refreshToken, + ); } else { - response.result = { error: "Can't authenticate" }; + response = this.createResultResponse({ + error: `Unknown grant type: ${grantType}`, + }); } } else { - response.result = { error: `Unknown grant type: ${grantType}` }; + response = this.createResultResponse({ + error: "Require client_id", + }); } + return response; + } + /** + * requestGrantTypePassword() used in requestAccessToken() for GrantType Password + * + * The Password grant type is used to obtain additional access tokens + * in order to prolong the client’s authorization of a user’s resources. + * + * Password Grant require : client_id, client_secret, "redirect_uri", username, password + */ + async requestGrantTypePassword(clientId, username, password) { + let response = {}; + let authentication = null; + let user = null; + // validate user + user = await this.model.validateCredentials(username, password); + if (user) { + // validate authentication + authentication = await this.model.getAuthentication(clientId, user.id); + if (!authentication) { + response = this.createResultResponse({ error: "Not authentified" }); + } + // TODO extras, redirectUri + } else { + response = this.createResultResponse({ error: "Can't authenticate" }); + } if (user && authentication) { // generate accessToken const { scope } = authentication; - const accessToken = await this.model.getAccessToken( + const session = await this.model.getAccessToken( + GRANT_TYPE_PASSWORD, + null, clientId, user.id, scope, ); response.result = { - access_token: accessToken.access_token, - expires_in: accessToken.expires_in, - scope: accessToken.scope, + access_token: session.access_token, + expires_in: session.expires_in, + refresh_token: session.refresh_token, + scope: session.scope, }; } return response; } + /* eslint-disable no-unused-vars */ + /** + * requestGrantTypeRefreshToken() used in requestAccessToken() for GrantType Refresh Token + * + * The Refresh Token grant type is used to obtain a new access token + * without setting a password. + * + * Refresh Token Grant require : client_id, client_secret, refresh_token + */ + async requestGrantTypeRefreshToken(clientId, refreshToken) { + let response = {}; + // no need to validate user we have refreshToken + if (refreshToken) { + // generate accessToken, scope is stock in refresh + const session = await this.model.getAccessToken( + GRANT_TYPE_REFRESH_TOKEN, + refreshToken, + clientId, + ); + if (session.error === undefined) { + response = this.createResultResponse({ + access_token: session.access_token, + expires_in: session.expires_in, + refresh_token: session.refresh_token, + scope: session.scope, + }); + } else { + response = this.createResultResponse({ + error: session.error, + }); + } + // TODO extras, redirectUri + } else { + response = this.createResultResponse({ + error: "Use GRANT_TYPE_REFRESH_TOKEN without refresh_token", + }); + } + return response; + } + /* eslint-disable no-unused-vars */ /* eslint-disable class-methods-use-this */ /** diff --git a/tests/config.js b/tests/config.js new file mode 100644 index 0000000..1357b52 --- /dev/null +++ b/tests/config.js @@ -0,0 +1,10 @@ +/* eslint import/prefer-default-export: 0 */ +export const mysqlConfig = { + database: { + datatype: "mysql", + host: "localhost", + name: "test", + user: "root", + }, + endpoint: "/auth", +}; diff --git a/tests/zoauthRouter.test.js b/tests/zoauthRouter.test.js index db32bb9..c2c01ff 100644 --- a/tests/zoauthRouter.test.js +++ b/tests/zoauthRouter.test.js @@ -6,16 +6,7 @@ */ import zoauthServer from "zoauth/zoauthServer"; import ZOAuthRouter, { send } from "zoauth/zoauthRouter"; - -const mysqlConfig = { - database: { - datatype: "mysql", - host: "localhost", - name: "auth_test", - user: "root", - }, - endpoint: "/auth", -}; +import { mysqlConfig } from "./config"; const describeParams = (name, params, func) => { params.forEach((p) => { @@ -89,6 +80,7 @@ describeParams( expect(Object.keys(result)).toEqual([ "access_token", "expires_in", + "refresh_token", "scope", ]); accessToken = result.access_token; @@ -177,6 +169,7 @@ describeParams( expect(Object.keys(result)).toEqual([ "access_token", "expires_in", + "refresh_token", "scope", ]); accessToken = result.access_token; @@ -198,6 +191,7 @@ describeParams( "access_token", "client_id", "expires_in", + "refresh_token", "scope", "user_id", ]); diff --git a/tests/zoauthServer.test.js b/tests/zoauthServer.test.js index 9b57b88..58136bf 100644 --- a/tests/zoauthServer.test.js +++ b/tests/zoauthServer.test.js @@ -5,16 +5,7 @@ * LICENSE file in the root directory of this source tree. */ import zoauthServer from "zoauth/zoauthServer"; - -const mysqlConfig = { - database: { - datatype: "mysql", - host: "localhost", - name: "auth_test", - user: "root", - }, - endpoint: "/auth", -}; +import { mysqlConfig } from "./config"; const describeParams = (name, params, func) => { params.forEach((p) => { @@ -241,6 +232,7 @@ describeParams( expect(Object.keys(result)).toEqual([ "access_token", "expires_in", + "refresh_token", "scope", "username", "user_id", @@ -424,6 +416,7 @@ describeParams( expect(Object.keys(result)).toEqual([ "access_token", "expires_in", + "refresh_token", "scope", ]); expect(result.access_token).toHaveLength(48); @@ -507,7 +500,153 @@ describeParams( }; response = await authServer.requestAccessToken(params); ({ result } = response); - expect(result.error).toEqual("Not authentified"); + expect(result.error).toEqual("Require client_id"); + }); + }); + + describe("requestAccessTokenWithRefreshToken", () => { + it("should get accessToken", async () => { + let params = { + name: "Zoapp", + grant_type: "password", + redirect_uri: "localhost", + email: "toto@test.com", + }; + const authServer = zoauthServer(config); + await authServer.reset(); + await authServer.start(); + + let response = await authServer.registerApplication(params); + let { result } = response; + expect(Object.keys(result)).toEqual(["client_id", "client_secret"]); + expect(result.client_id).toHaveLength(64); + const clientId = result.client_id; + + params = { + client_id: clientId, + username: "toto", + password: "12345", + email: "toto@test.com", + }; + response = await authServer.registerUser(params); + ({ result } = response); + expect(Object.keys(result)).toEqual(["id", "username", "email"]); + expect(result.id).toHaveLength(32); + + params = { + client_id: clientId, + username: "toto", + password: "12345", + redirect_uri: "localhost", + }; + response = await authServer.authorizeAccess(params); + ({ result } = response); + expect(Object.keys(result)).toEqual(["redirect_uri"]); + expect(result.redirect_uri).toEqual("localhost"); + + params = { + client_id: clientId, + username: "toto", + password: "12345", + redirect_uri: "localhost", + grant_type: "password", + }; + response = await authServer.requestAccessToken(params); + ({ result } = response); + expect(Object.keys(result)).toEqual([ + "access_token", + "expires_in", + "refresh_token", + "scope", + ]); + expect(result.access_token).toHaveLength(48); + const oldToken = { + access_token: result.access_token, + refresh_token: result.refresh_token, + }; + + params = { + grant_type: "refresh_token", + refresh_token: result.refresh_token, + client_id: clientId, + }; + response = await authServer.requestAccessToken(params); + ({ result } = response); + expect(Object.keys(result)).toEqual([ + "access_token", + "expires_in", + "refresh_token", + "scope", + ]); + expect(result.access_token).toHaveLength(48); + const newToken = { + access_token: result.access_token, + refresh_token: result.refresh_token, + }; + expect(newToken).not.toBe(oldToken); + }); + + it("should not get accessToken", async () => { + let params = { + name: "Zoapp", + grant_type: "password", + redirect_uri: "localhost", + email: "toto@test.com", + }; + const authServer = zoauthServer(config); + await authServer.reset(); + await authServer.start(); + + let response = await authServer.registerApplication(params); + let { result } = response; + expect(Object.keys(result)).toEqual(["client_id", "client_secret"]); + expect(result.client_id).toHaveLength(64); + const clientId = result.client_id; + + params = { + client_id: clientId, + username: "toto", + password: "12345", + email: "toto@test.com", + }; + response = await authServer.registerUser(params); + ({ result } = response); + expect(Object.keys(result)).toEqual(["id", "username", "email"]); + expect(result.id).toHaveLength(32); + + params = { + client_id: clientId, + }; + response = await authServer.requestAccessToken(params); + ({ result } = response); + expect(result.error).toEqual("Unknown grant type: undefined"); + + params = { + client_id: clientId, + grant_type: "refresh_token", + }; + response = await authServer.requestAccessToken(params); + ({ result } = response); + expect(result.error).toEqual( + "Use GRANT_TYPE_REFRESH_TOKEN without refresh_token", + ); + + params = { + grant_type: "refresh_token", + refresh_token: "bypass", + client_id: clientId, + }; + response = await authServer.requestAccessToken(params); + ({ result } = response); + expect(result.error).toEqual("Wrong refresh_token"); + + params = { + grant_type: "refresh_token", + refresh_token: "hacked", + }; + response = await authServer.requestAccessToken(params); + ({ result } = response); + expect(result.error).toEqual("Require client_id"); }); }); },