|
| 1 | +--- |
| 2 | +name: constructive-oauth |
| 3 | +description: "OAuth/SSO configuration and Identity Providers management — configure social login, manage identity providers, debug OAuth flows. Use when asked to 'configure OAuth', 'setup SSO', 'identity provider', 'GitHub login', 'social login', 'OAuth not working', or when working with authentication providers." |
| 4 | +metadata: |
| 5 | + author: constructive-io |
| 6 | + version: "1.0.0" |
| 7 | +--- |
| 8 | + |
| 9 | +# Constructive OAuth |
| 10 | + |
| 11 | +OAuth/SSO configuration and Identity Providers management. |
| 12 | + |
| 13 | +## When to Apply |
| 14 | + |
| 15 | +Use this skill when: |
| 16 | +- Configuring OAuth identity providers (GitHub, Google, Apple, etc.) |
| 17 | +- Debugging OAuth login flow (PROVIDER_NOT_CONFIGURED, EMAIL_NOT_VERIFIED) |
| 18 | +- Managing auth settings (oauthEnabled, allowIdentitySignIn, etc.) |
| 19 | +- Understanding identity providers and membership relationships |
| 20 | + |
| 21 | +## Architecture Overview |
| 22 | + |
| 23 | +``` |
| 24 | +Identity Providers (App-level) |
| 25 | + ├── github, google, apple... (social login) |
| 26 | + └── Shared across all users |
| 27 | +``` |
| 28 | + |
| 29 | +**Current Design**: Identity providers are configured at app-level, shared by all users. |
| 30 | + |
| 31 | +## Third-Party Provider Setup |
| 32 | + |
| 33 | +### Callback URL Format |
| 34 | + |
| 35 | +``` |
| 36 | +Platform: https://auth.your-app.com/auth/callback/{provider} |
| 37 | +Tenant: https://auth-{tenant-slug}.your-app.com/auth/callback/{provider} |
| 38 | +``` |
| 39 | + |
| 40 | +**Platform vs Tenant**: Each has its own OAuth App registration with matching callback URL. |
| 41 | + |
| 42 | +### GitHub OAuth App |
| 43 | + |
| 44 | +1. Go to **GitHub → Settings → Developer settings → OAuth Apps → New OAuth App** |
| 45 | + |
| 46 | +2. Configure: |
| 47 | + |
| 48 | + **Platform**: |
| 49 | + | Field | Value | |
| 50 | + |-------|-------| |
| 51 | + | Application name | Your App Name | |
| 52 | + | Homepage URL | `https://your-app.com` | |
| 53 | + | Authorization callback URL | `https://auth.your-app.com/auth/callback/github` | |
| 54 | + |
| 55 | + **Tenant**: |
| 56 | + | Field | Value | |
| 57 | + |-------|-------| |
| 58 | + | Application name | Your App Name - {Tenant} | |
| 59 | + | Homepage URL | `https://{tenant-slug}.your-app.com` | |
| 60 | + | Authorization callback URL | `https://auth-{tenant-slug}.your-app.com/auth/callback/github` | |
| 61 | + |
| 62 | +3. Copy **Client ID** and generate **Client Secret** |
| 63 | + |
| 64 | +### Google OAuth |
| 65 | + |
| 66 | +1. Go to **Google Cloud Console → APIs & Services → Credentials → Create OAuth Client ID** |
| 67 | + |
| 68 | +2. Configure: |
| 69 | + |
| 70 | + **Platform**: |
| 71 | + | Field | Value | |
| 72 | + |-------|-------| |
| 73 | + | Application type | Web application | |
| 74 | + | Authorized redirect URIs | `https://auth.your-app.com/auth/callback/google` | |
| 75 | + |
| 76 | + **Tenant**: |
| 77 | + | Field | Value | |
| 78 | + |-------|-------| |
| 79 | + | Application type | Web application | |
| 80 | + | Authorized redirect URIs | `https://auth-{tenant-slug}.your-app.com/auth/callback/google` | |
| 81 | + |
| 82 | +3. Copy **Client ID** and **Client Secret** |
| 83 | + |
| 84 | +### Apple Sign In |
| 85 | + |
| 86 | +1. Go to **Apple Developer → Certificates, Identifiers & Profiles → Identifiers** |
| 87 | + |
| 88 | +2. Create App ID with Sign In with Apple capability |
| 89 | + |
| 90 | +3. Create Services ID: |
| 91 | + |
| 92 | + **Platform**: |
| 93 | + | Field | Value | |
| 94 | + |-------|-------| |
| 95 | + | Return URLs | `https://auth.your-app.com/auth/callback/apple` | |
| 96 | + |
| 97 | + **Tenant**: |
| 98 | + | Field | Value | |
| 99 | + |-------|-------| |
| 100 | + | Return URLs | `https://auth-{tenant-slug}.your-app.com/auth/callback/apple` | |
| 101 | + |
| 102 | +### URL Patterns |
| 103 | + |
| 104 | +OAuth flow uses the `auth` subdomain from `services_public.domains`: |
| 105 | + |
| 106 | +``` |
| 107 | +Database schema: |
| 108 | + domain: "localhost" | "your-app.com" |
| 109 | + subdomain: "auth" | "auth-{tenant-slug}" | null |
| 110 | +``` |
| 111 | + |
| 112 | +**Start OAuth Flow** (user clicks "Login with GitHub"): |
| 113 | +``` |
| 114 | +https://{subdomain}.{domain}/auth/oauth/{provider_slug}?redirect_uri={success_url} |
| 115 | +
|
| 116 | +Platform: https://auth.your-app.com/auth/oauth/github?redirect_uri=/dashboard |
| 117 | +Tenant: https://auth-{tenant-slug}.your-app.com/auth/oauth/github?redirect_uri=/dashboard |
| 118 | +Local: https://auth.localhost:3000/auth/oauth/github?redirect_uri=/dashboard |
| 119 | +``` |
| 120 | + |
| 121 | +Query parameters: |
| 122 | +- `redirect_uri`: URL to redirect after successful login (default: `/`) |
| 123 | + |
| 124 | +**Callback URL** (configure in provider's developer console): |
| 125 | +``` |
| 126 | +https://{subdomain}.{domain}/auth/callback/{provider_slug} |
| 127 | +
|
| 128 | +Platform: https://auth.your-app.com/auth/callback/github |
| 129 | +Tenant: https://auth-{tenant-slug}.your-app.com/auth/callback/github |
| 130 | +Local: https://auth.localhost:3000/auth/callback/github |
| 131 | +``` |
| 132 | + |
| 133 | +**Note**: For tenant-specific subdomains, you may need to register a wildcard callback URL or multiple callback URLs in the provider's console. |
| 134 | + |
| 135 | +## REST API Endpoints |
| 136 | + |
| 137 | +API endpoints are accessed via the `auth` subdomain. The subdomain determines which tenant database to operate on: |
| 138 | + |
| 139 | +``` |
| 140 | +Base URL: https://{auth-subdomain}.{domain} |
| 141 | +
|
| 142 | +Platform: https://auth.your-app.com |
| 143 | +Tenant: https://auth-{tenant-slug}.your-app.com |
| 144 | +Local: https://auth.localhost:3000 |
| 145 | +``` |
| 146 | + |
| 147 | +### Identity Providers |
| 148 | + |
| 149 | +``` |
| 150 | +GET {base}/identity-providers # List all providers |
| 151 | +GET {base}/identity-providers/:slug # Get single provider |
| 152 | +PATCH {base}/identity-providers/:slug # Update provider config |
| 153 | +POST {base}/identity-providers/:slug/rotate-secret # Rotate client secret |
| 154 | +``` |
| 155 | + |
| 156 | +### Auth Settings |
| 157 | + |
| 158 | +``` |
| 159 | +GET {base}/app-settings-auth # Get auth settings |
| 160 | +PATCH {base}/app-settings-auth # Update auth settings |
| 161 | +``` |
| 162 | + |
| 163 | +**Example** (local development): |
| 164 | +```http |
| 165 | +GET https://auth.localhost:3000/identity-providers |
| 166 | +GET https://auth.localhost:3000/app-settings-auth |
| 167 | +``` |
| 168 | + |
| 169 | +## Configuration Flow |
| 170 | + |
| 171 | +### 1. Create Identity Provider |
| 172 | + |
| 173 | +```http |
| 174 | +POST /identity-providers |
| 175 | +Content-Type: application/json |
| 176 | +
|
| 177 | +{ |
| 178 | + "slug": "github", |
| 179 | + "kind": "oauth2", |
| 180 | + "displayName": "GitHub", |
| 181 | + "enabled": true, |
| 182 | + "clientId": "<GITHUB_CLIENT_ID>", |
| 183 | + "pkceEnabled": true |
| 184 | +} |
| 185 | +``` |
| 186 | + |
| 187 | +### 2. Set Client Secret |
| 188 | + |
| 189 | +```http |
| 190 | +POST /identity-providers/github/rotate-secret |
| 191 | +Content-Type: application/json |
| 192 | +
|
| 193 | +{ |
| 194 | + "clientSecret": "<GITHUB_CLIENT_SECRET>" |
| 195 | +} |
| 196 | +``` |
| 197 | + |
| 198 | +### 3. Configure Auth Settings |
| 199 | + |
| 200 | +```http |
| 201 | +PATCH /app-settings-auth |
| 202 | +Content-Type: application/json |
| 203 | +
|
| 204 | +{ |
| 205 | + "oauthEnabled": true, |
| 206 | + "oauthRequireVerifiedEmail": false, |
| 207 | + "allowIdentitySignIn": true, |
| 208 | + "allowIdentitySignUp": true |
| 209 | +} |
| 210 | +``` |
| 211 | + |
| 212 | +### 4. Test Login |
| 213 | + |
| 214 | +``` |
| 215 | +1. Start: GET https://auth.localhost:3000/auth/oauth/github |
| 216 | +2. Redirect: → GitHub authorization page |
| 217 | +3. Callback: → https://auth.localhost:3000/auth/callback/github |
| 218 | +4. Result: → Session cookie set, user logged in |
| 219 | +``` |
| 220 | + |
| 221 | +## Auth Settings Reference |
| 222 | + |
| 223 | +| Field | Type | Description | |
| 224 | +|-------|------|-------------| |
| 225 | +| `oauthEnabled` | boolean | Enable OAuth login globally | |
| 226 | +| `oauthRequireVerifiedEmail` | boolean | Require verified email from IdP | |
| 227 | +| `allowIdentitySignIn` | boolean | Allow existing users to sign in via OAuth | |
| 228 | +| `allowIdentitySignUp` | boolean | Allow new user registration via OAuth | |
| 229 | +| `oauthStateMaxAge` | interval | OAuth state token expiry | |
| 230 | +| `oauthErrorRedirectPath` | string | Redirect path on OAuth error (default: `/auth/error`) | |
| 231 | + |
| 232 | +### Redirect Behavior |
| 233 | + |
| 234 | +| Scenario | Redirect To | |
| 235 | +|----------|-------------| |
| 236 | +| Success | `redirect_uri` query parameter (default: `/`) | |
| 237 | +| Error | `oauthErrorRedirectPath` setting (default: `/auth/error`) | |
| 238 | + |
| 239 | +**Configure error redirect**: |
| 240 | +```http |
| 241 | +PATCH https://auth.localhost:3000/app-settings-auth |
| 242 | +Content-Type: application/json |
| 243 | +
|
| 244 | +{ |
| 245 | + "oauthErrorRedirectPath": "/login?error=oauth" |
| 246 | +} |
| 247 | +``` |
| 248 | + |
| 249 | +Error redirect includes query params: `?error={code}&provider={slug}&error_description={msg}` |
| 250 | + |
| 251 | +Example error redirect: |
| 252 | +``` |
| 253 | +/login?error=oauth&error=EMAIL_NOT_VERIFIED&provider=github&error_description=Email+not+verified |
| 254 | +``` |
| 255 | + |
| 256 | +## Common Issues |
| 257 | + |
| 258 | +### PROVIDER_NOT_CONFIGURED |
| 259 | + |
| 260 | +**Cause**: Provider not configured or cache not refreshed |
| 261 | + |
| 262 | +**Debug**: |
| 263 | +```sql |
| 264 | +SELECT slug, enabled, client_id, client_secret_id |
| 265 | +FROM <private_schema>.identity_providers |
| 266 | +WHERE slug = 'github'; |
| 267 | +``` |
| 268 | + |
| 269 | +**Solution**: |
| 270 | +- Ensure both `client_id` and `client_secret_id` are not NULL |
| 271 | +- Wait 5 minutes for cache expiry, or restart GraphQL server |
| 272 | + |
| 273 | +### EMAIL_NOT_VERIFIED |
| 274 | + |
| 275 | +**Cause**: `oauth_require_verified_email = true` but user email not verified |
| 276 | + |
| 277 | +**Solution**: |
| 278 | +```http |
| 279 | +PATCH /app-settings-auth |
| 280 | +Content-Type: application/json |
| 281 | +
|
| 282 | +{ "oauthRequireVerifiedEmail": false } |
| 283 | +``` |
| 284 | + |
| 285 | +### Cache Not Refreshing |
| 286 | + |
| 287 | +**Key Code** (`create-loader.ts`): |
| 288 | +```typescript |
| 289 | +const cache = new LRUCache({ |
| 290 | + ttl: opts.ttlMs ?? DEFAULT_TTL_MS, |
| 291 | + updateAgeOnGet: false, // Important: false = TTL from first set |
| 292 | +}); |
| 293 | +``` |
| 294 | + |
| 295 | +- `updateAgeOnGet: true` → TTL resets on every read, never expires |
| 296 | +- `updateAgeOnGet: false` → TTL starts from first set, expires after 5 min |
| 297 | + |
| 298 | +**Identity providers loader TTL**: 5 minutes (`ttlMs: 5 * 60_000`) |
| 299 | + |
| 300 | +## Key Files |
| 301 | + |
| 302 | +| File | Description | |
| 303 | +|------|-------------| |
| 304 | +| `graphql/server/src/middleware/identity-providers.ts` | REST API middleware | |
| 305 | +| `graphql/server/src/middleware/app-settings-auth.ts` | Auth settings middleware | |
| 306 | +| `graphql/server/src/middleware/oauth.ts` | OAuth callback handler | |
| 307 | +| `packages/express-context/src/loaders/identity-providers.ts` | Provider loader | |
| 308 | +| `packages/express-context/src/loaders/create-loader.ts` | Loader factory | |
| 309 | +| `constructive-db/.../identity_providers_module.sql` | DB schema generator | |
| 310 | + |
| 311 | +## Known Issues |
| 312 | + |
| 313 | +### rotate_identity_provider_platform_secret Function Bug |
| 314 | + |
| 315 | +**Problem**: INSERT to platform_secrets missing `database_id` column |
| 316 | + |
| 317 | +**Workaround**: Use direct SQL to insert secret |
| 318 | + |
| 319 | +```sql |
| 320 | +INSERT INTO constructive_store_private.platform_secrets |
| 321 | + (database_id, namespace_id, algo, key_id, value) |
| 322 | +VALUES ( |
| 323 | + '<DATABASE_ID>', |
| 324 | + '<NAMESPACE_ID>', |
| 325 | + 'pgp', |
| 326 | + '<KEY_ID>', |
| 327 | + pgp_sym_encrypt(encode('<CLIENT_SECRET>', 'hex'), '<KEY_ID>') |
| 328 | +); |
| 329 | + |
| 330 | +UPDATE <private_schema>.identity_providers |
| 331 | +SET client_secret_id = '<SECRET_ID>' |
| 332 | +WHERE slug = 'github'; |
| 333 | +``` |
| 334 | + |
| 335 | +## Login Flow |
| 336 | + |
| 337 | +``` |
| 338 | +OAuth Login |
| 339 | + ↓ |
| 340 | +sign_in_identity / sign_up_identity |
| 341 | + ↓ |
| 342 | +Create user (users table) |
| 343 | + ↓ |
| 344 | +Create app_membership (membership_type=1) |
| 345 | + ↓ |
| 346 | +Session cookie set → User logged in |
| 347 | +``` |
| 348 | + |
| 349 | +## Cross-References |
| 350 | + |
| 351 | +- **Authentication basics:** [`constructive-auth`](../constructive-auth/SKILL.md) |
| 352 | +- **Permissions & RLS:** [`constructive-security`](../constructive-security/SKILL.md) |
| 353 | +- **Multi-tenant entities:** [`constructive-entities`](../constructive-entities/SKILL.md) |
0 commit comments