From 51bf05189608ab8980a2cf1d545c69451a98b824 Mon Sep 17 00:00:00 2001 From: Juri Leino Date: Tue, 27 Aug 2024 14:58:43 +0200 Subject: [PATCH 01/10] deps: update axios to 1.7.5 --- package-lock.json | 14 ++++++++------ package.json | 2 +- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index bfd997f..d77584d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "@semantic-release/exec": "^6.0.3", "@semantic-release/git": "^10.0.1", "@semantic-release/github": "^8.0.6", - "axios": "^1.1.3", + "axios": "^1.7.5", "chai": "^4.3.7", "chai-openapi-response-validator": "^0.14.2", "chokidar": "^3.5.3", @@ -1084,11 +1084,12 @@ } }, "node_modules/axios": { - "version": "1.1.3", + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.5.tgz", + "integrity": "sha512-fZu86yCo+svH3uqJ/yTdQ0QHpQu5oL+/QE+QPSv6BZSkDAoky9vytxp7u5qk83OJFS3kEBcesWni9WTZAv3tSw==", "dev": true, - "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.0", + "follow-redirects": "^1.15.6", "form-data": "^4.0.0", "proxy-from-env": "^1.1.0" } @@ -3041,7 +3042,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.2", + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", + "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", "dev": true, "funding": [ { @@ -3049,7 +3052,6 @@ "url": "https://github.com/sponsors/RubenVerborgh" } ], - "license": "MIT", "engines": { "node": ">=4.0" }, diff --git a/package.json b/package.json index 534eb69..efc77fa 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "@semantic-release/exec": "^6.0.3", "@semantic-release/git": "^10.0.1", "@semantic-release/github": "^8.0.6", - "axios": "^1.1.3", + "axios": "^1.7.5", "chai": "^4.3.7", "chai-openapi-response-validator": "^0.14.2", "chokidar": "^3.5.3", From cee177cdbfb9ff967c71ccc9689590bebf55114c Mon Sep 17 00:00:00 2001 From: Juri Leino Date: Fri, 30 Aug 2024 00:41:22 +0200 Subject: [PATCH 02/10] test: remove test from paths.test that is now part of auth.test --- test/paths.test.js | 38 -------------------------------------- 1 file changed, 38 deletions(-) diff --git a/test/paths.test.js b/test/paths.test.js index b7d13bb..b128e0c 100644 --- a/test/paths.test.js +++ b/test/paths.test.js @@ -78,44 +78,6 @@ describe('Prefixed known path', function () { }); }); -describe("Binary up and download", function () { - const contents = fs.readFileSync("./dist/roasted.xar") - - before(async function () { - await util.login() - response = await util.axios.get('api/parameters', {}) - }) - - it('public route can be called', function () { - expect(response.status).to.equal(200); - }) - - it('user property is set on request map', function () { - expect(response.data.user).to.be.a('object') - expect(response.data.user.name).to.equal("admin") - expect(response.data.user.dba).to.equal(true) - }) - - describe('On logout', function () { - let logoutResponse - let guestResponse - before(async function () { - logoutResponse = await util.axios.get('logout') - guestResponse = await util.axios.get('api/parameters', {}) - }) - it('request returns true', function () { - expect(logoutResponse.status).to.equal(200) - expect(logoutResponse.data.success).to.equal(true) - }) - it('public route sets guest as user', function () { - expect(guestResponse.status).to.equal(200) - expect(guestResponse.data.user.name).to.equal("guest") - expect(guestResponse.data.user.dba).to.equal(false) - }) - - }) -}) - describe('Request body', function() { it('uploads string in body', async function() { const res = await util.axios.post('api/$op-er+ation*!'); From 30ef866db85f7615b69e8eaf445d03a0d10b1d15 Mon Sep 17 00:00:00 2001 From: Juri Leino Date: Sat, 21 Sep 2024 21:33:31 -0400 Subject: [PATCH 03/10] feat: improve cookie-login and -logout - Use persistent login module directly to allow custom login and logout route handlers. - Logout route does not need to redirect anymore. - Add cookie.xqm utility module that is package private. - Add custom login and logout route handlers to test app. - Add and extend tests for login and logout. --- content/auth.xql | 313 +++++++++++++++++-------- content/cookie.xqm | 93 ++++++++ test/app/api.json | 171 ++++++++++++-- test/app/modules/api.xql | 59 +++++ test/auth.test.js | 495 +++++++++++++++++++++++++++++++++++++-- test/util.js | 57 ++--- 6 files changed, 1027 insertions(+), 161 deletions(-) create mode 100644 content/cookie.xqm diff --git a/content/auth.xql b/content/auth.xql index 1daec0d..e4a9001 100644 --- a/content/auth.xql +++ b/content/auth.xql @@ -16,11 +16,16 @@ :) module namespace auth="http://e-editiones.org/roaster/auth"; -import module namespace login="http://exist-db.org/xquery/login" at "resource:org/exist/xquery/modules/persistentlogin/login.xql"; +import module namespace plogin="http://exist-db.org/xquery/persistentlogin" + at "java:org.exist.xquery.modules.persistentlogin.PersistentLoginModule"; +import module namespace request = "http://exist-db.org/xquery/request"; +import module namespace response = "http://exist-db.org/xquery/response"; +import module namespace session = "http://exist-db.org/xquery/session"; import module namespace router="http://e-editiones.org/roaster/router"; import module namespace rutil="http://e-editiones.org/roaster/util"; import module namespace errors="http://e-editiones.org/roaster/errors"; +import module namespace cookie="http://e-editiones.org/roaster/cookie" at "cookie.xqm"; (: API Request Authentication and Authorisation :) @@ -31,6 +36,15 @@ declare variable $auth:DEFAULT_STRATEGIES := map { "basicAuth": auth:use-basic-auth#1 }; +declare variable $auth:DEFAULT_LOGIN_OPTIONS := map { + "asDba": true(), + "maxAge": xs:dayTimeDuration("P7D"), + "Path": request:get-context-path(), + "createSession": true() (: this will _also_ set the JSESSIONID cookie :) +}; + +declare variable $auth:log-level := "debug"; + (:~ : standard authorization middleware : extend request with user information @@ -40,7 +54,7 @@ declare variable $auth:DEFAULT_STRATEGIES := map { : @param $request the current request : @return the extended request map :) -declare function auth:standard-authorization($request as map(*), $response as map(*)) as map(*)+ { +declare function auth:standard-authorization ($request as map(*), $response as map(*)) as map(*)+ { auth:authenticate($request, $response, $auth:DEFAULT_STRATEGIES) }; @@ -53,10 +67,143 @@ declare function auth:standard-authorization($request as map(*), $response as ma : @param $strategies the authorization strategies to use : @return the authorization middleware that extends the request map :) -declare function auth:use-authorization($strategies as map(*)) as function(*) { +declare function auth:use-authorization ($strategies as map(*)) as function(*) { auth:authenticate(?, ?, $strategies) }; +(: login-domain must be configured! :) +declare function auth:add-login-domain ($request as map(*), $auth-options as map(*)) as map(*) { + let $login-domain := auth:login-domain($request?spec) + return + if (empty($login-domain)) then ( + error($errors:OPERATION, 'Login domain not specified in API-definition!') + ) else ( + map:put($auth-options, 'name', $login-domain) + ) +}; + +(:~ + : @deprecated Default login handler + : + : @param $request the current request map + : @throws errors:OPERATION if cookieAuth does not provide a login domain + :) +declare function auth:login ($request as map(*)) as map(*) { + let $login-domain := auth:login-domain($request?spec) + let $user := auth:login-user( + $request?body?user, $request?body?password, + map{ "name": $login-domain } + ) + + return + if (exists($user)) + then + map { + "user": $user, + "groups": array { sm:get-user-groups($user) }, + "dba": sm:is-dba($user), + "domain": $login-domain + } + else + error($errors:UNAUTHORIZED, "Wrong user or password", map { + "user": $user, + "domain": $login-domain + }) +}; + +(:~ + : Preferred app-specific login function, that will set a cookie for cookieAuth + :) +declare function auth:login-user ($user as xs:string, $password as xs:string, $options as map(*)) as xs:string? { + let $merged-options := map:merge(($auth:DEFAULT_LOGIN_OPTIONS, $options), map{ "duplicates": "use-last" }) + return ( + util:log($auth:log-level, ("auth:login-user: ", $user)), + plogin:register($user, $password, $merged-options?maxAge, + auth:get-register-callback($merged-options)) + ) +}; + +(:~ + : @deprecated Default logout handler + : + : @param $request the current request map + : @throws errors:OPERATION if cookieAuth does not provide a login domain + :) +declare function auth:logout ($request as map(*)) as map(*) { + auth:logout-user(map{ "name": auth:login-domain($request?spec) }), + map { + "success": true(), + "message": "logged out" + } +}; + +(:~ + : Preferred logout function for use in app-specific handlers + : user session will immediately stop working + :) +declare function auth:logout-user ($options as map(*)) as empty-sequence() { + let $token := + if (empty($options?name)) then ( + error($errors:OPERATION, 'Cookie-name (login-domain) not set in call to auth:logout-user!') + ) else ( + request:get-cookie-value($options?name) + ) + + return ( + session:invalidate(), + if ($token and $token != "deleted") then (plogin:invalidate($token)) else (), + cookie:set(map:merge( + ($auth:DEFAULT_LOGIN_OPTIONS, $options, $auth:INVALIDATE_COOKIE), + map{ "duplicates": "use-last" })) + ) +}; + +declare %private variable $auth:INVALIDATE_COOKIE := map{ "value": "deleted", "maxAge": xs:dayTimeDuration("-P1D") }; + +(:~ + : Read the login domain from components.securitySchemes.cookieAuth.name + : @param $spec API definition + :) +declare function auth:login-domain ($spec as map(*)) as xs:string? { + router:resolve-pointer($spec, ("components", "securitySchemes", "cookieAuth", "name")) +}; + +declare function auth:use-cookie-auth ($request as map(*)) as map(*)? { + auth:use-cookie-auth($request, ()) +}; + +(:~ + : + : @throws errors:OPERATION if cookieAuth does not provide a login domain + :) +declare function auth:use-cookie-auth ($request as map(*), $custom-options as map(*)?) as map(*)? { + let $login-domain := auth:login-domain($request?spec) + let $token := request:get-cookie-value($login-domain) + + let $user := + if (empty($token)) then () else ( + let $merged-options := map:merge(($auth:DEFAULT_LOGIN_OPTIONS, $custom-options, map{ "name": $login-domain }), map{ "duplicates": "use-last" }) + let $callback := auth:get-credentials-callback($merged-options) + return plogin:login($token, $callback) + ) + + return ( + (: util:log($auth:log-level, ("auth:use-cookie-auth: token ", substring-before($token, ":") , ":******** evaluated to ", $user)), :) + if (empty($user)) then () else rutil:getDBUser() + ) +}; + +(:~ + : Basic authentication is handled by Jetty + : the user is already authenticated in the database and we just need to + : retrieve the information here + :) +declare function auth:use-basic-auth ($request as map(*)) as map(*) { + util:log($auth:log-level, sm:id()), + rutil:getDBUser() +}; + + declare %private function auth:is-public-route ($constraints as map(*)?) as xs:boolean { not(exists($constraints)) }; @@ -91,27 +238,9 @@ declare %private function auth:authenticate ($request as map(*), $response as ma then ($request?spec?security) else () - let $methods := $defined-auth-methods - => array:for-each(function ($method-config as map(*)) { - let $method-name := map:keys($method-config) - (: TODO handle method-parameters for OAuth and openID - : let $method-parameters := $method-config?($method-name) :) - - return - if (map:contains($strategies, $method-name)) - then ( - let $auth-method := $strategies($method-name) - return function () { - $auth-method($request) - } - ) - else error( - $errors:OPERATION, - "No strategy found for : '" || $method-name || "'", ($method-config, $strategies) - ) - }) - - let $user := array:fold-left($methods, (), auth:use-first-matching-method#2) + let $methods := array:for-each($defined-auth-methods, auth:map-auth-methods(?, $strategies)) + + let $user := array:fold-left($methods, (), auth:use-first-matching-method($request)) let $constraints := $request?config?x-constraints return if ( @@ -125,90 +254,70 @@ declare %private function auth:authenticate ($request as map(*), $response as ma else error($errors:UNAUTHORIZED, "Access denied") }; -declare function auth:use-first-matching-method ($user as map(*)?, $method as function(*)) as map(*)? { - if (exists($user)) - then $user - else $method() -}; - -(:~ - : Either login a user (if parameter `user` is specified) or check if the current user is logged in. - : Setting parameter `logout` to any value will log out the current user. - : - : @param $request the current request map - : @throws errors:OPERATION if cookieAuth does not provide a login domain - :) -declare function auth:login($request as map(*)) { - (: login-domain must be configured! :) - let $login-domain := auth:login-domain($request?spec) - - let $login := login:set-user($login-domain, (), false()) - - let $user := request:get-attribute($login-domain || ".user") - (: Work-around for the actual login request - : It is possible that the session is not yet ready - : and sm:id() still reports "guest" as real user - :) +declare %private function auth:map-auth-methods ($method-config as map(*), $strategies as map(*)) as function(*) { + let $method-name := map:keys($method-config) + (: TODO handle method-parameters for OAuth and openID + : let $method-parameters := $method-config?($method-name) :) + return - if (exists($user)) - then - map { - "user": $user, - "groups": array { sm:get-user-groups($user) }, - "dba": sm:is-dba($user), - "domain": $login-domain - } - else - error($errors:UNAUTHORIZED, "Wrong user or password", map { - "user": $user, - "domain": $login-domain - }) + if (map:contains($strategies, $method-name)) + then ($strategies($method-name)) + else error( + $errors:OPERATION, + "No strategy found for : '" || $method-name || "'", ($method-config, $strategies) + ) }; -declare function auth:logout ($request as map(*)) { - if (empty($request?parameters?logout)) - then router:response ( - 301, "text/plain", "redirecting", - map { "Location": "?logout=true" }) - else - let $user := - auth:login-domain($request?spec) - => concat(".user") - => request:get-attribute() - - return map { "success": empty($user) } +declare %private function auth:use-first-matching-method ($request as map(*)) as function(*) { + function ($user as map(*)?, $method as function(*)) as map(*)? { + if (exists($user)) + then $user + else $method($request) + } }; -(:~ - : Read the login domain from components.securitySchemes.cookieAuth.name - : @param $spec API definition - : @throws errors:OPERATION if cookieAuth does not provide a login domain - :) -declare function auth:login-domain ($spec as map(*)) as xs:string { - router:resolve-pointer($spec, ("components", "securitySchemes", "cookieAuth", "name")) +declare %private function auth:get-register-callback ($options as map(*)) { + function ( + $new-token as xs:string?, + $user as xs:string, + $password as xs:string, + $expiration as xs:duration + ) { + if ($options?asDba and not(sm:is-dba($user))) then ( + (: raise error here? :) + util:log($auth:log-level, 'asDba is set to true() but user is non-DBA // not creating a session') + ) else ( + if ($new-token) then ( + (: session:invalidate(), :) + cookie:set( + map:merge( + ($options, map{ "value": $new-token, "maxAge": $expiration }), + map{ "duplicates": "use-last" })) + ) else (), + let $_ := xmldb:login("/db", $user, $password, $options?createSession) + return $user + ) + } }; -(:~ - : - : @throws errors:OPERATION if cookieAuth does not provide a login domain - :) -declare function auth:use-cookie-auth ($request as map(*)) as map(*)? { - (: login-domain must be configured! :) - let $login-domain := auth:login-domain($request?spec) - let $login := login:set-user($login-domain, (), false()) - let $user := request:get-attribute($login-domain || ".user") - return ( - if ($user) - then rutil:getDBUser() - else () - ) +declare %private function auth:get-credentials-callback ($options as map(*)) as function(*) { + function ( + $new-token as xs:string?, + $user as xs:string, + $password as xs:string, + $expiration as xs:duration + ) as xs:string? { + (: util:log($auth:log-level, "auth:credentials-callback: --" || $user || "--"), :) + if (empty($new-token)) then ( + util:log($auth:log-level, "session still valid") + ) else ( + util:log($auth:log-level, "new token"), + cookie:set( + map:merge(($options, map{ "value": $new-token, "maxAge": $expiration}), + map{ "duplicates": "use-last" })) + ), + (: util:log($auth:log-level, "USER: --" || $user || "--"), :) + $user + } }; -(:~ - : Basic authentication is handled by Jetty - : the user is already authenticated in the database and we just need to - : retrieve the information here - :) -declare function auth:use-basic-auth ($request as map(*)) as map(*) { - rutil:getDBUser() -}; diff --git a/content/cookie.xqm b/content/cookie.xqm new file mode 100644 index 0000000..e6796d7 --- /dev/null +++ b/content/cookie.xqm @@ -0,0 +1,93 @@ +(: + : Copyright (C) 2024 TEI Publisher Project Team + : + : This program is free software: you can redistribute it and/or modify + : it under the terms of the GNU General Public License as published by + : the Free Software Foundation, either version 3 of the License, or + : (at your option) any later version. + : + : This program is distributed in the hope that it will be useful, + : but WITHOUT ANY WARRANTY; without even the implied warranty of + : MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + : GNU General Public License for more details. + : + : You should have received a copy of the GNU General Public License + : along with this program. If not, see . + :) +module namespace cookie="http://e-editiones.org/roaster/cookie"; + +import module namespace response="http://exist-db.org/xquery/response"; +import module namespace errors="http://e-editiones.org/roaster/errors"; + +declare %private variable $cookie:enforce-rfc2109 := "[/()<>@,;:\\""\[\]\?=\{\} \t]"; + +(:~ + : Custom implementation of response:set-cookie in XQuery + : Uses response:set-header instead + : The cookie is built from the passed in map + : This allows to set more cookie attributes + : Specifically, SameSite and HttpOnly + : + : name and value are mandatory, + : if they are missing an error is raised with code $errors:OPERATION. + : The same error will be raised, if maxAge is not an + : instance of xs:dayTimeDuration + : + : Example Input + map { + "name": "awesome.cookie", + "value": "._.*^*._.*^*._.*^*._.*^*._.*", + "maxAge": xs:dayTimeDuration("P1D"), + "Path": "/", + "SameSite": "Strict", + "Secure": false(), + "HttpOnly": true() + } + :) +declare function cookie:set($options as map(*)) as empty-sequence() { + response:set-header('Set-Cookie', string-join( + ( + cookie:name-and-value($options), + cookie:lifetime($options), + cookie:add-property($options, "Domain"), + cookie:add-property($options, "Path"), + cookie:add-property($options, "SameSite"), + cookie:add-flag($options, "Secure"), + cookie:add-flag($options, "HttpOnly") + ), + "; " + )) +}; + +declare %private function cookie:name-and-value($options as map(*)) as xs:string { + if (empty($options?("name")) or empty($options?("value"))) then ( + error($errors:OPERATION, "Cookie name and value must be set", $options) + ) else if (matches($options?name, $cookie:enforce-rfc2109)) then ( + error($errors:OPERATION, "Cookie name contains illegal charecters", $options) + ) else if ($options?name = ("Domain", "Path", "SameSite", "Secure", "HttpOnly")) then ( + error($errors:OPERATION, "Cookie name cannot be equal to property name", $options) + ) else ( + $options?name || "=" || $options?value + ) +}; + +declare %private function cookie:lifetime($options as map(*)) as xs:string* { + if (empty($options?maxAge)) then () + else if (not($options?maxAge instance of xs:dayTimeDuration)) then ( + error($errors:OPERATION, "maxAge must be an instance of xs:dayTimeDuration", $options) + ) else ( + "Max-Age=" || ($options?maxAge div xs:dayTimeDuration('PT1S')), + "Expires=" || string(current-dateTime() + $options?maxAge) + ) +}; + +declare %private function cookie:add-property($options as map(*), $property as xs:string) as xs:string? { + if (empty($options?($property))) then () else ( + $property || "=" || $options?($property) + ) +}; + +declare %private function cookie:add-flag($options as map(*), $property as xs:string) as xs:string? { + if (boolean($options?($property))) then ($property) else () +}; + diff --git a/test/app/api.json b/test/app/api.json index 2292d4e..bc3bf6c 100644 --- a/test/app/api.json +++ b/test/app/api.json @@ -68,17 +68,7 @@ "summary": "User Logout", "description": "End session of the current user", "operationId": "auth:logout", - "tags": ["auth", "query"], - "parameters": [ - { - "name": "logout", - "in": "query", - "description": "Set to some value to log out the current user", - "schema": { - "type": "string" - } - } - ], + "tags": ["auth"], "responses": { "200": { "description": "OK", @@ -87,18 +77,33 @@ "schema": { "type": "object", "properties": { - "success": { "type": "boolean" } + "success": { "type": "boolean" }, + "message": { "type": "string" } } } } } }, - "301": { - "description": "Redirect with the logout parameter set.", + "401": { "description": "unauthorized" } + } + } + }, + "/api/logout": { + "get": { + "summary": "User Logout", + "description": "End session of the current user", + "operationId": "api:logout", + "tags": ["auth"], + "responses": { + "200": { + "description": "OK", "content": { - "text/plain": { + "application/json": { "schema": { - "type": "string" + "type": "object", + "properties": { + "message": { "type": "string" } + } } } } @@ -107,15 +112,147 @@ } } }, + "/api/login": { + "post": { + "summary": "Custom user Login", + "description": "Custom login handler using different properties", + "tags": ["auth", "body"], + "operationId": "api:login", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ "usr" ], + "properties": { + "usr": { + "description": "Username", + "type": "string" + }, + "pwd": { + "description": "Password", + "type": "string", + "format": "password" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" } + } + } + } + } + }, + "401": { + "description": "Wrong user or password", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" } + } + } + } + } + } + }, + "security": [] + } + }, + "/api/login-xml": { + "post": { + "summary": "Login with XML", + "description": "Custom login handler using XML body", + "tags": ["auth", "body"], + "operationId": "api:login-xml", + "requestBody": { + "required": true, + "content": { + "application/xml": { + "schema": { + "type": "object", + "required": [ "username" ], + "properties": { + "username": { + "description": "Username", + "type": "string" + }, + "password": { + "description": "Password", + "type": "string", + "format": "password" + } + }, + "xml": { + "wrapped": true, + "name": "login" + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/xml": { + "schema": { + "type": "object" + } + } + } + }, + "401": { + "description": "Wrong user or password", + "content": { + "application/xml": { + "schema": { + "type": "object" + } + } + } + } + }, + "security": [] + } + }, "/login": { "post": { "summary": "User Login", - "description": "Start an authenticated session for the given user", + "description": "Start an authenticated session using roaster's login route handler", "tags": ["auth", "body"], "operationId": "auth:login", "requestBody": { "required": true, "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ "user" ], + "properties": { + "user": { + "description": "Name of the user", + "type": "string" + }, + "password": { + "type": "string", + "format": "password" + } + } + } + }, "multipart/form-data": { "schema": { "type": "object", diff --git a/test/app/modules/api.xql b/test/app/modules/api.xql index 317cfb9..dbdbe2f 100644 --- a/test/app/modules/api.xql +++ b/test/app/modules/api.xql @@ -112,6 +112,65 @@ declare function api:avatar ($request as map(*)) { }; +(:~ + : override default authentication options + :) +declare variable $api:auth-options := map { + "asDba": false(), + "createSession": false(), + "maxAge": xs:dayTimeDuration("PT10S"), (: set the cookie time-out to 10 seconds :) + "Path": "/exist/apps/roasted", (: requests must include this path for the cookie to be included :) + "SameSite": "Lax", (: sets the SameSite property to either "None", "Strict" or "Lax" :) + "Secure": true(), (: mark the cookie as secure :) + "HttpOnly": true() (: sets the HttpOnly property :) +}; + +(:~ + : Example login route handler using non-standard propertys + : within the request body to authenticate users against exist-db. + : The data can also be supplied as JSON + :) +declare function api:login ($request as map(*)) { + let $user := auth:login-user( + $request?body?usr, $request?body?pwd, + auth:add-login-domain($request, $api:auth-options)) + + return if (empty($user)) then ( + roaster:response(401, "application/json", + map{ "message": "Wrong user or password" }) + ) else ( + (: the request can also be redirected here :) + map{ "message": concat("Logged in as ", $user) } + ) +}; + +(:~ + : Example login route handler using XML + :) +declare function api:login-xml ($request as map(*)) { + let $user := auth:login-user( + $request?body//user/string(), $request?body//password/string(), + auth:add-login-domain($request, $api:auth-options)) + + return if (empty($user)) then ( + roaster:response(401, "application/xml", + Wrong user or password) + ) else ( + (: the request can also be redirected here :) + roaster:response(200, "application/xml", + Logged in as {$user}) + ) +}; + +(:~ + : Example logout route handler + :) +declare function api:logout ($request as map(*)) { + auth:logout-user(auth:add-login-domain($request, $api:auth-options)), + (: the request can also be redirected here :) + map{ "message": "Logged out" } +}; + (: end of route handlers :) (:~ diff --git a/test/auth.test.js b/test/auth.test.js index 3b689c6..076fefe 100644 --- a/test/auth.test.js +++ b/test/auth.test.js @@ -2,40 +2,507 @@ const util = require('./util.js') const chai = require('chai') const expect = chai.expect +function parseCookies(cookies) { + return cookies.map(parseCookieString) +} + +function parseCookieString (cookieString) { + return cookieString.split(';') + .map(kv => kv.split('=')) + .reduce((acc, next) => { + const key = decodeURIComponent(next[0].trim()) + const value = next[1] ? decodeURIComponent(next[1].trim()) : true + acc[key] = value; + return acc; + }, {}) +} + +function oneCookieHas(key) { + return cookies => cookies.filter(cookie => (key in cookie)).length === 1 +} + +function getCookieWith(cookies, key) { + return cookies.filter(cookie => (key in cookie))[0] +} + +const testAppLoginDomain = 'roasted.com.login' +const jettySessionId = 'JSESSIONID' + describe('On Login', function () { - let response + describe('using multipart/form-data', function(){ + let cookie, parsedCookies before(async function () { - await util.login() - response = await util.axios.get('api/parameters', {}) + let res = await util.axios.post('login', util.authForm, { + headers: { 'Content-Type': 'multipart/form-data' } + }) + cookie = res.headers['set-cookie']; + parsedCookies = parseCookies(cookie) }) - it('public route can be called', function () { - expect(response.status).to.equal(200); + it('sets two cookies', function () { + expect(cookie).to.have.lengthOf(2) }) - it('user property is set on request map', function () { - expect(response.data.user).to.be.a('object') - expect(response.data.user.name).to.equal("admin") - expect(response.data.user.dba).to.equal(true) + it('sets the ' + jettySessionId + ' cookie', function () { + expect(parsedCookies).to.satisfy(oneCookieHas(jettySessionId)) + }) + + it('sets the login domain cookie', function () { + expect(parsedCookies).to.satisfy(oneCookieHas(testAppLoginDomain)) + }) + + it('domain cookie has defaults', function () { + const domainCookie = getCookieWith(parsedCookies, testAppLoginDomain) + expect(domainCookie).to.exist + expect(domainCookie).to.have.property('Path') + expect(domainCookie['Path']).to.equal('/exist') + expect(domainCookie).to.have.property('Max-Age') + expect(domainCookie['Max-Age']).to.equal('604800') + expect(domainCookie).to.have.property('Expires') + expect(new Date(domainCookie.Expires).getTime()).to.be.greaterThan(Date.now()) + }) + + describe('using cookie auth', function () { + let publicRouteResponse + + before(async function () { + publicRouteResponse = await util.axios.get('api/parameters', { headers: { cookie } }) + }) + + it('public route can be called', async function () { + expect(publicRouteResponse.status).to.equal(200); + }) + + it('sets the correct user', function () { + expect(publicRouteResponse.data.user).to.be.a('object') + expect(publicRouteResponse.data.user.name).to.equal("admin") + expect(publicRouteResponse.data.user.dba).to.equal(true) + }) }) describe('On logout', function () { - let logoutResponse - let guestResponse + let logoutResponse, guestResponse, updatedCookie, parsedCookies + before(async function () { - logoutResponse = await util.axios.get('logout') - guestResponse = await util.axios.get('api/parameters', {}) + logoutResponse = await util.axios.get('logout', { headers: { cookie }}) + console.log(logoutResponse) + updatedCookie = logoutResponse.headers['set-cookie']; + parsedCookies = parseCookies(updatedCookie) + guestResponse = await util.axios.get('api/parameters', { headers: { cookie: updatedCookie }}) }) + it('request returns true', function () { expect(logoutResponse.status).to.equal(200) expect(logoutResponse.data.success).to.equal(true) }) - it('public route sets guest as user', function () { + + it('invalidates session and domain cookie', function () { + expect(updatedCookie.length).to.equal(1) + // expect(parsedCookies).to.satisfy(oneCookieHas(jettySessionId)) + expect(parsedCookies).to.satisfy(oneCookieHas(testAppLoginDomain)) + const domainCookie = getCookieWith(parsedCookies, testAppLoginDomain) + expect(domainCookie[testAppLoginDomain]).to.equal('deleted') + }) + + it('public route sets guest as user', async function () { expect(guestResponse.status).to.equal(200) expect(guestResponse.data.user.name).to.equal("guest") expect(guestResponse.data.user.dba).to.equal(false) }) + it('invalidated cookie reverts to guest access', async function () { + const responseWithOldCookies = await util.axios.get('api/parameters', { headers: { cookie }}) + expect(responseWithOldCookies.status).to.equal(200) + expect(responseWithOldCookies.data.user.name).to.equal("guest") + expect(responseWithOldCookies.data.user.dba).to.equal(false) + }) + }) + }) + + describe('using application/x-www-form-urlencoded', function(){ + let cookie, parsedCookies + + before(async function () { + const urlEncodedAuthForm = new URLSearchParams(util.authForm).toString() + const res = await util.axios.post('login', urlEncodedAuthForm, { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' } + }) + cookie = res.headers['set-cookie']; + parsedCookies = parseCookies(cookie) + }) + + it('sets two cookies', function () { + expect(cookie).to.have.lengthOf(2) + }) + + it('sets the ' + jettySessionId + ' cookie', function () { + expect(parsedCookies).to.satisfy(oneCookieHas(jettySessionId)) + }) + + it('sets the login domain cookie', function () { + expect(parsedCookies).to.satisfy(oneCookieHas(testAppLoginDomain)) + }) + + it('sets a cookie with defaults', function () { + const domainCookie = getCookieWith(parsedCookies, testAppLoginDomain) + expect(domainCookie).to.exist + expect(domainCookie).to.have.property('Path') + expect(domainCookie['Path']).to.equal('/exist') + expect(domainCookie).to.have.property('Max-Age') + expect(domainCookie['Max-Age']).to.equal('604800') + expect(domainCookie).to.have.property('Expires') + expect(new Date(domainCookie.Expires).getTime()).to.be.greaterThan(Date.now()) + }) + + describe('sets the correct user using cookie auth', function () { + let publicRouteResponse + + before(async function () { + publicRouteResponse = await util.axios.get('api/parameters', { headers: { cookie } }) + }) + + it('public route can be called', async function () { + expect(publicRouteResponse.status).to.equal(200); + }) + + it('user property is set on request map', function () { + expect(publicRouteResponse.data.user).to.be.a('object') + expect(publicRouteResponse.data.user.name).to.equal("admin") + expect(publicRouteResponse.data.user.dba).to.equal(true) + }) + }) + + describe('On logout', function () { + let logoutResponse, guestResponse + + before(async function () { + logoutResponse = await util.axios.get('logout', { headers: { cookie }}) + updatedCookie = logoutResponse.headers['set-cookie']; + guestResponse = await util.axios.get('api/parameters', { headers: { updatedCookie }}) + }) + it('request returns true', function () { + expect(logoutResponse.status).to.equal(200) + expect(logoutResponse.data.success).to.equal(true) + }) + it('public route sets guest as user', async function () { + expect(guestResponse.status).to.equal(200) + expect(guestResponse.data.user.name).to.equal("guest") + expect(guestResponse.data.user.dba).to.equal(false) + }) + }) + }) + describe('using application/json', function(){ + let cookie, parsedCookies + + before(async function () { + const data = { + user: util.adminCredentials.username, + password: util.adminCredentials.password + } + + const res = await util.axios.post('login', data, { + headers: { 'Content-Type': 'application/json' } + }) + cookie = res.headers['set-cookie']; + parsedCookies = parseCookies(cookie) + }) + + it('sets two cookies', function () { + expect(cookie).to.have.lengthOf(2) + }) + + it('sets the ' + jettySessionId + ' cookie', function () { + expect(parsedCookies).to.satisfy(oneCookieHas(jettySessionId)) + }) + + it('sets the login domain cookie', function () { + expect(parsedCookies).to.satisfy(oneCookieHas(testAppLoginDomain)) + }) + + it('domain cookie has defaults', function () { + const domainCookie = getCookieWith(parsedCookies, testAppLoginDomain) + expect(domainCookie).to.exist + expect(domainCookie).to.have.property('Path') + expect(domainCookie['Path']).to.equal('/exist') + expect(domainCookie).to.have.property('Max-Age') + expect(domainCookie['Max-Age']).to.equal('604800') + expect(domainCookie).to.have.property('Expires') + expect(new Date(domainCookie.Expires).getTime()).to.be.greaterThan(Date.now()) + }) + + describe('sets the correct user using cookie auth', function () { + let publicRouteResponse + + before(async function () { + publicRouteResponse = await util.axios.get('api/parameters', { headers: { cookie } }) + }) + + it('public route can be called', async function () { + expect(publicRouteResponse.status).to.equal(200); + }) + + it('user property is set on request map', function () { + expect(publicRouteResponse.data.user).to.be.a('object') + expect(publicRouteResponse.data.user.name).to.equal("admin") + expect(publicRouteResponse.data.user.dba).to.equal(true) + }) + }) + + describe('On logout', function () { + let logoutResponse, guestResponse, updatedCookie, parsedCookies + + before(async function () { + logoutResponse = await util.axios.get('logout', { headers: { cookie }}) + updatedCookie = logoutResponse.headers['set-cookie']; + parsedCookies = parseCookies(updatedCookie) + guestResponse = await util.axios.get('api/parameters', { headers: { cookie: updatedCookie }}) + }) + + it('request returns true', function () { + expect(logoutResponse.status).to.equal(200) + expect(logoutResponse.data.success).to.equal(true) + }) + + it('invalidates session and domain cookie', function () { + expect(updatedCookie.length).to.equal(1) + // expect(parsedCookies).to.satisfy(oneCookieHas(jettySessionId)) + expect(parsedCookies).to.satisfy(oneCookieHas(testAppLoginDomain)) + const domainCookie = getCookieWith(parsedCookies, testAppLoginDomain) + expect(domainCookie[testAppLoginDomain]).to.equal('deleted') + }) + + it('public route sets guest as user', async function () { + expect(guestResponse.status).to.equal(200) + expect(guestResponse.data.user.name).to.equal("guest") + expect(guestResponse.data.user.dba).to.equal(false) + }) + + it('invalidated cookie reverts to guest access', async function () { + const responseWithOldCookies = await util.axios.get('api/parameters', { headers: { cookie }}) + expect(responseWithOldCookies.status).to.equal(200) + expect(responseWithOldCookies.data.user.name).to.equal("guest") + expect(responseWithOldCookies.data.user.dba).to.equal(false) + }) + }) + }) + + describe('custom login using application/json', function(){ + let cookie, parsedCookies, domainCookie + + before(async function () { + const data = { + usr: util.adminCredentials.username, + pwd: util.adminCredentials.password + } + + const res = await util.axios.post('api/login', data, { + headers: { 'Content-Type': 'application/json' } + }) + cookie = res.headers['set-cookie']; + parsedCookies = parseCookies(cookie) + domainCookie = getCookieWith(parsedCookies, testAppLoginDomain) + }) + + it('sets two cookies', function () { + expect(cookie).to.have.lengthOf(1) + }) + + it('does not set the ' + jettySessionId + ' cookie', function () { + expect(parsedCookies).to.not.satisfy(oneCookieHas(jettySessionId)) + }) + + it('sets the login domain cookie', function () { + expect(domainCookie).to.exist + }) + + it('domain cookie has defaults', function () { + expect(domainCookie).to.have.property('Path') + expect(domainCookie['Path']).to.equal('/exist/apps/roasted') + expect(domainCookie).to.have.property('Expires') + expect(new Date(domainCookie.Expires).getTime()).to.be.greaterThan(Date.now()) + }) + + it('domain cookie has short lifetime', function () { + expect(domainCookie).to.have.property('Max-Age') + expect(domainCookie['Max-Age']).to.equal('10') + }) + + it('domain cookie has SameSite=strict', function () { + expect(domainCookie).to.have.property('SameSite') + expect(domainCookie['SameSite']).to.equal('Lax') + }) + + it('domain cookie has Secure', function () { + expect(domainCookie).to.have.property('Secure') + }) + + it('domain cookie has HttpOnly', function () { + expect(domainCookie).to.have.property('HttpOnly') + }) + + describe('sets the correct user using cookie auth', function () { + let publicRouteResponse + + before(async function () { + publicRouteResponse = await util.axios.get('api/parameters', { headers: { cookie } }) + }) + + it('public route can be called', async function () { + expect(publicRouteResponse.status).to.equal(200); + }) + + it('user property is set on request map', function () { + expect(publicRouteResponse.data.user).to.be.a('object') + expect(publicRouteResponse.data.user.name).to.equal("admin") + expect(publicRouteResponse.data.user.dba).to.equal(true) + }) + }) + + describe('On logout', function () { + let logoutResponse, guestResponse, updatedCookie, parsedCookies + + before(async function () { + logoutResponse = await util.axios.get('api/logout', { headers: { cookie }}) + updatedCookie = logoutResponse.headers['set-cookie']; + parsedCookies = parseCookies(updatedCookie) + guestResponse = await util.axios.get('api/parameters', { headers: { cookie: updatedCookie }}) + }) + + it('request returns a message', function () { + expect(logoutResponse.status).to.equal(200) + expect(logoutResponse.data.message).to.exist + }) + + it('invalidates domain cookie', function () { + expect(updatedCookie.length).to.equal(1) + domainCookie = getCookieWith(parsedCookies, testAppLoginDomain) + expect(domainCookie).to.exist + expect(domainCookie[testAppLoginDomain]).to.equal('deleted') + }) + + it('public route sets guest as user', async function () { + expect(guestResponse.status).to.equal(200) + expect(guestResponse.data.user.name).to.equal("guest") + expect(guestResponse.data.user.dba).to.equal(false) + }) + + it('invalidated cookie reverts to guest access', async function () { + const responseWithOldCookies = await util.axios.get('api/parameters', { headers: { cookie }}) + expect(responseWithOldCookies.status).to.equal(200) + expect(responseWithOldCookies.data.user.name).to.equal("guest") + expect(responseWithOldCookies.data.user.dba).to.equal(false) + }) + }) + }) + + describe('custom XML login using application/xml', function(){ + let cookie, parsedCookies, domainCookie + + before(async function () { + const data = ` + + ${util.adminCredentials.username} + ${util.adminCredentials.password} + +` + const res = await util.axios.post('api/login-xml', data, { + headers: { 'Content-Type': 'application/xml' } + }) + cookie = res.headers['set-cookie']; + parsedCookies = parseCookies(cookie) + domainCookie = getCookieWith(parsedCookies, testAppLoginDomain) + }) + + it('sets one cookie', function () { + expect(cookie).to.have.lengthOf(1) + }) + + it('does not set the ' + jettySessionId + ' cookie', function () { + expect(parsedCookies).to.not.satisfy(oneCookieHas(jettySessionId)) + }) + + it('sets the login domain cookie', function () { + expect(domainCookie).to.exist + }) + + it('domain cookie has defaults', function () { + expect(domainCookie).to.have.property('Path') + expect(domainCookie['Path']).to.equal('/exist/apps/roasted') + expect(domainCookie).to.have.property('Expires') + expect(new Date(domainCookie.Expires).getTime()).to.be.greaterThan(Date.now()) + }) + + it('domain cookie has short lifetime', function () { + expect(domainCookie).to.have.property('Max-Age') + expect(domainCookie['Max-Age']).to.equal('10') + }) + + it('domain cookie has SameSite=strict', function () { + expect(domainCookie).to.have.property('SameSite') + expect(domainCookie['SameSite']).to.equal('Lax') + }) + + it('domain cookie has Secure', function () { + expect(domainCookie).to.have.property('Secure') + }) + + it('domain cookie has HttpOnly', function () { + expect(domainCookie).to.have.property('HttpOnly') + }) + + describe('sets the correct user using cookie auth', function () { + let publicRouteResponse + + before(async function () { + publicRouteResponse = await util.axios.get('api/parameters', { headers: { cookie } }) + }) + + it('public route can be called', async function () { + expect(publicRouteResponse.status).to.equal(200); + }) + + it('user property is set on request map', function () { + expect(publicRouteResponse.data.user).to.be.a('object') + expect(publicRouteResponse.data.user.name).to.equal("admin") + expect(publicRouteResponse.data.user.dba).to.equal(true) + }) + }) + + describe('On logout', function () { + let logoutResponse, guestResponse, updatedCookie, parsedCookies + + before(async function () { + logoutResponse = await util.axios.get('api/logout', { headers: { cookie }}) + updatedCookie = logoutResponse.headers['set-cookie']; + parsedCookies = parseCookies(updatedCookie) + guestResponse = await util.axios.get('api/parameters', { headers: { cookie: updatedCookie }}) + }) + + it('request returns a message', function () { + expect(logoutResponse.status).to.equal(200) + expect(logoutResponse.data.message).to.exist + }) + + it('invalidates session and domain cookie', function () { + expect(updatedCookie.length).to.equal(1) + expect(parsedCookies).to.satisfy(oneCookieHas(testAppLoginDomain)) + const domainCookie = getCookieWith(parsedCookies, testAppLoginDomain) + expect(domainCookie[testAppLoginDomain]).to.equal('deleted') + }) + + it('public route sets guest as user', async function () { + expect(guestResponse.status).to.equal(200) + expect(guestResponse.data.user.name).to.equal("guest") + expect(guestResponse.data.user.dba).to.equal(false) + }) + + it('invalidated cookie reverts to guest access', async function () { + const responseWithOldCookies = await util.axios.get('api/parameters', { headers: { cookie }}) + expect(responseWithOldCookies.status).to.equal(200) + expect(responseWithOldCookies.data.user.name).to.equal("guest") + expect(responseWithOldCookies.data.user.dba).to.equal(false) + }) + }) }) }) diff --git a/test/util.js b/test/util.js index ea79f46..61b5b3a 100644 --- a/test/util.js +++ b/test/util.js @@ -1,27 +1,29 @@ -const chai = require('chai'); -const expect = chai.expect; const axios = require('axios'); const https = require('https') -// read connction options from ENV -const params = { user: 'admin', password: '' } -if (process.env.EXISTDB_USER && 'EXISTDB_PASS' in process.env) { - params.user = process.env.EXISTDB_USER - params.password = process.env.EXISTDB_PASS -} - // for use in custom controller tests const adminCredentials = { - username: params.user, - password: params.password + username: 'admin', + password: '' +} + +// read connction options from ENV +if (process.env.EXISTDB_USER && 'EXISTDB_PASS' in process.env) { + adminCredentials.username = process.env.EXISTDB_USER + adminCredentials.password = process.env.EXISTDB_PASS } const server = 'EXISTDB_SERVER' in process.env ? process.env.EXISTDB_SERVER : 'https://localhost:8443' - + const {origin, hostname} = new URL(server) +// authentication data for normal login +const authForm = new FormData() +authForm.append('user', adminCredentials.username) +authForm.append('password', adminCredentials.password) + const axiosInstance = axios.create({ baseURL: `${origin}/exist/apps/roasted`, headers: { Origin: origin }, @@ -33,26 +35,25 @@ const axiosInstance = axios.create({ async function login() { // console.log('Logging in ' + serverInfo.user + ' to ' + app) - const res = await axiosInstance.request({ - url: 'login', - method: 'post', - params - }); + let res = await axiosInstance.post('login', authForm, { + headers: { 'Content-Type': 'multipart/form-data' } + }) const cookie = res.headers['set-cookie']; - axiosInstance.defaults.headers.Cookie = cookie[0]; - // console.log('Logged in as %s: %s', res.data.user, res.statusText); + axiosInstance.defaults.headers.Cookie = cookie; + // console.log('Logged in as %s: %s', res.data.user, res.statusText, res.headers['set-cookie']); } -async function logout(done) { - const res = await axiosInstance.request({ - url: 'logout', - method: 'get' - }) - +async function logout() { + const res = await axiosInstance.get('logout') const cookie = res.headers["set-cookie"] - axiosInstance.defaults.headers.Cookie = cookie[0] - // console.log('Logged in as %s: %s', res.data.user, res.statusText) + // on logout we only get an update for the domain cookie + // the first cookie, the JSESSIONID, stays intact + axiosInstance.defaults.headers.Cookie = cookie } -module.exports = {axios: axiosInstance, login, logout, adminCredentials }; +module.exports = { + axios: axiosInstance, + login, logout, + adminCredentials, authForm +}; From 4eb8d5742088956c1c0e2967b34ef9eafb510bf7 Mon Sep 17 00:00:00 2001 From: Juri Leino Date: Sat, 21 Sep 2024 21:35:46 -0400 Subject: [PATCH 04/10] fix: raise specific error when route handler is not found When a route tries to call a handler function that does not exist, roaster will return with status code 500 and an actionable description: "Operation not found for operationId:" --- content/router.xql | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/content/router.xql b/content/router.xql index 9f8fb38..e170562 100644 --- a/content/router.xql +++ b/content/router.xql @@ -281,7 +281,12 @@ declare %private function router:execute-handler ($base-request as map(*), $use, let $response := $request-response-array?2 let $fn := $lookup($base-request?config?operationId) - let $handler-response := $fn($request) + let $handler-response := + if (empty($fn)) then ( + error($errors:OPERATION, 'Operation not found for operationId:"' || $base-request?config?operationId || '"', $base-request?config) + ) else ( + $fn($request) + ) return if (router:is-response-map($handler-response)) then From c356cac2ff9db76edf75ddf74d023245284efdf2 Mon Sep 17 00:00:00 2001 From: Juri Leino Date: Sat, 21 Sep 2024 21:36:15 -0400 Subject: [PATCH 05/10] chore(test): fix formatting --- test/mediatype.test.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/mediatype.test.js b/test/mediatype.test.js index c754959..f4e4b60 100644 --- a/test/mediatype.test.js +++ b/test/mediatype.test.js @@ -45,6 +45,7 @@ describe("Binary up and download", function () { expect(res.status).to.equal(201) expect(res.data).to.equal(dbUploadCollection + filename) }) + it('retrieves the data', async function () { const res = await util.axios.get(downloadApiEndpoint + filename, { responseType: 'arraybuffer' }) expect(res.status).to.equal(200) @@ -422,7 +423,7 @@ test. return util.axios.post( 'upload/single/' + filename, data, - { headers } + { headers } ) .then(r => uploadResponse = r) .catch(e => uploadResponse = e.response ) From 7f4dde57e48b9197c8af102019a5f0e68e1807f5 Mon Sep 17 00:00:00 2001 From: Juri Leino Date: Sun, 22 Sep 2024 17:11:02 -0400 Subject: [PATCH 06/10] build: use node v20 --- .github/workflows/node.js.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 9458c66..d98ef51 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -30,10 +30,10 @@ jobs: --health-interval 4s steps: - uses: actions/checkout@v3 - - name: Use Node.js 16 + - name: Use Node.js 20 uses: actions/setup-node@v3 with: - node-version: 16 + node-version: 20 - name: Install dependencies run: npm ci - name: Run tests @@ -48,10 +48,10 @@ jobs: uses: actions/checkout@v3 with: fetch-depth: 0 - - name: Use Node.js 16 + - name: Use Node.js 20 uses: actions/setup-node@v3 with: - node-version: 16 + node-version: 20 - name: Install dependencies run: npm ci - name: Release From e15d551050a29815009ff4392b16a0f516dbbf71 Mon Sep 17 00:00:00 2001 From: Juri Leino Date: Sun, 22 Sep 2024 18:59:05 -0400 Subject: [PATCH 07/10] fix: cookie module must be exported --- content/auth.xql | 2 +- expath-pkg.xml.tmpl | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/content/auth.xql b/content/auth.xql index e4a9001..04deb26 100644 --- a/content/auth.xql +++ b/content/auth.xql @@ -25,7 +25,7 @@ import module namespace session = "http://exist-db.org/xquery/session"; import module namespace router="http://e-editiones.org/roaster/router"; import module namespace rutil="http://e-editiones.org/roaster/util"; import module namespace errors="http://e-editiones.org/roaster/errors"; -import module namespace cookie="http://e-editiones.org/roaster/cookie" at "cookie.xqm"; +import module namespace cookie="http://e-editiones.org/roaster/cookie"; (: API Request Authentication and Authorisation :) diff --git a/expath-pkg.xml.tmpl b/expath-pkg.xml.tmpl index 2e6b418..4c7616c 100644 --- a/expath-pkg.xml.tmpl +++ b/expath-pkg.xml.tmpl @@ -30,4 +30,8 @@ http://e-editiones.org/roaster/body body.xqm + + http://e-editiones.org/roaster/cookie + cookie.xqm + From d5f9049a20d3bd5eb3e3550b4814cc1f202c7ad6 Mon Sep 17 00:00:00 2001 From: Juri Leino Date: Fri, 10 Jan 2025 15:10:54 +0100 Subject: [PATCH 08/10] feat: allow cookie lifetime to be set in seconds - use cookie-name everywhere and deprecate "login domain" - guard against cookie-name not being set --- content/auth.xql | 60 ++++++++++++++++++++++++++++------------ content/cookie.xqm | 59 +++++++++++++++++++++++++++------------ test/app/modules/api.xql | 10 +++---- 3 files changed, 90 insertions(+), 39 deletions(-) diff --git a/content/auth.xql b/content/auth.xql index 04deb26..54ee93c 100644 --- a/content/auth.xql +++ b/content/auth.xql @@ -71,14 +71,16 @@ declare function auth:use-authorization ($strategies as map(*)) as function(*) { auth:authenticate(?, ?, $strategies) }; -(: login-domain must be configured! :) -declare function auth:add-login-domain ($request as map(*), $auth-options as map(*)) as map(*) { - let $login-domain := auth:login-domain($request?spec) +(:~ + : helper function that sets the cookie name according to the API definition + :) +declare function auth:add-cookie-name ($request as map(*), $auth-options as map(*)) as map(*) { + let $cookie-name := auth:read-cookie-name($request?spec) return - if (empty($login-domain)) then ( - error($errors:OPERATION, 'Login domain not specified in API-definition!') + if (empty($cookie-name)) then ( + error($errors:OPERATION, 'Cookie-name not specified in API-definition!') ) else ( - map:put($auth-options, 'name', $login-domain) + map:put($auth-options, 'name', $cookie-name) ) }; @@ -89,10 +91,10 @@ declare function auth:add-login-domain ($request as map(*), $auth-options as map : @throws errors:OPERATION if cookieAuth does not provide a login domain :) declare function auth:login ($request as map(*)) as map(*) { - let $login-domain := auth:login-domain($request?spec) + let $cookie-name := auth:read-cookie-name($request?spec) let $user := auth:login-user( $request?body?user, $request?body?password, - map{ "name": $login-domain } + map{ "name": $cookie-name } ) return @@ -102,12 +104,12 @@ declare function auth:login ($request as map(*)) as map(*) { "user": $user, "groups": array { sm:get-user-groups($user) }, "dba": sm:is-dba($user), - "domain": $login-domain + "domain": $cookie-name } else error($errors:UNAUTHORIZED, "Wrong user or password", map { "user": $user, - "domain": $login-domain + "domain": $cookie-name }) }; @@ -115,10 +117,25 @@ declare function auth:login ($request as map(*)) as map(*) { : Preferred app-specific login function, that will set a cookie for cookieAuth :) declare function auth:login-user ($user as xs:string, $password as xs:string, $options as map(*)) as xs:string? { - let $merged-options := map:merge(($auth:DEFAULT_LOGIN_OPTIONS, $options), map{ "duplicates": "use-last" }) + let $merged-options := + if (empty($options?name)) then ( + error($errors:OPERATION, 'Cookie-name not set in call to auth:login-user!') + ) else ( + map:merge(($auth:DEFAULT_LOGIN_OPTIONS, $options), map{ "duplicates": "use-last" }) + ) + + let $ttl := + if ($merged-options?maxAge instance of xs:dayTimeDuration) then ( + $merged-options?maxAge + ) else if ($merged-options?maxAge instance of xs:integer) then ( + xs:dayTimeDuration('PT' || $merged-options?maxAge || 'S') + ) else ( + error($errors:OPERATION, "the maxAge option value cannot be used", $merged-options?maxAge) + ) + return ( util:log($auth:log-level, ("auth:login-user: ", $user)), - plogin:register($user, $password, $merged-options?maxAge, + plogin:register($user, $password, $ttl, auth:get-register-callback($merged-options)) ) }; @@ -130,7 +147,7 @@ declare function auth:login-user ($user as xs:string, $password as xs:string, $o : @throws errors:OPERATION if cookieAuth does not provide a login domain :) declare function auth:logout ($request as map(*)) as map(*) { - auth:logout-user(map{ "name": auth:login-domain($request?spec) }), + auth:logout-user(map{ "name": auth:read-cookie-name($request?spec) }), map { "success": true(), "message": "logged out" @@ -144,7 +161,7 @@ declare function auth:logout ($request as map(*)) as map(*) { declare function auth:logout-user ($options as map(*)) as empty-sequence() { let $token := if (empty($options?name)) then ( - error($errors:OPERATION, 'Cookie-name (login-domain) not set in call to auth:logout-user!') + error($errors:OPERATION, 'Cookie-name not set in call to auth:logout-user!') ) else ( request:get-cookie-value($options?name) ) @@ -163,8 +180,17 @@ declare %private variable $auth:INVALIDATE_COOKIE := map{ "value": "deleted", "m (:~ : Read the login domain from components.securitySchemes.cookieAuth.name : @param $spec API definition + : @deprecated use auth:read-cookie-name instead :) declare function auth:login-domain ($spec as map(*)) as xs:string? { + auth:read-cookie-name($spec) +}; + +(:~ + : Read the cookie name from the API definition + : @param $spec API definition + :) +declare function auth:read-cookie-name ($spec as map(*)) as xs:string? { router:resolve-pointer($spec, ("components", "securitySchemes", "cookieAuth", "name")) }; @@ -177,12 +203,12 @@ declare function auth:use-cookie-auth ($request as map(*)) as map(*)? { : @throws errors:OPERATION if cookieAuth does not provide a login domain :) declare function auth:use-cookie-auth ($request as map(*), $custom-options as map(*)?) as map(*)? { - let $login-domain := auth:login-domain($request?spec) - let $token := request:get-cookie-value($login-domain) + let $cookie-name := auth:read-cookie-name($request?spec) + let $token := request:get-cookie-value($cookie-name) let $user := if (empty($token)) then () else ( - let $merged-options := map:merge(($auth:DEFAULT_LOGIN_OPTIONS, $custom-options, map{ "name": $login-domain }), map{ "duplicates": "use-last" }) + let $merged-options := map:merge(($auth:DEFAULT_LOGIN_OPTIONS, $custom-options, map{ "name": $cookie-name }), map{ "duplicates": "use-last" }) let $callback := auth:get-credentials-callback($merged-options) return plogin:login($token, $callback) ) diff --git a/content/cookie.xqm b/content/cookie.xqm index e6796d7..27c42a8 100644 --- a/content/cookie.xqm +++ b/content/cookie.xqm @@ -20,6 +20,13 @@ import module namespace response="http://exist-db.org/xquery/response"; import module namespace errors="http://e-editiones.org/roaster/errors"; declare %private variable $cookie:enforce-rfc2109 := "[/()<>@,;:\\""\[\]\?=\{\} \t]"; +declare variable $cookie:properties := map{ + "Domain": "xs:string", + "Path": "xs:string", + "SameSite": ("None", "Lax", "Strict"), + "Secure": "xs:boolean", + "HttpOnly": "xs:boolean" +}; (:~ : Custom implementation of response:set-cookie in XQuery @@ -47,11 +54,11 @@ declare %private variable $cookie:enforce-rfc2109 := "[/()<>@,;:\\""\[\]\?=\{\} declare function cookie:set($options as map(*)) as empty-sequence() { response:set-header('Set-Cookie', string-join( ( - cookie:name-and-value($options), - cookie:lifetime($options), + cookie:name-and-value($options?name, $options?value), + cookie:lifetime($options?maxAge), + cookie:samesite($options?SameSite), cookie:add-property($options, "Domain"), cookie:add-property($options, "Path"), - cookie:add-property($options, "SameSite"), cookie:add-flag($options, "Secure"), cookie:add-flag($options, "HttpOnly") ), @@ -59,25 +66,43 @@ declare function cookie:set($options as map(*)) as empty-sequence() { )) }; -declare %private function cookie:name-and-value($options as map(*)) as xs:string { - if (empty($options?("name")) or empty($options?("value"))) then ( - error($errors:OPERATION, "Cookie name and value must be set", $options) - ) else if (matches($options?name, $cookie:enforce-rfc2109)) then ( - error($errors:OPERATION, "Cookie name contains illegal charecters", $options) - ) else if ($options?name = ("Domain", "Path", "SameSite", "Secure", "HttpOnly")) then ( - error($errors:OPERATION, "Cookie name cannot be equal to property name", $options) +declare %private function cookie:name-and-value($name as xs:string?, $value as xs:string?) as xs:string { + if (empty($name) or empty($value)) then ( + error($errors:OPERATION, "cookie:set: Cookie name and value must be set", ($name, $value)) + ) else + if (matches($name, $cookie:enforce-rfc2109)) then ( + error($errors:OPERATION, "cookie:set: Cookie name contains illegal charecters", $name) + ) else if ($name = map:keys($cookie:properties)) then ( + error($errors:OPERATION, "cookie:set: Cookie name cannot be equal to property name", $name) + ) else ( + $name || "=" || $value + ) +}; + +declare %private function cookie:lifetime($maxAge as item()?) as xs:string* { + if (empty($maxAge)) then ( + (: the cookie will not expire :) + ) else if ( + not($maxAge instance of xs:dayTimeDuration) + and not($maxAge castable as xs:integer) + ) then ( + error($errors:OPERATION, "cookie:set: maxAge must be an instance of xs:dayTimeDuration or xs:integer", $maxAge) + ) else if ($maxAge instance of xs:integer) then ( + "Max-Age=" || $maxAge, + "Expires=" || string(current-dateTime() + xs:dayTimeDuration('PT' || $maxAge || 'S')) ) else ( - $options?name || "=" || $options?value + "Max-Age=" || ($maxAge div xs:dayTimeDuration('PT1S')), + "Expires=" || string(current-dateTime() + $maxAge) ) }; -declare %private function cookie:lifetime($options as map(*)) as xs:string* { - if (empty($options?maxAge)) then () - else if (not($options?maxAge instance of xs:dayTimeDuration)) then ( - error($errors:OPERATION, "maxAge must be an instance of xs:dayTimeDuration", $options) +declare %private function cookie:samesite($samesite as xs:string?) as xs:string? { + if (empty($samesite)) then ( + (: unset :) + ) else if (not($samesite = $cookie:properties?SameSite)) then ( + error($errors:OPERATION, "cookie:set: SameSite must be one of " || string-join($cookie:properties?SameSite, ", "), $samesite) ) else ( - "Max-Age=" || ($options?maxAge div xs:dayTimeDuration('PT1S')), - "Expires=" || string(current-dateTime() + $options?maxAge) + "SameSite=" || $samesite ) }; diff --git a/test/app/modules/api.xql b/test/app/modules/api.xql index dbdbe2f..b3013fa 100644 --- a/test/app/modules/api.xql +++ b/test/app/modules/api.xql @@ -118,7 +118,7 @@ declare function api:avatar ($request as map(*)) { declare variable $api:auth-options := map { "asDba": false(), "createSession": false(), - "maxAge": xs:dayTimeDuration("PT10S"), (: set the cookie time-out to 10 seconds :) + "maxAge": 10, (: set the cookie time-out to 10 seconds using an integer literal :) "Path": "/exist/apps/roasted", (: requests must include this path for the cookie to be included :) "SameSite": "Lax", (: sets the SameSite property to either "None", "Strict" or "Lax" :) "Secure": true(), (: mark the cookie as secure :) @@ -132,8 +132,8 @@ declare variable $api:auth-options := map { :) declare function api:login ($request as map(*)) { let $user := auth:login-user( - $request?body?usr, $request?body?pwd, - auth:add-login-domain($request, $api:auth-options)) + $request?body?usr, $request?body?pwd, + auth:add-cookie-name($request, $api:auth-options)) return if (empty($user)) then ( roaster:response(401, "application/json", @@ -150,7 +150,7 @@ declare function api:login ($request as map(*)) { declare function api:login-xml ($request as map(*)) { let $user := auth:login-user( $request?body//user/string(), $request?body//password/string(), - auth:add-login-domain($request, $api:auth-options)) + auth:add-cookie-name($request, $api:auth-options)) return if (empty($user)) then ( roaster:response(401, "application/xml", @@ -166,7 +166,7 @@ declare function api:login-xml ($request as map(*)) { : Example logout route handler :) declare function api:logout ($request as map(*)) { - auth:logout-user(auth:add-login-domain($request, $api:auth-options)), + auth:logout-user(auth:add-cookie-name($request, $api:auth-options)), (: the request can also be redirected here :) map{ "message": "Logged out" } }; From 27bcdfd1641831cf56f8a07d84cc307d951da5e3 Mon Sep 17 00:00:00 2001 From: Juri Leino Date: Fri, 10 Jan 2025 15:11:26 +0100 Subject: [PATCH 09/10] chore: minor cleanup --- content/auth.xql | 3 +-- test/auth.test.js | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/content/auth.xql b/content/auth.xql index 54ee93c..be15767 100644 --- a/content/auth.xql +++ b/content/auth.xql @@ -19,7 +19,6 @@ module namespace auth="http://e-editiones.org/roaster/auth"; import module namespace plogin="http://exist-db.org/xquery/persistentlogin" at "java:org.exist.xquery.modules.persistentlogin.PersistentLoginModule"; import module namespace request = "http://exist-db.org/xquery/request"; -import module namespace response = "http://exist-db.org/xquery/response"; import module namespace session = "http://exist-db.org/xquery/session"; import module namespace router="http://e-editiones.org/roaster/router"; @@ -302,7 +301,7 @@ declare %private function auth:use-first-matching-method ($request as map(*)) as } }; -declare %private function auth:get-register-callback ($options as map(*)) { +declare %private function auth:get-register-callback ($options as map(*)) as function(*) { function ( $new-token as xs:string?, $user as xs:string, diff --git a/test/auth.test.js b/test/auth.test.js index 076fefe..9aacb53 100644 --- a/test/auth.test.js +++ b/test/auth.test.js @@ -86,7 +86,6 @@ describe('On Login', function () { before(async function () { logoutResponse = await util.axios.get('logout', { headers: { cookie }}) - console.log(logoutResponse) updatedCookie = logoutResponse.headers['set-cookie']; parsedCookies = parseCookies(updatedCookie) guestResponse = await util.axios.get('api/parameters', { headers: { cookie: updatedCookie }}) From 3b0ab4e867a21683bb00b93ab0be7809b6200887 Mon Sep 17 00:00:00 2001 From: Juri Leino Date: Fri, 10 Jan 2025 15:11:49 +0100 Subject: [PATCH 10/10] doc: explain new cookie authentication --- doc/cookie-auth.md | 242 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 242 insertions(+) create mode 100644 doc/cookie-auth.md diff --git a/doc/cookie-auth.md b/doc/cookie-auth.md new file mode 100644 index 0000000..7206fb1 --- /dev/null +++ b/doc/cookie-auth.md @@ -0,0 +1,242 @@ +# Cookie Authentication + +Roaster now uses the PersistentLogin module functions directly and bypasses its convenience functions. +Next to fixing several issues related to login and logout and cookie authentication this allows + +- application specific login and logout route handlers +- reading login information from anywhere in the request +- using XML payloads to login +- set additional login cookie attributes `HttpOnly` and `SameSite` + +These changes are completely backwards compatible and you do not need to do anything to reap some of the benefits. + +However, having the login and logout route handlers in your app allows you to set the additional cookie attributes `HttpOnly` and `SameSite` and also to read them from custom field names and formats like **XML**. + +## Options + +| name | default | description | +| ---- | ---- | ---- | +| `name` | - | **REQUIRED** the name of the cookie to create and read from (AKA "Login Domain") usually set by `auth:add-cookie-name#2`| +| `asDba` | `true()` | Should the login be aborted if the user is not member of DBA? | +| `createSession` | `true()` | this will _also_ set the JSESSIONID cookie | +| `maxAge` | `xs:dayTimeDuration("P7D")` | set the life time of the cookie either as a `xs:dayTimeDuration` or with a literal number in seconds | +| `Path` | `request:get-context-path()` | requests must include this path for the cookie to be included | +| `Domain` | - | "The Domain attribute specifies which server can receive a cookie." | +| `SameSite` | - | one of `"None"`, `"Lax"`, or `"Strict"` | +| `Secure` | - | mark the cookie as secure | +| `HttpOnly` | - | sets the HttpOnly property | + +In order to override default authentication options or add other ones create a map in your API module + +```xquery +declare variable $api:auth-options := map { + "asDba": false(), (: allow non-DBAs to register :) + "createSession": false(), (: do not create a server-side session :) + "maxAge": 10, (: set the cookie time-out to 10 seconds :) + "Path": "/exist/apps/roasted", (: requests must include this path for the cookie to be included :) + "SameSite": "Lax", (: set the SameSite attribute to "Lax" :) + "Secure": true(), (: mark the cookie as secure :) + "HttpOnly": true() (: sets the HttpOnly property :) +}; +``` + +## Login handler + +Instead of using the login handler that comes with Roaster you can use your own one. +The payload will use read username and password information from custom fields +It needs to call `auth:login-user` which needs three parameters: + +- `$username` : In the example below it is sent in the `usr` property in the body +- `$password` : In the example below it is sent in the `pwd` property in the body +- `$options` : A map of options. Make sure to add at least the name - mostly done using `auth:add-cookie-name#2`. + +This function will return a user map if the login was succesful or an empty sequence if it was not. + +Example login route handler using non-standard properties +within the request body to authenticate users against exist-db. + +> The data can also be supplied as JSON and you can redirect the request here to go to the page that was originally requested! + +```xquery +declare function api:login ($request as map(*)) { + let $user := auth:login-user( + $request?body?usr, + $request?body?pwd, + auth:add-cookie-name($request, $api:auth-options) + ) + + return + if (empty($user)) then ( + roaster:response(401, "application/json", + map{ "message": "Wrong user or password" }) + ) else ( + map{ "message": concat("Logged in as ", $user) } + ) +}; +``` + +The corresponding API JSON (shortened for brevity): + +```json +{ + "/api/login": { + "post": { + "operationId": "api:login", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ "usr" ], + "properties": { + "usr": { + "description": "Username", + "type": "string" + }, + "pwd": { + "description": "Password", + "type": "string", + "format": "password" + } + } + } + } + } + } + } + } +} +``` + +### Login with XML + +Example login route handler using XML + + +```xquery +declare function api:login-xml ($request as map(*)) { + let $user := auth:login-user( + $request?body//user/string(), + $request?body//password/string(), + auth:add-cookie-name($request, $api:auth-options) + ) + + return + if (empty($user)) then ( + roaster:response(401, "application/xml", + Wrong user or password) + ) else ( + roaster:response(200, "application/xml", + Logged in as {$user}) + ) +}; +``` + +and the corresponding definition + +```json +{ + "/api/login-xml": { + "post": { + "operationId": "api:login-xml", + "requestBody": { + "required": true, + "content": { + "application/xml": { + "schema": { + "type": "object", + "required": [ "username" ], + "properties": { + "username": { + "description": "Username", + "type": "string" + }, + "password": { + "description": "Password", + "type": "string", + "format": "password" + } + }, + "xml": { + "wrapped": true, + "name": "login" + } + } + } + } + } + } + } +} +``` + +## Logout handler + +Instead of using the route handler to logout users provided by Roaster you should +consider creating your own in order to be more flexible when a user logs out of your application: + +- redirect users afterwards +- perform additional tasks +- show custom messages + +Here is an example logout handler that just returns a custom message encoded as JSON. + +```xquery +declare function api:logout ($request as map(*)) { + auth:logout-user(auth:add-cookie-name($request, $api:auth-options)), + map{ "message": "Logged out" } +}; +``` + +In API JSON + +```json +{ + "/api/logout": { + "get": { + "operationId": "api:logout" + } + } +} +``` + +## Cookies + +Roaster now comes with a new module `content/cookie.xqm`. +It was introduced to overcome the limitations of eXist-db's `response:set-cookie` function +and allows to set additional cookie attributes `HttpOnly` and `SameSite`. +It is as secure as jetty's Cookie class that is used under the hood for the built-in function. +It can be used to set arbitrary cookies although it was created specifically for the use in Roaster. +This functionality is therefore now available in all versions of eXist-db that Roaster +is compatible with. + +```xquery +import module namespace cookie="http://e-editiones.org/roaster/cookie"; + +cookie:set(map { + (: options :) + "name": "my-cookie", + "value": "my-value", + "maxAge": xs:dayTimeDuration("P1D"), + (: properties :) + "Domain": "localhost", + "Path": "/", + "SameSite": true(), + "Secure": true(), + "HttpOnly": "lax" +}) +``` + +### cookie:set Options + +| name | required | description | +| ---- | ---- | ---- | +| `name` | X | used to retrieve the value, can be any string compliant with RFC2109 | +| `value` | X | a string payload that can be read on subsequent requests | +| `maxAge` | | set the life time of the cookie either as a xs:dayTimeDuration or an xs:integer in seconds | +| `Path` | | Requests must include this path for the cookie to be included | +| `Domain` | | The Domain attribute specifies which server can receive a cookie. | +| `SameSite` | | one of `"None"`, `"Lax"`, or `"Strict"` | +| `Secure` | | mark the cookie as secure | +| `HttpOnly` | | sets the HttpOnly property |