Skip to content

Commit 9cfd06e

Browse files
authored
fix: Parse Server OAuth2 authentication adapter account takeover via identity spoofing ([GHSA-fr88-w35c-r596](GHSA-fr88-w35c-r596)) (#10145)
1 parent 6576a19 commit 9cfd06e

File tree

2 files changed

+109
-2
lines changed

2 files changed

+109
-2
lines changed

spec/Adapters/Auth/oauth2.spec.js

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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: {

src/Adapters/Auth/oauth2.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* @param {Object} options - The adapter configuration options.
66
* @param {string} options.tokenIntrospectionEndpointUrl - The URL of the token introspection endpoint. Required.
77
* @param {boolean} options.oauth2 - Indicates that the request should be handled by the OAuth2 adapter. Required.
8-
* @param {string} [options.useridField] - The field in the introspection response that contains the user ID. Optional.
8+
* @param {string} [options.useridField='sub'] - The field in the introspection response that contains the user ID. Defaults to `sub` per RFC 7662.
99
* @param {string} [options.appidField] - The field in the introspection response that contains the app ID. Optional.
1010
* @param {string[]} [options.appIds] - List of allowed app IDs. Required if `appidField` is defined.
1111
* @param {string} [options.authorizationHeader] - The Authorization header value for the introspection request. Optional.
@@ -66,7 +66,7 @@ class OAuth2Adapter extends AuthAdapter {
6666
}
6767

6868
this.tokenIntrospectionEndpointUrl = options.tokenIntrospectionEndpointUrl;
69-
this.useridField = options.useridField;
69+
this.useridField = options.useridField || 'sub';
7070
this.appidField = options.appidField;
7171
this.appIds = options.appIds;
7272
this.authorizationHeader = options.authorizationHeader;

0 commit comments

Comments
 (0)