mirror of
https://github.com/steveiliop56/tinyauth.git
synced 2026-06-29 14:50:16 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 474e297d9d | |||
| 23af559f2f | |||
| cd51263428 | |||
| 24f166551e | |||
| e4c5f14d8c | |||
| ed97021c19 |
+2
-6
@@ -32,6 +32,8 @@ TINYAUTH_SERVER_PORT=3000
|
|||||||
TINYAUTH_SERVER_ADDRESS="0.0.0.0"
|
TINYAUTH_SERVER_ADDRESS="0.0.0.0"
|
||||||
# The path to the Unix socket.
|
# The path to the Unix socket.
|
||||||
TINYAUTH_SERVER_SOCKETPATH=
|
TINYAUTH_SERVER_SOCKETPATH=
|
||||||
|
# Enable listening on both TCP and Unix socket at the same time.
|
||||||
|
TINYAUTH_SERVER_CONCURRENTLISTENERSENABLED=false
|
||||||
|
|
||||||
# auth config
|
# auth config
|
||||||
|
|
||||||
@@ -97,8 +99,6 @@ TINYAUTH_AUTH_SESSIONMAXLIFETIME=0
|
|||||||
TINYAUTH_AUTH_LOGINTIMEOUT=300
|
TINYAUTH_AUTH_LOGINTIMEOUT=300
|
||||||
# Maximum login retries.
|
# Maximum login retries.
|
||||||
TINYAUTH_AUTH_LOGINMAXRETRIES=3
|
TINYAUTH_AUTH_LOGINMAXRETRIES=3
|
||||||
# Enable lockdown mode after maximum login retries. Lockdown mode limit is calculated automatically.
|
|
||||||
TINYAUTH_AUTH_LOCKDOWNENABLED=true
|
|
||||||
# Comma-separated list of trusted proxy addresses.
|
# Comma-separated list of trusted proxy addresses.
|
||||||
TINYAUTH_AUTH_TRUSTEDPROXIES=
|
TINYAUTH_AUTH_TRUSTEDPROXIES=
|
||||||
# ACL policy for allow-by-default or deny-by-default, available options are allow and deny, default is allow.
|
# ACL policy for allow-by-default or deny-by-default, available options are allow and deny, default is allow.
|
||||||
@@ -254,7 +254,3 @@ TINYAUTH_TAILSCALE_HOSTNAME=
|
|||||||
TINYAUTH_TAILSCALE_AUTHKEY=
|
TINYAUTH_TAILSCALE_AUTHKEY=
|
||||||
# Use ephemeral Tailscale node.
|
# Use ephemeral Tailscale node.
|
||||||
TINYAUTH_TAILSCALE_EPHEMERAL=false
|
TINYAUTH_TAILSCALE_EPHEMERAL=false
|
||||||
# Enable Tailscale Funnel.
|
|
||||||
TINYAUTH_TAILSCALE_FUNNEL=false
|
|
||||||
# Listen on the Tailscale address instead of standard address.
|
|
||||||
TINYAUTH_TAILSCALE_LISTEN=false
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||||
@@ -21,7 +21,7 @@ jobs:
|
|||||||
package_json_file: ./frontend/package.json
|
package_json_file: ./frontend/package.json
|
||||||
|
|
||||||
- name: Setup go
|
- name: Setup go
|
||||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
|
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||||
with:
|
with:
|
||||||
go-version: "^1.26.4"
|
go-version: "^1.26.4"
|
||||||
|
|
||||||
@@ -62,6 +62,6 @@ jobs:
|
|||||||
run: go test -coverprofile=coverage.txt -v ./...
|
run: go test -coverprofile=coverage.txt -v ./...
|
||||||
|
|
||||||
- name: Upload coverage reports to Codecov
|
- name: Upload coverage reports to Codecov
|
||||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
|
|
||||||
- name: Delete old release
|
- name: Delete old release
|
||||||
run: gh release delete --cleanup-tag --yes nightly || echo release not found
|
run: gh release delete --cleanup-tag --yes nightly || echo release not found
|
||||||
@@ -23,7 +23,7 @@ jobs:
|
|||||||
REPO: ${{ github.event.repository.name }}
|
REPO: ${{ github.event.repository.name }}
|
||||||
|
|
||||||
- name: Create release
|
- name: Create release
|
||||||
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3
|
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3
|
||||||
with:
|
with:
|
||||||
prerelease: true
|
prerelease: true
|
||||||
tag_name: nightly
|
tag_name: nightly
|
||||||
@@ -37,7 +37,7 @@ jobs:
|
|||||||
BUILD_TIMESTAMP: ${{ steps.metadata.outputs.BUILD_TIMESTAMP }}
|
BUILD_TIMESTAMP: ${{ steps.metadata.outputs.BUILD_TIMESTAMP }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
with:
|
with:
|
||||||
ref: nightly
|
ref: nightly
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ jobs:
|
|||||||
- generate-metadata
|
- generate-metadata
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
with:
|
with:
|
||||||
ref: nightly
|
ref: nightly
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ jobs:
|
|||||||
package_json_file: ./frontend/package.json
|
package_json_file: ./frontend/package.json
|
||||||
|
|
||||||
- name: Install go
|
- name: Install go
|
||||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
|
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||||
with:
|
with:
|
||||||
go-version: "^1.26.4"
|
go-version: "^1.26.4"
|
||||||
|
|
||||||
@@ -100,7 +100,7 @@ jobs:
|
|||||||
- generate-metadata
|
- generate-metadata
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
with:
|
with:
|
||||||
ref: nightly
|
ref: nightly
|
||||||
|
|
||||||
@@ -110,7 +110,7 @@ jobs:
|
|||||||
package_json_file: ./frontend/package.json
|
package_json_file: ./frontend/package.json
|
||||||
|
|
||||||
- name: Install go
|
- name: Install go
|
||||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
|
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||||
with:
|
with:
|
||||||
go-version: "^1.26.4"
|
go-version: "^1.26.4"
|
||||||
|
|
||||||
@@ -145,7 +145,7 @@ jobs:
|
|||||||
- generate-metadata
|
- generate-metadata
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
with:
|
with:
|
||||||
ref: nightly
|
ref: nightly
|
||||||
|
|
||||||
@@ -203,7 +203,7 @@ jobs:
|
|||||||
- image-build
|
- image-build
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
with:
|
with:
|
||||||
ref: nightly
|
ref: nightly
|
||||||
|
|
||||||
@@ -261,7 +261,7 @@ jobs:
|
|||||||
- generate-metadata
|
- generate-metadata
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
with:
|
with:
|
||||||
ref: nightly
|
ref: nightly
|
||||||
|
|
||||||
@@ -319,7 +319,7 @@ jobs:
|
|||||||
- image-build-arm
|
- image-build-arm
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
with:
|
with:
|
||||||
ref: nightly
|
ref: nightly
|
||||||
|
|
||||||
@@ -461,7 +461,7 @@ jobs:
|
|||||||
merge-multiple: true
|
merge-multiple: true
|
||||||
|
|
||||||
- name: Release
|
- name: Release
|
||||||
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3
|
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3
|
||||||
with:
|
with:
|
||||||
files: binaries/*
|
files: binaries/*
|
||||||
tag_name: nightly
|
tag_name: nightly
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ jobs:
|
|||||||
BUILD_TIMESTAMP: ${{ steps.metadata.outputs.BUILD_TIMESTAMP }}
|
BUILD_TIMESTAMP: ${{ steps.metadata.outputs.BUILD_TIMESTAMP }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
|
|
||||||
- name: Generate metadata
|
- name: Generate metadata
|
||||||
id: metadata
|
id: metadata
|
||||||
@@ -33,7 +33,7 @@ jobs:
|
|||||||
- generate-metadata
|
- generate-metadata
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||||
@@ -41,7 +41,7 @@ jobs:
|
|||||||
package_json_file: ./frontend/package.json
|
package_json_file: ./frontend/package.json
|
||||||
|
|
||||||
- name: Install go
|
- name: Install go
|
||||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
|
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||||
with:
|
with:
|
||||||
go-version: "^1.26.4"
|
go-version: "^1.26.4"
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ jobs:
|
|||||||
- generate-metadata
|
- generate-metadata
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||||
@@ -83,7 +83,7 @@ jobs:
|
|||||||
package_json_file: ./frontend/package.json
|
package_json_file: ./frontend/package.json
|
||||||
|
|
||||||
- name: Install go
|
- name: Install go
|
||||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
|
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||||
with:
|
with:
|
||||||
go-version: "^1.26.4"
|
go-version: "^1.26.4"
|
||||||
|
|
||||||
@@ -117,7 +117,7 @@ jobs:
|
|||||||
- generate-metadata
|
- generate-metadata
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
|
|
||||||
- name: Docker meta
|
- name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
@@ -173,7 +173,7 @@ jobs:
|
|||||||
- image-build
|
- image-build
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
|
|
||||||
- name: Docker meta
|
- name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
@@ -229,7 +229,7 @@ jobs:
|
|||||||
- generate-metadata
|
- generate-metadata
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
|
|
||||||
- name: Docker meta
|
- name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
@@ -285,7 +285,7 @@ jobs:
|
|||||||
- image-build-arm
|
- image-build-arm
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
|
|
||||||
- name: Docker meta
|
- name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
@@ -432,6 +432,6 @@ jobs:
|
|||||||
merge-multiple: true
|
merge-multiple: true
|
||||||
|
|
||||||
- name: Release
|
- name: Release
|
||||||
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3
|
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3
|
||||||
with:
|
with:
|
||||||
files: binaries/*
|
files: binaries/*
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
|
|
||||||
- name: Generate Sponsors
|
- name: Generate Sponsors
|
||||||
uses: JamesIves/github-sponsors-readme-action@2fd9142e765f755780202122261dc85e78459405 # v1
|
uses: JamesIves/github-sponsors-readme-action@2fd9142e765f755780202122261dc85e78459405 # v1
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
# Site builder
|
# Site builder
|
||||||
FROM node:26.4-alpine3.23 AS frontend-builder
|
FROM node:26.3-alpine3.23 AS frontend-builder
|
||||||
|
|
||||||
WORKDIR /frontend
|
WORKDIR /frontend
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Site builder
|
# Site builder
|
||||||
FROM node:26.4-alpine3.23 AS frontend-builder
|
FROM node:26.3-alpine3.23 AS frontend-builder
|
||||||
|
|
||||||
WORKDIR /frontend
|
WORKDIR /frontend
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
<img alt="Tinyauth" title="Tinyauth" width="96" src="assets/logo-rounded.png">
|
<img alt="Tinyauth" title="Tinyauth" width="96" src="assets/logo-rounded.png">
|
||||||
<h1>Tinyauth</h1>
|
<h1>Tinyauth</h1>
|
||||||
<p>The tiniest OpenID Certified™ authorization and authentication server you have ever seen.</p>
|
<p>The tiniest authentication and authorization server you have ever seen.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
@@ -28,10 +28,6 @@ Tinyauth is the simplest and tiniest authentication and authorization server you
|
|||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> This is the main development branch. For the latest stable release, see the [documentation](https://tinyauth.app) or the latest stable tag.
|
> This is the main development branch. For the latest stable release, see the [documentation](https://tinyauth.app) or the latest stable tag.
|
||||||
|
|
||||||
As of 2026-06-25, Tinyauth v5.1.0 is OpenID Certified™ for Basic OP. You can find the certification details [here](https://openid.net/certification-old/certified-openid-providers-profiles/), test suite available [here](https://www.certification.openid.net/plan-detail.html?public=true&plan=H0qhpsOcQkxUE).
|
|
||||||
|
|
||||||
<img alt="OpenID Certified" width="200" src="https://openid.net/wordpress-content/uploads/2016/05/oid-l-certification-mark-l-cmyk-150dpi-90mm.jpg" />
|
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
You can get started with Tinyauth by following the guide in the [documentation](https://tinyauth.app/docs/getting-started). There is also an available [docker-compose](./docker-compose.example.yml) file that has Traefik, Whoami and Tinyauth to demonstrate its capabilities (keep in mind that this file lives in the development branch so it may have updates that are not yet released).
|
You can get started with Tinyauth by following the guide in the [documentation](https://tinyauth.app/docs/getting-started). There is also an available [docker-compose](./docker-compose.example.yml) file that has Traefik, Whoami and Tinyauth to demonstrate its capabilities (keep in mind that this file lives in the development branch so it may have updates that are not yet released).
|
||||||
@@ -62,20 +58,11 @@ If you like, you can help translate Tinyauth into more languages by visiting the
|
|||||||
|
|
||||||
Tinyauth is licensed under the GNU Affero General Public License v3.0. TL;DR — You may copy, distribute and modify the software as long as you track changes/dates in source files. Any modifications to or software including (via compiler) AGPL-licensed code must also be made available under the AGPL along with build & install instructions. If you run a modified version over a network, you must also make the source available to the users of that service. For more information about the license check the [license](LICENSE) file.
|
Tinyauth is licensed under the GNU Affero General Public License v3.0. TL;DR — You may copy, distribute and modify the software as long as you track changes/dates in source files. Any modifications to or software including (via compiler) AGPL-licensed code must also be made available under the AGPL along with build & install instructions. If you run a modified version over a network, you must also make the source available to the users of that service. For more information about the license check the [license](LICENSE) file.
|
||||||
|
|
||||||
|
|
||||||
## Hosting Partners
|
|
||||||
|
|
||||||
If you use one of our partners, you can help support us while getting a great hosting deal.
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<a title="InstaPods" target="_blank" href="https://app.instapods.com/dashboard/pods/create?app=tinyauth&ref=tinyauth"><img src="https://instapods.com/deploy-button.svg"></a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
## Sponsors
|
## Sponsors
|
||||||
|
|
||||||
A big thank you to the following people for providing me with more coffee:
|
A big thank you to the following people for providing me with more coffee:
|
||||||
|
|
||||||
<!-- sponsors --><a href="https://github.com/erwinkramer"><img src="https://github.com/erwinkramer.png" width="64px" alt="User avatar: erwinkramer" /></a> <a href="https://github.com/nicotsx"><img src="https://github.com/nicotsx.png" width="64px" alt="User avatar: nicotsx" /></a> <a href="https://github.com/SimpleHomelab"><img src="https://github.com/SimpleHomelab.png" width="64px" alt="User avatar: SimpleHomelab" /></a> <a href="https://github.com/jmadden91"><img src="https://github.com/jmadden91.png" width="64px" alt="User avatar: jmadden91" /></a> <a href="https://github.com/tribor"><img src="https://github.com/tribor.png" width="64px" alt="User avatar: tribor" /></a> <a href="https://github.com/eliasbenb"><img src="https://github.com/eliasbenb.png" width="64px" alt="User avatar: eliasbenb" /></a> <a href="https://github.com/afunworm"><img src="https://github.com/afunworm.png" width="64px" alt="User avatar: afunworm" /></a> <a href="https://github.com/chip-well"><img src="https://github.com/chip-well.png" width="64px" alt="User avatar: chip-well" /></a> <a href="https://github.com/Lancelot-Enguerrand"><img src="https://github.com/Lancelot-Enguerrand.png" width="64px" alt="User avatar: Lancelot-Enguerrand" /></a> <a href="https://github.com/allgoewer"><img src="https://github.com/allgoewer.png" width="64px" alt="User avatar: allgoewer" /></a> <a href="https://github.com/NEANC"><img src="https://github.com/NEANC.png" width="64px" alt="User avatar: NEANC" /></a> <a href="https://github.com/axjab"><img src="https://github.com/axjab.png" width="64px" alt="User avatar: axjab" /></a> <a href="https://github.com/stegratech"><img src="https://github.com/stegratech.png" width="64px" alt="User avatar: stegratech" /></a> <a href="https://github.com/apearson"><img src="https://github.com/apearson.png" width="64px" alt="User avatar: apearson" /></a> <a href="https://github.com/Micky5991"><img src="https://github.com/Micky5991.png" width="64px" alt="User avatar: Micky5991" /></a> <!-- sponsors -->
|
<!-- sponsors --><a href="https://github.com/erwinkramer"><img src="https://github.com/erwinkramer.png" width="64px" alt="User avatar: erwinkramer" /></a> <a href="https://github.com/nicotsx"><img src="https://github.com/nicotsx.png" width="64px" alt="User avatar: nicotsx" /></a> <a href="https://github.com/SimpleHomelab"><img src="https://github.com/SimpleHomelab.png" width="64px" alt="User avatar: SimpleHomelab" /></a> <a href="https://github.com/jmadden91"><img src="https://github.com/jmadden91.png" width="64px" alt="User avatar: jmadden91" /></a> <a href="https://github.com/tribor"><img src="https://github.com/tribor.png" width="64px" alt="User avatar: tribor" /></a> <a href="https://github.com/eliasbenb"><img src="https://github.com/eliasbenb.png" width="64px" alt="User avatar: eliasbenb" /></a> <a href="https://github.com/afunworm"><img src="https://github.com/afunworm.png" width="64px" alt="User avatar: afunworm" /></a> <a href="https://github.com/chip-well"><img src="https://github.com/chip-well.png" width="64px" alt="User avatar: chip-well" /></a> <a href="https://github.com/Lancelot-Enguerrand"><img src="https://github.com/Lancelot-Enguerrand.png" width="64px" alt="User avatar: Lancelot-Enguerrand" /></a> <a href="https://github.com/allgoewer"><img src="https://github.com/allgoewer.png" width="64px" alt="User avatar: allgoewer" /></a> <a href="https://github.com/NEANC"><img src="https://github.com/NEANC.png" width="64px" alt="User avatar: NEANC" /></a> <a href="https://github.com/ax-mad"><img src="https://github.com/ax-mad.png" width="64px" alt="User avatar: ax-mad" /></a> <a href="https://github.com/stegratech"><img src="https://github.com/stegratech.png" width="64px" alt="User avatar: stegratech" /></a> <a href="https://github.com/apearson"><img src="https://github.com/apearson.png" width="64px" alt="User avatar: apearson" /></a> <!-- sponsors -->
|
||||||
|
|
||||||
## Acknowledgements
|
## Acknowledgements
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
import type { SVGProps } from "react";
|
|
||||||
|
|
||||||
export function LocalAuthIcon(props: SVGProps<SVGSVGElement>) {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="1em"
|
|
||||||
height="1em"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M8 7a4 4 0 1 0 8 0a4 4 0 0 0-8 0M6 21v-2a4 4 0 0 1 4-4h5m3.5 3.5L15 22l-1.5-1.5m5.054-2.086a2 2 0 1 1 2.828-2.828a2 2 0 0 1-2.828 2.828M16 19l1 1"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,6 @@ import { Outlet } from "react-router";
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { DomainWarning } from "../domain-warning/domain-warning";
|
import { DomainWarning } from "../domain-warning/domain-warning";
|
||||||
import { QuickActions } from "../quick-actions/quick-actions";
|
import { QuickActions } from "../quick-actions/quick-actions";
|
||||||
import { isTrustedDomain } from "@/lib/hooks/redirect-uri";
|
|
||||||
|
|
||||||
const BaseLayout = ({ children }: { children: React.ReactNode }) => {
|
const BaseLayout = ({ children }: { children: React.ReactNode }) => {
|
||||||
const { ui } = useAppContext();
|
const { ui } = useAppContext();
|
||||||
@@ -41,18 +40,11 @@ export const Layout = () => {
|
|||||||
setIgnoreDomainWarning(true);
|
setIgnoreDomainWarning(true);
|
||||||
}, [setIgnoreDomainWarning]);
|
}, [setIgnoreDomainWarning]);
|
||||||
|
|
||||||
const isTrusted = (() => {
|
if (
|
||||||
try {
|
!ignoreDomainWarning &&
|
||||||
const appUrlObj = new URL(app.appUrl);
|
ui.warningsEnabled &&
|
||||||
const currentUrlObj = new URL(currentUrl);
|
!app.trustedDomains.includes(currentUrl)
|
||||||
|
) {
|
||||||
return isTrustedDomain(currentUrlObj, appUrlObj, "", false);
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
if (!ignoreDomainWarning && ui.warningsEnabled && !isTrusted) {
|
|
||||||
return (
|
return (
|
||||||
<BaseLayout>
|
<BaseLayout>
|
||||||
<DomainWarning
|
<DomainWarning
|
||||||
|
|||||||
@@ -25,8 +25,6 @@ import {
|
|||||||
Palette,
|
Palette,
|
||||||
Settings,
|
Settings,
|
||||||
Sun,
|
Sun,
|
||||||
UserRoundKey,
|
|
||||||
X,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useLocation } from "react-router";
|
import { useLocation } from "react-router";
|
||||||
@@ -39,26 +37,20 @@ import { useMutation } from "@tanstack/react-query";
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { GoogleIcon } from "../icons/google";
|
|
||||||
import { GithubIcon } from "../icons/github";
|
|
||||||
import { TailscaleIcon } from "../icons/tailscale";
|
|
||||||
import { MicrosoftIcon } from "../icons/microsoft";
|
|
||||||
import { PocketIDIcon } from "../icons/pocket-id";
|
|
||||||
import { OAuthIcon } from "../icons/oauth";
|
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
|
||||||
|
|
||||||
const iconStyles = "size-4";
|
function Avatar({ initial }: { initial: string }) {
|
||||||
|
return (
|
||||||
const iconMap: Record<string, React.ReactNode> = {
|
<span className="group relative grid size-10 place-items-center rounded-full">
|
||||||
google: <GoogleIcon className={iconStyles} />,
|
<span className="absolute inset-0 overflow-hidden rounded-full bg-linear-to-b from-neutral-50 to-neutral-100 dark:from-neutral-700 dark:to-neutral-950 shadow-lg"></span>
|
||||||
github: <GithubIcon className={iconStyles} />,
|
<span className="relative text-sm font-semibold text-primary">
|
||||||
tailscale: <TailscaleIcon className={iconStyles} />,
|
{initial}
|
||||||
microsoft: <MicrosoftIcon className={iconStyles} />,
|
</span>
|
||||||
pocketid: <PocketIDIcon className={iconStyles} />,
|
</span>
|
||||||
};
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export const QuickActions = () => {
|
export const QuickActions = () => {
|
||||||
const { auth, oauth, tailscale } = useUserContext();
|
const { auth } = useUserContext();
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { search } = useLocation();
|
const { search } = useLocation();
|
||||||
@@ -72,49 +64,6 @@ export const QuickActions = () => {
|
|||||||
const screenParams = useScreenParams(searchParams);
|
const screenParams = useScreenParams(searchParams);
|
||||||
const compiledParams = recompileScreenParams(screenParams);
|
const compiledParams = recompileScreenParams(screenParams);
|
||||||
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
|
|
||||||
const providerDetails = (():
|
|
||||||
| { name: string; icon: React.ReactNode }
|
|
||||||
| undefined => {
|
|
||||||
if (!auth.authenticated) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (auth.providerId === "local" || auth.providerId === "ldap") {
|
|
||||||
return {
|
|
||||||
name: t(
|
|
||||||
auth.providerId === "ldap"
|
|
||||||
? "quickActionsProviderLDAP"
|
|
||||||
: "quickActionsProviderLocal",
|
|
||||||
),
|
|
||||||
icon: (
|
|
||||||
<UserRoundKey
|
|
||||||
strokeWidth={1.5}
|
|
||||||
size={16}
|
|
||||||
className="text-muted-foreground ml-0.5"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (oauth.active) {
|
|
||||||
return {
|
|
||||||
name: t("quickActionsProviderOAuth", { provider: oauth.displayName }),
|
|
||||||
icon: iconMap[auth.providerId] || <OAuthIcon className={iconStyles} />,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (auth.providerId === "tailscale") {
|
|
||||||
return {
|
|
||||||
name: `Tailscale (${tailscale.nodeName})`,
|
|
||||||
icon: <TailscaleIcon className={iconStyles} />,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
})();
|
|
||||||
|
|
||||||
const logoutMutation = useMutation({
|
const logoutMutation = useMutation({
|
||||||
mutationFn: () => axios.post("/api/user/logout"),
|
mutationFn: () => axios.post("/api/user/logout"),
|
||||||
mutationKey: ["logout"],
|
mutationKey: ["logout"],
|
||||||
@@ -158,29 +107,17 @@ export const QuickActions = () => {
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu onOpenChange={(open) => setIsOpen(open)} open={isOpen}>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button
|
<button
|
||||||
aria-label={t("quickActionsTitle")}
|
aria-label={t("quickActionsTitle")}
|
||||||
className="rounded-full transition-transform duration-200 will-change-transform hover:scale-105 hover:cursor-pointer focus:ring-0 focus:outline-3 focus:outline-ring/50"
|
className="rounded-full transition-transform duration-200 will-change-transform hover:scale-105 hover:cursor-pointer focus:ring-0 focus:outline-3 focus:outline-ring/50"
|
||||||
>
|
>
|
||||||
{auth.authenticated ? (
|
{auth.authenticated ? (
|
||||||
<div className="size-10 flex justify-center items-center p-2 rounded-full bg-card border border-border">
|
<Avatar initial={initial!} />
|
||||||
{isOpen ? (
|
|
||||||
<X className="size-4 text-primary rotate-0 transition-transform duration-200 starting:rotate-45" />
|
|
||||||
) : (
|
|
||||||
<span className="text-sm text-primary rotate-0 transition-transform duration-200 starting:-rotate-45">
|
|
||||||
{initial}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<span className="bg-card text-primary border-border size-10 flex items-center justify-center rounded-full border shadow-lg">
|
<span className="bg-card text-primary border-border size-10 flex items-center justify-center rounded-full border shadow-lg">
|
||||||
<Settings
|
<Settings className="size-4" />
|
||||||
className={`size-4 transition-transform duration-200 ${
|
|
||||||
isOpen ? "rotate-45" : "rotate-0"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
@@ -189,22 +126,19 @@ export const QuickActions = () => {
|
|||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
align="end"
|
align="end"
|
||||||
sideOffset={8}
|
sideOffset={8}
|
||||||
className="rounded-xl p-1 w-3xs"
|
className="rounded-xl p-1"
|
||||||
>
|
>
|
||||||
{auth.authenticated && (
|
{auth.authenticated && (
|
||||||
<>
|
<>
|
||||||
<DropdownMenuLabel className="flex items-center gap-3 p-2">
|
<DropdownMenuLabel className="flex items-center gap-3 p-2">
|
||||||
<Tooltip>
|
<div className="bg-foreground text-background flex size-9 shrink-0 items-center justify-center rounded-full text-sm font-medium">
|
||||||
<TooltipTrigger className="size-9 rounded-full p-2 bg-muted border-border border flex items-center justify-center">
|
{initial}
|
||||||
{providerDetails!.icon}
|
</div>
|
||||||
</TooltipTrigger>
|
<div className="flex min-w-0 flex-col">
|
||||||
<TooltipContent>{providerDetails!.name}</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<div className="flex min-w-0 flex-col gap-0.5">
|
|
||||||
<span className="truncate text-sm font-medium">
|
<span className="truncate text-sm font-medium">
|
||||||
{auth.name}
|
{auth.name}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-muted-foreground truncate text-xs">
|
<span className="text-muted-foreground truncate text-xs font-normal">
|
||||||
{auth.email}
|
{auth.email}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -263,7 +197,7 @@ export const QuickActions = () => {
|
|||||||
onSelect={() => logoutMutation.mutate()}
|
onSelect={() => logoutMutation.mutate()}
|
||||||
className="text-destructive"
|
className="text-destructive"
|
||||||
>
|
>
|
||||||
<DoorOpenIcon className="size-4 text-destructive" />
|
<DoorOpenIcon className="size-4" />
|
||||||
{t("quickActionsLogout")}
|
{t("quickActionsLogout")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -9,28 +9,13 @@ type IuseRedirectUri = {
|
|||||||
export const useRedirectUri = (
|
export const useRedirectUri = (
|
||||||
redirect_uri: string | undefined,
|
redirect_uri: string | undefined,
|
||||||
cookieDomain: string,
|
cookieDomain: string,
|
||||||
appUrl: string,
|
|
||||||
subdomainsEnabled: boolean,
|
|
||||||
): IuseRedirectUri => {
|
): IuseRedirectUri => {
|
||||||
let isValid = false;
|
let isValid = false;
|
||||||
let isTrusted = false;
|
let isTrusted = false;
|
||||||
let isAllowedProto = false;
|
let isAllowedProto = false;
|
||||||
let isHttpsDowngrade = false;
|
let isHttpsDowngrade = false;
|
||||||
|
|
||||||
let appUrlObj: URL;
|
if (redirect_uri === undefined) {
|
||||||
|
|
||||||
try {
|
|
||||||
appUrlObj = new URL(appUrl);
|
|
||||||
} catch {
|
|
||||||
return {
|
|
||||||
valid: isValid,
|
|
||||||
trusted: isTrusted,
|
|
||||||
allowedProto: isAllowedProto,
|
|
||||||
httpsDowngrade: isHttpsDowngrade,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!redirect_uri) {
|
|
||||||
return {
|
return {
|
||||||
valid: isValid,
|
valid: isValid,
|
||||||
trusted: isTrusted,
|
trusted: isTrusted,
|
||||||
@@ -54,7 +39,10 @@ export const useRedirectUri = (
|
|||||||
|
|
||||||
isValid = true;
|
isValid = true;
|
||||||
|
|
||||||
if (isTrustedDomain(url, appUrlObj, cookieDomain, subdomainsEnabled)) {
|
if (
|
||||||
|
url.hostname == cookieDomain ||
|
||||||
|
url.hostname.endsWith(`.${cookieDomain}`)
|
||||||
|
) {
|
||||||
isTrusted = true;
|
isTrusted = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,45 +62,3 @@ export const useRedirectUri = (
|
|||||||
httpsDowngrade: isHttpsDowngrade,
|
httpsDowngrade: isHttpsDowngrade,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// ported from internal/controller/oauth_controller.go
|
|
||||||
const getEffectivePort = (url: URL): string => {
|
|
||||||
if (url.port) {
|
|
||||||
return url.port;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url.protocol == "https:") {
|
|
||||||
return "443";
|
|
||||||
}
|
|
||||||
|
|
||||||
return "80";
|
|
||||||
};
|
|
||||||
|
|
||||||
export const isTrustedDomain = (
|
|
||||||
url: URL,
|
|
||||||
appUrl: URL,
|
|
||||||
cookieDomain: string,
|
|
||||||
subdomainsEnabled: boolean,
|
|
||||||
): boolean => {
|
|
||||||
if (url.protocol != appUrl.protocol) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (getEffectivePort(url) != getEffectivePort(appUrl)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url.hostname == appUrl.hostname) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!subdomainsEnabled) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url.hostname.endsWith("." + cookieDomain.toLowerCase())) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -99,8 +99,5 @@
|
|||||||
"quickActionsThemeDark": "Dark",
|
"quickActionsThemeDark": "Dark",
|
||||||
"quickActionsThemeSystem": "System",
|
"quickActionsThemeSystem": "System",
|
||||||
"quickActionsLogout": "Logout",
|
"quickActionsLogout": "Logout",
|
||||||
"quickActionsTitle": "Quick Actions",
|
"quickActionsTitle": "Quick Actions"
|
||||||
"quickActionsProviderLocal": "Local",
|
|
||||||
"quickActionsProviderLDAP": "LDAP",
|
|
||||||
"quickActionsProviderOAuth": "{{provider}} OAuth"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,8 +99,5 @@
|
|||||||
"quickActionsThemeDark": "Dark",
|
"quickActionsThemeDark": "Dark",
|
||||||
"quickActionsThemeSystem": "System",
|
"quickActionsThemeSystem": "System",
|
||||||
"quickActionsLogout": "Logout",
|
"quickActionsLogout": "Logout",
|
||||||
"quickActionsTitle": "Quick Actions",
|
"quickActionsTitle": "Quick Actions"
|
||||||
"quickActionsProviderLocal": "Local",
|
|
||||||
"quickActionsProviderLDAP": "LDAP",
|
|
||||||
"quickActionsProviderOAuth": "{{provider}} OAuth"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,8 +37,6 @@ export const ContinuePage = () => {
|
|||||||
const { url, valid, trusted, allowedProto, httpsDowngrade } = useRedirectUri(
|
const { url, valid, trusted, allowedProto, httpsDowngrade } = useRedirectUri(
|
||||||
redirectUri,
|
redirectUri,
|
||||||
app.cookieDomain,
|
app.cookieDomain,
|
||||||
app.appUrl,
|
|
||||||
app.subdomainsEnabled,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const urlHref = url?.href;
|
const urlHref = url?.href;
|
||||||
@@ -110,11 +108,7 @@ export const ContinuePage = () => {
|
|||||||
components={{
|
components={{
|
||||||
code: <code />,
|
code: <code />,
|
||||||
}}
|
}}
|
||||||
values={{
|
values={{ cookieDomain: app.cookieDomain }}
|
||||||
cookieDomain: app.subdomainsEnabled
|
|
||||||
? `.${app.cookieDomain}`
|
|
||||||
: app.cookieDomain,
|
|
||||||
}}
|
|
||||||
shouldUnescape={true}
|
shouldUnescape={true}
|
||||||
/>
|
/>
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ function LogoutLayout({ children, logoutMutation }: LogoutLayoutProps) {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardFooter>
|
<CardFooter>
|
||||||
<Button
|
<Button
|
||||||
className="w-full text-destructive"
|
className="w-full"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
loading={logoutMutation.isPending}
|
loading={logoutMutation.isPending}
|
||||||
onClick={() => logoutMutation.mutate()}
|
onClick={() => logoutMutation.mutate()}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const uiSchema = z.object({
|
|||||||
const appSchema = z.object({
|
const appSchema = z.object({
|
||||||
appUrl: z.string(),
|
appUrl: z.string(),
|
||||||
cookieDomain: z.string(),
|
cookieDomain: z.string(),
|
||||||
subdomainsEnabled: z.boolean(),
|
trustedDomains: z.array(z.string()),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const appContextSchema = z.object({
|
export const appContextSchema = z.object({
|
||||||
|
|||||||
@@ -67,15 +67,24 @@ func run() error {
|
|||||||
Overlay: map[string][]byte{outPath: stub},
|
Overlay: map[string][]byte{outPath: stub},
|
||||||
}
|
}
|
||||||
|
|
||||||
driverTypePkg, err := loadOnePkg(cfg, *driverPkg)
|
repoPkgPath := parentPkg(*driverPkg)
|
||||||
|
|
||||||
|
pkgs, err := loadMultiplePkgs(cfg, *driverPkg, repoPkgPath)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("load driver package: %w", err)
|
return fmt.Errorf("load packages: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
repoPkgPath := parentPkg(*driverPkg)
|
driverTypePkg, ok := pkgs[*driverPkg]
|
||||||
repoTypePkg, err := loadOnePkg(cfg, repoPkgPath)
|
|
||||||
if err != nil {
|
if !ok {
|
||||||
return fmt.Errorf("load repo package: %w", err)
|
return fmt.Errorf("driver package %s not found in loaded packages", *driverPkg)
|
||||||
|
}
|
||||||
|
|
||||||
|
repoTypePkg, ok := pkgs[repoPkgPath]
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("repository package %s not found in loaded packages", repoPkgPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := validateStructShapes(driverTypePkg, repoTypePkg); err != nil {
|
if err := validateStructShapes(driverTypePkg, repoTypePkg); err != nil {
|
||||||
@@ -106,25 +115,25 @@ func run() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadOnePkg loads a single package via cfg and returns its *types.Package,
|
// loadMultiplePkgs loads multiple packages via cfg and returns a map of import path → *types.Package,
|
||||||
// or an error if the package fails to load or has type errors.
|
// or an error if any package fails to load or has type errors.
|
||||||
func loadOnePkg(cfg *packages.Config, importPath string) (*types.Package, error) {
|
func loadMultiplePkgs(cfg *packages.Config, importPaths ...string) (map[string]*types.Package, error) {
|
||||||
pkgs, err := packages.Load(cfg, importPath)
|
pkgs, err := packages.Load(cfg, importPaths...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("load %s: %w", importPath, err)
|
return nil, fmt.Errorf("load %v: %w", importPaths, err)
|
||||||
}
|
}
|
||||||
if len(pkgs) != 1 {
|
out := make(map[string]*types.Package)
|
||||||
return nil, fmt.Errorf("expected 1 package for %s, got %d", importPath, len(pkgs))
|
for _, pkg := range pkgs {
|
||||||
}
|
|
||||||
pkg := pkgs[0]
|
|
||||||
if len(pkg.Errors) > 0 {
|
if len(pkg.Errors) > 0 {
|
||||||
msgs := make([]string, len(pkg.Errors))
|
msgs := make([]string, len(pkg.Errors))
|
||||||
for i, e := range pkg.Errors {
|
for i, e := range pkg.Errors {
|
||||||
msgs[i] = e.Error()
|
msgs[i] = e.Error()
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("package %s has errors:\n %s", importPath, strings.Join(msgs, "\n "))
|
return nil, fmt.Errorf("package %s has errors:\n %s", pkg.PkgPath, strings.Join(msgs, "\n "))
|
||||||
}
|
}
|
||||||
return pkg.Types, nil
|
out[pkg.PkgPath] = pkg.Types
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// parentPkg returns the parent import path (everything before the last /).
|
// parentPkg returns the parent import path (everything before the last /).
|
||||||
|
|||||||
@@ -24,10 +24,10 @@ require (
|
|||||||
go.uber.org/dig v1.19.0
|
go.uber.org/dig v1.19.0
|
||||||
golang.org/x/crypto v0.53.0
|
golang.org/x/crypto v0.53.0
|
||||||
golang.org/x/oauth2 v0.36.0
|
golang.org/x/oauth2 v0.36.0
|
||||||
golang.org/x/tools v0.47.0
|
golang.org/x/tools v0.46.0
|
||||||
k8s.io/apimachinery v0.36.2
|
k8s.io/apimachinery v0.36.2
|
||||||
k8s.io/client-go v0.36.2
|
k8s.io/client-go v0.36.2
|
||||||
modernc.org/sqlite v1.53.0
|
modernc.org/sqlite v1.52.0
|
||||||
tailscale.com v1.100.0
|
tailscale.com v1.100.0
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -175,7 +175,7 @@ require (
|
|||||||
k8s.io/klog/v2 v2.140.0 // indirect
|
k8s.io/klog/v2 v2.140.0 // indirect
|
||||||
k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect
|
k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect
|
||||||
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect
|
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect
|
||||||
modernc.org/libc v1.73.4 // indirect
|
modernc.org/libc v1.72.3 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
rsc.io/qr v0.2.0 // indirect
|
rsc.io/qr v0.2.0 // indirect
|
||||||
|
|||||||
@@ -526,8 +526,8 @@ golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
|||||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
golang.org/x/tools v0.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk=
|
||||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys=
|
||||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
|
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
|
||||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
|
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
|
||||||
golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE=
|
golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE=
|
||||||
@@ -571,20 +571,20 @@ k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a h1:xCeOEAOoGYl2jnJoHkC3hk
|
|||||||
k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0=
|
k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0=
|
||||||
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU=
|
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU=
|
||||||
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
|
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
|
||||||
modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c=
|
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
|
||||||
modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||||
modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws=
|
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
|
||||||
modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE=
|
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
|
||||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc=
|
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||||
modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA=
|
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
|
||||||
modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8=
|
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
|
||||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
@@ -593,8 +593,8 @@ modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
|||||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
|
modernc.org/sqlite v1.52.0 h1:p4dhYh2tXZCiyaqHwRVJDjIGKWyXayiQpThxgDzJaxo=
|
||||||
modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s=
|
modernc.org/sqlite v1.52.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
|
||||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS "oidc_consent" (
|
||||||
|
"uuid" TEXT NOT NULL UNIQUE PRIMARY KEY,
|
||||||
|
"client_id" TEXT NOT NULL,
|
||||||
|
"scopes" TEXT NOT NULL,
|
||||||
|
"created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS "oidc_consent";
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS "oidc_consent";
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS "oidc_consent" (
|
||||||
|
"uuid" TEXT NOT NULL UNIQUE PRIMARY KEY,
|
||||||
|
"client_id" TEXT NOT NULL,
|
||||||
|
"scopes" TEXT NOT NULL,
|
||||||
|
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
@@ -11,7 +11,6 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"slices"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
@@ -49,6 +48,7 @@ type Services struct {
|
|||||||
type BootstrapApp struct {
|
type BootstrapApp struct {
|
||||||
config model.Config
|
config model.Config
|
||||||
runtime model.RuntimeConfig
|
runtime model.RuntimeConfig
|
||||||
|
helpers model.RuntimeHelpers
|
||||||
services Services
|
services Services
|
||||||
log *logger.Logger
|
log *logger.Logger
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
@@ -57,6 +57,7 @@ type BootstrapApp struct {
|
|||||||
router *gin.Engine
|
router *gin.Engine
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
ding *ding.Ding
|
ding *ding.Ding
|
||||||
|
listeners []Listener
|
||||||
dig *dig.Container
|
dig *dig.Container
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,7 +99,8 @@ func (app *BootstrapApp) Setup() error {
|
|||||||
return fmt.Errorf("failed to parse app url: %w", err)
|
return fmt.Errorf("failed to parse app url: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
app.runtime.AppURL = strings.ToLower(appUrl.Scheme + "://" + appUrl.Host)
|
app.runtime.AppURL = appUrl.Scheme + "://" + appUrl.Host
|
||||||
|
app.runtime.TrustedDomains = append(app.runtime.TrustedDomains, app.runtime.AppURL)
|
||||||
|
|
||||||
// validate session config
|
// validate session config
|
||||||
if app.config.Auth.SessionMaxLifetime != 0 && app.config.Auth.SessionMaxLifetime < app.config.Auth.SessionExpiry {
|
if app.config.Auth.SessionMaxLifetime != 0 && app.config.Auth.SessionMaxLifetime < app.config.Auth.SessionExpiry {
|
||||||
@@ -132,10 +134,6 @@ func (app *BootstrapApp) Setup() error {
|
|||||||
app.runtime.OAuthProviders = app.config.OAuth.Providers
|
app.runtime.OAuthProviders = app.config.OAuth.Providers
|
||||||
|
|
||||||
for id, provider := range app.runtime.OAuthProviders {
|
for id, provider := range app.runtime.OAuthProviders {
|
||||||
if slices.Contains(model.ReservedProviderNames, id) {
|
|
||||||
return fmt.Errorf("provider id %s is reserved and cannot be used", id)
|
|
||||||
}
|
|
||||||
|
|
||||||
providerWhitelist, err := utils.GetStringList(provider.Whitelist, provider.WhitelistFile)
|
providerWhitelist, err := utils.GetStringList(provider.Whitelist, provider.WhitelistFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to load oauth whitelist for provider %s: %w", id, err)
|
return fmt.Errorf("failed to load oauth whitelist for provider %s: %w", id, err)
|
||||||
@@ -147,6 +145,15 @@ func (app *BootstrapApp) Setup() error {
|
|||||||
provider.ClientSecret = secret
|
provider.ClientSecret = secret
|
||||||
provider.ClientSecretFile = ""
|
provider.ClientSecretFile = ""
|
||||||
|
|
||||||
|
if provider.RedirectURL == "" {
|
||||||
|
provider.RedirectURL = app.runtime.AppURL + "/api/oauth/callback/" + id
|
||||||
|
}
|
||||||
|
|
||||||
|
app.runtime.OAuthProviders[id] = provider
|
||||||
|
}
|
||||||
|
|
||||||
|
// set presets for built-in providers
|
||||||
|
for id, provider := range app.runtime.OAuthProviders {
|
||||||
if provider.Name == "" {
|
if provider.Name == "" {
|
||||||
if name, ok := model.OverrideProviders[id]; ok {
|
if name, ok := model.OverrideProviders[id]; ok {
|
||||||
provider.Name = name
|
provider.Name = name
|
||||||
@@ -154,16 +161,18 @@ func (app *BootstrapApp) Setup() error {
|
|||||||
provider.Name = utils.Capitalize(id)
|
provider.Name = utils.Capitalize(id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
app.runtime.OAuthProviders[id] = provider
|
app.runtime.OAuthProviders[id] = provider
|
||||||
}
|
}
|
||||||
|
|
||||||
// cookie domain
|
// cookie domain
|
||||||
|
cookieDomainResolver := utils.GetCookieDomain
|
||||||
|
|
||||||
if !app.config.Auth.SubdomainsEnabled {
|
if !app.config.Auth.SubdomainsEnabled {
|
||||||
app.log.App.Warn().Msg("Subdomains are disabled, cookies will be set for the current domain only")
|
app.log.App.Warn().Msg("Subdomains are disabled, using standalone cookie domain resolver which will not work with subdomains")
|
||||||
|
cookieDomainResolver = utils.GetStandaloneCookieDomain
|
||||||
}
|
}
|
||||||
|
|
||||||
cookieDomain, err := utils.GetCookieDomain(app.runtime.AppURL, app.config.Auth.SubdomainsEnabled)
|
cookieDomain, err := cookieDomainResolver(app.runtime.AppURL)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get cookie domain: %w", err)
|
return fmt.Errorf("failed to get cookie domain: %w", err)
|
||||||
@@ -177,9 +186,8 @@ func (app *BootstrapApp) Setup() error {
|
|||||||
cookieId := strings.Split(app.runtime.UUID, "-")[0] // first 8 characters of the uuid should be good enough
|
cookieId := strings.Split(app.runtime.UUID, "-")[0] // first 8 characters of the uuid should be good enough
|
||||||
|
|
||||||
app.runtime.SessionCookieName = fmt.Sprintf("%s-%s", model.SessionCookieName, cookieId)
|
app.runtime.SessionCookieName = fmt.Sprintf("%s-%s", model.SessionCookieName, cookieId)
|
||||||
app.runtime.CSRFCookieName = fmt.Sprintf("%s-%s", model.CSRFCookieName, cookieId)
|
|
||||||
app.runtime.RedirectCookieName = fmt.Sprintf("%s-%s", model.RedirectCookieName, cookieId)
|
|
||||||
app.runtime.OAuthSessionCookieName = fmt.Sprintf("%s-%s", model.OAuthSessionCookieName, cookieId)
|
app.runtime.OAuthSessionCookieName = fmt.Sprintf("%s-%s", model.OAuthSessionCookieName, cookieId)
|
||||||
|
app.runtime.ConsentCookieName = fmt.Sprintf("%s-%s", model.ConsentCookieName, cookieId)
|
||||||
|
|
||||||
// database
|
// database
|
||||||
store, err := app.SetupStore()
|
store, err := app.SetupStore()
|
||||||
@@ -278,43 +286,20 @@ func (app *BootstrapApp) Setup() error {
|
|||||||
|
|
||||||
app.runtime.ConfiguredProviders = configuredProviders
|
app.runtime.ConfiguredProviders = configuredProviders
|
||||||
|
|
||||||
// if tailscale is enabled and listening, replace the app url with the tailscale hostname
|
// throw in tailscale if it's configured just before setting up the controllers
|
||||||
if app.services.tailscaleService != nil && app.config.Tailscale.Listen {
|
if app.services.tailscaleService != nil {
|
||||||
tailscaleUrl := "https://" + app.services.tailscaleService.GetHostname()
|
app.runtime.TrustedDomains = append(app.runtime.TrustedDomains, "https://"+app.services.tailscaleService.GetHostname())
|
||||||
|
}
|
||||||
|
|
||||||
// if the tailscale url is different from the app url, replace it
|
// runtime helpers
|
||||||
if tailscaleUrl != app.runtime.AppURL {
|
app.helpers.GetCookieDomain = app.getCookieDomain
|
||||||
app.log.App.Info().Msg("Listening on tailscale, replacing app url with tailscale hostname")
|
|
||||||
|
|
||||||
app.runtime.AppURL = tailscaleUrl
|
err = app.dig.Provide(func() *model.RuntimeHelpers {
|
||||||
|
return &app.helpers
|
||||||
// also update cookie domain
|
})
|
||||||
cookieDomain, err := utils.GetCookieDomain(tailscaleUrl, app.config.Auth.SubdomainsEnabled)
|
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get cookie domain: %w", err)
|
return fmt.Errorf("failed to provide runtime helpers to container: %w", err)
|
||||||
}
|
|
||||||
|
|
||||||
app.runtime.CookieDomain = cookieDomain
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// force an update of the redirect urls for all oauth providers, if they are empty
|
|
||||||
services := app.services.oauthBrokerService.GetConfiguredServices()
|
|
||||||
|
|
||||||
for _, service := range services {
|
|
||||||
oauthService, ok := app.services.oauthBrokerService.GetService(service)
|
|
||||||
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("failed to get oauth service for provider %s", service)
|
|
||||||
}
|
|
||||||
|
|
||||||
providerConfig := oauthService.GetConfig()
|
|
||||||
|
|
||||||
if providerConfig.RedirectURL == "" {
|
|
||||||
providerConfig.RedirectURL = app.runtime.AppURL + "/api/oauth/callback/" + service
|
|
||||||
oauthService.UpdateConfig(providerConfig)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// setup router
|
// setup router
|
||||||
@@ -334,19 +319,19 @@ func (app *BootstrapApp) Setup() error {
|
|||||||
app.ding.Go(app.heartbeatRoutine, ding.RingMinor)
|
app.ding.Go(app.heartbeatRoutine, ding.RingMinor)
|
||||||
}
|
}
|
||||||
|
|
||||||
// get listener
|
// setup listeners
|
||||||
listenerFunc, err := app.getListenerFunc()
|
app.listeners = app.calculateListenerPolicy()
|
||||||
|
|
||||||
if err != nil {
|
if app.config.Server.ConcurrentListenersEnabled {
|
||||||
return fmt.Errorf("failed to get listener function: %w", err)
|
app.log.App.Info().Msg("Concurrent listeners enabled, will run on all available listeners")
|
||||||
}
|
}
|
||||||
|
|
||||||
// run listener
|
// run listeners
|
||||||
lec := make(chan error, 1)
|
lec, err := app.runListeners()
|
||||||
|
|
||||||
app.ding.Go(func(ctx context.Context) {
|
if err != nil {
|
||||||
lec <- listenerFunc(ctx)
|
return fmt.Errorf("failed to run listeners: %w", err)
|
||||||
}, ding.RingNormal)
|
}
|
||||||
|
|
||||||
// monitor cancellation and server errors
|
// monitor cancellation and server errors
|
||||||
for {
|
for {
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package bootstrap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/tinyauthapp/tinyauth/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Not really the best place for the helpers to be but it works because bootstrap app provides
|
||||||
|
// them with everything they need
|
||||||
|
|
||||||
|
func (app *BootstrapApp) getCookieDomain(ctx context.Context, ip string) (string, error) {
|
||||||
|
cookieDomain := app.runtime.CookieDomain
|
||||||
|
|
||||||
|
if app.isTailscaleRequest(ctx, ip) {
|
||||||
|
if app.services.tailscaleService == nil {
|
||||||
|
return "", errors.New("tailscale service is not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
tsCookieDomain, err := utils.GetCookieDomain(fmt.Sprintf("https://%s", app.services.tailscaleService.GetHostname()))
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to get cookie domain for tailscale user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cookieDomain = tsCookieDomain
|
||||||
|
}
|
||||||
|
|
||||||
|
if app.config.Auth.SubdomainsEnabled {
|
||||||
|
cookieDomain = "." + cookieDomain
|
||||||
|
}
|
||||||
|
|
||||||
|
return cookieDomain, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *BootstrapApp) isTailscaleRequest(ctx context.Context, ip string) bool {
|
||||||
|
if app.services.tailscaleService == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
whois, err := app.services.tailscaleService.Whois(ctx, ip)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
app.log.App.Error().Err(err).Msgf("Error performing Tailscale whois for IP %s: %v", ip, err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if whois == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/steveiliop56/ding"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/controller"
|
"github.com/tinyauthapp/tinyauth/internal/controller"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/middleware"
|
"github.com/tinyauthapp/tinyauth/internal/middleware"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||||
@@ -17,6 +18,14 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type Listener int
|
||||||
|
|
||||||
|
const (
|
||||||
|
ListenerHTTP Listener = iota
|
||||||
|
ListenerUnix
|
||||||
|
ListenerTailscale
|
||||||
|
)
|
||||||
|
|
||||||
func (app *BootstrapApp) setupRouter() error {
|
func (app *BootstrapApp) setupRouter() error {
|
||||||
// we don't want gin debug mode
|
// we don't want gin debug mode
|
||||||
gin.SetMode(gin.ReleaseMode)
|
gin.SetMode(gin.ReleaseMode)
|
||||||
@@ -125,29 +134,79 @@ func (app *BootstrapApp) setupRouter() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Top down
|
func (app *BootstrapApp) runListeners() (chan error, error) {
|
||||||
// 1. Tailscale (if tailscale.listen)
|
// lec -> listener error channel
|
||||||
// 2. Unix socket (if server.socketPath)
|
lec := make(chan error, len(app.listeners))
|
||||||
// 3. HTTP - default
|
|
||||||
func (app *BootstrapApp) getListenerFunc() (func(ctx context.Context) error, error) {
|
for _, listenerType := range app.listeners {
|
||||||
if app.config.Tailscale.Listen {
|
listenerFunc, err := app.listenerFromType(listenerType)
|
||||||
if app.services.tailscaleService == nil {
|
|
||||||
return nil, fmt.Errorf("tailscale.listen is enabled but tailscale service is not initialized")
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get listener function: %w", err)
|
||||||
}
|
}
|
||||||
return app.serveTailscale, nil
|
|
||||||
|
app.ding.Go(func(ctx context.Context) {
|
||||||
|
lec <- listenerFunc(ctx)
|
||||||
|
}, ding.RingNormal)
|
||||||
|
}
|
||||||
|
|
||||||
|
return lec, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// The way we calculate listeners is as follows:
|
||||||
|
// If concurrent listeners are disabled, we pick the first available listener, so:
|
||||||
|
// 1. If tailscale is enabled, we use tailscale
|
||||||
|
// 2. If socket path is configured, we use unix socket
|
||||||
|
// 3. Finally if none is configured we use http
|
||||||
|
// If concurrent listeners are enabled, we add all available listeners in the following order
|
||||||
|
func (app *BootstrapApp) calculateListenerPolicy() []Listener {
|
||||||
|
l := []Listener{}
|
||||||
|
|
||||||
|
if !app.config.Server.ConcurrentListenersEnabled {
|
||||||
|
if app.services.tailscaleService != nil {
|
||||||
|
l = append(l, ListenerTailscale)
|
||||||
|
return l
|
||||||
}
|
}
|
||||||
|
|
||||||
if app.config.Server.SocketPath != "" {
|
if app.config.Server.SocketPath != "" {
|
||||||
return app.serveUnix, nil
|
l = append(l, ListenerUnix)
|
||||||
|
return l
|
||||||
}
|
}
|
||||||
|
|
||||||
|
l = append(l, ListenerHTTP)
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
if app.config.Server.SocketPath != "" {
|
||||||
|
l = append(l, ListenerUnix)
|
||||||
|
}
|
||||||
|
|
||||||
|
if app.services.tailscaleService != nil {
|
||||||
|
l = append(l, ListenerTailscale)
|
||||||
|
}
|
||||||
|
|
||||||
|
l = append(l, ListenerHTTP)
|
||||||
|
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *BootstrapApp) listenerFromType(listenerType Listener) (func(ctx context.Context) error, error) {
|
||||||
|
switch listenerType {
|
||||||
|
case ListenerHTTP:
|
||||||
return app.serveHTTP, nil
|
return app.serveHTTP, nil
|
||||||
|
case ListenerUnix:
|
||||||
|
return app.serveUnix, nil
|
||||||
|
case ListenerTailscale:
|
||||||
|
return app.serveTailscale, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("invalid listener type: %d", listenerType)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (app *BootstrapApp) serveHTTP(ctx context.Context) error {
|
func (app *BootstrapApp) serveHTTP(ctx context.Context) error {
|
||||||
address := fmt.Sprintf("%s:%d", app.config.Server.Address, app.config.Server.Port)
|
address := fmt.Sprintf("%s:%d", app.config.Server.Address, app.config.Server.Port)
|
||||||
|
|
||||||
app.log.App.Info().Msgf("Starting server on http://%s", address)
|
app.log.App.Info().Msgf("Starting server on %s", address)
|
||||||
|
|
||||||
listener, err := net.Listen("tcp", address)
|
listener, err := net.Listen("tcp", address)
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
|
|
||||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||||
"go.uber.org/dig"
|
"go.uber.org/dig"
|
||||||
@@ -62,7 +60,7 @@ type ACRUI struct {
|
|||||||
type ACRApp struct {
|
type ACRApp struct {
|
||||||
AppURL string `json:"appUrl"`
|
AppURL string `json:"appUrl"`
|
||||||
CookieDomain string `json:"cookieDomain"`
|
CookieDomain string `json:"cookieDomain"`
|
||||||
SubdomainsEnabled bool `json:"subdomainsEnabled"`
|
TrustedDomains []string `json:"trustedDomains"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AppContextResponse struct {
|
type AppContextResponse struct {
|
||||||
@@ -111,9 +109,7 @@ func (controller *ContextController) userContextHandler(c *gin.Context) {
|
|||||||
context, err := new(model.UserContext).NewFromGin(c)
|
context, err := new(model.UserContext).NewFromGin(c)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if !errors.Is(err, model.ErrUserContextNotFound) {
|
|
||||||
controller.log.App.Error().Err(err).Msg("Failed to create user context from request")
|
controller.log.App.Error().Err(err).Msg("Failed to create user context from request")
|
||||||
}
|
|
||||||
c.JSON(200, UserContextResponse{
|
c.JSON(200, UserContextResponse{
|
||||||
Status: 401,
|
Status: 401,
|
||||||
Message: "Unauthorized",
|
Message: "Unauthorized",
|
||||||
@@ -166,7 +162,7 @@ func (controller *ContextController) appContextHandler(c *gin.Context) {
|
|||||||
App: ACRApp{
|
App: ACRApp{
|
||||||
AppURL: controller.runtime.AppURL,
|
AppURL: controller.runtime.AppURL,
|
||||||
CookieDomain: controller.runtime.CookieDomain,
|
CookieDomain: controller.runtime.CookieDomain,
|
||||||
SubdomainsEnabled: controller.config.Auth.SubdomainsEnabled,
|
TrustedDomains: controller.runtime.TrustedDomains,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ func TestContextController(t *testing.T) {
|
|||||||
App: ACRApp{
|
App: ACRApp{
|
||||||
AppURL: runtime.AppURL,
|
AppURL: runtime.AppURL,
|
||||||
CookieDomain: runtime.CookieDomain,
|
CookieDomain: runtime.CookieDomain,
|
||||||
SubdomainsEnabled: cfg.Auth.SubdomainsEnabled,
|
TrustedDomains: runtime.TrustedDomains,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
bytes, err := json.Marshal(expectedAppContextResponse)
|
bytes, err := json.Marshal(expectedAppContextResponse)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/tinyauthapp/tinyauth/internal/service"
|
"github.com/tinyauthapp/tinyauth/internal/service"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/utils"
|
"github.com/tinyauthapp/tinyauth/internal/utils"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||||
|
"github.com/weppos/publicsuffix-go/publicsuffix"
|
||||||
"go.uber.org/dig"
|
"go.uber.org/dig"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -27,6 +28,7 @@ type OAuthController struct {
|
|||||||
config *model.Config
|
config *model.Config
|
||||||
runtime *model.RuntimeConfig
|
runtime *model.RuntimeConfig
|
||||||
auth *service.AuthService
|
auth *service.AuthService
|
||||||
|
helpers *model.RuntimeHelpers
|
||||||
}
|
}
|
||||||
|
|
||||||
type OAuthControllerInput struct {
|
type OAuthControllerInput struct {
|
||||||
@@ -35,6 +37,7 @@ type OAuthControllerInput struct {
|
|||||||
Log *logger.Logger
|
Log *logger.Logger
|
||||||
Config *model.Config
|
Config *model.Config
|
||||||
RuntimeConfig *model.RuntimeConfig
|
RuntimeConfig *model.RuntimeConfig
|
||||||
|
Helpers *model.RuntimeHelpers
|
||||||
RouterGroup *gin.RouterGroup `name:"apiRouterGroup"`
|
RouterGroup *gin.RouterGroup `name:"apiRouterGroup"`
|
||||||
AuthService *service.AuthService
|
AuthService *service.AuthService
|
||||||
}
|
}
|
||||||
@@ -45,6 +48,7 @@ func NewOAuthController(i OAuthControllerInput) *OAuthController {
|
|||||||
config: i.Config,
|
config: i.Config,
|
||||||
runtime: i.RuntimeConfig,
|
runtime: i.RuntimeConfig,
|
||||||
auth: i.AuthService,
|
auth: i.AuthService,
|
||||||
|
helpers: i.Helpers,
|
||||||
}
|
}
|
||||||
|
|
||||||
oauthGroup := i.RouterGroup.Group("/oauth")
|
oauthGroup := i.RouterGroup.Group("/oauth")
|
||||||
@@ -109,7 +113,18 @@ func (controller *OAuthController) oauthURLHandler(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.SetCookie(controller.runtime.OAuthSessionCookieName, sessionId, int(time.Hour.Seconds()), "/", controller.getCookieDomain(), controller.config.Auth.SecureCookie, true)
|
cookieDomain, err := controller.helpers.GetCookieDomain(c, c.RemoteIP())
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
controller.log.App.Error().Err(err).Msg("Failed to determine cookie domain")
|
||||||
|
c.JSON(500, gin.H{
|
||||||
|
"status": 500,
|
||||||
|
"message": "Internal Server Error",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.SetCookie(controller.runtime.OAuthSessionCookieName, sessionId, int(time.Hour.Seconds()), "/", cookieDomain, controller.config.Auth.SecureCookie, true)
|
||||||
|
|
||||||
c.JSON(200, gin.H{
|
c.JSON(200, gin.H{
|
||||||
"status": 200,
|
"status": 200,
|
||||||
@@ -139,7 +154,15 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.SetCookie(controller.runtime.OAuthSessionCookieName, "", -1, "/", controller.getCookieDomain(), controller.config.Auth.SecureCookie, true)
|
cookieDomain, err := controller.helpers.GetCookieDomain(c, c.RemoteIP())
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
controller.log.App.Error().Err(err).Msg("Failed to determine cookie domain")
|
||||||
|
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.runtime.AppURL))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.SetCookie(controller.runtime.OAuthSessionCookieName, "", -1, "/", cookieDomain, controller.config.Auth.SecureCookie, true)
|
||||||
|
|
||||||
oauthPendingSession, err := controller.auth.GetOAuthPendingSession(sessionIdCookie)
|
oauthPendingSession, err := controller.auth.GetOAuthPendingSession(sessionIdCookie)
|
||||||
|
|
||||||
@@ -256,7 +279,7 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
|
|||||||
|
|
||||||
controller.log.App.Debug().Msg("Creating session cookie for user")
|
controller.log.App.Debug().Msg("Creating session cookie for user")
|
||||||
|
|
||||||
cookie, err := controller.auth.CreateSession(c, sessionCookie)
|
cookie, err := controller.auth.CreateSession(c, sessionCookie, c.RemoteIP())
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
controller.log.App.Error().Err(err).Msg("Failed to create session cookie")
|
controller.log.App.Error().Err(err).Msg("Failed to create session cookie")
|
||||||
@@ -304,8 +327,8 @@ func (controller *OAuthController) isOidcRequest(params service.OAuthCallbackPar
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (controller *OAuthController) getCookieDomain() string {
|
func (controller *OAuthController) getCookieDomain() string {
|
||||||
if !controller.config.Auth.SubdomainsEnabled {
|
if controller.config.Auth.SubdomainsEnabled {
|
||||||
return ""
|
return "." + controller.runtime.CookieDomain
|
||||||
}
|
}
|
||||||
return controller.runtime.CookieDomain
|
return controller.runtime.CookieDomain
|
||||||
}
|
}
|
||||||
@@ -313,54 +336,52 @@ func (controller *OAuthController) getCookieDomain() string {
|
|||||||
func (controller *OAuthController) isRedirectSafe(redirectURI string) bool {
|
func (controller *OAuthController) isRedirectSafe(redirectURI string) bool {
|
||||||
u, err := url.Parse(redirectURI)
|
u, err := url.Parse(redirectURI)
|
||||||
|
|
||||||
|
if err != nil || u.Host == "" || u.Scheme == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, allowed := range controller.runtime.TrustedDomains {
|
||||||
|
tu, err := url.Parse(allowed)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
controller.log.App.Error().Err(err).Msg("Failed to parse redirect URI")
|
controller.log.App.Error().Err(err).Str("allowed", allowed).Msg("Failed to parse trusted domain")
|
||||||
return false
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if u.Scheme == "" || u.Host == "" {
|
if tu.Scheme != u.Scheme {
|
||||||
controller.log.App.Warn().Msg("Redirect URI has invalid scheme or host")
|
continue
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
au, err := url.Parse(controller.runtime.AppURL)
|
// exact match
|
||||||
|
if strings.EqualFold(u.Host, tu.Host) {
|
||||||
if err != nil {
|
|
||||||
controller.log.App.Error().Err(err).Msg("Failed to parse app URL")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if u.Scheme != au.Scheme {
|
|
||||||
controller.log.App.Warn().Msg("Redirect URI scheme does not match app URL scheme")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
getEffectivePort := func(u *url.URL) string {
|
|
||||||
if u.Port() != "" {
|
|
||||||
return u.Port()
|
|
||||||
}
|
|
||||||
if u.Scheme == "https" {
|
|
||||||
return "443"
|
|
||||||
}
|
|
||||||
return "80"
|
|
||||||
}
|
|
||||||
|
|
||||||
if getEffectivePort(u) != getEffectivePort(au) {
|
|
||||||
controller.log.App.Warn().Msg("Redirect URI port does not match app URL port")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.EqualFold(u.Hostname(), au.Hostname()) {
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// if subdomains are disabled, end here
|
||||||
if !controller.config.Auth.SubdomainsEnabled {
|
if !controller.config.Auth.SubdomainsEnabled {
|
||||||
return false
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.HasSuffix(strings.ToLower(u.Hostname()), "."+strings.ToLower(controller.runtime.CookieDomain)) {
|
// get the root domain (e.g. tinyauth.example.com -> example.com or
|
||||||
|
// tinyauth.sub.example.com -> sub.example.com)
|
||||||
|
_, root, ok := strings.Cut(tu.Host, ".")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
root = strings.ToLower(root)
|
||||||
|
|
||||||
|
// check if the root domain is in the psl
|
||||||
|
_, err = publicsuffix.DomainFromListWithOptions(publicsuffix.DefaultList, root, nil)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// subdomain match
|
||||||
|
if strings.HasSuffix(strings.ToLower(u.Host), "."+root) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestOAuthControllerIsRedirectSafe(t *testing.T) {
|
func TestOAuthController(t *testing.T) {
|
||||||
log := logger.NewLogger().WithTestConfig()
|
log := logger.NewLogger().WithTestConfig()
|
||||||
log.Init()
|
log.Init()
|
||||||
|
|
||||||
@@ -17,171 +17,145 @@ func TestOAuthControllerIsRedirectSafe(t *testing.T) {
|
|||||||
|
|
||||||
type testCase struct {
|
type testCase struct {
|
||||||
description string
|
description string
|
||||||
appURL string
|
run func(ctrl *OAuthController)
|
||||||
cookieDomain string
|
trustedDomains []string
|
||||||
subdomainsEnabled bool
|
subdomainsEnabled bool
|
||||||
redirectURI string
|
|
||||||
expected bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
tests := []testCase{
|
tests := []testCase{
|
||||||
{
|
{
|
||||||
description: "Exact host match returns true",
|
description: "Test exact match of redirect URI",
|
||||||
appURL: "https://tinyauth.example.com",
|
trustedDomains: []string{"https://tinyauth.example.com"},
|
||||||
cookieDomain: "example.com",
|
|
||||||
subdomainsEnabled: true,
|
subdomainsEnabled: true,
|
||||||
redirectURI: "https://tinyauth.example.com",
|
run: func(ctrl *OAuthController) {
|
||||||
expected: true,
|
redirectUri := "https://tinyauth.example.com"
|
||||||
|
assert.True(t, ctrl.isRedirectSafe(redirectUri))
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: "Exact host match is case insensitive",
|
description: "Test subdomain match of redirect URI",
|
||||||
appURL: "https://tinyauth.example.com",
|
trustedDomains: []string{"https://tinyauth.example.com"},
|
||||||
cookieDomain: "example.com",
|
|
||||||
subdomainsEnabled: true,
|
subdomainsEnabled: true,
|
||||||
redirectURI: "https://TinyAuth.Example.com",
|
run: func(ctrl *OAuthController) {
|
||||||
expected: true,
|
redirectUri := "https://sub.example.com"
|
||||||
|
assert.True(t, ctrl.isRedirectSafe(redirectUri))
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: "Exact host match with subdomains disabled returns true",
|
description: "Test different trusted domain",
|
||||||
appURL: "https://tinyauth.example.com",
|
trustedDomains: []string{"https://tinyauth.example.com", "https://tinyauth.foo.com"},
|
||||||
cookieDomain: "example.com",
|
subdomainsEnabled: true,
|
||||||
|
run: func(ctrl *OAuthController) {
|
||||||
|
redirectUri := "https://app.foo.com"
|
||||||
|
assert.True(t, ctrl.isRedirectSafe(redirectUri))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Test invalid redirect URI",
|
||||||
|
run: func(ctrl *OAuthController) {
|
||||||
|
redirectUri := "https:/malicious"
|
||||||
|
assert.False(t, ctrl.isRedirectSafe(redirectUri))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Test empty redirect URI",
|
||||||
|
run: func(ctrl *OAuthController) {
|
||||||
|
redirectUri := ""
|
||||||
|
assert.False(t, ctrl.isRedirectSafe(redirectUri))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Test redirect URI with different scheme",
|
||||||
|
trustedDomains: []string{"https://tinyauth.example.com"},
|
||||||
|
subdomainsEnabled: true,
|
||||||
|
run: func(ctrl *OAuthController) {
|
||||||
|
redirectUri := "http://tinyauth.example.com"
|
||||||
|
assert.False(t, ctrl.isRedirectSafe(redirectUri))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Test redirect URI with different port",
|
||||||
|
trustedDomains: []string{"https://tinyauth.example.com"},
|
||||||
|
subdomainsEnabled: true,
|
||||||
|
run: func(ctrl *OAuthController) {
|
||||||
|
redirectUri := "https://tinyauth.example.com:8080"
|
||||||
|
assert.False(t, ctrl.isRedirectSafe(redirectUri))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// weird case, subdomains enabled and domain without subdomain can't happen
|
||||||
|
description: "Test with trusted domain that's in PSL when split",
|
||||||
|
trustedDomains: []string{"https://example.com"}, // will become .com which we
|
||||||
|
// obviously don't want to allow
|
||||||
|
subdomainsEnabled: true,
|
||||||
|
run: func(ctrl *OAuthController) {
|
||||||
|
redirectUri := "https://sub.example.com"
|
||||||
|
assert.False(t, ctrl.isRedirectSafe(redirectUri))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Test subdomain redirect URI when subdomains are disabled",
|
||||||
|
trustedDomains: []string{"https://tinyauth.example.com"},
|
||||||
subdomainsEnabled: false,
|
subdomainsEnabled: false,
|
||||||
redirectURI: "https://tinyauth.example.com",
|
run: func(ctrl *OAuthController) {
|
||||||
expected: true,
|
redirectUri := "https://sub.tinyauth.example.com"
|
||||||
|
assert.False(t, ctrl.isRedirectSafe(redirectUri))
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: "Subdomain of cookie domain returns true when subdomains enabled",
|
description: "Test domain like the .co.uk",
|
||||||
appURL: "https://tinyauth.example.com",
|
trustedDomains: []string{"https://example.co.uk"},
|
||||||
cookieDomain: "example.com",
|
|
||||||
subdomainsEnabled: true,
|
subdomainsEnabled: true,
|
||||||
redirectURI: "https://sub.example.com",
|
run: func(ctrl *OAuthController) {
|
||||||
expected: true,
|
redirectUri := "https://sub.example.co.uk"
|
||||||
|
assert.False(t, ctrl.isRedirectSafe(redirectUri))
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: "Subdomain of cookie domain is case insensitive",
|
description: "Test domain like the .co.uk with subdomains disabled",
|
||||||
appURL: "https://tinyauth.example.com",
|
trustedDomains: []string{"https://example.co.uk"},
|
||||||
cookieDomain: "Example.COM",
|
|
||||||
subdomainsEnabled: true,
|
|
||||||
redirectURI: "https://SUB.example.com",
|
|
||||||
expected: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Subdomain not matching cookie domain returns false",
|
|
||||||
appURL: "https://tinyauth.example.com",
|
|
||||||
cookieDomain: "example.com",
|
|
||||||
subdomainsEnabled: true,
|
|
||||||
redirectURI: "https://sub.evil.com",
|
|
||||||
expected: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Subdomain returns false when subdomains disabled",
|
|
||||||
appURL: "https://tinyauth.example.com",
|
|
||||||
cookieDomain: "example.com",
|
|
||||||
subdomainsEnabled: false,
|
subdomainsEnabled: false,
|
||||||
redirectURI: "https://sub.example.com",
|
run: func(ctrl *OAuthController) {
|
||||||
expected: false,
|
redirectUri := "https://example.co.uk"
|
||||||
|
assert.True(t, ctrl.isRedirectSafe(redirectUri))
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: "Cookie domain itself is not a subdomain match",
|
description: "Test caps domain",
|
||||||
appURL: "https://tinyauth.example.com",
|
trustedDomains: []string{"https://TINYAUTH.ExAmpLe.com"},
|
||||||
cookieDomain: "example.com",
|
|
||||||
subdomainsEnabled: true,
|
subdomainsEnabled: true,
|
||||||
redirectURI: "https://example.com",
|
run: func(ctrl *OAuthController) {
|
||||||
expected: false,
|
redirectUri := "https://sUb.ExAmPle.com"
|
||||||
|
assert.True(t, ctrl.isRedirectSafe(redirectUri))
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: "Different scheme returns false",
|
description: "Test edge case with @",
|
||||||
appURL: "https://tinyauth.example.com",
|
trustedDomains: []string{"https://tinyauth.example.com"},
|
||||||
cookieDomain: "example.com",
|
|
||||||
subdomainsEnabled: true,
|
subdomainsEnabled: true,
|
||||||
redirectURI: "http://tinyauth.example.com",
|
run: func(ctrl *OAuthController) {
|
||||||
expected: false,
|
redirectUri := "https://malicious.example.com@evil.com"
|
||||||
|
assert.False(t, ctrl.isRedirectSafe(redirectUri))
|
||||||
},
|
},
|
||||||
{
|
|
||||||
description: "Different port returns false",
|
|
||||||
appURL: "https://tinyauth.example.com",
|
|
||||||
cookieDomain: "example.com",
|
|
||||||
subdomainsEnabled: true,
|
|
||||||
redirectURI: "https://tinyauth.example.com:8080",
|
|
||||||
expected: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Empty redirect URI returns false",
|
|
||||||
appURL: "https://tinyauth.example.com",
|
|
||||||
cookieDomain: "example.com",
|
|
||||||
subdomainsEnabled: true,
|
|
||||||
redirectURI: "",
|
|
||||||
expected: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Redirect URI without host returns false",
|
|
||||||
appURL: "https://tinyauth.example.com",
|
|
||||||
cookieDomain: "example.com",
|
|
||||||
subdomainsEnabled: true,
|
|
||||||
redirectURI: "https:/malicious",
|
|
||||||
expected: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Redirect URI without scheme returns false",
|
|
||||||
appURL: "https://tinyauth.example.com",
|
|
||||||
cookieDomain: "example.com",
|
|
||||||
subdomainsEnabled: true,
|
|
||||||
redirectURI: "tinyauth.example.com",
|
|
||||||
expected: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Relative redirect URI returns false",
|
|
||||||
appURL: "https://tinyauth.example.com",
|
|
||||||
cookieDomain: "example.com",
|
|
||||||
subdomainsEnabled: true,
|
|
||||||
redirectURI: "/some/path",
|
|
||||||
expected: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Userinfo trick with malicious host returns false",
|
|
||||||
appURL: "https://tinyauth.example.com",
|
|
||||||
cookieDomain: "example.com",
|
|
||||||
subdomainsEnabled: true,
|
|
||||||
redirectURI: "https://malicious.example.com@evil.com",
|
|
||||||
expected: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Unparseable redirect URI returns false",
|
|
||||||
appURL: "https://tinyauth.example.com",
|
|
||||||
cookieDomain: "example.com",
|
|
||||||
subdomainsEnabled: true,
|
|
||||||
redirectURI: "https://exa\x7fmple.com",
|
|
||||||
expected: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Unparseable app URL returns false",
|
|
||||||
appURL: "https://tinyauth.\x7fexample.com",
|
|
||||||
cookieDomain: "example.com",
|
|
||||||
subdomainsEnabled: true,
|
|
||||||
redirectURI: "https://tinyauth.example.com",
|
|
||||||
expected: false,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: add auth service
|
||||||
for _, tc := range tests {
|
for _, tc := range tests {
|
||||||
t.Run(tc.description, func(t *testing.T) {
|
t.Run(tc.description, func(t *testing.T) {
|
||||||
router := gin.Default()
|
router := gin.Default()
|
||||||
group := router.Group("/api")
|
group := router.Group("/api")
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
|
// overwrite the trusted domains and subdomain setting for each test case
|
||||||
// Overwrite the app URL, cookie domain and subdomain setting for each test case
|
runtime.TrustedDomains = tc.trustedDomains
|
||||||
runtime.AppURL = tc.appURL
|
|
||||||
runtime.CookieDomain = tc.cookieDomain
|
|
||||||
cfg.Auth.SubdomainsEnabled = tc.subdomainsEnabled
|
cfg.Auth.SubdomainsEnabled = tc.subdomainsEnabled
|
||||||
|
|
||||||
ctrl := NewOAuthController(OAuthControllerInput{
|
ctrl := NewOAuthController(OAuthControllerInput{
|
||||||
Log: log,
|
Log: log,
|
||||||
Config: &cfg,
|
Config: &cfg,
|
||||||
RuntimeConfig: &runtime,
|
RuntimeConfig: &runtime,
|
||||||
RouterGroup: group,
|
RouterGroup: group,
|
||||||
})
|
})
|
||||||
|
tc.run(ctrl)
|
||||||
assert.Equal(t, tc.expected, ctrl.isRedirectSafe(tc.redirectURI))
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -34,6 +35,8 @@ type OIDCController struct {
|
|||||||
log *logger.Logger
|
log *logger.Logger
|
||||||
oidc *service.OIDCService
|
oidc *service.OIDCService
|
||||||
runtime *model.RuntimeConfig
|
runtime *model.RuntimeConfig
|
||||||
|
helpers *model.RuntimeHelpers
|
||||||
|
config *model.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuthorizeCallback struct {
|
type AuthorizeCallback struct {
|
||||||
@@ -90,6 +93,8 @@ type OIDCControllerInput struct {
|
|||||||
RuntimeConfig *model.RuntimeConfig
|
RuntimeConfig *model.RuntimeConfig
|
||||||
RouterGroup *gin.RouterGroup `name:"apiRouterGroup"`
|
RouterGroup *gin.RouterGroup `name:"apiRouterGroup"`
|
||||||
MainRouter *gin.RouterGroup `name:"mainRouterGroup"`
|
MainRouter *gin.RouterGroup `name:"mainRouterGroup"`
|
||||||
|
Helpers *model.RuntimeHelpers
|
||||||
|
Config *model.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewOIDCController(i OIDCControllerInput) *OIDCController {
|
func NewOIDCController(i OIDCControllerInput) *OIDCController {
|
||||||
@@ -97,6 +102,8 @@ func NewOIDCController(i OIDCControllerInput) *OIDCController {
|
|||||||
log: i.Log,
|
log: i.Log,
|
||||||
oidc: i.OIDCService,
|
oidc: i.OIDCService,
|
||||||
runtime: i.RuntimeConfig,
|
runtime: i.RuntimeConfig,
|
||||||
|
helpers: i.Helpers,
|
||||||
|
config: i.Config,
|
||||||
}
|
}
|
||||||
|
|
||||||
i.MainRouter.POST("/authorize", controller.authorize)
|
i.MainRouter.POST("/authorize", controller.authorize)
|
||||||
@@ -219,6 +226,25 @@ func (controller *OIDCController) authorize(c *gin.Context) {
|
|||||||
values.OIDCPrompt = service.OIDCPromptNone
|
values.OIDCPrompt = service.OIDCPromptNone
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If no prompt is already set, we can check if we can/should skip it based on the cookie
|
||||||
|
if values.OIDCPrompt == "" {
|
||||||
|
consnetCookie, err := c.Cookie(controller.runtime.ConsentCookieName)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
consentEntry, err := controller.oidc.GetConsentEntry(c, consnetCookie)
|
||||||
|
|
||||||
|
if err == nil && consentEntry != nil {
|
||||||
|
if consentEntry.ClientID == req.ClientID && consentEntry.Scopes == req.Scope {
|
||||||
|
values.OIDCPrompt = service.OIDCPromptNone
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
controller.log.App.Error().Err(err).Msg("Failed to get consent entry for consent cookie")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if req.MaxAge != "" && userContext != nil {
|
if req.MaxAge != "" && userContext != nil {
|
||||||
maxAge, err := strconv.Atoi(req.MaxAge)
|
maxAge, err := strconv.Atoi(req.MaxAge)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -361,6 +387,33 @@ func (controller *OIDCController) authorizeComplete(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Just before returning let's set the consent cookie
|
||||||
|
consnetUUID, err := controller.oidc.CreateConsentEntry(c, authorizeReq.ClientID, authorizeReq.Scope)
|
||||||
|
|
||||||
|
// If we fail to create the consent entry, we don't want to block the authorization flow,
|
||||||
|
// but we log the error and move on without setting the cookie
|
||||||
|
if err == nil {
|
||||||
|
cookieDomain, err := controller.helpers.GetCookieDomain(c.Request.Context(), c.RemoteIP())
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
cookie := &http.Cookie{
|
||||||
|
Name: controller.runtime.ConsentCookieName,
|
||||||
|
Value: consnetUUID,
|
||||||
|
Path: "/",
|
||||||
|
Domain: cookieDomain,
|
||||||
|
Expires: time.Now().Add(365 * 24 * time.Hour), // set consent cookie for 1 year
|
||||||
|
Secure: controller.config.Auth.SecureCookie,
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
}
|
||||||
|
http.SetCookie(c.Writer, cookie)
|
||||||
|
} else {
|
||||||
|
controller.log.App.Error().Err(err).Msg("Failed to determine cookie domain for consent cookie")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
controller.log.App.Error().Err(err).Msg("Failed to create consent entry")
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(200, gin.H{
|
c.JSON(200, gin.H{
|
||||||
"status": 200,
|
"status": 200,
|
||||||
"redirect_uri": fmt.Sprintf("%s?%s", authorizeReq.RedirectURI, queries.Encode()),
|
"redirect_uri": fmt.Sprintf("%s?%s", authorizeReq.RedirectURI, queries.Encode()),
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ func TestOIDCController(t *testing.T) {
|
|||||||
|
|
||||||
cfg, runtime := test.CreateTestConfigs(t)
|
cfg, runtime := test.CreateTestConfigs(t)
|
||||||
|
|
||||||
|
helpers := test.CreateTestHelpers()
|
||||||
|
|
||||||
ctx := context.TODO()
|
ctx := context.TODO()
|
||||||
dg := ding.New(ctx)
|
dg := ding.New(ctx)
|
||||||
|
|
||||||
@@ -862,6 +864,8 @@ func TestOIDCController(t *testing.T) {
|
|||||||
RuntimeConfig: &runtime,
|
RuntimeConfig: &runtime,
|
||||||
RouterGroup: group,
|
RouterGroup: group,
|
||||||
MainRouter: &router.RouterGroup,
|
MainRouter: &router.RouterGroup,
|
||||||
|
Helpers: helpers,
|
||||||
|
Config: &cfg,
|
||||||
})
|
})
|
||||||
|
|
||||||
recorder := httptest.NewRecorder()
|
recorder := httptest.NewRecorder()
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ func TestProxyController(t *testing.T) {
|
|||||||
|
|
||||||
cfg, runtime := test.CreateTestConfigs(t)
|
cfg, runtime := test.CreateTestConfigs(t)
|
||||||
|
|
||||||
|
helpers := test.CreateTestHelpers()
|
||||||
|
|
||||||
const browserUserAgent = `
|
const browserUserAgent = `
|
||||||
Mozilla/5.0 (Linux; Android 8.0.0; SM-G955U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Mobile Safari/537.36`
|
Mozilla/5.0 (Linux; Android 8.0.0; SM-G955U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Mobile Safari/537.36`
|
||||||
|
|
||||||
@@ -719,6 +721,7 @@ func TestProxyController(t *testing.T) {
|
|||||||
OAuthBroker: broker,
|
OAuthBroker: broker,
|
||||||
Tailscale: nil,
|
Tailscale: nil,
|
||||||
PolicyEngine: policyEngine,
|
PolicyEngine: policyEngine,
|
||||||
|
Helpers: helpers,
|
||||||
})
|
})
|
||||||
|
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ func (controller *UserController) loginHandler(c *gin.Context) {
|
|||||||
Email: email,
|
Email: email,
|
||||||
Provider: "local",
|
Provider: "local",
|
||||||
TotpPending: true,
|
TotpPending: true,
|
||||||
})
|
}, c.RemoteIP())
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
controller.log.App.Error().Err(err).Str("username", req.Username).Msg("Failed to create pending TOTP session")
|
controller.log.App.Error().Err(err).Str("username", req.Username).Msg("Failed to create pending TOTP session")
|
||||||
@@ -200,7 +200,7 @@ func (controller *UserController) loginHandler(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cookie, err := controller.auth.CreateSession(c, sessionCookie)
|
cookie, err := controller.auth.CreateSession(c, sessionCookie, c.RemoteIP())
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
controller.log.App.Error().Err(err).Str("username", req.Username).Msg("Failed to create session cookie after successful login")
|
controller.log.App.Error().Err(err).Str("username", req.Username).Msg("Failed to create session cookie after successful login")
|
||||||
@@ -251,7 +251,7 @@ func (controller *UserController) logoutHandler(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
cookie, err := controller.auth.DeleteSession(c, uuid)
|
cookie, err := controller.auth.DeleteSession(c, uuid, c.RemoteIP())
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
controller.log.App.Error().Err(err).Msg("Error deleting session on logout")
|
controller.log.App.Error().Err(err).Msg("Error deleting session on logout")
|
||||||
@@ -295,14 +295,6 @@ func (controller *UserController) totpHandler(c *gin.Context) {
|
|||||||
context, err := new(model.UserContext).NewFromGin(c)
|
context, err := new(model.UserContext).NewFromGin(c)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, model.ErrUserContextNotFound) {
|
|
||||||
controller.log.App.Warn().Msg("TOTP verification attempt without user context")
|
|
||||||
c.JSON(401, gin.H{
|
|
||||||
"status": 401,
|
|
||||||
"message": "Unauthorized",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
controller.log.App.Error().Err(err).Msg("Failed to create user context from request for TOTP verification")
|
controller.log.App.Error().Err(err).Msg("Failed to create user context from request for TOTP verification")
|
||||||
c.JSON(500, gin.H{
|
c.JSON(500, gin.H{
|
||||||
"status": 500,
|
"status": 500,
|
||||||
@@ -363,7 +355,7 @@ func (controller *UserController) totpHandler(c *gin.Context) {
|
|||||||
uuid, err := c.Cookie(controller.runtime.SessionCookieName)
|
uuid, err := c.Cookie(controller.runtime.SessionCookieName)
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
_, err = controller.auth.DeleteSession(c, uuid)
|
_, err = controller.auth.DeleteSession(c, uuid, c.RemoteIP())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
controller.log.App.Error().Err(err).Msg("Failed to delete pending TOTP session after successful verification")
|
controller.log.App.Error().Err(err).Msg("Failed to delete pending TOTP session after successful verification")
|
||||||
}
|
}
|
||||||
@@ -387,7 +379,7 @@ func (controller *UserController) totpHandler(c *gin.Context) {
|
|||||||
sessionCookie.Email = user.Attributes.Email
|
sessionCookie.Email = user.Attributes.Email
|
||||||
}
|
}
|
||||||
|
|
||||||
cookie, err := controller.auth.CreateSession(c, sessionCookie)
|
cookie, err := controller.auth.CreateSession(c, sessionCookie, c.RemoteIP())
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
controller.log.App.Error().Err(err).Str("username", context.GetUsername()).Msg("Failed to create session cookie after successful TOTP verification")
|
controller.log.App.Error().Err(err).Str("username", context.GetUsername()).Msg("Failed to create session cookie after successful TOTP verification")
|
||||||
@@ -413,14 +405,6 @@ func (controller *UserController) tailscaleHandler(c *gin.Context) {
|
|||||||
context, err := new(model.UserContext).NewFromGin(c)
|
context, err := new(model.UserContext).NewFromGin(c)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, model.ErrUserContextNotFound) {
|
|
||||||
controller.log.App.Warn().Msg("Tailscale login attempt without user context")
|
|
||||||
c.JSON(401, gin.H{
|
|
||||||
"status": 401,
|
|
||||||
"message": "Unauthorized",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
controller.log.App.Error().Err(err).Msg("Failed to create user context from request")
|
controller.log.App.Error().Err(err).Msg("Failed to create user context from request")
|
||||||
c.JSON(401, gin.H{
|
c.JSON(401, gin.H{
|
||||||
"status": 401,
|
"status": 401,
|
||||||
@@ -445,7 +429,7 @@ func (controller *UserController) tailscaleHandler(c *gin.Context) {
|
|||||||
Provider: "tailscale",
|
Provider: "tailscale",
|
||||||
}
|
}
|
||||||
|
|
||||||
cookie, err := controller.auth.CreateSession(c, sessionCookie)
|
cookie, err := controller.auth.CreateSession(c, sessionCookie, c.RemoteIP())
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
controller.log.App.Error().Err(err).Str("username", context.GetUsername()).Msg("Failed to create session cookie after successful Tailscale login")
|
controller.log.App.Error().Err(err).Str("username", context.GetUsername()).Msg("Failed to create session cookie after successful Tailscale login")
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ func TestUserController(t *testing.T) {
|
|||||||
|
|
||||||
cfg, runtime := test.CreateTestConfigs(t)
|
cfg, runtime := test.CreateTestConfigs(t)
|
||||||
|
|
||||||
|
helpers := test.CreateTestHelpers()
|
||||||
|
|
||||||
totpCtx := func(c *gin.Context) {
|
totpCtx := func(c *gin.Context) {
|
||||||
c.Set("context", &model.UserContext{
|
c.Set("context", &model.UserContext{
|
||||||
Authenticated: false,
|
Authenticated: false,
|
||||||
@@ -553,6 +555,7 @@ func TestUserController(t *testing.T) {
|
|||||||
OAuthBroker: broker,
|
OAuthBroker: broker,
|
||||||
Tailscale: nil,
|
Tailscale: nil,
|
||||||
PolicyEngine: policyEngine,
|
PolicyEngine: policyEngine,
|
||||||
|
Helpers: helpers,
|
||||||
})
|
})
|
||||||
|
|
||||||
beforeEach := func() {
|
beforeEach := func() {
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc {
|
|||||||
uuid, err := c.Cookie(m.runtime.SessionCookieName)
|
uuid, err := c.Cookie(m.runtime.SessionCookieName)
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
userContext, cookie, err := m.cookieAuth(c.Request.Context(), uuid, c.ClientIP())
|
userContext, cookie, err := m.cookieAuth(c.Request.Context(), uuid, c.RemoteIP())
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
if cookie != nil {
|
if cookie != nil {
|
||||||
@@ -112,10 +112,10 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc {
|
|||||||
|
|
||||||
// Lastly check if we have a tailscale session to add
|
// Lastly check if we have a tailscale session to add
|
||||||
if m.tailscale != nil {
|
if m.tailscale != nil {
|
||||||
tailscaleContext, err := m.tailscaleWhois(c.Request.Context(), c.ClientIP())
|
tailscaleContext, err := m.tailscaleWhois(c.Request.Context(), c.RemoteIP())
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
m.log.App.Error().Err(err).Msgf("Error performing tailscale whois for IP %s: %v", c.ClientIP(), err)
|
m.log.App.Error().Err(err).Msgf("Error performing tailscale whois for IP %s: %v", c.RemoteIP(), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if tailscaleContext != nil {
|
if tailscaleContext != nil {
|
||||||
@@ -211,12 +211,12 @@ func (m *ContextMiddleware) cookieAuth(ctx context.Context, uuid string, ip stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !m.auth.IsEmailWhitelisted(userContext.OAuth.ID, userContext.OAuth.Email) {
|
if !m.auth.IsEmailWhitelisted(userContext.OAuth.ID, userContext.OAuth.Email) {
|
||||||
m.auth.DeleteSession(ctx, uuid)
|
m.auth.DeleteSession(ctx, uuid, ip)
|
||||||
return nil, nil, fmt.Errorf("email from session cookie not whitelisted: %s", userContext.OAuth.Email)
|
return nil, nil, fmt.Errorf("email from session cookie not whitelisted: %s", userContext.OAuth.Email)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cookie, err := m.auth.RefreshSession(ctx, uuid)
|
cookie, err := m.auth.RefreshSession(ctx, uuid, ip)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("error refreshing session: %w", err)
|
return nil, nil, fmt.Errorf("error refreshing session: %w", err)
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ func TestContextMiddleware(t *testing.T) {
|
|||||||
|
|
||||||
cfg, runtime := test.CreateTestConfigs(t)
|
cfg, runtime := test.CreateTestConfigs(t)
|
||||||
|
|
||||||
|
helpers := test.CreateTestHelpers()
|
||||||
|
|
||||||
basicAuthHeader := func(username, password string) string {
|
basicAuthHeader := func(username, password string) string {
|
||||||
return "Basic " + base64.StdEncoding.EncodeToString([]byte(username+":"+password))
|
return "Basic " + base64.StdEncoding.EncodeToString([]byte(username+":"+password))
|
||||||
}
|
}
|
||||||
@@ -275,6 +277,7 @@ func TestContextMiddleware(t *testing.T) {
|
|||||||
OAuthBroker: broker,
|
OAuthBroker: broker,
|
||||||
Tailscale: nil,
|
Tailscale: nil,
|
||||||
PolicyEngine: policyEngine,
|
PolicyEngine: policyEngine,
|
||||||
|
Helpers: helpers,
|
||||||
})
|
})
|
||||||
|
|
||||||
contextMiddleware := NewContextMiddleware(ContextMiddlewareInput{
|
contextMiddleware := NewContextMiddleware(ContextMiddlewareInput{
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ func NewDefaultConfiguration() *Config {
|
|||||||
Server: ServerConfig{
|
Server: ServerConfig{
|
||||||
Port: 3000,
|
Port: 3000,
|
||||||
Address: "0.0.0.0",
|
Address: "0.0.0.0",
|
||||||
|
ConcurrentListenersEnabled: false,
|
||||||
},
|
},
|
||||||
Auth: AuthConfig{
|
Auth: AuthConfig{
|
||||||
SubdomainsEnabled: true,
|
SubdomainsEnabled: true,
|
||||||
@@ -106,6 +107,7 @@ type ServerConfig struct {
|
|||||||
Port int `description:"The port on which the server listens." yaml:"port"`
|
Port int `description:"The port on which the server listens." yaml:"port"`
|
||||||
Address string `description:"The address on which the server listens." yaml:"address"`
|
Address string `description:"The address on which the server listens." yaml:"address"`
|
||||||
SocketPath string `description:"The path to the Unix socket." yaml:"socketPath"`
|
SocketPath string `description:"The path to the Unix socket." yaml:"socketPath"`
|
||||||
|
ConcurrentListenersEnabled bool `description:"Enable listening on both TCP and Unix socket at the same time." yaml:"concurrentListenersEnabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuthConfig struct {
|
type AuthConfig struct {
|
||||||
@@ -216,8 +218,6 @@ type TailscaleConfig struct {
|
|||||||
Hostname string `description:"Tailscale hostname." yaml:"hostname"`
|
Hostname string `description:"Tailscale hostname." yaml:"hostname"`
|
||||||
AuthKey string `description:"Tailscale auth key." yaml:"authKey"`
|
AuthKey string `description:"Tailscale auth key." yaml:"authKey"`
|
||||||
Ephemeral bool `description:"Use ephemeral Tailscale node." yaml:"ephemeral"`
|
Ephemeral bool `description:"Use ephemeral Tailscale node." yaml:"ephemeral"`
|
||||||
Funnel bool `description:"Enable Tailscale Funnel." yaml:"funnel"`
|
|
||||||
Listen bool `description:"Listen on the Tailscale address instead of standard address." yaml:"listen"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// OAuth/OIDC config
|
// OAuth/OIDC config
|
||||||
|
|||||||
@@ -17,11 +17,8 @@ var OverrideProviders = map[string]string{
|
|||||||
"github": "GitHub",
|
"github": "GitHub",
|
||||||
}
|
}
|
||||||
|
|
||||||
var ReservedProviderNames = []string{"local", "ldap", "tailscale"}
|
|
||||||
|
|
||||||
const SessionCookieName = "tinyauth-session"
|
const SessionCookieName = "tinyauth-session"
|
||||||
const CSRFCookieName = "tinyauth-csrf"
|
|
||||||
const RedirectCookieName = "tinyauth-redirect"
|
|
||||||
const OAuthSessionCookieName = "tinyauth-oauth"
|
const OAuthSessionCookieName = "tinyauth-oauth"
|
||||||
|
const ConsentCookieName = "tinyauth-consent"
|
||||||
|
|
||||||
const GracefulShutdownTimeout = 5 // seconds
|
const GracefulShutdownTimeout = 5 // seconds
|
||||||
|
|||||||
@@ -1,17 +1,23 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
type RuntimeConfig struct {
|
type RuntimeConfig struct {
|
||||||
AppURL string
|
AppURL string
|
||||||
UUID string
|
UUID string
|
||||||
CookieDomain string
|
CookieDomain string
|
||||||
SessionCookieName string
|
SessionCookieName string
|
||||||
CSRFCookieName string
|
|
||||||
RedirectCookieName string
|
|
||||||
OAuthSessionCookieName string
|
OAuthSessionCookieName string
|
||||||
|
ConsentCookieName string
|
||||||
LocalUsers []LocalUser
|
LocalUsers []LocalUser
|
||||||
OAuthProviders map[string]OAuthServiceConfig
|
OAuthProviders map[string]OAuthServiceConfig
|
||||||
OAuthWhitelist []string
|
OAuthWhitelist []string
|
||||||
ConfiguredProviders []Provider
|
ConfiguredProviders []Provider
|
||||||
|
TrustedDomains []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type RuntimeHelpers struct {
|
||||||
|
GetCookieDomain func(ctx context.Context, ip string) (string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type Provider struct {
|
type Provider struct {
|
||||||
|
|||||||
@@ -277,6 +277,78 @@ func TestMemoryStore(t *testing.T) {
|
|||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
description: "Create and get OIDC consent",
|
||||||
|
run: func(t *testing.T, s repository.Store) {
|
||||||
|
consent, err := s.CreateOIDCConsent(ctx, repository.CreateOIDCConsentParams{
|
||||||
|
UUID: "uuid-1",
|
||||||
|
ClientID: "client-1",
|
||||||
|
Scopes: "openid profile",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "uuid-1", consent.UUID)
|
||||||
|
assert.Equal(t, "client-1", consent.ClientID)
|
||||||
|
assert.Equal(t, "openid profile", consent.Scopes)
|
||||||
|
|
||||||
|
got, err := s.GetOIDCConsentByUUID(ctx, "uuid-1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, consent, got)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Get OIDC consent by UUID not found",
|
||||||
|
run: func(t *testing.T, s repository.Store) {
|
||||||
|
_, err := s.GetOIDCConsentByUUID(ctx, "missing")
|
||||||
|
assert.ErrorIs(t, err, repository.ErrNotFound)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Create OIDC consent unique UUID constraint",
|
||||||
|
run: func(t *testing.T, s repository.Store) {
|
||||||
|
_, err := s.CreateOIDCConsent(ctx, repository.CreateOIDCConsentParams{UUID: "uuid-1", ClientID: "client-1", Scopes: "openid"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = s.CreateOIDCConsent(ctx, repository.CreateOIDCConsentParams{UUID: "uuid-1", ClientID: "client-2", Scopes: "profile"})
|
||||||
|
assert.ErrorContains(t, err, "UNIQUE constraint failed: oidc_consent.uuid")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Update OIDC consent",
|
||||||
|
run: func(t *testing.T, s repository.Store) {
|
||||||
|
_, err := s.CreateOIDCConsent(ctx, repository.CreateOIDCConsentParams{UUID: "uuid-1", ClientID: "client-1", Scopes: "openid"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
updated, err := s.UpdateOIDCConsent(ctx, repository.UpdateOIDCConsentParams{
|
||||||
|
UUID: "uuid-1",
|
||||||
|
Scopes: "profile email",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "profile email", updated.Scopes)
|
||||||
|
|
||||||
|
got, err := s.GetOIDCConsentByUUID(ctx, "uuid-1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, updated, got)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Update OIDC consent not found",
|
||||||
|
run: func(t *testing.T, s repository.Store) {
|
||||||
|
_, err := s.UpdateOIDCConsent(ctx, repository.UpdateOIDCConsentParams{UUID: "missing"})
|
||||||
|
assert.ErrorIs(t, err, repository.ErrNotFound)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Delete OIDC consent by UUID",
|
||||||
|
run: func(t *testing.T, s repository.Store) {
|
||||||
|
_, err := s.CreateOIDCConsent(ctx, repository.CreateOIDCConsentParams{UUID: "uuid-1", ClientID: "client-1", Scopes: "openid"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.NoError(t, s.DeleteOIDCConsentByUUID(ctx, "uuid-1"))
|
||||||
|
|
||||||
|
_, err = s.GetOIDCConsentByUUID(ctx, "uuid-1")
|
||||||
|
assert.ErrorIs(t, err, repository.ErrNotFound)
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
|
|||||||
@@ -94,3 +94,47 @@ func (s *Store) DeleteExpiredOIDCSessions(_ context.Context, arg repository.Dele
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) CreateOIDCConsent(_ context.Context, arg repository.CreateOIDCConsentParams) (repository.OidcConsent, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if _, ok := s.oidcConsent[arg.UUID]; ok {
|
||||||
|
return repository.OidcConsent{}, fmt.Errorf("UNIQUE constraint failed: oidc_consent.uuid")
|
||||||
|
}
|
||||||
|
consent := repository.OidcConsent{
|
||||||
|
UUID: arg.UUID,
|
||||||
|
ClientID: arg.ClientID,
|
||||||
|
Scopes: arg.Scopes,
|
||||||
|
}
|
||||||
|
s.oidcConsent[arg.UUID] = consent
|
||||||
|
return consent, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetOIDCConsentByUUID(_ context.Context, uuid string) (repository.OidcConsent, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
consent, ok := s.oidcConsent[uuid]
|
||||||
|
if !ok {
|
||||||
|
return repository.OidcConsent{}, repository.ErrNotFound
|
||||||
|
}
|
||||||
|
return consent, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) UpdateOIDCConsent(_ context.Context, arg repository.UpdateOIDCConsentParams) (repository.OidcConsent, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
consent, ok := s.oidcConsent[arg.UUID]
|
||||||
|
if !ok {
|
||||||
|
return repository.OidcConsent{}, repository.ErrNotFound
|
||||||
|
}
|
||||||
|
consent.Scopes = arg.Scopes
|
||||||
|
s.oidcConsent[arg.UUID] = consent
|
||||||
|
return consent, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) DeleteOIDCConsentByUUID(_ context.Context, uuid string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
delete(s.oidcConsent, uuid)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ type Store struct {
|
|||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
sessions map[string]repository.Session
|
sessions map[string]repository.Session
|
||||||
oidcSessions map[string]repository.OidcSession
|
oidcSessions map[string]repository.OidcSession
|
||||||
|
oidcConsent map[string]repository.OidcConsent
|
||||||
}
|
}
|
||||||
|
|
||||||
// New returns a new empty in-memory Store.
|
// New returns a new empty in-memory Store.
|
||||||
@@ -19,5 +20,6 @@ func New() repository.Store {
|
|||||||
return &Store{
|
return &Store{
|
||||||
sessions: make(map[string]repository.Session),
|
sessions: make(map[string]repository.Session),
|
||||||
oidcSessions: make(map[string]repository.OidcSession),
|
oidcSessions: make(map[string]repository.OidcSession),
|
||||||
|
oidcConsent: make(map[string]repository.OidcConsent),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,18 @@
|
|||||||
package repository
|
package repository
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
// Shared model and parameter types for all storage drivers.
|
// Shared model and parameter types for all storage drivers.
|
||||||
// sqlc-generated driver packages use these via the conversion layer in their store.go.
|
// sqlc-generated driver packages use these via the conversion layer in their store.go.
|
||||||
|
|
||||||
|
type OidcConsent struct {
|
||||||
|
UUID string
|
||||||
|
ClientID string
|
||||||
|
Scopes string
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
type Session struct {
|
type Session struct {
|
||||||
UUID string
|
UUID string
|
||||||
Username string
|
Username string
|
||||||
@@ -84,3 +94,14 @@ type DeleteExpiredOIDCSessionsParams struct {
|
|||||||
TokenExpiresAt int64
|
TokenExpiresAt int64
|
||||||
RefreshTokenExpiresAt int64
|
RefreshTokenExpiresAt int64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CreateOIDCConsentParams struct {
|
||||||
|
UUID string
|
||||||
|
ClientID string
|
||||||
|
Scopes string
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateOIDCConsentParams struct {
|
||||||
|
Scopes string
|
||||||
|
UUID string
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,18 @@
|
|||||||
|
|
||||||
package postgres
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type OidcConsent struct {
|
||||||
|
UUID string
|
||||||
|
ClientID string
|
||||||
|
Scopes string
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
type OidcSession struct {
|
type OidcSession struct {
|
||||||
Sub string
|
Sub string
|
||||||
AccessTokenHash string
|
AccessTokenHash string
|
||||||
|
|||||||
@@ -9,6 +9,36 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const createOIDCConsent = `-- name: CreateOIDCConsent :one
|
||||||
|
INSERT INTO "oidc_consent" (
|
||||||
|
"uuid",
|
||||||
|
"client_id",
|
||||||
|
"scopes"
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3
|
||||||
|
)
|
||||||
|
RETURNING uuid, client_id, scopes, created_at, updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type CreateOIDCConsentParams struct {
|
||||||
|
UUID string
|
||||||
|
ClientID string
|
||||||
|
Scopes string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CreateOIDCConsent(ctx context.Context, arg CreateOIDCConsentParams) (OidcConsent, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, createOIDCConsent, arg.UUID, arg.ClientID, arg.Scopes)
|
||||||
|
var i OidcConsent
|
||||||
|
err := row.Scan(
|
||||||
|
&i.UUID,
|
||||||
|
&i.ClientID,
|
||||||
|
&i.Scopes,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
const createOIDCSession = `-- name: CreateOIDCSession :one
|
const createOIDCSession = `-- name: CreateOIDCSession :one
|
||||||
INSERT INTO "oidc_sessions" (
|
INSERT INTO "oidc_sessions" (
|
||||||
"sub",
|
"sub",
|
||||||
@@ -80,6 +110,16 @@ func (q *Queries) DeleteExpiredOIDCSessions(ctx context.Context, arg DeleteExpir
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const deleteOIDCConsentByUUID = `-- name: DeleteOIDCConsentByUUID :exec
|
||||||
|
DELETE FROM "oidc_consent"
|
||||||
|
WHERE "uuid" = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) DeleteOIDCConsentByUUID(ctx context.Context, uuid string) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, deleteOIDCConsentByUUID, uuid)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
const deleteOIDCSessionBySub = `-- name: DeleteOIDCSessionBySub :exec
|
const deleteOIDCSessionBySub = `-- name: DeleteOIDCSessionBySub :exec
|
||||||
DELETE FROM "oidc_sessions"
|
DELETE FROM "oidc_sessions"
|
||||||
WHERE "sub" = $1
|
WHERE "sub" = $1
|
||||||
@@ -90,6 +130,24 @@ func (q *Queries) DeleteOIDCSessionBySub(ctx context.Context, sub string) error
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getOIDCConsentByUUID = `-- name: GetOIDCConsentByUUID :one
|
||||||
|
SELECT uuid, client_id, scopes, created_at, updated_at FROM "oidc_consent"
|
||||||
|
WHERE "uuid" = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetOIDCConsentByUUID(ctx context.Context, uuid string) (OidcConsent, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, getOIDCConsentByUUID, uuid)
|
||||||
|
var i OidcConsent
|
||||||
|
err := row.Scan(
|
||||||
|
&i.UUID,
|
||||||
|
&i.ClientID,
|
||||||
|
&i.Scopes,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
const getOIDCSessionByAccessTokenHash = `-- name: GetOIDCSessionByAccessTokenHash :one
|
const getOIDCSessionByAccessTokenHash = `-- name: GetOIDCSessionByAccessTokenHash :one
|
||||||
SELECT sub, access_token_hash, refresh_token_hash, scope, client_id, token_expires_at, refresh_token_expires_at, nonce, userinfo_json FROM "oidc_sessions"
|
SELECT sub, access_token_hash, refresh_token_hash, scope, client_id, token_expires_at, refresh_token_expires_at, nonce, userinfo_json FROM "oidc_sessions"
|
||||||
WHERE "access_token_hash" = $1
|
WHERE "access_token_hash" = $1
|
||||||
@@ -156,6 +214,32 @@ func (q *Queries) GetOIDCSessionBySub(ctx context.Context, sub string) (OidcSess
|
|||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updateOIDCConsent = `-- name: UpdateOIDCConsent :one
|
||||||
|
UPDATE "oidc_consent" SET
|
||||||
|
"scopes" = $1,
|
||||||
|
"updated_at" = CURRENT_TIMESTAMP
|
||||||
|
WHERE "uuid" = $2
|
||||||
|
RETURNING uuid, client_id, scopes, created_at, updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpdateOIDCConsentParams struct {
|
||||||
|
Scopes string
|
||||||
|
UUID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpdateOIDCConsent(ctx context.Context, arg UpdateOIDCConsentParams) (OidcConsent, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, updateOIDCConsent, arg.Scopes, arg.UUID)
|
||||||
|
var i OidcConsent
|
||||||
|
err := row.Scan(
|
||||||
|
&i.UUID,
|
||||||
|
&i.ClientID,
|
||||||
|
&i.Scopes,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
const updateOIDCSession = `-- name: UpdateOIDCSession :one
|
const updateOIDCSession = `-- name: UpdateOIDCSession :one
|
||||||
UPDATE "oidc_sessions" SET
|
UPDATE "oidc_sessions" SET
|
||||||
"access_token_hash" = $1,
|
"access_token_hash" = $1,
|
||||||
|
|||||||
@@ -32,6 +32,14 @@ func mapErr(err error) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) CreateOIDCConsent(ctx context.Context, arg repository.CreateOIDCConsentParams) (repository.OidcConsent, error) {
|
||||||
|
r, err := s.q.CreateOIDCConsent(ctx, CreateOIDCConsentParams(arg))
|
||||||
|
if err != nil {
|
||||||
|
return repository.OidcConsent{}, mapErr(err)
|
||||||
|
}
|
||||||
|
return repository.OidcConsent(r), nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) CreateOIDCSession(ctx context.Context, arg repository.CreateOIDCSessionParams) (repository.OidcSession, error) {
|
func (s *Store) CreateOIDCSession(ctx context.Context, arg repository.CreateOIDCSessionParams) (repository.OidcSession, error) {
|
||||||
r, err := s.q.CreateOIDCSession(ctx, CreateOIDCSessionParams(arg))
|
r, err := s.q.CreateOIDCSession(ctx, CreateOIDCSessionParams(arg))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -56,6 +64,10 @@ func (s *Store) DeleteExpiredSessions(ctx context.Context, expiry int64) error {
|
|||||||
return mapErr(s.q.DeleteExpiredSessions(ctx, expiry))
|
return mapErr(s.q.DeleteExpiredSessions(ctx, expiry))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) DeleteOIDCConsentByUUID(ctx context.Context, uuid string) error {
|
||||||
|
return mapErr(s.q.DeleteOIDCConsentByUUID(ctx, uuid))
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) DeleteOIDCSessionBySub(ctx context.Context, sub string) error {
|
func (s *Store) DeleteOIDCSessionBySub(ctx context.Context, sub string) error {
|
||||||
return mapErr(s.q.DeleteOIDCSessionBySub(ctx, sub))
|
return mapErr(s.q.DeleteOIDCSessionBySub(ctx, sub))
|
||||||
}
|
}
|
||||||
@@ -64,6 +76,14 @@ func (s *Store) DeleteSession(ctx context.Context, uuid string) error {
|
|||||||
return mapErr(s.q.DeleteSession(ctx, uuid))
|
return mapErr(s.q.DeleteSession(ctx, uuid))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetOIDCConsentByUUID(ctx context.Context, uuid string) (repository.OidcConsent, error) {
|
||||||
|
r, err := s.q.GetOIDCConsentByUUID(ctx, uuid)
|
||||||
|
if err != nil {
|
||||||
|
return repository.OidcConsent{}, mapErr(err)
|
||||||
|
}
|
||||||
|
return repository.OidcConsent(r), nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) GetOIDCSessionByAccessTokenHash(ctx context.Context, accessTokenHash string) (repository.OidcSession, error) {
|
func (s *Store) GetOIDCSessionByAccessTokenHash(ctx context.Context, accessTokenHash string) (repository.OidcSession, error) {
|
||||||
r, err := s.q.GetOIDCSessionByAccessTokenHash(ctx, accessTokenHash)
|
r, err := s.q.GetOIDCSessionByAccessTokenHash(ctx, accessTokenHash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -96,6 +116,14 @@ func (s *Store) GetSession(ctx context.Context, uuid string) (repository.Session
|
|||||||
return repository.Session(r), nil
|
return repository.Session(r), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) UpdateOIDCConsent(ctx context.Context, arg repository.UpdateOIDCConsentParams) (repository.OidcConsent, error) {
|
||||||
|
r, err := s.q.UpdateOIDCConsent(ctx, UpdateOIDCConsentParams(arg))
|
||||||
|
if err != nil {
|
||||||
|
return repository.OidcConsent{}, mapErr(err)
|
||||||
|
}
|
||||||
|
return repository.OidcConsent(r), nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) UpdateOIDCSession(ctx context.Context, arg repository.UpdateOIDCSessionParams) (repository.OidcSession, error) {
|
func (s *Store) UpdateOIDCSession(ctx context.Context, arg repository.UpdateOIDCSessionParams) (repository.OidcSession, error) {
|
||||||
r, err := s.q.UpdateOIDCSession(ctx, UpdateOIDCSessionParams(arg))
|
r, err := s.q.UpdateOIDCSession(ctx, UpdateOIDCSessionParams(arg))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -4,6 +4,18 @@
|
|||||||
|
|
||||||
package sqlite
|
package sqlite
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type OidcConsent struct {
|
||||||
|
UUID string
|
||||||
|
ClientID string
|
||||||
|
Scopes string
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
type OidcSession struct {
|
type OidcSession struct {
|
||||||
Sub string
|
Sub string
|
||||||
AccessTokenHash string
|
AccessTokenHash string
|
||||||
|
|||||||
@@ -9,6 +9,36 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const createOIDCConsent = `-- name: CreateOIDCConsent :one
|
||||||
|
INSERT INTO "oidc_consent" (
|
||||||
|
"uuid",
|
||||||
|
"client_id",
|
||||||
|
"scopes"
|
||||||
|
) VALUES (
|
||||||
|
?, ?, ?
|
||||||
|
)
|
||||||
|
RETURNING uuid, client_id, scopes, created_at, updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type CreateOIDCConsentParams struct {
|
||||||
|
UUID string
|
||||||
|
ClientID string
|
||||||
|
Scopes string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CreateOIDCConsent(ctx context.Context, arg CreateOIDCConsentParams) (OidcConsent, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, createOIDCConsent, arg.UUID, arg.ClientID, arg.Scopes)
|
||||||
|
var i OidcConsent
|
||||||
|
err := row.Scan(
|
||||||
|
&i.UUID,
|
||||||
|
&i.ClientID,
|
||||||
|
&i.Scopes,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
const createOIDCSession = `-- name: CreateOIDCSession :one
|
const createOIDCSession = `-- name: CreateOIDCSession :one
|
||||||
INSERT INTO "oidc_sessions" (
|
INSERT INTO "oidc_sessions" (
|
||||||
"sub",
|
"sub",
|
||||||
@@ -80,6 +110,16 @@ func (q *Queries) DeleteExpiredOIDCSessions(ctx context.Context, arg DeleteExpir
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const deleteOIDCConsentByUUID = `-- name: DeleteOIDCConsentByUUID :exec
|
||||||
|
DELETE FROM "oidc_consent"
|
||||||
|
WHERE "uuid" = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) DeleteOIDCConsentByUUID(ctx context.Context, uuid string) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, deleteOIDCConsentByUUID, uuid)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
const deleteOIDCSessionBySub = `-- name: DeleteOIDCSessionBySub :exec
|
const deleteOIDCSessionBySub = `-- name: DeleteOIDCSessionBySub :exec
|
||||||
DELETE FROM "oidc_sessions"
|
DELETE FROM "oidc_sessions"
|
||||||
WHERE "sub" = ?
|
WHERE "sub" = ?
|
||||||
@@ -90,6 +130,24 @@ func (q *Queries) DeleteOIDCSessionBySub(ctx context.Context, sub string) error
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getOIDCConsentByUUID = `-- name: GetOIDCConsentByUUID :one
|
||||||
|
SELECT uuid, client_id, scopes, created_at, updated_at FROM "oidc_consent"
|
||||||
|
WHERE "uuid" = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetOIDCConsentByUUID(ctx context.Context, uuid string) (OidcConsent, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, getOIDCConsentByUUID, uuid)
|
||||||
|
var i OidcConsent
|
||||||
|
err := row.Scan(
|
||||||
|
&i.UUID,
|
||||||
|
&i.ClientID,
|
||||||
|
&i.Scopes,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
const getOIDCSessionByAccessTokenHash = `-- name: GetOIDCSessionByAccessTokenHash :one
|
const getOIDCSessionByAccessTokenHash = `-- name: GetOIDCSessionByAccessTokenHash :one
|
||||||
SELECT sub, access_token_hash, refresh_token_hash, scope, client_id, token_expires_at, refresh_token_expires_at, nonce, userinfo_json FROM "oidc_sessions"
|
SELECT sub, access_token_hash, refresh_token_hash, scope, client_id, token_expires_at, refresh_token_expires_at, nonce, userinfo_json FROM "oidc_sessions"
|
||||||
WHERE "access_token_hash" = ?
|
WHERE "access_token_hash" = ?
|
||||||
@@ -156,6 +214,32 @@ func (q *Queries) GetOIDCSessionBySub(ctx context.Context, sub string) (OidcSess
|
|||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updateOIDCConsent = `-- name: UpdateOIDCConsent :one
|
||||||
|
UPDATE "oidc_consent" SET
|
||||||
|
"scopes" = ?,
|
||||||
|
"updated_at" = CURRENT_TIMESTAMP
|
||||||
|
WHERE "uuid" = ?
|
||||||
|
RETURNING uuid, client_id, scopes, created_at, updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpdateOIDCConsentParams struct {
|
||||||
|
Scopes string
|
||||||
|
UUID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpdateOIDCConsent(ctx context.Context, arg UpdateOIDCConsentParams) (OidcConsent, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, updateOIDCConsent, arg.Scopes, arg.UUID)
|
||||||
|
var i OidcConsent
|
||||||
|
err := row.Scan(
|
||||||
|
&i.UUID,
|
||||||
|
&i.ClientID,
|
||||||
|
&i.Scopes,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
const updateOIDCSession = `-- name: UpdateOIDCSession :one
|
const updateOIDCSession = `-- name: UpdateOIDCSession :one
|
||||||
UPDATE "oidc_sessions" SET
|
UPDATE "oidc_sessions" SET
|
||||||
"access_token_hash" = ?,
|
"access_token_hash" = ?,
|
||||||
|
|||||||
@@ -32,6 +32,14 @@ func mapErr(err error) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) CreateOIDCConsent(ctx context.Context, arg repository.CreateOIDCConsentParams) (repository.OidcConsent, error) {
|
||||||
|
r, err := s.q.CreateOIDCConsent(ctx, CreateOIDCConsentParams(arg))
|
||||||
|
if err != nil {
|
||||||
|
return repository.OidcConsent{}, mapErr(err)
|
||||||
|
}
|
||||||
|
return repository.OidcConsent(r), nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) CreateOIDCSession(ctx context.Context, arg repository.CreateOIDCSessionParams) (repository.OidcSession, error) {
|
func (s *Store) CreateOIDCSession(ctx context.Context, arg repository.CreateOIDCSessionParams) (repository.OidcSession, error) {
|
||||||
r, err := s.q.CreateOIDCSession(ctx, CreateOIDCSessionParams(arg))
|
r, err := s.q.CreateOIDCSession(ctx, CreateOIDCSessionParams(arg))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -56,6 +64,10 @@ func (s *Store) DeleteExpiredSessions(ctx context.Context, expiry int64) error {
|
|||||||
return mapErr(s.q.DeleteExpiredSessions(ctx, expiry))
|
return mapErr(s.q.DeleteExpiredSessions(ctx, expiry))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) DeleteOIDCConsentByUUID(ctx context.Context, uuid string) error {
|
||||||
|
return mapErr(s.q.DeleteOIDCConsentByUUID(ctx, uuid))
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) DeleteOIDCSessionBySub(ctx context.Context, sub string) error {
|
func (s *Store) DeleteOIDCSessionBySub(ctx context.Context, sub string) error {
|
||||||
return mapErr(s.q.DeleteOIDCSessionBySub(ctx, sub))
|
return mapErr(s.q.DeleteOIDCSessionBySub(ctx, sub))
|
||||||
}
|
}
|
||||||
@@ -64,6 +76,14 @@ func (s *Store) DeleteSession(ctx context.Context, uuid string) error {
|
|||||||
return mapErr(s.q.DeleteSession(ctx, uuid))
|
return mapErr(s.q.DeleteSession(ctx, uuid))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetOIDCConsentByUUID(ctx context.Context, uuid string) (repository.OidcConsent, error) {
|
||||||
|
r, err := s.q.GetOIDCConsentByUUID(ctx, uuid)
|
||||||
|
if err != nil {
|
||||||
|
return repository.OidcConsent{}, mapErr(err)
|
||||||
|
}
|
||||||
|
return repository.OidcConsent(r), nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) GetOIDCSessionByAccessTokenHash(ctx context.Context, accessTokenHash string) (repository.OidcSession, error) {
|
func (s *Store) GetOIDCSessionByAccessTokenHash(ctx context.Context, accessTokenHash string) (repository.OidcSession, error) {
|
||||||
r, err := s.q.GetOIDCSessionByAccessTokenHash(ctx, accessTokenHash)
|
r, err := s.q.GetOIDCSessionByAccessTokenHash(ctx, accessTokenHash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -96,6 +116,14 @@ func (s *Store) GetSession(ctx context.Context, uuid string) (repository.Session
|
|||||||
return repository.Session(r), nil
|
return repository.Session(r), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) UpdateOIDCConsent(ctx context.Context, arg repository.UpdateOIDCConsentParams) (repository.OidcConsent, error) {
|
||||||
|
r, err := s.q.UpdateOIDCConsent(ctx, UpdateOIDCConsentParams(arg))
|
||||||
|
if err != nil {
|
||||||
|
return repository.OidcConsent{}, mapErr(err)
|
||||||
|
}
|
||||||
|
return repository.OidcConsent(r), nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) UpdateOIDCSession(ctx context.Context, arg repository.UpdateOIDCSessionParams) (repository.OidcSession, error) {
|
func (s *Store) UpdateOIDCSession(ctx context.Context, arg repository.UpdateOIDCSessionParams) (repository.OidcSession, error) {
|
||||||
r, err := s.q.UpdateOIDCSession(ctx, UpdateOIDCSessionParams(arg))
|
r, err := s.q.UpdateOIDCSession(ctx, UpdateOIDCSessionParams(arg))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -27,4 +27,10 @@ type Store interface {
|
|||||||
GetOIDCSessionByRefreshTokenHash(ctx context.Context, refreshTokenHash string) (OidcSession, error)
|
GetOIDCSessionByRefreshTokenHash(ctx context.Context, refreshTokenHash string) (OidcSession, error)
|
||||||
GetOIDCSessionBySub(ctx context.Context, sub string) (OidcSession, error)
|
GetOIDCSessionBySub(ctx context.Context, sub string) (OidcSession, error)
|
||||||
UpdateOIDCSession(ctx context.Context, arg UpdateOIDCSessionParams) (OidcSession, error)
|
UpdateOIDCSession(ctx context.Context, arg UpdateOIDCSessionParams) (OidcSession, error)
|
||||||
|
|
||||||
|
// OIDC consents
|
||||||
|
CreateOIDCConsent(ctx context.Context, arg CreateOIDCConsentParams) (OidcConsent, error)
|
||||||
|
DeleteOIDCConsentByUUID(ctx context.Context, uuid string) error
|
||||||
|
GetOIDCConsentByUUID(ctx context.Context, uuid string) (OidcConsent, error)
|
||||||
|
UpdateOIDCConsent(ctx context.Context, arg UpdateOIDCConsentParams) (OidcConsent, error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ type OAuthPendingSession struct {
|
|||||||
State string
|
State string
|
||||||
Verifier string
|
Verifier string
|
||||||
Token *oauth2.Token
|
Token *oauth2.Token
|
||||||
Service IOAuthService
|
Service *OAuthServiceImpl
|
||||||
ExpiresAt time.Time
|
ExpiresAt time.Time
|
||||||
CallbackParams OAuthCallbackParams
|
CallbackParams OAuthCallbackParams
|
||||||
}
|
}
|
||||||
@@ -62,6 +62,7 @@ type AuthService struct {
|
|||||||
config *model.Config
|
config *model.Config
|
||||||
runtime *model.RuntimeConfig
|
runtime *model.RuntimeConfig
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
|
helpers *model.RuntimeHelpers
|
||||||
|
|
||||||
ldap *LdapService
|
ldap *LdapService
|
||||||
queries repository.Store
|
queries repository.Store
|
||||||
@@ -99,6 +100,7 @@ type AuthServiceInput struct {
|
|||||||
OAuthBroker *OAuthBrokerService
|
OAuthBroker *OAuthBrokerService
|
||||||
Tailscale *TailscaleService `optional:"true"`
|
Tailscale *TailscaleService `optional:"true"`
|
||||||
PolicyEngine *PolicyEngine
|
PolicyEngine *PolicyEngine
|
||||||
|
Helpers *model.RuntimeHelpers
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAuthService(i AuthServiceInput) *AuthService {
|
func NewAuthService(i AuthServiceInput) *AuthService {
|
||||||
@@ -112,6 +114,7 @@ func NewAuthService(i AuthServiceInput) *AuthService {
|
|||||||
oauthBroker: i.OAuthBroker,
|
oauthBroker: i.OAuthBroker,
|
||||||
tailscale: i.Tailscale,
|
tailscale: i.Tailscale,
|
||||||
policyEngine: i.PolicyEngine,
|
policyEngine: i.PolicyEngine,
|
||||||
|
helpers: i.Helpers,
|
||||||
}
|
}
|
||||||
|
|
||||||
// get the max login limits based on the number of users and the configured max retries
|
// get the max login limits based on the number of users and the configured max retries
|
||||||
@@ -339,7 +342,7 @@ func (auth *AuthService) IsEmailWhitelisted(provider string, email string) bool
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (auth *AuthService) CreateSession(ctx context.Context, data repository.Session) (*http.Cookie, error) {
|
func (auth *AuthService) CreateSession(ctx context.Context, data repository.Session, ip string) (*http.Cookie, error) {
|
||||||
if data.Provider == "tailscale" && auth.tailscale == nil {
|
if data.Provider == "tailscale" && auth.tailscale == nil {
|
||||||
return nil, fmt.Errorf("tailscale service not configured, cannot create session for tailscale user")
|
return nil, fmt.Errorf("tailscale service not configured, cannot create session for tailscale user")
|
||||||
}
|
}
|
||||||
@@ -380,11 +383,17 @@ func (auth *AuthService) CreateSession(ctx context.Context, data repository.Sess
|
|||||||
return nil, fmt.Errorf("failed to create session entry: %w", err)
|
return nil, fmt.Errorf("failed to create session entry: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cookieDomain, err := auth.helpers.GetCookieDomain(ctx, ip)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to determine cookie domain: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return &http.Cookie{
|
return &http.Cookie{
|
||||||
Name: auth.runtime.SessionCookieName,
|
Name: auth.runtime.SessionCookieName,
|
||||||
Value: session.UUID,
|
Value: session.UUID,
|
||||||
Path: "/",
|
Path: "/",
|
||||||
Domain: auth.getCookieDomain(),
|
Domain: cookieDomain,
|
||||||
Expires: expiresAt,
|
Expires: expiresAt,
|
||||||
MaxAge: int(time.Until(expiresAt).Seconds()),
|
MaxAge: int(time.Until(expiresAt).Seconds()),
|
||||||
Secure: auth.config.Auth.SecureCookie,
|
Secure: auth.config.Auth.SecureCookie,
|
||||||
@@ -393,13 +402,17 @@ func (auth *AuthService) CreateSession(ctx context.Context, data repository.Sess
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (auth *AuthService) RefreshSession(ctx context.Context, uuid string) (*http.Cookie, error) {
|
func (auth *AuthService) RefreshSession(ctx context.Context, uuid string, ip string) (*http.Cookie, error) {
|
||||||
session, err := auth.queries.GetSession(ctx, uuid)
|
session, err := auth.queries.GetSession(ctx, uuid)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to retrieve session: %w", err)
|
return nil, fmt.Errorf("failed to retrieve session: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if session.Provider == "tailscale" && auth.tailscale == nil {
|
||||||
|
return nil, fmt.Errorf("tailscale service not configured, cannot create session for tailscale user")
|
||||||
|
}
|
||||||
|
|
||||||
currentTime := time.Now().Unix()
|
currentTime := time.Now().Unix()
|
||||||
|
|
||||||
var refreshThreshold int64
|
var refreshThreshold int64
|
||||||
@@ -433,11 +446,17 @@ func (auth *AuthService) RefreshSession(ctx context.Context, uuid string) (*http
|
|||||||
return nil, fmt.Errorf("failed to update session expiry: %w", err)
|
return nil, fmt.Errorf("failed to update session expiry: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cookieDomain, err := auth.helpers.GetCookieDomain(ctx, ip)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to determine cookie domain: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return &http.Cookie{
|
return &http.Cookie{
|
||||||
Name: auth.runtime.SessionCookieName,
|
Name: auth.runtime.SessionCookieName,
|
||||||
Value: session.UUID,
|
Value: session.UUID,
|
||||||
Path: "/",
|
Path: "/",
|
||||||
Domain: auth.getCookieDomain(),
|
Domain: cookieDomain,
|
||||||
Expires: time.Now().Add(time.Duration(newExpiry-currentTime) * time.Second),
|
Expires: time.Now().Add(time.Duration(newExpiry-currentTime) * time.Second),
|
||||||
MaxAge: int(newExpiry - currentTime),
|
MaxAge: int(newExpiry - currentTime),
|
||||||
Secure: auth.config.Auth.SecureCookie,
|
Secure: auth.config.Auth.SecureCookie,
|
||||||
@@ -447,18 +466,24 @@ func (auth *AuthService) RefreshSession(ctx context.Context, uuid string) (*http
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (auth *AuthService) DeleteSession(ctx context.Context, uuid string) (*http.Cookie, error) {
|
func (auth *AuthService) DeleteSession(ctx context.Context, uuid string, ip string) (*http.Cookie, error) {
|
||||||
err := auth.queries.DeleteSession(ctx, uuid)
|
err := auth.queries.DeleteSession(ctx, uuid)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
auth.log.App.Error().Err(err).Str("uuid", uuid).Msg("Failed to delete session from database")
|
auth.log.App.Error().Err(err).Str("uuid", uuid).Msg("Failed to delete session from database")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cookieDomain, err := auth.helpers.GetCookieDomain(ctx, ip)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to determine cookie domain: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return &http.Cookie{
|
return &http.Cookie{
|
||||||
Name: auth.runtime.SessionCookieName,
|
Name: auth.runtime.SessionCookieName,
|
||||||
Value: "",
|
Value: "",
|
||||||
Path: "/",
|
Path: "/",
|
||||||
Domain: auth.getCookieDomain(),
|
Domain: cookieDomain,
|
||||||
Expires: time.Now(),
|
Expires: time.Now(),
|
||||||
MaxAge: -1,
|
MaxAge: -1,
|
||||||
Secure: auth.config.Auth.SecureCookie,
|
Secure: auth.config.Auth.SecureCookie,
|
||||||
@@ -527,7 +552,7 @@ func (auth *AuthService) NewOAuthSession(serviceName string, params OAuthCallbac
|
|||||||
session := OAuthPendingSession{
|
session := OAuthPendingSession{
|
||||||
State: state,
|
State: state,
|
||||||
Verifier: verifier,
|
Verifier: verifier,
|
||||||
Service: service,
|
Service: &service,
|
||||||
ExpiresAt: time.Now().Add(1 * time.Hour),
|
ExpiresAt: time.Now().Add(1 * time.Hour),
|
||||||
CallbackParams: params,
|
CallbackParams: params,
|
||||||
}
|
}
|
||||||
@@ -544,7 +569,7 @@ func (auth *AuthService) GetOAuthURL(sessionId string) (string, error) {
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return session.Service.GetAuthURL(session.State, session.Verifier), nil
|
return (*session.Service).GetAuthURL(session.State, session.Verifier), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (auth *AuthService) GetOAuthToken(sessionId string, code string) (*oauth2.Token, error) {
|
func (auth *AuthService) GetOAuthToken(sessionId string, code string) (*oauth2.Token, error) {
|
||||||
@@ -554,7 +579,7 @@ func (auth *AuthService) GetOAuthToken(sessionId string, code string) (*oauth2.T
|
|||||||
return nil, fmt.Errorf("oauth session not found: %s", sessionId)
|
return nil, fmt.Errorf("oauth session not found: %s", sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
token, err := session.Service.GetToken(code, session.Verifier)
|
token, err := (*session.Service).GetToken(code, session.Verifier)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to exchange code for token: %w", err)
|
return nil, fmt.Errorf("failed to exchange code for token: %w", err)
|
||||||
@@ -583,7 +608,7 @@ func (auth *AuthService) GetOAuthUserinfo(sessionId string) (*model.Claims, erro
|
|||||||
return nil, fmt.Errorf("oauth token not found for session: %s", sessionId)
|
return nil, fmt.Errorf("oauth token not found for session: %s", sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
userinfo, err := session.Service.GetUserinfo(session.Token)
|
userinfo, err := (*session.Service).GetUserinfo(session.Token)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get userinfo: %w", err)
|
return nil, fmt.Errorf("failed to get userinfo: %w", err)
|
||||||
@@ -592,14 +617,14 @@ func (auth *AuthService) GetOAuthUserinfo(sessionId string) (*model.Claims, erro
|
|||||||
return userinfo, nil
|
return userinfo, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (auth *AuthService) GetOAuthService(sessionId string) (IOAuthService, error) {
|
func (auth *AuthService) GetOAuthService(sessionId string) (OAuthServiceImpl, error) {
|
||||||
session, err := auth.GetOAuthPendingSession(sessionId)
|
session, err := auth.GetOAuthPendingSession(sessionId)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return session.Service, nil
|
return *session.Service, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (auth *AuthService) EndOAuthSession(sessionId string) {
|
func (auth *AuthService) EndOAuthSession(sessionId string) {
|
||||||
@@ -704,10 +729,3 @@ func (auth *AuthService) calculateLockdownLimit() int {
|
|||||||
|
|
||||||
return limit
|
return limit
|
||||||
}
|
}
|
||||||
|
|
||||||
func (auth *AuthService) getCookieDomain() string {
|
|
||||||
if !auth.config.Auth.SubdomainsEnabled {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return auth.runtime.CookieDomain
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -12,21 +12,19 @@ import (
|
|||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
)
|
)
|
||||||
|
|
||||||
type IOAuthService interface {
|
type OAuthServiceImpl interface {
|
||||||
Name() string
|
Name() string
|
||||||
ID() string
|
ID() string
|
||||||
NewRandom() string
|
NewRandom() string
|
||||||
GetAuthURL(state, verifier string) string
|
GetAuthURL(state string, verifier string) string
|
||||||
GetToken(code, verifier string) (*oauth2.Token, error)
|
GetToken(code string, verifier string) (*oauth2.Token, error)
|
||||||
GetUserinfo(token *oauth2.Token) (*model.Claims, error)
|
GetUserinfo(token *oauth2.Token) (*model.Claims, error)
|
||||||
GetConfig() model.OAuthServiceConfig
|
|
||||||
UpdateConfig(config model.OAuthServiceConfig)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type OAuthBrokerService struct {
|
type OAuthBrokerService struct {
|
||||||
log *logger.Logger
|
log *logger.Logger
|
||||||
|
|
||||||
services map[string]IOAuthService
|
services map[string]OAuthServiceImpl
|
||||||
configs map[string]model.OAuthServiceConfig
|
configs map[string]model.OAuthServiceConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,7 +44,7 @@ type OAuthBrokerServiceInput struct {
|
|||||||
func NewOAuthBrokerService(i OAuthBrokerServiceInput) *OAuthBrokerService {
|
func NewOAuthBrokerService(i OAuthBrokerServiceInput) *OAuthBrokerService {
|
||||||
service := &OAuthBrokerService{
|
service := &OAuthBrokerService{
|
||||||
log: i.Log,
|
log: i.Log,
|
||||||
services: make(map[string]IOAuthService),
|
services: make(map[string]OAuthServiceImpl),
|
||||||
configs: i.Runtime.OAuthProviders,
|
configs: i.Runtime.OAuthProviders,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,7 +70,7 @@ func (broker *OAuthBrokerService) GetConfiguredServices() []string {
|
|||||||
return services
|
return services
|
||||||
}
|
}
|
||||||
|
|
||||||
func (broker *OAuthBrokerService) GetService(name string) (IOAuthService, bool) {
|
func (broker *OAuthBrokerService) GetService(name string) (OAuthServiceImpl, bool) {
|
||||||
service, exists := broker.services[name]
|
service, exists := broker.services[name]
|
||||||
return service, exists
|
return service, exists
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ func (s *OAuthService) NewRandom() string {
|
|||||||
return random
|
return random
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *OAuthService) GetAuthURL(state, verifier string) string {
|
func (s *OAuthService) GetAuthURL(state string, verifier string) string {
|
||||||
return s.config.AuthCodeURL(state, oauth2.AccessTypeOnline, oauth2.S256ChallengeOption(verifier))
|
return s.config.AuthCodeURL(state, oauth2.AccessTypeOnline, oauth2.S256ChallengeOption(verifier))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,17 +82,3 @@ func (s *OAuthService) GetUserinfo(token *oauth2.Token) (*model.Claims, error) {
|
|||||||
client := oauth2.NewClient(s.ctx, oauth2.StaticTokenSource(token))
|
client := oauth2.NewClient(s.ctx, oauth2.StaticTokenSource(token))
|
||||||
return s.userinfoExtractor(client, s.serviceCfg.UserinfoURL)
|
return s.userinfoExtractor(client, s.serviceCfg.UserinfoURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *OAuthService) GetConfig() model.OAuthServiceConfig {
|
|
||||||
return s.serviceCfg
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *OAuthService) UpdateConfig(config model.OAuthServiceConfig) {
|
|
||||||
s.serviceCfg = config
|
|
||||||
s.config.ClientID = config.ClientID
|
|
||||||
s.config.ClientSecret = config.ClientSecret
|
|
||||||
s.config.Scopes = config.Scopes
|
|
||||||
s.config.Endpoint.AuthURL = config.AuthURL
|
|
||||||
s.config.Endpoint.TokenURL = config.TokenURL
|
|
||||||
s.config.RedirectURL = config.RedirectURL
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import (
|
|||||||
|
|
||||||
"github.com/go-jose/go-jose/v4"
|
"github.com/go-jose/go-jose/v4"
|
||||||
"github.com/golang-jwt/jwt/v5"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/google/uuid"
|
||||||
"github.com/steveiliop56/ding"
|
"github.com/steveiliop56/ding"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/repository"
|
"github.com/tinyauthapp/tinyauth/internal/repository"
|
||||||
@@ -969,3 +970,47 @@ func (service *OIDCService) GetPrompt(prompt string) []OIDCPrompt {
|
|||||||
|
|
||||||
return parsedPromps
|
return parsedPromps
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (service *OIDCService) CreateConsentEntry(ctx context.Context, clientId string, scope string) (string, error) {
|
||||||
|
u := uuid.New()
|
||||||
|
|
||||||
|
entry := repository.CreateOIDCConsentParams{
|
||||||
|
UUID: u.String(),
|
||||||
|
ClientID: clientId,
|
||||||
|
Scopes: scope,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := service.queries.CreateOIDCConsent(ctx, entry)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry.UUID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *OIDCService) GetConsentEntry(ctx context.Context, uuid string) (*repository.OidcConsent, error) {
|
||||||
|
entry, err := service.queries.GetOIDCConsentByUUID(ctx, uuid)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, repository.ErrNotFound) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &entry, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *OIDCService) DeleteConsentEntry(ctx context.Context, uuid string) error {
|
||||||
|
return service.queries.DeleteOIDCConsentByUUID(ctx, uuid)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *OIDCService) UpdateConsentEntry(ctx context.Context, uuid string, scopes string) error {
|
||||||
|
_, err := service.queries.UpdateOIDCConsent(ctx, repository.UpdateOIDCConsentParams{
|
||||||
|
UUID: uuid,
|
||||||
|
Scopes: scopes,
|
||||||
|
})
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -94,10 +94,6 @@ func NewTailscaleService(i TailscaleServiceInput) (*TailscaleService, error) {
|
|||||||
|
|
||||||
i.Ding.Go(service.watchAndClose, ding.RingMajor)
|
i.Ding.Go(service.watchAndClose, ding.RingMajor)
|
||||||
|
|
||||||
if i.Config.Tailscale.Funnel && !i.Config.Tailscale.Listen {
|
|
||||||
service.log.App.Warn().Msg("Tailscale Funnel is enabled but listen is disabled. Funnel will not work without listen enabled.")
|
|
||||||
}
|
|
||||||
|
|
||||||
return service, nil
|
return service, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,16 +148,6 @@ func (ts *TailscaleService) CreateListener() (net.Listener, error) {
|
|||||||
if ts.ln != nil {
|
if ts.ln != nil {
|
||||||
return *ts.ln, nil
|
return *ts.ln, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if ts.config.Tailscale.Funnel {
|
|
||||||
ln, err := ts.srv.ListenFunnel("tcp", ":443")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
ts.ln = &ln
|
|
||||||
return ln, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
ln, err := ts.srv.ListenTLS("tcp", ":443")
|
ln, err := ts.srv.ListenTLS("tcp", ":443")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
+13
-1
@@ -1,6 +1,7 @@
|
|||||||
package test
|
package test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -43,7 +44,6 @@ func CreateTestConfigs(t *testing.T) (model.Config, model.RuntimeConfig) {
|
|||||||
ACLs: model.ACLsConfig{
|
ACLs: model.ACLsConfig{
|
||||||
Policy: "allow",
|
Policy: "allow",
|
||||||
},
|
},
|
||||||
SubdomainsEnabled: true,
|
|
||||||
},
|
},
|
||||||
Database: model.DatabaseConfig{
|
Database: model.DatabaseConfig{
|
||||||
Path: filepath.Join(tempDir, "test.db"),
|
Path: filepath.Join(tempDir, "test.db"),
|
||||||
@@ -166,7 +166,19 @@ func CreateTestConfigs(t *testing.T) (model.Config, model.RuntimeConfig) {
|
|||||||
CookieDomain: "example.com",
|
CookieDomain: "example.com",
|
||||||
AppURL: "https://tinyauth.example.com",
|
AppURL: "https://tinyauth.example.com",
|
||||||
SessionCookieName: "tinyauth-session",
|
SessionCookieName: "tinyauth-session",
|
||||||
|
TrustedDomains: []string{
|
||||||
|
"https://tinyauth.example.com",
|
||||||
|
"https://tinyauth.foo.com",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
return config, runtime
|
return config, runtime
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func CreateTestHelpers() *model.RuntimeHelpers {
|
||||||
|
return &model.RuntimeHelpers{
|
||||||
|
GetCookieDomain: func(ctx context.Context, ip string) (string, error) {
|
||||||
|
return "example.com", nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+35
-23
@@ -1,7 +1,7 @@
|
|||||||
package utils
|
package utils
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"errors"
|
||||||
"net"
|
"net"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -9,36 +9,27 @@ import (
|
|||||||
"github.com/weppos/publicsuffix-go/publicsuffix"
|
"github.com/weppos/publicsuffix-go/publicsuffix"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetCookieDomain parses the app url and returns the domain value to use for cookies.
|
// Get cookie domain parses a hostname and returns the upper domain (e.g. sub1.sub2.domain.com -> sub2.domain.com)
|
||||||
// When auth for subdomains is enabled, it strips the leftmost label
|
func GetCookieDomain(u string) (string, error) {
|
||||||
// (e.g. sub1.sub2.domain.com -> sub2.domain.com), otherwise it returns the full hostname.
|
parsed, err := url.Parse(u)
|
||||||
func GetCookieDomain(appUrl string, subdomainsEnabled bool) (string, error) {
|
|
||||||
u, err := url.Parse(appUrl)
|
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("invalid app url: %w", err)
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
hostname := strings.ToLower(u.Hostname())
|
host := parsed.Hostname()
|
||||||
|
|
||||||
if netIP := net.ParseIP(hostname); netIP != nil {
|
if netIP := net.ParseIP(host); netIP != nil {
|
||||||
return "", fmt.Errorf("ip addresses not allowed")
|
return "", errors.New("ip addresses not allowed")
|
||||||
}
|
}
|
||||||
|
|
||||||
parts := strings.Split(hostname, ".")
|
parts := strings.Split(host, ".")
|
||||||
|
|
||||||
if len(parts) < 2 {
|
if len(parts) == 2 {
|
||||||
return "", fmt.Errorf("invalid app url, must be in format subdomain.domain.tld or domain.tld")
|
return host, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if !subdomainsEnabled || len(parts) == 2 {
|
if len(parts) < 3 {
|
||||||
_, err = publicsuffix.DomainFromListWithOptions(publicsuffix.DefaultList, hostname, nil)
|
return "", errors.New("invalid app url, must be at least second level domain")
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("domain in public suffix list, cannot set cookies: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return hostname, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
domain := strings.Join(parts[1:], ".")
|
domain := strings.Join(parts[1:], ".")
|
||||||
@@ -46,12 +37,33 @@ func GetCookieDomain(appUrl string, subdomainsEnabled bool) (string, error) {
|
|||||||
_, err = publicsuffix.DomainFromListWithOptions(publicsuffix.DefaultList, domain, nil)
|
_, err = publicsuffix.DomainFromListWithOptions(publicsuffix.DefaultList, domain, nil)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("domain in public suffix list, cannot set cookies: %w", err)
|
return "", errors.New("domain in public suffix list, cannot set cookies")
|
||||||
}
|
}
|
||||||
|
|
||||||
return domain, nil
|
return domain, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetStandaloneCookieDomain(u string) (string, error) {
|
||||||
|
parsed, err := url.Parse(u)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
host := parsed.Hostname()
|
||||||
|
|
||||||
|
if netIP := net.ParseIP(host); netIP != nil {
|
||||||
|
return "", errors.New("ip addresses not allowed")
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(host, ".")
|
||||||
|
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return "", errors.New("invalid app url")
|
||||||
|
}
|
||||||
|
|
||||||
|
return host, nil
|
||||||
|
}
|
||||||
|
|
||||||
func ParseFileToLine(content string) string {
|
func ParseFileToLine(content string) string {
|
||||||
lines := strings.Split(content, "\n")
|
lines := strings.Split(content, "\n")
|
||||||
users := make([]string, 0)
|
users := make([]string, 0)
|
||||||
|
|||||||
@@ -11,71 +11,50 @@ func TestGetRootDomain(t *testing.T) {
|
|||||||
// Normal case
|
// Normal case
|
||||||
domain := "http://sub.tinyauth.app"
|
domain := "http://sub.tinyauth.app"
|
||||||
expected := "tinyauth.app"
|
expected := "tinyauth.app"
|
||||||
result, err := utils.GetCookieDomain(domain, true)
|
result, err := utils.GetCookieDomain(domain)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, expected, result)
|
assert.Equal(t, expected, result)
|
||||||
|
|
||||||
// Domain with multiple subdomains
|
// Domain with multiple subdomains
|
||||||
domain = "http://b.c.tinyauth.app"
|
domain = "http://b.c.tinyauth.app"
|
||||||
expected = "c.tinyauth.app"
|
expected = "c.tinyauth.app"
|
||||||
result, err = utils.GetCookieDomain(domain, true)
|
result, err = utils.GetCookieDomain(domain)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, expected, result)
|
assert.Equal(t, expected, result)
|
||||||
|
|
||||||
// Invalid domain (only TLD)
|
// Invalid domain (only TLD)
|
||||||
domain = "com"
|
domain = "com"
|
||||||
_, err = utils.GetCookieDomain(domain, true)
|
_, err = utils.GetCookieDomain(domain)
|
||||||
assert.EqualError(t, err, "invalid app url, must be in format subdomain.domain.tld or domain.tld")
|
assert.ErrorContains(t, err, "invalid app url, must be at least second level domain")
|
||||||
|
|
||||||
// IP address
|
// IP address
|
||||||
domain = "http://10.10.10.10"
|
domain = "http://10.10.10.10"
|
||||||
_, err = utils.GetCookieDomain(domain, true)
|
_, err = utils.GetCookieDomain(domain)
|
||||||
assert.ErrorContains(t, err, "ip addresses not allowed")
|
assert.ErrorContains(t, err, "ip addresses not allowed")
|
||||||
|
|
||||||
// Invalid URL
|
// Invalid URL
|
||||||
domain = "http://[::1]:namedport"
|
domain = "http://[::1]:namedport"
|
||||||
_, err = utils.GetCookieDomain(domain, true)
|
_, err = utils.GetCookieDomain(domain)
|
||||||
assert.ErrorContains(t, err, "parse \"http://[::1]:namedport\": invalid port \":namedport\" after host")
|
assert.ErrorContains(t, err, "parse \"http://[::1]:namedport\": invalid port \":namedport\" after host")
|
||||||
|
|
||||||
// URL with scheme and path
|
// URL with scheme and path
|
||||||
domain = "https://sub.tinyauth.app/path"
|
domain = "https://sub.tinyauth.app/path"
|
||||||
expected = "tinyauth.app"
|
expected = "tinyauth.app"
|
||||||
result, err = utils.GetCookieDomain(domain, true)
|
result, err = utils.GetCookieDomain(domain)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, expected, result)
|
assert.Equal(t, expected, result)
|
||||||
|
|
||||||
// URL with port
|
// URL with port
|
||||||
domain = "http://sub.tinyauth.app:8080"
|
domain = "http://sub.tinyauth.app:8080"
|
||||||
expected = "tinyauth.app"
|
expected = "tinyauth.app"
|
||||||
result, err = utils.GetCookieDomain(domain, true)
|
result, err = utils.GetCookieDomain(domain)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, expected, result)
|
assert.Equal(t, expected, result)
|
||||||
|
|
||||||
// Domain managed by ICANN
|
// Domain managed by ICANN
|
||||||
domain = "http://example.co.uk"
|
domain = "http://example.co.uk"
|
||||||
_, err = utils.GetCookieDomain(domain, true)
|
_, err = utils.GetCookieDomain(domain)
|
||||||
assert.ErrorContains(t, err, "domain in public suffix list, cannot set cookies")
|
assert.Error(t, err, "domain in public suffix list, cannot set cookies")
|
||||||
|
|
||||||
// Domain without subdomain
|
|
||||||
domain = "http://tinyauth.app"
|
|
||||||
expected = "tinyauth.app"
|
|
||||||
result, err = utils.GetCookieDomain(domain, true)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Equal(t, expected, result)
|
|
||||||
|
|
||||||
// Case insensitivity
|
|
||||||
domain = "http://Sub.Tinyauth.App"
|
|
||||||
expected = "tinyauth.app"
|
|
||||||
result, err = utils.GetCookieDomain(domain, true)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Equal(t, expected, result)
|
|
||||||
|
|
||||||
// Subdomains disabled
|
|
||||||
domain = "http://sub.tinyauth.app"
|
|
||||||
expected = "sub.tinyauth.app"
|
|
||||||
result, err = utils.GetCookieDomain(domain, false)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Equal(t, expected, result)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseFileToLine(t *testing.T) {
|
func TestParseFileToLine(t *testing.T) {
|
||||||
@@ -146,3 +125,48 @@ func TestFilter(t *testing.T) {
|
|||||||
resultStr := utils.Filter(sliceStr, testFuncStr)
|
resultStr := utils.Filter(sliceStr, testFuncStr)
|
||||||
assert.Equal(t, expectedStr, resultStr)
|
assert.Equal(t, expectedStr, resultStr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetStandaloneCookieDomain(t *testing.T) {
|
||||||
|
// Normal case
|
||||||
|
domain := "http://tinyauth.app"
|
||||||
|
expected := "tinyauth.app"
|
||||||
|
result, err := utils.GetStandaloneCookieDomain(domain)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, expected, result)
|
||||||
|
|
||||||
|
// URL with subdomain (full hostname is returned, no subdomain stripping)
|
||||||
|
domain = "http://sub.tinyauth.app"
|
||||||
|
expected = "sub.tinyauth.app"
|
||||||
|
result, err = utils.GetStandaloneCookieDomain(domain)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, expected, result)
|
||||||
|
|
||||||
|
// URL with port (port should be stripped)
|
||||||
|
domain = "http://tinyauth.app:8080"
|
||||||
|
expected = "tinyauth.app"
|
||||||
|
result, err = utils.GetStandaloneCookieDomain(domain)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, expected, result)
|
||||||
|
|
||||||
|
// URL with path
|
||||||
|
domain = "https://tinyauth.app/some/path"
|
||||||
|
expected = "tinyauth.app"
|
||||||
|
result, err = utils.GetStandaloneCookieDomain(domain)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, expected, result)
|
||||||
|
|
||||||
|
// IP address
|
||||||
|
domain = "http://10.10.10.10"
|
||||||
|
_, err = utils.GetStandaloneCookieDomain(domain)
|
||||||
|
assert.ErrorContains(t, err, "ip addresses not allowed")
|
||||||
|
|
||||||
|
// Invalid domain (only TLD)
|
||||||
|
domain = "com"
|
||||||
|
_, err = utils.GetStandaloneCookieDomain(domain)
|
||||||
|
assert.ErrorContains(t, err, "invalid app url")
|
||||||
|
|
||||||
|
// Invalid URL
|
||||||
|
domain = "http://[::1]:namedport"
|
||||||
|
_, err = utils.GetStandaloneCookieDomain(domain)
|
||||||
|
assert.ErrorContains(t, err, "parse \"http://[::1]:namedport\": invalid port \":namedport\" after host")
|
||||||
|
}
|
||||||
|
|||||||
@@ -46,3 +46,28 @@ UPDATE "oidc_sessions" SET
|
|||||||
"userinfo_json" = $8
|
"userinfo_json" = $8
|
||||||
WHERE "sub" = $9
|
WHERE "sub" = $9
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: CreateOIDCConsent :one
|
||||||
|
INSERT INTO "oidc_consent" (
|
||||||
|
"uuid",
|
||||||
|
"client_id",
|
||||||
|
"scopes"
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3
|
||||||
|
)
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: GetOIDCConsentByUUID :one
|
||||||
|
SELECT * FROM "oidc_consent"
|
||||||
|
WHERE "uuid" = $1;
|
||||||
|
|
||||||
|
-- name: UpdateOIDCConsent :one
|
||||||
|
UPDATE "oidc_consent" SET
|
||||||
|
"scopes" = $1,
|
||||||
|
"updated_at" = CURRENT_TIMESTAMP
|
||||||
|
WHERE "uuid" = $2
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: DeleteOIDCConsentByUUID :exec
|
||||||
|
DELETE FROM "oidc_consent"
|
||||||
|
WHERE "uuid" = $1;
|
||||||
|
|||||||
@@ -9,3 +9,11 @@ CREATE TABLE IF NOT EXISTS "oidc_sessions" (
|
|||||||
"nonce" TEXT NOT NULL DEFAULT '',
|
"nonce" TEXT NOT NULL DEFAULT '',
|
||||||
"userinfo_json" TEXT NOT NULL
|
"userinfo_json" TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "oidc_consent" (
|
||||||
|
"uuid" TEXT NOT NULL UNIQUE PRIMARY KEY,
|
||||||
|
"client_id" TEXT NOT NULL,
|
||||||
|
"scopes" TEXT NOT NULL,
|
||||||
|
"created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|||||||
@@ -46,3 +46,28 @@ UPDATE "oidc_sessions" SET
|
|||||||
"userinfo_json" = ?
|
"userinfo_json" = ?
|
||||||
WHERE "sub" = ?
|
WHERE "sub" = ?
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: CreateOIDCConsent :one
|
||||||
|
INSERT INTO "oidc_consent" (
|
||||||
|
"uuid",
|
||||||
|
"client_id",
|
||||||
|
"scopes"
|
||||||
|
) VALUES (
|
||||||
|
?, ?, ?
|
||||||
|
)
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: GetOIDCConsentByUUID :one
|
||||||
|
SELECT * FROM "oidc_consent"
|
||||||
|
WHERE "uuid" = ?;
|
||||||
|
|
||||||
|
-- name: UpdateOIDCConsent :one
|
||||||
|
UPDATE "oidc_consent" SET
|
||||||
|
"scopes" = ?,
|
||||||
|
"updated_at" = CURRENT_TIMESTAMP
|
||||||
|
WHERE "uuid" = ?
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: DeleteOIDCConsentByUUID :exec
|
||||||
|
DELETE FROM "oidc_consent"
|
||||||
|
WHERE "uuid" = ?;
|
||||||
|
|||||||
@@ -9,3 +9,11 @@ CREATE TABLE IF NOT EXISTS "oidc_sessions" (
|
|||||||
"nonce" TEXT NOT NULL DEFAULT "",
|
"nonce" TEXT NOT NULL DEFAULT "",
|
||||||
"userinfo_json" TEXT NOT NULL
|
"userinfo_json" TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "oidc_consent" (
|
||||||
|
"uuid" TEXT NOT NULL UNIQUE PRIMARY KEY,
|
||||||
|
"client_id" TEXT NOT NULL,
|
||||||
|
"scopes" TEXT NOT NULL,
|
||||||
|
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user