A connection that needs consent, not a password #150
Labels
No labels
accessibility
authentication
breaking change
bug
documentation
enhancement
interface
internationalisation
observability
security
tier
1
tier
2
tier
3
tier/4
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Blocks
#151 SMTP that Google and Microsoft will still accept
Postulo/postulo
Reference
Postulo/postulo#150
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Observation
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
FieldSpecis how a plugin asks for what it needs, and the whole vocabulary is: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.socialaccountis installed with the OpenID Connect provider, and Postulo alreadysigns people in through it (
POSTULO_OIDC_*). allauth stores aSocialApp, aSocialAccountand aSocialTokenwith 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
SocialTokenalready — but it carries the scopesrequested 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.pyunder the same Fernet key #111 protects, and the threat model gains aline: 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.pygives its requests, not abare
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.
Consentsits besideFieldSpecinplugins/base.py— the same kind of thing, part ofthe vocabulary a plugin uses to say what it needs — and
plugins/consent.pyconducts the roundtrip: 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
ConsentWithdrawnis keptapart 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 inthe 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.mdgains the line: a refresh token outlives a password, is not changed bychanging 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-authorshape 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
1230623on0.3.0, withmainkept level.