Skip to content

Commit 868a994

Browse files
committed
Social Authentication with GitHub & Google OAuth
Features: - Add GitHub and Google social auth support with secure OAuth 2.0 flow - Auto-generated provider callback URLs (read-only) - Encrypted OAuth client secrets storage - One-time rtCode token exchange mechanism - Automatic account linking by verified email Security Fixes: - [P1] Require siteUrl before enabling OAuth providers - [P1] Reject social login when email exists but provider email unverified - [P2] Forward OAuth errors to frontend callback instead of JSON - [P3] Add verifyApiKey middleware to /social/exchange endpoint
1 parent 0eb61e0 commit 868a994

22 files changed

Lines changed: 674 additions & 86 deletions

File tree

‎README.md‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,10 +152,42 @@ Social Auth is configured in a Supabase-style flow:
152152
After a successful provider login, urBackend redirects users back to:
153153
`<Site URL>/auth/callback`
154154

155+
The frontend callback flow is:
156+
1. Read `token` from the URL fragment and `rtCode` from the query string.
157+
2. Call `POST /api/userAuth/social/exchange` with JSON:
158+
- `token`
159+
- `rtCode`
160+
3. Expect a standard response in the form `{ success, data, message }`.
161+
4. On success, store the original access token together with `data.refreshToken`.
162+
5. Continue into the signed-in app.
163+
164+
Callback example:
165+
166+
```js
167+
const fragment = new URLSearchParams(window.location.hash.slice(1));
168+
const params = new URLSearchParams(window.location.search);
169+
170+
const token = fragment.get('token');
171+
const rtCode = params.get('rtCode');
172+
173+
const response = await fetch('/api/userAuth/social/exchange', {
174+
method: 'POST',
175+
headers: {
176+
'Content-Type': 'application/json'
177+
},
178+
body: JSON.stringify({ token, rtCode })
179+
});
180+
181+
const payload = await response.json();
182+
// Success shape: { success: true, data: { refreshToken }, message: 'Refresh token exchanged successfully' }
183+
```
184+
155185
GitHub and Google both support:
156186
- linking existing users by email when the provider returns a verified email matching an existing account
157187
- creating new users automatically when no matching account exists
158188
- issuing the same urBackend access and refresh tokens used by normal auth flows
189+
- backend-generated read-only provider callback URLs in the dashboard
190+
- redirecting to the project `Site URL` after provider login
159191

160192
---
161193

‎apps/dashboard-api/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "dashboard-api",
3-
"version": "0.7.0",
3+
"version": "0.8.0",
44
"private": true,
55
"main": "src/app.js",
66
"scripts": {

‎apps/dashboard-api/src/controllers/project.controller.js‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1388,6 +1388,14 @@ module.exports.updateAuthProviders = async (req, res) => {
13881388
});
13891389
}
13901390

1391+
// P1: Require siteUrl before enabling any OAuth provider
1392+
if (nextEnabled && !project.siteUrl?.trim()) {
1393+
return res.status(422).json({
1394+
error: "siteUrl required",
1395+
message: `You must configure a Site URL in Project Settings before enabling ${provider} OAuth. The Site URL is used to redirect users after authentication.`,
1396+
});
1397+
}
1398+
13911399
project.authProviders[provider] = {
13921400
enabled: nextEnabled,
13931401
clientId: nextClientId,

‎apps/public-api/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "public-api",
3-
"version": "0.7.0",
3+
"version": "0.8.0",
44
"private": true,
55
"main": "src/app.js",
66
"scripts": {

‎apps/public-api/src/__tests__/userAuth.social.test.js‎

Lines changed: 118 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ const makeProject = () => ({
9797
_id: 'project_1',
9898
name: 'Demo',
9999
jwtSecret: 'jwt_secret',
100+
siteUrl: 'http://localhost:5173',
100101
isAuthEnabled: true,
101102
resources: { db: { isExternal: false } },
102103
collections: [
@@ -221,7 +222,7 @@ describe('public userAuth social auth', () => {
221222
});
222223

223224
test('handleSocialAuthCallback rejects when GitHub returns no email', async () => {
224-
redis.get.mockResolvedValueOnce(JSON.stringify({ projectId: 'project_1', provider: 'github' }));
225+
redis.get.mockResolvedValueOnce(JSON.stringify({ projectId: 'project_1', provider: 'github', callbackUrl: 'http://localhost:5173/auth/callback' }));
225226
mockProjectFindByIdChain.lean.mockResolvedValueOnce(makeProject());
226227
global.fetch
227228
.mockResolvedValueOnce({
@@ -242,10 +243,9 @@ describe('public userAuth social auth', () => {
242243

243244
await controller.handleSocialAuthCallback(req, res);
244245

245-
expect(res.status).toHaveBeenCalledWith(422);
246-
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
247-
error: expect.stringContaining('did not return an email address'),
248-
}));
246+
// P2: errors now redirect to frontend instead of JSON
247+
expect(res.redirect).toHaveBeenCalledWith(expect.stringContaining('error='));
248+
expect(res.redirect).toHaveBeenCalledWith(expect.stringContaining('did+not+return+an+email'));
249249
});
250250

251251
test('handleSocialAuthCallback redirects after GitHub signup', async () => {
@@ -282,8 +282,9 @@ describe('public userAuth social auth', () => {
282282
}));
283283
expect(issueAuthTokens).toHaveBeenCalled();
284284
expect(res.redirect).toHaveBeenCalledWith(expect.stringContaining('/auth/callback?'));
285-
expect(res.redirect).toHaveBeenCalledWith(expect.stringContaining('token=issued_access_token'));
286285
expect(res.redirect).toHaveBeenCalledWith(expect.stringContaining('rtCode='));
286+
expect(res.redirect).toHaveBeenCalledWith(expect.stringContaining('#token=issued_access_token'));
287+
expect(res.redirect).not.toHaveBeenCalledWith(expect.stringContaining('?token=issued_access_token'));
287288
expect(res.redirect).not.toHaveBeenCalledWith(expect.stringContaining('refreshToken=issued_refresh_token'));
288289
});
289290

@@ -389,7 +390,7 @@ describe('public userAuth social auth', () => {
389390
});
390391

391392
test('handleSocialAuthCallback rejects when Google returns no email', async () => {
392-
redis.get.mockResolvedValueOnce(JSON.stringify({ projectId: 'project_1', provider: 'google' }));
393+
redis.get.mockResolvedValueOnce(JSON.stringify({ projectId: 'project_1', provider: 'google', callbackUrl: 'http://localhost:5173/auth/callback' }));
393394
mockProjectFindByIdChain.lean.mockResolvedValueOnce(makeProject());
394395

395396
global.fetch
@@ -407,14 +408,13 @@ describe('public userAuth social auth', () => {
407408

408409
await controller.handleSocialAuthCallback(req, res);
409410

410-
expect(res.status).toHaveBeenCalledWith(422);
411-
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
412-
error: expect.stringContaining('did not return an email address'),
413-
}));
411+
// P2: errors now redirect to frontend instead of JSON
412+
expect(res.redirect).toHaveBeenCalledWith(expect.stringContaining('error='));
413+
expect(res.redirect).toHaveBeenCalledWith(expect.stringContaining('did+not+return+an+email'));
414414
});
415415

416416
test('handleSocialAuthCallback rejects invalid Google id_token audience', async () => {
417-
redis.get.mockResolvedValueOnce(JSON.stringify({ projectId: 'project_1', provider: 'google' }));
417+
redis.get.mockResolvedValueOnce(JSON.stringify({ projectId: 'project_1', provider: 'google', callbackUrl: 'http://localhost:5173/auth/callback' }));
418418
mockProjectFindByIdChain.lean.mockResolvedValueOnce(makeProject());
419419

420420
global.fetch
@@ -432,10 +432,9 @@ describe('public userAuth social auth', () => {
432432

433433
await controller.handleSocialAuthCallback(req, res);
434434

435-
expect(res.status).toHaveBeenCalledWith(500);
436-
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
437-
error: 'Google id_token audience mismatch',
438-
}));
435+
// P2: errors now redirect to frontend instead of JSON
436+
expect(res.redirect).toHaveBeenCalledWith(expect.stringContaining('error='));
437+
expect(res.redirect).toHaveBeenCalledWith(expect.stringContaining('audience'));
439438
});
440439

441440
test('exchangeSocialRefreshToken returns refresh token and deletes exchange code', async () => {
@@ -506,4 +505,107 @@ describe('public userAuth social auth', () => {
506505
message: 'Invalid refresh token exchange payload',
507506
});
508507
});
508+
509+
// P2: Provider error forwarding
510+
test('handleSocialAuthCallback forwards provider error to frontend callback', async () => {
511+
redis.get.mockResolvedValueOnce(JSON.stringify({
512+
projectId: 'project_1',
513+
provider: 'github',
514+
callbackUrl: 'http://localhost:5173/auth/callback',
515+
}));
516+
517+
const req = makeReq({
518+
params: { provider: 'github' },
519+
query: {
520+
error: 'access_denied',
521+
error_description: 'The user denied the request',
522+
state: 'state_with_error',
523+
},
524+
});
525+
const res = makeRes();
526+
527+
await controller.handleSocialAuthCallback(req, res);
528+
529+
expect(res.redirect).toHaveBeenCalledWith(expect.stringContaining('error='));
530+
expect(res.redirect).toHaveBeenCalledWith(expect.stringContaining('The+user+denied'));
531+
});
532+
533+
// P1: Email collision when provider email is not verified
534+
test('handleSocialAuthCallback rejects if email exists but provider email not verified', async () => {
535+
redis.get.mockResolvedValueOnce(JSON.stringify({
536+
projectId: 'project_1',
537+
provider: 'github',
538+
callbackUrl: 'http://localhost:5173/auth/callback',
539+
}));
540+
mockProjectFindByIdChain.lean.mockResolvedValueOnce(makeProject());
541+
542+
const existingUser = { _id: 'existing_user', email: 'alice@example.com' };
543+
544+
// User not found by githubId, but found by email, then refetched after update
545+
mockUsersModel.findOne
546+
.mockResolvedValueOnce(null) // by githubId
547+
.mockResolvedValueOnce(existingUser) // by email
548+
.mockResolvedValueOnce({ ...existingUser, githubId: '123' }); // after updateOne
549+
550+
mockUsersModel.updateOne.mockResolvedValueOnce({ modifiedCount: 1 });
551+
552+
// GitHub returns an email that IS verified at provider
553+
global.fetch
554+
.mockResolvedValueOnce({
555+
ok: true,
556+
json: async () => ({ access_token: 'github_access_token' }),
557+
})
558+
.mockResolvedValueOnce({
559+
ok: true,
560+
json: async () => ({ id: 123, login: 'alice', avatar_url: '' }),
561+
})
562+
.mockResolvedValueOnce({
563+
ok: true,
564+
json: async () => ([{ email: 'alice@example.com', verified: true, primary: true }]),
565+
});
566+
567+
const req = makeReq({ params: { provider: 'github' }, query: { code: 'code_1', state: 'state_1' } });
568+
const res = makeRes();
569+
570+
await controller.handleSocialAuthCallback(req, res);
571+
572+
// With verified=true, should successfully link and redirect
573+
expect(res.redirect).toHaveBeenCalledWith(expect.stringContaining('rtCode='));
574+
expect(mockUsersModel.updateOne).toHaveBeenCalled();
575+
});
576+
577+
// P1: Email collision when provider email is NOT verified (rejection case)
578+
test('handleSocialAuthCallback rejects existing email when provider email unverified', async () => {
579+
redis.get.mockResolvedValueOnce(JSON.stringify({
580+
projectId: 'project_1',
581+
provider: 'google',
582+
callbackUrl: 'http://localhost:5173/auth/callback',
583+
}));
584+
mockProjectFindByIdChain.lean.mockResolvedValueOnce(makeProject());
585+
586+
// User not found by googleId, but found by email
587+
mockUsersModel.findOne
588+
.mockResolvedValueOnce(null) // by googleId
589+
.mockResolvedValueOnce({ _id: 'existing_user', email: 'alice@example.com' }); // by email
590+
591+
// Google returns unverified email (email_verified: false)
592+
global.fetch
593+
.mockResolvedValueOnce({
594+
ok: true,
595+
json: async () => ({ id_token: signGoogleIdToken({ email: 'alice@example.com', email_verified: false }) }),
596+
})
597+
.mockResolvedValueOnce({
598+
ok: true,
599+
json: async () => ({ keys: [GOOGLE_JWK] }),
600+
});
601+
602+
const req = makeReq({ params: { provider: 'google' }, query: { code: 'code_1', state: 'state_1' } });
603+
const res = makeRes();
604+
605+
await controller.handleSocialAuthCallback(req, res);
606+
607+
// Should redirect with error because email exists but not verified for linking
608+
expect(res.redirect).toHaveBeenCalledWith(expect.stringContaining('error='));
609+
expect(res.redirect).toHaveBeenCalledWith(expect.stringContaining('not+verified'));
610+
});
509611
});

0 commit comments

Comments
 (0)