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..be15767 100644
--- a/content/auth.xql
+++ b/content/auth.xql
@@ -16,11 +16,15 @@
:)
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 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 +35,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 +53,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 +66,169 @@ 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)
};
+(:~
+ : 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($cookie-name)) then (
+ error($errors:OPERATION, 'Cookie-name not specified in API-definition!')
+ ) else (
+ map:put($auth-options, 'name', $cookie-name)
+ )
+};
+
+(:~
+ : @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 $cookie-name := auth:read-cookie-name($request?spec)
+ let $user := auth:login-user(
+ $request?body?user, $request?body?password,
+ map{ "name": $cookie-name }
+ )
+
+ return
+ if (exists($user))
+ then
+ map {
+ "user": $user,
+ "groups": array { sm:get-user-groups($user) },
+ "dba": sm:is-dba($user),
+ "domain": $cookie-name
+ }
+ else
+ error($errors:UNAUTHORIZED, "Wrong user or password", map {
+ "user": $user,
+ "domain": $cookie-name
+ })
+};
+
+(:~
+ : 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 :=
+ 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, $ttl,
+ 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:read-cookie-name($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 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
+ : @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"))
+};
+
+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 $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": $cookie-name }), 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 +263,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 +279,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(*)) as function(*) {
+ 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..27c42a8
--- /dev/null
+++ b/content/cookie.xqm
@@ -0,0 +1,118 @@
+(:
+ : 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]";
+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
+ : 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?name, $options?value),
+ cookie:lifetime($options?maxAge),
+ cookie:samesite($options?SameSite),
+ cookie:add-property($options, "Domain"),
+ cookie:add-property($options, "Path"),
+ cookie:add-flag($options, "Secure"),
+ cookie:add-flag($options, "HttpOnly")
+ ),
+ "; "
+ ))
+};
+
+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 (
+ "Max-Age=" || ($maxAge div xs:dayTimeDuration('PT1S')),
+ "Expires=" || string(current-dateTime() + $maxAge)
+ )
+};
+
+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 (
+ "SameSite=" || $samesite
+ )
+};
+
+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/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 |
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..b3013fa 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": 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 :)
+ "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-cookie-name($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-cookie-name($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-cookie-name($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..9aacb53 100644
--- a/test/auth.test.js
+++ b/test/auth.test.js
@@ -2,40 +2,506 @@ 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 }})
+ 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
+};