Google OAuth¶
BottleCRM supports signing in with Google from both the web app (an authorization-code exchange with PKCE) and the mobile app (a Google ID token posted directly to the backend). Both are optional: the deployment works without them, using magic-link email sign-in instead, until you configure a Google OAuth client.
Registering an OAuth client¶
Create an OAuth 2.0 Client ID of type "Web application" in the Google Cloud Console. The redirect
URI it needs authorized is built by the frontend's login page
(frontend/src/routes/(no-layout)/login/+page.server.js) as ${GOOGLE_LOGIN_DOMAIN}/login;
GOOGLE_LOGIN_DOMAIN is a frontend environment variable (frontend/.env.example defaults it to
http://localhost:5181), so the value to register with Google is http://localhost:5181/login for
local development, or https://<your-frontend-host>/login in production. There is no separate
redirect registration needed for the mobile flow. It doesn't use a redirect at all (see
Mobile ID tokens below).
The frontend needs its own copy of the client ID; frontend/.env.example sets GOOGLE_CLIENT_ID
(client ID only; the comment in that file is explicit that "secret is on backend"), because it
builds the Google authorization URL client-side (client_id, redirect_uri, PKCE code_challenge,
and a CSRF state parameter). The client secret only ever lives on the backend.
Backend configuration¶
backend/crm/settings.py reads two Google-related variables, both defaulting to an empty string:
GOOGLE_CLIENT_ID = os.environ.get("GOOGLE_CLIENT_ID", "")
GOOGLE_CLIENT_SECRET = os.environ.get("GOOGLE_CLIENT_SECRET", "")
They are what the backend sends to Google's own token endpoint when exchanging an authorization code
(see below) and what it uses as the expected audience when verifying a mobile ID token. Leaving
either blank means every such exchange or verification fails, so sign-in with Google is effectively
off until both are set. .env.docker and .env.example both ship them blank, under an "optional.
Leave blank to disable" comment.
There is deliberately no redirect-URI setting. The redirect URI used in the token exchange is the
one the frontend sends in the request body on each call (see
The PKCE callback flow). A GOOGLE_REDIRECT_URI setting did exist for a
while, read into Django and referenced nowhere, which cost operators time configuring something
inert; it was removed rather than documented.
The PKCE callback flow¶
The web flow is the standard OAuth Authorization Code flow with PKCE, split across the frontend and
GoogleOAuthCallbackView:
login/+page.server.jsgenerates a PKCEcode_verifier/code_challengepair and a randomstate, stores the verifier and state in short-lived httpOnly cookies, and redirects the browser to Google's authorization endpoint with thecode_challenge.- Google redirects back to
/login?code=...&state=.... The frontend checks the returnedstateagainst the cookie (CSRF protection) and reads back the storedcode_verifier, then makes a server-to-server call:POST /api/auth/google/callback/(GoogleOAuthCallbackView, wired atbackend/common/urls.py:67), withcode,code_verifierandredirect_uriin the body. GoogleOAuthCallbackViewexchanges the code for tokens directly with Google (https://oauth2.googleapis.com/token), sendingGOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET, theredirect_uriandcode_verifierit received. This is the step that requires the client secret, which is why it has to happen on the backend rather than in the browser.- The view decodes the
id_tokenGoogle returns (its own code comment notes this is safe without a signature check specifically because the token came directly from Google over HTTPS in the previous step, not from an untrusted client) and requiresemail_verifiedto be true before trusting the email address at all, an unverified address never provisions or signs in to an account. - It gets-or-creates a
Userby email, rejects with 403 if the account is deactivated (user.is_active), and issues BottleCRM's own JWT pair viaOrgAwareRefreshToken.for_user_and_org(user, None). TheNoneorg means this token carries noorg_idclaim: the user still has to pick an organization (Org switching) before any org-scoped request will work.
Mobile ID tokens¶
The mobile app skips the redirect entirely: Google Sign-In on the device returns a Google-issued ID
token, and the app posts it straight to POST /api/auth/google/
(GoogleIdTokenView, wired at backend/common/urls.py:69) as {"idToken": "..."}. Unlike the web
callback above, this view does cryptographically verify the token, google.oauth2.id_token.
verify_oauth2_token(id_token_str, google_requests.Request(), settings.GOOGLE_CLIENT_ID), checking
the signature against Google's public keys and the audience against the backend's own
GOOGLE_CLIENT_ID, so a token issued for a different client is rejected. It applies the same
email_verified check and inactive-account check as the web flow, then returns JWTtoken,
refresh_token and the list of organizations the user already has a Profile in
({"id", "name", "role"} per org) so the mobile app can present an org picker without a second
round trip.