Compare commits

...

9 Commits

Author SHA1 Message Date
a41882de60 update
All checks were successful
continuous-integration/drone/push Build is passing
2023-02-09 11:58:10 +01:00
ba9608829d permissions, connection data reader
All checks were successful
continuous-integration/drone/push Build is passing
2022-09-12 12:30:06 +02:00
910099285b fix readme
Some checks failed
continuous-integration/drone/push Build is failing
2022-09-09 16:38:49 +02:00
6856ac718f fix
All checks were successful
continuous-integration/drone/push Build is passing
2022-09-09 16:02:25 +02:00
6eb2009141 fix
Some checks failed
continuous-integration/drone/push Build is failing
2022-09-09 15:53:38 +02:00
64d4f00629 blacklist with automatic garbage collector
Some checks failed
continuous-integration/drone/push Build is failing
2022-09-09 15:49:53 +02:00
31f739d4b8 blacklist sync
All checks were successful
continuous-integration/drone/push Build is passing
2022-08-27 16:39:07 +02:00
e80e3f9a94 improve debug, redis storage structure
Some checks failed
continuous-integration/drone/push Build is failing
2022-08-15 17:33:25 +02:00
b7514941f0 Revert "refactoring redis for multiple value classes"
This reverts commit d5c136790e.
2022-08-15 13:56:02 +02:00
21 changed files with 4095 additions and 3548 deletions

View File

@ -1,5 +1,16 @@
# Changelog # Changelog
## 4.1.0
- Permission Management
- Gateway function to read connection info
## 4.0.0
- Blacklist entries can now be synchronized through redis
BREAKING: Blacklist functions are now asynchronous
## 3.3.0 ## 3.3.0
- Verification Keys can now be synchronized through redis - Verification Keys can now be synchronized through redis

View File

@ -1,6 +1,6 @@
# auth-server-helper # auth-server-helper
version: 3.3.x version: 4.1.x
customizable and simple authentication customizable and simple authentication
@ -122,7 +122,8 @@ should be served here. (Read 2.1 for info on disabling this)
#### 2.1. Processing Auth Requests without closing the response object #### 2.1. Processing Auth Requests without closing the response object
to prevent the auth handler from closing the response object you can provide additional options on each of the allow/deny functions. to prevent the auth handler from closing the response object you can provide
additional options on each of the allow/deny functions.
```js ```js
allow_access({leave_open: true, ...}); allow_access({leave_open: true, ...});
@ -136,12 +137,13 @@ invalid('error description', true);
deny(true); deny(true);
``` ```
if this flag is set, no data will be written to the response body and no data will be sent. if this flag is set, no data will be written to the response body and no data
Status code and Headers will still be set. will be sent. Status code and Headers will still be set.
### Defining Custom Cookie Settings ### Defining Custom Cookie Settings
By default all cookies will be sent with 'Secure; HttpOnly; SameSite=Strict' Attributes By default all cookies will be sent with 'Secure; HttpOnly; SameSite=Strict'
Attributes
In the appropriate settings object, you can set the following options: In the appropriate settings object, you can set the following options:
@ -158,30 +160,33 @@ In the appropriate settings object, you can set the following options:
} }
``` ```
For Documentation on the different Cookie Attributes see <https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#creating_cookies> For Documentation on the different Cookie Attributes see
<https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#creating_cookies>
### Invalidating tokens after they are delivered to the client ### Invalidating tokens after they are delivered to the client
```js ```js
const {blacklist} = require('@sapphirecode/auth-server-helper'); const {blacklist} = require('@sapphirecode/auth-server-helper');
blacklist.add_signature(token_id); // the token id is returned from any function that creates tokens await blacklist.add_signature(token_id); // the token id is returned from any function that creates tokens
``` ```
#### Logout function #### Logout function
```js ```js
const {logout} = require('@sapphirecode/auth-server-helper'); const {GatewayClass} = require('@sapphirecode/auth-server-helper');
const gateway = new GatewayClass({ /* options */ });
// create a new express route // create a new express route
app.get('logout',(req,res)=>{ app.get('logout', (req, res) => {
// call the gateway's logout function // call the gateway's logout function
gateway.logout(req); gateway.logout(req);
// respond ok // respond ok
res.status(200); res.status(200);
res.end(); res.end();
}) });
``` ```
### Exporting and importing public keys to validate tokens across server instances ### Exporting and importing public keys to validate tokens across server instances
@ -196,7 +201,8 @@ const export = keystore.export_verification_data();
keystore.import_verification_data(export); keystore.import_verification_data(export);
``` ```
These keys can also be live synchronized with redis to allow sessions to be shared between servers These keys can also be live synchronized with redis to allow sessions to be
shared between servers
```js ```js
const {keystore} = require('@sapphirecode/auth-server-helper'); const {keystore} = require('@sapphirecode/auth-server-helper');
@ -228,10 +234,55 @@ const {keystore, blacklist} = require('@sapphirecode/auth-server-helper');
keystore.reset_instance(); keystore.reset_instance();
// clear blacklist // clear blacklist
blacklist.clear(); await blacklist.clear();
// clear blacklist items older than 10 seconds // clear blacklist items older than 10 seconds
blacklist.clear(Date.now() - 10000); await blacklist.clear(Date.now() - 10000);
```
### Setting and checking permissions
When allowing access to a client a list of permissions can be added. Permissions
are case sensitive.
```js
allow_access({permissions: ['foo','bar'], ...})
```
The gateway can be told to check those permissions before forwarding a request.
```js
const gateway = new GatewayClass({
require_permissions: ['foo'], // Only clients with the 'foo' permission will be granted access
});
```
additional checks can be run later
```js
(req, res) => {
const has_both = gateway.check_permissions(req, ['foo', 'bar']); // returns true if both permissions are set
const has_bar = gateway.has_permission(req, 'bar'); // returns true if permission 'bar' is set
};
```
### Reading connection info
Data like the used token id, custom data and permissions can be read from
`req.connection.auth` or using the function `gateway.get_info(req)`
```js
const info = gateway.get_info(req);
console.log(info);
/*
{
token_id: 'foo',
data: {}, // custom data
permissions: ['foo','bar']
}
*/
``` ```
## License ## License

View File

@ -20,6 +20,7 @@ interface AccessSettings {
redirect_to?: string redirect_to?: string
data?: unknown, data?: unknown,
leave_open?: boolean leave_open?: boolean
permissions?: string[]
} }
interface AccessResult { interface AccessResult {
@ -78,7 +79,7 @@ class AuthRequest {
this._cookie = cookie; this._cookie = cookie;
this._refresh_cookie = refresh_cookie; this._refresh_cookie = refresh_cookie;
this._is_successful = false; this._is_successful = false;
logger ('started processing new auth request'); logger.extend ('constructor') ('started processing new auth request');
} }
private default_header (set_content = true) { private default_header (set_content = true) {
@ -95,15 +96,17 @@ class AuthRequest {
refresh_token_expires_in, refresh_token_expires_in,
redirect_to, redirect_to,
data, data,
leave_open leave_open,
permissions
}: AccessSettings): Promise<AccessResult> { }: AccessSettings): Promise<AccessResult> {
logger ('allowed access'); const log = logger.extend ('allow_access');
log ('allowed access');
this.default_header (typeof redirect_to !== 'string' && !leave_open); this.default_header (typeof redirect_to !== 'string' && !leave_open);
const at = await auth.sign ( const at = await auth.sign (
'access_token', 'access_token',
access_token_expires_in, access_token_expires_in,
{ data } { data, permissions }
); );
const result: AccessResult = { access_token_id: at.id }; const result: AccessResult = { access_token_id: at.id };
@ -119,7 +122,7 @@ class AuthRequest {
cookies.push (build_cookie (this._cookie, at.signature)); cookies.push (build_cookie (this._cookie, at.signature));
if (include_refresh_token) { if (include_refresh_token) {
logger ('including refresh token'); log ('including refresh token');
if (typeof refresh_token_expires_in !== 'number') if (typeof refresh_token_expires_in !== 'number')
throw new Error ('no expiry time defined for refresh tokens'); throw new Error ('no expiry time defined for refresh tokens');
const rt = await auth.sign ( const rt = await auth.sign (
@ -136,7 +139,7 @@ class AuthRequest {
} }
if (cookies.length > 0) { if (cookies.length > 0) {
logger ('sending %d cookies', cookies.length); log ('sending %d cookies', cookies.length);
this.response.setHeader ( this.response.setHeader (
'Set-Cookie', 'Set-Cookie',
cookies cookies
@ -146,7 +149,7 @@ class AuthRequest {
this._is_successful = true; this._is_successful = true;
if (typeof redirect_to === 'string') { if (typeof redirect_to === 'string') {
logger ('redirecting to %s', redirect_to); log ('redirecting to %s', redirect_to);
this.response.setHeader ('Location', redirect_to); this.response.setHeader ('Location', redirect_to);
this.response.statusCode = 302; this.response.statusCode = 302;
if (!leave_open) if (!leave_open)
@ -155,7 +158,7 @@ class AuthRequest {
} }
if (!leave_open) { if (!leave_open) {
logger ('finishing http request'); log ('finishing http request');
this.response.writeHead (200); this.response.writeHead (200);
this.response.end (JSON.stringify (res)); this.response.end (JSON.stringify (res));
} }
@ -169,7 +172,8 @@ class AuthRequest {
data?: Record<string, unknown>, data?: Record<string, unknown>,
leave_open = false leave_open = false
): Promise<string> { ): Promise<string> {
logger ('allowed part token'); const log = logger.extend ('allow_part');
log ('allowed part token');
this.default_header (); this.default_header ();
const pt = await auth.sign ( const pt = await auth.sign (
@ -185,7 +189,7 @@ class AuthRequest {
}; };
if (!leave_open) { if (!leave_open) {
logger ('finishing http request'); log ('finishing http request');
this.response.writeHead (200); this.response.writeHead (200);
this.response.end (JSON.stringify (res)); this.response.end (JSON.stringify (res));
} }
@ -195,11 +199,12 @@ class AuthRequest {
} }
public invalid (error_description?: string, leave_open = false): void { public invalid (error_description?: string, leave_open = false): void {
logger ('rejecting invalid request'); const log = logger.extend ('invalid');
log ('rejecting invalid request');
this.default_header (); this.default_header ();
this.response.statusCode = 400; this.response.statusCode = 400;
if (!leave_open) { if (!leave_open) {
logger ('finishing http request'); log ('finishing http request');
this.response.end (JSON.stringify ({ this.response.end (JSON.stringify ({
error: 'invalid_request', error: 'invalid_request',
error_description error_description
@ -208,11 +213,12 @@ class AuthRequest {
} }
public deny (leave_open = false): void { public deny (leave_open = false): void {
logger ('denied access'); const log = logger.extend ('deny');
log ('denied access');
this.default_header (); this.default_header ();
this.response.statusCode = 401; this.response.statusCode = 401;
if (!leave_open) { if (!leave_open) {
logger ('finishing http request'); log ('finishing http request');
this.response.end (JSON.stringify ({ error: 'invalid_client' })); this.response.end (JSON.stringify ({ error: 'invalid_client' }));
} }
} }
@ -230,18 +236,19 @@ interface CreateHandlerOptions {
type ProcessRequestOptions = Omit<CreateHandlerOptions, 'parse_body'> type ProcessRequestOptions = Omit<CreateHandlerOptions, 'parse_body'>
// eslint-disable-next-line max-lines-per-function // eslint-disable-next-line max-lines-per-function, max-statements
async function process_request ( async function process_request (
request: AuthRequest, request: AuthRequest,
token: RegExpExecArray | null, token: RegExpExecArray | null,
default_handler: AuthRequestHandler, default_handler: AuthRequestHandler,
options?: ProcessRequestOptions options?: ProcessRequestOptions
): Promise<void> { ): Promise<void> {
const log = logger.extend ('process_request');
if (token === null) if (token === null)
return default_handler (request); return default_handler (request);
if ((/Basic/ui).test (token?.groups?.type as string)) { if ((/Basic/ui).test (token?.groups?.type as string)) {
logger ('found basic login data'); log ('found basic login data');
request.is_basic = true; request.is_basic = true;
let login = token?.groups?.token as string; let login = token?.groups?.token as string;
@ -255,7 +262,7 @@ async function process_request (
} }
if ((/Bearer/ui).test (token?.groups?.type as string)) { if ((/Bearer/ui).test (token?.groups?.type as string)) {
logger ('found bearer login data'); log ('found bearer login data');
request.is_bearer = true; request.is_bearer = true;
request.token = token?.groups?.token; request.token = token?.groups?.token;
@ -264,7 +271,7 @@ async function process_request (
if (!token_data.valid) if (!token_data.valid)
return default_handler (request); return default_handler (request);
logger ('bearer token is valid'); log ('bearer token is valid');
request.token_data = token_data.data; request.token_data = token_data.data;
request.token_id = token_data.id; request.token_id = token_data.id;
@ -274,7 +281,7 @@ async function process_request (
&& typeof options.refresh !== 'undefined' && typeof options.refresh !== 'undefined'
&& token_data.type === 'refresh_token' && token_data.type === 'refresh_token'
) { ) {
logger ('found refresh token, emitting new access token'); log ('found refresh token, emitting new access token');
request.allow_access (options.refresh); request.allow_access (options.refresh);
return Promise.resolve (); return Promise.resolve ();
} }
@ -287,7 +294,7 @@ async function process_request (
&& Object.keys (options.modules) && Object.keys (options.modules)
.includes (token_data.next_module) .includes (token_data.next_module)
) { ) {
logger ('processing module %s', token_data.next_module); log ('processing module %s', token_data.next_module);
return options.modules[token_data.next_module] (request); return options.modules[token_data.next_module] (request);
} }
@ -295,7 +302,7 @@ async function process_request (
return Promise.resolve (); return Promise.resolve ();
} }
logger ('no matching login method, triggering default handler'); log ('no matching login method, triggering default handler');
return default_handler (request); return default_handler (request);
} }
@ -304,7 +311,7 @@ export default function create_auth_handler (
default_handler: AuthRequestHandler, default_handler: AuthRequestHandler,
options?: CreateHandlerOptions options?: CreateHandlerOptions
): AuthHandler { ): AuthHandler {
logger ('creating new auth handler'); logger.extend ('create_auth_handler') ('creating new auth handler');
if ( if (
typeof options?.cookie !== 'undefined' typeof options?.cookie !== 'undefined'
&& typeof options?.refresh_cookie !== 'undefined' && typeof options?.refresh_cookie !== 'undefined'

View File

@ -6,13 +6,13 @@
*/ */
import { import {
create_salt,
sign_object, sign_object,
verify_signature_get_info verify_signature_get_info
} from '@sapphirecode/crypto-helper'; } from '@sapphirecode/crypto-helper';
import keystore from './KeyStore'; import keystore from './KeyStore';
import blacklist from './Blacklist'; import blacklist from './Blacklist';
import { debug } from './debug'; import { debug } from './debug';
import { generate_token_id } from './token_id';
const logger = debug ('authority'); const logger = debug ('authority');
@ -25,6 +25,7 @@ interface VerificationResult {
type: TokenType; type: TokenType;
id: string; id: string;
next_module?: string; next_module?: string;
permissions?: string[];
data?: unknown; data?: unknown;
error?: string; error?: string;
} }
@ -37,17 +38,20 @@ interface SignatureResult {
interface SignatureOptions interface SignatureOptions
{ {
data?: unknown data?: unknown
next_module?: string next_module?: string,
permissions?: string[]
} }
class Authority { class Authority {
public async verify (key: string): Promise<VerificationResult> { public async verify (key: string): Promise<VerificationResult> {
logger ('verifying token'); const log = logger.extend ('verify');
log ('verifying token');
const result: VerificationResult = { const result: VerificationResult = {
authorized: false, authorized: false,
valid: false, valid: false,
type: 'none', type: 'none',
id: '' permissions: [],
id: ''
}; };
const data = await verify_signature_get_info ( const data = await verify_signature_get_info (
key, key,
@ -63,7 +67,7 @@ class Authority {
); );
if (data === null) { if (data === null) {
logger ('token invalid'); log ('token invalid');
result.error = 'invalid signature'; result.error = 'invalid signature';
return result; return result;
} }
@ -71,10 +75,10 @@ class Authority {
result.id = data.id; result.id = data.id;
result.type = data.type; result.type = data.type;
logger ('parsing token %s %s', result.type, result.id); log ('parsing token %s %s', result.type, result.id);
if (!blacklist.is_valid (data.id)) { if (!(await blacklist.is_valid (data.id))) {
logger ('token is blacklisted'); log ('token is blacklisted');
result.error = 'blacklisted'; result.error = 'blacklisted';
return result; return result;
} }
@ -82,13 +86,15 @@ class Authority {
result.valid = true; result.valid = true;
result.authorized = result.type === 'access_token'; result.authorized = result.type === 'access_token';
result.next_module = data.next_module; result.next_module = data.next_module;
result.permissions = data.permissions;
result.data = data.obj; result.data = data.obj;
logger ( log (
'valid %s; targeting module %s', 'valid %s; targeting module %s',
result.type, result.type,
result.next_module result.next_module
); );
log ('permissions %o', result.permissions);
return result; return result;
} }
@ -98,19 +104,23 @@ class Authority {
valid_for: number, valid_for: number,
options?: SignatureOptions options?: SignatureOptions
): Promise<SignatureResult> { ): Promise<SignatureResult> {
logger ('signing new %s', type); const log = logger.extend ('sign');
log ('signing new %s', type);
const time = Date.now (); const time = Date.now ();
const valid_until = time + (valid_for * 1e3);
const key = await keystore.get_sign_key (time / 1000, valid_for); const key = await keystore.get_sign_key (time / 1000, valid_for);
const attributes = { const attributes = {
id: create_salt (), id: generate_token_id (new Date (valid_until)),
iat: time, iat: time,
iss: keystore.instance_id, iss: keystore.instance_id,
type, type,
valid_for, valid_for,
next_module: options?.next_module valid_until,
next_module: options?.next_module,
permissions: options?.permissions
}; };
const signature = sign_object (options?.data, key, attributes); const signature = sign_object (options?.data, key, attributes);
logger ('created token %s', attributes.id); log ('created token %s', attributes.id);
return { id: attributes.id, signature }; return { id: attributes.id, signature };
} }
} }

View File

@ -6,63 +6,141 @@
*/ */
import { debug } from './debug'; import { debug } from './debug';
import { redis_blacklist_store } from './RedisData/RedisBlacklistStore';
import { parse_token_id } from './token_id';
const logger = debug ('blacklist'); const logger = debug ('blacklist');
interface Signature { interface Signature {
hash: string; token_id: string;
iat: number;
valid_until: Date;
}
interface ExportedSignature {
token_id: string;
iat: number; iat: number;
} }
class Blacklist { class Blacklist {
private _signatures: Signature[]; private _signatures: Signature[];
private _interval: NodeJS.Timeout;
public constructor () { public constructor () {
this._signatures = []; this._signatures = [];
this._interval = setInterval (
this.garbage_collect.bind (this),
3600000
);
} }
public clear (before: number = Number.POSITIVE_INFINITY): void { public async clear (
logger ('clearing blacklist'); before: number = Number.POSITIVE_INFINITY
): Promise<void> {
logger.extend ('clear') ('clearing blacklist');
for (let i = this._signatures.length - 1; i >= 0; i--) { for (let i = this._signatures.length - 1; i >= 0; i--) {
if (this._signatures[i].iat < before) if (this._signatures[i].iat < before) {
this._signatures.splice (i, 1); // eslint-disable-next-line no-await-in-loop
await this.remove_signature (i);
}
} }
} }
public add_signature (hash: string): void { public async add_signature (token_id: string): Promise<void> {
logger ('blacklisting signature %s', hash); logger.extend ('add_signature') ('blacklisting signature %s', token_id);
this._signatures.push ({ iat: Date.now (), hash }); const parsed = parse_token_id (token_id);
this._signatures.push ({
iat: Date.now (),
token_id,
valid_until: parsed.valid_until
});
await redis_blacklist_store.add (token_id, parsed.valid_until);
} }
public remove_signature (hash: string): void { public async remove_signature (signature: number | string): Promise<void> {
logger ('removing signature from blacklist %s', hash); const log = logger.extend ('remove_signature');
for (let i = this._signatures.length - 1; i >= 0; i--) { log ('removing signature from blacklist %s', signature);
if (this._signatures[i].hash === hash) let key = '';
this._signatures.splice (i, 1); if (typeof signature === 'string') {
log ('received string, searching through signatures');
key = signature;
for (let i = this._signatures.length - 1; i >= 0; i--) {
if (this._signatures[i].token_id === signature) {
log ('removing sigature %s at %d', signature, i);
this._signatures.splice (i, 1);
}
}
} }
else {
log (
'received index, removing signature %s at index %s',
this._signatures[signature].token_id,
signature
);
key = this._signatures[signature].token_id;
this._signatures.splice (signature, 1);
}
await redis_blacklist_store.remove (key);
} }
public is_valid (hash: string): boolean { public async is_valid (hash: string): Promise<boolean> {
logger ('checking signature for blacklist entry %s', hash); const log = logger.extend ('is_valid');
log ('checking signature for blacklist entry %s', hash);
for (const sig of this._signatures) { for (const sig of this._signatures) {
if (sig.hash === hash) { if (sig.token_id === hash) {
logger ('found matching blacklist entry'); log ('found matching blacklist entry');
return false; return false;
} }
} }
logger ('signature is not blacklisted'); log ('signature is not blacklisted locally, checking redis');
if (await redis_blacklist_store.get (hash)) {
log ('signature is blacklisted in redis');
return false;
}
log ('signature is not blacklisted');
return true; return true;
} }
public export_blacklist (): Signature[] { public export_blacklist (): ExportedSignature[] {
logger ('exporting blacklist'); logger.extend ('export_blacklist') ('exporting blacklist');
return this._signatures; return this._signatures.map ((v) => ({
iat: v.iat,
token_id: v.token_id
}));
} }
public import_blacklist (data: Signature[]): void { public import_blacklist (data: ExportedSignature[]): void {
logger ('importing %d blacklist entries', data.length); logger.extend ('import_blacklist') (
this._signatures.push (...data); 'importing %d blacklist entries',
data.length
);
for (const token of data) {
const parsed = parse_token_id (token.token_id);
this._signatures.push ({
token_id: token.token_id,
iat: token.iat,
valid_until: parsed.valid_until
});
}
}
public sync_redis (url: string): void {
redis_blacklist_store.connect (url);
}
private async garbage_collect (): Promise<void> {
const log = logger.extend ('garbage_collect');
const time = new Date;
log ('removing signatures expired before', time);
for (let i = this._signatures.length - 1; i >= 0; i--) {
if (this._signatures[i].valid_until < time) {
log ('signature %s expired', this._signatures[i].token_id);
await this.remove_signature (i);
}
}
} }
} }

View File

@ -32,13 +32,21 @@ interface GatewayOptions {
cookie?: CookieSettings; cookie?: CookieSettings;
refresh_cookie?: CookieSettings; refresh_cookie?: CookieSettings;
refresh_settings?: RefreshSettings; refresh_settings?: RefreshSettings;
require_permissions?: string[];
}
interface ConnectionInfo {
token_id: string
token_data: unknown
permissions: string[]
} }
class GatewayClass { class GatewayClass {
private _options: GatewayOptions; private _options: GatewayOptions;
public constructor (options: GatewayOptions = {}) { public constructor (options: GatewayOptions = {}) {
logger ('creating new gateway'); const log = logger.extend ('constructor');
log ('creating new gateway');
if ( if (
typeof options?.cookie !== 'undefined' typeof options?.cookie !== 'undefined'
&& typeof options?.refresh_cookie !== 'undefined' && typeof options?.refresh_cookie !== 'undefined'
@ -50,15 +58,16 @@ class GatewayClass {
} }
public deny (res: ServerResponse): void { public deny (res: ServerResponse): void {
logger ('denied http request'); logger.extend ('deny') ('denied http request');
res.statusCode = 403; res.statusCode = 403;
res.end (); res.end ();
} }
public redirect (res: ServerResponse): void { public redirect (res: ServerResponse): void {
logger ('redirecting http request to %s', this._options.redirect_url); const log = logger.extend ('redirect');
log ('redirecting http request to %s', this._options.redirect_url);
if (typeof this._options.redirect_url !== 'string') { if (typeof this._options.redirect_url !== 'string') {
logger ('no redirect url defined'); log ('no redirect url defined');
this.deny (res); this.deny (res);
return; return;
} }
@ -68,34 +77,40 @@ class GatewayClass {
} }
public get_header_auth (req: IncomingMessage): string | null { public get_header_auth (req: IncomingMessage): string | null {
logger ('extracting authorization header'); const log = logger.extend ('get_header_auth');
log ('extracting authorization header');
const auth_header = req.headers.authorization; const auth_header = req.headers.authorization;
const auth = (/(?<type>\w+) (?<data>.*)/u).exec (auth_header || ''); const auth = (/(?<type>\w+) (?<data>.*)/u).exec (auth_header || '');
if (auth === null) if (auth === null)
return null; return null;
if (auth.groups?.type !== 'Bearer') if (auth.groups?.type !== 'Bearer')
return null; return null;
logger ('found bearer token'); log ('found bearer token');
return auth.groups?.data; return auth.groups?.data;
} }
public async try_access (req: IncomingMessage): Promise<boolean> { public async try_access (req: IncomingMessage): Promise<boolean> {
logger ('authenticating incoming request'); const log = logger.extend ('try_access');
log ('authenticating incoming request');
let auth = this.get_header_auth (req); let auth = this.get_header_auth (req);
if (auth === null) if (auth === null)
auth = extract_cookie (this._options.cookie?.name, req.headers.cookie); auth = extract_cookie (this._options.cookie?.name, req.headers.cookie);
if (auth === null) { if (auth === null) {
logger ('found no auth token'); log ('found no auth token');
return false; return false;
} }
const ver = await authority.verify (auth); const ver = await authority.verify (auth);
logger ('setting connection info'); log ('setting connection info');
const con = req.connection as unknown as Record<string, unknown>; const con = req.connection as unknown as Record<string, unknown>;
con.auth = { token_id: ver.id, token_data: ver.data }; con.auth = {
token_id: ver.id,
token_data: ver.data,
permissions: ver.permissions
};
logger ('token valid: %s', ver.authorized); log ('token valid: %s', ver.authorized);
return ver.authorized; return ver.authorized;
} }
@ -103,26 +118,27 @@ class GatewayClass {
req: IncomingMessage, req: IncomingMessage,
res: ServerResponse res: ServerResponse
): Promise<boolean> { ): Promise<boolean> {
const log = logger.extend ('try_refresh');
if ( if (
typeof this._options.refresh_cookie === 'undefined' typeof this._options.refresh_cookie === 'undefined'
|| typeof this._options.refresh_settings === 'undefined' || typeof this._options.refresh_settings === 'undefined'
) )
return false; return false;
logger ('trying to apply refresh token'); log ('trying to apply refresh token');
const refresh = extract_cookie ( const refresh = extract_cookie (
this._options.refresh_cookie.name, this._options.refresh_cookie.name,
req.headers.cookie req.headers.cookie
); );
if (refresh === null) { if (refresh === null) {
logger ('could not find refresh token'); log ('could not find refresh token');
return false; return false;
} }
const ver = await authority.verify (refresh); const ver = await authority.verify (refresh);
if (ver.type === 'refresh_token' && ver.valid) { if (ver.type === 'refresh_token' && ver.valid) {
logger ('refresh token valid, generating new tokens'); log ('refresh token valid, generating new tokens');
const auth_request = new AuthRequest ( const auth_request = new AuthRequest (
req, req,
res, res,
@ -136,18 +152,19 @@ class GatewayClass {
leave_open: true leave_open: true
}); });
logger ('setting connection info'); log ('setting connection info');
const con = req.connection as unknown as Record<string, unknown>; const con = req.connection as unknown as Record<string, unknown>;
con.auth = { con.auth = {
token_id: refresh_result.access_token_id, token_id: refresh_result.access_token_id,
token_data: ver.data token_data: ver.data,
permissions: ver.permissions
}; };
logger ('tokens refreshed'); log ('tokens refreshed');
return true; return true;
} }
logger ('refresh token invalid'); log ('refresh token invalid');
return false; return false;
} }
@ -155,38 +172,62 @@ class GatewayClass {
req: IncomingMessage, req: IncomingMessage,
res: ServerResponse res: ServerResponse
): Promise<boolean> { ): Promise<boolean> {
logger ('trying to authenticate http request'); const log = logger.extend ('authenticate');
log ('trying to authenticate http request');
if (await this.try_access (req)) { if (await this.try_access (req)) {
logger ('authenticated via access_token'); log ('authenticated via access_token');
return true; return true;
} }
if (await this.try_refresh (req, res)) { if (await this.try_refresh (req, res)) {
logger ('authenticated via refresh_token'); log ('authenticated via refresh_token');
return true; return true;
} }
logger ('could not verify session'); log ('could not verify session');
return false; return false;
} }
public check_permissions (
req: IncomingMessage,
permissions = this._options.require_permissions || []
): boolean {
for (const perm of permissions) {
if (!this.has_permission (req, perm))
return false;
}
return true;
}
public has_permission (req: IncomingMessage, permission: string) {
const info = this.get_info (req);
return info.permissions.includes (permission);
}
public async process_request ( public async process_request (
req: IncomingMessage, req: IncomingMessage,
res: ServerResponse, res: ServerResponse,
next: AnyFunc next: AnyFunc
): Promise<unknown> { ): Promise<unknown> {
logger ('processing incoming http request'); const log = logger.extend ('process_request');
log ('processing incoming http request');
if (await this.authenticate (req, res)) { if (await this.authenticate (req, res)) {
logger ('authentification successful, calling next handler'); log ('authentification successful');
log ('checking permissions');
if (!this.check_permissions (req))
return this.redirect (res);
log ('authorization successful. calling next handler');
return next (); return next ();
} }
logger ('failed to authenticate, redirecting client'); log ('failed to authenticate, redirecting client');
return this.redirect (res); return this.redirect (res);
} }
public async logout (req: IncomingMessage): Promise<void> { public async logout (req: IncomingMessage): Promise<void> {
const l = logger.extend ('logout'); const log = logger.extend ('logout');
l ('invalidating all submitted tokens'); log ('invalidating all submitted tokens');
const auth_strings = [ const auth_strings = [
this.get_header_auth (req), this.get_header_auth (req),
extract_cookie (this._options.cookie?.name, req.headers.cookie), extract_cookie (this._options.cookie?.name, req.headers.cookie),
@ -200,12 +241,24 @@ class GatewayClass {
) )
).filter ((v) => v.valid); ).filter ((v) => v.valid);
l ('found %d tokens: %O', tokens.length, tokens); log ('found %d tokens: %O', tokens.length, tokens);
for (const token of tokens) for (const token of tokens) {
blacklist.add_signature (token.id); // eslint-disable-next-line no-await-in-loop
await blacklist.add_signature (token.id);
}
l ('complete'); log ('complete');
}
public get_info (req: IncomingMessage): ConnectionInfo {
const conn = req.connection as unknown as Record<string, unknown>;
const auth = conn.auth as Record<string, unknown>;
return {
token_id: auth.token_id as string,
token_data: auth.token_data,
permissions: (auth.permissions as string[]) || []
};
} }
} }

View File

@ -1,3 +1,10 @@
/*
* Copyright (C) Sapphirecode - All Rights Reserved
* This file is part of Auth-Server-Helper which is released under MIT.
* See file 'LICENSE' for full license details.
* Created by Timo Hocker <timo@scode.ovh>, August 2022
*/
export interface Key { export interface Key {
key: string; key: string;
valid_until: number; valid_until: number;

View File

@ -9,7 +9,7 @@ import { generate_keypair, random_hex } from '@sapphirecode/crypto-helper';
import { to_b58 } from '@sapphirecode/encoding-helper'; import { to_b58 } from '@sapphirecode/encoding-helper';
import { debug } from './debug'; import { debug } from './debug';
import { KeyStoreData, KeyStoreExport } from './Key'; import { KeyStoreData, KeyStoreExport } from './Key';
import { redis } from './Redis'; import { redis_key_store } from './RedisData/RedisKeyStore';
const logger = debug ('keystore'); const logger = debug ('keystore');
@ -19,7 +19,6 @@ class KeyStore {
private _keys: KeyStoreData = {}; private _keys: KeyStoreData = {};
private _interval: NodeJS.Timeout; private _interval: NodeJS.Timeout;
private _instance: string; private _instance: string;
private _sync_redis = false;
public get instance_id (): string { public get instance_id (): string {
return this._instance; return this._instance;
@ -28,9 +27,12 @@ class KeyStore {
public constructor () { public constructor () {
this._interval = setInterval (() => { this._interval = setInterval (() => {
this.garbage_collect (); this.garbage_collect ();
}, renew_interval); }, renew_interval * 1000);
this._instance = to_b58 (random_hex (16), 'hex'); this._instance = to_b58 (random_hex (16), 'hex');
logger ('created keystore instance %s', this._instance); logger.extend ('constructor') (
'created keystore instance %s',
this._instance
);
} }
private get_index (iat: number, instance = this._instance): string { private get_index (iat: number, instance = this._instance): string {
@ -54,12 +56,12 @@ class KeyStore {
valid_until: time + (valid_for * 1000) valid_until: time + (valid_for * 1000)
} }
}; };
if (this._sync_redis) await redis_key_store.set ({ ...result.public_key, index });
await redis.set_key ({ ...result.public_key, index });
this._keys[index] = result; this._keys[index] = result;
} }
private garbage_collect (): void { private garbage_collect (): void {
const log = logger.extend ('garbage_collect');
const time = (new Date) const time = (new Date)
.getTime (); .getTime ();
const keys = Object.keys (this._keys); const keys = Object.keys (this._keys);
@ -68,12 +70,12 @@ class KeyStore {
if (typeof entry.private_key !== 'undefined' if (typeof entry.private_key !== 'undefined'
&& entry.private_key.valid_until < time && entry.private_key.valid_until < time
) { ) {
logger ('deleting expired private key'); log ('deleting expired private key');
delete entry.private_key; delete entry.private_key;
} }
if (entry.public_key.valid_until < time) { if (entry.public_key.valid_until < time) {
logger ('deleting expired key pair'); log ('deleting expired key pair');
delete this._keys[index]; delete this._keys[index];
} }
} }
@ -84,7 +86,8 @@ class KeyStore {
valid_for: number, valid_for: number,
instance?: string instance?: string
): Promise<string> { ): Promise<string> {
logger ( const log = logger.extend ('get_sign_key');
log (
'querying key from %s for timestamp %d, valid for %d', 'querying key from %s for timestamp %d, valid for %d',
instance, instance,
iat, iat,
@ -103,36 +106,37 @@ class KeyStore {
.getTime () + (valid_for * 1000); .getTime () + (valid_for * 1000);
if (typeof this._keys[index] !== 'undefined') { if (typeof this._keys[index] !== 'undefined') {
logger ('loading existing key'); log ('loading existing key');
const key = this._keys[index]; const key = this._keys[index];
if (typeof key.private_key === 'undefined') if (typeof key.private_key === 'undefined')
throw new Error ('cannot access already expired keys'); throw new Error ('cannot access already expired keys');
if (key.public_key.valid_until < valid_until) { if (key.public_key.valid_until < valid_until) {
logger ('updating key valid timespan to match new value'); log ('updating key valid timespan to match new value');
key.public_key.valid_until = valid_until; key.public_key.valid_until = valid_until;
} }
return key.private_key?.key as string; return key.private_key?.key as string;
} }
logger ('key does not exist, creating a new one'); log ('key does not exist, creating a new one');
await this.create_key (index, valid_for); await this.create_key (index, valid_for);
return this._keys[index].private_key?.key as string; return this._keys[index].private_key?.key as string;
} }
public async get_key (iat: number, instance?: string): Promise<string> { public async get_key (iat: number, instance?: string): Promise<string> {
logger ('querying public key from %s for timestamp %d', instance, iat); const log = logger.extend ('get_key');
log ('querying public key from %s for timestamp %d', instance, iat);
const index = this.get_index (iat, instance); const index = this.get_index (iat, instance);
let key = null; let key = null;
if (typeof this._keys[index] === 'undefined') { if (typeof this._keys[index] === 'undefined')
if (this._sync_redis) key = await redis_key_store.get (index);
key = await redis.get_key (index);
} else
else { key = this._keys[index].public_key; } key = this._keys[index].public_key;
if (key === null) if (key === null)
throw new Error ('key could not be found'); throw new Error ('key could not be found');
@ -141,18 +145,23 @@ class KeyStore {
} }
public export_verification_data (): KeyStoreExport { public export_verification_data (): KeyStoreExport {
logger ('exporting public keys'); const log = logger.extend ('export_verification_data');
log ('exporting public keys');
log ('cleaning up before export');
this.garbage_collect (); this.garbage_collect ();
const out: KeyStoreExport = []; const out: KeyStoreExport = [];
for (const index of Object.keys (this._keys)) for (const index of Object.keys (this._keys)) {
log ('exporting key %s', index);
out.push ({ ...this._keys[index].public_key, index }); out.push ({ ...this._keys[index].public_key, index });
}
return out; return out;
} }
public import_verification_data (data: KeyStoreExport): void { public import_verification_data (data: KeyStoreExport): void {
logger ('importing %d public keys', data.length); const log = logger.extend ('import_verification_data');
log ('importing %d public keys', data.length);
for (const key of data) { for (const key of data) {
log ('importing key %s', key.index);
if (typeof this._keys[key.index] !== 'undefined') if (typeof this._keys[key.index] !== 'undefined')
throw new Error ('cannot import to the same instance'); throw new Error ('cannot import to the same instance');
this._keys[key.index] = { this._keys[key.index] = {
@ -162,20 +171,19 @@ class KeyStore {
} }
}; };
} }
log ('running garbage collector');
this.garbage_collect (); this.garbage_collect ();
} }
public reset_instance (): void { public reset_instance (): void {
logger ('resetting keystore'); logger.extend ('reset_instance') ('resetting keystore');
this._instance = to_b58 (random_hex (16), 'hex'); this._instance = to_b58 (random_hex (16), 'hex');
this._keys = {}; this._keys = {};
this._sync_redis = false; redis_key_store.disconnect ();
redis.disconnect ();
} }
public sync_redis (url: string): void { public sync_redis (url: string): void {
redis.connect (url); redis_key_store.connect (url);
this._sync_redis = true;
} }
} }

View File

@ -7,13 +7,9 @@
import IORedis from 'ioredis'; import IORedis from 'ioredis';
import { debug } from './debug'; import { debug } from './debug';
import { LabelledKey } from './Key';
const logger = debug ('redis'); const logger = debug ('redis');
export type SyncClass = 'blacklist' | 'keystore'
export type SyncValue = LabelledKey | string;
export class Redis { export class Redis {
private _redis: IORedis | null = null; private _redis: IORedis | null = null;
@ -55,48 +51,14 @@ export class Redis {
log ('done'); log ('done');
} }
public async set ( protected get redis (): IORedis {
sync_class: SyncClass, if (this._redis === null)
key: string, throw new Error ('redis is not connected');
value: SyncValue
): Promise<void> {
const log = logger.extend ('set');
log ('trying to set %s value %s to redis', sync_class, key);
if (this._redis === null) {
log ('redis is inactive, skipping');
return;
}
let valid_for = null;
if (sync_class === 'keystore') {
valid_for = Math.floor (
(key.valid_until - (new Date)
.getTime ()) / 1000
);
log ('key is valid for %d seconds', valid_for);
}
if (valid_for === null)
await this._redis.set (key, JSON.stringify (value));
else
await this._redis.setex (key, valid_for, JSON.stringify (value));
log ('saved value'); return this._redis;
} }
public async get_key (index: string): Promise<LabelledKey | null> { protected get is_active (): boolean {
const log = logger.extend ('get_key'); return this._redis !== null;
log ('trying to get key %s from redis', index);
if (this._redis === null) {
log ('redis is inactive, skipping');
return null;
}
const res = await this._redis.get (index);
if (res === null) {
log ('key not found in redis');
return null;
}
log ('key found');
return JSON.parse (res);
} }
} }
export const redis = (new Redis);

View File

@ -0,0 +1,53 @@
/*
* Copyright (C) Sapphirecode - All Rights Reserved
* This file is part of Auth-Server-Helper which is released under MIT.
* See file 'LICENSE' for full license details.
* Created by Timo Hocker <timo@scode.ovh>, August 2022
*/
import { debug } from '../debug';
import { Redis } from '../Redis';
const logger = debug ('RedisBlacklistStore');
export class RedisBlacklistStore extends Redis {
public async add (key: string, valid_until: Date): Promise<void> {
const log = logger.extend ('set');
log ('trying to add key %s to redis blacklist', key);
if (!this.is_active) {
log ('redis is inactive, skipping');
return;
}
await this.redis.setex (
`blacklist_${key}`,
Math.floor ((valid_until.getTime () - Date.now ()) / 1000),
1
);
log ('saved key');
}
public async remove (key: string): Promise<void> {
const log = logger.extend ('remove');
log ('removing key %s from redis', key);
if (!this.is_active) {
log ('redis is inactive, skipping');
return;
}
await this.redis.del (`blacklist_${key}`);
log ('removed key');
}
public async get (key: string): Promise<boolean> {
const log = logger.extend ('get');
log ('trying to find key %s in redis blacklist', key);
if (!this.is_active) {
log ('redis is inactive, skipping');
return false;
}
const res = await this.redis.exists (`blacklist_${key}`) === 1;
log ('found key %s', res);
return res;
}
}
export const redis_blacklist_store = new RedisBlacklistStore;

View File

@ -0,0 +1,52 @@
/*
* Copyright (C) Sapphirecode - All Rights Reserved
* This file is part of Auth-Server-Helper which is released under MIT.
* See file 'LICENSE' for full license details.
* Created by Timo Hocker <timo@scode.ovh>, August 2022
*/
import { debug } from '../debug';
import { LabelledKey } from '../Key';
import { Redis } from '../Redis';
const logger = debug ('RedisKeyStore');
export class RedisKeyStore extends Redis {
public async set (value: LabelledKey): Promise<void> {
const log = logger.extend ('set');
log ('trying to set key %s to redis', value.index);
if (!this.is_active) {
log ('redis is inactive, skipping');
return;
}
const valid_for = Math.floor (
(value.valid_until - (new Date)
.getTime ()) / 1000
);
log ('key is valid for %d seconds', valid_for);
await this.redis.setex (
`keystore_${value.index}`,
valid_for,
JSON.stringify (value)
);
log ('saved key');
}
public async get (index: string): Promise<LabelledKey | null> {
const log = logger.extend ('get');
log ('trying to get key %s from redis', index);
if (!this.is_active) {
log ('redis is inactive, skipping');
return null;
}
const res = await this.redis.get (`keystore_${index}`);
if (res === null) {
log ('key not found in redis');
return null;
}
log ('key found');
return JSON.parse (res);
}
}
export const redis_key_store = new RedisKeyStore;

View File

@ -1,3 +1,10 @@
/*
* Copyright (C) Sapphirecode - All Rights Reserved
* This file is part of Auth-Server-Helper which is released under MIT.
* See file 'LICENSE' for full license details.
* Created by Timo Hocker <timo@scode.ovh>, August 2022
*/
import { run_regex } from '@sapphirecode/utilities'; import { run_regex } from '@sapphirecode/utilities';
import { debug } from './debug'; import { debug } from './debug';
@ -54,7 +61,8 @@ function extract_cookie (
name: string|undefined, name: string|undefined,
header: string|undefined header: string|undefined
): string| null { ): string| null {
logger (`extracting cookie ${name}`); const log = logger.extend ('extract_cookie');
log (`extracting cookie ${name}`);
const cookie_regex = /(?:^|;)\s*(?<name>[^;=]+)=(?<value>[^;]+)/gu; const cookie_regex = /(?:^|;)\s*(?<name>[^;=]+)=(?<value>[^;]+)/gu;
@ -64,9 +72,9 @@ function extract_cookie (
cookie_regex, cookie_regex,
header, header,
(res: RegExpMatchArray) => { (res: RegExpMatchArray) => {
logger ('parsing cookie %s', res.groups?.name); log ('parsing cookie %s', res.groups?.name);
if (res.groups?.name === name) { if (res.groups?.name === name) {
logger ('found cookie'); log ('found cookie');
result = res.groups?.value as string; result = res.groups?.value as string;
} }
} }

View File

@ -1,3 +1,10 @@
/*
* Copyright (C) Sapphirecode - All Rights Reserved
* This file is part of Auth-Server-Helper which is released under MIT.
* See file 'LICENSE' for full license details.
* Created by Timo Hocker <timo@scode.ovh>, August 2022
*/
import build_debug from 'debug'; import build_debug from 'debug';
function debug (scope: string): build_debug.Debugger { function debug (scope: string): build_debug.Debugger {

21
lib/token_id.ts Normal file
View File

@ -0,0 +1,21 @@
import { create_salt } from '@sapphirecode/crypto-helper';
import { to_b58 } from '@sapphirecode/encoding-helper';
export function generate_token_id (valid_until: Date) {
const salt = create_salt ();
return `${to_b58 (salt, 'hex')};${valid_until.toISOString ()}`;
}
export function parse_token_id (id: string) {
// eslint-disable-next-line max-len
const regex = /^(?<hash>[A-HJ-NP-Za-km-z1-9]+);(?<date>\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d\.\d{3}Z)$/u;
const result = regex.exec (id);
if (result === null)
throw new Error (`invalid token id ${id}`);
if (typeof result.groups === 'undefined')
throw new Error ('invalid state');
return {
hash: result.groups.hash as string,
valid_until: new Date (result.groups.date as string)
};
}

View File

@ -1,6 +1,6 @@
{ {
"name": "@sapphirecode/auth-server-helper", "name": "@sapphirecode/auth-server-helper",
"version": "3.3.3", "version": "4.1.1",
"main": "dist/lib/index.js", "main": "dist/lib/index.js",
"author": { "author": {
"name": "Timo Hocker", "name": "Timo Hocker",

View File

@ -104,7 +104,7 @@ describe ('authority', () => {
const token = await auth.sign ('access_token', 60); const token = await auth.sign ('access_token', 60);
jasmine.clock () jasmine.clock ()
.tick (30000); .tick (30000);
bl.add_signature (token.id); await bl.add_signature (token.id);
const res = await auth.verify (token.signature); const res = await auth.verify (token.signature);
expect (res.authorized) expect (res.authorized)
.toBeFalse (); .toBeFalse ();
@ -144,7 +144,7 @@ describe ('authority', () => {
const token = await auth.sign ('refresh_token', 600); const token = await auth.sign ('refresh_token', 600);
jasmine.clock () jasmine.clock ()
.tick (30000); .tick (30000);
bl.add_signature (token.id); await bl.add_signature (token.id);
const res = await auth.verify (token.signature); const res = await auth.verify (token.signature);
expect (res.authorized) expect (res.authorized)
.toBeFalse (); .toBeFalse ();

View File

@ -6,10 +6,15 @@
*/ */
import blacklist, { Blacklist } from '../../lib/Blacklist'; import blacklist, { Blacklist } from '../../lib/Blacklist';
import { generate_token_id } from '../../lib/token_id';
import { clock_finalize, clock_setup } from '../Helper'; import { clock_finalize, clock_setup } from '../Helper';
// eslint-disable-next-line max-lines-per-function // eslint-disable-next-line max-lines-per-function
describe ('blacklist', () => { describe ('blacklist', () => {
const token1 = generate_token_id (new Date (Date.now () + 3600000));
const token2 = generate_token_id (new Date (Date.now () + 3600000));
const token3 = generate_token_id (new Date (Date.now () + 3600000));
beforeAll (() => { beforeAll (() => {
clock_setup (); clock_setup ();
}); });
@ -18,78 +23,89 @@ describe ('blacklist', () => {
clock_finalize (); clock_finalize ();
}); });
it ('should validate any string', () => { it ('should validate any string', async () => {
expect (blacklist.is_valid ('foo')) expect (await blacklist.is_valid (token1))
.toBeTrue (); .toBeTrue ();
expect (blacklist.is_valid ('bar')) expect (await blacklist.is_valid (token2))
.toBeTrue (); .toBeTrue ();
expect (blacklist.is_valid ('baz')) expect (await blacklist.is_valid (token3))
.toBeTrue (); .toBeTrue ();
}); });
it ('should blacklist strings', () => { it ('should blacklist strings', async () => {
blacklist.add_signature ('foo'); await blacklist.add_signature (token1);
blacklist.add_signature ('bar'); await blacklist.add_signature (token2);
expect (blacklist.is_valid ('foo')) expect (await blacklist.is_valid (token1))
.toBeFalse (); .toBeFalse ();
expect (blacklist.is_valid ('bar')) expect (await blacklist.is_valid (token2))
.toBeFalse (); .toBeFalse ();
expect (blacklist.is_valid ('baz')) expect (await blacklist.is_valid (token3))
.toBeTrue (); .toBeTrue ();
}); });
it ('should remove one string', () => { it ('should remove one string', async () => {
blacklist.remove_signature ('foo'); await blacklist.remove_signature (token1);
expect (blacklist.is_valid ('foo')) expect (await blacklist.is_valid (token1))
.toBeTrue (); .toBeTrue ();
expect (blacklist.is_valid ('bar')) expect (await blacklist.is_valid (token2))
.toBeFalse (); .toBeFalse ();
expect (blacklist.is_valid ('baz')) expect (await blacklist.is_valid (token3))
.toBeTrue (); .toBeTrue ();
}); });
it ('should clear after time', () => { it ('should clear after time', async () => {
jasmine.clock () jasmine.clock ()
.tick (5000); .tick (5000);
blacklist.add_signature ('baz'); await blacklist.add_signature (token3);
blacklist.clear (Date.now () - 100); await blacklist.clear (Date.now () - 100);
expect (blacklist.is_valid ('foo')) expect (await blacklist.is_valid (token1))
.toBeTrue (); .toBeTrue ();
expect (blacklist.is_valid ('bar')) expect (await blacklist.is_valid (token2))
.toBeTrue (); .toBeTrue ();
expect (blacklist.is_valid ('baz')) expect (await blacklist.is_valid (token3))
.toBeFalse (); .toBeFalse ();
}); });
it ('should clear all', () => { it ('should clear all', async () => {
blacklist.add_signature ('foo'); await blacklist.add_signature (token1);
blacklist.add_signature ('bar'); await blacklist.add_signature (token2);
blacklist.add_signature ('baz'); await blacklist.add_signature (token3);
expect (blacklist.is_valid ('foo')) expect (await blacklist.is_valid (token1))
.toBeFalse (); .toBeFalse ();
expect (blacklist.is_valid ('bar')) expect (await blacklist.is_valid (token2))
.toBeFalse (); .toBeFalse ();
expect (blacklist.is_valid ('baz')) expect (await blacklist.is_valid (token3))
.toBeFalse (); .toBeFalse ();
blacklist.clear (); await blacklist.clear ();
expect (blacklist.is_valid ('foo')) expect (await blacklist.is_valid (token1))
.toBeTrue (); .toBeTrue ();
expect (blacklist.is_valid ('bar')) expect (await blacklist.is_valid (token2))
.toBeTrue (); .toBeTrue ();
expect (blacklist.is_valid ('baz')) expect (await blacklist.is_valid (token3))
.toBeTrue (); .toBeTrue ();
}); });
it ('should export and import data', () => { it ('should export and import data', async () => {
blacklist.add_signature ('baz'); const time = new Date;
const exp = blacklist.export_blacklist (); const token = generate_token_id (time);
await blacklist.add_signature (token);
// eslint-disable-next-line dot-notation // eslint-disable-next-line dot-notation
expect (blacklist['_signatures']) expect (blacklist['_signatures'])
.toEqual (exp); .toEqual ([
{
token_id: token,
iat: time.getTime (),
valid_until: time
}
]);
const exp = blacklist.export_blacklist ();
expect (exp)
.toEqual ([ { token_id: token, iat: time.getTime () } ]);
const bl2 = (new Blacklist); const bl2 = (new Blacklist);
bl2.import_blacklist (exp); bl2.import_blacklist (exp);
// eslint-disable-next-line dot-notation // eslint-disable-next-line dot-notation
expect (bl2['_signatures']) expect (bl2['_signatures'])
.toEqual (exp); // eslint-disable-next-line dot-notation
.toEqual (blacklist['_signatures']);
}); });
}); });

View File

@ -36,8 +36,13 @@ describe ('gateway', () => {
g.logout (req); g.logout (req);
} }
res.writeHead (200); res.writeHead (200);
const con = req.connection as unknown as Record<string, unknown>; const data = {
res.end (JSON.stringify (con.auth)); ...g.get_info (req),
foo: g.has_permission (req, 'foo'),
bar: g.has_permission (req, 'bar')
};
res.end (JSON.stringify (data));
}; };
g.process_request (req, res, passed_handler); g.process_request (req, res, passed_handler);
}); });
@ -112,6 +117,8 @@ describe ('gateway', () => {
.toEqual (token.id); .toEqual (token.id);
expect (body.token_data) expect (body.token_data)
.toEqual ('foobar'); .toEqual ('foobar');
expect (body.permissions)
.toEqual ([]);
}); });
it ('should reject an outdated access token', async () => { it ('should reject an outdated access token', async () => {
@ -127,7 +134,7 @@ describe ('gateway', () => {
it ('should reject a blacklisted access token', async () => { it ('should reject a blacklisted access token', async () => {
const token = await authority.sign ('access_token', 60); const token = await authority.sign ('access_token', 60);
blacklist.add_signature (token.id); await blacklist.add_signature (token.id);
const resp = await get ({ authorization: `Bearer ${token.signature}` }); const resp = await get ({ authorization: `Bearer ${token.signature}` });
expect (resp.statusCode) expect (resp.statusCode)
.toEqual (302); .toEqual (302);
@ -192,10 +199,28 @@ describe ('gateway', () => {
expect (resp.statusCode) expect (resp.statusCode)
.toEqual (200); .toEqual (200);
const blacklisted = blacklist.export_blacklist () const blacklisted = blacklist.export_blacklist ()
.map ((v) => v.hash); .map ((v) => v.token_id);
expect (blacklisted) expect (blacklisted)
.toContain (token.id); .toContain (token.id);
expect (blacklisted) expect (blacklisted)
.toContain (refresh.id); .toContain (refresh.id);
}); });
it ('should correctly check permissions', async () => {
const token = await authority.sign (
'access_token',
60,
{ permissions: [ 'foo' ] }
);
const resp = await get ({ authorization: `Bearer ${token.signature}` });
expect (resp.statusCode)
.toEqual (200);
expect (JSON.parse (resp.body as string))
.toEqual ({
token_id: token.id,
permissions: [ 'foo' ],
foo: true,
bar: false
});
});
}); });

View File

@ -5,47 +5,71 @@
* Created by Timo Hocker <timo@scode.ovh>, August 2022 * Created by Timo Hocker <timo@scode.ovh>, August 2022
*/ */
/* eslint-disable dot-notation */
import { blacklist } from '../../lib';
import ks from '../../lib/KeyStore'; import ks from '../../lib/KeyStore';
import { redis } from '../../lib/Redis'; import { Redis } from '../../lib/Redis';
import { generate_token_id } from '../../lib/token_id';
import { clock_finalize, clock_setup } from '../Helper'; import { clock_finalize, clock_setup } from '../Helper';
const frame = 3600; const frame = 3600;
const redis_url = process.env.TEST_REDIS_URL || 'redis://localhost'; const redis_url = process.env.TEST_REDIS_URL || 'redis://localhost';
const redis = new Redis;
redis.connect (redis_url);
// eslint-disable-next-line max-lines-per-function
describe ('redis', () => { describe ('redis', () => {
beforeAll (() => { const token1 = generate_token_id (new Date (Date.now () + 3600000));
const token2 = generate_token_id (new Date (Date.now () + 3600000));
const token3 = generate_token_id (new Date (Date.now () + 3600000));
beforeAll (async () => {
ks.reset_instance (); ks.reset_instance ();
ks.sync_redis (redis_url); ks.sync_redis (redis_url);
await blacklist.clear ();
blacklist.sync_redis (redis_url);
clock_setup (); clock_setup ();
}); });
let iat1 = 0;
let iat2 = 0;
let k1 = '';
let k2 = '';
let i1 = '';
let i2 = '';
afterAll (() => clock_finalize ()); afterAll (() => clock_finalize ());
it ('should write and read all keys', async () => { it ('should generate two keys', async () => {
const iat1 = (new Date) iat1 = (new Date)
.getTime () / 1000; .getTime () / 1000;
await ks.get_sign_key (iat1, frame); await ks.get_sign_key (iat1, frame);
const k1 = await ks.get_key (iat1); k1 = await ks.get_key (iat1);
jasmine.clock () jasmine.clock ()
.tick (frame * 1000); .tick (frame * 1000);
const iat2 = (new Date) iat2 = (new Date)
.getTime () / 1000; .getTime () / 1000;
await ks.get_sign_key (iat2, frame); await ks.get_sign_key (iat2, frame);
const k2 = await ks.get_key (iat2); k2 = await ks.get_key (iat2);
// eslint-disable-next-line dot-notation // eslint-disable-next-line dot-notation
const index1 = ks['get_index'] (iat1); i1 = ks['get_index'] (iat1);
// eslint-disable-next-line dot-notation // eslint-disable-next-line dot-notation
const index2 = ks['get_index'] (iat2); i2 = ks['get_index'] (iat2);
});
// eslint-disable-next-line dot-notation it ('should have two keys in redis', async () => {
expect (JSON.parse (await redis['_redis']?.get (index1) as string).key) expect (JSON.parse (await redis['_redis']
?.get (`keystore_${i1}`) as string).key)
.toEqual (k1); .toEqual (k1);
// eslint-disable-next-line dot-notation expect (JSON.parse (await redis['_redis']
expect (JSON.parse (await redis['_redis']?.get (index2) as string).key) ?.get (`keystore_${i2}`) as string).key)
.toEqual (k2); .toEqual (k2);
});
it ('should read two keys with a new instance', async () => {
const old_instance = ks.instance_id; const old_instance = ks.instance_id;
ks.reset_instance (); ks.reset_instance ();
expectAsync (ks.get_key (iat1, old_instance)) expectAsync (ks.get_key (iat1, old_instance))
@ -59,4 +83,28 @@ describe ('redis', () => {
expect (await ks.get_key (iat2, old_instance)) expect (await ks.get_key (iat2, old_instance))
.toEqual (k2); .toEqual (k2);
}); });
it ('should add two keys to the blacklist', async () => {
await blacklist.add_signature (token1);
await blacklist.add_signature (token2);
});
it ('should have two keys in redis blacklist', async () => {
expect ((await redis['_redis']?.exists (`blacklist_${token1}`)) === 1)
.toBeTrue ();
expect ((await redis['_redis']?.exists (`blacklist_${token2}`)) === 1)
.toBeTrue ();
expect ((await redis['_redis']?.exists (`blacklist_${token3}`)) === 1)
.toBeFalse ();
});
it ('should read keys from redis', async () => {
blacklist['_signatures'].splice (0, blacklist['_signatures'].length);
expect (await blacklist.is_valid (token1))
.toBeFalse ();
expect (await blacklist.is_valid (token2))
.toBeFalse ();
expect (await blacklist.is_valid (token3))
.toBeTrue ();
});
}); });

22
test/spec/token_id.ts Normal file
View File

@ -0,0 +1,22 @@
import { generate_token_id, parse_token_id } from '../../lib/token_id';
import { clock_finalize, clock_setup } from '../Helper';
describe ('token_id', () => {
beforeAll (() => {
clock_setup ();
});
afterAll (() => {
clock_finalize ();
});
it ('should always generate valid tokens', () => {
for (let i = 0; i < 1000; i++) {
const date = new Date;
const token_id = generate_token_id (new Date);
const parsed = parse_token_id (token_id);
expect (parsed.valid_until)
.toEqual (date);
}
});
});

6728
yarn.lock

File diff suppressed because it is too large Load Diff