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 # 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 ## 2.1.0
- Allow access to Gateway functions like authenticate, get_cookie_auth, get_header_auth, redirect, deny - Allow access to Gateway functions like authenticate, get_cookie_auth, get_header_auth, redirect, deny

View File

@ -1,6 +1,6 @@
# auth-server-helper # auth-server-helper
version: 2.1.x version: 2.2.x
customizable and simple authentication customizable and simple authentication
@ -23,7 +23,12 @@ const {create_gateway} = require('@sapphirecode/auth-server-helper');
const gateway = create_gateway({ const gateway = create_gateway({
redirect_url: '/auth', // if defined, unauthorized requests will be redirected 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 // 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 // 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; refresh_expires_in?: number;
} }
type AuthHandler =
(req: IncomingMessage, res: ServerResponse) => Promise<boolean>;
class AuthRequest { class AuthRequest {
public request: IncomingMessage; public request: IncomingMessage;
public response: ServerResponse; public response: ServerResponse;
@ -47,6 +50,7 @@ class AuthRequest {
public body: string; public body: string;
private _cookie_name?: string; private _cookie_name?: string;
private _refresh_cookie_name?: string;
private _is_successful: boolean; private _is_successful: boolean;
public get is_successful (): boolean { public get is_successful (): boolean {
@ -57,7 +61,8 @@ class AuthRequest {
req: IncomingMessage, req: IncomingMessage,
res: ServerResponse, res: ServerResponse,
body: string, body: string,
cookie?: string cookie?: string,
refresh_cookie?: string
) { ) {
this.request = req; this.request = req;
this.response = res; this.response = res;
@ -67,6 +72,7 @@ class AuthRequest {
this.user = ''; this.user = '';
this.password = ''; this.password = '';
this._cookie_name = cookie; this._cookie_name = cookie;
this._refresh_cookie_name = refresh_cookie;
this._is_successful = false; this._is_successful = false;
} }
@ -102,12 +108,10 @@ class AuthRequest {
expires_in: access_token_expires_in expires_in: access_token_expires_in
}; };
if (typeof this._cookie_name === 'string') { const cookies = [];
this.response.setHeader (
'Set-Cookie', if (typeof this._cookie_name === 'string')
`${this._cookie_name}=${at.signature}` cookies.push (`${this._cookie_name}=${at.signature}`);
);
}
if (include_refresh_token) { if (include_refresh_token) {
if (typeof refresh_token_expires_in !== 'number') if (typeof refresh_token_expires_in !== 'number')
@ -120,6 +124,16 @@ class AuthRequest {
res.refresh_token = rt.signature; res.refresh_token = rt.signature;
res.refresh_expires_in = refresh_token_expires_in; res.refresh_expires_in = refresh_token_expires_in;
result.refresh_token_id = rt.id; result.refresh_token_id = rt.id;
if (typeof this._refresh_cookie_name === 'string')
cookies.push (`${this._refresh_cookie_name}=${rt.signature}`);
}
if (cookies.length > 0) {
this.response.setHeader (
'Set-Cookie',
cookies
);
} }
this._is_successful = true; this._is_successful = true;
@ -194,6 +208,7 @@ interface CreateHandlerOptions {
refresh?: AccessSettings; refresh?: AccessSettings;
modules?: Record<string, AuthRequestHandler>; modules?: Record<string, AuthRequestHandler>;
cookie_name?: string; cookie_name?: string;
refresh_cookie_name?: string;
} }
// eslint-disable-next-line max-lines-per-function // eslint-disable-next-line max-lines-per-function
@ -261,7 +276,13 @@ function process_request (
export default function create_auth_handler ( export default function create_auth_handler (
default_handler: AuthRequestHandler, default_handler: AuthRequestHandler,
options?: CreateHandlerOptions 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 ( return async (
req: IncomingMessage, req: IncomingMessage,
res: ServerResponse 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) const token = (/(?<type>\S+) (?<token>.+)/ui)
.exec (req.headers.authorization as string); .exec (req.headers.authorization as string);
@ -292,5 +319,6 @@ export {
AccessResponse, AccessResponse,
AuthRequest, AuthRequest,
AuthRequestHandler, AuthRequestHandler,
CreateHandlerOptions CreateHandlerOptions,
AuthHandler
}; };

View File

@ -8,6 +8,7 @@
import { IncomingMessage, ServerResponse } from 'http'; import { IncomingMessage, ServerResponse } from 'http';
import { run_regex } from '@sapphirecode/utilities'; import { run_regex } from '@sapphirecode/utilities';
import authority from './Authority'; import authority from './Authority';
import { AuthRequest, AccessSettings } from './AuthHandler';
type AnyFunc = (...args: unknown[]) => unknown; type AnyFunc = (...args: unknown[]) => unknown;
type Gateway = ( type Gateway = (
@ -15,15 +16,33 @@ type Gateway = (
res: ServerResponse, next: AnyFunc res: ServerResponse, next: AnyFunc
) => unknown; ) => unknown;
interface RefreshSettings extends AccessSettings {
leave_open?: never;
redirect_to?: never;
}
interface GatewayOptions { interface GatewayOptions {
redirect_url?: string; redirect_url?: string;
cookie_name?: string; cookie_name?: string;
refresh_cookie_name?: string;
refresh_settings?: RefreshSettings;
}
interface AuthCookies {
access_cookie: string | null;
refresh_cookie: string | null;
} }
class GatewayClass { class GatewayClass {
private _options: GatewayOptions; private _options: GatewayOptions;
public constructor (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; this._options = options;
} }
@ -52,25 +71,33 @@ class GatewayClass {
return auth.groups?.data; return auth.groups?.data;
} }
public get_cookie_auth (req: IncomingMessage): string | null { public get_cookie_auth (req: IncomingMessage): AuthCookies {
if (typeof this._options.cookie_name === 'undefined') const result: AuthCookies = {
return null; access_cookie: null,
let auth = null; refresh_cookie: null
};
const cookie_regex = /(?:^|;)\s*(?<name>[^;=]+)=(?<value>[^;]+)/gu;
run_regex ( run_regex (
/(?:^|;)\s*(?<name>[^;=]+)=(?<value>[^;]+)/gu, cookie_regex,
req.headers.cookie, req.headers.cookie,
(res: RegExpMatchArray) => { (res: RegExpMatchArray) => {
if (res.groups?.name === this._options.cookie_name) 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 { public authenticate (req: IncomingMessage): boolean {
const cookies = this.get_cookie_auth (req);
let auth = this.get_header_auth (req); let auth = this.get_header_auth (req);
if (auth === null) if (auth === null)
auth = this.get_cookie_auth (req); auth = cookies.access_cookie;
if (auth === null) if (auth === null)
return false; return false;
@ -82,13 +109,55 @@ class GatewayClass {
return ver.authorized; 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, req: IncomingMessage,
res: ServerResponse, res: ServerResponse,
next: AnyFunc next: AnyFunc
): unknown { ): Promise<unknown> {
if (this.authenticate (req)) if (this.authenticate (req))
return next (); return next ();
if (await this.try_refresh (req, res))
return next ();
return this.redirect (res); return this.redirect (res);
} }
} }

View File

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

View File

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

View File

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

@ -20,7 +20,13 @@ describe ('gateway', () => {
const g = create_gateway ({ const g = create_gateway ({
redirect_url: 'http://localhost/auth', redirect_url: 'http://localhost/auth',
cookie_name: 'cookie_jar' cookie_name: 'cookie_jar',
refresh_cookie_name: 'mint_cookies',
refresh_settings: {
access_token_expires_in: 600,
include_refresh_token: true,
refresh_token_expires_in: 3600
}
}); });
server = http.createServer ((req, res) => { server = http.createServer ((req, res) => {
@ -70,6 +76,22 @@ describe ('gateway', () => {
.toEqual (token.id); .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 () => { it ('should correctly deliver token data', async () => {
const token = await authority.sign ('access_token', 60, { data: 'foobar' }); const token = await authority.sign ('access_token', 60, { data: 'foobar' });
const resp = await get ({ authorization: `Bearer ${token.signature}` }); const resp = await get ({ authorization: `Bearer ${token.signature}` });
@ -137,4 +159,11 @@ describe ('gateway', () => {
expect (resp.headers.location) expect (resp.headers.location)
.toEqual ('http://localhost/auth'); .toEqual ('http://localhost/auth');
}); });
it ('should disallow access and refresh cookies with the same name', () => {
expect (() => {
create_gateway ({ cookie_name: 'foo', refresh_cookie_name: 'foo' });
})
.toThrowError ('access and refresh cookies cannot have the same name');
});
}); });