Skip to content

REF-701-02: Authentication Flow

ADR: ADR-701 — API & AuthenticationEscopo: Login/refresh flow, payload JWT, TTL, rate limiting, validação de senha, nonce interno, OAuth 2.0 portal


1. Login Flow

POST /wp-json/middag-account/v1/auth/login
Body: { "email": "user@example.com", "password": "********" }

Validação:
├── Email existe?
├── Senha válida?
├── Rate limit ok? (5/min/IP)
└── Organização verificada?

Sucesso (200):
{
  "success": true,
  "data": {
    "access_token": "eyJ...",
    "refresh_token": "eyJ...",
    "token_type": "Bearer",
    "expires_in": 86400
  }
}

Falha (401):
{
  "success": false,
  "errors": { "code": "AUTHENTICATION_ERROR", "detail": "Invalid credentials" }
}

2. Refresh Flow

POST /wp-json/middag-account/v1/auth/refresh
Body: { "refresh_token": "eyJ..." }

Validação:
├── Refresh token válido?
├── Não expirado (7 dias)?
└── Não revogado?

Sucesso (200):
{
  "success": true,
  "data": {
    "access_token": "eyJ... (novo)",
    "token_type": "Bearer",
    "expires_in": 86400
  }
}

3. Payload JWT (Access Token)

json
{
  "sub": 42,
  "org": 15,
  "roles": ["admin"],
  "scopes": ["organization", "finances", "orders", "licenses", "tickets"],
  "company": "middag_br",
  "iat": 1714200000,
  "exp": 1714286400
}
CampoTipoDescrição
subintUser ID (WordPress)
orgintOrganization ID
rolesstring[]Roles do collaborator na org
scopesstring[]Scopes atribuídos
companystringCompany context default
iatintIssued at (Unix timestamp)
expintExpiration (Unix timestamp)

Decisão: Campos sub, org, roles, scopes no nível superior — NÃO aninhados. Alinha com padrão JWT (bloqueador #7 resolvido).


4. TTL

TokenTTLRenovável
Access24hSim (via refresh)
Refresh7 diasNão (requer login)

5. Algoritmo e Key Management

AspectoDetalhe
AlgoritmoRS256 (RSA + SHA-256)
Nunca HS256HS256 compartilha secret — RS256 usa keypair
Private keyFora do webroot (não acessível via HTTP)
Public keyDisponível para validação por serviços externos
RotaçãoManual (admin). Tokens existentes invalidados.

6. Rate Limiting

EndpointLimiteJanelaResposta excedida
/auth/login5 requests1 min429 RATE_LIMIT
/auth/refresh10 requests1 min429 RATE_LIMIT
/auth/register3 requests1 min429 RATE_LIMIT

Baseado em IP. Header Retry-After incluído na resposta 429.


7. Validação de Senha

RegraRequisito
Comprimento mínimo8 caracteres
ComplexidadeMaiúscula + minúscula + número
Caracteres especiaisNão obrigatórios (recomendados)

Bloqueador #6 resolvido.


8. Autenticação por Contexto

ContextoMétodoQuando
Portal (NextJS)JWT RS256 (access + refresh)Login do cliente
wp-admin (Inertia)WordPress nonce (wp_verify_nonce)Chamadas internas do admin
WC API KeysConsumer key + secret (WC nativo)Integrações WooCommerce diretas
Satélites (PHP)WordPress nonce (contexto admin)Chamadas server-to-server internas

9. OAuth 2.0 para Portal (Futuro v5.0.x)

ProviderSuportadoNotas
GoogleSimOAuth 2.0 standard
Azure ADSimPara clientes enterprise com Azure
SAML 2.0NãoRemovido na reorganização — OAuth cobre 90%

Flow: Provider OAuth → callback → vincular a WordPress user → emitir JWT middag-account.


10. Middleware Chain

Every authenticated request passes through the following middleware pipeline in order:

HTTP Request
      |
      v
+-------------------------------------+
| 1. AuthMiddleware::getCurrentUser()  |
|    Extract and validate JWT          |
|    Set WP_User in request context    |
+-------------------------------------+
      |
      v
+-------------------------------------+
| 2. PermissionsMiddleware             |
|    Validate role and scopes          |
|    (if route is protected)           |
+-------------------------------------+
      |
      v
+-------------------------------------+
| 3. OrganizationMiddleware            |
|    Validate X-Middag-Organization    |
|    (if route is org-scoped)          |
+-------------------------------------+
      |
      v
+-------------------------------------+
| 4. CompanyMiddleware                 |
|    Validate X-Middag-Company         |
|    (if route is company-scoped)      |
+-------------------------------------+
      |
      v
Controller processes the request

10.1 AuthMiddleware Detail

1. Extract token from Authorization header (regex: /^Bearer\s+(.+)$/)
   If absent -> 401 "Token not provided"
2. Decode JWT header (base64url), verify alg === "RS256"
   If different -> 401 "Unsupported algorithm"
3. Validate RS256 signature using RSA public key
   If invalid -> 401 "Invalid signature"
4. Check expiration (exp)
   If exp < time() -> 401 "Token expired"
5. Check issuer (iss)
   If iss !== expected issuer -> 401 "Invalid issuer"
6. Load WP_User by sub (user ID)
   If not found -> 401 "User not found"
7. Check last_logout
   If last_logout > iat -> 401 "Token revoked by logout"
8. Set user in request context: wp_set_current_user(user_id)

10.2 PermissionsMiddleware Detail

1. Get route definition -> extract required_role and required_scopes
2. If org-scoped: load CollaboratorModel for user + org
   If not a collaborator -> 403 "No access to organization"
3. Check minimum role
   Hierarchy: owner > admin > member > guest > pending
   If role < required_role -> 403 "Insufficient permission"
4. Check scopes
   Collaborator must have all required scopes
   Wildcard "*" grants all scopes
   If scope missing -> 403 "Scope not authorized: {scope}"

10.3 OrganizationMiddleware Detail

1. Extract org_id from X-Middag-Organization header
   If absent -> 400 "Header X-Middag-Organization required"
2. Validate organization exists
   If not found -> 404 "Organization not found"
3. Validate user is a collaborator
   If not a member -> 403 "No access to organization"
4. Set organization context in request

11. Login Validations (WordPress Side)

StepValidationFailure
1Email present and valid format400 Bad Request
2Password present400 Bad Request
3wp_authenticate() returns WP_User401 Unauthorized
4Account not blocked403 Forbidden
5Email verified (email_verified meta)403 Forbidden
6Rate limit not exceeded (5/min per IP)429 Too Many Requests

12. Refresh Token Rotation

The refresh token implements mandatory rotation:

  1. Each use of the refresh token generates a new pair (access + refresh)
  2. The previous refresh token is immediately invalidated
  3. If an already-invalidated refresh token is used (replay attack), all tokens for the user are revoked
  4. The new refresh token has a 7-day validity from issuance
Normal use:
  RT-1 -> (new AT-2, new RT-2) -> RT-1 invalidated
  RT-2 -> (new AT-3, new RT-3) -> RT-2 invalidated

Replay attack detected:
  RT-1 already used -> REVOKE ALL -> Force re-login

12.1 Refresh Validation Steps

StepValidationFailure
1Refresh token present in body400 Bad Request
2Token found in user_meta (hash match)401 Unauthorized
3Token not expired (< 7 days)401 Unauthorized
4User has not logged out (last_logout < token creation)401 Unauthorized
5User account is active403 Forbidden

13. OAuth 2.0 Flows (Portal)

OAuth is handled by the NextJS portal via NextAuth.js. WordPress provides the bridge endpoint /auth/register-email.

13.1 Google OAuth Flow

App (Browser)      NextJS             Google OAuth        WordPress API
     |
     | Click "Login with Google"
     |
     |---> NextAuth signIn('google')
                  |
                  | Redirect to Google authorize
                  |---> Google login page
                  |     User authenticates + consents
                  |<--- Callback with auth code
                  |
                  | Exchange code for tokens
                  |---> POST /oauth2/token
                  |<--- access_token, id_token
                  |
                  | Extract profile from id_token
                  | (email, name, picture)
                  |
                  | POST /middag-account/v1/auth/register-email
                  | Body: { email, name, avatar, provider: "google" }
                  |                                      |
                  |                                      | Find/create WP_User
                  |                                      | Mark email_verified = true
                  |                                      | Generate JWT + refresh
                  |<-------------------------------------|
                  |
                  | Store tokens in NextAuth session
     |<---
     | Session cookie set, redirect /home

Google provider config: Client ID/Secret via env vars, scopes: openid email profile, callback: https://app.middag.io/api/auth/callback/google.

13.2 Microsoft (Azure AD) OAuth Flow

Same structure as Google with Azure AD endpoints:

  • Authorization: https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize
  • Token: https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token
  • Scopes: openid email profile User.Read
  • Profile fields: preferred_username (email), displayName (name)

Azure AD provider config: Client ID/Secret/Tenant ID via env vars, callback: https://app.middag.io/api/auth/callback/azure-ad.


PropertyValueDescription
Namenext-auth.session-tokenSession cookie name
StrategyjwtSession stored in cookie as JWT (no database)
Max Age86400 (24 hours)Cookie lifetime
Securetrue (production)Cookie only sent via HTTPS
HttpOnlytrueInaccessible via JavaScript (XSS protection)
SameSitelaxPartial CSRF protection (allows normal navigation)
Path/Cookie available on all routes

The NextAuth JWT callback runs on every request and manages token lifecycle:

  • Initial login: Stores access_token, refresh_token, expires_at, and user data
  • Token still valid: Returns token unchanged
  • Token expired: Calls /auth/refresh, updates tokens; on failure sets error: 'RefreshAccessTokenError'
  • Client-side: If session has RefreshAccessTokenError, redirects to login

15. Token Invalidation Scenarios

EventMechanismEffect
Voluntary logoutUpdates last_logout in user_metaAll JWTs issued before logout are rejected
Password changeUpdates last_logout + revokes refresh tokensForces re-login on all devices
Admin disables accountFlag account_disabled in user_metaAuthMiddleware rejects any token
Refresh token replayDetects already-used tokenRevokes ALL tokens for the user
Collaborator removalRemoves CollaboratorModel recordPermissionsMiddleware blocks access to that org

15.1 last_logout Mechanism

The last_logout field in user_meta acts as a mass invalidation mechanism:

JWT issued at:      iat = 1735689600
User logged out at:       1735700000
JWT used at:              1735710000

Check: last_logout (1735700000) > iat (1735689600) -> TOKEN REJECTED

This ensures that even JWTs still within their 24h validity window are rejected after logout, without needing a centralized blacklist.


16. Security Protections

ProtectionImplementationLayer
CSRFSameSite=lax cookie + state parameter (OAuth)NextAuth
XSSHttpOnly cookie (tokens inaccessible via JS)NextAuth
Token theftRS256 (asymmetric signature), refresh rotationWordPress
Brute forceRate limit 5 attempts/min per IPWordPress
Replay attackJTI claim in JWT + refresh token rotationWordPress
Session fixationNew token on each refreshWordPress+NextAuth
Man-in-the-middleHTTPS mandatory, Secure cookie flagInfrastructure

16.1 Extended Rate Limiting

EndpointLimitWindowPer
/auth/login51 minuteIP
/auth/register31 minuteIP
/auth/forgot-password35 minutesIP
/auth/resend-verification35 minutesEmail
/auth/refresh101 minuteUser ID
Other authenticated endpoints601 minuteUser ID

16.2 Password Requirements

RequirementValue
Minimum length8 characters
Uppercase lettersAt least 1
Lowercase lettersAt least 1
NumbersAt least 1
Special charactersRecommended (not required)

17. Role Hierarchy

owner ---- Full access, can delete org
  |
  +-- admin ---- Manage members (member/guest), all operations per scopes
        |
        +-- member ---- Operations per assigned scopes
              |
              +-- guest ---- Restricted read access per assigned scopes
                    |
                    +-- pending ---- Invite not accepted, no access
RoleCan manage membersCan alter orgCan delete orgOperations access
ownerYesYesYesAll (implicit)
adminYes (member/guest)Yes (if scope)NoPer assigned scopes
memberNoNoNoPer assigned scopes
guestNoNoNoRestricted read per scopes
pendingNoNoNoNone (invite not accepted)

18. SAML 2.0 Removal

SAML 2.0 has been removed from the v4/v5 architecture. The Jira SSO is now handled directly by the NextJS portal using a dedicated integration. Removed dependencies: simplesamlphp/simplesamlphp, robrichards/xmlseclibs. No SAML classes, configs, metadata, or certificates exist in the plugin.