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_methodMUST beS256.plainand missing values return 400 fromEnforcePkce.- The
redirect_uriMUST match the registered URI exactly. The previousstr_ends_with($allowed, '*')wildcard (audit #34) was removed in F0;Application::isRedirectUriAlloweduseshash_equals. - Trusted clients (admin panel, tenant panel, mobile app) skip the
consent screen via
App\Models\Passport\Client::skipsAuthorization. The previousSkipTrustedClientAuthorizationmiddleware that injectedapprove=1from a query string was removed (audit #11).
PKCE-required clients
PassportSeeder registers:
| Client | secret | is_trusted | Notes |
|---|---|---|---|
Admin Panel | bcrypt(env OAUTH_CLIENT_SECRET) | true | Confidential |
Mobile App | null | true | Public (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/token →
SystemAuthController::token.
The flow:
- Caller presents
grant_type=client_credentials,client_id,client_secret. Application::verifySecretrunsHash::check(bcrypt) — F0 fixed the previous reversible-encryption + plain-text-compare issue (audit #6).- 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). ValidateSystemTokenmiddleware 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:
- Verifies the target tenant membership exists and the tenant is active.
- In a transaction: updates
users.tenant_idand revokes the current token. - 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
| Method | Path | Purpose | Auth |
|---|---|---|---|
POST | /api/auth/login | Email + password (and 2FA token if pending) | public, throttle:login |
POST | /api/auth/2fa/verify | Complete login post-2FA | public, throttle:2fa |
POST | /api/auth/refresh | Rotate token (with reuse detection) | auth:api |
POST | /api/auth/logout | Revoke current token + session | auth:api |
POST | /api/auth/logout-all | Revoke every active token + drop sessions | auth:api |
POST | /api/auth/switch-tenant | Switch active tenant + new token | auth:api |
GET | /oauth/authorize | Auth-code grant entry (PKCE-required) | session cookie |
POST | /oauth/token | Token exchange (auth-code, refresh) | Passport |
POST | /api/oauth/token | Client-credentials issuance | client-creds |
POST | /api/oauth/introspect | RFC 7662 introspection | client-creds |
POST | /api/oauth/revoke | Token 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.
| Aspect | App\Models\Application | App\Models\Passport\Client |
|---|---|---|
| Backing table | applications | oauth_clients |
| Token format | 48-byte opaque random | RSA-signed JWT |
| Token storage | Redis cache, key = system_token: + sha256(token) | DB row in oauth_access_tokens |
| Grant types | client_credentials only | authorization_code, refresh_token, personal_access |
| TTL | 1 h (cache TTL) | configurable (60 min access / 30 d refresh) |
| Used by | service-to-service (tenant-services, K8s credential generator) | user-facing SPAs, mobile app, admin panel |
| Issuance endpoint | POST /api/oauth/token (SystemAuthController) | POST /oauth/token (Passport) |
| Verification | ValidateSystemToken middleware → cache lookup | auth: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 byTenantSystemTokenServicefor 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 (viaTenantOAuthService).
Why not unify
Unifying would force one of two compromises:
- Migrate
Application→Passport\Client: lose the opaque-token- cache-TTL model, gain ~600 bytes of JWT per service-to-service
request, lose the simple revoke-by-
Cache::forgetsemantics.
- cache-TTL model, gain ~600 bytes of JWT per service-to-service
request, lose the simple revoke-by-
- Migrate
Passport\Client→Application: 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).