Newer
Older
smart-home-server / server / SHServ / Controllers / AuthController.php
<?php

declare(strict_types=1);

namespace SHServ\Controllers;

use GNexus\GAuth\DTO\TokenSet;
use SHServ\Integrations\GAuth\AuthControllerTrait;
use SHServ\Integrations\GAuth\AuthService;
use SHServ\Integrations\GAuth\PermissionResolver;
use SHServ\Integrations\GAuth\Store\DbTokenStore;
use SHServ\Integrations\GAuth\UserResolver;

class AuthController extends \SHServ\Middleware\Controller
{
    use AuthControllerTrait;

    /**
     * GET /auth/login
     * Redirect user to gnexus-auth authorization page.
     */
    public function login()
    {
        $service = new AuthService();
        $returnTo = $_GET['return_to'] ?? '/';
        $returnTo = $this->sanitizeReturnTo($returnTo);
        $url = $service->buildLoginUrl($returnTo);
        return $this->utils()->redirect($url);
    }

    /**
     * GET /auth/callback
     * Handle OAuth callback from gnexus-auth.
     */
    public function callback()
    {
        if (session_status() === PHP_SESSION_NONE) {
            @session_start();
        }

        $code = isset($_GET['code']) ? (string) $_GET['code'] : '';
        $state = isset($_GET['state']) ? (string) $_GET['state'] : '';

        if ($code === '' || $state === '') {
            return $this->utils()->response_error('invalid_callback');
        }

        $service = new AuthService();

        try {
            $user = $service->handleCallback($code, $state);
        } catch (\GNexus\GAuth\Exception\GAuthException $e) {
            return $this->utils()->response_error('auth_failed', [], ['message' => $e->getMessage()]);
        }

        $resolver = new UserResolver();
        $localUserId = $resolver->resolve($user);

        $_SESSION['shserv_user_id'] = $localUserId;

        // Persist tokens to DB now that we have the local user ID
        $sessionToken = $_SESSION['shserv_auth_token'] ?? null;
        $tokenData = $_SESSION['shserv_last_token_set'] ?? null;
        if ($sessionToken && $tokenData) {
            $expiresAt = $tokenData['expires_at'] ? new \DateTimeImmutable($tokenData['expires_at']) : null;
            $tokenSet = new TokenSet(
                accessToken: $tokenData['access_token'],
                refreshToken: $tokenData['refresh_token'] ?? null,
                tokenType: $tokenData['token_type'] ?? 'Bearer',
                expiresIn: $expiresAt ? (int) $expiresAt->format('U') - time() : 0,
                expiresAt: $expiresAt,
            );
            $dbStore = new DbTokenStore(app()->thin_builder);
            $dbStore->put($sessionToken, $tokenSet, $localUserId);
            unset($_SESSION['shserv_last_token_set']);
        }

        // Redirect back to app
        $context = $_SESSION['gauth_state'][$state]['context'] ?? [];
        $returnTo = $context['return_to'] ?? '/';
        $returnTo = $this->sanitizeReturnTo($returnTo);

        return $this->utils()->redirect($returnTo);
    }

    /**
     * Prevent open redirect: allow only same-origin relative paths or absolute URLs.
     */
    private function sanitizeReturnTo(string $returnTo): string
    {
        if (str_starts_with($returnTo, '/')) {
            return $returnTo;
        }

        if (str_starts_with($returnTo, 'http://') || str_starts_with($returnTo, 'https://')) {
            $host = parse_url($returnTo, PHP_URL_HOST);
            $allowedHost = $_SERVER['HTTP_HOST'] ?? '';
            if ($host && $host === $allowedHost) {
                return $returnTo;
            }
        }

        return '/';
    }

    /**
     * POST /auth/logout
     */
    public function logout()
    {
        if (session_status() === PHP_SESSION_NONE) {
            @session_start();
        }

        $service = new AuthService();
        $service->logout();

        // Destroy session cookie so the browser stops sending it
        if (ini_get('session.use_cookies')) {
            $params = session_get_cookie_params();
            setcookie(
                session_name(),
                '',
                time() - 42000,
                $params['path'],
                $params['domain'],
                $params['secure'],
                $params['httponly']
            );
        }

        session_destroy();

        return $this->utils()->response_success();
    }

    /**
     * GET /auth/me
     * Return current authenticated user + effective permissions.
     */
    public function me()
    {
        $user = $this->get_current_user();
        if (!$user) {
            return $this->utils()->response_error('not_found_any_sessions', [], [], 401);
        }

        $permissions = $this->get_current_permissions();

        $gauthBase = FCONF['gauth']['base_url'] ?? '';

        return $this->utils()->response_success([
            'user' => [
                'id' => $user['id'],
                'gauth_user_id' => $user['gauth_user_id'],
                'email' => $user['email'],
                'display_name' => $user['display_name'],
                'avatar_url' => $user['avatar_url'],
                'system_role' => $user['system_role'],
                'status' => $user['status'],
                'gauth_profile_url' => $gauthBase ? rtrim($gauthBase, '/') . '/account/profile' : '',
            ],
            'permissions' => $permissions,
        ]);
    }

    /**
     * POST /auth/refresh
     * Refresh access token using stored refresh token.
     */
    public function refresh()
    {
        $sessionToken = $_SESSION['shserv_auth_token'] ?? null;
        if (!$sessionToken) {
            return $this->utils()->response_error('not_found_any_sessions', [], [], 401);
        }

        $service = new AuthService();
        $tokenSet = $service->refreshAccessToken($sessionToken);

        if (!$tokenSet) {
            return $this->utils()->response_error('session_expired', [], [], 401);
        }

        return $this->utils()->response_success([
            'access_token' => $tokenSet->accessToken,
            'expires_in' => $tokenSet->expiresIn,
        ]);
    }
}