Skip to content
Merged
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
85 changes: 0 additions & 85 deletions api/src/unraid-api/auth/api-key.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,91 +199,6 @@ describe('ApiKeyService', () => {
});
});

describe('createLocalApiKeyForConnectIfNecessary', () => {
beforeEach(() => {
// Mock config getter
vi.mocked(getters.config).mockReturnValue({
status: FileLoadStatus.LOADED,
remote: {
apikey: 'remote-api-key',
localApiKey: null,
},
} as any);

// Mock store dispatch
vi.mocked(store.dispatch).mockResolvedValue({} as any);
});

it('should not create key if config is not loaded', async () => {
vi.mocked(getters.config).mockReturnValue({
status: FileLoadStatus.UNLOADED,
} as any);

await apiKeyService['createLocalApiKeyForConnectIfNecessary']();

expect(mockLogger.error).toHaveBeenCalledWith(
'Config file not loaded, cannot create local API key'
);
expect(store.dispatch).not.toHaveBeenCalled();
});

it('should not create key if remote apikey is not set', async () => {
vi.mocked(getters.config).mockReturnValue({
status: FileLoadStatus.LOADED,
remote: {
apikey: null,
localApiKey: null,
},
} as any);

await apiKeyService['createLocalApiKeyForConnectIfNecessary']();

expect(store.dispatch).not.toHaveBeenCalled();
});

it('should dispatch to update config if Connect key already exists', async () => {
vi.spyOn(apiKeyService, 'findByField').mockReturnValue(mockApiKeyWithSecret);

await apiKeyService['createLocalApiKeyForConnectIfNecessary']();

expect(store.dispatch).toHaveBeenCalled();
});

it('should create new Connect key and update config', async () => {
vi.spyOn(apiKeyService, 'findByField').mockReturnValue(null);
vi.spyOn(apiKeyService, 'create').mockResolvedValue(mockApiKeyWithSecret);

await apiKeyService['createLocalApiKeyForConnectIfNecessary']();

expect(apiKeyService.create).toHaveBeenCalledWith({
name: 'Connect',
description: 'API key for Connect user',
roles: [Role.CONNECT],
overwrite: true,
});
expect(store.dispatch).toHaveBeenCalledWith(
updateUserConfig({
remote: {
localApiKey: mockApiKeyWithSecret.key,
},
})
);
});

it('should log an error if key creation fails', async () => {
vi.spyOn(apiKeyService, 'findByField').mockReturnValue(null);
vi.spyOn(apiKeyService, 'createLocalConnectApiKey').mockResolvedValue(null);

await expect(apiKeyService['createLocalApiKeyForConnectIfNecessary']()).resolves.toBe(
undefined
);
expect(mockLogger.error).toHaveBeenCalledWith(
'Failed to create local API key - no key returned'
);
expect(store.dispatch).not.toHaveBeenCalled();
});
});

describe('findAll', () => {
it('should return all API keys', async () => {
vi.spyOn(apiKeyService, 'loadAllFromDisk').mockResolvedValue([
Expand Down
51 changes: 0 additions & 51 deletions api/src/unraid-api/auth/api-key.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@ export class ApiKeyService implements OnModuleInit {
async onModuleInit() {
this.memoryApiKeys = await this.loadAllFromDisk();
if (environment.IS_MAIN_PROCESS) {
await this.createLocalApiKeyForConnectIfNecessary();
this.setupWatch();
}
}
Expand Down Expand Up @@ -160,42 +159,6 @@ export class ApiKeyService implements OnModuleInit {
return apiKey as ApiKeyWithSecret;
}

private async createLocalApiKeyForConnectIfNecessary(): Promise<void> {
if (!environment.IS_MAIN_PROCESS) {
return;
}
const { remote, status } = getters.config();

if (status !== FileLoadStatus.LOADED) {
this.logger.error('Config file not loaded, cannot create local API key');
return;
}
if (!remote.apikey) {
return;
}

// If the remote API Key is set and the local key is either not set or not found on disk, create a key
if (!remote.localApiKey || !this.findByKey(remote.localApiKey)) {
const existingKey = this.findByField('name', 'Connect');

if (existingKey) {
this.logger.debug('Found existing Connect key, not set in config, setting');
store.dispatch(setLocalApiKey(existingKey.key));
} else {
this.logger.debug('Creating a new key for Connect');

// Create local API key
const localApiKey = await this.createLocalConnectApiKey();

if (localApiKey?.key) {
store.dispatch(setLocalApiKey(localApiKey.key));
} else {
this.logger.error('Failed to create local API key - no key returned');
}
}
}
}

async loadAllFromDisk(): Promise<ApiKeyWithSecret[]> {
const files = await readdir(this.basePath).catch((error) => {
this.logger.error(`Failed to read API key directory: ${error}`);
Expand Down Expand Up @@ -293,20 +256,6 @@ export class ApiKeyService implements OnModuleInit {
Errors: ${JSON.stringify(error.constraints, null, 2)}`);
}

public async createLocalConnectApiKey(): Promise<ApiKeyWithSecret | null> {
try {
return await this.create({
name: 'Connect',
description: 'API key for Connect user',
roles: [Role.CONNECT],
overwrite: true,
});
} catch (err) {
this.logger.error(`Failed to create local API key for Connect user: ${err}`);
return null;
}
}

public async saveApiKey(apiKey: ApiKeyWithSecret): Promise<void> {
try {
const validatedApiKey = await validateObject(ApiKeyWithSecret, apiKey);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { MothershipHandler } from '../event-handler/mothership.handler.js';
import { TimeoutCheckerJob } from '../job/timeout-checker.job.js';
import { CloudResolver } from '../resolver/cloud.resolver.js';
import { CloudService } from '../service/cloud.service.js';
import { ConnectApiKeyService } from '../service/connect-api-key.service.js';
import { MothershipConnectionService } from '../service/connection.service.js';
import { MothershipGraphqlClientService } from '../service/graphql.client.js';
import { InternalClientService } from '../service/internal.client.js';
Expand All @@ -13,6 +14,7 @@ import { RemoteAccessModule } from './remote-access.module.js';
@Module({
imports: [RemoteAccessModule],
providers: [
ConnectApiKeyService,
MothershipConnectionService,
MothershipGraphqlClientService,
InternalClientService,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,25 +1,20 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';

import { ApiKey, ApiKeyWithSecret, Permission, Resource, Role } from '@unraid/shared/graphql.model.js';
import { ApiKey, ApiKeyWithSecret, Permission, Role } from '@unraid/shared/graphql.model.js';
import { ApiKeyService } from '@unraid/shared/services/api-key.js';
import { API_KEY_SERVICE_TOKEN } from '@unraid/shared/tokens.js';
import { AuthActionVerb } from 'nest-authz';

import { ConnectConfigService } from './connect-config.service.js';

@Injectable()
export class ConnectApiKeyService implements ApiKeyService {
private readonly logger = new Logger(ConnectApiKeyService.name);
private static readonly validRoles: Set<Role> = new Set(Object.values(Role));
private static readonly CONNECT_API_KEY_NAME = 'Connect';
private static readonly CONNECT_API_KEY_DESCRIPTION = 'Internal API Key Used By Unraid Connect to access your server resources for the connect.myunraid.net dashboard';
private static readonly CONNECT_API_KEY_NAME = 'ConnectInternal';
private static readonly CONNECT_API_KEY_DESCRIPTION =
'Internal API Key Used By Unraid Connect to access your server resources for the connect.myunraid.net dashboard';

constructor(
@Inject(API_KEY_SERVICE_TOKEN)
private readonly apiKeyService: ApiKeyService,
private readonly configService: ConfigService,
private readonly connectConfig: ConnectConfigService
) {}

async findById(id: string): Promise<ApiKey | null> {
Expand Down Expand Up @@ -75,12 +70,10 @@ export class ConnectApiKeyService implements ApiKeyService {
try {
return await this.create({
name: ConnectApiKeyService.CONNECT_API_KEY_NAME,
description: 'API key for Connect user',
description: ConnectApiKeyService.CONNECT_API_KEY_DESCRIPTION,
roles: [Role.CONNECT],
overwrite: true,
});

// Delete all other API keys with the role CONNECT
} catch (err) {
this.logger.error(`Failed to create local API key for Connect user: ${err}`);
return null;
Expand All @@ -91,57 +84,25 @@ export class ConnectApiKeyService implements ApiKeyService {
* Gets or creates a local API key for Connect
*/
public async getOrCreateLocalApiKey(): Promise<string> {
const targetDescription = ConnectApiKeyService.CONNECT_API_KEY_DESCRIPTION;

// 1. Get all API keys first
const allKeys = await this.findAll();

// 2. Check in-memory config and verify key exists
const { localApiKey: localApiKeyFromConfig } = this.connectConfig.getConfig();
if (localApiKeyFromConfig && localApiKeyFromConfig !== '') {
const keyExists = allKeys.some(key => {
const keyWithSecret = this.findByIdWithSecret(key.id);
return keyWithSecret?.key === localApiKeyFromConfig;
});
if (keyExists) {
return localApiKeyFromConfig;
}
}

// 3. Filter by name "Connect"
const connectKeys = allKeys.filter(key => key.name === ConnectApiKeyService.CONNECT_API_KEY_NAME);

// 4. Find keys with correct description vs incorrect description
const correctKeys = connectKeys.filter(key => key.description === targetDescription);
const incorrectKeys = connectKeys.filter(key => key.description !== targetDescription);

// 5. Delete keys with incorrect description
if (incorrectKeys.length > 0) {
const idsToDelete = incorrectKeys.map(key => key.id);
await this.deleteApiKeys(idsToDelete);
this.logger.log(`Deleted ${incorrectKeys.length} Connect API keys with incorrect descriptions`);

const legacyConnectKeys = allKeys.filter((key) => key.name === 'Connect');
if (legacyConnectKeys.length > 0) {
await this.deleteApiKeys(legacyConnectKeys.map((key) => key.id));
this.logger.log(`Deleted legacy Connect API keys`);
}

// 6. If we have a correct key, return it
if (correctKeys.length > 0) {
const correctKeyWithSecret = this.findByIdWithSecret(correctKeys[0].id);
if (correctKeyWithSecret) {
return correctKeyWithSecret.key;
}

const connectKey = this.findByField('name', ConnectApiKeyService.CONNECT_API_KEY_NAME);
if (connectKey) {
return connectKey.key;
}

// 7. Create a new key with the correct description
const localApiKey = await this.create({
name: ConnectApiKeyService.CONNECT_API_KEY_NAME,
description: targetDescription,
roles: [Role.CONNECT],
overwrite: true,
});


const localApiKey = await this.createLocalConnectApiKey();

if (!localApiKey?.key) {
throw new Error('Failed to create local API key');
}

return localApiKey.key;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -147,21 +147,6 @@ export class ConnectSettingsService {
return restartRequired;
}

private async getOrCreateLocalApiKey() {
const { localApiKey: localApiKeyFromConfig } =
this.configService.getOrThrow<MyServersConfig>('connect.config');
if (localApiKeyFromConfig === '') {
const localApiKey = await this.apiKeyService.createLocalConnectApiKey();
if (!localApiKey?.key) {
throw new GraphQLError('Failed to create local API key', {
extensions: { code: 'INTERNAL_SERVER_ERROR' },
});
}
return localApiKey.key;
}
return localApiKeyFromConfig;
}

async signIn(input: ConnectSignInInput) {
const status = this.configService.get('store.emhttp.status');
if (status === 'LOADED') {
Expand All @@ -180,7 +165,8 @@ export class ConnectSettingsService {
}

try {
const localApiKey = await this.getOrCreateLocalApiKey();
// Make sure we have a local API key for Connect
await this.apiKeyService.getOrCreateLocalApiKey();

// Update config with user info
this.configService.set(
Expand All @@ -190,15 +176,13 @@ export class ConnectSettingsService {
this.configService.set('connect.config.username', userInfo.preferred_username);
this.configService.set('connect.config.email', userInfo.email);
this.configService.set('connect.config.apikey', input.apiKey);
this.configService.set('connect.config.localApiKey', localApiKey);

// Emit login event
this.eventEmitter.emit(EVENTS.LOGIN, {
username: userInfo.preferred_username,
avatar: typeof userInfo.avatar === 'string' ? userInfo.avatar : '',
email: userInfo.email,
apikey: input.apiKey,
localApiKey,
});

return true;
Expand Down
Loading
Loading