@@ -128,6 +128,62 @@ describe('OAuth2Adapter', () => {
128128 adapter . validateAuthData ( authData , null , validOptions )
129129 ) . toBeRejectedWithError ( 'OAuth2 access token is invalid for this user.' ) ;
130130 } ) ;
131+
132+ it ( 'should default useridField to sub and reject mismatched user ID' , async ( ) => {
133+ const adapterNoUseridField = new OAuth2Adapter . constructor ( ) ;
134+ adapterNoUseridField . validateOptions ( {
135+ tokenIntrospectionEndpointUrl : 'https://provider.example.com/introspect' ,
136+ } ) ;
137+
138+ const authData = { id : 'victim-user-id' , access_token : 'attackerToken' } ;
139+ const mockResponse = {
140+ active : true ,
141+ sub : 'attacker-user-id' ,
142+ } ;
143+
144+ mockFetch ( [
145+ {
146+ url : 'https://provider.example.com/introspect' ,
147+ method : 'POST' ,
148+ response : {
149+ ok : true ,
150+ json : ( ) => Promise . resolve ( mockResponse ) ,
151+ } ,
152+ } ,
153+ ] ) ;
154+
155+ await expectAsync (
156+ adapterNoUseridField . validateAuthData ( authData , null , { } )
157+ ) . toBeRejectedWithError ( 'OAuth2 access token is invalid for this user.' ) ;
158+ } ) ;
159+
160+ it ( 'should default useridField to sub and accept matching user ID' , async ( ) => {
161+ const adapterNoUseridField = new OAuth2Adapter . constructor ( ) ;
162+ adapterNoUseridField . validateOptions ( {
163+ tokenIntrospectionEndpointUrl : 'https://provider.example.com/introspect' ,
164+ } ) ;
165+
166+ const authData = { id : 'user-id' , access_token : 'validAccessToken' } ;
167+ const mockResponse = {
168+ active : true ,
169+ sub : 'user-id' ,
170+ } ;
171+
172+ mockFetch ( [
173+ {
174+ url : 'https://provider.example.com/introspect' ,
175+ method : 'POST' ,
176+ response : {
177+ ok : true ,
178+ json : ( ) => Promise . resolve ( mockResponse ) ,
179+ } ,
180+ } ,
181+ ] ) ;
182+
183+ await expectAsync (
184+ adapterNoUseridField . validateAuthData ( authData , null , { } )
185+ ) . toBeResolvedTo ( { } ) ;
186+ } ) ;
131187 } ) ;
132188
133189 describe ( 'requestTokenInfo' , ( ) => {
@@ -281,6 +337,57 @@ describe('OAuth2Adapter', () => {
281337 ) ;
282338 } ) ;
283339
340+ it ( 'should reject account takeover when useridField is omitted and attacker uses their own token with victim ID' , async ( ) => {
341+ await reconfigureServer ( {
342+ auth : {
343+ mockOauth : {
344+ tokenIntrospectionEndpointUrl : 'https://provider.example.com/introspect' ,
345+ authorizationHeader : 'Bearer validAuthToken' ,
346+ oauth2 : true ,
347+ } ,
348+ } ,
349+ } ) ;
350+
351+ // Victim signs up with their own valid token
352+ mockFetch ( [
353+ {
354+ url : 'https://provider.example.com/introspect' ,
355+ method : 'POST' ,
356+ response : {
357+ ok : true ,
358+ json : ( ) => Promise . resolve ( {
359+ active : true ,
360+ sub : 'victim-sub-id' ,
361+ } ) ,
362+ } ,
363+ } ,
364+ ] ) ;
365+
366+ const victimAuthData = { access_token : 'victimToken' , id : 'victim-sub-id' } ;
367+ const victim = await Parse . User . logInWith ( 'mockOauth' , { authData : victimAuthData } ) ;
368+ expect ( victim . id ) . toBeDefined ( ) ;
369+
370+ // Attacker tries to log in with their own valid token but claims victim's ID
371+ mockFetch ( [
372+ {
373+ url : 'https://provider.example.com/introspect' ,
374+ method : 'POST' ,
375+ response : {
376+ ok : true ,
377+ json : ( ) => Promise . resolve ( {
378+ active : true ,
379+ sub : 'attacker-sub-id' ,
380+ } ) ,
381+ } ,
382+ } ,
383+ ] ) ;
384+
385+ const attackerAuthData = { access_token : 'attackerToken' , id : 'victim-sub-id' } ;
386+ await expectAsync ( Parse . User . logInWith ( 'mockOauth' , { authData : attackerAuthData } ) ) . toBeRejectedWith (
387+ new Parse . Error ( Parse . Error . OBJECT_NOT_FOUND , 'OAuth2 access token is invalid for this user.' )
388+ ) ;
389+ } ) ;
390+
284391 it ( 'should handle error when token introspection endpoint is missing' , async ( ) => {
285392 await reconfigureServer ( {
286393 auth : {
0 commit comments