Collect Android browser sign-ins after the app was stopped
The Android app kept a pending browser sign-in in a thread inside the login screen. Android often kills the process while the browser is in front, so the approval reached the server but nothing collected it, and the app came back signed out with nothing saved. The pending device code is now kept in Prefs and collected whenever the login screen resumes. The app passes return_url=keeporg://device-approved when it starts the sign-in, and the server's approval page, after a password or single sign-on, links back to it. Only the keeporg: scheme is accepted. Against a server older than 0.9, which rejects the field, the app retries without it. Verified on an Android 15 emulator: approval collected after the process was killed, and the fallback against the 0.8 server. Server 0.9.0, Android 0.3.0 (versionCode 3). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
ef6598fa15
commit
318bd68908
+3
-1
@@ -27,7 +27,9 @@ The merge policy in `Mirror.decide` is the same table as the desktop client's
|
|||||||
|
|
||||||
## Building
|
## 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`.
|
`platforms;android-35` and `build-tools;35.0.0`.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
@@ -23,10 +23,26 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<!-- Exported for one link: the server's device-approval page offers
|
||||||
|
keeporg://device-approved to bring the user back from the
|
||||||
|
browser. Opening it only makes this screen collect the sign-in
|
||||||
|
it already started; it carries no data. singleTask brings back
|
||||||
|
the waiting instance instead of stacking a new one on the
|
||||||
|
browser. -->
|
||||||
<activity
|
<activity
|
||||||
android:name=".LoginActivity"
|
android:name=".LoginActivity"
|
||||||
android:exported="false"
|
android:exported="true"
|
||||||
android:label="@string/sign_in" />
|
android:label="@string/sign_in"
|
||||||
|
android:launchMode="singleTask">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data
|
||||||
|
android:host="device-approved"
|
||||||
|
android:scheme="keeporg" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
<service
|
<service
|
||||||
android:name=".SyncService"
|
android:name=".SyncService"
|
||||||
|
|||||||
@@ -67,8 +67,21 @@ class Api(baseUrl: String, var token: String?) {
|
|||||||
return session(request("POST", "/api/v1/auth/login", body))
|
return session(request("POST", "/api/v1/auth/login", body))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deviceStart(label: String): DeviceStart {
|
/**
|
||||||
val o = request("POST", "/api/v1/auth/device/start", JSONObject().put("label", label))
|
* Starts a browser sign-in. [returnUrl] is offered on the approval page
|
||||||
|
* as a link back to this app; the server accepts only keeporg: URLs.
|
||||||
|
* A server older than 0.9 rejects the field, so the request is retried
|
||||||
|
* without it: the sign-in still completes, the user just switches back
|
||||||
|
* by hand.
|
||||||
|
*/
|
||||||
|
fun deviceStart(label: String, returnUrl: String): DeviceStart {
|
||||||
|
val body = JSONObject().put("label", label).put("return_url", returnUrl)
|
||||||
|
val o = try {
|
||||||
|
request("POST", "/api/v1/auth/device/start", body)
|
||||||
|
} catch (e: ApiException) {
|
||||||
|
if (e.status != 400 || e.message?.contains("return_url") != true) throw e
|
||||||
|
request("POST", "/api/v1/auth/device/start", JSONObject().put("label", label))
|
||||||
|
}
|
||||||
return DeviceStart(
|
return DeviceStart(
|
||||||
o.getString("device_code"),
|
o.getString("device_code"),
|
||||||
o.getString("user_code"),
|
o.getString("user_code"),
|
||||||
|
|||||||
@@ -6,16 +6,30 @@ import android.net.Uri
|
|||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.text.InputType
|
import android.text.InputType
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Signs the device in, either with a password and the built-in second
|
* Signs the device in, either with a password and the built-in second
|
||||||
* factor, or by approving a code in a browser, which is also how single
|
* factor, or by approving a code in a browser, which is also how single
|
||||||
* sign-on works.
|
* sign-on works.
|
||||||
|
*
|
||||||
|
* A browser sign-in outlives this screen. The pending code is kept in
|
||||||
|
* [Prefs], so it is collected whenever the screen comes back: when the
|
||||||
|
* approval page's "Return to KeepOrg" link reopens it, when the user
|
||||||
|
* switches back by hand, or when Android restarted the app meanwhile.
|
||||||
*/
|
*/
|
||||||
class LoginActivity : Activity() {
|
class LoginActivity : Activity() {
|
||||||
|
|
||||||
private lateinit var message: TextView
|
private lateinit var message: TextView
|
||||||
|
|
||||||
|
/** Polls for the pending browser sign-in while this screen exists. */
|
||||||
|
private var poller: Thread? = null
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var destroyed = false
|
||||||
|
|
||||||
|
private var resumed = false
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
val prefs = Prefs(this)
|
val prefs = Prefs(this)
|
||||||
@@ -69,43 +83,129 @@ class LoginActivity : Activity() {
|
|||||||
setContentView(root)
|
setContentView(root)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
// The approval page's link back. onResume follows and collects it.
|
||||||
|
setIntent(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
resumed = true
|
||||||
|
if (Prefs(this).signedIn) {
|
||||||
|
// Approved while this screen was in the background.
|
||||||
|
openMain()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
collectPendingSignIn(now = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPause() {
|
||||||
|
resumed = false
|
||||||
|
super.onPause()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
destroyed = true
|
||||||
|
poller?.interrupt()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
/** Runs the device flow: show a code, open a browser, wait for approval. */
|
/** Runs the device flow: show a code, open a browser, wait for approval. */
|
||||||
private fun browserSignIn(api: Api) {
|
private fun browserSignIn(api: Api) {
|
||||||
message.setText(R.string.msg_opening_browser)
|
message.setText(R.string.msg_opening_browser)
|
||||||
Ui.background(this, { api.deviceStart(android.os.Build.MODEL ?: "android") }) { started ->
|
Ui.background(this, {
|
||||||
|
api.deviceStart(android.os.Build.MODEL ?: "android", RETURN_URL)
|
||||||
|
}) { started ->
|
||||||
started.fold({ start ->
|
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 {
|
runCatching {
|
||||||
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(start.verificationUrl)))
|
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) })
|
}, { failed(it) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun poll(api: Api, start: DeviceStart): SessionInfo? {
|
/**
|
||||||
val deadline = System.currentTimeMillis() + start.expiresIn * 1000L
|
* Polls for the pending browser sign-in until it is approved, refused or
|
||||||
val interval = (start.interval.coerceAtLeast(1)) * 1000L
|
* expired. It keeps running while the browser is in front, and with
|
||||||
while (System.currentTimeMillis() < deadline) {
|
* [now] polls at once instead of after the first interval, which is what
|
||||||
Thread.sleep(interval)
|
* returning to the app wants.
|
||||||
api.devicePoll(start.deviceCode)?.let { return it }
|
*/
|
||||||
|
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) {
|
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.token = session.token
|
||||||
prefs.username = session.username
|
prefs.username = session.username
|
||||||
Syncer.request(this, "signed in")
|
Syncer.request(applicationContext, "signed in")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openMain() {
|
||||||
startActivity(Intent(this, MainActivity::class.java))
|
startActivity(Intent(this, MainActivity::class.java))
|
||||||
finish()
|
finish()
|
||||||
}
|
}
|
||||||
@@ -113,4 +213,9 @@ class LoginActivity : Activity() {
|
|||||||
private fun failed(e: Throwable) {
|
private fun failed(e: Throwable) {
|
||||||
message.text = e.message ?: getString(R.string.msg_sign_in_failed)
|
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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,41 @@ class Prefs(context: Context) {
|
|||||||
|
|
||||||
val signedIn: Boolean get() = serverUrl.isNotEmpty() && token.isNotEmpty()
|
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. */
|
/** Whether the user asked this device to keep listening for changes. */
|
||||||
var watchEnabled: Boolean
|
var watchEnabled: Boolean
|
||||||
get() = sp.getBoolean("watch_enabled", false)
|
get() = sp.getBoolean("watch_enabled", false)
|
||||||
|
|||||||
@@ -49,7 +49,15 @@ Builds:
|
|||||||
- yes
|
- yes
|
||||||
output: android/app/build/outputs/apk/release/app-release-unsigned.apk
|
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
|
AutoUpdateMode: None
|
||||||
UpdateCheckMode: Tags
|
UpdateCheckMode: Tags
|
||||||
CurrentVersion: 0.2.0
|
CurrentVersion: 0.3.0
|
||||||
CurrentVersionCode: 2
|
CurrentVersionCode: 3
|
||||||
|
|||||||
+31
-5
@@ -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
|
`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.
|
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`)
|
### The KDBX codec (`internal/kdbx`)
|
||||||
|
|
||||||
A real KeePass 2 implementation, because merging cannot be done on
|
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
|
the server would have to hold those too. They work fine on any key-less
|
||||||
strategy, where the server never opens the file.
|
strategy, where the server never opens the file.
|
||||||
- **No transformed-key cache.** Each merge pays the Argon2 cost three times.
|
- **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
|
- **The Android app has run only on an emulator, and only to sign in.**
|
||||||
reports no issues and the DEX carries every class the manifest names, but
|
Browser sign-in was driven on an Android 15 emulator against a local
|
||||||
no emulator or phone was available here. The first real run is where to
|
server: approval collected after the process was killed and the return
|
||||||
confirm the file-picker integration, the save-triggered sync and the
|
link followed, and the fallback against a 0.8 server. The file-picker
|
||||||
foreground notification.
|
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
|
- **No key-file or Yubikey handling**, and none needed: those live in the
|
||||||
KeePass application.
|
KeePass application.
|
||||||
- **Sessions are not listable or revocable individually** beyond
|
- **Sessions are not listable or revocable individually** beyond
|
||||||
|
|||||||
+10
-1
@@ -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/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/enroll` | – | `{secret, uri}`; not active until confirmed |
|
||||||
| POST | `/api/v1/auth/totp/confirm` | `{code}` | `{totp_enabled: true}` |
|
| 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` |
|
| 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.
|
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
|
## Synchronization
|
||||||
|
|
||||||
| Method | Path | Notes |
|
| Method | Path | Notes |
|
||||||
|
|||||||
+22
-2
@@ -4,6 +4,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"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) {
|
func (s *Server) handleDeviceStart(w http.ResponseWriter, r *http.Request) {
|
||||||
var req struct {
|
var req struct {
|
||||||
Label string `json:"label"`
|
Label string `json:"label"`
|
||||||
|
ReturnURL string `json:"return_url"`
|
||||||
}
|
}
|
||||||
if r.ContentLength > 0 && !readJSON(w, r, &req) {
|
if r.ContentLength > 0 && !readJSON(w, r, &req) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if req.ReturnURL != "" && !validReturnURL(req.ReturnURL) {
|
||||||
|
fail(w, http.StatusBadRequest, "bad_request",
|
||||||
|
"return_url must use the "+returnScheme+": scheme")
|
||||||
|
return
|
||||||
|
}
|
||||||
d := &store.DeviceAuth{
|
d := &store.DeviceAuth{
|
||||||
DeviceCode: secret.Token(),
|
DeviceCode: secret.Token(),
|
||||||
UserCode: secret.UserCode(),
|
UserCode: secret.UserCode(),
|
||||||
Label: req.Label,
|
Label: req.Label,
|
||||||
|
ReturnURL: req.ReturnURL,
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
ExpiresAt: time.Now().Add(deviceTTL),
|
ExpiresAt: time.Now().Add(deviceTTL),
|
||||||
}
|
}
|
||||||
@@ -276,11 +284,13 @@ func (s *Server) handleDeviceApprove(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
code := strings.ToUpper(strings.TrimSpace(r.FormValue("code")))
|
code := strings.ToUpper(strings.TrimSpace(r.FormValue("code")))
|
||||||
|
var returnURL string
|
||||||
err := s.st.Update(func(st *store.State) error {
|
err := s.st.Update(func(st *store.State) error {
|
||||||
d := st.DeviceByUserCode(code)
|
d := st.DeviceByUserCode(code)
|
||||||
if d == nil || time.Now().After(d.ExpiresAt) {
|
if d == nil || time.Now().After(d.ExpiresAt) {
|
||||||
return errors.New("unknown or expired code")
|
return errors.New("unknown or expired code")
|
||||||
}
|
}
|
||||||
|
returnURL = d.ReturnURL
|
||||||
u, err := checkPassword(st, r.FormValue("username"), r.FormValue("password"), r.FormValue("totp"))
|
u, err := checkPassword(st, r.FormValue("username"), r.FormValue("password"), r.FormValue("totp"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -298,10 +308,20 @@ func (s *Server) handleDeviceApprove(w http.ResponseWriter, r *http.Request) {
|
|||||||
case err != nil:
|
case err != nil:
|
||||||
s.renderDevice(w, code, err.Error())
|
s.renderDevice(w, code, err.Error())
|
||||||
default:
|
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.
|
// 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
|
// 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
|
// current password is required even though the caller already holds a
|
||||||
|
|||||||
@@ -452,3 +452,39 @@ func TestNonAdminCannotAdminister(t *testing.T) {
|
|||||||
t.Fatalf("anonymous list: %d", status)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.")
|
s.renderDone(w, "Sign-in failed", "The identity provider rejected the sign-in.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var webToken string
|
var webToken, returnURL string
|
||||||
if err := s.st.Update(func(st *store.State) error {
|
if err := s.st.Update(func(st *store.State) error {
|
||||||
var dev *store.DeviceAuth
|
var dev *store.DeviceAuth
|
||||||
for _, d := range st.Devices {
|
for _, d := range st.Devices {
|
||||||
@@ -257,6 +257,7 @@ func (s *Server) handleOIDCCallback(w http.ResponseWriter, r *http.Request) {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
dev.SessionToken = token
|
dev.SessionToken = token
|
||||||
|
returnURL = dev.ReturnURL
|
||||||
return nil
|
return nil
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
s.renderDone(w, "Sign-in failed", err.Error())
|
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)
|
http.Redirect(w, r, "/web/vaults", http.StatusFound)
|
||||||
return
|
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
|
// linkOIDCUser finds the account behind an identity, provisioning one when
|
||||||
|
|||||||
@@ -140,7 +140,8 @@ func TestOIDCApprovesAWaitingClient(t *testing.T) {
|
|||||||
idp := newFakeIdP(t, "subject-2", "dev@example.com", "devuser")
|
idp := newFakeIdP(t, "subject-2", "dev@example.com", "devuser")
|
||||||
h := newHarnessWith(t, idp.withOIDC(true, "everyone"))
|
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 {
|
if status != http.StatusOK {
|
||||||
t.Fatalf("device start: %d %v", status, start)
|
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") {
|
if status != http.StatusOK || !strings.Contains(page, "Device approved") {
|
||||||
t.Fatalf("device approval through single sign-on: %d\n%s", status, page)
|
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.
|
// A device approval must not also hand the browser a session cookie.
|
||||||
if b.cookie() != "" {
|
if b.cookie() != "" {
|
||||||
t.Fatal("approving a device set a web session cookie")
|
t.Fatal("approving a device set a web session cookie")
|
||||||
|
|||||||
@@ -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}
|
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;
|
input{width:100%;padding:.6rem .7rem;font:inherit;color:var(--fg);background:transparent;
|
||||||
border:1px solid var(--line);border-radius:8px}
|
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}
|
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}
|
.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)}
|
.alt{display:block;margin-top:1rem;text-align:center;font-size:.9rem;color:var(--accent)}
|
||||||
code{font-size:1.1rem;letter-spacing:.08em}
|
code{font-size:1.1rem;letter-spacing:.08em}
|
||||||
@@ -250,7 +251,8 @@ to ask for.</p>
|
|||||||
{{end}}`))
|
{{end}}`))
|
||||||
|
|
||||||
var doneTmpl = template.Must(template.Must(pages.Clone()).Parse(`
|
var doneTmpl = template.Must(template.Must(pages.Clone()).Parse(`
|
||||||
{{define "body"}}<h1>{{.Title}}</h1><p class="sub">{{.Message}}</p>{{end}}`))
|
{{define "body"}}<h1>{{.Title}}</h1><p class="sub">{{.Message}}</p>
|
||||||
|
{{if .Return}}<p><a class="button" href="{{.Return}}">Return to KeepOrg</a></p>{{end}}{{end}}`))
|
||||||
|
|
||||||
func (s *Server) renderDevice(w http.ResponseWriter, code, errMsg string) {
|
func (s *Server) renderDevice(w http.ResponseWriter, code, errMsg string) {
|
||||||
render(w, deviceTmpl, map[string]any{
|
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})
|
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) {
|
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("Content-Type", "text/html; charset=utf-8")
|
||||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||||
|
|||||||
+10
-6
@@ -161,12 +161,16 @@ type DeviceAuth struct {
|
|||||||
UserCode string `json:"user_code"`
|
UserCode string `json:"user_code"`
|
||||||
// Web marks a sign-in that ends in a cookie rather than in a token a
|
// Web marks a sign-in that ends in a cookie rather than in a token a
|
||||||
// waiting client polls for.
|
// waiting client polls for.
|
||||||
Web bool `json:"web,omitempty"`
|
Web bool `json:"web,omitempty"`
|
||||||
Label string `json:"label,omitempty"`
|
Label string `json:"label,omitempty"`
|
||||||
SessionToken string `json:"session_token,omitempty"`
|
SessionToken string `json:"session_token,omitempty"`
|
||||||
OIDCState string `json:"oidc_state,omitempty"`
|
OIDCState string `json:"oidc_state,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
// ReturnURL is where the approval page sends the person back to: the
|
||||||
ExpiresAt time.Time `json:"expires_at"`
|
// 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.
|
// State is the whole metadata set, serialized as one JSON document.
|
||||||
|
|||||||
@@ -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 <github@reactorcoremeltdown.anonaddy.com> Mon, 02 Nov 2026 13:11:01 +0100
|
||||||
|
|
||||||
keeporgd (0.8.0) unstable; urgency=medium
|
keeporgd (0.8.0) unstable; urgency=medium
|
||||||
|
|
||||||
* The public base URL can be set with the KEEPORG_EXTERNAL_URL
|
* The public base URL can be set with the KEEPORG_EXTERNAL_URL
|
||||||
|
|||||||
+3
-3
@@ -1,13 +1,13 @@
|
|||||||
# Single source of truth for all platform versions (semver).
|
# Single source of truth for all platform versions (semver).
|
||||||
# Bump with: make bump PLATFORM=<server|linux|macos|windows|bsd|android> PART=<major|minor|patch>
|
# Bump with: make bump PLATFORM=<server|linux|macos|windows|bsd|android> PART=<major|minor|patch>
|
||||||
VERSION_SERVER := 0.8.0
|
VERSION_SERVER := 0.9.0
|
||||||
VERSION_LINUX := 0.5.0
|
VERSION_LINUX := 0.5.0
|
||||||
VERSION_MACOS := 0.5.0
|
VERSION_MACOS := 0.5.0
|
||||||
VERSION_WINDOWS := 0.5.0
|
VERSION_WINDOWS := 0.5.0
|
||||||
VERSION_BSD := 0.5.0
|
VERSION_BSD := 0.5.0
|
||||||
VERSION_ANDROID := 0.2.0
|
VERSION_ANDROID := 0.3.0
|
||||||
# Monotonic Android versionCode, never decreases.
|
# Monotonic Android versionCode, never decreases.
|
||||||
ANDROID_VERSION_CODE := 2
|
ANDROID_VERSION_CODE := 3
|
||||||
|
|
||||||
# Reverse-DNS application identifiers.
|
# Reverse-DNS application identifiers.
|
||||||
APPID_ANDROID := cc.tiredsysadmin.android.keeporg
|
APPID_ANDROID := cc.tiredsysadmin.android.keeporg
|
||||||
|
|||||||
Reference in New Issue
Block a user