From 68402387fbcf5fe41d5c8d5a1d4b0a3e6b122bb4 Mon Sep 17 00:00:00 2001 From: "Liam Verdejo (ES)" Date: Mon, 5 Oct 2026 23:35:11 +0200 Subject: [PATCH 1/2] New: Implements SSO Login with Google --- .github/copilot-instructions.md | 12 +- CLAUDE.md | 20 +- Dockerfile | 2 + README.md | 25 + src/hooks.server.ts | 11 +- src/lib/database/schemas.ts | 18 + src/lib/i18n/locales/en.json | 20 +- src/lib/i18n/locales/es.json | 20 +- .../application/secret-cipher.service.test.ts | 37 ++ .../auth/application/secret-cipher.service.ts | 46 ++ .../auth/application/sso.service.test.ts | 484 ++++++++++++++++++ src/modules/auth/application/sso.service.ts | 298 +++++++++++ .../auth/domain/auth-provider.domain.ts | 47 ++ src/modules/auth/domain/user.domain.ts | 12 + src/modules/auth/index.ts | 14 + .../oidc/google-oidc.client.test.ts | 185 +++++++ .../infrastructure/oidc/google-oidc.client.ts | 164 ++++++ .../repositories/auth-provider.repository.ts | 66 +++ .../repositories/user.repository.ts | 36 +- .../repositories/organization.repository.ts | 3 +- src/routes/auth/login/+page.server.ts | 38 +- src/routes/auth/login/+page.svelte | 50 +- .../auth/sso/google/callback/+server.ts | 51 ++ src/routes/auth/sso/google/start/+server.ts | 41 ++ .../org/[org]/settings/global/+page.server.ts | 55 +- .../org/[org]/settings/global/+page.svelte | 158 ++++-- 26 files changed, 1850 insertions(+), 63 deletions(-) create mode 100644 src/modules/auth/application/secret-cipher.service.test.ts create mode 100644 src/modules/auth/application/secret-cipher.service.ts create mode 100644 src/modules/auth/application/sso.service.test.ts create mode 100644 src/modules/auth/application/sso.service.ts create mode 100644 src/modules/auth/domain/auth-provider.domain.ts create mode 100644 src/modules/auth/infrastructure/oidc/google-oidc.client.test.ts create mode 100644 src/modules/auth/infrastructure/oidc/google-oidc.client.ts create mode 100644 src/modules/auth/infrastructure/repositories/auth-provider.repository.ts create mode 100644 src/routes/auth/sso/google/callback/+server.ts create mode 100644 src/routes/auth/sso/google/start/+server.ts diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 270f008..e54eb8d 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -7,7 +7,15 @@ 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 solo autentica usuarios que ya tienen acceso a la organización o sus proyectos +(vincula el `sub` de Google al usuario en `user.authProviders[]`; la invitación pendiente se acepta +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 +25,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/CLAUDE.md b/CLAUDE.md index ed27671..47cda5c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,8 +6,20 @@ 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 only signs in people who already have access to that organization +(or one of its projects): it never creates accounts or grants roles. 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 +48,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 f395f9a..0a5ce67 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,6 +29,8 @@ RUN apk add --no-cache git 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..b9b9241 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,30 @@ 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=`. + +Only users already invited to the organization can sign in with Google; access and roles are still +granted through invitations. 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 86f888e..5499961 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -94,10 +94,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' || @@ -142,7 +142,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 9f84737..578ff47 100644 --- a/src/lib/database/schemas.ts +++ b/src/lib/database/schemas.ts @@ -89,6 +89,24 @@ 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(() => []), + 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 72490a1..40e47e5 100644 --- a/src/lib/i18n/locales/en.json +++ b/src/lib/i18n/locales/en.json @@ -211,7 +211,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", @@ -754,7 +764,13 @@ "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. Users still need an invitation to this organization.", + "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 888493f..2c40da4 100644 --- a/src/lib/i18n/locales/es.json +++ b/src/lib/i18n/locales/es.json @@ -211,7 +211,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", @@ -754,7 +764,13 @@ "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. Los usuarios siguen necesitando una invitación a la organización.", + "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..d0f05ac --- /dev/null +++ b/src/modules/auth/application/sso.service.test.ts @@ -0,0 +1,484 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { SsoError, SsoService } from './sso.service'; +import { AuthProviderDomain } from '../domain/auth-provider.domain'; +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; + } +} + +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 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(); + 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, + 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'], + }); + 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('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' }); + }); + + 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..a6a56b0 --- /dev/null +++ b/src/modules/auth/application/sso.service.ts @@ -0,0 +1,298 @@ +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 { 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'; + +const GOOGLE = 'google'; +const SECRET_PURPOSE = 'sso:google:client-secret'; +const FLOW_TTL_MS = 10 * 60 * 1000; +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[]; +}; + +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[]; +}; + +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 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 updated = await this.authProviderRepository.upsert(organizationId, GOOGLE, { + enabled: input.enabled, + clientId: clientId || null, + allowedDomains, + ...(clientSecret + ? { clientSecretEncrypted: this.secretCipher.encrypt(clientSecret, SECRET_PURPOSE) } + : {}), + }); + return this.toSettingsView(updated); + } + + 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'); + } + + const user = + (await this.userRepository.findByAuthProvider(GOOGLE, claims.sub)) ?? + (await this.userRepository.findByEmail(email)); + if (!user) throw new SsoError('not_invited', 'No invited account for this Google identity'); + if (user.disabled) throw new SsoError('account_disabled', 'Account is disabled'); + + const access = await this.userAccessRepository.findByUserId(user.id); + if (!access.some((entry) => this.belongsToOrganization(entry, flow.organizationId))) { + throw new SsoError('not_invited', 'Account has no access to this organization'); + } + + 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 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] : [], + }; + } + + 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/domain/auth-provider.domain.ts b/src/modules/auth/domain/auth-provider.domain.ts new file mode 100644 index 0000000..51300e6 --- /dev/null +++ b/src/modules/auth/domain/auth-provider.domain.ts @@ -0,0 +1,47 @@ +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[] = []; + + 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()) + : []; + } + + 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()); + } + + toJson() { + return { + id: this.id, + organizationId: this.organizationId, + provider: this.provider, + enabled: this.enabled, + clientId: this.clientId, + hasClientSecret: Boolean(this.clientSecretEncrypted), + allowedDomains: [...this.allowedDomains], + createdAt: this.createdAt, + updatedAt: this.updatedAt, + }; + } +} 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..d747ddf 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,15 @@ export const userAccessService = new UserAccessService( invitationNotifier, invitationService, ); +export const ssoService = new SsoService( + authProviderRepository, + userRepository, + userAccessRepository, + 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..ebf388e --- /dev/null +++ b/src/modules/auth/infrastructure/repositories/auth-provider.repository.ts @@ -0,0 +1,66 @@ +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[]; +}; + +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 ?? [], + 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..6ef2669 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,51 @@ 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, + }, + ); + + return { + google: await ssoService.getGoogleSettings(organization.id), + 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 form = await request.formData(); + try { + 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,]+/), + }); + } 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 16645e1..fcb8345 100644 --- a/src/routes/org/[org]/settings/global/+page.svelte +++ b/src/routes/org/[org]/settings/global/+page.svelte @@ -1,4 +1,5 @@