{"record":{"id":"782a4dc94982eeb8","repo":"BookStackApp/BookStack","slug":"missing-token-expiration-time-value","errorCode":null,"errorMessage":"Missing token expiration time value","messagePattern":"Missing token expiration time value","errorType":"exception","errorClass":"OidcInvalidTokenException","httpStatus":null,"severity":"error","filePath":"app/Access/Oidc/OidcIdToken.php","lineNumber":54,"sourceCode":"        // Partially done in parent.\n        $aud = is_string($this->payload['aud']) ? [$this->payload['aud']] : $this->payload['aud'];\n        if (count($aud) !== 1) {\n            throw new OidcInvalidTokenException('Token audience value has ' . count($aud) . ' values, Expected 1');\n        }\n\n        // 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) {","sourceCodeStart":36,"sourceCodeEnd":72,"githubUrl":"https://github.com/BookStackApp/BookStack/blob/18f8469a1c72f8cc8497e9372635e6dea5028071/app/Access/Oidc/OidcIdToken.php#L36-L72","documentation":"An OIDC ID token must carry an exp (expiration) claim; validateTokenClaims rejects any token whose payload lacks an exp value. The spec requires exp so the client can enforce token lifetime, and BookStack hard-fails when it is missing rather than assuming a default.","triggerScenarios":"validate() on an ID token whose decoded payload has no 'exp' key or an empty/falsy exp — produced by a custom/misconfigured IdP token mapper that omits the claim, or a hand-crafted/signing-test token.","commonSituations":"Custom Keycloak/Okta protocol mappers or a homegrown OIDC provider not including standard claims; stripped claims by a token-transforming proxy; tokens built manually in tests; using a non-standard 'id token' endpoint that returns minimal claims.","solutions":["Fix the IdP/token mapper so the ID token includes the standard exp claim","Decode the token at jwt.io to confirm which claims the IdP actually emits","If using a custom OIDC provider, ensure ID tokens comply with OIDC Core (iss, sub, aud, exp, iat required)","Check whether a proxy or middleware is stripping claims from responses","Update the IdP firmware/plugin version if a known bug omitted standard claims"],"exampleFix":"// before (custom token builder)\n$payload = ['iss' => $iss, 'sub' => $sub, 'aud' => $aud];\n// after\n$payload = ['iss' => $iss, 'sub' => $sub, 'aud' => $aud,\n            'exp' => time() + 300, 'iat' => time()];","handlingStrategy":"validation","validationCode":"$payload = /* decode jwt payload */;\nif (empty($payload['exp'])) {\n    throw new InvalidArgumentException('ID token missing exp claim — fix IdP token mapper');\n}","typeGuard":"function hasRequiredOidcClaims(?array $payload): bool {\n    return is_array($payload)\n        && isset($payload['iss'], $payload['sub'], $payload['aud'], $payload['exp'], $payload['iat']);\n}","tryCatchPattern":"try {\n    $idToken = OidcIdToken::validate($token, $clientId, $keys);\n} catch (OidcInvalidTokenException $e) {\n    if (str_contains($e->getMessage(), 'expiration')) {\n        Log::error('OIDC token missing exp; IdP token configuration is non-compliant');\n    }\n    throw $e;\n}","preventionTips":["Use an OIDC-certified provider or verify standard claims are emitted","Inspect the raw token during IdP setup to confirm iss/sub/aud/exp/iat present","Avoid custom mappers that strip standard ID token claims","Test against a fresh decoded token, not hand-built ones"],"tags":["oidc","jwt","token-validation","openid-connect"],"backgroundTag":"jwt-missing-claim","analyzedSha":"18f8469a1c72f8cc8497e9372635e6dea5028071","analyzedAt":"2026-09-02T19:49:33.068Z","contentChangedAt":null,"schemaVersion":2},"datasetVersion":"2026-09-10T02:17:09.455Z"}