getMessage()===$code,$code.' got '.$e->getMessage());return;}throw new RuntimeException('accepted '.$code); } $app=new think\App(dirname(__DIR__).'/');think\Container::setInstance($app);$file=tempnam(sys_get_temp_dir(),'zyt-target-'); $app->config->set(['default'=>'fixture','connections'=>['fixture'=>['type'=>'sqlite','database'=>$file,'prefix'=>'','fields_strict'=>true]]],'database'); $db=new think\DbManager();$db->setConfig($app->config->get('database'));$app->instance('think\Db',$db); $config=['v2_enabled'=>true,'v2_management_enabled'=>true,'v2_launch_enabled'=>true,'api_url'=>'https://iam.example.test/api','public_url'=>'https://zyt.example.test','erp_url'=>'https://erp.example.test','application_id'=>'zyt','v2_token'=>str_repeat('v',32), 'v2_sources'=>['iam-admin'=>'https://iam.example.test/admin/iam/launch/continue','iam-portal'=>'https://iam.example.test/portal/launch/continue']];$app->config->set($config,'iam'); try { $ini=parse_ini_string('[IAM]'.PHP_EOL.'V2_SOURCES_JSON = '.json_encode($config['v2_sources']),true,INI_SCANNER_RAW); targetOK(json_decode($ini['IAM']['V2_SOURCES_JSON'],true)===$config['v2_sources'],'documented fixed source mapping parses through ThinkPHP INI scanner'); foreach([ 'CREATE TABLE admin (id INTEGER PRIMARY KEY,account TEXT,name TEXT,password TEXT,disable INTEGER,root INTEGER,delete_time INTEGER)', 'CREATE TABLE admin_session (id INTEGER PRIMARY KEY,admin_id INTEGER,token TEXT,expire_time INTEGER)', 'CREATE TABLE iam_v2_source_session (session_id TEXT PRIMARY KEY,admin_id INTEGER,business_session_id INTEGER,token_hash TEXT,account_stamp TEXT,auth_method TEXT,auth_time INTEGER,version INTEGER,revoked INTEGER,binding_version INTEGER,employee_id TEXT,expires_at INTEGER)', 'CREATE TABLE iam_v2_flow (flow_key TEXT PRIMARY KEY,kind TEXT,payload TEXT,expires_at INTEGER,consumed INTEGER)', 'CREATE TABLE iam_v2_binding (admin_id INTEGER PRIMARY KEY,employee_id TEXT,subject TEXT,state TEXT,version INTEGER,payload TEXT,checked_at INTEGER)', 'CREATE TABLE iam_v2_target_session (token_hash TEXT PRIMARY KEY,admin_id INTEGER,employee_id TEXT,source_application_id TEXT,source_binding_version INTEGER,checked_at INTEGER)' ]as$sql)Db::execute($sql); Db::name('admin')->insertAll([['id'=>1,'account'=>'other','name'=>'Other','password'=>'synthetic','disable'=>0,'root'=>0],['id'=>2,'account'=>'employee','name'=>'Employee','password'=>'synthetic','disable'=>0,'root'=>0]]); Db::name('admin_session')->insertAll([['id'=>1,'admin_id'=>1,'token'=>'token-other','expire_time'=>time()+3600],['id'=>2,'admin_id'=>2,'token'=>'token-matching','expire_time'=>time()+3600]]); $count=0;$tx=[];$evil=false;$exchangeCalls=0;$resolveCalls=0;$loginCalls=0;$source='iam-admin';$authTime=time()-20; $employee=['id'=>'emp-fixture','status'=>'active','oidcSubject'=>'subject-fixture'];$binding=['id'=>'binding','applicationId'=>'zyt','externalAccountId'=>'2','employeeId'=>'emp-fixture','state'=>'verified','version'=>1]; $client=new IamV2Client($config,function($method,$url,$headers,$body)use(&$count,&$tx,&$evil,&$exchangeCalls,&$source,$employee,$binding,$authTime){ $b=json_decode($body,true); if(str_ends_with($url,'/launch-transactions')){$id='tx-'.(++$count);$source=$b['sourceApplicationId'];$tx[$id]=$b;return ['status'=>200,'body'=>json_encode(['transactionId'=>$id,'sourceContinueUrl'=>($evil?'https://evil.example.test': 'https://iam.example.test').($source==='iam-admin'?'/admin/iam/launch/continue':'/portal/launch/continue').'?transaction_id='.$id,'expiresAt'=>gmdate(DATE_RFC3339,time()+300)])];} if(str_ends_with($url,'/launch-grants/exchange')){$exchangeCalls++;targetOK(rtrim(strtr(base64_encode(hash('sha256',$b['codeVerifier'],true)),'+/','-_'),'=')===$tx[$b['transactionId']]['codeChallenge'],'target PKCE is consistent');return ['status'=>200,'body'=>json_encode(['employee'=>$employee,'binding'=>$binding,'access'=>['allowed'=>true],'authentication'=>['sourceApplicationId'=>$source,'sessionId'=>'native-source-session','sessionVersion'=>1,'authMethod'=>'password','authTime'=>gmdate(DATE_RFC3339,$authTime),'sourceBindingVersion'=>0]])];} if(str_contains($url,'/bindings/by-external/'))return ['status'=>200,'body'=>json_encode(['employee'=>$employee,'binding'=>$binding,'access'=>['allowed'=>true]])]; throw new RuntimeException('UNEXPECTED_NETWORK'); }); $target=new IamV2Target($client,function($result)use(&$resolveCalls){$resolveCalls++;return 2;},function($id,$auth)use(&$loginCalls){$loginCalls++;IamV2Source::enroll('token-matching','iam_launch',false,IamV2Target::authTime($auth['authTime']));return ['token'=>'token-matching','is_paw'=>1,'need_bind_work_wechat'=>false];}); $intent=str_repeat('i',43);targetReject(fn()=>$target->prepare('zmxy-erp',$intent,''),'NOT_AUTHORIZED');targetReject(fn()=>$target->prepare('iam-admin',$intent,'invalid'),'REAUTH_REQUIRED'); $evil=true;targetReject(fn()=>$target->prepare('iam-admin',$intent,''),'INVALID_FIXED_URL');$evil=false; $start=$target->prepare('iam-admin',$intent,'');parse_str(parse_url($start['url'],PHP_URL_QUERY),$q);targetOK($q['intent']===$intent,'intent preserved in fixed continuation');$id=$q['transaction_id']; targetReject(fn()=>$target->callback($id,str_repeat('g',43),str_repeat('f',64),'https://iam.example.test'),'ACCOUNT_MISMATCH'); targetReject(fn()=>$target->callback($id,str_repeat('g',43),$start['browser'],'https://evil.example.test'),'INVALID_ORIGIN'); $ticket=$target->callback($id,str_repeat('g',43),$start['browser'],'https://iam.example.test');targetOK($resolveCalls===1,'same-identity resolver called once'); targetReject(fn()=>$target->callback($id,str_repeat('g',43),$start['browser'],'https://iam.example.test'),'TRANSACTION_EXPIRED'); targetReject(fn()=>$target->exchange($ticket['ticket'],$start['browser'],'token-other'),'ACCOUNT_MISMATCH');targetOK($loginCalls===0,'midflow account change does not issue token'); $session=IamV2Source::enroll('token-other','password',false); targetReject(fn()=>$target->exchange($ticket['ticket'],$start['browser'],'',$session['session_id']),'ACCOUNT_MISMATCH'); $result=$target->exchange($ticket['ticket'],$start['browser'],'token-matching');targetOK($result['token']==='token-matching'&&$loginCalls===1,'IAM local admin grant without KC cookie establishes target login'); $local=Db::name('iam_v2_source_session')->where('auth_method','iam_launch')->find();targetOK((int)$local['auth_time']===$authTime,'original RFC3339 authentication age preserved'); targetReject(fn()=>$target->exchange($ticket['ticket'],$start['browser'],'token-matching'),'TRANSACTION_EXPIRED'); $other=$target->prepare('iam-portal',$intent,'token-other');parse_str(parse_url($other['url'],PHP_URL_QUERY),$q); targetReject(fn()=>$target->callback($q['transaction_id'],str_repeat('g',43),$other['browser'],'https://iam.example.test'),'ACCOUNT_MISMATCH');targetOK($resolveCalls===1,'preexisting mismatch rejects before local auto-provision'); $selection=IamV2Manager::retrySelection(['indexes'=>[0,2],'items'=>[['status'=>'succeeded','externalAccountId'=>'1','kind'=>'bind'],['status'=>'failed','externalAccountId'=>'2','kind'=>'bind']]],[1]); targetOK($selection['indexes']===[2] && $selection['targets']===[2],'job item position translated to original noncontiguous preview index'); targetReject(fn()=>IamV2Target::authTime(gmdate(DATE_RFC3339,time()+120)),'INVALID_AUTHENTICATION'); echo "PASS IamV2TargetTest: native IAM admin/portal to ZYT without KC cookie, strict source allowlist, fixed continuation+intent, PKCE, browser/origin/replay checks, existing and midflow account mismatch, no duplicate provisioning, RFC3339 age, target lineage, noncontiguous retry index\n"; } finally {@unlink($file);}