Class: KairosMcp::Auth::TokenStore

Inherits:
Object
  • Object
show all
Defined in:
lib/kairos_mcp/auth/token_store.rb

Overview

TokenStore: Manages Bearer tokens for HTTP authentication

Stores SHA256 hashes of tokens (never raw tokens). Supports file-based (JSON) storage. SQLite support can be added later by extending Storage::Backend.

Token lifecycle:

1. --init-admin generates the first owner token (CLI)
2. owner uses token_manage tool to create/revoke/rotate tokens
3. Tokens expire after configured period (default: 90 days)

Token data structure:

{
  token_hash: "sha256...",
  user: "username",
  role: "owner" | "member" | "guest",
  issued_at: "2026-02-12T10:00:00Z",
  expires_at: "2026-05-13T10:00:00Z" | nil,
  issued_by: "username" | "system",
  status: "active" | "revoked"
}

Roles (Phase 1: all roles available, authorization enforced in Phase 2):

- owner:  Full access, can manage tokens
- member: Standard team access (Phase 2: L1/L2 write, L0 read)
- guest:  Limited access (Phase 2: read-only, own L2 only)

Constant Summary collapse

VALID_ROLES =
%w[owner member guest].freeze
TOKEN_PREFIX =
'kc_'
DEFAULT_EXPIRY_DAYS =
90

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(store_path = nil) ⇒ TokenStore

Returns a new instance of TokenStore.



73
74
75
76
# File 'lib/kairos_mcp/auth/token_store.rb', line 73

def initialize(store_path = nil)
  @store_path = store_path || default_store_path
  @tokens = load_tokens
end

Instance Attribute Details

#store_pathObject (readonly)



71
72
73
# File 'lib/kairos_mcp/auth/token_store.rb', line 71

def store_path
  @store_path
end

Class Method Details

.create(config = {}) ⇒ Object

Factory: create a TokenStore based on config. If a SkillSet has registered a backend matching config, use that; otherwise fall back to file-based store.



61
62
63
64
65
66
67
# File 'lib/kairos_mcp/auth/token_store.rb', line 61

def self.create(config = {})
  backend = config[:backend]&.to_s
  if backend && @registry.key?(backend)
    return @registry[backend].new(config[backend.to_sym] || {})
  end
  new(config[:store_path])
end

.register(name, klass) ⇒ Object

Register a named TokenStore backend (e.g. ‘postgresql’)



50
51
52
# File 'lib/kairos_mcp/auth/token_store.rb', line 50

def self.register(name, klass)
  @registry[name.to_s] = klass
end

.unregister(name) ⇒ Object



54
55
56
# File 'lib/kairos_mcp/auth/token_store.rb', line 54

def self.unregister(name)
  @registry.delete(name.to_s)
end

Instance Method Details

#create(user:, role: 'member', issued_by: 'system', expires_in: nil, pubkey_hash: nil) ⇒ Hash

Generate a new token for a user

Parameters:

  • user (String)

    Username

  • role (String) (defaults to: 'member')

    Role: “owner”, “member”, or “guest”

  • issued_by (String) (defaults to: 'system')

    Who issued this token

  • expires_in (String, nil) (defaults to: nil)

    Expiry duration: “90d”, “24h”, “never”, or nil (default)

  • pubkey_hash (String, nil) (defaults to: nil)

    SHA256 hex of agent’s public key (for Service Grant)

Returns:

  • (Hash)

    { raw_token:, token_hash:, user:, role:, … }



86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
# File 'lib/kairos_mcp/auth/token_store.rb', line 86

def create(user:, role: 'member', issued_by: 'system', expires_in: nil, pubkey_hash: nil)
  validate_role!(role)
  validate_user!(user)

  raw_token = generate_token
  token_hash = hash_token(raw_token)
  now = Time.now

  expires_at = calculate_expiry(now, expires_in)

  entry = {
    'token_hash' => token_hash,
    'user' => user,
    'role' => role,
    'issued_at' => now.iso8601,
    'expires_at' => expires_at&.iso8601,
    'issued_by' => issued_by,
    'status' => 'active'
  }
  entry['pubkey_hash'] = pubkey_hash if pubkey_hash

  @tokens << entry
  save_tokens

  entry.merge('raw_token' => raw_token)
end

#empty?Boolean

Check if any tokens exist

Returns:

  • (Boolean)


198
199
200
# File 'lib/kairos_mcp/auth/token_store.rb', line 198

def empty?
  @tokens.empty? || @tokens.none? { |e| e['status'] == 'active' }
end

#list(include_revoked: false) ⇒ Array<Hash>

List all tokens (without hashes, for display)

Parameters:

  • include_revoked (Boolean) (defaults to: false)

    Include revoked tokens

Returns:

  • (Array<Hash>)

    Token summaries



178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
# File 'lib/kairos_mcp/auth/token_store.rb', line 178

def list(include_revoked: false)
  entries = @tokens
  entries = entries.select { |e| e['status'] == 'active' } unless include_revoked

  entries.map do |entry|
    {
      user: entry['user'],
      role: entry['role'],
      status: entry['status'],
      issued_at: entry['issued_at'],
      expires_at: entry['expires_at'],
      issued_by: entry['issued_by'],
      expired: expired?(entry)
    }
  end
end

#reload!Object

Reload tokens from disk



203
204
205
# File 'lib/kairos_mcp/auth/token_store.rb', line 203

def reload!
  @tokens = load_tokens
end

#revoke(user:) ⇒ Integer

Revoke a user’s token(s)

Parameters:

  • user (String)

    Username whose tokens to revoke

Returns:

  • (Integer)

    Number of tokens revoked



139
140
141
142
143
144
145
146
147
148
149
150
# File 'lib/kairos_mcp/auth/token_store.rb', line 139

def revoke(user:)
  count = 0
  @tokens.each do |entry|
    if entry['user'] == user && entry['status'] == 'active'
      entry['status'] = 'revoked'
      count += 1
    end
  end

  save_tokens if count > 0
  count
end

#rotate(user:, issued_by: 'system') ⇒ Hash

Rotate a user’s token (revoke old, create new)

Parameters:

  • user (String)

    Username

  • issued_by (String) (defaults to: 'system')

    Who is rotating

Returns:

  • (Hash)

    New token info



157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
# File 'lib/kairos_mcp/auth/token_store.rb', line 157

def rotate(user:, issued_by: 'system')
  old_entry = @tokens.find { |e| e['user'] == user && e['status'] == 'active' }
  role = old_entry ? old_entry['role'] : 'member'
  expires_in = nil

  if old_entry && old_entry['expires_at']
    # Preserve the same expiry duration
    original_issued = Time.parse(old_entry['issued_at'])
    original_expires = Time.parse(old_entry['expires_at'])
    duration_seconds = (original_expires - original_issued).to_i
    expires_in = "#{duration_seconds / 86400}d"
  end

  revoke(user: user)
  create(user: user, role: role, issued_by: issued_by, expires_in: expires_in)
end

#verify(raw_token) ⇒ Hash?

Verify a raw token and return user info

Parameters:

  • raw_token (String)

    The Bearer token to verify

Returns:

  • (Hash, nil)

    User info if valid, nil if invalid/expired/revoked



117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
# File 'lib/kairos_mcp/auth/token_store.rb', line 117

def verify(raw_token)
  token_hash = hash_token(raw_token)
  entry = find_by_hash(token_hash)

  return nil unless entry
  return nil if entry['status'] != 'active'
  return nil if expired?(entry)

  result = {
    user: entry['user'],
    role: entry['role'],
    issued_at: entry['issued_at'],
    expires_at: entry['expires_at']
  }
  result[:pubkey_hash] = entry['pubkey_hash'] if entry['pubkey_hash']
  result
end