Skip to content

Commit 40edf07

Browse files
committed
feat: integrate Google Identity Services for enhanced authentication
1 parent 8d2b187 commit 40edf07

25 files changed

Lines changed: 764 additions & 215 deletions

.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,8 @@
44
VITE_SUPABASE_URL=https://your-project-ref.supabase.co
55
VITE_SUPABASE_ANON_KEY=your-anon-key
66

7+
# Google Identity Services — Web OAuth client ID (public)
8+
VITE_GOOGLE_WEB_CLIENT_ID=your-web-client-id.apps.googleusercontent.com
9+
710
# Optional: Pyodide CDN override (see src/constants/pyodideCdn.js)
811
# VITE_PYODIDE_CDN_BASE=

.github/workflows/deploy-cloudflare.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ jobs:
5050
VITE_DEV_SITE_URL: https://dev.bayanflow.com
5151
VITE_SUPABASE_URL: ${{ secrets.VITE_SUPABASE_URL }}
5252
VITE_SUPABASE_ANON_KEY: ${{ secrets.VITE_SUPABASE_ANON_KEY }}
53+
VITE_GOOGLE_WEB_CLIENT_ID: ${{ secrets.VITE_GOOGLE_WEB_CLIENT_ID }}
5354

5455
- name: Deploy to Cloudflare Workers
5556
uses: cloudflare/wrangler-action@9acf94ace14e7dc412b076f2c5c20b8ce93c79cd # v3

.github/workflows/preview-cloudflare.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ jobs:
5757
VITE_DEV_SITE_URL: https://dev.bayanflow.com
5858
VITE_SUPABASE_URL: ${{ secrets.VITE_SUPABASE_URL }}
5959
VITE_SUPABASE_ANON_KEY: ${{ secrets.VITE_SUPABASE_ANON_KEY }}
60+
VITE_GOOGLE_WEB_CLIENT_ID: ${{ secrets.VITE_GOOGLE_WEB_CLIENT_ID }}
6061

6162
- name: Upload preview version
6263
id: deploy

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ See reference doc for full checklists (JS, Python, pseudocode, sound, insight, t
6565
- **Service layer**`src/services/authService.js`, `profileService.js`; components use `AuthContext` / `useAuth`, never import Supabase directly
6666
- **Postgres-portable schema**`profiles` keyed to `auth.users`; RLS on public tables; client never writes `plan` (service role / webhook only, future)
6767
- **Session**`getSession()`, `onAuthStateChange()`; `AuthProvider` in `src/main.jsx`
68-
- **OAuth UX**popup flow via `/auth/callback`; redirect URLs must include `{origin}/auth/callback`
68+
- **OAuth UX**Google Identity Services (One Tap + PKCE popup on `/auth/google/callback`); web uses `signInWithIdToken`, not `signInWithOAuth`
6969
- **i18n** — sign-in/out strings and legal copy in en/fr/ar; audit RTL for Header auth control
7070
- **Free by default** — unauthenticated users keep full access; auth PRs must not gate existing features
7171
- **Secrets** — publishable anon key via `VITE_*` only; service role key never in repo or client bundle

netlify.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@
1717
X-Content-Type-Options = "nosniff"
1818
X-Frame-Options = "DENY"
1919
Referrer-Policy = "strict-origin-when-cross-origin"
20-
Permissions-Policy = "camera=(), microphone=(), geolocation=()"
21-
Content-Security-Policy = "default-src 'self'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' blob: https://cloud.umami.is https://static.cloudflareinsights.com https://cdn.jsdelivr.net; connect-src 'self' blob: https://cloud.umami.is https://gateway.umami.is https://cloudflareinsights.com https://api.github.com https://cdn.jsdelivr.net https://www.remotion.pro https://qketsapzqpzmccljfjcm.supabase.co; img-src 'self' data: blob: https://api.producthunt.com https://lh3.googleusercontent.com; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; font-src 'self' data:; worker-src 'self' blob:; media-src 'self' blob:; frame-src https://www.youtube-nocookie.com; object-src 'none'; base-uri 'self'; form-action 'self'"
20+
Permissions-Policy = "camera=(), microphone=(), geolocation=(), identity-credentials-get=(self \"https://accounts.google.com\")"
21+
Content-Security-Policy = "default-src 'self'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' blob: https://cloud.umami.is https://static.cloudflareinsights.com https://cdn.jsdelivr.net https://accounts.google.com; connect-src 'self' blob: https://cloud.umami.is https://gateway.umami.is https://cloudflareinsights.com https://api.github.com https://cdn.jsdelivr.net https://www.remotion.pro https://qketsapzqpzmccljfjcm.supabase.co https://accounts.google.com https://oauth2.googleapis.com; img-src 'self' data: blob: https://api.producthunt.com https://lh3.googleusercontent.com; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; font-src 'self' data:; worker-src 'self' blob:; media-src 'self' blob:; frame-src https://www.youtube-nocookie.com https://accounts.google.com; object-src 'none'; base-uri 'self'; form-action 'self'"
2222

2323
# SPA routing: rewrite all non-file requests to index.html
2424
[[redirects]]

public/_headers

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33
X-Content-Type-Options: nosniff
44
X-Frame-Options: DENY
55
Referrer-Policy: strict-origin-when-cross-origin
6-
Permissions-Policy: camera=(), microphone=(), geolocation=()
6+
Permissions-Policy: camera=(), microphone=(), geolocation=(), identity-credentials-get=(self "https://accounts.google.com")
77
# Pyodide CDN origins: jsDelivr below; custom VITE_PYODIDE_CDN_BASE origins appended at build (vite.config.js)
8-
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' blob: https://cloud.umami.is https://static.cloudflareinsights.com https://cdn.jsdelivr.net; connect-src 'self' blob: https://cloud.umami.is https://gateway.umami.is https://cloudflareinsights.com https://api.github.com https://cdn.jsdelivr.net https://www.remotion.pro https://qketsapzqpzmccljfjcm.supabase.co; img-src 'self' data: blob: https://api.producthunt.com https://lh3.googleusercontent.com; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; font-src 'self' data:; worker-src 'self' blob:; media-src 'self' blob:; frame-src https://www.youtube-nocookie.com; object-src 'none'; base-uri 'self'; form-action 'self'
8+
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' blob: https://cloud.umami.is https://static.cloudflareinsights.com https://cdn.jsdelivr.net https://accounts.google.com; connect-src 'self' blob: https://cloud.umami.is https://gateway.umami.is https://cloudflareinsights.com https://api.github.com https://cdn.jsdelivr.net https://www.remotion.pro https://qketsapzqpzmccljfjcm.supabase.co https://accounts.google.com https://oauth2.googleapis.com; img-src 'self' data: blob: https://api.producthunt.com https://lh3.googleusercontent.com; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; font-src 'self' data:; worker-src 'self' blob:; media-src 'self' blob:; frame-src https://www.youtube-nocookie.com https://accounts.google.com; object-src 'none'; base-uri 'self'; form-action 'self'
99
Link: <https://bayanflow.com/sitemap.xml>; rel="sitemap"
1010

1111
/assets/*

public/auth.md

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,23 @@ Bayan Flow offers optional sign-in with Google. You can use all algorithm visual
44

55
## Sign in
66

7-
When you choose **Sign in with Google**, a small popup opens for Google account selection. After you approve access, the popup closes and you stay on the same page (or are taken to the visualizer if you signed in from the landing page). Authentication is handled by Supabase Auth using OpenID Connect (OIDC). We receive your Google account email, display name, and profile photo URL to show your account in the app header and to create a minimal profile row in our database.
7+
When you visit the site while signed into Google in your browser, a **Google One Tap** prompt may appear automatically so you can sign in with one click. You can also use the **Sign in with Google** control in the header at any time.
88

9-
**Supabase redirect URL:** your project must allow `{origin}/auth/callback` (for example `https://bayanflow.com/auth/callback` and `http://localhost:5173/auth/callback` for local development).
9+
Sign-in uses [Google Identity Services](https://developers.google.com/identity) on **bayanflow.com** — Google shows our domain, not a third-party auth host. After you choose an account, Bayan Flow receives an ID token and creates a Supabase session. We store your Google account email, display name, and profile photo URL to show your account in the app and to maintain a minimal profile row in our database.
10+
11+
**Google Cloud setup (required):** in [Google Cloud Console](https://console.cloud.google.com/apis/credentials) → your **Web application** OAuth client → **Authorized JavaScript origins**, add every origin where the app runs (scheme + host + port, no trailing slash):
12+
13+
| Environment | Authorized JavaScript origin |
14+
|-------------|------------------------------|
15+
| Local dev (Vite default) | `http://localhost:5173` |
16+
| Dev deploy | `https://dev.bayanflow.com` |
17+
| Production | `https://bayanflow.com` |
18+
19+
The `VITE_GOOGLE_WEB_CLIENT_ID` in `.env.local` must match this same Web client. Redirect URIs are **not** required for One Tap or the header sign-in button. If sign-in fails with `origin_mismatch` or `403` on `accounts.google.com/gsi/`, the current browser origin is missing from that list — open DevTools and compare `window.location.origin` to the Console entries exactly.
20+
21+
**Supabase:** Authentication → Providers → Google — use the same Web Client ID and Client Secret from that OAuth client.
22+
23+
**Browser:** Chrome may block FedCM / third-party sign-in for a site. If One Tap fails, use the header **Sign in with Google** button, or re-enable third-party sign-in via the icon left of the address bar → Site settings.
1024

1125
## Data stored
1226

@@ -15,7 +29,7 @@ When you choose **Sign in with Google**, a small popup opens for Google account
1529

1630
## Sign out
1731

18-
Use **Sign out** from the account menu in the header at any time. This clears the local session.
32+
Use **Sign out** from the account menu in the header at any time. This clears the local session and disables Google automatic sign-in for this browser until you sign in again.
1933

2034
## Contact
2135

scripts/cspHeaders.js

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,5 +101,31 @@ export function assertAuthCspDirectives(csp, source) {
101101
);
102102
}
103103

104+
const scriptSrc = directives.get('script-src');
105+
if (!scriptSrc?.includes('https://accounts.google.com')) {
106+
throw new Error(
107+
`${source}: script-src must include https://accounts.google.com (Google Identity Services)`
108+
);
109+
}
110+
111+
if (!connectSrc?.includes('https://accounts.google.com')) {
112+
throw new Error(
113+
`${source}: connect-src must include https://accounts.google.com (Google Identity Services)`
114+
);
115+
}
116+
117+
if (!connectSrc?.includes('https://oauth2.googleapis.com')) {
118+
throw new Error(
119+
`${source}: connect-src must include https://oauth2.googleapis.com (Google OAuth token exchange)`
120+
);
121+
}
122+
123+
const frameSrc = directives.get('frame-src');
124+
if (!frameSrc?.includes('https://accounts.google.com')) {
125+
throw new Error(
126+
`${source}: frame-src must include https://accounts.google.com (Google One Tap)`
127+
);
128+
}
129+
104130
return { connectSrc: connectSrc ?? '', imgSrc: imgSrc ?? '' };
105131
}

src/components/GoogleOneTap.jsx

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/**
2+
* Copyright (c) 2025 Bayan Flow
3+
* Licensed under Elastic License 2.0 OR Commercial
4+
* See LICENSE for details.
5+
*/
6+
7+
import { useEffect, useRef } from 'react';
8+
import { useLocation, useNavigate } from 'react-router-dom';
9+
import { useAuth } from '@/hooks/useAuth';
10+
import { initOneTap } from '@/lib/googleIdentity';
11+
import * as authService from '@/services/authService';
12+
13+
const ONE_TAP_PATHS = new Set(['/', '/app']);
14+
15+
function GoogleOneTap() {
16+
const { isConfigured, isLoading, isAuthenticated } = useAuth();
17+
const location = useLocation();
18+
const navigate = useNavigate();
19+
const cancelRef = useRef(null);
20+
21+
useEffect(() => {
22+
if (
23+
!isConfigured ||
24+
isLoading ||
25+
isAuthenticated ||
26+
!ONE_TAP_PATHS.has(location.pathname)
27+
) {
28+
cancelRef.current?.();
29+
cancelRef.current = null;
30+
return undefined;
31+
}
32+
33+
let isMounted = true;
34+
35+
initOneTap({
36+
onCredential: async credential => {
37+
try {
38+
await authService.signInWithGoogleIdToken(credential);
39+
if (location.pathname === '/') {
40+
navigate('/app', { replace: true });
41+
}
42+
} catch (error) {
43+
console.error('Google One Tap sign-in failed:', error);
44+
}
45+
},
46+
})
47+
.then(handle => {
48+
if (!isMounted) {
49+
handle.cancel();
50+
return;
51+
}
52+
cancelRef.current = handle.cancel;
53+
})
54+
.catch(error => {
55+
if (isMounted) {
56+
console.error('Google One Tap initialization failed:', error);
57+
}
58+
});
59+
60+
return () => {
61+
isMounted = false;
62+
cancelRef.current?.();
63+
cancelRef.current = null;
64+
};
65+
}, [isConfigured, isLoading, isAuthenticated, location.pathname, navigate]);
66+
67+
return null;
68+
}
69+
70+
export default GoogleOneTap;
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
/**
2+
* Copyright (c) 2025 Bayan Flow
3+
* Licensed under Elastic License 2.0 OR Commercial
4+
* See LICENSE for details.
5+
*/
6+
7+
import { describe, it, expect, vi, beforeEach } from 'vitest';
8+
import { render, waitFor } from '@testing-library/react';
9+
import { MemoryRouter } from 'react-router-dom';
10+
import GoogleOneTap from './GoogleOneTap';
11+
import { useAuth } from '../hooks/useAuth';
12+
import { initOneTap } from '../lib/googleIdentity';
13+
import * as authService from '../services/authService';
14+
15+
vi.mock('../hooks/useAuth', () => ({
16+
useAuth: vi.fn(),
17+
}));
18+
19+
vi.mock('../lib/googleIdentity', () => ({
20+
initOneTap: vi.fn(),
21+
}));
22+
23+
vi.mock('../services/authService', () => ({
24+
signInWithGoogleIdToken: vi.fn(async () => undefined),
25+
}));
26+
27+
describe('GoogleOneTap', () => {
28+
beforeEach(() => {
29+
vi.mocked(useAuth).mockReset();
30+
vi.mocked(initOneTap).mockReset();
31+
vi.mocked(authService.signInWithGoogleIdToken).mockReset();
32+
vi.mocked(initOneTap).mockResolvedValue({
33+
cancel: vi.fn(),
34+
});
35+
});
36+
37+
it('does not initialize One Tap when user is authenticated', () => {
38+
vi.mocked(useAuth).mockReturnValue({
39+
isConfigured: true,
40+
isLoading: false,
41+
isAuthenticated: true,
42+
});
43+
44+
render(
45+
<MemoryRouter initialEntries={['/']}>
46+
<GoogleOneTap />
47+
</MemoryRouter>
48+
);
49+
50+
expect(initOneTap).not.toHaveBeenCalled();
51+
});
52+
53+
it('initializes One Tap on landing when signed out', async () => {
54+
vi.mocked(useAuth).mockReturnValue({
55+
isConfigured: true,
56+
isLoading: false,
57+
isAuthenticated: false,
58+
});
59+
60+
render(
61+
<MemoryRouter initialEntries={['/']}>
62+
<GoogleOneTap />
63+
</MemoryRouter>
64+
);
65+
66+
await waitFor(() => {
67+
expect(initOneTap).toHaveBeenCalled();
68+
});
69+
});
70+
71+
it('does not initialize One Tap on roadmap', () => {
72+
vi.mocked(useAuth).mockReturnValue({
73+
isConfigured: true,
74+
isLoading: false,
75+
isAuthenticated: false,
76+
});
77+
78+
render(
79+
<MemoryRouter initialEntries={['/roadmap']}>
80+
<GoogleOneTap />
81+
</MemoryRouter>
82+
);
83+
84+
expect(initOneTap).not.toHaveBeenCalled();
85+
});
86+
});

0 commit comments

Comments
 (0)