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
- Implementing a login screen / flow on any surface (web, mobile, desktop, CLI, TUI)
- Implementing client-side pre-validation of the email field in any Koder SDK
- Touching `LoginSubmit` / `CreateFlow` in koder-id-auth
- Implementing identifier normalization in any engine or library that does email lookup
- Auditing login UX in a Koder UI for parity with this contract
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_kitFlutter (KoderSignInButton,KoderAuthGate)engines/sdk/koder_web_kitJS (<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.devfor tenantkoder;crescer.netfor tenantcrescer;vivver.com.brforvivver. - 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 domainkoder.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:
- A user in the same tenant typing the full email (
rodrigo@koder.dev). - 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
rodrigobelongs to. Google Workspace behaves the same way at the universalaccounts.google.comURL.
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 torodrigo@koder.dev→ email lookup failed (e.g., user has handlerodrigobut 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
services/foundation/id/engine/docs/rfcs/RFC-012-account-models.mdservices/foundation/id/engine/docs/rfcs/RFC-013-invite-chain.mdservices/foundation/id/engine/backlog/done/041-bare-username-login.mdservices/foundation/id/engine/backlog/done/060-auth-handle-username-lookup.mdmeta/docs/stack/policies/username-allocation.kmdmeta/docs/stack/policies/dev-default-password.kmd