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>
96 lines
4.3 KiB
TypeScript
96 lines
4.3 KiB
TypeScript
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
|
|
// completely different act from a phone syncing (that is /dav, see sync-router.ts).
|
|
//
|
|
// The plaintext credential exists for exactly one response and is never stored, so POST is the only
|
|
// place it appears. There is deliberately no "show me it again" endpoint: if it is lost, revoke the
|
|
// row and mint another. That is cheaper than any design where the secret can be read back.
|
|
|
|
export const davRouter = createRouter();
|
|
|
|
davRouter.get('/passwords', async (ctx) => {
|
|
const user = ctx.get('user');
|
|
return ctx.json({ passwords: await listDavAppPasswords(user.id) });
|
|
});
|
|
|
|
davRouter.post('/passwords', async (ctx) => {
|
|
const user = ctx.get('user');
|
|
const body = ctx.get('body') as { label?: string } | undefined;
|
|
const label = body?.label?.trim();
|
|
if (!label) throw errors.BAD_REQUEST('label is required');
|
|
|
|
const { entry, password } = await createDavAppPassword(user.id, label);
|
|
// `password` is returned once, here. Nothing else in the system can produce it again.
|
|
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'));
|
|
if (!Number.isInteger(id)) throw errors.BAD_REQUEST('invalid id');
|
|
if (!(await revokeDavAppPassword(user.id, id))) throw errors.NOT_FOUND('no such app password');
|
|
return ctx.json({ ok: true });
|
|
});
|
|
|
|
davRouter.delete('/passwords/:id', async (ctx) => {
|
|
const user = ctx.get('user');
|
|
const id = Number(ctx.req.param('id'));
|
|
if (!Number.isInteger(id)) throw errors.BAD_REQUEST('invalid id');
|
|
if (!(await deleteDavAppPassword(user.id, id))) throw errors.NOT_FOUND('no such app password');
|
|
return ctx.json({ ok: true });
|
|
});
|