#!/usr/bin/perl

# Prontus CMS
# http://www.prontus.cl
# by Altavoz.net
#
# licensed under LGPL license.
# http://www.prontus.cl/license.html

# -------------------------------COMENTARIO GLOBAL---------------
# subrutinas de administración de usuarios del CPAN Prontus.
# -------------------------------BEGIN LIBRERIA-------------
# DECLARACIONES GLOBALES.
# ------------------------
package lib_oidc;

use strict;
use utf8;
use JSON;
use LWP::UserAgent ();
use URI::Escape;
use MIME::Base64;
#use Data::Dumper;

use lib_prontus;
use glib_dbi_03;
use glib_str_03;
use prontus_auth;

## SUBRUTINAS DE ADMINISTRACIÓN DE SESIÓN

# Verifica si un usuario existe en Prontus según un parámetro pasado.
# Implementada para sso/prontus_oidc_callback.cgi
# Parámetros:
#   field: el campo por el cual buscar. Por defecto es el nombre de usuario, puede ser
#   user, email, id.
# Retorna:
#   array (user_id y user_name), o (0, error_msg).
sub check_user_simple {
    my ($value, $field) = @_;

    my ($bd, $msg_err_bd) = &lib_prontus::conectar_user_bd();
    if (!ref($bd)) {
        return (0, "No fue posible conectar a la base de datos [$msg_err_bd]", '');
    }

    my $sub_query = " USERS_USR = ? ";
    if ($field eq 'email') {
        $sub_query = " USERS_EMAIL = ? ";
    } elsif ($field eq 'id') {
        $sub_query = " USERS_ID = ? ";
    }

    my $sql = "SELECT USERS_ID, USERS_USR, USERS_PROFILE_ID FROM USERS WHERE $sub_query AND USERS_DELETED = 0 LIMIT 1";
    my @params = $value;
    my $salida = &glib_dbi_03::ejecutar_sql_param($bd, $sql, \@params);

    if (my $row = $salida->fetchrow_hashref) {
        if ($row->{'USERS_ID'}) {
            return ($row->{'USERS_ID'}, $row->{'USERS_USR'}, $row->{'USERS_PROFILE_ID'});
        }
    }
    return (0, "No se encontró el usuario", '');
}

# Ingresa una nueva sesión OIDC en base de datos.
# Borra previamente una sesión existente del mismo usuario.
sub crear_sesion_oidc {
    my $user_data = shift;
    my ($bd, $msg_err_bd) = &lib_prontus::conectar_user_bd();
    if (!ref($bd)) {
        return (0, "No fue posible conectar a la base de datos [$msg_err_bd]");
    }

    # borramos, si hay, la sesion previa.
    &borrar_sesion_oidc($user_data->{'user_id'});
    #print STDERR Dumper($user_data);
    my $sql = "INSERT INTO OIDC_USERS (OIDC_IDUSERS, OIDC_TOKEN, OIDC_TIMESTAMP, OIDC_EXPIRATION, OIDC_REFRESH_TOKEN) values(?, ?, NOW(), FROM_UNIXTIME(?), ?)";
    my @params;
    push(@params, $user_data->{'user_id'});
    push(@params, $user_data->{'token'});
    push(@params, $user_data->{'expires'});
    push(@params, $user_data->{'refresh'});

    my $salida = &glib_dbi_03::ejecutar_sql_param($bd, $sql, \@params);
    if ($bd->errstr) {
        return (0, $bd->errstr);
    }
    return (1, "");
}

# Chequea si existe una sesión OIDC activa para este usuario.
# Revisa si ha expirado.
sub existe_sesion_oidc {
    my $user_id = shift;
    my ($bd, $msg_err_bd) = &lib_prontus::conectar_user_bd();
    if (!ref($bd)) {
        print STDERR "ERROR: No fue posible conectar a la base de datos [$msg_err_bd]\n";
        return (0, "No fue posible conectar a la base de datos [$msg_err_bd]");
    }

    my $sql = "SELECT OIDC_IDUSERS, (OIDC_EXPIRATION < NOW()) AS EXPIRADA FROM OIDC_USERS WHERE OIDC_IDUSERS = ? LIMIT 1";
    my @params = ($user_id,);
    my $salida = &glib_dbi_03::ejecutar_sql_param($bd, $sql, \@params);
    if ($bd->errstr) {
        print STDERR "ERROR: [".$bd->errstr."]\n";
        return (0, $bd->errstr);
    }
    if (my $row = $salida->fetchrow_hashref) {
        if ($row->{'EXPIRADA'} == 1) {
            &borrar_sesion_oidc($user_id);
            return (0, "Sesión expirada");
        }
    }
    return (1, "");
}

# Chequea si existe una sesión OIDC activa para este access token.
# Revisa si ha expirado.
# Parámetros:
#   token_md5: hash md5 del token a buscar
# Retorna:
#   (return_code, msg|username)
sub existe_oidc_token {
    my $token_md5 = shift;
    my ($bd, $msg_err_bd) = &lib_prontus::conectar_user_bd();
    if (!ref($bd)) {
        print STDERR "ERROR: No fue posible conectar a la base de datos [$msg_err_bd]\n";
        return (0, "No fue posible conectar a la base de datos [$msg_err_bd]");
    }

    my $sql = "SELECT OIDC_IDUSERS, USERS_USR, OIDC_TOKEN, (OIDC_EXPIRATION < NOW()) AS EXPIRADA FROM OIDC_USERS INNER JOIN USERS ON OIDC_IDUSERS = USERS_ID WHERE MD5(OIDC_TOKEN) = ? AND USERS_DELETED = 0 LIMIT 1";
    my @params = ($token_md5);
    my $salida = &glib_dbi_03::ejecutar_sql_param($bd, $sql, \@params);
    if ($bd->errstr) {
        print STDERR "ERROR: [".$bd->errstr."]\n";
        return (0, $bd->errstr);
    }

    my $row = $salida->fetchrow_hashref;
    if (defined $row) {
        if ($row->{'EXPIRADA'} == 1) {
            &borrar_sesion_oidc($row->{'OIDC_IDUSERS'});
            return (0, "Sesión expirada");
        } else {
            return (1, $row->{'USERS_USR'});
        }
    }
    return (0, '');

}

# Devuelve los datos de la sesión OIDC para un ID de usuario.
sub get_sesion_oidc {
    my $user_id = shift;

    my ($code, $msg) = &existe_sesion_oidc($user_id);
    if (!$code) {
        return (0, "No existe sesión OIDC para el usuario.");
    }

    my ($bd, $msg_err_bd) = &lib_prontus::conectar_user_bd();
    if (!ref($bd)) {
        return (0, "No fue posible conectar a la base de datos [$msg_err_bd]");
    }

    my $sql = "SELECT OIDC_IDUSERS, OIDC_TOKEN, OIDC_EXPIRATION, OIDC_REFRESH_TOKEN FROM OIDC_USERS WHERE OIDC_IDUSERS = ? LIMIT 1";
    my @params = ($user_id,);
    my $salida = &glib_dbi_03::ejecutar_sql_param($bd, $sql, \@params);
    if ($bd->errstr) {
        return (0, $bd->errstr);
    }
    return (1, $salida->fetchrow_hashref);
}

# Refresca los datos de una sesión OIDC para el usuario pasado.
sub actualizar_sesion_oidc {
    my $user_data = shift;
    my ($bd, $msg_err_bd) = &lib_prontus::conectar_user_bd();
    if (!ref($bd)) {
        return (0, "No fue posible conectar a la base de datos [$msg_err_bd]");
    }

    if (!&existe_sesion_oidc($user_data->{'user_id'})) {
        return (0, 'No existe sesión OIDC para este usuario.');
    }

    my $sql = "UPDATE OIDC_USERS SET OIDC_TOKEN = ?, OIDC_EXPIRATION = FROM_UNIXTIME(?), OIDC_REFRESH_TOKEN = ? WHERE OIDC_IDUSERS = ?";
    my @params;
    push(@params, $user_data->{'token'});
    push(@params, $user_data->{'expires'});
    push(@params, $user_data->{'refresh'});
    push(@params, $user_data->{'user_id'});

    my $salida = &glib_dbi_03::ejecutar_sql_param($bd, $sql, \@params);
    if ($bd->errstr) {
        return (0, $bd->errstr);
    }
}

# Elimina los datos de una sesión OIDC para un usuario determinado.
sub borrar_sesion_oidc {
    my $user_id = shift;
    my ($bd, $msg_err_bd) = &lib_prontus::conectar_user_bd();
    if (!ref($bd)) {
        return (0, "No fue posible conectar a la base de datos [$msg_err_bd]");
    }

    my $sql = "DELETE FROM OIDC_USERS WHERE OIDC_IDUSERS = ? LIMIT 1";
    my @params = ($user_id,);
    my $salida = &glib_dbi_03::ejecutar_sql_param($bd, $sql, \@params);
    if ($bd->errstr) {
        return (0, $bd->errstr);
    } else {
        return (1, "");
    }
}

## SUBRUTINAS DE VALIDACIÓN DE TOKENS

# Intenta obtener el access_token, id_token, refresh_token.
# si se le pasa un refresh_token, lo usa para intentar refrescar
# los demás tokens.
# Usa LWP::UserAgent
# Parámetros:
#   el código a intercambiar
#   el refresh token si se busca obtener un token refrescado.
# Retorna:
#   la respuesta del endpoint en formato JSON.
sub get_tokens {
    my ($code, $refresh_token) = @_;

    my $issuer = $prontus_varglb::CONFIG{$prontus_varglb::PRONTUS_ID}{'OIDC_ENDPOINT_URI'};
    my $client_id = $prontus_varglb::CONFIG{$prontus_varglb::PRONTUS_ID}{'OIDC_CLIENT_ID'};
    my $client_secret = $prontus_varglb::CONFIG{$prontus_varglb::PRONTUS_ID}{'OIDC_APP_SECRET'};

    my $ua = LWP::UserAgent->new;
    $ua->timeout(10);
    $ua->env_proxy;

    # obtenemos los tokens dado el code recibido.
    # generamos el digest con la id y secret.
    my $digest = encode_base64("$client_id:$client_secret", ''); # El segundo parámetro especifica no agregar un \n al final.
    my %query_hash = (
        'grant_type'=>'authorization_code',
        'code'=>$code,
        'redirect_uri'=>"$prontus_varglb::CONFIG{$prontus_varglb::PRONTUS_ID}{'SERVER_PROTOCOLO_CPAN'}://$prontus_varglb::CONFIG{$prontus_varglb::PRONTUS_ID}{'CPAN_SERVER_NAME'}/cgi-cpn/sso/prontus_oidc_callback.cgi?_prontus_id=$prontus_varglb::PRONTUS_ID",
    );

    if ($refresh_token) {
        $query_hash{'grant_type'} = 'refresh_token';
        $query_hash{'scope'} = 'offline_access openid',
        $query_hash{'refresh_token'} = $refresh_token,
    }

    my $token_endpoint = "$issuer/v1/token?";

    $ua->default_header("Authorization"=> "Basic $digest");
    $ua->default_header('Accept'=>'application/json');
    $ua->default_header('Content-Type'=>'application/x-www-form-urlencoded');
    $ua->default_header('Connection'=>'close');
    $ua->default_header('Content-Length'=>'0');

    my $response = $ua->post($token_endpoint, \%query_hash);
    my $content = decode_json($response->decoded_content);
    return $content;
}

# Intenta refrescar el access_token, id_token, refresh_token.
# Usa LWP::UserAgent
# Parámetros:
#   refresh_token
# Retorna:
#   hashref con el token refrescado o algún error de la API.
sub refrescar_tokens {
    my ($refresh_token) = @_;

    my $issuer = $prontus_varglb::CONFIG{$prontus_varglb::PRONTUS_ID}{'OIDC_ENDPOINT_URI'};
    my $client_id = $prontus_varglb::CONFIG{$prontus_varglb::PRONTUS_ID}{'OIDC_CLIENT_ID'};
    my $client_secret = $prontus_varglb::CONFIG{$prontus_varglb::PRONTUS_ID}{'OIDC_APP_SECRET'};

    my $ua = LWP::UserAgent->new;
    $ua->timeout(10);
    $ua->env_proxy;

    # obtenemos los tokens dado el code recibido.
    # generamos el digest con la id y secret.
    my $digest = encode_base64("$client_id:$client_secret", ''); # El segundo parámetro especifica no agregar un \n al final.
    my %query_hash = (
        'redirect_uri'=>"$prontus_varglb::CONFIG{$prontus_varglb::PRONTUS_ID}{'SERVER_PROTOCOLO_CPAN'}://$prontus_varglb::CONFIG{$prontus_varglb::PRONTUS_ID}{'CPAN_SERVER_NAME'}/cgi-cpn/sso/prontus_oidc_callback.cgi?_prontus_id=$prontus_varglb::PRONTUS_ID",
        'grant_type' => 'refresh_token',
        'scope' => 'offline_access openid email',
        'refresh_token' => $refresh_token,
    );

    my $token_endpoint = "$issuer/v1/token?";

    $ua->default_header("Authorization"=> "Basic $digest");
    $ua->default_header('Accept'=>'application/json');
    $ua->default_header('Content-Type'=>'application/x-www-form-urlencoded');
    $ua->default_header('Connection'=>'close');
    $ua->default_header('Content-Length'=>'0');

    my $response;
    $response = $ua->post($token_endpoint, \%query_hash);
    my $content = decode_json($response->decoded_content);
    return $content;
}

# Valida un token de acceso.
# https://developer.okta.com/docs/guides/validate-access-tokens/go/overview/#what-to-check-when-validating-an-access-token
# For any access token to be valid, the following must be asserted:
# Signature is valid (the token was signed by a private key which has a corresponding public key in the JWKS response from the authorization server).
# Access token is not expired (requires local system time to be in sync with Okta, checks the exp claim of the access token).
# The aud claim matches any expected aud claim passed to verifyAccessToken().
# The iss claim matches the issuer the verifier is constructed with.
# Any custom claim assertions that you add are confirmed
#
# Parámetros:
#   el token a validar
# Retorna:
#   booleano
sub validate_access_token_local {
    my $token = shift;
    my $valid = 1;
    #print STDERR "access token: ". Dumper($token);
    # validamos la audience
    if ($token->{'aud'} ne 'api://default') {
        $valid = 0;
        print STDERR "ERROR: Token inválido, la audiencia [$token->{'aud'}] es distinta de 'api://default'.\n";
    }

    # validamos el issuer
    if ($token->{'iss'} !~ /^$prontus_varglb::CONFIG{$prontus_varglb::PRONTUS_ID}{'OIDC_ENDPOINT_URI'}/ && $prontus_varglb::CONFIG{$prontus_varglb::PRONTUS_ID}{'OIDC_ENDPOINT_URI'} !~ /^$token->{'iss'}/) {
        $valid = 0;
        print STDERR "ERROR: Token inválido, el issuer [$token->{'iss'}] no coincide con [$prontus_varglb::CONFIG{$prontus_varglb::PRONTUS_ID}{'OIDC_ENDPOINT_URI'}].\n";
    }

    # validamos el Client ID
    if ($token->{'cid'} ne $prontus_varglb::CONFIG{$prontus_varglb::PRONTUS_ID}{'OIDC_CLIENT_ID'}) {
        $valid = 0;
        print STDERR "ERROR: Token inválido, el Client ID recibido [$token->{'cid'}] es distinto de [$prontus_varglb::CONFIG{$prontus_varglb::PRONTUS_ID}{'OIDC_CLIENT_ID'}].\n";
    }

    # validamos que el token no haya expirado.
    if ($token->{'exp'} <= time) {
        $valid = 0;
        my $now = localtime();
        my $exp = localtime($token->{'exp'});
        print STDERR "ERROR: Token expirado [$token->{'exp'}] indicaba [$exp], es [$now].\n";
    }

    return $valid;
}

# Valida un token de ID.
# https://developer.okta.com/docs/guides/validate-id-tokens/go/overview/#what-to-check-when-validating-an-id-token
# For any id token to be valid, the following must be asserted:
# Signature is valid (the token was signed by a private key which has a corresponding public key in the JWKS response from the authorization server).
# ID token is not expired (requires local system time to be in sync with Okta, checks the exp claim of the id token).
# The aud claim matches any expected aud claim passed to verifyIDToken().
# The iss claim matches the issuer the verifier is constructed with.
# Any custom claim assertions that you add are confirmed
#
# Parámetros:
#   el token a validar
# Retorna:
#   booleano
sub validate_id_token_local {
    my $token = shift;
    my $valid = 1;
    #print STDERR "id token: ". Dumper($token);
    # en teoría deberíamos validar el claim 'nonce', pero no viene...
    # validamos la audience
    if ($token->{'aud'} ne $prontus_varglb::CONFIG{$prontus_varglb::PRONTUS_ID}{'OIDC_CLIENT_ID'}) {
        $valid = 0;
        print STDERR "ERROR: ID token inválido, la audiencia [$token->{'aud'}] es distinta de '$prontus_varglb::CONFIG{$prontus_varglb::PRONTUS_ID}{'OIDC_CLIENT_ID'}'.\n";
    }

    # validamos el issuer
    if ($token->{'iss'} !~ /^$prontus_varglb::CONFIG{$prontus_varglb::PRONTUS_ID}{'OIDC_ENDPOINT_URI'}/ && $prontus_varglb::CONFIG{$prontus_varglb::PRONTUS_ID}{'OIDC_ENDPOINT_URI'} !~ /^$token->{'iss'}/) {
        $valid = 0;
        print STDERR "ERROR: ID token inválido, el issuer [$token->{'iss'}] no coincide con [$prontus_varglb::CONFIG{$prontus_varglb::PRONTUS_ID}{'OIDC_ENDPOINT_URI'}].\n";
    }

    # validamos que el issue time del token sea coherente.
    my $now_time = time;
    my $now = localtime();
    if ($token->{'iat'} > $now_time + 10) {
        $valid = 0;
        my $iat = localtime($token->{'iat'});
        print STDERR "ERROR: Token con tiempo incoherente: [$token->{'iat'}] contra [$now_time] indicaba [$iat], es [$now].\n";
    }

    # validamos que el token no haya expirado.
    if ($token->{'exp'} <= $now_time) {
        $valid = 0;
        my $exp = localtime($token->{'exp'});
        print STDERR "ERROR: Token expirado [$token->{'exp'}] indicaba [$exp], es [$now].\n";
    }

    return $valid;
}

# Valida remotamente un access_token
# Usa LWP::UserAgent
# Parámetros:
#   El access token a validar.
# Retorna:
#   0 o el json con el token validado.
sub validate_access_token_remote {
    my $access_token = shift;

    my $issuer = $prontus_varglb::CONFIG{$prontus_varglb::PRONTUS_ID}{'OIDC_ENDPOINT_URI'};
    my $client_id = $prontus_varglb::CONFIG{$prontus_varglb::PRONTUS_ID}{'OIDC_CLIENT_ID'};
    my $client_secret = $prontus_varglb::CONFIG{$prontus_varglb::PRONTUS_ID}{'OIDC_APP_SECRET'};

    my $ua = LWP::UserAgent->new;
    $ua->timeout(10);
    $ua->env_proxy;

    # validamos que el token siga activo con otra request al provider
    my $introspect_endpoint = "$issuer/v1/introspect?";
    my %query_hash = (
        'token' => $access_token,
        'token_type_hint' => 'access_token',
    );

    # generamos el digest con la id y secret.
    my $digest = encode_base64("$client_id:$client_secret", ''); # El segundo parámetro especifica no agregar un \n al final.
    $ua->default_header("Authorization"=> "Basic $digest");
    $ua->default_header('Accept'=>'application/json');
    $ua->default_header('Content-Type'=>'application/x-www-form-urlencoded');
    $ua->default_header('Connection'=>'close');
    $ua->default_header('Content-Length'=>'0');
    my $response = $ua->post($introspect_endpoint, \%query_hash);
    my $content = decode_json($response->decoded_content);

    if ($content->{'error'}) {
        print STDERR "ERROR: Recibimos un error [$content->{'error'}] desde la API al intentar validar remotamente el access_token: [$content->{'error_description'}].\n";
        return 0;
    }

    #print STDERR Dumper($content);
    if (!$content->{'active'}) {
        #print STDERR "ERROR: Token desactivado.\n";
        return 0;
    }

    return $content;
}

# Valida remotamente un id_token
# Usa LWP::UserAgent
# Parámetros:
#   El id token a validar.
# Retorna:
#   0 o el json con el token validado.
sub validate_id_token_remote {
    my $id_token = shift;

    my $issuer = $prontus_varglb::CONFIG{$prontus_varglb::PRONTUS_ID}{'OIDC_ENDPOINT_URI'};
    my $client_id = $prontus_varglb::CONFIG{$prontus_varglb::PRONTUS_ID}{'OIDC_CLIENT_ID'};
    my $client_secret = $prontus_varglb::CONFIG{$prontus_varglb::PRONTUS_ID}{'OIDC_APP_SECRET'};

    my $ua = LWP::UserAgent->new;
    $ua->timeout(10);
    $ua->env_proxy;

    # validamos que el token siga activo con otra request al provider
    my $introspect_endpoint = "$issuer/v1/introspect?";
    my %query_hash = (
        'token' => $id_token,
        'token_type_hint' => 'id_token',
    );

    # generamos el digest con la id y secret.
    my $digest = encode_base64("$client_id:$client_secret", ''); # El segundo parámetro especifica no agregar un \n al final.
    $ua->default_header("Authorization"=> "Basic $digest");
    $ua->default_header('Accept'=>'application/json');
    $ua->default_header('Content-Type'=>'application/x-www-form-urlencoded');
    $ua->default_header('Connection'=>'close');
    $ua->default_header('Content-Length'=>'0');
    my $response = $ua->post($introspect_endpoint, \%query_hash);
    my $content = decode_json($response->decoded_content);

    if ($content->{'error'}) {
        print STDERR "ERROR: Recibimos un error [$content->{'error'}] desde la API al intentar validar remotamente el id_token: [$content->{'error_description'}].\n";
        return 0;
    }

    # print STDERR Dumper($content);
    if (!$content->{'active'}) {
        # print STDERR "ERROR: Token desactivado.\n";
        return 0;
    }

    return $content;
}

# Ejecuta una validación básica de los datos recibidos al intercambiar access code por tokens
# Parámetros:
#   hashref con el contenido recibido.
# Retorna:
#   booleano
sub basic_token_validation {
    my $content = shift;
    if ($content->{'error'}) {
        print STDERR "ERROR: Recibimos un error [$content->{'error'}] desde la API al intentar intercambiar code por token: [$content->{'error_description'}]\n";
        return 0;
    }
    my $access_token = $content->{access_token};
    if ($access_token eq '') {
        print STDERR "ERROR: El Access token no venía en la response del proveedor de OpenID connect.\n";
        return 0;
    }
    my $id_token = $content->{id_token};
    if ($id_token eq '') {
        print STDERR "ERROR: El ID token no venía en la response del proveedor de OpenID connect.\n";
        return 0;
    }
    return 1;
}

# Lee la clave de decriptación obtenida por prontus_oidc_get_keys.pl
# Parámetros:
#   ninguno
# Retorna:
#   Contenido del archivo de claves.
sub get_key {
    my $path = "$prontus_varglb::DIR_SERVER/$prontus_varglb::PRONTUS_ID/cpan/data/oidc_keys/key_file.json";
    if (!-f $path) {
        print STDERR "ERROR: No se pudo leer la clave de decriptación desde el archivo de claves [$path].\n";
        return '';
    }
    return &glib_fildir_03::read_file($path);
}

return 1;
