㊙️
WinUI 3 で Discord 連携ログイン機能を実装する
MitamatchOperations Version 5 (Strongylodon)
なんと、2年以上開発している
使用技術
Windows Application
- WinUI 3
- WinUIEx (ライブラリ)
- JWT (ライブラリ)
リダイレクト用のサイト
- Next.js 14 / TypeScript
- Vercel
バックエンド
- Google Cloud
- DataStore
仕様
- スプラッシュスクリーンでログイン待ちをする
- ブラウザで認証サイトを開く
- Discord OAuth 2.0 をやってもらう
- カスタムURLスキーマ (mitamatch://auth)にJWTをつけてリダイレクトする
- JWTを検証する
- 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 でサクッと作った.
説明は割愛する.
本当に必要最低限しかないので参考にしやすいかも.
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