Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/functional-tests/tests/admin/adminPanel.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
import { expect, test } from '../../lib/fixtures/standard';

const ADMIN_PANEL_URL = process.env.ADMIN_PANEL_URL ?? 'http://localhost:8091';
const ADMIN_SERVER_URL = process.env.ADMIN_SERVER_URL ?? 'http://localhost:8095';
const ADMIN_SERVER_URL =
process.env.ADMIN_SERVER_URL ?? 'http://localhost:8095';

// Admin panel tests only run locally (stage/prod require SSO)
test.skip(({ target }) => target.name !== 'local');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ let accountResponse: AccountProps = {
linkedAccounts: [],
accountEvents: [],
passkeys: [],
accountAuthorizations: [],
};

it('renders without imploding', () => {
Expand Down Expand Up @@ -319,6 +320,41 @@ it('displays the locale', async () => {
expect(getByTestId('edit-account-locale')).toBeInTheDocument();
});

it('shows "no authorizations" message when authorizations list is empty', () => {
const { getByTestId } = render(<Account {...accountResponse} />);
expect(getByTestId('account-authorizations-none')).toBeInTheDocument();
});

it('displays authorized browser services', () => {
const withAuthorizations = {
...accountResponse,
accountAuthorizations: [
{
service: 'sync',
scope: 'https://identity.mozilla.com/apps/oldsync',
clientId: '5882386c6d801776',
firstAuthorizedTosAt: 1589467100316,
lastAuthorizedTosAt: 1589467100316,
},
{
service: 'relay',
scope: 'https://identity.mozilla.com/apps/relay',
clientId: '9ebfe2c2f9ea3c58',
firstAuthorizedTosAt: 1589467200000,
lastAuthorizedTosAt: 1589467200000,
},
],
};
const { getAllByTestId, getByRole } = render(
<Account {...withAuthorizations} />
);

expect(
getByRole('heading', { name: /authorized browser services/i })
).toBeInTheDocument();
expect(getAllByTestId('account-authorization-service')).toHaveLength(2);
});

it('displays key-stretch-version', async () => {
const lockedAccount = {
...accountResponse,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { AdminPanelFeature } from '@fxa/shared/guards';
import Guard from '../../Guard';
import Subscription from '../Subscription';
import { ConnectedServices } from '../ConnectedServices';
import { AccountAuthorizations } from '../AccountAuthorizations';
import { TableRowYHeader, TableYHeaders } from '../../TableYHeaders';
import { TableRowXHeader, TableXHeaders } from '../../TableXHeaders';
import EmailBounces from '../EmailBounces';
Expand Down Expand Up @@ -94,6 +95,7 @@ export const Account = ({
backupCodes,
recoveryPhone,
passkeys,
accountAuthorizations,
}: AccountProps) => {
const createdAtDate = getFormattedDate(createdAt);
const disabledAtDate = getFormattedDate(disabledAt);
Expand Down Expand Up @@ -417,6 +419,17 @@ export const Account = ({
<Guard features={[AdminPanelFeature.ConnectedServices]}>
<h3 className="header-lg">Connected Services</h3>
<ConnectedServices services={attachedClients} />

<h3 className="header-lg">Authorized Browser Services</h3>
<p className="mb-2">
OAuth consent records, not a record of active usage. A row means the
account has authorized the service through a Firefox flow at some
point. Note: a Sync row can appear for any browser-service sign-in
(Smart Window, Relay, VPN), because Firefox Desktop currently mints
a Sync-scoped refresh token on every flow even when the user did not
sign in to Sync.
</p>
<AccountAuthorizations authorizations={accountAuthorizations} />
</Guard>

<h3 className="header-lg">Account History</h3>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import { render, screen } from '@testing-library/react';
import { AccountAuthorization } from 'fxa-admin-server/src/types';
import { AccountAuthorizations } from '.';

const AUTHORIZATIONS: AccountAuthorization[] = [
{
service: 'sync',
scope: 'https://identity.mozilla.com/apps/oldsync',
clientId: '5882386c6d801776',
firstAuthorizedTosAt: new Date('2026-01-01T00:00:00Z').getTime(),
lastAuthorizedTosAt: new Date('2026-01-15T00:00:00Z').getTime(),
},
{
service: 'relay',
scope: 'https://identity.mozilla.com/apps/relay',
clientId: '9ebfe2c2f9ea3c58',
firstAuthorizedTosAt: new Date('2026-02-01T00:00:00Z').getTime(),
lastAuthorizedTosAt: new Date('2026-02-20T00:00:00Z').getTime(),
},
];

it('renders the empty state when there are no authorizations', () => {
render(<AccountAuthorizations authorizations={[]} />);
expect(screen.getByTestId('account-authorizations-none')).toHaveTextContent(
'This account has not authorized any browser services.'
);
});

it('renders the empty state when authorizations is null', () => {
render(<AccountAuthorizations authorizations={null} />);
expect(screen.getByTestId('account-authorizations-none')).toBeInTheDocument();
});

it('renders one row per authorization', () => {
render(<AccountAuthorizations authorizations={AUTHORIZATIONS} />);
const services = screen.getAllByTestId('account-authorization-service');
const scopes = screen.getAllByTestId('account-authorization-scope');
const clientIds = screen.getAllByTestId('account-authorization-client-id');
const firstDates = screen.getAllByTestId(
'account-authorization-first-authorized-at'
);
const lastDates = screen.getAllByTestId(
'account-authorization-last-authorized-at'
);

expect(services).toHaveLength(2);
expect(scopes).toHaveLength(2);
expect(clientIds).toHaveLength(2);
expect(firstDates).toHaveLength(2);
expect(lastDates).toHaveLength(2);

expect(services[0]).toHaveTextContent('sync');
expect(scopes[0]).toHaveTextContent(
'https://identity.mozilla.com/apps/oldsync'
);
expect(clientIds[0]).toHaveTextContent('5882386c6d801776');
expect(services[1]).toHaveTextContent('relay');
expect(clientIds[1]).toHaveTextContent('9ebfe2c2f9ea3c58');
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import { AccountAuthorization } from 'fxa-admin-server/src/types';
import { TableRowXHeader, TableXHeaders } from '../../TableXHeaders';
import { getFormattedDate } from '../../../lib/utils';

export const AccountAuthorizations = ({
authorizations,
}: {
authorizations?: Nullable<AccountAuthorization[]>;
}) => {
if (!authorizations || authorizations.length === 0) {
return (
<p data-testid="account-authorizations-none" className="result-none">
This account has not authorized any browser services.
</p>
);
}

return (
<TableXHeaders
rowHeaders={[
'Service',
'Scope',
'Client ID',
'First Authorized',
'Last Authorized',
]}
>
{authorizations.map(
({
service,
scope,
clientId,
firstAuthorizedTosAt,
lastAuthorizedTosAt,
}) => (
<TableRowXHeader key={`${service}-${scope}-${clientId}`}>
<td data-testid="account-authorization-service">{service}</td>
<td data-testid="account-authorization-scope">{scope}</td>
<td data-testid="account-authorization-client-id">{clientId}</td>
<td data-testid="account-authorization-first-authorized-at">
{getFormattedDate(firstAuthorizedTosAt)}
</td>
<td data-testid="account-authorization-last-authorized-at">
{getFormattedDate(lastAuthorizedTosAt)}
</td>
</TableRowXHeader>
)
)}
</TableXHeaders>
);
};
64 changes: 64 additions & 0 deletions packages/fxa-admin-server/src/database/database.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,18 @@ describe('#integration - DatabaseService', () => {

beforeAll(async () => {
knex = await testDatabaseSetup();
// Create inline so a stale fxa-shared:build cache cannot drop the fixture.
await knex.raw(
'CREATE TABLE IF NOT EXISTS `accountAuthorizations` (' +
'`uid` BINARY(16) NOT NULL,' +
"`scope` VARCHAR(256) NOT NULL DEFAULT '', " +
"`service` VARCHAR(64) NOT NULL DEFAULT '', " +
'`clientId` BINARY(8) NOT NULL,' +
'`firstAuthorizedTosAt` BIGINT UNSIGNED NOT NULL,' +
'`lastAuthorizedTosAt` BIGINT UNSIGNED NOT NULL,' +
'PRIMARY KEY (`uid`, `scope`, `service`, `clientId`)' +
') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
);
});

afterAll(async () => {
Expand Down Expand Up @@ -89,4 +101,56 @@ describe('#integration - DatabaseService', () => {
it('should be able to invoke attachedDevices', async () => {
await service.attachedDevices('AB12');
});

it('returns rows ordered by lastAuthorizedTosAt descending', async () => {
const uid = 'aabbccddeeff00112233445566778899';
const uidBuffer = Buffer.from(uid, 'hex');
const syncClientId = '5882386c6d801776';
const relayClientId = '9ebfe2c2f9ea3c58';
const now = Date.now();
await service.knexOauth('accountAuthorizations').insert([
{
uid: uidBuffer,
scope: 'https://identity.mozilla.com/apps/oldsync',
service: 'sync',
clientId: Buffer.from(syncClientId, 'hex'),
firstAuthorizedTosAt: now - 5000,
lastAuthorizedTosAt: now - 1000,
},
{
uid: uidBuffer,
scope: 'https://identity.mozilla.com/apps/relay',
service: 'relay',
clientId: Buffer.from(relayClientId, 'hex'),
firstAuthorizedTosAt: now - 2000,
lastAuthorizedTosAt: now,
},
]);

const rows = await service.accountAuthorizations(uid);

expect(rows).toEqual([
{
scope: 'https://identity.mozilla.com/apps/relay',
service: 'relay',
clientId: relayClientId,
firstAuthorizedTosAt: now - 2000,
lastAuthorizedTosAt: now,
},
{
scope: 'https://identity.mozilla.com/apps/oldsync',
service: 'sync',
clientId: syncClientId,
firstAuthorizedTosAt: now - 5000,
lastAuthorizedTosAt: now - 1000,
},
]);
});

it('returns an empty array when the uid has no authorizations', async () => {
const rows = await service.accountAuthorizations(
'00000000000000000000000000000001'
);
expect(rows).toEqual([]);
});
});
38 changes: 38 additions & 0 deletions packages/fxa-admin-server/src/database/database.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ import { MozLoggerService } from '@fxa/shared/mozlog';
import { StatsD } from 'hot-shots';
import { Knex, knex } from 'knex';
import { AppConfig } from '../config';
import { AccountAuthorization } from '../types';
import { uuidTransformer } from './transformers';

function typeCasting(field: any, next: any) {
if (field.type === 'TINY' && field.length === 1) {
Expand Down Expand Up @@ -137,6 +139,42 @@ export class DatabaseService implements OnModuleDestroy {
return mergeCachedSessionTokens(dbSessionTokens, cachedSessionTokens, true);
}

public async accountAuthorizations(
uid: string
): Promise<AccountAuthorization[]> {
const uidBuffer = uuidTransformer.to(uid);
const rows = await this.knexOauth('accountAuthorizations')
.select(
'scope',
'service',
'clientId',
'firstAuthorizedTosAt',
'lastAuthorizedTosAt'
)
.where('uid', uidBuffer)
.orderBy([
{ column: 'lastAuthorizedTosAt', order: 'desc' },
{ column: 'service', order: 'asc' },
{ column: 'clientId', order: 'asc' },
])
.limit(50);
return rows.map(
(row: {
scope: string;
service: string;
clientId: Buffer;
firstAuthorizedTosAt: number | string;
lastAuthorizedTosAt: number | string;
}) => ({
scope: row.scope,
service: row.service,
clientId: row.clientId.toString('hex'),
firstAuthorizedTosAt: Number(row.firstAuthorizedTosAt),
lastAuthorizedTosAt: Number(row.lastAuthorizedTosAt),
})
);
}

public async attachedDevices(uid: string) {
const [devices, sessionTokens] = await Promise.all([
this.device.findByUid(uid),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import { BasketService } from '../../newsletters/basket.service';
import { FidoMdsService } from '../../backend/fido-mds.service';
import { SubscriptionsService } from '../../subscriptions/subscriptions.service';
import {
AccountAuthorization,
AccountDeleteResponse,
AccountDeleteStatus,
AccountDeleteTaskStatus,
Expand Down Expand Up @@ -187,6 +188,7 @@ export class AccountController {
linkedAccounts,
attachedClients,
passkeys,
accountAuthorizations,
] = await Promise.all([
this.emails(account),
this.emailBounces(account),
Expand All @@ -201,6 +203,7 @@ export class AccountController {
this.linkedAccounts(account),
this.attachedClients(account),
this.passkeys(account),
this.accountAuthorizations(account),
]);

return {
Expand All @@ -218,6 +221,7 @@ export class AccountController {
linkedAccounts,
attachedClients,
passkeys,
accountAuthorizations,
};
}

Expand Down Expand Up @@ -919,6 +923,13 @@ export class AccountController {
);
}

@Features(AdminPanelFeature.ConnectedServices)
public async accountAuthorizations(
account: Account
): Promise<AccountAuthorization[]> {
return this.db.accountAuthorizations(account.uid);
}

@Features(AdminPanelFeature.ConnectedServices)
public async attachedClients(account: Account) {
const clientFormatter = new ClientFormatter(
Expand Down
Loading