Standards:GMCP Authentication

From Mudlet
Jump to navigation Jump to search

GMCP Extension for MUD Client Authentication

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

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 keeps a textual, interactive login for the many clients that do not implement this extension. This extension does not ask clients to replicate it; it adds four machine-readable hooks around it: 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.

Versioning

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. That is 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 the version report and echo; Char.Login.Credentials {} as an explicit, immediate hand-off to the server's interactive sign-in; the Char.Login.URL push; 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). The major version bump is warranted because version 2 adds a client→server message and changes the scope of an existing one.

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. token_storage is such a field. What a major version buys is the right to change what an existing message means.

The field encoding rules below are not versioned; they apply equally to a version 1 exchange, since the string form of success predates this document.

Design

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

Field encoding

Several MUD drivers have no JSON boolean in their serializer: LDMud's json_serialize(), for example, maps integers to JSON numbers and offers no true/false. This is a property of the serializer rather than of any particular field, so these rules apply wherever this document declares a field boolean, in both directions.

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. Client scripting layers stringify just as freely, so a server SHOULD likewise accept the client-sent version and token_storage in every form above.

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

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". Both sides MUST therefore distinguish "field absent" from "field present and false" instead of testing the payload for truthiness.

Server-side

Char.Login.Default

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. Some clients read two frames as one and reject the trailing bytes as garbage, so 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. 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 (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 on the game's own sign-in screen (see below).

Char.Login.URL

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; the server SHOULD also print the URL in its own text, 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. 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. The evidence is spent by the open, so one player action authorizes at most one automatic hand-off. Absent fresh evidence, present the URL as a link to click or copy instead.

Char.Login.Token

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. Whether to ask the player first ("stay signed in on this device?") is the server's decision, gathered in its own flow and subject to Remember-me consent below.

  • 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, falling back to the character roster 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 accepts 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 differ 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

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 went wrong, and nothing in the protocol reports it.

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. Only the question can be answered wrongly, and reading an absent token_storage as false would withdraw remember-me from every conformant client deployed before the field existed.

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. A client that sent nothing keeps the latitude it always had: store or discard as it sees fit.

The question belongs to the game. token_storage reports whether the client can keep a token, never whether the player wants one kept; the player's wish is gathered by the game, on its own screen, each time it asks. A client therefore does not answer the question for the player: it sends true whenever it could keep a token, and sends false only while the player has set a standing preference on the client not to be remembered on this device (see Client-side). A one-off local action, such as forgetting a saved sign-in, is not such a preference, and does not stop the game from asking again.

Replay transport. The token is a bearer secret; replayed in cleartext it hands over the account 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. 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

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, in any of the forms described in Field encoding.
  • 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

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: write it to protected storage and replay it on a later connection. A client sends false when it has no protected store available, or while the player has a standing preference not to be remembered on this device for this profile. That preference is a setting, not a one-off action: forgetting a saved sign-in clears what is stored, and the next connection sends true again, so the game can offer to remember the player afresh on its own screen. 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, so it reaches the server before the game's sign-in screen is written. See Remember-me consent for what a server does with it.
Char.Login.Credentials

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, so 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): 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 has learned its provider from the provider field of Char.Login.URL. It 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. The client remembers the provider in the same protected store as the reconnect token. The server treats the value only as the flow to start; the account–provider binding is verified after the browser sign-in completes, exactly as in the interactive flow.

On a cleartext connection the resume form discloses the account name and its identity provider to anyone on the path. These are identifiers, not credentials, comparable to what the game's own interactive login reveals on the same wire; a player or game that considers the pairing sensitive should be on telnets://. 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 may accompany it, so

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

is the same hand-off as {} and MUST be treated identically. It also tells the server, before it prints a line of its sign-in screen, that this player can be offered a remembered device. 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. It is 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 SHOULD wait silently: 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

On a later connection, a client that previously stored a Char.Login.Token replays it to log in without a browser or password, subject to the token's transport requirement (see Char.Login.Token).

  • 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 a server MUST NOT require the field here; a client 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

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, 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

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, so the two sides never race to present competing screens. The one thing the client reports about itself is token_storage, which the server cannot otherwise learn and needs before it words its own screen.

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

Password credentials

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 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.

Where the token earns its keep. The token matters most when the client does not hold the password: the player typed it on the game's screen, or at a client prompt for a password it deliberately does not store. The device is then remembered by a revocable, rotating, device-scoped secret instead of a stored password. A client holding a complete stored pair keeps any token it is sent, but replays it only once those credentials are removed, since stored credentials outrank a token (see Guidance for clients). Both cases arrive as the same Char.Login.Credentials with a password, so a server cannot tell them apart and need not try: for the client with a stored pair, the question is redundant rather than untrue, because the player who answers yes is signed in automatically either way.

Browser sign-in (server-owned screen)

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}
  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 by typing a number or clicking 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)

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. It may rotate the token with a fresh Char.Login.Token, 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)

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 re-establishes the session:

Resuming the remembered provider's browser sign-in with no password and 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; 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. It mints a fresh per-device Char.Login.Token and confirms with Char.Login.Result {"success": true}.

Token lifecycle

  • 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 from more than one client. A server SHOULD hold a small set of concurrent tokens per account rather than a single slot that every new device silently invalidates. Each is minted for one client install, rotated in place, with its own lifetime. The token is the device's identity: opaque, anonymous, and individually revocable, so servers SHOULD NOT fingerprint devices to tell them apart.
  • 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.
  • Local forget: forgetting a saved sign-in on the client removes the client's copy only. This extension has no message that revokes a token, so the server still honours the forgotten token until it expires, is rotated away, or is revoked on the server's side. Single-use rotation and a sensible TTL bound that window; a player who believes a token was exposed revokes it on the game (for example, sign-out-everywhere).
  • 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

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 consent rule on Char.Login.URL, the transport rules on Char.Login.Reconnect and Char.Login.AuthCode, and the rules below are all instances of that principle; a client SHOULD apply it to any situation this document does not enumerate.

  • Over plain telnet:// a reconnect token 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. The default replay rule enforces the half of a server's TLS-only policy that 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.
  • 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".
  • token_storage is not security-bearing in either direction: it says 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 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 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.

Guidance for servers

  • Own the interactive screen, and wait well. Keep your textual sign-in first-class; it is the natural home for provider choice, character creation, and "remember me" consent. Begin it immediately on a hand-off, but treat silence differently: give a client that has said nothing a generous, silent window before taking 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: {"version": 2, "token_storage": true} is a hand-off, not a malformed credential attempt.
  • Ask about remembering only when the client said it would remember, but mint at your own discretion regardless. An absent token_storage is unknown, not false. A true says the client can keep a token, not that the player wants one kept: the asking is yours, and so is the answer.
  • 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, so do not infer that the device lost its token. Mint a fresh one after the sign-in completes, as usual.
  • Dispatch client requests in your advertised order. A reconnect token is not one of the advertised methods and is verified ahead of them. Let incomplete credentials 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. Prefer single-use rotation, set a sensible TTL, and revoke on password change or provider unlink. Mint the account in its character-qualified form so each device's reconnect skips the roster. Decide the token's replay transport when you mint it: a game that accepts reconnects on plain telnet must send secure_only false deliberately rather than get it by accident. Refuse a disqualified token without consuming it, and close the connection after rejecting one rather than falling back to another method.
  • Encode fields for the client, not for your driver. Send JSON booleans where your serializer can produce them; otherwise pick one form and keep to it for the whole connection. Treat a value you cannot decode as absent.
  • Report the negotiated version and gate version 2 messages on the client having negotiated version 2. Send Char.Login.Result as the terminal message of every flow, and reject a version you cannot honour with {"success": false} rather than misparse the payload.

Guidance for clients

  • 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.
  • 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; (2) otherwise, replay a saved reconnect token this connection's transport is allowed to carry; (3) otherwise, with an account and provider remembered from an earlier browser sign-in, send the resume form; (4) otherwise, hand off with Char.Login.Credentials {}. Never let a partial credential 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.
  • 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. The test is not whether your build was compiled with the feature, but whether this profile, on this machine, with this keychain reachable, will keep it. Send false when the store is unavailable or while the player has a standing preference not to be remembered here, and then discard any token that arrives anyway. Do not use false to answer the remember-me question on the player's behalf: that question is the game's to ask. Put it on the first message you send, the hand-off included.
  • 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.
  • Persist reconnect tokens with their provider and transport. Save the account, the token, the provider learned from Char.Login.URL, and the token's transport requirement together in protected storage (a keychain or equivalent, never a plaintext settings file), and overwrite on rotation. Protected means at least as protected as the client keeps the player's own password: the platform keychain, or a store readable only by the player's account where that is where the player has chosen to keep their passwords. Accept a token whenever it arrives, before or after Char.Login.Result and more than once per connection. On a rejected Char.Login.Reconnect, re-read the store first: if the saved token no longer matches the one just sent, another instance of the client sharing the store rotated it, so replay the fresh token instead of discarding. If it does match, discard the token but keep the account and provider, then reconnect. Latch the rejection before any asynchronous store read, so that whichever Char.Login.Default comes next does not replay the token that was just rejected.
  • Forget means all of it, once. A local "forget this sign-in" control clears the account, token, provider and transport requirement from every store the client may have written them to, including one a since-changed storage preference no longer reads. It is a one-off action and does not by itself change token_storage: the next connection answers as it always would, and the game may offer to remember the player again. A client that also offers a standing "don't remember me here" setting sends false while that setting is on. A forget made during a connection is not undone by that connection: a Char.Login.Token rotating the token the connection replayed before the forget is discarded, while a token minted for a sign-in the player completes after the forget is a fresh opt-in and is stored. Forgetting does not end the identity provider's own browser session, so a later browser sign-in may complete without the provider prompting at all; tell the player so if your forget control could be read as signing them out of the provider.
  • Echo the version you are acting on; treat an absent server version as version 1.

Appendix: client-driven OAuth (advanced, optional)

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 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. That request is good for exactly one hand-off: a client MAY auto-open the flow's first authorization URL on a connection without prior input, and no further unprompted hand-off is available on that connection, however many frames the server sends. Every later authorization URL, 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.

Remembering the device: the reconnect token this flow may end with is the same server-minted Char.Login.Token as in the brokered flows, and follows the same consent and forget rules. The client-driven flow gives the client no provider tokens to keep or forget: the ID token and any refresh token stay with the server, and the provider's own browser session, like any other, outlives a local forget.

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 over an unencrypted connection, and a server MUST NOT accept it there. A server MUST advertise location and 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

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, which is 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 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

  • 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.

Status

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.