Skip to content

[18.0][ADD] auth_oauth_autolink: link OAuth logins to existing users by verified email - #997

Open
nimarosa wants to merge 1 commit into
OCA:18.0from
nimarosa:18.0-add-auth_oauth_autolink
Open

[18.0][ADD] auth_oauth_autolink: link OAuth logins to existing users by verified email#997
nimarosa wants to merge 1 commit into
OCA:18.0from
nimarosa:18.0-add-auth_oauth_autolink

Conversation

@nimarosa

@nimarosa nimarosa commented Sep 2, 2026

Copy link
Copy Markdown

What it does

Stock auth_oauth recognises a user only by the oauth_uid stored on the user record. A user created by an administrator has neither oauth_uid nor oauth_provider_id, so the very first OAuth login misses, falls through to the signup path and — on a B2B database, where signup is disabled — is refused with "You do not have access to this database…". The only stock workarounds are enabling public signup or writing the provider's opaque oauth_uid onto every user by hand.

This module performs that first link automatically: when the oauth_uid lookup misses, the login is linked to the existing user whose login is the e-mail address the provider verified. From then on the stock path takes over and nothing else changes.

Security model

The trust anchor is the provider's verified-email claim, and every guard around it is deliberate. A link happens only when all of these hold:

  1. The provider has Auto-link existing users by email ticked (auth.oauth.provider.autolink_by_email, off by default, opt-in per provider).
  2. The provider reports the e-mail as verified — email_verified (OpenID Connect) or verified_email (legacy Google tokeninfo). An absent claim counts as not verified.
  3. Exactly one active user matches login == email, compared through Odoo's own email_normalize (case-insensitive, unlike Odoo's case-sensitive login).
  4. That user has no oauth_uid yet.

Consequences, spelled out:

  • An already-linked user is never re-pointed at another OAuth account, so the flag cannot be used to hijack an account that already logs in.
  • An archived user is never linked (search excludes archived records), so deactivating a user still ends their access.
  • An ambiguous e-mail (two users whose logins differ only in case) is refused rather than resolved arbitrarily.
  • Portal and public users are eligible, deliberately — a portal customer whose login is their verified e-mail may use the same provider.
  • The module never widens what OAuth can do: it only replaces a refusal with a link to an account that already exists.

Anything that does not pass every guard behaves exactly like stock auth_oauth: the same AccessDenied, with no hint about which condition refused. The reason is written to the server log only.

On a successful link the module writes oauth_provider_id, oauth_uid and oauth_access_token (so the session behaves like any other OAuth login), logs at INFO and posts a note on the user's partner chatter — res.users is not a mail.thread.

Implementation note

The override runs before super() rather than catching its AccessDenied. On a signup-enabled (B2C) database super() would first try to create a user with the very login we are about to link, which fails on the login unique index and poisons the transaction. Behaviour is unchanged: an existing oauth_uid match always wins, and everything not linked is handed to super() untouched.

Test coverage

12 tests in tests/test_auth_oauth_autolink.py, driving _auth_oauth_signin with controlled validation dictionaries (exactly what _auth_oauth_validate returns after a real round-trip — no network needed), with signup forced to b2b:

  • links an existing user by verified e-mail, writing provider/uid/access token and posting one chatter note
  • the second login goes through the stock path (token refreshed, no second note)
  • e-mail match is case-insensitive
  • refusals, each asserting AccessDenied and that nothing was written: provider flag off, email_verified: False, missing verified claim, missing email claim, no matching user, already-linked user (never overwritten), archived user, ambiguous e-mail (two logins differing only in case)
  • the legacy verified_email spelling is accepted

Prior art

Related to #941 ([17.0][ADD] auth_oauth_link_by_email, still open). Same problem, deliberately stricter design:

#941 this PR
Verified-email claim not required required; an absent claim is treated as not verified
Activation always on once installed opt-in per provider (autolink_by_email, default off)
Already-linked user relinked refused — an existing OAuth link is never repointed
Ambiguous match first match wins refused
Case handling raw login comparison email_normalize on both sides
Token oauth_uid + provider also stores oauth_access_token
Tests 12

Without the verified-email requirement and the per-provider opt-in, any provider that lets a user assert an arbitrary e-mail address can be used to take over the matching Odoo account on every database that installs the module. Making the claim mandatory and the behaviour opt-in is what makes this safe to ship as a generic addon.

Runbat/manual check: used in production against a Google Workspace provider.

@OCA/server-auth-maintainers

@OCA-git-bot OCA-git-bot added series:18.0 mod:auth_oauth_autolink Module auth_oauth_autolink labels Sep 2, 2026
@nimarosa nimarosa closed this Sep 2, 2026
@nimarosa nimarosa reopened this Sep 2, 2026
@nimarosa
nimarosa force-pushed the 18.0-add-auth_oauth_autolink branch from 3ee297d to 40c06a1 Compare September 2, 2026 00:45
@nimarosa
nimarosa force-pushed the 18.0-add-auth_oauth_autolink branch from 40c06a1 to 4b183ff Compare September 2, 2026 00:50
@nimarosa

nimarosa commented Sep 2, 2026

Copy link
Copy Markdown
Author

Force-pushed a fix: the first version passed when the addon was installed on its own, but failed the all-addons CI job because of two interactions with sibling addons in this repo. Both are now handled, and the design is better for it.

1. auth_oauth_multi_token. Its _auth_oauth_signin captures the user recordset before delegating to super(), then raises AccessDenied on that stale (empty) recordset afterwards. Linking from inside _auth_oauth_signin therefore could never satisfy it: the link happened, and the outer override still denied.

The link now happens in _auth_oauth_validate, right after the provider vouches for the identity and before the sign-in chain starts. By the time any _auth_oauth_signin override runs, the user is an ordinary already-linked user, so the whole chain works untouched and this module needs no cooperation from anyone. Two nice consequences: the module no longer writes oauth_access_token at all (the stock implementation does it, and auth_oauth_multi_token does its own thing), and the B2C-signup safety property is unchanged — the link still precedes any attempt to create a user.

2. auth_user_case_insensitive. It force-lowercases logins on create/write, so the two scenarios that need logins differing only in case (mixed-case login, and two users whose logins collide case-insensitively) cannot be constructed while it is installed — the fixture itself hit a res_users_login_key unique violation. Those two tests now skip when that addon is installed, and a new test covers claim-side normalisation against a lowercase login, which holds either way.

3. Log levels. The refusal messages were logged at WARNING, which trips OCA_ENABLE_CHECKLOG_ODOO. A refused auto-link is expected operation, not a fault, so they are INFO now.

Test suite is 17 tests (15 running in the all-addons job, 2 skipped as above), including one that drives the real entry point with only _auth_oauth_rpc mocked, so the stock _auth_oauth_validate_auth_oauth_signin path is exercised end to end.

All test jobs and pre-commit are green. codecov/patch sits ~0.5 pp under target: the uncovered lines are the two guard branches that are unreachable while auth_user_case_insensitive is installed, which is exactly the environment the coverage job runs in.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

mod:auth_oauth_autolink Module auth_oauth_autolink series:18.0

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants