<?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\RateLimiter;
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()
{
$rateLimit = $this->checkAuthRateLimit('login');
if ($rateLimit !== null) {
return $rateLimit;
}
$service = new AuthService();
$returnTo = $_GET['return_to'] ?? '/';
$returnTo = $this->sanitizeReturnTo($returnTo);
$url = $service->buildLoginUrl($returnTo);
logging()->info('php:Auth', 'Login attempt', [
'client_ip' => $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0',
'return_to' => $returnTo,
]);
return $this->utils()->redirect($url);
}
/**
* GET /auth/callback
* Handle OAuth callback from gnexus-auth.
*/
public function callback()
{
$rateLimit = $this->checkAuthRateLimit('callback');
if ($rateLimit !== null) {
return $rateLimit;
}
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');
}
// Capture returnTo BEFORE handleCallback wipes the state from session
$context = $_SESSION['gauth_state'][$state]['context'] ?? [];
$service = new AuthService();
try {
$user = $service->handleCallback($code, $state);
} catch (\GNexus\GAuth\Exception\GAuthException $e) {
logging()->warn('php:Auth', 'OAuth callback failed', [
'message' => $e->getMessage(),
'client_ip' => $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0',
]);
return $this->utils()->response_error('auth_failed', [], ['message' => $e->getMessage()]);
}
$resolver = new UserResolver();
$localUserId = $resolver->resolve($user);
logging()->info('php:Auth', 'OAuth login successful', [
'user_id' => $localUserId,
'email' => $user->email,
'gauth_user_id' => $user->userId,
]);
$_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
$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()
{
$sessionToken = $_SESSION['shserv_auth_token'] ?? null;
$currentUser = $this->get_current_user();
$userId = $currentUser['id'] ?? null;
if (!$sessionToken) {
$bearer = $this->get_bearer_token();
if ($bearer) {
$tb = app()->thin_builder;
$result = $tb->select('shserv_sessions', ['session_token'], [['access_token', '=', $bearer]]);
if ($result) {
$sessionToken = $result[0]['session_token'];
}
}
}
if ($sessionToken) {
$service = new AuthService();
$dbStore = new DbTokenStore(app()->thin_builder);
$tokenSet = $dbStore->get($sessionToken);
if ($tokenSet && $tokenSet->refreshToken) {
try {
$service->getClient()->revokeToken($tokenSet->refreshToken, 'refresh_token');
} catch (\Throwable $e) {
logging()->warn('php:Auth', 'Token revoke failed during logout', [
'user_id' => $userId,
'message' => $e->getMessage(),
]);
}
}
$dbStore->forget($sessionToken);
}
if (session_status() === PHP_SESSION_NONE) {
@session_start();
}
unset(
$_SESSION['shserv_auth_token'],
$_SESSION['shserv_access_token'],
$_SESSION['shserv_user_id'],
$_SESSION['gauth_state'],
$_SESSION['gauth_pkce']
);
// 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();
logging()->info('php:Auth', 'Logout', ['user_id' => $userId]);
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);
}
logging()->trace('php:Auth', 'Auth me request', ['user_id' => $user['id']]);
$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,
]);
}
/**
* Check rate limit for auth endpoints. Returns error response if exceeded.
*/
private function checkAuthRateLimit(string $action): ?string
{
$limiter = new RateLimiter('shserv_auth_', 10, 60);
$clientIp = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
if (!$limiter->check($action . ':' . $clientIp)) {
logging()->warn('php:Auth', 'Auth rate limit exceeded', [
'action' => $action,
'client_ip' => $clientIp,
]);
return $this->utils()->response_error('rate_limit', [], [], 429);
}
return null;
}
/**
* POST /auth/refresh
* Refresh access token using stored refresh token.
*/
public function refresh()
{
$sessionToken = $_SESSION['shserv_auth_token'] ?? null;
if (!$sessionToken) {
$bearer = $this->get_bearer_token();
if ($bearer) {
$tb = app()->thin_builder;
$result = $tb->select('shserv_sessions', ['session_token'], [['access_token', '=', $bearer]]);
if ($result) {
$sessionToken = $result[0]['session_token'];
}
}
}
if (!$sessionToken) {
logging()->warn('php:Auth', 'Token refresh failed: no session', [
'client_ip' => $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0',
]);
return $this->utils()->response_error('not_found_any_sessions', [], [], 401);
}
$service = new AuthService();
$tokenSet = $service->refreshAccessToken($sessionToken);
if (!$tokenSet) {
logging()->warn('php:Auth', 'Token refresh failed: session expired', [
'session_token' => substr($sessionToken, 0, 8) . '...',
]);
return $this->utils()->response_error('session_expired', [], [], 401);
}
logging()->info('php:Auth', 'Token refresh successful', [
'expires_in' => $tokenSet->expiresIn,
]);
return $this->utils()->response_success([
'access_token' => $tokenSet->accessToken,
'expires_in' => $tokenSet->expiresIn,
]);
}
/**
* GET /auth/mobile-bridge
* Renders a page that extracts the token from session and redirects back to the mobile app.
* Used by the Capacitor mobile app after OAuth login.
*/
public function mobileBridge()
{
if (session_status() === PHP_SESSION_NONE) {
@session_start();
}
$sessionToken = $_SESSION['shserv_auth_token'] ?? null;
if (!$sessionToken) {
return $this->utils()->redirect('/auth/login');
}
$service = new AuthService();
$tokenSet = $service->refreshAccessToken($sessionToken);
if (!$tokenSet) {
return $this->utils()->redirect('/auth/login');
}
$token = $tokenSet->accessToken;
$expiresIn = $tokenSet->expiresIn;
$tokenEsc = htmlspecialchars($token, ENT_QUOTES, 'UTF-8');
$intentUrl = 'intent://auth/callback?token=' . $tokenEsc . '&expires_in=' . (int)$expiresIn . '#Intent;scheme=shserv;package=com.gnexus.shserv;end';
return '<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Smart Home — Authentication Complete</title>
<style>
:root {
--page: #0f172a;
--card: rgba(148, 163, 184, 0.06);
--border: rgba(148, 163, 184, 0.24);
--text-light: #f1f5f9;
--text-medium: #94a3b8;
--text-dark: #64748b;
--accent: #12b7f5;
--accent-rgb: 18, 183, 245;
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
* { box-sizing: border-box; }
body {
font-family: var(--font);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
margin: 0;
background: var(--page);
}
.card {
background: var(--card);
border: 2px solid var(--border);
border-left: 6px solid var(--accent);
padding: 34px 22px;
text-align: center;
max-width: 340px;
width: 90%;
border-radius: 0 8px 8px 0;
}
.logo {
width: 64px;
height: 64px;
margin: 0 auto 16px;
background: rgba(var(--accent-rgb), 0.15);
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
color: var(--accent);
}
h1 {
margin: 0 0 12px;
font-size: 18px;
font-weight: 600;
color: var(--text-light);
text-transform: uppercase;
letter-spacing: 0.08em;
}
p {
color: var(--text-medium);
margin: 0 0 26px;
font-size: 14px;
line-height: 1.5;
min-height: 42px;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 46px;
font-family: var(--font);
font-size: 14px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--accent);
text-decoration: none;
padding: 12px 22px;
border: 2px solid var(--accent);
border-left-width: 6px;
border-radius: 0 4px 4px 0;
transition: background-color .2s, color .2s;
cursor: pointer;
}
.btn:hover, .btn:active {
background: var(--accent);
color: var(--page);
}
.hidden { display: none; }
.spinner {
width: 20px;
height: 20px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 12px;
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>
</head>
<body>
<div class="card">
<div class="logo">✓</div>
<h1>Login successful</h1>
<p id="status"><span class="spinner"></span>Opening Smart Home…</p>
<a id="btn" class="btn hidden" href="' . $intentUrl . '">Open Smart Home App</a>
</div>
<script>
(function() {
var appUrl = "intent://auth/callback?token=" + encodeURIComponent("' . $tokenEsc . '") + "&expires_in=' . (int)$expiresIn . '#Intent;scheme=shserv;package=com.gnexus.shserv;end";
window.location.href = appUrl;
setTimeout(function() {
document.getElementById("status").innerHTML = "Tap below to continue back to the app";
document.getElementById("btn").classList.remove("hidden");
}, 1500);
})();
</script>
</body>
</html>';
}
}