Artículos /

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í.

¿Qué es SingleInstance en AL?

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.

AL
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.

AL
    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.

AL
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;
¿Por qué? Caché en memoria, no en tabla
El token tiene una vida corta (típicamente 3600s) y es sensible. Guardarlo en una tabla implicaría gestionarlo por empresa y asegurar que no queda expuesto en un log o query. Con 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.
¿Por qué? Buffer de 5 minutos antes de expirar
Si se espera a que el token expire exactamente para renovarlo, hay una ventana donde un request puede llegar al API con un token técnicamente válido según el caché pero ya rechazado por el servidor (por diferencias de reloj o por latencia de red). El buffer de 300 segundos elimina ese riesgo. Es conservador pero en tokens con vida de 3600s no supone ningún impacto práctico.
¿Por qué? Credenciales como parámetro, no hardcodeadas
El 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.