grokAuth.test.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144
  1. // @vitest-environment node
  2. import {afterEach,beforeEach,expect,it,vi} from 'vitest'
  3. import {mkdtempSync,rmSync} from 'node:fs'
  4. import {tmpdir} from 'node:os'
  5. import {join} from 'node:path'
  6. import {createHash,randomUUID} from 'node:crypto'
  7. import Koa from 'koa'
  8. import {bodyParser} from '@koa/bodyparser'
  9. import request from 'supertest'
  10. import {WorkspaceStore} from '../../src/server/workspaceStore'
  11. import {WorkspaceNodes} from '../../src/server/workspaceGateway'
  12. import {loadServerConfig} from '../../src/server/config'
  13. import {GrokAuth} from '../../src/server/grokAuth'
  14. import {HttpError} from '../../src/server/errors'
  15. let home:string,store:WorkspaceStore,nodes:WorkspaceNodes,service:GrokAuth,version:number,active:boolean
  16. let exchanges:Map<string,{accessToken:string;refreshToken:string}>,calls:Array<{url:URL;init:RequestInit}>
  17. let oauth:(init:RequestInit)=>Promise<Response>,pollResponse:((url:URL)=>Promise<Response>)|undefined,verifyStatus:number
  18. const token=(subject='account-a',seconds=3600)=>'header.'+Buffer.from(JSON.stringify({sub:subject,exp:Math.floor(Date.now()/1000)+seconds,email:subject+'@example.test'})).toString('base64url')+'.signature'
  19. const auth={
  20. require:(ctx:any)=>({id:ctx.get('x-user')||'owner',role:ctx.get('x-user')==='member'?'user':'admin'}),
  21. requireAdmin:(ctx:any)=>{if(ctx.get('x-user')==='member')throw new HttpError(403,'仅限管理员','admin_required');return {id:ctx.get('x-user')||'owner',role:'admin'}},
  22. isUserActive:()=>active,isAdminActive:(owner:string)=>active&&owner!=='member',pushAuthorizationVersion:()=>version,
  23. } as any
  24. let fetcher:typeof fetch
  25. beforeEach(()=>{
  26. home=mkdtempSync(join(tmpdir(),'grok-auth-test-'));store=new WorkspaceStore(home);nodes=new WorkspaceNodes(store,loadServerConfig({HERMES_YAOYAO_HOME:home}),{} as any)
  27. version=1;active=true;exchanges=new Map();calls=[];pollResponse=undefined;verifyStatus=200
  28. oauth=async()=>Response.json({access_token:token(),refresh_token:'rotated-refresh-token'})
  29. fetcher=(async(input,init={})=>{const url=new URL(String(input));calls.push({url,init})
  30. if(url.pathname==='/auth/poll'){if(pollResponse)return pollResponse(url);const tokens=exchanges.get(url.searchParams.get('uuid')!);return tokens?Response.json(tokens):new Response('',{status:404})}
  31. if(url.pathname==='/oauth/token')return oauth(init)
  32. if(url.pathname.endsWith('/GetSandBoxRunState'))return Response.json({state:'SAND_BOX_RUN_STATE_RUNNING'},{status:verifyStatus})
  33. if(url.pathname.endsWith('/GetMe'))return Response.json({email:'authorized@example.test',firstName:'Test',lastName:'Account'})
  34. throw new Error('Unexpected auth request')
  35. }) as typeof fetch
  36. service=new GrokAuth(store,auth,nodes,{fetcher})
  37. })
  38. afterEach(()=>{service.close();nodes.close();store.close();rmSync(home,{recursive:true,force:true});vi.useRealTimers()})
  39. function begin(){return service.begin('owner',randomUUID()).attempt!}
  40. function accept(a:ReturnType<typeof begin>,subject='account-a',seconds=3600){exchanges.set(new URL(a.loginUrl!).searchParams.get('uuid')!,{accessToken:token(subject,seconds),refreshToken:'private-refresh-token'})}
  41. function stored(){return nodes.open<any>(store.require<any>('owner','grok-cloud','connection').sealed)}
  42. function app(){const a=new Koa();a.use(async(ctx,next)=>{try{await next()}catch(error:any){ctx.status=error.status??500;ctx.body={error:error.message,code:error.code}}});a.use(bodyParser());a.use(service.router().routes());return a.callback()}
  43. it('creates owner-bound PKCE challenges without sending the verifier or credentials to clients',async()=>{
  44. const a=begin(),url=new URL(a.loginUrl!),internal=store.require<any>('owner','grok-auth-attempt','current'),verifier=nodes.open<any>(internal.sealedVerifier).verifier
  45. expect(url.origin).toBe('https://cursor.com');expect(url.pathname).toBe('/loginDeepControl');expect(url.searchParams.get('redirectTarget')).toBe('cli')
  46. expect(url.searchParams.get('challenge')).toBe(createHash('sha256').update(verifier).digest('base64url'))
  47. expect(JSON.stringify(a)).not.toContain(verifier);expect(JSON.stringify(internal)).not.toContain(verifier)
  48. await service.poll('owner',a.id);expect(service.snapshot('owner').attempt?.status).toBe('pending')
  49. accept(a);const result=await service.poll('owner',a.id)
  50. expect(result).toMatchObject({status:'connected',automaticRefresh:true,account:{email:'authorized@example.test',name:'Test Account'},attempt:{status:'complete'}})
  51. expect(JSON.stringify(result)).not.toContain('private-refresh-token');expect(JSON.stringify(result)).not.toContain('accessToken')
  52. expect(JSON.stringify(store.list('owner','grok-cloud'))).not.toContain('private-refresh-token')
  53. expect(stored().refreshToken).toBe('private-refresh-token')
  54. expect(store.require<any>('owner','grok-auth-attempt','current').sealedVerifier).toBeUndefined()
  55. })
  56. it('makes start retries idempotent and refuses a competing pending attempt',()=>{
  57. const id=randomUUID(),a=service.begin('owner',id)
  58. expect(service.begin('owner',id).attempt?.id).toBe(a.attempt?.id)
  59. expect(()=>service.begin('owner',randomUUID())).toThrow('已有进行中')
  60. expect(calls).toEqual([])
  61. })
  62. it('does not let a late browser result restore a cancelled or replaced login',async()=>{
  63. const a=begin();let deliver!:(r:Response)=>void
  64. pollResponse=()=>new Promise(resolve=>{deliver=resolve})
  65. const polling=service.poll('owner',a.id);await Promise.resolve()
  66. service.cancel('owner',a.id);const next=begin()
  67. deliver(Response.json({accessToken:token(),refreshToken:'late-refresh'}));await polling
  68. expect(service.configured('owner')).toBe(false);expect(service.snapshot('owner').attempt?.id).toBe(next.id)
  69. })
  70. it('expires links, removes verifiers and recovers an unexpired authorization after server restart',async()=>{
  71. vi.useFakeTimers();const a=begin();service.close();service=new GrokAuth(store,auth,nodes,{fetcher});accept(a)
  72. expect((await service.poll('owner',a.id)).status).toBe('connected')
  73. const b=begin();vi.advanceTimersByTime(10*60000+1)
  74. expect(service.snapshot('owner').attempt?.status).toBe('expired');expect(store.require<any>('owner','grok-auth-attempt','current').sealedVerifier).toBeUndefined()
  75. expect(begin().id).not.toBe(b.id)
  76. })
  77. it('refuses other owners and non-admin account linking',async()=>{
  78. const a=begin()
  79. await expect(service.poll('other',a.id)).rejects.toThrow('不存在')
  80. expect(()=>service.cancel('other',a.id)).toThrow('不存在')
  81. expect((await request(app()).post('/api/app/grok-cloud/auth/start').set('x-user','member').send({requestId:randomUUID()})).status).toBe(403)
  82. expect((await request(app()).get('/api/app/grok-cloud/auth').set('x-user','other')).body.configured).toBe(false)
  83. })
  84. it('cannot finish an authorization after local account permissions have changed',async()=>{
  85. const a=begin();accept(a);version++
  86. expect((await service.poll('owner',a.id)).attempt?.status).toBe('failed')
  87. expect(service.configured('owner')).toBe(false)
  88. })
  89. it('coalesces token refreshes and persists rotation without exposing tokens',async()=>{
  90. const a=begin();accept(a,'account-a',60);await service.poll('owner',a.id)
  91. const results=await Promise.all([service.credentials('owner'),service.credentials('owner'),service.credentials('owner')])
  92. expect(calls.filter(c=>c.url.pathname==='/oauth/token')).toHaveLength(1)
  93. expect(new Set(results.map(r=>r.accessToken)).size).toBe(1)
  94. expect(stored().refreshToken).toBe('rotated-refresh-token')
  95. const payload=JSON.parse(String(calls.find(c=>c.url.pathname==='/oauth/token')!.init.body))
  96. expect(payload).toMatchObject({client_id:'KbZUR41cY7W6zRSdpSUJ7I7mLYBKOCmB',grant_type:'refresh_token',refresh_token:'private-refresh-token'})
  97. expect(JSON.stringify(service.snapshot('owner'))).not.toContain('refreshToken')
  98. })
  99. it('keeps usable credentials on transient refresh failure and backs off retries',async()=>{
  100. const a=begin();accept(a,'account-a',60);await service.poll('owner',a.id)
  101. oauth=async()=>Response.json({error:'unavailable'},{status:503});const original=stored().accessToken
  102. expect((await service.credentials('owner')).accessToken).toBe(original)
  103. expect((await service.credentials('owner')).accessToken).toBe(original)
  104. expect(calls.filter(c=>c.url.pathname==='/oauth/token')).toHaveLength(1)
  105. expect(service.snapshot('owner')).toMatchObject({configured:true,status:'connected',automaticRefresh:true})
  106. })
  107. it('marks revoked authorization as requiring login without turning cloud auto selection off',async()=>{
  108. const a=begin();accept(a,'account-a',60);await service.poll('owner',a.id)
  109. oauth=async()=>Response.json({shouldLogout:true,error:'invalid_grant'})
  110. await expect(service.credentials('owner')).rejects.toMatchObject({status:409,code:'grok_login_required'})
  111. expect(service.snapshot('owner')).toMatchObject({configured:true,status:'reauthorization-required',automaticRefresh:false})
  112. expect(stored().accessToken).toBe('');expect(stored().refreshToken).toBeUndefined()
  113. })
  114. it('never restores a connection from a refresh response arriving after disconnect',async()=>{
  115. const a=begin();accept(a,'account-a',60);await service.poll('owner',a.id)
  116. let deliver!:(r:Response)=>void;oauth=()=>new Promise(resolve=>{deliver=resolve})
  117. const refreshing=service.credentials('owner');const rejection=expect(refreshing).rejects.toMatchObject({code:'grok_auth_superseded'});await Promise.resolve()
  118. service.disconnect('owner');deliver(Response.json({access_token:token(),refresh_token:'late-refresh'}));await rejection
  119. expect(service.snapshot('owner').status).toBe('disconnected');expect(store.list('_system','grok-account')).toEqual([])
  120. })
  121. it('refuses a different account during reauthorization and preserves the original connection',async()=>{
  122. const first=begin();accept(first);await service.poll('owner',first.id);const old=stored().account
  123. const a=begin();accept(a,'different-account');const result=await service.poll('owner',a.id)
  124. expect(result.attempt?.status).toBe('failed');expect(result.attempt?.error).toContain('当前已连接')
  125. expect(stored().account).toBe(old)
  126. })
  127. it('validates new credentials before replacing an existing connection',async()=>{
  128. await service.configure('owner',token(),'0.47.0','refresh-a');const old=store.require<any>('owner','grok-cloud','connection').sealed
  129. verifyStatus=403;await expect(service.configure('owner',token('second'),'0.47.0','refresh-b')).rejects.toThrow('未接受')
  130. expect(store.require<any>('owner','grok-cloud','connection').sealed).toBe(old)
  131. })