diff --git a/src/config/entities.ts b/src/config/entities.ts index efab4e06..44534520 100644 --- a/src/config/entities.ts +++ b/src/config/entities.ts @@ -282,8 +282,13 @@ export interface ListViewConfig { rowSelectionView?: ComponentsConfig; } +export enum OAUTH_FLOW { + AUTHORIZATION_CODE = "AUTHORIZATION_CODE", + IMPLICIT = "IMPLICIT", +} + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Use of `any` is intentional to allow for flexibility in the model. -export interface OAuthProvider

{ +interface OAuthProviderBase

{ authorization: { params: { scope: string } }; clientId: string; icon: ReactNode; @@ -293,6 +298,24 @@ export interface OAuthProvider

{ userinfo: string; } +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Use of `any` is intentional to allow for flexibility in the model. +export interface ImplicitFlowProvider

extends OAuthProviderBase

{ + flow: OAUTH_FLOW.IMPLICIT; +} + +export interface AuthorizationCodeFlowProvider< + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Use of `any` is intentional to allow for flexibility in the model. + P = any, +> extends OAuthProviderBase

{ + authorize: string; // Backend endpoint that exchanges an OAuth authorization code for a token set. + flow: OAUTH_FLOW.AUTHORIZATION_CODE; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Use of `any` is intentional to allow for flexibility in the model. +export type OAuthProvider

= + | ImplicitFlowProvider

+ | AuthorizationCodeFlowProvider

; + /** * Option Method. */ diff --git a/src/google/service.ts b/src/google/service.ts index 3aaa07e2..4cc56605 100644 --- a/src/google/service.ts +++ b/src/google/service.ts @@ -1,18 +1,8 @@ -import { requestAuth, resetAuthState } from "../auth/dispatch/auth"; -import { - requestAuthentication, - resetAuthenticationState, - updateAuthentication, -} from "../auth/dispatch/authentication"; -import { resetCredentialsState } from "../auth/dispatch/credentials"; -import { resetTokenState, updateToken } from "../auth/dispatch/token"; -import { AUTHENTICATION_STATUS } from "../auth/types/authentication"; -import { OAuthProvider } from "../config/entities"; -import { GoogleProfile, SessionDispatch, TokenSetParameters } from "./types"; -import { fetchProfile, getAuthenticationRequestOptions } from "./utils/auth"; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -- TODO see https://github.com/clevercanary/data-browser/issues/544. -declare const google: any; +import { OAUTH_FLOW, type OAuthProvider } from "../config/entities"; +import { login as authorizationCodeFlowLogin } from "./services/authorizationCodeFlow"; +import { logout, type LoginDispatch } from "./services/common"; +import { login as implicitFlowLogin } from "./services/implicitFlow"; +import type { SessionDispatch } from "./types"; /** * Google Sign-In service. @@ -20,56 +10,22 @@ declare const google: any; export const service = { /** * Login with Google OAuth. + * Dispatches to the configured flow based on `provider.flow`. * @param provider - OAuth provider configuration. * @param dispatch - Dispatch functions for auth state. */ - login: ( - provider: OAuthProvider, - dispatch: Pick< - SessionDispatch, - "authDispatch" | "authenticationDispatch" | "tokenDispatch" - >, - ): void => { - const client = google.accounts.oauth2.initTokenClient({ - callback: (response: TokenSetParameters) => { - const { id, profile, userinfo } = provider; - const { access_token: token } = response; - dispatch.authDispatch?.(requestAuth()); - dispatch.authenticationDispatch?.(requestAuthentication()); - dispatch.tokenDispatch?.(updateToken({ providerId: id, token })); - fetchProfile(userinfo, getAuthenticationRequestOptions(token), { - onError: () => { - dispatch.authDispatch?.(resetAuthState()); - dispatch.authenticationDispatch?.( - updateAuthentication({ - profile: undefined, - status: AUTHENTICATION_STATUS.SETTLED, - }), - ); - dispatch.tokenDispatch?.(resetTokenState()); - }, - onSuccess: (r: GoogleProfile) => - dispatch.authenticationDispatch?.( - updateAuthentication({ - profile: profile(r), - status: AUTHENTICATION_STATUS.PENDING, // Authentication is pending until session controller is resolved. - }), - ), - }); - }, - client_id: provider.clientId, - scope: provider.authorization.params.scope, - }); - client.requestAccessToken(); + login: (provider: OAuthProvider, dispatch: LoginDispatch): void => { + if (provider.flow === OAUTH_FLOW.AUTHORIZATION_CODE) { + authorizationCodeFlowLogin(provider, dispatch); + } else { + implicitFlowLogin(provider, dispatch); + } }, /** * Logout and clear all auth state. * @param dispatch - Dispatch functions for auth state. */ logout: (dispatch: SessionDispatch): void => { - dispatch.authDispatch?.(resetAuthState()); - dispatch.authenticationDispatch?.(resetAuthenticationState()); - dispatch.credentialsDispatch?.(resetCredentialsState()); - dispatch.tokenDispatch?.(resetTokenState()); + logout(dispatch); }, }; diff --git a/src/google/services/authorizationCodeFlow.ts b/src/google/services/authorizationCodeFlow.ts new file mode 100644 index 00000000..853a940a --- /dev/null +++ b/src/google/services/authorizationCodeFlow.ts @@ -0,0 +1,51 @@ +import type { AuthorizationCodeFlowProvider } from "../../config/entities"; +import type { CodeResponse, TokenSetParameters } from "../types"; +import { + createOnAccessToken, + createResetSession, + type LoginDispatch, +} from "./common"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- TODO see https://github.com/clevercanary/data-browser/issues/544. +declare const google: any; + +/** + * Login using the OAuth 2.0 authorization code flow. + * Uses Google's initCodeClient to request an authorization code, + * then exchanges it for an access token via the configured authorize endpoint. + * @param provider - OAuth provider configuration. + * @param dispatch - Dispatch functions for auth state. + */ +export function login( + provider: AuthorizationCodeFlowProvider, + dispatch: LoginDispatch, +): void { + const { authorize } = provider; + const resetSession = createResetSession(dispatch); + const onAccessToken = createOnAccessToken(provider, dispatch, resetSession); + const client = google.accounts.oauth2.initCodeClient({ + callback: (response: CodeResponse) => { + fetch(authorize, { + body: JSON.stringify(response), + headers: { "Content-Type": "application/json" }, + method: "POST", + }) + .then((r) => { + if (!r.ok) { + throw new Error(`authorize request failed (${r.status})`); + } + return r.json(); + }) + .then((tokens: TokenSetParameters) => { + if (!tokens?.access_token) { + throw new Error("authorize response missing access_token"); + } + onAccessToken(tokens.access_token); + }) + .catch(resetSession); + }, + client_id: provider.clientId, + scope: provider.authorization.params.scope, + }); + client.requestCode(); +} diff --git a/src/google/services/common.ts b/src/google/services/common.ts new file mode 100644 index 00000000..fde9f05f --- /dev/null +++ b/src/google/services/common.ts @@ -0,0 +1,77 @@ +import { requestAuth, resetAuthState } from "../../auth/dispatch/auth"; +import { + requestAuthentication, + resetAuthenticationState, + updateAuthentication, +} from "../../auth/dispatch/authentication"; +import { resetCredentialsState } from "../../auth/dispatch/credentials"; +import { resetTokenState, updateToken } from "../../auth/dispatch/token"; +import { AUTHENTICATION_STATUS } from "../../auth/types/authentication"; +import type { OAuthProvider } from "../../config/entities"; +import type { GoogleProfile, SessionDispatch } from "../types"; +import { fetchProfile, getAuthenticationRequestOptions } from "../utils/auth"; + +export type LoginDispatch = Pick< + SessionDispatch, + "authDispatch" | "authenticationDispatch" | "tokenDispatch" +>; + +/** + * Creates a function that resets the session state on auth failure. + * @param dispatch - Dispatch functions for auth state. + * @returns reset session function. + */ +export function createResetSession(dispatch: LoginDispatch): () => void { + return (): void => { + dispatch.authDispatch?.(resetAuthState()); + dispatch.authenticationDispatch?.( + updateAuthentication({ + profile: undefined, + status: AUTHENTICATION_STATUS.SETTLED, + }), + ); + dispatch.tokenDispatch?.(resetTokenState()); + }; +} + +/** + * Creates a function that handles a successful access token. + * Dispatches auth state updates and fetches the user profile. + * @param provider - OAuth provider configuration. + * @param dispatch - Dispatch functions for auth state. + * @param resetSession - Reset session function. + * @returns on access token function. + */ +export function createOnAccessToken( + provider: OAuthProvider, + dispatch: LoginDispatch, + resetSession: () => void, +): (token: string) => void { + const { id, profile, userinfo } = provider; + return (token: string): void => { + dispatch.authDispatch?.(requestAuth()); + dispatch.authenticationDispatch?.(requestAuthentication()); + dispatch.tokenDispatch?.(updateToken({ providerId: id, token })); + fetchProfile(userinfo, getAuthenticationRequestOptions(token), { + onError: resetSession, + onSuccess: (r: GoogleProfile) => + dispatch.authenticationDispatch?.( + updateAuthentication({ + profile: profile(r), + status: AUTHENTICATION_STATUS.PENDING, + }), + ), + }); + }; +} + +/** + * Logout and clear all auth state. + * @param dispatch - Dispatch functions for auth state. + */ +export function logout(dispatch: SessionDispatch): void { + dispatch.authDispatch?.(resetAuthState()); + dispatch.authenticationDispatch?.(resetAuthenticationState()); + dispatch.credentialsDispatch?.(resetCredentialsState()); + dispatch.tokenDispatch?.(resetTokenState()); +} diff --git a/src/google/services/implicitFlow.ts b/src/google/services/implicitFlow.ts new file mode 100644 index 00000000..b401494c --- /dev/null +++ b/src/google/services/implicitFlow.ts @@ -0,0 +1,31 @@ +import type { ImplicitFlowProvider } from "../../config/entities"; +import type { TokenSetParameters } from "../types"; +import { + createOnAccessToken, + createResetSession, + type LoginDispatch, +} from "./common"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- TODO see https://github.com/clevercanary/data-browser/issues/544. +declare const google: any; + +/** + * Login using the OAuth 2.0 implicit flow. + * Uses Google's initTokenClient to request an access token directly. + * @param provider - OAuth provider configuration. + * @param dispatch - Dispatch functions for auth state. + */ +export function login( + provider: ImplicitFlowProvider, + dispatch: LoginDispatch, +): void { + const resetSession = createResetSession(dispatch); + const onAccessToken = createOnAccessToken(provider, dispatch, resetSession); + const client = google.accounts.oauth2.initTokenClient({ + callback: (response: TokenSetParameters) => + onAccessToken(response.access_token), + client_id: provider.clientId, + scope: provider.authorization.params.scope, + }); + client.requestAccessToken(); +} diff --git a/src/google/types.ts b/src/google/types.ts index 8aedc611..a9ee312b 100644 --- a/src/google/types.ts +++ b/src/google/types.ts @@ -14,6 +14,18 @@ import { TokenState, } from "../auth/types/token"; +/** + * Authorization code response from Google OAuth. + */ +export interface CodeResponse { + code?: string; + error?: string; + error_description?: string; + error_uri?: string; + scope?: string; + state?: string; +} + /** * Google Sign-In authentication provider props. */ diff --git a/tests/googleService.test.ts b/tests/googleService.test.ts new file mode 100644 index 00000000..1703eaee --- /dev/null +++ b/tests/googleService.test.ts @@ -0,0 +1,172 @@ +import { jest } from "@jest/globals"; +import { resetTokenState, updateToken } from "../src/auth/dispatch/token"; +import { OAUTH_FLOW, type OAuthProvider } from "../src/config/entities"; +import { service } from "../src/google/service"; +import type { CodeResponse, SessionDispatch } from "../src/google/types"; + +const PROVIDER_BASE: OAuthProvider = { + authorization: { params: { scope: "openid email profile" } }, + clientId: "test-client-id", + flow: OAUTH_FLOW.IMPLICIT, + icon: null, + id: "google", + name: "Google", + profile: (p) => p, + userinfo: "https://example.com/userinfo", +}; + +const AUTHORIZE_URL = "https://service.example.com/user/authorize"; + +const PROVIDER_AUTH_CODE: OAuthProvider = { + ...PROVIDER_BASE, + authorize: AUTHORIZE_URL, + flow: OAUTH_FLOW.AUTHORIZATION_CODE, +}; + +const CODE_RESPONSE: CodeResponse = { code: "test-code" }; + +type LoginDispatch = Pick< + SessionDispatch, + "authDispatch" | "authenticationDispatch" | "tokenDispatch" +>; + +const flushPromises = async (): Promise => { + for (let i = 0; i < 5; i++) { + await Promise.resolve(); + } +}; + +describe("service.login (Google)", () => { + let initCodeClient: jest.Mock; + let initTokenClient: jest.Mock; + let requestCode: jest.Mock; + let requestAccessToken: jest.Mock; + let dispatch: LoginDispatch; + let originalFetch: typeof fetch | undefined; + + beforeEach(() => { + requestCode = jest.fn(); + requestAccessToken = jest.fn(); + initCodeClient = jest.fn(() => ({ requestCode })); + initTokenClient = jest.fn(() => ({ requestAccessToken })); + dispatch = { + authDispatch: jest.fn(), + authenticationDispatch: jest.fn(), + tokenDispatch: jest.fn(), + }; + (globalThis as unknown as { google: unknown }).google = { + accounts: { oauth2: { initCodeClient, initTokenClient } }, + }; + originalFetch = (globalThis as unknown as { fetch?: typeof fetch }).fetch; + }); + + afterEach(() => { + delete (globalThis as unknown as { google?: unknown }).google; + if (originalFetch === undefined) { + delete (globalThis as unknown as { fetch?: unknown }).fetch; + } else { + (globalThis as unknown as { fetch: typeof fetch }).fetch = originalFetch; + } + }); + + it("uses the authorization code flow when provider.flow is OAUTH_FLOW.AUTHORIZATION_CODE", () => { + service.login(PROVIDER_AUTH_CODE, dispatch); + + expect(initCodeClient).toHaveBeenCalledTimes(1); + expect(requestCode).toHaveBeenCalledTimes(1); + expect(initTokenClient).not.toHaveBeenCalled(); + expect(requestAccessToken).not.toHaveBeenCalled(); + + const config = initCodeClient.mock.calls[0]?.[0] as { + client_id: string; + scope: string; + }; + expect(config.client_id).toBe(PROVIDER_BASE.clientId); + expect(config.scope).toBe(PROVIDER_BASE.authorization.params.scope); + }); + + it("uses the implicit token flow when provider.flow is OAUTH_FLOW.IMPLICIT", () => { + service.login(PROVIDER_BASE, dispatch); + + expect(initTokenClient).toHaveBeenCalledTimes(1); + expect(requestAccessToken).toHaveBeenCalledTimes(1); + expect(initCodeClient).not.toHaveBeenCalled(); + expect(requestCode).not.toHaveBeenCalled(); + }); + + it("exchanges the authorization code at provider.authorize and dispatches updateToken", async () => { + const fetchMock = jest.fn(() => + Promise.resolve({ + json: () => Promise.resolve({ access_token: "fake-access-token" }), + ok: true, + } as Response), + ); + (globalThis as unknown as { fetch: typeof fetch }).fetch = + fetchMock as unknown as typeof fetch; + + service.login(PROVIDER_AUTH_CODE, dispatch); + + const config = initCodeClient.mock.calls[0]?.[0] as { + callback: (response: CodeResponse) => void; + }; + config.callback(CODE_RESPONSE); + await flushPromises(); + + expect(fetchMock).toHaveBeenCalledWith(AUTHORIZE_URL, { + body: JSON.stringify(CODE_RESPONSE), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + expect(dispatch.tokenDispatch).toHaveBeenCalledWith( + updateToken({ providerId: "google", token: "fake-access-token" }), + ); + }); + + it("resets session state when the authorize request returns a non-ok response", async () => { + const fetchMock = jest.fn(() => + Promise.resolve({ + json: () => Promise.resolve({}), + ok: false, + status: 500, + } as Response), + ); + (globalThis as unknown as { fetch: typeof fetch }).fetch = + fetchMock as unknown as typeof fetch; + + service.login(PROVIDER_AUTH_CODE, dispatch); + + const config = initCodeClient.mock.calls[0]?.[0] as { + callback: (response: CodeResponse) => void; + }; + config.callback(CODE_RESPONSE); + await flushPromises(); + + expect(dispatch.tokenDispatch).toHaveBeenCalledWith(resetTokenState()); + expect(dispatch.tokenDispatch).not.toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ token: expect.any(String) }), + }), + ); + }); + + it("resets session state when the authorize response is missing access_token", async () => { + const fetchMock = jest.fn(() => + Promise.resolve({ + json: () => Promise.resolve({}), + ok: true, + } as Response), + ); + (globalThis as unknown as { fetch: typeof fetch }).fetch = + fetchMock as unknown as typeof fetch; + + service.login(PROVIDER_AUTH_CODE, dispatch); + + const config = initCodeClient.mock.calls[0]?.[0] as { + callback: (response: CodeResponse) => void; + }; + config.callback(CODE_RESPONSE); + await flushPromises(); + + expect(dispatch.tokenDispatch).toHaveBeenCalledWith(resetTokenState()); + }); +});