OAuth2 と PKCE で Delphi アプリケーションにユーザーをサインインさせる

コンポーネント 1 つ、グラントタイプ 1 つ、ブラウザーへの受け渡し 1 回。このページは空のフォームから、有効なアクセストークンを持つサインイン済みユーザーまでを案内します。使うのは PKCE 付き認可コードフロー(RFC 7636)で、今日どのプロバイダーもネイティブデスクトップアプリケーションに求めるフローです。

TsgcHTTP_OAuth2_Client
code verifier と challenge を自動生成
Delphi 7 から 13、C++ Builder、VCL と FireMonkey

ユーザーをサインインさせるために必要なもの

非ビジュアルコンポーネント 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 が存在するのは、デスクトップアプリケーションが秘密を保持できないからです。PKCE は秘密の代わりに、フロー開始前からクライアントがその値を知っていたと証明できる値を使います。

1. code verifier を生成する

高エントロピーのランダム文字列です。sgcWebSockets はプラットフォームの CSPRNG に 32 バイトを要求し、それを Base64URL エンコードして、RFC 7636 が求める 43 文字の verifier を生成します。

2. code challenge を導出する

verifier の SHA-256 を Base64URL エンコードしたものです。認可リクエストで流れるのはこの challenge なので、リダイレクトを盗聴する者が verifier を目にすることはありません。

3. ブラウザーを開く

コンポーネントは client_idredirect_uriscopestatecode_challengecode_challenge_method=S256 を含む認可 URL を組み立て、システムブラウザーを起動します。

4. ユーザーがサインインする

同意はブラウザー上、プロバイダー自身のドメインで、ユーザーの既存セッション、パスワードマネージャー、2 要素認証デバイスとともに行われます。アプリケーションがパスワードを目にすることはありません。

5. リダイレクトが返ってくる

プロバイダーは codestate を付けて redirect_uri へリダイレクトします。デスクトップではその URI はループバックアドレスで、コンポーネントはすでにそこで待ち受けています。

6. コードを交換する

コンポーネントはコードと元の code_verifier をトークンエンドポイントへ POST します。プロバイダーは SHA-256 を再計算して比較します。一致すれば、アクセストークンが得られます。

verifier が重要な理由

認可コードは、生存する数秒間はベアラー値です。リダイレクトを観測できるもの、たとえば同じカスタム URI スキームに登録された悪意あるアプリケーション、プロキシ、共有ログなどは、それを盗めます。PKCE がなければ、盗まれたコードだけでトークンを発行させられます。

PKCE があれば、トークンエンドポイントは、開始時に送られた challenge と SHA-256 ハッシュが一致する verifier を呼び出し側が提示しない限り、そのコードを拒否します。攻撃者が見たのはハッシュだけなので、盗んだコードには価値がありません。

ここに自分で書くものはありません。GrantTypeauth2CodePKCE に設定すれば、コンポーネントがステップ 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

ユーザーのサインインは、およそ 20 行で

コンポーネントを生成し、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 から来ると答えが自明でない部分であり、最初の試みで最も間違えやすい部分です。

公開 URL ではなく、ループバック

デスクトップアプリケーションにはリダイレクト先のドメインがありません。広く受け入れられている答えであり、このコンポーネントが実装しているのがループバックリダイレクトです。アプリケーションが 127.0.0.1 で小さな HTTP リスナーを起動し、そのアドレスをリダイレクト URI として登録し、コードが届いたらすぐにリスナーを停止します。

LocalServerOptions.IP の既定値は 127.0.0.1LocalServerOptions.Port の既定値は 8080 です。出荷するデスクトップアプリケーションでは代わりに Port := 0 を設定してください。オペレーティングシステムが空いているエフェメラルポートを割り当て、コンポーネントは送信するリダイレクト URI にそのポートを入れるため、同じマシン上でアプリケーションが 2 つ動いても衝突しません。

プロバイダーがホストとポートだけでなく、登録済みの正確なパスを要求する場合は、LocalServerOptions.RedirectURL に登録した値を設定します。その文字列が、計算された値を上書きします。パスを固定するということはポートも固定するということなので、ポートも登録し、Port := 0 の手は使わないでください。

リスナーはフローが待機している間だけ起動しています。auth2ClientCredentialsauth2ResourceOwnerPasswordauth2DeviceCode ではリダイレクトがまったく不要なため、起動されることはありません。

redirect.pas
// 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 クライアントへ渡せます。

プロパティと、自動的な Bearer ヘッダー

イベントのパラメーターは便利ですが、値の写しはそれだけではありません。AccessTokenTokenTypeCurrentExpiresInCurrentRefreshToken はコンポーネントが生きている限り同じ値を保持するため、コードの別の場所にあるハンドラーが、値を引き回さずに読み取れます。

RawParams は、トークンエンドポイントから返った手つかずの JSON ボディです。プロバイダーが標準の項目以外を返した場合、たとえば OpenID Connect の id_token などは、そこから取り出して解析します。コンポーネントが ID トークンをデコードすることはありません。

すべてのリクエストに自動的にトークンを載せるには、TsgcHTTP1ClientTsgcHTTP2ClientTsgcWebSocketClientAuthentication.Token.OAuth に OAuth2 コンポーネントを割り当てます。クライアントは、プロバイダーが返した token_type を使って Authorization: Bearer <token> を代わりに送信します。

use-token.pas
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;

リフレッシュして、ブラウザーが二度と開かないようにする

アクセストークンの寿命は数分です。リフレッシュトークンの寿命は数週間から数か月です。後者を保持することが、サインインをセッションに変えます。

2 つの異なる問題

アプリケーションの 1 回の実行中は何もすることがありません。トークンエンドポイントがリフレッシュトークンと expires_in の両方を返すと、コンポーネントはその寿命のおよそ半分で内部タイマーを仕掛け、発火時にアクセストークンが切れるよりずっと前に grant_type=refresh_token を送信します。OnAfterRefreshToken が新しい組とともに発火し、プロバイダーが拒否した場合は OnErrorRefreshToken が発火します。OnAfterAccessTokenHandled パラメーターはそのままにしてください。True にすると、以降は自分で引き受けるとコンポーネントに伝えることになり、コンポーネントはリフレッシュトークンを保存せず、そのタイマーも仕掛けません。

再起動をまたぐ場合はこちらの担当です。ユーザーのマシンのどこに秘密を書き込んでよいかを知っているのは開発者だけだからです。リフレッシュトークンを永続化し、次回の起動では Start をまるごと飛ばして、保存した値で Refresh を呼び出します。ブラウザーは開かず、メインフォームが描画される前にユーザーはサインイン済みになります。

リフレッシュトークンをローテーションするプロバイダーは、更新のたびに新しいものを渡してくるため、OnAfterRefreshToken のたびに保存済みの値を上書きしてください。保存したトークンがついに拒否されたら、Start に戻ってユーザーにもう一度サインインしてもらいます。

ユーザーを正しくサインアウトさせるには Revoke を、トークンがまだ有効かどうかをプロバイダーに問い合わせるには Introspect を使います。どちらも AuthorizationServerOptions に対応するエンドポイントを設定しておく必要があります。

refresh.pas
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 については、エンドポイントを埋めてユーザープロファイルを返す、すぐに使えるコンポーネントも用意されています。

Google と Microsoft は、呼び出し 1 回で

TsgcHTTP_OAuth2_Client_GoogleTsgcHTTP_OAuth2_Client_Microsoft は同じ基底コンポーネントを継承し、エンドポイントをあらかじめ埋めています。これらの Authenticate メソッドはブロッキングです。フロー全体を実行し、ブラウザーの往復を待ち、Authenticated と内容の入った UserProfile を持つデータオブジェクトを返します。

「このユーザーは誰か」に至る最短経路がこれです。TsgcOAuth2_Google_Data.UserProfile_NameGiven_NameFamily_NameIdLocalePicture を持ちます。TsgcOAuth2_Microsoft_Data.UserProfileDisplayNameGivenNameSurnameMailJobTitleOfficeLocation などを持ちます。Microsoft の Authenticate は最初にテナント ID を取ります。

それ以外のプロバイダーでは、基底の TsgcHTTP_OAuth2_Client を使い、2 つの URL をそのドキュメントから写します。その後にプロバイダー固有のものは何も残りません。

social-signin.pas
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;
プロバイダー コンポーネント グラントタイプ リダイレクト クライアントシークレット
Google 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) なし。ユーザーはスマートフォンで完了します 通常は不要です

メール送信のためのサインイン:OAuth 2.0 と XOAUTH2

Gmail と Microsoft 365 は SMTP、IMAP、POP でのパスワード受け付けをやめました。代わりに使うのは、いま取得したのと同じアクセストークンで、SASL XOAUTH2 メカニズムを通して提示します。

トークンはここから、SASL の手順は sgcIndy から

トークンの取得は上記のフローそのものです。auth2CodePKCE、ループバックリダイレクト、そして AuthorizationServerOptions.Scopehttps://mail.google.com/ のようなメール用スコープを指定します。メールの用途だからといって OAuth2 側が変わることはありません。

提示するのがもう半分です。sgcIndy はユニット IdSASLXOAUTH2TIdSASLXOAUTH2 を同梱しています。これを TIdSMTP.SASLMechanisms に追加し、AuthType := satSASL を設定して、その OnAuthenticate イベントからユーザー名とアクセストークンを渡します。同じメカニズムは TIdIMAP4TIdPOP3 でも使えます。

2 つのコンポーネントは頭の中で切り分けておいてください。OAuth2 クライアントはトークンの取得と更新の仕方を知っており、SASL メカニズムはトークンの提示の仕方を知っています。どちらも相手のことを知る必要はありません。

smtp-xoauth2.pas
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_ClientTsgcHTTP_JWT_Client は Standard 以上です。クライアントとサーバーは別のエディションティアに属するので、どちらを前提に計画を立てるにしても、先に確認しておく価値があります。

own-server.pas
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 回限りで寿命も短いものです。リダイレクトと交換の間にブレークポイントを置いてデバッグすると、コードは期限切れになります。推測するのではなく、プロバイダー自身の errorerror_description を返す OnErrorAccessToken から失敗の内容を読み取ってください。

リフレッシュトークンが返ってこない

プロバイダーは要求されたときにしか発行しません。Google は access_type=offline を、Microsoft は offline_access スコープを求めます。AuthorizationServerOptions.Scope にスコープを追加するか、OnBeforeAuthorizeCodeURL パラメーターを編集してクエリパラメーターを付け足してください。

Linux やモバイルで TLS が失敗する

トークン交換は HTTPS の POST なので、動作する TLS バックエンドが必要です。選択するのは HTTPClientOptions.TLSOptions.IOHandler です。iohOpenSSL、DLL を配布せずに済む Windows 上の iohSChannel、そして Enterprise エディションに含まれるネイティブの iohAndroidTLSiohAppleTLS のハンドラーがあります。

サインインページをアプリ内に表示したかった

OnBeforeAuthorizeCode を処理し、Handled := True を設定して、渡された URL に自前の TsgcWebView2TWebBrowser を遷移させます。ループバックリスナーは変わらずリダイレクトを捕捉します。なお、いくつかのプロバイダーは埋め込みブラウザー内での同意画面の表示を拒否するようになっています。

Delphi の OAuth2 と PKCE に関する質問

開発者が着手前に実際に検索する質問です。

TsgcHTTP_OAuth2_Client を配置し、OAuth2Options.GrantType := auth2CodePKCE を設定し、OAuth2Options.ClientIdAuthorizationServerOptions.AuthURLAuthorizationServerOptions.TokenURLAuthorizationServerOptions.Scope を入力し、LocalServerOptions.IP127.0.0.1LocalServerOptions.Port0 に設定してから、Start を呼び出します。コンポーネントが PKCE の値を生成し、ブラウザーを開き、ループバックリスナーでリダイレクトを捕捉し、コードを交換して、トークンとともに OnAfterAccessToken を発生させます。
その必要はありません。GrantTypeauth2CodePKCE のとき、TsgcHTTP_OAuth2_Client はプラットフォームの暗号論的乱数源から 32 バイトを取り出し、Base64URL エンコードして 43 文字の code verifier を作り、その verifier の SHA-256 ハッシュを Base64URL エンコードしたものを code challenge に設定し、code_challenge_methodS256 に固定します。verifier はコンポーネント内部に秘匿されたままトークン交換で再送されるため、リダイレクトに現れることはありません。別の用途でこの組を自分で作りたい場合、同じプリミティブが公開されています。ユニット sgcCrypto_RandomsgcRandomBytes、およびユニット sgcBase_HelpersGetHashSHA256EncodeBase64URL です。
ループバックアドレスです。TsgcHTTP_OAuth2_Client はフローの実行中だけ LocalServerOptions.IPLocalServerOptions.Port で小さな HTTP リスナーを起動し、送信するリダイレクト URI はその値から組み立てられます。既定値は 127.0.0.1 とポート 8080 です。出荷するアプリケーションでは Port0 に設定して、オペレーティングシステムに空いているエフェメラルポートを選ばせれば、2 つのインスタンスが 1 つのポートを奪い合うことはありません。プロバイダーが登録済みの正確な URI を要求する場合は、その文字列を LocalServerOptions.RedirectURL に入れてください。計算された値を上書きします。
プロバイダーによります。PKCE が存在するのは、出荷されたデスクトップアプリケーションが秘密を保持できないからこそなので、パブリッククライアントは通常シークレットをまったく送らず、OAuth2Options.ClientSecret を空のままにします。それでもデスクトップクライアント向けにシークレットを発行し、トークンリクエストでの提示を求めるプロバイダーもあります。その場合は設定してください。ただし保護手段ではなく識別子として扱ってください。配布される実行ファイルの中身は取り出せるからです。
リフレッシュトークンを永続化し、次回の起動では Start ではなくそれを使って Refresh を呼び出します。値は OnAfterAccessTokenRefresh_Token パラメーターから、あるいは後から CurrentRefreshToken プロパティから読み取れます。リフレッシュトークンをローテーションするプロバイダーは古いものを無効化するため、OnAfterRefreshToken のたびに保存済みの写しを上書きしてください。1 回の実行中はまったく作業が要りません。コンポーネントが expires_in の値からタイマーを仕掛け、自動的にアクセストークンを更新します。
アクセストークンはメモリ内だけに保持してください。数分で期限切れになり、いつでも再発行できます。リフレッシュトークンは暗号化して現在のユーザーに限定して永続化します。たとえば Windows では DPAPI、macOS では Keychain、Linux では Secret Service を使い、実行ファイルの隣ではなくユーザーごとのアプリケーションデータに置きます。sgcWebSockets が独自のトークン保管庫を同梱していないのは意図的です。トークンを渡し、保管の判断は開発者に委ねています。出荷前に HTTPClientOptions.LogOptions を無効にすることも忘れないでください。そのログにはトークンが含まれます。
このページのフローでアクセストークンを取得します。その際、https://mail.google.com/ のようなプロバイダーのメール用スコープを要求し、取得したトークンを SASL XOAUTH2 で提示します。sgcIndy はユニット IdSASLXOAUTH2TIdSASLXOAUTH2 を同梱しています。これを TIdSMTP.SASLMechanisms に追加し、AuthType := satSASL を設定して、その OnAuthenticate イベントからユーザー名とアクセストークンを返します。同じメカニズムで TIdIMAP4TIdPOP3 も認証できます。
OAuth2 クライアントと JWT クライアントは Standard エディションとそれ以上のすべてのエディションにコンパイルされているため、Standard、Professional、Enterprise、All-Access のいずれにも含まれます。OAuth2 サーバー、JWT サーバー、WebAuthn サーバーは Enterprise コンポーネントで、Standard や Professional のビルドには含まれません。この 2 つのクライアントコンポーネントは、必要なランタイムを同梱した単体パッケージ sgcAuth としても単独で販売されています。
自分がトークンを発行する側である場合だけです。Google、Microsoft、Auth0、Okta、Keycloak、AWS Cognito、あるいは企業の ID プロバイダーにユーザーをサインインさせるだけなら、必要なのはクライアントコンポーネントだけです。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) ライブラリのすべてのコンポーネントを網羅した総合マニュアル。

このフローが実装している仕様

プロバイダーのサポート窓口と議論を決着させる必要があるときのための一次資料です。

このページの背後にあるコンポーネントと記事

コンポーネントのページには機能の全一覧があり、記事はこのページが触れるだけにとどめたケースを扱っています。

OAuth2 クライアントコンポーネント

TsgcHTTP_OAuth2_Client のプロパティ、メソッド、イベントの全体像です。デバイスコードと DPoP も含みます。

続きを読む →

OAuth2 サーバーコンポーネント

Enterprise の認可サーバーです。自前の authorize、token、revoke、introspect の各エンドポイントを提供します。

続きを読む →

sgcAuth

OAuth2 と JWT のクライアントコンポーネントを、必要なランタイムを同梱した単体パッケージにしたものです。

続きを読む →

JWT クライアントコンポーネント

JSON Web Token に署名して付与します。単独でも、HTTP クライアントや WebSocket クライアントの Bearer トークンの供給元としても使えます。

続きを読む →

Delphi の PKCE OAuth2

クライアントとサーバーの両コンポーネントで PKCE 対応を導入した、最初のリリース記事です。

記事を読む →

sgcIndy の XOAuth2

SASL XOAUTH2 メカニズムを通じて、OAuth 2.0 のアクセストークンで SMTP、IMAP、POP からメールを送ります。

記事を読む →

OAuth2 クライアントクレデンシャル

ユーザーを介さない方式です。バックグラウンドサービスやマシン間の API アクセス向けです。

記事を読む →

Delphi での OAuth2 DPoP

RFC 9449 に基づく所有証明を要求するプロバイダー向けに、アクセストークンを鍵ペアに結び付けます。

記事を読む →

AWS Cognito と OAuth2

実在の ID プロバイダーに対する設定例を、エンドポイントごとにたどります。

記事を読む →

OAuth2 サーバー:アプリを登録する

自前の認可サーバーで、クライアントアプリケーション、リダイレクト URI、許可するグラントタイプを登録します。

記事を読む →

外部プロバイダーによる認可

自前のサーバーが、Google や Microsoft などの外部 ID プロバイダーにサインインを委譲します。

記事を読む →

WebAuthn とパスキー

トークンの受け渡し自体をなくしたい場合の、パスワードレスな選択肢です。

続きを読む →

このページは Delphi ユースケースの 1 つで、いずれも 1 つのタスクを最初から最後まで扱います。現時点でのほかのページは、Delphi から LLM を呼び出す2 つのアプリケーションを WebRTC でピアツーピア接続するです。

今日、最初のユーザーをサインインさせましょう

体験版をダウンロードし、OAuth2 デモを開いて、自分のプロバイダーに向けて、ブラウザーの往復が完了する様子を確認してください。