automatic refresh tokens
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
This commit is contained in:
parent
1188e4573f
commit
22075489c2
@ -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
|
||||
|
12
README.md
12
README.md
@ -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
|
||||
}
|
||||
);
|
||||
|
||||
|
@ -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
|
||||
};
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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",
|
||||
|
@ -111,6 +111,7 @@ describe ('auth handler', () => {
|
||||
}
|
||||
}, {
|
||||
cookie_name: 'cookie_jar',
|
||||
refresh_cookie_name: 'mint_cookies',
|
||||
refresh: {
|
||||
access_token_expires_in: expires_seconds,
|
||||
refresh_token_expires_in: refresh_expires_seconds,
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
@ -20,7 +20,13 @@ describe ('gateway', () => {
|
||||
|
||||
const g = create_gateway ({
|
||||
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) => {
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
Loading…
x
Reference in New Issue
Block a user