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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
139 changes: 139 additions & 0 deletions backend/app/cmd/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -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"`
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
Expand Down
114 changes: 114 additions & 0 deletions backend/app/cmd/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down
9 changes: 9 additions & 0 deletions frontend/apps/remark42/app/assets/social/custom.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 2 additions & 1 deletion frontend/apps/remark42/app/common/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ export interface Tree {
info: PostInfo;
}

export type OAuthProvider =
export type DefaultOAuthProvider =
| 'apple'
| 'facebook'
| 'twitter'
Expand All @@ -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;

Expand Down
18 changes: 18 additions & 0 deletions frontend/apps/remark42/app/components/auth/auth.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ describe('<Auth/>', () => {
it.each([
[[]],
[['dev']],
[['customoidc']],
[['facebook', 'google']],
[['facebook', 'google', 'microsoft']],
[['facebook', 'google', 'microsoft', 'yandex']],
Expand Down Expand Up @@ -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', () => {
Expand Down
Loading
Loading