Compare commits

...

44 Commits

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

View File

@ -6,10 +6,10 @@ steps:
image: registry:5000/node-build image: registry:5000/node-build
commands: commands:
- yarn - 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 - name: build
image: registry:5000/node-build image: registry:5000/node-build
commands: commands:
- redis-server --daemonize yes
- node ci.js - node ci.js

View File

@ -1,5 +1,46 @@
# Changelog # Changelog
## 4.1.0
- Permission Management
- Gateway function to read connection info
## 4.0.0
- Blacklist entries can now be synchronized through redis
BREAKING: Blacklist functions are now asynchronous
## 3.3.0
- 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 ## 2.0.0
Complete redesign Complete redesign

171
README.md
View File

@ -1,6 +1,6 @@
# auth-server-helper # auth-server-helper
version: 2.0.0 version: 4.1.x
customizable and simple authentication customizable and simple authentication
@ -22,8 +22,13 @@ yarn:
const {create_gateway} = require('@sapphirecode/auth-server-helper'); const {create_gateway} = require('@sapphirecode/auth-server-helper');
const gateway = create_gateway({ const gateway = create_gateway({
redirect_url: '/auth', redirect_url: '/auth', // if defined, unauthorized requests will be redirected
cookie_name: 'auth_cookie', // if defined, access tokens will be read from this cookie 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 // express
@ -40,6 +45,23 @@ http.createServer((main_req, main_res) =>
the gateway will forward any authorized requests to the next handler and the gateway will forward any authorized requests to the next handler and
redirect all others to the specified url 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 ### 2. creating the auth endpoint
```js ```js
@ -81,7 +103,9 @@ const handler = create_auth_handler(
// the same works in handlers after the gateway, information is always stored in request.connection.auth // 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 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)
} }
); );
@ -90,18 +114,79 @@ app.use(handler);
// node http // node http
// ... create server, on path /auth run the handler // ... create server, on path /auth run the handler
handler(req, res); 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 after the auth handler, the request will be completed, no additional content
should be served here. 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 ### Invalidating tokens after they are delivered to the client
```js ```js
const {blacklist} = require('@sapphirecode/auth-server-helper'); const {blacklist} = require('@sapphirecode/auth-server-helper');
blacklist.add_signature(token_id); // the token id is returned from any function that creates tokens await blacklist.add_signature(token_id); // the token id is returned from any function that creates tokens
```
#### Logout function
```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 ### Exporting and importing public keys to validate tokens across server instances
@ -116,6 +201,15 @@ const export = keystore.export_verification_data();
keystore.import_verification_data(export); keystore.import_verification_data(export);
``` ```
These keys can also be live synchronized with redis to allow sessions to be
shared between servers
```js
const {keystore} = require('@sapphirecode/auth-server-helper');
keystore.sync_redis('redis://localhost');
```
### Exporting and importing blacklist entries across server instances ### Exporting and importing blacklist entries across server instances
```js ```js
@ -128,6 +222,69 @@ const export = blacklist.export_blacklist();
blacklist.import_blacklist(export); 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 ## License
MIT © Timo Hocker <timo@scode.ovh> MIT © Timo Hocker <timo@scode.ovh>

View File

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

View File

@ -8,12 +8,19 @@
import { IncomingMessage, ServerResponse } from 'http'; import { IncomingMessage, ServerResponse } from 'http';
import { to_utf8 } from '@sapphirecode/encoding-helper'; import { to_utf8 } from '@sapphirecode/encoding-helper';
import auth from './Authority'; import auth from './Authority';
import { debug } from './debug';
import { build_cookie, CookieSettings } from './cookie';
const logger = debug ('auth');
interface AccessSettings { interface AccessSettings {
access_token_expires_in: number access_token_expires_in: number
include_refresh_token?: boolean include_refresh_token?: boolean
refresh_token_expires_in?: number refresh_token_expires_in?: number
data?: Record<string, unknown> redirect_to?: string
data?: unknown,
leave_open?: boolean
permissions?: string[]
} }
interface AccessResult { interface AccessResult {
@ -29,6 +36,9 @@ interface AccessResponse {
refresh_expires_in?: number; refresh_expires_in?: number;
} }
type AuthHandler =
(req: IncomingMessage, res: ServerResponse) => Promise<boolean>;
class AuthRequest { class AuthRequest {
public request: IncomingMessage; public request: IncomingMessage;
public response: ServerResponse; public response: ServerResponse;
@ -44,13 +54,20 @@ class AuthRequest {
public body: string; public body: string;
private _cookie_name?: string; private _cookie?: CookieSettings;
private _refresh_cookie?: CookieSettings;
private _is_successful: boolean;
public get is_successful (): boolean {
return this._is_successful;
}
public constructor ( public constructor (
req: IncomingMessage, req: IncomingMessage,
res: ServerResponse, res: ServerResponse,
body: string, body: string,
cookie?: string cookie?: CookieSettings,
refresh_cookie?: CookieSettings
) { ) {
this.request = req; this.request = req;
this.response = res; this.response = res;
@ -59,28 +76,37 @@ class AuthRequest {
this.is_bearer = false; this.is_bearer = false;
this.user = ''; this.user = '';
this.password = ''; 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 ('Cache-Control', 'no-store');
this.response.setHeader ('Pragma', 'no-cache'); this.response.setHeader ('Pragma', 'no-cache');
this.response.setHeader ('Content-Type', 'application/json'); if (set_content)
this.response.setHeader ('Content-Type', 'application/json');
} }
// eslint-disable-next-line max-statements, max-lines-per-function
public async allow_access ({ public async allow_access ({
access_token_expires_in, access_token_expires_in,
include_refresh_token, include_refresh_token,
refresh_token_expires_in, refresh_token_expires_in,
data redirect_to,
data,
leave_open,
permissions
}: AccessSettings): Promise<AccessResult> { }: AccessSettings): Promise<AccessResult> {
this.default_header (); const log = logger.extend ('allow_access');
log ('allowed access');
this.default_header (typeof redirect_to !== 'string' && !leave_open);
const at = await auth.sign ( const at = await auth.sign (
'access_token', 'access_token',
access_token_expires_in, access_token_expires_in,
{ data, permissions }
{ data }
); );
const result: AccessResult = { access_token_id: at.id }; const result: AccessResult = { access_token_id: at.id };
@ -90,14 +116,13 @@ class AuthRequest {
expires_in: access_token_expires_in expires_in: access_token_expires_in
}; };
if (typeof this._cookie_name === 'string') { const cookies = [];
this.response.setHeader (
'Set-Cookie', if (typeof this._cookie !== 'undefined')
`${this._cookie_name}=${at.signature}` cookies.push (build_cookie (this._cookie, at.signature));
);
}
if (include_refresh_token) { if (include_refresh_token) {
log ('including refresh token');
if (typeof refresh_token_expires_in !== 'number') if (typeof refresh_token_expires_in !== 'number')
throw new Error ('no expiry time defined for refresh tokens'); throw new Error ('no expiry time defined for refresh tokens');
const rt = await auth.sign ( const rt = await auth.sign (
@ -108,9 +133,35 @@ class AuthRequest {
res.refresh_token = rt.signature; res.refresh_token = rt.signature;
res.refresh_expires_in = refresh_token_expires_in; res.refresh_expires_in = refresh_token_expires_in;
result.refresh_token_id = rt.id; 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; return result;
} }
@ -118,8 +169,11 @@ class AuthRequest {
public async allow_part ( public async allow_part (
part_token_expires_in: number, part_token_expires_in: number,
next_module: string, next_module: string,
data?: Record<string, unknown> data?: Record<string, unknown>,
leave_open = false
): Promise<string> { ): Promise<string> {
const log = logger.extend ('allow_part');
log ('allowed part token');
this.default_header (); this.default_header ();
const pt = await auth.sign ( const pt = await auth.sign (
@ -134,112 +188,166 @@ class AuthRequest {
expires_in: part_token_expires_in expires_in: part_token_expires_in
}; };
this.response.writeHead (200); if (!leave_open) {
this.response.end (JSON.stringify (res)); log ('finishing http request');
this.response.writeHead (200);
this.response.end (JSON.stringify (res));
}
this._is_successful = true;
return pt.id; return pt.id;
} }
public invalid (error_description?: string): void { public invalid (error_description?: string, leave_open = false): void {
const log = logger.extend ('invalid');
log ('rejecting invalid request');
this.default_header (); this.default_header ();
this.response.writeHead (400); this.response.statusCode = 400;
this.response.end (JSON.stringify ({ if (!leave_open) {
error: 'invalid_request', log ('finishing http request');
error_description this.response.end (JSON.stringify ({
})); error: 'invalid_request',
error_description
}));
}
} }
public deny (): void { public deny (leave_open = false): void {
const log = logger.extend ('deny');
log ('denied access');
this.default_header (); this.default_header ();
this.response.writeHead (401); this.response.statusCode = 401;
this.response.end (JSON.stringify ({ error: 'invalid_client' })); 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 { interface CreateHandlerOptions {
refresh?: AccessSettings; refresh?: AccessSettings;
modules?: Record<string, AuthRequestHandler>; modules?: Record<string, AuthRequestHandler>;
cookie_name?: string; cookie?: CookieSettings;
refresh_cookie?: CookieSettings;
parse_body?: boolean;
}
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?: ProcessRequestOptions
): Promise<void> {
const log = logger.extend ('process_request');
if (token === null)
return default_handler (request);
if ((/Basic/ui).test (token?.groups?.type as string)) {
log ('found basic login data');
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];
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);
log ('bearer token is valid');
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 ();
}
if (
typeof options !== 'undefined'
&& typeof options.modules !== 'undefined'
&& token_data.type === 'part_token'
&& 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 // eslint-disable-next-line max-lines-per-function
export default function create_auth_handler ( export default function create_auth_handler (
default_handler: AuthRequestHandler, default_handler: AuthRequestHandler,
options?: CreateHandlerOptions options?: CreateHandlerOptions
) { ): AuthHandler {
// eslint-disable-next-line max-lines-per-function logger.extend ('create_auth_handler') ('creating new auth handler');
return async function process_request ( 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, req: IncomingMessage,
res: ServerResponse res: ServerResponse
): Promise<void> { ): Promise<boolean> => {
const body: string = await new Promise ((resolve) => { const body: string = options?.parse_body
let data = ''; ? await new Promise ((resolve) => {
req.on ('data', (c) => { let data = '';
data += c; req.on ('data', (c) => {
}); data += c;
req.on ('end', () => { });
resolve (data); req.on ('end', () => {
}); resolve (data);
}); });
})
: '';
const request = new AuthRequest (req, res, body, options?.cookie_name); const request = new AuthRequest (
req,
res,
body,
options?.cookie,
options?.refresh_cookie
);
const token = (/(?<type>\S+) (?<token>.+)/ui) const token = (/(?<type>\S+) (?<token>.+)/ui)
.exec (req.headers.authorization as string); .exec (req.headers.authorization as string);
if (token === null) await process_request (request, token, default_handler, options);
return default_handler (request);
if ((/Basic/ui).test (token?.groups?.type as string)) { return request.is_successful;
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];
return default_handler (request);
}
if ((/Bearer/ui).test (token?.groups?.type as string)) {
request.is_bearer = true;
request.token = token?.groups?.token;
const token_data = auth.verify (request.token as string);
if (!token_data.valid)
return default_handler (request);
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'
) {
request.allow_access (options.refresh);
return Promise.resolve ();
}
if (
typeof options !== 'undefined'
&& typeof options.modules !== 'undefined'
&& token_data.type === 'part_token'
&& typeof token_data.next_module !== 'undefined'
&& Object.keys (options.modules)
.includes (token_data.next_module)
)
return options.modules[token_data.next_module] (request);
request.invalid ('invalid bearer type');
return Promise.resolve ();
}
return default_handler (request);
}; };
} }
@ -249,5 +357,6 @@ export {
AccessResponse, AccessResponse,
AuthRequest, AuthRequest,
AuthRequestHandler, AuthRequestHandler,
CreateHandlerOptions CreateHandlerOptions,
AuthHandler
}; };

View File

@ -6,15 +6,18 @@
*/ */
import { import {
create_salt,
sign_object, sign_object,
verify_signature_get_info verify_signature_get_info
} from '@sapphirecode/crypto-helper'; } from '@sapphirecode/crypto-helper';
import keystore from './KeyStore'; import keystore from './KeyStore';
import blacklist from './Blacklist'; import blacklist from './Blacklist';
import { debug } from './debug';
import { generate_token_id } from './token_id';
const logger = debug ('authority');
// eslint-disable-next-line no-shadow // 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 { interface VerificationResult {
authorized: boolean; authorized: boolean;
@ -22,6 +25,7 @@ interface VerificationResult {
type: TokenType; type: TokenType;
id: string; id: string;
next_module?: string; next_module?: string;
permissions?: string[];
data?: unknown; data?: unknown;
error?: string; error?: string;
} }
@ -34,22 +38,26 @@ interface SignatureResult {
interface SignatureOptions interface SignatureOptions
{ {
data?: unknown data?: unknown
next_module?: string next_module?: string,
permissions?: string[]
} }
class Authority { 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 = { const result: VerificationResult = {
authorized: false, authorized: false,
valid: false, valid: false,
type: 'none', type: 'none',
id: '' permissions: [],
id: ''
}; };
const data = verify_signature_get_info ( const data = await verify_signature_get_info (
key, key,
(info) => { async (info) => {
try { try {
return keystore.get_key (info.iat / 1000, info.iss); return await keystore.get_key (info.iat / 1000, info.iss);
} }
catch { catch {
return ''; return '';
@ -59,6 +67,7 @@ class Authority {
); );
if (data === null) { if (data === null) {
log ('token invalid');
result.error = 'invalid signature'; result.error = 'invalid signature';
return result; return result;
} }
@ -66,7 +75,10 @@ class Authority {
result.id = data.id; result.id = data.id;
result.type = data.type; 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'; result.error = 'blacklisted';
return result; return result;
} }
@ -74,8 +86,16 @@ class Authority {
result.valid = true; result.valid = true;
result.authorized = result.type === 'access_token'; result.authorized = result.type === 'access_token';
result.next_module = data.next_module; result.next_module = data.next_module;
result.permissions = data.permissions;
result.data = data.obj; result.data = data.obj;
log (
'valid %s; targeting module %s',
result.type,
result.next_module
);
log ('permissions %o', result.permissions);
return result; return result;
} }
@ -84,17 +104,23 @@ class Authority {
valid_for: number, valid_for: number,
options?: SignatureOptions options?: SignatureOptions
): Promise<SignatureResult> { ): Promise<SignatureResult> {
const log = logger.extend ('sign');
log ('signing new %s', type);
const time = Date.now (); const time = Date.now ();
const valid_until = time + (valid_for * 1e3);
const key = await keystore.get_sign_key (time / 1000, valid_for); const key = await keystore.get_sign_key (time / 1000, valid_for);
const attributes = { const attributes = {
id: create_salt (), id: generate_token_id (new Date (valid_until)),
iat: time, iat: time,
iss: keystore.instance_id, iss: keystore.instance_id,
type, type,
valid_for, valid_for,
next_module: options?.next_module valid_until,
next_module: options?.next_module,
permissions: options?.permissions
}; };
const signature = sign_object (options?.data, key, attributes); const signature = sign_object (options?.data, key, attributes);
log ('created token %s', attributes.id);
return { id: attributes.id, signature }; return { id: attributes.id, signature };
} }
} }

View File

@ -5,51 +5,142 @@
* Created by Timo Hocker <timo@scode.ovh>, December 2020 * 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 { interface Signature {
hash: string; token_id: string;
iat: Date; iat: number;
valid_until: Date;
}
interface ExportedSignature {
token_id: string;
iat: number;
} }
class Blacklist { class Blacklist {
private _signatures: Signature[]; private _signatures: Signature[];
private _interval: NodeJS.Timeout;
public constructor () { public constructor () {
this._signatures = []; this._signatures = [];
this._interval = setInterval (
this.garbage_collect.bind (this),
3600000
);
} }
public clear_before (date: Date):void { 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--) { for (let i = this._signatures.length - 1; i >= 0; i--) {
if (this._signatures[i].iat < date) if (this._signatures[i].iat < before) {
this._signatures.splice (i, 1); // eslint-disable-next-line no-await-in-loop
await this.remove_signature (i);
}
} }
} }
public add_signature (hash: string):void { public async add_signature (token_id: string): Promise<void> {
this._signatures.push ({ iat: (new Date), hash }); 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):void { public async remove_signature (signature: number | string): Promise<void> {
for (let i = this._signatures.length - 1; i >= 0; i--) { const log = logger.extend ('remove_signature');
if (this._signatures[i].hash === hash) log ('removing signature from blacklist %s', signature);
this._signatures.splice (i, 1); 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):boolean { 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) { for (const sig of this._signatures) {
if (sig.hash === hash) if (sig.token_id === hash) {
log ('found matching blacklist entry');
return false; return false;
}
} }
log ('signature is not blacklisted locally, checking redis');
if (await redis_blacklist_store.get (hash)) {
log ('signature is blacklisted in redis');
return false;
}
log ('signature is not blacklisted');
return true; return true;
} }
public export_blacklist (): Signature[] { public export_blacklist (): ExportedSignature[] {
return this._signatures; logger.extend ('export_blacklist') ('exporting blacklist');
return this._signatures.map ((v) => ({
iat: v.iat,
token_id: v.token_id
}));
} }
public import_blacklist (data: Signature[]): void { public import_blacklist (data: ExportedSignature[]): void {
this._signatures.push (...data); 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);
}
}
} }
} }

View File

@ -6,82 +6,260 @@
*/ */
import { IncomingMessage, ServerResponse } from 'http'; import { IncomingMessage, ServerResponse } from 'http';
import { run_regex } from '@sapphirecode/utilities';
import authority from './Authority'; 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 AnyFunc = (...args: unknown[]) => unknown;
type Gateway = ( type Gateway = (
req: IncomingMessage, req: IncomingMessage,
res: ServerResponse, next: AnyFunc res: ServerResponse,
next: AnyFunc
) => unknown; ) => unknown;
interface RefreshSettings extends AccessSettings {
leave_open?: never;
redirect_to?: never;
data?: never;
}
interface GatewayOptions { interface GatewayOptions {
redirect_url: string; redirect_url?: string;
cookie_name?: string; cookie?: CookieSettings;
refresh_cookie?: CookieSettings;
refresh_settings?: RefreshSettings;
require_permissions?: string[];
}
interface ConnectionInfo {
token_id: string
token_data: unknown
permissions: string[]
} }
class GatewayClass { class GatewayClass {
private _options: GatewayOptions; private _options: GatewayOptions;
public constructor (options: GatewayOptions) { public constructor (options: GatewayOptions = {}) {
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; 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.statusCode = 302;
res.setHeader ('Location', this._options.redirect_url); res.setHeader ('Location', this._options.redirect_url);
res.end (); 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_header = req.headers.authorization;
const auth = (/(?<type>\w+) (?<data>.*)/u).exec (auth_header || ''); const auth = (/(?<type>\w+) (?<data>.*)/u).exec (auth_header || '');
if (auth === null) if (auth === null)
return null; return null;
if (auth.groups?.type !== 'Bearer') if (auth.groups?.type !== 'Bearer')
return null; return null;
log ('found bearer token');
return auth.groups?.data; return auth.groups?.data;
} }
private get_cookie_auth (req: IncomingMessage): string | null { public async try_access (req: IncomingMessage): Promise<boolean> {
if (typeof this._options.cookie_name === 'undefined') const log = logger.extend ('try_access');
return null; log ('authenticating incoming request');
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 {
let auth = this.get_header_auth (req); let auth = this.get_header_auth (req);
if (auth === null) if (auth === null)
auth = this.get_cookie_auth (req); auth = extract_cookie (this._options.cookie?.name, req.headers.cookie);
if (auth === null) if (auth === null) {
log ('found no auth token');
return false; return false;
}
const ver = authority.verify (auth); const ver = await authority.verify (auth);
log ('setting connection info');
const con = req.connection as unknown as Record<string, unknown>; const con = req.connection as unknown as Record<string, unknown>;
con.auth = { token_id: ver.id, token_data: ver.data }; con.auth = {
token_id: ver.id,
token_data: ver.data,
permissions: ver.permissions
};
log ('token valid: %s', ver.authorized);
return 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, req: IncomingMessage,
res: ServerResponse, res: ServerResponse,
next: AnyFunc next: AnyFunc
): unknown { ): Promise<unknown> {
if (this.authenticate (req)) 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 (); return next ();
}
log ('failed to authenticate, redirecting client');
return this.redirect (res); 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 { export default function create_gateway (options: GatewayOptions): Gateway {
@ -89,9 +267,4 @@ export default function create_gateway (options: GatewayOptions): Gateway {
return g.process_request.bind (g); return g.process_request.bind (g);
} }
export { export { AnyFunc, Gateway, GatewayOptions, GatewayClass, RefreshSettings };
AnyFunc,
Gateway,
GatewayOptions,
GatewayClass
};

23
lib/Key.ts Normal file
View File

@ -0,0 +1,23 @@
/*
* Copyright (C) Sapphirecode - All Rights Reserved
* This file is part of Auth-Server-Helper which is released under MIT.
* See file 'LICENSE' for full license details.
* Created by Timo Hocker <timo@scode.ovh>, August 2022
*/
export interface Key {
key: string;
valid_until: number;
}
export interface LabelledKey extends Key {
index: string;
}
export interface KeyPair {
private_key?: Key;
public_key: Key;
}
export type KeyStoreData = Record<string, KeyPair>;
export type KeyStoreExport = LabelledKey[];

View File

@ -7,37 +7,14 @@
import { generate_keypair, random_hex } from '@sapphirecode/crypto-helper'; import { generate_keypair, random_hex } from '@sapphirecode/crypto-helper';
import { to_b58 } from '@sapphirecode/encoding-helper'; import { to_b58 } from '@sapphirecode/encoding-helper';
import { debug } from './debug';
import { KeyStoreData, KeyStoreExport } from './Key';
import { redis_key_store } from './RedisData/RedisKeyStore';
const logger = debug ('keystore');
const renew_interval = 3600; const renew_interval = 3600;
interface Key {
key: string;
valid_until: number;
}
interface KeyPair {
private_key?: Key;
public_key: Key;
}
type KeyStoreData = Record<string, KeyPair>;
async function create_key (valid_for: number) {
const time = (new Date)
.getTime ();
const pair = await generate_keypair ();
return {
private_key: {
key: pair.private_key,
valid_until: time + (renew_interval * 1000)
},
public_key: {
key: pair.public_key,
valid_until: time + (valid_for * 1000)
}
};
}
class KeyStore { class KeyStore {
private _keys: KeyStoreData = {}; private _keys: KeyStoreData = {};
private _interval: NodeJS.Timeout; private _interval: NodeJS.Timeout;
@ -50,8 +27,12 @@ class KeyStore {
public constructor () { public constructor () {
this._interval = setInterval (() => { this._interval = setInterval (() => {
this.garbage_collect (); this.garbage_collect ();
}, renew_interval); }, renew_interval * 1000);
this._instance = to_b58 (random_hex (16), 'hex'); this._instance = to_b58 (random_hex (16), 'hex');
logger.extend ('constructor') (
'created keystore instance %s',
this._instance
);
} }
private get_index (iat: number, instance = this._instance): string { private get_index (iat: number, instance = this._instance): string {
@ -59,19 +40,44 @@ class KeyStore {
.toFixed (0); .toFixed (0);
} }
private garbage_collect (set: KeyStoreData = this._keys): void { 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) const time = (new Date)
.getTime (); .getTime ();
const keys = Object.keys (set); 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;
}
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) { for (const index of keys) {
const entry = set[index]; const entry = this._keys[index];
if (typeof entry.private_key !== 'undefined' if (typeof entry.private_key !== 'undefined'
&& entry.private_key.valid_until < time && entry.private_key.valid_until < time
) ) {
log ('deleting expired private key');
delete entry.private_key; delete entry.private_key;
}
if (entry.public_key.valid_until < time) if (entry.public_key.valid_until < time) {
delete set[index]; log ('deleting expired key pair');
delete this._keys[index];
}
} }
} }
@ -80,6 +86,13 @@ class KeyStore {
valid_for: number, valid_for: number,
instance?: string instance?: string
): Promise<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) if (valid_for <= 0)
throw new Error ('cannot create infinitely valid key'); throw new Error ('cannot create infinitely valid key');
@ -93,51 +106,87 @@ class KeyStore {
.getTime () + (valid_for * 1000); .getTime () + (valid_for * 1000);
if (typeof this._keys[index] !== 'undefined') { if (typeof this._keys[index] !== 'undefined') {
log ('loading existing key');
const key = this._keys[index]; const key = this._keys[index];
if (key.public_key.valid_until < valid_until)
key.public_key.valid_until = valid_until;
if (typeof key.private_key === 'undefined') if (typeof key.private_key === 'undefined')
throw new Error ('cannot access already expired keys'); throw new Error ('cannot access already expired keys');
if (key.public_key.valid_until < valid_until) {
log ('updating key valid timespan to match new value');
key.public_key.valid_until = valid_until;
}
return key.private_key?.key as string; return key.private_key?.key as string;
} }
this._keys[index] = await create_key (valid_for); 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; return this._keys[index].private_key?.key as string;
} }
public get_key (iat: number, instance?: string): 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); const index = this.get_index (iat, instance);
let key = null;
if (typeof this._keys[index] === 'undefined') 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'); throw new Error ('key could not be found');
const key = this._keys[index]; return key.key;
return key.public_key.key;
} }
public export_verification_data (): KeyStoreData { public export_verification_data (): KeyStoreExport {
const log = logger.extend ('export_verification_data');
log ('exporting public keys');
log ('cleaning up before export');
this.garbage_collect (); this.garbage_collect ();
const out: KeyStoreData = {}; const out: KeyStoreExport = [];
for (const index of Object.keys (this._keys)) for (const index of Object.keys (this._keys)) {
out[index] = { public_key: this._keys[index].public_key }; log ('exporting key %s', index);
out.push ({ ...this._keys[index].public_key, index });
}
return out; return out;
} }
public import_verification_data (data: KeyStoreData): void { public import_verification_data (data: KeyStoreExport): void {
const import_set = { ...data }; const log = logger.extend ('import_verification_data');
this.garbage_collect (import_set); log ('importing %d public keys', data.length);
for (const key of Object.keys (import_set)) { for (const key of data) {
if (typeof this._keys[key] !== 'undefined') log ('importing key %s', key.index);
if (typeof this._keys[key.index] !== 'undefined')
throw new Error ('cannot import to the same instance'); throw new Error ('cannot import to the same instance');
this._keys[key] = import_set[key]; this._keys[key.index] = {
public_key: {
key: key.key,
valid_until: key.valid_until
}
};
} }
log ('running garbage collector');
this.garbage_collect (); 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); const ks: KeyStore = (new KeyStore);
export default ks; export default ks;
export { KeyStore, KeyStoreData, Key, KeyPair }; export { KeyStore };

64
lib/Redis.ts Normal file
View File

@ -0,0 +1,64 @@
/*
* Copyright (C) Sapphirecode - All Rights Reserved
* This file is part of Auth-Server-Helper which is released under MIT.
* See file 'LICENSE' for full license details.
* Created by Timo Hocker <timo@scode.ovh>, August 2022
*/
import IORedis from 'ioredis';
import { debug } from './debug';
const logger = debug ('redis');
export class Redis {
private _redis: IORedis | null = null;
public connect (url: string): void {
const log = logger.extend ('connect');
log ('connecting to redis instance %s', url);
if (this._redis !== null) {
log ('disconnecting existing redis client');
this.disconnect ();
}
this._redis = new IORedis (url);
this._redis.on ('connect', () => {
log ('connected');
});
this._redis.on ('ready', () => {
log ('ready');
});
this._redis.on ('error', (err) => {
log ('error %o', err);
});
this._redis.on ('reconnecting', () => {
log ('reconnecting');
});
this._redis.on ('end', () => {
log ('connection ended');
});
}
public disconnect (): void {
const log = logger.extend ('disconnect');
log ('disconnecting redis client');
if (this._redis === null) {
log ('redis is inactive, skipping');
return;
}
this._redis.quit ();
this._redis = null;
log ('done');
}
protected get redis (): IORedis {
if (this._redis === null)
throw new Error ('redis is not connected');
return this._redis;
}
protected get is_active (): boolean {
return this._redis !== null;
}
}

View File

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

View File

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

86
lib/cookie.ts Normal file
View File

@ -0,0 +1,86 @@
/*
* Copyright (C) Sapphirecode - All Rights Reserved
* This file is part of Auth-Server-Helper which is released under MIT.
* See file 'LICENSE' for full license details.
* Created by Timo Hocker <timo@scode.ovh>, August 2022
*/
import { run_regex } from '@sapphirecode/utilities';
import { debug } from './debug';
const logger = debug ('cookies');
type SameSiteValue = 'Lax' | 'None' | 'Strict';
interface CookieSettings {
name: string;
secure?: boolean;
http_only?: boolean;
same_site?: SameSiteValue|null;
expires?: string;
max_age?: number;
domain?: string;
path?: string;
}
const default_settings: Omit<CookieSettings, 'name'> = {
secure: true,
http_only: true,
same_site: 'Strict'
};
function build_cookie (
settings: CookieSettings,
value: string
): string {
const local_settings = { ...default_settings, ...settings };
const sections = [ `${local_settings.name}=${value}` ];
if (local_settings.secure)
sections.push ('Secure');
if (local_settings.http_only)
sections.push ('HttpOnly');
if (
typeof local_settings.same_site !== 'undefined'
&& local_settings.same_site !== null
)
sections.push (`SameSite=${local_settings.same_site}`);
if (typeof local_settings.expires !== 'undefined')
sections.push (`Expires=${local_settings.expires}`);
if (typeof local_settings.max_age !== 'undefined')
sections.push (`Max-Age=${local_settings.max_age}`);
if (typeof local_settings.domain !== 'undefined')
sections.push (`Domain=${local_settings.domain}`);
if (typeof local_settings.path !== 'undefined')
sections.push (`Path=${local_settings.path}`);
return sections.join ('; ');
}
function extract_cookie (
name: string|undefined,
header: string|undefined
): string| null {
const log = logger.extend ('extract_cookie');
log (`extracting cookie ${name}`);
const cookie_regex = /(?:^|;)\s*(?<name>[^;=]+)=(?<value>[^;]+)/gu;
let result = null;
run_regex (
cookie_regex,
header,
(res: RegExpMatchArray) => {
log ('parsing cookie %s', res.groups?.name);
if (res.groups?.name === name) {
log ('found cookie');
result = res.groups?.value as string;
}
}
);
return result;
}
export { build_cookie, extract_cookie, SameSiteValue, CookieSettings };

15
lib/debug.ts Normal file
View File

@ -0,0 +1,15 @@
/*
* Copyright (C) Sapphirecode - All Rights Reserved
* This file is part of Auth-Server-Helper which is released under MIT.
* See file 'LICENSE' for full license details.
* Created by Timo Hocker <timo@scode.ovh>, August 2022
*/
import build_debug from 'debug';
function debug (scope: string): build_debug.Debugger {
const namespace = `sapphirecode:auth-server-helper:${scope}`;
return build_debug (namespace);
}
export { debug };

View File

@ -12,7 +12,8 @@ import create_auth_handler, {
AuthRequestHandler, AuthRequestHandler,
AuthRequest, AuthRequest,
AccessSettings, AccessSettings,
AccessResult AccessResult,
AuthHandler
} from './AuthHandler'; } from './AuthHandler';
import authority, { import authority, {
VerificationResult, VerificationResult,
@ -26,9 +27,18 @@ import create_gateway, {
GatewayOptions, GatewayOptions,
GatewayClass, GatewayClass,
Gateway, Gateway,
AnyFunc AnyFunc,
RefreshSettings
} from './Gateway'; } from './Gateway';
import keystore, { KeyStore, KeyStoreData } from './KeyStore'; import keystore, { KeyStore } from './KeyStore';
import {
KeyStoreExport,
LabelledKey, Key
} from './Key';
import {
CookieSettings,
SameSiteValue
} from './cookie';
export { export {
create_gateway, create_gateway,
@ -41,6 +51,7 @@ export {
CreateHandlerOptions, CreateHandlerOptions,
AuthRequestHandler, AuthRequestHandler,
AuthRequest, AuthRequest,
AuthHandler,
AccessSettings, AccessSettings,
AccessResult, AccessResult,
VerificationResult, VerificationResult,
@ -52,7 +63,12 @@ export {
GatewayOptions, GatewayOptions,
GatewayClass, GatewayClass,
Gateway, Gateway,
RefreshSettings,
AnyFunc, AnyFunc,
KeyStore, KeyStore,
KeyStoreData KeyStoreExport,
LabelledKey,
Key,
CookieSettings,
SameSiteValue
}; };

21
lib/token_id.ts Normal file
View File

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

View File

@ -1,30 +1,37 @@
{ {
"name": "@sapphirecode/auth-server-helper", "name": "@sapphirecode/auth-server-helper",
"version": "2.0.0", "version": "4.1.1",
"main": "dist/index.js", "main": "dist/lib/index.js",
"author": { "author": {
"name": "Timo Hocker", "name": "Timo Hocker",
"email": "timo@scode.ovh" "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", "license": "MIT",
"devDependencies": { "devDependencies": {
"@ert78gb/jasmine-ts": "^0.3.1",
"@sapphirecode/eslint-config-ts": "^1.1.27", "@sapphirecode/eslint-config-ts": "^1.1.27",
"@stryker-mutator/core": "^4.3.1", "@stryker-mutator/core": "^6.1.2",
"@stryker-mutator/jasmine-runner": "^4.3.1", "@stryker-mutator/jasmine-runner": "^6.1.2",
"@types/jasmine": "^3.6.2", "@types/debug": "^4.1.7",
"@types/node": "^10.0.0", "@types/jasmine": "^4.0.3",
"eslint": "^7.14.0", "@types/node": "^18.6.4",
"jasmine": "^3.6.3", "eslint": "^8.21.0",
"jasmine": "^4.3.0",
"nyc": "^15.1.0", "nyc": "^15.1.0",
"ts-node": "^9.1.1", "ts-node": "^10.9.1",
"typescript": "^4.1.2" "typescript": "^4.1.2"
}, },
"scripts": { "scripts": {
"lint": "eslint . --ext .js,.jsx,.ts,.tsx,.vue,.mjs", "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", "mutate": "stryker run",
"precompile": "rm -rf dist",
"compile": "tsc" "compile": "tsc"
}, },
"files": [ "files": [
@ -39,11 +46,13 @@
"middleware" "middleware"
], ],
"dependencies": { "dependencies": {
"@sapphirecode/crypto-helper": "^1.3.0", "@sapphirecode/crypto-helper": "^2.0.0",
"@sapphirecode/encoding-helper": "^1.1.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": { "engines": {
"node": ">=10.0.0" "node": ">=10.0.0"
} }
} }

View File

@ -16,10 +16,11 @@ export class Response extends http.IncomingMessage {
export function get ( export function get (
// eslint-disable-next-line default-param-last // eslint-disable-next-line default-param-last
headers: http.OutgoingHttpHeaders = {}, headers: http.OutgoingHttpHeaders = {},
body?: string body?: string|null,
path = ''
): Promise<Response> { ): Promise<Response> {
return new Promise ((resolve) => { return new Promise ((resolve) => {
const req = http.request ('http://localhost:3000', { const req = http.request (`http://localhost:3000${path}`, {
headers, headers,
method: typeof body === 'string' ? 'POST' : 'GET' method: typeof body === 'string' ? 'POST' : 'GET'
}, (res: Response) => { }, (res: Response) => {
@ -59,7 +60,7 @@ export function assert_keystore_state (): void {
} }
/* eslint-enable dot-notation */ /* eslint-enable dot-notation */
export function clock_setup ():void { export function clock_setup (): void {
assert_keystore_state (); assert_keystore_state ();
const date = (new Date); const date = (new Date);
@ -70,7 +71,7 @@ export function clock_setup ():void {
.mockDate (date); .mockDate (date);
} }
export function clock_finalize ():void { export function clock_finalize (): void {
jasmine.clock () jasmine.clock ()
.tick (30 * 24 * 60 * 60 * 1000); .tick (30 * 24 * 60 * 60 * 1000);
// eslint-disable-next-line dot-notation // eslint-disable-next-line dot-notation

View File

@ -5,6 +5,7 @@
* Created by Timo Hocker <timo@scode.ovh>, January 2021 * Created by Timo Hocker <timo@scode.ovh>, January 2021
*/ */
/* eslint-disable max-lines */
import http, { IncomingMessage, ServerResponse } from 'http'; import http, { IncomingMessage, ServerResponse } from 'http';
import { to_b64 } from '@sapphirecode/encoding-helper'; import { to_b64 } from '@sapphirecode/encoding-helper';
import auth from '../../lib/Authority'; import auth from '../../lib/Authority';
@ -14,6 +15,7 @@ import {
get, modify_signature, Response get, modify_signature, Response
} from '../Helper'; } from '../Helper';
import { create_auth_handler } from '../../lib/index'; import { create_auth_handler } from '../../lib/index';
import { build_cookie, extract_cookie } from '../../lib/cookie';
const expires_seconds = 600; const expires_seconds = 600;
const refresh_expires_seconds = 3600; const refresh_expires_seconds = 3600;
@ -36,8 +38,8 @@ function check_headers (resp: Response): CheckHeaderResult {
return { data, at, rt }; return { data, at, rt };
} }
function check_token (token: string, type: string):void { async function check_token (token: string|null, type: string): Promise<void> {
const v = auth.verify (token); const v = await auth.verify (token || '');
expect (v.valid) expect (v.valid)
.toEqual (true); .toEqual (true);
expect (v.authorized) expect (v.authorized)
@ -55,7 +57,8 @@ describe ('auth handler', () => {
beforeAll (() => { beforeAll (() => {
clock_setup (); clock_setup ();
const ah = create_auth_handler ((req) => { // eslint-disable-next-line complexity, max-lines-per-function
const ah = create_auth_handler (async (req) => {
if (!req.is_basic && !req.is_bearer) { if (!req.is_basic && !req.is_bearer) {
let body_auth = false; let body_auth = false;
try { try {
@ -88,12 +91,29 @@ describe ('auth handler', () => {
else if (req.user === 'part' && req.password === 'bar') { else if (req.user === 'part' && req.password === 'bar') {
req.allow_part (part_expires_seconds, 'two_factor'); 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 { else {
req.deny (); req.deny ();
} }
}, { }, {
cookie_name: 'cookie_jar', cookie: { name: 'cookie_jar' },
refresh: { refresh_cookie: { name: 'mint_cookies' },
refresh: {
access_token_expires_in: expires_seconds, access_token_expires_in: expires_seconds,
refresh_token_expires_in: refresh_expires_seconds, refresh_token_expires_in: refresh_expires_seconds,
include_refresh_token: true include_refresh_token: true
@ -109,11 +129,17 @@ describe ('auth handler', () => {
} }
else { request.deny (); } else { request.deny (); }
} }
} },
parse_body: true
}); });
server = http.createServer ((req: IncomingMessage, res: ServerResponse) => { server = http.createServer (async (
ah (req, res); 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); server.listen (3000);
}); });
@ -134,13 +160,15 @@ describe ('auth handler', () => {
expect (res1.data.token_type) expect (res1.data.token_type)
.toEqual ('bearer'); .toEqual ('bearer');
expect (resp1.headers['set-cookie']) 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) expect (res1.data.expires_in)
.toEqual (expires_seconds); .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) expect (res1.data.refresh_expires_in)
.toEqual (refresh_expires_seconds); .toEqual (refresh_expires_seconds);
@ -153,14 +181,16 @@ describe ('auth handler', () => {
expect (res2.data.token_type) expect (res2.data.token_type)
.toEqual ('bearer'); .toEqual ('bearer');
expect (resp2.headers['set-cookie']) 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) expect (res2.data.expires_in)
.toEqual (expires_seconds); .toEqual (expires_seconds);
expect (res2.at).not.toEqual (res1.at); 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) expect (res2.data.refresh_expires_in)
.toEqual (refresh_expires_seconds); .toEqual (refresh_expires_seconds);
expect (res2.rt).not.toEqual (res1.rt); expect (res2.rt).not.toEqual (res1.rt);
@ -183,13 +213,15 @@ describe ('auth handler', () => {
expect (res1.data.token_type) expect (res1.data.token_type)
.toEqual ('bearer'); .toEqual ('bearer');
expect (resp1.headers['set-cookie']) 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) expect (res1.data.expires_in)
.toEqual (expires_seconds); .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) expect (res1.data.refresh_expires_in)
.toEqual (refresh_expires_seconds); .toEqual (refresh_expires_seconds);
}); });
@ -206,13 +238,15 @@ describe ('auth handler', () => {
expect (res1.data.token_type) expect (res1.data.token_type)
.toEqual ('bearer'); .toEqual ('bearer');
expect (resp1.headers['set-cookie']) 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) expect (res1.data.expires_in)
.toEqual (expires_seconds); .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) expect (res1.data.refresh_expires_in)
.toEqual (refresh_expires_seconds); .toEqual (refresh_expires_seconds);
}); });
@ -265,7 +299,7 @@ describe ('auth handler', () => {
.toEqual ('bearer'); .toEqual ('bearer');
expect (res1.data.expires_in) expect (res1.data.expires_in)
.toEqual (part_expires_seconds); .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 ( const resp2 = await get (
{ authorization: `Bearer ${res1.data.part_token}` }, { authorization: `Bearer ${res1.data.part_token}` },
@ -277,19 +311,34 @@ describe ('auth handler', () => {
expect (res2.data.token_type) expect (res2.data.token_type)
.toEqual ('bearer'); .toEqual ('bearer');
expect (resp2.headers['set-cookie']) 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) expect (res2.data.expires_in)
.toEqual (expires_seconds); .toEqual (expires_seconds);
expect (res2.at).not.toEqual (res1.at); 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) expect (res2.data.refresh_expires_in)
.toEqual (refresh_expires_seconds); .toEqual (refresh_expires_seconds);
expect (res2.rt).not.toEqual (res1.rt); expect (res2.rt).not.toEqual (res1.rt);
}); });
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 () => { it ('should handle any authorization type', async () => {
const resp = await get ({ authorization: 'Foo asdefg' }); const resp = await get ({ authorization: 'Foo asdefg' });
expect (resp.statusCode) expect (resp.statusCode)
@ -300,4 +349,30 @@ describe ('auth handler', () => {
error_description: 'unknown authorization type' error_description: 'unknown authorization type'
}); });
}); });
it ('should not set content-type when leave-open is specified', async () => {
const resp1 = await get ({ authorization: 'Basic leave:open' });
expect (resp1.statusCode)
.toEqual (200);
expect (resp1.headers['content-type'])
.toEqual ('text/plain');
expect (resp1.body)
.toEqual ('custom response, true');
const signature = extract_cookie (
'cookie_jar',
(resp1.headers['set-cookie'] || []).join ('\n')
);
expect (signature).not.toEqual ('');
await check_token (signature, 'access_token');
});
it ('should disallow access and refresh cookies with the same name', () => {
expect (() => {
create_auth_handler (() => Promise.resolve (), {
cookie: { name: 'foo' },
refresh_cookie: { name: 'foo' }
});
})
.toThrowError ('access and refresh cookies cannot have the same name');
});
}); });

View File

@ -27,7 +27,7 @@ describe ('authority', () => {
const token = await auth.sign ('access_token', 60); const token = await auth.sign ('access_token', 60);
jasmine.clock () jasmine.clock ()
.tick (30000); .tick (30000);
const res = auth.verify (token.signature); const res = await auth.verify (token.signature);
expect (res.authorized) expect (res.authorized)
.toBeTrue (); .toBeTrue ();
expect (res.valid) expect (res.valid)
@ -46,7 +46,7 @@ describe ('authority', () => {
const token = await auth.sign ('refresh_token', 600); const token = await auth.sign ('refresh_token', 600);
jasmine.clock () jasmine.clock ()
.tick (30000); .tick (30000);
const res = auth.verify (token.signature); const res = await auth.verify (token.signature);
expect (res.authorized) expect (res.authorized)
.toBeFalse (); .toBeFalse ();
expect (res.valid) expect (res.valid)
@ -65,7 +65,7 @@ describe ('authority', () => {
const token = await auth.sign ('part_token', 60, { next_module: '2fa' }); const token = await auth.sign ('part_token', 60, { next_module: '2fa' });
jasmine.clock () jasmine.clock ()
.tick (30000); .tick (30000);
const res = auth.verify (token.signature); const res = await auth.verify (token.signature);
expect (res.authorized) expect (res.authorized)
.toBeFalse (); .toBeFalse ();
expect (res.valid) expect (res.valid)
@ -85,7 +85,7 @@ describe ('authority', () => {
token.signature = modify_signature (token.signature); token.signature = modify_signature (token.signature);
jasmine.clock () jasmine.clock ()
.tick (30000); .tick (30000);
const res = auth.verify (token.signature); const res = await auth.verify (token.signature);
expect (res.authorized) expect (res.authorized)
.toBeFalse (); .toBeFalse ();
expect (res.valid) expect (res.valid)
@ -104,8 +104,8 @@ describe ('authority', () => {
const token = await auth.sign ('access_token', 60); const token = await auth.sign ('access_token', 60);
jasmine.clock () jasmine.clock ()
.tick (30000); .tick (30000);
bl.add_signature (token.id); await bl.add_signature (token.id);
const res = auth.verify (token.signature); const res = await auth.verify (token.signature);
expect (res.authorized) expect (res.authorized)
.toBeFalse (); .toBeFalse ();
expect (res.valid) expect (res.valid)
@ -125,7 +125,7 @@ describe ('authority', () => {
token.signature = modify_signature (token.signature); token.signature = modify_signature (token.signature);
jasmine.clock () jasmine.clock ()
.tick (30000); .tick (30000);
const res = auth.verify (token.signature); const res = await auth.verify (token.signature);
expect (res.authorized) expect (res.authorized)
.toBeFalse (); .toBeFalse ();
expect (res.valid) expect (res.valid)
@ -144,8 +144,8 @@ describe ('authority', () => {
const token = await auth.sign ('refresh_token', 600); const token = await auth.sign ('refresh_token', 600);
jasmine.clock () jasmine.clock ()
.tick (30000); .tick (30000);
bl.add_signature (token.id); await bl.add_signature (token.id);
const res = auth.verify (token.signature); const res = await auth.verify (token.signature);
expect (res.authorized) expect (res.authorized)
.toBeFalse (); .toBeFalse ();
expect (res.valid) expect (res.valid)

View File

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

View File

@ -6,7 +6,7 @@
*/ */
import http from 'http'; import http from 'http';
import { create_gateway } from '../../lib/index'; import { GatewayClass, create_gateway } from '../../lib/index';
import authority from '../../lib/Authority'; import authority from '../../lib/Authority';
import blacklist from '../../lib/Blacklist'; import blacklist from '../../lib/Blacklist';
import { clock_finalize, clock_setup, get } from '../Helper'; import { clock_finalize, clock_setup, get } from '../Helper';
@ -18,18 +18,33 @@ describe ('gateway', () => {
beforeAll (() => { beforeAll (() => {
clock_setup (); clock_setup ();
const g = create_gateway ({ const g = new GatewayClass ({
redirect_url: 'http://localhost/auth', redirect_url: 'http://localhost/auth',
cookie_name: 'cookie_jar' 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) => { server = http.createServer ((req, res) => {
const passed_handler = () => { const passed_handler = () => {
if (typeof req.url !== 'undefined') {
if (req.url.endsWith ('logout'))
g.logout (req);
}
res.writeHead (200); res.writeHead (200);
const con = req.connection as unknown as Record<string, unknown>; const data = {
res.end (JSON.stringify (con.auth)); ...g.get_info (req),
foo: g.has_permission (req, 'foo'),
bar: g.has_permission (req, 'bar')
};
res.end (JSON.stringify (data));
}; };
g (req, res, passed_handler); g.process_request (req, res, passed_handler);
}); });
server.listen (3000); server.listen (3000);
}); });
@ -61,13 +76,37 @@ describe ('gateway', () => {
it ('should allow a valid access token using cookies', async () => { it ('should allow a valid access token using cookies', async () => {
const token = await authority.sign ('access_token', 60); const token = await authority.sign ('access_token', 60);
const resp = await get ({ cookie: `cookie_jar=${token.signature}` }); const resp = await get (
{ cookie: `foo=bar;cookie_jar=${token.signature};asd=efg` }
);
expect (resp.statusCode) expect (resp.statusCode)
.toEqual (200); .toEqual (200);
expect (JSON.parse (resp.body as string).token_id) expect (JSON.parse (resp.body as string).token_id)
.toEqual (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 () => { it ('should correctly deliver token data', async () => {
const token = await authority.sign ('access_token', 60, { data: 'foobar' }); const token = await authority.sign ('access_token', 60, { data: 'foobar' });
const resp = await get ({ authorization: `Bearer ${token.signature}` }); const resp = await get ({ authorization: `Bearer ${token.signature}` });
@ -78,6 +117,8 @@ describe ('gateway', () => {
.toEqual (token.id); .toEqual (token.id);
expect (body.token_data) expect (body.token_data)
.toEqual ('foobar'); .toEqual ('foobar');
expect (body.permissions)
.toEqual ([]);
}); });
it ('should reject an outdated access token', async () => { it ('should reject an outdated access token', async () => {
@ -93,7 +134,7 @@ describe ('gateway', () => {
it ('should reject a blacklisted access token', async () => { it ('should reject a blacklisted access token', async () => {
const token = await authority.sign ('access_token', 60); const token = await authority.sign ('access_token', 60);
blacklist.add_signature (token.id); await blacklist.add_signature (token.id);
const resp = await get ({ authorization: `Bearer ${token.signature}` }); const resp = await get ({ authorization: `Bearer ${token.signature}` });
expect (resp.statusCode) expect (resp.statusCode)
.toEqual (302); .toEqual (302);
@ -135,4 +176,51 @@ describe ('gateway', () => {
expect (resp.headers.location) expect (resp.headers.location)
.toEqual ('http://localhost/auth'); .toEqual ('http://localhost/auth');
}); });
it ('should disallow access and refresh cookies with the same name', () => {
expect (() => {
create_gateway ({
cookie: { name: 'foo' },
refresh_cookie: { name: 'foo' }
});
})
.toThrowError ('access and refresh cookies cannot have the same name');
});
it ('should logout all tokens', async () => {
const token = await authority.sign ('access_token', 60);
const refresh = await authority.sign ('refresh_token', 3600);
const resp = await get (
// eslint-disable-next-line max-len
{ cookie: `foo=bar;cookie_jar=${token.signature};asd=efg;mint_cookies=${refresh.signature}` },
null,
'/logout'
);
expect (resp.statusCode)
.toEqual (200);
const blacklisted = blacklist.export_blacklist ()
.map ((v) => v.token_id);
expect (blacklisted)
.toContain (token.id);
expect (blacklisted)
.toContain (refresh.id);
});
it ('should correctly check permissions', async () => {
const token = await authority.sign (
'access_token',
60,
{ permissions: [ 'foo' ] }
);
const resp = await get ({ authorization: `Bearer ${token.signature}` });
expect (resp.statusCode)
.toEqual (200);
expect (JSON.parse (resp.body as string))
.toEqual ({
token_id: token.id,
permissions: [ 'foo' ],
foo: true,
bar: false
});
});
}); });

View File

@ -20,14 +20,14 @@ describe ('key store', () => {
clock_finalize (); clock_finalize ();
}); });
const keys: {key:string, sign:string, iat:number}[] = []; const keys: {key: string, sign: string, iat: number}[] = [];
it ('should generate a new key', async () => { it ('should generate a new key', async () => {
const iat = (new Date) const iat = (new Date)
.getTime () / 1000; .getTime () / 1000;
const duration = 10 * frame; const duration = 10 * frame;
const key = await ks.get_sign_key (iat, duration); const key = await ks.get_sign_key (iat, duration);
const sign = ks.get_key (iat); const sign = await ks.get_key (iat);
expect (typeof key) expect (typeof key)
.toEqual ('string'); .toEqual ('string');
expect (typeof sign) expect (typeof sign)
@ -39,7 +39,7 @@ describe ('key store', () => {
const key = await ks.get_sign_key (keys[0].iat, 1); const key = await ks.get_sign_key (keys[0].iat, 1);
expect (key) expect (key)
.toEqual (keys[0].key); .toEqual (keys[0].key);
const sign = ks.get_key (keys[0].iat); const sign = await ks.get_key (keys[0].iat);
expect (sign) expect (sign)
.toEqual (keys[0].sign); .toEqual (keys[0].sign);
}); });
@ -48,7 +48,7 @@ describe ('key store', () => {
const key = await ks.get_sign_key (keys[0].iat + (frame / 2), 1); const key = await ks.get_sign_key (keys[0].iat + (frame / 2), 1);
expect (key) expect (key)
.toEqual (keys[0].key); .toEqual (keys[0].key);
const sign = ks.get_key (keys[0].iat + (frame / 2)); const sign = await ks.get_key (keys[0].iat + (frame / 2));
expect (sign) expect (sign)
.toEqual (keys[0].sign); .toEqual (keys[0].sign);
}); });
@ -60,7 +60,7 @@ describe ('key store', () => {
.getTime () / 1000; .getTime () / 1000;
const duration = 10 * frame; const duration = 10 * frame;
const key = await ks.get_sign_key (iat, duration); const key = await ks.get_sign_key (iat, duration);
const sign = ks.get_key (iat); const sign = await ks.get_key (iat);
expect (typeof key) expect (typeof key)
.toEqual ('string'); .toEqual ('string');
expect (key).not.toEqual (keys[0].key); expect (key).not.toEqual (keys[0].key);
@ -69,32 +69,32 @@ describe ('key store', () => {
}); });
it ('should return both keys, but not the first sign key', async () => { it ('should return both keys, but not the first sign key', async () => {
const sign = ks.get_key (keys[0].iat); const sign = await ks.get_key (keys[0].iat);
expect (sign) expect (sign)
.toEqual (keys[0].sign); .toEqual (keys[0].sign);
await expectAsync (ks.get_sign_key (keys[0].iat, 1)) await expectAsync (ks.get_sign_key (keys[0].iat, 1))
.toBeRejectedWithError ('cannot access already expired keys'); .toBeRejectedWithError ('cannot access already expired keys');
const k2 = await ks.get_sign_key (keys[1].iat, 1); const k2 = await ks.get_sign_key (keys[1].iat, 1);
const s2 = ks.get_key (keys[1].iat); const s2 = await ks.get_key (keys[1].iat);
expect (k2) expect (k2)
.toEqual (keys[1].key); .toEqual (keys[1].key);
expect (s2) expect (s2)
.toEqual (keys[1].sign); .toEqual (keys[1].sign);
}); });
it ('should throw on non existing key', () => { it ('should throw on non existing key', async () => {
expect (() => ks.get_key (keys[1].iat + frame)) await expectAsync (ks.get_key (keys[1].iat + frame))
.toThrowError ('key could not be found'); .toBeRejectedWithError ('key could not be found');
}); });
it ('should delete a key after it expires', () => { it ('should delete a key after it expires', async () => {
// go to 10 frames + 1ms after key creation // go to 10 frames + 1ms after key creation
jasmine.clock () jasmine.clock ()
.tick ((frame * 9e3) + 1); .tick ((frame * 9e3) + 1);
// eslint-disable-next-line dot-notation // eslint-disable-next-line dot-notation
ks['garbage_collect'] (); ks['garbage_collect'] ();
expect (() => ks.get_key (keys[0].iat)) await expectAsync (ks.get_key (keys[0].iat))
.toThrowError ('key could not be found'); .toBeRejectedWithError ('key could not be found');
}); });
it ( it (
@ -102,7 +102,7 @@ describe ('key store', () => {
async () => { async () => {
await expectAsync (ks.get_sign_key (keys[1].iat, 1)) await expectAsync (ks.get_sign_key (keys[1].iat, 1))
.toBeRejectedWithError ('cannot access already expired keys'); .toBeRejectedWithError ('cannot access already expired keys');
const sign = ks.get_key (keys[1].iat); const sign = await ks.get_key (keys[1].iat);
expect (sign) expect (sign)
.toEqual (keys[1].sign); .toEqual (keys[1].sign);
} }
@ -129,12 +129,12 @@ describe ('key store', () => {
jasmine.clock () jasmine.clock ()
.tick (step * 1000); .tick (step * 1000);
const key2 = await ks.get_sign_key (iat + step, duration2); const key2 = await ks.get_sign_key (iat + step, duration2);
const sign = ks.get_key (iat); const sign = await ks.get_key (iat);
expect (key1) expect (key1)
.toEqual (key2); .toEqual (key2);
jasmine.clock () jasmine.clock ()
.tick (5000 * frame); .tick (5000 * frame);
const signv = ks.get_key (iat + step); const signv = await ks.get_key (iat + step);
expect (signv) expect (signv)
.toEqual (sign); .toEqual (sign);
}); });
@ -151,29 +151,26 @@ describe ('key store', () => {
.getTime () / 1000; .getTime () / 1000;
const sign = await ks.get_sign_key (iat, frame); const sign = await ks.get_sign_key (iat, frame);
const ver = ks.get_key (iat); const ver = await ks.get_key (iat);
const exp = ks.export_verification_data (); const exp = ks.export_verification_data ();
// eslint-disable-next-line dot-notation // eslint-disable-next-line dot-notation
expect (Object.keys (ks['_keys'])) expect (Object.keys (ks['_keys']))
.toEqual (Object.keys (exp)); .toEqual (exp.map ((v) => v.index));
expect (Object.keys (exp)
.filter ((v) => typeof exp[v].private_key !== 'undefined').length)
.toEqual (0);
const ks2 = (new KeyStore); const ks2 = (new KeyStore);
expect (ks2.instance_id).not.toEqual (ks.instance_id); expect (ks2.instance_id).not.toEqual (ks.instance_id);
ks2.import_verification_data (exp); ks2.import_verification_data (exp);
// eslint-disable-next-line dot-notation // eslint-disable-next-line dot-notation
expect (ks2['_keys']) expect (Object.keys (ks2['_keys']))
.toEqual (exp); .toEqual (exp.map ((v) => v.index));
const sign2 = await ks2.get_sign_key (iat, frame); const sign2 = await ks2.get_sign_key (iat, frame);
const ver2 = ks2.get_key (iat); const ver2 = await ks2.get_key (iat);
expect (sign).not.toEqual (sign2); expect (sign).not.toEqual (sign2);
expect (ver).not.toEqual (ver2); expect (ver).not.toEqual (ver2);
await expectAsync (ks2.get_sign_key (iat, 60, ks.instance_id)) await expectAsync (ks2.get_sign_key (iat, 60, ks.instance_id))
.toBeRejectedWithError ('cannot access already expired keys'); .toBeRejectedWithError ('cannot access already expired keys');
expect (ks2.get_key (iat, ks.instance_id)) expect (await ks2.get_key (iat, ks.instance_id))
.toEqual (ver); .toEqual (ver);
}); });
@ -182,4 +179,16 @@ describe ('key store', () => {
expect (() => ks.import_verification_data (exp)) expect (() => ks.import_verification_data (exp))
.toThrowError ('cannot import to the same instance'); .toThrowError ('cannot import to the same instance');
}); });
it ('should clear all', () => {
// eslint-disable-next-line dot-notation
expect (Object.keys (ks['_keys']).length)
.toBeGreaterThan (0);
const instance = ks.instance_id;
ks.reset_instance ();
// eslint-disable-next-line dot-notation
expect (Object.keys (ks['_keys']).length)
.toEqual (0);
expect (instance).not.toEqual (ks.instance_id);
});
}); });

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

@ -0,0 +1,110 @@
/*
* Copyright (C) Sapphirecode - All Rights Reserved
* This file is part of Auth-Server-Helper which is released under MIT.
* See file 'LICENSE' for full license details.
* Created by Timo Hocker <timo@scode.ovh>, August 2022
*/
/* eslint-disable dot-notation */
import { blacklist } from '../../lib';
import ks from '../../lib/KeyStore';
import { Redis } from '../../lib/Redis';
import { generate_token_id } from '../../lib/token_id';
import { clock_finalize, clock_setup } from '../Helper';
const frame = 3600;
const redis_url = process.env.TEST_REDIS_URL || 'redis://localhost';
const redis = new Redis;
redis.connect (redis_url);
// eslint-disable-next-line max-lines-per-function
describe ('redis', () => {
const token1 = generate_token_id (new Date (Date.now () + 3600000));
const token2 = generate_token_id (new Date (Date.now () + 3600000));
const token3 = generate_token_id (new Date (Date.now () + 3600000));
beforeAll (async () => {
ks.reset_instance ();
ks.sync_redis (redis_url);
await blacklist.clear ();
blacklist.sync_redis (redis_url);
clock_setup ();
});
let iat1 = 0;
let iat2 = 0;
let k1 = '';
let k2 = '';
let i1 = '';
let i2 = '';
afterAll (() => clock_finalize ());
it ('should generate two keys', async () => {
iat1 = (new Date)
.getTime () / 1000;
await ks.get_sign_key (iat1, frame);
k1 = await ks.get_key (iat1);
jasmine.clock ()
.tick (frame * 1000);
iat2 = (new Date)
.getTime () / 1000;
await ks.get_sign_key (iat2, frame);
k2 = await ks.get_key (iat2);
// eslint-disable-next-line dot-notation
i1 = ks['get_index'] (iat1);
// eslint-disable-next-line dot-notation
i2 = ks['get_index'] (iat2);
});
it ('should have two keys in redis', async () => {
expect (JSON.parse (await redis['_redis']
?.get (`keystore_${i1}`) as string).key)
.toEqual (k1);
expect (JSON.parse (await redis['_redis']
?.get (`keystore_${i2}`) as string).key)
.toEqual (k2);
});
it ('should read two keys with a new instance', async () => {
const old_instance = ks.instance_id;
ks.reset_instance ();
expectAsync (ks.get_key (iat1, old_instance))
.toBeRejectedWithError ('key could not be found');
expectAsync (ks.get_key (iat1, old_instance))
.toBeRejectedWithError ('key could not be found');
ks.sync_redis (redis_url);
expect (await ks.get_key (iat1, old_instance))
.toEqual (k1);
expect (await ks.get_key (iat2, old_instance))
.toEqual (k2);
});
it ('should add two keys to the blacklist', async () => {
await blacklist.add_signature (token1);
await blacklist.add_signature (token2);
});
it ('should have two keys in redis blacklist', async () => {
expect ((await redis['_redis']?.exists (`blacklist_${token1}`)) === 1)
.toBeTrue ();
expect ((await redis['_redis']?.exists (`blacklist_${token2}`)) === 1)
.toBeTrue ();
expect ((await redis['_redis']?.exists (`blacklist_${token3}`)) === 1)
.toBeFalse ();
});
it ('should read keys from redis', async () => {
blacklist['_signatures'].splice (0, blacklist['_signatures'].length);
expect (await blacklist.is_valid (token1))
.toBeFalse ();
expect (await blacklist.is_valid (token2))
.toBeFalse ();
expect (await blacklist.is_valid (token3))
.toBeTrue ();
});
});

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

@ -0,0 +1,130 @@
/*
* Copyright (C) Sapphirecode - All Rights Reserved
* This file is part of Auth-Server-Helper which is released under MIT.
* See file 'LICENSE' for full license details.
* Created by Timo Hocker <timo@scode.ovh>, January 2022
*/
import { build_cookie, CookieSettings, extract_cookie } from '../../lib/cookie';
interface CreateCookie {
settings: CookieSettings
value: string
result: string
}
const create_cookie_pairs: CreateCookie[] = [
{
settings: { name: 'foo' },
value: 'bar',
result: 'foo=bar; Secure; HttpOnly; SameSite=Strict'
},
{
settings: { name: 'foäöüo' },
value: 'baäöür',
result: 'foäöüo=baäöür; Secure; HttpOnly; SameSite=Strict'
},
{
settings: {
name: 'foo',
secure: true,
http_only: false,
same_site: null
},
value: 'bar',
result: 'foo=bar; Secure'
},
{
settings: {
name: 'foo',
secure: false,
http_only: true,
same_site: null
},
value: 'bar',
result: 'foo=bar; HttpOnly'
},
{
settings: {
name: 'foo',
secure: false,
http_only: false,
same_site: 'Lax'
},
value: 'bar',
result: 'foo=bar; SameSite=Lax'
},
{
settings: {
name: 'foo',
secure: false,
http_only: false,
same_site: null,
expires: 'Tomorrow'
},
value: 'bar',
result: 'foo=bar; Expires=Tomorrow'
},
{
settings: {
name: 'foo',
secure: false,
http_only: false,
same_site: null,
max_age: 600
},
value: 'bar',
result: 'foo=bar; Max-Age=600'
},
{
settings: {
name: 'foo',
secure: false,
http_only: false,
same_site: null,
domain: 'example.com'
},
value: 'bar',
result: 'foo=bar; Domain=example.com'
},
{
settings: {
name: 'foo',
secure: false,
http_only: false,
same_site: null,
path: '/test'
},
value: 'bar',
result: 'foo=bar; Path=/test'
}
];
const parse_cookie_pairs = [
{
header: 'foo=bar; Secure; HttpOnly; SameSite=Strict',
name: 'foo',
value: 'bar'
},
{
header: '134=567;foäöüo=baäöür;tesT=123',
name: 'foäöüo',
value: 'baäöür'
}
];
describe ('cookie', () => {
it ('should create a cookie', () => {
for (const pair of create_cookie_pairs) {
expect (build_cookie (pair.settings, pair.value))
.toEqual (pair.result);
}
});
it ('should parse a cookie', () => {
for (const pair of parse_cookie_pairs) {
expect (extract_cookie (pair.name, pair.header))
.toEqual (pair.value);
}
});
});

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

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

View File

@ -2,13 +2,15 @@
"compilerOptions": { "compilerOptions": {
"target": "es5", "target": "es5",
"module": "commonjs", "module": "commonjs",
"outDir": "./dist", "outDir": "./dist",
"rootDir": "./lib",
"strict": true, "strict": true,
"esModuleInterop": true, "esModuleInterop": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"declaration": true, "declaration": true,
"sourceMap": true "sourceMap": true
}, },
"exclude": ["test/**/*.ts"] "include": [
"lib/**/*.ts",
"test/**/*.ts"
]
} }

3144
yarn.lock

File diff suppressed because it is too large Load Diff