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 diff --git a/content/auth.xql b/content/auth.xql index 1daec0d..04deb26 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"; (: 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/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 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 + 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", 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/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 ) 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*!'); 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 +};