From 350595fc597d98df3b4b86e6aeab4076643b4a0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20Puiggen=C3=A9?= Date: Tue, 28 Apr 2026 15:18:11 +0200 Subject: [PATCH 1/3] Add Bearer token (JWT) authentication via PAS plugin --- setup.py | 2 + src/senaite/jsonapi/config.py | 16 ++ src/senaite/jsonapi/configure.zcml | 19 ++ src/senaite/jsonapi/pas/__init__.py | 19 ++ src/senaite/jsonapi/pas/plugin.py | 221 ++++++++++++++++++ .../jsonapi/profiles/default/metadata.xml | 4 + .../profiles/default/senaite.jsonapi.txt | 1 + src/senaite/jsonapi/setuphandlers.py | 67 ++++++ src/senaite/jsonapi/tests/base.py | 3 + src/senaite/jsonapi/tests/doctests/login.rst | 148 ++++++++++++ src/senaite/jsonapi/v1/routes/users.py | 70 ++++-- 11 files changed, 552 insertions(+), 18 deletions(-) create mode 100644 src/senaite/jsonapi/pas/__init__.py create mode 100644 src/senaite/jsonapi/pas/plugin.py create mode 100644 src/senaite/jsonapi/profiles/default/metadata.xml create mode 100644 src/senaite/jsonapi/profiles/default/senaite.jsonapi.txt create mode 100644 src/senaite/jsonapi/setuphandlers.py create mode 100644 src/senaite/jsonapi/tests/doctests/login.rst diff --git a/setup.py b/setup.py index b34e8c9..51d7de4 100644 --- a/setup.py +++ b/setup.py @@ -38,6 +38,8 @@ install_requires=[ "setuptools", "senaite.core", + # PyJWT >=2.0.0 does not support Python 2.x anymore + "pyjwt<2.0.0", ], extras_require={ "test": [ diff --git a/src/senaite/jsonapi/config.py b/src/senaite/jsonapi/config.py index 7d61653..2f3dec9 100644 --- a/src/senaite/jsonapi/config.py +++ b/src/senaite/jsonapi/config.py @@ -17,3 +17,19 @@ # # Copyright 2017-2025 by it's authors. # Some rights reserved, see README and LICENSE. + +from senaite.jsonapi import PRODUCT_NAME + +# Default JWT lifetime (60 minutes, in seconds) +JWT_TOKENS_TIMEOUT = 60 * 60 + +# Name of the HttpOnly cookie used to carry the JWT token +JWT_COOKIE_ID = "token" + +# Fallback header used to carry the JWT token when neither the +# Authorization Bearer header nor the cookie is set +JWT_HEADER_ID = "X-JWT-Auth-Token" + +# Annotation key used to store the per-user JWT signing secrets on the +# portal (IAnnotations(portal)[KEY_STORAGE] is an OOBTree keyed by userid) +KEY_STORAGE = "%s.pas.keystorage" % PRODUCT_NAME diff --git a/src/senaite/jsonapi/configure.zcml b/src/senaite/jsonapi/configure.zcml index ff561fb..7fb8a71 100644 --- a/src/senaite/jsonapi/configure.zcml +++ b/src/senaite/jsonapi/configure.zcml @@ -1,12 +1,31 @@ + + + + + + + + diff --git a/src/senaite/jsonapi/pas/__init__.py b/src/senaite/jsonapi/pas/__init__.py new file mode 100644 index 0000000..eb0e1d5 --- /dev/null +++ b/src/senaite/jsonapi/pas/__init__.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +# +# This file is part of SENAITE.JSONAPI. +# +# SENAITE.JSONAPI 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, version 2. +# +# 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, write to the Free Software Foundation, Inc., 51 +# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# +# Copyright 2017-2026 by it's authors. +# Some rights reserved, see README and LICENSE. diff --git a/src/senaite/jsonapi/pas/plugin.py b/src/senaite/jsonapi/pas/plugin.py new file mode 100644 index 0000000..61e545c --- /dev/null +++ b/src/senaite/jsonapi/pas/plugin.py @@ -0,0 +1,221 @@ +# -*- coding: utf-8 -*- +# +# This file is part of SENAITE.JSONAPI. +# +# SENAITE.JSONAPI 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, version 2. +# +# 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, write to the Free Software Foundation, Inc., 51 +# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# +# Copyright 2017-2026 by it's authors. +# Some rights reserved, see README and LICENSE. + +from calendar import timegm +from datetime import timedelta + +import jwt +from AccessControl import ClassSecurityInfo +from bika.lims import api +from BTrees.OOBTree import OOBTree +from plone.keyring.keyring import GenerateSecret +from Products.PluggableAuthService.interfaces import plugins +from Products.PluggableAuthService.plugins.BasePlugin import BasePlugin +from senaite.core.api import dtime +from senaite.jsonapi import PRODUCT_NAME +from senaite.jsonapi.config import JWT_COOKIE_ID +from senaite.jsonapi.config import JWT_HEADER_ID +from senaite.jsonapi.config import JWT_TOKENS_TIMEOUT +from senaite.jsonapi.config import KEY_STORAGE +from zope.annotation.interfaces import IAnnotations +from zope.interface import implementer + +# The id of the plugin +ID = "%s.pas.plugin.JWTAuthenticationPlugin" % PRODUCT_NAME + +# Meta type name of the plugin +META_TYPE = "%s: JWT Authentication Plugin" % PRODUCT_NAME + + +@implementer(plugins.IAuthenticationPlugin, plugins.IExtractionPlugin) +class JWTAuthenticationPlugin(BasePlugin): + """PAS plugin for authentication with JSON Web tokens (JWT). + + JWT authentication is a stateless, token-based method where the SENAITE + server issues a signed token to a client after a user logs in. The client + then stores this token and includes it in subsequent requests to access + protected resources, allowing the server to verify the user's identity + without needing to store session data on the server-side. + """ + + id = ID + title = META_TYPE + meta_type = META_TYPE + security = ClassSecurityInfo() + + @security.private + def extractCredentials(self, request): # noqa camelCase + """IExtractionPlugin implementation. Extracts the JSON Web Token (JWT) + from the request, if any. Returns a dict made of {"token": } + :param request: the HTTPRequest object to extract credentials from + :return: a dict with credentials + """ + token = get_jwt_token(request) + if not token: + return {} + return {"token": token} + + @security.private + def authenticateCredentials(self, credentials): # noqa camelCase + """IAuthenticationPlugin implementation, maps credentials to a User ID. + If credentials cannot be authenticated, return None + :param credentials: dict with credentials + :return: a tuple with the user id and login name or None + """ + # Ignore credentials that are not from our extractor + extractor = credentials.get("extractor") + if extractor != self.getId(): + return None + + token = credentials.get("token") + if not token: + return None + + # Peek the payload (no signature check) to learn the userid the + # token claims to belong to + userid = peek_userid(token) + if not userid: + return None + + # Verify signature and expiration with that user's secret + payload = decode_token(token, userid) + if not payload: + return None + + # Defensive: ensure the verified payload still names the same user + if api.to_utf8(payload.get("userid"), default=None) != userid: + return None + + # Make sure the user still exists + user = api.get_user(userid) + if not user: + return None + + return userid, userid + + +def get_jwt_token(request): + """Extracts the JWT token from the request, in this order of preference: + Authorization: Bearer header, "token" cookie, X-JWT-Auth-Token header. + """ + # Read from Authorization header + auth = request._auth # noqa + if auth and auth[:7].lower() == "bearer ": + return auth.split()[-1] + + # Read from cookie + token = request.cookies.get(JWT_COOKIE_ID) + if token: + return token + + # Read from header + return request.getHeader(JWT_HEADER_ID) + + +def get_keystorage(): + """Returns the OOBTree where per-user JWT signing secrets are stored, + creating it lazily on the portal's annotations on first access. + """ + annotation = IAnnotations(api.get_portal()) + storage = annotation.get(KEY_STORAGE) + if storage is None: + annotation[KEY_STORAGE] = OOBTree() + return annotation[KEY_STORAGE] + + +def signing_secret(userid): + """Returns the signing secret for the given userid, generating one + on first call. + """ + userid = api.to_utf8(userid, default=None) + if not userid: + raise ValueError("Invalid userid: %r" % userid) + + storage = get_keystorage() + data = storage.get(userid) + if not data: + storage[userid] = { + "key": GenerateSecret(), + "created": timestamp(), + } + return storage[userid]["key"] + + +def rotate_secret(userid): + """Discards the signing secret for the given userid, so a new one is + generated on the next call to ``signing_secret``. All previously + issued tokens for the user are invalidated. + """ + userid = api.to_utf8(userid, default=None) + if not userid: + return + storage = get_keystorage() + if userid in storage: + del storage[userid] + + +def timestamp(seconds=0, minutes=0, hours=0, days=0): + """Returns a Unix timestamp from GMT plus the given offset. + """ + delta = timedelta(seconds=seconds, minutes=minutes, hours=hours, days=days) + utc = dtime.datetime.utcnow() + delta + return timegm(utc.utctimetuple()) + + +def peek_userid(token): + """Returns the ``userid`` claim of the given token without verifying + the signature, or None if the token cannot be decoded. + """ + token = api.to_utf8(token, default=None) + if not token: + return None + try: + payload = jwt.decode(token, verify=False) + except (ValueError, TypeError, jwt.InvalidTokenError): + return None + return api.to_utf8(payload.get("userid"), default=None) + + +def decode_token(token, userid): + """Decodes the given JWT, verifies the signature with ``userid``'s + secret and checks the expiration. Returns the payload or None. + """ + token = api.to_utf8(token, default=None) + if not token: + return None + try: + secret = signing_secret(userid) + return jwt.decode(token, secret, algorithms=["HS256"]) + except (ValueError, TypeError, jwt.InvalidTokenError): + return None + + +def create_token(userid, timeout=JWT_TOKENS_TIMEOUT, **kwargs): + """Creates a JSON Web Token (JWT) for the given userid. + """ + payload = { + "userid": userid, + "exp": timestamp(seconds=timeout), + } + payload.update(kwargs) + + secret = signing_secret(userid) + token = jwt.encode(payload, secret, algorithm="HS256") + return api.safe_unicode(token) diff --git a/src/senaite/jsonapi/profiles/default/metadata.xml b/src/senaite/jsonapi/profiles/default/metadata.xml new file mode 100644 index 0000000..cf4492a --- /dev/null +++ b/src/senaite/jsonapi/profiles/default/metadata.xml @@ -0,0 +1,4 @@ + + + 1 + diff --git a/src/senaite/jsonapi/profiles/default/senaite.jsonapi.txt b/src/senaite/jsonapi/profiles/default/senaite.jsonapi.txt new file mode 100644 index 0000000..e168503 --- /dev/null +++ b/src/senaite/jsonapi/profiles/default/senaite.jsonapi.txt @@ -0,0 +1 @@ +Marker file checked by senaite.jsonapi.setuphandlers.setup_handler diff --git a/src/senaite/jsonapi/setuphandlers.py b/src/senaite/jsonapi/setuphandlers.py new file mode 100644 index 0000000..54afb1a --- /dev/null +++ b/src/senaite/jsonapi/setuphandlers.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- +# +# This file is part of SENAITE.JSONAPI. +# +# SENAITE.JSONAPI 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, version 2. +# +# 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, write to the Free Software Foundation, Inc., 51 +# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# +# Copyright 2017-2025 by it's authors. +# Some rights reserved, see README and LICENSE. + +from Products.PlonePAS.setuphandlers import activatePluginInterfaces +from senaite.jsonapi import logger +from senaite.jsonapi import PRODUCT_NAME +from senaite.jsonapi.pas.plugin import JWTAuthenticationPlugin + + +def setup_handler(context): + """Generic setup handler for senaite.jsonapi + """ + install_file = "%s.txt" % PRODUCT_NAME + if context.readDataFile(install_file) is None: + return + + logger.info("%s setup handler [BEGIN]" % PRODUCT_NAME.upper()) + portal = context.getSite() + + setup_pas_plugin(portal) + + logger.info("%s setup handler [DONE]" % PRODUCT_NAME.upper()) + + +def setup_pas_plugin(portal): + """Register the JWT PAS plugin in the site's acl_users so JWT-based + authentication becomes active. + """ + plugin = JWTAuthenticationPlugin() + plugin_id = plugin.getId() + + logger.info("Setup %s plugin ..." % plugin_id) + + pas = portal.acl_users + if plugin_id not in pas.objectIds(): + # Add the plugin + pas._setObject(plugin_id, plugin) # noqa + + # Activate all supported interfaces for this plugin + activatePluginInterfaces(pas, plugin_id) + + # Make our plugin the first one for these interfaces, so the JWT + # token takes precedence over other extractors/authenticators + ifaces = ["IExtractionPlugin", "IAuthenticationPlugin"] + plugin_registry = pas.plugins + for iface_name in ifaces: + iface = plugin_registry._getInterfaceFromName(iface_name) # noqa + plugin_registry.movePluginsTop(iface, [plugin_id]) + + logger.info("Setup %s plugin [DONE]" % plugin_id) diff --git a/src/senaite/jsonapi/tests/base.py b/src/senaite/jsonapi/tests/base.py index 39f9bcc..b565022 100644 --- a/src/senaite/jsonapi/tests/base.py +++ b/src/senaite/jsonapi/tests/base.py @@ -78,6 +78,9 @@ def setUpPloneSite(self, portal): # Apply Setup Profile (portal_quickinstaller) applyProfile(portal, "senaite.core:default") + # Install the JWT PAS plugin + applyProfile(portal, "senaite.jsonapi:default") + # Add test users self.add_test_users(portal) diff --git a/src/senaite/jsonapi/tests/doctests/login.rst b/src/senaite/jsonapi/tests/doctests/login.rst new file mode 100644 index 0000000..8f23c86 --- /dev/null +++ b/src/senaite/jsonapi/tests/doctests/login.rst @@ -0,0 +1,148 @@ +JWT LOGIN +--------- + +Running this test from the buildout directory: + + bin/test test_doctests -t login + + +Test Setup +~~~~~~~~~~ + +Needed Imports: + + >>> import json + >>> import transaction + >>> from base64 import b64encode + >>> from plone.app.testing import setRoles + >>> from plone.app.testing import TEST_USER_ID + >>> from plone.app.testing import TEST_USER_PASSWORD + >>> from plone.testing.zope import Browser + >>> from senaite.jsonapi.pas.plugin import create_token + >>> from senaite.jsonapi.pas.plugin import rotate_secret + >>> from senaite.jsonapi.pas.plugin import timestamp + + +Functional Helpers: + + >>> def fresh_browser(): + ... b = Browser(self.portal) + ... b.addHeader("Accept-Language", "en-US") + ... b.handleErrors = False + ... return b + + >>> def basic(b, userid, password): + ... b.addHeader( + ... "Authorization", "Basic {}:{}".format(userid, password)) + ... return b + + +Variables: + + >>> portal = self.portal + >>> portal_url = portal.absolute_url() + >>> api_url = "{}/@@API/senaite/v1".format(portal_url) + >>> setRoles(portal, TEST_USER_ID, ["LabManager", "Manager"]) + >>> transaction.commit() + + +Issue a token via /login +~~~~~~~~~~~~~~~~~~~~~~~~ + +An already-authenticated user calls /login and obtains a JWT in the +response body. The setup helper ``self.getBrowser()`` returns a browser +that has a Plone session cookie, so the request is authenticated by +the cookie auth plugin before the route runs: + + >>> browser = self.getBrowser() + >>> browser.open("{}/login".format(api_url)) + >>> data = json.loads(browser.contents) + >>> data["items"][0]["authenticated"] + True + + >>> token = data["token"] + >>> isinstance(token, basestring) and len(token) > 0 + True + + >>> data["expires"] > timestamp() + True + +The same response sets the JWT as an HttpOnly cookie: + + >>> "token" in browser.cookies + True + + +Authenticate with Authorization: Bearer +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +A fresh browser (no Plone cookie, no Basic auth) authenticated only +with the Bearer token can access the auth-protected route: + + >>> bearer = fresh_browser() + >>> bearer.addHeader("Authorization", "Bearer {}".format(token)) + >>> bearer.open("{}/auth".format(api_url)) + >>> "_runtime" in bearer.contents + True + +And `/users/current` returns the authenticated user: + + >>> bearer.open("{}/users/current".format(api_url)) + >>> info = json.loads(bearer.contents) + >>> info["items"][0]["authenticated"] + True + + +An invalid Bearer token is rejected +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +A bogus token must not authenticate the request: + + >>> bogus = fresh_browser() + >>> bogus.addHeader("Authorization", "Bearer not-a-real-token") + >>> bogus.open("{}/auth".format(api_url)) + Traceback (most recent call last): + ... + HTTPError: HTTP Error 401: Unauthorized + + +An expired token is rejected +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +A token that has already expired is treated as anonymous and rejected +by the auth route: + + >>> expired_token = create_token(TEST_USER_ID, exp=timestamp(seconds=-60)) + >>> transaction.commit() + >>> expired = fresh_browser() + >>> expired.addHeader("Authorization", "Bearer {}".format(expired_token)) + >>> expired.open("{}/auth".format(api_url)) + Traceback (most recent call last): + ... + HTTPError: HTTP Error 401: Unauthorized + + +Rotating the user's secret revokes existing tokens +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +After rotating, the previously-issued token no longer verifies: + + >>> rotate_secret(TEST_USER_ID) + >>> transaction.commit() + + >>> revoked = fresh_browser() + >>> revoked.addHeader("Authorization", "Bearer {}".format(token)) + >>> revoked.open("{}/auth".format(api_url)) + Traceback (most recent call last): + ... + HTTPError: HTTP Error 401: Unauthorized + +A freshly issued token works again: + + >>> new_token = create_token(TEST_USER_ID) + >>> transaction.commit() + >>> renewed = fresh_browser() + >>> renewed.addHeader("Authorization", "Bearer {}".format(new_token)) + >>> renewed.open("{}/auth".format(api_url)) + >>> "_runtime" in renewed.contents + True diff --git a/src/senaite/jsonapi/v1/routes/users.py b/src/senaite/jsonapi/v1/routes/users.py index 5d161e9..a2dbcf1 100644 --- a/src/senaite/jsonapi/v1/routes/users.py +++ b/src/senaite/jsonapi/v1/routes/users.py @@ -23,6 +23,10 @@ from senaite.jsonapi import api from senaite.jsonapi import logger from senaite.jsonapi import request as req +from senaite.jsonapi.config import JWT_COOKIE_ID +from senaite.jsonapi.config import JWT_TOKENS_TIMEOUT +from senaite.jsonapi.pas.plugin import create_token +from senaite.jsonapi.pas.plugin import timestamp from senaite.jsonapi.v1 import add_route from senaite.jsonapi.interfaces import IInfo from senaite.jsonapi.interfaces import IUsersFilter @@ -141,34 +145,58 @@ def auth(context, request): def login(context, request): """ Login Route - Login route to authenticate a user against Plone. + Authenticates the user and issues a JSON Web Token (JWT). Two + authentication modes are supported: + + 1. HTTP Basic auth (header ``Authorization: Basic ...``): no body + fields are needed; the route just issues a token for the user + that was already authenticated by the PAS layer. + 2. Form login: POST ``__ac_name`` and ``__ac_password`` as form + fields. The route logs the user in via the cookie auth plugin + and then issues the token. + + The JWT is returned in the JSON body as ``token`` (with ``expires`` + as a Unix timestamp) and is also set as the ``token`` HttpOnly + cookie so subsequent requests can be authenticated either via + ``Authorization: Bearer `` or via the cookie. """ # extract the data __ac_name = request.get("__ac_name", None) __ac_password = request.get("__ac_password", None) - logger.info("*** LOGIN %s ***" % __ac_name) - - if __ac_name is None: - api.fail(400, "__ac_name is missing") - if __ac_password is None: - api.fail(400, "__ac_password is missing") - - acl_users = api.get_tool("acl_users") - - # XXX hard coded - acl_users.credentials_cookie_auth.login() + logger.info("*** LOGIN %s ***" % (__ac_name or "")) - # XXX admin user won't be logged in if I use this approach - # acl_users.login() - # response = request.response - # acl_users.updateCredentials(request, response, __ac_name, __ac_password) + # Form-based login path: log the user in via the cookie auth plugin. + # Basic-auth requests (and other PAS-authenticated requests) skip this + # block since the user is already authenticated by the time the route + # is dispatched. + if __ac_name is not None and __ac_password is not None: + acl_users = api.get_tool("acl_users") + # XXX hard coded + acl_users.credentials_cookie_auth.login() if api.is_anonymous(): api.fail(401, "Invalid Credentials") - # return the JSON in the same format like the user route - return get(context, request, username=__ac_name) + # Issue a JWT for the now-authenticated user + userid = api.get_current_user().getId() + expires = timestamp(seconds=JWT_TOKENS_TIMEOUT) + token = create_token(userid, exp=expires) + + # Set the token as an HttpOnly cookie so cookie-based clients can use + # it transparently + request.response.setCookie( + JWT_COOKIE_ID, token, + http_only=True, path="/", same_site="None", expires=expires, + ) + + # Return the user info merged with the token payload + info = get(context, request, username=userid) or {} + info.update({ + "token": token, + "expires": expires, + }) + return info @add_route("/logout", "senaite.jsonapi.v1.logout", methods=["GET"]) @@ -185,6 +213,12 @@ def logout(context, request): acl_users = api.get_tool("acl_users") acl_users.logout(request) + # Expire the JWT cookie on the client side. Note this does not + # invalidate the JWT itself — to revoke a token before its + # expiration, rotate the user's signing secret with + # ``senaite.jsonapi.pas.plugin.rotate_secret``. + request.response.expireCookie(JWT_COOKIE_ID, path="/") + return { "url": api.url_for("senaite.jsonapi.v1.users"), "authenticated": False, From e0d99483484b5343b3a8463ec75e3573231a8bb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20Puiggen=C3=A9?= Date: Tue, 28 Apr 2026 15:29:07 +0200 Subject: [PATCH 2/3] Added bearer doctest --- src/senaite/jsonapi/tests/doctests/bearer.rst | 127 ++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 src/senaite/jsonapi/tests/doctests/bearer.rst diff --git a/src/senaite/jsonapi/tests/doctests/bearer.rst b/src/senaite/jsonapi/tests/doctests/bearer.rst new file mode 100644 index 0000000..ef4262b --- /dev/null +++ b/src/senaite/jsonapi/tests/doctests/bearer.rst @@ -0,0 +1,127 @@ +BEARER TOKEN AUTHENTICATION +--------------------------- + +Running this test from the buildout directory: + + bin/test test_doctests -t bearer + + +Test Setup +~~~~~~~~~~ + +Needed Imports: + + >>> import json + >>> import transaction + >>> from plone.app.testing import setRoles + >>> from plone.app.testing import TEST_USER_ID + >>> from plone.testing.zope import Browser + >>> from senaite.jsonapi.pas.plugin import create_token + >>> from senaite.jsonapi.pas.plugin import rotate_secret + >>> from senaite.jsonapi.pas.plugin import timestamp + + +Functional Helpers: + + >>> def fresh_browser(): + ... b = Browser(self.portal) + ... b.addHeader("Accept-Language", "en-US") + ... b.handleErrors = False + ... return b + + >>> def with_bearer(b, token): + ... b.addHeader("Authorization", "Bearer {}".format(token)) + ... return b + + >>> def is_authenticated(b): + ... b.open("{}/users/current".format(api_url)) + ... return json.loads(b.contents)["items"][0]["authenticated"] + + +Variables: + + >>> portal = self.portal + >>> portal_url = portal.absolute_url() + >>> api_url = "{}/@@API/senaite/v1".format(portal_url) + >>> setRoles(portal, TEST_USER_ID, ["LabManager", "Manager"]) + >>> transaction.commit() + + +No token, no authentication +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +A fresh browser without any credentials is anonymous: + + >>> is_authenticated(fresh_browser()) + False + + +A valid Bearer token authenticates the request +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Issue a token for the test user (this is what /login does internally +once the user is authenticated): + + >>> token = create_token(TEST_USER_ID) + >>> transaction.commit() + >>> isinstance(token, basestring) and len(token) > 0 + True + +A fresh browser carrying that token in the ``Authorization: Bearer`` +header is authenticated by the JWT PAS plugin: + + >>> is_authenticated(with_bearer(fresh_browser(), token)) + True + + +An invalid Bearer token does not authenticate +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +A garbage value cannot be decoded, so the request stays anonymous: + + >>> is_authenticated(with_bearer(fresh_browser(), "not-a-real-token")) + False + + +A token signed with the wrong secret does not authenticate +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +A well-formed JWT signed with a different secret fails signature +verification: + + >>> import jwt as pyjwt + >>> forged = pyjwt.encode( + ... {"userid": TEST_USER_ID, "exp": timestamp(seconds=3600)}, + ... "wrong-secret", algorithm="HS256") + >>> is_authenticated(with_bearer(fresh_browser(), forged)) + False + + +An expired token does not authenticate +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +A token whose ``exp`` claim lies in the past is rejected by PyJWT: + + >>> expired_token = create_token(TEST_USER_ID, exp=timestamp(seconds=-60)) + >>> transaction.commit() + >>> is_authenticated(with_bearer(fresh_browser(), expired_token)) + False + + +Rotating the user's secret revokes existing tokens +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +After ``rotate_secret``, the previously-issued token no longer +verifies: + + >>> rotate_secret(TEST_USER_ID) + >>> transaction.commit() + >>> is_authenticated(with_bearer(fresh_browser(), token)) + False + +A token issued after the rotation is accepted again: + + >>> new_token = create_token(TEST_USER_ID) + >>> transaction.commit() + >>> is_authenticated(with_bearer(fresh_browser(), new_token)) + True From badc8166b6ce684b14a827aea913bb8523248838 Mon Sep 17 00:00:00 2001 From: Ramon Bartl Date: Sat, 27 Jun 2026 08:04:40 +0200 Subject: [PATCH 3/3] Add #88 changelog entry --- docs/changelog.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 64384e2..9820f60 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,6 +4,7 @@ Changelog 2.7.0 (unreleased) ------------------ +- #88 Add Bearer token (JWT) authentication via PAS plugin - #87 Inject additional analyses fields - #85 Allow field projection - #81 Support second-level precision on searches against DateIndex