Add Android OIDC callback broker for Termux
Verify / verify (ubuntu-latest, linux-bionic-arm64) (push) Canceled after 0s
Verify / verify (ubuntu-latest, linux-x64) (push) Canceled after 0s
Verify / verify (windows-latest, win-x64) (push) Canceled after 0s
Verify / postgres (push) Canceled after 0s

This commit is contained in:
Nitload
2026-10-06 11:28:13 +08:00
parent f9effc890b
commit 6d072b3718
5 changed files with 160 additions and 10 deletions
+5
View File
@@ -1,5 +1,10 @@
# 身份认证与授权
桌面 Client 使用动态 loopback URI `http://127.0.0.1:<动态端口>/callback`;Termux
Client 使用 `com.nitload.charac://oauth/callback`,由 Android APK 接收 authentik
回调,再通过 Termux `RUN_COMMAND` 转回 CLI。authentik Provider 必须同时允许这两类
Redirect URI。
CLI 已实现 authentik OIDC Authorization Code + PKCE 的浏览器登录和本机回调;令牌仅保存在当前 CLI 进程内。Server 使用 JWT Bearer 中间件从 HTTPS OIDC discovery 获取签名密钥,校验签名、issuer、audience 和有效期,再以稳定的 `(iss, sub)` 识别用户。资源列表、创建、附着、结束工作区均重新查询数据库授权。需在 authentik 创建 public client、启用 PKCE、设置允许的 `http://127.0.0.1:<动态端口>/callback` 回调,并为 Provider 配置非对称 JWT 签名密钥。Server 配置 `[oidc] issuer` 和 `client_id`;若采用 authentik 的全局 issuer 模式,还需设置指向应用 slug 下 discovery 文档的 `discovery_url`。正式 Client 的 `login` 已通过真实 authentik 浏览器登录及 Server 令牌校验;2026-10-05 已在 `.100 → .101` 实测目标授权、资源选择和交互式 `ls`;正常退出及重新附着待复测。
管理员将外部身份映射到允许访问的目标机器和系统账户。Windows 目标使用账户 SID,Linux 目标使用 UID/GID;实际登录环境及文件权限由目标 sshd 和操作系统建立。不同外部身份即使映射到同一个系统账户,工作区归属仍独立。
+2
View File
@@ -1,5 +1,7 @@
# Android CLI 客户端
Termux 登录不让浏览器直接访问 Termux 的 `127.0.0.1`:CLI 通过 `/system/bin/am` 启动 APK,APK 注册 `com.nitload.charac://oauth/callback` 并接收 authentik 回调,再用 `RUN_COMMAND` 调用 `charac callback` 将授权码转回 CLI 的本地监听器。
推荐把 Android CLI 作为 Termux DEB 分发,而不是把 CLI 当成普通 Android APK。APK 只是可选的 One UI 启动器:它不能单独运行 CLI,必须与 Termux、Termux DEB、运行命令权限和 `allow-external-apps=true` 配置一起使用。直接安装 APK 后点击图标不能替代 Termux 环境,也不会自动安装 .NET 或 `charac`。
Android 首期支持 ARM64 设备上的 Termux 终端,CLI 使用 `linux-bionic-arm64` RID。另有独立的 .NET Android 启动器项目 `src/android/CharacAndroid.csproj`,包 ID 为 `com.nitload.charac`;Server 仍只部署在 Windows 或 Linux。
+77
View File
@@ -9,12 +9,17 @@ using Android.Widget;
namespace Charac.Android;
[Activity(Label = "@string/app_name", MainLauncher = true, Exported = true, LaunchMode = LaunchMode.SingleTop)]
[IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault, Intent.CategoryBrowsable },
DataScheme = "com.nitload.charac", DataHost = "oauth")]
public sealed class MainActivity : Activity
{
private const string TermuxPackage = "com.termux";
private const string TermuxHome = "/data/data/com.termux/files/home";
private const string TermuxBin = "/data/data/com.termux/files/usr/bin";
private const string ResultAction = "com.nitload.charac.TERMUX_CHECK_RESULT";
private const string AuthUrlExtra = "charac_auth_url";
private const string CallbackPortExtra = "charac_callback_port";
private const string CallbackPortPreference = "oidc_callback_port";
private TextView? _status;
private bool _cliReady;
@@ -28,7 +33,10 @@ public sealed class MainActivity : Activity
FindViewById<Button>(Resource.Id.launch)!.Click += (_, _) => Launch();
ShowPrerequisites();
if (Intent is not null)
{
HandleCheckResult(Intent);
HandleBrokerIntent(Intent);
}
}
protected override void OnResume()
@@ -41,7 +49,76 @@ public sealed class MainActivity : Activity
{
base.OnNewIntent(intent);
if (intent is not null)
{
HandleCheckResult(intent);
HandleBrokerIntent(intent);
}
}
private void HandleBrokerIntent(Intent intent)
{
if (intent.GetStringExtra(AuthUrlExtra) is { Length: > 0 } authorizationUrl)
{
var port = intent.GetIntExtra(CallbackPortExtra, 0);
if (port is <= 0 or > 65535)
{
SetStatus("OIDC 回调端口无效。请从 Termux 重新发起登录。", false);
return;
}
var preferences = GetPreferences(FileCreationMode.Private);
if (preferences is null)
{
SetStatus("无法保存 OIDC 回调状态。请从 Termux 重新发起登录。", false);
return;
}
var editor = preferences.Edit();
if (editor is null)
{
SetStatus("无法保存 OIDC 回调状态。请从 Termux 重新发起登录。", false);
return;
}
var updatedEditor = editor.PutInt(CallbackPortPreference, port);
if (updatedEditor is null)
{
SetStatus("无法保存 OIDC 回调状态。请从 Termux 重新发起登录。", false);
return;
}
updatedEditor.Apply();
try
{
StartActivity(new Intent(Intent.ActionView, global::Android.Net.Uri.Parse(authorizationUrl)));
SetStatus("已打开浏览器。完成登录后将自动返回 Termux。", false);
}
catch (Exception exception)
{
SetStatus("无法打开浏览器:" + exception.Message, false);
}
return;
}
var callback = intent.Data;
if (callback is null || callback.Scheme != "com.nitload.charac" ||
callback.Host != "oauth" || callback.Path != "/callback")
return;
var portFromPreferences = GetPreferences(FileCreationMode.Private)!
.GetInt(CallbackPortPreference, 0);
var code = callback.GetQueryParameter("code");
var state = callback.GetQueryParameter("state");
if (portFromPreferences is <= 0 or > 65535 ||
string.IsNullOrWhiteSpace(code) || string.IsNullOrWhiteSpace(state))
{
SetStatus("OIDC 回调缺少必要参数。请从 Termux 重新发起登录。", false);
return;
}
var command = CommandIntent(TermuxBin + "/charac", [
"callback", "--port", portFromPreferences.ToString(),
"--code", code, "--state", state
], true);
if (StartTermux(command))
SetStatus("登录回调已交给 Termux。请返回终端等待完成。", true);
}
private bool HasTermux()
+52 -10
View File
@@ -33,7 +33,10 @@ internal static class OidcLogin
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
var redirectUri = $"http://127.0.0.1:{port}/callback";
var termuxBroker = IsTermux();
var redirectUri = termuxBroker
? "com.nitload.charac://oauth/callback"
: $"http://127.0.0.1:{port}/callback";
var verifier = Base64Url(RandomNumberGenerator.GetBytes(32));
var challenge = Base64Url(SHA256.HashData(Encoding.ASCII.GetBytes(verifier)));
var state = Base64Url(RandomNumberGenerator.GetBytes(32));
@@ -53,15 +56,10 @@ internal static class OidcLogin
$"{Uri.EscapeDataString(pair.Key)}={Uri.EscapeDataString(pair.Value)}"))
}.Uri;
Console.WriteLine($"Open this URL to sign in: {authorizationUri}");
try
{
Process.Start(new ProcessStartInfo(authorizationUri.ToString()) { UseShellExecute = true });
}
catch (Exception exception) when (exception is System.ComponentModel.Win32Exception or
PlatformNotSupportedException or InvalidOperationException)
{
Console.WriteLine("Open the URL manually in a browser on this device.");
}
if (termuxBroker)
StartTermuxBroker(authorizationUri, port);
else
OpenBrowser(authorizationUri);
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(TimeSpan.FromMinutes(3));
@@ -86,6 +84,50 @@ internal static class OidcLogin
return accessToken;
}
private static bool IsTermux() =>
OperatingSystem.IsLinux() &&
(Environment.GetEnvironmentVariable("TERMUX_VERSION") is not null ||
Environment.GetEnvironmentVariable("PREFIX")?.Contains("/com.termux/", StringComparison.Ordinal) == true);
private static void OpenBrowser(Uri authorizationUri)
{
try
{
Process.Start(new ProcessStartInfo(authorizationUri.ToString()) { UseShellExecute = true });
}
catch (Exception exception) when (exception is System.ComponentModel.Win32Exception or
PlatformNotSupportedException or InvalidOperationException)
{
Console.WriteLine("Open the URL manually in a browser on this device.");
}
}
private static void StartTermuxBroker(Uri authorizationUri, int port)
{
try
{
var start = new ProcessStartInfo("/system/bin/am") { UseShellExecute = false };
start.ArgumentList.Add("start");
start.ArgumentList.Add("-n");
start.ArgumentList.Add("com.nitload.charac/.MainActivity");
start.ArgumentList.Add("--es");
start.ArgumentList.Add("charac_auth_url");
start.ArgumentList.Add(authorizationUri.ToString());
start.ArgumentList.Add("--ei");
start.ArgumentList.Add("charac_callback_port");
start.ArgumentList.Add(port.ToString(System.Globalization.CultureInfo.InvariantCulture));
using var process = Process.Start(start);
process?.WaitForExit(5000);
if (process is null || process.ExitCode != 0)
throw new InvalidOperationException("Android callback broker did not start.");
}
catch (Exception exception) when (exception is System.ComponentModel.Win32Exception or InvalidOperationException)
{
Console.WriteLine($"Could not start the Android callback broker: {exception.Message}");
Console.WriteLine("Open the URL manually in a browser on this device.");
}
}
private static async Task<string> ReceiveCodeAsync(TcpListener listener, string expectedState,
CancellationToken cancellationToken)
{
+24
View File
@@ -69,6 +69,9 @@ internal static class Program
return await WorkspaceClient.RunLocalProbeAsync(probeServer);
if (args is ["shell", "--server", var shellServer])
return await WorkspaceClient.RunLocalShellAsync(shellServer);
if (args is ["callback", "--port", var portText, "--code", var code, "--state", var state] &&
int.TryParse(portText, out var callbackPort) && callbackPort is > 0 and <= 65535)
return await SendCallbackAsync(callbackPort, code, state);
PrintUsage(Console.Error);
return 2;
@@ -111,6 +114,26 @@ internal static class Program
}
}
private static async Task<int> SendCallbackAsync(int port, string code, string state)
{
try
{
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
var callback = new UriBuilder(Uri.UriSchemeHttp, "127.0.0.1", port, "/callback")
{
Query = $"code={Uri.EscapeDataString(code)}&state={Uri.EscapeDataString(state)}"
}.Uri;
using var response = await client.GetAsync(callback);
response.EnsureSuccessStatusCode();
return 0;
}
catch (Exception exception) when (exception is HttpRequestException or TaskCanceledException or UriFormatException)
{
Console.Error.WriteLine($"OIDC callback forwarding failed: {exception.Message}");
return 1;
}
}
internal static bool TryGetServerUri(string value, out Uri serverUri)
{
serverUri = null!;
@@ -142,6 +165,7 @@ internal static class Program
output.WriteLine(" charac attach --server https://access.example.com --id <workspace-id>");
output.WriteLine(" charac probe-session --server http://127.0.0.1:5080");
output.WriteLine(" charac shell --server http://127.0.0.1:5080");
output.WriteLine(" charac callback --port <port> --code <code> --state <state>");
output.WriteLine("Login opens the browser and verifies identity with Server; tokens are kept only for this command.");
output.WriteLine("Connect signs in, lists your resources when no ID is supplied, and opens an interactive workspace. Ctrl+] detaches; Server keeps the SSH session.");
output.WriteLine("Run and attach sign in with OIDC and verify a Linux ls through the managed SSH workspace.");