Appearance
Login Flow
This page documents the standard ("smart") login flow in GrydAuth v5, covering the paths that resolve directly to a tenant token without requiring a separate tenant-selection step.
Overview
POST /api/v1/auth/login runs a single Smart Login decision tree (LoginCommandHandler.ExecuteSmartLoginAsync) that inspects how many tenants the user belongs to and whether a preferred/default tenant can be resolved. Depending on the outcome it returns one of three token shapes in the same AuthenticationResult body:
- a Tenant token (
tokenType: "Tenant") — full access, this page; - a Global token (
tokenType: "Global") — see Switch Tenant and First Login; - an MFA-pending token (
tokenType: "MfaPending") — see MFA.
Smart Login Decision Tree
| Condition | Result |
|---|---|
| Multi-tenancy disabled for the app | Tenant token for the single implicit tenant |
| User has 0 tenants | Tenant token path with no tenant context |
| User has exactly 1 tenant | Auto-switch — tenant token, smartAutoSwitched: true |
User has multiple tenants and preferredTenantId matches one | Tenant token for that tenant |
| User has multiple tenants and one is marked default | Tenant token for the default, smartAutoSwitched: true |
User has multiple tenants, no default, no matching preferredTenantId | Global token — client must call /switch-tenant |
At any of the above branches, IsFirstLogin/MustChangePassword is set | Global token instead — see First Login |
| At any of the above branches, the resolved tenant's MFA policy requires it | MFA-pending token instead — see MFA |
Prerequisites
- User has an active account
- Password is verified with Argon2id
requestedAppId(body) orX-App-Id(header, takes priority) matches an application the user is authorized for
Sequence Diagram
100% 💡 Use Ctrl + Scroll para zoom | Arraste para navegar
Step-by-Step Explanation
1. Request
json
POST /api/v1/auth/login
{
"email": "user@example.com",
"password": "SecurePassword123!",
"preferredTenantId": "550e8400-e29b-41d4-a716-446655440000",
"requestedAppId": "APP_WEB"
}Password is sent as plain text over HTTPS — client-side RSA encryption was removed in v5 (no isPasswordEncrypted field; see password transport breaking change). If the X-App-Id header is present it takes priority over requestedAppId in the body.
2. Zero Trust Checks
Zero Trust runs twice: a pre-authentication pass (IP threat intelligence, login rate-limit) before user lookup, and a post-authentication pass once identity is known. A blocking signal returns 403 with AUTH_RISK_FORBIDDEN. A risk flag (non-blocking) can instead trigger a step-up to MFA when the tenant's MFA policy is RiskBased.
3. Credential Validation
Email lookup and Argon2id password verification. To prevent account enumeration and timing oracles, a dummy-hash verification path runs even when the user doesn't exist, so response timing doesn't leak whether an email is registered.
4. Tenant Resolution (Smart Login)
See the decision tree above. This replaces a simple "has default tenant" check — the handler also auto-switches single-tenant users and honors preferredTenantId.
5. MFA Check
For the tenant that would otherwise receive a full token, IMfaPolicyResolver resolves the effective policy (Disabled / RiskBased / Always). If MFA is required, an mfa_pending token is returned instead — no refresh token is issued, and the client must complete POST /api/v1/mfa/challenge/begin + verify. See MFA.
6. Token Generation
| Outcome | tokenType | Notes |
|---|---|---|
| Tenant resolved, no MFA required | Tenant | Full access token + refresh token |
| No tenant resolved (multiple, no default/preferred match) | Global | 2-minute TTL, single-use, no refresh token |
| MFA required | MfaPending | Short-lived, no refresh token, only valid on MFA challenge endpoints |
| First login / forced password change | Global | See First Login |
7. Response
v5 (Epic 533): the response body no longer contains
refreshToken. The refresh token is sent in theSet-Cookie: __Secure-gryd.refreshHttpOnly cookie (plus a readablegryd.csrfcookie for CSRF).
json
{
"token": "eyJhbGciOiJSUzI1NiIs...",
"expiresAt": "2026-07-02T15:30:00Z",
"permissions": ["users:read", "users:write"],
"isFirstLogin": false,
"mustChangePassword": false,
"daysUntilPasswordExpiration": 30,
"isGlobal": false,
"requiresTenantSelection": false,
"tokenType": "Tenant",
"availableTenants": null,
"currentTenant": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Acme Corp",
"isDefault": true,
"domain": "acme"
},
"smartAutoSwitched": true,
"groupId": null,
"groupName": null,
"tenantType": "company"
}Field name is token, not accessToken
The response DTO wired to AuthController is AuthenticationResult, whose token field is named token. Do not confuse it with the unrelated AuthenticationResponse DTO (AccessToken/ExpiresIn) that also exists in the codebase but is not returned by any controller action.
JWT Claims (Tenant / Access Token)
Claim names come from ClaimsService — most are singular claim types repeated once per value, not JSON array keys:
| Claim | Description |
|---|---|
sub | User ID (GUID) |
token_type | access for this flow |
tenant_id | Current tenant ID |
tenant_name | Current tenant name |
role | Repeated once per role |
permission | Repeated once per permission |
token_version | For fail-secure invalidation (logout, password change, admin action) |
group_id / group_name | Present only for child tenants in a hierarchical/group tenant |
Error Scenarios
| Error | HTTP Status | Code | Cause |
|---|---|---|---|
| Invalid credentials | 401 | INVALID_CREDENTIALS | Wrong email/password (uniform response, no enumeration) |
| Account locked | 401 | — | Too many failed attempts |
| Security risk blocked | 403 | AUTH_RISK_FORBIDDEN | Zero Trust returned a blocking signal |
| Rate limit exceeded | 429 | AUTH_RATE_LIMIT_EXCEEDED | 10/min per account, 60/min per IP; honor Retry-After |
Related Flows
- First Login - When user must change password
- Switch Tenant - When login returns a Global token
- MFA - When login returns an MFA-pending token
- Refresh Token - Refreshing expired access tokens