OAuth2와 PKCE로 Delphi 애플리케이션에 사용자 로그인 붙이기

컴포넌트 하나, 부여 방식 하나, 브라우저 전달 한 번이면 돼요. 이 페이지는 빈 폼에서 시작해, 살아 있는 액세스 토큰을 가진 로그인된 사용자까지 데려가요. PKCE를 적용한 Authorization Code 플로우(RFC 7636)를 쓰는데, 이제 모든 공급자가 네이티브 데스크톱 애플리케이션에 요구하는 방식이에요.

TsgcHTTP_OAuth2_Client
코드 verifier와 challenge를 대신 생성해 줘요
Delphi 7부터 13까지, C++ Builder, VCL과 FireMonkey

사용자 로그인에 필요한 것

비주얼이 아닌 컴포넌트 하나가 공급자와 이야기해요. 웹 서버도, 내장 브라우저도, REST 프레임워크도 필요 없어요.

컴포넌트

sgcHTTP 유닛에 선언된 TsgcHTTP_OAuth2_Client예요. 모든 데모가 그러듯 코드에서 생성해요.

부여 방식

OAuth2Options.GrantType := auth2CodePKCE. 이 대입 한 줄로 PKCE가 켜져요.

에디션

Standard, Professional, Enterprise, All-Access에 들어 있어요. 클라이언트는 Enterprise 기능이 아니고, 서버가 Enterprise 기능이에요.

플랫폼

Windows, macOS, Linux, iOS, Android를 지원해요. 컴포넌트는 각 플랫폼이 제공하는 브라우저를 열어요.

PKCE 플로우가 실제로 하는 일

PKCE가 존재하는 이유는 데스크톱 애플리케이션이 비밀을 지킬 수 없기 때문이에요. 비밀 대신, 플로우가 시작되기 전부터 알고 있었음을 클라이언트가 증명하는 값을 쓰죠.

1. 코드 verifier 생성

엔트로피가 높은 무작위 문자열이에요. sgcWebSockets는 플랫폼 CSPRNG에서 32바이트를 받아 Base64URL로 인코딩해요. 그러면 RFC 7636이 요구하는 43자 verifier가 나와요.

2. 코드 challenge 유도

verifier의 SHA-256 해시를 Base64URL로 인코딩한 값이에요. 인증 요청에 실려 가는 건 challenge이므로, 리디렉션을 엿듣는 쪽은 verifier를 결코 볼 수 없어요.

3. 브라우저 열기

컴포넌트가 client_id, redirect_uri, scope, state, code_challenge, code_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단계를 대신 수행해요. 아래에 나오는 것은 그걸 실행하는 코드와, 여러분이 직접 정해야 하는 두 가지 결정이에요. 바로 리디렉션 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를 고르고, 공급자의 두 엔드포인트를 가리키게 하고, 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");
}

데스크톱 애플리케이션에서 리디렉션이 향하는 곳

웹 OAuth2에서 넘어오면 답이 뻔하지 않은 부분이고, 첫 시도에서 대부분 틀리는 부분이에요.

공개 URL이 아니라 루프백

데스크톱 애플리케이션에는 리디렉션할 도메인이 없어요. 널리 받아들여진 답이자 이 컴포넌트가 구현한 방식은 루프백 리디렉션이에요. 애플리케이션이 127.0.0.1에 아주 작은 HTTP 리스너를 띄우고, 그 주소를 리디렉션 URI로 등록한 다음, 코드가 도착하자마자 리스너를 닫아요.

LocalServerOptions.IP의 기본값은 127.0.0.1이고 LocalServerOptions.Port의 기본값은 8080이에요. 배포하는 데스크톱 애플리케이션이라면 대신 Port := 0으로 두세요. 운영체제가 비어 있는 임시 포트를 내주고, 컴포넌트가 보내는 리디렉션 URI에 그 포트를 넣기 때문에, 같은 컴퓨터에서 애플리케이션 두 개가 떠 있어도 충돌하지 않아요.

공급자가 호스트와 포트만이 아니라 정확히 등록된 경로를 요구한다면, LocalServerOptions.RedirectURL에 등록한 값을 그대로 넣으세요. 그 문자열이 계산된 값을 대체해요. 경로가 고정이면 포트도 고정이라는 뜻이므로 포트도 함께 등록하고 Port := 0 방식은 쓰지 마세요.

리스너는 플로우가 대기하는 동안에만 떠 있어요. 리디렉션이 아예 필요 없는 auth2ClientCredentials, auth2ResourceOwnerPassword, auth2DeviceCode에서는 시작되지 않아요.

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 헤더

이벤트 매개변수가 편하긴 하지만 사본이 그것뿐인 건 아니에요. AccessToken, TokenType, CurrentExpiresIn, CurrentRefreshToken이 컴포넌트가 살아 있는 동안 같은 값을 유지해요. 그래서 코드 다른 곳의 핸들러가 값을 일일이 넘겨받지 않고도 읽을 수 있어요.

RawParams는 토큰 엔드포인트에서 온 JSON 본문을 손대지 않은 그대로예요. 공급자가 표준 집합 밖의 값을 돌려줄 때, 예를 들어 OpenID Connect의 id_token 같은 값은 거기서 파싱하세요. 컴포넌트가 ID 토큰을 대신 디코딩해 주지는 않아요.

모든 요청이 토큰을 자동으로 싣게 하려면, OAuth2 컴포넌트를 TsgcHTTP1ClientTsgcHTTP2Client, TsgcWebSocketClientAuthentication.Token.OAuth에 할당하세요. 그러면 클라이언트가 공급자가 돌려준 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;

갱신하기, 브라우저가 두 번 열리지 않도록

액세스 토큰은 몇 분을 살아요. 리프레시 토큰은 몇 주에서 몇 달을 살고요. 뒤쪽을 보관하는 것이 로그인을 세션으로 바꿔줘요.

서로 다른 두 가지 문제

애플리케이션이 한 번 실행되는 동안에는 할 일이 없어요. 토큰 엔드포인트가 리프레시 토큰과 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 공급자가 요구하는 설정은 몇 가지로 같아요. 엔드포인트 두 개, 클라이언트 id, 스코프, 그리고 등록된 리디렉션이에요. Google과 Microsoft에는 엔드포인트를 미리 채워 두고 사용자 프로필까지 돌려주는 전용 컴포넌트가 따로 있어요.

Google과 Microsoft, 호출 한 번으로

TsgcHTTP_OAuth2_Client_GoogleTsgcHTTP_OAuth2_Client_Microsoft는 같은 기반 컴포넌트를 상속하고 엔드포인트를 미리 채워 둬요. 두 컴포넌트의 Authenticate 메서드는 블로킹 방식이에요. 플로우 전체를 실행하고, 브라우저 왕복을 기다린 뒤, Authenticated와 채워진 UserProfile을 담은 데이터 객체를 반환해요.

"이 사용자가 누구인가"에 이르는 가장 짧은 길이에요. TsgcOAuth2_Google_Data.UserProfile_Name, Given_Name, Family_Name, Id, Locale, Picture를 담아요. TsgcOAuth2_Microsoft_Data.UserProfileDisplayName, GivenName, Surname, Mail, JobTitle, OfficeLocation 등을 담고요. Microsoft의 Authenticate는 테넌트 id를 첫 인수로 받아요.

그 밖의 모든 공급자에는 기반 컴포넌트인 TsgcHTTP_OAuth2_Client를 쓰고 문서에서 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 없어요. 브라우저가 관여하지 않아요 필요하고, 배포되는 것이 없으니 안전해요
키오스크, TV, 헤드리스 장비 TsgcHTTP_OAuth2_Client auth2DeviceCode (RFC 8628) 없어요. 사용자가 휴대폰에서 마무리해요 대개 필요 없어요

메일을 보내기 위한 로그인: OAuth 2.0과 XOAUTH2

Gmail과 Microsoft 365는 SMTP, IMAP, POP에서 비밀번호를 더 이상 받지 않아요. 대신 방금 얻은 그 액세스 토큰을 SASL XOAUTH2 메커니즘으로 제시하면 돼요.

토큰은 여기서, SASL 단계는 sgcIndy에서

토큰을 얻는 과정은 위의 플로우 그대로예요. auth2CodePKCE, 루프백 리디렉션, 그리고 AuthorizationServerOptions.Scope에 넣는 https://mail.google.com/ 같은 메일 스코프면 돼요. 메일이라고 해서 OAuth2 쪽이 달라지는 건 없어요.

나머지 절반은 그 토큰을 제시하는 일이에요. sgcIndy에는 IdSASLXOAUTH2 유닛의 TIdSASLXOAUTH2가 들어 있어요. 이걸 TIdSMTP.SASLMechanisms에 추가하고, AuthType := satSASL로 설정한 다음, OnAuthenticate 이벤트에서 사용자 이름과 액세스 토큰을 넘겨주세요. TIdIMAP4TIdPOP3에서도 같은 메커니즘이 통해요.

두 컴포넌트를 머릿속에서 분리해 두세요. 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가 신뢰할 액세스 토큰을 발급하고 무효화하는 경우죠. 그게 TsgcWebSocketHTTPServer에 붙이는 TsgcHTTP_OAuth2_Server이고, 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를 처음 시도했다가 실패하는 경우는 거의 다 이 여섯 가지 중 하나예요.

redirect_uri_mismatch

컴포넌트가 보내는 URI는 등록한 값과 글자 하나까지, 끝의 슬래시와 포트까지 똑같아야 해요. 고정 URI를 등록했다면 계산된 값에 기대지 말고 LocalServerOptions.RedirectURL에 그 문자열을 그대로 넣으세요. 공급자가 루프백 포트를 아무거나 허용한다면 Port := 0을 쓰고 호스트만 등록하면 돼요.

브라우저는 열리는데 아무것도 돌아오지 않아요

무언가가 포트를 잡고 있거나, 방화벽 규칙이 루프백 리스너를 막고 있어요. Port := 0으로 설정하고, 이전 플로우가 대기 상태로 남지 않고 Stop으로 끝났는지 확인해 보세요.

토큰 교환에서 나오는 invalid_grant

인증 코드는 한 번만 쓸 수 있고 수명이 짧아요. 리디렉션과 교환 사이에 중단점을 걸고 디버깅하면 코드가 만료돼요. 추측하지 말고 OnErrorAccessToken에서 실패 원인을 읽으세요. 공급자가 보낸 errorerror_description을 그대로 알려줘요.

리프레시 토큰이 돌아오지 않아요

공급자는 요청할 때만 발급해요. Google은 access_type=offline을 원하고, Microsoft는 offline_access 스코프를 원해요. 스코프는 AuthorizationServerOptions.Scope에 추가하고, 쿼리 매개변수는 OnBeforeAuthorizeCode에서 URL 매개변수를 고쳐 덧붙이면 돼요.

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.AuthURL, AuthorizationServerOptions.TokenURL, AuthorizationServerOptions.Scope를 채우고, LocalServerOptions.IP127.0.0.1로, LocalServerOptions.Port0으로 설정한 다음 Start를 호출하세요. 컴포넌트가 PKCE 값을 생성하고, 브라우저를 열고, 루프백 리스너에서 리디렉션을 받고, 코드를 교환한 뒤 토큰과 함께 OnAfterAccessToken을 발생시켜요.
직접 하지 않아도 돼요. GrantTypeauth2CodePKCE이면 TsgcHTTP_OAuth2_Client가 플랫폼 암호학적 난수원에서 32바이트를 뽑아 Base64URL로 인코딩해 43자 코드 verifier를 만들고, 그 verifier의 SHA-256 해시를 Base64URL로 인코딩한 값을 코드 challenge로 설정하고, code_challenge_methodS256으로 고정해요. verifier는 컴포넌트 안에 비공개로 보관됐다가 토큰 교환 때 다시 쓰이므로 리디렉션에는 결코 나타나지 않아요. 다른 목적으로 이 쌍을 직접 만들고 싶다면 같은 기본 함수들이 공개돼 있어요. sgcCrypto_Random 유닛의 sgcRandomBytes, 그리고 sgcBase_Helpers 유닛의 GetHashSHA256EncodeBase64URL이에요.
루프백 주소를 쓰세요. TsgcHTTP_OAuth2_Client는 플로우가 실행되는 동안에만 LocalServerOptions.IPLocalServerOptions.Port에 작은 HTTP 리스너를 띄우고, 보내는 리디렉션 URI도 그 값으로 만들어요. 기본값은 127.0.0.1과 포트 8080이에요. 배포하는 애플리케이션이라면 Port0으로 두어 운영체제가 비어 있는 임시 포트를 고르게 하세요. 그러면 인스턴스 두 개가 포트를 두고 다투지 않아요. 공급자가 정확히 등록된 URI를 요구한다면 그 문자열을 LocalServerOptions.RedirectURL에 넣으면 계산된 값을 대체해요.
공급자에 따라 달라요. PKCE가 존재하는 이유가 바로 배포된 데스크톱 애플리케이션이 비밀을 지킬 수 없기 때문이라, 공개 클라이언트는 보통 시크릿을 전혀 보내지 않고 OAuth2Options.ClientSecret을 비워 둬요. 일부 공급자는 여전히 데스크톱 클라이언트용 시크릿을 발급하고 토큰 요청에 담기를 기대해요. 그럴 때는 설정하되, 보호 수단이 아니라 식별자로 여기세요. 배포되는 실행 파일 안에 있는 것은 무엇이든 꺼낼 수 있으니까요.
리프레시 토큰을 저장해 두었다가, 다음 실행 때 Start 대신 그 값으로 Refresh를 호출하세요. 토큰은 OnAfterAccessTokenRefresh_Token 매개변수에서, 또는 나중에 CurrentRefreshToken 속성에서 읽을 수 있어요. OnAfterRefreshToken이 발생할 때마다 저장된 사본을 덮어쓰세요. 리프레시 토큰을 회전시키는 공급자는 이전 토큰을 무효화하기 때문이에요. 한 번의 실행 안에서는 할 일이 전혀 없어요. 컴포넌트가 expires_in 값으로 타이머를 걸고 알아서 액세스 토큰을 갱신해요.
액세스 토큰은 메모리에만 두세요. 몇 분이면 만료되고 언제든 다시 발급받을 수 있어요. 리프레시 토큰은 암호화해서 현재 사용자에게 한정해 저장하세요. 예를 들어 Windows에서는 DPAPI, macOS에서는 Keychain, Linux에서는 Secret Service를 쓰고, 실행 파일 옆이 아니라 사용자별 애플리케이션 데이터 폴더에 두세요. sgcWebSockets가 자체 토큰 저장소를 제공하지 않는 건 의도적이에요. 토큰을 건네주고 저장에 대한 결정은 여러분에게 맡기는 거죠. 배포 전에 HTTPClientOptions.LogOptions를 끄는 것도 잊지 마세요. 그 로그에는 토큰이 들어 있어요.
이 페이지의 플로우로 액세스 토큰을 얻되, https://mail.google.com/ 같은 공급자의 메일 스코프를 요청하세요. 그런 다음 SASL XOAUTH2로 제시하면 돼요. sgcIndy에는 IdSASLXOAUTH2 유닛의 TIdSASLXOAUTH2가 들어 있어요. 이걸 TIdSMTP.SASLMechanisms에 추가하고, AuthType := satSASL로 설정한 다음, OnAuthenticate 이벤트에서 사용자 이름과 액세스 토큰을 반환하세요. TIdIMAP4TIdPOP3도 같은 메커니즘으로 인증해요.
OAuth2 클라이언트와 JWT 클라이언트는 Standard 에디션과 그 위의 모든 에디션에 컴파일돼 들어가요. 그래서 Standard, Professional, Enterprise, All-Access 모두 포함하고 있어요. OAuth2 서버와 JWT 서버, WebAuthn 서버는 Enterprise 컴포넌트라 Standard나 Professional 빌드에는 없어요. 두 클라이언트 컴포넌트는 필요한 런타임과 함께 묶인 단독 sgcAuth 패키지로도 따로 판매해요.
토큰을 발급하는 쪽이 여러분일 때만 그래요. Google, Microsoft, Auth0, Okta, Keycloak, AWS Cognito 또는 사내 ID 공급자에 사용자를 로그인시키는 데는 클라이언트 컴포넌트 말고는 아무것도 필요 없어요. TsgcHTTP_OAuth2_Server가 필요한 건 여러분의 애플리케이션이 클라이언트 id를 등록하고, 로그인 페이지를 제공하고, API가 신뢰하는 토큰을 발급할 때예요. 이 컴포넌트는 OAuth2Options.PKCE를 통해 기본적으로 PKCE를 검증하고, Apps.AddApp으로 애플리케이션을 등록하며, Authentication.OAuth.OAuth2를 통해 TsgcWebSocketHTTPServer에 붙어요.
네. 완성된 인증 URL을 var 매개변수로 받는 OnBeforeAuthorizeCode를 처리하고, 컴포넌트가 시스템 브라우저를 실행하지 않도록 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 클라이언트 컴포넌트의 모든 속성과 메서드, 이벤트를 Authorization Code + PKCE 항목과 함께 다뤄요.
온라인 도움말, PKCE를 적용한 Authorization Code 부여 방식 항목이에요. 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) 라이브러리의 모든 컴포넌트를 다루는 종합 설명서예요.

이 플로우가 구현하는 사양

공급자 지원 데스크와 이견을 정리해야 할 때 볼 1차 자료예요.

이 페이지 뒤에 있는 컴포넌트와 글

컴포넌트 페이지에는 전체 기능 목록이 있고, 글에서는 이 페이지가 스치듯 지나간 경우들을 다뤄요.

OAuth2 클라이언트 컴포넌트

Device Code와 DPoP를 포함한 TsgcHTTP_OAuth2_Client의 전체 속성과 메서드, 이벤트를 다뤄요.

자세히 알아보기 →

OAuth2 서버 컴포넌트

Enterprise 인증 서버예요. 직접 운영하는 authorize, token, revoke, introspect 엔드포인트를 다뤄요.

자세히 알아보기 →

sgcAuth

OAuth2와 JWT 클라이언트 컴포넌트를 단독 패키지로 묶은 것이에요. 필요한 런타임도 함께 들어 있어요.

자세히 알아보기 →

JWT 클라이언트 컴포넌트

JSON Web Token에 서명하고 붙여요. 단독으로도 쓰고, HTTP와 WebSocket 클라이언트의 Bearer 토큰 공급원으로도 써요.

자세히 알아보기 →

Delphi PKCE OAuth2

클라이언트와 서버 컴포넌트 양쪽의 PKCE 지원을 소개한 최초의 릴리스 글이에요.

글 읽기 →

sgcIndy XOAuth2

SASL XOAUTH2 메커니즘으로 SMTP, IMAP, POP에서 OAuth 2.0 액세스 토큰을 써서 메일을 보내요.

글 읽기 →

OAuth2 Client Credentials

사용자가 없는 변형이에요. 백그라운드 서비스와 기계 대 기계 API 접근에 써요.

글 읽기 →

Delphi의 OAuth2 DPoP

액세스 토큰을 키 쌍에 묶어요. RFC 9449에 따라 소유 증명을 요구하는 공급자를 위한 방식이에요.

글 읽기 →

AWS Cognito와 OAuth2

실제 ID 공급자를 대상으로 엔드포인트 하나하나를 설정해 본 사례예요.

글 읽기 →

OAuth2 서버: 앱 등록하기

직접 운영하는 인증 서버에 클라이언트 애플리케이션과 리디렉션 URI, 허용할 부여 방식을 등록해요.

글 읽기 →

외부 공급자를 통한 인증

직접 운영하는 서버가 로그인을 Google이나 Microsoft, 그 밖의 외부 ID 공급자에 위임하게 해요.

글 읽기 →

WebAuthn과 패스키

비밀번호 없는 대안이에요. 토큰을 주고받는 과정 자체를 없애고 싶을 때 쓰세요.

자세히 알아보기 →

이 페이지는 Delphi 사용 사례 중 하나예요. 각 페이지가 하나의 작업을 처음부터 끝까지 다뤄요. 지금까지 나온 다른 페이지는 Delphi에서 LLM 호출하기WebRTC로 두 애플리케이션을 피어 투 피어로 연결하기예요.

최고의 가성비: All-Access모든 eSeGeCe 제품과 프리미엄 지원이 포함되어 연 €1,059부터 이용할 수 있어요.
All-Access 가격 보기

오늘 첫 사용자를 로그인시켜 보세요

무료 체험판을 내려받고, OAuth2 데모를 열고, 여러분의 공급자를 가리키게 한 뒤, 브라우저 왕복이 완료되는 모습을 지켜보세요.