feat(google-calendar): add backend OAuth2 connect/sync routes - #1804
feat(google-calendar): add backend OAuth2 connect/sync routes#1804ionfwsrijan wants to merge 1 commit into
Conversation
📝 WalkthroughWalkthroughThe backend adds Google Calendar OAuth connection, encrypted refresh-token storage, connection status reporting, and event creation. It validates event requests, mounts protected API routes, supports OAuth callbacks, and adds controller unit tests. ChangesGoogle Calendar integration
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant Client
participant GoogleCalendarRoutes
participant GoogleCalendarController
participant GoogleCalendarToken
participant Google
Client->>GoogleCalendarRoutes: Request calendar connection
GoogleCalendarRoutes->>GoogleCalendarController: Call OAuth handler
GoogleCalendarController->>GoogleCalendarToken: Store OAuth state
GoogleCalendarController->>Google: Redirect for authorization
Google-->>GoogleCalendarController: Return authorization code
GoogleCalendarController->>Google: Exchange code and retrieve account
GoogleCalendarController->>GoogleCalendarToken: Encrypt and store refresh token
Client->>GoogleCalendarRoutes: Submit calendar event
GoogleCalendarRoutes->>GoogleCalendarController: Validate event request
GoogleCalendarController->>GoogleCalendarToken: Load refresh token
GoogleCalendarController->>Google: Refresh access token and create event
Google-->>Client: Return created event
Possibly related PRs
Suggested labels: 🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (3 passed)
✨ Finishing Touches 💡 1🛠️ Fix failing CI checks 💡
🧪 Generate unit tests (beta)
Comment |
| } | ||
|
|
||
| try { | ||
| const authState = await GoogleCalendarAuthState.findOne({ state }); |
There was a problem hiding this comment.
Actionable comments posted: 5
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/controllers/googleCalendarController.js`:
- Around line 53-76: Update the OAuth callback’s auth-state lookup to use
findOneAndDelete with a createdAt cutoff of ten minutes before the current time,
so expired or already-consumed states redirect via the existing !authState path
before token exchange. Remove the later GoogleCalendarAuthState.deleteOne call,
and add coverage for expired states and repeated callbacks.
- Around line 46-53: Update the OAuth callback validation before
GoogleCalendarAuthState.findOne and googleCalendar.exchangeCode to require both
code and state to be strings, rejecting arrays and other non-string values with
the existing frontend redirect. Preserve the current handling for missing
parameters and the valid scalar-string flow.
In `@backend/Input_validators/ValidateGoogleCalendar.js`:
- Around line 4-10: Update createGoogleCalendarEventSchema with an object-level
refinement that requires endTime to be strictly later than startTime, assigning
failures to path ["endTime"]. Preserve the existing timestamp validation and
return a clear validation message so invalid durations are rejected by
middleware as HTTP 400.
In `@backend/utils/googleCalendar.js`:
- Line 9: Update the scope definition used by googleCalendarCallback to include
openid and email alongside the existing calendar.events scope, ensuring the
generated authorization URL requests the UserInfo identity scopes; add or update
coverage to verify all required scopes appear in that URL.
- Around line 42-85: Configure a shared Axios client with a bounded timeout and
use it for the requests in exchangeCode, refreshAccessToken, getGoogleUserInfo,
and createCalendarEvent. Preserve each request’s existing URL, payload, and
headers, and do not add automatic retries to createCalendarEvent.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 697448b6-ac73-4f64-abf0-f9af212f0bc0
📒 Files selected for processing (11)
backend/.env.examplebackend/Input_validators/ValidateGoogleCalendar.jsbackend/config/validateEnv.jsbackend/controllers/googleCalendarController.jsbackend/models/GoogleCalendarAuthState.jsbackend/models/GoogleCalendarToken.jsbackend/routes/googleCalendarRoutes.jsbackend/server.jsbackend/tests/googleCalendarController.unit.test.jsbackend/utils/encryption.jsbackend/utils/googleCalendar.js
| const { code, state } = req.query; | ||
|
|
||
| if (!code || !state) { | ||
| return res.redirect(FRONTEND_URL); | ||
| } | ||
|
|
||
| try { | ||
| const authState = await GoogleCalendarAuthState.findOne({ state }); |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Inspect whether the application changes Express query parsing.
rg -n -C 3 'query parser|app\.set\s*\(' backend
# Locate callback tests that should cover a non-string state value.
rg -n -C 3 'googleCalendarCallback|state:' backend/testsRepository: Canopus-Labs/PrepPilot
Length of output: 4292
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '%s\n' '--- controller ---'
sed -n '1,130p' backend/controllers/googleCalendarController.js
printf '%s\n' '--- server query-related setup ---'
sed -n '1,100p' backend/server.js
printf '%s\n' '--- package versions ---'
rg -n '"express"|"mongoose"|"qs"|"querystring"' package.json backend/package.json 2>/dev/null || true
printf '%s\n' '--- relevant tests ---'
sed -n '90,180p' backend/tests/googleCalendarController.unit.test.jsRepository: Canopus-Labs/PrepPilot
Length of output: 10882
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '%s\n' '--- auth-state schema ---'
fd -i 'GoogleCalendarAuthState' . --type f -x sh -c 'echo "--- $1"; cat -n "$1"' sh {}
printf '%s\n' '--- all query-parser configuration and req.query handling ---'
rg -n -C 3 'query[[:space:]]*parser|req\.query|querystring|qs|express\(' backend
printf '%s\n' '--- Express default parser behavior for relevant inputs ---'
node - <<'JS'
const querystring = require('node:querystring');
for (const input of [
'state%5B%24ne%5D=x&code=oauth-code',
'state=x&state=y&code=oauth-code',
'state=x&code=a&code=b',
'state=state-123&code=oauth-code',
]) {
console.log(input, '=>', JSON.stringify(querystring.parse(input)));
}
JSRepository: Canopus-Labs/PrepPilot
Length of output: 13439
🌐 Web query:
Mongoose 8 String schema query casting array value findOne operator injection official documentation
💡 Result:
In Mongoose 8, schema casting is a primary, built-in defense against operator injection, but it is not a complete solution on its own [1]. When you pass a value to a Mongoose query (like Model.findOne()), Mongoose attempts to cast that value to the type defined in your schema [2][3]. If a schema field is strictly typed (e.g., type: String or type: Number), Mongoose will attempt to cast the input to that type [3][1]. If an attacker provides an object containing MongoDB operators—such as { "$ne": "" }—instead of the expected primitive, casting will typically fail or result in a type-mismatched value, effectively neutralizing the injected operator [1]. However, this protection has critical limitations: 1. Schema-less or 'Mixed' Types: If a schema field is defined as Schema.Types.Mixed or is not explicitly typed, Mongoose may not enforce the expected type, allowing operator objects to pass through to MongoDB [1]. 2. Key-Side Injection: Schema casting validates values, not keys [1]. If your code dynamically constructs query filters using user-supplied keys (e.g., Model.findOne({ [req.body.field]: req.body.value })), an attacker can inject operators into the query structure itself regardless of value-side casting [1]. 3. Bypass of Sanitization: Mongoose provides a sanitizeFilter utility intended to strip operators from queries, but this mechanism has had vulnerabilities. For instance, flaws were identified where operators nested within $nor clauses could bypass sanitization [4]. 4. $where Operator Vulnerabilities: Mongoose has been subject to CVEs (e.g., CVE-2024-53900 and CVE-2025-23061) related to the improper use of the $where operator, which allows execution of arbitrary JavaScript within MongoDB queries [5][6][7]. Always ensure you are using a patched version of Mongoose (at least 8.9.5 or later) [6][7]. To effectively prevent operator injection: - Always validate and sanitize user input before it reaches your database layer [8][1]. - Use explicit allowlists for fields used in queries [8]. - Avoid passing raw request bodies (like req.body or req.query) directly into Mongoose methods [4][8]. - Use tools like Zod or Joi to enforce strict input schemas before the data reaches Mongoose [1]. - Avoid the $where operator entirely [5][7].
Citations:
- 1: https://safeguard.sh/resources/blog/nosql-injection-prevention-mongodb
- 2: https://mongoosejs.com/docs/8.x/docs/tutorials/query_casting.html
- 3: https://mongoosejs.com/docs/8.x/docs/schematypes.html
- 4: GHSA-wpg9-53fq-2r8h
- 5: GHSA-m7xq-9374-9rvx
- 6: https://nvd.nist.gov/vuln/detail/cve-2025-23061
- 7: GHSA-vg7j-7cwx-8wgw
- 8: https://mongoose.cloud/mongoose-security-checklist-injection-validation-query-hardening
Validate OAuth callback parameters as strings.
The default Express parser does not convert state[$ne]=x into a nested object. Repeated code or state parameters still produce arrays, and the current truthiness check accepts them. Reject non-string values before GoogleCalendarAuthState.findOne and googleCalendar.exchangeCode.
🧰 Tools
🪛 GitHub Check: CodeQL
[failure] 53-53: Database query built from user-controlled sources
This query object depends on a user-provided value.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/controllers/googleCalendarController.js` around lines 46 - 53, Update
the OAuth callback validation before GoogleCalendarAuthState.findOne and
googleCalendar.exchangeCode to require both code and state to be strings,
rejecting arrays and other non-string values with the existing frontend
redirect. Preserve the current handling for missing parameters and the valid
scalar-string flow.
Source: Linters/SAST tools
| const authState = await GoogleCalendarAuthState.findOne({ state }); | ||
| if (!authState) { | ||
| return res.redirect(FRONTEND_URL); | ||
| } | ||
|
|
||
| const redirectUri = googleCalendar.getRedirectUri(req); | ||
| const tokens = await googleCalendar.exchangeCode(code, redirectUri); | ||
| if (!tokens.refresh_token) { | ||
| return res.redirect(FRONTEND_URL); | ||
| } | ||
|
|
||
| const userInfo = await googleCalendar.getGoogleUserInfo(tokens.access_token); | ||
|
|
||
| await GoogleCalendarToken.findOneAndUpdate( | ||
| { userId: authState.userId }, | ||
| { | ||
| userId: authState.userId, | ||
| refreshTokenEnc: encrypt(tokens.refresh_token), | ||
| email: userInfo.email || "", | ||
| }, | ||
| { upsert: true, new: true }, | ||
| ); | ||
|
|
||
| await GoogleCalendarAuthState.deleteOne({ _id: authState._id }); |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -eu
printf '%s\n' '--- controller ---'
cat -n backend/controllers/googleCalendarController.js | sed -n '1,130p'
printf '%s\n' '--- auth-state model and indexes ---'
rg -n -S --glob '!node_modules' 'GoogleCalendarAuthState|createdAt|expires|TTL|expireAfter' backend models . | head -200
printf '%s\n' '--- query-parser and callback wiring ---'
rg -n -S --glob '!node_modules' 'query parser|queryParser|extended|googleCalendarController|callback|calendar' backend app.js server.js index.js 2>/dev/null | head -250
printf '%s\n' '--- relevant tests ---'
rg -n -S --glob '!node_modules' 'GoogleCalendar|google calendar|auth state|exchangeCode|getGoogleUserInfo|findOneAndDelete' . | head -250Repository: Canopus-Labs/PrepPilot
Length of output: 27502
🏁 Script executed:
#!/bin/bash
set -eu
printf '%s\n' '--- auth-state model ---'
cat -n backend/models/GoogleCalendarAuthState.js
printf '%s\n' '--- server parser configuration ---'
cat -n backend/server.js | sed -n '1,210p'
printf '%s\n' '--- callback tests ---'
cat -n backend/tests/googleCalendarController.unit.test.js | sed -n '75,175p'
printf '%s\n' '--- package versions ---'
cat backend/package.json 2>/dev/null | sed -n '1,180p' || true
rg -n '"mongoose"|"express"' package.json backend/package.json package-lock.json backend/package-lock.json 2>/dev/null | head -40Repository: Canopus-Labs/PrepPilot
Length of output: 15360
🏁 Script executed:
#!/bin/bash
set -eu
python3 - <<'PY'
from pathlib import Path
import re
controller = Path("backend/controllers/googleCalendarController.js").read_text()
model = Path("backend/models/GoogleCalendarAuthState.js").read_text()
find_one = controller.index("GoogleCalendarAuthState.findOne({ state })")
exchange = controller.index("googleCalendar.exchangeCode")
user_info = controller.index("googleCalendar.getGoogleUserInfo")
persist = controller.index("GoogleCalendarToken.findOneAndUpdate")
delete_one = controller.index("GoogleCalendarAuthState.deleteOne")
ttl = re.search(r"createdAt:\s*\{.*?expires:\s*(\d+)", model, re.S)
print({
"lookup_is_non_consuming_findOne": "findOne({ state })" in controller,
"delete_after_exchange": delete_one > exchange,
"delete_after_user_info": delete_one > user_info,
"delete_after_persistence": delete_one > persist,
"ttl_seconds": int(ttl.group(1)) if ttl else None,
"state_deleted_before_exchange": delete_one < exchange,
})
PYRepository: Canopus-Labs/PrepPilot
Length of output: 359
Consume OAuth state atomically and enforce its expiry in the query.
A TTL index does not enforce request-time expiry. This findOne leaves the state reusable until deletion and when processing fails.
Use findOneAndDelete with a ten-minute createdAt cutoff before the token exchange. Remove the later deleteOne call. Add tests for expired and repeated callbacks.
🧰 Tools
🪛 GitHub Check: CodeQL
[failure] 53-53: Database query built from user-controlled sources
This query object depends on a user-provided value.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/controllers/googleCalendarController.js` around lines 53 - 76, Update
the OAuth callback’s auth-state lookup to use findOneAndDelete with a createdAt
cutoff of ten minutes before the current time, so expired or already-consumed
states redirect via the existing !authState path before token exchange. Remove
the later GoogleCalendarAuthState.deleteOne call, and add coverage for expired
states and repeated callbacks.
| const createGoogleCalendarEventSchema = z.object({ | ||
| title: z.string().trim().min(1, "Title is required").max(200), | ||
| description: z.string().trim().max(2000).optional().default(""), | ||
| startTime: z.string().datetime({ offset: true }), | ||
| endTime: z.string().datetime({ offset: true }), | ||
| reminderMinutes: z.number().int().min(0).max(60).optional().default(15), | ||
| }); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -eu
printf '%s\n' '--- candidate files ---'
git ls-files | grep -E '(^|/)(ValidateGoogleCalendar\.js|package\.json|package-lock\.json|yarn\.lock|pnpm-lock\.yaml)$' || true
printf '%s\n' '--- validator ---'
file=$(git ls-files | grep 'backend/Input_validators/ValidateGoogleCalendar\.js$' | head -n1)
test -n "$file"
cat -n "$file"
printf '%s\n' '--- relevant usages ---'
rg -n -C 5 'createGoogleCalendarEventSchema|ValidateGoogleCalendar|startTime|endTime' backend --glob '*.js' --glob '*.ts' || true
printf '%s\n' '--- dependency declarations ---'
for f in $(git ls-files | grep -E '(^|/)(package\.json|package-lock\.json|yarn\.lock|pnpm-lock\.yaml)$'); do
echo "### $f"
rg -n -C 2 '"zod"|zod@' "$f" || true
doneRepository: Canopus-Labs/PrepPilot
Length of output: 8110
🏁 Script executed:
#!/bin/bash
set -eu
printf '%s\n' '--- controller error path ---'
cat -n backend/controllers/googleCalendarController.js | sed -n '120,190p'
printf '%s\n' '--- validation error helper ---'
cat -n backend/Input_validators/ValidateQuestions.js | sed -n '1,110p'
printf '%s\n' '--- route middleware order ---'
cat -n backend/routes/googleCalendarRoutes.js
printf '%s\n' '--- resolved Zod version ---'
python3 - <<'PY'
import json
from pathlib import Path
p = Path("backend/package-lock.json")
data = json.loads(p.read_text())
for key, value in data.get("packages", {}).items():
if key.endswith("node_modules/zod") or key == "node_modules/zod":
print(key, value.get("version"))
PY
printf '%s\n' '--- relevant tests ---'
rg -n -C 8 '500|invalid|validation|startTime|endTime|createGoogleCalendarEvent' backend/tests --glob '*.js' | head -n 240
printf '%s\n' '--- Date ordering probe ---'
node - <<'JS'
const cases = [
["2026-08-11T10:00:00.000Z", "2026-08-11T11:00:00.000Z"],
["2026-08-11T10:00:00.000Z", "2026-08-11T10:00:00.000Z"],
["2026-08-11T11:00:00.000Z", "2026-08-11T10:00:00.000Z"],
["2026-08-11T10:00:00+02:00", "2026-08-11T09:00:00Z"],
];
for (const [startTime, endTime] of cases) {
console.log({startTime, endTime, startMs: Date.parse(startTime), endMs: Date.parse(endTime), valid: new Date(endTime) > new Date(startTime)});
}
JSRepository: Canopus-Labs/PrepPilot
Length of output: 28412
🌐 Web query:
Zod 4 refine object schema error path option official documentation
💡 Result:
In Zod, you can customize the error path of a refinement by passing a configuration object as the second argument to the.refine method [1][2][3]. This object allows you to specify a path option, which is an array of strings or numbers representing the location of the error [2][3][4]. This feature is primarily used with object schemas to associate a cross-field validation error with a specific property, making it easier to target that field in your UI [1][5][4]. Example usage: const passwordForm = z.object({ password: z.string, confirm: z.string, }).refine((data) => data.password === data.confirm, { message: "Passwords don't match", path: ["confirm"], // The error will be associated with the 'confirm' field }); When validation fails, the resulting ZodError will include this path in its issues array [1][2][3]: { "code": "custom", "path": ["confirm"], "message": "Passwords don't match" } The configuration object passed to.refine accepts the following optional parameters: - message: A string to override the default error message [2][3]. - path: An array (string[] | number[]) to append to the error path [2][3]. - params: An object that can be used to customize the message within an error map [2][3].
Citations:
- 1: https://zod.dev/api
- 2: https://github.com/colinhacks/zod/blob/9575cfecbabeb149a4991f931cf24f685ea8c55e/README.md
- 3: https://github.com/colinhacks/zod/blob/c617ad3edbaf42971485f00042751771c335f9aa/README.md
- 4: https://mintlify.wiki/colinhacks/zod/concepts/refinements
- 5: Specify path for object refinement colinhacks/zod#69
🌐 Web query:
Google Calendar API events.insert end time must be after start time official documentation
💡 Result:
Yes, when using the Google Calendar API events.insert method, the event's end time must be after its start time [1][2]. The Google Calendar API requires both start and end times to be provided as objects [3][4]. If the end time is equal to or earlier than the start time, the API will reject the request with an error [1][5][2]. Common errors encountered when this condition is not met include a 400 Bad Request response containing a "timeRangeEmpty" reason and a message stating, "The specified time range is empty" [2][6]. To ensure your event is accepted, verify that your start and end date-time strings follow the RFC3339 format (e.g., 2026-08-11T10:00:00Z) and that the start value chronologically precedes the end value [1][7][6].
Citations:
- 1: https://stackoverflow.com/questions/10891425/how-to-use-google-calendar-apis-events-insert-command-properly
- 2: https://www.exchangetuts.com/how-to-use-google-calendar-apis-eventsinsert-command-properly-1639781482830526
- 3: https://developers.google.com/workspace/calendar/api/v3/reference/events/insert
- 4: https://developers.google.com/workspace/calendar/api/v3/reference/events
- 5: https://groups.google.com/g/google-calendar-api/c/t1aaXjgKFqU
- 6: https://stackguides.com/questions/10891425/how-to-use-google-calendar-apis-events-insert-command-properly
- 7: https://developers.google.com/workspace/calendar/api/concepts/events-calendars
Reject an endTime that is not after startTime.
Both timestamps can be valid while defining an empty or negative event duration. The controller maps the Calendar API failure to HTTP 500. Add an object-level refinement with path: ["endTime"] so the middleware returns HTTP 400.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/Input_validators/ValidateGoogleCalendar.js` around lines 4 - 10,
Update createGoogleCalendarEventSchema with an object-level refinement that
requires endTime to be strictly later than startTime, assigning failures to path
["endTime"]. Preserve the existing timestamp validation and return a clear
validation message so invalid durations are rejected by middleware as HTTP 400.
| const GOOGLE_CALENDAR_EVENTS_URL = | ||
| "https://www.googleapis.com/calendar/v3/calendars/primary/events"; | ||
|
|
||
| const GOOGLE_CALENDAR_SCOPE = "https://www.googleapis.com/auth/calendar.events"; |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -eu
printf '%s\n' '--- candidate files ---'
fd -i 'googleCalendar|calendar' . | head -80
printf '%s\n' '--- relevant symbols and calls ---'
rg -n -C 8 'GOOGLE_CALENDAR_SCOPE|googleCalendarCallback|UserInfo|userinfo|buildAuthUrl|axios\.(get|post)|timeout' . \
-g '!node_modules' -g '!dist' -g '!build'
printf '%s\n' '--- file map ---'
if command -v ast-grep >/dev/null 2>&1; then
ast-grep outline backend/utils/googleCalendar.js --view compact || true
fiRepository: Canopus-Labs/PrepPilot
Length of output: 41048
🏁 Script executed:
#!/bin/bash
set -eu
printf '%s\n' '--- backend/utils/googleCalendar.js ---'
cat -n backend/utils/googleCalendar.js
printf '%s\n' '--- callback implementation ---'
sed -n '45,90p' backend/controllers/googleCalendarController.js
printf '%s\n' '--- existing calendar tests ---'
sed -n '1,180p' backend/tests/googleCalendarController.unit.test.js
printf '%s\n' '--- behavioral scope probe ---'
python3 - <<'PY'
from pathlib import Path
from urllib.parse import parse_qs, urlparse
source = Path("backend/utils/googleCalendar.js").read_text()
scope_line = next(
line.strip()
for line in source.splitlines()
if line.startswith("const GOOGLE_CALENDAR_SCOPE =")
)
scope = scope_line.split("=", 1)[1].strip().rstrip(";").strip('"')
url = "https://accounts.google.com/o/oauth2/v2/auth?" + \
"scope=" + __import__("urllib.parse").parse.quote(scope, safe="")
parsed_scope = parse_qs(urlparse(url).query)["scope"][0]
print("scope_constant:", scope)
print("authorization_scope:", parsed_scope)
print("scope_tokens:", parsed_scope.split())
print("has_openid:", "openid" in parsed_scope.split())
print("has_email:", "email" in parsed_scope.split())
print("userinfo_call_has_timeout:", "timeout:" in source[source.index("const getGoogleUserInfo"):source.index("const createCalendarEvent")])
PYRepository: Canopus-Labs/PrepPilot
Length of output: 11333
🌐 Web query:
Google OpenID Connect UserInfo endpoint email claim openid email scope official documentation
💡 Result:
To receive the email claim from the Google OpenID Connect (OIDC) UserInfo endpoint, you must include the email scope in your authentication request [1][2]. Official Google documentation specifies the following regarding the email claim and scope: 1. Requirement: The email claim is only provided if the email scope is included in the authorization request [1][2]. 2. Endpoint: The UserInfo endpoint returns user information in a JSON object [1]. To locate the correct endpoint URL, you should use the userinfo_endpoint metadata value retrieved from Google's OpenID Connect discovery document [2]. 3. Claim Behavior: - The email claim contains the user's email address [1]. - If the email scope is present, Google will also provide an email_verified claim, which is a boolean indicating whether the email address has been verified [1]. 4. Important Recommendations: - Google explicitly warns against using the email claim as a primary identifier for a user, as a Google Account's email address can change over time [1][2]. - You should always use the sub (subject) field as the unique, immutable identifier for the user [1][2]. When making a request, your scope parameter must be a space-delimited list that includes openid and at least one other scope (such as email or profile) [1]. Accessing the UserInfo endpoint requires providing a valid access token in the authorization header [2].
Citations:
- 1: https://developers.google.com/identity/openid-connect/reference
- 2: https://developers.google.com/identity/openid-connect/openid-connect
Request the identity scopes required by the UserInfo call.
googleCalendarCallback persists userInfo.email, but the authorization request includes only calendar.events. Add openid email and test the generated authorization URL.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/utils/googleCalendar.js` at line 9, Update the scope definition used
by googleCalendarCallback to include openid and email alongside the existing
calendar.events scope, ensuring the generated authorization URL requests the
UserInfo identity scopes; add or update coverage to verify all required scopes
appear in that URL.
| const exchangeCode = async (code, redirectUri) => { | ||
| const { data } = await axios.post( | ||
| GOOGLE_TOKEN_URL, | ||
| new URLSearchParams({ | ||
| code, | ||
| client_id: process.env.GOOGLE_CLIENT_ID, | ||
| client_secret: process.env.GOOGLE_CLIENT_SECRET, | ||
| redirect_uri: redirectUri, | ||
| grant_type: "authorization_code", | ||
| }), | ||
| { headers: { "Content-Type": "application/x-www-form-urlencoded" } }, | ||
| ); | ||
| return data; | ||
| }; | ||
|
|
||
| const refreshAccessToken = async (refreshToken) => { | ||
| const { data } = await axios.post( | ||
| GOOGLE_TOKEN_URL, | ||
| new URLSearchParams({ | ||
| refresh_token: refreshToken, | ||
| client_id: process.env.GOOGLE_CLIENT_ID, | ||
| client_secret: process.env.GOOGLE_CLIENT_SECRET, | ||
| grant_type: "refresh_token", | ||
| }), | ||
| { headers: { "Content-Type": "application/x-www-form-urlencoded" } }, | ||
| ); | ||
| return data.access_token; | ||
| }; | ||
|
|
||
| const getGoogleUserInfo = async (accessToken) => { | ||
| const { data } = await axios.get(GOOGLE_USERINFO_URL, { | ||
| headers: { Authorization: `Bearer ${accessToken}` }, | ||
| }); | ||
| return data; | ||
| }; | ||
|
|
||
| const createCalendarEvent = async (accessToken, event) => { | ||
| const { data } = await axios.post(GOOGLE_CALENDAR_EVENTS_URL, event, { | ||
| headers: { | ||
| Authorization: `Bearer ${accessToken}`, | ||
| "Content-Type": "application/json", | ||
| }, | ||
| }); | ||
| return data; |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -eu
printf '%s\n' '--- target file outline ---'
if command -v ast-grep >/dev/null 2>&1; then
ast-grep outline backend/utils/googleCalendar.js || true
fi
printf '%s\n' '--- target file size ---'
wc -l backend/utils/googleCalendar.js
printf '%s\n' '--- target file ---'
cat -n backend/utils/googleCalendar.js
printf '%s\n' '--- axios timeout/configuration references ---'
rg -n -S 'axios|timeout|GOOGLE_TOKEN_URL|GOOGLE_USERINFO_URL|GOOGLE_CALENDAR_EVENTS_URL' \
backend package.json package-lock.json yarn.lock pnpm-lock.yaml 2>/dev/null || trueRepository: Canopus-Labs/PrepPilot
Length of output: 8607
🏁 Script executed:
#!/bin/bash
set -eu
printf '%s\n' '--- Google Calendar helper usages ---'
rg -n -S 'exchangeCode|refreshAccessToken|getGoogleUserInfo|createCalendarEvent|googleCalendar' \
backend --glob '!backend/package-lock.json'
printf '%s\n' '--- Axios global/default timeout configuration ---'
rg -n -S 'axios\.defaults|create\(\s*\{[^}]*timeout|timeout\s*:' . \
--glob '!node_modules/**' --glob '!backend/package-lock.json' --glob '!package-lock.json' \
|| true
printf '%s\n' '--- likely controller request paths ---'
rg -n -S 'google-calendar|exchangeCode|refreshAccessToken|getGoogleUserInfo|createCalendarEvent' \
backend/controllers backend/routes backend --glob '!backend/package-lock.json' \
| head -200Repository: Canopus-Labs/PrepPilot
Length of output: 9797
🏁 Script executed:
#!/bin/bash
set -eu
python3 - <<'PY'
from pathlib import Path
import re
path = Path("backend/utils/googleCalendar.js")
source = path.read_text()
calls = list(re.finditer(r"\baxios\.(get|post)\s*\(", source))
print(f"axios_http_call_count={len(calls)}")
for match in calls:
line = source.count("\n", 0, match.start()) + 1
end = source.find(");", match.start())
request = source[match.start(): end if end != -1 else len(source)]
print(f"line={line} method={match.group(1)} has_timeout={bool(re.search(r'\\btimeout\\s*:', request))}")
print(f"axios_default_assignment_count={len(re.findall(r'axios\\.defaults', source))}")
PY
printf '%s\n' '--- controller callback and event paths ---'
cat -n backend/controllers/googleCalendarController.js | sed -n '42,180p'Repository: Canopus-Labs/PrepPilot
Length of output: 469
🏁 Script executed:
#!/bin/bash
set -eu
python3 - <<'PY'
from pathlib import Path
import re
path = Path("backend/utils/googleCalendar.js")
source = path.read_text()
calls = list(re.finditer(r"\baxios\.(get|post)\s*\(", source))
print(f"axios_http_call_count={len(calls)}")
for match in calls:
line = source.count("\n", 0, match.start()) + 1
end = source.find(");", match.start())
request = source[match.start(): end if end != -1 else len(source)]
has_timeout = bool(re.search(r"\btimeout\s*:", request))
print(f"line={line} method={match.group(1)} has_timeout={has_timeout}")
print(f"axios_default_assignment_count={len(re.findall(r'axios\.defaults', source))}")
PY
printf '%s\n' '--- controller callback and event paths ---'
cat -n backend/controllers/googleCalendarController.js | sed -n '42,180p'Repository: Canopus-Labs/PrepPilot
Length of output: 433
🏁 Script executed:
#!/bin/bash
set -eu
python3 - <<'PY'
from pathlib import Path
import re
path = Path("backend/utils/googleCalendar.js")
source = path.read_text()
calls = list(re.finditer(r"\baxios\.(get|post)\s*\(", source))
default_assignments = len(re.findall(r"axios\.defaults", source))
print("axios_http_call_count=" + str(len(calls)))
for match in calls:
line = source.count("\n", 0, match.start()) + 1
end = source.find(");", match.start())
request = source[match.start(): end if end != -1 else len(source)]
has_timeout = bool(re.search(r"\btimeout\s*:", request))
print(
"line=" + str(line)
+ " method=" + match.group(1)
+ " has_timeout=" + str(has_timeout)
)
print("axios_default_assignment_count=" + str(default_assignments))
PY
printf '%s\n' '--- controller callback and event paths ---'
cat -n backend/controllers/googleCalendarController.js | sed -n '42,180p'Repository: Canopus-Labs/PrepPilot
Length of output: 5051
Set a bounded timeout for all four Google HTTP requests.
None of the four requests has a timeout. A stalled Google request can keep the OAuth callback or event-creation request open indefinitely. Use a configured Axios client with a bounded timeout. Do not automatically retry createCalendarEvent because the operation is non-idempotent.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/utils/googleCalendar.js` around lines 42 - 85, Configure a shared
Axios client with a bounded timeout and use it for the requests in exchangeCode,
refreshAccessToken, getGoogleUserInfo, and createCalendarEvent. Preserve each
request’s existing URL, payload, and headers, and do not add automatic retries
to createCalendarEvent.
|
@ionfwsrijan Address coderabbit and github action bot suggestions |
Problem
The Interview Prep page ships Google Calendar connect/sync functionality in the frontend, but the backend has no
/api/google-calendar/*routes at all. Every calendar action fails:frontend/src/utils/apiPaths.jsdefinesGOOGLE_CALENDAR.CONNECT(GET /api/google-calendar/connect),CALLBACK,STATUS, andEVENTS(POST /api/google-calendar/events).frontend/src/pages/InterviewPrep/InterviewPrep.jsxcalls/statuson every page load,/connectwhen the user clicks "Connect your Google Calendar", and/eventsto sync./statuslogsCalendar status erroron every visit, Connect shows "Unable to connect Google Calendar", Sync shows "Unable to sync Google Calendar event", and the calendar banner is permanently stuck in the empty "Connect" state.Fix
Added the missing backend implementation (multi-file):
backend/routes/googleCalendarRoutes.js—GET /connect,GET /callback,GET /status, andPOST /events, mounted at/api/google-calendarwithgeneralLimiterandprotecton the authed routes.POST /eventsmatches the route the current frontend calls (GOOGLE_CALENDAR.EVENTS).backend/controllers/googleCalendarController.js— OAuth2 flow:connectgenerates a single-use unguessable state and returns the Google auth URL;callbackexchanges the code, stores the refresh token encrypted per user, and redirects back to the app;statusreports whether the account is linked (and which email);eventsrefreshes the access token and creates a calendar event for the session.backend/models/GoogleCalendarToken.js— per-user encrypted refresh token storage (user-scoped).backend/models/GoogleCalendarAuthState.js— single-use OAuth state mapping with a 10-minute TTL so the callback (which cannot carry a Bearer header) resolves the connecting user securely.backend/utils/googleCalendar.js— minimal OAuth2 + Calendar API client (auth URL, code exchange, token refresh, user info, event creation) using the already-availableaxios, scoped tohttps://www.googleapis.com/auth/calendar.events.backend/utils/encryption.js— AES-256-GCM encryption for the stored Google refresh token.backend/Input_validators/ValidateGoogleCalendar.js— Zod validation for the event payload (title, description, ISO start/end times, reminder minutes).backend/server.js— mountsapp.use("/api/google-calendar", generalLimiter, googleCalendarRoutes).backend/config/validateEnv.jsandbackend/.env.example— document the optionalGOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET,GOOGLE_CALENDAR_ENCRYPTION_KEY, andGOOGLE_CALENDAR_CALLBACK_URLenv vars; when they are missing the integration reports itself as disabled instead of 404ing.Files changed
backend/server.jsbackend/routes/googleCalendarRoutes.jsbackend/controllers/googleCalendarController.jsbackend/models/GoogleCalendarToken.jsbackend/models/GoogleCalendarAuthState.jsbackend/utils/googleCalendar.jsbackend/utils/encryption.jsbackend/Input_validators/ValidateGoogleCalendar.jsbackend/tests/googleCalendarController.unit.test.jsbackend/config/validateEnv.jsbackend/.env.exampleTesting
backend/tests/googleCalendarController.unit.test.js(12 tests) covering connect (auth URL + state persistence), callback (code exchange, encrypted token storage, redirects), status (connected/not connected/disabled), and event creation (mapped payload, not-connected rejection, API failure).cd backend && npm test: the new tests and all existing tests pass except the pre-existingjobCache.boundedKeysfailures that also fail on cleanorigin/mainand are unrelated to this change.Closes #1793
Ready to merge.