automatic refresh tokens
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
Timo Hocker 2022-01-04 21:32:04 +01:00
parent 1188e4573f
commit 22075489c2
Signed by: Timo
GPG Key ID: DFAC2CF4E1D1BEC9
8 changed files with 189 additions and 29 deletions

View File

@ -1,5 +1,10 @@
# Changelog
## 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

View File

@ -1,6 +1,6 @@
# auth-server-helper
version: 2.1.x
version: 2.2.x
customizable and simple authentication
@ -23,7 +23,12 @@ 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 this cookie
cookie_name: 'auth_cookie', // if defined, access tokens will be read from 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 redirect_to and leave_open are not supported here
}
});
// express
@ -98,7 +103,8 @@ 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
}
);

View File

@ -31,6 +31,9 @@ interface AccessResponse {
refresh_expires_in?: number;
}
type AuthHandler =
(req: IncomingMessage, res: ServerResponse) => Promise<boolean>;
class AuthRequest {
public request: IncomingMessage;
public response: ServerResponse;
@ -47,6 +50,7 @@ class AuthRequest {
public body: string;
private _cookie_name?: string;
private _refresh_cookie_name?: string;
private _is_successful: boolean;
public get is_successful (): boolean {
@ -57,7 +61,8 @@ class AuthRequest {
req: IncomingMessage,
res: ServerResponse,
body: string,
cookie?: string
cookie?: string,
refresh_cookie?: string
) {
this.request = req;
this.response = res;
@ -67,6 +72,7 @@ class AuthRequest {
this.user = '';
this.password = '';
this._cookie_name = cookie;
this._refresh_cookie_name = refresh_cookie;
this._is_successful = false;
}
@ -102,12 +108,10 @@ 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_name === 'string')
cookies.push (`${this._cookie_name}=${at.signature}`);
if (include_refresh_token) {
if (typeof refresh_token_expires_in !== 'number')
@ -120,6 +124,16 @@ 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_name === 'string')
cookies.push (`${this._refresh_cookie_name}=${rt.signature}`);
}
if (cookies.length > 0) {
this.response.setHeader (
'Set-Cookie',
cookies
);
}
this._is_successful = true;
@ -194,6 +208,7 @@ interface CreateHandlerOptions {
refresh?: AccessSettings;
modules?: Record<string, AuthRequestHandler>;
cookie_name?: string;
refresh_cookie_name?: string;
}
// eslint-disable-next-line max-lines-per-function
@ -261,7 +276,13 @@ function process_request (
export default function create_auth_handler (
default_handler: AuthRequestHandler,
options?: CreateHandlerOptions
) {
): AuthHandler {
if (
typeof options?.cookie_name !== '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
@ -276,7 +297,13 @@ export default function create_auth_handler (
});
});
const request = new AuthRequest (req, res, body, options?.cookie_name);
const request = new AuthRequest (
req,
res,
body,
options?.cookie_name,
options?.refresh_cookie_name
);
const token = (/(?<type>\S+) (?<token>.+)/ui)
.exec (req.headers.authorization as string);
@ -292,5 +319,6 @@ export {
AccessResponse,
AuthRequest,
AuthRequestHandler,
CreateHandlerOptions
CreateHandlerOptions,
AuthHandler
};

View File

@ -8,6 +8,7 @@
import { IncomingMessage, ServerResponse } from 'http';
import { run_regex } from '@sapphirecode/utilities';
import authority from './Authority';
import { AuthRequest, AccessSettings } from './AuthHandler';
type AnyFunc = (...args: unknown[]) => unknown;
type Gateway = (
@ -15,15 +16,33 @@ type Gateway = (
res: ServerResponse, next: AnyFunc
) => unknown;
interface RefreshSettings extends AccessSettings {
leave_open?: never;
redirect_to?: never;
}
interface GatewayOptions {
redirect_url?: string;
cookie_name?: string;
refresh_cookie_name?: string;
refresh_settings?: RefreshSettings;
}
interface AuthCookies {
access_cookie: string | null;
refresh_cookie: string | null;
}
class GatewayClass {
private _options: GatewayOptions;
public constructor (options: GatewayOptions = {}) {
if (
typeof options.cookie_name === 'string'
&& options.cookie_name === options.refresh_cookie_name
)
throw new Error ('access and refresh cookies cannot have the same name');
this._options = options;
}
@ -52,25 +71,33 @@ class GatewayClass {
return auth.groups?.data;
}
public get_cookie_auth (req: IncomingMessage): string | null {
if (typeof this._options.cookie_name === 'undefined')
return null;
let auth = null;
public get_cookie_auth (req: IncomingMessage): AuthCookies {
const result: AuthCookies = {
access_cookie: null,
refresh_cookie: null
};
const cookie_regex = /(?:^|;)\s*(?<name>[^;=]+)=(?<value>[^;]+)/gu;
run_regex (
/(?:^|;)\s*(?<name>[^;=]+)=(?<value>[^;]+)/gu,
cookie_regex,
req.headers.cookie,
(res: RegExpMatchArray) => {
if (res.groups?.name === this._options.cookie_name)
auth = res.groups?.value;
result.access_cookie = res.groups?.value as string;
else if (res.groups?.name === this._options.refresh_cookie_name)
result.refresh_cookie = res.groups?.value as string;
}
);
return auth;
return result;
}
public authenticate (req: IncomingMessage): boolean {
const cookies = this.get_cookie_auth (req);
let auth = this.get_header_auth (req);
if (auth === null)
auth = this.get_cookie_auth (req);
auth = cookies.access_cookie;
if (auth === null)
return false;
@ -82,13 +109,55 @@ class GatewayClass {
return ver.authorized;
}
public process_request (
public async try_refresh (
req: IncomingMessage,
res: ServerResponse
): Promise<boolean> {
if (
typeof this._options.refresh_cookie_name === 'undefined'
|| typeof this._options.refresh_settings === 'undefined'
)
return false;
const refresh = this.get_cookie_auth (req).refresh_cookie;
if (refresh === null)
return false;
const ver = authority.verify (refresh);
if (ver.type === 'refresh_token' && ver.valid) {
const auth_request = new AuthRequest (
req,
res,
''
, this._options.cookie_name,
this._options.refresh_cookie_name
);
const refresh_result = await auth_request.allow_access ({
...this._options.refresh_settings,
leave_open: true
});
const con = req.connection as unknown as Record<string, unknown>;
con.auth = {
token_id: refresh_result.access_token_id,
token_data: this._options.refresh_settings.data
};
return true;
}
return false;
}
public async process_request (
req: IncomingMessage,
res: ServerResponse,
next: AnyFunc
): unknown {
): Promise<unknown> {
if (this.authenticate (req))
return next ();
if (await this.try_refresh (req, res))
return next ();
return this.redirect (res);
}
}

View File

@ -12,7 +12,8 @@ import create_auth_handler, {
AuthRequestHandler,
AuthRequest,
AccessSettings,
AccessResult
AccessResult,
AuthHandler
} from './AuthHandler';
import authority, {
VerificationResult,
@ -44,6 +45,7 @@ export {
CreateHandlerOptions,
AuthRequestHandler,
AuthRequest,
AuthHandler,
AccessSettings,
AccessResult,
VerificationResult,

View File

@ -1,6 +1,6 @@
{
"name": "@sapphirecode/auth-server-helper",
"version": "2.1.4",
"version": "2.2.0",
"main": "dist/index.js",
"author": {
"name": "Timo Hocker",

View File

@ -110,8 +110,9 @@ describe ('auth handler', () => {
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
@ -158,6 +159,8 @@ describe ('auth handler', () => {
.toEqual ('bearer');
expect (resp1.headers['set-cookie'])
.toContain (`cookie_jar=${res1.at}`);
expect (resp1.headers['set-cookie'])
.toContain (`mint_cookies=${res1.rt}`);
check_token (res1.at as string, 'access_token');
expect (res1.data.expires_in)
@ -177,6 +180,8 @@ describe ('auth handler', () => {
.toEqual ('bearer');
expect (resp2.headers['set-cookie'])
.toContain (`cookie_jar=${res2.at}`);
expect (resp2.headers['set-cookie'])
.toContain (`mint_cookies=${res2.rt}`);
check_token (res2.at as string, 'access_token');
expect (res2.data.expires_in)
@ -207,6 +212,8 @@ describe ('auth handler', () => {
.toEqual ('bearer');
expect (resp1.headers['set-cookie'])
.toContain (`cookie_jar=${res1.at}`);
expect (resp1.headers['set-cookie'])
.toContain (`mint_cookies=${res1.rt}`);
check_token (res1.at as string, 'access_token');
expect (res1.data.expires_in)
@ -230,6 +237,8 @@ describe ('auth handler', () => {
.toEqual ('bearer');
expect (resp1.headers['set-cookie'])
.toContain (`cookie_jar=${res1.at}`);
expect (resp1.headers['set-cookie'])
.toContain (`mint_cookies=${res1.rt}`);
check_token (res1.at as string, 'access_token');
expect (res1.data.expires_in)
@ -301,6 +310,8 @@ describe ('auth handler', () => {
.toEqual ('bearer');
expect (resp2.headers['set-cookie'])
.toContain (`cookie_jar=${res2.at}`);
expect (resp2.headers['set-cookie'])
.toContain (`mint_cookies=${res2.rt}`);
check_token (res2.at as string, 'access_token');
expect (res2.data.expires_in)
@ -354,4 +365,14 @@ describe ('auth handler', () => {
expect (signature).not.toEqual ('');
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

@ -19,8 +19,14 @@ describe ('gateway', () => {
clock_setup ();
const g = create_gateway ({
redirect_url: 'http://localhost/auth',
cookie_name: 'cookie_jar'
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) => {
@ -70,6 +76,22 @@ describe ('gateway', () => {
.toEqual (token.id);
});
it ('should automatically return new tokens', async () => {
const token = await authority.sign ('access_token', 60);
const refresh = await authority.sign ('refresh_token', 3600);
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);
});
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}` });
@ -137,4 +159,11 @@ 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');
});
});