Skip to content

SSO Enterprise (OIDC + SAML + SCIM)

Objetivo

Permitir que uma empresa enterprise (com subdomínio próprio provisionado) configure login único corporativo via OIDC ou SAML 2.0, com provisionamento/desprovisionamento automático (SCIM 2.0) e mapeamento de grupos do IdP para perfis de permissão, reutilizando o mesmo motor de identidade do login social — login social, OIDC e SAML são o mesmo mecanismo, não sistemas paralelos.

Personas e jobs

  • Admin da empresa quer "ligar o login pelo provedor de identidade da minha empresa (Okta/Azure AD/Workspace/OneLogin) por OIDC ou SAML, com mais de um provedor se a holding precisar".
  • TI/Compliance quer "que entrada e saída de pessoas sigam o IdP corporativo automaticamente (SCIM) e que grupos do IdP definam o perfil de permissão".
  • Usuário da empresa quer "entrar com a conta que já uso no trabalho".

Fronteiras

Faz:

  • Multi-conexão por empresa: cada empresa pode ter N conexões de SSO (ex: dois SAML para sociedade/holding), cada uma com connectionSlug, isDefault e protocolo próprio.
  • OIDC (auth code) via .well-known/openid-configuration; SAML 2.0 (SP-initiated) com validação de assinatura da asserção (X.509 do IdP), metadados do SP e ACS.
  • Roteamento por subdomínio (<slug>.contrasync.com) → empresa; a conexão é escolhida por connection (ou a padrão).
  • JIT provisioning: cria usuário (conta única global) e vínculo ativo no primeiro login.
  • SCIM 2.0 Users: provisionamento (POST), leitura (GET/list com filtro), atualização e desprovisionamento (PATCH/PUT/DELETE → desativa o vínculo), autenticado por bearer token por conexão.
  • Group sync: o admin seleciona os permission-profiles liberados na conexão; ao entrar, o usuário recebe o perfil cujo nome coincide com um grupo vindo da asserção SAML (ou claim groups no OIDC). Não há nome de grupo duplicado — o nome do perfil é a chave de casamento.

NÃO faz:

  • Não substitui o login social/e-mail; é mais um método sobre o mesmo motor.
  • SCIM Groups push (Okta/Azure empurrando associação de grupo via PATCH /Groups) não está nesta fatia — o vínculo grupo→perfil é resolvido pelos claims de grupo no login (SAML/OIDC) + a tabela de mapeamento. SCIM expõe apenas Users.
  • Não cobre SLO (single logout) nem SP-initiated logout SAML.
  • Não provisiona o subdomínio/slug (operação separada; consome CompanyDomain.provisioned).

Contratos

Model (Prisma, additive-only)

  • CompanyDomain (company_domains, FK companyId único): slug (único), provisioned. Identidade de tenant/URL (1 por empresa).
  • CompanySSOConfig (company_sso_configs, N por empresa, único [companyId, connectionSlug]): connectionSlug, label, enabled, isDefault, protocol (oidc|saml), jitProvisioning; OIDC (oidcIssuerUrl, oidcClientId, oidcClientSecret cifrado AES-256-GCM); SAML (samlEntryPoint, samlIssuer, samlCert); SCIM (scimEnabled, scimTokenHash sha256).
  • CompanySsoGroupMapping (company_sso_group_mappings, único [ssoConfigId, permissionProfileId]): vincula um permissionProfileId (FK permission_profiles, onDelete: Cascade) a uma conexão. Não armazena nome de grupo — o casamento usa o nome do perfil.
  • AuthProvider ganhou OIDC e SAML (vínculo UserAuthProvider com providerId = "<issuer>|<sub|nameId>").

API (Product API)

GET    /sso/config                                  → status + connections[] (sem segredos; expõe hasClientSecret/hasSamlCert/hasScimToken)
GET    /sso/config/domain/availability?slug=x        → { slug, valid, available } (checagem self-service do subdomínio, com debounce no front)
PUT    /sso/config/domain                            → { slug } → reserva o subdomínio da empresa (upsert CompanyDomain, provisioned=true)
PUT    /sso/config/connections                      → cria/atualiza uma conexão (cifra secret/cert quando enviados)
DELETE /sso/config/connections/:slug                → remove conexão
POST   /sso/config/connections/:slug/scim-token     → gera (rotaciona) o token SCIM (retorna o token uma única vez)
PUT    /sso/config/group-mappings                   → vincula um perfil à conexão ({ connectionSlug, permissionProfileId })
DELETE /sso/config/group-mappings/:id               → remove o vínculo do perfil
POST   /auth/sso/start                              → { slug, connection?, redirectUri } → { authorizeUrl } (público; OIDC ou SAML)
POST   /auth/sso/callback                           → { state, code } → { user, token, companies } (público; OIDC)
GET    /auth/sso/saml/metadata                      → XML de metadados do SP (público)
POST   /auth/sso/saml/acs                           → SAMLResponse + RelayState → 302 p/ <redirectUri>#token=<jwt> (público)
GET    /company/:id                                 → inclui ssoProvisioned (gate da aba no front)
POST   /auth/login                                  → aceita tenantSlug opcional (amarra a sessão ao subdomínio no token final)
POST   /auth/verify-code                            → aceita tenantSlug opcional (idem, após o OTP)
GET    /company                                     → lista empresas; empresa SSO-enforced some para membro não-owner fora da sessão-SSO
GET    /me/access                                   → lista de contratos do prestador; contrato de empresa com tenant volta com tenantUrl
GET    /contracts/:id/provider/detail               → 403 quando a empresa é tenant e a sessão não nasceu no subdomínio dela

SCIM (Product API, bearer token por conexão)

GET    /scim/v2/Users[?filter=userName eq "x"]      → ListResponse
POST   /scim/v2/Users                               → provisiona (cria/ativa) + vínculo ativo
GET    /scim/v2/Users/:id                           → detalhe
PUT|PATCH /scim/v2/Users/:id                        → ativa/desativa (active)
DELETE /scim/v2/Users/:id                           → desprovisiona (desativa o vínculo)

Eventos (webhook + audit)

N/A — o login SSO/SAML e o SCIM reusam a emissão de JWT + sessão e o vínculo UserCompany existentes; não introduzem eventos próprios nesta fatia.

Tools IA expostas (AI API)

N/A — SSO/SCIM é infraestrutura de autenticação/provisionamento; não expõe tools de IA.

Regras de negócio

  • RN-001 Só empresas com CompanyDomain.provisioned = true podem configurar e usar SSO. Em produção a aba de configuração só aparece para provisionadas; fora de produção sempre aparece (para a equipe integrar).
  • RN-001a O subdomínio é self-service: o admin digita só o slug, a disponibilidade é verificada contra CompanyDomain com debounce (estilo Slack) e, ao reservar (PUT /sso/config/domain), o registro é criado/atualizado com provisioned = true — o slug passa a responder sob o wildcard *.contrasync.com. Slugs reservados (www, app, api, admin, …) e fora do padrão DNS (minúsculas, dígitos e hífen; até 63 chars) são recusados; um slug já pertencente a outra empresa fica indisponível.
  • RN-002 Identidade é conta única global: o casamento é por providerId (issuer|sub para OIDC, issuer|nameId para SAML) e, em fallback, por e-mail verificado.
  • RN-003 JIT provisioning: com jitProvisioning ligado, o primeiro login cria o usuário (se novo) e garante UserCompany ativo. Desligado, usuário inexistente é recusado (ERR-003).
  • RN-004 O client secret (OIDC) é cifrado em repouso (AES-256-GCM); o token SCIM é guardado como hash sha256; o client secret e o certificado SAML nunca retornam pela API (a UI mostra só hasClientSecret/hasSamlCert/hasScimToken).
  • RN-005 Multi-conexão: o subdomínio resolve a empresa; a conexão é escolhida por connection no start, ou pela isDefault, ou a primeira habilitada. O state/RelayState é um JWT assinado ({slug, connection, redirectUri}) validado no callback/ACS (anti-CSRF).
  • RN-006 SAML: a asserção tem assinatura validada contra samlCert (X.509 do IdP); wantAssertionsSigned ligado. E-mail/nome/grupos são extraídos de claims comuns (incl. URIs do Azure/ADFS).
  • RN-007 Group sync: dentre os perfis vinculados à conexão, o primeiro cujo nome coincida com um grupo do IdP do usuário define o UserPermissionProfile naquela empresa (um perfil por usuário/empresa). O IdP precisa enviar grupos cujos nomes batam com os nomes dos perfis do Contrasync.
  • RN-008 SCIM: autenticado por bearer token (hash) que resolve a conexão → empresa. Provisionar cria/ativa o vínculo; desprovisionar (active=false/DELETE) desativa o vínculo (não apaga o usuário global).
  • RN-009 Sessão amarrada ao subdomínio: o JWT carrega duas claims — sso (companyId, só no login por IdP) e tenant (companyId, em qualquer login feito no subdomínio: IdP ou e-mail/senha via tenantSlug resolvido para empresa provisionada). A JwtStrategy expõe ssoCompanyId/tenantCompanyId na sessão. Como o token vive no storage por origin, não é reaproveitado entre subdomínios.
  • RN-010 Gate de membro (SSO): no CompanyGuard, um membro direto não-owner de empresa SSO-enforced (≥1 conexão habilitada) só passa se ssoCompanyId === companyId; senão 403. Owner é isento (contingência pela aplicação principal). Na listagem (resolveAccessibleCompanies em GET /company e nos builders de login), a empresa SSO-enforced é omitida para não-owner fora da sessão-SSO.
  • RN-011 Gate de prestador (tenant): no branch de provider do CompanyGuard (assertTenantAccessForProvider), acesso a contrato de empresa com domínio provisionado exige tenantCompanyId === companyId; senão 403 (ERR-007). Não há isenção de owner aqui (prestador não é owner). O gate cobre o detalhe mesmo por navegação/chamada manual.
  • RN-012 Listagem do prestador não é filtrada: GET /me/access retorna todos os contratos; os de empresa com tenant vêm com tenantUrl (https://<slug>.<domínio base>, com o domínio base derivado do CLIENT_URL do ambiente). No front, o clique abre o subdomínio em nova aba quando não se está naquele tenant; dentro do subdomínio, entra direto no detalhe.

Estados (máquina)

[empresa provisionada] → cria conexão (OIDC|SAML, enabled) → start (descobre/redireciona ao IdP) →
  IdP autentica → (OIDC: callback troca code | SAML: ACS valida asserção) →
  JIT (user + membership) → group sync (grupo→perfil) → JWT (sessão)

[SCIM] IdP → bearer token → Users POST/PATCH/DELETE → vínculo ativo/inativo

Erros conhecidos

IDErroMitigação
ERR-001state/RelayState inválido ou expirado401 — reiniciar o login pelo subdomínio
ERR-002SSO desabilitado / conexão incompleta / protocolo divergente no start/callback400 — completar a conexão e habilitar
ERR-003Usuário não existe e JIT desligado401 — admin habilita JIT ou cria/provisiona o usuário antes
ERR-004IdP não retorna e-mail400 — incluir o claim/atributo de e-mail no cliente OIDC/SAML
ERR-005Assinatura SAML inválida / certificado errado400 — conferir o samlCert (X.509 do IdP)
ERR-006Token SCIM ausente/inválido401 — rotacionar o token na conexão e reconfigurar no IdP
ERR-007Acesso na aplicação principal a recurso de empresa com tenant (membro não-owner ou prestador) fora da sessão-tenant403 — acessar pelo subdomínio da empresa (<slug>.contrasync.com)

Exemplos canônicos

1. Caminho feliz: login SAML com JIT + group sync

Usuário acessa acme.contrasync.com → "Entrar com SSO" → start (conexão SAML padrão) → redirect ao Okta
Okta autentica → POST SAMLResponse no ACS → assinatura validada → joao@acme.com, grupo "Admins"
Usuário novo → criado + UserCompany ativo; grupo "Admins" → perfil "Administrador" → JWT → dashboard

2. Borda: multi-IdP na holding

Holding tem 2 conexões SAML (matriz, filial). Login em matriz.contrasync.com?connection=filial
escolhe a conexão "filial"; sem connection, usa a isDefault.

3. Falha: SCIM desprovisionando

IdP envia DELETE /scim/v2/Users/<id> (bearer token da conexão) → UserCompany vira inactive
(usuário global preservado; perde acesso à empresa)

4. Acesso de prestador a contrato de empresa-tenant

Maria (prestadora da Acme, que tem tenant) abre app.contrasync.com → /me/access
Contrato da Acme vem com tenantUrl=https://acme.contrasync.com → clique abre nova aba no tenant
Maria loga em acme.contrasync.com (token com tenant=acme) → detalhe do contrato liberado
Mesmo contrato via app.contrasync.com (sessão sem tenant=acme) → GET /contracts/:id/provider/detail 403

Métricas

  • M-001: logins SSO concluídos / iniciados — alvo ≥ 95%.
  • M-002: contas duplicadas por e-mail após SSO — alvo 0 (casamento por e-mail).
  • M-003: provisionamentos/desprovisionamentos SCIM aplicados sem erro — alvo ≥ 99%.

Compliance

  • PII: client secret cifrado; certificado SAML e token SCIM nunca retornam ao front (token SCIM só é exibido uma vez na geração). Geolocalização/IP/UA do login seguem a sessão padrão.
  • LGPD: JIT e SCIM criam/desativam vínculo de empresa apenas no fluxo efetivo via IdP corporativo.
  • Auditoria: emissão de JWT + sessão reusa o registro de sessão do login existente.

Dependências externas

  • IdP OIDC (Okta, Azure AD/Entra, Google Workspace) com .well-known/openid-configuration.
  • IdP SAML 2.0 (Okta, Azure AD, Workspace, OneLogin) — validação via @node-saml/node-saml.
  • SSO_ENCRYPTION_KEY (AES-256 base64); opcionais SSO_SAML_SP_ENTITY_ID e SSO_SAML_ACS_URL. O domínio base dos subdomínios de tenant é derivado do CLIENT_URL (sem variável dedicada).

Riscos

RiscoMitigação
redirect_uri/ACS precisa estar registrado no IdPDocumentar no onboarding; ACS e SP entityID expostos em /auth/sso/saml/metadata
Mesmo sub/nameId entre IdPs diferentesproviderId prefixado pelo issuer evita colisão
SCIM Groups push ainda não suportadoBoundary documentado; grupo→perfil resolvido por claims no login + tabela de mapeamento

Documentos relacionados