Add device pairing and a device-only map for the Android app
The app gets a device token without handling passwords: the web UI's #pair page creates a one-time code that only the app holding the secret behind the challenge can exchange. The #device page shows the map of one device, with the token from a JavaScript bridge. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MREADME.md
@@ -99,4 +99,7 @@ Web endpoints use the session cookie. Devices use `Authorization: Bearer <token>
| `POST /api/guest/unlock` | link token + password | returns the key for a password-protected link. 5 failures lock the link for 15 minutes |
| `GET/POST /api/users`, `DELETE /api/users/{id}`, `PUT /api/users/{id}/role`, `POST /api/users/{id}/password` | admin | |
| `POST /api/devices/register` | username + password | returns a device token |
| `POST /api/devices/pair/begin` | session | `{challenge, name}` → one-time code for the app, valid 5 minutes |
| `POST /api/devices/pair` | code + verifier | the app exchanges the code and the secret behind the challenge for a device token. Each code works once. |
| `GET /api/device`, `GET /api/device/track?from=&to=` | device token | the app's own position and trail. The web UI's `#device` page uses them inside the app. |
| `POST /api/points` | device token or session | JSON array of points, at most 1000. Returns the visible people. Duplicates (same device and second) are ignored, so a client can retry a batch. A session uploads as the "Web" device. |
Mcrates/api/src/lib.rs
@@ -165,6 +165,27 @@ pub struct DeviceToken {
pub token: String,
}
/// Starts pairing an app. The web UI sends it for the signed-in user.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct PairBegin {
/// SHA-256 in hex of a secret that only the app knows.
pub challenge: String,
pub name: String,
}
/// A one-time code for the app, valid 5 minutes.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct PairCode {
pub code: String,
}
/// The app exchanges the code and its secret for a device token.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct PairFinish {
pub code: String,
pub verifier: String,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct Device {
pub id: i64,
Mcrates/server/src/auth.rs
@@ -244,7 +244,14 @@ impl FromRequestParts<AppState> for Uploader {
type Rejection = Error;
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Error> {
let Some(auth) = parts.headers.get(header::AUTHORIZATION) else {
if parts.headers.contains_key(header::AUTHORIZATION) {
let d = Device::from_request_parts(parts, state).await?;
return Ok(Uploader {
user_id: d.user_id,
device_id: d.id,
});
}
{
let user = User::from_request_parts(parts, state).await?;
let db = state.db();
db.execute(
@@ -256,14 +263,28 @@ impl FromRequestParts<AppState> for Uploader {
[user.id],
|r| r.get(0),
)?;
return Ok(Uploader {
Ok(Uploader {
user_id: user.id,
device_id,
});
};
let token = auth
.to_str()
.ok()
})
}
}
}
/// A device, from its `Authorization: Bearer` token.
pub struct Device {
pub id: i64,
pub user_id: i64,
}
impl FromRequestParts<AppState> for Device {
type Rejection = Error;
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Error> {
let token = parts
.headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.ok_or(Error::Unauthorized)?;
let (id, user_id) = state
@@ -275,10 +296,7 @@ impl FromRequestParts<AppState> for Uploader {
)
.optional()?
.ok_or(Error::Unauthorized)?;
Ok(Uploader {
user_id,
device_id: id,
})
Ok(Device { id, user_id })
}
}
Acrates/server/src/device.rs
@@ -0,0 +1,138 @@
//! What an app sees with its device token, and pairing an app with an account.
use std::collections::HashMap;
use std::sync::Mutex;
use api::{DeviceToken, PairBegin, PairCode, PairFinish, Person, Point, Trail};
use axum::Json;
use axum::extract::{Query, State};
use rusqlite::Connection;
use serde::Deserialize;
use sha2::{Digest, Sha256};
use crate::auth::{self, Device, User};
use crate::routes::{Access, check_device_name, insert_device, person_for, track_points};
use crate::{AppState, Error, now};
type Result<T> = std::result::Result<T, Error>;
fn own_access(db: &Connection, user_id: i64) -> Result<Access> {
let username = db.query_row("SELECT username FROM users WHERE id = ?1", [user_id], |r| {
r.get(0)
})?;
Ok(Access {
owner: user_id,
username,
share: None,
all_devices: true,
trail: Trail::All,
precision_m: 0,
})
}
/// The device's owner, with only this device.
pub async fn me(State(s): State<AppState>, d: Device) -> Result<Json<Person>> {
let db = s.db();
let mut person = person_for(&db, own_access(&db, d.user_id)?)?;
person.devices.retain(|x| x.id == d.id);
Ok(Json(person))
}
#[derive(Deserialize)]
pub struct Range {
from: i64,
to: i64,
}
pub async fn track(
State(s): State<AppState>,
d: Device,
Query(q): Query<Range>,
) -> Result<Json<Vec<Point>>> {
let db = s.db();
let a = own_access(&db, d.user_id)?;
Ok(Json(track_points(&db, &a, d.id, q.from, q.to)?))
}
const PAIR_SECS: i64 = 300;
struct Pairing {
user_id: i64,
challenge: String,
name: String,
expires_at: i64,
}
/// Codes from the web UI that an app can exchange for a device token, once.
#[derive(Default)]
pub struct Pairings(Mutex<HashMap<String, Pairing>>);
impl Pairings {
fn put(&self, user_id: i64, challenge: String, name: String) -> String {
let (code, _) = auth::new_secret();
let now = now();
let mut map = self.0.lock().unwrap();
map.retain(|_, p| p.expires_at > now);
let p = Pairing {
user_id,
challenge,
name,
expires_at: now + PAIR_SECS,
};
map.insert(code.clone(), p);
code
}
/// The code is gone after one attempt, so a wrong verifier cannot be retried.
fn take(&self, code: &str, verifier: &str) -> Option<(i64, String)> {
let p = self.0.lock().unwrap().remove(code)?;
let challenge = hex::encode(Sha256::digest(verifier.as_bytes()));
(p.expires_at > now() && challenge == p.challenge).then_some((p.user_id, p.name))
}
}
/// The app sends the SHA-256 of a secret it keeps. Only that app can then use the code.
pub async fn pair_begin(
State(s): State<AppState>,
user: User,
Json(b): Json<PairBegin>,
) -> Result<Json<PairCode>> {
let challenge = b.challenge.to_ascii_lowercase();
if challenge.len() != 64 || !challenge.bytes().all(|c| c.is_ascii_hexdigit()) {
return Err(Error::BadRequest(
"challenge must be a SHA-256 in hex".into(),
));
}
let name = check_device_name(&b.name)?.to_owned();
Ok(Json(PairCode {
code: s.pairings.put(user.id, challenge, name),
}))
}
pub async fn pair_finish(
State(s): State<AppState>,
Json(b): Json<PairFinish>,
) -> Result<Json<DeviceToken>> {
let (user_id, name) = s
.pairings
.take(&b.code, &b.verifier)
.ok_or(Error::NotFound)?;
Ok(Json(insert_device(&s.db(), user_id, &name)?))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_code_needs_its_verifier_and_works_once() {
let p = Pairings::default();
let challenge = hex::encode(Sha256::digest(b"secret"));
let code = p.put(7, challenge.clone(), "phone".into());
assert_eq!(p.take(&code, "secret"), Some((7, "phone".into())));
assert_eq!(p.take(&code, "secret"), None);
let code = p.put(7, challenge, "phone".into());
assert_eq!(p.take(&code, "guess"), None);
assert_eq!(p.take(&code, "secret"), None);
}
}
Mcrates/server/src/main.rs
@@ -1,6 +1,7 @@
//! opentracker server: one binary, one SQLite file.
mod auth;
mod device;
mod guest;
mod passkeys;
mod routes;
@@ -246,6 +247,7 @@ pub struct AppState {
db: Arc<Mutex<Connection>>,
limiter: Arc<auth::Limiter>,
ceremonies: Arc<passkeys::Ceremonies>,
pairings: Arc<device::Pairings>,
public_url: Option<Url>,
/// 0 means forever.
max_retention_days: i64,
@@ -405,6 +407,7 @@ async fn serve(cli: Cli, db: Connection) {
db: Arc::new(Mutex::new(db)),
limiter: Arc::default(),
ceremonies: Arc::default(),
pairings: Arc::default(),
public_url: cli.public_url,
max_retention_days: cli.retention_days.max(0),
};
Mcrates/server/src/routes.rs
@@ -16,9 +16,9 @@ use serde::Deserialize;
use tower_http::services::ServeDir;
use crate::auth::{self, Admin, User};
use crate::guest;
use crate::passkeys::{self, Pending};
use crate::{AppState, Error, now};
use crate::{device, guest};
type Result<T> = std::result::Result<T, Error>;
@@ -47,6 +47,10 @@ pub fn router(state: AppState, web_dir: &Path) -> Router {
.route("/api/people/{id}/track", get(track))
.route("/api/devices", get(list_devices).post(create_device))
.route("/api/devices/register", post(register_device))
.route("/api/devices/pair/begin", post(device::pair_begin))
.route("/api/devices/pair", post(device::pair_finish))
.route("/api/device", get(device::me))
.route("/api/device/track", get(device::track))
.route("/api/devices/{id}", delete(delete_device))
.route("/api/points", post(upload))
.route("/api/shares", get(list_shares).post(create_share))
@@ -473,13 +477,18 @@ async fn list_devices(State(s): State<AppState>, user: User) -> Result<Json<Vec<
Ok(Json(devices))
}
fn insert_device(db: &Connection, user_id: i64, name: &str) -> Result<DeviceToken> {
pub fn check_device_name(name: &str) -> Result<&str> {
let name = name.trim();
if name.is_empty() || name.chars().count() > 100 {
return Err(Error::BadRequest(
"device name must have 1 to 100 characters".into(),
));
}
Ok(name)
}
pub fn insert_device(db: &Connection, user_id: i64, name: &str) -> Result<DeviceToken> {
let name = check_device_name(name)?;
let (token, hash) = auth::new_secret();
db.execute(
"INSERT INTO devices (user_id, name, token_hash, created_at) VALUES (?1, ?2, ?3, ?4)",
Aweb/src/app.rs
@@ -0,0 +1,102 @@
//! Pages for the Android app: `#device` inside its WebView, `#pair` in the browser.
use api::{PairBegin, PairCode};
use leptos::prelude::*;
use leptos::task::spawn_local;
use wasm_bindgen::prelude::*;
use crate::{http, map};
#[wasm_bindgen(inline_js = r#"
export function appToken() {
return window.OtApp ? window.OtApp.token() : null;
}
"#)]
extern "C" {
#[wasm_bindgen(js_name = appToken)]
fn app_token() -> Option<String>;
}
/// The map of this device only. The app hands over its token through a JavaScript bridge.
#[component]
pub fn DevicePage() -> impl IntoView {
match app_token() {
Some(token) => {
view! { <main class="device"><map::MapPage source=map::Source::Device(token) /></main> }
.into_any()
}
None => view! { <p class="login">"This page is for the opentracker app."</p> }.into_any(),
}
}
/// A query parameter of `#pair?challenge=…&name=…`.
fn hash_param(key: &str) -> Option<String> {
let hash = window().location().hash().ok()?;
let query = hash.split_once('?')?.1;
query.split('&').find_map(|pair| {
let (k, v) = pair.split_once('=')?;
(k == key)
.then(|| {
js_sys::decode_uri_component(&v.replace('+', " "))
.ok()
.map(String::from)
})
.flatten()
})
}
/// Confirms a new device for the signed-in user, then hands a one-time code back to the app.
#[component]
pub fn PairPage() -> impl IntoView {
let challenge = hash_param("challenge");
let name = RwSignal::new(hash_param("name").unwrap_or_else(|| "Android".into()));
let error = RwSignal::new(None::<String>);
let done = RwSignal::new(None::<String>);
let busy = RwSignal::new(false);
let has_challenge = challenge.is_some();
let connect = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default();
let Some(challenge) = challenge.clone() else {
return;
};
busy.set(true);
let body = PairBegin {
challenge,
name: name.get_untracked(),
};
spawn_local(async move {
match http::post::<PairCode>("/api/devices/pair/begin", &body).await {
Ok(c) => {
let url = format!("opentracker://paired?code={}", c.code);
let _ = window().location().assign(&url);
done.set(Some(url));
}
Err(e) => error.set(Some(e.to_string())),
}
busy.set(false);
});
};
view! {
<form class="login" on:submit=connect>
<h1>"Connect the app"</h1>
{move || match (has_challenge, done.get()) {
(false, _) => view! { <p class="error">"This link is incomplete. Start again in the app."</p> }.into_any(),
// Browsers may block opening the app without a tap, so the link stays as a fallback.
(true, Some(url)) => view! {
<p>"Done. Return to the app to finish."</p>
<a class="button primary" href=url>"Open the app"</a>
}
.into_any(),
(true, None) => view! {
<p>"The opentracker app asks to upload positions to your account as a new device."</p>
<label>"Device name" <input required maxlength="100" bind:value=name /></label>
<button class="primary" disabled=busy>"Connect"</button>
}
.into_any(),
}}
<p class="error">{move || error.get()}</p>
</form>
}
}
Mweb/src/guest.rs
@@ -95,7 +95,7 @@ pub fn GuestPage(token: String) -> impl IntoView {
}
.into_any()
}
State::Open(_) => view! { <main><map::MapPage guest=auth() /></main> }.into_any(),
State::Open(_) => view! { <main><map::MapPage source=map::Source::Guest(auth()) /></main> }.into_any(),
State::Gone => {
view! { <p class="login">"This link is no longer valid. It may have expired or been deleted."</p> }
.into_any()
Mweb/src/http.rs
@@ -20,6 +20,15 @@ pub async fn get<T: DeserializeOwned>(path: &str) -> Result<T, Error> {
read(path, Request::get(path).send().await).await
}
/// For the app's device mode. A 401 there means the device was removed, not that a session ended.
pub async fn get_bearer<T: DeserializeOwned>(path: &str, token: &str) -> Result<T, Error> {
let res = Request::get(path)
.header("Authorization", &format!("Bearer {token}"))
.send()
.await;
read("", res).await
}
pub async fn post<T: DeserializeOwned>(path: &str, body: &impl Serialize) -> Result<T, Error> {
let req = Request::post(path).json(body).map_err(|e| Error {
status: 0,
@@ -50,7 +59,7 @@ async fn read<T: DeserializeOwned>(
})?;
// The session has ended. A reload shows the login form.
if res.status() == 401
&& !matches!(path, "/api/login" | "/api/me")
&& !matches!(path, "" | "/api/login" | "/api/me")
&& !path.starts_with("/api/guest")
{
let _ = web_sys::window().unwrap().location().reload();
Mweb/src/main.rs
@@ -1,3 +1,4 @@
mod app;
mod guest;
mod http;
mod locate;
@@ -62,14 +63,18 @@ enum Page {
#[component]
fn App() -> impl IntoView {
let hash = window().location().hash().unwrap_or_default();
match hash.strip_prefix("#l=") {
Some(token) => view! { <guest::GuestPage token=token.to_owned() /> }.into_any(),
None => view! { <AccountApp /> }.into_any(),
if let Some(token) = hash.strip_prefix("#l=") {
return view! { <guest::GuestPage token=token.to_owned() /> }.into_any();
}
if hash == "#device" {
return view! { <app::DevicePage /> }.into_any();
}
view! { <AccountApp pair=hash.starts_with("#pair") /> }.into_any()
}
/// `pair` shows the app pairing page instead of the main UI after sign-in.
#[component]
fn AccountApp() -> impl IntoView {
fn AccountApp(pair: bool) -> impl IntoView {
let account = Account {
session: RwSignal::new(Session::Loading),
};
@@ -86,6 +91,7 @@ fn AccountApp() -> impl IntoView {
Page::Loading => ().into_any(),
Page::Setup => view! { <SetupForm /> }.into_any(),
Page::Login => view! { <LoginForm /> }.into_any(),
Page::Main if pair => view! { <app::PairPage /> }.into_any(),
Page::Main => view! { <Shell /> }.into_any(),
}
}
Mweb/src/map.rs
@@ -298,11 +298,21 @@ fn trail_range(span: &str, day: &str, from: &str, to: &str) -> Result<(i64, i64)
}
}
/// Shows the map to a guest link instead of the signed-in user.
/// Whose view the map shows.
#[derive(Clone)]
pub enum Source {
Account,
Guest(GuestAuth),
/// The app, with its device token. It sees only this device.
Device(String),
}
#[component]
pub fn MapPage(#[prop(optional)] guest: Option<GuestAuth>) -> impl IntoView {
let is_guest = guest.is_some();
let guest = StoredValue::new(guest);
pub fn MapPage(#[prop(default = Source::Account)] source: Source) -> impl IntoView {
let is_account = matches!(source, Source::Account);
let is_guest = matches!(source, Source::Guest(_));
let is_device = matches!(source, Source::Device(_));
let source = StoredValue::new(source);
let expires = RwSignal::new(None::<i64>);
let people = RwSignal::new(Vec::<Person>::new());
let selected = RwSignal::new(None::<i64>);
@@ -339,17 +349,20 @@ pub fn MapPage(#[prop(optional)] guest: Option<GuestAuth>) -> impl IntoView {
return;
}
spawn_local(async move {
let result = match guest.get_value() {
None => http::get::<Vec<Person>>("/api/people").await,
Some(g) => http::post::<GuestView>("/api/guest", &g).await.map(|v| {
let result = match source.get_value() {
Source::Account => http::get::<Vec<Person>>("/api/people").await,
Source::Guest(g) => http::post::<GuestView>("/api/guest", &g).await.map(|v| {
expires.set(v.expires_at);
vec![v.person]
}),
Source::Device(t) => http::get_bearer::<Person>("/api/device", &t)
.await
.map(|p| vec![p]),
};
match result {
Ok(p) => {
// A guest sees one person, so their details open at once.
if is_guest && selected.get_untracked().is_none() {
// Guests and the app see one person, so their details open at once.
if !is_account && selected.get_untracked().is_none() {
selected.set(p.first().map(|p| p.id));
}
people.set(p);
@@ -358,6 +371,9 @@ pub fn MapPage(#[prop(optional)] guest: Option<GuestAuth>) -> impl IntoView {
Err(e) if e.status == 404 && is_guest => {
error.set(Some("This link is no longer valid.".into()))
}
Err(e) if e.status == 401 && is_device => error.set(Some(
"This device was removed. Connect the app again.".into(),
)),
Err(e) => error.set(Some(e.to_string())),
}
})
@@ -430,14 +446,21 @@ pub fn MapPage(#[prop(optional)] guest: Option<GuestAuth>) -> impl IntoView {
}
let id = p.id;
spawn_local(async move {
let result = match guest.get_value() {
None => {
let result = match source.get_value() {
Source::Account => {
http::get::<Vec<Point>>(&format!(
"/api/people/{id}/track?from={from}&to={to}&device={dev}"
))
.await
}
Some(auth) => {
Source::Device(t) => {
http::get_bearer::<Vec<Point>>(
&format!("/api/device/track?from={from}&to={to}"),
&t,
)
.await
}
Source::Guest(auth) => {
let body = GuestTrack {
auth,
device: dev,
@@ -531,7 +554,7 @@ pub fn MapPage(#[prop(optional)] guest: Option<GuestAuth>) -> impl IntoView {
}}
</ul>
{move || {
let p = person().filter(|p| p.trail != Trail::None && !p.devices.is_empty())?;
let p = person().filter(|p| !is_device && p.trail != Trail::None && !p.devices.is_empty())?;
let current = device();
Some(view! {
<label>
@@ -577,7 +600,7 @@ pub fn MapPage(#[prop(optional)] guest: Option<GuestAuth>) -> impl IntoView {
}}
</p>
{move || expires.get().map(|t| view! { <p class="hint">"This link works until " {fmt_time(t)} "."</p> })}
{(!is_guest).then(|| view! { <crate::locate::LocationControls /> })}
{is_account.then(|| view! { <crate::locate::LocationControls /> })}
</aside>
<div class="map" node_ref=el></div>
</div>
Mweb/style.css
@@ -33,7 +33,8 @@ button {
cursor: pointer;
}
button:hover { background: var(--hover); }
button.primary { background: #2563eb; border-color: #2563eb; color: white; }
a.button { display: block; padding: 0.35em 0.9em; border-radius: 6px; text-align: center; text-decoration: none; }
button.primary, a.button.primary { background: #2563eb; border-color: #2563eb; color: white; }
button:disabled { opacity: 0.5; cursor: default; }
input, select { padding: 0.35em 0.6em; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg); }
.error { color: var(--error); }