Compare commits

..

13 Commits

Author SHA1 Message Date
Olivier Dumont
3f2f813902 Trigger automated review 2025-12-30 14:22:53 +01:00
Olivier Dumont
014550f80e CRITICAL: Add audience validation for access tokens
Access tokens include an 'aud' (audience) claim set to the client ID,
but this was never validated during token validation. This allowed
tokens issued for one client to be used by another client, violating
the OAuth 2.0 security model.

Changes:
- Add ValidateAccessTokenForClient method that validates audience
  if expectedClientID is provided
- Update ValidateAccessToken to call ValidateAccessTokenForClient
  (backward compatible, no audience check if not specified)
- Update userinfo endpoint to accept optional client_id parameter
  and validate token audience matches it

Security impact:
- Prevents token reuse across different clients
- Ensures tokens are scoped to specific clients as intended
- Prevents attackers from using tokens issued for one client to
  access resources protected by another client
2025-12-30 14:10:50 +01:00
Olivier Dumont
5ec9989189 Remove redundant 'openid' scope special case logic
The special case for adding 'openid' scope was redundant and could
potentially bypass client scope restrictions. The main loop already
correctly adds 'openid' to validScopes if it's in both requestedScopes
and allowedScopes.

Since 'openid' is already in the default scopes during client
configuration (SyncClientsFromConfig), it will be available for
clients that don't explicitly configure scopes. Clients can include
or exclude 'openid' in their allowedScopes as needed.

This ensures consistent enforcement of client scope restrictions
with no special-case bypasses.
2025-12-30 13:52:01 +01:00
Olivier Dumont
ad12110fbf Replace SHA256 with HKDF for key derivation and fix scope validation
Security improvements:

1. HKDF key derivation:
   - Replace raw sha256.Sum256() with proper HKDF (HMAC-based KDF)
   - Uses domain-separated label 'oidc-aes-256-key-v1' for key derivation
   - Applied to both encryptPrivateKey and decryptPrivateKey
   - Provides better security properties than raw hash

2. Scope validation fix:
   - Only add 'openid' scope if it's both requested AND in client's
     allowedScopes
   - Prevents bypassing client scope restrictions
   - Respects configured allowedScopes

Both changes improve security posture while maintaining backward
compatibility.
2025-12-30 13:37:43 +01:00
Olivier Dumont
ca74534048 Add bcrypt hashing for client secrets and RSA key encryption
Security improvements:

1. Client secret hashing:
   - Replace plaintext comparison with bcrypt.CompareHashAndPassword
   - Provides constant-time comparison to prevent timing attacks
   - Hash secrets with bcrypt before storing in database
   - Update SyncClientsFromConfig to hash incoming plaintext secrets

2. Deterministic RSA key loading:
   - Load most recently created key using ORDER BY created_at DESC
   - Add warning if multiple keys detected in database
   - Ensures consistent key selection on startup

3. Optional RSA key encryption:
   - Encrypt private keys with AES-256-GCM when OIDC_RSA_MASTER_KEY is set
   - Master key derived via SHA256 from environment variable
   - Backward compatible: stores plaintext if no master key set
   - Automatic detection of encrypted vs plaintext on load

All changes maintain backward compatibility with existing deployments.
2025-12-30 13:26:06 +01:00
Olivier Dumont
1b37096b58 CRITICAL: Add replay protection for authorization codes
Authorization codes were implemented as stateless JWTs with no tracking,
allowing the same code to be exchanged for tokens multiple times. This
violates OAuth 2.0 RFC 6749 Section 4.1.2 which mandates that authorization
codes MUST be single-use.

This change:
- Adds oidc_authorization_codes table to track code usage
- Stores authorization codes in database when generated
- Validates code exists and hasn't been used before exchange
- Marks code as used immediately after validation
- Prevents replay attacks where intercepted codes could be reused

Security impact:
- Prevents attackers from reusing intercepted authorization codes
- Ensures compliance with OAuth 2.0 security requirements
- Adds database-backed single-use enforcement
2025-12-30 13:00:19 +01:00
Olivier Dumont
cd068d16c2 Fix Python scoping issue: rename html variable to avoid conflict
The variable 'html' was being assigned to store HTML content, which
caused Python to treat 'html' as a local variable throughout the
function. This prevented access to the 'html' module (imported at
the top) within f-strings that referenced html.escape().

Renamed the HTML content variable to 'html_content' to avoid the
naming conflict with the html module.
2025-12-30 12:52:53 +01:00
Olivier Dumont
5b5799ab62 Fix XSS vulnerability: Escape user claims in HTML output
User claims from ID tokens (username, name, email) were directly
interpolated into HTML without escaping, allowing XSS attacks if
malicious content was present in claims.

This fix:
- Imports html module for escaping
- Escapes all user-controlled data before rendering in HTML
- Escapes JSON output in pre tags as well
- Prevents execution of malicious scripts in browser
2025-12-30 12:46:03 +01:00
Olivier Dumont
672914ceb7 Remove insecure query parameter fallback for client credentials
The discovery document only advertises client_secret_basic and
client_secret_post as supported authentication methods. Query parameters
are insecure because they are:
- Logged in access logs
- Stored in browser history
- Exposed in referrer headers

This fix removes the query parameter fallback, ensuring client secrets
are only accepted via:
- Authorization header (client_secret_basic)
- POST form body (client_secret_post)

This aligns the implementation with the advertised capabilities and
prevents client secret exposure through query strings.
2025-12-30 12:40:55 +01:00
Olivier Dumont
f006ebe5e4 Fix open redirect vulnerability in authorize endpoint
Per OAuth 2.0 RFC 6749 §4.1.2.1, errors should NOT redirect to
unvalidated redirect_uri values. This fix:

- Returns JSON errors for failures before redirect_uri validation
  (missing parameters, invalid client)
- Only redirects to redirect_uri after it has been validated
  against registered client URIs
- Prevents open redirect attacks where malicious redirect_uri
  values could be used to redirect users to attacker-controlled sites
2025-12-30 12:40:01 +01:00
Olivier Dumont
dabb4398ad Implement PKCE (Proof Key for Code Exchange) support
PKCE was advertised in the discovery document but not actually implemented.
This commit adds full PKCE support:

- Store code_challenge and code_challenge_method in authorization code JWT
- Accept code_verifier parameter in token endpoint
- Validate code_verifier against stored code_challenge
- Support both S256 (SHA256) and plain code challenge methods
- PKCE validation is required when code_challenge is present

This prevents authorization code interception attacks by requiring
the client to prove possession of the code_verifier that was used
to generate the code_challenge.
2025-12-30 12:39:00 +01:00
Olivier Dumont
ef157ae9ba Fix critical security issue: verify JWT signature in access token validation
The validateAccessToken method was only decoding the JWT payload without
verifying the signature, allowing attackers to forge tokens. This fix:

- Adds ValidateAccessToken method to OIDCService that properly verifies
  JWT signature using RSA public key
- Validates issuer, expiration, and required claims
- Updates controller to use the secure validation method
- Removes insecure manual JWT parsing code
2025-12-30 12:36:30 +01:00
Olivier Dumont
020fcb9878 Add OIDC provider functionality with validation setup
This commit adds OpenID Connect (OIDC) provider functionality to tinyauth,
allowing it to act as an OIDC identity provider for other applications.

Features:
- OIDC discovery endpoint at /.well-known/openid-configuration
- Authorization endpoint for OAuth 2.0 authorization code flow
- Token endpoint for exchanging authorization codes for tokens
- ID token generation with JWT signing
- JWKS endpoint for public key distribution
- Support for PKCE (code challenge/verifier)
- Nonce validation for ID tokens
- Configurable OIDC clients with redirect URIs, scopes, and grant types

Validation:
- Docker Compose setup for local testing
- OIDC test client (oidc-whoami) with session management
- Nginx reverse proxy configuration
- DNS server (dnsmasq) for custom domain resolution
- Chrome launch script for easy testing

Configuration:
- OIDC configuration in config.yaml
- Example configuration in config.example.yaml
- Database migrations for OIDC client storage
2025-12-30 12:17:55 +01:00
54 changed files with 2631 additions and 756 deletions

View File

@@ -18,16 +18,7 @@ jobs:
- name: Setup go
uses: actions/setup-go@v5
with:
go-version: "^1.24.0"
- name: Initialize submodules
run: |
git submodule init
git submodule update
- name: Apply patches
run: |
git apply --directory paerser/ patches/nested_maps.diff
go-version: "^1.23.2"
- name: Install frontend dependencies
run: |

View File

@@ -61,16 +61,7 @@ jobs:
- name: Install go
uses: actions/setup-go@v5
with:
go-version: "^1.24.0"
- name: Initialize submodules
run: |
git submodule init
git submodule update
- name: Apply patches
run: |
git apply --directory paerser/ patches/nested_maps.diff
go-version: "^1.23.2"
- name: Install frontend dependencies
run: |
@@ -116,16 +107,7 @@ jobs:
- name: Install go
uses: actions/setup-go@v5
with:
go-version: "^1.24.0"
- name: Initialize submodules
run: |
git submodule init
git submodule update
- name: Apply patches
run: |
git apply --directory paerser/ patches/nested_maps.diff
go-version: "^1.23.2"
- name: Install frontend dependencies
run: |
@@ -165,15 +147,6 @@ jobs:
with:
ref: nightly
- name: Initialize submodules
run: |
git submodule init
git submodule update
- name: Apply patches
run: |
git apply --directory paerser/ patches/nested_maps.diff
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
@@ -232,15 +205,6 @@ jobs:
with:
ref: nightly
- name: Initialize submodules
run: |
git submodule init
git submodule update
- name: Apply patches
run: |
git apply --directory paerser/ patches/nested_maps.diff
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
@@ -299,15 +263,6 @@ jobs:
with:
ref: nightly
- name: Initialize submodules
run: |
git submodule init
git submodule update
- name: Apply patches
run: |
git apply --directory paerser/ patches/nested_maps.diff
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
@@ -366,15 +321,6 @@ jobs:
with:
ref: nightly
- name: Initialize submodules
run: |
git submodule init
git submodule update
- name: Apply patches
run: |
git apply --directory paerser/ patches/nested_maps.diff
- name: Docker meta
id: meta
uses: docker/metadata-action@v5

View File

@@ -39,16 +39,7 @@ jobs:
- name: Install go
uses: actions/setup-go@v5
with:
go-version: "^1.24.0"
- name: Initialize submodules
run: |
git submodule init
git submodule update
- name: Apply patches
run: |
git apply --directory paerser/ patches/nested_maps.diff
go-version: "^1.23.2"
- name: Install frontend dependencies
run: |
@@ -91,16 +82,7 @@ jobs:
- name: Install go
uses: actions/setup-go@v5
with:
go-version: "^1.24.0"
- name: Initialize submodules
run: |
git submodule init
git submodule update
- name: Apply patches
run: |
git apply --directory paerser/ patches/nested_maps.diff
go-version: "^1.23.2"
- name: Install frontend dependencies
run: |
@@ -137,15 +119,6 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Initialize submodules
run: |
git submodule init
git submodule update
- name: Apply patches
run: |
git apply --directory paerser/ patches/nested_maps.diff
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
@@ -201,15 +174,6 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Initialize submodules
run: |
git submodule init
git submodule update
- name: Apply patches
run: |
git apply --directory paerser/ patches/nested_maps.diff
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
@@ -265,15 +229,6 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Initialize submodules
run: |
git submodule init
git submodule update
- name: Apply patches
run: |
git apply --directory paerser/ patches/nested_maps.diff
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
@@ -329,15 +284,6 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Initialize submodules
run: |
git submodule init
git submodule update
- name: Apply patches
run: |
git apply --directory paerser/ patches/nested_maps.diff
- name: Docker meta
id: meta
uses: docker/metadata-action@v5

4
.gitmodules vendored
View File

@@ -1,4 +0,0 @@
[submodule "paerser"]
path = paerser
url = https://github.com/traefik/paerser
ignore = all

1
.review_trigger Normal file
View File

@@ -0,0 +1 @@
# Trigger automated review

View File

@@ -5,7 +5,7 @@ Contributing is relatively easy, you just need to follow the steps below and you
## Requirements
- Bun
- Golang 1.24.0+
- Golang v1.23.2 and above
- Git
- Docker
@@ -18,21 +18,12 @@ git clone https://github.com/steveiliop56/tinyauth
cd tinyauth
```
## Initialize submodules
The project uses Git submodules for some dependencies, so you need to initialize them with:
```sh
git submodule init
git submodule update
```
## Install requirements
Although you will not need the requirements in your machine since the development will happen in Docker, I still recommend to install them because this way you will not have import errors. To install the Go requirements run:
Although you will not need the requirements in your machine since the development will happen in docker, I still recommend to install them because this way you will not have import errors. To install the go requirements run:
```sh
go mod download
go mod tidy
```
You also need to download the frontend dependencies, this can be done like so:
@@ -42,21 +33,13 @@ cd frontend/
bun install
```
## Apply patches
Some of the dependencies need to be patched in order to work correctly with the project, you can apply the patches by running:
```sh
git apply --directory paerser/ patches/nested_maps.diff
```
## Create your `.env` file
In order to configure the app you need to create an environment file, this can be done by copying the `.env.example` file to `.env` and modifying the environment variables to suit your needs.
## Developing
I have designed the development workflow to be entirely in Docker, this is because it will directly work with Traefik and you will not need to do any building in your host machine. The recommended development setup is to have a subdomain pointing to your machine like this:
I have designed the development workflow to be entirely in docker, this is because it will directly work with traefik and you will not need to do any building in your host machine. The recommended development setup is to have a subdomain pointing to your machine like this:
```
*.dev.example.com -> 127.0.0.1
@@ -66,7 +49,7 @@ dev.example.com -> 127.0.0.1
> [!TIP]
> You can use [sslip.io](https://sslip.io) as a domain if you don't have one to develop with.
Then you can just make sure the domains are correct in the development Docker compose file and run:
Then you can just make sure the domains are correct in the development docker compose file and run:
```sh
docker compose -f docker-compose.dev.yml up --build

View File

@@ -28,8 +28,6 @@ ARG BUILD_TIMESTAMP
WORKDIR /tinyauth
COPY ./paerser ./paerser
COPY go.mod ./
COPY go.sum ./

View File

@@ -2,8 +2,6 @@ FROM golang:1.25-alpine3.21
WORKDIR /tinyauth
COPY ./paerser ./paerser
COPY go.mod ./
COPY go.sum ./

View File

@@ -28,8 +28,6 @@ ARG BUILD_TIMESTAMP
WORKDIR /tinyauth
COPY ./paerser ./paerser
COPY go.mod ./
COPY go.sum ./

View File

@@ -63,6 +63,42 @@ oauth:
# 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

View File

@@ -42,6 +42,7 @@ services:
volumes:
- ./internal:/tinyauth/internal
- ./cmd:/tinyauth/cmd
- ./main.go:/tinyauth/main.go
- /var/run/docker.sock:/var/run/docker.sock
- ./data:/data
ports:

View File

@@ -12,7 +12,7 @@
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-query": "^5.90.15",
"@tanstack/react-query": "^5.90.12",
"axios": "^1.13.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -24,7 +24,7 @@
"next-themes": "^0.4.6",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-hook-form": "^7.69.0",
"react-hook-form": "^7.68.0",
"react-i18next": "^16.5.0",
"react-markdown": "^10.1.0",
"react-router": "^7.11.0",
@@ -47,7 +47,7 @@
"prettier": "3.7.4",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.51.0",
"typescript-eslint": "^8.50.0",
"vite": "^7.3.0",
},
},
@@ -339,9 +339,9 @@
"@tanstack/eslint-plugin-query": ["@tanstack/eslint-plugin-query@5.91.2", "", { "dependencies": { "@typescript-eslint/utils": "^8.44.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0" } }, "sha512-UPeWKl/Acu1IuuHJlsN+eITUHqAaa9/04geHHPedY8siVarSaWprY0SVMKrkpKfk5ehRT7+/MZ5QwWuEtkWrFw=="],
"@tanstack/query-core": ["@tanstack/query-core@5.90.15", "", {}, "sha512-mInIZNUZftbERE+/Hbtswfse49uUQwch46p+27gP9DWJL927UjnaWEF2t3RMOqBcXbfMdcNkPe06VyUIAZTV1g=="],
"@tanstack/query-core": ["@tanstack/query-core@5.90.12", "", {}, "sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg=="],
"@tanstack/react-query": ["@tanstack/react-query@5.90.15", "", { "dependencies": { "@tanstack/query-core": "5.90.15" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-uQvnDDcTOgJouNtAyrgRej+Azf0U5WDov3PXmHFUBc+t1INnAYhIlpZtCGNBLwCN41b43yO7dPNZu8xWkUFBwQ=="],
"@tanstack/react-query": ["@tanstack/react-query@5.90.12", "", { "dependencies": { "@tanstack/query-core": "5.90.12" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg=="],
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
@@ -373,25 +373,25 @@
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.51.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.51.0", "@typescript-eslint/type-utils": "8.51.0", "@typescript-eslint/utils": "8.51.0", "@typescript-eslint/visitor-keys": "8.51.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.2.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.51.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-XtssGWJvypyM2ytBnSnKtHYOGT+4ZwTnBVl36TA4nRO2f4PRNGz5/1OszHzcZCvcBMh+qb7I06uoCmLTRdR9og=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.50.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.50.0", "@typescript-eslint/type-utils": "8.50.0", "@typescript-eslint/utils": "8.50.0", "@typescript-eslint/visitor-keys": "8.50.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.50.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-O7QnmOXYKVtPrfYzMolrCTfkezCJS9+ljLdKW/+DCvRsc3UAz+sbH6Xcsv7p30+0OwUbeWfUDAQE0vpabZ3QLg=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.51.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.51.0", "@typescript-eslint/types": "8.51.0", "@typescript-eslint/typescript-estree": "8.51.0", "@typescript-eslint/visitor-keys": "8.51.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-3xP4XzzDNQOIqBMWogftkwxhg5oMKApqY0BAflmLZiFYHqyhSOxv/cd/zPQLTcCXr4AkaKb25joocY0BD1WC6A=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.50.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.50.0", "@typescript-eslint/types": "8.50.0", "@typescript-eslint/typescript-estree": "8.50.0", "@typescript-eslint/visitor-keys": "8.50.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q=="],
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.51.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.51.0", "@typescript-eslint/types": "^8.51.0", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-Luv/GafO07Z7HpiI7qeEW5NW8HUtZI/fo/kE0YbtQEFpJRUuR0ajcWfCE5bnMvL7QQFrmT/odMe8QZww8X2nfQ=="],
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.50.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.50.0", "@typescript-eslint/types": "^8.50.0", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-Cg/nQcL1BcoTijEWyx4mkVC56r8dj44bFDvBdygifuS20f3OZCHmFbjF34DPSi07kwlFvqfv/xOLnJ5DquxSGQ=="],
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.46.1", "", { "dependencies": { "@typescript-eslint/types": "8.46.1", "@typescript-eslint/visitor-keys": "8.46.1" } }, "sha512-weL9Gg3/5F0pVQKiF8eOXFZp8emqWzZsOJuWRUNtHT+UNV2xSJegmpCNQHy37aEQIbToTq7RHKhWvOsmbM680A=="],
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.51.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-Qi5bSy/vuHeWyir2C8u/uqGMIlIDu8fuiYWv48ZGlZ/k+PRPHtaAu7erpc7p5bzw2WNNSniuxoMSO4Ar6V9OXw=="],
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.50.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-vxd3G/ybKTSlm31MOA96gqvrRGv9RJ7LGtZCn2Vrc5htA0zCDvcMqUkifcjrWNNKXHUU3WCkYOzzVSFBd0wa2w=="],
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.51.0", "", { "dependencies": { "@typescript-eslint/types": "8.51.0", "@typescript-eslint/typescript-estree": "8.51.0", "@typescript-eslint/utils": "8.51.0", "debug": "^4.3.4", "ts-api-utils": "^2.2.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-0XVtYzxnobc9K0VU7wRWg1yiUrw4oQzexCG2V2IDxxCxhqBMSMbjB+6o91A+Uc0GWtgjCa3Y8bi7hwI0Tu4n5Q=="],
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.50.0", "", { "dependencies": { "@typescript-eslint/types": "8.50.0", "@typescript-eslint/typescript-estree": "8.50.0", "@typescript-eslint/utils": "8.50.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-7OciHT2lKCewR0mFoBrvZJ4AXTMe/sYOe87289WAViOocEmDjjv8MvIOT2XESuKj9jp8u3SZYUSh89QA4S1kQw=="],
"@typescript-eslint/types": ["@typescript-eslint/types@8.46.1", "", {}, "sha512-C+soprGBHwWBdkDpbaRC4paGBrkIXxVlNohadL5o0kfhsXqOC6GYH2S/Obmig+I0HTDl8wMaRySwrfrXVP8/pQ=="],
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.51.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.51.0", "@typescript-eslint/tsconfig-utils": "8.51.0", "@typescript-eslint/types": "8.51.0", "@typescript-eslint/visitor-keys": "8.51.0", "debug": "^4.3.4", "minimatch": "^9.0.4", "semver": "^7.6.0", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.2.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-1qNjGqFRmlq0VW5iVlcyHBbCjPB7y6SxpBkrbhNWMy/65ZoncXCEPJxkRZL8McrseNH6lFhaxCIaX+vBuFnRng=="],
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.50.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.50.0", "@typescript-eslint/tsconfig-utils": "8.50.0", "@typescript-eslint/types": "8.50.0", "@typescript-eslint/visitor-keys": "8.50.0", "debug": "^4.3.4", "minimatch": "^9.0.4", "semver": "^7.6.0", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-W7SVAGBR/IX7zm1t70Yujpbk+zdPq/u4soeFSknWFdXIFuWsBGBOUu/Tn/I6KHSKvSh91OiMuaSnYp3mtPt5IQ=="],
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.46.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.46.1", "@typescript-eslint/types": "8.46.1", "@typescript-eslint/typescript-estree": "8.46.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-vkYUy6LdZS7q1v/Gxb2Zs7zziuXN0wxqsetJdeZdRe/f5dwJFglmuvZBfTUivCtjH725C1jWCDfpadadD95EDQ=="],
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.51.0", "", { "dependencies": { "@typescript-eslint/types": "8.51.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-mM/JRQOzhVN1ykejrvwnBRV3+7yTKK8tVANVN3o1O0t0v7o+jqdVu9crPy5Y9dov15TJk/FTIgoUGHrTOVL3Zg=="],
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.50.0", "", { "dependencies": { "@typescript-eslint/types": "8.50.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-Xzmnb58+Db78gT/CCj/PVCvK+zxbnsw6F+O1oheYszJbBSdEjVhQi3C/Xttzxgi/GLmpvOggRs1RFpiJ8+c34Q=="],
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
@@ -795,7 +795,7 @@
"react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="],
"react-hook-form": ["react-hook-form@7.69.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-yt6ZGME9f4F6WHwevrvpAjh42HMvocuSnSIHUGycBqXIJdhqGSPQzTpGF+1NLREk/58IdPxEMfPcFCjlMhclGw=="],
"react-hook-form": ["react-hook-form@7.68.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-oNN3fjrZ/Xo40SWlHf1yCjlMK417JxoSJVUXQjGdvdRCU07NTFei1i1f8ApUAts+IVh14e4EdakeLEA+BEAs/Q=="],
"react-i18next": ["react-i18next@16.5.0", "", { "dependencies": { "@babel/runtime": "^7.27.6", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 25.6.2", "react": ">= 16.8.0", "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-IMpPTyCTKxEj8klCrLKUTIUa8uYTd851+jcu2fJuUB9Agkk9Qq8asw4omyeHVnOXHrLgQJGTm5zTvn8HpaPiqw=="],
@@ -863,7 +863,7 @@
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
"ts-api-utils": ["ts-api-utils@2.3.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-6eg3Y9SF7SsAvGzRHQvvc1skDAhwI4YQ32ui1scxD1Ccr0G5qIIbUBT3pFTKX8kmWIQClHobtUdNuaBgwdfdWg=="],
"ts-api-utils": ["ts-api-utils@2.1.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
@@ -873,7 +873,7 @@
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"typescript-eslint": ["typescript-eslint@8.51.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.51.0", "@typescript-eslint/parser": "8.51.0", "@typescript-eslint/typescript-estree": "8.51.0", "@typescript-eslint/utils": "8.51.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-jh8ZuM5oEh2PSdyQG9YAEM1TCGuWenLSuSUhf/irbVUNW9O5FhbFVONviN2TgMTBnUmyHv7E56rYnfLZK6TkiA=="],
"typescript-eslint": ["typescript-eslint@8.50.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.50.0", "@typescript-eslint/parser": "8.50.0", "@typescript-eslint/typescript-estree": "8.50.0", "@typescript-eslint/utils": "8.50.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-Q1/6yNUmCpH94fbgMUMg2/BSAr/6U7GBk61kZTv1/asghQOWOjTlp9K8mixS5NcJmm2creY+UFfGeW/+OcA64A=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
@@ -995,25 +995,25 @@
"@types/estree-jsx/@types/estree": ["@types/estree@1.0.7", "", {}, "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="],
"@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.51.0", "", { "dependencies": { "@typescript-eslint/types": "8.51.0", "@typescript-eslint/visitor-keys": "8.51.0" } }, "sha512-JhhJDVwsSx4hiOEQPeajGhCWgBMBwVkxC/Pet53EpBVs7zHHtayKefw1jtPaNRXpI9RA2uocdmpdfE7T+NrizA=="],
"@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.50.0", "", { "dependencies": { "@typescript-eslint/types": "8.50.0", "@typescript-eslint/visitor-keys": "8.50.0" } }, "sha512-xCwfuCZjhIqy7+HKxBLrDVT5q/iq7XBVBXLn57RTIIpelLtEIZHXAF/Upa3+gaCpeV1NNS5Z9A+ID6jn50VD4A=="],
"@typescript-eslint/eslint-plugin/@typescript-eslint/utils": ["@typescript-eslint/utils@8.51.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.51.0", "@typescript-eslint/types": "8.51.0", "@typescript-eslint/typescript-estree": "8.51.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-11rZYxSe0zabiKaCP2QAwRf/dnmgFgvTmeDTtZvUvXG3UuAdg/GU02NExmmIXzz3vLGgMdtrIosI84jITQOxUA=="],
"@typescript-eslint/eslint-plugin/@typescript-eslint/utils": ["@typescript-eslint/utils@8.50.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.50.0", "@typescript-eslint/types": "8.50.0", "@typescript-eslint/typescript-estree": "8.50.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-87KgUXET09CRjGCi2Ejxy3PULXna63/bMYv72tCAlDJC3Yqwln0HiFJ3VJMst2+mEtNtZu5oFvX4qJGjKsnAgg=="],
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.4", "", {}, "sha512-gJzzk+PQNznz8ysRrC0aOkBNVRBDtE1n53IqyqEf3PXrYwomFs5q4pGMizBMJF+ykh03insJ27hB8gSrD2Hn8A=="],
"@typescript-eslint/parser/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.51.0", "", { "dependencies": { "@typescript-eslint/types": "8.51.0", "@typescript-eslint/visitor-keys": "8.51.0" } }, "sha512-JhhJDVwsSx4hiOEQPeajGhCWgBMBwVkxC/Pet53EpBVs7zHHtayKefw1jtPaNRXpI9RA2uocdmpdfE7T+NrizA=="],
"@typescript-eslint/parser/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.50.0", "", { "dependencies": { "@typescript-eslint/types": "8.50.0", "@typescript-eslint/visitor-keys": "8.50.0" } }, "sha512-xCwfuCZjhIqy7+HKxBLrDVT5q/iq7XBVBXLn57RTIIpelLtEIZHXAF/Upa3+gaCpeV1NNS5Z9A+ID6jn50VD4A=="],
"@typescript-eslint/parser/@typescript-eslint/types": ["@typescript-eslint/types@8.51.0", "", {}, "sha512-TizAvWYFM6sSscmEakjY3sPqGwxZRSywSsPEiuZF6d5GmGD9Gvlsv0f6N8FvAAA0CD06l3rIcWNbsN1e5F/9Ag=="],
"@typescript-eslint/parser/@typescript-eslint/types": ["@typescript-eslint/types@8.50.0", "", {}, "sha512-iX1mgmGrXdANhhITbpp2QQM2fGehBse9LbTf0sidWK6yg/NE+uhV5dfU1g6EYPlcReYmkE9QLPq/2irKAmtS9w=="],
"@typescript-eslint/project-service/@typescript-eslint/types": ["@typescript-eslint/types@8.51.0", "", {}, "sha512-TizAvWYFM6sSscmEakjY3sPqGwxZRSywSsPEiuZF6d5GmGD9Gvlsv0f6N8FvAAA0CD06l3rIcWNbsN1e5F/9Ag=="],
"@typescript-eslint/project-service/@typescript-eslint/types": ["@typescript-eslint/types@8.50.0", "", {}, "sha512-iX1mgmGrXdANhhITbpp2QQM2fGehBse9LbTf0sidWK6yg/NE+uhV5dfU1g6EYPlcReYmkE9QLPq/2irKAmtS9w=="],
"@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.46.1", "", { "dependencies": { "@typescript-eslint/types": "8.46.1", "eslint-visitor-keys": "^4.2.1" } }, "sha512-ptkmIf2iDkNUjdeu2bQqhFPV1m6qTnFFjg7PPDjxKWaMaP0Z6I9l30Jr3g5QqbZGdw8YdYvLp+XnqnWWZOg/NA=="],
"@typescript-eslint/type-utils/@typescript-eslint/types": ["@typescript-eslint/types@8.51.0", "", {}, "sha512-TizAvWYFM6sSscmEakjY3sPqGwxZRSywSsPEiuZF6d5GmGD9Gvlsv0f6N8FvAAA0CD06l3rIcWNbsN1e5F/9Ag=="],
"@typescript-eslint/type-utils/@typescript-eslint/types": ["@typescript-eslint/types@8.50.0", "", {}, "sha512-iX1mgmGrXdANhhITbpp2QQM2fGehBse9LbTf0sidWK6yg/NE+uhV5dfU1g6EYPlcReYmkE9QLPq/2irKAmtS9w=="],
"@typescript-eslint/type-utils/@typescript-eslint/utils": ["@typescript-eslint/utils@8.51.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.51.0", "@typescript-eslint/types": "8.51.0", "@typescript-eslint/typescript-estree": "8.51.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-11rZYxSe0zabiKaCP2QAwRf/dnmgFgvTmeDTtZvUvXG3UuAdg/GU02NExmmIXzz3vLGgMdtrIosI84jITQOxUA=="],
"@typescript-eslint/type-utils/@typescript-eslint/utils": ["@typescript-eslint/utils@8.50.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.50.0", "@typescript-eslint/types": "8.50.0", "@typescript-eslint/typescript-estree": "8.50.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-87KgUXET09CRjGCi2Ejxy3PULXna63/bMYv72tCAlDJC3Yqwln0HiFJ3VJMst2+mEtNtZu5oFvX4qJGjKsnAgg=="],
"@typescript-eslint/typescript-estree/@typescript-eslint/types": ["@typescript-eslint/types@8.51.0", "", {}, "sha512-TizAvWYFM6sSscmEakjY3sPqGwxZRSywSsPEiuZF6d5GmGD9Gvlsv0f6N8FvAAA0CD06l3rIcWNbsN1e5F/9Ag=="],
"@typescript-eslint/typescript-estree/@typescript-eslint/types": ["@typescript-eslint/types@8.50.0", "", {}, "sha512-iX1mgmGrXdANhhITbpp2QQM2fGehBse9LbTf0sidWK6yg/NE+uhV5dfU1g6EYPlcReYmkE9QLPq/2irKAmtS9w=="],
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
@@ -1021,7 +1021,7 @@
"@typescript-eslint/utils/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.46.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.46.1", "@typescript-eslint/tsconfig-utils": "8.46.1", "@typescript-eslint/types": "8.46.1", "@typescript-eslint/visitor-keys": "8.46.1", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-uIifjT4s8cQKFQ8ZBXXyoUODtRoAd7F7+G8MKmtzj17+1UbdzFl52AzRyZRyKqPHhgzvXunnSckVu36flGy8cg=="],
"@typescript-eslint/visitor-keys/@typescript-eslint/types": ["@typescript-eslint/types@8.51.0", "", {}, "sha512-TizAvWYFM6sSscmEakjY3sPqGwxZRSywSsPEiuZF6d5GmGD9Gvlsv0f6N8FvAAA0CD06l3rIcWNbsN1e5F/9Ag=="],
"@typescript-eslint/visitor-keys/@typescript-eslint/types": ["@typescript-eslint/types@8.50.0", "", {}, "sha512-iX1mgmGrXdANhhITbpp2QQM2fGehBse9LbTf0sidWK6yg/NE+uhV5dfU1g6EYPlcReYmkE9QLPq/2irKAmtS9w=="],
"eslint-plugin-react-hooks/@babel/core": ["@babel/core@7.28.4", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.28.3", "@babel/helpers": "^7.28.4", "@babel/parser": "^7.28.4", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.4", "@babel/types": "^7.28.4", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA=="],
@@ -1039,7 +1039,7 @@
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
"typescript-eslint/@typescript-eslint/utils": ["@typescript-eslint/utils@8.51.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.51.0", "@typescript-eslint/types": "8.51.0", "@typescript-eslint/typescript-estree": "8.51.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-11rZYxSe0zabiKaCP2QAwRf/dnmgFgvTmeDTtZvUvXG3UuAdg/GU02NExmmIXzz3vLGgMdtrIosI84jITQOxUA=="],
"typescript-eslint/@typescript-eslint/utils": ["@typescript-eslint/utils@8.50.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.50.0", "@typescript-eslint/types": "8.50.0", "@typescript-eslint/typescript-estree": "8.50.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-87KgUXET09CRjGCi2Ejxy3PULXna63/bMYv72tCAlDJC3Yqwln0HiFJ3VJMst2+mEtNtZu5oFvX4qJGjKsnAgg=="],
"@babel/helper-module-imports/@babel/traverse/@babel/generator": ["@babel/generator@7.27.1", "", { "dependencies": { "@babel/parser": "^7.27.1", "@babel/types": "^7.27.1", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^3.0.2" } }, "sha512-UnJfnIpc/+JO0/+KRVQNGU+y5taA5vCbwN8+azkX6beii/ZF+enZJSOKo11ZSzGJjlNfJHfQtmQT8H+9TXPG2w=="],
@@ -1057,11 +1057,11 @@
"@eslint/eslintrc/espree/eslint-visitor-keys": ["eslint-visitor-keys@4.2.0", "", {}, "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw=="],
"@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager/@typescript-eslint/types": ["@typescript-eslint/types@8.51.0", "", {}, "sha512-TizAvWYFM6sSscmEakjY3sPqGwxZRSywSsPEiuZF6d5GmGD9Gvlsv0f6N8FvAAA0CD06l3rIcWNbsN1e5F/9Ag=="],
"@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager/@typescript-eslint/types": ["@typescript-eslint/types@8.50.0", "", {}, "sha512-iX1mgmGrXdANhhITbpp2QQM2fGehBse9LbTf0sidWK6yg/NE+uhV5dfU1g6EYPlcReYmkE9QLPq/2irKAmtS9w=="],
"@typescript-eslint/eslint-plugin/@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.51.0", "", {}, "sha512-TizAvWYFM6sSscmEakjY3sPqGwxZRSywSsPEiuZF6d5GmGD9Gvlsv0f6N8FvAAA0CD06l3rIcWNbsN1e5F/9Ag=="],
"@typescript-eslint/eslint-plugin/@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.50.0", "", {}, "sha512-iX1mgmGrXdANhhITbpp2QQM2fGehBse9LbTf0sidWK6yg/NE+uhV5dfU1g6EYPlcReYmkE9QLPq/2irKAmtS9w=="],
"@typescript-eslint/type-utils/@typescript-eslint/utils/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.51.0", "", { "dependencies": { "@typescript-eslint/types": "8.51.0", "@typescript-eslint/visitor-keys": "8.51.0" } }, "sha512-JhhJDVwsSx4hiOEQPeajGhCWgBMBwVkxC/Pet53EpBVs7zHHtayKefw1jtPaNRXpI9RA2uocdmpdfE7T+NrizA=="],
"@typescript-eslint/type-utils/@typescript-eslint/utils/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.50.0", "", { "dependencies": { "@typescript-eslint/types": "8.50.0", "@typescript-eslint/visitor-keys": "8.50.0" } }, "sha512-xCwfuCZjhIqy7+HKxBLrDVT5q/iq7XBVBXLn57RTIIpelLtEIZHXAF/Upa3+gaCpeV1NNS5Z9A+ID6jn50VD4A=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="],
@@ -1075,17 +1075,15 @@
"@typescript-eslint/utils/@typescript-eslint/typescript-estree/semver": ["semver@7.7.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="],
"@typescript-eslint/utils/@typescript-eslint/typescript-estree/ts-api-utils": ["ts-api-utils@2.1.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="],
"eslint-plugin-react-hooks/@babel/core/@babel/generator": ["@babel/generator@7.28.3", "", { "dependencies": { "@babel/parser": "^7.28.3", "@babel/types": "^7.28.2", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw=="],
"eslint-plugin-react-hooks/@babel/core/@babel/traverse": ["@babel/traverse@7.28.4", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.4", "@babel/template": "^7.27.2", "@babel/types": "^7.28.4", "debug": "^4.3.1" } }, "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ=="],
"eslint-plugin-react-hooks/@babel/core/@babel/types": ["@babel/types@7.28.4", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q=="],
"typescript-eslint/@typescript-eslint/utils/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.51.0", "", { "dependencies": { "@typescript-eslint/types": "8.51.0", "@typescript-eslint/visitor-keys": "8.51.0" } }, "sha512-JhhJDVwsSx4hiOEQPeajGhCWgBMBwVkxC/Pet53EpBVs7zHHtayKefw1jtPaNRXpI9RA2uocdmpdfE7T+NrizA=="],
"typescript-eslint/@typescript-eslint/utils/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.50.0", "", { "dependencies": { "@typescript-eslint/types": "8.50.0", "@typescript-eslint/visitor-keys": "8.50.0" } }, "sha512-xCwfuCZjhIqy7+HKxBLrDVT5q/iq7XBVBXLn57RTIIpelLtEIZHXAF/Upa3+gaCpeV1NNS5Z9A+ID6jn50VD4A=="],
"typescript-eslint/@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.51.0", "", {}, "sha512-TizAvWYFM6sSscmEakjY3sPqGwxZRSywSsPEiuZF6d5GmGD9Gvlsv0f6N8FvAAA0CD06l3rIcWNbsN1e5F/9Ag=="],
"typescript-eslint/@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.50.0", "", {}, "sha512-iX1mgmGrXdANhhITbpp2QQM2fGehBse9LbTf0sidWK6yg/NE+uhV5dfU1g6EYPlcReYmkE9QLPq/2irKAmtS9w=="],
"@babel/helper-module-imports/@babel/traverse/@babel/generator/@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.8", "", { "dependencies": { "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA=="],

View File

@@ -18,7 +18,7 @@
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-query": "^5.90.15",
"@tanstack/react-query": "^5.90.12",
"axios": "^1.13.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -30,7 +30,7 @@
"next-themes": "^0.4.6",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-hook-form": "^7.69.0",
"react-hook-form": "^7.68.0",
"react-i18next": "^16.5.0",
"react-markdown": "^10.1.0",
"react-router": "^7.11.0",
@@ -53,7 +53,7 @@
"prettier": "3.7.4",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.51.0",
"typescript-eslint": "^8.50.0",
"vite": "^7.3.0"
}
}

12
go.mod
View File

@@ -4,16 +4,16 @@ go 1.24.0
toolchain go1.24.3
replace github.com/traefik/paerser v0.2.2 => ./paerser
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-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.2.0
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
@@ -23,8 +23,8 @@ require (
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
modernc.org/sqlite v1.38.2
)
require (
@@ -60,6 +60,7 @@ require (
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/gabriel-vasile/mimetype v1.4.10 // indirect
github.com/gin-contrib/sse v1.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-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
@@ -71,6 +72,8 @@ require (
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
@@ -122,5 +125,6 @@ require (
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
)

21
go.sum
View File

@@ -101,6 +101,10 @@ 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=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4=
@@ -123,13 +127,15 @@ github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PU
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.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0=
github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
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=
@@ -157,6 +163,10 @@ github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh6
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
@@ -263,6 +273,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
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/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=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
@@ -349,6 +361,7 @@ 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=
google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c/go.mod h1:ea2MjsO70ssTfCjiwHgI0ZFqcw45Ksuk2ckf9G468GA=
@@ -367,6 +380,8 @@ 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=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=

View File

@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS "oidc_clients";

View 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
);

View File

@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS "oidc_keys";

View 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
);

View File

@@ -0,0 +1,3 @@
DROP INDEX IF EXISTS "idx_oidc_auth_codes_expires_at";
DROP TABLE IF EXISTS "oidc_authorization_codes";

View File

@@ -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");

View File

@@ -14,10 +14,11 @@ import (
"github.com/steveiliop56/tinyauth/internal/config"
"github.com/steveiliop56/tinyauth/internal/controller"
"github.com/steveiliop56/tinyauth/internal/repository"
"github.com/steveiliop56/tinyauth/internal/model"
"github.com/steveiliop56/tinyauth/internal/utils"
"github.com/rs/zerolog/log"
"gorm.io/gorm"
)
type BootstrapApp struct {
@@ -107,18 +108,8 @@ func (app *BootstrapApp) Setup() error {
log.Trace().Str("csrfCookieName", app.context.csrfCookieName).Msg("CSRF cookie name")
log.Trace().Str("redirectCookieName", app.context.redirectCookieName).Msg("Redirect cookie name")
// Database
db, err := app.setupDatabase(app.config.DatabasePath)
if err != nil {
return fmt.Errorf("failed to setup database: %w", err)
}
// Queries
queries := repository.New(db)
// Services
services, err := app.initServices(queries)
services, err := app.initServices()
if err != nil {
return fmt.Errorf("failed to initialize services: %w", err)
@@ -164,9 +155,9 @@ func (app *BootstrapApp) Setup() error {
return fmt.Errorf("failed to setup routes: %w", err)
}
// Start db cleanup routine
// Start DB cleanup routine
log.Debug().Msg("Starting database cleanup routine")
go app.dbCleanup(queries)
go app.dbCleanup(services.databaseService.GetDatabase())
// If analytics are not disabled, start heartbeat
if !app.config.DisableAnalytics {
@@ -256,16 +247,16 @@ func (app *BootstrapApp) heartbeat() {
}
}
func (app *BootstrapApp) dbCleanup(queries *repository.Queries) {
func (app *BootstrapApp) dbCleanup(db *gorm.DB) {
ticker := time.NewTicker(time.Duration(30) * time.Minute)
defer ticker.Stop()
ctx := context.Background()
for ; true; <-ticker.C {
log.Debug().Msg("Cleaning up old database sessions")
err := queries.DeleteExpiredSessions(ctx, time.Now().Unix())
_, err := gorm.G[model.Session](db).Where("expiry < ?", time.Now().Unix()).Delete(ctx)
if err != nil {
log.Error().Err(err).Msg("Failed to clean up old database sessions")
log.Error().Err(err).Msg("Failed to cleanup old sessions")
}
}
}

View File

@@ -1,53 +0,0 @@
package bootstrap
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"github.com/steveiliop56/tinyauth/internal/assets"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/sqlite3"
"github.com/golang-migrate/migrate/v4/source/iofs"
_ "modernc.org/sqlite"
)
func (app *BootstrapApp) setupDatabase(databasePath string) (*sql.DB, error) {
dir := filepath.Dir(databasePath)
if err := os.MkdirAll(dir, 0750); err != nil {
return nil, fmt.Errorf("failed to create database directory %s: %w", dir, err)
}
db, err := sql.Open("sqlite", databasePath)
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
migrations, err := iofs.New(assets.Migrations, "migrations")
if err != nil {
return nil, fmt.Errorf("failed to create migrations: %w", err)
}
target, err := sqlite3.WithInstance(db, &sqlite3.Config{})
if err != nil {
return nil, fmt.Errorf("failed to create sqlite3 instance: %w", err)
}
migrator, err := migrate.NewWithInstance("iofs", migrations, "sqlite3", target)
if err != nil {
return nil, fmt.Errorf("failed to create migrator: %w", err)
}
if err := migrator.Up(); err != nil && err != migrate.ErrNoChange {
return nil, fmt.Errorf("failed to migrate database: %w", err)
}
return db, nil
}

View File

@@ -102,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
}

View File

@@ -1,7 +1,6 @@
package bootstrap
import (
"github.com/steveiliop56/tinyauth/internal/repository"
"github.com/steveiliop56/tinyauth/internal/service"
"github.com/rs/zerolog/log"
@@ -10,14 +9,28 @@ import (
type Services struct {
accessControlService *service.AccessControlsService
authService *service.AuthService
databaseService *service.DatabaseService
dockerService *service.DockerService
ldapService *service.LdapService
oauthBrokerService *service.OAuthBrokerService
oidcService *service.OIDCService
}
func (app *BootstrapApp) initServices(queries *repository.Queries) (Services, error) {
func (app *BootstrapApp) initServices() (Services, error) {
services := Services{}
databaseService := service.NewDatabaseService(service.DatabaseServiceConfig{
DatabasePath: app.config.DatabasePath,
})
err := databaseService.Init()
if err != nil {
return Services{}, err
}
services.databaseService = databaseService
ldapService := service.NewLdapService(service.LdapServiceConfig{
Address: app.config.Ldap.Address,
BindDN: app.config.Ldap.BindDN,
@@ -27,7 +40,7 @@ func (app *BootstrapApp) initServices(queries *repository.Queries) (Services, er
SearchFilter: app.config.Ldap.SearchFilter,
})
err := ldapService.Init()
err = ldapService.Init()
if err == nil {
services.ldapService = ldapService
@@ -45,7 +58,7 @@ func (app *BootstrapApp) initServices(queries *repository.Queries) (Services, er
services.dockerService = dockerService
accessControlsService := service.NewAccessControlsService(dockerService, app.config.Apps)
accessControlsService := service.NewAccessControlsService(dockerService)
err = accessControlsService.Init()
@@ -64,7 +77,7 @@ func (app *BootstrapApp) initServices(queries *repository.Queries) (Services, er
LoginTimeout: app.config.Auth.LoginTimeout,
LoginMaxRetries: app.config.Auth.LoginMaxRetries,
SessionCookieName: app.context.sessionCookieName,
}, dockerService, ldapService, queries)
}, dockerService, ldapService, databaseService.GetDatabase())
err = authService.Init()
@@ -84,5 +97,39 @@ func (app *BootstrapApp) initServices(queries *repository.Queries) (Services, er
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
}

View File

@@ -25,8 +25,8 @@ type Config struct {
LogJSON bool `description:"Enable JSON formatted logs." yaml:"logJSON"`
Server ServerConfig `description:"Server configuration." yaml:"server"`
Auth AuthConfig `description:"Authentication configuration." yaml:"auth"`
Apps map[string]App `description:"Application ACLs configuration." yaml:"apps"`
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"`
@@ -69,6 +69,24 @@ type LdapConfig struct {
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:"-"`
}
@@ -157,55 +175,61 @@ type RedirectQuery struct {
RedirectURI string `url:"redirect_uri"`
}
// ACLs
// Labels
type Apps struct {
Apps map[string]App `description:"App ACLs configuration." yaml:"apps"`
Apps map[string]App
}
type App struct {
Config AppConfig `description:"App configuration." yaml:"config"`
Users AppUsers `description:"User access configuration." yaml:"users"`
OAuth AppOAuth `description:"OAuth access configuration." yaml:"oauth"`
IP AppIP `description:"IP access configuration." yaml:"ip"`
Response AppResponse `description:"Response customization." yaml:"response"`
Path AppPath `description:"Path access configuration." yaml:"path"`
Config AppConfig
Users AppUsers
OAuth AppOAuth
IP AppIP
Response AppResponse
Path AppPath
}
type AppConfig struct {
Domain string `description:"The domain of the app." yaml:"domain"`
Domain string
}
type AppUsers struct {
Allow string `description:"Comma-separated list of allowed users." yaml:"allow"`
Block string `description:"Comma-separated list of blocked users." yaml:"block"`
Allow string
Block string
}
type AppOAuth struct {
Whitelist string `description:"Comma-separated list of allowed OAuth groups." yaml:"whitelist"`
Groups string `description:"Comma-separated list of required OAuth groups." yaml:"groups"`
Whitelist string
Groups string
}
type AppIP struct {
Allow []string `description:"List of allowed IPs or CIDR ranges." yaml:"allow"`
Block []string `description:"List of blocked IPs or CIDR ranges." yaml:"block"`
Bypass []string `description:"List of IPs or CIDR ranges that bypass authentication." yaml:"bypass"`
Allow []string
Block []string
Bypass []string
}
type AppResponse struct {
Headers []string `description:"Custom headers to add to the response." yaml:"headers"`
BasicAuth AppBasicAuth `description:"Basic authentication for the app." yaml:"basicAuth"`
Headers []string
BasicAuth AppBasicAuth
}
type AppBasicAuth struct {
Username string `description:"Basic auth username." yaml:"username"`
Password string `description:"Basic auth password." yaml:"password"`
PasswordFile string `description:"Path to the file containing the basic auth password." yaml:"passwordFile"`
Username string
Password string
PasswordFile string
}
type AppPath struct {
Allow string `description:"Comma-separated list of allowed paths." yaml:"allow"`
Block string `description:"Comma-separated list of blocked paths." yaml:"block"`
Allow string
Block string
}
// Flags
type Providers struct {
Providers map[string]OAuthServiceConfig
}
// API server

View 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)
}

View File

@@ -41,7 +41,7 @@ func setupProxyController(t *testing.T, middlewares *[]gin.HandlerFunc) (*gin.En
assert.NilError(t, dockerService.Init())
// Access controls
accessControlsService := service.NewAccessControlsService(dockerService, map[string]config.App{})
accessControlsService := service.NewAccessControlsService(dockerService)
assert.NilError(t, accessControlsService.Init())

View 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"
}

View 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"
}

View 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"
}

View File

@@ -0,0 +1,14 @@
package model
type Session struct {
UUID string `gorm:"column:uuid;primaryKey"`
Username string `gorm:"column:username"`
Email string `gorm:"column:email"`
Name string `gorm:"column:name"`
Provider string `gorm:"column:provider"`
TOTPPending bool `gorm:"column:totp_pending"`
OAuthGroups string `gorm:"column:oauth_groups"`
Expiry int64 `gorm:"column:expiry"`
OAuthName string `gorm:"column:oauth_name"`
OAuthSub string `gorm:"column:oauth_sub"`
}

View File

@@ -1,31 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
package repository
import (
"context"
"database/sql"
)
type DBTX interface {
ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
PrepareContext(context.Context, string) (*sql.Stmt, error)
QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
QueryRowContext(context.Context, string, ...interface{}) *sql.Row
}
func New(db DBTX) *Queries {
return &Queries{db: db}
}
type Queries struct {
db DBTX
}
func (q *Queries) WithTx(tx *sql.Tx) *Queries {
return &Queries{
db: tx,
}
}

View File

@@ -1,18 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
package repository
type Session struct {
UUID string
Username string
Email string
Name string
Provider string
TotpPending bool
OAuthGroups string
Expiry int64
OAuthName string
OAuthSub string
}

View File

@@ -1,170 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: query.sql
package repository
import (
"context"
)
const createSession = `-- name: CreateSession :one
INSERT INTO sessions (
"uuid",
"username",
"email",
"name",
"provider",
"totp_pending",
"oauth_groups",
"expiry",
"oauth_name",
"oauth_sub"
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?
)
RETURNING uuid, username, email, name, provider, totp_pending, oauth_groups, expiry, oauth_name, oauth_sub
`
type CreateSessionParams struct {
UUID string
Username string
Email string
Name string
Provider string
TotpPending bool
OAuthGroups string
Expiry int64
OAuthName string
OAuthSub string
}
func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error) {
row := q.db.QueryRowContext(ctx, createSession,
arg.UUID,
arg.Username,
arg.Email,
arg.Name,
arg.Provider,
arg.TotpPending,
arg.OAuthGroups,
arg.Expiry,
arg.OAuthName,
arg.OAuthSub,
)
var i Session
err := row.Scan(
&i.UUID,
&i.Username,
&i.Email,
&i.Name,
&i.Provider,
&i.TotpPending,
&i.OAuthGroups,
&i.Expiry,
&i.OAuthName,
&i.OAuthSub,
)
return i, err
}
const deleteExpiredSessions = `-- name: DeleteExpiredSessions :exec
DELETE FROM "sessions"
WHERE "expiry" < ?
`
func (q *Queries) DeleteExpiredSessions(ctx context.Context, expiry int64) error {
_, err := q.db.ExecContext(ctx, deleteExpiredSessions, expiry)
return err
}
const deleteSession = `-- name: DeleteSession :exec
DELETE FROM "sessions"
WHERE "uuid" = ?
`
func (q *Queries) DeleteSession(ctx context.Context, uuid string) error {
_, err := q.db.ExecContext(ctx, deleteSession, uuid)
return err
}
const getSession = `-- name: GetSession :one
SELECT uuid, username, email, name, provider, totp_pending, oauth_groups, expiry, oauth_name, oauth_sub FROM "sessions"
WHERE "uuid" = ?
`
func (q *Queries) GetSession(ctx context.Context, uuid string) (Session, error) {
row := q.db.QueryRowContext(ctx, getSession, uuid)
var i Session
err := row.Scan(
&i.UUID,
&i.Username,
&i.Email,
&i.Name,
&i.Provider,
&i.TotpPending,
&i.OAuthGroups,
&i.Expiry,
&i.OAuthName,
&i.OAuthSub,
)
return i, err
}
const updateSession = `-- name: UpdateSession :one
UPDATE "sessions" SET
"username" = ?,
"email" = ?,
"name" = ?,
"provider" = ?,
"totp_pending" = ?,
"oauth_groups" = ?,
"expiry" = ?,
"oauth_name" = ?,
"oauth_sub" = ?
WHERE "uuid" = ?
RETURNING uuid, username, email, name, provider, totp_pending, oauth_groups, expiry, oauth_name, oauth_sub
`
type UpdateSessionParams struct {
Username string
Email string
Name string
Provider string
TotpPending bool
OAuthGroups string
Expiry int64
OAuthName string
OAuthSub string
UUID string
}
func (q *Queries) UpdateSession(ctx context.Context, arg UpdateSessionParams) (Session, error) {
row := q.db.QueryRowContext(ctx, updateSession,
arg.Username,
arg.Email,
arg.Name,
arg.Provider,
arg.TotpPending,
arg.OAuthGroups,
arg.Expiry,
arg.OAuthName,
arg.OAuthSub,
arg.UUID,
)
var i Session
err := row.Scan(
&i.UUID,
&i.Username,
&i.Email,
&i.Name,
&i.Provider,
&i.TotpPending,
&i.OAuthGroups,
&i.Expiry,
&i.OAuthName,
&i.OAuthSub,
)
return i, err
}

View File

@@ -1,54 +1,122 @@
package service
import (
"errors"
"strings"
"github.com/rs/zerolog/log"
"github.com/steveiliop56/tinyauth/internal/config"
)
/*
Environment variable/flag based ACLs are disabled until v5 due to a technical challenge
with the current parsing logic.
The current parser works for simple OAuth provider configs like:
- PROVIDERS_MY_AMAZING_PROVIDER_CLIENT_ID
However, it breaks down when handling nested structs required for ACLs. The custom parsing
solution that worked for v4 OAuth providers is incompatible with the ACL parsing logic,
making the codebase unmaintainable and fragile.
A solution is being considered for v5 that would standardize the format to something like:
- TINYAUTH_PROVIDERS_GOOGLE_CLIENTSECRET
- TINYAUTH_APPS_MYAPP_CONFIG_DOMAIN
This would allow the Traefik parser to handle everything consistently, but requires a
config migration. Until this is resolved, environment-based ACLs are disabled and only
Docker label-based ACLs are supported.
See: https://discord.com/channels/1337450123600465984/1337459086270271538/1434986689935179838 for more information
*/
type AccessControlsService struct {
docker *DockerService
static map[string]config.App
// envACLs config.Apps
}
func NewAccessControlsService(docker *DockerService, static map[string]config.App) *AccessControlsService {
func NewAccessControlsService(docker *DockerService) *AccessControlsService {
return &AccessControlsService{
docker: docker,
static: static,
}
}
func (acls *AccessControlsService) Init() error {
return nil // No initialization needed
// acls.envACLs = config.Apps{}
// env := os.Environ()
// appEnvVars := []string{}
// for _, e := range env {
// if strings.HasPrefix(e, "TINYAUTH_APPS_") {
// appEnvVars = append(appEnvVars, e)
// }
// }
// err := acls.loadEnvACLs(appEnvVars)
// if err != nil {
// return err
// }
// return nil
return nil
}
func (acls *AccessControlsService) lookupStaticACLs(domain string) (config.App, error) {
for app, config := range acls.static {
if config.Config.Domain == domain {
log.Debug().Str("name", app).Msg("Found matching container by domain")
return config, nil
}
// func (acls *AccessControlsService) loadEnvACLs(appEnvVars []string) error {
// if len(appEnvVars) == 0 {
// return nil
// }
if strings.SplitN(domain, ".", 2)[0] == app {
log.Debug().Str("name", app).Msg("Found matching container by app name")
return config, nil
}
}
return config.App{}, errors.New("no results")
}
// envAcls := map[string]string{}
func (acls *AccessControlsService) GetAccessControls(domain string) (config.App, error) {
// First check in the static config
app, err := acls.lookupStaticACLs(domain)
// for _, e := range appEnvVars {
// parts := strings.SplitN(e, "=", 2)
// if len(parts) != 2 {
// continue
// }
if err == nil {
log.Debug().Msg("Using ACls from static configuration")
return app, nil
}
// key := parts[0]
// key = strings.ToLower(key)
// key = strings.ReplaceAll(key, "_", ".")
// value := parts[1]
// envAcls[key] = value
// }
// apps, err := decoders.DecodeLabels(envAcls)
// if err != nil {
// return err
// }
// acls.envACLs = apps
// return nil
// }
// func (acls *AccessControlsService) lookupEnvACLs(appDomain string) *config.App {
// if len(acls.envACLs.Apps) == 0 {
// return nil
// }
// for appName, appACLs := range acls.envACLs.Apps {
// if appACLs.Config.Domain == appDomain {
// return &appACLs
// }
// if strings.SplitN(appDomain, ".", 2)[0] == appName {
// return &appACLs
// }
// }
// return nil
// }
func (acls *AccessControlsService) GetAccessControls(appDomain string) (config.App, error) {
// First check environment variables
// envACLs := acls.lookupEnvACLs(appDomain)
// if envACLs != nil {
// log.Debug().Str("domain", appDomain).Msg("Found matching access controls in environment variables")
// return *envACLs, nil
// }
// Fallback to Docker labels
log.Debug().Msg("Falling back to Docker labels for ACLs")
return acls.docker.GetLabels(domain)
return acls.docker.GetLabels(appDomain)
}

View File

@@ -1,8 +1,6 @@
package service
import (
"context"
"database/sql"
"errors"
"fmt"
"regexp"
@@ -11,13 +9,14 @@ import (
"time"
"github.com/steveiliop56/tinyauth/internal/config"
"github.com/steveiliop56/tinyauth/internal/repository"
"github.com/steveiliop56/tinyauth/internal/model"
"github.com/steveiliop56/tinyauth/internal/utils"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
type LoginAttempt struct {
@@ -43,17 +42,16 @@ type AuthService struct {
loginAttempts map[string]*LoginAttempt
loginMutex sync.RWMutex
ldap *LdapService
queries *repository.Queries
ctx context.Context
database *gorm.DB
}
func NewAuthService(config AuthServiceConfig, docker *DockerService, ldap *LdapService, queries *repository.Queries) *AuthService {
func NewAuthService(config AuthServiceConfig, docker *DockerService, ldap *LdapService, database *gorm.DB) *AuthService {
return &AuthService{
config: config,
docker: docker,
loginAttempts: make(map[string]*LoginAttempt),
ldap: ldap,
queries: queries,
database: database,
}
}
@@ -205,20 +203,20 @@ func (auth *AuthService) CreateSessionCookie(c *gin.Context, data *config.Sessio
expiry = auth.config.SessionExpiry
}
session := repository.CreateSessionParams{
session := model.Session{
UUID: uuid.String(),
Username: data.Username,
Email: data.Email,
Name: data.Name,
Provider: data.Provider,
TotpPending: data.TotpPending,
TOTPPending: data.TotpPending,
OAuthGroups: data.OAuthGroups,
Expiry: time.Now().Add(time.Duration(expiry) * time.Second).Unix(),
OAuthName: data.OAuthName,
OAuthSub: data.OAuthSub,
}
_, err = auth.queries.CreateSession(c, session)
err = gorm.G[model.Session](auth.database).Create(c, &session)
if err != nil {
return err
@@ -236,7 +234,7 @@ func (auth *AuthService) RefreshSessionCookie(c *gin.Context) error {
return err
}
session, err := auth.queries.GetSession(auth.ctx, cookie)
session, err := gorm.G[model.Session](auth.database).Where("uuid = ?", cookie).First(c)
if err != nil {
return err
@@ -250,17 +248,8 @@ func (auth *AuthService) RefreshSessionCookie(c *gin.Context) error {
newExpiry := currentTime + int64(time.Hour.Seconds())
_, err = auth.queries.UpdateSession(c, repository.UpdateSessionParams{
Username: session.Username,
Email: session.Email,
Name: session.Name,
Provider: session.Provider,
TotpPending: session.TotpPending,
OAuthGroups: session.OAuthGroups,
Expiry: newExpiry,
OAuthName: session.OAuthName,
OAuthSub: session.OAuthSub,
UUID: session.UUID,
_, err = gorm.G[model.Session](auth.database).Where("uuid = ?", cookie).Updates(c, model.Session{
Expiry: newExpiry,
})
if err != nil {
@@ -279,7 +268,7 @@ func (auth *AuthService) DeleteSessionCookie(c *gin.Context) error {
return err
}
err = auth.queries.DeleteSession(auth.ctx, cookie)
_, err = gorm.G[model.Session](auth.database).Where("uuid = ?", cookie).Delete(c)
if err != nil {
return err
@@ -297,20 +286,20 @@ func (auth *AuthService) GetSessionCookie(c *gin.Context) (config.SessionCookie,
return config.SessionCookie{}, err
}
session, err := auth.queries.GetSession(auth.ctx, cookie)
session, err := gorm.G[model.Session](auth.database).Where("uuid = ?", cookie).First(c)
if err != nil {
return config.SessionCookie{}, err
}
if errors.Is(err, sql.ErrNoRows) {
if errors.Is(err, gorm.ErrRecordNotFound) {
return config.SessionCookie{}, fmt.Errorf("session not found")
}
currentTime := time.Now().Unix()
if currentTime > session.Expiry {
err = auth.queries.DeleteSession(auth.ctx, cookie)
_, 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")
}
@@ -323,7 +312,7 @@ func (auth *AuthService) GetSessionCookie(c *gin.Context) (config.SessionCookie,
Email: session.Email,
Name: session.Name,
Provider: session.Provider,
TotpPending: session.TotpPending,
TotpPending: session.TOTPPending,
OAuthGroups: session.OAuthGroups,
OAuthName: session.OAuthName,
OAuthSub: session.OAuthSub,

View File

@@ -0,0 +1,92 @@
package service
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"github.com/steveiliop56/tinyauth/internal/assets"
"github.com/glebarez/sqlite"
"github.com/golang-migrate/migrate/v4"
sqliteMigrate "github.com/golang-migrate/migrate/v4/database/sqlite3"
"github.com/golang-migrate/migrate/v4/source/iofs"
"gorm.io/gorm"
)
type DatabaseServiceConfig struct {
DatabasePath string
}
type DatabaseService struct {
config DatabaseServiceConfig
database *gorm.DB
}
func NewDatabaseService(config DatabaseServiceConfig) *DatabaseService {
return &DatabaseService{
config: config,
}
}
func (ds *DatabaseService) Init() error {
dbPath := ds.config.DatabasePath
if dbPath == "" {
dbPath = "/data/tinyauth.db"
}
dir := filepath.Dir(dbPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create database directory %s: %w", dir, err)
}
gormDB, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
if err != nil {
return err
}
sqlDB, err := gormDB.DB()
if err != nil {
return err
}
sqlDB.SetMaxOpenConns(1)
err = ds.migrateDatabase(sqlDB)
if err != nil && err != migrate.ErrNoChange {
return err
}
ds.database = gormDB
return nil
}
func (ds *DatabaseService) migrateDatabase(sqlDB *sql.DB) error {
data, err := iofs.New(assets.Migrations, "migrations")
if err != nil {
return err
}
target, err := sqliteMigrate.WithInstance(sqlDB, &sqliteMigrate.Config{})
if err != nil {
return err
}
migrator, err := migrate.NewWithInstance("iofs", data, "tinyauth", target)
if err != nil {
return err
}
return migrator.Up()
}
func (ds *DatabaseService) GetDatabase() *gorm.DB {
return ds.database
}

View 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 ""
}

View File

@@ -2,6 +2,7 @@ package utils
import (
"errors"
"fmt"
"net"
"net/url"
"strings"
@@ -22,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:], ".")
@@ -36,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

View File

@@ -16,18 +16,25 @@ func (f *FileLoader) Load(args []string, cmd *cli.Command) (bool, error) {
return false, err
}
// I guess we are using traefik as the root name
configFileFlag := "traefik.experimental.configFile"
// 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 _, ok := flags[configFileFlag]; !ok {
if configFilePath == "" {
return false, nil
}
log.Warn().Msg("Using experimental file config loader, this feature is experimental and may change or be removed in future releases")
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(flags[configFileFlag], cmd.Configuration)
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
}

Submodule paerser deleted from 7e1b633ba9

View File

@@ -1,95 +0,0 @@
diff --git a/env/env_test.go b/env/env_test.go
index 7045569..365dc00 100644
--- a/env/env_test.go
+++ b/env/env_test.go
@@ -166,6 +166,38 @@ func TestDecode(t *testing.T) {
Foo: &struct{ Field string }{},
},
},
+ {
+ desc: "map under the root key",
+ environ: []string{"TRAEFIK_FOO_BAR_FOOBAR_BARFOO=foo"},
+ element: &struct {
+ Foo map[string]struct {
+ Foobar struct {
+ Barfoo string
+ }
+ }
+ }{},
+ expected: &struct {
+ Foo map[string]struct {
+ Foobar struct {
+ Barfoo string
+ }
+ }
+ }{
+ Foo: map[string]struct {
+ Foobar struct {
+ Barfoo string
+ }
+ }{
+ "bar": {
+ Foobar: struct {
+ Barfoo string
+ }{
+ Barfoo: "foo",
+ },
+ },
+ },
+ },
+ },
}
for _, test := range testCases {
diff --git a/parser/nodes_metadata.go b/parser/nodes_metadata.go
index 36946c1..0279705 100644
--- a/parser/nodes_metadata.go
+++ b/parser/nodes_metadata.go
@@ -75,8 +75,13 @@ func (m metadata) add(rootType reflect.Type, node *Node) error {
node.Kind = fType.Kind()
node.Tag = field.Tag
- if fType.Kind() == reflect.Struct || fType.Kind() == reflect.Pointer && fType.Elem().Kind() == reflect.Struct ||
- fType.Kind() == reflect.Map {
+ if node.Kind == reflect.String && len(node.Children) > 0 {
+ fType = reflect.TypeOf(struct{}{})
+ node.Kind = reflect.Struct
+ }
+
+ if node.Kind == reflect.Struct || node.Kind == reflect.Pointer && fType.Elem().Kind() == reflect.Struct ||
+ node.Kind == reflect.Map {
if len(node.Children) == 0 && !(field.Tag.Get(m.TagName) == TagLabelAllowEmpty || field.Tag.Get(m.TagName) == "-") {
return fmt.Errorf("%s cannot be a standalone element (type %s)", node.Name, fType)
}
@@ -90,11 +95,11 @@ func (m metadata) add(rootType reflect.Type, node *Node) error {
return nil
}
- if fType.Kind() == reflect.Struct || fType.Kind() == reflect.Pointer && fType.Elem().Kind() == reflect.Struct {
+ if node.Kind == reflect.Struct || node.Kind == reflect.Pointer && fType.Elem().Kind() == reflect.Struct {
return m.browseChildren(fType, node)
}
- if fType.Kind() == reflect.Map {
+ if node.Kind == reflect.Map {
if fType.Elem().Kind() == reflect.Interface {
addRawValue(node)
return nil
@@ -115,7 +120,7 @@ func (m metadata) add(rootType reflect.Type, node *Node) error {
return nil
}
- if fType.Kind() == reflect.Slice {
+ if node.Kind == reflect.Slice {
if m.AllowSliceAsStruct && field.Tag.Get(TagLabelSliceAsStruct) != "" {
return m.browseChildren(fType.Elem(), node)
}
@@ -129,7 +134,7 @@ func (m metadata) add(rootType reflect.Type, node *Node) error {
return nil
}
- return fmt.Errorf("invalid node %s: %v", node.Name, fType.Kind())
+ return fmt.Errorf("invalid node %s: %v", node.Name, node.Kind)
}
func (m metadata) findTypedField(rType reflect.Type, node *Node) (reflect.StructField, error) {

View File

@@ -1,42 +0,0 @@
-- name: CreateSession :one
INSERT INTO sessions (
"uuid",
"username",
"email",
"name",
"provider",
"totp_pending",
"oauth_groups",
"expiry",
"oauth_name",
"oauth_sub"
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?
)
RETURNING *;
-- name: GetSession :one
SELECT * FROM "sessions"
WHERE "uuid" = ?;
-- name: DeleteSession :exec
DELETE FROM "sessions"
WHERE "uuid" = ?;
-- name: UpdateSession :one
UPDATE "sessions" SET
"username" = ?,
"email" = ?,
"name" = ?,
"provider" = ?,
"totp_pending" = ?,
"oauth_groups" = ?,
"expiry" = ?,
"oauth_name" = ?,
"oauth_sub" = ?
WHERE "uuid" = ?
RETURNING *;
-- name: DeleteExpiredSessions :exec
DELETE FROM "sessions"
WHERE "expiry" < ?;

View File

@@ -1,12 +0,0 @@
CREATE TABLE IF NOT EXISTS "sessions" (
"uuid" TEXT NOT NULL PRIMARY KEY UNIQUE,
"username" TEXT NOT NULL,
"email" TEXT NOT NULL,
"name" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"totp_pending" BOOLEAN NOT NULL,
"oauth_groups" TEXT NULL,
"expiry" INTEGER NOT NULL,
"oauth_name" TEXT NULL,
"oauth_sub" TEXT NULL
);

View File

@@ -1,21 +0,0 @@
version: "2"
sql:
- engine: "sqlite"
queries: "query.sql"
schema: "schema.sql"
gen:
go:
package: "repository"
out: "internal/repository"
rename:
uuid: "UUID"
oauth_groups: "OAuthGroups"
oauth_name: "OAuthName"
oauth_sub: "OAuthSub"
overrides:
- column: "sessions.oauth_groups"
go_type: "string"
- column: "sessions.oauth_name"
go_type: "string"
- column: "sessions.oauth_sub"
go_type: "string"

14
validation/Dockerfile Normal file
View 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
View 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
View 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"

View 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

View 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
View 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
View 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
View 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()