Skip to content

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):

TypedLookup attempt
rodrigorodrigo@koder.dev
r2d2r2d2@koder.dev
koder.teamkoder.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).

R9 — default_domain is the org's primary domain; aliases are full-email-only

Per RFC-019, an org owns one primary domain plus N verified alias domains. The default_domain used by R1/R3 (bare-local-part expansion) MUST be the org's primary domain — never an alias. A bare local part therefore expands deterministically against the single primary domain and never has to disambiguate among N domains.

Addresses on an org's alias domains are valid identifiers but MUST be entered as a full email (handled by R2). The full-email lookup resolves to the user's koder_user_id via any of the user's verified emails (a user may hold one address per verified org domain; one is the canonical primary email).

Org Crescer (primary crescer.net, alias crescer.com.br)Behavior
rodrigo (bare)expands to rodrigo@crescer.net (primary only)
rodrigo@crescer.netfull-email lookup
rodrigo@crescer.com.br (alias)full-email lookup → same koder_user_id

Rationale: keeps R1 deterministic while letting members hold addresses on any verified org domain. Mirrors Google Workspace, where the primary domain drives the shortcut and secondary domains require the full address.

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.1 — 2026-06-04 — add R9: default_domain ≔ org primary domain; alias domains are full-email-only (RFC-019, id#206).
  • v1.0 — 2026-05-08 — first release. Codifies #041 + #060 into a single cross-surface contract.

References