Appearance
First Login Flow
This page documents the login flow when a user logs in for the first time or when password change is required.
Overview
When a user's account is newly created or an administrator forces a password reset, the IsFirstLogin or MustChangePassword flag is set to true. The login response includes these flags so the client can redirect the user to the password change screen before tenant selection.
Key architectural decision: Password is a user-level concern, not tenant-level. The user changes their password with a global token, then selects a tenant.
Triggers for Mandatory Password Change
| Condition | Description |
|---|---|
IsFirstLogin = true | User has never logged in |
MustChangePassword = true | Admin-forced password reset |
PasswordExpiresAt < NOW() | Password has expired (policy-based) |
Sequence Diagram
100% 💡 Use Ctrl + Scroll para zoom | Arraste para navegar
Step-by-Step Explanation
Phase 1: Initial Login
1.1 Credential Validation
The user provides valid credentials. The system verifies:
- Email exists and user is active
- Password is correct
- AppId is valid (if configured)
1.2 Password Change Detection
After successful credential validation, the login response includes flags:
json
{
"isFirstLogin": true,
"mustChangePassword": true
}The client detects these flags and redirects to the password change form before tenant selection.
1.3 Global Token
For multi-tenant users without a default tenant, the login returns a global token:
json
{
"token": "eyJhbGciOiJSUzI1NiIs...",
"isGlobal": true,
"requiresTenantSelection": true,
"tokenType": "Global",
"availableTenants": [
{ "id": "...", "name": "Tenant A", "isDefault": false },
{ "id": "...", "name": "Tenant B", "isDefault": false }
]
}Token Properties
- TTL: 2 minutes (short-lived for security)
- Scope:
tenant-selector-only— cannot access tenant resources - Single-use: Enforced by the authorization handler
Phase 2: Complete First Login
2.1 Submit New Password
The client calls POST /api/v1/auth/complete-first-login with the global token:
json
{
"currentPassword": "TemporaryPassword123!",
"newPassword": "NewSecurePassword456!",
"confirmPassword": "NewSecurePassword456!"
}Passwords are sent in plain text over TLS
Client-side RSA password encryption was removed in v5 — there is no GET /api/v1/auth/public-key endpoint and no isCurrentPasswordEncrypted/isNewPasswordEncrypted fields. Send both password fields directly over HTTPS. See password transport breaking change.
2.2 Password Validation Pipeline
The handler delegates to IPasswordChangeService (shared with change-password — DRY):
- Decrypt passwords if encrypted
- Verify current (temporary) password matches
- Check history — prevents reusing the last N passwords
- Validate strength — minimum length, complexity, etc.
- Hash new password and update user
2.3 Token Invalidation
After password change, all previous tokens are invalidated:
csharp
user.CompleteFirstLogin(); // IsFirstLogin=false, MustChangePassword=false
user.InvalidateAllTokens("first_login"); // TokenVersion++ → old tokens rejected by middleware2.4 New Global Token
A fresh global token is generated with the updated TokenVersion. This allows the user to proceed to tenant selection without re-authenticating:
json
{
"token": "eyJhbGciOiJSUzI1NiIs...",
"isFirstLogin": false,
"mustChangePassword": false,
"isGlobal": true,
"requiresTenantSelection": true,
"availableTenants": [...]
}Why a new token?
- The original global token was consumed (single-use enforcement)
- The
TokenVersionwas incremented, invalidating all previous tokens - The user has already proven their identity (valid token + correct old password + new password set)
- Forcing re-login would be redundant and poor UX
Phase 3: Tenant Selection
The user selects a tenant from availableTenants. This follows the standard Switch Tenant flow, producing a full tenant token (default 10-minute TTL, configurable 1–15 minutes via JwtSettings:ExpirationMinutes), a refresh token, roles, and permissions.
Error Scenarios
| Error | HTTP Status | Cause |
|---|---|---|
| Invalid current password | 400 | Temporary password doesn't match |
| Password reuse | 400 | New password matches a recent one |
| Weak password | 400 | Doesn't meet strength requirements |
| Passwords don't match | 400 | newPassword ≠ confirmPassword |
| Token expired | 401 | Global token expired (2 min TTL) |
| Token already used | 403 | Global token was already consumed |
| Not first login | 400 | User doesn't need to change password |
Security Considerations
Global Token Limitations
The global token used during first login:
- Has a 2-minute TTL (short-lived)
- Is single-use (replay attack prevention)
- Contains no roles or permissions
- Cannot access tenant-scoped resources
- Is validated by
AllowGlobalAndTenantTokenHandler
Single-use is enforced on every endpoint carrying [AllowGlobalAndTenantToken], not only /switch-tenant. So the global token spent on complete-first-login is consumed: the client must continue the flow with the new global token returned in that response. Replaying the old one returns 403 (see Error Scenarios).
Token Type Enforcement
Which token types an endpoint accepts is declared once, by its authorization attribute, via IAllowedTokenTypesMetadata:
| Attribute | Accepted token_type |
|---|---|
[AllowGlobalAndTenantToken] | global, access |
[RequireMfaPendingToken] | mfa_pending |
anything else (e.g. [Authorize]) | access only (fail-secure default) |
GrydAuthTokenProcessor reads that metadata from the routed endpoint. It does not keep an allowlist of its own — a second, path-keyed allowlist is what previously rejected complete-first-login with a 401 WrongTokenType even though the endpoint's attribute accepted global tokens.
Two consequences worth knowing:
UseGrydAuth()must run afterUseRouting(). Without a routed endpoint the processor falls back to the access-only allowlist and logs a warning.TokenTypeAllowlistContractTestsfails the build if an endpoint's policy and its declared token types ever disagree again.
Password History
Password history prevents:
- Reusing recent passwords (configurable count via
PasswordPolicyOptions.HistoryCount) - Cycling through passwords to return to a favorite
- Predictable password rotation
Token Invalidation
After password change:
TokenVersionis incremented in the database- The middleware (
GrydAuthTokenProcessor) compares the token'stoken_versionclaim against Redis cache - All tokens with old version are rejected (fail-secure)
Code Example
Client-Side Flow (TypeScript)
typescript
async function handleLogin(email: string, password: string) {
const response = await authService.login({ email, password });
if (response.data.isFirstLogin || response.data.mustChangePassword) {
// Store global token and redirect to password change
authStore.setGlobalToken(response.data.token);
authStore.setAvailableTenants(response.data.availableTenants);
router.push('/change-password');
return;
}
if (response.data.requiresTenantSelection) {
// Multi-tenant user, go to tenant selection
authStore.setGlobalToken(response.data.token);
authStore.setAvailableTenants(response.data.availableTenants);
router.push('/select-tenant');
return;
}
// Single tenant or auto-switched — ready to go
authStore.setTokens(response.data.token, response.data.refreshToken);
router.push('/dashboard');
}
async function handleFirstLoginPasswordChange(
currentPassword: string,
newPassword: string
) {
const globalToken = authStore.getGlobalToken();
const response = await authService.completeFirstLogin({
currentPassword,
newPassword,
confirmPassword: newPassword
}, globalToken);
// Response contains NEW global token + available tenants
authStore.setGlobalToken(response.data.token);
authStore.setAvailableTenants(response.data.availableTenants);
// Now go to tenant selection
router.push('/select-tenant');
}
async function handleTenantSelection(tenantId: string) {
const globalToken = authStore.getGlobalToken();
const response = await authService.switchTenant({ tenantId }, globalToken);
// Full tenant token received
authStore.setTokens(response.data.token, response.data.refreshToken);
router.push('/dashboard');
}Related Flows
- Login Flow - Standard login with default tenant
- Switch Tenant - Tenant selection after login