The developer's decisions of 23 Oct, evening (D12–D15). Serverpod's own ID-token verifier, chosen first, refuses every SimpleLogin token: they carry no typ header, and it checks one with dart_jsonwebtoken's default, with no way to turn that off. So the register checks them with dart_jsonwebtoken itself. Attempts go into a table, steps 2 to 4 come in one release, and the proposal stands as written. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
193 lines
14 KiB
Markdown
193 lines
14 KiB
Markdown
# ADR 0020: Signing in with SimpleLogin only
|
||
|
||
- **Status:** decided 23 Oct 2026, by the developer (D1–D15 below), the *Proposal*
|
||
with it (D15).
|
||
- **Built (23 Oct 2026), step 1:** the forwarding page (3),
|
||
`/simplelogin-callback` on the register's web server (halkyon_base's
|
||
`addSimpleLoginCallbackRoute`, Caddy forwarding the path), ahead of the sign-in, so
|
||
development signs in through learn.halkyon.eu's page, which its SimpleLogin app
|
||
registers too. The state is `<app>-<platform>.<rest>`: the marker one of the module's
|
||
fixed table (Learn and Todo, the desktop's loopback on ports 43823 and 43824, or the
|
||
app's scheme), the rest 1 to 512 characters of base64url. A 303 carries the code or
|
||
SimpleLogin's error (`deny`) with the state and nothing else; whatever is malformed,
|
||
repeated or not a GET is refused with a page that repeats nothing. No caching, no
|
||
referrer, nothing logged. Android's `assetlinks.json` for Halkyon Learn signed with
|
||
the release key (D9) is answered by `deploy/Caddyfile` on learn.halkyon.eu alone,
|
||
held to `tool/release/android-certificate.sha256` by a test. The app's intent filter
|
||
comes with the sign-in; iOS's associated domains wait for an Apple team ID, so iPhones
|
||
come back through the forwarding page and the scheme until then.
|
||
- **Builds on:** ADR 0010 (email sign-in), ADR 0011 (profile pages), ADR 0016 (the
|
||
register), ADR 0017 (a password, TOTP and the keys).
|
||
- **Changes:** ADR 0017's way in on an instance that offers SimpleLogin (its keys, their
|
||
wrapping and the recovery phrase stay as they are), and every instance's anonymous
|
||
accounts, which go (ADR 0010's start without an account, ADR 0016's 6, ADR 0017's 9).
|
||
|
||
## Context
|
||
|
||
The developer asked on 23 Oct to limit signing in to "only SimpleLogin/Proton single sign
|
||
in". Proton offers no OpenID Connect sign-in to other apps; SimpleLogin, which belongs to
|
||
Proton, does ("Sign in with SimpleLogin"), and its own login page offers "Log in with
|
||
Proton". What SimpleLogin does, from its source (`simple-login/app`, 22 Oct) and from tries
|
||
against app.simplelogin.io on 23 Oct:
|
||
|
||
- **OpenID Connect's code flow:** `https://app.simplelogin.io/oauth2/authorize`,
|
||
`/oauth2/token`, `/jwks`; ID tokens signed with RS256, carrying the `nonce` the
|
||
authorization asked for and what the person shares (address, name, avatar); each code is
|
||
taken once. **No PKCE**; the client secret is always required, so only a server can
|
||
exchange a code.
|
||
- **New apps are closed by default since 22 Oct** (`ALLOW_NEW_OAUTH_CLIENTS`); "existing
|
||
websites keep working as usual". app.simplelogin.io still let the developer make apps on
|
||
23 Oct: one for development, one for learn.halkyon.eu ("Halkyon") and a few in reserve.
|
||
Other operators may not get an app any more.
|
||
- **Dev Mode:** until SimpleLogin approves an app, its consent page warns "… is in Dev
|
||
Mode and isn't approved (yet) by SimpleLogin". Approval is asked for on the app's page.
|
||
- **Redirects:** each must be registered exactly (an unregistered loopback address was
|
||
refused on 23 Oct), and the settings page takes https addresses only.
|
||
- **The ID (`sub`) belongs to one person in one app** (`sub` is the person's link to the
|
||
app, `ClientUser`), whatever the discovery document's `public` says. It changes when the
|
||
person removes the app in SimpleLogin's list, deletes the alias they shared with it, or
|
||
deletes their SimpleLogin account. Once someone has allowed an app, later sign-ins skip
|
||
the consent page and give the same ID.
|
||
- **The consent page always shares an address, a name and an avatar:** the app can't
|
||
choose (`Client.get_scopes`). The address is the person's own or an alias, by default a
|
||
new one, which counts against their aliases.
|
||
|
||
## Decisions (23 Oct 2026, the developer's)
|
||
|
||
- **D1 SimpleLogin only** on learn.halkyon.eu (over SimpleLogin as an extra way in beside
|
||
ADR 0017's, which was recommended): no address, no TOTP and no password signs in there.
|
||
- **D2 A password still unlocks the keys** (recommended, over dropping the keys): it never
|
||
leaves the device; ADR 0017's keys, wrapping and recovery phrase stay.
|
||
- **D3 Per instance:** each operator chooses the ways; operators without a SimpleLogin app
|
||
keep ADR 0017's.
|
||
- **D4 No anonymous accounts, anywhere** (recommended): every account has a way in and a
|
||
handle; the first start needs a connection and an account.
|
||
- **D5 A handle for everyone**, suggested at sign-up from the name SimpleLogin shares
|
||
(recommended), confirmed or changed by the person; the name is then dropped. Its public
|
||
page exists only once the person goes public (recommended): something published or the
|
||
profile filled in.
|
||
- **D6 An ID nobody has, signed out: ask first** (recommended): "New here?" makes an
|
||
account; "I already have one" takes the handle and the recovery phrase (8).
|
||
- **D7 A broken link is mended with the handle and the recovery phrase** (the developer's,
|
||
over the phrase alone, which was recommended): every account has a handle now (D5).
|
||
- **D8 A hard cut-off on learn.halkyon.eu, 30 days after the release** (over the day
|
||
GitHub ends, 22 Jan 2027, which was recommended): from then on every session of an
|
||
account without SimpleLogin ends, GitHub and email codes sign nobody in there, and the
|
||
instance offers SimpleLogin alone.
|
||
- **D9 Phones come back through verified links** (recommended): Android's App Links and
|
||
iOS's https callbacks with associated domains, for learn.halkyon.eu; the forwarding
|
||
page with the app's scheme for other instances, the loopback on desktops.
|
||
- **D10 Accounts without SimpleLogin are deleted 30 days after the cut-off**
|
||
(recommended, over keeping them unreachable): nobody can add SimpleLogin signed out;
|
||
the reminder names both days.
|
||
- **D11 An account left unfinished (no keys) is deleted after a day** (recommended, over
|
||
finishing it at the next sign-in); its handle is free again.
|
||
- **D12 The ID token checked with `dart_jsonwebtoken` itself** (23 Oct, evening,
|
||
recommended), already in the lock through Serverpod's sign-ins. The developer first
|
||
chose Serverpod's own verifier (`IdTokenVerifier`, which Google's and Firebase's
|
||
sign-ins use; not exported, so imported from its `src/`), which can't: SimpleLogin's
|
||
ID tokens carry no `typ` in their header (`jose_utils.make_id_token` writes `alg` and
|
||
`kid` alone, and jwcrypto 0.8 adds nothing), and the verifier calls
|
||
`JWT.verify` with its default `checkHeaderType: true`, so it refuses every one, and
|
||
takes no options to change that. It also tries every key rather than the one the
|
||
`kid` names, never fetches the keys again for an unknown `kid` (an hour of failed
|
||
sign-ins after SimpleLogin changes its key) and fetches them without a timeout. So:
|
||
`checkHeaderType` off, `RS256` alone, the key the `kid` names, fetched again for one
|
||
unknown, with a timeout (over adapting a copy of Serverpod's verifier, or `jose`).
|
||
- **D13 Attempts in a table** (recommended, over the server's memory): a restart or a
|
||
deploy in the middle of a sign-in loses none, and the database takes each once.
|
||
- **D14 Steps 2 to 4 in one release** (recommended, over the server's side alone first):
|
||
the module's side, the keys without SRP with handles and without anonymous accounts,
|
||
and the apps' screens come as one release, so people get a sign-in that works; the
|
||
cut-off (step 5) follows 30 days after it.
|
||
- **D15 The proposal (1–12) as written** (23 Oct, evening); what building it shows
|
||
otherwise comes back to the developer.
|
||
- **Also (23 Oct):** the developer's own account moves by adding SimpleLogin to it while
|
||
signed in with GitHub; the instance's SimpleLogin app goes up for approval once
|
||
halkyon.app/learn exists, before learn.halkyon.eu switches.
|
||
|
||
## Proposal
|
||
|
||
1. **The instance's ways.** The operator's SimpleLogin app: `simpleLoginClientId` and
|
||
`simpleLoginClientSecret` in the passwords, on the register (ADR 0016), which alone
|
||
exchanges codes. `SIGN_IN_WAYS`: `simplelogin`, `password` or both; unset, every way
|
||
whose keys are there. On learn.halkyon.eu `simplelogin`. The instance's basics say
|
||
which ways it offers.
|
||
2. **One SimpleLogin app per instance, the register's.** Every service of the instance
|
||
signs in through it (Todo's too), as ADR 0016 has them sign in through the register;
|
||
one app means one ID per person on the instance.
|
||
3. **The flow.** The app asks its server to begin (`simpleLogin.begin`, with or without a
|
||
session; a member forwards to the register): the register keeps an attempt (a random
|
||
ID the app holds, a `nonce`, which app and platform, the caller if signed in; ten
|
||
minutes, taken once) and answers the authorization address. The app opens it in the
|
||
browser (`flutter_web_auth_2`, as GitHub's). SimpleLogin sends the browser to
|
||
`https://<register's host>/simplelogin-callback`, the one address registered. On a
|
||
phone with learn.halkyon.eu, the system hands that address straight to the app (D9).
|
||
Elsewhere the instance's page there forwards code and `state` to the app: the
|
||
desktop's loopback (`http://127.0.0.1:43823/simplelogin-callback`, each app its own
|
||
port) or the phone's scheme (`app.halkyon.learn://simplelogin-callback`). Its targets
|
||
are fixed per app and platform, chosen by a marker in `state`, so it sends nobody
|
||
elsewhere; SimpleLogin's errors (a denial) are forwarded alike. The app hands the code
|
||
with its attempt's ID to `simpleLogin.complete`.
|
||
4. **Checking.** The register exchanges the code and checks the ID token: RS256 against
|
||
SimpleLogin's keys (`/jwks`, kept, fetched again for an unknown key ID), the issuer
|
||
`https://app.simplelogin.io`, the audience its client ID, not expired, and the `nonce`
|
||
the attempt's. So a code caught on its way to the app is worthless: it can only
|
||
complete the attempt it was asked for, whose ID never left the app. (A hostile app
|
||
starting an attempt of its own is what D9's verified links stop: SimpleLogin answers
|
||
without a consent page for an app already allowed.) It keeps the `sub` alone
|
||
(`simplelogin_identity`: account ID, `sub`, each unique); the name serves only as the
|
||
handle's suggestion at sign-up (D5); address, name and avatar are never stored, and
|
||
SimpleLogin's userinfo is never asked.
|
||
5. **A known ID** gives a session for its account (on a member, the member's own, ADR
|
||
0016). A signed-in caller adds SimpleLogin to their account; a `sub` another account
|
||
has is refused, and nobody is switched to another account.
|
||
6. **Signing up** (D6, "New here?"): the handle (suggested, D5), then the keys on the
|
||
device: made (ADR 0017, 3), a password to unlock them (asked twice, never sent), the
|
||
recovery phrase shown once and three of its words typed back; the register keeps what
|
||
ADR 0017 keeps of them (`keys.set`: salt, costs, public keys, both wrappings), without
|
||
SRP's verifier, an address or TOTP where SimpleLogin is the way in. Until the keys are
|
||
set the account is unfinished: its session does nothing else, and one unfinished for a
|
||
day is deleted (D11).
|
||
7. **A new device** gets the wrapped keys and the salt with the session, and the password
|
||
unwraps them there. Changing it takes the old one on the device (the keys rewrapped and
|
||
sent); a lost password with the phrase rewraps them; without it, new keys, and what was
|
||
encrypted with the old ones is lost (ADR 0017, 6). No TOTP of ours: SimpleLogin and
|
||
Proton have their own second factor.
|
||
8. **Mending a broken link** (D7, "I already have one"): the handle and the recovery
|
||
phrase. The register answers the keys wrapped under the phrase and a nonce; the device
|
||
unwraps them and signs (`halkyon key proof v1|`, as ADR 0017's resets); the new `sub`
|
||
takes the old one's place. A handle nobody has gets a decoy wrapping and the same
|
||
refusal, as ADR 0017 does for addresses; at most 10 tries an hour per handle and 30
|
||
per network.
|
||
9. **Handles** (D5): required at sign-up, on every instance; `/@handle` answers as for an
|
||
unknown handle until the person publishes something or fills in their profile; asking
|
||
whether a handle is free stays limited per person and network, since its answer tells
|
||
that the handle exists.
|
||
10. **Anonymous accounts go** (D4): `anonymousIdp` is removed; the first start offers the
|
||
instance's ways and other instances. On learn.halkyon.eu, anonymous people can add
|
||
SimpleLogin (and a handle and keys) until the cut-off (D8); another instance's
|
||
anonymous people add a password until a cut-off their operator sets
|
||
(`ANONYMOUS_SESSIONS_END`, a day, as `GITHUB_SIGN_IN_ENDS`).
|
||
11. **The cut-off on learn.halkyon.eu** (D8), 30 days after the release: the app reminds
|
||
everyone without SimpleLogin from the release on, saying the day and what follows;
|
||
then their sessions end, and 30 days later their accounts are deleted (D10).
|
||
12. **The privacy page** says that SimpleLogin learns when someone signs in to Halkyon,
|
||
that the instance keeps only SimpleLogin's ID for its app, and that the name helps
|
||
suggest a handle once and the rest of what the consent page shares is dropped; the
|
||
sign-in screen says so before the browser opens. Store listings, the README and the
|
||
website stop saying that the app starts with an anonymous account.
|
||
|
||
## Consequences
|
||
|
||
- People need a SimpleLogin account (a Proton account gives one) and a connection to sign
|
||
up or sign in on a new device; nothing works signed out, on any instance.
|
||
- The instance depends on SimpleLogin's sign-in for apps, which SimpleLogin closed to new
|
||
apps on 22 Oct: if it ends altogether, the operator turns `password` on, and people set
|
||
a password and an address (ADR 0017); their keys stay.
|
||
- Operators who can't get a SimpleLogin app keep ADR 0017's way in; the code keeps both.
|
||
- Everyone has a handle, which stays private until they go public.
|
||
- The keys' password is one more thing to keep, and the recovery phrase also mends a lost
|
||
SimpleLogin link.
|
||
- GitHub's sign-in on phones has the weakness D9 avoids (its callback is the scheme)
|
||
until it ends.
|