Skip to main content

OAuth Providers

This guide walks through registering OAuth 2.0 / OpenID Connect apps for the four providers Orkestra supports out of the box: Google, Apple, GitHub, and Discord. Email/password login works without any of these — OAuth is opt-in.

:::info Where credentials live As of the auth-module ConfigService refactor (PR-C), OAuth client IDs, secrets, and redirect URLs are runtime configuration owned by the auth module. The recommended workflow:

  1. (Optional) Seed env vars on a fresh install — the auth module reads OAUTH_<PROVIDER>_* on first boot and writes them into module_configs in MongoDB.
  2. Manage them from then on at /admin/modules/auth in the operator console. Secrets are AES-256-GCM encrypted at rest.

Env-var names follow the convention OAUTH_<PROVIDER>_<FIELD> (e.g. OAUTH_GOOGLE_CLIENT_ID). Older docs that show GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET without the OAUTH_ prefix are stale.

See backend/internal/core/auth/AGENTS.md for the canonical field-by-field schema. :::

Prerequisites​

  • Access to the respective provider developer portals (Google Cloud Console, Apple Developer, GitHub, Discord)
  • Orkestra backend running and reachable at your operator and client hosts. Set ALLOW_LOCALHOST_REDIRECTS=true in docker/.env for development; do not ship that to production.

Google OAuth setup​

Step 1: access Google Cloud Console​

  1. Go to Google Cloud Console.
  2. Sign in with your Google account.
  3. Create a new project or select an existing one.

Step 2: enable required APIs​

  1. Navigate to APIs & Services → Library.
  2. Search for and enable:
    • Google+ API
    • Google Identity Toolkit API
  1. Go to APIs & Services → OAuth consent screen.
  2. Choose External user type (unless for internal organization).
  3. Fill in the required information:
    • App name: Orkestra
    • User support email: your support email
    • App logo: upload your logo (optional)
    • Application home page: https://yourdomain.com
    • Authorized domains: add your domain(s)
    • Developer contact information: your email
  4. Add scopes: openid, email, profile.
  5. Save and continue.

Step 4: create OAuth 2.0 credentials​

  1. Navigate to APIs & Services → Credentials.
  2. Click Create Credentials → OAuth client ID.
  3. Select Web application as the application type.
  4. Configure:
    • Name: Orkestra Web Client
    • Authorized JavaScript origins:
      • https://console.orkestra.com (operator, prod)
      • https://api.orkestra.com (client, prod)
      • http://localhost:8080 (dev, operator)
      • http://client.localhost:8081 (dev, client)
    • Authorized redirect URI (one callback, on the operator backend, not the frontend):
      • https://console.orkestra.com/v1/auth/oauth/google/callback
      • http://localhost:3000/v1/auth/oauth/google/callback (dev, operator) The signed OAuth state selects the operator or client auth service after this callback, so client-tier flows do not need a client-host callback URI.
  5. Click Create.
  6. Save the Client ID and Client Secret — paste them into /admin/modules/auth under the Google group (or set OAUTH_GOOGLE_CLIENT_ID / OAUTH_GOOGLE_CLIENT_SECRET as seed env vars on first boot).

Step 5: mobile app configuration (if needed)​

  1. Create another OAuth client ID.
  2. Select iOS or Android as the application type.
  3. For iOS: enter your Bundle ID.
  4. For Android: enter your Package name and your SHA-1 certificate fingerprint.
  5. Save the configuration.

Apple Sign In setup​

Step 1: access Apple Developer account​

  1. Go to Apple Developer.
  2. Sign in with your Apple ID.
  3. Ensure you have an active Developer Program membership.

Step 2: register App ID​

  1. Navigate to Certificates, Identifiers & Profiles.
  2. Click Identifiers → + button.
  3. Select App IDs and click Continue.
  4. Select App and click Continue.
  5. Fill in:
    • Description: Orkestra
    • Bundle ID: Choose Explicit and enter com.yourdomain.orkestra
  6. Under Capabilities, enable Sign In with Apple.
  7. Click Continue and Register.

Step 3: create Service ID (for Web)​

  1. In Identifiers, click + button.
  2. Select Services IDs and click Continue.
  3. Fill in:
    • Description: Orkestra Web Service
    • Identifier: com.yourdomain.orkestra.web
  4. Click Continue and Register.
  5. Click on the created Service ID to configure.
  6. Enable Sign In with Apple.
  7. Click Configure next to Sign In with Apple.
  8. Set:
    • Primary App ID: select your App ID from Step 2.
    • Domains and Subdomains: add the operator callback host (for example, console.orkestra.com). Apple does not accept localhost; use ngrok for local development.
    • Return URL (one callback on the operator backend):
      • https://console.orkestra.com/v1/auth/oauth/apple/callback The signed OAuth state dispatches the completed flow to the client tier when that is where it started; do not register a client-host callback.
  9. Click Next, Done, and Continue.
  10. Click Save.

Step 4: create private key​

  1. Navigate to Keys → + button.
  2. Enter a Key Name: Orkestra Auth Key.
  3. Enable Sign In with Apple.
  4. Click Continue and Register.
  5. Download the private key (.p8 file).
    • IMPORTANT: save this file securely — you can't download it again.
  6. Note the Key ID shown on the page.

Step 5: gather required information​

You'll need:

  • Team ID: found in your Apple Developer account (top right corner).
  • Service ID: the identifier created in Step 3 (e.g. com.yourdomain.orkestra.web).
  • Key ID: from Step 4.
  • Private Key: the .p8 file downloaded in Step 4.

Apple private-key incident response​

Treat a suspected Apple .p8 private-key exposure as an incident. In order:

  1. Identify every affected environment and the Apple configuration each uses.
  2. Restrict access to, and sanitize where retention policy permits, local and OTLP log records that could contain the key or related credential material. Preserve only the incident evidence required by policy.
  3. In Apple Developer, rotate the key and invalidate the exposed key.
  4. Update the encrypted auth module configuration with the replacement Apple key and its matching key ID.
  5. Do not restart a service unless the live configuration behavior requires it; the application does not rotate an external Apple key for you.
  6. Force reauthentication for affected users and monitor OAuth failures while the replacement is in use.

GitHub OAuth setup​

  1. Go to GitHub Developer Settings → OAuth Apps.
  2. Click New OAuth App and fill in:
    • Application name: Orkestra
    • Homepage URL: https://console.orkestra.com (or your operator host)
    • Authorization callback URL: https://console.orkestra.com/v1/auth/oauth/github/callback
  3. Register the app, then click Generate a new client secret.
  4. Save the Client ID and Client Secret — paste them into /admin/modules/auth under the GitHub group (or use OAUTH_GITHUB_CLIENT_ID / OAUTH_GITHUB_CLIENT_SECRET as seed env vars).
  5. Register the single operator-host callback above. The signed OAuth state dispatches a completed client-tier flow to the client auth service, so a second client-host callback or provider app is not required for the audience split.

Discord OAuth setup​

  1. Go to the Discord Developer Portal and create a New Application.
  2. Open OAuth2 → General. Add the single operator-host redirect:
    • https://console.orkestra.com/v1/auth/oauth/discord/callback
    • http://localhost:3000/v1/auth/oauth/discord/callback (dev) The signed OAuth state dispatches client-tier flows after the callback; do not add a client-host callback.
  3. Click Reset Secret to generate a client secret.
  4. Save the Client ID and Client Secret — paste them into /admin/modules/auth under the Discord group (or OAUTH_DISCORD_CLIENT_ID / OAUTH_DISCORD_CLIENT_SECRET).
  5. Required scopes Orkestra requests: identify email.

Environment configuration​

Backend (seed env vars, optional)​

These env vars only seed module_configs: they are read on first boot and on any later boot at which the key is still absent — SeedFromModules back-fills every absent schema key from its EnvVar on every boot — and they are ignored as soon as a value is stored, because the stored value then wins. So manage values from /admin/modules/auth (or PATCH /v1/admin/modules/auth). Secrets are AES-256-GCM encrypted at rest by ConfigService.

# Google OAuth
OAUTH_GOOGLE_CLIENT_ID=your-google-client-id.apps.googleusercontent.com
OAUTH_GOOGLE_CLIENT_SECRET=your-google-client-secret
OAUTH_GOOGLE_REDIRECT_URL=https://console.orkestra.com/v1/auth/oauth/google/callback

# Apple Sign In
OAUTH_APPLE_TEAM_ID=your-team-id
OAUTH_APPLE_CLIENT_ID=com.yourdomain.orkestra.web # Apple Service ID
OAUTH_APPLE_KEY_ID=your-key-id
OAUTH_APPLE_PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\n... # inline PEM (preferred)
OAUTH_APPLE_PRIVATE_KEY_PATH=/app/keys/AuthKey_XXXXXX.p8 # OR file fallback
OAUTH_APPLE_REDIRECT_URL=https://console.orkestra.com/v1/auth/oauth/apple/callback

# GitHub
OAUTH_GITHUB_CLIENT_ID=your-github-client-id
OAUTH_GITHUB_CLIENT_SECRET=your-github-client-secret
OAUTH_GITHUB_REDIRECT_URL=https://console.orkestra.com/v1/auth/oauth/github/callback

# Discord
OAUTH_DISCORD_CLIENT_ID=your-discord-client-id
OAUTH_DISCORD_CLIENT_SECRET=your-discord-client-secret
OAUTH_DISCORD_REDIRECT_URL=https://console.orkestra.com/v1/auth/oauth/discord/callback

# JWT — Orkestra uses RS256 (asymmetric), NOT a shared HMAC secret.
# `make init` generates these for you under docker/keys/ — see init.sh.
JWT_PRIVATE_KEY_PATH=/app/keys/jwt-private.pem
JWT_PUBLIC_KEY_PATH=/app/keys/jwt-public.pem

Frontend​

The browser never talks to OAuth providers directly — it starts at the tier-specific backend endpoint POST /v1/auth/{tier}/oauth/login, which returns the provider authorization URL with state, scopes, and the configured single operator-host callback. So the frontend does not need provider client IDs.

# frontend-admin (operator console) — .env.production
VITE_API_URL=https://console.orkestra.com

# frontend-client (Tier-2 client SPA) — .env.production
VITE_API_BASE=https://api.orkestra.com

Mobile​

For the Flutter mobile app, update:

iOS — ios/Runner/Info.plist:

<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>com.googleusercontent.apps.YOUR_GOOGLE_IOS_CLIENT_ID</string>
</array>
</dict>
</array>

Android — add the Google services configuration file to android/app/google-services.json.

Testing OAuth integration​

Local development​

  1. Ensure your backend is running and reachable as both http://localhost:3000 (operator — an unmatched Host falls through to the operator mux in development) and http://client.localhost:3000 (client) — the host mux dispatches on Host. The client API shares the client SPA's hostname because the client-tier cookies are SameSite=Lax and sibling *.localhost labels are different sites; add entries to /etc/hosts if your resolver needs them:

    127.0.0.1 client.localhost
  2. Start the frontends — operator on http://localhost:8080, client SPA on http://client.localhost:8081. http://localhost:8080 is the shipped operator entry point: it matches the default VITE_API_URL=http://localhost:3000, so the console's origin and its API are same-site, and it is the hostname the console's dev server allows out of the box.

    Two host rules bind the operator OAuth walk-through in step 3, and the shipped localhost layout satisfies both. The orkestra_oauth_state cookie is host-only and SameSite=Lax (oauth_state_binding.go:48-58), so the host that answers the login POST must be the host that answers the callback — both are localhost:3000 here, which is why the dev callback registered above is http://localhost:3000/v1/auth/oauth/google/callback — the value the auth module's Redirect URL field for Google must also hold, since that field, not any compiled fallback, is what the backend sends the IdP. And the console's origin must stay same-site with VITE_API_URL or the refresh cookie is lost the same way; scripts/env-validate.sh refuses a mixed pairing (spec §8 follow-up #13). Putting the console on console.localhost end to end works too — origin http://console.localhost:8080, VITE_API_URL=http://console.localhost:3000, console.localhost in VITE_ADMIN_ALLOWED_HOSTS — but then the registered callback has to move to http://console.localhost:3000/... with it, and the module's Redirect URL at /admin/modules/auth has to say so (OAUTH_GOOGLE_REDIRECT_URL only seeds that field while it is still unset — any boot back-fills an absent key from the variable — and is ignored once a value is stored).

  3. From the operator login page, start Google OAuth through POST /v1/auth/operator/oauth/login. The browser follows the returned authorization URL to Google, then returns to http://localhost:3000/v1/auth/oauth/google/callback, the single operator-host callback.

  4. Repeat from the client SPA (http://client.localhost:8081/login) — its provider buttons call POST /v1/auth/client/oauth/login. The signed state carries tier=client, so the same operator-host callback performs only the IdP half and relays the outcome to http://client.localhost:3000/v1/auth/client/oauth/complete?relay=… on the client API host, which verifies the browser binding against the state cookie it set at start, issues the aud=client session and the client-tier refresh cookie, and lands on http://client.localhost:8081/auth/callback, where the SPA adopts the cookie through POST /v1/auth/client/refresh-cookie.

Production​

  1. Deploy your application to your domain.
  2. Ensure HTTPS is properly configured.
  3. Test both authentication flows (operator + client).
  4. Verify tokens are properly generated and stored.

Going SSO-only​

Once a provider works, a surface can stop accepting email/password altogether. The two switches live under Login & Sessions at /admin/modules/auth: passwordLoginEnabledAdmin for the operator console and passwordLoginEnabledClient for the client app. They are independent — take one surface SSO-only and leave the other alone.

Do it in this order.

  1. Configure the provider and verify it end to end on the surface you are about to close, following the setup guide above. A provider counts only when its toggle for that surface is on and every field the flow needs is filled in: client ID, redirect URL and client secret — for Apple, also team ID, key ID and a private key (inline or a readable, non-empty key file).
  2. Sign in through it at least once, with an account you will still need afterwards. The identity provider must return the address as verified; an unverified one can neither sign up nor attach to an existing account.
  3. Leave "Auto-link OAuth provider to existing email account" (oauthAutoLinkByEmail) ON. This is what lets accounts created with a password reach their account through the provider afterwards. It is part of the guard below, not a nicety.
  4. Turn the surface's passwordLoginEnabled… switch off and save. The change is live immediately — no restart, no restart banner.

What the validator refuses. A save that would leave a surface with no way to sign in is rejected with 422 auth.login_method_lockout, and nothing is written — neither the config values nor the module's enabled state. That covers an ordinary save, a save into a named environment, and activating an environment whose own configuration is invalid, so you cannot walk into a lockout sideways. In practice you will meet it when you try to disable the last usable provider for a closed surface, blank one of its required fields, remove its secret, or turn auto-link off. A field that is not a literal true/false is refused separately, naming that field.

Two more behaviours are worth knowing before you flip:

  • Existing sessions are not revoked. Whoever is signed in stays signed in. Flipping the switch governs the next authentication.
  • Password-related recovery closes with it. Signup, forgot-password and the operator's send password reset stop working for that surface (the last one answers 409); reset-password, accept-invite, verify-email and change-password stay open so a recovery already in flight can finish.

Walk the checks in the auth module reference before and after the flip: password login refused with the stable code and the form gone, OAuth login working, an admin password reset refused, and unlinking the sole usable identity refused.

The break-glass drill​

If the console is SSO-only and OAuth then breaks — a rotated secret, a lost key, an outage at the identity provider — there is a boot-time escape hatch that restores operator password login only:

# docker/.env
AUTH_OPERATOR_PASSWORD_LOGIN_BREAK_GLASS=true
  1. Set it, then restart the backend through the sanctioned stack lifecycle (./orkestra.sh).
  2. Sign in on the console's labelled emergency form. MFA still applies, and so does every RBAC and step-up gate on what you do next.
  3. Repair the provider configuration at /admin/modules/auth.
  4. Unset the variable and restart again.

It is deliberately narrow: it rescues POST /v1/auth/operator/login and the MFA continuation of a login it allowed — nothing else. Registration, forgot/reset password, password reconfirmation, the OAuth-unlink guard and the whole client surface stay closed, and the persisted policy is never rewritten. While the variable is set the backend logs a warning on every boot, and every rescued sign-in is audited with the audience, the user id, the session id and the source IP — never a credential. Treat a lingering warning in the logs as an unfinished incident.

Rehearse it on staging before you need it: set the variable, restart, confirm the emergency form appears and an operator can sign in while signup and forgot-password stay absent and client login stays refused, then unset it, restart, and confirm the form and the warning are gone.

Security best practices​

  1. Never commit secrets to version control.
  2. Use environment variables (or, better, ConfigService) for all sensitive configuration.
  3. Implement HTTPS for production deployments.
  4. Rotate keys periodically.
  5. Implement rate limiting on authentication endpoints (built in — RATE_LIMIT_REQUESTS_PER_MINUTE / RATE_LIMIT_BURST).
  6. Log authentication attempts for security monitoring (built in — see compliance addon).
  7. Use secure session management with JWT tokens (built in — RS256, refresh-token rotation).
  8. Implement refresh token rotation (built in — see backend/internal/core/auth/).

Troubleshooting​

Common Google OAuth issues​

Error: redirect_uri_mismatch

  • Ensure the redirect URI in your request matches exactly with configured URIs.
  • Check for trailing slashes and protocol (http vs https).

Error: invalid_client

  • Verify Client ID and Client Secret are correct.
  • Check if the OAuth consent screen is properly configured.

Common Apple Sign In issues​

Error: invalid_grant

  • Ensure the authorization code is used only once.
  • Verify the redirect URI matches exactly.

Error: invalid_client

  • Check Team ID, Service ID, and Key ID are correct.
  • Verify the private key file is accessible and valid.

JWT token issues

  • Ensure the private key file has correct permissions.
  • Verify the key hasn't expired.
  • Check the algorithm matches (ES256 for Apple).

Support​

Pre-production checklist​

  • Google OAuth credentials created and configured for the intended audiences
  • Apple Sign In credentials created and configured (operator callback host; ngrok used for local)
  • GitHub OAuth app created with the operator-host callback
  • Discord OAuth credentials created and configured
  • Credentials stored in /admin/modules/auth (ConfigService), not in plain .env in prod
  • Each provider's redirect URI is registered on the operator host (console.orkestra.com in these examples)
  • RS256 keys (jwt-private.pem / jwt-public.pem) generated and JWT_PRIVATE_KEY_PATH / JWT_PUBLIC_KEY_PATH set
  • HTTPS enabled on production
  • OAuth tested in development environment (both operator and client tiers)
  • OAuth tested in production environment
  • Security best practices implemented
  • Monitoring and logging configured
  • Documentation shared with team