Standards:GMCP Authentication

From Mudlet
Jump to navigation Jump to search

GMCP Extension for MUD Client Authentication[edit | edit source]

This document defines a GMCP extension that lets MUD clients automate sign-in around the game's own login screen: sending stored credentials, opening browser sign-in links, and reconnecting password-lessly with a server-minted token. Version 2 adds OAuth-based sign-in and the reconnect token to the password login defined by version 1.

Rationale[edit | edit source]

Different MUDs have diverse login command formats, making it challenging for MUD clients to handle login consistently. This extension provides a standardized way to exchange authentication information through GMCP, simplifying client development and improving compatibility.

Two principles shape the design:

  • The game owns the sign-in screen; the client owns automation. Every game must keep a textual, interactive login for the many clients that do not implement this extension, so that screen always exists and is always the most complete presentation of the game's account model — its providers, its character creation, its wording. This extension does not ask clients to replicate any of it. Instead it gives clients four machine-readable hooks around that screen: autofill stored credentials, open a sign-in URL the server pushes, save a reconnect token, and learn the outcome.
  • A minimal client floor. A conformant client needs no OAuth machinery and no sign-in UI of its own. It opens URLs in a browser and stores an opaque string. A first-time sign-in happens once per player; reconnects happen thousands of times — the hooks optimize the common case.

Versioning[edit | edit source]

This extension is negotiated through the module version in Core.Supports.Set / Core.Supports.Add: a client advertises the highest major version it understands, for example Core.Supports.Set ["Char.Login 2", ...].

Because base GMCP negotiation is one-directional, the server reports the version it is speaking back to the client in a version field on Char.Login.Default — the effective negotiated version, min(client's advertised version, server's highest supported version). A client treats the absence of the field as version 1, and SHOULD echo the version it is acting on in a version field on each message it sends, so the server can reject a mismatch cleanly (with Char.Login.Result {"success": false, "message": "Unsupported Char.Login version"}) rather than misread a payload.

  • Version 1 defined password-only login: Char.Login.Default (with type and, for OAuth servers, a location), Char.Login.Credentials, and Char.Login.Result as the response to a credentials attempt.
  • Version 2 (this document) adds four things: the version report/echo; the empty Char.Login.Credentials {} defined as an explicit, immediate hand-off to the server's interactive sign-in; the Char.Login.URL push that lets the server open the player's browser at the right moment; and the password-less reconnect pair, Char.Login.Token / Char.Login.Reconnect. It broadens Char.Login.Result into the terminal message of every flow, and relaxes location from required to optional (it now belongs to the optional client-driven appendix). A new major version is warranted because version 2 adds a client→server message and changes the scope of an existing one; a client must opt in by version before a server uses them.

Backward compatibility. A version 2 server remains interoperable with a version 1 client: it offers password-credentials, omits the version 2 fields and messages, and treats the exchange as version 1. In all cases a client may send Char.Login.Credentials {} to fall back to interactive login.

Additive extensions. A field added to version 2 after publication does not warrant a new major version when a server that ignores it and a client that omits it both behave exactly as this document already describes: there is nothing for either side to opt into, and gating it behind a version would strand implementations that are already correct. token_storage is such a field — a client that never sends it is treated as it was before the field existed, and a server that never reads it keeps minting tokens as it always did. What a major version buys is the right to change what an existing message means; an additive field that changes nothing must not need to buy it.

The field encoding rules below are not versioned. They describe how values of the declared types may appear on the wire, and apply equally to a version 1 exchange — the string form of success predates this document.

Design[edit | edit source]

The extension uses the Char.Login namespace with the following messages.

Field encoding[edit | edit source]

Several MUD drivers have no JSON boolean in their serializer at all — LDMud's json_serialize(), for example, maps integers to JSON numbers and offers no true/false — so a server obliged to report one has nothing correct to send. This is a property of the serializer, not of any particular field, so it applies wherever this document declares a field boolean.

A server MAY send any such field as a JSON boolean, as the string "true" / "false", or as the integer 1 / 0. Clients MUST accept all three forms. Servers SHOULD send a JSON boolean where the serializer can produce one, and SHOULD NOT vary the encoding of a given field from message to message within one connection.

String forms are matched case-insensitively. A value that is none of the forms above is not a boolean: a client MUST treat the field as absent and apply the default this document gives for it, rather than guess.

Absent is not false. Every optional boolean here documents what its absence means, and that default is not always false: secure_only inherits the transport it arrived on, and token_storage means "unknown". A client MUST therefore distinguish "field absent" from "field present and false" instead of testing the payload for truthiness. For a token minted over TLS the two readings differ, and the truthy one silently widens the token's exposure — the exact outcome the field exists to prevent.

The same rules apply in the client→server direction. Client scripting layers stringify freely — a Lua or JavaScript table marshalled into GMCP may render every scalar as a string — so a server SHOULD accept the client-sent fields defined here in every form above: the integer version when it arrives as a string, and the boolean token_storage as a JSON boolean, as "true" / "false", or as 1 / 0. A server that cannot decode a token_storage value MUST treat the field as absent and apply its documented default — unknown, not false — rather than guess.

Server-side[edit | edit source]

Char.Login.Default[edit | edit source]

Sent in response to Core.Supports.Set, or as soon as telnet negotiation completes. It reports the negotiated version and the supported authentication methods.

A server sends it exactly once per connection. A client that advertises support across more than one message — Core.Supports.Set followed by Core.Supports.Add — MUST NOT be answered with a second Char.Login.Default: some clients read the two frames as one and reject the trailing bytes as garbage. The sole exception is the optional re-offer after a rejected attempt described under Char.Login.Reconnect. The obligation binds the server; a client MUST NOT rely on it, and treats an illegal repeat as at most a fresh offer to fold into work already pending — never as a license to repeat side effects (see Security and transport).

  • version (integer, optional; sent by version 2+ servers): the negotiated Char.Login version for this session, min(client's advertised version, server's highest supported version). A positive, non-zero integer. Absent means version 1.
  • type (array of strings, required): the supported method(s), ordered by the server's preference, most-preferred first:
    • password-credentials: traditional username/password login.
    • oauth: sign-in completes through the player's web browser (external identity providers, brokered by the server).
 Char.Login.Default {"version": 2, "type": ["oauth", "password-credentials"]}

A version 1 exchange omits version:

 Char.Login.Default {"type": ["password-credentials"]}

A server that is itself an OpenID Provider may include the additional fields defined in the client-driven appendix on an encrypted connection.

Note: the server does not enumerate its identity providers here, and the client does not choose one over GMCP. The provider choice happens where it always has — on the game's own sign-in screen (see below).

Char.Login.URL[edit | edit source]

Pushed by the server whenever the sign-in reaches a browser step — typically the moment the player picks a provider on the game's own login screen. A capable client opens the URL in the system browser so the player does not have to click or copy anything; the server SHOULD also print the URL in its own text (clickable where the client renders links, copy/paste otherwise), which remains the universal fallback for clients that ignore this message.

  • url (string, required): the fully-formed authorization URL to open in the player's default browser. The server constructs it (provider, scopes, redirect URI, PKCE, etc.); the client performs no OAuth machinery.
  • nonce (string, optional): an opaque, single-use value tying this browser sign-in to the current session, so the server can correlate the provider callback with this connection. Not a usable credential on its own, so this message is safe over plain telnet.
  • provider (string, optional): the id of the provider this URL signs in with (for example discord), letting the client label the handoff ("Opening your browser to sign in with Discord…").
 Char.Login.URL {"url": "https://example.com/oauth/start?provider=discord&nonce=abc123", "nonce": "abc123", "provider": "discord"}

Consent and safety. A client MUST open only http/https URLs from this message, and only in the system browser — never hand other URI schemes to the operating system. Because the message can arrive unprompted, the client SHOULD confirm user intent before auto-opening; a practical test is whether the user has sent input on this connection since the last automatic hand-off (their provider choice on the game's screen is what triggered the push). The evidence is spent by the open: one player action authorizes at most one automatic hand-off, so a burst of pushed URLs buys one tab and the rest are offered as links. Absent fresh evidence, present the URL as a link to click or copy instead — a hostile or misbehaving server can then neither pop a browser at an idle player nor turn a single keystroke into a cascade of tabs.

Char.Login.Token[edit | edit source]

An opaque, server-minted reconnect credential the client stores in its profile for password-less reconnects. The server sends it at its discretion once the session is authenticated, and MAY send it again at any time on that connection, any number of times — typically to rotate the token on each successful reconnect. The most recent token supersedes every earlier one: the client overwrites its stored copy and treats the previous token as dead. No ordering relative to Char.Login.Result is implied — a server may mint the token before the terminal result, after it, or both. Whether to ask the player first ("stay signed in on this device?") is the server's decision, gathered in its own flow — but the question is only worth asking of a client that will keep the answer, which is what Remember-me consent below is about. The client's job is otherwise mechanical: save what arrives, overwrite on rotation, and offer a local way to forget it.

  • account (string, required): the character or account the token authenticates, in the same form as the account field of Char.Login.Credentials (for example myaccount:mycharacter). A server with multi-character accounts SHOULD mint the character-qualified form: the client replays it verbatim, so a later Char.Login.Reconnect can land directly in the character this device last played — a truly screenless reconnect — with the character roster as the fallback whenever the named character no longer belongs to the account.
  • token (string, required): an opaque, server-owned bearer secret. The client stores it verbatim and never interprets it.
  • secure_only (boolean, optional): whether this token may be replayed only over an encrypted transport (telnets:// / TLS). When absent, the token inherits the transport it arrived on: a token issued over an encrypted connection is encrypted-only, and a token issued in the clear may be replayed on either. A server that issues tokens over TLS but is content to accept them on plain telnet says so explicitly with false — silence never widens the token's exposure beyond what the issuing connection already risked. Because absence and false carry different meanings here, a client MUST read this field as described in Field encoding and never by testing the payload for truthiness.
 Char.Login.Token {"account": "myaccount:mycharacter", "token": "opaque-reconnect-token", "secure_only": true}

The identity provider's own access and refresh tokens never reach the client or the wire — only this opaque, server-minted reconnect token does.

Remember-me consent[edit | edit source]

A player who is asked "stay signed in on this device?", answers yes, and is asked to sign in again on the very next connection has been told something untrue by the game: the client never kept the token. Nothing in the sign-in itself went wrong, and nothing in the protocol reports it — the player simply learns that the game's promises do not hold.

A server therefore SHOULD NOT put that question in front of the player unless the client sent token_storage true (see Client-side). Minting is not gated the same way: a server MAY send Char.Login.Token whenever it likes, including to a client that said nothing at all, which is what every version 2 client did before this field existed and remains entirely correct. Only the question needs an affirmative, because only the question can be answered wrongly. A server that reads an absent token_storage as false would stop offering remember-me to the conformant clients deployed today, which is why absence means unknown.

The obligation runs both ways. A client that sent token_storage false and then receives a Char.Login.Token discards it rather than storing it: having declined, it does not quietly change its mind, and a server that mints anyway is not thereby told the token survived. A client that sent nothing keeps the latitude it always had — store or discard as it sees fit — which is exactly what the absent value preserves.

Replay transport. The token is a bearer secret: replayed in cleartext it hands the account to anyone on the path, without the player's password ever being involved. A client MUST NOT send Char.Login.Reconnect carrying a token whose transport requirement the current connection does not meet. It stores the requirement alongside the token, skips the token when the transport disqualifies it, and continues down its ladder — the resume form or the interactive hand-off — exactly as if no token were saved; to the server this is indistinguishable from a client with nothing stored, so no protocol accommodation is needed. The client SHOULD tell its player why the saved sign-in was not used, and MUST NOT discard the token, which remains valid for the next connection on an adequate transport.

Char.Login.Result[edit | edit source]

The terminal message of every authentication flow — password, browser sign-in, and reconnect. The server sends it once the outcome is known.

  • success (boolean, required): whether authentication succeeded. May arrive in any of the forms described in Field encoding — a JSON boolean, the string "true" / "false", or the integer 1 / 0 — all of which clients MUST accept. The string form is not merely legacy: several MUD drivers have no JSON boolean in their serializer at all.
  • message (string, required when success is false): a human-readable explanation, such as "Invalid credentials" or "Reconnect token expired".
 Char.Login.Result {"success": true}
 Char.Login.Result {"success": false, "message": "Invalid credentials"}

A rejected sign-in or reconnect token is reported this way and never falls through to a password or password-creation prompt.

Client-side[edit | edit source]

Every client→server message in this extension MAY carry these two common fields:

  • version (integer, optional): the version echoed from Char.Login.Default; a version 2 client SHOULD include it.
  • token_storage (boolean, optional): whether the client will keep a reconnect token if the server mints one on this connection. true means a Char.Login.Token arriving on this connection will be written to protected storage and replayed on a later one. A client sends false when it has no protected store available, or when the player has turned remembering off for this profile — capability alone is not the question, because a client that receives a token and discards it is indistinguishable, to the player, from one that was never sent a token at all. Absent means unknown: a server MUST treat it as the version 2 behaviour that predates this field, never as false. A client SHOULD send the field on the first message it sends, which is what makes it available before the game's sign-in screen begins. See Remember-me consent for what a server does with it.
Char.Login.Credentials[edit | edit source]

After receiving Char.Login.Default with type including password-credentials, a client holding stored credentials sends them. A complete stored pair is the player's explicit choice of sign-in method: it is the client's default, taking precedence over a saved reconnect token (see Guidance for clients). A partial pair — a password without a name, or vice versa — is never sent and never blocks the other methods.

  • account (string, required): character name or player account name. For games that implement both, the character name follows a colon (:), for example myaccount:mycharacter.
  • password (string, required): the password. Servers are encouraged to implement TLS over telnet so passwords transit securely.
  • version (integer, optional): the negotiated version the client is acting on.
  • token_storage (boolean, optional): whether a minted token would be kept, as defined under Client-side. This is the message that carries it in a password sign-in, and so the message that decides whether the game may offer to remember the player.

Resume form. A client that previously completed a browser sign-in — and therefore learned its provider from the provider field of Char.Login.URL — may instead send the account with that remembered provider and no password:

 Char.Login.Credentials {"account": "myaccount:mycharacter", "provider": "discord", "version": 2, "token_storage": true}

This asks the server to restart the browser sign-in for that provider directly, replying with Char.Login.URL, so a player whose reconnect token has expired or been revoked is never sent back to a provider menu to answer "which provider did I use here?". The client remembers the provider on the player's behalf, in the same protected store as the reconnect token. To the server it discloses nothing the server did not itself send on this connection's predecessors; the server treats the value only as the flow to start (account–provider binding is verified after the browser sign-in completes, exactly as in the interactive flow, so the resume form confirms nothing about which provider an account uses). To anyone on the path of a cleartext connection it discloses the account name and its identity provider — identifiers, not credentials, comparable to what the game's own interactive login reveals on the same wire; this design treats the pairing as non-secret, and a player or game that considers it sensitive should be on telnets://, as for everything else it values.

A worked example is given in the resume flow.

A client with nothing to send — or one that simply prefers the game's own screen — hands off with an empty object:

 Char.Login.Credentials {}

This is the explicit interactive hand-off: "the user will sign in on your screen — proceed with your interactive login now." It may be sent in response to any advertised method. A server that receives it SHOULD begin its interactive sign-in immediately rather than wait longer; a client that holds no stored token, provider, or credentials SHOULD send it promptly after Char.Login.Default.

Identifying the hand-off. A server recognises the hand-off by the absence of account, not by the literal emptiness of the object. The common fields of the Client-side preamble may accompany it, so

 Char.Login.Credentials {"version": 2, "token_storage": true}

is the same hand-off as {} and MUST be treated identically — while telling the server, before it prints a single line of its sign-in screen, that this player can be offered a remembered device. (The ambiguity is not new with token_storage: version could already ride on the hand-off in version 2. It is worth stating plainly, because a server that tests for an empty object misreads both.)

Silence is not a hand-off. A client that advertised Char.Login but has sent nothing yet may be gathering credentials — typically prompting the player for a password it deliberately does not store, a version 1 behaviour that remains fully valid. The server SHOULD wait a generous window (long enough for a player to type) before falling back to its interactive screen, and waiting silently is correct: the player is looking at their client's prompt, not the game's. Only the explicit {} — or a completed attempt — ends the wait early.

Char.Login.Reconnect[edit | edit source]

On a later connection, a client that previously stored a Char.Login.Token replays it to log in without a browser or password. The replay is subject to the token's transport requirement (see Char.Login.Token): a token the current connection may not carry is skipped, not sent:

  • account (string, required): the saved account.
  • token (string, required): the saved opaque token.
  • version (integer, optional): the negotiated version the client is acting on.
  • token_storage (boolean, optional): as defined under Client-side. A client replaying a token evidently stores them, so the field is redundant here and a server MUST NOT require it; a client that sends it uniformly on every message is not in error, and one that has since lost its protected store says so with false, sparing the server a pointless rotation.
 Char.Login.Reconnect {"account": "myaccount:mycharacter", "token": "opaque-reconnect-token"}

The server replies with Char.Login.Result. On success it may rotate the token by sending a fresh Char.Login.Token.

After a rejection. A server SHOULD close the connection once it has sent Char.Login.Result {"success": false} for a reconnect attempt. The client discards the dead token, keeps the account and provider, and reconnects — the fresh connection brings a fresh Char.Login.Default, which is where the next attempt begins. A client MUST NOT replay a token that was rejected on this connection. (Replaying a different token is permitted — see the shared-store rotation case in Guidance for clients.) A server MAY instead hold the connection open and re-send Char.Login.Default to reopen negotiation; this is the one case where a second Char.Login.Default is legal, and a client MUST treat it as a new attempt rather than a protocol error. A rejected token never falls through to a password path.

Messages at a glance[edit | edit source]

Direction Message Payload Purpose
Server → Client Char.Login.Default {"version"?: <int>, "type": [...]} report negotiated version and supported methods
Client → Server Char.Login.Credentials {"account": "<id>", "password": "<secret>"}, {"account": "<id>", "provider": "<id>"}, or {} — each plus the common fields autofill stored credentials, resume the remembered provider's browser sign-in, or hand off to the game's screen
Server → Client Char.Login.URL {"url": "<authorize-url>", "nonce"?: "<id>", "provider"?: "<id>"} open this in the system browser
Server → Client Char.Login.Token {"account": "<id>", "token": "<opaque>", "secure_only"?: <bool>} a reconnect credential to save in the profile
Client → Server Char.Login.Reconnect {"account": "<id>", "token": "<opaque>"} plus the common fields replay the saved token to log in with no screen at all
Server → Client Char.Login.Result {"success": <bool>, "message"?: "<text>"} terminal status of any flow

The common fields are the two every client→server message may carry: "version"?: <int> and "token_storage"?: <bool> (see Client-side). A <bool> above is any of the forms allowed by Field encoding — a JSON boolean, "true"/"false", or 1/0 — and an optional one carries its own documented meaning when absent, which is not always false.

(The optional client-driven appendix adds fields to Char.Login.Default and one client→server message, Char.Login.AuthCode.)

Who owns the sign-in screen[edit | edit source]

The server presents all sign-in choices — which providers exist, in what order, with what wording — on its own interactive screen, exactly as it does for clients without this extension. Plain numbered menus work everywhere; games may enhance them with clickable links (for example OSC 8 hyperlinks) where the connecting client supports them. Those presentation niceties are negotiated out of band and are orthogonal to this extension.

The client contributes what only it can do, silently: replay a saved token, autofill stored credentials, resume a remembered provider, open a pushed URL, save a fresh token, and act on the result. It renders no sign-in UI of its own, which also means the two sides never race to present competing screens: a version 2 client either sends a message promptly or hands off promptly. The server still allows for the client that does neither — it may be prompting the player for an unsaved password, version 1 style — by waiting a generous window before its interactive screen takes over (see Char.Login.Credentials).

The one thing the client tells the server about itself is token_storage — not a preference, and not a UI decision, but a fact the server cannot otherwise learn and needs before it words its own screen. Everything the player then sees about staying signed in remains the game's to write.

The resulting first-connection experience on a capable client: the game's login screen appears immediately; the player picks a provider there; the browser opens by itself; they authorize; the server logs them in and mints a token. Every connection after that is instant and screenless via Char.Login.Reconnect.

Flows[edit | edit source]

Password credentials[edit | edit source]

Password credentials sign-in sequence.
  1. Client connects and sends: Core.Supports.Set ["Char.Login 2", ...]
  2. Server responds with: Char.Login.Default {"version": 2, "type": ["password-credentials"]}
  3. Client sends: Char.Login.Credentials {"account": "username", "password": "password", "version": 2, "token_storage": true}
  4. Server validates and replies with: Char.Login.Result {"success": true}

A version 1 client uses this same flow without the version and token_storage fields.

Remembering the device. Because the client said it would keep a token, the server may ask on its own screen, in its own words, whether the player wants to stay signed in, and on a yes send Char.Login.Token {"account": "myaccount:mycharacter", "token": "opaque-reconnect-token", "secure_only": true} for the client to save. Every later connection is then the screenless reconnect below, with no password crossing the wire at all. Had the client sent token_storage false, the server would have skipped the question and left the password login as the complete sign-in it already is; had the client sent nothing, the server may still mint a token unasked, exactly as before this field existed.

Browser sign-in (server-owned screen)[edit | edit source]

Browser sign-in on the server-owned screen.
  1. Client connects and sends: Core.Supports.Set ["Char.Login 2", ...]
  2. Server responds with: Char.Login.Default {"version": 2, "type": ["oauth", "password-credentials"]}
  3. Holding no saved token or credentials, the client hands off immediately: Char.Login.Credentials {"version": 2, "token_storage": true} — the hand-off, since it names no account, and a statement that a minted token would survive
  4. The server begins its normal interactive login at once, printing its own sign-in menu.
  5. The player picks a provider on the game's screen — types a number or clicks a link.
  6. The server pushes Char.Login.URL {"url": "https://example.com/oauth/start?provider=discord&nonce=abc123", "nonce": "abc123", "provider": "discord"} and also prints the URL in its own text.
  7. The player has sent input this connection (their menu choice), so the client auto-opens the URL; the player signs in and consents at the provider.
  8. The provider redirects back to the server, which exchanges the authorization code server-side and authenticates the session. Nothing secret crossed the GMCP connection.
  9. The server mints a reconnect credential: Char.Login.Token {"account": "myaccount:mycharacter", "token": "opaque-reconnect-token"}; the client saves it.
  10. Server confirms with: Char.Login.Result {"success": true}

Later reconnect (password-less, screenless)[edit | edit source]

Password-less reconnect using a saved token.
  1. Client connects and sends: Core.Supports.Set ["Char.Login 2", ...]
  2. Server responds with: Char.Login.Default {"version": 2, "type": ["oauth", "password-credentials"]}
  3. Having a saved token (and no stored credentials, which take precedence), the client sends: Char.Login.Reconnect {"account": "myaccount:mycharacter", "token": "opaque-reconnect-token", "version": 2}
  4. On success: the server connects the player — directly into the character the token names, when the account still owns it — may rotate the token with a fresh Char.Login.Token (the client overwrites its copy), then confirms with Char.Login.Result {"success": true}.
  5. On failure: the server sends Char.Login.Result {"success": false, "message": "Reconnect token expired"} and closes the connection; the client drops only the dead token, keeps the account and provider, and reconnects, continuing with the resume flow below (or the interactive hand-off when no provider is remembered).

Resume (browser sign-in, no menu)[edit | edit source]

When the reconnect token has expired or been revoked but the client still remembers the account and provider from an earlier sign-in, one browser visit — and nothing else — re-establishes the session:

Resuming the remembered provider's browser sign-in — no password, no provider menu.
  1. Client connects and sends: Core.Supports.Set ["Char.Login 2", ...]
  2. Server responds with: Char.Login.Default {"version": 2, "type": ["oauth", "password-credentials"]}
  3. Holding no usable token but remembering the account and provider, the client sends the resume form: Char.Login.Credentials {"account": "myaccount:mycharacter", "provider": "discord", "version": 2, "token_storage": true}
  4. The server starts that provider's browser flow directly and pushes Char.Login.URL {"url": "...", "nonce": "...", "provider": "discord"} — no provider menu appears.
  5. The player authorizes in the browser (typically one click — the provider session usually still exists); the provider redirects back to the server, which validates the account–provider binding.
  6. The server connects the player — directly into the character named by the account hint, when the account still owns it — mints a fresh per-device Char.Login.Token, and confirms with Char.Login.Result {"success": true}.

Token lifecycle[edit | edit source]

  • Rotation (single-use): on each successful reconnect the server may issue a fresh token and invalidate the one just used, shrinking the replay window of any captured token to near zero.
  • One token per device, several per account: a player signs in from more than one machine (and more than one client). A server SHOULD hold a small set of concurrent tokens per account — each minted for one client install, rotated in place, with its own lifetime — rather than a single slot that every new device silently invalidates, which otherwise forces a browser re-pairing on every device switch. The token itself is the device's identity: opaque, anonymous, and individually revocable. Servers SHOULD NOT fingerprint devices to tell them apart — the token already does that without collecting anything about the player's hardware.
  • Revocation: the server may revoke one token or all of them at any time — on password change, provider unlink, or an explicit sign-out-everywhere. The next reconnect fails cleanly and the client falls back to the resume form or the hand-off.
  • No password fallback: a rejected token is always reported via Char.Login.Result {"success": false}, never routed into a password or password-creation prompt.

Security and transport[edit | edit source]

The server is untrusted. A MUD client connects to arbitrary servers of the player's choosing, so every server→client message in this extension must be safe to receive from a hostile peer. Nothing a server sends may, by itself, spend the player's secrets on a weaker transport than they were earned on, launch the player's browser without their intent, or consume the client machine's resources in proportion to how much the server transmits. The rules in this section, the consent rule on Char.Login.URL, and the transport rules on Char.Login.Reconnect and Char.Login.AuthCode are all instances of that principle; a client SHOULD apply it to any situation this document does not enumerate.

  • The reconnect token is an opaque, server-owned bearer secret carried in its own message, never conflated with the game password. Over plain telnet:// it travels in cleartext like a password — a long-standing MUD reality; single-use rotation, a sensible TTL, and a login-only scope limit the risk. A server may choose to issue and accept tokens only over telnets://, and the default replay rule enforces the half of that choice the server cannot see: a token issued over an encrypted connection is never replayed in the clear unless the server explicitly allowed it with secure_only false (see Char.Login.Token).
  • A malformed or unrecognised value in a security-bearing field is not a licence to pick the permissive reading. A client that cannot decode secure_only MUST fall back to that field's documented default — inherit the issuing transport — and never to "no requirement"; see Field encoding.
  • token_storage is not security-bearing in either direction: it is a client's statement about its own storage, it carries nothing about the player, and a hostile server learns only whether a token it mints would be kept. A client that would rather not say may omit it and lose nothing but the remember-me offer.
  • In the browser sign-in, only the URL and an opaque nonce cross the GMCP connection; the authorization code is exchanged out of band over HTTPS. The flow is therefore safe over plain telnet.
  • Clients MUST validate that any Char.Login.URL is http/https before opening it, and SHOULD gate auto-opening on evidence of user intent (input sent this connection), consuming that evidence with each automatic open so one action never authorizes more than one hand-off.
  • Clients SHOULD treat credentials, tokens, and any buffers holding them as secrets: clear them from memory once sent or stored.
  • A client's costly side effects — credential-store reads, browser hand-offs, network requests — MUST be bounded by the player's actions and by wall clock, never by the number of frames the server sends. The obligations this document places on servers (for example, Char.Login.Default exactly once per connection) do not defend the client, because a hostile server ignores them: a client folds a burst of identical or repeated messages into one pending response (say, at most one sign-in attempt per second) and drops deferred work whose connection has since closed, rather than trusting the server's discipline.

Guidance for servers[edit | edit source]

  • Own the interactive screen, and wait well. Keep your textual sign-in first-class — most connecting clients have nothing else, and it is the natural home for provider choice, character creation, and "remember me" consent. Treat Char.Login.Credentials with no account as "proceed interactively now" and begin your normal login immediately — presenting the interactive welcome exactly once however the hand-off arrives. But treat silence differently: a client that advertised Char.Login and has said nothing may be prompting its player for an unsaved password (valid since version 1), so give it a generous window — silently, since the player is at their client's prompt — before the interactive screen takes over. A GMCP client that never advertised Char.Login needs no wait at all.
  • Recognise the hand-off by the missing account, not by an empty object. The common fields may ride on it, so {"version": 2, "token_storage": true} is a hand-off, not a malformed credential attempt. A server that tests payload == {} will fall back to its silence timer against clients that told it everything it needed.
  • Ask about remembering only when the client said it would remember. Gate the "stay signed in on this device?" question on token_storage true; a yes from a player whose client will drop the token is a promise you cannot keep. Do not gate minting the same way — an absent token_storage means unknown, and reading it as false would withdraw remember-me from every conformant client deployed before the field existed. Mint at your discretion as you always have, and ask only when asking is honest.
  • A missing reconnect is not a missing client. A client holding a token it may not replay on this transport behaves exactly like one holding nothing: it sends the resume form or the interactive hand-off. Do not infer from the absence of Char.Login.Reconnect that the device lost its token — mint a fresh one after the sign-in completes, as usual, on whatever transport the new token should be scoped to.
  • Dispatch client requests in your advertised order. When more than one Char.Login request could apply, process them in the same preference order you advertised in type; a reconnect token is not one of the advertised methods and is verified ahead of them. Let incomplete credentials (a missing account or password) fall through to the next method rather than dead-ending the sign-in, scrubbing any partial secret either way.
  • Push the URL when the browser step arrives, with the provider label, and still print it in your own text so every other client can click or copy it.
  • Mint and manage tokens deliberately. Issue Char.Login.Token after a successful sign-in regardless of which screen drove it; prefer single-use rotation; set a sensible TTL; revoke on password change or provider unlink. Hold a small set of tokens per account — one per client install, rotated in place — so a player's other devices stay signed in, and never fingerprint devices to tell them apart: the token already is the device's identity. Mint the account in its character-qualified form (myaccount:mycharacter) so each device's reconnect goes straight into the character it last played, skipping the roster. Gather any "remember me" consent in your own flow, subject to the gate above; clients treat token receipt as your instruction to save. Decide the token's replay transport when you mint it: absent secure_only, a token issued over TLS is encrypted-only, so a game that accepts reconnects on plain telnet must send secure_only false deliberately rather than get it by accident. If you enforce a transport requirement, refuse a disqualified token without consuming it — it is still valid on the transport it was issued for. Close the connection after rejecting a reconnect token rather than falling back to another method on it.
  • Encode fields for the client, not for your driver. Send booleans as JSON booleans where your serializer can produce them, and otherwise pick one of the forms in Field encoding and keep to it for the whole connection. Accept a client's version and token_storage in every form that section allows, and treat a value you cannot decode as absent.
  • Report the negotiated version in Char.Login.Default and gate version 2 messages on the client having negotiated version 2.
  • Send Char.Login.Result as the terminal message of every flow. If a client echoes a version you cannot honour, reject with {"success": false} rather than misparse the payload.

Guidance for clients[edit | edit source]

  • A minimal client is fully conformant with: handle Char.Login.Result; open Char.Login.URL (with the safety checks above); send Char.Login.Credentials {} when you have nothing stored. Saving Char.Login.Token and replaying it with Char.Login.Reconnect is the single highest-value addition — it makes every later connect instant.
  • Decide promptly and deterministically on Char.Login.Default, in this order: (1) when the game offers password-credentials and the profile stores a complete character name and password, autofill Char.Login.Credentials — storing them is the player's explicit choice of sign-in method, so it is the default and no separate setting is needed; (2) otherwise, replay a saved reconnect token the current connection's transport is allowed to carry (see Char.Login.Token); (3) otherwise, with an account and provider remembered from an earlier browser sign-in, send the resume form of Char.Login.Credentials; (4) otherwise, hand off with Char.Login.Credentials {} and let the game's screen drive. Never let a partial credential (a password without a name, or vice versa) block the later rungs. Stored credentials outrank a saved token because they name the exact character the player wants to play, whereas the token names whatever account last signed in; the token serves the password-less profile.
  • Answer token_storage honestly, per connection, and answer it early. Send true only when a token arriving now would actually be written to protected storage — not when your build was compiled with the feature, but when this profile, on this machine, with this keychain reachable, will keep it. Send false when the store is unavailable or the player has asked not to be remembered here, and then discard any token that arrives anyway. Omit the field only if you would rather not answer; you forfeit the game's remember-me offer, not correctness. Put it on the first message you send — the hand-off included — because the server's screen is written before your second message exists.
  • Decode declared booleans in every allowed form, per Field encoding, and keep "absent" distinct from "false" in your parser rather than collapsing the payload to a truthy test. secure_only is the field that punishes the shortcut: missing means "inherit the issuing transport", which for a TLS-minted token is the strict answer, not the lax one. Treat any value you cannot decode as absent.
  • Persist reconnect tokens with their provider and transport: on Char.Login.Token, save the account, the token, the provider learned from Char.Login.URL, and the token's transport requirement (secure_only, defaulted from whether the issuing connection was encrypted), together, in protected storage (a keychain or equivalent — never a plaintext settings file), and overwrite on rotation. Accept it whenever it arrives — before or after Char.Login.Result, and more than once on the same connection. On a rejected Char.Login.Reconnect, first re-read the store: if the saved token no longer matches the one just sent, another running instance of the client sharing the store rotated it — replay the fresh token instead of discarding. If it does match, the token is truly dead: discard the token but keep the account and provider, then reconnect — the next attempt begins at the fresh connection's Char.Login.Default and can use the resume form instead of sending the player back to a provider menu. Latch the rejection before any asynchronous store read, so that whichever Char.Login.Default comes next — on the new connection, or re-offered by a server that held this one open — does not replay the token that was just rejected. A local "forget this sign-in" control clears all of it, and switches token_storage to false for as long as it stays off.
  • Echo the version you are acting on; treat an absent server version as version 1.

Appendix: client-driven OAuth (advanced, optional)[edit | edit source]

A server that is itself an OpenID Provider may additionally let an OIDC-capable client run the authorization request end to end — useful when the client can offer a smoother native handoff than the brokered flow. This appendix is entirely optional in both directions: servers that broker external providers omit it, and clients that do not implement it ignore its fields and use the flows above.

Additional Char.Login.Default fields (sent only over an encrypted transport, telnets:// / TLS):

  • location (string): the server's OpenID Connect Discovery document (.well-known/openid-configuration).
  • client_id (string): a public OAuth client identifier; PKCE protects it, no secret involved.
  • scopes (array of strings, optional): scopes to request; defaults to ["openid"].
  • nonce_required (boolean, optional): when true, the client SHOULD include an OpenID Connect nonce in the authorization request. The name is deliberately distinct from the string nonce carried by Char.Login.URL and Char.Login.AuthCode: no key in this namespace should hold two types, or a client decoding by field name would read an opaque nonce string as a boolean true.

Char.Login.AuthCode (client → server): after capturing the authorization code at its loopback redirect, the client submits it; the server performs the token exchange and validation, so provider tokens never reach the client.

  • code (string, required): the captured authorization code.
  • code_verifier (string, required): the PKCE verifier matching the code_challenge sent in the authorization request.
  • redirect_uri (string, required): the redirect URI used in the authorization request, replayed verbatim in the token exchange.
  • nonce (string, required when the server advertised nonce_required true): the OpenID Connect nonce the client placed in the authorization request. The server MUST verify the ID token's nonce claim against this value during the token exchange and reject the sign-in on a mismatch; without this field the nonce_required advertisement would ask the client to generate a value that no verifying party ever learns.
  • version (integer, optional): the negotiated version.
  • token_storage (boolean, optional): as defined under Client-side. A client that reaches this flow has usually said so already on an earlier message; repeating it here is harmless.
Client-driven OAuth against the game's own OpenID Provider.

Flow: the client fetches location to find the authorization_endpoint; generates a PKCE verifier with an S256 challenge (REQUIRED) and a random state; starts a loopback listener (RFC 8252; RECOMMENDED) as the redirect URI; opens the browser to the authorization endpoint; verifies state on the redirect and captures code; sends Char.Login.AuthCode. The server exchanges the code at its token_endpoint, validates the ID token — including its nonce claim, when one was requested — authenticates the session, may issue Char.Login.Token, and confirms with Char.Login.Result.

Consent: This flow starts from fields on Char.Login.Default, which arrives before the player has sent anything, so the input test of Consent and safety cannot yet be met. Connecting to a game whose sign-in offer includes these fields is itself the request to sign in — once: a client MAY auto-open the flow's first authorization URL on a connection without prior input, and this once-per-connection allowance is the only unprompted hand-off the connection gets, however many frames the server sends. Every later authorization URL on the same connection — from this flow or from Char.Login.URL — needs fresh input, exactly as in the server-driven flow. A client that cannot open the URL, or declines to, presents it as a link to click or copy; the loopback listener stays up either way, so the sign-in still completes by hand.

Transport requirement: Char.Login.AuthCode carries the authorization code and PKCE verifier together, which on a cleartext connection would let an eavesdropper redeem the code at the provider. A client MUST NOT send it — and a server MUST NOT accept it — over an unencrypted connection, and a server MUST advertise location/client_id only over TLS so no client ever begins the flow in the clear.

Fit: only genuinely public OIDC providers qualify (a self-hosted OP, or the game itself). External providers with no discovery document, confidential secrets, or non-OAuth identity systems are reachable only through the server's own brokered screen, which the base flows above already cover.

Reference implementation[edit | edit source]

A working server-side implementation exists in StickMUD: the interactive hand-off, the pushed Char.Login.URL against real providers (Discord, GitHub, Google, Microsoft, Twitch, Facebook and Steam), Char.Login.Token issuance from the game's own screen with secure_only scoped to the issuing transport and enforced on replay, Char.Login.Reconnect with rotation, and revocation on password change, provider unlink, and sign-out-everywhere. Its driver has no JSON boolean, so it sends booleans in string form — the case the encoding rules above exist for. Client-side, Mudlet implements version 2 including the reconnect flow and the client-driven appendix.

The OAuth half of that server is packaged separately as mudauth (MIT; source), a game-agnostic broker any MUD can run. It is not itself a Char.Login implementation — it owns only the provider round-trip, handing the game a verified (provider, provider_id, username) exactly once, and leaves accounts, characters and every GMCP message in this document on the game's side. It supports Discord, GitHub, Google, Microsoft, Apple, Twitch, Facebook and Steam, is Python 3.11+ standard library only, keeps no database, and listens on loopback so the game originates every request and never accepts an inbound connection. A game adopting it is left to implement the messages above and none of OAuth.

Implementation considerations[edit | edit source]

  • Implement this extension according to the GMCP protocol specifications.
  • Messages must be well-formed JSON objects.
  • Encode field values as Field encoding describes, and parse incoming values by that rule rather than by your language's truthiness.
  • Servers must validate incoming authentication information securely.
  • Clients should handle errors and unexpected server responses gracefully.

Conclusion[edit | edit source]

This GMCP extension keeps the game in charge of its own sign-in screen while giving clients the hooks to automate around it — culminating in password-less, screenless reconnects. The result is a floor low enough for every client and a ceiling high enough for OAuth-based sign-in, without burdening either side with the other's presentation.

Note: This document describes version 2 of the Char.Login extension. Version 1 (password-credentials only) remains valid and is what a server offers to clients that negotiate Char.Login 1. This is a draft proposal, and further community input and refinement may be necessary before finalizing the specification.