From 22075489c280a9fc1e43a492b74eb55b44199821 Mon Sep 17 00:00:00 2001 From: Timo Hocker <35867059+TimoHocker@users.noreply.github.com> Date: Tue, 4 Jan 2022 21:32:04 +0100 Subject: [PATCH] automatic refresh tokens --- CHANGELOG.md | 5 +++ README.md | 12 ++++-- lib/AuthHandler.ts | 48 +++++++++++++++++----- lib/Gateway.ts | 89 +++++++++++++++++++++++++++++++++++----- lib/index.ts | 4 +- package.json | 2 +- test/spec/AuthHandler.ts | 25 ++++++++++- test/spec/Gateway.ts | 33 ++++++++++++++- 8 files changed, 189 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c9e4600..bcd8e5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 7e62444..f0bb077 100644 --- a/README.md +++ b/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 } ); diff --git a/lib/AuthHandler.ts b/lib/AuthHandler.ts index 0f6d264..04a4836 100644 --- a/lib/AuthHandler.ts +++ b/lib/AuthHandler.ts @@ -31,6 +31,9 @@ interface AccessResponse { refresh_expires_in?: number; } +type AuthHandler = + (req: IncomingMessage, res: ServerResponse) => Promise; + 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; 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 = (/(?\S+) (?.+)/ui) .exec (req.headers.authorization as string); @@ -292,5 +319,6 @@ export { AccessResponse, AuthRequest, AuthRequestHandler, - CreateHandlerOptions + CreateHandlerOptions, + AuthHandler }; diff --git a/lib/Gateway.ts b/lib/Gateway.ts index b60830f..505c28d 100644 --- a/lib/Gateway.ts +++ b/lib/Gateway.ts @@ -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*(?[^;=]+)=(?[^;]+)/gu; + run_regex ( - /(?:^|;)\s*(?[^;=]+)=(?[^;]+)/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 { + 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; + 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 { if (this.authenticate (req)) return next (); + if (await this.try_refresh (req, res)) + return next (); return this.redirect (res); } } diff --git a/lib/index.ts b/lib/index.ts index 6dc6aa0..19bcf04 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -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, diff --git a/package.json b/package.json index deddf24..4811f66 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/test/spec/AuthHandler.ts b/test/spec/AuthHandler.ts index f888737..2004ca1 100644 --- a/test/spec/AuthHandler.ts +++ b/test/spec/AuthHandler.ts @@ -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'); + }); }); diff --git a/test/spec/Gateway.ts b/test/spec/Gateway.ts index 25098db..5ef3137 100644 --- a/test/spec/Gateway.ts +++ b/test/spec/Gateway.ts @@ -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'); + }); });