From 3ced2bc61e463b3488b19aa85e17bbb4d4b971f9 Mon Sep 17 00:00:00 2001 From: Ryo Hasegawa <49798519+ryo8000@users.noreply.github.com> Date: Wed, 2 Jul 2025 14:04:32 +0000 Subject: [PATCH] Add basic authentication endpoint --- README.md | 41 ++-- src/app.ts | 2 + src/routes/basic-auth.ts | 58 +++++ tests/api/api-test-collection.json | 134 +++++++++++ tests/ut/routes/basic-auth.test.ts | 352 +++++++++++++++++++++++++++++ 5 files changed, 568 insertions(+), 19 deletions(-) create mode 100644 src/routes/basic-auth.ts create mode 100644 tests/ut/routes/basic-auth.test.ts diff --git a/README.md b/README.md index 5d8b0d3..55eb609 100644 --- a/README.md +++ b/README.md @@ -11,28 +11,31 @@ Built with **Node.js** and **Express**. ## 📚 API Reference -| Method | Path | Description | -| ------ | ------------------------ | ------------------------------------------------------------------------------------ | -| `ALL` | `/base64/encode` | Encodes a string value to Base64 format. | -| `ALL` | `/base64/decode` | Decodes a Base64 string to its original format. | -| `ALL` | `/error/timeout/` | Simulates a timeout by never sending a response. | -| `ALL` | `/error/network/` | Simulates a network error by closing the connection. | -| `ALL` | `/error/malformed-json/` | Returns malformed JSON response. | -| `ALL` | `/error/error/` | Throws an unhandled exception to trigger Express error handler. | -| `ALL` | `/mirror/` | Returns the request body as a response. | -| `ALL` | `/redirect/` | Returns a redirect response based on the `status` and `url` of the query parameters. | -| `ALL` | `/request/` | Return a structured JSON dump of the incoming request. | -| `ALL` | `/shutdown/` | GTriggers a shutdown of the server. Requires `ENABLE_SHUTDOWN=true`. | -| `ALL` | `/status/{status}` | Respond with arbitrary HTTP status. | -| `ALL` | `/uuid/` | Generate and return a random UUID (version 4). | +| Method | Path | Description | +| ------ | ------------------------ | ------------------------------------------------------------------------------------------------------------ | +| `ALL` | `/base64/encode` | Encodes a string value to Base64 format. | +| `ALL` | `/base64/decode` | Decodes a Base64 string to its original format. | +| `ALL` | `/basic-auth/` | Tests HTTP Basic Authentication by comparing credentials from Authorization header against query parameters. | +| `ALL` | `/error/timeout/` | Simulates a timeout by never sending a response. | +| `ALL` | `/error/network/` | Simulates a network error by closing the connection. | +| `ALL` | `/error/malformed-json/` | Returns malformed JSON response. | +| `ALL` | `/error/error/` | Throws an unhandled exception to trigger Express error handler. | +| `ALL` | `/mirror/` | Returns the request body as a response. | +| `ALL` | `/redirect/` | Returns a redirect response based on the `status` and `url` of the query parameters. | +| `ALL` | `/request/` | Return a structured JSON dump of the incoming request. | +| `ALL` | `/shutdown/` | GTriggers a shutdown of the server. Requires `ENABLE_SHUTDOWN=true`. | +| `ALL` | `/status/{status}` | Respond with arbitrary HTTP status. | +| `ALL` | `/uuid/` | Generate and return a random UUID (version 4). | ### Query Parameters -| Name | Type | Default | Description | -| -------- | ------ | ------- | ----------------------------------------------------------- | -| `delay` | Number | `0` | Delays the response by the specified value in milliseconds. | -| `status` | Number | — | HTTP status code for `/redirect/` or `/status/{status}`. | -| `url` | String | — | Target URL for `/redirect/`. | +| Name | Type | Default | Description | +| ---------- | ------ | ------- | ----------------------------------------------------------- | +| `delay` | Number | `0` | Delays the response by the specified value in milliseconds. | +| `status` | Number | — | HTTP status code for `/redirect/` or `/status/{status}`. | +| `url` | String | — | Target URL for `/redirect/`. | +| `user` | String | — | Expected username for `/basic-auth/` (required). | +| `password` | String | — | Expected password for `/basic-auth/` (required). | --- diff --git a/src/app.ts b/src/app.ts index 355a26e..33b39e2 100644 --- a/src/app.ts +++ b/src/app.ts @@ -4,6 +4,7 @@ import cors from 'cors'; import { delayMiddleware } from './middlewares/delay.js'; import { loggerMiddleware } from './middlewares/logger.js'; import { base64Router } from './routes/base64.js'; +import { basicAuthRouter } from './routes/basic-auth.js'; import { errorRouter } from './routes/error.js'; import { indexRouter } from './routes/index.js'; import { mirrorRouter } from './routes/mirror.js'; @@ -35,6 +36,7 @@ app.use(delayMiddleware); app.use('/', indexRouter); app.use('/base64', base64Router); +app.use('/basic-auth', basicAuthRouter); app.use('/error', errorRouter); app.use('/mirror', mirrorRouter); app.use('/redirect', redirectRouter); diff --git a/src/routes/basic-auth.ts b/src/routes/basic-auth.ts new file mode 100644 index 0000000..e3132d9 --- /dev/null +++ b/src/routes/basic-auth.ts @@ -0,0 +1,58 @@ +import { Router } from 'express'; +import { HttpStatusCodes } from '../utils/http.js'; + +const basicAuthRouter = Router(); + +basicAuthRouter.all('/', (req, res) => { + const { user, password } = req.query; + + // Validate that both user and password are provided and non-empty + if ( + !user || + !password || + typeof user !== 'string' || + typeof password !== 'string' || + user.trim() === '' || + password.trim() === '' + ) { + res.status(HttpStatusCodes.BAD_REQUEST).json({ + error: { + message: 'Missing user or password query parameter', + }, + }); + return; + } + + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Basic ')) { + res.setHeader('WWW-Authenticate', 'Basic realm="Access to /basic-auth"'); + res.status(HttpStatusCodes.UNAUTHORIZED).json({ + authenticated: false, + message: 'Authentication required', + }); + return; + } + + const base64Credentials = authHeader.split(' ')[1]; + const credentials = Buffer.from(base64Credentials as string, 'base64').toString('utf-8'); + const [providedUser, providedPassword] = credentials.split(':'); + + if (providedUser === user && providedPassword === password) { + res.status(HttpStatusCodes.OK).json({ + authenticated: true, + message: 'Authentication successful', + user, + password, + }); + } else { + res.setHeader('WWW-Authenticate', 'Basic realm="Access to /basic-auth"'); + res.status(HttpStatusCodes.UNAUTHORIZED).json({ + authenticated: false, + message: 'Authentication failed', + user: providedUser, + password: providedPassword, + }); + } +}); + +export { basicAuthRouter }; diff --git a/tests/api/api-test-collection.json b/tests/api/api-test-collection.json index 220fb3f..7cd7d42 100644 --- a/tests/api/api-test-collection.json +++ b/tests/api/api-test-collection.json @@ -210,6 +210,140 @@ } ] }, + { + "name": "Basic Auth - Successful Authentication", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Basic dGVzdHVzZXI6dGVzdHBhc3M=" + } + ], + "url": { + "raw": "{{baseUrl}}/basic-auth?user=testuser&password=testpass", + "host": ["{{baseUrl}}"], + "path": ["basic-auth"], + "query": [ + { + "key": "user", + "value": "testuser" + }, + { + "key": "password", + "value": "testpass" + } + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Status code is 200', function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test('Authentication is successful', function () {", + " var jsonData = pm.response.json();", + " pm.expect(jsonData).to.have.property('authenticated', true);", + " pm.expect(jsonData).to.have.property('user', 'testuser');", + " pm.expect(jsonData).to.have.property('password', 'testpass');", + " pm.expect(jsonData).to.have.property('message', 'Authentication successful');", + "});" + ] + } + } + ] + }, + { + "name": "Basic Auth - Failed Authentication", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Basic d3JvbmdUZXN0Ondyb25nUGFzcw==" + } + ], + "url": { + "raw": "{{baseUrl}}/basic-auth?user=expected&password=expected", + "host": ["{{baseUrl}}"], + "path": ["basic-auth"], + "query": [ + { + "key": "user", + "value": "expected" + }, + { + "key": "password", + "value": "expected" + } + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Status code is 401', function () {", + " pm.response.to.have.status(401);", + "});", + "", + "pm.test('Authentication failed response', function () {", + " var jsonData = pm.response.json();", + " pm.expect(jsonData).to.have.property('authenticated', false);", + " pm.expect(jsonData).to.have.property('message', 'Authentication failed');", + " pm.expect(jsonData).to.have.property('user', 'wrongTest');", + " pm.expect(jsonData).to.have.property('password', 'wrongPass');", + "});" + ] + } + } + ] + }, + { + "name": "Basic Auth - Empty Parameters", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/basic-auth?user=&password=", + "host": ["{{baseUrl}}"], + "path": ["basic-auth"], + "query": [ + { + "key": "user", + "value": "" + }, + { + "key": "password", + "value": "" + } + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Status code is 400', function () {", + " pm.response.to.have.status(400);", + "});", + "", + "pm.test('Empty parameter error', function () {", + " var jsonData = pm.response.json();", + " pm.expect(jsonData).to.have.property('error');", + " pm.expect(jsonData.error).to.have.property('message', 'Missing user or password query parameter');", + "});" + ] + } + } + ] + }, { "name": "Mirror Route", "request": { diff --git a/tests/ut/routes/basic-auth.test.ts b/tests/ut/routes/basic-auth.test.ts new file mode 100644 index 0000000..8dcce9b --- /dev/null +++ b/tests/ut/routes/basic-auth.test.ts @@ -0,0 +1,352 @@ +import express from 'express'; +import request from 'supertest'; + +describe('basicAuthRouter', () => { + let app: express.Express; + + const createApp = async (): Promise => { + const app = express(); + app.use(express.json()); + + const { basicAuthRouter } = await import('../../../src/routes/basic-auth.js'); + app.use('/basic-auth', basicAuthRouter); + + return app; + }; + + beforeEach(async () => { + app = await createApp(); + }); + + const httpMethods = ['get', 'post', 'put', 'delete', 'patch', 'head'] as const; + + describe.each(httpMethods)('%s method', (method) => { + describe('when credentials match', () => { + it('should return 200 with authentication success for matching user/password', async () => { + const response = await request(app) + [method]('/basic-auth?user=testuser&password=testpass') + .auth('testuser', 'testpass'); + + expect(response.status).toBe(200); + if (method !== 'head') { + expect(response.body).toEqual({ + authenticated: true, + user: 'testuser', + password: 'testpass', + message: 'Authentication successful', + }); + } + }); + + it('should authenticate when both parameters are the same complex string', async () => { + const credentials = 'complex-P@ssw0rd!123'; + const response = await request(app) + [method]( + `/basic-auth?user=${encodeURIComponent(credentials)}&password=${encodeURIComponent(credentials)}` + ) + .auth(credentials, credentials); + + expect(response.status).toBe(200); + if (method !== 'head') { + expect(response.body).toEqual({ + authenticated: true, + user: credentials, + password: credentials, + message: 'Authentication successful', + }); + } + }); + + it('should authenticate with special characters', async () => { + const credentials = 'test@example.com'; + const response = await request(app) + [method]( + `/basic-auth?user=${encodeURIComponent(credentials)}&password=${encodeURIComponent(credentials)}` + ) + .auth(credentials, credentials); + + expect(response.status).toBe(200); + if (method !== 'head') { + expect(response.body).toEqual({ + authenticated: true, + user: credentials, + password: credentials, + message: 'Authentication successful', + }); + } + }); + }); + + describe('when credentials do not match', () => { + it('should return 401 when user and password are different', async () => { + const response = await request(app) + [method]('/basic-auth?user=expecteduser&password=expectedpass') + .auth('provideduser', 'providedpass'); + + expect(response.status).toBe(401); + if (method !== 'head') { + expect(response.body).toEqual({ + authenticated: false, + message: 'Authentication failed', + user: 'provideduser', + password: 'providedpass', + }); + } + }); + + it('should return 401 for case-sensitive mismatch', async () => { + const response = await request(app) + [method]('/basic-auth?user=TestUser&password=TestPass') + .auth('testuser', 'testpass'); + + expect(response.status).toBe(401); + if (method !== 'head') { + expect(response.body).toEqual({ + authenticated: false, + message: 'Authentication failed', + user: 'testuser', + password: 'testpass', + }); + } + }); + }); + + describe('when credentials are missing or invalid', () => { + it('should return 400 when user parameter is missing', async () => { + const response = await request(app)[method]('/basic-auth?password=testpass'); + + expect(response.status).toBe(400); + if (method !== 'head') { + expect(response.body).toEqual({ + error: { + message: 'Missing user or password query parameter', + }, + }); + } + }); + + it('should return 400 when password parameter is missing', async () => { + const response = await request(app)[method]('/basic-auth?user=testuser'); + + expect(response.status).toBe(400); + if (method !== 'head') { + expect(response.body).toEqual({ + error: { + message: 'Missing user or password query parameter', + }, + }); + } + }); + + it('should return 400 when both parameters are missing', async () => { + const response = await request(app)[method]('/basic-auth'); + + expect(response.status).toBe(400); + if (method !== 'head') { + expect(response.body).toEqual({ + error: { + message: 'Missing user or password query parameter', + }, + }); + } + }); + + it('should return 400 when user parameter is empty', async () => { + const response = await request(app)[method]('/basic-auth?user=&password=testpass'); + + expect(response.status).toBe(400); + if (method !== 'head') { + expect(response.body).toEqual({ + error: { + message: 'Missing user or password query parameter', + }, + }); + } + }); + + it('should return 400 when password parameter is empty', async () => { + const response = await request(app)[method]('/basic-auth?user=testuser&password='); + + expect(response.status).toBe(400); + if (method !== 'head') { + expect(response.body).toEqual({ + error: { + message: 'Missing user or password query parameter', + }, + }); + } + }); + + it('should return 400 when user parameter is only whitespace', async () => { + const response = await request(app)[method]('/basic-auth?user=%20%20&password=testpass'); + + expect(response.status).toBe(400); + if (method !== 'head') { + expect(response.body).toEqual({ + error: { + message: 'Missing user or password query parameter', + }, + }); + } + }); + + it('should return 400 when password parameter is only whitespace', async () => { + const response = await request(app)[method]('/basic-auth?user=testuser&password=%20%20'); + + expect(response.status).toBe(400); + if (method !== 'head') { + expect(response.body).toEqual({ + error: { + message: 'Missing user or password query parameter', + }, + }); + } + }); + + it('should return 401 when Authorization header is missing', async () => { + const response = await request(app)[method]('/basic-auth?user=testuser&password=testpass'); + + expect(response.status).toBe(401); + expect(response.headers['www-authenticate']).toBe('Basic realm="Access to /basic-auth"'); + if (method !== 'head') { + expect(response.body).toEqual({ + authenticated: false, + message: 'Authentication required', + }); + } + }); + + it('should return 401 when Authorization header is malformed', async () => { + const response = await request(app) + [method]('/basic-auth?user=testuser&password=testpass') + .set('Authorization', 'Bearer token123'); + + expect(response.status).toBe(401); + expect(response.headers['www-authenticate']).toBe('Basic realm="Access to /basic-auth"'); + if (method !== 'head') { + expect(response.body).toEqual({ + authenticated: false, + message: 'Authentication required', + }); + } + }); + }); + + describe('edge cases', () => { + it('should handle numeric credentials when they match', async () => { + const response = await request(app) + [method]('/basic-auth?user=12345&password=67890') + .auth('12345', '67890'); + + expect(response.status).toBe(200); + if (method !== 'head') { + expect(response.body).toEqual({ + authenticated: true, + user: '12345', + password: '67890', + message: 'Authentication successful', + }); + } + }); + + it('should handle single character credentials', async () => { + const response = await request(app)[method]('/basic-auth?user=a&password=a').auth('a', 'a'); + + expect(response.status).toBe(200); + if (method !== 'head') { + expect(response.body).toEqual({ + authenticated: true, + user: 'a', + password: 'a', + message: 'Authentication successful', + }); + } + }); + + it('should handle unicode characters', async () => { + const credentials = '测试用户'; + const encoded = encodeURIComponent(credentials); + const response = await request(app) + [method](`/basic-auth?user=${encoded}&password=${encoded}`) + .auth(credentials, credentials); + + expect(response.status).toBe(200); + if (method !== 'head') { + expect(response.body).toEqual({ + authenticated: true, + user: credentials, + password: credentials, + message: 'Authentication successful', + }); + } + }); + }); + }); + + describe('URL encoding', () => { + it('should handle URL encoded special characters in credentials', async () => { + const user = 'user+with spaces@domain.com'; + const password = 'user+with spaces@domain.com'; + const response = await request(app) + .get( + `/basic-auth?user=${encodeURIComponent(user)}&password=${encodeURIComponent(password)}` + ) + .auth(user, password); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + authenticated: true, + user: user, + password: password, + message: 'Authentication successful', + }); + }); + + it('should fail authentication when encoding differs', async () => { + const response = await request(app) + .get('/basic-auth?user=test%2Buser&password=test+pass') + .auth('test%2Buser', 'test+pass'); + + expect(response.status).toBe(401); + expect(response.body).toEqual({ + authenticated: false, + message: 'Authentication failed', + user: 'test%2Buser', + password: 'test+pass', + }); + }); + }); + + describe('with request body (POST method)', () => { + it('should authenticate successfully with POST and body data', async () => { + const response = await request(app) + .post('/basic-auth?user=testuser&password=testpass') + .auth('testuser', 'testpass') + .send({ someData: 'ignored' }); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + authenticated: true, + user: 'testuser', + password: 'testpass', + message: 'Authentication successful', + }); + }); + }); + + describe('query parameter handling', () => { + it('should properly handle additional parameters without affecting authentication', async () => { + const response = await request(app) + .get('/basic-auth?user=test&password=test&additionalParam=value&anotherParam=123') + .auth('test', 'test'); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + authenticated: true, + user: 'test', + password: 'test', + message: 'Authentication successful', + }); + }); + }); +});