OAuth2 desde BC:
flujo client credentials
y renovación de
tokens
Integrando Business Central con una API externa protegida con OAuth2, la primera implementación era simple: pedir un token nuevo en cada llamada. Funcionaba, pero añadía una petición extra al identity provider en cada request y empezaba a notarse en la latencia. La solución obvia era cachear el token — pero entonces apareció el problema real: ¿cuándo renovarlo?
Si se reutiliza uno expirado la llamada falla. Si se pide uno nuevo antes de tiempo se pierde el beneficio de la caché. Tuve que implementar renovación anticipada — refrescar el token un margen antes de que expire, no cuando ya ha fallado. Aquí está cómo lo resolví.
Un codeunit con SingleInstance = true existe como una
única instancia durante toda la sesión de usuario en BC. Las
variables globales del codeunit se mantienen entre llamadas, lo
que lo convierte en el lugar ideal para cachear el token en
memoria — sin necesidad de escribirlo en ninguna tabla.
El Token Manager es un codeunit
SingleInstance que expone un único procedimiento
público: GetValidToken. Internamente comprueba si el
token cacheado sigue siendo válido — con un buffer de 5 minutos
antes de la expiración real — y solo pide uno nuevo si es
necesario.
codeunit 50200 "OAuth2 Token Manager"
{
SingleInstance = true;
var
CachedToken : Text;
TokenExpiry : DateTime;
BufferSeconds : Integer; // renovar N segundos antes de expirar
procedure GetValidToken(
TokenEndpoint : Text;
ClientId : Text;
ClientSecret : Text;
Scope : Text
) : Text
begin
BufferSeconds := 300; // renovar 5 min antes de que expire
if IsTokenValid() then
exit(CachedToken);
RequestNewToken(TokenEndpoint, ClientId, ClientSecret, Scope);
exit(CachedToken);
end;
local procedure IsTokenValid() : Boolean
begin
if CachedToken = '' then exit(false);
if TokenExpiry = 0DT then exit(false);
// El token es válido si no hemos llegado a (ExpiresAt - buffer)
exit(CurrentDateTime() < (TokenExpiry - CreateDuration(0, 0, BufferSeconds, 0)));
end;
La petición al identity provider sigue el estándar OAuth2: un POST
con
Content-Type: application/x-www-form-urlencoded y el
body con grant_type, client_id,
client_secret y scope. La respuesta es
un JSON con access_token y
expires_in (segundos hasta expiración).
El expires_in que devuelve el provider es el tiempo
real hasta que expira. Al calculcular el
TokenExpiry se almacena tal cual — el buffer se
aplica en IsTokenValid, no al guardar, para que el
valor de expiración sea siempre el real.
local procedure RequestNewToken(
TokenEndpoint : Text;
ClientId : Text;
ClientSecret : Text;
Scope : Text
)
var
Client : HttpClient;
Request : HttpRequestMessage;
Response : HttpResponseMessage;
Content : HttpContent;
ContentHeaders : HttpHeaders;
Body : Text;
ResponseText : Text;
JsonObj : JsonObject;
TokenTok : JsonToken;
ExpiresInTok : JsonToken;
begin
// Body en formato application/x-www-form-urlencoded
Body := StrSubstNo(
'grant_type=client_credentials&client_id=%1&client_secret=%2&scope=%3',
ClientId, ClientSecret, Scope
);
Content.WriteFrom(Body);
Content.GetHeaders(ContentHeaders);
ContentHeaders.Remove('Content-Type');
ContentHeaders.Add('Content-Type', 'application/x-www-form-urlencoded');
Request.SetRequestUri(TokenEndpoint);
Request.Method('POST');
Request.Content(Content);
if not Client.Send(Request, Response) then
Error('No se pudo conectar con el endpoint de autenticación.');
if not Response.IsSuccessStatusCode() then
Error('Error obteniendo token OAuth2: HTTP %1', Response.HttpStatusCode());
Response.Content().ReadAs(ResponseText);
JsonObj.ReadFrom(ResponseText);
JsonObj.Get('access_token', TokenTok);
JsonObj.Get('expires_in', ExpiresInTok);
// Guardar token y expiración real (el buffer se aplica en IsTokenValid)
CachedToken := TokenTok.AsValue().AsText();
TokenExpiry := CurrentDateTime() + CreateDuration(0, 0, ExpiresInTok.AsValue().AsInteger(), 0);
end;
}
Cualquier codeunit de integración obtiene el token con una sola
línea y lo añade a la cabecera Authorization. Si el
token está en caché y es válido, la llamada al
Token Manager es prácticamente instantánea. Si está
expirado o no existe, el Manager lo renueva de forma transparente.
procedure CallProtectedEndpoint(Endpoint : Text) : Text
var
TokenManager : Codeunit "OAuth2 Token Manager";
Dispatcher : Codeunit "HTTP Dispatcher";
Request : HttpRequestMessage;
Response : HttpResponseMessage;
Headers : HttpHeaders;
Token : Text;
ResponseBody : Text;
begin
Token := TokenManager.GetValidToken(
'https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token',
'my-client-id',
'my-client-secret',
'api://my-api/.default'
);
Request.SetRequestUri(Endpoint);
Request.Method('GET');
Request.GetHeaders(Headers);
Headers.Add('Authorization', 'Bearer ' + Token);
if not Dispatcher.Send(Request, Response, 3) then
Error('Error en la llamada a la API: HTTP %1', Response.HttpStatusCode());
Response.Content().ReadAs(ResponseBody);
exit(ResponseBody);
end;
SingleInstance el token vive en memoria durante la
sesión, sin escrituras en base de datos y sin superficie de
exposición. La contrapartida es que cada nueva sesión de BC
arranca sin token y necesita pedir uno nuevo — lo que es
perfectamente aceptable.
Token Manager recibe el endpoint, el client id, el
secret y el scope como parámetros. Esto permite usarlo con
múltiples APIs distintas desde el mismo codeunit. Las credenciales
se leen desde una tabla de configuración en el codeunit de cada
integración, no están embebidas en el Manager.