diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index fad4150..3f1ee86 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -7,7 +7,13 @@ con SvelteKit. Mantener este archivo y `CLAUDE.md` equivalentes en arquitectura GitOps combina autenticacion y RBAC, organizaciones y proyectos, Open Report de seguridad y visibilidad de Pulumi State. La ruta Vault es actualmente una base visual sin backend completo. -Google SSO y SAML son configuracion, no estrategias de autenticacion implementadas. + +Google SSO es por organización: `/org//settings/global` almacena el OAuth client +(entidad `OrganizationAuthProviderEntity`, client secret AES-256-GCM cifrado vía `SecretCipherService` +con clave derivada de `GITDB_ENCRYPTION_KEY`, nunca devuelto a UI) y opcionalmente dominios +Workspace permitidos. El servicio `ssoService` en `$modules/auth` ejecuta Authorization Code + PKCE +(rutas `/auth/sso/google/start` y `/auth/sso/google/callback`, ambas sin sesión), verifica el ID token contra JWKS +de Google via `GoogleOidcClient` en la capa OIDC, y por defecto solo autentica usuarios que ya tienen acceso a la organización o sus proyectos. Para habilitar auto-registro (`autoProvision`), se requiere al menos un dominio Workspace permitido (configurado en settings) y permiso `organization:users:invite` para cambiar esta opción. Con auto-registro activo, cualquier cuenta de Workspace que coincida con los dominios permitidos se registra automáticamente: se crea la cuenta `cluster-user` si falta y se asigna el rol configurado (por defecto `org-developer`) vía `userAccessService.provisionOrganizationMember()`. Ese rol debe cumplir `RoleDomain.isAutoProvisionable` (solo lecturas y permisos de Org Developer, nunca de administración), comprobado al guardar y en cada alta. Las cuentas personales de Google nunca se auto-registran, porque la validación se hace contra el claim `hd` de Workspace, no contra el dominio del email. El servicio vincula el `sub` de Google al usuario en `user.authProviders[]` y acepta la invitación pendiente en el primer sign-in. Los usuarios pueden deshabilitarse via `user.disabled`. Templates de notificación (`src/notifications/`) se leen en tiempo de ejecución. SAML es configuración solo. - SvelteKit 2, Svelte 5, Vite 8 y TypeScript 6 strict - Tailwind CSS 4 y `@lucide/svelte` @@ -17,6 +23,8 @@ Google SSO y SAML son configuracion, no estrategias de autenticacion implementad - Vitest, ESLint y Prettier - Playwright para tests e2e de RBAC (`e2e/`) +Variables de entorno: `GITDB_REPOSITORY_URL`, `GITDB_ENCRYPTION_KEY`, `GITDB_SYNC_POLL_SECONDS`. Las credenciales de Google OAuth se configuran por organización en la UI (almacenadas cifradas en la base de datos), no como variables de entorno globales. + Comandos: ```bash diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..165c712 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + # Mantiene al día los SHA de las actions fijadas en los workflows. + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + # No se adoptan releases de menos de 7 días. + cooldown: + default-days: 7 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e3866f..4d51f60 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,12 +12,12 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Setup Bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: latest + bun-version: 1.4.3 - name: Install dependencies run: bun install @@ -30,12 +30,12 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Setup Bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: latest + bun-version: 1.4.3 - name: Install dependencies run: bun install @@ -48,12 +48,12 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Setup Bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: latest + bun-version: 1.4.3 - name: Install dependencies run: bun install diff --git a/.github/workflows/code-report.yml b/.github/workflows/code-report.yml index 524d4d1..4f79ef6 100644 --- a/.github/workflows/code-report.yml +++ b/.github/workflows/code-report.yml @@ -10,18 +10,18 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out repository - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Setup Bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: latest + bun-version: 1.4.3 - name: Install dependencies run: bun install - name: Install GitOps CLI - run: bun install --global @getgitops/cli + run: bun install --global @getgitops/cli@1.2.1 - name: Run Code Report scan env: diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 766cb58..7f5e4ca 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -20,10 +20,10 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Log in to the Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -31,7 +31,7 @@ jobs: - name: Extract metadata (tags, labels) for Docker id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | @@ -40,10 +40,10 @@ jobs: type=semver,pattern={{major}} - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - name: Build and push Docker image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 with: context: . push: true diff --git a/CLAUDE.md b/CLAUDE.md index 3df4eef..21db95d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,8 +6,25 @@ Guidance for working on GitOps, an open source internal developer platform built GitOps combines identity and access management, organizations and projects, Open Report security analysis, and Pulumi State visibility. The Vault route is currently a UI foundation and does not -yet provide a complete secrets backend. Google SSO and SAML settings are configuration only; no -external authentication strategy is implemented. +yet provide a complete secrets backend. SAML settings are configuration only. + +Google SSO is per organization: `/org//settings/global` stores the OAuth client +(`OrganizationAuthProviderEntity` in schemas, client secret AES-256-GCM encrypted via +`SecretCipherService` with a key derived from `GITDB_ENCRYPTION_KEY`, never returned to the UI) +and an optional list of allowed Workspace domains (checked against the ID token `hd` claim). +`/auth/login` shows "Continue with Google" next to the always-available password form for the +organization named by `?org=` (the auth guard adds it when bouncing an `/org//...` +request), else the `last_org` cookie, else the only organization with Google enabled, if exactly one. `ssoService` in `$modules/auth` runs Authorization Code + +PKCE through `/auth/sso/google/start` and `/auth/sso/google/callback` (both routes allow unauthenticated access in `hooks.server.ts`), verifies the ID token +against Google's JWKS via `GoogleOidcClient` in the OIDC infrastructure layer, and by default only signs in people who already have access to that organization +(or one of its projects). To enable automatic sign-up (`autoProvision`), the organization must configure at least one allowed Workspace domain and enable it in +`/org//settings/global` (requires `organization:users:invite` permission to change). With automatic sign-up enabled, a verified Workspace account whose +`hd` matches an allowed domain gets an active `cluster-user` account (if missing) plus organization access with the configured organization role (default +`org-developer`) through `userAccessService.provisionOrganizationMember()`. That role must pass `RoleDomain.isAutoProvisionable` +(allow-list: read grants plus `AUTO_PROVISION_ALLOWED_ORGANIZATION_GRANTS`, never admin rights), checked on save and again on every sign-up, so a role +edited later to gain admin rights blocks automatic sign-up instead of handing them out. Personal Google accounts are never auto-signed-up, as the domain is checked against +the Workspace `hd` claim, not the email domain. It links the Google `sub` to the user found by verified email (stored in `user.authProviders[]`), and a pending +invitation is accepted on first Google sign-in. Users can be disabled via `user.disabled` to block access without deletion. Self-service registration (`/auth/registration`) lets a visitor create a plain `cluster-user` account with no organization membership. It is gated by a cluster-wide toggle managed at @@ -36,6 +53,10 @@ The roadmap is in `IDEAS.md`. Do not describe roadmap items as implemented featu - Vitest, ESLint, and Prettier - Playwright for RBAC end-to-end tests (`e2e/`) +Environment variables: `GITDB_REPOSITORY_URL`, `GITDB_ENCRYPTION_KEY`, `GITDB_SYNC_POLL_SECONDS`. Google OAuth credentials are configured per organization in the UI (stored encrypted in the database), not as global environment variables. + +Email notification templates (`src/notifications/`) are read from disk at runtime; the Docker image copies them so emails can be sent from the container. + ```bash bun install bun run dev diff --git a/Dockerfile b/Dockerfile index cee432d..e71c229 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,6 +32,8 @@ RUN apk add --no-cache git openssh-client && \ COPY --from=builder /app/build ./build COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/package.json ./package.json +# email templates are read from disk at runtime (src/lib/server/infra/notifications/template.ts) +COPY --from=builder /app/src/notifications ./src/notifications # Persistent data directory (SQLite DB + gitdb clone) # VOLUME ["/app/data"] diff --git a/README.md b/README.md index 08e90c1..430eef5 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ operational data versioned and auditable in Git. - Identity and access management for users, organizations, projects, roles, permissions, invitations, sessions, and API keys. +- Per-organization Google sign-in (OpenID Connect) alongside local email and password login. - Security reporting for vulnerabilities, SBOMs, secrets, licenses, and services. - Pulumi state, history, and lock visibility for S3 and Google Cloud Storage backends. - Git-backed, auditable application data through GitDB. @@ -40,6 +41,37 @@ docker compose up --build ``` The container is available at `http://localhost:3000` and stores persistent data in `./data`. +The image ships `git` without an SSH client, so point `GITDB_REPOSITORY_URL` at an HTTPS remote +and authenticate with `GITDB_TOKEN` (or `GITDB_USERNAME`/`GITDB_PASSWORD`); with an SSH +(`git@...`) remote the container can commit locally but cannot push. + +### Google sign-in + +Each organization configures its own Google OAuth client: + +1. In Google Cloud Console, open **APIs & Services → Credentials** and create an **OAuth client ID** + of type **Web application** (configure the consent screen as *Internal* for Workspace-only + access). +2. Under **Authorized redirect URIs** (not *JavaScript origins*), add + `https:///auth/sso/google/callback`. For local use add the URI of the port you run + on, which must match exactly: `http://localhost:5173/auth/sso/google/callback` for + `bun run dev`, `http://localhost:3000/auth/sso/google/callback` for Docker. +3. In GitOps, open **Organization settings → Global**, enable Google SSO, paste the client ID and + secret, and optionally restrict sign-in to your Workspace domains. Leave the domain list empty + for personal `@gmail.com` accounts, which carry no Workspace domain. With an *External* + consent screen in testing mode, only the accounts listed as test users can sign in. +4. Share `https:///auth/login?org=`. + +By default only users already invited to the organization can sign in with Google. To let anyone +from your company in without an invitation, set at least one allowed domain and enable +**Automatically sign up accounts from the allowed domains**: the first Google sign-in of a +`@yourdomain.com` Workspace account creates the user and grants organization access with the role +you pick (Org Developer by default). Only roles without administrative permissions can be picked +(read access and, at most, creating and editing projects); if the role is later edited to gain +admin rights, automatic sign-up stops until another role is chosen. Changing this setting also requires permission to invite +organization users. Personal Google accounts are never signed up automatically, because the domain +is checked against the Workspace `hd` claim, not the email address. The client secret is stored encrypted with a key derived from +`GITDB_ENCRYPTION_KEY`, so rotating that key requires re-entering the secret. ## Development diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 6100547..0cfecda 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -82,10 +82,10 @@ const authGuard: Handle = async ({ event, resolve }) => { return new Response(null, { status: 302, headers: { location: '/bootstrap' } }); } - // sign-in, sign-out, self-registration, password recovery and invitation acceptance must work - // without (or with a broken) session; each route re-checks whatever it needs server-side if ( pathname === '/auth/login' || + pathname === '/auth/sso/google/start' || + pathname === '/auth/sso/google/callback' || pathname === '/auth/logout' || pathname === '/auth/invitation' || pathname === '/auth/registration' || @@ -130,7 +130,12 @@ const authGuard: Handle = async ({ event, resolve }) => { if (isApiRequest) { return unauthorized('Authentication required'); } - return new Response(null, { status: 302, headers: { location: '/auth/login' } }); + + const organizationSlug = event.params.org ?? pathname.match(/^\/org\/([^/]+)/)?.[1]; + const location = organizationSlug + ? `/auth/login?${new URLSearchParams({ org: organizationSlug })}` + : '/auth/login'; + return new Response(null, { status: 302, headers: { location } }); } const projectSettingsMatch = event.url.pathname.match( diff --git a/src/lib/database/schemas.ts b/src/lib/database/schemas.ts index eb43457..6126fdc 100644 --- a/src/lib/database/schemas.ts +++ b/src/lib/database/schemas.ts @@ -89,6 +89,26 @@ export const ClusterSettingsEntity = entity('cluster_settings', { .$defaultFn(() => new Date().toISOString()), }); +export const OrganizationAuthProviderEntity = entity('organization_auth_providers', { + id: uuid().primaryKey(), + organizationId: uuid().notNull(), + provider: text().notNull(), + enabled: bool().notNull().default(false), + clientId: text(), + clientSecretEncrypted: text(), + allowedDomains: json() + .notNull() + .$defaultFn(() => []), + autoProvision: bool().notNull().default(false), + autoProvisionRoleId: uuid().$defaultFn(() => null), + createdAt: timestamp() + .notNull() + .$defaultFn(() => new Date().toISOString()), + updatedAt: timestamp() + .notNull() + .$defaultFn(() => new Date().toISOString()), +}); + export const relations = defineRelations(); relations.for(UserEntity, ({ one, many }) => ({ diff --git a/src/lib/i18n/locales/en.json b/src/lib/i18n/locales/en.json index 808a401..d024ad6 100644 --- a/src/lib/i18n/locales/en.json +++ b/src/lib/i18n/locales/en.json @@ -212,7 +212,17 @@ "newPassword": "New password", "resetPassword": "Reset password", "resetting": "Resetting...", - "passwordResetSuccess": "Your password has been reset. Sign in with your new password." + "passwordResetSuccess": "Your password has been reset. Sign in with your new password.", + "ssoContinueWithGoogle": "Continue with Google", + "ssoOrganizationHint": "Single sign-on for {organization}", + "ssoDivider": "or", + "ssoErrorCancelled": "Google sign-in was cancelled.", + "ssoErrorNotConfigured": "Google sign-in is not enabled for this organization.", + "ssoErrorInvalidState": "The sign-in request expired. Please try again.", + "ssoErrorEmailNotVerified": "Your Google account email is not verified.", + "ssoErrorDomainNotAllowed": "This Google account domain is not allowed for this organization.", + "ssoErrorNotInvited": "This Google account has no access to this organization. Ask an administrator for an invitation.", + "ssoErrorGeneric": "Google sign-in failed. Please try again or use your password." }, "bootstrap": { "title": "Cluster setup", @@ -755,7 +765,19 @@ "comingSoon": "Coming soon", "comingSoonDescription": "Key rotation, encryption policy, and scoped secret protection settings are planned for a future release.", "saveNotWired": "Global authentication settings are not wired to a server action yet.", - "localAuthTitle": "Local authentication: always ON" + "localAuthTitle": "Local authentication: always ON", + "googleSecretConfigured": "A client secret is stored (encrypted). Leave empty to keep it, or type a new one to replace it.", + "googleAllowedDomains": "Allowed Google Workspace domains", + "googleAllowedDomainsHelp": "Comma-separated. Only accounts of these Workspace domains can sign in. Leave empty to accept any verified Google account. Without automatic sign-up, users still need an invitation to this organization.", + "googleAutoProvision": "Automatically sign up accounts from the allowed domains", + "googleAutoProvisionHelp": "Any Google Workspace account from the allowed domains can sign in without an invitation: its user is created and given access to this organization with the chosen role. Requires at least one allowed domain.", + "googleAutoProvisionNoPermission": "You need permission to invite organization users to change automatic sign-up.", + "googleAutoProvisionRole": "Role granted to automatically signed-up accounts", + "googleAutoProvisionRoleHelp": "Only roles without administrative permissions are listed: read access and, at most, creating and editing projects (like Org Developer). Administrators can grant more afterwards, per user.", + "googleAutoProvisionRoleInvalid": "The saved role no longer exists or now grants administrative permissions, so automatic sign-up is blocked. Pick another role and save.", + "googleRedirectUri": "Authorized redirect URI (GCP):", + "googleLoginUrl": "Organization login URL:", + "saveSaml": "Save SAML settings" }, "projects": { "title": "Projects", diff --git a/src/lib/i18n/locales/es.json b/src/lib/i18n/locales/es.json index 4dbb695..db278d3 100644 --- a/src/lib/i18n/locales/es.json +++ b/src/lib/i18n/locales/es.json @@ -212,7 +212,17 @@ "newPassword": "Nueva contraseña", "resetPassword": "Restablecer contraseña", "resetting": "Restableciendo...", - "passwordResetSuccess": "Tu contraseña se ha restablecido. Inicia sesión con tu nueva contraseña." + "passwordResetSuccess": "Tu contraseña se ha restablecido. Inicia sesión con tu nueva contraseña.", + "ssoContinueWithGoogle": "Continuar con Google", + "ssoOrganizationHint": "Inicio de sesión único de {organization}", + "ssoDivider": "o", + "ssoErrorCancelled": "Se canceló el inicio de sesión con Google.", + "ssoErrorNotConfigured": "El inicio de sesión con Google no está activado para esta organización.", + "ssoErrorInvalidState": "La solicitud de inicio de sesión ha caducado. Inténtalo de nuevo.", + "ssoErrorEmailNotVerified": "El email de tu cuenta de Google no está verificado.", + "ssoErrorDomainNotAllowed": "El dominio de esta cuenta de Google no está permitido en esta organización.", + "ssoErrorNotInvited": "Esta cuenta de Google no tiene acceso a esta organización. Pide una invitación a un administrador.", + "ssoErrorGeneric": "Falló el inicio de sesión con Google. Inténtalo de nuevo o usa tu contraseña." }, "bootstrap": { "title": "Configuración del clúster", @@ -755,7 +765,19 @@ "comingSoon": "Próximamente", "comingSoonDescription": "La rotación de claves, la política de cifrado y la protección de secretos por ámbito están previstas para una futura versión.", "saveNotWired": "La configuración global de autenticación aún no está conectada a una acción del servidor.", - "localAuthTitle": "Autenticación local: siempre ACTIVA" + "localAuthTitle": "Autenticación local: siempre ACTIVA", + "googleSecretConfigured": "Hay un client secret guardado (cifrado). Déjalo vacío para conservarlo o escribe uno nuevo para reemplazarlo.", + "googleAllowedDomains": "Dominios de Google Workspace permitidos", + "googleAllowedDomainsHelp": "Separados por comas. Solo pueden entrar cuentas de estos dominios de Workspace. Déjalo vacío para aceptar cualquier cuenta de Google verificada. Sin el auto-registro, los usuarios siguen necesitando una invitación a la organización.", + "googleAutoProvision": "Auto-registrar cuentas de los dominios permitidos", + "googleAutoProvisionHelp": "Cualquier cuenta de Google Workspace de los dominios permitidos podrá entrar sin invitación: se crea su usuario y se le da acceso a esta organización con el rol elegido. Requiere al menos un dominio permitido.", + "googleAutoProvisionNoPermission": "Necesitas permiso para invitar usuarios a la organización para cambiar el auto-registro.", + "googleAutoProvisionRole": "Rol asignado a las cuentas auto-registradas", + "googleAutoProvisionRoleHelp": "Solo se muestran roles sin permisos de administración: lectura y, como mucho, crear y editar proyectos (como Org Developer). Los administradores pueden ampliar permisos después, usuario a usuario.", + "googleAutoProvisionRoleInvalid": "El rol guardado ya no existe o ahora tiene permisos de administración, así que el auto-registro está bloqueado. Elige otro rol y guarda.", + "googleRedirectUri": "URI de redirección autorizada (GCP):", + "googleLoginUrl": "URL de login de la organización:", + "saveSaml": "Guardar ajustes SAML" }, "projects": { "title": "Proyectos", diff --git a/src/modules/auth/application/secret-cipher.service.test.ts b/src/modules/auth/application/secret-cipher.service.test.ts new file mode 100644 index 0000000..5ebec66 --- /dev/null +++ b/src/modules/auth/application/secret-cipher.service.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import { SecretCipherService } from './secret-cipher.service'; + +describe('SecretCipherService', () => { + const cipher = new SecretCipherService(() => 'test-master-key'); + + it('round-trips a secret without storing it in clear', () => { + const encrypted = cipher.encrypt('GOCSPX-super-secret', 'sso'); + + expect(encrypted).not.toContain('GOCSPX-super-secret'); + expect(encrypted.startsWith('v1:')).toBe(true); + expect(cipher.decrypt(encrypted, 'sso')).toBe('GOCSPX-super-secret'); + }); + + it('uses a fresh IV per encryption', () => { + expect(cipher.encrypt('same', 'sso')).not.toBe(cipher.encrypt('same', 'sso')); + }); + + it('rejects a ciphertext decrypted for another purpose', () => { + const encrypted = cipher.encrypt('secret', 'sso'); + expect(() => cipher.decrypt(encrypted, 'other')).toThrow(); + }); + + it('rejects a ciphertext produced with another master key', () => { + const encrypted = new SecretCipherService(() => 'another-key').encrypt('secret', 'sso'); + expect(() => cipher.decrypt(encrypted, 'sso')).toThrow(); + }); + + it('rejects a tampered ciphertext', () => { + const [version, iv, tag, data] = cipher.encrypt('secret', 'sso').split(':'); + const flipped = Buffer.from(data, 'base64url'); + flipped[0] ^= 0xff; + expect(() => + cipher.decrypt([version, iv, tag, flipped.toString('base64url')].join(':'), 'sso'), + ).toThrow(); + }); +}); diff --git a/src/modules/auth/application/secret-cipher.service.ts b/src/modules/auth/application/secret-cipher.service.ts new file mode 100644 index 0000000..a57313a --- /dev/null +++ b/src/modules/auth/application/secret-cipher.service.ts @@ -0,0 +1,46 @@ +import crypto from 'crypto'; + +const VERSION = 'v1'; +const IV_BYTES = 12; + +export class SecretCipherService { + constructor(private readonly masterKey: () => string) {} + + encrypt(plaintext: string, purpose: string): string { + const iv = crypto.randomBytes(IV_BYTES); + const cipher = crypto.createCipheriv('aes-256-gcm', this.deriveKey(purpose), iv); + const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); + + return [ + VERSION, + iv.toString('base64url'), + cipher.getAuthTag().toString('base64url'), + ciphertext.toString('base64url'), + ].join(':'); + } + + decrypt(payload: string, purpose: string): string { + const [version, iv, tag, ciphertext] = payload.split(':'); + if (version !== VERSION || !iv || !tag || ciphertext === undefined) { + throw new Error('Unsupported encrypted secret format'); + } + + const decipher = crypto.createDecipheriv( + 'aes-256-gcm', + this.deriveKey(purpose), + Buffer.from(iv, 'base64url'), + ); + decipher.setAuthTag(Buffer.from(tag, 'base64url')); + + return Buffer.concat([ + decipher.update(Buffer.from(ciphertext, 'base64url')), + decipher.final(), + ]).toString('utf8'); + } + + private deriveKey(purpose: string): Buffer { + return Buffer.from( + crypto.hkdfSync('sha256', this.masterKey(), Buffer.alloc(0), `gitops:${purpose}`, 32), + ); + } +} diff --git a/src/modules/auth/application/sso.service.test.ts b/src/modules/auth/application/sso.service.test.ts new file mode 100644 index 0000000..cb03831 --- /dev/null +++ b/src/modules/auth/application/sso.service.test.ts @@ -0,0 +1,711 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { SsoError, SsoService } from './sso.service'; +import { AuthProviderDomain } from '../domain/auth-provider.domain'; +import { RoleDomain } from '../domain/role.domain'; +import { + ORGANIZATION_ADMIN_PERMISSIONS, + ORGANIZATION_DEVELOPER_PERMISSIONS, +} from '../domain/role-permissions.data'; +import { UserDomain, type UserAuthProvider } from '../domain/user.domain'; +import { UserAccessDomain } from '../domain/user-access.domain'; +import type { GoogleIdTokenClaims } from '../infrastructure/oidc/google-oidc.client'; + +const ORG = { id: 'org-1', slug: 'gitops' }; +const OTHER_ORG_ID = 'org-2'; +const REDIRECT_URI = 'https://gitops.local/auth/sso/google/callback'; +const TIMESTAMPS = { createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z' }; + +class FakeAuthProviderRepository { + rows: AuthProviderDomain[] = []; + + async findByOrganization(organizationId: string, provider: string) { + return ( + this.rows.find((row) => row.organizationId === organizationId && row.provider === provider) ?? + null + ); + } + + async findEnabled(provider: string) { + return this.rows.filter((row) => row.provider === provider && row.enabled); + } + + async upsert(organizationId: string, provider: 'google', changes: Record) { + const existing = await this.findByOrganization(organizationId, provider); + const next = new AuthProviderDomain({ + id: existing?.id ?? `provider-${this.rows.length + 1}`, + organizationId, + provider, + enabled: existing?.enabled ?? false, + clientId: existing?.clientId ?? null, + clientSecretEncrypted: existing?.clientSecretEncrypted ?? null, + allowedDomains: existing?.allowedDomains ?? [], + ...changes, + ...TIMESTAMPS, + }); + this.rows = [...this.rows.filter((row) => row !== existing), next]; + return next; + } +} + +class FakeUserRepository { + rows: UserDomain[] = []; + activated: string[] = []; + lastLogins: string[] = []; + + async findByAuthProvider(provider: string, providerId: string) { + return this.rows.find((user) => user.hasAuthProvider(provider, providerId)) ?? null; + } + + async findByEmail(email: string) { + return this.rows.find((user) => user.email === email) ?? null; + } + + async updateAuthProviders(userId: string, authProviders: UserAuthProvider[]) { + const user = this.rows.find((entry) => entry.id === userId)!; + user.authProviders = authProviders; + } + + async activateInvitedUser(userId: string) { + this.activated.push(userId); + this.rows.find((entry) => entry.id === userId)!.status = 'active'; + } + + async touchLastLogin(userId: string) { + this.lastLogins.push(userId); + } +} + +class FakeUserAccessRepository { + rows: UserAccessDomain[] = []; + + async findByUserId(userId: string) { + return this.rows.filter((row) => row.userId === userId); + } + + async update(id: string, changes: { status: 'active' | 'invited' }) { + const row = this.rows.find((entry) => entry.id === id)!; + row.status = changes.status; + return row; + } +} + +class FakeRoleRepository { + rows: RoleDomain[] = [ + role({ id: 'role-dev', slug: 'org-developer', organizationId: ORG.id }), + role({ + id: 'role-admin', + slug: 'org-admin', + organizationId: ORG.id, + permissions: ORGANIZATION_ADMIN_PERMISSIONS, + }), + role({ + id: 'role-readers', + slug: 'readers', + organizationId: ORG.id, + permissions: ['organization:projects:read', 'organization:audit:read'], + }), + // a custom role that looks harmless by name but can manage members + role({ + id: 'role-sneaky', + slug: 'support', + organizationId: ORG.id, + permissions: ['organization:projects:read', 'organization:users:update'], + }), + role({ id: 'role-other', slug: 'org-developer', organizationId: OTHER_ORG_ID }), + ]; + + async findAll(_scope: string, organizationId: string) { + return this.rows.filter((row) => row.organizationId === organizationId); + } + + async findById(id: string) { + return this.rows.find((row) => row.id === id) ?? null; + } + + async findBySlug(slug: string, scope: string, organizationId?: string) { + return ( + this.rows.find( + (row) => row.slug === slug && row.scope === scope && row.organizationId === organizationId, + ) ?? null + ); + } +} + +/** Mirrors UserAccessService.provisionOrganizationMember on top of the fakes above. */ +class FakeMemberProvisioner { + calls: Array<{ organizationId: string; email: string; roleId: string; userId?: string | null }> = + []; + + constructor( + private readonly users: FakeUserRepository, + private readonly accesses: FakeUserAccessRepository, + ) {} + + async provisionOrganizationMember(input: { + organizationId: string; + email: string; + roleId: string; + userId?: string | null; + }) { + this.calls.push(input); + let created = input.userId ? this.users.rows.find((row) => row.id === input.userId) : null; + if (!created) { + created = user({ + id: `user-${this.users.rows.length + 1}`, + username: 'new', + email: input.email, + }); + this.users.rows.push(created); + } + this.accesses.rows.push( + access({ + id: `access-${this.accesses.rows.length + 1}`, + userId: created.id, + roleId: input.roleId, + organizationId: input.organizationId, + }), + ); + return created; + } +} + +function role(input: { id: string; slug: string; organizationId: string; permissions?: string[] }) { + return new RoleDomain({ + permissions: ORGANIZATION_DEVELOPER_PERMISSIONS, + ...input, + name: input.slug, + scope: 'organization', + projectId: null, + ...TIMESTAMPS, + }); +} + +const fakeCipher = { + encrypt: (value: string) => `enc:${value}`, + decrypt: (value: string) => value.replace(/^enc:/, ''), +}; + +function user(overrides: Record = {}) { + return new UserDomain({ + id: 'user-1', + username: 'ana', + email: 'ana@gigigo.com', + password: 'hash', + status: 'active', + role: null, + authProviders: [{ provider: 'local', providerId: null }], + ...TIMESTAMPS, + ...overrides, + }); +} + +function access(overrides: Record = {}) { + return new UserAccessDomain({ + id: 'access-1', + userId: 'user-1', + roleId: 'role-dev', + scope: 'organization', + organizationId: ORG.id, + status: 'active', + ...TIMESTAMPS, + ...overrides, + }); +} + +describe('SsoService', () => { + let providers: FakeAuthProviderRepository; + let users: FakeUserRepository; + let accesses: FakeUserAccessRepository; + let roles: FakeRoleRepository; + let provisioner: FakeMemberProvisioner; + let googleClaims: GoogleIdTokenClaims; + let googleClient: { + buildAuthorizationUrl: ReturnType; + exchangeCode: ReturnType; + }; + let now: number; + let service: SsoService; + + beforeEach(async () => { + providers = new FakeAuthProviderRepository(); + users = new FakeUserRepository(); + accesses = new FakeUserAccessRepository(); + roles = new FakeRoleRepository(); + provisioner = new FakeMemberProvisioner(users, accesses); + now = Date.UTC(2026, 0, 1); + googleClaims = { + sub: 'google-sub-1', + email: 'ana@gigigo.com', + email_verified: true, + hd: 'gigigo.com', + }; + googleClient = { + buildAuthorizationUrl: vi.fn( + (request: { state: string; nonce: string }) => + `https://accounts.google.com/auth?state=${request.state}&nonce=${request.nonce}`, + ), + // echo the nonce from the authorization request, as Google does + exchangeCode: vi.fn(async () => ({ ...googleClaims, nonce: lastNonce() })), + }; + service = new SsoService( + providers as any, + users as any, + accesses as any, + roles as any, + provisioner, + fakeCipher, + googleClient as any, + () => 'signing-key', + () => now, + ); + + await service.saveGoogleSettings(ORG.id, { + enabled: true, + clientId: 'client-123', + clientSecret: 'super-secret', + allowedDomains: ['gigigo.com'], + }); + users.rows.push(user()); + accesses.rows.push(access()); + }); + + function lastNonce() { + const url = googleClient.buildAuthorizationUrl.mock.results.at(-1)?.value as string; + return new URL(url).searchParams.get('nonce') ?? undefined; + } + + async function signIn(overrides: { state?: string; flowCookie?: string } = {}) { + const started = await service.startGoogleSignIn({ + organization: ORG, + redirectUri: REDIRECT_URI, + }); + const state = new URL(started.authorizationUrl).searchParams.get('state')!; + return service.completeGoogleSignIn({ + flowCookie: overrides.flowCookie ?? started.flowCookie, + state: overrides.state ?? state, + code: 'auth-code', + redirectUri: REDIRECT_URI, + }); + } + + async function expectSsoError(promise: Promise, code: string) { + await expect(promise).rejects.toBeInstanceOf(SsoError); + await expect(promise).rejects.toMatchObject({ code }); + } + + describe('settings', () => { + it('stores the client secret encrypted and never returns it', async () => { + const view = await service.getGoogleSettings(ORG.id); + + expect(view).toEqual({ + enabled: true, + clientId: 'client-123', + hasClientSecret: true, + allowedDomains: ['gigigo.com'], + autoProvision: false, + autoProvisionRoleId: null, + }); + expect(JSON.stringify(view)).not.toContain('super-secret'); + expect(providers.rows[0].clientSecretEncrypted).toBe('enc:super-secret'); + }); + + it('keeps the stored secret when saved with an empty one', async () => { + await service.saveGoogleSettings(ORG.id, { + enabled: true, + clientId: 'client-456', + clientSecret: '', + allowedDomains: [], + }); + expect(providers.rows[0].clientSecretEncrypted).toBe('enc:super-secret'); + expect(providers.rows[0].clientId).toBe('client-456'); + }); + + it('normalizes and validates allowed domains', async () => { + const view = await service.saveGoogleSettings(ORG.id, { + enabled: true, + clientId: 'client-123', + allowedDomains: [' Gigigo.com ', 'gigigo.com', '', 'example.org'], + }); + expect(view.allowedDomains).toEqual(['gigigo.com', 'example.org']); + + await expect( + service.saveGoogleSettings(ORG.id, { + enabled: true, + clientId: 'client-123', + allowedDomains: ['not a domain'], + }), + ).rejects.toThrow(/valid domain/); + }); + + it('refuses to enable Google without client credentials', async () => { + await expect( + service.saveGoogleSettings(OTHER_ORG_ID, { + enabled: true, + clientId: 'client-123', + allowedDomains: [], + }), + ).rejects.toThrow(/Client Secret/); + await expect( + service.saveGoogleSettings(OTHER_ORG_ID, { + enabled: true, + clientId: ' ', + clientSecret: 'secret', + allowedDomains: [], + }), + ).rejects.toThrow(/Client ID/); + }); + + it('reports whether Google sign-in is usable', async () => { + expect(await service.isGoogleEnabled(ORG.id)).toBe(true); + expect(await service.isGoogleEnabled(OTHER_ORG_ID)).toBe(false); + + await service.saveGoogleSettings(ORG.id, { + enabled: false, + clientId: 'client-123', + allowedDomains: [], + }); + expect(await service.isGoogleEnabled(ORG.id)).toBe(false); + }); + }); + + describe('automatic sign-up settings', () => { + const base = { enabled: true, clientId: 'client-123', allowedDomains: ['gigigo.com'] }; + + it('defaults the role to Org Developer of the organization', async () => { + const view = await service.saveGoogleSettings(ORG.id, { ...base, autoProvision: true }); + expect(view).toMatchObject({ autoProvision: true, autoProvisionRoleId: 'role-dev' }); + }); + + it('stores an explicit non-administrative organization role', async () => { + const view = await service.saveGoogleSettings(ORG.id, { + ...base, + autoProvision: true, + autoProvisionRoleId: 'role-readers', + }); + expect(view.autoProvisionRoleId).toBe('role-readers'); + }); + + it('rejects roles with administrative permissions, whatever their name', async () => { + for (const autoProvisionRoleId of ['role-admin', 'role-sneaky']) { + await expect( + service.saveGoogleSettings(ORG.id, { ...base, autoProvision: true, autoProvisionRoleId }), + ).rejects.toThrow(/administrative permissions/); + } + expect(providers.rows[0].autoProvision).toBe(false); + }); + + it('lists only the roles automatic sign-up may grant', async () => { + const listed = await service.listAutoProvisionRoles(ORG.id); + expect(listed.map((entry) => entry.id)).toEqual(['role-dev', 'role-readers']); + }); + + it('rejects roles of another organization or unknown roles', async () => { + for (const autoProvisionRoleId of ['role-other', 'missing']) { + await expect( + service.saveGoogleSettings(ORG.id, { ...base, autoProvision: true, autoProvisionRoleId }), + ).rejects.toThrow(/role of this organization/); + } + }); + + it('requires at least one allowed domain', async () => { + await expect( + service.saveGoogleSettings(ORG.id, { ...base, allowedDomains: [], autoProvision: true }), + ).rejects.toThrow(/allowed Workspace domain/); + }); + + it('clears the role when automatic sign-up is turned off', async () => { + await service.saveGoogleSettings(ORG.id, { ...base, autoProvision: true }); + const view = await service.saveGoogleSettings(ORG.id, { ...base, autoProvision: false }); + expect(view).toMatchObject({ autoProvision: false, autoProvisionRoleId: null }); + }); + }); + + describe('findSoleGoogleOrganizationId', () => { + it('returns the organization when it is the only one with Google enabled', async () => { + expect(await service.findSoleGoogleOrganizationId()).toBe(ORG.id); + }); + + it('returns null when several organizations have Google enabled', async () => { + await service.saveGoogleSettings(OTHER_ORG_ID, { + enabled: true, + clientId: 'client-other', + clientSecret: 'other-secret', + allowedDomains: [], + }); + expect(await service.findSoleGoogleOrganizationId()).toBeNull(); + }); + + it('returns null when no organization has Google enabled', async () => { + await service.saveGoogleSettings(ORG.id, { + enabled: false, + clientId: 'client-123', + allowedDomains: [], + }); + expect(await service.findSoleGoogleOrganizationId()).toBeNull(); + }); + }); + + describe('startGoogleSignIn', () => { + it('builds a PKCE request with the hosted domain hint', async () => { + await service.startGoogleSignIn({ organization: ORG, redirectUri: REDIRECT_URI }); + + const request = googleClient.buildAuthorizationUrl.mock.calls[0]?.[0]; + expect(request).toMatchObject({ + clientId: 'client-123', + redirectUri: REDIRECT_URI, + hostedDomain: 'gigigo.com', + }); + expect(request.codeChallenge).toMatch(/^[A-Za-z0-9_-]{43}$/); + }); + + it('fails when the organization has no usable provider', async () => { + await expectSsoError( + service.startGoogleSignIn({ + organization: { id: OTHER_ORG_ID, slug: 'other' }, + redirectUri: REDIRECT_URI, + }), + 'not_configured', + ); + }); + }); + + describe('completeGoogleSignIn', () => { + it('signs in an invited member and links the Google identity', async () => { + const result = await signIn(); + + expect(result).toEqual({ userId: 'user-1', organizationSlug: 'gitops' }); + expect(users.rows[0].authProviders).toContainEqual({ + provider: 'google', + providerId: 'google-sub-1', + }); + expect(users.lastLogins).toEqual(['user-1']); + const exchange = googleClient.exchangeCode.mock.calls[0]?.[0]; + expect(exchange.clientSecret).toBe('super-secret'); + }); + + it('resolves an already linked user by Google subject even if the email changed', async () => { + users.rows[0].email = 'ana.old@gigigo.com'; + users.rows[0].authProviders.push({ provider: 'google', providerId: 'google-sub-1' }); + + await expect(signIn()).resolves.toMatchObject({ userId: 'user-1' }); + }); + + it('accepts a pending invitation instead of asking for a password', async () => { + users.rows[0].status = 'invited'; + accesses.rows[0].status = 'invited'; + + await signIn(); + + expect(users.activated).toEqual(['user-1']); + expect(accesses.rows[0].status).toBe('active'); + }); + + it('accepts users whose only access is to a project of the organization', async () => { + accesses.rows = [ + access({ + scope: 'project', + organizationId: null, + projectId: 'project-1', + project: { + id: 'project-1', + name: 'P', + slug: 'p', + organization: { id: ORG.id, ...TIMESTAMPS }, + ...TIMESTAMPS, + }, + }), + ]; + + await expect(signIn()).resolves.toMatchObject({ userId: 'user-1' }); + }); + + it('rejects users with no account', async () => { + users.rows = []; + await expectSsoError(signIn(), 'not_invited'); + }); + + it('rejects users invited only to another organization', async () => { + accesses.rows = [access({ organizationId: OTHER_ORG_ID })]; + await expectSsoError(signIn(), 'not_invited'); + expect(users.rows[0].authProviders).not.toContainEqual( + expect.objectContaining({ provider: 'google' }), + ); + }); + + it('rejects disabled accounts', async () => { + users.rows[0].disabled = true; + await expectSsoError(signIn(), 'account_disabled'); + }); + + it('rejects unverified emails', async () => { + googleClaims.email_verified = false; + await expectSsoError(signIn(), 'email_not_verified'); + }); + + it('rejects accounts outside the allowed Workspace domains', async () => { + googleClaims.hd = 'evil.com'; + await expectSsoError(signIn(), 'domain_not_allowed'); + }); + + it('does not trust the email suffix without the hd claim', async () => { + delete googleClaims.hd; + await expectSsoError(signIn(), 'domain_not_allowed'); + }); + + it('accepts any verified account when no domain is restricted', async () => { + await service.saveGoogleSettings(ORG.id, { + enabled: true, + clientId: 'client-123', + allowedDomains: [], + }); + delete googleClaims.hd; + await expect(signIn()).resolves.toMatchObject({ userId: 'user-1' }); + }); + + describe('with automatic sign-up', () => { + beforeEach(async () => { + await service.saveGoogleSettings(ORG.id, { + enabled: true, + clientId: 'client-123', + allowedDomains: ['gigigo.com'], + autoProvision: true, + }); + }); + + it('creates an account with the configured role for a new domain user', async () => { + users.rows = []; + accesses.rows = []; + + const result = await signIn(); + + expect(provisioner.calls).toEqual([ + { organizationId: ORG.id, email: 'ana@gigigo.com', roleId: 'role-dev', userId: null }, + ]); + expect(result.organizationSlug).toBe('gitops'); + expect(users.rows[0].authProviders).toContainEqual({ + provider: 'google', + providerId: 'google-sub-1', + }); + expect(users.lastLogins).toEqual([result.userId]); + }); + + it('grants access to an existing account that belongs to another organization', async () => { + accesses.rows = [access({ organizationId: OTHER_ORG_ID })]; + + await expect(signIn()).resolves.toMatchObject({ userId: 'user-1' }); + expect(provisioner.calls).toEqual([ + { organizationId: ORG.id, email: 'ana@gigigo.com', roleId: 'role-dev', userId: 'user-1' }, + ]); + }); + + it('does not touch members that already have access', async () => { + await signIn(); + expect(provisioner.calls).toEqual([]); + }); + + it('still rejects disabled accounts', async () => { + accesses.rows = []; + users.rows[0].disabled = true; + await expectSsoError(signIn(), 'account_disabled'); + expect(provisioner.calls).toEqual([]); + }); + + it('still rejects accounts outside the allowed domains', async () => { + users.rows = []; + googleClaims.hd = 'evil.com'; + await expectSsoError(signIn(), 'domain_not_allowed'); + expect(provisioner.calls).toEqual([]); + }); + + it('never provisions a personal account without the hd claim', async () => { + users.rows = []; + delete googleClaims.hd; + await expectSsoError(signIn(), 'domain_not_allowed'); + expect(provisioner.calls).toEqual([]); + }); + + it('fails closed when the configured role no longer exists', async () => { + users.rows = []; + provisioner.provisionOrganizationMember = async () => { + throw new Error('Role not found'); + }; + await expectSsoError(signIn(), 'not_invited'); + }); + }); + + it('rejects a mismatched state', async () => { + await expectSsoError(signIn({ state: 'forged' }), 'invalid_state'); + expect(googleClient.exchangeCode).not.toHaveBeenCalled(); + }); + + it('rejects a tampered flow cookie', async () => { + const started = await service.startGoogleSignIn({ + organization: ORG, + redirectUri: REDIRECT_URI, + }); + const [payload] = started.flowCookie.split('.'); + await expectSsoError(signIn({ flowCookie: `${payload}.forged` }), 'invalid_state'); + }); + + it('rejects an expired flow', async () => { + const started = await service.startGoogleSignIn({ + organization: ORG, + redirectUri: REDIRECT_URI, + }); + const state = new URL(started.authorizationUrl).searchParams.get('state')!; + now += 11 * 60 * 1000; + + await expectSsoError( + service.completeGoogleSignIn({ + flowCookie: started.flowCookie, + state, + code: 'auth-code', + redirectUri: REDIRECT_URI, + }), + 'invalid_state', + ); + }); + + it('rejects a replayed ID token with another nonce', async () => { + googleClient.exchangeCode.mockResolvedValueOnce({ ...googleClaims, nonce: 'other' }); + await expectSsoError(signIn(), 'invalid_state'); + }); + + it('maps Google failures to provider_error', async () => { + googleClient.exchangeCode.mockRejectedValueOnce(new Error('invalid_grant')); + await expectSsoError(signIn(), 'provider_error'); + }); + + it('fails when the provider was disabled mid-flow', async () => { + const started = await service.startGoogleSignIn({ + organization: ORG, + redirectUri: REDIRECT_URI, + }); + const state = new URL(started.authorizationUrl).searchParams.get('state')!; + await service.saveGoogleSettings(ORG.id, { + enabled: false, + clientId: 'client-123', + allowedDomains: [], + }); + + await expectSsoError( + service.completeGoogleSignIn({ + flowCookie: started.flowCookie, + state, + code: 'auth-code', + redirectUri: REDIRECT_URI, + }), + 'not_configured', + ); + }); + }); + + it('exposes the organization of a pending flow', async () => { + const started = await service.startGoogleSignIn({ + organization: ORG, + redirectUri: REDIRECT_URI, + }); + expect(service.peekFlowOrganization(started.flowCookie)).toBe('gitops'); + expect(service.peekFlowOrganization('garbage')).toBeNull(); + }); +}); diff --git a/src/modules/auth/application/sso.service.ts b/src/modules/auth/application/sso.service.ts new file mode 100644 index 0000000..f59b6f5 --- /dev/null +++ b/src/modules/auth/application/sso.service.ts @@ -0,0 +1,372 @@ +import crypto from 'crypto'; +import type { AuthProviderDomain } from '../domain/auth-provider.domain'; +import type { UserDomain } from '../domain/user.domain'; +import type { UserAccessDomain } from '../domain/user-access.domain'; +import type { AuthProviderRepository } from '../infrastructure/repositories/auth-provider.repository'; +import type { RoleRepository } from '../infrastructure/repositories/role.repository'; +import type { UserAccessRepository } from '../infrastructure/repositories/user-access.repository'; +import type { UserRepository } from '../infrastructure/repositories/user.repository'; +import type { GoogleOidcClient } from '../infrastructure/oidc/google-oidc.client'; +import type { SecretCipherService } from './secret-cipher.service'; +import type { UserAccessService } from './user-access.service'; + +const GOOGLE = 'google'; +const SECRET_PURPOSE = 'sso:google:client-secret'; +const FLOW_TTL_MS = 10 * 60 * 1000; +const DEFAULT_AUTO_PROVISION_ROLE = 'org-developer'; +const DOMAIN_PATTERN = /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/; + +export type SsoErrorCode = + | 'not_configured' + | 'invalid_state' + | 'provider_error' + | 'email_not_verified' + | 'domain_not_allowed' + | 'not_invited' + | 'account_disabled'; + +/** Sign-in failure with a stable code the login page maps to a user-facing message. */ +export class SsoError extends Error { + constructor( + public readonly code: SsoErrorCode, + message: string, + ) { + super(message); + } +} + +export type GoogleSsoSettingsView = { + enabled: boolean; + clientId: string | null; + hasClientSecret: boolean; + allowedDomains: string[]; + autoProvision: boolean; + autoProvisionRoleId: string | null; +}; + +export type GoogleSsoSettingsInput = { + enabled: boolean; + clientId: string; + /** empty keeps the stored secret, so the UI never has to receive it back */ + clientSecret?: string; + allowedDomains: string[]; + autoProvision?: boolean; + /** organization role granted to auto-provisioned members; empty picks Org Developer */ + autoProvisionRoleId?: string | null; +}; + +type SsoFlow = { + state: string; + nonce: string; + codeVerifier: string; + organizationId: string; + organizationSlug: string; + expiresAt: number; +}; + +export class SsoService { + constructor( + private readonly authProviderRepository: Pick< + AuthProviderRepository, + 'findByOrganization' | 'findEnabled' | 'upsert' + >, + private readonly userRepository: Pick< + UserRepository, + | 'findByAuthProvider' + | 'findByEmail' + | 'updateAuthProviders' + | 'activateInvitedUser' + | 'touchLastLogin' + >, + private readonly userAccessRepository: Pick, + private readonly roleRepository: Pick, + private readonly memberProvisioner: Pick, + private readonly secretCipher: Pick, + private readonly googleClient: Pick, + private readonly signingKey: () => string, + private readonly now: () => number = () => Date.now(), + ) {} + + async getGoogleSettings(organizationId: string): Promise { + const provider = await this.authProviderRepository.findByOrganization(organizationId, GOOGLE); + return this.toSettingsView(provider); + } + + async saveGoogleSettings( + organizationId: string, + input: GoogleSsoSettingsInput, + ): Promise { + const clientId = input.clientId.trim(); + const clientSecret = input.clientSecret?.trim() ?? ''; + const allowedDomains = [ + ...new Set(input.allowedDomains.map((domain) => domain.trim().toLowerCase()).filter(Boolean)), + ]; + + const invalidDomain = allowedDomains.find((domain) => !DOMAIN_PATTERN.test(domain)); + if (invalidDomain) throw new Error(`"${invalidDomain}" is not a valid domain`); + + const existing = await this.authProviderRepository.findByOrganization(organizationId, GOOGLE); + if (input.enabled) { + if (!clientId) throw new Error('Google Client ID is required to enable Google sign-in'); + if (!clientSecret && !existing?.clientSecretEncrypted) { + throw new Error('Google Client Secret is required to enable Google sign-in'); + } + } + + const autoProvision = input.autoProvision ?? false; + if (autoProvision && allowedDomains.length === 0) { + throw new Error('Automatic sign-up requires at least one allowed Workspace domain'); + } + const autoProvisionRoleId = autoProvision + ? await this.resolveAutoProvisionRoleId(organizationId, input.autoProvisionRoleId) + : null; + + const updated = await this.authProviderRepository.upsert(organizationId, GOOGLE, { + enabled: input.enabled, + clientId: clientId || null, + allowedDomains, + autoProvision, + autoProvisionRoleId, + ...(clientSecret + ? { clientSecretEncrypted: this.secretCipher.encrypt(clientSecret, SECRET_PURPOSE) } + : {}), + }); + return this.toSettingsView(updated); + } + + /** Organization roles automatic sign-up may grant, i.e. without administrative permissions. */ + async listAutoProvisionRoles( + organizationId: string, + ): Promise> { + const roles = await this.roleRepository.findAll('organization', organizationId); + return roles + .filter((role) => role.organizationId === organizationId && role.isAutoProvisionable) + .map((role) => ({ id: role.id, name: role.name, slug: role.slug })); + } + + async isGoogleEnabled(organizationId: string): Promise { + const provider = await this.authProviderRepository.findByOrganization(organizationId, GOOGLE); + return provider?.isUsable ?? false; + } + + async findSoleGoogleOrganizationId(): Promise { + const usable = (await this.authProviderRepository.findEnabled(GOOGLE)).filter( + (provider) => provider.isUsable, + ); + return usable.length === 1 ? usable[0].organizationId : null; + } + + async startGoogleSignIn(input: { + organization: { id: string; slug: string }; + redirectUri: string; + }): Promise<{ authorizationUrl: string; flowCookie: string }> { + const provider = await this.requireUsableProvider(input.organization.id); + + const flow: SsoFlow = { + state: this.randomToken(), + nonce: this.randomToken(), + codeVerifier: this.randomToken(), + organizationId: input.organization.id, + organizationSlug: input.organization.slug, + expiresAt: this.now() + FLOW_TTL_MS, + }; + + const authorizationUrl = this.googleClient.buildAuthorizationUrl({ + clientId: provider.clientId!, + redirectUri: input.redirectUri, + state: flow.state, + nonce: flow.nonce, + codeChallenge: crypto.createHash('sha256').update(flow.codeVerifier).digest('base64url'), + hostedDomain: provider.allowedDomains.length === 1 ? provider.allowedDomains[0] : undefined, + }); + + return { authorizationUrl, flowCookie: this.signFlow(flow) }; + } + + peekFlowOrganization(flowCookie: string | undefined): string | null { + return this.readFlow(flowCookie)?.organizationSlug ?? null; + } + + async completeGoogleSignIn(input: { + flowCookie: string | undefined; + state: string; + code: string; + redirectUri: string; + }): Promise<{ userId: string; organizationSlug: string }> { + const flow = this.readFlow(input.flowCookie); + if (!flow || !input.state || !this.safeEqual(flow.state, input.state)) { + throw new SsoError('invalid_state', 'The sign-in request expired or was tampered with'); + } + if (!input.code) throw new SsoError('provider_error', 'Google did not return a code'); + + const provider = await this.requireUsableProvider(flow.organizationId); + + let claims; + try { + claims = await this.googleClient.exchangeCode({ + clientId: provider.clientId!, + clientSecret: this.secretCipher.decrypt(provider.clientSecretEncrypted!, SECRET_PURPOSE), + code: input.code, + codeVerifier: flow.codeVerifier, + redirectUri: input.redirectUri, + }); + } catch (error) { + throw new SsoError( + 'provider_error', + error instanceof Error ? error.message : 'Google sign-in failed', + ); + } + + if (!claims.nonce || !this.safeEqual(claims.nonce, flow.nonce)) { + throw new SsoError('invalid_state', 'ID token nonce mismatch'); + } + const email = claims.email?.trim().toLowerCase(); + if (!email || !claims.email_verified) { + throw new SsoError('email_not_verified', 'Google account email is not verified'); + } + if (!provider.allowsHostedDomain(claims.hd)) { + throw new SsoError('domain_not_allowed', 'Google account domain is not allowed'); + } + + let user = + (await this.userRepository.findByAuthProvider(GOOGLE, claims.sub)) ?? + (await this.userRepository.findByEmail(email)); + if (user?.disabled) throw new SsoError('account_disabled', 'Account is disabled'); + + let access = user ? await this.userAccessRepository.findByUserId(user.id) : []; + if (!access.some((entry) => this.belongsToOrganization(entry, flow.organizationId))) { + if (!provider.allowsAutoProvision(claims.hd)) { + throw new SsoError('not_invited', 'Account has no access to this organization'); + } + user = await this.provisionMember(provider, email, user?.id ?? null); + access = await this.userAccessRepository.findByUserId(user.id); + } + if (!user) throw new SsoError('not_invited', 'No invited account for this Google identity'); + + await this.linkGoogleIdentity(user, claims.sub); + if (user.status === 'invited') await this.acceptPendingInvitation(user.id, access); + await this.userRepository.touchLastLogin(user.id); + + return { userId: user.id, organizationSlug: flow.organizationSlug }; + } + + private async requireUsableProvider(organizationId: string): Promise { + const provider = await this.authProviderRepository.findByOrganization(organizationId, GOOGLE); + if (!provider?.isUsable) { + throw new SsoError('not_configured', 'Google sign-in is not enabled for this organization'); + } + return provider; + } + + private async resolveAutoProvisionRoleId( + organizationId: string, + roleId: string | null | undefined, + ): Promise { + const role = roleId?.trim() + ? await this.roleRepository.findById(roleId.trim()) + : await this.roleRepository.findBySlug( + DEFAULT_AUTO_PROVISION_ROLE, + 'organization', + organizationId, + ); + if (!role || role.scope !== 'organization' || role.organizationId !== organizationId) { + throw new Error('Automatic sign-up role must be a role of this organization'); + } + if (!role.isAutoProvisionable) { + throw new Error('Automatic sign-up role cannot grant administrative permissions'); + } + return role.id; + } + + private async provisionMember( + provider: AuthProviderDomain, + email: string, + userId: string | null, + ): Promise { + try { + return await this.memberProvisioner.provisionOrganizationMember({ + organizationId: provider.organizationId, + email, + roleId: provider.autoProvisionRoleId!, + userId, + }); + } catch { + // e.g. the configured role was deleted or gained admin permissions afterwards: fail closed + throw new SsoError('not_invited', 'Automatic sign-up is misconfigured for this organization'); + } + } + + private async linkGoogleIdentity(user: UserDomain, sub: string): Promise { + if (user.hasAuthProvider(GOOGLE, sub)) return; + await this.userRepository.updateAuthProviders(user.id, [ + ...user.authProviders.filter((entry) => entry.provider !== GOOGLE), + { provider: GOOGLE, providerId: sub }, + ]); + } + + private async acceptPendingInvitation(userId: string, access: UserAccessDomain[]) { + await this.userRepository.activateInvitedUser(userId); + await Promise.all( + access + .filter((entry) => entry.status === 'invited') + .map((entry) => this.userAccessRepository.update(entry.id, { status: 'active' })), + ); + } + + private belongsToOrganization(entry: UserAccessDomain, organizationId: string): boolean { + if (entry.scope === 'organization') return entry.organizationId === organizationId; + if (entry.scope === 'project') { + return ( + entry.organizationId === organizationId || + entry.project?.organization?.id === organizationId + ); + } + return false; + } + + private toSettingsView(provider: AuthProviderDomain | null): GoogleSsoSettingsView { + return { + enabled: provider?.enabled ?? false, + clientId: provider?.clientId ?? null, + hasClientSecret: Boolean(provider?.clientSecretEncrypted), + allowedDomains: provider ? [...provider.allowedDomains] : [], + autoProvision: provider?.autoProvision ?? false, + autoProvisionRoleId: provider?.autoProvisionRoleId ?? null, + }; + } + + private signFlow(flow: SsoFlow): string { + const payload = Buffer.from(JSON.stringify(flow)).toString('base64url'); + return `${payload}.${this.sign(payload)}`; + } + + private readFlow(cookie: string | undefined): SsoFlow | null { + if (!cookie) return null; + const [payload, signature] = cookie.split('.'); + if (!payload || !signature || !this.safeEqual(signature, this.sign(payload))) return null; + + try { + const flow = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as SsoFlow; + return typeof flow.expiresAt === 'number' && flow.expiresAt > this.now() ? flow : null; + } catch { + return null; + } + } + + private sign(payload: string): string { + return crypto + .createHmac('sha256', this.signingKey()) + .update(`sso-flow:${payload}`) + .digest('base64url'); + } + + private randomToken(): string { + return crypto.randomBytes(32).toString('base64url'); + } + + private safeEqual(left: string, right: string): boolean { + const a = Buffer.from(left); + const b = Buffer.from(right); + return a.length === b.length && crypto.timingSafeEqual(a, b); + } +} diff --git a/src/modules/auth/application/user-access.service.test.ts b/src/modules/auth/application/user-access.service.test.ts index b35aebf..075df00 100644 --- a/src/modules/auth/application/user-access.service.test.ts +++ b/src/modules/auth/application/user-access.service.test.ts @@ -251,6 +251,95 @@ describe('UserAccessService', () => { expect(userRepository.rows[0].role?.slug).toBe('cluster-user'); }); + describe('provisionOrganizationMember', () => { + beforeEach(() => { + roleRepository.rows.push( + role({ + id: 'org-developer-id', + slug: 'org-developer', + scope: 'organization', + organizationId: 'gitops', + }), + ); + }); + + it('creates an active account with organization access and no invitation', async () => { + const created = await service.provisionOrganizationMember({ + organizationId: 'gitops', + email: 'Ana@Gigigo.com', + roleId: 'org-developer-id', + }); + + expect(created.status).toBe('active'); + expect(userRepository.rows[0]).toMatchObject({ + username: 'ana', + email: 'ana@gigigo.com', + status: 'active', + }); + expect(userRepository.rows[0].role?.slug).toBe('cluster-user'); + expect(userAccessRepository.rows[0]).toMatchObject({ + userId: created.id, + roleId: 'org-developer-id', + scope: 'organization', + organizationId: 'gitops', + status: 'active', + }); + expect(invitationNotifier.sendInvitation).not.toHaveBeenCalled(); + }); + + it('grants access to the given existing account', async () => { + userRepository.rows.push(user({ id: 'existing', username: 'ana', email: null, role: null })); + + const provisioned = await service.provisionOrganizationMember({ + organizationId: 'gitops', + email: 'ana@gigigo.com', + roleId: 'org-developer-id', + userId: 'existing', + }); + + expect(provisioned.id).toBe('existing'); + expect(userRepository.rows).toHaveLength(1); + expect(userAccessRepository.rows[0].userId).toBe('existing'); + }); + + it('refuses roles that grant administrative permissions', async () => { + roleRepository.rows.push( + new RoleDomain({ + id: 'org-admin-id', + slug: 'org-admin', + name: 'Org Admin', + scope: 'organization', + organizationId: 'gitops', + projectId: null, + permissions: ['organization:users:all'], + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }), + ); + + await expect( + service.provisionOrganizationMember({ + organizationId: 'gitops', + email: 'ana@gigigo.com', + roleId: 'org-admin-id', + }), + ).rejects.toThrow(/administrative permissions/); + expect(userRepository.rows).toHaveLength(0); + expect(userAccessRepository.rows).toHaveLength(0); + }); + + it('rejects roles that do not belong to the organization', async () => { + await expect( + service.provisionOrganizationMember({ + organizationId: 'other', + email: 'ana@gigigo.com', + roleId: 'org-developer-id', + }), + ).rejects.toThrow(/does not belong/); + expect(userRepository.rows).toHaveLength(0); + }); + }); + describe('inviteOrganizationUser', () => { function seedOrganizationRole() { roleRepository.rows.push( diff --git a/src/modules/auth/application/user-access.service.ts b/src/modules/auth/application/user-access.service.ts index cf5abae..a4a7906 100644 --- a/src/modules/auth/application/user-access.service.ts +++ b/src/modules/auth/application/user-access.service.ts @@ -129,7 +129,8 @@ export class UserAccessService { : await this.findDefaultOrganizationRole(organizationId); const user = - (await this.userRepository.findByEmail(email)) ?? (await this.createInvitedUser(email)); + (await this.userRepository.findByEmail(email)) ?? + (await this.createUserFromEmail(email, 'invited')); // an already active account just gets access, there is nothing to activate const status: UserAccessStatus = user.status === 'active' ? 'active' : 'invited'; @@ -159,7 +160,43 @@ export class UserAccessService { return this.toRow(access); } - private async createInvitedUser(email: string) { + /** + * Grants organization access to an identity already verified by an SSO provider, creating its + * account when missing. Callers must have checked the identity against the organization policy. + */ + async provisionOrganizationMember(input: { + organizationId: string; + email: string; + roleId: string; + /** existing account to grant access to; otherwise it is looked up or created by email */ + userId?: string | null; + }): Promise { + const organizationId = input.organizationId.trim(); + const email = input.email.trim().toLowerCase(); + if (!organizationId) throw new Error('Organization is required'); + if (!email) throw new Error('Email is required'); + + const role = await this.findRoleForScope(input.roleId, 'organization', organizationId); + // re-checked on every sign-up: the role may have been edited since it was configured + if (!role.isAutoProvisionable) { + throw new Error('Role grants administrative permissions'); + } + const existing = input.userId + ? await this.userRepository.findById(input.userId) + : await this.userRepository.findByEmail(email); + if (input.userId && !existing) throw new Error('User not found'); + const user = existing ?? (await this.createUserFromEmail(email, 'active')); + + await this.createAccess({ + userId: user.id, + roleId: role.id, + scope: 'organization', + organizationId, + }); + return user; + } + + private async createUserFromEmail(email: string, status: UserAccessStatus) { const clusterUserRole = await this.ensureClusterUserRole(); const username = await this.buildUsernameFromEmail(email); @@ -167,10 +204,10 @@ export class UserAccessService { id: crypto.randomUUID(), username, email, - // placeholder credential: the invited user must set a password before signing in + // placeholder credential: the user signs in through an invitation or SSO, or resets it passwordHash: this.passwordService.hashPassword(crypto.randomBytes(32).toString('hex')), role: this.toRoleView(clusterUserRole), - status: 'invited', + status, }); const user = await this.userRepository.findByUsername(username); diff --git a/src/modules/auth/domain/auth-provider.domain.ts b/src/modules/auth/domain/auth-provider.domain.ts new file mode 100644 index 0000000..6f08179 --- /dev/null +++ b/src/modules/auth/domain/auth-provider.domain.ts @@ -0,0 +1,64 @@ +import { Domain } from './domain'; + +export type AuthProviderType = 'google'; + +export class AuthProviderDomain extends Domain { + public organizationId: string = ''; + public provider: AuthProviderType = 'google'; + public enabled: boolean = false; + public clientId: string | null = null; + public clientSecretEncrypted: string | null = null; + public allowedDomains: string[] = []; + /** signs up any verified account of an allowed domain without an invitation */ + public autoProvision: boolean = false; + public autoProvisionRoleId: string | null = null; + + constructor(data: any) { + super(data); + this.organizationId = data.organizationId; + this.provider = data.provider; + this.enabled = Boolean(data.enabled); + this.clientId = data.clientId ?? null; + this.clientSecretEncrypted = data.clientSecretEncrypted ?? null; + this.allowedDomains = Array.isArray(data.allowedDomains) + ? data.allowedDomains.map((domain: unknown) => String(domain).toLowerCase()) + : []; + this.autoProvision = Boolean(data.autoProvision); + this.autoProvisionRoleId = data.autoProvisionRoleId ?? null; + } + + get isUsable(): boolean { + return this.enabled && Boolean(this.clientId) && Boolean(this.clientSecretEncrypted); + } + + allowsHostedDomain(hostedDomain: string | null | undefined): boolean { + if (this.allowedDomains.length === 0) return true; + return Boolean(hostedDomain) && this.allowedDomains.includes(hostedDomain!.toLowerCase()); + } + + /** Auto-provisioning is only honoured when restricted to Workspace domains, never for any Google account. */ + allowsAutoProvision(hostedDomain: string | null | undefined): boolean { + return ( + this.autoProvision && + Boolean(this.autoProvisionRoleId) && + this.allowedDomains.length > 0 && + this.allowsHostedDomain(hostedDomain) + ); + } + + toJson() { + return { + id: this.id, + organizationId: this.organizationId, + provider: this.provider, + enabled: this.enabled, + clientId: this.clientId, + hasClientSecret: Boolean(this.clientSecretEncrypted), + allowedDomains: [...this.allowedDomains], + autoProvision: this.autoProvision, + autoProvisionRoleId: this.autoProvisionRoleId, + createdAt: this.createdAt, + updatedAt: this.updatedAt, + }; + } +} diff --git a/src/modules/auth/domain/role-permissions.data.ts b/src/modules/auth/domain/role-permissions.data.ts index 1b7c5dc..5bd9636 100644 --- a/src/modules/auth/domain/role-permissions.data.ts +++ b/src/modules/auth/domain/role-permissions.data.ts @@ -24,6 +24,10 @@ export const ORGANIZATION_DEVELOPER_PERMISSIONS = [ 'organization:projects:update', ]; +// besides read grants, the most a role handed out by automatic sign-up may carry +export const AUTO_PROVISION_ALLOWED_ORGANIZATION_GRANTS: readonly string[] = + ORGANIZATION_DEVELOPER_PERMISSIONS; + export const PROJECT_ADMIN_PERMISSIONS = [ 'project:project:all', 'project:users:all', diff --git a/src/modules/auth/domain/role.domain.test.ts b/src/modules/auth/domain/role.domain.test.ts new file mode 100644 index 0000000..198ac82 --- /dev/null +++ b/src/modules/auth/domain/role.domain.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import { RoleDomain } from './role.domain'; +import { + ORGANIZATION_ADMIN_PERMISSIONS, + ORGANIZATION_DEVELOPER_PERMISSIONS, +} from './role-permissions.data'; + +function role(permissions: string[], scope: 'organization' | 'project' = 'organization') { + return new RoleDomain({ + id: 'role-1', + name: 'Role', + slug: 'role', + scope, + organizationId: 'org-1', + permissions, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }); +} + +describe('RoleDomain.isAutoProvisionable', () => { + it('accepts Org Developer and read-only roles', () => { + expect(role(ORGANIZATION_DEVELOPER_PERMISSIONS).isAutoProvisionable).toBe(true); + expect(role(['organization:audit:read', 'organization:users:read']).isAutoProvisionable).toBe( + true, + ); + }); + + it('rejects Org Admin', () => { + expect(role(ORGANIZATION_ADMIN_PERMISSIONS).isAutoProvisionable).toBe(false); + }); + + it.each([ + 'organization:users:invite', + 'organization:users:update', + 'organization:roles:create', + 'organization:settings:update', + 'organization:backups:all', + 'organization:server-keys:create', + 'organization:projects:delete', + 'organization:projects:all', + ])('rejects a role that also grants %s', (grant) => { + expect(role([...ORGANIZATION_DEVELOPER_PERMISSIONS, grant]).isAutoProvisionable).toBe(false); + }); + + it('rejects non-organization roles', () => { + expect(role(['project:project:read'], 'project').isAutoProvisionable).toBe(false); + }); +}); diff --git a/src/modules/auth/domain/role.domain.ts b/src/modules/auth/domain/role.domain.ts index 8c56021..296ce39 100644 --- a/src/modules/auth/domain/role.domain.ts +++ b/src/modules/auth/domain/role.domain.ts @@ -2,6 +2,7 @@ import { Domain } from './domain'; import { normalizePermissionGrants } from '$lib/permissions'; import { OrganizationDomain } from '../../organization/domain/organization.domain'; import { ProjectDomain } from '../../projects/domain/project.domain'; +import { AUTO_PROVISION_ALLOWED_ORGANIZATION_GRANTS } from './role-permissions.data'; export type RoleScope = 'cluster' | 'organization' | 'project'; @@ -30,6 +31,22 @@ export class RoleDomain extends Domain { : []; } + /** + * Whether the role may be granted without an administrator's review, as automatic sign-up does: + * an organization role with read grants and Org Developer grants only. Allow-listed, so any + * management, delete, invite or `:all` grant (including ones added later) disqualifies it. + */ + get isAutoProvisionable(): boolean { + return ( + this.scope === 'organization' && + this.permissions.every( + (grant) => + AUTO_PROVISION_ALLOWED_ORGANIZATION_GRANTS.includes(grant) || + /^organization:[a-z-]+:read$/.test(grant), + ) + ); + } + toJson() { return { id: this.id, diff --git a/src/modules/auth/domain/user.domain.ts b/src/modules/auth/domain/user.domain.ts index 5cf9a95..02341d4 100644 --- a/src/modules/auth/domain/user.domain.ts +++ b/src/modules/auth/domain/user.domain.ts @@ -1,6 +1,8 @@ import { Domain } from './domain'; import { RoleDomain } from './role.domain'; +export type UserAuthProvider = { provider: string; providerId: string | null }; + export class UserDomain extends Domain { public username: string = ''; public email: string | null = null; @@ -9,6 +11,8 @@ export class UserDomain extends Domain { public status: 'active' | 'invited' = 'active'; public invitationExpiresAt: Date | null = null; public passwordResetExpiresAt: Date | null = null; + public authProviders: UserAuthProvider[] = []; + public disabled: boolean = false; constructor(data: any) { super(data); this.username = data.username; @@ -20,6 +24,14 @@ export class UserDomain extends Domain { ? new Date(data.passwordResetExpiresAt) : null; this.role = data.role ? new RoleDomain(data.role) : null; + this.authProviders = Array.isArray(data.authProviders) ? data.authProviders : []; + this.disabled = Boolean(data.disabled); + } + + hasAuthProvider(provider: string, providerId: string): boolean { + return this.authProviders.some( + (entry) => entry.provider === provider && entry.providerId === providerId, + ); } toJson() { diff --git a/src/modules/auth/index.ts b/src/modules/auth/index.ts index fe7a309..d7fa0c7 100644 --- a/src/modules/auth/index.ts +++ b/src/modules/auth/index.ts @@ -9,10 +9,14 @@ import { InvitationService } from './application/invitation.service'; import { PasswordResetService } from './application/password-reset.service'; import { CanCanService } from './application/cancan.service'; import { RoleService } from './application/role.service'; +import { SecretCipherService } from './application/secret-cipher.service'; +import { SsoService } from './application/sso.service'; import { UserRepository } from './infrastructure/repositories/user.repository'; import { RoleRepository } from './infrastructure/repositories/role.repository'; import { ApiKeyRepository } from './infrastructure/repositories/apikey.repository'; import { UserAccessRepository } from './infrastructure/repositories/user-access.repository'; +import { AuthProviderRepository } from './infrastructure/repositories/auth-provider.repository'; +import { GoogleOidcClient } from './infrastructure/oidc/google-oidc.client'; import { InvitationNotifier } from './infrastructure/notifications/invitation.notifier'; import { PasswordResetNotifier } from './infrastructure/notifications/password-reset.notifier'; import { projectService } from '../projects'; @@ -21,6 +25,7 @@ const userRepository = new UserRepository(); const roleRepository = new RoleRepository(); const apiKeyRepository = new ApiKeyRepository(); const userAccessRepository = new UserAccessRepository(); +const authProviderRepository = new AuthProviderRepository(); const invitationNotifier = new InvitationNotifier(); const passwordResetNotifier = new PasswordResetNotifier(); @@ -55,6 +60,17 @@ export const userAccessService = new UserAccessService( invitationNotifier, invitationService, ); +export const ssoService = new SsoService( + authProviderRepository, + userRepository, + userAccessRepository, + roleRepository, + userAccessService, + new SecretCipherService(() => passwordService.ensureEncryptionKey()), + new GoogleOidcClient(), + () => passwordService.ensureEncryptionKey(), +); +export { SsoError, type SsoErrorCode } from './application/sso.service'; export const cancanService = new CanCanService( userRepository, userAccessRepository, diff --git a/src/modules/auth/infrastructure/oidc/google-oidc.client.test.ts b/src/modules/auth/infrastructure/oidc/google-oidc.client.test.ts new file mode 100644 index 0000000..24582ff --- /dev/null +++ b/src/modules/auth/infrastructure/oidc/google-oidc.client.test.ts @@ -0,0 +1,185 @@ +import crypto from 'crypto'; +import { describe, expect, it, vi } from 'vitest'; +import { GoogleOidcClient } from './google-oidc.client'; + +const CLIENT_ID = 'client-123.apps.googleusercontent.com'; +const NOW = Date.UTC(2026, 0, 1); + +const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); +const jwk = { ...publicKey.export({ format: 'jwk' }), kid: 'key-1', alg: 'RS256', use: 'sig' }; + +function sign(payload: Record, header: Record = {}) { + const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString('base64url'); + const head = encode({ alg: 'RS256', kid: 'key-1', typ: 'JWT', ...header }); + const body = encode(payload); + const signature = crypto + .sign('RSA-SHA256', Buffer.from(`${head}.${body}`), privateKey) + .toString('base64url'); + return `${head}.${body}.${signature}`; +} + +function claims(overrides: Record = {}) { + return { + iss: 'https://accounts.google.com', + aud: CLIENT_ID, + sub: 'google-sub-1', + email: 'ana@gigigo.com', + email_verified: true, + hd: 'gigigo.com', + nonce: 'nonce-1', + exp: Math.floor(NOW / 1000) + 300, + ...overrides, + }; +} + +function jwksFetch() { + return vi.fn( + async () => + new Response(JSON.stringify({ keys: [jwk] }), { + headers: { 'cache-control': 'public, max-age=3600' }, + }), + ); +} + +describe('GoogleOidcClient', () => { + describe('verifyIdToken', () => { + it('accepts a valid Google ID token', async () => { + const client = new GoogleOidcClient(jwksFetch(), () => NOW); + const result = await client.verifyIdToken(sign(claims()), CLIENT_ID); + + expect(result).toMatchObject({ + sub: 'google-sub-1', + email: 'ana@gigigo.com', + email_verified: true, + hd: 'gigigo.com', + nonce: 'nonce-1', + }); + }); + + it('rejects a token signed with another key', async () => { + const other = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey; + const token = sign(claims()); + const [head, body] = token.split('.'); + const forged = crypto.sign('RSA-SHA256', Buffer.from(`${head}.${body}`), other); + + const client = new GoogleOidcClient(jwksFetch(), () => NOW); + await expect( + client.verifyIdToken(`${head}.${body}.${forged.toString('base64url')}`, CLIENT_ID), + ).rejects.toThrow(/signature/); + }); + + it('rejects a tampered payload', async () => { + const [head, , signature] = sign(claims()).split('.'); + const body = Buffer.from(JSON.stringify(claims({ email: 'admin@gigigo.com' }))).toString( + 'base64url', + ); + + const client = new GoogleOidcClient(jwksFetch(), () => NOW); + await expect(client.verifyIdToken(`${head}.${body}.${signature}`, CLIENT_ID)).rejects.toThrow( + /signature/, + ); + }); + + it('rejects a token issued for another client', async () => { + const client = new GoogleOidcClient(jwksFetch(), () => NOW); + await expect( + client.verifyIdToken(sign(claims({ aud: 'someone-else' })), CLIENT_ID), + ).rejects.toThrow(/audience/); + }); + + it('rejects a token from another issuer', async () => { + const client = new GoogleOidcClient(jwksFetch(), () => NOW); + await expect( + client.verifyIdToken(sign(claims({ iss: 'https://evil.example.com' })), CLIENT_ID), + ).rejects.toThrow(/issuer/); + }); + + it('rejects an expired token', async () => { + const client = new GoogleOidcClient(jwksFetch(), () => NOW); + await expect( + client.verifyIdToken(sign(claims({ exp: Math.floor(NOW / 1000) - 120 })), CLIENT_ID), + ).rejects.toThrow(/expired/); + }); + + it('rejects non-RS256 tokens', async () => { + const client = new GoogleOidcClient(jwksFetch(), () => NOW); + await expect( + client.verifyIdToken(sign(claims(), { alg: 'none' }), CLIENT_ID), + ).rejects.toThrow(/algorithm/); + }); + + it('caches the JWKS and refetches once for an unknown kid', async () => { + const fetchFn = jwksFetch(); + const client = new GoogleOidcClient(fetchFn, () => NOW); + + await client.verifyIdToken(sign(claims()), CLIENT_ID); + await client.verifyIdToken(sign(claims()), CLIENT_ID); + expect(fetchFn).toHaveBeenCalledTimes(1); + + await expect( + client.verifyIdToken(sign(claims(), { kid: 'rotated' }), CLIENT_ID), + ).rejects.toThrow(/signing key/); + expect(fetchFn).toHaveBeenCalledTimes(2); + }); + }); + + it('builds a PKCE authorization URL', () => { + const client = new GoogleOidcClient(jwksFetch(), () => NOW); + const url = new URL( + client.buildAuthorizationUrl({ + clientId: CLIENT_ID, + redirectUri: 'https://gitops.local/auth/sso/google/callback', + state: 'state-1', + nonce: 'nonce-1', + codeChallenge: 'challenge', + hostedDomain: 'gigigo.com', + }), + ); + + expect(url.origin).toBe('https://accounts.google.com'); + expect(url.searchParams.get('response_type')).toBe('code'); + expect(url.searchParams.get('scope')).toBe('openid email profile'); + expect(url.searchParams.get('code_challenge_method')).toBe('S256'); + expect(url.searchParams.get('hd')).toBe('gigigo.com'); + }); + + it('exchanges the code and verifies the returned ID token', async () => { + const idToken = sign(claims()); + const fetchFn = vi.fn(async (input: string, _init?: Parameters[1]) => + input.includes('/token') + ? new Response(JSON.stringify({ id_token: idToken })) + : new Response(JSON.stringify({ keys: [jwk] })), + ); + const client = new GoogleOidcClient(fetchFn, () => NOW); + + const result = await client.exchangeCode({ + clientId: CLIENT_ID, + clientSecret: 'secret', + code: 'code-1', + codeVerifier: 'verifier', + redirectUri: 'https://gitops.local/auth/sso/google/callback', + }); + + expect(result.sub).toBe('google-sub-1'); + const body = fetchFn.mock.calls[0]?.[1]?.body as URLSearchParams; + expect(body.get('code_verifier')).toBe('verifier'); + expect(body.get('grant_type')).toBe('authorization_code'); + }); + + it('surfaces a failed token exchange', async () => { + const client = new GoogleOidcClient( + vi.fn(async () => new Response(JSON.stringify({ error: 'invalid_grant' }), { status: 400 })), + () => NOW, + ); + + await expect( + client.exchangeCode({ + clientId: CLIENT_ID, + clientSecret: 'secret', + code: 'bad', + codeVerifier: 'verifier', + redirectUri: 'https://gitops.local/cb', + }), + ).rejects.toThrow(/invalid_grant/); + }); +}); diff --git a/src/modules/auth/infrastructure/oidc/google-oidc.client.ts b/src/modules/auth/infrastructure/oidc/google-oidc.client.ts new file mode 100644 index 0000000..5349fe0 --- /dev/null +++ b/src/modules/auth/infrastructure/oidc/google-oidc.client.ts @@ -0,0 +1,164 @@ +import crypto from 'crypto'; + +const AUTHORIZATION_ENDPOINT = 'https://accounts.google.com/o/oauth2/v2/auth'; +const TOKEN_ENDPOINT = 'https://oauth2.googleapis.com/token'; +const JWKS_URI = 'https://www.googleapis.com/oauth2/v3/certs'; +const ISSUERS = ['https://accounts.google.com', 'accounts.google.com']; +const CLOCK_SKEW_SECONDS = 60; +const DEFAULT_JWKS_TTL_MS = 60 * 60 * 1000; + +export type GoogleIdTokenClaims = { + sub: string; + email?: string; + email_verified?: boolean; + hd?: string; + name?: string; + nonce?: string; +}; + +export type GoogleAuthorizationRequest = { + clientId: string; + redirectUri: string; + state: string; + nonce: string; + codeChallenge: string; + hostedDomain?: string; +}; + +export type GoogleCodeExchange = { + clientId: string; + clientSecret: string; + code: string; + codeVerifier: string; + redirectUri: string; +}; + +type Jwk = crypto.webcrypto.JsonWebKey & { kid?: string }; +type FetchLike = (input: string, init?: Parameters[1]) => Promise; + +export class GoogleOidcError extends Error {} + +export class GoogleOidcClient { + private jwks: { keys: Jwk[]; expiresAt: number } | null = null; + + constructor( + private readonly fetchFn: FetchLike = (input, init) => fetch(input, init), + private readonly now: () => number = () => Date.now(), + ) {} + + buildAuthorizationUrl(request: GoogleAuthorizationRequest): string { + const url = new URL(AUTHORIZATION_ENDPOINT); + url.searchParams.set('client_id', request.clientId); + url.searchParams.set('redirect_uri', request.redirectUri); + url.searchParams.set('response_type', 'code'); + url.searchParams.set('scope', 'openid email profile'); + url.searchParams.set('state', request.state); + url.searchParams.set('nonce', request.nonce); + url.searchParams.set('code_challenge', request.codeChallenge); + url.searchParams.set('code_challenge_method', 'S256'); + url.searchParams.set('prompt', 'select_account'); + if (request.hostedDomain) url.searchParams.set('hd', request.hostedDomain); + return url.toString(); + } + + async exchangeCode(exchange: GoogleCodeExchange): Promise { + const response = await this.fetchFn(TOKEN_ENDPOINT, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code: exchange.code, + code_verifier: exchange.codeVerifier, + client_id: exchange.clientId, + client_secret: exchange.clientSecret, + redirect_uri: exchange.redirectUri, + }), + }); + + const body = (await response.json().catch(() => ({}))) as { + id_token?: string; + error?: string; + }; + if (!response.ok || !body.id_token) { + throw new GoogleOidcError(`Google token exchange failed: ${body.error ?? response.status}`); + } + + return this.verifyIdToken(body.id_token, exchange.clientId); + } + + async verifyIdToken(idToken: string, clientId: string): Promise { + const parts = idToken.split('.'); + if (parts.length !== 3) throw new GoogleOidcError('Malformed ID token'); + const [encodedHeader, encodedPayload, encodedSignature] = parts; + + const header = this.decodeSegment(encodedHeader) as { alg?: string; kid?: string }; + if (header.alg !== 'RS256' || !header.kid) { + throw new GoogleOidcError('Unsupported ID token algorithm'); + } + + const jwk = await this.findKey(header.kid); + const validSignature = crypto.verify( + 'RSA-SHA256', + Buffer.from(`${encodedHeader}.${encodedPayload}`), + crypto.createPublicKey({ key: jwk, format: 'jwk' }), + Buffer.from(encodedSignature, 'base64url'), + ); + if (!validSignature) throw new GoogleOidcError('Invalid ID token signature'); + + const claims = this.decodeSegment(encodedPayload) as GoogleIdTokenClaims & { + iss?: string; + aud?: string | string[]; + exp?: number; + }; + const nowSeconds = Math.floor(this.now() / 1000); + const audiences = Array.isArray(claims.aud) ? claims.aud : [claims.aud]; + + if (!claims.iss || !ISSUERS.includes(claims.iss)) { + throw new GoogleOidcError('Unexpected ID token issuer'); + } + if (!audiences.includes(clientId)) throw new GoogleOidcError('ID token audience mismatch'); + if (typeof claims.exp !== 'number' || claims.exp + CLOCK_SKEW_SECONDS < nowSeconds) { + throw new GoogleOidcError('ID token expired'); + } + if (!claims.sub) throw new GoogleOidcError('ID token has no subject'); + + return { + sub: claims.sub, + email: claims.email, + email_verified: claims.email_verified === true || String(claims.email_verified) === 'true', + hd: claims.hd, + name: claims.name, + nonce: claims.nonce, + }; + } + + private async findKey(kid: string): Promise { + let key = (await this.loadJwks(false)).find((entry) => entry.kid === kid); + if (!key) key = (await this.loadJwks(true)).find((entry) => entry.kid === kid); + if (!key) throw new GoogleOidcError('Unknown ID token signing key'); + return key; + } + + private async loadJwks(force: boolean): Promise { + if (!force && this.jwks && this.jwks.expiresAt > this.now()) return this.jwks.keys; + + const response = await this.fetchFn(JWKS_URI); + if (!response.ok) throw new GoogleOidcError(`Failed to fetch Google JWKS: ${response.status}`); + const body = (await response.json()) as { keys?: Jwk[] }; + + const maxAge = /max-age=(\d+)/.exec(response.headers.get('cache-control') ?? '')?.[1]; + this.jwks = { + keys: Array.isArray(body.keys) ? body.keys : [], + expiresAt: this.now() + (maxAge ? Number(maxAge) * 1000 : DEFAULT_JWKS_TTL_MS), + }; + return this.jwks.keys; + } + + private decodeSegment(segment: string): unknown { + try { + return JSON.parse(Buffer.from(segment, 'base64url').toString('utf8')); + } catch { + throw new GoogleOidcError('Malformed ID token'); + } + } +} diff --git a/src/modules/auth/infrastructure/repositories/auth-provider.repository.ts b/src/modules/auth/infrastructure/repositories/auth-provider.repository.ts new file mode 100644 index 0000000..f87717a --- /dev/null +++ b/src/modules/auth/infrastructure/repositories/auth-provider.repository.ts @@ -0,0 +1,70 @@ +import crypto from 'crypto'; +import { OrganizationAuthProviderEntity } from '$lib/database/schemas'; +import { AuthProviderDomain, type AuthProviderType } from '../../domain/auth-provider.domain'; +import { Repository } from './repository'; + +export type AuthProviderChanges = { + enabled?: boolean; + clientId?: string | null; + clientSecretEncrypted?: string | null; + allowedDomains?: string[]; + autoProvision?: boolean; + autoProvisionRoleId?: string | null; +}; + +export class AuthProviderRepository extends Repository { + async findByOrganization( + organizationId: string, + provider: AuthProviderType, + ): Promise { + const result = await this.db + .select() + .from(OrganizationAuthProviderEntity) + .where({ organizationId, provider }) + .limit(1); + const row = result.rows[0]; + return row ? new AuthProviderDomain(row) : null; + } + + async findEnabled(provider: AuthProviderType): Promise { + const result = await this.db + .select() + .from(OrganizationAuthProviderEntity) + .where({ provider, enabled: true }); + return result.rows.map((row: any) => new AuthProviderDomain(row)); + } + + async upsert( + organizationId: string, + provider: AuthProviderType, + changes: AuthProviderChanges, + ): Promise { + const existing = await this.findByOrganization(organizationId, provider); + const now = new Date().toISOString(); + + if (!existing) { + await this.db.insert(OrganizationAuthProviderEntity).values({ + id: crypto.randomUUID(), + organizationId, + provider, + enabled: changes.enabled ?? false, + clientId: changes.clientId ?? null, + clientSecretEncrypted: changes.clientSecretEncrypted ?? null, + allowedDomains: changes.allowedDomains ?? [], + autoProvision: changes.autoProvision ?? false, + autoProvisionRoleId: changes.autoProvisionRoleId ?? null, + createdAt: now, + updatedAt: now, + }); + } else { + await this.db + .update(OrganizationAuthProviderEntity) + .set({ ...changes, updatedAt: now }) + .where({ id: existing.id }); + } + + const updated = await this.findByOrganization(organizationId, provider); + if (!updated) throw new Error('Failed to persist authentication provider'); + return updated; + } +} diff --git a/src/modules/auth/infrastructure/repositories/user.repository.ts b/src/modules/auth/infrastructure/repositories/user.repository.ts index f297229..7e13e0d 100644 --- a/src/modules/auth/infrastructure/repositories/user.repository.ts +++ b/src/modules/auth/infrastructure/repositories/user.repository.ts @@ -1,8 +1,7 @@ import { Repository } from './repository'; import { ApiKeyEntity, UserEntity } from '$lib/database/schemas'; import type { CreateUserInput } from '../../domain/entities'; - -import { UserDomain } from '../../domain/user.domain'; +import { UserDomain, type UserAuthProvider } from '../../domain/user.domain'; export class UserRepository extends Repository { async findById(id: string): Promise { @@ -60,6 +59,11 @@ export class UserRepository extends Repository { return row ? this.toDomain(row) : null; } + async findByAuthProvider(provider: string, providerId: string): Promise { + const users = await this.listUsers(); + return users.find((user) => user.hasAuthProvider(provider, providerId)) ?? null; + } + async listUsers(): Promise { const result = await this.db .with({ role: true }) @@ -142,6 +146,32 @@ export class UserRepository extends Repository { .where({ id: userId }); } + async updateAuthProviders(userId: string, authProviders: UserAuthProvider[]): Promise { + await this.db + .update(UserEntity) + .set({ authProviders, updatedAt: new Date().toISOString() }) + .where({ id: userId }); + } + + async activateInvitedUser(userId: string): Promise { + await this.db + .update(UserEntity) + .set({ + status: 'active', + invitationTokenHash: null, + invitationExpiresAt: null, + updatedAt: new Date().toISOString(), + }) + .where({ id: userId }); + } + + async touchLastLogin(userId: string): Promise { + await this.db + .update(UserEntity) + .set({ lastLoginAt: new Date().toISOString() }) + .where({ id: userId }); + } + async setPasswordResetToken( userId: string, passwordResetTokenHash: string, @@ -189,6 +219,8 @@ export class UserRepository extends Repository { status: user.status, invitationExpiresAt: user.invitationExpiresAt ?? null, passwordResetExpiresAt: user.passwordResetExpiresAt ?? null, + authProviders: user.authProviders ?? [], + disabled: user.disabled ?? false, role: user.role, createdAt: user.createdAt, updatedAt: user.updatedAt, diff --git a/src/modules/organization/infrastructure/repositories/organization.repository.ts b/src/modules/organization/infrastructure/repositories/organization.repository.ts index 8193bc6..9bdfabb 100644 --- a/src/modules/organization/infrastructure/repositories/organization.repository.ts +++ b/src/modules/organization/infrastructure/repositories/organization.repository.ts @@ -1,6 +1,6 @@ import { Repository } from '$lib/server/infra/repository'; import { OrganizationDomain } from '../../domain/organization.domain'; -import { OrganizationEntity } from '$lib/database/schemas'; +import { OrganizationAuthProviderEntity, OrganizationEntity } from '$lib/database/schemas'; export class OrganizationRepository extends Repository { async findAll(): Promise { @@ -47,6 +47,7 @@ export class OrganizationRepository extends Repository { } async deleteById(id: string): Promise { + await this.db.delete(OrganizationAuthProviderEntity).where({ organizationId: id }); await this.db.delete(OrganizationEntity).where({ id }); } } diff --git a/src/routes/auth/login/+page.server.ts b/src/routes/auth/login/+page.server.ts index 1114da7..e0aa77d 100644 --- a/src/routes/auth/login/+page.server.ts +++ b/src/routes/auth/login/+page.server.ts @@ -1,17 +1,49 @@ import { fail, redirect } from '@sveltejs/kit'; -import { authService } from '$modules/auth'; +import { authService, ssoService } from '$modules/auth'; import { clusterSettingsService } from '$modules/config'; +import { organizationService } from '$modules/organization'; const SESSION_COOKIE = 'pos_session'; const SESSION_MAX_AGE = 60 * 60 * 24 * 30; +const LAST_ORG_COOKIE = 'last_org'; +const LAST_ORG_MAX_AGE = 60 * 60 * 24 * 365; -export async function load({ cookies }) { +export async function load({ cookies, url }) { const currentUser = await authService.resolveAuthenticatedUser(cookies.get(SESSION_COOKIE)); if (currentUser) { throw redirect(303, '/'); } const registrationEnabled = await clusterSettingsService.isRegistrationEnabled(); - return { registrationEnabled }; + const requestedSlug = url.searchParams.get('org')?.trim() || null; + const slug = requestedSlug ?? cookies.get(LAST_ORG_COOKIE) ?? null; + let organization = slug ? await organizationService.tryFindBySlug(slug) : null; + + if (!organization) { + const soleOrganizationId = await ssoService.findSoleGoogleOrganizationId(); + organization = soleOrganizationId + ? await organizationService.getOrganization(soleOrganizationId).catch(() => null) + : null; + } + + if (organization && requestedSlug) { + cookies.set(LAST_ORG_COOKIE, organization.slug, { + path: '/auth', + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: LAST_ORG_MAX_AGE, + }); + } + + const googleEnabled = organization ? await ssoService.isGoogleEnabled(organization.id) : false; + + return { + registrationEnabled, + sso: googleEnabled + ? { organizationSlug: organization!.slug, organizationName: organization!.name, google: true } + : null, + ssoError: url.searchParams.get('ssoError'), + }; } export const actions = { diff --git a/src/routes/auth/login/+page.svelte b/src/routes/auth/login/+page.svelte index 5373683..9d6c0ce 100644 --- a/src/routes/auth/login/+page.svelte +++ b/src/routes/auth/login/+page.svelte @@ -5,7 +5,11 @@ import { ShieldCheck } from '@lucide/svelte'; import { _ } from '$lib/i18n'; - export let data: { registrationEnabled: boolean }; + export let data: { + registrationEnabled: boolean; + sso: { organizationSlug: string; organizationName: string; google: boolean } | null; + ssoError: string | null; + }; export let form: ActionData; let isSubmitting = false; @@ -13,6 +17,19 @@ $: email = form?.email ?? ''; $: loggedOut = $page.url.searchParams.has('loggedOut'); $: passwordReset = $page.url.searchParams.has('passwordReset'); + + const SSO_ERROR_KEYS: Record = { + cancelled: 'auth.ssoErrorCancelled', + not_configured: 'auth.ssoErrorNotConfigured', + invalid_state: 'auth.ssoErrorInvalidState', + email_not_verified: 'auth.ssoErrorEmailNotVerified', + domain_not_allowed: 'auth.ssoErrorDomainNotAllowed', + not_invited: 'auth.ssoErrorNotInvited', + account_disabled: 'auth.ssoErrorNotInvited', + }; + $: ssoErrorMessage = data.ssoError + ? $_(SSO_ERROR_KEYS[data.ssoError] ?? 'auth.ssoErrorGeneric') + : ''; @@ -57,6 +74,37 @@ {/if} + {#if ssoErrorMessage} +
+ {ssoErrorMessage} +
+ {/if} + + {#if data.sso?.google} + + + {$_('auth.ssoContinueWithGoogle')} + +

+ {$_('auth.ssoOrganizationHint', { values: { organization: data.sso.organizationName } })} +

+ +
+ + {$_('auth.ssoDivider')} + +
+ {/if} + {#if form?.error}
{form.error} diff --git a/src/routes/auth/sso/google/callback/+server.ts b/src/routes/auth/sso/google/callback/+server.ts new file mode 100644 index 0000000..f115205 --- /dev/null +++ b/src/routes/auth/sso/google/callback/+server.ts @@ -0,0 +1,51 @@ +import { redirect } from '@sveltejs/kit'; +import { authService, SsoError, ssoService } from '$modules/auth'; + +const SESSION_COOKIE = 'pos_session'; +const SESSION_MAX_AGE = 60 * 60 * 24 * 30; +const FLOW_COOKIE = 'gitops_sso_flow'; + +export async function GET({ url, cookies, locals }) { + const flowCookie = cookies.get(FLOW_COOKIE); + const organizationSlug = ssoService.peekFlowOrganization(flowCookie); + // the flow is single-use whatever the outcome + cookies.delete(FLOW_COOKIE, { path: '/auth/sso' }); + + const loginWithError = (code: string) => { + const params = new URLSearchParams({ ssoError: code }); + if (organizationSlug) params.set('org', organizationSlug); + return `/auth/login?${params}`; + }; + + // the user cancelled or Google refused the request before issuing a code + if (url.searchParams.has('error')) { + throw redirect(303, loginWithError('cancelled')); + } + + let result: { userId: string; organizationSlug: string }; + try { + result = await ssoService.completeGoogleSignIn({ + flowCookie, + state: url.searchParams.get('state') ?? '', + code: url.searchParams.get('code') ?? '', + redirectUri: `${url.origin}/auth/sso/google/callback`, + }); + } catch (error) { + const code = error instanceof SsoError ? error.code : 'provider_error'; + locals.logger.warn( + { err: error, organization: organizationSlug, code }, + '[sso] sign-in rejected', + ); + throw redirect(303, loginWithError(code)); + } + + cookies.set(SESSION_COOKIE, authService.createSessionToken(result.userId), { + path: '/', + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: SESSION_MAX_AGE, + }); + + throw redirect(303, `/org/${encodeURIComponent(result.organizationSlug)}`); +} diff --git a/src/routes/auth/sso/google/start/+server.ts b/src/routes/auth/sso/google/start/+server.ts new file mode 100644 index 0000000..68bb0dd --- /dev/null +++ b/src/routes/auth/sso/google/start/+server.ts @@ -0,0 +1,41 @@ +import { redirect } from '@sveltejs/kit'; +import { SsoError, ssoService } from '$modules/auth'; +import { organizationService } from '$modules/organization'; + +const FLOW_COOKIE = 'gitops_sso_flow'; +const FLOW_MAX_AGE = 10 * 60; + +export async function GET({ url, cookies, locals }) { + const slug = url.searchParams.get('org')?.trim() ?? ''; + const organization = slug ? await organizationService.tryFindBySlug(slug) : null; + if (!organization) { + throw redirect(303, '/auth/login'); + } + + let authorizationUrl: string; + try { + const started = await ssoService.startGoogleSignIn({ + organization: { id: organization.id, slug: organization.slug }, + redirectUri: `${url.origin}/auth/sso/google/callback`, + }); + authorizationUrl = started.authorizationUrl; + + // sameSite lax: Google's redirect back is a top-level GET navigation, so the cookie travels + cookies.set(FLOW_COOKIE, started.flowCookie, { + path: '/auth/sso', + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: FLOW_MAX_AGE, + }); + } catch (error) { + const code = error instanceof SsoError ? error.code : 'provider_error'; + locals.logger.warn({ err: error, organization: organization.slug }, '[sso] start failed'); + throw redirect( + 303, + `/auth/login?org=${encodeURIComponent(organization.slug)}&ssoError=${code}`, + ); + } + + throw redirect(303, authorizationUrl); +} diff --git a/src/routes/org/[org]/settings/global/+page.server.ts b/src/routes/org/[org]/settings/global/+page.server.ts index 393cccb..fd12b47 100644 --- a/src/routes/org/[org]/settings/global/+page.server.ts +++ b/src/routes/org/[org]/settings/global/+page.server.ts @@ -1,7 +1,8 @@ -import { error } from '@sveltejs/kit'; -import { cancanService } from '$modules/auth'; +import { error, fail } from '@sveltejs/kit'; +import { cancanService, ssoService } from '$modules/auth'; +import { organizationService } from '$modules/organization'; -export async function load({ parent, locals }) { +export async function load({ parent, locals, url }) { const { organization } = await parent(); if ( @@ -13,5 +14,75 @@ export async function load({ parent, locals }) { throw error(403, 'Forbidden'); } - return {}; + const canUpdate = await cancanService.canSessionUser( + locals.user, + 'organization:settings:update', + { + scope: 'organization', + organizationId: organization.id, + }, + ); + + // automatic sign-up is a standing invitation, so it also needs the right to invite members + const canManageAutoProvision = + canUpdate && + (await cancanService.canSessionUser(locals.user, 'organization:users:invite', { + scope: 'organization', + organizationId: organization.id, + })); + + return { + google: await ssoService.getGoogleSettings(organization.id), + autoProvisionRoles: await ssoService.listAutoProvisionRoles(organization.id), + canManageAutoProvision, + googleRedirectUri: `${url.origin}/auth/sso/google/callback`, + loginUrl: `${url.origin}/auth/login?org=${encodeURIComponent(organization.slug)}`, + canUpdate, + }; } + +export const actions = { + async saveGoogle({ request, params, locals }) { + const organization = await organizationService.tryFindBySlug(params.org); + if (!organization) return fail(404, { googleError: 'Organization not found' }); + + if ( + !(await cancanService.canSessionUser(locals.user, 'organization:settings:update', { + scope: 'organization', + organizationId: organization.id, + })) + ) { + return fail(403, { googleError: 'Forbidden' }); + } + + const canManageAutoProvision = await cancanService.canSessionUser( + locals.user, + 'organization:users:invite', + { scope: 'organization', organizationId: organization.id }, + ); + + const form = await request.formData(); + try { + // without the invite permission the stored auto sign-up policy is kept as is + const current = canManageAutoProvision + ? null + : await ssoService.getGoogleSettings(organization.id); + await ssoService.saveGoogleSettings(organization.id, { + enabled: form.get('enabled') === 'on', + clientId: String(form.get('clientId') ?? ''), + clientSecret: String(form.get('clientSecret') ?? ''), + allowedDomains: String(form.get('allowedDomains') ?? '').split(/[\s,]+/), + autoProvision: current ? current.autoProvision : form.get('autoProvision') === 'on', + autoProvisionRoleId: current + ? current.autoProvisionRoleId + : String(form.get('autoProvisionRoleId') ?? ''), + }); + } catch (err) { + return fail(400, { + googleError: err instanceof Error ? err.message : 'Failed to save Google settings', + }); + } + + return { googleSaved: true }; + }, +}; diff --git a/src/routes/org/[org]/settings/global/+page.svelte b/src/routes/org/[org]/settings/global/+page.svelte index cdb2c01..996e29d 100644 --- a/src/routes/org/[org]/settings/global/+page.svelte +++ b/src/routes/org/[org]/settings/global/+page.svelte @@ -1,4 +1,5 @@