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>
21 KiB
21 KiB
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-callbackon the register's web server (halkyon_base'saddSimpleLoginCallbackRoute, 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'sassetlinks.jsonfor Halkyon Learn signed with the release key is answered bydeploy/Caddyfileon learn.halkyon.eu alone, held totool/release/android-certificate.sha256by 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.beginandsimpleLogin.completein halkyon_base, a member asking the register for both; the instance's basics withsimpleLogin, 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 providersimplelogin), 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_WAYScounts on the register, which members ask, and stops any server with a name it doesn't know as it starts; sessions name the methodsimplelogin(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'stesting.dartgives 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), whichaccount_passwordkeeps in place of SRP's verifier for an account that signs in with SimpleLogin (the verifier becomes nullable: a migration that drops nothing);simpleLogin.signUpin one call (D18) andsimpleLogin.checkHandle, both for someone new's identity alone;keys.beginandkeys.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'skeysProof), 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)andsimpleLogin.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 needsSIGN_IN_DECOY_KEYalone of ADR 0017's three keys, a server with a SimpleLogin app and without it stopping as it starts. Then handles (9, D5):/@handleandprofiles.byHandleanswer 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 thenoncethe 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 (subis the person's link to the app,ClientUser), whatever the discovery document'spublicsays. 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.begintakes 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 on127.0.0.1too). 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_jsonwebtokenitself (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 itssrc/), which can't: SimpleLogin's ID tokens carry notypin their header (jose_utils.make_id_tokenwritesalgandkidalone, and jwcrypto 0.8 adds nothing), and the verifier callsJWT.verifywith its defaultcheckHeaderType: true, so it refuses every one, and takes no options to change that. It also tries every key rather than the one thekidnames, never fetches the keys again for an unknownkid(an hour of failed sign-ins after SimpleLogin changes its key) and fetches them without a timeout. So:checkHeaderTypeoff,RS256alone, the key thekidnames, fetched again for one unknown, with a timeout (over adapting a copy of Serverpod's verifier, orjose); 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.signUpmakes 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
- The instance's ways. The operator's SimpleLogin app:
simpleLoginClientIdandsimpleLoginClientSecretin the passwords, on the register (ADR 0016), which alone exchanges codes.SIGN_IN_WAYS:simplelogin,passwordor both; unset, every way whose keys are there. On learn.halkyon.eusimplelogin. The instance's basics say which ways it offers. - 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.
- 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, anonce, 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 tohttps://<register's host>/simplelogin-callback, the one address registered. The instance's page there forwards code andstateto 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 instate, so it sends nobody elsewhere; SimpleLogin's errors (a denial) are forwarded alike. The app hands the code with its attempt's ID tosimpleLogin.complete. - 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 issuerhttps://app.simplelogin.io, the audience its client ID, not expired, and thenoncethe 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 thesubalone (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. - 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
subanother account has is refused, and nobody is switched to another account. - 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). - 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.
- 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 newsubtakes 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. - Handles (D5): required at sign-up, on every instance;
/@handleanswers 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. - Anonymous accounts go (D4):
anonymousIdpis 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, asGITHUB_SIGN_IN_ENDS). - 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).
- 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
passwordon, 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.