Skip to content
OpenScribe brand logo Docs

Google OAuth & Multi-Account Google Calendar Setup

This guide explains how to configure Google Cloud, Google Auth Platform, and OpenScribe for primary user sign-in and multi-account read-only Google Calendar synchronization.

Throughout this guide, replace https://scribe.example.com with your own configured PUBLIC_URL (for example https://scribe.akyonlabs.com in production or http://127.0.0.1:8090 for local development).

Diagram: Google Auth Platform configuration panels for OpenScribe
Diagram: Google Auth Platform configuration panels for OpenScribeOpen full SVG diagram

1. Create a Dedicated Google Cloud Project

Create a dedicated Google Cloud project for your OpenScribe deployment rather than reusing a project that hosts other apps. Google Cloud enforces a strict 1:1 relationship between a project and its OAuth consent screen (Branding, Audience, and Data Access). Sharing a project across unrelated applications mixes application names, logos, authorized domains, and scope verification requirements.

Create the Project in Google Cloud Console

  1. Open console.cloud.google.com/projectcreate.
  2. Enter a Project name (for example, OpenScribe) and select your Billing account or Organization if applicable.
  3. Click Create and wait for Google Cloud to provision the project, then select it in the top project switcher.

Create the Project with gcloud CLI (Optional)

bash
export GCP_PROJECT_ID="openscribe-prod-$(date +%s)"
gcloud projects create "$GCP_PROJECT_ID" --name="OpenScribe"
gcloud config set project "$GCP_PROJECT_ID"

2. Enable the Google Calendar API

OpenScribe uses the Google Calendar v3 REST API (events.list) to fetch upcoming and past meetings across your connected Google accounts.

Enable the API in Google Cloud Console

  1. Open APIs & Services -> Library at console.cloud.google.com/apis/library.
  2. Search for Google Calendar API (calendar-json.googleapis.com).
  3. Click Google Calendar API and click Enable.

Enable the API with gcloud CLI (Optional)

bash
gcloud services enable calendar-json.googleapis.com --project="$GCP_PROJECT_ID"

Note: Google OAuth2 / OpenID Connect userinfo endpoints (oauth2.googleapis.com and openidconnect.googleapis.com) are enabled by default in every Google Cloud project.


Google replaced the legacy APIs & Services -> OAuth consent screen view with the unified Google Auth Platform console. Configure the four sections below in order.

3.1 Branding (console.cloud.google.com/auth/branding)

  1. Open console.cloud.google.com/auth/branding (or click Get started under Google Auth Platform -> Overview).
  2. Fill in App Information:
    • App name: OpenScribe
    • User support email: Select your primary administrator email address.
    • App logo (optional): Upload web/public/oauth-logo.png (512x512 PNG). Note that uploading a custom logo on an External app published In production triggers Google brand verification. Skip the logo if you want immediate unverified production use without waiting for brand review.
  3. Fill in App domain:
    • Application home page: https://scribe.example.com
    • Application privacy policy link: https://scribe.example.com/docs/privacy/ (do not point to a generic /docs index page, because Google OAuth review requires a dedicated public privacy policy disclosing Google user data handling). If your OAuth client lives inside a shared parent-domain Google Cloud project (such as akyonlabs), keep the shared umbrella privacy URL on Branding and ensure it covers or links to <PUBLIC_URL>/docs/privacy/.
    • Application terms of service link: https://scribe.example.com/docs/privacy/ (optional)
  4. Under Authorized domains, click Add domain and enter your registrable root domain without scheme or subdomain:
    • Example: If PUBLIC_URL is https://scribe.example.com, add example.com.
    • Example: If PUBLIC_URL is https://scribe.akyonlabs.com, add akyonlabs.com.
    • Do not add localhost or 127.0.0.1 under Authorized domains (Google automatically permits loopback origins for local testing).
  5. Enter your Developer contact information email address and click Save.

3.2 Audience (console.cloud.google.com/auth/audience)

Open console.cloud.google.com/auth/audience and choose your User type and Publishing status:

SettingBehavior & Token LifetimeWhen to Choose
User type: InternalOnly accounts inside your Google Workspace organization can sign in or connect calendars. Refresh tokens do not expire after 7 days, and no sensitive-scope verification is required.Best when every user and every linked calendar belongs to the same Google Workspace organization. Note that an Internal app cannot link a personal @gmail.com calendar.
User type: ExternalAny Google Account (both Google Workspace and personal @gmail.com accounts) can sign in and connect calendars. Required whenever you connect a personal Gmail calendar alongside a work calendar.Required for personal @gmail.com accounts or multi-organization calendar connections.
Publishing status: TestingOnly email addresses explicitly listed under Test users (up to 100 addresses) can authenticate. Important: When an External app is in Testing mode and requests calendar.readonly, Google automatically expires issued OAuth refresh tokens after 7 days.Good for initial setup or short-lived testing. If you stay in Testing, add every Google account you plan to link under Test users.
Publishing status: In productionIssued refresh tokens are persistent (they do not expire after 7 days unless revoked by the user). If you have not completed Google Sensitive Scope Verification for calendar.readonly, users see no warning during basic sign-in (openid, email, profile), but see Google hasn't verified this app when connecting Google Calendar (calendar.readonly). Users can click Advanced -> Go to example.com (unsafe) to grant consent (subject to Google's 100-user unverified cap) until sensitive-scope verification is approved.Recommended for self-hosters using External user type so linked calendar refresh tokens do not expire every 7 days.

3.3 Data Access / Scopes (console.cloud.google.com/auth/scopes) and Verification Center

  1. Open console.cloud.google.com/auth/scopes and click Add or remove scopes.
  2. Select the three non-sensitive identity scopes used for PocketBase user sign-in and calendar account identification:
    • openid
    • https://www.googleapis.com/auth/userinfo.email
    • https://www.googleapis.com/auth/userinfo.profile
  3. Search for calendar.readonly (or paste into Manually add scopes) and add the single sensitive read-only Calendar scope:
    • https://www.googleapis.com/auth/calendar.readonly
  4. Click Update, then click Save.
  5. To remove the Google hasn't verified this app interstitial during Connect Google Calendar for all users, open console.cloud.google.com/auth/verification (Verification Center) and complete Sensitive Scope Verification for https://www.googleapis.com/auth/calendar.readonly. Note that a green Your branding has been verified badge on Branding only verifies app identity (name, logo, and homepage/privacy links) for non-sensitive login scopes. Removing the warning on Calendar connection additionally requires declaring calendar.readonly in Data Access, providing a written read-only scope justification, and submitting a YouTube demonstration video showing the end-to-end OAuth consent screen and read-only event sync in OpenScribe.

OpenScribe never requests write permissions to your Google Calendar. It only reads calendar event metadata (summary, description, start, end, attendees, hangoutLink, conferenceData, recurringEventId) using calendar.readonly.


4. Create the Web Application OAuth Client (Clients)

OpenScribe uses a single Web application OAuth 2.0 Client ID for both PocketBase user sign-in and multi-account Google Calendar connections. Because each flow terminates at its own server handler, you must register both callback paths on that client.

  1. Open console.cloud.google.com/auth/clients and click Create client.
  2. Select Application type: Web application.
  3. Set Name to OpenScribe Web.
  4. Under Authorized JavaScript origins, click Add URI and add your exact origin (scheme, host, and port only, with no trailing slash and no path):
    • Production origin: https://scribe.example.com
    • Local development origin (optional): http://127.0.0.1:8090 and http://localhost:8090
  5. Under Authorized redirect URIs, click Add URI and register both callback endpoints:
PurposeProduction Redirect URI (PUBLIC_URL)Local Development Redirect URIHandled By
1. Primary User Sign-Inhttps://scribe.example.com/api/oauth2-redirecthttp://127.0.0.1:8090/api/oauth2-redirectPocketBase built-in OAuth2 (users collection in server/auth.go)
2. Multi-Account Calendar Linkhttps://scribe.example.com/api/openscribe/calendar/callbackhttp://127.0.0.1:8090/api/openscribe/calendar/callbackCustom Calendar OAuth handler (registerCalendarRoutes in server/calendar.go)
  1. Click Create.
  2. Copy the Client ID (GOOGLE_CLIENT_ID, ending in .apps.googleusercontent.com) and Client secret (GOOGLE_CLIENT_SECRET, starting with GOCSPX-) into your .env file or OpenShip project secrets.

5. Required Environment Variables

Configure the following variables in .env (with chmod 600 .env) or in your deployment secret manager:

dotenv
PUBLIC_URL=https://scribe.example.com
GOOGLE_CLIENT_ID=123456789012-abcdefghijklmnopqrstuvwxyz.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=GOCSPX-your-google-client-secret
OPENSCRIBE_ADMIN_EMAIL=you@example.com
PB_ENCRYPTION_KEY=0123456789abcdef0123456789abcdef
OPENSCRIBE_TOKEN_ENCRYPTION_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef

How OPENSCRIBE_TOKEN_ENCRYPTION_KEY Works (server/crypto.go)

  • OpenScribe never stores plaintext Google OAuth access or refresh tokens in SQLite (data.db). Every token bundle in calendar_accounts.encrypted_token is encrypted with AES-256-GCM using a random 12-byte nonce prepended to the ciphertext and encoded with base64.RawURLEncoding (encryptTokenPayload and decryptTokenPayload in server/crypto.go).
  • In production, generate a dedicated 32-byte key (64 hex characters or 43-44 base64 characters) via:
    bash
    openssl rand -hex 32
  • If OPENSCRIBE_TOKEN_ENCRYPTION_KEY is omitted in local development, encryptionKey() (server/crypto.go) derives a deterministic 32-byte key using SHA-256 over GOOGLE_CLIENT_SECRET|openscribe-calendar-token-key|OPENSCRIBE_ADMIN_EMAIL. Setting an explicit OPENSCRIBE_TOKEN_ENCRYPTION_KEY is strongly recommended so rotating GOOGLE_CLIENT_SECRET does not invalidate stored encrypted_token ciphertexts.

How PocketBase Syncs the Google Provider (server/auth.go)

On every server bootstrap (applyRuntimeSettings in server/auth.go), OpenScribe reads GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET from the environment and automatically configures the google OAuth2 provider on the PocketBase users collection with PKCE = true. You do not need to paste the Client ID or Client Secret manually inside the PocketBase Admin UI (/_/).

Furthermore, registerAuth (server/auth.go) checks every authenticating user against OPENSCRIBE_ADMIN_EMAIL (syncUserAdminFlag). Only a verified account whose email matches OPENSCRIBE_ADMIN_EMAIL receives admin = true.


6. How Multi-Account Google Calendar OAuth Works (server/calendar.go)

A single OpenScribe user can connect multiple Google Calendar accounts (for example, alice@company.com, alice@client.org, and alice@gmail.com). Each connected calendar is stored as a separate record in calendar_accounts with a unique index on (owner, email, calendar_id).

Diagram: Multi-account Google Calendar OAuth signed state and PKCE flow
Diagram: Multi-account Google Calendar OAuth signed state and PKCE flowOpen full SVG diagram

End-to-End Request Lifecycle

  1. Initiate Connection (POST /api/openscribe/calendar/connect):
    • The authenticated user clicks Connect Google Calendar in the web UI (web/src/App.tsx), sending POST /api/openscribe/calendar/connect with Authorization: Bearer <pb_jwt> and optional JSON {"calendar_id": "primary", "return_to": "/"}.
    • If GOOGLE_CLIENT_ID or GOOGLE_CLIENT_SECRET is not set, the server returns HTTP 400 (Google OAuth is not configured on the server.).
    • Otherwise, /api/openscribe/calendar/connect (server/calendar.go) generates:
      • A 16-byte random hex nonce
      • A 32-byte PKCE code_verifier and S256 code_challenge
      • A 24-byte browserBinding token stored in an HttpOnly, SameSite=Lax cookie named openscribe_calendar_oauth_bind (with Secure=true when PUBLIC_URL uses https:// or the request arrives over TLS) scoped to /api/openscribe/calendar/callback with a 15-minute MaxAge
      • A database record in oauth_transactions storing owner, nonce, browser_binding_hash (SHA-256 of the cookie value), code_verifier, calendar_id, return_to, and expires (15 minutes from creation)
      • A signed state string formatted as <base64url(JSON payload)>.<base64url(HMAC-SHA256 signature)>, where the JSON payload contains {"uid","nonce","cal","ret","exp"}
    • The endpoint returns 200 OK with {"auth_url": "https://accounts.google.com/o/oauth2/v2/auth?...", "redirect_uri": "<PUBLIC_URL>/api/openscribe/calendar/callback", "scopes": [...]} including access_type=offline, prompt=consent select_account, include_granted_scopes=true, and code_challenge_method=S256.
  2. User Consent at Google:
    • The browser navigates to auth_url. Because prompt=consent select_account is passed, Google always displays the account chooser (allowing the user to pick a second or third Google account) and issues a fresh refresh_token.
  3. Callback Verification (GET /api/openscribe/calendar/callback):
    • Google redirects the browser to <PUBLIC_URL>/api/openscribe/calendar/callback?code=...&state=....
    • /api/openscribe/calendar/callback (server/calendar.go) verifies the HMAC-SHA256 signature on state, checks that exp has not passed, looks up the oauth_transactions record by nonce, verifies that the openscribe_calendar_oauth_bind cookie SHA-256 matches browser_binding_hash, and atomically deletes the oauth_transactions record before calling Google so the nonce can never be replayed.
    • The server exchanges code + code_verifier at https://oauth2.googleapis.com/token, fetches the connected account's identity (sub, email, name) from https://openidconnect.googleapis.com/v1/userinfo, encrypts the token payload with AES-256-GCM, and upserts the calendar_accounts record with sync_status = "connected".
    • Finally, the server clears the openscribe_calendar_oauth_bind cookie and returns a 302 Found redirect to <PUBLIC_URL><return_to>?calendar_connected=<accountId> (or returns 200 OK JSON {"account": ...} when the request header Accept: application/json is sent).

Warning: Never Probe /api/openscribe/calendar/callback with Handcrafted URLs

Do not test /api/openscribe/calendar/callback by opening it directly in a browser or running curl with a synthetic query string such as ?code=test&state=test_state:

  1. Handcrafted state strings lack a valid <base64url(payload)>.<base64url(HMAC)> signature and immediately return HTTP 400 (Invalid OAuth state signature. or Malformed OAuth state.).
  2. Calling /api/openscribe/calendar/callback with a real state copied from another session without the matching openscribe_calendar_oauth_bind cookie fails with HTTP 400 (Missing browser binding cookie for OAuth callback. or OAuth callback does not match the initiating browser session.).
  3. Once a real nonce is verified and consumed, oauth_transactions deletes it atomically. Re-loading the same callback URL a second time returns HTTP 400 (OAuth state was already used or not found.).

7. Troubleshooting Common Google OAuth & Calendar Errors

Symptom or HTTP ErrorRoot CauseFix
Google Error 400: redirect_uri_mismatchEither PUBLIC_URL does not match the browser URL, or one of the two redirect URIs is missing in Google Auth Platform -> Clients.Check GET /api/openscribe/config and confirm public_url matches your domain without a trailing slash. Ensure both <PUBLIC_URL>/api/oauth2-redirect and <PUBLIC_URL>/api/openscribe/calendar/callback are listed under Authorized redirect URIs.
Google Error 403: access_denied (App is currently being tested)The Google Auth Platform Audience publishing status is set to Testing, and the Google account you selected is not listed under Test users.Either add that email address under Google Auth Platform -> Audience -> Test users, or click Publish app to move the consent screen to In production.
Calendar sync_status flips to "revoked" after 7 daysGoogle expires refresh tokens after 7 days for External apps in Testing mode that request calendar.readonly.Switch Google Auth Platform -> Audience -> Publishing status to In production, then click Connect Google Calendar again in OpenScribe to mint a persistent refresh token.
Calendar sync_status is "error" during GET /api/openscribe/calendar/events?sync=trueThe Google Calendar API (calendar-json.googleapis.com) is disabled in your Google Cloud project, or OPENSCRIBE_TOKEN_ENCRYPTION_KEY was changed after linking the account.Enable Google Calendar API at console.cloud.google.com/apis/library and re-link the calendar account in OpenScribe.
POST /api/openscribe/calendar/connect returns HTTP 400 (Google OAuth is not configured on the server.)GOOGLE_CLIENT_ID or GOOGLE_CLIENT_SECRET is empty in the server environment.Set both variables in .env (or OpenShip secrets) and restart the server. Verify google_oauth_enabled: true in GET /api/openscribe/config.
GET /api/openscribe/calendar/callback returns HTTP 400 (Missing browser binding cookie for OAuth callback. or OAuth callback does not match the initiating browser session.)The browser started /api/openscribe/calendar/connect on one host (for example http://localhost:8090) while PUBLIC_URL redirected the callback to a different host (for example http://127.0.0.1:8090), preventing the openscribe_calendar_oauth_bind cookie from being sent.Access the OpenScribe web UI from the exact hostname configured in PUBLIC_URL.
Google hasn't verified this app interstitial appears during Connect Google Calendar, even though basic Sign-In has no warningBasic Sign-In (web/src/api.ts) requests only non-sensitive openid, email, and profile scopes, whereas Connect Google Calendar (server/calendar.go) requests https://www.googleapis.com/auth/calendar.readonly, which Google classifies as a Sensitive scope. Brand verification alone does not approve sensitive scopes.Add openid, userinfo.email, userinfo.profile, and https://www.googleapis.com/auth/calendar.readonly under Google Auth Platform -> Data Access, ensure <PUBLIC_URL>/docs/privacy/ is live and linked in your privacy policy, and complete Sensitive Scope Verification (scope justification + YouTube demo video) in Verification Center. Until approved, authorized users can click Advanced -> Go to ... (unsafe) to complete consent.