Compare commits
9 Commits
d5c136790e
...
master
Author | SHA1 | Date | |
---|---|---|---|
a41882de60
|
|||
ba9608829d | |||
910099285b | |||
6856ac718f | |||
6eb2009141 | |||
64d4f00629 | |||
31f739d4b8
|
|||
e80e3f9a94
|
|||
b7514941f0
|
11
CHANGELOG.md
11
CHANGELOG.md
@ -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
|
||||||
|
77
README.md
77
README.md
@ -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
|
||||||
|
@ -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'
|
||||||
|
@ -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,16 +38,19 @@ 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',
|
||||||
|
permissions: [],
|
||||||
id: ''
|
id: ''
|
||||||
};
|
};
|
||||||
const data = await verify_signature_get_info (
|
const data = await verify_signature_get_info (
|
||||||
@ -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 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
130
lib/Blacklist.ts
130
lib/Blacklist.ts
@ -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) {
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
await this.remove_signature (i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async add_signature (token_id: string): Promise<void> {
|
||||||
|
logger.extend ('add_signature') ('blacklisting signature %s', token_id);
|
||||||
|
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 async remove_signature (signature: number | string): Promise<void> {
|
||||||
|
const log = logger.extend ('remove_signature');
|
||||||
|
log ('removing signature from blacklist %s', signature);
|
||||||
|
let key = '';
|
||||||
|
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);
|
this._signatures.splice (i, 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
log (
|
||||||
|
'received index, removing signature %s at index %s',
|
||||||
|
this._signatures[signature].token_id,
|
||||||
|
signature
|
||||||
|
);
|
||||||
|
|
||||||
public add_signature (hash: string): void {
|
key = this._signatures[signature].token_id;
|
||||||
logger ('blacklisting signature %s', hash);
|
this._signatures.splice (signature, 1);
|
||||||
this._signatures.push ({ iat: Date.now (), hash });
|
}
|
||||||
|
await redis_blacklist_store.remove (key);
|
||||||
}
|
}
|
||||||
|
|
||||||
public remove_signature (hash: string): void {
|
public async is_valid (hash: string): Promise<boolean> {
|
||||||
logger ('removing signature from blacklist %s', hash);
|
const log = logger.extend ('is_valid');
|
||||||
for (let i = this._signatures.length - 1; i >= 0; i--) {
|
log ('checking signature for blacklist entry %s', hash);
|
||||||
if (this._signatures[i].hash === hash)
|
|
||||||
this._signatures.splice (i, 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public is_valid (hash: string): boolean {
|
|
||||||
logger ('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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
115
lib/Gateway.ts
115
lib/Gateway.ts
@ -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[]) || []
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
50
lib/Redis.ts
50
lib/Redis.ts
@ -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);
|
|
||||||
|
53
lib/RedisData/RedisBlacklistStore.ts
Normal file
53
lib/RedisData/RedisBlacklistStore.ts
Normal 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;
|
52
lib/RedisData/RedisKeyStore.ts
Normal file
52
lib/RedisData/RedisKeyStore.ts
Normal 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;
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
21
lib/token_id.ts
Normal 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)
|
||||||
|
};
|
||||||
|
}
|
@ -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",
|
||||||
|
@ -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 ();
|
||||||
|
@ -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']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -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
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -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
22
test/spec/token_id.ts
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
Reference in New Issue
Block a user