Skip to content

Commit bd313d3

Browse files
committed
Merge pull request #2911 from OpenC3/maint/cable
One-time use tokens for AnyCable
1 parent 5ebdd22 commit bd313d3

File tree

13 files changed

+730
-29
lines changed

13 files changed

+730
-29
lines changed

openc3-cosmos-cmd-tlm-api/app/controllers/auth_controller.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,12 @@ def verify_service
8080
end
8181
end
8282

83+
def get_otp
84+
user = authorization('system')
85+
return unless user
86+
render :plain => OpenC3::Authorization.generate_otp(user)
87+
end
88+
8389
def set
8490
if user_rate_limited?
8591
head :too_many_requests

openc3-cosmos-cmd-tlm-api/config/routes.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,7 @@
219219
get "/auth/token-exists" => "auth#token_exists"
220220
post "/auth/verify" => "auth#verify"
221221
post "/auth/set" => "auth#set"
222+
get "/auth/otp" => "auth#get_otp"
222223

223224
get "/internal/health" => "internal_health#health"
224225
get "/internal/metrics" => "internal_metrics#index"

openc3-cosmos-init/plugins/packages/openc3-js-common/src/services/cable.js

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,19 +13,21 @@
1313
# GNU Affero General Public License for more details.
1414
1515
# Modified by OpenC3, Inc.
16-
# All changes Copyright 2022, OpenC3, Inc.
16+
# All changes Copyright 2026, OpenC3, Inc.
1717
# All Rights Reserved
1818
#
1919
# This file may also be used under the terms of a commercial license
2020
# if purchased from OpenC3, Inc.
2121
*/
2222

23+
import { Api } from '.'
2324
import { createConsumer } from '@anycable/web'
2425

2526
export default class Cable {
26-
constructor(url = '/openc3-api/cable') {
27+
constructor(url = '/openc3-api/cable', otpUrl = '/openc3-api/auth/otp') {
2728
this._cable = null
2829
this._url = url
30+
this._otpUrl = otpUrl
2931
}
3032
disconnect() {
3133
if (this._cable) {
@@ -34,20 +36,23 @@ export default class Cable {
3436
}
3537
}
3638
createSubscription(channel, scope, callbacks = {}, additionalOptions = {}) {
37-
return OpenC3Auth.updateToken(OpenC3Auth.defaultMinValidity).then(
38-
(refreshed) => {
39+
return OpenC3Auth.updateToken(OpenC3Auth.defaultMinValidity)
40+
.then((refreshed) => {
3941
if (refreshed) {
4042
OpenC3Auth.setTokens()
4143
}
44+
return Api.get(this._otpUrl, {
45+
params: {
46+
scope,
47+
},
48+
})
49+
})
50+
.then(({ data: otp }) => {
4251
if (this._cable == null) {
43-
let final_url =
44-
this._url +
45-
'?scope=' +
46-
encodeURIComponent(scope) +
47-
'&authorization=' +
48-
encodeURIComponent(localStorage.openc3Token)
49-
final_url = new URL(final_url, document.baseURI).href
50-
this._cable = createConsumer(final_url)
52+
const finalUrl = new URL(this._url, document.baseURI)
53+
finalUrl.searchParams.set('scope', scope)
54+
finalUrl.searchParams.set('authorization', otp)
55+
this._cable = createConsumer(finalUrl.href)
5156
}
5257
return this._cable.subscriptions.create(
5358
{
@@ -56,8 +61,7 @@ export default class Cable {
5661
},
5762
callbacks,
5863
)
59-
},
60-
)
64+
})
6165
}
6266
recordPing() {
6367
// Noop with Anycable

openc3/lib/openc3/models/auth_model.rb

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ class AuthModel
3838

3939
MIN_TOKEN_LENGTH = 8
4040

41+
SESSION_PREFIX = "ses_"
42+
OTP_PREFIX = "otp_"
43+
4144
def self.set?(key = PRIMARY_KEY)
4245
Store.exists(key) == 1
4346
end
@@ -66,12 +69,18 @@ def self.verify_no_service(token, mode: :token)
6669

6770
time = Time.now
6871
unless mode == :password
69-
return true if @@session_cache and (time - @@session_cache_time) < SESSION_CACHE_TIMEOUT and @@session_cache[token]
72+
if @@session_cache and (time - @@session_cache_time) < SESSION_CACHE_TIMEOUT and @@session_cache[token]
73+
terminate_otp(token)
74+
return true
75+
end
7076

7177
# Check stored session tokens
7278
@@session_cache = Store.hgetall(SESSIONS_KEY)
7379
@@session_cache_time = time
74-
return true if @@session_cache[token]
80+
if @@session_cache[token]
81+
terminate_otp(token)
82+
return true
83+
end
7584
end
7685

7786
unless mode == :token
@@ -103,8 +112,16 @@ def self.set(token, old_token, key = PRIMARY_KEY)
103112
logout
104113
end
105114

106-
def self.generate_session
115+
# Creates a new session token. DO NOT CALL BEFORE VERIFYING.
116+
# @param otp [Boolean] whether to create a one-time use token (default: false)
117+
# @return [String] the new session token
118+
def self.generate_session(otp: false)
107119
token = SecureRandom.urlsafe_base64(nil, false)
120+
if otp
121+
token = OTP_PREFIX + token
122+
else
123+
token = SESSION_PREFIX + token
124+
end
108125
Store.hset(SESSIONS_KEY, token, Time.now.iso8601)
109126
return token
110127
end
@@ -118,5 +135,16 @@ def self.logout
118135
def self.hash(token)
119136
Digest::SHA2.hexdigest token
120137
end
138+
139+
# Terminates the given session token.
140+
def self.terminate(token)
141+
Store.hdel(SESSIONS_KEY, token)
142+
@@session_cache.delete(token) if @@session_cache
143+
end
144+
145+
# Terminates the session if the token is an OTP.
146+
def self.terminate_otp(token)
147+
terminate(token) if token.start_with?(OTP_PREFIX)
148+
end
121149
end
122150
end

openc3/lib/openc3/script/web_socket_api.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# encoding: ascii-8bit
22

3-
# Copyright 2023 OpenC3, Inc.
3+
# Copyright 2026 OpenC3, Inc.
44
# All Rights Reserved.
55
#
66
# This program is free software; you can modify and/or redistribute it
@@ -126,7 +126,7 @@ def write(data)
126126
# Connect to the websocket with authorization in query params
127127
def connect
128128
disconnect()
129-
final_url = @url + "?scope=#{@scope}&authorization=#{@authentication.token(include_bearer: false)}"
129+
final_url = @url + "?scope=#{@scope}&authorization=#{@authentication.get_otp(scope: @scope)}"
130130
@stream = WebSocketClientStream.new(final_url, @write_timeout, @read_timeout, @connect_timeout)
131131
@stream.headers = {
132132
'Sec-WebSocket-Protocol' => 'actioncable-v1-json, actioncable-unsupported',

openc3/lib/openc3/utilities/authentication.rb

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
# GNU Affero General Public License for more details.
1515

1616
# Modified by OpenC3, Inc.
17-
# All changes Copyright 2023, OpenC3, Inc.
17+
# All changes Copyright 2026, OpenC3, Inc.
1818
# All Rights Reserved
1919
#
2020
# This file may also be used under the terms of a commercial license
@@ -37,12 +37,74 @@ def initialize()
3737
if @token.nil?
3838
raise OpenC3AuthenticationError, "Authentication requires environment variable OPENC3_API_PASSWORD"
3939
end
40+
@service = password == ENV['OPENC3_SERVICE_PASSWORD']
41+
retry_faraday_request do
42+
response = _make_auth_request(password)
43+
@token = response.body
44+
end
45+
if @token.nil? or @token.empty?
46+
raise OpenC3AuthenticationError, "Authentication failed. Please check the password in the environment variable OPENC3_API_PASSWORD"
47+
end
4048
end
4149

4250
# Load the token from the environment
4351
def token(include_bearer: true)
4452
@token
4553
end
54+
55+
def get_otp(scope: 'DEFAULT')
56+
if @token.nil? or @token.empty?
57+
raise OpenC3AuthenticationError, "Uninitialized authentication: unable to get OTP"
58+
end
59+
retry_faraday_request do
60+
response = _make_otp_request(scope: scope)
61+
return response.body
62+
end
63+
end
64+
65+
def _make_auth_request(password)
66+
Faraday.new.post(_generate_auth_url, '{"password": "' + password + '"}', {'Content-Type' => 'application/json'})
67+
end
68+
69+
def _make_otp_request(scope: 'DEFAULT')
70+
params = {
71+
'scope' => scope
72+
}
73+
headers = {
74+
'Authorization' => token,
75+
}
76+
Faraday.new.get(_generate_auth_url('/auth/otp'), params, headers)
77+
end
78+
79+
def _generate_auth_url(endpoint = nil)
80+
schema = ENV['OPENC3_API_SCHEMA'] || 'http'
81+
hostname = ENV['OPENC3_API_HOSTNAME'] || (ENV['OPENC3_DEVEL'] ? '127.0.0.1' : 'openc3-cosmos-cmd-tlm-api')
82+
port = ENV['OPENC3_API_PORT'] || '2901'
83+
port = port.to_i
84+
unless endpoint
85+
endpoint = if @service
86+
"auth/verify_service"
87+
else
88+
"auth/verify"
89+
end
90+
end
91+
return "#{schema}://#{hostname}:#{port}/openc3-api/#{endpoint}"
92+
end
93+
94+
def retry_faraday_request(max_retries: 3)
95+
retries = 0
96+
begin
97+
yield
98+
rescue Faraday::ConnectionFailed, Faraday::TimeoutError => e
99+
retries += 1
100+
if retries <= max_retries
101+
STDOUT.puts "Authentication request failed (attempt #{retries}/3): #{e.message}. Retrying in #{retries}s..."
102+
sleep(retries)
103+
retry
104+
end
105+
raise
106+
end
107+
end
46108
end
47109

48110
# OpenC3 enterprise Keycloak authentication code

openc3/lib/openc3/utilities/authorization.rb

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
# GNU Affero General Public License for more details.
1515

1616
# Modified by OpenC3, Inc.
17-
# All changes Copyright 2024, OpenC3, Inc.
17+
# All changes Copyright 2026, OpenC3, Inc.
1818
# All Rights Reserved
1919
#
2020
# This file may also be used under the terms of a commercial license
@@ -34,6 +34,13 @@ class ForbiddenError < StandardError
3434
end
3535

3636
module Authorization
37+
ANONYMOUS_USER = "anonymous"
38+
39+
def self.generate_otp(user)
40+
raise AuthError.new("Invalid OTP user") unless user == ANONYMOUS_USER
41+
return OpenC3::AuthModel.generate_session(otp: true)
42+
end
43+
3744
private
3845

3946
# Raises an exception if unauthorized, otherwise does nothing
@@ -46,7 +53,7 @@ def authorize(permission: nil, target_name: nil, packet_name: nil, interface_nam
4653
raise AuthError.new("Password is invalid")
4754
end
4855
end
49-
return "anonymous"
56+
return ANONYMOUS_USER
5057
end
5158

5259
def user_info(_token)

openc3/python/openc3/script/web_socket_api.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright 2025 OpenC3, Inc.
1+
# Copyright 2026 OpenC3, Inc.
22
# All Rights Reserved.
33
#
44
# This program is free software; you can modify and/or redistribute it
@@ -138,7 +138,7 @@ def connect(self):
138138
self.disconnect()
139139
# Add the token directly in the URL since adding it to the header doesn't seem to work
140140
# Note in the this case we remove the "Bearer " string which is part of the token
141-
final_url = self.url + f"?scope={self.scope}&authorization={self.authentication.token(include_bearer=False)}"
141+
final_url = self.url + f"?scope={self.scope}&authorization={self.authentication.get_otp(self.scope)}"
142142
self.stream = WebSocketClientStream(final_url, self.write_timeout, self.read_timeout, self.connect_timeout)
143143
self.stream.headers = {
144144
"Sec-WebSocket-Protocol": "actioncable-v1-json, actioncable-unsupported",

openc3/python/openc3/utilities/authentication.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright 2023 OpenC3, Inc.
1+
# Copyright 2026 OpenC3, Inc.
22
# All Rights Reserved.
33
#
44
# This program is free software; you can modify and/or redistribute it
@@ -41,6 +41,25 @@ def __init__(self):
4141
def token(self, include_bearer=True):
4242
return self._token
4343

44+
def get_otp(self, scope="DEFAULT"):
45+
if not self._token:
46+
raise OpenC3AuthenticationError("Uninitialized authentication: unable to get OTP")
47+
response = Session().get(
48+
self._generate_auth_url("/auth/otp"),
49+
params={"scope": scope},
50+
headers={"Authorization": self.token()},
51+
)
52+
return response.text
53+
54+
def _generate_auth_url(self, endpoint=None):
55+
schema = OPENC3_API_SCHEMA or "http"
56+
hostname = OPENC3_API_HOSTNAME or ("127.0.0.1" if OPENC3_DEVEL else "openc3-cosmos-cmd-tlm-api")
57+
port = OPENC3_API_PORT or "2901"
58+
port = int(port)
59+
if endpoint is None:
60+
endpoint = "auth/verify_service" if self.service else "auth/verify"
61+
return f"{schema}://{hostname}:{port}/openc3-api/{endpoint}"
62+
4463

4564
# OpenC3 enterprise Keycloak authentication code
4665
class OpenC3KeycloakAuthentication(OpenC3Authentication):

0 commit comments

Comments
 (0)