{"record":{"id":"9b6bbb475ef479e2","repo":"instructure/canvas-lms","slug":"logout-service-token-has-already-been-used","errorCode":null,"errorMessage":"Logout service token has already been used","messagePattern":"Logout service token has already been used","errorType":"http","errorClass":"BasicLTI::BasicOutcomes::Unauthorized","httpStatus":401,"severity":"error","filePath":"app/models/lti/logout_service.rb","lineNumber":87,"sourceCode":"        callbacks.each_value do |callback|\n          InstrumentTLSCiphers.without_tls_metrics do\n            CanvasHttp.get(URI.parse(callback).to_s)\n          end\n        rescue => e\n          Rails.logger.error(\"Failed to call logout callback '#{callback}': #{e.inspect}\")\n        end\n      end\n    end\n\n    def self.create_token(tool, pseudonym)\n      Token.create(tool, pseudonym).serialize\n    end\n\n    def self.register_logout_callback(token, callback)\n      return unless token.pseudonym&.id && callback.present?\n\n      callbacks = get_logout_callbacks(token.pseudonym)\n      raise BasicLTI::BasicOutcomes::Unauthorized, \"Logout service token has already been used\" if callbacks.key?(token.nonce)\n\n      callbacks[token.nonce] = callback\n      Rails.cache.write(cache_key(token.pseudonym), callbacks, expires_in: 1.day)\n    end\n\n    def self.queue_callbacks(pseudonym)\n      return unless pseudonym&.id\n\n      callbacks = get_logout_callbacks(pseudonym)\n      return unless callbacks.any?\n\n      clear_logout_callbacks(pseudonym)\n      Delayed::Job.enqueue(Lti::LogoutService::Runner.new(callbacks))\n    end\n  end\nend\n","sourceCodeStart":69,"sourceCodeEnd":104,"githubUrl":"https://github.com/instructure/canvas-lms/blob/1c9f0bb8013ed69c4f2efe11fd483025469b7e6c/app/models/lti/logout_service.rb#L69-L104","documentation":"Logout service tokens are single-use: each token's nonce is registered in the Rails cache keyed by pseudonym. register_logout_callback rejects a callback if the token's nonce already exists in the cached callback map, raising Unauthorized, because replaying a nonce means the token was already used.","triggerScenarios":"Calling register_logout_callback twice with tokens carrying the same nonce for the same pseudonym — e.g. replaying the same logout service token, or nonce collisions from a badly generated token.","commonSituations":"Tools retrying logout service calls with the same token; cache retaining the nonce for the 1-day expiry window; token generation reusing nonces.","solutions":["Issue a fresh token (new nonce) for each logout service invocation","Check callbacks.keys before invoking, or rescue BasicLTI::BasicOutcomes::Unauthorized to treat replay as a no-op","Clear/expire the pseudonym's logout callback cache entry only through legitimate logout completion, not by replaying old tokens"],"exampleFix":"// before\nLogoutService.register_logout_callback(reused_token, cb) # raises\n// after\ntoken = LogoutService.create_token(tool, pseudonym) # fresh nonce each time\nLogoutService.register_logout_callback(token, cb)","handlingStrategy":"try-catch","validationCode":"used = Lti::LogoutService.get_logout_callbacks(token.pseudonym).key?(token.nonce)\nraise \"token replay\" if used","typeGuard":null,"tryCatchPattern":"begin\n  LogoutService.register_logout_callback(token, callback)\nrescue BasicLTI::BasicOutcomes::Unauthorized\n  # token already consumed; treat as no-op\nend","preventionTips":["Never reuse a logout service token","Generate a fresh nonce per token","Handle replay idempotently server-side"],"tags":["lti","nonce","replay-protection"],"backgroundTag":"invalid-argument-value","analyzedSha":"1c9f0bb8013ed69c4f2efe11fd483025469b7e6c","analyzedAt":"2026-09-15T20:33:18.891Z","contentChangedAt":"2026-09-15T20:33:18.891Z","schemaVersion":2},"datasetVersion":"2026-09-23T02:17:17.105Z"}