diff --git a/README.md b/README.md index 79aa3045..9bf04dd7 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Remark42 is a self-hosted, lightweight and simple (yet functional) comment engine, which doesn't spy on users. It can be embedded into blogs, articles, or any other place where readers add comments. -* Social login via Google, Facebook, Microsoft, GitHub, Apple, Yandex, Patreon, Discord and Telegram +* Social login via Google, Facebook, Microsoft, GitHub, Apple, Yandex, Patreon, Discord, Telegram and custom OAuth2 providers * Login via email * Optional anonymous access * Multi-level nested comments with both tree and plain presentations diff --git a/backend/app/cmd/server.go b/backend/app/cmd/server.go index d9ca50a4..9b1aebb7 100644 --- a/backend/app/cmd/server.go +++ b/backend/app/cmd/server.go @@ -2,7 +2,9 @@ package cmd import ( "context" + "crypto/sha1" //nolint:gosec // used only for stable ID hashing, not for security "embed" + "encoding/json" "fmt" "net" "net/http" @@ -23,6 +25,7 @@ import ( "github.com/golang-jwt/jwt/v5" "github.com/kyokomi/emoji/v2" bolt "go.etcd.io/bbolt" + "golang.org/x/oauth2" "github.com/go-pkgz/auth/v2" "github.com/go-pkgz/auth/v2/avatar" @@ -109,6 +112,7 @@ type ServerCommand struct { Twitter AuthGroup `group:"twitter" namespace:"twitter" env-namespace:"TWITTER" description:"[deprecated, doesn't work] Twitter OAuth"` Patreon AuthGroup `group:"patreon" namespace:"patreon" env-namespace:"PATREON" description:"Patreon OAuth"` Discord AuthGroup `group:"discord" namespace:"discord" env-namespace:"DISCORD" description:"Discord OAuth"` + Custom CustomAuthGroup `group:"custom" namespace:"custom" env-namespace:"CUSTOM" description:"Custom OAuth2 provider"` Telegram bool `long:"telegram" env:"TELEGRAM" description:"Enable Telegram auth (using token from telegram.token)"` Dev bool `long:"dev" env:"DEV" description:"enable dev (local) oauth2"` Anonymous bool `long:"anon" env:"ANON" description:"enable anonymous login"` @@ -160,6 +164,21 @@ type MicrosoftAuthGroup struct { Tenant string `long:"tenant" env:"TENANT" description:"Azure AD tenant ID, domain, or 'common' (default)" default:"common"` } +// CustomAuthGroup defines options group for custom OAuth2 provider params +type CustomAuthGroup struct { + Name string `long:"name" env:"NAME" description:"custom provider name used in auth route"` + CID string `long:"cid" env:"CID" description:"OAuth client ID"` + CSEC string `long:"csec" env:"CSEC" description:"OAuth client secret"` + AuthURL string `long:"auth-url" env:"AUTH_URL" description:"OAuth authorization endpoint"` + TokenURL string `long:"token-url" env:"TOKEN_URL" description:"OAuth token endpoint"` + InfoURL string `long:"info-url" env:"INFO_URL" description:"OAuth user info endpoint"` + Scopes []string `long:"scopes" env:"SCOPES" env-delim:"," description:"OAuth scopes"` + IDField string `long:"id-field" env:"ID_FIELD" default:"sub" description:"user info field used as unique id"` + NameField string `long:"name-field" env:"NAME_FIELD" default:"name" description:"user info field used as display name"` + PictureField string `long:"picture-field" env:"PICTURE_FIELD" default:"picture" description:"user info field used as avatar url"` + EmailField string `long:"email-field" env:"EMAIL_FIELD" default:"email" description:"user info field used as email"` +} + // StoreGroup defines options group for store params type StoreGroup struct { Type string `long:"type" env:"TYPE" description:"type of storage" choice:"bolt" choice:"rpc" default:"bolt"` // nolint @@ -331,6 +350,7 @@ func (s *ServerCommand) Execute(_ []string) error { "AUTH_YANDEX_CSEC", "AUTH_PATREON_CSEC", "AUTH_DISCORD_CSEC", + "AUTH_CUSTOM_CSEC", "TELEGRAM_TOKEN", "SMTP_PASSWORD", "ADMIN_PASSWD", @@ -483,6 +503,86 @@ func contains(s string, a []string) bool { return slices.Contains(a, s) } +var reservedCustomProviderNames = map[string]struct{}{ + "email": {}, + "anonymous": {}, + "google": {}, + "github": {}, + "facebook": {}, + "yandex": {}, + "twitter": {}, + "microsoft": {}, + "patreon": {}, + "discord": {}, + "telegram": {}, + "dev": {}, + "apple": {}, +} + +var validCustomProviderName = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`) + +func isReservedCustomProviderName(name string) bool { + _, ok := reservedCustomProviderNames[name] + return ok +} + +func isValidCustomProviderName(name string) bool { + return validCustomProviderName.MatchString(name) +} + +func customProviderSourceID(data provider.UserData, cfg CustomAuthGroup) string { + sourceID := data.Value(cfg.IDField) + if sourceID == "" { + sourceID = data.Value(cfg.EmailField) + } + if sourceID == "" { + sourceID = data.Value(cfg.NameField) + } + if sourceID == "" { + sourceID = data.Value(cfg.PictureField) + } + if sourceID == "" { + payload, err := json.Marshal(data) + if err != nil { + log.Printf("[WARN] failed to serialize custom oauth user data for ID fallback: %v", err) + } else { + sourceID = string(payload) + } + } + if sourceID == "" || sourceID == "{}" { + log.Printf("[WARN] custom oauth provider returned no stable user identifier fields, falling back to hashed payload") + } + return sourceID +} + +func (c CustomAuthGroup) isConfigured() bool { + return c.Name != "" || c.CID != "" || c.CSEC != "" || c.AuthURL != "" || c.TokenURL != "" || c.InfoURL != "" || + len(c.Scopes) > 0 || c.IDField != "sub" || c.NameField != "name" || c.PictureField != "picture" || c.EmailField != "email" +} + +func (c CustomAuthGroup) missingRequired() []string { + missing := []string{} + if c.Name == "" { + missing = append(missing, "AUTH_CUSTOM_NAME") + } + if c.CID == "" { + missing = append(missing, "AUTH_CUSTOM_CID") + } + if c.CSEC == "" { + missing = append(missing, "AUTH_CUSTOM_CSEC") + } + if c.AuthURL == "" { + missing = append(missing, "AUTH_CUSTOM_AUTH_URL") + } + if c.TokenURL == "" { + missing = append(missing, "AUTH_CUSTOM_TOKEN_URL") + } + if c.InfoURL == "" { + missing = append(missing, "AUTH_CUSTOM_INFO_URL") + } + return missing +} + // newServerApp prepares application and return it with all active parts // doesn't start anything func (s *ServerCommand) newServerApp(ctx context.Context) (*serverApp, error) { @@ -962,6 +1062,45 @@ func (s *ServerCommand) addAuthProviders(authenticator *auth.Service) error { providersCount++ } + if s.Auth.Custom.isConfigured() { + missing := s.Auth.Custom.missingRequired() + if len(missing) > 0 { + return fmt.Errorf("custom oauth provider configuration is incomplete, missing: %s", strings.Join(missing, ", ")) + } + + customName := strings.ToLower(strings.TrimSpace(s.Auth.Custom.Name)) + if !isValidCustomProviderName(customName) { + return fmt.Errorf("custom oauth provider name %q is invalid, expected pattern %q", customName, validCustomProviderName.String()) + } + if isReservedCustomProviderName(customName) { + return fmt.Errorf("custom oauth provider name %q is reserved", customName) + } + + authenticator.AddCustomProvider(customName, auth.Client{Cid: s.Auth.Custom.CID, Csecret: s.Auth.Custom.CSEC}, provider.CustomHandlerOpt{ + Endpoint: oauth2.Endpoint{ + AuthURL: s.Auth.Custom.AuthURL, + TokenURL: s.Auth.Custom.TokenURL, + }, + InfoURL: s.Auth.Custom.InfoURL, + Scopes: s.Auth.Custom.Scopes, + MapUserFn: func(data provider.UserData, _ []byte) token.User { + sourceID := customProviderSourceID(data, s.Auth.Custom) + hashID := token.HashID(sha1.New(), sourceID) //nolint:gosec // stable provider user id hash + user := token.User{ + ID: customName + "_" + hashID, + Name: data.Value(s.Auth.Custom.NameField), + Picture: data.Value(s.Auth.Custom.PictureField), + Email: data.Value(s.Auth.Custom.EmailField), + } + if user.Name == "" { + user.Name = "noname_" + hashID[:4] + } + return user + }, + }) + providersCount++ + } + if s.Auth.Dev { log.Print("[INFO] dev access enabled") u, errURL := url.Parse(s.RemarkURL) diff --git a/backend/app/cmd/server_test.go b/backend/app/cmd/server_test.go index 42ea8842..9aa7ef20 100644 --- a/backend/app/cmd/server_test.go +++ b/backend/app/cmd/server_test.go @@ -15,6 +15,7 @@ import ( "testing" "time" + "github.com/go-pkgz/auth/v2/provider" "github.com/go-pkgz/auth/v2/token" "github.com/golang-jwt/jwt/v5" "github.com/jessevdk/go-flags" @@ -95,6 +96,30 @@ func TestServerApp_DevMode(t *testing.T) { app.Wait() } +func TestServerApp_CustomOAuthProvider(t *testing.T) { + port := chooseRandomUnusedPort() + app, ctx, cancel := prepServerApp(t, func(o ServerCommand) ServerCommand { + o.Port = port + o.Auth.Custom.Name = "oidc" + o.Auth.Custom.CID = "cid" + o.Auth.Custom.CSEC = "csec" + o.Auth.Custom.AuthURL = "https://example.com/oauth2/authorize" + o.Auth.Custom.TokenURL = "https://example.com/oauth2/token" + o.Auth.Custom.InfoURL = "https://example.com/oauth2/userinfo" + return o + }) + + go func() { _ = app.run(ctx) }() + waitForHTTPServerStart(port) + + providers := app.restSrv.Authenticator.Providers() + require.Equal(t, 11+1, len(providers), "extra auth provider") + assert.Equal(t, "oidc", providers[len(providers)-2].Name(), "custom auth provider") + + cancel() + app.Wait() +} + func TestServerApp_AnonMode(t *testing.T) { port := chooseRandomUnusedPort() app, ctx, cancel := prepServerApp(t, func(o ServerCommand) ServerCommand { @@ -389,6 +414,95 @@ func TestServerApp_Failed(t *testing.T) { "failed to make authenticator: an AppleProvider creating failed: "+ "provided private key is not ECDSA") t.Log(err) + + // incomplete custom oauth config + opts = ServerCommand{} + opts.SetCommon(CommonOpts{RemarkURL: "https://demo.remark42.com", SharedSecret: "123456"}) + p = flags.NewParser(&opts, flags.Default) + _, err = p.ParseArgs([]string{"--store.bolt.path=/tmp", "--backup=/tmp", "--image.fs.path=/tmp", "--auth.custom.name=oidc", "--auth.custom.cid=123"}) + assert.NoError(t, err) + _, err = opts.newServerApp(context.Background()) + assert.EqualError(t, err, + "failed to make authenticator: custom oauth provider configuration is incomplete, missing: "+ + "AUTH_CUSTOM_CSEC, AUTH_CUSTOM_AUTH_URL, AUTH_CUSTOM_TOKEN_URL, AUTH_CUSTOM_INFO_URL") + t.Log(err) +} + +func TestIsReservedCustomProviderName(t *testing.T) { + reserved := []string{ + "email", "anonymous", "google", "github", "facebook", "yandex", "twitter", + "microsoft", "patreon", "discord", "telegram", "dev", "apple", + } + + for _, name := range reserved { + t.Run(name, func(t *testing.T) { + assert.True(t, isReservedCustomProviderName(name)) + }) + } + + assert.False(t, isReservedCustomProviderName("oidc")) +} + +func TestIsValidCustomProviderName(t *testing.T) { + valid := []string{"oidc", "codeberg", "provider_1", "provider-1", "a1"} + for _, name := range valid { + t.Run("valid_"+name, func(t *testing.T) { + assert.True(t, isValidCustomProviderName(name)) + }) + } + + invalid := []string{"", " has-space", "has space", "Uppercase", "provider!", "-provider", "_provider"} + for _, name := range invalid { + t.Run("invalid_"+strings.ReplaceAll(name, " ", "_"), func(t *testing.T) { + assert.False(t, isValidCustomProviderName(name)) + }) + } +} + +func TestCustomProviderSourceID(t *testing.T) { + cfg := CustomAuthGroup{IDField: "sub", EmailField: "email", NameField: "name", PictureField: "picture"} + + assert.Equal(t, "user-1", customProviderSourceID(provider.UserData{"sub": "user-1", "email": "a@example.com"}, cfg)) + assert.Equal(t, "a@example.com", customProviderSourceID(provider.UserData{"email": "a@example.com"}, cfg)) + assert.Equal(t, "alice", customProviderSourceID(provider.UserData{"name": "alice"}, cfg)) + assert.Equal(t, "https://example.com/avatar.png", customProviderSourceID(provider.UserData{"picture": "https://example.com/avatar.png"}, cfg)) + assert.Equal(t, `{"login":"alice"}`, customProviderSourceID(provider.UserData{"login": "alice"}, cfg)) + assert.Equal(t, "{}", customProviderSourceID(provider.UserData{}, cfg)) +} + +func TestServerApp_InvalidCustomOAuthProviderName(t *testing.T) { + baseArgs := []string{ + "--store.bolt.path=/tmp", + "--backup=/tmp", + "--image.fs.path=/tmp", + "--auth.custom.cid=123", + "--auth.custom.csec=456", + "--auth.custom.auth-url=https://example.com/oauth2/authorize", + "--auth.custom.token-url=https://example.com/oauth2/token", + "--auth.custom.info-url=https://example.com/oauth2/userinfo", + } + + t.Run("reserved", func(t *testing.T) { + opts := ServerCommand{} + opts.SetCommon(CommonOpts{RemarkURL: "https://demo.remark42.com", SharedSecret: "123456"}) + p := flags.NewParser(&opts, flags.Default) + _, err := p.ParseArgs(append(baseArgs, "--auth.custom.name=twitter")) + require.NoError(t, err) + + _, err = opts.newServerApp(context.Background()) + assert.EqualError(t, err, `failed to make authenticator: custom oauth provider name "twitter" is reserved`) + }) + + t.Run("not_url_safe", func(t *testing.T) { + opts := ServerCommand{} + opts.SetCommon(CommonOpts{RemarkURL: "https://demo.remark42.com", SharedSecret: "123456"}) + p := flags.NewParser(&opts, flags.Default) + _, err := p.ParseArgs(append(baseArgs, "--auth.custom.name=bad name")) + require.NoError(t, err) + + _, err = opts.newServerApp(context.Background()) + assert.EqualError(t, err, `failed to make authenticator: custom oauth provider name "bad name" is invalid, expected pattern "^[a-z0-9][a-z0-9_-]*$"`) + }) } func TestServerApp_Shutdown(t *testing.T) { diff --git a/frontend/apps/remark42/app/assets/social/custom.svg b/frontend/apps/remark42/app/assets/social/custom.svg new file mode 100644 index 00000000..91f36d88 --- /dev/null +++ b/frontend/apps/remark42/app/assets/social/custom.svg @@ -0,0 +1,9 @@ + diff --git a/frontend/apps/remark42/app/common/types.ts b/frontend/apps/remark42/app/common/types.ts index 1bacffbd..0a04c159 100644 --- a/frontend/apps/remark42/app/common/types.ts +++ b/frontend/apps/remark42/app/common/types.ts @@ -90,7 +90,7 @@ export interface Tree { info: PostInfo; } -export type OAuthProvider = +export type DefaultOAuthProvider = | 'apple' | 'facebook' | 'twitter' @@ -102,6 +102,7 @@ export type OAuthProvider = | 'discord' | 'telegram' | 'dev'; +export type OAuthProvider = DefaultOAuthProvider | (string & {}); export type FormProvider = 'email' | 'anonymous'; export type Provider = OAuthProvider | FormProvider; diff --git a/frontend/apps/remark42/app/components/auth/auth.spec.tsx b/frontend/apps/remark42/app/components/auth/auth.spec.tsx index 9df6e605..15d577fd 100644 --- a/frontend/apps/remark42/app/components/auth/auth.spec.tsx +++ b/frontend/apps/remark42/app/components/auth/auth.spec.tsx @@ -70,6 +70,7 @@ describe('', () => { it.each([ [[]], [['dev']], + [['customoidc']], [['facebook', 'google']], [['facebook', 'google', 'microsoft']], [['facebook', 'google', 'microsoft', 'yandex']], @@ -291,6 +292,23 @@ describe('', () => { ); expect(setUser).toBeCalledWith(user); }); + + it('should use custom provider route', async () => { + StaticStore.config.auth_providers = ['customoidc']; + + const oauthSignin = jest.spyOn(api, 'oauthSignin').mockImplementation(async () => null); + + render(); + + fireEvent.click(screen.getByText('Sign In')); + await waitFor(() => fireEvent.click(screen.getByTitle('Sign In with Customoidc'))); + + await waitFor(() => + expect(oauthSignin).toBeCalledWith( + `${BASE_URL}/auth/customoidc/login?from=http%3A%2F%2Flocalhost%2F%3FselfClose&site=remark` + ) + ); + }); }); describe('Telegram auth', () => { diff --git a/frontend/apps/remark42/app/components/auth/auth.utils.ts b/frontend/apps/remark42/app/components/auth/auth.utils.ts index b92e1cb9..c1f2f0b5 100644 --- a/frontend/apps/remark42/app/components/auth/auth.utils.ts +++ b/frontend/apps/remark42/app/components/auth/auth.utils.ts @@ -2,7 +2,6 @@ import { isJwtExpired } from 'utils/jwt'; import { StaticStore } from 'common/static-store'; import type { FormProvider, OAuthProvider } from 'common/types'; -import { OAUTH_PROVIDERS } from './components/oauth.consts'; import { messages } from './auth.messsages'; import { setItem, getItem } from 'common/local-storage'; import { LS_EMAIL_KEY } from 'common/constants'; @@ -12,7 +11,9 @@ export function getProviders(): [OAuthProvider[], FormProvider[]] { const formProviders: FormProvider[] = []; StaticStore.config.auth_providers.forEach((p) => { - OAUTH_PROVIDERS.includes(p) ? oauthProviders.push(p as OAuthProvider) : formProviders.push(p as FormProvider); + p === 'email' || p === 'anonymous' + ? formProviders.push(p as FormProvider) + : oauthProviders.push(p as OAuthProvider); }); return [oauthProviders, formProviders]; diff --git a/frontend/apps/remark42/app/components/auth/components/oauth.consts.ts b/frontend/apps/remark42/app/components/auth/components/oauth.consts.ts index 9336d71c..ce933204 100644 --- a/frontend/apps/remark42/app/components/auth/components/oauth.consts.ts +++ b/frontend/apps/remark42/app/components/auth/components/oauth.consts.ts @@ -20,6 +20,7 @@ export const OAUTH_DATA = { microsoft: require('assets/social/microsoft.svg').default as string, yandex: require('assets/social/yandex.svg').default as string, dev: require('assets/social/dev.svg').default as string, + custom: require('assets/social/custom.svg').default as string, github: { name: 'GitHub', icons: { diff --git a/frontend/apps/remark42/app/components/auth/components/oauth.utils.ts b/frontend/apps/remark42/app/components/auth/components/oauth.utils.ts index 1f5b4804..639bbb46 100644 --- a/frontend/apps/remark42/app/components/auth/components/oauth.utils.ts +++ b/frontend/apps/remark42/app/components/auth/components/oauth.utils.ts @@ -16,7 +16,11 @@ export function getButtonVariant(num: number) { } export function getProviderData(provider: OAuthProvider, theme: Theme) { - const data = OAUTH_DATA[provider]; + const data = OAUTH_DATA[provider as keyof typeof OAUTH_DATA]; + + if (!data) { + return { name: capitalizeFirstLetter(provider), icon: OAUTH_DATA.custom }; + } if (typeof data !== 'string') { return { diff --git a/site/src/docs/configuration/authorization/index.md b/site/src/docs/configuration/authorization/index.md index 1557b038..0abd34d8 100644 --- a/site/src/docs/configuration/authorization/index.md +++ b/site/src/docs/configuration/authorization/index.md @@ -113,6 +113,32 @@ For more details refer to [Yandex OAuth](https://yandex.com/dev/oauth/doc/dg/con 3. Under **"Redirects"** enter the correct url constructed as domain + `/auth/discord/callback`. ie `https://remark42.mysite.com/auth/discord/callback` 4. Take note of the **CLIENT ID** and **CLIENT SECRET**, as they are values for `AUTH_DISCORD_CID` and `AUTH_DISCORD_CSEC` respectively +### Custom OAuth2 Provider + +You can configure any OAuth2-compatible provider by setting these variables: + +- `AUTH_CUSTOM_NAME` - provider name used in auth routes +- `AUTH_CUSTOM_CID` - OAuth client ID +- `AUTH_CUSTOM_CSEC` - OAuth client secret +- `AUTH_CUSTOM_AUTH_URL` - authorization endpoint +- `AUTH_CUSTOM_TOKEN_URL` - token endpoint +- `AUTH_CUSTOM_INFO_URL` - user info endpoint +- `AUTH_CUSTOM_SCOPES` - optional scopes, comma-separated +- `AUTH_CUSTOM_ID_FIELD` - optional user info field used as unique id (default `sub`) +- `AUTH_CUSTOM_NAME_FIELD` - optional user info field used as display name (default `name`) +- `AUTH_CUSTOM_PICTURE_FIELD` - optional user info field used as avatar URL (default `picture`) +- `AUTH_CUSTOM_EMAIL_FIELD` - optional user info field used as email (default `email`) + +Callback URL format: + +`https:///auth//callback` + +Notes: + +- `AUTH_CUSTOM_NAME` must match `^[a-z0-9][a-z0-9_-]*$` and should not conflict with built-in providers: `email`, `anonymous`, `google`, `github`, `facebook`, `yandex`, `twitter`, `microsoft`, `patreon`, `discord`, `telegram`, `dev`, `apple`. +- If any required custom variable is missing, Remark42 will fail to start. +- Remark42 currently supports only one custom OAuth2 provider at a time. + ### Telegram 1. Contact [@BotFather](https://t.me/botfather) and follow his instructions to create your bot (call it, for example, "My site auth bot") diff --git a/site/src/docs/configuration/parameters/index.md b/site/src/docs/configuration/parameters/index.md index 7ad170b0..7801ee21 100644 --- a/site/src/docs/configuration/parameters/index.md +++ b/site/src/docs/configuration/parameters/index.md @@ -8,7 +8,7 @@ Most of the parameters have sane defaults and don't require customization. There 1. `SECRET` - secret key, can be any long and hard-to-guess string 2. `REMARK_URL` - URL pointing to your Remark42 server, i.e., `https://demo.remark42.com` -3. At least one pair of `AUTH__CID` and `AUTH__CSEC` defining OAuth2 provider(s) +3. At least one OAuth2 provider, either via `AUTH__CID` + `AUTH__CSEC` or via `AUTH_CUSTOM_*` The minimal `docker-compose.yml` has to include all required parameters: @@ -98,6 +98,20 @@ services: | auth.patreon.csec | AUTH_PATREON_CSEC | | Patreon OAuth Client Secret | | auth.discord.cid | AUTH_DISCORD_CID | | Discord OAuth Client ID | | auth.discord.csec | AUTH_DISCORD_CSEC | | Discord OAuth Client Secret | +| auth.custom.name | AUTH_CUSTOM_NAME | | custom OAuth provider name (used in `/auth//...`) | +| auth.custom.cid | AUTH_CUSTOM_CID | | custom OAuth client ID | +| auth.custom.csec | AUTH_CUSTOM_CSEC | | custom OAuth client secret | +| auth.custom.auth-url | AUTH_CUSTOM_AUTH_URL | | custom OAuth authorization endpoint | +| auth.custom.token-url | AUTH_CUSTOM_TOKEN_URL | | custom OAuth token endpoint | +| auth.custom.info-url | AUTH_CUSTOM_INFO_URL | | custom OAuth user info endpoint | +| auth.custom.scopes | AUTH_CUSTOM_SCOPES | none | custom OAuth scopes, comma-separated | +| auth.custom.id-field | AUTH_CUSTOM_ID_FIELD | `sub` | user info field used as unique id | +| auth.custom.name-field | AUTH_CUSTOM_NAME_FIELD | `name` | user info field used as display name | +| auth.custom.picture-field | AUTH_CUSTOM_PICTURE_FIELD | `picture` | user info field used as avatar URL | +| auth.custom.email-field | AUTH_CUSTOM_EMAIL_FIELD | `email` | user info field used as email | + +Custom OAuth2 integration currently supports only one custom provider at a time, and `AUTH_CUSTOM_NAME` must match `^[a-z0-9][a-z0-9_-]*$`. + | auth.telegram | AUTH_TELEGRAM | `false` | Enable Telegram auth (telegram.token must be present) | | auth.yandex.cid | AUTH_YANDEX_CID | | Yandex OAuth client ID | | auth.yandex.csec | AUTH_YANDEX_CSEC | | Yandex OAuth client secret | diff --git a/site/src/pages/index.md b/site/src/pages/index.md index 1a7af722..14902546 100644 --- a/site/src/pages/index.md +++ b/site/src/pages/index.md @@ -8,7 +8,7 @@ title: Remark42 – Privacy-focused lightweight commenting engine Remark42 allows you to have a self-hosted, lightweight, and simple (yet functional) comment engine, which doesn't spy on users. It can be embedded into blogs, articles or any other place where readers add comments. -- Social login via Google, Facebook, Microsoft, GitHub, Apple, Yandex, Patreon and Telegram +- Social login via Google, Facebook, Microsoft, GitHub, Apple, Yandex, Patreon, Telegram and custom OAuth2 providers - Login via email - Optional anonymous access - Multi-level nested comments with both tree and plain presentations