From 4c27d0eace9ff5764d332246a1c731bc85b5b759 Mon Sep 17 00:00:00 2001 From: Timo Hocker Date: Fri, 1 Jan 2021 14:14:19 +0100 Subject: [PATCH] auth handler tests --- lib/AuthHandler.ts | 121 ++++++++++++++++---- package.json | 1 + test/Helper.ts | 11 +- test/spec/AuthHandler.ts | 231 ++++++++++++++++++++++++++++++++------- test/spec/Authority.ts | 7 +- 5 files changed, 299 insertions(+), 72 deletions(-) diff --git a/lib/AuthHandler.ts b/lib/AuthHandler.ts index e22e9bf..3c779cc 100644 --- a/lib/AuthHandler.ts +++ b/lib/AuthHandler.ts @@ -1,19 +1,48 @@ import { IncomingMessage, ServerResponse } from 'http'; +import { to_utf8 } from '@sapphirecode/encoding-helper'; import auth from './Authority'; interface AccessSettings { - access_token_expires_in?: number + access_token_expires_in: number include_refresh_token?: boolean refresh_token_expires_in?: number } +interface AccessResult { + access_token_id: string; + refresh_token_id?: string; +} + + +interface AccessResponse { + token_type: string; + access_token: string; + expires_in: number; + refresh_token?: string; + refresh_expires_in?: number; +} + class AuthRequest { public request: IncomingMessage; public response: ServerResponse; - public constructor (req: IncomingMessage, res: ServerResponse) { + public is_basic: boolean; + public user: string; + public password: string; + + private _cookie_name?: string; + + public constructor ( + req: IncomingMessage, + res: ServerResponse, + cookie?: string + ) { this.request = req; this.response = res; + this.is_basic = false; + this.user = ''; + this.password = ''; + this._cookie_name = cookie; } private default_header () { @@ -26,25 +55,40 @@ class AuthRequest { access_token_expires_in, include_refresh_token, refresh_token_expires_in - }: AccessSettings): void { + }: AccessSettings): AccessResult { this.default_header (); - this.response.writeHead (200); - const res = { + const at = auth.sign ('access_token', access_token_expires_in); + const result: AccessResult = { access_token_id: at.id }; + + const res: AccessResponse = { token_type: 'bearer', - access_token: auth.sign ('access_token', access_token_expires_in), - expires_in: access_token_expires_in, - scope + access_token: at.signature, + expires_in: access_token_expires_in }; - if (include_refresh_token) { - res.refresh_token = auth.sign ('refresh_token', refresh_token_expires_in); - res.refresh_token_expires_in = refresh_token_expires_in; + if (typeof this._cookie_name === 'string') { + this.response.setHeader ( + 'Set-Cookie', + `${this._cookie_name}=${at.signature}` + ); } + + if (include_refresh_token) { + if (typeof refresh_token_expires_in !== 'number') + throw new Error ('no expiry time defined for refresh tokens'); + const rt = auth.sign ('refresh_token', refresh_token_expires_in); + res.refresh_token = rt.signature; + res.refresh_expires_in = refresh_token_expires_in; + result.refresh_token_id = rt.id; + } + this.response.writeHead (200); this.response.end (JSON.stringify (res)); + + return result; } - public invalid (error_description) { + public invalid (error_description?: string) { this.default_header (); this.response.writeHead (400); this.response.end (JSON.stringify ({ @@ -65,38 +109,67 @@ type AuthRequestHandler = (req: AuthRequest) => void|Promise; interface CreateHandlerOptions { refresh?: AccessSettings; modules?: Record; + cookie_name?: string; } +// eslint-disable-next-line max-lines-per-function export default function create_auth_handler ( default_handler: AuthRequestHandler, - { refresh, modules }: CreateHandlerOptions + options?: CreateHandlerOptions ) { return function process_request ( req: IncomingMessage, res: ServerResponse ): Promise|void { - const request = new AuthRequest (req, res); - const token = (/Bearer (?.+)/ui).exec (req.headers.authorization); - if (token === null) - return default_handler (request); + const request = new AuthRequest (req, res, options?.cookie_name); + const token = (/(?\S+) (?.+)/ui) + .exec (req.headers.authorization as string); - const token_data = auth.verify (token.groups.token); + if (token === null) { + request.deny (); + return Promise.resolve (); + } + + if ((/Basic/ui).test (token?.groups?.type as string)) { + request.is_basic = true; + + let login = token?.groups?.token as string; + if (!login.includes (':')) + login = to_utf8 (login, 'base64'); + const login_data = login.split (':'); + request.user = login_data[0]; + request.password = login_data[1]; + + return default_handler (request); + } + + const token_data = auth.verify (token?.groups?.token as string); if (!token_data.valid) { request.deny (); return Promise.resolve (); } - if (token_data.type === 'refresh_token') { - request.allow_access (refresh); + if ( + typeof options !== 'undefined' + && typeof options.refresh !== 'undefined' + && token_data.type === 'refresh_token' + ) { + request.allow_access (options.refresh); return Promise.resolve (); } - if (token_data.type === 'part_token' && Object.keys (modules) - .includes (token_data.next_module)) - return modules[token_data.next_module] (request); + if ( + typeof options !== 'undefined' + && typeof options.modules !== 'undefined' + && token_data.type === 'part_token' + && typeof token_data.next_module !== 'undefined' + && Object.keys (options.modules) + .includes (token_data.next_module) + ) + return options.modules[token_data.next_module] (request); - request.invalid ('invalid bearer token'); + request.invalid ('invalid bearer type'); return Promise.resolve (); }; } diff --git a/package.json b/package.json index baa052c..e156c35 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ ], "dependencies": { "@sapphirecode/crypto-helper": "^1.2.2", + "@sapphirecode/encoding-helper": "^1.1.0", "@sapphirecode/utilities": "^1.8.8" }, "engines": { diff --git a/test/Helper.ts b/test/Helper.ts index ab630ca..b382e78 100644 --- a/test/Helper.ts +++ b/test/Helper.ts @@ -1,11 +1,10 @@ import http from 'http'; -class Response extends http.IncomingMessage { +export class Response extends http.IncomingMessage { body?: string; } -export -function get ( +export function get ( headers: http.OutgoingHttpHeaders = {} ): Promise { return new Promise ((resolve) => { @@ -21,3 +20,9 @@ function get ( }); }); } + +export function modify_signature (signature: string): string { + const dec = signature.split ('.'); + dec[1] = ''; + return dec.join ('.'); +} diff --git a/test/spec/AuthHandler.ts b/test/spec/AuthHandler.ts index 61dcdab..80d1099 100644 --- a/test/spec/AuthHandler.ts +++ b/test/spec/AuthHandler.ts @@ -1,17 +1,72 @@ -import http from 'http'; +import http, { IncomingMessage, ServerResponse } from 'http'; +import { to_b64 } from '@sapphirecode/encoding-helper'; import auth from '../../lib/Authority'; -import { get } from '../Helper'; +import { get, modify_signature, Response } from '../Helper'; +import create_auth_handler from '../../lib/AuthHandler'; + const expires_seconds = 600; const refresh_expires_seconds = 3600; +const part_expires_seconds = 60; + +interface CheckHeaderResult { + at: string; + rt?: string; + data: Record; +} + +function check_headers (resp: Response): CheckHeaderResult { + const data = JSON.parse (resp.body as string); + const at = data.access_token; + const rt = data.refresh_token; + expect (resp.headers['cache-control']) + .toEqual ('no-store'); + expect (resp.headers.pragma) + .toEqual ('no-cache'); + return { data, at, rt }; +} + +function check_token (token: string, type: string):void { + const v = auth.verify (token); + expect (v.valid) + .toEqual (true); + expect (v.authorized) + .toEqual (type === 'access_token'); + expect (v.type) + .toEqual (type); + expect (token) + .toMatch (/^[0-9a-z-._~+/]+$/ui); +} // eslint-disable-next-line max-lines-per-function -xdescribe ('auth handler', () => { +describe ('auth handler', () => { let server: http.Server|null = null; beforeAll (() => { - server = http.createServer ((req, res) => { - res.writeHead (404); - res.end (); + const ah = create_auth_handler ((req) => { + if (!req.is_basic) { + req.invalid ('unknown autorization type'); + } + else if (req.user !== 'foo' || req.password !== 'bar') { + req.deny (); + } + else { + req.allow_access ({ + access_token_expires_in: expires_seconds, + include_refresh_token: true, + refresh_token_expires_in: refresh_expires_seconds + }); + } + }, { + cookie_name: 'cookie_jar', + refresh: { + access_token_expires_in: expires_seconds, + refresh_token_expires_in: refresh_expires_seconds, + include_refresh_token: true + } + }); + + server = http.createServer ((req: IncomingMessage, res: ServerResponse) => { + ah (req, res); }); server.listen (3000); @@ -21,46 +76,144 @@ xdescribe ('auth handler', () => { .mockDate (new Date); }); - it ('should return a valid access and refresh token', async () => { - const resp = await get ({ authorization: 'Basic foo:bar' }); - expect (resp.statusCode) + it ('auth test sequence', async () => { + // get initial access and refresh tokens + const resp1 = await get ({ authorization: 'Basic foo:bar' }); + expect (resp1.statusCode) .toEqual (200); - const data = JSON.parse (resp.body as string); - const at = data.access_token; - const rt = data.refresh_token; - expect (resp.headers['set-cookie']) - .toContain (`cookie_jar=${at}`); - expect (resp.headers['cache-control']) - .toEqual ('no-store'); - expect (resp.headers.pragma) - .toEqual ('no-cache'); - expect (data.token_type) + const res1 = check_headers (resp1); + expect (res1.data.token_type) .toEqual ('bearer'); - expect (data.expires_in) + expect (resp1.headers['set-cookie']) + .toContain (`cookie_jar=${res1.at}`); + + check_token (res1.at as string, 'access_token'); + expect (res1.data.expires_in) .toEqual (expires_seconds); - expect (data.refresh_expires_in) + + check_token (res1.rt as string, 'refresh_token'); + expect (res1.data.refresh_expires_in) .toEqual (refresh_expires_seconds); - expect (at as string) - .toMatch (/^[0-9a-z-._~+/]+$/ui); - expect (rt as string) - .toMatch (/^[0-9a-z-._~+/]+$/ui); - const atv = auth.verify (at as string); - expect (atv.valid) - .toEqual (true); - expect (atv.authorized) - .toEqual (true); - expect (atv.type) - .toEqual ('access_token'); + // get refresh token + const resp2 = await get ({ authorization: `Bearer ${res1.rt}` }); + expect (resp2.statusCode) + .toEqual (200); + const res2 = check_headers (resp2); + expect (res2.data.token_type) + .toEqual ('bearer'); + expect (resp2.headers['set-cookie']) + .toContain (`cookie_jar=${res2.at}`); - const rtv = auth.verify (rt as string); - expect (rtv.valid) - .toEqual (true); - expect (rtv.authorized) - .toEqual (false); - expect (rtv.type) - .toEqual ('refresh_token'); + check_token (res2.at as string, 'access_token'); + expect (res2.data.expires_in) + .toEqual (expires_seconds); + expect (res2.at).not.toEqual (res1.at); + + check_token (res2.rt as string, 'refresh_token'); + expect (res2.data.refresh_expires_in) + .toEqual (refresh_expires_seconds); + expect (res2.rt).not.toEqual (res1.rt); + }); + + it ('should return the correct denial message', async () => { + const resp = await get ({ authorization: 'Basic bar:baz' }); + expect (resp.statusCode) + .toEqual (401); + const res = check_headers (resp); + expect (res.data) + .toEqual ({ error: 'invalid_client' }); + }); + + it ('should allow base64 login', async () => { + // get initial access and refresh tokens + const resp1 = await get ({ authorization: `Basic ${to_b64 ('foo:bar')}` }); + expect (resp1.statusCode) + .toEqual (200); + const res1 = check_headers (resp1); + expect (res1.data.token_type) + .toEqual ('bearer'); + expect (resp1.headers['set-cookie']) + .toContain (`cookie_jar=${res1.at}`); + + check_token (res1.at as string, 'access_token'); + expect (res1.data.expires_in) + .toEqual (expires_seconds); + + check_token (res1.rt as string, 'refresh_token'); + expect (res1.data.refresh_expires_in) + .toEqual (refresh_expires_seconds); + }); + + it ('should reject invalid requests', async () => { + const resp1 = await get (); + expect (resp1.statusCode) + .toEqual (401); + const res1 = check_headers (resp1); + expect (res1.data) + .toEqual ({ error: 'invalid_client' }); + + const resp2a = await get ({ authorization: 'Basic foo:bar' }); + const res2a = check_headers (resp2a); + const resp2b = await get ( + { authorization: `Bearer ${res2a.at}` } + ); + expect (resp2b.statusCode) + .toEqual (400); + const res2 = check_headers (resp2b); + expect (res2.data) + .toEqual ({ + error: 'invalid_request', + error_description: 'invalid bearer type' + }); + }); + + it ('should reject an invalid token', async () => { + const resp1 = await get ({ authorization: 'Basic foo:bar' }); + const res1 = check_headers (resp1); + const resp2 = await get ( + { authorization: `Bearer ${modify_signature (res1.at)}` } + ); + expect (resp2.statusCode) + .toEqual (401); + const res2 = check_headers (resp2); + expect (res2.data) + .toEqual ({ error: 'invalid_client' }); + }); + + + xit ('should process part token', async () => { + const resp1 = await get ({ authorization: 'Basic part:bar' }); + expect (resp1.statusCode) + .toEqual (200); + const res1 = check_headers (resp1); + expect (res1.data.token_type) + .toEqual ('bearer'); + expect (res1.data.expires_in) + .toEqual (part_expires_seconds); + check_token (res1.data.part_token as string, 'part_token'); + + const resp2 = await get ( + { authorization: `Bearer ${res1.data.part_token}` } + ); + expect (resp2.statusCode) + .toEqual (200); + const res2 = check_headers (resp2); + expect (res2.data.token_type) + .toEqual ('bearer'); + expect (resp2.headers['set-cookie']) + .toContain (`cookie_jar=${res2.at}`); + + check_token (res2.at as string, 'access_token'); + expect (res2.data.expires_in) + .toEqual (expires_seconds); + expect (res2.at).not.toEqual (res1.at); + + check_token (res2.rt as string, 'refresh_token'); + expect (res2.data.refresh_expires_in) + .toEqual (refresh_expires_seconds); + expect (res2.rt).not.toEqual (res1.rt); }); afterAll (() => { diff --git a/test/spec/Authority.ts b/test/spec/Authority.ts index 607ecf5..5bc2a17 100644 --- a/test/spec/Authority.ts +++ b/test/spec/Authority.ts @@ -7,12 +7,7 @@ import auth from '../../lib/Authority'; import bl from '../../lib/Blacklist'; - -function modify_signature (signature: string): string { - const dec = signature.split ('.'); - dec[1] = ''; - return dec.join ('.'); -} +import { modify_signature } from '../Helper'; // eslint-disable-next-line max-lines-per-function describe ('authority', () => {