mirror of
https://github.com/tinyauthapp/tinyauth.git
synced 2026-09-25 04:03:32 +08:00
Merge branch 'main' into feat/k8s_gateways
This commit is contained in:
@@ -2,11 +2,13 @@ package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
"github.com/tinyauthapp/tinyauth/pkg/validators"
|
||||
"go.uber.org/dig"
|
||||
)
|
||||
|
||||
@@ -21,6 +23,7 @@ type LabelProvider interface {
|
||||
type AccessControlsService struct {
|
||||
log *logger.Logger
|
||||
config *model.Config
|
||||
runtime *model.RuntimeConfig
|
||||
labelProvider LabelProvider
|
||||
}
|
||||
|
||||
@@ -29,6 +32,7 @@ type AccessControlServiceInput struct {
|
||||
|
||||
Log *logger.Logger
|
||||
Config *model.Config
|
||||
Runtime *model.RuntimeConfig
|
||||
LabelProvider LabelProvider `optional:"true"`
|
||||
}
|
||||
|
||||
@@ -37,12 +41,38 @@ func NewAccessControlsService(i AccessControlServiceInput) *AccessControlsServic
|
||||
return &AccessControlsService{
|
||||
log: i.Log,
|
||||
config: i.Config,
|
||||
runtime: i.Runtime,
|
||||
labelProvider: i.LabelProvider,
|
||||
}
|
||||
}
|
||||
|
||||
func (service *AccessControlsService) ensureAscii(str string) bool {
|
||||
for i := 0; i < len(str); i++ {
|
||||
if str[i] > unicode.MaxASCII {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (service *AccessControlsService) normalizeDomain(domain string) string {
|
||||
if host, _, err := net.SplitHostPort(domain); err == nil {
|
||||
domain = host
|
||||
}
|
||||
domain = strings.TrimRight(domain, ".")
|
||||
return strings.ToLower(domain)
|
||||
}
|
||||
|
||||
func (service *AccessControlsService) getACLs(domain string, lookup func(locator func(name string, app *model.App) bool) error) (*model.App, error) {
|
||||
v := validators.NewDomainValidator(validators.DomainValidatorOptions{})
|
||||
if !service.ensureAscii(domain) {
|
||||
return nil, errors.New("domain contains non-ascii characters")
|
||||
}
|
||||
|
||||
normalizedDomain := service.normalizeDomain(domain)
|
||||
|
||||
if !strings.HasSuffix(normalizedDomain, "."+service.runtime.CookieDomain) && normalizedDomain != service.runtime.CookieDomain {
|
||||
return nil, fmt.Errorf("domain does not match cookie domain, expected %s (or a subdomain), got %s", service.runtime.CookieDomain, domain)
|
||||
}
|
||||
|
||||
var domainMatch *model.App
|
||||
var nameMatch *model.App
|
||||
@@ -50,16 +80,18 @@ func (service *AccessControlsService) getACLs(domain string, lookup func(locator
|
||||
|
||||
locatorFunc := func(name string, app *model.App) bool {
|
||||
if app.Config.Domain != "" {
|
||||
err := v.Validate(app.Config.Domain, domain)
|
||||
if err == nil {
|
||||
if !service.ensureAscii(app.Config.Domain) {
|
||||
service.log.App.Warn().Str("name", name).Str("domain", app.Config.Domain).Msg("Domain contains non-ascii characters, skipping")
|
||||
return false
|
||||
}
|
||||
if normalizedDomain == service.normalizeDomain(app.Config.Domain) {
|
||||
service.log.App.Debug().Str("name", name).Msg("Found matching container by domain")
|
||||
domainMatch = app
|
||||
return true
|
||||
} else if !errors.Is(err, validators.ErrHostnameMismatch) {
|
||||
service.log.App.Debug().Str("name", name).Err(err).Msg("Domain validation failed")
|
||||
}
|
||||
return false
|
||||
}
|
||||
if strings.HasPrefix(strings.ToLower(domain), strings.ToLower(name+".")) {
|
||||
if strings.HasPrefix(normalizedDomain, strings.ToLower(name+".")) {
|
||||
service.log.App.Debug().Str("name", name).Msg("Found matching container by app name")
|
||||
nameMatch = app
|
||||
nameMatchedApps = append(nameMatchedApps, name)
|
||||
@@ -83,7 +115,7 @@ func (service *AccessControlsService) getACLs(domain string, lookup func(locator
|
||||
}
|
||||
|
||||
if len(nameMatchedApps) > 1 {
|
||||
service.log.App.Warn().Str("domain", domain).Strs("apps", nameMatchedApps).Msg("Multiple apps matched domain by name, app names must be unique, using last match")
|
||||
return nil, fmt.Errorf("domain matched multiple apps by name prefix, use explicit domain config")
|
||||
}
|
||||
|
||||
service.log.App.Debug().Str("domain", domain).Msg("Found matching app by app name")
|
||||
|
||||
@@ -4,8 +4,10 @@ import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/test"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
)
|
||||
|
||||
@@ -34,14 +36,25 @@ func TestAccessControlsService(t *testing.T) {
|
||||
log := logger.NewLogger().WithTestConfig()
|
||||
log.Init()
|
||||
|
||||
_, runtime := test.CreateTestConfigs(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
domain string
|
||||
acls map[string]model.App
|
||||
want *model.App
|
||||
name string
|
||||
domain string
|
||||
acls map[string]model.App
|
||||
want *model.App
|
||||
errorFunc func(t *testing.T, e error)
|
||||
}{
|
||||
{
|
||||
name: "returns ACLs for domain",
|
||||
domain: "app.example.com",
|
||||
acls: map[string]model.App{
|
||||
"foo": {Config: model.AppConfig{Domain: "app.example.com"}},
|
||||
},
|
||||
want: &model.App{Config: model.AppConfig{Domain: "app.example.com"}},
|
||||
},
|
||||
{
|
||||
name: "returns ACLs for root domain",
|
||||
domain: "example.com",
|
||||
acls: map[string]model.App{
|
||||
"foo": {Config: model.AppConfig{Domain: "example.com"}},
|
||||
@@ -65,20 +78,11 @@ func TestAccessControlsService(t *testing.T) {
|
||||
want: &model.App{Config: model.AppConfig{Domain: "example.com"}},
|
||||
},
|
||||
{
|
||||
name: "returns ACLs for non-ascii domain",
|
||||
name: "returns error for non-ascii domain",
|
||||
domain: "bücher.example.com",
|
||||
acls: map[string]model.App{
|
||||
"foo": {Config: model.AppConfig{Domain: "bücher.example.com"}},
|
||||
errorFunc: func(t *testing.T, e error) {
|
||||
assert.ErrorContains(t, e, "domain contains non-ascii characters")
|
||||
},
|
||||
want: &model.App{Config: model.AppConfig{Domain: "bücher.example.com"}},
|
||||
},
|
||||
{
|
||||
name: "returns ACLs for punycode domain and non-ascii config",
|
||||
domain: "bücher.example.com",
|
||||
acls: map[string]model.App{
|
||||
"foo": {Config: model.AppConfig{Domain: "xn--bcher-kva.example.com"}},
|
||||
},
|
||||
want: &model.App{Config: model.AppConfig{Domain: "xn--bcher-kva.example.com"}},
|
||||
},
|
||||
{
|
||||
name: "returns ACLs with case-insensitive matching",
|
||||
@@ -110,6 +114,33 @@ func TestAccessControlsService(t *testing.T) {
|
||||
acls: map[string]model.App{},
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "App in domain not matching with the cookie domain should return nothing with name matching",
|
||||
domain: "foo.bad_example.com",
|
||||
acls: map[string]model.App{
|
||||
"foo": {
|
||||
Path: model.AppPath{Allow: "/foo"},
|
||||
},
|
||||
},
|
||||
want: nil,
|
||||
errorFunc: func(t *testing.T, e error) {
|
||||
assert.ErrorContains(t, e, "domain does not match cookie domain")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "App in domain not matching with the cookie domain should return nothing with domain matching",
|
||||
domain: "foo.bad_example.com",
|
||||
acls: map[string]model.App{
|
||||
"foo": {
|
||||
Path: model.AppPath{Allow: "/foo"},
|
||||
Config: model.AppConfig{Domain: "foo.bad_example.com"},
|
||||
},
|
||||
},
|
||||
want: nil,
|
||||
errorFunc: func(t *testing.T, e error) {
|
||||
assert.ErrorContains(t, e, "domain does not match cookie domain")
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// run once for a mock provider
|
||||
@@ -118,10 +149,17 @@ func TestAccessControlsService(t *testing.T) {
|
||||
mock := newMockProvider(test.acls, false)
|
||||
acls := NewAccessControlsService(AccessControlServiceInput{
|
||||
Log: log,
|
||||
Runtime: &runtime,
|
||||
Config: &model.Config{},
|
||||
LabelProvider: mock,
|
||||
})
|
||||
app, err := acls.GetAccessControls(test.domain)
|
||||
app, err := acls.getACLs(test.domain, func(locator func(name string, app *model.App) bool) error {
|
||||
return mock.Lookup(test.domain, locator)
|
||||
})
|
||||
if test.errorFunc != nil {
|
||||
test.errorFunc(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, test.want, app)
|
||||
})
|
||||
@@ -131,12 +169,17 @@ func TestAccessControlsService(t *testing.T) {
|
||||
for _, test := range tests {
|
||||
t.Run(test.name+"(staticACLs)", func(t *testing.T) {
|
||||
acls := NewAccessControlsService(AccessControlServiceInput{
|
||||
Log: log,
|
||||
Log: log,
|
||||
Runtime: &runtime,
|
||||
Config: &model.Config{
|
||||
Apps: test.acls,
|
||||
},
|
||||
})
|
||||
app, err := acls.lookupStaticACLs(test.domain)
|
||||
if test.errorFunc != nil {
|
||||
test.errorFunc(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, test.want, app)
|
||||
})
|
||||
@@ -146,16 +189,34 @@ func TestAccessControlsService(t *testing.T) {
|
||||
mock := newMockProvider(map[string]model.App{}, true)
|
||||
acls := NewAccessControlsService(AccessControlServiceInput{
|
||||
Log: log,
|
||||
Runtime: &runtime,
|
||||
Config: &model.Config{},
|
||||
LabelProvider: mock,
|
||||
})
|
||||
_, err := acls.GetAccessControls("example.com")
|
||||
require.Error(t, err)
|
||||
_, err := acls.getACLs("example.com", func(locator func(name string, app *model.App) bool) error {
|
||||
return mock.Lookup("example.com", locator)
|
||||
})
|
||||
assert.Error(t, err)
|
||||
|
||||
// get acls should return an error when multiple apps with the same domain exist
|
||||
acls = NewAccessControlsService(AccessControlServiceInput{
|
||||
Log: log,
|
||||
Runtime: &runtime,
|
||||
Config: &model.Config{
|
||||
Apps: map[string]model.App{
|
||||
"foo": {Path: model.AppPath{Allow: "/foo"}},
|
||||
"foo.bar": {Path: model.AppPath{Allow: "/bar"}},
|
||||
},
|
||||
},
|
||||
})
|
||||
_, err = acls.GetAccessControls("foo.bar.example.com")
|
||||
assert.ErrorContains(t, err, "domain matched multiple apps by name prefix, use explicit domain config")
|
||||
|
||||
// get access controls should get acls from
|
||||
// static when static acls are configured
|
||||
acls = NewAccessControlsService(AccessControlServiceInput{
|
||||
Log: log,
|
||||
Log: log,
|
||||
Runtime: &runtime,
|
||||
Config: &model.Config{
|
||||
Apps: map[string]model.App{
|
||||
"foo": {Config: model.AppConfig{Domain: "foo.example.com"}},
|
||||
@@ -164,12 +225,12 @@ func TestAccessControlsService(t *testing.T) {
|
||||
})
|
||||
app, err := acls.GetAccessControls("foo.example.com")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, &model.App{Config: model.AppConfig{Domain: "foo.example.com"}}, app)
|
||||
assert.Equal(t, &model.App{Config: model.AppConfig{Domain: "foo.example.com"}}, app)
|
||||
|
||||
// should return nil for no apps
|
||||
app, err = acls.GetAccessControls("bar.example.com")
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, app)
|
||||
assert.Nil(t, app)
|
||||
|
||||
// Should use label provider if available
|
||||
mock = newMockProvider(map[string]model.App{
|
||||
@@ -179,10 +240,11 @@ func TestAccessControlsService(t *testing.T) {
|
||||
}, false)
|
||||
acls = NewAccessControlsService(AccessControlServiceInput{
|
||||
Log: log,
|
||||
Runtime: &runtime,
|
||||
Config: &model.Config{},
|
||||
LabelProvider: mock,
|
||||
})
|
||||
app, err = acls.GetAccessControls("bar.example.com")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, &model.App{Config: model.AppConfig{Domain: "bar.example.com"}}, app)
|
||||
assert.Equal(t, &model.App{Config: model.AppConfig{Domain: "bar.example.com"}}, app)
|
||||
}
|
||||
|
||||
@@ -2,8 +2,12 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/cenkalti/backoff/v5"
|
||||
"github.com/steveiliop56/ding"
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/decoders"
|
||||
@@ -14,6 +18,10 @@ import (
|
||||
"github.com/docker/docker/client"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrPingFailed = fmt.Errorf("failed to ping docker")
|
||||
)
|
||||
|
||||
type DockerService struct {
|
||||
log *logger.Logger
|
||||
client *client.Client
|
||||
@@ -31,26 +39,54 @@ type DockerServiceInput struct {
|
||||
}
|
||||
|
||||
func NewDockerService(i DockerServiceInput) (*DockerService, error) {
|
||||
client, err := client.NewClientWithOpts(client.FromEnv)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client.NegotiateAPIVersion(i.Ctx)
|
||||
|
||||
_, err = client.Ping(i.Ctx)
|
||||
|
||||
if err != nil {
|
||||
i.Log.App.Debug().Err(err).Msg("Docker not connected")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
service := &DockerService{
|
||||
log: i.Log,
|
||||
client: client,
|
||||
context: i.Ctx,
|
||||
}
|
||||
|
||||
service.log.App.Debug().Msg("Attempting to connect to Docker")
|
||||
|
||||
if os.Getenv("DOCKER_HOST") == "" {
|
||||
cli, err := service.connect()
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrPingFailed) {
|
||||
service.log.App.Debug().Msg("Docker not connected")
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to connect to docker: %w", err)
|
||||
}
|
||||
service.client = cli
|
||||
} else {
|
||||
exp := backoff.NewExponentialBackOff()
|
||||
exp.InitialInterval = 3 * time.Second
|
||||
exp.RandomizationFactor = 0.1
|
||||
exp.Multiplier = 1.5
|
||||
exp.Reset()
|
||||
|
||||
operation := func() (*client.Client, error) {
|
||||
if service.client != nil {
|
||||
service.client.Close()
|
||||
}
|
||||
cli, err := service.connect()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cli, nil
|
||||
}
|
||||
|
||||
cli, err := backoff.Retry(service.context, operation, backoff.WithBackOff(exp), backoff.WithMaxTries(3))
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrPingFailed) {
|
||||
service.log.App.Debug().Msg("Docker not connected after retrying")
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to connect to docker after retrying: %w", err)
|
||||
}
|
||||
|
||||
service.client = cli
|
||||
}
|
||||
|
||||
service.isConnected = true
|
||||
service.log.App.Debug().Msg("Docker connected successfully")
|
||||
|
||||
@@ -59,6 +95,22 @@ func NewDockerService(i DockerServiceInput) (*DockerService, error) {
|
||||
return service, nil
|
||||
}
|
||||
|
||||
func (docker *DockerService) connect() (*client.Client, error) {
|
||||
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = cli.Ping(docker.context)
|
||||
|
||||
if err != nil {
|
||||
return nil, ErrPingFailed
|
||||
}
|
||||
|
||||
return cli, nil
|
||||
}
|
||||
|
||||
func (docker *DockerService) getContainers() ([]container.Summary, error) {
|
||||
return docker.client.ContainerList(docker.context, container.ListOptions{})
|
||||
}
|
||||
|
||||
@@ -21,11 +21,15 @@ type GithubUserinfoResponse struct {
|
||||
ID int `json:"id"`
|
||||
}
|
||||
|
||||
func defaultExtractor(client *http.Client, ctx context.Context, url string) (*model.Claims, error) {
|
||||
return simpleReq[model.Claims](client, ctx, url, nil)
|
||||
func defaultExtractor(client *http.Client, ctx context.Context, url string, mapClaims MapClaims) (*model.Claims, error) {
|
||||
claims, err := simpleReq[map[string]any](client, ctx, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return new(mapClaims(*claims)), nil
|
||||
}
|
||||
|
||||
func githubExtractor(client *http.Client, ctx context.Context, _ string) (*model.Claims, error) {
|
||||
func githubExtractor(client *http.Client, ctx context.Context, _ string, _ MapClaims) (*model.Claims, error) {
|
||||
var user model.Claims
|
||||
|
||||
userInfo, err := simpleReq[GithubUserinfoResponse](client, ctx, "https://api.github.com/user", map[string]string{
|
||||
|
||||
@@ -10,7 +10,8 @@ import (
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
type OAuthUserinfoExtractor func(client *http.Client, ctx context.Context, url string) (*model.Claims, error)
|
||||
type MapClaims func(claims map[string]any) model.Claims
|
||||
type OAuthUserinfoExtractor func(client *http.Client, ctx context.Context, url string, mapClaims MapClaims) (*model.Claims, error)
|
||||
|
||||
type OAuthService struct {
|
||||
serviceCfg model.OAuthServiceConfig
|
||||
@@ -81,7 +82,7 @@ func (s *OAuthService) GetToken(code string, verifier string) (*oauth2.Token, er
|
||||
|
||||
func (s *OAuthService) GetUserinfo(token *oauth2.Token) (*model.Claims, error) {
|
||||
client := oauth2.NewClient(s.ctx, oauth2.StaticTokenSource(token))
|
||||
return s.userinfoExtractor(client, s.ctx, s.serviceCfg.UserinfoURL)
|
||||
return s.userinfoExtractor(client, s.ctx, s.serviceCfg.UserinfoURL, s.mapClaims)
|
||||
}
|
||||
|
||||
func (s *OAuthService) GetConfig() model.OAuthServiceConfig {
|
||||
@@ -97,3 +98,26 @@ func (s *OAuthService) UpdateConfig(config model.OAuthServiceConfig) {
|
||||
s.config.Endpoint.TokenURL = config.TokenURL
|
||||
s.config.RedirectURL = config.RedirectURL
|
||||
}
|
||||
|
||||
func (s *OAuthService) mapClaims(claims map[string]any) model.Claims {
|
||||
return model.Claims{
|
||||
Sub: mapClaim[string]("sub", "", claims),
|
||||
Name: mapClaim[string]("name", s.serviceCfg.Claims.Name, claims),
|
||||
PreferredUsername: mapClaim[string]("preferred_username", s.serviceCfg.Claims.Username, claims),
|
||||
Email: mapClaim[string]("email", s.serviceCfg.Claims.Email, claims),
|
||||
Groups: mapClaim[any]("groups", s.serviceCfg.Claims.Groups, claims),
|
||||
}
|
||||
}
|
||||
|
||||
func mapClaim[T any](fallback, override string, kv map[string]any) T {
|
||||
key := fallback
|
||||
if override != "" {
|
||||
key = override
|
||||
}
|
||||
v, ok := kv[key].(T)
|
||||
if !ok {
|
||||
var zero T
|
||||
return zero
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
@@ -53,6 +54,17 @@ const (
|
||||
OIDCPromptNone OIDCPrompt = "none"
|
||||
)
|
||||
|
||||
func (p OIDCPrompt) String() string {
|
||||
switch p {
|
||||
case OIDCPromptLogin:
|
||||
return "login"
|
||||
case OIDCPromptNone:
|
||||
return "none"
|
||||
default:
|
||||
return "login"
|
||||
}
|
||||
}
|
||||
|
||||
var SupportedPrompts = []string{string(OIDCPromptLogin), string(OIDCPromptNone)}
|
||||
|
||||
// This is not spec-compliant, the ID token SHOULD NOT contain user info claims but,
|
||||
@@ -871,7 +883,7 @@ func (service *OIDCService) ValidatePKCE(codeChallenge string, codeVerifier stri
|
||||
if codeChallenge == "" {
|
||||
return true
|
||||
}
|
||||
return codeChallenge == service.hashAndEncodePKCE(codeVerifier)
|
||||
return subtle.ConstantTimeCompare([]byte(codeChallenge), []byte(service.hashAndEncodePKCE(codeVerifier))) == 1
|
||||
}
|
||||
|
||||
func (service *OIDCService) hashAndEncodePKCE(codeVerifier string) string {
|
||||
|
||||
Reference in New Issue
Block a user