Compare commits

..

2 Commits

Author SHA1 Message Date
Stavros b9abab2f17 fix: review comments 2026-05-12 18:17:01 +03:00
Stavros 3fd56272d2 feat: add support for deny by default access controls 2026-05-12 17:21:45 +03:00
27 changed files with 1468 additions and 5433 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
version: 2 version: 2
updates: updates:
- package-ecosystem: "npm" - package-ecosystem: "bun"
directory: "/frontend" directory: "/frontend"
groups: groups:
minor-patch: minor-patch:
+15 -12
View File
@@ -15,10 +15,8 @@ jobs:
- name: Checkout code - name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup pnpm - name: Setup bun
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
package_json_file: ./frontend/package.json
- name: Setup go - name: Setup go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
@@ -29,22 +27,27 @@ jobs:
run: go mod download run: go mod download
- name: Install frontend dependencies - name: Install frontend dependencies
working-directory: ./frontend run: |
run: pnpm ci cd frontend
bun install --frozen-lockfile
- name: Set version - name: Set version
run: echo testing > internal/assets/version run: |
echo testing > internal/assets/version
- name: Lint frontend - name: Lint frontend
working-directory: ./frontend run: |
run: pnpm run lint cd frontend
bun run lint
- name: Build frontend - name: Build frontend
working-directory: ./frontend run: |
run: pnpm run build cd frontend
bun run build
- name: Copy frontend - name: Copy frontend
run: cp -r frontend/dist internal/assets/dist run: |
cp -r frontend/dist internal/assets/dist
- name: Run tests - name: Run tests
run: go test -coverprofile=coverage.txt -v ./... run: go test -coverprofile=coverage.txt -v ./...
+20 -18
View File
@@ -59,10 +59,8 @@ jobs:
with: with:
ref: nightly ref: nightly
- name: Setup pnpm - name: Install bun
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
package_json_file: ./frontend/package.json
- name: Install go - name: Install go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
@@ -70,15 +68,18 @@ jobs:
go-version: "^1.26.0" go-version: "^1.26.0"
- name: Install frontend dependencies - name: Install frontend dependencies
working-directory: ./frontend run: |
run: pnpm ci cd frontend
bun install --frozen-lockfile
- name: Install backend dependencies - name: Install backend dependencies
run: go mod download run: |
go mod download
- name: Build frontend - name: Build frontend
working-directory: ./frontend run: |
run: pnpm run build cd frontend
bun run build
- name: Build - name: Build
run: | run: |
@@ -104,10 +105,8 @@ jobs:
with: with:
ref: nightly ref: nightly
- name: Setup pnpm - name: Install bun
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
package_json_file: ./frontend/package.json
- name: Install go - name: Install go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
@@ -115,15 +114,18 @@ jobs:
go-version: "^1.26.0" go-version: "^1.26.0"
- name: Install frontend dependencies - name: Install frontend dependencies
working-directory: ./frontend run: |
run: pnpm ci cd frontend
bun install --frozen-lockfile
- name: Install backend dependencies - name: Install backend dependencies
run: go mod download run: |
go mod download
- name: Build frontend - name: Build frontend
working-directory: ./frontend run: |
run: pnpm run build cd frontend
bun run build
- name: Build - name: Build
run: | run: |
+20 -18
View File
@@ -35,10 +35,8 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup pnpm - name: Install bun
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
package_json_file: ./frontend/package.json
- name: Install go - name: Install go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
@@ -46,15 +44,18 @@ jobs:
go-version: "^1.26.0" go-version: "^1.26.0"
- name: Install frontend dependencies - name: Install frontend dependencies
working-directory: ./frontend run: |
run: pnpm ci cd frontend
bun install --frozen-lockfile
- name: Install backend dependencies - name: Install backend dependencies
run: go mod download run: |
go mod download
- name: Build frontend - name: Build frontend
working-directory: ./frontend run: |
run: pnpm run build cd frontend
bun run build
- name: Build - name: Build
run: | run: |
@@ -77,10 +78,8 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup pnpm - name: Install bun
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
package_json_file: ./frontend/package.json
- name: Install go - name: Install go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
@@ -88,15 +87,18 @@ jobs:
go-version: "^1.26.0" go-version: "^1.26.0"
- name: Install frontend dependencies - name: Install frontend dependencies
working-directory: ./frontend run: |
run: pnpm ci cd frontend
bun install --frozen-lockfile
- name: Install backend dependencies - name: Install backend dependencies
run: go mod download run: |
go mod download
- name: Build frontend - name: Build frontend
working-directory: ./frontend run: |
run: pnpm run build cd frontend
bun run build
- name: Build - name: Build
run: | run: |
-3
View File
@@ -48,6 +48,3 @@ __debug_*
# testing config # testing config
config.certify.yml config.certify.yml
# deepsec
/.deepsec
+2 -2
View File
@@ -7,7 +7,7 @@ Contributing to Tinyauth is straightforward. Follow the steps below to set up a
## Requirements ## Requirements
- pnpm - Bun
- Golang v1.24.0 or later - Golang v1.24.0 or later
- Git - Git
- Docker - Docker
@@ -34,7 +34,7 @@ Frontend dependencies can be installed as follows:
```sh ```sh
cd frontend/ cd frontend/
pnpm ci bun install
``` ```
## Create the `.env` file ## Create the `.env` file
+4 -6
View File
@@ -1,14 +1,12 @@
# Site builder # Site builder
FROM node:26.1-alpine3.23 AS frontend-builder FROM oven/bun:1.3.13-alpine AS frontend-builder
WORKDIR /frontend WORKDIR /frontend
RUN npm install -g pnpm@11.1.2
COPY ./frontend/package.json ./ COPY ./frontend/package.json ./
COPY ./frontend/pnpm-lock.yaml ./ COPY ./frontend/bun.lock ./
RUN pnpm ci RUN bun install --frozen-lockfile
COPY ./frontend/public ./public COPY ./frontend/public ./public
COPY ./frontend/src ./src COPY ./frontend/src ./src
@@ -19,7 +17,7 @@ COPY ./frontend/tsconfig.app.json ./
COPY ./frontend/tsconfig.node.json ./ COPY ./frontend/tsconfig.node.json ./
COPY ./frontend/vite.config.ts ./ COPY ./frontend/vite.config.ts ./
RUN pnpm run build RUN bun run build
# Builder # Builder
FROM golang:1.26-alpine3.23 AS builder FROM golang:1.26-alpine3.23 AS builder
+1 -1
View File
@@ -8,7 +8,7 @@ COPY go.sum ./
RUN go mod download RUN go mod download
RUN go install github.com/air-verse/air@v1.61.7 RUN go install github.com/air-verse/air@v1.61.7
RUN go install github.com/go-delve/delve/cmd/dlv@v1.26.3 RUN go install github.com/go-delve/delve/cmd/dlv@latest
COPY ./cmd ./cmd COPY ./cmd ./cmd
COPY ./internal ./internal COPY ./internal ./internal
+4 -6
View File
@@ -1,14 +1,12 @@
# Site builder # Site builder
FROM node:26.1-alpine3.23 AS frontend-builder FROM oven/bun:1.3.13-alpine AS frontend-builder
WORKDIR /frontend WORKDIR /frontend
RUN npm install -g pnpm@11.1.2
COPY ./frontend/package.json ./ COPY ./frontend/package.json ./
COPY ./frontend/pnpm-lock.yaml ./ COPY ./frontend/bun.lock ./
RUN pnpm ci RUN bun install --frozen-lockfile
COPY ./frontend/public ./public COPY ./frontend/public ./public
COPY ./frontend/src ./src COPY ./frontend/src ./src
@@ -19,7 +17,7 @@ COPY ./frontend/tsconfig.app.json ./
COPY ./frontend/tsconfig.node.json ./ COPY ./frontend/tsconfig.node.json ./
COPY ./frontend/vite.config.ts ./ COPY ./frontend/vite.config.ts ./
RUN pnpm run build RUN bun run build
# Builder # Builder
FROM golang:1.26-alpine3.23 AS builder FROM golang:1.26-alpine3.23 AS builder
+2 -2
View File
@@ -17,7 +17,7 @@ PROD_COMPOSE := $(shell test -f "docker-compose.test.prod.yml" && echo "docker-c
# Deps # Deps
deps: deps:
cd frontend && pnpm ci bun install --frozen-lockfile --cwd frontend
go mod download go mod download
# Clean data # Clean data
@@ -31,7 +31,7 @@ clean-webui:
# Build the web UI # Build the web UI
webui: clean-webui webui: clean-webui
cd frontend && pnpm run build bun run --cwd frontend build
cp -r frontend/dist internal/assets cp -r frontend/dist internal/assets
# Build the binary # Build the binary
+2 -50
View File
@@ -2,56 +2,8 @@
## Supported Versions ## Supported Versions
It is recommended to use the [latest](https://github.com/tinyauthapp/tinyauth/releases/latest) available version of Tinyauth. This is because it includes security fixes, new features and dependency updates. Older versions, especially major ones, are not supported and won't receive security or patch updates. It is recommended to use the [latest](https://github.com/tinyauthapp/tinyauth/releases/latest) available version of tinyauth. This is because it includes security fixes, new features and dependency updates. Older versions, especially major ones, are not supported and won't receive security or patch updates.
## Reporting a Vulnerability ## Reporting a Vulnerability
Please **do not** report security vulnerabilities through public GitHub issues, discussions, or pull requests as I won't be able to patch them in time and they may get exploited by malicious actors. Due to the nature of this app, it needs to be secure. If you discover any security issues or vulnerabilities in the app please contact me as soon as possible at <security@tinyauth.app>. Please do not use the issues section to report security issues as I won't be able to patch them in time and they may get exploited by malicious actors.
Instead, report them privately using [GitHub's Private Vulnerability Reporting](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability) via the **Security** tab of this repository.
Or send us an email at <security@tinyauth.app>.
### A note on AI-assisted reports
If AI tooling (LLMs, automated scanners, agentic assistants, etc.) helped you discover, analyse, or write up this issue, please say so in your report. This isn't a judgement - AI-assisted findings are welcome - but disclosing it up front helps maintainers calibrate how much additional verification a report needs, and tends to make the report itself clearer.
When submitting a report, please use the structure below so it can be triaged quickly.
---
### 1. Summary
A short, one-paragraph description of the vulnerability and its impact (e.g. what an attacker can achieve, who is affected, and under what conditions).
### 2. Steps to Reproduce / Proof of Concept
Provide a minimal, reliable reproduction:
1. Step one
2. Step two
3. Step three
Include any required input, payloads, configuration, or code snippets. Attach a PoC script or screenshots where helpful.
### 3. Expected vs. Actual Behaviour
- **Expected:** what *should* happen
- **Actual:** what *does* happen, and why it's a security issue
### 4. Suggested Fix or Mitigation *(optional)*
If you have an idea for how to address the issue, describe it here. A private gist link is welcome but not required.
- **Have you tested this fix?** Yes / No
- **If yes,** briefly describe how it was tested and what was verified.
---
## What to Expect
- **Acknowledgement** within a reasonable timeframe after receiving your report
- **Updates** as the issue is investigated and addressed
- **Public credit** in the resulting advisory, along with any **CVE assigned**, unless you'd prefer to stay anonymous
We follow a **90-day coordinated disclosure** window: please allow up to 90 days from the date of your report for the issue to be investigated and patched before publicly disclosing it. The publication date - whether earlier if a fix lands sooner, or later if more time is genuinely needed - will be agreed with you in advance.
+6
View File
@@ -0,0 +1,6 @@
# Ignore artifacts:
dist
node_modules
bun.lock
package.json
src/lib/i18n/locales
+1
View File
@@ -0,0 +1 @@
{}
+4 -6
View File
@@ -1,13 +1,11 @@
FROM node:26.1-alpine3.23 FROM oven/bun:1.2.16-alpine
RUN npm install -g pnpm@11.1.2
WORKDIR /frontend WORKDIR /frontend
COPY ./frontend/package.json ./ COPY ./frontend/package.json ./
COPY ./frontend/pnpm-lock.yaml ./ COPY ./frontend/bun.lock ./
RUN pnpm ci RUN bun install --frozen-lockfile
COPY ./frontend/public ./public COPY ./frontend/public ./public
COPY ./frontend/src ./src COPY ./frontend/src ./src
@@ -21,4 +19,4 @@ COPY ./frontend/vite.config.ts ./
EXPOSE 5173 EXPOSE 5173
ENTRYPOINT ["pnpm", "run", "dev"] ENTRYPOINT ["bun", "run", "dev"]
+1107
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -10,7 +10,6 @@
"preview": "vite preview", "preview": "vite preview",
"tsc": "tsc -b" "tsc": "tsc -b"
}, },
"packageManager": "pnpm@11.1.2",
"dependencies": { "dependencies": {
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
-5072
View File
File diff suppressed because it is too large Load Diff
-4
View File
@@ -1,4 +0,0 @@
dangerouslyAllowAllBuilds: false
blockExoticSubdeps: true
minimumReleaseAge: 1440 # 1 day
trustPolicy: no-downgrade
+1 -1
View File
@@ -45,7 +45,7 @@ func (app *BootstrapApp) setupServices() error {
labelProvider = dockerService labelProvider = dockerService
} }
accessControlsService := service.NewAccessControlsService(app.log, &labelProvider, app.config.Apps) accessControlsService := service.NewAccessControlsService(app.log, app.config, &labelProvider)
app.services.accessControlService = accessControlsService app.services.accessControlService = accessControlsService
oauthBrokerService := service.NewOAuthBrokerService(app.log, app.runtime.OAuthProviders, app.ctx) oauthBrokerService := service.NewOAuthBrokerService(app.log, app.runtime.OAuthProviders, app.ctx)
+9 -15
View File
@@ -101,7 +101,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
clientIP := c.ClientIP() clientIP := c.ClientIP()
if controller.auth.IsBypassedIP(clientIP, acls) { if controller.acls.IsIPBypassed(clientIP, acls) {
controller.setHeaders(c, acls) controller.setHeaders(c, acls)
c.JSON(200, gin.H{ c.JSON(200, gin.H{
"status": 200, "status": 200,
@@ -110,13 +110,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
return return
} }
authEnabled, err := controller.auth.IsAuthEnabled(proxyCtx.Path, acls) authEnabled := controller.acls.IsAuthEnabled(proxyCtx.Path, acls)
if err != nil {
controller.log.App.Error().Err(err).Msg("Failed to determine if authentication is enabled for resource")
controller.handleError(c, proxyCtx)
return
}
if !authEnabled { if !authEnabled {
controller.log.App.Debug().Msg("Authentication is disabled for this resource, allowing access without authentication") controller.log.App.Debug().Msg("Authentication is disabled for this resource, allowing access without authentication")
@@ -128,7 +122,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
return return
} }
if !controller.auth.CheckIP(clientIP, acls) { if !controller.acls.IsIPAllowed(clientIP, acls) {
queries, err := query.Values(UnauthorizedQuery{ queries, err := query.Values(UnauthorizedQuery{
Resource: strings.Split(proxyCtx.Host, ".")[0], Resource: strings.Split(proxyCtx.Host, ".")[0],
IP: clientIP, IP: clientIP,
@@ -144,9 +138,9 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
if !controller.useBrowserResponse(proxyCtx) { if !controller.useBrowserResponse(proxyCtx) {
c.Header("x-tinyauth-location", redirectURL) c.Header("x-tinyauth-location", redirectURL)
c.JSON(403, gin.H{ c.JSON(401, gin.H{
"status": 403, "status": 401,
"message": "Forbidden", "message": "Unauthorized",
}) })
return return
} }
@@ -165,7 +159,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
} }
if userContext.Authenticated { if userContext.Authenticated {
userAllowed := controller.auth.IsUserAllowed(c, *userContext, acls) userAllowed := controller.acls.IsUserAllowed(*userContext, acls)
if !userAllowed { if !userAllowed {
controller.log.App.Warn().Str("user", userContext.GetUsername()).Str("resource", strings.Split(proxyCtx.Host, ".")[0]).Msg("User is not allowed to access resource") controller.log.App.Warn().Str("user", userContext.GetUsername()).Str("resource", strings.Split(proxyCtx.Host, ".")[0]).Msg("User is not allowed to access resource")
@@ -205,9 +199,9 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
var groupOK bool var groupOK bool
if userContext.IsOAuth() { if userContext.IsOAuth() {
groupOK = controller.auth.IsInOAuthGroup(c, *userContext, acls) groupOK = controller.acls.IsInOAuthGroup(*userContext, acls)
} else { } else {
groupOK = controller.auth.IsInLDAPGroup(c, *userContext, acls) groupOK = controller.acls.IsInLDAPGroup(*userContext, acls)
} }
if !groupOK { if !groupOK {
+1 -28
View File
@@ -24,33 +24,6 @@ func TestProxyController(t *testing.T) {
cfg, runtime := test.CreateTestConfigs(t) cfg, runtime := test.CreateTestConfigs(t)
acls := map[string]model.App{
"app_path_allow": {
Config: model.AppConfig{
Domain: "path-allow.example.com",
},
Path: model.AppPath{
Allow: "/allowed",
},
},
"app_user_allow": {
Config: model.AppConfig{
Domain: "user-allow.example.com",
},
Users: model.AppUsers{
Allow: "testuser",
},
},
"ip_bypass": {
Config: model.AppConfig{
Domain: "ip-bypass.example.com",
},
IP: model.AppIP{
Bypass: []string{"10.10.10.10"},
},
},
}
const browserUserAgent = ` const browserUserAgent = `
Mozilla/5.0 (Linux; Android 8.0.0; SM-G955U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Mobile Safari/537.36` Mozilla/5.0 (Linux; Android 8.0.0; SM-G955U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Mobile Safari/537.36`
@@ -391,7 +364,7 @@ func TestProxyController(t *testing.T) {
broker := service.NewOAuthBrokerService(log, map[string]model.OAuthServiceConfig{}, ctx) broker := service.NewOAuthBrokerService(log, map[string]model.OAuthServiceConfig{}, ctx)
authService := service.NewAuthService(log, cfg, runtime, ctx, wg, nil, queries, broker) authService := service.NewAuthService(log, cfg, runtime, ctx, wg, nil, queries, broker)
aclsService := service.NewAccessControlsService(log, nil, acls) aclsService := service.NewAccessControlsService(log, cfg, nil)
for _, test := range tests { for _, test := range tests {
t.Run(test.description, func(t *testing.T) { t.Run(test.description, func(t *testing.T) {
+1 -1
View File
@@ -32,7 +32,7 @@ func (controller *ResourcesController) resourcesHandler(c *gin.Context) {
if controller.config.Resources.Path == "" { if controller.config.Resources.Path == "" {
c.JSON(404, gin.H{ c.JSON(404, gin.H{
"status": 404, "status": 404,
"message": "Resource not found", "message": "Resources not found",
}) })
return return
} }
+8
View File
@@ -24,6 +24,9 @@ func NewDefaultConfiguration() *Config {
SessionMaxLifetime: 0, // disabled SessionMaxLifetime: 0, // disabled
LoginTimeout: 300, // 5 minutes LoginTimeout: 300, // 5 minutes
LoginMaxRetries: 3, LoginMaxRetries: 3,
ACLs: ACLsConfig{
Policy: "allow",
},
}, },
UI: UIConfig{ UI: UIConfig{
Title: "Tinyauth", Title: "Tinyauth",
@@ -114,6 +117,7 @@ type AuthConfig struct {
LoginTimeout int `description:"Login timeout in seconds." yaml:"loginTimeout"` LoginTimeout int `description:"Login timeout in seconds." yaml:"loginTimeout"`
LoginMaxRetries int `description:"Maximum login retries." yaml:"loginMaxRetries"` LoginMaxRetries int `description:"Maximum login retries." yaml:"loginMaxRetries"`
TrustedProxies []string `description:"Comma-separated list of trusted proxy addresses." yaml:"trustedProxies"` TrustedProxies []string `description:"Comma-separated list of trusted proxy addresses." yaml:"trustedProxies"`
ACLs ACLsConfig `description:"ACLs configuration." yaml:"acls"`
} }
type UserAttributes struct { type UserAttributes struct {
@@ -223,6 +227,10 @@ type OIDCClientConfig struct {
Name string `description:"Client name in UI." yaml:"name"` Name string `description:"Client name in UI." yaml:"name"`
} }
type ACLsConfig struct {
Policy string `description:"ACL policy for allow-by-default or deny-by-default, available options are allow and deny, default is allow." yaml:"policy"`
}
// ACLs // ACLs
type Apps struct { type Apps struct {
+225 -14
View File
@@ -1,44 +1,82 @@
package service package service
import ( import (
"regexp"
"strings" "strings"
"github.com/tinyauthapp/tinyauth/internal/model" "github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/utils"
"github.com/tinyauthapp/tinyauth/internal/utils/logger" "github.com/tinyauthapp/tinyauth/internal/utils/logger"
) )
type AccessControlPolicy string
const (
PolicyAllow AccessControlPolicy = "allow"
PolicyDeny AccessControlPolicy = "deny"
)
func accessControlPolicyFromString(s string) (AccessControlPolicy, bool) {
switch strings.ToLower(s) {
case "allow":
return PolicyAllow, true
case "deny":
return PolicyDeny, true
default:
return PolicyAllow, false
}
}
type LabelProvider interface { type LabelProvider interface {
GetLabels(appDomain string) (*model.App, error) GetLabels(appDomain string) (*model.App, error)
} }
type AccessControlsService struct { type AccessControlsService struct {
log *logger.Logger log *logger.Logger
config model.Config
labelProvider *LabelProvider labelProvider *LabelProvider
static map[string]model.App policy AccessControlPolicy
} }
func NewAccessControlsService( func NewAccessControlsService(
log *logger.Logger, log *logger.Logger,
labelProvider *LabelProvider, config model.Config,
static map[string]model.App) *AccessControlsService { labelProvider *LabelProvider) *AccessControlsService {
return &AccessControlsService{
service := AccessControlsService{
log: log, log: log,
config: config,
labelProvider: labelProvider, labelProvider: labelProvider,
static: static,
} }
policy, ok := accessControlPolicyFromString(config.Auth.ACLs.Policy)
if !ok {
log.App.Warn().Str("policy", config.Auth.ACLs.Policy).Msg("Invalid ACL policy in config, defaulting to 'allow'")
}
if policy == PolicyAllow {
log.App.Debug().Msg("Using 'allow' ACL policy: access to apps will be allowed by default unless explicitly blocked")
} else {
log.App.Debug().Msg("Using 'deny' ACL policy: access to apps will be blocked by default unless explicitly allowed")
}
service.policy = policy
return &service
} }
func (acls *AccessControlsService) lookupStaticACLs(domain string) *model.App { func (service *AccessControlsService) lookupStaticACLs(domain string) *model.App {
var appAcls *model.App var appAcls *model.App
for app, config := range acls.static { for app, config := range service.config.Apps {
if config.Config.Domain == domain { if config.Config.Domain == domain {
acls.log.App.Debug().Str("name", app).Msg("Found matching container by domain") service.log.App.Debug().Str("name", app).Msg("Found matching container by domain")
appAcls = &config appAcls = &config
break // If we find a match by domain, we can stop searching break // If we find a match by domain, we can stop searching
} }
if strings.SplitN(domain, ".", 2)[0] == app { if strings.SplitN(domain, ".", 2)[0] == app {
acls.log.App.Debug().Str("name", app).Msg("Found matching container by app name") service.log.App.Debug().Str("name", app).Msg("Found matching container by app name")
appAcls = &config appAcls = &config
break // If we find a match by app name, we can stop searching break // If we find a match by app name, we can stop searching
} }
@@ -46,20 +84,193 @@ func (acls *AccessControlsService) lookupStaticACLs(domain string) *model.App {
return appAcls return appAcls
} }
func (acls *AccessControlsService) GetAccessControls(domain string) (*model.App, error) { func (service *AccessControlsService) GetAccessControls(domain string) (*model.App, error) {
// First check in the static config // First check in the static config
app := acls.lookupStaticACLs(domain) app := service.lookupStaticACLs(domain)
if app != nil { if app != nil {
acls.log.App.Debug().Msg("Using static ACLs for app") service.log.App.Debug().Msg("Using static ACLs for app")
return app, nil return app, nil
} }
// If we have a label provider configured, try to get ACLs from it // If we have a label provider configured, try to get ACLs from it
if acls.labelProvider != nil { if service.labelProvider != nil {
return (*acls.labelProvider).GetLabels(domain) return (*service.labelProvider).GetLabels(domain)
} }
// no labels // no labels
return nil, nil return nil, nil
} }
func (service *AccessControlsService) IsUserAllowed(context model.UserContext, acls *model.App) bool {
if acls == nil {
return service.policyResult(true)
}
if context.Provider == model.ProviderOAuth {
service.log.App.Debug().Msg("User is an OAuth user, checking OAuth whitelist")
return utils.CheckFilter(acls.OAuth.Whitelist, context.OAuth.Email)
}
if acls.Users.Block != "" {
service.log.App.Debug().Msg("Checking users block list")
if utils.CheckFilter(acls.Users.Block, context.GetUsername()) {
return false
}
}
service.log.App.Debug().Msg("Checking users allow list")
return service.policyResult(utils.CheckFilter(acls.Users.Allow, context.GetUsername()))
}
func (service *AccessControlsService) IsInOAuthGroup(context model.UserContext, acls *model.App) bool {
if acls == nil {
return true
}
if !context.IsOAuth() {
service.log.App.Debug().Msg("User is not an OAuth user, skipping OAuth group check")
return false
}
if _, ok := model.OverrideProviders[context.OAuth.ID]; ok {
service.log.App.Debug().Str("provider", context.OAuth.ID).Msg("Provider override detected, skipping group check")
return true
}
for _, userGroup := range context.OAuth.Groups {
if utils.CheckFilter(acls.OAuth.Groups, strings.TrimSpace(userGroup)) {
service.log.App.Trace().Str("group", userGroup).Str("required", acls.OAuth.Groups).Msg("User group matched")
return true
}
}
service.log.App.Debug().Msg("No groups matched")
return false
}
func (service *AccessControlsService) IsInLDAPGroup(context model.UserContext, acls *model.App) bool {
if acls == nil {
return true
}
if !context.IsLDAP() {
service.log.App.Debug().Msg("User is not an LDAP user, skipping LDAP group check")
return false
}
for _, userGroup := range context.LDAP.Groups {
if utils.CheckFilter(acls.LDAP.Groups, strings.TrimSpace(userGroup)) {
service.log.App.Trace().Str("group", userGroup).Str("required", acls.LDAP.Groups).Msg("User group matched")
return true
}
}
service.log.App.Debug().Msg("No groups matched")
return false
}
func (service *AccessControlsService) IsAuthEnabled(uri string, acls *model.App) bool {
if acls == nil {
return true
}
if acls.Path.Block != "" {
regex, err := regexp.Compile(acls.Path.Block)
if err != nil {
service.log.App.Error().Err(err).Msg("Failed to compile block regex")
return true
}
if !regex.MatchString(uri) {
return false
}
}
if acls.Path.Allow != "" {
regex, err := regexp.Compile(acls.Path.Allow)
if err != nil {
service.log.App.Error().Err(err).Msg("Failed to compile allow regex")
return true
}
if regex.MatchString(uri) {
return false
}
}
return true
}
func (service *AccessControlsService) IsIPAllowed(ip string, acls *model.App) bool {
if acls == nil {
return service.policyResult(true)
}
// Merge the global and app IP filter
blockedIps := append(acls.IP.Block, service.config.Auth.IP.Block...)
allowedIPs := append(acls.IP.Allow, service.config.Auth.IP.Allow...)
for _, blocked := range blockedIps {
res, err := utils.FilterIP(blocked, ip)
if err != nil {
service.log.App.Warn().Err(err).Str("item", blocked).Msg("Invalid IP/CIDR in block list")
continue
}
if res {
service.log.App.Debug().Str("ip", ip).Str("item", blocked).Msg("IP is in block list, denying access")
return false
}
}
for _, allowed := range allowedIPs {
res, err := utils.FilterIP(allowed, ip)
if err != nil {
service.log.App.Warn().Err(err).Str("item", allowed).Msg("Invalid IP/CIDR in allow list")
continue
}
if res {
service.log.App.Debug().Str("ip", ip).Str("item", allowed).Msg("IP is in allow list, allowing access")
return true
}
}
if len(allowedIPs) > 0 {
service.log.App.Debug().Str("ip", ip).Msg("IP not in allow list, denying access")
return false
}
service.log.App.Debug().Str("ip", ip).Msg("IP not in block or allow list, allowing access")
return service.policyResult(true)
}
func (service *AccessControlsService) IsIPBypassed(ip string, acls *model.App) bool {
if acls == nil {
return false
}
for _, bypassed := range acls.IP.Bypass {
res, err := utils.FilterIP(bypassed, ip)
if err != nil {
service.log.App.Warn().Err(err).Str("item", bypassed).Msg("Invalid IP/CIDR in bypass list")
continue
}
if res {
service.log.App.Debug().Str("ip", ip).Str("item", bypassed).Msg("IP is in bypass list, skipping authentication")
return true
}
}
service.log.App.Debug().Str("ip", ip).Msg("IP not in bypass list, proceeding with authentication")
return false
}
func (service *AccessControlsService) policyResult(result bool) bool {
if service.policy == PolicyAllow {
return result
} else {
return !result
}
}
-167
View File
@@ -6,7 +6,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
"regexp"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -18,7 +17,6 @@ import (
"slices" "slices"
"github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"golang.org/x/oauth2" "golang.org/x/oauth2"
@@ -454,171 +452,6 @@ func (auth *AuthService) LDAPAuthConfigured() bool {
return auth.ldap != nil return auth.ldap != nil
} }
func (auth *AuthService) IsUserAllowed(c *gin.Context, context model.UserContext, acls *model.App) bool {
if acls == nil {
return true
}
if context.Provider == model.ProviderOAuth {
auth.log.App.Debug().Msg("User is an OAuth user, checking OAuth whitelist")
return utils.CheckFilter(acls.OAuth.Whitelist, context.OAuth.Email)
}
if acls.Users.Block != "" {
auth.log.App.Debug().Msg("Checking users block list")
if utils.CheckFilter(acls.Users.Block, context.GetUsername()) {
return false
}
}
auth.log.App.Debug().Msg("Checking users allow list")
return utils.CheckFilter(acls.Users.Allow, context.GetUsername())
}
func (auth *AuthService) IsInOAuthGroup(c *gin.Context, context model.UserContext, acls *model.App) bool {
if acls == nil {
return true
}
if !context.IsOAuth() {
auth.log.App.Debug().Msg("User is not an OAuth user, skipping OAuth group check")
return false
}
if _, ok := model.OverrideProviders[context.OAuth.ID]; ok {
auth.log.App.Debug().Str("provider", context.OAuth.ID).Msg("Provider override detected, skipping group check")
return true
}
for _, userGroup := range context.OAuth.Groups {
if utils.CheckFilter(acls.OAuth.Groups, strings.TrimSpace(userGroup)) {
auth.log.App.Trace().Str("group", userGroup).Str("required", acls.OAuth.Groups).Msg("User group matched")
return true
}
}
auth.log.App.Debug().Msg("No groups matched")
return false
}
func (auth *AuthService) IsInLDAPGroup(c *gin.Context, context model.UserContext, acls *model.App) bool {
if acls == nil {
return true
}
if !context.IsLDAP() {
auth.log.App.Debug().Msg("User is not an LDAP user, skipping LDAP group check")
return false
}
for _, userGroup := range context.LDAP.Groups {
if utils.CheckFilter(acls.LDAP.Groups, strings.TrimSpace(userGroup)) {
auth.log.App.Trace().Str("group", userGroup).Str("required", acls.LDAP.Groups).Msg("User group matched")
return true
}
}
auth.log.App.Debug().Msg("No groups matched")
return false
}
func (auth *AuthService) IsAuthEnabled(uri string, acls *model.App) (bool, error) {
if acls == nil {
return true, nil
}
// Check for block list
if acls.Path.Block != "" {
regex, err := regexp.Compile(acls.Path.Block)
if err != nil {
return true, err
}
if !regex.MatchString(uri) {
return false, nil
}
}
// Check for allow list
if acls.Path.Allow != "" {
regex, err := regexp.Compile(acls.Path.Allow)
if err != nil {
return true, err
}
if regex.MatchString(uri) {
return false, nil
}
}
return true, nil
}
func (auth *AuthService) CheckIP(ip string, acls *model.App) bool {
if acls == nil {
return true
}
// Merge the global and app IP filter
blockedIps := append(auth.config.Auth.IP.Block, acls.IP.Block...)
allowedIPs := append(auth.config.Auth.IP.Allow, acls.IP.Allow...)
for _, blocked := range blockedIps {
res, err := utils.FilterIP(blocked, ip)
if err != nil {
auth.log.App.Warn().Err(err).Str("item", blocked).Msg("Invalid IP/CIDR in block list")
continue
}
if res {
auth.log.App.Debug().Str("ip", ip).Str("item", blocked).Msg("IP is in block list, denying access")
return false
}
}
for _, allowed := range allowedIPs {
res, err := utils.FilterIP(allowed, ip)
if err != nil {
auth.log.App.Warn().Err(err).Str("item", allowed).Msg("Invalid IP/CIDR in allow list")
continue
}
if res {
auth.log.App.Debug().Str("ip", ip).Str("item", allowed).Msg("IP is in allow list, allowing access")
return true
}
}
if len(allowedIPs) > 0 {
auth.log.App.Debug().Str("ip", ip).Msg("IP not in allow list, denying access")
return false
}
auth.log.App.Debug().Str("ip", ip).Msg("IP not in any block or allow list, allowing access by default")
return true
}
func (auth *AuthService) IsBypassedIP(ip string, acls *model.App) bool {
if acls == nil {
return false
}
for _, bypassed := range acls.IP.Bypass {
res, err := utils.FilterIP(bypassed, ip)
if err != nil {
auth.log.App.Warn().Err(err).Str("item", bypassed).Msg("Invalid IP/CIDR in bypass list")
continue
}
if res {
auth.log.App.Debug().Str("ip", ip).Str("item", bypassed).Msg("IP is in bypass list, skipping authentication")
return true
}
}
auth.log.App.Debug().Str("ip", ip).Msg("IP not in bypass list, proceeding with authentication")
return false
}
func (auth *AuthService) NewOAuthSession(serviceName string, params OAuthURLParams) (string, OAuthPendingSession, error) { func (auth *AuthService) NewOAuthSession(serviceName string, params OAuthURLParams) (string, OAuthPendingSession, error) {
auth.ensureOAuthSessionLimit() auth.ensureOAuthSessionLimit()
+5 -5
View File
@@ -296,11 +296,6 @@ func (service *OIDCService) ValidateAuthorizeParams(req AuthorizeRequest) error
if !ok { if !ok {
return errors.New("access_denied") return errors.New("access_denied")
} }
// Redirect URI to verify that it's trusted
if !slices.Contains(client.TrustedRedirectURIs, req.RedirectURI) {
return errors.New("invalid_request_uri")
}
// Scopes // Scopes
scopes := strings.Split(req.Scope, " ") scopes := strings.Split(req.Scope, " ")
@@ -323,6 +318,11 @@ func (service *OIDCService) ValidateAuthorizeParams(req AuthorizeRequest) error
return errors.New("unsupported_response_type") return errors.New("unsupported_response_type")
} }
// Redirect URI
if !slices.Contains(client.TrustedRedirectURIs, req.RedirectURI) {
return errors.New("invalid_request_uri")
}
// PKCE code challenge method if set // PKCE code challenge method if set
if req.CodeChallenge != "" && req.CodeChallengeMethod != "" { if req.CodeChallenge != "" && req.CodeChallengeMethod != "" {
if req.CodeChallengeMethod != "S256" && req.CodeChallengeMethod != "plain" { if req.CodeChallengeMethod != "S256" && req.CodeChallengeMethod != "plain" {
+29
View File
@@ -40,6 +40,9 @@ func CreateTestConfigs(t *testing.T) (model.Config, model.RuntimeConfig) {
SessionExpiry: 10, SessionExpiry: 10,
LoginTimeout: 10, LoginTimeout: 10,
LoginMaxRetries: 3, LoginMaxRetries: 3,
ACLs: model.ACLsConfig{
Policy: "allow",
},
}, },
Database: model.DatabaseConfig{ Database: model.DatabaseConfig{
Path: filepath.Join(tempDir, "test.db"), Path: filepath.Join(tempDir, "test.db"),
@@ -48,6 +51,32 @@ func CreateTestConfigs(t *testing.T) (model.Config, model.RuntimeConfig) {
Enabled: true, Enabled: true,
Path: filepath.Join(tempDir, "resources"), Path: filepath.Join(tempDir, "resources"),
}, },
Apps: map[string]model.App{
"app_path_allow": {
Config: model.AppConfig{
Domain: "path-allow.example.com",
},
Path: model.AppPath{
Allow: "/allowed",
},
},
"app_user_allow": {
Config: model.AppConfig{
Domain: "user-allow.example.com",
},
Users: model.AppUsers{
Allow: "testuser",
},
},
"ip_bypass": {
Config: model.AppConfig{
Domain: "ip-bypass.example.com",
},
IP: model.AppIP{
Bypass: []string{"10.10.10.10"},
},
},
},
} }
passwd, err := bcrypt.GenerateFromPassword([]byte("password"), bcrypt.DefaultCost) passwd, err := bcrypt.GenerateFromPassword([]byte("password"), bcrypt.DefaultCost)