Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
112 changes: 101 additions & 11 deletions backend/app/cmd/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cmd

import (
"context"
"crypto/sha256"
"embed"
"fmt"
"net"
Expand All @@ -22,6 +23,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 @@ -99,18 +101,19 @@ type ServerCommand struct {
SendJWTHeader bool `long:"send-jwt-header" env:"SEND_JWT_HEADER" description:"send JWT as a header instead of server-set cookie; with this enabled, frontend stores the JWT in a client-side cookie (note: increases vulnerability to XSS attacks)"`
SameSite string `long:"same-site" env:"SAME_SITE" description:"set same site policy for cookies" choice:"default" choice:"none" choice:"lax" choice:"strict" default:"default"` // nolint

Apple AppleGroup `group:"apple" namespace:"apple" env-namespace:"APPLE" description:"Apple OAuth"`
Google AuthGroup `group:"google" namespace:"google" env-namespace:"GOOGLE" description:"Google OAuth"`
Github AuthGroup `group:"github" namespace:"github" env-namespace:"GITHUB" description:"Github OAuth"`
Facebook AuthGroup `group:"facebook" namespace:"facebook" env-namespace:"FACEBOOK" description:"Facebook OAuth"`
Apple AppleGroup `group:"apple" namespace:"apple" env-namespace:"APPLE" description:"Apple OAuth"`
Google AuthGroup `group:"google" namespace:"google" env-namespace:"GOOGLE" description:"Google OAuth"`
Github AuthGroup `group:"github" namespace:"github" env-namespace:"GITHUB" description:"Github OAuth"`
Facebook AuthGroup `group:"facebook" namespace:"facebook" env-namespace:"FACEBOOK" description:"Facebook OAuth"`
Microsoft MicrosoftAuthGroup `group:"microsoft" namespace:"microsoft" env-namespace:"MICROSOFT" description:"Microsoft OAuth"`
Yandex AuthGroup `group:"yandex" namespace:"yandex" env-namespace:"YANDEX" description:"Yandex OAuth"`
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"`
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"`
Yandex AuthGroup `group:"yandex" namespace:"yandex" env-namespace:"YANDEX" description:"Yandex OAuth"`
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"`
Email struct {
Enable bool `long:"enable" env:"ENABLE" description:"enable auth via email"`
From string `long:"from" env:"FROM" description:"from email address"`
Expand Down Expand Up @@ -159,6 +162,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 @@ -330,6 +348,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 @@ -487,6 +506,34 @@ func contains(s string, a []string) bool {
return false
}

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 @@ -966,6 +1013,49 @@ 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 customName == "email" || customName == "anonymous" {
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 := data.Value(s.Auth.Custom.IDField)
if sourceID == "" {
sourceID = data.Value(s.Auth.Custom.EmailField)
}
if sourceID == "" {
sourceID = data.Value(s.Auth.Custom.NameField)
}

hashID := token.HashID(sha256.New(), sourceID)
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
12 changes: 12 additions & 0 deletions backend/app/cmd/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,18 @@ 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 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
5 changes: 3 additions & 2 deletions frontend/apps/remark42/app/components/auth/auth.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
25 changes: 25 additions & 0 deletions site/src/docs/configuration/authorization/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,31 @@ 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 be URL-safe and should not be `email` or `anonymous`.
- If any required custom variable is missing, Remark42 will fail to start.

### Telegram

1. Contact [@BotFather](https://t.me/botfather) and follow his instructions to create your bot (call it, for example, "My site auth bot")
Expand Down
13 changes: 12 additions & 1 deletion site/src/docs/configuration/parameters/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -98,6 +98,17 @@ 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 |
| 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 |
Expand Down
2 changes: 1 addition & 1 deletion site/src/pages/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down