Compare commits
118 Commits
6c6c0bc35d
...
master
Author | SHA1 | Date | |
---|---|---|---|
a41882de60
|
|||
ba9608829d | |||
910099285b | |||
6856ac718f | |||
6eb2009141 | |||
64d4f00629 | |||
31f739d4b8
|
|||
e80e3f9a94
|
|||
b7514941f0
|
|||
d5c136790e
|
|||
43cf782511 | |||
b43190d048 | |||
7bb6dac737 | |||
1009a9b8d5 | |||
da36f87250
|
|||
cf2f9c0182 | |||
4d69efd9f5 | |||
fd26975559 | |||
122bd7b574
|
|||
84be087743 | |||
ec08f8f04e
|
|||
cc8762e4ec
|
|||
3aaaf10fd9
|
|||
8f047f2700
|
|||
80a98704af
|
|||
c7708f4bc0
|
|||
b58af27719
|
|||
2a51e0a753
|
|||
22075489c2
|
|||
1188e4573f
|
|||
d28be9e3f8
|
|||
dab45e39a6
|
|||
4820bda8ca
|
|||
86b07af63d
|
|||
85a5f3c2fb
|
|||
c55ed33e53 | |||
3bc5538a69 | |||
e7ad5656e3 | |||
a3f021fdd2 | |||
d286548850 | |||
e326c6c077 | |||
ce58c0d204 | |||
9ec97d8aa2 | |||
1af8c0702c | |||
e6039e78b1 | |||
c5bc0855d7 | |||
d6a40871c4 | |||
4c42a682d5 | |||
fd4f891b3e | |||
adfeeaa52c | |||
1437316519 | |||
5df2577e71 | |||
df8de9e0c8 | |||
05f2e53a8f | |||
8285e58337 | |||
6cf6286fb8 | |||
872661a926 | |||
48afa73ae8 | |||
debb7debf1 | |||
80d04f7441 | |||
f39759bad9 | |||
4c27d0eace | |||
83a402db8b | |||
2f342b31f7 | |||
a8fb92b367 | |||
051c2bdbbd | |||
b27ab8c6fc | |||
669bc19943 | |||
8a264bfa58 | |||
170eb8a743 | |||
68c06b6742 | |||
a4892f6262 | |||
ddde2806d8 | |||
0be180f632 | |||
008fd3f545 | |||
01cb121a68 | |||
210696dda0 | |||
fe037d43d3 | |||
e9c111ff55 | |||
9ea4f4664c | |||
2ee7a11fea | |||
c6bd55eb0d | |||
055bb84a70 | |||
cd9d0aff5b | |||
abf98cc4ad | |||
5b9dc10db9 | |||
c425882e20 | |||
a77180d637 | |||
fe0b409db5 | |||
a4a13e665a | |||
b116d4e164 | |||
064ddd0a1e | |||
cf927114c2 | |||
c40e6c19ea | |||
507c0ceba3 | |||
8f131a932f | |||
1d944287f1 | |||
26261da0a5 | |||
d22776f7b9 | |||
573ca9e633 | |||
f9f93fb5bc | |||
53c2deefc9 | |||
7724ccc7bf | |||
3006a0b918 | |||
6fc9895884 | |||
4168ba8cec | |||
e10d665c2b | |||
9e8fb8fc60 | |||
560f558e0d | |||
cdc37e93b3 | |||
5a6a74c614 | |||
605ee9d73b | |||
af465b47d9 | |||
3a6910f406 | |||
18c0909276 | |||
9e4d6477b7 | |||
7e2c3742af | |||
640d8d6889 |
15
.drone.yml
Normal file
15
.drone.yml
Normal file
@ -0,0 +1,15 @@
|
||||
kind: pipeline
|
||||
name: default
|
||||
|
||||
steps:
|
||||
- name: setup
|
||||
image: registry:5000/node-build
|
||||
commands:
|
||||
- yarn
|
||||
- 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
|
2
.eslintignore
Normal file
2
.eslintignore
Normal file
@ -0,0 +1,2 @@
|
||||
/dist/
|
||||
*.d.ts
|
14
.eslintrc.js
14
.eslintrc.js
@ -2,23 +2,21 @@
|
||||
* Copyright (C) Sapphirecode - All Rights Reserved
|
||||
* This file is part of Auth-Server-Helper which is released under MIT.
|
||||
* See file 'LICENSE' for full license details.
|
||||
* Created by Timo Hocker <timo@sapphirecode.ovh>, March 2020
|
||||
* Created by Timo Hocker <timo@scode.ovh>, December 2020
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
env: {
|
||||
commonjs: true,
|
||||
es6: true,
|
||||
node: true
|
||||
},
|
||||
extends: [
|
||||
'@sapphirecode'
|
||||
],
|
||||
extends: [ '@sapphirecode' ],
|
||||
globals: {
|
||||
Atomics: 'readonly',
|
||||
SharedArrayBuffer: 'readonly'
|
||||
},
|
||||
parserOptions: {
|
||||
ecmaVersion: 2018
|
||||
}
|
||||
}
|
||||
parserOptions: { ecmaVersion: 2018 }
|
||||
};
|
||||
|
4
.gitignore
vendored
4
.gitignore
vendored
@ -2,6 +2,4 @@
|
||||
/dist/
|
||||
/.nyc_output/
|
||||
/coverage/
|
||||
/db.sqlite
|
||||
# stryker temp files
|
||||
.stryker-tmp
|
||||
/.stryker-tmp/
|
||||
|
8
.liconfig.json
Normal file
8
.liconfig.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"has_license": true,
|
||||
"license": "MIT",
|
||||
"author": "Timo Hocker",
|
||||
"company": "Sapphirecode",
|
||||
"email": "timo@scode.ovh",
|
||||
"software": "Auth-Server-Helper"
|
||||
}
|
54
CHANGELOG.md
Normal file
54
CHANGELOG.md
Normal file
@ -0,0 +1,54 @@
|
||||
# Changelog
|
||||
|
||||
## 4.1.0
|
||||
|
||||
- Permission Management
|
||||
- Gateway function to read connection info
|
||||
|
||||
## 4.0.0
|
||||
|
||||
- Blacklist entries can now be synchronized through redis
|
||||
|
||||
BREAKING: Blacklist functions are now asynchronous
|
||||
|
||||
## 3.3.0
|
||||
|
||||
- Verification Keys can now be synchronized through redis
|
||||
|
||||
## 3.2.0
|
||||
|
||||
- Logout function
|
||||
|
||||
## 3.1.0
|
||||
|
||||
- Option to enable body parsing
|
||||
|
||||
## 3.0.0
|
||||
|
||||
- Allows Cookies Parameters to be set
|
||||
|
||||
BREAKING:
|
||||
|
||||
- All cookie_name and refresh_cookie_name properties have been renamed to cookie and refresh_cookie and are now a settings object instead of a string
|
||||
|
||||
## 2.2.0
|
||||
|
||||
- Allow refresh tokens to be sent on a separate cookie
|
||||
- Automatic token refresh if the access token is expired and the cookie header contains a valid refresh token
|
||||
|
||||
## 2.1.0
|
||||
|
||||
- Allow access to Gateway functions like authenticate, get_cookie_auth, get_header_auth, redirect, deny
|
||||
- Allow Gateway to deny a request in case no redirect url is specified
|
||||
|
||||
## 2.0.0
|
||||
|
||||
Complete redesign
|
||||
|
||||
## 1.1.0
|
||||
|
||||
add user_id to res.connection, so request handlers can access the current user
|
||||
|
||||
## 1.0.0
|
||||
|
||||
initial release
|
23
Jenkinsfile
vendored
23
Jenkinsfile
vendored
@ -1,23 +0,0 @@
|
||||
pipeline {
|
||||
agent any
|
||||
|
||||
environment {
|
||||
VERSION = VersionNumber([
|
||||
versionNumberString:
|
||||
'${BUILDS_ALL_TIME}',
|
||||
versionPrefix: '1.0.',
|
||||
worstResultForIncrement: 'SUCCESS'
|
||||
])
|
||||
}
|
||||
|
||||
stages {
|
||||
stage('Building') {
|
||||
steps {
|
||||
script {
|
||||
currentBuild.displayName = env.VERSION
|
||||
}
|
||||
sh 'yarn ci ${VERSION}'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
7
LICENSE
Normal file
7
LICENSE
Normal file
@ -0,0 +1,7 @@
|
||||
MIT License Copyright (c) <year> <author>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice (including the next paragraph) shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
304
README.md
304
README.md
@ -1,42 +1,290 @@
|
||||
# Auth Server Helper
|
||||
# auth-server-helper
|
||||
|
||||
Authentication middleware for express
|
||||
version: 4.1.x
|
||||
|
||||
customizable and simple authentication
|
||||
|
||||
## Installation
|
||||
|
||||
npm:
|
||||
|
||||
> npm i --save auth-server-helper
|
||||
|
||||
yarn:
|
||||
|
||||
> yarn add auth-server-helper
|
||||
|
||||
## Usage
|
||||
|
||||
```js
|
||||
const auth = require('@scode/auth-server-helper');
|
||||
const password_helper = require('@scode/password_helper');
|
||||
### 1. put a gateway in front of the routes you want to secure
|
||||
|
||||
const users = {
|
||||
foo: {
|
||||
id: 0
|
||||
password: await password_helper.hash('bar'),
|
||||
salt: '123'
|
||||
```js
|
||||
const {create_gateway} = require('@sapphirecode/auth-server-helper');
|
||||
|
||||
const gateway = create_gateway({
|
||||
redirect_url: '/auth', // if defined, unauthorized requests will be redirected
|
||||
cookie: { name: 'auth_cookie' }, // if defined, access tokens will be read from or written to this cookie,
|
||||
refresh_cookie: { name: 'refresh_cookie' }, // if defined, refresh tokens will be read and used to automatically refresh client tokens (requires the refresh_settings attribute)
|
||||
refresh_settings: {
|
||||
// same as settings for allow_access under section 2
|
||||
// the options data, redirect_to and leave_open are not supported here
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// add cookieParser to allow session management via cookies
|
||||
app.use(cookieParser());
|
||||
|
||||
// the middleware needs a function to determine user data
|
||||
// this function can also return a promise
|
||||
app.use(auth((user_name) => {
|
||||
if (!users[user_name])
|
||||
return null;
|
||||
return users[user_name];
|
||||
}));
|
||||
// express
|
||||
app.use(gateway);
|
||||
|
||||
// node http
|
||||
http.createServer((main_req, main_res) =>
|
||||
gateway(main_req, main_res, (req, res) => {
|
||||
// your request handler
|
||||
});
|
||||
);
|
||||
```
|
||||
|
||||
when a client logs in, it will set a header called 'session' that the client can use to authorize the following requests.
|
||||
it also sets a cookie to make requesting from the client more simple. (cookie parser is needed to make authentication with cookies possible)
|
||||
the gateway will forward any authorized requests to the next handler and
|
||||
redirect all others to the specified url
|
||||
|
||||
## Excluding routes
|
||||
|
||||
exceptions to the auth module can be added by adding an array of regular expressions
|
||||
a specific method can also be filtered for by giving an object instead of a plain regular expression.
|
||||
#### 1.1. Creating a gateway for manual processing of requests
|
||||
|
||||
```js
|
||||
auth(..., [/no-auth/, {regex: '/no-auth-post/', method: 'POST'}]);
|
||||
const {GatewayClass} = require('@sapphirecode/auth-server-helper');
|
||||
|
||||
const gateway = new GatewayClass({ /* options */ }); // options are the same as for create_gateway above
|
||||
|
||||
// process a request
|
||||
if (gateway.authenticate(http_request)) { // returns true if request is valid and sets req.connection.token_id and .token_data
|
||||
console.log('access granted');
|
||||
} else {
|
||||
gateway.redirect(response); // redirects the client, triggers deny if no redirect_url was set in options
|
||||
// or
|
||||
gateway.deny(response); // sends status 403
|
||||
}
|
||||
```
|
||||
|
||||
### 2. creating the auth endpoint
|
||||
|
||||
```js
|
||||
const {create_auth_handler} = require('@sapphirecode/auth-server-helper');
|
||||
|
||||
const handler = create_auth_handler(
|
||||
async (req) => {
|
||||
if (req.user === 'foo' && req.password === 'bar')
|
||||
const {access_token_id, refresh_token_id} = await req.allow_access({
|
||||
access_token_expires_in: 600, // seconds until access tokens expire
|
||||
include_refresh_token: true, // should the answer include a refresh token? default: false
|
||||
refresh_token_expires_in: 3600, // seconds until refresh tokens expire (required if refresh tokens are generated)
|
||||
data: {user: 'foo'}, // additional custom data to include in the token
|
||||
});
|
||||
|
||||
if (req.user === 'part' && req.password === 'baz')
|
||||
const part_id = await req.allow_part(
|
||||
60, // seconds until part_token expires
|
||||
'some_module', // next module handler (defined below)
|
||||
{foo: 'bar'} // custom data to attach to the token
|
||||
);
|
||||
|
||||
// all allow_ functions return a token id, which can later be used to invalidate specific tokens from the server side
|
||||
|
||||
req.deny();
|
||||
},
|
||||
{
|
||||
refresh: {
|
||||
/*...same options as allow_access */
|
||||
}, // define the behaviour of refresh tokens. Refresh tokens will not be accepted if this option is undefined
|
||||
modules: {
|
||||
some_module(req) {
|
||||
// request handlers for part_tokens
|
||||
|
||||
// access custom data:
|
||||
const auth_data = req.request.connection.auth;
|
||||
auth_data.token_id; // token id
|
||||
auth_data.token_data; // custom data
|
||||
// the same works in handlers after the gateway, information is always stored in request.connection.auth
|
||||
},
|
||||
},
|
||||
cookie: { name: 'auth_cookie' }, // if defined, access tokens will be stored in this cookie,
|
||||
refresh_cookie: { name: 'refresh_cookie' }, // if defined, refresh tokens will be stored in this cookie
|
||||
parse_body: true // read the request body into a string (default false)
|
||||
}
|
||||
);
|
||||
|
||||
// express
|
||||
app.use(handler);
|
||||
|
||||
// node http
|
||||
// ... create server, on path /auth run the handler
|
||||
handler(req, res); // the handler will also return true if allow_access or allow_part was called
|
||||
```
|
||||
|
||||
after the auth handler, the request will be completed, no additional content
|
||||
should be served here. (Read 2.1 for info on disabling this)
|
||||
|
||||
#### 2.1. Processing Auth Requests without closing the response object
|
||||
|
||||
to prevent the auth handler from closing the response object you can provide
|
||||
additional options on each of the allow/deny functions.
|
||||
|
||||
```js
|
||||
allow_access({leave_open: true, ...});
|
||||
allow_part(
|
||||
60,
|
||||
'some_module',
|
||||
{foo: 'bar'},
|
||||
true // additional flag to leave request open
|
||||
);
|
||||
invalid('error description', true);
|
||||
deny(true);
|
||||
```
|
||||
|
||||
if this flag is set, no data will be written to the response body and no data
|
||||
will be sent. Status code and Headers will still be set.
|
||||
|
||||
### Defining Custom Cookie Settings
|
||||
|
||||
By default all cookies will be sent with 'Secure; HttpOnly; SameSite=Strict'
|
||||
Attributes
|
||||
|
||||
In the appropriate settings object, you can set the following options:
|
||||
|
||||
```js
|
||||
{
|
||||
name: 'foo', // name of the cookies
|
||||
secure: true, // option to enable or disable the Secure option default: true
|
||||
http_only: true, // option to enable or disable HttpOnly default: true
|
||||
same_site: 'Strict', // SameSite property (Strict, Lax or None) default: 'Strict'. Set this to null to disable
|
||||
expires: 'Mon, 10 Jan 2022 09:28:00 GMT', // Expiry date of the cookie
|
||||
max_age: 600, // Maximum age in Seconds
|
||||
domain: 'example.com', // Domain property
|
||||
path: '/cookies_here' // Path property
|
||||
}
|
||||
```
|
||||
|
||||
For Documentation on the different Cookie Attributes see
|
||||
<https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#creating_cookies>
|
||||
|
||||
### Invalidating tokens after they are delivered to the client
|
||||
|
||||
```js
|
||||
const {blacklist} = require('@sapphirecode/auth-server-helper');
|
||||
|
||||
await blacklist.add_signature(token_id); // the token id is returned from any function that creates tokens
|
||||
```
|
||||
|
||||
#### Logout function
|
||||
|
||||
```js
|
||||
const {GatewayClass} = require('@sapphirecode/auth-server-helper');
|
||||
|
||||
const gateway = new GatewayClass({ /* options */ });
|
||||
|
||||
// create a new express route
|
||||
app.get('logout', (req, res) => {
|
||||
// call the gateway's logout function
|
||||
gateway.logout(req);
|
||||
|
||||
// respond ok
|
||||
res.status(200);
|
||||
res.end();
|
||||
});
|
||||
```
|
||||
|
||||
### Exporting and importing public keys to validate tokens across server instances
|
||||
|
||||
```js
|
||||
const {keystore} = require('@sapphirecode/auth-server-helper');
|
||||
|
||||
const export = keystore.export_verification_data();
|
||||
|
||||
// second instance
|
||||
|
||||
keystore.import_verification_data(export);
|
||||
```
|
||||
|
||||
These keys can also be live synchronized with redis to allow sessions to be
|
||||
shared between servers
|
||||
|
||||
```js
|
||||
const {keystore} = require('@sapphirecode/auth-server-helper');
|
||||
|
||||
keystore.sync_redis('redis://localhost');
|
||||
```
|
||||
|
||||
### Exporting and importing blacklist entries across server instances
|
||||
|
||||
```js
|
||||
const {blacklist} = require('@sapphirecode/auth-server-helper');
|
||||
|
||||
const export = blacklist.export_blacklist();
|
||||
|
||||
// second instance
|
||||
|
||||
blacklist.import_blacklist(export);
|
||||
```
|
||||
|
||||
### Clearing Keystore and Blacklist
|
||||
|
||||
Resetting the Keystore instance generates a new instance id and deletes all
|
||||
imported or generated keys.
|
||||
|
||||
```js
|
||||
const {keystore, blacklist} = require('@sapphirecode/auth-server-helper');
|
||||
|
||||
// clear keystore
|
||||
keystore.reset_instance();
|
||||
|
||||
// clear blacklist
|
||||
await blacklist.clear();
|
||||
|
||||
// clear blacklist items older than 10 seconds
|
||||
await blacklist.clear(Date.now() - 10000);
|
||||
```
|
||||
|
||||
### Setting and checking permissions
|
||||
|
||||
When allowing access to a client a list of permissions can be added. Permissions
|
||||
are case sensitive.
|
||||
|
||||
```js
|
||||
allow_access({permissions: ['foo','bar'], ...})
|
||||
```
|
||||
|
||||
The gateway can be told to check those permissions before forwarding a request.
|
||||
|
||||
```js
|
||||
const gateway = new GatewayClass({
|
||||
require_permissions: ['foo'], // Only clients with the 'foo' permission will be granted access
|
||||
});
|
||||
```
|
||||
|
||||
additional checks can be run later
|
||||
|
||||
```js
|
||||
(req, res) => {
|
||||
const has_both = gateway.check_permissions(req, ['foo', 'bar']); // returns true if both permissions are set
|
||||
const has_bar = gateway.has_permission(req, 'bar'); // returns true if permission 'bar' is set
|
||||
};
|
||||
```
|
||||
|
||||
### Reading connection info
|
||||
|
||||
Data like the used token id, custom data and permissions can be read from
|
||||
`req.connection.auth` or using the function `gateway.get_info(req)`
|
||||
|
||||
```js
|
||||
const info = gateway.get_info(req);
|
||||
|
||||
console.log(info);
|
||||
|
||||
/*
|
||||
{
|
||||
token_id: 'foo',
|
||||
data: {}, // custom data
|
||||
permissions: ['foo','bar']
|
||||
}
|
||||
*/
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT © Timo Hocker <timo@scode.ovh>
|
||||
|
200
index.js
200
index.js
@ -1,200 +0,0 @@
|
||||
/*
|
||||
* 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>, March 2020
|
||||
*/
|
||||
|
||||
// @ts-nocheck
|
||||
/* eslint-disable no-magic-numbers */
|
||||
|
||||
'use strict';
|
||||
|
||||
const password_helper = require ('@scode/password-helper');
|
||||
const crypto = require ('@scode/crypto-helper');
|
||||
const consts = require ('@scode/consts');
|
||||
|
||||
const me = {};
|
||||
|
||||
/**
|
||||
* initializes the module
|
||||
*
|
||||
* @param {Function<Promise|object>} get_user
|
||||
* function that returns {id:number, password:string, salt:string}
|
||||
* for a given user identifier
|
||||
* @param {Array<RegExp>} ignore_paths array of regex to skip auth
|
||||
* @returns {Function} request handler
|
||||
*/
|
||||
function init (get_user, ignore_paths = []) {
|
||||
me.get_user = get_user;
|
||||
me.session_timeout_milliseconds = 300000;
|
||||
me.ignore_paths = ignore_paths;
|
||||
me.jwt_secret = crypto.create_salt ();
|
||||
me.app_id = crypto.create_salt ();
|
||||
return request_handler;
|
||||
}
|
||||
|
||||
/**
|
||||
* tries to authenticate a user
|
||||
*
|
||||
* @param {string} user name or email of the given user
|
||||
* @param {string} password hashed password
|
||||
* @returns {Promise<string>} session key if successful
|
||||
*/
|
||||
async function authenticate (user, password) {
|
||||
const user_entry
|
||||
= await new Promise ((res) => res (me.get_user (user)));
|
||||
|
||||
if (!user_entry)
|
||||
return null;
|
||||
|
||||
if (!await password_helper.verify (user_entry.password, password))
|
||||
return null;
|
||||
|
||||
const session_key = crypto.sign_object (
|
||||
{ id: user_entry.id },
|
||||
me.jwt_secret
|
||||
);
|
||||
|
||||
return session_key;
|
||||
}
|
||||
|
||||
/**
|
||||
* gets the correct salt for a given user
|
||||
*
|
||||
* @param {string} user user name or email to query
|
||||
*/
|
||||
async function salt (user) {
|
||||
const user_entry
|
||||
= await new Promise ((res) => res (me.get_user (user)));
|
||||
if (!user_entry)
|
||||
return null;
|
||||
|
||||
return user_entry.salt;
|
||||
}
|
||||
|
||||
/**
|
||||
* block if no auth header found
|
||||
*
|
||||
* @param {string} session session key
|
||||
* @param {string} user user name
|
||||
* @param {any} res response object
|
||||
* @returns {boolean} true if handler blocked request
|
||||
*/
|
||||
function request_handler_block (session, user, res) {
|
||||
if (typeof session === 'undefined' && typeof user === 'undefined') {
|
||||
res.status (consts.http.status_unauthorized);
|
||||
res.end ();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* handle authentication
|
||||
*
|
||||
* @param {string} session session key
|
||||
* @param {string} user user name
|
||||
* @param {string} key user hash
|
||||
* @param {any} res response object
|
||||
* @param {any} next next handler
|
||||
* @returns {Promise<boolean>} true if handler authenticated
|
||||
*/
|
||||
async function request_handler_authenticate (session, user, key, res, next) {
|
||||
if (typeof session === 'undefined' && typeof user !== 'undefined') {
|
||||
if (typeof key === 'undefined') {
|
||||
const user_salt = await salt (user);
|
||||
res.status (
|
||||
user_salt === null
|
||||
? consts.http.status_forbidden
|
||||
: consts.http.status_ok
|
||||
);
|
||||
res.end (user_salt);
|
||||
return true;
|
||||
}
|
||||
|
||||
const session_key = await authenticate (user, key);
|
||||
|
||||
res.status (
|
||||
session_key === null
|
||||
? consts.http.status_forbidden
|
||||
: consts.http.status_ok
|
||||
)
|
||||
.cookie (me.app_id, session_key, { maxAge: 900000, httpOnly: true })
|
||||
.end (session_key);
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const jwt = crypto.verify_signature (
|
||||
session,
|
||||
me.jwt_secret,
|
||||
me.session_timeout_milliseconds
|
||||
);
|
||||
res.locals.user_id = jwt.id;
|
||||
const new_user_token = crypto.sign_object (
|
||||
{ id: jwt.id },
|
||||
me.jwt_secret
|
||||
);
|
||||
res.cookie (
|
||||
me.app_id,
|
||||
new_user_token,
|
||||
{ maxAge: 900000, httpOnly: true }
|
||||
)
|
||||
.header ('session', new_user_token);
|
||||
next ();
|
||||
return true;
|
||||
}
|
||||
catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* check if a filter matches a request
|
||||
*
|
||||
* @param {any} req request
|
||||
* @param {any} filter filter
|
||||
* @returns {boolean} true if filter matches
|
||||
*/
|
||||
function filter_matches (req, filter) {
|
||||
if (filter instanceof RegExp && filter.test (req.url))
|
||||
return true;
|
||||
return req.method === filter.method
|
||||
&& filter.regex
|
||||
&& filter.regex.test (req.url);
|
||||
}
|
||||
|
||||
/**
|
||||
* handles http requests
|
||||
*
|
||||
* @param {any} req request
|
||||
* @param {any} res response
|
||||
* @param {any} next next handler
|
||||
*/
|
||||
async function request_handler (req, res, next) {
|
||||
if (Array.isArray (me.ignore_paths)) {
|
||||
for (const ignore of me.ignore_paths) {
|
||||
if (filter_matches (req, ignore)) {
|
||||
next ();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { user, key, session: header_session } = req.headers;
|
||||
const cookie_session = typeof req.cookies === 'undefined'
|
||||
? null
|
||||
: req.cookies[me.app_id];
|
||||
|
||||
const session = cookie_session || header_session;
|
||||
|
||||
if (request_handler_block (session, user, res))
|
||||
return;
|
||||
if (await request_handler_authenticate (session, user, key, res, next))
|
||||
return;
|
||||
|
||||
res.status (consts.http.status_forbidden);
|
||||
res.end ();
|
||||
}
|
||||
|
||||
module.exports = init;
|
12
jasmine.json
Normal file
12
jasmine.json
Normal file
@ -0,0 +1,12 @@
|
||||
|
||||
{
|
||||
"spec_dir": "dist/test",
|
||||
"spec_files": [
|
||||
"spec/*.js"
|
||||
],
|
||||
"helpers": [
|
||||
"helpers/*.js"
|
||||
],
|
||||
"stopSpecOnExpectationFailure": false,
|
||||
"random": false
|
||||
}
|
27
jenkins.js
27
jenkins.js
@ -1,27 +0,0 @@
|
||||
/* eslint-disable no-process-exit */
|
||||
/* eslint-disable no-console */
|
||||
/* eslint-disable no-sync */
|
||||
'use strict';
|
||||
|
||||
const fs = require ('fs');
|
||||
const child_process = require ('child_process');
|
||||
|
||||
const pkg = JSON.parse (fs.readFileSync ('package.json', 'utf-8'));
|
||||
[
|
||||
,, pkg.version
|
||||
] = process.argv;
|
||||
fs.writeFileSync ('package.json', JSON.stringify (pkg, null, 2));
|
||||
|
||||
child_process.execSync ('yarn lint', { stdio: 'inherit' });
|
||||
child_process.execSync ('yarn test', { stdio: 'inherit' });
|
||||
child_process.execSync ('yarn compile', { stdio: 'inherit' });
|
||||
|
||||
child_process.exec ('git log -1 | grep \'\\[no publish\\]\'')
|
||||
.addListener ('exit', (code) => {
|
||||
if (code === 0) {
|
||||
console.log ('build not marked for deployment');
|
||||
process.exit (1);
|
||||
}
|
||||
else { child_process.execSync ('yarn publish --access public'); }
|
||||
});
|
||||
|
23
lib/.eslintrc.js
Normal file
23
lib/.eslintrc.js
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>, December 2020
|
||||
*/
|
||||
|
||||
/* eslint-disable */
|
||||
|
||||
module.exports = {
|
||||
env: {
|
||||
commonjs: true,
|
||||
es6: true,
|
||||
node: true
|
||||
},
|
||||
extends: [ '@sapphirecode/eslint-config-ts' ],
|
||||
globals: {
|
||||
Atomics: 'readonly',
|
||||
SharedArrayBuffer: 'readonly'
|
||||
},
|
||||
parserOptions: { ecmaVersion: 2018 },
|
||||
rules: { 'node/no-unpublished-import': 'off' }
|
||||
};
|
362
lib/AuthHandler.ts
Normal file
362
lib/AuthHandler.ts
Normal file
@ -0,0 +1,362 @@
|
||||
/*
|
||||
* Copyright (C) Sapphirecode - All Rights Reserved
|
||||
* This file is part of Auth-Server-Helper which is released under MIT.
|
||||
* See file 'LICENSE' for full license details.
|
||||
* Created by Timo Hocker <timo@scode.ovh>, January 2021
|
||||
*/
|
||||
|
||||
import { IncomingMessage, ServerResponse } from 'http';
|
||||
import { to_utf8 } from '@sapphirecode/encoding-helper';
|
||||
import auth from './Authority';
|
||||
import { debug } from './debug';
|
||||
import { build_cookie, CookieSettings } from './cookie';
|
||||
|
||||
const logger = debug ('auth');
|
||||
|
||||
interface AccessSettings {
|
||||
access_token_expires_in: number
|
||||
include_refresh_token?: boolean
|
||||
refresh_token_expires_in?: number
|
||||
redirect_to?: string
|
||||
data?: unknown,
|
||||
leave_open?: boolean
|
||||
permissions?: string[]
|
||||
}
|
||||
|
||||
interface AccessResult {
|
||||
access_token_id: string;
|
||||
refresh_token_id?: string;
|
||||
}
|
||||
|
||||
interface AccessResponse {
|
||||
token_type: string;
|
||||
access_token: string;
|
||||
expires_in: number;
|
||||
refresh_token?: string;
|
||||
refresh_expires_in?: number;
|
||||
}
|
||||
|
||||
type AuthHandler =
|
||||
(req: IncomingMessage, res: ServerResponse) => Promise<boolean>;
|
||||
|
||||
class AuthRequest {
|
||||
public request: IncomingMessage;
|
||||
public response: ServerResponse;
|
||||
|
||||
public is_basic: boolean;
|
||||
public user: string;
|
||||
public password: string;
|
||||
|
||||
public is_bearer: boolean;
|
||||
public token?: string;
|
||||
public token_data?: unknown;
|
||||
public token_id?: string;
|
||||
|
||||
public body: string;
|
||||
|
||||
private _cookie?: CookieSettings;
|
||||
private _refresh_cookie?: CookieSettings;
|
||||
private _is_successful: boolean;
|
||||
|
||||
public get is_successful (): boolean {
|
||||
return this._is_successful;
|
||||
}
|
||||
|
||||
public constructor (
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
body: string,
|
||||
cookie?: CookieSettings,
|
||||
refresh_cookie?: CookieSettings
|
||||
) {
|
||||
this.request = req;
|
||||
this.response = res;
|
||||
this.body = body;
|
||||
this.is_basic = false;
|
||||
this.is_bearer = false;
|
||||
this.user = '';
|
||||
this.password = '';
|
||||
this._cookie = cookie;
|
||||
this._refresh_cookie = refresh_cookie;
|
||||
this._is_successful = false;
|
||||
logger.extend ('constructor') ('started processing new auth request');
|
||||
}
|
||||
|
||||
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,
|
||||
redirect_to,
|
||||
data,
|
||||
leave_open,
|
||||
permissions
|
||||
}: AccessSettings): Promise<AccessResult> {
|
||||
const log = logger.extend ('allow_access');
|
||||
log ('allowed access');
|
||||
this.default_header (typeof redirect_to !== 'string' && !leave_open);
|
||||
|
||||
const at = await auth.sign (
|
||||
'access_token',
|
||||
access_token_expires_in,
|
||||
{ data, permissions }
|
||||
);
|
||||
const result: AccessResult = { access_token_id: at.id };
|
||||
|
||||
const res: AccessResponse = {
|
||||
token_type: 'bearer',
|
||||
access_token: at.signature,
|
||||
expires_in: access_token_expires_in
|
||||
};
|
||||
|
||||
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 (
|
||||
'refresh_token',
|
||||
refresh_token_expires_in,
|
||||
{ data }
|
||||
);
|
||||
res.refresh_token = rt.signature;
|
||||
res.refresh_expires_in = refresh_token_expires_in;
|
||||
result.refresh_token_id = rt.id;
|
||||
|
||||
if (typeof this._refresh_cookie !== 'undefined')
|
||||
cookies.push (build_cookie (this._refresh_cookie, rt.signature));
|
||||
}
|
||||
|
||||
if (cookies.length > 0) {
|
||||
log ('sending %d cookies', cookies.length);
|
||||
this.response.setHeader (
|
||||
'Set-Cookie',
|
||||
cookies
|
||||
);
|
||||
}
|
||||
|
||||
this._is_successful = true;
|
||||
|
||||
if (typeof redirect_to === 'string') {
|
||||
log ('redirecting to %s', redirect_to);
|
||||
this.response.setHeader ('Location', redirect_to);
|
||||
this.response.statusCode = 302;
|
||||
if (!leave_open)
|
||||
this.response.end ();
|
||||
return result;
|
||||
}
|
||||
|
||||
if (!leave_open) {
|
||||
log ('finishing http request');
|
||||
this.response.writeHead (200);
|
||||
this.response.end (JSON.stringify (res));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async allow_part (
|
||||
part_token_expires_in: number,
|
||||
next_module: string,
|
||||
data?: Record<string, unknown>,
|
||||
leave_open = false
|
||||
): Promise<string> {
|
||||
const log = logger.extend ('allow_part');
|
||||
log ('allowed part token');
|
||||
this.default_header ();
|
||||
|
||||
const pt = await auth.sign (
|
||||
'part_token',
|
||||
part_token_expires_in,
|
||||
{ next_module, data }
|
||||
);
|
||||
|
||||
const res = {
|
||||
token_type: 'bearer',
|
||||
part_token: pt.signature,
|
||||
expires_in: part_token_expires_in
|
||||
};
|
||||
|
||||
if (!leave_open) {
|
||||
log ('finishing http request');
|
||||
this.response.writeHead (200);
|
||||
this.response.end (JSON.stringify (res));
|
||||
}
|
||||
|
||||
this._is_successful = true;
|
||||
return pt.id;
|
||||
}
|
||||
|
||||
public invalid (error_description?: string, leave_open = false): void {
|
||||
const log = logger.extend ('invalid');
|
||||
log ('rejecting invalid request');
|
||||
this.default_header ();
|
||||
this.response.statusCode = 400;
|
||||
if (!leave_open) {
|
||||
log ('finishing http request');
|
||||
this.response.end (JSON.stringify ({
|
||||
error: 'invalid_request',
|
||||
error_description
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
public deny (leave_open = false): void {
|
||||
const log = logger.extend ('deny');
|
||||
log ('denied access');
|
||||
this.default_header ();
|
||||
this.response.statusCode = 401;
|
||||
if (!leave_open) {
|
||||
log ('finishing http request');
|
||||
this.response.end (JSON.stringify ({ error: 'invalid_client' }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type AuthRequestHandler = (req: AuthRequest) => Promise<void> | void;
|
||||
|
||||
interface CreateHandlerOptions {
|
||||
refresh?: AccessSettings;
|
||||
modules?: Record<string, AuthRequestHandler>;
|
||||
cookie?: CookieSettings;
|
||||
refresh_cookie?: CookieSettings;
|
||||
parse_body?: boolean;
|
||||
}
|
||||
|
||||
type ProcessRequestOptions = Omit<CreateHandlerOptions, 'parse_body'>
|
||||
|
||||
// eslint-disable-next-line max-lines-per-function, max-statements
|
||||
async function process_request (
|
||||
request: AuthRequest,
|
||||
token: RegExpExecArray | null,
|
||||
default_handler: AuthRequestHandler,
|
||||
options?: ProcessRequestOptions
|
||||
): Promise<void> {
|
||||
const log = logger.extend ('process_request');
|
||||
if (token === null)
|
||||
return default_handler (request);
|
||||
|
||||
if ((/Basic/ui).test (token?.groups?.type as string)) {
|
||||
log ('found basic login data');
|
||||
request.is_basic = true;
|
||||
|
||||
let login = token?.groups?.token as string;
|
||||
if (!login.includes (':'))
|
||||
login = to_utf8 (login, 'base64');
|
||||
const login_data = login.split (':');
|
||||
request.user = login_data[0];
|
||||
request.password = login_data[1];
|
||||
|
||||
return default_handler (request);
|
||||
}
|
||||
|
||||
if ((/Bearer/ui).test (token?.groups?.type as string)) {
|
||||
log ('found bearer login data');
|
||||
request.is_bearer = true;
|
||||
request.token = token?.groups?.token;
|
||||
|
||||
const token_data = await auth.verify (request.token as string);
|
||||
|
||||
if (!token_data.valid)
|
||||
return default_handler (request);
|
||||
|
||||
log ('bearer token is valid');
|
||||
|
||||
request.token_data = token_data.data;
|
||||
request.token_id = token_data.id;
|
||||
|
||||
if (
|
||||
typeof options !== 'undefined'
|
||||
&& typeof options.refresh !== 'undefined'
|
||||
&& token_data.type === 'refresh_token'
|
||||
) {
|
||||
log ('found refresh token, emitting new access token');
|
||||
request.allow_access (options.refresh);
|
||||
return Promise.resolve ();
|
||||
}
|
||||
|
||||
if (
|
||||
typeof options !== 'undefined'
|
||||
&& typeof options.modules !== 'undefined'
|
||||
&& token_data.type === 'part_token'
|
||||
&& typeof token_data.next_module !== 'undefined'
|
||||
&& Object.keys (options.modules)
|
||||
.includes (token_data.next_module)
|
||||
) {
|
||||
log ('processing module %s', token_data.next_module);
|
||||
return options.modules[token_data.next_module] (request);
|
||||
}
|
||||
|
||||
request.invalid ('invalid bearer type');
|
||||
return Promise.resolve ();
|
||||
}
|
||||
|
||||
log ('no matching login method, triggering default handler');
|
||||
return default_handler (request);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line max-lines-per-function
|
||||
export default function create_auth_handler (
|
||||
default_handler: AuthRequestHandler,
|
||||
options?: CreateHandlerOptions
|
||||
): AuthHandler {
|
||||
logger.extend ('create_auth_handler') ('creating new auth handler');
|
||||
if (
|
||||
typeof options?.cookie !== 'undefined'
|
||||
&& typeof options?.refresh_cookie !== 'undefined'
|
||||
&& options.cookie.name === options.refresh_cookie.name
|
||||
)
|
||||
throw new Error ('access and refresh cookies cannot have the same name');
|
||||
|
||||
return async (
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse
|
||||
): Promise<boolean> => {
|
||||
const body: string = options?.parse_body
|
||||
? await new Promise ((resolve) => {
|
||||
let data = '';
|
||||
req.on ('data', (c) => {
|
||||
data += c;
|
||||
});
|
||||
req.on ('end', () => {
|
||||
resolve (data);
|
||||
});
|
||||
})
|
||||
: '';
|
||||
|
||||
const request = new AuthRequest (
|
||||
req,
|
||||
res,
|
||||
body,
|
||||
options?.cookie,
|
||||
options?.refresh_cookie
|
||||
);
|
||||
const token = (/(?<type>\S+) (?<token>.+)/ui)
|
||||
.exec (req.headers.authorization as string);
|
||||
|
||||
await process_request (request, token, default_handler, options);
|
||||
|
||||
return request.is_successful;
|
||||
};
|
||||
}
|
||||
|
||||
export {
|
||||
AccessSettings,
|
||||
AccessResult,
|
||||
AccessResponse,
|
||||
AuthRequest,
|
||||
AuthRequestHandler,
|
||||
CreateHandlerOptions,
|
||||
AuthHandler
|
||||
};
|
138
lib/Authority.ts
Normal file
138
lib/Authority.ts
Normal file
@ -0,0 +1,138 @@
|
||||
/*
|
||||
* 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>, December 2020
|
||||
*/
|
||||
|
||||
import {
|
||||
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' | 'none' | 'part_token' | 'refresh_token'
|
||||
|
||||
interface VerificationResult {
|
||||
authorized: boolean;
|
||||
valid: boolean;
|
||||
type: TokenType;
|
||||
id: string;
|
||||
next_module?: string;
|
||||
permissions?: string[];
|
||||
data?: unknown;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface SignatureResult {
|
||||
signature: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface SignatureOptions
|
||||
{
|
||||
data?: unknown
|
||||
next_module?: string,
|
||||
permissions?: string[]
|
||||
}
|
||||
|
||||
class Authority {
|
||||
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 = await verify_signature_get_info (
|
||||
key,
|
||||
async (info) => {
|
||||
try {
|
||||
return await keystore.get_key (info.iat / 1000, info.iss);
|
||||
}
|
||||
catch {
|
||||
return '';
|
||||
}
|
||||
},
|
||||
(info) => info.valid_for * 1000
|
||||
);
|
||||
|
||||
if (data === null) {
|
||||
log ('token invalid');
|
||||
result.error = 'invalid signature';
|
||||
return result;
|
||||
}
|
||||
|
||||
result.id = data.id;
|
||||
result.type = data.type;
|
||||
|
||||
log ('parsing token %s %s', result.type, result.id);
|
||||
|
||||
if (!(await blacklist.is_valid (data.id))) {
|
||||
log ('token is blacklisted');
|
||||
result.error = 'blacklisted';
|
||||
return result;
|
||||
}
|
||||
|
||||
result.valid = true;
|
||||
result.authorized = result.type === 'access_token';
|
||||
result.next_module = data.next_module;
|
||||
result.permissions = data.permissions;
|
||||
result.data = data.obj;
|
||||
|
||||
log (
|
||||
'valid %s; targeting module %s',
|
||||
result.type,
|
||||
result.next_module
|
||||
);
|
||||
log ('permissions %o', result.permissions);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async sign (
|
||||
type: TokenType,
|
||||
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: generate_token_id (new Date (valid_until)),
|
||||
iat: time,
|
||||
iss: keystore.instance_id,
|
||||
type,
|
||||
valid_for,
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
const auth = (new Authority);
|
||||
|
||||
export {
|
||||
TokenType,
|
||||
VerificationResult,
|
||||
SignatureResult,
|
||||
SignatureOptions,
|
||||
Authority
|
||||
};
|
||||
|
||||
export default auth;
|
150
lib/Blacklist.ts
Normal file
150
lib/Blacklist.ts
Normal file
@ -0,0 +1,150 @@
|
||||
/*
|
||||
* 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>, 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 {
|
||||
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 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 < 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
|
||||
);
|
||||
|
||||
key = this._signatures[signature].token_id;
|
||||
this._signatures.splice (signature, 1);
|
||||
}
|
||||
await redis_blacklist_store.remove (key);
|
||||
}
|
||||
|
||||
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.token_id === hash) {
|
||||
log ('found matching blacklist entry');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
log ('signature is not blacklisted locally, checking redis');
|
||||
if (await redis_blacklist_store.get (hash)) {
|
||||
log ('signature is blacklisted in redis');
|
||||
return false;
|
||||
}
|
||||
|
||||
log ('signature is not blacklisted');
|
||||
return true;
|
||||
}
|
||||
|
||||
public export_blacklist (): ExportedSignature[] {
|
||||
logger.extend ('export_blacklist') ('exporting blacklist');
|
||||
return this._signatures.map ((v) => ({
|
||||
iat: v.iat,
|
||||
token_id: v.token_id
|
||||
}));
|
||||
}
|
||||
|
||||
public import_blacklist (data: ExportedSignature[]): void {
|
||||
logger.extend ('import_blacklist') (
|
||||
'importing %d blacklist entries',
|
||||
data.length
|
||||
);
|
||||
for (const token of data) {
|
||||
const parsed = parse_token_id (token.token_id);
|
||||
this._signatures.push ({
|
||||
token_id: token.token_id,
|
||||
iat: token.iat,
|
||||
valid_until: parsed.valid_until
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public sync_redis (url: string): void {
|
||||
redis_blacklist_store.connect (url);
|
||||
}
|
||||
|
||||
private async garbage_collect (): Promise<void> {
|
||||
const log = logger.extend ('garbage_collect');
|
||||
const time = new Date;
|
||||
log ('removing signatures expired before', time);
|
||||
for (let i = this._signatures.length - 1; i >= 0; i--) {
|
||||
if (this._signatures[i].valid_until < time) {
|
||||
log ('signature %s expired', this._signatures[i].token_id);
|
||||
await this.remove_signature (i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const bl = (new Blacklist);
|
||||
|
||||
export { Blacklist };
|
||||
export default bl;
|
270
lib/Gateway.ts
Normal file
270
lib/Gateway.ts
Normal file
@ -0,0 +1,270 @@
|
||||
/*
|
||||
* 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>, December 2020
|
||||
*/
|
||||
|
||||
import { IncomingMessage, ServerResponse } from 'http';
|
||||
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
|
||||
) => unknown;
|
||||
|
||||
interface RefreshSettings extends AccessSettings {
|
||||
leave_open?: never;
|
||||
redirect_to?: never;
|
||||
data?: never;
|
||||
}
|
||||
|
||||
interface GatewayOptions {
|
||||
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 = {}) {
|
||||
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;
|
||||
}
|
||||
|
||||
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 ();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 = extract_cookie (this._options.cookie?.name, req.headers.cookie);
|
||||
if (auth === null) {
|
||||
log ('found no auth token');
|
||||
return false;
|
||||
}
|
||||
|
||||
const ver = await authority.verify (auth);
|
||||
|
||||
log ('setting connection info');
|
||||
const con = req.connection as unknown as Record<string, unknown>;
|
||||
con.auth = {
|
||||
token_id: ver.id,
|
||||
token_data: ver.data,
|
||||
permissions: ver.permissions
|
||||
};
|
||||
|
||||
log ('token valid: %s', ver.authorized);
|
||||
return ver.authorized;
|
||||
}
|
||||
|
||||
public 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
|
||||
): Promise<unknown> {
|
||||
const log = logger.extend ('process_request');
|
||||
log ('processing incoming http request');
|
||||
if (await this.authenticate (req, res)) {
|
||||
log ('authentification successful');
|
||||
log ('checking permissions');
|
||||
|
||||
if (!this.check_permissions (req))
|
||||
return this.redirect (res);
|
||||
|
||||
log ('authorization successful. calling next handler');
|
||||
return next ();
|
||||
}
|
||||
|
||||
log ('failed to authenticate, redirecting client');
|
||||
return this.redirect (res);
|
||||
}
|
||||
|
||||
public async logout (req: IncomingMessage): Promise<void> {
|
||||
const log = logger.extend ('logout');
|
||||
log ('invalidating all submitted tokens');
|
||||
const auth_strings = [
|
||||
this.get_header_auth (req),
|
||||
extract_cookie (this._options.cookie?.name, req.headers.cookie),
|
||||
extract_cookie (this._options.refresh_cookie?.name, req.headers.cookie)
|
||||
];
|
||||
const tokens = (
|
||||
await Promise.all (
|
||||
auth_strings
|
||||
.filter ((v) => v !== null)
|
||||
.map ((v) => authority.verify (v as string))
|
||||
)
|
||||
).filter ((v) => v.valid);
|
||||
|
||||
log ('found %d tokens: %O', tokens.length, tokens);
|
||||
|
||||
for (const token of tokens) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await blacklist.add_signature (token.id);
|
||||
}
|
||||
|
||||
log ('complete');
|
||||
}
|
||||
|
||||
public get_info (req: IncomingMessage): ConnectionInfo {
|
||||
const conn = req.connection as unknown as Record<string, unknown>;
|
||||
const auth = conn.auth as Record<string, unknown>;
|
||||
return {
|
||||
token_id: auth.token_id as string,
|
||||
token_data: auth.token_data,
|
||||
permissions: (auth.permissions as string[]) || []
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default function create_gateway (options: GatewayOptions): Gateway {
|
||||
const g = new GatewayClass (options);
|
||||
return g.process_request.bind (g);
|
||||
}
|
||||
|
||||
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[];
|
192
lib/KeyStore.ts
Normal file
192
lib/KeyStore.ts
Normal file
@ -0,0 +1,192 @@
|
||||
/*
|
||||
* 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>, December 2020
|
||||
*/
|
||||
|
||||
import { generate_keypair, random_hex } from '@sapphirecode/crypto-helper';
|
||||
import { to_b58 } from '@sapphirecode/encoding-helper';
|
||||
import { debug } from './debug';
|
||||
import { KeyStoreData, KeyStoreExport } from './Key';
|
||||
import { redis_key_store } from './RedisData/RedisKeyStore';
|
||||
|
||||
const logger = debug ('keystore');
|
||||
|
||||
const renew_interval = 3600;
|
||||
|
||||
class KeyStore {
|
||||
private _keys: KeyStoreData = {};
|
||||
private _interval: NodeJS.Timeout;
|
||||
private _instance: string;
|
||||
|
||||
public get instance_id (): string {
|
||||
return this._instance;
|
||||
}
|
||||
|
||||
public constructor () {
|
||||
this._interval = setInterval (() => {
|
||||
this.garbage_collect ();
|
||||
}, renew_interval * 1000);
|
||||
this._instance = to_b58 (random_hex (16), 'hex');
|
||||
logger.extend ('constructor') (
|
||||
'created keystore instance %s',
|
||||
this._instance
|
||||
);
|
||||
}
|
||||
|
||||
private get_index (iat: number, instance = this._instance): string {
|
||||
return instance + Math.floor (iat / renew_interval)
|
||||
.toFixed (0);
|
||||
}
|
||||
|
||||
private async create_key (index: string, valid_for: number): Promise<void> {
|
||||
const log = logger.extend ('create_key');
|
||||
log ('generating new key');
|
||||
const time = (new Date)
|
||||
.getTime ();
|
||||
const pair = await generate_keypair ();
|
||||
const result = {
|
||||
private_key: {
|
||||
key: pair.private_key,
|
||||
valid_until: time + (renew_interval * 1000)
|
||||
},
|
||||
public_key: {
|
||||
key: pair.public_key,
|
||||
valid_until: time + (valid_for * 1000)
|
||||
}
|
||||
};
|
||||
await redis_key_store.set ({ ...result.public_key, index });
|
||||
this._keys[index] = result;
|
||||
}
|
||||
|
||||
private garbage_collect (): void {
|
||||
const log = logger.extend ('garbage_collect');
|
||||
const time = (new Date)
|
||||
.getTime ();
|
||||
const keys = Object.keys (this._keys);
|
||||
for (const index of keys) {
|
||||
const entry = this._keys[index];
|
||||
if (typeof entry.private_key !== 'undefined'
|
||||
&& entry.private_key.valid_until < time
|
||||
) {
|
||||
log ('deleting expired private key');
|
||||
delete entry.private_key;
|
||||
}
|
||||
|
||||
if (entry.public_key.valid_until < time) {
|
||||
log ('deleting expired key pair');
|
||||
delete this._keys[index];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async get_sign_key (
|
||||
iat: number,
|
||||
valid_for: number,
|
||||
instance?: string
|
||||
): Promise<string> {
|
||||
const log = logger.extend ('get_sign_key');
|
||||
log (
|
||||
'querying key from %s for timestamp %d, valid for %d',
|
||||
instance,
|
||||
iat,
|
||||
valid_for
|
||||
);
|
||||
if (valid_for <= 0)
|
||||
throw new Error ('cannot create infinitely valid key');
|
||||
|
||||
if ((iat + 1) * 1000 < (new Date)
|
||||
.getTime ())
|
||||
throw new Error ('cannot access already expired keys');
|
||||
|
||||
const index = this.get_index (iat, instance);
|
||||
|
||||
const valid_until = (new Date)
|
||||
.getTime () + (valid_for * 1000);
|
||||
|
||||
if (typeof this._keys[index] !== 'undefined') {
|
||||
log ('loading existing key');
|
||||
const key = this._keys[index];
|
||||
|
||||
if (typeof key.private_key === 'undefined')
|
||||
throw new Error ('cannot access already expired keys');
|
||||
|
||||
if (key.public_key.valid_until < valid_until) {
|
||||
log ('updating key valid timespan to match new value');
|
||||
key.public_key.valid_until = valid_until;
|
||||
}
|
||||
|
||||
return key.private_key?.key as string;
|
||||
}
|
||||
|
||||
log ('key does not exist, creating a new one');
|
||||
await this.create_key (index, valid_for);
|
||||
return this._keys[index].private_key?.key as string;
|
||||
}
|
||||
|
||||
public async get_key (iat: number, instance?: string): Promise<string> {
|
||||
const log = logger.extend ('get_key');
|
||||
log ('querying public key from %s for timestamp %d', instance, iat);
|
||||
const index = this.get_index (iat, instance);
|
||||
|
||||
let key = null;
|
||||
|
||||
if (typeof this._keys[index] === 'undefined')
|
||||
key = await redis_key_store.get (index);
|
||||
|
||||
else
|
||||
key = this._keys[index].public_key;
|
||||
|
||||
if (key === null)
|
||||
throw new Error ('key could not be found');
|
||||
|
||||
return key.key;
|
||||
}
|
||||
|
||||
public export_verification_data (): KeyStoreExport {
|
||||
const log = logger.extend ('export_verification_data');
|
||||
log ('exporting public keys');
|
||||
log ('cleaning up before export');
|
||||
this.garbage_collect ();
|
||||
const out: KeyStoreExport = [];
|
||||
for (const index of Object.keys (this._keys)) {
|
||||
log ('exporting key %s', index);
|
||||
out.push ({ ...this._keys[index].public_key, index });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public import_verification_data (data: KeyStoreExport): void {
|
||||
const log = logger.extend ('import_verification_data');
|
||||
log ('importing %d public keys', data.length);
|
||||
for (const key of data) {
|
||||
log ('importing key %s', key.index);
|
||||
if (typeof this._keys[key.index] !== 'undefined')
|
||||
throw new Error ('cannot import to the same instance');
|
||||
this._keys[key.index] = {
|
||||
public_key: {
|
||||
key: key.key,
|
||||
valid_until: key.valid_until
|
||||
}
|
||||
};
|
||||
}
|
||||
log ('running garbage collector');
|
||||
this.garbage_collect ();
|
||||
}
|
||||
|
||||
public reset_instance (): void {
|
||||
logger.extend ('reset_instance') ('resetting keystore');
|
||||
this._instance = to_b58 (random_hex (16), 'hex');
|
||||
this._keys = {};
|
||||
redis_key_store.disconnect ();
|
||||
}
|
||||
|
||||
public sync_redis (url: string): void {
|
||||
redis_key_store.connect (url);
|
||||
}
|
||||
}
|
||||
|
||||
const ks: KeyStore = (new KeyStore);
|
||||
export default ks;
|
||||
export { KeyStore };
|
64
lib/Redis.ts
Normal file
64
lib/Redis.ts
Normal file
@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Copyright (C) Sapphirecode - All Rights Reserved
|
||||
* This file is part of Auth-Server-Helper which is released under MIT.
|
||||
* See file 'LICENSE' for full license details.
|
||||
* Created by Timo Hocker <timo@scode.ovh>, August 2022
|
||||
*/
|
||||
|
||||
import IORedis from 'ioredis';
|
||||
import { debug } from './debug';
|
||||
|
||||
const logger = debug ('redis');
|
||||
|
||||
export class Redis {
|
||||
private _redis: IORedis | null = null;
|
||||
|
||||
public connect (url: string): void {
|
||||
const log = logger.extend ('connect');
|
||||
log ('connecting to redis instance %s', url);
|
||||
if (this._redis !== null) {
|
||||
log ('disconnecting existing redis client');
|
||||
this.disconnect ();
|
||||
}
|
||||
|
||||
this._redis = new IORedis (url);
|
||||
this._redis.on ('connect', () => {
|
||||
log ('connected');
|
||||
});
|
||||
this._redis.on ('ready', () => {
|
||||
log ('ready');
|
||||
});
|
||||
this._redis.on ('error', (err) => {
|
||||
log ('error %o', err);
|
||||
});
|
||||
this._redis.on ('reconnecting', () => {
|
||||
log ('reconnecting');
|
||||
});
|
||||
this._redis.on ('end', () => {
|
||||
log ('connection ended');
|
||||
});
|
||||
}
|
||||
|
||||
public disconnect (): void {
|
||||
const log = logger.extend ('disconnect');
|
||||
log ('disconnecting redis client');
|
||||
if (this._redis === null) {
|
||||
log ('redis is inactive, skipping');
|
||||
return;
|
||||
}
|
||||
this._redis.quit ();
|
||||
this._redis = null;
|
||||
log ('done');
|
||||
}
|
||||
|
||||
protected get redis (): IORedis {
|
||||
if (this._redis === null)
|
||||
throw new Error ('redis is not connected');
|
||||
|
||||
return this._redis;
|
||||
}
|
||||
|
||||
protected get is_active (): boolean {
|
||||
return this._redis !== null;
|
||||
}
|
||||
}
|
53
lib/RedisData/RedisBlacklistStore.ts
Normal file
53
lib/RedisData/RedisBlacklistStore.ts
Normal file
@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright (C) Sapphirecode - All Rights Reserved
|
||||
* This file is part of Auth-Server-Helper which is released under MIT.
|
||||
* See file 'LICENSE' for full license details.
|
||||
* Created by Timo Hocker <timo@scode.ovh>, August 2022
|
||||
*/
|
||||
|
||||
import { debug } from '../debug';
|
||||
import { Redis } from '../Redis';
|
||||
|
||||
const logger = debug ('RedisBlacklistStore');
|
||||
|
||||
export class RedisBlacklistStore extends Redis {
|
||||
public async add (key: string, valid_until: Date): Promise<void> {
|
||||
const log = logger.extend ('set');
|
||||
log ('trying to add key %s to redis blacklist', key);
|
||||
if (!this.is_active) {
|
||||
log ('redis is inactive, skipping');
|
||||
return;
|
||||
}
|
||||
await this.redis.setex (
|
||||
`blacklist_${key}`,
|
||||
Math.floor ((valid_until.getTime () - Date.now ()) / 1000),
|
||||
1
|
||||
);
|
||||
log ('saved key');
|
||||
}
|
||||
|
||||
public async remove (key: string): Promise<void> {
|
||||
const log = logger.extend ('remove');
|
||||
log ('removing key %s from redis', key);
|
||||
if (!this.is_active) {
|
||||
log ('redis is inactive, skipping');
|
||||
return;
|
||||
}
|
||||
await this.redis.del (`blacklist_${key}`);
|
||||
log ('removed key');
|
||||
}
|
||||
|
||||
public async get (key: string): Promise<boolean> {
|
||||
const log = logger.extend ('get');
|
||||
log ('trying to find key %s in redis blacklist', key);
|
||||
if (!this.is_active) {
|
||||
log ('redis is inactive, skipping');
|
||||
return false;
|
||||
}
|
||||
const res = await this.redis.exists (`blacklist_${key}`) === 1;
|
||||
log ('found key %s', res);
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
export const redis_blacklist_store = new RedisBlacklistStore;
|
52
lib/RedisData/RedisKeyStore.ts
Normal file
52
lib/RedisData/RedisKeyStore.ts
Normal file
@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Copyright (C) Sapphirecode - All Rights Reserved
|
||||
* This file is part of Auth-Server-Helper which is released under MIT.
|
||||
* See file 'LICENSE' for full license details.
|
||||
* Created by Timo Hocker <timo@scode.ovh>, August 2022
|
||||
*/
|
||||
|
||||
import { debug } from '../debug';
|
||||
import { LabelledKey } from '../Key';
|
||||
import { Redis } from '../Redis';
|
||||
|
||||
const logger = debug ('RedisKeyStore');
|
||||
|
||||
export class RedisKeyStore extends Redis {
|
||||
public async set (value: LabelledKey): Promise<void> {
|
||||
const log = logger.extend ('set');
|
||||
log ('trying to set key %s to redis', value.index);
|
||||
if (!this.is_active) {
|
||||
log ('redis is inactive, skipping');
|
||||
return;
|
||||
}
|
||||
const valid_for = Math.floor (
|
||||
(value.valid_until - (new Date)
|
||||
.getTime ()) / 1000
|
||||
);
|
||||
log ('key is valid for %d seconds', valid_for);
|
||||
await this.redis.setex (
|
||||
`keystore_${value.index}`,
|
||||
valid_for,
|
||||
JSON.stringify (value)
|
||||
);
|
||||
log ('saved key');
|
||||
}
|
||||
|
||||
public async get (index: string): Promise<LabelledKey | null> {
|
||||
const log = logger.extend ('get');
|
||||
log ('trying to get key %s from redis', index);
|
||||
if (!this.is_active) {
|
||||
log ('redis is inactive, skipping');
|
||||
return null;
|
||||
}
|
||||
const res = await this.redis.get (`keystore_${index}`);
|
||||
if (res === null) {
|
||||
log ('key not found in redis');
|
||||
return null;
|
||||
}
|
||||
log ('key found');
|
||||
return JSON.parse (res);
|
||||
}
|
||||
}
|
||||
|
||||
export const redis_key_store = new RedisKeyStore;
|
86
lib/cookie.ts
Normal file
86
lib/cookie.ts
Normal file
@ -0,0 +1,86 @@
|
||||
/*
|
||||
* Copyright (C) Sapphirecode - All Rights Reserved
|
||||
* This file is part of Auth-Server-Helper which is released under MIT.
|
||||
* See file 'LICENSE' for full license details.
|
||||
* Created by Timo Hocker <timo@scode.ovh>, August 2022
|
||||
*/
|
||||
|
||||
import { run_regex } from '@sapphirecode/utilities';
|
||||
import { debug } from './debug';
|
||||
|
||||
const logger = debug ('cookies');
|
||||
|
||||
type SameSiteValue = 'Lax' | 'None' | 'Strict';
|
||||
|
||||
interface CookieSettings {
|
||||
name: string;
|
||||
secure?: boolean;
|
||||
http_only?: boolean;
|
||||
same_site?: SameSiteValue|null;
|
||||
expires?: string;
|
||||
max_age?: number;
|
||||
domain?: string;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
const default_settings: Omit<CookieSettings, 'name'> = {
|
||||
secure: true,
|
||||
http_only: true,
|
||||
same_site: 'Strict'
|
||||
};
|
||||
|
||||
function build_cookie (
|
||||
settings: CookieSettings,
|
||||
value: string
|
||||
): string {
|
||||
const local_settings = { ...default_settings, ...settings };
|
||||
const sections = [ `${local_settings.name}=${value}` ];
|
||||
|
||||
if (local_settings.secure)
|
||||
sections.push ('Secure');
|
||||
if (local_settings.http_only)
|
||||
sections.push ('HttpOnly');
|
||||
if (
|
||||
typeof local_settings.same_site !== 'undefined'
|
||||
&& local_settings.same_site !== null
|
||||
)
|
||||
sections.push (`SameSite=${local_settings.same_site}`);
|
||||
if (typeof local_settings.expires !== 'undefined')
|
||||
sections.push (`Expires=${local_settings.expires}`);
|
||||
if (typeof local_settings.max_age !== 'undefined')
|
||||
sections.push (`Max-Age=${local_settings.max_age}`);
|
||||
if (typeof local_settings.domain !== 'undefined')
|
||||
sections.push (`Domain=${local_settings.domain}`);
|
||||
if (typeof local_settings.path !== 'undefined')
|
||||
sections.push (`Path=${local_settings.path}`);
|
||||
|
||||
return sections.join ('; ');
|
||||
}
|
||||
|
||||
function extract_cookie (
|
||||
name: string|undefined,
|
||||
header: string|undefined
|
||||
): string| null {
|
||||
const log = logger.extend ('extract_cookie');
|
||||
log (`extracting cookie ${name}`);
|
||||
|
||||
const cookie_regex = /(?:^|;)\s*(?<name>[^;=]+)=(?<value>[^;]+)/gu;
|
||||
|
||||
let result = null;
|
||||
|
||||
run_regex (
|
||||
cookie_regex,
|
||||
header,
|
||||
(res: RegExpMatchArray) => {
|
||||
log ('parsing cookie %s', res.groups?.name);
|
||||
if (res.groups?.name === name) {
|
||||
log ('found cookie');
|
||||
result = res.groups?.value as string;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export { build_cookie, extract_cookie, SameSiteValue, CookieSettings };
|
15
lib/debug.ts
Normal file
15
lib/debug.ts
Normal file
@ -0,0 +1,15 @@
|
||||
/*
|
||||
* Copyright (C) Sapphirecode - All Rights Reserved
|
||||
* This file is part of Auth-Server-Helper which is released under MIT.
|
||||
* See file 'LICENSE' for full license details.
|
||||
* Created by Timo Hocker <timo@scode.ovh>, August 2022
|
||||
*/
|
||||
|
||||
import build_debug from 'debug';
|
||||
|
||||
function debug (scope: string): build_debug.Debugger {
|
||||
const namespace = `sapphirecode:auth-server-helper:${scope}`;
|
||||
return build_debug (namespace);
|
||||
}
|
||||
|
||||
export { debug };
|
74
lib/index.ts
Normal file
74
lib/index.ts
Normal file
@ -0,0 +1,74 @@
|
||||
/*
|
||||
* Copyright (C) Sapphirecode - All Rights Reserved
|
||||
* This file is part of Auth-Server-Helper which is released under MIT.
|
||||
* See file 'LICENSE' for full license details.
|
||||
* Created by Timo Hocker <timo@scode.ovh>, January 2021
|
||||
*/
|
||||
|
||||
/* eslint-disable import/no-namespace */
|
||||
import create_auth_handler, {
|
||||
AccessResponse,
|
||||
CreateHandlerOptions,
|
||||
AuthRequestHandler,
|
||||
AuthRequest,
|
||||
AccessSettings,
|
||||
AccessResult,
|
||||
AuthHandler
|
||||
} from './AuthHandler';
|
||||
import authority, {
|
||||
VerificationResult,
|
||||
TokenType,
|
||||
SignatureResult,
|
||||
SignatureOptions,
|
||||
Authority
|
||||
} from './Authority';
|
||||
import blacklist, { Blacklist } from './Blacklist';
|
||||
import create_gateway, {
|
||||
GatewayOptions,
|
||||
GatewayClass,
|
||||
Gateway,
|
||||
AnyFunc,
|
||||
RefreshSettings
|
||||
} from './Gateway';
|
||||
import keystore, { KeyStore } from './KeyStore';
|
||||
import {
|
||||
KeyStoreExport,
|
||||
LabelledKey, Key
|
||||
} from './Key';
|
||||
import {
|
||||
CookieSettings,
|
||||
SameSiteValue
|
||||
} from './cookie';
|
||||
|
||||
export {
|
||||
create_gateway,
|
||||
create_auth_handler,
|
||||
blacklist,
|
||||
authority,
|
||||
keystore,
|
||||
|
||||
AccessResponse,
|
||||
CreateHandlerOptions,
|
||||
AuthRequestHandler,
|
||||
AuthRequest,
|
||||
AuthHandler,
|
||||
AccessSettings,
|
||||
AccessResult,
|
||||
VerificationResult,
|
||||
TokenType,
|
||||
SignatureResult,
|
||||
SignatureOptions,
|
||||
Authority,
|
||||
Blacklist,
|
||||
GatewayOptions,
|
||||
GatewayClass,
|
||||
Gateway,
|
||||
RefreshSettings,
|
||||
AnyFunc,
|
||||
KeyStore,
|
||||
KeyStoreExport,
|
||||
LabelledKey,
|
||||
Key,
|
||||
CookieSettings,
|
||||
SameSiteValue
|
||||
};
|
21
lib/token_id.ts
Normal file
21
lib/token_id.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { create_salt } from '@sapphirecode/crypto-helper';
|
||||
import { to_b58 } from '@sapphirecode/encoding-helper';
|
||||
|
||||
export function generate_token_id (valid_until: Date) {
|
||||
const salt = create_salt ();
|
||||
return `${to_b58 (salt, 'hex')};${valid_until.toISOString ()}`;
|
||||
}
|
||||
|
||||
export function parse_token_id (id: string) {
|
||||
// eslint-disable-next-line max-len
|
||||
const regex = /^(?<hash>[A-HJ-NP-Za-km-z1-9]+);(?<date>\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d\.\d{3}Z)$/u;
|
||||
const result = regex.exec (id);
|
||||
if (result === null)
|
||||
throw new Error (`invalid token id ${id}`);
|
||||
if (typeof result.groups === 'undefined')
|
||||
throw new Error ('invalid state');
|
||||
return {
|
||||
hash: result.groups.hash as string,
|
||||
valid_until: new Date (result.groups.date as string)
|
||||
};
|
||||
}
|
@ -1,52 +0,0 @@
|
||||
/*
|
||||
* 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>, March 2020
|
||||
*/
|
||||
|
||||
/* eslint-disable no-magic-numbers */
|
||||
// @ts-nocheck
|
||||
'use strict';
|
||||
|
||||
const express = require ('express');
|
||||
const auth = require ('./index');
|
||||
const consts = require ('@scode/consts');
|
||||
const crypto = require ('@scode/crypto-helper');
|
||||
const password_helper = require ('@scode/password-helper');
|
||||
|
||||
|
||||
/**
|
||||
* start the server
|
||||
*/
|
||||
async function start_server () {
|
||||
const app = express ();
|
||||
|
||||
const name = 'testuser';
|
||||
const salt = crypto.create_salt ();
|
||||
const password = await password_helper.hash (
|
||||
crypto.hash_sha512 ('foo', salt)
|
||||
);
|
||||
const user = { name, salt, password };
|
||||
|
||||
app.use (auth ((user_name) => {
|
||||
if (user.name === user_name)
|
||||
return user;
|
||||
|
||||
return null;
|
||||
}, [
|
||||
/noauthreg/u,
|
||||
{ method: 'POST', regex: /noauthobj/u }
|
||||
]));
|
||||
|
||||
app.use ((req, res) => {
|
||||
res.status (consts.http.status_ok)
|
||||
.end ('foo');
|
||||
});
|
||||
|
||||
app.listen (3000);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
module.exports = { start_server };
|
70
package.json
70
package.json
@ -1,34 +1,58 @@
|
||||
{
|
||||
"name": "@sapphirecode/auth-server-helper",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"author": "Timo Hocker <t-hocker@web.de>",
|
||||
"version": "4.1.1",
|
||||
"main": "dist/lib/index.js",
|
||||
"author": {
|
||||
"name": "Timo Hocker",
|
||||
"email": "timo@scode.ovh"
|
||||
},
|
||||
"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": {
|
||||
"@sapphirecode/auth-client-helper": "^1.0.6",
|
||||
"@sapphirecode/crypto-helper": "^1.1.9",
|
||||
"@sapphirecode/eslint-config": "^2.0.2",
|
||||
"@sapphirecode/password-helper": "^1.0.3",
|
||||
"@stryker-mutator/core": "^3.0.2",
|
||||
"@stryker-mutator/javascript-mutator": "^3.0.2",
|
||||
"ava": "^3.5.0",
|
||||
"eslint": "^6.8.0",
|
||||
"express": "^4.17.1",
|
||||
"node-fetch": "^2.6.0",
|
||||
"nyc": "^15.0.0"
|
||||
"@sapphirecode/eslint-config-ts": "^1.1.27",
|
||||
"@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": "^10.9.1",
|
||||
"typescript": "^4.1.2"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "eslint . --ext .js,.jsx,.ts,.tsx,.vue,.mjs",
|
||||
"test": "nyc ava",
|
||||
"ci": "yarn && node jenkins.js",
|
||||
"pretest": "yarn compile",
|
||||
"test": "nyc jasmine --config=\"jasmine.json\"",
|
||||
"mutate": "stryker run",
|
||||
"compile": "tsc --allowJs --declaration --emitDeclarationOnly index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sapphirecode/consts": "^1.0.3"
|
||||
"precompile": "rm -rf dist",
|
||||
"compile": "tsc"
|
||||
},
|
||||
"files": [
|
||||
"LICENSE",
|
||||
"index.js"
|
||||
]
|
||||
"lib/**/*.ts",
|
||||
"dist/**/*.js",
|
||||
"dist/**/*.d.ts",
|
||||
"dist/**/*.map"
|
||||
],
|
||||
"keywords": [
|
||||
"authentication",
|
||||
"express",
|
||||
"middleware"
|
||||
],
|
||||
"dependencies": {
|
||||
"@sapphirecode/crypto-helper": "^2.0.0",
|
||||
"@sapphirecode/encoding-helper": "^1.1.0",
|
||||
"@sapphirecode/utilities": "^1.8.8",
|
||||
"debug": "^4.3.3",
|
||||
"ioredis": "^5.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
}
|
||||
|
@ -2,7 +2,7 @@
|
||||
* 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>, March 2020
|
||||
* Created by Timo Hocker <timo@scode.ovh>, May 2020
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
@ -11,14 +11,14 @@
|
||||
* @type {import('@stryker-mutator/api/core').StrykerOptions}
|
||||
*/
|
||||
module.exports = {
|
||||
mutator: 'javascript',
|
||||
packageManager: 'yarn',
|
||||
reporters: [
|
||||
'clear-text',
|
||||
'progress'
|
||||
],
|
||||
testRunner: 'command',
|
||||
transpilers: [],
|
||||
coverageAnalysis: 'all',
|
||||
mutate: [ 'index.js' ]
|
||||
testRunner: 'jasmine',
|
||||
jasmineConfigFile: 'jasmine.json',
|
||||
coverageAnalysis: 'perTest',
|
||||
mutate: [ 'lib/*.ts' ],
|
||||
tsconfigFile: 'tsconfig.json'
|
||||
};
|
||||
|
22
test/.eslintrc.js
Normal file
22
test/.eslintrc.js
Normal file
@ -0,0 +1,22 @@
|
||||
/*
|
||||
* 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>, December 2020
|
||||
*/
|
||||
|
||||
/* eslint-disable */
|
||||
|
||||
module.exports = {
|
||||
env: {
|
||||
commonjs: true,
|
||||
es6: true,
|
||||
node: true
|
||||
},
|
||||
extends: [ '@sapphirecode/eslint-config-ts' ],
|
||||
globals: {
|
||||
Atomics: 'readonly',
|
||||
SharedArrayBuffer: 'readonly'
|
||||
},
|
||||
parserOptions: { ecmaVersion: 2018 }
|
||||
};
|
81
test/Helper.ts
Normal file
81
test/Helper.ts
Normal file
@ -0,0 +1,81 @@
|
||||
/*
|
||||
* Copyright (C) Sapphirecode - All Rights Reserved
|
||||
* This file is part of Auth-Server-Helper which is released under MIT.
|
||||
* See file 'LICENSE' for full license details.
|
||||
* Created by Timo Hocker <timo@scode.ovh>, January 2021
|
||||
*/
|
||||
|
||||
/* eslint-disable no-console */
|
||||
import http from 'http';
|
||||
import ks from '../lib/KeyStore';
|
||||
|
||||
export class Response extends http.IncomingMessage {
|
||||
body?: string;
|
||||
}
|
||||
|
||||
export function get (
|
||||
// eslint-disable-next-line default-param-last
|
||||
headers: http.OutgoingHttpHeaders = {},
|
||||
body?: string|null,
|
||||
path = ''
|
||||
): Promise<Response> {
|
||||
return new Promise ((resolve) => {
|
||||
const req = http.request (`http://localhost:3000${path}`, {
|
||||
headers,
|
||||
method: typeof body === 'string' ? 'POST' : 'GET'
|
||||
}, (res: Response) => {
|
||||
let data = '';
|
||||
res.on ('data', (d) => {
|
||||
data += d;
|
||||
});
|
||||
res.on ('end', () => {
|
||||
res.body = data;
|
||||
resolve (res);
|
||||
});
|
||||
});
|
||||
if (typeof body === 'string')
|
||||
req.write (body);
|
||||
req.end ();
|
||||
});
|
||||
}
|
||||
|
||||
export function modify_signature (signature: string): string {
|
||||
const dec = signature.split ('.');
|
||||
dec[1] = '';
|
||||
return dec.join ('.');
|
||||
}
|
||||
|
||||
/* eslint-disable dot-notation */
|
||||
export function assert_keystore_state (): void {
|
||||
const set = ks['_keys'];
|
||||
const keys = Object.keys (set);
|
||||
if (keys.length !== 0) {
|
||||
const has_sign = keys.filter (
|
||||
(v) => typeof set[v].private_key !== 'undefined'
|
||||
).length;
|
||||
console.warn ('keystore gc not running!');
|
||||
console.warn (`${keys.length} keys with ${has_sign} signature keys left`);
|
||||
ks['_keys'] = {};
|
||||
}
|
||||
}
|
||||
/* eslint-enable dot-notation */
|
||||
|
||||
export function clock_setup (): void {
|
||||
assert_keystore_state ();
|
||||
|
||||
const date = (new Date);
|
||||
date.setHours (0, 0, 2, 0);
|
||||
jasmine.clock ()
|
||||
.install ();
|
||||
jasmine.clock ()
|
||||
.mockDate (date);
|
||||
}
|
||||
|
||||
export function clock_finalize (): void {
|
||||
jasmine.clock ()
|
||||
.tick (30 * 24 * 60 * 60 * 1000);
|
||||
// eslint-disable-next-line dot-notation
|
||||
ks['garbage_collect'] ();
|
||||
jasmine.clock ()
|
||||
.uninstall ();
|
||||
}
|
@ -1,75 +0,0 @@
|
||||
/*
|
||||
* 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>, March 2020
|
||||
*/
|
||||
|
||||
// @ts-nocheck
|
||||
'use strict';
|
||||
|
||||
const test = require ('ava');
|
||||
const mock_server = require ('../mock_server');
|
||||
const client = require ('@scode/auth-client-helper');
|
||||
const consts = require ('@scode/consts');
|
||||
const fetch = require ('node-fetch');
|
||||
|
||||
test.before (async () => {
|
||||
await mock_server.start_server ();
|
||||
});
|
||||
|
||||
test ('login', async (t) => {
|
||||
const session = await client.login (
|
||||
'testuser',
|
||||
'foo',
|
||||
'http://localhost:3000'
|
||||
);
|
||||
|
||||
t.is (typeof session, 'string');
|
||||
|
||||
const resp = await fetch ('http://localhost:3000', { headers: { session } });
|
||||
|
||||
t.is (resp.status, consts.http.status_ok);
|
||||
t.is (await resp.text (), 'foo');
|
||||
});
|
||||
|
||||
test ('allow access to excluded paths', async (t) => {
|
||||
const resp = await fetch ('http://localhost:3000/noauthreg');
|
||||
|
||||
t.is (resp.status, consts.http.status_ok);
|
||||
t.is (await resp.text (), 'foo');
|
||||
});
|
||||
|
||||
test ('allow access to excluded paths with correct method', async (t) => {
|
||||
const resp = await fetch (
|
||||
'http://localhost:3000/noauthobj',
|
||||
{ method: 'POST' }
|
||||
);
|
||||
|
||||
t.is (resp.status, consts.http.status_ok);
|
||||
t.is (await resp.text (), 'foo');
|
||||
});
|
||||
|
||||
test ('reject access to excluded paths with wrong method', async (t) => {
|
||||
const resp = await fetch (
|
||||
'http://localhost:3000/noauthobj'
|
||||
);
|
||||
|
||||
t.is (resp.status, consts.http.status_unauthorized);
|
||||
});
|
||||
|
||||
test ('reject invalid user', async (t) => {
|
||||
await t.throwsAsync (client.login (
|
||||
'foo',
|
||||
'foo',
|
||||
'http://localhost:3000'
|
||||
));
|
||||
});
|
||||
|
||||
test ('reject invalid password', async (t) => {
|
||||
await t.throwsAsync (client.login (
|
||||
'testuser',
|
||||
'bar',
|
||||
'http://localhost:3000'
|
||||
));
|
||||
});
|
378
test/spec/AuthHandler.ts
Normal file
378
test/spec/AuthHandler.ts
Normal file
@ -0,0 +1,378 @@
|
||||
/*
|
||||
* Copyright (C) Sapphirecode - All Rights Reserved
|
||||
* This file is part of Auth-Server-Helper which is released under MIT.
|
||||
* See file 'LICENSE' for full license details.
|
||||
* Created by Timo Hocker <timo@scode.ovh>, January 2021
|
||||
*/
|
||||
|
||||
/* eslint-disable max-lines */
|
||||
import http, { IncomingMessage, ServerResponse } from 'http';
|
||||
import { to_b64 } from '@sapphirecode/encoding-helper';
|
||||
import auth from '../../lib/Authority';
|
||||
import {
|
||||
clock_finalize,
|
||||
clock_setup,
|
||||
get, modify_signature, Response
|
||||
} from '../Helper';
|
||||
import { create_auth_handler } from '../../lib/index';
|
||||
import { build_cookie, extract_cookie } from '../../lib/cookie';
|
||||
|
||||
const expires_seconds = 600;
|
||||
const refresh_expires_seconds = 3600;
|
||||
const part_expires_seconds = 60;
|
||||
|
||||
interface CheckHeaderResult {
|
||||
at: string;
|
||||
rt?: string;
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function check_headers (resp: Response): CheckHeaderResult {
|
||||
const data = JSON.parse (resp.body as string);
|
||||
const at = data.access_token;
|
||||
const rt = data.refresh_token;
|
||||
expect (resp.headers['cache-control'])
|
||||
.toEqual ('no-store');
|
||||
expect (resp.headers.pragma)
|
||||
.toEqual ('no-cache');
|
||||
return { data, at, rt };
|
||||
}
|
||||
|
||||
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)
|
||||
.toEqual (type === 'access_token');
|
||||
expect (v.type)
|
||||
.toEqual (type);
|
||||
expect (token)
|
||||
.toMatch (/^[0-9a-z-._~+/]+$/ui);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line max-lines-per-function
|
||||
describe ('auth handler', () => {
|
||||
let server: http.Server|null = null;
|
||||
// eslint-disable-next-line max-lines-per-function
|
||||
beforeAll (() => {
|
||||
clock_setup ();
|
||||
|
||||
// eslint-disable-next-line complexity, max-lines-per-function
|
||||
const ah = create_auth_handler (async (req) => {
|
||||
if (!req.is_basic && !req.is_bearer) {
|
||||
let body_auth = false;
|
||||
try {
|
||||
const data = JSON.parse (req.body);
|
||||
if (data.username === 'foo' && data.password === 'bar') {
|
||||
req.allow_access ({
|
||||
access_token_expires_in: expires_seconds,
|
||||
include_refresh_token: true,
|
||||
refresh_token_expires_in: refresh_expires_seconds
|
||||
});
|
||||
body_auth = true;
|
||||
}
|
||||
}
|
||||
catch {
|
||||
body_auth = false;
|
||||
}
|
||||
if (!body_auth)
|
||||
req.invalid ('unknown authorization type');
|
||||
}
|
||||
else if (req.is_bearer) {
|
||||
req.deny ();
|
||||
}
|
||||
else if (req.user === 'foo' && req.password === 'bar') {
|
||||
req.allow_access ({
|
||||
access_token_expires_in: expires_seconds,
|
||||
include_refresh_token: true,
|
||||
refresh_token_expires_in: refresh_expires_seconds
|
||||
});
|
||||
}
|
||||
else if (req.user === 'part' && req.password === 'bar') {
|
||||
req.allow_part (part_expires_seconds, 'two_factor');
|
||||
}
|
||||
else if (req.user === 'red' && req.password === 'irect') {
|
||||
req.allow_access ({
|
||||
access_token_expires_in: expires_seconds,
|
||||
redirect_to: '/redirected'
|
||||
});
|
||||
}
|
||||
else if (req.user === 'leave' && req.password === 'open') {
|
||||
req.response.setHeader ('Content-Type', 'text/plain');
|
||||
await req.allow_access ({
|
||||
access_token_expires_in: expires_seconds,
|
||||
leave_open: true
|
||||
});
|
||||
req.response.write ('custom response, ');
|
||||
(req.response.connection as unknown as Record<string, unknown>)
|
||||
.append_flag = true;
|
||||
}
|
||||
else {
|
||||
req.deny ();
|
||||
}
|
||||
}, {
|
||||
cookie: { name: 'cookie_jar' },
|
||||
refresh_cookie: { name: 'mint_cookies' },
|
||||
refresh: {
|
||||
access_token_expires_in: expires_seconds,
|
||||
refresh_token_expires_in: refresh_expires_seconds,
|
||||
include_refresh_token: true
|
||||
},
|
||||
modules: {
|
||||
two_factor (request) {
|
||||
if (request.body === 'letmein') {
|
||||
request.allow_access ({
|
||||
access_token_expires_in: expires_seconds,
|
||||
include_refresh_token: true,
|
||||
refresh_token_expires_in: refresh_expires_seconds
|
||||
});
|
||||
}
|
||||
else { request.deny (); }
|
||||
}
|
||||
},
|
||||
parse_body: true
|
||||
});
|
||||
|
||||
server = http.createServer (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);
|
||||
});
|
||||
|
||||
afterAll (() => {
|
||||
if (server === null)
|
||||
throw new Error ('server is null');
|
||||
server.close ();
|
||||
clock_finalize ();
|
||||
});
|
||||
|
||||
it ('auth test sequence', async () => {
|
||||
// get initial access and refresh tokens
|
||||
const resp1 = await get ({ authorization: 'Basic foo:bar' });
|
||||
expect (resp1.statusCode)
|
||||
.toEqual (200);
|
||||
const res1 = check_headers (resp1);
|
||||
expect (res1.data.token_type)
|
||||
.toEqual ('bearer');
|
||||
expect (resp1.headers['set-cookie'])
|
||||
.toContain (build_cookie ({ name: 'cookie_jar' }, res1.at as string));
|
||||
expect (resp1.headers['set-cookie'])
|
||||
.toContain (build_cookie ({ name: 'mint_cookies' }, res1.rt as string));
|
||||
|
||||
await check_token (res1.at as string, 'access_token');
|
||||
expect (res1.data.expires_in)
|
||||
.toEqual (expires_seconds);
|
||||
|
||||
await check_token (res1.rt as string, 'refresh_token');
|
||||
expect (res1.data.refresh_expires_in)
|
||||
.toEqual (refresh_expires_seconds);
|
||||
|
||||
|
||||
// get refresh token
|
||||
const resp2 = await get ({ authorization: `Bearer ${res1.rt}` });
|
||||
expect (resp2.statusCode)
|
||||
.toEqual (200);
|
||||
const res2 = check_headers (resp2);
|
||||
expect (res2.data.token_type)
|
||||
.toEqual ('bearer');
|
||||
expect (resp2.headers['set-cookie'])
|
||||
.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));
|
||||
|
||||
await check_token (res2.at as string, 'access_token');
|
||||
expect (res2.data.expires_in)
|
||||
.toEqual (expires_seconds);
|
||||
expect (res2.at).not.toEqual (res1.at);
|
||||
|
||||
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 return the correct denial message', async () => {
|
||||
const resp = await get ({ authorization: 'Basic bar:baz' });
|
||||
expect (resp.statusCode)
|
||||
.toEqual (401);
|
||||
const res = check_headers (resp);
|
||||
expect (res.data)
|
||||
.toEqual ({ error: 'invalid_client' });
|
||||
});
|
||||
|
||||
it ('should allow base64 login', async () => {
|
||||
const resp1 = await get ({ authorization: `Basic ${to_b64 ('foo:bar')}` });
|
||||
expect (resp1.statusCode)
|
||||
.toEqual (200);
|
||||
const res1 = check_headers (resp1);
|
||||
expect (res1.data.token_type)
|
||||
.toEqual ('bearer');
|
||||
expect (resp1.headers['set-cookie'])
|
||||
.toContain (build_cookie ({ name: 'cookie_jar' }, res1.at as string));
|
||||
expect (resp1.headers['set-cookie'])
|
||||
.toContain (build_cookie ({ name: 'mint_cookies' }, res1.rt as string));
|
||||
|
||||
await check_token (res1.at as string, 'access_token');
|
||||
expect (res1.data.expires_in)
|
||||
.toEqual (expires_seconds);
|
||||
|
||||
await check_token (res1.rt as string, 'refresh_token');
|
||||
expect (res1.data.refresh_expires_in)
|
||||
.toEqual (refresh_expires_seconds);
|
||||
});
|
||||
|
||||
it ('should allow body login', async () => {
|
||||
const resp1 = await get (
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
{ 'Content-Type': 'application/json' },
|
||||
JSON.stringify ({ username: 'foo', password: 'bar' })
|
||||
);
|
||||
expect (resp1.statusCode)
|
||||
.toEqual (200);
|
||||
const res1 = check_headers (resp1);
|
||||
expect (res1.data.token_type)
|
||||
.toEqual ('bearer');
|
||||
expect (resp1.headers['set-cookie'])
|
||||
.toContain (build_cookie ({ name: 'cookie_jar' }, res1.at as string));
|
||||
expect (resp1.headers['set-cookie'])
|
||||
.toContain (build_cookie ({ name: 'mint_cookies' }, res1.rt as string));
|
||||
|
||||
await check_token (res1.at as string, 'access_token');
|
||||
expect (res1.data.expires_in)
|
||||
.toEqual (expires_seconds);
|
||||
|
||||
await check_token (res1.rt as string, 'refresh_token');
|
||||
expect (res1.data.refresh_expires_in)
|
||||
.toEqual (refresh_expires_seconds);
|
||||
});
|
||||
|
||||
it ('should reject invalid requests', async () => {
|
||||
const resp1 = await get ();
|
||||
expect (resp1.statusCode)
|
||||
.toEqual (400);
|
||||
const res1 = check_headers (resp1);
|
||||
expect (res1.data)
|
||||
.toEqual ({
|
||||
error: 'invalid_request',
|
||||
error_description: 'unknown authorization type'
|
||||
});
|
||||
|
||||
const resp2a = await get ({ authorization: 'Basic foo:bar' });
|
||||
const res2a = check_headers (resp2a);
|
||||
const resp2b = await get (
|
||||
{ authorization: `Bearer ${res2a.at}` }
|
||||
);
|
||||
expect (resp2b.statusCode)
|
||||
.toEqual (400);
|
||||
const res2 = check_headers (resp2b);
|
||||
expect (res2.data)
|
||||
.toEqual ({
|
||||
error: 'invalid_request',
|
||||
error_description: 'invalid bearer type'
|
||||
});
|
||||
});
|
||||
|
||||
it ('should reject an invalid token', async () => {
|
||||
const resp1 = await get ({ authorization: 'Basic foo:bar' });
|
||||
const res1 = check_headers (resp1);
|
||||
const resp2 = await get (
|
||||
{ authorization: `Bearer ${modify_signature (res1.at)}` }
|
||||
);
|
||||
expect (resp2.statusCode)
|
||||
.toEqual (401);
|
||||
const res2 = check_headers (resp2);
|
||||
expect (res2.data)
|
||||
.toEqual ({ error: 'invalid_client' });
|
||||
});
|
||||
|
||||
it ('should process part token', async () => {
|
||||
const resp1 = await get ({ authorization: 'Basic part:bar' });
|
||||
expect (resp1.statusCode)
|
||||
.toEqual (200);
|
||||
const res1 = check_headers (resp1);
|
||||
expect (res1.data.token_type)
|
||||
.toEqual ('bearer');
|
||||
expect (res1.data.expires_in)
|
||||
.toEqual (part_expires_seconds);
|
||||
await check_token (res1.data.part_token as string, 'part_token');
|
||||
|
||||
const resp2 = await get (
|
||||
{ authorization: `Bearer ${res1.data.part_token}` },
|
||||
'letmein'
|
||||
);
|
||||
expect (resp2.statusCode)
|
||||
.toEqual (200);
|
||||
const res2 = check_headers (resp2);
|
||||
expect (res2.data.token_type)
|
||||
.toEqual ('bearer');
|
||||
expect (resp2.headers['set-cookie'])
|
||||
.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));
|
||||
|
||||
await check_token (res2.at as string, 'access_token');
|
||||
expect (res2.data.expires_in)
|
||||
.toEqual (expires_seconds);
|
||||
expect (res2.at).not.toEqual (res1.at);
|
||||
|
||||
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)
|
||||
.toEqual (400);
|
||||
expect (JSON.parse (resp.body as string))
|
||||
.toEqual ({
|
||||
error: 'invalid_request',
|
||||
error_description: 'unknown authorization type'
|
||||
});
|
||||
});
|
||||
|
||||
it ('should not set content-type when leave-open is specified', async () => {
|
||||
const resp1 = await get ({ authorization: 'Basic leave:open' });
|
||||
expect (resp1.statusCode)
|
||||
.toEqual (200);
|
||||
expect (resp1.headers['content-type'])
|
||||
.toEqual ('text/plain');
|
||||
expect (resp1.body)
|
||||
.toEqual ('custom response, true');
|
||||
const signature = extract_cookie (
|
||||
'cookie_jar',
|
||||
(resp1.headers['set-cookie'] || []).join ('\n')
|
||||
);
|
||||
expect (signature).not.toEqual ('');
|
||||
await check_token (signature, 'access_token');
|
||||
});
|
||||
|
||||
it ('should disallow access and refresh cookies with the same name', () => {
|
||||
expect (() => {
|
||||
create_auth_handler (() => Promise.resolve (), {
|
||||
cookie: { name: 'foo' },
|
||||
refresh_cookie: { name: 'foo' }
|
||||
});
|
||||
})
|
||||
.toThrowError ('access and refresh cookies cannot have the same name');
|
||||
});
|
||||
});
|
162
test/spec/Authority.ts
Normal file
162
test/spec/Authority.ts
Normal file
@ -0,0 +1,162 @@
|
||||
/*
|
||||
* 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>, December 2020
|
||||
*/
|
||||
|
||||
import auth from '../../lib/Authority';
|
||||
import bl from '../../lib/Blacklist';
|
||||
import {
|
||||
clock_finalize,
|
||||
clock_setup,
|
||||
modify_signature
|
||||
} from '../Helper';
|
||||
|
||||
// eslint-disable-next-line max-lines-per-function
|
||||
describe ('authority', () => {
|
||||
beforeEach (() => {
|
||||
clock_setup ();
|
||||
});
|
||||
|
||||
afterEach (() => {
|
||||
clock_finalize ();
|
||||
});
|
||||
|
||||
it ('should create an access token', async () => {
|
||||
const token = await auth.sign ('access_token', 60);
|
||||
jasmine.clock ()
|
||||
.tick (30000);
|
||||
const res = await auth.verify (token.signature);
|
||||
expect (res.authorized)
|
||||
.toBeTrue ();
|
||||
expect (res.valid)
|
||||
.toBeTrue ();
|
||||
expect (res.type)
|
||||
.toEqual ('access_token');
|
||||
expect (res.next_module)
|
||||
.toBeUndefined ();
|
||||
expect (res.id)
|
||||
.toEqual (token.id);
|
||||
expect (res.error)
|
||||
.toBeUndefined ();
|
||||
});
|
||||
|
||||
it ('should create a refresh token', async () => {
|
||||
const token = await auth.sign ('refresh_token', 600);
|
||||
jasmine.clock ()
|
||||
.tick (30000);
|
||||
const res = await auth.verify (token.signature);
|
||||
expect (res.authorized)
|
||||
.toBeFalse ();
|
||||
expect (res.valid)
|
||||
.toBeTrue ();
|
||||
expect (res.type)
|
||||
.toEqual ('refresh_token');
|
||||
expect (res.next_module)
|
||||
.toBeUndefined ();
|
||||
expect (res.id)
|
||||
.toEqual (token.id);
|
||||
expect (res.error)
|
||||
.toBeUndefined ();
|
||||
});
|
||||
|
||||
it ('should create a part token', async () => {
|
||||
const token = await auth.sign ('part_token', 60, { next_module: '2fa' });
|
||||
jasmine.clock ()
|
||||
.tick (30000);
|
||||
const res = await auth.verify (token.signature);
|
||||
expect (res.authorized)
|
||||
.toBeFalse ();
|
||||
expect (res.valid)
|
||||
.toBeTrue ();
|
||||
expect (res.type)
|
||||
.toEqual ('part_token');
|
||||
expect (res.next_module)
|
||||
.toEqual ('2fa');
|
||||
expect (res.id)
|
||||
.toEqual (token.id);
|
||||
expect (res.error)
|
||||
.toBeUndefined ();
|
||||
});
|
||||
|
||||
it ('should reject an invalid access token', async () => {
|
||||
const token = await auth.sign ('access_token', 60);
|
||||
token.signature = modify_signature (token.signature);
|
||||
jasmine.clock ()
|
||||
.tick (30000);
|
||||
const res = await auth.verify (token.signature);
|
||||
expect (res.authorized)
|
||||
.toBeFalse ();
|
||||
expect (res.valid)
|
||||
.toBeFalse ();
|
||||
expect (res.type)
|
||||
.toEqual ('none');
|
||||
expect (res.next_module)
|
||||
.toBeUndefined ();
|
||||
expect (res.id)
|
||||
.toEqual ('');
|
||||
expect (res.error)
|
||||
.toEqual ('invalid signature');
|
||||
});
|
||||
|
||||
it ('should reject blacklisted access token', async () => {
|
||||
const token = await auth.sign ('access_token', 60);
|
||||
jasmine.clock ()
|
||||
.tick (30000);
|
||||
await bl.add_signature (token.id);
|
||||
const res = await auth.verify (token.signature);
|
||||
expect (res.authorized)
|
||||
.toBeFalse ();
|
||||
expect (res.valid)
|
||||
.toBeFalse ();
|
||||
expect (res.type)
|
||||
.toEqual ('access_token');
|
||||
expect (res.next_module)
|
||||
.toBeUndefined ();
|
||||
expect (res.id)
|
||||
.toEqual (token.id);
|
||||
expect (res.error)
|
||||
.toEqual ('blacklisted');
|
||||
});
|
||||
|
||||
it ('should reject an invalid refresh token', async () => {
|
||||
const token = await auth.sign ('refresh_token', 600);
|
||||
token.signature = modify_signature (token.signature);
|
||||
jasmine.clock ()
|
||||
.tick (30000);
|
||||
const res = await auth.verify (token.signature);
|
||||
expect (res.authorized)
|
||||
.toBeFalse ();
|
||||
expect (res.valid)
|
||||
.toBeFalse ();
|
||||
expect (res.type)
|
||||
.toEqual ('none');
|
||||
expect (res.next_module)
|
||||
.toBeUndefined ();
|
||||
expect (res.id)
|
||||
.toEqual ('');
|
||||
expect (res.error)
|
||||
.toEqual ('invalid signature');
|
||||
});
|
||||
|
||||
it ('should reject a blacklisted refresh token', async () => {
|
||||
const token = await auth.sign ('refresh_token', 600);
|
||||
jasmine.clock ()
|
||||
.tick (30000);
|
||||
await bl.add_signature (token.id);
|
||||
const res = await auth.verify (token.signature);
|
||||
expect (res.authorized)
|
||||
.toBeFalse ();
|
||||
expect (res.valid)
|
||||
.toBeFalse ();
|
||||
expect (res.type)
|
||||
.toEqual ('refresh_token');
|
||||
expect (res.next_module)
|
||||
.toBeUndefined ();
|
||||
expect (res.id)
|
||||
.toEqual (token.id);
|
||||
expect (res.error)
|
||||
.toEqual ('blacklisted');
|
||||
});
|
||||
});
|
111
test/spec/Blacklist.ts
Normal file
111
test/spec/Blacklist.ts
Normal file
@ -0,0 +1,111 @@
|
||||
/*
|
||||
* 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>, December 2020
|
||||
*/
|
||||
|
||||
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 ();
|
||||
});
|
||||
|
||||
afterAll (() => {
|
||||
clock_finalize ();
|
||||
});
|
||||
|
||||
it ('should validate any string', async () => {
|
||||
expect (await blacklist.is_valid (token1))
|
||||
.toBeTrue ();
|
||||
expect (await blacklist.is_valid (token2))
|
||||
.toBeTrue ();
|
||||
expect (await blacklist.is_valid (token3))
|
||||
.toBeTrue ();
|
||||
});
|
||||
|
||||
it ('should blacklist strings', async () => {
|
||||
await blacklist.add_signature (token1);
|
||||
await blacklist.add_signature (token2);
|
||||
expect (await blacklist.is_valid (token1))
|
||||
.toBeFalse ();
|
||||
expect (await blacklist.is_valid (token2))
|
||||
.toBeFalse ();
|
||||
expect (await blacklist.is_valid (token3))
|
||||
.toBeTrue ();
|
||||
});
|
||||
|
||||
it ('should remove one string', async () => {
|
||||
await blacklist.remove_signature (token1);
|
||||
expect (await blacklist.is_valid (token1))
|
||||
.toBeTrue ();
|
||||
expect (await blacklist.is_valid (token2))
|
||||
.toBeFalse ();
|
||||
expect (await blacklist.is_valid (token3))
|
||||
.toBeTrue ();
|
||||
});
|
||||
|
||||
it ('should clear after time', async () => {
|
||||
jasmine.clock ()
|
||||
.tick (5000);
|
||||
await blacklist.add_signature (token3);
|
||||
await blacklist.clear (Date.now () - 100);
|
||||
expect (await blacklist.is_valid (token1))
|
||||
.toBeTrue ();
|
||||
expect (await blacklist.is_valid (token2))
|
||||
.toBeTrue ();
|
||||
expect (await blacklist.is_valid (token3))
|
||||
.toBeFalse ();
|
||||
});
|
||||
|
||||
it ('should clear all', async () => {
|
||||
await blacklist.add_signature (token1);
|
||||
await blacklist.add_signature (token2);
|
||||
await blacklist.add_signature (token3);
|
||||
expect (await blacklist.is_valid (token1))
|
||||
.toBeFalse ();
|
||||
expect (await blacklist.is_valid (token2))
|
||||
.toBeFalse ();
|
||||
expect (await blacklist.is_valid (token3))
|
||||
.toBeFalse ();
|
||||
await blacklist.clear ();
|
||||
expect (await blacklist.is_valid (token1))
|
||||
.toBeTrue ();
|
||||
expect (await blacklist.is_valid (token2))
|
||||
.toBeTrue ();
|
||||
expect (await blacklist.is_valid (token3))
|
||||
.toBeTrue ();
|
||||
});
|
||||
|
||||
it ('should export and import data', async () => {
|
||||
const time = new Date;
|
||||
const token = generate_token_id (time);
|
||||
await blacklist.add_signature (token);
|
||||
// eslint-disable-next-line dot-notation
|
||||
expect (blacklist['_signatures'])
|
||||
.toEqual ([
|
||||
{
|
||||
token_id: token,
|
||||
iat: time.getTime (),
|
||||
valid_until: time
|
||||
}
|
||||
]);
|
||||
const exp = blacklist.export_blacklist ();
|
||||
expect (exp)
|
||||
.toEqual ([ { token_id: token, iat: time.getTime () } ]);
|
||||
const bl2 = (new Blacklist);
|
||||
bl2.import_blacklist (exp);
|
||||
// eslint-disable-next-line dot-notation
|
||||
expect (bl2['_signatures'])
|
||||
// eslint-disable-next-line dot-notation
|
||||
.toEqual (blacklist['_signatures']);
|
||||
});
|
||||
});
|
226
test/spec/Gateway.ts
Normal file
226
test/spec/Gateway.ts
Normal file
@ -0,0 +1,226 @@
|
||||
/*
|
||||
* Copyright (C) Sapphirecode - All Rights Reserved
|
||||
* This file is part of Auth-Server-Helper which is released under MIT.
|
||||
* See file 'LICENSE' for full license details.
|
||||
* Created by Timo Hocker <timo@scode.ovh>, January 2021
|
||||
*/
|
||||
|
||||
import http from 'http';
|
||||
import { GatewayClass, create_gateway } from '../../lib/index';
|
||||
import authority from '../../lib/Authority';
|
||||
import blacklist from '../../lib/Blacklist';
|
||||
import { clock_finalize, clock_setup, get } from '../Helper';
|
||||
|
||||
// eslint-disable-next-line max-lines-per-function
|
||||
describe ('gateway', () => {
|
||||
let server: http.Server|null = null;
|
||||
|
||||
beforeAll (() => {
|
||||
clock_setup ();
|
||||
|
||||
const g = new GatewayClass ({
|
||||
redirect_url: 'http://localhost/auth',
|
||||
cookie: { name: 'cookie_jar' },
|
||||
refresh_cookie: { name: 'mint_cookies' },
|
||||
refresh_settings: {
|
||||
access_token_expires_in: 600,
|
||||
include_refresh_token: true,
|
||||
refresh_token_expires_in: 3600
|
||||
}
|
||||
});
|
||||
|
||||
server = http.createServer ((req, res) => {
|
||||
const passed_handler = () => {
|
||||
if (typeof req.url !== 'undefined') {
|
||||
if (req.url.endsWith ('logout'))
|
||||
g.logout (req);
|
||||
}
|
||||
res.writeHead (200);
|
||||
const data = {
|
||||
...g.get_info (req),
|
||||
foo: g.has_permission (req, 'foo'),
|
||||
bar: g.has_permission (req, 'bar')
|
||||
};
|
||||
|
||||
res.end (JSON.stringify (data));
|
||||
};
|
||||
g.process_request (req, res, passed_handler);
|
||||
});
|
||||
server.listen (3000);
|
||||
});
|
||||
|
||||
afterAll (() => {
|
||||
if (server === null)
|
||||
throw new Error ('server is null');
|
||||
server.close ();
|
||||
|
||||
clock_finalize ();
|
||||
});
|
||||
|
||||
it ('should redirect any unauthorized request', async () => {
|
||||
const resp = await get ();
|
||||
expect (resp.statusCode)
|
||||
.toEqual (302);
|
||||
expect (resp.headers.location)
|
||||
.toEqual ('http://localhost/auth');
|
||||
});
|
||||
|
||||
it ('should allow a valid access token', async () => {
|
||||
const token = await authority.sign ('access_token', 60);
|
||||
const resp = await get ({ authorization: `Bearer ${token.signature}` });
|
||||
expect (resp.statusCode)
|
||||
.toEqual (200);
|
||||
expect (JSON.parse (resp.body as string).token_id)
|
||||
.toEqual (token.id);
|
||||
});
|
||||
|
||||
it ('should allow a valid access token using cookies', async () => {
|
||||
const token = await authority.sign ('access_token', 60);
|
||||
const resp = await get (
|
||||
{ cookie: `foo=bar;cookie_jar=${token.signature};asd=efg` }
|
||||
);
|
||||
expect (resp.statusCode)
|
||||
.toEqual (200);
|
||||
expect (JSON.parse (resp.body as string).token_id)
|
||||
.toEqual (token.id);
|
||||
});
|
||||
|
||||
it ('should automatically return new tokens', async () => {
|
||||
const token = await authority.sign ('access_token', 60, { data: 'foobar' });
|
||||
const refresh = await authority.sign (
|
||||
'refresh_token',
|
||||
3600,
|
||||
{ data: 'foobar' }
|
||||
);
|
||||
jasmine.clock ()
|
||||
.tick (70000);
|
||||
const resp = await get (
|
||||
// eslint-disable-next-line max-len
|
||||
{ cookie: `foo=bar;cookie_jar=${token.signature};asd=efg;mint_cookies=${refresh.signature}` }
|
||||
);
|
||||
expect (resp.statusCode)
|
||||
.toEqual (200);
|
||||
expect (JSON.parse (resp.body as string).token_id)
|
||||
.not
|
||||
.toEqual (token.id);
|
||||
expect (JSON.parse (resp.body as string).token_data)
|
||||
.toEqual ('foobar');
|
||||
});
|
||||
|
||||
it ('should correctly deliver token data', async () => {
|
||||
const token = await authority.sign ('access_token', 60, { data: 'foobar' });
|
||||
const resp = await get ({ authorization: `Bearer ${token.signature}` });
|
||||
expect (resp.statusCode)
|
||||
.toEqual (200);
|
||||
const body = JSON.parse (resp.body as string);
|
||||
expect (body.token_id)
|
||||
.toEqual (token.id);
|
||||
expect (body.token_data)
|
||||
.toEqual ('foobar');
|
||||
expect (body.permissions)
|
||||
.toEqual ([]);
|
||||
});
|
||||
|
||||
it ('should reject an outdated access token', async () => {
|
||||
const token = await authority.sign ('access_token', 60);
|
||||
jasmine.clock ()
|
||||
.tick (70000);
|
||||
const resp = await get ({ authorization: `Bearer ${token.signature}` });
|
||||
expect (resp.statusCode)
|
||||
.toEqual (302);
|
||||
expect (resp.headers.location)
|
||||
.toEqual ('http://localhost/auth');
|
||||
});
|
||||
|
||||
it ('should reject a blacklisted access token', async () => {
|
||||
const token = await authority.sign ('access_token', 60);
|
||||
await blacklist.add_signature (token.id);
|
||||
const resp = await get ({ authorization: `Bearer ${token.signature}` });
|
||||
expect (resp.statusCode)
|
||||
.toEqual (302);
|
||||
expect (resp.headers.location)
|
||||
.toEqual ('http://localhost/auth');
|
||||
});
|
||||
|
||||
it ('should reject any refresh_token', async () => {
|
||||
const token = await authority.sign ('refresh_token', 60);
|
||||
const resp = await get ({ authorization: `Bearer ${token.signature}` });
|
||||
expect (resp.statusCode)
|
||||
.toEqual (302);
|
||||
expect (resp.headers.location)
|
||||
.toEqual ('http://localhost/auth');
|
||||
});
|
||||
|
||||
it ('should reject any part_token', async () => {
|
||||
const token = await authority.sign ('part_token', 60);
|
||||
const resp = await get ({ authorization: `Bearer ${token.signature}` });
|
||||
expect (resp.statusCode)
|
||||
.toEqual (302);
|
||||
expect (resp.headers.location)
|
||||
.toEqual ('http://localhost/auth');
|
||||
});
|
||||
|
||||
it ('should reject any noname token', async () => {
|
||||
const token = await authority.sign ('none', 60);
|
||||
const resp = await get ({ authorization: `Bearer ${token.signature}` });
|
||||
expect (resp.statusCode)
|
||||
.toEqual (302);
|
||||
expect (resp.headers.location)
|
||||
.toEqual ('http://localhost/auth');
|
||||
});
|
||||
|
||||
it ('should reject non-bearer auth', async () => {
|
||||
const resp = await get ({ authorization: 'Basic foo:bar' });
|
||||
expect (resp.statusCode)
|
||||
.toEqual (302);
|
||||
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
|
||||
});
|
||||
});
|
||||
});
|
194
test/spec/KeyStore.ts
Normal file
194
test/spec/KeyStore.ts
Normal file
@ -0,0 +1,194 @@
|
||||
/*
|
||||
* 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>, December 2020
|
||||
*/
|
||||
|
||||
import ks, { KeyStore } from '../../lib/KeyStore';
|
||||
import { clock_finalize, clock_setup } from '../Helper';
|
||||
|
||||
const frame = 3600;
|
||||
|
||||
/* eslint-disable-next-line max-lines-per-function */
|
||||
describe ('key store', () => {
|
||||
beforeAll (() => {
|
||||
clock_setup ();
|
||||
});
|
||||
|
||||
afterAll (() => {
|
||||
clock_finalize ();
|
||||
});
|
||||
|
||||
const keys: {key: string, sign: string, iat: number}[] = [];
|
||||
|
||||
it ('should generate a new key', async () => {
|
||||
const iat = (new Date)
|
||||
.getTime () / 1000;
|
||||
const duration = 10 * frame;
|
||||
const key = await ks.get_sign_key (iat, duration);
|
||||
const sign = await ks.get_key (iat);
|
||||
expect (typeof key)
|
||||
.toEqual ('string');
|
||||
expect (typeof sign)
|
||||
.toEqual ('string');
|
||||
keys.push ({ iat, key, sign });
|
||||
});
|
||||
|
||||
it ('should return the generated key', async () => {
|
||||
const key = await ks.get_sign_key (keys[0].iat, 1);
|
||||
expect (key)
|
||||
.toEqual (keys[0].key);
|
||||
const sign = await ks.get_key (keys[0].iat);
|
||||
expect (sign)
|
||||
.toEqual (keys[0].sign);
|
||||
});
|
||||
|
||||
it ('should return the same key on a different time', async () => {
|
||||
const key = await ks.get_sign_key (keys[0].iat + (frame / 2), 1);
|
||||
expect (key)
|
||||
.toEqual (keys[0].key);
|
||||
const sign = await ks.get_key (keys[0].iat + (frame / 2));
|
||||
expect (sign)
|
||||
.toEqual (keys[0].sign);
|
||||
});
|
||||
|
||||
it ('should generate a new key after time frame is over', async () => {
|
||||
jasmine.clock ()
|
||||
.tick (frame * 1000);
|
||||
const iat = (new Date)
|
||||
.getTime () / 1000;
|
||||
const duration = 10 * frame;
|
||||
const key = await ks.get_sign_key (iat, duration);
|
||||
const sign = await ks.get_key (iat);
|
||||
expect (typeof key)
|
||||
.toEqual ('string');
|
||||
expect (key).not.toEqual (keys[0].key);
|
||||
expect (sign).not.toEqual (keys[0].sign);
|
||||
keys.push ({ iat, key, sign });
|
||||
});
|
||||
|
||||
it ('should return both keys, but not the first sign key', async () => {
|
||||
const sign = await ks.get_key (keys[0].iat);
|
||||
expect (sign)
|
||||
.toEqual (keys[0].sign);
|
||||
await expectAsync (ks.get_sign_key (keys[0].iat, 1))
|
||||
.toBeRejectedWithError ('cannot access already expired keys');
|
||||
const k2 = await ks.get_sign_key (keys[1].iat, 1);
|
||||
const s2 = await ks.get_key (keys[1].iat);
|
||||
expect (k2)
|
||||
.toEqual (keys[1].key);
|
||||
expect (s2)
|
||||
.toEqual (keys[1].sign);
|
||||
});
|
||||
|
||||
it ('should throw on non existing key', async () => {
|
||||
await expectAsync (ks.get_key (keys[1].iat + frame))
|
||||
.toBeRejectedWithError ('key could not be found');
|
||||
});
|
||||
|
||||
it ('should delete a key after it expires', async () => {
|
||||
// go to 10 frames + 1ms after key creation
|
||||
jasmine.clock ()
|
||||
.tick ((frame * 9e3) + 1);
|
||||
// eslint-disable-next-line dot-notation
|
||||
ks['garbage_collect'] ();
|
||||
await expectAsync (ks.get_key (keys[0].iat))
|
||||
.toBeRejectedWithError ('key could not be found');
|
||||
});
|
||||
|
||||
it (
|
||||
'should still retrieve the second key, but not its sign key',
|
||||
async () => {
|
||||
await expectAsync (ks.get_sign_key (keys[1].iat, 1))
|
||||
.toBeRejectedWithError ('cannot access already expired keys');
|
||||
const sign = await ks.get_key (keys[1].iat);
|
||||
expect (sign)
|
||||
.toEqual (keys[1].sign);
|
||||
}
|
||||
);
|
||||
|
||||
it ('should reject key generation of expired keys', async () => {
|
||||
const iat = ((new Date)
|
||||
.getTime () / 1000) - 2;
|
||||
const duration = 5;
|
||||
await expectAsync (ks.get_sign_key (iat, duration))
|
||||
.toBeRejectedWithError ('cannot access already expired keys');
|
||||
});
|
||||
|
||||
it ('key should live as long as the longest created token', async () => {
|
||||
jasmine.clock ()
|
||||
.tick (frame * 10e3);
|
||||
const iat = (new Date)
|
||||
.getTime () / 1000;
|
||||
const duration1 = frame;
|
||||
const duration2 = frame * 10;
|
||||
|
||||
const key1 = await ks.get_sign_key (iat, duration1);
|
||||
const step = 0.9 * frame;
|
||||
jasmine.clock ()
|
||||
.tick (step * 1000);
|
||||
const key2 = await ks.get_sign_key (iat + step, duration2);
|
||||
const sign = await ks.get_key (iat);
|
||||
expect (key1)
|
||||
.toEqual (key2);
|
||||
jasmine.clock ()
|
||||
.tick (5000 * frame);
|
||||
const signv = await ks.get_key (iat + step);
|
||||
expect (signv)
|
||||
.toEqual (sign);
|
||||
});
|
||||
|
||||
it ('should not allow invalid expiry times', async () => {
|
||||
await expectAsync (ks.get_sign_key (0, 0))
|
||||
.toBeRejectedWithError ('cannot create infinitely valid key');
|
||||
await expectAsync (ks.get_sign_key (0, -1))
|
||||
.toBeRejectedWithError ('cannot create infinitely valid key');
|
||||
});
|
||||
|
||||
it ('should export and import all keys', async () => {
|
||||
const iat = (new Date)
|
||||
.getTime () / 1000;
|
||||
|
||||
const sign = await ks.get_sign_key (iat, frame);
|
||||
const ver = await ks.get_key (iat);
|
||||
const exp = ks.export_verification_data ();
|
||||
// eslint-disable-next-line dot-notation
|
||||
expect (Object.keys (ks['_keys']))
|
||||
.toEqual (exp.map ((v) => v.index));
|
||||
|
||||
const ks2 = (new KeyStore);
|
||||
expect (ks2.instance_id).not.toEqual (ks.instance_id);
|
||||
ks2.import_verification_data (exp);
|
||||
// eslint-disable-next-line dot-notation
|
||||
expect (Object.keys (ks2['_keys']))
|
||||
.toEqual (exp.map ((v) => v.index));
|
||||
|
||||
const sign2 = await ks2.get_sign_key (iat, frame);
|
||||
const ver2 = await ks2.get_key (iat);
|
||||
expect (sign).not.toEqual (sign2);
|
||||
expect (ver).not.toEqual (ver2);
|
||||
await expectAsync (ks2.get_sign_key (iat, 60, ks.instance_id))
|
||||
.toBeRejectedWithError ('cannot access already expired keys');
|
||||
expect (await ks2.get_key (iat, ks.instance_id))
|
||||
.toEqual (ver);
|
||||
});
|
||||
|
||||
it ('should disallow importing to itself', () => {
|
||||
const exp = ks.export_verification_data ();
|
||||
expect (() => ks.import_verification_data (exp))
|
||||
.toThrowError ('cannot import to the same instance');
|
||||
});
|
||||
|
||||
it ('should clear all', () => {
|
||||
// eslint-disable-next-line dot-notation
|
||||
expect (Object.keys (ks['_keys']).length)
|
||||
.toBeGreaterThan (0);
|
||||
const instance = ks.instance_id;
|
||||
ks.reset_instance ();
|
||||
// eslint-disable-next-line dot-notation
|
||||
expect (Object.keys (ks['_keys']).length)
|
||||
.toEqual (0);
|
||||
expect (instance).not.toEqual (ks.instance_id);
|
||||
});
|
||||
});
|
110
test/spec/Redis.ts
Normal file
110
test/spec/Redis.ts
Normal file
@ -0,0 +1,110 @@
|
||||
/*
|
||||
* Copyright (C) Sapphirecode - All Rights Reserved
|
||||
* This file is part of Auth-Server-Helper which is released under MIT.
|
||||
* See file 'LICENSE' for full license details.
|
||||
* Created by Timo Hocker <timo@scode.ovh>, August 2022
|
||||
*/
|
||||
|
||||
/* eslint-disable dot-notation */
|
||||
|
||||
import { blacklist } from '../../lib';
|
||||
import ks from '../../lib/KeyStore';
|
||||
import { Redis } from '../../lib/Redis';
|
||||
import { generate_token_id } from '../../lib/token_id';
|
||||
import { clock_finalize, clock_setup } from '../Helper';
|
||||
|
||||
const frame = 3600;
|
||||
const redis_url = process.env.TEST_REDIS_URL || 'redis://localhost';
|
||||
const redis = new Redis;
|
||||
redis.connect (redis_url);
|
||||
|
||||
// eslint-disable-next-line max-lines-per-function
|
||||
describe ('redis', () => {
|
||||
const token1 = generate_token_id (new Date (Date.now () + 3600000));
|
||||
const token2 = generate_token_id (new Date (Date.now () + 3600000));
|
||||
const token3 = generate_token_id (new Date (Date.now () + 3600000));
|
||||
|
||||
beforeAll (async () => {
|
||||
ks.reset_instance ();
|
||||
ks.sync_redis (redis_url);
|
||||
await blacklist.clear ();
|
||||
blacklist.sync_redis (redis_url);
|
||||
clock_setup ();
|
||||
});
|
||||
|
||||
let iat1 = 0;
|
||||
let iat2 = 0;
|
||||
let k1 = '';
|
||||
let k2 = '';
|
||||
let i1 = '';
|
||||
let i2 = '';
|
||||
|
||||
afterAll (() => clock_finalize ());
|
||||
|
||||
it ('should generate two keys', async () => {
|
||||
iat1 = (new Date)
|
||||
.getTime () / 1000;
|
||||
await ks.get_sign_key (iat1, frame);
|
||||
k1 = await ks.get_key (iat1);
|
||||
|
||||
jasmine.clock ()
|
||||
.tick (frame * 1000);
|
||||
|
||||
iat2 = (new Date)
|
||||
.getTime () / 1000;
|
||||
await ks.get_sign_key (iat2, frame);
|
||||
k2 = await ks.get_key (iat2);
|
||||
// eslint-disable-next-line dot-notation
|
||||
i1 = ks['get_index'] (iat1);
|
||||
// eslint-disable-next-line dot-notation
|
||||
i2 = ks['get_index'] (iat2);
|
||||
});
|
||||
|
||||
it ('should have two keys in redis', async () => {
|
||||
expect (JSON.parse (await redis['_redis']
|
||||
?.get (`keystore_${i1}`) as string).key)
|
||||
.toEqual (k1);
|
||||
expect (JSON.parse (await redis['_redis']
|
||||
?.get (`keystore_${i2}`) as string).key)
|
||||
.toEqual (k2);
|
||||
});
|
||||
|
||||
it ('should read two keys with a new instance', async () => {
|
||||
const old_instance = ks.instance_id;
|
||||
ks.reset_instance ();
|
||||
expectAsync (ks.get_key (iat1, old_instance))
|
||||
.toBeRejectedWithError ('key could not be found');
|
||||
expectAsync (ks.get_key (iat1, old_instance))
|
||||
.toBeRejectedWithError ('key could not be found');
|
||||
|
||||
ks.sync_redis (redis_url);
|
||||
expect (await ks.get_key (iat1, old_instance))
|
||||
.toEqual (k1);
|
||||
expect (await ks.get_key (iat2, old_instance))
|
||||
.toEqual (k2);
|
||||
});
|
||||
|
||||
it ('should add two keys to the blacklist', async () => {
|
||||
await blacklist.add_signature (token1);
|
||||
await blacklist.add_signature (token2);
|
||||
});
|
||||
|
||||
it ('should have two keys in redis blacklist', async () => {
|
||||
expect ((await redis['_redis']?.exists (`blacklist_${token1}`)) === 1)
|
||||
.toBeTrue ();
|
||||
expect ((await redis['_redis']?.exists (`blacklist_${token2}`)) === 1)
|
||||
.toBeTrue ();
|
||||
expect ((await redis['_redis']?.exists (`blacklist_${token3}`)) === 1)
|
||||
.toBeFalse ();
|
||||
});
|
||||
|
||||
it ('should read keys from redis', async () => {
|
||||
blacklist['_signatures'].splice (0, blacklist['_signatures'].length);
|
||||
expect (await blacklist.is_valid (token1))
|
||||
.toBeFalse ();
|
||||
expect (await blacklist.is_valid (token2))
|
||||
.toBeFalse ();
|
||||
expect (await blacklist.is_valid (token3))
|
||||
.toBeTrue ();
|
||||
});
|
||||
});
|
130
test/spec/cookie.ts
Normal file
130
test/spec/cookie.ts
Normal file
@ -0,0 +1,130 @@
|
||||
/*
|
||||
* Copyright (C) Sapphirecode - All Rights Reserved
|
||||
* This file is part of Auth-Server-Helper which is released under MIT.
|
||||
* See file 'LICENSE' for full license details.
|
||||
* Created by Timo Hocker <timo@scode.ovh>, January 2022
|
||||
*/
|
||||
|
||||
import { build_cookie, CookieSettings, extract_cookie } from '../../lib/cookie';
|
||||
|
||||
interface CreateCookie {
|
||||
settings: CookieSettings
|
||||
value: string
|
||||
result: string
|
||||
}
|
||||
|
||||
const create_cookie_pairs: CreateCookie[] = [
|
||||
{
|
||||
settings: { name: 'foo' },
|
||||
value: 'bar',
|
||||
result: 'foo=bar; Secure; HttpOnly; SameSite=Strict'
|
||||
},
|
||||
{
|
||||
settings: { name: 'foäöüo' },
|
||||
value: 'baäöür',
|
||||
result: 'foäöüo=baäöür; Secure; HttpOnly; SameSite=Strict'
|
||||
},
|
||||
{
|
||||
settings: {
|
||||
name: 'foo',
|
||||
secure: true,
|
||||
http_only: false,
|
||||
same_site: null
|
||||
},
|
||||
value: 'bar',
|
||||
result: 'foo=bar; Secure'
|
||||
},
|
||||
{
|
||||
settings: {
|
||||
name: 'foo',
|
||||
secure: false,
|
||||
http_only: true,
|
||||
same_site: null
|
||||
},
|
||||
value: 'bar',
|
||||
result: 'foo=bar; HttpOnly'
|
||||
},
|
||||
{
|
||||
settings: {
|
||||
name: 'foo',
|
||||
secure: false,
|
||||
http_only: false,
|
||||
same_site: 'Lax'
|
||||
},
|
||||
value: 'bar',
|
||||
result: 'foo=bar; SameSite=Lax'
|
||||
},
|
||||
{
|
||||
settings: {
|
||||
name: 'foo',
|
||||
secure: false,
|
||||
http_only: false,
|
||||
same_site: null,
|
||||
expires: 'Tomorrow'
|
||||
},
|
||||
value: 'bar',
|
||||
result: 'foo=bar; Expires=Tomorrow'
|
||||
},
|
||||
{
|
||||
settings: {
|
||||
name: 'foo',
|
||||
secure: false,
|
||||
http_only: false,
|
||||
same_site: null,
|
||||
max_age: 600
|
||||
},
|
||||
value: 'bar',
|
||||
result: 'foo=bar; Max-Age=600'
|
||||
},
|
||||
{
|
||||
settings: {
|
||||
name: 'foo',
|
||||
secure: false,
|
||||
http_only: false,
|
||||
same_site: null,
|
||||
domain: 'example.com'
|
||||
},
|
||||
value: 'bar',
|
||||
result: 'foo=bar; Domain=example.com'
|
||||
},
|
||||
{
|
||||
settings: {
|
||||
name: 'foo',
|
||||
secure: false,
|
||||
http_only: false,
|
||||
same_site: null,
|
||||
path: '/test'
|
||||
},
|
||||
value: 'bar',
|
||||
result: 'foo=bar; Path=/test'
|
||||
}
|
||||
];
|
||||
|
||||
const parse_cookie_pairs = [
|
||||
{
|
||||
header: 'foo=bar; Secure; HttpOnly; SameSite=Strict',
|
||||
name: 'foo',
|
||||
value: 'bar'
|
||||
},
|
||||
{
|
||||
header: '134=567;foäöüo=baäöür;tesT=123',
|
||||
name: 'foäöüo',
|
||||
value: 'baäöür'
|
||||
}
|
||||
];
|
||||
|
||||
describe ('cookie', () => {
|
||||
it ('should create a cookie', () => {
|
||||
for (const pair of create_cookie_pairs) {
|
||||
expect (build_cookie (pair.settings, pair.value))
|
||||
.toEqual (pair.result);
|
||||
}
|
||||
});
|
||||
|
||||
it ('should parse a cookie', () => {
|
||||
for (const pair of parse_cookie_pairs) {
|
||||
expect (extract_cookie (pair.name, pair.header))
|
||||
.toEqual (pair.value);
|
||||
}
|
||||
});
|
||||
});
|
22
test/spec/token_id.ts
Normal file
22
test/spec/token_id.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { generate_token_id, parse_token_id } from '../../lib/token_id';
|
||||
import { clock_finalize, clock_setup } from '../Helper';
|
||||
|
||||
describe ('token_id', () => {
|
||||
beforeAll (() => {
|
||||
clock_setup ();
|
||||
});
|
||||
|
||||
afterAll (() => {
|
||||
clock_finalize ();
|
||||
});
|
||||
|
||||
it ('should always generate valid tokens', () => {
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
const date = new Date;
|
||||
const token_id = generate_token_id (new Date);
|
||||
const parsed = parse_token_id (token_id);
|
||||
expect (parsed.valid_until)
|
||||
.toEqual (date);
|
||||
}
|
||||
});
|
||||
});
|
16
tsconfig.json
Normal file
16
tsconfig.json
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"module": "commonjs",
|
||||
"outDir": "./dist",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": [
|
||||
"lib/**/*.ts",
|
||||
"test/**/*.ts"
|
||||
]
|
||||
}
|
3966
yarn-error.log
3966
yarn-error.log
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user