configuredString('dingtalk.client_id') !== '' && $this->configuredString('dingtalk.client_secret') !== '' && $this->configuredString('dingtalk.redirect_uri') !== ''; } public function authorizationUrl(string $state): string { $this->ensureConfigured(); return rtrim($this->configuredString('dingtalk.authorize_url'), '?') . '?' . http_build_query([ 'redirect_uri' => $this->configuredString('dingtalk.redirect_uri'), 'response_type' => 'code', 'client_id' => $this->configuredString('dingtalk.client_id'), 'scope' => 'openid', 'prompt' => 'consent', 'state' => $state, ], '', '&', PHP_QUERY_RFC3986); } /** * @return array{access_token: string, expires_in: int|null} */ public function exchangeAuthorizationCode(string $code): array { $this->ensureConfigured(); try { $response = Http::asJson() ->acceptJson() ->timeout($this->timeout()) ->post($this->configuredString('dingtalk.token_url'), [ 'clientId' => $this->configuredString('dingtalk.client_id'), 'clientSecret' => $this->configuredString('dingtalk.client_secret'), 'code' => $code, 'grantType' => 'authorization_code', ]) ->throw(); } catch (RequestException $exception) { throw new RuntimeException('DingTalk token exchange failed.', 0, $exception); } $accessToken = trim((string) $response->json('accessToken')); if ($accessToken === '') { throw new RuntimeException('DingTalk token response did not contain an access token.'); } return [ 'access_token' => $accessToken, 'expires_in' => is_numeric($response->json('expireIn')) ? (int) $response->json('expireIn') : null, ]; } /** @return array{union_id: string, open_id: string|null, nickname: string|null} */ public function currentUser(string $accessToken): array { try { $response = Http::acceptJson() ->withHeaders(['x-acs-dingtalk-access-token' => $accessToken]) ->timeout($this->timeout()) ->get($this->configuredString('dingtalk.user_url')) ->throw(); } catch (RequestException $exception) { throw new RuntimeException('DingTalk user lookup failed.', 0, $exception); } $unionId = trim((string) $response->json('unionId')); if ($unionId === '') { throw new RuntimeException('DingTalk user response did not contain a union id.'); } return [ 'union_id' => $unionId, 'open_id' => $this->nullableString($response->json('openId')), 'nickname' => $this->nullableString($response->json('nick')), ]; } /** * Resolve a user through the enterprise application. A personal DingTalk * account is not considered trusted unless the enterprise application can * resolve it to an active internal user. * * @return array{ * union_id: string, * open_id: string|null, * dingtalk_user_id: string, * nickname: string|null, * email: string|null, * mobile: string|null, * active: bool * } */ public function enterpriseUser(array $oauthProfile): array { $unionId = trim((string) ($oauthProfile['union_id'] ?? '')); if ($unionId === '') { throw new RuntimeException('DingTalk enterprise lookup requires a union id.'); } $appToken = $this->appAccessToken(); try { $userIdResponse = Http::asJson() ->acceptJson() ->timeout($this->timeout()) ->withQueryParameters(['access_token' => $appToken]) ->post($this->configuredString('dingtalk.user_by_union_id_url'), ['unionid' => $unionId]) ->throw(); } catch (RequestException $exception) { throw new RuntimeException('DingTalk enterprise user lookup failed.', 0, $exception); } $userId = trim((string) $userIdResponse->json('result.userid')); if ((int) $userIdResponse->json('errcode', -1) !== 0 || $userId === '') { throw new RuntimeException('DingTalk identity is not an enterprise user.'); } try { $detailResponse = Http::asJson() ->acceptJson() ->timeout($this->timeout()) ->withQueryParameters(['access_token' => $appToken]) ->post($this->configuredString('dingtalk.user_detail_url'), ['userid' => $userId]) ->throw(); } catch (RequestException $exception) { throw new RuntimeException('DingTalk enterprise user detail lookup failed.', 0, $exception); } $detail = $detailResponse->json('result'); if ((int) $detailResponse->json('errcode', -1) !== 0 || ! is_array($detail)) { throw new RuntimeException('DingTalk enterprise user detail was unavailable.'); } $detailUserId = trim((string) ($detail['userid'] ?? '')); if ($detailUserId !== '' && ! hash_equals($userId, $detailUserId)) { throw new RuntimeException('DingTalk enterprise user identity was inconsistent.'); } $active = filter_var($detail['active'] ?? null, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE); return [ 'union_id' => $unionId, 'open_id' => $oauthProfile['open_id'] ?? null, 'dingtalk_user_id' => $userId, 'nickname' => $this->nullableString($detail['name'] ?? ($oauthProfile['nickname'] ?? null)), 'email' => $this->nullableString($detail['email'] ?? null), 'mobile' => $this->nullableString($detail['mobile'] ?? null), 'active' => $active === true, ]; } protected function appAccessToken(): string { try { $response = Http::acceptJson() ->timeout($this->timeout()) ->get($this->configuredString('dingtalk.app_token_url'), [ 'appkey' => $this->configuredString('dingtalk.client_id'), 'appsecret' => $this->configuredString('dingtalk.client_secret'), ]) ->throw(); } catch (RequestException $exception) { throw new RuntimeException('DingTalk application token lookup failed.', 0, $exception); } $token = trim((string) $response->json('access_token')); if ((int) $response->json('errcode', -1) !== 0 || $token === '') { throw new RuntimeException('DingTalk application token was unavailable.'); } return $token; } protected function ensureConfigured(): void { if (! $this->isConfigured()) { throw new RuntimeException('DingTalk login is not configured.'); } } protected function configuredString(string $key): string { return trim((string) config($key, '')); } protected function timeout(): int { return max(3, min(30, (int) config('dingtalk.timeout', 10))); } protected function nullableString(mixed $value): ?string { $value = trim((string) $value); return $value === '' ? null : $value; } }