Compare commits

...

16 Commits

Author SHA1 Message Date
Stavros
673381dae6 fix: use correct forwardauth address 2025-03-14 20:36:12 +02:00
Stavros
9747ea8014 chore: rename dockerfiles 2025-03-14 17:49:10 +02:00
Stavros
cb2521f3d9 tests: fix api tests 2025-03-14 17:43:16 +02:00
Stavros
d859f74a10 refactor: split app context and user context 2025-03-14 17:36:22 +02:00
Stavros
7e39cb0dfe chore: use v3 tag instead of latest 2025-03-10 22:15:35 +02:00
Stavros
be836c296c chore: remove auth response headers from example compose since it's an advanced feature 2025-03-10 22:11:32 +02:00
Stavros
6f6b1f4862 chore: use v prefix in versioning 2025-03-10 21:44:41 +02:00
Stavros
ada3531565 chore: use correct timezone in discohook 2025-03-10 21:25:49 +02:00
Stavros
939ed26fd0 chore: update discohook date 2025-03-10 21:22:01 +02:00
Stavros
da0641c115 chore: migrate to new domain 2025-03-10 21:21:31 +02:00
Stavros
753b95baff docs: change warning to note in contributing 2025-03-10 18:50:43 +02:00
Stavros
9dd9829058 docs: update contributing 2025-03-10 18:47:54 +02:00
Stavros
ec67ea3807 refactor: detect if using browser or headless client for better responses 2025-03-10 17:02:23 +02:00
Stavros
3649d0d84e fix: allow oauth resource when oauth whitelist is empty 2025-03-10 16:22:32 +02:00
Stavros
c0ffe3faf4 refactor: release multiple semver tags 2025-03-10 16:02:48 +02:00
Stavros
ad718d3ef8 refactor: migrate release workflow to native runners 2025-03-09 19:24:52 +02:00
30 changed files with 481 additions and 458 deletions

30
.env.example Normal file
View File

@@ -0,0 +1,30 @@
PORT=3000
ADDRESS=0.0.0.0
SECRET=app_secret
SECRET_FILE=app_secret_file
APP_URL=http://localhost:3000
USERS=your_user_password_hash
USERS_FILE=users_file
COOKIE_SECURE=false
GITHUB_CLIENT_ID=github_client_id
GITHUB_CLIENT_SECRET=github_client_secret
GITHUB_CLIENT_SECRET_FILE=github_client_secret_file
GOOGLE_CLIENT_ID=google_client_id
GOOGLE_CLIENT_SECRET=google_client_secret
GOOGLE_CLIENT_SECRET_FILE=google_client_secret_file
TAILSCALE_CLIENT_ID=tailscale_client_id
TAILSCALE_CLIENT_SECRET=tailscale_client_secret
TAILSCALE_CLIENT_SECRET_FILE=tailscale__client_secret_file
GENERIC_CLIENT_ID=generic_client_id
GENERIC_CLIENT_SECRET=generic_client_secret
GENERIC_CLIENT_SECRET_FILE=generic_client_secret_file
GENERIC_SCOPES=generic_scopes
GENERIC_AUTH_URL=generic_auth_url
GENERIC_TOKEN_URL=generic_token_url
GENERIC_USER_URL=generic_user_url
DISABLE_CONTINUE=false
OAUTH_WHITELIST=
GENERIC_NAME=My OAuth
SESSION_EXPIRY=7200
LOG_LEVEL=0
APP_TITLE=Tinyauth SSO

View File

@@ -1,58 +0,0 @@
name: Alpha Release
on:
workflow_dispatch:
inputs:
alpha:
description: "Alpha version (e.g. 1, 2, 3)"
required: true
jobs:
get-tag:
runs-on: ubuntu-latest
outputs:
tag: ${{ steps.tag.outputs.name }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Get tag
id: tag
run: echo "name=$(cat internal/assets/version)-alpha.${{ github.event.inputs.alpha }}" >> $GITHUB_OUTPUT
build-docker:
needs: get-tag
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
platforms: linux/arm64, linux/amd64
tags: ghcr.io/${{ github.repository_owner }}/tinyauth:${{ needs.get-tag.outputs.tag }}
alpha-release:
needs: [get-tag, build-docker]
runs-on: ubuntu-latest
steps:
- name: Create alpha release
uses: softprops/action-gh-release@v2
with:
prerelease: true
tag_name: ${{ needs.get-tag.outputs.tag }}

View File

@@ -1,58 +0,0 @@
name: Beta Release
on:
workflow_dispatch:
inputs:
alpha:
description: "Beta version (e.g. 1, 2, 3)"
required: true
jobs:
get-tag:
runs-on: ubuntu-latest
outputs:
tag: ${{ steps.tag.outputs.name }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Get tag
id: tag
run: echo "name=$(cat internal/assets/version)-beta.${{ github.event.inputs.alpha }}" >> $GITHUB_OUTPUT
build-docker:
needs: get-tag
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
platforms: linux/arm64, linux/amd64
tags: ghcr.io/${{ github.repository_owner }}/tinyauth:${{ needs.get-tag.outputs.tag }}
beta-release:
needs: [get-tag, build-docker]
runs-on: ubuntu-latest
steps:
- name: Create beta release
uses: softprops/action-gh-release@v2
with:
prerelease: true
tag_name: ${{ needs.get-tag.outputs.tag }}

View File

@@ -1,137 +0,0 @@
name: Experimental Build
on:
workflow_dispatch:
push:
tags:
- "v*"
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository_owner }}/tinyauth
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v6
id: build
with:
platforms: linux/amd64
labels: ${{ steps.meta.outputs.labels }}
tags: ghcr.io/${{ github.repository_owner }}/tinyauth
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
- name: Export digest
run: |
mkdir -p ${{ runner.temp }}/digests
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-linux-amd64
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
build-arm:
runs-on: ubuntu-24.04-arm
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository_owner }}/tinyauth
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v6
id: build
with:
platforms: linux/arm64
labels: ${{ steps.meta.outputs.labels }}
tags: ghcr.io/${{ github.repository_owner }}/tinyauth
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
- name: Export digest
run: |
mkdir -p ${{ runner.temp }}/digests
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-linux-arm64
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubuntu-latest
needs:
- build
- build-arm
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digests-*
merge-multiple: true
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository_owner }}/tinyauth
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Create manifest list and push
working-directory: ${{ runner.temp }}/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf 'ghcr.io/${{ github.repository_owner }}/tinyauth@sha256:%s ' *)

View File

@@ -1,32 +1,22 @@
name: Release
on:
workflow_dispatch:
push:
tags:
- "v*"
jobs:
get-tag:
runs-on: ubuntu-latest
outputs:
tag: ${{ steps.tag.outputs.name }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Get tag
id: tag
run: echo "name=$(cat internal/assets/version)" >> $GITHUB_OUTPUT
build-docker:
needs: get-tag
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository_owner }}/tinyauth
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
@@ -35,21 +25,112 @@ jobs:
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v6
id: build
with:
context: .
push: true
platforms: linux/arm64, linux/amd64
tags: ghcr.io/${{ github.repository_owner }}/tinyauth:${{ needs.get-tag.outputs.tag }}, ghcr.io/${{ github.repository_owner }}/tinyauth:latest
platforms: linux/amd64
labels: ${{ steps.meta.outputs.labels }}
tags: ghcr.io/${{ github.repository_owner }}/tinyauth
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
release:
needs: [get-tag, build-docker]
runs-on: ubuntu-latest
steps:
- name: Create release
uses: softprops/action-gh-release@v2
- name: Export digest
run: |
mkdir -p ${{ runner.temp }}/digests
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
prerelease: false
make_latest: false
tag_name: ${{ needs.get-tag.outputs.tag }}
name: digests-linux-amd64
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
build-arm:
runs-on: ubuntu-24.04-arm
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository_owner }}/tinyauth
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v6
id: build
with:
platforms: linux/arm64
labels: ${{ steps.meta.outputs.labels }}
tags: ghcr.io/${{ github.repository_owner }}/tinyauth
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
- name: Export digest
run: |
mkdir -p ${{ runner.temp }}/digests
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-linux-arm64
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubuntu-latest
needs:
- build
- build-arm
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digests-*
merge-multiple: true
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository_owner }}/tinyauth
tags: |
type=semver,pattern=v{{version}}
type=semver,pattern=v{{major}}
type=semver,pattern=v{{major}}.{{minor}}
- name: Create manifest list and push
working-directory: ${{ runner.temp }}/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf 'ghcr.io/${{ github.repository_owner }}/tinyauth@sha256:%s ' *)

8
.gitignore vendored
View File

@@ -18,4 +18,10 @@ secret_oauth.txt
.vscode
# apple stuff
.DS_Store
.DS_Store
# env
.env
# tmp directory
tmp

View File

@@ -1,6 +1,6 @@
# Contributing
Contributing is relatively easy.
Contributing is relatively easy, you just need to follow the steps carefully and you will be up and running with a development server in less than 5 minutes.
## Requirements
@@ -20,62 +20,37 @@ cd tinyauth
## Install requirements
Now it's time to install the requirements, firstly the Go ones:
Although you will not need the requirements in your machine since the development will happen in docker, I still recommend to install them because this way you will not have errors, to install the go requirements, run:
```sh
go mod download
go mod tidy
```
And now the site ones:
You also need to download the frontend requirements, this can be done like so:
```sh
cd site
bun i
cd site/
bun install
```
## Developing locally
## Create your `.env` file
In order to develop the app locally you need to build the frontend and copy it to the assets folder in order for Go to embed it and host it. In order to build the frontend run:
In order to ocnfigure the app you need to create an environment file, this can be done by copying the `.env.example` file to `.env` and modifying the environment variables inside to suit your needs.
```sh
cd site
bun run build
cd ..
```
## Developing
Copy it to the assets folder:
```sh
rm -rf internal/assets/dist
cp -r site/dist internal/assets/dist
```
Finally either run the app with:
```sh
go run main.go
```
Or build it with:
```sh
go build
```
> [!WARNING]
> Make sure you have set the environment variables when running outside of docker else the app will fail.
## Developing in docker
My recommended development method is docker so I can test that both my image works and that the app responds correctly to traefik. In my setup I have set these two DNS records in my DNS server:
I have designed the development workflow to be entirely in docker, this is because it will directly work with traefik and you will not need to do any building in your host machine. The recommended development setup is to have a subdomain pointing to your machine like this:
```
*.dev.local -> 127.0.0.1
dev.local -> 127.0.0.1
*.dev.example.com -> 127.0.0.1
dev.example.com -> 127.0.0.1
```
Then I can just make sure the domains are correct in the example docker compose file and do:
Then you can just make sure the domains are correct in the example docker compose file and run:
```sh
docker compose -f docker-compose.dev.yml up --build
```
> [!NOTE]
> I would recommend copying the example `docker-compose.dev.yml` into a `docker-compose.test.yml` file, so as you don't accidentally commit any sensitive information.

22
Dockerfile.dev Normal file
View File

@@ -0,0 +1,22 @@
FROM golang:1.23-alpine3.21
WORKDIR /tinyauth
COPY go.mod ./
COPY go.sum ./
RUN go mod download
COPY ./cmd ./cmd
COPY ./internal ./internal
COPY ./main.go ./
COPY ./air.toml ./
RUN mkdir -p ./internal/assets/dist && \
echo "app running" > ./internal/assets/dist/index.html
RUN go install github.com/air-verse/air@v1.61.7
EXPOSE 3000
ENTRYPOINT ["air", "-c", "air.toml"]

View File

@@ -1,25 +0,0 @@
# Build website
web:
cd site; bun run build
# Copy site assets
assets: web
rm -rf internal/assets/dist
mkdir -p internal/assets/dist
cp -r site/dist/* internal/assets/dist
# Run development binary
run: assets
go run main.go
# Test
test:
go test ./...
# Build
build: assets
go build -o tinyauth
# Build no site
build-skip-web:
go build -o tinyauth

View File

@@ -28,11 +28,11 @@ I just made a Discord server for Tinyauth! It is not only for Tinyauth but gener
## Getting Started
You can easily get started with tinyauth by following the guide on the [documentation](https://tinyauth.doesmycode.work/docs/getting-started.html). There is also an available [docker compose file](./docker-compose.example.yml) that has traefik, nginx and tinyauth to demonstrate its capabilities.
You can easily get started with tinyauth by following the guide on the [documentation](https://tinyauth.app/docs/getting-started.html). There is also an available [docker compose file](./docker-compose.example.yml) that has traefik, nginx and tinyauth to demonstrate its capabilities.
## Documentation
You can find documentation and guides on all available configuration of tinyauth [here](https://tinyauth.doesmycode.work).
You can find documentation and guides on all available configuration of tinyauth [here](https://tinyauth.app).
## Contributing

24
air.toml Normal file
View File

@@ -0,0 +1,24 @@
root = "/tinyauth"
tmp_dir = "tmp"
[build]
pre_cmd = ["go mod tidy"]
cmd = "go build -o ./tmp/tinyauth ."
bin = "tmp/tinyauth"
include_ext = ["go"]
exclude_dir = ["internal/assets/dist"]
exclude_regex = [".*_test\\.go"]
stop_on_error = true
[color]
main = "magenta"
watcher = "cyan"
build = "yellow"
runner = "green"
[misc]
clean_on_exit = true
[screen]
clear_on_rebuild = false
keep_scroll = true

View File

@@ -3,8 +3,8 @@
"embeds": [
{
"title": "Welcome to Tinyauth Discord!",
"description": "Tinyauth is a simple authentication middleware that adds simple username/password login or OAuth with Google, Github and any generic OAuth provider to all of your docker apps.\n\n**Information**\n\n• Github: <https://github.com/steveiliop56/tinyauth>\n• Website: <https://tinyauth.doesmycode.work>",
"url": "https://tinyauth.doesmycode.work",
"description": "Tinyauth is a simple authentication middleware that adds simple username/password login or OAuth with Google, Github and any generic OAuth provider to all of your docker apps.\n\n**Information**\n\n• Github: <https://github.com/steveiliop56/tinyauth>\n• Website: <https://tinyauth.app>",
"url": "https://tinyauth.app",
"color": 7002085,
"author": {
"name": "Tinyauth"
@@ -12,11 +12,11 @@
"footer": {
"text": "Updated at"
},
"timestamp": "2025-02-06T22:00:00.000Z",
"timestamp": "2025-03-10T19:00:00.000Z",
"thumbnail": {
"url": "https://github.com/steveiliop56/tinyauth/blob/main/site/public/logo.png?raw=true"
}
}
],
"attachments": []
}
}

View File

@@ -8,27 +8,41 @@ services:
volumes:
- /var/run/docker.sock:/var/run/docker.sock
nginx:
container_name: nginx
image: nginx:latest
whoami:
container_name: whoami
image: traefik/whoami:latest
labels:
traefik.enable: true
traefik.http.routers.nginx.rule: Host(`nginx.dev.local`)
traefik.http.routers.nginx.rule: Host(`whoami.example.com`)
traefik.http.services.nginx.loadbalancer.server.port: 80
traefik.http.routers.nginx.middlewares: tinyauth
tinyauth:
container_name: tinyauth
tinyauth-frontend:
container_name: tinyauth-frontend
build:
context: .
dockerfile: Dockerfile
environment:
- SECRET=some-random-32-chars-string
- APP_URL=http://tinyauth.dev.local
- USERS=user:$$2a$$10$$UdLYoJ5lgPsC0RKqYH/jMua7zIn0g9kPqWmhYayJYLaZQ/FTmH2/u # user:password
dockerfile: site/Dockerfile.dev
volumes:
- ./site/src:/site/src
ports:
- 5173:5173
labels:
traefik.enable: true
traefik.http.routers.tinyauth.rule: Host(`tinyauth.dev.local`)
traefik.http.services.tinyauth.loadbalancer.server.port: 3000
traefik.http.middlewares.tinyauth.forwardauth.address: http://tinyauth:3000/api/auth/traefik
traefik.http.middlewares.tinyauth.forwardauth.authResponseHeaders: Remote-User
traefik.http.routers.tinyauth.rule: Host(`tinyauth.example.com`)
traefik.http.services.tinyauth.loadbalancer.server.port: 5173
tinyauth-backend:
container_name: tinyauth-backend
build:
context: .
dockerfile: Dockerfile.dev
env_file: .env
volumes:
- ./internal:/tinyauth/internal
- ./cmd:/tinyauth/cmd
- ./main.go:/tinyauth/main.go
ports:
- 3000:3000
labels:
traefik.enable: true
traefik.http.middlewares.tinyauth.forwardauth.address: http://tinyauth-backend:3000/api/auth/traefik

View File

@@ -8,18 +8,18 @@ services:
volumes:
- /var/run/docker.sock:/var/run/docker.sock
nginx:
container_name: nginx
image: nginx:latest
whoami:
container_name: whoami
image: traefik/whoami:latest
labels:
traefik.enable: true
traefik.http.routers.nginx.rule: Host(`nginx.example.com`)
traefik.http.routers.nginx.rule: Host(`whoami.example.com`)
traefik.http.services.nginx.loadbalancer.server.port: 80
traefik.http.routers.nginx.middlewares: tinyauth
tinyauth:
container_name: tinyauth
image: ghcr.io/steveiliop56/tinyauth:latest
image: ghcr.io/steveiliop56/tinyauth:v3
environment:
- SECRET=some-random-32-chars-string
- APP_URL=https://tinyauth.example.com
@@ -29,4 +29,3 @@ services:
traefik.http.routers.tinyauth.rule: Host(`tinyauth.example.com`)
traefik.http.services.tinyauth.loadbalancer.server.port: 3000
traefik.http.middlewares.tinyauth.forwardauth.address: http://tinyauth:3000/api/auth/traefik
traefik.http.middlewares.tinyauth.forwardauth.authResponseHeaders: Remote-User

2
go.mod
View File

@@ -7,6 +7,7 @@ require (
github.com/gin-gonic/gin v1.10.0
github.com/go-playground/validator/v10 v10.24.0
github.com/google/go-querystring v1.1.0
github.com/mdp/qrterminal/v3 v3.2.0
github.com/rs/zerolog v1.33.0
github.com/spf13/cobra v1.8.1
github.com/spf13/viper v1.19.0
@@ -15,7 +16,6 @@ require (
require (
github.com/containerd/log v0.1.0 // indirect
github.com/mdp/qrterminal/v3 v3.2.0 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/morikuni/aec v1.0.0 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect

View File

@@ -131,18 +131,24 @@ func (api *API) SetupRoutes() {
return
}
log.Debug().Interface("proxy", proxy.Proxy).Msg("Got proxy")
// Check if the request is coming from a browser (tools like curl/bruno use */* and they don't include the text/html)
isBrowser := strings.Contains(c.Request.Header.Get("Accept"), "text/html")
// Check if using basic auth
_, _, basicAuth := c.Request.BasicAuth()
if isBrowser {
log.Debug().Msg("Request is most likely coming from a browser")
} else {
log.Debug().Msg("Request is most likely not coming from a browser")
}
log.Debug().Interface("proxy", proxy.Proxy).Msg("Got proxy")
// Check if auth is enabled
authEnabled, authEnabledErr := api.Auth.AuthEnabled(c)
// Handle error
if authEnabledErr != nil {
// Return 500 if nginx is the proxy or if the request is using basic auth
if proxy.Proxy == "nginx" || basicAuth {
// Return 500 if nginx is the proxy or if the request is not coming from a browser
if proxy.Proxy == "nginx" || !isBrowser {
log.Error().Err(authEnabledErr).Msg("Failed to check if auth is enabled")
c.JSON(500, gin.H{
"status": 500,
@@ -186,8 +192,8 @@ func (api *API) SetupRoutes() {
// Check if there was an error
if appAllowedErr != nil {
// Return 500 if nginx is the proxy or if the request is using basic auth
if proxy.Proxy == "nginx" || basicAuth {
// Return 500 if nginx is the proxy or if the request is not coming from a browser
if proxy.Proxy == "nginx" || !isBrowser {
log.Error().Err(appAllowedErr).Msg("Failed to check if app is allowed")
c.JSON(500, gin.H{
"status": 500,
@@ -208,9 +214,11 @@ func (api *API) SetupRoutes() {
if !appAllowed {
log.Warn().Str("username", userContext.Username).Str("host", host).Msg("User not allowed")
// Return 401 if nginx is the proxy or if the request is using an Authorization header
if proxy.Proxy == "nginx" || basicAuth {
c.Header("WWW-Authenticate", "Basic realm=\"tinyauth\"")
// Set WWW-Authenticate header
c.Header("WWW-Authenticate", "Basic realm=\"tinyauth\"")
// Return 401 if nginx is the proxy or if the request is not coming from a browser
if proxy.Proxy == "nginx" || !isBrowser {
c.JSON(401, gin.H{
"status": 401,
"message": "Unauthorized",
@@ -252,9 +260,11 @@ func (api *API) SetupRoutes() {
// The user is not logged in
log.Debug().Msg("Unauthorized")
// Return 401 if nginx is the proxy or if the request is using an Authorization header
if proxy.Proxy == "nginx" || basicAuth {
c.Header("WWW-Authenticate", "Basic realm=\"tinyauth\"")
// Set www-authenticate header
c.Header("WWW-Authenticate", "Basic realm=\"tinyauth\"")
// Return 401 if nginx is the proxy or if the request is not coming from a browser
if proxy.Proxy == "nginx" || !isBrowser {
c.JSON(401, gin.H{
"status": 401,
"message": "Unauthorized",
@@ -362,7 +372,7 @@ func (api *API) SetupRoutes() {
api.Router.POST("/api/totp", func(c *gin.Context) {
// Create totp struct
var totpReq types.Totp
var totpReq types.TotpRequest
// Bind JSON
err := c.BindJSON(&totpReq)
@@ -451,11 +461,8 @@ func (api *API) SetupRoutes() {
})
})
api.Router.GET("/api/status", func(c *gin.Context) {
log.Debug().Msg("Checking status")
// Get user context
userContext := api.Hooks.UseUserContext(c)
api.Router.GET("/api/app", func(c *gin.Context) {
log.Debug().Msg("Getting app context")
// Get configured providers
configuredProviders := api.Providers.GetConfiguredProviders()
@@ -465,33 +472,48 @@ func (api *API) SetupRoutes() {
configuredProviders = append(configuredProviders, "username")
}
// Fill status struct with data from user context and api config
status := types.Status{
Username: userContext.Username,
IsLoggedIn: userContext.IsLoggedIn,
Oauth: userContext.OAuth,
Provider: userContext.Provider,
// Create app context struct
appContext := types.AppContext{
Status: 200,
Message: "Ok",
ConfiguredProviders: configuredProviders,
DisableContinue: api.Config.DisableContinue,
Title: api.Config.Title,
GenericName: api.Config.GenericName,
TotpPending: userContext.TotpPending,
}
// Return app context
c.JSON(200, appContext)
})
api.Router.GET("/api/user", func(c *gin.Context) {
log.Debug().Msg("Getting user context")
// Get user context
userContext := api.Hooks.UseUserContext(c)
// Create user context response
userContextResponse := types.UserContextResponse{
Status: 200,
IsLoggedIn: userContext.IsLoggedIn,
Username: userContext.Username,
Provider: userContext.Provider,
Oauth: userContext.OAuth,
TotpPending: userContext.TotpPending,
}
// If we are not logged in we set the status to 401 and add the WWW-Authenticate header else we set it to 200
if !userContext.IsLoggedIn {
log.Debug().Msg("Unauthorized")
c.Header("WWW-Authenticate", "Basic realm=\"tinyauth\"")
status.Status = 401
status.Message = "Unauthorized"
userContextResponse.Message = "Unauthorized"
} else {
log.Debug().Interface("userContext", userContext).Strs("configuredProviders", configuredProviders).Bool("disableContinue", api.Config.DisableContinue).Msg("Authenticated")
status.Status = 200
status.Message = "Authenticated"
log.Debug().Interface("userContext", userContext).Msg("Authenticated")
userContextResponse.Message = "Authenticated"
}
// Return data
c.JSON(200, status)
// Return user context
c.JSON(200, userContextResponse)
})
api.Router.GET("/api/oauth/url/:provider", func(c *gin.Context) {
@@ -700,7 +722,12 @@ func (api *API) Run() {
log.Info().Str("address", api.Config.Address).Int("port", api.Config.Port).Msg("Starting server")
// Run server
api.Router.Run(fmt.Sprintf("%s:%d", api.Config.Address, api.Config.Port))
err := api.Router.Run(fmt.Sprintf("%s:%d", api.Config.Address, api.Config.Port))
// Check error
if err != nil {
log.Fatal().Err(err).Msg("Failed to start server")
}
}
// handleError logs the error and redirects to the error page (only meant for stuff the user may access does not apply for login paths)

View File

@@ -2,6 +2,7 @@ package api_test
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
@@ -122,9 +123,9 @@ func TestLogin(t *testing.T) {
}
}
// Test status
func TestStatus(t *testing.T) {
t.Log("Testing status")
// Test user context
func TestUserContext(t *testing.T) {
t.Log("Testing user context")
// Get API
api := getAPI(t)
@@ -133,7 +134,7 @@ func TestStatus(t *testing.T) {
recorder := httptest.NewRecorder()
// Create request
req, err := http.NewRequest("GET", "/api/status", nil)
req, err := http.NewRequest("GET", "/api/user", nil)
// Check if there was an error
if err != nil {
@@ -152,11 +153,31 @@ func TestStatus(t *testing.T) {
// Assert
assert.Equal(t, recorder.Code, http.StatusOK)
// Parse the body
body := recorder.Body.String()
// Read the body of the response
body, bodyErr := io.ReadAll(recorder.Body)
if !strings.Contains(body, "user") {
t.Fatalf("Expected user in body")
// Check if there was an error
if bodyErr != nil {
t.Fatalf("Error getting body: %v", bodyErr)
}
// Unmarshal the body into the user struct
type User struct {
Username string `json:"username"`
}
var user User
jsonErr := json.Unmarshal(body, &user)
// Check if there was an error
if jsonErr != nil {
t.Fatalf("Error unmarshalling body: %v", jsonErr)
}
// We should get the username back
if user.Username != "user" {
t.Fatalf("Expected user, got %s", user.Username)
}
}

View File

@@ -162,7 +162,10 @@ func (auth *Auth) ResourceAllowed(c *gin.Context, context types.UserContext) (bo
// Check if resource is allowed
allowed, allowedErr := auth.Docker.ContainerAction(appId, func(labels types.TinyauthLabels) (bool, error) {
// If the container has an oauth whitelist, check if the user is in it
if context.OAuth && len(labels.OAuthWhitelist) != 0 {
if context.OAuth {
if len(labels.OAuthWhitelist) == 0 {
return true, nil
}
log.Debug().Msg("Checking OAuth whitelist")
if slices.Contains(labels.OAuthWhitelist, context.Username) {
return true, nil

View File

@@ -55,6 +55,7 @@ type Config struct {
SessionExpiry int `mapstructure:"session-expiry"`
LogLevel int8 `mapstructure:"log-level" validate:"min=-1,max=5"`
Title string `mapstructure:"app-title"`
EnvFile string `mapstructure:"env-file"`
}
// UserContext is the context for the user
@@ -138,22 +139,28 @@ type Proxy struct {
Proxy string `uri:"proxy" binding:"required"`
}
// Status response
type Status struct {
// User Context response is the response for the user context endpoint
type UserContextResponse struct {
Status int `json:"status"`
Message string `json:"message"`
IsLoggedIn bool `json:"isLoggedIn"`
Username string `json:"username"`
Provider string `json:"provider"`
Oauth bool `json:"oauth"`
TotpPending bool `json:"totpPending"`
}
// App Context is the response for the app context endpoint
type AppContext struct {
Status int `json:"status"`
Message string `json:"message"`
IsLoggedIn bool `json:"isLoggedIn"`
Username string `json:"username"`
Provider string `json:"provider"`
Oauth bool `json:"oauth"`
ConfiguredProviders []string `json:"configuredProviders"`
DisableContinue bool `json:"disableContinue"`
Title string `json:"title"`
GenericName string `json:"genericName"`
TotpPending bool `json:"totpPending"`
}
// Totp request
type Totp struct {
// Totp request is the request for the totp endpoint
type TotpRequest struct {
Code string `json:"code"`
}

23
site/Dockerfile.dev Normal file
View File

@@ -0,0 +1,23 @@
FROM oven/bun:1.1.45-alpine
WORKDIR /site
COPY ./site/package.json ./
COPY ./site/bun.lockb ./
RUN bun install
COPY ./site/public ./public
COPY ./site/src ./src
COPY ./site/eslint.config.js ./
COPY ./site/index.html ./
COPY ./site/tsconfig.json ./
COPY ./site/tsconfig.app.json ./
COPY ./site/tsconfig.node.json ./
COPY ./site/vite.config.ts ./
COPY ./site/postcss.config.cjs ./
EXPOSE 5173
ENTRYPOINT ["bun", "run", "dev"]

View File

@@ -0,0 +1,42 @@
import { useQuery } from "@tanstack/react-query";
import React, { createContext, useContext } from "react";
import axios from "axios";
import { AppContextSchemaType } from "../schemas/app-context-schema";
const AppContext = createContext<AppContextSchemaType | null>(null);
export const AppContextProvider = ({
children,
}: {
children: React.ReactNode;
}) => {
const {
data: userContext,
isLoading,
error,
} = useQuery({
queryKey: ["appContext"],
queryFn: async () => {
const res = await axios.get("/api/app");
return res.data;
},
});
if (error && !isLoading) {
throw error;
}
return (
<AppContext.Provider value={userContext}>{children}</AppContext.Provider>
);
};
export const useAppContext = () => {
const context = useContext(AppContext);
if (context === null) {
throw new Error("useAppContext must be used within an AppContextProvider");
}
return context;
};

View File

@@ -17,7 +17,7 @@ export const UserContextProvider = ({
} = useQuery({
queryKey: ["userContext"],
queryFn: async () => {
const res = await axios.get("/api/status");
const res = await axios.get("/api/user");
return res.data;
},
});

View File

@@ -16,6 +16,7 @@ import { NotFoundPage } from "./pages/not-found-page.tsx";
import { UnauthorizedPage } from "./pages/unauthorized-page.tsx";
import { InternalServerError } from "./pages/internal-server-error.tsx";
import { TotpPage } from "./pages/totp-page.tsx";
import { AppContextProvider } from "./context/app-context.tsx";
const queryClient = new QueryClient({
defaultOptions: {
@@ -30,20 +31,22 @@ createRoot(document.getElementById("root")!).render(
<MantineProvider forceColorScheme="dark">
<QueryClientProvider client={queryClient}>
<Notifications />
<UserContextProvider>
<BrowserRouter>
<Routes>
<Route path="/" element={<App />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/totp" element={<TotpPage />} />
<Route path="/logout" element={<LogoutPage />} />
<Route path="/continue" element={<ContinuePage />} />
<Route path="/unauthorized" element={<UnauthorizedPage />} />
<Route path="/error" element={<InternalServerError />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</BrowserRouter>
</UserContextProvider>
<AppContextProvider>
<UserContextProvider>
<BrowserRouter>
<Routes>
<Route path="/" element={<App />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/totp" element={<TotpPage />} />
<Route path="/logout" element={<LogoutPage />} />
<Route path="/continue" element={<ContinuePage />} />
<Route path="/unauthorized" element={<UnauthorizedPage />} />
<Route path="/error" element={<InternalServerError />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</BrowserRouter>
</UserContextProvider>
</AppContextProvider>
</QueryClientProvider>
</MantineProvider>
</StrictMode>,

View File

@@ -5,13 +5,15 @@ import { useUserContext } from "../context/user-context";
import { Layout } from "../components/layouts/layout";
import { ReactNode } from "react";
import { isQueryValid } from "../utils/utils";
import { useAppContext } from "../context/app-context";
export const ContinuePage = () => {
const queryString = window.location.search;
const params = new URLSearchParams(queryString);
const redirectUri = params.get("redirect_uri") ?? "";
const { isLoggedIn, disableContinue } = useUserContext();
const { isLoggedIn } = useUserContext();
const { disableContinue } = useAppContext();
if (!isLoggedIn) {
return <Navigate to={`/login?redirect_uri=${redirectUri}`} />;

View File

@@ -9,14 +9,15 @@ import { OAuthButtons } from "../components/auth/oauth-buttons";
import { LoginFormValues } from "../schemas/login-schema";
import { LoginForm } from "../components/auth/login-forn";
import { isQueryValid } from "../utils/utils";
import { useAppContext } from "../context/app-context";
export const LoginPage = () => {
const queryString = window.location.search;
const params = new URLSearchParams(queryString);
const redirectUri = params.get("redirect_uri") ?? "";
const { isLoggedIn, configuredProviders, title, genericName } =
useUserContext();
const { isLoggedIn } = useUserContext();
const { configuredProviders, title, genericName } = useAppContext();
const oauthProviders = configuredProviders.filter(
(value) => value !== "username",

View File

@@ -6,9 +6,11 @@ import { useUserContext } from "../context/user-context";
import { Navigate } from "react-router";
import { Layout } from "../components/layouts/layout";
import { capitalize } from "../utils/utils";
import { useAppContext } from "../context/app-context";
export const LogoutPage = () => {
const { isLoggedIn, username, oauth, provider, genericName } = useUserContext();
const { isLoggedIn, username, oauth, provider } = useUserContext();
const { genericName } = useAppContext();
if (!isLoggedIn) {
return <Navigate to="/login" />;
@@ -45,8 +47,9 @@ export const LogoutPage = () => {
</Text>
<Text>
You are currently logged in as <Code>{username}</Code>
{oauth && ` using ${capitalize(provider === "generic" ? genericName : provider)} OAuth`}. Click the button
below to log out.
{oauth &&
` using ${capitalize(provider === "generic" ? genericName : provider)} OAuth`}
. Click the button below to log out.
</Text>
<Button
fullWidth

View File

@@ -6,13 +6,15 @@ import { TotpForm } from "../components/auth/totp-form";
import { useMutation } from "@tanstack/react-query";
import axios from "axios";
import { notifications } from "@mantine/notifications";
import { useAppContext } from "../context/app-context";
export const TotpPage = () => {
const queryString = window.location.search;
const params = new URLSearchParams(queryString);
const redirectUri = params.get("redirect_uri") ?? "";
const { totpPending, isLoggedIn, title } = useUserContext();
const { totpPending, isLoggedIn } = useUserContext();
const { title } = useAppContext();
if (isLoggedIn) {
return <Navigate to={`/logout`} />;

View File

@@ -0,0 +1,10 @@
import { z } from "zod";
export const appContextSchema = z.object({
configuredProviders: z.array(z.string()),
disableContinue: z.boolean(),
title: z.string(),
genericName: z.string(),
});
export type AppContextSchemaType = z.infer<typeof appContextSchema>;

View File

@@ -5,10 +5,6 @@ export const userContextSchema = z.object({
username: z.string(),
oauth: z.boolean(),
provider: z.string(),
configuredProviders: z.array(z.string()),
disableContinue: z.boolean(),
title: z.string(),
genericName: z.string(),
totpPending: z.boolean(),
});

View File

@@ -4,4 +4,14 @@ import react from "@vitejs/plugin-react-swc";
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
host: "0.0.0.0",
proxy: {
"/api": {
target: "http://tinyauth-backend:3000/api",
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ""),
},
}
}
});