Skip to content

Commit cbd6e35

Browse files
xisparamonski
andauthored
Add Bearer token (JWT) authentication via PAS plugin (#88)
* Add Bearer token (JWT) authentication via PAS plugin * Added bearer doctest * Add #88 changelog entry * Set Secure + SameSite=Lax on JWT cookie SameSite=None requires Secure on modern browsers, which silently drops the cookie over HTTP. Lax doesn't have that constraint and is the right default for an API token cookie; clients that need cross-site requests should use the Authorization: Bearer header instead. * Document JWT/Bearer auth and explain peek_userid trust model * Drop cookie assertion from login doctest The test browser drives the API over plain HTTP, so the Secure cookie set by /login is not retained. Bearer-header auth is exercised in the following section and is the recommended path for non-HTTPS clients. * Add 'Try it' section to auth docs * Use single backticks in auth docs * Test X-JWT-Auth-Token fallback and cookie security attributes * Add uninstall profile, declare senaite.core dependency, fix X-JWT-Auth-Token doctest - profiles/default/metadata.xml declares profile-senaite.core:default as dependency. - New profiles/uninstall profile + uninstall_handler removes the JWT PAS plugin and clears the per-user key storage annotation. - Changelog note that existing 2.6.x sites must install the new profile from Site Setup -> Add-ons to enable JWT authentication. - Encode the token as ASCII bytes when setting the X-JWT-Auth-Token doctest header to satisfy WebTest's WSGI environ lint. * Hide uninstall profile from Add-ons panel via INonInstallable Register an INonInstallable adapter so the Add-ons control panel only lists 'SENAITE JSONAPI' (the default profile) as installable; the uninstall profile is applied automatically when the user clicks 'Uninstall' and should not appear as a separate add-on entry. --------- Co-authored-by: Ramon Bartl <rb@ridingbytes.com>
1 parent dd2c845 commit cbd6e35

16 files changed

Lines changed: 1009 additions & 28 deletions

File tree

docs/auth.rst

Lines changed: 192 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,27 @@ The API provides a simple way to authenticate a user with SENAITE.
77
Login
88
-----
99

10-
:URL Schema: ``<BASE URL>/login?__ac_name=<username>&__ac_password=<password>``
10+
:URL Schema: `<BASE URL>/login?__ac_name=<username>&__ac_password=<password>`
1111

12-
The response will set the `__ac` cookie for further cookie authenticated requests.
12+
The response sets the `__ac` cookie for further cookie-authenticated
13+
requests. In addition, a JSON Web Token (JWT) is returned in the
14+
response body as `token` (with the Unix expiration timestamp as
15+
`expires`) and is also set as the HttpOnly `token` cookie.
16+
Subsequent requests can be authenticated with either the cookie or by
17+
sending the token in the `Authorization: Bearer <token>` header (see
18+
`Bearer Token Authentication` below).
1319

14-
.. note:: Currently only cookie authentication works. Other PAS plugins might
15-
not work as expected.
20+
The route accepts two authentication modes:
21+
22+
1. **HTTP Basic auth** (`Authorization: Basic ...` header). No body
23+
fields are needed; the route issues a token for the user that was
24+
already authenticated by the PAS layer.
25+
2. **Form login** with `__ac_name` and `__ac_password` as form
26+
fields, as shown in the example below.
1627

1728
Example
1829

19-
``http://localhost:8080/senaite/@@API/senaite/v1/login?__ac_name=admin&__ac_password=admin``
30+
`http://localhost:8080/senaite/@@API/senaite/v1/login?__ac_name=admin&__ac_password=admin`
2031

2132
Response
2233

@@ -25,6 +36,8 @@ Response
2536
{
2637
url: "http://localhost:8080/senaite/@@API/senaite/v1/users",
2738
count: 1,
39+
token: "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
40+
expires: 1735689600,
2841
_runtime: 0.0019960403442382812,
2942
items: [
3043
{
@@ -49,13 +62,19 @@ Response
4962
Logout
5063
------
5164

52-
:URL Schema: ``<BASE URL>/users/logout``
65+
:URL Schema: `<BASE URL>/users/logout`
5366

54-
The response will expire the `__ac` cookie for further requests.
67+
The response expires the `__ac` and `token` cookies on the client.
68+
Note that this does not invalidate the JWT itself: a token that was
69+
already extracted from the response and stored by the client remains
70+
valid until its `exp` claim is reached. To revoke a token
71+
server-side before it expires, rotate the user's signing secret with
72+
`senaite.jsonapi.pas.plugin.rotate_secret(userid)`; this invalidates
73+
every token previously issued for that user.
5574

5675
Example
5776

58-
``http://localhost:8080/senaite/@@API/senaite/v1/users/logout``
77+
`http://localhost:8080/senaite/@@API/senaite/v1/users/logout`
5978

6079
Response
6180

@@ -71,12 +90,175 @@ Response
7190
Basic Authentication
7291
--------------------
7392

74-
:URL Schema: ``<BASE URL>/auth``
93+
:URL Schema: `<BASE URL>/auth`
7594

7695
If the request is not authenticated, this route will raise an unauthorized
7796
response with status code 401. Browsers should display the Basic Authentication
7897
login.
7998

8099
Example
81100

82-
``http://localhost:8080/senaite/@@API/senaite/v1/auth``
101+
`http://localhost:8080/senaite/@@API/senaite/v1/auth`
102+
103+
104+
Bearer Token Authentication
105+
---------------------------
106+
107+
After a successful `/login` call, the JWT returned in the response
108+
body can be used to authenticate any subsequent request by sending it
109+
in the standard `Authorization` header:
110+
111+
.. code-block:: text
112+
113+
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...
114+
115+
The token is extracted, in this order, from:
116+
117+
1. `Authorization: Bearer <token>` header
118+
2. `token` cookie (HttpOnly, set automatically by `/login`)
119+
3. `X-JWT-Auth-Token` header (fallback for clients that cannot set
120+
the `Authorization` header)
121+
122+
Tokens are signed per user with a secret generated on first use and
123+
stored in the portal's annotations. The default token lifetime is
124+
60 minutes (`JWT_TOKENS_TIMEOUT` in `senaite.jsonapi.config`).
125+
126+
Example
127+
128+
.. code-block:: bash
129+
130+
curl -H "Authorization: Bearer $TOKEN" \
131+
http://localhost:8080/senaite/@@API/senaite/v1/users/current
132+
133+
134+
Revoking tokens
135+
~~~~~~~~~~~~~~~
136+
137+
JWTs are stateless: `/logout` only expires the client-side cookie. To
138+
revoke every token issued for a given user, rotate their signing
139+
secret from a debug shell or upgrade step:
140+
141+
.. code-block:: python
142+
143+
from senaite.jsonapi.pas.plugin import rotate_secret
144+
rotate_secret("johndoe")
145+
146+
The next call to `/login` for that user generates a new secret and
147+
issues a new token; all previously-issued tokens fail signature
148+
verification.
149+
150+
151+
Cookie security
152+
~~~~~~~~~~~~~~~
153+
154+
The `token` cookie is set with `HttpOnly`, `Secure` and
155+
`SameSite=Lax`. The `Secure` flag means the cookie is only sent
156+
over HTTPS; on plain HTTP setups (local development, non-TLS
157+
deployments) the cookie will not round-trip and clients must use the
158+
`Authorization: Bearer` header instead.
159+
160+
161+
Try it
162+
------
163+
164+
DevTools console (fastest)
165+
~~~~~~~~~~~~~~~~~~~~~~~~~~
166+
167+
Log into SENAITE via the standard UI so the browser has a Plone
168+
session, then open the DevTools console and run:
169+
170+
.. code-block:: javascript
171+
172+
// /login is cookie-authenticated by the existing session and
173+
// returns a fresh JWT in the body
174+
const r = await fetch("/senaite/@@API/senaite/v1/login",
175+
{credentials: "include"})
176+
const {token} = await r.json()
177+
console.log(token)
178+
179+
// Use the token to authenticate any subsequent request
180+
const me = await fetch("/senaite/@@API/senaite/v1/users/current", {
181+
headers: {Authorization: `Bearer ${token}`}
182+
})
183+
console.log(await me.json())
184+
185+
Over plain `http://` the `Secure` `token` cookie is not retained
186+
by the browser, but the JSON body still carries the token — use the
187+
`Authorization: Bearer` header.
188+
189+
190+
curl
191+
~~~~
192+
193+
.. code-block:: bash
194+
195+
# Form login
196+
TOKEN=$(curl -s -X POST \
197+
http://localhost:8080/senaite/@@API/senaite/v1/login \
198+
-d "__ac_name=admin&__ac_password=admin" | jq -r .token)
199+
200+
# Or Basic auth
201+
TOKEN=$(curl -s -u admin:admin \
202+
http://localhost:8080/senaite/@@API/senaite/v1/login | jq -r .token)
203+
204+
# Use it
205+
curl -H "Authorization: Bearer $TOKEN" \
206+
http://localhost:8080/senaite/@@API/senaite/v1/users/current
207+
208+
Verify the expected failures:
209+
210+
.. code-block:: bash
211+
212+
# Bogus token -> 401
213+
curl -i -H "Authorization: Bearer not-a-real-token" \
214+
http://localhost:8080/senaite/@@API/senaite/v1/auth
215+
216+
# No token -> 401
217+
curl -i http://localhost:8080/senaite/@@API/senaite/v1/auth
218+
219+
220+
Test the Secure cookie path
221+
~~~~~~~~~~~~~~~~~~~~~~~~~~~
222+
223+
The `Secure` flag requires HTTPS. Easiest local option is to put a
224+
reverse proxy in front of the Zope instance, for example with Caddy:
225+
226+
.. code-block:: bash
227+
228+
caddy reverse-proxy --from https://localhost:8443 --to localhost:8080
229+
230+
After `/login`, open DevTools → Application → Cookies →
231+
`https://localhost:8443` and verify the `token` cookie is set
232+
with `HttpOnly`, `Secure` and `SameSite=Lax`. Subsequent
233+
`fetch` calls without the `Authorization` header are authenticated
234+
by the cookie automatically.
235+
236+
237+
Revocation
238+
~~~~~~~~~~
239+
240+
Rotate the user's signing secret from a debug shell, then reuse the
241+
old token to confirm it is rejected:
242+
243+
.. code-block:: python
244+
245+
bin/instance debug
246+
>>> from senaite.jsonapi.pas.plugin import rotate_secret
247+
>>> rotate_secret("admin")
248+
>>> import transaction; transaction.commit()
249+
250+
.. code-block:: bash
251+
252+
# Previously valid token -> 401
253+
curl -i -H "Authorization: Bearer $TOKEN" \
254+
http://localhost:8080/senaite/@@API/senaite/v1/auth
255+
256+
257+
REST client (Bruno, Insomnia, Postman)
258+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
259+
260+
Define a collection-level variable `token` and populate it from the
261+
`/login` response (most clients support extracting a JSON field into
262+
an environment variable). Add the header
263+
`Authorization: Bearer {{token}}` at the collection level so every
264+
request in the collection authenticates transparently.

docs/changelog.rst

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,18 @@ Changelog
44
2.7.0 (unreleased)
55
------------------
66

7+
.. note::
8+
9+
This release introduces the first GenericSetup profile for
10+
senaite.jsonapi. Existing sites upgrading from 2.6.x must install
11+
the add-on once: go to `Site Setup` -> `Add-ons`, find
12+
*SENAITE JSONAPI* in the "Available add-ons" list, and click
13+
*Install*. The cookie and Basic auth paths keep working without
14+
the install; only the JWT authentication path needs the profile
15+
applied. Uninstalling from the same panel removes the PAS plugin
16+
and the per-user JWT signing secrets.
17+
18+
- #88 Add Bearer token (JWT) authentication via PAS plugin
719
- #89 Fix inherited schema fields missing for multi-schema Dexterity types
820
- #87 Inject additional analyses fields
921
- #85 Allow field projection

setup.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@
3838
install_requires=[
3939
"setuptools",
4040
"senaite.core",
41+
# PyJWT >=2.0.0 does not support Python 2.x anymore
42+
"pyjwt<2.0.0",
4143
],
4244
extras_require={
4345
"test": [

src/senaite/jsonapi/config.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,19 @@
1717
#
1818
# Copyright 2017-2025 by it's authors.
1919
# Some rights reserved, see README and LICENSE.
20+
21+
from senaite.jsonapi import PRODUCT_NAME
22+
23+
# Default JWT lifetime (60 minutes, in seconds)
24+
JWT_TOKENS_TIMEOUT = 60 * 60
25+
26+
# Name of the HttpOnly cookie used to carry the JWT token
27+
JWT_COOKIE_ID = "token"
28+
29+
# Fallback header used to carry the JWT token when neither the
30+
# Authorization Bearer header nor the cookie is set
31+
JWT_HEADER_ID = "X-JWT-Auth-Token"
32+
33+
# Annotation key used to store the per-user JWT signing secrets on the
34+
# portal (IAnnotations(portal)[KEY_STORAGE] is an OOBTree keyed by userid)
35+
KEY_STORAGE = "%s.pas.keystorage" % PRODUCT_NAME

src/senaite/jsonapi/configure.zcml

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,55 @@
11
<configure
22
xmlns="http://namespaces.zope.org/zope"
33
xmlns:browser="http://namespaces.zope.org/browser"
4+
xmlns:genericsetup="http://namespaces.zope.org/genericsetup"
45
xmlns:zcml="http://namespaces.zope.org/zcml"
56
i18n_domain="senaite.jsonapi">
67

78
<!-- Package Includes -->
89
<include package=".v1"/>
910

11+
<!-- GenericSetup profile -->
12+
<genericsetup:registerProfile
13+
name="default"
14+
title="SENAITE JSONAPI"
15+
description="Installs SENAITE JSONAPI's JWT PAS plugin"
16+
directory="profiles/default"
17+
provides="Products.GenericSetup.interfaces.EXTENSION"
18+
/>
19+
20+
<!-- Hide the uninstall profile from the Add-ons control panel -->
21+
<utility
22+
factory=".setuphandlers.HiddenProfiles"
23+
name="senaite.jsonapi"
24+
/>
25+
26+
<!-- Setup import step -->
27+
<genericsetup:importStep
28+
name="senaite.jsonapi.setuphandler"
29+
title="senaite.jsonapi setup handler"
30+
description="Installs SENAITE JSONAPI's JWT PAS Pugin"
31+
handler="senaite.jsonapi.setuphandlers.setup_handler">
32+
<depends name="plone-various"/>
33+
</genericsetup:importStep>
34+
35+
<!-- Uninstall profile -->
36+
<genericsetup:registerProfile
37+
name="uninstall"
38+
title="SENAITE JSONAPI (Uninstall)"
39+
description="Removes SENAITE JSONAPI's JWT PAS plugin and key storage"
40+
directory="profiles/uninstall"
41+
provides="Products.GenericSetup.interfaces.EXTENSION"
42+
/>
43+
44+
<!-- Uninstall import step -->
45+
<genericsetup:importStep
46+
name="senaite.jsonapi.uninstallhandler"
47+
title="senaite.jsonapi uninstall handler"
48+
description="Removes SENAITE JSONAPI's JWT PAS plugin and key storage"
49+
handler="senaite.jsonapi.setuphandlers.uninstall_handler">
50+
<depends name="plone-various"/>
51+
</genericsetup:importStep>
52+
1053
<!-- CATALOG
1154
Unified interface to one or more Portal Catalog tools.
1255
-->
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# -*- coding: utf-8 -*-
2+
#
3+
# This file is part of SENAITE.JSONAPI.
4+
#
5+
# SENAITE.JSONAPI is free software: you can redistribute it and/or modify it
6+
# under the terms of the GNU General Public License as published by the Free
7+
# Software Foundation, version 2.
8+
#
9+
# This program is distributed in the hope that it will be useful, but WITHOUT
10+
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
11+
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
12+
# details.
13+
#
14+
# You should have received a copy of the GNU General Public License along with
15+
# this program; if not, write to the Free Software Foundation, Inc., 51
16+
# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
17+
#
18+
# Copyright 2017-2026 by it's authors.
19+
# Some rights reserved, see README and LICENSE.

0 commit comments

Comments
 (0)