calendar and contacts, extracted from the officer platform
one sidecar, one permission, one install — and two screens, because Calendar and Contacts are two places you go rather than two sections of one page. the first plugin to declare `screens`, which the platform gained for this. what moved: the radicale sidecar, the four routers, the panel apps, the app-password table and the settings form. the form became a modal on the calendar screen — a plugin ships panels and cannot contribute a settings section, which is the same wall the email plugin hit and the same answer. the routes are /calendar and /contacts, not /caldav/calendar. they were core routes and keeping them is not cosmetic: a bookmark, a dock tile someone arranged, and a persisted layout all address them. only a first-party plugin may choose its own segment; a third-party one is confined to /p/<publisher>/. four paths outside /api come with it, above the account gate, because phones are not browsers: DAVx5, iOS and thunderbird send HTTP basic on every request and have nowhere to put a JWT. that is not "open" — the sync door demands a scoped app password and resolves it to one user before radicale sees anything. two things deliberately unchanged. the table is still dav_app_passwords, not caldav_app_passwords: installs hold real rows and a naming convention is not worth a data migration on a live machine. and the panel names still resolve through the platform's catalogs, so the six locales they had as core screens survive rather than dropping to english to make the refactor tidier. per-user was already true and is worth writing down because the config said otherwise: the sync door resolves an app password to an account, tells radicale that id is the principal, and owner_only does the rest. the comment claiming "the owner is the only principal that exists" had been stale since multi-user landed on 2026-08-07. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
listDavAppPasswords,
|
||||
createDavAppPassword,
|
||||
revokeDavAppPassword,
|
||||
deleteDavAppPassword,
|
||||
verifyDavAppPassword,
|
||||
} from './queries';
|
||||
|
||||
export type { DavAppPassword, DavAppPasswordView } from './queries';
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
import { and, desc, eq, isNull } from 'drizzle-orm';
|
||||
import argon2 from 'argon2';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { db } from 'officerdb';
|
||||
import { davAppPasswords } from './schema';
|
||||
|
||||
export type DavAppPassword = typeof davAppPasswords.$inferSelect;
|
||||
|
||||
/** What the UI is allowed to see: everything except the hash. */
|
||||
export type DavAppPasswordView = Omit<DavAppPassword, 'passwordHash'>;
|
||||
|
||||
const view = (row: DavAppPassword): DavAppPasswordView => {
|
||||
const { passwordHash: _hash, ...rest } = row;
|
||||
return rest;
|
||||
};
|
||||
|
||||
// Base32-ish over an unambiguous alphabet: no 0/O/1/I/l, because this gets read off a screen and typed
|
||||
// into a phone by hand. Grouped into blocks of four for the same reason.
|
||||
const ALPHABET = 'abcdefghjkmnpqrstuvwxyz23456789';
|
||||
|
||||
function generateSecret(): string {
|
||||
const bytes = randomBytes(20);
|
||||
// 20 bytes over a 31-char alphabet ≈ 99 bits. Well past anything Basic auth over TLS needs, and it
|
||||
// still fits in five readable blocks.
|
||||
const chars = Array.from(bytes, (b) => ALPHABET[b % ALPHABET.length]).join('');
|
||||
return (chars.match(/.{1,4}/g) ?? [chars]).join('-');
|
||||
}
|
||||
|
||||
export async function listDavAppPasswords(userId: number): Promise<DavAppPasswordView[]> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(davAppPasswords)
|
||||
.where(eq(davAppPasswords.userId, userId))
|
||||
.orderBy(desc(davAppPasswords.createdAt));
|
||||
return rows.map(view);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint a credential. The plaintext is returned HERE AND NOWHERE ELSE — it is not stored, so this return
|
||||
* value is the only chance the owner gets to see it.
|
||||
*/
|
||||
export async function createDavAppPassword(
|
||||
userId: number,
|
||||
label: string,
|
||||
): Promise<{ entry: DavAppPasswordView; password: string }> {
|
||||
const password = generateSecret();
|
||||
const [row] = await db
|
||||
.insert(davAppPasswords)
|
||||
.values({
|
||||
userId,
|
||||
label,
|
||||
passwordHash: await argon2.hash(password),
|
||||
hint: password.slice(0, 8),
|
||||
})
|
||||
.returning();
|
||||
if (!row) throw new Error('failed to create dav app password');
|
||||
return { entry: view(row), password };
|
||||
}
|
||||
|
||||
export async function revokeDavAppPassword(userId: number, id: number): Promise<boolean> {
|
||||
const [row] = await db
|
||||
.update(davAppPasswords)
|
||||
.set({ revokedAt: new Date() })
|
||||
.where(and(eq(davAppPasswords.id, id), eq(davAppPasswords.userId, userId), isNull(davAppPasswords.revokedAt)))
|
||||
.returning();
|
||||
return !!row;
|
||||
}
|
||||
|
||||
export async function deleteDavAppPassword(userId: number, id: number): Promise<boolean> {
|
||||
const [row] = await db
|
||||
.delete(davAppPasswords)
|
||||
.where(and(eq(davAppPasswords.id, id), eq(davAppPasswords.userId, userId)))
|
||||
.returning();
|
||||
return !!row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a Basic-auth credential, returning the owning user id.
|
||||
*
|
||||
* Every live credential is tried, because the username a DAV client sends is the account email, not a
|
||||
* row id — there is nothing in the request that says WHICH device is calling. That means the cost is
|
||||
* one argon2 verify per stored credential, which is why revoked rows are filtered in SQL and why the
|
||||
* UI should encourage deleting devices that are gone rather than accumulating them.
|
||||
*
|
||||
* `lastUsedAt` is written on success, at most once a minute: a syncing phone hits this constantly and
|
||||
* the column exists to answer "is this device still around", not to be an access log.
|
||||
*/
|
||||
export async function verifyDavAppPassword(userId: number, password: string): Promise<number | null> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(davAppPasswords)
|
||||
.where(and(eq(davAppPasswords.userId, userId), isNull(davAppPasswords.revokedAt)));
|
||||
|
||||
for (const row of rows) {
|
||||
let ok = false;
|
||||
try {
|
||||
ok = await argon2.verify(row.passwordHash, password);
|
||||
} catch {
|
||||
ok = false; // a corrupt hash must not take the whole login path down
|
||||
}
|
||||
if (!ok) continue;
|
||||
|
||||
const now = Date.now();
|
||||
if (!row.lastUsedAt || now - row.lastUsedAt.getTime() > 60_000) {
|
||||
await db
|
||||
.update(davAppPasswords)
|
||||
.set({ lastUsedAt: new Date(now) })
|
||||
.where(eq(davAppPasswords.id, row.id));
|
||||
}
|
||||
return row.userId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { pgTable, serial, integer, text, timestamp, index } from 'drizzle-orm/pg-core';
|
||||
import { users } from 'officerdb/auth/schema';
|
||||
|
||||
// Per-device credentials for CalDAV / CardDAV clients — DAVx5, iOS, macOS, Thunderbird.
|
||||
//
|
||||
// A new credential type is unavoidable and NextCloud solved it the same way for the same reason: a DAV
|
||||
// client cannot log into Officer. It speaks HTTP Basic on every request and has nowhere to put a 30-day
|
||||
// JWT, no way to refresh one, and no way to answer a passkey challenge. Without this table the only
|
||||
// credential a phone could carry is the account password itself — which then lives in that phone's
|
||||
// account manager in recoverable form, and in whatever backs the phone up.
|
||||
//
|
||||
// So: one password per device, revocable per device, hashed with argon2 like the account password and
|
||||
// SHOWN EXACTLY ONCE at creation. Nothing reads the plaintext back, because nothing stores it.
|
||||
//
|
||||
// SCOPE IS LOAD-BEARING. These are accepted ONLY by the /dav mount. `userMiddleware` must never look at
|
||||
// this table — an app password is not a session, and a device that syncs a calendar has no business
|
||||
// reaching the wallet or the vault.
|
||||
//
|
||||
// `revokedAt` rather than a delete, so a revoked credential stays visible in the UI ("this phone had
|
||||
// access until Tuesday") instead of silently vanishing. `lastUsedAt` is what makes a stale device
|
||||
// noticeable at all.
|
||||
export const davAppPasswords = pgTable(
|
||||
'dav_app_passwords',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
userId: integer('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
/** What the owner calls the device — "Pixel 9", "iPad". The only way to tell two rows apart in the UI. */
|
||||
label: text('label').notNull(),
|
||||
/** argon2 hash. Deliberately not unique: two devices could in principle collide and it would not matter. */
|
||||
passwordHash: text('password_hash').notNull(),
|
||||
/**
|
||||
* First 8 chars of the generated secret, stored in the clear on purpose. It is not enough to
|
||||
* authenticate with, and it is the only way for the owner to match a row in this list against the
|
||||
* password saved on a device they are looking at.
|
||||
*/
|
||||
hint: text('hint').notNull(),
|
||||
lastUsedAt: timestamp('last_used_at', { withTimezone: true }),
|
||||
revokedAt: timestamp('revoked_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
// Every DAV request authenticates, and a phone polls often. This index is the difference between that
|
||||
// being free and it being a sequential scan on every PROPFIND.
|
||||
(t) => [index('idx_dav_app_passwords_user').on(t.userId)],
|
||||
);
|
||||
Reference in New Issue
Block a user