one-tap ios dav provisioning
mints a dav app password, renders a configuration profile carrying both the caldav and carddav payloads, and parks it behind a single-use five-minute token that safari can fetch without a session. one profile with both payloads is not a convenience: ios keys accounts by server+username, so adding carddav separately gets folded into the existing caldav account and contacts silently never appear. the profile holds the password in plaintext, so it is held in memory only — persisting it would falsify createDavAppPassword's "not stored" guarantee. signing is opt-in via DAV_PROFILE_SIGN_CERT/_KEY/_CHAIN and off by default; this box has no tls certificate, tls terminates upstream. signed at mint time reading the cert from disk, so a renewal needs no restart and no hook. the download route is registered before the /dav mount because hono matches in registration order and the sync door's /* would otherwise demand http basic. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { createRouter } from '../../create-router';
|
||||
import { listDavAppPasswords, createDavAppPassword, revokeDavAppPassword, deleteDavAppPassword } from 'officerdb';
|
||||
import * as errors from '../../custom-errors';
|
||||
import { stashIosProfile } from './ios-profile';
|
||||
|
||||
// Management of DAV app passwords, for Officer's own UI. Behind userMiddleware like everything else
|
||||
// under /api — this is the owner administering their devices from a logged-in browser, which is a
|
||||
@@ -28,6 +29,55 @@ davRouter.post('/passwords', async (ctx) => {
|
||||
return ctx.json({ entry, password, username: user.email });
|
||||
});
|
||||
|
||||
// One-tap iOS setup: mint a credential, wrap it in a configuration profile, hand back a URL for the app
|
||||
// to open in Safari. See docs/mobile-dav-provisioning.md §3, and ios-profile.ts for why the profile is
|
||||
// held in memory behind a one-shot token rather than stored.
|
||||
//
|
||||
// This is the only endpoint that returns a route to a live password instead of the password itself. It
|
||||
// exists because the alternative is the owner typing a 25-character secret into a phone keyboard twice.
|
||||
davRouter.post('/provision/ios', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const body = ctx.get('body') as { deviceLabel?: string } | undefined;
|
||||
const deviceLabel = body?.deviceLabel?.trim();
|
||||
if (!deviceLabel) throw errors.BAD_REQUEST('deviceLabel is required');
|
||||
|
||||
// The profile has to name a host, and PUBLIC_URL is the only place that knows the one a phone can
|
||||
// actually reach — the request's own Host header is whatever the reverse proxy forwarded.
|
||||
const publicUrl = process.env.PUBLIC_URL;
|
||||
if (!publicUrl) throw errors.INTERNAL_SERVER_ERROR('PUBLIC_URL is not set; cannot build a profile');
|
||||
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(publicUrl);
|
||||
} catch {
|
||||
throw errors.INTERNAL_SERVER_ERROR('PUBLIC_URL is not a valid URL');
|
||||
}
|
||||
if (url.protocol !== 'https:') {
|
||||
// CalDAVUseSSL is hardcoded true in the payload, so an http PUBLIC_URL would produce a profile that
|
||||
// cannot work. Better to say so than to ship a phone an account that silently never syncs.
|
||||
throw errors.INTERNAL_SERVER_ERROR('PUBLIC_URL must be https to provision a device');
|
||||
}
|
||||
|
||||
const { entry, password } = await createDavAppPassword(user.id, deviceLabel);
|
||||
|
||||
const stored = await stashIosProfile(
|
||||
{
|
||||
host: url.hostname,
|
||||
port: Number(url.port) || 443,
|
||||
username: user.email,
|
||||
password,
|
||||
// The principal, not a collection: iOS discovers every calendar and address book under it, so a
|
||||
// collection the owner adds later appears without re-provisioning.
|
||||
principalUrl: `/dav/${user.id}/`,
|
||||
accountName: 'Officer',
|
||||
},
|
||||
url.origin,
|
||||
);
|
||||
|
||||
console.log(`[dav] provisioned ios profile for "${deviceLabel}" (password #${entry.id}, signed=${stored.signed})`);
|
||||
return ctx.json(stored);
|
||||
});
|
||||
|
||||
davRouter.post('/passwords/:id/revoke', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const id = Number(ctx.req.param('id'));
|
||||
|
||||
Reference in New Issue
Block a user