|
| 1 | +# Google Workspace SSO |
| 2 | + |
| 3 | +Let your team sign in to DeepSQL with their work Google accounts, and |
| 4 | +optionally turn off password sign-in entirely. |
| 5 | + |
| 6 | +DeepSQL verifies Google's `hd` (hosted domain) claim against an allowlist you |
| 7 | +control, so only domains you name can get in — a personal Gmail account cannot |
| 8 | +sign in even if someone types a matching email address. |
| 9 | + |
| 10 | +**Two things surprise most people, so they are called out up front:** |
| 11 | + |
| 12 | +1. The allowlist is a **database table**. Configuring the environment variables |
| 13 | + alone leaves SSO rejecting everyone. See [step 4](#4-allowlist-your-domain). |
| 14 | +2. SSO does **not** create accounts. A user who has never been added to DeepSQL |
| 15 | + gets `403 This account is not provisioned in DeepSQL`, even with a valid |
| 16 | + Workspace login. That is deliberate — see [Provisioning](#provisioning-users). |
| 17 | + |
| 18 | +--- |
| 19 | + |
| 20 | +## 1. Create the OAuth client |
| 21 | + |
| 22 | +OAuth clients are created in **Google Cloud Console** (`console.cloud.google.com`), |
| 23 | +*not* in the Workspace Admin console (`admin.google.com`). There is no `gcloud` |
| 24 | +command for this — the IAP OAuth Admin API was the closest thing and was shut |
| 25 | +down in March 2026, and it never supported custom redirect URIs anyway. |
| 26 | + |
| 27 | +In the Cloud Console, the relevant area is **Google Auth Platform**. It was |
| 28 | +previously a single "OAuth consent screen" page and is now split: |
| 29 | + |
| 30 | +| You want | Where it lives now | |
| 31 | +| --- | --- | |
| 32 | +| Internal / External | **Audience** | |
| 33 | +| Create an OAuth client | **Clients** | |
| 34 | +| App name, logo, support email | **Branding** | |
| 35 | +| Scopes | **Data Access** | |
| 36 | + |
| 37 | +Steps: |
| 38 | + |
| 39 | +1. Pick or create a project. It must belong to your Google Workspace |
| 40 | + **organization**, or the Internal option in step 2 is not offered. |
| 41 | +2. **Branding** — app name, user support email, developer contact. |
| 42 | +3. **Audience** — choose **Internal**. |
| 43 | +4. **Clients → Create client** |
| 44 | + - Application type: **Web application** |
| 45 | + - Authorized redirect URI, exactly: |
| 46 | + `https://deepsql.example.com/api/auth/google/callback` |
| 47 | + - Leave *Authorized JavaScript origins* empty — this is a server-side flow. |
| 48 | +5. Copy the **Client ID** and **Client secret**. |
| 49 | + |
| 50 | +### Choose Internal if you can |
| 51 | + |
| 52 | +**Internal** means Google itself refuses to issue a token to anyone outside your |
| 53 | +organization — the request never reaches DeepSQL. That sits on top of DeepSQL's |
| 54 | +own domain allowlist, so a mistake in one does not open the door. It also avoids |
| 55 | +the "unverified app" warning that External shows. |
| 56 | + |
| 57 | +> **Do not flip an existing project from External to Internal without checking |
| 58 | +> what already uses it.** The Audience setting is project-wide. If another |
| 59 | +> application in that project serves users outside your organization, switching |
| 60 | +> to Internal cuts them off immediately. Check **Overview → Metrics** for live |
| 61 | +> OAuth traffic first; if in doubt, create a separate project. |
| 62 | +
|
| 63 | +### One project can serve many apps |
| 64 | + |
| 65 | +`Audience` and `Branding` are per-project; **Clients** are not. Add one client |
| 66 | +per application and they share the Internal gate with no extra setup. |
| 67 | + |
| 68 | +Because Branding is shared, name it for the organization rather than one app — |
| 69 | +`Acme Internal` rather than `DeepSQL` — since every app in the project shows that |
| 70 | +same name on its consent screen. |
| 71 | + |
| 72 | +--- |
| 73 | + |
| 74 | +## 2. Configure DeepSQL |
| 75 | + |
| 76 | +```bash |
| 77 | +SECURITY_GOOGLE_ENABLED=true |
| 78 | +SECURITY_GOOGLE_CLIENT_ID=<client id> |
| 79 | +SECURITY_GOOGLE_CLIENT_SECRET=<client secret> |
| 80 | +SECURITY_GOOGLE_REDIRECT_URI=https://deepsql.example.com/api/auth/google/callback |
| 81 | +``` |
| 82 | + |
| 83 | +`SECURITY_GOOGLE_REDIRECT_URI` must match the value registered in the Cloud |
| 84 | +Console **character for character**, trailing slash included, or Google rejects |
| 85 | +the callback. |
| 86 | + |
| 87 | +--- |
| 88 | + |
| 89 | +## 3. Set your public URL |
| 90 | + |
| 91 | +```bash |
| 92 | +APP_PUBLIC_URL=https://deepsql.example.com |
| 93 | +``` |
| 94 | + |
| 95 | +**This one catches people.** There are two similarly named settings and they do |
| 96 | +different jobs: |
| 97 | + |
| 98 | +| Variable | Property | Used for | |
| 99 | +| --- | --- | --- | |
| 100 | +| `APP_PUBLIC_URL` | `app.public-url` | **every auth redirect**, including the Google callback, and invite emails | |
| 101 | +| `APP_BASE_URL` | `app.base-url` | the `deepsql login` CLI device-flow authorize URL | |
| 102 | + |
| 103 | +Both default to `http://localhost:3000`. Leave `APP_PUBLIC_URL` unset and Google |
| 104 | +sign-in appears to work, then dumps the user on `localhost:3000/dashboard` — the |
| 105 | +session cookie was written correctly, but the browser is sent nowhere useful. |
| 106 | +Set both on any real deployment. |
| 107 | + |
| 108 | +--- |
| 109 | + |
| 110 | +## 4. Allowlist your domain |
| 111 | + |
| 112 | +**SSO rejects every sign-in until you do this.** There is no environment |
| 113 | +variable for it; the allowlist lives in the `google_workspace_domain` table so |
| 114 | +it can be changed without a restart. |
| 115 | + |
| 116 | +```sql |
| 117 | +INSERT INTO google_workspace_domain (domain, enabled, created_at, updated_at) |
| 118 | +VALUES ('example.com', true, NOW(), NOW()); |
| 119 | +``` |
| 120 | + |
| 121 | +A sign-in is accepted only when the domain is present **and** enabled **and** |
| 122 | +Google's `hd` claim for that account matches it. Checking `hd` rather than the |
| 123 | +email suffix is what stops a personal account from impersonating a Workspace |
| 124 | +one. |
| 125 | + |
| 126 | +To revoke a domain later, set `enabled = false` — it takes effect immediately. |
| 127 | + |
| 128 | +--- |
| 129 | + |
| 130 | +## 5. Restart and verify |
| 131 | + |
| 132 | +Configuration is read at startup, and `docker restart` does **not** re-read |
| 133 | +`.env`: |
| 134 | + |
| 135 | +```bash |
| 136 | +docker compose up -d --force-recreate backend |
| 137 | +``` |
| 138 | + |
| 139 | +Check the flag is live: |
| 140 | + |
| 141 | +```bash |
| 142 | +curl -s https://deepsql.example.com/api/setup/status |
| 143 | +# {"setupComplete":true, ... ,"googleEnabled":true,"passwordLoginEnabled":true} |
| 144 | +``` |
| 145 | + |
| 146 | +`googleEnabled: true` makes the **Sign in with Google** button appear on the |
| 147 | +login page. |
| 148 | + |
| 149 | +--- |
| 150 | + |
| 151 | +## Provisioning users |
| 152 | + |
| 153 | +Google proves *who someone is*. It does not decide *whether they may use |
| 154 | +DeepSQL*. A user who has never been added gets: |
| 155 | + |
| 156 | +``` |
| 157 | +403 This account is not provisioned in DeepSQL |
| 158 | +``` |
| 159 | + |
| 160 | +This is intentional. Without it, anyone in your Workspace — contractors, |
| 161 | +interns, every department — could grant themselves access to a tool holding |
| 162 | +production database credentials. |
| 163 | + |
| 164 | +Add users through the admin UI (or `POST /admin/users`) **before** they sign in. |
| 165 | +On first successful SSO login DeepSQL links their Google identity to the |
| 166 | +existing account automatically; there is no separate linking step. |
| 167 | + |
| 168 | +--- |
| 169 | + |
| 170 | +## Optional: disable password sign-in |
| 171 | + |
| 172 | +Enabling SSO does **not** close the password path. `/auth/login` keeps working |
| 173 | +for every local account, so seed or demo credentials remain usable on an |
| 174 | +internet-facing install. To make SSO exclusive: |
| 175 | + |
| 176 | +```bash |
| 177 | +SECURITY_PASSWORD_ENABLED=false |
| 178 | +``` |
| 179 | + |
| 180 | +The login page then hides the password form and offers only Google. The backend |
| 181 | +refuses password logins before any credential comparison and records a |
| 182 | +`PASSWORD_LOGIN_FAILURE` audit event with `reason=password_login_disabled`. |
| 183 | + |
| 184 | +Defaults to `true`, so existing installs are unaffected. |
| 185 | + |
| 186 | +> **Every user must be able to sign in with Google before you set this.** Anyone |
| 187 | +> who has only ever used a password is locked out until they use the Google |
| 188 | +> button once. Setting this while `SECURITY_GOOGLE_ENABLED=false` leaves nobody |
| 189 | +> able to sign in at all; DeepSQL logs an ERROR at startup if you do. |
| 190 | +
|
| 191 | +--- |
| 192 | + |
| 193 | +## Troubleshooting |
| 194 | + |
| 195 | +| Symptom | Cause | |
| 196 | +| --- | --- | |
| 197 | +| No **Sign in with Google** button | `SECURITY_GOOGLE_ENABLED` is not `true`, or the backend was not recreated. Check `/api/setup/status`. | |
| 198 | +| `redirect_uri_mismatch` from Google | `SECURITY_GOOGLE_REDIRECT_URI` differs from the Cloud Console entry. Compare exactly — a trailing slash counts. | |
| 199 | +| `403 This Google Workspace domain is not allowed` | No enabled row in `google_workspace_domain`, or the account's `hd` claim does not match. Personal Gmail accounts have no `hd` and are always rejected. | |
| 200 | +| `403 This account is not provisioned in DeepSQL` | Authentication worked; the user does not exist in DeepSQL. Create them first. | |
| 201 | +| Lands on `localhost:3000` after sign-in | `APP_PUBLIC_URL` is unset. See [step 3](#3-set-your-public-url). | |
| 202 | +| Everyone locked out after `SECURITY_PASSWORD_ENABLED=false` | Set it back to `true`, recreate the backend, and confirm SSO works before disabling again. | |
| 203 | + |
| 204 | +To see what actually happened, the `security_event` table records every attempt |
| 205 | +with its outcome and reason: |
| 206 | + |
| 207 | +```sql |
| 208 | +SELECT created_at, event_type, outcome, email, event_metadata |
| 209 | +FROM security_event ORDER BY id DESC LIMIT 20; |
| 210 | +``` |
0 commit comments