true, 'application_id' => 'zyt', 'revocation_secret' => $secret, ]))->apply($eventBody, 'sha256=invalid', 'event-1'); throw new RuntimeException('invalid webhook signature must be rejected'); } catch (IamHubException $error) { iamExpect($error->httpStatus() === 401, 'invalid webhook signature must return HTTP 401'); } $privateKey = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]); iamExpect($privateKey instanceof OpenSSLAsymmetricKey, 'test RSA key must be created'); $details = openssl_pkey_get_details($privateKey); iamExpect(is_array($details) && isset($details['rsa']['n'], $details['rsa']['e']), 'test RSA public key must be readable'); $accessToken = 'access-token'; $nonce = 'nonce-value'; $issuer = 'https://iam.example.com/realms/iam-hub'; $clientId = 'zyt'; $payload = [ 'iss' => $issuer, 'aud' => $clientId, 'sub' => 'employee-subject', 'iat' => time() - 1, 'exp' => time() + 300, 'nonce' => $nonce, 'at_hash' => IamSecurity::base64UrlEncode(substr(hash('sha256', $accessToken, true), 0, 16)), ]; $jwt = iamJwt(['alg' => 'RS256', 'kid' => 'test-key'], $payload, $privateKey); $jwks = ['keys' => [[ 'kty' => 'RSA', 'kid' => 'test-key', 'n' => IamSecurity::base64UrlEncode($details['rsa']['n']), 'e' => IamSecurity::base64UrlEncode($details['rsa']['e']), ]]]; $claims = (new IamJwtVerifier($issuer, $clientId))->verify($jwt, $accessToken, $nonce, $jwks); iamExpect($claims['sub'] === 'employee-subject', 'signed OIDC subject must be returned'); try { (new IamJwtVerifier($issuer, $clientId))->verify($jwt, $accessToken, 'wrong-nonce', $jwks); throw new RuntimeException('wrong nonce must be rejected'); } catch (IamHubException $error) { iamExpect(str_contains($error->getMessage(), 'nonce'), 'wrong nonce rejection must be explicit'); } $oidc = new IamOidcService([ 'oidc_issuer' => $issuer, 'oidc_client_id' => $clientId, 'oidc_redirect_uri' => 'https://zyt.example.com/adminapi/login/iamCallback', ]); $authorizationUrl = $oidc->authorizationUrl('state-value', $nonce, 'challenge-value'); iamExpect(str_contains($authorizationUrl, 'code_challenge_method=S256'), 'authorization must require PKCE S256'); iamExpect(str_contains($authorizationUrl, 'nonce=nonce-value'), 'authorization must carry nonce'); $requests = []; $http = new IamHttpClient(function (string $method, string $url, array $headers, ?string $body) use (&$requests): array { $requests[] = compact('method', 'url', 'headers', 'body'); if ($method === 'GET') { return ['status' => 200, 'body' => ['employee' => ['oidcSubject' => 'employee-subject']]]; } return ['status' => 201, 'body' => ['id' => 'binding-1']]; }); $hub = new IamHubClient([ 'base_url' => 'http://iam-hub:8080', 'application_id' => 'zyt', 'application_token' => 'application-token', ], $http); $hub->provisioningContext('employee-subject'); $hub->bindAccount('employee-subject', 42); iamExpect(count($requests) === 2, 'provisioning and binding must both call IAM Hub'); iamExpect(str_contains($requests[0]['url'], '/applications/zyt/employees/employee-subject/provisioning-context'), 'provisioning path must be application scoped'); iamExpect(str_contains(implode('\n', $requests[1]['headers']), 'Idempotency-Key:'), 'binding must be idempotent'); iamExpect( IamAdminIdentityService::stableAccount('employee-subject') === IamAdminIdentityService::stableAccount('employee-subject'), 'lazy account mapping must be stable' ); $root = dirname(__DIR__, 2); $sources = [ 'controller' => $root . '/server/app/adminapi/controller/LoginController.php', 'revocation' => $root . '/server/app/adminapi/controller/IamController.php', 'validation' => $root . '/server/app/adminapi/validate/LoginValidate.php', 'migration' => $root . '/server/sql/1.9.20260908/add_iam_hub_admin_identity.sql', 'frontend' => $root . '/admin/src/views/account/login.vue', 'store' => $root . '/admin/src/stores/modules/user.ts', ]; foreach ($sources as $name => $path) { $source = file_get_contents($path); iamExpect(is_string($source), "{$name} source must be readable"); $sources[$name] = $source; } foreach (['iamStart', 'iamCallback', 'iamExchange', '#iam_code='] as $needle) { iamExpect(str_contains($sources['controller'], $needle), "login controller must contain {$needle}"); } iamExpect(str_contains($sources['revocation'], "response('', 204)"), 'successful revocation must return HTTP 204'); iamExpect(str_contains($sources['validation'], '请使用公司员工统一登录'), 'IAM-managed local password login must be disabled'); foreach (['iam_subject', 'iam_managed', 'iam_revoked_at', 'uk_iam_revocation_event_id'] as $needle) { iamExpect(str_contains($sources['migration'], $needle), "migration must contain {$needle}"); } foreach (['公司员工统一登录', 'window.location.hash', 'iam_code', 'iam_error'] as $needle) { iamExpect(str_contains($sources['frontend'], $needle), "frontend callback must contain {$needle}"); } iamExpect(str_contains($sources['store'], 'iamHubExchange'), 'frontend store must consume the one-time exchange code'); echo "IAM_HUB_ZYT_INTEGRATION_TEST=PASS\n";