diff --git a/extension/src/constants/ui.ts b/extension/src/constants/ui.ts index b6ba4d5..c6212bb 100644 --- a/extension/src/constants/ui.ts +++ b/extension/src/constants/ui.ts @@ -39,6 +39,16 @@ export const UI_DEFAULTS = { DISPLAY_MODE: "full" as const, } as const; +export const UI_MESSAGES = { + AUTH: { + LOGIN_ERROR_FALLBACK: "Login failed. Please try again.", + LOGIN_IN_PROGRESS: "Opening GitHub login...", + LOGIN_SUCCESS: "Successfully connected to GitHub!", + LOGOUT_ERROR: "Sign out failed. Please try again.", + LOGOUT_SUCCESS: "Signed out successfully", + }, +} as const; + export const UI_TIMING = { DEBOUNCE_DELAY: 500, POLL_INTERVAL: 200, diff --git a/extension/src/popup.ts b/extension/src/popup.ts index 726cb74..0e1880a 100644 --- a/extension/src/popup.ts +++ b/extension/src/popup.ts @@ -1,14 +1,16 @@ -import { API } from "./constants/api"; import { STORAGE_KEYS } from "./constants/storage"; -import { CSS_CLASSES, ELEMENT_IDS, STATUS_ICONS, UI_TIMING } from "./constants/ui"; +import { CSS_CLASSES, ELEMENT_IDS, STATUS_ICONS, UI_MESSAGES, UI_TIMING } from "./constants/ui"; +import { + clearTokens, + exchangeCodeForTokens, + generateState, + initiateOAuth, + isAuthenticated, + storeTokens, +} from "./services/auth.service"; import { DisplayMode, StatusType } from "./shared/types"; (() => { - type CallbackResponse = { - access_token: string; - refresh_token: string; - }; - type UIElements = { displayModeSelect: HTMLSelectElement; loggedInSection: HTMLElement; @@ -23,12 +25,6 @@ import { DisplayMode, StatusType } from "./shared/types"; let statusHideTimer: ReturnType | null = null; - const generateState = (): string => { - const array = new Uint8Array(32); - crypto.getRandomValues(array); - return Array.from(array, (byte) => byte.toString(16).padStart(2, "0")).join(""); - }; - const getUIElements = (): UIElements => { const getElement = (id: string): T => { const element = document.getElementById(id); @@ -89,14 +85,6 @@ import { DisplayMode, StatusType } from "./shared/types"; } }; - const checkAuthStatus = async (): Promise => { - const result = await chrome.storage.session.get([ - STORAGE_KEYS.ACCESS_TOKEN, - STORAGE_KEYS.REFRESH_TOKEN, - ]); - return !!(result[STORAGE_KEYS.ACCESS_TOKEN] && result[STORAGE_KEYS.REFRESH_TOKEN]); - }; - const addSuccessPulse = (element: HTMLElement) => { element.classList.add(CSS_CLASSES.POPUP.SUCCESS_PULSE); setTimeout(() => { @@ -106,9 +94,9 @@ import { DisplayMode, StatusType } from "./shared/types"; const updateUI = async (elements: UIElements, showPulse = false) => { const { loggedInSection, loginSection } = elements; - const isLoggedIn = await checkAuthStatus(); + const authenticated = await isAuthenticated(); - if (isLoggedIn) { + if (authenticated) { loginSection.classList.remove(CSS_CLASSES.POPUP.ACTIVE); loggedInSection.classList.add(CSS_CLASSES.POPUP.ACTIVE); @@ -130,62 +118,15 @@ import { DisplayMode, StatusType } from "./shared/types"; setButtonLoading(loginBtn, true); try { - const state = generateState(); - const redirectUri = chrome.identity.getRedirectURL(); - - await chrome.storage.session.set({ [STORAGE_KEYS.OAUTH_STATE]: state }); - - const authUrl = `${API.GITHUB.OAUTH_URL}?client_id=${API.GITHUB.CLIENT_ID}&redirect_uri=${encodeURIComponent( - redirectUri - )}&scope=${API.GITHUB.SCOPE}&state=${state}`; - - showStatus(elements, "Opening GitHub login...", "info"); - - const redirectUrl = await chrome.identity.launchWebAuthFlow({ - interactive: true, - url: authUrl, - }); - - if (!redirectUrl) { - showStatus(elements, "Login cancelled", "error"); - return; - } - - const url = new URL(redirectUrl); - const code = url.searchParams.get("code"); - const returnedState = url.searchParams.get("state"); - - if (!code || !returnedState) { - showStatus(elements, "Invalid OAuth response", "error"); - return; - } - - const { [STORAGE_KEYS.OAUTH_STATE]: storedState } = await chrome.storage.session.get([ - STORAGE_KEYS.OAUTH_STATE, - ]); - - if (returnedState !== storedState) { - showStatus(elements, "Security validation failed", "error"); - return; - } + showStatus(elements, UI_MESSAGES.AUTH.LOGIN_IN_PROGRESS, "info"); - await chrome.storage.session.remove([STORAGE_KEYS.OAUTH_STATE]); - - const callbackUrl = `${API.BASE_URL}/callback?code=${code}&state=${returnedState}`; - const response = await fetch(callbackUrl); - - if (!response.ok) { - throw new Error(`Authentication failed (${response.status}). Please try again.`); - } - - const data: CallbackResponse = await response.json(); + const state = generateState(); + const { code, state: returnedState } = await initiateOAuth(state); + const tokens = await exchangeCodeForTokens({ code, state: returnedState }); - await chrome.storage.session.set({ - [STORAGE_KEYS.ACCESS_TOKEN]: data.access_token, - [STORAGE_KEYS.REFRESH_TOKEN]: data.refresh_token, - }); + await storeTokens({ accessToken: tokens.access_token, refreshToken: tokens.refresh_token }); - showStatus(elements, "Successfully connected to GitHub!", "success"); + showStatus(elements, UI_MESSAGES.AUTH.LOGIN_SUCCESS, "success"); await updateUI(elements, true); const [tab] = await chrome.tabs.query({ @@ -196,7 +137,8 @@ import { DisplayMode, StatusType } from "./shared/types"; chrome.tabs.sendMessage(tab.id, { type: "RELOAD_BADGES" }); } } catch (error) { - const message = error instanceof Error ? error.message : "Login failed. Please try again."; + const message = + error instanceof Error ? error.message : UI_MESSAGES.AUTH.LOGIN_ERROR_FALLBACK; showStatus(elements, message, "error"); } finally { setButtonLoading(loginBtn, false); @@ -209,11 +151,11 @@ import { DisplayMode, StatusType } from "./shared/types"; setButtonLoading(logoutBtn, true); try { - await chrome.storage.session.remove([STORAGE_KEYS.ACCESS_TOKEN, STORAGE_KEYS.REFRESH_TOKEN]); - showStatus(elements, "Signed out successfully", "success"); + await clearTokens(); + showStatus(elements, UI_MESSAGES.AUTH.LOGOUT_SUCCESS, "success"); await updateUI(elements); } catch { - showStatus(elements, "Sign out failed. Please try again.", "error"); + showStatus(elements, UI_MESSAGES.AUTH.LOGOUT_ERROR, "error"); } finally { setButtonLoading(logoutBtn, false); } @@ -227,7 +169,7 @@ import { DisplayMode, StatusType } from "./shared/types"; displayModeSelect.value = mode; }; - const handleDisplayModeChange = async (elements: UIElements, mode: DisplayMode) => { + const handleDisplayModeChange = async (_elements: UIElements, mode: DisplayMode) => { await chrome.storage.sync.set({ [STORAGE_KEYS.DISPLAY_MODE]: mode }); const [tab] = await chrome.tabs.query({ diff --git a/extension/src/services/auth.service.spec.ts b/extension/src/services/auth.service.spec.ts new file mode 100644 index 0000000..5d858c8 --- /dev/null +++ b/extension/src/services/auth.service.spec.ts @@ -0,0 +1,339 @@ +import { API } from "../constants/api"; +import { STORAGE_KEYS } from "../constants/storage"; +import { + clearTokens, + exchangeCodeForTokens, + generateState, + getStoredTokens, + initiateOAuth, + isAuthenticated, + storeTokens, +} from "./auth.service"; + +const mockChromeIdentity = { + getRedirectURL: jest.fn(), + launchWebAuthFlow: jest.fn(), +}; + +const mockChromeStorage = { + session: { + get: jest.fn(), + remove: jest.fn(), + set: jest.fn(), + }, +}; + +const EXPECTED_DETERMINISTIC_STATE = + "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"; + +const mockGetRandomValues = jest.fn((array: Uint8Array) => { + for (let i = 0; i < array.length; i++) { + array[i] = i; + } + return array; +}); + +Object.defineProperty(globalThis, "chrome", { + value: { + identity: mockChromeIdentity, + storage: mockChromeStorage, + }, + writable: true, +}); + +Object.defineProperty(globalThis, "crypto", { + value: { + getRandomValues: mockGetRandomValues, + }, + writable: true, +}); + +globalThis.fetch = jest.fn() as jest.Mock; + +describe("auth.service", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetRandomValues.mockImplementation((array: Uint8Array) => { + for (let i = 0; i < array.length; i++) { + array[i] = i; + } + return array; + }); + }); + + describe("generateState", () => { + it("should generate state string from crypto random values", () => { + const state = generateState(); + + expect(state).toHaveLength(64); + expect(mockGetRandomValues).toHaveBeenCalledWith(expect.any(Uint8Array)); + }); + + it("should generate deterministic state with mocked crypto", () => { + const state1 = generateState(); + const state2 = generateState(); + + expect(state1).toBe(state2); + expect(state1).toBe(EXPECTED_DETERMINISTIC_STATE); + }); + }); + + describe("storeTokens", () => { + it("should store access and refresh tokens in session storage", async () => { + await storeTokens({ accessToken: "access_token_123", refreshToken: "refresh_token_456" }); + + expect(mockChromeStorage.session.set).toHaveBeenCalledWith({ + [STORAGE_KEYS.ACCESS_TOKEN]: "access_token_123", + [STORAGE_KEYS.REFRESH_TOKEN]: "refresh_token_456", + }); + }); + }); + + describe("getStoredTokens", () => { + it("should retrieve access and refresh tokens from session storage", async () => { + mockChromeStorage.session.get.mockResolvedValueOnce({ + [STORAGE_KEYS.ACCESS_TOKEN]: "stored_access_token", + [STORAGE_KEYS.REFRESH_TOKEN]: "stored_refresh_token", + }); + + const result = await getStoredTokens(); + + expect(result).toEqual({ + accessToken: "stored_access_token", + refreshToken: "stored_refresh_token", + }); + expect(mockChromeStorage.session.get).toHaveBeenCalledWith({ + [STORAGE_KEYS.ACCESS_TOKEN]: null, + [STORAGE_KEYS.REFRESH_TOKEN]: null, + }); + }); + + it("should return null values when tokens are not stored", async () => { + mockChromeStorage.session.get.mockResolvedValueOnce({}); + + const result = await getStoredTokens(); + + expect(result).toEqual({ + accessToken: null, + refreshToken: null, + }); + }); + + it("should handle partial token storage", async () => { + mockChromeStorage.session.get.mockResolvedValueOnce({ + [STORAGE_KEYS.ACCESS_TOKEN]: "access_token_only", + }); + + const result = await getStoredTokens(); + + expect(result).toEqual({ + accessToken: "access_token_only", + refreshToken: null, + }); + }); + }); + + describe("clearTokens", () => { + it("should remove access and refresh tokens from session storage", async () => { + await clearTokens(); + + expect(mockChromeStorage.session.remove).toHaveBeenCalledWith([ + STORAGE_KEYS.ACCESS_TOKEN, + STORAGE_KEYS.REFRESH_TOKEN, + ]); + }); + }); + + describe("isAuthenticated", () => { + it("should return true when both tokens exist", async () => { + mockChromeStorage.session.get.mockResolvedValueOnce({ + [STORAGE_KEYS.ACCESS_TOKEN]: "access_token", + [STORAGE_KEYS.REFRESH_TOKEN]: "refresh_token", + }); + + const result = await isAuthenticated(); + + expect(result).toBe(true); + }); + + it("should return false when access token is missing", async () => { + mockChromeStorage.session.get.mockResolvedValueOnce({ + [STORAGE_KEYS.REFRESH_TOKEN]: "refresh_token", + }); + + const result = await isAuthenticated(); + + expect(result).toBe(false); + }); + + it("should return false when refresh token is missing", async () => { + mockChromeStorage.session.get.mockResolvedValueOnce({ + [STORAGE_KEYS.ACCESS_TOKEN]: "access_token", + }); + + const result = await isAuthenticated(); + + expect(result).toBe(false); + }); + + it("should return false when both tokens are missing", async () => { + mockChromeStorage.session.get.mockResolvedValueOnce({}); + + const result = await isAuthenticated(); + + expect(result).toBe(false); + }); + }); + + describe("initiateOAuth", () => { + const mockRedirectUri = "https://extension-redirect.chromium.org"; + const mockState = "test_state_12345"; + + beforeEach(() => { + mockChromeIdentity.getRedirectURL.mockReturnValue(mockRedirectUri); + }); + + it("should successfully complete OAuth flow", async () => { + const mockCode = "oauth_code_123"; + const mockRedirectUrl = `${mockRedirectUri}?code=${mockCode}&state=${mockState}`; + + mockChromeStorage.session.get.mockResolvedValueOnce({ + [STORAGE_KEYS.OAUTH_STATE]: mockState, + }); + mockChromeIdentity.launchWebAuthFlow.mockResolvedValueOnce(mockRedirectUrl); + + const result = await initiateOAuth(mockState); + + expect(result).toEqual({ + code: mockCode, + state: mockState, + }); + + expect(mockChromeStorage.session.set).toHaveBeenCalledWith({ + [STORAGE_KEYS.OAUTH_STATE]: mockState, + }); + + expect(mockChromeIdentity.launchWebAuthFlow).toHaveBeenCalledWith({ + interactive: true, + url: `${API.GITHUB.OAUTH_URL}?client_id=${API.GITHUB.CLIENT_ID}&redirect_uri=${encodeURIComponent( + mockRedirectUri + )}&scope=${API.GITHUB.SCOPE}&state=${mockState}`, + }); + + expect(mockChromeStorage.session.remove).toHaveBeenCalledWith([STORAGE_KEYS.OAUTH_STATE]); + }); + + it("should throw error when OAuth flow is cancelled", async () => { + mockChromeIdentity.launchWebAuthFlow.mockResolvedValueOnce(undefined); + + await expect(initiateOAuth(mockState)).rejects.toThrow("OAuth flow cancelled by user"); + + expect(mockChromeStorage.session.set).toHaveBeenCalledWith({ + [STORAGE_KEYS.OAUTH_STATE]: mockState, + }); + }); + + it("should throw error when code is missing from redirect URL", async () => { + const mockRedirectUrl = `${mockRedirectUri}?state=${mockState}`; + + mockChromeIdentity.launchWebAuthFlow.mockResolvedValueOnce(mockRedirectUrl); + + await expect(initiateOAuth(mockState)).rejects.toThrow( + "Invalid OAuth response: missing code or state" + ); + }); + + it("should throw error when state is missing from redirect URL", async () => { + const mockRedirectUrl = `${mockRedirectUri}?code=oauth_code`; + + mockChromeIdentity.launchWebAuthFlow.mockResolvedValueOnce(mockRedirectUrl); + + await expect(initiateOAuth(mockState)).rejects.toThrow( + "Invalid OAuth response: missing code or state" + ); + }); + + it("should throw error when state validation fails", async () => { + const mockCode = "oauth_code_123"; + const differentState = "different_state"; + const mockRedirectUrl = `${mockRedirectUri}?code=${mockCode}&state=${differentState}`; + + mockChromeStorage.session.get.mockResolvedValueOnce({ + [STORAGE_KEYS.OAUTH_STATE]: mockState, + }); + mockChromeIdentity.launchWebAuthFlow.mockResolvedValueOnce(mockRedirectUrl); + + await expect(initiateOAuth(mockState)).rejects.toThrow( + "State validation failed: potential CSRF attack" + ); + }); + + it("should clean up OAuth state after successful flow", async () => { + const mockCode = "oauth_code_123"; + const mockRedirectUrl = `${mockRedirectUri}?code=${mockCode}&state=${mockState}`; + + mockChromeStorage.session.get.mockResolvedValueOnce({ + [STORAGE_KEYS.OAUTH_STATE]: mockState, + }); + mockChromeIdentity.launchWebAuthFlow.mockResolvedValueOnce(mockRedirectUrl); + + await initiateOAuth(mockState); + + expect(mockChromeStorage.session.remove).toHaveBeenCalledWith([STORAGE_KEYS.OAUTH_STATE]); + }); + }); + + describe("exchangeCodeForTokens", () => { + it("should exchange code for tokens successfully", async () => { + const mockResponse = { + access_token: "github_access_token", + refresh_token: "github_refresh_token", + }; + + (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ + json: async () => mockResponse, + ok: true, + }); + + const result = await exchangeCodeForTokens({ code: "oauth_code_123", state: "state_456" }); + + expect(result).toEqual(mockResponse); + expect(globalThis.fetch).toHaveBeenCalledWith( + `${API.BASE_URL}/callback?code=oauth_code_123&state=state_456` + ); + }); + + it("should throw error when callback fails with 400", async () => { + (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 400, + }); + + await expect(exchangeCodeForTokens({ code: "invalid_code", state: "state" })).rejects.toThrow( + "Authentication failed (400). Please try again." + ); + }); + + it("should throw error when callback fails with 401", async () => { + (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 401, + }); + + await expect(exchangeCodeForTokens({ code: "expired_code", state: "state" })).rejects.toThrow( + "Authentication failed (401). Please try again." + ); + }); + + it("should throw error when callback fails with 500", async () => { + (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 500, + }); + + await expect(exchangeCodeForTokens({ code: "code", state: "state" })).rejects.toThrow( + "Authentication failed (500). Please try again." + ); + }); + }); +}); diff --git a/extension/src/services/auth.service.ts b/extension/src/services/auth.service.ts new file mode 100644 index 0000000..1ae0cd8 --- /dev/null +++ b/extension/src/services/auth.service.ts @@ -0,0 +1,126 @@ +import { API } from "../constants/api"; +import { STORAGE_KEYS } from "../constants/storage"; + +const ERROR_MESSAGES = { + AUTH_FAILED: (status: number) => `Authentication failed (${status}). Please try again.`, + OAUTH_CANCELLED: "OAuth flow cancelled by user", + OAUTH_INVALID_RESPONSE: "Invalid OAuth response: missing code or state", + STATE_VALIDATION_FAILED: "State validation failed: potential CSRF attack", +} as const; + +const STATE_LENGTH = 32; + +type OAuthTokenResponse = { + access_token: string; + refresh_token: string; +}; + +type OAuthFlowResult = { + code: string; + state: string; +}; + +export const clearTokens = async (): Promise => { + await chrome.storage.session.remove([STORAGE_KEYS.ACCESS_TOKEN, STORAGE_KEYS.REFRESH_TOKEN]); +}; + +export const exchangeCodeForTokens = async ({ + code, + state, +}: { + code: string; + state: string; +}): Promise => { + const callbackUrl = `${API.BASE_URL}/callback?code=${code}&state=${state}`; + const response = await fetch(callbackUrl); + + if (!response.ok) { + throw new Error(ERROR_MESSAGES.AUTH_FAILED(response.status)); + } + + return await response.json(); +}; + +export const generateState = (): string => { + const array = new Uint8Array(STATE_LENGTH); + crypto.getRandomValues(array); + return Array.from(array, (byte) => byte.toString(16).padStart(2, "0")).join(""); +}; + +export const getStoredTokens = async (): Promise<{ + accessToken: string | null; + refreshToken: string | null; +}> => { + const result = await chrome.storage.session.get({ + [STORAGE_KEYS.ACCESS_TOKEN]: null, + [STORAGE_KEYS.REFRESH_TOKEN]: null, + }); + + const accessToken = result[STORAGE_KEYS.ACCESS_TOKEN]; + const refreshToken = result[STORAGE_KEYS.REFRESH_TOKEN]; + + return { + accessToken: typeof accessToken === "string" ? accessToken : null, + refreshToken: typeof refreshToken === "string" ? refreshToken : null, + }; +}; + +export const initiateOAuth = async (state: string): Promise => { + const redirectUri = chrome.identity.getRedirectURL(); + + await chrome.storage.session.set({ [STORAGE_KEYS.OAUTH_STATE]: state }); + + const authUrl = `${API.GITHUB.OAUTH_URL}?client_id=${API.GITHUB.CLIENT_ID}&redirect_uri=${encodeURIComponent( + redirectUri + )}&scope=${API.GITHUB.SCOPE}&state=${state}`; + + const redirectUrl = await chrome.identity.launchWebAuthFlow({ + interactive: true, + url: authUrl, + }); + + if (!redirectUrl) { + throw new Error(ERROR_MESSAGES.OAUTH_CANCELLED); + } + + const url = new URL(redirectUrl); + const code = url.searchParams.get("code"); + const returnedState = url.searchParams.get("state"); + + if (!code || !returnedState) { + throw new Error(ERROR_MESSAGES.OAUTH_INVALID_RESPONSE); + } + + const { [STORAGE_KEYS.OAUTH_STATE]: storedState } = await chrome.storage.session.get([ + STORAGE_KEYS.OAUTH_STATE, + ]); + + if (returnedState !== storedState) { + throw new Error(ERROR_MESSAGES.STATE_VALIDATION_FAILED); + } + + await chrome.storage.session.remove([STORAGE_KEYS.OAUTH_STATE]); + + return { + code, + state: returnedState, + }; +}; + +export const isAuthenticated = async (): Promise => { + const { accessToken, refreshToken } = await getStoredTokens(); + return !!(accessToken && refreshToken); +}; + +export const storeTokens = async ({ + accessToken, + refreshToken, +}: { + accessToken: string; + refreshToken: string; +}): Promise => { + await chrome.storage.session.set({ + [STORAGE_KEYS.ACCESS_TOKEN]: accessToken, + [STORAGE_KEYS.REFRESH_TOKEN]: refreshToken, + }); +};