Module: Turbocable::Auth

Defined in:
lib/turbocable/auth.rb

Overview

JWT minting and public-key publishing for the TurboCable gateway.

The gateway validates every WebSocket connection with an RS256 JWT. The gem is responsible for:

  1. Minting short-lived tokens via Auth.issue_token.

  2. Publishing the corresponding public key to the NATS KV bucket the gateway watches via Auth.publish_public_key!.

Typical boot sequence

Turbocable.configure do |c|
  c.jwt_private_key = File.read("private.pem")
  c.jwt_public_key  = File.read("public.pem")
  c.jwt_issuer      = "my-rails-app"
end

# Once at boot (or after every key rotation):
Turbocable::Auth.publish_public_key!

# Per request / per user:
token = Turbocable::Auth.issue_token(
  sub:             current_user.id.to_s,
  allowed_streams: ["chat_room_*"],
  ttl:             3600
)

Allowed-stream patterns

The server supports three glob forms for allowed_streams:

  • “*” — the subscriber may access any stream.

  • “prefix_*” — any stream whose name starts with prefix_. The prefix must be a non-empty string matching /A\z/ and the trailing *+ must be the only wildcard character.

  • Exact name — a single stream name matching +/A\z/.

Anything else (embedded dots, mid-string wildcards, multiple wildcards, whitespace) is rejected by issue_token at mint time with AuthError.

Key rotation runbook

See docs/auth.md for the full runbook, including the warning about TURBOCABLE_JWT_PUBLIC_KEY_PATH silently shadowing the KV entry.

Class Method Summary collapse

Class Method Details

.issue_token(sub:, allowed_streams:, ttl:, **extra_claims) ⇒ String

Mints a short-lived RS256 JWT for a WebSocket subscriber.

Parameters:

  • sub (String)

    subject (typically a user ID or session ID)

  • allowed_streams (Array<String>)

    stream patterns the subscriber may access. Each entry must be “*”, “prefix_*”, or an exact name.

  • ttl (Integer)

    token lifetime in seconds (minimum recommended: 60)

  • extra_claims (Hash)

    additional claims merged into the payload

Returns:

  • (String)

    signed JWT string

Raises:

  • (ConfigurationError)

    if jwt_private_key is not configured

  • (AuthError)

    if the key is not a valid RSA private key, if it is an HMAC secret, or if any allowed_streams entry is invalid



64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
# File 'lib/turbocable/auth.rb', line 64

def self.issue_token(sub:, allowed_streams:, ttl:, **extra_claims)
  config = Turbocable.config

  pem = config.jwt_private_key
  raise ConfigurationError, "jwt_private_key is required to mint tokens" if pem.nil? || pem.empty?

  rsa_key = load_rsa_private_key!(pem)
  validate_allowed_streams!(allowed_streams)

  now = Time.now.to_i
  payload = {
    sub: sub,
    allowed_streams: allowed_streams,
    iat: now,
    exp: now + ttl
  }
  payload[:iss] = config.jwt_issuer if config.jwt_issuer
  payload.merge!(extra_claims)

  JWT.encode(payload, rsa_key, "RS256")
end

.publish_public_key!Integer

Publishes the configured RSA public key PEM to the NATS KV bucket that the gateway watches for hot-reload.

Creates the TC_PUBKEYS bucket if it does not yet exist. The server watches the bucket but never creates it.

Warning: if the server operator has set TURBOCABLE_JWT_PUBLIC_KEY_PATH to a file, the server will prioritise that file over the KV entry and KV rotations will be silently ignored. This method emits a :warn log when it detects this condition (by probing GET /pubkey on the server). See docs/auth.md for the rotation runbook.

Returns:

  • (Integer)

    KV revision number of the written entry

Raises:

  • (ConfigurationError)

    if jwt_public_key is not configured

  • (AuthError)

    if jwt_public_key contains private-key PEM material or is not a valid RSA public key



102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
# File 'lib/turbocable/auth.rb', line 102

def self.publish_public_key!
  config = Turbocable.config

  pem = config.jwt_public_key
  raise ConfigurationError, "jwt_public_key is required to publish the public key" if pem.nil? || pem.empty?

  # Guard against accidentally publishing a private key
  if pem.include?("PRIVATE KEY")
    raise AuthError,
      "jwt_public_key appears to contain a private key — refusing to publish. " \
      "Set jwt_public_key to the *public* half of your RSA key pair."
  end

  rsa_pub = load_rsa_public_key!(pem)
  canonical_pem = rsa_pub.public_key.to_pem

  maybe_warn_file_shadow!(config, canonical_pem)

  kv = Turbocable.client.send(:connection).key_value(config.jwt_kv_bucket)
  revision = kv.put(config.jwt_kv_key, canonical_pem)

  config.logger.info do
    "[Turbocable::Auth] Published public key to #{config.jwt_kv_bucket}/#{config.jwt_kv_key} " \
    "(revision #{revision})"
  end

  revision
end

.valid_stream_pattern?(pattern) ⇒ Boolean

Returns true if pattern is a valid allowed_streams entry for the server’s glob grammar. Exposed for callers that want to pre-validate patterns without minting a full token.

Parameters:

  • pattern (String)

Returns:

  • (Boolean)


157
158
159
160
161
162
163
164
165
166
167
# File 'lib/turbocable/auth.rb', line 157

def self.valid_stream_pattern?(pattern)
  return true if pattern == "*"

  if pattern.end_with?("*")
    prefix = pattern[0..-2]
    return false if prefix.empty?
    return prefix.match?(/\A[A-Za-z0-9_:-]+\z/)
  end

  pattern.match?(/\A[A-Za-z0-9_:-]+\z/)
end

.verify_token(token) ⇒ Array<(Hash, Hash)>

Decodes and verifies a JWT signed with the configured public key.

*Intended for test suites only* — the gateway verifies tokens itself. Do not use this in production request paths.

Parameters:

  • token (String)

    the JWT to verify

Returns:

  • (Array<(Hash, Hash)>)

    [payload, header] as returned by JWT.decode

Raises:

  • (ConfigurationError)

    if jwt_public_key is not configured

  • (JWT::DecodeError)

    and subclasses on verification failure



141
142
143
144
145
146
147
148
149
# File 'lib/turbocable/auth.rb', line 141

def self.verify_token(token)
  config = Turbocable.config

  pem = config.jwt_public_key
  raise ConfigurationError, "jwt_public_key is required to verify tokens" if pem.nil? || pem.empty?

  rsa_pub = load_rsa_public_key!(pem)
  JWT.decode(token, rsa_pub.public_key, true, algorithms: ["RS256"])
end