From 15f94d99caecdf09da9e9071b4988cd91646e360 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 27 Apr 2026 14:02:30 +0200 Subject: [PATCH] Add Kick OAuth adapter --- app/config/oAuthProviders.php | 11 + app/init/models.php | 2 + src/Appwrite/Auth/OAuth2/Kick.php | 230 ++++++++++++++++++ .../Http/Project/OAuth2/Kick/Update.php | 45 ++++ .../Modules/Project/Services/Http.php | 2 + src/Appwrite/Utopia/Response.php | 1 + .../Utopia/Response/Model/OAuth2Kick.php | 43 ++++ 7 files changed, 334 insertions(+) create mode 100644 src/Appwrite/Auth/OAuth2/Kick.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Kick/Update.php create mode 100644 src/Appwrite/Utopia/Response/Model/OAuth2Kick.php diff --git a/app/config/oAuthProviders.php b/app/config/oAuthProviders.php index cda6459519..0dc2cb8b1e 100644 --- a/app/config/oAuthProviders.php +++ b/app/config/oAuthProviders.php @@ -200,6 +200,17 @@ return [ 'mock' => false, 'class' => 'Appwrite\\Auth\\OAuth2\\Google', ], + 'kick' => [ + 'name' => 'Kick', + 'developers' => 'https://docs.kick.com/', + 'icon' => 'icon-kick', + 'enabled' => true, + 'sandbox' => false, + 'form' => false, + 'beta' => false, + 'mock' => false, + 'class' => 'Appwrite\\Auth\\OAuth2\\Kick', + ], 'linkedin' => [ 'name' => 'LinkedIn', 'developers' => 'https://developer.linkedin.com/', diff --git a/app/init/models.php b/app/init/models.php index df2ebac150..c439bdf28f 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -123,6 +123,7 @@ use Appwrite\Utopia\Response\Model\OAuth2Figma; use Appwrite\Utopia\Response\Model\OAuth2GitHub; use Appwrite\Utopia\Response\Model\OAuth2Gitlab; use Appwrite\Utopia\Response\Model\OAuth2Google; +use Appwrite\Utopia\Response\Model\OAuth2Kick; use Appwrite\Utopia\Response\Model\OAuth2Linkedin; use Appwrite\Utopia\Response\Model\OAuth2Notion; use Appwrite\Utopia\Response\Model\OAuth2Oidc; @@ -421,6 +422,7 @@ Response::setModel(new OAuth2Authentik()); Response::setModel(new OAuth2Auth0()); Response::setModel(new OAuth2Oidc()); Response::setModel(new OAuth2Okta()); +Response::setModel(new OAuth2Kick()); Response::setModel(new OAuth2Apple()); Response::setModel(new PolicyPasswordDictionary()); Response::setModel(new PolicyPasswordHistory()); diff --git a/src/Appwrite/Auth/OAuth2/Kick.php b/src/Appwrite/Auth/OAuth2/Kick.php new file mode 100644 index 0000000000..85b447fcd8 --- /dev/null +++ b/src/Appwrite/Auth/OAuth2/Kick.php @@ -0,0 +1,230 @@ +state; + $state[self::PKCE_STATE_KEY] = $this->getPKCEVerifier(); + + return 'https://id.kick.com/oauth/authorize?' . \http_build_query([ + 'response_type' => 'code', + 'client_id' => $this->appID, + 'redirect_uri' => $this->callback, + 'scope' => \implode(' ', $this->getScopes()), + 'state' => \json_encode($state), + 'code_challenge' => $this->getPKCEChallenge(), + 'code_challenge_method' => 'S256', + ]); + } + + /** + * @param string $code + * + * @return array + */ + protected function getTokens(string $code): array + { + if (empty($this->tokens)) { + $headers = ['Content-Type: application/x-www-form-urlencoded']; + $this->tokens = \json_decode($this->request( + 'POST', + 'https://id.kick.com/oauth/token', + $headers, + \http_build_query([ + 'grant_type' => 'authorization_code', + 'client_id' => $this->appID, + 'client_secret' => $this->appSecret, + 'redirect_uri' => $this->callback, + 'code_verifier' => $this->getPKCEVerifier(), + 'code' => $code, + ]) + ), true); + } + + return $this->tokens; + } + + /** + * @param string $refreshToken + * + * @return array + */ + public function refreshTokens(string $refreshToken): array + { + $headers = ['Content-Type: application/x-www-form-urlencoded']; + $this->tokens = \json_decode($this->request( + 'POST', + 'https://id.kick.com/oauth/token', + $headers, + \http_build_query([ + 'grant_type' => 'refresh_token', + 'client_id' => $this->appID, + 'client_secret' => $this->appSecret, + 'refresh_token' => $refreshToken, + ]) + ), true); + + if (empty($this->tokens['refresh_token'])) { + $this->tokens['refresh_token'] = $refreshToken; + } + + return $this->tokens; + } + + /** + * @param string $accessToken + * + * @return string + */ + public function getUserID(string $accessToken): string + { + $user = $this->getUser($accessToken); + + return isset($user['user_id']) ? (string)$user['user_id'] : ''; + } + + /** + * @param string $accessToken + * + * @return string + */ + public function getUserEmail(string $accessToken): string + { + $user = $this->getUser($accessToken); + + return $user['email'] ?? ''; + } + + /** + * Check if the OAuth email is verified. + * + * Kick only returns an email when the user has granted the `user:read` + * scope and the account email is verified, so a non-empty email is + * treated as verified. + * + * @param string $accessToken + * + * @return bool + */ + public function isEmailVerified(string $accessToken): bool + { + return !empty($this->getUserEmail($accessToken)); + } + + /** + * @param string $accessToken + * + * @return string + */ + public function getUserName(string $accessToken): string + { + $user = $this->getUser($accessToken); + + return $user['name'] ?? ''; + } + + /** + * @param string $accessToken + * + * @return array + */ + protected function getUser(string $accessToken): array + { + if (empty($this->user)) { + $headers = ['Authorization: Bearer ' . $accessToken]; + $response = \json_decode($this->request( + 'GET', + 'https://api.kick.com/public/v1/users', + $headers + ), true); + + $this->user = $response['data'][0] ?? []; + } + + return $this->user; + } + + /** + * Extract the PKCE verifier from the state on the callback so the same + * value generated in getLoginURL() can be sent to the token endpoint. + * + * @param string $state + * + * @return array|null + */ + public function parseState(string $state): ?array + { + $parsed = \json_decode($state, true); + + if (!\is_array($parsed)) { + return null; + } + + $verifier = $parsed[self::PKCE_STATE_KEY] ?? null; + if (\is_string($verifier)) { + $this->pkceVerifier = $verifier; + } + + unset($parsed[self::PKCE_STATE_KEY]); + + return $parsed; + } + + private function getPKCEVerifier(): string + { + if ($this->pkceVerifier === '') { + $this->pkceVerifier = \rtrim(\strtr(\base64_encode(\random_bytes(64)), '+/', '-_'), '='); + } + + return $this->pkceVerifier; + } + + private function getPKCEChallenge(): string + { + return \rtrim(\strtr(\base64_encode(\hash('sha256', $this->getPKCEVerifier(), true)), '+/', '-_'), '='); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Kick/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Kick/Update.php new file mode 100644 index 0000000000..b5c126a08c --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Kick/Update.php @@ -0,0 +1,45 @@ +addAction(UpdateOAuth2Auth0::getName(), new UpdateOAuth2Auth0()); $this->addAction(UpdateOAuth2Oidc::getName(), new UpdateOAuth2Oidc()); $this->addAction(UpdateOAuth2Okta::getName(), new UpdateOAuth2Okta()); + $this->addAction(UpdateOAuth2Kick::getName(), new UpdateOAuth2Kick()); } } diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index 3d8902342f..1ac9054766 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -314,6 +314,7 @@ class Response extends SwooleResponse public const MODEL_OAUTH2_OIDC = 'oAuth2Oidc'; public const MODEL_OAUTH2_APPLE = 'oAuth2Apple'; public const MODEL_OAUTH2_OKTA = 'oAuth2Okta'; + public const MODEL_OAUTH2_KICK = 'oAuth2Kick'; // Health public const MODEL_HEALTH_STATUS = 'healthStatus'; diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Kick.php b/src/Appwrite/Utopia/Response/Model/OAuth2Kick.php new file mode 100644 index 0000000000..e4692ac6ea --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Kick.php @@ -0,0 +1,43 @@ +