Skip to main content

OAuth Flows

Identity is the only OAuth issuer in the Vecton platform. Every other service (admin panel, tenant frontends, mobile app, CI worker) is a client of this issuer. This document captures the four flows that ship with the F0–F7 hardening work.

1. Authorization Code + PKCE (web SPAs)

This is the only flow allowed for first-party SPAs. The implicit grant was disabled in F0 (it's deprecated by OAuth 2.1 and was leaking tokens through URL fragments) and confidential-client variants are rejected by the EnforcePkce middleware on /oauth/authorize.

┌──────┐   1. /oauth/authorize?response_type=code&code_challenge=…&code_challenge_method=S256
│ SPA │ ───────────────────────────────────────────────────────────►
│ │ ┌────────────┐
│ │ │ Identity │
│ │ 2. 302 → frontend /login │ │
│ │ ◄────────────────────────────────────────────── │ │
│ │ 3. POST /api/auth/login │ │
│ │ ───────────────────────────────────────────────► │ │
│ │ 4. session cookie + access_token (Bearer) │ │
│ │ ◄────────────────────────────────────────────── │ │
│ │ 5. GET /oauth/authorize (cookie) │ │
│ │ ───────────────────────────────────────────────► │ │
│ │ 6. trusted client → auto-approve, redirect+code│ │
│ │ ◄────────────────────────────────────────────── └────────────┘
│ │ 7. POST /oauth/token (code + code_verifier)
│ │ ──► access_token + refresh_token
└──────┘

Key constraints enforced:

  • code_challenge_method MUST be S256. plain and missing values return 400 from EnforcePkce.
  • The redirect_uri MUST match the registered URI exactly. The previous str_ends_with($allowed, '*') wildcard (audit #34) was removed in F0; Application::isRedirectUriAllowed uses hash_equals.
  • Trusted clients (admin panel, tenant panel, mobile app) skip the consent screen via App\Models\Passport\Client::skipsAuthorization. The previous SkipTrustedClientAuthorization middleware that injected approve=1 from a query string was removed (audit #11).

PKCE-required clients

PassportSeeder registers:

Clientsecretis_trustedNotes
Admin Panelbcrypt(env OAUTH_CLIENT_SECRET)trueConfidential
Mobile AppnulltruePublic (PKCE-only)

Each tenant additionally gets its own confidential oauth_clients row provisioned by TenantOAuthService::createClientForTenant.

2. Refresh-token rotation

The custom /api/auth/refresh endpoint (AuthController::refreshToken) revokes the old token and issues a new one in a single atomic step. Token ids are tagged through a cache marker (token_rotation:replaced:{id}) so any subsequent attempt to refresh with the previously rotated id triggers chain-revocation: every active token for the user is revoked, the web session is invalidated, and the response is 422.

client.refresh(token_a) → token_b   (cache: replaced[token_a] = token_b)
client.refresh(token_a) AGAIN → 422 + revoke every active token

This is reuse-detection per OAuth 2.1 §6.3 and addresses audit #13. The RefreshTokenRotationTest exercises both branches.

Token TTLs

Set in AuthServiceProvider::boot (F0). Configurable via config/passport.php env entries:

PASSPORT_TOKENS_EXPIRE_IN_MINUTES=60
PASSPORT_REFRESH_TOKENS_EXPIRE_IN_DAYS=30
PASSPORT_PERSONAL_ACCESS_TOKENS_EXPIRE_IN_DAYS=1

The previous Passport defaults of 1 year for everything (audit cross-repo #7) are gone.

3. Client credentials (system-to-system)

Used by tenant-services to make admin-grade calls back into identity (provisioning, sync, audit). Endpoint: POST /api/oauth/tokenSystemAuthController::token.

The flow:

  1. Caller presents grant_type=client_credentials, client_id, client_secret.
  2. Application::verifySecret runs Hash::check (bcrypt) — F0 fixed the previous reversible-encryption + plain-text-compare issue (audit #6).
  3. On success the issuer mints a 48-byte opaque token and stores it in cache under system_token: + sha256(token) so a cache leak never hands over usable bearer credentials (audit #8).
  4. ValidateSystemToken middleware does the same hashed lookup.

introspect and revoke are RFC 7662-style: both require the caller to authenticate as the same confidential client (Basic auth or POST body) before they answer or mutate. This closes the audit's "anyone who learns a token can revoke it" DoS path (#33).

4. Tenant switch

POST /api/auth/switch-tenant (F7) exists for users who belong to more than one tenant. The endpoint:

  1. Verifies the target tenant membership exists and the tenant is active.
  2. In a transaction: updates users.tenant_id and revokes the current token.
  3. Issues a fresh personal-access token tagged tenant-switch:{new_tenant_id}.

Downstream tenant-services see the new context immediately because the old token is gone — no waiting for the 1h policy cache to flip.

Endpoints at a glance

MethodPathPurposeAuth
POST/api/auth/loginEmail + password (and 2FA token if pending)public, throttle:login
POST/api/auth/2fa/verifyComplete login post-2FApublic, throttle:2fa
POST/api/auth/refreshRotate token (with reuse detection)auth:api
POST/api/auth/logoutRevoke current token + sessionauth:api
POST/api/auth/logout-allRevoke every active token + drop sessionsauth:api
POST/api/auth/switch-tenantSwitch active tenant + new tokenauth:api
GET/oauth/authorizeAuth-code grant entry (PKCE-required)session cookie
POST/oauth/tokenToken exchange (auth-code, refresh)Passport
POST/api/oauth/tokenClient-credentials issuanceclient-creds
POST/api/oauth/introspectRFC 7662 introspectionclient-creds
POST/api/oauth/revokeToken revoke (owner-checked)client-creds

Two OAuth client models — Application vs Passport\Client

The identity service intentionally keeps two parallel OAuth client models. They look similar (both have client_id / client_secret) but serve different audiences. The audit (#85) raised the question of unifying them — F-future-3.B's decision is don't unify, document the boundary instead.

AspectApp\Models\ApplicationApp\Models\Passport\Client
Backing tableapplicationsoauth_clients
Token format48-byte opaque randomRSA-signed JWT
Token storageRedis cache, key = system_token: + sha256(token)DB row in oauth_access_tokens
Grant typesclient_credentials onlyauthorization_code, refresh_token, personal_access
TTL1 h (cache TTL)configurable (60 min access / 30 d refresh)
Used byservice-to-service (tenant-services, K8s credential generator)user-facing SPAs, mobile app, admin panel
Issuance endpointPOST /api/oauth/token (SystemAuthController)POST /oauth/token (Passport)
VerificationValidateSystemToken middleware → cache lookupauth:api Passport guard

When to use which

  • Application — when a backend service needs to call identity with no human in the loop. Cheap to issue, cheap to revoke, no JWT bloat. Used by TenantSystemTokenService for tenant-services and by the K8s credential generator.
  • Passport\Client — for any human-facing OAuth flow (PKCE auth-code, refresh-token rotation, recovery). Used by the admin panel, mobile app, and per-tenant SPA (via TenantOAuthService).

Why not unify

Unifying would force one of two compromises:

  1. Migrate ApplicationPassport\Client: lose the opaque-token
    • cache-TTL model, gain ~600 bytes of JWT per service-to-service request, lose the simple revoke-by-Cache::forget semantics.
  2. Migrate Passport\ClientApplication: lose Passport's authcode + refresh + personal-access grant infrastructure, would need to rebuild OAuth code flow from scratch.

Neither pays for itself. The two models are separate by design: service-token == cache-backed opaque, user-token == DB-backed JWT.

Frontend pairing

The SPA reads expires_in from every login / refresh response (F0 fix for hardcoded 24h) and stores tokens behind axios interceptors that auto-refresh on 401. Idle inactivity beyond VITE_IDLE_TIMEOUT_SECONDS (default 30 min) auto-logs the user out via useIdleTimeout (F7).