Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
180 changes: 180 additions & 0 deletions auth_api_key_native_generate/README.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
.. image:: https://odoo-community.org/readme-banner-image
:target: https://odoo-community.org/get-involved?utm_source=readme
:alt: Odoo Community Association

============================
Auth Api Key Native Generate
============================

..
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:586ca7eec36781512625c0ce8c9dd99769d5a566f6df800a48e8408399e03a92
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!

.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
:target: https://odoo-community.org/page/development-status
:alt: Beta
.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png
:target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
:alt: License: AGPL-3
.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fserver--auth-lightgray.png?logo=github
:target: https://github.com/OCA/server-auth/tree/19.0/auth_api_key_native_generate
:alt: OCA/server-auth
.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
:target: https://translation.odoo-community.org/projects/server-auth-19-0/server-auth-19-0-auth_api_key_native_generate
:alt: Translate me on Weblate
.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png
:target: https://runboat.odoo-community.org/builds?repo=OCA/server-auth&target_branch=19.0
:alt: Try me on Runboat

|badge1| |badge2| |badge3| |badge4| |badge5|

This module adds an HTTP endpoint that allows to create a new native
Odoo API key (``res.users.apikeys``) with a user's login and password.

It is for clients such as mobile apps that cannot use session-cookie
authentication and need a bearer API key to call the new native Odoo
``/json/2`` api.

The ```auth_api_key`` <../auth_api_key>`__ module manages its own
``auth.api.key`` records and a custom ``API-KEY`` header. This module
instead reuses the native Odoo API key mechanism:

- Credentials are checked through ``res.users.authenticate``, which goes
through Odoo's ``_assert_can_auth()`` login cooldown for brute-force
protection.
- Keys are created with ``res.users.apikeys._generate()`` using the
``rpc`` scope.
- Each key has a fixed validity window, 90 days by default and
configurable.

**Table of contents**

.. contents::
:local:

Configuration
=============

Go to **Settings > General Settings > Integrations > API Key Generation
Endpoint** to set the validity window, in days, applied to newly
generated API keys. The value is stored in the
``auth_api_key_native_generate.duration`` system parameter (default:
``90``).

Notes on the key lifecycle:

- The validity window is fixed. There is no rolling or automatic
renewal.
- Changing the duration only affects keys generated afterwards. Existing
keys keep their own expiration date.
- Generating a key does not revoke the user's other keys, so the same
user can hold valid keys on several devices (with no maximum number of
devices)

Reverse proxy hardening (recommended)
-------------------------------------

This endpoint exposes a credential-exchange surface. Add these
protections at the reverse proxy layer:

- HTTP Basic Authentication in front of ``/json/2`` and
``/api/auth/generate_api_key``.
- Rate limiting on ``/api/auth/generate_api_key`` (for example 6
requests per minute per IP) to slow down brute-force attempts in
multi-worker deployments.

The new endpoint relies on Odoo's native login cooldown
(``_assert_can_auth``), which throttles per client IP using
``request.httprequest.remote_addr``. When Odoo runs behind a reverse
proxy, enable proxy mode (``--proxy-mode`` / ``proxy_mode = True``) and
have the proxy forward ``X-Forwarded-For``, so the cooldown sees the
real client IP. Otherwise every request shares one IP and the per-IP
protection is useless.

Usage
=====

Request a key by POSTing JSON credentials to the endpoint:

::

POST /api/auth/generate_api_key
Content-Type: application/json

{
"db": "mydb",
"login": "user@example.com",
"password": "the-user-password"
}

On success (HTTP 200) the response holds the key and its expiration date
(ISO 8601, UTC):

.. code:: json

{
"api_key": "0123456789abcdef...",
"expiration_date": "2026-10-11T09:30:00+00:00"
}

Error responses:

- ``400``: missing ``db``, ``login`` or ``password``, or a body that is
not a JSON object.
- ``401``: invalid credentials.
- ``403``: the user has two-factor authentication enabled, so a password
alone cannot issue a key.
- ``404``: unknown database.
- ``413``: request body too large.

Use the returned ``api_key`` as the bearer credential for the native
Odoo ``/json/2`` and RPC endpoints, in place of the user password:

::

curl -H "Authorization: Bearer 0123456789abcdef..." \
https://mydb.example.com/json/2/...

Bug Tracker
===========

Bugs are tracked on `GitHub Issues <https://github.com/OCA/server-auth/issues>`_.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us to smash it by providing a detailed and welcomed
`feedback <https://github.com/OCA/server-auth/issues/new?body=module:%20auth_api_key_native_generate%0Aversion:%2019.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.

Do not contact contributors directly about support or help with technical issues.

Credits
=======

Authors
-------

* Trobz

Contributors
------------

- Do Anh Duy <duyda@trobz.com>

Maintainers
-----------

This module is maintained by the OCA.

.. image:: https://odoo-community.org/logo.png
:alt: Odoo Community Association
:target: https://odoo-community.org

OCA, or the Odoo Community Association, is a nonprofit organization whose
mission is to support the collaborative development of Odoo features and
promote its widespread use.

This module is part of the `OCA/server-auth <https://github.com/OCA/server-auth/tree/19.0/auth_api_key_native_generate>`_ project on GitHub.

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.
2 changes: 2 additions & 0 deletions auth_api_key_native_generate/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
from . import controllers
from . import models
17 changes: 17 additions & 0 deletions auth_api_key_native_generate/__manifest__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Copyright 2026 Trobz
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).

{
"name": "Auth Api Key Native Generate",
"summary": """
Endpoint to generate a native Odoo API key from user credentials""",
"version": "19.0.1.0.0",
"license": "AGPL-3",
"author": "Trobz,Odoo Community Association (OCA)",
"website": "https://github.com/OCA/server-auth",
"development_status": "Beta",
"depends": ["base_setup"],
"data": [
"views/res_config_settings.xml",
],
}
1 change: 1 addition & 0 deletions auth_api_key_native_generate/controllers/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import main
161 changes: 161 additions & 0 deletions auth_api_key_native_generate/controllers/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
# Copyright 2026 Trobz
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).

import logging
from contextlib import ExitStack
from datetime import timedelta, timezone

import odoo
from odoo import fields, http
from odoo.exceptions import AccessDenied
from odoo.http import Controller, request, route

_logger = logging.getLogger(__name__)

# Default validity window for a generated API key, in days. Can be overridden
# through the ``auth_api_key_native_generate.duration`` system parameter.
DEFAULT_DURATION_DAYS = 90

# The endpoint is unauthenticated (auth="none"); cap the request body to a small
# size so it cannot be abused to feed large payloads into memory. Odoo's default
# limit is 128MiB, far more than a credentials payload ever needs.
MAX_CONTENT_LENGTH = 8 * 1024 # 8 KiB


class AuthApiKeyNativeGenerate(Controller):
def _get_api_key_duration(self, env):
"""Return the configured validity duration (in days) for new keys."""
param = (
env["ir.config_parameter"]
.sudo()
.get_param("auth_api_key_native_generate.duration")
)
try:
duration = int(param)
except (TypeError, ValueError):
return DEFAULT_DURATION_DAYS
return duration if duration > 0 else DEFAULT_DURATION_DAYS

@route(
"/api/auth/generate_api_key",
type="http",
auth="none",
methods=["POST"],
csrf=False,
save_session=False,
readonly=False,
max_content_length=MAX_CONTENT_LENGTH,
)
def generate_api_key(self):
"""Exchange user credentials for a native Odoo API key.

Request body (JSON)::

{"db": "...", "login": "...", "password": "..."}

Response body (JSON)::

{"api_key": "...", "expiration_date": "..."}

The credentials are verified through the native
``res.users.authenticate`` path, which relies on ``_assert_can_auth``
for the built-in login cooldown protection. On success a new API key is
issued with ``res.users.apikeys._generate`` using the ``rpc`` scope and
a fixed validity window (default 90 days, configurable in the settings).
"""
try:
data = request.get_json_data()
except ValueError:
return request.make_json_response(
{"error": "Invalid JSON body."}, status=400
)
# get_json_data() does no shape validation: reject anything that is not
# a JSON object so the .get() calls below cannot raise a 500.
if not isinstance(data, dict):
return request.make_json_response(
{"error": "Request body must be a JSON object."}, status=400
)

db = data.get("db")
login = data.get("login")
password = data.get("password")
if not (db and login and password):
return request.make_json_response(
{"error": "'db', 'login' and 'password' are required."}, status=400
)

if not http.db_filter([db]):
return request.make_json_response(
{"error": "Database not found."}, status=404
)

with ExitStack() as stack:
if not request.db or request.db != db:
# Open a dedicated cursor/env when the request is not already
# bound to the requested database. The cursor is committed on a
# clean exit of the ExitStack, persisting the new key.
try:
registry = odoo.modules.registry.Registry(db)
except Exception:
_logger.warning("Database %r not found or not reachable.", db)
return request.make_json_response(
{"error": "Database not found."}, status=404
)
cr = stack.enter_context(registry.cursor())
env = odoo.api.Environment(cr, None, {})
else:
env = request.env(user=None, su=False)

credential = {"login": login, "password": password, "type": "password"}
# "interactive": True selects the password-login path in
# _check_credentials (verify the real password, not treat it as an
# API key) and silences the "assuming interactive login" warning.
try:
auth_info = env["res.users"].authenticate(
credential, {"interactive": True}
)
except AccessDenied:
return request.make_json_response(
{"error": "Invalid credentials."}, status=401
)

uid = auth_info["uid"]
# Enforce the same MFA gate as the native login flow
# (see Session.authenticate / odoo.http). This endpoint only accepts
# a password, so a user with two-factor authentication enabled must
# not be able to obtain an API key by password alone.
user = env["res.users"].browse(uid)
if auth_info.get("mfa") != "skip" and user._mfa_url():
return request.make_json_response(
{
"error": "Two-factor authentication is enabled for this "
"user; API key generation from a password is not allowed."
},
status=403,
)
duration = self._get_api_key_duration(env)
# fields.Datetime.now() returns naive UTC, matching how Odoo stores
# and compares apikeys.expiration_date (now() at time zone 'utc').
# The response below serializes it timezone-aware to be explicit
# towards external clients.
expiration_date = fields.Datetime.now() + timedelta(days=duration)

# Generate the key as the authenticated user, in superuser mode so
# the fixed validity window is not capped by the user's group
# ``api_key_duration`` limit. Keeping ``uid`` ensures the key is
# owned by the authenticated user, not the superuser.
key_env = env(user=uid, su=True)
api_key = key_env["res.users.apikeys"]._generate(
scope="rpc",
name="Generated via /api/auth/generate_api_key",
expiration_date=expiration_date,
)

return request.make_json_response(
{
"api_key": api_key,
"expiration_date": expiration_date.replace(
tzinfo=timezone.utc
).isoformat(),
}
)
1 change: 1 addition & 0 deletions auth_api_key_native_generate/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import res_config_settings
17 changes: 17 additions & 0 deletions auth_api_key_native_generate/models/res_config_settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Copyright 2026 Trobz
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).

from odoo import fields, models


class ResConfigSettings(models.TransientModel):
_inherit = "res.config.settings"

auth_api_key_native_generate_duration = fields.Integer(
string="Generated API Key Validity (days)",
default=90,
config_parameter="auth_api_key_native_generate.duration",
help="Validity window, in days, applied to API keys issued through the "
"/api/auth/generate_api_key endpoint. Only newly generated keys are "
"affected; existing keys keep their own expiration date.",
)
3 changes: 3 additions & 0 deletions auth_api_key_native_generate/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[build-system]
requires = ["whool"]
build-backend = "whool.buildapi"
Loading
Loading