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