Module: McpToolkit::Authority::Token
- Extended by:
- ActiveSupport::Concern
- Defined in:
- lib/mcp_toolkit/authority/token.rb
Overview
Generic, ownership-agnostic machinery for an AUTHORITY's access-token model,
packaged as a concern to include into a host's ActiveRecord token model. It
extracts everything about a token that is NOT specific to the host's tenancy
model (how a token maps to accounts/users), leaving the host to declare only its
ownership associations, account resolution, and any bespoke validations.
Expected columns on the including model:
token_digest :string NOT NULL, unique — SHA256(plaintext) for O(1) lookup
token_prefix :string NOT NULL — first 11 plaintext chars, safe to display
scopes :string[] / json — OAuth-style "<app>__<action>" grants (nullable)
expires_at :datetime nullable — nil = never expires
last_used_at :datetime nullable — throttled touch (see #touch_last_used!)
What it provides:
* Secure token generation on create (`assign_token`) + the plaintext reader
(`#token`, populated only on the instance that generated it).
* Lookup/verification: `.authenticate(plaintext)` / `.digest_for(plaintext)`.
* Lifecycle scopes (`active` / `expired` / `never_used` / `used_within`) and
`#expired?`, plus the throttled `#touch_last_used!`.
* OAuth-style scope helpers (`#normalized_scopes` / `#authorized_for_scope?` /
`#scope_restricted?`).
Why a plain SHA256 (not bcrypt/scrypt/argon2): the token is a high-entropy random
secret (24 bytes over a 64-char alphabet ≈ 144 bits), so rainbow tables can't
exist and brute force is infeasible — the slow KDFs that protect low-entropy
passwords buy nothing and would break the O(1) find_by(token_digest:) lookup.
Defined Under Namespace
Modules: ClassMethods
Constant Summary collapse
- TOKEN_PREFIX =
Plaintext token layout. "mcp_" is the MCP-generic scheme prefix (not a host fingerprint); the display length is that prefix plus 7 random chars.
"mcp_"- RAW_TOKEN_BYTES =
24- TOKEN_PREFIX_DISPLAY_LENGTH =
11- LAST_USED_AT_THROTTLE =
Skip the last_used_at UPDATE unless this much time has passed, so a burst of calls doesn't write on every request.
1.minute
Instance Method Summary collapse
-
#authorized_for_scope?(scope) ⇒ Boolean
Per-scope check.
- #expired? ⇒ Boolean
-
#normalized_scopes ⇒ Object
The OAuth-style scopes granted to this token as a clean array of "
__ " strings. - #reload ⇒ Object
-
#scope_restricted? ⇒ Boolean
True when the token carries an explicit scope set (i.e. is restricted).
-
#touch_last_used! ⇒ Object
Throttled last_used_at bump: persists on its own, without validations or bumping updated_at, and only once per LAST_USED_AT_THROTTLE window.
Instance Method Details
#authorized_for_scope?(scope) ⇒ Boolean
Per-scope check. A tool requiring no scope is reachable by any token; a tool requiring a scope needs the token to HOLD that exact scope. An unrestricted token holds NO scopes, so it can reach only no-scope tools.
93 94 95 96 97 |
# File 'lib/mcp_toolkit/authority/token.rb', line 93 def (scope) return true if scope.blank? normalized_scopes.include?(scope.to_s) end |
#expired? ⇒ Boolean
80 81 82 |
# File 'lib/mcp_toolkit/authority/token.rb', line 80 def expired? expires_at.present? && expires_at <= Time.current end |
#normalized_scopes ⇒ Object
The OAuth-style scopes granted to this token as a clean array of "scopes) returns [].
86 87 88 |
# File 'lib/mcp_toolkit/authority/token.rb', line 86 def normalized_scopes Array(scopes).compact_blank end |
#reload ⇒ Object
75 76 77 78 |
# File 'lib/mcp_toolkit/authority/token.rb', line 75 def reload(...) @token = nil super end |
#scope_restricted? ⇒ Boolean
True when the token carries an explicit scope set (i.e. is restricted).
100 101 102 |
# File 'lib/mcp_toolkit/authority/token.rb', line 100 def scope_restricted? normalized_scopes.any? end |
#touch_last_used! ⇒ Object
Throttled last_used_at bump: persists on its own, without validations or bumping updated_at, and only once per LAST_USED_AT_THROTTLE window.
106 107 108 109 110 111 |
# File 'lib/mcp_toolkit/authority/token.rb', line 106 def touch_last_used! return unless last_used_at.nil? || last_used_at < LAST_USED_AT_THROTTLE.ago self.last_used_at = Time.current save!(validate: false, touch: false) end |