diff --git a/CHANGELOG.md b/CHANGELOG.md index bcd8e5c..53c6f2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 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 diff --git a/README.md b/README.md index 74177ca..e1ad6ac 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # auth-server-helper -version: 2.2.x +version: 3.0.x customizable and simple authentication @@ -23,8 +23,8 @@ 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, - refresh_cookie_name: 'refresh_cookie', // if defined, refresh tokens will be read and used to automatically refresh client tokens (requires the refresh_settings attribute) + 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 @@ -103,8 +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, - refresh_cookie_name: 'refresh_cookie' // if defined, refresh 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 } ); @@ -138,6 +138,27 @@ 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 + ### Invalidating tokens after they are delivered to the client ```js diff --git a/lib/AuthHandler.ts b/lib/AuthHandler.ts index fc992fc..0950f29 100644 --- a/lib/AuthHandler.ts +++ b/lib/AuthHandler.ts @@ -9,6 +9,7 @@ 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'); @@ -37,10 +38,6 @@ interface AccessResponse { type AuthHandler = (req: IncomingMessage, res: ServerResponse) => Promise; -function build_cookie (name: string, value: string): string { - return `${name}=${value}; Secure; HttpOnly; SameSite=Strict`; -} - class AuthRequest { public request: IncomingMessage; public response: ServerResponse; @@ -56,8 +53,8 @@ class AuthRequest { public body: string; - private _cookie_name?: string; - private _refresh_cookie_name?: string; + private _cookie?: CookieSettings; + private _refresh_cookie?: CookieSettings; private _is_successful: boolean; public get is_successful (): boolean { @@ -68,8 +65,8 @@ class AuthRequest { req: IncomingMessage, res: ServerResponse, body: string, - cookie?: string, - refresh_cookie?: string + cookie?: CookieSettings, + refresh_cookie?: CookieSettings ) { this.request = req; this.response = res; @@ -78,8 +75,8 @@ class AuthRequest { this.is_bearer = false; this.user = ''; this.password = ''; - this._cookie_name = cookie; - this._refresh_cookie_name = refresh_cookie; + this._cookie = cookie; + this._refresh_cookie = refresh_cookie; this._is_successful = false; logger ('started processing new auth request'); } @@ -106,7 +103,6 @@ class AuthRequest { const at = await auth.sign ( 'access_token', access_token_expires_in, - { data } ); const result: AccessResult = { access_token_id: at.id }; @@ -119,8 +115,8 @@ class AuthRequest { const cookies = []; - if (typeof this._cookie_name === 'string') - cookies.push (build_cookie (this._cookie_name, at.signature)); + if (typeof this._cookie !== 'undefined') + cookies.push (build_cookie (this._cookie, at.signature)); if (include_refresh_token) { logger ('including refresh token'); @@ -135,9 +131,8 @@ class AuthRequest { res.refresh_expires_in = refresh_token_expires_in; result.refresh_token_id = rt.id; - if (typeof this._refresh_cookie_name === 'string') - // eslint-disable-next-line max-len - cookies.push (build_cookie (this._refresh_cookie_name, rt.signature)); + if (typeof this._refresh_cookie !== 'undefined') + cookies.push (build_cookie (this._refresh_cookie, rt.signature)); } if (cookies.length > 0) { @@ -228,8 +223,8 @@ type AuthRequestHandler = (req: AuthRequest) => Promise | void; interface CreateHandlerOptions { refresh?: AccessSettings; modules?: Record; - cookie_name?: string; - refresh_cookie_name?: string; + cookie?: CookieSettings; + refresh_cookie?: CookieSettings; } // eslint-disable-next-line max-lines-per-function @@ -308,8 +303,9 @@ export default function create_auth_handler ( ): AuthHandler { logger ('creating new auth handler'); if ( - typeof options?.cookie_name !== 'undefined' - && options.cookie_name === options.refresh_cookie_name + 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'); @@ -331,8 +327,8 @@ export default function create_auth_handler ( req, res, body, - options?.cookie_name, - options?.refresh_cookie_name + options?.cookie, + options?.refresh_cookie ); const token = (/(?\S+) (?.+)/ui) .exec (req.headers.authorization as string); diff --git a/lib/Gateway.ts b/lib/Gateway.ts index 082b22e..032fd6d 100644 --- a/lib/Gateway.ts +++ b/lib/Gateway.ts @@ -9,7 +9,7 @@ import { IncomingMessage, ServerResponse } from 'http'; import authority from './Authority'; import { AuthRequest, AccessSettings } from './AuthHandler'; import { debug } from './debug'; -import { extract_cookie } from './cookie'; +import { extract_cookie, CookieSettings } from './cookie'; const logger = debug ('gateway'); @@ -27,8 +27,8 @@ interface RefreshSettings extends AccessSettings { interface GatewayOptions { redirect_url?: string; - cookie_name?: string; - refresh_cookie_name?: string; + cookie?: CookieSettings; + refresh_cookie?: CookieSettings; refresh_settings?: RefreshSettings; } @@ -38,8 +38,9 @@ class GatewayClass { public constructor (options: GatewayOptions = {}) { logger ('creating new gateway'); if ( - typeof options.cookie_name === 'string' - && options.cookie_name === options.refresh_cookie_name + 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'); @@ -80,7 +81,7 @@ class GatewayClass { logger ('authenticating incoming request'); let auth = this.get_header_auth (req); if (auth === null) - auth = extract_cookie (this._options.cookie_name, req.headers.cookie); + auth = extract_cookie (this._options.cookie?.name, req.headers.cookie); if (auth === null) { logger ('found no auth token'); return false; @@ -101,7 +102,7 @@ class GatewayClass { res: ServerResponse ): Promise { if ( - typeof this._options.refresh_cookie_name === 'undefined' + typeof this._options.refresh_cookie === 'undefined' || typeof this._options.refresh_settings === 'undefined' ) return false; @@ -109,7 +110,7 @@ class GatewayClass { logger ('trying to apply refresh token'); const refresh = extract_cookie ( - this._options.refresh_cookie_name, + this._options.refresh_cookie.name, req.headers.cookie ); if (refresh === null) { @@ -124,8 +125,8 @@ class GatewayClass { req, res, '' - , this._options.cookie_name, - this._options.refresh_cookie_name + , this._options.cookie, + this._options.refresh_cookie ); const refresh_result = await auth_request.allow_access ({ ...this._options.refresh_settings, diff --git a/lib/cookie.ts b/lib/cookie.ts index f1c5c3f..51ef669 100644 --- a/lib/cookie.ts +++ b/lib/cookie.ts @@ -3,8 +3,51 @@ import { debug } from './debug'; const logger = debug ('cookies'); -function build_cookie (name: string, value: string): string { - return `${name}=${value}; Secure; HttpOnly; SameSite=Strict`; +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 = { + 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 ( @@ -32,4 +75,4 @@ function extract_cookie ( return result; } -export { build_cookie, extract_cookie }; +export { build_cookie, extract_cookie, SameSiteValue, CookieSettings }; diff --git a/lib/index.ts b/lib/index.ts index ea798e1..4b0e293 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -34,6 +34,10 @@ import keystore, { KeyStore, KeyStoreExport, LabelledKey, Key } from './KeyStore'; +import { + CookieSettings, + SameSiteValue +} from './cookie'; export { create_gateway, @@ -63,5 +67,7 @@ export { KeyStore, KeyStoreExport, LabelledKey, - Key + Key, + CookieSettings, + SameSiteValue }; diff --git a/package.json b/package.json index a80a3e1..0a8fad4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@sapphirecode/auth-server-helper", - "version": "2.2.6", + "version": "3.0.0", "main": "dist/index.js", "author": { "name": "Timo Hocker", diff --git a/test/spec/AuthHandler.ts b/test/spec/AuthHandler.ts index ae969b8..0840c62 100644 --- a/test/spec/AuthHandler.ts +++ b/test/spec/AuthHandler.ts @@ -111,8 +111,8 @@ describe ('auth handler', () => { req.deny (); } }, { - cookie_name: 'cookie_jar', - refresh_cookie_name: 'mint_cookies', + cookie: { name: 'cookie_jar' }, + refresh_cookie: { name: 'mint_cookies' }, refresh: { access_token_expires_in: expires_seconds, refresh_token_expires_in: refresh_expires_seconds, @@ -159,9 +159,9 @@ describe ('auth handler', () => { expect (res1.data.token_type) .toEqual ('bearer'); expect (resp1.headers['set-cookie']) - .toContain (build_cookie ('cookie_jar', res1.at as string)); + .toContain (build_cookie ({ name: 'cookie_jar' }, res1.at as string)); expect (resp1.headers['set-cookie']) - .toContain (build_cookie ('mint_cookies', res1.rt as string)); + .toContain (build_cookie ({ name: 'mint_cookies' }, res1.rt as string)); check_token (res1.at as string, 'access_token'); expect (res1.data.expires_in) @@ -180,9 +180,9 @@ describe ('auth handler', () => { expect (res2.data.token_type) .toEqual ('bearer'); expect (resp2.headers['set-cookie']) - .toContain (build_cookie ('cookie_jar', res2.at as string)); + .toContain (build_cookie ({ name: 'cookie_jar' }, res2.at as string)); expect (resp2.headers['set-cookie']) - .toContain (build_cookie ('mint_cookies', res2.rt as string)); + .toContain (build_cookie ({ name: 'mint_cookies' }, res2.rt as string)); check_token (res2.at as string, 'access_token'); expect (res2.data.expires_in) @@ -212,9 +212,9 @@ describe ('auth handler', () => { expect (res1.data.token_type) .toEqual ('bearer'); expect (resp1.headers['set-cookie']) - .toContain (build_cookie ('cookie_jar', res1.at as string)); + .toContain (build_cookie ({ name: 'cookie_jar' }, res1.at as string)); expect (resp1.headers['set-cookie']) - .toContain (build_cookie ('mint_cookies', res1.rt as string)); + .toContain (build_cookie ({ name: 'mint_cookies' }, res1.rt as string)); check_token (res1.at as string, 'access_token'); expect (res1.data.expires_in) @@ -237,9 +237,9 @@ describe ('auth handler', () => { expect (res1.data.token_type) .toEqual ('bearer'); expect (resp1.headers['set-cookie']) - .toContain (build_cookie ('cookie_jar', res1.at as string)); + .toContain (build_cookie ({ name: 'cookie_jar' }, res1.at as string)); expect (resp1.headers['set-cookie']) - .toContain (build_cookie ('mint_cookies', res1.rt as string)); + .toContain (build_cookie ({ name: 'mint_cookies' }, res1.rt as string)); check_token (res1.at as string, 'access_token'); expect (res1.data.expires_in) @@ -310,9 +310,9 @@ describe ('auth handler', () => { expect (res2.data.token_type) .toEqual ('bearer'); expect (resp2.headers['set-cookie']) - .toContain (build_cookie ('cookie_jar', res2.at as string)); + .toContain (build_cookie ({ name: 'cookie_jar' }, res2.at as string)); expect (resp2.headers['set-cookie']) - .toContain (build_cookie ('mint_cookies', res2.rt as string)); + .toContain (build_cookie ({ name: 'mint_cookies' }, res2.rt as string)); check_token (res2.at as string, 'access_token'); expect (res2.data.expires_in) @@ -368,8 +368,8 @@ describe ('auth handler', () => { it ('should disallow access and refresh cookies with the same name', () => { expect (() => { create_auth_handler (() => Promise.resolve (), { - cookie_name: 'foo', - refresh_cookie_name: 'foo' + 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 5ef3137..8b84679 100644 --- a/test/spec/Gateway.ts +++ b/test/spec/Gateway.ts @@ -19,9 +19,9 @@ describe ('gateway', () => { clock_setup (); const g = create_gateway ({ - redirect_url: 'http://localhost/auth', - cookie_name: 'cookie_jar', - refresh_cookie_name: 'mint_cookies', + 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, @@ -162,7 +162,10 @@ describe ('gateway', () => { it ('should disallow access and refresh cookies with the same name', () => { expect (() => { - create_gateway ({ cookie_name: 'foo', refresh_cookie_name: 'foo' }); + create_gateway ({ + cookie: { name: 'foo' }, + refresh_cookie: { name: 'foo' } + }); }) .toThrowError ('access and refresh cookies cannot have the same name'); }); diff --git a/test/spec/cookie.ts b/test/spec/cookie.ts index 859a39b..dc1d76e 100644 --- a/test/spec/cookie.ts +++ b/test/spec/cookie.ts @@ -5,44 +5,124 @@ * Created by Timo Hocker , January 2022 */ -import { build_cookie, extract_cookie } from '../../lib/cookie'; +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', () => { - const pairs = [ - { - name: 'foo', - value: 'bar', - result: 'foo=bar; Secure; HttpOnly; SameSite=Strict' - }, - { - name: 'foäöüo', - value: 'baäöür', - result: 'foäöüo=baäöür; Secure; HttpOnly; SameSite=Strict' - } - ]; - - for (const pair of pairs) { - expect (build_cookie (pair.name, pair.value)) + for (const pair of create_cookie_pairs) { + expect (build_cookie (pair.settings, pair.value)) .toEqual (pair.result); } }); it ('should parse a cookie', () => { - const 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' - } - ]; - - for (const pair of pairs) { + for (const pair of parse_cookie_pairs) { expect (extract_cookie (pair.name, pair.header)) .toEqual (pair.value); }