1
0
Fork 0

Adding upstream version 0.28.1.

Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
Daniel Baumann 2025-05-22 10:57:38 +02:00
parent 88f1d47ab6
commit e28c88ef14
Signed by: daniel
GPG key ID: FBB4F0E80A80222F
933 changed files with 194711 additions and 0 deletions

166
tools/auth/apple.go Normal file
View file

@ -0,0 +1,166 @@
package auth
import (
"context"
"encoding/json"
"errors"
"strings"
"github.com/golang-jwt/jwt/v5"
"github.com/pocketbase/pocketbase/tools/types"
"github.com/spf13/cast"
"golang.org/x/oauth2"
)
func init() {
Providers[NameApple] = wrapFactory(NewAppleProvider)
}
var _ Provider = (*Apple)(nil)
// NameApple is the unique name of the Apple provider.
const NameApple string = "apple"
// Apple allows authentication via Apple OAuth2.
//
// [OIDC differences]: https://bitbucket.org/openid/connect/src/master/How-Sign-in-with-Apple-differs-from-OpenID-Connect.md
type Apple struct {
BaseProvider
jwksURL string
}
// NewAppleProvider creates a new Apple provider instance with some defaults.
func NewAppleProvider() *Apple {
return &Apple{
BaseProvider: BaseProvider{
ctx: context.Background(),
displayName: "Apple",
pkce: true,
scopes: []string{"name", "email"},
authURL: "https://appleid.apple.com/auth/authorize",
tokenURL: "https://appleid.apple.com/auth/token",
},
jwksURL: "https://appleid.apple.com/auth/keys",
}
}
// FetchAuthUser returns an AuthUser instance based on the provided token.
//
// API reference: https://developer.apple.com/documentation/sign_in_with_apple/tokenresponse.
func (p *Apple) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
data, err := p.FetchRawUserInfo(token)
if err != nil {
return nil, err
}
rawUser := map[string]any{}
if err := json.Unmarshal(data, &rawUser); err != nil {
return nil, err
}
extracted := struct {
Id string `json:"sub"`
Name string `json:"name"`
Email string `json:"email"`
EmailVerified any `json:"email_verified"` // could be string or bool
User struct {
Name struct {
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
} `json:"name"`
} `json:"user"`
}{}
if err := json.Unmarshal(data, &extracted); err != nil {
return nil, err
}
user := &AuthUser{
Id: extracted.Id,
Name: extracted.Name,
RawUser: rawUser,
AccessToken: token.AccessToken,
RefreshToken: token.RefreshToken,
}
user.Expiry, _ = types.ParseDateTime(token.Expiry)
if cast.ToBool(extracted.EmailVerified) {
user.Email = extracted.Email
}
if user.Name == "" {
user.Name = strings.TrimSpace(extracted.User.Name.FirstName + " " + extracted.User.Name.LastName)
}
return user, nil
}
// FetchRawUserInfo implements Provider.FetchRawUserInfo interface.
//
// Apple doesn't have a UserInfo endpoint and claims about users
// are instead included in the "id_token" (https://openid.net/specs/openid-connect-core-1_0.html#id_tokenExample)
func (p *Apple) FetchRawUserInfo(token *oauth2.Token) ([]byte, error) {
idToken, _ := token.Extra("id_token").(string)
claims, err := p.parseAndVerifyIdToken(idToken)
if err != nil {
return nil, err
}
// Apple only returns the user object the first time the user authorizes the app
// https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_js/configuring_your_webpage_for_sign_in_with_apple#3331292
rawUser, _ := token.Extra("user").(string)
if rawUser != "" {
user := map[string]any{}
err = json.Unmarshal([]byte(rawUser), &user)
if err != nil {
return nil, err
}
claims["user"] = user
}
return json.Marshal(claims)
}
func (p *Apple) parseAndVerifyIdToken(idToken string) (jwt.MapClaims, error) {
if idToken == "" {
return nil, errors.New("empty id_token")
}
// extract the token header params and claims
// ---
claims := jwt.MapClaims{}
t, _, err := jwt.NewParser().ParseUnverified(idToken, claims)
if err != nil {
return nil, err
}
// validate common claims per https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_rest_api/verifying_a_user#3383769
// ---
jwtValidator := jwt.NewValidator(
jwt.WithExpirationRequired(),
jwt.WithIssuedAt(),
jwt.WithLeeway(idTokenLeeway),
jwt.WithIssuer("https://appleid.apple.com"),
jwt.WithAudience(p.clientId),
)
err = jwtValidator.Validate(claims)
if err != nil {
return nil, err
}
// validate id_token signature
//
// note: this step could be technically considered optional because we trust
// the token which is a result of direct TLS communication with the provider
// (see also https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation)
// ---
kid, _ := t.Header["kid"].(string)
err = validateIdTokenSignature(p.ctx, idToken, p.jwksURL, kid)
if err != nil {
return nil, err
}
return claims, nil
}

157
tools/auth/auth.go Normal file
View file

@ -0,0 +1,157 @@
package auth
import (
"context"
"encoding/json"
"errors"
"net/http"
"github.com/pocketbase/pocketbase/tools/types"
"golang.org/x/oauth2"
)
// ProviderFactoryFunc defines a function for initializing a new OAuth2 provider.
type ProviderFactoryFunc func() Provider
// Providers defines a map with all of the available OAuth2 providers.
//
// To register a new provider append a new entry in the map.
var Providers = map[string]ProviderFactoryFunc{}
// NewProviderByName returns a new preconfigured provider instance by its name identifier.
func NewProviderByName(name string) (Provider, error) {
factory, ok := Providers[name]
if !ok {
return nil, errors.New("missing provider " + name)
}
return factory(), nil
}
// Provider defines a common interface for an OAuth2 client.
type Provider interface {
// Context returns the context associated with the provider (if any).
Context() context.Context
// SetContext assigns the specified context to the current provider.
SetContext(ctx context.Context)
// PKCE indicates whether the provider can use the PKCE flow.
PKCE() bool
// SetPKCE toggles the state whether the provider can use the PKCE flow or not.
SetPKCE(enable bool)
// DisplayName usually returns provider name as it is officially written
// and it could be used directly in the UI.
DisplayName() string
// SetDisplayName sets the provider's display name.
SetDisplayName(displayName string)
// Scopes returns the provider access permissions that will be requested.
Scopes() []string
// SetScopes sets the provider access permissions that will be requested later.
SetScopes(scopes []string)
// ClientId returns the provider client's app ID.
ClientId() string
// SetClientId sets the provider client's ID.
SetClientId(clientId string)
// ClientSecret returns the provider client's app secret.
ClientSecret() string
// SetClientSecret sets the provider client's app secret.
SetClientSecret(secret string)
// RedirectURL returns the end address to redirect the user
// going through the OAuth flow.
RedirectURL() string
// SetRedirectURL sets the provider's RedirectURL.
SetRedirectURL(url string)
// AuthURL returns the provider's authorization service url.
AuthURL() string
// SetAuthURL sets the provider's AuthURL.
SetAuthURL(url string)
// TokenURL returns the provider's token exchange service url.
TokenURL() string
// SetTokenURL sets the provider's TokenURL.
SetTokenURL(url string)
// UserInfoURL returns the provider's user info api url.
UserInfoURL() string
// SetUserInfoURL sets the provider's UserInfoURL.
SetUserInfoURL(url string)
// Extra returns a shallow copy of any custom config data
// that the provider may be need.
Extra() map[string]any
// SetExtra updates the provider's custom config data.
SetExtra(data map[string]any)
// Client returns an http client using the provided token.
Client(token *oauth2.Token) *http.Client
// BuildAuthURL returns a URL to the provider's consent page
// that asks for permissions for the required scopes explicitly.
BuildAuthURL(state string, opts ...oauth2.AuthCodeOption) string
// FetchToken converts an authorization code to token.
FetchToken(code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error)
// FetchRawUserInfo requests and marshalizes into `result` the
// the OAuth user api response.
FetchRawUserInfo(token *oauth2.Token) ([]byte, error)
// FetchAuthUser is similar to FetchRawUserInfo, but normalizes and
// marshalizes the user api response into a standardized AuthUser struct.
FetchAuthUser(token *oauth2.Token) (user *AuthUser, err error)
}
// wrapFactory is a helper that wraps a Provider specific factory
// function and returns its result as Provider interface.
func wrapFactory[T Provider](factory func() T) ProviderFactoryFunc {
return func() Provider {
return factory()
}
}
// AuthUser defines a standardized OAuth2 user data structure.
type AuthUser struct {
Expiry types.DateTime `json:"expiry"`
RawUser map[string]any `json:"rawUser"`
Id string `json:"id"`
Name string `json:"name"`
Username string `json:"username"`
Email string `json:"email"`
AvatarURL string `json:"avatarURL"`
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
// @todo
// deprecated: use AvatarURL instead
// AvatarUrl will be removed after dropping v0.22 support
AvatarUrl string `json:"avatarUrl"`
}
// MarshalJSON implements the [json.Marshaler] interface.
//
// @todo remove after dropping v0.22 support
func (au AuthUser) MarshalJSON() ([]byte, error) {
type alias AuthUser // prevent recursion
au2 := alias(au)
au2.AvatarUrl = au.AvatarURL // ensure that the legacy field is populated
return json.Marshal(au2)
}

299
tools/auth/auth_test.go Normal file
View file

@ -0,0 +1,299 @@
package auth_test
import (
"testing"
"github.com/pocketbase/pocketbase/tools/auth"
)
func TestProvidersCount(t *testing.T) {
expected := 30
if total := len(auth.Providers); total != expected {
t.Fatalf("Expected %d providers, got %d", expected, total)
}
}
func TestNewProviderByName(t *testing.T) {
var err error
var p auth.Provider
// invalid
p, err = auth.NewProviderByName("invalid")
if err == nil {
t.Error("Expected error, got nil")
}
if p != nil {
t.Errorf("Expected provider to be nil, got %v", p)
}
// google
p, err = auth.NewProviderByName(auth.NameGoogle)
if err != nil {
t.Errorf("Expected nil, got error %v", err)
}
if _, ok := p.(*auth.Google); !ok {
t.Error("Expected to be instance of *auth.Google")
}
// facebook
p, err = auth.NewProviderByName(auth.NameFacebook)
if err != nil {
t.Errorf("Expected nil, got error %v", err)
}
if _, ok := p.(*auth.Facebook); !ok {
t.Error("Expected to be instance of *auth.Facebook")
}
// github
p, err = auth.NewProviderByName(auth.NameGithub)
if err != nil {
t.Errorf("Expected nil, got error %v", err)
}
if _, ok := p.(*auth.Github); !ok {
t.Error("Expected to be instance of *auth.Github")
}
// gitlab
p, err = auth.NewProviderByName(auth.NameGitlab)
if err != nil {
t.Errorf("Expected nil, got error %v", err)
}
if _, ok := p.(*auth.Gitlab); !ok {
t.Error("Expected to be instance of *auth.Gitlab")
}
// twitter
p, err = auth.NewProviderByName(auth.NameTwitter)
if err != nil {
t.Errorf("Expected nil, got error %v", err)
}
if _, ok := p.(*auth.Twitter); !ok {
t.Error("Expected to be instance of *auth.Twitter")
}
// discord
p, err = auth.NewProviderByName(auth.NameDiscord)
if err != nil {
t.Errorf("Expected nil, got error %v", err)
}
if _, ok := p.(*auth.Discord); !ok {
t.Error("Expected to be instance of *auth.Discord")
}
// microsoft
p, err = auth.NewProviderByName(auth.NameMicrosoft)
if err != nil {
t.Errorf("Expected nil, got error %v", err)
}
if _, ok := p.(*auth.Microsoft); !ok {
t.Error("Expected to be instance of *auth.Microsoft")
}
// spotify
p, err = auth.NewProviderByName(auth.NameSpotify)
if err != nil {
t.Errorf("Expected nil, got error %v", err)
}
if _, ok := p.(*auth.Spotify); !ok {
t.Error("Expected to be instance of *auth.Spotify")
}
// kakao
p, err = auth.NewProviderByName(auth.NameKakao)
if err != nil {
t.Errorf("Expected nil, got error %v", err)
}
if _, ok := p.(*auth.Kakao); !ok {
t.Error("Expected to be instance of *auth.Kakao")
}
// twitch
p, err = auth.NewProviderByName(auth.NameTwitch)
if err != nil {
t.Errorf("Expected nil, got error %v", err)
}
if _, ok := p.(*auth.Twitch); !ok {
t.Error("Expected to be instance of *auth.Twitch")
}
// strava
p, err = auth.NewProviderByName(auth.NameStrava)
if err != nil {
t.Errorf("Expected nil, got error %v", err)
}
if _, ok := p.(*auth.Strava); !ok {
t.Error("Expected to be instance of *auth.Strava")
}
// gitee
p, err = auth.NewProviderByName(auth.NameGitee)
if err != nil {
t.Errorf("Expected nil, got error %v", err)
}
if _, ok := p.(*auth.Gitee); !ok {
t.Error("Expected to be instance of *auth.Gitee")
}
// livechat
p, err = auth.NewProviderByName(auth.NameLivechat)
if err != nil {
t.Errorf("Expected nil, got error %v", err)
}
if _, ok := p.(*auth.Livechat); !ok {
t.Error("Expected to be instance of *auth.Livechat")
}
// gitea
p, err = auth.NewProviderByName(auth.NameGitea)
if err != nil {
t.Errorf("Expected nil, got error %v", err)
}
if _, ok := p.(*auth.Gitea); !ok {
t.Error("Expected to be instance of *auth.Gitea")
}
// oidc
p, err = auth.NewProviderByName(auth.NameOIDC)
if err != nil {
t.Errorf("Expected nil, got error %v", err)
}
if _, ok := p.(*auth.OIDC); !ok {
t.Error("Expected to be instance of *auth.OIDC")
}
// oidc2
p, err = auth.NewProviderByName(auth.NameOIDC + "2")
if err != nil {
t.Errorf("Expected nil, got error %v", err)
}
if _, ok := p.(*auth.OIDC); !ok {
t.Error("Expected to be instance of *auth.OIDC")
}
// oidc3
p, err = auth.NewProviderByName(auth.NameOIDC + "3")
if err != nil {
t.Errorf("Expected nil, got error %v", err)
}
if _, ok := p.(*auth.OIDC); !ok {
t.Error("Expected to be instance of *auth.OIDC")
}
// apple
p, err = auth.NewProviderByName(auth.NameApple)
if err != nil {
t.Errorf("Expected nil, got error %v", err)
}
if _, ok := p.(*auth.Apple); !ok {
t.Error("Expected to be instance of *auth.Apple")
}
// instagram
p, err = auth.NewProviderByName(auth.NameInstagram)
if err != nil {
t.Errorf("Expected nil, got error %v", err)
}
if _, ok := p.(*auth.Instagram); !ok {
t.Error("Expected to be instance of *auth.Instagram")
}
// vk
p, err = auth.NewProviderByName(auth.NameVK)
if err != nil {
t.Errorf("Expected nil, got error %v", err)
}
if _, ok := p.(*auth.VK); !ok {
t.Error("Expected to be instance of *auth.VK")
}
// yandex
p, err = auth.NewProviderByName(auth.NameYandex)
if err != nil {
t.Errorf("Expected nil, got error %v", err)
}
if _, ok := p.(*auth.Yandex); !ok {
t.Error("Expected to be instance of *auth.Yandex")
}
// patreon
p, err = auth.NewProviderByName(auth.NamePatreon)
if err != nil {
t.Errorf("Expected nil, got error %v", err)
}
if _, ok := p.(*auth.Patreon); !ok {
t.Error("Expected to be instance of *auth.Patreon")
}
// mailcow
p, err = auth.NewProviderByName(auth.NameMailcow)
if err != nil {
t.Errorf("Expected nil, got error %v", err)
}
if _, ok := p.(*auth.Mailcow); !ok {
t.Error("Expected to be instance of *auth.Mailcow")
}
// bitbucket
p, err = auth.NewProviderByName(auth.NameBitbucket)
if err != nil {
t.Errorf("Expected nil, got error %v", err)
}
if _, ok := p.(*auth.Bitbucket); !ok {
t.Error("Expected to be instance of *auth.Bitbucket")
}
// planningcenter
p, err = auth.NewProviderByName(auth.NamePlanningcenter)
if err != nil {
t.Errorf("Expected nil, got error %v", err)
}
if _, ok := p.(*auth.Planningcenter); !ok {
t.Error("Expected to be instance of *auth.Planningcenter")
}
// notion
p, err = auth.NewProviderByName(auth.NameNotion)
if err != nil {
t.Errorf("Expected nil, got error %v", err)
}
if _, ok := p.(*auth.Notion); !ok {
t.Error("Expected to be instance of *auth.Notion")
}
// monday
p, err = auth.NewProviderByName(auth.NameMonday)
if err != nil {
t.Errorf("Expected nil, got error %v", err)
}
if _, ok := p.(*auth.Monday); !ok {
t.Error("Expected to be instance of *auth.Monday")
}
// wakatime
p, err = auth.NewProviderByName(auth.NameWakatime)
if err != nil {
t.Errorf("Expected nil, got error %v", err)
}
if _, ok := p.(*auth.Wakatime); !ok {
t.Error("Expected to be instance of *auth.Wakatime")
}
// linear
p, err = auth.NewProviderByName(auth.NameLinear)
if err != nil {
t.Errorf("Expected nil, got error %v", err)
}
if _, ok := p.(*auth.Linear); !ok {
t.Error("Expected to be instance of *auth.Linear")
}
// trakt
p, err = auth.NewProviderByName(auth.NameTrakt)
if err != nil {
t.Errorf("Expected nil, got error %v", err)
}
if _, ok := p.(*auth.Trakt); !ok {
t.Error("Expected to be instance of *auth.Trakt")
}
}

203
tools/auth/base_provider.go Normal file
View file

@ -0,0 +1,203 @@
package auth
import (
"context"
"fmt"
"io"
"maps"
"net/http"
"golang.org/x/oauth2"
)
// BaseProvider defines common fields and methods used by OAuth2 client providers.
type BaseProvider struct {
ctx context.Context
clientId string
clientSecret string
displayName string
redirectURL string
authURL string
tokenURL string
userInfoURL string
scopes []string
pkce bool
extra map[string]any
}
// Context implements Provider.Context() interface method.
func (p *BaseProvider) Context() context.Context {
return p.ctx
}
// SetContext implements Provider.SetContext() interface method.
func (p *BaseProvider) SetContext(ctx context.Context) {
p.ctx = ctx
}
// PKCE implements Provider.PKCE() interface method.
func (p *BaseProvider) PKCE() bool {
return p.pkce
}
// SetPKCE implements Provider.SetPKCE() interface method.
func (p *BaseProvider) SetPKCE(enable bool) {
p.pkce = enable
}
// DisplayName implements Provider.DisplayName() interface method.
func (p *BaseProvider) DisplayName() string {
return p.displayName
}
// SetDisplayName implements Provider.SetDisplayName() interface method.
func (p *BaseProvider) SetDisplayName(displayName string) {
p.displayName = displayName
}
// Scopes implements Provider.Scopes() interface method.
func (p *BaseProvider) Scopes() []string {
return p.scopes
}
// SetScopes implements Provider.SetScopes() interface method.
func (p *BaseProvider) SetScopes(scopes []string) {
p.scopes = scopes
}
// ClientId implements Provider.ClientId() interface method.
func (p *BaseProvider) ClientId() string {
return p.clientId
}
// SetClientId implements Provider.SetClientId() interface method.
func (p *BaseProvider) SetClientId(clientId string) {
p.clientId = clientId
}
// ClientSecret implements Provider.ClientSecret() interface method.
func (p *BaseProvider) ClientSecret() string {
return p.clientSecret
}
// SetClientSecret implements Provider.SetClientSecret() interface method.
func (p *BaseProvider) SetClientSecret(secret string) {
p.clientSecret = secret
}
// RedirectURL implements Provider.RedirectURL() interface method.
func (p *BaseProvider) RedirectURL() string {
return p.redirectURL
}
// SetRedirectURL implements Provider.SetRedirectURL() interface method.
func (p *BaseProvider) SetRedirectURL(url string) {
p.redirectURL = url
}
// AuthURL implements Provider.AuthURL() interface method.
func (p *BaseProvider) AuthURL() string {
return p.authURL
}
// SetAuthURL implements Provider.SetAuthURL() interface method.
func (p *BaseProvider) SetAuthURL(url string) {
p.authURL = url
}
// TokenURL implements Provider.TokenURL() interface method.
func (p *BaseProvider) TokenURL() string {
return p.tokenURL
}
// SetTokenURL implements Provider.SetTokenURL() interface method.
func (p *BaseProvider) SetTokenURL(url string) {
p.tokenURL = url
}
// UserInfoURL implements Provider.UserInfoURL() interface method.
func (p *BaseProvider) UserInfoURL() string {
return p.userInfoURL
}
// SetUserInfoURL implements Provider.SetUserInfoURL() interface method.
func (p *BaseProvider) SetUserInfoURL(url string) {
p.userInfoURL = url
}
// Extra implements Provider.Extra() interface method.
func (p *BaseProvider) Extra() map[string]any {
return maps.Clone(p.extra)
}
// SetExtra implements Provider.SetExtra() interface method.
func (p *BaseProvider) SetExtra(data map[string]any) {
p.extra = data
}
// BuildAuthURL implements Provider.BuildAuthURL() interface method.
func (p *BaseProvider) BuildAuthURL(state string, opts ...oauth2.AuthCodeOption) string {
return p.oauth2Config().AuthCodeURL(state, opts...)
}
// FetchToken implements Provider.FetchToken() interface method.
func (p *BaseProvider) FetchToken(code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) {
return p.oauth2Config().Exchange(p.ctx, code, opts...)
}
// Client implements Provider.Client() interface method.
func (p *BaseProvider) Client(token *oauth2.Token) *http.Client {
return p.oauth2Config().Client(p.ctx, token)
}
// FetchRawUserInfo implements Provider.FetchRawUserInfo() interface method.
func (p *BaseProvider) FetchRawUserInfo(token *oauth2.Token) ([]byte, error) {
req, err := http.NewRequestWithContext(p.ctx, "GET", p.userInfoURL, nil)
if err != nil {
return nil, err
}
return p.sendRawUserInfoRequest(req, token)
}
// sendRawUserInfoRequest sends the specified user info request and return its raw response body.
func (p *BaseProvider) sendRawUserInfoRequest(req *http.Request, token *oauth2.Token) ([]byte, error) {
client := p.Client(token)
res, err := client.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
result, err := io.ReadAll(res.Body)
if err != nil {
return nil, err
}
// http.Client.Get doesn't treat non 2xx responses as error
if res.StatusCode >= 400 {
return nil, fmt.Errorf(
"failed to fetch OAuth2 user profile via %s (%d):\n%s",
p.userInfoURL,
res.StatusCode,
string(result),
)
}
return result, nil
}
// oauth2Config constructs a oauth2.Config instance based on the provider settings.
func (p *BaseProvider) oauth2Config() *oauth2.Config {
return &oauth2.Config{
RedirectURL: p.redirectURL,
ClientID: p.clientId,
ClientSecret: p.clientSecret,
Scopes: p.scopes,
Endpoint: oauth2.Endpoint{
AuthURL: p.authURL,
TokenURL: p.tokenURL,
},
}
}

View file

@ -0,0 +1,269 @@
package auth
import (
"bytes"
"context"
"encoding/json"
"testing"
"golang.org/x/oauth2"
)
func TestContext(t *testing.T) {
b := BaseProvider{}
before := b.Scopes()
if before != nil {
t.Errorf("Expected nil context, got %v", before)
}
b.SetContext(context.Background())
after := b.Scopes()
if after != nil {
t.Error("Expected non-nil context")
}
}
func TestDisplayName(t *testing.T) {
b := BaseProvider{}
before := b.DisplayName()
if before != "" {
t.Fatalf("Expected displayName to be empty, got %v", before)
}
b.SetDisplayName("test")
after := b.DisplayName()
if after != "test" {
t.Fatalf("Expected displayName to be 'test', got %v", after)
}
}
func TestPKCE(t *testing.T) {
b := BaseProvider{}
before := b.PKCE()
if before != false {
t.Fatalf("Expected pkce to be %v, got %v", false, before)
}
b.SetPKCE(true)
after := b.PKCE()
if after != true {
t.Fatalf("Expected pkce to be %v, got %v", true, after)
}
}
func TestScopes(t *testing.T) {
b := BaseProvider{}
before := b.Scopes()
if len(before) != 0 {
t.Fatalf("Expected 0 scopes, got %v", before)
}
b.SetScopes([]string{"test1", "test2"})
after := b.Scopes()
if len(after) != 2 {
t.Fatalf("Expected 2 scopes, got %v", after)
}
}
func TestClientId(t *testing.T) {
b := BaseProvider{}
before := b.ClientId()
if before != "" {
t.Fatalf("Expected clientId to be empty, got %v", before)
}
b.SetClientId("test")
after := b.ClientId()
if after != "test" {
t.Fatalf("Expected clientId to be 'test', got %v", after)
}
}
func TestClientSecret(t *testing.T) {
b := BaseProvider{}
before := b.ClientSecret()
if before != "" {
t.Fatalf("Expected clientSecret to be empty, got %v", before)
}
b.SetClientSecret("test")
after := b.ClientSecret()
if after != "test" {
t.Fatalf("Expected clientSecret to be 'test', got %v", after)
}
}
func TestRedirectURL(t *testing.T) {
b := BaseProvider{}
before := b.RedirectURL()
if before != "" {
t.Fatalf("Expected RedirectURL to be empty, got %v", before)
}
b.SetRedirectURL("test")
after := b.RedirectURL()
if after != "test" {
t.Fatalf("Expected RedirectURL to be 'test', got %v", after)
}
}
func TestAuthURL(t *testing.T) {
b := BaseProvider{}
before := b.AuthURL()
if before != "" {
t.Fatalf("Expected authURL to be empty, got %v", before)
}
b.SetAuthURL("test")
after := b.AuthURL()
if after != "test" {
t.Fatalf("Expected authURL to be 'test', got %v", after)
}
}
func TestTokenURL(t *testing.T) {
b := BaseProvider{}
before := b.TokenURL()
if before != "" {
t.Fatalf("Expected tokenURL to be empty, got %v", before)
}
b.SetTokenURL("test")
after := b.TokenURL()
if after != "test" {
t.Fatalf("Expected tokenURL to be 'test', got %v", after)
}
}
func TestUserInfoURL(t *testing.T) {
b := BaseProvider{}
before := b.UserInfoURL()
if before != "" {
t.Fatalf("Expected userInfoURL to be empty, got %v", before)
}
b.SetUserInfoURL("test")
after := b.UserInfoURL()
if after != "test" {
t.Fatalf("Expected userInfoURL to be 'test', got %v", after)
}
}
func TestExtra(t *testing.T) {
b := BaseProvider{}
before := b.Extra()
if before != nil {
t.Fatalf("Expected extra to be empty, got %v", before)
}
extra := map[string]any{"a": 1, "b": 2}
b.SetExtra(extra)
after := b.Extra()
rawExtra, err := json.Marshal(extra)
if err != nil {
t.Fatal(err)
}
rawAfter, err := json.Marshal(after)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(rawExtra, rawAfter) {
t.Fatalf("Expected extra to be\n%s\ngot\n%s", rawExtra, rawAfter)
}
// ensure that it was shallow copied
after["b"] = 3
if d := b.Extra(); d["b"] != 2 {
t.Fatalf("Expected extra to remain unchanged, got\n%v", d)
}
}
func TestBuildAuthURL(t *testing.T) {
b := BaseProvider{
authURL: "authURL_test",
tokenURL: "tokenURL_test",
redirectURL: "redirectURL_test",
clientId: "clientId_test",
clientSecret: "clientSecret_test",
scopes: []string{"test_scope"},
}
expected := "authURL_test?access_type=offline&client_id=clientId_test&prompt=consent&redirect_uri=redirectURL_test&response_type=code&scope=test_scope&state=state_test"
result := b.BuildAuthURL("state_test", oauth2.AccessTypeOffline, oauth2.ApprovalForce)
if result != expected {
t.Errorf("Expected auth url %q, got %q", expected, result)
}
}
func TestClient(t *testing.T) {
b := BaseProvider{}
result := b.Client(&oauth2.Token{})
if result == nil {
t.Error("Expected *http.Client instance, got nil")
}
}
func TestOauth2Config(t *testing.T) {
b := BaseProvider{
authURL: "authURL_test",
tokenURL: "tokenURL_test",
redirectURL: "redirectURL_test",
clientId: "clientId_test",
clientSecret: "clientSecret_test",
scopes: []string{"test"},
}
result := b.oauth2Config()
if result.RedirectURL != b.RedirectURL() {
t.Errorf("Expected redirectURL %s, got %s", b.RedirectURL(), result.RedirectURL)
}
if result.ClientID != b.ClientId() {
t.Errorf("Expected clientId %s, got %s", b.ClientId(), result.ClientID)
}
if result.ClientSecret != b.ClientSecret() {
t.Errorf("Expected clientSecret %s, got %s", b.ClientSecret(), result.ClientSecret)
}
if result.Endpoint.AuthURL != b.AuthURL() {
t.Errorf("Expected authURL %s, got %s", b.AuthURL(), result.Endpoint.AuthURL)
}
if result.Endpoint.TokenURL != b.TokenURL() {
t.Errorf("Expected authURL %s, got %s", b.TokenURL(), result.Endpoint.TokenURL)
}
if len(result.Scopes) != len(b.Scopes()) || result.Scopes[0] != b.Scopes()[0] {
t.Errorf("Expected scopes %s, got %s", b.Scopes(), result.Scopes)
}
}

136
tools/auth/bitbucket.go Normal file
View file

@ -0,0 +1,136 @@
package auth
import (
"context"
"encoding/json"
"errors"
"io"
"github.com/pocketbase/pocketbase/tools/types"
"golang.org/x/oauth2"
)
func init() {
Providers[NameBitbucket] = wrapFactory(NewBitbucketProvider)
}
var _ Provider = (*Bitbucket)(nil)
// NameBitbucket is the unique name of the Bitbucket provider.
const NameBitbucket = "bitbucket"
// Bitbucket is an auth provider for Bitbucket.
type Bitbucket struct {
BaseProvider
}
// NewBitbucketProvider creates a new Bitbucket provider instance with some defaults.
func NewBitbucketProvider() *Bitbucket {
return &Bitbucket{BaseProvider{
ctx: context.Background(),
displayName: "Bitbucket",
pkce: false,
scopes: []string{"account"},
authURL: "https://bitbucket.org/site/oauth2/authorize",
tokenURL: "https://bitbucket.org/site/oauth2/access_token",
userInfoURL: "https://api.bitbucket.org/2.0/user",
}}
}
// FetchAuthUser returns an AuthUser instance based on the Bitbucket's user API.
//
// API reference: https://developer.atlassian.com/cloud/bitbucket/rest/api-group-users/#api-user-get
func (p *Bitbucket) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
data, err := p.FetchRawUserInfo(token)
if err != nil {
return nil, err
}
rawUser := map[string]any{}
if err := json.Unmarshal(data, &rawUser); err != nil {
return nil, err
}
extracted := struct {
UUID string `json:"uuid"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
AccountStatus string `json:"account_status"`
Links struct {
Avatar struct {
Href string `json:"href"`
} `json:"avatar"`
} `json:"links"`
}{}
if err := json.Unmarshal(data, &extracted); err != nil {
return nil, err
}
if extracted.AccountStatus != "active" {
return nil, errors.New("the Bitbucket user is not active")
}
email, err := p.fetchPrimaryEmail(token)
if err != nil {
return nil, err
}
user := &AuthUser{
Id: extracted.UUID,
Name: extracted.DisplayName,
Username: extracted.Username,
Email: email,
AvatarURL: extracted.Links.Avatar.Href,
RawUser: rawUser,
AccessToken: token.AccessToken,
RefreshToken: token.RefreshToken,
}
user.Expiry, _ = types.ParseDateTime(token.Expiry)
return user, nil
}
// fetchPrimaryEmail sends an API request to retrieve the first
// verified primary email.
//
// NB! This method can succeed and still return an empty email.
// Error responses that are result of insufficient scopes permissions are ignored.
//
// API reference: https://developer.atlassian.com/cloud/bitbucket/rest/api-group-users/#api-user-emails-get
func (p *Bitbucket) fetchPrimaryEmail(token *oauth2.Token) (string, error) {
response, err := p.Client(token).Get(p.userInfoURL + "/emails")
if err != nil {
return "", err
}
defer response.Body.Close()
// ignore common http errors caused by insufficient scope permissions
// (the email field is optional, aka. return the auth user without it)
if response.StatusCode >= 400 {
return "", nil
}
data, err := io.ReadAll(response.Body)
if err != nil {
return "", err
}
expected := struct {
Values []struct {
Email string `json:"email"`
IsPrimary bool `json:"is_primary"`
} `json:"values"`
}{}
if err := json.Unmarshal(data, &expected); err != nil {
return "", err
}
for _, v := range expected.Values {
if v.IsPrimary {
return v.Email, nil
}
}
return "", nil
}

91
tools/auth/discord.go Normal file
View file

@ -0,0 +1,91 @@
package auth
import (
"context"
"encoding/json"
"fmt"
"github.com/pocketbase/pocketbase/tools/types"
"golang.org/x/oauth2"
)
func init() {
Providers[NameDiscord] = wrapFactory(NewDiscordProvider)
}
var _ Provider = (*Discord)(nil)
// NameDiscord is the unique name of the Discord provider.
const NameDiscord string = "discord"
// Discord allows authentication via Discord OAuth2.
type Discord struct {
BaseProvider
}
// NewDiscordProvider creates a new Discord provider instance with some defaults.
func NewDiscordProvider() *Discord {
// https://discord.com/developers/docs/topics/oauth2
// https://discord.com/developers/docs/resources/user#get-current-user
return &Discord{BaseProvider{
ctx: context.Background(),
displayName: "Discord",
pkce: true,
scopes: []string{"identify", "email"},
authURL: "https://discord.com/api/oauth2/authorize",
tokenURL: "https://discord.com/api/oauth2/token",
userInfoURL: "https://discord.com/api/users/@me",
}}
}
// FetchAuthUser returns an AuthUser instance from Discord's user api.
//
// API reference: https://discord.com/developers/docs/resources/user#user-object
func (p *Discord) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
data, err := p.FetchRawUserInfo(token)
if err != nil {
return nil, err
}
rawUser := map[string]any{}
if err := json.Unmarshal(data, &rawUser); err != nil {
return nil, err
}
extracted := struct {
Id string `json:"id"`
Username string `json:"username"`
Discriminator string `json:"discriminator"`
Avatar string `json:"avatar"`
Email string `json:"email"`
Verified bool `json:"verified"`
}{}
if err := json.Unmarshal(data, &extracted); err != nil {
return nil, err
}
// Build a full avatar URL using the avatar hash provided in the API response
// https://discord.com/developers/docs/reference#image-formatting
avatarURL := fmt.Sprintf("https://cdn.discordapp.com/avatars/%s/%s.png", extracted.Id, extracted.Avatar)
// Concatenate the user's username and discriminator into a single username string
username := fmt.Sprintf("%s#%s", extracted.Username, extracted.Discriminator)
user := &AuthUser{
Id: extracted.Id,
Name: username,
Username: extracted.Username,
AvatarURL: avatarURL,
RawUser: rawUser,
AccessToken: token.AccessToken,
RefreshToken: token.RefreshToken,
}
user.Expiry, _ = types.ParseDateTime(token.Expiry)
if extracted.Verified {
user.Email = extracted.Email
}
return user, nil
}

78
tools/auth/facebook.go Normal file
View file

@ -0,0 +1,78 @@
package auth
import (
"context"
"encoding/json"
"github.com/pocketbase/pocketbase/tools/types"
"golang.org/x/oauth2"
"golang.org/x/oauth2/facebook"
)
func init() {
Providers[NameFacebook] = wrapFactory(NewFacebookProvider)
}
var _ Provider = (*Facebook)(nil)
// NameFacebook is the unique name of the Facebook provider.
const NameFacebook string = "facebook"
// Facebook allows authentication via Facebook OAuth2.
type Facebook struct {
BaseProvider
}
// NewFacebookProvider creates new Facebook provider instance with some defaults.
func NewFacebookProvider() *Facebook {
return &Facebook{BaseProvider{
ctx: context.Background(),
displayName: "Facebook",
pkce: true,
scopes: []string{"email"},
authURL: facebook.Endpoint.AuthURL,
tokenURL: facebook.Endpoint.TokenURL,
userInfoURL: "https://graph.facebook.com/me?fields=name,email,picture.type(large)",
}}
}
// FetchAuthUser returns an AuthUser instance based on the Facebook's user api.
//
// API reference: https://developers.facebook.com/docs/graph-api/reference/user/
func (p *Facebook) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
data, err := p.FetchRawUserInfo(token)
if err != nil {
return nil, err
}
rawUser := map[string]any{}
if err := json.Unmarshal(data, &rawUser); err != nil {
return nil, err
}
extracted := struct {
Id string
Name string
Email string
Picture struct {
Data struct{ Url string }
}
}{}
if err := json.Unmarshal(data, &extracted); err != nil {
return nil, err
}
user := &AuthUser{
Id: extracted.Id,
Name: extracted.Name,
Email: extracted.Email,
AvatarURL: extracted.Picture.Data.Url,
RawUser: rawUser,
AccessToken: token.AccessToken,
RefreshToken: token.RefreshToken,
}
user.Expiry, _ = types.ParseDateTime(token.Expiry)
return user, nil
}

78
tools/auth/gitea.go Normal file
View file

@ -0,0 +1,78 @@
package auth
import (
"context"
"encoding/json"
"strconv"
"github.com/pocketbase/pocketbase/tools/types"
"golang.org/x/oauth2"
)
func init() {
Providers[NameGitea] = wrapFactory(NewGiteaProvider)
}
var _ Provider = (*Gitea)(nil)
// NameGitea is the unique name of the Gitea provider.
const NameGitea string = "gitea"
// Gitea allows authentication via Gitea OAuth2.
type Gitea struct {
BaseProvider
}
// NewGiteaProvider creates new Gitea provider instance with some defaults.
func NewGiteaProvider() *Gitea {
return &Gitea{BaseProvider{
ctx: context.Background(),
displayName: "Gitea",
pkce: true,
scopes: []string{"read:user", "user:email"},
authURL: "https://gitea.com/login/oauth/authorize",
tokenURL: "https://gitea.com/login/oauth/access_token",
userInfoURL: "https://gitea.com/api/v1/user",
}}
}
// FetchAuthUser returns an AuthUser instance based on Gitea's user api.
//
// API reference: https://try.gitea.io/api/swagger#/user/userGetCurrent
func (p *Gitea) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
data, err := p.FetchRawUserInfo(token)
if err != nil {
return nil, err
}
rawUser := map[string]any{}
if err := json.Unmarshal(data, &rawUser); err != nil {
return nil, err
}
extracted := struct {
Name string `json:"full_name"`
Username string `json:"login"`
Email string `json:"email"`
AvatarURL string `json:"avatar_url"`
Id int64 `json:"id"`
}{}
if err := json.Unmarshal(data, &extracted); err != nil {
return nil, err
}
user := &AuthUser{
Id: strconv.FormatInt(extracted.Id, 10),
Name: extracted.Name,
Username: extracted.Username,
Email: extracted.Email,
AvatarURL: extracted.AvatarURL,
RawUser: rawUser,
AccessToken: token.AccessToken,
RefreshToken: token.RefreshToken,
}
user.Expiry, _ = types.ParseDateTime(token.Expiry)
return user, nil
}

142
tools/auth/gitee.go Normal file
View file

@ -0,0 +1,142 @@
package auth
import (
"context"
"encoding/json"
"io"
"strconv"
"github.com/go-ozzo/ozzo-validation/v4/is"
"github.com/pocketbase/pocketbase/tools/types"
"golang.org/x/oauth2"
)
func init() {
Providers[NameGitee] = wrapFactory(NewGiteeProvider)
}
var _ Provider = (*Gitee)(nil)
// NameGitee is the unique name of the Gitee provider.
const NameGitee string = "gitee"
// Gitee allows authentication via Gitee OAuth2.
type Gitee struct {
BaseProvider
}
// NewGiteeProvider creates new Gitee provider instance with some defaults.
func NewGiteeProvider() *Gitee {
return &Gitee{BaseProvider{
ctx: context.Background(),
displayName: "Gitee",
pkce: true,
scopes: []string{"user_info", "emails"},
authURL: "https://gitee.com/oauth/authorize",
tokenURL: "https://gitee.com/oauth/token",
userInfoURL: "https://gitee.com/api/v5/user",
}}
}
// FetchAuthUser returns an AuthUser instance based the Gitee's user api.
//
// API reference: https://gitee.com/api/v5/swagger#/getV5User
func (p *Gitee) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
data, err := p.FetchRawUserInfo(token)
if err != nil {
return nil, err
}
rawUser := map[string]any{}
if err := json.Unmarshal(data, &rawUser); err != nil {
return nil, err
}
extracted := struct {
Login string `json:"login"`
Name string `json:"name"`
Email string `json:"email"`
AvatarURL string `json:"avatar_url"`
Id int64 `json:"id"`
}{}
if err := json.Unmarshal(data, &extracted); err != nil {
return nil, err
}
user := &AuthUser{
Id: strconv.FormatInt(extracted.Id, 10),
Name: extracted.Name,
Username: extracted.Login,
AvatarURL: extracted.AvatarURL,
RawUser: rawUser,
AccessToken: token.AccessToken,
RefreshToken: token.RefreshToken,
}
user.Expiry, _ = types.ParseDateTime(token.Expiry)
if extracted.Email != "" && is.EmailFormat.Validate(extracted.Email) == nil {
// valid public primary email
user.Email = extracted.Email
} else {
// send an additional optional request to retrieve the email
email, err := p.fetchPrimaryEmail(token)
if err != nil {
return nil, err
}
user.Email = email
}
return user, nil
}
// fetchPrimaryEmail sends an API request to retrieve the verified primary email,
// in case the user hasn't set "Public email address" or has unchecked
// the "Access your emails data" permission during authentication.
//
// NB! This method can succeed and still return an empty email.
// Error responses that are result of insufficient scopes permissions are ignored.
//
// API reference: https://gitee.com/api/v5/swagger#/getV5Emails
func (p *Gitee) fetchPrimaryEmail(token *oauth2.Token) (string, error) {
client := p.Client(token)
response, err := client.Get("https://gitee.com/api/v5/emails")
if err != nil {
return "", err
}
defer response.Body.Close()
// ignore common http errors caused by insufficient scope permissions
if response.StatusCode == 401 || response.StatusCode == 403 || response.StatusCode == 404 {
return "", nil
}
content, err := io.ReadAll(response.Body)
if err != nil {
return "", err
}
emails := []struct {
Email string
State string
Scope []string
}{}
if err := json.Unmarshal(content, &emails); err != nil {
// ignore unmarshal error in case "Keep my email address private"
// was set because response.Body will be something like:
// {"email":"12285415+test@user.noreply.gitee.com"}
return "", nil
}
// extract the first verified primary email
for _, email := range emails {
for _, scope := range email.Scope {
if email.State == "confirmed" && scope == "primary" && is.EmailFormat.Validate(email.Email) == nil {
return email.Email, nil
}
}
}
return "", nil
}

136
tools/auth/github.go Normal file
View file

@ -0,0 +1,136 @@
package auth
import (
"context"
"encoding/json"
"io"
"strconv"
"github.com/pocketbase/pocketbase/tools/types"
"golang.org/x/oauth2"
"golang.org/x/oauth2/github"
)
func init() {
Providers[NameGithub] = wrapFactory(NewGithubProvider)
}
var _ Provider = (*Github)(nil)
// NameGithub is the unique name of the Github provider.
const NameGithub string = "github"
// Github allows authentication via Github OAuth2.
type Github struct {
BaseProvider
}
// NewGithubProvider creates new Github provider instance with some defaults.
func NewGithubProvider() *Github {
return &Github{BaseProvider{
ctx: context.Background(),
displayName: "GitHub",
pkce: true, // technically is not supported yet but it is safe as the PKCE params are just ignored
scopes: []string{"read:user", "user:email"},
authURL: github.Endpoint.AuthURL,
tokenURL: github.Endpoint.TokenURL,
userInfoURL: "https://api.github.com/user",
}}
}
// FetchAuthUser returns an AuthUser instance based the Github's user api.
//
// API reference: https://docs.github.com/en/rest/reference/users#get-the-authenticated-user
func (p *Github) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
data, err := p.FetchRawUserInfo(token)
if err != nil {
return nil, err
}
rawUser := map[string]any{}
if err := json.Unmarshal(data, &rawUser); err != nil {
return nil, err
}
extracted := struct {
Login string `json:"login"`
Name string `json:"name"`
Email string `json:"email"`
AvatarURL string `json:"avatar_url"`
Id int64 `json:"id"`
}{}
if err := json.Unmarshal(data, &extracted); err != nil {
return nil, err
}
user := &AuthUser{
Id: strconv.FormatInt(extracted.Id, 10),
Name: extracted.Name,
Username: extracted.Login,
Email: extracted.Email,
AvatarURL: extracted.AvatarURL,
RawUser: rawUser,
AccessToken: token.AccessToken,
RefreshToken: token.RefreshToken,
}
user.Expiry, _ = types.ParseDateTime(token.Expiry)
// in case user has set "Keep my email address private", send an
// **optional** API request to retrieve the verified primary email
if user.Email == "" {
email, err := p.fetchPrimaryEmail(token)
if err != nil {
return nil, err
}
user.Email = email
}
return user, nil
}
// fetchPrimaryEmail sends an API request to retrieve the verified
// primary email, in case "Keep my email address private" was set.
//
// NB! This method can succeed and still return an empty email.
// Error responses that are result of insufficient scopes permissions are ignored.
//
// API reference: https://docs.github.com/en/rest/users/emails?apiVersion=2022-11-28
func (p *Github) fetchPrimaryEmail(token *oauth2.Token) (string, error) {
client := p.Client(token)
response, err := client.Get(p.userInfoURL + "/emails")
if err != nil {
return "", err
}
defer response.Body.Close()
// ignore common http errors caused by insufficient scope permissions
// (the email field is optional, aka. return the auth user without it)
if response.StatusCode == 401 || response.StatusCode == 403 || response.StatusCode == 404 {
return "", nil
}
content, err := io.ReadAll(response.Body)
if err != nil {
return "", err
}
emails := []struct {
Email string
Verified bool
Primary bool
}{}
if err := json.Unmarshal(content, &emails); err != nil {
return "", err
}
// extract the verified primary email
for _, email := range emails {
if email.Verified && email.Primary {
return email.Email, nil
}
}
return "", nil
}

78
tools/auth/gitlab.go Normal file
View file

@ -0,0 +1,78 @@
package auth
import (
"context"
"encoding/json"
"strconv"
"github.com/pocketbase/pocketbase/tools/types"
"golang.org/x/oauth2"
)
func init() {
Providers[NameGitlab] = wrapFactory(NewGitlabProvider)
}
var _ Provider = (*Gitlab)(nil)
// NameGitlab is the unique name of the Gitlab provider.
const NameGitlab string = "gitlab"
// Gitlab allows authentication via Gitlab OAuth2.
type Gitlab struct {
BaseProvider
}
// NewGitlabProvider creates new Gitlab provider instance with some defaults.
func NewGitlabProvider() *Gitlab {
return &Gitlab{BaseProvider{
ctx: context.Background(),
displayName: "GitLab",
pkce: true,
scopes: []string{"read_user"},
authURL: "https://gitlab.com/oauth/authorize",
tokenURL: "https://gitlab.com/oauth/token",
userInfoURL: "https://gitlab.com/api/v4/user",
}}
}
// FetchAuthUser returns an AuthUser instance based the Gitlab's user api.
//
// API reference: https://docs.gitlab.com/ee/api/users.html#for-admin
func (p *Gitlab) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
data, err := p.FetchRawUserInfo(token)
if err != nil {
return nil, err
}
rawUser := map[string]any{}
if err := json.Unmarshal(data, &rawUser); err != nil {
return nil, err
}
extracted := struct {
Name string `json:"name"`
Username string `json:"username"`
Email string `json:"email"`
AvatarURL string `json:"avatar_url"`
Id int64 `json:"id"`
}{}
if err := json.Unmarshal(data, &extracted); err != nil {
return nil, err
}
user := &AuthUser{
Id: strconv.FormatInt(extracted.Id, 10),
Name: extracted.Name,
Username: extracted.Username,
Email: extracted.Email,
AvatarURL: extracted.AvatarURL,
RawUser: rawUser,
AccessToken: token.AccessToken,
RefreshToken: token.RefreshToken,
}
user.Expiry, _ = types.ParseDateTime(token.Expiry)
return user, nil
}

80
tools/auth/google.go Normal file
View file

@ -0,0 +1,80 @@
package auth
import (
"context"
"encoding/json"
"github.com/pocketbase/pocketbase/tools/types"
"golang.org/x/oauth2"
)
func init() {
Providers[NameGoogle] = wrapFactory(NewGoogleProvider)
}
var _ Provider = (*Google)(nil)
// NameGoogle is the unique name of the Google provider.
const NameGoogle string = "google"
// Google allows authentication via Google OAuth2.
type Google struct {
BaseProvider
}
// NewGoogleProvider creates new Google provider instance with some defaults.
func NewGoogleProvider() *Google {
return &Google{BaseProvider{
ctx: context.Background(),
displayName: "Google",
pkce: true,
scopes: []string{
"https://www.googleapis.com/auth/userinfo.profile",
"https://www.googleapis.com/auth/userinfo.email",
},
authURL: "https://accounts.google.com/o/oauth2/v2/auth",
tokenURL: "https://oauth2.googleapis.com/token",
userInfoURL: "https://www.googleapis.com/oauth2/v3/userinfo",
}}
}
// FetchAuthUser returns an AuthUser instance based the Google's user api.
func (p *Google) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
data, err := p.FetchRawUserInfo(token)
if err != nil {
return nil, err
}
rawUser := map[string]any{}
if err := json.Unmarshal(data, &rawUser); err != nil {
return nil, err
}
extracted := struct {
Id string `json:"sub"`
Name string `json:"name"`
Picture string `json:"picture"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
}{}
if err := json.Unmarshal(data, &extracted); err != nil {
return nil, err
}
user := &AuthUser{
Id: extracted.Id,
Name: extracted.Name,
AvatarURL: extracted.Picture,
RawUser: rawUser,
AccessToken: token.AccessToken,
RefreshToken: token.RefreshToken,
}
user.Expiry, _ = types.ParseDateTime(token.Expiry)
if extracted.EmailVerified {
user.Email = extracted.Email
}
return user, nil
}

82
tools/auth/instagram.go Normal file
View file

@ -0,0 +1,82 @@
package auth
import (
"context"
"encoding/json"
"github.com/pocketbase/pocketbase/tools/types"
"golang.org/x/oauth2"
)
func init() {
Providers[NameInstagram] = wrapFactory(NewInstagramProvider)
}
var _ Provider = (*Instagram)(nil)
// NameInstagram is the unique name of the Instagram provider.
const NameInstagram string = "instagram2" // "2" suffix to avoid conflicts with the old deprecated version
// Instagram allows authentication via Instagram Login OAuth2.
type Instagram struct {
BaseProvider
}
// NewInstagramProvider creates new Instagram provider instance with some defaults.
func NewInstagramProvider() *Instagram {
return &Instagram{BaseProvider{
ctx: context.Background(),
displayName: "Instagram",
pkce: true,
scopes: []string{"instagram_business_basic"},
authURL: "https://www.instagram.com/oauth/authorize",
tokenURL: "https://api.instagram.com/oauth/access_token",
userInfoURL: "https://graph.instagram.com/me?fields=id,username,account_type,user_id,name,profile_picture_url,followers_count,follows_count,media_count",
}}
}
// FetchAuthUser returns an AuthUser instance based on the Instagram Login user api response.
//
// API reference: https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/get-started#fields
func (p *Instagram) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
data, err := p.FetchRawUserInfo(token)
if err != nil {
return nil, err
}
rawUser := map[string]any{}
if err := json.Unmarshal(data, &rawUser); err != nil {
return nil, err
}
// include list of granted permissions to RawUser's payload
if _, ok := rawUser["permissions"]; !ok {
if permissions := token.Extra("permissions"); permissions != nil {
rawUser["permissions"] = permissions
}
}
extracted := struct {
Id string `json:"user_id"`
Name string `json:"name"`
Username string `json:"username"`
AvatarURL string `json:"profile_picture_url"`
}{}
if err := json.Unmarshal(data, &extracted); err != nil {
return nil, err
}
user := &AuthUser{
Id: extracted.Id,
Username: extracted.Username,
Name: extracted.Name,
AvatarURL: extracted.AvatarURL,
RawUser: rawUser,
AccessToken: token.AccessToken,
RefreshToken: token.RefreshToken,
}
user.Expiry, _ = types.ParseDateTime(token.Expiry)
return user, nil
}

86
tools/auth/kakao.go Normal file
View file

@ -0,0 +1,86 @@
package auth
import (
"context"
"encoding/json"
"strconv"
"github.com/pocketbase/pocketbase/tools/types"
"golang.org/x/oauth2"
"golang.org/x/oauth2/kakao"
)
func init() {
Providers[NameKakao] = wrapFactory(NewKakaoProvider)
}
var _ Provider = (*Kakao)(nil)
// NameKakao is the unique name of the Kakao provider.
const NameKakao string = "kakao"
// Kakao allows authentication via Kakao OAuth2.
type Kakao struct {
BaseProvider
}
// NewKakaoProvider creates a new Kakao provider instance with some defaults.
func NewKakaoProvider() *Kakao {
return &Kakao{BaseProvider{
ctx: context.Background(),
displayName: "Kakao",
pkce: true,
scopes: []string{"account_email", "profile_nickname", "profile_image"},
authURL: kakao.Endpoint.AuthURL,
tokenURL: kakao.Endpoint.TokenURL,
userInfoURL: "https://kapi.kakao.com/v2/user/me",
}}
}
// FetchAuthUser returns an AuthUser instance based on the Kakao's user api.
//
// API reference: https://developers.kakao.com/docs/latest/en/kakaologin/rest-api#req-user-info-response
func (p *Kakao) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
data, err := p.FetchRawUserInfo(token)
if err != nil {
return nil, err
}
rawUser := map[string]any{}
if err := json.Unmarshal(data, &rawUser); err != nil {
return nil, err
}
extracted := struct {
Profile struct {
Nickname string `json:"nickname"`
ImageURL string `json:"profile_image"`
} `json:"properties"`
KakaoAccount struct {
Email string `json:"email"`
IsEmailVerified bool `json:"is_email_verified"`
IsEmailValid bool `json:"is_email_valid"`
} `json:"kakao_account"`
Id int64 `json:"id"`
}{}
if err := json.Unmarshal(data, &extracted); err != nil {
return nil, err
}
user := &AuthUser{
Id: strconv.FormatInt(extracted.Id, 10),
Username: extracted.Profile.Nickname,
AvatarURL: extracted.Profile.ImageURL,
RawUser: rawUser,
AccessToken: token.AccessToken,
RefreshToken: token.RefreshToken,
}
user.Expiry, _ = types.ParseDateTime(token.Expiry)
if extracted.KakaoAccount.IsEmailValid && extracted.KakaoAccount.IsEmailVerified {
user.Email = extracted.KakaoAccount.Email
}
return user, nil
}

109
tools/auth/linear.go Normal file
View file

@ -0,0 +1,109 @@
package auth
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"github.com/pocketbase/pocketbase/tools/types"
"golang.org/x/oauth2"
)
func init() {
Providers[NameLinear] = wrapFactory(NewLinearProvider)
}
var _ Provider = (*Linear)(nil)
// NameLinear is the unique name of the Linear provider.
const NameLinear string = "linear"
// Linear allows authentication via Linear OAuth2.
type Linear struct {
BaseProvider
}
// NewLinearProvider creates new Linear provider instance with some defaults.
//
// API reference: https://developers.linear.app/docs/oauth/authentication
func NewLinearProvider() *Linear {
return &Linear{BaseProvider{
ctx: context.Background(),
displayName: "Linear",
pkce: false, // Linear doesn't support PKCE at the moment and returns an error if enabled
scopes: []string{"read"},
authURL: "https://linear.app/oauth/authorize",
tokenURL: "https://api.linear.app/oauth/token",
userInfoURL: "https://api.linear.app/graphql",
}}
}
// FetchAuthUser returns an AuthUser instance based on the Linear's user api.
//
// API reference: https://developers.linear.app/docs/graphql/working-with-the-graphql-api#authentication
func (p *Linear) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
data, err := p.FetchRawUserInfo(token)
if err != nil {
return nil, err
}
rawUser := map[string]any{}
if err := json.Unmarshal(data, &rawUser); err != nil {
return nil, err
}
extracted := struct {
Data struct {
Viewer struct {
Id string `json:"id"`
DisplayName string `json:"displayName"`
Name string `json:"name"`
Email string `json:"email"`
AvatarURL string `json:"avatarUrl"`
Active bool `json:"active"`
} `json:"viewer"`
} `json:"data"`
}{}
if err := json.Unmarshal(data, &extracted); err != nil {
return nil, err
}
if !extracted.Data.Viewer.Active {
return nil, errors.New("the Linear user account is not active")
}
user := &AuthUser{
Id: extracted.Data.Viewer.Id,
Name: extracted.Data.Viewer.Name,
Username: extracted.Data.Viewer.DisplayName,
Email: extracted.Data.Viewer.Email,
AvatarURL: extracted.Data.Viewer.AvatarURL,
RawUser: rawUser,
AccessToken: token.AccessToken,
RefreshToken: token.RefreshToken,
}
user.Expiry, _ = types.ParseDateTime(token.Expiry)
return user, nil
}
// FetchRawUserInfo implements Provider.FetchRawUserInfo interface method.
//
// Linear doesn't have a UserInfo endpoint and information on the user
// is retrieved using their GraphQL API (https://developers.linear.app/docs/graphql/working-with-the-graphql-api#queries-and-mutations)
func (p *Linear) FetchRawUserInfo(token *oauth2.Token) ([]byte, error) {
query := []byte(`{"query": "query Me { viewer { id displayName name email avatarUrl active } }"}`)
bodyReader := bytes.NewReader(query)
req, err := http.NewRequestWithContext(p.ctx, "POST", p.userInfoURL, bodyReader)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
return p.sendRawUserInfoRequest(req, token)
}

79
tools/auth/livechat.go Normal file
View file

@ -0,0 +1,79 @@
package auth
import (
"context"
"encoding/json"
"github.com/pocketbase/pocketbase/tools/types"
"golang.org/x/oauth2"
)
func init() {
Providers[NameLivechat] = wrapFactory(NewLivechatProvider)
}
var _ Provider = (*Livechat)(nil)
// NameLivechat is the unique name of the Livechat provider.
const NameLivechat = "livechat"
// Livechat allows authentication via Livechat OAuth2.
type Livechat struct {
BaseProvider
}
// NewLivechatProvider creates new Livechat provider instance with some defaults.
func NewLivechatProvider() *Livechat {
return &Livechat{BaseProvider{
ctx: context.Background(),
displayName: "LiveChat",
pkce: true,
scopes: []string{}, // default scopes are specified from the provider dashboard
authURL: "https://accounts.livechat.com/",
tokenURL: "https://accounts.livechat.com/token",
userInfoURL: "https://accounts.livechat.com/v2/accounts/me",
}}
}
// FetchAuthUser returns an AuthUser based on the Livechat accounts API.
//
// API reference: https://developers.livechat.com/docs/authorization
func (p *Livechat) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
data, err := p.FetchRawUserInfo(token)
if err != nil {
return nil, err
}
rawUser := map[string]any{}
if err := json.Unmarshal(data, &rawUser); err != nil {
return nil, err
}
extracted := struct {
Id string `json:"account_id"`
Name string `json:"name"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
AvatarURL string `json:"avatar_url"`
}{}
if err := json.Unmarshal(data, &extracted); err != nil {
return nil, err
}
user := &AuthUser{
Id: extracted.Id,
Name: extracted.Name,
AvatarURL: extracted.AvatarURL,
RawUser: rawUser,
AccessToken: token.AccessToken,
RefreshToken: token.RefreshToken,
}
user.Expiry, _ = types.ParseDateTime(token.Expiry)
if extracted.EmailVerified {
user.Email = extracted.Email
}
return user, nil
}

84
tools/auth/mailcow.go Normal file
View file

@ -0,0 +1,84 @@
package auth
import (
"context"
"encoding/json"
"errors"
"strings"
"github.com/pocketbase/pocketbase/tools/types"
"golang.org/x/oauth2"
)
func init() {
Providers[NameMailcow] = wrapFactory(NewMailcowProvider)
}
var _ Provider = (*Mailcow)(nil)
// NameMailcow is the unique name of the mailcow provider.
const NameMailcow string = "mailcow"
// Mailcow allows authentication via mailcow OAuth2.
type Mailcow struct {
BaseProvider
}
// NewMailcowProvider creates a new mailcow provider instance with some defaults.
func NewMailcowProvider() *Mailcow {
return &Mailcow{BaseProvider{
ctx: context.Background(),
displayName: "mailcow",
pkce: true,
scopes: []string{"profile"},
}}
}
// FetchAuthUser returns an AuthUser instance based on mailcow's user api.
//
// API reference: https://github.com/mailcow/mailcow-dockerized/blob/master/data/web/oauth/profile.php
func (p *Mailcow) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
data, err := p.FetchRawUserInfo(token)
if err != nil {
return nil, err
}
rawUser := map[string]any{}
if err := json.Unmarshal(data, &rawUser); err != nil {
return nil, err
}
extracted := struct {
Id string `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
FullName string `json:"full_name"`
Active int `json:"active"`
}{}
if err := json.Unmarshal(data, &extracted); err != nil {
return nil, err
}
if extracted.Active != 1 {
return nil, errors.New("the mailcow user is not active")
}
user := &AuthUser{
Id: extracted.Id,
Name: extracted.FullName,
Username: extracted.Username,
Email: extracted.Email,
RawUser: rawUser,
AccessToken: token.AccessToken,
RefreshToken: token.RefreshToken,
}
user.Expiry, _ = types.ParseDateTime(token.Expiry)
// mailcow usernames are usually just the email adresses, so we just take the part in front of the @
if strings.Contains(user.Username, "@") {
user.Username = strings.Split(user.Username, "@")[0]
}
return user, nil
}

76
tools/auth/microsoft.go Normal file
View file

@ -0,0 +1,76 @@
package auth
import (
"context"
"encoding/json"
"github.com/pocketbase/pocketbase/tools/types"
"golang.org/x/oauth2"
"golang.org/x/oauth2/microsoft"
)
func init() {
Providers[NameMicrosoft] = wrapFactory(NewMicrosoftProvider)
}
var _ Provider = (*Microsoft)(nil)
// NameMicrosoft is the unique name of the Microsoft provider.
const NameMicrosoft string = "microsoft"
// Microsoft allows authentication via AzureADEndpoint OAuth2.
type Microsoft struct {
BaseProvider
}
// NewMicrosoftProvider creates new Microsoft AD provider instance with some defaults.
func NewMicrosoftProvider() *Microsoft {
endpoints := microsoft.AzureADEndpoint("")
return &Microsoft{BaseProvider{
ctx: context.Background(),
displayName: "Microsoft",
pkce: true,
scopes: []string{"User.Read"},
authURL: endpoints.AuthURL,
tokenURL: endpoints.TokenURL,
userInfoURL: "https://graph.microsoft.com/v1.0/me",
}}
}
// FetchAuthUser returns an AuthUser instance based on the Microsoft's user api.
//
// API reference: https://learn.microsoft.com/en-us/azure/active-directory/develop/userinfo
// Graph explorer: https://developer.microsoft.com/en-us/graph/graph-explorer
func (p *Microsoft) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
data, err := p.FetchRawUserInfo(token)
if err != nil {
return nil, err
}
rawUser := map[string]any{}
if err := json.Unmarshal(data, &rawUser); err != nil {
return nil, err
}
extracted := struct {
Id string `json:"id"`
Name string `json:"displayName"`
Email string `json:"mail"`
}{}
if err := json.Unmarshal(data, &extracted); err != nil {
return nil, err
}
user := &AuthUser{
Id: extracted.Id,
Name: extracted.Name,
Email: extracted.Email,
RawUser: rawUser,
AccessToken: token.AccessToken,
RefreshToken: token.RefreshToken,
}
user.Expiry, _ = types.ParseDateTime(token.Expiry)
return user, nil
}

108
tools/auth/monday.go Normal file
View file

@ -0,0 +1,108 @@
package auth
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"github.com/pocketbase/pocketbase/tools/types"
"golang.org/x/oauth2"
)
func init() {
Providers[NameMonday] = wrapFactory(NewMondayProvider)
}
var _ Provider = (*Monday)(nil)
// NameMonday is the unique name of the Monday provider.
const NameMonday = "monday"
// Monday is an auth provider for monday.com.
type Monday struct {
BaseProvider
}
// NewMondayProvider creates a new Monday provider instance with some defaults.
func NewMondayProvider() *Monday {
return &Monday{BaseProvider{
ctx: context.Background(),
displayName: "monday.com",
pkce: true,
scopes: []string{"me:read"},
authURL: "https://auth.monday.com/oauth2/authorize",
tokenURL: "https://auth.monday.com/oauth2/token",
userInfoURL: "https://api.monday.com/v2",
}}
}
// FetchAuthUser returns an AuthUser instance based on the Monday's user api.
//
// API reference: https://developer.monday.com/api-reference/reference/me
func (p *Monday) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
data, err := p.FetchRawUserInfo(token)
if err != nil {
return nil, err
}
rawUser := map[string]any{}
if err := json.Unmarshal(data, &rawUser); err != nil {
return nil, err
}
extracted := struct {
Data struct {
Me struct {
Id string `json:"id"`
Enabled bool `json:"enabled"`
Name string `json:"name"`
Email string `json:"email"`
IsVerified bool `json:"is_verified"`
Avatar string `json:"photo_small"`
} `json:"me"`
} `json:"data"`
}{}
if err := json.Unmarshal(data, &extracted); err != nil {
return nil, err
}
if !extracted.Data.Me.Enabled {
return nil, errors.New("the monday.com user account is not enabled")
}
user := &AuthUser{
Id: extracted.Data.Me.Id,
Name: extracted.Data.Me.Name,
AvatarURL: extracted.Data.Me.Avatar,
RawUser: rawUser,
AccessToken: token.AccessToken,
RefreshToken: token.RefreshToken,
}
if extracted.Data.Me.IsVerified {
user.Email = extracted.Data.Me.Email
}
user.Expiry, _ = types.ParseDateTime(token.Expiry)
return user, nil
}
// FetchRawUserInfo implements Provider.FetchRawUserInfo interface.
//
// monday.com doesn't have a UserInfo endpoint and information on the user
// is retrieved using their GraphQL API (https://developer.monday.com/api-reference/reference/me#queries)
func (p *Monday) FetchRawUserInfo(token *oauth2.Token) ([]byte, error) {
query := []byte(`{"query": "query { me { id enabled name email is_verified photo_small }}"}`)
bodyReader := bytes.NewReader(query)
req, err := http.NewRequestWithContext(p.ctx, "POST", p.userInfoURL, bodyReader)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
return p.sendRawUserInfoRequest(req, token)
}

106
tools/auth/notion.go Normal file
View file

@ -0,0 +1,106 @@
package auth
import (
"context"
"encoding/json"
"net/http"
"github.com/pocketbase/pocketbase/tools/types"
"golang.org/x/oauth2"
)
func init() {
Providers[NameNotion] = wrapFactory(NewNotionProvider)
}
var _ Provider = (*Notion)(nil)
// NameNotion is the unique name of the Notion provider.
const NameNotion string = "notion"
// Notion allows authentication via Notion OAuth2.
type Notion struct {
BaseProvider
}
// NewNotionProvider creates new Notion provider instance with some defaults.
func NewNotionProvider() *Notion {
return &Notion{BaseProvider{
ctx: context.Background(),
displayName: "Notion",
pkce: true,
authURL: "https://api.notion.com/v1/oauth/authorize",
tokenURL: "https://api.notion.com/v1/oauth/token",
userInfoURL: "https://api.notion.com/v1/users/me",
}}
}
// FetchAuthUser returns an AuthUser instance based on the Notion's User api.
// API reference: https://developers.notion.com/reference/get-self
func (p *Notion) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
data, err := p.FetchRawUserInfo(token)
if err != nil {
return nil, err
}
rawUser := map[string]any{}
if err := json.Unmarshal(data, &rawUser); err != nil {
return nil, err
}
extracted := struct {
AvatarURL string `json:"avatar_url"`
Bot struct {
Owner struct {
Type string `json:"type"`
User struct {
AvatarURL string `json:"avatar_url"`
Id string `json:"id"`
Name string `json:"name"`
Person struct {
Email string `json:"email"`
} `json:"person"`
} `json:"user"`
} `json:"owner"`
WorkspaceName string `json:"workspace_name"`
} `json:"bot"`
Id string `json:"id"`
Name string `json:"name"`
Object string `json:"object"`
RequestId string `json:"request_id"`
Type string `json:"type"`
}{}
if err := json.Unmarshal(data, &extracted); err != nil {
return nil, err
}
user := &AuthUser{
Id: extracted.Bot.Owner.User.Id,
Name: extracted.Bot.Owner.User.Name,
Email: extracted.Bot.Owner.User.Person.Email,
AvatarURL: extracted.Bot.Owner.User.AvatarURL,
RawUser: rawUser,
AccessToken: token.AccessToken,
RefreshToken: token.RefreshToken,
}
user.Expiry, _ = types.ParseDateTime(token.Expiry)
return user, nil
}
// FetchRawUserInfo implements Provider.FetchRawUserInfo interface method.
//
// This differ from BaseProvider because Notion requires a version header for all requests
// (https://developers.notion.com/reference/versioning).
func (p *Notion) FetchRawUserInfo(token *oauth2.Token) ([]byte, error) {
req, err := http.NewRequestWithContext(p.ctx, "GET", p.userInfoURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Notion-Version", "2022-06-28")
return p.sendRawUserInfoRequest(req, token)
}

292
tools/auth/oidc.go Normal file
View file

@ -0,0 +1,292 @@
package auth
import (
"context"
"crypto/rsa"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"math/big"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/pocketbase/pocketbase/tools/types"
"github.com/spf13/cast"
"golang.org/x/oauth2"
)
// idTokenLeeway is the optional leeway for the id_token timestamp fields validation.
//
// It can be changed externally using the PB_ID_TOKEN_LEEWAY env variable
// (the value must be in seconds, e.g. "PB_ID_TOKEN_LEEWAY=60" for 1 minute).
var idTokenLeeway time.Duration = 5 * time.Minute
func init() {
Providers[NameOIDC] = wrapFactory(NewOIDCProvider)
Providers[NameOIDC+"2"] = wrapFactory(NewOIDCProvider)
Providers[NameOIDC+"3"] = wrapFactory(NewOIDCProvider)
if leewayStr := os.Getenv("PB_ID_TOKEN_LEEWAY"); leewayStr != "" {
leeway, err := strconv.Atoi(leewayStr)
if err == nil {
idTokenLeeway = time.Duration(leeway) * time.Second
}
}
}
var _ Provider = (*OIDC)(nil)
// NameOIDC is the unique name of the OpenID Connect (OIDC) provider.
const NameOIDC string = "oidc"
// OIDC allows authentication via OpenID Connect (OIDC) OAuth2 provider.
//
// If specified the user data is fetched from the userInfoURL.
// Otherwise - from the id_token payload.
//
// The provider support the following Extra config options:
// - "jwksURL" - url to the keys to validate the id_token signature (optional and used only when reading the user data from the id_token)
// - "issuers" - list of valid issuers for the iss id_token claim (optioanl and used only when reading the user data from the id_token)
type OIDC struct {
BaseProvider
}
// NewOIDCProvider creates new OpenID Connect (OIDC) provider instance with some defaults.
func NewOIDCProvider() *OIDC {
return &OIDC{BaseProvider{
ctx: context.Background(),
displayName: "OIDC",
pkce: true,
scopes: []string{
"openid", // minimal requirement to return the id
"email",
"profile",
},
}}
}
// FetchAuthUser returns an AuthUser instance based the provider's user api.
//
// API reference: https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims
func (p *OIDC) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
data, err := p.FetchRawUserInfo(token)
if err != nil {
return nil, err
}
rawUser := map[string]any{}
if err := json.Unmarshal(data, &rawUser); err != nil {
return nil, err
}
extracted := struct {
Id string `json:"sub"`
Name string `json:"name"`
Username string `json:"preferred_username"`
Picture string `json:"picture"`
Email string `json:"email"`
EmailVerified any `json:"email_verified"` // see #6657
}{}
if err := json.Unmarshal(data, &extracted); err != nil {
return nil, err
}
user := &AuthUser{
Id: extracted.Id,
Name: extracted.Name,
Username: extracted.Username,
AvatarURL: extracted.Picture,
RawUser: rawUser,
AccessToken: token.AccessToken,
RefreshToken: token.RefreshToken,
}
user.Expiry, _ = types.ParseDateTime(token.Expiry)
if cast.ToBool(extracted.EmailVerified) {
user.Email = extracted.Email
}
return user, nil
}
// FetchRawUserInfo implements Provider.FetchRawUserInfo interface method.
//
// It either fetch the data from p.userInfoURL, or if not set - returns the id_token claims.
func (p *OIDC) FetchRawUserInfo(token *oauth2.Token) ([]byte, error) {
if p.userInfoURL != "" {
return p.BaseProvider.FetchRawUserInfo(token)
}
claims, err := p.parseIdToken(token)
if err != nil {
return nil, err
}
return json.Marshal(claims)
}
func (p *OIDC) parseIdToken(token *oauth2.Token) (jwt.MapClaims, error) {
idToken := token.Extra("id_token").(string)
if idToken == "" {
return nil, errors.New("empty id_token")
}
claims := jwt.MapClaims{}
t, _, err := jwt.NewParser().ParseUnverified(idToken, claims)
if err != nil {
return nil, err
}
// validate common claims
jwtValidator := jwt.NewValidator(
jwt.WithIssuedAt(),
jwt.WithLeeway(idTokenLeeway),
jwt.WithAudience(p.clientId),
)
err = jwtValidator.Validate(claims)
if err != nil {
return nil, err
}
// validate iss (if "issuers" extra config is set)
issuers := cast.ToStringSlice(p.Extra()["issuers"])
if len(issuers) > 0 {
var isIssValid bool
claimIssuer, _ := claims.GetIssuer()
for _, issuer := range issuers {
if security.Equal(claimIssuer, issuer) {
isIssValid = true
break
}
}
if !isIssValid {
return nil, fmt.Errorf("iss must be one of %v, got %#v", issuers, claims["iss"])
}
}
// validate signature (if "jwksURL" extra config is set)
//
// note: this step could be technically considered optional because we trust
// the token which is a result of direct TLS communication with the provider
// (see also https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation)
jwksURL := cast.ToString(p.Extra()["jwksURL"])
if jwksURL != "" {
kid, _ := t.Header["kid"].(string)
err = validateIdTokenSignature(p.ctx, idToken, jwksURL, kid)
if err != nil {
return nil, err
}
}
return claims, nil
}
func validateIdTokenSignature(ctx context.Context, idToken string, jwksURL string, kid string) error {
// fetch the public key set
// ---
if kid == "" {
return errors.New("missing kid header value")
}
key, err := fetchJWK(ctx, jwksURL, kid)
if err != nil {
return err
}
// decode the key params per RFC 7518 (https://tools.ietf.org/html/rfc7518#section-6.3)
// and construct a valid publicKey from them
// ---
exponent, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(key.E, "="))
if err != nil {
return err
}
modulus, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(key.N, "="))
if err != nil {
return err
}
publicKey := &rsa.PublicKey{
// https://tools.ietf.org/html/rfc7517#appendix-A.1
E: int(big.NewInt(0).SetBytes(exponent).Uint64()),
N: big.NewInt(0).SetBytes(modulus),
}
// verify the signiture
// ---
parser := jwt.NewParser(jwt.WithValidMethods([]string{key.Alg}))
parsedToken, err := parser.Parse(idToken, func(t *jwt.Token) (any, error) {
return publicKey, nil
})
if err != nil {
return err
}
if !parsedToken.Valid {
return errors.New("the parsed id_token is invalid")
}
return nil
}
type jwk struct {
Kty string
Kid string
Use string
Alg string
N string
E string
}
func fetchJWK(ctx context.Context, jwksURL string, kid string) (*jwk, error) {
req, err := http.NewRequestWithContext(ctx, "GET", jwksURL, nil)
if err != nil {
return nil, err
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
rawBody, err := io.ReadAll(res.Body)
if err != nil {
return nil, err
}
// http.Client.Get doesn't treat non 2xx responses as error
if res.StatusCode >= 400 {
return nil, fmt.Errorf(
"failed to verify the provided id_token (%d):\n%s",
res.StatusCode,
string(rawBody),
)
}
jwks := struct {
Keys []*jwk
}{}
if err := json.Unmarshal(rawBody, &jwks); err != nil {
return nil, err
}
for _, key := range jwks.Keys {
if key.Kid == kid {
return key, nil
}
}
return nil, fmt.Errorf("jwk with kid %q was not found", kid)
}

88
tools/auth/patreon.go Normal file
View file

@ -0,0 +1,88 @@
package auth
import (
"context"
"encoding/json"
"github.com/pocketbase/pocketbase/tools/types"
"golang.org/x/oauth2"
"golang.org/x/oauth2/endpoints"
)
func init() {
Providers[NamePatreon] = wrapFactory(NewPatreonProvider)
}
var _ Provider = (*Patreon)(nil)
// NamePatreon is the unique name of the Patreon provider.
const NamePatreon string = "patreon"
// Patreon allows authentication via Patreon OAuth2.
type Patreon struct {
BaseProvider
}
// NewPatreonProvider creates new Patreon provider instance with some defaults.
func NewPatreonProvider() *Patreon {
return &Patreon{BaseProvider{
ctx: context.Background(),
displayName: "Patreon",
pkce: true,
scopes: []string{"identity", "identity[email]"},
authURL: endpoints.Patreon.AuthURL,
tokenURL: endpoints.Patreon.TokenURL,
userInfoURL: "https://www.patreon.com/api/oauth2/v2/identity?fields%5Buser%5D=full_name,email,vanity,image_url,is_email_verified",
}}
}
// FetchAuthUser returns an AuthUser instance based on the Patreons's identity api.
//
// API reference:
// https://docs.patreon.com/#get-api-oauth2-v2-identity
// https://docs.patreon.com/#user-v2
func (p *Patreon) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
data, err := p.FetchRawUserInfo(token)
if err != nil {
return nil, err
}
rawUser := map[string]any{}
if err := json.Unmarshal(data, &rawUser); err != nil {
return nil, err
}
extracted := struct {
Data struct {
Id string `json:"id"`
Attributes struct {
Email string `json:"email"`
Name string `json:"full_name"`
Username string `json:"vanity"`
AvatarURL string `json:"image_url"`
IsEmailVerified bool `json:"is_email_verified"`
} `json:"attributes"`
} `json:"data"`
}{}
if err := json.Unmarshal(data, &extracted); err != nil {
return nil, err
}
user := &AuthUser{
Id: extracted.Data.Id,
Username: extracted.Data.Attributes.Username,
Name: extracted.Data.Attributes.Name,
AvatarURL: extracted.Data.Attributes.AvatarURL,
RawUser: rawUser,
AccessToken: token.AccessToken,
RefreshToken: token.RefreshToken,
}
user.Expiry, _ = types.ParseDateTime(token.Expiry)
if extracted.Data.Attributes.IsEmailVerified {
user.Email = extracted.Data.Attributes.Email
}
return user, nil
}

View file

@ -0,0 +1,85 @@
package auth
import (
"context"
"encoding/json"
"errors"
"github.com/pocketbase/pocketbase/tools/types"
"golang.org/x/oauth2"
)
func init() {
Providers[NamePlanningcenter] = wrapFactory(NewPlanningcenterProvider)
}
var _ Provider = (*Planningcenter)(nil)
// NamePlanningcenter is the unique name of the Planningcenter provider.
const NamePlanningcenter string = "planningcenter"
// Planningcenter allows authentication via Planningcenter OAuth2.
type Planningcenter struct {
BaseProvider
}
// NewPlanningcenterProvider creates a new Planningcenter provider instance with some defaults.
func NewPlanningcenterProvider() *Planningcenter {
return &Planningcenter{BaseProvider{
ctx: context.Background(),
displayName: "Planning Center",
pkce: true,
scopes: []string{"people"},
authURL: "https://api.planningcenteronline.com/oauth/authorize",
tokenURL: "https://api.planningcenteronline.com/oauth/token",
userInfoURL: "https://api.planningcenteronline.com/people/v2/me",
}}
}
// FetchAuthUser returns an AuthUser instance based on the Planningcenter's user api.
//
// API reference: https://developer.planning.center/docs/#/overview/authentication
func (p *Planningcenter) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
data, err := p.FetchRawUserInfo(token)
if err != nil {
return nil, err
}
rawUser := map[string]any{}
if err := json.Unmarshal(data, &rawUser); err != nil {
return nil, err
}
extracted := struct {
Data struct {
Id string `json:"id"`
Attributes struct {
Status string `json:"status"`
Name string `json:"name"`
AvatarURL string `json:"avatar"`
// don't map the email because users can have multiple assigned
// and it's not clear if they are verified
}
}
}{}
if err := json.Unmarshal(data, &extracted); err != nil {
return nil, err
}
if extracted.Data.Attributes.Status != "active" {
return nil, errors.New("the user is not active")
}
user := &AuthUser{
Id: extracted.Data.Id,
Name: extracted.Data.Attributes.Name,
AvatarURL: extracted.Data.Attributes.AvatarURL,
RawUser: rawUser,
AccessToken: token.AccessToken,
RefreshToken: token.RefreshToken,
}
user.Expiry, _ = types.ParseDateTime(token.Expiry)
return user, nil
}

87
tools/auth/spotify.go Normal file
View file

@ -0,0 +1,87 @@
package auth
import (
"context"
"encoding/json"
"github.com/pocketbase/pocketbase/tools/types"
"golang.org/x/oauth2"
"golang.org/x/oauth2/spotify"
)
func init() {
Providers[NameSpotify] = wrapFactory(NewSpotifyProvider)
}
var _ Provider = (*Spotify)(nil)
// NameSpotify is the unique name of the Spotify provider.
const NameSpotify string = "spotify"
// Spotify allows authentication via Spotify OAuth2.
type Spotify struct {
BaseProvider
}
// NewSpotifyProvider creates a new Spotify provider instance with some defaults.
func NewSpotifyProvider() *Spotify {
return &Spotify{BaseProvider{
ctx: context.Background(),
displayName: "Spotify",
pkce: true,
scopes: []string{
"user-read-private",
// currently Spotify doesn't return information whether the email is verified or not
// "user-read-email",
},
authURL: spotify.Endpoint.AuthURL,
tokenURL: spotify.Endpoint.TokenURL,
userInfoURL: "https://api.spotify.com/v1/me",
}}
}
// FetchAuthUser returns an AuthUser instance based on the Spotify's user api.
//
// API reference: https://developer.spotify.com/documentation/web-api/reference/#/operations/get-current-users-profile
func (p *Spotify) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
data, err := p.FetchRawUserInfo(token)
if err != nil {
return nil, err
}
rawUser := map[string]any{}
if err := json.Unmarshal(data, &rawUser); err != nil {
return nil, err
}
extracted := struct {
Id string `json:"id"`
Name string `json:"display_name"`
Images []struct {
URL string `json:"url"`
} `json:"images"`
// don't map the email because per the official docs
// the email field is "unverified" and there is no proof
// that it actually belongs to the user
// Email string `json:"email"`
}{}
if err := json.Unmarshal(data, &extracted); err != nil {
return nil, err
}
user := &AuthUser{
Id: extracted.Id,
Name: extracted.Name,
RawUser: rawUser,
AccessToken: token.AccessToken,
RefreshToken: token.RefreshToken,
}
user.Expiry, _ = types.ParseDateTime(token.Expiry)
if len(extracted.Images) > 0 {
user.AvatarURL = extracted.Images[0].URL
}
return user, nil
}

85
tools/auth/strava.go Normal file
View file

@ -0,0 +1,85 @@
package auth
import (
"context"
"encoding/json"
"strconv"
"github.com/pocketbase/pocketbase/tools/types"
"golang.org/x/oauth2"
)
func init() {
Providers[NameStrava] = wrapFactory(NewStravaProvider)
}
var _ Provider = (*Strava)(nil)
// NameStrava is the unique name of the Strava provider.
const NameStrava string = "strava"
// Strava allows authentication via Strava OAuth2.
type Strava struct {
BaseProvider
}
// NewStravaProvider creates new Strava provider instance with some defaults.
func NewStravaProvider() *Strava {
return &Strava{BaseProvider{
ctx: context.Background(),
displayName: "Strava",
pkce: true,
scopes: []string{
"profile:read_all",
},
authURL: "https://www.strava.com/oauth/authorize",
tokenURL: "https://www.strava.com/api/v3/oauth/token",
userInfoURL: "https://www.strava.com/api/v3/athlete",
}}
}
// FetchAuthUser returns an AuthUser instance based on the Strava's user api.
//
// API reference: https://developers.strava.com/docs/authentication/
func (p *Strava) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
data, err := p.FetchRawUserInfo(token)
if err != nil {
return nil, err
}
rawUser := map[string]any{}
if err := json.Unmarshal(data, &rawUser); err != nil {
return nil, err
}
extracted := struct {
Id int64 `json:"id"`
FirstName string `json:"firstname"`
LastName string `json:"lastname"`
Username string `json:"username"`
ProfileImageURL string `json:"profile"`
// At the time of writing, Strava OAuth2 doesn't support returning the user email address
// Email string `json:"email"`
}{}
if err := json.Unmarshal(data, &extracted); err != nil {
return nil, err
}
user := &AuthUser{
Name: extracted.FirstName + " " + extracted.LastName,
Username: extracted.Username,
AvatarURL: extracted.ProfileImageURL,
RawUser: rawUser,
AccessToken: token.AccessToken,
RefreshToken: token.RefreshToken,
}
user.Expiry, _ = types.ParseDateTime(token.Expiry)
if extracted.Id != 0 {
user.Id = strconv.FormatInt(extracted.Id, 10)
}
return user, nil
}

102
tools/auth/trakt.go Normal file
View file

@ -0,0 +1,102 @@
package auth
import (
"context"
"encoding/json"
"net/http"
"github.com/pocketbase/pocketbase/tools/types"
"golang.org/x/oauth2"
)
func init() {
Providers[NameTrakt] = wrapFactory(NewTraktProvider)
}
var _ Provider = (*Trakt)(nil)
// NameTrakt is the unique name of the Trakt provider.
const NameTrakt string = "trakt"
// Trakt allows authentication via Trakt OAuth2.
type Trakt struct {
BaseProvider
}
// NewTraktProvider creates new Trakt provider instance with some defaults.
func NewTraktProvider() *Trakt {
return &Trakt{BaseProvider{
ctx: context.Background(),
displayName: "Trakt",
pkce: true,
authURL: "https://trakt.tv/oauth/authorize",
tokenURL: "https://api.trakt.tv/oauth/token",
userInfoURL: "https://api.trakt.tv/users/settings",
}}
}
// FetchAuthUser returns an AuthUser instance based on Trakt's user settings API.
// API reference: https://trakt.docs.apiary.io/#reference/users/settings/retrieve-settings
func (p *Trakt) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
data, err := p.FetchRawUserInfo(token)
if err != nil {
return nil, err
}
rawUser := map[string]any{}
if err := json.Unmarshal(data, &rawUser); err != nil {
return nil, err
}
extracted := struct {
User struct {
Username string `json:"username"`
Name string `json:"name"`
Ids struct {
Slug string `json:"slug"`
UUID string `json:"uuid"`
} `json:"ids"`
Images struct {
Avatar struct {
Full string `json:"full"`
} `json:"avatar"`
} `json:"images"`
} `json:"user"`
}{}
if err := json.Unmarshal(data, &extracted); err != nil {
return nil, err
}
user := &AuthUser{
Id: extracted.User.Ids.UUID,
Username: extracted.User.Username,
Name: extracted.User.Name,
AvatarURL: extracted.User.Images.Avatar.Full,
RawUser: rawUser,
AccessToken: token.AccessToken,
RefreshToken: token.RefreshToken,
}
user.Expiry, _ = types.ParseDateTime(token.Expiry)
return user, nil
}
// FetchRawUserInfo implements Provider.FetchRawUserInfo interface method.
//
// This differ from BaseProvider because Trakt requires a number of
// mandatory headers for all requests
// (https://trakt.docs.apiary.io/#introduction/required-headers).
func (p *Trakt) FetchRawUserInfo(token *oauth2.Token) ([]byte, error) {
req, err := http.NewRequestWithContext(p.ctx, "GET", p.userInfoURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Content-type", "application/json")
req.Header.Set("trakt-api-key", p.clientId)
req.Header.Set("trakt-api-version", "2")
return p.sendRawUserInfoRequest(req, token)
}

100
tools/auth/twitch.go Normal file
View file

@ -0,0 +1,100 @@
package auth
import (
"context"
"encoding/json"
"errors"
"net/http"
"github.com/pocketbase/pocketbase/tools/types"
"golang.org/x/oauth2"
"golang.org/x/oauth2/twitch"
)
func init() {
Providers[NameTwitch] = wrapFactory(NewTwitchProvider)
}
var _ Provider = (*Twitch)(nil)
// NameTwitch is the unique name of the Twitch provider.
const NameTwitch string = "twitch"
// Twitch allows authentication via Twitch OAuth2.
type Twitch struct {
BaseProvider
}
// NewTwitchProvider creates new Twitch provider instance with some defaults.
func NewTwitchProvider() *Twitch {
return &Twitch{BaseProvider{
ctx: context.Background(),
displayName: "Twitch",
pkce: true,
scopes: []string{"user:read:email"},
authURL: twitch.Endpoint.AuthURL,
tokenURL: twitch.Endpoint.TokenURL,
userInfoURL: "https://api.twitch.tv/helix/users",
}}
}
// FetchAuthUser returns an AuthUser instance based the Twitch's user api.
//
// API reference: https://dev.twitch.tv/docs/api/reference#get-users
func (p *Twitch) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
data, err := p.FetchRawUserInfo(token)
if err != nil {
return nil, err
}
rawUser := map[string]any{}
if err := json.Unmarshal(data, &rawUser); err != nil {
return nil, err
}
extracted := struct {
Data []struct {
Id string `json:"id"`
Login string `json:"login"`
DisplayName string `json:"display_name"`
Email string `json:"email"`
ProfileImageURL string `json:"profile_image_url"`
} `json:"data"`
}{}
if err := json.Unmarshal(data, &extracted); err != nil {
return nil, err
}
if len(extracted.Data) == 0 {
return nil, errors.New("failed to fetch AuthUser data")
}
user := &AuthUser{
Id: extracted.Data[0].Id,
Name: extracted.Data[0].DisplayName,
Username: extracted.Data[0].Login,
Email: extracted.Data[0].Email,
AvatarURL: extracted.Data[0].ProfileImageURL,
RawUser: rawUser,
AccessToken: token.AccessToken,
RefreshToken: token.RefreshToken,
}
user.Expiry, _ = types.ParseDateTime(token.Expiry)
return user, nil
}
// FetchRawUserInfo implements Provider.FetchRawUserInfo interface method.
//
// This differ from BaseProvider because Twitch requires the Client-Id header.
func (p *Twitch) FetchRawUserInfo(token *oauth2.Token) ([]byte, error) {
req, err := http.NewRequestWithContext(p.ctx, "GET", p.userInfoURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Client-Id", p.clientId)
return p.sendRawUserInfoRequest(req, token)
}

87
tools/auth/twitter.go Normal file
View file

@ -0,0 +1,87 @@
package auth
import (
"context"
"encoding/json"
"github.com/pocketbase/pocketbase/tools/types"
"golang.org/x/oauth2"
)
func init() {
Providers[NameTwitter] = wrapFactory(NewTwitterProvider)
}
var _ Provider = (*Twitter)(nil)
// NameTwitter is the unique name of the Twitter provider.
const NameTwitter string = "twitter"
// Twitter allows authentication via Twitter OAuth2.
type Twitter struct {
BaseProvider
}
// NewTwitterProvider creates new Twitter provider instance with some defaults.
func NewTwitterProvider() *Twitter {
return &Twitter{BaseProvider{
ctx: context.Background(),
displayName: "Twitter",
pkce: true,
scopes: []string{
"users.read",
// we don't actually use this scope, but for some reason it is required by the `/2/users/me` endpoint
// (see https://developer.twitter.com/en/docs/twitter-api/users/lookup/api-reference/get-users-me)
"tweet.read",
},
authURL: "https://twitter.com/i/oauth2/authorize",
tokenURL: "https://api.twitter.com/2/oauth2/token",
userInfoURL: "https://api.twitter.com/2/users/me?user.fields=id,name,username,profile_image_url",
}}
}
// FetchAuthUser returns an AuthUser instance based on the Twitter's user api.
//
// API reference: https://developer.twitter.com/en/docs/twitter-api/users/lookup/api-reference/get-users-me
func (p *Twitter) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
data, err := p.FetchRawUserInfo(token)
if err != nil {
return nil, err
}
rawUser := map[string]any{}
if err := json.Unmarshal(data, &rawUser); err != nil {
return nil, err
}
extracted := struct {
Data struct {
Id string `json:"id"`
Name string `json:"name"`
Username string `json:"username"`
ProfileImageURL string `json:"profile_image_url"`
// NB! At the time of writing, Twitter OAuth2 doesn't support returning the user email address
// (see https://twittercommunity.com/t/which-api-to-get-user-after-oauth2-authorization/162417/33)
// Email string `json:"email"`
} `json:"data"`
}{}
if err := json.Unmarshal(data, &extracted); err != nil {
return nil, err
}
user := &AuthUser{
Id: extracted.Data.Id,
Name: extracted.Data.Name,
Username: extracted.Data.Username,
AvatarURL: extracted.Data.ProfileImageURL,
RawUser: rawUser,
AccessToken: token.AccessToken,
RefreshToken: token.RefreshToken,
}
user.Expiry, _ = types.ParseDateTime(token.Expiry)
return user, nil
}

94
tools/auth/vk.go Normal file
View file

@ -0,0 +1,94 @@
package auth
import (
"context"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"github.com/pocketbase/pocketbase/tools/types"
"golang.org/x/oauth2"
"golang.org/x/oauth2/vk"
)
func init() {
Providers[NameVK] = wrapFactory(NewVKProvider)
}
var _ Provider = (*VK)(nil)
// NameVK is the unique name of the VK provider.
const NameVK string = "vk"
// VK allows authentication via VK OAuth2.
type VK struct {
BaseProvider
}
// NewVKProvider creates new VK provider instance with some defaults.
//
// Docs: https://dev.vk.com/api/oauth-parameters
func NewVKProvider() *VK {
return &VK{BaseProvider{
ctx: context.Background(),
displayName: "ВКонтакте",
pkce: false, // VK currently doesn't support PKCE and throws an error if PKCE params are send
scopes: []string{"email"},
authURL: vk.Endpoint.AuthURL,
tokenURL: vk.Endpoint.TokenURL,
userInfoURL: "https://api.vk.com/method/users.get?fields=photo_max,screen_name&v=5.131",
}}
}
// FetchAuthUser returns an AuthUser instance based on VK's user api.
//
// API reference: https://dev.vk.com/method/users.get
func (p *VK) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
data, err := p.FetchRawUserInfo(token)
if err != nil {
return nil, err
}
rawUser := map[string]any{}
if err := json.Unmarshal(data, &rawUser); err != nil {
return nil, err
}
extracted := struct {
Response []struct {
Id int64 `json:"id"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Username string `json:"screen_name"`
AvatarURL string `json:"photo_max"`
} `json:"response"`
}{}
if err := json.Unmarshal(data, &extracted); err != nil {
return nil, err
}
if len(extracted.Response) == 0 {
return nil, errors.New("missing response entry")
}
user := &AuthUser{
Id: strconv.FormatInt(extracted.Response[0].Id, 10),
Name: strings.TrimSpace(extracted.Response[0].FirstName + " " + extracted.Response[0].LastName),
Username: extracted.Response[0].Username,
AvatarURL: extracted.Response[0].AvatarURL,
RawUser: rawUser,
AccessToken: token.AccessToken,
RefreshToken: token.RefreshToken,
}
user.Expiry, _ = types.ParseDateTime(token.Expiry)
if email := token.Extra("email"); email != nil {
user.Email = fmt.Sprint(email)
}
return user, nil
}

90
tools/auth/wakatime.go Normal file
View file

@ -0,0 +1,90 @@
package auth
import (
"context"
"encoding/json"
"github.com/pocketbase/pocketbase/tools/types"
"golang.org/x/oauth2"
)
func init() {
Providers[NameWakatime] = wrapFactory(NewWakatimeProvider)
}
var _ Provider = (*Wakatime)(nil)
// NameWakatime is the unique name of the Wakatime provider.
const NameWakatime = "wakatime"
// Wakatime is an auth provider for Wakatime.
type Wakatime struct {
BaseProvider
}
// NewWakatimeProvider creates a new Wakatime provider instance with some defaults.
func NewWakatimeProvider() *Wakatime {
return &Wakatime{BaseProvider{
ctx: context.Background(),
displayName: "WakaTime",
pkce: true,
scopes: []string{"email"},
authURL: "https://wakatime.com/oauth/authorize",
tokenURL: "https://wakatime.com/oauth/token",
userInfoURL: "https://wakatime.com/api/v1/users/current",
}}
}
// FetchAuthUser returns an AuthUser instance based on the Wakatime's user API.
//
// API reference: https://wakatime.com/developers#users
func (p *Wakatime) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
data, err := p.FetchRawUserInfo(token)
if err != nil {
return nil, err
}
rawUser := map[string]any{}
if err := json.Unmarshal(data, &rawUser); err != nil {
return nil, err
}
extracted := struct {
Data struct {
Id string `json:"id"`
DisplayName string `json:"display_name"`
Username string `json:"username"`
Email string `json:"email"`
Photo string `json:"photo"`
IsPhotoPublic bool `json:"photo_public"`
IsEmailConfirmed bool `json:"is_email_confirmed"`
} `json:"data"`
}{}
if err := json.Unmarshal(data, &extracted); err != nil {
return nil, err
}
user := &AuthUser{
Id: extracted.Data.Id,
Name: extracted.Data.DisplayName,
Username: extracted.Data.Username,
RawUser: rawUser,
AccessToken: token.AccessToken,
RefreshToken: token.RefreshToken,
}
// note: we don't check for is_email_public field because PocketBase
// has its own emailVisibility flag which is false by default
if extracted.Data.IsEmailConfirmed {
user.Email = extracted.Data.Email
}
if extracted.Data.IsPhotoPublic {
user.AvatarURL = extracted.Data.Photo
}
user.Expiry, _ = types.ParseDateTime(token.Expiry)
return user, nil
}

84
tools/auth/yandex.go Normal file
View file

@ -0,0 +1,84 @@
package auth
import (
"context"
"encoding/json"
"github.com/pocketbase/pocketbase/tools/types"
"golang.org/x/oauth2"
"golang.org/x/oauth2/yandex"
)
func init() {
Providers[NameYandex] = wrapFactory(NewYandexProvider)
}
var _ Provider = (*Yandex)(nil)
// NameYandex is the unique name of the Yandex provider.
const NameYandex string = "yandex"
// Yandex allows authentication via Yandex OAuth2.
type Yandex struct {
BaseProvider
}
// NewYandexProvider creates new Yandex provider instance with some defaults.
//
// Docs: https://yandex.ru/dev/id/doc/en/
func NewYandexProvider() *Yandex {
return &Yandex{BaseProvider{
ctx: context.Background(),
displayName: "Yandex",
pkce: true,
scopes: []string{"login:email", "login:avatar", "login:info"},
authURL: yandex.Endpoint.AuthURL,
tokenURL: yandex.Endpoint.TokenURL,
userInfoURL: "https://login.yandex.ru/info",
}}
}
// FetchAuthUser returns an AuthUser instance based on Yandex's user api.
//
// API reference: https://yandex.ru/dev/id/doc/en/user-information#response-format
func (p *Yandex) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
data, err := p.FetchRawUserInfo(token)
if err != nil {
return nil, err
}
rawUser := map[string]any{}
if err := json.Unmarshal(data, &rawUser); err != nil {
return nil, err
}
extracted := struct {
Id string `json:"id"`
Name string `json:"real_name"`
Username string `json:"login"`
Email string `json:"default_email"`
IsAvatarEmpty bool `json:"is_avatar_empty"`
AvatarId string `json:"default_avatar_id"`
}{}
if err := json.Unmarshal(data, &extracted); err != nil {
return nil, err
}
user := &AuthUser{
Id: extracted.Id,
Name: extracted.Name,
Username: extracted.Username,
Email: extracted.Email,
RawUser: rawUser,
AccessToken: token.AccessToken,
RefreshToken: token.RefreshToken,
}
user.Expiry, _ = types.ParseDateTime(token.Expiry)
if !extracted.IsAvatarEmpty {
user.AvatarURL = "https://avatars.yandex.net/get-yapic/" + extracted.AvatarId + "/islands-200"
}
return user, nil
}