v4: federated sign-in closes the holes a review found — a callback link made from someone else's sign-in signed the visitor in to that account, a password reset left the user's sessions on other instances open, and events could be lost, reordered or delayed. FederatedSignIn keeps the sign-in's state in sessionStorage (utils/auth/federation.ts) and FederationCallback finishes only a sign-in this tab started. A password reset tells the instances the user signed in to, as a change does. The outbox delivers each instance's events in the order they were made, the first that fails holding back the rest, so a late older update no longer undoes a newer one, and delivers to every instance at once, so a dead one delays none of the others; an event queued during a run is sent by it.
Receiving, a home's keys that can't be fetched answer 502, which the sender retries, instead of 401, which it dropped; a bad update answers 400 rather than 502; a blocked instance's events answer 403; events are limited to a burst of 120 per instance, and a token naming an unknown key refetches the keys at most once a minute. An avatar must be on its issuer's origin, and its URL is versioned by its file's write time instead of updatedAt, so a profile edit no longer makes clients download it again. An instance that doesn't answer can be blocked, keeping its name, though not trusted. 303 backend tests and 703 frontend tests pass, the federation tests 8 times in a row, and deno check, tsc, eslint, doc:lint and i18n:compile --strict are clean. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
3
API.md
3
API.md
@@ -221,7 +221,8 @@ key.
|
|||||||
an invite.
|
an invite.
|
||||||
- `POST /api/federation/sessions` — anyone. Finishes it, as a sign-in.
|
- `POST /api/federation/sessions` — anyone. Finishes it, as a sign-in.
|
||||||
- `POST /api/federation/events` — another instance. Its signed news of one of
|
- `POST /api/federation/events` — another instance. Its signed news of one of
|
||||||
its users here: a new name, email or avatar, or the end of their sessions.
|
its users here: a new name, email or avatar, or the end of their sessions. A
|
||||||
|
4xx tells it to give up on the event, a 429 or a 5xx to try again.
|
||||||
- `GET /api/federation/peers` — anyone the trusted instances,
|
- `GET /api/federation/peers` — anyone the trusted instances,
|
||||||
`federation:manage` every listed one.
|
`federation:manage` every listed one.
|
||||||
- `PUT /api/federation/peers`, `DELETE /api/federation/peers/:host` —
|
- `PUT /api/federation/peers`, `DELETE /api/federation/peers/:host` —
|
||||||
|
|||||||
@@ -38,10 +38,12 @@ const USER_AGENT = `gerbeur (${OG_SITE_NAME}; federation)`;
|
|||||||
/**
|
/**
|
||||||
* Keeps each instance's metadata and keys for 10 minutes, so that a sign-in
|
* Keeps each instance's metadata and keys for 10 minutes, so that a sign-in
|
||||||
* or an event doesn't fetch them each time. A key set is refetched sooner
|
* or an event doesn't fetch them each time. A key set is refetched sooner
|
||||||
* when a token names a key it lacks ({@link fetchKeys} with `fresh`).
|
* when a token names a key it lacks ({@link fetchKeys} with `fresh`), at most
|
||||||
|
* once a minute: anyone can send a token naming a key that doesn't exist.
|
||||||
*/
|
*/
|
||||||
const metadataCache = new TtlCache<InstanceMetadata>(10 * 60_000, 500);
|
const metadataCache = new TtlCache<InstanceMetadata>(10 * 60_000, 500);
|
||||||
const keysCache = new TtlCache<JSONWebKeySet>(10 * 60_000, 500);
|
const keysCache = new TtlCache<JSONWebKeySet>(10 * 60_000, 500);
|
||||||
|
const keysRefetched = new TtlCache<true>(60_000, 500);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks that `url` may be reached: https on a public address, or anything
|
* Checks that `url` may be reached: https on a public address, or anything
|
||||||
@@ -119,7 +121,7 @@ export async function fetchMetadata(origin: string): Promise<InstanceMetadata> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the public keys of the instance at `origin`, cached unless `fresh`
|
* Returns the public keys of the instance at `origin`, cached unless `fresh`
|
||||||
* asks to refetch them.
|
* asks to refetch them and they weren't in the last minute.
|
||||||
*
|
*
|
||||||
* @throws {Error} When they can't be fetched.
|
* @throws {Error} When they can't be fetched.
|
||||||
*/
|
*/
|
||||||
@@ -127,8 +129,10 @@ export async function fetchKeys(
|
|||||||
origin: string,
|
origin: string,
|
||||||
fresh = false,
|
fresh = false,
|
||||||
): Promise<JSONWebKeySet> {
|
): Promise<JSONWebKeySet> {
|
||||||
const hit = fresh ? undefined : keysCache.get(origin);
|
const refetch = fresh && !keysRefetched.get(origin);
|
||||||
|
const hit = refetch ? undefined : keysCache.get(origin);
|
||||||
if (hit) return hit;
|
if (hit) return hit;
|
||||||
|
if (refetch) keysRefetched.set(origin, true);
|
||||||
const { jwks_uri } = await fetchMetadata(origin);
|
const { jwks_uri } = await fetchMetadata(origin);
|
||||||
const body = await readJson(await request(jwks_uri), `${origin}'s keys`);
|
const body = await readJson(await request(jwks_uri), `${origin}'s keys`);
|
||||||
if (
|
if (
|
||||||
@@ -221,4 +225,5 @@ export async function fetchAvatar(url: string): Promise<Uint8Array> {
|
|||||||
export function clearInstanceCaches(): void {
|
export function clearInstanceCaches(): void {
|
||||||
metadataCache.clear();
|
metadataCache.clear();
|
||||||
keysCache.clear();
|
keysCache.clear();
|
||||||
|
keysRefetched.clear();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,14 +11,17 @@
|
|||||||
* instance.
|
* instance.
|
||||||
* - `POST /api/federation/sessions` — public. Finishes it
|
* - `POST /api/federation/sessions` — public. Finishes it
|
||||||
* (`FederationSessionRequest`) and answers the user and their tokens, as a
|
* (`FederationSessionRequest`) and answers the user and their tokens, as a
|
||||||
* sign-in does. Rate-limited per instance.
|
* sign-in does. Each sign-in started finishes once, so the limit on
|
||||||
|
* `/logins` bounds it.
|
||||||
* - `POST /api/federation/events` — public, server to server. Applies a
|
* - `POST /api/federation/events` — public, server to server. Applies a
|
||||||
* home instance's signed event about one of its users (`{ event }`).
|
* home instance's signed event about one of its users (`{ event }`).
|
||||||
|
* Rate-limited per instance; a 4xx tells the sender to give up on the
|
||||||
|
* event, a 429 or a 5xx to try again.
|
||||||
* - `GET /api/federation/peers` — optional sign-in. Answers the trusted
|
* - `GET /api/federation/peers` — optional sign-in. Answers the trusted
|
||||||
* instances, or every listed one to `federation:manage`.
|
* instances, or every listed one to `federation:manage`.
|
||||||
* - `PUT /api/federation/peers` — permission `federation:manage`. Trusts or
|
* - `PUT /api/federation/peers` — permission `federation:manage`. Trusts or
|
||||||
* blocks an instance (`SetPeerRequest`) and answers it; 502 when it
|
* blocks an instance (`SetPeerRequest`) and answers it; 502 when one to
|
||||||
* doesn't answer as a gerbeur.
|
* trust doesn't answer as a gerbeur.
|
||||||
* - `DELETE /api/federation/peers/:host` — permission `federation:manage`.
|
* - `DELETE /api/federation/peers/:host` — permission `federation:manage`.
|
||||||
* Takes an instance off the lists.
|
* Takes an instance off the lists.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
* - `POST /api/password-resets` — public. Emails a reset link to the account
|
* - `POST /api/password-resets` — public. Emails a reset link to the account
|
||||||
* with that `email`, if any; always answers 200.
|
* with that `email`, if any; always answers 200.
|
||||||
* - `PUT /api/password-resets/:token` — public, with a reset token. Sets the
|
* - `PUT /api/password-resets/:token` — public, with a reset token. Sets the
|
||||||
* `newPassword`.
|
* `newPassword`, which ends the user's sessions here and on the instances
|
||||||
|
* they signed in to.
|
||||||
*
|
*
|
||||||
* @module
|
* @module
|
||||||
*/
|
*/
|
||||||
@@ -20,6 +21,7 @@ import {
|
|||||||
requestPasswordReset,
|
requestPasswordReset,
|
||||||
resetPassword,
|
resetPassword,
|
||||||
} from "../services/password-reset-service.ts";
|
} from "../services/password-reset-service.ts";
|
||||||
|
import { notifyClients } from "../services/identity-provider-service.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware.
|
* Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware.
|
||||||
@@ -48,7 +50,8 @@ router.put("/:token", async (ctx) => {
|
|||||||
if (typeof newPassword !== "string") {
|
if (typeof newPassword !== "string") {
|
||||||
throw new APIException(400, "Invalid request");
|
throw new APIException(400, "Invalid request");
|
||||||
}
|
}
|
||||||
await resetPassword(ctx.params.token, newPassword);
|
const userId = await resetPassword(ctx.params.token, newPassword);
|
||||||
|
await notifyClients(userId, { type: "revoke" });
|
||||||
const responseBody: APIResponse<null> = { success: true, data: null };
|
const responseBody: APIResponse<null> = { success: true, data: null };
|
||||||
ctx.response.body = responseBody;
|
ctx.response.body = responseBody;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -65,12 +65,14 @@ export function listPeers(trustedOnly: boolean): FederationPeer[] {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Puts the instance `instance` names on the list of `status`, for `/manage`,
|
* Puts the instance `instance` names on the list of `status`, for `/manage`,
|
||||||
* and returns it. It must answer as a gerbeur instance, whose name is kept.
|
* and returns it. A trusted one must answer as a gerbeur instance, whose name
|
||||||
* Blocking one ends the sessions of its users here at once: their tokens stop
|
* is kept; a blocked one is blocked whether it answers or not, keeping the
|
||||||
* counting by `getSessionState`, and this closes their sockets.
|
* name it had. Blocking one ends the sessions of its users here at once:
|
||||||
|
* their tokens stop counting by `getSessionState`, and this closes their
|
||||||
|
* sockets.
|
||||||
*
|
*
|
||||||
* @throws {APIException} 400 when `instance` names no instance, or this one,
|
* @throws {APIException} 400 when `instance` names no instance, or this one,
|
||||||
* 502 when it doesn't answer as a gerbeur instance.
|
* 502 when an instance to trust doesn't answer as a gerbeur instance.
|
||||||
*/
|
*/
|
||||||
export async function setPeer(
|
export async function setPeer(
|
||||||
instance: string,
|
instance: string,
|
||||||
@@ -81,17 +83,20 @@ export async function setPeer(
|
|||||||
if (issuer === PUBLIC_URL) {
|
if (issuer === PUBLIC_URL) {
|
||||||
throw new APIException(400, "That is this instance");
|
throw new APIException(400, "That is this instance");
|
||||||
}
|
}
|
||||||
let name: string;
|
let name: string | null = null;
|
||||||
try {
|
try {
|
||||||
name = (await fetchMetadata(issuer)).gerbeur_site_name;
|
name = (await fetchMetadata(issuer)).gerbeur_site_name;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[federation] ${issuer} can't be added:`, err);
|
console.error(`[federation] ${issuer} doesn't answer:`, err);
|
||||||
|
if (status === "trusted") {
|
||||||
throw new APIException(502, "That address doesn't answer as a gerbeur");
|
throw new APIException(502, "That address doesn't answer as a gerbeur");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`INSERT INTO federation_peers (issuer, status, name, created_at)
|
`INSERT INTO federation_peers (issuer, status, name, created_at)
|
||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?)
|
||||||
ON CONFLICT (issuer) DO UPDATE SET status = excluded.status, name = excluded.name;`,
|
ON CONFLICT (issuer) DO UPDATE SET status = excluded.status,
|
||||||
|
name = coalesce(excluded.name, name);`,
|
||||||
).run(issuer, status, name, new Date().toISOString());
|
).run(issuer, status, name, new Date().toISOString());
|
||||||
|
|
||||||
if (status === "blocked") {
|
if (status === "blocked") {
|
||||||
|
|||||||
@@ -22,11 +22,12 @@
|
|||||||
* @module
|
* @module
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { decodeJwt } from "@panva/jose";
|
import { decodeJwt, type JSONWebKeySet } from "@panva/jose";
|
||||||
import { createHash, randomBytes } from "node:crypto";
|
import { createHash, randomBytes } from "node:crypto";
|
||||||
import { APIException } from "../lib/api-exception.ts";
|
import { APIException } from "../lib/api-exception.ts";
|
||||||
import { db, transaction } from "../db/connection.ts";
|
import { db, transaction } from "../db/connection.ts";
|
||||||
import { verifyInstanceToken } from "../lib/jwt.ts";
|
import { verifyInstanceToken } from "../lib/jwt.ts";
|
||||||
|
import { type RateLimit, takeToken } from "../lib/rate-limit.ts";
|
||||||
import {
|
import {
|
||||||
type FederationLoginRequest,
|
type FederationLoginRequest,
|
||||||
type FederationSessionRequest,
|
type FederationSessionRequest,
|
||||||
@@ -65,6 +66,13 @@ import { broadcastUserUpdated, updateClientUsername } from "./ws-service.ts";
|
|||||||
/** How long a sign-in may take, from the login page to the callback. */
|
/** How long a sign-in may take, from the login page to the callback. */
|
||||||
const LOGIN_TTL_MS = 10 * 60_000;
|
const LOGIN_TTL_MS = 10 * 60_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allows an instance a burst of 120 events, as when many of its users change
|
||||||
|
* at once, then one a second. Each may fetch its keys, so a stranger posting
|
||||||
|
* events in an instance's name can't make this one hammer it.
|
||||||
|
*/
|
||||||
|
const EVENT_LIMIT: RateLimit = { capacity: 120, refill: 1 };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the origin of the instance `instance` names, checked as a home: not
|
* Returns the origin of the instance `instance` names, checked as a home: not
|
||||||
* this instance, nor a blocked one.
|
* this instance, nor a blocked one.
|
||||||
@@ -157,50 +165,73 @@ export async function startLogin(
|
|||||||
return url.href;
|
return url.href;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the keys of the instance at `issuer`, refetched when `fresh` asks.
|
||||||
|
*
|
||||||
|
* @throws {APIException} 502 when they can't be fetched: the sender may try
|
||||||
|
* again, which it wouldn't after a 4xx.
|
||||||
|
*/
|
||||||
|
async function keysOf(issuer: string, fresh: boolean): Promise<JSONWebKeySet> {
|
||||||
|
try {
|
||||||
|
return await fetchKeys(issuer, fresh);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[federation] ${issuer}'s keys failed:`, err);
|
||||||
|
throw new APIException(502, "That instance's keys can't be fetched");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the payload of `token`, an identity token or an event from the
|
* Returns the payload of `token`, an identity token or an event from the
|
||||||
* instance at `issuer`, once its signature checks against that instance's
|
* instance at `issuer`, once its signature checks against that instance's
|
||||||
* keys and it is addressed to this one. The keys are refetched once when the
|
* keys and it is addressed to this one. The keys are refetched once when the
|
||||||
* token names one they lack.
|
* token names one they lack.
|
||||||
*
|
*
|
||||||
* @throws {APIException} 401 when it doesn't verify.
|
* @throws {APIException} 401 when it doesn't verify, 502 when the keys can't
|
||||||
|
* be fetched.
|
||||||
*/
|
*/
|
||||||
async function verifyFrom(
|
async function verifyFrom(
|
||||||
token: string,
|
token: string,
|
||||||
issuer: string,
|
issuer: string,
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
const claims = { issuer, audience: ISSUER };
|
const claims = { issuer, audience: ISSUER };
|
||||||
try {
|
let failure: unknown;
|
||||||
try {
|
for (const fresh of [false, true]) {
|
||||||
return await verifyInstanceToken(token, await fetchKeys(issuer), claims);
|
if (
|
||||||
} catch (err) {
|
fresh &&
|
||||||
if ((err as { code?: string }).code !== "ERR_JWKS_NO_MATCHING_KEY") {
|
(failure as { code?: string }).code !== "ERR_JWKS_NO_MATCHING_KEY"
|
||||||
throw err;
|
) {
|
||||||
}
|
break;
|
||||||
return await verifyInstanceToken(
|
|
||||||
token,
|
|
||||||
await fetchKeys(issuer, true),
|
|
||||||
claims,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
const keys = await keysOf(issuer, fresh);
|
||||||
|
try {
|
||||||
|
return await verifyInstanceToken(token, keys, claims);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[federation] a token from ${issuer} failed:`, err);
|
failure = err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.error(`[federation] a token from ${issuer} failed:`, failure);
|
||||||
throw new APIException(401, "Invalid token");
|
throw new APIException(401, "Invalid token");
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns `claims` once checked: a name a local user could have, and an email.
|
* Returns `claims` from the instance at `issuer` once checked: a name a local
|
||||||
*
|
* user could have, an email, and an avatar on that instance, so that it can't
|
||||||
* @throws {APIException} 502 when the home sent something else.
|
* make this server fetch from elsewhere. Returns null when they aren't.
|
||||||
*/
|
*/
|
||||||
function checkClaims(claims: unknown): IdentityClaims {
|
function checkClaims(claims: unknown, issuer: string): IdentityClaims | null {
|
||||||
if (
|
if (
|
||||||
!isIdentityClaims(claims) ||
|
!isIdentityClaims(claims) ||
|
||||||
!USERNAME_RE.test(claims.preferred_username) ||
|
!USERNAME_RE.test(claims.preferred_username) ||
|
||||||
!EMAIL_RE.test(claims.email)
|
!EMAIL_RE.test(claims.email)
|
||||||
) {
|
) {
|
||||||
throw new APIException(502, "The instance sent an invalid identity");
|
return null;
|
||||||
|
}
|
||||||
|
if (claims.picture !== undefined) {
|
||||||
|
try {
|
||||||
|
if (new URL(claims.picture).origin !== issuer) return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return claims;
|
return claims;
|
||||||
}
|
}
|
||||||
@@ -255,7 +286,10 @@ export async function completeLogin(
|
|||||||
if (payload.nonce !== login.nonce || typeof payload.sub !== "string") {
|
if (payload.nonce !== login.nonce || typeof payload.sub !== "string") {
|
||||||
throw new APIException(401, "Invalid token");
|
throw new APIException(401, "Invalid token");
|
||||||
}
|
}
|
||||||
const claims = checkClaims(payload);
|
const claims = checkClaims(payload, issuer);
|
||||||
|
if (!claims) {
|
||||||
|
throw new APIException(502, "The instance sent an invalid identity");
|
||||||
|
}
|
||||||
const subject = payload.sub;
|
const subject = payload.sub;
|
||||||
|
|
||||||
const identity = db.prepare(
|
const identity = db.prepare(
|
||||||
@@ -342,10 +376,13 @@ async function applyClaims(
|
|||||||
* Applies the signed event `token` from a user's home, for
|
* Applies the signed event `token` from a user's home, for
|
||||||
* `POST /api/federation/events`: `update` brings their account in step,
|
* `POST /api/federation/events`: `update` brings their account in step,
|
||||||
* `revoke` ends their sessions here. Only an instance some account here comes
|
* `revoke` ends their sessions here. Only an instance some account here comes
|
||||||
* from may send one, about one of those accounts.
|
* from, and not blocked, may send one, about one of those accounts, and at
|
||||||
|
* most {@link EVENT_LIMIT}'s rate. The sender gives up on an event at a 4xx
|
||||||
|
* other than 429, and tries it again later otherwise.
|
||||||
*
|
*
|
||||||
* @throws {APIException} 400 when it isn't an event of a known instance about
|
* @throws {APIException} 400 when it isn't an event of a known instance about
|
||||||
* one of its users; 401 when it doesn't verify.
|
* one of its users; 401 when it doesn't verify; 403 when the instance is
|
||||||
|
* blocked; 429 when it sends too many; 502 when its keys can't be fetched.
|
||||||
*/
|
*/
|
||||||
export async function receiveEvent(token: string): Promise<void> {
|
export async function receiveEvent(token: string): Promise<void> {
|
||||||
let issuer: unknown;
|
let issuer: unknown;
|
||||||
@@ -361,6 +398,12 @@ export async function receiveEvent(token: string): Promise<void> {
|
|||||||
) {
|
) {
|
||||||
throw new APIException(400, "Unknown instance");
|
throw new APIException(400, "Unknown instance");
|
||||||
}
|
}
|
||||||
|
if (getPeerStatus(issuer) === "blocked") {
|
||||||
|
throw new APIException(403, "This instance is blocked here");
|
||||||
|
}
|
||||||
|
if (!takeToken(`federation-events:${issuer}`, EVENT_LIMIT)) {
|
||||||
|
throw new APIException(429, "Too many events");
|
||||||
|
}
|
||||||
const payload = await verifyFrom(token, issuer);
|
const payload = await verifyFrom(token, issuer);
|
||||||
const identity = db.prepare(
|
const identity = db.prepare(
|
||||||
`SELECT user_id FROM user_identities WHERE issuer = ? AND subject = ?;`,
|
`SELECT user_id FROM user_identities WHERE issuer = ? AND subject = ?;`,
|
||||||
@@ -370,7 +413,9 @@ export async function receiveEvent(token: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
if (payload.type === "revoke") {
|
if (payload.type === "revoke") {
|
||||||
revokeSessions(identity.user_id, new Date(), "Signed out by home");
|
revokeSessions(identity.user_id, new Date(), "Signed out by home");
|
||||||
} else {
|
return;
|
||||||
await applyClaims(identity.user_id, issuer, checkClaims(payload.claims));
|
|
||||||
}
|
}
|
||||||
|
const claims = checkClaims(payload.claims, issuer);
|
||||||
|
if (!claims) throw new APIException(400, "Invalid event");
|
||||||
|
await applyClaims(identity.user_id, issuer, claims);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
* the instance key.
|
* the instance key.
|
||||||
* 3. While the user's grant lasts, the client hears of their changes: a new
|
* 3. While the user's grant lasts, the client hears of their changes: a new
|
||||||
* name, email or avatar, and the end of their sessions after a password
|
* name, email or avatar, and the end of their sessions after a password
|
||||||
* change. {@link notifyClients} queues the signed events in
|
* change or reset. {@link notifyClients} queues the signed events in
|
||||||
* `federation_outbox`, and {@link outboxJob} delivers them.
|
* `federation_outbox`, and {@link outboxJob} delivers them.
|
||||||
*
|
*
|
||||||
* Only a local account signs in elsewhere: a user who came from another
|
* Only a local account signs in elsewhere: a user who came from another
|
||||||
@@ -26,7 +26,12 @@
|
|||||||
import { createHash, randomBytes } from "node:crypto";
|
import { createHash, randomBytes } from "node:crypto";
|
||||||
import { APIException } from "../lib/api-exception.ts";
|
import { APIException } from "../lib/api-exception.ts";
|
||||||
import { db, transaction } from "../db/connection.ts";
|
import { db, transaction } from "../db/connection.ts";
|
||||||
import { FRONTEND_URL, OG_SITE_NAME, PUBLIC_URL } from "../config.ts";
|
import {
|
||||||
|
AVATARS_DIR,
|
||||||
|
FRONTEND_URL,
|
||||||
|
OG_SITE_NAME,
|
||||||
|
PUBLIC_URL,
|
||||||
|
} from "../config.ts";
|
||||||
import { signInstanceToken } from "../lib/jwt.ts";
|
import { signInstanceToken } from "../lib/jwt.ts";
|
||||||
import type { Job } from "../lib/scheduler.ts";
|
import type { Job } from "../lib/scheduler.ts";
|
||||||
import {
|
import {
|
||||||
@@ -105,9 +110,23 @@ function sha256(value: string): string {
|
|||||||
return createHash("sha256").update(value).digest("base64url");
|
return createHash("sha256").update(value).digest("base64url");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the version of `user`'s avatar: when its file was last written,
|
||||||
|
* which only a new avatar does, unlike `updatedAt`, which every profile edit
|
||||||
|
* bumps.
|
||||||
|
*/
|
||||||
|
function avatarVersion(user: User): number {
|
||||||
|
try {
|
||||||
|
return Deno.statSync(`${AVATARS_DIR}/${user.id}`).mtime?.getTime() ?? 0;
|
||||||
|
} catch {
|
||||||
|
return user.updatedAt?.getTime() ?? 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns what this instance tells another of `user`: their name, email and
|
* Returns what this instance tells another of `user`: their name, email and
|
||||||
* avatar, whose URL changes with each new one.
|
* avatar, whose URL changes with each new one, so that a client fetches it
|
||||||
|
* again only then.
|
||||||
*/
|
*/
|
||||||
export function identityClaims(user: User): IdentityClaims {
|
export function identityClaims(user: User): IdentityClaims {
|
||||||
return {
|
return {
|
||||||
@@ -115,7 +134,7 @@ export function identityClaims(user: User): IdentityClaims {
|
|||||||
email: user.email,
|
email: user.email,
|
||||||
...(user.avatarMime && {
|
...(user.avatarMime && {
|
||||||
picture: `${PUBLIC_URL}/api/users/${user.id}/avatar?v=${
|
picture: `${PUBLIC_URL}/api/users/${user.id}/avatar?v=${
|
||||||
user.updatedAt?.getTime() ?? 0
|
avatarVersion(user)
|
||||||
}`,
|
}`,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
@@ -348,7 +367,8 @@ export async function revokeGrant(userId: string, host: string): Promise<void> {
|
|||||||
* `clientIds`: `update` when their name, email or avatar changed, `revoke`
|
* `clientIds`: `update` when their name, email or avatar changed, `revoke`
|
||||||
* when their sessions there must end. The routes that change a user call it,
|
* when their sessions there must end. The routes that change a user call it,
|
||||||
* and for an `update`, `event` may be just the type: the claims are read now.
|
* and for an `update`, `event` may be just the type: the claims are read now.
|
||||||
* The events are queued in the outbox and their delivery starts at once.
|
* The events are queued in the outbox and their delivery starts at once,
|
||||||
|
* after those queued before for the same instance ({@link deliverTo}).
|
||||||
*/
|
*/
|
||||||
export async function notifyClients(
|
export async function notifyClients(
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -381,40 +401,70 @@ export async function notifyClients(
|
|||||||
now.toISOString(),
|
now.toISOString(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
deliverOutbox().catch((err) =>
|
for (const clientId of clients) deliverTo(clientId);
|
||||||
console.error("[federation] delivery failed:", err)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Set while {@link deliverOutbox} runs, so that two runs don't overlap. */
|
/**
|
||||||
let delivering = false;
|
* The instances whose events {@link deliverTo} is sending, so that two runs
|
||||||
|
* don't send the same events, and those whose events came meanwhile, which
|
||||||
|
* the running one sends too once done.
|
||||||
|
*/
|
||||||
|
const delivering = new Set<string>();
|
||||||
|
const queuedMeanwhile = new Set<string>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sends each due event of the outbox to its instance. One it took, or refused
|
* Sends the outbox's events to each instance that has some, every instance at
|
||||||
* for good, leaves the outbox; one that failed waits twice as long as the time
|
* once so that one that doesn't answer delays none of the others.
|
||||||
* before, up to 6 hours, until it expires after 3 days.
|
|
||||||
*/
|
*/
|
||||||
export async function deliverOutbox(): Promise<void> {
|
export async function deliverOutbox(): Promise<void> {
|
||||||
if (delivering) return;
|
|
||||||
delivering = true;
|
|
||||||
try {
|
|
||||||
const now = new Date();
|
|
||||||
db.prepare(`DELETE FROM federation_outbox WHERE created_at < ?;`).run(
|
db.prepare(`DELETE FROM federation_outbox WHERE created_at < ?;`).run(
|
||||||
new Date(now.getTime() - EVENT_TTL_MS).toISOString(),
|
new Date(Date.now() - EVENT_TTL_MS).toISOString(),
|
||||||
);
|
);
|
||||||
const due = db.prepare(
|
const clients = db.prepare(
|
||||||
`SELECT id, client_id, event, attempts FROM federation_outbox
|
`SELECT DISTINCT client_id FROM federation_outbox;`,
|
||||||
WHERE next_attempt_at <= ? ORDER BY created_at LIMIT 100;`,
|
).all() as { client_id: string }[];
|
||||||
).all(now.toISOString()) as {
|
await Promise.all(clients.map((c) => deliverTo(c.client_id)));
|
||||||
id: string;
|
}
|
||||||
client_id: string;
|
|
||||||
event: string;
|
/**
|
||||||
attempts: number;
|
* Sends the events for the instance `clientId` in the order they were made,
|
||||||
}[];
|
* as long as it takes them, since each `update` carries all the claims and a
|
||||||
for (const row of due) {
|
* late older one would undo a newer one. One it took, or refused for good,
|
||||||
|
* leaves the outbox. The first that fails stops the run, and waits twice as
|
||||||
|
* long as the time before, up to 6 hours, with the events behind it, until
|
||||||
|
* it expires after 3 days.
|
||||||
|
*/
|
||||||
|
async function deliverTo(clientId: string): Promise<void> {
|
||||||
|
if (delivering.has(clientId)) {
|
||||||
|
queuedMeanwhile.add(clientId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
delivering.add(clientId);
|
||||||
try {
|
try {
|
||||||
if (!await postEvent(row.client_id, row.event)) {
|
do {
|
||||||
console.error(`[federation] ${row.client_id} refused an event`);
|
queuedMeanwhile.delete(clientId);
|
||||||
|
await sendDue(clientId);
|
||||||
|
} while (queuedMeanwhile.has(clientId));
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[federation] delivery to ${clientId} failed:`, err);
|
||||||
|
} finally {
|
||||||
|
delivering.delete(clientId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs one pass of {@link deliverTo}. */
|
||||||
|
async function sendDue(clientId: string): Promise<void> {
|
||||||
|
for (;;) {
|
||||||
|
const row = db.prepare(
|
||||||
|
`SELECT id, event, attempts, next_attempt_at FROM federation_outbox
|
||||||
|
WHERE client_id = ? ORDER BY created_at, rowid LIMIT 1;`,
|
||||||
|
).get(clientId) as
|
||||||
|
| { id: string; event: string; attempts: number; next_attempt_at: string }
|
||||||
|
| undefined;
|
||||||
|
if (!row || row.next_attempt_at > new Date().toISOString()) return;
|
||||||
|
try {
|
||||||
|
if (!await postEvent(clientId, row.event)) {
|
||||||
|
console.error(`[federation] ${clientId} refused an event`);
|
||||||
}
|
}
|
||||||
db.prepare(`DELETE FROM federation_outbox WHERE id = ?;`).run(row.id);
|
db.prepare(`DELETE FROM federation_outbox WHERE id = ?;`).run(row.id);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -422,12 +472,10 @@ export async function deliverOutbox(): Promise<void> {
|
|||||||
db.prepare(
|
db.prepare(
|
||||||
`UPDATE federation_outbox SET attempts = attempts + 1, next_attempt_at = ? WHERE id = ?;`,
|
`UPDATE federation_outbox SET attempts = attempts + 1, next_attempt_at = ? WHERE id = ?;`,
|
||||||
).run(new Date(Date.now() + wait).toISOString(), row.id);
|
).run(new Date(Date.now() + wait).toISOString(), row.id);
|
||||||
console.error(`[federation] ${row.client_id} unreachable:`, err);
|
console.error(`[federation] ${clientId} unreachable:`, err);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
|
||||||
delivering = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Retries the outbox's failed events every minute. */
|
/** Retries the outbox's failed events every minute. */
|
||||||
|
|||||||
@@ -65,8 +65,9 @@ export async function requestPasswordReset(email: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets `newPassword` for the user of a reset token and marks the token used.
|
* Sets `newPassword` for the user of a reset token, marks the token used, and
|
||||||
* Through `updateUser`, this ends the user's other sessions.
|
* returns the user's id. Through `updateUser`, this ends the user's sessions
|
||||||
|
* here; the route tells the instances they signed in to.
|
||||||
*
|
*
|
||||||
* @throws {APIException} 400 when the password's length is out of bounds or
|
* @throws {APIException} 400 when the password's length is out of bounds or
|
||||||
* the token is invalid or expired, 404 when the token isn't on record or its
|
* the token is invalid or expired, 404 when the token isn't on record or its
|
||||||
@@ -75,7 +76,7 @@ export async function requestPasswordReset(email: string): Promise<void> {
|
|||||||
export async function resetPassword(
|
export async function resetPassword(
|
||||||
token: string,
|
token: string,
|
||||||
newPassword: string,
|
newPassword: string,
|
||||||
): Promise<void> {
|
): Promise<string> {
|
||||||
if (
|
if (
|
||||||
newPassword.length < VALIDATION.PASSWORD_MIN ||
|
newPassword.length < VALIDATION.PASSWORD_MIN ||
|
||||||
newPassword.length > VALIDATION.PASSWORD_MAX
|
newPassword.length > VALIDATION.PASSWORD_MAX
|
||||||
@@ -118,4 +119,5 @@ export async function resetPassword(
|
|||||||
.run(token);
|
.run(token);
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
return payload.userId;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
test,
|
test,
|
||||||
} from "../helpers.ts";
|
} from "../helpers.ts";
|
||||||
import { assert, assertEquals, assertMatch } from "@std/assert";
|
import { assert, assertEquals, assertMatch } from "@std/assert";
|
||||||
|
import { clearInstanceCaches } from "../../remote/gerbeur-instance.ts";
|
||||||
import type {
|
import type {
|
||||||
FederationLoginResponse,
|
FederationLoginResponse,
|
||||||
FederationPeer,
|
FederationPeer,
|
||||||
@@ -265,6 +266,64 @@ test("federation: the home's events rename, update and sign out its users", asyn
|
|||||||
assertEquals((await get("/api/me", { auth })).status, 401);
|
assertEquals((await get("/api/me", { auth })).status, 401);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("federation: a home's events are refused from a blocked home, with an avatar elsewhere, and put off when its keys fail", async () => {
|
||||||
|
const home = await fakeInstance(HOME);
|
||||||
|
let keysDown = false;
|
||||||
|
using net = stubNetwork((url, init) =>
|
||||||
|
keysDown && url.pathname === "/.well-known/jwks.json"
|
||||||
|
? new Response("", { status: 503 })
|
||||||
|
: home.handle(url, init) ?? new Response("", { status: 404 })
|
||||||
|
);
|
||||||
|
await trust(HOME);
|
||||||
|
await signIn(home, ALICE);
|
||||||
|
const send = async (payload: Record<string, unknown>) =>
|
||||||
|
(await post("/api/federation/events", {
|
||||||
|
json: { event: await home.event(ALICE.sub, payload) },
|
||||||
|
})).status;
|
||||||
|
|
||||||
|
// Answered 5xx, so that the home tries again rather than give up.
|
||||||
|
keysDown = true;
|
||||||
|
clearInstanceCaches();
|
||||||
|
assertEquals(await send({ type: "revoke" }), 502);
|
||||||
|
keysDown = false;
|
||||||
|
|
||||||
|
const avatars = net.calls.filter((u) => u.pathname.endsWith("/avatar"))
|
||||||
|
.length;
|
||||||
|
assertEquals(
|
||||||
|
await send({
|
||||||
|
type: "update",
|
||||||
|
claims: { ...ALICE, picture: "https://elsewhere.test/a.png" },
|
||||||
|
}),
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
net.calls.filter((u) => u.pathname.endsWith("/avatar")).length,
|
||||||
|
avatars,
|
||||||
|
);
|
||||||
|
|
||||||
|
await trust(HOME, "blocked");
|
||||||
|
assertEquals(
|
||||||
|
await send({
|
||||||
|
type: "update",
|
||||||
|
claims: { ...ALICE, preferred_username: "mallory" },
|
||||||
|
}),
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
(await get<SelfUser>("/api/users/@alice@home.test")).data.username,
|
||||||
|
"alice@home.test",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("federation: an instance that doesn't answer can be blocked, not trusted", async () => {
|
||||||
|
using _net = network();
|
||||||
|
assertEquals((await trust("https://down.test")).status, 502);
|
||||||
|
const blocked = await trust("https://down.test", "blocked");
|
||||||
|
assertEquals(blocked.status, 200);
|
||||||
|
assertEquals(blocked.data.status, "blocked");
|
||||||
|
assertEquals(blocked.data.name, null);
|
||||||
|
});
|
||||||
|
|
||||||
test("federation: an account from another instance changes its password, email, avatar and name there", async () => {
|
test("federation: an account from another instance changes its password, email, avatar and name there", async () => {
|
||||||
const home = await fakeInstance(HOME);
|
const home = await fakeInstance(HOME);
|
||||||
using _net = network(home);
|
using _net = network(home);
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import {
|
|||||||
del,
|
del,
|
||||||
type FakeInstance,
|
type FakeInstance,
|
||||||
fakeInstance,
|
fakeInstance,
|
||||||
|
formWithFile,
|
||||||
get,
|
get,
|
||||||
patch,
|
patch,
|
||||||
|
PNG,
|
||||||
post,
|
post,
|
||||||
put,
|
put,
|
||||||
stubNetwork,
|
stubNetwork,
|
||||||
@@ -16,7 +18,15 @@ import {
|
|||||||
import { assert, assertEquals } from "@std/assert";
|
import { assert, assertEquals } from "@std/assert";
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { decodeJwt, type JSONWebKeySet } from "@panva/jose";
|
import { decodeJwt, type JSONWebKeySet } from "@panva/jose";
|
||||||
import { verifyInstanceToken } from "../../lib/jwt.ts";
|
import {
|
||||||
|
createPasswordResetToken,
|
||||||
|
verifyInstanceToken,
|
||||||
|
} from "../../lib/jwt.ts";
|
||||||
|
import {
|
||||||
|
deliverOutbox,
|
||||||
|
identityClaims,
|
||||||
|
} from "../../services/identity-provider-service.ts";
|
||||||
|
import { getUserById } from "../../services/user-service.ts";
|
||||||
import type {
|
import type {
|
||||||
AuthorizeResponse,
|
AuthorizeResponse,
|
||||||
FederationGrant,
|
FederationGrant,
|
||||||
@@ -266,3 +276,65 @@ test("identity provider: an event an instance fails to take waits in the outbox"
|
|||||||
assert(row);
|
assert(row);
|
||||||
assertEquals(row.attempts, 1);
|
assertEquals(row.attempts, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("identity provider: a password reset ends the user's sessions where they signed in", async () => {
|
||||||
|
const fake = await fakeInstance(CLIENT);
|
||||||
|
using _net = network(fake);
|
||||||
|
const user = await createUser();
|
||||||
|
await authorize(user.auth, pkce().challenge);
|
||||||
|
const token = await createPasswordResetToken(user.id);
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO password_reset_tokens (token, user_id, expires_at) VALUES (?, ?, ?);`,
|
||||||
|
).run(token, user.id, new Date(Date.now() + 3600_000).toISOString());
|
||||||
|
assertEquals(
|
||||||
|
(await put(`/api/password-resets/${token}`, {
|
||||||
|
json: { newPassword: "reset-password-1" },
|
||||||
|
})).status,
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
await until(() => fake.received.length === 1);
|
||||||
|
assertEquals(decodeJwt(fake.received[0]).type, "revoke");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("identity provider: an instance gets its events in order, a failed one holding back the next", async () => {
|
||||||
|
const fake = await fakeInstance(CLIENT);
|
||||||
|
let down = true;
|
||||||
|
using _net = stubNetwork((url, init) =>
|
||||||
|
down && url.pathname === "/api/federation/events"
|
||||||
|
? new Response("", { status: 503 })
|
||||||
|
: fake.handle(url, init) ?? new Response("", { status: 404 })
|
||||||
|
);
|
||||||
|
const user = await createUser();
|
||||||
|
await authorize(user.auth, pkce().challenge);
|
||||||
|
await patch("/api/me", { auth: user.auth, json: { email: "a@new.test" } });
|
||||||
|
await until(() =>
|
||||||
|
!!db.prepare(`SELECT 1 FROM federation_outbox WHERE attempts > 0;`).get()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Up again, but the first event still waits: the second must wait behind.
|
||||||
|
down = false;
|
||||||
|
await patch("/api/me", { auth: user.auth, json: { email: "b@new.test" } });
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
assertEquals(fake.received.length, 0);
|
||||||
|
|
||||||
|
db.prepare(`UPDATE federation_outbox SET next_attempt_at = ?;`).run(
|
||||||
|
new Date().toISOString(),
|
||||||
|
);
|
||||||
|
await deliverOutbox();
|
||||||
|
assertEquals(
|
||||||
|
fake.received.map((e) => (decodeJwt(e).claims as { email: string }).email),
|
||||||
|
["a@new.test", "b@new.test"],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("identity provider: the avatar's URL changes with the avatar only", async () => {
|
||||||
|
const user = await createUser();
|
||||||
|
await put("/api/me/avatar", { auth: user.auth, form: formWithFile(PNG) });
|
||||||
|
const before = identityClaims(getUserById(user.id)).picture;
|
||||||
|
assert(before);
|
||||||
|
await new Promise((r) => setTimeout(r, 5));
|
||||||
|
await patch("/api/me", { auth: user.auth, json: { description: "hi" } });
|
||||||
|
assertEquals(identityClaims(getUserById(user.id)).picture, before);
|
||||||
|
await put("/api/me/avatar", { auth: user.auth, form: formWithFile(PNG) });
|
||||||
|
assert(identityClaims(getUserById(user.id)).picture !== before);
|
||||||
|
});
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -391,7 +391,7 @@ msgstr "Auto"
|
|||||||
msgid "Back to login"
|
msgid "Back to login"
|
||||||
msgstr "Back to login"
|
msgstr "Back to login"
|
||||||
|
|
||||||
#: src/pages/FederationCallback.tsx:66
|
#: src/pages/FederationCallback.tsx:71
|
||||||
msgid "Back to the login page"
|
msgid "Back to the login page"
|
||||||
msgstr "Back to the login page"
|
msgstr "Back to the login page"
|
||||||
|
|
||||||
@@ -545,7 +545,7 @@ msgstr "Community"
|
|||||||
msgid "Confirm new password"
|
msgid "Confirm new password"
|
||||||
msgstr "Confirm new password"
|
msgstr "Confirm new password"
|
||||||
|
|
||||||
#: src/components/federation/FederatedSignIn.tsx:111
|
#: src/components/federation/FederatedSignIn.tsx:113
|
||||||
msgid "Connecting…"
|
msgid "Connecting…"
|
||||||
msgstr "Connecting…"
|
msgstr "Connecting…"
|
||||||
|
|
||||||
@@ -553,7 +553,7 @@ msgstr "Connecting…"
|
|||||||
msgid "Connection lost — this message may not have been sent"
|
msgid "Connection lost — this message may not have been sent"
|
||||||
msgstr "Connection lost — this message may not have been sent"
|
msgstr "Connection lost — this message may not have been sent"
|
||||||
|
|
||||||
#: src/components/federation/FederatedSignIn.tsx:113
|
#: src/components/federation/FederatedSignIn.tsx:115
|
||||||
msgid "Continue"
|
msgid "Continue"
|
||||||
msgstr "Continue"
|
msgstr "Continue"
|
||||||
|
|
||||||
@@ -602,7 +602,7 @@ msgstr "Couldn't load a preview for this link — check it, or post it as-is and
|
|||||||
msgid "Couldn't load the chat."
|
msgid "Couldn't load the chat."
|
||||||
msgstr "Couldn't load the chat."
|
msgstr "Couldn't load the chat."
|
||||||
|
|
||||||
#: src/components/federation/FederatedSignIn.tsx:102
|
#: src/components/federation/FederatedSignIn.tsx:104
|
||||||
msgid "Couldn't reach that instance"
|
msgid "Couldn't reach that instance"
|
||||||
msgstr "Couldn't reach that instance"
|
msgstr "Couldn't reach that instance"
|
||||||
|
|
||||||
@@ -1066,7 +1066,7 @@ msgstr "invited by you"
|
|||||||
msgid "Invitees"
|
msgid "Invitees"
|
||||||
msgstr "Invitees"
|
msgstr "Invitees"
|
||||||
|
|
||||||
#: src/components/federation/FederatedSignIn.tsx:105
|
#: src/components/federation/FederatedSignIn.tsx:107
|
||||||
msgid "Its address, like music.example"
|
msgid "Its address, like music.example"
|
||||||
msgstr "Its address, like music.example"
|
msgstr "Its address, like music.example"
|
||||||
|
|
||||||
@@ -1155,7 +1155,7 @@ msgid "Loading…"
|
|||||||
msgstr "Loading…"
|
msgstr "Loading…"
|
||||||
|
|
||||||
#: src/components/layout/AppHeader.tsx:208
|
#: src/components/layout/AppHeader.tsx:208
|
||||||
#: src/pages/FederationCallback.tsx:31
|
#: src/pages/FederationCallback.tsx:32
|
||||||
#: src/pages/UserLogin.tsx:44
|
#: src/pages/UserLogin.tsx:44
|
||||||
#: src/pages/UserLogin.tsx:71
|
#: src/pages/UserLogin.tsx:71
|
||||||
#: src/pages/UserLogin.tsx:90
|
#: src/pages/UserLogin.tsx:90
|
||||||
@@ -1171,7 +1171,7 @@ msgid "Log in to vote"
|
|||||||
msgstr "Log in to vote"
|
msgstr "Log in to vote"
|
||||||
|
|
||||||
#. placeholder {0}: peer.name ?? peer.host
|
#. placeholder {0}: peer.name ?? peer.host
|
||||||
#: src/components/federation/FederatedSignIn.tsx:97
|
#: src/components/federation/FederatedSignIn.tsx:99
|
||||||
msgid "Log in with {0}"
|
msgid "Log in with {0}"
|
||||||
msgstr "Log in with {0}"
|
msgstr "Log in with {0}"
|
||||||
|
|
||||||
@@ -1184,7 +1184,7 @@ msgstr "Log out"
|
|||||||
msgid "Logging in…"
|
msgid "Logging in…"
|
||||||
msgstr "Logging in…"
|
msgstr "Logging in…"
|
||||||
|
|
||||||
#: src/pages/FederationCallback.tsx:63
|
#: src/pages/FederationCallback.tsx:68
|
||||||
#: src/pages/UserLogin.tsx:76
|
#: src/pages/UserLogin.tsx:76
|
||||||
msgid "Login failed"
|
msgid "Login failed"
|
||||||
msgstr "Login failed"
|
msgstr "Login failed"
|
||||||
@@ -1390,11 +1390,11 @@ msgstr "Open the chat"
|
|||||||
msgid "or <0>browse files</0>"
|
msgid "or <0>browse files</0>"
|
||||||
msgstr "or <0>browse files</0>"
|
msgstr "or <0>browse files</0>"
|
||||||
|
|
||||||
#: src/components/federation/FederatedSignIn.tsx:82
|
#: src/components/federation/FederatedSignIn.tsx:84
|
||||||
msgid "Or join with your account on another gerbeur"
|
msgid "Or join with your account on another gerbeur"
|
||||||
msgstr "Or join with your account on another gerbeur"
|
msgstr "Or join with your account on another gerbeur"
|
||||||
|
|
||||||
#: src/components/federation/FederatedSignIn.tsx:83
|
#: src/components/federation/FederatedSignIn.tsx:85
|
||||||
msgid "Or log in with your account on another gerbeur"
|
msgid "Or log in with your account on another gerbeur"
|
||||||
msgstr "Or log in with your account on another gerbeur"
|
msgstr "Or log in with your account on another gerbeur"
|
||||||
|
|
||||||
@@ -1730,7 +1730,7 @@ msgid "Sign in to {name}?"
|
|||||||
msgstr "Sign in to {name}?"
|
msgstr "Sign in to {name}?"
|
||||||
|
|
||||||
#: src/pages/Authorize.tsx:154
|
#: src/pages/Authorize.tsx:154
|
||||||
#: src/pages/FederationCallback.tsx:73
|
#: src/pages/FederationCallback.tsx:78
|
||||||
msgid "Signing you in…"
|
msgid "Signing you in…"
|
||||||
msgstr "Signing you in…"
|
msgstr "Signing you in…"
|
||||||
|
|
||||||
@@ -1838,10 +1838,14 @@ msgstr "This page does not exist."
|
|||||||
msgid "This reset link is missing or malformed."
|
msgid "This reset link is missing or malformed."
|
||||||
msgstr "This reset link is missing or malformed."
|
msgstr "This reset link is missing or malformed."
|
||||||
|
|
||||||
#: src/pages/FederationCallback.tsx:38
|
#: src/pages/FederationCallback.tsx:39
|
||||||
msgid "This sign-in link is incomplete."
|
msgid "This sign-in link is incomplete."
|
||||||
msgstr "This sign-in link is incomplete."
|
msgstr "This sign-in link is incomplete."
|
||||||
|
|
||||||
|
#: src/pages/FederationCallback.tsx:47
|
||||||
|
msgid "This sign-in was started elsewhere. Start again from the login page."
|
||||||
|
msgstr "This sign-in was started elsewhere. Start again from the login page."
|
||||||
|
|
||||||
#: src/pages/Settings.tsx:201
|
#: src/pages/Settings.tsx:201
|
||||||
msgid "This site doesn't send email, so no digest goes out."
|
msgid "This site doesn't send email, so no digest goes out."
|
||||||
msgstr "This site doesn't send email, so no digest goes out."
|
msgstr "This site doesn't send email, so no digest goes out."
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -391,7 +391,7 @@ msgstr "Auto"
|
|||||||
msgid "Back to login"
|
msgid "Back to login"
|
||||||
msgstr "Retour à la connexion"
|
msgstr "Retour à la connexion"
|
||||||
|
|
||||||
#: src/pages/FederationCallback.tsx:66
|
#: src/pages/FederationCallback.tsx:71
|
||||||
msgid "Back to the login page"
|
msgid "Back to the login page"
|
||||||
msgstr "Retour à la page de connexion"
|
msgstr "Retour à la page de connexion"
|
||||||
|
|
||||||
@@ -545,7 +545,7 @@ msgstr "Communauté"
|
|||||||
msgid "Confirm new password"
|
msgid "Confirm new password"
|
||||||
msgstr "Confirmer le nouveau mot de passe"
|
msgstr "Confirmer le nouveau mot de passe"
|
||||||
|
|
||||||
#: src/components/federation/FederatedSignIn.tsx:111
|
#: src/components/federation/FederatedSignIn.tsx:113
|
||||||
msgid "Connecting…"
|
msgid "Connecting…"
|
||||||
msgstr "Connexion…"
|
msgstr "Connexion…"
|
||||||
|
|
||||||
@@ -553,7 +553,7 @@ msgstr "Connexion…"
|
|||||||
msgid "Connection lost — this message may not have been sent"
|
msgid "Connection lost — this message may not have been sent"
|
||||||
msgstr "Connexion perdue — ce message n'a peut-être pas été envoyé"
|
msgstr "Connexion perdue — ce message n'a peut-être pas été envoyé"
|
||||||
|
|
||||||
#: src/components/federation/FederatedSignIn.tsx:113
|
#: src/components/federation/FederatedSignIn.tsx:115
|
||||||
msgid "Continue"
|
msgid "Continue"
|
||||||
msgstr "Continuer"
|
msgstr "Continuer"
|
||||||
|
|
||||||
@@ -602,7 +602,7 @@ msgstr "Impossible de charger un aperçu pour ce lien — vérifiez-le, ou publi
|
|||||||
msgid "Couldn't load the chat."
|
msgid "Couldn't load the chat."
|
||||||
msgstr "Impossible de charger le chat."
|
msgstr "Impossible de charger le chat."
|
||||||
|
|
||||||
#: src/components/federation/FederatedSignIn.tsx:102
|
#: src/components/federation/FederatedSignIn.tsx:104
|
||||||
msgid "Couldn't reach that instance"
|
msgid "Couldn't reach that instance"
|
||||||
msgstr "Impossible de joindre cette instance"
|
msgstr "Impossible de joindre cette instance"
|
||||||
|
|
||||||
@@ -1066,7 +1066,7 @@ msgstr "sur votre invitation"
|
|||||||
msgid "Invitees"
|
msgid "Invitees"
|
||||||
msgstr "Invités"
|
msgstr "Invités"
|
||||||
|
|
||||||
#: src/components/federation/FederatedSignIn.tsx:105
|
#: src/components/federation/FederatedSignIn.tsx:107
|
||||||
msgid "Its address, like music.example"
|
msgid "Its address, like music.example"
|
||||||
msgstr "Son adresse, comme music.example"
|
msgstr "Son adresse, comme music.example"
|
||||||
|
|
||||||
@@ -1155,7 +1155,7 @@ msgid "Loading…"
|
|||||||
msgstr "Chargement…"
|
msgstr "Chargement…"
|
||||||
|
|
||||||
#: src/components/layout/AppHeader.tsx:208
|
#: src/components/layout/AppHeader.tsx:208
|
||||||
#: src/pages/FederationCallback.tsx:31
|
#: src/pages/FederationCallback.tsx:32
|
||||||
#: src/pages/UserLogin.tsx:44
|
#: src/pages/UserLogin.tsx:44
|
||||||
#: src/pages/UserLogin.tsx:71
|
#: src/pages/UserLogin.tsx:71
|
||||||
#: src/pages/UserLogin.tsx:90
|
#: src/pages/UserLogin.tsx:90
|
||||||
@@ -1171,7 +1171,7 @@ msgid "Log in to vote"
|
|||||||
msgstr "Se connecter pour voter"
|
msgstr "Se connecter pour voter"
|
||||||
|
|
||||||
#. placeholder {0}: peer.name ?? peer.host
|
#. placeholder {0}: peer.name ?? peer.host
|
||||||
#: src/components/federation/FederatedSignIn.tsx:97
|
#: src/components/federation/FederatedSignIn.tsx:99
|
||||||
msgid "Log in with {0}"
|
msgid "Log in with {0}"
|
||||||
msgstr "Se connecter avec {0}"
|
msgstr "Se connecter avec {0}"
|
||||||
|
|
||||||
@@ -1184,7 +1184,7 @@ msgstr "Se déconnecter"
|
|||||||
msgid "Logging in…"
|
msgid "Logging in…"
|
||||||
msgstr "Connexion…"
|
msgstr "Connexion…"
|
||||||
|
|
||||||
#: src/pages/FederationCallback.tsx:63
|
#: src/pages/FederationCallback.tsx:68
|
||||||
#: src/pages/UserLogin.tsx:76
|
#: src/pages/UserLogin.tsx:76
|
||||||
msgid "Login failed"
|
msgid "Login failed"
|
||||||
msgstr "Connexion échouée"
|
msgstr "Connexion échouée"
|
||||||
@@ -1390,11 +1390,11 @@ msgstr "Ouvrir la tribune"
|
|||||||
msgid "or <0>browse files</0>"
|
msgid "or <0>browse files</0>"
|
||||||
msgstr "ou <0>parcourir les fichiers</0>"
|
msgstr "ou <0>parcourir les fichiers</0>"
|
||||||
|
|
||||||
#: src/components/federation/FederatedSignIn.tsx:82
|
#: src/components/federation/FederatedSignIn.tsx:84
|
||||||
msgid "Or join with your account on another gerbeur"
|
msgid "Or join with your account on another gerbeur"
|
||||||
msgstr "Ou rejoignez-nous avec votre compte d'un autre gerbeur"
|
msgstr "Ou rejoignez-nous avec votre compte d'un autre gerbeur"
|
||||||
|
|
||||||
#: src/components/federation/FederatedSignIn.tsx:83
|
#: src/components/federation/FederatedSignIn.tsx:85
|
||||||
msgid "Or log in with your account on another gerbeur"
|
msgid "Or log in with your account on another gerbeur"
|
||||||
msgstr "Ou connectez-vous avec votre compte d'un autre gerbeur"
|
msgstr "Ou connectez-vous avec votre compte d'un autre gerbeur"
|
||||||
|
|
||||||
@@ -1730,7 +1730,7 @@ msgid "Sign in to {name}?"
|
|||||||
msgstr "Se connecter à {name} ?"
|
msgstr "Se connecter à {name} ?"
|
||||||
|
|
||||||
#: src/pages/Authorize.tsx:154
|
#: src/pages/Authorize.tsx:154
|
||||||
#: src/pages/FederationCallback.tsx:73
|
#: src/pages/FederationCallback.tsx:78
|
||||||
msgid "Signing you in…"
|
msgid "Signing you in…"
|
||||||
msgstr "Connexion…"
|
msgstr "Connexion…"
|
||||||
|
|
||||||
@@ -1838,10 +1838,14 @@ msgstr "Rien à voir, circulez."
|
|||||||
msgid "This reset link is missing or malformed."
|
msgid "This reset link is missing or malformed."
|
||||||
msgstr "Ce lien de réinitialisation est absent ou malformé."
|
msgstr "Ce lien de réinitialisation est absent ou malformé."
|
||||||
|
|
||||||
#: src/pages/FederationCallback.tsx:38
|
#: src/pages/FederationCallback.tsx:39
|
||||||
msgid "This sign-in link is incomplete."
|
msgid "This sign-in link is incomplete."
|
||||||
msgstr "Ce lien de connexion est incomplet."
|
msgstr "Ce lien de connexion est incomplet."
|
||||||
|
|
||||||
|
#: src/pages/FederationCallback.tsx:47
|
||||||
|
msgid "This sign-in was started elsewhere. Start again from the login page."
|
||||||
|
msgstr "Cette connexion a été commencée ailleurs. Recommencez depuis la page de connexion."
|
||||||
|
|
||||||
#: src/pages/Settings.tsx:201
|
#: src/pages/Settings.tsx:201
|
||||||
msgid "This site doesn't send email, so no digest goes out."
|
msgid "This site doesn't send email, so no digest goes out."
|
||||||
msgstr "Ce site n'envoie pas d'e-mails, aucun résumé ne part donc."
|
msgstr "Ce site n'envoie pas d'e-mails, aucun résumé ne part donc."
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import type {
|
|||||||
FederationLoginResponse,
|
FederationLoginResponse,
|
||||||
FederationPeer,
|
FederationPeer,
|
||||||
} from "../../../shared/federation.ts";
|
} from "../../../shared/federation.ts";
|
||||||
|
import { rememberSignIn } from "../../utils/auth/federation.ts";
|
||||||
import { dataOr, expectOk } from "../../utils/shared/api.ts";
|
import { dataOr, expectOk } from "../../utils/shared/api.ts";
|
||||||
import { deserializeFederationPeer } from "../../utils/shared/deserialize.ts";
|
import { deserializeFederationPeer } from "../../utils/shared/deserialize.ts";
|
||||||
import { useApiForm } from "../../hooks/form/useApiForm.ts";
|
import { useApiForm } from "../../hooks/form/useApiForm.ts";
|
||||||
@@ -72,6 +73,7 @@ export function FederatedSignIn(
|
|||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
rememberSignIn(authorizeUrl);
|
||||||
globalThis.location.assign(authorizeUrl);
|
globalThis.location.assign(authorizeUrl);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type { Wire } from "../../shared/api.ts";
|
|||||||
import type { FederationSessionRequest } from "../../shared/federation.ts";
|
import type { FederationSessionRequest } from "../../shared/federation.ts";
|
||||||
import type { AuthResponse } from "../../shared/user.ts";
|
import type { AuthResponse } from "../../shared/user.ts";
|
||||||
import { useAuth } from "../hooks/auth/useAuth.ts";
|
import { useAuth } from "../hooks/auth/useAuth.ts";
|
||||||
|
import { forgetSignIn, startedHere } from "../utils/auth/federation.ts";
|
||||||
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
|
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
|
||||||
import { PageShell } from "../components/layout/PageShell.tsx";
|
import { PageShell } from "../components/layout/PageShell.tsx";
|
||||||
import { ErrorCard } from "../components/shared/ErrorCard.tsx";
|
import { ErrorCard } from "../components/shared/ErrorCard.tsx";
|
||||||
@@ -23,9 +24,9 @@ import { deserializeAuthResponse } from "../utils/shared/deserialize.ts";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders `/federation/callback?code=&state=`. It finishes the sign-in
|
* Renders `/federation/callback?code=&state=`. It finishes the sign-in
|
||||||
* (`POST /api/federation/sessions`), once: a code works only once, and a
|
* (`POST /api/federation/sessions`) if this tab started it (`startedHere`),
|
||||||
* second request would fail the sign-in. On success the user lands on `/`,
|
* and once: a code works only once, and a second request would fail the
|
||||||
* signed in.
|
* sign-in. On success the user lands on `/`, signed in.
|
||||||
*/
|
*/
|
||||||
export function FederationCallback(): React.JSX.Element {
|
export function FederationCallback(): React.JSX.Element {
|
||||||
useDocumentTitle(t`Log in`);
|
useDocumentTitle(t`Log in`);
|
||||||
@@ -34,14 +35,19 @@ export function FederationCallback(): React.JSX.Element {
|
|||||||
const { login } = useAuth();
|
const { login } = useAuth();
|
||||||
const code = params.get("code");
|
const code = params.get("code");
|
||||||
const state = params.get("state");
|
const state = params.get("state");
|
||||||
const [error, setError] = useState<string | null>(
|
const [error, setError] = useState<string | null>(() =>
|
||||||
code && state ? null : t`This sign-in link is incomplete.`,
|
!code || !state
|
||||||
|
? t`This sign-in link is incomplete.`
|
||||||
|
: !startedHere(state)
|
||||||
|
? t`This sign-in was started elsewhere. Start again from the login page.`
|
||||||
|
: null
|
||||||
);
|
);
|
||||||
const sent = useRef(false);
|
const sent = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (sent.current || !code || !state) return;
|
if (sent.current || error || !code || !state) return;
|
||||||
sent.current = true;
|
sent.current = true;
|
||||||
|
forgetSignIn();
|
||||||
fetch(`${API_URL}/api/federation/sessions`, {
|
fetch(`${API_URL}/api/federation/sessions`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@@ -53,7 +59,7 @@ export function FederationCallback(): React.JSX.Element {
|
|||||||
navigate("/", { replace: true });
|
navigate("/", { replace: true });
|
||||||
})
|
})
|
||||||
.catch((err) => setError(friendlyFetchError(err)));
|
.catch((err) => setError(friendlyFetchError(err)));
|
||||||
}, [code, state, login, navigate]);
|
}, [code, state, error, login, navigate]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell centered>
|
<PageShell centered>
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import { calls, fail, route } from "../fetch.ts";
|
|||||||
import { makeSession, wire } from "../fixtures.ts";
|
import { makeSession, wire } from "../fixtures.ts";
|
||||||
import { renderApp } from "../render.tsx";
|
import { renderApp } from "../render.tsx";
|
||||||
|
|
||||||
function open(query: string) {
|
/** Opens the callback, in a tab that started the sign-in of state `s`. */
|
||||||
|
function open(query: string, started = "s") {
|
||||||
|
sessionStorage.setItem("federationState", started);
|
||||||
renderApp(<FederationCallback />, {
|
renderApp(<FederationCallback />, {
|
||||||
route: `/federation/callback${query}`,
|
route: `/federation/callback${query}`,
|
||||||
path: "/federation/callback",
|
path: "/federation/callback",
|
||||||
@@ -42,6 +44,16 @@ describe("FederationCallback", () => {
|
|||||||
.toHaveAttribute("href", "/login");
|
.toHaveAttribute("href", "/login");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a sign-in this tab didn't start is refused", async () => {
|
||||||
|
open("?code=c&state=theirs");
|
||||||
|
expect(
|
||||||
|
await screen.findByText(
|
||||||
|
"This sign-in was started elsewhere. Start again from the login page.",
|
||||||
|
),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(calls("POST /api/federation/sessions")).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
test("a link without its code says so", async () => {
|
test("a link without its code says so", async () => {
|
||||||
open("?state=s");
|
open("?state=s");
|
||||||
expect(await screen.findByText("This sign-in link is incomplete."))
|
expect(await screen.findByText("This sign-in link is incomplete."))
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ describe("UserLogin", () => {
|
|||||||
route("POST /api/federation/logins", (req) => ({
|
route("POST /api/federation/logins", (req) => ({
|
||||||
authorizeUrl: `https://${
|
authorizeUrl: `https://${
|
||||||
(req.body as { instance: string }).instance.replace(/^https:\/\//, "")
|
(req.body as { instance: string }).instance.replace(/^https:\/\//, "")
|
||||||
}/authorize?x`,
|
}/authorize?state=s1`,
|
||||||
}));
|
}));
|
||||||
const user = setup();
|
const user = setup();
|
||||||
await user.click(
|
await user.click(
|
||||||
@@ -175,9 +175,11 @@ describe("UserLogin", () => {
|
|||||||
);
|
);
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(assign).toHaveBeenCalledWith(
|
expect(assign).toHaveBeenCalledWith(
|
||||||
"https://music.example/authorize?x",
|
"https://music.example/authorize?state=s1",
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
// The callback finishes only the sign-in this tab started.
|
||||||
|
expect(sessionStorage.getItem("federationState")).toBe("s1");
|
||||||
expect(calls("POST /api/federation/logins")[0].body).toEqual({
|
expect(calls("POST /api/federation/logins")[0].body).toEqual({
|
||||||
instance: "https://music.example",
|
instance: "https://music.example",
|
||||||
});
|
});
|
||||||
@@ -191,7 +193,7 @@ describe("UserLogin", () => {
|
|||||||
);
|
);
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(assign).toHaveBeenLastCalledWith(
|
expect(assign).toHaveBeenLastCalledWith(
|
||||||
"https://films.example/authorize?x",
|
"https://films.example/authorize?state=s1",
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
42
src/utils/auth/federation.ts
Normal file
42
src/utils/auth/federation.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* Ties a federated sign-in to the tab that started it, so that a link to the
|
||||||
|
* callback page made from someone else's sign-in can't sign this browser in
|
||||||
|
* to their account (login CSRF). `FederatedSignIn` keeps the sign-in's state
|
||||||
|
* before leaving for the other instance, in sessionStorage, which outlives
|
||||||
|
* the trip there and back in one tab, and `FederationCallback` finishes only
|
||||||
|
* a sign-in whose state it finds.
|
||||||
|
*
|
||||||
|
* @module
|
||||||
|
*/
|
||||||
|
|
||||||
|
const STATE_KEY = "federationState";
|
||||||
|
|
||||||
|
/** Keeps the state of the sign-in that `authorizeUrl` starts. */
|
||||||
|
export function rememberSignIn(authorizeUrl: string): void {
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(
|
||||||
|
STATE_KEY,
|
||||||
|
new URL(authorizeUrl).searchParams.get("state") ?? "",
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Without sessionStorage, the callback refuses the sign-in.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns whether `state` is that of the sign-in this tab started. */
|
||||||
|
export function startedHere(state: string): boolean {
|
||||||
|
try {
|
||||||
|
return sessionStorage.getItem(STATE_KEY) === state;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Forgets the sign-in this tab started, once it is being finished. */
|
||||||
|
export function forgetSignIn(): void {
|
||||||
|
try {
|
||||||
|
sessionStorage.removeItem(STATE_KEY);
|
||||||
|
} catch {
|
||||||
|
// Nothing was kept.
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user