Files
dsp/classes/OAuth.php
2026-01-29 14:31:48 +07:00

258 lines
8.4 KiB
PHP

<?php
/**
* Lightweight OAuth 2.0 data/access service for DSP -> 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);
}
}