Compare commits

...

44 Commits

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

View File

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

View File

@ -1,5 +1,46 @@
# Changelog
## 4.1.0
- Permission Management
- Gateway function to read connection info
## 4.0.0
- Blacklist entries can now be synchronized through redis
BREAKING: Blacklist functions are now asynchronous
## 3.3.0
- Verification Keys can now be synchronized through redis
## 3.2.0
- Logout function
## 3.1.0
- Option to enable body parsing
## 3.0.0
- Allows Cookies Parameters to be set
BREAKING:
- All cookie_name and refresh_cookie_name properties have been renamed to cookie and refresh_cookie and are now a settings object instead of a string
## 2.2.0
- Allow refresh tokens to be sent on a separate cookie
- Automatic token refresh if the access token is expired and the cookie header contains a valid refresh token
## 2.1.0
- Allow access to Gateway functions like authenticate, get_cookie_auth, get_header_auth, redirect, deny
- Allow Gateway to deny a request in case no redirect url is specified
## 2.0.0
Complete redesign

171
README.md
View File

@ -1,6 +1,6 @@
# auth-server-helper
version: 2.0.0
version: 4.1.x
customizable and simple authentication
@ -22,8 +22,13 @@ yarn:
const {create_gateway} = require('@sapphirecode/auth-server-helper');
const gateway = create_gateway({
redirect_url: '/auth',
cookie_name: 'auth_cookie', // if defined, access tokens will be read from this cookie
redirect_url: '/auth', // if defined, unauthorized requests will be redirected
cookie: { name: 'auth_cookie' }, // if defined, access tokens will be read from or written to this cookie,
refresh_cookie: { name: 'refresh_cookie' }, // if defined, refresh tokens will be read and used to automatically refresh client tokens (requires the refresh_settings attribute)
refresh_settings: {
// same as settings for allow_access under section 2
// the options data, redirect_to and leave_open are not supported here
}
});
// express
@ -40,6 +45,23 @@ http.createServer((main_req, main_res) =>
the gateway will forward any authorized requests to the next handler and
redirect all others to the specified url
#### 1.1. Creating a gateway for manual processing of requests
```js
const {GatewayClass} = require('@sapphirecode/auth-server-helper');
const gateway = new GatewayClass({ /* options */ }); // options are the same as for create_gateway above
// process a request
if (gateway.authenticate(http_request)) { // returns true if request is valid and sets req.connection.token_id and .token_data
console.log('access granted');
} else {
gateway.redirect(response); // redirects the client, triggers deny if no redirect_url was set in options
// or
gateway.deny(response); // sends status 403
}
```
### 2. creating the auth endpoint
```js
@ -81,7 +103,9 @@ const handler = create_auth_handler(
// the same works in handlers after the gateway, information is always stored in request.connection.auth
},
},
cookie_name: 'auth_cookie', // if defined, access tokens will be stored in this cookie
cookie: { name: 'auth_cookie' }, // if defined, access tokens will be stored in this cookie,
refresh_cookie: { name: 'refresh_cookie' }, // if defined, refresh tokens will be stored in this cookie
parse_body: true // read the request body into a string (default false)
}
);
@ -90,18 +114,79 @@ app.use(handler);
// node http
// ... create server, on path /auth run the handler
handler(req, res);
handler(req, res); // the handler will also return true if allow_access or allow_part was called
```
after the auth handler, the request will be completed, no additional content
should be served here.
should be served here. (Read 2.1 for info on disabling this)
#### 2.1. Processing Auth Requests without closing the response object
to prevent the auth handler from closing the response object you can provide
additional options on each of the allow/deny functions.
```js
allow_access({leave_open: true, ...});
allow_part(
60,
'some_module',
{foo: 'bar'},
true // additional flag to leave request open
);
invalid('error description', true);
deny(true);
```
if this flag is set, no data will be written to the response body and no data
will be sent. Status code and Headers will still be set.
### Defining Custom Cookie Settings
By default all cookies will be sent with 'Secure; HttpOnly; SameSite=Strict'
Attributes
In the appropriate settings object, you can set the following options:
```js
{
name: 'foo', // name of the cookies
secure: true, // option to enable or disable the Secure option default: true
http_only: true, // option to enable or disable HttpOnly default: true
same_site: 'Strict', // SameSite property (Strict, Lax or None) default: 'Strict'. Set this to null to disable
expires: 'Mon, 10 Jan 2022 09:28:00 GMT', // Expiry date of the cookie
max_age: 600, // Maximum age in Seconds
domain: 'example.com', // Domain property
path: '/cookies_here' // Path property
}
```
For Documentation on the different Cookie Attributes see
<https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#creating_cookies>
### Invalidating tokens after they are delivered to the client
```js
const {blacklist} = require('@sapphirecode/auth-server-helper');
blacklist.add_signature(token_id); // the token id is returned from any function that creates tokens
await blacklist.add_signature(token_id); // the token id is returned from any function that creates tokens
```
#### Logout function
```js
const {GatewayClass} = require('@sapphirecode/auth-server-helper');
const gateway = new GatewayClass({ /* options */ });
// create a new express route
app.get('logout', (req, res) => {
// call the gateway's logout function
gateway.logout(req);
// respond ok
res.status(200);
res.end();
});
```
### Exporting and importing public keys to validate tokens across server instances
@ -116,6 +201,15 @@ const export = keystore.export_verification_data();
keystore.import_verification_data(export);
```
These keys can also be live synchronized with redis to allow sessions to be
shared between servers
```js
const {keystore} = require('@sapphirecode/auth-server-helper');
keystore.sync_redis('redis://localhost');
```
### Exporting and importing blacklist entries across server instances
```js
@ -128,6 +222,69 @@ const export = blacklist.export_blacklist();
blacklist.import_blacklist(export);
```
### Clearing Keystore and Blacklist
Resetting the Keystore instance generates a new instance id and deletes all
imported or generated keys.
```js
const {keystore, blacklist} = require('@sapphirecode/auth-server-helper');
// clear keystore
keystore.reset_instance();
// clear blacklist
await blacklist.clear();
// clear blacklist items older than 10 seconds
await blacklist.clear(Date.now() - 10000);
```
### Setting and checking permissions
When allowing access to a client a list of permissions can be added. Permissions
are case sensitive.
```js
allow_access({permissions: ['foo','bar'], ...})
```
The gateway can be told to check those permissions before forwarding a request.
```js
const gateway = new GatewayClass({
require_permissions: ['foo'], // Only clients with the 'foo' permission will be granted access
});
```
additional checks can be run later
```js
(req, res) => {
const has_both = gateway.check_permissions(req, ['foo', 'bar']); // returns true if both permissions are set
const has_bar = gateway.has_permission(req, 'bar'); // returns true if permission 'bar' is set
};
```
### Reading connection info
Data like the used token id, custom data and permissions can be read from
`req.connection.auth` or using the function `gateway.get_info(req)`
```js
const info = gateway.get_info(req);
console.log(info);
/*
{
token_id: 'foo',
data: {}, // custom data
permissions: ['foo','bar']
}
*/
```
## License
MIT © Timo Hocker <timo@scode.ovh>

View File

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

View File

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

View File

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

View File

@ -5,51 +5,142 @@
* Created by Timo Hocker <timo@scode.ovh>, December 2020
*/
import { debug } from './debug';
import { redis_blacklist_store } from './RedisData/RedisBlacklistStore';
import { parse_token_id } from './token_id';
const logger = debug ('blacklist');
interface Signature {
hash: string;
iat: Date;
token_id: string;
iat: number;
valid_until: Date;
}
interface ExportedSignature {
token_id: string;
iat: number;
}
class Blacklist {
private _signatures: Signature[];
private _interval: NodeJS.Timeout;
public constructor () {
this._signatures = [];
this._interval = setInterval (
this.garbage_collect.bind (this),
3600000
);
}
public clear_before (date: Date):void {
public async clear (
before: number = Number.POSITIVE_INFINITY
): Promise<void> {
logger.extend ('clear') ('clearing blacklist');
for (let i = this._signatures.length - 1; i >= 0; i--) {
if (this._signatures[i].iat < date)
this._signatures.splice (i, 1);
if (this._signatures[i].iat < before) {
// eslint-disable-next-line no-await-in-loop
await this.remove_signature (i);
}
}
}
public add_signature (hash: string):void {
this._signatures.push ({ iat: (new Date), hash });
public async add_signature (token_id: string): Promise<void> {
logger.extend ('add_signature') ('blacklisting signature %s', token_id);
const parsed = parse_token_id (token_id);
this._signatures.push ({
iat: Date.now (),
token_id,
valid_until: parsed.valid_until
});
await redis_blacklist_store.add (token_id, parsed.valid_until);
}
public remove_signature (hash:string):void {
for (let i = this._signatures.length - 1; i >= 0; i--) {
if (this._signatures[i].hash === hash)
this._signatures.splice (i, 1);
public async remove_signature (signature: number | string): Promise<void> {
const log = logger.extend ('remove_signature');
log ('removing signature from blacklist %s', signature);
let key = '';
if (typeof signature === 'string') {
log ('received string, searching through signatures');
key = signature;
for (let i = this._signatures.length - 1; i >= 0; i--) {
if (this._signatures[i].token_id === signature) {
log ('removing sigature %s at %d', signature, i);
this._signatures.splice (i, 1);
}
}
}
else {
log (
'received index, removing signature %s at index %s',
this._signatures[signature].token_id,
signature
);
key = this._signatures[signature].token_id;
this._signatures.splice (signature, 1);
}
await redis_blacklist_store.remove (key);
}
public is_valid (hash: string):boolean {
public async is_valid (hash: string): Promise<boolean> {
const log = logger.extend ('is_valid');
log ('checking signature for blacklist entry %s', hash);
for (const sig of this._signatures) {
if (sig.hash === hash)
if (sig.token_id === hash) {
log ('found matching blacklist entry');
return false;
}
}
log ('signature is not blacklisted locally, checking redis');
if (await redis_blacklist_store.get (hash)) {
log ('signature is blacklisted in redis');
return false;
}
log ('signature is not blacklisted');
return true;
}
public export_blacklist (): Signature[] {
return this._signatures;
public export_blacklist (): ExportedSignature[] {
logger.extend ('export_blacklist') ('exporting blacklist');
return this._signatures.map ((v) => ({
iat: v.iat,
token_id: v.token_id
}));
}
public import_blacklist (data: Signature[]): void {
this._signatures.push (...data);
public import_blacklist (data: ExportedSignature[]): void {
logger.extend ('import_blacklist') (
'importing %d blacklist entries',
data.length
);
for (const token of data) {
const parsed = parse_token_id (token.token_id);
this._signatures.push ({
token_id: token.token_id,
iat: token.iat,
valid_until: parsed.valid_until
});
}
}
public sync_redis (url: string): void {
redis_blacklist_store.connect (url);
}
private async garbage_collect (): Promise<void> {
const log = logger.extend ('garbage_collect');
const time = new Date;
log ('removing signatures expired before', time);
for (let i = this._signatures.length - 1; i >= 0; i--) {
if (this._signatures[i].valid_until < time) {
log ('signature %s expired', this._signatures[i].token_id);
await this.remove_signature (i);
}
}
}
}

View File

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

23
lib/Key.ts Normal file
View File

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

View File

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

64
lib/Redis.ts Normal file
View File

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

View File

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

View File

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

86
lib/cookie.ts Normal file
View File

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

15
lib/debug.ts Normal file
View File

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

View File

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

21
lib/token_id.ts Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

View File

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

3144
yarn.lock

File diff suppressed because it is too large Load Diff