feat: add API Keys management and IP allowlist for App Passwords
This commit is contained in:
@@ -49,18 +49,21 @@ describe('account-security-store', () => {
|
||||
mockedJmap.mockResolvedValueOnce([
|
||||
['x:AccountPassword/get', { list: [{ id: 'singleton', otpAuth: { otpUrl: 'otpauth://totp/x' } }] }, '0'],
|
||||
['x:AppPassword/query', { ids: [] }, '1'],
|
||||
['x:ApiKey/query', { ids: [] }, '2'],
|
||||
]);
|
||||
|
||||
await useAccountSecurityStore.getState().fetchAuthInfo();
|
||||
|
||||
expect(useAccountSecurityStore.getState().otpEnabled).toBe(true);
|
||||
expect(useAccountSecurityStore.getState().appPasswords).toEqual([]);
|
||||
expect(useAccountSecurityStore.getState().apiKeys).toEqual([]);
|
||||
});
|
||||
|
||||
it('reports TOTP disabled when otpAuth is empty', async () => {
|
||||
mockedJmap.mockResolvedValueOnce([
|
||||
['x:AccountPassword/get', { list: [{ id: 'singleton', otpAuth: {} }] }, '0'],
|
||||
['x:AppPassword/query', { ids: [] }, '1'],
|
||||
['x:ApiKey/query', { ids: [] }, '2'],
|
||||
]);
|
||||
|
||||
await useAccountSecurityStore.getState().fetchAuthInfo();
|
||||
@@ -68,11 +71,12 @@ describe('account-security-store', () => {
|
||||
expect(useAccountSecurityStore.getState().otpEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('resolves app password rows via a follow-up Get when query returns ids', async () => {
|
||||
it('resolves app password and api key rows via a single follow-up batch when queries return ids', async () => {
|
||||
mockedJmap
|
||||
.mockResolvedValueOnce([
|
||||
['x:AccountPassword/get', { list: [{ otpAuth: {} }] }, '0'],
|
||||
['x:AppPassword/query', { ids: ['p1'] }, '1'],
|
||||
['x:ApiKey/query', { ids: ['k1'] }, '2'],
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
['x:AppPassword/get', {
|
||||
@@ -83,7 +87,16 @@ describe('account-security-store', () => {
|
||||
expiresAt: null,
|
||||
allowedIps: { '10.0.0.1': true },
|
||||
}],
|
||||
}, '0'],
|
||||
}, 'app'],
|
||||
['x:ApiKey/get', {
|
||||
list: [{
|
||||
id: 'k1',
|
||||
description: 'CI bot',
|
||||
createdAt: '2026-02-01T00:00:00Z',
|
||||
expiresAt: '2027-01-01T00:00:00Z',
|
||||
allowedIps: {},
|
||||
}],
|
||||
}, 'key'],
|
||||
]);
|
||||
|
||||
await useAccountSecurityStore.getState().fetchAuthInfo();
|
||||
@@ -96,6 +109,13 @@ describe('account-security-store', () => {
|
||||
expiresAt: null,
|
||||
allowedIps: ['10.0.0.1'],
|
||||
});
|
||||
const k = useAccountSecurityStore.getState().apiKeys[0];
|
||||
expect(k).toMatchObject({
|
||||
id: 'k1',
|
||||
description: 'CI bot',
|
||||
expiresAt: '2027-01-01T00:00:00Z',
|
||||
allowedIps: [],
|
||||
});
|
||||
expect(mockedJmap).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
@@ -257,24 +277,48 @@ describe('account-security-store', () => {
|
||||
.mockResolvedValueOnce([
|
||||
['x:AccountPassword/get', { list: [{ otpAuth: {} }] }, '0'],
|
||||
['x:AppPassword/query', { ids: [] }, '1'],
|
||||
['x:ApiKey/query', { ids: [] }, '2'],
|
||||
]);
|
||||
|
||||
const result = await useAccountSecurityStore.getState().createAppPassword('CLI', '2026-12-01T00:00:00Z');
|
||||
const result = await useAccountSecurityStore
|
||||
.getState()
|
||||
.createAppPassword({ description: 'CLI', expiresAt: '2026-12-01T00:00:00Z', allowedIps: ['10.0.0.1', '192.168.1.0/24'] });
|
||||
|
||||
expect(result).toEqual({ id: 'p-new', secret: 'S3CR3T' });
|
||||
|
||||
const createArgs = mockedJmap.mock.calls[0][0][0][1];
|
||||
expect(createArgs.create.new).toEqual({ description: 'CLI', expiresAt: '2026-12-01T00:00:00Z' });
|
||||
expect(createArgs.create.new).toEqual({
|
||||
description: 'CLI',
|
||||
expiresAt: '2026-12-01T00:00:00Z',
|
||||
allowedIps: { '10.0.0.1': true, '192.168.1.0/24': true },
|
||||
});
|
||||
expect(mockedJmap).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('omits allowedIps when none provided', async () => {
|
||||
mockedJmap
|
||||
.mockResolvedValueOnce([
|
||||
['x:AppPassword/set', { created: { new: { id: 'p', secret: 's' } } }, '0'],
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
['x:AccountPassword/get', { list: [{ otpAuth: {} }] }, '0'],
|
||||
['x:AppPassword/query', { ids: [] }, '1'],
|
||||
['x:ApiKey/query', { ids: [] }, '2'],
|
||||
]);
|
||||
|
||||
await useAccountSecurityStore.getState().createAppPassword({ description: 'CLI' });
|
||||
|
||||
const createArgs = mockedJmap.mock.calls[0][0][0][1];
|
||||
expect(createArgs.create.new).toEqual({ description: 'CLI' });
|
||||
});
|
||||
|
||||
it('throws with server-provided description when notCreated is returned', async () => {
|
||||
mockedJmap.mockResolvedValueOnce([
|
||||
['x:AppPassword/set', { notCreated: { new: { type: 'invalidProperties', description: 'description too short' } } }, '0'],
|
||||
]);
|
||||
|
||||
await expect(
|
||||
useAccountSecurityStore.getState().createAppPassword('x')
|
||||
useAccountSecurityStore.getState().createAppPassword({ description: 'x' })
|
||||
).rejects.toThrow('description too short');
|
||||
});
|
||||
|
||||
@@ -284,7 +328,7 @@ describe('account-security-store', () => {
|
||||
]);
|
||||
|
||||
await expect(
|
||||
useAccountSecurityStore.getState().createAppPassword('x')
|
||||
useAccountSecurityStore.getState().createAppPassword({ description: 'x' })
|
||||
).rejects.toThrow(/did not return/i);
|
||||
});
|
||||
});
|
||||
@@ -296,6 +340,7 @@ describe('account-security-store', () => {
|
||||
.mockResolvedValueOnce([
|
||||
['x:AccountPassword/get', { list: [{ otpAuth: {} }] }, '0'],
|
||||
['x:AppPassword/query', { ids: [] }, '1'],
|
||||
['x:ApiKey/query', { ids: [] }, '2'],
|
||||
]);
|
||||
|
||||
await useAccountSecurityStore.getState().removeAppPassword('p1');
|
||||
@@ -306,12 +351,48 @@ describe('account-security-store', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('createApiKey / removeApiKey', () => {
|
||||
it('routes through x:ApiKey/set and refreshes auth info', async () => {
|
||||
mockedJmap
|
||||
.mockResolvedValueOnce([
|
||||
['x:ApiKey/set', { created: { new: { id: 'k1', secret: 'API_KEY' } } }, '0'],
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
['x:AccountPassword/get', { list: [{ otpAuth: {} }] }, '0'],
|
||||
['x:AppPassword/query', { ids: [] }, '1'],
|
||||
['x:ApiKey/query', { ids: [] }, '2'],
|
||||
]);
|
||||
|
||||
const result = await useAccountSecurityStore.getState().createApiKey({ description: 'bot', allowedIps: ['127.0.0.1'] });
|
||||
|
||||
expect(result).toEqual({ id: 'k1', secret: 'API_KEY' });
|
||||
const createArgs = mockedJmap.mock.calls[0][0][0][1];
|
||||
expect(createArgs.create.new).toEqual({ description: 'bot', allowedIps: { '127.0.0.1': true } });
|
||||
});
|
||||
|
||||
it('removes via x:ApiKey/set destroy', async () => {
|
||||
mockedJmap
|
||||
.mockResolvedValueOnce([['x:ApiKey/set', { destroyed: ['k1'] }, '0']])
|
||||
.mockResolvedValueOnce([
|
||||
['x:AccountPassword/get', { list: [{ otpAuth: {} }] }, '0'],
|
||||
['x:AppPassword/query', { ids: [] }, '1'],
|
||||
['x:ApiKey/query', { ids: [] }, '2'],
|
||||
]);
|
||||
|
||||
await useAccountSecurityStore.getState().removeApiKey('k1');
|
||||
|
||||
const args = mockedJmap.mock.calls[0][0][0][1];
|
||||
expect(args).toEqual({ accountId: 'acc-primary', destroy: ['k1'] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearState', () => {
|
||||
it('resets all derived fields back to defaults', () => {
|
||||
useAccountSecurityStore.setState({
|
||||
isStalwart: true,
|
||||
otpEnabled: true,
|
||||
appPasswords: [{ id: 'p', description: 'd', createdAt: null, expiresAt: null, allowedIps: [] }],
|
||||
apiKeys: [{ id: 'k', description: 'd', createdAt: null, expiresAt: null, allowedIps: [] }],
|
||||
encryptionType: 'Aes256',
|
||||
displayName: 'user',
|
||||
emails: ['a@b'],
|
||||
@@ -326,6 +407,7 @@ describe('account-security-store', () => {
|
||||
expect(state.isStalwart).toBeNull();
|
||||
expect(state.otpEnabled).toBe(false);
|
||||
expect(state.appPasswords).toEqual([]);
|
||||
expect(state.apiKeys).toEqual([]);
|
||||
expect(state.encryptionType).toBe('Disabled');
|
||||
expect(state.displayName).toBe('');
|
||||
expect(state.emails).toEqual([]);
|
||||
|
||||
@@ -13,6 +13,20 @@ export interface AppPasswordInfo {
|
||||
allowedIps: string[];
|
||||
}
|
||||
|
||||
export interface ApiKeyInfo {
|
||||
id: string;
|
||||
description: string;
|
||||
createdAt: string | null;
|
||||
expiresAt: string | null;
|
||||
allowedIps: string[];
|
||||
}
|
||||
|
||||
export interface AppCredentialInput {
|
||||
description: string;
|
||||
expiresAt?: string | null;
|
||||
allowedIps?: string[];
|
||||
}
|
||||
|
||||
interface AccountSecurityState {
|
||||
isStalwart: boolean | null;
|
||||
isProbing: boolean;
|
||||
@@ -20,6 +34,7 @@ interface AccountSecurityState {
|
||||
// Auth info
|
||||
otpEnabled: boolean;
|
||||
appPasswords: AppPasswordInfo[];
|
||||
apiKeys: ApiKeyInfo[];
|
||||
isLoadingAuth: boolean;
|
||||
|
||||
// Encryption-at-rest
|
||||
@@ -48,9 +63,12 @@ interface AccountSecurityState {
|
||||
enableTotp: (currentPassword: string, otpUrl: string, otpCode: string) => Promise<void>;
|
||||
disableTotp: (currentPassword: string) => Promise<void>;
|
||||
|
||||
createAppPassword: (description: string, expiresAt?: string | null) => Promise<{ id: string; secret: string }>;
|
||||
createAppPassword: (input: AppCredentialInput) => Promise<{ id: string; secret: string }>;
|
||||
removeAppPassword: (id: string) => Promise<void>;
|
||||
|
||||
createApiKey: (input: AppCredentialInput) => Promise<{ id: string; secret: string }>;
|
||||
removeApiKey: (id: string) => Promise<void>;
|
||||
|
||||
clearState: () => void;
|
||||
}
|
||||
|
||||
@@ -60,7 +78,7 @@ function getPrimaryAccountId(): string {
|
||||
return client.getAccountId();
|
||||
}
|
||||
|
||||
function appPasswordFromResult(raw: Record<string, unknown>): AppPasswordInfo {
|
||||
function credentialFromResult(raw: Record<string, unknown>): AppPasswordInfo {
|
||||
const allowedIps = raw.allowedIps && typeof raw.allowedIps === 'object'
|
||||
? Object.keys(raw.allowedIps as Record<string, unknown>)
|
||||
: [];
|
||||
@@ -73,6 +91,88 @@ function appPasswordFromResult(raw: Record<string, unknown>): AppPasswordInfo {
|
||||
};
|
||||
}
|
||||
|
||||
function ipsToMap(ips?: string[]): Record<string, true> | undefined {
|
||||
if (!ips || ips.length === 0) return undefined;
|
||||
return Object.fromEntries(ips.map((ip) => [ip, true]));
|
||||
}
|
||||
|
||||
function buildCreateBody(input: AppCredentialInput): Record<string, unknown> {
|
||||
const body: Record<string, unknown> = { description: input.description };
|
||||
if (input.expiresAt) body.expiresAt = input.expiresAt;
|
||||
const allowed = ipsToMap(input.allowedIps);
|
||||
if (allowed) body.allowedIps = allowed;
|
||||
return body;
|
||||
}
|
||||
|
||||
type SetMethod = 'x:AppPassword/set' | 'x:ApiKey/set';
|
||||
|
||||
type StoreGet = () => AccountSecurityState;
|
||||
type StoreSet = (partial: Partial<AccountSecurityState>) => void;
|
||||
|
||||
async function createCredential(
|
||||
get: StoreGet,
|
||||
set: StoreSet,
|
||||
method: SetMethod,
|
||||
input: AppCredentialInput,
|
||||
fallbackError: string,
|
||||
): Promise<{ id: string; secret: string }> {
|
||||
set({ isSaving: true, error: null });
|
||||
try {
|
||||
const accountId = getPrimaryAccountId();
|
||||
const tmpId = 'new';
|
||||
const responses = await stalwartJmap([
|
||||
[method, { accountId, create: { [tmpId]: buildCreateBody(input) } }, '0'],
|
||||
]);
|
||||
const result = requireResult<{
|
||||
created?: Record<string, { id: string; secret: string; createdAt?: string }>;
|
||||
notCreated?: Record<string, { type: string; description?: string }>;
|
||||
}>(responses, method);
|
||||
|
||||
const notCreated = result.notCreated?.[tmpId];
|
||||
if (notCreated) {
|
||||
throw new Error(notCreated.description || notCreated.type || fallbackError);
|
||||
}
|
||||
const created = result.created?.[tmpId];
|
||||
if (!created?.id || !created.secret) {
|
||||
throw new Error(`Server did not return created credential`);
|
||||
}
|
||||
|
||||
await get().fetchAuthInfo();
|
||||
set({ isSaving: false });
|
||||
return { id: created.id, secret: created.secret };
|
||||
} catch (error) {
|
||||
set({
|
||||
isSaving: false,
|
||||
error: error instanceof Error ? error.message : fallbackError,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeCredential(
|
||||
get: StoreGet,
|
||||
set: StoreSet,
|
||||
method: SetMethod,
|
||||
id: string,
|
||||
fallbackError: string,
|
||||
): Promise<void> {
|
||||
set({ isSaving: true, error: null });
|
||||
try {
|
||||
const accountId = getPrimaryAccountId();
|
||||
await stalwartJmap([
|
||||
[method, { accountId, destroy: [id] }, '0'],
|
||||
]);
|
||||
await get().fetchAuthInfo();
|
||||
set({ isSaving: false });
|
||||
} catch (error) {
|
||||
set({
|
||||
isSaving: false,
|
||||
error: error instanceof Error ? error.message : fallbackError,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function extractEncryptionType(raw: unknown): EncryptionType {
|
||||
if (!raw || typeof raw !== 'object') return 'Disabled';
|
||||
const type = (raw as { ['@type']?: string })['@type'];
|
||||
@@ -85,6 +185,7 @@ export const useAccountSecurityStore = create<AccountSecurityState>()((set, get)
|
||||
isProbing: false,
|
||||
otpEnabled: false,
|
||||
appPasswords: [],
|
||||
apiKeys: [],
|
||||
isLoadingAuth: false,
|
||||
encryptionType: 'Disabled',
|
||||
isLoadingCrypto: false,
|
||||
@@ -117,27 +218,42 @@ export const useAccountSecurityStore = create<AccountSecurityState>()((set, get)
|
||||
const responses = await stalwartJmap([
|
||||
['x:AccountPassword/get', { accountId, ids: ['singleton'] }, '0'],
|
||||
['x:AppPassword/query', { accountId }, '1'],
|
||||
['x:ApiKey/query', { accountId }, '2'],
|
||||
]);
|
||||
|
||||
const passwordResult = requireResult<{ list: Array<{ otpAuth?: { otpUrl?: string | null } }> }>(
|
||||
responses,
|
||||
'x:AccountPassword/get',
|
||||
);
|
||||
const queryResult = requireResult<{ ids: string[] }>(responses, 'x:AppPassword/query');
|
||||
const appPwQuery = requireResult<{ ids: string[] }>(responses, 'x:AppPassword/query');
|
||||
const apiKeyQuery = requireResult<{ ids: string[] }>(responses, 'x:ApiKey/query');
|
||||
|
||||
const otpAuth = passwordResult.list?.[0]?.otpAuth;
|
||||
const otpEnabled = !!(otpAuth && typeof otpAuth === 'object' && otpAuth.otpUrl);
|
||||
|
||||
let appPasswords: AppPasswordInfo[] = [];
|
||||
if (queryResult.ids?.length) {
|
||||
const getResponses = await stalwartJmap([
|
||||
['x:AppPassword/get', { accountId, ids: queryResult.ids }, '0'],
|
||||
]);
|
||||
const getResult = requireResult<{ list: Array<Record<string, unknown>> }>(getResponses, 'x:AppPassword/get');
|
||||
appPasswords = (getResult.list ?? []).map(appPasswordFromResult);
|
||||
const followUps: [string, Record<string, unknown>, string][] = [];
|
||||
if (appPwQuery.ids?.length) {
|
||||
followUps.push(['x:AppPassword/get', { accountId, ids: appPwQuery.ids }, 'app']);
|
||||
}
|
||||
if (apiKeyQuery.ids?.length) {
|
||||
followUps.push(['x:ApiKey/get', { accountId, ids: apiKeyQuery.ids }, 'key']);
|
||||
}
|
||||
|
||||
set({ otpEnabled, appPasswords, isLoadingAuth: false });
|
||||
let appPasswords: AppPasswordInfo[] = [];
|
||||
let apiKeys: ApiKeyInfo[] = [];
|
||||
if (followUps.length) {
|
||||
const followUpResponses = await stalwartJmap(followUps);
|
||||
if (appPwQuery.ids?.length) {
|
||||
const r = requireResult<{ list: Array<Record<string, unknown>> }>(followUpResponses, 'x:AppPassword/get');
|
||||
appPasswords = (r.list ?? []).map(credentialFromResult);
|
||||
}
|
||||
if (apiKeyQuery.ids?.length) {
|
||||
const r = requireResult<{ list: Array<Record<string, unknown>> }>(followUpResponses, 'x:ApiKey/get');
|
||||
apiKeys = (r.list ?? []).map(credentialFromResult);
|
||||
}
|
||||
}
|
||||
|
||||
set({ otpEnabled, appPasswords, apiKeys, isLoadingAuth: false });
|
||||
} catch (error) {
|
||||
debug.error('Failed to fetch auth info:', error);
|
||||
set({
|
||||
@@ -319,68 +435,20 @@ export const useAccountSecurityStore = create<AccountSecurityState>()((set, get)
|
||||
}
|
||||
},
|
||||
|
||||
createAppPassword: async (description, expiresAt) => {
|
||||
set({ isSaving: true, error: null });
|
||||
try {
|
||||
const accountId = getPrimaryAccountId();
|
||||
const tmpId = 'new';
|
||||
const responses = await stalwartJmap([
|
||||
[
|
||||
'x:AppPassword/set',
|
||||
{
|
||||
accountId,
|
||||
create: {
|
||||
[tmpId]: {
|
||||
description,
|
||||
...(expiresAt ? { expiresAt } : {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
'0',
|
||||
],
|
||||
]);
|
||||
const result = requireResult<{
|
||||
created?: Record<string, { id: string; secret: string; createdAt?: string }>;
|
||||
notCreated?: Record<string, { type: string; description?: string }>;
|
||||
}>(responses, 'x:AppPassword/set');
|
||||
|
||||
const notCreated = result.notCreated?.[tmpId];
|
||||
if (notCreated) {
|
||||
throw new Error(notCreated.description || notCreated.type || 'Failed to create app password');
|
||||
}
|
||||
const created = result.created?.[tmpId];
|
||||
if (!created?.id || !created.secret) {
|
||||
throw new Error('Server did not return created app password');
|
||||
}
|
||||
|
||||
await get().fetchAuthInfo();
|
||||
set({ isSaving: false });
|
||||
return { id: created.id, secret: created.secret };
|
||||
} catch (error) {
|
||||
set({
|
||||
isSaving: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to create app password',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
createAppPassword: async (input) => {
|
||||
return createCredential(get, set, 'x:AppPassword/set', input, 'Failed to create app password');
|
||||
},
|
||||
|
||||
removeAppPassword: async (id) => {
|
||||
set({ isSaving: true, error: null });
|
||||
try {
|
||||
const accountId = getPrimaryAccountId();
|
||||
await stalwartJmap([
|
||||
['x:AppPassword/set', { accountId, destroy: [id] }, '0'],
|
||||
]);
|
||||
await get().fetchAuthInfo();
|
||||
set({ isSaving: false });
|
||||
} catch (error) {
|
||||
set({
|
||||
isSaving: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to remove app password',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
return removeCredential(get, set, 'x:AppPassword/set', id, 'Failed to remove app password');
|
||||
},
|
||||
|
||||
createApiKey: async (input) => {
|
||||
return createCredential(get, set, 'x:ApiKey/set', input, 'Failed to create API key');
|
||||
},
|
||||
|
||||
removeApiKey: async (id) => {
|
||||
return removeCredential(get, set, 'x:ApiKey/set', id, 'Failed to remove API key');
|
||||
},
|
||||
|
||||
clearState: () => set({
|
||||
@@ -388,6 +456,7 @@ export const useAccountSecurityStore = create<AccountSecurityState>()((set, get)
|
||||
isProbing: false,
|
||||
otpEnabled: false,
|
||||
appPasswords: [],
|
||||
apiKeys: [],
|
||||
isLoadingAuth: false,
|
||||
encryptionType: 'Disabled',
|
||||
isLoadingCrypto: false,
|
||||
|
||||
Reference in New Issue
Block a user