// SPDX-License-Identifier: GPL-3.0-or-later
// MyIce Kotlin Multiplatform — schedule/convocation viewer
// Copyright (C) 2026 parano.ch
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see .
package ch.parano.myice.auth
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.withTimeout
import java.awt.Desktop
import java.io.BufferedReader
import java.io.InputStreamReader
import java.io.PrintWriter
import java.net.InetSocketAddress
import java.net.ServerSocket
import java.net.URI
import java.net.URLEncoder
import kotlin.concurrent.thread
class DesktopOAuthClient : OAuthClient {
override fun redirectUri(): String = "myice://callback"
override suspend fun authenticate(loginUrl: String): String? {
val server = ServerSocket()
server.bind(InetSocketAddress("127.0.0.1", 0))
val serverPort = server.localPort
val realRedirectUri = "http://localhost:$serverPort/callback"
val fullLoginUrl = loginUrl.replace(
"redirect_uri=myice://callback",
"redirect_uri=" + URLEncoder.encode(realRedirectUri, "UTF-8")
)
val deferred = CompletableDeferred()
thread {
try {
while (true) {
val socket = server.accept()
val reader = BufferedReader(InputStreamReader(socket.getInputStream()))
val writer = PrintWriter(socket.getOutputStream())
val requestLine = reader.readLine() ?: continue
val path = requestLine.split(" ").getOrNull(1) ?: continue
if (path.startsWith("/callback")) {
val html = """
""".trimIndent()
writer.print("HTTP/1.1 200 OK\r\n")
writer.print("Content-Type: text/html\r\n")
writer.print("Connection: close\r\n")
writer.print("\r\n")
writer.print(html)
writer.flush()
} else if (path.startsWith("/token")) {
val query = path.substringAfter("?")
val token = parseAccessTokenFromFragment(query)
deferred.complete(token)
writer.print("HTTP/1.1 200 OK\r\n")
writer.print("Content-Type: text/html\r\n")
writer.print("Connection: close\r\n")
writer.print("\r\n")
writer.print("Authentication complete. You can close this window.
")
writer.flush()
socket.close()
break
}
socket.close()
}
} catch (e: Exception) {
deferred.complete(null)
} finally {
server.close()
}
}
try {
Desktop.getDesktop().browse(URI(fullLoginUrl))
} catch (e: Exception) {
server.close()
return null
}
return try {
withTimeout(5 * 60 * 1000L) {
deferred.await()
}
} catch (e: TimeoutCancellationException) {
null
} finally {
server.close()
}
}
}