diff --git a/android/README.md b/android/README.md index c66668b..2096dad 100644 --- a/android/README.md +++ b/android/README.md @@ -27,7 +27,9 @@ The merge policy in `Mirror.decide` is the same table as the desktop client's ## Building -Requirements: JDK 17 or newer, Gradle 8.9 or newer, and an Android SDK with +Requirements: JDK 17 (lint in AGP 8.7 fails on newer JDKs; set `JAVA_HOME` +to a 17 when Gradle defaults to something else), Gradle 8.9 or newer, and an +Android SDK with `platforms;android-35` and `build-tools;35.0.0`. ```sh diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 4085a1a..f35a041 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -23,10 +23,26 @@ + + android:exported="true" + android:label="@string/sign_in" + android:launchMode="singleTask"> + + + + + + + + Ui.background(this, { + api.deviceStart(android.os.Build.MODEL ?: "android", RETURN_URL) + }) { started -> started.fold({ start -> - message.text = getString(R.string.msg_approve_code, start.userCode) + Prefs(this).pendingSignIn = Prefs.PendingSignIn( + start.deviceCode, start.userCode, start.interval, + System.currentTimeMillis() + start.expiresIn * 1000L, + ) + collectPendingSignIn(now = false) runCatching { startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(start.verificationUrl))) } - Ui.background(this, { poll(api, start) }) { polled -> - polled.fold({ session -> - if (session == null) { - message.setText(R.string.msg_approval_expired) - } else { - finishSignIn(session) - } - }, { failed(it) }) - } }, { failed(it) }) } } - private fun poll(api: Api, start: DeviceStart): SessionInfo? { - val deadline = System.currentTimeMillis() + start.expiresIn * 1000L - val interval = (start.interval.coerceAtLeast(1)) * 1000L - while (System.currentTimeMillis() < deadline) { - Thread.sleep(interval) - api.devicePoll(start.deviceCode)?.let { return it } + /** + * Polls for the pending browser sign-in until it is approved, refused or + * expired. It keeps running while the browser is in front, and with + * [now] polls at once instead of after the first interval, which is what + * returning to the app wants. + */ + private fun collectPendingSignIn(now: Boolean) { + val prefs = Prefs(applicationContext) + val pending = prefs.pendingSignIn ?: return + if (pending.expired) { + prefs.pendingSignIn = null + message.setText(R.string.msg_approval_expired) + return } - return null + message.text = getString(R.string.msg_approve_code, pending.userCode) + poller?.let { running -> + if (running.isAlive) { + if (now) running.interrupt() // cut the current wait short + return + } + } + val api = Api(prefs.serverUrl, null) + val interval = pending.intervalSeconds.coerceAtLeast(1) * 1000L + poller = Thread { + var wait = !now + while (!destroyed && !pending.expired) { + if (wait) { + try { + Thread.sleep(interval) + } catch (_: InterruptedException) { + if (destroyed) return@Thread + } + } + wait = true + val session: SessionInfo? = try { + api.devicePoll(pending.deviceCode) + } catch (e: ApiException) { + // Expired, already used, or refused: this code is spent. + // A screen that went away mid-request may have spent it + // on our behalf, in which case we are signed in. + prefs.pendingSignIn = null + runOnUiThread { + if (destroyed) return@runOnUiThread + if (prefs.signedIn) openMain() else failed(e) + } + return@Thread + } catch (_: IOException) { + continue // the network may be down while the browser is busy + } + if (session == null) continue + // Saved here, not on the UI thread, so an approval collected + // as the screen goes away is still kept. + prefs.pendingSignIn = null + saveSession(prefs, session) + runOnUiThread { if (!destroyed && resumed) openMain() } + return@Thread + } + if (pending.expired) { + prefs.pendingSignIn = null + runOnUiThread { if (!destroyed) message.setText(R.string.msg_approval_expired) } + } + }.apply { start() } } private fun finishSignIn(session: SessionInfo) { - val prefs = Prefs(this) + saveSession(Prefs(applicationContext), session) + openMain() + } + + private fun saveSession(prefs: Prefs, session: SessionInfo) { prefs.token = session.token prefs.username = session.username - Syncer.request(this, "signed in") + Syncer.request(applicationContext, "signed in") + } + + private fun openMain() { startActivity(Intent(this, MainActivity::class.java)) finish() } @@ -113,4 +213,9 @@ class LoginActivity : Activity() { private fun failed(e: Throwable) { message.text = e.message ?: getString(R.string.msg_sign_in_failed) } + + companion object { + /** Where the approval page links back to; see the manifest. */ + const val RETURN_URL = "keeporg://device-approved" + } } diff --git a/android/app/src/main/java/cc/tiredsysadmin/android/keeporg/Prefs.kt b/android/app/src/main/java/cc/tiredsysadmin/android/keeporg/Prefs.kt index f07655e..a9b1f03 100644 --- a/android/app/src/main/java/cc/tiredsysadmin/android/keeporg/Prefs.kt +++ b/android/app/src/main/java/cc/tiredsysadmin/android/keeporg/Prefs.kt @@ -27,6 +27,41 @@ class Prefs(context: Context) { val signedIn: Boolean get() = serverUrl.isNotEmpty() && token.isNotEmpty() + /** A browser sign-in waiting for approval. */ + data class PendingSignIn( + val deviceCode: String, + val userCode: String, + val intervalSeconds: Int, + val expiresAtMillis: Long, + ) { + val expired: Boolean get() = System.currentTimeMillis() >= expiresAtMillis + } + + /** + * The browser sign-in in progress. It lives here rather than in the + * activity because Android may stop the app while the browser is in + * front, and the approval must still be collected when the user returns. + */ + var pendingSignIn: PendingSignIn? + get() { + val raw = sp.getString("pending_sign_in", null) ?: return null + val o = runCatching { JSONObject(raw) }.getOrNull() ?: return null + return PendingSignIn( + o.optString("device_code"), o.optString("user_code"), + o.optInt("interval", 2), o.optLong("expires_at"), + ) + } + set(v) { + if (v == null) { + sp.edit().remove("pending_sign_in").apply() + return + } + val o = JSONObject() + .put("device_code", v.deviceCode).put("user_code", v.userCode) + .put("interval", v.intervalSeconds).put("expires_at", v.expiresAtMillis) + sp.edit().putString("pending_sign_in", o.toString()).apply() + } + /** Whether the user asked this device to keep listening for changes. */ var watchEnabled: Boolean get() = sp.getBoolean("watch_enabled", false) diff --git a/android/metadata/cc.tiredsysadmin.android.keeporg.yml b/android/metadata/cc.tiredsysadmin.android.keeporg.yml index 052f1c7..ce82d71 100644 --- a/android/metadata/cc.tiredsysadmin.android.keeporg.yml +++ b/android/metadata/cc.tiredsysadmin.android.keeporg.yml @@ -49,7 +49,15 @@ Builds: - yes output: android/app/build/outputs/apk/release/app-release-unsigned.apk + - versionName: 0.3.0 + versionCode: 3 + commit: v0.3.0-android + subdir: android/app + gradle: + - yes + output: android/app/build/outputs/apk/release/app-release-unsigned.apk + AutoUpdateMode: None UpdateCheckMode: Tags -CurrentVersion: 0.2.0 -CurrentVersionCode: 2 +CurrentVersion: 0.3.0 +CurrentVersionCode: 3 diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md index ba52b71..83e7ef8 100644 --- a/docs/HANDOVER.md +++ b/docs/HANDOVER.md @@ -157,6 +157,26 @@ event stream open so colleagues' changes arrive without opening the app. `Mirror.decide` is a transcription of the Go decision table. **Keep the two in step**: a change to one belongs in the other in the same commit. +**Browser sign-in survives the app being stopped.** On a phone the browser +is a separate application, and Android may kill KeepOrg's process while it +is in front — SSO with a second factor is plenty of time for that. So the +pending device code is kept in `Prefs.pendingSignIn`, not in the activity, +and `LoginActivity` collects it on every `onResume`, polling at once. The +server's approval page links back with `keeporg://device-approved`, which +the device passes as `return_url` and the manifest routes to +`LoginActivity` (`singleTask`, so the waiting screen comes back rather than +a new one stacking on the browser). The link carries no data: it only +brings the screen forward, and the session still arrives by polling. A +session collected while the screen is in the background is saved at once +and the main screen opened on the next resume, because Android forbids +starting an activity from the background. Against a server older than 0.9, +which rejects `return_url`, `Api.deviceStart` retries without it and the +user returns by hand. + +Before this, the code lived in a thread inside the activity: a killed +process lost it, the approval reached the server and nobody collected it, +and the app came back signed out with nothing saved. + ### The KDBX codec (`internal/kdbx`) A real KeePass 2 implementation, because merging cannot be done on @@ -841,11 +861,17 @@ what an arm64 Linux build host needs. the server would have to hold those too. They work fine on any key-less strategy, where the server never opens the file. - **No transformed-key cache.** Each merge pays the Argon2 cost three times. -- **The Android app has never run on a device.** It compiles clean, lint - reports no issues and the DEX carries every class the manifest names, but - no emulator or phone was available here. The first real run is where to - confirm the file-picker integration, the save-triggered sync and the - foreground notification. +- **The Android app has run only on an emulator, and only to sign in.** + Browser sign-in was driven on an Android 15 emulator against a local + server: approval collected after the process was killed and the return + link followed, and the fallback against a 0.8 server. The file-picker + integration, the save-triggered sync and the foreground notification + still await a first real run. +- **Lint needs JDK 17.** AGP 8.7's lint cannot parse the version string of + newer JDKs (Homebrew's Gradle runs on JDK 26): run it as + `JAVA_HOME=$(/usr/libexec/java_home -v 17) make android-lint`. It reports + one warning, `OldTargetApi`, since API 36 and 37 SDKs exist; raising + `targetSdk` is its own change. - **No key-file or Yubikey handling**, and none needed: those live in the KeePass application. - **Sessions are not listable or revocable individually** beyond diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 9e29971..dc03e85 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -23,11 +23,20 @@ https, and every form carries a per-session CSRF token. | POST | `/api/v1/auth/password` | `{current_password, new_password, totp}` | changes the account password; the current one is required, and the second factor when enrolled. Every other session of that account is revoked | | POST | `/api/v1/auth/totp/enroll` | – | `{secret, uri}`; not active until confirmed | | POST | `/api/v1/auth/totp/confirm` | `{code}` | `{totp_enabled: true}` | -| POST | `/api/v1/auth/device/start` | `{label}` | `{device_code, user_code, verification_url, interval, expires_in}` | +| POST | `/api/v1/auth/device/start` | `{label, return_url}` | `{device_code, user_code, verification_url, interval, expires_in}`; `400` when `return_url` is not a `keeporg:` URL | | POST | `/api/v1/auth/device/poll` | `{device_code}` | `202 {status: "pending"}`, then `200 {token, ...}` once; `410 expired` | A device code is single use: the first successful poll consumes it. +`return_url` is optional (server 0.9 and later). When it is given, the page +that ends the approval, after a password or single sign-on, carries a +"Return to KeepOrg" link to it. Only the `keeporg:` scheme is accepted, so +the page cannot be made to send anybody elsewhere. It is for a client whose +browser is a separate application, which is to say a phone; the client still +learns of the approval by polling, and the link only brings the person back. +Servers before 0.9 reject the unknown field with `400`, so a client should +retry without it. + ## Synchronization | Method | Path | Notes | diff --git a/internal/server/auth.go b/internal/server/auth.go index 268c4f7..f5b4961 100644 --- a/internal/server/auth.go +++ b/internal/server/auth.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "net/http" + "net/url" "strings" "time" @@ -187,15 +188,22 @@ func (s *Server) handleTOTPConfirm(w http.ResponseWriter, r *http.Request, u *st func (s *Server) handleDeviceStart(w http.ResponseWriter, r *http.Request) { var req struct { - Label string `json:"label"` + Label string `json:"label"` + ReturnURL string `json:"return_url"` } if r.ContentLength > 0 && !readJSON(w, r, &req) { return } + if req.ReturnURL != "" && !validReturnURL(req.ReturnURL) { + fail(w, http.StatusBadRequest, "bad_request", + "return_url must use the "+returnScheme+": scheme") + return + } d := &store.DeviceAuth{ DeviceCode: secret.Token(), UserCode: secret.UserCode(), Label: req.Label, + ReturnURL: req.ReturnURL, CreatedAt: time.Now(), ExpiresAt: time.Now().Add(deviceTTL), } @@ -276,11 +284,13 @@ func (s *Server) handleDeviceApprove(w http.ResponseWriter, r *http.Request) { return } code := strings.ToUpper(strings.TrimSpace(r.FormValue("code"))) + var returnURL string err := s.st.Update(func(st *store.State) error { d := st.DeviceByUserCode(code) if d == nil || time.Now().After(d.ExpiresAt) { return errors.New("unknown or expired code") } + returnURL = d.ReturnURL u, err := checkPassword(st, r.FormValue("username"), r.FormValue("password"), r.FormValue("totp")) if err != nil { return err @@ -298,10 +308,20 @@ func (s *Server) handleDeviceApprove(w http.ResponseWriter, r *http.Request) { case err != nil: s.renderDevice(w, code, err.Error()) default: - s.renderDone(w, "Device approved", "You can close this page and return to the application.") + s.renderApproved(w, returnURL) } } +// returnScheme is the only scheme a device may ask to be sent back to. It +// names KeepOrg's own clients, so the approval page cannot be turned into a +// redirect to anywhere else. +const returnScheme = "keeporg" + +func validReturnURL(raw string) bool { + u, err := url.Parse(raw) + return err == nil && u.Scheme == returnScheme && u.User == nil +} + // changePassword replaces a user's password after proving the current one. // It is the one place a password changes, so the rules live here: the // current password is required even though the caller already holds a diff --git a/internal/server/integration_test.go b/internal/server/integration_test.go index 766bf7e..6f470db 100644 --- a/internal/server/integration_test.go +++ b/internal/server/integration_test.go @@ -452,3 +452,39 @@ func TestNonAdminCannotAdminister(t *testing.T) { t.Fatalf("anonymous list: %d", status) } } + +func TestDeviceApprovalLinksBackToTheApp(t *testing.T) { + h := newHarness(t) + if status, body := h.call("POST", "/api/v1/auth/device/start", "", + map[string]string{"label": "phone", "return_url": "https://evil.example/"}); status != http.StatusBadRequest { + t.Fatalf("foreign return_url accepted: %d %v", status, body) + } + status, start := h.call("POST", "/api/v1/auth/device/start", "", + map[string]string{"label": "phone", "return_url": "keeporg://device-approved"}) + if status != http.StatusOK { + t.Fatalf("device start: %d %v", status, start) + } + resp, err := h.http.Client().PostForm(h.http.URL+"/device", url.Values{ + "code": {start["user_code"].(string)}, "username": {"admin"}, "password": {adminPassword}}) + if err != nil { + t.Fatal(err) + } + page, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if !strings.Contains(string(page), `href="keeporg://device-approved"`) { + t.Fatalf("approval page has no link back to the app: %s", page) + } + + // A client that asked for nothing gets no link. + _, start = h.call("POST", "/api/v1/auth/device/start", "", map[string]string{"label": "laptop"}) + resp, err = h.http.Client().PostForm(h.http.URL+"/device", url.Values{ + "code": {start["user_code"].(string)}, "username": {"admin"}, "password": {adminPassword}}) + if err != nil { + t.Fatal(err) + } + page, _ = io.ReadAll(resp.Body) + resp.Body.Close() + if strings.Contains(string(page), "Return to KeepOrg") { + t.Fatalf("link shown without a return_url: %s", page) + } +} diff --git a/internal/server/oidc.go b/internal/server/oidc.go index 364512c..e473914 100644 --- a/internal/server/oidc.go +++ b/internal/server/oidc.go @@ -232,7 +232,7 @@ func (s *Server) handleOIDCCallback(w http.ResponseWriter, r *http.Request) { s.renderDone(w, "Sign-in failed", "The identity provider rejected the sign-in.") return } - var webToken string + var webToken, returnURL string if err := s.st.Update(func(st *store.State) error { var dev *store.DeviceAuth for _, d := range st.Devices { @@ -257,6 +257,7 @@ func (s *Server) handleOIDCCallback(w http.ResponseWriter, r *http.Request) { return nil } dev.SessionToken = token + returnURL = dev.ReturnURL return nil }); err != nil { s.renderDone(w, "Sign-in failed", err.Error()) @@ -267,7 +268,7 @@ func (s *Server) handleOIDCCallback(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/web/vaults", http.StatusFound) return } - s.renderDone(w, "Device approved", "You can close this page and return to the application.") + s.renderApproved(w, returnURL) } // linkOIDCUser finds the account behind an identity, provisioning one when diff --git a/internal/server/oidc_test.go b/internal/server/oidc_test.go index ffff452..a0a12e5 100644 --- a/internal/server/oidc_test.go +++ b/internal/server/oidc_test.go @@ -140,7 +140,8 @@ func TestOIDCApprovesAWaitingClient(t *testing.T) { idp := newFakeIdP(t, "subject-2", "dev@example.com", "devuser") h := newHarnessWith(t, idp.withOIDC(true, "everyone")) - status, start := h.call("POST", "/api/v1/auth/device/start", "", map[string]string{"label": "laptop"}) + status, start := h.call("POST", "/api/v1/auth/device/start", "", + map[string]string{"label": "phone", "return_url": "keeporg://device-approved"}) if status != http.StatusOK { t.Fatalf("device start: %d %v", status, start) } @@ -151,6 +152,10 @@ func TestOIDCApprovesAWaitingClient(t *testing.T) { if status != http.StatusOK || !strings.Contains(page, "Device approved") { t.Fatalf("device approval through single sign-on: %d\n%s", status, page) } + // A phone is handed back to its app from the provider's redirect too. + if !strings.Contains(page, `href="keeporg://device-approved"`) { + t.Fatalf("no link back to the app after single sign-on:\n%s", page) + } // A device approval must not also hand the browser a session cookie. if b.cookie() != "" { t.Fatal("approving a device set a web session cookie") diff --git a/internal/server/pages.go b/internal/server/pages.go index be88a00..b82cdab 100644 --- a/internal/server/pages.go +++ b/internal/server/pages.go @@ -31,8 +31,9 @@ p.sub{margin:0 0 1.25rem;opacity:.7;font-size:.9rem} label{display:block;font-size:.8rem;text-transform:uppercase;letter-spacing:.04em;opacity:.7;margin:.9rem 0 .25rem} input{width:100%;padding:.6rem .7rem;font:inherit;color:var(--fg);background:transparent; border:1px solid var(--line);border-radius:8px} -button{width:100%;margin-top:1.4rem;padding:.7rem;font:inherit;font-weight:600;color:#fff; +button,a.button{width:100%;margin-top:1.4rem;padding:.7rem;font:inherit;font-weight:600;color:#fff; background:var(--accent);border:0;border-radius:8px;cursor:pointer} +a.button{display:block;box-sizing:border-box;text-align:center;text-decoration:none} .err{margin:0 0 1rem;padding:.6rem .7rem;border-radius:8px;background:#f8d7da;color:#842029;font-size:.9rem} .alt{display:block;margin-top:1rem;text-align:center;font-size:.9rem;color:var(--accent)} code{font-size:1.1rem;letter-spacing:.08em} @@ -250,7 +251,8 @@ to ask for.

{{end}}`)) var doneTmpl = template.Must(template.Must(pages.Clone()).Parse(` -{{define "body"}}

{{.Title}}

{{.Message}}

{{end}}`)) +{{define "body"}}

{{.Title}}

{{.Message}}

+{{if .Return}}

Return to KeepOrg

{{end}}{{end}}`)) func (s *Server) renderDevice(w http.ResponseWriter, code, errMsg string) { render(w, deviceTmpl, map[string]any{ @@ -290,6 +292,18 @@ func (s *Server) renderDone(w http.ResponseWriter, title, msg string) { render(w, doneTmpl, map[string]any{"Title": title, "Message": msg}) } +// renderApproved ends a device approval. A client that gave a return URL +// gets a link back to itself; html/template would neutralise its custom +// scheme, which is why it is marked safe here, after validReturnURL. +func (s *Server) renderApproved(w http.ResponseWriter, returnURL string) { + d := map[string]any{"Title": "Device approved", + "Message": "You can close this page and return to the application."} + if returnURL != "" && validReturnURL(returnURL) { + d["Return"] = template.URL(returnURL) + } + render(w, doneTmpl, d) +} + func render(w http.ResponseWriter, t *template.Template, data map[string]any) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Referrer-Policy", "no-referrer") diff --git a/internal/store/model.go b/internal/store/model.go index 982b41c..00e4a67 100644 --- a/internal/store/model.go +++ b/internal/store/model.go @@ -161,12 +161,16 @@ type DeviceAuth struct { UserCode string `json:"user_code"` // Web marks a sign-in that ends in a cookie rather than in a token a // waiting client polls for. - Web bool `json:"web,omitempty"` - Label string `json:"label,omitempty"` - SessionToken string `json:"session_token,omitempty"` - OIDCState string `json:"oidc_state,omitempty"` - CreatedAt time.Time `json:"created_at"` - ExpiresAt time.Time `json:"expires_at"` + Web bool `json:"web,omitempty"` + Label string `json:"label,omitempty"` + SessionToken string `json:"session_token,omitempty"` + OIDCState string `json:"oidc_state,omitempty"` + // ReturnURL is where the approval page sends the person back to: the + // client's own URL scheme, for a phone whose browser would otherwise be + // a dead end. Empty for clients that need no hand-back. + ReturnURL string `json:"return_url,omitempty"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at"` } // State is the whole metadata set, serialized as one JSON document. diff --git a/packaging/deb/server/changelog b/packaging/deb/server/changelog index a1182f8..91a2d06 100644 --- a/packaging/deb/server/changelog +++ b/packaging/deb/server/changelog @@ -1,3 +1,12 @@ +keeporgd (0.9.0) unstable; urgency=medium + + * A client starting a browser sign-in may pass return_url, a keeporg: + URL, and the approval page then links back to it, after a password or + single sign-on alike. The Android app uses it to come back from the + browser. + + -- Azer Abdullaev Mon, 02 Nov 2026 13:11:01 +0100 + keeporgd (0.8.0) unstable; urgency=medium * The public base URL can be set with the KEEPORG_EXTERNAL_URL diff --git a/versions.mk b/versions.mk index 6cbd18b..d61876e 100644 --- a/versions.mk +++ b/versions.mk @@ -1,13 +1,13 @@ # Single source of truth for all platform versions (semver). # Bump with: make bump PLATFORM= PART= -VERSION_SERVER := 0.8.0 +VERSION_SERVER := 0.9.0 VERSION_LINUX := 0.5.0 VERSION_MACOS := 0.5.0 VERSION_WINDOWS := 0.5.0 VERSION_BSD := 0.5.0 -VERSION_ANDROID := 0.2.0 +VERSION_ANDROID := 0.3.0 # Monotonic Android versionCode, never decreases. -ANDROID_VERSION_CODE := 2 +ANDROID_VERSION_CODE := 3 # Reverse-DNS application identifiers. APPID_ANDROID := cc.tiredsysadmin.android.keeporg