Appearance
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,isDefaulte 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 porconnection(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-profilesliberados na conexão; ao entrar, o usuário recebe o perfil cujo nome coincide com um grupo vindo da asserção SAML (ou claimgroupsno 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, FKcompanyIdú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,oidcClientSecretcifrado AES-256-GCM); SAML (samlEntryPoint,samlIssuer,samlCert); SCIM (scimEnabled,scimTokenHashsha256).CompanySsoGroupMapping(company_sso_group_mappings, único[ssoConfigId, permissionProfileId]): vincula umpermissionProfileId(FKpermission_profiles,onDelete: Cascade) a uma conexão. Não armazena nome de grupo — o casamento usa o nome do perfil.AuthProviderganhouOIDCeSAML(vínculoUserAuthProvidercomproviderId = "<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 delaSCIM (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 = truepodem 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
CompanyDomaincom debounce (estilo Slack) e, ao reservar (PUT /sso/config/domain), o registro é criado/atualizado comprovisioned = 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|subpara OIDC,issuer|nameIdpara SAML) e, em fallback, por e-mail verificado. - RN-003 JIT provisioning: com
jitProvisioningligado, o primeiro login cria o usuário (se novo) e garanteUserCompanyativo. 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
connectionnostart, ou pelaisDefault, ou a primeira habilitada. Ostate/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);wantAssertionsSignedligado. 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
UserPermissionProfilenaquela 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) etenant(companyId, em qualquer login feito no subdomínio: IdP ou e-mail/senha viatenantSlugresolvido para empresa provisionada). AJwtStrategyexpõessoCompanyId/tenantCompanyIdna 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 sessoCompanyId === companyId; senão403. Owner é isento (contingência pela aplicação principal). Na listagem (resolveAccessibleCompaniesemGET /companye 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 exigetenantCompanyId === companyId; senão403(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/accessretorna todos os contratos; os de empresa com tenant vêm comtenantUrl(https://<slug>.<domínio base>, com o domínio base derivado doCLIENT_URLdo 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/inativoErros conhecidos
| ID | Erro | Mitigação |
|---|---|---|
| ERR-001 | state/RelayState inválido ou expirado | 401 — reiniciar o login pelo subdomínio |
| ERR-002 | SSO desabilitado / conexão incompleta / protocolo divergente no start/callback | 400 — completar a conexão e habilitar |
| ERR-003 | Usuário não existe e JIT desligado | 401 — admin habilita JIT ou cria/provisiona o usuário antes |
| ERR-004 | IdP não retorna e-mail | 400 — incluir o claim/atributo de e-mail no cliente OIDC/SAML |
| ERR-005 | Assinatura SAML inválida / certificado errado | 400 — conferir o samlCert (X.509 do IdP) |
| ERR-006 | Token SCIM ausente/inválido | 401 — rotacionar o token na conexão e reconfigurar no IdP |
| ERR-007 | Acesso na aplicação principal a recurso de empresa com tenant (membro não-owner ou prestador) fora da sessão-tenant | 403 — 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 → [email protected], grupo "Admins"
Usuário novo → criado + UserCompany ativo; grupo "Admins" → perfil "Administrador" → JWT → dashboard2. 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 403Mé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); opcionaisSSO_SAML_SP_ENTITY_IDeSSO_SAML_ACS_URL. O domínio base dos subdomínios de tenant é derivado doCLIENT_URL(sem variável dedicada).
Riscos
| Risco | Mitigação |
|---|---|
redirect_uri/ACS precisa estar registrado no IdP | Documentar no onboarding; ACS e SP entityID expostos em /auth/sso/saml/metadata |
Mesmo sub/nameId entre IdPs diferentes | providerId prefixado pelo issuer evita colisão |
| SCIM Groups push ainda não suportado | Boundary documentado; grupo→perfil resolvido por claims no login + tabela de mapeamento |
Documentos relacionados
- authentication.md (nest-api) — fluxo OAuth/JWT reutilizado.
- authentication.md (vue-spa) — login no front.
- Acesso via SSO e ambiente do tenant — regra de negócio de quem entra pela app principal vs subdomínio.