# Authentication & identity

## The rule: identity is always server-assigned

Who a user _is_ (their `userId`, display name, caret color, role) is
decided **exclusively on the server**. The client's `hello` carries an opaque
`token` and nothing else; presence messages carry only a selection.

The message schemas enforce this: client→server schemas are **strict**, so a
frontend that tries to send `name` or `color` gets a `bad_message` validation
error: it is not a suggestion the server politely ignores, it is a protocol
violation. What peers see comes exclusively from server→client messages:
`welcome.self`, `welcome.peers`, `peer.join`, and server-enriched `presence`
broadcasts. A compromised or mischievous page can therefore never impersonate
"Alice".

```ts
interface Identity {
	userId: string; // stable user id in the host system (or a generated guest id)
	name: string; // display name: server-assigned, never client-sent
	color: string; // caret/selection CSS color
	role: DocRole; // 'owner' | 'writer' | 'commenter' | 'reader'
	guest?: boolean; // true when produced by the anonymous/guest flow
}
```

## The two hooks

A backend plugs authentication and authorization in through two callbacks on
`ServerConfig.auth` (type `AuthOptions` from `@jodit/collab-protocol`):

```ts
interface AuthOptions {
	/** token → identity. Return null to reject. JWT, session lookup, API key: anything. */
	checkAuthentication?: (
		token: string | undefined,
		ctx: AuthContext
	) => Identity | null | Promise<Identity | null>;

	/** Per-document permission check. Default: allow every action of the role. */
	authorize?: (
		identity: Identity,
		docId: string,
		action: DocAction
	) => boolean | Promise<boolean>;

	/** Demo mode: connections without a token get a generated guest identity. */
	allowAnonymous?: boolean;

	/** Override the guest generator (naming scheme, corporate colors…). */
	guestIdentity?: (seed: string) => Identity;

	/** Role assigned to generated guests. Default: 'writer'. */
	guestRole?: DocRole;
}
```

`AuthContext` gives the hook the `docId`, the request `headers`, and the
client `ip`.

The resolution flow (implemented by `resolveIdentity`, used on the WS
handshake and on the REST history endpoint alike):

```text
token present → checkAuthentication → identity | 'auth_failed'
token absent  → allowAnonymous ? guest identity : 'auth_required'
```

> **Warning: Fail-closed defaults**
>
> - No `checkAuthentication` **and** no `allowAnonymous` → every
>   connection is rejected with `auth_required`. You must opt in to either
>   real auth or demo mode.
> - An explicitly presented but **invalid** token is `auth_failed`,
>   **even in anonymous mode**, **when a `checkAuthentication` hook is
>   configured**. That is what stops a broken/expired token from being
>   silently downgraded to guest access. With no hook there is nothing to
>   validate against, so anonymous mode treats any token merely as a seed
>   for the generated guest identity (evaluation only).

## Roles and actions

```ts
type DocRole = 'owner' | 'writer' | 'commenter' | 'reader';
type DocAction = 'read' | 'write' | 'comment' | 'admin';
```

The built-in role model (`can(identity, action)`):

| Role        | read | write | comment | admin |
| ----------- | :--: | :---: | :-----: | :---: |
| `owner`     |  ✓   |   ✓   |    ✓    |   ✓   |
| `writer`    |  ✓   |   ✓   |    ✓    |   -   |
| `commenter` |  ✓   |   -   |    ✓    |   -   |
| `reader`    |  ✓   |   -   |    -    |   -   |

## Per-operation authorization

Every operation is authorized individually; a connection is not "trusted
forever" once joined. The mapping:

| Operation                    | Required action | Additional ownership rule                                            |
| ---------------------------- | --------------- | -------------------------------------------------------------------- |
| `hello` (join), REST history | `read`          | None                                                                 |
| `submit` (document edits)    | `write`         | None                                                                 |
| `comment.add`                | `comment`       | None (you become the thread's author)                                |
| `comment.edit`               | `comment`       | **Author only**: even `owner` cannot rewrite someone else's comment. |
| `comment.reply`              | `comment`       | Anyone with the action.                                              |
| `comment.resolve`            | `comment`       | Anyone with the action (resolve and reopen).                         |
| `comment.delete`             | `comment`       | Author, **or** any user with the `owner` role.                       |

Two consequences worth spelling out:

- a `reader` can watch a live document but can neither type nor comment; a
  `commenter` can discuss but not touch the text (the classic review
  workflow);
- the `owner` role is the **moderator**: an owner can delete _any_ comment
  thread (spam cleanup), but editing a thread body remains author-only, so
  nobody's words can be silently rewritten.

If you provide `auth.authorize`, it replaces the role table for the
_action_ checks (you can still call `can()` inside it and add per-document
ACL on top). The per-thread **ownership** rules are enforced by the server
after the action check and are not overridable; comparison is by the
stable `Identity.userId`, so the same user keeps ownership of their
comments across reconnects and devices.

## What happens when a request is not authorized

The server answers with a structured [`error`](protocol.md#error) whose
`code` and `context` fields tell the client exactly how to recover; see
the [reaction table](protocol.md#the-context-field-how-clients-must-react)
in the protocol spec. Summary:

| Denied operation                                                                            | Error                                                                  | Connection | Reference-client behavior                                                                                                                                                 |
| ------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Join: no/invalid token                                                                      | `auth_required` / `auth_failed`, `context: "auth"`                     | **closed** | The session is over. The automatic reconnect calls the token provider again (see below), so a refreshed token heals it; otherwise `onError` surfaces the failure.         |
| Join: `read` denied                                                                         | `forbidden`, `context: "auth"`                                         | **closed** | Same as above: this user has no access to the document at all.                                                                                                            |
| `submit`: `write` denied (also: content-firewall rejection, rate limit, `base_seq_too_old`) | `forbidden` / `rate_limited` / `base_seq_too_old`, `context: "submit"` | open       | **Auto-resync**: the optimistic local change is discarded and the editor reloads a fresh server snapshot, so the screen never stays desynced from the canonical document. |
| Comment op: `comment` denied or ownership rule violated                                     | `forbidden` (or `comment_not_found`), `context: "comment"`             | open       | A UI notice only; the document is unaffected and the authoritative thread state keeps arriving via `comment.updated`/`comment.deleted` broadcasts.                        |

> **Tip: Disable the UI for read-only roles**
>
> Denial-by-error is the _enforcement_, not the UX. The client learns its
> own server-assigned role in `welcome.self.identity.role`. Use it to
> render read-only editors for `reader`s and to hide comment controls the
> user cannot use. The reference comments panel does exactly this: edit
> and delete buttons appear only when the viewer is the thread's author,
> or (for delete) has the `owner` role. The server enforces the rules
> regardless of what the UI shows.

## Token refresh on reconnect

The `collab.token` option accepts a **string or a function** (sync or async):

```ts
Jodit.make('#editor', {
	collab: {
		url: 'wss://example.com/collab/ws',
		docId: 'my-document',
		// Called on EVERY (re)connect; always return a fresh, unexpired token.
		token: async () => (await fetch('/api/collab-token')).text()
	}
});
```

The function is invoked on every (re)connect, right before `hello` is sent.
So when a JWT expires mid-session and the server drops the connection with
an `auth`-context error, the automatic reconnect obtains a fresh token and
the session resumes (with a `sinceSeq` delta when no local edits were
pending). Pass a plain string only when your tokens outlive any realistic
editing session.

## Anonymous / demo mode

`allowAnonymous: true` (env: `ALLOW_ANONYMOUS=true`; see
[Configuration](configuration.md)) hands every token-less connection a
generated guest identity:

- name like **"Guest Amber Fox"**: adjective + animal picked from a hash of
  the seed `docId:ip:token`, so the same connection keeps its name and color
  on reconnect;
- a color from a fixed 12-color palette;
- `role: guestRole` (default `'writer'`, because a live demo should be
  editable), `guest: true`;
- `userId` like `guest-1a2b3c`.

Override `guestIdentity(seed)` to change the naming scheme or colors, or
`guestRole: 'reader'` for a read-only public demo.

The guest role is also settable from the environment (env `GUEST_ROLE`,
values `owner` | `writer` | `commenter` | `reader`; see
[Configuration](configuration.md)). This is handy for demos:
`GUEST_ROLE=commenter` shows the review workflow (guests can discuss but
not edit), and `GUEST_ROLE=owner` turns every guest into a moderator so the
delete-any-comment flow can be tried without setting up real accounts.

## JWT example

`examples/with-jwt-auth.mjs` in the repository is a complete
production-style setup: no anonymous mode, HS256 JWTs verified server-side,
identity built from the token **claims**. The page cannot invent one.
The core of it:

```js
import { start } from '@jodit/collab-server';

const server = await start({
	port: 8083,
	auth: {
		// No allowAnonymous: connections without a valid JWT are rejected.
		checkAuthentication(token) {
			const claims =
				token && verifyJwtHS256(token, process.env.JWT_SECRET);
			if (!claims || typeof claims.sub !== 'string') {
				return null; // → 'auth_failed'
			}
			return {
				userId: claims.sub,
				name: claims.name ?? claims.sub, // identity comes from claims, not the page
				color: claims.color ?? '#2196f3',
				role: ['owner', 'writer', 'commenter', 'reader'].includes(
					claims.role
				)
					? claims.role
					: 'reader'
			};
		}
		// Optional finer-grained ACL on top of the role:
		// authorize: (identity, docId, action) => myAcl.check(identity.userId, docId, action),
	}
});
```

Run it and mint a test token:

```bash
JWT_SECRET=dev-secret node examples/with-jwt-auth.mjs
```

The frontend passes the token to `CollabClient` (the `token` option accepts a string
or a [provider function](#token-refresh-on-reconnect) for expiring JWTs).
The plugin puts it into the `hello` message, and the display name and role
come back in `welcome.self` from the claims resolved above.

> **Note**
>
> The example implements HS256 verification with `node:crypto` in ~20 lines
> to stay dependency-free; in a real system use your JWT library of choice.
> The contract is only: _token in → `Identity` or `null` out_.
