OAuth2 と PKCE で Delphi アプリケーションにユーザーをサインインさせる
コンポーネント 1 つ、グラントタイプ 1 つ、ブラウザーへの受け渡し 1 回。このページは空のフォームから、有効なアクセストークンを持つサインイン済みユーザーまでを案内します。使うのは PKCE 付き認可コードフロー(RFC 7636)で、今日どのプロバイダーもネイティブデスクトップアプリケーションに求めるフローです。
コンポーネント 1 つ、グラントタイプ 1 つ、ブラウザーへの受け渡し 1 回。このページは空のフォームから、有効なアクセストークンを持つサインイン済みユーザーまでを案内します。使うのは PKCE 付き認可コードフロー(RFC 7636)で、今日どのプロバイダーもネイティブデスクトップアプリケーションに求めるフローです。
非ビジュアルコンポーネント 1 つがプロバイダーとやり取りします。Web サーバーも、埋め込みブラウザーも、REST フレームワークも必要ありません。
TsgcHTTP_OAuth2_Client です。ユニット sgcHTTP で宣言されており、どのデモでもそうしているようにコードで生成します。
OAuth2Options.GrantType := auth2CodePKCE です。この代入 1 行で PKCE が有効になります。
Standard、Professional、Enterprise、All-Access です。クライアントは Enterprise 機能ではありませんが、サーバーはそうです。
Windows、macOS、Linux、iOS、Android です。コンポーネントは各プラットフォームが提供するブラウザーを開きます。
PKCE が存在するのは、デスクトップアプリケーションが秘密を保持できないからです。PKCE は秘密の代わりに、フロー開始前からクライアントがその値を知っていたと証明できる値を使います。
高エントロピーのランダム文字列です。sgcWebSockets はプラットフォームの CSPRNG に 32 バイトを要求し、それを Base64URL エンコードして、RFC 7636 が求める 43 文字の verifier を生成します。
verifier の SHA-256 を Base64URL エンコードしたものです。認可リクエストで流れるのはこの challenge なので、リダイレクトを盗聴する者が verifier を目にすることはありません。
コンポーネントは client_id、redirect_uri、scope、state、code_challenge、code_challenge_method=S256 を含む認可 URL を組み立て、システムブラウザーを起動します。
同意はブラウザー上、プロバイダー自身のドメインで、ユーザーの既存セッション、パスワードマネージャー、2 要素認証デバイスとともに行われます。アプリケーションがパスワードを目にすることはありません。
プロバイダーは code と state を付けて redirect_uri へリダイレクトします。デスクトップではその URI はループバックアドレスで、コンポーネントはすでにそこで待ち受けています。
コンポーネントはコードと元の code_verifier をトークンエンドポイントへ POST します。プロバイダーは SHA-256 を再計算して比較します。一致すれば、アクセストークンが得られます。
認可コードは、生存する数秒間はベアラー値です。リダイレクトを観測できるもの、たとえば同じカスタム URI スキームに登録された悪意あるアプリケーション、プロキシ、共有ログなどは、それを盗めます。PKCE がなければ、盗まれたコードだけでトークンを発行させられます。
PKCE があれば、トークンエンドポイントは、開始時に送られた challenge と SHA-256 ハッシュが一致する verifier を呼び出し側が提示しない限り、そのコードを拒否します。攻撃者が見たのはハッシュだけなので、盗んだコードには価値がありません。
ここに自分で書くものはありません。GrantType を auth2CodePKCE に設定すれば、コンポーネントがステップ 1、2、3、5、6 を代行します。以降に示すのは、それを動かすコードと、自分で決める必要がある 2 つの事柄、すなわちリダイレクト URI と、リフレッシュトークンの保管場所です。
# 1. Browser is sent here (query wrapped for reading)
GET https://provider.com/oauth2/authorize
?response_type=code
&client_id=your-client-id
&redirect_uri=http://127.0.0.1:52413/
&scope=openid%20profile
&state=8F3B1C2A-...-9D4E
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256
# 2. Provider redirects back to the loopback listener
GET http://127.0.0.1:52413/?code=4/0Ab_5q...&state=8F3B1C2A-...-9D4E
# 3. Component exchanges the code, adding the verifier
POST https://provider.com/oauth2/token
grant_type=authorization_code
&code=4/0Ab_5q...
&redirect_uri=http://127.0.0.1:52413/
&client_id=your-client-id
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
コンポーネントを生成し、auth2CodePKCE を選び、プロバイダーの 2 つのエンドポイントを指定し、OnAfterAccessToken を割り当てて Start を呼び出します。ブラウザーが開き、ユーザーが同意すると、イベントがトークンとともに発火します。
uses
Classes, SysUtils,
// sgc
sgcHTTP, sgcHTTP_OAuth_Types;
// OAuth2 is a form field: OAuth2: TsgcHTTP_OAuth2_Client;
procedure TForm1.SignIn;
begin
OAuth2 := TsgcHTTP_OAuth2_Client.Create(nil);
OAuth2.OnAfterAccessToken := OnAfterAccessToken;
OAuth2.OnErrorAccessToken := OnErrorAccessToken;
// PKCE. The verifier and the S256 challenge are generated internally.
OAuth2.OAuth2Options.GrantType := auth2CodePKCE;
OAuth2.OAuth2Options.ClientId := 'your-client-id';
// The two endpoints from the provider's documentation.
OAuth2.AuthorizationServerOptions.AuthURL :=
'https://provider.com/oauth2/authorize';
OAuth2.AuthorizationServerOptions.TokenURL :=
'https://provider.com/oauth2/token';
OAuth2.AuthorizationServerOptions.Scope.Clear;
OAuth2.AuthorizationServerOptions.Scope.Add('openid');
OAuth2.AuthorizationServerOptions.Scope.Add('profile');
// Loopback redirect. Port 0 asks the OS for a free port.
OAuth2.LocalServerOptions.IP := '127.0.0.1';
OAuth2.LocalServerOptions.Port := 0;
OAuth2.Start; // opens the browser and returns immediately
end;
procedure TForm1.OnAfterAccessToken(Sender: TObject; const Access_Token,
Token_Type, Expires_In, Refresh_Token, Scope, RawParams: String;
var Handled: Boolean);
begin
Memo1.Lines.Add('Signed in. Token expires in ' + Expires_In + ' s');
SaveRefreshToken(Refresh_Token); // your own storage, see below
end;
procedure TForm1.OnErrorAccessToken(Sender: TObject; const Error,
Error_Description, Error_URI, RawParams: String);
begin
Memo1.Lines.Add('Sign-in failed: ' + Error + ' / ' + Error_Description);
end;
// include: sgcHTTP.hpp, sgcHTTP_OAuth_Types.hpp
TsgcHTTP_OAuth2_Client *OAuth2 = new TsgcHTTP_OAuth2_Client(this);
OAuth2->OnAfterAccessToken = OnAfterAccessToken;
OAuth2->OnErrorAccessToken = OnErrorAccessToken;
OAuth2->OAuth2Options->GrantType = auth2CodePKCE;
OAuth2->OAuth2Options->ClientId = "your-client-id";
OAuth2->AuthorizationServerOptions->AuthURL =
"https://provider.com/oauth2/authorize";
OAuth2->AuthorizationServerOptions->TokenURL =
"https://provider.com/oauth2/token";
OAuth2->AuthorizationServerOptions->Scope->Clear();
OAuth2->AuthorizationServerOptions->Scope->Add("openid");
OAuth2->AuthorizationServerOptions->Scope->Add("profile");
OAuth2->LocalServerOptions->IP = "127.0.0.1";
OAuth2->LocalServerOptions->Port = 0;
OAuth2->Start();
void __fastcall TForm1::OnAfterAccessToken(TObject *Sender,
const UnicodeString Access_Token, const UnicodeString Token_Type,
const UnicodeString Expires_In, const UnicodeString Refresh_Token,
const UnicodeString Scope, const UnicodeString RawParams, bool &Handled)
{
Memo1->Lines->Add("Signed in. Token expires in " + Expires_In + " s");
}
Web の OAuth2 から来ると答えが自明でない部分であり、最初の試みで最も間違えやすい部分です。
デスクトップアプリケーションにはリダイレクト先のドメインがありません。広く受け入れられている答えであり、このコンポーネントが実装しているのがループバックリダイレクトです。アプリケーションが 127.0.0.1 で小さな HTTP リスナーを起動し、そのアドレスをリダイレクト URI として登録し、コードが届いたらすぐにリスナーを停止します。
LocalServerOptions.IP の既定値は 127.0.0.1、LocalServerOptions.Port の既定値は 8080 です。出荷するデスクトップアプリケーションでは代わりに Port := 0 を設定してください。オペレーティングシステムが空いているエフェメラルポートを割り当て、コンポーネントは送信するリダイレクト URI にそのポートを入れるため、同じマシン上でアプリケーションが 2 つ動いても衝突しません。
プロバイダーがホストとポートだけでなく、登録済みの正確なパスを要求する場合は、LocalServerOptions.RedirectURL に登録した値を設定します。その文字列が、計算された値を上書きします。パスを固定するということはポートも固定するということなので、ポートも登録し、Port := 0 の手は使わないでください。
リスナーはフローが待機している間だけ起動しています。auth2ClientCredentials、auth2ResourceOwnerPassword、auth2DeviceCode ではリダイレクトがまったく不要なため、起動されることはありません。
// Recommended for a shipped desktop app:
// random free port, no collisions, no registration of a port
OAuth2.LocalServerOptions.IP := '127.0.0.1';
OAuth2.LocalServerOptions.Port := 0;
// When the provider requires an exact registered redirect URI:
OAuth2.LocalServerOptions.IP := '127.0.0.1';
OAuth2.LocalServerOptions.Port := 8080;
OAuth2.LocalServerOptions.RedirectURL := 'http://localhost:8080/oauth/';
// Replace the browser page the user is left looking at
OAuth2.OnHTTPResponse := OnHTTPResponse;
procedure TForm1.OnHTTPResponse(Sender: TObject; var Code: Integer;
var Text: String);
begin
Code := 200;
Text := '<html><body>You are signed in. ' +
'Close this tab and return to the app.</body></html>';
end;
OnAfterAccessToken が発火した後も、同じ値は読み取り専用プロパティとして利用でき、コンポーネントはヘッダーに触れさせることなくそれらを HTTP クライアントや WebSocket クライアントへ渡せます。
イベントのパラメーターは便利ですが、値の写しはそれだけではありません。AccessToken、TokenType、CurrentExpiresIn、CurrentRefreshToken はコンポーネントが生きている限り同じ値を保持するため、コードの別の場所にあるハンドラーが、値を引き回さずに読み取れます。
RawParams は、トークンエンドポイントから返った手つかずの JSON ボディです。プロバイダーが標準の項目以外を返した場合、たとえば OpenID Connect の id_token などは、そこから取り出して解析します。コンポーネントが ID トークンをデコードすることはありません。
すべてのリクエストに自動的にトークンを載せるには、TsgcHTTP1Client、TsgcHTTP2Client、TsgcWebSocketClient の Authentication.Token.OAuth に OAuth2 コンポーネントを割り当てます。クライアントは、プロバイダーが返した token_type を使って Authorization: Bearer <token> を代わりに送信します。
var
vHTTP: TsgcHTTP1Client;
begin
// Read the tokens at any time after the flow completed
Memo1.Lines.Add(OAuth2.AccessToken);
Memo1.Lines.Add(OAuth2.TokenType); // normally 'Bearer'
Memo1.Lines.Add(IntToStr(OAuth2.CurrentExpiresIn));
Memo1.Lines.Add(OAuth2.CurrentRefreshToken);
// Let the HTTP client attach the Authorization header itself
vHTTP := TsgcHTTP1Client.Create(nil);
vHTTP.Authentication.Token.OAuth := OAuth2;
Memo1.Lines.Add(vHTTP.Get('https://api.provider.com/v1/me'));
end;
アクセストークンの寿命は数分です。リフレッシュトークンの寿命は数週間から数か月です。後者を保持することが、サインインをセッションに変えます。
アプリケーションの 1 回の実行中は何もすることがありません。トークンエンドポイントがリフレッシュトークンと expires_in の両方を返すと、コンポーネントはその寿命のおよそ半分で内部タイマーを仕掛け、発火時にアクセストークンが切れるよりずっと前に grant_type=refresh_token を送信します。OnAfterRefreshToken が新しい組とともに発火し、プロバイダーが拒否した場合は OnErrorRefreshToken が発火します。OnAfterAccessToken の Handled パラメーターはそのままにしてください。True にすると、以降は自分で引き受けるとコンポーネントに伝えることになり、コンポーネントはリフレッシュトークンを保存せず、そのタイマーも仕掛けません。
再起動をまたぐ場合はこちらの担当です。ユーザーのマシンのどこに秘密を書き込んでよいかを知っているのは開発者だけだからです。リフレッシュトークンを永続化し、次回の起動では Start をまるごと飛ばして、保存した値で Refresh を呼び出します。ブラウザーは開かず、メインフォームが描画される前にユーザーはサインイン済みになります。
リフレッシュトークンをローテーションするプロバイダーは、更新のたびに新しいものを渡してくるため、OnAfterRefreshToken のたびに保存済みの値を上書きしてください。保存したトークンがついに拒否されたら、Start に戻ってユーザーにもう一度サインインしてもらいます。
ユーザーを正しくサインアウトさせるには Revoke を、トークンがまだ有効かどうかをプロバイダーに問い合わせるには Introspect を使います。どちらも AuthorizationServerOptions に対応するエンドポイントを設定しておく必要があります。
procedure TForm1.FormCreate(Sender: TObject);
var
vStored: string;
begin
ConfigureOAuth2; // same settings as the QuickStart
OAuth2.OnAfterRefreshToken := OnAfterRefreshToken;
OAuth2.OnErrorRefreshToken := OnErrorRefreshToken;
vStored := LoadRefreshToken;
if vStored <> '' then
OAuth2.Refresh(vStored) // silent, no browser
else
OAuth2.Start; // first run, ask the user
end;
procedure TForm1.OnAfterRefreshToken(Sender: TObject; const Access_Token,
Token_Type, Expires_In, Refresh_Token, Scope, RawParams: String;
var Handled: Boolean);
begin
// providers that rotate hand back a new refresh token
if Refresh_Token <> '' then
SaveRefreshToken(Refresh_Token);
end;
procedure TForm1.OnErrorRefreshToken(Sender: TObject; const Error,
Error_Description, Error_URI, RawParams: String);
begin
ClearStoredRefreshToken;
OAuth2.Start; // the stored token is dead, prompt again
end;
// Signing out
OAuth2.AuthorizationServerOptions.RevocationURL :=
'https://provider.com/oauth2/revoke';
OAuth2.Revoke(OAuth2.CurrentRefreshToken, 'refresh_token');
sgcWebSockets はトークン保管庫をあえて同梱していません。資格情報をどこに書き込んでよいかは、利用者と配布形態に関する判断なので、ライブラリはトークンを渡すところまでにとどめています。
数分で期限切れになりますし、リフレッシュトークンがあればいつでも新しく発行できます。ディスクに書き出す理由はなく、書き出さない理由ばかりです。
Windows では DPAPI(CryptProtectData)が暗号文を Windows アカウントに結び付けるため、ファイルをコピーしても別のマシンでは役に立ちません。macOS には Keychain が、最近の Linux デスクトップには Secret Service があります。
配布される実行ファイルの中身はすべて公開されているのと同じです。それが PKCE の前提そのものです。プロバイダーがデスクトップクライアント向けにシークレットを発行する場合は、保護手段ではなく識別子として扱ってください。
ユーザーごとのアプリケーションデータに置きます。Program Files でも、実行ファイルの隣でも、共有のネットワークパスでも、ソース管理にコミットされた素の INI でもありません。
Revoke を呼び出してプロバイダーにトークンを無効化させ、そのうえで保存した写しを削除します。失効済みのトークンがディスクに残っていれば、それも監査での指摘対象です。
HTTPClientOptions.LogOptions は認可サーバーとの通信をファイルに書き出します。フローを動かすまでの間は非常に有用ですが、そのファイルはトークンだらけです。出荷前に無効にしてください。
OAuth 2.0 のプロバイダーはどれも、同じ数個の設定を求めます。2 つのエンドポイント、クライアント ID、スコープ、そして登録済みのリダイレクトです。Google と Microsoft については、エンドポイントを埋めてユーザープロファイルを返す、すぐに使えるコンポーネントも用意されています。
TsgcHTTP_OAuth2_Client_Google と TsgcHTTP_OAuth2_Client_Microsoft は同じ基底コンポーネントを継承し、エンドポイントをあらかじめ埋めています。これらの Authenticate メソッドはブロッキングです。フロー全体を実行し、ブラウザーの往復を待ち、Authenticated と内容の入った UserProfile を持つデータオブジェクトを返します。
「このユーザーは誰か」に至る最短経路がこれです。TsgcOAuth2_Google_Data.UserProfile は _Name、Given_Name、Family_Name、Id、Locale、Picture を持ちます。TsgcOAuth2_Microsoft_Data.UserProfile は DisplayName、GivenName、Surname、Mail、JobTitle、OfficeLocation などを持ちます。Microsoft の Authenticate は最初にテナント ID を取ります。
それ以外のプロバイダーでは、基底の TsgcHTTP_OAuth2_Client を使い、2 つの URL をそのドキュメントから写します。その後にプロバイダー固有のものは何も残りません。
uses
sgcHTTP, sgcHTTP_OAuth2_Client_Google;
var
vClient: TsgcHTTP_OAuth2_Client_Google;
vData: TsgcOAuth2_Google_Data;
begin
vClient := TsgcHTTP_OAuth2_Client_Google.Create(nil);
try
vData := vClient.Authenticate('client-id', 'client-secret');
if vData.Authenticated then
begin
ShowMessage(vData.UserProfile._Name);
ShowMessage(vData.AccessToken);
end;
finally
vClient.Free;
end;
end;
| プロバイダー | コンポーネント | グラントタイプ | リダイレクト | クライアントシークレット |
|---|---|---|---|---|
TsgcHTTP_OAuth2_Client_Google または基底クライアント |
auth2CodePKCE |
ループバック、Port := 0 |
デスクトップクライアント向けに発行されます。持っている場合は設定します | |
| Microsoft Entra ID | TsgcHTTP_OAuth2_Client_Microsoft または基底クライアント |
auth2CodePKCE |
ループバック、モバイル/デスクトップのプラットフォームとして登録します | パブリッククライアントでは使いません。空のままにします |
| Auth0、Okta、Keycloak、AWS Cognito | TsgcHTTP_OAuth2_Client |
auth2CodePKCE |
ループバック、アプリケーションに登録します | アプリがパブリックか、コンフィデンシャルかによります |
| バックグラウンドジョブとサービス | TsgcHTTP_OAuth2_Client |
auth2ClientCredentials |
なし。ブラウザーは関与しません | 必須です。配布されるものがないため安全です |
| キオスク、テレビ、ヘッドレス機器 | TsgcHTTP_OAuth2_Client |
auth2DeviceCode(RFC 8628) |
なし。ユーザーはスマートフォンで完了します | 通常は不要です |
Gmail と Microsoft 365 は SMTP、IMAP、POP でのパスワード受け付けをやめました。代わりに使うのは、いま取得したのと同じアクセストークンで、SASL XOAUTH2 メカニズムを通して提示します。
トークンの取得は上記のフローそのものです。auth2CodePKCE、ループバックリダイレクト、そして AuthorizationServerOptions.Scope に https://mail.google.com/ のようなメール用スコープを指定します。メールの用途だからといって OAuth2 側が変わることはありません。
提示するのがもう半分です。sgcIndy はユニット IdSASLXOAUTH2 に TIdSASLXOAUTH2 を同梱しています。これを TIdSMTP.SASLMechanisms に追加し、AuthType := satSASL を設定して、その OnAuthenticate イベントからユーザー名とアクセストークンを渡します。同じメカニズムは TIdIMAP4 と TIdPOP3 でも使えます。
2 つのコンポーネントは頭の中で切り分けておいてください。OAuth2 クライアントはトークンの取得と更新の仕方を知っており、SASL メカニズムはトークンの提示の仕方を知っています。どちらも相手のことを知る必要はありません。
uses
IdSMTP, IdSASLXOAUTH2;
var
vSASL: TIdSASLXOAUTH2;
vSMTP: TIdSMTP;
begin
vSASL := TIdSASLXOAUTH2.Create(nil);
vSASL.OnAuthenticate := OnXOAuth2Authenticate;
vSMTP := TIdSMTP.Create(nil);
vSMTP.AuthType := satSASL;
vSMTP.SASLMechanisms.Clear;
vSMTP.SASLMechanisms.Add.SASL := vSASL;
end;
procedure TForm1.OnXOAuth2Authenticate(Sender: TObject;
var Username: string; var Token: string);
begin
Username := 'user@example.com';
Token := OAuth2.AccessToken; // from the PKCE flow above
end;
ここまではすべてクライアント側の話です。後半が必要になるのは、自分がトークンを発行する側である場合だけです。
他社の ID プロバイダー、つまり Google、Microsoft、Auth0、Okta、Keycloak、AWS Cognito、あるいは自社の企業 SSO にユーザーをサインインさせるだけなら、必要なのは TsgcHTTP_OAuth2_Client だけです。このコンポーネントは Standard エディションとそれ以上のすべてのエディションにコンパイルされています。必要なランタイムを同梱した単体パッケージ sgcAuth としても単独で入手できます。
これが一般的なケースであり、ここまでのページ全体がその話です。
サーバー側が必要になるのは、自分のアプリケーション自体が認可サーバーである場合だけです。つまりクライアント ID を発行し、サインインページをホストし、自分の API が信頼するアクセストークンを発行・失効させる場合です。それが TsgcHTTP_OAuth2_Server で、TsgcWebSocketHTTPServer に接続して使う Enterprise コンポーネントです。
既定で PKCE を検証します。OAuth2Options.PKCE は初期状態で True なので、challenge を送ってきたクライアントは一致する verifier を提示しなければならず、提示できないものは拒否されます。クライアントアプリケーションは Apps.AddApp で登録し、ユーザーの認証は OnOAuth2Authentication で行い、再起動をまたぐトークンの復元は AddToken で行います。
同じ Enterprise ティアには、エンドポイントで JWT ベアラートークンを検証する TsgcHTTP_JWT_Server と、パスキー用の TsgcWSAPIServer_WebAuthn も含まれます。対応するクライアントである TsgcHTTP_OAuth2_Client と TsgcHTTP_JWT_Client は Standard 以上です。クライアントとサーバーは別のエディションティアに属するので、どちらを前提に計画を立てるにしても、先に確認しておく価値があります。
uses
sgcWebSocket, sgcWebSocket_Classes, sgcHTTP,
sgcHTTP_OAuth_Types, sgcHTTP_OAuth2_Server;
var
vOAuth2: TsgcHTTP_OAuth2_Server;
vServer: TsgcWebSocketHTTPServer;
begin
vOAuth2 := TsgcHTTP_OAuth2_Server.Create(nil);
vOAuth2.OAuth2Options.PKCE := True; // default
vOAuth2.OnOAuth2Authentication := OnOAuth2Authentication;
vOAuth2.Apps.AddApp('MyDesktopApp', 'http://127.0.0.1:8080',
'my-client-id', 'my-client-secret', 3600, True,
[auth2Code, auth2CodePKCE]);
vServer := TsgcWebSocketHTTPServer.Create(nil);
vServer.Authentication.Enabled := True;
vServer.Authentication.OAuth.OAuth2 := vOAuth2;
vServer.Port := 8080;
vServer.Active := True;
end;
procedure TForm1.OnOAuth2Authentication(Connection: TsgcWSConnection;
OAuth2: TsgcHTTPOAuth2Request; aUser, aPassword: String;
var Authenticated: Boolean);
begin
Authenticated := CheckUserInYourDatabase(aUser, aPassword);
end;
デスクトップでの OAuth2 の最初の失敗は、ほぼこの 6 つのいずれかです。
redirect_uri_mismatchコンポーネントが送る URI は、末尾のスラッシュとポートを含めて、登録した内容と 1 文字ずつ一致していなければなりません。固定の URI を登録した場合は、計算された値に頼らず、LocalServerOptions.RedirectURL にその文字列をそのまま設定します。プロバイダーが任意のループバックポートを許可するなら、Port := 0 を使ってホストだけを登録します。
ポートが何かに占有されているか、ファイアウォールの規則がループバックリスナーを遮断しています。Port := 0 を設定し、前回のフローが待ち受けたままではなく Stop で終了しているかを確認してください。
invalid_grant認可コードは 1 回限りで寿命も短いものです。リダイレクトと交換の間にブレークポイントを置いてデバッグすると、コードは期限切れになります。推測するのではなく、プロバイダー自身の error と error_description を返す OnErrorAccessToken から失敗の内容を読み取ってください。
プロバイダーは要求されたときにしか発行しません。Google は access_type=offline を、Microsoft は offline_access スコープを求めます。AuthorizationServerOptions.Scope にスコープを追加するか、OnBeforeAuthorizeCode で URL パラメーターを編集してクエリパラメーターを付け足してください。
トークン交換は HTTPS の POST なので、動作する TLS バックエンドが必要です。選択するのは HTTPClientOptions.TLSOptions.IOHandler です。iohOpenSSL、DLL を配布せずに済む Windows 上の iohSChannel、そして Enterprise エディションに含まれるネイティブの iohAndroidTLS と iohAppleTLS のハンドラーがあります。
OnBeforeAuthorizeCode を処理し、Handled := True を設定して、渡された URL に自前の TsgcWebView2 や TWebBrowser を遷移させます。ループバックリスナーは変わらずリダイレクトを捕捉します。なお、いくつかのプロバイダーは埋め込みブラウザー内での同意画面の表示を拒否するようになっています。
開発者が着手前に実際に検索する質問です。
TsgcHTTP_OAuth2_Client を配置し、OAuth2Options.GrantType := auth2CodePKCE を設定し、OAuth2Options.ClientId、AuthorizationServerOptions.AuthURL、AuthorizationServerOptions.TokenURL、AuthorizationServerOptions.Scope を入力し、LocalServerOptions.IP を 127.0.0.1、LocalServerOptions.Port を 0 に設定してから、Start を呼び出します。コンポーネントが PKCE の値を生成し、ブラウザーを開き、ループバックリスナーでリダイレクトを捕捉し、コードを交換して、トークンとともに OnAfterAccessToken を発生させます。GrantType が auth2CodePKCE のとき、TsgcHTTP_OAuth2_Client はプラットフォームの暗号論的乱数源から 32 バイトを取り出し、Base64URL エンコードして 43 文字の code verifier を作り、その verifier の SHA-256 ハッシュを Base64URL エンコードしたものを code challenge に設定し、code_challenge_method を S256 に固定します。verifier はコンポーネント内部に秘匿されたままトークン交換で再送されるため、リダイレクトに現れることはありません。別の用途でこの組を自分で作りたい場合、同じプリミティブが公開されています。ユニット sgcCrypto_Random の sgcRandomBytes、およびユニット sgcBase_Helpers の GetHashSHA256 と EncodeBase64URL です。TsgcHTTP_OAuth2_Client はフローの実行中だけ LocalServerOptions.IP と LocalServerOptions.Port で小さな HTTP リスナーを起動し、送信するリダイレクト URI はその値から組み立てられます。既定値は 127.0.0.1 とポート 8080 です。出荷するアプリケーションでは Port を 0 に設定して、オペレーティングシステムに空いているエフェメラルポートを選ばせれば、2 つのインスタンスが 1 つのポートを奪い合うことはありません。プロバイダーが登録済みの正確な URI を要求する場合は、その文字列を LocalServerOptions.RedirectURL に入れてください。計算された値を上書きします。OAuth2Options.ClientSecret を空のままにします。それでもデスクトップクライアント向けにシークレットを発行し、トークンリクエストでの提示を求めるプロバイダーもあります。その場合は設定してください。ただし保護手段ではなく識別子として扱ってください。配布される実行ファイルの中身は取り出せるからです。Start ではなくそれを使って Refresh を呼び出します。値は OnAfterAccessToken の Refresh_Token パラメーターから、あるいは後から CurrentRefreshToken プロパティから読み取れます。リフレッシュトークンをローテーションするプロバイダーは古いものを無効化するため、OnAfterRefreshToken のたびに保存済みの写しを上書きしてください。1 回の実行中はまったく作業が要りません。コンポーネントが expires_in の値からタイマーを仕掛け、自動的にアクセストークンを更新します。HTTPClientOptions.LogOptions を無効にすることも忘れないでください。そのログにはトークンが含まれます。https://mail.google.com/ のようなプロバイダーのメール用スコープを要求し、取得したトークンを SASL XOAUTH2 で提示します。sgcIndy はユニット IdSASLXOAUTH2 に TIdSASLXOAUTH2 を同梱しています。これを TIdSMTP.SASLMechanisms に追加し、AuthType := satSASL を設定して、その OnAuthenticate イベントからユーザー名とアクセストークンを返します。同じメカニズムで TIdIMAP4 と TIdPOP3 も認証できます。TsgcHTTP_OAuth2_Server が必要になるのは、自分のアプリケーションがクライアント ID を登録し、サインインページをホストし、自分の API が信頼するトークンを発行する場合です。OAuth2Options.PKCE によって既定で PKCE を検証し、アプリケーションは Apps.AddApp で登録し、Authentication.OAuth.OAuth2 を通じて TsgcWebSocketHTTPServer に接続します。OnBeforeAuthorizeCode を処理してください。組み立て済みの認可 URL を var パラメーターとして受け取るので、Handled := True を設定してコンポーネントにシステムブラウザーを起動させないようにし、TsgcWebView2 などの埋め込みコントロールをその URL へ遷移させます。ループバックリスナーは変わらずリダイレクトを受け取り、フローは通常どおり完了します。ただし、いくつかのプロバイダーは埋め込みブラウザーでの同意画面の表示を遮断するようになっており、システムブラウザーが既定である理由もそこにあります。TsgcHTTP_OAuth2_Client は Windows、macOS、Linux、iOS、Android 向けに、VCL、FireMonkey、Lazarus / FPC で、Delphi 7 から Delphi 13 および対応する C++ Builder のバージョンでコンパイルできます。ブラウザーを開く処理は各プラットフォームが提供するものを使います。プラットフォーム固有の選択はトークン交換用の TLS バックエンドだけで、HTTPClientOptions.TLSOptions.IOHandler で選びます。コンポーネントリファレンス、すぐ実行できるデモプロジェクト、そしてこのページより踏み込んだ技術文書です。
| オンラインヘルプ、TsgcHTTP_OAuth2_Client クライアントコンポーネントのすべてのプロパティ、メソッド、イベントと、認可コード + PKCE のトピック。 | 開く | |
| オンラインヘルプ、PKCE 付き認可コード グラントタイプのトピック。PKCE が何をするか、設定表、ランダムポートの推奨事項。 | 開く | |
| デモプロジェクト、Demos\20.HTTP_Protocol\02.OAuth2_Authentication Gmail、Google Pub/Sub、Azure AD、AWS Cognito、Dropbox、Auth0 向けの動作するプリセットを備えたクライアントとサーバーのプロジェクト。埋め込みブラウザー版も含みます。 | 開く | |
| 技術文書、OAuth2 クライアント(PDF) 機能、クイックスタート、すべてのグラントタイプ、そして Delphi、C++ Builder、.NET のコードサンプル。 | 開く | |
| 技術文書、OAuth2 サーバー(PDF) Enterprise の認可サーバーコンポーネント。エンドポイント、アプリ登録、PKCE 検証、トークンのライフサイクル。 | 開く | |
| ユーザーマニュアル(PDF) ライブラリのすべてのコンポーネントを網羅した総合マニュアル。 | 開く |
プロバイダーのサポート窓口と議論を決着させる必要があるときのための一次資料です。
コンポーネントのページには機能の全一覧があり、記事はこのページが触れるだけにとどめたケースを扱っています。
Enterprise の認可サーバーです。自前の authorize、token、revoke、introspect の各エンドポイントを提供します。
続きを読む →JSON Web Token に署名して付与します。単独でも、HTTP クライアントや WebSocket クライアントの Bearer トークンの供給元としても使えます。
続きを読む →このページは Delphi ユースケースの 1 つで、いずれも 1 つのタスクを最初から最後まで扱います。現時点でのほかのページは、Delphi から LLM を呼び出すと2 つのアプリケーションを WebRTC でピアツーピア接続するです。