feat: custom oauth2 provider (#2006)

* feat: add configurable custom OAuth2 provider and icons

* fix: reserve built-in custom provider names

* fix: add nolint directive for sha1 import

* fix: harden custom oauth provider validation
This commit is contained in:
AlexMa233
2026-04-16 23:10:05 -05:00
committed by GitHub
parent ba3df171d1
commit 94d1f6e224
12 changed files with 334 additions and 7 deletions
+1 -1
View File
@@ -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
+139
View File
@@ -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)
+114
View File
@@ -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) {
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2400 2400" width="28" height="28" aria-hidden="true">
<path fill="#ff7c00"
d="m1270 218.3-173.1 84.3-.7 981.8c-.3 540 .2 981.4 1.1 981l174.5-81.8 172.3-80.8v-984.4c0-541.7-.2-984.7-.4-984.4z">
</path>
<path fill="#aaa"
d="M981.9 785.5c-425.3 63.2-766.5 264.1-889 523a491.5 491.5 0 0 0-43.6 146c-4.2 29.2-4.7 95-1.2 124 19 152.6 115.2 299.9 273.2 418.8 147.7 111 350.5 196.5 568.6 239.7 59 11.6 179 29 200.5 29 2.3 0 3-23.2 3-109.1v-109.2l-5.1-1-37.9-6a1182 1182 0 0 1-305.4-90.6c-122.2-55.7-225.1-137.7-284.6-226.4-107.5-160.5-81.3-344.3 70-491.3 57-55.5 115.4-95.2 199.5-136.1a1112.6 1112.6 0 0 1 269.4-89.2l29.7-6c3.7-1.2 4-8.6 4-111.5V779.5l-6.3.2a823 823 0 0 0-44.8 5.8m525 104c0 103 .2 110.4 4.1 111.6l29.5 6a1221.6 1221.6 0 0 1 207.7 61.3A1088 1088 0 0 1 1862 1123c4.6 3.7 1.4 5.8-88 56-51.1 28.5-93 52.7-93 53.4 0 1.9 671.6 146.8 673.2 145.2 1.2-1.2-45.5-496-47-497.6-.2-.2-38.5 21-85 47.2l-89.6 50.2c-4.2 2-8.8.2-27.9-10.7-130.8-75-289.6-132.2-460.8-166.1a1871 1871 0 0 0-132.9-21.1c-4 0-4.2 6.7-4.2 110z">
</path>
<path fill="#cbaa7c" d="M1094.5 2156.9c0 60.6.3 85.5.5 55 .5-30.2.5-79.9 0-110.3-.2-30.2-.5-5.3-.5 55.3"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+2 -1
View File
@@ -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;
@@ -70,6 +70,7 @@ describe('<Auth/>', () => {
it.each([
[[]],
[['dev']],
[['customoidc']],
[['facebook', 'google']],
[['facebook', 'google', 'microsoft']],
[['facebook', 'google', 'microsoft', 'yandex']],
@@ -291,6 +292,23 @@ describe('<Auth/>', () => {
);
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(<Auth />);
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', () => {
@@ -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];
@@ -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: {
@@ -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 {
@@ -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://<remark42-url>/auth/<AUTH_CUSTOM_NAME>/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")
@@ -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_<PROVIDER>_CID` and `AUTH_<PROVIDER>_CSEC` defining OAuth2 provider(s)
3. At least one OAuth2 provider, either via `AUTH_<PROVIDER>_CID` + `AUTH_<PROVIDER>_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/<name>/...`) |
| 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 |
+1 -1
View File
@@ -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