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/common/src/dto/api/index.ts b/packages/common/src/dto/api/index.ts index 22ec7a7e1c..b8ae84de03 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; + /** Token validity from a separate validate call. 'unknown' means check could not complete. */ + valid?: 'valid' | 'invalid' | 'unknown'; +}; + +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: GitHubDeviceAuthTokenApiService; + + 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 GitHubDeviceAuthTokenApiService(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}"`, + ); + }); + }); + + describe('initiateDeviceAuth', () => { + afterEach(() => { + 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(namespace, 'test-client-id'); + + expect(result).toEqual({ + deviceCode: 'dev-code-123', + userCode: 'ABCD-1234', + verificationUri: 'https://github.com/login/device', + interval: 5, + }); + }); + + 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, 'test-client-id')).rejects.toThrow( + 'Bad client', + ); + }); + }); + + describe('pollDeviceAuth', () => { + beforeEach(async () => { + stubCoreV1Api.createNamespacedSecret = jest.fn().mockResolvedValue({ + metadata: { + name: 'device-authentication-github', + creationTimestamp: new Date('2024-01-01'), + }, + }); + stubCoreV1Api.replaceNamespacedSecret = jest.fn().mockResolvedValue({ + metadata: { + name: 'device-authentication-github', + creationTimestamp: new Date('2024-01-01'), + }, + }); + // Seed active code cache so pollDeviceAuth accepts 'dev-code-123' + 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, + expires_in: 900, + }), + }); + await service.initiateDeviceAuth(namespace, 'test-client-id'); + }); + afterEach(() => { + 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', 'test-client-id'); + 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', 'test-client-id'); + 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', 'test-client-id'); + 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', 'test-client-id'); + expect(result.status).toBe('authorized'); + expect((result as { status: 'authorized'; token: api.DeviceAuthToken }).token.provider).toBe( + 'github', + ); + 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(); + }); + + it('should replace existing K8s secret when reconnecting', async () => { + // Seed a second code for this reconnect scenario + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + device_code: 'dev-code-456', + user_code: 'EFGH-5678', + verification_uri: 'https://github.com/login/device', + interval: 5, + expires_in: 900, + }), + }); + await service.initiateDeviceAuth(namespace, 'test-client-id'); + const existingName = 'device-authentication-github'; + spyListNamespacedSecret.mockResolvedValueOnce({ + items: [{ metadata: { name: existingName } }], + } as V1SecretList); + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ access_token: 'ghp_new_token', token_type: 'bearer', scope: 'repo' }), + }); + + 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( + existingName, + ); + expect(stubCoreV1Api.replaceNamespacedSecret).toHaveBeenCalledWith( + expect.objectContaining({ name: existingName, namespace }), + ); + expect(stubCoreV1Api.createNamespacedSecret).not.toHaveBeenCalled(); + }); + + 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', 'test-client-id'); + 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', 'test-client-id'); + expect(result).toEqual({ status: 'error', message: 'No access_token in response' }); + }); + }); + + describe('validateToken', () => { + const origClientId = process.env.CHE_GITHUB_OAUTH_CLIENT_ID; + afterEach(() => { + process.env.CHE_GITHUB_OAUTH_CLIENT_ID = origClientId; + jest.clearAllMocks(); + }); + + it('should return unknown when secret is not found', async () => { + spyReadNamespacedSecret.mockRejectedValueOnce(new Error('Not found')); + const result = await service.validateToken(namespace, tokenName); + expect(result).toBe('unknown'); + }); + + it('should return unknown when secret does not carry the device-auth label', async () => { + spyReadNamespacedSecret.mockResolvedValueOnce({ + metadata: { name: tokenName, labels: {} }, + data: { token: Buffer.from('ghp_test').toString('base64') }, + } as V1Secret); + const result = await service.validateToken(namespace, tokenName); + expect(result).toBe('unknown'); + }); + + it('should return unknown when token data is empty', async () => { + spyReadNamespacedSecret.mockResolvedValueOnce({ + metadata: { name: tokenName, labels: { [DEVICE_AUTH_LABEL]: 'true' } }, + data: {}, + } as V1Secret); + const result = await service.validateToken(namespace, tokenName); + expect(result).toBe('unknown'); + }); + + it('should return valid when GitHub responds with 200', async () => { + spyReadNamespacedSecret.mockResolvedValueOnce({ + metadata: { name: tokenName, labels: { [DEVICE_AUTH_LABEL]: 'true' } }, + data: { token: Buffer.from('ghp_test_token').toString('base64') }, + } as V1Secret); + mockFetch.mockResolvedValueOnce({ ok: true }); + const result = await service.validateToken(namespace, tokenName); + expect(result).toBe('valid'); + }); + + it('should return invalid when GitHub responds with non-200', async () => { + spyReadNamespacedSecret.mockResolvedValueOnce({ + metadata: { name: tokenName, labels: { [DEVICE_AUTH_LABEL]: 'true' } }, + data: { token: Buffer.from('ghp_revoked_token').toString('base64') }, + } as V1Secret); + mockFetch.mockResolvedValueOnce({ ok: false, status: 401 }); + const result = await service.validateToken(namespace, tokenName); + expect(result).toBe('invalid'); + }); + + it('should return unknown when GitHub API throws (network error)', async () => { + spyReadNamespacedSecret.mockResolvedValueOnce({ + metadata: { name: tokenName, labels: { [DEVICE_AUTH_LABEL]: 'true' } }, + data: { token: Buffer.from('ghp_test_token').toString('base64') }, + } as V1Secret); + mockFetch.mockRejectedValueOnce(new Error('Network error')); + const result = await service.validateToken(namespace, tokenName); + expect(result).toBe('unknown'); + }); + }); +}); 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..641f4c2c29 --- /dev/null +++ b/packages/dashboard-backend/src/devworkspaceClient/services/deviceAuthTokenApi.ts @@ -0,0 +1,340 @@ +/* + * 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 { 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 DEVICE_AUTH_SECRET_NAME = 'device-authentication-github'; + +const GITHUB_SCOPES = 'read:user repo user:email workflow'; +const GITHUB_API_TIMEOUT_MS = 30_000; + +// Binds an in-flight device code to the namespace that initiated the flow. +// Prevents a different authenticated user from polling with another user's device code. +// TTL matches GitHub's device code expiry (~15 min); cleared on successful auth. +const activeDeviceCodes = new Map(); + +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; +} + +async function githubPostDeviceCode( + params: Record, +): Promise { + 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". + 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); + } +} + +async function githubPostToken(params: Record): Promise { + 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.) + try { + const data: unknown = await response.json(); + return data as GitHubTokenResponse; + } catch { + throw new Error(`GitHub API returned HTTP ${response.status} with non-JSON body`); + } + } finally { + clearTimeout(timer); + } +} + +// GitHub-specific implementation. Only GitHub OAuth App device flow is supported. +export class GitHubDeviceAuthTokenApiService 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, + }); + 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); + } + } + + 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 Object.assign( + new Error(`Secret "${tokenName}" does not carry the ${DEVICE_AUTH_LABEL_SELECTOR} label`), + { statusCode: 403 }, + ); + } + + // 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({ + name: tokenName, + namespace, + body: { preconditions: { resourceVersion: secret.metadata?.resourceVersion } }, + }); + } catch (error) { + throw createError(error, API_ERROR_LABEL, additionalMessage); + } + } + + 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, + }; + } + + 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 }; + } + + async validateToken( + namespace: string, + tokenName: string, + ): Promise<'valid' | 'invalid' | 'unknown'> { + let secret: k8s.V1Secret; + try { + secret = await this.coreV1API.readNamespacedSecret({ name: tokenName, namespace }); + } catch { + return 'unknown'; // secret not found or K8s API error + } + if (secret.metadata?.labels?.[DEVICE_AUTH_LABEL] !== 'true') { + return 'unknown'; // not a device-auth secret + } + const rawToken = Buffer.from(secret.data?.['token'] ?? '', 'base64').toString('utf-8'); + if (!rawToken) { + return 'unknown'; // empty token field + } + 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 ? 'valid' : 'invalid'; + } catch { + return 'unknown'; // network error or timeout + } finally { + clearTimeout(timer); + } + } + + private async createDeviceAuthSecret( + namespace: string, + accessToken: string, + ): Promise { + 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 existingMeta = existing.items[0].metadata; + try { + const updated = await this.coreV1API.replaceNamespacedSecret({ + name: existingName, + namespace, + body: { + metadata: { + name: existingName, + namespace, + resourceVersion: existingMeta.resourceVersion, + labels: { + ...(existingMeta.labels ?? {}), + [DEVICE_AUTH_LABEL]: 'true', + [DEVICE_AUTH_PROVIDER_LABEL]: 'github', + }, + }, + data: { token: tokenData }, + }, + }); + return { + name: existingName, + provider: 'github', + creationTimestamp: updated.metadata?.creationTimestamp?.toISOString(), + }; + } catch { + // Secret was deleted between list and replace (TOCTOU) — fall through to create + } + } + + // Use a deterministic name so concurrent poll completions (e.g. two browser + // tabs) fail with a 409 Conflict on the second create rather than silently + // producing two separate secrets. + const name = DEVICE_AUTH_SECRET_NAME; + const created = await this.coreV1API.createNamespacedSecret({ + namespace, + body: { + metadata: { + name, + namespace, + labels: { + [DEVICE_AUTH_LABEL]: 'true', + [DEVICE_AUTH_PROVIDER_LABEL]: 'github', + }, + }, + data: { token: tokenData }, + }, + }); + 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..fcdc916108 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,38 @@ 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(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'>; +} + 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..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; @@ -47,6 +48,7 @@ describe('Cluster Config Route', () => { runningWorkspacesLimit: stubRunningWorkspacesLimit, allWorkspacesLimit: stubAllWorkspacesLimit, currentArchitecture: stubCurrentArchitecture, + githubDeviceAuthEnabled: false, }); }); }); 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..482a9eff1f --- /dev/null +++ b/packages/dashboard-backend/src/routes/api/__tests__/deviceAuthToken.spec.ts @@ -0,0 +1,93 @@ +/* + * 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'; +const authHeader = { authorization: 'Bearer test-token' }; + +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`, + headers: authHeader, + }); + 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`, + headers: authHeader, + }); + 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`, + headers: authHeader, + 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`, + headers: authHeader, + 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 fea934eb8c..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'; @@ -38,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, @@ -45,5 +47,6 @@ async function buildClusterConfig(): Promise { allWorkspacesLimit, runningWorkspacesLimit, currentArchitecture, + githubDeviceAuthEnabled: clientId !== null, }; } 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..c233742e2b --- /dev/null +++ b/packages/dashboard-backend/src/routes/api/deviceAuthToken.ts @@ -0,0 +1,159 @@ +/* + * 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, + deviceAuthTokenResponseSchema, + deviceAuthValidateResponseSchema, + 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'; + +const tags = ['Device Auth Token']; +const rateLimitConfig = { + config: { + rateLimit: { + max: 100, + timeWindow: '1 minute', + }, + }, +}; + +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 => { + /** + * 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, + response: { 200: deviceAuthTokenResponseSchema }, + }), + ), + 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. 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) { + const { namespace } = request.params as restParams.INamespacedParams; + 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.initiateDeviceAuth(namespace, clientId); + }, + ); + + /** + * 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`, + 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 }; + 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); + }, + ); + + /** + * 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, + response: { 200: deviceAuthValidateResponseSchema }, + }), + ), + 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 }; + }, + ); + }); +} 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..c5deff101a --- /dev/null +++ b/packages/dashboard-backend/src/routes/api/helpers/__tests__/getDeviceAuthClientId.spec.ts @@ -0,0 +1,90 @@ +/* + * 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 { + _resetCacheForTesting, + getDeviceAuthClientId, +} from '@/routes/api/helpers/getDeviceAuthClientId'; + +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(() => { + _resetCacheForTesting(); + 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' }, + }); + await expect(getDeviceAuthClientId()).resolves.toBe('01ab8ac9400c4e429b23'); + }); + + it('returns null when ConfigMap key is absent', async () => { + mockReadNamespacedConfigMap.mockResolvedValueOnce({ data: {} }); + await expect(getDeviceAuthClientId()).resolves.toBeNull(); + }); + + it('returns null when ConfigMap does not exist', async () => { + mockReadNamespacedConfigMap.mockRejectedValueOnce( + Object.assign(new Error('Not Found'), { code: 404 }), + ); + 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'; + 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; + await expect(getDeviceAuthClientId()).resolves.toBeNull(); + expect(mockReadNamespacedConfigMap).not.toHaveBeenCalled(); + }); + + it('caches the result for subsequent calls', async () => { + mockReadNamespacedConfigMap.mockResolvedValue({ + data: { github_client_id: 'cached-id' }, + }); + 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..56ac453ebc --- /dev/null +++ b/packages/dashboard-backend/src/routes/api/helpers/getDeviceAuthClientId.ts @@ -0,0 +1,62 @@ +/* + * 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; + +/** 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; + } + + 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/__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..5188e8ea6c --- /dev/null +++ b/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/ConnectModal/__tests__/index.spec.tsx @@ -0,0 +1,147 @@ +/* + * 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, { + 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'; + +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(); + }); + + 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); + await waitFor(() => screen.getByTestId('user-code')); + // advance by original 5s interval + jest.advanceTimersByTime(5000); + await waitFor(() => expect(pollDeviceAuth).toHaveBeenCalled()); + const callCount = (pollDeviceAuth as jest.Mock).mock.calls.length; + // advance by 5s (original) — should NOT trigger yet (interval is now 10s) + jest.advanceTimersByTime(5000); + await Promise.resolve(); // flush microtasks + // advance remaining 5s to complete the 10s interval + jest.advanceTimersByTime(5000); + await waitFor(() => + expect((pollDeviceAuth as jest.Mock).mock.calls.length).toBeGreaterThan(callCount), + ); + }); +}); 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..d98f848683 --- /dev/null +++ b/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/ConnectModal/index.tsx @@ -0,0 +1,282 @@ +/* + * 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 MAX_POLL_ERRORS = 15; + +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; + pollErrorCount: number; +}; + +class ConnectModalClass extends React.PureComponent { + private pollTimer: ReturnType | undefined; + private _isMounted = false; + + constructor(props: Props) { + super(props); + this.state = { + deviceCode: undefined, + error: undefined, + isLoading: false, + copyTimerId: undefined, + pollErrorCount: 0, + }; + } + + componentDidMount(): void { + this._isMounted = true; + if (this.props.isOpen) { + this.initiateAuth(); + } + } + + componentDidUpdate(prevProps: Props): void { + if (this.props.isOpen && !prevProps.isOpen) { + void this.initiateAuth(); + } + if (!this.props.isOpen && prevProps.isOpen) { + this.stopPolling(); + } + } + + componentWillUnmount(): void { + this._isMounted = false; + this.stopPolling(); + } + + private async initiateAuth(): Promise { + if (this.state.isLoading) { + return; + } + this.setState({ deviceCode: undefined, error: undefined, isLoading: true, pollErrorCount: 0 }); + 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 { + // pollDeviceAuth is called directly (not through a Redux thunk) to avoid + // storing high-frequency polling results in Redux state. Network/session + // errors are caught below and retried via the next scheduled poll. + result = await pollDeviceAuth(namespace, response.deviceCode); + } catch { + if (!this._isMounted) { + return; + } + const nextCount = this.state.pollErrorCount + 1; + this.setState({ pollErrorCount: nextCount }); + if (nextCount >= MAX_POLL_ERRORS) { + this.setState({ error: 'Unable to reach the server. Please close and try again.' }); + return; + } + this.schedulePoll(response); + return; + } + if (!this._isMounted) { + 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; 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); + } 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} + + )} + {this.state.pollErrorCount >= 3 && !error && ( + + Having trouble reaching the server. Still trying… + + )} + {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 && ( + window.open(deviceCode.verificationUri, '_blank')} + > + + + )} + + +
+ ); + } +} + +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..ce76f02a00 --- /dev/null +++ b/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/DeleteModal/index.tsx @@ -0,0 +1,125 @@ +/* + * 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 and attempts to revoke the GitHub authorization. If revocation fails, + you can also manually revoke at{' '} + + github.com/settings/applications + + . + + ) : ( + + Are you sure you want to delete {count} Device Authentication Tokens? + 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 + + . + + ); + + 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..565a484c6d --- /dev/null +++ b/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/List/__mocks__/index.tsx @@ -0,0 +1,41 @@ +/* + * 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, onConnect, isConnectEnabled } = this.props; + + const entries = tokens.map(token => ( +
+ {token.name} + +
+ )); + + 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 new file mode 100644 index 0000000000..6903a0bc77 --- /dev/null +++ b/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/List/__tests__/index.spec.tsx @@ -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 } 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 mockOnConnect = jest.fn(); + +const { renderComponent } = getComponentRenderer( + ({ tokens, isDisabled }: { tokens: api.DeviceAuthToken[]; isDisabled?: boolean }) => ( + + ), +); + +describe('DeviceAuthTokensList', () => { + afterEach(() => { + 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(); + 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(); + }); +}); 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..292f4154a4 --- /dev/null +++ b/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/List/index.tsx @@ -0,0 +1,172 @@ +/* + * 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, + QuestionCircleIcon, +} from '@patternfly/react-icons'; +import React from 'react'; + +import { CheTooltip } from '@/components/CheTooltip'; +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 = { + openDropdown: string | undefined; +}; + +export class DeviceAuthTokensList extends React.PureComponent { + constructor(props: Props) { + super(props); + this.state = { openDropdown: undefined }; + } + + render(): React.ReactElement { + const { tokens, isDisabled, isConnectEnabled, 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' }} + > + + {isConnectEnabled && ( + { + this.setState({ openDropdown: undefined }); + this.props.onConnect(); + }} + data-testid="reconnect-token-action" + > + Reconnect + + )} + { + this.setState({ openDropdown: undefined }); + onDeleteTokens([token]); + }} + data-testid="delete-token-action" + > + Delete + + + + ), + }} + > + + {token.provider != null + ? token.provider.charAt(0).toUpperCase() + token.provider.slice(1) + : 'GitHub'} + {token.valid === 'valid' && ( + Token is valid}> + + + )} + {token.valid === 'invalid' && ( + Token has been revoked or expired}> + + + )} + {token.valid === 'unknown' && ( + Token validity could not be determined}> + + + )} + + + + + + {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..9a6dc5bede --- /dev/null +++ b/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/__tests__/index.spec.tsx @@ -0,0 +1,265 @@ +/* + * 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'); + +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; + let consoleErrorSpy: jest.SpyInstance; + + beforeEach(() => { + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + 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(() => { + consoleErrorSpy.mockRestore(); + 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..d59dc05643 --- /dev/null +++ b/packages/dashboard-frontend/src/pages/UserPreferences/DeviceAuthTokens/index.tsx @@ -0,0 +1,263 @@ +/* + * 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 { validateDeviceAuthToken } from '@/services/backend-client/deviceAuthTokenApi'; +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; + validatedTokens: Record; +}; + +class DeviceAuthTokens extends React.PureComponent { + @lazyInject(AppAlerts) + private readonly appAlerts: AppAlerts; + + private _isMounted = false; + + constructor(props: Props) { + super(props); + this.state = { + isDeleteOpen: false, + deletingTokens: [], + isConnectOpen: false, + validatedTokens: {}, + }; + } + + public componentDidMount(): void { + this._isMounted = true; + void this._fetchTokens(); + } + + public componentWillUnmount(): void { + this._isMounted = false; + } + + private async _fetchTokens(): Promise { + if (this.props.isLoading) { + return; + } + try { + await this.props.requestDeviceAuthTokens(); + // Validation is triggered from componentDidUpdate once the updated tokens + // prop arrives — reading this.props.tokens here would be stale due to + // React 18 automatic batching deferring the re-render past this point. + } 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, tokens } = this.props; + if (error && error !== prevProps.error) { + this.appAlerts.showAlert({ + key: 'device-auth-token-error', + title: helpers.errors.getMessage(error), + variant: AlertVariant.danger, + }); + } + if (tokens.length > 0 && prevProps.tokens !== tokens) { + this.validateTokensInBackground(); + } + } + + private validateTokensInBackground(): void { + const { tokens, namespace } = this.props; + tokens.forEach(token => { + validateDeviceAuthToken(namespace, token.name) + .then(valid => { + if (!this._isMounted) { + return; + } + this.setState(prev => ({ + validatedTokens: { ...prev.validatedTokens, [token.name]: valid }, + })); + }) + .catch(() => { + /* ignore */ + }); + }); + } + + 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(token: api.DeviceAuthToken): Promise { + this.setState({ isConnectOpen: false }); + this.appAlerts.showAlert({ + key: 'device-auth-token-connected', + title: 'GitHub account connected successfully.', + variant: AlertVariant.success, + }); + // Kick off background validation for the new token immediately, + // so the status icon appears without waiting for the list re-fetch. + const { namespace } = this.props; + validateDeviceAuthToken(namespace, token.name) + .then(valid => { + if (this._isMounted) { + this.setState(prev => ({ + validatedTokens: { ...prev.validatedTokens, [token.name]: valid }, + })); + } + }) + .catch(() => { + /* ignore */ + }); + try { + await this.props.requestDeviceAuthTokens(); + } catch (e) { + this.appAlerts.showAlert({ + key: 'device-auth-token-refresh-failed', + title: 'Token added but the list could not be refreshed. Try navigating away and back.', + variant: AlertVariant.warning, + }); + } + } + + public render(): React.ReactElement { + const { tokens, isLoading, namespace } = this.props; + const { isDeleteOpen, deletingTokens, isConnectOpen, validatedTokens = {} } = this.state; + + const showEmptyState = tokens.length === 0 && !isLoading; + const showList = tokens.length > 0; + + return ( + + + this.handleCloseDeleteModal()} + onDelete={tokens => this.handleDelete(tokens)} + /> + this.handleCloseConnectModal()} + onSuccess={token => this.handleConnectSuccess(token)} + /> + + {showEmptyState && ( + this.handleOpenConnectModal()} + isConnectEnabled={this.props.githubDeviceAuthEnabled} + /> + )} + {showList && ( + ({ + ...t, + ...(validatedTokens[t.name] !== undefined + ? { valid: validatedTokens[t.name] } + : {}), + }))} + isDisabled={isLoading} + isConnectEnabled={this.props.githubDeviceAuthEnabled} + onDeleteTokens={selectedTokens => this.handleShowDeleteModal(selectedTokens)} + onConnect={() => this.handleOpenConnectModal()} + /> + )} + + + ); + } +} + +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..96d21bc5ba 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 Tokens' }); 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..464c910191 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..b655f6734b --- /dev/null +++ b/packages/dashboard-frontend/src/services/backend-client/deviceAuthTokenApi.ts @@ -0,0 +1,85 @@ +/* + * 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)}`); + } +} + +export async function validateDeviceAuthToken( + namespace: string, + tokenName: string, +): 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: 'valid' | 'invalid' | 'unknown' }; + return valid; + } catch { + return 'unknown'; + } +} 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..15f37cfad1 --- /dev/null +++ b/packages/dashboard-frontend/src/store/DeviceAuthToken/actions.ts @@ -0,0 +1,76 @@ +/* + * 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); + // Pass namespace so the backend can bind the device code to this user's namespace (security) + 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,