mirror of
https://github.com/steveiliop56/tinyauth.git
synced 2025-12-31 20:42:31 +00:00
Compare commits
21 Commits
feat/unifi
...
shreknel-f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f2f813902 | ||
|
|
014550f80e | ||
|
|
5ec9989189 | ||
|
|
ad12110fbf | ||
|
|
ca74534048 | ||
|
|
1b37096b58 | ||
|
|
cd068d16c2 | ||
|
|
5b5799ab62 | ||
|
|
672914ceb7 | ||
|
|
f006ebe5e4 | ||
|
|
dabb4398ad | ||
|
|
ef157ae9ba | ||
|
|
020fcb9878 | ||
|
|
986ac88e14 | ||
|
|
b159f44729 | ||
|
|
43487d44f7 | ||
|
|
2d8af0510e | ||
|
|
a1c3e416b6 | ||
|
|
7269fa1b95 | ||
|
|
ef25872fc3 | ||
|
|
03ed18343e |
3
.coderabbit.yaml
Normal file
3
.coderabbit.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
issue_enrichment:
|
||||
auto_enrich:
|
||||
enabled: false
|
||||
108
.env.example
108
.env.example
@@ -1,22 +1,86 @@
|
||||
PORT=3000
|
||||
ADDRESS=0.0.0.0
|
||||
APP_URL=http://localhost:3000
|
||||
USERS=your_user_password_hash
|
||||
USERS_FILE=users_file
|
||||
SECURE_COOKIE=false
|
||||
OAUTH_WHITELIST=
|
||||
GENERIC_NAME=My OAuth
|
||||
SESSION_EXPIRY=7200
|
||||
LOGIN_TIMEOUT=300
|
||||
LOGIN_MAX_RETRIES=5
|
||||
LOG_LEVEL=debug
|
||||
APP_TITLE=Tinyauth SSO
|
||||
FORGOT_PASSWORD_MESSAGE=Some message about resetting the password
|
||||
OAUTH_AUTO_REDIRECT=none
|
||||
BACKGROUND_IMAGE=some_image_url
|
||||
GENERIC_SKIP_SSL=false
|
||||
RESOURCES_DIR=/data/resources
|
||||
DATABASE_PATH=/data/tinyauth.db
|
||||
DISABLE_ANALYTICS=false
|
||||
DISABLE_RESOURCES=false
|
||||
TRUSTED_PROXIES=
|
||||
# Base Configuration
|
||||
|
||||
# The base URL where Tinyauth is accessible
|
||||
TINYAUTH_APPURL="https://auth.example.com"
|
||||
# Log level: trace, debug, info, warn, error
|
||||
TINYAUTH_LOGLEVEL="info"
|
||||
# Directory for static resources
|
||||
TINYAUTH_RESOURCESDIR="/data/resources"
|
||||
# Path to SQLite database file
|
||||
TINYAUTH_DATABASEPATH="/data/tinyauth.db"
|
||||
# Disable version heartbeat
|
||||
TINYAUTH_DISABLEANALYTICS="false"
|
||||
# Disable static resource serving
|
||||
TINYAUTH_DISABLERESOURCES="false"
|
||||
# Disable UI warning messages
|
||||
TINYAUTH_DISABLEUIWARNINGS="false"
|
||||
# Enable JSON formatted logs
|
||||
TINYAUTH_LOGJSON="false"
|
||||
|
||||
# Server Configuration
|
||||
|
||||
# Port to listen on
|
||||
TINYAUTH_SERVER_PORT="3000"
|
||||
# Interface to bind to (0.0.0.0 for all interfaces)
|
||||
TINYAUTH_SERVER_ADDRESS="0.0.0.0"
|
||||
# Unix socket path (optional, overrides port/address if set)
|
||||
TINYAUTH_SERVER_SOCKETPATH=""
|
||||
# Comma-separated list of trusted proxy IPs/CIDRs
|
||||
TINYAUTH_SERVER_TRUSTEDPROXIES=""
|
||||
|
||||
# Authentication Configuration
|
||||
|
||||
# Format: username:bcrypt_hash (use bcrypt to generate hash)
|
||||
TINYAUTH_AUTH_USERS="admin:$2a$10$example_bcrypt_hash_here"
|
||||
# Path to external users file (optional)
|
||||
TINYAUTH_USERSFILE=""
|
||||
# Enable secure cookies (requires HTTPS)
|
||||
TINYAUTH_SECURECOOKIE="true"
|
||||
# Session expiry in seconds (7200 = 2 hours)
|
||||
TINYAUTH_SESSIONEXPIRY="7200"
|
||||
# Login timeout in seconds (300 = 5 minutes)
|
||||
TINYAUTH_LOGINTIMEOUT="300"
|
||||
# Maximum login retries before lockout
|
||||
TINYAUTH_LOGINMAXRETRIES="5"
|
||||
|
||||
# OAuth Configuration
|
||||
|
||||
# Regex pattern for allowed email addresses (e.g., /@example\.com$/)
|
||||
TINYAUTH_OAUTH_WHITELIST=""
|
||||
# Provider ID to auto-redirect to (skips login page)
|
||||
TINYAUTH_OAUTH_AUTOREDIRECT=""
|
||||
# OAuth Provider Configuration (replace MYPROVIDER with your provider name)
|
||||
TINYAUTH_OAUTH_PROVIDERS_MYPROVIDER_CLIENTID="your_client_id_here"
|
||||
TINYAUTH_OAUTH_PROVIDERS_MYPROVIDER_CLIENTSECRET="your_client_secret_here"
|
||||
TINYAUTH_OAUTH_PROVIDERS_MYPROVIDER_AUTHURL="https://provider.example.com/oauth/authorize"
|
||||
TINYAUTH_OAUTH_PROVIDERS_MYPROVIDER_TOKENURL="https://provider.example.com/oauth/token"
|
||||
TINYAUTH_OAUTH_PROVIDERS_MYPROVIDER_USERINFOURL="https://provider.example.com/oauth/userinfo"
|
||||
TINYAUTH_OAUTH_PROVIDERS_MYPROVIDER_REDIRECTURL="https://auth.example.com/oauth/callback/myprovider"
|
||||
TINYAUTH_OAUTH_PROVIDERS_MYPROVIDER_SCOPES="openid email profile"
|
||||
TINYAUTH_OAUTH_PROVIDERS_MYPROVIDER_NAME="My OAuth Provider"
|
||||
# Allow self-signed certificates
|
||||
TINYAUTH_OAUTH_PROVIDERS_MYPROVIDER_INSECURE="false"
|
||||
|
||||
# UI Customization
|
||||
|
||||
# Custom title for login page
|
||||
TINYAUTH_UI_TITLE="Tinyauth"
|
||||
# Message shown on forgot password page
|
||||
TINYAUTH_UI_FORGOTPASSWORDMESSAGE="Contact your administrator to reset your password"
|
||||
# Background image URL for login page
|
||||
TINYAUTH_UI_BACKGROUNDIMAGE=""
|
||||
|
||||
# LDAP Configuration
|
||||
|
||||
# LDAP server address
|
||||
TINYAUTH_LDAP_ADDRESS="ldap://ldap.example.com:389"
|
||||
# DN for binding to LDAP server
|
||||
TINYAUTH_LDAP_BINDDN="cn=readonly,dc=example,dc=com"
|
||||
# Password for bind DN
|
||||
TINYAUTH_LDAP_BINDPASSWORD="your_bind_password"
|
||||
# Base DN for user searches
|
||||
TINYAUTH_LDAP_BASEDN="dc=example,dc=com"
|
||||
# Search filter (%s will be replaced with username)
|
||||
TINYAUTH_LDAP_SEARCHFILTER="(&(uid=%s)(memberOf=cn=users,ou=groups,dc=example,dc=com))"
|
||||
# Allow insecure LDAP connections
|
||||
TINYAUTH_LDAP_INSECURE="false"
|
||||
|
||||
4
.github/workflows/nightly.yml
vendored
4
.github/workflows/nightly.yml
vendored
@@ -80,7 +80,7 @@ jobs:
|
||||
- name: Build
|
||||
run: |
|
||||
cp -r frontend/dist internal/assets/dist
|
||||
go build -ldflags "-s -w -X tinyauth/internal/config.Version=${{ needs.generate-metadata.outputs.VERSION }} -X tinyauth/internal/config.CommitHash=${{ needs.generate-metadata.outputs.COMMIT_HASH }} -X tinyauth/internal/config.BuildTimestamp=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}" -o tinyauth-amd64
|
||||
go build -ldflags "-s -w -X tinyauth/internal/config.Version=${{ needs.generate-metadata.outputs.VERSION }} -X tinyauth/internal/config.CommitHash=${{ needs.generate-metadata.outputs.COMMIT_HASH }} -X tinyauth/internal/config.BuildTimestamp=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}" -o tinyauth-amd64 ./cmd/tinyauth
|
||||
env:
|
||||
CGO_ENABLED: 0
|
||||
|
||||
@@ -126,7 +126,7 @@ jobs:
|
||||
- name: Build
|
||||
run: |
|
||||
cp -r frontend/dist internal/assets/dist
|
||||
go build -ldflags "-s -w -X tinyauth/internal/config.Version=${{ needs.generate-metadata.outputs.VERSION }} -X tinyauth/internal/config.CommitHash=${{ needs.generate-metadata.outputs.COMMIT_HASH }} -X tinyauth/internal/config.BuildTimestamp=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}" -o tinyauth-arm64
|
||||
go build -ldflags "-s -w -X tinyauth/internal/config.Version=${{ needs.generate-metadata.outputs.VERSION }} -X tinyauth/internal/config.CommitHash=${{ needs.generate-metadata.outputs.COMMIT_HASH }} -X tinyauth/internal/config.BuildTimestamp=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}" -o tinyauth-arm64 ./cmd/tinyauth
|
||||
env:
|
||||
CGO_ENABLED: 0
|
||||
|
||||
|
||||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
@@ -58,7 +58,7 @@ jobs:
|
||||
- name: Build
|
||||
run: |
|
||||
cp -r frontend/dist internal/assets/dist
|
||||
go build -ldflags "-s -w -X tinyauth/internal/config.Version=${{ needs.generate-metadata.outputs.VERSION }} -X tinyauth/internal/config.CommitHash=${{ needs.generate-metadata.outputs.COMMIT_HASH }} -X tinyauth/internal/config.BuildTimestamp=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}" -o tinyauth-amd64
|
||||
go build -ldflags "-s -w -X tinyauth/internal/config.Version=${{ needs.generate-metadata.outputs.VERSION }} -X tinyauth/internal/config.CommitHash=${{ needs.generate-metadata.outputs.COMMIT_HASH }} -X tinyauth/internal/config.BuildTimestamp=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}" -o tinyauth-amd64 ./cmd/tinyauth
|
||||
env:
|
||||
CGO_ENABLED: 0
|
||||
|
||||
@@ -101,7 +101,7 @@ jobs:
|
||||
- name: Build
|
||||
run: |
|
||||
cp -r frontend/dist internal/assets/dist
|
||||
go build -ldflags "-s -w -X tinyauth/internal/config.Version=${{ needs.generate-metadata.outputs.VERSION }} -X tinyauth/internal/config.CommitHash=${{ needs.generate-metadata.outputs.COMMIT_HASH }} -X tinyauth/internal/config.BuildTimestamp=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}" -o tinyauth-arm64
|
||||
go build -ldflags "-s -w -X tinyauth/internal/config.Version=${{ needs.generate-metadata.outputs.VERSION }} -X tinyauth/internal/config.CommitHash=${{ needs.generate-metadata.outputs.COMMIT_HASH }} -X tinyauth/internal/config.BuildTimestamp=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}" -o tinyauth-arm64 ./cmd/tinyauth
|
||||
env:
|
||||
CGO_ENABLED: 0
|
||||
|
||||
|
||||
28
.gitignore
vendored
28
.gitignore
vendored
@@ -1,29 +1,39 @@
|
||||
# dist
|
||||
internal/assets/dist
|
||||
/internal/assets/dist
|
||||
|
||||
# binaries
|
||||
tinyauth
|
||||
/tinyauth
|
||||
|
||||
# test docker compose
|
||||
docker-compose.test*
|
||||
/docker-compose.test*
|
||||
|
||||
# users file
|
||||
users.txt
|
||||
/users.txt
|
||||
|
||||
# secret test file
|
||||
secret*
|
||||
/secret*
|
||||
|
||||
# apple stuff
|
||||
.DS_Store
|
||||
|
||||
# env
|
||||
.env
|
||||
/.env
|
||||
|
||||
# tmp directory
|
||||
tmp
|
||||
/tmp
|
||||
|
||||
# version files
|
||||
internal/assets/version
|
||||
/internal/assets/version
|
||||
|
||||
# data directory
|
||||
data
|
||||
/data
|
||||
|
||||
# config file
|
||||
/config.yml
|
||||
|
||||
# binary out
|
||||
/tinyauth.db
|
||||
/resources
|
||||
|
||||
# debug files
|
||||
__debug_*
|
||||
1
.review_trigger
Normal file
1
.review_trigger
Normal file
@@ -0,0 +1 @@
|
||||
# Trigger automated review
|
||||
@@ -33,12 +33,11 @@ COPY go.sum ./
|
||||
|
||||
RUN go mod download
|
||||
|
||||
COPY ./main.go ./
|
||||
COPY ./cmd ./cmd
|
||||
COPY ./internal ./internal
|
||||
COPY --from=frontend-builder /frontend/dist ./internal/assets/dist
|
||||
|
||||
RUN CGO_ENABLED=0 go build -ldflags "-s -w -X tinyauth/internal/config.Version=${VERSION} -X tinyauth/internal/config.CommitHash=${COMMIT_HASH} -X tinyauth/internal/config.BuildTimestamp=${BUILD_TIMESTAMP}"
|
||||
RUN CGO_ENABLED=0 go build -ldflags "-s -w -X tinyauth/internal/config.Version=${VERSION} -X tinyauth/internal/config.CommitHash=${COMMIT_HASH} -X tinyauth/internal/config.BuildTimestamp=${BUILD_TIMESTAMP}" ./cmd/tinyauth
|
||||
|
||||
# Runner
|
||||
FROM alpine:3.23 AS runner
|
||||
@@ -53,6 +52,10 @@ EXPOSE 3000
|
||||
|
||||
VOLUME ["/data"]
|
||||
|
||||
ENV DATABASEPATH=/data/tinyauth.db
|
||||
|
||||
ENV RESOURCESDIR=/data/resources
|
||||
|
||||
ENV GIN_MODE=release
|
||||
|
||||
ENV PATH=$PATH:/tinyauth
|
||||
|
||||
@@ -12,9 +12,12 @@ RUN go install github.com/go-delve/delve/cmd/dlv@latest
|
||||
|
||||
COPY ./cmd ./cmd
|
||||
COPY ./internal ./internal
|
||||
COPY ./main.go ./
|
||||
COPY ./air.toml ./
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV TINYAUTH_DATABASEPATH=/data/tinyauth.db
|
||||
|
||||
ENV TINYAUTH_RESOURCESDIR=/data/resources
|
||||
|
||||
ENTRYPOINT ["air", "-c", "air.toml"]
|
||||
@@ -33,14 +33,13 @@ COPY go.sum ./
|
||||
|
||||
RUN go mod download
|
||||
|
||||
COPY ./main.go ./
|
||||
COPY ./cmd ./cmd
|
||||
COPY ./internal ./internal
|
||||
COPY --from=frontend-builder /frontend/dist ./internal/assets/dist
|
||||
|
||||
RUN mkdir -p data
|
||||
|
||||
RUN CGO_ENABLED=0 go build -ldflags "-s -w -X tinyauth/internal/config.Version=${VERSION} -X tinyauth/internal/config.CommitHash=${COMMIT_HASH} -X tinyauth/internal/config.BuildTimestamp=${BUILD_TIMESTAMP}"
|
||||
RUN CGO_ENABLED=0 go build -ldflags "-s -w -X tinyauth/internal/config.Version=${VERSION} -X tinyauth/internal/config.CommitHash=${COMMIT_HASH} -X tinyauth/internal/config.BuildTimestamp=${BUILD_TIMESTAMP}" ./cmd/tinyauth
|
||||
|
||||
# Runner
|
||||
FROM gcr.io/distroless/static-debian12:latest AS runner
|
||||
@@ -56,6 +55,10 @@ EXPOSE 3000
|
||||
|
||||
VOLUME ["/data"]
|
||||
|
||||
ENV TINYAUTH_DATABASEPATH=/data/tinyauth.db
|
||||
|
||||
ENV TINYAUTH_RESOURCESDIR=/data/resources
|
||||
|
||||
ENV GIN_MODE=release
|
||||
|
||||
ENV PATH=$PATH:/tinyauth
|
||||
|
||||
2
air.toml
2
air.toml
@@ -3,7 +3,7 @@ tmp_dir = "tmp"
|
||||
|
||||
[build]
|
||||
pre_cmd = ["mkdir -p internal/assets/dist", "mkdir -p /data", "echo 'backend running' > internal/assets/dist/index.html"]
|
||||
cmd = "CGO_ENABLED=0 go build -gcflags=\"all=-N -l\" -o tmp/tinyauth ."
|
||||
cmd = "CGO_ENABLED=0 go build -gcflags=\"all=-N -l\" -o tmp/tinyauth ./cmd/tinyauth"
|
||||
bin = "tmp/tinyauth"
|
||||
full_bin = "dlv --listen :4000 --headless=true --api-version=2 --accept-multiclient --log=true exec tmp/tinyauth --continue --check-go-version=false"
|
||||
include_ext = ["go"]
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type createUserCmd struct {
|
||||
root *cobra.Command
|
||||
cmd *cobra.Command
|
||||
|
||||
interactive bool
|
||||
docker bool
|
||||
username string
|
||||
password string
|
||||
}
|
||||
|
||||
func newCreateUserCmd(root *cobra.Command) *createUserCmd {
|
||||
return &createUserCmd{
|
||||
root: root,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *createUserCmd) Register() {
|
||||
c.cmd = &cobra.Command{
|
||||
Use: "create",
|
||||
Short: "Create a user",
|
||||
Long: `Create a user either interactively or by passing flags.`,
|
||||
Run: c.run,
|
||||
}
|
||||
|
||||
c.cmd.Flags().BoolVarP(&c.interactive, "interactive", "i", false, "Create a user interactively")
|
||||
c.cmd.Flags().BoolVar(&c.docker, "docker", false, "Format output for docker")
|
||||
c.cmd.Flags().StringVar(&c.username, "username", "", "Username")
|
||||
c.cmd.Flags().StringVar(&c.password, "password", "", "Password")
|
||||
|
||||
if c.root != nil {
|
||||
c.root.AddCommand(c.cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *createUserCmd) GetCmd() *cobra.Command {
|
||||
return c.cmd
|
||||
}
|
||||
|
||||
func (c *createUserCmd) run(cmd *cobra.Command, args []string) {
|
||||
log.Logger = log.Level(zerolog.InfoLevel)
|
||||
|
||||
if c.interactive {
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewInput().Title("Username").Value(&c.username).Validate((func(s string) error {
|
||||
if s == "" {
|
||||
return errors.New("username cannot be empty")
|
||||
}
|
||||
return nil
|
||||
})),
|
||||
huh.NewInput().Title("Password").Value(&c.password).Validate((func(s string) error {
|
||||
if s == "" {
|
||||
return errors.New("password cannot be empty")
|
||||
}
|
||||
return nil
|
||||
})),
|
||||
huh.NewSelect[bool]().Title("Format the output for Docker?").Options(huh.NewOption("Yes", true), huh.NewOption("No", false)).Value(&c.docker),
|
||||
),
|
||||
)
|
||||
var baseTheme *huh.Theme = huh.ThemeBase()
|
||||
err := form.WithTheme(baseTheme).Run()
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("Form failed")
|
||||
}
|
||||
}
|
||||
|
||||
if c.username == "" || c.password == "" {
|
||||
log.Fatal().Err(errors.New("error invalid input")).Msg("Username and password cannot be empty")
|
||||
}
|
||||
|
||||
log.Info().Str("username", c.username).Msg("Creating user")
|
||||
|
||||
passwd, err := bcrypt.GenerateFromPassword([]byte(c.password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("Failed to hash password")
|
||||
}
|
||||
|
||||
// If docker format is enabled, escape the dollar sign
|
||||
passwdStr := string(passwd)
|
||||
if c.docker {
|
||||
passwdStr = strings.ReplaceAll(passwdStr, "$", "$$")
|
||||
}
|
||||
|
||||
log.Info().Str("user", fmt.Sprintf("%s:%s", c.username, passwdStr)).Msg("User created")
|
||||
}
|
||||
120
cmd/generate.go
120
cmd/generate.go
@@ -1,120 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"tinyauth/internal/utils"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/mdp/qrterminal/v3"
|
||||
"github.com/pquerna/otp/totp"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type generateTotpCmd struct {
|
||||
root *cobra.Command
|
||||
cmd *cobra.Command
|
||||
|
||||
interactive bool
|
||||
user string
|
||||
}
|
||||
|
||||
func newGenerateTotpCmd(root *cobra.Command) *generateTotpCmd {
|
||||
return &generateTotpCmd{
|
||||
root: root,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *generateTotpCmd) Register() {
|
||||
c.cmd = &cobra.Command{
|
||||
Use: "generate",
|
||||
Short: "Generate a totp secret",
|
||||
Long: `Generate a totp secret for a user either interactively or by passing flags.`,
|
||||
Run: c.run,
|
||||
}
|
||||
|
||||
c.cmd.Flags().BoolVarP(&c.interactive, "interactive", "i", false, "Run in interactive mode")
|
||||
c.cmd.Flags().StringVar(&c.user, "user", "", "Your current user (username:hash)")
|
||||
|
||||
if c.root != nil {
|
||||
c.root.AddCommand(c.cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *generateTotpCmd) GetCmd() *cobra.Command {
|
||||
return c.cmd
|
||||
}
|
||||
|
||||
func (c *generateTotpCmd) run(cmd *cobra.Command, args []string) {
|
||||
log.Logger = log.Level(zerolog.InfoLevel)
|
||||
|
||||
if c.interactive {
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewInput().Title("Current user (username:hash)").Value(&c.user).Validate((func(s string) error {
|
||||
if s == "" {
|
||||
return errors.New("user cannot be empty")
|
||||
}
|
||||
return nil
|
||||
})),
|
||||
),
|
||||
)
|
||||
var baseTheme *huh.Theme = huh.ThemeBase()
|
||||
err := form.WithTheme(baseTheme).Run()
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("Form failed")
|
||||
}
|
||||
}
|
||||
|
||||
user, err := utils.ParseUser(c.user)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("Failed to parse user")
|
||||
}
|
||||
|
||||
docker := false
|
||||
if strings.Contains(c.user, "$$") {
|
||||
docker = true
|
||||
}
|
||||
|
||||
if user.TotpSecret != "" {
|
||||
log.Fatal().Msg("User already has a TOTP secret")
|
||||
}
|
||||
|
||||
key, err := totp.Generate(totp.GenerateOpts{
|
||||
Issuer: "Tinyauth",
|
||||
AccountName: user.Username,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("Failed to generate TOTP secret")
|
||||
}
|
||||
|
||||
secret := key.Secret()
|
||||
|
||||
log.Info().Str("secret", secret).Msg("Generated TOTP secret")
|
||||
|
||||
log.Info().Msg("Generated QR code")
|
||||
|
||||
config := qrterminal.Config{
|
||||
Level: qrterminal.L,
|
||||
Writer: os.Stdout,
|
||||
BlackChar: qrterminal.BLACK,
|
||||
WhiteChar: qrterminal.WHITE,
|
||||
QuietZone: 2,
|
||||
}
|
||||
|
||||
qrterminal.GenerateWithConfig(key.URL(), config)
|
||||
|
||||
user.TotpSecret = secret
|
||||
|
||||
// If using docker escape re-escape it
|
||||
if docker {
|
||||
user.Password = strings.ReplaceAll(user.Password, "$", "$$")
|
||||
}
|
||||
|
||||
log.Info().Str("user", fmt.Sprintf("%s:%s:%s", user.Username, user.Password, user.TotpSecret)).Msg("Add the totp secret to your authenticator app then use the verify command to ensure everything is working correctly.")
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
type healthzResponse struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type healthcheckCmd struct {
|
||||
root *cobra.Command
|
||||
cmd *cobra.Command
|
||||
|
||||
viper *viper.Viper
|
||||
}
|
||||
|
||||
func newHealthcheckCmd(root *cobra.Command) *healthcheckCmd {
|
||||
return &healthcheckCmd{
|
||||
root: root,
|
||||
viper: viper.New(),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *healthcheckCmd) Register() {
|
||||
c.cmd = &cobra.Command{
|
||||
Use: "healthcheck [app-url]",
|
||||
Short: "Perform a health check",
|
||||
Long: `Use the health check endpoint to verify that Tinyauth is running and it's healthy.`,
|
||||
Run: c.run,
|
||||
}
|
||||
|
||||
c.viper.AutomaticEnv()
|
||||
|
||||
if c.root != nil {
|
||||
c.root.AddCommand(c.cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *healthcheckCmd) GetCmd() *cobra.Command {
|
||||
return c.cmd
|
||||
}
|
||||
|
||||
func (c *healthcheckCmd) run(cmd *cobra.Command, args []string) {
|
||||
log.Logger = log.Level(zerolog.InfoLevel)
|
||||
|
||||
var appUrl string
|
||||
|
||||
port := c.viper.GetString("PORT")
|
||||
address := c.viper.GetString("ADDRESS")
|
||||
|
||||
if port == "" {
|
||||
port = "3000"
|
||||
}
|
||||
|
||||
if address == "" {
|
||||
address = "127.0.0.1"
|
||||
}
|
||||
|
||||
appUrl = "http://" + address + ":" + port
|
||||
|
||||
if len(args) > 0 {
|
||||
appUrl = args[0]
|
||||
}
|
||||
|
||||
log.Info().Str("app_url", appUrl).Msg("Performing health check")
|
||||
|
||||
client := http.Client{}
|
||||
|
||||
req, err := http.NewRequest("GET", appUrl+"/api/healthz", nil)
|
||||
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("Failed to create request")
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("Failed to perform request")
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
log.Fatal().Err(errors.New("service is not healthy")).Msgf("Service is not healthy. Status code: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
var healthResp healthzResponse
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("Failed to read response")
|
||||
}
|
||||
|
||||
err = json.Unmarshal(body, &healthResp)
|
||||
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("Failed to decode response")
|
||||
}
|
||||
|
||||
log.Info().Interface("response", healthResp).Msg("Tinyauth is healthy")
|
||||
}
|
||||
162
cmd/root.go
162
cmd/root.go
@@ -1,162 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"tinyauth/internal/bootstrap"
|
||||
"tinyauth/internal/config"
|
||||
"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"
|
||||
)
|
||||
|
||||
type rootCmd struct {
|
||||
root *cobra.Command
|
||||
cmd *cobra.Command
|
||||
|
||||
viper *viper.Viper
|
||||
}
|
||||
|
||||
func newRootCmd() *rootCmd {
|
||||
return &rootCmd{
|
||||
viper: viper.New(),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *rootCmd) Register() {
|
||||
c.cmd = &cobra.Command{
|
||||
Use: "tinyauth",
|
||||
Short: "The simplest way to protect your apps with a login screen",
|
||||
Long: `Tinyauth is a simple authentication middleware that adds a simple login screen or OAuth with Google, Github or any other provider to all of your docker apps.`,
|
||||
Run: c.run,
|
||||
}
|
||||
|
||||
// Ignore unknown flags to allow --providers-*
|
||||
c.cmd.FParseErrWhitelist.UnknownFlags = true
|
||||
|
||||
c.viper.AutomaticEnv()
|
||||
|
||||
configOptions := []struct {
|
||||
name string
|
||||
defaultVal any
|
||||
description string
|
||||
}{
|
||||
{"port", 3000, "Port to run the server on."},
|
||||
{"address", "0.0.0.0", "Address to bind the server to."},
|
||||
{"app-url", "", "The Tinyauth URL."},
|
||||
{"users", "", "Comma separated list of users in the format username:hash."},
|
||||
{"users-file", "", "Path to a file containing users in the format username:hash."},
|
||||
{"secure-cookie", false, "Send cookie over secure connection only."},
|
||||
{"oauth-whitelist", "", "Comma separated list of email addresses to whitelist when using OAuth."},
|
||||
{"oauth-auto-redirect", "none", "Auto redirect to the specified OAuth provider if configured. (available providers: github, google, generic)"},
|
||||
{"session-expiry", 86400, "Session (cookie) expiration time in seconds."},
|
||||
{"login-timeout", 300, "Login timeout in seconds after max retries reached (0 to disable)."},
|
||||
{"login-max-retries", 5, "Maximum login attempts before timeout (0 to disable)."},
|
||||
{"log-level", "info", "Log level."},
|
||||
{"app-title", "Tinyauth", "Title of the app."},
|
||||
{"forgot-password-message", "", "Message to show on the forgot password page."},
|
||||
{"background-image", "/background.jpg", "Background image URL for the login page."},
|
||||
{"ldap-address", "", "LDAP server address (e.g. ldap://localhost:389)."},
|
||||
{"ldap-bind-dn", "", "LDAP bind DN (e.g. uid=user,dc=example,dc=com)."},
|
||||
{"ldap-bind-password", "", "LDAP bind password."},
|
||||
{"ldap-base-dn", "", "LDAP base DN (e.g. dc=example,dc=com)."},
|
||||
{"ldap-insecure", false, "Skip certificate verification for the LDAP server."},
|
||||
{"ldap-search-filter", "(uid=%s)", "LDAP search filter for user lookup."},
|
||||
{"resources-dir", "/data/resources", "Path to a directory containing custom resources (e.g. background image)."},
|
||||
{"database-path", "/data/tinyauth.db", "Path to the Sqlite database file. Directory will be created if it doesn't exist."},
|
||||
{"trusted-proxies", "", "Comma separated list of trusted proxies (IP addresses or CIDRs) for correct client IP detection."},
|
||||
{"disable-analytics", false, "Disable anonymous version collection."},
|
||||
{"disable-resources", false, "Disable the resources server."},
|
||||
{"socket-path", "", "Path to the Unix socket to bind the server to."},
|
||||
{"disable-ui-warnings", false, "Disable UI warnings about insecure configurations."},
|
||||
}
|
||||
|
||||
for _, opt := range configOptions {
|
||||
switch v := opt.defaultVal.(type) {
|
||||
case bool:
|
||||
c.cmd.Flags().Bool(opt.name, v, opt.description)
|
||||
case int:
|
||||
c.cmd.Flags().Int(opt.name, v, opt.description)
|
||||
case string:
|
||||
c.cmd.Flags().String(opt.name, v, opt.description)
|
||||
}
|
||||
|
||||
// Create uppercase env var name
|
||||
envVar := strings.ReplaceAll(strings.ToUpper(opt.name), "-", "_")
|
||||
c.viper.BindEnv(opt.name, envVar)
|
||||
}
|
||||
|
||||
c.viper.BindPFlags(c.cmd.Flags())
|
||||
|
||||
if c.root != nil {
|
||||
c.root.AddCommand(c.cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *rootCmd) GetCmd() *cobra.Command {
|
||||
return c.cmd
|
||||
}
|
||||
|
||||
func (c *rootCmd) run(cmd *cobra.Command, args []string) {
|
||||
var conf config.Config
|
||||
|
||||
err := c.viper.Unmarshal(&conf)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("Failed to parse config")
|
||||
}
|
||||
|
||||
v := validator.New()
|
||||
err = v.Struct(conf)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("Invalid config")
|
||||
}
|
||||
|
||||
log.Logger = log.Level(zerolog.Level(utils.GetLogLevel(conf.LogLevel)))
|
||||
log.Info().Str("version", strings.TrimSpace(config.Version)).Msg("Starting Tinyauth")
|
||||
|
||||
if log.Logger.GetLevel() == zerolog.TraceLevel {
|
||||
log.Warn().Msg("Log level set to trace, this will log sensitive information!")
|
||||
}
|
||||
|
||||
app := bootstrap.NewBootstrapApp(conf)
|
||||
|
||||
err = app.Setup()
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("Failed to setup app")
|
||||
}
|
||||
}
|
||||
|
||||
func Run() {
|
||||
rootCmd := newRootCmd()
|
||||
rootCmd.Register()
|
||||
root := rootCmd.GetCmd()
|
||||
|
||||
userCmd := &cobra.Command{
|
||||
Use: "user",
|
||||
Short: "User utilities",
|
||||
Long: `Utilities for creating and verifying tinyauth compatible users.`,
|
||||
}
|
||||
totpCmd := &cobra.Command{
|
||||
Use: "totp",
|
||||
Short: "Totp utilities",
|
||||
Long: `Utilities for creating and verifying totp codes.`,
|
||||
}
|
||||
|
||||
newCreateUserCmd(userCmd).Register()
|
||||
newVerifyUserCmd(userCmd).Register()
|
||||
newGenerateTotpCmd(totpCmd).Register()
|
||||
newVersionCmd(root).Register()
|
||||
newHealthcheckCmd(root).Register()
|
||||
|
||||
root.AddCommand(userCmd)
|
||||
root.AddCommand(totpCmd)
|
||||
|
||||
err := root.Execute()
|
||||
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("Failed to execute root command")
|
||||
}
|
||||
}
|
||||
98
cmd/tinyauth/create.go
Normal file
98
cmd/tinyauth/create.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/traefik/paerser/cli"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type CreateUserConfig struct {
|
||||
Interactive bool `description:"Create a user interactively."`
|
||||
Docker bool `description:"Format output for docker."`
|
||||
Username string `description:"Username."`
|
||||
Password string `description:"Password."`
|
||||
}
|
||||
|
||||
func NewCreateUserConfig() *CreateUserConfig {
|
||||
return &CreateUserConfig{
|
||||
Interactive: false,
|
||||
Docker: false,
|
||||
Username: "",
|
||||
Password: "",
|
||||
}
|
||||
}
|
||||
|
||||
func createUserCmd() *cli.Command {
|
||||
tCfg := NewCreateUserConfig()
|
||||
|
||||
loaders := []cli.ResourceLoader{
|
||||
&cli.FlagLoader{},
|
||||
}
|
||||
|
||||
return &cli.Command{
|
||||
Name: "create",
|
||||
Description: "Create a user",
|
||||
Configuration: tCfg,
|
||||
Resources: loaders,
|
||||
Run: func(_ []string) error {
|
||||
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339}).With().Caller().Logger().Level(zerolog.InfoLevel)
|
||||
|
||||
if tCfg.Interactive {
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewInput().Title("Username").Value(&tCfg.Username).Validate((func(s string) error {
|
||||
if s == "" {
|
||||
return errors.New("username cannot be empty")
|
||||
}
|
||||
return nil
|
||||
})),
|
||||
huh.NewInput().Title("Password").Value(&tCfg.Password).Validate((func(s string) error {
|
||||
if s == "" {
|
||||
return errors.New("password cannot be empty")
|
||||
}
|
||||
return nil
|
||||
})),
|
||||
huh.NewSelect[bool]().Title("Format the output for Docker?").Options(huh.NewOption("Yes", true), huh.NewOption("No", false)).Value(&tCfg.Docker),
|
||||
),
|
||||
)
|
||||
|
||||
var baseTheme *huh.Theme = huh.ThemeBase()
|
||||
|
||||
err := form.WithTheme(baseTheme).Run()
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to run interactive prompt: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if tCfg.Username == "" || tCfg.Password == "" {
|
||||
return errors.New("username and password cannot be empty")
|
||||
}
|
||||
|
||||
log.Info().Str("username", tCfg.Username).Msg("Creating user")
|
||||
|
||||
passwd, err := bcrypt.GenerateFromPassword([]byte(tCfg.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to hash password: %w", err)
|
||||
}
|
||||
|
||||
// If docker format is enabled, escape the dollar sign
|
||||
passwdStr := string(passwd)
|
||||
if tCfg.Docker {
|
||||
passwdStr = strings.ReplaceAll(passwdStr, "$", "$$")
|
||||
}
|
||||
|
||||
log.Info().Str("user", fmt.Sprintf("%s:%s", tCfg.Username, passwdStr)).Msg("User created")
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
120
cmd/tinyauth/generate.go
Normal file
120
cmd/tinyauth/generate.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/steveiliop56/tinyauth/internal/utils"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/mdp/qrterminal/v3"
|
||||
"github.com/pquerna/otp/totp"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/traefik/paerser/cli"
|
||||
)
|
||||
|
||||
type GenerateTotpConfig struct {
|
||||
Interactive bool `description:"Generate a TOTP secret interactively."`
|
||||
User string `description:"Your current user (username:hash)."`
|
||||
}
|
||||
|
||||
func NewGenerateTotpConfig() *GenerateTotpConfig {
|
||||
return &GenerateTotpConfig{
|
||||
Interactive: false,
|
||||
User: "",
|
||||
}
|
||||
}
|
||||
|
||||
func generateTotpCmd() *cli.Command {
|
||||
tCfg := NewGenerateTotpConfig()
|
||||
|
||||
loaders := []cli.ResourceLoader{
|
||||
&cli.FlagLoader{},
|
||||
}
|
||||
|
||||
return &cli.Command{
|
||||
Name: "generate",
|
||||
Description: "Generate a TOTP secret",
|
||||
Configuration: tCfg,
|
||||
Resources: loaders,
|
||||
Run: func(_ []string) error {
|
||||
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339}).With().Caller().Logger().Level(zerolog.InfoLevel)
|
||||
|
||||
if tCfg.Interactive {
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewInput().Title("Current user (username:hash)").Value(&tCfg.User).Validate((func(s string) error {
|
||||
if s == "" {
|
||||
return errors.New("user cannot be empty")
|
||||
}
|
||||
return nil
|
||||
})),
|
||||
),
|
||||
)
|
||||
|
||||
var baseTheme *huh.Theme = huh.ThemeBase()
|
||||
|
||||
err := form.WithTheme(baseTheme).Run()
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to run interactive prompt: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
user, err := utils.ParseUser(tCfg.User)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse user: %w", err)
|
||||
}
|
||||
|
||||
docker := false
|
||||
if strings.Contains(tCfg.User, "$$") {
|
||||
docker = true
|
||||
}
|
||||
|
||||
if user.TotpSecret != "" {
|
||||
return fmt.Errorf("user already has a TOTP secret")
|
||||
}
|
||||
|
||||
key, err := totp.Generate(totp.GenerateOpts{
|
||||
Issuer: "Tinyauth",
|
||||
AccountName: user.Username,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate TOTP secret: %w", err)
|
||||
}
|
||||
|
||||
secret := key.Secret()
|
||||
|
||||
log.Info().Str("secret", secret).Msg("Generated TOTP secret")
|
||||
|
||||
log.Info().Msg("Generated QR code")
|
||||
|
||||
config := qrterminal.Config{
|
||||
Level: qrterminal.L,
|
||||
Writer: os.Stdout,
|
||||
BlackChar: qrterminal.BLACK,
|
||||
WhiteChar: qrterminal.WHITE,
|
||||
QuietZone: 2,
|
||||
}
|
||||
|
||||
qrterminal.GenerateWithConfig(key.URL(), config)
|
||||
|
||||
user.TotpSecret = secret
|
||||
|
||||
// If using docker escape re-escape it
|
||||
if docker {
|
||||
user.Password = strings.ReplaceAll(user.Password, "$", "$$")
|
||||
}
|
||||
|
||||
log.Info().Str("user", fmt.Sprintf("%s:%s:%s", user.Username, user.Password, user.TotpSecret)).Msg("Add the totp secret to your authenticator app then use the verify command to ensure everything is working correctly.")
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
85
cmd/tinyauth/healthcheck.go
Normal file
85
cmd/tinyauth/healthcheck.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/traefik/paerser/cli"
|
||||
)
|
||||
|
||||
type healthzResponse struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func healthcheckCmd() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "healthcheck",
|
||||
Description: "Perform a health check",
|
||||
Configuration: nil,
|
||||
Resources: nil,
|
||||
AllowArg: true,
|
||||
Run: func(args []string) error {
|
||||
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339}).With().Caller().Logger().Level(zerolog.InfoLevel)
|
||||
|
||||
appUrl := os.Getenv("TINYAUTH_APPURL")
|
||||
|
||||
if len(args) > 0 {
|
||||
appUrl = args[0]
|
||||
}
|
||||
|
||||
if appUrl == "" {
|
||||
return errors.New("TINYAUTH_APPURL is not set and no argument was provided")
|
||||
}
|
||||
|
||||
log.Info().Str("app_url", appUrl).Msg("Performing health check")
|
||||
|
||||
client := http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", appUrl+"/api/healthz", nil)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to perform request: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("service is not healthy, got: %s", resp.Status)
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
var healthResp healthzResponse
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
err = json.Unmarshal(body, &healthResp)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
log.Info().Interface("response", healthResp).Msg("Tinyauth is healthy")
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
129
cmd/tinyauth/tinyauth.go
Normal file
129
cmd/tinyauth/tinyauth.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/steveiliop56/tinyauth/internal/bootstrap"
|
||||
"github.com/steveiliop56/tinyauth/internal/config"
|
||||
"github.com/steveiliop56/tinyauth/internal/utils/loaders"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/traefik/paerser/cli"
|
||||
)
|
||||
|
||||
func NewTinyauthCmdConfiguration() *config.Config {
|
||||
return &config.Config{
|
||||
LogLevel: "info",
|
||||
ResourcesDir: "./resources",
|
||||
DatabasePath: "./tinyauth.db",
|
||||
Server: config.ServerConfig{
|
||||
Port: 3000,
|
||||
Address: "0.0.0.0",
|
||||
},
|
||||
Auth: config.AuthConfig{
|
||||
SessionExpiry: 3600,
|
||||
LoginTimeout: 300,
|
||||
LoginMaxRetries: 3,
|
||||
},
|
||||
UI: config.UIConfig{
|
||||
Title: "Tinyauth",
|
||||
ForgotPasswordMessage: "You can change your password by changing the configuration.",
|
||||
BackgroundImage: "/background.jpg",
|
||||
},
|
||||
Ldap: config.LdapConfig{
|
||||
Insecure: false,
|
||||
SearchFilter: "(uid=%s)",
|
||||
},
|
||||
Experimental: config.ExperimentalConfig{
|
||||
ConfigFile: "",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
tConfig := NewTinyauthCmdConfiguration()
|
||||
|
||||
loaders := []cli.ResourceLoader{
|
||||
&loaders.FileLoader{},
|
||||
&loaders.FlagLoader{},
|
||||
&loaders.EnvLoader{},
|
||||
}
|
||||
|
||||
cmdTinyauth := &cli.Command{
|
||||
Name: "tinyauth",
|
||||
Description: "The simplest way to protect your apps with a login screen.",
|
||||
Configuration: tConfig,
|
||||
Resources: loaders,
|
||||
Run: func(_ []string) error {
|
||||
return runCmd(*tConfig)
|
||||
},
|
||||
}
|
||||
|
||||
err := cmdTinyauth.AddCommand(versionCmd())
|
||||
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("Failed to add version command")
|
||||
}
|
||||
|
||||
err = cmdTinyauth.AddCommand(verifyUserCmd())
|
||||
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("Failed to add verify command")
|
||||
}
|
||||
|
||||
err = cmdTinyauth.AddCommand(healthcheckCmd())
|
||||
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("Failed to add healthcheck command")
|
||||
}
|
||||
|
||||
err = cmdTinyauth.AddCommand(generateTotpCmd())
|
||||
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("Failed to add generate command")
|
||||
}
|
||||
|
||||
err = cmdTinyauth.AddCommand(createUserCmd())
|
||||
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("Failed to add create command")
|
||||
}
|
||||
|
||||
err = cli.Execute(cmdTinyauth)
|
||||
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("Failed to execute command")
|
||||
}
|
||||
}
|
||||
|
||||
func runCmd(cfg config.Config) error {
|
||||
logLevel, err := zerolog.ParseLevel(strings.ToLower(cfg.LogLevel))
|
||||
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Invalid or missing log level, defaulting to info")
|
||||
} else {
|
||||
zerolog.SetGlobalLevel(logLevel)
|
||||
}
|
||||
|
||||
log.Logger = log.With().Caller().Logger()
|
||||
|
||||
if !cfg.LogJSON {
|
||||
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339})
|
||||
}
|
||||
|
||||
log.Info().Str("version", config.Version).Msg("Starting tinyauth")
|
||||
|
||||
app := bootstrap.NewBootstrapApp(cfg)
|
||||
|
||||
err = app.Setup()
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to bootstrap app: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
121
cmd/tinyauth/verify.go
Normal file
121
cmd/tinyauth/verify.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/steveiliop56/tinyauth/internal/utils"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/pquerna/otp/totp"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/traefik/paerser/cli"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type VerifyUserConfig struct {
|
||||
Interactive bool `description:"Validate a user interactively."`
|
||||
Username string `description:"Username."`
|
||||
Password string `description:"Password."`
|
||||
Totp string `description:"TOTP code."`
|
||||
User string `description:"Hash (username:hash:totp)."`
|
||||
}
|
||||
|
||||
func NewVerifyUserConfig() *VerifyUserConfig {
|
||||
return &VerifyUserConfig{
|
||||
Interactive: false,
|
||||
Username: "",
|
||||
Password: "",
|
||||
Totp: "",
|
||||
User: "",
|
||||
}
|
||||
}
|
||||
|
||||
func verifyUserCmd() *cli.Command {
|
||||
tCfg := NewVerifyUserConfig()
|
||||
|
||||
loaders := []cli.ResourceLoader{
|
||||
&cli.FlagLoader{},
|
||||
}
|
||||
|
||||
return &cli.Command{
|
||||
Name: "verify",
|
||||
Description: "Verify a user is set up correctly.",
|
||||
Configuration: tCfg,
|
||||
Resources: loaders,
|
||||
Run: func(_ []string) error {
|
||||
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339}).With().Caller().Logger().Level(zerolog.InfoLevel)
|
||||
|
||||
if tCfg.Interactive {
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewInput().Title("User (username:hash:totp)").Value(&tCfg.User).Validate((func(s string) error {
|
||||
if s == "" {
|
||||
return errors.New("user cannot be empty")
|
||||
}
|
||||
return nil
|
||||
})),
|
||||
huh.NewInput().Title("Username").Value(&tCfg.Username).Validate((func(s string) error {
|
||||
if s == "" {
|
||||
return errors.New("username cannot be empty")
|
||||
}
|
||||
return nil
|
||||
})),
|
||||
huh.NewInput().Title("Password").Value(&tCfg.Password).Validate((func(s string) error {
|
||||
if s == "" {
|
||||
return errors.New("password cannot be empty")
|
||||
}
|
||||
return nil
|
||||
})),
|
||||
huh.NewInput().Title("TOTP Code (optional)").Value(&tCfg.Totp),
|
||||
),
|
||||
)
|
||||
|
||||
var baseTheme *huh.Theme = huh.ThemeBase()
|
||||
|
||||
err := form.WithTheme(baseTheme).Run()
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to run interactive prompt: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
user, err := utils.ParseUser(tCfg.User)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse user: %w", err)
|
||||
}
|
||||
|
||||
if user.Username != tCfg.Username {
|
||||
return fmt.Errorf("username is incorrect")
|
||||
}
|
||||
|
||||
err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(tCfg.Password))
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("password is incorrect: %w", err)
|
||||
}
|
||||
|
||||
if user.TotpSecret == "" {
|
||||
if tCfg.Totp != "" {
|
||||
log.Warn().Msg("User does not have TOTP secret")
|
||||
}
|
||||
log.Info().Msg("User verified")
|
||||
return nil
|
||||
}
|
||||
|
||||
ok := totp.Validate(tCfg.Totp, user.TotpSecret)
|
||||
|
||||
if !ok {
|
||||
return fmt.Errorf("TOTP code incorrect")
|
||||
}
|
||||
|
||||
log.Info().Msg("User verified")
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
24
cmd/tinyauth/version.go
Normal file
24
cmd/tinyauth/version.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/steveiliop56/tinyauth/internal/config"
|
||||
|
||||
"github.com/traefik/paerser/cli"
|
||||
)
|
||||
|
||||
func versionCmd() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "version",
|
||||
Description: "Print the version number of Tinyauth.",
|
||||
Configuration: nil,
|
||||
Resources: nil,
|
||||
Run: func(_ []string) error {
|
||||
fmt.Printf("Version: %s\n", config.Version)
|
||||
fmt.Printf("Commit Hash: %s\n", config.CommitHash)
|
||||
fmt.Printf("Build Timestamp: %s\n", config.BuildTimestamp)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
118
cmd/verify.go
118
cmd/verify.go
@@ -1,118 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"tinyauth/internal/utils"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/pquerna/otp/totp"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type verifyUserCmd struct {
|
||||
root *cobra.Command
|
||||
cmd *cobra.Command
|
||||
|
||||
interactive bool
|
||||
username string
|
||||
password string
|
||||
totp string
|
||||
user string
|
||||
}
|
||||
|
||||
func newVerifyUserCmd(root *cobra.Command) *verifyUserCmd {
|
||||
return &verifyUserCmd{
|
||||
root: root,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *verifyUserCmd) Register() {
|
||||
c.cmd = &cobra.Command{
|
||||
Use: "verify",
|
||||
Short: "Verify a user is set up correctly",
|
||||
Long: `Verify a user is set up correctly meaning that it has a correct username, password and TOTP code.`,
|
||||
Run: c.run,
|
||||
}
|
||||
|
||||
c.cmd.Flags().BoolVarP(&c.interactive, "interactive", "i", false, "Validate a user interactively")
|
||||
c.cmd.Flags().StringVar(&c.username, "username", "", "Username")
|
||||
c.cmd.Flags().StringVar(&c.password, "password", "", "Password")
|
||||
c.cmd.Flags().StringVar(&c.totp, "totp", "", "TOTP code")
|
||||
c.cmd.Flags().StringVar(&c.user, "user", "", "Hash (username:hash:totp)")
|
||||
|
||||
if c.root != nil {
|
||||
c.root.AddCommand(c.cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *verifyUserCmd) GetCmd() *cobra.Command {
|
||||
return c.cmd
|
||||
}
|
||||
|
||||
func (c *verifyUserCmd) run(cmd *cobra.Command, args []string) {
|
||||
log.Logger = log.Level(zerolog.InfoLevel)
|
||||
|
||||
if c.interactive {
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewInput().Title("User (username:hash:totp)").Value(&c.user).Validate((func(s string) error {
|
||||
if s == "" {
|
||||
return errors.New("user cannot be empty")
|
||||
}
|
||||
return nil
|
||||
})),
|
||||
huh.NewInput().Title("Username").Value(&c.username).Validate((func(s string) error {
|
||||
if s == "" {
|
||||
return errors.New("username cannot be empty")
|
||||
}
|
||||
return nil
|
||||
})),
|
||||
huh.NewInput().Title("Password").Value(&c.password).Validate((func(s string) error {
|
||||
if s == "" {
|
||||
return errors.New("password cannot be empty")
|
||||
}
|
||||
return nil
|
||||
})),
|
||||
huh.NewInput().Title("TOTP Code (optional)").Value(&c.totp),
|
||||
),
|
||||
)
|
||||
var baseTheme *huh.Theme = huh.ThemeBase()
|
||||
err := form.WithTheme(baseTheme).Run()
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("Form failed")
|
||||
}
|
||||
}
|
||||
|
||||
user, err := utils.ParseUser(c.user)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("Failed to parse user")
|
||||
}
|
||||
|
||||
if user.Username != c.username {
|
||||
log.Fatal().Msg("Username is incorrect")
|
||||
}
|
||||
|
||||
err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(c.password))
|
||||
if err != nil {
|
||||
log.Fatal().Msg("Password is incorrect")
|
||||
}
|
||||
|
||||
if user.TotpSecret == "" {
|
||||
if c.totp != "" {
|
||||
log.Warn().Msg("User does not have TOTP secret")
|
||||
}
|
||||
log.Info().Msg("User verified")
|
||||
return
|
||||
}
|
||||
|
||||
ok := totp.Validate(c.totp, user.TotpSecret)
|
||||
if !ok {
|
||||
log.Fatal().Msg("TOTP code incorrect")
|
||||
|
||||
}
|
||||
|
||||
log.Info().Msg("User verified")
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"tinyauth/internal/config"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type versionCmd struct {
|
||||
root *cobra.Command
|
||||
cmd *cobra.Command
|
||||
}
|
||||
|
||||
func newVersionCmd(root *cobra.Command) *versionCmd {
|
||||
return &versionCmd{
|
||||
root: root,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *versionCmd) Register() {
|
||||
c.cmd = &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Print the version number of Tinyauth",
|
||||
Long: `All software has versions. This is Tinyauth's.`,
|
||||
Run: c.run,
|
||||
}
|
||||
|
||||
if c.root != nil {
|
||||
c.root.AddCommand(c.cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *versionCmd) GetCmd() *cobra.Command {
|
||||
return c.cmd
|
||||
}
|
||||
|
||||
func (c *versionCmd) run(cmd *cobra.Command, args []string) {
|
||||
fmt.Printf("Version: %s\n", config.Version)
|
||||
fmt.Printf("Commit Hash: %s\n", config.CommitHash)
|
||||
fmt.Printf("Build Timestamp: %s\n", config.BuildTimestamp)
|
||||
}
|
||||
124
config.example.yaml
Normal file
124
config.example.yaml
Normal file
@@ -0,0 +1,124 @@
|
||||
# Tinyauth Example Configuration
|
||||
|
||||
# The base URL where Tinyauth is accessible
|
||||
appUrl: "https://auth.example.com"
|
||||
# Log level: trace, debug, info, warn, error
|
||||
logLevel: "info"
|
||||
# Directory for static resources
|
||||
resourcesDir: "./resources"
|
||||
# Path to SQLite database file
|
||||
databasePath: "./tinyauth.db"
|
||||
# Disable usage analytics
|
||||
disableAnalytics: false
|
||||
# Disable static resource serving
|
||||
disableResources: false
|
||||
# Disable UI warning messages
|
||||
disableUIWarnings: false
|
||||
# Enable JSON formatted logs
|
||||
logJSON: false
|
||||
|
||||
# Server Configuration
|
||||
server:
|
||||
# Port to listen on
|
||||
port: 3000
|
||||
# Interface to bind to (0.0.0.0 for all interfaces)
|
||||
address: "0.0.0.0"
|
||||
# Unix socket path (optional, overrides port/address if set)
|
||||
socketPath: ""
|
||||
# Comma-separated list of trusted proxy IPs/CIDRs
|
||||
trustedProxies: ""
|
||||
|
||||
# Authentication Configuration
|
||||
auth:
|
||||
# Format: username:bcrypt_hash (use bcrypt to generate hash)
|
||||
users: "admin:$2a$10$example_bcrypt_hash_here"
|
||||
# Path to external users file (optional)
|
||||
usersFile: ""
|
||||
# Enable secure cookies (requires HTTPS)
|
||||
secureCookie: false
|
||||
# Session expiry in seconds (3600 = 1 hour)
|
||||
sessionExpiry: 3600
|
||||
# Login timeout in seconds (300 = 5 minutes)
|
||||
loginTimeout: 300
|
||||
# Maximum login retries before lockout
|
||||
loginMaxRetries: 3
|
||||
|
||||
# OAuth Configuration
|
||||
oauth:
|
||||
# Regex pattern for allowed email addresses (e.g., /@example\.com$/)
|
||||
whitelist: ""
|
||||
# Provider ID to auto-redirect to (skips login page)
|
||||
autoRedirect: ""
|
||||
# OAuth Provider Configuration (replace myprovider with your provider name)
|
||||
providers:
|
||||
myprovider:
|
||||
clientId: "your_client_id_here"
|
||||
clientSecret: "your_client_secret_here"
|
||||
authUrl: "https://provider.example.com/oauth/authorize"
|
||||
tokenUrl: "https://provider.example.com/oauth/token"
|
||||
userInfoUrl: "https://provider.example.com/oauth/userinfo"
|
||||
redirectUrl: "https://auth.example.com/api/oauth/callback/myprovider"
|
||||
scopes: "openid email profile"
|
||||
name: "My OAuth Provider"
|
||||
# Allow insecure connections (self-signed certificates)
|
||||
insecure: false
|
||||
|
||||
# OIDC Provider Configuration
|
||||
oidc:
|
||||
# Enable OIDC provider functionality
|
||||
enabled: false
|
||||
# OIDC issuer URL (defaults to appUrl if not set)
|
||||
issuer: ""
|
||||
# Access token expiry in seconds (3600 = 1 hour)
|
||||
accessTokenExpiry: 3600
|
||||
# ID token expiry in seconds (3600 = 1 hour)
|
||||
idTokenExpiry: 3600
|
||||
# OIDC Client Configuration
|
||||
clients:
|
||||
# Client ID (used as the key)
|
||||
myapp:
|
||||
# Client secret (or use clientSecretFile)
|
||||
clientSecret: "your_client_secret_here"
|
||||
# Path to file containing client secret (optional, alternative to clientSecret)
|
||||
clientSecretFile: ""
|
||||
# Client name for display purposes
|
||||
clientName: "My Application"
|
||||
# Allowed redirect URIs
|
||||
redirectUris:
|
||||
- "https://myapp.example.com/callback"
|
||||
- "http://localhost:3000/callback"
|
||||
# Allowed grant types (defaults to ["authorization_code"] if not specified)
|
||||
grantTypes:
|
||||
- "authorization_code"
|
||||
# Allowed response types (defaults to ["code"] if not specified)
|
||||
responseTypes:
|
||||
- "code"
|
||||
# Allowed scopes (defaults to ["openid", "profile", "email"] if not specified)
|
||||
scopes:
|
||||
- "openid"
|
||||
- "profile"
|
||||
- "email"
|
||||
|
||||
# UI Customization
|
||||
ui:
|
||||
# Custom title for login page
|
||||
title: "Tinyauth"
|
||||
# Message shown on forgot password page
|
||||
forgotPasswordMessage: "Contact your administrator to reset your password"
|
||||
# Background image URL for login page
|
||||
backgroundImage: ""
|
||||
|
||||
# LDAP Configuration (optional)
|
||||
ldap:
|
||||
# LDAP server address
|
||||
address: "ldap://ldap.example.com:389"
|
||||
# DN for binding to LDAP server
|
||||
bindDn: "cn=readonly,dc=example,dc=com"
|
||||
# Password for bind DN
|
||||
bindPassword: "your_bind_password"
|
||||
# Base DN for user searches
|
||||
baseDn: "dc=example,dc=com"
|
||||
# Search filter (%s will be replaced with username)
|
||||
searchFilter: "(&(uid=%s)(memberOf=cn=users,ou=groups,dc=example,dc=com))"
|
||||
# Allow insecure LDAP connections
|
||||
insecure: false
|
||||
@@ -20,8 +20,8 @@ services:
|
||||
container_name: tinyauth
|
||||
image: ghcr.io/steveiliop56/tinyauth:v3
|
||||
environment:
|
||||
- APP_URL=https://tinyauth.example.com
|
||||
- USERS=user:$$2a$$10$$UdLYoJ5lgPsC0RKqYH/jMua7zIn0g9kPqWmhYayJYLaZQ/FTmH2/u # user:password
|
||||
- TINYAUTH_APPURL=https://tinyauth.example.com
|
||||
- TINYAUTH_AUTH_USERS=user:$$2a$$10$$UdLYoJ5lgPsC0RKqYH/jMua7zIn0g9kPqWmhYayJYLaZQ/FTmH2/u # user:password
|
||||
volumes:
|
||||
- ./data:/data
|
||||
labels:
|
||||
|
||||
@@ -2,7 +2,6 @@ import i18n from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import LanguageDetector from "i18next-browser-languagedetector";
|
||||
import resourcesToBackend from "i18next-resources-to-backend";
|
||||
import { languages } from "./locales";
|
||||
|
||||
i18n
|
||||
.use(LanguageDetector)
|
||||
@@ -16,7 +15,6 @@ i18n
|
||||
fallbackLng: "en",
|
||||
debug: import.meta.env.MODE === "development",
|
||||
nonExplicitSupportedLngs: true,
|
||||
supportedLngs: Object.keys(languages),
|
||||
load: "currentOnly",
|
||||
detection: {
|
||||
lookupLocalStorage: "tinyauth-lang",
|
||||
|
||||
96
go.mod
96
go.mod
@@ -1,4 +1,4 @@
|
||||
module tinyauth
|
||||
module github.com/steveiliop56/tinyauth
|
||||
|
||||
go 1.24.0
|
||||
|
||||
@@ -6,59 +6,33 @@ toolchain go1.24.3
|
||||
|
||||
require (
|
||||
github.com/cenkalti/backoff/v5 v5.0.3
|
||||
github.com/charmbracelet/huh v0.8.0
|
||||
github.com/docker/docker v28.5.2+incompatible
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/go-playground/validator/v10 v10.29.0
|
||||
github.com/go-ldap/ldap/v3 v3.4.12
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1
|
||||
github.com/google/go-querystring v1.1.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/mdp/qrterminal/v3 v3.2.1
|
||||
github.com/pquerna/otp v1.5.0
|
||||
github.com/rs/zerolog v1.34.0
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/viper v1.21.0
|
||||
github.com/stoewer/go-strcase v1.3.1
|
||||
github.com/traefik/paerser v0.2.2
|
||||
github.com/weppos/publicsuffix-go v0.50.1
|
||||
golang.org/x/crypto v0.46.0
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b
|
||||
golang.org/x/oauth2 v0.34.0
|
||||
gorm.io/gorm v1.31.1
|
||||
gotest.tools/v3 v3.5.2
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
|
||||
github.com/containerd/errdefs v1.0.0 // indirect
|
||||
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
||||
github.com/containerd/log v0.1.0 // indirect
|
||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.32 // indirect
|
||||
github.com/moby/sys/atomicwriter v0.1.0 // indirect
|
||||
github.com/moby/term v0.5.2 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.57.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.34.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/term v0.38.0 // indirect
|
||||
modernc.org/libc v1.66.3 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.38.2 // indirect
|
||||
rsc.io/qr v0.2.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v1.4.0 // indirect
|
||||
github.com/Masterminds/goutils v1.1.1 // indirect
|
||||
github.com/Masterminds/semver/v3 v3.2.1 // indirect
|
||||
github.com/Masterminds/sprig/v3 v3.2.3 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
@@ -68,29 +42,38 @@ require (
|
||||
github.com/catppuccin/go v0.3.0 // indirect
|
||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect
|
||||
github.com/charmbracelet/bubbletea v1.3.6 // indirect
|
||||
github.com/charmbracelet/huh v0.8.0
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||
github.com/charmbracelet/lipgloss v1.1.0 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.9.3 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/containerd/errdefs v1.0.0 // indirect
|
||||
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
||||
github.com/containerd/log v0.1.0 // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/docker/docker v28.5.2+incompatible
|
||||
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.9.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.10 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-ldap/ldap/v3 v3.4.12
|
||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // 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/go-playground/validator/v10 v10.28.0 // indirect
|
||||
github.com/goccy/go-json v0.10.4 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/huandu/xstrings v1.5.0 // indirect
|
||||
github.com/imdario/mergo v0.3.11 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
@@ -99,36 +82,49 @@ require (
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.32 // indirect
|
||||
github.com/mitchellh/copystructure v1.2.0 // indirect
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
||||
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/moby/sys/atomicwriter v0.1.0 // indirect
|
||||
github.com/moby/term v0.5.2 // 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.16.0 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // 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.4 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pquerna/otp v1.5.0
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.57.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/shopspring/decimal v1.4.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
|
||||
go.opentelemetry.io/otel v1.37.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.34.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.37.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.37.0 // indirect
|
||||
golang.org/x/arch v0.20.0 // indirect
|
||||
golang.org/x/net v0.47.0 // indirect
|
||||
golang.org/x/oauth2 v0.34.0
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/term v0.38.0 // indirect
|
||||
golang.org/x/text v0.32.0 // indirect
|
||||
google.golang.org/protobuf v1.36.9 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/libc v1.66.3 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.38.2 // indirect
|
||||
rsc.io/qr v0.2.0 // indirect
|
||||
)
|
||||
|
||||
95
go.sum
95
go.sum
@@ -2,8 +2,17 @@ github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEK
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
|
||||
github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0=
|
||||
github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
||||
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
||||
github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
|
||||
github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
|
||||
github.com/Masterminds/semver/v3 v3.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
|
||||
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
|
||||
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
|
||||
github.com/Masterminds/sprig/v3 v3.2.3 h1:eL2fZNezLomi0uOLqjQoN6BfsDD+fyLtgbJMAj9n6YA=
|
||||
github.com/Masterminds/sprig/v3 v3.2.3/go.mod h1:rXcFaZ2zZbLRJv/xSysmlgIM1u11eBaRMhvYXJNkGuM=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
|
||||
@@ -64,7 +73,6 @@ github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmC
|
||||
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
||||
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -87,10 +95,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2
|
||||
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.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
|
||||
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||
@@ -114,15 +120,15 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.29.0 h1:lQlF5VNJWNlRbRZNeOIkWElR+1LL/OuHcc0Kp14w1xk=
|
||||
github.com/go-playground/validator/v10 v10.29.0/go.mod h1:D6QxqeMlgIPuT02L66f2ccrZ7AGgHkzKmmTMZhk/Kc4=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
|
||||
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
|
||||
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/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
|
||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
@@ -133,14 +139,18 @@ github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 h1:VNqngBF40hVlDloBruUehVYC3ArSgIyScOAyMRqBxRg=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1/go.mod h1:RBRO7fro65R6tjKzYgLAFo0t1QEXY1Dp+i/bvpRiqiQ=
|
||||
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
||||
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/huandu/xstrings v1.3.3/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
|
||||
github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=
|
||||
github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
|
||||
github.com/imdario/mergo v0.3.11 h1:3tnifQM4i+fbajXKBHXWEH+KvNHqojZ778UH75j3bGA=
|
||||
github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
|
||||
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
|
||||
@@ -186,8 +196,14 @@ github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuE
|
||||
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4=
|
||||
github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU=
|
||||
github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw=
|
||||
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
|
||||
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
|
||||
github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
|
||||
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
|
||||
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
|
||||
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/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
|
||||
@@ -238,37 +254,25 @@ github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWN
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
||||
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
||||
github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
|
||||
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||
github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
|
||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||
github.com/stoewer/go-strcase v1.3.1 h1:iS0MdW+kVTxgMoE1LAZyMiYJFKlOzLooE4MxjirtkAs=
|
||||
github.com/stoewer/go-strcase v1.3.1/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo=
|
||||
github.com/stretchr/objx v0.1.0/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/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.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/traefik/paerser v0.2.2 h1:cpzW/ZrQrBh3mdwD/jnp6aXASiUFKOVr6ldP+keJTcQ=
|
||||
github.com/traefik/paerser v0.2.2/go.mod h1:7BBDd4FANoVgaTZG+yh26jI6CA2nds7D/4VTEdIsh24=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
@@ -279,6 +283,7 @@ github.com/weppos/publicsuffix-go v0.50.1 h1:elrBHeSkS/eIb169+DnLrknqmdP4AjT0Q0t
|
||||
github.com/weppos/publicsuffix-go v0.50.1/go.mod h1:znn0JVXjcR5hpUl9pbEogwH6I710rA1AX0QQPT0bf+k=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
|
||||
@@ -301,36 +306,61 @@ go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU
|
||||
go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4=
|
||||
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
|
||||
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
||||
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
|
||||
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
|
||||
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.2.0/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.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
|
||||
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
|
||||
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
|
||||
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.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
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.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
|
||||
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c h1:AtEkQdl5b6zsybXcbz00j1LwNodDuH6hVifIaNqk7NQ=
|
||||
@@ -344,6 +374,9 @@ google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXn
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
|
||||
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
1
internal/assets/migrations/000003_oauth_sub.down.sql
Normal file
1
internal/assets/migrations/000003_oauth_sub.down.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "sessions" DROP COLUMN "oauth_sub";
|
||||
1
internal/assets/migrations/000003_oauth_sub.up.sql
Normal file
1
internal/assets/migrations/000003_oauth_sub.up.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "sessions" ADD COLUMN "oauth_sub" TEXT;
|
||||
2
internal/assets/migrations/000004_oidc_clients.down.sql
Normal file
2
internal/assets/migrations/000004_oidc_clients.down.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
DROP TABLE IF EXISTS "oidc_clients";
|
||||
|
||||
12
internal/assets/migrations/000004_oidc_clients.up.sql
Normal file
12
internal/assets/migrations/000004_oidc_clients.up.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE IF NOT EXISTS "oidc_clients" (
|
||||
"client_id" TEXT NOT NULL PRIMARY KEY UNIQUE,
|
||||
"client_secret" TEXT NOT NULL,
|
||||
"client_name" TEXT NOT NULL,
|
||||
"redirect_uris" TEXT NOT NULL,
|
||||
"grant_types" TEXT NOT NULL,
|
||||
"response_types" TEXT NOT NULL,
|
||||
"scopes" TEXT NOT NULL,
|
||||
"created_at" INTEGER NOT NULL,
|
||||
"updated_at" INTEGER NOT NULL
|
||||
);
|
||||
|
||||
2
internal/assets/migrations/000005_oidc_keys.down.sql
Normal file
2
internal/assets/migrations/000005_oidc_keys.down.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
DROP TABLE IF EXISTS "oidc_keys";
|
||||
|
||||
7
internal/assets/migrations/000005_oidc_keys.up.sql
Normal file
7
internal/assets/migrations/000005_oidc_keys.up.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
CREATE TABLE IF NOT EXISTS "oidc_keys" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"private_key" TEXT NOT NULL,
|
||||
"created_at" INTEGER NOT NULL,
|
||||
"updated_at" INTEGER NOT NULL
|
||||
);
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
DROP INDEX IF EXISTS "idx_oidc_auth_codes_expires_at";
|
||||
DROP TABLE IF EXISTS "oidc_authorization_codes";
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE IF NOT EXISTS "oidc_authorization_codes" (
|
||||
"code" TEXT NOT NULL PRIMARY KEY,
|
||||
"client_id" TEXT NOT NULL,
|
||||
"redirect_uri" TEXT NOT NULL,
|
||||
"used" BOOLEAN NOT NULL DEFAULT 0,
|
||||
"expires_at" INTEGER NOT NULL,
|
||||
"created_at" INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "idx_oidc_auth_codes_expires_at" ON "oidc_authorization_codes"("expires_at");
|
||||
|
||||
@@ -11,10 +11,11 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
"tinyauth/internal/config"
|
||||
"tinyauth/internal/controller"
|
||||
"tinyauth/internal/model"
|
||||
"tinyauth/internal/utils"
|
||||
|
||||
"github.com/steveiliop56/tinyauth/internal/config"
|
||||
"github.com/steveiliop56/tinyauth/internal/controller"
|
||||
"github.com/steveiliop56/tinyauth/internal/model"
|
||||
"github.com/steveiliop56/tinyauth/internal/utils"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"gorm.io/gorm"
|
||||
@@ -43,7 +44,7 @@ func NewBootstrapApp(config config.Config) *BootstrapApp {
|
||||
|
||||
func (app *BootstrapApp) Setup() error {
|
||||
// Parse users
|
||||
users, err := utils.GetUsers(app.config.Users, app.config.UsersFile)
|
||||
users, err := utils.GetUsers(app.config.Auth.Users, app.config.Auth.UsersFile)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -51,14 +52,35 @@ func (app *BootstrapApp) Setup() error {
|
||||
|
||||
app.context.users = users
|
||||
|
||||
// Get OAuth configs
|
||||
oauthProviders, err := utils.GetOAuthProvidersConfig(os.Environ(), os.Args, app.config.AppURL)
|
||||
// Setup OAuth providers
|
||||
app.context.oauthProviders = app.config.OAuth.Providers
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
for name, provider := range app.context.oauthProviders {
|
||||
secret := utils.GetSecret(provider.ClientSecret, provider.ClientSecretFile)
|
||||
provider.ClientSecret = secret
|
||||
provider.ClientSecretFile = ""
|
||||
app.context.oauthProviders[name] = provider
|
||||
}
|
||||
|
||||
app.context.oauthProviders = oauthProviders
|
||||
for id := range config.OverrideProviders {
|
||||
if provider, exists := app.context.oauthProviders[id]; exists {
|
||||
if provider.RedirectURL == "" {
|
||||
provider.RedirectURL = app.config.AppURL + "/api/oauth/callback/" + id
|
||||
app.context.oauthProviders[id] = provider
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for id, provider := range app.context.oauthProviders {
|
||||
if provider.Name == "" {
|
||||
if name, ok := config.OverrideProviders[id]; ok {
|
||||
provider.Name = name
|
||||
} else {
|
||||
provider.Name = utils.Capitalize(id)
|
||||
}
|
||||
}
|
||||
app.context.oauthProviders[id] = provider
|
||||
}
|
||||
|
||||
// Get cookie domain
|
||||
cookieDomain, err := utils.GetCookieDomain(app.config.AppURL)
|
||||
@@ -98,7 +120,7 @@ func (app *BootstrapApp) Setup() error {
|
||||
// Configured providers
|
||||
configuredProviders := make([]controller.Provider, 0)
|
||||
|
||||
for id, provider := range oauthProviders {
|
||||
for id, provider := range app.context.oauthProviders {
|
||||
configuredProviders = append(configuredProviders, controller.Provider{
|
||||
Name: provider.Name,
|
||||
ID: id,
|
||||
@@ -144,17 +166,17 @@ func (app *BootstrapApp) Setup() error {
|
||||
}
|
||||
|
||||
// If we have an socket path, bind to it
|
||||
if app.config.SocketPath != "" {
|
||||
if _, err := os.Stat(app.config.SocketPath); err == nil {
|
||||
log.Info().Msgf("Removing existing socket file %s", app.config.SocketPath)
|
||||
err := os.Remove(app.config.SocketPath)
|
||||
if app.config.Server.SocketPath != "" {
|
||||
if _, err := os.Stat(app.config.Server.SocketPath); err == nil {
|
||||
log.Info().Msgf("Removing existing socket file %s", app.config.Server.SocketPath)
|
||||
err := os.Remove(app.config.Server.SocketPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove existing socket file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
log.Info().Msgf("Starting server on unix socket %s", app.config.SocketPath)
|
||||
if err := router.RunUnix(app.config.SocketPath); err != nil {
|
||||
log.Info().Msgf("Starting server on unix socket %s", app.config.Server.SocketPath)
|
||||
if err := router.RunUnix(app.config.Server.SocketPath); err != nil {
|
||||
log.Fatal().Err(err).Msg("Failed to start server")
|
||||
}
|
||||
|
||||
@@ -162,7 +184,7 @@ func (app *BootstrapApp) Setup() error {
|
||||
}
|
||||
|
||||
// Start server
|
||||
address := fmt.Sprintf("%s:%d", app.config.Address, app.config.Port)
|
||||
address := fmt.Sprintf("%s:%d", app.config.Server.Address, app.config.Server.Port)
|
||||
log.Info().Msgf("Starting server on %s", address)
|
||||
if err := router.Run(address); err != nil {
|
||||
log.Fatal().Err(err).Msg("Failed to start server")
|
||||
@@ -193,7 +215,7 @@ func (app *BootstrapApp) heartbeat() {
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: time.Duration(10) * time.Second, // The server should never take more than 10 seconds to respond
|
||||
Timeout: 30 * time.Second, // The server should never take more than 30 seconds to respond
|
||||
}
|
||||
|
||||
heartbeatURL := config.ApiServer + "/v1/instances/heartbeat"
|
||||
|
||||
@@ -3,8 +3,9 @@ package bootstrap
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"tinyauth/internal/controller"
|
||||
"tinyauth/internal/middleware"
|
||||
|
||||
"github.com/steveiliop56/tinyauth/internal/controller"
|
||||
"github.com/steveiliop56/tinyauth/internal/middleware"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -13,8 +14,8 @@ func (app *BootstrapApp) setupRouter() (*gin.Engine, error) {
|
||||
engine := gin.New()
|
||||
engine.Use(gin.Recovery())
|
||||
|
||||
if len(app.config.TrustedProxies) > 0 {
|
||||
err := engine.SetTrustedProxies(strings.Split(app.config.TrustedProxies, ","))
|
||||
if len(app.config.Server.TrustedProxies) > 0 {
|
||||
err := engine.SetTrustedProxies(strings.Split(app.config.Server.TrustedProxies, ","))
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to set trusted proxies: %w", err)
|
||||
@@ -57,12 +58,12 @@ func (app *BootstrapApp) setupRouter() (*gin.Engine, error) {
|
||||
|
||||
contextController := controller.NewContextController(controller.ContextControllerConfig{
|
||||
Providers: app.context.configuredProviders,
|
||||
Title: app.config.Title,
|
||||
Title: app.config.UI.Title,
|
||||
AppURL: app.config.AppURL,
|
||||
CookieDomain: app.context.cookieDomain,
|
||||
ForgotPasswordMessage: app.config.ForgotPasswordMessage,
|
||||
BackgroundImage: app.config.BackgroundImage,
|
||||
OAuthAutoRedirect: app.config.OAuthAutoRedirect,
|
||||
ForgotPasswordMessage: app.config.UI.ForgotPasswordMessage,
|
||||
BackgroundImage: app.config.UI.BackgroundImage,
|
||||
OAuthAutoRedirect: app.config.OAuth.AutoRedirect,
|
||||
DisableUIWarnings: app.config.DisableUIWarnings,
|
||||
}, apiRouter)
|
||||
|
||||
@@ -70,7 +71,7 @@ func (app *BootstrapApp) setupRouter() (*gin.Engine, error) {
|
||||
|
||||
oauthController := controller.NewOAuthController(controller.OAuthControllerConfig{
|
||||
AppURL: app.config.AppURL,
|
||||
SecureCookie: app.config.SecureCookie,
|
||||
SecureCookie: app.config.Auth.SecureCookie,
|
||||
CSRFCookieName: app.context.csrfCookieName,
|
||||
RedirectCookieName: app.context.redirectCookieName,
|
||||
CookieDomain: app.context.cookieDomain,
|
||||
@@ -101,5 +102,15 @@ func (app *BootstrapApp) setupRouter() (*gin.Engine, error) {
|
||||
|
||||
healthController.SetupRoutes()
|
||||
|
||||
// Setup OIDC controller if OIDC is enabled
|
||||
if app.config.OIDC.Enabled && app.services.oidcService != nil {
|
||||
oidcController := controller.NewOIDCController(controller.OIDCControllerConfig{
|
||||
AppURL: app.config.AppURL,
|
||||
CookieDomain: app.context.cookieDomain,
|
||||
}, apiRouter, app.services.oidcService, app.services.authService)
|
||||
|
||||
oidcController.SetupRoutes()
|
||||
}
|
||||
|
||||
return engine, nil
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"tinyauth/internal/service"
|
||||
"github.com/steveiliop56/tinyauth/internal/service"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
@@ -13,6 +13,7 @@ type Services struct {
|
||||
dockerService *service.DockerService
|
||||
ldapService *service.LdapService
|
||||
oauthBrokerService *service.OAuthBrokerService
|
||||
oidcService *service.OIDCService
|
||||
}
|
||||
|
||||
func (app *BootstrapApp) initServices() (Services, error) {
|
||||
@@ -31,12 +32,12 @@ func (app *BootstrapApp) initServices() (Services, error) {
|
||||
services.databaseService = databaseService
|
||||
|
||||
ldapService := service.NewLdapService(service.LdapServiceConfig{
|
||||
Address: app.config.LdapAddress,
|
||||
BindDN: app.config.LdapBindDN,
|
||||
BindPassword: app.config.LdapBindPassword,
|
||||
BaseDN: app.config.LdapBaseDN,
|
||||
Insecure: app.config.LdapInsecure,
|
||||
SearchFilter: app.config.LdapSearchFilter,
|
||||
Address: app.config.Ldap.Address,
|
||||
BindDN: app.config.Ldap.BindDN,
|
||||
BindPassword: app.config.Ldap.BindPassword,
|
||||
BaseDN: app.config.Ldap.BaseDN,
|
||||
Insecure: app.config.Ldap.Insecure,
|
||||
SearchFilter: app.config.Ldap.SearchFilter,
|
||||
})
|
||||
|
||||
err = ldapService.Init()
|
||||
@@ -69,12 +70,12 @@ func (app *BootstrapApp) initServices() (Services, error) {
|
||||
|
||||
authService := service.NewAuthService(service.AuthServiceConfig{
|
||||
Users: app.context.users,
|
||||
OauthWhitelist: app.config.OAuthWhitelist,
|
||||
SessionExpiry: app.config.SessionExpiry,
|
||||
SecureCookie: app.config.SecureCookie,
|
||||
OauthWhitelist: app.config.OAuth.Whitelist,
|
||||
SessionExpiry: app.config.Auth.SessionExpiry,
|
||||
SecureCookie: app.config.Auth.SecureCookie,
|
||||
CookieDomain: app.context.cookieDomain,
|
||||
LoginTimeout: app.config.LoginTimeout,
|
||||
LoginMaxRetries: app.config.LoginMaxRetries,
|
||||
LoginTimeout: app.config.Auth.LoginTimeout,
|
||||
LoginMaxRetries: app.config.Auth.LoginMaxRetries,
|
||||
SessionCookieName: app.context.sessionCookieName,
|
||||
}, dockerService, ldapService, databaseService.GetDatabase())
|
||||
|
||||
@@ -96,5 +97,39 @@ func (app *BootstrapApp) initServices() (Services, error) {
|
||||
|
||||
services.oauthBrokerService = oauthBrokerService
|
||||
|
||||
// Initialize OIDC service if enabled
|
||||
if app.config.OIDC.Enabled {
|
||||
issuer := app.config.OIDC.Issuer
|
||||
if issuer == "" {
|
||||
issuer = app.config.AppURL
|
||||
}
|
||||
|
||||
oidcService := service.NewOIDCService(service.OIDCServiceConfig{
|
||||
AppURL: app.config.AppURL,
|
||||
Issuer: issuer,
|
||||
AccessTokenExpiry: app.config.OIDC.AccessTokenExpiry,
|
||||
IDTokenExpiry: app.config.OIDC.IDTokenExpiry,
|
||||
Database: databaseService.GetDatabase(),
|
||||
})
|
||||
|
||||
err = oidcService.Init()
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("Failed to initialize OIDC service, continuing without it")
|
||||
} else {
|
||||
services.oidcService = oidcService
|
||||
log.Info().Msg("OIDC service initialized")
|
||||
|
||||
// Sync clients from config
|
||||
if len(app.config.OIDC.Clients) > 0 {
|
||||
err = oidcService.SyncClientsFromConfig(app.config.OIDC.Clients)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("Failed to sync OIDC clients from config")
|
||||
} else {
|
||||
log.Info().Int("count", len(app.config.OIDC.Clients)).Msg("Synced OIDC clients from config")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return services, nil
|
||||
}
|
||||
|
||||
@@ -15,39 +15,90 @@ var RedirectCookieName = "tinyauth-redirect"
|
||||
// Main app config
|
||||
|
||||
type Config struct {
|
||||
Port int `mapstructure:"port" validate:"required"`
|
||||
Address string `validate:"required,ip4_addr" mapstructure:"address"`
|
||||
AppURL string `validate:"required,url" mapstructure:"app-url"`
|
||||
Users string `mapstructure:"users"`
|
||||
UsersFile string `mapstructure:"users-file"`
|
||||
SecureCookie bool `mapstructure:"secure-cookie"`
|
||||
OAuthWhitelist string `mapstructure:"oauth-whitelist"`
|
||||
OAuthAutoRedirect string `mapstructure:"oauth-auto-redirect"`
|
||||
SessionExpiry int `mapstructure:"session-expiry"`
|
||||
LogLevel string `mapstructure:"log-level" validate:"oneof=trace debug info warn error fatal panic"`
|
||||
Title string `mapstructure:"app-title"`
|
||||
LoginTimeout int `mapstructure:"login-timeout"`
|
||||
LoginMaxRetries int `mapstructure:"login-max-retries"`
|
||||
ForgotPasswordMessage string `mapstructure:"forgot-password-message"`
|
||||
BackgroundImage string `mapstructure:"background-image" validate:"required"`
|
||||
LdapAddress string `mapstructure:"ldap-address"`
|
||||
LdapBindDN string `mapstructure:"ldap-bind-dn"`
|
||||
LdapBindPassword string `mapstructure:"ldap-bind-password"`
|
||||
LdapBaseDN string `mapstructure:"ldap-base-dn"`
|
||||
LdapInsecure bool `mapstructure:"ldap-insecure"`
|
||||
LdapSearchFilter string `mapstructure:"ldap-search-filter"`
|
||||
ResourcesDir string `mapstructure:"resources-dir"`
|
||||
DatabasePath string `mapstructure:"database-path"`
|
||||
TrustedProxies string `mapstructure:"trusted-proxies"`
|
||||
DisableAnalytics bool `mapstructure:"disable-analytics"`
|
||||
DisableResources bool `mapstructure:"disable-resources"`
|
||||
DisableUIWarnings bool `mapstructure:"disable-ui-warnings"`
|
||||
SocketPath string `mapstructure:"socket-path"`
|
||||
AppURL string `description:"The base URL where the app is hosted." yaml:"appUrl"`
|
||||
LogLevel string `description:"Log level (trace, debug, info, warn, error)." yaml:"logLevel"`
|
||||
ResourcesDir string `description:"The directory where resources are stored." yaml:"resourcesDir"`
|
||||
DatabasePath string `description:"The path to the database file." yaml:"databasePath"`
|
||||
DisableAnalytics bool `description:"Disable analytics." yaml:"disableAnalytics"`
|
||||
DisableResources bool `description:"Disable resources server." yaml:"disableResources"`
|
||||
DisableUIWarnings bool `description:"Disable UI warnings." yaml:"disableUIWarnings"`
|
||||
LogJSON bool `description:"Enable JSON formatted logs." yaml:"logJSON"`
|
||||
Server ServerConfig `description:"Server configuration." yaml:"server"`
|
||||
Auth AuthConfig `description:"Authentication configuration." yaml:"auth"`
|
||||
OAuth OAuthConfig `description:"OAuth configuration." yaml:"oauth"`
|
||||
OIDC OIDCConfig `description:"OIDC provider configuration." yaml:"oidc"`
|
||||
UI UIConfig `description:"UI customization." yaml:"ui"`
|
||||
Ldap LdapConfig `description:"LDAP configuration." yaml:"ldap"`
|
||||
Experimental ExperimentalConfig `description:"Experimental features, use with caution." yaml:"experimental"`
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Port int `description:"The port on which the server listens." yaml:"port"`
|
||||
Address string `description:"The address on which the server listens." yaml:"address"`
|
||||
SocketPath string `description:"The path to the Unix socket." yaml:"socketPath"`
|
||||
TrustedProxies string `description:"Comma-separated list of trusted proxy addresses." yaml:"trustedProxies"`
|
||||
}
|
||||
|
||||
type AuthConfig struct {
|
||||
Users string `description:"Comma-separated list of users (username:hashed_password)." yaml:"users"`
|
||||
UsersFile string `description:"Path to the users file." yaml:"usersFile"`
|
||||
SecureCookie bool `description:"Enable secure cookies." yaml:"secureCookie"`
|
||||
SessionExpiry int `description:"Session expiry time in seconds." yaml:"sessionExpiry"`
|
||||
LoginTimeout int `description:"Login timeout in seconds." yaml:"loginTimeout"`
|
||||
LoginMaxRetries int `description:"Maximum login retries." yaml:"loginMaxRetries"`
|
||||
}
|
||||
|
||||
type OAuthConfig struct {
|
||||
Whitelist string `description:"Comma-separated list of allowed OAuth domains." yaml:"whitelist"`
|
||||
AutoRedirect string `description:"The OAuth provider to use for automatic redirection." yaml:"autoRedirect"`
|
||||
Providers map[string]OAuthServiceConfig `description:"OAuth providers configuration." yaml:"providers"`
|
||||
}
|
||||
|
||||
type UIConfig struct {
|
||||
Title string `description:"The title of the UI." yaml:"title"`
|
||||
ForgotPasswordMessage string `description:"Message displayed on the forgot password page." yaml:"forgotPasswordMessage"`
|
||||
BackgroundImage string `description:"Path to the background image." yaml:"backgroundImage"`
|
||||
}
|
||||
|
||||
type LdapConfig struct {
|
||||
Address string `description:"LDAP server address." yaml:"address"`
|
||||
BindDN string `description:"Bind DN for LDAP authentication." yaml:"bindDn"`
|
||||
BindPassword string `description:"Bind password for LDAP authentication." yaml:"bindPassword"`
|
||||
BaseDN string `description:"Base DN for LDAP searches." yaml:"baseDn"`
|
||||
Insecure bool `description:"Allow insecure LDAP connections." yaml:"insecure"`
|
||||
SearchFilter string `description:"LDAP search filter." yaml:"searchFilter"`
|
||||
}
|
||||
|
||||
type OIDCConfig struct {
|
||||
Enabled bool `description:"Enable OIDC provider functionality." yaml:"enabled"`
|
||||
Issuer string `description:"OIDC issuer URL (defaults to appUrl)." yaml:"issuer"`
|
||||
AccessTokenExpiry int `description:"Access token expiry time in seconds." yaml:"accessTokenExpiry"`
|
||||
IDTokenExpiry int `description:"ID token expiry time in seconds." yaml:"idTokenExpiry"`
|
||||
Clients map[string]OIDCClientConfig `description:"OIDC client configurations." yaml:"clients"`
|
||||
}
|
||||
|
||||
type OIDCClientConfig struct {
|
||||
ClientSecret string `description:"OIDC client secret." yaml:"clientSecret"`
|
||||
ClientSecretFile string `description:"Path to the file containing the OIDC client secret." yaml:"clientSecretFile"`
|
||||
ClientName string `description:"Client name for display purposes." yaml:"clientName"`
|
||||
RedirectURIs []string `description:"Allowed redirect URIs." yaml:"redirectUris"`
|
||||
GrantTypes []string `description:"Allowed grant types (defaults to ['authorization_code'])." yaml:"grantTypes"`
|
||||
ResponseTypes []string `description:"Allowed response types (defaults to ['code'])." yaml:"responseTypes"`
|
||||
Scopes []string `description:"Allowed scopes (defaults to ['openid', 'profile', 'email'])." yaml:"scopes"`
|
||||
}
|
||||
|
||||
type ExperimentalConfig struct {
|
||||
ConfigFile string `description:"Path to config file." yaml:"-"`
|
||||
}
|
||||
|
||||
// Config loader options
|
||||
|
||||
const DefaultNamePrefix = "TINYAUTH_"
|
||||
|
||||
// OAuth/OIDC config
|
||||
|
||||
type Claims struct {
|
||||
Sub string `json:"sub"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
PreferredUsername string `json:"preferred_username"`
|
||||
@@ -55,16 +106,16 @@ type Claims struct {
|
||||
}
|
||||
|
||||
type OAuthServiceConfig struct {
|
||||
ClientID string `field:"client-id"`
|
||||
ClientSecret string
|
||||
ClientSecretFile string
|
||||
Scopes []string
|
||||
RedirectURL string `field:"redirect-url"`
|
||||
AuthURL string `field:"auth-url"`
|
||||
TokenURL string `field:"token-url"`
|
||||
UserinfoURL string `field:"user-info-url"`
|
||||
InsecureSkipVerify bool
|
||||
Name string
|
||||
ClientID string `description:"OAuth client ID."`
|
||||
ClientSecret string `description:"OAuth client secret."`
|
||||
ClientSecretFile string `description:"Path to the file containing the OAuth client secret."`
|
||||
Scopes []string `description:"OAuth scopes."`
|
||||
RedirectURL string `description:"OAuth redirect URL."`
|
||||
AuthURL string `description:"OAuth authorization URL."`
|
||||
TokenURL string `description:"OAuth token URL."`
|
||||
UserinfoURL string `description:"OAuth userinfo URL."`
|
||||
Insecure bool `description:"Allow insecure OAuth connections."`
|
||||
Name string `description:"Provider name in UI."`
|
||||
}
|
||||
|
||||
var OverrideProviders = map[string]string{
|
||||
@@ -94,6 +145,7 @@ type SessionCookie struct {
|
||||
TotpPending bool
|
||||
OAuthGroups string
|
||||
OAuthName string
|
||||
OAuthSub string
|
||||
}
|
||||
|
||||
type UserContext struct {
|
||||
@@ -107,6 +159,7 @@ type UserContext struct {
|
||||
OAuthGroups string
|
||||
TotpEnabled bool
|
||||
OAuthName string
|
||||
OAuthSub string
|
||||
}
|
||||
|
||||
// API responses and queries
|
||||
|
||||
@@ -3,7 +3,8 @@ package controller
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"tinyauth/internal/utils"
|
||||
|
||||
"github.com/steveiliop56/tinyauth/internal/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/rs/zerolog/log"
|
||||
@@ -20,6 +21,7 @@ type UserContextResponse struct {
|
||||
OAuth bool `json:"oauth"`
|
||||
TotpPending bool `json:"totpPending"`
|
||||
OAuthName string `json:"oauthName"`
|
||||
OAuthSub string `json:"oauthSub"`
|
||||
}
|
||||
|
||||
type AppContextResponse struct {
|
||||
@@ -88,6 +90,7 @@ func (controller *ContextController) userContextHandler(c *gin.Context) {
|
||||
OAuth: context.OAuth,
|
||||
TotpPending: context.TotpPending,
|
||||
OAuthName: context.OAuthName,
|
||||
OAuthSub: context.OAuthSub,
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
|
||||
@@ -4,8 +4,9 @@ import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"tinyauth/internal/config"
|
||||
"tinyauth/internal/controller"
|
||||
|
||||
"github.com/steveiliop56/tinyauth/internal/config"
|
||||
"github.com/steveiliop56/tinyauth/internal/controller"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gotest.tools/v3/assert"
|
||||
@@ -43,6 +44,7 @@ var userContext = config.UserContext{
|
||||
TotpPending: false,
|
||||
OAuthGroups: "",
|
||||
TotpEnabled: false,
|
||||
OAuthSub: "",
|
||||
}
|
||||
|
||||
func setupContextController(middlewares *[]gin.HandlerFunc) (*gin.Engine, *httptest.ResponseRecorder) {
|
||||
|
||||
@@ -5,9 +5,10 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
"tinyauth/internal/config"
|
||||
"tinyauth/internal/service"
|
||||
"tinyauth/internal/utils"
|
||||
|
||||
"github.com/steveiliop56/tinyauth/internal/config"
|
||||
"github.com/steveiliop56/tinyauth/internal/service"
|
||||
"github.com/steveiliop56/tinyauth/internal/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/go-querystring/query"
|
||||
@@ -196,6 +197,7 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
|
||||
Provider: req.Provider,
|
||||
OAuthGroups: utils.CoalesceToString(user.Groups),
|
||||
OAuthName: service.GetName(),
|
||||
OAuthSub: user.Sub,
|
||||
}
|
||||
|
||||
log.Trace().Interface("session_cookie", sessionCookie).Msg("Creating session cookie")
|
||||
|
||||
489
internal/controller/oidc_controller.go
Normal file
489
internal/controller/oidc_controller.go
Normal file
@@ -0,0 +1,489 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/steveiliop56/tinyauth/internal/config"
|
||||
"github.com/steveiliop56/tinyauth/internal/service"
|
||||
"github.com/steveiliop56/tinyauth/internal/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// OIDCControllerConfig holds configuration for the OIDC controller.
|
||||
type OIDCControllerConfig struct {
|
||||
AppURL string // Base URL of the application
|
||||
CookieDomain string // Domain for setting cookies
|
||||
}
|
||||
|
||||
// OIDCController handles OpenID Connect (OIDC) protocol endpoints.
|
||||
// It implements the OIDC provider functionality including discovery, authorization,
|
||||
// token exchange, userinfo, and JWKS endpoints.
|
||||
type OIDCController struct {
|
||||
config OIDCControllerConfig
|
||||
router *gin.RouterGroup
|
||||
oidc *service.OIDCService
|
||||
auth *service.AuthService
|
||||
}
|
||||
|
||||
// NewOIDCController creates a new OIDC controller with the given configuration and services.
|
||||
func NewOIDCController(config OIDCControllerConfig, router *gin.RouterGroup, oidc *service.OIDCService, auth *service.AuthService) *OIDCController {
|
||||
return &OIDCController{
|
||||
config: config,
|
||||
router: router,
|
||||
oidc: oidc,
|
||||
auth: auth,
|
||||
}
|
||||
}
|
||||
|
||||
// SetupRoutes registers all OIDC endpoints with the router.
|
||||
// This includes:
|
||||
// - /.well-known/openid-configuration - OIDC discovery endpoint
|
||||
// - /oidc/authorize - Authorization endpoint
|
||||
// - /oidc/token - Token endpoint
|
||||
// - /oidc/userinfo - UserInfo endpoint
|
||||
// - /oidc/jwks - JSON Web Key Set endpoint
|
||||
func (controller *OIDCController) SetupRoutes() {
|
||||
// Well-known discovery endpoint
|
||||
controller.router.GET("/.well-known/openid-configuration", controller.discoveryHandler)
|
||||
|
||||
// OIDC endpoints
|
||||
oidcGroup := controller.router.Group("/oidc")
|
||||
oidcGroup.GET("/authorize", controller.authorizeHandler)
|
||||
oidcGroup.POST("/token", controller.tokenHandler)
|
||||
oidcGroup.GET("/userinfo", controller.userinfoHandler)
|
||||
oidcGroup.GET("/jwks", controller.jwksHandler)
|
||||
}
|
||||
|
||||
// discoveryHandler handles the OIDC discovery endpoint.
|
||||
// Returns the OpenID Connect discovery document as specified in RFC 8414.
|
||||
// The document contains metadata about the OIDC provider including endpoints,
|
||||
// supported features, and cryptographic capabilities.
|
||||
func (controller *OIDCController) discoveryHandler(c *gin.Context) {
|
||||
issuer := controller.oidc.GetIssuer()
|
||||
baseURL := strings.TrimSuffix(controller.config.AppURL, "/")
|
||||
|
||||
discovery := map[string]interface{}{
|
||||
"issuer": issuer,
|
||||
"authorization_endpoint": fmt.Sprintf("%s/api/oidc/authorize", baseURL),
|
||||
"token_endpoint": fmt.Sprintf("%s/api/oidc/token", baseURL),
|
||||
"userinfo_endpoint": fmt.Sprintf("%s/api/oidc/userinfo", baseURL),
|
||||
"jwks_uri": fmt.Sprintf("%s/api/oidc/jwks", baseURL),
|
||||
"response_types_supported": []string{"code"},
|
||||
"subject_types_supported": []string{"public"},
|
||||
"id_token_signing_alg_values_supported": []string{"RS256"},
|
||||
"scopes_supported": []string{"openid", "profile", "email"},
|
||||
"token_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post"},
|
||||
"grant_types_supported": []string{"authorization_code"},
|
||||
"code_challenge_methods_supported": []string{"S256", "plain"},
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, discovery)
|
||||
}
|
||||
|
||||
// authorizeHandler handles the OIDC authorization endpoint.
|
||||
// Implements the authorization code flow as specified in OAuth 2.0 RFC 6749.
|
||||
// Validates client credentials, redirect URI, scopes, and response type.
|
||||
// Supports PKCE (RFC 7636) for enhanced security.
|
||||
// If the user is not authenticated, redirects to the login page with the
|
||||
// authorization request parameters preserved for redirect after login.
|
||||
// On success, generates an authorization code and redirects to the client's
|
||||
// redirect URI with the code and state parameter.
|
||||
func (controller *OIDCController) authorizeHandler(c *gin.Context) {
|
||||
// Get query parameters
|
||||
clientID := c.Query("client_id")
|
||||
redirectURI := c.Query("redirect_uri")
|
||||
responseType := c.Query("response_type")
|
||||
scope := c.Query("scope")
|
||||
state := c.Query("state")
|
||||
nonce := c.Query("nonce")
|
||||
codeChallenge := c.Query("code_challenge")
|
||||
codeChallengeMethod := c.Query("code_challenge_method")
|
||||
|
||||
// Validate required parameters
|
||||
// Return JSON error instead of redirecting since redirect_uri is not yet validated
|
||||
if clientID == "" || redirectURI == "" || responseType == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "invalid_request",
|
||||
"error_description": "Missing required parameters",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Get client
|
||||
// Return JSON error instead of redirecting since redirect_uri is not yet validated
|
||||
client, err := controller.oidc.GetClient(clientID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "invalid_client",
|
||||
"error_description": "Client not found",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate redirect URI
|
||||
// After this point, redirect_uri is validated and we can safely redirect
|
||||
if !controller.oidc.ValidateRedirectURI(client, redirectURI) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "invalid_request",
|
||||
"error_description": "Invalid redirect_uri",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate response type
|
||||
if !controller.oidc.ValidateResponseType(client, responseType) {
|
||||
controller.redirectError(c, redirectURI, state, "unsupported_response_type", "Unsupported response_type")
|
||||
return
|
||||
}
|
||||
|
||||
// Validate scopes
|
||||
scopes, err := controller.oidc.ValidateScope(client, scope)
|
||||
if err != nil {
|
||||
controller.redirectError(c, redirectURI, state, "invalid_scope", "Invalid scope")
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user is authenticated
|
||||
userContext, err := utils.GetContext(c)
|
||||
if err != nil || !userContext.IsLoggedIn {
|
||||
// User not authenticated, redirect to login
|
||||
// Build the full authorize URL to redirect back to after login
|
||||
authorizeURL := fmt.Sprintf("%s%s", controller.config.AppURL, c.Request.URL.Path)
|
||||
if c.Request.URL.RawQuery != "" {
|
||||
authorizeURL = fmt.Sprintf("%s?%s", authorizeURL, c.Request.URL.RawQuery)
|
||||
}
|
||||
loginURL := fmt.Sprintf("%s/login?redirect_uri=%s&client_id=%s&response_type=%s&scope=%s&state=%s&nonce=%s&code_challenge=%s&code_challenge_method=%s",
|
||||
controller.config.AppURL,
|
||||
url.QueryEscape(authorizeURL),
|
||||
url.QueryEscape(clientID),
|
||||
url.QueryEscape(responseType),
|
||||
url.QueryEscape(scope),
|
||||
url.QueryEscape(state),
|
||||
url.QueryEscape(nonce),
|
||||
url.QueryEscape(codeChallenge),
|
||||
url.QueryEscape(codeChallengeMethod))
|
||||
c.Redirect(http.StatusFound, loginURL)
|
||||
return
|
||||
}
|
||||
|
||||
// Check for TOTP pending
|
||||
if userContext.TotpPending {
|
||||
controller.redirectError(c, redirectURI, state, "access_denied", "TOTP verification required")
|
||||
return
|
||||
}
|
||||
|
||||
// Generate authorization code (including PKCE challenge if provided)
|
||||
authCode, err := controller.oidc.GenerateAuthorizationCode(&userContext, clientID, redirectURI, scopes, nonce, codeChallenge, codeChallengeMethod)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to generate authorization code")
|
||||
controller.redirectError(c, redirectURI, state, "server_error", "Internal server error")
|
||||
return
|
||||
}
|
||||
|
||||
// Build redirect URL with authorization code
|
||||
redirectURL, err := url.Parse(redirectURI)
|
||||
if err != nil {
|
||||
controller.redirectError(c, redirectURI, state, "invalid_request", "Invalid redirect_uri")
|
||||
return
|
||||
}
|
||||
|
||||
query := redirectURL.Query()
|
||||
query.Set("code", authCode)
|
||||
if state != "" {
|
||||
query.Set("state", state)
|
||||
}
|
||||
redirectURL.RawQuery = query.Encode()
|
||||
|
||||
c.Redirect(http.StatusFound, redirectURL.String())
|
||||
}
|
||||
|
||||
// tokenHandler handles the OIDC token endpoint.
|
||||
// Exchanges an authorization code for access and ID tokens.
|
||||
// Validates the authorization code, client credentials, redirect URI, and PKCE verifier.
|
||||
// Returns an access token and optionally an ID token (if openid scope is present).
|
||||
// Implements the authorization code grant type as specified in OAuth 2.0 RFC 6749.
|
||||
func (controller *OIDCController) tokenHandler(c *gin.Context) {
|
||||
// Get grant type
|
||||
grantType := c.PostForm("grant_type")
|
||||
if grantType == "" {
|
||||
grantType = c.Query("grant_type")
|
||||
}
|
||||
|
||||
if grantType != "authorization_code" {
|
||||
controller.tokenError(c, "unsupported_grant_type", "Only authorization_code grant type is supported")
|
||||
return
|
||||
}
|
||||
|
||||
// Get authorization code
|
||||
code := c.PostForm("code")
|
||||
if code == "" {
|
||||
code = c.Query("code")
|
||||
}
|
||||
|
||||
if code == "" {
|
||||
controller.tokenError(c, "invalid_request", "Missing authorization code")
|
||||
return
|
||||
}
|
||||
|
||||
// Get client credentials
|
||||
clientID, clientSecret, err := controller.getClientCredentials(c)
|
||||
if err != nil {
|
||||
controller.tokenError(c, "invalid_client", "Invalid client credentials")
|
||||
return
|
||||
}
|
||||
|
||||
// Get client
|
||||
client, err := controller.oidc.GetClient(clientID)
|
||||
if err != nil {
|
||||
controller.tokenError(c, "invalid_client", "Client not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify client secret
|
||||
if !controller.oidc.VerifyClientSecret(client, clientSecret) {
|
||||
controller.tokenError(c, "invalid_client", "Invalid client secret")
|
||||
return
|
||||
}
|
||||
|
||||
// Get redirect URI
|
||||
redirectURI := c.PostForm("redirect_uri")
|
||||
if redirectURI == "" {
|
||||
redirectURI = c.Query("redirect_uri")
|
||||
}
|
||||
|
||||
// Validate redirect URI
|
||||
if !controller.oidc.ValidateRedirectURI(client, redirectURI) {
|
||||
controller.tokenError(c, "invalid_request", "Invalid redirect_uri")
|
||||
return
|
||||
}
|
||||
|
||||
// Get code_verifier for PKCE validation
|
||||
codeVerifier := c.PostForm("code_verifier")
|
||||
if codeVerifier == "" {
|
||||
codeVerifier = c.Query("code_verifier")
|
||||
}
|
||||
|
||||
// Validate authorization code
|
||||
userContext, scopes, nonce, codeChallenge, codeChallengeMethod, err := controller.oidc.ValidateAuthorizationCode(code, clientID, redirectURI)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to validate authorization code")
|
||||
controller.tokenError(c, "invalid_grant", "Invalid or expired authorization code")
|
||||
return
|
||||
}
|
||||
|
||||
// Validate PKCE if code challenge was provided
|
||||
if codeChallenge != "" {
|
||||
if err := controller.oidc.ValidatePKCE(codeChallenge, codeChallengeMethod, codeVerifier); err != nil {
|
||||
log.Error().Err(err).Msg("PKCE validation failed")
|
||||
controller.tokenError(c, "invalid_grant", "Invalid code_verifier")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Generate tokens
|
||||
accessToken, err := controller.oidc.GenerateAccessToken(userContext, clientID, scopes)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to generate access token")
|
||||
controller.tokenError(c, "server_error", "Internal server error")
|
||||
return
|
||||
}
|
||||
|
||||
// Generate ID token if openid scope is present
|
||||
var idToken string
|
||||
hasOpenID := false
|
||||
for _, scope := range scopes {
|
||||
if scope == "openid" {
|
||||
hasOpenID = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if hasOpenID {
|
||||
idToken, err = controller.oidc.GenerateIDToken(userContext, clientID, nonce)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to generate ID token")
|
||||
controller.tokenError(c, "server_error", "Internal server error")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Return token response
|
||||
response := map[string]interface{}{
|
||||
"access_token": accessToken,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": controller.oidc.GetAccessTokenExpiry(),
|
||||
"scope": strings.Join(scopes, " "),
|
||||
}
|
||||
|
||||
if idToken != "" {
|
||||
response["id_token"] = idToken
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// userinfoHandler handles the OIDC UserInfo endpoint.
|
||||
// Returns user information claims for the authenticated user based on the
|
||||
// provided access token. Validates the access token signature, issuer, and expiration.
|
||||
// Returns standard OIDC claims: sub, email, name, and preferred_username.
|
||||
func (controller *OIDCController) userinfoHandler(c *gin.Context) {
|
||||
// Get access token from Authorization header or query parameter
|
||||
accessToken := controller.getAccessToken(c)
|
||||
if accessToken == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "invalid_token",
|
||||
"error_description": "Missing access token",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Get optional client_id from request for audience validation
|
||||
clientID := c.Query("client_id")
|
||||
if clientID == "" {
|
||||
clientID = c.PostForm("client_id")
|
||||
}
|
||||
|
||||
// Validate and parse access token with audience validation
|
||||
userContext, err := controller.oidc.ValidateAccessTokenForClient(accessToken, clientID)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to validate access token")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "invalid_token",
|
||||
"error_description": "Invalid or expired access token",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Return user info
|
||||
userInfo := map[string]interface{}{
|
||||
"sub": userContext.Username,
|
||||
"email": userContext.Email,
|
||||
"name": userContext.Name,
|
||||
"preferred_username": userContext.Username,
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, userInfo)
|
||||
}
|
||||
|
||||
// jwksHandler handles the JSON Web Key Set (JWKS) endpoint.
|
||||
// Returns the public keys used to verify ID tokens and access tokens.
|
||||
// The keys are in JWK format as specified in RFC 7517.
|
||||
func (controller *OIDCController) jwksHandler(c *gin.Context) {
|
||||
jwks, err := controller.oidc.GetJWKS()
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to get JWKS")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "server_error",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, jwks)
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
// redirectError redirects the user to the redirect URI with an error response.
|
||||
// Includes the error code, error description, and state parameter (if provided).
|
||||
// If the redirect URI is invalid or empty, returns a JSON error response instead.
|
||||
func (controller *OIDCController) redirectError(c *gin.Context, redirectURI string, state string, errorCode string, errorDescription string) {
|
||||
if redirectURI == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": errorCode,
|
||||
"error_description": errorDescription,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
redirectURL, err := url.Parse(redirectURI)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": errorCode,
|
||||
"error_description": errorDescription,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
query := redirectURL.Query()
|
||||
query.Set("error", errorCode)
|
||||
query.Set("error_description", errorDescription)
|
||||
if state != "" {
|
||||
query.Set("state", state)
|
||||
}
|
||||
redirectURL.RawQuery = query.Encode()
|
||||
|
||||
c.Redirect(http.StatusFound, redirectURL.String())
|
||||
}
|
||||
|
||||
// tokenError returns a JSON error response for token endpoint errors.
|
||||
// Uses the standard OAuth 2.0 error format with error and error_description fields.
|
||||
func (controller *OIDCController) tokenError(c *gin.Context, errorCode string, errorDescription string) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": errorCode,
|
||||
"error_description": errorDescription,
|
||||
})
|
||||
}
|
||||
|
||||
// getClientCredentials extracts client credentials from the request.
|
||||
// Supports client_secret_basic (HTTP Basic Authentication) and
|
||||
// client_secret_post (POST form parameters) as specified in the discovery document.
|
||||
// Does not accept credentials via query parameters for security reasons
|
||||
// (they may be logged in access logs, browser history, or referrer headers).
|
||||
// Returns the client ID, client secret, and an error if credentials are not found.
|
||||
func (controller *OIDCController) getClientCredentials(c *gin.Context) (string, string, error) {
|
||||
// Try Basic Auth first (client_secret_basic)
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if strings.HasPrefix(authHeader, "Basic ") {
|
||||
encoded := strings.TrimPrefix(authHeader, "Basic ")
|
||||
decoded, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err == nil {
|
||||
parts := strings.SplitN(string(decoded), ":", 2)
|
||||
if len(parts) == 2 {
|
||||
return parts[0], parts[1], nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try POST form parameters (client_secret_post)
|
||||
clientID := c.PostForm("client_id")
|
||||
clientSecret := c.PostForm("client_secret")
|
||||
if clientID != "" && clientSecret != "" {
|
||||
return clientID, clientSecret, nil
|
||||
}
|
||||
|
||||
// Do not accept credentials via query parameters as they are logged
|
||||
// in access logs, browser history, and referrer headers
|
||||
return "", "", fmt.Errorf("client credentials not found")
|
||||
}
|
||||
|
||||
// getAccessToken extracts the access token from the request.
|
||||
// Checks the Authorization header (Bearer token) first, then falls back to
|
||||
// the access_token query parameter.
|
||||
// Returns an empty string if no access token is found.
|
||||
func (controller *OIDCController) getAccessToken(c *gin.Context) string {
|
||||
// Try Authorization header
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if strings.HasPrefix(authHeader, "Bearer ") {
|
||||
return strings.TrimPrefix(authHeader, "Bearer ")
|
||||
}
|
||||
|
||||
// Try query parameter
|
||||
return c.Query("access_token")
|
||||
}
|
||||
|
||||
// validateAccessToken validates an access token and extracts user context.
|
||||
// Verifies the JWT signature using the OIDC service's public key, checks the
|
||||
// issuer, and validates expiration. Returns the user context if valid, or an
|
||||
// error if validation fails.
|
||||
func (controller *OIDCController) validateAccessToken(accessToken string) (*config.UserContext, error) {
|
||||
// Validate the JWT token using the OIDC service's public key
|
||||
// This properly verifies the signature, issuer, and expiration
|
||||
// Note: This method does not validate audience - use ValidateAccessTokenForClient for that
|
||||
return controller.oidc.ValidateAccessToken(accessToken)
|
||||
}
|
||||
@@ -3,16 +3,20 @@ package controller
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
"tinyauth/internal/config"
|
||||
"tinyauth/internal/service"
|
||||
"tinyauth/internal/utils"
|
||||
|
||||
"github.com/steveiliop56/tinyauth/internal/config"
|
||||
"github.com/steveiliop56/tinyauth/internal/service"
|
||||
"github.com/steveiliop56/tinyauth/internal/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/go-querystring/query"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
var SupportedProxies = []string{"nginx", "traefik", "caddy", "envoy"}
|
||||
|
||||
type Proxy struct {
|
||||
Proxy string `uri:"proxy" binding:"required"`
|
||||
}
|
||||
@@ -40,6 +44,7 @@ func NewProxyController(config ProxyControllerConfig, router *gin.RouterGroup, a
|
||||
func (controller *ProxyController) SetupRoutes() {
|
||||
proxyGroup := controller.router.Group("/auth")
|
||||
proxyGroup.GET("/:proxy", controller.proxyHandler)
|
||||
proxyGroup.POST("/:proxy", controller.proxyHandler)
|
||||
}
|
||||
|
||||
func (controller *ProxyController) proxyHandler(c *gin.Context) {
|
||||
@@ -55,7 +60,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if req.Proxy != "nginx" && req.Proxy != "traefik" && req.Proxy != "caddy" {
|
||||
if !slices.Contains(SupportedProxies, req.Proxy) {
|
||||
log.Warn().Str("proxy", req.Proxy).Msg("Invalid proxy")
|
||||
c.JSON(400, gin.H{
|
||||
"status": 400,
|
||||
@@ -234,6 +239,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
|
||||
c.Header("Remote-Name", utils.SanitizeHeader(userContext.Name))
|
||||
c.Header("Remote-Email", utils.SanitizeHeader(userContext.Email))
|
||||
c.Header("Remote-Groups", utils.SanitizeHeader(userContext.OAuthGroups))
|
||||
c.Header("Remote-Sub", utils.SanitizeHeader(userContext.OAuthSub))
|
||||
|
||||
controller.setHeaders(c, acls)
|
||||
|
||||
|
||||
@@ -3,9 +3,10 @@ package controller_test
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"tinyauth/internal/config"
|
||||
"tinyauth/internal/controller"
|
||||
"tinyauth/internal/service"
|
||||
|
||||
"github.com/steveiliop56/tinyauth/internal/config"
|
||||
"github.com/steveiliop56/tinyauth/internal/controller"
|
||||
"github.com/steveiliop56/tinyauth/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gotest.tools/v3/assert"
|
||||
@@ -92,6 +93,18 @@ func TestProxyHandler(t *testing.T) {
|
||||
assert.Equal(t, 307, recorder.Code)
|
||||
assert.Equal(t, "http://localhost:8080/login?redirect_uri=https%3A%2F%2Fexample.com%2Fsomepath", recorder.Header().Get("Location"))
|
||||
|
||||
// Test logged out user (envoy)
|
||||
recorder = httptest.NewRecorder()
|
||||
req = httptest.NewRequest("POST", "/api/auth/envoy", nil)
|
||||
req.Header.Set("X-Forwarded-Proto", "https")
|
||||
req.Header.Set("X-Forwarded-Host", "example.com")
|
||||
req.Header.Set("X-Forwarded-Uri", "/somepath")
|
||||
req.Header.Set("Accept", "text/html")
|
||||
router.ServeHTTP(recorder, req)
|
||||
|
||||
assert.Equal(t, 307, recorder.Code)
|
||||
assert.Equal(t, "http://localhost:8080/login?redirect_uri=https%3A%2F%2Fexample.com%2Fsomepath", recorder.Header().Get("Location"))
|
||||
|
||||
// Test logged out user (nginx)
|
||||
recorder = httptest.NewRecorder()
|
||||
req = httptest.NewRequest("GET", "/api/auth/nginx", nil)
|
||||
|
||||
@@ -4,7 +4,8 @@ import (
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
"tinyauth/internal/controller"
|
||||
|
||||
"github.com/steveiliop56/tinyauth/internal/controller"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gotest.tools/v3/assert"
|
||||
|
||||
@@ -3,9 +3,10 @@ package controller
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"tinyauth/internal/config"
|
||||
"tinyauth/internal/service"
|
||||
"tinyauth/internal/utils"
|
||||
|
||||
"github.com/steveiliop56/tinyauth/internal/config"
|
||||
"github.com/steveiliop56/tinyauth/internal/service"
|
||||
"github.com/steveiliop56/tinyauth/internal/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pquerna/otp/totp"
|
||||
|
||||
@@ -7,9 +7,10 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
"tinyauth/internal/config"
|
||||
"tinyauth/internal/controller"
|
||||
"tinyauth/internal/service"
|
||||
|
||||
"github.com/steveiliop56/tinyauth/internal/config"
|
||||
"github.com/steveiliop56/tinyauth/internal/controller"
|
||||
"github.com/steveiliop56/tinyauth/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pquerna/otp/totp"
|
||||
|
||||
@@ -3,9 +3,10 @@ package middleware
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"tinyauth/internal/config"
|
||||
"tinyauth/internal/service"
|
||||
"tinyauth/internal/utils"
|
||||
|
||||
"github.com/steveiliop56/tinyauth/internal/config"
|
||||
"github.com/steveiliop56/tinyauth/internal/service"
|
||||
"github.com/steveiliop56/tinyauth/internal/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/rs/zerolog/log"
|
||||
@@ -65,6 +66,7 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc {
|
||||
goto basic
|
||||
}
|
||||
|
||||
m.auth.RefreshSessionCookie(c)
|
||||
c.Set("context", &config.UserContext{
|
||||
Username: cookie.Username,
|
||||
Name: cookie.Name,
|
||||
@@ -89,6 +91,7 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc {
|
||||
goto basic
|
||||
}
|
||||
|
||||
m.auth.RefreshSessionCookie(c)
|
||||
c.Set("context", &config.UserContext{
|
||||
Username: cookie.Username,
|
||||
Name: cookie.Name,
|
||||
@@ -96,6 +99,7 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc {
|
||||
Provider: cookie.Provider,
|
||||
OAuthGroups: cookie.OAuthGroups,
|
||||
OAuthName: cookie.OAuthName,
|
||||
OAuthSub: cookie.OAuthSub,
|
||||
IsLoggedIn: true,
|
||||
OAuth: true,
|
||||
})
|
||||
|
||||
@@ -7,7 +7,8 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
"tinyauth/internal/assets"
|
||||
|
||||
"github.com/steveiliop56/tinyauth/internal/assets"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
15
internal/model/oidc_authorization_code_model.go
Normal file
15
internal/model/oidc_authorization_code_model.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package model
|
||||
|
||||
type OIDCAuthorizationCode struct {
|
||||
Code string `gorm:"column:code;primaryKey"`
|
||||
ClientID string `gorm:"column:client_id;not null"`
|
||||
RedirectURI string `gorm:"column:redirect_uri;not null"`
|
||||
Used bool `gorm:"column:used;default:false"`
|
||||
ExpiresAt int64 `gorm:"column:expires_at;not null"`
|
||||
CreatedAt int64 `gorm:"column:created_at;not null"`
|
||||
}
|
||||
|
||||
func (OIDCAuthorizationCode) TableName() string {
|
||||
return "oidc_authorization_codes"
|
||||
}
|
||||
|
||||
18
internal/model/oidc_client_model.go
Normal file
18
internal/model/oidc_client_model.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package model
|
||||
|
||||
type OIDCClient struct {
|
||||
ClientID string `gorm:"column:client_id;primaryKey"`
|
||||
ClientSecret string `gorm:"column:client_secret"`
|
||||
ClientName string `gorm:"column:client_name"`
|
||||
RedirectURIs string `gorm:"column:redirect_uris"` // JSON array
|
||||
GrantTypes string `gorm:"column:grant_types"` // JSON array
|
||||
ResponseTypes string `gorm:"column:response_types"` // JSON array
|
||||
Scopes string `gorm:"column:scopes"` // JSON array
|
||||
CreatedAt int64 `gorm:"column:created_at"`
|
||||
UpdatedAt int64 `gorm:"column:updated_at"`
|
||||
}
|
||||
|
||||
func (OIDCClient) TableName() string {
|
||||
return "oidc_clients"
|
||||
}
|
||||
|
||||
13
internal/model/oidc_key_model.go
Normal file
13
internal/model/oidc_key_model.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package model
|
||||
|
||||
type OIDCKey struct {
|
||||
ID int `gorm:"column:id;primaryKey;autoIncrement"`
|
||||
PrivateKey string `gorm:"column:private_key;not null"`
|
||||
CreatedAt int64 `gorm:"column:created_at"`
|
||||
UpdatedAt int64 `gorm:"column:updated_at"`
|
||||
}
|
||||
|
||||
func (OIDCKey) TableName() string {
|
||||
return "oidc_keys"
|
||||
}
|
||||
|
||||
@@ -10,4 +10,5 @@ type Session struct {
|
||||
OAuthGroups string `gorm:"column:oauth_groups"`
|
||||
Expiry int64 `gorm:"column:expiry"`
|
||||
OAuthName string `gorm:"column:oauth_name"`
|
||||
OAuthSub string `gorm:"column:oauth_sub"`
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"tinyauth/internal/config"
|
||||
"github.com/steveiliop56/tinyauth/internal/config"
|
||||
)
|
||||
|
||||
/*
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"tinyauth/internal/config"
|
||||
"tinyauth/internal/model"
|
||||
"tinyauth/internal/utils"
|
||||
|
||||
"github.com/steveiliop56/tinyauth/internal/config"
|
||||
"github.com/steveiliop56/tinyauth/internal/model"
|
||||
"github.com/steveiliop56/tinyauth/internal/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
@@ -43,7 +43,6 @@ type AuthService struct {
|
||||
loginMutex sync.RWMutex
|
||||
ldap *LdapService
|
||||
database *gorm.DB
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
func NewAuthService(config AuthServiceConfig, docker *DockerService, ldap *LdapService, database *gorm.DB) *AuthService {
|
||||
@@ -57,7 +56,6 @@ func NewAuthService(config AuthServiceConfig, docker *DockerService, ldap *LdapS
|
||||
}
|
||||
|
||||
func (auth *AuthService) Init() error {
|
||||
auth.ctx = context.Background()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -215,9 +213,10 @@ func (auth *AuthService) CreateSessionCookie(c *gin.Context, data *config.Sessio
|
||||
OAuthGroups: data.OAuthGroups,
|
||||
Expiry: time.Now().Add(time.Duration(expiry) * time.Second).Unix(),
|
||||
OAuthName: data.OAuthName,
|
||||
OAuthSub: data.OAuthSub,
|
||||
}
|
||||
|
||||
err = gorm.G[model.Session](auth.database).Create(auth.ctx, &session)
|
||||
err = gorm.G[model.Session](auth.database).Create(c, &session)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -228,6 +227,40 @@ func (auth *AuthService) CreateSessionCookie(c *gin.Context, data *config.Sessio
|
||||
return nil
|
||||
}
|
||||
|
||||
func (auth *AuthService) RefreshSessionCookie(c *gin.Context) error {
|
||||
cookie, err := c.Cookie(auth.config.SessionCookieName)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
session, err := gorm.G[model.Session](auth.database).Where("uuid = ?", cookie).First(c)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
currentTime := time.Now().Unix()
|
||||
|
||||
if session.Expiry-currentTime > int64(time.Hour.Seconds()) {
|
||||
return nil
|
||||
}
|
||||
|
||||
newExpiry := currentTime + int64(time.Hour.Seconds())
|
||||
|
||||
_, err = gorm.G[model.Session](auth.database).Where("uuid = ?", cookie).Updates(c, model.Session{
|
||||
Expiry: newExpiry,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.SetCookie(auth.config.SessionCookieName, cookie, int(time.Hour.Seconds()), "/", fmt.Sprintf(".%s", auth.config.CookieDomain), auth.config.SecureCookie, true)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (auth *AuthService) DeleteSessionCookie(c *gin.Context) error {
|
||||
cookie, err := c.Cookie(auth.config.SessionCookieName)
|
||||
|
||||
@@ -235,7 +268,7 @@ func (auth *AuthService) DeleteSessionCookie(c *gin.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = gorm.G[model.Session](auth.database).Where("uuid = ?", cookie).Delete(auth.ctx)
|
||||
_, err = gorm.G[model.Session](auth.database).Where("uuid = ?", cookie).Delete(c)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -253,7 +286,7 @@ func (auth *AuthService) GetSessionCookie(c *gin.Context) (config.SessionCookie,
|
||||
return config.SessionCookie{}, err
|
||||
}
|
||||
|
||||
session, err := gorm.G[model.Session](auth.database).Where("uuid = ?", cookie).First(auth.ctx)
|
||||
session, err := gorm.G[model.Session](auth.database).Where("uuid = ?", cookie).First(c)
|
||||
|
||||
if err != nil {
|
||||
return config.SessionCookie{}, err
|
||||
@@ -266,7 +299,7 @@ func (auth *AuthService) GetSessionCookie(c *gin.Context) (config.SessionCookie,
|
||||
currentTime := time.Now().Unix()
|
||||
|
||||
if currentTime > session.Expiry {
|
||||
_, err = gorm.G[model.Session](auth.database).Where("uuid = ?", cookie).Delete(auth.ctx)
|
||||
_, err = gorm.G[model.Session](auth.database).Where("uuid = ?", cookie).Delete(c)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to delete expired session")
|
||||
}
|
||||
@@ -282,6 +315,7 @@ func (auth *AuthService) GetSessionCookie(c *gin.Context) (config.SessionCookie,
|
||||
TotpPending: session.TOTPPending,
|
||||
OAuthGroups: session.OAuthGroups,
|
||||
OAuthName: session.OAuthName,
|
||||
OAuthSub: session.OAuthSub,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"tinyauth/internal/assets"
|
||||
|
||||
"github.com/steveiliop56/tinyauth/internal/assets"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
|
||||
@@ -3,8 +3,9 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"tinyauth/internal/config"
|
||||
"tinyauth/internal/utils/decoders"
|
||||
|
||||
"github.com/steveiliop56/tinyauth/internal/config"
|
||||
"github.com/steveiliop56/tinyauth/internal/utils/decoders"
|
||||
|
||||
container "github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/client"
|
||||
@@ -82,7 +83,7 @@ func (docker *DockerService) GetLabels(appDomain string) (config.App, error) {
|
||||
return config.App{}, err
|
||||
}
|
||||
|
||||
labels, err := decoders.DecodeLabels(inspect.Config.Labels)
|
||||
labels, err := decoders.DecodeLabels[config.Apps](inspect.Config.Labels, "apps")
|
||||
if err != nil {
|
||||
return config.App{}, err
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
"tinyauth/internal/config"
|
||||
|
||||
"github.com/steveiliop56/tinyauth/internal/config"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"golang.org/x/oauth2"
|
||||
@@ -38,7 +39,7 @@ func NewGenericOAuthService(config config.OAuthServiceConfig) *GenericOAuthServi
|
||||
TokenURL: config.TokenURL,
|
||||
},
|
||||
},
|
||||
insecureSkipVerify: config.InsecureSkipVerify,
|
||||
insecureSkipVerify: config.Insecure,
|
||||
userinfoUrl: config.UserinfoURL,
|
||||
name: config.Name,
|
||||
}
|
||||
@@ -54,6 +55,7 @@ func (generic *GenericOAuthService) Init() error {
|
||||
|
||||
httpClient := &http.Client{
|
||||
Transport: transport,
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -9,8 +9,10 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
"tinyauth/internal/config"
|
||||
|
||||
"github.com/steveiliop56/tinyauth/internal/config"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/endpoints"
|
||||
@@ -26,6 +28,7 @@ type GithubEmailResponse []struct {
|
||||
type GithubUserInfoResponse struct {
|
||||
Login string `json:"login"`
|
||||
Name string `json:"name"`
|
||||
ID int `json:"id"`
|
||||
}
|
||||
|
||||
type GithubOAuthService struct {
|
||||
@@ -50,7 +53,9 @@ func NewGithubOAuthService(config config.OAuthServiceConfig) *GithubOAuthService
|
||||
}
|
||||
|
||||
func (github *GithubOAuthService) Init() error {
|
||||
httpClient := &http.Client{}
|
||||
httpClient := &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
ctx := context.Background()
|
||||
ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient)
|
||||
github.context = ctx
|
||||
@@ -169,6 +174,7 @@ func (github *GithubOAuthService) Userinfo() (config.Claims, error) {
|
||||
|
||||
user.PreferredUsername = userInfo.Login
|
||||
user.Name = userInfo.Name
|
||||
user.Sub = strconv.Itoa(userInfo.ID)
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
@@ -10,18 +10,14 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
"tinyauth/internal/config"
|
||||
|
||||
"github.com/steveiliop56/tinyauth/internal/config"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/endpoints"
|
||||
)
|
||||
|
||||
var GoogleOAuthScopes = []string{"https://www.googleapis.com/auth/userinfo.email", "https://www.googleapis.com/auth/userinfo.profile"}
|
||||
|
||||
type GoogleUserInfoResponse struct {
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
var GoogleOAuthScopes = []string{"openid", "email", "profile"}
|
||||
|
||||
type GoogleOAuthService struct {
|
||||
config oauth2.Config
|
||||
@@ -45,7 +41,9 @@ func NewGoogleOAuthService(config config.OAuthServiceConfig) *GoogleOAuthService
|
||||
}
|
||||
|
||||
func (google *GoogleOAuthService) Init() error {
|
||||
httpClient := &http.Client{}
|
||||
httpClient := &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
ctx := context.Background()
|
||||
ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient)
|
||||
google.context = ctx
|
||||
@@ -88,7 +86,7 @@ func (google *GoogleOAuthService) Userinfo() (config.Claims, error) {
|
||||
|
||||
client := google.config.Client(google.context, google.token)
|
||||
|
||||
res, err := client.Get("https://www.googleapis.com/userinfo/v2/me")
|
||||
res, err := client.Get("https://openidconnect.googleapis.com/v1/userinfo")
|
||||
if err != nil {
|
||||
return config.Claims{}, err
|
||||
}
|
||||
@@ -103,16 +101,12 @@ func (google *GoogleOAuthService) Userinfo() (config.Claims, error) {
|
||||
return config.Claims{}, err
|
||||
}
|
||||
|
||||
var userInfo GoogleUserInfoResponse
|
||||
|
||||
err = json.Unmarshal(body, &userInfo)
|
||||
err = json.Unmarshal(body, &user)
|
||||
if err != nil {
|
||||
return config.Claims{}, err
|
||||
}
|
||||
|
||||
user.PreferredUsername = strings.Split(userInfo.Email, "@")[0]
|
||||
user.Name = userInfo.Name
|
||||
user.Email = userInfo.Email
|
||||
user.PreferredUsername = strings.SplitN(user.Email, "@", 2)[0]
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"tinyauth/internal/config"
|
||||
|
||||
"github.com/steveiliop56/tinyauth/internal/config"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"golang.org/x/exp/slices"
|
||||
|
||||
822
internal/service/oidc_service.go
Normal file
822
internal/service/oidc_service.go
Normal file
@@ -0,0 +1,822 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/steveiliop56/tinyauth/internal/config"
|
||||
"github.com/steveiliop56/tinyauth/internal/model"
|
||||
"github.com/steveiliop56/tinyauth/internal/utils"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/rs/zerolog/log"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"golang.org/x/crypto/hkdf"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type OIDCServiceConfig struct {
|
||||
AppURL string
|
||||
Issuer string
|
||||
AccessTokenExpiry int
|
||||
IDTokenExpiry int
|
||||
Database *gorm.DB
|
||||
}
|
||||
|
||||
type OIDCService struct {
|
||||
config OIDCServiceConfig
|
||||
privateKey *rsa.PrivateKey
|
||||
publicKey *rsa.PublicKey
|
||||
masterKey []byte // Master key for encrypting private keys (optional)
|
||||
}
|
||||
|
||||
func NewOIDCService(config OIDCServiceConfig) *OIDCService {
|
||||
return &OIDCService{
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
// encryptPrivateKey encrypts a private key PEM string using AES-GCM
|
||||
func (oidc *OIDCService) encryptPrivateKey(plaintext string) (string, error) {
|
||||
if len(oidc.masterKey) == 0 {
|
||||
// No encryption key set, return plaintext
|
||||
return plaintext, nil
|
||||
}
|
||||
|
||||
// Derive AES-256 key from master key using HKDF
|
||||
hkdfReader := hkdf.New(sha256.New, oidc.masterKey, nil, []byte("oidc-aes-256-key-v1"))
|
||||
key := make([]byte, 32) // AES-256 requires 32 bytes
|
||||
if _, err := io.ReadFull(hkdfReader, key); err != nil {
|
||||
return "", fmt.Errorf("failed to derive encryption key: %w", err)
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create cipher: %w", err)
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create GCM: %w", err)
|
||||
}
|
||||
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return "", fmt.Errorf("failed to generate nonce: %w", err)
|
||||
}
|
||||
|
||||
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
|
||||
// Encode as base64 for storage
|
||||
return base64.StdEncoding.EncodeToString(ciphertext), nil
|
||||
}
|
||||
|
||||
// decryptPrivateKey decrypts an encrypted private key PEM string
|
||||
func (oidc *OIDCService) decryptPrivateKey(encrypted string) (string, error) {
|
||||
if len(oidc.masterKey) == 0 {
|
||||
// No encryption key set, assume plaintext
|
||||
return encrypted, nil
|
||||
}
|
||||
|
||||
// Try to decode as base64 (encrypted) first
|
||||
ciphertext, err := base64.StdEncoding.DecodeString(encrypted)
|
||||
if err != nil {
|
||||
// Not base64, assume it's plaintext (backward compatibility)
|
||||
return encrypted, nil
|
||||
}
|
||||
|
||||
// Derive AES-256 key from master key using HKDF
|
||||
hkdfReader := hkdf.New(sha256.New, oidc.masterKey, nil, []byte("oidc-aes-256-key-v1"))
|
||||
key := make([]byte, 32) // AES-256 requires 32 bytes
|
||||
if _, err := io.ReadFull(hkdfReader, key); err != nil {
|
||||
return "", fmt.Errorf("failed to derive decryption key: %w", err)
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create cipher: %w", err)
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create GCM: %w", err)
|
||||
}
|
||||
|
||||
nonceSize := gcm.NonceSize()
|
||||
if len(ciphertext) < nonceSize {
|
||||
// Too short to be encrypted, assume plaintext
|
||||
return encrypted, nil
|
||||
}
|
||||
|
||||
nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
|
||||
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to decrypt private key: %w", err)
|
||||
}
|
||||
|
||||
return string(plaintext), nil
|
||||
}
|
||||
|
||||
func (oidc *OIDCService) Init() error {
|
||||
// Load master key from environment (optional)
|
||||
masterKeyEnv := os.Getenv("OIDC_RSA_MASTER_KEY")
|
||||
if masterKeyEnv != "" {
|
||||
oidc.masterKey = []byte(masterKeyEnv)
|
||||
if len(oidc.masterKey) < 32 {
|
||||
log.Warn().Msg("OIDC_RSA_MASTER_KEY is shorter than 32 bytes, consider using a longer key for better security")
|
||||
}
|
||||
log.Info().Msg("RSA private key encryption enabled (using OIDC_RSA_MASTER_KEY)")
|
||||
} else {
|
||||
log.Info().Msg("RSA private key encryption disabled (OIDC_RSA_MASTER_KEY not set)")
|
||||
}
|
||||
// Check if multiple keys exist (for warning)
|
||||
var keyCount int64
|
||||
if err := oidc.config.Database.Model(&model.OIDCKey{}).Count(&keyCount).Error; err != nil {
|
||||
return fmt.Errorf("failed to count RSA keys: %w", err)
|
||||
}
|
||||
if keyCount > 1 {
|
||||
log.Warn().Int64("count", keyCount).Msg("Multiple RSA keys detected in database, loading most recently created key. Consider cleaning up older keys.")
|
||||
}
|
||||
|
||||
// Try to load existing key from database (most recently created)
|
||||
var keyRecord model.OIDCKey
|
||||
err := oidc.config.Database.Order("created_at DESC").First(&keyRecord).Error
|
||||
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return fmt.Errorf("failed to query for existing RSA key: %w", err)
|
||||
}
|
||||
|
||||
var privateKey *rsa.PrivateKey
|
||||
|
||||
if err == nil && keyRecord.PrivateKey != "" {
|
||||
// Decrypt private key if encrypted
|
||||
privateKeyPEM, err := oidc.decryptPrivateKey(keyRecord.PrivateKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decrypt private key: %w", err)
|
||||
}
|
||||
|
||||
// Load existing key
|
||||
block, _ := pem.Decode([]byte(privateKeyPEM))
|
||||
if block == nil {
|
||||
return fmt.Errorf("failed to decode PEM block from stored key")
|
||||
}
|
||||
|
||||
parsedKey, err := x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
// Try PKCS8 format as fallback
|
||||
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse stored private key: %w", err)
|
||||
}
|
||||
var ok bool
|
||||
privateKey, ok = key.(*rsa.PrivateKey)
|
||||
if !ok {
|
||||
return fmt.Errorf("stored key is not an RSA private key")
|
||||
}
|
||||
} else {
|
||||
privateKey = parsedKey
|
||||
}
|
||||
|
||||
oidc.privateKey = privateKey
|
||||
oidc.publicKey = &privateKey.PublicKey
|
||||
|
||||
log.Info().Msg("OIDC service initialized with existing RSA key pair from database")
|
||||
return nil
|
||||
}
|
||||
|
||||
// No existing key found, generate new one
|
||||
privateKey, err = rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate RSA key: %w", err)
|
||||
}
|
||||
|
||||
// Encode private key to PEM format
|
||||
privateKeyBytes := x509.MarshalPKCS1PrivateKey(privateKey)
|
||||
privateKeyPEM := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "RSA PRIVATE KEY",
|
||||
Bytes: privateKeyBytes,
|
||||
})
|
||||
|
||||
// Encrypt private key before storing
|
||||
encryptedPrivateKey, err := oidc.encryptPrivateKey(string(privateKeyPEM))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt private key: %w", err)
|
||||
}
|
||||
|
||||
// Save to database
|
||||
now := time.Now().Unix()
|
||||
keyRecord = model.OIDCKey{
|
||||
PrivateKey: encryptedPrivateKey,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
if err := oidc.config.Database.Create(&keyRecord).Error; err != nil {
|
||||
return fmt.Errorf("failed to save RSA key to database: %w", err)
|
||||
}
|
||||
|
||||
oidc.privateKey = privateKey
|
||||
oidc.publicKey = &privateKey.PublicKey
|
||||
|
||||
log.Info().Msg("OIDC service initialized with new RSA key pair (saved to database)")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (oidc *OIDCService) GetClient(clientID string) (*model.OIDCClient, error) {
|
||||
var client model.OIDCClient
|
||||
err := oidc.config.Database.Where("client_id = ?", clientID).First(&client).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("client not found")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &client, nil
|
||||
}
|
||||
|
||||
func (oidc *OIDCService) VerifyClientSecret(client *model.OIDCClient, secret string) bool {
|
||||
// Use bcrypt for constant-time comparison to prevent timing attacks
|
||||
err := bcrypt.CompareHashAndPassword([]byte(client.ClientSecret), []byte(secret))
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Str("client_id", client.ClientID).Msg("Client secret verification failed")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (oidc *OIDCService) ValidateRedirectURI(client *model.OIDCClient, redirectURI string) bool {
|
||||
var redirectURIs []string
|
||||
if err := json.Unmarshal([]byte(client.RedirectURIs), &redirectURIs); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to unmarshal redirect URIs")
|
||||
return false
|
||||
}
|
||||
|
||||
for _, uri := range redirectURIs {
|
||||
if uri == redirectURI {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (oidc *OIDCService) ValidateGrantType(client *model.OIDCClient, grantType string) bool {
|
||||
var grantTypes []string
|
||||
if err := json.Unmarshal([]byte(client.GrantTypes), &grantTypes); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to unmarshal grant types")
|
||||
return false
|
||||
}
|
||||
|
||||
for _, gt := range grantTypes {
|
||||
if gt == grantType {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (oidc *OIDCService) ValidateResponseType(client *model.OIDCClient, responseType string) bool {
|
||||
var responseTypes []string
|
||||
if err := json.Unmarshal([]byte(client.ResponseTypes), &responseTypes); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to unmarshal response types")
|
||||
return false
|
||||
}
|
||||
|
||||
for _, rt := range responseTypes {
|
||||
if rt == responseType {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (oidc *OIDCService) ValidateScope(client *model.OIDCClient, requestedScopes string) ([]string, error) {
|
||||
var allowedScopes []string
|
||||
if err := json.Unmarshal([]byte(client.Scopes), &allowedScopes); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal scopes: %w", err)
|
||||
}
|
||||
|
||||
requestedScopesList := []string{}
|
||||
if requestedScopes != "" {
|
||||
requestedScopesList = splitScopes(requestedScopes)
|
||||
}
|
||||
|
||||
validScopes := []string{}
|
||||
for _, scope := range requestedScopesList {
|
||||
for _, allowed := range allowedScopes {
|
||||
if scope == allowed {
|
||||
validScopes = append(validScopes, scope)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return validScopes, nil
|
||||
}
|
||||
|
||||
func (oidc *OIDCService) GenerateAuthorizationCode(userContext *config.UserContext, clientID string, redirectURI string, scopes []string, nonce string, codeChallenge string, codeChallengeMethod string) (string, error) {
|
||||
code := uuid.New().String()
|
||||
now := time.Now()
|
||||
expiresAt := now.Add(10 * time.Minute).Unix()
|
||||
|
||||
// Store authorization code in database for replay protection
|
||||
authCodeRecord := model.OIDCAuthorizationCode{
|
||||
Code: code,
|
||||
ClientID: clientID,
|
||||
RedirectURI: redirectURI,
|
||||
Used: false,
|
||||
ExpiresAt: expiresAt,
|
||||
CreatedAt: now.Unix(),
|
||||
}
|
||||
|
||||
if err := oidc.config.Database.Create(&authCodeRecord).Error; err != nil {
|
||||
return "", fmt.Errorf("failed to store authorization code: %w", err)
|
||||
}
|
||||
|
||||
// Encode as JWT for stateless operation (but code is tracked in DB)
|
||||
claims := jwt.MapClaims{
|
||||
"code": code,
|
||||
"username": userContext.Username,
|
||||
"email": userContext.Email,
|
||||
"name": userContext.Name,
|
||||
"provider": userContext.Provider,
|
||||
"client_id": clientID,
|
||||
"redirect_uri": redirectURI,
|
||||
"scopes": scopes,
|
||||
"exp": expiresAt,
|
||||
"iat": now.Unix(),
|
||||
}
|
||||
|
||||
if nonce != "" {
|
||||
claims["nonce"] = nonce
|
||||
}
|
||||
|
||||
// Store PKCE challenge if provided
|
||||
if codeChallenge != "" {
|
||||
claims["code_challenge"] = codeChallenge
|
||||
if codeChallengeMethod != "" {
|
||||
claims["code_challenge_method"] = codeChallengeMethod
|
||||
} else {
|
||||
// Default to plain if method not specified
|
||||
claims["code_challenge_method"] = "plain"
|
||||
}
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||
codeToken, err := token.SignedString(oidc.privateKey)
|
||||
if err != nil {
|
||||
// Clean up the database record if JWT signing fails
|
||||
oidc.config.Database.Delete(&authCodeRecord)
|
||||
return "", fmt.Errorf("failed to sign authorization code: %w", err)
|
||||
}
|
||||
|
||||
return codeToken, nil
|
||||
}
|
||||
|
||||
func (oidc *OIDCService) ValidateAuthorizationCode(codeToken string, clientID string, redirectURI string) (*config.UserContext, []string, string, string, string, error) {
|
||||
token, err := jwt.Parse(codeToken, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
return oidc.publicKey, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, "", "", "", fmt.Errorf("failed to parse authorization code: %w", err)
|
||||
}
|
||||
|
||||
if !token.Valid {
|
||||
return nil, nil, "", "", "", errors.New("invalid authorization code")
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
return nil, nil, "", "", "", errors.New("invalid token claims")
|
||||
}
|
||||
|
||||
// Extract code from JWT for database lookup
|
||||
code, ok := claims["code"].(string)
|
||||
if !ok || code == "" {
|
||||
return nil, nil, "", "", "", errors.New("missing code in authorization code token")
|
||||
}
|
||||
|
||||
// Check database for replay protection - verify code exists and hasn't been used
|
||||
var authCodeRecord model.OIDCAuthorizationCode
|
||||
err = oidc.config.Database.Where("code = ?", code).First(&authCodeRecord).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil, "", "", "", errors.New("authorization code not found")
|
||||
}
|
||||
return nil, nil, "", "", "", fmt.Errorf("failed to query authorization code: %w", err)
|
||||
}
|
||||
|
||||
// Check if code has already been used (replay attack protection)
|
||||
if authCodeRecord.Used {
|
||||
return nil, nil, "", "", "", errors.New("authorization code has already been used")
|
||||
}
|
||||
|
||||
// Check expiration
|
||||
if time.Now().Unix() > authCodeRecord.ExpiresAt {
|
||||
return nil, nil, "", "", "", errors.New("authorization code expired")
|
||||
}
|
||||
|
||||
// Verify client_id and redirect_uri match
|
||||
if claims["client_id"] != clientID {
|
||||
return nil, nil, "", "", "", errors.New("client_id mismatch")
|
||||
}
|
||||
|
||||
if claims["redirect_uri"] != redirectURI {
|
||||
return nil, nil, "", "", "", errors.New("redirect_uri mismatch")
|
||||
}
|
||||
|
||||
// Verify database record matches request parameters
|
||||
if authCodeRecord.ClientID != clientID {
|
||||
return nil, nil, "", "", "", errors.New("client_id mismatch")
|
||||
}
|
||||
|
||||
if authCodeRecord.RedirectURI != redirectURI {
|
||||
return nil, nil, "", "", "", errors.New("redirect_uri mismatch")
|
||||
}
|
||||
|
||||
// Mark code as used to prevent replay attacks
|
||||
authCodeRecord.Used = true
|
||||
if err := oidc.config.Database.Save(&authCodeRecord).Error; err != nil {
|
||||
return nil, nil, "", "", "", fmt.Errorf("failed to mark authorization code as used: %w", err)
|
||||
}
|
||||
|
||||
userContext := &config.UserContext{
|
||||
Username: getStringClaim(claims, "username"),
|
||||
Email: getStringClaim(claims, "email"),
|
||||
Name: getStringClaim(claims, "name"),
|
||||
Provider: getStringClaim(claims, "provider"),
|
||||
IsLoggedIn: true,
|
||||
}
|
||||
|
||||
scopes := []string{}
|
||||
if scopesInterface, ok := claims["scopes"].([]interface{}); ok {
|
||||
for _, s := range scopesInterface {
|
||||
if scope, ok := s.(string); ok {
|
||||
scopes = append(scopes, scope)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nonce := getStringClaim(claims, "nonce")
|
||||
codeChallenge := getStringClaim(claims, "code_challenge")
|
||||
codeChallengeMethod := getStringClaim(claims, "code_challenge_method")
|
||||
|
||||
return userContext, scopes, nonce, codeChallenge, codeChallengeMethod, nil
|
||||
}
|
||||
|
||||
func (oidc *OIDCService) ValidatePKCE(codeChallenge string, codeChallengeMethod string, codeVerifier string) error {
|
||||
if codeChallenge == "" {
|
||||
// PKCE not used, validation passes
|
||||
return nil
|
||||
}
|
||||
|
||||
if codeVerifier == "" {
|
||||
return errors.New("code_verifier required when code_challenge is present")
|
||||
}
|
||||
|
||||
switch codeChallengeMethod {
|
||||
case "S256":
|
||||
// Compute SHA256 hash of code_verifier
|
||||
hash := sha256.Sum256([]byte(codeVerifier))
|
||||
// Base64URL encode (without padding)
|
||||
computedChallenge := base64.RawURLEncoding.EncodeToString(hash[:])
|
||||
if computedChallenge != codeChallenge {
|
||||
return errors.New("code_verifier does not match code_challenge")
|
||||
}
|
||||
case "plain":
|
||||
// Direct comparison
|
||||
if codeVerifier != codeChallenge {
|
||||
return errors.New("code_verifier does not match code_challenge")
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unsupported code_challenge_method: %s", codeChallengeMethod)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (oidc *OIDCService) GenerateAccessToken(userContext *config.UserContext, clientID string, scopes []string) (string, error) {
|
||||
expiry := oidc.config.AccessTokenExpiry
|
||||
if expiry <= 0 {
|
||||
expiry = 3600 // Default 1 hour
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
claims := jwt.MapClaims{
|
||||
"sub": userContext.Username,
|
||||
"iss": oidc.config.Issuer,
|
||||
"aud": clientID,
|
||||
"exp": now.Add(time.Duration(expiry) * time.Second).Unix(),
|
||||
"iat": now.Unix(),
|
||||
"scope": joinScopes(scopes),
|
||||
"client_id": clientID,
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||
accessToken, err := token.SignedString(oidc.privateKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to sign access token: %w", err)
|
||||
}
|
||||
|
||||
return accessToken, nil
|
||||
}
|
||||
|
||||
func (oidc *OIDCService) ValidateAccessToken(accessToken string) (*config.UserContext, error) {
|
||||
return oidc.ValidateAccessTokenForClient(accessToken, "")
|
||||
}
|
||||
|
||||
// ValidateAccessTokenForClient validates an access token and optionally checks the audience claim.
|
||||
// If expectedClientID is provided, validates that the token's audience matches the expected client ID.
|
||||
// This prevents tokens issued for one client from being used by another client.
|
||||
func (oidc *OIDCService) ValidateAccessTokenForClient(accessToken string, expectedClientID string) (*config.UserContext, error) {
|
||||
token, err := jwt.Parse(accessToken, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
return oidc.publicKey, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse access token: %w", err)
|
||||
}
|
||||
|
||||
if !token.Valid {
|
||||
return nil, errors.New("invalid access token")
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
return nil, errors.New("invalid token claims")
|
||||
}
|
||||
|
||||
// Verify issuer
|
||||
iss, ok := claims["iss"].(string)
|
||||
if !ok || iss != oidc.config.Issuer {
|
||||
return nil, errors.New("invalid issuer")
|
||||
}
|
||||
|
||||
// Verify audience if expected client ID is provided
|
||||
if expectedClientID != "" {
|
||||
aud, ok := claims["aud"].(string)
|
||||
if !ok || aud != expectedClientID {
|
||||
return nil, errors.New("invalid audience")
|
||||
}
|
||||
}
|
||||
|
||||
// Check expiration
|
||||
exp, ok := claims["exp"].(float64)
|
||||
if !ok || time.Now().Unix() > int64(exp) {
|
||||
return nil, errors.New("access token expired")
|
||||
}
|
||||
|
||||
// Extract user info from claims
|
||||
username, ok := claims["sub"].(string)
|
||||
if !ok || username == "" {
|
||||
return nil, errors.New("missing sub claim")
|
||||
}
|
||||
|
||||
// Extract email and name if available
|
||||
email, _ := claims["email"].(string)
|
||||
name, _ := claims["name"].(string)
|
||||
|
||||
// Create user context
|
||||
userContext := &config.UserContext{
|
||||
Username: username,
|
||||
Email: email,
|
||||
Name: name,
|
||||
IsLoggedIn: true,
|
||||
}
|
||||
|
||||
return userContext, nil
|
||||
}
|
||||
|
||||
func (oidc *OIDCService) GenerateIDToken(userContext *config.UserContext, clientID string, nonce string) (string, error) {
|
||||
expiry := oidc.config.IDTokenExpiry
|
||||
if expiry <= 0 {
|
||||
expiry = 3600 // Default 1 hour
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
claims := jwt.MapClaims{
|
||||
"sub": userContext.Username,
|
||||
"iss": oidc.config.Issuer,
|
||||
"aud": clientID,
|
||||
"exp": now.Add(time.Duration(expiry) * time.Second).Unix(),
|
||||
"iat": now.Unix(),
|
||||
"auth_time": now.Unix(),
|
||||
"email": userContext.Email,
|
||||
"name": userContext.Name,
|
||||
"preferred_username": userContext.Username,
|
||||
}
|
||||
|
||||
if nonce != "" {
|
||||
claims["nonce"] = nonce
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||
idToken, err := token.SignedString(oidc.privateKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to sign ID token: %w", err)
|
||||
}
|
||||
|
||||
return idToken, nil
|
||||
}
|
||||
|
||||
func (oidc *OIDCService) GetJWKS() (map[string]interface{}, error) {
|
||||
// Extract modulus and exponent from public key
|
||||
n := oidc.publicKey.N
|
||||
e := oidc.publicKey.E
|
||||
|
||||
nBytes := n.Bytes()
|
||||
// Use minimal-octet encoding for exponent per RFC 7517
|
||||
eBytes := big.NewInt(int64(e)).Bytes()
|
||||
|
||||
jwk := map[string]interface{}{
|
||||
"kty": "RSA",
|
||||
"use": "sig",
|
||||
"kid": "default",
|
||||
"n": base64.RawURLEncoding.EncodeToString(nBytes),
|
||||
"e": base64.RawURLEncoding.EncodeToString(eBytes),
|
||||
"alg": "RS256",
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"keys": []interface{}{jwk},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (oidc *OIDCService) GetIssuer() string {
|
||||
return oidc.config.Issuer
|
||||
}
|
||||
|
||||
func (oidc *OIDCService) GetAccessTokenExpiry() int {
|
||||
if oidc.config.AccessTokenExpiry <= 0 {
|
||||
return 3600 // Default 1 hour
|
||||
}
|
||||
return oidc.config.AccessTokenExpiry
|
||||
}
|
||||
|
||||
func (oidc *OIDCService) SyncClientsFromConfig(clients map[string]config.OIDCClientConfig) error {
|
||||
for clientID, clientConfig := range clients {
|
||||
// Get client secret from config or file (similar to OAuth providers)
|
||||
clientSecret := utils.GetSecret(clientConfig.ClientSecret, clientConfig.ClientSecretFile)
|
||||
|
||||
if clientSecret == "" {
|
||||
log.Warn().Str("client_id", clientID).Msg("Client secret is empty, skipping client")
|
||||
continue
|
||||
}
|
||||
|
||||
// Set defaults
|
||||
clientName := clientConfig.ClientName
|
||||
if clientName == "" {
|
||||
clientName = clientID
|
||||
}
|
||||
|
||||
redirectURIs := clientConfig.RedirectURIs
|
||||
if len(redirectURIs) == 0 {
|
||||
log.Warn().Str("client_id", clientID).Msg("No redirect URIs configured for client")
|
||||
continue
|
||||
}
|
||||
|
||||
grantTypes := clientConfig.GrantTypes
|
||||
if len(grantTypes) == 0 {
|
||||
grantTypes = []string{"authorization_code"}
|
||||
}
|
||||
|
||||
responseTypes := clientConfig.ResponseTypes
|
||||
if len(responseTypes) == 0 {
|
||||
responseTypes = []string{"code"}
|
||||
}
|
||||
|
||||
scopes := clientConfig.Scopes
|
||||
if len(scopes) == 0 {
|
||||
scopes = []string{"openid", "profile", "email"}
|
||||
}
|
||||
|
||||
// Serialize arrays to JSON
|
||||
redirectURIsJSON, err := json.Marshal(redirectURIs)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("client_id", clientID).Msg("Failed to marshal redirect URIs")
|
||||
continue
|
||||
}
|
||||
|
||||
grantTypesJSON, err := json.Marshal(grantTypes)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("client_id", clientID).Msg("Failed to marshal grant types")
|
||||
continue
|
||||
}
|
||||
|
||||
responseTypesJSON, err := json.Marshal(responseTypes)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("client_id", clientID).Msg("Failed to marshal response types")
|
||||
continue
|
||||
}
|
||||
|
||||
scopesJSON, err := json.Marshal(scopes)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("client_id", clientID).Msg("Failed to marshal scopes")
|
||||
continue
|
||||
}
|
||||
|
||||
// Hash client secret with bcrypt before storing
|
||||
hashedSecret, err := bcrypt.GenerateFromPassword([]byte(clientSecret), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("client_id", clientID).Msg("Failed to hash client secret")
|
||||
continue
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
|
||||
// Check if client exists
|
||||
var existingClient model.OIDCClient
|
||||
err = oidc.config.Database.Where("client_id = ?", clientID).First(&existingClient).Error
|
||||
|
||||
client := model.OIDCClient{
|
||||
ClientID: clientID,
|
||||
ClientSecret: string(hashedSecret),
|
||||
ClientName: clientName,
|
||||
RedirectURIs: string(redirectURIsJSON),
|
||||
GrantTypes: string(grantTypesJSON),
|
||||
ResponseTypes: string(responseTypesJSON),
|
||||
Scopes: string(scopesJSON),
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
// Create new client
|
||||
client.CreatedAt = now
|
||||
if err := oidc.config.Database.Create(&client).Error; err != nil {
|
||||
log.Error().Err(err).Str("client_id", clientID).Msg("Failed to create OIDC client")
|
||||
continue
|
||||
}
|
||||
log.Info().Str("client_id", clientID).Str("client_name", clientName).Msg("Created OIDC client from config")
|
||||
} else if err == nil {
|
||||
// Update existing client
|
||||
client.CreatedAt = existingClient.CreatedAt // Preserve original creation time
|
||||
if err := oidc.config.Database.Where("client_id = ?", clientID).Updates(&client).Error; err != nil {
|
||||
log.Error().Err(err).Str("client_id", clientID).Msg("Failed to update OIDC client")
|
||||
continue
|
||||
}
|
||||
log.Info().Str("client_id", clientID).Str("client_name", clientName).Msg("Updated OIDC client from config")
|
||||
} else {
|
||||
log.Error().Err(err).Str("client_id", clientID).Msg("Failed to check existing OIDC client")
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func splitScopes(scopes string) []string {
|
||||
if scopes == "" {
|
||||
return []string{}
|
||||
}
|
||||
parts := strings.Split(scopes, " ")
|
||||
result := []string{}
|
||||
for _, part := range parts {
|
||||
trimmed := strings.TrimSpace(part)
|
||||
if trimmed != "" {
|
||||
result = append(result, trimmed)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func joinScopes(scopes []string) string {
|
||||
return strings.Join(scopes, " ")
|
||||
}
|
||||
|
||||
func contains(slice []string, item string) bool {
|
||||
for _, s := range slice {
|
||||
if s == item {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func getStringClaim(claims jwt.MapClaims, key string) string {
|
||||
if val, ok := claims[key].(string); ok {
|
||||
return val
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -2,16 +2,14 @@ package utils
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
"tinyauth/internal/config"
|
||||
"tinyauth/internal/utils/decoders"
|
||||
|
||||
"maps"
|
||||
"github.com/steveiliop56/tinyauth/internal/config"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/weppos/publicsuffix-go/publicsuffix"
|
||||
)
|
||||
|
||||
@@ -25,13 +23,13 @@ func GetCookieDomain(u string) (string, error) {
|
||||
host := parsed.Hostname()
|
||||
|
||||
if netIP := net.ParseIP(host); netIP != nil {
|
||||
return "", errors.New("IP addresses not allowed")
|
||||
return "", fmt.Errorf("IP addresses not allowed for app url '%s' (got IP: %s)", u, host)
|
||||
}
|
||||
|
||||
parts := strings.Split(host, ".")
|
||||
|
||||
if len(parts) < 3 {
|
||||
return "", errors.New("invalid app url, must be at least second level domain")
|
||||
return "", fmt.Errorf("invalid app url '%s', must be at least second level domain (got %d parts, need 3+)", u, len(parts))
|
||||
}
|
||||
|
||||
domain := strings.Join(parts[1:], ".")
|
||||
@@ -39,7 +37,7 @@ func GetCookieDomain(u string) (string, error) {
|
||||
_, err = publicsuffix.DomainFromListWithOptions(publicsuffix.DefaultList, domain, nil)
|
||||
|
||||
if err != nil {
|
||||
return "", errors.New("domain in public suffix list, cannot set cookies")
|
||||
return "", fmt.Errorf("domain '%s' (from app url '%s') is in public suffix list, cannot set cookies", domain, u)
|
||||
}
|
||||
|
||||
return domain, nil
|
||||
@@ -104,119 +102,3 @@ func IsRedirectSafe(redirectURL string, domain string) bool {
|
||||
|
||||
return hostname == domain
|
||||
}
|
||||
|
||||
func GetLogLevel(level string) zerolog.Level {
|
||||
switch strings.ToLower(level) {
|
||||
case "trace":
|
||||
return zerolog.TraceLevel
|
||||
case "debug":
|
||||
return zerolog.DebugLevel
|
||||
case "info":
|
||||
return zerolog.InfoLevel
|
||||
case "warn":
|
||||
return zerolog.WarnLevel
|
||||
case "error":
|
||||
return zerolog.ErrorLevel
|
||||
case "fatal":
|
||||
return zerolog.FatalLevel
|
||||
case "panic":
|
||||
return zerolog.PanicLevel
|
||||
default:
|
||||
return zerolog.InfoLevel
|
||||
}
|
||||
}
|
||||
|
||||
func GetOAuthProvidersConfig(env []string, args []string, appUrl string) (map[string]config.OAuthServiceConfig, error) {
|
||||
providers := make(map[string]config.OAuthServiceConfig)
|
||||
|
||||
// Get from environment variables
|
||||
envMap := make(map[string]string)
|
||||
|
||||
for _, e := range env {
|
||||
pair := strings.SplitN(e, "=", 2)
|
||||
if len(pair) == 2 {
|
||||
envMap[pair[0]] = pair[1]
|
||||
}
|
||||
}
|
||||
|
||||
envProviders, err := decoders.DecodeEnv[config.Providers, config.OAuthServiceConfig](envMap, "providers")
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
maps.Copy(providers, envProviders.Providers)
|
||||
|
||||
// Get from flags
|
||||
flagsMap := make(map[string]string)
|
||||
|
||||
for _, arg := range args[1:] {
|
||||
if strings.HasPrefix(arg, "--") {
|
||||
pair := strings.SplitN(arg[2:], "=", 2)
|
||||
if len(pair) == 2 {
|
||||
flagsMap[pair[0]] = pair[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flagProviders, err := decoders.DecodeFlags[config.Providers, config.OAuthServiceConfig](flagsMap, "providers")
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
maps.Copy(providers, flagProviders.Providers)
|
||||
|
||||
// For every provider get correct secret from file if set
|
||||
for name, provider := range providers {
|
||||
secret := GetSecret(provider.ClientSecret, provider.ClientSecretFile)
|
||||
provider.ClientSecret = secret
|
||||
provider.ClientSecretFile = ""
|
||||
providers[name] = provider
|
||||
}
|
||||
|
||||
// If we have google/github providers and no redirect URL then set a default
|
||||
for id := range config.OverrideProviders {
|
||||
if provider, exists := providers[id]; exists {
|
||||
if provider.RedirectURL == "" {
|
||||
provider.RedirectURL = appUrl + "/api/oauth/callback/" + id
|
||||
providers[id] = provider
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set names
|
||||
for id, provider := range providers {
|
||||
if provider.Name == "" {
|
||||
if name, ok := config.OverrideProviders[id]; ok {
|
||||
provider.Name = name
|
||||
} else {
|
||||
provider.Name = Capitalize(id)
|
||||
}
|
||||
}
|
||||
providers[id] = provider
|
||||
}
|
||||
|
||||
// Return combined providers
|
||||
return providers, nil
|
||||
}
|
||||
|
||||
func ShoudLogJSON(environ []string, args []string) bool {
|
||||
for _, e := range environ {
|
||||
pair := strings.SplitN(e, "=", 2)
|
||||
if len(pair) == 2 && pair[0] == "LOG_JSON" && strings.ToLower(pair[1]) == "true" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
for _, arg := range args[1:] {
|
||||
if strings.HasPrefix(arg, "--log-json=") {
|
||||
value := strings.SplitN(arg, "=", 2)[1]
|
||||
if strings.ToLower(value) == "true" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
package utils_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"tinyauth/internal/config"
|
||||
"tinyauth/internal/utils"
|
||||
|
||||
"github.com/steveiliop56/tinyauth/internal/config"
|
||||
"github.com/steveiliop56/tinyauth/internal/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gotest.tools/v3/assert"
|
||||
@@ -206,93 +206,3 @@ func TestIsRedirectSafe(t *testing.T) {
|
||||
result = utils.IsRedirectSafe(redirectURL, domain)
|
||||
assert.Equal(t, false, result)
|
||||
}
|
||||
|
||||
func TestGetOAuthProvidersConfig(t *testing.T) {
|
||||
env := []string{"PROVIDERS_CLIENT1_CLIENT_ID=client1-id", "PROVIDERS_CLIENT1_CLIENT_SECRET=client1-secret"}
|
||||
args := []string{"/tinyauth/tinyauth", "--providers-client2-client-id=client2-id", "--providers-client2-client-secret=client2-secret"}
|
||||
|
||||
expected := map[string]config.OAuthServiceConfig{
|
||||
"client1": {
|
||||
ClientID: "client1-id",
|
||||
ClientSecret: "client1-secret",
|
||||
Name: "Client1",
|
||||
},
|
||||
"client2": {
|
||||
ClientID: "client2-id",
|
||||
ClientSecret: "client2-secret",
|
||||
Name: "Client2",
|
||||
},
|
||||
}
|
||||
|
||||
result, err := utils.GetOAuthProvidersConfig(env, args, "")
|
||||
assert.NilError(t, err)
|
||||
assert.DeepEqual(t, expected, result)
|
||||
|
||||
// Case with no providers
|
||||
env = []string{}
|
||||
args = []string{"/tinyauth/tinyauth"}
|
||||
expected = map[string]config.OAuthServiceConfig{}
|
||||
|
||||
result, err = utils.GetOAuthProvidersConfig(env, args, "")
|
||||
assert.NilError(t, err)
|
||||
assert.DeepEqual(t, expected, result)
|
||||
|
||||
// Case with secret from file
|
||||
file, err := os.Create("/tmp/tinyauth_test_file")
|
||||
assert.NilError(t, err)
|
||||
|
||||
_, err = file.WriteString("file content\n")
|
||||
assert.NilError(t, err)
|
||||
|
||||
err = file.Close()
|
||||
assert.NilError(t, err)
|
||||
defer os.Remove("/tmp/tinyauth_test_file")
|
||||
|
||||
env = []string{"PROVIDERS_CLIENT1_CLIENT_ID=client1-id", "PROVIDERS_CLIENT1_CLIENT_SECRET_FILE=/tmp/tinyauth_test_file"}
|
||||
args = []string{"/tinyauth/tinyauth"}
|
||||
expected = map[string]config.OAuthServiceConfig{
|
||||
"client1": {
|
||||
ClientID: "client1-id",
|
||||
ClientSecret: "file content",
|
||||
Name: "Client1",
|
||||
},
|
||||
}
|
||||
|
||||
result, err = utils.GetOAuthProvidersConfig(env, args, "")
|
||||
assert.NilError(t, err)
|
||||
assert.DeepEqual(t, expected, result)
|
||||
|
||||
// Case with google provider and no redirect URL
|
||||
env = []string{"PROVIDERS_GOOGLE_CLIENT_ID=google-id", "PROVIDERS_GOOGLE_CLIENT_SECRET=google-secret"}
|
||||
args = []string{"/tinyauth/tinyauth"}
|
||||
expected = map[string]config.OAuthServiceConfig{
|
||||
"google": {
|
||||
ClientID: "google-id",
|
||||
ClientSecret: "google-secret",
|
||||
RedirectURL: "http://app.url/api/oauth/callback/google",
|
||||
Name: "Google",
|
||||
},
|
||||
}
|
||||
|
||||
result, err = utils.GetOAuthProvidersConfig(env, args, "http://app.url")
|
||||
assert.NilError(t, err)
|
||||
assert.DeepEqual(t, expected, result)
|
||||
}
|
||||
|
||||
func TestShoudLogJSON(t *testing.T) {
|
||||
// Test with no env or args
|
||||
result := utils.ShoudLogJSON([]string{"FOO=bar"}, []string{"tinyauth", "--foo-bar=baz"})
|
||||
assert.Equal(t, false, result)
|
||||
|
||||
// Test with env variable set
|
||||
result = utils.ShoudLogJSON([]string{"LOG_JSON=true"}, []string{"tinyauth", "--foo-bar=baz"})
|
||||
assert.Equal(t, true, result)
|
||||
|
||||
// Test with flag set
|
||||
result = utils.ShoudLogJSON([]string{"FOO=bar"}, []string{"tinyauth", "--log-json=true"})
|
||||
assert.Equal(t, true, result)
|
||||
|
||||
// Test with both env and flag set to false
|
||||
result = utils.ShoudLogJSON([]string{"LOG_JSON=false"}, []string{"tinyauth", "--log-json=false"})
|
||||
assert.Equal(t, false, result)
|
||||
}
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
package decoders
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/stoewer/go-strcase"
|
||||
)
|
||||
|
||||
func normalizeKeys[T any](input map[string]string, root string, sep string) map[string]string {
|
||||
knownKeys := getKnownKeys[T]()
|
||||
normalized := make(map[string]string)
|
||||
|
||||
for k, v := range input {
|
||||
parts := []string{"tinyauth"}
|
||||
|
||||
key := strings.ToLower(k)
|
||||
key = strings.ReplaceAll(key, sep, "-")
|
||||
|
||||
if !strings.HasPrefix(key, root+"-") {
|
||||
continue
|
||||
}
|
||||
|
||||
suffix := ""
|
||||
|
||||
for _, known := range knownKeys {
|
||||
if strings.HasSuffix(key, known) {
|
||||
suffix = known
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if suffix == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
parts = append(parts, root)
|
||||
|
||||
id := strings.TrimPrefix(key, root+"-")
|
||||
id = strings.TrimSuffix(id, "-"+suffix)
|
||||
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
parts = append(parts, id)
|
||||
parts = append(parts, suffix)
|
||||
|
||||
final := ""
|
||||
|
||||
for i, part := range parts {
|
||||
if i > 0 {
|
||||
final += "."
|
||||
}
|
||||
final += strcase.LowerCamelCase(part)
|
||||
}
|
||||
|
||||
normalized[final] = v
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
func getKnownKeys[T any]() []string {
|
||||
var keys []string
|
||||
var t T
|
||||
|
||||
v := reflect.ValueOf(t)
|
||||
typeOfT := v.Type()
|
||||
|
||||
for field := range typeOfT.NumField() {
|
||||
if typeOfT.Field(field).Tag.Get("field") != "" {
|
||||
keys = append(keys, typeOfT.Field(field).Tag.Get("field"))
|
||||
continue
|
||||
}
|
||||
keys = append(keys, strcase.KebabCase(typeOfT.Field(field).Name))
|
||||
}
|
||||
|
||||
return keys
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
package decoders
|
||||
|
||||
import (
|
||||
"github.com/traefik/paerser/parser"
|
||||
)
|
||||
|
||||
func DecodeEnv[T any, C any](env map[string]string, subName string) (T, error) {
|
||||
var result T
|
||||
|
||||
normalized := normalizeKeys[C](env, subName, "_")
|
||||
|
||||
err := parser.Decode(normalized, &result, "tinyauth", "tinyauth."+subName)
|
||||
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
package decoders_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"tinyauth/internal/config"
|
||||
"tinyauth/internal/utils/decoders"
|
||||
|
||||
"gotest.tools/v3/assert"
|
||||
)
|
||||
|
||||
func TestDecodeEnv(t *testing.T) {
|
||||
// Setup
|
||||
env := map[string]string{
|
||||
"PROVIDERS_GOOGLE_CLIENT_ID": "google-client-id",
|
||||
"PROVIDERS_GOOGLE_CLIENT_SECRET": "google-client-secret",
|
||||
"PROVIDERS_MY_GITHUB_CLIENT_ID": "github-client-id",
|
||||
"PROVIDERS_MY_GITHUB_CLIENT_SECRET": "github-client-secret",
|
||||
}
|
||||
|
||||
expected := config.Providers{
|
||||
Providers: map[string]config.OAuthServiceConfig{
|
||||
"google": {
|
||||
ClientID: "google-client-id",
|
||||
ClientSecret: "google-client-secret",
|
||||
},
|
||||
"myGithub": {
|
||||
ClientID: "github-client-id",
|
||||
ClientSecret: "github-client-secret",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Execute
|
||||
result, err := decoders.DecodeEnv[config.Providers, config.OAuthServiceConfig](env, "providers")
|
||||
assert.NilError(t, err)
|
||||
assert.DeepEqual(t, result, expected)
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
package decoders
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/traefik/paerser/parser"
|
||||
)
|
||||
|
||||
func DecodeFlags[T any, C any](flags map[string]string, subName string) (T, error) {
|
||||
var result T
|
||||
|
||||
filtered := filterFlags(flags)
|
||||
normalized := normalizeKeys[C](filtered, subName, "_")
|
||||
|
||||
err := parser.Decode(normalized, &result, "tinyauth", "tinyauth."+subName)
|
||||
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func filterFlags(flags map[string]string) map[string]string {
|
||||
filtered := make(map[string]string)
|
||||
for k, v := range flags {
|
||||
filtered[strings.TrimPrefix(k, "--")] = v
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
package decoders_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"tinyauth/internal/config"
|
||||
"tinyauth/internal/utils/decoders"
|
||||
|
||||
"gotest.tools/v3/assert"
|
||||
)
|
||||
|
||||
func TestDecodeFlags(t *testing.T) {
|
||||
// Setup
|
||||
flags := map[string]string{
|
||||
"--providers-google-client-id": "google-client-id",
|
||||
"--providers-google-client-secret": "google-client-secret",
|
||||
"--providers-my-github-client-id": "github-client-id",
|
||||
"--providers-my-github-client-secret": "github-client-secret",
|
||||
}
|
||||
|
||||
expected := config.Providers{
|
||||
Providers: map[string]config.OAuthServiceConfig{
|
||||
"google": {
|
||||
ClientID: "google-client-id",
|
||||
ClientSecret: "google-client-secret",
|
||||
},
|
||||
"myGithub": {
|
||||
ClientID: "github-client-id",
|
||||
ClientSecret: "github-client-secret",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Execute
|
||||
result, err := decoders.DecodeFlags[config.Providers, config.OAuthServiceConfig](flags, "providers")
|
||||
assert.NilError(t, err)
|
||||
assert.DeepEqual(t, result, expected)
|
||||
}
|
||||
@@ -1,19 +1,17 @@
|
||||
package decoders
|
||||
|
||||
import (
|
||||
"tinyauth/internal/config"
|
||||
|
||||
"github.com/traefik/paerser/parser"
|
||||
)
|
||||
|
||||
func DecodeLabels(labels map[string]string) (config.Apps, error) {
|
||||
var appLabels config.Apps
|
||||
func DecodeLabels[T any](labels map[string]string, root string) (T, error) {
|
||||
var labelsDecoded T
|
||||
|
||||
err := parser.Decode(labels, &appLabels, "tinyauth", "tinyauth.apps")
|
||||
err := parser.Decode(labels, &labelsDecoded, "tinyauth", "tinyauth."+root)
|
||||
|
||||
if err != nil {
|
||||
return config.Apps{}, err
|
||||
return labelsDecoded, err
|
||||
}
|
||||
|
||||
return appLabels, nil
|
||||
return labelsDecoded, nil
|
||||
}
|
||||
|
||||
@@ -2,8 +2,9 @@ package decoders_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"tinyauth/internal/config"
|
||||
"tinyauth/internal/utils/decoders"
|
||||
|
||||
"github.com/steveiliop56/tinyauth/internal/config"
|
||||
"github.com/steveiliop56/tinyauth/internal/utils/decoders"
|
||||
|
||||
"gotest.tools/v3/assert"
|
||||
)
|
||||
@@ -62,7 +63,7 @@ func TestDecodeLabels(t *testing.T) {
|
||||
}
|
||||
|
||||
// Test
|
||||
result, err := decoders.DecodeLabels(test)
|
||||
result, err := decoders.DecodeLabels[config.Apps](test, "apps")
|
||||
assert.NilError(t, err)
|
||||
assert.DeepEqual(t, expected, result)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ package utils_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"tinyauth/internal/utils"
|
||||
|
||||
"github.com/steveiliop56/tinyauth/internal/utils"
|
||||
|
||||
"gotest.tools/v3/assert"
|
||||
)
|
||||
|
||||
26
internal/utils/loaders/loader_env.go
Normal file
26
internal/utils/loaders/loader_env.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package loaders
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/steveiliop56/tinyauth/internal/config"
|
||||
|
||||
"github.com/traefik/paerser/cli"
|
||||
"github.com/traefik/paerser/env"
|
||||
)
|
||||
|
||||
type EnvLoader struct{}
|
||||
|
||||
func (e *EnvLoader) Load(_ []string, cmd *cli.Command) (bool, error) {
|
||||
vars := env.FindPrefixedEnvVars(os.Environ(), config.DefaultNamePrefix, cmd.Configuration)
|
||||
if len(vars) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if err := env.Decode(vars, config.DefaultNamePrefix, cmd.Configuration); err != nil {
|
||||
return false, fmt.Errorf("failed to decode configuration from environment variables: %w", err)
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
42
internal/utils/loaders/loader_file.go
Normal file
42
internal/utils/loaders/loader_file.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package loaders
|
||||
|
||||
import (
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/traefik/paerser/cli"
|
||||
"github.com/traefik/paerser/file"
|
||||
"github.com/traefik/paerser/flag"
|
||||
)
|
||||
|
||||
type FileLoader struct{}
|
||||
|
||||
func (f *FileLoader) Load(args []string, cmd *cli.Command) (bool, error) {
|
||||
flags, err := flag.Parse(args, cmd.Configuration)
|
||||
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// Check for experimental config file flag (supports both traefik.* and direct format)
|
||||
// Note: paerser converts flags to lowercase, so we check lowercase versions
|
||||
configFilePath := ""
|
||||
if val, ok := flags["traefik.experimental.configfile"]; ok {
|
||||
configFilePath = val
|
||||
} else if val, ok := flags["experimental.configfile"]; ok {
|
||||
configFilePath = val
|
||||
}
|
||||
|
||||
if configFilePath == "" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
log.Warn().Str("configFile", configFilePath).Msg("Using experimental file config loader, this feature is experimental and may change or be removed in future releases")
|
||||
|
||||
err = file.Decode(configFilePath, cmd.Configuration)
|
||||
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("configFile", configFilePath).Msg("Failed to decode config file")
|
||||
return false, err
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
22
internal/utils/loaders/loader_flag.go
Normal file
22
internal/utils/loaders/loader_flag.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package loaders
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/traefik/paerser/cli"
|
||||
"github.com/traefik/paerser/flag"
|
||||
)
|
||||
|
||||
type FlagLoader struct{}
|
||||
|
||||
func (*FlagLoader) Load(args []string, cmd *cli.Command) (bool, error) {
|
||||
if len(args) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if err := flag.Decode(args, cmd.Configuration); err != nil {
|
||||
return false, fmt.Errorf("failed to decode configuration from flags: %w", err)
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
@@ -3,7 +3,8 @@ package utils_test
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"tinyauth/internal/utils"
|
||||
|
||||
"github.com/steveiliop56/tinyauth/internal/utils"
|
||||
|
||||
"gotest.tools/v3/assert"
|
||||
)
|
||||
|
||||
@@ -2,7 +2,8 @@ package utils_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"tinyauth/internal/utils"
|
||||
|
||||
"github.com/steveiliop56/tinyauth/internal/utils"
|
||||
|
||||
"gotest.tools/v3/assert"
|
||||
)
|
||||
|
||||
@@ -3,7 +3,8 @@ package utils
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"tinyauth/internal/config"
|
||||
|
||||
"github.com/steveiliop56/tinyauth/internal/config"
|
||||
)
|
||||
|
||||
func ParseUsers(users string) ([]config.User, error) {
|
||||
|
||||
@@ -3,7 +3,8 @@ package utils_test
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"tinyauth/internal/utils"
|
||||
|
||||
"github.com/steveiliop56/tinyauth/internal/utils"
|
||||
|
||||
"gotest.tools/v3/assert"
|
||||
)
|
||||
|
||||
19
main.go
19
main.go
@@ -1,19 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"time"
|
||||
"tinyauth/cmd"
|
||||
"tinyauth/internal/utils"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
func main() {
|
||||
log.Logger = log.Logger.With().Caller().Logger()
|
||||
if !utils.ShoudLogJSON(os.Environ(), os.Args) {
|
||||
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339})
|
||||
}
|
||||
cmd.Run()
|
||||
}
|
||||
14
validation/Dockerfile
Normal file
14
validation/Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN pip install --no-cache-dir requests authlib
|
||||
|
||||
COPY oidc_whoami.py /app/oidc_whoami.py
|
||||
|
||||
RUN chmod +x /app/oidc_whoami.py
|
||||
|
||||
EXPOSE 8765
|
||||
|
||||
CMD ["python3", "/app/oidc_whoami.py"]
|
||||
|
||||
181
validation/README.md
Normal file
181
validation/README.md
Normal file
@@ -0,0 +1,181 @@
|
||||
# OIDC Validation Setup
|
||||
|
||||
This directory contains a docker-compose setup for testing tinyauth's OIDC provider functionality with a minimal test client.
|
||||
|
||||
## Setup
|
||||
|
||||
1. **Build the OIDC test client image:**
|
||||
```bash
|
||||
docker build -t oidc-whoami-test:latest .
|
||||
```
|
||||
|
||||
2. **Start the services:**
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
## Services
|
||||
|
||||
### nginx
|
||||
- **Purpose:** Reverse proxy for `auth.example.com` → tinyauth
|
||||
- **Ports:** 80 (exposed to host)
|
||||
- **Access:** http://auth.example.com/ (via nginx on port 80)
|
||||
|
||||
### dns
|
||||
- **Purpose:** DNS server (dnsmasq) that resolves `auth.example.com` to the tinyauth container
|
||||
- **Configuration:** Resolves `auth.example.com` to the `tinyauth` container IP (172.28.0.20) within the Docker network
|
||||
- **Ports:** 53 (UDP/TCP) - not exposed to host (only for container-to-container communication)
|
||||
|
||||
### tinyauth
|
||||
- **URL:** http://auth.example.com/ (via nginx)
|
||||
- **Credentials:** `user` / `pass`
|
||||
- **OIDC Discovery:** http://auth.example.com/api/.well-known/openid-configuration
|
||||
- **OIDC Client ID:** `testclient`
|
||||
- **OIDC Client Secret:** `test-secret-123`
|
||||
- **Ports:** Not exposed to host (accessed via nginx on port 80)
|
||||
|
||||
### oidc-whoami
|
||||
- **Callback URL:** http://localhost:8765/callback
|
||||
- **Purpose:** Minimal OIDC test client that validates the OIDC flow
|
||||
- **Ports:** 8765 (exposed to host)
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. **Start all services:**
|
||||
```bash
|
||||
docker compose up --build -d
|
||||
```
|
||||
|
||||
2. **Launch Chrome with host-resolver-rules:**
|
||||
```bash
|
||||
./launch-chrome-host.sh
|
||||
```
|
||||
|
||||
Or manually:
|
||||
```bash
|
||||
google-chrome \
|
||||
--host-resolver-rules="MAP auth.example.com 127.0.0.1" \
|
||||
--disable-features=HttpsOnlyMode \
|
||||
--unsafely-treat-insecure-origin-as-secure=http://auth.example.com \
|
||||
--user-data-dir=/tmp/chrome-test-profile \
|
||||
http://auth.example.com/
|
||||
```
|
||||
|
||||
**Note:** The `--user-data-dir` flag uses a temporary profile to avoid HSTS (HTTP Strict Transport Security) issues that might force HTTPS redirects.
|
||||
|
||||
3. **Access tinyauth:** http://auth.example.com/
|
||||
- Login with: `user` / `pass`
|
||||
|
||||
4. **Test OIDC flow:**
|
||||
```bash
|
||||
# Get authorization URL from oidc-whoami logs
|
||||
docker compose logs oidc-whoami | grep "Authorization URL"
|
||||
# Open that URL in Chrome (already configured with host-resolver-rules)
|
||||
```
|
||||
|
||||
## Connecting from Chrome/Browser
|
||||
|
||||
Since the DNS server is only accessible within the Docker network, you have several options to access `auth.example.com` from your browser:
|
||||
|
||||
### Option 1: Use /etc/hosts (Simplest)
|
||||
|
||||
Add this line to your `/etc/hosts` file (or `C:\Windows\System32\drivers\etc\hosts` on Windows):
|
||||
|
||||
```
|
||||
127.0.0.1 auth.example.com
|
||||
```
|
||||
|
||||
Then access: http://auth.example.com/
|
||||
|
||||
**To edit /etc/hosts on Linux/Mac:**
|
||||
```bash
|
||||
sudo nano /etc/hosts
|
||||
# Add: 127.0.0.1 auth.example.com
|
||||
```
|
||||
|
||||
**To edit hosts on Windows:**
|
||||
1. Open Notepad as Administrator
|
||||
2. Open `C:\Windows\System32\drivers\etc\hosts`
|
||||
3. Add: `127.0.0.1 auth.example.com`
|
||||
|
||||
### Option 2: Use Chrome's `--host-resolver-rules` (Chrome-specific, No System Changes)
|
||||
|
||||
Chrome has a command-line flag that lets you map hostnames directly, bypassing DNS entirely. This is perfect for testing without modifying system settings.
|
||||
|
||||
**To use it:**
|
||||
|
||||
1. **Make sure services are running:**
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
2. **Launch Chrome with the host resolver rule:**
|
||||
|
||||
**Linux:**
|
||||
```bash
|
||||
google-chrome --host-resolver-rules="MAP auth.example.com 127.0.0.1"
|
||||
```
|
||||
|
||||
**Mac:**
|
||||
```bash
|
||||
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
|
||||
--host-resolver-rules="MAP auth.example.com 127.0.0.1"
|
||||
```
|
||||
|
||||
**Windows:**
|
||||
```cmd
|
||||
"C:\Program Files\Google\ Chrome\Application\chrome.exe" --host-resolver-rules="MAP auth.example.com 127.0.0.1"
|
||||
```
|
||||
|
||||
3. **Or modify Chrome's shortcut:**
|
||||
- Right-click Chrome shortcut → Properties
|
||||
- In "Target" field, append: ` --host-resolver-rules="MAP auth.example.com 127.0.0.1"`
|
||||
- Click OK
|
||||
|
||||
4. **Access:** http://auth.example.com/
|
||||
|
||||
**Note:** This only affects Chrome, not other applications. The DNS server on port 5353 isn't needed for this approach.
|
||||
|
||||
### Option 3: Use System DNS (All Applications)
|
||||
|
||||
If you want to use the DNS server on port 5353 for all applications (not just Chrome), configure your system DNS:
|
||||
|
||||
**Linux (with systemd-resolved):**
|
||||
```bash
|
||||
# Configure systemd-resolved to use our DNS
|
||||
sudo resolvectl dns lo 127.0.0.1:5353
|
||||
```
|
||||
|
||||
**Linux (without systemd-resolved):**
|
||||
```bash
|
||||
# Edit /etc/resolv.conf
|
||||
sudo nano /etc/resolv.conf
|
||||
# Add: nameserver 127.0.0.1
|
||||
# Note: This won't work with port 5353, you'd need port 53
|
||||
```
|
||||
|
||||
**Note:** Most systems expect DNS on port 53. To use port 5353, you'd need a DNS proxy or configure Chrome specifically (see Option 2 above).
|
||||
|
||||
## Testing
|
||||
|
||||
1. Start the services with `docker compose up --build -d`
|
||||
2. Launch Chrome: `./launch-chrome-host.sh` (or use `--host-resolver-rules` manually)
|
||||
3. Navigate to: http://auth.example.com/
|
||||
4. Login with `user` / `pass`
|
||||
5. Test the OIDC flow by accessing the discovery endpoint: http://auth.example.com/api/.well-known/openid-configuration
|
||||
|
||||
## Configuration
|
||||
|
||||
The tinyauth configuration is in `config.yaml`:
|
||||
- OIDC is enabled
|
||||
- Single user: `user` with password `pass`
|
||||
- OIDC client `testclient` is configured with redirect URI `http://localhost:8765/callback`
|
||||
- App URL and OIDC issuer: `http://auth.example.com` (via nginx on port 80)
|
||||
|
||||
## Notes
|
||||
|
||||
- All containers are on a custom Docker network (`tinyauth-network`) with a DNS server for domain resolution
|
||||
- The DNS server resolves `auth.example.com` to the tinyauth container within the network
|
||||
- The redirect URI must match exactly what's configured in tinyauth
|
||||
- Data is persisted in the `./data` directory
|
||||
- The domain `auth.example.com` is used to satisfy cookie domain validation requirements (needs at least 3 domain parts and not in public suffix list)
|
||||
36
validation/config.yaml
Normal file
36
validation/config.yaml
Normal file
@@ -0,0 +1,36 @@
|
||||
appUrl: "http://auth.example.com"
|
||||
logLevel: "info"
|
||||
databasePath: "/data/tinyauth.db"
|
||||
|
||||
auth:
|
||||
users: "user:$2b$12$mWEdxub8KTTBLK/f7dloKOS4t3kIeLOpme5pMXci5.lXNPANjCT5u" # user:pass
|
||||
secureCookie: false
|
||||
sessionExpiry: 3600
|
||||
loginTimeout: 300
|
||||
loginMaxRetries: 3
|
||||
|
||||
oidc:
|
||||
enabled: true
|
||||
issuer: "http://auth.example.com"
|
||||
accessTokenExpiry: 3600
|
||||
idTokenExpiry: 3600
|
||||
clients:
|
||||
testclient:
|
||||
clientSecret: "test-secret-123"
|
||||
clientName: "OIDC Test Client"
|
||||
redirectUris:
|
||||
- "http://client.example.com/callback"
|
||||
- "http://localhost:8765/callback"
|
||||
- "http://127.0.0.1:8765/callback"
|
||||
grantTypes:
|
||||
- "authorization_code"
|
||||
responseTypes:
|
||||
- "code"
|
||||
scopes:
|
||||
- "openid"
|
||||
- "profile"
|
||||
- "email"
|
||||
|
||||
ui:
|
||||
title: "Tinyauth OIDC Test"
|
||||
|
||||
91
validation/docker-compose.yml
Normal file
91
validation/docker-compose.yml
Normal file
@@ -0,0 +1,91 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
dns:
|
||||
container_name: dns-server
|
||||
image: strm/dnsmasq:latest
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
command:
|
||||
- "--no-daemon"
|
||||
- "--log-queries"
|
||||
- "--no-resolv"
|
||||
- "--server=8.8.8.8"
|
||||
- "--server=8.8.4.4"
|
||||
- "--address=/auth.example.com/172.28.0.2"
|
||||
- "--address=/client.example.com/172.28.0.2"
|
||||
# DNS port not exposed to host - only needed for container-to-container communication
|
||||
# Chrome uses --host-resolver-rules instead
|
||||
networks:
|
||||
tinyauth-network:
|
||||
ipv4_address: 172.28.0.10
|
||||
|
||||
nginx:
|
||||
container_name: nginx-proxy
|
||||
image: nginx:alpine
|
||||
ports:
|
||||
- "80:80"
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
networks:
|
||||
- tinyauth-network
|
||||
# Use Docker's built-in DNS (127.0.0.11) for service name resolution
|
||||
# Our custom DNS (172.28.0.10) is only used via resolver directive in nginx.conf
|
||||
depends_on:
|
||||
- tinyauth
|
||||
- dns
|
||||
- oidc-whoami
|
||||
|
||||
|
||||
tinyauth:
|
||||
container_name: tinyauth-oidc-test
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: Dockerfile
|
||||
command: ["--experimental.configfile=/config/config.yaml"]
|
||||
# Port not exposed to host - accessed via nginx
|
||||
volumes:
|
||||
- ./data:/data
|
||||
- ./config.yaml:/config/config.yaml:ro
|
||||
networks:
|
||||
tinyauth-network:
|
||||
ipv4_address: 172.28.0.20
|
||||
depends_on:
|
||||
- dns
|
||||
healthcheck:
|
||||
test: ["CMD", "tinyauth", "healthcheck"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
oidc-whoami:
|
||||
container_name: oidc-whoami-test
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
environment:
|
||||
- OIDC_ISSUER=http://auth.example.com
|
||||
- CLIENT_ID=testclient
|
||||
- CLIENT_SECRET=test-secret-123
|
||||
# Port not exposed to host - accessed via nginx
|
||||
depends_on:
|
||||
- tinyauth
|
||||
- dns
|
||||
# Use Docker's built-in DNS first, then our custom DNS for custom domains
|
||||
dns:
|
||||
- 127.0.0.11
|
||||
- 172.28.0.10
|
||||
networks:
|
||||
tinyauth-network:
|
||||
ipv4_address: 172.28.0.30
|
||||
# Note: Using custom network with DNS server to resolve auth.example.test
|
||||
# The redirect URI must match what's configured in tinyauth (http://localhost:8765/callback)
|
||||
# Using auth.example.test domain to satisfy cookie domain validation requirements (needs 3+ parts, not in public suffix list)
|
||||
|
||||
networks:
|
||||
tinyauth-network:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.28.0.0/16
|
||||
|
||||
39
validation/launch-chrome-host.sh
Executable file
39
validation/launch-chrome-host.sh
Executable file
@@ -0,0 +1,39 @@
|
||||
#!/bin/bash
|
||||
# Launch Chrome from host (not in container)
|
||||
# This script should be run on your host machine
|
||||
|
||||
set -e
|
||||
|
||||
echo "Launching Chrome for OIDC test setup..."
|
||||
|
||||
# Detect Chrome
|
||||
if command -v google-chrome &> /dev/null; then
|
||||
CHROME_CMD="google-chrome"
|
||||
elif command -v chromium-browser &> /dev/null; then
|
||||
CHROME_CMD="chromium-browser"
|
||||
elif command -v chromium &> /dev/null; then
|
||||
CHROME_CMD="chromium"
|
||||
elif [ -f "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" ]; then
|
||||
CHROME_CMD="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
|
||||
else
|
||||
echo "Error: Chrome not found. Please install Google Chrome or Chromium."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Using: $CHROME_CMD"
|
||||
echo "Opening: http://client.example.com/ (OIDC test client)"
|
||||
echo ""
|
||||
|
||||
$CHROME_CMD \
|
||||
--host-resolver-rules="MAP auth.example.com 127.0.0.1, MAP client.example.com 127.0.0.1" \
|
||||
--disable-features=HttpsOnlyMode \
|
||||
--unsafely-treat-insecure-origin-as-secure=http://auth.example.com,http://client.example.com \
|
||||
--user-data-dir=/tmp/chrome-test-profile-$(date +%s) \
|
||||
--new-window \
|
||||
http://client.example.com/ \
|
||||
> /dev/null 2>&1 &
|
||||
|
||||
echo "Chrome launched!"
|
||||
echo "OIDC test client: http://client.example.com/"
|
||||
echo "Tinyauth: http://auth.example.com/"
|
||||
|
||||
68
validation/launch-chrome.sh
Executable file
68
validation/launch-chrome.sh
Executable file
@@ -0,0 +1,68 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "=========================================="
|
||||
echo "Chrome Launcher for OIDC Test Setup"
|
||||
echo "=========================================="
|
||||
|
||||
# Wait for nginx to be ready
|
||||
echo "Waiting for nginx to be ready..."
|
||||
for i in {1..30}; do
|
||||
if curl -s http://127.0.0.1/ > /dev/null 2>&1; then
|
||||
echo "✓ Nginx is ready"
|
||||
break
|
||||
fi
|
||||
if [ $i -eq 30 ]; then
|
||||
echo "✗ Nginx not ready after 30 seconds"
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Try to find Chrome on the host system
|
||||
# Since we're in a container, we need to check common locations
|
||||
CHROME_PATHS=(
|
||||
"/usr/bin/google-chrome"
|
||||
"/usr/bin/google-chrome-stable"
|
||||
"/usr/bin/chromium-browser"
|
||||
"/usr/bin/chromium"
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
|
||||
)
|
||||
|
||||
CHROME_CMD=""
|
||||
for path in "${CHROME_PATHS[@]}"; do
|
||||
if [ -f "$path" ] || command -v "$(basename "$path")" &> /dev/null; then
|
||||
CHROME_CMD="$(basename "$path")"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$CHROME_CMD" ]; then
|
||||
echo ""
|
||||
echo "Chrome not found in container. This is expected."
|
||||
echo "Please launch Chrome manually on your host with:"
|
||||
echo ""
|
||||
echo ' google-chrome --host-resolver-rules="MAP auth.example.com 127.0.0.1" http://auth.example.com/'
|
||||
echo ""
|
||||
echo "Or use the launch script on your host:"
|
||||
echo " ./launch-chrome.sh"
|
||||
echo ""
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Found Chrome: $CHROME_CMD"
|
||||
echo "Launching Chrome with host-resolver-rules..."
|
||||
echo ""
|
||||
|
||||
$CHROME_CMD \
|
||||
--host-resolver-rules="MAP auth.example.com 127.0.0.1" \
|
||||
--new-window \
|
||||
http://auth.example.com/ \
|
||||
> /dev/null 2>&1 &
|
||||
|
||||
echo "✓ Chrome launched!"
|
||||
echo ""
|
||||
echo "Access tinyauth at: http://auth.example.com/"
|
||||
echo "OIDC test client callback: http://127.0.0.1:8765/callback"
|
||||
echo ""
|
||||
|
||||
43
validation/nginx.conf
Normal file
43
validation/nginx.conf
Normal file
@@ -0,0 +1,43 @@
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
# Use Docker's built-in DNS (127.0.0.11) for service name resolution
|
||||
# This allows nginx to resolve Docker service names like "tinyauth" and "oidc-whoami"
|
||||
resolver 127.0.0.11 valid=10s;
|
||||
resolver_timeout 5s;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name auth.example.com;
|
||||
|
||||
location / {
|
||||
# Use variable to enable dynamic resolution at request time
|
||||
set $backend "tinyauth:3000";
|
||||
proxy_pass http://$backend;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name client.example.com;
|
||||
|
||||
location / {
|
||||
# Use variable to enable dynamic resolution at request time
|
||||
set $backend "oidc-whoami:8765";
|
||||
proxy_pass http://$backend;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
298
validation/oidc_whoami.py
Normal file
298
validation/oidc_whoami.py
Normal file
@@ -0,0 +1,298 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import html
|
||||
import webbrowser
|
||||
import secrets
|
||||
import time
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
from http.cookies import SimpleCookie
|
||||
|
||||
import requests
|
||||
from authlib.integrations.requests_client import OAuth2Session
|
||||
from authlib.oidc.core import CodeIDToken
|
||||
from authlib.jose import jwt
|
||||
|
||||
# ---- config via env ----
|
||||
ISSUER = os.environ["OIDC_ISSUER"]
|
||||
CLIENT_ID = os.environ["CLIENT_ID"]
|
||||
CLIENT_SECRET= os.environ.get("CLIENT_SECRET") # optional (public clients ok)
|
||||
REDIRECT_URI = "http://client.example.com/callback"
|
||||
SCOPE = "openid profile email"
|
||||
|
||||
# ---- discovery ----
|
||||
# Retry discovery in case nginx isn't ready yet
|
||||
discovery = None
|
||||
for attempt in range(10):
|
||||
try:
|
||||
discovery = requests.get(
|
||||
f"{ISSUER.rstrip('/')}/api/.well-known/openid-configuration",
|
||||
timeout=5
|
||||
).json()
|
||||
break
|
||||
except Exception as e:
|
||||
if attempt < 9:
|
||||
print(f"Discovery attempt {attempt + 1} failed: {e}, retrying...")
|
||||
time.sleep(2)
|
||||
else:
|
||||
raise
|
||||
|
||||
if discovery is None:
|
||||
raise RuntimeError("Failed to fetch OIDC discovery document after 10 attempts")
|
||||
|
||||
state = secrets.token_urlsafe(16)
|
||||
nonce = secrets.token_urlsafe(16)
|
||||
|
||||
client = OAuth2Session(
|
||||
client_id=CLIENT_ID,
|
||||
client_secret=CLIENT_SECRET,
|
||||
scope=SCOPE,
|
||||
redirect_uri=REDIRECT_URI,
|
||||
)
|
||||
|
||||
auth_result = client.create_authorization_url(
|
||||
discovery["authorization_endpoint"],
|
||||
state=state,
|
||||
nonce=nonce,
|
||||
code_challenge_method="S256",
|
||||
)
|
||||
auth_url = auth_result[0]
|
||||
code_verifier = auth_result[1] if len(auth_result) > 1 else None
|
||||
|
||||
# Cache JWKS for token validation
|
||||
jwk_set_cache = None
|
||||
jwk_set_cache_time = None
|
||||
|
||||
def get_jwk_set():
|
||||
"""Get JWKS with caching"""
|
||||
global jwk_set_cache, jwk_set_cache_time
|
||||
# Cache for 1 hour
|
||||
if jwk_set_cache is None or (jwk_set_cache_time and time.time() - jwk_set_cache_time > 3600):
|
||||
jwk_set_cache = requests.get(discovery["jwks_uri"]).json()
|
||||
jwk_set_cache_time = time.time()
|
||||
return jwk_set_cache
|
||||
|
||||
def parse_cookies(cookie_header):
|
||||
"""Parse cookies from Cookie header"""
|
||||
if not cookie_header:
|
||||
return {}
|
||||
cookie = SimpleCookie()
|
||||
cookie.load(cookie_header)
|
||||
return {k: v.value for k, v in cookie.items()}
|
||||
|
||||
def validate_id_token(id_token):
|
||||
"""Validate and decode ID token"""
|
||||
try:
|
||||
jwk_set = get_jwk_set()
|
||||
claims_options = {
|
||||
"iss": {"essential": True, "value": discovery["issuer"]},
|
||||
"aud": {"essential": True, "value": CLIENT_ID},
|
||||
}
|
||||
decoded = jwt.decode(
|
||||
id_token,
|
||||
key=jwk_set,
|
||||
claims_options=claims_options
|
||||
)
|
||||
decoded.validate()
|
||||
return dict(decoded)
|
||||
except Exception as e:
|
||||
print(f"Token validation failed: {e}")
|
||||
return None
|
||||
|
||||
# ---- tiny callback server ----
|
||||
class CallbackHandler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
# Handle root path - check if already logged in
|
||||
if self.path == "/" or self.path == "":
|
||||
cookies = parse_cookies(self.headers.get("Cookie"))
|
||||
id_token = cookies.get("id_token")
|
||||
|
||||
# Check if we have a valid token
|
||||
if id_token:
|
||||
claims = validate_id_token(id_token)
|
||||
if claims and claims.get("exp", 0) > time.time():
|
||||
# Already logged in - show main page
|
||||
self.send_response(200)
|
||||
self.send_header("Content-type", "text/html")
|
||||
self.end_headers()
|
||||
html_content = f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>OIDC Test Client - Welcome</title>
|
||||
<style>
|
||||
body {{
|
||||
font-family: Arial, sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 50px auto;
|
||||
padding: 20px;
|
||||
background: #f5f5f5;
|
||||
}}
|
||||
.main-box {{
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 30px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}}
|
||||
h1 {{
|
||||
color: #4285f4;
|
||||
margin-top: 0;
|
||||
}}
|
||||
.user-info {{
|
||||
background: #f9f9f9;
|
||||
padding: 20px;
|
||||
border-radius: 4px;
|
||||
margin: 20px 0;
|
||||
border-left: 4px solid #4285f4;
|
||||
}}
|
||||
pre {{
|
||||
background: #f9f9f9;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
border: 1px solid #ddd;
|
||||
}}
|
||||
.logout-btn {{
|
||||
display: inline-block;
|
||||
padding: 10px 20px;
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
margin-top: 20px;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="main-box">
|
||||
<h1>✅ Welcome back!</h1>
|
||||
<div class="user-info">
|
||||
<h2>User Information</h2>
|
||||
<p><strong>Username:</strong> {html.escape(str(claims.get('preferred_username', claims.get('sub', 'N/A'))))}</p>
|
||||
<p><strong>Name:</strong> {html.escape(str(claims.get('name', 'N/A')))}</p>
|
||||
<p><strong>Email:</strong> {html.escape(str(claims.get('email', 'N/A')))}</p>
|
||||
</div>
|
||||
<hr>
|
||||
<h2>ID Token Claims:</h2>
|
||||
<pre>{html.escape(json.dumps(claims, indent=2))}</pre>
|
||||
<a href="/logout" class="logout-btn">Logout</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
self.wfile.write(html_content.encode())
|
||||
return
|
||||
|
||||
# Not logged in - show login page
|
||||
self.send_response(200)
|
||||
self.send_header("Content-type", "text/html")
|
||||
self.end_headers()
|
||||
html_content = f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>OIDC Test Client</title></head>
|
||||
<body>
|
||||
<h1>OIDC Test Client</h1>
|
||||
<p>Click the button below to start the OIDC flow:</p>
|
||||
<a href="{auth_url}" style="display: inline-block; padding: 10px 20px; background: #4285f4; color: white; text-decoration: none; border-radius: 4px;">Login with OIDC</a>
|
||||
<hr>
|
||||
<p><small>Authorization URL: <code>{auth_url}</code></small></p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
self.wfile.write(html_content.encode())
|
||||
return
|
||||
|
||||
# Handle logout
|
||||
if self.path == "/logout":
|
||||
self.send_response(302)
|
||||
self.send_header("Location", "/")
|
||||
self.send_header("Set-Cookie", "id_token=; Path=/; Max-Age=0")
|
||||
self.end_headers()
|
||||
return
|
||||
|
||||
# Handle callback
|
||||
if not self.path.startswith("/callback"):
|
||||
self.send_error(404, "Not Found")
|
||||
return
|
||||
|
||||
qs = parse_qs(urlparse(self.path).query)
|
||||
|
||||
if qs.get("state", [None])[0] != state:
|
||||
self.send_error(400, "Invalid state")
|
||||
return
|
||||
|
||||
code = qs.get("code", [None])[0]
|
||||
if not code:
|
||||
self.send_error(400, "Missing code")
|
||||
return
|
||||
|
||||
token = client.fetch_token(
|
||||
discovery["token_endpoint"],
|
||||
code=code,
|
||||
code_verifier=code_verifier,
|
||||
)
|
||||
|
||||
# ---- ID token validation ----
|
||||
# Decode and validate the ID token using cached JWKS
|
||||
jwk_set = get_jwk_set()
|
||||
|
||||
# Decode the JWT - make nonce optional if not provided
|
||||
claims_options = {
|
||||
"iss": {"essential": True, "value": discovery["issuer"]},
|
||||
"aud": {"essential": True, "value": CLIENT_ID},
|
||||
}
|
||||
if nonce:
|
||||
claims_options["nonce"] = {"essential": True, "value": nonce}
|
||||
|
||||
decoded = jwt.decode(
|
||||
token["id_token"],
|
||||
key=jwk_set,
|
||||
claims_options=claims_options
|
||||
)
|
||||
decoded.validate()
|
||||
|
||||
# Convert JWTClaims to dict for display
|
||||
id_token_claims = dict(decoded)
|
||||
|
||||
# Store ID token in cookie (expires when token expires)
|
||||
token_expiry = id_token_claims.get("exp", 0) - time.time()
|
||||
max_age = max(0, int(token_expiry))
|
||||
|
||||
# Redirect to main page with cookie set
|
||||
self.send_response(302)
|
||||
self.send_header("Location", "/")
|
||||
self.send_header("Set-Cookie", f"id_token={token['id_token']}; Path=/; Max-Age={max_age}; HttpOnly")
|
||||
self.end_headers()
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("✅ OIDC Authentication Successful!")
|
||||
print("=" * 60)
|
||||
print("\nID Token Claims:")
|
||||
print(json.dumps(id_token_claims, indent=2))
|
||||
print("\n" + "=" * 60)
|
||||
# Don't exit - keep server running for multiple test flows
|
||||
|
||||
# ---- run ----
|
||||
print("=" * 60)
|
||||
print("OIDC Test Client")
|
||||
print("=" * 60)
|
||||
print(f"\nAuthorization URL: {auth_url}")
|
||||
print("\nTo test the OIDC flow:")
|
||||
print("1. Open the authorization URL above in your browser")
|
||||
print("2. Login with credentials: user / pass")
|
||||
print("3. You will be redirected back to the callback")
|
||||
print("4. The ID token claims will be displayed below")
|
||||
print(f"\nWaiting for callback on {REDIRECT_URI}...")
|
||||
print("=" * 60)
|
||||
|
||||
# Try to open browser (may fail in Docker, that's OK)
|
||||
try:
|
||||
webbrowser.open(auth_url)
|
||||
except Exception as e:
|
||||
print(f"Could not open browser automatically: {e}")
|
||||
print("Please open the authorization URL manually")
|
||||
|
||||
HTTPServer(("0.0.0.0", 8765), CallbackHandler).serve_forever()
|
||||
Reference in New Issue
Block a user