using System.Diagnostics; using System.Net; using System.Net.Sockets; using System.Security.Cryptography; using System.Text; using System.Text.Json; namespace Charac.Client; internal static class OidcLogin { public static async Task LoginAsync(HttpClient client, string issuer, string clientId, string discoveryUrl, CancellationToken cancellationToken = default) { if (!Uri.TryCreate(issuer, UriKind.Absolute, out var issuerUri) || issuerUri.Scheme != Uri.UriSchemeHttps || string.IsNullOrWhiteSpace(clientId)) throw new InvalidOperationException("The Server returned invalid OIDC settings."); if (!Uri.TryCreate(discoveryUrl, UriKind.Absolute, out var discoveryUri) || discoveryUri.Scheme != Uri.UriSchemeHttps) throw new InvalidOperationException("The Server returned an invalid OIDC discovery URL."); using var discoveryResponse = await client.GetAsync(discoveryUri, cancellationToken); discoveryResponse.EnsureSuccessStatusCode(); using var discovery = await JsonDocument.ParseAsync( await discoveryResponse.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken); var metadata = discovery.RootElement; if (metadata.GetProperty("issuer").GetString() != issuer) throw new InvalidOperationException("OIDC discovery issuer does not match the Server configuration."); var authorize = HttpsEndpoint(metadata.GetProperty("authorization_endpoint").GetString()); var token = HttpsEndpoint(metadata.GetProperty("token_endpoint").GetString()); 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 verifier = Base64Url(RandomNumberGenerator.GetBytes(32)); var challenge = Base64Url(SHA256.HashData(Encoding.ASCII.GetBytes(verifier))); var state = Base64Url(RandomNumberGenerator.GetBytes(32)); var query = new Dictionary { ["response_type"] = "code", ["client_id"] = clientId, ["redirect_uri"] = redirectUri, ["scope"] = "openid", ["state"] = state, ["code_challenge"] = challenge, ["code_challenge_method"] = "S256" }; var authorizationUri = new UriBuilder(authorize) { Query = string.Join('&', query.Select(pair => $"{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."); } using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeout.CancelAfter(TimeSpan.FromMinutes(3)); var code = await ReceiveCodeAsync(listener, state, timeout.Token); using var body = new FormUrlEncodedContent(new Dictionary { ["grant_type"] = "authorization_code", ["client_id"] = clientId, ["code"] = code, ["redirect_uri"] = redirectUri, ["code_verifier"] = verifier }); using var tokenResponse = await client.PostAsync(token, body, cancellationToken); if (!tokenResponse.IsSuccessStatusCode) throw new InvalidOperationException(await DescribeTokenErrorAsync(tokenResponse, cancellationToken)); using var tokenDocument = await JsonDocument.ParseAsync( await tokenResponse.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken); if (tokenDocument.RootElement.GetProperty("token_type").GetString() is not { } tokenType || !string.Equals(tokenType, "Bearer", StringComparison.OrdinalIgnoreCase) || tokenDocument.RootElement.GetProperty("access_token").GetString() is not { Length: > 0 } accessToken) throw new InvalidOperationException("OIDC token response did not contain a bearer access token."); return accessToken; } private static async Task ReceiveCodeAsync(TcpListener listener, string expectedState, CancellationToken cancellationToken) { using var connection = await listener.AcceptTcpClientAsync(cancellationToken); await using var stream = connection.GetStream(); using var reader = new StreamReader(stream, Encoding.ASCII, leaveOpen: true); var requestLine = await reader.ReadLineAsync(cancellationToken); if (requestLine is null || requestLine.Length > 8192 || !requestLine.StartsWith("GET ", StringComparison.Ordinal)) throw new InvalidOperationException("The OIDC callback request is invalid."); string? header; do { header = await reader.ReadLineAsync(cancellationToken); if (header is { Length: > 8192 }) throw new InvalidOperationException("The OIDC callback request is too large."); } while (header is { Length: > 0 }); var path = requestLine.Split(' ', 3)[1]; if (!Uri.TryCreate("http://127.0.0.1" + path, UriKind.Absolute, out var callback) || callback.AbsolutePath != "/callback") throw new InvalidOperationException("The OIDC callback path is invalid."); var values = callback.Query.TrimStart('?').Split('&', StringSplitOptions.RemoveEmptyEntries) .Select(part => part.Split('=', 2)) .ToDictionary(parts => Uri.UnescapeDataString(parts[0]), parts => Uri.UnescapeDataString(parts.Length == 2 ? parts[1] : "")); values.TryGetValue("code", out var code); var valid = values.TryGetValue("state", out var state) && CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(state), Encoding.ASCII.GetBytes(expectedState)) && !string.IsNullOrWhiteSpace(code); var page = CallbackPage .Replace("__FONT_REGULAR__", ReadFontBase64("Regular"), StringComparison.Ordinal) .Replace("__FONT_BOLD__", ReadFontBase64("Bold"), StringComparison.Ordinal) .Replace("__STATE__", valid ? "success" : "failure", StringComparison.Ordinal) .Replace("__ICON__", valid ? "✓" : "!", StringComparison.Ordinal) .Replace("__TITLE__", valid ? "已收到授权码" : "授权回调未通过验证", StringComparison.Ordinal) .Replace("__MESSAGE__", valid ? "请返回终端,等待 RAC 完成登录。现在可以关闭此页面。" : "请关闭此页面,在终端重新发起登录。", StringComparison.Ordinal); var content = Encoding.UTF8.GetBytes(page); var headers = Encoding.ASCII.GetBytes( $"HTTP/1.1 {(valid ? "200 OK" : "400 Bad Request")}\r\n" + "Content-Type: text/html; charset=utf-8\r\n" + $"Content-Length: {content.Length}\r\n" + "Cache-Control: no-store\r\n" + "Referrer-Policy: no-referrer\r\n" + "X-Content-Type-Options: nosniff\r\n" + "Content-Security-Policy: default-src 'none'; font-src data:; style-src 'unsafe-inline'; script-src 'unsafe-inline'; base-uri 'none'; form-action 'none'\r\n" + "Connection: close\r\n\r\n"); await stream.WriteAsync(headers, cancellationToken); await stream.WriteAsync(content, cancellationToken); if (!valid) throw new InvalidOperationException("OIDC callback state or authorization code is invalid."); return code!; } private static async Task DescribeTokenErrorAsync(HttpResponseMessage response, CancellationToken cancellationToken) { var message = $"OIDC token exchange failed (HTTP {(int)response.StatusCode})"; try { using var document = await JsonDocument.ParseAsync( await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken); if (document.RootElement.ValueKind != JsonValueKind.Object || !document.RootElement.TryGetProperty("error", out var error) || error.ValueKind != JsonValueKind.String) return message; var code = error.GetString(); if (string.IsNullOrEmpty(code) || code.Length > 80 || !code.All(character => char.IsAsciiLetterOrDigit(character) || character is '_' or '-')) return message; message += $": {code}"; if (document.RootElement.TryGetProperty("error_description", out var description) && description.ValueKind == JsonValueKind.String) { var detail = description.GetString(); if (!string.IsNullOrWhiteSpace(detail)) { detail = new string(detail.Take(300).Select(character => char.IsControl(character) ? ' ' : character).ToArray()); message += $" ({detail})"; } } } catch (JsonException) { // A non-JSON proxy or provider error still reports the HTTP status. } return message; } private static Uri HttpsEndpoint(string? value) { if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) || uri.Scheme != Uri.UriSchemeHttps || uri.UserInfo.Length != 0) throw new InvalidOperationException("OIDC discovery returned an invalid HTTPS endpoint."); return uri; } private static string Base64Url(byte[] bytes) => Convert.ToBase64String(bytes) .TrimEnd('=').Replace('+', '-').Replace('/', '_'); private static string ReadFontBase64(string weight) { using var stream = typeof(OidcLogin).Assembly.GetManifestResourceStream( $"Charac.Fonts.IBMPlexMono-{weight}.woff2") ?? throw new InvalidOperationException("The bundled callback font is missing."); using var buffer = new MemoryStream(); stream.CopyTo(buffer); return Convert.ToBase64String(buffer.ToArray()); } private const string CallbackPage = """ RAC · 授权回调
RACWORKSPACE
ACCESS
授权回调 / AUTHORIZATION

__TITLE__

__MESSAGE__

此页面由本机客户端提供。登录结果以终端显示为准。

"""; }