Compare commits
42 Commits
9ec97d8aa2
...
master
Author | SHA1 | Date | |
---|---|---|---|
a41882de60
|
|||
ba9608829d | |||
910099285b | |||
6856ac718f | |||
6eb2009141 | |||
64d4f00629 | |||
31f739d4b8
|
|||
e80e3f9a94
|
|||
b7514941f0
|
|||
d5c136790e
|
|||
43cf782511 | |||
b43190d048 | |||
7bb6dac737 | |||
1009a9b8d5 | |||
da36f87250
|
|||
cf2f9c0182 | |||
4d69efd9f5 | |||
fd26975559 | |||
122bd7b574
|
|||
84be087743 | |||
ec08f8f04e
|
|||
cc8762e4ec
|
|||
3aaaf10fd9
|
|||
8f047f2700
|
|||
80a98704af
|
|||
c7708f4bc0
|
|||
b58af27719
|
|||
2a51e0a753
|
|||
22075489c2
|
|||
1188e4573f
|
|||
d28be9e3f8
|
|||
dab45e39a6
|
|||
4820bda8ca
|
|||
86b07af63d
|
|||
85a5f3c2fb
|
|||
c55ed33e53 | |||
3bc5538a69 | |||
e7ad5656e3 | |||
a3f021fdd2 | |||
d286548850 | |||
e326c6c077 | |||
ce58c0d204 |
@ -6,10 +6,10 @@ steps:
|
||||
image: registry:5000/node-build
|
||||
commands:
|
||||
- yarn
|
||||
- echo "process.exit(1)" > ci.js
|
||||
# - curl https://git.scode.ovh/Timo/standard/raw/branch/master/ci.js > ci.js
|
||||
- curl https://git.scode.ovh/Timo/standard/raw/branch/master/ci.js > ci.js
|
||||
|
||||
- name: build
|
||||
image: registry:5000/node-build
|
||||
commands:
|
||||
- redis-server --daemonize yes
|
||||
- node ci.js
|
||||
|
41
CHANGELOG.md
41
CHANGELOG.md
@ -1,5 +1,46 @@
|
||||
# Changelog
|
||||
|
||||
## 4.1.0
|
||||
|
||||
- Permission Management
|
||||
- Gateway function to read connection info
|
||||
|
||||
## 4.0.0
|
||||
|
||||
- Blacklist entries can now be synchronized through redis
|
||||
|
||||
BREAKING: Blacklist functions are now asynchronous
|
||||
|
||||
## 3.3.0
|
||||
|
||||
- Verification Keys can now be synchronized through redis
|
||||
|
||||
## 3.2.0
|
||||
|
||||
- Logout function
|
||||
|
||||
## 3.1.0
|
||||
|
||||
- Option to enable body parsing
|
||||
|
||||
## 3.0.0
|
||||
|
||||
- Allows Cookies Parameters to be set
|
||||
|
||||
BREAKING:
|
||||
|
||||
- All cookie_name and refresh_cookie_name properties have been renamed to cookie and refresh_cookie and are now a settings object instead of a string
|
||||
|
||||
## 2.2.0
|
||||
|
||||
- Allow refresh tokens to be sent on a separate cookie
|
||||
- Automatic token refresh if the access token is expired and the cookie header contains a valid refresh token
|
||||
|
||||
## 2.1.0
|
||||
|
||||
- Allow access to Gateway functions like authenticate, get_cookie_auth, get_header_auth, redirect, deny
|
||||
- Allow Gateway to deny a request in case no redirect url is specified
|
||||
|
||||
## 2.0.0
|
||||
|
||||
Complete redesign
|
||||
|
171
README.md
171
README.md
@ -1,6 +1,6 @@
|
||||
# auth-server-helper
|
||||
|
||||
version: 2.0.0
|
||||
version: 4.1.x
|
||||
|
||||
customizable and simple authentication
|
||||
|
||||
@ -22,8 +22,13 @@ yarn:
|
||||
const {create_gateway} = require('@sapphirecode/auth-server-helper');
|
||||
|
||||
const gateway = create_gateway({
|
||||
redirect_url: '/auth',
|
||||
cookie_name: 'auth_cookie', // if defined, access tokens will be read from this cookie
|
||||
redirect_url: '/auth', // if defined, unauthorized requests will be redirected
|
||||
cookie: { name: 'auth_cookie' }, // if defined, access tokens will be read from or written to this cookie,
|
||||
refresh_cookie: { name: 'refresh_cookie' }, // if defined, refresh tokens will be read and used to automatically refresh client tokens (requires the refresh_settings attribute)
|
||||
refresh_settings: {
|
||||
// same as settings for allow_access under section 2
|
||||
// the options data, redirect_to and leave_open are not supported here
|
||||
}
|
||||
});
|
||||
|
||||
// express
|
||||
@ -40,6 +45,23 @@ http.createServer((main_req, main_res) =>
|
||||
the gateway will forward any authorized requests to the next handler and
|
||||
redirect all others to the specified url
|
||||
|
||||
#### 1.1. Creating a gateway for manual processing of requests
|
||||
|
||||
```js
|
||||
const {GatewayClass} = require('@sapphirecode/auth-server-helper');
|
||||
|
||||
const gateway = new GatewayClass({ /* options */ }); // options are the same as for create_gateway above
|
||||
|
||||
// process a request
|
||||
if (gateway.authenticate(http_request)) { // returns true if request is valid and sets req.connection.token_id and .token_data
|
||||
console.log('access granted');
|
||||
} else {
|
||||
gateway.redirect(response); // redirects the client, triggers deny if no redirect_url was set in options
|
||||
// or
|
||||
gateway.deny(response); // sends status 403
|
||||
}
|
||||
```
|
||||
|
||||
### 2. creating the auth endpoint
|
||||
|
||||
```js
|
||||
@ -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
|
||||
},
|
||||
},
|
||||
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
|
||||
// ... 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
|
||||
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
|
||||
|
||||
```js
|
||||
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
|
||||
@ -116,6 +201,15 @@ const export = keystore.export_verification_data();
|
||||
keystore.import_verification_data(export);
|
||||
```
|
||||
|
||||
These keys can also be live synchronized with redis to allow sessions to be
|
||||
shared between servers
|
||||
|
||||
```js
|
||||
const {keystore} = require('@sapphirecode/auth-server-helper');
|
||||
|
||||
keystore.sync_redis('redis://localhost');
|
||||
```
|
||||
|
||||
### Exporting and importing blacklist entries across server instances
|
||||
|
||||
```js
|
||||
@ -128,6 +222,69 @@ const export = blacklist.export_blacklist();
|
||||
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
|
||||
|
||||
MIT © Timo Hocker <timo@scode.ovh>
|
||||
|
@ -1,13 +1,11 @@
|
||||
|
||||
{
|
||||
"spec_dir": "test",
|
||||
"spec_dir": "dist/test",
|
||||
"spec_files": [
|
||||
"spec/*.js",
|
||||
"spec/*.ts"
|
||||
"spec/*.js"
|
||||
],
|
||||
"helpers": [
|
||||
"helpers/*.js",
|
||||
"helpers/*.ts"
|
||||
"helpers/*.js"
|
||||
],
|
||||
"stopSpecOnExpectationFailure": false,
|
||||
"random": false
|
||||
|
@ -8,12 +8,19 @@
|
||||
import { IncomingMessage, ServerResponse } from 'http';
|
||||
import { to_utf8 } from '@sapphirecode/encoding-helper';
|
||||
import auth from './Authority';
|
||||
import { debug } from './debug';
|
||||
import { build_cookie, CookieSettings } from './cookie';
|
||||
|
||||
const logger = debug ('auth');
|
||||
|
||||
interface AccessSettings {
|
||||
access_token_expires_in: number
|
||||
include_refresh_token?: boolean
|
||||
refresh_token_expires_in?: number
|
||||
data?: Record<string, unknown>
|
||||
redirect_to?: string
|
||||
data?: unknown,
|
||||
leave_open?: boolean
|
||||
permissions?: string[]
|
||||
}
|
||||
|
||||
interface AccessResult {
|
||||
@ -29,6 +36,9 @@ interface AccessResponse {
|
||||
refresh_expires_in?: number;
|
||||
}
|
||||
|
||||
type AuthHandler =
|
||||
(req: IncomingMessage, res: ServerResponse) => Promise<boolean>;
|
||||
|
||||
class AuthRequest {
|
||||
public request: IncomingMessage;
|
||||
public response: ServerResponse;
|
||||
@ -44,13 +54,20 @@ class AuthRequest {
|
||||
|
||||
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 (
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
body: string,
|
||||
cookie?: string
|
||||
cookie?: CookieSettings,
|
||||
refresh_cookie?: CookieSettings
|
||||
) {
|
||||
this.request = req;
|
||||
this.response = res;
|
||||
@ -59,28 +76,37 @@ class AuthRequest {
|
||||
this.is_bearer = false;
|
||||
this.user = '';
|
||||
this.password = '';
|
||||
this._cookie_name = cookie;
|
||||
this._cookie = cookie;
|
||||
this._refresh_cookie = refresh_cookie;
|
||||
this._is_successful = false;
|
||||
logger.extend ('constructor') ('started processing new auth request');
|
||||
}
|
||||
|
||||
private default_header () {
|
||||
private default_header (set_content = true) {
|
||||
this.response.setHeader ('Cache-Control', 'no-store');
|
||||
this.response.setHeader ('Pragma', 'no-cache');
|
||||
if (set_content)
|
||||
this.response.setHeader ('Content-Type', 'application/json');
|
||||
}
|
||||
|
||||
// eslint-disable-next-line max-statements, max-lines-per-function
|
||||
public async allow_access ({
|
||||
access_token_expires_in,
|
||||
include_refresh_token,
|
||||
refresh_token_expires_in,
|
||||
data
|
||||
redirect_to,
|
||||
data,
|
||||
leave_open,
|
||||
permissions
|
||||
}: 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 (
|
||||
'access_token',
|
||||
access_token_expires_in,
|
||||
|
||||
{ data }
|
||||
{ data, permissions }
|
||||
);
|
||||
const result: AccessResult = { access_token_id: at.id };
|
||||
|
||||
@ -90,14 +116,13 @@ class AuthRequest {
|
||||
expires_in: access_token_expires_in
|
||||
};
|
||||
|
||||
if (typeof this._cookie_name === 'string') {
|
||||
this.response.setHeader (
|
||||
'Set-Cookie',
|
||||
`${this._cookie_name}=${at.signature}`
|
||||
);
|
||||
}
|
||||
const cookies = [];
|
||||
|
||||
if (typeof this._cookie !== 'undefined')
|
||||
cookies.push (build_cookie (this._cookie, at.signature));
|
||||
|
||||
if (include_refresh_token) {
|
||||
log ('including refresh token');
|
||||
if (typeof refresh_token_expires_in !== 'number')
|
||||
throw new Error ('no expiry time defined for refresh tokens');
|
||||
const rt = await auth.sign (
|
||||
@ -108,9 +133,35 @@ class AuthRequest {
|
||||
res.refresh_token = rt.signature;
|
||||
res.refresh_expires_in = refresh_token_expires_in;
|
||||
result.refresh_token_id = rt.id;
|
||||
|
||||
if (typeof this._refresh_cookie !== 'undefined')
|
||||
cookies.push (build_cookie (this._refresh_cookie, rt.signature));
|
||||
}
|
||||
|
||||
if (cookies.length > 0) {
|
||||
log ('sending %d cookies', cookies.length);
|
||||
this.response.setHeader (
|
||||
'Set-Cookie',
|
||||
cookies
|
||||
);
|
||||
}
|
||||
|
||||
this._is_successful = true;
|
||||
|
||||
if (typeof redirect_to === 'string') {
|
||||
log ('redirecting to %s', redirect_to);
|
||||
this.response.setHeader ('Location', redirect_to);
|
||||
this.response.statusCode = 302;
|
||||
if (!leave_open)
|
||||
this.response.end ();
|
||||
return result;
|
||||
}
|
||||
|
||||
if (!leave_open) {
|
||||
log ('finishing http request');
|
||||
this.response.writeHead (200);
|
||||
this.response.end (JSON.stringify (res));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@ -118,8 +169,11 @@ class AuthRequest {
|
||||
public async allow_part (
|
||||
part_token_expires_in: number,
|
||||
next_module: string,
|
||||
data?: Record<string, unknown>
|
||||
data?: Record<string, unknown>,
|
||||
leave_open = false
|
||||
): Promise<string> {
|
||||
const log = logger.extend ('allow_part');
|
||||
log ('allowed part token');
|
||||
this.default_header ();
|
||||
|
||||
const pt = await auth.sign (
|
||||
@ -134,64 +188,67 @@ class AuthRequest {
|
||||
expires_in: part_token_expires_in
|
||||
};
|
||||
|
||||
if (!leave_open) {
|
||||
log ('finishing http request');
|
||||
this.response.writeHead (200);
|
||||
this.response.end (JSON.stringify (res));
|
||||
}
|
||||
|
||||
this._is_successful = true;
|
||||
return pt.id;
|
||||
}
|
||||
|
||||
public 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.response.writeHead (400);
|
||||
this.response.statusCode = 400;
|
||||
if (!leave_open) {
|
||||
log ('finishing http request');
|
||||
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.response.writeHead (401);
|
||||
this.response.statusCode = 401;
|
||||
if (!leave_open) {
|
||||
log ('finishing http request');
|
||||
this.response.end (JSON.stringify ({ error: 'invalid_client' }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type AuthRequestHandler = (req: AuthRequest) => void|Promise<void>;
|
||||
type AuthRequestHandler = (req: AuthRequest) => Promise<void> | void;
|
||||
|
||||
interface CreateHandlerOptions {
|
||||
refresh?: AccessSettings;
|
||||
modules?: Record<string, AuthRequestHandler>;
|
||||
cookie_name?: string;
|
||||
cookie?: CookieSettings;
|
||||
refresh_cookie?: CookieSettings;
|
||||
parse_body?: boolean;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line max-lines-per-function
|
||||
export default function create_auth_handler (
|
||||
type ProcessRequestOptions = Omit<CreateHandlerOptions, 'parse_body'>
|
||||
|
||||
// eslint-disable-next-line max-lines-per-function, max-statements
|
||||
async function process_request (
|
||||
request: AuthRequest,
|
||||
token: RegExpExecArray | null,
|
||||
default_handler: AuthRequestHandler,
|
||||
options?: CreateHandlerOptions
|
||||
) {
|
||||
// eslint-disable-next-line max-lines-per-function
|
||||
return async function process_request (
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse
|
||||
options?: ProcessRequestOptions
|
||||
): Promise<void> {
|
||||
const body: string = await new Promise ((resolve) => {
|
||||
let data = '';
|
||||
req.on ('data', (c) => {
|
||||
data += c;
|
||||
});
|
||||
req.on ('end', () => {
|
||||
resolve (data);
|
||||
});
|
||||
});
|
||||
|
||||
const request = new AuthRequest (req, res, body, options?.cookie_name);
|
||||
const token = (/(?<type>\S+) (?<token>.+)/ui)
|
||||
.exec (req.headers.authorization as string);
|
||||
|
||||
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;
|
||||
@ -205,14 +262,17 @@ export default function create_auth_handler (
|
||||
}
|
||||
|
||||
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 = auth.verify (request.token as string);
|
||||
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;
|
||||
|
||||
@ -221,6 +281,7 @@ export default function create_auth_handler (
|
||||
&& 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 ();
|
||||
}
|
||||
@ -232,14 +293,61 @@ export default function create_auth_handler (
|
||||
&& typeof token_data.next_module !== 'undefined'
|
||||
&& Object.keys (options.modules)
|
||||
.includes (token_data.next_module)
|
||||
)
|
||||
) {
|
||||
log ('processing module %s', token_data.next_module);
|
||||
return options.modules[token_data.next_module] (request);
|
||||
}
|
||||
|
||||
request.invalid ('invalid bearer type');
|
||||
return Promise.resolve ();
|
||||
}
|
||||
|
||||
log ('no matching login method, triggering default handler');
|
||||
return default_handler (request);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line max-lines-per-function
|
||||
export default function create_auth_handler (
|
||||
default_handler: AuthRequestHandler,
|
||||
options?: CreateHandlerOptions
|
||||
): AuthHandler {
|
||||
logger.extend ('create_auth_handler') ('creating new auth handler');
|
||||
if (
|
||||
typeof options?.cookie !== 'undefined'
|
||||
&& typeof options?.refresh_cookie !== 'undefined'
|
||||
&& options.cookie.name === options.refresh_cookie.name
|
||||
)
|
||||
throw new Error ('access and refresh cookies cannot have the same name');
|
||||
|
||||
return async (
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse
|
||||
): Promise<boolean> => {
|
||||
const body: string = options?.parse_body
|
||||
? await new Promise ((resolve) => {
|
||||
let data = '';
|
||||
req.on ('data', (c) => {
|
||||
data += c;
|
||||
});
|
||||
req.on ('end', () => {
|
||||
resolve (data);
|
||||
});
|
||||
})
|
||||
: '';
|
||||
|
||||
const request = new AuthRequest (
|
||||
req,
|
||||
res,
|
||||
body,
|
||||
options?.cookie,
|
||||
options?.refresh_cookie
|
||||
);
|
||||
const token = (/(?<type>\S+) (?<token>.+)/ui)
|
||||
.exec (req.headers.authorization as string);
|
||||
|
||||
await process_request (request, token, default_handler, options);
|
||||
|
||||
return request.is_successful;
|
||||
};
|
||||
}
|
||||
|
||||
@ -249,5 +357,6 @@ export {
|
||||
AccessResponse,
|
||||
AuthRequest,
|
||||
AuthRequestHandler,
|
||||
CreateHandlerOptions
|
||||
CreateHandlerOptions,
|
||||
AuthHandler
|
||||
};
|
||||
|
@ -6,15 +6,18 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
create_salt,
|
||||
sign_object,
|
||||
verify_signature_get_info
|
||||
} from '@sapphirecode/crypto-helper';
|
||||
import keystore from './KeyStore';
|
||||
import blacklist from './Blacklist';
|
||||
import { debug } from './debug';
|
||||
import { generate_token_id } from './token_id';
|
||||
|
||||
const logger = debug ('authority');
|
||||
|
||||
// eslint-disable-next-line no-shadow
|
||||
type TokenType = 'access_token'|'refresh_token'|'part_token'|'none'
|
||||
type TokenType = 'access_token' | 'none' | 'part_token' | 'refresh_token'
|
||||
|
||||
interface VerificationResult {
|
||||
authorized: boolean;
|
||||
@ -22,6 +25,7 @@ interface VerificationResult {
|
||||
type: TokenType;
|
||||
id: string;
|
||||
next_module?: string;
|
||||
permissions?: string[];
|
||||
data?: unknown;
|
||||
error?: string;
|
||||
}
|
||||
@ -34,22 +38,26 @@ interface SignatureResult {
|
||||
interface SignatureOptions
|
||||
{
|
||||
data?: unknown
|
||||
next_module?: string
|
||||
next_module?: string,
|
||||
permissions?: string[]
|
||||
}
|
||||
|
||||
class Authority {
|
||||
public verify (key: string): VerificationResult {
|
||||
public async verify (key: string): Promise<VerificationResult> {
|
||||
const log = logger.extend ('verify');
|
||||
log ('verifying token');
|
||||
const result: VerificationResult = {
|
||||
authorized: false,
|
||||
valid: false,
|
||||
type: 'none',
|
||||
permissions: [],
|
||||
id: ''
|
||||
};
|
||||
const data = verify_signature_get_info (
|
||||
const data = await verify_signature_get_info (
|
||||
key,
|
||||
(info) => {
|
||||
async (info) => {
|
||||
try {
|
||||
return keystore.get_key (info.iat / 1000, info.iss);
|
||||
return await keystore.get_key (info.iat / 1000, info.iss);
|
||||
}
|
||||
catch {
|
||||
return '';
|
||||
@ -59,6 +67,7 @@ class Authority {
|
||||
);
|
||||
|
||||
if (data === null) {
|
||||
log ('token invalid');
|
||||
result.error = 'invalid signature';
|
||||
return result;
|
||||
}
|
||||
@ -66,7 +75,10 @@ class Authority {
|
||||
result.id = data.id;
|
||||
result.type = data.type;
|
||||
|
||||
if (!blacklist.is_valid (data.id)) {
|
||||
log ('parsing token %s %s', result.type, result.id);
|
||||
|
||||
if (!(await blacklist.is_valid (data.id))) {
|
||||
log ('token is blacklisted');
|
||||
result.error = 'blacklisted';
|
||||
return result;
|
||||
}
|
||||
@ -74,8 +86,16 @@ class Authority {
|
||||
result.valid = true;
|
||||
result.authorized = result.type === 'access_token';
|
||||
result.next_module = data.next_module;
|
||||
result.permissions = data.permissions;
|
||||
result.data = data.obj;
|
||||
|
||||
log (
|
||||
'valid %s; targeting module %s',
|
||||
result.type,
|
||||
result.next_module
|
||||
);
|
||||
log ('permissions %o', result.permissions);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@ -84,17 +104,23 @@ class Authority {
|
||||
valid_for: number,
|
||||
options?: SignatureOptions
|
||||
): Promise<SignatureResult> {
|
||||
const log = logger.extend ('sign');
|
||||
log ('signing new %s', type);
|
||||
const time = Date.now ();
|
||||
const valid_until = time + (valid_for * 1e3);
|
||||
const key = await keystore.get_sign_key (time / 1000, valid_for);
|
||||
const attributes = {
|
||||
id: create_salt (),
|
||||
id: generate_token_id (new Date (valid_until)),
|
||||
iat: time,
|
||||
iss: keystore.instance_id,
|
||||
type,
|
||||
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);
|
||||
log ('created token %s', attributes.id);
|
||||
return { id: attributes.id, signature };
|
||||
}
|
||||
}
|
||||
|
129
lib/Blacklist.ts
129
lib/Blacklist.ts
@ -5,51 +5,142 @@
|
||||
* Created by Timo Hocker <timo@scode.ovh>, December 2020
|
||||
*/
|
||||
|
||||
import { debug } from './debug';
|
||||
import { redis_blacklist_store } from './RedisData/RedisBlacklistStore';
|
||||
import { parse_token_id } from './token_id';
|
||||
|
||||
const logger = debug ('blacklist');
|
||||
|
||||
interface Signature {
|
||||
hash: string;
|
||||
iat: Date;
|
||||
token_id: string;
|
||||
iat: number;
|
||||
valid_until: Date;
|
||||
}
|
||||
|
||||
interface ExportedSignature {
|
||||
token_id: string;
|
||||
iat: number;
|
||||
}
|
||||
|
||||
class Blacklist {
|
||||
private _signatures: Signature[];
|
||||
private _interval: NodeJS.Timeout;
|
||||
|
||||
public constructor () {
|
||||
this._signatures = [];
|
||||
this._interval = setInterval (
|
||||
this.garbage_collect.bind (this),
|
||||
3600000
|
||||
);
|
||||
}
|
||||
|
||||
public clear_before (date: Date):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--) {
|
||||
if (this._signatures[i].iat < date)
|
||||
if (this._signatures[i].iat < before) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await this.remove_signature (i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async add_signature (token_id: string): Promise<void> {
|
||||
logger.extend ('add_signature') ('blacklisting signature %s', token_id);
|
||||
const parsed = parse_token_id (token_id);
|
||||
this._signatures.push ({
|
||||
iat: Date.now (),
|
||||
token_id,
|
||||
valid_until: parsed.valid_until
|
||||
});
|
||||
await redis_blacklist_store.add (token_id, parsed.valid_until);
|
||||
}
|
||||
|
||||
public async remove_signature (signature: number | string): Promise<void> {
|
||||
const log = logger.extend ('remove_signature');
|
||||
log ('removing signature from blacklist %s', signature);
|
||||
let key = '';
|
||||
if (typeof signature === 'string') {
|
||||
log ('received string, searching through signatures');
|
||||
key = signature;
|
||||
for (let i = this._signatures.length - 1; i >= 0; i--) {
|
||||
if (this._signatures[i].token_id === signature) {
|
||||
log ('removing sigature %s at %d', signature, i);
|
||||
this._signatures.splice (i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
log (
|
||||
'received index, removing signature %s at index %s',
|
||||
this._signatures[signature].token_id,
|
||||
signature
|
||||
);
|
||||
|
||||
public add_signature (hash: string):void {
|
||||
this._signatures.push ({ iat: (new Date), hash });
|
||||
key = this._signatures[signature].token_id;
|
||||
this._signatures.splice (signature, 1);
|
||||
}
|
||||
await redis_blacklist_store.remove (key);
|
||||
}
|
||||
|
||||
public remove_signature (hash:string):void {
|
||||
for (let i = this._signatures.length - 1; i >= 0; i--) {
|
||||
if (this._signatures[i].hash === hash)
|
||||
this._signatures.splice (i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
if (sig.hash === hash)
|
||||
if (sig.token_id === hash) {
|
||||
log ('found matching blacklist entry');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
log ('signature is not blacklisted locally, checking redis');
|
||||
if (await redis_blacklist_store.get (hash)) {
|
||||
log ('signature is blacklisted in redis');
|
||||
return false;
|
||||
}
|
||||
|
||||
log ('signature is not blacklisted');
|
||||
return true;
|
||||
}
|
||||
|
||||
public export_blacklist (): Signature[] {
|
||||
return this._signatures;
|
||||
public export_blacklist (): ExportedSignature[] {
|
||||
logger.extend ('export_blacklist') ('exporting blacklist');
|
||||
return this._signatures.map ((v) => ({
|
||||
iat: v.iat,
|
||||
token_id: v.token_id
|
||||
}));
|
||||
}
|
||||
|
||||
public import_blacklist (data: Signature[]): void {
|
||||
this._signatures.push (...data);
|
||||
public import_blacklist (data: ExportedSignature[]): void {
|
||||
logger.extend ('import_blacklist') (
|
||||
'importing %d blacklist entries',
|
||||
data.length
|
||||
);
|
||||
for (const token of data) {
|
||||
const parsed = parse_token_id (token.token_id);
|
||||
this._signatures.push ({
|
||||
token_id: token.token_id,
|
||||
iat: token.iat,
|
||||
valid_until: parsed.valid_until
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public sync_redis (url: string): void {
|
||||
redis_blacklist_store.connect (url);
|
||||
}
|
||||
|
||||
private async garbage_collect (): Promise<void> {
|
||||
const log = logger.extend ('garbage_collect');
|
||||
const time = new Date;
|
||||
log ('removing signatures expired before', time);
|
||||
for (let i = this._signatures.length - 1; i >= 0; i--) {
|
||||
if (this._signatures[i].valid_until < time) {
|
||||
log ('signature %s expired', this._signatures[i].token_id);
|
||||
await this.remove_signature (i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
247
lib/Gateway.ts
247
lib/Gateway.ts
@ -6,81 +6,259 @@
|
||||
*/
|
||||
|
||||
import { IncomingMessage, ServerResponse } from 'http';
|
||||
import { run_regex } from '@sapphirecode/utilities';
|
||||
import authority from './Authority';
|
||||
import { AuthRequest, AccessSettings } from './AuthHandler';
|
||||
import { debug } from './debug';
|
||||
import { extract_cookie, CookieSettings } from './cookie';
|
||||
import blacklist from './Blacklist';
|
||||
|
||||
const logger = debug ('gateway');
|
||||
|
||||
type AnyFunc = (...args: unknown[]) => unknown;
|
||||
type Gateway = (
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse, next: AnyFunc
|
||||
res: ServerResponse,
|
||||
next: AnyFunc
|
||||
) => unknown;
|
||||
|
||||
interface RefreshSettings extends AccessSettings {
|
||||
leave_open?: never;
|
||||
redirect_to?: never;
|
||||
data?: never;
|
||||
}
|
||||
|
||||
interface GatewayOptions {
|
||||
redirect_url: string;
|
||||
cookie_name?: string;
|
||||
redirect_url?: string;
|
||||
cookie?: CookieSettings;
|
||||
refresh_cookie?: CookieSettings;
|
||||
refresh_settings?: RefreshSettings;
|
||||
require_permissions?: string[];
|
||||
}
|
||||
|
||||
interface ConnectionInfo {
|
||||
token_id: string
|
||||
token_data: unknown
|
||||
permissions: string[]
|
||||
}
|
||||
|
||||
class GatewayClass {
|
||||
private _options: GatewayOptions;
|
||||
|
||||
public constructor (options: GatewayOptions) {
|
||||
public constructor (options: GatewayOptions = {}) {
|
||||
const log = logger.extend ('constructor');
|
||||
log ('creating new gateway');
|
||||
if (
|
||||
typeof options?.cookie !== 'undefined'
|
||||
&& typeof options?.refresh_cookie !== 'undefined'
|
||||
&& options.cookie.name === options.refresh_cookie.name
|
||||
)
|
||||
throw new Error ('access and refresh cookies cannot have the same name');
|
||||
|
||||
this._options = options;
|
||||
}
|
||||
|
||||
private redirect (res: ServerResponse): void {
|
||||
public deny (res: ServerResponse): void {
|
||||
logger.extend ('deny') ('denied http request');
|
||||
res.statusCode = 403;
|
||||
res.end ();
|
||||
}
|
||||
|
||||
public redirect (res: ServerResponse): void {
|
||||
const log = logger.extend ('redirect');
|
||||
log ('redirecting http request to %s', this._options.redirect_url);
|
||||
if (typeof this._options.redirect_url !== 'string') {
|
||||
log ('no redirect url defined');
|
||||
this.deny (res);
|
||||
return;
|
||||
}
|
||||
res.statusCode = 302;
|
||||
res.setHeader ('Location', this._options.redirect_url);
|
||||
res.end ();
|
||||
}
|
||||
|
||||
private get_header_auth (req: IncomingMessage): string | null {
|
||||
public get_header_auth (req: IncomingMessage): string | null {
|
||||
const log = logger.extend ('get_header_auth');
|
||||
log ('extracting authorization header');
|
||||
const auth_header = req.headers.authorization;
|
||||
const auth = (/(?<type>\w+) (?<data>.*)/u).exec (auth_header || '');
|
||||
if (auth === null)
|
||||
return null;
|
||||
if (auth.groups?.type !== 'Bearer')
|
||||
return null;
|
||||
log ('found bearer token');
|
||||
return auth.groups?.data;
|
||||
}
|
||||
|
||||
private get_cookie_auth (req: IncomingMessage): string | null {
|
||||
if (typeof this._options.cookie_name === 'undefined')
|
||||
return null;
|
||||
let auth = null;
|
||||
run_regex (
|
||||
/(?:^|;)\s*(?<name>[^;=]+)=(?<value>[^;]+)/gu,
|
||||
req.headers.cookie,
|
||||
(res: RegExpMatchArray) => {
|
||||
if (res.groups?.name === this._options.cookie_name)
|
||||
auth = res.groups?.value;
|
||||
}
|
||||
);
|
||||
return auth;
|
||||
}
|
||||
|
||||
private authenticate (req: IncomingMessage): boolean {
|
||||
public async try_access (req: IncomingMessage): Promise<boolean> {
|
||||
const log = logger.extend ('try_access');
|
||||
log ('authenticating incoming request');
|
||||
let auth = this.get_header_auth (req);
|
||||
if (auth === null)
|
||||
auth = this.get_cookie_auth (req);
|
||||
if (auth === null)
|
||||
auth = extract_cookie (this._options.cookie?.name, req.headers.cookie);
|
||||
if (auth === null) {
|
||||
log ('found no auth token');
|
||||
return false;
|
||||
}
|
||||
|
||||
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>;
|
||||
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;
|
||||
}
|
||||
|
||||
public process_request (
|
||||
public async try_refresh (
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse
|
||||
): Promise<boolean> {
|
||||
const log = logger.extend ('try_refresh');
|
||||
if (
|
||||
typeof this._options.refresh_cookie === 'undefined'
|
||||
|| typeof this._options.refresh_settings === 'undefined'
|
||||
)
|
||||
return false;
|
||||
|
||||
log ('trying to apply refresh token');
|
||||
|
||||
const refresh = extract_cookie (
|
||||
this._options.refresh_cookie.name,
|
||||
req.headers.cookie
|
||||
);
|
||||
if (refresh === null) {
|
||||
log ('could not find refresh token');
|
||||
return false;
|
||||
}
|
||||
|
||||
const ver = await authority.verify (refresh);
|
||||
if (ver.type === 'refresh_token' && ver.valid) {
|
||||
log ('refresh token valid, generating new tokens');
|
||||
const auth_request = new AuthRequest (
|
||||
req,
|
||||
res,
|
||||
'',
|
||||
this._options.cookie,
|
||||
this._options.refresh_cookie
|
||||
);
|
||||
const refresh_result = await auth_request.allow_access ({
|
||||
...this._options.refresh_settings,
|
||||
data: ver.data,
|
||||
leave_open: true
|
||||
});
|
||||
|
||||
log ('setting connection info');
|
||||
const con = req.connection as unknown as Record<string, unknown>;
|
||||
con.auth = {
|
||||
token_id: refresh_result.access_token_id,
|
||||
token_data: ver.data,
|
||||
permissions: ver.permissions
|
||||
};
|
||||
|
||||
log ('tokens refreshed');
|
||||
return true;
|
||||
}
|
||||
|
||||
log ('refresh token invalid');
|
||||
return false;
|
||||
}
|
||||
|
||||
public async authenticate (
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse
|
||||
): Promise<boolean> {
|
||||
const log = logger.extend ('authenticate');
|
||||
log ('trying to authenticate http request');
|
||||
if (await this.try_access (req)) {
|
||||
log ('authenticated via access_token');
|
||||
return true;
|
||||
}
|
||||
if (await this.try_refresh (req, res)) {
|
||||
log ('authenticated via refresh_token');
|
||||
return true;
|
||||
}
|
||||
|
||||
log ('could not verify session');
|
||||
return false;
|
||||
}
|
||||
|
||||
public check_permissions (
|
||||
req: IncomingMessage,
|
||||
permissions = this._options.require_permissions || []
|
||||
): boolean {
|
||||
for (const perm of permissions) {
|
||||
if (!this.has_permission (req, perm))
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public has_permission (req: IncomingMessage, permission: string) {
|
||||
const info = this.get_info (req);
|
||||
return info.permissions.includes (permission);
|
||||
}
|
||||
|
||||
public async process_request (
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
next: AnyFunc
|
||||
): unknown {
|
||||
if (this.authenticate (req))
|
||||
return next ();
|
||||
): Promise<unknown> {
|
||||
const log = logger.extend ('process_request');
|
||||
log ('processing incoming http request');
|
||||
if (await this.authenticate (req, res)) {
|
||||
log ('authentification successful');
|
||||
log ('checking permissions');
|
||||
|
||||
if (!this.check_permissions (req))
|
||||
return this.redirect (res);
|
||||
|
||||
log ('authorization successful. calling next handler');
|
||||
return next ();
|
||||
}
|
||||
|
||||
log ('failed to authenticate, redirecting client');
|
||||
return this.redirect (res);
|
||||
}
|
||||
|
||||
public async logout (req: IncomingMessage): Promise<void> {
|
||||
const log = logger.extend ('logout');
|
||||
log ('invalidating all submitted tokens');
|
||||
const auth_strings = [
|
||||
this.get_header_auth (req),
|
||||
extract_cookie (this._options.cookie?.name, req.headers.cookie),
|
||||
extract_cookie (this._options.refresh_cookie?.name, req.headers.cookie)
|
||||
];
|
||||
const tokens = (
|
||||
await Promise.all (
|
||||
auth_strings
|
||||
.filter ((v) => v !== null)
|
||||
.map ((v) => authority.verify (v as string))
|
||||
)
|
||||
).filter ((v) => v.valid);
|
||||
|
||||
log ('found %d tokens: %O', tokens.length, tokens);
|
||||
|
||||
for (const token of tokens) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await blacklist.add_signature (token.id);
|
||||
}
|
||||
|
||||
log ('complete');
|
||||
}
|
||||
|
||||
public get_info (req: IncomingMessage): ConnectionInfo {
|
||||
const conn = req.connection as unknown as Record<string, unknown>;
|
||||
const auth = conn.auth as Record<string, unknown>;
|
||||
return {
|
||||
token_id: auth.token_id as string,
|
||||
token_data: auth.token_data,
|
||||
permissions: (auth.permissions as string[]) || []
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -89,9 +267,4 @@ export default function create_gateway (options: GatewayOptions): Gateway {
|
||||
return g.process_request.bind (g);
|
||||
}
|
||||
|
||||
export {
|
||||
AnyFunc,
|
||||
Gateway,
|
||||
GatewayOptions,
|
||||
GatewayClass
|
||||
};
|
||||
export { AnyFunc, Gateway, GatewayOptions, GatewayClass, RefreshSettings };
|
||||
|
23
lib/Key.ts
Normal file
23
lib/Key.ts
Normal file
@ -0,0 +1,23 @@
|
||||
/*
|
||||
* Copyright (C) Sapphirecode - All Rights Reserved
|
||||
* This file is part of Auth-Server-Helper which is released under MIT.
|
||||
* See file 'LICENSE' for full license details.
|
||||
* Created by Timo Hocker <timo@scode.ovh>, August 2022
|
||||
*/
|
||||
|
||||
export interface Key {
|
||||
key: string;
|
||||
valid_until: number;
|
||||
}
|
||||
|
||||
export interface LabelledKey extends Key {
|
||||
index: string;
|
||||
}
|
||||
|
||||
export interface KeyPair {
|
||||
private_key?: Key;
|
||||
public_key: Key;
|
||||
}
|
||||
|
||||
export type KeyStoreData = Record<string, KeyPair>;
|
||||
export type KeyStoreExport = LabelledKey[];
|
155
lib/KeyStore.ts
155
lib/KeyStore.ts
@ -7,37 +7,14 @@
|
||||
|
||||
import { generate_keypair, random_hex } from '@sapphirecode/crypto-helper';
|
||||
import { to_b58 } from '@sapphirecode/encoding-helper';
|
||||
import { debug } from './debug';
|
||||
import { KeyStoreData, KeyStoreExport } from './Key';
|
||||
import { redis_key_store } from './RedisData/RedisKeyStore';
|
||||
|
||||
const logger = debug ('keystore');
|
||||
|
||||
const renew_interval = 3600;
|
||||
|
||||
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 {
|
||||
private _keys: KeyStoreData = {};
|
||||
private _interval: NodeJS.Timeout;
|
||||
@ -50,8 +27,12 @@ class KeyStore {
|
||||
public constructor () {
|
||||
this._interval = setInterval (() => {
|
||||
this.garbage_collect ();
|
||||
}, renew_interval);
|
||||
}, renew_interval * 1000);
|
||||
this._instance = to_b58 (random_hex (16), 'hex');
|
||||
logger.extend ('constructor') (
|
||||
'created keystore instance %s',
|
||||
this._instance
|
||||
);
|
||||
}
|
||||
|
||||
private get_index (iat: number, instance = this._instance): string {
|
||||
@ -59,19 +40,44 @@ class KeyStore {
|
||||
.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)
|
||||
.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) {
|
||||
const entry = set[index];
|
||||
const entry = this._keys[index];
|
||||
if (typeof entry.private_key !== 'undefined'
|
||||
&& entry.private_key.valid_until < time
|
||||
)
|
||||
) {
|
||||
log ('deleting expired private key');
|
||||
delete entry.private_key;
|
||||
}
|
||||
|
||||
if (entry.public_key.valid_until < time)
|
||||
delete set[index];
|
||||
if (entry.public_key.valid_until < time) {
|
||||
log ('deleting expired key pair');
|
||||
delete this._keys[index];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -80,6 +86,13 @@ class KeyStore {
|
||||
valid_for: number,
|
||||
instance?: string
|
||||
): Promise<string> {
|
||||
const log = logger.extend ('get_sign_key');
|
||||
log (
|
||||
'querying key from %s for timestamp %d, valid for %d',
|
||||
instance,
|
||||
iat,
|
||||
valid_for
|
||||
);
|
||||
if (valid_for <= 0)
|
||||
throw new Error ('cannot create infinitely valid key');
|
||||
|
||||
@ -93,51 +106,87 @@ class KeyStore {
|
||||
.getTime () + (valid_for * 1000);
|
||||
|
||||
if (typeof this._keys[index] !== 'undefined') {
|
||||
log ('loading existing key');
|
||||
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')
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
let key = null;
|
||||
|
||||
if (typeof this._keys[index] === 'undefined')
|
||||
key = await redis_key_store.get (index);
|
||||
|
||||
else
|
||||
key = this._keys[index].public_key;
|
||||
|
||||
if (key === null)
|
||||
throw new Error ('key could not be found');
|
||||
|
||||
const key = this._keys[index];
|
||||
return key.public_key.key;
|
||||
return 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 ();
|
||||
const out: KeyStoreData = {};
|
||||
for (const index of Object.keys (this._keys))
|
||||
out[index] = { public_key: this._keys[index].public_key };
|
||||
|
||||
const out: KeyStoreExport = [];
|
||||
for (const index of Object.keys (this._keys)) {
|
||||
log ('exporting key %s', index);
|
||||
out.push ({ ...this._keys[index].public_key, index });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public import_verification_data (data: KeyStoreData): void {
|
||||
const import_set = { ...data };
|
||||
this.garbage_collect (import_set);
|
||||
for (const key of Object.keys (import_set)) {
|
||||
if (typeof this._keys[key] !== 'undefined')
|
||||
public import_verification_data (data: KeyStoreExport): void {
|
||||
const log = logger.extend ('import_verification_data');
|
||||
log ('importing %d public keys', data.length);
|
||||
for (const key of data) {
|
||||
log ('importing key %s', key.index);
|
||||
if (typeof this._keys[key.index] !== 'undefined')
|
||||
throw new Error ('cannot import to the same instance');
|
||||
this._keys[key] = import_set[key];
|
||||
this._keys[key.index] = {
|
||||
public_key: {
|
||||
key: key.key,
|
||||
valid_until: key.valid_until
|
||||
}
|
||||
};
|
||||
}
|
||||
log ('running garbage collector');
|
||||
this.garbage_collect ();
|
||||
}
|
||||
|
||||
public reset_instance (): void {
|
||||
logger.extend ('reset_instance') ('resetting keystore');
|
||||
this._instance = to_b58 (random_hex (16), 'hex');
|
||||
this._keys = {};
|
||||
redis_key_store.disconnect ();
|
||||
}
|
||||
|
||||
public sync_redis (url: string): void {
|
||||
redis_key_store.connect (url);
|
||||
}
|
||||
}
|
||||
|
||||
const ks: KeyStore = (new KeyStore);
|
||||
export default ks;
|
||||
export { KeyStore, KeyStoreData, Key, KeyPair };
|
||||
export { KeyStore };
|
||||
|
64
lib/Redis.ts
Normal file
64
lib/Redis.ts
Normal file
@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Copyright (C) Sapphirecode - All Rights Reserved
|
||||
* This file is part of Auth-Server-Helper which is released under MIT.
|
||||
* See file 'LICENSE' for full license details.
|
||||
* Created by Timo Hocker <timo@scode.ovh>, August 2022
|
||||
*/
|
||||
|
||||
import IORedis from 'ioredis';
|
||||
import { debug } from './debug';
|
||||
|
||||
const logger = debug ('redis');
|
||||
|
||||
export class Redis {
|
||||
private _redis: IORedis | null = null;
|
||||
|
||||
public connect (url: string): void {
|
||||
const log = logger.extend ('connect');
|
||||
log ('connecting to redis instance %s', url);
|
||||
if (this._redis !== null) {
|
||||
log ('disconnecting existing redis client');
|
||||
this.disconnect ();
|
||||
}
|
||||
|
||||
this._redis = new IORedis (url);
|
||||
this._redis.on ('connect', () => {
|
||||
log ('connected');
|
||||
});
|
||||
this._redis.on ('ready', () => {
|
||||
log ('ready');
|
||||
});
|
||||
this._redis.on ('error', (err) => {
|
||||
log ('error %o', err);
|
||||
});
|
||||
this._redis.on ('reconnecting', () => {
|
||||
log ('reconnecting');
|
||||
});
|
||||
this._redis.on ('end', () => {
|
||||
log ('connection ended');
|
||||
});
|
||||
}
|
||||
|
||||
public disconnect (): void {
|
||||
const log = logger.extend ('disconnect');
|
||||
log ('disconnecting redis client');
|
||||
if (this._redis === null) {
|
||||
log ('redis is inactive, skipping');
|
||||
return;
|
||||
}
|
||||
this._redis.quit ();
|
||||
this._redis = null;
|
||||
log ('done');
|
||||
}
|
||||
|
||||
protected get redis (): IORedis {
|
||||
if (this._redis === null)
|
||||
throw new Error ('redis is not connected');
|
||||
|
||||
return this._redis;
|
||||
}
|
||||
|
||||
protected get is_active (): boolean {
|
||||
return this._redis !== null;
|
||||
}
|
||||
}
|
53
lib/RedisData/RedisBlacklistStore.ts
Normal file
53
lib/RedisData/RedisBlacklistStore.ts
Normal file
@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright (C) Sapphirecode - All Rights Reserved
|
||||
* This file is part of Auth-Server-Helper which is released under MIT.
|
||||
* See file 'LICENSE' for full license details.
|
||||
* Created by Timo Hocker <timo@scode.ovh>, August 2022
|
||||
*/
|
||||
|
||||
import { debug } from '../debug';
|
||||
import { Redis } from '../Redis';
|
||||
|
||||
const logger = debug ('RedisBlacklistStore');
|
||||
|
||||
export class RedisBlacklistStore extends Redis {
|
||||
public async add (key: string, valid_until: Date): Promise<void> {
|
||||
const log = logger.extend ('set');
|
||||
log ('trying to add key %s to redis blacklist', key);
|
||||
if (!this.is_active) {
|
||||
log ('redis is inactive, skipping');
|
||||
return;
|
||||
}
|
||||
await this.redis.setex (
|
||||
`blacklist_${key}`,
|
||||
Math.floor ((valid_until.getTime () - Date.now ()) / 1000),
|
||||
1
|
||||
);
|
||||
log ('saved key');
|
||||
}
|
||||
|
||||
public async remove (key: string): Promise<void> {
|
||||
const log = logger.extend ('remove');
|
||||
log ('removing key %s from redis', key);
|
||||
if (!this.is_active) {
|
||||
log ('redis is inactive, skipping');
|
||||
return;
|
||||
}
|
||||
await this.redis.del (`blacklist_${key}`);
|
||||
log ('removed key');
|
||||
}
|
||||
|
||||
public async get (key: string): Promise<boolean> {
|
||||
const log = logger.extend ('get');
|
||||
log ('trying to find key %s in redis blacklist', key);
|
||||
if (!this.is_active) {
|
||||
log ('redis is inactive, skipping');
|
||||
return false;
|
||||
}
|
||||
const res = await this.redis.exists (`blacklist_${key}`) === 1;
|
||||
log ('found key %s', res);
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
export const redis_blacklist_store = new RedisBlacklistStore;
|
52
lib/RedisData/RedisKeyStore.ts
Normal file
52
lib/RedisData/RedisKeyStore.ts
Normal file
@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Copyright (C) Sapphirecode - All Rights Reserved
|
||||
* This file is part of Auth-Server-Helper which is released under MIT.
|
||||
* See file 'LICENSE' for full license details.
|
||||
* Created by Timo Hocker <timo@scode.ovh>, August 2022
|
||||
*/
|
||||
|
||||
import { debug } from '../debug';
|
||||
import { LabelledKey } from '../Key';
|
||||
import { Redis } from '../Redis';
|
||||
|
||||
const logger = debug ('RedisKeyStore');
|
||||
|
||||
export class RedisKeyStore extends Redis {
|
||||
public async set (value: LabelledKey): Promise<void> {
|
||||
const log = logger.extend ('set');
|
||||
log ('trying to set key %s to redis', value.index);
|
||||
if (!this.is_active) {
|
||||
log ('redis is inactive, skipping');
|
||||
return;
|
||||
}
|
||||
const valid_for = Math.floor (
|
||||
(value.valid_until - (new Date)
|
||||
.getTime ()) / 1000
|
||||
);
|
||||
log ('key is valid for %d seconds', valid_for);
|
||||
await this.redis.setex (
|
||||
`keystore_${value.index}`,
|
||||
valid_for,
|
||||
JSON.stringify (value)
|
||||
);
|
||||
log ('saved key');
|
||||
}
|
||||
|
||||
public async get (index: string): Promise<LabelledKey | null> {
|
||||
const log = logger.extend ('get');
|
||||
log ('trying to get key %s from redis', index);
|
||||
if (!this.is_active) {
|
||||
log ('redis is inactive, skipping');
|
||||
return null;
|
||||
}
|
||||
const res = await this.redis.get (`keystore_${index}`);
|
||||
if (res === null) {
|
||||
log ('key not found in redis');
|
||||
return null;
|
||||
}
|
||||
log ('key found');
|
||||
return JSON.parse (res);
|
||||
}
|
||||
}
|
||||
|
||||
export const redis_key_store = new RedisKeyStore;
|
86
lib/cookie.ts
Normal file
86
lib/cookie.ts
Normal file
@ -0,0 +1,86 @@
|
||||
/*
|
||||
* Copyright (C) Sapphirecode - All Rights Reserved
|
||||
* This file is part of Auth-Server-Helper which is released under MIT.
|
||||
* See file 'LICENSE' for full license details.
|
||||
* Created by Timo Hocker <timo@scode.ovh>, August 2022
|
||||
*/
|
||||
|
||||
import { run_regex } from '@sapphirecode/utilities';
|
||||
import { debug } from './debug';
|
||||
|
||||
const logger = debug ('cookies');
|
||||
|
||||
type SameSiteValue = 'Lax' | 'None' | 'Strict';
|
||||
|
||||
interface CookieSettings {
|
||||
name: string;
|
||||
secure?: boolean;
|
||||
http_only?: boolean;
|
||||
same_site?: SameSiteValue|null;
|
||||
expires?: string;
|
||||
max_age?: number;
|
||||
domain?: string;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
const default_settings: Omit<CookieSettings, 'name'> = {
|
||||
secure: true,
|
||||
http_only: true,
|
||||
same_site: 'Strict'
|
||||
};
|
||||
|
||||
function build_cookie (
|
||||
settings: CookieSettings,
|
||||
value: string
|
||||
): string {
|
||||
const local_settings = { ...default_settings, ...settings };
|
||||
const sections = [ `${local_settings.name}=${value}` ];
|
||||
|
||||
if (local_settings.secure)
|
||||
sections.push ('Secure');
|
||||
if (local_settings.http_only)
|
||||
sections.push ('HttpOnly');
|
||||
if (
|
||||
typeof local_settings.same_site !== 'undefined'
|
||||
&& local_settings.same_site !== null
|
||||
)
|
||||
sections.push (`SameSite=${local_settings.same_site}`);
|
||||
if (typeof local_settings.expires !== 'undefined')
|
||||
sections.push (`Expires=${local_settings.expires}`);
|
||||
if (typeof local_settings.max_age !== 'undefined')
|
||||
sections.push (`Max-Age=${local_settings.max_age}`);
|
||||
if (typeof local_settings.domain !== 'undefined')
|
||||
sections.push (`Domain=${local_settings.domain}`);
|
||||
if (typeof local_settings.path !== 'undefined')
|
||||
sections.push (`Path=${local_settings.path}`);
|
||||
|
||||
return sections.join ('; ');
|
||||
}
|
||||
|
||||
function extract_cookie (
|
||||
name: string|undefined,
|
||||
header: string|undefined
|
||||
): string| null {
|
||||
const log = logger.extend ('extract_cookie');
|
||||
log (`extracting cookie ${name}`);
|
||||
|
||||
const cookie_regex = /(?:^|;)\s*(?<name>[^;=]+)=(?<value>[^;]+)/gu;
|
||||
|
||||
let result = null;
|
||||
|
||||
run_regex (
|
||||
cookie_regex,
|
||||
header,
|
||||
(res: RegExpMatchArray) => {
|
||||
log ('parsing cookie %s', res.groups?.name);
|
||||
if (res.groups?.name === name) {
|
||||
log ('found cookie');
|
||||
result = res.groups?.value as string;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export { build_cookie, extract_cookie, SameSiteValue, CookieSettings };
|
15
lib/debug.ts
Normal file
15
lib/debug.ts
Normal file
@ -0,0 +1,15 @@
|
||||
/*
|
||||
* Copyright (C) Sapphirecode - All Rights Reserved
|
||||
* This file is part of Auth-Server-Helper which is released under MIT.
|
||||
* See file 'LICENSE' for full license details.
|
||||
* Created by Timo Hocker <timo@scode.ovh>, August 2022
|
||||
*/
|
||||
|
||||
import build_debug from 'debug';
|
||||
|
||||
function debug (scope: string): build_debug.Debugger {
|
||||
const namespace = `sapphirecode:auth-server-helper:${scope}`;
|
||||
return build_debug (namespace);
|
||||
}
|
||||
|
||||
export { debug };
|
24
lib/index.ts
24
lib/index.ts
@ -12,7 +12,8 @@ import create_auth_handler, {
|
||||
AuthRequestHandler,
|
||||
AuthRequest,
|
||||
AccessSettings,
|
||||
AccessResult
|
||||
AccessResult,
|
||||
AuthHandler
|
||||
} from './AuthHandler';
|
||||
import authority, {
|
||||
VerificationResult,
|
||||
@ -26,9 +27,18 @@ import create_gateway, {
|
||||
GatewayOptions,
|
||||
GatewayClass,
|
||||
Gateway,
|
||||
AnyFunc
|
||||
AnyFunc,
|
||||
RefreshSettings
|
||||
} 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 {
|
||||
create_gateway,
|
||||
@ -41,6 +51,7 @@ export {
|
||||
CreateHandlerOptions,
|
||||
AuthRequestHandler,
|
||||
AuthRequest,
|
||||
AuthHandler,
|
||||
AccessSettings,
|
||||
AccessResult,
|
||||
VerificationResult,
|
||||
@ -52,7 +63,12 @@ export {
|
||||
GatewayOptions,
|
||||
GatewayClass,
|
||||
Gateway,
|
||||
RefreshSettings,
|
||||
AnyFunc,
|
||||
KeyStore,
|
||||
KeyStoreData
|
||||
KeyStoreExport,
|
||||
LabelledKey,
|
||||
Key,
|
||||
CookieSettings,
|
||||
SameSiteValue
|
||||
};
|
||||
|
21
lib/token_id.ts
Normal file
21
lib/token_id.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { create_salt } from '@sapphirecode/crypto-helper';
|
||||
import { to_b58 } from '@sapphirecode/encoding-helper';
|
||||
|
||||
export function generate_token_id (valid_until: Date) {
|
||||
const salt = create_salt ();
|
||||
return `${to_b58 (salt, 'hex')};${valid_until.toISOString ()}`;
|
||||
}
|
||||
|
||||
export function parse_token_id (id: string) {
|
||||
// eslint-disable-next-line max-len
|
||||
const regex = /^(?<hash>[A-HJ-NP-Za-km-z1-9]+);(?<date>\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d\.\d{3}Z)$/u;
|
||||
const result = regex.exec (id);
|
||||
if (result === null)
|
||||
throw new Error (`invalid token id ${id}`);
|
||||
if (typeof result.groups === 'undefined')
|
||||
throw new Error ('invalid state');
|
||||
return {
|
||||
hash: result.groups.hash as string,
|
||||
valid_until: new Date (result.groups.date as string)
|
||||
};
|
||||
}
|
37
package.json
37
package.json
@ -1,30 +1,37 @@
|
||||
{
|
||||
"name": "@sapphirecode/auth-server-helper",
|
||||
"version": "2.0.0",
|
||||
"main": "dist/index.js",
|
||||
"version": "4.1.1",
|
||||
"main": "dist/lib/index.js",
|
||||
"author": {
|
||||
"name": "Timo Hocker",
|
||||
"email": "timo@scode.ovh"
|
||||
},
|
||||
"description": "authentication middleware for express",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.scode.ovh/timo/auth-server-helper.git"
|
||||
},
|
||||
"bugs": "https://git.scode.ovh/timo/auth-server-helper",
|
||||
"description": "authentication middleware for node http and express",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@ert78gb/jasmine-ts": "^0.3.1",
|
||||
"@sapphirecode/eslint-config-ts": "^1.1.27",
|
||||
"@stryker-mutator/core": "^4.3.1",
|
||||
"@stryker-mutator/jasmine-runner": "^4.3.1",
|
||||
"@types/jasmine": "^3.6.2",
|
||||
"@types/node": "^10.0.0",
|
||||
"eslint": "^7.14.0",
|
||||
"jasmine": "^3.6.3",
|
||||
"@stryker-mutator/core": "^6.1.2",
|
||||
"@stryker-mutator/jasmine-runner": "^6.1.2",
|
||||
"@types/debug": "^4.1.7",
|
||||
"@types/jasmine": "^4.0.3",
|
||||
"@types/node": "^18.6.4",
|
||||
"eslint": "^8.21.0",
|
||||
"jasmine": "^4.3.0",
|
||||
"nyc": "^15.1.0",
|
||||
"ts-node": "^9.1.1",
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "^4.1.2"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "eslint . --ext .js,.jsx,.ts,.tsx,.vue,.mjs",
|
||||
"test": "nyc jasmine-ts --config=\"jasmine.json\"",
|
||||
"pretest": "yarn compile",
|
||||
"test": "nyc jasmine --config=\"jasmine.json\"",
|
||||
"mutate": "stryker run",
|
||||
"precompile": "rm -rf dist",
|
||||
"compile": "tsc"
|
||||
},
|
||||
"files": [
|
||||
@ -39,9 +46,11 @@
|
||||
"middleware"
|
||||
],
|
||||
"dependencies": {
|
||||
"@sapphirecode/crypto-helper": "^1.3.0",
|
||||
"@sapphirecode/crypto-helper": "^2.0.0",
|
||||
"@sapphirecode/encoding-helper": "^1.1.0",
|
||||
"@sapphirecode/utilities": "^1.8.8"
|
||||
"@sapphirecode/utilities": "^1.8.8",
|
||||
"debug": "^4.3.3",
|
||||
"ioredis": "^5.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
|
@ -16,10 +16,11 @@ export class Response extends http.IncomingMessage {
|
||||
export function get (
|
||||
// eslint-disable-next-line default-param-last
|
||||
headers: http.OutgoingHttpHeaders = {},
|
||||
body?: string
|
||||
body?: string|null,
|
||||
path = ''
|
||||
): Promise<Response> {
|
||||
return new Promise ((resolve) => {
|
||||
const req = http.request ('http://localhost:3000', {
|
||||
const req = http.request (`http://localhost:3000${path}`, {
|
||||
headers,
|
||||
method: typeof body === 'string' ? 'POST' : 'GET'
|
||||
}, (res: Response) => {
|
||||
|
@ -5,6 +5,7 @@
|
||||
* Created by Timo Hocker <timo@scode.ovh>, January 2021
|
||||
*/
|
||||
|
||||
/* eslint-disable max-lines */
|
||||
import http, { IncomingMessage, ServerResponse } from 'http';
|
||||
import { to_b64 } from '@sapphirecode/encoding-helper';
|
||||
import auth from '../../lib/Authority';
|
||||
@ -14,6 +15,7 @@ import {
|
||||
get, modify_signature, Response
|
||||
} from '../Helper';
|
||||
import { create_auth_handler } from '../../lib/index';
|
||||
import { build_cookie, extract_cookie } from '../../lib/cookie';
|
||||
|
||||
const expires_seconds = 600;
|
||||
const refresh_expires_seconds = 3600;
|
||||
@ -36,8 +38,8 @@ function check_headers (resp: Response): CheckHeaderResult {
|
||||
return { data, at, rt };
|
||||
}
|
||||
|
||||
function check_token (token: string, type: string):void {
|
||||
const v = auth.verify (token);
|
||||
async function check_token (token: string|null, type: string): Promise<void> {
|
||||
const v = await auth.verify (token || '');
|
||||
expect (v.valid)
|
||||
.toEqual (true);
|
||||
expect (v.authorized)
|
||||
@ -55,7 +57,8 @@ describe ('auth handler', () => {
|
||||
beforeAll (() => {
|
||||
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) {
|
||||
let body_auth = false;
|
||||
try {
|
||||
@ -88,11 +91,28 @@ describe ('auth handler', () => {
|
||||
else if (req.user === 'part' && req.password === 'bar') {
|
||||
req.allow_part (part_expires_seconds, 'two_factor');
|
||||
}
|
||||
else if (req.user === 'red' && req.password === 'irect') {
|
||||
req.allow_access ({
|
||||
access_token_expires_in: expires_seconds,
|
||||
redirect_to: '/redirected'
|
||||
});
|
||||
}
|
||||
else if (req.user === 'leave' && req.password === 'open') {
|
||||
req.response.setHeader ('Content-Type', 'text/plain');
|
||||
await req.allow_access ({
|
||||
access_token_expires_in: expires_seconds,
|
||||
leave_open: true
|
||||
});
|
||||
req.response.write ('custom response, ');
|
||||
(req.response.connection as unknown as Record<string, unknown>)
|
||||
.append_flag = true;
|
||||
}
|
||||
else {
|
||||
req.deny ();
|
||||
}
|
||||
}, {
|
||||
cookie_name: 'cookie_jar',
|
||||
cookie: { name: 'cookie_jar' },
|
||||
refresh_cookie: { name: 'mint_cookies' },
|
||||
refresh: {
|
||||
access_token_expires_in: expires_seconds,
|
||||
refresh_token_expires_in: refresh_expires_seconds,
|
||||
@ -109,11 +129,17 @@ describe ('auth handler', () => {
|
||||
}
|
||||
else { request.deny (); }
|
||||
}
|
||||
}
|
||||
},
|
||||
parse_body: true
|
||||
});
|
||||
|
||||
server = http.createServer ((req: IncomingMessage, res: ServerResponse) => {
|
||||
ah (req, res);
|
||||
server = http.createServer (async (
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse
|
||||
) => {
|
||||
const is_successful = await ah (req, res);
|
||||
if ((res.connection as unknown as Record<string, unknown>).append_flag)
|
||||
res.end (String (is_successful));
|
||||
});
|
||||
server.listen (3000);
|
||||
});
|
||||
@ -134,13 +160,15 @@ describe ('auth handler', () => {
|
||||
expect (res1.data.token_type)
|
||||
.toEqual ('bearer');
|
||||
expect (resp1.headers['set-cookie'])
|
||||
.toContain (`cookie_jar=${res1.at}`);
|
||||
.toContain (build_cookie ({ name: 'cookie_jar' }, res1.at as string));
|
||||
expect (resp1.headers['set-cookie'])
|
||||
.toContain (build_cookie ({ name: 'mint_cookies' }, res1.rt as string));
|
||||
|
||||
check_token (res1.at as string, 'access_token');
|
||||
await check_token (res1.at as string, 'access_token');
|
||||
expect (res1.data.expires_in)
|
||||
.toEqual (expires_seconds);
|
||||
|
||||
check_token (res1.rt as string, 'refresh_token');
|
||||
await check_token (res1.rt as string, 'refresh_token');
|
||||
expect (res1.data.refresh_expires_in)
|
||||
.toEqual (refresh_expires_seconds);
|
||||
|
||||
@ -153,14 +181,16 @@ describe ('auth handler', () => {
|
||||
expect (res2.data.token_type)
|
||||
.toEqual ('bearer');
|
||||
expect (resp2.headers['set-cookie'])
|
||||
.toContain (`cookie_jar=${res2.at}`);
|
||||
.toContain (build_cookie ({ name: 'cookie_jar' }, res2.at as string));
|
||||
expect (resp2.headers['set-cookie'])
|
||||
.toContain (build_cookie ({ name: 'mint_cookies' }, res2.rt as string));
|
||||
|
||||
check_token (res2.at as string, 'access_token');
|
||||
await check_token (res2.at as string, 'access_token');
|
||||
expect (res2.data.expires_in)
|
||||
.toEqual (expires_seconds);
|
||||
expect (res2.at).not.toEqual (res1.at);
|
||||
|
||||
check_token (res2.rt as string, 'refresh_token');
|
||||
await check_token (res2.rt as string, 'refresh_token');
|
||||
expect (res2.data.refresh_expires_in)
|
||||
.toEqual (refresh_expires_seconds);
|
||||
expect (res2.rt).not.toEqual (res1.rt);
|
||||
@ -183,13 +213,15 @@ describe ('auth handler', () => {
|
||||
expect (res1.data.token_type)
|
||||
.toEqual ('bearer');
|
||||
expect (resp1.headers['set-cookie'])
|
||||
.toContain (`cookie_jar=${res1.at}`);
|
||||
.toContain (build_cookie ({ name: 'cookie_jar' }, res1.at as string));
|
||||
expect (resp1.headers['set-cookie'])
|
||||
.toContain (build_cookie ({ name: 'mint_cookies' }, res1.rt as string));
|
||||
|
||||
check_token (res1.at as string, 'access_token');
|
||||
await check_token (res1.at as string, 'access_token');
|
||||
expect (res1.data.expires_in)
|
||||
.toEqual (expires_seconds);
|
||||
|
||||
check_token (res1.rt as string, 'refresh_token');
|
||||
await check_token (res1.rt as string, 'refresh_token');
|
||||
expect (res1.data.refresh_expires_in)
|
||||
.toEqual (refresh_expires_seconds);
|
||||
});
|
||||
@ -206,13 +238,15 @@ describe ('auth handler', () => {
|
||||
expect (res1.data.token_type)
|
||||
.toEqual ('bearer');
|
||||
expect (resp1.headers['set-cookie'])
|
||||
.toContain (`cookie_jar=${res1.at}`);
|
||||
.toContain (build_cookie ({ name: 'cookie_jar' }, res1.at as string));
|
||||
expect (resp1.headers['set-cookie'])
|
||||
.toContain (build_cookie ({ name: 'mint_cookies' }, res1.rt as string));
|
||||
|
||||
check_token (res1.at as string, 'access_token');
|
||||
await check_token (res1.at as string, 'access_token');
|
||||
expect (res1.data.expires_in)
|
||||
.toEqual (expires_seconds);
|
||||
|
||||
check_token (res1.rt as string, 'refresh_token');
|
||||
await check_token (res1.rt as string, 'refresh_token');
|
||||
expect (res1.data.refresh_expires_in)
|
||||
.toEqual (refresh_expires_seconds);
|
||||
});
|
||||
@ -265,7 +299,7 @@ describe ('auth handler', () => {
|
||||
.toEqual ('bearer');
|
||||
expect (res1.data.expires_in)
|
||||
.toEqual (part_expires_seconds);
|
||||
check_token (res1.data.part_token as string, 'part_token');
|
||||
await check_token (res1.data.part_token as string, 'part_token');
|
||||
|
||||
const resp2 = await get (
|
||||
{ authorization: `Bearer ${res1.data.part_token}` },
|
||||
@ -277,19 +311,34 @@ describe ('auth handler', () => {
|
||||
expect (res2.data.token_type)
|
||||
.toEqual ('bearer');
|
||||
expect (resp2.headers['set-cookie'])
|
||||
.toContain (`cookie_jar=${res2.at}`);
|
||||
.toContain (build_cookie ({ name: 'cookie_jar' }, res2.at as string));
|
||||
expect (resp2.headers['set-cookie'])
|
||||
.toContain (build_cookie ({ name: 'mint_cookies' }, res2.rt as string));
|
||||
|
||||
check_token (res2.at as string, 'access_token');
|
||||
await check_token (res2.at as string, 'access_token');
|
||||
expect (res2.data.expires_in)
|
||||
.toEqual (expires_seconds);
|
||||
expect (res2.at).not.toEqual (res1.at);
|
||||
|
||||
check_token (res2.rt as string, 'refresh_token');
|
||||
await check_token (res2.rt as string, 'refresh_token');
|
||||
expect (res2.data.refresh_expires_in)
|
||||
.toEqual (refresh_expires_seconds);
|
||||
expect (res2.rt).not.toEqual (res1.rt);
|
||||
});
|
||||
|
||||
it ('should do immediate redirect', async () => {
|
||||
const resp1 = await get ({ authorization: 'Basic red:irect' });
|
||||
expect (resp1.statusCode)
|
||||
.toEqual (302);
|
||||
expect (resp1.headers.location)
|
||||
.toEqual ('/redirected');
|
||||
const signature = extract_cookie (
|
||||
'cookie_jar',
|
||||
(resp1.headers['set-cookie'] || []).join ('\n')
|
||||
);
|
||||
await check_token (signature, 'access_token');
|
||||
});
|
||||
|
||||
it ('should handle any authorization type', async () => {
|
||||
const resp = await get ({ authorization: 'Foo asdefg' });
|
||||
expect (resp.statusCode)
|
||||
@ -300,4 +349,30 @@ describe ('auth handler', () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
@ -27,7 +27,7 @@ describe ('authority', () => {
|
||||
const token = await auth.sign ('access_token', 60);
|
||||
jasmine.clock ()
|
||||
.tick (30000);
|
||||
const res = auth.verify (token.signature);
|
||||
const res = await auth.verify (token.signature);
|
||||
expect (res.authorized)
|
||||
.toBeTrue ();
|
||||
expect (res.valid)
|
||||
@ -46,7 +46,7 @@ describe ('authority', () => {
|
||||
const token = await auth.sign ('refresh_token', 600);
|
||||
jasmine.clock ()
|
||||
.tick (30000);
|
||||
const res = auth.verify (token.signature);
|
||||
const res = await auth.verify (token.signature);
|
||||
expect (res.authorized)
|
||||
.toBeFalse ();
|
||||
expect (res.valid)
|
||||
@ -65,7 +65,7 @@ describe ('authority', () => {
|
||||
const token = await auth.sign ('part_token', 60, { next_module: '2fa' });
|
||||
jasmine.clock ()
|
||||
.tick (30000);
|
||||
const res = auth.verify (token.signature);
|
||||
const res = await auth.verify (token.signature);
|
||||
expect (res.authorized)
|
||||
.toBeFalse ();
|
||||
expect (res.valid)
|
||||
@ -85,7 +85,7 @@ describe ('authority', () => {
|
||||
token.signature = modify_signature (token.signature);
|
||||
jasmine.clock ()
|
||||
.tick (30000);
|
||||
const res = auth.verify (token.signature);
|
||||
const res = await auth.verify (token.signature);
|
||||
expect (res.authorized)
|
||||
.toBeFalse ();
|
||||
expect (res.valid)
|
||||
@ -104,8 +104,8 @@ describe ('authority', () => {
|
||||
const token = await auth.sign ('access_token', 60);
|
||||
jasmine.clock ()
|
||||
.tick (30000);
|
||||
bl.add_signature (token.id);
|
||||
const res = auth.verify (token.signature);
|
||||
await bl.add_signature (token.id);
|
||||
const res = await auth.verify (token.signature);
|
||||
expect (res.authorized)
|
||||
.toBeFalse ();
|
||||
expect (res.valid)
|
||||
@ -125,7 +125,7 @@ describe ('authority', () => {
|
||||
token.signature = modify_signature (token.signature);
|
||||
jasmine.clock ()
|
||||
.tick (30000);
|
||||
const res = auth.verify (token.signature);
|
||||
const res = await auth.verify (token.signature);
|
||||
expect (res.authorized)
|
||||
.toBeFalse ();
|
||||
expect (res.valid)
|
||||
@ -144,8 +144,8 @@ describe ('authority', () => {
|
||||
const token = await auth.sign ('refresh_token', 600);
|
||||
jasmine.clock ()
|
||||
.tick (30000);
|
||||
bl.add_signature (token.id);
|
||||
const res = auth.verify (token.signature);
|
||||
await bl.add_signature (token.id);
|
||||
const res = await auth.verify (token.signature);
|
||||
expect (res.authorized)
|
||||
.toBeFalse ();
|
||||
expect (res.valid)
|
||||
|
@ -6,10 +6,15 @@
|
||||
*/
|
||||
|
||||
import blacklist, { Blacklist } from '../../lib/Blacklist';
|
||||
import { generate_token_id } from '../../lib/token_id';
|
||||
import { clock_finalize, clock_setup } from '../Helper';
|
||||
|
||||
// eslint-disable-next-line max-lines-per-function
|
||||
describe ('blacklist', () => {
|
||||
const token1 = generate_token_id (new Date (Date.now () + 3600000));
|
||||
const token2 = generate_token_id (new Date (Date.now () + 3600000));
|
||||
const token3 = generate_token_id (new Date (Date.now () + 3600000));
|
||||
|
||||
beforeAll (() => {
|
||||
clock_setup ();
|
||||
});
|
||||
@ -18,58 +23,89 @@ describe ('blacklist', () => {
|
||||
clock_finalize ();
|
||||
});
|
||||
|
||||
it ('should validate any string', () => {
|
||||
expect (blacklist.is_valid ('foo'))
|
||||
it ('should validate any string', async () => {
|
||||
expect (await blacklist.is_valid (token1))
|
||||
.toBeTrue ();
|
||||
expect (blacklist.is_valid ('bar'))
|
||||
expect (await blacklist.is_valid (token2))
|
||||
.toBeTrue ();
|
||||
expect (blacklist.is_valid ('baz'))
|
||||
expect (await blacklist.is_valid (token3))
|
||||
.toBeTrue ();
|
||||
});
|
||||
|
||||
it ('should blacklist strings', () => {
|
||||
blacklist.add_signature ('foo');
|
||||
blacklist.add_signature ('bar');
|
||||
expect (blacklist.is_valid ('foo'))
|
||||
it ('should blacklist strings', async () => {
|
||||
await blacklist.add_signature (token1);
|
||||
await blacklist.add_signature (token2);
|
||||
expect (await blacklist.is_valid (token1))
|
||||
.toBeFalse ();
|
||||
expect (blacklist.is_valid ('bar'))
|
||||
expect (await blacklist.is_valid (token2))
|
||||
.toBeFalse ();
|
||||
expect (blacklist.is_valid ('baz'))
|
||||
expect (await blacklist.is_valid (token3))
|
||||
.toBeTrue ();
|
||||
});
|
||||
|
||||
it ('should remove one string', () => {
|
||||
blacklist.remove_signature ('foo');
|
||||
expect (blacklist.is_valid ('foo'))
|
||||
it ('should remove one string', async () => {
|
||||
await blacklist.remove_signature (token1);
|
||||
expect (await blacklist.is_valid (token1))
|
||||
.toBeTrue ();
|
||||
expect (blacklist.is_valid ('bar'))
|
||||
expect (await blacklist.is_valid (token2))
|
||||
.toBeFalse ();
|
||||
expect (blacklist.is_valid ('baz'))
|
||||
expect (await blacklist.is_valid (token3))
|
||||
.toBeTrue ();
|
||||
});
|
||||
|
||||
it ('should clear after time', () => {
|
||||
it ('should clear after time', async () => {
|
||||
jasmine.clock ()
|
||||
.tick (5000);
|
||||
blacklist.add_signature ('baz');
|
||||
blacklist.clear_before (new Date (Date.now () - 100));
|
||||
expect (blacklist.is_valid ('foo'))
|
||||
await blacklist.add_signature (token3);
|
||||
await blacklist.clear (Date.now () - 100);
|
||||
expect (await blacklist.is_valid (token1))
|
||||
.toBeTrue ();
|
||||
expect (blacklist.is_valid ('bar'))
|
||||
expect (await blacklist.is_valid (token2))
|
||||
.toBeTrue ();
|
||||
expect (blacklist.is_valid ('baz'))
|
||||
expect (await blacklist.is_valid (token3))
|
||||
.toBeFalse ();
|
||||
});
|
||||
|
||||
it ('should export and import data', () => {
|
||||
const exp = blacklist.export_blacklist ();
|
||||
it ('should clear all', async () => {
|
||||
await blacklist.add_signature (token1);
|
||||
await blacklist.add_signature (token2);
|
||||
await blacklist.add_signature (token3);
|
||||
expect (await blacklist.is_valid (token1))
|
||||
.toBeFalse ();
|
||||
expect (await blacklist.is_valid (token2))
|
||||
.toBeFalse ();
|
||||
expect (await blacklist.is_valid (token3))
|
||||
.toBeFalse ();
|
||||
await blacklist.clear ();
|
||||
expect (await blacklist.is_valid (token1))
|
||||
.toBeTrue ();
|
||||
expect (await blacklist.is_valid (token2))
|
||||
.toBeTrue ();
|
||||
expect (await blacklist.is_valid (token3))
|
||||
.toBeTrue ();
|
||||
});
|
||||
|
||||
it ('should export and import data', async () => {
|
||||
const time = new Date;
|
||||
const token = generate_token_id (time);
|
||||
await blacklist.add_signature (token);
|
||||
// eslint-disable-next-line dot-notation
|
||||
expect (blacklist['_signatures'])
|
||||
.toEqual (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);
|
||||
bl2.import_blacklist (exp);
|
||||
// eslint-disable-next-line dot-notation
|
||||
expect (bl2['_signatures'])
|
||||
.toEqual (exp);
|
||||
// eslint-disable-next-line dot-notation
|
||||
.toEqual (blacklist['_signatures']);
|
||||
});
|
||||
});
|
||||
|
@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import http from 'http';
|
||||
import { create_gateway } from '../../lib/index';
|
||||
import { GatewayClass, create_gateway } from '../../lib/index';
|
||||
import authority from '../../lib/Authority';
|
||||
import blacklist from '../../lib/Blacklist';
|
||||
import { clock_finalize, clock_setup, get } from '../Helper';
|
||||
@ -18,18 +18,33 @@ describe ('gateway', () => {
|
||||
beforeAll (() => {
|
||||
clock_setup ();
|
||||
|
||||
const g = create_gateway ({
|
||||
const g = new GatewayClass ({
|
||||
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) => {
|
||||
const passed_handler = () => {
|
||||
if (typeof req.url !== 'undefined') {
|
||||
if (req.url.endsWith ('logout'))
|
||||
g.logout (req);
|
||||
}
|
||||
res.writeHead (200);
|
||||
const con = req.connection as unknown as Record<string, unknown>;
|
||||
res.end (JSON.stringify (con.auth));
|
||||
const data = {
|
||||
...g.get_info (req),
|
||||
foo: g.has_permission (req, 'foo'),
|
||||
bar: g.has_permission (req, 'bar')
|
||||
};
|
||||
g (req, res, passed_handler);
|
||||
|
||||
res.end (JSON.stringify (data));
|
||||
};
|
||||
g.process_request (req, res, passed_handler);
|
||||
});
|
||||
server.listen (3000);
|
||||
});
|
||||
@ -70,6 +85,28 @@ describe ('gateway', () => {
|
||||
.toEqual (token.id);
|
||||
});
|
||||
|
||||
it ('should automatically return new tokens', async () => {
|
||||
const token = await authority.sign ('access_token', 60, { data: 'foobar' });
|
||||
const refresh = await authority.sign (
|
||||
'refresh_token',
|
||||
3600,
|
||||
{ data: 'foobar' }
|
||||
);
|
||||
jasmine.clock ()
|
||||
.tick (70000);
|
||||
const resp = await get (
|
||||
// eslint-disable-next-line max-len
|
||||
{ cookie: `foo=bar;cookie_jar=${token.signature};asd=efg;mint_cookies=${refresh.signature}` }
|
||||
);
|
||||
expect (resp.statusCode)
|
||||
.toEqual (200);
|
||||
expect (JSON.parse (resp.body as string).token_id)
|
||||
.not
|
||||
.toEqual (token.id);
|
||||
expect (JSON.parse (resp.body as string).token_data)
|
||||
.toEqual ('foobar');
|
||||
});
|
||||
|
||||
it ('should correctly deliver token data', async () => {
|
||||
const token = await authority.sign ('access_token', 60, { data: 'foobar' });
|
||||
const resp = await get ({ authorization: `Bearer ${token.signature}` });
|
||||
@ -80,6 +117,8 @@ describe ('gateway', () => {
|
||||
.toEqual (token.id);
|
||||
expect (body.token_data)
|
||||
.toEqual ('foobar');
|
||||
expect (body.permissions)
|
||||
.toEqual ([]);
|
||||
});
|
||||
|
||||
it ('should reject an outdated access token', async () => {
|
||||
@ -95,7 +134,7 @@ describe ('gateway', () => {
|
||||
|
||||
it ('should reject a blacklisted access token', async () => {
|
||||
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}` });
|
||||
expect (resp.statusCode)
|
||||
.toEqual (302);
|
||||
@ -137,4 +176,51 @@ describe ('gateway', () => {
|
||||
expect (resp.headers.location)
|
||||
.toEqual ('http://localhost/auth');
|
||||
});
|
||||
|
||||
it ('should disallow access and refresh cookies with the same name', () => {
|
||||
expect (() => {
|
||||
create_gateway ({
|
||||
cookie: { name: 'foo' },
|
||||
refresh_cookie: { name: 'foo' }
|
||||
});
|
||||
})
|
||||
.toThrowError ('access and refresh cookies cannot have the same name');
|
||||
});
|
||||
|
||||
it ('should logout all tokens', async () => {
|
||||
const token = await authority.sign ('access_token', 60);
|
||||
const refresh = await authority.sign ('refresh_token', 3600);
|
||||
const resp = await get (
|
||||
// eslint-disable-next-line max-len
|
||||
{ cookie: `foo=bar;cookie_jar=${token.signature};asd=efg;mint_cookies=${refresh.signature}` },
|
||||
null,
|
||||
'/logout'
|
||||
);
|
||||
expect (resp.statusCode)
|
||||
.toEqual (200);
|
||||
const blacklisted = blacklist.export_blacklist ()
|
||||
.map ((v) => v.token_id);
|
||||
expect (blacklisted)
|
||||
.toContain (token.id);
|
||||
expect (blacklisted)
|
||||
.toContain (refresh.id);
|
||||
});
|
||||
|
||||
it ('should correctly check permissions', async () => {
|
||||
const token = await authority.sign (
|
||||
'access_token',
|
||||
60,
|
||||
{ permissions: [ 'foo' ] }
|
||||
);
|
||||
const resp = await get ({ authorization: `Bearer ${token.signature}` });
|
||||
expect (resp.statusCode)
|
||||
.toEqual (200);
|
||||
expect (JSON.parse (resp.body as string))
|
||||
.toEqual ({
|
||||
token_id: token.id,
|
||||
permissions: [ 'foo' ],
|
||||
foo: true,
|
||||
bar: false
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -27,7 +27,7 @@ describe ('key store', () => {
|
||||
.getTime () / 1000;
|
||||
const duration = 10 * frame;
|
||||
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)
|
||||
.toEqual ('string');
|
||||
expect (typeof sign)
|
||||
@ -39,7 +39,7 @@ describe ('key store', () => {
|
||||
const key = await ks.get_sign_key (keys[0].iat, 1);
|
||||
expect (key)
|
||||
.toEqual (keys[0].key);
|
||||
const sign = ks.get_key (keys[0].iat);
|
||||
const sign = await ks.get_key (keys[0].iat);
|
||||
expect (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);
|
||||
expect (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)
|
||||
.toEqual (keys[0].sign);
|
||||
});
|
||||
@ -60,7 +60,7 @@ describe ('key store', () => {
|
||||
.getTime () / 1000;
|
||||
const duration = 10 * frame;
|
||||
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)
|
||||
.toEqual ('string');
|
||||
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 () => {
|
||||
const sign = ks.get_key (keys[0].iat);
|
||||
const sign = await ks.get_key (keys[0].iat);
|
||||
expect (sign)
|
||||
.toEqual (keys[0].sign);
|
||||
await expectAsync (ks.get_sign_key (keys[0].iat, 1))
|
||||
.toBeRejectedWithError ('cannot access already expired keys');
|
||||
const k2 = await ks.get_sign_key (keys[1].iat, 1);
|
||||
const s2 = ks.get_key (keys[1].iat);
|
||||
const s2 = await ks.get_key (keys[1].iat);
|
||||
expect (k2)
|
||||
.toEqual (keys[1].key);
|
||||
expect (s2)
|
||||
.toEqual (keys[1].sign);
|
||||
});
|
||||
|
||||
it ('should throw on non existing key', () => {
|
||||
expect (() => ks.get_key (keys[1].iat + frame))
|
||||
.toThrowError ('key could not be found');
|
||||
it ('should throw on non existing key', async () => {
|
||||
await expectAsync (ks.get_key (keys[1].iat + frame))
|
||||
.toBeRejectedWithError ('key could not be found');
|
||||
});
|
||||
|
||||
it ('should delete a key after it expires', () => {
|
||||
it ('should delete a key after it expires', async () => {
|
||||
// go to 10 frames + 1ms after key creation
|
||||
jasmine.clock ()
|
||||
.tick ((frame * 9e3) + 1);
|
||||
// eslint-disable-next-line dot-notation
|
||||
ks['garbage_collect'] ();
|
||||
expect (() => ks.get_key (keys[0].iat))
|
||||
.toThrowError ('key could not be found');
|
||||
await expectAsync (ks.get_key (keys[0].iat))
|
||||
.toBeRejectedWithError ('key could not be found');
|
||||
});
|
||||
|
||||
it (
|
||||
@ -102,7 +102,7 @@ describe ('key store', () => {
|
||||
async () => {
|
||||
await expectAsync (ks.get_sign_key (keys[1].iat, 1))
|
||||
.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)
|
||||
.toEqual (keys[1].sign);
|
||||
}
|
||||
@ -129,12 +129,12 @@ describe ('key store', () => {
|
||||
jasmine.clock ()
|
||||
.tick (step * 1000);
|
||||
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)
|
||||
.toEqual (key2);
|
||||
jasmine.clock ()
|
||||
.tick (5000 * frame);
|
||||
const signv = ks.get_key (iat + step);
|
||||
const signv = await ks.get_key (iat + step);
|
||||
expect (signv)
|
||||
.toEqual (sign);
|
||||
});
|
||||
@ -151,29 +151,26 @@ describe ('key store', () => {
|
||||
.getTime () / 1000;
|
||||
|
||||
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 ();
|
||||
// eslint-disable-next-line dot-notation
|
||||
expect (Object.keys (ks['_keys']))
|
||||
.toEqual (Object.keys (exp));
|
||||
expect (Object.keys (exp)
|
||||
.filter ((v) => typeof exp[v].private_key !== 'undefined').length)
|
||||
.toEqual (0);
|
||||
.toEqual (exp.map ((v) => v.index));
|
||||
|
||||
const ks2 = (new KeyStore);
|
||||
expect (ks2.instance_id).not.toEqual (ks.instance_id);
|
||||
ks2.import_verification_data (exp);
|
||||
// eslint-disable-next-line dot-notation
|
||||
expect (ks2['_keys'])
|
||||
.toEqual (exp);
|
||||
expect (Object.keys (ks2['_keys']))
|
||||
.toEqual (exp.map ((v) => v.index));
|
||||
|
||||
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 (ver).not.toEqual (ver2);
|
||||
await expectAsync (ks2.get_sign_key (iat, 60, ks.instance_id))
|
||||
.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);
|
||||
});
|
||||
|
||||
@ -182,4 +179,16 @@ describe ('key store', () => {
|
||||
expect (() => ks.import_verification_data (exp))
|
||||
.toThrowError ('cannot import to the same instance');
|
||||
});
|
||||
|
||||
it ('should clear all', () => {
|
||||
// eslint-disable-next-line dot-notation
|
||||
expect (Object.keys (ks['_keys']).length)
|
||||
.toBeGreaterThan (0);
|
||||
const instance = ks.instance_id;
|
||||
ks.reset_instance ();
|
||||
// eslint-disable-next-line dot-notation
|
||||
expect (Object.keys (ks['_keys']).length)
|
||||
.toEqual (0);
|
||||
expect (instance).not.toEqual (ks.instance_id);
|
||||
});
|
||||
});
|
||||
|
110
test/spec/Redis.ts
Normal file
110
test/spec/Redis.ts
Normal file
@ -0,0 +1,110 @@
|
||||
/*
|
||||
* Copyright (C) Sapphirecode - All Rights Reserved
|
||||
* This file is part of Auth-Server-Helper which is released under MIT.
|
||||
* See file 'LICENSE' for full license details.
|
||||
* Created by Timo Hocker <timo@scode.ovh>, August 2022
|
||||
*/
|
||||
|
||||
/* eslint-disable dot-notation */
|
||||
|
||||
import { blacklist } from '../../lib';
|
||||
import ks from '../../lib/KeyStore';
|
||||
import { Redis } from '../../lib/Redis';
|
||||
import { generate_token_id } from '../../lib/token_id';
|
||||
import { clock_finalize, clock_setup } from '../Helper';
|
||||
|
||||
const frame = 3600;
|
||||
const redis_url = process.env.TEST_REDIS_URL || 'redis://localhost';
|
||||
const redis = new Redis;
|
||||
redis.connect (redis_url);
|
||||
|
||||
// eslint-disable-next-line max-lines-per-function
|
||||
describe ('redis', () => {
|
||||
const token1 = generate_token_id (new Date (Date.now () + 3600000));
|
||||
const token2 = generate_token_id (new Date (Date.now () + 3600000));
|
||||
const token3 = generate_token_id (new Date (Date.now () + 3600000));
|
||||
|
||||
beforeAll (async () => {
|
||||
ks.reset_instance ();
|
||||
ks.sync_redis (redis_url);
|
||||
await blacklist.clear ();
|
||||
blacklist.sync_redis (redis_url);
|
||||
clock_setup ();
|
||||
});
|
||||
|
||||
let iat1 = 0;
|
||||
let iat2 = 0;
|
||||
let k1 = '';
|
||||
let k2 = '';
|
||||
let i1 = '';
|
||||
let i2 = '';
|
||||
|
||||
afterAll (() => clock_finalize ());
|
||||
|
||||
it ('should generate two keys', async () => {
|
||||
iat1 = (new Date)
|
||||
.getTime () / 1000;
|
||||
await ks.get_sign_key (iat1, frame);
|
||||
k1 = await ks.get_key (iat1);
|
||||
|
||||
jasmine.clock ()
|
||||
.tick (frame * 1000);
|
||||
|
||||
iat2 = (new Date)
|
||||
.getTime () / 1000;
|
||||
await ks.get_sign_key (iat2, frame);
|
||||
k2 = await ks.get_key (iat2);
|
||||
// eslint-disable-next-line dot-notation
|
||||
i1 = ks['get_index'] (iat1);
|
||||
// eslint-disable-next-line dot-notation
|
||||
i2 = ks['get_index'] (iat2);
|
||||
});
|
||||
|
||||
it ('should have two keys in redis', async () => {
|
||||
expect (JSON.parse (await redis['_redis']
|
||||
?.get (`keystore_${i1}`) as string).key)
|
||||
.toEqual (k1);
|
||||
expect (JSON.parse (await redis['_redis']
|
||||
?.get (`keystore_${i2}`) as string).key)
|
||||
.toEqual (k2);
|
||||
});
|
||||
|
||||
it ('should read two keys with a new instance', async () => {
|
||||
const old_instance = ks.instance_id;
|
||||
ks.reset_instance ();
|
||||
expectAsync (ks.get_key (iat1, old_instance))
|
||||
.toBeRejectedWithError ('key could not be found');
|
||||
expectAsync (ks.get_key (iat1, old_instance))
|
||||
.toBeRejectedWithError ('key could not be found');
|
||||
|
||||
ks.sync_redis (redis_url);
|
||||
expect (await ks.get_key (iat1, old_instance))
|
||||
.toEqual (k1);
|
||||
expect (await ks.get_key (iat2, old_instance))
|
||||
.toEqual (k2);
|
||||
});
|
||||
|
||||
it ('should add two keys to the blacklist', async () => {
|
||||
await blacklist.add_signature (token1);
|
||||
await blacklist.add_signature (token2);
|
||||
});
|
||||
|
||||
it ('should have two keys in redis blacklist', async () => {
|
||||
expect ((await redis['_redis']?.exists (`blacklist_${token1}`)) === 1)
|
||||
.toBeTrue ();
|
||||
expect ((await redis['_redis']?.exists (`blacklist_${token2}`)) === 1)
|
||||
.toBeTrue ();
|
||||
expect ((await redis['_redis']?.exists (`blacklist_${token3}`)) === 1)
|
||||
.toBeFalse ();
|
||||
});
|
||||
|
||||
it ('should read keys from redis', async () => {
|
||||
blacklist['_signatures'].splice (0, blacklist['_signatures'].length);
|
||||
expect (await blacklist.is_valid (token1))
|
||||
.toBeFalse ();
|
||||
expect (await blacklist.is_valid (token2))
|
||||
.toBeFalse ();
|
||||
expect (await blacklist.is_valid (token3))
|
||||
.toBeTrue ();
|
||||
});
|
||||
});
|
130
test/spec/cookie.ts
Normal file
130
test/spec/cookie.ts
Normal file
@ -0,0 +1,130 @@
|
||||
/*
|
||||
* Copyright (C) Sapphirecode - All Rights Reserved
|
||||
* This file is part of Auth-Server-Helper which is released under MIT.
|
||||
* See file 'LICENSE' for full license details.
|
||||
* Created by Timo Hocker <timo@scode.ovh>, January 2022
|
||||
*/
|
||||
|
||||
import { build_cookie, CookieSettings, extract_cookie } from '../../lib/cookie';
|
||||
|
||||
interface CreateCookie {
|
||||
settings: CookieSettings
|
||||
value: string
|
||||
result: string
|
||||
}
|
||||
|
||||
const create_cookie_pairs: CreateCookie[] = [
|
||||
{
|
||||
settings: { name: 'foo' },
|
||||
value: 'bar',
|
||||
result: 'foo=bar; Secure; HttpOnly; SameSite=Strict'
|
||||
},
|
||||
{
|
||||
settings: { name: 'foäöüo' },
|
||||
value: 'baäöür',
|
||||
result: 'foäöüo=baäöür; Secure; HttpOnly; SameSite=Strict'
|
||||
},
|
||||
{
|
||||
settings: {
|
||||
name: 'foo',
|
||||
secure: true,
|
||||
http_only: false,
|
||||
same_site: null
|
||||
},
|
||||
value: 'bar',
|
||||
result: 'foo=bar; Secure'
|
||||
},
|
||||
{
|
||||
settings: {
|
||||
name: 'foo',
|
||||
secure: false,
|
||||
http_only: true,
|
||||
same_site: null
|
||||
},
|
||||
value: 'bar',
|
||||
result: 'foo=bar; HttpOnly'
|
||||
},
|
||||
{
|
||||
settings: {
|
||||
name: 'foo',
|
||||
secure: false,
|
||||
http_only: false,
|
||||
same_site: 'Lax'
|
||||
},
|
||||
value: 'bar',
|
||||
result: 'foo=bar; SameSite=Lax'
|
||||
},
|
||||
{
|
||||
settings: {
|
||||
name: 'foo',
|
||||
secure: false,
|
||||
http_only: false,
|
||||
same_site: null,
|
||||
expires: 'Tomorrow'
|
||||
},
|
||||
value: 'bar',
|
||||
result: 'foo=bar; Expires=Tomorrow'
|
||||
},
|
||||
{
|
||||
settings: {
|
||||
name: 'foo',
|
||||
secure: false,
|
||||
http_only: false,
|
||||
same_site: null,
|
||||
max_age: 600
|
||||
},
|
||||
value: 'bar',
|
||||
result: 'foo=bar; Max-Age=600'
|
||||
},
|
||||
{
|
||||
settings: {
|
||||
name: 'foo',
|
||||
secure: false,
|
||||
http_only: false,
|
||||
same_site: null,
|
||||
domain: 'example.com'
|
||||
},
|
||||
value: 'bar',
|
||||
result: 'foo=bar; Domain=example.com'
|
||||
},
|
||||
{
|
||||
settings: {
|
||||
name: 'foo',
|
||||
secure: false,
|
||||
http_only: false,
|
||||
same_site: null,
|
||||
path: '/test'
|
||||
},
|
||||
value: 'bar',
|
||||
result: 'foo=bar; Path=/test'
|
||||
}
|
||||
];
|
||||
|
||||
const parse_cookie_pairs = [
|
||||
{
|
||||
header: 'foo=bar; Secure; HttpOnly; SameSite=Strict',
|
||||
name: 'foo',
|
||||
value: 'bar'
|
||||
},
|
||||
{
|
||||
header: '134=567;foäöüo=baäöür;tesT=123',
|
||||
name: 'foäöüo',
|
||||
value: 'baäöür'
|
||||
}
|
||||
];
|
||||
|
||||
describe ('cookie', () => {
|
||||
it ('should create a cookie', () => {
|
||||
for (const pair of create_cookie_pairs) {
|
||||
expect (build_cookie (pair.settings, pair.value))
|
||||
.toEqual (pair.result);
|
||||
}
|
||||
});
|
||||
|
||||
it ('should parse a cookie', () => {
|
||||
for (const pair of parse_cookie_pairs) {
|
||||
expect (extract_cookie (pair.name, pair.header))
|
||||
.toEqual (pair.value);
|
||||
}
|
||||
});
|
||||
});
|
22
test/spec/token_id.ts
Normal file
22
test/spec/token_id.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { generate_token_id, parse_token_id } from '../../lib/token_id';
|
||||
import { clock_finalize, clock_setup } from '../Helper';
|
||||
|
||||
describe ('token_id', () => {
|
||||
beforeAll (() => {
|
||||
clock_setup ();
|
||||
});
|
||||
|
||||
afterAll (() => {
|
||||
clock_finalize ();
|
||||
});
|
||||
|
||||
it ('should always generate valid tokens', () => {
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
const date = new Date;
|
||||
const token_id = generate_token_id (new Date);
|
||||
const parsed = parse_token_id (token_id);
|
||||
expect (parsed.valid_until)
|
||||
.toEqual (date);
|
||||
}
|
||||
});
|
||||
});
|
@ -3,12 +3,14 @@
|
||||
"target": "es5",
|
||||
"module": "commonjs",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./lib",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"exclude": ["test/**/*.ts"]
|
||||
"include": [
|
||||
"lib/**/*.ts",
|
||||
"test/**/*.ts"
|
||||
]
|
||||
}
|
||||
|
Reference in New Issue
Block a user