Añadir inicio de sesión a una aplicación Laravel

Esta guía rápida añade el inicio de sesión de KeenAuth a una aplicación Laravel 13 con el cliente estándar de OpenID Connect facile-it/php-openid-client. KeenAuth todavía no tiene un SDK para PHP, y no lo necesita: KeenAuth es un proveedor estándar de OpenID Connect, así que funciona cualquier biblioteca cliente estándar de OpenID Connect. Su aplicación es el cliente de OAuth y guarda todos los tokens en la sesión del servidor; el navegador solo recibe una cookie cifrada con el identificador de sesión. Añadirá las rutas de inicio de sesión, protegerá una página, llamará a una API protegida con el token de acceso del usuario, cerrará la sesión y probará la aplicación sin conexión.

La aplicación tiene estas rutas:

Ruta Qué hace
GET / Página de inicio pública: un botón para iniciar sesión, o el nombre del usuario y un botón para cerrar sesión
GET /dashboard Requiere una sesión. Muestra el usuario y los mensajes que devuelve su API
POST /login Inicia el inicio de sesión. Laravel comprueba el token CSRF
GET /auth/callback Comprueba la respuesta, intercambia el código e inicia la sesión
POST /logout Termina la sesión aquí y en KeenAuth

Antes de empezar

1. Instalar

composer require facile-it/php-openid-client:1.0.0 guzzlehttp/guzzle:8.3.1

La biblioteca necesita un cliente HTTP PSR-18; Guzzle es uno de ellos. El ejemplo fija cada paquete exactamente en composer.json, y composer.lock fija cada paquete a un commit. Se instala con composer install --no-plugins --no-scripts, así que no se ejecuta código de paquetes durante la instalación.

2. Configurar

La aplicación lee su configuración únicamente de variables de entorno, a través de config/keenauth.php. Colóquelas en .env, que Laravel carga, y no suba nunca ese archivo al control de versiones.

Variable Obligatoria Significado
KEENAUTH_ISSUER sí El emisor de su entorno, con https
KEENAUTH_CLIENT_ID sí El client_id de la aplicación web
KEENAUTH_CLIENT_SECRET sí Su secreto de cliente
APP_BASE_URL sí La URL pública de la aplicación. El URI de redirección se construye a partir de ella. http solo para localhost, 127.0.0.1 o [::1]
API_URL sí La API que llama la aplicación, por ejemplo http://localhost:8080
KEENAUTH_API_AUDIENCE sí El identificador de esa API en KeenAuth
KEENAUTH_ID_TOKEN_ALG no El algoritmo con el que su entorno firma los tokens de ID: RS256 (predeterminado), PS256, ES256 o EdDSA
APP_KEY sí La clave propia de Laravel; cifra la cookie de sesión. Genere una con php artisan key:generate

Config::fromArray comprueba estos valores. Un valor que falta o no es utilizable detiene la primera solicitud que lo necesita, y ningún mensaje de error contiene un valor. Se rechaza un algoritmo HMAC, para que el secreto de cliente nunca pueda firmar un token de ID:

        $alg = $values['id_token_alg'] ?? 'RS256';
        if (!in_array($alg, ['RS256', 'PS256', 'ES256', 'EdDSA'], true)) {
            // Never an HMAC algorithm: the client secret must not be able to sign an ID token.
            throw new InvalidArgumentException('KEENAUTH_ID_TOKEN_ALG must be RS256, PS256, ES256 or EdDSA');
        }

3. Crear el cliente de OpenID Connect

App\KeenAuth\KeenAuth construye el cliente a partir del descubrimiento (<issuer>/.well-known/openid-configuration) y guarda en caché los metadatos y el conjunto de claves durante una hora:

        $issuer = (new IssuerBuilder())
            ->setMetadataProviderBuilder((new MetadataProviderBuilder())->setHttpClient($this->http)
                ->setCache($this->cache)->setCacheTtl(3600))
            ->setJwksProviderBuilder((new JwksProviderBuilder())->withHttpClient($this->http)
                ->withCache($this->cache)->withCacheTtl(3600))
            ->build($this->config->issuer);
            ->setClientMetadata(ClientMetadata::fromArray([
                'client_id' => $this->config->clientId,
                'client_secret' => $this->config->clientSecret,
                'token_endpoint_auth_method' => 'client_secret_basic',
                'redirect_uris' => [$this->config->redirectUri()],
                // The verifier accepts only this algorithm, never an HMAC one.
                'id_token_signed_response_alg' => $this->config->idTokenAlg,
            ]))

El proveedor de servicios lo registra como singleton, y el cliente HTTP como un enlace aparte, para que las pruebas puedan reemplazarlo.

4. Iniciar el inicio de sesión

Cada inicio de sesión genera un nuevo state, nonce y verificador PKCE, y los guarda en la sesión:

    public static function newTransaction(): array
    {
        return [
            'state' => self::base64url(random_bytes(32)),
            'nonce' => self::base64url(random_bytes(32)),
            'code_verifier' => self::base64url(random_bytes(32)),
        ];
    }

La solicitud de autorización envía el desafío S256 del verificador y el identificador de su API como resource (RFC 8707). Sin resource, el token de acceso no está destinado a su API y la API lo rechaza.

        return $this->service()->getAuthorizationUri($this->client(), [
            // messages:read is a scope of your API; the example API requires it on /api/messages.
            'scope' => 'openid profile email messages:read',
            'state' => $transaction['state'],
            'nonce' => $transaction['nonce'],
            // PKCE (RFC 7636): S256 of the verifier that only this session knows.
            'code_challenge' => self::base64url(hash('sha256', $transaction['code_verifier'], true)),
            'code_challenge_method' => 'S256',
            // RFC 8707: an access token for your API.
            'resource' => $this->config->apiAudience,
        ]);

El inicio de sesión es un formulario que se envía a /login con el token CSRF, para que otro sitio no pueda iniciar un inicio de sesión en nombre del usuario:

    <form action="/login" method="post">
      @csrf
      <button type="submit">Sign in</button>
    </form>

5. Gestionar la llamada de retorno

La biblioteca deja la comprobación de state en manos de su aplicación. La llamada de retorno saca la transacción de la sesión, de modo que un state solo funciona una vez, y lo compara en tiempo constante. Su entorno se identifica en cada respuesta de autorización (RFC 9207), así que la llamada de retorno también rechaza una respuesta cuyo iss falta o nombra otro emisor:

        $transaction = $request->session()->pull('keenauth.transaction');
        $state = $request->query('state');
        if (!is_array($transaction) || !is_string($state) || !hash_equals($transaction['state'], $state)) {
            return $this->failure('invalid_state');
        }
        // RFC 9207: the response names its issuer, so a response from another provider is refused.
        $iss = $request->query('iss');
        if ($keenauth->issuerInResponse() && $iss !== $keenauth->config->issuer) {
            return $this->failure('invalid_issuer');
        }

De la llamada de retorno solo se toma el código. La biblioteca lo intercambia con el verificador PKCE y comprueba el token de ID: su firma con las claves de su entorno, iss, aud, exp, iat, el algoritmo y el nonce de este inicio de sesión.

        return $this->service()->callback($this->client(), ['code' => $code], $this->config->redirectUri(),
            AuthSession::fromArray($transaction));

Después, la aplicación inicia un nuevo identificador de sesión y guarda el usuario y los tokens en la sesión del servidor:

        // A new session id after sign-in prevents session fixation.
        $request->session()->regenerate(destroy: true);

Los ajustes de sesión están en config/session.php: el controlador file, una cookie cifrada, HttpOnly y SameSite=Lax, que es Secure salvo que establezca SESSION_SECURE_COOKIE=false para http://localhost. Con varios servidores, use el controlador redis o database.

6. Proteger páginas y llamar a su API

Las rutas que necesitan un usuario usan el middleware keenauth. El ejemplo no tiene token de actualización: cuando caduca el token de acceso, el usuario vuelve a iniciar sesión.

Route::get('/dashboard', [DashboardController::class, 'show'])->middleware('keenauth');

El panel llama a su API con el token de acceso como token de portador. El token permanece en el servidor; el navegador recibe solo el HTML.

        $response = $http->sendRequest(new HttpRequest('GET', $keenauth->config->apiUrl . '/api/messages', [
            'Authorization' => 'Bearer ' . $request->session()->get('keenauth.access_token'),
            'Accept' => 'application/json',
        ]));

7. Cerrar sesión

Cerrar sesión es un POST con el token CSRF. La aplicación termina la sesión local y envía el navegador al endpoint de cierre de sesión de su entorno (OpenID Connect RP-Initiated Logout) con id_token_hint, client_id y post_logout_redirect_uri, que devuelve el navegador a su aplicación:

        $idToken = $request->session()->get('keenauth.id_token');
        $request->session()->invalidate();
        $request->session()->regenerateToken();

8. Ejecutar la aplicación

Inicie la API de la guía rápida de Express (o cualquier API que acepte los tokens) y ejecute la aplicación:

php artisan serve

Abra http://localhost:8000, elija Iniciar sesión, inicie sesión en KeenAuth y abra el panel.

9. Probar sin red

Las pruebas no necesitan red ni una cuenta de KeenAuth. Un cliente PSR-18 simulado ocupa el lugar del cliente HTTP y hace de su entorno (descubrimiento, un conjunto de claves creado para la ejecución de las pruebas, el endpoint de tokens) y de la API. Las pruebas se ejecutan con APP_ENV=ci, porque Laravel omite la comprobación CSRF en testing y estas pruebas la verifican.

    public function test_callback_with_an_unknown_state_is_refused_before_any_token_request(): void
    {
        $this->startSignIn();
        $this->returnFromKeenAuth(['state' => 'not-the-state'])->assertStatus(401)->assertSee('invalid_state');
vendor/bin/phpunit
php ../scripts/php_deps.php    # exact pins, commits and licences

Las pruebas comprueban que la configuración se valida (y que ningún error contiene un secreto); que el inicio de sesión necesita el token CSRF y envía PKCE S256, state, nonce y resource; que el código se intercambia con el verificador correspondiente y la autenticación del cliente; que el token de acceso va a la API en la cabecera Authorization y nunca a una cookie; que un state desconocido o reutilizado, un iss ausente o incorrecto, un token de ID para otro cliente o con otro nonce, y un error de autorización se rechazan sin crear una sesión; que una página sin sesión redirige a la página de inicio; y que cerrar sesión necesita el token CSRF y va al endpoint de cierre de sesión con id_token_hint.

El inicio de sesión completo con un navegador real necesita un entorno de KeenAuth. No forma parte de las pruebas sin conexión.

Detrás de un proxy o balanceador de carga

Establezca APP_BASE_URL en la URL pública de la aplicación; el URI de redirección se construye a partir de ella, nunca a partir de la cabecera Host. Si un proxy termina TLS, configure los proxies de confianza de Laravel solo para ese proxy.

Límites actuales

Próximos pasos