@icon("./security-icon.svg") @tool extends Node ## Orchestrates the complete authentication process class_name OAuth const OAuthHTTPServer = preload("res://addons/twitcher/lib/http/http_server.gd") const OAuthHTTPClient = preload("res://addons/twitcher/lib/http/buffered_http_client.gd") const OAuthDeviceCodeResponse = preload("./oauth_device_code_response.gd") ## Called when the authorization for AuthCodeFlow is complete to handle the auth code signal _auth_succeed(code: String) ## In case the authorization wasn't succesfull signal auth_error(error: String, error_description: String) ## The requested devicecode to show to the user for authorization signal device_code_requested(device_code: OAuthDeviceCodeResponse) ## Called when the token has changed signal token_changed(access_token: String) @export var oauth_setting: OAuthSetting @export var scopes: OAuthScopes @export var token_handler: OAuthTokenHandler ## Customize how you want to open the authorization page (advanced usage for example multi user authentication) @export var shell_command: String ## Parameters for the shell command (advanced usage for example multi user authentication) @export var shell_parameter: Array[String] = [] ## Some oauth provide doesn't return the provided scopes so you can disable the scope check @export var check_scope_changed: bool = true var login_in_process: bool ## Special solution just for twitch ignore it in all other providers var force_verify: String var _query_parser = RegEx.create_from_string("GET (.*?/?)\\??(.*?)? HTTP/1\\.1.*?") var _auth_http_server: OAuthHTTPServer var _last_login_attempt: int ## State for the current authcode request to compare with var _current_state: String var _client: OAuthHTTPClient var _crypto: Crypto = Crypto.new() var _login_timeout_timer: Timer var _initialized: bool enum AuthorizationFlow { AUTHORIZATION_CODE_FLOW, IMPLICIT_FLOW, DEVICE_CODE_FLOW, CLIENT_CREDENTIALS } func _on_unauthenticated() -> void: login() func _on_token_resolved(token: OAuthToken) -> void: if token == null: return token_changed.emit(await token.get_access_token()) ## Checks if the authentication is valid. func is_authenticated() -> bool: return token_handler.is_token_valid() ## Starts the token refresh process to rotate the tokens func refresh_token() -> void: await token_handler.refresh_tokens() func _setup_nodes() -> void: if _initialized: return _initialized = true if _client == null: _client = OAuthHTTPClient.new() _client.name = "OAuthClient" add_child(_client) if _auth_http_server == null: _auth_http_server = OAuthHTTPServer.create(oauth_setting.redirect_port) _auth_http_server.name = "OAuthServer" add_child(_auth_http_server) if token_handler == null: token_handler = OAuthTokenHandler.new() add_child(token_handler) token_handler.unauthenticated.connect(_on_unauthenticated) token_handler.token_resolved.connect(_on_token_resolved) _login_timeout_timer = Timer.new() _login_timeout_timer.name = "LoginTimeoutTimer" _login_timeout_timer.one_shot = true _login_timeout_timer.wait_time = 30 _login_timeout_timer.timeout.connect(_on_login_timeout) add_child(_login_timeout_timer) ## Depending on the authorization_flow it gets resolves the token via the different ## Flow types. Only one login process at the time. All other tries wait until the first process ## was succesful. func login() -> bool: if not is_node_ready(): await ready _setup_nodes() if token_handler.is_token_valid() && not _got_scopes_changed(): return true logDebug("Token is valid (%s) and not scopes changed (%s)" % [ token_handler.is_token_valid(), _got_scopes_changed()]) if login_in_process: logInfo("Another process tries already to login. Abort") if (await token_handler.token_resolved) == null: return false return true if _last_login_attempt != 0 && Time.get_ticks_msec() - 60 * 1000 < _last_login_attempt: print("[OAuth] Last Login attempt was within 1 minute wait 1 minute before trying again. Please enable and consult logs, cause there is an issue with your authentication!") await get_tree().create_timer(60, true, false, true).timeout _last_login_attempt = Time.get_ticks_msec() login_in_process = true _login_timeout_timer.start() logInfo("do login") match oauth_setting.authorization_flow: AuthorizationFlow.AUTHORIZATION_CODE_FLOW: await _start_login_process("code") AuthorizationFlow.CLIENT_CREDENTIALS: await token_handler.request_token("client_credentials") AuthorizationFlow.IMPLICIT_FLOW: await _start_login_process("token") AuthorizationFlow.DEVICE_CODE_FLOW: await _start_device_login_process() login_in_process = false _login_timeout_timer.stop() return true func _got_scopes_changed() -> bool: if not check_scope_changed: return false var existing_scopes = token_handler.get_scopes() var requested_scopes = scopes.used_scopes if existing_scopes.size() != requested_scopes.size(): return true for scope in existing_scopes: if requested_scopes.find(scope) == -1: return true return false ## Called when the login process is timing out cause of misconfiguration or other natural catastrophes. func _on_login_timeout() -> void: if token_handler.is_token_valid(): return logError("Login run into a timeout. Stop all login processes.") _auth_succeed.emit("") token_handler.token_resolved.emit(null) func _start_login_process(response_type: String) -> void: if scopes == null: scopes = OAuthScopes.new() _auth_http_server.start_listening() if response_type == "code": _auth_http_server.request_received.connect(_process_code_request.bind(_auth_http_server)) elif response_type == "token": _auth_http_server.request_received.connect(_process_implicit_request.bind(_auth_http_server)) _current_state = _crypto.generate_random_bytes(16).hex_encode() var query_param = "&".join([ "force_verify=%s" % force_verify.uri_encode(), "response_type=%s" % response_type.uri_encode(), "client_id=%s" % oauth_setting.client_id.uri_encode(), "scope=%s" % scopes.ssv_scopes().uri_encode(), "redirect_uri=%s" % oauth_setting.redirect_url.uri_encode(), "state=%s" % _current_state ]) var url = oauth_setting.authorization_url + "?" + query_param logInfo("start login process to get token for scopes %s" % (",".join(scopes.used_scopes))) logDebug("login to %s" % url) if not shell_command.is_empty(): var parameters: PackedStringArray = shell_parameter.duplicate() \ .map(func(param: String): return param.format({"url": url})) OS.create_process(shell_command, parameters) else: OS.shell_open(url) logDebug("waiting for user to login.") if response_type == "code": var auth_code = await _auth_succeed if auth_code == "": logDebug("Auth code was empty. Abort Login.") return token_handler.request_token("authorization_code", auth_code) await token_handler.token_resolved _auth_http_server.request_received.disconnect(_process_code_request.bind(_auth_http_server)) elif response_type == "token": await token_handler.token_resolved _auth_http_server.request_received.disconnect(_process_implicit_request.bind(_auth_http_server)) logInfo("authorization is done stop server") _auth_http_server.stop_listening() #region DeviceCodeFlow ## Starts the device flow. func _start_device_login_process(): var scopes = scopes.used_scopes var device_code_response = await _fetch_device_code_response(scopes) device_code_requested.emit(device_code_response) # print the information instead of opening the browser so that the developer can decide if # he want to open the browser manually. Also use print not the logger so that the information # is sent always. print("Visit %s and enter the code %s for authorization." % [device_code_response.verification_uri, device_code_response.user_code]) await token_handler.request_device_token(device_code_response, scopes) func _fetch_device_code_response(scopes: String) -> OAuthDeviceCodeResponse: logInfo("Start device code flow") logDebug("Request Scopes: %s" % scopes) var body = "client_id=%s&scopes=%s" % [oauth_setting.client_id, scopes.uri_encode()] var request = _client.request(oauth_setting.device_authorization_url, HTTPClient.METHOD_POST, { "Content-Type": "application/x-www-form-urlencoded" }, body) var initial_response_data = await _client.wait_for_request(request) if initial_response_data.response_code != 200: logError("Couldn't initiate device code flow response code %s" % initial_response_data.response_code) var initial_response_string = initial_response_data.response_data.get_string_from_ascii() var initial_response_dict = JSON.parse_string(initial_response_string) as Dictionary return OAuthDeviceCodeResponse.new(initial_response_dict) #endregion #region ImplicitFlow ## Handles the response after auth endpoint redirects to our server with the response func _process_implicit_request(client: OAuthHTTPServer.Client, server: OAuthHTTPServer) -> void: var request = client.peer.get_utf8_string(client.peer.get_available_bytes()) if request == "": logError("Empty response. Check if your redirect URL is set to %s." % oauth_setting.redirect_url) client.peer.disconnect_from_host() return var first_linebreak = request.find("\n") var first_line = request.substr(0, first_linebreak) if first_line.begins_with("GET"): var matcher = _query_parser.search(first_line) if matcher == null: logDebug("Response from auth server was not right expected redirect url. It's ok browser asked probably for favicon etc.") return var redirect_path = oauth_setting.redirect_path var request_path = matcher.get_string(1) if redirect_path == request_path: server.send_response(client, "200 OK", ("Login Redirect Token to Godot ").to_utf8_buffer()) logInfo("Send Response to send it via POST") elif first_line.begins_with("POST"): var parts = request.split("\r\n\r\n") if parts.size() < 2: return # Not a valid request var json_body = parts[1] var token_request = JSON.parse_string(json_body) token_handler.update_tokens(token_request["access_token"]) logInfo("Received Access Token update it") server.send_response(client, "200 OK", "LoginSuccess!".to_utf8_buffer()) #endregion #region AuthCodeFlow ## Handles the response after auth endpoint redirects to our server with the response func _process_code_request(client: OAuthHTTPServer.Client, server: OAuthHTTPServer) -> void: if client.peer.get_status() != StreamPeerTCP.STATUS_CONNECTED: logError("Client not connected can't process code response.") return var request = client.peer.get_utf8_string(client.peer.get_available_bytes()) if request == "": logError("Empty response. Check if your redirect URL is set to %s." % oauth_setting.redirect_url) client.peer.disconnect_from_host() return # Firstline contains request path and parameters var first_line = request.substr(0, request.find("\n")) var matcher = _query_parser.search(first_line) if matcher == null: logDebug("Response from auth server was not right expected query params. It's ok browser asked probably for favicon etc.") client.peer.disconnect_from_host() return var query_params_str = matcher.get_string(2) var query_params = parse_query(query_params_str) var state = query_params.get("state") if query_params.has("error"): _handle_error(server, client, query_params) elif state == _current_state: _handle_success(server, client, query_params) else: _handle_other_requests(server, client, first_line) client.peer.disconnect_from_host() ## Returns the response for the given auth request back to the browser also emits the auth code func _handle_success(server: OAuthHTTPServer, client: OAuthHTTPServer.Client, query_params : Dictionary) -> void: logInfo("Authentication success. Send auth code.") if query_params.has("code"): var succes_page = FileAccess.get_file_as_bytes("res://addons/twitcher/assets/success-page.txt") server.send_response(client, "200 OK", succes_page) _auth_succeed.emit(query_params['code']) else: var error_page = FileAccess.get_file_as_bytes("res://addons/twitcher/assets/error-page.txt") server.send_response(client, "200 OK", error_page) logError("Auth code expected wasn't send!") ## Handles the error in case that Auth API has a problem func _handle_error(server: OAuthHTTPServer, client: OAuthHTTPServer.Client, query_params : Dictionary) -> void: var msg = "Error %s: %s" % [query_params["error"], query_params["error_description"]] logError(msg) server.send_response(client, "400 BAD REQUEST", msg.to_utf8_buffer()) #endregion func _handle_other_requests(server: OAuthHTTPServer, client: OAuthHTTPServer.Client, fist_line: String) -> void: if fist_line.contains("favicon.ico"): var favicon: PackedByteArray = FileAccess.get_file_as_bytes("res://addons/twitcher/assets/favicon.ico") server.send_response(client, "200", favicon) ## Parses a query string and returns a dictionary with the parameters. static func parse_query(query: String) -> Dictionary: var parameters = Dictionary() # Split the query by '&' to separate different parameters. var pairs = query.split("&") # Iterate over each pair of key-value. for pair in pairs: # Split the pair by '=' to separate the key from the value. var kv = pair.split("=") if kv.size() == 2: var key = kv[0].strip_edges() var value = kv[1].strip_edges() var decoded_key = key.uri_decode() var decoded_value = value.uri_decode() parameters[decoded_key] = decoded_value return parameters # === LOGGER === static var logger: Dictionary = {} static func set_logger(error: Callable, info: Callable, debug: Callable) -> void: logger.debug = debug logger.info = info logger.error = error OAuthTokenHandler.set_logger(error, info, debug) static func logDebug(text: String) -> void: if logger.has("debug"): logger.debug.call(text) static func logInfo(text: String) -> void: if logger.has("info"): logger.info.call(text) static func logError(text: String) -> void: if logger.has("error"): logger.error.call(text)