JupyterHub integration. */ class OAuthService { private const AUTH_CODE_TTL = 600; // 10 minutes private const ACCESS_TOKEN_TTL = 3600; // 1 hour private const REFRESH_TOKEN_TTL = 2592000; // 30 days private PDO $pdo; public function __construct(PDO $pdo) { $this->pdo = $pdo; } public function getClient(string $clientId): ?array { $sql = "SELECT client_id, client_name, client_secret_hash, redirect_uris, allowed_scopes, is_confidential FROM dsp_oauth_clients WHERE client_id = :client_id AND is_revoked = 0"; $stmt = $this->pdo->prepare($sql); $stmt->execute([':client_id' => $clientId]); $client = $stmt->fetch(PDO::FETCH_ASSOC); return $client ?: null; } public function verifyClientSecret(array $client, string $candidate): bool { if (empty($client['client_secret_hash'])) { return $candidate === ''; } return password_verify($candidate, $client['client_secret_hash']); } public function isRedirectUriAllowed(array $client, string $redirectUri): bool { $allowed = array_filter(array_map('trim', preg_split('/[\s,]+/', (string) ($client['redirect_uris'] ?? '')))); if (empty($allowed)) { return false; } foreach ($allowed as $prefix) { if (stripos($redirectUri, $prefix) === 0) { return true; } } return false; } public function isScopeAllowed(array $client, ?string $requestedScope): bool { $requestedScope = trim((string) $requestedScope); if ($requestedScope === '') { return true; } $allowedScopes = array_filter(array_map('trim', explode(' ', (string) ($client['allowed_scopes'] ?? '')))); if (empty($allowedScopes)) { return true; } foreach (explode(' ', $requestedScope) as $scope) { if (!in_array($scope, $allowedScopes, true)) { return false; } } return true; } public function issueAuthorizationCode(string $clientId, int $personId, string $redirectUri, ?string $scope = null): array { $code = $this->generateToken(32); $codeHash = $this->hashToken($code); $expiresAt = time() + self::AUTH_CODE_TTL; $sql = "INSERT INTO dsp_oauth_auth_codes (code_hash, client_id, person_id, scope, redirect_uri, expires_at, created_at) VALUES (:code_hash, :client_id, :person_id, :scope, :redirect_uri, FROM_UNIXTIME(:expires_at), NOW())"; $stmt = $this->pdo->prepare($sql); $stmt->execute([ ':code_hash' => $codeHash, ':client_id' => $clientId, ':person_id' => $personId, ':scope' => $scope, ':redirect_uri' => $redirectUri, ':expires_at' => $expiresAt, ]); return [ 'code' => $code, 'expires_at' => $expiresAt, ]; } public function consumeAuthorizationCode(string $code, string $clientId): ?array { $codeHash = $this->hashToken($code); $sql = "SELECT code_hash, client_id, person_id, scope, redirect_uri, UNIX_TIMESTAMP(expires_at) AS expires_at FROM dsp_oauth_auth_codes WHERE code_hash = :code_hash"; $stmt = $this->pdo->prepare($sql); $stmt->execute([':code_hash' => $codeHash]); $record = $stmt->fetch(PDO::FETCH_ASSOC); if (!$record) { return null; } // Delete regardless of outcome $deleteStmt = $this->pdo->prepare("DELETE FROM dsp_oauth_auth_codes WHERE code_hash = :code_hash"); $deleteStmt->execute([':code_hash' => $codeHash]); if ((int) $record['expires_at'] < time()) { return null; } if ($record['client_id'] !== $clientId) { return null; } return $record; } public function issueTokens(string $clientId, int $personId, ?string $scope = null, bool $includeRefresh = true): array { $accessToken = $this->generateToken(43); $accessHash = $this->hashToken($accessToken); $accessExpiresAt = time() + self::ACCESS_TOKEN_TTL; $refreshToken = null; $refreshHash = null; $refreshExpiresAt = null; if ($includeRefresh) { $refreshToken = $this->generateToken(43); $refreshHash = $this->hashToken($refreshToken); $refreshExpiresAt = time() + self::REFRESH_TOKEN_TTL; } $sql = "INSERT INTO dsp_oauth_access_tokens (token_hash, client_id, person_id, scope, expires_at, refresh_token_hash, refresh_expires_at, created_at) VALUES (:token_hash, :client_id, :person_id, :scope, FROM_UNIXTIME(:expires_at), :refresh_hash, " . ($refreshExpiresAt ? "FROM_UNIXTIME(:refresh_expires_at)" : "NULL") . ", NOW())"; $stmt = $this->pdo->prepare($sql); $stmt->execute([ ':token_hash' => $accessHash, ':client_id' => $clientId, ':person_id' => $personId, ':scope' => $scope, ':expires_at' => $accessExpiresAt, ':refresh_hash' => $refreshHash, ':refresh_expires_at' => $refreshExpiresAt, ]); return [ 'access_token' => $accessToken, 'access_expires_at' => $accessExpiresAt, 'refresh_token' => $refreshToken, 'refresh_expires_at' => $refreshExpiresAt, 'token_type' => 'Bearer', 'scope' => $scope, ]; } public function exchangeRefreshToken(string $clientId, string $refreshToken): ?array { $refreshHash = $this->hashToken($refreshToken); $sql = "SELECT token_hash, client_id, person_id, scope, UNIX_TIMESTAMP(refresh_expires_at) AS refresh_expires_at FROM dsp_oauth_access_tokens WHERE refresh_token_hash = :refresh_hash AND is_revoked = 0"; $stmt = $this->pdo->prepare($sql); $stmt->execute([':refresh_hash' => $refreshHash]); $record = $stmt->fetch(PDO::FETCH_ASSOC); if (!$record) { return null; } if ($record['client_id'] !== $clientId) { return null; } if (!empty($record['refresh_expires_at']) && (int) $record['refresh_expires_at'] < time()) { $this->revokeTokenByHash($record['token_hash']); return null; } // Revoke old access token $this->revokeTokenByHash($record['token_hash']); // Issue new pair return $this->issueTokens($clientId, (int) $record['person_id'], $record['scope'], true); } public function getAccessToken(string $token): ?array { $hash = $this->hashToken($token); $sql = "SELECT token_hash, client_id, person_id, scope, UNIX_TIMESTAMP(expires_at) AS expires_at, refresh_token_hash, UNIX_TIMESTAMP(refresh_expires_at) AS refresh_expires_at FROM dsp_oauth_access_tokens WHERE token_hash = :hash AND is_revoked = 0"; $stmt = $this->pdo->prepare($sql); $stmt->execute([':hash' => $hash]); $record = $stmt->fetch(PDO::FETCH_ASSOC); if (!$record) { return null; } if ((int) $record['expires_at'] < time()) { $this->revokeTokenByHash($record['token_hash']); return null; } return $record; } public function revokeTokenByHash(string $hash): void { $sql = "UPDATE dsp_oauth_access_tokens SET is_revoked = 1, revoked_at = NOW() WHERE token_hash = :hash"; $stmt = $this->pdo->prepare($sql); $stmt->execute([':hash' => $hash]); } public function recordTokenUsage(string $hash): void { $sql = "UPDATE dsp_oauth_access_tokens SET last_used_at = NOW() WHERE token_hash = :hash"; $stmt = $this->pdo->prepare($sql); $stmt->execute([':hash' => $hash]); } private function generateToken(int $length): string { return rtrim(strtr(base64_encode(random_bytes($length)), '+/', '-_'), '='); } private function hashToken(string $token): string { return hash('sha256', $token); } }