auth handler tests
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
Timo Hocker 2021-01-01 14:14:19 +01:00
parent 83a402db8b
commit 4c27d0eace
5 changed files with 299 additions and 72 deletions

View File

@ -1,19 +1,48 @@
import { IncomingMessage, ServerResponse } from 'http'; import { IncomingMessage, ServerResponse } from 'http';
import { to_utf8 } from '@sapphirecode/encoding-helper';
import auth from './Authority'; import auth from './Authority';
interface AccessSettings { interface AccessSettings {
access_token_expires_in?: number access_token_expires_in: number
include_refresh_token?: boolean include_refresh_token?: boolean
refresh_token_expires_in?: number 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 { class AuthRequest {
public request: IncomingMessage; public request: IncomingMessage;
public response: ServerResponse; 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.request = req;
this.response = res; this.response = res;
this.is_basic = false;
this.user = '';
this.password = '';
this._cookie_name = cookie;
} }
private default_header () { private default_header () {
@ -26,25 +55,40 @@ class AuthRequest {
access_token_expires_in, access_token_expires_in,
include_refresh_token, include_refresh_token,
refresh_token_expires_in refresh_token_expires_in
}: AccessSettings): void { }: AccessSettings): AccessResult {
this.default_header (); 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', token_type: 'bearer',
access_token: auth.sign ('access_token', access_token_expires_in), access_token: at.signature,
expires_in: access_token_expires_in, expires_in: access_token_expires_in
scope
}; };
if (include_refresh_token) { if (typeof this._cookie_name === 'string') {
res.refresh_token = auth.sign ('refresh_token', refresh_token_expires_in); this.response.setHeader (
res.refresh_token_expires_in = refresh_token_expires_in; 'Set-Cookie',
} `${this._cookie_name}=${at.signature}`
this.response.end (JSON.stringify (res)); );
} }
public invalid (error_description) { 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?: string) {
this.default_header (); this.default_header ();
this.response.writeHead (400); this.response.writeHead (400);
this.response.end (JSON.stringify ({ this.response.end (JSON.stringify ({
@ -65,38 +109,67 @@ type AuthRequestHandler = (req: AuthRequest) => void|Promise<void>;
interface CreateHandlerOptions { interface CreateHandlerOptions {
refresh?: AccessSettings; refresh?: AccessSettings;
modules?: Record<string, AuthRequestHandler>; modules?: Record<string, AuthRequestHandler>;
cookie_name?: string;
} }
// eslint-disable-next-line max-lines-per-function
export default function create_auth_handler ( export default function create_auth_handler (
default_handler: AuthRequestHandler, default_handler: AuthRequestHandler,
{ refresh, modules }: CreateHandlerOptions options?: CreateHandlerOptions
) { ) {
return function process_request ( return function process_request (
req: IncomingMessage, req: IncomingMessage,
res: ServerResponse res: ServerResponse
): Promise<void>|void { ): Promise<void>|void {
const request = new AuthRequest (req, res); const request = new AuthRequest (req, res, options?.cookie_name);
const token = (/Bearer (?<token>.+)/ui).exec (req.headers.authorization); const token = (/(?<type>\S+) (?<token>.+)/ui)
if (token === null) .exec (req.headers.authorization as string);
return default_handler (request);
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) { if (!token_data.valid) {
request.deny (); request.deny ();
return Promise.resolve (); return Promise.resolve ();
} }
if (token_data.type === 'refresh_token') { if (
request.allow_access (refresh); typeof options !== 'undefined'
&& typeof options.refresh !== 'undefined'
&& token_data.type === 'refresh_token'
) {
request.allow_access (options.refresh);
return Promise.resolve (); return Promise.resolve ();
} }
if (token_data.type === 'part_token' && Object.keys (modules) if (
.includes (token_data.next_module)) typeof options !== 'undefined'
return modules[token_data.next_module] (request); && 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 (); return Promise.resolve ();
}; };
} }

View File

@ -40,6 +40,7 @@
], ],
"dependencies": { "dependencies": {
"@sapphirecode/crypto-helper": "^1.2.2", "@sapphirecode/crypto-helper": "^1.2.2",
"@sapphirecode/encoding-helper": "^1.1.0",
"@sapphirecode/utilities": "^1.8.8" "@sapphirecode/utilities": "^1.8.8"
}, },
"engines": { "engines": {

View File

@ -1,11 +1,10 @@
import http from 'http'; import http from 'http';
class Response extends http.IncomingMessage { export class Response extends http.IncomingMessage {
body?: string; body?: string;
} }
export export function get (
function get (
headers: http.OutgoingHttpHeaders = {} headers: http.OutgoingHttpHeaders = {}
): Promise<Response> { ): Promise<Response> {
return new Promise ((resolve) => { 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 ('.');
}

View File

@ -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 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 expires_seconds = 600;
const refresh_expires_seconds = 3600; const refresh_expires_seconds = 3600;
const part_expires_seconds = 60;
interface CheckHeaderResult {
at: string;
rt?: string;
data: Record<string, unknown>;
}
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 // eslint-disable-next-line max-lines-per-function
xdescribe ('auth handler', () => { describe ('auth handler', () => {
let server: http.Server|null = null; let server: http.Server|null = null;
beforeAll (() => { beforeAll (() => {
server = http.createServer ((req, res) => { const ah = create_auth_handler ((req) => {
res.writeHead (404); if (!req.is_basic) {
res.end (); 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); server.listen (3000);
@ -21,46 +76,144 @@ xdescribe ('auth handler', () => {
.mockDate (new Date); .mockDate (new Date);
}); });
it ('should return a valid access and refresh token', async () => { it ('auth test sequence', async () => {
const resp = await get ({ authorization: 'Basic foo:bar' }); // get initial access and refresh tokens
expect (resp.statusCode) const resp1 = await get ({ authorization: 'Basic foo:bar' });
expect (resp1.statusCode)
.toEqual (200); .toEqual (200);
const data = JSON.parse (resp.body as string); const res1 = check_headers (resp1);
const at = data.access_token; expect (res1.data.token_type)
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)
.toEqual ('bearer'); .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); .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); .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); // get refresh token
expect (atv.valid) const resp2 = await get ({ authorization: `Bearer ${res1.rt}` });
.toEqual (true); expect (resp2.statusCode)
expect (atv.authorized) .toEqual (200);
.toEqual (true); const res2 = check_headers (resp2);
expect (atv.type) expect (res2.data.token_type)
.toEqual ('access_token'); .toEqual ('bearer');
expect (resp2.headers['set-cookie'])
.toContain (`cookie_jar=${res2.at}`);
const rtv = auth.verify (rt as string); check_token (res2.at as string, 'access_token');
expect (rtv.valid) expect (res2.data.expires_in)
.toEqual (true); .toEqual (expires_seconds);
expect (rtv.authorized) expect (res2.at).not.toEqual (res1.at);
.toEqual (false);
expect (rtv.type) check_token (res2.rt as string, 'refresh_token');
.toEqual ('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 (() => { afterAll (() => {

View File

@ -7,12 +7,7 @@
import auth from '../../lib/Authority'; import auth from '../../lib/Authority';
import bl from '../../lib/Blacklist'; import bl from '../../lib/Blacklist';
import { modify_signature } from '../Helper';
function modify_signature (signature: string): string {
const dec = signature.split ('.');
dec[1] = '';
return dec.join ('.');
}
// eslint-disable-next-line max-lines-per-function // eslint-disable-next-line max-lines-per-function
describe ('authority', () => { describe ('authority', () => {