Compare commits

...

7 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
18 changed files with 1180 additions and 719 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,20 +160,23 @@ 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) => {
@ -181,7 +186,7 @@ app.get('logout',(req,res)=>{
// 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 {
@ -95,7 +96,8 @@ 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> {
const log = logger.extend ('allow_access'); const log = logger.extend ('allow_access');
log ('allowed access'); log ('allowed access');
@ -104,7 +106,7 @@ class AuthRequest {
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 };

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,7 +38,8 @@ interface SignatureResult {
interface SignatureOptions interface SignatureOptions
{ {
data?: unknown data?: unknown
next_module?: string next_module?: string,
permissions?: string[]
} }
class Authority { class Authority {
@ -48,6 +50,7 @@ class Authority {
authorized: false, authorized: false,
valid: false, valid: false,
type: 'none', type: 'none',
permissions: [],
id: '' id: ''
}; };
const data = await verify_signature_get_info ( const data = await verify_signature_get_info (
@ -74,7 +77,7 @@ class Authority {
log ('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))) {
log ('token is blacklisted'); log ('token is blacklisted');
result.error = 'blacklisted'; result.error = 'blacklisted';
return result; return result;
@ -83,6 +86,7 @@ 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;
log ( log (
@ -90,6 +94,7 @@ class Authority {
result.type, result.type,
result.next_module result.next_module
); );
log ('permissions %o', result.permissions);
return result; return result;
} }
@ -102,14 +107,17 @@ class Authority {
const log = logger.extend ('sign'); const log = logger.extend ('sign');
log ('signing new %s', type); 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);
log ('created token %s', attributes.id); log ('created token %s', attributes.id);

View File

@ -6,75 +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 (
before: number = Number.POSITIVE_INFINITY
): Promise<void> {
logger.extend ('clear') ('clearing blacklist'); 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.remove_signature (i); // 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.extend ('add_signature') ('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 (signature: number | string): void { public async remove_signature (signature: number | string): Promise<void> {
const log = logger.extend ('remove_signature'); const log = logger.extend ('remove_signature');
log ('removing signature from blacklist %s', signature); log ('removing signature from blacklist %s', signature);
let key = '';
if (typeof signature === 'string') { if (typeof signature === 'string') {
log ('received string, searching through signatures'); log ('received string, searching through signatures');
key = signature;
for (let i = this._signatures.length - 1; i >= 0; i--) { for (let i = this._signatures.length - 1; i >= 0; i--) {
if (this._signatures[i].hash === signature) if (this._signatures[i].token_id === signature) {
log ('removing sigature %s at %d', signature, i);
this._signatures.splice (i, 1); this._signatures.splice (i, 1);
} }
} }
}
else { else {
log ('received index, removing at index'); 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); 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> {
const log = logger.extend ('is_valid'); const log = logger.extend ('is_valid');
log ('checking signature for blacklist entry %s', hash); 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) {
log ('found matching blacklist entry'); log ('found matching blacklist entry');
return false; return false;
} }
} }
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'); log ('signature is not blacklisted');
return true; return true;
} }
public export_blacklist (): Signature[] { public export_blacklist (): ExportedSignature[] {
logger.extend ('export_blacklist') ('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.extend ('import_blacklist') ( logger.extend ('import_blacklist') (
'importing %d blacklist entries', 'importing %d blacklist entries',
data.length data.length
); );
this._signatures.push (...data); 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,6 +32,13 @@ 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 {
@ -97,7 +104,11 @@ class GatewayClass {
log ('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
};
log ('token valid: %s', ver.authorized); log ('token valid: %s', ver.authorized);
return ver.authorized; return ver.authorized;
@ -145,7 +156,8 @@ class GatewayClass {
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
}; };
log ('tokens refreshed'); log ('tokens refreshed');
@ -175,6 +187,22 @@ class GatewayClass {
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,
@ -183,7 +211,13 @@ class GatewayClass {
const log = logger.extend ('process_request'); const log = logger.extend ('process_request');
log ('processing incoming http request'); log ('processing incoming http request');
if (await this.authenticate (req, res)) { if (await this.authenticate (req, res)) {
log ('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 ();
} }
@ -209,11 +243,23 @@ class GatewayClass {
log ('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);
}
log ('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[]) || []
};
}
} }
export default function create_gateway (options: GatewayOptions): Gateway { export default function create_gateway (options: GatewayOptions): Gateway {

View File

@ -9,7 +9,6 @@ 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'; import { redis_key_store } from './RedisData/RedisKeyStore';
const logger = debug ('keystore'); const logger = debug ('keystore');
@ -20,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;
@ -29,7 +27,7 @@ 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.extend ('constructor') ( logger.extend ('constructor') (
'created keystore instance %s', 'created keystore instance %s',
@ -58,7 +56,6 @@ 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_key_store.set ({ ...result.public_key, index });
this._keys[index] = result; this._keys[index] = result;
} }
@ -135,11 +132,11 @@ class KeyStore {
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_key_store.get (index);
}
else { key = this._keys[index].public_key; } else
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');
@ -182,13 +179,11 @@ class KeyStore {
logger.extend ('reset_instance') ('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

@ -51,16 +51,14 @@ export class Redis {
log ('done'); log ('done');
} }
public get redis (): IORedis { protected get redis (): IORedis {
if (this._redis === null) if (this._redis === null)
throw new Error ('redis is not connected'); throw new Error ('redis is not connected');
return this._redis; return this._redis;
} }
public get is_active (): boolean { protected get is_active (): boolean {
return this._redis !== null; return this._redis !== null;
} }
} }
export const redis = (new Redis);

View File

@ -6,31 +6,48 @@
*/ */
import { debug } from '../debug'; import { debug } from '../debug';
import { redis } from '../Redis'; import { Redis } from '../Redis';
const logger = debug ('RedisBlacklistStore'); const logger = debug ('RedisBlacklistStore');
export class RedisBlacklistStore { export class RedisBlacklistStore extends Redis {
public async add (key: string): Promise<void> { public async add (key: string, valid_until: Date): Promise<void> {
const log = logger.extend ('set'); const log = logger.extend ('set');
log ('trying to add key %s to redis blacklist', key); log ('trying to add key %s to redis blacklist', key);
if (!redis.is_active) { if (!this.is_active) {
log ('redis is inactive, skipping'); log ('redis is inactive, skipping');
return; return;
} }
await redis.redis.sadd ('blacklist', key); await this.redis.setex (
`blacklist_${key}`,
Math.floor ((valid_until.getTime () - Date.now ()) / 1000),
1
);
log ('saved key'); 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> { public async get (key: string): Promise<boolean> {
const log = logger.extend ('get'); const log = logger.extend ('get');
log ('trying to find key %s in redis blacklist', key); log ('trying to find key %s in redis blacklist', key);
if (!redis.is_active) { if (!this.is_active) {
log ('redis is inactive, skipping'); log ('redis is inactive, skipping');
return false; return false;
} }
const res = await redis.redis.sismember ('blacklist', key) === 1; const res = await this.redis.exists (`blacklist_${key}`) === 1;
log ('found key %s', res); log ('found key %s', res);
return res; return res;
} }
} }
export const redis_blacklist_store = new RedisBlacklistStore;

View File

@ -7,15 +7,15 @@
import { debug } from '../debug'; import { debug } from '../debug';
import { LabelledKey } from '../Key'; import { LabelledKey } from '../Key';
import { redis } from '../Redis'; import { Redis } from '../Redis';
const logger = debug ('RedisKeyStore'); const logger = debug ('RedisKeyStore');
export class RedisKeyStore { export class RedisKeyStore extends Redis {
public async set (value: LabelledKey): Promise<void> { public async set (value: LabelledKey): Promise<void> {
const log = logger.extend ('set'); const log = logger.extend ('set');
log ('trying to set key %s to redis', value.index); log ('trying to set key %s to redis', value.index);
if (!redis.is_active) { if (!this.is_active) {
log ('redis is inactive, skipping'); log ('redis is inactive, skipping');
return; return;
} }
@ -24,7 +24,7 @@ export class RedisKeyStore {
.getTime ()) / 1000 .getTime ()) / 1000
); );
log ('key is valid for %d seconds', valid_for); log ('key is valid for %d seconds', valid_for);
await redis.redis.setex ( await this.redis.setex (
`keystore_${value.index}`, `keystore_${value.index}`,
valid_for, valid_for,
JSON.stringify (value) JSON.stringify (value)
@ -35,11 +35,11 @@ export class RedisKeyStore {
public async get (index: string): Promise<LabelledKey | null> { public async get (index: string): Promise<LabelledKey | null> {
const log = logger.extend ('get'); const log = logger.extend ('get');
log ('trying to get key %s from redis', index); log ('trying to get key %s from redis', index);
if (!redis.is_active) { if (!this.is_active) {
log ('redis is inactive, skipping'); log ('redis is inactive, skipping');
return null; return null;
} }
const res = await redis.redis.get (`keystore_${index}`); const res = await this.redis.get (`keystore_${index}`);
if (res === null) { if (res === null) {
log ('key not found in redis'); log ('key not found in redis');
return null; return null;

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,17 +5,30 @@
* 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 ();
}); });
@ -48,11 +61,9 @@ describe ('redis', () => {
}); });
it ('should have two keys in redis', async () => { it ('should have two keys in redis', async () => {
// eslint-disable-next-line dot-notation
expect (JSON.parse (await redis['_redis'] expect (JSON.parse (await redis['_redis']
?.get (`keystore_${i1}`) as string).key) ?.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 (`keystore_${i2}`) as string).key) ?.get (`keystore_${i2}`) as string).key)
.toEqual (k2); .toEqual (k2);
@ -72,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);
}
});
});

1334
yarn.lock

File diff suppressed because it is too large Load Diff