{"record":{"id":"8059fcdac35829b4","repo":"BookStackApp/BookStack","slug":"token-has-expired","errorCode":null,"errorMessage":"Token has expired","messagePattern":"Token has expired","errorType":"exception","errorClass":"OidcInvalidTokenException","httpStatus":null,"severity":"error","filePath":"app/Access/Oidc/OidcIdToken.php","lineNumber":60,"sourceCode":"        // 3. If the ID Token contains multiple audiences, the Client SHOULD verify that an azp Claim is present.\n        // NOTE: Addressed by enforcing a count of 1 above.\n\n        // 4. If an azp (authorized party) Claim is present, the Client SHOULD verify that its client_id\n        // is the Claim Value.\n        if (isset($this->payload['azp']) && $this->payload['azp'] !== $clientId) {\n            throw new OidcInvalidTokenException('Token authorized party exists but does not match the expected client_id');\n        }\n\n        // 5. The current time MUST be before the time represented by the exp Claim\n        // (possibly allowing for some small leeway to account for clock skew).\n        if (empty($this->payload['exp'])) {\n            throw new OidcInvalidTokenException('Missing token expiration time value');\n        }\n\n        $skewSeconds = 120;\n        $now = time();\n        if ($now >= (intval($this->payload['exp']) + $skewSeconds)) {\n            throw new OidcInvalidTokenException('Token has expired');\n        }\n\n        // 6. The iat Claim can be used to reject tokens that were issued too far away from the current time,\n        // limiting the amount of time that nonces need to be stored to prevent attacks.\n        // The acceptable range is Client specific.\n        if (empty($this->payload['iat'])) {\n            throw new OidcInvalidTokenException('Missing token issued at time value');\n        }\n\n        $dayAgo = time() - 86400;\n        $iat = intval($this->payload['iat']);\n        if ($iat > ($now + $skewSeconds) || $iat < $dayAgo) {\n            throw new OidcInvalidTokenException('Token issue at time is not recent or is invalid');\n        }\n\n        // 7. If the acr Claim was requested, the Client SHOULD check that the asserted Claim Value is appropriate.\n        // The meaning and processing of acr Claim Values is out of scope for this document.\n        // NOTE: Not used for our case here. acr is not requested.","sourceCodeStart":42,"sourceCodeEnd":78,"githubUrl":"https://github.com/BookStackApp/BookStack/blob/18f8469a1c72f8cc8497e9372635e6dea5028071/app/Access/Oidc/OidcIdToken.php#L42-L78","documentation":"The exp claim was present but the current time has passed exp + 120 seconds of allowed clock skew, so the ID token is expired and validateTokenClaims rejects it. This protects against replaying old tokens; the 2-minute skew window tolerates minor clock drift between client and IdP.","triggerScenarios":"validate() called with a token whose exp timestamp is in the past (beyond the 120s skew): user left the OIDC login flow open too long, IdP issues very short-lived tokens, or the server clock is ahead of the IdP clock.","commonSituations":"Slow login redirect where the user waits minutes before the callback; NTP drift on the BookStack server making it think valid tokens are expired; replaying a captured/hardcoded token in tests; IdP token lifetime configured to a few seconds.","solutions":["Restart the OIDC login flow to obtain a fresh ID token","Sync server clocks with NTP (timedatectl / chrony) to eliminate clock drift","Increase the IdP's ID token lifetime if it is set impractically short","Do not cache or reuse ID tokens across login attempts; use each token immediately after issuance","For testing, generate tokens at request time instead of reusing stored ones"],"exampleFix":"// before (test fixture)\n$token = buildIdToken(['exp' => 1600000000]); // long past\n// after\n$token = buildIdToken(['iat' => time(), 'exp' => time() + 300]);","handlingStrategy":"retry","validationCode":"$payload = /* decode jwt payload */;\nif (!empty($payload['exp']) && time() >= (intval($payload['exp']) + 120)) {\n    // token already expired — request a fresh one instead of validating\n    return redirect('/oidc/login');\n}","typeGuard":"function tokenIsCurrent(?array $payload, int $skew = 120): bool {\n    return isset($payload['exp']) && time() < (intval($payload['exp']) + $skew);\n}","tryCatchPattern":"try {\n    $idToken = OidcIdToken::validate($token, $clientId, $keys);\n} catch (OidcInvalidTokenException $e) {\n    if (str_contains($e->getMessage(), 'expired')) {\n        return redirect('/oidc/login'); // silently restart the flow for a fresh token\n    }\n    throw $e;\n}","preventionTips":["Run NTP/chrony on the app server to prevent clock-drift false expiries","Consume ID tokens immediately after callback; never cache across attempts","Set a sane IdP token lifetime (a few minutes minimum)","In tests, mint tokens at request time with current iat/exp"],"tags":["oidc","jwt","token-expired","clock-skew"],"backgroundTag":"jwt-token-expired","analyzedSha":"18f8469a1c72f8cc8497e9372635e6dea5028071","analyzedAt":"2026-09-02T19:49:33.068Z","contentChangedAt":null,"schemaVersion":2},"datasetVersion":"2026-09-10T02:17:09.455Z"}