This commit is contained in:
parent
83a402db8b
commit
4c27d0eace
@ -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 ();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -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": {
|
||||||
|
@ -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 ('.');
|
||||||
|
}
|
||||||
|
@ -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 (() => {
|
||||||
|
@ -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', () => {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user