simpleLogin.begin takes the way back (SimpleLoginReturn: loopback, scheme) rather than the platform, so macOS can come back through its scheme as iOS and Android do; the markers become learn-loopback, learn-scheme, todo-loopback and todo-scheme. Not breaking: the endpoint has never been released. D9 corrected by the developer after research: Android, iOS and macOS come back through the app's scheme, Linux and Windows through the loopback. Verified links protected nothing here: a code caught on its way is worthless without the attempt's ID, and a hostile app beginning an attempt of its own can ask for a way back that other platforms need open. The ADR notes the residual risk and the check of prompt=consent still to do. assetlinks.json stays served. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
287 lines
21 KiB
Markdown
287 lines
21 KiB
Markdown
# ADR 0020: Signing in with SimpleLogin only
|
||
|
||
- **Status:** decided 23 Oct 2026, by the developer (D1–D20 below), the *Proposal*
|
||
with it (D15), as D18 to D20 change it.
|
||
- **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>-<way back>.<rest>`: the marker one of the module's
|
||
fixed table (Learn and Todo, the loopback on ports 43823 and 43824, or the app's
|
||
scheme; named `<app>-<platform>` until D9's correction), 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 is answered by `deploy/Caddyfile` on learn.halkyon.eu alone, held to
|
||
`tool/release/android-certificate.sha256` by a test: built for D9's verified links,
|
||
which its correction dropped, and kept, as it costs nothing.
|
||
- **Built (23 Oct 2026), step 2:** the sign-in on the server's side (1 to 5), on
|
||
halkyon_oidc (ADR 0023) with its SimpleLogin preset: `simpleLogin.begin` and
|
||
`simpleLogin.complete` in halkyon_base, a member asking the register for both; the
|
||
instance's basics with `simpleLogin`, the ways in with it; the attempts and the
|
||
newcomers' identities forgotten hourly with the sign-up attempts. Otherwise than
|
||
proposed: the identities are halkyon_oidc's (`halkyon_oidc_identity`: account,
|
||
provider, subject; the provider `simplelogin`), not a table of halkyon_base's; the app
|
||
and platform are the state's marker rather than a column; a signed-in caller of a
|
||
member becomes a person on the register as the attempt begins, as linking would make
|
||
them; GitHub links nobody more where SimpleLogin is offered, as where passwords are
|
||
(ADR 0017); `SIGN_IN_WAYS` counts on the register, which members ask, and stops any
|
||
server with a name it doesn't know as it starts; sessions name the method
|
||
`simplelogin` (these deviations accepted by the developer, 23 Oct). Someone new gets
|
||
no account yet: their identity waits (D16), with a
|
||
handle suggested (D17), for the sign-up of step 3. halkyon_oidc's `testing.dart` gives
|
||
hosts a fake SimpleLogin signing real ID tokens, which the module's tests use too.
|
||
- **Built (23 Oct 2026), step 3 so far:** the password's own proof key (`halkyon_keys`'
|
||
subkey 3 of the master key, D19), which `account_password` keeps in place of SRP's
|
||
verifier for an account that signs in with SimpleLogin (the verifier becomes nullable:
|
||
a migration that drops nothing); `simpleLogin.signUp` in one call (D18) and
|
||
`simpleLogin.checkHandle`, both for someone new's identity alone; `keys.begin` and
|
||
`keys.unlock`, the proof counted as ADR 0017's; every sign-in into an account kept ten
|
||
minutes under its attempt as D20's confirmation. Then the changes, each through the
|
||
register: the first keys (and handle, where none) of an account SimpleLogin was added
|
||
to (`keys.set`), with the sign-in alone, as there is no password to prove yet; a new
|
||
password, a lost one with the phrase (`keys.beginPhrase`, `keys.resetPassword`), a new
|
||
phrase and the deletion (`account.delete`'s `keysProof`), each with the proof and the
|
||
sign-in, which the change takes: one change for each sign-in. A new or lost password
|
||
ends every other session, as ADR 0017's change does. Then mending a broken link
|
||
(8, D7): `simpleLogin.beginMend(attempt, handle)` and `simpleLogin.mend(attempt,
|
||
proof)`, the phrase's proof binding the attempt, so a relayed proof links nobody
|
||
else's identity. Otherwise than proposed: only an account with a SimpleLogin identity
|
||
is mended (D10: nobody adds SimpleLogin signed out), every other answered with a
|
||
decoy; mending ends every session of the account, as whoever came in through the old
|
||
link may still be in; and SimpleLogin needs `SIGN_IN_DECOY_KEY` alone of ADR 0017's
|
||
three keys, a server with a SimpleLogin app and without it stopping as it starts.
|
||
Then handles (9, D5): `/@handle` and `profiles.byHandle` answer as for a handle nobody
|
||
has until its person goes public (a handle fixed by a publish, or a display name, a
|
||
bio, a link or an avatar), the host's own code still finding whose a handle is.
|
||
Otherwise than proposed: not only asking whether a handle is free is limited, but
|
||
every answer about a new handle, counted together (`countHandleCheck`, 30 an hour per
|
||
person or attempt, 60 per network): saving a profile with one, signing up and the
|
||
first keys with one, and Halkyon Learn's blocks by handle, as each tells the same, so
|
||
a limit on checking alone would limit nothing; a person's own handle and what is no
|
||
handle aren't counted. The handle suggested at sign-up is not counted: it tells at
|
||
most that the name SimpleLogin shared is someone's handle already, once a sign-in.
|
||
Step 3's deviations accepted by the developer (23 Oct, night).
|
||
- **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 Apps come back through their scheme or the loopback** (corrected 23 Oct, night,
|
||
by the developer, after research; decided first as verified links for phones on
|
||
learn.halkyon.eu, Android's App Links and iOS's https callbacks): Android, iOS and
|
||
macOS come back through the app's scheme, which the system's browser sheet catches
|
||
(Chrome's Auth Tab, since Chrome 132, even after the forwarding page's redirect;
|
||
`ASWebAuthenticationSession`), Linux and Windows through the loopback.
|
||
`simpleLogin.begin` takes the way back (`SimpleLoginReturn`: `loopback`, `scheme`),
|
||
not the platform. Verified links protected nothing here: a code caught on its way is
|
||
worthless without the attempt's ID (4), and a hostile app beginning an attempt of its
|
||
own can ask for the scheme or the loopback, which other platforms need open (an
|
||
Android app can listen on `127.0.0.1` too). They cost: debug builds and other
|
||
instances need the scheme anyway, Firefox has no Auth Tab, Samsung Internet stays on
|
||
the page, and flutter_web_auth_2 (v5.1.0) has open bugs with https callbacks (#183,
|
||
#215). The scheme is what AppAuth calls the most widely supported.
|
||
- **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`); in
|
||
the OpenID Connect module the sign-in is built on (ADR 0023).
|
||
- **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.
|
||
- **D16 A newcomer's identity waits 30 minutes** (23 Oct, night, recommended, over the
|
||
attempt's ten minutes or a day): long enough to choose a handle and make the keys, or
|
||
to find the recovery phrase and mend the link (8); after that they sign in with
|
||
SimpleLogin again, whose consent page they then skip. Nothing is reserved meanwhile,
|
||
the suggested handle neither.
|
||
- **D17 Handles suggested as `anna_mueller`** (23 Oct, night, recommended): the name in
|
||
lower case, its words joined by underscores; German's letters as German writes them
|
||
without (ü as ue, ß as ss), æ, œ and þ as ae, oe and th, every other Latin letter
|
||
without its mark (é as e, ø as o); apostrophes dropped, anything else between words;
|
||
cut to 30 characters, and where the handle is taken (whatever its case) or kept by the
|
||
instance, a number from 2 to 99 after it. None where nothing of the name is left (a
|
||
name in another script): the person types one.
|
||
- **D18 Signing up in one call** (23 Oct, night, recommended, over the proposal's two
|
||
steps): the device takes the handle, makes the keys, asks the password twice and shows
|
||
the recovery phrase (three of its words typed back), then `simpleLogin.signUp` makes
|
||
the account and its session together, the newcomer's identity taken once. No account is
|
||
ever unfinished, so D11's deletion after a day has nothing left to delete.
|
||
- **D19 The password proven with a proof key of its own, the keys only after it** (23
|
||
Oct, night, recommended, over the keys coming with the session, as Bitwarden's SSO
|
||
has it, and over keeping SRP): a third subkey of the password's Argon2id seeds an
|
||
Ed25519 key, whose public half the register keeps; the device signs a one-time nonce
|
||
with it as it signs with the recovery phrase's (ADR 0017, the same challenges and
|
||
message). A new device gets the wrapped keys once it has proven the password, so
|
||
someone who took over a SimpleLogin account can only guess the password online,
|
||
against the limits, never offline. SimpleLogin accounts keep no SRP verifier.
|
||
- **D20 Changes take the password and a fresh SimpleLogin sign-in** (23 Oct, night,
|
||
recommended, over the password's proof alone): a new password, a new recovery phrase
|
||
and deleting the account take the password's proof (the phrase's, for a lost
|
||
password) and a SimpleLogin sign-in of the last ten minutes, the two factors of ADR
|
||
0017's third reading with SimpleLogin's and Proton's own second factor in TOTP's
|
||
place.
|
||
- **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. The
|
||
instance's page there forwards code and `state` to the app (D9): the loopback on
|
||
Linux and Windows (`http://127.0.0.1:43823/simplelogin-callback`, each app its own
|
||
port) or the app's scheme on Android, iOS and macOS
|
||
(`app.halkyon.learn://simplelogin-callback`). Its targets
|
||
are fixed per app and way back, 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
|
||
on the device beginning an attempt of its own isn't stopped, verified links or not
|
||
(D9): 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.
|
||
- A hostile app on a person's device can begin a sign-in of its own and, once the person
|
||
has allowed Halkyon at SimpleLogin, come back with a session without a consent page
|
||
(4, D9); what it gets is the account's server-side data, while private studysets,
|
||
tags and settings stay sealed under the keys (ADR 0019). Whether SimpleLogin honours
|
||
OpenID Connect's `prompt=consent`, which would show the consent page every time, is
|
||
still to be checked.
|