diff --git a/test/compliance/client-authentication_test.js b/test/compliance/client-authentication_test.js new file mode 100644 index 0000000..72624ec --- /dev/null +++ b/test/compliance/client-authentication_test.js @@ -0,0 +1,128 @@ +/** + * @see https://datatracker.ietf.org/doc/html/rfc6749#section-2.3.1 + * + * For example (with extra line breaks for display purposes only): + * + * Authorization: Basic czZCaGRSa3F0Mzo3RmpmcDBaQnIxS3REUmJuZlZkbUl3 + * + * Alternatively, the authorization server MAY support including the + * client credentials in the request-body using the following + * parameters: + * + * client_id + * REQUIRED. The client identifier issued to the client during + * the registration process described by Section 2.2. + * + * client_secret + * REQUIRED. The client secret. The client MAY omit the + * parameter if the client secret is an empty string. + */ + +const OAuth2Server = require('../..'); +const DB = require('../helpers/db'); +const createModel = require('../helpers/model'); +const createRequest = require('../helpers/request'); +const Response = require('../../lib/response'); + +require('chai').should(); + +const db = new DB(); + +const auth = new OAuth2Server({ + model: createModel(db) +}); + +const user = db.saveUser({ id: 1, username: 'test', password: 'test'}); +const client = db.saveClient({ id: 'a', secret: 'b', grants: ['password'] }); +const scope = 'read write'; + +function createDefaultRequest () { + return createRequest({ + body: { + grant_type: 'password', + username: user.username, + password: user.password, + scope + }, + headers: { + 'authorization': 'Basic ' + Buffer.from(client.id + ':' + client.secret).toString('base64'), + 'content-type': 'application/x-www-form-urlencoded' + }, + method: 'POST', + }); +} + +describe('Client Authentication Compliance', function () { + describe('No authentication', function () { + it('should be an unsuccesfull authentication', async function () { + const request = createDefaultRequest(); + const response = new Response({}); + + delete request.headers.authorization; + + await auth.token(request, response, {}) + .then((token) => { + throw new Error('Should not be here'); + }). + catch(err => { + err.name.should.equal('invalid_client'); + }); + }); + }); + + describe('Basic Authentication', function () { + it('should be a succesfull authentication', async function () { + const request = createDefaultRequest(); + const response = new Response({}); + + await auth.token(request, response, {}); + }); + + it('should be an unsuccesfull authentication', async function () { + const request = createDefaultRequest(); + const response = new Response({}); + + request.headers.authorization = 'Basic ' + Buffer.from('a:c').toString('base64'); + + await auth.token(request, response, {}) + .then((token) => { + throw new Error('Should not be here'); + }). + catch(err => { + err.name.should.equal('invalid_client'); + }); + }); + }); + + describe('Request body authentication', function () { + it('should be a succesfull authentication', async function () { + const request = createDefaultRequest(); + const response = new Response({}); + + delete request.headers.authorization; + + request.body.client_id = client.id; + request.body.client_secret = client.secret; + + await auth.token(request, response, {}); + }); + + it('should be an unsuccesfull authentication', async function () { + const request = createDefaultRequest(); + const response = new Response({}); + + delete request.headers.authorization; + + request.body.client_id = 'a'; + request.body.client_secret = 'c'; + + await auth.token(request, response, {}) + .then((token) => { + throw new Error('Should not be here'); + }) + .catch(err => { + err.name.should.equal('invalid_client'); + }); + }); + }); +}); diff --git a/test/compliance/password-grant-type_test.js b/test/compliance/password-grant-type_test.js new file mode 100644 index 0000000..7941d54 --- /dev/null +++ b/test/compliance/password-grant-type_test.js @@ -0,0 +1,236 @@ +/** + * Request + * @see https://datatracker.ietf.org/doc/html/rfc6749#section-4.3.2 + * + * grant_type + * REQUIRED. Value MUST be set to "password". + * username + * REQUIRED. The resource owner username. + * password + * REQUIRED. The resource owner password. + * scope + * OPTIONAL. The scope of the access request as described by Section 3.3. + */ + +/** + * Response + * @see https://datatracker.ietf.org/doc/html/rfc6749#section-5.1 + * + * access_token + * REQUIRED. The access token issued by the authorization server. + * token_type + * REQUIRED. The type of the token issued as described in + * Section 7.1. Value is case insensitive. + * expires_in + * RECOMMENDED. The lifetime in seconds of the access token. For + * example, the value "3600" denotes that the access token will + * expire in one hour from the time the response was generated. + * If omitted, the authorization server SHOULD provide the + * expiration time via other means or document the default value. + * refresh_token + * OPTIONAL. The refresh token, which can be used to obtain new + * access tokens using the same authorization grant as described + * in Section 6. + * scope + * OPTIONAL, if identical to the scope requested by the client; + * otherwise, REQUIRED. The scope of the access token as + * described by Section 3.3. + */ + +/** + * Response (error) + * @see https://datatracker.ietf.org/doc/html/rfc6749#section-5.2 + * + * error + * REQUIRED. A single ASCII [USASCII] error code from the following: + * invalid_request, invalid_client, invalid_grant + * unauthorized_client, unsupported_grant_type, invalid_scope + * error_description + * OPTIONAL. Human-readable ASCII [USASCII] text providing + * additional information, used to assist the client developer in + * understanding the error that occurred. + * error_uri + * OPTIONAL. A URI identifying a human-readable web page with + * information about the error, used to provide the client + * developer with additional information about the error. + */ + +const OAuth2Server = require('../..'); +const DB = require('../helpers/db'); +const createModel = require('../helpers/model'); +const createRequest = require('../helpers/request'); +const Response = require('../../lib/response'); +const crypto = require('crypto'); + +require('chai').should(); + +const db = new DB(); + +const auth = new OAuth2Server({ + model: createModel(db) +}); + +const user = db.saveUser({ id: 1, username: 'test', password: 'test'}); +const client = db.saveClient({ id: 'a', secret: 'b', grants: ['password'] }); +const scope = 'read write'; + +function createDefaultRequest () { + return createRequest({ + body: { + grant_type: 'password', + username: user.username, + password: user.password, + scope + }, + headers: { + 'authorization': 'Basic ' + Buffer.from(client.id + ':' + client.secret).toString('base64'), + 'content-type': 'application/x-www-form-urlencoded' + }, + method: 'POST', + }); +} + +describe('PasswordGrantType Compliance', function () { + describe('Authenticate', function () { + it ('Succesfull authorization', async function () { + const request = createDefaultRequest(); + const response = new Response({}); + + const token = await auth.token(request, response, {}); + response.body.token_type.should.equal('Bearer'); + response.body.access_token.should.equal(token.accessToken); + response.body.refresh_token.should.equal(token.refreshToken); + response.body.expires_in.should.be.a('number'); + response.body.scope.should.equal(scope); + + token.accessToken.should.be.a('string'); + token.refreshToken.should.be.a('string'); + token.accessTokenExpiresAt.should.be.a('date'); + token.refreshTokenExpiresAt.should.be.a('date'); + token.scope.should.equal(scope); + + db.accessTokens.has(token.accessToken).should.equal(true); + db.refreshTokens.has(token.refreshToken).should.equal(true); + }); + + it ('Succesfull authorization and authentication', async function () { + const tokenRequest = createDefaultRequest(); + const tokenResponse = new Response({}); + + const token = await auth.token(tokenRequest, tokenResponse, {}); + + const authenticationRequest = createRequest({ + body: {}, + headers: { + 'Authorization': `Bearer ${token.accessToken}` + }, + method: 'GET', + query: {} + }); + const authenticationResponse = new Response({}); + + const authenticated = await auth.authenticate( + authenticationRequest, + authenticationResponse, + {}); + + authenticated.scope.should.equal(scope); + authenticated.user.should.be.an('object'); + authenticated.client.should.be.an('object'); + }); + + it ('Username missing', async function () { + const request = createDefaultRequest(); + const response = new Response({}); + + delete request.body.username; + + await auth.token(request, response, {}) + .catch(err => { + err.name.should.equal('invalid_request'); + }); + }); + + it ('Password missing', async function () { + const request = createDefaultRequest(); + const response = new Response({}); + + delete request.body.password; + + await auth.token(request, response, {}) + .catch(err => { + err.name.should.equal('invalid_request'); + }); + }); + + it ('Wrong username', async function () { + const request = createDefaultRequest(); + const response = new Response({}); + + request.body.username = 'wrong'; + + await auth.token(request, response, {}) + .catch(err => { + err.name.should.equal('invalid_grant'); + }); + }); + + it ('Wrong password', async function () { + const request = createDefaultRequest(); + const response = new Response({}); + + request.body.password = 'wrong'; + + await auth.token(request, response, {}) + .catch(err => { + err.name.should.equal('invalid_grant'); + }); + }); + + it ('Client not found', async function () { + const request = createDefaultRequest(); + const response = new Response({}); + + const clientId = crypto.randomBytes(4).toString('hex'); + const clientSecret = crypto.randomBytes(4).toString('hex'); + + request.headers.authorization = 'Basic ' + Buffer.from(`${clientId}:${clientSecret}`).toString('base64'); + + await auth.token(request, response, {}) + .catch(err => { + err.name.should.equal('invalid_client'); + }); + }); + + it ('Client secret not required', async function () { + const request = createDefaultRequest(); + const response = new Response({}); + + delete request.body.client_secret; + + const token = await auth.token(request, response, { + requireClientAuthentication: { + password: false + } + }); + + token.accessToken.should.be.a('string'); + }); + + it ('Client secret required', async function () { + const request = createDefaultRequest(); + const response = new Response({}); + + delete request.body.client_secret; + + await auth.token(request, response, { + requireClientAuthentication: { + password: false + } + }) + .catch(err => { + err.name.should.equal('invalid_client'); + }); + }); + }); +}); diff --git a/test/compliance/refresh-token-grant-type_test.js b/test/compliance/refresh-token-grant-type_test.js new file mode 100644 index 0000000..b01fef3 --- /dev/null +++ b/test/compliance/refresh-token-grant-type_test.js @@ -0,0 +1,173 @@ +/** + * Request an access token using the refresh token grant type. + * @see https://datatracker.ietf.org/doc/html/rfc6749#section-6 + * + * grant_type + * REQUIRED. Value MUST be set to "refresh_token". + * refresh_token + * REQUIRED. The refresh token issued to the client. + * scope + * OPTIONAL. The scope of the access request as described by + * Section 3.3. The requested scope MUST NOT include any scope + * not originally granted by the resource owner, and if omitted is + * treated as equal to the scope originally granted by the + * resource owner. + */ + + +/** + * Response + * @see https://datatracker.ietf.org/doc/html/rfc6749#section-5.1 + * + * access_token + * REQUIRED. The access token issued by the authorization server. + * token_type + * REQUIRED. The type of the token issued as described in + * Section 7.1. Value is case insensitive. + * expires_in + * RECOMMENDED. The lifetime in seconds of the access token. For + * example, the value "3600" denotes that the access token will + * expire in one hour from the time the response was generated. + * If omitted, the authorization server SHOULD provide the + * expiration time via other means or document the default value. + * refresh_token + * OPTIONAL. The refresh token, which can be used to obtain new + * access tokens using the same authorization grant as described + * in Section 6. + * scope + * OPTIONAL, if identical to the scope requested by the client; + * otherwise, REQUIRED. The scope of the access token as + * described by Section 3.3. + */ + +/** + * Response (error) + * @see https://datatracker.ietf.org/doc/html/rfc6749#section-5.2 + * + * error + * REQUIRED. A single ASCII [USASCII] error code from the following: + * invalid_request, invalid_client, invalid_grant + * unauthorized_client, unsupported_grant_type, invalid_scope + * error_description + * OPTIONAL. Human-readable ASCII [USASCII] text providing + * additional information, used to assist the client developer in + * understanding the error that occurred. + * error_uri + * OPTIONAL. A URI identifying a human-readable web page with + * information about the error, used to provide the client + * developer with additional information about the error. + */ +const OAuth2Server = require('../..'); +const DB = require('../helpers/db'); +const createModel = require('../helpers/model'); +const createRequest = require('../helpers/request'); +const Response = require('../../lib/response'); + +require('chai').should(); + +const db = new DB(); + +const auth = new OAuth2Server({ + model: createModel(db) +}); + +const user = db.saveUser({ id: 1, username: 'test', password: 'test'}); +const client = db.saveClient({ id: 'a', secret: 'b', grants: ['password', 'refresh_token'] }); +const scope = 'read write'; + +function createLoginRequest () { + return createRequest({ + body: { + grant_type: 'password', + username: user.username, + password: user.password, + scope + }, + headers: { + 'authorization': 'Basic ' + Buffer.from(client.id + ':' + client.secret).toString('base64'), + 'content-type': 'application/x-www-form-urlencoded' + }, + method: 'POST', + }); +} + +function createRefreshRequest (refresh_token) { + return createRequest({ + method: 'POST', + body: { + grant_type: 'refresh_token', + refresh_token, + scope + }, + headers: { + 'authorization': 'Basic ' + Buffer.from(client.id + ':' + client.secret).toString('base64'), + 'content-type': 'application/x-www-form-urlencoded' + } + }); +} + +describe('RefreshTokenGrantType Compliance', function () { + describe('With scope', function () { + it('Should generate token response', async function () { + const request = createLoginRequest(); + const response = new Response({}); + + const credentials = await auth.token(request, response, {}); + + const refreshRequest = createRefreshRequest(credentials.refreshToken); + const refreshResponse = new Response({}); + + const token = await auth.token(refreshRequest, refreshResponse, {}); + + refreshResponse.body.token_type.should.equal('Bearer'); + refreshResponse.body.access_token.should.equal(token.accessToken); + refreshResponse.body.refresh_token.should.equal(token.refreshToken); + refreshResponse.body.expires_in.should.be.a('number'); + refreshResponse.body.scope.should.equal(scope); + + token.accessToken.should.be.a('string'); + token.refreshToken.should.be.a('string'); + token.accessTokenExpiresAt.should.be.a('date'); + token.refreshTokenExpiresAt.should.be.a('date'); + token.scope.should.equal(scope); + + db.accessTokens.has(token.accessToken).should.equal(true); + db.refreshTokens.has(token.refreshToken).should.equal(true); + }); + + it('Should throw invalid_grant error', async function () { + const request = createRefreshRequest('invalid'); + const response = new Response({}); + + await auth.token(request, response, {}) + .then(() => { + throw Error('Should not reach this'); + }).catch(err => { + err.name.should.equal('invalid_grant'); + }); + }); + + // TODO: test refresh token with different scopes + // https://github.com/node-oauth/node-oauth2-server/issues/104 + + // it('Should throw invalid_scope error', async function () { + // const request = createLoginRequest(); + // const response = new Response({}); + + // const credentials = await auth.token(request, response, {}); + + // const refreshRequest = createRefreshRequest(credentials.refreshToken); + // const refreshResponse = new Response({}); + + // refreshRequest.scope = 'invalid'; + + // await auth.token(refreshRequest, refreshResponse, {}) + // .then(() => { + // throw Error('Should not reach this'); + // }) + // .catch(err => { + // err.name.should.equal('invalid_scope'); + // }); + // }); + }); +}); diff --git a/test/helpers/db.js b/test/helpers/db.js new file mode 100644 index 0000000..147d174 --- /dev/null +++ b/test/helpers/db.js @@ -0,0 +1,70 @@ +class DB { + constructor () { + this.users = new Map(); + this.clients = []; + this.accessTokens = new Map(); + this.refreshTokens= new Map(); + } + + saveUser (user) { + this.users.set(user.id, user); + + return user; + } + + findUser (username, password) { + return Array.from(this.users.values()).find(user => { + return user.username === username && user.password === password; + }); + } + + findUserById (id) { + return this.users.get(id); + } + + saveClient (client) { + this.clients.push(client); + + return client; + } + + findClient (clientId, clientSecret) { + return this.clients.find(client => { + if (clientSecret) { + return client.id === clientId && client.secret === clientSecret; + } else { + return client.id === clientId; + } + }); + } + + findClientById (id) { + return this.clients.find(client => client.id === id); + } + + saveAccessToken (accessToken, meta) { + this.accessTokens.set(accessToken, meta); + } + + findAccessToken (accessToken) { + return this.accessTokens.get(accessToken); + } + + deleteAccessToken (accessToken) { + this.accessTokens.delete(accessToken); + } + + saveRefreshToken (refreshToken, meta) { + this.refreshTokens.set(refreshToken, meta); + } + + findRefreshToken (refreshToken) { + return this.refreshTokens.get(refreshToken); + } + + deleteRefreshToken (refreshToken) { + this.refreshTokens.delete(refreshToken); + } +} + +module.exports = DB; diff --git a/test/helpers/model.js b/test/helpers/model.js new file mode 100644 index 0000000..7a1893b --- /dev/null +++ b/test/helpers/model.js @@ -0,0 +1,92 @@ +const scopes = ['read', 'write']; + +function createModel (db) { + async function getUser (username, password) { + return db.findUser(username, password); + } + + async function getClient (clientId, clientSecret) { + return db.findClient(clientId, clientSecret); + } + + async function saveToken (token, client, user) { + const meta = { + clientId: client.id, + userId: user.id, + scope: token.scope, + accessTokenExpiresAt: token.accessTokenExpiresAt, + refreshTokenExpiresAt: token.refreshTokenExpiresAt + }; + + token.client = client; + token.user = user; + + if (token.accessToken) { + db.saveAccessToken(token.accessToken, meta); + } + + if (token.refreshToken) { + db.saveRefreshToken(token.refreshToken, meta); + } + + return token; + } + + async function getAccessToken (accessToken) { + const meta = db.findAccessToken(accessToken); + + if (!meta) { + return false; + } + + return { + accessToken, + accessTokenExpiresAt: meta.accessTokenExpiresAt, + user: db.findUserById(meta.userId), + client: db.findClientById(meta.clientId), + scope: meta.scope + }; + } + + async function getRefreshToken (refreshToken) { + const meta = db.findRefreshToken(refreshToken); + + if (!meta) { + return false; + } + + return { + refreshToken, + refreshTokenExpiresAt: meta.refreshTokenExpiresAt, + user: db.findUserById(meta.userId), + client: db.findClientById(meta.clientId), + scope: meta.scope + }; + } + + async function revokeToken (token) { + db.deleteRefreshToken(token.refreshToken); + + return true; + } + + async function verifyScope (token, scope) { + if (typeof scope === 'string') { + return scopes.includes(scope); + } else { + return scope.every(s => scopes.includes(s)); + } + } + + return { + getUser, + getClient, + saveToken, + getAccessToken, + getRefreshToken, + revokeToken, + verifyScope + }; +} + +module.exports = createModel; diff --git a/test/helpers/request.js b/test/helpers/request.js new file mode 100644 index 0000000..be556a8 --- /dev/null +++ b/test/helpers/request.js @@ -0,0 +1,17 @@ +const Request = require('../../lib/request'); + +module.exports = (request) => { + const req = new Request({ + query: {}, + body: {}, + headers: {}, + method: 'GET', + ...request + }); + + req.is = function (header) { + return this.headers['content-type'] === header; + }; + + return req; +};