Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ description: Configure local accounts, SMTP email flows, external login provider

import { Aside, CardGrid, LinkCard, Tabs, TabItem } from "@astrojs/starlight/components";

Chatto supports local password accounts, email verification and reset flows, external OAuth/OIDC providers, explicit account linking, and owner recovery through verified email addresses.
Chatto supports local password accounts, opt-in passkeys, email verification and reset flows, external OAuth/OIDC providers, explicit account linking, and owner recovery through verified email addresses.

## Choose Your Account Model

Expand All @@ -16,6 +16,21 @@ Chatto supports local password accounts, email verification and reset flows, ext
| Organization SSO | Configure one or more `[[auth.providers]]` entries. |
| Mixed community | Allow local accounts and selected external providers. |
| Passwordless external login | Ensure users keep at least one linked provider and understand recovery paths. |
| Passkey-first daily login | Enable passkeys and SMTP. Email remains the recovery path while users can omit a password. |

## Passkeys

Passkeys are disabled by default. Enable them only after `webserver.url` is the stable public HTTPS URL people use to open Chatto: WebAuthn credentials are scoped to that hostname. `localhost` is accepted for development.

```toml
[webserver]
url = "https://chat.example.com"

[auth.passkeys]
enabled = true
```

Passkeys accept platform, synced, cross-device, and hardware-key authenticators. Chatto requires a device PIN or biometric verification and does not request device attestation. Users can use a passkey for daily sign-in while retaining a verified email address for password recovery.

## SMTP Email

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -782,6 +782,7 @@ Login and registration options exposed before authentication.
| `direct_registration_enabled` | `bool` | Whether users can create accounts through the public UI. |
| `providers` | repeated [`ProviderMetadata`](#chatto-api-v1-ProviderMetadata) | Configured login providers. |
| `authorize_url` | `string` | URL for the legacy authorization flow, when enabled. |
| `passkeys_enabled` | `bool` | Whether this server has enabled WebAuthn passkey login. Older servers omit this field and clients must treat that as unavailable. |

<a id="chatto-api-v1-ServerPublicProfile"></a>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,10 @@ Only used when `storage_backend` is set to `s3`. See the [S3 Storage guide](/gui
Enable direct (email/password) registration. When `false`, the registration page is hidden and the registration API returns `403`. Users can still sign in via configured SSO providers.
</EnvVar>

<EnvVar name="CHATTO_AUTH_PASSKEYS_ENABLED" toml="auth.passkeys.enabled" default="false">
Enable WebAuthn passkeys. Requires a secure public `webserver.url`; `localhost` is allowed for development. The relying-party hostname is derived from that URL.
</EnvVar>

<EnvVar name="CHATTO_AUTH_TOKEN_TTL" toml="auth.token_ttl" default="90d">
Inactivity TTL for bearer auth tokens. Supports durations like `90d`, `2160h`. Successful validation refreshes the TTL; inactive tokens expire automatically.
</EnvVar>
Expand Down
1 change: 1 addition & 0 deletions apps/docs-website/src/generated/connectrpc-api/api.raw.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -4148,6 +4148,7 @@ Login and registration options exposed before authentication.
| `direct_registration_enabled` | `bool` | Whether users can create accounts through the public UI. |
| `providers` | repeated [`ProviderMetadata`](#chatto-api-v1-ProviderMetadata) | Configured login providers. |
| `authorize_url` | `string` | URL for the legacy authorization flow, when enabled. |
| `passkeys_enabled` | `bool` | Whether this server has enabled WebAuthn passkey login. Older servers omit this field and clients must treat that as unavailable. |



Expand Down
1 change: 1 addition & 0 deletions apps/frontend/src/lib/api-client-tests/server.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ describe('getPublicServerInfo', () => {
version: '9.8.7',
authorizeUrl: '/oauth/authorize',
directRegistrationEnabled: true,
passkeysEnabled: false,
welcomeMessage: 'welcome',
description: 'description',
iconUrl: 'https://cdn/logo.webp',
Expand Down
3 changes: 3 additions & 0 deletions apps/frontend/src/lib/api-client/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ export type PublicServerInfo = {
version: string;
authorizeUrl: string;
directRegistrationEnabled: boolean;
/** Omitted by older server/client fixtures; fetched server responses normalize it to false. */
passkeysEnabled?: boolean;
welcomeMessage: string | null;
description: string | null;
iconUrl: string | null;
Expand All @@ -38,6 +40,7 @@ export async function getPublicServerInfo(
authorizeUrl: response.login?.authorizeUrl ?? "",
directRegistrationEnabled:
response.login?.directRegistrationEnabled ?? false,
passkeysEnabled: response.login?.passkeysEnabled ?? false,
welcomeMessage: profile.welcomeMessage,
description: profile.description,
iconUrl: profile.logoUrl,
Expand Down
7 changes: 7 additions & 0 deletions cli/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ require (
github.com/gin-contrib/sessions v1.1.0
github.com/gin-gonic/gin v1.12.0
github.com/go-jose/go-jose/v3 v3.0.5
github.com/go-webauthn/webauthn v0.17.4
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/gorilla/securecookie v1.1.2
github.com/gorilla/sessions v1.4.0
Expand Down Expand Up @@ -107,6 +108,7 @@ require (
github.com/fatih/structtag v1.2.0 // indirect
github.com/frostbyte73/core v0.1.1 // indirect
github.com/fsnotify/fsnotify v1.10.1 // indirect
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/gammazero/deque v1.2.1 // indirect
github.com/gin-contrib/sse v1.1.1 // indirect
Expand All @@ -116,6 +118,8 @@ require (
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.3 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go-webauthn/x v0.2.6 // indirect
github.com/goccy/go-json v0.10.6 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
Expand Down Expand Up @@ -153,6 +157,7 @@ require (
github.com/nats-io/nuid v1.0.1 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/opencontainers/runc v1.3.3 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/pion/datachannel v1.6.0 // indirect
github.com/pion/dtls/v3 v3.1.2 // indirect
github.com/pion/ice/v4 v4.2.6 // indirect
Expand Down Expand Up @@ -182,9 +187,11 @@ require (
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sanity-io/litter v1.5.8 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/tinylib/msgp v1.6.4 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/wlynxg/anet v0.0.5 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/zeebo/xxh3 v1.1.0 // indirect
go.mongodb.org/mongo-driver/v2 v2.6.0 // indirect
Expand Down
16 changes: 16 additions & 0 deletions cli/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,8 @@ github.com/frostbyte73/core v0.1.1 h1:ChhJOR7bAKOCPbA+lqDLE2cGKlCG5JXsDvvQr4YaJI
github.com/frostbyte73/core v0.1.1/go.mod h1:mhfOtR+xWAvwXiwor7jnqPMnu4fxbv1F2MwZ0BEpzZo=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gammazero/deque v1.2.1 h1:9fnQVFCCZ9/NOc7ccTNqzoKd1tCWOqeI05/lPqFPMGQ=
Expand All @@ -177,6 +179,12 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8=
github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-webauthn/webauthn v0.17.4 h1:KFTSz3R2RYDiUn/0cDi3XTJgFenSG74eKTTHlqWhlxk=
github.com/go-webauthn/webauthn v0.17.4/go.mod h1:pZk63EE/BdztlmyS4Yc+9H5g4a8blNlbtGmdHQHbZX8=
github.com/go-webauthn/x v0.2.6 h1:TEyDuQAIiEgYpx60nKiBJIX/5nSUC8LxNbH+uf5U9uk=
github.com/go-webauthn/x v0.2.6/go.mod h1:45bA7YEqyQhRcQJ/TiBb46Ww8yqHBGvgEhQ3WWF0aDo=
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
Expand All @@ -196,6 +204,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
Expand Down Expand Up @@ -314,6 +324,8 @@ github.com/otiai10/opengraph/v2 v2.2.0 h1:33L8EVEN1xRKTK+aGOOwU/XSbPYCFaezG0l5gr
github.com/otiai10/opengraph/v2 v2.2.0/go.mod h1:tX5Dg72DBqE1dJc0cOKX8RUHbQqJOViu6T+Vlhhqo5U=
github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pion/datachannel v1.6.0 h1:XecBlj+cvsxhAMZWFfFcPyUaDZtd7IJvrXqlXD/53i0=
github.com/pion/datachannel v1.6.0/go.mod h1:ur+wzYF8mWdC+Mkis5Thosk+u/VOL287apDNEbFpsIk=
github.com/pion/dtls/v3 v3.1.2 h1:gqEdOUXLtCGW+afsBLO0LtDD8GnuBBjEy6HRtyofZTc=
Expand Down Expand Up @@ -406,6 +418,8 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/twitchtv/twirp v8.1.3+incompatible h1:+F4TdErPgSUbMZMwp13Q/KgDVuI7HJXP61mNV3/7iuU=
github.com/twitchtv/twirp v8.1.3+incompatible/go.mod h1:RRJoFSAmTEh2weEqWtpPE3vFK5YBhA6bqp2l1kfCC5A=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
Expand All @@ -416,6 +430,8 @@ github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
github.com/wneessen/go-mail v0.7.3 h1:g3DravXC5SMlVdboFrQA8Jx95A8sOzoBeS5F+vzNRK0=
github.com/wneessen/go-mail v0.7.3/go.mod h1:QGhBX0yNbc1J+Mkjcu7z2rpj4B4l+BmDY8gYznPC9sk=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo=
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
Expand Down
35 changes: 35 additions & 0 deletions cli/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -496,11 +496,39 @@ func IsAllowedAuthProviderType(providerType string) bool {

type AuthConfig struct {
DirectRegistration *bool `toml:"direct_registration" env:"CHATTO_AUTH_DIRECT_REGISTRATION" comment:"Enable direct (email/password) registration. When false, users can only sign in via SSO providers. Default: true."`
Passkeys PasskeysConfig `toml:"passkeys,commented" comment:"WebAuthn passkey login. Passkeys require a secure webserver.url; enable explicitly after confirming the public URL is stable."`
TokenTTL Duration `toml:"token_ttl,commented" env:"CHATTO_AUTH_TOKEN_TTL" comment:"TTL for bearer auth tokens. Supports human-readable durations like '90d', '2160h'. Default: 90d."`
EmailOTP EmailOTPConfig `toml:"email_otp,commented" comment:"Email OTP guardrails for registration and email verification."`
Providers []AuthProviderConfig `toml:"providers" comment:"External login providers. Configure as repeated [[auth.providers]] tables."`
}

// PasskeysConfig controls the WebAuthn passkey feature. The relying-party ID
// is deliberately derived from webserver.url rather than configured separately
// so credentials cannot accidentally be scoped more broadly than the public
// Chatto origin.
type PasskeysConfig struct {
Enabled *bool `toml:"enabled,commented" env:"CHATTO_AUTH_PASSKEYS_ENABLED" comment:"Enable passkey sign-in and account management. Default: false. Requires an HTTPS webserver.url, except localhost development URLs."`
}

// EnabledOrDefault reports whether passkeys are explicitly enabled.
func (c *PasskeysConfig) EnabledOrDefault() bool {
return c.Enabled != nil && *c.Enabled
}

// PasskeyRelyingParty returns the exact WebAuthn relying-party ID and origin
// derived from the configured public URL. Callers should use it only after
// Validate has accepted an enabled passkey configuration.
func (c *ChattoConfig) PasskeyRelyingParty() (id string, origin string, ok bool) {
if !c.Auth.Passkeys.EnabledOrDefault() {
return "", "", false
}
u, err := url.Parse(c.Webserver.URL)
if err != nil || u.Hostname() == "" || (u.Scheme != "https" && !isLoopbackHost(u.Hostname())) {
return "", "", false
}
return u.Hostname(), u.Scheme + "://" + u.Host, true
}

// EmailOTPConfig controls registration and email-verification one-time-password guardrails.
type EmailOTPConfig struct {
ThrottlingEnabled *bool `toml:"throttling_enabled,commented" env:"CHATTO_AUTH_EMAIL_OTP_THROTTLING_ENABLED" comment:"Enable email OTP throttling for registration and email verification. Default: true."`
Expand Down Expand Up @@ -1012,6 +1040,13 @@ func (c *ChattoConfig) Validate() error {
errs = append(errs, err.Error())
}
}
if c.Auth.Passkeys.EnabledOrDefault() {
if c.Webserver.URL == "" {
errs = append(errs, "webserver.url is required when passkeys are enabled")
} else if parsed, err := url.Parse(c.Webserver.URL); err != nil || parsed.Hostname() == "" || (parsed.Scheme != "https" && !isLoopbackHost(parsed.Hostname())) {
errs = append(errs, "passkeys require an HTTPS webserver.url (except localhost development URLs)")
}
}
if c.NATS.Client.URL != "" {
if _, err := url.Parse(c.NATS.Client.URL); err != nil {
errs = append(errs, fmt.Sprintf("nats.client.url is invalid: %v", err))
Expand Down
27 changes: 27 additions & 0 deletions cli/internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,33 @@ func TestReadConfig_WithoutConfigFile(t *testing.T) {
}
}

func TestPasskeysRequireSecurePublicURL(t *testing.T) {
enabled := true
for _, tt := range []struct {
name string
url string
wantErr bool
}{
{name: "missing URL", wantErr: true},
{name: "plain HTTP public URL", url: "http://chat.example.test", wantErr: true},
{name: "HTTPS public URL", url: "https://chat.example.test"},
{name: "localhost development URL", url: "http://localhost:5173"},
} {
t.Run(tt.name, func(t *testing.T) {
cfg := validTestConfig()
cfg.Auth.Passkeys.Enabled = &enabled
cfg.Webserver.URL = tt.url
err := cfg.Validate()
if tt.wantErr && (err == nil || !strings.Contains(err.Error(), "passkeys")) {
t.Fatalf("Validate() error = %v, want passkeys validation error", err)
}
if !tt.wantErr && err != nil {
t.Fatalf("Validate() error = %v, want nil", err)
}
})
}
}

func TestReadConfig_WithConfigFile(t *testing.T) {
// Create a temp directory with a config file
tmpDir := t.TempDir()
Expand Down
1 change: 1 addition & 0 deletions cli/internal/connectapi/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ func (s *serverDiscoveryService) GetServer(ctx context.Context, _ *connect.Reque
Profile: profile,
Login: &apiv1.ServerLogin{
DirectRegistrationEnabled: s.api.config.Auth.DirectRegistrationOrDefault(),
PasskeysEnabled: s.api.config.Auth.Passkeys.EnabledOrDefault(),
Providers: apiAuthProviders(s.api.config.Auth.PublicProviders()),
AuthorizeUrl: "/oauth/authorize",
},
Expand Down
1 change: 1 addition & 0 deletions cli/internal/core/auth_generation.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ func (c *ChattoCore) waitForUserAuthGenerationCurrent(ctx context.Context, userI
if err := c.userModel.waitForUsersCurrent(ctx, "user auth generation",
agg.Subject(events.EventUserPasswordHashChanged),
agg.Subject(events.EventUserExternalIdentityUnlinked),
agg.Subject(events.EventUserPasskeyUnlinked),
agg.Subject(events.EventUserAccountDeleted),
); err != nil {
return fmt.Errorf("wait for user auth generation: %w", err)
Expand Down
11 changes: 11 additions & 0 deletions cli/internal/core/core.go
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,12 @@ type ChattoCore struct {
// WaitFor from user/account writers.
UsersProjector *events.Projector

// Passkeys owns credential-hash lookup and mutable authenticator state.
Passkeys *PasskeyProjection

// PasskeysProjector runs credential-specific passkey facts.
PasskeysProjector *events.Projector

// ContentKeys holds wrapped per-user DEK epochs used by encrypted
// message bodies and durable user PII.
ContentKeys *ContentKeyProjection
Expand Down Expand Up @@ -953,6 +959,9 @@ func NewChattoCore(ctx context.Context, nc *nats.Conn, cfg config.CoreConfig) (*
users := newUserProjectionWithDEKResolver(dekResolver)
usersProjector := newProjector(users, "users", "Users", users.adminProjectionEstimate)

passkeys := NewPasskeyProjection()
passkeysProjector := newProjector(passkeys, "passkeys", "Passkeys", func() (int64, int64, []ProjectionAdminMetric) { return 0, 0, nil })

contentKeys := NewContentKeyProjection()
contentKeysProjector := newProjector(contentKeys, "content_keys", "Content Keys", contentKeys.adminProjectionEstimate)

Expand Down Expand Up @@ -1018,6 +1027,8 @@ func NewChattoCore(ctx context.Context, nc *nats.Conn, cfg config.CoreConfig) (*
ReactionsProjector: reactionsProjector,
Users: users,
UsersProjector: usersProjector,
Passkeys: passkeys,
PasskeysProjector: passkeysProjector,
ContentKeys: contentKeys,
ContentKeysProjector: contentKeysProjector,
RBAC: rbac,
Expand Down
2 changes: 1 addition & 1 deletion cli/internal/core/external_identities.go
Original file line number Diff line number Diff line change
Expand Up @@ -360,7 +360,7 @@ func (c *ChattoCore) DisconnectExternalIdentity(ctx context.Context, userID, sub
if !found {
return ErrExternalIdentityNotFound
}
if _, hasPassword := c.Users.PasswordHash(userID); !hasPassword && len(identities) <= 1 {
if _, hasPassword := c.Users.PasswordHash(userID); !hasPassword && len(identities) <= 1 && !c.Users.HasVerifiedEmail(userID) && len(c.Users.Passkeys(userID)) < 2 {
return ErrExternalIdentityLastMethod
}
return nil
Expand Down
Loading