A connection that needs consent, not a password #150

Closed
opened 2026-09-09 11:06:20 +00:00 by tiagoagueda · 1 comment
Owner

Observation

i want that the smtp internal plugin (both sides) can handle newer authntification protocols
like the ones deployed by google or microsoft365

Prerequisite. A connection in Postulo is a form of typed fields; OAuth is a round trip
through somebody else's website. The contract has no way to say the second.

What exists

FieldSpec is how a plugin asks for what it needs, and the whole vocabulary is:

FIELD_TYPES = ("text", "url", "email", "password", "integer", "boolean", "choice", "textarea")

A plugin declares fields, Postulo builds a form, the person fills it in, the secrets are
stored encrypted, and a Test button proves it works. Every connection in the application
fits that shape because every one of them authenticates with something a person can type.

OAuth does not. It is: send the person to a provider, have them consent, receive a code at a
callback address this instance publishes, exchange it for an access token and a refresh
token, store the refresh token, and swap it for a fresh access token before every send. Two
of those steps are HTTP requests the plugin makes, one is a redirect the browser makes,
and none of them is a field.

Half the machinery is already in the process, for a different purpose.
allauth.socialaccount is installed with the OpenID Connect provider, and Postulo already
signs people in through it (POSTULO_OIDC_*). allauth stores a SocialApp, a
SocialAccount and a SocialToken with access and refresh tokens. That is the same protocol,
already implemented, already migrated — for sign-in rather than for mail.

What this asks for

A way for a connection to say I need consent, not a password, and for Postulo to conduct it.

Worth being careful about

The callback address is instance state, not plugin state. OAuth requires a redirect URI
registered with the provider in advance, which means the instance's own public address —
something a self-hosted application behind Traefik or a tunnel may not know about itself, and
which changes when somebody moves the instance. Getting this wrong produces a consent screen
that ends in an error nobody can read. Whatever is built has to show the operator the exact
URI to register.

Sign-in tokens are not mail tokens, and reusing them is a trap worth examining anyway. A
person who signed in with Google has a SocialToken already — but it carries the scopes
requested at sign-in, and sending mail needs a mail scope. Asking for a mail scope at sign-in
so it might be useful later is exactly the over-broad consent this project should not be
teaching. Two grants, asked for when each is needed, is the honest shape — but whether
allauth's token store can hold the second is a real question and would save a great deal.

A refresh token is a longer-lived credential than a password. It goes in
plugins/secrets.py under the same Fernet key #111 protects, and the threat model gains a
line: a stolen refresh token sends mail as that person until it is revoked, and unlike a
password the person cannot change it by changing their password.

Refreshing happens at send time, which means an outbound HTTPS call to a provider in the
middle of delivering somebody's mail. That is a new failure mode on a path that currently has
only one — and it wants the same treatment plugins/fetching.py gives its requests, not a
bare requests.post.

The Test button has to mean something different. Today it proves a password works. With
OAuth it proves a token can be refreshed and a server accepts it, and the useful failure is
"consent was withdrawn", which is not what a mail server says.

This is a plugin-contract change, so it lands in #126's surface: the shape a plugin uses
to say what it needs is exactly the kind of thing a declared surface has to carry.

## Observation > i want that the smtp internal plugin (both sides) can handle newer authntification protocols > like the ones deployed by google or microsoft365 Prerequisite. A connection in Postulo is a form of typed fields; OAuth is a round trip through somebody else's website. The contract has no way to say the second. ## What exists `FieldSpec` is how a plugin asks for what it needs, and the whole vocabulary is: ```python FIELD_TYPES = ("text", "url", "email", "password", "integer", "boolean", "choice", "textarea") ``` A plugin declares fields, Postulo builds a form, the person fills it in, the secrets are stored encrypted, and a *Test* button proves it works. Every connection in the application fits that shape because every one of them authenticates with something a person can type. OAuth does not. It is: send the person to a provider, have them consent, receive a code at a callback address this instance publishes, exchange it for an access token and a refresh token, store the refresh token, and swap it for a fresh access token before every send. Two of those steps are HTTP requests the *plugin* makes, one is a redirect the *browser* makes, and none of them is a field. **Half the machinery is already in the process, for a different purpose.** `allauth.socialaccount` is installed with the OpenID Connect provider, and Postulo already signs people in through it (`POSTULO_OIDC_*`). allauth stores a `SocialApp`, a `SocialAccount` and a `SocialToken` with access and refresh tokens. That is the same protocol, already implemented, already migrated — for sign-in rather than for mail. ## What this asks for A way for a connection to say *I need consent, not a password*, and for Postulo to conduct it. ## Worth being careful about **The callback address is instance state, not plugin state.** OAuth requires a redirect URI registered with the provider in advance, which means the instance's own public address — something a self-hosted application behind Traefik or a tunnel may not know about itself, and which changes when somebody moves the instance. Getting this wrong produces a consent screen that ends in an error nobody can read. Whatever is built has to *show* the operator the exact URI to register. **Sign-in tokens are not mail tokens, and reusing them is a trap worth examining anyway.** A person who signed in with Google has a `SocialToken` already — but it carries the scopes requested at sign-in, and sending mail needs a mail scope. Asking for a mail scope at sign-in so it might be useful later is exactly the over-broad consent this project should not be teaching. Two grants, asked for when each is needed, is the honest shape — but whether allauth's token store can hold the second is a real question and would save a great deal. **A refresh token is a longer-lived credential than a password.** It goes in `plugins/secrets.py` under the same Fernet key #111 protects, and the threat model gains a line: a stolen refresh token sends mail as that person until it is revoked, and unlike a password the person cannot change it by changing their password. **Refreshing happens at send time**, which means an outbound HTTPS call to a provider in the middle of delivering somebody's mail. That is a new failure mode on a path that currently has only one — and it wants the same treatment `plugins/fetching.py` gives its requests, not a bare `requests.post`. **The *Test* button has to mean something different.** Today it proves a password works. With OAuth it proves a token can be refreshed and a server accepts it, and the useful failure is "consent was withdrawn", which is not what a mail server says. **This is a plugin-contract change**, so it lands in #126's surface: the shape a plugin uses to say what it needs is exactly the kind of thing a declared surface has to carry.
tiagoagueda added this to the 0.3.0 milestone 2026-09-09 11:06:20 +00:00
Author
Owner

Consent sits beside FieldSpec in plugins/base.py — the same kind of thing, part of
the vocabulary a plugin uses to say what it needs — and plugins/consent.py conducts the round
trip: the redirect, a signed state, the code exchange, the encrypted tokens, and a refresh
before every use.

On reusing allauth's sign-in tokens, which the issue flagged as "a real question that would
save a great deal": no, and for two reasons.
The sign-in grant carries the scopes asked for
at sign-in, so reusing it means asking for a mail scope at sign-in on the chance it might be
useful later — precisely the over-broad consent this project should not teach. And allauth
holds one token per account per application, so a second grant with different scopes has
nowhere to sit beside the first. Two grants, asked for when each is needed, in the connection's
own encrypted secrets: one store, one key, one rule.

The callback address is shown, not described, exactly as asked. One address for the whole
instance rather than one per plugin, printed verbatim on the connection's page with a note that
moving the instance means registering the new one first. The scopes are on the page too — a
list nobody can read is a list nobody consented to.

The Test button means something different, also as asked: for a consenting connection it
proves the grant still stands before it proves anything else, and ConsentWithdrawn is kept
apart from every other refusal. Both arrive as a 400 from the provider and telling them apart
is the whole value: one is fixed by agreeing again, the other by editing a field.

Refreshing goes through plugins/http.py, not a bare post — it is an outbound request in
the middle of delivering somebody's mail and gets the same destination policy, timeout and
redirect limit as every other. A provider that returns no new refresh token keeps the old one,
which is the difference between a connection that lasts and one that dies quietly.

The state is signed, short-lived, and checked against whoever is signed in when it comes
back.
A callback is a request another page can cause, so a connection belonging to a
different account is not reachable through one. Starting the flow is a POST rather than a link,
because it ends in a stored credential.

docs/THREAT-MODEL.md gains the line: a refresh token outlives a password, is not changed by
changing one, and only the provider can revoke the grant — which is why Forget the token says
Postulo has stopped holding it rather than implying the grant is gone.

29 tests in tests/test_consent.py, a wiki section under Configuration, the plugin-author
shape in CONTRIBUTING.md, and 22 strings in all 39 European catalogues.

#151 can now be written against this, though it still waits on #126.

Shipped in 1230623 on 0.3.0, with main kept level.

`Consent` sits beside `FieldSpec` in `plugins/base.py` — the same kind of thing, part of the vocabulary a plugin uses to say what it needs — and `plugins/consent.py` conducts the round trip: the redirect, a signed state, the code exchange, the encrypted tokens, and a refresh before every use. **On reusing allauth's sign-in tokens, which the issue flagged as "a real question that would save a great deal": no, and for two reasons.** The sign-in grant carries the scopes asked for at sign-in, so reusing it means asking for a mail scope at sign-in on the chance it might be useful later — precisely the over-broad consent this project should not teach. And allauth holds one token per account per application, so a second grant with different scopes has nowhere to sit beside the first. Two grants, asked for when each is needed, in the connection's own encrypted secrets: one store, one key, one rule. **The callback address is shown, not described**, exactly as asked. One address for the whole instance rather than one per plugin, printed verbatim on the connection's page with a note that moving the instance means registering the new one first. The scopes are on the page too — a list nobody can read is a list nobody consented to. **The Test button means something different**, also as asked: for a consenting connection it proves the grant still stands *before* it proves anything else, and `ConsentWithdrawn` is kept apart from every other refusal. Both arrive as a 400 from the provider and telling them apart is the whole value: one is fixed by agreeing again, the other by editing a field. **Refreshing goes through `plugins/http.py`**, not a bare post — it is an outbound request in the middle of delivering somebody's mail and gets the same destination policy, timeout and redirect limit as every other. A provider that returns no new refresh token keeps the old one, which is the difference between a connection that lasts and one that dies quietly. **The state is signed, short-lived, and checked against whoever is signed in when it comes back.** A callback is a request another page can cause, so a connection belonging to a different account is not reachable through one. Starting the flow is a POST rather than a link, because it ends in a stored credential. `docs/THREAT-MODEL.md` gains the line: a refresh token outlives a password, is not changed by changing one, and only the provider can revoke the grant — which is why *Forget the token* says Postulo has stopped holding it rather than implying the grant is gone. 29 tests in `tests/test_consent.py`, a wiki section under *Configuration*, the plugin-author shape in `CONTRIBUTING.md`, and 22 strings in all 39 European catalogues. #151 can now be written against this, though it still waits on #126. Shipped in `1230623` on `0.3.0`, with `main` kept level.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Reference
Postulo/postulo#150
No description provided.