Login Identifier Resolution
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.
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
- *dentifier*— raw string typed by the user in the "Email" /
"Login" / "Username" field.
- *ocal part*— substring before the
@(RFC 5321 §4.5.3). - *omain part*— substring after the
@. - *enant default domain*— domain the tenant declares as its
shortcut domain for users in that tenant. Each tenant has exactly *ne*default domain. Examples:
koder.devfor tenantkoder;crescer.netfor tenantcrescer;vivver.com.brforvivver. - *orkspace tenant*— tenant whose default domain is a B2B
customer's custom domain (e.g.
crescer.net,vivver.com.br). - *ndividual 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 *ot contain @* the resolver *UST*expand it to identifier + "@" + tenant_default_domain before doing the lookup. The expanded lookup *UST*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 |
*ationale:*matches Gmail consumer behavior. Ergonomic for first-party apps in the tenant.
R2 — Identifier with @ is treated as a full email
If the identifier *ontains @* the resolver *UST*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 anothertenant (
ana@crescer.net).
*ationale:*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 *orkspace tenant*(default domain is a custom B2B domain), barelocalpart attempts *UST*still be expanded with @<tenant_default_domain> — *rovided the tenant is already determined*at submit time.
If the tenant *s not determined*at submit time (universal multi-tenant login UI), the resolver *UST*reject the bare local-part and require the full email. Error message: "For Workspace accounts, please enter the full email address."
*ationale:*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 *ails* the resolver *UST*attempt *ne*lookup by handle == original_identifier (the value before the R1 expansion).
Cases covered:
- User typed
rodrigo→ R1 expanded torodrigo@koder.dev→ emaillookup failed (e.g., user has handle
rodrigobut a custom primary email) → fallback resolves by handle. - User typed
guest@xyz.com→ email lookup failed → handle fallbackdoes not apply (R4 only triggers when the original identifier was a bare local-part).
*ationale:*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).
*ationale:*matches the canonicalization rule in
username-allocation.kmd.
R6 — Timing-safe failure
If none of R1R2R3/R4 finds a user, the resolver *UST*still run the password verify step against a dummy hash before returning authentication_failed. The user-facing error message *UST*be identical for "user does not exist" and "wrong password" ("Invalid email or password").
*ationale:*prevents username enumeration via timing oracle. Already implemented in
services/auth/internal/service/auth.go.
R7 — Clientside prevalidation is a hint only
SDKs *AY*show inline feedback ("will look up as rodrigo@koder.dev") while the user types. The final resolution *UST*happen serverside. Clientside *UST NOT*
- Block submit because "user not found".
- Pre-fetch user records.
- Branch UI based on existence (R6 applies here too).
*ationale:*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 *bsent or empty* R1 and R3 are *isabled*— the resolver will treat any bare local-part as invalid_identifier ("Please enter the full email address.").
*ationale:*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 *nd*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. * 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 *UST*pass the T1–T8 baseline before release.
Non-goals
- *oes not*define handle allocation rules — see
policies/username-allocation.kmd. - *oes not*change the user record storage layout.
- *oes not*define recovery / forgot
password flow — see RFC006. - *oes not*define MFA challenge — see RFC-006.
Version
- v1.0 — 2026
0508 — first release. Codifies #041 + #060 into asingle cross-surface contract.