OAuth2와 PKCE로 Delphi 애플리케이션에 사용자 로그인 붙이기
컴포넌트 하나, 부여 방식 하나, 브라우저 전달 한 번이면 돼요. 이 페이지는 빈 폼에서 시작해, 살아 있는 액세스 토큰을 가진 로그인된 사용자까지 데려가요. PKCE를 적용한 Authorization Code 플로우(RFC 7636)를 쓰는데, 이제 모든 공급자가 네이티브 데스크톱 애플리케이션에 요구하는 방식이에요.
컴포넌트 하나, 부여 방식 하나, 브라우저 전달 한 번이면 돼요. 이 페이지는 빈 폼에서 시작해, 살아 있는 액세스 토큰을 가진 로그인된 사용자까지 데려가요. PKCE를 적용한 Authorization Code 플로우(RFC 7636)를 쓰는데, 이제 모든 공급자가 네이티브 데스크톱 애플리케이션에 요구하는 방식이에요.
비주얼이 아닌 컴포넌트 하나가 공급자와 이야기해요. 웹 서버도, 내장 브라우저도, REST 프레임워크도 필요 없어요.
sgcHTTP 유닛에 선언된 TsgcHTTP_OAuth2_Client예요. 모든 데모가 그러듯 코드에서 생성해요.
OAuth2Options.GrantType := auth2CodePKCE. 이 대입 한 줄로 PKCE가 켜져요.
Standard, Professional, Enterprise, All-Access에 들어 있어요. 클라이언트는 Enterprise 기능이 아니고, 서버가 Enterprise 기능이에요.
Windows, macOS, Linux, iOS, Android를 지원해요. 컴포넌트는 각 플랫폼이 제공하는 브라우저를 열어요.
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단계를 대신 수행해요. 아래에 나오는 것은 그걸 실행하는 코드와, 여러분이 직접 정해야 하는 두 가지 결정이에요. 바로 리디렉션 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에서 넘어오면 답이 뻔하지 않은 부분이고, 첫 시도에서 대부분 틀리는 부분이에요.
데스크톱 애플리케이션에는 리디렉션할 도메인이 없어요. 널리 받아들여진 답이자 이 컴포넌트가 구현한 방식은 루프백 리디렉션이에요. 애플리케이션이 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에서는 시작되지 않아요.
// 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 토큰을 대신 디코딩해 주지는 않아요.
모든 요청이 토큰을 자동으로 싣게 하려면, OAuth2 컴포넌트를 TsgcHTTP1Client나 TsgcHTTP2Client, TsgcWebSocketClient의 Authentication.Token.OAuth에 할당하세요. 그러면 클라이언트가 공급자가 돌려준 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;
액세스 토큰은 몇 분을 살아요. 리프레시 토큰은 몇 주에서 몇 달을 살고요. 뒤쪽을 보관하는 것이 로그인을 세션으로 바꿔줘요.
애플리케이션이 한 번 실행되는 동안에는 할 일이 없어요. 토큰 엔드포인트가 리프레시 토큰과 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 공급자가 요구하는 설정은 몇 가지로 같아요. 엔드포인트 두 개, 클라이언트 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를 쓰고 문서에서 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 |
없어요. 브라우저가 관여하지 않아요 | 필요하고, 배포되는 것이 없으니 안전해요 |
| 키오스크, TV, 헤드리스 장비 | 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에서도 같은 메커니즘이 통해요.
두 컴포넌트를 머릿속에서 분리해 두세요. 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가 신뢰할 액세스 토큰을 발급하고 무효화하는 경우죠. 그게 TsgcWebSocketHTTPServer에 붙이는 TsgcHTTP_OAuth2_Server이고, 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를 처음 시도했다가 실패하는 경우는 거의 다 이 여섯 가지 중 하나예요.
redirect_uri_mismatch컴포넌트가 보내는 URI는 등록한 값과 글자 하나까지, 끝의 슬래시와 포트까지 똑같아야 해요. 고정 URI를 등록했다면 계산된 값에 기대지 말고 LocalServerOptions.RedirectURL에 그 문자열을 그대로 넣으세요. 공급자가 루프백 포트를 아무거나 허용한다면 Port := 0을 쓰고 호스트만 등록하면 돼요.
무언가가 포트를 잡고 있거나, 방화벽 규칙이 루프백 리스너를 막고 있어요. Port := 0으로 설정하고, 이전 플로우가 대기 상태로 남지 않고 Stop으로 끝났는지 확인해 보세요.
invalid_grant인증 코드는 한 번만 쓸 수 있고 수명이 짧아요. 리디렉션과 교환 사이에 중단점을 걸고 디버깅하면 코드가 만료돼요. 추측하지 말고 OnErrorAccessToken에서 실패 원인을 읽으세요. 공급자가 보낸 error와 error_description을 그대로 알려줘요.
공급자는 요청할 때만 발급해요. 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자 코드 verifier를 만들고, 그 verifier의 SHA-256 해시를 Base64URL로 인코딩한 값을 코드 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으로 두어 운영체제가 비어 있는 임시 포트를 고르게 하세요. 그러면 인스턴스 두 개가 포트를 두고 다투지 않아요. 공급자가 정확히 등록된 URI를 요구한다면 그 문자열을 LocalServerOptions.RedirectURL에 넣으면 계산된 값을 대체해요.OAuth2Options.ClientSecret을 비워 둬요. 일부 공급자는 여전히 데스크톱 클라이언트용 시크릿을 발급하고 토큰 요청에 담기를 기대해요. 그럴 때는 설정하되, 보호 수단이 아니라 식별자로 여기세요. 배포되는 실행 파일 안에 있는 것은 무엇이든 꺼낼 수 있으니까요.Start 대신 그 값으로 Refresh를 호출하세요. 토큰은 OnAfterAccessToken의 Refresh_Token 매개변수에서, 또는 나중에 CurrentRefreshToken 속성에서 읽을 수 있어요. OnAfterRefreshToken이 발생할 때마다 저장된 사본을 덮어쓰세요. 리프레시 토큰을 회전시키는 공급자는 이전 토큰을 무효화하기 때문이에요. 한 번의 실행 안에서는 할 일이 전혀 없어요. 컴포넌트가 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에 붙어요.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) 라이브러리의 모든 컴포넌트를 다루는 종합 설명서예요. | 열기 |
컴포넌트 페이지에는 전체 기능 목록이 있고, 글에서는 이 페이지가 스치듯 지나간 경우들을 다뤄요.
Enterprise 인증 서버예요. 직접 운영하는 authorize, token, revoke, introspect 엔드포인트를 다뤄요.
자세히 알아보기 →JSON Web Token에 서명하고 붙여요. 단독으로도 쓰고, HTTP와 WebSocket 클라이언트의 Bearer 토큰 공급원으로도 써요.
자세히 알아보기 →이 페이지는 Delphi 사용 사례 중 하나예요. 각 페이지가 하나의 작업을 처음부터 끝까지 다뤄요. 지금까지 나온 다른 페이지는 Delphi에서 LLM 호출하기와 WebRTC로 두 애플리케이션을 피어 투 피어로 연결하기예요.