㊙️

WinUI 3 で Discord 連携ログイン機能を実装する

2024/03/01に公開

MitamatchOperations Version 5 (Strongylodon)

https://github.com/loliGothicK/MitamatchOperations

なんと、2年以上開発している

使用技術

Windows Application

  • WinUI 3
    • WinUIEx (ライブラリ)
    • JWT (ライブラリ)

リダイレクト用のサイト

  • Next.js 14 / TypeScript
  • Vercel

バックエンド

  • Google Cloud
    • DataStore

仕様

  1. スプラッシュスクリーンでログイン待ちをする
  2. ブラウザで認証サイトを開く
  3. Discord OAuth 2.0 をやってもらう
  4. カスタムURLスキーマ (mitamatch://auth)にJWTをつけてリダイレクトする
  5. JWTを検証する
  6. DataStoreにユーザ情報を送る

WinUI 側の実装

Protocolの宣言

appmanifest から Protocol を追加する.

SplashScreen

WinUIEx を使う.
ドキュメント通りに実装すれば簡単である.

ログイン待ちをする.
まず、SplashScreen にログインのポーリング用に関数を渡す.
ログインボタンを用意して認証用のサイトに飛ばすようにする.

SplashScreen.xaml.cs
public sealed partial class SplashScreen : WinUIEx.SplashScreen
{
    private readonly Func<Task<bool>> PerformLogin;
    public SplashScreen(Type window, Func<Task<bool>> IO) : base(window)
    {
        InitializeComponent();
        PerformLogin = IO;
        Login.Click += async (_, _) =>
        {
            // open the mitamatch login page by default in the default browser
            _ = await Windows.System.Launcher.LaunchUriAsync(new Uri("https://mitama-oauth.vercel.app"));
        };
    }

    protected override async Task OnLoading()
    {
        var jwt = Director.ReadCache().JWT;
        if (jwt is not null && App.DecodeJwt(jwt).IsOk())
        {
            Login.IsEnabled = false;
            await Task.Delay(1000);
            return;
        }

        while (!await PerformLogin())
        {
            await Task.Delay(1000);
        }
    }
}

ポーリングには channel を使う.
App 側で channel を持っておいて、それを SplashScreen に渡すラムダ式の中で ReadAsync すればよい.

App.xaml.cs
// ...
    private readonly Channel<Result<DiscordUser, string>> channel
        = Channel.CreateUnbounded<Result<DiscordUser, string>>();
// ...
        AppInstance.GetCurrent().Activated += On_Activated;

        var splash = new SplashScreen(typeof(MainWindow), async () =>
            await channel.Reader.ReadAsync() switch
            {
                Ok<DiscordUser, string> => true,
                _ => false,
            });
        splash.Completed += (_, e) => m_window = e;
// ...

リダイレクト時には On_Activated を呼ぶようにする.
On_Activated から channel に WriteAsync する.

App.xaml.cs
    private async void On_Activated(object _, AppActivationArguments args)
    {
        if (args.Kind == ExtendedActivationKind.Protocol)
        {
            var eventArgs = args.Data as Windows.ApplicationModel.Activation.ProtocolActivatedEventArgs;
            var query = eventArgs.Uri.Query;
            var queryDictionary = System.Web.HttpUtility.ParseQueryString(query);
            var jwtToken = queryDictionary["token"];
            // verify JWT token
            switch (DecodeJwt(jwtToken))
            {
                case Ok<string, string>(var json):
                    {
                        var user = JsonConvert.DeserializeObject<DiscordUser>(json);
                        await channel.Writer.WriteAsync(new Ok<DiscordUser, string>(user));
                        break;
                    }
                case Err<string, string>(var msg):
                    {
                        await channel.Writer.WriteAsync(new Err<DiscordUser, string>(msg));
                        break;
                    }
            }
        }
    }

最後に JWT の検証を作ったらおわり……

    public static Result<string, string> DecodeJwt(string token)
    {
        try
        {
            var json = JwtBuilder
                .Create()
                .WithAlgorithm(new JWT.Algorithms.HMACSHA256Algorithm())
                .WithSecret("MITAMA_AUTH_JWT_SECRET")
                .MustVerifySignature()
                .Decode(token);
            return new Ok<string, string>(json);
        }
        catch (Exception ex)
        {
            return new Err<string, string>(ex.Message);
        }
    }

終わりではなかった、ここでとんでもない問題がある.
WinUI は Protocol で開いた場合、新しいウィンドウを開くことしかできない.
リダイレクトで処理を戻すには新しいウィンドウを即閉じて開いているウィンドウに処理を移譲する必要がある.
よって、完全な OnLaunched は次のようになる:

App.xaml.cs
    protected override async void OnLaunched(LaunchActivatedEventArgs args)
    {
        // Get the activation args
        var appArgs = AppInstance.GetCurrent().GetActivatedEventArgs();

        // Get or register the main instance
        var mainInstance = AppInstance.FindOrRegisterForKey("main");

        // If the main instance isn't this current instance
        if (!mainInstance.IsCurrent)
        {
            // Redirect activation to that instance
            await mainInstance.RedirectActivationToAsync(appArgs);

            // And exit our instance and stop
            Process.GetCurrentProcess().Kill();
            return;
        }
        else
        {
            // call if directly open with schema
            On_Activated(null, appArgs);
        }

        // Otherwise, register for activation redirection
        AppInstance.GetCurrent().Activated += On_Activated;

        var splash = new SplashScreen(typeof(MainWindow), async () =>
            await channel.Reader.ReadAsync() switch
            {
                Ok<DiscordUser, string> => true,
                _ => false,
            });
        splash.Completed += (_, e) => m_window = e;
    }

これで本当に終わりである.

認証用のサイト

Next.js 14 でサクッと作った.
説明は割愛する.
本当に必要最低限しかないので参考にしやすいかも.

https://github.com/loliGothicK/mitama-oauth

DataStore

ユーザ情報を貯めておきたいのでどこかに送信しておきましょう.
Google.Cloud.Datastore.V1 を使いましょう.
複数テーブルに対応してる雰囲気がなさすぎてキレましたが.
とりあえず (default) という名前のテーブルは扱えるようなので……

    private static void Upsert(DiscordUser user)
    {
        var credential = Google.Apis.Auth.OAuth2.GoogleCredential.FromJson(JsonConvert.SerializeObject(secretJson));
        var client = new DatastoreClientBuilder() { Credential = credential }.Build();
        DatastoreDb db = DatastoreDb.Create("assaultlily", string.Empty, client);

        var key = db.CreateKeyFactory("user").CreateKey(user.id);
        var result = db.Lookup(key);
        if (result is not null)
        {
            var entity = new Entity()
            {
                Key = key,
                ["id"] = user.id,
                ["avatar"] = user.avatar,
                ["global_name"] = user.global_name,
                ["email"] = user.email,
                ["created_at"] = result["created_at"],
                ["updated_at"] = DateTime.UtcNow
            };

            db.Upsert(entity);
        }
        else
        {
            var entity = new Entity()
            {
                Key = key,
                ["id"] = user.id,
                ["avatar"] = user.avatar,
                ["global_name"] = user.global_name,
                ["email"] = user.email,
                ["created_at"] = DateTime.UtcNow,
                ["updated_at"] = DateTime.UtcNow
            };

            db.Upsert(entity);
        }
    }

いかがでしたか?

つらい

Discussion