Skip to content

Commit

Permalink
add keycload addon
Browse files Browse the repository at this point in the history
  • Loading branch information
costaconrado committed May 26, 2024
1 parent 8efc446 commit ed97150
Show file tree
Hide file tree
Showing 9 changed files with 381 additions and 0 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.godot/
**/.DS_Store
19 changes: 19 additions & 0 deletions addons/keycloak/keycloak.gd
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
@tool
extends EditorPlugin

const AUTOLOAD_NAME = "Keycloak"

func _enter_tree():
set_default("realm_id", "my_keycloak_realm")
set_default("client_id", "game_client_id")
set_default("client_secret", "super_secret")
set_default("server_addr", "127.0.0.1")
set_default("server_port", 8080)
add_autoload_singleton(AUTOLOAD_NAME, "res://addons/keycloak/src/auth.gd")

func _exit_tree():
remove_autoload_singleton(AUTOLOAD_NAME)

func set_default(key, value):
if not ProjectSettings.has_setting(key):
ProjectSettings.set_setting("keycloak/%s" % key, value)
7 changes: 7 additions & 0 deletions addons/keycloak/plugin.cfg
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
[plugin]

name="Keycloak"
description=""
author="Conrado Costa"
version="0.1"
script="keycloak.gd"
211 changes: 211 additions & 0 deletions addons/keycloak/src/auth.gd
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
extends Node

signal token_received
signal authentication_failed(String)

var public_key: CryptoKey

var basic = load("res://addons/keycloak/src/auth_types/basic.gd")
var google = load("res://addons/keycloak/src/auth_types/google.gd")

var realm_id: String :
get:
return ProjectSettings.get_setting("keycloak/realm_id")
var client_id: String :
get:
return ProjectSettings.get_setting("keycloak/client_id")
var client_secret: String :
get:
return ProjectSettings.get_setting("keycloak/client_secret")
var server_addr: String :
get:
return ProjectSettings.get_setting("keycloak/server_addr")
var server_port: int :
get:
return ProjectSettings.get_setting("keycloak/server_port")
var token_url: String :
get:
return "/realms/%s/protocol/openid-connect/token" % realm_id
var auth_url:
get:
return "/realms/%s/protocol/openid-connect/auth" % realm_id

# Used to receive access code using external auth providers (e.g. Google)
const LOCAL_CLIENT_PORT := 30581
const LOCAL_CLIENT_ADDR := "127.0.0.1"

var redirect_server := TCPServer.new()
var redirect_uri: String = "http://%s:%s" % [LOCAL_CLIENT_ADDR, LOCAL_CLIENT_PORT]

var _access_token: String
var _refresh_token: String

func is_token_valid(token: String) -> bool:
if not public_key:
await get_pub_key()

var jwt_algorithm: JWTAlgorithm = JWTAlgorithmBuilder.RS256(public_key, public_key)
var jwt_verifier: JWTVerifier = JWT.require(jwt_algorithm) \
.build(int(Time.get_unix_time_from_system() + 300))

if jwt_verifier.verify(token) == JWTVerifier.JWTExceptions.OK:
return true
else:
push_error(jwt_verifier.exception)
return false

func get_token() -> String:
var jwt_decoder = JWTDecoder.new(_access_token)

if Time.get_unix_time_from_system() <= jwt_decoder.get_expires_at():
print("Token still good!")
return _access_token
else:
print("Expired")
return await refresh_token()

func _process(_delta):
if redirect_server.is_connection_available():
var connection = redirect_server.take_connection()
var request = connection.get_string(connection.get_available_bytes())
if request:
set_process(false)

connection.put_data(("HTTP/1.1 %d\r\n" % 200).to_ascii_buffer())
connection.put_data(load_html("res://addons/keycloak/src/display_page.html").to_ascii_buffer())
redirect_server.stop()

var parameters = _parse_url_parameters(request)
var code = parameters.get("code")

var data = [
"code=%s" % code,
"grant_type=authorization_code",
"client_id=%s" % client_id,
"client_secret=%s" % client_secret,
"redirect_uri=%s" % redirect_uri,
]

var headers: Array[String] = ["Content-Type: application/x-www-form-urlencoded"]

request_token(headers, "&".join(data))

func load_html(path):
if FileAccess.file_exists(path):
var file = FileAccess.open(path, FileAccess.READ)
var html = file.get_as_text().replace(" ", "\t").insert(0, "\n")
file.close()
return html

func _parse_url_parameters(request: String) -> Dictionary:
var parameters = {}
var query = request.split("\n")[0]
query = query.substr(request.find("?") + 1).split(" ")[0]
var query_params = query.split("&")

for param in query_params:
var parts = param.split("=")
if parts.size() == 2:
parameters[parts[0]] = parts[1]

return parameters

func random_string(length):
var chars = 'abcdefghijklmnopqrstuvwxyz0123456789'
var word = ""
var n_char = len(chars)
for i in range(length):
word += chars[randi() % n_char]
return word

func get_pub_key():
var url = "http://%s:%d/realms/%s" % [
server_addr,
server_port,
realm_id,
]
var http_request = HTTPRequest.new()
http_request.name = "token_request"
add_child(http_request)
var error = http_request.request(url, [], HTTPClient.METHOD_GET)
if error != OK:
push_error("An error occurred while getting token: %s" % error)
return ""
var response = await http_request.request_completed
http_request.queue_free()
var response_body = JSON.parse_string(response[3].get_string_from_utf8())
if response_body.get("public_key"):
public_key = CryptoKey.new()
public_key.load_from_string(
"%s\n%s\n%s" % [
"-----BEGIN PUBLIC KEY-----",
response_body.get("public_key"),
"-----END PUBLIC KEY-----"],
true
)
else:
push_error("Unable to get auth server public key.")

func refresh_token() -> String:
var url = "http://%s:%d%s" % [
server_addr,
server_port,
token_url,
]
var data = [
"grant_type=refresh_token",
"refresh_token=%s" % _refresh_token,
"client_id=%s" % client_id,
"client_secret=%s" % client_secret,
]

var headers: Array[String] = ["Content-Type: application/x-www-form-urlencoded"]

var http_request = HTTPRequest.new()
http_request.name = "token_request"
add_child(http_request)
var error = http_request.request(url, headers, HTTPClient.METHOD_POST, "&".join(data))
if error != OK:
push_error("An error occurred while getting token: %s" % error)
return ""
var response = await http_request.request_completed
http_request.queue_free()
var response_body = JSON.parse_string(response[3].get_string_from_utf8())

_access_token = response_body.get("access_token")
_refresh_token = response_body.get("refresh_token")

if not _access_token:
authentication_failed.emit("Session expired.")
return ""

token_received.emit()
return _access_token

func request_token(headers: Array[String], data: String) -> void:
var http_request = HTTPRequest.new()
http_request.name = "token_request"
add_child(http_request)
http_request.request_completed.connect(_on_token_received)
http_request.request_completed.connect(
func(_result, _response_code, _headers, _body) -> void:
http_request.queue_free()
)
var url = "http://%s:%d%s" % [
server_addr,
server_port,
token_url,
]
var error = http_request.request(url, headers, HTTPClient.METHOD_POST, data)
if error != OK:
push_error("An error occurred while getting token: %s" % error)

func _on_token_received(_result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
var response_body = JSON.parse_string(body.get_string_from_utf8())
if response_code == 200:
_access_token = response_body.get("access_token")
_refresh_token = response_body.get("refresh_token")

token_received.emit()
else:
authentication_failed.emit(response_body.get("error_description"))
50 changes: 50 additions & 0 deletions addons/keycloak/src/auth_types/basic.gd
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
extends RefCounted

static func auth(username: String, password: String):
var headers: Array[String] = ["Content-Type: application/x-www-form-urlencoded"]
var data = [
"username=%s" % username,
"password=%s" % password,
"client_id=%s" % Keycloak.client_id,
"client_secret=%s" % Keycloak.client_secret,
"grant_type=password",
]
Keycloak.request_token(headers, "&".join(data))

static func generate_basic_form() -> VBoxContainer:
var vbox = VBoxContainer.new()
var grid = GridContainer.new()
grid.columns = 2

var username_label = Label.new()
username_label.text = "Username"
username_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
grid.add_child(username_label)

var username_input = LineEdit.new()
username_input.alignment = HORIZONTAL_ALIGNMENT_CENTER
username_input.custom_minimum_size = Vector2(150, 0)
grid.add_child(username_input)

var password_label = Label.new()
password_label.text = "Password"
password_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
grid.add_child(password_label)

var password_input = LineEdit.new()
password_input.alignment = HORIZONTAL_ALIGNMENT_CENTER
password_input.secret = true
password_input.custom_minimum_size = Vector2(150, 0)
grid.add_child(password_input)
var submit_btn = Button.new()
submit_btn.text = "Sign In"
submit_btn.pressed.connect(func(): auth(username_input.text, password_input.text))

vbox.add_child(grid)
grid.add_child(username_label)
grid.add_child(username_input)
grid.add_child(password_label)
grid.add_child(password_input)
vbox.add_child(submit_btn)

return vbox
35 changes: 35 additions & 0 deletions addons/keycloak/src/auth_types/google.gd
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
extends RefCounted

static func auth():
Keycloak.set_process(true)
var redir_err = Keycloak.redirect_server.listen(Keycloak.LOCAL_CLIENT_PORT, Keycloak.LOCAL_CLIENT_ADDR)
if redir_err:
printerr(redir_err)

var data = [
"response_type=code",
"scope=openid",
"client_id=%s" % Keycloak.client_id,
"client_secret=%s" % Keycloak.client_secret,
"redirect_uri=%s" % Keycloak.redirect_uri,
"state=%s" % Keycloak.random_string(22),
"nonce=%s" % Keycloak.random_string(22),
"login_hint=google",
"kc_idp_hint=google",
]

OS.shell_open("http://%s:%d%s?%s" % [
Keycloak.server_addr,
Keycloak.server_port,
Keycloak.auth_url,
"&".join(data)
])

static func generate_google_btn() -> Button:
var google_btn = Button.new()
google_btn.icon = load("res://addons/keycloak/textures/google-logo.png")
google_btn.expand_icon = true
google_btn.text = "Sign in with Google"
google_btn.pressed.connect(func(): auth())

return google_btn
23 changes: 23 additions & 0 deletions addons/keycloak/src/success_page.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<html>
<style>
body {
position: absolute;
background-color: 1A1A1A;
font-family: arial;
font-weight: bold;
font-size: 24px;
top: 50%;
left: 50%;
-ms-transform: translate(-50%, -50%);
transform: translate(-50%, -50%);
text-align: center;
vertical-align: middle;
}
</style>

<body>
<h1 style="color:e0e0e0;">Success!</h2>
<h2 style="color:e0e0e0;">You can close this window.</h2>
</body>

</html>
Binary file added addons/keycloak/textures/google-logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
34 changes: 34 additions & 0 deletions addons/keycloak/textures/google-logo.png.import
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
[remap]

importer="texture"
type="CompressedTexture2D"
uid="uid://bilnswoplg03f"
path="res://.godot/imported/google-logo.png-c55412ecf17f85293aa015c617f58dab.ctex"
metadata={
"vram_texture": false
}

[deps]

source_file="res://addons/keycloak/textures/google-logo.png"
dest_files=["res://.godot/imported/google-logo.png-c55412ecf17f85293aa015c617f58dab.ctex"]

[params]

compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

0 comments on commit ed97150

Please sign in to comment.