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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 |
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user