The face from the provider, and whose business a password is
The password tab was hidden for accounts with no password, which is not the same as accounts that sign in through the provider — an account linked to one kept its password and therefore kept the tab. That is precisely the account the person setting this up is using. It goes by "signs in over there" now; the password it kept is the emergency door, set by an administrator. And the picture. The provider sends one, the server fetches it at each sign-in and stores it encrypted in a table beside the users rather than in them, and serves it back through the API — so the page never talks to the provider, no third-party cookie is needed for a face to appear, and it survives the provider being down like everything else here. Fetched once per account into an object URL, since an img tag cannot carry a bearer token. The suite's provider now serves a picture too, and the check asks the browser whether it actually decoded rather than whether the element exists. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
40bdf81aa2
commit
a2b3f91e00
12 files changed
+325
-22
No files matched your search
@@ -287,6 +287,10 @@ const publicUser = (row) => ({
|
||||
* second decides whether there is a password to offer changing. */
|
||||
linkedToProvider: !!row.oidc_sub,
|
||||
signsInWithProvider: isProviderOnly(row),
|
||||
/* Whether there is a picture to ask for. The bytes are fetched separately —
|
||||
* this only decides whether anything asks at all, so a row without one draws
|
||||
* its initial instead of firing a request that answers 404. */
|
||||
hasAvatar: !!hasAvatar.get(row.id),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -480,6 +484,20 @@ if (!tableColumns("notes").includes("icon_color")) {
|
||||
db.exec("ALTER TABLE notes ADD COLUMN icon_color TEXT");
|
||||
}
|
||||
|
||||
/* A profile picture, kept out of the users table on purpose: every query that
|
||||
* reads a user does `SELECT *`, and a blob in that row would be carried through
|
||||
* the admin list, every sign-in and every `/auth/me` for the sake of something
|
||||
* shown in one corner. Encrypted at rest like every other file here — it comes
|
||||
* from outside and is a picture of a person. */
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS user_avatars (
|
||||
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
bytes BLOB NOT NULL,
|
||||
content_type TEXT NOT NULL,
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
`);
|
||||
|
||||
/* Which identity at the provider this account is, once it has signed in that
|
||||
* way. Kept on the account rather than in a table of its own: a person has one
|
||||
* provider here, and the column is what makes the link survive a rename — the
|
||||
@@ -704,6 +722,7 @@ function authMiddleware(req, res, next) {
|
||||
}
|
||||
}
|
||||
const userExists = db.prepare("SELECT 1 FROM users WHERE id = ?").pluck();
|
||||
const hasAvatar = db.prepare("SELECT 1 FROM user_avatars WHERE user_id = ?").pluck();
|
||||
|
||||
/* ─────────── Express app ─────────── */
|
||||
const app = express();
|
||||
@@ -876,6 +895,7 @@ app.get("/api/auth/oidc/callback", async (req, res) => {
|
||||
try {
|
||||
const identity = await oidc.completeLogin({ code: req.query.code, state: req.query.state });
|
||||
const user = accountForIdentity(identity);
|
||||
await rememberPicture(user.id, identity.picture);
|
||||
const token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: "30d" });
|
||||
// The token itself never rides in the URL: a redirect lands in the history,
|
||||
// in the proxy's log, and in the next request's referrer.
|
||||
@@ -886,6 +906,60 @@ app.get("/api/auth/oidc/callback", async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Keeps the picture the provider has of somebody, or clears it when they take
|
||||
* theirs away.
|
||||
*
|
||||
* On every sign-in rather than only the first: a picture that changed over there
|
||||
* and never here is worse than none, and this is the one moment we are talking
|
||||
* to the provider anyway. Failure is silence — the account is already signed in
|
||||
* by the time this runs, and a portrait is not worth a failed sign-in.
|
||||
*/
|
||||
async function rememberPicture(userId, url) {
|
||||
try {
|
||||
if (!url) {
|
||||
db.prepare("DELETE FROM user_avatars WHERE user_id = ?").run(userId);
|
||||
return;
|
||||
}
|
||||
const picture = await oidc.fetchPicture(url);
|
||||
if (!picture) return; // unreachable or not an image: keep whatever is stored
|
||||
db.prepare(
|
||||
`INSERT INTO user_avatars (user_id, bytes, content_type, updated_at)
|
||||
VALUES (?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(user_id) DO UPDATE SET bytes = excluded.bytes,
|
||||
content_type = excluded.content_type,
|
||||
updated_at = excluded.updated_at`
|
||||
).run(userId, encryptBuffer(picture.bytes), picture.type);
|
||||
} catch (err) {
|
||||
console.warn("[oidc] could not store the profile picture:", err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The picture itself.
|
||||
*
|
||||
* Yourself always; anybody else only for an administrator, who already sees the
|
||||
* list of accounts. It is not a public avatar service — everything in this app
|
||||
* is behind the same door.
|
||||
*/
|
||||
app.get("/api/users/:id/avatar", authMiddleware, (req, res) => {
|
||||
const mine = req.params.id === req.userId;
|
||||
if (!mine && !db.prepare("SELECT is_admin FROM users WHERE id = ?").get(req.userId)?.is_admin) {
|
||||
return res.status(403).json({ error: "Not allowed" });
|
||||
}
|
||||
const row = db.prepare("SELECT bytes, content_type, updated_at FROM user_avatars WHERE user_id = ?").get(req.params.id);
|
||||
if (!row) return res.status(404).json({ error: "No picture" });
|
||||
try {
|
||||
res.set("Content-Type", row.content_type);
|
||||
// Private: it is fetched with a bearer token and belongs to one person.
|
||||
res.set("Cache-Control", "private, max-age=300");
|
||||
res.send(decryptBuffer(row.bytes));
|
||||
} catch (err) {
|
||||
console.error("[avatar] could not be read:", err.message);
|
||||
res.status(500).json({ error: "Could not read the picture" });
|
||||
}
|
||||
});
|
||||
|
||||
/* The ticket is swapped for the token by the app itself, once, within a minute. */
|
||||
app.post("/api/auth/oidc/claim", (req, res) => {
|
||||
const payload = oidc.redeemTicket(req.body?.ticket);
|
||||
|
||||
Reference in new issue
Block a user