A native client carries a session token¶
Decided for Sundown for iOS, and not yet built. The iOS app gets in through django-allauth's
headless app client and proves who it is with allauth's session token: the Django session
key, sent as X-Session-Token on every request. There are no JWTs, no OAuth and no token
model of our own.
The decision¶
- allauth headless issues the credential. Signup, verification by code, login, logout and
password reset are its JSON flows, on the same adapter and
ApprovalStageas the web.HEADLESS_CLIENTSis("app",): thebrowserclient is off. - The credential is the session token. A phone login is a row in the same session table
as a browser login, and lives
SESSION_COOKIE_AGE, one year, fixed. - Our own views read it through one reader of ours. It resolves the header through
allauth's public seam,
HEADLESS_TOKEN_STRATEGY.lookup_session, and setsrequest.sessionandrequest.user. It covers the API's own URL prefix and the stream. - The API prefix takes the header and only the header. It refuses cookie sessions, and so is exempt from CSRF: with no ambient credential there is nothing for another site to ride on.
Why not JWT¶
A native client is usually given a short-lived access token and a refresh token. allauth ships that strategy too. It was turned down for these reasons:
- Revocation. Deleting a session row signs a device out on its next request. A JWT stays good until it expires, so signing out, a lost phone, Remove and account deletion would each need a denylist, which is a session table by another name.
- One credential model. A password change or reset already ends a person's other sessions. With session tokens that covers phones with no new code.
- Nothing to refresh. The Share Extension is a short-lived separate process that reads the same token from a shared keychain group. It could not sensibly run a refresh and write the result back for the main app.
- What JWT buys is not needed. It saves a database read per request, at a scale SQLite and two-person Circles are nowhere near.
Why a reader of our own¶
allauth ships token readers for Django REST framework and Django Ninja only, and neither is
installed. Sending the token as the sessionid cookie also works, because the token is the
session key, but allauth documents the header only, and Django's CSRF checks would then apply
to a client that has no page to carry a CSRF token. If the API later adopts one of those
frameworks, allauth's reader replaces ours with the same header and the same token.
Consequences¶
- The token changes at login and at password change. A client stores whichever token the latest response hands it.
- A dead token is a
401that never says why on our views (allauth's own answer410). Expired, signed out and never-existed look the same. - Nobody Waiting, Rejected or Held ever holds a login, since nobody gets one until they're Approved. What they do hold is the token allauth hands out before login, for a session with nobody in it.
- One endpoint on the API prefix answers that token (#62), the single exception to the
401above. It takes no Email address and says only what this session earned on its last attempt to get in: Waiting, Held, a mail that couldn't be sent, or nothing. A stranger, or no token at all, gets the same200with nothing. A Rejected person gets Waiting. The app reads Waiting from here and never from allauth's status codes, andApprovalStagestill clears the login rather than stashing it. - Mounting headless opens a second door past our daily limits (#63), because they were charged in the web's views. So each limit is charged by one function per action, every door's view calls it, and each door has a test that proves the refusal. Both doors spend one budget. allauth's headless signup and reset-request views are subclassed and mapped over their URLs to do it, and headless is never mounted without them. The adapter was turned down as the place: the only reset hook both doors share runs just for addresses that exist, so refusing there would say which addresses have accounts.
- The honeypot stays a web-form defence, with no counterpart on the JSON door: a bot that posts JSON sends only what it needs. App Attest is the named escalation, not built; what would call for it is Held signups or Approval-queue noise traced to the JSON door.
- A browser session cannot call the API. The web keeps its HTML routes and its cookie.
- There is no list of logins. A phone out of reach is signed out by changing the password.
- Sliding expiry is not ruled out.
SESSION_SAVE_EVERY_REQUESTwould do it at a write per request; renewing near expiry in the reader would do it at a write a year. Either would apply to both clients.