Login Identifier Resolution

identity specs/identity/login-resolution.kmd

How Koder ID (and any SDK or library that performs client-side pre-validation) resolves what the user typed in the "Email" field to the target user record. Gmail-style behavior: accept a bare local part (no "@") for accounts hosted under the tenant default domain; require a full email for external domains and third-party workspaces. The contract is single and applies to every surface: server-side OAuth UI, Flutter sign-in, web sign-in, CLI auth, KoderAuthGate, etc.

When this pattern applies

All triggers

Specification body

Spec — Login Identifier Resolution (Gmail-style)

Applicability

Every surface in the Koder Stack that accepts a login identifier from the user and resolves it to a user record:

  • services/foundation/id/engine/services/{auth,oauth} (server-side)
  • engines/sdk/koder_kit Flutter (KoderSignInButton, KoderAuthGate)
  • engines/sdk/koder_web_kit JS (<koder-sign-in>, web auth helpers)
  • engines/sdk/go/auth (Go SDK for Koder backend apps)
  • CLIs that perform login (khub login, klint login, kdev auth, …)
  • TUIs (Bubble Tea apps that collect credentials)

Vocabulary

  • Identifier — raw string typed by the user in the "Email" / "Login" / "Username" field.
  • Local part — substring before the @ (RFC 5321 §4.5.3).
  • Domain part — substring after the @.
  • Tenant default domain — domain the tenant declares as its shortcut domain for users in that tenant. Each tenant has exactly one default domain. Examples: koder.dev for tenant koder; crescer.net for tenant crescer; vivver.com.br for vivver.
  • Workspace tenant — tenant whose default domain is a B2B customer's custom domain (e.g. crescer.net, vivver.com.br).
  • Individual tenant — tenant koder (default domain koder.dev). Current convention: a single individual tenant exists; Koder individual users live there.

Rules

R1 — Bare local-part is equivalent to <local>@<tenant_default_domain>

If the identifier does not contain @, the resolver MUST expand it to identifier + "@" + tenant_default_domain before doing the lookup. The expanded lookup MUST be tried first.

Examples in tenant koder (default koder.dev):

Typed Lookup attempt
rodrigo rodrigo@koder.dev
r2d2 r2d2@koder.dev
koder.team koder.team@koder.dev

Rationale: matches Gmail consumer behavior. Ergonomic for first-party apps in the tenant.

R2 — Identifier with @ is treated as a full email

If the identifier contains @, the resolver MUST use it literally, with no expansion. This covers two cases:

  1. A user in the same tenant typing the full email (rodrigo@koder.dev).
  2. A user from an external domain (guest@external.com) or another tenant (ana@crescer.net).

Rationale: the @ is the syntactic signal that the user provided the full address. Same convention as Google Workspace.

R3 — Workspace tenants require full email when undetermined

When the target tenant is a workspace tenant (default domain is a custom B2B domain), bare-local-part attempts MUST still be expanded with @<tenant_default_domain>provided the tenant is already determined at submit time.

If the tenant is not determined at submit time (universal multi-tenant login UI), the resolver MUST reject the bare local-part and require the full email. Error message: "For Workspace accounts, please enter the full email address."

Rationale: avoids ambiguity when the resolver doesn't know which workspace rodrigo belongs to. Google Workspace behaves the same way at the universal accounts.google.com URL.

R4 — Handle fallback (independent of @)

After R1 or R2, if the email lookup fails, the resolver MUST attempt one lookup by handle == original_identifier (the value before the R1 expansion).

Cases covered:

  • User typed rodrigo → R1 expanded to rodrigo@koder.dev → email lookup failed (e.g., user has handle rodrigo but a custom primary email) → fallback resolves by handle.
  • User typed guest@xyz.com → email lookup failed → handle fallback does not apply (R4 only triggers when the original identifier was a bare local-part).

Rationale: combines #041 (normalization) with #060 (handle lookup) into a single robust flow.

R5 — Resolution is case-insensitive ASCII

Rodrigo and RODRIGO resolve to the same user as rodrigo. Before the R1 expansion the resolver lowercases the local-part. The domain part is also lowercased (RFC 5321 §2.3.11).

Rationale: matches the canonicalization rule in username-allocation.kmd.

R6 — Timing-safe failure

If none of R1/R2/R3/R4 finds a user, the resolver MUST still run the password verify step against a dummy hash before returning authentication_failed. The user-facing error message MUST be identical for "user does not exist" and "wrong password" ("Invalid email or password").

Rationale: prevents username enumeration via timing oracle. Already implemented in services/auth/internal/service/auth.go.

R7 — Client-side pre-validation is a hint only

SDKs MAY show inline feedback ("will look up as rodrigo@koder.dev") while the user types. The final resolution MUST happen server-side. Client-side MUST NOT:

  • Block submit because "user not found".
  • Pre-fetch user records.
  • Branch UI based on existence (R6 applies here too).

Rationale: lookups expose email enumeration; the server is the only authority that can perform lookups with timing-safe countermeasures.

R8 — Expansion is per-tenant configurable

Each tenant declares default_domain in its tenant catalog record. If absent or empty, R1 and R3 are disabled — the resolver will treat any bare local-part as invalid_identifier ("Please enter the full email address.").

Rationale: supports future tenants that explicitly want no shortcut (e.g., BYOD enterprise with mixed domains).

Implementation per surface

S1 — services/foundation/id/engine/services/auth (Go, server)

Canonical helper:

// ResolveLoginIdentifier expands a bare local-part to
// <local>@<defaultDomain> when applicable, and returns both the
// expanded email AND the original (for the R4 handle fallback).
func ResolveLoginIdentifier(identifier, defaultDomain string) (email, originalForHandle string)

Used in CreateFlow before GetUserByEmail. When the email lookup fails and originalForHandle != "", attempt GetUserByHandle(originalForHandle).

Source tickets: services/foundation/id/engine/backlog/done/041-bare-username-login.md plus done/060-auth-handle-username-lookup.md. A rebuild and redeploy is required — the production binary is still from Apr 13 and predates the unified branch.

S2 — engines/sdk/koder_kit (Dart, Flutter)

Public helper in lib/src/auth/identifier.dart:

class KoderLoginIdentifier {
  /// Returns `{email, handle}` — the caller submits both
  /// to the auth API.
  static ({String email, String? handle}) resolve(
    String identifier, {
    required String defaultDomain,
  });
}

Consumed by KoderSignInButton / KoderAuthGate before calling the OAuth endpoint. Optional visual pre-validation (R7) via InputDecoration.helperText.

S3 — engines/sdk/koder_web_kit (JS, web)

koderResolveLoginIdentifier(identifier, defaultDomain) exported from auth/identifier.js. The <koder-sign-in> web component applies it when the default-domain attribute is set.

S4 — engines/sdk/go/auth (Go, app backend)

Same signature as S1, exposed as auth.ResolveLoginIdentifier.

S5 — CLIs (khub, klint, kdev, …)

Shared helper at engines/sdk/go/auth/cli/. Always reads KODER_ID_DEFAULT_DOMAIN env var (default koder.dev).

S6 — TUIs

Reuses S5 (Go). All Koder TUIs are Bubble Tea apps in Go.

Tests

See template at meta/docs/stack/specs/identity/login-resolution-test-template.kmd. Each implementing surface S1–S6 MUST pass the T1–T8 baseline before release.

Non-goals

  • Does not define handle allocation rules — see policies/username-allocation.kmd.
  • Does not change the user record storage layout.
  • Does not define recovery / forgot-password flow — see RFC-006.
  • Does not define MFA challenge — see RFC-006.

Version

  • v1.0 — 2026-05-08 — first release. Codifies #041 + #060 into a single cross-surface contract.

References