Compare commits

...

61 Commits

Author SHA1 Message Date
a41882de60 update
All checks were successful
continuous-integration/drone/push Build is passing
2023-02-09 11:58:10 +01:00
ba9608829d permissions, connection data reader
All checks were successful
continuous-integration/drone/push Build is passing
2022-09-12 12:30:06 +02:00
910099285b fix readme
Some checks failed
continuous-integration/drone/push Build is failing
2022-09-09 16:38:49 +02:00
6856ac718f fix
All checks were successful
continuous-integration/drone/push Build is passing
2022-09-09 16:02:25 +02:00
6eb2009141 fix
Some checks failed
continuous-integration/drone/push Build is failing
2022-09-09 15:53:38 +02:00
64d4f00629 blacklist with automatic garbage collector
Some checks failed
continuous-integration/drone/push Build is failing
2022-09-09 15:49:53 +02:00
31f739d4b8 blacklist sync
All checks were successful
continuous-integration/drone/push Build is passing
2022-08-27 16:39:07 +02:00
e80e3f9a94 improve debug, redis storage structure
Some checks failed
continuous-integration/drone/push Build is failing
2022-08-15 17:33:25 +02:00
b7514941f0 Revert "refactoring redis for multiple value classes"
This reverts commit d5c136790e.
2022-08-15 13:56:02 +02:00
d5c136790e refactoring redis for multiple value classes
Some checks failed
continuous-integration/drone/push Build is failing
2022-08-13 17:18:09 +02:00
43cf782511 fix
All checks were successful
continuous-integration/drone/push Build is passing
2022-08-10 16:18:05 +02:00
b43190d048 fix redis sync
Some checks failed
continuous-integration/drone/push Build is failing
2022-08-10 16:17:00 +02:00
7bb6dac737 fix
All checks were successful
continuous-integration/drone/push Build is passing
2022-08-10 11:11:39 +02:00
1009a9b8d5 catch key error
Some checks failed
continuous-integration/drone/push Build is failing
2022-08-10 11:08:14 +02:00
da36f87250 fix
All checks were successful
continuous-integration/drone/push Build is passing
2022-08-09 08:53:17 +02:00
cf2f9c0182 start redis server
All checks were successful
continuous-integration/drone/push Build is passing
2022-08-08 16:10:33 +02:00
4d69efd9f5 debug
Some checks failed
continuous-integration/drone/push Build is failing
2022-08-08 15:59:53 +02:00
fd26975559 redis sync
Some checks failed
continuous-integration/drone/push Build is failing
2022-08-08 15:52:56 +02:00
122bd7b574 fix refresh data carrying
All checks were successful
continuous-integration/drone/push Build is passing
2022-08-03 16:21:00 +02:00
84be087743 logout function
All checks were successful
continuous-integration/drone/push Build is passing
2022-05-02 13:30:30 +02:00
ec08f8f04e option to enable body parsing
All checks were successful
continuous-integration/drone/push Build is passing
2022-01-26 09:08:45 +01:00
cc8762e4ec cookie settings
All checks were successful
continuous-integration/drone/push Build is passing
2022-01-10 10:06:54 +01:00
3aaaf10fd9 improved cookie security
All checks were successful
continuous-integration/drone/push Build is passing
2022-01-08 22:10:21 +01:00
8f047f2700 fixed wrong return type
All checks were successful
continuous-integration/drone/push Build is passing
2022-01-05 13:01:32 +01:00
80a98704af fix
All checks were successful
continuous-integration/drone/push Build is passing
2022-01-05 12:58:00 +01:00
c7708f4bc0 fix
All checks were successful
continuous-integration/drone/push Build is passing
2022-01-05 12:35:18 +01:00
b58af27719 add debug logging
All checks were successful
continuous-integration/drone/push Build is passing
2022-01-05 12:32:04 +01:00
2a51e0a753 fix refresh cookie settings
All checks were successful
continuous-integration/drone/push Build is passing
2022-01-05 08:14:35 +01:00
22075489c2 automatic refresh tokens
All checks were successful
continuous-integration/drone/push Build is passing
2022-01-04 21:32:04 +01:00
1188e4573f fix promise not being awaited
All checks were successful
continuous-integration/drone/push Build is passing
2022-01-04 15:01:33 +01:00
d28be9e3f8 fix unreliable 'successful' flag, don't set content-type on leave_open
All checks were successful
continuous-integration/drone/push Build is passing
2022-01-04 13:39:10 +01:00
dab45e39a6 flag to leave request open on auth
All checks were successful
continuous-integration/drone/push Build is passing
2022-01-03 16:26:45 +01:00
4820bda8ca get boolean return from auth handler
All checks were successful
continuous-integration/drone/push Build is passing
2022-01-03 15:40:13 +01:00
86b07af63d fix
All checks were successful
continuous-integration/drone/push Build is passing
2022-01-03 14:46:12 +01:00
85a5f3c2fb allow gateway without redirection, manual request handling
Some checks failed
continuous-integration/drone/push Build is failing
2022-01-03 14:44:27 +01:00
c55ed33e53 fix line endings
Some checks failed
continuous-integration/drone/push Build is failing
2021-05-24 14:43:14 +02:00
3bc5538a69 formatting
All checks were successful
continuous-integration/drone/push Build is passing
2021-05-10 12:41:00 +02:00
e7ad5656e3 allow immediate redirect on auth
Some checks failed
continuous-integration/drone/push Build is failing
2021-05-10 12:26:56 +02:00
a3f021fdd2 clearing instances
All checks were successful
continuous-integration/drone/push Build is passing
2021-01-15 14:45:18 +01:00
d286548850 fix
All checks were successful
continuous-integration/drone/push Build is passing
2021-01-15 13:48:29 +01:00
e326c6c077 publish
Some checks failed
continuous-integration/drone/push Build is failing
2021-01-15 13:45:03 +01:00
ce58c0d204 simplify export data
Some checks failed
continuous-integration/drone/push Build is failing
2021-01-14 21:31:21 +01:00
9ec97d8aa2 fix
Some checks failed
continuous-integration/drone/push Build is failing
2021-01-12 22:04:24 +01:00
1af8c0702c test with additional cookies 2021-01-12 21:26:19 +01:00
e6039e78b1 allow autorization via body only
Some checks failed
continuous-integration/drone/push Build is failing
2021-01-12 21:19:56 +01:00
c5bc0855d7 blacklist import export
Some checks failed
continuous-integration/drone/push Build is failing
2021-01-09 12:20:14 +01:00
d6a40871c4 export/import keys
Some checks failed
continuous-integration/drone/push Build is failing
2021-01-08 13:30:53 +01:00
4c42a682d5 fixes
Some checks failed
continuous-integration/drone/push Build is failing
2021-01-07 15:43:54 +01:00
fd4f891b3e asymmetric keys import/export
Some checks failed
continuous-integration/drone/push Build is failing
2021-01-06 22:43:03 +01:00
adfeeaa52c asymmetric keys
Some checks failed
continuous-integration/drone/push Build is failing
2021-01-06 16:06:03 +01:00
1437316519 fix packaging
Some checks failed
continuous-integration/drone/push Build is failing
2021-01-06 12:17:04 +01:00
5df2577e71 allow bearer and other types of authorization in default handler
Some checks failed
continuous-integration/drone/push Build is failing
2021-01-06 11:38:56 +01:00
df8de9e0c8 fix
Some checks failed
continuous-integration/drone/push Build is failing
2021-01-06 11:15:56 +01:00
05f2e53a8f fix
Some checks failed
continuous-integration/drone/push Build is failing
2021-01-05 22:10:41 +01:00
8285e58337 fix
Some checks failed
continuous-integration/drone/push Build is failing
2021-01-05 21:35:45 +01:00
6cf6286fb8 package
Some checks failed
continuous-integration/drone/push Build is failing
2021-01-05 17:06:35 +01:00
872661a926 full documentation
Some checks failed
continuous-integration/drone/push Build is failing
2021-01-05 16:50:23 +01:00
48afa73ae8 fix
Some checks failed
continuous-integration/drone/push Build is failing
2021-01-05 15:59:06 +01:00
debb7debf1 allow attaching of custom data
Some checks failed
continuous-integration/drone/push Build is failing
2021-01-03 15:32:29 +01:00
80d04f7441 allow signed data storage
Some checks failed
continuous-integration/drone/push Build is failing
2021-01-03 15:13:03 +01:00
f39759bad9 complete redesign 2021-01-03 14:51:22 +01:00
31 changed files with 4441 additions and 1679 deletions

View File

@ -6,10 +6,10 @@ steps:
image: registry:5000/node-build
commands:
- yarn
- echo "process.exit(1)" > ci.js
# - curl https://git.scode.ovh/Timo/standard/raw/branch/master/ci.js > ci.js
- curl https://git.scode.ovh/Timo/standard/raw/branch/master/ci.js > ci.js
- name: build
image: registry:5000/node-build
commands:
- redis-server --daemonize yes
- node ci.js

54
CHANGELOG.md Normal file
View File

@ -0,0 +1,54 @@
# 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
- Verification Keys can now be synchronized through redis
## 3.2.0
- Logout function
## 3.1.0
- Option to enable body parsing
## 3.0.0
- Allows Cookies Parameters to be set
BREAKING:
- All cookie_name and refresh_cookie_name properties have been renamed to cookie and refresh_cookie and are now a settings object instead of a string
## 2.2.0
- Allow refresh tokens to be sent on a separate cookie
- Automatic token refresh if the access token is expired and the cookie header contains a valid refresh token
## 2.1.0
- Allow access to Gateway functions like authenticate, get_cookie_auth, get_header_auth, redirect, deny
- Allow Gateway to deny a request in case no redirect url is specified
## 2.0.0
Complete redesign
## 1.1.0
add user_id to res.connection, so request handlers can access the current user
## 1.0.0
initial release

273
README.md
View File

@ -1,8 +1,8 @@
# auth-server-helper
version: 0.0.0
version: 4.1.x
undefined
customizable and simple authentication
## Installation
@ -16,7 +16,274 @@ yarn:
## Usage
TODO: Add usage
### 1. put a gateway in front of the routes you want to secure
```js
const {create_gateway} = require('@sapphirecode/auth-server-helper');
const gateway = create_gateway({
redirect_url: '/auth', // if defined, unauthorized requests will be redirected
cookie: { name: 'auth_cookie' }, // if defined, access tokens will be read from or written to this cookie,
refresh_cookie: { name: 'refresh_cookie' }, // if defined, refresh tokens will be read and used to automatically refresh client tokens (requires the refresh_settings attribute)
refresh_settings: {
// same as settings for allow_access under section 2
// the options data, redirect_to and leave_open are not supported here
}
});
// express
app.use(gateway);
// node http
http.createServer((main_req, main_res) =>
gateway(main_req, main_res, (req, res) => {
// your request handler
});
);
```
the gateway will forward any authorized requests to the next handler and
redirect all others to the specified url
#### 1.1. Creating a gateway for manual processing of requests
```js
const {GatewayClass} = require('@sapphirecode/auth-server-helper');
const gateway = new GatewayClass({ /* options */ }); // options are the same as for create_gateway above
// process a request
if (gateway.authenticate(http_request)) { // returns true if request is valid and sets req.connection.token_id and .token_data
console.log('access granted');
} else {
gateway.redirect(response); // redirects the client, triggers deny if no redirect_url was set in options
// or
gateway.deny(response); // sends status 403
}
```
### 2. creating the auth endpoint
```js
const {create_auth_handler} = require('@sapphirecode/auth-server-helper');
const handler = create_auth_handler(
async (req) => {
if (req.user === 'foo' && req.password === 'bar')
const {access_token_id, refresh_token_id} = await req.allow_access({
access_token_expires_in: 600, // seconds until access tokens expire
include_refresh_token: true, // should the answer include a refresh token? default: false
refresh_token_expires_in: 3600, // seconds until refresh tokens expire (required if refresh tokens are generated)
data: {user: 'foo'}, // additional custom data to include in the token
});
if (req.user === 'part' && req.password === 'baz')
const part_id = await req.allow_part(
60, // seconds until part_token expires
'some_module', // next module handler (defined below)
{foo: 'bar'} // custom data to attach to the token
);
// all allow_ functions return a token id, which can later be used to invalidate specific tokens from the server side
req.deny();
},
{
refresh: {
/*...same options as allow_access */
}, // define the behaviour of refresh tokens. Refresh tokens will not be accepted if this option is undefined
modules: {
some_module(req) {
// request handlers for part_tokens
// access custom data:
const auth_data = req.request.connection.auth;
auth_data.token_id; // token id
auth_data.token_data; // custom data
// the same works in handlers after the gateway, information is always stored in request.connection.auth
},
},
cookie: { name: 'auth_cookie' }, // if defined, access tokens will be stored in this cookie,
refresh_cookie: { name: 'refresh_cookie' }, // if defined, refresh tokens will be stored in this cookie
parse_body: true // read the request body into a string (default false)
}
);
// express
app.use(handler);
// node http
// ... create server, on path /auth run the handler
handler(req, res); // the handler will also return true if allow_access or allow_part was called
```
after the auth handler, the request will be completed, no additional content
should be served here. (Read 2.1 for info on disabling this)
#### 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.
```js
allow_access({leave_open: true, ...});
allow_part(
60,
'some_module',
{foo: 'bar'},
true // additional flag to leave request open
);
invalid('error description', true);
deny(true);
```
if this flag is set, no data will be written to the response body and no data
will be sent. Status code and Headers will still be set.
### Defining Custom Cookie Settings
By default all cookies will be sent with 'Secure; HttpOnly; SameSite=Strict'
Attributes
In the appropriate settings object, you can set the following options:
```js
{
name: 'foo', // name of the cookies
secure: true, // option to enable or disable the Secure option default: true
http_only: true, // option to enable or disable HttpOnly default: true
same_site: 'Strict', // SameSite property (Strict, Lax or None) default: 'Strict'. Set this to null to disable
expires: 'Mon, 10 Jan 2022 09:28:00 GMT', // Expiry date of the cookie
max_age: 600, // Maximum age in Seconds
domain: 'example.com', // Domain property
path: '/cookies_here' // Path property
}
```
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
```js
const {blacklist} = require('@sapphirecode/auth-server-helper');
await blacklist.add_signature(token_id); // the token id is returned from any function that creates tokens
```
#### Logout function
```js
const {GatewayClass} = require('@sapphirecode/auth-server-helper');
const gateway = new GatewayClass({ /* options */ });
// create a new express route
app.get('logout', (req, res) => {
// call the gateway's logout function
gateway.logout(req);
// respond ok
res.status(200);
res.end();
});
```
### Exporting and importing public keys to validate tokens across server instances
```js
const {keystore} = require('@sapphirecode/auth-server-helper');
const export = keystore.export_verification_data();
// second instance
keystore.import_verification_data(export);
```
These keys can also be live synchronized with redis to allow sessions to be
shared between servers
```js
const {keystore} = require('@sapphirecode/auth-server-helper');
keystore.sync_redis('redis://localhost');
```
### Exporting and importing blacklist entries across server instances
```js
const {blacklist} = require('@sapphirecode/auth-server-helper');
const export = blacklist.export_blacklist();
// second instance
blacklist.import_blacklist(export);
```
### Clearing Keystore and Blacklist
Resetting the Keystore instance generates a new instance id and deletes all
imported or generated keys.
```js
const {keystore, blacklist} = require('@sapphirecode/auth-server-helper');
// clear keystore
keystore.reset_instance();
// clear blacklist
await blacklist.clear();
// clear blacklist items older than 10 seconds
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

View File

@ -1,13 +1,11 @@
{
"spec_dir": "test",
"spec_dir": "dist/test",
"spec_files": [
"spec/*.js",
"spec/*.ts"
"spec/*.js"
],
"helpers": [
"helpers/*.js",
"helpers/*.ts"
"helpers/*.js"
],
"stopSpecOnExpectationFailure": false,
"random": false

View File

@ -18,5 +18,6 @@ module.exports = {
Atomics: 'readonly',
SharedArrayBuffer: 'readonly'
},
parserOptions: { ecmaVersion: 2018 }
parserOptions: { ecmaVersion: 2018 },
rules: { 'node/no-unpublished-import': 'off' }
};

View File

@ -1,11 +1,26 @@
/*
* 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>, January 2021
*/
import { IncomingMessage, ServerResponse } from 'http';
import { to_utf8 } from '@sapphirecode/encoding-helper';
import auth from './Authority';
import { debug } from './debug';
import { build_cookie, CookieSettings } from './cookie';
const logger = debug ('auth');
interface AccessSettings {
access_token_expires_in: number
include_refresh_token?: boolean
refresh_token_expires_in?: number
redirect_to?: string
data?: unknown,
leave_open?: boolean
permissions?: string[]
}
interface AccessResult {
@ -13,7 +28,6 @@ interface AccessResult {
refresh_token_id?: string;
}
interface AccessResponse {
token_type: string;
access_token: string;
@ -22,6 +36,9 @@ interface AccessResponse {
refresh_expires_in?: number;
}
type AuthHandler =
(req: IncomingMessage, res: ServerResponse) => Promise<boolean>;
class AuthRequest {
public request: IncomingMessage;
public response: ServerResponse;
@ -30,35 +47,67 @@ class AuthRequest {
public user: string;
public password: string;
private _cookie_name?: string;
public is_bearer: boolean;
public token?: string;
public token_data?: unknown;
public token_id?: string;
public body: string;
private _cookie?: CookieSettings;
private _refresh_cookie?: CookieSettings;
private _is_successful: boolean;
public get is_successful (): boolean {
return this._is_successful;
}
public constructor (
req: IncomingMessage,
res: ServerResponse,
cookie?: string
body: string,
cookie?: CookieSettings,
refresh_cookie?: CookieSettings
) {
this.request = req;
this.response = res;
this.body = body;
this.is_basic = false;
this.is_bearer = false;
this.user = '';
this.password = '';
this._cookie_name = cookie;
this._cookie = cookie;
this._refresh_cookie = refresh_cookie;
this._is_successful = false;
logger.extend ('constructor') ('started processing new auth request');
}
private default_header () {
private default_header (set_content = true) {
this.response.setHeader ('Cache-Control', 'no-store');
this.response.setHeader ('Pragma', 'no-cache');
this.response.setHeader ('Content-Type', 'application/json');
if (set_content)
this.response.setHeader ('Content-Type', 'application/json');
}
public allow_access ({
// eslint-disable-next-line max-statements, max-lines-per-function
public async allow_access ({
access_token_expires_in,
include_refresh_token,
refresh_token_expires_in
}: AccessSettings): AccessResult {
this.default_header ();
refresh_token_expires_in,
redirect_to,
data,
leave_open,
permissions
}: AccessSettings): Promise<AccessResult> {
const log = logger.extend ('allow_access');
log ('allowed access');
this.default_header (typeof redirect_to !== 'string' && !leave_open);
const at = auth.sign ('access_token', access_token_expires_in);
const at = await auth.sign (
'access_token',
access_token_expires_in,
{ data, permissions }
);
const result: AccessResult = { access_token_id: at.id };
const res: AccessResponse = {
@ -67,94 +116,172 @@ class AuthRequest {
expires_in: access_token_expires_in
};
if (typeof this._cookie_name === 'string') {
this.response.setHeader (
'Set-Cookie',
`${this._cookie_name}=${at.signature}`
);
}
const cookies = [];
if (typeof this._cookie !== 'undefined')
cookies.push (build_cookie (this._cookie, at.signature));
if (include_refresh_token) {
log ('including refresh token');
if (typeof refresh_token_expires_in !== 'number')
throw new Error ('no expiry time defined for refresh tokens');
const rt = auth.sign ('refresh_token', refresh_token_expires_in);
const rt = await auth.sign (
'refresh_token',
refresh_token_expires_in,
{ data }
);
res.refresh_token = rt.signature;
res.refresh_expires_in = refresh_token_expires_in;
result.refresh_token_id = rt.id;
if (typeof this._refresh_cookie !== 'undefined')
cookies.push (build_cookie (this._refresh_cookie, rt.signature));
}
if (cookies.length > 0) {
log ('sending %d cookies', cookies.length);
this.response.setHeader (
'Set-Cookie',
cookies
);
}
this._is_successful = true;
if (typeof redirect_to === 'string') {
log ('redirecting to %s', redirect_to);
this.response.setHeader ('Location', redirect_to);
this.response.statusCode = 302;
if (!leave_open)
this.response.end ();
return result;
}
if (!leave_open) {
log ('finishing http request');
this.response.writeHead (200);
this.response.end (JSON.stringify (res));
}
this.response.writeHead (200);
this.response.end (JSON.stringify (res));
return result;
}
public invalid (error_description?: string) {
public async allow_part (
part_token_expires_in: number,
next_module: string,
data?: Record<string, unknown>,
leave_open = false
): Promise<string> {
const log = logger.extend ('allow_part');
log ('allowed part token');
this.default_header ();
this.response.writeHead (400);
this.response.end (JSON.stringify ({
error: 'invalid_request',
error_description
}));
const pt = await auth.sign (
'part_token',
part_token_expires_in,
{ next_module, data }
);
const res = {
token_type: 'bearer',
part_token: pt.signature,
expires_in: part_token_expires_in
};
if (!leave_open) {
log ('finishing http request');
this.response.writeHead (200);
this.response.end (JSON.stringify (res));
}
this._is_successful = true;
return pt.id;
}
public deny () {
public invalid (error_description?: string, leave_open = false): void {
const log = logger.extend ('invalid');
log ('rejecting invalid request');
this.default_header ();
this.response.writeHead (401);
this.response.end (JSON.stringify ({ error: 'invalid_client' }));
this.response.statusCode = 400;
if (!leave_open) {
log ('finishing http request');
this.response.end (JSON.stringify ({
error: 'invalid_request',
error_description
}));
}
}
public deny (leave_open = false): void {
const log = logger.extend ('deny');
log ('denied access');
this.default_header ();
this.response.statusCode = 401;
if (!leave_open) {
log ('finishing http request');
this.response.end (JSON.stringify ({ error: 'invalid_client' }));
}
}
}
type AuthRequestHandler = (req: AuthRequest) => void|Promise<void>;
type AuthRequestHandler = (req: AuthRequest) => Promise<void> | void;
interface CreateHandlerOptions {
refresh?: AccessSettings;
modules?: Record<string, AuthRequestHandler>;
cookie_name?: string;
cookie?: CookieSettings;
refresh_cookie?: CookieSettings;
parse_body?: boolean;
}
// eslint-disable-next-line max-lines-per-function
export default function create_auth_handler (
type ProcessRequestOptions = Omit<CreateHandlerOptions, 'parse_body'>
// eslint-disable-next-line max-lines-per-function, max-statements
async function process_request (
request: AuthRequest,
token: RegExpExecArray | null,
default_handler: AuthRequestHandler,
options?: CreateHandlerOptions
) {
return function process_request (
req: IncomingMessage,
res: ServerResponse
): Promise<void>|void {
const request = new AuthRequest (req, res, options?.cookie_name);
const token = (/(?<type>\S+) (?<token>.+)/ui)
.exec (req.headers.authorization as string);
options?: ProcessRequestOptions
): Promise<void> {
const log = logger.extend ('process_request');
if (token === null)
return default_handler (request);
if (token === null) {
request.deny ();
return Promise.resolve ();
}
if ((/Basic/ui).test (token?.groups?.type as string)) {
log ('found basic login data');
request.is_basic = true;
if ((/Basic/ui).test (token?.groups?.type as string)) {
request.is_basic = true;
let login = token?.groups?.token as string;
if (!login.includes (':'))
login = to_utf8 (login, 'base64');
const login_data = login.split (':');
request.user = login_data[0];
request.password = login_data[1];
let login = token?.groups?.token as string;
if (!login.includes (':'))
login = to_utf8 (login, 'base64');
const login_data = login.split (':');
request.user = login_data[0];
request.password = login_data[1];
return default_handler (request);
}
if ((/Bearer/ui).test (token?.groups?.type as string)) {
log ('found bearer login data');
request.is_bearer = true;
request.token = token?.groups?.token;
const token_data = await auth.verify (request.token as string);
if (!token_data.valid)
return default_handler (request);
}
const token_data = auth.verify (token?.groups?.token as string);
log ('bearer token is valid');
if (!token_data.valid) {
request.deny ();
return Promise.resolve ();
}
request.token_data = token_data.data;
request.token_id = token_data.id;
if (
typeof options !== 'undefined'
&& typeof options.refresh !== 'undefined'
&& token_data.type === 'refresh_token'
) {
log ('found refresh token, emitting new access token');
request.allow_access (options.refresh);
return Promise.resolve ();
}
@ -166,10 +293,70 @@ export default function create_auth_handler (
&& typeof token_data.next_module !== 'undefined'
&& Object.keys (options.modules)
.includes (token_data.next_module)
)
) {
log ('processing module %s', token_data.next_module);
return options.modules[token_data.next_module] (request);
}
request.invalid ('invalid bearer type');
return Promise.resolve ();
}
log ('no matching login method, triggering default handler');
return default_handler (request);
}
// eslint-disable-next-line max-lines-per-function
export default function create_auth_handler (
default_handler: AuthRequestHandler,
options?: CreateHandlerOptions
): AuthHandler {
logger.extend ('create_auth_handler') ('creating new auth handler');
if (
typeof options?.cookie !== 'undefined'
&& typeof options?.refresh_cookie !== 'undefined'
&& options.cookie.name === options.refresh_cookie.name
)
throw new Error ('access and refresh cookies cannot have the same name');
return async (
req: IncomingMessage,
res: ServerResponse
): Promise<boolean> => {
const body: string = options?.parse_body
? await new Promise ((resolve) => {
let data = '';
req.on ('data', (c) => {
data += c;
});
req.on ('end', () => {
resolve (data);
});
})
: '';
const request = new AuthRequest (
req,
res,
body,
options?.cookie,
options?.refresh_cookie
);
const token = (/(?<type>\S+) (?<token>.+)/ui)
.exec (req.headers.authorization as string);
await process_request (request, token, default_handler, options);
return request.is_successful;
};
}
export {
AccessSettings,
AccessResult,
AccessResponse,
AuthRequest,
AuthRequestHandler,
CreateHandlerOptions,
AuthHandler
};

View File

@ -6,21 +6,28 @@
*/
import {
create_salt,
sign_object,
verify_signature_get_info
} from '@sapphirecode/crypto-helper';
import keystore from './KeyStore';
import blacklist from './Blacklist';
import { debug } from './debug';
import { generate_token_id } from './token_id';
const logger = debug ('authority');
// eslint-disable-next-line no-shadow
type TokenType = 'access_token'|'refresh_token'|'part_token'|'none'
type TokenType = 'access_token' | 'none' | 'part_token' | 'refresh_token'
interface VerificationResult {
authorized: boolean;
valid: boolean;
type: TokenType;
id: string;
next_module?: string;
permissions?: string[];
data?: unknown;
error?: string;
}
interface SignatureResult {
@ -28,18 +35,29 @@ interface SignatureResult {
id: string;
}
interface SignatureOptions
{
data?: unknown
next_module?: string,
permissions?: string[]
}
class Authority {
public verify (key: string): VerificationResult {
public async verify (key: string): Promise<VerificationResult> {
const log = logger.extend ('verify');
log ('verifying token');
const result: VerificationResult = {
authorized: false,
valid: false,
type: 'none'
authorized: false,
valid: false,
type: 'none',
permissions: [],
id: ''
};
const data = verify_signature_get_info (
const data = await verify_signature_get_info (
key,
(info) => {
async (info) => {
try {
return keystore.get_key (info.iat / 1000);
return await keystore.get_key (info.iat / 1000, info.iss);
}
catch {
return '';
@ -48,39 +66,73 @@ class Authority {
(info) => info.valid_for * 1000
);
if (data === null)
if (data === null) {
log ('token invalid');
result.error = 'invalid signature';
return result;
}
result.id = data.id;
result.type = data.type;
if (!blacklist.is_valid (data.id))
log ('parsing token %s %s', result.type, result.id);
if (!(await blacklist.is_valid (data.id))) {
log ('token is blacklisted');
result.error = 'blacklisted';
return result;
}
result.valid = true;
result.authorized = result.type === 'access_token';
result.next_module = data.obj;
result.next_module = data.next_module;
result.permissions = data.permissions;
result.data = data.obj;
log (
'valid %s; targeting module %s',
result.type,
result.next_module
);
log ('permissions %o', result.permissions);
return result;
}
public sign (
public async sign (
type: TokenType,
valid_for: number,
next_module?: string
): SignatureResult {
options?: SignatureOptions
): Promise<SignatureResult> {
const log = logger.extend ('sign');
log ('signing new %s', type);
const time = Date.now ();
const key = keystore.get_key (time / 1000, valid_for);
const valid_until = time + (valid_for * 1e3);
const key = await keystore.get_sign_key (time / 1000, valid_for);
const attributes = {
id: create_salt (),
iat: time,
id: generate_token_id (new Date (valid_until)),
iat: time,
iss: keystore.instance_id,
type,
valid_for
valid_for,
valid_until,
next_module: options?.next_module,
permissions: options?.permissions
};
const signature = sign_object (next_module, key, attributes);
const signature = sign_object (options?.data, key, attributes);
log ('created token %s', attributes.id);
return { id: attributes.id, signature };
}
}
const auth = (new Authority);
export {
TokenType,
VerificationResult,
SignatureResult,
SignatureOptions,
Authority
};
export default auth;

View File

@ -5,45 +5,146 @@
* Created by Timo Hocker <timo@scode.ovh>, December 2020
*/
import { debug } from './debug';
import { redis_blacklist_store } from './RedisData/RedisBlacklistStore';
import { parse_token_id } from './token_id';
const logger = debug ('blacklist');
interface Signature {
hash: string;
iat: Date;
token_id: string;
iat: number;
valid_until: Date;
}
interface ExportedSignature {
token_id: string;
iat: number;
}
class Blacklist {
private _signatures: Signature[];
private _interval: NodeJS.Timeout;
public constructor () {
this._signatures = [];
this._interval = setInterval (
this.garbage_collect.bind (this),
3600000
);
}
public clear_before (date: Date) {
public async clear (
before: number = Number.POSITIVE_INFINITY
): Promise<void> {
logger.extend ('clear') ('clearing blacklist');
for (let i = this._signatures.length - 1; i >= 0; i--) {
if (this._signatures[i].iat < date)
this._signatures.splice (i, 1);
if (this._signatures[i].iat < before) {
// eslint-disable-next-line no-await-in-loop
await this.remove_signature (i);
}
}
}
public add_signature (hash: string) {
this._signatures.push ({ iat: (new Date), hash });
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 remove_signature (hash:string) {
for (let i = this._signatures.length - 1; i >= 0; i--) {
if (this._signatures[i].hash === hash)
this._signatures.splice (i, 1);
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);
}
}
}
else {
log (
'received index, removing signature %s at index %s',
this._signatures[signature].token_id,
signature
);
key = this._signatures[signature].token_id;
this._signatures.splice (signature, 1);
}
await redis_blacklist_store.remove (key);
}
public is_valid (hash: string) {
public async is_valid (hash: string): Promise<boolean> {
const log = logger.extend ('is_valid');
log ('checking signature for blacklist entry %s', hash);
for (const sig of this._signatures) {
if (sig.hash === hash)
if (sig.token_id === hash) {
log ('found matching blacklist entry');
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');
return true;
}
public export_blacklist (): ExportedSignature[] {
logger.extend ('export_blacklist') ('exporting blacklist');
return this._signatures.map ((v) => ({
iat: v.iat,
token_id: v.token_id
}));
}
public import_blacklist (data: ExportedSignature[]): void {
logger.extend ('import_blacklist') (
'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);
}
}
}
}
const bl = (new Blacklist);
export { Blacklist };
export default bl;

View File

@ -6,77 +6,260 @@
*/
import { IncomingMessage, ServerResponse } from 'http';
import { run_regex } from '@sapphirecode/utilities';
import authority from './Authority';
import { AuthRequest, AccessSettings } from './AuthHandler';
import { debug } from './debug';
import { extract_cookie, CookieSettings } from './cookie';
import blacklist from './Blacklist';
const logger = debug ('gateway');
type AnyFunc = (...args: unknown[]) => unknown;
type Gateway = (
req: IncomingMessage,
res: ServerResponse, next: AnyFunc
res: ServerResponse,
next: AnyFunc
) => unknown;
interface RefreshSettings extends AccessSettings {
leave_open?: never;
redirect_to?: never;
data?: never;
}
interface GatewayOptions {
redirect_url: string;
cookie_name?: string;
redirect_url?: string;
cookie?: CookieSettings;
refresh_cookie?: CookieSettings;
refresh_settings?: RefreshSettings;
require_permissions?: string[];
}
interface ConnectionInfo {
token_id: string
token_data: unknown
permissions: string[]
}
class GatewayClass {
private _options: GatewayOptions;
public constructor (options: GatewayOptions) {
public constructor (options: GatewayOptions = {}) {
const log = logger.extend ('constructor');
log ('creating new gateway');
if (
typeof options?.cookie !== 'undefined'
&& typeof options?.refresh_cookie !== 'undefined'
&& options.cookie.name === options.refresh_cookie.name
)
throw new Error ('access and refresh cookies cannot have the same name');
this._options = options;
}
private redirect (res: ServerResponse): void {
public deny (res: ServerResponse): void {
logger.extend ('deny') ('denied http request');
res.statusCode = 403;
res.end ();
}
public redirect (res: ServerResponse): void {
const log = logger.extend ('redirect');
log ('redirecting http request to %s', this._options.redirect_url);
if (typeof this._options.redirect_url !== 'string') {
log ('no redirect url defined');
this.deny (res);
return;
}
res.statusCode = 302;
res.setHeader ('Location', this._options.redirect_url);
res.end ();
}
private get_header_auth (req: IncomingMessage): string | null {
public get_header_auth (req: IncomingMessage): string | null {
const log = logger.extend ('get_header_auth');
log ('extracting authorization header');
const auth_header = req.headers.authorization;
const auth = (/(?<type>\w+) (?<data>.*)/u).exec (auth_header || '');
if (auth === null)
return null;
if (auth.groups?.type !== 'Bearer')
return null;
log ('found bearer token');
return auth.groups?.data;
}
private get_cookie_auth (req: IncomingMessage): string | null {
if (typeof this._options.cookie_name === 'undefined')
return null;
let auth = null;
run_regex (
/(?:^|;)(?<name>[^;=]+)=(?<value>[^;]+)/gu,
req.headers.cookie,
(res: RegExpMatchArray) => {
if (res.groups?.name === this._options.cookie_name)
auth = res.groups?.value;
}
);
return auth;
}
private authenticate (req: IncomingMessage): boolean {
public async try_access (req: IncomingMessage): Promise<boolean> {
const log = logger.extend ('try_access');
log ('authenticating incoming request');
let auth = this.get_header_auth (req);
if (auth === null)
auth = this.get_cookie_auth (req);
if (auth === null)
auth = extract_cookie (this._options.cookie?.name, req.headers.cookie);
if (auth === null) {
log ('found no auth token');
return false;
}
return authority.verify (auth).authorized;
const ver = await authority.verify (auth);
log ('setting connection info');
const con = req.connection as unknown as Record<string, unknown>;
con.auth = {
token_id: ver.id,
token_data: ver.data,
permissions: ver.permissions
};
log ('token valid: %s', ver.authorized);
return ver.authorized;
}
public process_request (
public async try_refresh (
req: IncomingMessage,
res: ServerResponse
): Promise<boolean> {
const log = logger.extend ('try_refresh');
if (
typeof this._options.refresh_cookie === 'undefined'
|| typeof this._options.refresh_settings === 'undefined'
)
return false;
log ('trying to apply refresh token');
const refresh = extract_cookie (
this._options.refresh_cookie.name,
req.headers.cookie
);
if (refresh === null) {
log ('could not find refresh token');
return false;
}
const ver = await authority.verify (refresh);
if (ver.type === 'refresh_token' && ver.valid) {
log ('refresh token valid, generating new tokens');
const auth_request = new AuthRequest (
req,
res,
'',
this._options.cookie,
this._options.refresh_cookie
);
const refresh_result = await auth_request.allow_access ({
...this._options.refresh_settings,
data: ver.data,
leave_open: true
});
log ('setting connection info');
const con = req.connection as unknown as Record<string, unknown>;
con.auth = {
token_id: refresh_result.access_token_id,
token_data: ver.data,
permissions: ver.permissions
};
log ('tokens refreshed');
return true;
}
log ('refresh token invalid');
return false;
}
public async authenticate (
req: IncomingMessage,
res: ServerResponse
): Promise<boolean> {
const log = logger.extend ('authenticate');
log ('trying to authenticate http request');
if (await this.try_access (req)) {
log ('authenticated via access_token');
return true;
}
if (await this.try_refresh (req, res)) {
log ('authenticated via refresh_token');
return true;
}
log ('could not verify session');
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 (
req: IncomingMessage,
res: ServerResponse,
next: AnyFunc
): unknown {
if (this.authenticate (req))
): Promise<unknown> {
const log = logger.extend ('process_request');
log ('processing incoming http request');
if (await this.authenticate (req, res)) {
log ('authentification successful');
log ('checking permissions');
if (!this.check_permissions (req))
return this.redirect (res);
log ('authorization successful. calling next handler');
return next ();
}
log ('failed to authenticate, redirecting client');
return this.redirect (res);
}
public async logout (req: IncomingMessage): Promise<void> {
const log = logger.extend ('logout');
log ('invalidating all submitted tokens');
const auth_strings = [
this.get_header_auth (req),
extract_cookie (this._options.cookie?.name, req.headers.cookie),
extract_cookie (this._options.refresh_cookie?.name, req.headers.cookie)
];
const tokens = (
await Promise.all (
auth_strings
.filter ((v) => v !== null)
.map ((v) => authority.verify (v as string))
)
).filter ((v) => v.valid);
log ('found %d tokens: %O', tokens.length, tokens);
for (const token of tokens) {
// eslint-disable-next-line no-await-in-loop
await blacklist.add_signature (token.id);
}
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 {
@ -84,4 +267,4 @@ export default function create_gateway (options: GatewayOptions): Gateway {
return g.process_request.bind (g);
}
export { Gateway, AnyFunc };
export { AnyFunc, Gateway, GatewayOptions, GatewayClass, RefreshSettings };

23
lib/Key.ts Normal file
View File

@ -0,0 +1,23 @@
/*
* 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 {
key: string;
valid_until: number;
}
export interface LabelledKey extends Key {
index: string;
}
export interface KeyPair {
private_key?: Key;
public_key: Key;
}
export type KeyStoreData = Record<string, KeyPair>;
export type KeyStoreExport = LabelledKey[];

View File

@ -5,33 +5,188 @@
* Created by Timo Hocker <timo@scode.ovh>, December 2020
*/
import { create_salt } from '@sapphirecode/crypto-helper';
import { generate_keypair, random_hex } from '@sapphirecode/crypto-helper';
import { to_b58 } from '@sapphirecode/encoding-helper';
import { debug } from './debug';
import { KeyStoreData, KeyStoreExport } from './Key';
import { redis_key_store } from './RedisData/RedisKeyStore';
const logger = debug ('keystore');
const renew_interval = 3600;
class KeyStore {
private _keys: Record<string, string> = {};
private _keys: KeyStoreData = {};
private _interval: NodeJS.Timeout;
private _instance: string;
public get_key (iat: number, valid_for = 0): string {
const key = Math.floor (iat / 60)
public get instance_id (): string {
return this._instance;
}
public constructor () {
this._interval = setInterval (() => {
this.garbage_collect ();
}, renew_interval * 1000);
this._instance = to_b58 (random_hex (16), 'hex');
logger.extend ('constructor') (
'created keystore instance %s',
this._instance
);
}
private get_index (iat: number, instance = this._instance): string {
return instance + Math.floor (iat / renew_interval)
.toFixed (0);
}
if (typeof this._keys[key] === 'string')
return this._keys[key];
private async create_key (index: string, valid_for: number): Promise<void> {
const log = logger.extend ('create_key');
log ('generating new key');
const time = (new Date)
.getTime ();
const pair = await generate_keypair ();
const result = {
private_key: {
key: pair.private_key,
valid_until: time + (renew_interval * 1000)
},
public_key: {
key: pair.public_key,
valid_until: time + (valid_for * 1000)
}
};
await redis_key_store.set ({ ...result.public_key, index });
this._keys[index] = result;
}
if (valid_for !== 0) {
if ((iat + valid_for) * 1000 < (new Date)
.getTime ())
throw new Error ('cannot create already expired keys');
private garbage_collect (): void {
const log = logger.extend ('garbage_collect');
const time = (new Date)
.getTime ();
const keys = Object.keys (this._keys);
for (const index of keys) {
const entry = this._keys[index];
if (typeof entry.private_key !== 'undefined'
&& entry.private_key.valid_until < time
) {
log ('deleting expired private key');
delete entry.private_key;
}
this._keys[key] = create_salt ();
setTimeout (() => {
delete this._keys[key];
}, (valid_for + 5) * 1000);
return this._keys[key];
if (entry.public_key.valid_until < time) {
log ('deleting expired key pair');
delete this._keys[index];
}
}
}
public async get_sign_key (
iat: number,
valid_for: number,
instance?: string
): Promise<string> {
const log = logger.extend ('get_sign_key');
log (
'querying key from %s for timestamp %d, valid for %d',
instance,
iat,
valid_for
);
if (valid_for <= 0)
throw new Error ('cannot create infinitely valid key');
if ((iat + 1) * 1000 < (new Date)
.getTime ())
throw new Error ('cannot access already expired keys');
const index = this.get_index (iat, instance);
const valid_until = (new Date)
.getTime () + (valid_for * 1000);
if (typeof this._keys[index] !== 'undefined') {
log ('loading existing key');
const key = this._keys[index];
if (typeof key.private_key === 'undefined')
throw new Error ('cannot access already expired keys');
if (key.public_key.valid_until < valid_until) {
log ('updating key valid timespan to match new value');
key.public_key.valid_until = valid_until;
}
return key.private_key?.key as string;
}
throw new Error ('key could not be found');
log ('key does not exist, creating a new one');
await this.create_key (index, valid_for);
return this._keys[index].private_key?.key as string;
}
public async get_key (iat: number, instance?: string): Promise<string> {
const log = logger.extend ('get_key');
log ('querying public key from %s for timestamp %d', instance, iat);
const index = this.get_index (iat, instance);
let key = null;
if (typeof this._keys[index] === 'undefined')
key = await redis_key_store.get (index);
else
key = this._keys[index].public_key;
if (key === null)
throw new Error ('key could not be found');
return key.key;
}
public export_verification_data (): KeyStoreExport {
const log = logger.extend ('export_verification_data');
log ('exporting public keys');
log ('cleaning up before export');
this.garbage_collect ();
const out: KeyStoreExport = [];
for (const index of Object.keys (this._keys)) {
log ('exporting key %s', index);
out.push ({ ...this._keys[index].public_key, index });
}
return out;
}
public import_verification_data (data: KeyStoreExport): void {
const log = logger.extend ('import_verification_data');
log ('importing %d public keys', data.length);
for (const key of data) {
log ('importing key %s', key.index);
if (typeof this._keys[key.index] !== 'undefined')
throw new Error ('cannot import to the same instance');
this._keys[key.index] = {
public_key: {
key: key.key,
valid_until: key.valid_until
}
};
}
log ('running garbage collector');
this.garbage_collect ();
}
public reset_instance (): void {
logger.extend ('reset_instance') ('resetting keystore');
this._instance = to_b58 (random_hex (16), 'hex');
this._keys = {};
redis_key_store.disconnect ();
}
public sync_redis (url: string): void {
redis_key_store.connect (url);
}
}
const ks: KeyStore = (new KeyStore);
export default ks;
export { KeyStore };

64
lib/Redis.ts Normal file
View File

@ -0,0 +1,64 @@
/*
* 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 IORedis from 'ioredis';
import { debug } from './debug';
const logger = debug ('redis');
export class Redis {
private _redis: IORedis | null = null;
public connect (url: string): void {
const log = logger.extend ('connect');
log ('connecting to redis instance %s', url);
if (this._redis !== null) {
log ('disconnecting existing redis client');
this.disconnect ();
}
this._redis = new IORedis (url);
this._redis.on ('connect', () => {
log ('connected');
});
this._redis.on ('ready', () => {
log ('ready');
});
this._redis.on ('error', (err) => {
log ('error %o', err);
});
this._redis.on ('reconnecting', () => {
log ('reconnecting');
});
this._redis.on ('end', () => {
log ('connection ended');
});
}
public disconnect (): void {
const log = logger.extend ('disconnect');
log ('disconnecting redis client');
if (this._redis === null) {
log ('redis is inactive, skipping');
return;
}
this._redis.quit ();
this._redis = null;
log ('done');
}
protected get redis (): IORedis {
if (this._redis === null)
throw new Error ('redis is not connected');
return this._redis;
}
protected get is_active (): boolean {
return this._redis !== null;
}
}

View File

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

View File

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

86
lib/cookie.ts Normal file
View File

@ -0,0 +1,86 @@
/*
* 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 { debug } from './debug';
const logger = debug ('cookies');
type SameSiteValue = 'Lax' | 'None' | 'Strict';
interface CookieSettings {
name: string;
secure?: boolean;
http_only?: boolean;
same_site?: SameSiteValue|null;
expires?: string;
max_age?: number;
domain?: string;
path?: string;
}
const default_settings: Omit<CookieSettings, 'name'> = {
secure: true,
http_only: true,
same_site: 'Strict'
};
function build_cookie (
settings: CookieSettings,
value: string
): string {
const local_settings = { ...default_settings, ...settings };
const sections = [ `${local_settings.name}=${value}` ];
if (local_settings.secure)
sections.push ('Secure');
if (local_settings.http_only)
sections.push ('HttpOnly');
if (
typeof local_settings.same_site !== 'undefined'
&& local_settings.same_site !== null
)
sections.push (`SameSite=${local_settings.same_site}`);
if (typeof local_settings.expires !== 'undefined')
sections.push (`Expires=${local_settings.expires}`);
if (typeof local_settings.max_age !== 'undefined')
sections.push (`Max-Age=${local_settings.max_age}`);
if (typeof local_settings.domain !== 'undefined')
sections.push (`Domain=${local_settings.domain}`);
if (typeof local_settings.path !== 'undefined')
sections.push (`Path=${local_settings.path}`);
return sections.join ('; ');
}
function extract_cookie (
name: string|undefined,
header: string|undefined
): string| null {
const log = logger.extend ('extract_cookie');
log (`extracting cookie ${name}`);
const cookie_regex = /(?:^|;)\s*(?<name>[^;=]+)=(?<value>[^;]+)/gu;
let result = null;
run_regex (
cookie_regex,
header,
(res: RegExpMatchArray) => {
log ('parsing cookie %s', res.groups?.name);
if (res.groups?.name === name) {
log ('found cookie');
result = res.groups?.value as string;
}
}
);
return result;
}
export { build_cookie, extract_cookie, SameSiteValue, CookieSettings };

15
lib/debug.ts Normal file
View File

@ -0,0 +1,15 @@
/*
* 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';
function debug (scope: string): build_debug.Debugger {
const namespace = `sapphirecode:auth-server-helper:${scope}`;
return build_debug (namespace);
}
export { debug };

74
lib/index.ts Normal file
View File

@ -0,0 +1,74 @@
/*
* 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>, January 2021
*/
/* eslint-disable import/no-namespace */
import create_auth_handler, {
AccessResponse,
CreateHandlerOptions,
AuthRequestHandler,
AuthRequest,
AccessSettings,
AccessResult,
AuthHandler
} from './AuthHandler';
import authority, {
VerificationResult,
TokenType,
SignatureResult,
SignatureOptions,
Authority
} from './Authority';
import blacklist, { Blacklist } from './Blacklist';
import create_gateway, {
GatewayOptions,
GatewayClass,
Gateway,
AnyFunc,
RefreshSettings
} from './Gateway';
import keystore, { KeyStore } from './KeyStore';
import {
KeyStoreExport,
LabelledKey, Key
} from './Key';
import {
CookieSettings,
SameSiteValue
} from './cookie';
export {
create_gateway,
create_auth_handler,
blacklist,
authority,
keystore,
AccessResponse,
CreateHandlerOptions,
AuthRequestHandler,
AuthRequest,
AuthHandler,
AccessSettings,
AccessResult,
VerificationResult,
TokenType,
SignatureResult,
SignatureOptions,
Authority,
Blacklist,
GatewayOptions,
GatewayClass,
Gateway,
RefreshSettings,
AnyFunc,
KeyStore,
KeyStoreExport,
LabelledKey,
Key,
CookieSettings,
SameSiteValue
};

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,37 +1,44 @@
{
"name": "@sapphirecode/auth-server-helper",
"version": "2.0.0",
"main": "dist/index.js",
"version": "4.1.1",
"main": "dist/lib/index.js",
"author": {
"name": "Timo Hocker",
"email": "timo@scode.ovh"
},
"description": "authentication middleware for express",
"repository": {
"type": "git",
"url": "https://git.scode.ovh/timo/auth-server-helper.git"
},
"bugs": "https://git.scode.ovh/timo/auth-server-helper",
"description": "authentication middleware for node http and express",
"license": "MIT",
"devDependencies": {
"@ert78gb/jasmine-ts": "^0.3.1",
"@sapphirecode/eslint-config-ts": "^1.1.27",
"@stryker-mutator/core": "^4.3.1",
"@stryker-mutator/jasmine-runner": "^4.3.1",
"@types/jasmine": "^3.6.2",
"@types/node": "^10.0.0",
"eslint": "^7.14.0",
"jasmine": "^3.6.3",
"@stryker-mutator/core": "^6.1.2",
"@stryker-mutator/jasmine-runner": "^6.1.2",
"@types/debug": "^4.1.7",
"@types/jasmine": "^4.0.3",
"@types/node": "^18.6.4",
"eslint": "^8.21.0",
"jasmine": "^4.3.0",
"nyc": "^15.1.0",
"ts-node": "^9.1.1",
"ts-node": "^10.9.1",
"typescript": "^4.1.2"
},
"scripts": {
"lint": "eslint . --ext .js,.jsx,.ts,.tsx,.vue,.mjs",
"test": "nyc jasmine-ts --config=\"jasmine.json\"",
"pretest": "yarn compile",
"test": "nyc jasmine --config=\"jasmine.json\"",
"mutate": "stryker run",
"precompile": "rm -rf dist",
"compile": "tsc"
},
"files": [
"LICENSE",
"*.js",
"*.ts",
"*.d.ts"
"lib/**/*.ts",
"dist/**/*.js",
"dist/**/*.d.ts",
"dist/**/*.map"
],
"keywords": [
"authentication",
@ -39,9 +46,11 @@
"middleware"
],
"dependencies": {
"@sapphirecode/crypto-helper": "^1.2.2",
"@sapphirecode/crypto-helper": "^2.0.0",
"@sapphirecode/encoding-helper": "^1.1.0",
"@sapphirecode/utilities": "^1.8.8"
"@sapphirecode/utilities": "^1.8.8",
"debug": "^4.3.3",
"ioredis": "^5.2.2"
},
"engines": {
"node": ">=10.0.0"

View File

@ -19,5 +19,6 @@ module.exports = {
testRunner: 'jasmine',
jasmineConfigFile: 'jasmine.json',
coverageAnalysis: 'perTest',
mutate: [ 'lib/*.ts' ]
mutate: [ 'lib/*.ts' ],
tsconfigFile: 'tsconfig.json'
};

View File

@ -1,23 +1,41 @@
/*
* 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>, January 2021
*/
/* eslint-disable no-console */
import http from 'http';
import ks from '../lib/KeyStore';
export class Response extends http.IncomingMessage {
body?: string;
}
export function get (
headers: http.OutgoingHttpHeaders = {}
// eslint-disable-next-line default-param-last
headers: http.OutgoingHttpHeaders = {},
body?: string|null,
path = ''
): Promise<Response> {
return new Promise ((resolve) => {
http.get ('http://localhost:3000', { headers }, (res: Response) => {
let body = '';
const req = http.request (`http://localhost:3000${path}`, {
headers,
method: typeof body === 'string' ? 'POST' : 'GET'
}, (res: Response) => {
let data = '';
res.on ('data', (d) => {
body += d;
data += d;
});
res.on ('end', () => {
res.body = body;
res.body = data;
resolve (res);
});
});
if (typeof body === 'string')
req.write (body);
req.end ();
});
}
@ -26,3 +44,38 @@ export function modify_signature (signature: string): string {
dec[1] = '';
return dec.join ('.');
}
/* eslint-disable dot-notation */
export function assert_keystore_state (): void {
const set = ks['_keys'];
const keys = Object.keys (set);
if (keys.length !== 0) {
const has_sign = keys.filter (
(v) => typeof set[v].private_key !== 'undefined'
).length;
console.warn ('keystore gc not running!');
console.warn (`${keys.length} keys with ${has_sign} signature keys left`);
ks['_keys'] = {};
}
}
/* eslint-enable dot-notation */
export function clock_setup (): void {
assert_keystore_state ();
const date = (new Date);
date.setHours (0, 0, 2, 0);
jasmine.clock ()
.install ();
jasmine.clock ()
.mockDate (date);
}
export function clock_finalize (): void {
jasmine.clock ()
.tick (30 * 24 * 60 * 60 * 1000);
// eslint-disable-next-line dot-notation
ks['garbage_collect'] ();
jasmine.clock ()
.uninstall ();
}

View File

@ -1,9 +1,21 @@
/*
* 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>, January 2021
*/
/* eslint-disable max-lines */
import http, { IncomingMessage, ServerResponse } from 'http';
import { to_b64 } from '@sapphirecode/encoding-helper';
import auth from '../../lib/Authority';
import { get, modify_signature, Response } from '../Helper';
import create_auth_handler from '../../lib/AuthHandler';
import {
clock_finalize,
clock_setup,
get, modify_signature, Response
} from '../Helper';
import { create_auth_handler } from '../../lib/index';
import { build_cookie, extract_cookie } from '../../lib/cookie';
const expires_seconds = 600;
const refresh_expires_seconds = 3600;
@ -26,8 +38,8 @@ function check_headers (resp: Response): CheckHeaderResult {
return { data, at, rt };
}
function check_token (token: string, type: string):void {
const v = auth.verify (token);
async function check_token (token: string|null, type: string): Promise<void> {
const v = await auth.verify (token || '');
expect (v.valid)
.toEqual (true);
expect (v.authorized)
@ -41,39 +53,102 @@ function check_token (token: string, type: string):void {
// eslint-disable-next-line max-lines-per-function
describe ('auth handler', () => {
let server: http.Server|null = null;
// eslint-disable-next-line max-lines-per-function
beforeAll (() => {
const ah = create_auth_handler ((req) => {
if (!req.is_basic) {
req.invalid ('unknown autorization type');
clock_setup ();
// eslint-disable-next-line complexity, max-lines-per-function
const ah = create_auth_handler (async (req) => {
if (!req.is_basic && !req.is_bearer) {
let body_auth = false;
try {
const data = JSON.parse (req.body);
if (data.username === 'foo' && data.password === 'bar') {
req.allow_access ({
access_token_expires_in: expires_seconds,
include_refresh_token: true,
refresh_token_expires_in: refresh_expires_seconds
});
body_auth = true;
}
}
catch {
body_auth = false;
}
if (!body_auth)
req.invalid ('unknown authorization type');
}
else if (req.user !== 'foo' || req.password !== 'bar') {
else if (req.is_bearer) {
req.deny ();
}
else {
else if (req.user === 'foo' && req.password === 'bar') {
req.allow_access ({
access_token_expires_in: expires_seconds,
include_refresh_token: true,
refresh_token_expires_in: refresh_expires_seconds
});
}
else if (req.user === 'part' && req.password === 'bar') {
req.allow_part (part_expires_seconds, 'two_factor');
}
else if (req.user === 'red' && req.password === 'irect') {
req.allow_access ({
access_token_expires_in: expires_seconds,
redirect_to: '/redirected'
});
}
else if (req.user === 'leave' && req.password === 'open') {
req.response.setHeader ('Content-Type', 'text/plain');
await req.allow_access ({
access_token_expires_in: expires_seconds,
leave_open: true
});
req.response.write ('custom response, ');
(req.response.connection as unknown as Record<string, unknown>)
.append_flag = true;
}
else {
req.deny ();
}
}, {
cookie_name: 'cookie_jar',
refresh: {
cookie: { name: 'cookie_jar' },
refresh_cookie: { name: 'mint_cookies' },
refresh: {
access_token_expires_in: expires_seconds,
refresh_token_expires_in: refresh_expires_seconds,
include_refresh_token: true
}
},
modules: {
two_factor (request) {
if (request.body === 'letmein') {
request.allow_access ({
access_token_expires_in: expires_seconds,
include_refresh_token: true,
refresh_token_expires_in: refresh_expires_seconds
});
}
else { request.deny (); }
}
},
parse_body: true
});
server = http.createServer ((req: IncomingMessage, res: ServerResponse) => {
ah (req, res);
server = http.createServer (async (
req: IncomingMessage,
res: ServerResponse
) => {
const is_successful = await ah (req, res);
if ((res.connection as unknown as Record<string, unknown>).append_flag)
res.end (String (is_successful));
});
server.listen (3000);
});
jasmine.clock ()
.install ();
jasmine.clock ()
.mockDate (new Date);
afterAll (() => {
if (server === null)
throw new Error ('server is null');
server.close ();
clock_finalize ();
});
it ('auth test sequence', async () => {
@ -85,13 +160,15 @@ describe ('auth handler', () => {
expect (res1.data.token_type)
.toEqual ('bearer');
expect (resp1.headers['set-cookie'])
.toContain (`cookie_jar=${res1.at}`);
.toContain (build_cookie ({ name: 'cookie_jar' }, res1.at as string));
expect (resp1.headers['set-cookie'])
.toContain (build_cookie ({ name: 'mint_cookies' }, res1.rt as string));
check_token (res1.at as string, 'access_token');
await check_token (res1.at as string, 'access_token');
expect (res1.data.expires_in)
.toEqual (expires_seconds);
check_token (res1.rt as string, 'refresh_token');
await check_token (res1.rt as string, 'refresh_token');
expect (res1.data.refresh_expires_in)
.toEqual (refresh_expires_seconds);
@ -104,14 +181,16 @@ describe ('auth handler', () => {
expect (res2.data.token_type)
.toEqual ('bearer');
expect (resp2.headers['set-cookie'])
.toContain (`cookie_jar=${res2.at}`);
.toContain (build_cookie ({ name: 'cookie_jar' }, res2.at as string));
expect (resp2.headers['set-cookie'])
.toContain (build_cookie ({ name: 'mint_cookies' }, res2.rt as string));
check_token (res2.at as string, 'access_token');
await check_token (res2.at as string, 'access_token');
expect (res2.data.expires_in)
.toEqual (expires_seconds);
expect (res2.at).not.toEqual (res1.at);
check_token (res2.rt as string, 'refresh_token');
await check_token (res2.rt as string, 'refresh_token');
expect (res2.data.refresh_expires_in)
.toEqual (refresh_expires_seconds);
expect (res2.rt).not.toEqual (res1.rt);
@ -127,7 +206,6 @@ describe ('auth handler', () => {
});
it ('should allow base64 login', async () => {
// get initial access and refresh tokens
const resp1 = await get ({ authorization: `Basic ${to_b64 ('foo:bar')}` });
expect (resp1.statusCode)
.toEqual (200);
@ -135,13 +213,40 @@ describe ('auth handler', () => {
expect (res1.data.token_type)
.toEqual ('bearer');
expect (resp1.headers['set-cookie'])
.toContain (`cookie_jar=${res1.at}`);
.toContain (build_cookie ({ name: 'cookie_jar' }, res1.at as string));
expect (resp1.headers['set-cookie'])
.toContain (build_cookie ({ name: 'mint_cookies' }, res1.rt as string));
check_token (res1.at as string, 'access_token');
await check_token (res1.at as string, 'access_token');
expect (res1.data.expires_in)
.toEqual (expires_seconds);
check_token (res1.rt as string, 'refresh_token');
await check_token (res1.rt as string, 'refresh_token');
expect (res1.data.refresh_expires_in)
.toEqual (refresh_expires_seconds);
});
it ('should allow body login', async () => {
const resp1 = await get (
// eslint-disable-next-line @typescript-eslint/naming-convention
{ 'Content-Type': 'application/json' },
JSON.stringify ({ username: 'foo', password: 'bar' })
);
expect (resp1.statusCode)
.toEqual (200);
const res1 = check_headers (resp1);
expect (res1.data.token_type)
.toEqual ('bearer');
expect (resp1.headers['set-cookie'])
.toContain (build_cookie ({ name: 'cookie_jar' }, res1.at as string));
expect (resp1.headers['set-cookie'])
.toContain (build_cookie ({ name: 'mint_cookies' }, res1.rt as string));
await check_token (res1.at as string, 'access_token');
expect (res1.data.expires_in)
.toEqual (expires_seconds);
await check_token (res1.rt as string, 'refresh_token');
expect (res1.data.refresh_expires_in)
.toEqual (refresh_expires_seconds);
});
@ -149,10 +254,13 @@ describe ('auth handler', () => {
it ('should reject invalid requests', async () => {
const resp1 = await get ();
expect (resp1.statusCode)
.toEqual (401);
.toEqual (400);
const res1 = check_headers (resp1);
expect (res1.data)
.toEqual ({ error: 'invalid_client' });
.toEqual ({
error: 'invalid_request',
error_description: 'unknown authorization type'
});
const resp2a = await get ({ authorization: 'Basic foo:bar' });
const res2a = check_headers (resp2a);
@ -182,8 +290,7 @@ describe ('auth handler', () => {
.toEqual ({ error: 'invalid_client' });
});
xit ('should process part token', async () => {
it ('should process part token', async () => {
const resp1 = await get ({ authorization: 'Basic part:bar' });
expect (resp1.statusCode)
.toEqual (200);
@ -192,10 +299,11 @@ describe ('auth handler', () => {
.toEqual ('bearer');
expect (res1.data.expires_in)
.toEqual (part_expires_seconds);
check_token (res1.data.part_token as string, 'part_token');
await check_token (res1.data.part_token as string, 'part_token');
const resp2 = await get (
{ authorization: `Bearer ${res1.data.part_token}` }
{ authorization: `Bearer ${res1.data.part_token}` },
'letmein'
);
expect (resp2.statusCode)
.toEqual (200);
@ -203,26 +311,68 @@ describe ('auth handler', () => {
expect (res2.data.token_type)
.toEqual ('bearer');
expect (resp2.headers['set-cookie'])
.toContain (`cookie_jar=${res2.at}`);
.toContain (build_cookie ({ name: 'cookie_jar' }, res2.at as string));
expect (resp2.headers['set-cookie'])
.toContain (build_cookie ({ name: 'mint_cookies' }, res2.rt as string));
check_token (res2.at as string, 'access_token');
await check_token (res2.at as string, 'access_token');
expect (res2.data.expires_in)
.toEqual (expires_seconds);
expect (res2.at).not.toEqual (res1.at);
check_token (res2.rt as string, 'refresh_token');
await check_token (res2.rt as string, 'refresh_token');
expect (res2.data.refresh_expires_in)
.toEqual (refresh_expires_seconds);
expect (res2.rt).not.toEqual (res1.rt);
});
afterAll (() => {
if (server === null)
throw new Error ('server is null');
server.close ();
jasmine.clock ()
.tick (24 * 60 * 60 * 1000);
jasmine.clock ()
.uninstall ();
it ('should do immediate redirect', async () => {
const resp1 = await get ({ authorization: 'Basic red:irect' });
expect (resp1.statusCode)
.toEqual (302);
expect (resp1.headers.location)
.toEqual ('/redirected');
const signature = extract_cookie (
'cookie_jar',
(resp1.headers['set-cookie'] || []).join ('\n')
);
await check_token (signature, 'access_token');
});
it ('should handle any authorization type', async () => {
const resp = await get ({ authorization: 'Foo asdefg' });
expect (resp.statusCode)
.toEqual (400);
expect (JSON.parse (resp.body as string))
.toEqual ({
error: 'invalid_request',
error_description: 'unknown authorization type'
});
});
it ('should not set content-type when leave-open is specified', async () => {
const resp1 = await get ({ authorization: 'Basic leave:open' });
expect (resp1.statusCode)
.toEqual (200);
expect (resp1.headers['content-type'])
.toEqual ('text/plain');
expect (resp1.body)
.toEqual ('custom response, true');
const signature = extract_cookie (
'cookie_jar',
(resp1.headers['set-cookie'] || []).join ('\n')
);
expect (signature).not.toEqual ('');
await check_token (signature, 'access_token');
});
it ('should disallow access and refresh cookies with the same name', () => {
expect (() => {
create_auth_handler (() => Promise.resolve (), {
cookie: { name: 'foo' },
refresh_cookie: { name: 'foo' }
});
})
.toThrowError ('access and refresh cookies cannot have the same name');
});
});

View File

@ -7,29 +7,27 @@
import auth from '../../lib/Authority';
import bl from '../../lib/Blacklist';
import { modify_signature } from '../Helper';
import {
clock_finalize,
clock_setup,
modify_signature
} from '../Helper';
// eslint-disable-next-line max-lines-per-function
describe ('authority', () => {
beforeEach (() => {
jasmine.clock ()
.install ();
jasmine.clock ()
.mockDate (new Date);
clock_setup ();
});
afterEach (() => {
jasmine.clock ()
.tick (24 * 60 * 60 * 1000);
jasmine.clock ()
.uninstall ();
clock_finalize ();
});
it ('should create an access token', () => {
const token = auth.sign ('access_token', 60);
it ('should create an access token', async () => {
const token = await auth.sign ('access_token', 60);
jasmine.clock ()
.tick (30000);
const res = auth.verify (token.signature);
const res = await auth.verify (token.signature);
expect (res.authorized)
.toBeTrue ();
expect (res.valid)
@ -38,13 +36,17 @@ describe ('authority', () => {
.toEqual ('access_token');
expect (res.next_module)
.toBeUndefined ();
expect (res.id)
.toEqual (token.id);
expect (res.error)
.toBeUndefined ();
});
it ('should create a refresh token', () => {
const token = auth.sign ('refresh_token', 600);
it ('should create a refresh token', async () => {
const token = await auth.sign ('refresh_token', 600);
jasmine.clock ()
.tick (30000);
const res = auth.verify (token.signature);
const res = await auth.verify (token.signature);
expect (res.authorized)
.toBeFalse ();
expect (res.valid)
@ -53,13 +55,17 @@ describe ('authority', () => {
.toEqual ('refresh_token');
expect (res.next_module)
.toBeUndefined ();
expect (res.id)
.toEqual (token.id);
expect (res.error)
.toBeUndefined ();
});
it ('should create a part token', () => {
const token = auth.sign ('part_token', 60, '2fa');
it ('should create a part token', async () => {
const token = await auth.sign ('part_token', 60, { next_module: '2fa' });
jasmine.clock ()
.tick (30000);
const res = auth.verify (token.signature);
const res = await auth.verify (token.signature);
expect (res.authorized)
.toBeFalse ();
expect (res.valid)
@ -68,14 +74,18 @@ describe ('authority', () => {
.toEqual ('part_token');
expect (res.next_module)
.toEqual ('2fa');
expect (res.id)
.toEqual (token.id);
expect (res.error)
.toBeUndefined ();
});
it ('should reject an invalid access token', () => {
const token = auth.sign ('access_token', 60);
it ('should reject an invalid access token', async () => {
const token = await auth.sign ('access_token', 60);
token.signature = modify_signature (token.signature);
jasmine.clock ()
.tick (30000);
const res = auth.verify (token.signature);
const res = await auth.verify (token.signature);
expect (res.authorized)
.toBeFalse ();
expect (res.valid)
@ -84,14 +94,18 @@ describe ('authority', () => {
.toEqual ('none');
expect (res.next_module)
.toBeUndefined ();
expect (res.id)
.toEqual ('');
expect (res.error)
.toEqual ('invalid signature');
});
it ('should reject blacklisted access token', () => {
const token = auth.sign ('access_token', 60);
it ('should reject blacklisted access token', async () => {
const token = await auth.sign ('access_token', 60);
jasmine.clock ()
.tick (30000);
bl.add_signature (token.id);
const res = auth.verify (token.signature);
await bl.add_signature (token.id);
const res = await auth.verify (token.signature);
expect (res.authorized)
.toBeFalse ();
expect (res.valid)
@ -100,14 +114,18 @@ describe ('authority', () => {
.toEqual ('access_token');
expect (res.next_module)
.toBeUndefined ();
expect (res.id)
.toEqual (token.id);
expect (res.error)
.toEqual ('blacklisted');
});
it ('should reject an invalid refresh token', () => {
const token = auth.sign ('refresh_token', 600);
it ('should reject an invalid refresh token', async () => {
const token = await auth.sign ('refresh_token', 600);
token.signature = modify_signature (token.signature);
jasmine.clock ()
.tick (30000);
const res = auth.verify (token.signature);
const res = await auth.verify (token.signature);
expect (res.authorized)
.toBeFalse ();
expect (res.valid)
@ -116,14 +134,18 @@ describe ('authority', () => {
.toEqual ('none');
expect (res.next_module)
.toBeUndefined ();
expect (res.id)
.toEqual ('');
expect (res.error)
.toEqual ('invalid signature');
});
it ('should reject a blacklisted refresh token', () => {
const token = auth.sign ('refresh_token', 600);
it ('should reject a blacklisted refresh token', async () => {
const token = await auth.sign ('refresh_token', 600);
jasmine.clock ()
.tick (30000);
bl.add_signature (token.id);
const res = auth.verify (token.signature);
await bl.add_signature (token.id);
const res = await auth.verify (token.signature);
expect (res.authorized)
.toBeFalse ();
expect (res.valid)
@ -132,5 +154,9 @@ describe ('authority', () => {
.toEqual ('refresh_token');
expect (res.next_module)
.toBeUndefined ();
expect (res.id)
.toEqual (token.id);
expect (res.error)
.toEqual ('blacklisted');
});
});

View File

@ -5,62 +5,107 @@
* Created by Timo Hocker <timo@scode.ovh>, December 2020
*/
import 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';
// eslint-disable-next-line max-lines-per-function
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 (() => {
jasmine.clock ()
.install ();
jasmine.clock ()
.mockDate (new Date);
});
it ('should validate any string', () => {
expect (blacklist.is_valid ('foo'))
.toBeTrue ();
expect (blacklist.is_valid ('bar'))
.toBeTrue ();
expect (blacklist.is_valid ('baz'))
.toBeTrue ();
});
it ('should blacklist strings', () => {
blacklist.add_signature ('foo');
blacklist.add_signature ('bar');
expect (blacklist.is_valid ('foo'))
.toBeFalse ();
expect (blacklist.is_valid ('bar'))
.toBeFalse ();
expect (blacklist.is_valid ('baz'))
.toBeTrue ();
});
it ('should remove one string', () => {
blacklist.remove_signature ('foo');
expect (blacklist.is_valid ('foo'))
.toBeTrue ();
expect (blacklist.is_valid ('bar'))
.toBeFalse ();
expect (blacklist.is_valid ('baz'))
.toBeTrue ();
});
it ('should clear after time', () => {
jasmine.clock ()
.tick (5000);
blacklist.add_signature ('baz');
blacklist.clear_before (new Date (Date.now () - 100));
expect (blacklist.is_valid ('foo'))
.toBeTrue ();
expect (blacklist.is_valid ('bar'))
.toBeTrue ();
expect (blacklist.is_valid ('baz'))
.toBeFalse ();
clock_setup ();
});
afterAll (() => {
clock_finalize ();
});
it ('should validate any string', async () => {
expect (await blacklist.is_valid (token1))
.toBeTrue ();
expect (await blacklist.is_valid (token2))
.toBeTrue ();
expect (await blacklist.is_valid (token3))
.toBeTrue ();
});
it ('should blacklist strings', async () => {
await blacklist.add_signature (token1);
await blacklist.add_signature (token2);
expect (await blacklist.is_valid (token1))
.toBeFalse ();
expect (await blacklist.is_valid (token2))
.toBeFalse ();
expect (await blacklist.is_valid (token3))
.toBeTrue ();
});
it ('should remove one string', async () => {
await blacklist.remove_signature (token1);
expect (await blacklist.is_valid (token1))
.toBeTrue ();
expect (await blacklist.is_valid (token2))
.toBeFalse ();
expect (await blacklist.is_valid (token3))
.toBeTrue ();
});
it ('should clear after time', async () => {
jasmine.clock ()
.uninstall ();
.tick (5000);
await blacklist.add_signature (token3);
await blacklist.clear (Date.now () - 100);
expect (await blacklist.is_valid (token1))
.toBeTrue ();
expect (await blacklist.is_valid (token2))
.toBeTrue ();
expect (await blacklist.is_valid (token3))
.toBeFalse ();
});
it ('should clear all', async () => {
await blacklist.add_signature (token1);
await blacklist.add_signature (token2);
await blacklist.add_signature (token3);
expect (await blacklist.is_valid (token1))
.toBeFalse ();
expect (await blacklist.is_valid (token2))
.toBeFalse ();
expect (await blacklist.is_valid (token3))
.toBeFalse ();
await blacklist.clear ();
expect (await blacklist.is_valid (token1))
.toBeTrue ();
expect (await blacklist.is_valid (token2))
.toBeTrue ();
expect (await blacklist.is_valid (token3))
.toBeTrue ();
});
it ('should export and import data', async () => {
const time = new Date;
const token = generate_token_id (time);
await blacklist.add_signature (token);
// eslint-disable-next-line dot-notation
expect (blacklist['_signatures'])
.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);
bl2.import_blacklist (exp);
// eslint-disable-next-line dot-notation
expect (bl2['_signatures'])
// eslint-disable-next-line dot-notation
.toEqual (blacklist['_signatures']);
});
});

View File

@ -1,30 +1,50 @@
/*
* 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>, January 2021
*/
import http from 'http';
import gateway from '../../lib/Gateway';
import { GatewayClass, create_gateway } from '../../lib/index';
import authority from '../../lib/Authority';
import blacklist from '../../lib/Blacklist';
import { get } from '../Helper';
import { clock_finalize, clock_setup, get } from '../Helper';
// eslint-disable-next-line max-lines-per-function
describe ('gateway', () => {
let server: http.Server|null = null;
beforeAll (() => {
jasmine.clock ()
.install ();
jasmine.clock ()
.mockDate (new Date);
clock_setup ();
const g = gateway ({
redirect_url: 'http://localhost/auth',
cookie_name: 'cookie_jar'
const g = new GatewayClass ({
redirect_url: 'http://localhost/auth',
cookie: { name: 'cookie_jar' },
refresh_cookie: { name: 'mint_cookies' },
refresh_settings: {
access_token_expires_in: 600,
include_refresh_token: true,
refresh_token_expires_in: 3600
}
});
server = http.createServer ((req, res) => {
const passed_handler = () => {
if (typeof req.url !== 'undefined') {
if (req.url.endsWith ('logout'))
g.logout (req);
}
res.writeHead (200);
res.end ('passed');
const data = {
...g.get_info (req),
foo: g.has_permission (req, 'foo'),
bar: g.has_permission (req, 'bar')
};
res.end (JSON.stringify (data));
};
g (req, res, passed_handler);
g.process_request (req, res, passed_handler);
});
server.listen (3000);
});
@ -34,10 +54,7 @@ describe ('gateway', () => {
throw new Error ('server is null');
server.close ();
jasmine.clock ()
.tick (24 * 60 * 60 * 1000);
jasmine.clock ()
.uninstall ();
clock_finalize ();
});
it ('should redirect any unauthorized request', async () => {
@ -49,25 +66,63 @@ describe ('gateway', () => {
});
it ('should allow a valid access token', async () => {
const token = authority.sign ('access_token', 60);
const token = await authority.sign ('access_token', 60);
const resp = await get ({ authorization: `Bearer ${token.signature}` });
expect (resp.statusCode)
.toEqual (200);
expect (resp.body)
.toEqual ('passed');
expect (JSON.parse (resp.body as string).token_id)
.toEqual (token.id);
});
it ('should allow a valid access token using cookies', async () => {
const token = authority.sign ('access_token', 60);
const resp = await get ({ cookie: `cookie_jar=${token.signature}` });
const token = await authority.sign ('access_token', 60);
const resp = await get (
{ cookie: `foo=bar;cookie_jar=${token.signature};asd=efg` }
);
expect (resp.statusCode)
.toEqual (200);
expect (resp.body)
.toEqual ('passed');
expect (JSON.parse (resp.body as string).token_id)
.toEqual (token.id);
});
it ('should automatically return new tokens', async () => {
const token = await authority.sign ('access_token', 60, { data: 'foobar' });
const refresh = await authority.sign (
'refresh_token',
3600,
{ data: 'foobar' }
);
jasmine.clock ()
.tick (70000);
const resp = await get (
// eslint-disable-next-line max-len
{ cookie: `foo=bar;cookie_jar=${token.signature};asd=efg;mint_cookies=${refresh.signature}` }
);
expect (resp.statusCode)
.toEqual (200);
expect (JSON.parse (resp.body as string).token_id)
.not
.toEqual (token.id);
expect (JSON.parse (resp.body as string).token_data)
.toEqual ('foobar');
});
it ('should correctly deliver token data', async () => {
const token = await authority.sign ('access_token', 60, { data: 'foobar' });
const resp = await get ({ authorization: `Bearer ${token.signature}` });
expect (resp.statusCode)
.toEqual (200);
const body = JSON.parse (resp.body as string);
expect (body.token_id)
.toEqual (token.id);
expect (body.token_data)
.toEqual ('foobar');
expect (body.permissions)
.toEqual ([]);
});
it ('should reject an outdated access token', async () => {
const token = authority.sign ('access_token', 60);
const token = await authority.sign ('access_token', 60);
jasmine.clock ()
.tick (70000);
const resp = await get ({ authorization: `Bearer ${token.signature}` });
@ -78,8 +133,8 @@ describe ('gateway', () => {
});
it ('should reject a blacklisted access token', async () => {
const token = authority.sign ('access_token', 60);
blacklist.add_signature (token.id);
const token = await authority.sign ('access_token', 60);
await blacklist.add_signature (token.id);
const resp = await get ({ authorization: `Bearer ${token.signature}` });
expect (resp.statusCode)
.toEqual (302);
@ -88,7 +143,7 @@ describe ('gateway', () => {
});
it ('should reject any refresh_token', async () => {
const token = authority.sign ('refresh_token', 60);
const token = await authority.sign ('refresh_token', 60);
const resp = await get ({ authorization: `Bearer ${token.signature}` });
expect (resp.statusCode)
.toEqual (302);
@ -97,7 +152,7 @@ describe ('gateway', () => {
});
it ('should reject any part_token', async () => {
const token = authority.sign ('part_token', 60);
const token = await authority.sign ('part_token', 60);
const resp = await get ({ authorization: `Bearer ${token.signature}` });
expect (resp.statusCode)
.toEqual (302);
@ -106,7 +161,7 @@ describe ('gateway', () => {
});
it ('should reject any noname token', async () => {
const token = authority.sign ('none', 60);
const token = await authority.sign ('none', 60);
const resp = await get ({ authorization: `Bearer ${token.signature}` });
expect (resp.statusCode)
.toEqual (302);
@ -121,4 +176,51 @@ describe ('gateway', () => {
expect (resp.headers.location)
.toEqual ('http://localhost/auth');
});
it ('should disallow access and refresh cookies with the same name', () => {
expect (() => {
create_gateway ({
cookie: { name: 'foo' },
refresh_cookie: { name: 'foo' }
});
})
.toThrowError ('access and refresh cookies cannot have the same name');
});
it ('should logout all tokens', async () => {
const token = await authority.sign ('access_token', 60);
const refresh = await authority.sign ('refresh_token', 3600);
const resp = await get (
// eslint-disable-next-line max-len
{ cookie: `foo=bar;cookie_jar=${token.signature};asd=efg;mint_cookies=${refresh.signature}` },
null,
'/logout'
);
expect (resp.statusCode)
.toEqual (200);
const blacklisted = blacklist.export_blacklist ()
.map ((v) => v.token_id);
expect (blacklisted)
.toContain (token.id);
expect (blacklisted)
.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,99 +5,190 @@
* Created by Timo Hocker <timo@scode.ovh>, December 2020
*/
import ks from '../../lib/KeyStore';
import ks, { KeyStore } from '../../lib/KeyStore';
import { clock_finalize, clock_setup } from '../Helper';
const frame = 3600;
/* eslint-disable-next-line max-lines-per-function */
describe ('key store', () => {
beforeAll (() => {
jasmine.clock ()
.install ();
const base_date = (new Date);
base_date.setSeconds (2);
jasmine.clock ()
.mockDate (base_date);
});
const keys: {key:string, iat:number}[] = [];
it ('should generate a new key', () => {
const iat = (new Date)
.getTime () / 1000;
const duration = 600;
const key = ks.get_key (iat, duration);
expect (typeof key)
.toEqual ('string');
expect (key.length)
.toEqual (64);
keys.push ({ iat, key });
});
it ('should return the generated key', () => {
const key = ks.get_key (keys[0].iat);
expect (key)
.toEqual (keys[0].key);
});
it ('should return the same key on a different time', () => {
const key = ks.get_key (keys[0].iat + 30);
expect (key)
.toEqual (keys[0].key);
});
it ('should generate a new key after 60 seconds', () => {
jasmine.clock ()
.tick (60000);
const iat = (new Date)
.getTime () / 1000;
const duration = 600;
const key = ks.get_key (iat, duration);
expect (typeof key)
.toEqual ('string');
expect (key.length)
.toEqual (64);
expect (key).not.toEqual (keys[0].key);
keys.push ({ iat, key });
});
it ('should return both keys', () => {
const key = ks.get_key (keys[0].iat);
expect (key)
.toEqual (keys[0].key);
const k2 = ks.get_key (keys[1].iat);
expect (k2)
.toEqual (keys[1].key);
});
it ('should throw on non existing key', () => {
expect (() => ks.get_key (keys[1].iat + 60))
.toThrowError ('key could not be found');
});
it ('should delete a key after it expires', () => {
jasmine.clock ()
.tick (600000);
expect (() => ks.get_key (keys[0].iat))
.toThrowError ('key could not be found');
});
it ('should still retrieve the second key', () => {
const key = ks.get_key (keys[1].iat);
expect (key)
.toEqual (keys[1].key);
});
it ('should reject key generation of expired keys', () => {
const iat = ((new Date)
.getTime () / 1000) - 10;
const duration = 5;
expect (() => ks.get_key (iat, duration))
.toThrowError ('cannot create already expired keys');
clock_setup ();
});
afterAll (() => {
clock_finalize ();
});
const keys: {key: string, sign: string, iat: number}[] = [];
it ('should generate a new key', async () => {
const iat = (new Date)
.getTime () / 1000;
const duration = 10 * frame;
const key = await ks.get_sign_key (iat, duration);
const sign = await ks.get_key (iat);
expect (typeof key)
.toEqual ('string');
expect (typeof sign)
.toEqual ('string');
keys.push ({ iat, key, sign });
});
it ('should return the generated key', async () => {
const key = await ks.get_sign_key (keys[0].iat, 1);
expect (key)
.toEqual (keys[0].key);
const sign = await ks.get_key (keys[0].iat);
expect (sign)
.toEqual (keys[0].sign);
});
it ('should return the same key on a different time', async () => {
const key = await ks.get_sign_key (keys[0].iat + (frame / 2), 1);
expect (key)
.toEqual (keys[0].key);
const sign = await ks.get_key (keys[0].iat + (frame / 2));
expect (sign)
.toEqual (keys[0].sign);
});
it ('should generate a new key after time frame is over', async () => {
jasmine.clock ()
.tick (24 * 60 * 60 * 1000);
.tick (frame * 1000);
const iat = (new Date)
.getTime () / 1000;
const duration = 10 * frame;
const key = await ks.get_sign_key (iat, duration);
const sign = await ks.get_key (iat);
expect (typeof key)
.toEqual ('string');
expect (key).not.toEqual (keys[0].key);
expect (sign).not.toEqual (keys[0].sign);
keys.push ({ iat, key, sign });
});
it ('should return both keys, but not the first sign key', async () => {
const sign = await ks.get_key (keys[0].iat);
expect (sign)
.toEqual (keys[0].sign);
await expectAsync (ks.get_sign_key (keys[0].iat, 1))
.toBeRejectedWithError ('cannot access already expired keys');
const k2 = await ks.get_sign_key (keys[1].iat, 1);
const s2 = await ks.get_key (keys[1].iat);
expect (k2)
.toEqual (keys[1].key);
expect (s2)
.toEqual (keys[1].sign);
});
it ('should throw on non existing key', async () => {
await expectAsync (ks.get_key (keys[1].iat + frame))
.toBeRejectedWithError ('key could not be found');
});
it ('should delete a key after it expires', async () => {
// go to 10 frames + 1ms after key creation
jasmine.clock ()
.uninstall ();
.tick ((frame * 9e3) + 1);
// eslint-disable-next-line dot-notation
ks['garbage_collect'] ();
await expectAsync (ks.get_key (keys[0].iat))
.toBeRejectedWithError ('key could not be found');
});
it (
'should still retrieve the second key, but not its sign key',
async () => {
await expectAsync (ks.get_sign_key (keys[1].iat, 1))
.toBeRejectedWithError ('cannot access already expired keys');
const sign = await ks.get_key (keys[1].iat);
expect (sign)
.toEqual (keys[1].sign);
}
);
it ('should reject key generation of expired keys', async () => {
const iat = ((new Date)
.getTime () / 1000) - 2;
const duration = 5;
await expectAsync (ks.get_sign_key (iat, duration))
.toBeRejectedWithError ('cannot access already expired keys');
});
it ('key should live as long as the longest created token', async () => {
jasmine.clock ()
.tick (frame * 10e3);
const iat = (new Date)
.getTime () / 1000;
const duration1 = frame;
const duration2 = frame * 10;
const key1 = await ks.get_sign_key (iat, duration1);
const step = 0.9 * frame;
jasmine.clock ()
.tick (step * 1000);
const key2 = await ks.get_sign_key (iat + step, duration2);
const sign = await ks.get_key (iat);
expect (key1)
.toEqual (key2);
jasmine.clock ()
.tick (5000 * frame);
const signv = await ks.get_key (iat + step);
expect (signv)
.toEqual (sign);
});
it ('should not allow invalid expiry times', async () => {
await expectAsync (ks.get_sign_key (0, 0))
.toBeRejectedWithError ('cannot create infinitely valid key');
await expectAsync (ks.get_sign_key (0, -1))
.toBeRejectedWithError ('cannot create infinitely valid key');
});
it ('should export and import all keys', async () => {
const iat = (new Date)
.getTime () / 1000;
const sign = await ks.get_sign_key (iat, frame);
const ver = await ks.get_key (iat);
const exp = ks.export_verification_data ();
// eslint-disable-next-line dot-notation
expect (Object.keys (ks['_keys']))
.toEqual (exp.map ((v) => v.index));
const ks2 = (new KeyStore);
expect (ks2.instance_id).not.toEqual (ks.instance_id);
ks2.import_verification_data (exp);
// eslint-disable-next-line dot-notation
expect (Object.keys (ks2['_keys']))
.toEqual (exp.map ((v) => v.index));
const sign2 = await ks2.get_sign_key (iat, frame);
const ver2 = await ks2.get_key (iat);
expect (sign).not.toEqual (sign2);
expect (ver).not.toEqual (ver2);
await expectAsync (ks2.get_sign_key (iat, 60, ks.instance_id))
.toBeRejectedWithError ('cannot access already expired keys');
expect (await ks2.get_key (iat, ks.instance_id))
.toEqual (ver);
});
it ('should disallow importing to itself', () => {
const exp = ks.export_verification_data ();
expect (() => ks.import_verification_data (exp))
.toThrowError ('cannot import to the same instance');
});
it ('should clear all', () => {
// eslint-disable-next-line dot-notation
expect (Object.keys (ks['_keys']).length)
.toBeGreaterThan (0);
const instance = ks.instance_id;
ks.reset_instance ();
// eslint-disable-next-line dot-notation
expect (Object.keys (ks['_keys']).length)
.toEqual (0);
expect (instance).not.toEqual (ks.instance_id);
});
});

110
test/spec/Redis.ts Normal file
View File

@ -0,0 +1,110 @@
/*
* 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
*/
/* eslint-disable dot-notation */
import { blacklist } from '../../lib';
import ks from '../../lib/KeyStore';
import { Redis } from '../../lib/Redis';
import { generate_token_id } from '../../lib/token_id';
import { clock_finalize, clock_setup } from '../Helper';
const frame = 3600;
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', () => {
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.sync_redis (redis_url);
await blacklist.clear ();
blacklist.sync_redis (redis_url);
clock_setup ();
});
let iat1 = 0;
let iat2 = 0;
let k1 = '';
let k2 = '';
let i1 = '';
let i2 = '';
afterAll (() => clock_finalize ());
it ('should generate two keys', async () => {
iat1 = (new Date)
.getTime () / 1000;
await ks.get_sign_key (iat1, frame);
k1 = await ks.get_key (iat1);
jasmine.clock ()
.tick (frame * 1000);
iat2 = (new Date)
.getTime () / 1000;
await ks.get_sign_key (iat2, frame);
k2 = await ks.get_key (iat2);
// eslint-disable-next-line dot-notation
i1 = ks['get_index'] (iat1);
// eslint-disable-next-line dot-notation
i2 = ks['get_index'] (iat2);
});
it ('should have two keys in redis', async () => {
expect (JSON.parse (await redis['_redis']
?.get (`keystore_${i1}`) as string).key)
.toEqual (k1);
expect (JSON.parse (await redis['_redis']
?.get (`keystore_${i2}`) as string).key)
.toEqual (k2);
});
it ('should read two keys with a new instance', async () => {
const old_instance = ks.instance_id;
ks.reset_instance ();
expectAsync (ks.get_key (iat1, old_instance))
.toBeRejectedWithError ('key could not be found');
expectAsync (ks.get_key (iat1, old_instance))
.toBeRejectedWithError ('key could not be found');
ks.sync_redis (redis_url);
expect (await ks.get_key (iat1, old_instance))
.toEqual (k1);
expect (await ks.get_key (iat2, old_instance))
.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 ();
});
});

130
test/spec/cookie.ts Normal file
View File

@ -0,0 +1,130 @@
/*
* 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>, January 2022
*/
import { build_cookie, CookieSettings, extract_cookie } from '../../lib/cookie';
interface CreateCookie {
settings: CookieSettings
value: string
result: string
}
const create_cookie_pairs: CreateCookie[] = [
{
settings: { name: 'foo' },
value: 'bar',
result: 'foo=bar; Secure; HttpOnly; SameSite=Strict'
},
{
settings: { name: 'foäöüo' },
value: 'baäöür',
result: 'foäöüo=baäöür; Secure; HttpOnly; SameSite=Strict'
},
{
settings: {
name: 'foo',
secure: true,
http_only: false,
same_site: null
},
value: 'bar',
result: 'foo=bar; Secure'
},
{
settings: {
name: 'foo',
secure: false,
http_only: true,
same_site: null
},
value: 'bar',
result: 'foo=bar; HttpOnly'
},
{
settings: {
name: 'foo',
secure: false,
http_only: false,
same_site: 'Lax'
},
value: 'bar',
result: 'foo=bar; SameSite=Lax'
},
{
settings: {
name: 'foo',
secure: false,
http_only: false,
same_site: null,
expires: 'Tomorrow'
},
value: 'bar',
result: 'foo=bar; Expires=Tomorrow'
},
{
settings: {
name: 'foo',
secure: false,
http_only: false,
same_site: null,
max_age: 600
},
value: 'bar',
result: 'foo=bar; Max-Age=600'
},
{
settings: {
name: 'foo',
secure: false,
http_only: false,
same_site: null,
domain: 'example.com'
},
value: 'bar',
result: 'foo=bar; Domain=example.com'
},
{
settings: {
name: 'foo',
secure: false,
http_only: false,
same_site: null,
path: '/test'
},
value: 'bar',
result: 'foo=bar; Path=/test'
}
];
const parse_cookie_pairs = [
{
header: 'foo=bar; Secure; HttpOnly; SameSite=Strict',
name: 'foo',
value: 'bar'
},
{
header: '134=567;foäöüo=baäöür;tesT=123',
name: 'foäöüo',
value: 'baäöür'
}
];
describe ('cookie', () => {
it ('should create a cookie', () => {
for (const pair of create_cookie_pairs) {
expect (build_cookie (pair.settings, pair.value))
.toEqual (pair.result);
}
});
it ('should parse a cookie', () => {
for (const pair of parse_cookie_pairs) {
expect (extract_cookie (pair.name, pair.header))
.toEqual (pair.value);
}
});
});

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);
}
});
});

View File

@ -3,11 +3,14 @@
"target": "es5",
"module": "commonjs",
"outDir": "./dist",
"rootDir": "./lib",
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"sourceMap": true
}
},
"include": [
"lib/**/*.ts",
"test/**/*.ts"
]
}

3156
yarn.lock

File diff suppressed because it is too large Load Diff