Skip to content

Commit eea716a

Browse files
authored
Merge pull request #19941 from mozilla/FXA-12616
feat(auth-server): Add new attached_oauth_clients endpoint
2 parents a4d760a + 5a108b7 commit eea716a

11 files changed

Lines changed: 777 additions & 290 deletions

File tree

packages/fxa-auth-client/lib/client.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1844,6 +1844,14 @@ export default class AuthClient {
18441844
return this.sessionGet('/account/attached_clients', sessionToken, headers);
18451845
}
18461846

1847+
async attachedOauthClients(sessionToken: hexstring, headers?: Headers) {
1848+
return this.sessionGet(
1849+
'/account/attached_oauth_clients',
1850+
sessionToken,
1851+
headers
1852+
);
1853+
}
1854+
18471855
async attachedClientDestroy(
18481856
sessionToken: hexstring,
18491857
clientInfo: any,

packages/fxa-auth-server/docs/swagger/devices-and-sessions-api.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,22 @@ const ACCOUNT_ATTACHED_CLIENTS_GET = {
3636
],
3737
};
3838

39+
const ACCOUNT_ATTACHED_OAUTH_CLIENTS_GET = {
40+
...TAGS_DEVICES_AND_SESSIONS,
41+
description: '/account/attached_oauth_clients',
42+
notes: [
43+
dedent`
44+
🔒 Authenticated with session token
45+
46+
Returns an array listing all the OAuth Clients that the authenticated user has connected to their account.
47+
48+
This will only return active sessions. For example, if a user has signed into a service and then later disconnects from that service via account settings connected devices, they would not appear on this list.
49+
50+
Each OAuth Client will have exactly one record, and include the 'lastAccessTime' property.
51+
`,
52+
],
53+
};
54+
3955
const ACCOUNT_ATTACHED_CLIENT_DESTROY_POST = {
4056
...TAGS_DEVICES_AND_SESSIONS,
4157
description: '/account/attached_client/destroy',
@@ -71,13 +87,13 @@ const ACCOUNT_DEVICE_POST = {
7187
description: dedent`
7288
Failing requests may be caused by the following errors (this is not an exhaustive list):
7389
- \`errno: 107\` - Invalid parameter in request body
74-
`
90+
`,
7591
},
7692
503: {
7793
description: dedent`
7894
Failing requests may be caused by the following errors (this is not an exhaustive list):
7995
- \`errno: 202\` - Feature not enabled
80-
`
96+
`,
8197
},
8298
},
8399
},
@@ -200,6 +216,7 @@ const ACCOUNT_DEVICE_DESTROY_POST = {
200216
const API_DOCS = {
201217
ACCOUNT_ATTACHED_CLIENT_DESTROY_POST,
202218
ACCOUNT_ATTACHED_CLIENTS_GET,
219+
ACCOUNT_ATTACHED_OAUTH_CLIENTS_GET,
203220
ACCOUNT_DEVICE_COMMANDS_GET,
204221
ACCOUNT_DEVICE_DESTROY_POST,
205222
ACCOUNT_DEVICE_POST,

packages/fxa-auth-server/lib/oauth/authorized_clients.js

Lines changed: 126 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,99 @@ function serialize(clientIdHex, token) {
2121
};
2222
}
2323

24+
/**
25+
* Sorts authorized clients by last_access_time, client_name, created_time, and scope.
26+
*/
27+
function sortAuthorizedClients(clients) {
28+
return clients.sort(function (a, b) {
29+
if (b.last_access_time > a.last_access_time) {
30+
return 1;
31+
}
32+
if (b.last_access_time < a.last_access_time) {
33+
return -1;
34+
}
35+
if (a.client_name > b.client_name) {
36+
return 1;
37+
}
38+
if (a.client_name < b.client_name) {
39+
return -1;
40+
}
41+
if (a.created_time > b.created_time) {
42+
return 1;
43+
}
44+
if (a.created_time < b.created_time) {
45+
return -1;
46+
}
47+
// To help provide a deterministic result order to simplify testing, also sort of scope values.
48+
if (a.scope > b.scope) {
49+
return 1;
50+
}
51+
if (a.scope < b.scope) {
52+
return -1;
53+
}
54+
return 0;
55+
});
56+
}
57+
58+
/**
59+
* Processes access tokens into unified records by clientId.
60+
* Merges multiple access tokens for the same client.
61+
* Filters out clients already seen in refresh tokens and canGrant clients.
62+
* @param {Array} accessTokens
63+
* @param {Set} seenClientIds - ClientIds to exclude
64+
* @returns {Array} Array of serialized records
65+
*/
66+
function processAccessTokens(accessTokens, seenClientIds) {
67+
const accessTokenRecordsByClientId = new Map();
68+
69+
for (const token of accessTokens) {
70+
const clientId = token.clientId.toString('hex');
71+
if (!seenClientIds.has(clientId) && !token.clientCanGrant) {
72+
let record = accessTokenRecordsByClientId.get(clientId);
73+
if (typeof record === 'undefined') {
74+
record = {
75+
clientId,
76+
clientName: token.clientName,
77+
createdAt: token.createdAt,
78+
lastUsedAt: token.createdAt,
79+
scope: ScopeSet.fromArray([]),
80+
};
81+
accessTokenRecordsByClientId.set(clientId, record);
82+
}
83+
// Merge details of all access tokens into a single record.
84+
record.scope.add(token.scope);
85+
if (token.createdAt < record.createdAt) {
86+
record.createdAt = token.createdAt;
87+
}
88+
if (record.lastUsedAt < token.createdAt) {
89+
record.lastUsedAt = token.createdAt;
90+
}
91+
}
92+
}
93+
94+
return Array.from(accessTokenRecordsByClientId.values()).map((record) =>
95+
serialize(record.clientId, record)
96+
);
97+
}
98+
99+
/**
100+
* Processes refresh tokens into serialized records.
101+
* @param {Array} refreshTokens
102+
* @returns {Object} { records: Array, seenClientIds: Set }
103+
*/
104+
function processRefreshTokens(refreshTokens) {
105+
const records = [];
106+
const seenClientIds = new Set();
107+
108+
for (const token of refreshTokens) {
109+
const clientId = token.clientId.toString('hex');
110+
records.push(serialize(clientId, token));
111+
seenClientIds.add(clientId);
112+
}
113+
114+
return { records, seenClientIds };
115+
}
116+
24117
module.exports = {
25118
async destroy(clientId, uid, refreshTokenId) {
26119
await oauthDB.ready();
@@ -34,90 +127,47 @@ module.exports = {
34127
await oauthDB.deleteClientAuthorization(clientId, uid);
35128
}
36129
},
130+
/**
131+
* Fetches all authorized clients for a given user ID,
132+
* @param {*} uid
133+
* @returns
134+
*/
37135
async list(uid) {
38136
await oauthDB.ready();
39-
const authorizedClients = [];
40-
137+
// get both refresh and access tokens in parallel
41138
const [refreshTokens, accessTokens] = await Promise.all([
42139
oauthDB.getRefreshTokensByUid(uid),
43140
oauthDB.getAccessTokensByUid(uid),
44141
]);
45142

46-
// First, enumerate all the refresh tokens.
47-
// Each of these is a separate instance of an authorized client
48-
// and should be displayed to the user as such. Nice and simple!
49-
const seenClientIds = new Set();
50-
for (const token of refreshTokens) {
51-
const clientId = token.clientId.toString('hex');
52-
authorizedClients.push(serialize(clientId, token));
53-
seenClientIds.add(clientId);
54-
}
143+
const { records: refreshRecords, seenClientIds } =
144+
processRefreshTokens(refreshTokens);
55145

56-
// Next, enumerate all the access tokens. In the interests of giving the user a
57-
// complete-yet-comprehensible list of all the things attached to their account,
58-
// we want to:
59-
//
60-
// 1. Show a single unified record for any client that is not using refresh tokens.
61-
// 2. Avoid showing access tokens for `canGrant` clients; such clients will always
62-
// hold some other sort of token, and we don't want them to appear in the list twice.
63-
const accessTokenRecordsByClientId = new Map();
64-
for (const token of accessTokens) {
65-
const clientId = token.clientId.toString('hex');
66-
if (!seenClientIds.has(clientId) && !token.clientCanGrant) {
67-
let record = accessTokenRecordsByClientId.get(clientId);
68-
if (typeof record === 'undefined') {
69-
record = {
70-
clientId,
71-
clientName: token.clientName,
72-
createdAt: token.createdAt,
73-
lastUsedAt: token.createdAt,
74-
scope: ScopeSet.fromArray([]),
75-
};
76-
accessTokenRecordsByClientId.set(clientId, record);
77-
}
78-
// Merge details of all access tokens into a single record.
79-
record.scope.add(token.scope);
80-
if (token.createdAt < record.createdAt) {
81-
record.createdAt = token.createdAt;
82-
}
83-
if (record.lastUsedAt < token.createdAt) {
84-
record.lastUsedAt = token.createdAt;
85-
}
86-
}
87-
}
88-
for (const [clientId, record] of accessTokenRecordsByClientId.entries()) {
89-
authorizedClients.push(serialize(clientId, record));
90-
}
146+
const accessRecords = processAccessTokens(accessTokens, seenClientIds);
91147

92-
// Sort the final list first by last_access_time, then by client_name, then by created_time.
93-
authorizedClients.sort(function (a, b) {
94-
if (b.last_access_time > a.last_access_time) {
95-
return 1;
96-
}
97-
if (b.last_access_time < a.last_access_time) {
98-
return -1;
99-
}
100-
if (a.client_name > b.client_name) {
101-
return 1;
102-
}
103-
if (a.client_name < b.client_name) {
104-
return -1;
105-
}
106-
if (a.created_time > b.created_time) {
107-
return 1;
108-
}
109-
if (a.created_time < b.created_time) {
110-
return -1;
111-
}
112-
// To help provide a deterministic result order to simplify testing, also sort of scope values.
113-
if (a.scope > b.scope) {
114-
return 1;
115-
}
116-
if (a.scope < b.scope) {
117-
return -1;
118-
}
119-
return 0;
120-
});
121-
return authorizedClients;
148+
const authorizedClients = [...refreshRecords, ...accessRecords];
149+
return sortAuthorizedClients(authorizedClients);
150+
},
151+
152+
/**
153+
* Fetches a list of unique OAuth clients authorized by a user.
154+
* Each clientId appears only once. The DB layer handles uniqueness
155+
* and selects the token with the most recent lastAccessTime.
156+
*/
157+
async listUnique(uid) {
158+
await oauthDB.ready();
159+
160+
const [refreshTokens, accessTokens] = await Promise.all([
161+
oauthDB.getUniqueRefreshTokensByUid(uid),
162+
oauthDB.getAccessTokensByUid(uid),
163+
]);
164+
165+
const { records: refreshRecords, seenClientIds } =
166+
processRefreshTokens(refreshTokens);
167+
168+
const accessRecords = processAccessTokens(accessTokens, seenClientIds);
169+
170+
const authorizedClients = [...refreshRecords, ...accessRecords];
171+
return sortAuthorizedClients(authorizedClients);
122172
},
123173
};

packages/fxa-auth-server/lib/oauth/db/index.js

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -133,9 +133,16 @@ class OauthDB extends ConnectedServicesDb {
133133
return t;
134134
}
135135

136-
async getRefreshTokensByUid(uid) {
137-
await this.ready();
138-
const tokens = await this.mysql._getRefreshTokensByUid(uid);
136+
/**
137+
* Processes each refresh token, checking it against the redis cache. If
138+
* found, it merges the metadata from redis into the token object. Objects
139+
* found in redis, but not in mysql, are pruned.
140+
* @param {*} uid
141+
* @param {*} getTokens
142+
* @returns
143+
*/
144+
async _processRefreshTokens(uid, getTokens) {
145+
const tokens = await getTokens;
139146
const extraMetadata = await this.redis.getRefreshTokens(uid);
140147
// We'll take this opportunity to clean up any tokens that exist in redis but
141148
// not in mysql, so this loop deletes each token from `extraMetadata` once handled.
@@ -154,6 +161,34 @@ class OauthDB extends ConnectedServicesDb {
154161
return tokens;
155162
}
156163

164+
/**
165+
* Fetches all refresh tokens for a given uid. Multiple tokens can be
166+
* returned for a single clientId
167+
* @param uid
168+
* @returns {Promise<*>}
169+
*/
170+
async getRefreshTokensByUid(uid) {
171+
await this.ready();
172+
return this._processRefreshTokens(
173+
uid,
174+
this.mysql._getRefreshTokensByUid(uid)
175+
);
176+
}
177+
178+
/**
179+
* Fetches all unique refresh tokens for a given uid. A clientId will
180+
* only appear once, prioritized by lastUsedAt date
181+
* @param uid
182+
* @returns {Promise<*>}
183+
*/
184+
async getUniqueRefreshTokensByUid(uid) {
185+
await this.ready();
186+
return await this._processRefreshTokens(
187+
uid,
188+
this.mysql._getUniqueRefreshTokensByUid(uid)
189+
);
190+
}
191+
157192
async removeRefreshToken(token) {
158193
await this.ready();
159194
await this.redis.removeRefreshToken(token.userId, token.tokenId);

packages/fxa-auth-server/lib/oauth/db/mysql/index.js

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,30 @@ const QUERY_LIST_REFRESH_TOKENS_BY_UID =
138138
' refreshTokens.scope, clients.name as clientName, clients.canGrant AS clientCanGrant ' +
139139
'FROM refreshTokens LEFT OUTER JOIN clients ON clients.id = refreshTokens.clientId ' +
140140
'WHERE refreshTokens.userId=?';
141+
142+
/**
143+
* Gets a unique list of refresh tokens for a given user.
144+
* Groups by clientId, returning only the most recently used token for each client.
145+
*/
146+
const QUERY_LIST_UNIQUE_REFRESH_TOKENS_BY_UID =
147+
'SELECT ' +
148+
' rt.clientId, ' +
149+
' rt.token AS tokenId, ' +
150+
' rt.createdAt, ' +
151+
' rt.lastUsedAt, ' +
152+
' rt.scope, ' +
153+
' clients.name AS clientName, ' +
154+
' clients.canGrant AS clientCanGrant ' +
155+
'FROM refreshTokens rt ' +
156+
'INNER JOIN ( ' +
157+
' SELECT clientId, MAX(lastUsedAt) AS maxLastUsedAt ' +
158+
' FROM refreshTokens ' +
159+
' WHERE userId=? ' +
160+
' GROUP BY clientId ' +
161+
') latest ON rt.clientId = latest.clientId AND rt.lastUsedAt = latest.maxLastUsedAt ' +
162+
'LEFT OUTER JOIN clients ON clients.id = rt.clientId ' +
163+
'WHERE rt.userId=?';
164+
141165
const QUERY_LIST_REFRESH_TOKENS_BY_CLIENT_ID =
142166
'SELECT refreshTokens.createdAt, refreshTokens.userId FROM refreshTokens WHERE refreshTokens.clientId=?';
143167
const DELETE_ACTIVE_CODES_BY_CLIENT_AND_UID =
@@ -426,6 +450,24 @@ class MysqlStore extends MysqlOAuthShared {
426450
return refreshTokens;
427451
}
428452

453+
/**
454+
* Get a unique list of refresh tokens for a given user.
455+
* @param {String} uid
456+
* @returns {Promise}
457+
*/
458+
async _getUniqueRefreshTokensByUid(uid) {
459+
const uidBuf = buf(uid);
460+
const refreshTokens = await this._read(
461+
QUERY_LIST_UNIQUE_REFRESH_TOKENS_BY_UID,
462+
// we need to pass uid twice because it's used two times in the query
463+
[uidBuf, uidBuf]
464+
);
465+
refreshTokens.forEach((t) => {
466+
t.scope = ScopeSet.fromString(t.scope);
467+
});
468+
return refreshTokens;
469+
}
470+
429471
/**
430472
* Get all refresh tokens for all users for a given clientId.
431473
* @param {String} clientId

0 commit comments

Comments
 (0)