Compare commits

...

90 Commits

Author SHA1 Message Date
Stavros
f9ab9a6406 fix: filter oauth whitelist to remove empty strings 2025-02-15 17:23:24 +02:00
Stavros
6f35923735 refactor: use try catch instead of can parse 2025-02-12 18:43:35 +02:00
Stavros
b1dc5cb4cc fix: add check to make sure the url can be parsed 2025-02-11 18:50:33 +02:00
Stavros
3c9bc8c67f fix: use new URL instead of URL.parse 2025-02-11 18:31:31 +02:00
Stavros
b2f4041e09 refactor: handle url queries null values better 2025-02-10 22:12:30 +02:00
Stavros
eb4e157def refactor: small updates in the verify and create subcommands 2025-02-10 21:53:44 +02:00
Stavros
cfe2a1967a refactor: use go's builtin basic auth parser 2025-02-10 21:42:27 +02:00
Stavros
c4ee269283 Merge pull request #33 from robotman4/patch-1
Change forwardauth endpoint in example compose
2025-02-10 21:30:07 +02:00
robotman4
d18fba1ef3 Change forwardauth endpoint in docker-compose.dev.yml 2025-02-10 20:28:01 +01:00
robotman4
acaee5357f Change forwardauth endpoint in example compose 2025-02-10 20:20:52 +01:00
Stavros
38412e1962 tests: add api tests 2025-02-10 19:05:50 +02:00
Stavros
302d9cf2fd chore: add ci to readme 2025-02-10 18:32:47 +02:00
Stavros
caf9cde08f tests: add frontend build to ci 2025-02-10 18:28:06 +02:00
Stavros
2473d3ce34 tests: add simple dist folder 2025-02-10 18:24:16 +02:00
Stavros
d8d347b45f tests: add basic tests for utilities 2025-02-10 18:22:46 +02:00
Stavros
a8da813374 fix: the traefik forward label should be in the tinyauth container 2025-02-09 13:35:23 +02:00
Stavros
6936987c6b chore: add security file 2025-02-08 12:58:37 +02:00
Stavros
9a16737a54 chore: add issue templates 2025-02-08 12:55:46 +02:00
Stavros
ddf40e6d63 chore: add contributing guide from docs 2025-02-08 12:51:31 +02:00
Stavros
9fd1c81f8b chore: add code of conduct 2025-02-08 12:49:45 +02:00
Stavros
567e6f0b5b chore: bump version 2025-02-08 12:44:42 +02:00
Stavros
f7e7dee3da Merge pull request #31 from steveiliop56/chore/comments
chore: add comments to code
2025-02-08 12:40:34 +02:00
Stavros
7a3a463489 chore: add comments to code 2025-02-08 12:33:58 +02:00
Stavros
e09f241364 fix: handle user parse errors correctly 2025-02-07 20:11:16 +02:00
Stavros
d2ee382f92 fix: return json errors when authorization header is present 2025-02-07 20:03:24 +02:00
Stavros
4e8a2443a6 chore: update readme 2025-02-07 19:25:23 +02:00
Stavros
22777a16a1 chore: add discohook data 2025-02-07 19:12:53 +02:00
Stavros
0872556c1a chore: add to do in basic auth 2025-02-07 17:46:13 +02:00
Stavros
daad2abc33 feat: add basic header authorization 2025-02-07 17:08:39 +02:00
Stavros
ce567ae3de feat: add support for nginx/nginx proxy manager (breaking) 2025-02-07 16:36:47 +02:00
Stavros
87393d3c64 feat: add session expiry inside cookie (breaking) 2025-02-05 19:08:23 +02:00
Stavros
97830a309b chore: bump version 2025-02-02 19:36:12 +02:00
Stavros
fe594d2755 fix: do not crash when docker is not connected 2025-02-02 19:34:02 +02:00
Stavros
b3aac26644 chore: update gitignore 2025-02-02 14:28:12 +02:00
Stavros
c37f66abb9 feat: strip go executable for smaller size 2025-02-02 13:31:35 +02:00
Stavros
2c4f086008 chore: bump version 2025-02-01 16:30:31 +02:00
Stavros
6e5f882e0b feat: tailscale oauth 2025-02-01 16:28:39 +02:00
Stavros
99268f80c9 fix: check available providers correctly 2025-02-01 15:29:26 +02:00
Stavros
dcd816b6c6 fix: parse generic oauth provider config correctly 2025-01-31 17:28:59 +02:00
Stavros
381f6ef76f Merge pull request #24 from steveiliop56/feat/docker-labels
Feat/docker labels
2025-01-30 17:14:07 +02:00
Stavros
8a8ba18ded refactor: remove label value from logging 2025-01-30 17:13:38 +02:00
Stavros
29f0a94faf feat: finalize logic 2025-01-30 17:11:31 +02:00
Stavros
6602e8140b wip 2025-01-29 22:06:52 +02:00
Stavros
2385599c80 fix: omit port from cookie domain configuration 2025-01-29 17:55:21 +02:00
Stavros
6f184856f1 chore: bump version 2025-01-28 19:10:36 +02:00
Stavros
e2e3b3bdc6 refactor: use window.location.href for redirects 2025-01-28 19:08:00 +02:00
Stavros
3efcb26db1 refactor: remove sensitive info logging even in debug mode 2025-01-28 17:36:06 +02:00
Stavros
c54267f50d fix: parse users correctly 2025-01-26 22:40:55 +02:00
Stavros
4de12ce5c1 fix: no need to log that the provider is empty 2025-01-26 21:36:41 +02:00
Stavros
0cf0aafc14 fix: configure secrets before config validation 2025-01-26 21:13:26 +02:00
Stavros
80ea43184c chore: update readme 2025-01-26 20:52:35 +02:00
Stavros
3c4dffd479 chore: bump version 2025-01-26 20:52:06 +02:00
Stavros
f19f40f9fc feat: add secret file 2025-01-26 20:47:08 +02:00
Stavros
a243f22ac8 refactor: users are not a requirement when using oauth 2025-01-26 20:45:34 +02:00
Stavros
08d382c981 feat: add debug log level 2025-01-26 20:23:09 +02:00
Stavros
94f7debb10 feat: secrets file 2025-01-26 19:51:58 +02:00
Stavros
3b50d9303b refactor: use cookie store correctly 2025-01-26 19:51:58 +02:00
Stavros
d67133aca7 fix: get correct username from query params 2025-01-26 19:51:58 +02:00
Stavros
989ea8f229 refactor: rename email back to username 2025-01-26 19:51:58 +02:00
Stavros
708006decf refactor: move disable continue screen logic back to the continue screen 2025-01-26 19:51:14 +02:00
Stavros
682a918812 refactor: don't store oauth token in cookie 2025-01-26 11:05:11 +02:00
Stavros
389248cfe1 refactor: change cli about text 2025-01-25 21:11:56 +02:00
Stavros
81d25061df refactor: move disable continue logic in login screen 2025-01-25 21:11:09 +02:00
Stavros
f59697955d chore: update readme 2025-01-25 20:47:26 +02:00
Stavros
47d8f1e5aa chore: update utility commands 2025-01-25 20:36:04 +02:00
Stavros
e8d2e059a9 fix: pass cookie expiry to api config 2025-01-25 20:00:07 +02:00
Stavros
2c7a3fc801 refactor: simplify cookie secure logic 2025-01-25 16:29:18 +02:00
Stavros
61fffb9708 chore: disable cgo 2025-01-25 16:16:42 +02:00
Stavros
9d2aef163b chore: rename whitelist to oauth whitelist 2025-01-25 15:32:46 +02:00
Stavros
cc480085c5 feat: custom cookie age 2025-01-25 15:29:17 +02:00
Stavros
2c7144937a chore: update readme 2025-01-25 13:20:09 +02:00
Stavros
c7ec788ce1 fix: split generic scopes string to array 2025-01-25 10:25:20 +02:00
Stavros
96a373a794 feat: internal server error page 2025-01-24 20:31:10 +02:00
Stavros
c5a8639822 feat: oauth email whitelist 2025-01-24 20:17:08 +02:00
Stavros
b87cb54d91 refactor: rename generic user info url to generic user url 2025-01-24 19:41:44 +02:00
Stavros
f61b6dbad4 refactor: log errors 2025-01-24 18:24:20 +02:00
Stavros
35854f5ce4 chore: bump version 2025-01-24 18:17:05 +02:00
Stavros
c59aaa5600 feat: add option to disable continue screen 2025-01-24 18:16:23 +02:00
Stavros
085b1492cc fix: ignore new lines in password file 2025-01-24 17:51:32 +02:00
Stavros
a19f3589f8 Merge pull request #5 from steveiliop56/feat/oauth
feat/oauth
2025-01-24 17:45:55 +02:00
Stavros
e88ec22ce3 fix: fix spacing in logout screen 2025-01-24 17:43:12 +02:00
Stavros
90f4c3c980 feat: generic oauth 2025-01-24 17:13:51 +02:00
Stavros
f487e25ac5 refactor: remove microsoft icon 2025-01-24 16:55:03 +02:00
Stavros
d4eca52b12 feat: google oauth 2025-01-24 16:29:21 +02:00
Stavros
433e71bd50 feat: persist sessions and auto redirect to app 2025-01-24 15:29:46 +02:00
Stavros
80d25551e0 wip 2025-01-23 19:16:35 +02:00
Stavros
143b13af2c refactor: remove short flags 2025-01-22 21:50:01 +02:00
Stavros
4457d6f525 feat: add cookie secure option in the cli 2025-01-22 21:37:59 +02:00
Stavros
b901744e03 fix: use password hash instead of password when verifying 2025-01-22 20:18:03 +02:00
Stavros
61a7400cf1 refactor: change cmd to entrypoint 2025-01-22 17:26:46 +02:00
49 changed files with 3295 additions and 326 deletions

37
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,37 @@
---
name: Bug report
about: Create a report to help improve Tinyauth
title: "[BUG]"
labels: bug
assignees: steveiliop56
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Logs**
Please include the Tinyauth logs below, make sure to not include sensitive info.
**Device (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Tinyauth [e.g. v2.1.1]
- Docker [e.g. 27.3.1]
**
**Additional context**
Add any other context about the problem here.

View File

@@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: "[FEATURE]"
labels: enhancement
assignees: steveiliop56
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

42
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,42 @@
name: Tinyauth CI
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "^1.23.2"
- name: Setup bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install frontend dependencies
run: |
cd site
bun install
- name: Build frontend
run: |
cd site
bun run build
- name: Copy frontend
run: |
cp -r site/dist internal/assets/dist
- name: Run tests
run: go test -v ./...

14
.gitignore vendored
View File

@@ -5,7 +5,17 @@ internal/assets/dist
tinyauth
# test docker compose
docker-compose.test.yml
docker-compose.test*
# users file
users.txt
users.txt
# secret test file
secret.txt
secret_oauth.txt
# vscode
.vscode
# apple stuff
.DS_Store

128
CODE_OF_CONDUCT.md Normal file
View File

@@ -0,0 +1,128 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.

81
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,81 @@
# Contributing
Contributing is relatively easy.
## Requirements
- Bun
- Golang v1.23.2 and above
- Git
- Docker
## Cloning the repository
You firstly need to clone the repository with:
```sh
git clone https://github.com/steveiliop56/tinyauth
cd tinyauth
```
## Install requirements
Now it's time to install the requirements, firstly the Go ones:
```sh
go mod download
```
And now the site ones:
```sh
cd site
bun i
```
## Developing locally
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:
```sh
cd site
bun run build
cd ..
```
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:
```
*.dev.local -> 127.0.0.1
dev.local -> 127.0.0.1
```
Then I can just make sure the domains are correct in the example docker compose file and do:
```sh
docker compose -f docker-compose.dev.yml up --build
```

View File

@@ -35,10 +35,10 @@ COPY ./cmd ./cmd
COPY ./internal ./internal
COPY --from=site-builder /site/dist ./internal/assets/dist
RUN go build
RUN CGO_ENABLED=0 go build -ldflags "-s -w"
# Runner
FROM busybox:1.37-musl AS runner
FROM alpine:3.21 AS runner
WORKDIR /tinyauth
@@ -46,4 +46,4 @@ COPY --from=builder /tinyauth/tinyauth ./
EXPOSE 3000
CMD ["./tinyauth"]
ENTRYPOINT ["./tinyauth"]

View File

@@ -1,36 +1,50 @@
# Tinyauth - The simplest way to protect your apps with a login screen
<div align="center">
<img alt="Tinyauth" title="Tinyauth" width="256" src="site/public/logo.png">
<h1>Tinyauth</h1>
<p>The easiest way to secure your apps with a login screen.</p>
</div>
Tinyauth is an extremely simple traefik middleware that adds a login screen to all of your apps that are using the traefik reverse proxy. Tinyauth is configurable through environment variables and it is only 20MB in size.
<div align="center">
<img alt="License" src="https://img.shields.io/github/license/steveiliop56/tinyauth">
<img alt="Release" src="https://img.shields.io/github/v/release/steveiliop56/tinyauth">
<img alt="Commit activity" src="https://img.shields.io/github/commit-activity/w/steveiliop56/tinyauth">
<img alt="Issues" src="https://img.shields.io/github/issues/steveiliop56/tinyauth">
<img alt="Tinyauth CI" src="https://github.com/steveiliop56/tinyauth/actions/workflows/ci.yml/badge.svg">
</div>
## Getting started
<br />
Tinyauth is extremely easy to run since it's shipped as a docker container. The guide on how to get started is available on the website [here](https://tinyauth.doesmycode.work/).
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. It is made for traefik but it can be extended to work with all reverse proxies like caddy and nginx.
## FAQ
> [!WARNING]
> Tinyauth is in active development and configuration may change often. Please make sure to carefully read the release notes before updating.
### Why?
> [!NOTE]
> Tinyauth is intended for homelab use and it is not made for production use cases. If you are looking for something production ready please use [authentik](https://goauthentik.io).
Why make this project? Well, we all know that more powerful alternatives like authentik and authelia exist, but when I tried to use them, I felt overwhelmed with all the configration options and environment variables I had to configure in order for them to work. So, I decided to make a small alternative in Go to both test my skills and cover my simple login screen needs.
## Discord
### Is this secure?
I just made a Discord server for Tinyauth! It is not only for Tinyauth but general self-hosting because I just like chatting with people! The link is [here](https://discord.gg/gWpzrksk), see you there!
Probably, the sessions are managed with the gin sessions package so it should be very secure. It is definitely not made for production but it could easily serve as a simple login screen to all of your homelab apps.
## Getting Started
### Do I need to login every time?
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.
No, when you login, tinyauth sets a `tinyauth` cookie in your browser that applies to all of the subdomains of your domain.
## Documentation
## License
Tinyauth is licensed under the GNU General Public License v3.0. TL;DR — You may copy, distribute and modify the software as long as you track changes/dates in source files. Any modifications to or software including (via compiler) GPL-licensed code must also be made available under the GPL along with build & install instructions.
You can find documentation and guides on all available configuration of tinyauth [here](https://tinyauth.doesmycode.work).
## Contributing
Any contributions to the codebase are welcome! I am not a cybersecurity person so my code may have a security issue, if you find something that could be used to exploit and bypass tinyauth please let me know as soon as possible so I can fix it.
All contributions to the codebase are welcome! If you have any recommendations on how to improve security or find a security issue in tinyauth please open an issue or pull request so it can be fixed as soon as possible!
## License
Tinyauth is licensed under the GNU General Public License v3.0. TL;DR — You may copy, distribute and modify the software as long as you track changes/dates in source files. Any modifications to or software including (via compiler) GPL-licensed code must also be made available under the GPL along with build & install instructions. For more information about the license check the [license](./LICENSE) file.
## Acknowledgements
Credits for the logo go to:
Credits for the logo of this app go to:
- Freepik for providing the hat and police badge.
- Renee French for making the gopher logo.
- **Freepik** for providing the police hat and logo.
- **Renee French** for the original gopher logo.

9
SECURITY.md Normal file
View File

@@ -0,0 +1,9 @@
# Security Policy
## Supported Versions
Please always use the latest available Tinyauth version which can be found [here](https://github.com/steveiliop56/tinyauth/releases/latest). Older versions (especially major) may contain security issues which I cannot go back and fix.
## Reporting a Vulnerability
Due to the nature of this app, it needs to be secure. If you find any security issues in the OAuth or login flow of the app please contact me at <steve@doesmycode.work> and include a concise description of the issue. Please do not use the issues section for reporting major security issues.

22
assets/discohook.json Normal file
View File

@@ -0,0 +1,22 @@
{
"content": null,
"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",
"color": 7002085,
"author": {
"name": "Tinyauth"
},
"footer": {
"text": "Updated at"
},
"timestamp": "2025-02-06T22:00:00.000Z",
"thumbnail": {
"url": "https://github.com/steveiliop56/tinyauth/blob/main/site/public/logo.png?raw=true"
}
}
],
"attachments": []
}

View File

@@ -1,16 +1,22 @@
package cmd
import (
"errors"
"os"
"strings"
"time"
cmd "tinyauth/cmd/user"
"tinyauth/internal/api"
"tinyauth/internal/assets"
"tinyauth/internal/auth"
"tinyauth/internal/docker"
"tinyauth/internal/hooks"
"tinyauth/internal/providers"
"tinyauth/internal/types"
"tinyauth/internal/utils"
"github.com/go-playground/validator/v10"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"github.com/spf13/viper"
@@ -18,60 +24,97 @@ import (
var rootCmd = &cobra.Command{
Use: "tinyauth",
Short: "An extremely simple traefik forward auth proxy.",
Long: `Tinyauth is an extremely simple traefik forward-auth login screen that makes securing your apps easy.`,
Short: "The simplest way to protect your apps with a login screen.",
Long: `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.`,
Run: func(cmd *cobra.Command, args []string) {
// Logger
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339}).With().Timestamp().Logger().Level(zerolog.FatalLevel)
// Get config
log.Info().Msg("Parsing config")
var config types.Config
parseErr := viper.Unmarshal(&config)
HandleError(parseErr, "Failed to parse config")
// Secrets
config.Secret = utils.GetSecret(config.Secret, config.SecretFile)
config.GithubClientSecret = utils.GetSecret(config.GithubClientSecret, config.GithubClientSecretFile)
config.GoogleClientSecret = utils.GetSecret(config.GoogleClientSecret, config.GoogleClientSecretFile)
config.GenericClientSecret = utils.GetSecret(config.GenericClientSecret, config.GenericClientSecretFile)
config.TailscaleClientSecret = utils.GetSecret(config.TailscaleClientSecret, config.TailscaleClientSecretFile)
// Validate config
log.Info().Msg("Validating config")
validator := validator.New()
validateErr := validator.Struct(config)
HandleError(validateErr, "Invalid config")
HandleError(validateErr, "Failed to validate config")
// Parse users
// Logger
log.Logger = log.Level(zerolog.Level(config.LogLevel))
log.Info().Str("version", assets.Version).Msg("Starting tinyauth")
// Users
log.Info().Msg("Parsing users")
users, usersErr := utils.GetUsers(config.Users, config.UsersFile)
if config.UsersFile == "" && config.Users == "" {
log.Fatal().Msg("No users provided")
os.Exit(1)
HandleError(usersErr, "Failed to parse users")
if len(users) == 0 && !utils.OAuthConfigured(config) {
HandleError(errors.New("no users or OAuth configured"), "No users or OAuth configured")
}
usersString := config.Users
// Create oauth whitelist
oauthWhitelist := utils.Filter(strings.Split(config.OAuthWhitelist, ","), func(val string) bool {
return val != ""
})
log.Debug().Msg("Parsed OAuth whitelist")
if config.UsersFile != "" {
log.Info().Msg("Reading users from file")
usersFromFile, readErr := utils.GetUsersFromFile(config.UsersFile)
HandleError(readErr, "Failed to read users from file")
usersFromFileParsed := strings.Join(strings.Split(usersFromFile, "\n"), ",")
if usersString != "" {
usersString = usersString + "," + usersFromFileParsed
} else {
usersString = usersFromFileParsed
}
// Create OAuth config
oauthConfig := types.OAuthConfig{
GithubClientId: config.GithubClientId,
GithubClientSecret: config.GithubClientSecret,
GoogleClientId: config.GoogleClientId,
GoogleClientSecret: config.GoogleClientSecret,
TailscaleClientId: config.TailscaleClientId,
TailscaleClientSecret: config.TailscaleClientSecret,
GenericClientId: config.GenericClientId,
GenericClientSecret: config.GenericClientSecret,
GenericScopes: strings.Split(config.GenericScopes, ","),
GenericAuthURL: config.GenericAuthURL,
GenericTokenURL: config.GenericTokenURL,
GenericUserURL: config.GenericUserURL,
AppURL: config.AppURL,
}
users, parseErr := utils.ParseUsers(usersString)
HandleError(parseErr, "Failed to parse users")
log.Debug().Msg("Parsed OAuth config")
// Create docker service
docker := docker.NewDocker()
// Initialize docker
dockerErr := docker.Init()
HandleError(dockerErr, "Failed to initialize docker")
// Create auth service
auth := auth.NewAuth(users)
auth := auth.NewAuth(docker, users, oauthWhitelist, config.SessionExpiry)
// Create OAuth providers service
providers := providers.NewProviders(oauthConfig)
// Initialize providers
providers.Init()
// Create hooks service
hooks := hooks.NewHooks(auth)
hooks := hooks.NewHooks(auth, providers)
// Create API
api := api.NewAPI(types.APIConfig{
Port: config.Port,
Address: config.Address,
Secret: config.Secret,
AppURL: config.AppURL,
CookieSecure: config.CookieSecure,
}, hooks, auth)
Port: config.Port,
Address: config.Address,
Secret: config.Secret,
AppURL: config.AppURL,
CookieSecure: config.CookieSecure,
DisableContinue: config.DisableContinue,
CookieExpiry: config.SessionExpiry,
}, hooks, auth, providers)
// Setup routes
api.Init()
@@ -84,33 +127,83 @@ var rootCmd = &cobra.Command{
func Execute() {
err := rootCmd.Execute()
if err != nil {
log.Fatal().Err(err).Msg("Failed to execute command")
os.Exit(1)
}
HandleError(err, "Failed to execute root command")
}
func HandleError(err error, msg string) {
// If error log it and exit
if err != nil {
log.Fatal().Err(err).Msg(msg)
os.Exit(1)
}
}
func init() {
// Add user command
rootCmd.AddCommand(cmd.UserCmd())
// Read environment variables
viper.AutomaticEnv()
rootCmd.Flags().IntP("port", "p", 3000, "Port to run the server on.")
// Flags
rootCmd.Flags().Int("port", 3000, "Port to run the server on.")
rootCmd.Flags().String("address", "0.0.0.0", "Address to bind the server to.")
rootCmd.Flags().String("secret", "", "Secret to use for the cookie.")
rootCmd.Flags().String("secret-file", "", "Path to a file containing the secret.")
rootCmd.Flags().String("app-url", "", "The tinyauth URL.")
rootCmd.Flags().String("users", "", "Comma separated list of users in the format username:bcrypt-hashed-password.")
rootCmd.Flags().String("users-file", "", "Path to a file containing users in the format username:bcrypt-hashed-password.")
rootCmd.Flags().String("users", "", "Comma separated list of users in the format username:hash.")
rootCmd.Flags().String("users-file", "", "Path to a file containing users in the format username:hash.")
rootCmd.Flags().Bool("cookie-secure", false, "Send cookie over secure connection only.")
rootCmd.Flags().String("github-client-id", "", "Github OAuth client ID.")
rootCmd.Flags().String("github-client-secret", "", "Github OAuth client secret.")
rootCmd.Flags().String("github-client-secret-file", "", "Github OAuth client secret file.")
rootCmd.Flags().String("google-client-id", "", "Google OAuth client ID.")
rootCmd.Flags().String("google-client-secret", "", "Google OAuth client secret.")
rootCmd.Flags().String("google-client-secret-file", "", "Google OAuth client secret file.")
rootCmd.Flags().String("tailscale-client-id", "", "Tailscale OAuth client ID.")
rootCmd.Flags().String("tailscale-client-secret", "", "Tailscale OAuth client secret.")
rootCmd.Flags().String("tailscale-client-secret-file", "", "Tailscale OAuth client secret file.")
rootCmd.Flags().String("generic-client-id", "", "Generic OAuth client ID.")
rootCmd.Flags().String("generic-client-secret", "", "Generic OAuth client secret.")
rootCmd.Flags().String("generic-client-secret-file", "", "Generic OAuth client secret file.")
rootCmd.Flags().String("generic-scopes", "", "Generic OAuth scopes.")
rootCmd.Flags().String("generic-auth-url", "", "Generic OAuth auth URL.")
rootCmd.Flags().String("generic-token-url", "", "Generic OAuth token URL.")
rootCmd.Flags().String("generic-user-url", "", "Generic OAuth user info URL.")
rootCmd.Flags().Bool("disable-continue", false, "Disable continue screen and redirect to app directly.")
rootCmd.Flags().String("oauth-whitelist", "", "Comma separated list of email addresses to whitelist when using OAuth.")
rootCmd.Flags().Int("session-expiry", 86400, "Session (cookie) expiration time in seconds.")
rootCmd.Flags().Int("log-level", 1, "Log level.")
// Bind flags to environment
viper.BindEnv("port", "PORT")
viper.BindEnv("address", "ADDRESS")
viper.BindEnv("secret", "SECRET")
viper.BindEnv("secret-file", "SECRET_FILE")
viper.BindEnv("app-url", "APP_URL")
viper.BindEnv("users", "USERS")
viper.BindEnv("users-file", "USERS_FILE")
viper.BindEnv("cookie-secure", "COOKIE_SECURE")
viper.BindEnv("github-client-id", "GITHUB_CLIENT_ID")
viper.BindEnv("github-client-secret", "GITHUB_CLIENT_SECRET")
viper.BindEnv("github-client-secret-file", "GITHUB_CLIENT_SECRET_FILE")
viper.BindEnv("google-client-id", "GOOGLE_CLIENT_ID")
viper.BindEnv("google-client-secret", "GOOGLE_CLIENT_SECRET")
viper.BindEnv("google-client-secret-file", "GOOGLE_CLIENT_SECRET_FILE")
viper.BindEnv("tailscale-client-id", "TAILSCALE_CLIENT_ID")
viper.BindEnv("tailscale-client-secret", "TAILSCALE_CLIENT_SECRET")
viper.BindEnv("tailscale-client-secret-file", "TAILSCALE_CLIENT_SECRET_FILE")
viper.BindEnv("generic-client-id", "GENERIC_CLIENT_ID")
viper.BindEnv("generic-client-secret", "GENERIC_CLIENT_SECRET")
viper.BindEnv("generic-client-secret-file", "GENERIC_CLIENT_SECRET_FILE")
viper.BindEnv("generic-scopes", "GENERIC_SCOPES")
viper.BindEnv("generic-auth-url", "GENERIC_AUTH_URL")
viper.BindEnv("generic-token-url", "GENERIC_TOKEN_URL")
viper.BindEnv("generic-user-url", "GENERIC_USER_URL")
viper.BindEnv("disable-continue", "DISABLE_CONTINUE")
viper.BindEnv("oauth-whitelist", "OAUTH_WHITELIST")
viper.BindEnv("session-expiry", "SESSION_EXPIRY")
viper.BindEnv("log-level", "LOG_LEVEL")
// Bind flags to viper
viper.BindPFlags(rootCmd.Flags())
}

View File

@@ -3,35 +3,42 @@ package create
import (
"errors"
"fmt"
"os"
"strings"
"github.com/charmbracelet/huh"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"golang.org/x/crypto/bcrypt"
)
var interactive bool
var username string
var password string
var docker bool
// i stands for input
var iUsername string
var iPassword string
var CreateCmd = &cobra.Command{
Use: "create",
Use: "create",
Short: "Create a user",
Long: `Create a user either interactively or by passing flags.`,
Long: `Create a user either interactively or by passing flags.`,
Run: func(cmd *cobra.Command, args []string) {
// Setup logger
log.Logger = log.Level(zerolog.InfoLevel)
// Check if interactive
if interactive {
// Create huh form
form := huh.NewForm(
huh.NewGroup(
huh.NewInput().Title("Username").Value(&username).Validate((func(s string) error {
huh.NewInput().Title("Username").Value(&iUsername).Validate((func(s string) error {
if s == "" {
return errors.New("username cannot be empty")
}
return nil
})),
huh.NewInput().Title("Password").Value(&password).Validate((func(s string) error {
huh.NewInput().Title("Password").Value(&iPassword).Validate((func(s string) error {
if s == "" {
return errors.New("password cannot be empty")
}
@@ -41,43 +48,47 @@ var CreateCmd = &cobra.Command{
),
)
// Use simple theme
var baseTheme *huh.Theme = huh.ThemeBase()
formErr := form.WithTheme(baseTheme).Run()
if formErr != nil {
log.Fatal().Err(formErr).Msg("Form failed")
os.Exit(1)
}
}
if username == "" || password == "" {
// Do we have username and password?
if iUsername == "" || iPassword == "" {
log.Error().Msg("Username and password cannot be empty")
os.Exit(1)
}
log.Info().Str("username", username).Str("password", password).Bool("docker", docker).Msg("Creating user")
log.Info().Str("username", iUsername).Str("password", iPassword).Bool("docker", docker).Msg("Creating user")
passwordByte, passwordErr := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
// Hash password
password, passwordErr := bcrypt.GenerateFromPassword([]byte(iPassword), bcrypt.DefaultCost)
if passwordErr != nil {
log.Fatal().Err(passwordErr).Msg("Failed to hash password")
os.Exit(1)
}
passwordString := string(passwordByte)
// Convert password to string
passwordString := string(password)
// Escape $ for docker
if docker {
passwordString = strings.ReplaceAll(passwordString, "$", "$$")
}
log.Info().Str("user", fmt.Sprintf("%s:%s", username, passwordString)).Msg("User created")
// Log user created
log.Info().Str("user", fmt.Sprintf("%s:%s", iUsername, passwordString)).Msg("User created")
},
}
func init() {
// Flags
CreateCmd.Flags().BoolVarP(&interactive, "interactive", "i", false, "Create a user interactively")
CreateCmd.Flags().BoolVarP(&docker, "docker", "d", false, "Format output for docker")
CreateCmd.Flags().StringVarP(&username, "username", "u", "", "Username")
CreateCmd.Flags().StringVarP(&password, "password", "p", "", "Password")
}
CreateCmd.Flags().BoolVar(&docker, "docker", false, "Format output for docker")
CreateCmd.Flags().StringVar(&iUsername, "username", "", "Username")
CreateCmd.Flags().StringVar(&iPassword, "password", "", "Password")
}

View File

@@ -8,12 +8,17 @@ import (
)
func UserCmd() *cobra.Command {
// Create the user command
userCmd := &cobra.Command{
Use: "user",
Use: "user",
Short: "User utilities",
Long: `Utilities for creating and verifying tinyauth compatible users.`,
Long: `Utilities for creating and verifying tinyauth compatible users.`,
}
// Add subcommands
userCmd.AddCommand(create.CreateCmd)
userCmd.AddCommand(verify.VerifyCmd)
// Return the user command
return userCmd
}
}

View File

@@ -2,42 +2,49 @@ package verify
import (
"errors"
"os"
"strings"
"github.com/charmbracelet/huh"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"golang.org/x/crypto/bcrypt"
)
var interactive bool
var username string
var password string
var docker bool
var user string
// i stands for input
var iUsername string
var iPassword string
var iUser string
var VerifyCmd = &cobra.Command{
Use: "verify",
Use: "verify",
Short: "Verify a user is set up correctly",
Long: `Verify a user is set up correctly meaning that it has a correct password.`,
Long: `Verify a user is set up correctly meaning that it has a correct username and password.`,
Run: func(cmd *cobra.Command, args []string) {
// Setup logger
log.Logger = log.Level(zerolog.InfoLevel)
// Check if interactive
if interactive {
// Create huh form
form := huh.NewForm(
huh.NewGroup(
huh.NewInput().Title("User (user:hash)").Value(&user).Validate((func(s string) error {
huh.NewInput().Title("User (username:hash)").Value(&iUser).Validate((func(s string) error {
if s == "" {
return errors.New("user cannot be empty")
}
return nil
})),
huh.NewInput().Title("Username").Value(&username).Validate((func(s string) error {
huh.NewInput().Title("Username").Value(&iUsername).Validate((func(s string) error {
if s == "" {
return errors.New("username cannot be empty")
}
return nil
})),
huh.NewInput().Title("Password").Value(&password).Validate((func(s string) error {
huh.NewInput().Title("Password").Value(&iPassword).Validate((func(s string) error {
if s == "" {
return errors.New("password cannot be empty")
}
@@ -47,40 +54,40 @@ var VerifyCmd = &cobra.Command{
),
)
// Use simple theme
var baseTheme *huh.Theme = huh.ThemeBase()
formErr := form.WithTheme(baseTheme).Run()
if formErr != nil {
log.Fatal().Err(formErr).Msg("Form failed")
os.Exit(1)
}
}
if username == "" || password == "" || user == "" {
log.Error().Msg("Username, password and user cannot be empty")
os.Exit(1)
// Do we have username, password and user?
if iUsername == "" || iPassword == "" || iUser == "" {
log.Fatal().Msg("Username, password and user cannot be empty")
}
log.Info().Str("user", iUser).Str("username", iUsername).Str("password", iPassword).Bool("docker", docker).Msg("Verifying user")
log.Info().Str("user", user).Str("username", username).Str("password", password).Bool("docker", docker).Msg("Verifying user")
// Split username and password hash
username, hash, ok := strings.Cut(iUser, ":")
userSplit := strings.Split(user, ":")
if userSplit[1] == "" {
log.Error().Msg("User is not formatted correctly")
os.Exit(1)
if !ok {
log.Fatal().Msg("User is not formatted correctly")
}
// Replace $$ with $ if formatted for docker
if docker {
userSplit[1] = strings.ReplaceAll(password, "$$", "$")
hash = strings.ReplaceAll(hash, "$$", "$")
}
verifyErr := bcrypt.CompareHashAndPassword([]byte(userSplit[1]), []byte(password))
// Compare username and password
verifyErr := bcrypt.CompareHashAndPassword([]byte(hash), []byte(iPassword))
if verifyErr != nil || username != userSplit[0] {
log.Error().Msg("Username or password incorrect")
os.Exit(1)
if verifyErr != nil || username != iUsername {
log.Fatal().Msg("Username or password incorrect")
} else {
log.Info().Msg("Verification successful")
}
@@ -88,9 +95,10 @@ var VerifyCmd = &cobra.Command{
}
func init() {
// Flags
VerifyCmd.Flags().BoolVarP(&interactive, "interactive", "i", false, "Create a user interactively")
VerifyCmd.Flags().BoolVar(&docker, "docker", false, "Is the user formatted for docker?")
VerifyCmd.Flags().StringVar(&username, "username", "", "Username")
VerifyCmd.Flags().StringVar(&password, "password", "", "Password")
VerifyCmd.Flags().StringVar(&user, "user", "", "Hash (user:hash combination)")
}
VerifyCmd.Flags().StringVar(&iUsername, "username", "", "Username")
VerifyCmd.Flags().StringVar(&iPassword, "password", "", "Password")
VerifyCmd.Flags().StringVar(&iUser, "user", "", "Hash (username:hash combination)")
}

View File

@@ -7,8 +7,6 @@ services:
- 80:80
volumes:
- /var/run/docker.sock:/var/run/docker.sock
labels:
traefik.http.middlewares.tinyauth.forwardauth.address: http://tinyauth:3000/api/auth
nginx:
container_name: nginx
@@ -32,3 +30,4 @@ services:
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

View File

@@ -7,8 +7,6 @@ services:
- 80:80
volumes:
- /var/run/docker.sock:/var/run/docker.sock
labels:
traefik.http.middlewares.tinyauth.forwardauth.address: http://tinyauth:3000/api/auth
nginx:
container_name: nginx
@@ -30,3 +28,4 @@ services:
traefik.enable: true
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

18
go.mod
View File

@@ -14,6 +14,7 @@ require (
)
require (
github.com/Microsoft/go-winio v0.4.14 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/bytedance/sonic v1.12.7 // indirect
@@ -27,14 +28,22 @@ require (
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
github.com/charmbracelet/x/term v0.2.0 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/docker v27.5.1+incompatible // indirect
github.com/docker/go-connections v0.5.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.0.0 // indirect
github.com/go-logr/logr v1.4.1 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/goccy/go-json v0.10.4 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/gorilla/context v1.1.2 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/gorilla/sessions v1.2.2 // indirect
@@ -51,12 +60,16 @@ require (
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
@@ -67,11 +80,16 @@ require (
github.com/subosito/gotenv v1.6.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect
go.opentelemetry.io/otel v1.24.0 // indirect
go.opentelemetry.io/otel/metric v1.24.0 // indirect
go.opentelemetry.io/otel/trace v1.24.0 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/arch v0.13.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/net v0.34.0 // indirect
golang.org/x/oauth2 v0.25.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/text v0.21.0 // indirect

71
go.sum
View File

@@ -1,3 +1,5 @@
github.com/Microsoft/go-winio v0.4.14 h1:+hMXMk01us9KgxGb7ftKQt2Xpf5hH/yky+TDA+qxleU=
github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
@@ -32,10 +34,20 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v27.5.1+incompatible h1:4PYU5dnBYqRQi0294d1FBECqT9ECWeQAIfE8q4YnPY8=
github.com/docker/docker v27.5.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
@@ -48,6 +60,11 @@ github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E
github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@@ -59,6 +76,8 @@ github.com/go-playground/validator/v10 v10.24.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1
github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM=
github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
@@ -79,10 +98,13 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@@ -108,6 +130,8 @@ github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -119,8 +143,14 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a h1:2MaM6YC3mGu54x+RKAA6JiFFHlHDY1UbkxqppT7wYOg=
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a/go.mod h1:hxSnBBYLK21Vtq/PHd0S2FYCxBXzBua8ov5s1RobyRQ=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
@@ -138,6 +168,7 @@ github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6ke
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
@@ -151,9 +182,11 @@ github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An
github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
@@ -168,29 +201,67 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw=
go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo=
go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo=
go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI=
go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco=
go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI=
go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
golang.org/x/arch v0.13.0 h1:KCkqVVV1kGg0X87TFysjCJ8MxtZEIU4Ja/yXGeoECdA=
golang.org/x/arch v0.13.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70=
golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.3 h1:82DV7MYdb8anAVi3qge1wSnMDrnKK7ebr+I0hHRN1BU=
google.golang.org/protobuf v1.36.3/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -3,6 +3,7 @@ package api
import (
"fmt"
"io/fs"
"math/rand/v2"
"net/http"
"os"
"strings"
@@ -10,6 +11,7 @@ import (
"tinyauth/internal/assets"
"tinyauth/internal/auth"
"tinyauth/internal/hooks"
"tinyauth/internal/providers"
"tinyauth/internal/types"
"tinyauth/internal/utils"
@@ -20,214 +22,597 @@ import (
"github.com/rs/zerolog/log"
)
func NewAPI(config types.APIConfig, hooks *hooks.Hooks, auth *auth.Auth) (*API) {
func NewAPI(config types.APIConfig, hooks *hooks.Hooks, auth *auth.Auth, providers *providers.Providers) *API {
return &API{
Config: config,
Hooks: hooks,
Auth: auth,
Router: nil,
Config: config,
Hooks: hooks,
Auth: auth,
Providers: providers,
}
}
type API struct {
Config types.APIConfig
Router *gin.Engine
Hooks *hooks.Hooks
Auth *auth.Auth
Config types.APIConfig
Router *gin.Engine
Hooks *hooks.Hooks
Auth *auth.Auth
Providers *providers.Providers
Domain string
}
func (api *API) Init() {
// Disable gin logs
gin.SetMode(gin.ReleaseMode)
// Create router and use zerolog for logs
log.Debug().Msg("Setting up router")
router := gin.New()
router.Use(zerolog())
// Read UI assets
log.Debug().Msg("Setting up assets")
dist, distErr := fs.Sub(assets.Assets, "dist")
if distErr != nil {
log.Fatal().Err(distErr).Msg("Failed to get UI assets")
os.Exit(1)
}
// Create file server
log.Debug().Msg("Setting up file server")
fileServer := http.FileServer(http.FS(dist))
// Setup cookie store
log.Debug().Msg("Setting up cookie store")
store := cookie.NewStore([]byte(api.Config.Secret))
// Get domain to use for session cookies
log.Debug().Msg("Getting domain")
domain, domainErr := utils.GetRootURL(api.Config.AppURL)
log.Info().Str("domain", domain).Msg("Using domain for cookies")
if domainErr != nil {
log.Fatal().Err(domainErr).Msg("Failed to get domain")
os.Exit(1)
}
var isSecure bool
log.Info().Str("domain", domain).Msg("Using domain for cookies")
if api.Config.CookieSecure {
isSecure = true
} else {
isSecure = false
}
api.Domain = fmt.Sprintf(".%s", domain)
// Use session middleware
store.Options(sessions.Options{
Domain: fmt.Sprintf(".%s", domain),
Path: "/",
Domain: api.Domain,
Path: "/",
HttpOnly: true,
Secure: isSecure,
Secure: api.Config.CookieSecure,
MaxAge: api.Config.CookieExpiry,
})
router.Use(sessions.Sessions("tinyauth", store))
router.Use(func(c *gin.Context) {
router.Use(sessions.Sessions("tinyauth", store))
// UI middleware
router.Use(func(c *gin.Context) {
// If not an API request, serve the UI
if !strings.HasPrefix(c.Request.URL.Path, "/api") {
_, err := fs.Stat(dist, strings.TrimPrefix(c.Request.URL.Path, "/"))
// If the file doesn't exist, serve the index.html
if os.IsNotExist(err) {
c.Request.URL.Path = "/"
}
// Serve the file
fileServer.ServeHTTP(c.Writer, c.Request)
// Stop further processing
c.Abort()
}
})
// Set router
api.Router = router
}
func (api *API) SetupRoutes() {
api.Router.GET("/api/auth", func (c *gin.Context) {
userContext := api.Hooks.UseUserContext(c)
api.Router.GET("/api/auth/:proxy", func(c *gin.Context) {
// Create struct for proxy
var proxy types.Proxy
if userContext.IsLoggedIn {
c.JSON(200, gin.H{
"status": 200,
"message": "Authenticated",
})
return
}
// Bind URI
bindErr := c.BindUri(&proxy)
uri := c.Request.Header.Get("X-Forwarded-Uri")
proto := c.Request.Header.Get("X-Forwarded-Proto")
host := c.Request.Header.Get("X-Forwarded-Host")
queries, queryErr := query.Values(types.LoginQuery{
RedirectURI: fmt.Sprintf("%s://%s%s", proto, host, uri),
})
if queryErr != nil {
c.JSON(501, gin.H{
"status": 501,
"message": "Internal Server Error",
})
return
}
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/?%s", api.Config.AppURL, queries.Encode()))
})
api.Router.POST("/api/login", func (c *gin.Context) {
var login types.LoginRequest
err := c.BindJSON(&login)
if err != nil {
// Handle error
if bindErr != nil {
log.Error().Err(bindErr).Msg("Failed to bind URI")
c.JSON(400, gin.H{
"status": 400,
"status": 400,
"message": "Bad Request",
})
return
}
log.Debug().Interface("proxy", proxy.Proxy).Msg("Got proxy")
// Get user context
userContext := api.Hooks.UseUserContext(c)
// Check if using basic auth
_, _, basicAuth := c.Request.BasicAuth()
// Get headers
uri := c.Request.Header.Get("X-Forwarded-Uri")
proto := c.Request.Header.Get("X-Forwarded-Proto")
host := c.Request.Header.Get("X-Forwarded-Host")
// Check if user is logged in
if userContext.IsLoggedIn {
log.Debug().Msg("Authenticated")
// Check if user is allowed to access subdomain, if request is nginx.example.com the subdomain (resource) is nginx
appAllowed, appAllowedErr := api.Auth.ResourceAllowed(userContext, host)
// Check if there was an error
if appAllowedErr != nil {
// Return 501 if nginx is the proxy or if the request is using basic auth
if proxy.Proxy == "nginx" || basicAuth {
log.Error().Err(appAllowedErr).Msg("Failed to check if app is allowed")
c.JSON(501, gin.H{
"status": 501,
"message": "Internal Server Error",
})
return
}
// Return the internal server error page
if api.handleError(c, "Failed to check if app is allowed", appAllowedErr) {
return
}
}
log.Debug().Bool("appAllowed", appAllowed).Msg("Checking if app is allowed")
// The user is not allowed to access the app
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\"")
c.JSON(401, gin.H{
"status": 401,
"message": "Unauthorized",
})
return
}
// Build query
queries, queryErr := query.Values(types.UnauthorizedQuery{
Username: userContext.Username,
Resource: strings.Split(host, ".")[0],
})
// Handle error (no need to check for nginx/headers since we are sure we are using caddy/traefik)
if api.handleError(c, "Failed to build query", queryErr) {
return
}
// We are using caddy/traefik so redirect
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/unauthorized?%s", api.Config.AppURL, queries.Encode()))
// Stop further processing
return
}
// The user is allowed to access the app
c.JSON(200, gin.H{
"status": 200,
"message": "Authenticated",
})
// Stop further processing
return
}
// 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\"")
c.JSON(401, gin.H{
"status": 401,
"message": "Unauthorized",
})
return
}
// Build query
queries, queryErr := query.Values(types.LoginQuery{
RedirectURI: fmt.Sprintf("%s://%s%s", proto, host, uri),
})
// Handle error (no need to check for nginx/headers since we are sure we are using caddy/traefik)
if api.handleError(c, "Failed to build query", queryErr) {
return
}
log.Debug().Interface("redirect_uri", fmt.Sprintf("%s://%s%s", proto, host, uri)).Msg("Redirecting to login")
// Redirect to login
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/?%s", api.Config.AppURL, queries.Encode()))
})
api.Router.POST("/api/login", func(c *gin.Context) {
// Create login struct
var login types.LoginRequest
// Bind JSON
err := c.BindJSON(&login)
// Handle error
if err != nil {
log.Error().Err(err).Msg("Failed to bind JSON")
c.JSON(400, gin.H{
"status": 400,
"message": "Bad Request",
})
return
}
log.Debug().Msg("Got login request")
// Get user based on username
user := api.Auth.GetUser(login.Username)
// User does not exist
if user == nil {
log.Debug().Str("username", login.Username).Msg("User not found")
c.JSON(401, gin.H{
"status": 401,
"status": 401,
"message": "Unauthorized",
})
return
}
log.Debug().Msg("Got user")
// Check if password is correct
if !api.Auth.CheckPassword(*user, login.Password) {
log.Debug().Str("username", login.Username).Msg("Password incorrect")
c.JSON(401, gin.H{
"status": 401,
"status": 401,
"message": "Unauthorized",
})
return
}
session := sessions.Default(c)
session.Set("tinyauth", user.Username)
session.Save()
log.Debug().Msg("Password correct, logging in")
// Create session cookie with username as provider
api.Auth.CreateSessionCookie(c, &types.SessionCookie{
Username: login.Username,
Provider: "username",
})
// Return logged in
c.JSON(200, gin.H{
"status": 200,
"status": 200,
"message": "Logged in",
})
})
api.Router.POST("/api/logout", func (c *gin.Context) {
session := sessions.Default(c)
session.Delete("tinyauth")
session.Save()
api.Router.POST("/api/logout", func(c *gin.Context) {
log.Debug().Msg("Logging out")
// Delete session cookie
api.Auth.DeleteSessionCookie(c)
log.Debug().Msg("Cleaning up redirect cookie")
// Clean up redirect cookie if it exists
c.SetCookie("tinyauth_redirect_uri", "", -1, "/", api.Domain, api.Config.CookieSecure, true)
// Return logged out
c.JSON(200, gin.H{
"status": 200,
"status": 200,
"message": "Logged out",
})
})
api.Router.GET("/api/status", func (c *gin.Context) {
api.Router.GET("/api/status", func(c *gin.Context) {
log.Debug().Msg("Checking status")
// Get user context
userContext := api.Hooks.UseUserContext(c)
// Get configured providers
configuredProviders := api.Providers.GetConfiguredProviders()
// We have username/password configured so add it to our providers
if api.Auth.UserAuthConfigured() {
configuredProviders = append(configuredProviders, "username")
}
// We are not logged in so return unauthorized
if !userContext.IsLoggedIn {
log.Debug().Msg("Unauthorized")
c.Header("WWW-Authenticate", "Basic realm=\"tinyauth\"")
c.JSON(200, gin.H{
"status": 200,
"message": "Unauthenticated",
"username": "",
"isLoggedIn": false,
"status": 200,
"message": "Unauthorized",
"username": "",
"isLoggedIn": false,
"oauth": false,
"provider": "",
"configuredProviders": configuredProviders,
"disableContinue": api.Config.DisableContinue,
})
return
}
}
log.Debug().Interface("userContext", userContext).Strs("configuredProviders", configuredProviders).Bool("disableContinue", api.Config.DisableContinue).Msg("Authenticated")
// We are logged in so return our user context
c.JSON(200, gin.H{
"status": 200,
"message": "Authenticated",
"username": userContext.Username,
"isLoggedIn": true,
"status": 200,
"message": "Authenticated",
"username": userContext.Username,
"isLoggedIn": userContext.IsLoggedIn,
"oauth": userContext.OAuth,
"provider": userContext.Provider,
"configuredProviders": configuredProviders,
"disableContinue": api.Config.DisableContinue,
})
})
api.Router.GET("/api/healthcheck", func (c *gin.Context) {
api.Router.GET("/api/oauth/url/:provider", func(c *gin.Context) {
// Create struct for OAuth request
var request types.OAuthRequest
// Bind URI
bindErr := c.BindUri(&request)
// Handle error
if bindErr != nil {
log.Error().Err(bindErr).Msg("Failed to bind URI")
c.JSON(400, gin.H{
"status": 400,
"message": "Bad Request",
})
return
}
log.Debug().Msg("Got OAuth request")
// Check if provider exists
provider := api.Providers.GetProvider(request.Provider)
// Provider does not exist
if provider == nil {
c.JSON(404, gin.H{
"status": 404,
"message": "Not Found",
})
return
}
log.Debug().Str("provider", request.Provider).Msg("Got provider")
// Get auth URL
authURL := provider.GetAuthURL()
log.Debug().Msg("Got auth URL")
// Get redirect URI
redirectURI := c.Query("redirect_uri")
// Set redirect cookie if redirect URI is provided
if redirectURI != "" {
log.Debug().Str("redirectURI", redirectURI).Msg("Setting redirect cookie")
c.SetCookie("tinyauth_redirect_uri", redirectURI, 3600, "/", api.Domain, api.Config.CookieSecure, true)
}
// Tailscale does not have an auth url so we create a random code (does not need to be secure) to avoid caching and send it
if request.Provider == "tailscale" {
// Build tailscale query
tailscaleQuery, tailscaleQueryErr := query.Values(types.TailscaleQuery{
Code: (1000 + rand.IntN(9000)),
})
// Handle error
if tailscaleQueryErr != nil {
log.Error().Err(tailscaleQueryErr).Msg("Failed to build query")
c.JSON(500, gin.H{
"status": 500,
"message": "Internal Server Error",
})
return
}
// Return tailscale URL (immidiately redirects to the callback)
c.JSON(200, gin.H{
"status": 200,
"message": "Ok",
"url": fmt.Sprintf("%s/api/oauth/callback/tailscale?%s", api.Config.AppURL, tailscaleQuery.Encode()),
})
return
}
// Return auth URL
c.JSON(200, gin.H{
"status": 200,
"status": 200,
"message": "Ok",
"url": authURL,
})
})
api.Router.GET("/api/oauth/callback/:provider", func(c *gin.Context) {
// Create struct for OAuth request
var providerName types.OAuthRequest
// Bind URI
bindErr := c.BindUri(&providerName)
// Handle error
if api.handleError(c, "Failed to bind URI", bindErr) {
return
}
log.Debug().Interface("provider", providerName.Provider).Msg("Got provider name")
// Get code
code := c.Query("code")
// Code empty so redirect to error
if code == "" {
log.Error().Msg("No code provided")
c.Redirect(http.StatusPermanentRedirect, "/error")
return
}
log.Debug().Msg("Got code")
// Get provider
provider := api.Providers.GetProvider(providerName.Provider)
log.Debug().Str("provider", providerName.Provider).Msg("Got provider")
// Provider does not exist
if provider == nil {
c.Redirect(http.StatusPermanentRedirect, "/not-found")
return
}
// Exchange token (authenticates user)
_, tokenErr := provider.ExchangeToken(code)
log.Debug().Msg("Got token")
// Handle error
if api.handleError(c, "Failed to exchange token", tokenErr) {
return
}
// Get email
email, emailErr := api.Providers.GetUser(providerName.Provider)
log.Debug().Str("email", email).Msg("Got email")
// Handle error
if api.handleError(c, "Failed to get user", emailErr) {
return
}
// Email is not whitelisted
if !api.Auth.EmailWhitelisted(email) {
log.Warn().Str("email", email).Msg("Email not whitelisted")
// Build query
unauthorizedQuery, unauthorizedQueryErr := query.Values(types.UnauthorizedQuery{
Username: email,
})
// Handle error
if api.handleError(c, "Failed to build query", unauthorizedQueryErr) {
return
}
// Redirect to unauthorized
c.Redirect(http.StatusPermanentRedirect, fmt.Sprintf("%s/unauthorized?%s", api.Config.AppURL, unauthorizedQuery.Encode()))
}
log.Debug().Msg("Email whitelisted")
// Create session cookie
api.Auth.CreateSessionCookie(c, &types.SessionCookie{
Username: email,
Provider: providerName.Provider,
})
// Get redirect URI
redirectURI, redirectURIErr := c.Cookie("tinyauth_redirect_uri")
// If it is empty it means that no redirect_uri was provided to the login screen so we just log in
if redirectURIErr != nil {
c.JSON(200, gin.H{
"status": 200,
"message": "Logged in",
})
}
log.Debug().Str("redirectURI", redirectURI).Msg("Got redirect URI")
// Clean up redirect cookie since we already have the value
c.SetCookie("tinyauth_redirect_uri", "", -1, "/", api.Domain, api.Config.CookieSecure, true)
// Build query
redirectQuery, redirectQueryErr := query.Values(types.LoginQuery{
RedirectURI: redirectURI,
})
log.Debug().Msg("Got redirect query")
// Handle error
if api.handleError(c, "Failed to build query", redirectQueryErr) {
return
}
// Redirect to continue with the redirect URI
c.Redirect(http.StatusPermanentRedirect, fmt.Sprintf("%s/continue?%s", api.Config.AppURL, redirectQuery.Encode()))
})
// Simple healthcheck
api.Router.GET("/api/healthcheck", func(c *gin.Context) {
c.JSON(200, gin.H{
"status": 200,
"message": "OK",
})
})
}
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))
}
// handleError logs the error and redirects to the error page (only meant for stuff the user may access does not apply for login paths)
func (api *API) handleError(c *gin.Context, msg string, err error) bool {
// If error is not nil log it and redirect to error page also return true so we can stop further processing
if err != nil {
log.Error().Err(err).Msg(msg)
c.Redirect(http.StatusPermanentRedirect, fmt.Sprintf("%s/error", api.Config.AppURL))
return true
}
return false
}
// zerolog is a middleware for gin that logs requests using zerolog
func zerolog() gin.HandlerFunc {
return func(c *gin.Context) {
// Get initial time
tStart := time.Now()
// Process request
c.Next()
// Get status code, address, method and path
code := c.Writer.Status()
address := c.Request.RemoteAddr
method := c.Request.Method
path := c.Request.URL.Path
// Get latency
latency := time.Since(tStart).String()
// Log request
switch {
case code >= 200 && code < 300:
log.Info().Str("method", method).Str("path", path).Str("address", address).Int("status", code).Str("latency", latency).Msg("Request")
case code >= 300 && code < 400:
log.Warn().Str("method", method).Str("path", path).Str("address", address).Int("status", code).Str("latency", latency).Msg("Request")
case code >= 400:
log.Error().Str("method", method).Str("path", path).Str("address", address).Int("status", code).Str("latency", latency).Msg("Request")
case code >= 200 && code < 300:
log.Info().Str("method", method).Str("path", path).Str("address", address).Int("status", code).Str("latency", latency).Msg("Request")
case code >= 300 && code < 400:
log.Warn().Str("method", method).Str("path", path).Str("address", address).Int("status", code).Str("latency", latency).Msg("Request")
case code >= 400:
log.Error().Str("method", method).Str("path", path).Str("address", address).Int("status", code).Str("latency", latency).Msg("Request")
}
}
}
}

199
internal/api/api_test.go Normal file
View File

@@ -0,0 +1,199 @@
package api_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"tinyauth/internal/api"
"tinyauth/internal/auth"
"tinyauth/internal/docker"
"tinyauth/internal/hooks"
"tinyauth/internal/providers"
"tinyauth/internal/types"
"github.com/magiconair/properties/assert"
)
// Simple API config for tests
var apiConfig = types.APIConfig{
Port: 8080,
Address: "0.0.0.0",
Secret: "super-secret-api-thing-for-tests", // It is 32 chars long
AppURL: "http://tinyauth.localhost",
CookieSecure: false,
CookieExpiry: 3600,
DisableContinue: false,
}
// Cookie
var cookie string
// User
var user = types.User{
Username: "user",
Password: "$2a$10$AvGHLTYv3xiRJ0xV9xs3XeVIlkGTygI9nqIamFYB5Xu.5.0UWF7B6", // pass
}
// We need all this to be able to test the API
func getAPI(t *testing.T) *api.API {
// Create docker service
docker := docker.NewDocker()
// Initialize docker
dockerErr := docker.Init()
// Check if there was an error
if dockerErr != nil {
t.Fatalf("Failed to initialize docker: %v", dockerErr)
}
// Create auth service
auth := auth.NewAuth(docker, types.Users{
{
Username: user.Username,
Password: user.Password,
},
}, nil, apiConfig.CookieExpiry)
// Create providers service
providers := providers.NewProviders(types.OAuthConfig{})
// Initialize providers
providers.Init()
// Create hooks service
hooks := hooks.NewHooks(auth, providers)
// Create API
api := api.NewAPI(apiConfig, hooks, auth, providers)
// Setup routes
api.Init()
api.SetupRoutes()
return api
}
// Test login (we will need this for the other tests)
func TestLogin(t *testing.T) {
t.Log("Testing login")
// Get API
api := getAPI(t)
// Create recorder
recorder := httptest.NewRecorder()
// Create request
user := types.LoginRequest{
Username: "user",
Password: "pass",
}
json, err := json.Marshal(user)
// Check if there was an error
if err != nil {
t.Fatalf("Error marshalling json: %v", err)
}
// Create request
req, err := http.NewRequest("POST", "/api/login", strings.NewReader(string(json)))
// Check if there was an error
if err != nil {
t.Fatalf("Error creating request: %v", err)
}
// Serve the request
api.Router.ServeHTTP(recorder, req)
// Assert
assert.Equal(t, recorder.Code, http.StatusOK)
// Get the cookie
cookie = recorder.Result().Cookies()[0].Value
// Check if the cookie is set
if cookie == "" {
t.Fatalf("Cookie not set")
}
}
// Test status
func TestStatus(t *testing.T) {
t.Log("Testing status")
// Get API
api := getAPI(t)
// Create recorder
recorder := httptest.NewRecorder()
// Create request
req, err := http.NewRequest("GET", "/api/status", nil)
// Check if there was an error
if err != nil {
t.Fatalf("Error creating request: %v", err)
}
// Set the cookie
req.AddCookie(&http.Cookie{
Name: "tinyauth",
Value: cookie,
})
// Serve the request
api.Router.ServeHTTP(recorder, req)
// Assert
assert.Equal(t, recorder.Code, http.StatusOK)
// Parse the body
body := recorder.Body.String()
if !strings.Contains(body, "user") {
t.Fatalf("Expected user in body")
}
}
// Test logout
func TestLogout(t *testing.T) {
t.Log("Testing logout")
// Get API
api := getAPI(t)
// Create recorder
recorder := httptest.NewRecorder()
// Create request
req, err := http.NewRequest("POST", "/api/logout", nil)
// Check if there was an error
if err != nil {
t.Fatalf("Error creating request: %v", err)
}
// Set the cookie
req.AddCookie(&http.Cookie{
Name: "tinyauth",
Value: cookie,
})
// Serve the request
api.Router.ServeHTTP(recorder, req)
// Assert
assert.Equal(t, recorder.Code, http.StatusOK)
// Check if the cookie is different (means go sessions flushed it)
if recorder.Result().Cookies()[0].Value == cookie {
t.Fatalf("Cookie not flushed")
}
}
// TODO: Testing for the oauth stuff

View File

@@ -4,8 +4,12 @@ import (
"embed"
)
// UI assets
//
//go:embed dist
var Assets embed.FS
// Version file
//
//go:embed version
var Version string
var Version string

View File

@@ -1 +1 @@
v0.3.0
v3.0.0

View File

@@ -1,22 +1,37 @@
package auth
import (
"slices"
"strings"
"time"
"tinyauth/internal/docker"
"tinyauth/internal/types"
"tinyauth/internal/utils"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog/log"
"golang.org/x/crypto/bcrypt"
)
func NewAuth(userList types.Users) *Auth {
func NewAuth(docker *docker.Docker, userList types.Users, oauthWhitelist []string, sessionExpiry int) *Auth {
return &Auth{
Users: userList,
Docker: docker,
Users: userList,
OAuthWhitelist: oauthWhitelist,
SessionExpiry: sessionExpiry,
}
}
type Auth struct {
Users types.Users
Users types.Users
Docker *docker.Docker
OAuthWhitelist []string
SessionExpiry int
}
func (auth *Auth) GetUser(username string) *types.User {
// Loop through users and return the user if the username matches
for _, user := range auth.Users {
if user.Username == username {
return &user
@@ -26,6 +41,188 @@ func (auth *Auth) GetUser(username string) *types.User {
}
func (auth *Auth) CheckPassword(user types.User, password string) bool {
hashedPasswordErr := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password))
return hashedPasswordErr == nil
}
// Compare the hashed password with the password provided
return bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) == nil
}
func (auth *Auth) EmailWhitelisted(emailSrc string) bool {
// If the whitelist is empty, allow all emails
if len(auth.OAuthWhitelist) == 0 {
return true
}
// Loop through the whitelist and return true if the email matches
for _, email := range auth.OAuthWhitelist {
if email == emailSrc {
return true
}
}
// If no emails match, return false
return false
}
func (auth *Auth) CreateSessionCookie(c *gin.Context, data *types.SessionCookie) {
log.Debug().Msg("Creating session cookie")
// Get session
sessions := sessions.Default(c)
log.Debug().Msg("Setting session cookie")
// Set data
sessions.Set("username", data.Username)
sessions.Set("provider", data.Provider)
sessions.Set("expiry", time.Now().Add(time.Duration(auth.SessionExpiry)*time.Second).Unix())
// Save session
sessions.Save()
}
func (auth *Auth) DeleteSessionCookie(c *gin.Context) {
log.Debug().Msg("Deleting session cookie")
// Get session
sessions := sessions.Default(c)
// Clear session
sessions.Clear()
// Save session
sessions.Save()
}
func (auth *Auth) GetSessionCookie(c *gin.Context) types.SessionCookie {
log.Debug().Msg("Getting session cookie")
// Get session
sessions := sessions.Default(c)
// Get data
cookieUsername := sessions.Get("username")
cookieProvider := sessions.Get("provider")
cookieExpiry := sessions.Get("expiry")
// Convert interfaces to correct types
username, usernameOk := cookieUsername.(string)
provider, providerOk := cookieProvider.(string)
expiry, expiryOk := cookieExpiry.(int64)
// Check if the cookie is invalid
if !usernameOk || !providerOk || !expiryOk {
log.Warn().Msg("Session cookie invalid")
return types.SessionCookie{}
}
// Check if the cookie has expired
if time.Now().Unix() > expiry {
log.Warn().Msg("Session cookie expired")
// If it has, delete it
auth.DeleteSessionCookie(c)
// Return empty cookie
return types.SessionCookie{}
}
log.Debug().Str("username", username).Str("provider", provider).Int64("expiry", expiry).Msg("Parsed cookie")
// Return the cookie
return types.SessionCookie{
Username: username,
Provider: provider,
}
}
func (auth *Auth) UserAuthConfigured() bool {
// If there are users, return true
return len(auth.Users) > 0
}
func (auth *Auth) ResourceAllowed(context types.UserContext, host string) (bool, error) {
// Check if we have access to the Docker API
isConnected := auth.Docker.DockerConnected()
// If we don't have access, it is assumed that the user has access
if !isConnected {
log.Debug().Msg("Docker not connected, allowing access")
return true, nil
}
// Get the app ID from the host
appId := strings.Split(host, ".")[0]
// Get the containers
containers, containersErr := auth.Docker.GetContainers()
// If there is an error, return false
if containersErr != nil {
return false, containersErr
}
log.Debug().Msg("Got containers")
// Loop through the containers
for _, container := range containers {
// Inspect the container
inspect, inspectErr := auth.Docker.InspectContainer(container.ID)
// If there is an error, return false
if inspectErr != nil {
return false, inspectErr
}
// Get the container name (for some reason it is /name)
containerName := strings.Split(inspect.Name, "/")[1]
// There is a container with the same name as the app ID
if containerName == appId {
log.Debug().Str("container", containerName).Msg("Found container")
// Get only the tinyauth labels in a struct
labels := utils.GetTinyauthLabels(inspect.Config.Labels)
log.Debug().Msg("Got labels")
// If the container has an oauth whitelist, check if the user is in it
if context.OAuth && len(labels.OAuthWhitelist) != 0 {
log.Debug().Msg("Checking OAuth whitelist")
if slices.Contains(labels.OAuthWhitelist, context.Username) {
return true, nil
}
return false, nil
}
// If the container has users, check if the user is in it
if len(labels.Users) != 0 {
log.Debug().Msg("Checking users")
if slices.Contains(labels.Users, context.Username) {
return true, nil
}
return false, nil
}
}
}
log.Debug().Msg("No matching container found, allowing access")
// If no matching container is found, allow access
return true, nil
}
func (auth *Auth) GetBasicAuth(c *gin.Context) *types.User {
// Get the Authorization header
username, password, ok := c.Request.BasicAuth()
// If not ok, return an empty user
if !ok {
return nil
}
// Return the user
return &types.User{
Username: username,
Password: password,
}
}

View File

@@ -0,0 +1,7 @@
package constants
// TinyauthLabels is a list of labels that can be used in a tinyauth protected container
var TinyauthLabels = []string{
"tinyauth.oauth.whitelist",
"tinyauth.users",
}

67
internal/docker/docker.go Normal file
View File

@@ -0,0 +1,67 @@
package docker
import (
"context"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/client"
)
func NewDocker() *Docker {
return &Docker{}
}
type Docker struct {
Client *client.Client
Context context.Context
}
func (docker *Docker) Init() error {
// Create a new docker client
apiClient, err := client.NewClientWithOpts(client.FromEnv)
// Check if there was an error
if err != nil {
return err
}
// Set the context and api client
docker.Context = context.Background()
docker.Client = apiClient
// Done
return nil
}
func (docker *Docker) GetContainers() ([]types.Container, error) {
// Get the list of containers
containers, err := docker.Client.ContainerList(docker.Context, container.ListOptions{})
// Check if there was an error
if err != nil {
return nil, err
}
// Return the containers
return containers, nil
}
func (docker *Docker) InspectContainer(containerId string) (types.ContainerJSON, error) {
// Inspect the container
inspect, err := docker.Client.ContainerInspect(docker.Context, containerId)
// Check if there was an error
if err != nil {
return types.ContainerJSON{}, err
}
// Return the inspect
return inspect, nil
}
func (docker *Docker) DockerConnected() bool {
// Ping the docker client if there is an error it is not connected
_, err := docker.Client.Ping(docker.Context)
return err == nil
}

View File

@@ -2,53 +2,108 @@ package hooks
import (
"tinyauth/internal/auth"
"tinyauth/internal/providers"
"tinyauth/internal/types"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog/log"
)
func NewHooks(auth *auth.Auth) *Hooks {
func NewHooks(auth *auth.Auth, providers *providers.Providers) *Hooks {
return &Hooks{
Auth: auth,
Auth: auth,
Providers: providers,
}
}
type Hooks struct {
Auth *auth.Auth
Auth *auth.Auth
Providers *providers.Providers
}
func (hooks *Hooks) UseUserContext(c *gin.Context) (types.UserContext) {
session := sessions.Default(c)
cookie := session.Get("tinyauth")
func (hooks *Hooks) UseUserContext(c *gin.Context) types.UserContext {
// Get session cookie and basic auth
cookie := hooks.Auth.GetSessionCookie(c)
basic := hooks.Auth.GetBasicAuth(c)
if cookie == nil {
return types.UserContext{
Username: "",
IsLoggedIn: false,
// Check if basic auth is set
if basic != nil {
log.Debug().Msg("Got basic auth")
// Check if user exists and password is correct
user := hooks.Auth.GetUser(basic.Username)
if user != nil && hooks.Auth.CheckPassword(*user, basic.Password) {
// Return user context since we are logged in with basic auth
return types.UserContext{
Username: basic.Username,
IsLoggedIn: true,
OAuth: false,
Provider: "basic",
}
}
}
// Check if session cookie is username/password auth
if cookie.Provider == "username" {
log.Debug().Msg("Provider is username")
// Check if user exists
if hooks.Auth.GetUser(cookie.Username) != nil {
log.Debug().Msg("User exists")
// It exists so we are logged in
return types.UserContext{
Username: cookie.Username,
IsLoggedIn: true,
OAuth: false,
Provider: "username",
}
}
}
username, ok := cookie.(string)
log.Debug().Msg("Provider is not username")
if !ok {
// The provider is not username so we need to check if it is an oauth provider
provider := hooks.Providers.GetProvider(cookie.Provider)
// If we have a provider with this name
if provider != nil {
log.Debug().Msg("Provider exists")
// Check if the oauth email is whitelisted
if !hooks.Auth.EmailWhitelisted(cookie.Username) {
log.Error().Str("email", cookie.Username).Msg("Email is not whitelisted")
// It isn't so we delete the cookie and return an empty context
hooks.Auth.DeleteSessionCookie(c)
// Return empty context
return types.UserContext{
Username: "",
IsLoggedIn: false,
OAuth: false,
Provider: "",
}
}
log.Debug().Msg("Email is whitelisted")
// Return user context since we are logged in with oauth
return types.UserContext{
Username: "",
IsLoggedIn: false,
}
}
user := hooks.Auth.GetUser(username)
if user == nil {
return types.UserContext{
Username: "",
IsLoggedIn: false,
Username: cookie.Username,
IsLoggedIn: true,
OAuth: true,
Provider: cookie.Provider,
}
}
// Neither basic auth or oauth is set so we return an empty context
return types.UserContext{
Username: username,
IsLoggedIn: true,
Username: "",
IsLoggedIn: false,
OAuth: false,
Provider: "",
}
}
}

53
internal/oauth/oauth.go Normal file
View File

@@ -0,0 +1,53 @@
package oauth
import (
"context"
"net/http"
"golang.org/x/oauth2"
)
func NewOAuth(config oauth2.Config) *OAuth {
return &OAuth{
Config: config,
}
}
type OAuth struct {
Config oauth2.Config
Context context.Context
Token *oauth2.Token
Verifier string
}
func (oauth *OAuth) Init() {
// Create a new context and verifier
oauth.Context = context.Background()
oauth.Verifier = oauth2.GenerateVerifier()
}
func (oauth *OAuth) GetAuthURL() string {
// Return the auth url
return oauth.Config.AuthCodeURL("state", oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(oauth.Verifier))
}
func (oauth *OAuth) ExchangeToken(code string) (string, error) {
// Exchange the code for a token
token, err := oauth.Config.Exchange(oauth.Context, code, oauth2.VerifierOption(oauth.Verifier))
// Check if there was an error
if err != nil {
return "", err
}
// Set the token
oauth.Token = token
// Return the access token
return oauth.Token.AccessToken, nil
}
func (oauth *OAuth) GetClient() *http.Client {
// Return the http client with the token set
return oauth.Config.Client(oauth.Context, oauth.Token)
}

View File

@@ -0,0 +1,52 @@
package providers
import (
"encoding/json"
"io"
"net/http"
"github.com/rs/zerolog/log"
)
// We are assuming that the generic provider will return a JSON object with an email field
type GenericUserInfoResponse struct {
Email string `json:"email"`
}
func GetGenericEmail(client *http.Client, url string) (string, error) {
// Using the oauth client get the user info url
res, resErr := client.Get(url)
// Check if there was an error
if resErr != nil {
return "", resErr
}
log.Debug().Msg("Got response from generic provider")
// Read the body of the response
body, bodyErr := io.ReadAll(res.Body)
// Check if there was an error
if bodyErr != nil {
return "", bodyErr
}
log.Debug().Msg("Read body from generic provider")
// Parse the body into a user struct
var user GenericUserInfoResponse
// Unmarshal the body into the user struct
jsonErr := json.Unmarshal(body, &user)
// Check if there was an error
if jsonErr != nil {
return "", jsonErr
}
log.Debug().Msg("Parsed user from generic provider")
// Return the email
return user.Email, nil
}

View File

@@ -0,0 +1,66 @@
package providers
import (
"encoding/json"
"errors"
"io"
"net/http"
"github.com/rs/zerolog/log"
)
// Github has a different response than the generic provider
type GithubUserInfoResponse []struct {
Email string `json:"email"`
Primary bool `json:"primary"`
}
// The scopes required for the github provider
func GithubScopes() []string {
return []string{"user:email"}
}
func GetGithubEmail(client *http.Client) (string, error) {
// Get the user emails from github using the oauth http client
res, resErr := client.Get("https://api.github.com/user/emails")
// Check if there was an error
if resErr != nil {
return "", resErr
}
log.Debug().Msg("Got response from github")
// Read the body of the response
body, bodyErr := io.ReadAll(res.Body)
// Check if there was an error
if bodyErr != nil {
return "", bodyErr
}
log.Debug().Msg("Read body from github")
// Parse the body into a user struct
var emails GithubUserInfoResponse
// Unmarshal the body into the user struct
jsonErr := json.Unmarshal(body, &emails)
// Check if there was an error
if jsonErr != nil {
return "", jsonErr
}
log.Debug().Msg("Parsed emails from github")
// Find and return the primary email
for _, email := range emails {
if email.Primary {
return email.Email, nil
}
}
// User does not have a primary email?
return "", errors.New("no primary email found")
}

View File

@@ -0,0 +1,57 @@
package providers
import (
"encoding/json"
"io"
"net/http"
"github.com/rs/zerolog/log"
)
// Google works the same as the generic provider
type GoogleUserInfoResponse struct {
Email string `json:"email"`
}
// The scopes required for the google provider
func GoogleScopes() []string {
return []string{"https://www.googleapis.com/auth/userinfo.email"}
}
func GetGoogleEmail(client *http.Client) (string, error) {
// Get the user info from google using the oauth http client
res, resErr := client.Get("https://www.googleapis.com/userinfo/v2/me")
// Check if there was an error
if resErr != nil {
return "", resErr
}
log.Debug().Msg("Got response from google")
// Read the body of the response
body, bodyErr := io.ReadAll(res.Body)
// Check if there was an error
if bodyErr != nil {
return "", bodyErr
}
log.Debug().Msg("Read body from google")
// Parse the body into a user struct
var user GoogleUserInfoResponse
// Unmarshal the body into the user struct
jsonErr := json.Unmarshal(body, &user)
// Check if there was an error
if jsonErr != nil {
return "", jsonErr
}
log.Debug().Msg("Parsed user from google")
// Return the email
return user.Email, nil
}

View File

@@ -0,0 +1,235 @@
package providers
import (
"fmt"
"tinyauth/internal/oauth"
"tinyauth/internal/types"
"github.com/rs/zerolog/log"
"golang.org/x/oauth2"
"golang.org/x/oauth2/endpoints"
)
func NewProviders(config types.OAuthConfig) *Providers {
return &Providers{
Config: config,
}
}
type Providers struct {
Config types.OAuthConfig
Github *oauth.OAuth
Google *oauth.OAuth
Tailscale *oauth.OAuth
Generic *oauth.OAuth
}
func (providers *Providers) Init() {
// If we have a client id and secret for github, initialize the oauth provider
if providers.Config.GithubClientId != "" && providers.Config.GithubClientSecret != "" {
log.Info().Msg("Initializing Github OAuth")
// Create a new oauth provider with the github config
providers.Github = oauth.NewOAuth(oauth2.Config{
ClientID: providers.Config.GithubClientId,
ClientSecret: providers.Config.GithubClientSecret,
RedirectURL: fmt.Sprintf("%s/api/oauth/callback/github", providers.Config.AppURL),
Scopes: GithubScopes(),
Endpoint: endpoints.GitHub,
})
// Initialize the oauth provider
providers.Github.Init()
}
// If we have a client id and secret for google, initialize the oauth provider
if providers.Config.GoogleClientId != "" && providers.Config.GoogleClientSecret != "" {
log.Info().Msg("Initializing Google OAuth")
// Create a new oauth provider with the google config
providers.Google = oauth.NewOAuth(oauth2.Config{
ClientID: providers.Config.GoogleClientId,
ClientSecret: providers.Config.GoogleClientSecret,
RedirectURL: fmt.Sprintf("%s/api/oauth/callback/google", providers.Config.AppURL),
Scopes: GoogleScopes(),
Endpoint: endpoints.Google,
})
// Initialize the oauth provider
providers.Google.Init()
}
if providers.Config.TailscaleClientId != "" && providers.Config.TailscaleClientSecret != "" {
log.Info().Msg("Initializing Tailscale OAuth")
// Create a new oauth provider with the tailscale config
providers.Tailscale = oauth.NewOAuth(oauth2.Config{
ClientID: providers.Config.TailscaleClientId,
ClientSecret: providers.Config.TailscaleClientSecret,
RedirectURL: fmt.Sprintf("%s/api/oauth/callback/tailscale", providers.Config.AppURL),
Scopes: TailscaleScopes(),
Endpoint: TailscaleEndpoint,
})
// Initialize the oauth provider
providers.Tailscale.Init()
}
// If we have a client id and secret for generic oauth, initialize the oauth provider
if providers.Config.GenericClientId != "" && providers.Config.GenericClientSecret != "" {
log.Info().Msg("Initializing Generic OAuth")
// Create a new oauth provider with the generic config
providers.Generic = oauth.NewOAuth(oauth2.Config{
ClientID: providers.Config.GenericClientId,
ClientSecret: providers.Config.GenericClientSecret,
RedirectURL: fmt.Sprintf("%s/api/oauth/callback/generic", providers.Config.AppURL),
Scopes: providers.Config.GenericScopes,
Endpoint: oauth2.Endpoint{
AuthURL: providers.Config.GenericAuthURL,
TokenURL: providers.Config.GenericTokenURL,
},
})
// Initialize the oauth provider
providers.Generic.Init()
}
}
func (providers *Providers) GetProvider(provider string) *oauth.OAuth {
// Return the provider based on the provider string
switch provider {
case "github":
return providers.Github
case "google":
return providers.Google
case "tailscale":
return providers.Tailscale
case "generic":
return providers.Generic
default:
return nil
}
}
func (providers *Providers) GetUser(provider string) (string, error) {
// Get the email from the provider
switch provider {
case "github":
// If the github provider is not configured, return an error
if providers.Github == nil {
log.Debug().Msg("Github provider not configured")
return "", nil
}
// Get the client from the github provider
client := providers.Github.GetClient()
log.Debug().Msg("Got client from github")
// Get the email from the github provider
email, emailErr := GetGithubEmail(client)
// Check if there was an error
if emailErr != nil {
return "", emailErr
}
log.Debug().Msg("Got email from github")
// Return the email
return email, nil
case "google":
// If the google provider is not configured, return an error
if providers.Google == nil {
log.Debug().Msg("Google provider not configured")
return "", nil
}
// Get the client from the google provider
client := providers.Google.GetClient()
log.Debug().Msg("Got client from google")
// Get the email from the google provider
email, emailErr := GetGoogleEmail(client)
// Check if there was an error
if emailErr != nil {
return "", emailErr
}
log.Debug().Msg("Got email from google")
// Return the email
return email, nil
case "tailscale":
// If the tailscale provider is not configured, return an error
if providers.Tailscale == nil {
log.Debug().Msg("Tailscale provider not configured")
return "", nil
}
// Get the client from the tailscale provider
client := providers.Tailscale.GetClient()
log.Debug().Msg("Got client from tailscale")
// Get the email from the tailscale provider
email, emailErr := GetTailscaleEmail(client)
// Check if there was an error
if emailErr != nil {
return "", emailErr
}
log.Debug().Msg("Got email from tailscale")
// Return the email
return email, nil
case "generic":
// If the generic provider is not configured, return an error
if providers.Generic == nil {
log.Debug().Msg("Generic provider not configured")
return "", nil
}
// Get the client from the generic provider
client := providers.Generic.GetClient()
log.Debug().Msg("Got client from generic")
// Get the email from the generic provider
email, emailErr := GetGenericEmail(client, providers.Config.GenericUserURL)
// Check if there was an error
if emailErr != nil {
return "", emailErr
}
log.Debug().Msg("Got email from generic")
// Return the email
return email, nil
default:
return "", nil
}
}
func (provider *Providers) GetConfiguredProviders() []string {
// Create a list of the configured providers
providers := []string{}
if provider.Github != nil {
providers = append(providers, "github")
}
if provider.Google != nil {
providers = append(providers, "google")
}
if provider.Tailscale != nil {
providers = append(providers, "tailscale")
}
if provider.Generic != nil {
providers = append(providers, "generic")
}
return providers
}

View File

@@ -0,0 +1,68 @@
package providers
import (
"encoding/json"
"io"
"net/http"
"github.com/rs/zerolog/log"
"golang.org/x/oauth2"
)
// The tailscale email is the loginName
type TailscaleUser struct {
LoginName string `json:"loginName"`
}
// The response from the tailscale user info endpoint
type TailscaleUserInfoResponse struct {
Users []TailscaleUser `json:"users"`
}
// The scopes required for the tailscale provider
func TailscaleScopes() []string {
return []string{"users:read"}
}
// The tailscale endpoint
var TailscaleEndpoint = oauth2.Endpoint{
TokenURL: "https://api.tailscale.com/api/v2/oauth/token",
}
func GetTailscaleEmail(client *http.Client) (string, error) {
// Get the user info from tailscale using the oauth http client
res, resErr := client.Get("https://api.tailscale.com/api/v2/tailnet/-/users")
// Check if there was an error
if resErr != nil {
return "", resErr
}
log.Debug().Msg("Got response from tailscale")
// Read the body of the response
body, bodyErr := io.ReadAll(res.Body)
// Check if there was an error
if bodyErr != nil {
return "", bodyErr
}
log.Debug().Msg("Read body from tailscale")
// Parse the body into a user struct
var users TailscaleUserInfoResponse
// Unmarshal the body into the user struct
jsonErr := json.Unmarshal(body, &users)
// Check if there was an error
if jsonErr != nil {
return "", jsonErr
}
log.Debug().Msg("Parsed users from tailscale")
// Return the email of the first user
return users.Users[0].LoginName, nil
}

View File

@@ -1,40 +1,131 @@
package types
import "tinyauth/internal/oauth"
// LoginQuery is the query parameters for the login endpoint
type LoginQuery struct {
RedirectURI string `url:"redirect_uri"`
}
// LoginRequest is the request body for the login endpoint
type LoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
// User is the struct for a user
type User struct {
Username string
Password string
}
// Users is a list of users
type Users []User
// Config is the configuration for the tinyauth server
type Config struct {
Port int `validate:"number" mapstructure:"port"`
Address string `mapstructure:"address, ip4_addr"`
Secret string `validate:"required,len=32" mapstructure:"secret"`
AppURL string `validate:"required,url" mapstructure:"app-url"`
Users string `mapstructure:"users"`
UsersFile string `mapstructure:"users-file"`
CookieSecure bool `mapstructure:"cookie-secure"`
Port int `mapstructure:"port" validate:"required"`
Address string `validate:"required,ip4_addr" mapstructure:"address"`
Secret string `validate:"required,len=32" mapstructure:"secret"`
SecretFile string `mapstructure:"secret-file"`
AppURL string `validate:"required,url" mapstructure:"app-url"`
Users string `mapstructure:"users"`
UsersFile string `mapstructure:"users-file"`
CookieSecure bool `mapstructure:"cookie-secure"`
GithubClientId string `mapstructure:"github-client-id"`
GithubClientSecret string `mapstructure:"github-client-secret"`
GithubClientSecretFile string `mapstructure:"github-client-secret-file"`
GoogleClientId string `mapstructure:"google-client-id"`
GoogleClientSecret string `mapstructure:"google-client-secret"`
GoogleClientSecretFile string `mapstructure:"google-client-secret-file"`
TailscaleClientId string `mapstructure:"tailscale-client-id"`
TailscaleClientSecret string `mapstructure:"tailscale-client-secret"`
TailscaleClientSecretFile string `mapstructure:"tailscale-client-secret-file"`
GenericClientId string `mapstructure:"generic-client-id"`
GenericClientSecret string `mapstructure:"generic-client-secret"`
GenericClientSecretFile string `mapstructure:"generic-client-secret-file"`
GenericScopes string `mapstructure:"generic-scopes"`
GenericAuthURL string `mapstructure:"generic-auth-url"`
GenericTokenURL string `mapstructure:"generic-token-url"`
GenericUserURL string `mapstructure:"generic-user-url"`
DisableContinue bool `mapstructure:"disable-continue"`
OAuthWhitelist string `mapstructure:"oauth-whitelist"`
SessionExpiry int `mapstructure:"session-expiry"`
LogLevel int8 `mapstructure:"log-level" validate:"min=-1,max=5"`
}
// UserContext is the context for the user
type UserContext struct {
Username string
Username string
IsLoggedIn bool
OAuth bool
Provider string
}
// APIConfig is the configuration for the API
type APIConfig struct {
Port int
Address string
Secret string
AppURL string
CookieSecure bool
}
Port int
Address string
Secret string
AppURL string
CookieSecure bool
CookieExpiry int
DisableContinue bool
}
// OAuthConfig is the configuration for the providers
type OAuthConfig struct {
GithubClientId string
GithubClientSecret string
GoogleClientId string
GoogleClientSecret string
TailscaleClientId string
TailscaleClientSecret string
GenericClientId string
GenericClientSecret string
GenericScopes []string
GenericAuthURL string
GenericTokenURL string
GenericUserURL string
AppURL string
}
// OAuthRequest is the request for the OAuth endpoint
type OAuthRequest struct {
Provider string `uri:"provider" binding:"required"`
}
// OAuthProviders is the struct for the OAuth providers
type OAuthProviders struct {
Github *oauth.OAuth
Google *oauth.OAuth
Microsoft *oauth.OAuth
}
// UnauthorizedQuery is the query parameters for the unauthorized endpoint
type UnauthorizedQuery struct {
Username string `url:"username"`
Resource string `url:"resource"`
}
// SessionCookie is the cookie for the session (exculding the expiry)
type SessionCookie struct {
Username string
Provider string
}
// TinyauthLabels is the labels for the tinyauth container
type TinyauthLabels struct {
OAuthWhitelist []string
Users []string
}
// TailscaleQuery is the query parameters for the tailscale endpoint
type TailscaleQuery struct {
Code int `url:"code"`
}
// Proxy is the uri parameters for the proxy endpoint
type Proxy struct {
Proxy string `uri:"proxy" binding:"required"`
}

View File

@@ -4,58 +4,216 @@ import (
"errors"
"net/url"
"os"
"slices"
"strings"
"tinyauth/internal/constants"
"tinyauth/internal/types"
"github.com/rs/zerolog/log"
)
// Parses a list of comma separated users in a struct
func ParseUsers(users string) (types.Users, error) {
log.Debug().Msg("Parsing users")
// Create a new users struct
var usersParsed types.Users
// Split the users by comma
userList := strings.Split(users, ",")
// Check if there are any users
if len(userList) == 0 {
return types.Users{}, errors.New("invalid user format")
}
// Loop through the users and split them by colon
for _, user := range userList {
// Split the user by colon
userSplit := strings.Split(user, ":")
// Check if the user is in the correct format
if len(userSplit) != 2 {
return types.Users{}, errors.New("invalid user format")
}
// Append the user to the users struct
usersParsed = append(usersParsed, types.User{
Username: userSplit[0],
Password: userSplit[1],
})
}
log.Debug().Msg("Parsed users")
// Return the users struct
return usersParsed, nil
}
// Root url parses parses a hostname and returns the root domain (e.g. sub1.sub2.domain.com -> sub2.domain.com)
func GetRootURL(urlSrc string) (string, error) {
// Make sure the url is valid
urlParsed, parseErr := url.Parse(urlSrc)
// Check if there was an error
if parseErr != nil {
return "", parseErr
}
urlSplitted := strings.Split(urlParsed.Host, ".")
// Split the hostname by period
urlSplitted := strings.Split(urlParsed.Hostname(), ".")
// Get the last part of the url
urlFinal := strings.Join(urlSplitted[1:], ".")
// Return the root domain
return urlFinal, nil
}
func GetUsersFromFile(usersFile string) (string, error) {
_, statErr := os.Stat(usersFile)
// Reads a file and returns the contents
func ReadFile(file string) (string, error) {
// Check if the file exists
_, statErr := os.Stat(file)
// Check if there was an error
if statErr != nil {
return "", statErr
}
data, readErr := os.ReadFile(usersFile)
// Read the file
data, readErr := os.ReadFile(file)
// Check if there was an error
if readErr != nil {
return "", readErr
}
// Return the file contents
return string(data), nil
}
// Parses a file into a comma separated list of users
func ParseFileToLine(content string) string {
// Split the content by newline
lines := strings.Split(content, "\n")
// Create a list of users
users := make([]string, 0)
// Loop through the lines, trimming the whitespace and appending to the users list
for _, line := range lines {
if strings.TrimSpace(line) == "" {
continue
}
users = append(users, strings.TrimSpace(line))
}
// Return the users as a comma separated string
return strings.Join(users, ",")
}
// Get the secret from the config or file
func GetSecret(conf string, file string) string {
// If neither the config or file is set, return an empty string
if conf == "" && file == "" {
return ""
}
// If the config is set, return the config (environment variable)
if conf != "" {
return conf
}
// If the file is set, read the file
contents, err := ReadFile(file)
// Check if there was an error
if err != nil {
return ""
}
// Return the contents of the file
return contents
}
// Get the users from the config or file
func GetUsers(conf string, file string) (types.Users, error) {
// Create a string to store the users
var users string
// If neither the config or file is set, return an empty users struct
if conf == "" && file == "" {
return types.Users{}, nil
}
// If the config (environment) is set, append the users to the users string
if conf != "" {
log.Debug().Msg("Using users from config")
users += conf
}
// If the file is set, read the file and append the users to the users string
if file != "" {
// Read the file
fileContents, fileErr := ReadFile(file)
// If there isn't an error we can append the users to the users string
if fileErr == nil {
log.Debug().Msg("Using users from file")
// Append the users to the users string
if users != "" {
users += ","
}
// Parse the file contents into a comma separated list of users
users += ParseFileToLine(fileContents)
}
}
// Return the parsed users
return ParseUsers(users)
}
// Parse the docker labels to the tinyauth labels struct
func GetTinyauthLabels(labels map[string]string) types.TinyauthLabels {
// Create a new tinyauth labels struct
var tinyauthLabels types.TinyauthLabels
// Loop through the labels
for label, value := range labels {
// Check if the label is in the tinyauth labels
if slices.Contains(constants.TinyauthLabels, label) {
log.Debug().Str("label", label).Msg("Found label")
// Add the label value to the tinyauth labels struct
switch label {
case "tinyauth.oauth.whitelist":
tinyauthLabels.OAuthWhitelist = strings.Split(value, ",")
case "tinyauth.users":
tinyauthLabels.Users = strings.Split(value, ",")
}
}
}
// Return the tinyauth labels
return tinyauthLabels
}
// Check if any of the OAuth providers are configured based on the client id and secret
func OAuthConfigured(config types.Config) bool {
return (config.GithubClientId != "" && config.GithubClientSecret != "") || (config.GoogleClientId != "" && config.GoogleClientSecret != "") || (config.GenericClientId != "" && config.GenericClientSecret != "") || (config.TailscaleClientId != "" && config.TailscaleClientSecret != "")
}
// Filter helper function
func Filter[T any](slice []T, test func(T) bool) (res []T) {
for _, value := range slice {
if test(value) {
res = append(res, value)
}
}
return res
}

View File

@@ -0,0 +1,334 @@
package utils_test
import (
"os"
"reflect"
"testing"
"tinyauth/internal/types"
"tinyauth/internal/utils"
)
// Test the parse users function
func TestParseUsers(t *testing.T) {
t.Log("Testing parse users with a valid string")
// Test the parse users function with a valid string
users := "user1:pass1,user2:pass2"
expected := types.Users{
{
Username: "user1",
Password: "pass1",
},
{
Username: "user2",
Password: "pass2",
},
}
result, err := utils.ParseUsers(users)
// Check if there was an error
if err != nil {
t.Fatalf("Error parsing users: %v", err)
}
// Check if the result is equal to the expected
if !reflect.DeepEqual(expected, result) {
t.Fatalf("Expected %v, got %v", expected, result)
}
t.Log("Testing parse users with an invalid string")
// Test the parse users function with an invalid string
users = "user1:pass1,user2"
_, err = utils.ParseUsers(users)
// There should be an error
if err == nil {
t.Fatalf("Expected error parsing users")
}
}
// Test the get root url function
func TestGetRootURL(t *testing.T) {
t.Log("Testing get root url with a valid url")
// Test the get root url function with a valid url
url := "https://sub1.sub2.domain.com:8080"
expected := "sub2.domain.com"
result, err := utils.GetRootURL(url)
// Check if there was an error
if err != nil {
t.Fatalf("Error getting root url: %v", err)
}
// Check if the result is equal to the expected
if expected != result {
t.Fatalf("Expected %v, got %v", expected, result)
}
}
// Test the read file function
func TestReadFile(t *testing.T) {
t.Log("Creating a test file")
// Create a test file
err := os.WriteFile("/tmp/test.txt", []byte("test"), 0644)
// Check if there was an error
if err != nil {
t.Fatalf("Error creating test file: %v", err)
}
// Test the read file function
t.Log("Testing read file with a valid file")
data, err := utils.ReadFile("/tmp/test.txt")
// Check if there was an error
if err != nil {
t.Fatalf("Error reading file: %v", err)
}
// Check if the data is equal to the expected
if data != "test" {
t.Fatalf("Expected test, got %v", data)
}
// Cleanup the test file
t.Log("Cleaning up test file")
err = os.Remove("/tmp/test.txt")
// Check if there was an error
if err != nil {
t.Fatalf("Error cleaning up test file: %v", err)
}
}
// Test the parse file to line function
func TestParseFileToLine(t *testing.T) {
t.Log("Testing parse file to line with a valid string")
// Test the parse file to line function with a valid string
content := "user1:pass1\nuser2:pass2"
expected := "user1:pass1,user2:pass2"
result := utils.ParseFileToLine(content)
// Check if the result is equal to the expected
if expected != result {
t.Fatalf("Expected %v, got %v", expected, result)
}
}
// Test the get secret function
func TestGetSecret(t *testing.T) {
t.Log("Testing get secret with an empty config and file")
// Test the get secret function with an empty config and file
conf := ""
file := "/tmp/test.txt"
expected := "test"
// Create file
err := os.WriteFile(file, []byte(expected), 0644)
// Check if there was an error
if err != nil {
t.Fatalf("Error creating test file: %v", err)
}
// Test
result := utils.GetSecret(conf, file)
// Check if the result is equal to the expected
if result != expected {
t.Fatalf("Expected %v, got %v", expected, result)
}
t.Log("Testing get secret with an empty file and a valid config")
// Test the get secret function with an empty file and a valid config
result = utils.GetSecret(expected, "")
// Check if the result is equal to the expected
if result != expected {
t.Fatalf("Expected %v, got %v", expected, result)
}
t.Log("Testing get secret with both a valid config and file")
// Test the get secret function with both a valid config and file
result = utils.GetSecret(expected, file)
// Check if the result is equal to the expected
if result != expected {
t.Fatalf("Expected %v, got %v", expected, result)
}
// Cleanup the test file
t.Log("Cleaning up test file")
err = os.Remove(file)
// Check if there was an error
if err != nil {
t.Fatalf("Error cleaning up test file: %v", err)
}
}
// Test the get users function
func TestGetUsers(t *testing.T) {
t.Log("Testing get users with a config and no file")
// Test the get users function with a config and no file
conf := "user1:pass1,user2:pass2"
file := ""
expected := types.Users{
{
Username: "user1",
Password: "pass1",
},
{
Username: "user2",
Password: "pass2",
},
}
result, err := utils.GetUsers(conf, file)
// Check if there was an error
if err != nil {
t.Fatalf("Error getting users: %v", err)
}
// Check if the result is equal to the expected
if !reflect.DeepEqual(expected, result) {
t.Fatalf("Expected %v, got %v", expected, result)
}
t.Log("Testing get users with a file and no config")
// Test the get users function with a file and no config
conf = ""
file = "/tmp/test.txt"
expected = types.Users{
{
Username: "user1",
Password: "pass1",
},
{
Username: "user2",
Password: "pass2",
},
}
// Create file
err = os.WriteFile(file, []byte("user1:pass1\nuser2:pass2"), 0644)
// Check if there was an error
if err != nil {
t.Fatalf("Error creating test file: %v", err)
}
// Test
result, err = utils.GetUsers(conf, file)
// Check if there was an error
if err != nil {
t.Fatalf("Error getting users: %v", err)
}
// Check if the result is equal to the expected
if !reflect.DeepEqual(expected, result) {
t.Fatalf("Expected %v, got %v", expected, result)
}
// Test the get users function with both a config and file
t.Log("Testing get users with both a config and file")
conf = "user3:pass3"
expected = types.Users{
{
Username: "user3",
Password: "pass3",
},
{
Username: "user1",
Password: "pass1",
},
{
Username: "user2",
Password: "pass2",
},
}
result, err = utils.GetUsers(conf, file)
// Check if there was an error
if err != nil {
t.Fatalf("Error getting users: %v", err)
}
// Check if the result is equal to the expected
if !reflect.DeepEqual(expected, result) {
t.Fatalf("Expected %v, got %v", expected, result)
}
// Cleanup the test file
t.Log("Cleaning up test file")
err = os.Remove(file)
// Check if there was an error
if err != nil {
t.Fatalf("Error cleaning up test file: %v", err)
}
}
// Test the tinyauth labels function
func TestGetTinyauthLabels(t *testing.T) {
t.Log("Testing get tinyauth labels with a valid map")
// Test the get tinyauth labels function with a valid map
labels := map[string]string{
"tinyauth.users": "user1,user2",
"tinyauth.oauth.whitelist": "user1,user2",
"random": "random",
}
expected := types.TinyauthLabels{
Users: []string{"user1", "user2"},
OAuthWhitelist: []string{"user1", "user2"},
}
result := utils.GetTinyauthLabels(labels)
// Check if the result is equal to the expected
if !reflect.DeepEqual(expected, result) {
t.Fatalf("Expected %v, got %v", expected, result)
}
}
// Test the filter function
func TestFilter(t *testing.T) {
t.Log("Testing filter helper")
// Create variables
data := []string{"", "val1", "", "val2", "", "val3", ""}
expected := []string{"val1", "val2", "val3"}
// Test the filter function
result := utils.Filter(data, func(val string) bool {
return val != ""
})
// Check if the result is equal to the expected
if !reflect.DeepEqual(expected, result) {
t.Fatalf("Expected %v, got %v", expected, result)
}
}

View File

@@ -4,7 +4,6 @@ import (
"os"
"time"
"tinyauth/cmd"
"tinyauth/internal/assets"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
@@ -12,9 +11,8 @@ import (
func main() {
// Logger
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339}).With().Timestamp().Logger()
log.Info().Str("version", assets.Version).Msg("Starting tinyauth")
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339}).With().Timestamp().Logger().Level(zerolog.FatalLevel)
// Run cmd
cmd.Execute()
}

Binary file not shown.

18
site/src/icons/github.tsx Normal file
View File

@@ -0,0 +1,18 @@
import type { SVGProps } from "react";
export function GithubIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={24}
height={24}
viewBox="0 0 24 24"
{...props}
>
<path
fill="currentColor"
d="M12 2A10 10 0 0 0 2 12c0 4.42 2.87 8.17 6.84 9.5c.5.08.66-.23.66-.5v-1.69c-2.77.6-3.36-1.34-3.36-1.34c-.46-1.16-1.11-1.47-1.11-1.47c-.91-.62.07-.6.07-.6c1 .07 1.53 1.03 1.53 1.03c.87 1.52 2.34 1.07 2.91.83c.09-.65.35-1.09.63-1.34c-2.22-.25-4.55-1.11-4.55-4.92c0-1.11.38-2 1.03-2.71c-.1-.25-.45-1.29.1-2.64c0 0 .84-.27 2.75 1.02c.79-.22 1.65-.33 2.5-.33s1.71.11 2.5.33c1.91-1.29 2.75-1.02 2.75-1.02c.55 1.35.2 2.39.1 2.64c.65.71 1.03 1.6 1.03 2.71c0 3.82-2.34 4.66-4.57 4.91c.36.31.69.92.69 1.85V21c0 .27.16.59.67.5C19.14 20.16 22 16.42 22 12A10 10 0 0 0 12 2"
></path>
</svg>
);
}

30
site/src/icons/google.tsx Normal file
View File

@@ -0,0 +1,30 @@
import type { SVGProps } from "react";
export function GoogleIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={48}
height={48}
viewBox="0 0 48 48"
{...props}
>
<path
fill="#ffc107"
d="M43.611 20.083H42V20H24v8h11.303c-1.649 4.657-6.08 8-11.303 8c-6.627 0-12-5.373-12-12s5.373-12 12-12c3.059 0 5.842 1.154 7.961 3.039l5.657-5.657C34.046 6.053 29.268 4 24 4C12.955 4 4 12.955 4 24s8.955 20 20 20s20-8.955 20-20c0-1.341-.138-2.65-.389-3.917"
></path>
<path
fill="#ff3d00"
d="m6.306 14.691l6.571 4.819C14.655 15.108 18.961 12 24 12c3.059 0 5.842 1.154 7.961 3.039l5.657-5.657C34.046 6.053 29.268 4 24 4C16.318 4 9.656 8.337 6.306 14.691"
></path>
<path
fill="#4caf50"
d="M24 44c5.166 0 9.86-1.977 13.409-5.192l-6.19-5.238A11.9 11.9 0 0 1 24 36c-5.202 0-9.619-3.317-11.283-7.946l-6.522 5.025C9.505 39.556 16.227 44 24 44"
></path>
<path
fill="#1976d2"
d="M43.611 20.083H42V20H24v8h11.303a12.04 12.04 0 0 1-4.087 5.571l.003-.002l6.19 5.238C36.971 39.205 44 34 44 24c0-1.341-.138-2.65-.389-3.917"
></path>
</svg>
);
}

24
site/src/icons/oauth.tsx Normal file
View File

@@ -0,0 +1,24 @@
import type { SVGProps } from "react";
export function OAuthIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={24}
height={24}
viewBox="0 0 24 24"
{...props}
>
<g
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
>
<path d="M2 12a10 10 0 1 0 20 0a10 10 0 1 0-20 0"></path>
<path d="M12.556 6c.65 0 1.235.373 1.508.947l2.839 7.848a1.646 1.646 0 0 1-1.01 2.108a1.673 1.673 0 0 1-2.068-.851L13.365 15h-2.73l-.398.905A1.67 1.67 0 0 1 8.26 16.95l-.153-.047a1.647 1.647 0 0 1-1.056-1.956l2.824-7.852a1.66 1.66 0 0 1 1.409-1.087z"></path>
</g>
</svg>
);
}

View File

@@ -0,0 +1,55 @@
import type { SVGProps } from "react";
export function TailscaleIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512"
width={24}
height={24}
{...props}
>
<style>{".st0{opacity:0.2;fill:#CCCAC9;}.st1{fill:#FFFFFF;}"}</style>
<g>
<g>
<path
className="st0"
d="M65.6,127.7c35.3,0,63.9-28.6,63.9-63.9S100.9,0,65.6,0S1.8,28.6,1.8,63.9S30.4,127.7,65.6,127.7z"
/>
<path
className="st1"
d="M65.6,318.1c35.3,0,63.9-28.6,63.9-63.9s-28.6-63.9-63.9-63.9S1.8,219,1.8,254.2S30.4,318.1,65.6,318.1z"
/>
<path
className="st0"
d="M65.6,512c35.3,0,63.9-28.6,63.9-63.9s-28.6-63.9-63.9-63.9S1.8,412.9,1.8,448.1S30.4,512,65.6,512z"
/>
<path
className="st1"
d="M257.2,318.1c35.3,0,63.9-28.6,63.9-63.9s-28.6-63.9-63.9-63.9s-63.9,28.6-63.9,63.9S221.9,318.1,257.2,318.1z"
/>
<path
className="st1"
d="M257.2,512c35.3,0,63.9-28.6,63.9-63.9s-28.6-63.9-63.9-63.9s-63.9,28.6-63.9,63.9S221.9,512,257.2,512z"
/>
<path
className="st0"
d="M257.2,127.7c35.3,0,63.9-28.6,63.9-63.9S292.5,0,257.2,0s-63.9,28.6-63.9,63.9S221.9,127.7,257.2,127.7z"
/>
<path
className="st0"
d="M446.4,127.7c35.3,0,63.9-28.6,63.9-63.9S481.6,0,446.4,0c-35.3,0-63.9,28.6-63.9,63.9S411.1,127.7,446.4,127.7z"
/>
<path
className="st1"
d="M446.4,318.1c35.3,0,63.9-28.6,63.9-63.9s-28.6-63.9-63.9-63.9s-63.9,28.6-63.9,63.9S411.1,318.1,446.4,318.1z"
/>
<path
className="st0"
d="M446.4,512c35.3,0,63.9-28.6,63.9-63.9s-28.6-63.9-63.9-63.9s-63.9,28.6-63.9,63.9S411.1,512,446.4,512z"
/>
</g>
</g>
</svg>
);
}

View File

@@ -13,6 +13,8 @@ import { LoginPage } from "./pages/login-page.tsx";
import { LogoutPage } from "./pages/logout-page.tsx";
import { ContinuePage } from "./pages/continue-page.tsx";
import { NotFoundPage } from "./pages/not-found-page.tsx";
import { UnauthorizedPage } from "./pages/unauthorized-page.tsx";
import { InternalServerError } from "./pages/internal-server-error.tsx";
const queryClient = new QueryClient({
defaultOptions: {
@@ -34,6 +36,8 @@ createRoot(document.getElementById("root")!).render(
<Route path="/login" element={<LoginPage />} />
<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>

View File

@@ -1,18 +1,23 @@
import { Button, Paper, Text } from "@mantine/core";
import { Button, Code, Paper, Text } from "@mantine/core";
import { notifications } from "@mantine/notifications";
import { Navigate } from "react-router";
import { useUserContext } from "../context/user-context";
import { Layout } from "../components/layouts/layout";
import { ReactNode } from "react";
export const ContinuePage = () => {
const queryString = window.location.search;
const params = new URLSearchParams(queryString);
const redirectUri = params.get("redirect_uri");
const redirectUri = params.get("redirect_uri") ?? "";
const { isLoggedIn } = useUserContext();
const { isLoggedIn, disableContinue } = useUserContext();
if (!isLoggedIn) {
return <Navigate to="/login" />;
return <Navigate to={`/login?redirect_uri=${redirectUri}`} />;
}
if (redirectUri === "null" || redirectUri === "") {
return <Navigate to="/" />;
}
const redirect = () => {
@@ -22,31 +27,78 @@ export const ContinuePage = () => {
color: "blue",
});
setTimeout(() => {
window.location.replace(redirectUri!);
window.location.href = redirectUri;
}, 500);
};
let uri;
try {
uri = new URL(redirectUri);
} catch {
return (
<ContinuePageLayout>
<Text size="xl" fw={700}>
Invalid Redirect
</Text>
<Text>
The redirect URL is invalid, please contact the app owner to fix the
issue.
</Text>
</ContinuePageLayout>
);
}
if (
window.location.protocol === "https:" &&
uri.protocol === "http:"
) {
return (
<ContinuePageLayout>
<Text size="xl" fw={700}>
Insecure Redirect
</Text>
<Text>
Your are logged in but trying to redirect from <Code>https</Code> to{" "}
<Code>http</Code>, please click the button to redirect.
</Text>
<Button fullWidth mt="xl" onClick={redirect}>
Continue
</Button>
</ContinuePageLayout>
);
}
if (disableContinue) {
window.location.href = redirectUri;
return (
<ContinuePageLayout>
<Text size="xl" fw={700}>
Redirecting
</Text>
<Text>You should be redirected to your app soon.</Text>
</ContinuePageLayout>
);
}
return (
<ContinuePageLayout>
<Text size="xl" fw={700}>
Continue
</Text>
<Text>Click the button to continue to your app.</Text>
<Button fullWidth mt="xl" onClick={redirect}>
Continue
</Button>
</ContinuePageLayout>
);
};
export const ContinuePageLayout = ({ children }: { children: ReactNode }) => {
return (
<Layout>
<Paper shadow="md" p={30} mt={30} radius="md" withBorder>
{redirectUri !== "null" ? (
<>
<Text size="xl" fw={700}>
Continue
</Text>
<Text>Click the button to continue to your app.</Text>
<Button fullWidth mt="xl" onClick={redirect}>
Continue
</Button>
</>
) : (
<>
<Text size="xl" fw={700}>
Logged in
</Text>
<Text>You are now signed in and can use your apps.</Text>
</>
)}
{children}
</Paper>
</Layout>
);

View File

@@ -0,0 +1,21 @@
import { Button, Paper, Text } from "@mantine/core";
import { Layout } from "../components/layouts/layout";
export const InternalServerError = () => {
return (
<Layout>
<Paper shadow="md" p={30} mt={30} radius="md" withBorder>
<Text size="xl" fw={700}>
Internal Server Error
</Text>
<Text>
An error occured on the server and it currently cannot serve your
request.
</Text>
<Button fullWidth mt="xl" onClick={() => window.location.replace("/")}>
Try again
</Button>
</Paper>
</Layout>
);
};

View File

@@ -1,4 +1,13 @@
import { Button, Paper, PasswordInput, TextInput, Title } from "@mantine/core";
import {
Button,
Paper,
PasswordInput,
TextInput,
Title,
Text,
Divider,
Grid,
} from "@mantine/core";
import { useForm, zodResolver } from "@mantine/form";
import { notifications } from "@mantine/notifications";
import { useMutation } from "@tanstack/react-query";
@@ -7,13 +16,21 @@ import { z } from "zod";
import { useUserContext } from "../context/user-context";
import { Navigate } from "react-router";
import { Layout } from "../components/layouts/layout";
import { GoogleIcon } from "../icons/google";
import { GithubIcon } from "../icons/github";
import { OAuthIcon } from "../icons/oauth";
import { TailscaleIcon } from "../icons/tailscale";
export const LoginPage = () => {
const queryString = window.location.search;
const params = new URLSearchParams(queryString);
const redirectUri = params.get("redirect_uri");
const redirectUri = params.get("redirect_uri") ?? "";
const { isLoggedIn } = useUserContext();
const { isLoggedIn, configuredProviders } = useUserContext();
const oauthProviders = configuredProviders.filter(
(value) => value !== "username",
);
if (isLoggedIn) {
return <Navigate to="/logout" />;
@@ -53,9 +70,38 @@ export const LoginPage = () => {
color: "green",
});
setTimeout(() => {
window.location.replace(`/continue?redirect_uri=${redirectUri}`);
if (redirectUri === "null" || redirectUri === "") {
window.location.replace("/");
} else {
window.location.replace(`/continue?redirect_uri=${redirectUri}`);
}
}, 500);
},
});
const loginOAuthMutation = useMutation({
mutationFn: (provider: string) => {
return axios.get(
`/api/oauth/url/${provider}?redirect_uri=${redirectUri}`,
);
},
onError: () => {
notifications.show({
title: "Internal error",
message: "Failed to get OAuth URL",
color: "red",
});
},
onSuccess: (data) => {
notifications.show({
title: "Redirecting",
message: "Redirecting to your OAuth provider",
color: "blue",
});
setTimeout(() => {
window.location.href = data.data.url;
}, 500);
},
});
const handleSubmit = (values: FormValues) => {
@@ -64,35 +110,113 @@ export const LoginPage = () => {
return (
<Layout>
<Title ta="center">Welcome back!</Title>
<Paper shadow="md" p={30} mt={30} radius="md" withBorder>
<form onSubmit={form.onSubmit(handleSubmit)}>
<TextInput
label="Username"
placeholder="tinyauth"
required
disabled={loginMutation.isLoading}
key={form.key("username")}
{...form.getInputProps("username")}
/>
<PasswordInput
label="Password"
placeholder="password"
required
mt="md"
disabled={loginMutation.isLoading}
key={form.key("password")}
{...form.getInputProps("password")}
/>
<Button
fullWidth
mt="xl"
type="submit"
loading={loginMutation.isLoading}
>
Sign in
</Button>
</form>
<Title ta="center">Tinyauth</Title>
<Paper shadow="md" p="xl" mt={30} radius="md" withBorder>
{oauthProviders.length > 0 && (
<>
<Text size="lg" fw={500} ta="center">
Welcome back, login with
</Text>
<Grid mb="md" mt="md" align="center" justify="center">
{oauthProviders.includes("google") && (
<Grid.Col span="content">
<Button
radius="xl"
leftSection={
<GoogleIcon style={{ width: 14, height: 14 }} />
}
variant="default"
onClick={() => loginOAuthMutation.mutate("google")}
loading={loginOAuthMutation.isLoading}
>
Google
</Button>
</Grid.Col>
)}
{oauthProviders.includes("github") && (
<Grid.Col span="content">
<Button
radius="xl"
leftSection={
<GithubIcon style={{ width: 14, height: 14 }} />
}
variant="default"
onClick={() => loginOAuthMutation.mutate("github")}
loading={loginOAuthMutation.isLoading}
>
Github
</Button>
</Grid.Col>
)}
{oauthProviders.includes("tailscale") && (
<Grid.Col span="content">
<Button
radius="xl"
leftSection={
<TailscaleIcon style={{ width: 14, height: 14 }} />
}
variant="default"
onClick={() => loginOAuthMutation.mutate("tailscale")}
loading={loginOAuthMutation.isLoading}
>
Tailscale
</Button>
</Grid.Col>
)}
{oauthProviders.includes("generic") && (
<Grid.Col span="content">
<Button
radius="xl"
leftSection={
<OAuthIcon style={{ width: 14, height: 14 }} />
}
variant="default"
onClick={() => loginOAuthMutation.mutate("generic")}
loading={loginOAuthMutation.isLoading}
>
Generic
</Button>
</Grid.Col>
)}
</Grid>
{configuredProviders.includes("username") && (
<Divider
label="Or continue with password"
labelPosition="center"
my="lg"
/>
)}
</>
)}
{configuredProviders.includes("username") && (
<form onSubmit={form.onSubmit(handleSubmit)}>
<TextInput
label="Username"
placeholder="user@example.com"
required
disabled={loginMutation.isLoading}
key={form.key("username")}
{...form.getInputProps("username")}
/>
<PasswordInput
label="Password"
placeholder="password"
required
mt="md"
disabled={loginMutation.isLoading}
key={form.key("password")}
{...form.getInputProps("password")}
/>
<Button
fullWidth
mt="xl"
type="submit"
loading={loginMutation.isLoading}
>
Login
</Button>
</form>
)}
</Paper>
</Layout>
);

View File

@@ -5,9 +5,10 @@ import axios from "axios";
import { useUserContext } from "../context/user-context";
import { Navigate } from "react-router";
import { Layout } from "../components/layouts/layout";
import { capitalize } from "../utils/utils";
export const LogoutPage = () => {
const { isLoggedIn, username } = useUserContext();
const { isLoggedIn, username, oauth, provider } = useUserContext();
if (!isLoggedIn) {
return <Navigate to="/login" />;
@@ -31,7 +32,7 @@ export const LogoutPage = () => {
color: "green",
});
setTimeout(() => {
window.location.reload();
window.location.replace("/login");
}, 500);
},
});
@@ -43,8 +44,9 @@ export const LogoutPage = () => {
Logout
</Text>
<Text>
You are currently logged in as <Code>{username}</Code>, click the
button below to log out.
You are currently logged in as <Code>{username}</Code>
{oauth && ` using ${capitalize(provider)} OAuth`}. Click the button
below to log out.
</Text>
<Button
fullWidth

View File

@@ -0,0 +1,41 @@
import { Button, Code, Paper, Text } from "@mantine/core";
import { Layout } from "../components/layouts/layout";
import { Navigate } from "react-router";
export const UnauthorizedPage = () => {
const queryString = window.location.search;
const params = new URLSearchParams(queryString);
const username = params.get("username") ?? "";
const resource = params.get("resource") ?? "";
if (username === "null" || username === "") {
return <Navigate to="/" />;
}
return (
<Layout>
<Paper shadow="md" p={30} mt={30} radius="md" withBorder>
<Text size="xl" fw={700}>
Unauthorized
</Text>
<Text>
The user with username <Code>{username}</Code> is not authorized to{" "}
{resource !== "null" && resource !== "" ? (
<span>
access the <Code>{resource}</Code> resource.
</span>
) : (
"login."
)}
</Text>
<Button
fullWidth
mt="xl"
onClick={() => window.location.replace("/login")}
>
Try again
</Button>
</Paper>
</Layout>
);
};

View File

@@ -3,6 +3,10 @@ import { z } from "zod";
export const userContextSchema = z.object({
isLoggedIn: z.boolean(),
username: z.string(),
oauth: z.boolean(),
provider: z.string(),
configuredProviders: z.array(z.string()),
disableContinue: z.boolean(),
});
export type UserContextSchemaType = z.infer<typeof userContextSchema>;

1
site/src/utils/utils.ts Normal file
View File

@@ -0,0 +1 @@
export const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);