From 58a3deb942f8cd17af9746b4e52eaf5e627dfbe4 Mon Sep 17 00:00:00 2001 From: Oleksii Orel Date: Tue, 21 Jul 2026 12:58:37 +0300 Subject: [PATCH 1/9] feat(device-auth): add backend service, routes, and common types Assisted-by: Claude Sonnet 4.6 Signed-off-by: Oleksii Orel --- packages/common/src/dto/api/index.ts | 23 ++ packages/common/src/dto/cluster-config.ts | 1 + packages/dashboard-backend/src/app.ts | 3 + .../src/constants/schemas.ts | 23 ++ .../src/devworkspaceClient/__mocks__/index.ts | 5 + .../src/devworkspaceClient/index.ts | 6 + .../__tests__/deviceAuthTokenApi.spec.ts | 326 ++++++++++++++++++ .../services/deviceAuthTokenApi.ts | 290 ++++++++++++++++ .../src/devworkspaceClient/types/index.ts | 28 ++ .../src/models/restParams.ts | 4 + .../api/__tests__/clusterConfig.spec.ts | 1 + .../src/routes/api/clusterConfig.ts | 1 + .../src/routes/api/deviceAuthToken.ts | 120 +++++++ 13 files changed, 831 insertions(+) create mode 100644 packages/dashboard-backend/src/devworkspaceClient/services/__tests__/deviceAuthTokenApi.spec.ts create mode 100644 packages/dashboard-backend/src/devworkspaceClient/services/deviceAuthTokenApi.ts create mode 100644 packages/dashboard-backend/src/routes/api/deviceAuthToken.ts diff --git a/packages/common/src/dto/api/index.ts b/packages/common/src/dto/api/index.ts index 22ec7a7e1c..f6ba9c2a06 100644 --- a/packages/common/src/dto/api/index.ts +++ b/packages/common/src/dto/api/index.ts @@ -73,6 +73,29 @@ export type NewSshKey = Omit & { key: string; }; +export type DeviceAuthToken = { + name: string; + provider?: string; + /** ISO 8601 timestamp string as returned by Kubernetes and serialized by Fastify */ + creationTimestamp?: string; + /** Whether the stored token is still accepted by GitHub. Undefined means the check was not performed or timed out. */ + valid?: boolean; +}; + +export type DeviceCodeResponse = { + deviceCode: string; + userCode: string; + verificationUri: string; + interval: number; +}; + +export type DeviceAuthPollResult = + | { status: 'pending' } + | { status: 'slow_down' } + | { status: 'authorized'; token: DeviceAuthToken } + | { status: 'expired' } + | { status: 'error'; message: string }; + export interface IPatch { op: string; path: string; diff --git a/packages/common/src/dto/cluster-config.ts b/packages/common/src/dto/cluster-config.ts index 11bfa6b79e..a758b05422 100644 --- a/packages/common/src/dto/cluster-config.ts +++ b/packages/common/src/dto/cluster-config.ts @@ -28,4 +28,5 @@ export interface ClusterConfig { allWorkspacesLimit: number; runningWorkspacesLimit: number; currentArchitecture?: Architecture; + githubDeviceAuthEnabled: boolean; } diff --git a/packages/dashboard-backend/src/app.ts b/packages/dashboard-backend/src/app.ts index 21bc82afce..4ab6dd3405 100644 --- a/packages/dashboard-backend/src/app.ts +++ b/packages/dashboard-backend/src/app.ts @@ -29,6 +29,7 @@ import { registerBackupRoutes } from '@/routes/api/backup'; import { registerClusterConfigRoute } from '@/routes/api/clusterConfig'; import { registerClusterInfoRoute } from '@/routes/api/clusterInfo'; import { registerDataResolverRoute } from '@/routes/api/dataResolver'; +import { registerDeviceAuthTokenRoutes } from '@/routes/api/deviceAuthToken'; import { registerDevWorkspaceClusterRoutes } from '@/routes/api/devworkspaceCluster'; import { registerDevworkspaceResourcesRoute } from '@/routes/api/devworkspaceResources'; import { registerDevworkspacesRoutes } from '@/routes/api/devworkspaces'; @@ -149,5 +150,7 @@ export default async function buildApp(server: FastifyInstance): Promise { + let service: DeviceAuthTokenApiService; + + const stubCoreV1Api = { + listNamespacedSecret: () => { + return Promise.resolve({ items: [] } as V1SecretList); + }, + readNamespacedSecret: () => { + return Promise.resolve({ + metadata: { + name: tokenName, + resourceVersion, + labels: { [DEVICE_AUTH_LABEL]: 'true' }, + creationTimestamp: new Date('2024-01-01'), + }, + } as V1Secret); + }, + deleteNamespacedSecret: () => { + return Promise.resolve(undefined); + }, + } as unknown as CoreV1Api; + + const spyListNamespacedSecret = jest.spyOn(stubCoreV1Api, 'listNamespacedSecret'); + const spyReadNamespacedSecret = jest.spyOn(stubCoreV1Api, 'readNamespacedSecret'); + const spyDeleteNamespacedSecret = jest.spyOn(stubCoreV1Api, 'deleteNamespacedSecret'); + + beforeEach(() => { + const { KubeConfig } = mockClient; + const kubeConfig = new KubeConfig(); + kubeConfig.makeApiClient = jest.fn().mockImplementation(_api => stubCoreV1Api); + service = new DeviceAuthTokenApiService(kubeConfig); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('listTokens', () => { + it('should return tokens from labeled secrets', async () => { + const creationTimestamp = new Date('2024-01-01'); + spyListNamespacedSecret.mockResolvedValueOnce({ + items: [ + { + metadata: { + name: tokenName, + creationTimestamp, + labels: { [DEVICE_AUTH_LABEL]: 'true' }, + }, + } as V1Secret, + ], + } as V1SecretList); + + const result = await service.listTokens(namespace); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe(tokenName); + expect(result[0].provider).toBeUndefined(); + expect(result[0].creationTimestamp).toBe(creationTimestamp.toISOString()); + expect(spyListNamespacedSecret).toHaveBeenCalledWith({ + namespace, + labelSelector: `${DEVICE_AUTH_LABEL}=true`, + }); + }); + + it('should include provider from label when present', async () => { + spyListNamespacedSecret.mockResolvedValueOnce({ + items: [ + { + metadata: { + name: tokenName, + labels: { + [DEVICE_AUTH_LABEL]: 'true', + [DEVICE_AUTH_PROVIDER_LABEL]: 'github', + }, + }, + } as V1Secret, + ], + } as V1SecretList); + + const result = await service.listTokens(namespace); + + expect(result[0].provider).toBe('github'); + }); + + it('should return an empty array when no labeled secrets exist', async () => { + spyListNamespacedSecret.mockResolvedValueOnce({ items: [] } as V1SecretList); + + const result = await service.listTokens(namespace); + + expect(result).toHaveLength(0); + }); + + it('should filter out secrets without a name', async () => { + spyListNamespacedSecret.mockResolvedValueOnce({ + items: [{ metadata: {} } as V1Secret], + } as V1SecretList); + + const result = await service.listTokens(namespace); + + expect(result).toHaveLength(0); + }); + + it('should throw a formatted error when the API call fails', async () => { + spyListNamespacedSecret.mockRejectedValueOnce(new Error('API error')); + + await expect(service.listTokens(namespace)).rejects.toThrow( + `Unable to list Device Authentication tokens in the namespace "${namespace}"`, + ); + }); + }); + + describe('deleteToken', () => { + it('should read and delete with resourceVersion precondition', async () => { + await service.deleteToken(namespace, tokenName); + + expect(spyReadNamespacedSecret).toHaveBeenCalledWith({ name: tokenName, namespace }); + expect(spyDeleteNamespacedSecret).toHaveBeenCalledWith({ + name: tokenName, + namespace, + body: { preconditions: { resourceVersion } }, + }); + }); + + it('should throw a distinct error when the secret does not carry the device-auth label', async () => { + spyReadNamespacedSecret.mockResolvedValueOnce({ + metadata: { name: tokenName, labels: {} }, + } as V1Secret); + + await expect(service.deleteToken(namespace, tokenName)).rejects.toThrow( + `Secret "${tokenName}" does not carry the`, + ); + expect(spyDeleteNamespacedSecret).not.toHaveBeenCalled(); + }); + + it('should throw a formatted error when the read API call fails', async () => { + spyReadNamespacedSecret.mockRejectedValueOnce(new Error('API error')); + + await expect(service.deleteToken(namespace, tokenName)).rejects.toThrow( + `Unable to delete Device Authentication token "${tokenName}" in the namespace "${namespace}"`, + ); + }); + + it('should throw a formatted error when the delete API call fails', async () => { + spyDeleteNamespacedSecret.mockRejectedValueOnce(new Error('API error')); + + await expect(service.deleteToken(namespace, tokenName)).rejects.toThrow( + `Unable to delete Device Authentication token "${tokenName}" in the namespace "${namespace}"`, + ); + }); + + it('should still delete the K8s secret when GitHub token revocation throws', async () => { + process.env.CHE_GITHUB_OAUTH_CLIENT_ID = 'test-client-id'; + + // Secret has a token in data field (base64 encoded) + spyReadNamespacedSecret.mockResolvedValueOnce({ + metadata: { + name: tokenName, + resourceVersion, + labels: { [DEVICE_AUTH_LABEL]: 'true' }, + }, + data: { token: Buffer.from('ghp_test_token').toString('base64') }, + } as V1Secret); + + // fetch (revocation call) throws a network error + mockFetch.mockRejectedValueOnce(new Error('Network error')); + + // Should NOT throw — deleteNamespacedSecret must still be called + await expect(service.deleteToken(namespace, tokenName)).resolves.toBeUndefined(); + expect(spyDeleteNamespacedSecret).toHaveBeenCalled(); + + delete process.env.CHE_GITHUB_OAUTH_CLIENT_ID; + }); + }); + + describe('initiateDeviceAuth', () => { + beforeEach(() => { + process.env.CHE_GITHUB_OAUTH_CLIENT_ID = 'test-client-id'; + }); + afterEach(() => { + delete process.env.CHE_GITHUB_OAUTH_CLIENT_ID; + jest.clearAllMocks(); + }); + + it('should return device code response', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + device_code: 'dev-code-123', + user_code: 'ABCD-1234', + verification_uri: 'https://github.com/login/device', + interval: 5, + }), + }); + + const result = await service.initiateDeviceAuth(); + + expect(result).toEqual({ + deviceCode: 'dev-code-123', + userCode: 'ABCD-1234', + verificationUri: 'https://github.com/login/device', + interval: 5, + }); + }); + + it('should throw when CHE_GITHUB_OAUTH_CLIENT_ID is not set', async () => { + delete process.env.CHE_GITHUB_OAUTH_CLIENT_ID; + await expect(service.initiateDeviceAuth()).rejects.toThrow('CHE_GITHUB_OAUTH_CLIENT_ID'); + }); + + it('should throw when GitHub returns an error', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ error: 'invalid_client', error_description: 'Bad client' }), + }); + await expect(service.initiateDeviceAuth()).rejects.toThrow('Bad client'); + }); + }); + + describe('pollDeviceAuth', () => { + beforeEach(() => { + process.env.CHE_GITHUB_OAUTH_CLIENT_ID = 'test-client-id'; + stubCoreV1Api.createNamespacedSecret = jest.fn().mockResolvedValue({ + metadata: { + name: 'device-authentication-secret-abc12', + creationTimestamp: new Date('2024-01-01'), + }, + }); + }); + afterEach(() => { + delete process.env.CHE_GITHUB_OAUTH_CLIENT_ID; + jest.clearAllMocks(); + }); + + it('should return pending when GitHub returns authorization_pending', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ error: 'authorization_pending' }), + }); + const result = await service.pollDeviceAuth(namespace, 'dev-code-123'); + expect(result).toEqual({ status: 'pending' }); + }); + + it('should return slow_down when GitHub returns slow_down', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ error: 'slow_down' }), + }); + const result = await service.pollDeviceAuth(namespace, 'dev-code-123'); + expect(result).toEqual({ status: 'slow_down' }); + }); + + it('should return expired when GitHub returns expired_token', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ error: 'expired_token' }), + }); + const result = await service.pollDeviceAuth(namespace, 'dev-code-123'); + expect(result).toEqual({ status: 'expired' }); + }); + + it('should create K8s secret and return authorized on success', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ access_token: 'ghp_token123', token_type: 'bearer', scope: 'repo' }), + }); + const result = await service.pollDeviceAuth(namespace, 'dev-code-123'); + expect(result.status).toBe('authorized'); + expect((result as { status: 'authorized'; token: api.DeviceAuthToken }).token.provider).toBe( + 'github', + ); + }); + + it('should return error when GitHub returns unknown error', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ error: 'access_denied', error_description: 'User denied access' }), + }); + const result = await service.pollDeviceAuth(namespace, 'dev-code-123'); + expect(result).toEqual({ status: 'error', message: 'User denied access' }); + }); + + it('should return error when response has no access_token', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({}), + }); + const result = await service.pollDeviceAuth(namespace, 'dev-code-123'); + expect(result).toEqual({ status: 'error', message: 'No access_token in response' }); + }); + }); +}); diff --git a/packages/dashboard-backend/src/devworkspaceClient/services/deviceAuthTokenApi.ts b/packages/dashboard-backend/src/devworkspaceClient/services/deviceAuthTokenApi.ts new file mode 100644 index 0000000000..3eb3317240 --- /dev/null +++ b/packages/dashboard-backend/src/devworkspaceClient/services/deviceAuthTokenApi.ts @@ -0,0 +1,290 @@ +/* + * Copyright (c) 2018-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { api } from '@eclipse-che/common'; +import * as k8s from '@kubernetes/client-node'; +import { randomBytes } from 'crypto'; + +import { createError } from '@/devworkspaceClient/services/helpers/createError'; +import { + CoreV1API, + prepareCoreV1API, +} from '@/devworkspaceClient/services/helpers/prepareCoreV1API'; +import { + DeviceAuthPollResult, + DeviceCodeResponse, + IDeviceAuthTokenApi, +} from '@/devworkspaceClient/types'; + +const API_ERROR_LABEL = 'CORE_V1_API_ERROR'; + +const DEVICE_AUTH_LABEL = 'che.eclipse.org/device-authentication'; +const DEVICE_AUTH_LABEL_SELECTOR = `${DEVICE_AUTH_LABEL}=true`; +const DEVICE_AUTH_PROVIDER_LABEL = 'che.eclipse.org/device-authentication-provider'; + +const GITHUB_SCOPES = 'repo user:email workflow'; +const GITHUB_API_TIMEOUT_MS = 30_000; + +interface GitHubDeviceCodeResponse { + device_code?: string; + user_code?: string; + verification_uri?: string; + interval?: number; + expires_in?: number; + error?: string; + error_description?: string; +} + +interface GitHubTokenResponse { + access_token?: string; + token_type?: string; + scope?: string; + error?: string; + error_description?: string; +} + +function getGitHubClientId(): string { + const clientId = process.env.CHE_GITHUB_OAUTH_CLIENT_ID; + if (!clientId) { + throw new Error('CHE_GITHUB_OAUTH_CLIENT_ID environment variable is not set'); + } + return clientId; +} + +async function githubPostDeviceCode( + params: Record, +): Promise { + const query = new URLSearchParams(params).toString(); + const url = `https://github.com/login/device/code?${query}`; + const response = await fetch(url, { + method: 'POST', + headers: { Accept: 'application/json' }, + }); + if ( + !response.ok && + response.headers.get('content-type')?.includes('application/json') === false + ) { + throw new Error(`GitHub API returned HTTP ${response.status}`); + } + const data: unknown = await response.json(); + return data as GitHubDeviceCodeResponse; +} + +async function githubPostToken(params: Record): Promise { + const query = new URLSearchParams(params).toString(); + const url = `https://github.com/login/oauth/access_token?${query}`; + const response = await fetch(url, { + method: 'POST', + headers: { Accept: 'application/json' }, + }); + if ( + !response.ok && + response.headers.get('content-type')?.includes('application/json') === false + ) { + throw new Error(`GitHub API returned HTTP ${response.status}`); + } + const data: unknown = await response.json(); + return data as GitHubTokenResponse; +} + +export class DeviceAuthTokenApiService implements IDeviceAuthTokenApi { + private readonly coreV1API: CoreV1API; + + constructor(kc: k8s.KubeConfig) { + this.coreV1API = prepareCoreV1API(kc); + } + + async listTokens(namespace: string): Promise { + try { + const resp = await this.coreV1API.listNamespacedSecret({ + namespace, + labelSelector: DEVICE_AUTH_LABEL_SELECTOR, + }); + const tokens = resp.items.filter(secret => !!secret.metadata?.name); + return Promise.all( + tokens.map(async secret => { + const rawToken = Buffer.from(secret.data?.['token'] ?? '', 'base64').toString('utf-8'); + let valid: boolean | undefined; + if (rawToken) { + valid = await this.checkTokenValidity(rawToken); + } + return { + name: secret.metadata?.name ?? '', + provider: secret.metadata?.labels?.[DEVICE_AUTH_PROVIDER_LABEL], + creationTimestamp: secret.metadata?.creationTimestamp?.toISOString(), + valid, + }; + }), + ); + } catch (error) { + const additionalMessage = `Unable to list Device Authentication tokens in the namespace "${namespace}"`; + throw createError(error, API_ERROR_LABEL, additionalMessage); + } + } + + private async checkTokenValidity(token: string): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 5_000); + try { + const response = await fetch('https://api.github.com/user', { + headers: { + Authorization: `token ${token}`, + Accept: 'application/vnd.github+json', + }, + signal: controller.signal, + }); + return response.ok; + } catch { + return undefined; + } finally { + clearTimeout(timer); + } + } + + async deleteToken(namespace: string, tokenName: string): Promise { + const additionalMessage = `Unable to delete Device Authentication token "${tokenName}" in the namespace "${namespace}"`; + + let secret: k8s.V1Secret; + try { + secret = await this.coreV1API.readNamespacedSecret({ name: tokenName, namespace }); + } catch (error) { + throw createError(error, API_ERROR_LABEL, additionalMessage); + } + + if (secret.metadata?.labels?.[DEVICE_AUTH_LABEL] !== 'true') { + throw new Error( + `Secret "${tokenName}" does not carry the ${DEVICE_AUTH_LABEL_SELECTOR} label`, + ); + } + + // Best-effort GitHub token revocation via POST /credentials/revoke. + // This endpoint requires NO app credentials — works for any gho_/ghp_ token. + // The token owner receives a GitHub notification email upon revocation. + // See: https://docs.github.com/en/rest/credentials/revoke + const rawToken = Buffer.from(secret.data?.['token'] ?? '', 'base64').toString('utf-8'); + if (rawToken) { + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), GITHUB_API_TIMEOUT_MS); + try { + const revokeResponse = await fetch('https://api.github.com/credentials/revoke', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-GitHub-Api-Version': '2022-11-28', + }, + body: JSON.stringify({ credentials: [rawToken] }), + signal: controller.signal, + }); + if (!revokeResponse.ok && revokeResponse.status !== 202) { + console.warn( + `[device-auth] GitHub token revocation failed (HTTP ${revokeResponse.status}).`, + ); + } + } finally { + clearTimeout(timer); + } + } catch (e) { + console.warn(`[device-auth] GitHub token revocation error: ${e}`); + } + } + + try { + await this.coreV1API.deleteNamespacedSecret({ + name: tokenName, + namespace, + body: { preconditions: { resourceVersion: secret.metadata?.resourceVersion } }, + }); + } catch (error) { + throw createError(error, API_ERROR_LABEL, additionalMessage); + } + } + + async initiateDeviceAuth(): Promise { + const clientId = getGitHubClientId(); + const data = await githubPostDeviceCode({ + client_id: clientId, + scope: GITHUB_SCOPES, + }); + if (!data.device_code || !data.user_code || !data.verification_uri) { + if (data.error === 'device_flow_disabled' || data.error === 'device_flow_not_enabled') { + throw new Error( + 'Device Flow is not enabled for this GitHub OAuth App. ' + + 'An administrator must enable it at GitHub Settings → Developer settings → OAuth Apps.', + ); + } + throw new Error( + `Failed to initiate device auth: ${data.error_description ?? JSON.stringify(data)}`, + ); + } + return { + deviceCode: data.device_code, + userCode: data.user_code, + verificationUri: data.verification_uri, + interval: data.interval ?? 5, + }; + } + + async pollDeviceAuth(namespace: string, deviceCode: string): Promise { + const clientId = getGitHubClientId(); + const data = await githubPostToken({ + client_id: clientId, + device_code: deviceCode, + grant_type: 'urn:ietf:params:oauth:grant-type:device_code', + }); + + if (data.error === 'authorization_pending') { + return { status: 'pending' }; + } + if (data.error === 'slow_down') { + return { status: 'slow_down' }; + } + if (data.error === 'expired_token') { + return { status: 'expired' }; + } + if (data.error) { + return { status: 'error', message: data.error_description ?? data.error }; + } + if (!data.access_token) { + return { status: 'error', message: 'No access_token in response' }; + } + + const token = await this.createDeviceAuthSecret(namespace, data.access_token); + return { status: 'authorized', token }; + } + + private async createDeviceAuthSecret( + namespace: string, + accessToken: string, + ): Promise { + const name = `device-authentication-secret-${randomBytes(6).toString('hex')}`; + const secret: k8s.V1Secret = { + metadata: { + name, + namespace, + labels: { + [DEVICE_AUTH_LABEL]: 'true', + [DEVICE_AUTH_PROVIDER_LABEL]: 'github', + }, + }, + data: { + token: Buffer.from(accessToken).toString('base64'), + }, + }; + const created = await this.coreV1API.createNamespacedSecret({ namespace, body: secret }); + return { + name, + provider: 'github', + creationTimestamp: created.metadata?.creationTimestamp?.toISOString(), + }; + } +} diff --git a/packages/dashboard-backend/src/devworkspaceClient/types/index.ts b/packages/dashboard-backend/src/devworkspaceClient/types/index.ts index 7c7751edbd..72f8b6fa67 100644 --- a/packages/dashboard-backend/src/devworkspaceClient/types/index.ts +++ b/packages/dashboard-backend/src/devworkspaceClient/types/index.ts @@ -523,6 +523,7 @@ export interface IDevWorkspaceClient { editorsApi: IEditorsApi; aiProviderKeyApi: IAiProviderKeyApi; aiRegistryApi: IAiRegistryApi; + deviceAuthTokenApi: IDeviceAuthTokenApi; sccPermissionApi: ISccPermissionApi; } @@ -623,6 +624,33 @@ export interface IAiProviderKeyApi { delete(namespace: string, providerId: string): Promise; } +export type DeviceCodeResponse = api.DeviceCodeResponse; +export type DeviceAuthPollResult = api.DeviceAuthPollResult; + +export interface IDeviceAuthTokenApi { + /** + * Lists Device Authentication token secrets in the namespace + * (identified by the che.eclipse.org/device-authentication=true label). + */ + listTokens(namespace: string): Promise; + + /** + * Deletes the Device Authentication token secret by name. + */ + deleteToken(namespace: string, tokenName: string): Promise; + + /** + * Initiates a GitHub Device Authorization flow and returns the device code and user code. + */ + initiateDeviceAuth(): Promise; + + /** + * Polls GitHub for the access token using the device code. + * On success, stores the token as a Kubernetes secret. + */ + pollDeviceAuth(namespace: string, deviceCode: string): Promise; +} + export interface ISccPermissionApi { /** * Checks whether the current user has 'use' permission on a specific SCC diff --git a/packages/dashboard-backend/src/models/restParams.ts b/packages/dashboard-backend/src/models/restParams.ts index 2e855d110e..9abb67491d 100644 --- a/packages/dashboard-backend/src/models/restParams.ts +++ b/packages/dashboard-backend/src/models/restParams.ts @@ -80,3 +80,7 @@ export interface AiProviderKeyBody { envVarName: string; apiKey: string; } + +export interface DeviceAuthTokenNamespacedParams extends INamespacedParams { + tokenName: string; +} diff --git a/packages/dashboard-backend/src/routes/api/__tests__/clusterConfig.spec.ts b/packages/dashboard-backend/src/routes/api/__tests__/clusterConfig.spec.ts index e143fe27bc..745f7d7d45 100644 --- a/packages/dashboard-backend/src/routes/api/__tests__/clusterConfig.spec.ts +++ b/packages/dashboard-backend/src/routes/api/__tests__/clusterConfig.spec.ts @@ -47,6 +47,7 @@ describe('Cluster Config Route', () => { runningWorkspacesLimit: stubRunningWorkspacesLimit, allWorkspacesLimit: stubAllWorkspacesLimit, currentArchitecture: stubCurrentArchitecture, + githubDeviceAuthEnabled: false, }); }); }); diff --git a/packages/dashboard-backend/src/routes/api/clusterConfig.ts b/packages/dashboard-backend/src/routes/api/clusterConfig.ts index fea934eb8c..697927d3be 100644 --- a/packages/dashboard-backend/src/routes/api/clusterConfig.ts +++ b/packages/dashboard-backend/src/routes/api/clusterConfig.ts @@ -45,5 +45,6 @@ async function buildClusterConfig(): Promise { allWorkspacesLimit, runningWorkspacesLimit, currentArchitecture, + githubDeviceAuthEnabled: !!process.env.CHE_GITHUB_OAUTH_CLIENT_ID, }; } diff --git a/packages/dashboard-backend/src/routes/api/deviceAuthToken.ts b/packages/dashboard-backend/src/routes/api/deviceAuthToken.ts new file mode 100644 index 0000000000..a517583232 --- /dev/null +++ b/packages/dashboard-backend/src/routes/api/deviceAuthToken.ts @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2018-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; + +import { baseApiPath } from '@/constants/config'; +import { + deviceAuthPollBodySchema, + deviceAuthTokenParamsSchema, + namespacedSchema, +} from '@/constants/schemas'; +import { restParams } from '@/models'; +import { getDevWorkspaceClient } from '@/routes/api/helpers/getDevWorkspaceClient'; +import { getToken } from '@/routes/api/helpers/getToken'; +import { getSchema } from '@/services/helpers'; + +const tags = ['Device Auth Token']; +const rateLimitConfig = { + config: { + rateLimit: { + max: 100, + timeWindow: '1 minute', + }, + }, +}; + +export function registerDeviceAuthTokenRoutes(instance: FastifyInstance) { + instance.register(async server => { + /** + * GET /dashboard/api/namespace/:namespace/device-auth-token + * Returns metadata of Device Authentication token secrets in the namespace + * (identified by the che.eclipse.org/device-authentication=true label). + * Uses user bearer token. + */ + server.get( + `${baseApiPath}/namespace/:namespace/device-auth-token`, + Object.assign({}, rateLimitConfig, getSchema({ tags, params: namespacedSchema })), + async function (request: FastifyRequest) { + const { namespace } = request.params as restParams.INamespacedParams; + const token = getToken(request); + const { deviceAuthTokenApi } = getDevWorkspaceClient(token); + return deviceAuthTokenApi.listTokens(namespace); + }, + ); + + /** + * DELETE /dashboard/api/namespace/:namespace/device-auth-token/:tokenName + * Deletes the Device Authentication token secret by name. + * Uses user bearer token. + */ + server.delete( + `${baseApiPath}/namespace/:namespace/device-auth-token/:tokenName`, + Object.assign({}, rateLimitConfig, getSchema({ tags, params: deviceAuthTokenParamsSchema })), + async function (request: FastifyRequest, reply: FastifyReply) { + const { namespace, tokenName } = + request.params as restParams.DeviceAuthTokenNamespacedParams; + const token = getToken(request); + const { deviceAuthTokenApi } = getDevWorkspaceClient(token); + await deviceAuthTokenApi.deleteToken(namespace, tokenName); + reply.code(204).send(); + }, + ); + + /** + * POST /dashboard/api/namespace/:namespace/device-auth-token/initiate + * Calls GitHub to obtain a device code. Requires CHE_GITHUB_OAUTH_CLIENT_ID env var. + * Uses user bearer token for auth. + */ + server.post( + `${baseApiPath}/namespace/:namespace/device-auth-token/initiate`, + { + ...getSchema({ tags, params: namespacedSchema }), + config: { + rateLimit: { + max: 100, + timeWindow: '1 minute', + }, + }, + }, + async function (request: FastifyRequest) { + const token = getToken(request); + const { deviceAuthTokenApi } = getDevWorkspaceClient(token); + return deviceAuthTokenApi.initiateDeviceAuth(); + }, + ); + + /** + * POST /dashboard/api/namespace/:namespace/device-auth-token/poll + * Polls GitHub for a device code exchange. On success, creates the K8s secret. + * Uses user bearer token for auth. + */ + server.post( + `${baseApiPath}/namespace/:namespace/device-auth-token/poll`, + { + ...rateLimitConfig, + ...getSchema({ tags, params: namespacedSchema, body: deviceAuthPollBodySchema }), + preHandler: server.rateLimit({ + max: 100, + timeWindow: '1 minute', + }), + }, + async function (request: FastifyRequest) { + const { namespace } = request.params as restParams.INamespacedParams; + const { deviceCode } = request.body as { deviceCode: string }; + const token = getToken(request); + const { deviceAuthTokenApi } = getDevWorkspaceClient(token); + return deviceAuthTokenApi.pollDeviceAuth(namespace, deviceCode); + }, + ); + }); +} From 0a0a1b97758dbdb0d5f85f5d134af5dd81782ad6 Mon Sep 17 00:00:00 2001 From: Oleksii Orel Date: Tue, 21 Jul 2026 12:58:46 +0300 Subject: [PATCH 2/9] feat(device-auth): add frontend API client, Redux slice, and User Preferences tab Assisted-by: Claude Sonnet 4.6 Signed-off-by: Oleksii Orel --- .../ConnectModal/__mocks__/index.tsx | 38 +++ .../ConnectModal/__tests__/index.spec.tsx | 98 +++++++ .../DeviceAuthTokens/ConnectModal/index.tsx | 249 +++++++++++++++++ .../DeleteModal/__mocks__/index.tsx | 37 +++ .../DeleteModal/__tests__/index.spec.tsx | 84 ++++++ .../DeviceAuthTokens/DeleteModal/index.tsx | 124 ++++++++ .../EmptyState/__tests__/index.spec.tsx | 54 ++++ .../DeviceAuthTokens/EmptyState/index.tsx | 55 ++++ .../DeviceAuthTokens/List/__mocks__/index.tsx | 32 +++ .../List/__tests__/index.spec.tsx | 71 +++++ .../DeviceAuthTokens/List/index.tsx | 139 +++++++++ .../DeviceAuthTokens/__tests__/index.spec.tsx | 264 ++++++++++++++++++ .../DeviceAuthTokens/index.tsx | 200 +++++++++++++ .../__snapshots__/index.spec.tsx.snap | 17 ++ .../UserPreferences/__tests__/index.spec.tsx | 9 +- .../src/pages/UserPreferences/index.tsx | 10 + .../backend-client/deviceAuthTokenApi.ts | 70 +++++ .../src/services/helpers/types.ts | 1 + .../ClusterConfig/__tests__/reducer.spec.ts | 4 + .../src/store/ClusterConfig/reducer.ts | 1 + .../src/store/ClusterConfig/selectors.ts | 5 + .../DeviceAuthToken/__tests__/actions.spec.ts | 133 +++++++++ .../__tests__/reducers.spec.ts | 87 ++++++ .../__tests__/selectors.spec.ts | 46 +++ .../src/store/DeviceAuthToken/actions.ts | 75 +++++ .../src/store/DeviceAuthToken/index.ts | 21 ++ .../src/store/DeviceAuthToken/reducer.ts | 54 ++++ .../src/store/DeviceAuthToken/selectors.ts | 23 ++ .../src/store/__mocks__/mockStore.ts | 22 ++ .../src/store/rootReducer.ts | 2 + 30 files changed, 2021 insertions(+), 4 deletions(-) create mode 100644 packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/ConnectModal/__mocks__/index.tsx create mode 100644 packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/ConnectModal/__tests__/index.spec.tsx create mode 100644 packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/ConnectModal/index.tsx create mode 100644 packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/DeleteModal/__mocks__/index.tsx create mode 100644 packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/DeleteModal/__tests__/index.spec.tsx create mode 100644 packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/DeleteModal/index.tsx create mode 100644 packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/EmptyState/__tests__/index.spec.tsx create mode 100644 packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/EmptyState/index.tsx create mode 100644 packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/List/__mocks__/index.tsx create mode 100644 packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/List/__tests__/index.spec.tsx create mode 100644 packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/List/index.tsx create mode 100644 packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/__tests__/index.spec.tsx create mode 100644 packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/index.tsx create mode 100644 packages/dashboard-frontend/src/services/backend-client/deviceAuthTokenApi.ts create mode 100644 packages/dashboard-frontend/src/store/DeviceAuthToken/__tests__/actions.spec.ts create mode 100644 packages/dashboard-frontend/src/store/DeviceAuthToken/__tests__/reducers.spec.ts create mode 100644 packages/dashboard-frontend/src/store/DeviceAuthToken/__tests__/selectors.spec.ts create mode 100644 packages/dashboard-frontend/src/store/DeviceAuthToken/actions.ts create mode 100644 packages/dashboard-frontend/src/store/DeviceAuthToken/index.ts create mode 100644 packages/dashboard-frontend/src/store/DeviceAuthToken/reducer.ts create mode 100644 packages/dashboard-frontend/src/store/DeviceAuthToken/selectors.ts diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/ConnectModal/__mocks__/index.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/ConnectModal/__mocks__/index.tsx new file mode 100644 index 0000000000..400bc4906f --- /dev/null +++ b/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/ConnectModal/__mocks__/index.tsx @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2018-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import React from 'react'; + +export const ConnectModal = ({ + isOpen, + onCloseModal, + onSuccess, +}: { + isOpen: boolean; + namespace: string; + onCloseModal: () => void; + onSuccess: (token: unknown) => void; +}): React.ReactElement => ( +
+ + +
+); + +export default ConnectModal; diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/ConnectModal/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/ConnectModal/__tests__/index.spec.tsx new file mode 100644 index 0000000000..f5ff163f57 --- /dev/null +++ b/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/ConnectModal/__tests__/index.spec.tsx @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2018-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { api } from '@eclipse-che/common'; +import React from 'react'; +import { Provider } from 'react-redux'; + +import { ConnectModal } from '@/pages/UserPreferences/DeviceAuthTokens/ConnectModal'; +import getComponentRenderer, { screen, waitFor } from '@/services/__mocks__/getComponentRenderer'; +import { DeviceCodeResponse, pollDeviceAuth } from '@/services/backend-client/deviceAuthTokenApi'; +import { AppThunk } from '@/store'; +import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; +import { deviceAuthTokenActionCreators } from '@/store/DeviceAuthToken'; + +jest.mock('@/services/backend-client/deviceAuthTokenApi'); +jest.mock('@/store/DeviceAuthToken', () => ({ + ...jest.requireActual('@/store/DeviceAuthToken'), + deviceAuthTokenActionCreators: { + initiateDeviceAuth: (): AppThunk> => async () => ({ + deviceCode: 'dev-code-123', + userCode: 'ABCD-1234', + verificationUri: 'https://github.com/login/device', + interval: 5, + }), + } as typeof deviceAuthTokenActionCreators, +})); + +const mockOnCloseModal = jest.fn(); +const mockOnSuccess = jest.fn(); + +const newToken: api.DeviceAuthToken = { + name: 'device-authentication-secret-abc12', + provider: 'github', +}; + +const { renderComponent } = getComponentRenderer(getComponent); + +function getComponent(isOpen: boolean) { + const store = new MockStoreBuilder().build(); + return ( + + + + ); +} + +describe('ConnectModal', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => { + jest.clearAllMocks(); + jest.useRealTimers(); + }); + + it('should render the user code when open', async () => { + renderComponent(true); + await waitFor(() => screen.getByTestId('user-code')); + expect(screen.getByTestId('user-code')).toHaveTextContent('ABCD-1234'); + }); + + it('should call onSuccess when poll returns authorized', async () => { + (pollDeviceAuth as jest.Mock).mockResolvedValue({ status: 'authorized', token: newToken }); + renderComponent(true); + await waitFor(() => screen.getByTestId('user-code')); + jest.runAllTimers(); + await waitFor(() => expect(mockOnSuccess).toHaveBeenCalledWith(newToken)); + }); + + it('should show error when poll returns expired', async () => { + (pollDeviceAuth as jest.Mock).mockResolvedValue({ status: 'expired' }); + renderComponent(true); + await waitFor(() => screen.getByTestId('user-code')); + jest.runAllTimers(); + await waitFor(() => screen.getByTestId('connect-error')); + expect(screen.getByTestId('connect-error')).toHaveTextContent('expired'); + }); + + it('should call onCloseModal when Cancel is clicked', async () => { + (pollDeviceAuth as jest.Mock).mockResolvedValue({ status: 'pending' }); + renderComponent(true); + await waitFor(() => screen.getByTestId('cancel-button')); + screen.getByTestId('cancel-button').click(); + expect(mockOnCloseModal).toHaveBeenCalled(); + }); +}); diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/ConnectModal/index.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/ConnectModal/index.tsx new file mode 100644 index 0000000000..4e8dbcfbe9 --- /dev/null +++ b/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/ConnectModal/index.tsx @@ -0,0 +1,249 @@ +/* + * Copyright (c) 2018-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { api } from '@eclipse-che/common'; +import { + Button, + ButtonVariant, + Content, + Modal, + ModalBody, + ModalFooter, + ModalHeader, + ModalVariant, + Spinner, + Tooltip, +} from '@patternfly/react-core'; +import { CopyIcon } from '@patternfly/react-icons'; +import React from 'react'; +import CopyToClipboard from 'react-copy-to-clipboard'; +import { connect, ConnectedProps } from 'react-redux'; + +import { + DeviceAuthPollResult, + DeviceCodeResponse, + pollDeviceAuth, +} from '@/services/backend-client/deviceAuthTokenApi'; +import { deviceAuthTokenActionCreators } from '@/store/DeviceAuthToken'; + +const connector = connect(null, { + initiateDeviceAuth: deviceAuthTokenActionCreators.initiateDeviceAuth, +}); + +type MappedProps = ConnectedProps; + +type OwnProps = { + isOpen: boolean; + namespace: string; + onCloseModal: () => void; + onSuccess: (token: api.DeviceAuthToken) => void; +}; + +export type Props = OwnProps & MappedProps; + +export type State = { + deviceCode: DeviceCodeResponse | undefined; + error: string | undefined; + isLoading: boolean; + copyTimerId: number | undefined; +}; + +class ConnectModalClass extends React.PureComponent { + private pollTimer: ReturnType | undefined; + + constructor(props: Props) { + super(props); + this.state = { + deviceCode: undefined, + error: undefined, + isLoading: false, + copyTimerId: undefined, + }; + } + + componentDidMount(): void { + if (this.props.isOpen) { + this.initiateAuth(); + } + } + + async componentDidUpdate(prevProps: Props): Promise { + if (this.props.isOpen && !prevProps.isOpen) { + await this.initiateAuth(); + } + if (!this.props.isOpen && prevProps.isOpen) { + this.stopPolling(); + } + } + + componentWillUnmount(): void { + this.stopPolling(); + } + + private async initiateAuth(): Promise { + this.setState({ deviceCode: undefined, error: undefined, isLoading: true }); + try { + const result = await this.props.initiateDeviceAuth(); + this.setState({ deviceCode: result, isLoading: false }); + this.schedulePoll(result); + } catch (e) { + this.setState({ error: String(e), isLoading: false }); + } + } + + private handleCopyToClipboard(): void { + let { copyTimerId } = this.state; + if (copyTimerId !== undefined) { + window.clearTimeout(copyTimerId); + } + copyTimerId = window.setTimeout(() => { + this.setState({ copyTimerId: undefined }); + }, 3000); + this.setState({ copyTimerId }); + } + + private schedulePoll(response: DeviceCodeResponse): void { + this.pollTimer = setTimeout(() => this.runPoll(response), response.interval * 1000); + } + + private async runPoll(response: DeviceCodeResponse): Promise { + const { namespace } = this.props; + let result: DeviceAuthPollResult; + try { + result = await pollDeviceAuth(namespace, response.deviceCode); + } catch { + this.schedulePoll(response); + return; + } + if (result.status === 'pending') { + this.schedulePoll(response); + } else if (result.status === 'slow_down') { + // RFC 8628 §3.5: increase interval by 5s on slow_down + this.schedulePoll({ ...response, interval: response.interval + 5 }); + } else if (result.status === 'authorized') { + this.props.onSuccess(result.token); + } else if (result.status === 'expired') { + this.setState({ error: 'The code has expired. Please try again.' }); + } else { + this.setState({ error: result.message }); + } + } + + private stopPolling(): void { + if (this.pollTimer !== undefined) { + clearTimeout(this.pollTimer); + this.pollTimer = undefined; + } + } + + private handleClose(): void { + this.stopPolling(); + this.props.onCloseModal(); + } + + public render(): React.ReactElement { + const { isOpen } = this.props; + const { deviceCode, error, isLoading } = this.state; + const modalTitle = 'Connect to GitHub'; + + return ( + this.handleClose()} + > + + + {isLoading && } + {error && ( + + {error} + + )} + {deviceCode && !error && ( + +
+ + Your one-time code: + + + {deviceCode.userCode} + + + this.handleCopyToClipboard()} + > +
+ + Copy the code above + + Open{' '} + + {deviceCode.verificationUri} + {' '} + and paste the code + + Return here — the page will update automatically + +
+ +
+
+ )} +
+ + {deviceCode && !error && ( + + )} + + +
+ ); + } +} + +export const ConnectModal = connector(ConnectModalClass); +export default ConnectModal; diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/DeleteModal/__mocks__/index.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/DeleteModal/__mocks__/index.tsx new file mode 100644 index 0000000000..8cdd28414c --- /dev/null +++ b/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/DeleteModal/__mocks__/index.tsx @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2018-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import React from 'react'; + +import { Props } from '..'; + +export class DeviceAuthTokensDeleteModal extends React.PureComponent { + render() { + const { isOpen, tokens, onDelete, onCloseModal } = this.props; + + if (!isOpen) { + return null; + } + + return ( +
+

Delete Device Authentication Token

+ + +
+ ); + } +} diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/DeleteModal/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/DeleteModal/__tests__/index.spec.tsx new file mode 100644 index 0000000000..ec5bbb6b01 --- /dev/null +++ b/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/DeleteModal/__tests__/index.spec.tsx @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2018-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { api } from '@eclipse-che/common'; +import React from 'react'; + +import { DeviceAuthTokensDeleteModal } from '@/pages/UserPreferences/DeviceAuthTokens/DeleteModal'; +import getComponentRenderer, { fireEvent, screen } from '@/services/__mocks__/getComponentRenderer'; + +const token: api.DeviceAuthToken = { + name: 'device-authentication-secret-abc12', + creationTimestamp: '2024-01-01T00:00:00.000Z', +}; +const token2: api.DeviceAuthToken = { + name: 'device-authentication-secret-xyz34', + creationTimestamp: '2024-02-01T00:00:00.000Z', +}; + +const mockOnDelete = jest.fn(); +const mockOnClose = jest.fn(); + +const { renderComponent } = getComponentRenderer( + ({ isOpen, tokens }: { isOpen: boolean; tokens: api.DeviceAuthToken[] }) => ( + + ), +); + +describe('DeviceAuthTokensDeleteModal', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should not render when closed', () => { + renderComponent({ isOpen: false, tokens: [token] }); + expect(screen.queryByRole('dialog')).toBeNull(); + }); + + it('should render when open with a single token', () => { + renderComponent({ isOpen: true, tokens: [token] }); + expect(screen.queryByRole('dialog')).not.toBeNull(); + expect(screen.getByText(/Delete Device Authentication Token/)).not.toBeNull(); + }); + + it('should render a bulk title when multiple tokens', () => { + renderComponent({ isOpen: true, tokens: [token, token2] }); + expect(screen.getByText(/Delete 2 Device Authentication Tokens/)).not.toBeNull(); + }); + + it('should call onCloseModal when Cancel is clicked', () => { + renderComponent({ isOpen: true, tokens: [token] }); + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + expect(mockOnClose).toHaveBeenCalled(); + }); + + it('should have Delete button disabled until checkbox is checked', () => { + renderComponent({ isOpen: true, tokens: [token] }); + const deleteButton = screen.getByRole('button', { name: 'Delete' }); + expect(deleteButton).toBeDisabled(); + + fireEvent.click(screen.getByRole('checkbox')); + expect(deleteButton).not.toBeDisabled(); + }); + + it('should call onDelete with tokens array when Delete button is clicked', () => { + renderComponent({ isOpen: true, tokens: [token] }); + fireEvent.click(screen.getByRole('checkbox')); + fireEvent.click(screen.getByRole('button', { name: 'Delete' })); + expect(mockOnDelete).toHaveBeenCalledWith([token]); + }); +}); diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/DeleteModal/index.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/DeleteModal/index.tsx new file mode 100644 index 0000000000..f068ae5395 --- /dev/null +++ b/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/DeleteModal/index.tsx @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2018-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { api } from '@eclipse-che/common'; +import { + Button, + ButtonVariant, + Checkbox, + Content, + Modal, + ModalBody, + ModalFooter, + ModalHeader, + ModalVariant, +} from '@patternfly/react-core'; +import React from 'react'; + +export type Props = { + isOpen: boolean; + tokens: api.DeviceAuthToken[]; + onCloseModal: () => void; + onDelete: (tokens: api.DeviceAuthToken[]) => void; +}; + +export type State = { + isChecked: boolean; +}; + +export class DeviceAuthTokensDeleteModal extends React.PureComponent { + constructor(props: Props) { + super(props); + this.state = { isChecked: false }; + } + + private handleDelete(): void { + const { tokens } = this.props; + if (tokens.length > 0) { + this.setState({ isChecked: false }); + this.props.onDelete(tokens); + } + } + + private handleCloseModal(): void { + this.setState({ isChecked: false }); + this.props.onCloseModal(); + } + + public render(): React.ReactElement { + const { isOpen, tokens } = this.props; + const { isChecked } = this.state; + + const count = tokens.length; + const modalTitle = + count === 1 + ? 'Delete Device Authentication Token' + : `Delete ${count} Device Authentication Tokens`; + + const bodyText = + count === 1 ? ( + + Are you sure you want to delete the token {tokens[0]?.name}? This removes + the token from Che. The GitHub authorization will remain active — to fully revoke access, + also visit{' '} + + github.com/settings/applications + + . + + ) : ( + + Are you sure you want to delete {count} Device Authentication Tokens? + This removes the tokens from Che. To fully revoke GitHub access, also visit{' '} + + github.com/settings/applications + + . + + ); + + return ( + this.handleCloseModal()} + elementToFocus="[data-pf-initial-focus]" + > + + + + {bodyText} + this.setState({ isChecked: checked })} + /> + + + + + + + + ); + } +} diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/EmptyState/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/EmptyState/__tests__/index.spec.tsx new file mode 100644 index 0000000000..24046ef061 --- /dev/null +++ b/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/EmptyState/__tests__/index.spec.tsx @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2018-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import React from 'react'; + +import { DeviceAuthTokensEmptyState } from '@/pages/UserPreferences/DeviceAuthTokens/EmptyState'; +import getComponentRenderer, { fireEvent, screen } from '@/services/__mocks__/getComponentRenderer'; + +const { renderComponent } = getComponentRenderer(getComponent); + +describe('DeviceAuthTokensEmptyState', () => { + it('should render the empty state heading', () => { + renderComponent(); + + expect(screen.getByRole('heading', { name: 'No Device Authentication Tokens' })).not.toBeNull(); + }); + + it('should render the informational message', () => { + renderComponent(); + + expect( + screen.getByText(/Connect your GitHub account using device authorization/), + ).not.toBeNull(); + }); + + it('should not render "Connect to GitHub" button when isConnectEnabled is false', () => { + renderComponent(jest.fn(), false); + + expect(screen.queryByTestId('connect-github-button')).not.toBeInTheDocument(); + }); + + it('should call onConnect when "Connect to GitHub" is clicked', () => { + const onConnect = jest.fn(); + renderComponent(onConnect); + fireEvent.click(screen.getByTestId('connect-github-button')); + expect(onConnect).toHaveBeenCalled(); + }); +}); + +function getComponent( + onConnect: () => void = jest.fn(), + isConnectEnabled = true, +): React.ReactElement { + return ; +} diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/EmptyState/index.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/EmptyState/index.tsx new file mode 100644 index 0000000000..f5833f781d --- /dev/null +++ b/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/EmptyState/index.tsx @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2018-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { + Button, + EmptyState, + EmptyStateBody, + EmptyStateFooter, + EmptyStateVariant, +} from '@patternfly/react-core'; +import { KeyIcon } from '@patternfly/react-icons'; +import React from 'react'; + +export type Props = { + onConnect: () => void; + isConnectEnabled: boolean; +}; + +export class DeviceAuthTokensEmptyState extends React.PureComponent { + public render(): React.ReactElement { + return ( + + + Connect your GitHub account using device authorization to allow workspaces to clone + private repositories and interact with the GitHub API. + + {this.props.isConnectEnabled && ( + + + + )} + + ); + } +} diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/List/__mocks__/index.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/List/__mocks__/index.tsx new file mode 100644 index 0000000000..39530cd2fb --- /dev/null +++ b/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/List/__mocks__/index.tsx @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2018-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import React from 'react'; + +import { Props } from '..'; + +export class DeviceAuthTokensList extends React.PureComponent { + render() { + const { tokens, onDeleteTokens } = this.props; + + const entries = tokens.map(token => ( +
+ {token.name} + +
+ )); + + return
{entries}
; + } +} diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/List/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/List/__tests__/index.spec.tsx new file mode 100644 index 0000000000..33ae2e4f1a --- /dev/null +++ b/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/List/__tests__/index.spec.tsx @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2018-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { api } from '@eclipse-che/common'; +import React from 'react'; + +import { DeviceAuthTokensList } from '@/pages/UserPreferences/DeviceAuthTokens/List'; +import getComponentRenderer, { screen } from '@/services/__mocks__/getComponentRenderer'; + +const token1: api.DeviceAuthToken = { + name: 'device-authentication-secret-abc12', + creationTimestamp: '2024-01-01T00:00:00.000Z', +}; +const token2: api.DeviceAuthToken = { + name: 'device-authentication-secret-xyz34', + creationTimestamp: '2024-02-01T00:00:00.000Z', +}; + +const mockOnDeleteTokens = jest.fn(); + +const { renderComponent } = getComponentRenderer( + ({ tokens, isDisabled }: { tokens: api.DeviceAuthToken[]; isDisabled?: boolean }) => ( + + ), +); + +describe('DeviceAuthTokensList', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should render token cards', () => { + renderComponent({ tokens: [token1] }); + expect(screen.getByTestId('device-auth-token-row')).not.toBeNull(); + expect(screen.getByTestId('token-provider')).toHaveTextContent('GitHub'); + expect(screen.getByTestId('token-name')).toHaveTextContent(token1.name); + }); + + it('should render multiple token cards', () => { + renderComponent({ tokens: [token1, token2] }); + expect(screen.getAllByTestId('device-auth-token-row')).toHaveLength(2); + }); + + it('should render token actions toggle', () => { + renderComponent({ tokens: [token1] }); + expect(screen.getByTestId('token-actions-toggle')).not.toBeNull(); + }); + + it('should show valid indicator when token.valid is true', () => { + renderComponent({ tokens: [{ ...token1, valid: true }] }); + expect(screen.getByTitle('Token is valid')).not.toBeNull(); + }); + + it('should show invalid indicator when token.valid is false', () => { + renderComponent({ tokens: [{ ...token1, valid: false }] }); + expect(screen.getByTitle('Token has been revoked or expired')).not.toBeNull(); + }); +}); diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/List/index.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/List/index.tsx new file mode 100644 index 0000000000..40dc098b74 --- /dev/null +++ b/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/List/index.tsx @@ -0,0 +1,139 @@ +/* + * Copyright (c) 2018-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { api } from '@eclipse-che/common'; +import { + Card, + CardFooter, + CardHeader, + CardTitle, + Content, + ContentVariants, + Dropdown, + DropdownItem, + DropdownList, + MenuToggle, + MenuToggleElement, + PageSection, +} from '@patternfly/react-core'; +import { CheckCircleIcon, EllipsisVIcon, ExclamationCircleIcon } from '@patternfly/react-icons'; +import React from 'react'; + +import { getFormattedDate } from '@/services/helpers/dates'; + +export type Props = { + tokens: api.DeviceAuthToken[]; + isDisabled: boolean; + onDeleteTokens: (tokens: api.DeviceAuthToken[]) => void; +}; + +type State = { + openDropdown: string | undefined; +}; + +export class DeviceAuthTokensList extends React.PureComponent { + constructor(props: Props) { + super(props); + this.state = { openDropdown: undefined }; + } + + render(): React.ReactElement { + const { tokens, isDisabled, onDeleteTokens } = this.props; + const { openDropdown } = this.state; + + const cards = tokens.map(token => { + const added = getFormattedDate( + token.creationTimestamp ? new Date(token.creationTimestamp) : undefined, + ); + + return ( + + ) => ( + + this.setState(prev => ({ + openDropdown: prev.openDropdown === token.name ? undefined : token.name, + })) + } + isExpanded={openDropdown === token.name} + aria-label="Actions" + data-testid="token-actions-toggle" + > + + + )} + isOpen={openDropdown === token.name} + onOpenChange={isOpen => + this.setState({ openDropdown: isOpen ? token.name : undefined }) + } + popperProps={{ position: 'right' }} + > + + { + this.setState({ openDropdown: undefined }); + onDeleteTokens([token]); + }} + data-testid="delete-token-action" + > + Delete + + + + ), + }} + > + + {token.provider ?? 'GitHub'} + {token.valid === true && ( + + )} + {token.valid === false && ( + + )} + + + + + + {token.name} + + + Added: {added} + + + + + ); + }); + + return {cards}; + } +} diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/__tests__/index.spec.tsx new file mode 100644 index 0000000000..8293a24bf5 --- /dev/null +++ b/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/__tests__/index.spec.tsx @@ -0,0 +1,264 @@ +/* + * Copyright (c) 2018-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { StateMock } from '@react-mock/state'; +import React from 'react'; +import { Provider } from 'react-redux'; +import { Store } from 'redux'; + +import { container } from '@/inversify.config'; +import DeviceAuthTokens, { State } from '@/pages/UserPreferences/DeviceAuthTokens'; +import getComponentRenderer, { + fireEvent, + screen, + waitFor, + within, +} from '@/services/__mocks__/getComponentRenderer'; +import { AppAlerts } from '@/services/alerts/appAlerts'; +import { AlertItem } from '@/services/helpers/types'; +import { AppThunk } from '@/store'; +import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; +import { deviceAuthTokenActionCreators } from '@/store/DeviceAuthToken'; + +jest.mock('@/pages/UserPreferences/DeviceAuthTokens/ConnectModal'); +jest.mock('@/pages/UserPreferences/DeviceAuthTokens/DeleteModal'); +jest.mock('@/pages/UserPreferences/DeviceAuthTokens/List'); + +console.error = jest.fn(); + +const mockShowAlert = jest.fn(); + +const mockRequestDeviceAuthTokens = jest.fn(); +const mockDeleteDeviceAuthToken = jest.fn(); +jest.mock('@/store/DeviceAuthToken', () => ({ + ...jest.requireActual('@/store/DeviceAuthToken'), + deviceAuthTokenActionCreators: { + requestDeviceAuthTokens: + (...args): AppThunk => + async () => + mockRequestDeviceAuthTokens(...args), + deleteDeviceAuthToken: + (...args): AppThunk => + async () => + mockDeleteDeviceAuthToken(...args), + } as typeof deviceAuthTokenActionCreators, +})); + +const token1: { name: string; creationTimestamp: string } = { + name: 'device-authentication-secret-abc12', + creationTimestamp: '2024-01-01T00:00:00.000Z', +}; + +const { renderComponent } = getComponentRenderer(getComponent); + +describe('DeviceAuthTokens', () => { + let storeBuilder: MockStoreBuilder; + let localState: Partial; + + beforeEach(() => { + storeBuilder = new MockStoreBuilder().withClusterConfig({ githubDeviceAuthEnabled: true }); + + class MockAppAlerts extends AppAlerts { + showAlert(alert: AlertItem): void { + mockShowAlert(alert); + } + } + + container.snapshot(); + container.rebind(AppAlerts).to(MockAppAlerts).inSingletonScope(); + }); + + afterEach(() => { + jest.clearAllMocks(); + container.restore(); + localState = {}; + }); + + it('should render empty state when there are no tokens', () => { + const store = storeBuilder.build(); + renderComponent(store); + + expect( + screen.queryByRole('heading', { name: 'No Device Authentication Tokens' }), + ).not.toBeNull(); + }); + + it('should not render empty state with tokens', () => { + const store = storeBuilder.withDeviceAuthTokens({ tokens: [token1] }).build(); + renderComponent(store); + + expect(screen.queryByRole('heading', { name: 'No Device Authentication Tokens' })).toBeNull(); + }); + + it('should request tokens on mount', async () => { + const store = storeBuilder.build(); + renderComponent(store); + + await waitFor(() => expect(mockRequestDeviceAuthTokens).toHaveBeenCalled()); + }); + + describe('connect flow', () => { + it('should open ConnectModal when "Connect to GitHub" is clicked on empty state', () => { + const store = storeBuilder.build(); + renderComponent(store); + + expect(screen.getByTestId('connect-modal')).toHaveAttribute('data-is-open', 'false'); + + const connectBtn = screen.getByTestId('connect-github-button'); + fireEvent.click(connectBtn); + + expect(screen.getByTestId('connect-modal')).toHaveAttribute('data-is-open', 'true'); + }); + + it('should close ConnectModal when cancel is clicked', () => { + const store = storeBuilder.build(); + localState = { isConnectOpen: true }; + renderComponent(store, localState); + + expect(screen.getByTestId('connect-modal')).toHaveAttribute('data-is-open', 'true'); + + const closeButton = screen.getByTestId('mock-close-button'); + fireEvent.click(closeButton); + + expect(screen.getByTestId('connect-modal')).toHaveAttribute('data-is-open', 'false'); + }); + + it('should close ConnectModal and show success alert on connect success', async () => { + const store = storeBuilder.build(); + localState = { isConnectOpen: true }; + renderComponent(store, localState); + + expect(screen.getByTestId('connect-modal')).toHaveAttribute('data-is-open', 'true'); + + const successButton = screen.getByTestId('mock-success-button'); + fireEvent.click(successButton); + + await waitFor(() => + expect(mockShowAlert).toHaveBeenCalledWith({ + key: 'device-auth-token-connected', + title: 'GitHub account connected successfully.', + variant: 'success', + } as AlertItem), + ); + + expect(screen.getByTestId('connect-modal')).toHaveAttribute('data-is-open', 'false'); + }); + + it('should refresh tokens after connect success', async () => { + const store = storeBuilder.build(); + localState = { isConnectOpen: true }; + renderComponent(store, localState); + + mockRequestDeviceAuthTokens.mockClear(); + const successButton = screen.getByTestId('mock-success-button'); + fireEvent.click(successButton); + + await waitFor(() => expect(mockRequestDeviceAuthTokens).toHaveBeenCalledTimes(1)); + }); + }); + + describe('delete flow', () => { + it('should open delete modal when delete is triggered from list', () => { + const store = storeBuilder.withDeviceAuthTokens({ tokens: [token1] }).build(); + renderComponent(store); + + const entries = screen.getAllByTestId('device-auth-token-entry'); + const deleteButton = within(entries[0]).getByRole('button', { name: 'Delete' }); + fireEvent.click(deleteButton); + + expect(screen.queryByTestId('modal-delete-device-auth-token')).not.toBeNull(); + }); + + it('should close the delete modal', () => { + const store = storeBuilder.withDeviceAuthTokens({ tokens: [token1] }).build(); + localState = { isDeleteOpen: true, deletingTokens: [token1] }; + renderComponent(store, localState); + + expect(screen.queryByTestId('modal-delete-device-auth-token')).not.toBeNull(); + + const closeButton = screen.getByTestId('close-modal'); + fireEvent.click(closeButton); + + expect(screen.queryByTestId('modal-delete-device-auth-token')).toBeNull(); + }); + + it('should delete token and show success notification', async () => { + const store = storeBuilder.withDeviceAuthTokens({ tokens: [token1] }).build(); + localState = { isDeleteOpen: true, deletingTokens: [token1] }; + renderComponent(store, localState); + + const deleteButton = screen.getByTestId('delete-token'); + fireEvent.click(deleteButton); + + await waitFor(() => expect(mockDeleteDeviceAuthToken).toHaveBeenCalledWith(token1.name)); + + await waitFor(() => + expect(mockShowAlert).toHaveBeenCalledWith({ + key: 'device-auth-token-deleted', + title: 'Device Authentication token deleted successfully.', + variant: 'success', + } as AlertItem), + ); + }); + + it('should show error notification when delete fails', async () => { + const store = storeBuilder.withDeviceAuthTokens({ tokens: [token1] }).build(); + localState = { isDeleteOpen: true, deletingTokens: [token1] }; + renderComponent(store, localState); + + mockDeleteDeviceAuthToken.mockRejectedValueOnce(new Error('delete-error')); + + const deleteButton = screen.getByTestId('delete-token'); + fireEvent.click(deleteButton); + + await waitFor(() => + expect(mockShowAlert).toHaveBeenCalledWith(expect.objectContaining({ variant: 'danger' })), + ); + }); + }); + + describe('component updated', () => { + it('should report error when an error occurs', async () => { + const store = storeBuilder.build(); + const { reRenderComponent } = renderComponent(store); + + const errorMessage = 'device-auth-token-error'; + const nextStore = new MockStoreBuilder() + .withDeviceAuthTokens({ tokens: [], error: errorMessage }, false) + .build(); + reRenderComponent(nextStore); + + await waitFor(() => expect(mockShowAlert).toHaveBeenCalled()); + expect(mockShowAlert).toHaveBeenCalledWith({ + key: 'device-auth-token-error', + title: errorMessage, + variant: 'danger', + } as AlertItem); + }); + }); +}); + +function getComponent(store: Store, localState?: Partial): React.ReactElement { + const component = ; + if (localState) { + return ( + + {component} + + ); + } + return ( + + + + ); +} diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/index.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/index.tsx new file mode 100644 index 0000000000..c85d5bbe69 --- /dev/null +++ b/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/index.tsx @@ -0,0 +1,200 @@ +/* + * Copyright (c) 2018-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { api, helpers } from '@eclipse-che/common'; +import { AlertVariant, PageSection } from '@patternfly/react-core'; +import React from 'react'; +import { connect, ConnectedProps } from 'react-redux'; + +import ProgressIndicator from '@/components/Progress'; +import { lazyInject } from '@/inversify.config'; +import ConnectModal from '@/pages/UserPreferences/DeviceAuthTokens/ConnectModal'; +import { DeviceAuthTokensDeleteModal } from '@/pages/UserPreferences/DeviceAuthTokens/DeleteModal'; +import { DeviceAuthTokensEmptyState } from '@/pages/UserPreferences/DeviceAuthTokens/EmptyState'; +import { DeviceAuthTokensList } from '@/pages/UserPreferences/DeviceAuthTokens/List'; +import { AppAlerts } from '@/services/alerts/appAlerts'; +import { RootState } from '@/store'; +import { selectGithubDeviceAuthEnabled } from '@/store/ClusterConfig/selectors'; +import { + deviceAuthTokenActionCreators, + selectDeviceAuthTokenError, + selectDeviceAuthTokenIsLoading, + selectDeviceAuthTokens, +} from '@/store/DeviceAuthToken'; +import { selectDefaultNamespace } from '@/store/InfrastructureNamespaces/selectors'; + +export type Props = MappedProps; + +export type State = { + isDeleteOpen: boolean; + deletingTokens: api.DeviceAuthToken[]; + isConnectOpen: boolean; +}; + +class DeviceAuthTokens extends React.PureComponent { + @lazyInject(AppAlerts) + private readonly appAlerts: AppAlerts; + + constructor(props: Props) { + super(props); + this.state = { + isDeleteOpen: false, + deletingTokens: [], + isConnectOpen: false, + }; + } + + public async componentDidMount(): Promise { + if (this.props.isLoading) { + return; + } + try { + await this.props.requestDeviceAuthTokens(); + } catch (e) { + this.appAlerts.showAlert({ + key: 'request-device-auth-tokens-failed', + variant: AlertVariant.danger, + title: helpers.errors.getMessage(e), + }); + } + } + + public componentDidUpdate(prevProps: Props): void { + const { error } = this.props; + if (error && error !== prevProps.error) { + this.appAlerts.showAlert({ + key: 'device-auth-token-error', + title: helpers.errors.getMessage(error), + variant: AlertVariant.danger, + }); + } + } + + private handleShowDeleteModal(tokens: api.DeviceAuthToken[]): void { + if (tokens.length === 0) { + return; + } + this.setState({ isDeleteOpen: true, deletingTokens: tokens }); + } + + private handleCloseDeleteModal(): void { + this.setState({ isDeleteOpen: false, deletingTokens: [] }); + } + + private async handleDelete(tokens: api.DeviceAuthToken[]): Promise { + this.setState({ isDeleteOpen: false, deletingTokens: [] }); + const results = await Promise.allSettled( + tokens.map(token => this.props.deleteDeviceAuthToken(token.name)), + ); + const failed = results.filter(r => r.status === 'rejected'); + if (failed.length === 0) { + this.appAlerts.showAlert({ + key: 'device-auth-token-deleted', + title: + tokens.length === 1 + ? 'Device Authentication token deleted successfully.' + : `${tokens.length} Device Authentication tokens deleted successfully.`, + variant: AlertVariant.success, + }); + } else { + this.appAlerts.showAlert({ + key: 'device-auth-token-delete-failed', + title: `Failed to delete ${failed.length} of ${tokens.length} token(s).`, + variant: AlertVariant.danger, + }); + } + } + + private handleOpenConnectModal(): void { + this.setState({ isConnectOpen: true }); + } + + private handleCloseConnectModal(): void { + this.setState({ isConnectOpen: false }); + } + + private async handleConnectSuccess(): Promise { + this.setState({ isConnectOpen: false }); + this.appAlerts.showAlert({ + key: 'device-auth-token-connected', + title: 'GitHub account connected successfully.', + variant: AlertVariant.success, + }); + try { + await this.props.requestDeviceAuthTokens(); + } catch { + // ignore refresh errors + } + } + + public render(): React.ReactElement { + const { tokens, isLoading, namespace } = this.props; + const { isDeleteOpen, deletingTokens, isConnectOpen } = this.state; + + const showEmptyState = tokens.length === 0 && !isLoading; + const showList = tokens.length > 0; + + return ( + + + this.handleCloseDeleteModal()} + onDelete={tokens => this.handleDelete(tokens)} + /> + this.handleCloseConnectModal()} + onSuccess={() => this.handleConnectSuccess()} + /> + + {showEmptyState && ( + this.handleOpenConnectModal()} + isConnectEnabled={this.props.githubDeviceAuthEnabled} + /> + )} + {showList && ( + this.handleShowDeleteModal(selectedTokens)} + /> + )} + + + ); + } +} + +const mapStateToProps = (state: RootState) => ({ + tokens: selectDeviceAuthTokens(state), + isLoading: selectDeviceAuthTokenIsLoading(state), + error: selectDeviceAuthTokenError(state), + namespace: selectDefaultNamespace(state).name, + githubDeviceAuthEnabled: selectGithubDeviceAuthEnabled(state), +}); + +const connector = connect( + mapStateToProps, + { + requestDeviceAuthTokens: deviceAuthTokenActionCreators.requestDeviceAuthTokens, + deleteDeviceAuthToken: deviceAuthTokenActionCreators.deleteDeviceAuthToken, + }, + null, + { forwardRef: true }, +); + +type MappedProps = ConnectedProps; +export default connector(DeviceAuthTokens); diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/__tests__/__snapshots__/index.spec.tsx.snap b/packages/dashboard-frontend/src/pages/UserPreferences/__tests__/__snapshots__/index.spec.tsx.snap index 09c8c4d641..39d21bd33f 100644 --- a/packages/dashboard-frontend/src/pages/UserPreferences/__tests__/__snapshots__/index.spec.tsx.snap +++ b/packages/dashboard-frontend/src/pages/UserPreferences/__tests__/__snapshots__/index.spec.tsx.snap @@ -123,6 +123,23 @@ exports[`UserPreferences snapshot 1`] = ` SSH Keys +
{ }); it('should wrap around to the first tab on ArrowRight from the last tab', () => { - const location = buildUserPreferencesLocation(UserPreferencesTab.SSH_KEYS); + const location = buildUserPreferencesLocation(UserPreferencesTab.DEVICE_AUTH_TOKENS); renderComponent(location); - const tab = screen.getByRole('tab', { name: 'SSH Keys' }); + const tab = screen.getByRole('tab', { name: 'Device Auth Token' }); fireEvent.keyDown(tab, { key: 'ArrowRight' }); expect(mockNavigate).toHaveBeenCalledWith( @@ -212,7 +213,7 @@ describe('UserPreferences', () => { fireEvent.keyDown(tab, { key: 'ArrowLeft' }); expect(mockNavigate).toHaveBeenCalledWith( - expect.stringContaining(`tab=${UserPreferencesTab.SSH_KEYS}`), + expect.stringContaining(`tab=${UserPreferencesTab.DEVICE_AUTH_TOKENS}`), ); }); @@ -236,7 +237,7 @@ describe('UserPreferences', () => { fireEvent.keyDown(tab, { key: 'End' }); expect(mockNavigate).toHaveBeenCalledWith( - expect.stringContaining(`tab=${UserPreferencesTab.SSH_KEYS}`), + expect.stringContaining(`tab=${UserPreferencesTab.DEVICE_AUTH_TOKENS}`), ); }); diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/index.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/index.tsx index 143074b5b3..462301b2e2 100644 --- a/packages/dashboard-frontend/src/pages/UserPreferences/index.tsx +++ b/packages/dashboard-frontend/src/pages/UserPreferences/index.tsx @@ -18,6 +18,7 @@ import { Location, NavigateFunction } from 'react-router-dom'; import Head from '@/components/Head'; import AiProviderKeys from '@/pages/UserPreferences/AiProviderKeys'; import ContainerRegistries from '@/pages/UserPreferences/ContainerRegistriesTab'; +import DeviceAuthTokens from '@/pages/UserPreferences/DeviceAuthTokens'; import GitConfig from '@/pages/UserPreferences/GitConfig'; import GitServices from '@/pages/UserPreferences/GitServices'; import PersonalAccessTokens from '@/pages/UserPreferences/PersonalAccessTokens'; @@ -45,6 +46,7 @@ class UserPreferences extends React.PureComponent { UserPreferencesTab.PERSONAL_ACCESS_TOKENS, UserPreferencesTab.GITCONFIG, UserPreferencesTab.SSH_KEYS, + UserPreferencesTab.DEVICE_AUTH_TOKENS, ]; constructor(props: Props) { @@ -68,6 +70,7 @@ class UserPreferences extends React.PureComponent { pathname === ROUTE.USER_PREFERENCES && ((tab === UserPreferencesTab.AI_PROVIDER_KEYS && aiEnabled) || tab === UserPreferencesTab.CONTAINER_REGISTRIES || + tab === UserPreferencesTab.DEVICE_AUTH_TOKENS || tab === UserPreferencesTab.GITCONFIG || tab === UserPreferencesTab.GIT_SERVICES || tab === UserPreferencesTab.PERSONAL_ACCESS_TOKENS || @@ -178,6 +181,13 @@ class UserPreferences extends React.PureComponent { > + + + {this.props.aiEnabled && ( diff --git a/packages/dashboard-frontend/src/services/backend-client/deviceAuthTokenApi.ts b/packages/dashboard-frontend/src/services/backend-client/deviceAuthTokenApi.ts new file mode 100644 index 0000000000..37e802a52a --- /dev/null +++ b/packages/dashboard-frontend/src/services/backend-client/deviceAuthTokenApi.ts @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2018-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { api, helpers } from '@eclipse-che/common'; + +export type DeviceCodeResponse = api.DeviceCodeResponse; +export type DeviceAuthPollResult = api.DeviceAuthPollResult; + +import { AxiosWrapper } from '@/services/axios-wrapper/axiosWrapper'; +import { dashboardBackendPrefix } from '@/services/backend-client/const'; + +export async function fetchDeviceAuthTokens(namespace: string): Promise { + try { + const response = await AxiosWrapper.createToRetryMissedBearerTokenError().get( + `${dashboardBackendPrefix}/namespace/${namespace}/device-auth-token`, + ); + return response.data; + } catch (e) { + throw new Error( + `Failed to fetch Device Authentication tokens. ${helpers.errors.getMessage(e)}`, + ); + } +} + +export async function deleteDeviceAuthToken(namespace: string, tokenName: string): Promise { + try { + await AxiosWrapper.createToRetryMissedBearerTokenError().delete( + `${dashboardBackendPrefix}/namespace/${namespace}/device-auth-token/${encodeURIComponent(tokenName)}`, + ); + } catch (e) { + throw new Error( + `Failed to delete Device Authentication token. ${helpers.errors.getMessage(e)}`, + ); + } +} + +export async function initiateDeviceAuth(namespace: string): Promise { + try { + const response = await AxiosWrapper.createToRetryMissedBearerTokenError().post( + `${dashboardBackendPrefix}/namespace/${namespace}/device-auth-token/initiate`, + ); + return response.data; + } catch (e) { + throw new Error(`Failed to initiate Device Authentication. ${helpers.errors.getMessage(e)}`); + } +} + +export async function pollDeviceAuth( + namespace: string, + deviceCode: string, +): Promise { + try { + const response = await AxiosWrapper.createToRetryMissedBearerTokenError().post( + `${dashboardBackendPrefix}/namespace/${namespace}/device-auth-token/poll`, + { deviceCode }, + ); + return response.data; + } catch (e) { + throw new Error(`Failed to poll Device Authentication. ${helpers.errors.getMessage(e)}`); + } +} diff --git a/packages/dashboard-frontend/src/services/helpers/types.ts b/packages/dashboard-frontend/src/services/helpers/types.ts index c5bcaa6f98..e9f3ed0e35 100644 --- a/packages/dashboard-frontend/src/services/helpers/types.ts +++ b/packages/dashboard-frontend/src/services/helpers/types.ts @@ -127,6 +127,7 @@ export enum WorkspaceAction { export enum UserPreferencesTab { AI_PROVIDER_KEYS = 'AiProviderKeys', CONTAINER_REGISTRIES = 'ContainerRegistries', + DEVICE_AUTH_TOKENS = 'DeviceAuthTokens', GIT_SERVICES = 'GitServices', GITCONFIG = 'Gitconfig', PERSONAL_ACCESS_TOKENS = 'PersonalAccessTokens', diff --git a/packages/dashboard-frontend/src/store/ClusterConfig/__tests__/reducer.spec.ts b/packages/dashboard-frontend/src/store/ClusterConfig/__tests__/reducer.spec.ts index 2fe503441c..07f808a98f 100644 --- a/packages/dashboard-frontend/src/store/ClusterConfig/__tests__/reducer.spec.ts +++ b/packages/dashboard-frontend/src/store/ClusterConfig/__tests__/reducer.spec.ts @@ -32,6 +32,7 @@ describe('ClusterConfig, reducer', () => { clusterConfig: { allWorkspacesLimit: -1, runningWorkspacesLimit: 1, + githubDeviceAuthEnabled: false, }, isLoading: true, error: undefined, @@ -46,6 +47,7 @@ describe('ClusterConfig, reducer', () => { allWorkspacesLimit: -1, runningWorkspacesLimit: 1, currentArchitecture: 'x86_64', + githubDeviceAuthEnabled: true, }; const action = clusterConfigReceiveAction(clusterConfig); @@ -65,6 +67,7 @@ describe('ClusterConfig, reducer', () => { clusterConfig: { allWorkspacesLimit: -1, runningWorkspacesLimit: 1, + githubDeviceAuthEnabled: false, }, isLoading: false, error: 'Error message', @@ -79,6 +82,7 @@ describe('ClusterConfig, reducer', () => { clusterConfig: { allWorkspacesLimit: -1, runningWorkspacesLimit: 1, + githubDeviceAuthEnabled: false, }, isLoading: false, error: undefined, diff --git a/packages/dashboard-frontend/src/store/ClusterConfig/reducer.ts b/packages/dashboard-frontend/src/store/ClusterConfig/reducer.ts index 0ede1299ac..f8b98ddb02 100644 --- a/packages/dashboard-frontend/src/store/ClusterConfig/reducer.ts +++ b/packages/dashboard-frontend/src/store/ClusterConfig/reducer.ts @@ -31,6 +31,7 @@ export const unloadedState: State = { runningWorkspacesLimit: 1, allWorkspacesLimit: -1, currentArchitecture: undefined, + githubDeviceAuthEnabled: false, }, }; diff --git a/packages/dashboard-frontend/src/store/ClusterConfig/selectors.ts b/packages/dashboard-frontend/src/store/ClusterConfig/selectors.ts index 1e2a7a4a5b..206832fb74 100644 --- a/packages/dashboard-frontend/src/store/ClusterConfig/selectors.ts +++ b/packages/dashboard-frontend/src/store/ClusterConfig/selectors.ts @@ -42,3 +42,8 @@ export const selectCurrentArchitecture = createSelector( selectState, state => state.clusterConfig.currentArchitecture as Architecture, ); + +export const selectGithubDeviceAuthEnabled = createSelector( + selectState, + state => state.clusterConfig.githubDeviceAuthEnabled === true, +); diff --git a/packages/dashboard-frontend/src/store/DeviceAuthToken/__tests__/actions.spec.ts b/packages/dashboard-frontend/src/store/DeviceAuthToken/__tests__/actions.spec.ts new file mode 100644 index 0000000000..d933ef32dc --- /dev/null +++ b/packages/dashboard-frontend/src/store/DeviceAuthToken/__tests__/actions.spec.ts @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2018-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { api, helpers } from '@eclipse-che/common'; + +import { + deleteDeviceAuthToken, + fetchDeviceAuthTokens, + initiateDeviceAuth as apiInitiateDeviceAuth, +} from '@/services/backend-client/deviceAuthTokenApi'; +import { createMockStore } from '@/store/__mocks__/mockActionsTestStore'; +import { + actionCreators, + deviceAuthTokenErrorAction, + deviceAuthTokenReceiveAction, + deviceAuthTokenRemoveAction, + deviceAuthTokenRequestAction, +} from '@/store/DeviceAuthToken/actions'; +import * as infrastructureNamespacesSelectors from '@/store/InfrastructureNamespaces/selectors'; +import { verifyAuthorized } from '@/store/SanityCheck'; + +jest.mock('@eclipse-che/common'); +jest.mock('@/services/backend-client/deviceAuthTokenApi'); +jest.mock('@/store/SanityCheck'); + +const mockNamespace = 'test-namespace'; +jest + .spyOn(infrastructureNamespacesSelectors, 'selectDefaultNamespace') + .mockReturnValue({ name: mockNamespace, attributes: { default: 'true', phase: 'Active' } }); + +const token1: api.DeviceAuthToken = { name: 'device-authentication-secret-abc12' }; + +describe('DeviceAuthToken, actions', () => { + let store: ReturnType; + + beforeEach(() => { + store = createMockStore({}); + jest.clearAllMocks(); + }); + + describe('requestDeviceAuthTokens', () => { + it('should dispatch receive action on successful fetch', async () => { + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (fetchDeviceAuthTokens as jest.Mock).mockResolvedValue([token1]); + + await store.dispatch(actionCreators.requestDeviceAuthTokens()); + + const actions = store.getActions(); + expect(actions[0]).toEqual(deviceAuthTokenRequestAction()); + expect(actions[1]).toEqual(deviceAuthTokenReceiveAction([token1])); + }); + + it('should dispatch error action on failed fetch', async () => { + const errorMessage = 'Network error'; + + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (fetchDeviceAuthTokens as jest.Mock).mockRejectedValue(new Error(errorMessage)); + (helpers.errors.getMessage as jest.Mock).mockReturnValue(errorMessage); + + await expect(store.dispatch(actionCreators.requestDeviceAuthTokens())).rejects.toThrow( + errorMessage, + ); + + const actions = store.getActions(); + expect(actions[0]).toEqual(deviceAuthTokenRequestAction()); + expect(actions[1]).toEqual(deviceAuthTokenErrorAction(errorMessage)); + }); + }); + + describe('deleteDeviceAuthToken', () => { + it('should dispatch remove action on successful delete', async () => { + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (deleteDeviceAuthToken as jest.Mock).mockResolvedValue(undefined); + + await store.dispatch(actionCreators.deleteDeviceAuthToken(token1.name)); + + const actions = store.getActions(); + expect(actions[0]).toEqual(deviceAuthTokenRequestAction()); + expect(actions[1]).toEqual(deviceAuthTokenRemoveAction(token1.name)); + }); + + it('should dispatch error action on failed delete', async () => { + const errorMessage = 'Delete failed'; + + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (deleteDeviceAuthToken as jest.Mock).mockRejectedValue(new Error(errorMessage)); + (helpers.errors.getMessage as jest.Mock).mockReturnValue(errorMessage); + + await expect( + store.dispatch(actionCreators.deleteDeviceAuthToken(token1.name)), + ).rejects.toThrow(errorMessage); + + const actions = store.getActions(); + expect(actions[0]).toEqual(deviceAuthTokenRequestAction()); + expect(actions[1]).toEqual(deviceAuthTokenErrorAction(errorMessage)); + }); + }); + + describe('initiateDeviceAuth', () => { + it('should return DeviceCodeResponse on success', async () => { + const response = { + deviceCode: 'dev-code', + userCode: 'ABCD-1234', + verificationUri: 'https://github.com/login/device', + interval: 5, + }; + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (apiInitiateDeviceAuth as jest.Mock).mockResolvedValue(response); + + const result = await store.dispatch(actionCreators.initiateDeviceAuth()); + expect(result).toEqual(response); + expect(apiInitiateDeviceAuth).toHaveBeenCalledWith(mockNamespace); + }); + + it('should throw on API failure', async () => { + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (apiInitiateDeviceAuth as jest.Mock).mockRejectedValue(new Error('Network error')); + + await expect(store.dispatch(actionCreators.initiateDeviceAuth())).rejects.toThrow( + 'Network error', + ); + }); + }); +}); diff --git a/packages/dashboard-frontend/src/store/DeviceAuthToken/__tests__/reducers.spec.ts b/packages/dashboard-frontend/src/store/DeviceAuthToken/__tests__/reducers.spec.ts new file mode 100644 index 0000000000..d34825fdcc --- /dev/null +++ b/packages/dashboard-frontend/src/store/DeviceAuthToken/__tests__/reducers.spec.ts @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2018-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { api } from '@eclipse-che/common'; +import { UnknownAction } from 'redux'; + +import { + deviceAuthTokenErrorAction, + deviceAuthTokenReceiveAction, + deviceAuthTokenRemoveAction, + deviceAuthTokenRequestAction, +} from '@/store/DeviceAuthToken/actions'; +import { DeviceAuthTokenState, reducer, unloadedState } from '@/store/DeviceAuthToken/reducer'; + +describe('DeviceAuthToken, reducer', () => { + let initialState: DeviceAuthTokenState; + + beforeEach(() => { + initialState = { ...unloadedState }; + }); + + it('should handle deviceAuthTokenRequestAction', () => { + const action = deviceAuthTokenRequestAction(); + const expectedState: DeviceAuthTokenState = { + ...initialState, + isLoading: true, + }; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should handle deviceAuthTokenReceiveAction', () => { + const tokens = [{ name: 'device-authentication-secret-abc12' }] as api.DeviceAuthToken[]; + const action = deviceAuthTokenReceiveAction(tokens); + const expectedState: DeviceAuthTokenState = { + ...initialState, + isLoading: false, + tokens, + }; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should handle deviceAuthTokenRemoveAction — filters by name', () => { + const token1: api.DeviceAuthToken = { name: 'device-authentication-secret-abc12' }; + const token2: api.DeviceAuthToken = { name: 'device-authentication-secret-xyz34' }; + const stateWithTokens: DeviceAuthTokenState = { + ...initialState, + tokens: [token1, token2], + }; + + const action = deviceAuthTokenRemoveAction(token1.name); + const expectedState: DeviceAuthTokenState = { + ...stateWithTokens, + isLoading: false, + tokens: [token2], + }; + + expect(reducer(stateWithTokens, action)).toEqual(expectedState); + }); + + it('should handle deviceAuthTokenErrorAction', () => { + const error = 'Something went wrong'; + const action = deviceAuthTokenErrorAction(error); + const expectedState: DeviceAuthTokenState = { + ...initialState, + isLoading: false, + error, + }; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should return the current state for unknown actions', () => { + const unknownAction = { type: 'UNKNOWN_ACTION' } as UnknownAction; + expect(reducer(initialState, unknownAction)).toEqual(initialState); + }); +}); diff --git a/packages/dashboard-frontend/src/store/DeviceAuthToken/__tests__/selectors.spec.ts b/packages/dashboard-frontend/src/store/DeviceAuthToken/__tests__/selectors.spec.ts new file mode 100644 index 0000000000..6de8343be4 --- /dev/null +++ b/packages/dashboard-frontend/src/store/DeviceAuthToken/__tests__/selectors.spec.ts @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2018-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { RootState } from '@/store'; +import { + selectDeviceAuthTokenError, + selectDeviceAuthTokenIsLoading, + selectDeviceAuthTokens, +} from '@/store/DeviceAuthToken/selectors'; + +describe('DeviceAuthToken, selectors', () => { + const mockState = { + deviceAuthToken: { + isLoading: true, + tokens: [ + { name: 'device-authentication-secret-abc12' }, + { name: 'device-authentication-secret-xyz34' }, + ], + error: 'Something went wrong', + }, + } as RootState; + + it('should select isLoading', () => { + expect(selectDeviceAuthTokenIsLoading(mockState)).toBe(true); + }); + + it('should select tokens', () => { + expect(selectDeviceAuthTokens(mockState)).toEqual([ + { name: 'device-authentication-secret-abc12' }, + { name: 'device-authentication-secret-xyz34' }, + ]); + }); + + it('should select error', () => { + expect(selectDeviceAuthTokenError(mockState)).toBe('Something went wrong'); + }); +}); diff --git a/packages/dashboard-frontend/src/store/DeviceAuthToken/actions.ts b/packages/dashboard-frontend/src/store/DeviceAuthToken/actions.ts new file mode 100644 index 0000000000..54d417c100 --- /dev/null +++ b/packages/dashboard-frontend/src/store/DeviceAuthToken/actions.ts @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2018-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { api, helpers } from '@eclipse-che/common'; +import { createAction } from '@reduxjs/toolkit'; + +import { + deleteDeviceAuthToken, + DeviceCodeResponse, + fetchDeviceAuthTokens, + initiateDeviceAuth as apiInitiateDeviceAuth, +} from '@/services/backend-client/deviceAuthTokenApi'; +import { AppThunk } from '@/store'; +import { selectDefaultNamespace } from '@/store/InfrastructureNamespaces/selectors'; +import { verifyAuthorized } from '@/store/SanityCheck'; + +export const deviceAuthTokenRequestAction = createAction('deviceAuthToken/request'); +export const deviceAuthTokenReceiveAction = + createAction('deviceAuthToken/receive'); +export const deviceAuthTokenRemoveAction = createAction('deviceAuthToken/remove'); +export const deviceAuthTokenErrorAction = createAction('deviceAuthToken/error'); + +export const actionCreators = { + requestDeviceAuthTokens: (): AppThunk => async (dispatch, getState) => { + const state = getState(); + const namespace = selectDefaultNamespace(state).name; + try { + await verifyAuthorized(dispatch, getState); + + dispatch(deviceAuthTokenRequestAction()); + + const tokens = await fetchDeviceAuthTokens(namespace); + dispatch(deviceAuthTokenReceiveAction(tokens)); + } catch (e) { + const errorMessage = helpers.errors.getMessage(e); + dispatch(deviceAuthTokenErrorAction(errorMessage)); + throw e; + } + }, + + deleteDeviceAuthToken: + (tokenName: string): AppThunk => + async (dispatch, getState) => { + const state = getState(); + const namespace = selectDefaultNamespace(state).name; + try { + await verifyAuthorized(dispatch, getState); + + dispatch(deviceAuthTokenRequestAction()); + + await deleteDeviceAuthToken(namespace, tokenName); + dispatch(deviceAuthTokenRemoveAction(tokenName)); + } catch (e) { + const errorMessage = helpers.errors.getMessage(e); + dispatch(deviceAuthTokenErrorAction(errorMessage)); + throw e; + } + }, + + initiateDeviceAuth: (): AppThunk> => async (dispatch, getState) => { + const state = getState(); + const namespace = selectDefaultNamespace(state).name; + await verifyAuthorized(dispatch, getState); + return apiInitiateDeviceAuth(namespace); + }, +}; diff --git a/packages/dashboard-frontend/src/store/DeviceAuthToken/index.ts b/packages/dashboard-frontend/src/store/DeviceAuthToken/index.ts new file mode 100644 index 0000000000..ec772f979a --- /dev/null +++ b/packages/dashboard-frontend/src/store/DeviceAuthToken/index.ts @@ -0,0 +1,21 @@ +/* c8 ignore start */ + +/* + * Copyright (c) 2018-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +export { actionCreators as deviceAuthTokenActionCreators } from '@/store/DeviceAuthToken/actions'; +export { + reducer as deviceAuthTokenReducer, + DeviceAuthTokenState, + unloadedState as deviceAuthTokenUnloadedState, +} from '@/store/DeviceAuthToken/reducer'; +export * from '@/store/DeviceAuthToken/selectors'; diff --git a/packages/dashboard-frontend/src/store/DeviceAuthToken/reducer.ts b/packages/dashboard-frontend/src/store/DeviceAuthToken/reducer.ts new file mode 100644 index 0000000000..8934869a9c --- /dev/null +++ b/packages/dashboard-frontend/src/store/DeviceAuthToken/reducer.ts @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2018-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { api } from '@eclipse-che/common'; +import { createReducer } from '@reduxjs/toolkit'; + +import { + deviceAuthTokenErrorAction, + deviceAuthTokenReceiveAction, + deviceAuthTokenRemoveAction, + deviceAuthTokenRequestAction, +} from '@/store/DeviceAuthToken/actions'; + +export type DeviceAuthTokenState = { + tokens: api.DeviceAuthToken[]; + isLoading: boolean; + error: string | undefined; +}; + +export const unloadedState: DeviceAuthTokenState = { + tokens: [], + isLoading: false, + error: undefined, +}; + +export const reducer = createReducer(unloadedState, builder => + builder + .addCase(deviceAuthTokenRequestAction, state => { + state.isLoading = true; + state.error = undefined; + }) + .addCase(deviceAuthTokenReceiveAction, (state, action) => { + state.isLoading = false; + state.tokens = action.payload; + }) + .addCase(deviceAuthTokenRemoveAction, (state, action) => { + state.isLoading = false; + state.tokens = state.tokens.filter(t => t.name !== action.payload); + }) + .addCase(deviceAuthTokenErrorAction, (state, action) => { + state.isLoading = false; + state.error = action.payload; + }) + .addDefaultCase(state => state), +); diff --git a/packages/dashboard-frontend/src/store/DeviceAuthToken/selectors.ts b/packages/dashboard-frontend/src/store/DeviceAuthToken/selectors.ts new file mode 100644 index 0000000000..b8527c0cc4 --- /dev/null +++ b/packages/dashboard-frontend/src/store/DeviceAuthToken/selectors.ts @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2018-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { createSelector } from '@reduxjs/toolkit'; + +import { RootState } from '@/store'; + +const selectState = (state: RootState) => state.deviceAuthToken; + +export const selectDeviceAuthTokens = createSelector(selectState, state => state.tokens); + +export const selectDeviceAuthTokenIsLoading = createSelector(selectState, state => state.isLoading); + +export const selectDeviceAuthTokenError = createSelector(selectState, state => state.error); diff --git a/packages/dashboard-frontend/src/store/__mocks__/mockStore.ts b/packages/dashboard-frontend/src/store/__mocks__/mockStore.ts index fd59188d80..7505c70033 100644 --- a/packages/dashboard-frontend/src/store/__mocks__/mockStore.ts +++ b/packages/dashboard-frontend/src/store/__mocks__/mockStore.ts @@ -111,6 +111,10 @@ export class MockStoreBuilder { dashboardWarning: clusterConfig.dashboardWarning ?? this.state.clusterConfig?.clusterConfig.dashboardWarning, + githubDeviceAuthEnabled: + clusterConfig.githubDeviceAuthEnabled ?? + this.state.clusterConfig?.clusterConfig.githubDeviceAuthEnabled ?? + false, }, isLoading, error, @@ -464,6 +468,24 @@ export class MockStoreBuilder { return this; } + public withDeviceAuthTokens( + options: { + tokens?: api.DeviceAuthToken[]; + error?: string; + }, + isLoading = false, + ) { + this.state = { + ...this.state, + deviceAuthToken: { + tokens: options.tokens || [], + error: options.error, + isLoading, + }, + }; + return this; + } + public withWorkspacePreferences( options: { 'skip-authorisation'?: api.GitProvider[]; diff --git a/packages/dashboard-frontend/src/store/rootReducer.ts b/packages/dashboard-frontend/src/store/rootReducer.ts index 11df18a5ea..d9dcf10184 100644 --- a/packages/dashboard-frontend/src/store/rootReducer.ts +++ b/packages/dashboard-frontend/src/store/rootReducer.ts @@ -17,6 +17,7 @@ import { brandingReducer } from '@/store/Branding'; import { clusterConfigReducer } from '@/store/ClusterConfig'; import { clusterInfoReducer } from '@/store/ClusterInfo'; import { devfileRegistriesReducer } from '@/store/DevfileRegistries'; +import { deviceAuthTokenReducer } from '@/store/DeviceAuthToken'; import { devWorkspacesClusterReducer } from '@/store/DevWorkspacesCluster'; import { dockerConfigReducer } from '@/store/DockerConfig'; import { eventsReducer } from '@/store/Events'; @@ -46,6 +47,7 @@ export const rootReducer = { clusterConfig: clusterConfigReducer, clusterInfo: clusterInfoReducer, devfileRegistries: devfileRegistriesReducer, + deviceAuthToken: deviceAuthTokenReducer, devWorkspaces: devWorkspacesReducer, devWorkspacesCluster: devWorkspacesClusterReducer, dockerConfig: dockerConfigReducer, From 16342459f83520427e9a8fe996e86aea9701cd38 Mon Sep 17 00:00:00 2001 From: Oleksii Orel Date: Tue, 21 Jul 2026 16:54:32 +0300 Subject: [PATCH 3/9] fix(device-auth): add Reconnect action, rate limiting, timeouts, and error handling Assisted-by: Claude Sonnet 4.6 Signed-off-by: Oleksii Orel --- packages/common/src/dto/api/index.ts | 2 - .../services/deviceAuthTokenApi.ts | 157 +++++++++--------- .../src/routes/api/deviceAuthToken.ts | 23 +-- .../DeviceAuthTokens/DeleteModal/index.tsx | 3 +- .../DeviceAuthTokens/List/__mocks__/index.tsx | 13 +- .../List/__tests__/index.spec.tsx | 19 +-- .../DeviceAuthTokens/List/index.tsx | 37 ++--- .../DeviceAuthTokens/index.tsx | 2 + 8 files changed, 131 insertions(+), 125 deletions(-) diff --git a/packages/common/src/dto/api/index.ts b/packages/common/src/dto/api/index.ts index f6ba9c2a06..6193075395 100644 --- a/packages/common/src/dto/api/index.ts +++ b/packages/common/src/dto/api/index.ts @@ -78,8 +78,6 @@ export type DeviceAuthToken = { provider?: string; /** ISO 8601 timestamp string as returned by Kubernetes and serialized by Fastify */ creationTimestamp?: string; - /** Whether the stored token is still accepted by GitHub. Undefined means the check was not performed or timed out. */ - valid?: boolean; }; export type DeviceCodeResponse = { diff --git a/packages/dashboard-backend/src/devworkspaceClient/services/deviceAuthTokenApi.ts b/packages/dashboard-backend/src/devworkspaceClient/services/deviceAuthTokenApi.ts index 3eb3317240..2b6b1f818f 100644 --- a/packages/dashboard-backend/src/devworkspaceClient/services/deviceAuthTokenApi.ts +++ b/packages/dashboard-backend/src/devworkspaceClient/services/deviceAuthTokenApi.ts @@ -31,7 +31,7 @@ const DEVICE_AUTH_LABEL = 'che.eclipse.org/device-authentication'; const DEVICE_AUTH_LABEL_SELECTOR = `${DEVICE_AUTH_LABEL}=true`; const DEVICE_AUTH_PROVIDER_LABEL = 'che.eclipse.org/device-authentication-provider'; -const GITHUB_SCOPES = 'repo user:email workflow'; +const GITHUB_SCOPES = 'read:user repo user:email workflow'; const GITHUB_API_TIMEOUT_MS = 30_000; interface GitHubDeviceCodeResponse { @@ -63,37 +63,43 @@ function getGitHubClientId(): string { async function githubPostDeviceCode( params: Record, ): Promise { - const query = new URLSearchParams(params).toString(); - const url = `https://github.com/login/device/code?${query}`; - const response = await fetch(url, { - method: 'POST', - headers: { Accept: 'application/json' }, - }); - if ( - !response.ok && - response.headers.get('content-type')?.includes('application/json') === false - ) { - throw new Error(`GitHub API returned HTTP ${response.status}`); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), GITHUB_API_TIMEOUT_MS); + try { + const query = new URLSearchParams(params).toString(); + const url = `https://github.com/login/device/code?${query}`; + const response = await fetch(url, { + method: 'POST', + headers: { Accept: 'application/json' }, + signal: controller.signal, + }); + // GitHub returns structured JSON even on 4xx (e.g. device_flow_disabled). + // Parse the body regardless of HTTP status so the caller can surface + // specific error codes rather than a generic "HTTP 400". + const data: unknown = await response.json(); + return data as GitHubDeviceCodeResponse; + } finally { + clearTimeout(timer); } - const data: unknown = await response.json(); - return data as GitHubDeviceCodeResponse; } async function githubPostToken(params: Record): Promise { - const query = new URLSearchParams(params).toString(); - const url = `https://github.com/login/oauth/access_token?${query}`; - const response = await fetch(url, { - method: 'POST', - headers: { Accept: 'application/json' }, - }); - if ( - !response.ok && - response.headers.get('content-type')?.includes('application/json') === false - ) { - throw new Error(`GitHub API returned HTTP ${response.status}`); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), GITHUB_API_TIMEOUT_MS); + try { + const query = new URLSearchParams(params).toString(); + const url = `https://github.com/login/oauth/access_token?${query}`; + const response = await fetch(url, { + method: 'POST', + headers: { Accept: 'application/json' }, + signal: controller.signal, + }); + // GitHub returns structured JSON even for error states (authorization_pending, etc.) + const data: unknown = await response.json(); + return data as GitHubTokenResponse; + } finally { + clearTimeout(timer); } - const data: unknown = await response.json(); - return data as GitHubTokenResponse; } export class DeviceAuthTokenApiService implements IDeviceAuthTokenApi { @@ -109,47 +115,19 @@ export class DeviceAuthTokenApiService implements IDeviceAuthTokenApi { namespace, labelSelector: DEVICE_AUTH_LABEL_SELECTOR, }); - const tokens = resp.items.filter(secret => !!secret.metadata?.name); - return Promise.all( - tokens.map(async secret => { - const rawToken = Buffer.from(secret.data?.['token'] ?? '', 'base64').toString('utf-8'); - let valid: boolean | undefined; - if (rawToken) { - valid = await this.checkTokenValidity(rawToken); - } - return { - name: secret.metadata?.name ?? '', - provider: secret.metadata?.labels?.[DEVICE_AUTH_PROVIDER_LABEL], - creationTimestamp: secret.metadata?.creationTimestamp?.toISOString(), - valid, - }; - }), - ); + return resp.items + .filter(secret => !!secret.metadata?.name) + .map(secret => ({ + name: secret.metadata?.name ?? '', + provider: secret.metadata?.labels?.[DEVICE_AUTH_PROVIDER_LABEL], + creationTimestamp: secret.metadata?.creationTimestamp?.toISOString(), + })); } catch (error) { const additionalMessage = `Unable to list Device Authentication tokens in the namespace "${namespace}"`; throw createError(error, API_ERROR_LABEL, additionalMessage); } } - private async checkTokenValidity(token: string): Promise { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), 5_000); - try { - const response = await fetch('https://api.github.com/user', { - headers: { - Authorization: `token ${token}`, - Accept: 'application/vnd.github+json', - }, - signal: controller.signal, - }); - return response.ok; - } catch { - return undefined; - } finally { - clearTimeout(timer); - } - } - async deleteToken(namespace: string, tokenName: string): Promise { const additionalMessage = `Unable to delete Device Authentication token "${tokenName}" in the namespace "${namespace}"`; @@ -266,21 +244,52 @@ export class DeviceAuthTokenApiService implements IDeviceAuthTokenApi { namespace: string, accessToken: string, ): Promise { - const name = `device-authentication-secret-${randomBytes(6).toString('hex')}`; - const secret: k8s.V1Secret = { - metadata: { - name, + const tokenData = Buffer.from(accessToken).toString('base64'); + + // Match che-code's single-active-token approach: replace existing secret if present + const existing = await this.coreV1API.listNamespacedSecret({ + namespace, + labelSelector: DEVICE_AUTH_LABEL_SELECTOR, + }); + if (existing.items.length > 0 && existing.items[0].metadata?.name) { + const existingName = existing.items[0].metadata.name; + const updated = await this.coreV1API.replaceNamespacedSecret({ + name: existingName, namespace, - labels: { - [DEVICE_AUTH_LABEL]: 'true', - [DEVICE_AUTH_PROVIDER_LABEL]: 'github', + body: { + metadata: { + name: existingName, + namespace, + labels: { + [DEVICE_AUTH_LABEL]: 'true', + [DEVICE_AUTH_PROVIDER_LABEL]: 'github', + }, + }, + data: { token: tokenData }, }, + }); + return { + name: existingName, + provider: 'github', + creationTimestamp: updated.metadata?.creationTimestamp?.toISOString(), + }; + } + + const name = `device-authentication-secret-${randomBytes(6).toString('hex')}`; + const created = await this.coreV1API.createNamespacedSecret({ + namespace, + body: { + metadata: { + name, + namespace, + labels: { + [DEVICE_AUTH_LABEL]: 'true', + [DEVICE_AUTH_PROVIDER_LABEL]: 'github', + }, + }, + data: { token: tokenData }, }, - data: { - token: Buffer.from(accessToken).toString('base64'), - }, - }; - const created = await this.coreV1API.createNamespacedSecret({ namespace, body: secret }); + }); return { name, provider: 'github', diff --git a/packages/dashboard-backend/src/routes/api/deviceAuthToken.ts b/packages/dashboard-backend/src/routes/api/deviceAuthToken.ts index a517583232..a85b7bfbc2 100644 --- a/packages/dashboard-backend/src/routes/api/deviceAuthToken.ts +++ b/packages/dashboard-backend/src/routes/api/deviceAuthToken.ts @@ -77,15 +77,7 @@ export function registerDeviceAuthTokenRoutes(instance: FastifyInstance) { */ server.post( `${baseApiPath}/namespace/:namespace/device-auth-token/initiate`, - { - ...getSchema({ tags, params: namespacedSchema }), - config: { - rateLimit: { - max: 100, - timeWindow: '1 minute', - }, - }, - }, + Object.assign({}, rateLimitConfig, getSchema({ tags, params: namespacedSchema })), async function (request: FastifyRequest) { const token = getToken(request); const { deviceAuthTokenApi } = getDevWorkspaceClient(token); @@ -100,14 +92,11 @@ export function registerDeviceAuthTokenRoutes(instance: FastifyInstance) { */ server.post( `${baseApiPath}/namespace/:namespace/device-auth-token/poll`, - { - ...rateLimitConfig, - ...getSchema({ tags, params: namespacedSchema, body: deviceAuthPollBodySchema }), - preHandler: server.rateLimit({ - max: 100, - timeWindow: '1 minute', - }), - }, + Object.assign( + {}, + rateLimitConfig, + getSchema({ tags, params: namespacedSchema, body: deviceAuthPollBodySchema }), + ), async function (request: FastifyRequest) { const { namespace } = request.params as restParams.INamespacedParams; const { deviceCode } = request.body as { deviceCode: string }; diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/DeleteModal/index.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/DeleteModal/index.tsx index f068ae5395..1908a47845 100644 --- a/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/DeleteModal/index.tsx +++ b/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/DeleteModal/index.tsx @@ -78,7 +78,8 @@ export class DeviceAuthTokensDeleteModal extends React.PureComponent Are you sure you want to delete {count} Device Authentication Tokens? - This removes the tokens from Che. To fully revoke GitHub access, also visit{' '} + This removes the tokens from Che and attempts to revoke the GitHub authorizations. If + revocation fails, you can also manually revoke at{' '} github.com/settings/applications diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/List/__mocks__/index.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/List/__mocks__/index.tsx index 39530cd2fb..565a484c6d 100644 --- a/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/List/__mocks__/index.tsx +++ b/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/List/__mocks__/index.tsx @@ -16,7 +16,7 @@ import { Props } from '..'; export class DeviceAuthTokensList extends React.PureComponent { render() { - const { tokens, onDeleteTokens } = this.props; + const { tokens, onDeleteTokens, onConnect, isConnectEnabled } = this.props; const entries = tokens.map(token => (
@@ -27,6 +27,15 @@ export class DeviceAuthTokensList extends React.PureComponent {
)); - return
{entries}
; + return ( +
+ {entries} + {isConnectEnabled && ( + + )} +
+ ); } } diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/List/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/List/__tests__/index.spec.tsx index 33ae2e4f1a..6903a0bc77 100644 --- a/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/List/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/List/__tests__/index.spec.tsx @@ -27,12 +27,16 @@ const token2: api.DeviceAuthToken = { const mockOnDeleteTokens = jest.fn(); +const mockOnConnect = jest.fn(); + const { renderComponent } = getComponentRenderer( ({ tokens, isDisabled }: { tokens: api.DeviceAuthToken[]; isDisabled?: boolean }) => ( ), ); @@ -42,6 +46,11 @@ describe('DeviceAuthTokensList', () => { jest.clearAllMocks(); }); + it('should render Reconnect action toggle', () => { + renderComponent({ tokens: [token1] }); + expect(screen.getByTestId('token-actions-toggle')).not.toBeNull(); + }); + it('should render token cards', () => { renderComponent({ tokens: [token1] }); expect(screen.getByTestId('device-auth-token-row')).not.toBeNull(); @@ -58,14 +67,4 @@ describe('DeviceAuthTokensList', () => { renderComponent({ tokens: [token1] }); expect(screen.getByTestId('token-actions-toggle')).not.toBeNull(); }); - - it('should show valid indicator when token.valid is true', () => { - renderComponent({ tokens: [{ ...token1, valid: true }] }); - expect(screen.getByTitle('Token is valid')).not.toBeNull(); - }); - - it('should show invalid indicator when token.valid is false', () => { - renderComponent({ tokens: [{ ...token1, valid: false }] }); - expect(screen.getByTitle('Token has been revoked or expired')).not.toBeNull(); - }); }); diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/List/index.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/List/index.tsx index 40dc098b74..afd5a44c29 100644 --- a/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/List/index.tsx +++ b/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/List/index.tsx @@ -25,7 +25,7 @@ import { MenuToggleElement, PageSection, } from '@patternfly/react-core'; -import { CheckCircleIcon, EllipsisVIcon, ExclamationCircleIcon } from '@patternfly/react-icons'; +import { EllipsisVIcon } from '@patternfly/react-icons'; import React from 'react'; import { getFormattedDate } from '@/services/helpers/dates'; @@ -33,7 +33,9 @@ import { getFormattedDate } from '@/services/helpers/dates'; export type Props = { tokens: api.DeviceAuthToken[]; isDisabled: boolean; + isConnectEnabled: boolean; onDeleteTokens: (tokens: api.DeviceAuthToken[]) => void; + onConnect: () => void; }; type State = { @@ -47,7 +49,7 @@ export class DeviceAuthTokensList extends React.PureComponent { } render(): React.ReactElement { - const { tokens, isDisabled, onDeleteTokens } = this.props; + const { tokens, isDisabled, isConnectEnabled, onDeleteTokens } = this.props; const { openDropdown } = this.state; const cards = tokens.map(token => { @@ -85,6 +87,19 @@ export class DeviceAuthTokensList extends React.PureComponent { popperProps={{ position: 'right' }} > + {isConnectEnabled && ( + { + this.setState({ openDropdown: undefined }); + this.props.onConnect(); + }} + data-testid="reconnect-token-action" + > + Reconnect + + )} { ), }} > - - {token.provider ?? 'GitHub'} - {token.valid === true && ( - - )} - {token.valid === false && ( - - )} - + {token.provider ?? 'GitHub'} diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/index.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/index.tsx index c85d5bbe69..4425063b13 100644 --- a/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/index.tsx +++ b/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/index.tsx @@ -169,7 +169,9 @@ class DeviceAuthTokens extends React.PureComponent { this.handleShowDeleteModal(selectedTokens)} + onConnect={() => this.handleOpenConnectModal()} /> )} From b0020b088427f17311019a08735315fae90e0acf Mon Sep 17 00:00:00 2001 From: Oleksii Orel Date: Tue, 21 Jul 2026 18:27:46 +0300 Subject: [PATCH 4/9] feat(device-auth): add token validity check, status icons, and UX improvements Assisted-by: Claude Sonnet 4.6 Signed-off-by: Oleksii Orel --- packages/common/src/dto/api/index.ts | 2 + .../services/deviceAuthTokenApi.ts | 45 +++++++++++++++++-- .../src/devworkspaceClient/types/index.ts | 1 + .../src/routes/api/deviceAuthToken.ts | 18 ++++++++ .../DeviceAuthTokens/ConnectModal/index.tsx | 15 +++---- .../DeviceAuthTokens/List/index.tsx | 23 +++++++++- .../DeviceAuthTokens/index.tsx | 40 +++++++++++++++-- .../backend-client/deviceAuthTokenApi.ts | 15 +++++++ 8 files changed, 142 insertions(+), 17 deletions(-) diff --git a/packages/common/src/dto/api/index.ts b/packages/common/src/dto/api/index.ts index 6193075395..8824f41c6b 100644 --- a/packages/common/src/dto/api/index.ts +++ b/packages/common/src/dto/api/index.ts @@ -78,6 +78,8 @@ export type DeviceAuthToken = { provider?: string; /** ISO 8601 timestamp string as returned by Kubernetes and serialized by Fastify */ creationTimestamp?: string; + /** Whether the token is still accepted by GitHub. Set by a separate validate call. */ + valid?: boolean; }; export type DeviceCodeResponse = { diff --git a/packages/dashboard-backend/src/devworkspaceClient/services/deviceAuthTokenApi.ts b/packages/dashboard-backend/src/devworkspaceClient/services/deviceAuthTokenApi.ts index 2b6b1f818f..f4bf6bd08d 100644 --- a/packages/dashboard-backend/src/devworkspaceClient/services/deviceAuthTokenApi.ts +++ b/packages/dashboard-backend/src/devworkspaceClient/services/deviceAuthTokenApi.ts @@ -76,8 +76,12 @@ async function githubPostDeviceCode( // GitHub returns structured JSON even on 4xx (e.g. device_flow_disabled). // Parse the body regardless of HTTP status so the caller can surface // specific error codes rather than a generic "HTTP 400". - const data: unknown = await response.json(); - return data as GitHubDeviceCodeResponse; + try { + const data: unknown = await response.json(); + return data as GitHubDeviceCodeResponse; + } catch { + throw new Error(`GitHub API returned HTTP ${response.status} with non-JSON body`); + } } finally { clearTimeout(timer); } @@ -95,8 +99,12 @@ async function githubPostToken(params: Record): Promise { + let secret: k8s.V1Secret; + try { + secret = await this.coreV1API.readNamespacedSecret({ name: tokenName, namespace }); + } catch { + return undefined; + } + if (secret.metadata?.labels?.[DEVICE_AUTH_LABEL] !== 'true') { + return undefined; + } + const rawToken = Buffer.from(secret.data?.['token'] ?? '', 'base64').toString('utf-8'); + if (!rawToken) { + return undefined; + } + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 5_000); + try { + const response = await fetch('https://api.github.com/user', { + headers: { Authorization: `token ${rawToken}`, Accept: 'application/vnd.github+json' }, + signal: controller.signal, + }); + return response.ok; + } catch { + return undefined; + } finally { + clearTimeout(timer); + } + } + private async createDeviceAuthSecret( namespace: string, accessToken: string, diff --git a/packages/dashboard-backend/src/devworkspaceClient/types/index.ts b/packages/dashboard-backend/src/devworkspaceClient/types/index.ts index 72f8b6fa67..f6641d58e8 100644 --- a/packages/dashboard-backend/src/devworkspaceClient/types/index.ts +++ b/packages/dashboard-backend/src/devworkspaceClient/types/index.ts @@ -649,6 +649,7 @@ export interface IDeviceAuthTokenApi { * On success, stores the token as a Kubernetes secret. */ pollDeviceAuth(namespace: string, deviceCode: string): Promise; + validateToken(namespace: string, tokenName: string): Promise; } export interface ISccPermissionApi { diff --git a/packages/dashboard-backend/src/routes/api/deviceAuthToken.ts b/packages/dashboard-backend/src/routes/api/deviceAuthToken.ts index a85b7bfbc2..cc231b5571 100644 --- a/packages/dashboard-backend/src/routes/api/deviceAuthToken.ts +++ b/packages/dashboard-backend/src/routes/api/deviceAuthToken.ts @@ -105,5 +105,23 @@ export function registerDeviceAuthTokenRoutes(instance: FastifyInstance) { return deviceAuthTokenApi.pollDeviceAuth(namespace, deviceCode); }, ); + /** + * GET /dashboard/api/namespace/:namespace/device-auth-token/:tokenName/validate + * Checks whether the stored GitHub token is still valid. + * Returns { valid: boolean | null } — null means check could not be performed. + * Uses user bearer token. + */ + server.get( + `${baseApiPath}/namespace/:namespace/device-auth-token/:tokenName/validate`, + Object.assign({}, rateLimitConfig, getSchema({ tags, params: deviceAuthTokenParamsSchema })), + async function (request: FastifyRequest) { + const { namespace, tokenName } = + request.params as restParams.DeviceAuthTokenNamespacedParams; + const token = getToken(request); + const { deviceAuthTokenApi } = getDevWorkspaceClient(token); + const valid = await deviceAuthTokenApi.validateToken(namespace, tokenName); + return { valid: valid ?? null }; + }, + ); }); } diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/ConnectModal/index.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/ConnectModal/index.tsx index 4e8dbcfbe9..e4a0085d46 100644 --- a/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/ConnectModal/index.tsx +++ b/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/ConnectModal/index.tsx @@ -215,21 +215,20 @@ class ConnectModalClass extends React.PureComponent { Return here — the page will update automatically -
- -
)} {deviceCode && !error && ( )} + + )} diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/__tests__/index.spec.tsx index 04a2c97fee..3e5946dcef 100644 --- a/packages/dashboard-frontend/src/pages/UserPreferences/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/pages/UserPreferences/__tests__/index.spec.tsx @@ -197,7 +197,7 @@ describe('UserPreferences', () => { const location = buildUserPreferencesLocation(UserPreferencesTab.DEVICE_AUTH_TOKENS); renderComponent(location); - const tab = screen.getByRole('tab', { name: 'Device Auth Token' }); + const tab = screen.getByRole('tab', { name: 'Device Auth Tokens' }); fireEvent.keyDown(tab, { key: 'ArrowRight' }); expect(mockNavigate).toHaveBeenCalledWith( diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/index.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/index.tsx index 462301b2e2..464c910191 100644 --- a/packages/dashboard-frontend/src/pages/UserPreferences/index.tsx +++ b/packages/dashboard-frontend/src/pages/UserPreferences/index.tsx @@ -183,7 +183,7 @@ class UserPreferences extends React.PureComponent {
diff --git a/packages/dashboard-frontend/src/services/backend-client/deviceAuthTokenApi.ts b/packages/dashboard-frontend/src/services/backend-client/deviceAuthTokenApi.ts index dbac485a7d..b655f6734b 100644 --- a/packages/dashboard-frontend/src/services/backend-client/deviceAuthTokenApi.ts +++ b/packages/dashboard-frontend/src/services/backend-client/deviceAuthTokenApi.ts @@ -72,14 +72,14 @@ export async function pollDeviceAuth( export async function validateDeviceAuthToken( namespace: string, tokenName: string, -): Promise { +): Promise<'valid' | 'invalid' | 'unknown'> { try { const response = await AxiosWrapper.createToRetryMissedBearerTokenError().get( `${dashboardBackendPrefix}/namespace/${namespace}/device-auth-token/${encodeURIComponent(tokenName)}/validate`, ); - const { valid } = response.data as { valid: boolean | null }; - return valid ?? undefined; + const { valid } = response.data as { valid: 'valid' | 'invalid' | 'unknown' }; + return valid; } catch { - return undefined; + return 'unknown'; } } diff --git a/packages/dashboard-frontend/src/store/DeviceAuthToken/actions.ts b/packages/dashboard-frontend/src/store/DeviceAuthToken/actions.ts index 54d417c100..15f37cfad1 100644 --- a/packages/dashboard-frontend/src/store/DeviceAuthToken/actions.ts +++ b/packages/dashboard-frontend/src/store/DeviceAuthToken/actions.ts @@ -70,6 +70,7 @@ export const actionCreators = { const state = getState(); const namespace = selectDefaultNamespace(state).name; await verifyAuthorized(dispatch, getState); + // Pass namespace so the backend can bind the device code to this user's namespace (security) return apiInitiateDeviceAuth(namespace); }, }; From dbf22fab7623110c4cd999215b5b13f5faa3055f Mon Sep 17 00:00:00 2001 From: Oleksii Orel Date: Fri, 24 Jul 2026 21:20:17 +0300 Subject: [PATCH 6/9] feat(device-auth): detect GitHub OAuth via Che Server API and ConfigMap fallback Assisted-by: Claude Sonnet 4.6 Signed-off-by: Oleksii Orel --- .../services/deviceAuthTokenApi.ts | 58 ++++++++++++++++--- .../src/routes/api/clusterConfig.ts | 32 +++++++++- 2 files changed, 82 insertions(+), 8 deletions(-) diff --git a/packages/dashboard-backend/src/devworkspaceClient/services/deviceAuthTokenApi.ts b/packages/dashboard-backend/src/devworkspaceClient/services/deviceAuthTokenApi.ts index 6cfef4da34..378f0440e1 100644 --- a/packages/dashboard-backend/src/devworkspaceClient/services/deviceAuthTokenApi.ts +++ b/packages/dashboard-backend/src/devworkspaceClient/services/deviceAuthTokenApi.ts @@ -12,6 +12,7 @@ import { api } from '@eclipse-che/common'; import * as k8s from '@kubernetes/client-node'; +import { existsSync, readFileSync } from 'fs'; import { createError } from '@/devworkspaceClient/services/helpers/createError'; import { @@ -58,12 +59,55 @@ interface GitHubTokenResponse { error_description?: string; } -function getGitHubClientId(): string { - const clientId = process.env.CHE_GITHUB_OAUTH_CLIENT_ID; - if (!clientId) { - throw new Error('CHE_GITHUB_OAUTH_CLIENT_ID environment variable is not set'); +// Mounted file path — same path Che Server uses when the operator +// mounts github-oauth-config into the pod as a volume. +const GITHUB_OAUTH_ID_FILE = '/che-conf/oauth/github/id'; + +async function getGitHubClientId(): Promise { + // Priority 1: env var (operator auto-injection in standard deployments) + if (process.env.CHE_GITHUB_OAUTH_CLIENT_ID) { + return process.env.CHE_GITHUB_OAUTH_CLIENT_ID; + } + // Priority 2: mounted file (only needed with a custom deployment spec where + // the operator does not auto-inject the env var) + if (existsSync(GITHUB_OAUTH_ID_FILE)) { + const id = readFileSync(GITHUB_OAUTH_ID_FILE, 'utf8').trim(); + if (id) { + return id; + } + } + // Priority 3: clientId from GET /api/oauth on the Che Server (available + // once che-server#1035 is deployed — no env var or volume mount needed). + const cheInternalUrl = process.env.CHE_INTERNAL_URL; + if (cheInternalUrl) { + try { + const { getServiceAccountToken } = await import( + '@/routes/api/helpers/getServiceAccountToken' + ); + const saToken = getServiceAccountToken(); + const response = await fetch(`${cheInternalUrl}/oauth`, { + headers: { Authorization: `Bearer ${saToken}` }, + signal: AbortSignal.timeout(5_000), + }); + if (response.ok) { + const providers = (await response.json()) as Array<{ + name: string; + clientId?: string; + }>; + const github = providers.find(p => p.name === 'github'); + if (github?.clientId) { + return github.clientId; + } + } + } catch { + // fall through + } } - return clientId; + throw new Error( + 'GitHub OAuth client_id is not available. ' + + 'Ensure CHE_GITHUB_OAUTH_CLIENT_ID is set, or deploy che-server#1035 ' + + 'which exposes clientId via GET /api/oauth.', + ); } async function githubPostDeviceCode( @@ -207,7 +251,7 @@ export class GitHubDeviceAuthTokenApiService implements IDeviceAuthTokenApi { } async initiateDeviceAuth(namespace: string): Promise { - const clientId = getGitHubClientId(); + const clientId = await getGitHubClientId(); const data = await githubPostDeviceCode({ client_id: clientId, scope: GITHUB_SCOPES, @@ -241,7 +285,7 @@ export class GitHubDeviceAuthTokenApiService implements IDeviceAuthTokenApi { message: 'Device code is not valid for this session. Please initiate a new connection.', }; } - const clientId = getGitHubClientId(); + const clientId = await getGitHubClientId(); const data = await githubPostToken({ client_id: clientId, device_code: deviceCode, diff --git a/packages/dashboard-backend/src/routes/api/clusterConfig.ts b/packages/dashboard-backend/src/routes/api/clusterConfig.ts index 697927d3be..47cb076c26 100644 --- a/packages/dashboard-backend/src/routes/api/clusterConfig.ts +++ b/packages/dashboard-backend/src/routes/api/clusterConfig.ts @@ -28,6 +28,36 @@ export function registerClusterConfigRoute(instance: FastifyInstance) { }); } +/** + * Determines whether GitHub OAuth is configured by calling the Che Server's + * /api/oauth endpoint with the dashboard SA token — the same source the + * Git Services tab uses. No RBAC changes or env vars required. + * Falls back to CHE_GITHUB_OAUTH_CLIENT_ID env var for local dev / override. + */ +async function isGitHubOAuthConfigured(): Promise { + if (process.env.CHE_GITHUB_OAUTH_CLIENT_ID) { + return true; + } + const cheInternalUrl = process.env.CHE_INTERNAL_URL; + if (!cheInternalUrl) { + return false; + } + try { + const saToken = getServiceAccountToken(); + const response = await fetch(`${cheInternalUrl}/oauth`, { + headers: { Authorization: `Bearer ${saToken}` }, + signal: AbortSignal.timeout(5_000), + }); + if (!response.ok) { + return false; + } + const providers = (await response.json()) as Array<{ name: string }>; + return Array.isArray(providers) && providers.some(p => p.name === 'github'); + } catch { + return false; + } +} + async function buildClusterConfig(): Promise { const token = getServiceAccountToken(); const { serverConfigApi } = getDevWorkspaceClient(token); @@ -45,6 +75,6 @@ async function buildClusterConfig(): Promise { allWorkspacesLimit, runningWorkspacesLimit, currentArchitecture, - githubDeviceAuthEnabled: !!process.env.CHE_GITHUB_OAUTH_CLIENT_ID, + githubDeviceAuthEnabled: await isGitHubOAuthConfigured(), }; } From 9abec5704179e3c86c6a9fb73d8dcc1005ed1a3c Mon Sep 17 00:00:00 2001 From: Oleksii Orel Date: Wed, 29 Jul 2026 22:58:14 +0300 Subject: [PATCH 7/9] feat(device-auth): decouple from Git Services OAuth via ConfigMap Assisted-by: Claude Sonnet 4.6 Signed-off-by: Oleksii Orel --- .../plans/2026-08-06-device-auth-decouple.md | 701 ++++++++++++++++++ .../src/constants/schemas.ts | 1 - .../__tests__/deviceAuthTokenApi.spec.ts | 39 +- .../services/deviceAuthTokenApi.ts | 73 +- .../src/devworkspaceClient/types/index.ts | 8 +- .../api/__tests__/clusterConfig.spec.ts | 1 + .../api/__tests__/deviceAuthToken.spec.ts | 88 +++ .../src/routes/api/clusterConfig.ts | 34 +- .../src/routes/api/deviceAuthToken.ts | 23 +- .../__mocks__/getDeviceAuthClientId.ts | 15 + .../__tests__/getDeviceAuthClientId.spec.ts | 120 +++ .../api/helpers/getDeviceAuthClientId.ts | 57 ++ .../ConnectModal/__tests__/index.spec.tsx | 35 +- .../DeviceAuthTokens/ConnectModal/index.tsx | 4 +- .../DeviceAuthTokens/__tests__/index.spec.tsx | 5 +- .../DeviceAuthTokens/index.tsx | 3 +- 16 files changed, 1076 insertions(+), 131 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-06-device-auth-decouple.md create mode 100644 packages/dashboard-backend/src/routes/api/__tests__/deviceAuthToken.spec.ts create mode 100644 packages/dashboard-backend/src/routes/api/helpers/__mocks__/getDeviceAuthClientId.ts create mode 100644 packages/dashboard-backend/src/routes/api/helpers/__tests__/getDeviceAuthClientId.spec.ts create mode 100644 packages/dashboard-backend/src/routes/api/helpers/getDeviceAuthClientId.ts diff --git a/docs/superpowers/plans/2026-08-06-device-auth-decouple.md b/docs/superpowers/plans/2026-08-06-device-auth-decouple.md new file mode 100644 index 0000000000..db0204c966 --- /dev/null +++ b/docs/superpowers/plans/2026-08-06-device-auth-decouple.md @@ -0,0 +1,701 @@ +# Device Auth Decoupled from Git Services OAuth — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the `getGitHubClientId()` 3-tier fallback and Che Server `isGitHubOAuthConfigured()` check with a single admin-configurable `device-auth-config` ConfigMap that makes device auth fully independent of Git Services OAuth. + +**Architecture:** A new route-layer helper `getDeviceAuthClientId()` reads `github_client_id` from a `device-auth-config` ConfigMap in the Che namespace (via SA KubeConfig) and caches the result with a 90 s TTL. `clusterConfig.ts` uses it to set `githubDeviceAuthEnabled`. The device auth route passes the resolved `clientId` to the service — removing all config-reading logic from `deviceAuthTokenApi.ts`. + +**Tech Stack:** TypeScript, Fastify, `@kubernetes/client-node`, Jest. + +## Global Constraints + +- No `any` type; strict TypeScript throughout. +- Absolute imports with `@/` alias only. +- EPL-2.0 copyright header on every new file. +- Assisted-by trailer on the commit. +- Run `yarn format:fix && yarn lint:fix` before committing. +- Run targeted tests with `yarn workspace @eclipse-che/dashboard-backend test --testPathPatterns="" --no-cache`. + +--- + +## File Map + +| Action | Path | Responsibility | +|--------|------|---------------| +| **Create** | `src/routes/api/helpers/getDeviceAuthClientId.ts` | SA-based ConfigMap read + 90 s cache | +| **Create** | `src/routes/api/helpers/__mocks__/getDeviceAuthClientId.ts` | Jest auto-mock for route tests | +| **Create** | `src/routes/api/helpers/__tests__/getDeviceAuthClientId.spec.ts` | Unit tests for the helper | +| **Create** | `src/routes/api/__tests__/deviceAuthToken.spec.ts` | Route-level tests for initiate / poll | +| **Modify** | `src/routes/api/clusterConfig.ts` | Use `getDeviceAuthClientId()` → drop old cache + HTTP call | +| **Modify** | `src/routes/api/__tests__/clusterConfig.spec.ts` | Mock `getDeviceAuthClientId` | +| **Modify** | `src/routes/api/deviceAuthToken.ts` | Resolve `clientId` in route, gate on null | +| **Modify** | `src/devworkspaceClient/types/index.ts` | Add `clientId` param to `initiateDeviceAuth`/`pollDeviceAuth` | +| **Modify** | `src/devworkspaceClient/services/deviceAuthTokenApi.ts` | Accept `clientId` param, delete `getGitHubClientId()` | +| **Modify** | `src/devworkspaceClient/services/__tests__/deviceAuthTokenApi.spec.ts` | Pass `clientId` in all test calls | + +--- + +### Task 1: Create `getDeviceAuthClientId` helper + mock + tests + +**Files:** +- Create: `packages/dashboard-backend/src/routes/api/helpers/getDeviceAuthClientId.ts` +- Create: `packages/dashboard-backend/src/routes/api/helpers/__mocks__/getDeviceAuthClientId.ts` +- Create: `packages/dashboard-backend/src/routes/api/helpers/__tests__/getDeviceAuthClientId.spec.ts` + +**Interfaces:** +- Produces: `getDeviceAuthClientId(): Promise` + +- [ ] **Step 1: Write the failing test** + +```typescript +// packages/dashboard-backend/src/routes/api/helpers/__tests__/getDeviceAuthClientId.spec.ts +/* + * Copyright (c) 2018-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import * as mockClient from '@kubernetes/client-node'; +import { CoreV1Api } from '@kubernetes/client-node'; + +jest.mock('@/routes/api/helpers/getServiceAccountToken'); +jest.mock('@/devworkspaceClient/services/helpers/retryableExec'); + +const mockReadNamespacedConfigMap = jest.fn(); +const stubCoreV1Api = { + readNamespacedConfigMap: mockReadNamespacedConfigMap, +} as unknown as CoreV1Api; + +describe('getDeviceAuthClientId', () => { + const origEnv = { ...process.env }; + + beforeEach(() => { + jest.resetModules(); + process.env = { ...origEnv }; + delete process.env.DEVICE_AUTH_GITHUB_CLIENT_ID; + process.env.CHECLUSTER_CR_NAMESPACE = 'eclipse-che'; + const { KubeConfig } = mockClient; + KubeConfig.prototype.makeApiClient = jest.fn().mockReturnValue(stubCoreV1Api); + }); + + afterEach(() => { + process.env = origEnv; + jest.clearAllMocks(); + }); + + it('returns client_id from ConfigMap when present', async () => { + mockReadNamespacedConfigMap.mockResolvedValueOnce({ + data: { github_client_id: '01ab8ac9400c4e429b23' }, + }); + const { getDeviceAuthClientId } = await import( + '@/routes/api/helpers/getDeviceAuthClientId' + ); + const result = await getDeviceAuthClientId(); + expect(result).toBe('01ab8ac9400c4e429b23'); + }); + + it('returns null when ConfigMap key is absent', async () => { + mockReadNamespacedConfigMap.mockResolvedValueOnce({ data: {} }); + const { getDeviceAuthClientId } = await import( + '@/routes/api/helpers/getDeviceAuthClientId' + ); + const result = await getDeviceAuthClientId(); + expect(result).toBeNull(); + }); + + it('returns null when ConfigMap does not exist (404)', async () => { + mockReadNamespacedConfigMap.mockRejectedValueOnce( + Object.assign(new Error('Not Found'), { code: 404 }), + ); + const { getDeviceAuthClientId } = await import( + '@/routes/api/helpers/getDeviceAuthClientId' + ); + const result = await getDeviceAuthClientId(); + expect(result).toBeNull(); + }); + + it('returns value from DEVICE_AUTH_GITHUB_CLIENT_ID env var (local dev override)', async () => { + process.env.DEVICE_AUTH_GITHUB_CLIENT_ID = 'local-override-id'; + const { getDeviceAuthClientId } = await import( + '@/routes/api/helpers/getDeviceAuthClientId' + ); + const result = await getDeviceAuthClientId(); + expect(result).toBe('local-override-id'); + expect(mockReadNamespacedConfigMap).not.toHaveBeenCalled(); + }); + + it('returns null when CHECLUSTER_CR_NAMESPACE is not set', async () => { + delete process.env.CHECLUSTER_CR_NAMESPACE; + const { getDeviceAuthClientId } = await import( + '@/routes/api/helpers/getDeviceAuthClientId' + ); + const result = await getDeviceAuthClientId(); + expect(result).toBeNull(); + expect(mockReadNamespacedConfigMap).not.toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 2: Run the test to confirm it fails** + +```bash +yarn workspace @eclipse-che/dashboard-backend test --testPathPatterns="getDeviceAuthClientId" --no-cache +``` + +Expected: Module not found error. + +- [ ] **Step 3: Implement the helper** + +```typescript +// packages/dashboard-backend/src/routes/api/helpers/getDeviceAuthClientId.ts +/* + * Copyright (c) 2018-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import * as k8s from '@kubernetes/client-node'; + +import { prepareCoreV1API } from '@/devworkspaceClient/services/helpers/prepareCoreV1API'; +import { getServiceAccountToken } from '@/routes/api/helpers/getServiceAccountToken'; +import { KubeConfigProvider } from '@/services/kubeclient/kubeConfigProvider'; + +const DEVICE_AUTH_CONFIG_MAP = 'device-auth-config'; +const GITHUB_CLIENT_ID_KEY = 'github_client_id'; +const CACHE_TTL_MS = 90_000; + +let cache: { value: string | null; expiresAt: number } | null = null; + +export async function getDeviceAuthClientId(): Promise { + if (cache && Date.now() < cache.expiresAt) { + return cache.value; + } + + const value = await resolveClientId(); + cache = { value, expiresAt: Date.now() + CACHE_TTL_MS }; + return value; +} + +async function resolveClientId(): Promise { + // Local dev / CI override + if (process.env.DEVICE_AUTH_GITHUB_CLIENT_ID) { + return process.env.DEVICE_AUTH_GITHUB_CLIENT_ID; + } + + const namespace = process.env.CHECLUSTER_CR_NAMESPACE; + if (!namespace) { + return null; + } + + try { + const token = getServiceAccountToken(); + const kc = new KubeConfigProvider().getKubeConfig(token); + const coreV1API = prepareCoreV1API(kc); + const configMap = await coreV1API.readNamespacedConfigMap({ + name: DEVICE_AUTH_CONFIG_MAP, + namespace, + }); + const clientId = configMap.data?.[GITHUB_CLIENT_ID_KEY]?.trim() ?? ''; + return clientId || null; + } catch { + return null; + } +} +``` + +- [ ] **Step 4: Create the mock** + +```typescript +// packages/dashboard-backend/src/routes/api/helpers/__mocks__/getDeviceAuthClientId.ts +/* + * Copyright (c) 2018-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +export const stubDeviceAuthClientId: string | null = null; + +export async function getDeviceAuthClientId(): Promise { + return stubDeviceAuthClientId; +} +``` + +- [ ] **Step 5: Run the test to confirm it passes** + +```bash +yarn workspace @eclipse-che/dashboard-backend test --testPathPatterns="getDeviceAuthClientId" --no-cache +``` + +Expected: All 5 tests pass. + +- [ ] **Step 6: Format and lint** + +```bash +yarn format:fix && yarn lint:fix +``` + +- [ ] **Step 7: Commit** + +```bash +git add packages/dashboard-backend/src/routes/api/helpers/getDeviceAuthClientId.ts \ + packages/dashboard-backend/src/routes/api/helpers/__mocks__/getDeviceAuthClientId.ts \ + packages/dashboard-backend/src/routes/api/helpers/__tests__/getDeviceAuthClientId.spec.ts +git commit -m "feat(device-auth): add getDeviceAuthClientId helper reading device-auth-config ConfigMap" +``` + +--- + +### Task 2: Update `IDeviceAuthTokenApi` interface and `deviceAuthTokenApi.ts` service + +**Files:** +- Modify: `packages/dashboard-backend/src/devworkspaceClient/types/index.ts:642-648` +- Modify: `packages/dashboard-backend/src/devworkspaceClient/services/deviceAuthTokenApi.ts` +- Modify: `packages/dashboard-backend/src/devworkspaceClient/services/__tests__/deviceAuthTokenApi.spec.ts` + +**Interfaces:** +- Consumes: nothing from Task 1 (service layer is isolated) +- Produces: `initiateDeviceAuth(namespace: string, clientId: string): Promise`, `pollDeviceAuth(namespace: string, deviceCode: string, clientId: string): Promise` + +- [ ] **Step 1: Update the interface in `types/index.ts`** + +Find lines 642–648 and replace: +```typescript + initiateDeviceAuth(namespace: string): Promise; + // ... + pollDeviceAuth(namespace: string, deviceCode: string): Promise; +``` +with: +```typescript + initiateDeviceAuth(namespace: string, clientId: string): Promise; + // ... + pollDeviceAuth(namespace: string, deviceCode: string, clientId: string): Promise; +``` + +Full replacement block (lines 639–649 in `types/index.ts`): +```typescript + /** + * Initiates a GitHub Device Authorization flow and returns the device code and user code. + */ + initiateDeviceAuth(namespace: string, clientId: string): Promise; + + /** + * Polls GitHub for the access token using the device code. + * On success, stores the token as a Kubernetes secret. + */ + pollDeviceAuth(namespace: string, deviceCode: string, clientId: string): Promise; + validateToken(namespace: string, tokenName: string): Promise<'valid' | 'invalid' | 'unknown'>; +``` + +- [ ] **Step 2: Update `deviceAuthTokenApi.ts` service** + +Remove these items from the service file: +- `import { existsSync, readFileSync } from 'fs';` +- `const GITHUB_OAUTH_ID_FILE` constant +- The entire `getGitHubClientId()` async function (lines ~66–111) + +Change `initiateDeviceAuth`: +```typescript +async initiateDeviceAuth(namespace: string, clientId: string): Promise { + const data = await githubPostDeviceCode({ + client_id: clientId, + scope: GITHUB_SCOPES, + }); + if (!data.device_code || !data.user_code || !data.verification_uri) { + if (data.error === 'device_flow_disabled' || data.error === 'device_flow_not_enabled') { + throw new Error( + 'Device Flow is not enabled for this GitHub OAuth App. ' + + 'An administrator must enable it at GitHub Settings → Developer settings → OAuth Apps.', + ); + } + throw new Error( + `Failed to initiate device auth: ${data.error_description ?? JSON.stringify(data)}`, + ); + } + const expiresAt = Date.now() + (data.expires_in ?? 900) * 1_000; + activeDeviceCodes.set(namespace, { code: data.device_code, expiresAt }); + return { + deviceCode: data.device_code, + userCode: data.user_code, + verificationUri: data.verification_uri, + interval: data.interval ?? 5, + }; +} +``` + +Change `pollDeviceAuth`: +```typescript +async pollDeviceAuth(namespace: string, deviceCode: string, clientId: string): Promise { + const stored = activeDeviceCodes.get(namespace); + if (!stored || stored.code !== deviceCode || Date.now() > stored.expiresAt) { + if (stored && Date.now() > stored.expiresAt) { + activeDeviceCodes.delete(namespace); + } + return { + status: 'error', + message: 'Device code is not valid for this session. Please initiate a new connection.', + }; + } + const data = await githubPostToken({ + client_id: clientId, + device_code: deviceCode, + grant_type: 'urn:ietf:params:oauth:grant-type:device_code', + }); + + if (data.error === 'authorization_pending') { + return { status: 'pending' }; + } + if (data.error === 'slow_down') { + return { status: 'slow_down' }; + } + if (data.error === 'expired_token') { + return { status: 'expired' }; + } + if (data.error) { + return { status: 'error', message: data.error_description ?? data.error }; + } + if (!data.access_token) { + return { status: 'error', message: 'No access_token in response' }; + } + + activeDeviceCodes.delete(namespace); + const token = await this.createDeviceAuthSecret(namespace, data.access_token); + return { status: 'authorized', token }; +} +``` + +- [ ] **Step 3: Update the service unit tests** + +In `deviceAuthTokenApi.spec.ts`: +- Remove the `origClientId` / `process.env.CHE_GITHUB_OAUTH_CLIENT_ID` setup in `initiateDeviceAuth` and `pollDeviceAuth` describe blocks. +- Pass `'test-client-id'` as the `clientId` argument to every `service.initiateDeviceAuth(namespace, 'test-client-id')` and `service.pollDeviceAuth(namespace, 'dev-code-123', 'test-client-id')` call. +- Remove the test "should throw when CHE_GITHUB_OAUTH_CLIENT_ID is not set" (no longer applicable). + +- [ ] **Step 4: Run the service tests** + +```bash +yarn workspace @eclipse-che/dashboard-backend test --testPathPatterns="deviceAuthTokenApi" --no-cache +``` + +Expected: All tests pass. + +- [ ] **Step 5: Format and lint** + +```bash +yarn format:fix && yarn lint:fix +``` + +- [ ] **Step 6: Commit** + +```bash +git add packages/dashboard-backend/src/devworkspaceClient/types/index.ts \ + packages/dashboard-backend/src/devworkspaceClient/services/deviceAuthTokenApi.ts \ + packages/dashboard-backend/src/devworkspaceClient/services/__tests__/deviceAuthTokenApi.spec.ts +git commit -m "refactor(device-auth): accept clientId param; remove 3-tier getGitHubClientId fallback" +``` + +--- + +### Task 3: Update `clusterConfig.ts` and its spec + +**Files:** +- Modify: `packages/dashboard-backend/src/routes/api/clusterConfig.ts` +- Modify: `packages/dashboard-backend/src/routes/api/__tests__/clusterConfig.spec.ts` + +**Interfaces:** +- Consumes: `getDeviceAuthClientId()` from Task 1 + +- [ ] **Step 1: Update `clusterConfig.ts`** + +Replace the entire file with: +```typescript +/* + * Copyright (c) 2018-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { ClusterConfig } from '@eclipse-che/common'; +import { FastifyInstance } from 'fastify'; + +import { baseApiPath } from '@/constants/config'; +import { getDeviceAuthClientId } from '@/routes/api/helpers/getDeviceAuthClientId'; +import { getDevWorkspaceClient } from '@/routes/api/helpers/getDevWorkspaceClient'; +import { getServiceAccountToken } from '@/routes/api/helpers/getServiceAccountToken'; +import { getSchema } from '@/services/helpers'; + +const tags = ['Cluster Config']; + +export function registerClusterConfigRoute(instance: FastifyInstance) { + instance.register(async server => { + server.get(`${baseApiPath}/cluster-config`, getSchema({ tags }), async () => + buildClusterConfig(), + ); + }); +} + +async function buildClusterConfig(): Promise { + const token = getServiceAccountToken(); + const { serverConfigApi } = getDevWorkspaceClient(token); + + const cheCustomResource = await serverConfigApi.fetchCheCustomResource(); + const dashboardWarning = serverConfigApi.getDashboardWarning(cheCustomResource); + const runningWorkspacesLimit = serverConfigApi.getRunningWorkspacesLimit(cheCustomResource); + const allWorkspacesLimit = serverConfigApi.getAllWorkspacesLimit(cheCustomResource); + const dashboardFavicon = serverConfigApi.getDashboardLogo(cheCustomResource); + const currentArchitecture = await serverConfigApi.getCurrentArchitecture(); + const clientId = await getDeviceAuthClientId(); + + return { + dashboardWarning, + dashboardFavicon, + allWorkspacesLimit, + runningWorkspacesLimit, + currentArchitecture, + githubDeviceAuthEnabled: clientId !== null, + }; +} +``` + +- [ ] **Step 2: Update `clusterConfig.spec.ts`** + +Add `jest.mock('../helpers/getDeviceAuthClientId.ts');` at the top with the other mocks. The auto-mock returns `null` (from the `__mocks__` file written in Task 1), so `githubDeviceAuthEnabled` stays `false` — no assertion changes needed. + +Full updated spec (only the mock line changes): +```typescript +jest.mock('../helpers/getServiceAccountToken.ts'); +jest.mock('../helpers/getDevWorkspaceClient.ts'); +jest.mock('../helpers/getDeviceAuthClientId.ts'); +``` + +- [ ] **Step 3: Run the clusterConfig spec** + +```bash +yarn workspace @eclipse-che/dashboard-backend test --testPathPatterns="clusterConfig" --no-cache +``` + +Expected: 1 test passes. + +- [ ] **Step 4: Format and lint** + +```bash +yarn format:fix && yarn lint:fix +``` + +- [ ] **Step 5: Commit** + +```bash +git add packages/dashboard-backend/src/routes/api/clusterConfig.ts \ + packages/dashboard-backend/src/routes/api/__tests__/clusterConfig.spec.ts +git commit -m "refactor(device-auth): use device-auth-config ConfigMap for githubDeviceAuthEnabled" +``` + +--- + +### Task 4: Update `deviceAuthToken.ts` route + add route-level spec + +**Files:** +- Modify: `packages/dashboard-backend/src/routes/api/deviceAuthToken.ts` +- Create: `packages/dashboard-backend/src/routes/api/__tests__/deviceAuthToken.spec.ts` + +**Interfaces:** +- Consumes: `getDeviceAuthClientId()` from Task 1; `initiateDeviceAuth(namespace, clientId)` / `pollDeviceAuth(namespace, deviceCode, clientId)` from Task 2 + +- [ ] **Step 1: Update `deviceAuthToken.ts`** + +Add `import { getDeviceAuthClientId } from '@/routes/api/helpers/getDeviceAuthClientId';` at the top. + +Replace the `initiate` route handler body: +```typescript +async function (request: FastifyRequest, reply: FastifyReply) { + const clientId = await getDeviceAuthClientId(); + if (!clientId) { + return reply.code(503).send({ + message: + 'GitHub device auth is not configured. Create a device-auth-config ConfigMap in the Che namespace.', + }); + } + const { namespace } = request.params as restParams.INamespacedParams; + const token = getToken(request); + const { deviceAuthTokenApi } = getDevWorkspaceClient(token); + return deviceAuthTokenApi.initiateDeviceAuth(namespace, clientId); +}, +``` + +Replace the `poll` route handler body: +```typescript +async function (request: FastifyRequest, reply: FastifyReply) { + const clientId = await getDeviceAuthClientId(); + if (!clientId) { + return reply.code(503).send({ + message: + 'GitHub device auth is not configured. Create a device-auth-config ConfigMap in the Che namespace.', + }); + } + const { namespace } = request.params as restParams.INamespacedParams; + const { deviceCode } = request.body as { deviceCode: string }; + const token = getToken(request); + const { deviceAuthTokenApi } = getDevWorkspaceClient(token); + return deviceAuthTokenApi.pollDeviceAuth(namespace, deviceCode, clientId); +}, +``` + +Add `FastifyReply` to the `fastify` import. + +- [ ] **Step 2: Write the route spec** + +```typescript +// packages/dashboard-backend/src/routes/api/__tests__/deviceAuthToken.spec.ts +/* + * Copyright (c) 2018-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { FastifyInstance } from 'fastify'; + +import { baseApiPath } from '@/constants/config'; +import { + stubDeviceAuthClientId, +} from '@/routes/api/helpers/__mocks__/getDeviceAuthClientId'; +import { setup, teardown } from '@/utils/appBuilder'; + +jest.mock('../helpers/getServiceAccountToken.ts'); +jest.mock('../helpers/getDevWorkspaceClient.ts'); +jest.mock('../helpers/getDeviceAuthClientId.ts'); + +// Allow the mock to be overridden per test +let mockClientId: string | null = stubDeviceAuthClientId; +jest.mock('@/routes/api/helpers/getDeviceAuthClientId', () => ({ + getDeviceAuthClientId: () => Promise.resolve(mockClientId), +})); + +const namespace = 'user-che'; + +describe('Device Auth Token Routes', () => { + let app: FastifyInstance; + + beforeAll(async () => { + app = await setup(); + }); + + afterAll(() => { + teardown(app); + }); + + beforeEach(() => { + mockClientId = null; + }); + + describe('POST /initiate', () => { + it('returns 503 when device auth is not configured', async () => { + mockClientId = null; + const res = await app.inject({ + method: 'POST', + url: `${baseApiPath}/namespace/${namespace}/device-auth-token/initiate`, + }); + expect(res.statusCode).toBe(503); + }); + + it('calls initiateDeviceAuth with clientId when configured', async () => { + mockClientId = 'test-client-id'; + // getDevWorkspaceClient mock returns stub that throws; 500 is expected but 503 must not occur + const res = await app.inject({ + method: 'POST', + url: `${baseApiPath}/namespace/${namespace}/device-auth-token/initiate`, + }); + expect(res.statusCode).not.toBe(503); + }); + }); + + describe('POST /poll', () => { + it('returns 503 when device auth is not configured', async () => { + mockClientId = null; + const res = await app.inject({ + method: 'POST', + url: `${baseApiPath}/namespace/${namespace}/device-auth-token/poll`, + payload: { deviceCode: 'ABCD-1234' }, + }); + expect(res.statusCode).toBe(503); + }); + + it('does not return 503 when configured', async () => { + mockClientId = 'test-client-id'; + const res = await app.inject({ + method: 'POST', + url: `${baseApiPath}/namespace/${namespace}/device-auth-token/poll`, + payload: { deviceCode: 'ABCD-1234' }, + }); + expect(res.statusCode).not.toBe(503); + }); + }); +}); +``` + +- [ ] **Step 3: Run the route spec** + +```bash +yarn workspace @eclipse-che/dashboard-backend test --testPathPatterns="deviceAuthToken.spec" --no-cache +``` + +Expected: All tests pass. + +- [ ] **Step 4: Run the full backend test suite to confirm no regressions** + +```bash +yarn workspace @eclipse-che/dashboard-backend test --no-cache 2>&1 | tail -20 +``` + +Expected: All suites pass. + +- [ ] **Step 5: Format and lint** + +```bash +yarn format:fix && yarn lint:fix +``` + +- [ ] **Step 6: Commit** + +```bash +git add packages/dashboard-backend/src/routes/api/deviceAuthToken.ts \ + packages/dashboard-backend/src/routes/api/__tests__/deviceAuthToken.spec.ts +git commit -m "feat(device-auth): gate initiate/poll on device-auth-config; pass clientId from route" +``` + +--- + +## Self-Review Checklist + +- [x] Spec coverage: ConfigMap read ✓, TTL cache ✓, `githubDeviceAuthEnabled` via ConfigMap ✓, service `clientId` param ✓, 503 gate on routes ✓, 3-tier fallback removed ✓, no Che Server call for device auth ✓, no env-var injection dependency ✓ +- [x] Placeholder scan: all code blocks are complete and self-contained +- [x] Type consistency: `initiateDeviceAuth(namespace: string, clientId: string)` and `pollDeviceAuth(namespace: string, deviceCode: string, clientId: string)` used consistently across Task 2, 3, and 4 diff --git a/packages/dashboard-backend/src/constants/schemas.ts b/packages/dashboard-backend/src/constants/schemas.ts index 83b056fe1b..2fbfb2d384 100644 --- a/packages/dashboard-backend/src/constants/schemas.ts +++ b/packages/dashboard-backend/src/constants/schemas.ts @@ -329,7 +329,6 @@ export const deviceAuthTokenResponseSchema = { name: { type: 'string' }, provider: { type: 'string' }, creationTimestamp: { type: 'string' }, - valid: { type: 'string', enum: ['valid', 'invalid', 'unknown'] }, }, required: ['name'], }, diff --git a/packages/dashboard-backend/src/devworkspaceClient/services/__tests__/deviceAuthTokenApi.spec.ts b/packages/dashboard-backend/src/devworkspaceClient/services/__tests__/deviceAuthTokenApi.spec.ts index a05f97a58b..c14a862878 100644 --- a/packages/dashboard-backend/src/devworkspaceClient/services/__tests__/deviceAuthTokenApi.spec.ts +++ b/packages/dashboard-backend/src/devworkspaceClient/services/__tests__/deviceAuthTokenApi.spec.ts @@ -210,12 +210,7 @@ describe('DeviceAuthToken API Service', () => { }); describe('initiateDeviceAuth', () => { - const origClientId = process.env.CHE_GITHUB_OAUTH_CLIENT_ID; - beforeEach(() => { - process.env.CHE_GITHUB_OAUTH_CLIENT_ID = 'test-client-id'; - }); afterEach(() => { - process.env.CHE_GITHUB_OAUTH_CLIENT_ID = origClientId; jest.clearAllMocks(); }); @@ -231,7 +226,7 @@ describe('DeviceAuthToken API Service', () => { }), }); - const result = await service.initiateDeviceAuth(namespace); + const result = await service.initiateDeviceAuth(namespace, 'test-client-id'); expect(result).toEqual({ deviceCode: 'dev-code-123', @@ -241,26 +236,19 @@ describe('DeviceAuthToken API Service', () => { }); }); - it('should throw when CHE_GITHUB_OAUTH_CLIENT_ID is not set', async () => { - delete process.env.CHE_GITHUB_OAUTH_CLIENT_ID; - await expect(service.initiateDeviceAuth(namespace)).rejects.toThrow( - 'CHE_GITHUB_OAUTH_CLIENT_ID', - ); - }); - it('should throw when GitHub returns an error', async () => { mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ error: 'invalid_client', error_description: 'Bad client' }), }); - await expect(service.initiateDeviceAuth(namespace)).rejects.toThrow('Bad client'); + await expect(service.initiateDeviceAuth(namespace, 'test-client-id')).rejects.toThrow( + 'Bad client', + ); }); }); describe('pollDeviceAuth', () => { - const origClientId = process.env.CHE_GITHUB_OAUTH_CLIENT_ID; beforeEach(async () => { - process.env.CHE_GITHUB_OAUTH_CLIENT_ID = 'test-client-id'; stubCoreV1Api.createNamespacedSecret = jest.fn().mockResolvedValue({ metadata: { name: 'device-authentication-github', @@ -285,10 +273,9 @@ describe('DeviceAuthToken API Service', () => { expires_in: 900, }), }); - await service.initiateDeviceAuth(namespace); + await service.initiateDeviceAuth(namespace, 'test-client-id'); }); afterEach(() => { - process.env.CHE_GITHUB_OAUTH_CLIENT_ID = origClientId; jest.clearAllMocks(); }); @@ -297,7 +284,7 @@ describe('DeviceAuthToken API Service', () => { ok: true, json: () => Promise.resolve({ error: 'authorization_pending' }), }); - const result = await service.pollDeviceAuth(namespace, 'dev-code-123'); + const result = await service.pollDeviceAuth(namespace, 'dev-code-123', 'test-client-id'); expect(result).toEqual({ status: 'pending' }); }); @@ -306,7 +293,7 @@ describe('DeviceAuthToken API Service', () => { ok: true, json: () => Promise.resolve({ error: 'slow_down' }), }); - const result = await service.pollDeviceAuth(namespace, 'dev-code-123'); + const result = await service.pollDeviceAuth(namespace, 'dev-code-123', 'test-client-id'); expect(result).toEqual({ status: 'slow_down' }); }); @@ -315,7 +302,7 @@ describe('DeviceAuthToken API Service', () => { ok: true, json: () => Promise.resolve({ error: 'expired_token' }), }); - const result = await service.pollDeviceAuth(namespace, 'dev-code-123'); + const result = await service.pollDeviceAuth(namespace, 'dev-code-123', 'test-client-id'); expect(result).toEqual({ status: 'expired' }); }); @@ -325,7 +312,7 @@ describe('DeviceAuthToken API Service', () => { json: () => Promise.resolve({ access_token: 'ghp_token123', token_type: 'bearer', scope: 'repo' }), }); - const result = await service.pollDeviceAuth(namespace, 'dev-code-123'); + const result = await service.pollDeviceAuth(namespace, 'dev-code-123', 'test-client-id'); expect(result.status).toBe('authorized'); expect((result as { status: 'authorized'; token: api.DeviceAuthToken }).token.provider).toBe( 'github', @@ -347,7 +334,7 @@ describe('DeviceAuthToken API Service', () => { expires_in: 900, }), }); - await service.initiateDeviceAuth(namespace); + await service.initiateDeviceAuth(namespace, 'test-client-id'); const existingName = 'device-authentication-github'; spyListNamespacedSecret.mockResolvedValueOnce({ items: [{ metadata: { name: existingName } }], @@ -358,7 +345,7 @@ describe('DeviceAuthToken API Service', () => { Promise.resolve({ access_token: 'ghp_new_token', token_type: 'bearer', scope: 'repo' }), }); - const result = await service.pollDeviceAuth(namespace, 'dev-code-456'); + const result = await service.pollDeviceAuth(namespace, 'dev-code-456', 'test-client-id'); expect(result.status).toBe('authorized'); expect((result as { status: 'authorized'; token: api.DeviceAuthToken }).token.name).toBe( @@ -376,7 +363,7 @@ describe('DeviceAuthToken API Service', () => { json: () => Promise.resolve({ error: 'access_denied', error_description: 'User denied access' }), }); - const result = await service.pollDeviceAuth(namespace, 'dev-code-123'); + const result = await service.pollDeviceAuth(namespace, 'dev-code-123', 'test-client-id'); expect(result).toEqual({ status: 'error', message: 'User denied access' }); }); @@ -385,7 +372,7 @@ describe('DeviceAuthToken API Service', () => { ok: true, json: () => Promise.resolve({}), }); - const result = await service.pollDeviceAuth(namespace, 'dev-code-123'); + const result = await service.pollDeviceAuth(namespace, 'dev-code-123', 'test-client-id'); expect(result).toEqual({ status: 'error', message: 'No access_token in response' }); }); }); diff --git a/packages/dashboard-backend/src/devworkspaceClient/services/deviceAuthTokenApi.ts b/packages/dashboard-backend/src/devworkspaceClient/services/deviceAuthTokenApi.ts index 378f0440e1..df87c4fcbc 100644 --- a/packages/dashboard-backend/src/devworkspaceClient/services/deviceAuthTokenApi.ts +++ b/packages/dashboard-backend/src/devworkspaceClient/services/deviceAuthTokenApi.ts @@ -12,7 +12,6 @@ import { api } from '@eclipse-che/common'; import * as k8s from '@kubernetes/client-node'; -import { existsSync, readFileSync } from 'fs'; import { createError } from '@/devworkspaceClient/services/helpers/createError'; import { @@ -59,57 +58,6 @@ interface GitHubTokenResponse { error_description?: string; } -// Mounted file path — same path Che Server uses when the operator -// mounts github-oauth-config into the pod as a volume. -const GITHUB_OAUTH_ID_FILE = '/che-conf/oauth/github/id'; - -async function getGitHubClientId(): Promise { - // Priority 1: env var (operator auto-injection in standard deployments) - if (process.env.CHE_GITHUB_OAUTH_CLIENT_ID) { - return process.env.CHE_GITHUB_OAUTH_CLIENT_ID; - } - // Priority 2: mounted file (only needed with a custom deployment spec where - // the operator does not auto-inject the env var) - if (existsSync(GITHUB_OAUTH_ID_FILE)) { - const id = readFileSync(GITHUB_OAUTH_ID_FILE, 'utf8').trim(); - if (id) { - return id; - } - } - // Priority 3: clientId from GET /api/oauth on the Che Server (available - // once che-server#1035 is deployed — no env var or volume mount needed). - const cheInternalUrl = process.env.CHE_INTERNAL_URL; - if (cheInternalUrl) { - try { - const { getServiceAccountToken } = await import( - '@/routes/api/helpers/getServiceAccountToken' - ); - const saToken = getServiceAccountToken(); - const response = await fetch(`${cheInternalUrl}/oauth`, { - headers: { Authorization: `Bearer ${saToken}` }, - signal: AbortSignal.timeout(5_000), - }); - if (response.ok) { - const providers = (await response.json()) as Array<{ - name: string; - clientId?: string; - }>; - const github = providers.find(p => p.name === 'github'); - if (github?.clientId) { - return github.clientId; - } - } - } catch { - // fall through - } - } - throw new Error( - 'GitHub OAuth client_id is not available. ' + - 'Ensure CHE_GITHUB_OAUTH_CLIENT_ID is set, or deploy che-server#1035 ' + - 'which exposes clientId via GET /api/oauth.', - ); -} - async function githubPostDeviceCode( params: Record, ): Promise { @@ -198,8 +146,9 @@ export class GitHubDeviceAuthTokenApiService implements IDeviceAuthTokenApi { } if (secret.metadata?.labels?.[DEVICE_AUTH_LABEL] !== 'true') { - throw new Error( - `Secret "${tokenName}" does not carry the ${DEVICE_AUTH_LABEL_SELECTOR} label`, + throw Object.assign( + new Error(`Secret "${tokenName}" does not carry the ${DEVICE_AUTH_LABEL_SELECTOR} label`), + { statusCode: 403 }, ); } @@ -250,8 +199,7 @@ export class GitHubDeviceAuthTokenApiService implements IDeviceAuthTokenApi { } } - async initiateDeviceAuth(namespace: string): Promise { - const clientId = await getGitHubClientId(); + async initiateDeviceAuth(namespace: string, clientId: string): Promise { const data = await githubPostDeviceCode({ client_id: clientId, scope: GITHUB_SCOPES, @@ -277,15 +225,21 @@ export class GitHubDeviceAuthTokenApiService implements IDeviceAuthTokenApi { }; } - async pollDeviceAuth(namespace: string, deviceCode: string): Promise { + async pollDeviceAuth( + namespace: string, + deviceCode: string, + clientId: string, + ): Promise { const stored = activeDeviceCodes.get(namespace); if (!stored || stored.code !== deviceCode || Date.now() > stored.expiresAt) { + if (stored && Date.now() > stored.expiresAt) { + activeDeviceCodes.delete(namespace); + } return { status: 'error', message: 'Device code is not valid for this session. Please initiate a new connection.', }; } - const clientId = await getGitHubClientId(); const data = await githubPostToken({ client_id: clientId, device_code: deviceCode, @@ -358,6 +312,7 @@ export class GitHubDeviceAuthTokenApiService implements IDeviceAuthTokenApi { }); if (existing.items.length > 0 && existing.items[0].metadata?.name) { const existingName = existing.items[0].metadata.name; + const existingMeta = existing.items[0].metadata; try { const updated = await this.coreV1API.replaceNamespacedSecret({ name: existingName, @@ -366,7 +321,9 @@ export class GitHubDeviceAuthTokenApiService implements IDeviceAuthTokenApi { metadata: { name: existingName, namespace, + resourceVersion: existingMeta.resourceVersion, labels: { + ...(existingMeta.labels ?? {}), [DEVICE_AUTH_LABEL]: 'true', [DEVICE_AUTH_PROVIDER_LABEL]: 'github', }, diff --git a/packages/dashboard-backend/src/devworkspaceClient/types/index.ts b/packages/dashboard-backend/src/devworkspaceClient/types/index.ts index 7831c598e4..fcdc916108 100644 --- a/packages/dashboard-backend/src/devworkspaceClient/types/index.ts +++ b/packages/dashboard-backend/src/devworkspaceClient/types/index.ts @@ -642,13 +642,17 @@ export interface IDeviceAuthTokenApi { /** * Initiates a GitHub Device Authorization flow and returns the device code and user code. */ - initiateDeviceAuth(namespace: string): Promise; + initiateDeviceAuth(namespace: string, clientId: string): Promise; /** * Polls GitHub for the access token using the device code. * On success, stores the token as a Kubernetes secret. */ - pollDeviceAuth(namespace: string, deviceCode: string): Promise; + pollDeviceAuth( + namespace: string, + deviceCode: string, + clientId: string, + ): Promise; validateToken(namespace: string, tokenName: string): Promise<'valid' | 'invalid' | 'unknown'>; } diff --git a/packages/dashboard-backend/src/routes/api/__tests__/clusterConfig.spec.ts b/packages/dashboard-backend/src/routes/api/__tests__/clusterConfig.spec.ts index 745f7d7d45..bb6517615a 100644 --- a/packages/dashboard-backend/src/routes/api/__tests__/clusterConfig.spec.ts +++ b/packages/dashboard-backend/src/routes/api/__tests__/clusterConfig.spec.ts @@ -24,6 +24,7 @@ import { setup, teardown } from '@/utils/appBuilder'; jest.mock('../helpers/getServiceAccountToken.ts'); jest.mock('../helpers/getDevWorkspaceClient.ts'); +jest.mock('../helpers/getDeviceAuthClientId.ts'); describe('Cluster Config Route', () => { let app: FastifyInstance; diff --git a/packages/dashboard-backend/src/routes/api/__tests__/deviceAuthToken.spec.ts b/packages/dashboard-backend/src/routes/api/__tests__/deviceAuthToken.spec.ts new file mode 100644 index 0000000000..b2b148e64d --- /dev/null +++ b/packages/dashboard-backend/src/routes/api/__tests__/deviceAuthToken.spec.ts @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2018-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { FastifyInstance } from 'fastify'; + +import { baseApiPath } from '@/constants/config'; +import { setup, teardown } from '@/utils/appBuilder'; + +jest.mock('../helpers/getServiceAccountToken.ts'); +jest.mock('../helpers/getDevWorkspaceClient.ts'); + +let mockClientId: string | null = null; +jest.mock('@/routes/api/helpers/getDeviceAuthClientId', () => ({ + getDeviceAuthClientId: () => Promise.resolve(mockClientId), +})); + +const namespace = 'user-che'; + +describe('Device Auth Token Routes', () => { + let app: FastifyInstance; + + beforeAll(async () => { + app = await setup(); + }); + + afterAll(() => { + teardown(app); + }); + + beforeEach(() => { + mockClientId = null; + }); + + describe('POST /initiate — device auth not configured', () => { + it('returns 503 when device-auth-config ConfigMap is absent', async () => { + mockClientId = null; + const res = await app.inject({ + method: 'POST', + url: `${baseApiPath}/namespace/${namespace}/device-auth-token/initiate`, + }); + expect(res.statusCode).toBe(503); + }); + }); + + describe('POST /initiate — device auth configured', () => { + it('does not return 503 when clientId is present', async () => { + mockClientId = 'test-client-id'; + const res = await app.inject({ + method: 'POST', + url: `${baseApiPath}/namespace/${namespace}/device-auth-token/initiate`, + }); + expect(res.statusCode).not.toBe(503); + }); + }); + + describe('POST /poll — device auth not configured', () => { + it('returns 503 when device-auth-config ConfigMap is absent', async () => { + mockClientId = null; + const res = await app.inject({ + method: 'POST', + url: `${baseApiPath}/namespace/${namespace}/device-auth-token/poll`, + payload: { deviceCode: 'ABCD-1234' }, + }); + expect(res.statusCode).toBe(503); + }); + }); + + describe('POST /poll — device auth configured', () => { + it('does not return 503 when clientId is present', async () => { + mockClientId = 'test-client-id'; + const res = await app.inject({ + method: 'POST', + url: `${baseApiPath}/namespace/${namespace}/device-auth-token/poll`, + payload: { deviceCode: 'ABCD-1234' }, + }); + expect(res.statusCode).not.toBe(503); + }); + }); +}); diff --git a/packages/dashboard-backend/src/routes/api/clusterConfig.ts b/packages/dashboard-backend/src/routes/api/clusterConfig.ts index 47cb076c26..dc5683955a 100644 --- a/packages/dashboard-backend/src/routes/api/clusterConfig.ts +++ b/packages/dashboard-backend/src/routes/api/clusterConfig.ts @@ -14,6 +14,7 @@ import { ClusterConfig } from '@eclipse-che/common'; import { FastifyInstance } from 'fastify'; import { baseApiPath } from '@/constants/config'; +import { getDeviceAuthClientId } from '@/routes/api/helpers/getDeviceAuthClientId'; import { getDevWorkspaceClient } from '@/routes/api/helpers/getDevWorkspaceClient'; import { getServiceAccountToken } from '@/routes/api/helpers/getServiceAccountToken'; import { getSchema } from '@/services/helpers'; @@ -28,36 +29,6 @@ export function registerClusterConfigRoute(instance: FastifyInstance) { }); } -/** - * Determines whether GitHub OAuth is configured by calling the Che Server's - * /api/oauth endpoint with the dashboard SA token — the same source the - * Git Services tab uses. No RBAC changes or env vars required. - * Falls back to CHE_GITHUB_OAUTH_CLIENT_ID env var for local dev / override. - */ -async function isGitHubOAuthConfigured(): Promise { - if (process.env.CHE_GITHUB_OAUTH_CLIENT_ID) { - return true; - } - const cheInternalUrl = process.env.CHE_INTERNAL_URL; - if (!cheInternalUrl) { - return false; - } - try { - const saToken = getServiceAccountToken(); - const response = await fetch(`${cheInternalUrl}/oauth`, { - headers: { Authorization: `Bearer ${saToken}` }, - signal: AbortSignal.timeout(5_000), - }); - if (!response.ok) { - return false; - } - const providers = (await response.json()) as Array<{ name: string }>; - return Array.isArray(providers) && providers.some(p => p.name === 'github'); - } catch { - return false; - } -} - async function buildClusterConfig(): Promise { const token = getServiceAccountToken(); const { serverConfigApi } = getDevWorkspaceClient(token); @@ -68,6 +39,7 @@ async function buildClusterConfig(): Promise { const allWorkspacesLimit = serverConfigApi.getAllWorkspacesLimit(cheCustomResource); const dashboardFavicon = serverConfigApi.getDashboardLogo(cheCustomResource); const currentArchitecture = await serverConfigApi.getCurrentArchitecture(); + const clientId = await getDeviceAuthClientId(); return { dashboardWarning, @@ -75,6 +47,6 @@ async function buildClusterConfig(): Promise { allWorkspacesLimit, runningWorkspacesLimit, currentArchitecture, - githubDeviceAuthEnabled: await isGitHubOAuthConfigured(), + githubDeviceAuthEnabled: clientId !== null, }; } diff --git a/packages/dashboard-backend/src/routes/api/deviceAuthToken.ts b/packages/dashboard-backend/src/routes/api/deviceAuthToken.ts index f344c347ef..3f5d4997ad 100644 --- a/packages/dashboard-backend/src/routes/api/deviceAuthToken.ts +++ b/packages/dashboard-backend/src/routes/api/deviceAuthToken.ts @@ -21,6 +21,7 @@ import { namespacedSchema, } from '@/constants/schemas'; import { restParams } from '@/models'; +import { getDeviceAuthClientId } from '@/routes/api/helpers/getDeviceAuthClientId'; import { getDevWorkspaceClient } from '@/routes/api/helpers/getDevWorkspaceClient'; import { getToken } from '@/routes/api/helpers/getToken'; import { getSchema } from '@/services/helpers'; @@ -35,6 +36,9 @@ const rateLimitConfig = { }, }; +const DEVICE_AUTH_NOT_CONFIGURED_MSG = + 'GitHub device auth is not configured. Create a device-auth-config ConfigMap in the Che namespace with a github_client_id key.'; + export function registerDeviceAuthTokenRoutes(instance: FastifyInstance) { instance.register(async server => { /** @@ -82,17 +86,21 @@ export function registerDeviceAuthTokenRoutes(instance: FastifyInstance) { /** * POST /dashboard/api/namespace/:namespace/device-auth-token/initiate - * Calls GitHub to obtain a device code. Requires CHE_GITHUB_OAUTH_CLIENT_ID env var. + * Calls GitHub to obtain a device code. Reads client_id from device-auth-config ConfigMap. * Uses user bearer token for auth. */ server.post( `${baseApiPath}/namespace/:namespace/device-auth-token/initiate`, Object.assign({}, rateLimitConfig, getSchema({ tags, params: namespacedSchema })), - async function (request: FastifyRequest) { + async function (request: FastifyRequest, reply: FastifyReply) { + const clientId = await getDeviceAuthClientId(); + if (!clientId) { + return reply.code(503).send({ message: DEVICE_AUTH_NOT_CONFIGURED_MSG }); + } const { namespace } = request.params as restParams.INamespacedParams; const token = getToken(request); const { deviceAuthTokenApi } = getDevWorkspaceClient(token); - return deviceAuthTokenApi.initiateDeviceAuth(namespace); + return deviceAuthTokenApi.initiateDeviceAuth(namespace, clientId); }, ); @@ -108,14 +116,19 @@ export function registerDeviceAuthTokenRoutes(instance: FastifyInstance) { rateLimitConfig, getSchema({ tags, params: namespacedSchema, body: deviceAuthPollBodySchema }), ), - async function (request: FastifyRequest) { + async function (request: FastifyRequest, reply: FastifyReply) { + const clientId = await getDeviceAuthClientId(); + if (!clientId) { + return reply.code(503).send({ message: DEVICE_AUTH_NOT_CONFIGURED_MSG }); + } const { namespace } = request.params as restParams.INamespacedParams; const { deviceCode } = request.body as { deviceCode: string }; const token = getToken(request); const { deviceAuthTokenApi } = getDevWorkspaceClient(token); - return deviceAuthTokenApi.pollDeviceAuth(namespace, deviceCode); + return deviceAuthTokenApi.pollDeviceAuth(namespace, deviceCode, clientId); }, ); + /** * GET /dashboard/api/namespace/:namespace/device-auth-token/:tokenName/validate * Checks whether the stored GitHub token is still valid. diff --git a/packages/dashboard-backend/src/routes/api/helpers/__mocks__/getDeviceAuthClientId.ts b/packages/dashboard-backend/src/routes/api/helpers/__mocks__/getDeviceAuthClientId.ts new file mode 100644 index 0000000000..bdb2c52f83 --- /dev/null +++ b/packages/dashboard-backend/src/routes/api/helpers/__mocks__/getDeviceAuthClientId.ts @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2018-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +export async function getDeviceAuthClientId(): Promise { + return null; +} diff --git a/packages/dashboard-backend/src/routes/api/helpers/__tests__/getDeviceAuthClientId.spec.ts b/packages/dashboard-backend/src/routes/api/helpers/__tests__/getDeviceAuthClientId.spec.ts new file mode 100644 index 0000000000..de54b40bea --- /dev/null +++ b/packages/dashboard-backend/src/routes/api/helpers/__tests__/getDeviceAuthClientId.spec.ts @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2018-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +const mockReadNamespacedConfigMap = jest.fn(); + +jest.mock('@/routes/api/helpers/getServiceAccountToken', () => ({ + getServiceAccountToken: jest.fn().mockReturnValue('sa-token'), +})); + +jest.mock('@/services/kubeclient/kubeConfigProvider', () => ({ + KubeConfigProvider: jest.fn().mockImplementation(() => ({ + getKubeConfig: jest.fn().mockReturnValue({}), + })), +})); + +jest.mock('@/devworkspaceClient/services/helpers/prepareCoreV1API', () => ({ + prepareCoreV1API: jest.fn().mockReturnValue({ + readNamespacedConfigMap: (...args: unknown[]) => mockReadNamespacedConfigMap(...args), + }), +})); + +describe('getDeviceAuthClientId', () => { + const origEnv = { ...process.env }; + + beforeEach(() => { + jest.resetModules(); + process.env = { ...origEnv }; + delete process.env.DEVICE_AUTH_GITHUB_CLIENT_ID; + process.env.CHECLUSTER_CR_NAMESPACE = 'eclipse-che'; + mockReadNamespacedConfigMap.mockReset(); + }); + + afterEach(() => { + process.env = origEnv; + }); + + it('returns client_id from ConfigMap when present', async () => { + mockReadNamespacedConfigMap.mockResolvedValueOnce({ + data: { github_client_id: '01ab8ac9400c4e429b23' }, + }); + let result: string | null; + jest.isolateModules(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { getDeviceAuthClientId } = require('@/routes/api/helpers/getDeviceAuthClientId'); + result = getDeviceAuthClientId(); + }); + await expect(result!).resolves.toBe('01ab8ac9400c4e429b23'); + }); + + it('returns null when ConfigMap key is absent', async () => { + mockReadNamespacedConfigMap.mockResolvedValueOnce({ data: {} }); + let result: Promise; + jest.isolateModules(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { getDeviceAuthClientId } = require('@/routes/api/helpers/getDeviceAuthClientId'); + result = getDeviceAuthClientId(); + }); + await expect(result!).resolves.toBeNull(); + }); + + it('returns null when ConfigMap does not exist', async () => { + mockReadNamespacedConfigMap.mockRejectedValueOnce( + Object.assign(new Error('Not Found'), { code: 404 }), + ); + let result: Promise; + jest.isolateModules(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { getDeviceAuthClientId } = require('@/routes/api/helpers/getDeviceAuthClientId'); + result = getDeviceAuthClientId(); + }); + await expect(result!).resolves.toBeNull(); + }); + + it('returns value from DEVICE_AUTH_GITHUB_CLIENT_ID env var without hitting K8s', async () => { + process.env.DEVICE_AUTH_GITHUB_CLIENT_ID = 'local-override-id'; + let result: Promise; + jest.isolateModules(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { getDeviceAuthClientId } = require('@/routes/api/helpers/getDeviceAuthClientId'); + result = getDeviceAuthClientId(); + }); + await expect(result!).resolves.toBe('local-override-id'); + expect(mockReadNamespacedConfigMap).not.toHaveBeenCalled(); + }); + + it('returns null when CHECLUSTER_CR_NAMESPACE is not set', async () => { + delete process.env.CHECLUSTER_CR_NAMESPACE; + let result: Promise; + jest.isolateModules(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { getDeviceAuthClientId } = require('@/routes/api/helpers/getDeviceAuthClientId'); + result = getDeviceAuthClientId(); + }); + await expect(result!).resolves.toBeNull(); + expect(mockReadNamespacedConfigMap).not.toHaveBeenCalled(); + }); + + it('caches the result for subsequent calls', async () => { + mockReadNamespacedConfigMap.mockResolvedValue({ + data: { github_client_id: 'cached-id' }, + }); + let getDeviceAuthClientId: () => Promise; + jest.isolateModules(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + ({ getDeviceAuthClientId } = require('@/routes/api/helpers/getDeviceAuthClientId')); + }); + await getDeviceAuthClientId!(); + await getDeviceAuthClientId!(); + expect(mockReadNamespacedConfigMap).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/dashboard-backend/src/routes/api/helpers/getDeviceAuthClientId.ts b/packages/dashboard-backend/src/routes/api/helpers/getDeviceAuthClientId.ts new file mode 100644 index 0000000000..6b45a6fd6a --- /dev/null +++ b/packages/dashboard-backend/src/routes/api/helpers/getDeviceAuthClientId.ts @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2018-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { prepareCoreV1API } from '@/devworkspaceClient/services/helpers/prepareCoreV1API'; +import { getServiceAccountToken } from '@/routes/api/helpers/getServiceAccountToken'; +import { KubeConfigProvider } from '@/services/kubeclient/kubeConfigProvider'; + +const DEVICE_AUTH_CONFIG_MAP = 'device-auth-config'; +const GITHUB_CLIENT_ID_KEY = 'github_client_id'; +const CACHE_TTL_MS = 90_000; + +let cache: { value: string | null; expiresAt: number } | null = null; + +export async function getDeviceAuthClientId(): Promise { + if (cache && Date.now() < cache.expiresAt) { + return cache.value; + } + + const value = await resolveClientId(); + cache = { value, expiresAt: Date.now() + CACHE_TTL_MS }; + return value; +} + +async function resolveClientId(): Promise { + // Local dev / CI override — avoids needing a ConfigMap in development + if (process.env.DEVICE_AUTH_GITHUB_CLIENT_ID) { + return process.env.DEVICE_AUTH_GITHUB_CLIENT_ID; + } + + const namespace = process.env.CHECLUSTER_CR_NAMESPACE; + if (!namespace) { + return null; + } + + try { + const token = getServiceAccountToken(); + const kc = new KubeConfigProvider().getKubeConfig(token); + const coreV1API = prepareCoreV1API(kc); + const configMap = await coreV1API.readNamespacedConfigMap({ + name: DEVICE_AUTH_CONFIG_MAP, + namespace, + }); + const clientId = configMap.data?.[GITHUB_CLIENT_ID_KEY]?.trim() ?? ''; + return clientId || null; + } catch { + return null; + } +} diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/ConnectModal/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/ConnectModal/__tests__/index.spec.tsx index d6adb86799..5188e8ea6c 100644 --- a/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/ConnectModal/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/ConnectModal/__tests__/index.spec.tsx @@ -15,8 +15,16 @@ import React from 'react'; import { Provider } from 'react-redux'; import { ConnectModal } from '@/pages/UserPreferences/DeviceAuthTokens/ConnectModal'; -import getComponentRenderer, { screen, waitFor } from '@/services/__mocks__/getComponentRenderer'; -import { DeviceCodeResponse, pollDeviceAuth } from '@/services/backend-client/deviceAuthTokenApi'; +import getComponentRenderer, { + render, + screen, + waitFor, +} from '@/services/__mocks__/getComponentRenderer'; +import { + DeviceAuthPollResult, + DeviceCodeResponse, + pollDeviceAuth, +} from '@/services/backend-client/deviceAuthTokenApi'; import { AppThunk } from '@/store'; import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; import { deviceAuthTokenActionCreators } from '@/store/DeviceAuthToken'; @@ -96,6 +104,29 @@ describe('ConnectModal', () => { expect(mockOnCloseModal).toHaveBeenCalled(); }); + it('should not call onSuccess when component unmounts before poll resolves', async () => { + let resolvePoll!: (result: DeviceAuthPollResult) => void; + const pendingPoll = new Promise(resolve => { + resolvePoll = resolve; + }); + (pollDeviceAuth as jest.Mock).mockReturnValue(pendingPoll); + + const { unmount } = render(getComponent(true)); + await waitFor(() => screen.getByTestId('user-code')); + + // advance to trigger the first scheduled poll + jest.advanceTimersByTime(5000); + + // unmount while the poll promise is still pending + unmount(); + + // resolve the in-flight poll after unmount + resolvePoll({ status: 'authorized', token: newToken }); + await Promise.resolve(); + + expect(mockOnSuccess).not.toHaveBeenCalled(); + }); + it('should increase poll interval by 5s on slow_down', async () => { (pollDeviceAuth as jest.Mock).mockResolvedValue({ status: 'slow_down' }); renderComponent(true); diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/ConnectModal/index.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/ConnectModal/index.tsx index ead7c5aacf..d98f848683 100644 --- a/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/ConnectModal/index.tsx +++ b/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/ConnectModal/index.tsx @@ -152,8 +152,8 @@ class ConnectModalClass extends React.PureComponent { if (result.status === 'pending') { this.schedulePoll(response); } else if (result.status === 'slow_down') { - // RFC 8628 §3.5: increase interval by 5s on slow_down - this.schedulePoll({ ...response, interval: response.interval + 5 }); + // RFC 8628 §3.5: increase interval by 5s on slow_down; cap at 60s + this.schedulePoll({ ...response, interval: Math.min(response.interval + 5, 60) }); } else if (result.status === 'authorized') { this.setState({ pollErrorCount: 0 }); this.props.onSuccess(result.token); diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/__tests__/index.spec.tsx index 8293a24bf5..9a6dc5bede 100644 --- a/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/__tests__/index.spec.tsx @@ -33,8 +33,6 @@ jest.mock('@/pages/UserPreferences/DeviceAuthTokens/ConnectModal'); jest.mock('@/pages/UserPreferences/DeviceAuthTokens/DeleteModal'); jest.mock('@/pages/UserPreferences/DeviceAuthTokens/List'); -console.error = jest.fn(); - const mockShowAlert = jest.fn(); const mockRequestDeviceAuthTokens = jest.fn(); @@ -63,8 +61,10 @@ const { renderComponent } = getComponentRenderer(getComponent); describe('DeviceAuthTokens', () => { let storeBuilder: MockStoreBuilder; let localState: Partial; + let consoleErrorSpy: jest.SpyInstance; beforeEach(() => { + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); storeBuilder = new MockStoreBuilder().withClusterConfig({ githubDeviceAuthEnabled: true }); class MockAppAlerts extends AppAlerts { @@ -78,6 +78,7 @@ describe('DeviceAuthTokens', () => { }); afterEach(() => { + consoleErrorSpy.mockRestore(); jest.clearAllMocks(); container.restore(); localState = {}; diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/index.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/index.tsx index 64cba6633a..d59dc05643 100644 --- a/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/index.tsx +++ b/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/index.tsx @@ -94,14 +94,13 @@ class DeviceAuthTokens extends React.PureComponent { variant: AlertVariant.danger, }); } - if (prevProps.tokens.length === 0 && tokens.length > 0) { + if (tokens.length > 0 && prevProps.tokens !== tokens) { this.validateTokensInBackground(); } } private validateTokensInBackground(): void { const { tokens, namespace } = this.props; - this.setState({ validatedTokens: {} }); tokens.forEach(token => { validateDeviceAuthToken(namespace, token.name) .then(valid => { From 0c93d88264cd3c0115f08f5a043a5a1a28a219fc Mon Sep 17 00:00:00 2001 From: Oleksii Orel Date: Thu, 6 Aug 2026 17:20:48 +0300 Subject: [PATCH 8/9] fix(device-auth): address final review feedback and regenerate licenses Assisted-by: Claude Sonnet 4.6 Signed-off-by: Oleksii Orel --- .../__tests__/deviceAuthTokenApi.spec.ts | 52 +++++++++++++---- .../api/__tests__/deviceAuthToken.spec.ts | 5 ++ .../src/routes/api/deviceAuthToken.ts | 18 +++--- .../__tests__/getDeviceAuthClientId.spec.ts | 56 +++++-------------- .../api/helpers/getDeviceAuthClientId.ts | 5 ++ .../DeviceAuthTokens/List/index.tsx | 4 +- 6 files changed, 75 insertions(+), 65 deletions(-) diff --git a/packages/dashboard-backend/src/devworkspaceClient/services/__tests__/deviceAuthTokenApi.spec.ts b/packages/dashboard-backend/src/devworkspaceClient/services/__tests__/deviceAuthTokenApi.spec.ts index c14a862878..426ceae745 100644 --- a/packages/dashboard-backend/src/devworkspaceClient/services/__tests__/deviceAuthTokenApi.spec.ts +++ b/packages/dashboard-backend/src/devworkspaceClient/services/__tests__/deviceAuthTokenApi.spec.ts @@ -181,31 +181,47 @@ describe('DeviceAuthToken API Service', () => { }); describe('revocation behavior', () => { - const origClientId = process.env.CHE_GITHUB_OAUTH_CLIENT_ID; - afterEach(() => { - process.env.CHE_GITHUB_OAUTH_CLIENT_ID = origClientId; - }); - - it('should still delete the K8s secret when GitHub token revocation throws', async () => { - process.env.CHE_GITHUB_OAUTH_CLIENT_ID = 'test-client-id'; + const rawToken = 'ghp_test_token'; + const encodedToken = Buffer.from(rawToken).toString('base64'); - // Secret has a token in data field (base64 encoded) - spyReadNamespacedSecret.mockResolvedValueOnce({ + beforeEach(() => { + spyReadNamespacedSecret.mockResolvedValue({ metadata: { name: tokenName, resourceVersion, labels: { [DEVICE_AUTH_LABEL]: 'true' }, }, - data: { token: Buffer.from('ghp_test_token').toString('base64') }, + data: { token: encodedToken }, } as V1Secret); + }); - // fetch (revocation call) throws a network error + it('should still delete the K8s secret when GitHub token revocation throws', async () => { mockFetch.mockRejectedValueOnce(new Error('Network error')); // Should NOT throw — deleteNamespacedSecret must still be called await expect(service.deleteToken(namespace, tokenName)).resolves.toBeUndefined(); expect(spyDeleteNamespacedSecret).toHaveBeenCalled(); }); + + it('should call GitHub revoke API with correct URL, headers, and body on success', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }); + + await service.deleteToken(namespace, tokenName); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.github.com/credentials/revoke', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + Authorization: `Bearer ${rawToken}`, + 'Content-Type': 'application/json', + 'X-GitHub-Api-Version': '2022-11-28', + }), + body: JSON.stringify({ credentials: [rawToken] }), + }), + ); + expect(spyDeleteNamespacedSecret).toHaveBeenCalled(); + }); }); // revocation behavior }); @@ -317,7 +333,19 @@ describe('DeviceAuthToken API Service', () => { expect((result as { status: 'authorized'; token: api.DeviceAuthToken }).token.provider).toBe( 'github', ); - expect(stubCoreV1Api.createNamespacedSecret).toHaveBeenCalled(); + expect(stubCoreV1Api.createNamespacedSecret).toHaveBeenCalledWith({ + namespace, + body: expect.objectContaining({ + metadata: expect.objectContaining({ + name: 'device-authentication-github', + labels: { + [DEVICE_AUTH_LABEL]: 'true', + [DEVICE_AUTH_PROVIDER_LABEL]: 'github', + }, + }), + data: { token: Buffer.from('ghp_token123').toString('base64') }, + }), + }); expect(stubCoreV1Api.replaceNamespacedSecret).not.toHaveBeenCalled(); }); diff --git a/packages/dashboard-backend/src/routes/api/__tests__/deviceAuthToken.spec.ts b/packages/dashboard-backend/src/routes/api/__tests__/deviceAuthToken.spec.ts index b2b148e64d..482a9eff1f 100644 --- a/packages/dashboard-backend/src/routes/api/__tests__/deviceAuthToken.spec.ts +++ b/packages/dashboard-backend/src/routes/api/__tests__/deviceAuthToken.spec.ts @@ -24,6 +24,7 @@ jest.mock('@/routes/api/helpers/getDeviceAuthClientId', () => ({ })); const namespace = 'user-che'; +const authHeader = { authorization: 'Bearer test-token' }; describe('Device Auth Token Routes', () => { let app: FastifyInstance; @@ -46,6 +47,7 @@ describe('Device Auth Token Routes', () => { const res = await app.inject({ method: 'POST', url: `${baseApiPath}/namespace/${namespace}/device-auth-token/initiate`, + headers: authHeader, }); expect(res.statusCode).toBe(503); }); @@ -57,6 +59,7 @@ describe('Device Auth Token Routes', () => { const res = await app.inject({ method: 'POST', url: `${baseApiPath}/namespace/${namespace}/device-auth-token/initiate`, + headers: authHeader, }); expect(res.statusCode).not.toBe(503); }); @@ -68,6 +71,7 @@ describe('Device Auth Token Routes', () => { const res = await app.inject({ method: 'POST', url: `${baseApiPath}/namespace/${namespace}/device-auth-token/poll`, + headers: authHeader, payload: { deviceCode: 'ABCD-1234' }, }); expect(res.statusCode).toBe(503); @@ -80,6 +84,7 @@ describe('Device Auth Token Routes', () => { const res = await app.inject({ method: 'POST', url: `${baseApiPath}/namespace/${namespace}/device-auth-token/poll`, + headers: authHeader, payload: { deviceCode: 'ABCD-1234' }, }); expect(res.statusCode).not.toBe(503); diff --git a/packages/dashboard-backend/src/routes/api/deviceAuthToken.ts b/packages/dashboard-backend/src/routes/api/deviceAuthToken.ts index 3f5d4997ad..c233742e2b 100644 --- a/packages/dashboard-backend/src/routes/api/deviceAuthToken.ts +++ b/packages/dashboard-backend/src/routes/api/deviceAuthToken.ts @@ -92,13 +92,13 @@ export function registerDeviceAuthTokenRoutes(instance: FastifyInstance) { server.post( `${baseApiPath}/namespace/:namespace/device-auth-token/initiate`, Object.assign({}, rateLimitConfig, getSchema({ tags, params: namespacedSchema })), - async function (request: FastifyRequest, reply: FastifyReply) { + async function (request: FastifyRequest) { + const { namespace } = request.params as restParams.INamespacedParams; + const token = getToken(request); const clientId = await getDeviceAuthClientId(); if (!clientId) { - return reply.code(503).send({ message: DEVICE_AUTH_NOT_CONFIGURED_MSG }); + throw Object.assign(new Error(DEVICE_AUTH_NOT_CONFIGURED_MSG), { statusCode: 503 }); } - const { namespace } = request.params as restParams.INamespacedParams; - const token = getToken(request); const { deviceAuthTokenApi } = getDevWorkspaceClient(token); return deviceAuthTokenApi.initiateDeviceAuth(namespace, clientId); }, @@ -116,14 +116,14 @@ export function registerDeviceAuthTokenRoutes(instance: FastifyInstance) { rateLimitConfig, getSchema({ tags, params: namespacedSchema, body: deviceAuthPollBodySchema }), ), - async function (request: FastifyRequest, reply: FastifyReply) { - const clientId = await getDeviceAuthClientId(); - if (!clientId) { - return reply.code(503).send({ message: DEVICE_AUTH_NOT_CONFIGURED_MSG }); - } + async function (request: FastifyRequest) { const { namespace } = request.params as restParams.INamespacedParams; const { deviceCode } = request.body as { deviceCode: string }; const token = getToken(request); + const clientId = await getDeviceAuthClientId(); + if (!clientId) { + throw Object.assign(new Error(DEVICE_AUTH_NOT_CONFIGURED_MSG), { statusCode: 503 }); + } const { deviceAuthTokenApi } = getDevWorkspaceClient(token); return deviceAuthTokenApi.pollDeviceAuth(namespace, deviceCode, clientId); }, diff --git a/packages/dashboard-backend/src/routes/api/helpers/__tests__/getDeviceAuthClientId.spec.ts b/packages/dashboard-backend/src/routes/api/helpers/__tests__/getDeviceAuthClientId.spec.ts index de54b40bea..c5deff101a 100644 --- a/packages/dashboard-backend/src/routes/api/helpers/__tests__/getDeviceAuthClientId.spec.ts +++ b/packages/dashboard-backend/src/routes/api/helpers/__tests__/getDeviceAuthClientId.spec.ts @@ -10,6 +10,11 @@ * Red Hat, Inc. - initial API and implementation */ +import { + _resetCacheForTesting, + getDeviceAuthClientId, +} from '@/routes/api/helpers/getDeviceAuthClientId'; + const mockReadNamespacedConfigMap = jest.fn(); jest.mock('@/routes/api/helpers/getServiceAccountToken', () => ({ @@ -32,7 +37,7 @@ describe('getDeviceAuthClientId', () => { const origEnv = { ...process.env }; beforeEach(() => { - jest.resetModules(); + _resetCacheForTesting(); process.env = { ...origEnv }; delete process.env.DEVICE_AUTH_GITHUB_CLIENT_ID; process.env.CHECLUSTER_CR_NAMESPACE = 'eclipse-che'; @@ -47,60 +52,30 @@ describe('getDeviceAuthClientId', () => { mockReadNamespacedConfigMap.mockResolvedValueOnce({ data: { github_client_id: '01ab8ac9400c4e429b23' }, }); - let result: string | null; - jest.isolateModules(() => { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const { getDeviceAuthClientId } = require('@/routes/api/helpers/getDeviceAuthClientId'); - result = getDeviceAuthClientId(); - }); - await expect(result!).resolves.toBe('01ab8ac9400c4e429b23'); + await expect(getDeviceAuthClientId()).resolves.toBe('01ab8ac9400c4e429b23'); }); it('returns null when ConfigMap key is absent', async () => { mockReadNamespacedConfigMap.mockResolvedValueOnce({ data: {} }); - let result: Promise; - jest.isolateModules(() => { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const { getDeviceAuthClientId } = require('@/routes/api/helpers/getDeviceAuthClientId'); - result = getDeviceAuthClientId(); - }); - await expect(result!).resolves.toBeNull(); + await expect(getDeviceAuthClientId()).resolves.toBeNull(); }); it('returns null when ConfigMap does not exist', async () => { mockReadNamespacedConfigMap.mockRejectedValueOnce( Object.assign(new Error('Not Found'), { code: 404 }), ); - let result: Promise; - jest.isolateModules(() => { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const { getDeviceAuthClientId } = require('@/routes/api/helpers/getDeviceAuthClientId'); - result = getDeviceAuthClientId(); - }); - await expect(result!).resolves.toBeNull(); + await expect(getDeviceAuthClientId()).resolves.toBeNull(); }); it('returns value from DEVICE_AUTH_GITHUB_CLIENT_ID env var without hitting K8s', async () => { process.env.DEVICE_AUTH_GITHUB_CLIENT_ID = 'local-override-id'; - let result: Promise; - jest.isolateModules(() => { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const { getDeviceAuthClientId } = require('@/routes/api/helpers/getDeviceAuthClientId'); - result = getDeviceAuthClientId(); - }); - await expect(result!).resolves.toBe('local-override-id'); + await expect(getDeviceAuthClientId()).resolves.toBe('local-override-id'); expect(mockReadNamespacedConfigMap).not.toHaveBeenCalled(); }); it('returns null when CHECLUSTER_CR_NAMESPACE is not set', async () => { delete process.env.CHECLUSTER_CR_NAMESPACE; - let result: Promise; - jest.isolateModules(() => { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const { getDeviceAuthClientId } = require('@/routes/api/helpers/getDeviceAuthClientId'); - result = getDeviceAuthClientId(); - }); - await expect(result!).resolves.toBeNull(); + await expect(getDeviceAuthClientId()).resolves.toBeNull(); expect(mockReadNamespacedConfigMap).not.toHaveBeenCalled(); }); @@ -108,13 +83,8 @@ describe('getDeviceAuthClientId', () => { mockReadNamespacedConfigMap.mockResolvedValue({ data: { github_client_id: 'cached-id' }, }); - let getDeviceAuthClientId: () => Promise; - jest.isolateModules(() => { - // eslint-disable-next-line @typescript-eslint/no-require-imports - ({ getDeviceAuthClientId } = require('@/routes/api/helpers/getDeviceAuthClientId')); - }); - await getDeviceAuthClientId!(); - await getDeviceAuthClientId!(); + await getDeviceAuthClientId(); + await getDeviceAuthClientId(); expect(mockReadNamespacedConfigMap).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/dashboard-backend/src/routes/api/helpers/getDeviceAuthClientId.ts b/packages/dashboard-backend/src/routes/api/helpers/getDeviceAuthClientId.ts index 6b45a6fd6a..56ac453ebc 100644 --- a/packages/dashboard-backend/src/routes/api/helpers/getDeviceAuthClientId.ts +++ b/packages/dashboard-backend/src/routes/api/helpers/getDeviceAuthClientId.ts @@ -20,6 +20,11 @@ const CACHE_TTL_MS = 90_000; let cache: { value: string | null; expiresAt: number } | null = null; +/** Resets the TTL cache. For testing only. */ +export function _resetCacheForTesting(): void { + cache = null; +} + export async function getDeviceAuthClientId(): Promise { if (cache && Date.now() < cache.expiresAt) { return cache.value; diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/List/index.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/List/index.tsx index 6a672570ae..292f4154a4 100644 --- a/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/List/index.tsx +++ b/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/List/index.tsx @@ -124,7 +124,9 @@ export class DeviceAuthTokensList extends React.PureComponent { }} > - {token.provider ?? 'GitHub'} + {token.provider != null + ? token.provider.charAt(0).toUpperCase() + token.provider.slice(1) + : 'GitHub'} {token.valid === 'valid' && ( Token is valid}> Date: Tue, 25 Aug 2026 08:46:14 +0300 Subject: [PATCH 9/9] fix(device-auth): drop GitHub token revocation on delete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /credentials/revoke is for reporting exposed credentials found by third parties; it returns 403 when called by the token owner. The correct self-revocation endpoint (DELETE /applications/{client_id}/token) requires the OAuth app client secret, which this service does not hold. Remove the revocation attempt and its test coverage. The GitHub token remains valid until the user revokes it manually from GitHub Settings → Applications → Authorized OAuth Apps. Assisted-by: Claude Sonnet 4.6 Signed-off-by: Oleksii Orel --- .../__tests__/deviceAuthTokenApi.spec.ts | 44 ------------------- .../services/deviceAuthTokenApi.ts | 42 +++--------------- 2 files changed, 7 insertions(+), 79 deletions(-) diff --git a/packages/dashboard-backend/src/devworkspaceClient/services/__tests__/deviceAuthTokenApi.spec.ts b/packages/dashboard-backend/src/devworkspaceClient/services/__tests__/deviceAuthTokenApi.spec.ts index 426ceae745..adf747afd2 100644 --- a/packages/dashboard-backend/src/devworkspaceClient/services/__tests__/deviceAuthTokenApi.spec.ts +++ b/packages/dashboard-backend/src/devworkspaceClient/services/__tests__/deviceAuthTokenApi.spec.ts @@ -179,50 +179,6 @@ describe('DeviceAuthToken API Service', () => { `Unable to delete Device Authentication token "${tokenName}" in the namespace "${namespace}"`, ); }); - - describe('revocation behavior', () => { - const rawToken = 'ghp_test_token'; - const encodedToken = Buffer.from(rawToken).toString('base64'); - - beforeEach(() => { - spyReadNamespacedSecret.mockResolvedValue({ - metadata: { - name: tokenName, - resourceVersion, - labels: { [DEVICE_AUTH_LABEL]: 'true' }, - }, - data: { token: encodedToken }, - } as V1Secret); - }); - - it('should still delete the K8s secret when GitHub token revocation throws', async () => { - mockFetch.mockRejectedValueOnce(new Error('Network error')); - - // Should NOT throw — deleteNamespacedSecret must still be called - await expect(service.deleteToken(namespace, tokenName)).resolves.toBeUndefined(); - expect(spyDeleteNamespacedSecret).toHaveBeenCalled(); - }); - - it('should call GitHub revoke API with correct URL, headers, and body on success', async () => { - mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }); - - await service.deleteToken(namespace, tokenName); - - expect(mockFetch).toHaveBeenCalledWith( - 'https://api.github.com/credentials/revoke', - expect.objectContaining({ - method: 'POST', - headers: expect.objectContaining({ - Authorization: `Bearer ${rawToken}`, - 'Content-Type': 'application/json', - 'X-GitHub-Api-Version': '2022-11-28', - }), - body: JSON.stringify({ credentials: [rawToken] }), - }), - ); - expect(spyDeleteNamespacedSecret).toHaveBeenCalled(); - }); - }); // revocation behavior }); describe('initiateDeviceAuth', () => { diff --git a/packages/dashboard-backend/src/devworkspaceClient/services/deviceAuthTokenApi.ts b/packages/dashboard-backend/src/devworkspaceClient/services/deviceAuthTokenApi.ts index df87c4fcbc..641f4c2c29 100644 --- a/packages/dashboard-backend/src/devworkspaceClient/services/deviceAuthTokenApi.ts +++ b/packages/dashboard-backend/src/devworkspaceClient/services/deviceAuthTokenApi.ts @@ -152,41 +152,13 @@ export class GitHubDeviceAuthTokenApiService implements IDeviceAuthTokenApi { ); } - // Best-effort GitHub token revocation via POST /credentials/revoke. - // Authorization: Bearer is required — the endpoint authenticates via - // the token being revoked rather than an OAuth app client secret. - // The token owner receives a GitHub notification email upon revocation. - // See: https://docs.github.com/en/rest/credentials/revoke - const rawToken = Buffer.from(secret.data?.['token'] ?? '', 'base64').toString('utf-8'); - if (rawToken) { - try { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), GITHUB_API_TIMEOUT_MS); - try { - const revokeResponse = await fetch('https://api.github.com/credentials/revoke', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-GitHub-Api-Version': '2022-11-28', - Authorization: `Bearer ${rawToken}`, - }, - body: JSON.stringify({ credentials: [rawToken] }), - signal: controller.signal, - }); - if (!revokeResponse.ok && revokeResponse.status !== 202) { - console.warn( - `[device-auth] GitHub token revocation failed (HTTP ${revokeResponse.status}).`, - ); - } - } finally { - clearTimeout(timer); - } - } catch (e) { - console.warn( - `[device-auth] GitHub token revocation error: ${e instanceof Error ? e.message : String(e)}`, - ); - } - } + // Note: GitHub's DELETE /applications/{client_id}/token endpoint (the only + // supported self-revocation API) requires the OAuth app client secret, which + // this backend service does not hold. The POST /credentials/revoke endpoint + // is for reporting credentials found exposed by third parties and returns 403 + // when called by the token owner. We therefore only remove the local K8s + // secret; the GitHub token remains valid until the user revokes it from + // GitHub Settings → Applications → Authorized OAuth Apps. try { await this.coreV1API.deleteNamespacedSecret({