Compare commits
61 Commits
4c27d0eace
...
master
Author | SHA1 | Date | |
---|---|---|---|
a41882de60
|
|||
ba9608829d | |||
910099285b | |||
6856ac718f | |||
6eb2009141 | |||
64d4f00629 | |||
31f739d4b8
|
|||
e80e3f9a94
|
|||
b7514941f0
|
|||
d5c136790e
|
|||
43cf782511 | |||
b43190d048 | |||
7bb6dac737 | |||
1009a9b8d5 | |||
da36f87250
|
|||
cf2f9c0182 | |||
4d69efd9f5 | |||
fd26975559 | |||
122bd7b574
|
|||
84be087743 | |||
ec08f8f04e
|
|||
cc8762e4ec
|
|||
3aaaf10fd9
|
|||
8f047f2700
|
|||
80a98704af
|
|||
c7708f4bc0
|
|||
b58af27719
|
|||
2a51e0a753
|
|||
22075489c2
|
|||
1188e4573f
|
|||
d28be9e3f8
|
|||
dab45e39a6
|
|||
4820bda8ca
|
|||
86b07af63d
|
|||
85a5f3c2fb
|
|||
c55ed33e53 | |||
3bc5538a69 | |||
e7ad5656e3 | |||
a3f021fdd2 | |||
d286548850 | |||
e326c6c077 | |||
ce58c0d204 | |||
9ec97d8aa2 | |||
1af8c0702c | |||
e6039e78b1 | |||
c5bc0855d7 | |||
d6a40871c4 | |||
4c42a682d5 | |||
fd4f891b3e | |||
adfeeaa52c | |||
1437316519 | |||
5df2577e71 | |||
df8de9e0c8 | |||
05f2e53a8f | |||
8285e58337 | |||
6cf6286fb8 | |||
872661a926 | |||
48afa73ae8 | |||
debb7debf1 | |||
80d04f7441 | |||
f39759bad9 |
@ -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
54
CHANGELOG.md
Normal 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
273
README.md
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
@ -18,5 +18,6 @@ module.exports = {
|
||||
Atomics: 'readonly',
|
||||
SharedArrayBuffer: 'readonly'
|
||||
},
|
||||
parserOptions: { ecmaVersion: 2018 }
|
||||
parserOptions: { ecmaVersion: 2018 },
|
||||
rules: { 'node/no-unpublished-import': 'off' }
|
||||
};
|
||||
|
@ -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
|
||||
};
|
||||
|
@ -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;
|
||||
|
127
lib/Blacklist.ts
127
lib/Blacklist.ts
@ -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;
|
||||
|
243
lib/Gateway.ts
243
lib/Gateway.ts
@ -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
23
lib/Key.ts
Normal 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[];
|
187
lib/KeyStore.ts
187
lib/KeyStore.ts
@ -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
64
lib/Redis.ts
Normal 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;
|
||||
}
|
||||
}
|
53
lib/RedisData/RedisBlacklistStore.ts
Normal file
53
lib/RedisData/RedisBlacklistStore.ts
Normal file
@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright (C) Sapphirecode - All Rights Reserved
|
||||
* This file is part of Auth-Server-Helper which is released under MIT.
|
||||
* See file 'LICENSE' for full license details.
|
||||
* Created by Timo Hocker <timo@scode.ovh>, August 2022
|
||||
*/
|
||||
|
||||
import { debug } from '../debug';
|
||||
import { Redis } from '../Redis';
|
||||
|
||||
const logger = debug ('RedisBlacklistStore');
|
||||
|
||||
export class RedisBlacklistStore extends Redis {
|
||||
public async add (key: string, valid_until: Date): Promise<void> {
|
||||
const log = logger.extend ('set');
|
||||
log ('trying to add key %s to redis blacklist', key);
|
||||
if (!this.is_active) {
|
||||
log ('redis is inactive, skipping');
|
||||
return;
|
||||
}
|
||||
await this.redis.setex (
|
||||
`blacklist_${key}`,
|
||||
Math.floor ((valid_until.getTime () - Date.now ()) / 1000),
|
||||
1
|
||||
);
|
||||
log ('saved key');
|
||||
}
|
||||
|
||||
public async remove (key: string): Promise<void> {
|
||||
const log = logger.extend ('remove');
|
||||
log ('removing key %s from redis', key);
|
||||
if (!this.is_active) {
|
||||
log ('redis is inactive, skipping');
|
||||
return;
|
||||
}
|
||||
await this.redis.del (`blacklist_${key}`);
|
||||
log ('removed key');
|
||||
}
|
||||
|
||||
public async get (key: string): Promise<boolean> {
|
||||
const log = logger.extend ('get');
|
||||
log ('trying to find key %s in redis blacklist', key);
|
||||
if (!this.is_active) {
|
||||
log ('redis is inactive, skipping');
|
||||
return false;
|
||||
}
|
||||
const res = await this.redis.exists (`blacklist_${key}`) === 1;
|
||||
log ('found key %s', res);
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
export const redis_blacklist_store = new RedisBlacklistStore;
|
52
lib/RedisData/RedisKeyStore.ts
Normal file
52
lib/RedisData/RedisKeyStore.ts
Normal file
@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Copyright (C) Sapphirecode - All Rights Reserved
|
||||
* This file is part of Auth-Server-Helper which is released under MIT.
|
||||
* See file 'LICENSE' for full license details.
|
||||
* Created by Timo Hocker <timo@scode.ovh>, August 2022
|
||||
*/
|
||||
|
||||
import { debug } from '../debug';
|
||||
import { LabelledKey } from '../Key';
|
||||
import { Redis } from '../Redis';
|
||||
|
||||
const logger = debug ('RedisKeyStore');
|
||||
|
||||
export class RedisKeyStore extends Redis {
|
||||
public async set (value: LabelledKey): Promise<void> {
|
||||
const log = logger.extend ('set');
|
||||
log ('trying to set key %s to redis', value.index);
|
||||
if (!this.is_active) {
|
||||
log ('redis is inactive, skipping');
|
||||
return;
|
||||
}
|
||||
const valid_for = Math.floor (
|
||||
(value.valid_until - (new Date)
|
||||
.getTime ()) / 1000
|
||||
);
|
||||
log ('key is valid for %d seconds', valid_for);
|
||||
await this.redis.setex (
|
||||
`keystore_${value.index}`,
|
||||
valid_for,
|
||||
JSON.stringify (value)
|
||||
);
|
||||
log ('saved key');
|
||||
}
|
||||
|
||||
public async get (index: string): Promise<LabelledKey | null> {
|
||||
const log = logger.extend ('get');
|
||||
log ('trying to get key %s from redis', index);
|
||||
if (!this.is_active) {
|
||||
log ('redis is inactive, skipping');
|
||||
return null;
|
||||
}
|
||||
const res = await this.redis.get (`keystore_${index}`);
|
||||
if (res === null) {
|
||||
log ('key not found in redis');
|
||||
return null;
|
||||
}
|
||||
log ('key found');
|
||||
return JSON.parse (res);
|
||||
}
|
||||
}
|
||||
|
||||
export const redis_key_store = new RedisKeyStore;
|
86
lib/cookie.ts
Normal file
86
lib/cookie.ts
Normal 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
15
lib/debug.ts
Normal 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
74
lib/index.ts
Normal 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
21
lib/token_id.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { create_salt } from '@sapphirecode/crypto-helper';
|
||||
import { to_b58 } from '@sapphirecode/encoding-helper';
|
||||
|
||||
export function generate_token_id (valid_until: Date) {
|
||||
const salt = create_salt ();
|
||||
return `${to_b58 (salt, 'hex')};${valid_until.toISOString ()}`;
|
||||
}
|
||||
|
||||
export function parse_token_id (id: string) {
|
||||
// eslint-disable-next-line max-len
|
||||
const regex = /^(?<hash>[A-HJ-NP-Za-km-z1-9]+);(?<date>\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d\.\d{3}Z)$/u;
|
||||
const result = regex.exec (id);
|
||||
if (result === null)
|
||||
throw new Error (`invalid token id ${id}`);
|
||||
if (typeof result.groups === 'undefined')
|
||||
throw new Error ('invalid state');
|
||||
return {
|
||||
hash: result.groups.hash as string,
|
||||
valid_until: new Date (result.groups.date as string)
|
||||
};
|
||||
}
|
45
package.json
45
package.json
@ -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"
|
||||
|
@ -19,5 +19,6 @@ module.exports = {
|
||||
testRunner: 'jasmine',
|
||||
jasmineConfigFile: 'jasmine.json',
|
||||
coverageAnalysis: 'perTest',
|
||||
mutate: [ 'lib/*.ts' ]
|
||||
mutate: [ 'lib/*.ts' ],
|
||||
tsconfigFile: 'tsconfig.json'
|
||||
};
|
||||
|
@ -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 ();
|
||||
}
|
||||
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
@ -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']);
|
||||
});
|
||||
});
|
||||
|
@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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
110
test/spec/Redis.ts
Normal 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
130
test/spec/cookie.ts
Normal 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
22
test/spec/token_id.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { generate_token_id, parse_token_id } from '../../lib/token_id';
|
||||
import { clock_finalize, clock_setup } from '../Helper';
|
||||
|
||||
describe ('token_id', () => {
|
||||
beforeAll (() => {
|
||||
clock_setup ();
|
||||
});
|
||||
|
||||
afterAll (() => {
|
||||
clock_finalize ();
|
||||
});
|
||||
|
||||
it ('should always generate valid tokens', () => {
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
const date = new Date;
|
||||
const token_id = generate_token_id (new Date);
|
||||
const parsed = parse_token_id (token_id);
|
||||
expect (parsed.valid_until)
|
||||
.toEqual (date);
|
||||
}
|
||||
});
|
||||
});
|
@ -2,12 +2,15 @@
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"module": "commonjs",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./lib",
|
||||
"outDir": "./dist",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"sourceMap": true
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"lib/**/*.ts",
|
||||
"test/**/*.ts"
|
||||
]
|
||||
}
|
||||
|
Reference in New Issue
Block a user