Show server auth policy limits in client auth UI

This commit is contained in:
Jage9
2026-02-24 22:35:29 -05:00
parent f26e1f3c27
commit 06d5e3fbf3
10 changed files with 154 additions and 13 deletions

View File

@@ -19,6 +19,7 @@
<label for="authPassword">Password</label> <label for="authPassword">Password</label>
<input id="authPassword" type="password" maxlength="64" autocomplete="current-password" /> <input id="authPassword" type="password" maxlength="64" autocomplete="current-password" />
</div> </div>
<p id="authPolicyHintLogin" class="auth-hint"></p>
<button id="showRegisterButton" type="button">Create account</button> <button id="showRegisterButton" type="button">Create account</button>
</section> </section>
<section id="registerView" class="auth-panel hidden"> <section id="registerView" class="auth-panel hidden">
@@ -35,6 +36,7 @@
<label for="registerEmail">Email (optional)</label> <label for="registerEmail">Email (optional)</label>
<input id="registerEmail" type="email" maxlength="320" autocomplete="email" /> <input id="registerEmail" type="email" maxlength="320" autocomplete="email" />
</div> </div>
<p id="authPolicyHintRegister" class="auth-hint"></p>
<button id="showLoginButton" type="button">Back to login</button> <button id="showLoginButton" type="button">Back to login</button>
</section> </section>
<div class="controls" id="button-container"> <div class="controls" id="button-container">

View File

@@ -1,5 +1,5 @@
// Maintainer-controlled web client version. // Maintainer-controlled web client version.
// Format: YYYY.MM.DD Rn (example: 2026.02.20 R2) // Format: YYYY.MM.DD Rn (example: 2026.02.20 R2)
window.CHGRID_WEB_VERSION = "2026.02.25 R244"; window.CHGRID_WEB_VERSION = "2026.02.25 R245";
// Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid. // Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid.
window.CHGRID_TIME_ZONE = "America/Detroit"; window.CHGRID_TIME_ZONE = "America/Detroit";

View File

@@ -79,6 +79,7 @@ const RECONNECT_MAX_ATTEMPTS = 3;
const AUDIO_SUBSCRIPTION_REFRESH_MS = 500; const AUDIO_SUBSCRIPTION_REFRESH_MS = 500;
const TELEPORT_SQUARES_PER_SECOND = 20; const TELEPORT_SQUARES_PER_SECOND = 20;
const TELEPORT_SYNC_INTERVAL_MS = 100; const TELEPORT_SYNC_INTERVAL_MS = 100;
const AUTH_POLICY_STORAGE_KEY = 'chgridAuthPolicy';
declare global { declare global {
interface Window { interface Window {
@@ -94,9 +95,11 @@ type Dom = {
registerView: HTMLElement; registerView: HTMLElement;
authUsername: HTMLInputElement; authUsername: HTMLInputElement;
authPassword: HTMLInputElement; authPassword: HTMLInputElement;
authPolicyHintLogin: HTMLParagraphElement;
registerUsername: HTMLInputElement; registerUsername: HTMLInputElement;
registerPassword: HTMLInputElement; registerPassword: HTMLInputElement;
registerEmail: HTMLInputElement; registerEmail: HTMLInputElement;
authPolicyHintRegister: HTMLParagraphElement;
showRegisterButton: HTMLButtonElement; showRegisterButton: HTMLButtonElement;
showLoginButton: HTMLButtonElement; showLoginButton: HTMLButtonElement;
updatesSection: HTMLElement; updatesSection: HTMLElement;
@@ -125,9 +128,11 @@ const dom: Dom = {
registerView: requiredById('registerView'), registerView: requiredById('registerView'),
authUsername: requiredById('authUsername'), authUsername: requiredById('authUsername'),
authPassword: requiredById('authPassword'), authPassword: requiredById('authPassword'),
authPolicyHintLogin: requiredById('authPolicyHintLogin'),
registerUsername: requiredById('registerUsername'), registerUsername: requiredById('registerUsername'),
registerPassword: requiredById('registerPassword'), registerPassword: requiredById('registerPassword'),
registerEmail: requiredById('registerEmail'), registerEmail: requiredById('registerEmail'),
authPolicyHintRegister: requiredById('authPolicyHintRegister'),
showRegisterButton: requiredById('showRegisterButton'), showRegisterButton: requiredById('showRegisterButton'),
showLoginButton: requiredById('showLoginButton'), showLoginButton: requiredById('showLoginButton'),
updatesSection: requiredById('updatesSection'), updatesSection: requiredById('updatesSection'),
@@ -172,6 +177,13 @@ type HelpData = {
sections: HelpSection[]; sections: HelpSection[];
}; };
type AuthPolicy = {
usernameMinLength: number;
usernameMaxLength: number;
passwordMinLength: number;
passwordMaxLength: number;
};
/** Builds linearized help-view lines from sectioned help content. */ /** Builds linearized help-view lines from sectioned help content. */
function buildHelpLines(help: HelpData): string[] { function buildHelpLines(help: HelpData): string[] {
const lines: string[] = []; const lines: string[] = [];
@@ -222,6 +234,7 @@ let outputMode = settings.loadOutputMode();
let authMode: 'login' | 'register' = 'login'; let authMode: 'login' | 'register' = 'login';
let authSessionToken = settings.loadAuthSessionToken(); let authSessionToken = settings.loadAuthSessionToken();
let authUsername = settings.loadAuthUsername(); let authUsername = settings.loadAuthUsername();
let authPolicy: AuthPolicy | null = null;
let pendingAuthRequest = false; let pendingAuthRequest = false;
const messageBuffer: string[] = []; const messageBuffer: string[] = [];
let messageCursor = -1; let messageCursor = -1;
@@ -504,11 +517,63 @@ function sanitizeName(value: string): string {
/** Normalizes auth username according to server policy. */ /** Normalizes auth username according to server policy. */
function sanitizeAuthUsername(value: string): string { function sanitizeAuthUsername(value: string): string {
const maxLength = authPolicy?.usernameMaxLength ?? 128;
return value return value
.trim() .trim()
.toLowerCase() .toLowerCase()
.replace(/[^a-z0-9_-]/g, '') .replace(/[^a-z0-9_-]/g, '')
.slice(0, 32); .slice(0, Math.max(1, maxLength));
}
/** Normalizes and stores server-advertised auth policy limits, then refreshes auth UI hints. */
function applyAuthPolicy(policy: unknown): void {
if (!policy || typeof policy !== 'object') return;
const raw = policy as Partial<AuthPolicy>;
const usernameMin = Number(raw.usernameMinLength);
const usernameMax = Number(raw.usernameMaxLength);
const passwordMin = Number(raw.passwordMinLength);
const passwordMax = Number(raw.passwordMaxLength);
if (
!Number.isInteger(usernameMin) ||
!Number.isInteger(usernameMax) ||
!Number.isInteger(passwordMin) ||
!Number.isInteger(passwordMax)
) {
return;
}
if (usernameMin < 1 || usernameMax < usernameMin || passwordMin < 1 || passwordMax < passwordMin) {
return;
}
authPolicy = {
usernameMinLength: usernameMin,
usernameMaxLength: usernameMax,
passwordMinLength: passwordMin,
passwordMaxLength: passwordMax,
};
localStorage.setItem(AUTH_POLICY_STORAGE_KEY, JSON.stringify(authPolicy));
const hint = `Username: ${usernameMin}-${usernameMax} chars (a-z, 0-9, _, -). Password: ${passwordMin}-${passwordMax} chars.`;
dom.authPolicyHintLogin.textContent = hint;
dom.authPolicyHintRegister.textContent = hint;
dom.authUsername.minLength = usernameMin;
dom.authUsername.maxLength = usernameMax;
dom.registerUsername.minLength = usernameMin;
dom.registerUsername.maxLength = usernameMax;
dom.authPassword.minLength = passwordMin;
dom.authPassword.maxLength = passwordMax;
dom.registerPassword.minLength = passwordMin;
dom.registerPassword.maxLength = passwordMax;
updateConnectAvailability();
}
/** Loads most recently-seen auth policy limits from local storage for pre-connect UI hints. */
function loadPersistedAuthPolicy(): void {
const raw = localStorage.getItem(AUTH_POLICY_STORAGE_KEY);
if (!raw) return;
try {
applyAuthPolicy(JSON.parse(raw));
} catch {
// Ignore malformed persisted policy and keep live server policy source of truth.
}
} }
/** Enables/disables the connect button based on state and nickname validity. */ /** Enables/disables the connect button based on state and nickname validity. */
@@ -524,10 +589,13 @@ function updateConnectAvailability(): void {
dom.loginView.classList.toggle('hidden', authMode !== 'login'); dom.loginView.classList.toggle('hidden', authMode !== 'login');
dom.registerView.classList.toggle('hidden', authMode !== 'register'); dom.registerView.classList.toggle('hidden', authMode !== 'register');
const hasSessionToken = authSessionToken.trim().length > 0; const hasSessionToken = authSessionToken.trim().length > 0;
const usernameMin = authPolicy?.usernameMinLength ?? 1;
const passwordMin = authPolicy?.passwordMinLength ?? 1;
const hasLoginCredentials = const hasLoginCredentials =
sanitizeAuthUsername(dom.authUsername.value).length >= 2 && dom.authPassword.value.trim().length >= 8; sanitizeAuthUsername(dom.authUsername.value).length >= usernameMin && dom.authPassword.value.trim().length >= passwordMin;
const hasRegisterCredentials = const hasRegisterCredentials =
sanitizeAuthUsername(dom.registerUsername.value).length >= 2 && dom.registerPassword.value.trim().length >= 8; sanitizeAuthUsername(dom.registerUsername.value).length >= usernameMin &&
dom.registerPassword.value.trim().length >= passwordMin;
const authReady = hasSessionToken || (authMode === 'login' ? hasLoginCredentials : hasRegisterCredentials); const authReady = hasSessionToken || (authMode === 'login' ? hasLoginCredentials : hasRegisterCredentials);
dom.connectButton.textContent = hasSessionToken ? 'Connect' : authMode === 'login' ? 'Log In & Connect' : 'Register & Connect'; dom.connectButton.textContent = hasSessionToken ? 'Connect' : authMode === 'login' ? 'Log In & Connect' : 'Register & Connect';
dom.connectButton.disabled = mediaSession.isConnecting() || !authReady; dom.connectButton.disabled = mediaSession.isConnecting() || !authReady;
@@ -1379,14 +1447,16 @@ function sendAuthRequest(): void {
} }
/** Handles server auth-required prompts prior to world welcome. */ /** Handles server auth-required prompts prior to world welcome. */
function handleAuthRequired(message: string): void { function handleAuthRequired(message: Extract<IncomingMessage, { type: 'auth_required' }>): void {
applyAuthPolicy(message.authPolicy);
setConnectionStatus('Authentication required.'); setConnectionStatus('Authentication required.');
updateStatus(message); updateStatus(message.message);
} }
/** Applies auth result state and terminates failed auth attempts quickly. */ /** Applies auth result state and terminates failed auth attempts quickly. */
async function handleAuthResult(message: Extract<IncomingMessage, { type: 'auth_result' }>): Promise<void> { async function handleAuthResult(message: Extract<IncomingMessage, { type: 'auth_result' }>): Promise<void> {
pendingAuthRequest = false; pendingAuthRequest = false;
applyAuthPolicy(message.authPolicy);
if (!message.ok) { if (!message.ok) {
dom.authPassword.value = ''; dom.authPassword.value = '';
dom.registerPassword.value = ''; dom.registerPassword.value = '';
@@ -1581,6 +1651,7 @@ async function onSignalingMessage(message: IncomingMessage): Promise<void> {
let restartAnnouncement: string | null = null; let restartAnnouncement: string | null = null;
let connectedAnnouncement: string | null = null; let connectedAnnouncement: string | null = null;
if (message.type === 'welcome') { if (message.type === 'welcome') {
applyAuthPolicy(message.auth?.policy);
const incomingInstanceId = String(message.serverInfo?.instanceId ?? '').trim() || null; const incomingInstanceId = String(message.serverInfo?.instanceId ?? '').trim() || null;
const incomingVersion = String(message.serverInfo?.version ?? '').trim() || 'unknown'; const incomingVersion = String(message.serverInfo?.version ?? '').trim() || 'unknown';
connectedAnnouncement = reconnectInFlight connectedAnnouncement = reconnectInFlight
@@ -2608,6 +2679,7 @@ setupInputHandlers();
setupUiHandlers(); setupUiHandlers();
dom.authUsername.value = sanitizeAuthUsername(authUsername); dom.authUsername.value = sanitizeAuthUsername(authUsername);
dom.registerUsername.value = sanitizeAuthUsername(authUsername); dom.registerUsername.value = sanitizeAuthUsername(authUsername);
loadPersistedAuthPolicy();
setAuthMode('login'); setAuthMode('login');
updateConnectAvailability(); updateConnectAvailability();
updateDeviceSummary(); updateDeviceSummary();

View File

@@ -70,7 +70,7 @@ type MessageHandlerDeps = {
playLocateToneAt: (x: number, y: number) => void; playLocateToneAt: (x: number, y: number) => void;
resolveIncomingSoundUrl: (url: string) => string; resolveIncomingSoundUrl: (url: string) => string;
playIncomingItemUseSound: (url: string, x: number, y: number) => void; playIncomingItemUseSound: (url: string, x: number, y: number) => void;
handleAuthRequired: (message: string) => void; handleAuthRequired: (message: Extract<IncomingMessage, { type: 'auth_required' }>) => void;
handleAuthResult: (message: Extract<IncomingMessage, { type: 'auth_result' }>) => Promise<void>; handleAuthResult: (message: Extract<IncomingMessage, { type: 'auth_result' }>) => Promise<void>;
}; };
@@ -81,7 +81,7 @@ export function createOnMessageHandler(deps: MessageHandlerDeps): (message: Inco
return async function onMessage(message: IncomingMessage): Promise<void> { return async function onMessage(message: IncomingMessage): Promise<void> {
switch (message.type) { switch (message.type) {
case 'auth_required': case 'auth_required':
deps.handleAuthRequired(message.message); deps.handleAuthRequired(message);
break; break;
case 'auth_result': case 'auth_result':

View File

@@ -54,6 +54,14 @@ export const welcomeMessageSchema = z.object({
userId: z.string().nullable().optional(), userId: z.string().nullable().optional(),
username: z.string().nullable().optional(), username: z.string().nullable().optional(),
role: z.string().nullable().optional(), role: z.string().nullable().optional(),
policy: z
.object({
usernameMinLength: z.number().int().positive(),
usernameMaxLength: z.number().int().positive(),
passwordMinLength: z.number().int().positive(),
passwordMaxLength: z.number().int().positive(),
})
.optional(),
}) })
.optional(), .optional(),
uiDefinitions: z uiDefinitions: z
@@ -96,6 +104,14 @@ export const welcomeMessageSchema = z.object({
export const authRequiredSchema = z.object({ export const authRequiredSchema = z.object({
type: z.literal('auth_required'), type: z.literal('auth_required'),
message: z.string(), message: z.string(),
authPolicy: z
.object({
usernameMinLength: z.number().int().positive(),
usernameMaxLength: z.number().int().positive(),
passwordMinLength: z.number().int().positive(),
passwordMaxLength: z.number().int().positive(),
})
.optional(),
}); });
export const authResultSchema = z.object({ export const authResultSchema = z.object({
@@ -106,6 +122,14 @@ export const authResultSchema = z.object({
username: z.string().optional(), username: z.string().optional(),
role: z.string().optional(), role: z.string().optional(),
nickname: z.string().optional(), nickname: z.string().optional(),
authPolicy: z
.object({
usernameMinLength: z.number().int().positive(),
usernameMaxLength: z.number().int().positive(),
passwordMinLength: z.number().int().positive(),
passwordMaxLength: z.number().int().positive(),
})
.optional(),
}); });
export const signalMessageSchema = z.object({ export const signalMessageSchema = z.object({

View File

@@ -117,6 +117,12 @@ body {
width: min(280px, 60vw); width: min(280px, 60vw);
} }
.auth-hint {
color: #94a3b8;
margin: 0.35rem 0 0.5rem;
font-size: 0.92rem;
}
.nickname-row { .nickname-row {
display: flex; display: flex;
justify-content: center; justify-content: center;

View File

@@ -62,6 +62,9 @@ This is a behavior guide for packet semantics beyond raw schemas.
- `userId` - `userId`
- `username` - `username`
- `role` - `role`
- `policy` (`usernameMinLength`, `usernameMaxLength`, `passwordMinLength`, `passwordMaxLength`)
- `auth_required.authPolicy`: server auth limits advertised before login/register submit.
- `auth_result.authPolicy`: server auth limits echoed on auth success/failure responses.
- `welcome.worldConfig.gridSize`: server-authoritative grid size used by clients for bounds/drawing. - `welcome.worldConfig.gridSize`: server-authoritative grid size used by clients for bounds/drawing.
- `welcome.worldConfig.movementTickMs`: server movement-rate window used for client movement pacing. - `welcome.worldConfig.movementTickMs`: server movement-rate window used for client movement pacing.
- `welcome.worldConfig.movementMaxStepsPerTick`: max allowed grid steps per movement window. - `welcome.worldConfig.movementMaxStepsPerTick`: max allowed grid steps per movement window.

View File

@@ -6,6 +6,7 @@
2. Client validates auth form/session token and sets up local media. 2. Client validates auth form/session token and sets up local media.
3. Client connects signaling websocket. 3. Client connects signaling websocket.
4. Server sends `auth_required`. 4. Server sends `auth_required`.
- includes `authPolicy` limits for username/password.
5. Client sends `auth_login`, `auth_register`, or `auth_resume`. 5. Client sends `auth_login`, `auth_register`, or `auth_resume`.
6. Server sends `auth_result`. 6. Server sends `auth_result`.
7. Server sends `welcome` with users/items snapshot. 7. Server sends `welcome` with users/items snapshot.
@@ -42,7 +43,7 @@ Core incoming message effects:
- `signal`: WebRTC negotiation and ICE exchange. - `signal`: WebRTC negotiation and ICE exchange.
- `auth_required`: prompt client to authenticate before gameplay messages. - `auth_required`: prompt client to authenticate before gameplay messages.
- `auth_result`: auth success/failure with optional session token + account metadata. - `auth_result`: auth success/failure with optional session token + account metadata + `authPolicy`.
- `update_position`: update peer position; may play movement/teleport world sound. - `update_position`: update peer position; may play movement/teleport world sound.
- `teleport_complete`: play peer teleport landing sound at final tile. - `teleport_complete`: play peer teleport landing sound at final tile.
- `update_nickname`: update peer display name. - `update_nickname`: update peer display name.

View File

@@ -160,6 +160,7 @@ class WelcomePacket(BasePacket):
class AuthRequiredPacket(BasePacket): class AuthRequiredPacket(BasePacket):
type: Literal["auth_required"] type: Literal["auth_required"]
message: str message: str
authPolicy: dict | None = None
class AuthResultPacket(BasePacket): class AuthResultPacket(BasePacket):
@@ -170,6 +171,7 @@ class AuthResultPacket(BasePacket):
username: str | None = None username: str | None = None
role: str | None = None role: str | None = None
nickname: str | None = None nickname: str | None = None
authPolicy: dict | None = None
class UserLeftPacket(BasePacket): class UserLeftPacket(BasePacket):

View File

@@ -177,6 +177,16 @@ class SignalingServer:
return nickname.casefold() return nickname.casefold()
def _auth_policy(self) -> dict[str, int]:
"""Return server-auth policy limits advertised to clients."""
return {
"usernameMinLength": self.auth_service.username_min_length,
"usernameMaxLength": self.auth_service.username_max_length,
"passwordMinLength": self.auth_service.password_min_length,
"passwordMaxLength": self.auth_service.password_max_length,
}
def _flush_state_save(self) -> None: def _flush_state_save(self) -> None:
"""Immediately flush pending state persistence and clear debounce state.""" """Immediately flush pending state persistence and clear debounce state."""
@@ -748,7 +758,11 @@ class SignalingServer:
try: try:
await self._send( await self._send(
websocket, websocket,
AuthRequiredPacket(type="auth_required", message="Authentication required."), AuthRequiredPacket(
type="auth_required",
message="Authentication required.",
authPolicy=self._auth_policy(),
),
) )
async for raw_message in websocket: async for raw_message in websocket:
await self._handle_message(client, raw_message) await self._handle_message(client, raw_message)
@@ -805,6 +819,7 @@ class SignalingServer:
"userId": client.user_id, "userId": client.user_id,
"username": client.username, "username": client.username,
"role": client.role if client.authenticated else None, "role": client.role if client.authenticated else None,
"policy": self._auth_policy(),
}, },
) )
await self._send(client.websocket, packet) await self._send(client.websocket, packet)
@@ -844,7 +859,12 @@ class SignalingServer:
if client.authenticated and isinstance(packet, (AuthLoginPacket, AuthRegisterPacket, AuthResumePacket)): if client.authenticated and isinstance(packet, (AuthLoginPacket, AuthRegisterPacket, AuthResumePacket)):
await self._send( await self._send(
client.websocket, client.websocket,
AuthResultPacket(type="auth_result", ok=False, message="Already authenticated."), AuthResultPacket(
type="auth_result",
ok=False,
message="Already authenticated.",
authPolicy=self._auth_policy(),
),
) )
return True return True
@@ -861,7 +881,12 @@ class SignalingServer:
client.session_token = None client.session_token = None
await self._send( await self._send(
client.websocket, client.websocket,
AuthResultPacket(type="auth_result", ok=True, message="Logged out."), AuthResultPacket(
type="auth_result",
ok=True,
message="Logged out.",
authPolicy=self._auth_policy(),
),
) )
await client.websocket.close() await client.websocket.close()
return True return True
@@ -870,7 +895,12 @@ class SignalingServer:
except AuthError as exc: except AuthError as exc:
await self._send( await self._send(
client.websocket, client.websocket,
AuthResultPacket(type="auth_result", ok=False, message=str(exc)), AuthResultPacket(
type="auth_result",
ok=False,
message=str(exc),
authPolicy=self._auth_policy(),
),
) )
return True return True
@@ -890,6 +920,7 @@ class SignalingServer:
username=session.user.username, username=session.user.username,
role=session.user.role, role=session.user.role,
nickname=client.nickname, nickname=client.nickname,
authPolicy=self._auth_policy(),
), ),
) )
await self._activate_authenticated_client(client) await self._activate_authenticated_client(client)