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