Skip to content

Commit

Permalink
[IMP] auth_jwt: add public_or_jwt auth method
Browse files Browse the repository at this point in the history
This method is useful for public endpoints that need
to work for anonymous user, but can be enhanced when
an authenticated user is know.

A typical use case is a "add to cart" enpoint that can
work for anonymous users, but can be enhanced by
binding the cart to a known customer when the authenticated
user is known.
  • Loading branch information
sbidoul committed Oct 5, 2021
1 parent 166851b commit d3ac22b
Show file tree
Hide file tree
Showing 7 changed files with 114 additions and 10 deletions.
8 changes: 8 additions & 0 deletions auth_jwt/models/auth_jwt_validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,12 +180,20 @@ def _register_auth_method(self):
f"_auth_method_jwt_{rec.name}",
partial(IrHttp.__class__._auth_method_jwt, validator_name=rec.name),
)
setattr(
IrHttp.__class__,
f"_auth_method_public_or_jwt_{rec.name}",
partial(
IrHttp.__class__._auth_method_public_or_jwt, validator_name=rec.name
),
)

def _unregister_auth_method(self):
IrHttp = self.env["ir.http"]
for rec in self:
try:
delattr(IrHttp.__class__, f"_auth_method_jwt_{rec.name}")
delattr(IrHttp.__class__, f"_auth_method_public_or_jwt_{rec.name}")
except AttributeError:
pass

Expand Down
14 changes: 12 additions & 2 deletions auth_jwt/models/ir_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,11 @@ def _authenticate(cls, endpoint):
_authenticate method and make sure the conditions have not changed.
"""
auth_method = endpoint.routing["auth"]
if auth_method == "jwt" or auth_method.startswith("jwt_"):
if (
auth_method in ("jwt", "public_or_jwt")
or auth_method.startswith("jwt_")
or auth_method.startswith("public_or_jwt_")
):
if request.session.uid:
_logger.warning(
'A route with auth="jwt" must not be used within a user session.'
Expand All @@ -44,7 +48,7 @@ def _authenticate(cls, endpoint):
# because _authenticate will not call _auth_method_jwt a second time.
if request.uid and not hasattr(request, "jwt_payload"):
_logger.error(
'A route with auth="jwt" should not have a request.uid here.'
"A route with auth='jwt' should not have a request.uid here."
)
raise UnauthorizedSessionMismatch()
return super()._authenticate(endpoint)
Expand All @@ -69,6 +73,12 @@ def _auth_method_jwt(cls, validator_name=None):
request.jwt_payload = payload
request.jwt_partner_id = partner_id

@classmethod
def _auth_method_public_or_jwt(cls, validator_name=None):
if "HTTP_AUTHORIZATION" not in request.httprequest.environ:
return cls._auth_method_public()
return cls._auth_method_jwt(validator_name)

@classmethod
def _get_bearer_token(cls):
# https://tools.ietf.org/html/rfc2617#section-3.2.2
Expand Down
14 changes: 11 additions & 3 deletions auth_jwt/readme/USAGE.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ To use it, you must:

* Create an ``auth.jwt.validator`` record to configure how the JWT token will
be validated.
* Add an ``auth="jwt_{validator-name}"`` attribute to the routes
you want to protect where ``{validator-name}`` corresponds to the name
attribute of the JWT validator record.
* Add an ``auth="jwt_{validator-name}"`` or ``auth="public_or_jwt_{validator-name}"``
attribute to the routes you want to protect where ``{validator-name}`` corresponds to
the name attribute of the JWT validator record.

The ``auth_jwt_demo`` module provides examples.

Expand Down Expand Up @@ -45,3 +45,11 @@ strategies can be provided by overriding the ``_get_partner_id()`` method
and extending the ``partner_id_strategy`` selection field.

The decoded JWT payload is stored in ``request.jwt_payload``.

The ``public_auth_jwt`` method delegates authentication to the standard Odoo ``public``
method when the Authorization header is not set. If it is set, the regular JWT
authentication is performed as described above. This method is useful for public
endpoints that need to work for anonymous users, but can be enhanced when an
authenticated user is know. A typical use case is a "add to cart" endpoint that can work
for anonymous users, but can be enhanced by binding the cart to a known customer when
the authenticated user is known.
44 changes: 43 additions & 1 deletion auth_jwt/tests/test_auth_jwt.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,21 @@
class TestAuthMethod(TransactionCase):
@contextlib.contextmanager
def _mock_request(self, authorization):
environ = {}
if authorization:
environ["HTTP_AUTHORIZATION"] = authorization
request = Mock(
context={},
db=self.env.cr.dbname,
uid=None,
httprequest=Mock(environ={"HTTP_AUTHORIZATION": authorization}),
httprequest=Mock(environ=environ),
session=DotDict(),
env=self.env,
)
# These attributes are added upon successful auth, so make sure
# calling hasattr on the mock when they are not yet set returns False.
del request.jwt_payload
del request.jwt_partner_id

with contextlib.ExitStack() as s:
odoo.http._request_stack.push(request)
Expand Down Expand Up @@ -238,24 +246,58 @@ def test_nbf(self):
def test_auth_method_registration_on_create(self):
IrHttp = self.env["ir.http"]
self.assertFalse(hasattr(IrHttp.__class__, "_auth_method_jwt_validator1"))
self.assertFalse(
hasattr(IrHttp.__class__, "_auth_method_public_or_jwt_validator1")
)
self._create_validator("validator1")
self.assertTrue(hasattr(IrHttp.__class__, "_auth_method_jwt_validator1"))
self.assertTrue(
hasattr(IrHttp.__class__, "_auth_method_public_or_jwt_validator1")
)

def test_auth_method_unregistration_on_unlink(self):
IrHttp = self.env["ir.http"]
validator = self._create_validator("validator1")
self.assertTrue(hasattr(IrHttp.__class__, "_auth_method_jwt_validator1"))
self.assertTrue(
hasattr(IrHttp.__class__, "_auth_method_public_or_jwt_validator1")
)
validator.unlink()
self.assertFalse(hasattr(IrHttp.__class__, "_auth_method_jwt_validator1"))
self.assertFalse(
hasattr(IrHttp.__class__, "_auth_method_public_or_jwt_validator1")
)

def test_auth_method_registration_on_rename(self):
IrHttp = self.env["ir.http"]
validator = self._create_validator("validator1")
self.assertTrue(hasattr(IrHttp.__class__, "_auth_method_jwt_validator1"))
self.assertTrue(
hasattr(IrHttp.__class__, "_auth_method_public_or_jwt_validator1")
)
validator.name = "validator2"
self.assertFalse(hasattr(IrHttp.__class__, "_auth_method_jwt_validator1"))
self.assertFalse(
hasattr(IrHttp.__class__, "_auth_method_public_or_jwt_validator1")
)
self.assertTrue(hasattr(IrHttp.__class__, "_auth_method_jwt_validator2"))
self.assertTrue(
hasattr(IrHttp.__class__, "_auth_method_public_or_jwt_validator2")
)

def test_name_check(self):
with self.assertRaises(ValidationError):
self._create_validator(name="not an identifier")

def test_public_or_jwt_no_token(self):
with self._mock_request(authorization=None) as request:
self.env["ir.http"]._auth_method_public_or_jwt()
assert request.uid == self.env.ref("base.public_user").id
assert not hasattr(request, "jwt_payload")

def test_public_or_jwt_valid_token(self):
with self._commit_validator("validator"):
authorization = "Bearer " + self._create_token()
with self._mock_request(authorization=authorization) as request:
self.env["ir.http"]._auth_method_public_or_jwt_validator()
assert request.jwt_payload["aud"] == "me"
24 changes: 24 additions & 0 deletions auth_jwt_demo/controllers/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,27 @@ def whoami_keycloak(self):
partner = request.env["res.partner"].browse(request.jwt_partner_id)
data.update(name=partner.name, email=partner.email)
return Response(json.dumps(data), content_type="application/json", status=200)

@route(
"/auth_jwt_demo/keycloak/whoami-public-or-jwt",
type="http",
auth="public_or_jwt_demo_keycloak",
csrf=False,
cors="*",
save_session=False,
methods=["GET", "OPTIONS"],
)
def whoami_public_or_keycloak(self):
"""To use with the demo_keycloak validator.
You can play with this using the browser app in tests/spa and the
identity provider in tests/keycloak.
"""
data = {}
if hasattr(request, "jwt_partner_id") and request.jwt_partner_id:
partner = request.env["res.partner"].browse(request.jwt_partner_id)
data.update(name=partner.name, email=partner.email)
else:
# public
data.update(name="Anonymous")
return Response(json.dumps(data), content_type="application/json", status=200)
6 changes: 5 additions & 1 deletion auth_jwt_demo/tests/spa/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,17 @@ <h2>SPA OIDC Authentication Sample</h2>
<button id="btn-login" disabled>Log in</button>
<button id="btn-logout" disabled>Log out</button>
<button id="btn-whoami">Who am I? (api call)</button>
<button id="btn-whoami-public-or-jwt">Who am I (public or auth)? (api call)</button>
<script type="module">
import {onload, login, logout, whoami} from "./js/app.js";
import {onload, login, logout, whoami, whoami_public_or_jwt} from "./js/app.js";

window.onload = onload;
document.getElementById("btn-login").onclick = login;
document.getElementById("btn-logout").onclick = logout;
document.getElementById("btn-whoami").onclick = whoami;
document.getElementById(
"btn-whoami-public-or-jwt"
).onclick = whoami_public_or_jwt;
</script>
</body>
</html>
14 changes: 11 additions & 3 deletions auth_jwt_demo/tests/spa/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,11 +76,11 @@ async function refresh() {
client.startSilentRenew();
}

async function whoami() {
async function _whoami(endpoint) {
let user = await client.getUser();
try {
let response = await fetch(
"http://localhost:8069/auth_jwt_demo/keycloak/whoami",
"http://localhost:8069/auth_jwt_demo/keycloak" + endpoint,
{
headers: {
...(user && {Authorization: `Bearer ${user.access_token}`}),
Expand All @@ -94,4 +94,12 @@ async function whoami() {
}
}

export {onload, login, logout, whoami};
async function whoami() {
await _whoami("/whoami");
}

async function whoami_public_or_jwt() {
await _whoami("/whoami-public-or-jwt");
}

export {onload, login, logout, whoami, whoami_public_or_jwt};

0 comments on commit d3ac22b

Please sign in to comment.