mirror of
https://github.com/tinyauthapp/tinyauth.git
synced 2026-08-16 15:53:33 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
634964d10a | ||
|
|
f665d55bbf | ||
|
|
da7e0e39ba | ||
|
|
1d17917fca | ||
|
|
0f6bfcaf6b | ||
|
|
ff271e7f18 |
@@ -1,43 +0,0 @@
|
||||
name: Run e2e tests
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
test:
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # ratchet:actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # ratchet:pnpm/action-setup@v6
|
||||
with:
|
||||
package_json_file: ./e2e/package.json
|
||||
|
||||
- name: Set up Docker
|
||||
uses: docker/setup-docker-action@77e84dbf09b47d1e29270283c22f16145aa85ca1 # ratchet:docker/setup-docker-action@v5
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm ci
|
||||
working-directory: e2e
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: pnpm exec playwright install --with-deps
|
||||
working-directory: e2e
|
||||
|
||||
- name: Run Playwright tests
|
||||
run: pnpm test
|
||||
working-directory: e2e
|
||||
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # ratchet:actions/upload-artifact@v4
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: playwright-report
|
||||
path: e2e/playwright-report/
|
||||
retention-days: 5
|
||||
+1
-4
@@ -50,10 +50,7 @@ __debug_*
|
||||
config.certify.yml
|
||||
|
||||
# deepsec
|
||||
/.deepsec/
|
||||
/.deepsec
|
||||
|
||||
# jetbrains
|
||||
/.idea/
|
||||
|
||||
# claude stuff
|
||||
/.claude/
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
# Agents
|
||||
|
||||
*This file is written by Humans for Agents.*
|
||||
|
||||
## Overview
|
||||
|
||||
Tinyauth is a lightweight and open-source authentication server written in Go and TypeScript (React). It acts as either an authentication middleware (forward_auth, ext_authz or auth_request) to protect applications using proxy authentication or as an OpenID Connect provider to offer SSO (Single-Sign-On) to your self-hosted apps. It supports local users with optional 2FA (via TOTP), LDAP, SSO users via OAuth, and access controls (ACLs). Tinyauth can be deployed with Docker, Kubernetes or bare-metal with a binary.
|
||||
|
||||
## Considerations
|
||||
|
||||
- The repository we are working at is `https://github.com/tinyauthapp/tinyauth`.
|
||||
- ALWAYS follow the instructions for committing and creating a pull request as mentioned below.
|
||||
|
||||
## Philosophy
|
||||
|
||||
Tinyauth is designed to run with simplicity in mind. This is why we try to avoid adding unnecessary persistent storage and configuration options.
|
||||
|
||||
Tinyauth can run without persistent storage, and the SQLite database is only used for storing normal or OpenID Connect sessions. You MUST never store data in the database that is required for Tinyauth function.
|
||||
|
||||
As for the configuration, we support environment variables, CLI flags and a YAML configuration file. We try to keep the required configuration at a minimal with sane defaults so users can spend the least amount of time configuring Tinyauth.
|
||||
|
||||
We NEVER create a breaking change unless absolutely necessary and only if non-breaking changes have been discussed and deemed not ideal.
|
||||
|
||||
## Technical Overview
|
||||
|
||||
Tinyauth is designed to be as modular as possible. We use a repository-service-controller structure where each service/controller/middleware defines its dependencies in a Dig input struct, and then the main bootstrap entrypoint dynamically injects the dependencies to each method.
|
||||
|
||||
All methods share one global static config struct, which contains the user configuration as is, and a runtime config struct that contains dynamically generated values on startup. If a method needs a modified version, it MUST never modify the global configuration struct but rather create a local copy.
|
||||
|
||||
We write database migrations by hand. Migrations go in the respective database directory inside the `assets/migrations` directory and follow the `000001_migration_name_in_snake_case.sql` format where the 6-digit number is incremented on each new migration. The repository is automatically generated from SQL queries, SQLC and our own custom generator that unifies each SQLC package into one repository interface. Always ensure that migrations and queries exist for all available database drivers, else our store generation will fail. After adding your migrations and queries, run the SQLC code-gen with `make sql` and update the store code-gen with `make generate`. DO NOT EDIT the automatically generated files from SQLC or our store generator, they are marked.
|
||||
|
||||
When updating translations, you should only update the `frontend/src/lib/i18n/locales/en.json` and `frontend/src/lib/i18n/locales/en-US.json` files (they should be exactly the same). Crowdin will handle the generation of the keys for the rest of the available locales. NEVER hard-code plain English in the frontend, instead use the available `i18next` library and the respective translations.
|
||||
|
||||
For the REST framework we use Gin. However, functions or methods should avoid using the Gin Context (`gin.Context`) and default to stdlib arguments and outputs. The Gin Context exposes stdlib-compatible structs such as `http.Request` and `http.ResponseWriter` and is compatible with the `context` package.
|
||||
|
||||
When you need to log in the backend, use the injected logger, NOT the global zerolog struct.
|
||||
|
||||
In case you need toolchain versions, you can find the Node + Go version in the `Dockerfile` and the PNPM version in the `package.json` file inside the `frontend` directory.
|
||||
|
||||
Tinyauth uses Semantic Versioning (SemVer) for versions.
|
||||
|
||||
## File structure
|
||||
|
||||
Tinyauth is composed of two parts, the React frontend and the Go backend.
|
||||
|
||||
A high level of the backend is as follows:
|
||||
|
||||
```text
|
||||
internal
|
||||
├── assets # Contains the embedded assets
|
||||
│ ├── dist # Dist is the compiled frontend
|
||||
│ └── migrations # Migrations in SQL for all supported databases
|
||||
│ ├── postgres
|
||||
│ └── sqlite
|
||||
├── bootstrap # The main entrypoint that bootstraps and starts Tinyauth, called by the CLI
|
||||
├── controller # All of the HTTP controllers
|
||||
├── middleware # The HTTP middlewares
|
||||
├── model # Configuration schemas
|
||||
├── repository # Repository holds all of the queries used by the services, each child-repository implements the store interface
|
||||
│ ├── memory
|
||||
│ ├── postgres
|
||||
│ └── sqlite
|
||||
├── service # The services that handle the underlying logic for the controllers
|
||||
├── test # Creates any necessary package-wide configurations and helpers used by tests
|
||||
└── utils # Small helpers and utils used by the app
|
||||
├── decoders # Wrappers around paerser decoders such as the label decoder
|
||||
├── loaders # The env, cli and YAML wrappers around the paerser loaders
|
||||
└── logger # A wrapper around the zerolog logging library
|
||||
```
|
||||
|
||||
Same for the frontend:
|
||||
|
||||
```text
|
||||
frontend/src
|
||||
├── components # Different components used by the pages
|
||||
│ ├── auth # Forms used for authentication
|
||||
│ ├── domain-warning # Domain warning when configured domain and actual domain don't match
|
||||
│ ├── icons # Hardcoded SVG icons for OAuth providers
|
||||
│ ├── layout # Main frontend layout
|
||||
│ ├── providers # Different state providers such as theme
|
||||
│ ├── quick-actions # The top right quick settings menu
|
||||
│ └── ui # ShadCN based UI components
|
||||
├── context # Holds and provides the app and user context
|
||||
├── lib # Helpers used by the pages
|
||||
│ ├── hooks # Hooks around the query parameters
|
||||
│ └── i18n # Holds translation logic
|
||||
│ └── locales # The raw JSON locales provided by Crowdin
|
||||
├── pages # The actual app pages
|
||||
└── schemas # Different schemas, mostly used for fetching data from the backend
|
||||
```
|
||||
|
||||
## Make recipes
|
||||
|
||||
Tinyauth uses a Makefile for simplifying development. A reference of the available recipes can be found below:
|
||||
|
||||
- `deps` - Install the frontend and backend dependencies.
|
||||
- `clean-data` - Clean any data created by running Tinyauth.
|
||||
- `clean-webui` - Clean frontend build output.
|
||||
- `webui` - Compile the WebUI.
|
||||
- `binary` - Compile the binary for the current system.
|
||||
- `binary-linux-amd64` - Compile the binary for Linux amd64.
|
||||
- `binary-linux-arm64` - Compile the binary for Linux arm64.
|
||||
- `test` - Test the Go backend.
|
||||
- `vet` - Vet the Go backend.
|
||||
- `test-race` - Test the Go backend with the race detector enabled.
|
||||
- `dev` - Start the Docker-based development server.
|
||||
- `prod` - Start the Docker-based production deployment (used for testing pre-releases).
|
||||
- `sql` - Generate the SQLC repositories.
|
||||
- `generate` - Update Go code-gen.
|
||||
- `docker` - Build the Docker image for the current system.
|
||||
- `docker-distroless` - Build the distroless Docker image for the current system.
|
||||
- `lint-webui` - Lint the frontend with ESLint.
|
||||
- `fmt` - Format the Go code with the Go `fmt` tool.
|
||||
|
||||
## Development lifecycle
|
||||
|
||||
Development of Tinyauth happens inside two Docker containers. The backend is built automatically by air using a template build output for the frontend. The frontend is run with PNPM, and then backend requests are routed with the help of Vite's proxy.
|
||||
|
||||
When developing, you should default to the `make dev` command to start everything in Docker and avoid platform-specific issues. If you need to test the CLI, use the `make binary` command.
|
||||
|
||||
After finishing with the development, test and vet the backend with `make test` and `make vet` respectively. If you believe you need to test for race conditions, use `make test-race`. You can also test specific parts of the code using the normal `go test` command, for example to run the `TestHealthController` test, you can use `go test ./internal/controller/ -run TestHealthController -v`. Finally, format the Go code with `make fmt`.
|
||||
|
||||
If you made any changes to the frontend, make sure to lint with `make lint-webui`.
|
||||
|
||||
NEVER run any destructive commands like `make clean-data` or delete any configurations without the user's approval.
|
||||
|
||||
## Creating a pull request
|
||||
|
||||
When committing, you MUST use the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0) standard for your commit messages. You can add a commit description if you like. You MUST also use your standard no-reply Co-Author trailer (`Co-Authored-By:`).
|
||||
|
||||
You should work in separate branches unless it's clearly specified to work in the main branch. When working in a separate branch, follow the naming convention below:
|
||||
|
||||
```text
|
||||
[feat/refactor/fix/tests/etc]/[small-change-description-in-kebab-case]
|
||||
```
|
||||
|
||||
For example, if your change was to add OAuth to Tinyauth, the branch would look as follows:
|
||||
|
||||
```text
|
||||
feat/oauth
|
||||
```
|
||||
|
||||
Or:
|
||||
|
||||
```text
|
||||
feat/add-oauth-support
|
||||
```
|
||||
|
||||
Shorter branch names that still describe the general change are preferred.
|
||||
|
||||
Finally, when creating the actual pull request and if you have access to the internet/a GitHub tool, you should look if it resolves any open issues and if it does, reference them.
|
||||
@@ -107,11 +107,3 @@ docker-distroless:
|
||||
--build-arg=BUILD_TIMESTAMP=$(BUILD_TIMESTAMP) \
|
||||
--build-arg=BUILD_TAGS=$(BUILD_TAGS) \
|
||||
-f Dockerfile.distroless .
|
||||
|
||||
# Lint the frontend
|
||||
lint-webui:
|
||||
cd frontend && pnpm lint
|
||||
|
||||
# Format the code
|
||||
fmt:
|
||||
go fmt ./...
|
||||
@@ -1,7 +0,0 @@
|
||||
# playwright files
|
||||
/node_modules/
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
/playwright/.auth/
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
auto_https off
|
||||
}
|
||||
|
||||
http://whoami.127.0.0.1.sslip.io {
|
||||
forward_auth tinyauth:3000 {
|
||||
uri /api/auth/caddy
|
||||
copy_headers Remote-User Remote-Name Remote-Email Remote-Groups
|
||||
}
|
||||
reverse_proxy whoami:80
|
||||
}
|
||||
|
||||
http://tinyauth.127.0.0.1.sslip.io {
|
||||
reverse_proxy tinyauth:3000
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
appUrl: http://tinyauth.127.0.0.1.sslip.io
|
||||
|
||||
log:
|
||||
level: debug
|
||||
|
||||
auth:
|
||||
users:
|
||||
# user1:password,user2:password,user3:password:token
|
||||
- user1:$2a$10$h1laww4k5a4bJcG5KwE3nO45YKSC4mOKHxbcccgxr3Y7H9zHlQe8e
|
||||
- user2:$2a$10$h1laww4k5a4bJcG5KwE3nO45YKSC4mOKHxbcccgxr3Y7H9zHlQe8e
|
||||
- user3:$2a$10$h1laww4k5a4bJcG5KwE3nO45YKSC4mOKHxbcccgxr3Y7H9zHlQe8e:MVR4JQWNXYKNM6HHJEYEFP2O74QIIEJE
|
||||
# disable rate limits for multiple workers to work
|
||||
loginMaxRetries: 0
|
||||
|
||||
apps:
|
||||
whoami:
|
||||
config:
|
||||
domain: whoami.127.0.0.1.sslip.io
|
||||
path:
|
||||
allow: /foo
|
||||
users:
|
||||
allow: user1
|
||||
@@ -1,24 +0,0 @@
|
||||
services:
|
||||
caddy:
|
||||
image: caddy:2.11.4
|
||||
pull_policy: missing
|
||||
ports:
|
||||
- 80:80
|
||||
volumes:
|
||||
- ./conf:/etc/caddy
|
||||
|
||||
whoami:
|
||||
image: traefik/whoami:v1.11.0
|
||||
pull_policy: missing
|
||||
|
||||
tinyauth:
|
||||
build:
|
||||
context: ../
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
- VERSION=e2e
|
||||
- BUILD_TAGS=nomsgpack
|
||||
- LDFLAGS=-s -w
|
||||
command: ["--configfile", "/app/config.yaml"]
|
||||
volumes:
|
||||
- ./config.e2e.yaml:/app/config.yaml:ro
|
||||
@@ -1,47 +0,0 @@
|
||||
import {expect, Page} from '@playwright/test';
|
||||
import { OTP } from 'otplib';
|
||||
|
||||
export class LoginFixture {
|
||||
constructor(public readonly page: Page) {}
|
||||
|
||||
async run(username: string, password: string) {
|
||||
await expect(this.page.getByText('Welcome back, please login')).toBeVisible();
|
||||
await this.page.getByLabel('Username').fill(username);
|
||||
await this.page.getByLabel('Password').fill(password);
|
||||
await this.page.getByRole('button', { name: 'Login' }).click();
|
||||
}
|
||||
|
||||
async expectSuccess(username: string) {
|
||||
await expect(this.page.getByText(`You are currently logged in as ${username}.`)).toBeVisible()
|
||||
}
|
||||
}
|
||||
|
||||
export class LogoutFixture {
|
||||
constructor(public readonly page: Page) {}
|
||||
|
||||
async run() {
|
||||
await expect(this.page.getByText('Click the button below to logout.')).toBeVisible();
|
||||
await this.page.getByRole('button', { name: 'Logout' }).click();
|
||||
}
|
||||
|
||||
async expectSuccess() {
|
||||
await expect(this.page.getByText('Welcome back, please login')).toBeVisible();
|
||||
}
|
||||
}
|
||||
|
||||
export class TOTPFixture {
|
||||
constructor(public readonly page: Page) {}
|
||||
|
||||
async run(secret: string) {
|
||||
await expect(this.page.getByText('Enter your TOTP code')).toBeVisible();
|
||||
const otp = new OTP();
|
||||
const token = await otp.generate({ secret });
|
||||
await this.page.getByPlaceholder('XXXXXX').fill(token);
|
||||
// we shouldn't need to click continue, it will auto submit
|
||||
// await this.page.getByRole('button', { name: 'Continue' }).click();
|
||||
}
|
||||
|
||||
async expectSuccess(username: string) {
|
||||
await expect(this.page.getByText(`You are currently logged in as ${username}.`)).toBeVisible()
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
{
|
||||
"name": "e2e",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"down": "docker compose -f docker-compose.e2e.yml down",
|
||||
"up": "docker compose -f docker-compose.e2e.yml up --build --force-recreate --remove-orphans",
|
||||
"test": "playwright test",
|
||||
"report": "playwright show-report"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devEngines": {
|
||||
"packageManager": {
|
||||
"name": "pnpm",
|
||||
"version": "^11.1.2",
|
||||
"onFail": "download"
|
||||
}
|
||||
},
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@types/node": "^26.1.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"otplib": "^13.4.1"
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './specs',
|
||||
fullyParallel: true,
|
||||
forbidOnly: false,
|
||||
retries: 0,
|
||||
workers: 4,
|
||||
reporter: 'html',
|
||||
use: {
|
||||
trace: 'on-first-retry',
|
||||
video: 'on',
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
{
|
||||
name: 'firefox',
|
||||
use: { ...devices['Desktop Firefox'] },
|
||||
},
|
||||
|
||||
{
|
||||
name: 'webkit',
|
||||
use: { ...devices['Desktop Safari'] },
|
||||
},
|
||||
{
|
||||
name: 'Mobile Chrome',
|
||||
use: { ...devices['Pixel 5'] },
|
||||
},
|
||||
{
|
||||
name: 'Mobile Safari',
|
||||
use: { ...devices['iPhone 12'] },
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: 'pnpm run up',
|
||||
url: 'http://tinyauth.127.0.0.1.sslip.io/api/healthz',
|
||||
reuseExistingServer: true,
|
||||
timeout: 5 * 60 * 1000,
|
||||
gracefulShutdown: {
|
||||
signal: 'SIGINT',
|
||||
timeout: 1000,
|
||||
},
|
||||
stderr: 'pipe',
|
||||
stdout: 'pipe',
|
||||
},
|
||||
});
|
||||
Generated
-338
@@ -1,338 +0,0 @@
|
||||
---
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
configDependencies: {}
|
||||
packageManagerDependencies:
|
||||
'@pnpm/exe':
|
||||
specifier: ^11.1.2
|
||||
version: 11.13.1
|
||||
pnpm:
|
||||
specifier: ^11.1.2
|
||||
version: 11.13.1
|
||||
|
||||
packages:
|
||||
|
||||
'@pnpm/exe@11.13.1':
|
||||
resolution: {integrity: sha512-P4euEK6lOFnd5oTHEc5M/HhvyF4XUhTnVsklEcM6rmY0QJxPD6xbT+u1+gskEIBp4nSRorz20IJQtAU1Nerggg==}
|
||||
hasBin: true
|
||||
|
||||
'@pnpm/linux-arm64@11.13.1':
|
||||
resolution: {integrity: sha512-wB8zloqrYrudPyuA5qbuTCnJGe4eETPwqOjoPjoyyyvA4zFI5XfLpxgqOOcaY5UJBoqzckcGpRVDwhSRfsQ/6A==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@pnpm/linux-x64@11.13.1':
|
||||
resolution: {integrity: sha512-A+wnEvzfWEvanXiwww3tnOPmtjPSrrf5tOP6vk8+K0BRFEe/Df0oPytm2nWgGcn5iwPnqtr1Btkof913McnSPA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@pnpm/linuxstatic-arm64@11.13.1':
|
||||
resolution: {integrity: sha512-k4t65VeqRX4COMFe45TF58CVmCpmAsKZShaR1HobmUeleo98mWTctggKolrA2MHcVUeSS+12yB5Urb3uDazhmw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@pnpm/linuxstatic-x64@11.13.1':
|
||||
resolution: {integrity: sha512-A65GqPzwCl0bAMk3kRWfbjSRBm5RRaqR2oMxV/9AYZrwO0X9yEfngbLBISCPHjt6/Qe4nH7DFemyhy6yODYwEw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@pnpm/macos-arm64@11.13.1':
|
||||
resolution: {integrity: sha512-MJvOtyGOWSfBoqdVEfAH8ljmHs13mt82k/UxN4f+q7koDxJRR2n4Nie6Og6RwbnbaubCz0Fh2bTeL1+MxDSFpA==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@pnpm/win-arm64@11.13.1':
|
||||
resolution: {integrity: sha512-kl/g1cCKOJPe4HntspyrAJW0LRco0UHnVfxHSspezo4Zj4AanJAZ8WzLqfa6/w3lBSKHTEN4x0pb3m4J7B7Vpw==}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@pnpm/win-x64@11.13.1':
|
||||
resolution: {integrity: sha512-Bcb14NeBlbHS2Gq1qr8VnCiAz5eC1lYzXOls7zH0bnV0Taaj4/xyfm0HVO4dn9R2TQtVtz1qnBZHHL9PDFumqQ==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@reflink/reflink-darwin-arm64@0.1.19':
|
||||
resolution: {integrity: sha512-ruy44Lpepdk1FqDz38vExBY/PVUsjxZA+chd9wozjUH9JjuDT/HEaQYA6wYN9mf041l0yLVar6BCZuWABJvHSA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@reflink/reflink-darwin-x64@0.1.19':
|
||||
resolution: {integrity: sha512-By85MSWrMZa+c26TcnAy8SDk0sTUkYlNnwknSchkhHpGXOtjNDUOxJE9oByBnGbeuIE1PiQsxDG3Ud+IVV9yuA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@reflink/reflink-linux-arm64-gnu@0.1.19':
|
||||
resolution: {integrity: sha512-7P+er8+rP9iNeN+bfmccM4hTAaLP6PQJPKWSA4iSk2bNvo6KU6RyPgYeHxXmzNKzPVRcypZQTpFgstHam6maVg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@reflink/reflink-linux-arm64-musl@0.1.19':
|
||||
resolution: {integrity: sha512-37iO/Dp6m5DDaC2sf3zPtx/hl9FV3Xze4xoYidrxxS9bgP3S8ALroxRK6xBG/1TtfXKTvolvp+IjrUU6ujIGmA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@reflink/reflink-linux-x64-gnu@0.1.19':
|
||||
resolution: {integrity: sha512-jbI8jvuYCaA3MVUdu8vLoLAFqC+iNMpiSuLbxlAgg7x3K5bsS8nOpTRnkLF7vISJ+rVR8W+7ThXlXlUQ93ulkw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@reflink/reflink-linux-x64-musl@0.1.19':
|
||||
resolution: {integrity: sha512-e9FBWDe+lv7QKAwtKOt6A2W/fyy/aEEfr0g6j/hWzvQcrzHCsz07BNQYlNOjTfeytrtLU7k449H1PI95jA4OjQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@reflink/reflink-win32-arm64-msvc@0.1.19':
|
||||
resolution: {integrity: sha512-09PxnVIQcd+UOn4WAW73WU6PXL7DwGS6wPlkMhMg2zlHHG65F3vHepOw06HFCq+N42qkaNAc8AKIabWvtk6cIQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@reflink/reflink-win32-x64-msvc@0.1.19':
|
||||
resolution: {integrity: sha512-E//yT4ni2SyhwP8JRjVGWr3cbnhWDiPLgnQ66qqaanjjnMiu3O/2tjCPQXlcGc/DEYofpDc9fvhv6tALQsMV9w==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@reflink/reflink@0.1.19':
|
||||
resolution: {integrity: sha512-DmCG8GzysnCZ15bres3N5AHCmwBwYgp0As6xjhQ47rAUTUXxJiK+lLUxaGsX3hd/30qUpVElh05PbGuxRPgJwA==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
detect-libc@2.1.2:
|
||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
pnpm@11.13.1:
|
||||
resolution: {integrity: sha512-svx2g7imUlQU59E+G6KMqt3elr9m7FQL+ut+cCuB8+C+TR8pXt9/n+A5Z0Co3ORQnFgt33mJH0VD/qMtN2RfJQ==}
|
||||
engines: {node: '>=22.13'}
|
||||
hasBin: true
|
||||
|
||||
snapshots:
|
||||
|
||||
'@pnpm/exe@11.13.1':
|
||||
dependencies:
|
||||
'@reflink/reflink': 0.1.19
|
||||
detect-libc: 2.1.2
|
||||
optionalDependencies:
|
||||
'@pnpm/linux-arm64': 11.13.1
|
||||
'@pnpm/linux-x64': 11.13.1
|
||||
'@pnpm/linuxstatic-arm64': 11.13.1
|
||||
'@pnpm/linuxstatic-x64': 11.13.1
|
||||
'@pnpm/macos-arm64': 11.13.1
|
||||
'@pnpm/win-arm64': 11.13.1
|
||||
'@pnpm/win-x64': 11.13.1
|
||||
|
||||
'@pnpm/linux-arm64@11.13.1':
|
||||
optional: true
|
||||
|
||||
'@pnpm/linux-x64@11.13.1':
|
||||
optional: true
|
||||
|
||||
'@pnpm/linuxstatic-arm64@11.13.1':
|
||||
optional: true
|
||||
|
||||
'@pnpm/linuxstatic-x64@11.13.1':
|
||||
optional: true
|
||||
|
||||
'@pnpm/macos-arm64@11.13.1':
|
||||
optional: true
|
||||
|
||||
'@pnpm/win-arm64@11.13.1':
|
||||
optional: true
|
||||
|
||||
'@pnpm/win-x64@11.13.1':
|
||||
optional: true
|
||||
|
||||
'@reflink/reflink-darwin-arm64@0.1.19':
|
||||
optional: true
|
||||
|
||||
'@reflink/reflink-darwin-x64@0.1.19':
|
||||
optional: true
|
||||
|
||||
'@reflink/reflink-linux-arm64-gnu@0.1.19':
|
||||
optional: true
|
||||
|
||||
'@reflink/reflink-linux-arm64-musl@0.1.19':
|
||||
optional: true
|
||||
|
||||
'@reflink/reflink-linux-x64-gnu@0.1.19':
|
||||
optional: true
|
||||
|
||||
'@reflink/reflink-linux-x64-musl@0.1.19':
|
||||
optional: true
|
||||
|
||||
'@reflink/reflink-win32-arm64-msvc@0.1.19':
|
||||
optional: true
|
||||
|
||||
'@reflink/reflink-win32-x64-msvc@0.1.19':
|
||||
optional: true
|
||||
|
||||
'@reflink/reflink@0.1.19':
|
||||
optionalDependencies:
|
||||
'@reflink/reflink-darwin-arm64': 0.1.19
|
||||
'@reflink/reflink-darwin-x64': 0.1.19
|
||||
'@reflink/reflink-linux-arm64-gnu': 0.1.19
|
||||
'@reflink/reflink-linux-arm64-musl': 0.1.19
|
||||
'@reflink/reflink-linux-x64-gnu': 0.1.19
|
||||
'@reflink/reflink-linux-x64-musl': 0.1.19
|
||||
'@reflink/reflink-win32-arm64-msvc': 0.1.19
|
||||
'@reflink/reflink-win32-x64-msvc': 0.1.19
|
||||
|
||||
detect-libc@2.1.2: {}
|
||||
|
||||
pnpm@11.13.1: {}
|
||||
|
||||
---
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
otplib:
|
||||
specifier: ^13.4.1
|
||||
version: 13.4.1
|
||||
devDependencies:
|
||||
'@playwright/test':
|
||||
specifier: ^1.61.1
|
||||
version: 1.61.1
|
||||
'@types/node':
|
||||
specifier: ^26.1.1
|
||||
version: 26.1.1
|
||||
|
||||
packages:
|
||||
|
||||
'@noble/hashes@2.2.0':
|
||||
resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==}
|
||||
engines: {node: '>= 20.19.0'}
|
||||
|
||||
'@otplib/core@13.4.1':
|
||||
resolution: {integrity: sha512-KIXgK1hNtWJEBMTastbe1bpmuais+3f+ATeO8TkMs2rNkfGO1FbQy8+/UWVEu3TR/iTJerU0idkPudaPmLP2BA==}
|
||||
|
||||
'@otplib/hotp@13.4.1':
|
||||
resolution: {integrity: sha512-g9q04SwpG5ZtMnVkUcgcoAlwCH4YLROZN1qhyBwgkBzqYYVSYhpP6gSGaxGHwePLt1c+e6NqDlgIZN+e1/XPuA==}
|
||||
|
||||
'@otplib/plugin-base32-scure@13.4.1':
|
||||
resolution: {integrity: sha512-Fs/r5qisC05SRhT6xWXaypB6PVC0vgWf6zztmi0J5RnQ09OJiPDWCJFH6cDm6ANsrdvB9di7X+Jb7L13BoEbUA==}
|
||||
|
||||
'@otplib/plugin-crypto-noble@13.4.1':
|
||||
resolution: {integrity: sha512-PJfVW8/1hdS6CfxLheKPZSLTwDq4TijZbN4yRjxlv0ODdzmxpM+wGwWr1JXMdy0xJPxLziydQD5gdVqrR4/gAg==}
|
||||
|
||||
'@otplib/totp@13.4.1':
|
||||
resolution: {integrity: sha512-QOkBVPrf6AM4qZaReZPSk9/I8ATVdZpIISJz115MqeVtcrbcr5llPZ0J7804tpnjnp1vCRkI5Qjd47HhgVteBQ==}
|
||||
|
||||
'@otplib/uri@13.4.1':
|
||||
resolution: {integrity: sha512-xaIm7bvICMhoB2rZIR5luiaMdssWR5nY5nXnR1fdezUgZuEO58D6zrGzLp7pQuBmlpmL0HagnscDQFoskp9yiA==}
|
||||
|
||||
'@playwright/test@1.61.1':
|
||||
resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
'@scure/base@2.2.0':
|
||||
resolution: {integrity: sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==}
|
||||
|
||||
'@types/node@26.1.1':
|
||||
resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==}
|
||||
|
||||
fsevents@2.3.2:
|
||||
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
|
||||
otplib@13.4.1:
|
||||
resolution: {integrity: sha512-o5CxfDw6bh7hoDv0NUUIcc0RqzJ9ipfUrzeKheKJ+vs4rXZnDlA9n4a/7R1cDjpmLjKLix4BgNVRmoDkm5rLSQ==}
|
||||
|
||||
playwright-core@1.61.1:
|
||||
resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
playwright@1.61.1:
|
||||
resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
undici-types@8.3.0:
|
||||
resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@noble/hashes@2.2.0': {}
|
||||
|
||||
'@otplib/core@13.4.1': {}
|
||||
|
||||
'@otplib/hotp@13.4.1':
|
||||
dependencies:
|
||||
'@otplib/core': 13.4.1
|
||||
'@otplib/uri': 13.4.1
|
||||
|
||||
'@otplib/plugin-base32-scure@13.4.1':
|
||||
dependencies:
|
||||
'@otplib/core': 13.4.1
|
||||
'@scure/base': 2.2.0
|
||||
|
||||
'@otplib/plugin-crypto-noble@13.4.1':
|
||||
dependencies:
|
||||
'@noble/hashes': 2.2.0
|
||||
'@otplib/core': 13.4.1
|
||||
|
||||
'@otplib/totp@13.4.1':
|
||||
dependencies:
|
||||
'@otplib/core': 13.4.1
|
||||
'@otplib/hotp': 13.4.1
|
||||
'@otplib/uri': 13.4.1
|
||||
|
||||
'@otplib/uri@13.4.1':
|
||||
dependencies:
|
||||
'@otplib/core': 13.4.1
|
||||
|
||||
'@playwright/test@1.61.1':
|
||||
dependencies:
|
||||
playwright: 1.61.1
|
||||
|
||||
'@scure/base@2.2.0': {}
|
||||
|
||||
'@types/node@26.1.1':
|
||||
dependencies:
|
||||
undici-types: 8.3.0
|
||||
|
||||
fsevents@2.3.2:
|
||||
optional: true
|
||||
|
||||
otplib@13.4.1:
|
||||
dependencies:
|
||||
'@otplib/core': 13.4.1
|
||||
'@otplib/hotp': 13.4.1
|
||||
'@otplib/plugin-base32-scure': 13.4.1
|
||||
'@otplib/plugin-crypto-noble': 13.4.1
|
||||
'@otplib/totp': 13.4.1
|
||||
'@otplib/uri': 13.4.1
|
||||
|
||||
playwright-core@1.61.1: {}
|
||||
|
||||
playwright@1.61.1:
|
||||
dependencies:
|
||||
playwright-core: 1.61.1
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.2
|
||||
|
||||
undici-types@8.3.0: {}
|
||||
@@ -1,28 +0,0 @@
|
||||
import {expect, test} from '@playwright/test';
|
||||
import {LoginFixture} from "../fixtures/auth.fixtures";
|
||||
|
||||
test('should be able to login to app with forward-auth', async ({ page }) => {
|
||||
const loginFixture = new LoginFixture(page);
|
||||
await page.goto('http://whoami.127.0.0.1.sslip.io');
|
||||
// redirect to tinyauth
|
||||
await expect(page.getByText('Welcome back, please login')).toBeVisible()
|
||||
await loginFixture.run('user1', 'password')
|
||||
// redirect to app
|
||||
await expect(page.getByText('whoami.127.0.0.1.sslip.io')).toBeVisible()
|
||||
});
|
||||
|
||||
test('non authorized user should not be able to access app', async ({ page }) => {
|
||||
const loginFixture = new LoginFixture(page);
|
||||
await page.goto('http://whoami.127.0.0.1.sslip.io');
|
||||
// redirect to tinyauth
|
||||
await expect(page.getByText('Welcome back, please login')).toBeVisible()
|
||||
// user2 is not authorized to access app
|
||||
await loginFixture.run('user2', 'password')
|
||||
// redirect to app
|
||||
await expect(page.getByText('The user with username user2 is not authorized to access the resource whoami.')).toBeVisible()
|
||||
})
|
||||
|
||||
test('allowed path should skip authentication', async ({ page }) => {
|
||||
await page.goto('http://whoami.127.0.0.1.sslip.io/foo');
|
||||
await expect(page.getByText('whoami.127.0.0.1.sslip.io')).toBeVisible()
|
||||
})
|
||||
@@ -1,48 +0,0 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {LoginFixture, LogoutFixture, TOTPFixture} from "../fixtures/auth.fixtures";
|
||||
|
||||
test('should be able to login', async ({ page }) => {
|
||||
const loginFixture = new LoginFixture(page);
|
||||
await page.goto('http://tinyauth.127.0.0.1.sslip.io');
|
||||
await loginFixture.run('user1', 'password')
|
||||
await loginFixture.expectSuccess('user1')
|
||||
});
|
||||
|
||||
test('should fail to login with wrong credentials', async ({ page }) => {
|
||||
const loginFixture = new LoginFixture(page);
|
||||
await page.goto('http://tinyauth.127.0.0.1.sslip.io');
|
||||
await loginFixture.run('user27267', 'password')
|
||||
const toast = page.locator('[data-sonner-toast]').first();
|
||||
await expect(toast).toBeVisible();
|
||||
await expect(toast).toContainText('Failed to log in');
|
||||
});
|
||||
|
||||
|
||||
test('should be able to logout', async ({ page }) => {
|
||||
const loginFixture = new LoginFixture(page);
|
||||
await page.goto('http://tinyauth.127.0.0.1.sslip.io');
|
||||
await loginFixture.run('user1', 'password')
|
||||
await loginFixture.expectSuccess('user1')
|
||||
const logoutFixture = new LogoutFixture(page);
|
||||
await logoutFixture.run()
|
||||
})
|
||||
|
||||
test('should be able to login with totp', async ({ page }) => {
|
||||
const loginFixture = new LoginFixture(page);
|
||||
const totpFixture = new TOTPFixture(page);
|
||||
await page.goto('http://tinyauth.127.0.0.1.sslip.io');
|
||||
await loginFixture.run('user3', 'password')
|
||||
await totpFixture.run('MVR4JQWNXYKNM6HHJEYEFP2O74QIIEJE')
|
||||
await loginFixture.expectSuccess('user3');
|
||||
});
|
||||
|
||||
test('should fail to login with wrong totp', async ({ page }) => {
|
||||
const loginFixture = new LoginFixture(page);
|
||||
const totpFixture = new TOTPFixture(page);
|
||||
await page.goto('http://tinyauth.127.0.0.1.sslip.io');
|
||||
await loginFixture.run('user3', 'password')
|
||||
await totpFixture.run('VZVMOMQCBN24DJ5VRFAL5TJAZGBHXMN3')
|
||||
const toast = page.locator('[data-sonner-toast]').first();
|
||||
await expect(toast).toBeVisible();
|
||||
await expect(toast).toContainText('Failed to verify code');
|
||||
});
|
||||
@@ -190,8 +190,7 @@ export const AuthorizePage = () => {
|
||||
<CardFooter className="flex flex-col items-stretch gap-3">
|
||||
<Button
|
||||
onClick={() => authorizeMutate()}
|
||||
loading={authorizePending}
|
||||
disabled={shouldAutoAuthorize}
|
||||
loading={authorizePending || shouldAutoAuthorize}
|
||||
>
|
||||
{t("authorizeTitle")}
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS "oidc_consents";
|
||||
@@ -0,0 +1,7 @@
|
||||
CREATE TABLE IF NOT EXISTS "oidc_consents" (
|
||||
"username" TEXT NOT NULL,
|
||||
"client_id" TEXT NOT NULL,
|
||||
"scope" TEXT NOT NULL,
|
||||
"created_at" BIGINT NOT NULL,
|
||||
PRIMARY KEY ("username", "client_id")
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS "oidc_consents";
|
||||
@@ -0,0 +1,7 @@
|
||||
CREATE TABLE IF NOT EXISTS "oidc_consents" (
|
||||
"username" TEXT NOT NULL,
|
||||
"client_id" TEXT NOT NULL,
|
||||
"scope" TEXT NOT NULL,
|
||||
"created_at" INTEGER NOT NULL,
|
||||
PRIMARY KEY ("username", "client_id")
|
||||
);
|
||||
@@ -179,8 +179,6 @@ func (app *BootstrapApp) Setup() error {
|
||||
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.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)
|
||||
|
||||
// database
|
||||
|
||||
@@ -242,6 +242,16 @@ func (controller *OIDCController) authorize(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
if userContext != nil && userContext.Authenticated && values.OIDCPrompt != service.OIDCPromptLogin {
|
||||
consent, err := controller.oidc.GetOIDCConsent(c, userContext.GetUsername(), req.ClientID)
|
||||
|
||||
if err != nil {
|
||||
controller.log.App.Warn().Err(err).Msg("Failed to get OIDC consent")
|
||||
} else if consent != nil && scopesGranted(consent.Scope, req.Scope) {
|
||||
values.OIDCPrompt = service.OIDCPromptNone
|
||||
}
|
||||
}
|
||||
|
||||
queries, err := query.Values(values)
|
||||
|
||||
if err != nil {
|
||||
@@ -320,6 +330,19 @@ func (controller *OIDCController) authorizeComplete(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Get the client
|
||||
client, ok := controller.oidc.GetClient(authorizeReq.ClientID)
|
||||
|
||||
if !ok {
|
||||
controller.authorizeError(c, authorizeErrorParams{
|
||||
err: errors.New("client not found"),
|
||||
reason: "Client not found",
|
||||
reasonPublic: "The client is not configured",
|
||||
json: true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// We no longer need the ticket
|
||||
controller.oidc.DeleteAuthorizeRequestTicket(req.Ticket)
|
||||
|
||||
@@ -356,6 +379,11 @@ func (controller *OIDCController) authorizeComplete(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Store the consent granted by the user for this client
|
||||
if _, err := controller.oidc.UpsertOIDCConsent(c, userContext.GetUsername(), authorizeReq.Scope, client.ClientID); err != nil {
|
||||
controller.log.App.Warn().Err(err).Msg("Failed to store OIDC consent")
|
||||
}
|
||||
|
||||
q := cu.Query()
|
||||
|
||||
q.Set("code", code)
|
||||
@@ -756,3 +784,20 @@ func (controller *OIDCController) resolveNormalParams(c *gin.Context) (*service.
|
||||
|
||||
return &req, nil
|
||||
}
|
||||
|
||||
// scopesGranted reports whether every scope in requested is present in the
|
||||
// space-separated granted scope string.
|
||||
func scopesGranted(granted, requested string) bool {
|
||||
grantedScopes := strings.Split(granted, " ")
|
||||
|
||||
for _, scope := range strings.Split(requested, " ") {
|
||||
if scope == "" {
|
||||
continue
|
||||
}
|
||||
if !slices.Contains(grantedScopes, scope) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -170,6 +170,102 @@ func TestOIDCController(t *testing.T) {
|
||||
assert.Contains(t, location, "oidc_name="+url.QueryEscape("Test Client"))
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Authorize skips the consent screen when all requested scopes were already granted",
|
||||
middlewares: []gin.HandlerFunc{authedUser},
|
||||
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
|
||||
_, err := store.UpsertOIDCConsent(ctx, repository.UpsertOIDCConsentParams{
|
||||
Username: "testuser", ClientID: "some-client-id",
|
||||
Scope: "openid profile", CreatedAt: time.Now().Unix(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
q := url.Values{}
|
||||
q.Set("scope", "openid profile")
|
||||
q.Set("response_type", "code")
|
||||
q.Set("client_id", "some-client-id")
|
||||
q.Set("redirect_uri", "https://test.example.com/callback")
|
||||
|
||||
req := httptest.NewRequest("GET", "/authorize?"+q.Encode(), nil)
|
||||
router.ServeHTTP(recorder, req)
|
||||
|
||||
assert.Equal(t, http.StatusFound, recorder.Code)
|
||||
location := recorder.Header().Get("Location")
|
||||
assert.True(t, strings.HasPrefix(location, oidcService.GetIssuer()+"/oidc/authorize?"))
|
||||
assert.Contains(t, location, "oidc_prompt=none")
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Authorize shows the consent screen when a new scope is requested",
|
||||
middlewares: []gin.HandlerFunc{authedUser},
|
||||
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
|
||||
_, err := store.UpsertOIDCConsent(ctx, repository.UpsertOIDCConsentParams{
|
||||
Username: "testuser", ClientID: "some-client-id",
|
||||
Scope: "openid", CreatedAt: time.Now().Unix(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
q := url.Values{}
|
||||
q.Set("scope", "openid profile")
|
||||
q.Set("response_type", "code")
|
||||
q.Set("client_id", "some-client-id")
|
||||
q.Set("redirect_uri", "https://test.example.com/callback")
|
||||
|
||||
req := httptest.NewRequest("GET", "/authorize?"+q.Encode(), nil)
|
||||
router.ServeHTTP(recorder, req)
|
||||
|
||||
assert.Equal(t, http.StatusFound, recorder.Code)
|
||||
location := recorder.Header().Get("Location")
|
||||
assert.True(t, strings.HasPrefix(location, oidcService.GetIssuer()+"/oidc/authorize?"))
|
||||
assert.NotContains(t, location, "oidc_prompt=none")
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Authorize skips the consent screen for a subset of already granted scopes",
|
||||
middlewares: []gin.HandlerFunc{authedUser},
|
||||
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
|
||||
_, err := store.UpsertOIDCConsent(ctx, repository.UpsertOIDCConsentParams{
|
||||
Username: "testuser", ClientID: "some-client-id",
|
||||
Scope: "openid profile email", CreatedAt: time.Now().Unix(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
q := url.Values{}
|
||||
q.Set("scope", "openid profile")
|
||||
q.Set("response_type", "code")
|
||||
q.Set("client_id", "some-client-id")
|
||||
q.Set("redirect_uri", "https://test.example.com/callback")
|
||||
|
||||
req := httptest.NewRequest("GET", "/authorize?"+q.Encode(), nil)
|
||||
router.ServeHTTP(recorder, req)
|
||||
|
||||
assert.Equal(t, http.StatusFound, recorder.Code)
|
||||
location := recorder.Header().Get("Location")
|
||||
assert.True(t, strings.HasPrefix(location, oidcService.GetIssuer()+"/oidc/authorize?"))
|
||||
assert.Contains(t, location, "oidc_prompt=none")
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Authorize shows the consent screen when no consent was granted yet",
|
||||
middlewares: []gin.HandlerFunc{authedUser},
|
||||
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
|
||||
require.NoError(t, store.DeleteOIDCConsentByClientID(ctx, "some-client-id"))
|
||||
|
||||
q := url.Values{}
|
||||
q.Set("scope", "openid profile")
|
||||
q.Set("response_type", "code")
|
||||
q.Set("client_id", "some-client-id")
|
||||
q.Set("redirect_uri", "https://test.example.com/callback")
|
||||
|
||||
req := httptest.NewRequest("GET", "/authorize?"+q.Encode(), nil)
|
||||
router.ServeHTTP(recorder, req)
|
||||
|
||||
assert.Equal(t, http.StatusFound, recorder.Code)
|
||||
location := recorder.Header().Get("Location")
|
||||
assert.True(t, strings.HasPrefix(location, oidcService.GetIssuer()+"/oidc/authorize?"))
|
||||
assert.NotContains(t, location, "oidc_prompt=none")
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Authorize redirects to error screen when the request object is invalid",
|
||||
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
|
||||
|
||||
@@ -47,7 +47,6 @@ type ProxyContext struct {
|
||||
Host string
|
||||
Proto string
|
||||
Path string
|
||||
PathRaw string
|
||||
Method string
|
||||
Type AuthModuleType
|
||||
IsBrowser bool
|
||||
@@ -282,7 +281,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
|
||||
}
|
||||
|
||||
queries, err := query.Values(RedirectQuery{
|
||||
RedirectURI: fmt.Sprintf("%s://%s%s", proxyCtx.Proto, proxyCtx.Host, proxyCtx.PathRaw),
|
||||
RedirectURI: fmt.Sprintf("%s://%s%s", proxyCtx.Proto, proxyCtx.Host, proxyCtx.Path),
|
||||
LoginFor: FrontendLoginForApp,
|
||||
})
|
||||
|
||||
@@ -403,11 +402,11 @@ func (controller *ProxyController) getForwardAuthContext(c *gin.Context) (ProxyC
|
||||
method := c.Request.Method
|
||||
|
||||
return ProxyContext{
|
||||
Host: host,
|
||||
Proto: proto,
|
||||
PathRaw: uri,
|
||||
Method: method,
|
||||
Type: ForwardAuth,
|
||||
Host: host,
|
||||
Proto: proto,
|
||||
Path: uri,
|
||||
Method: method,
|
||||
Type: ForwardAuth,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -436,14 +435,15 @@ func (controller *ProxyController) getAuthRequestContext(c *gin.Context) (ProxyC
|
||||
return ProxyContext{}, errors.New("proto not found")
|
||||
}
|
||||
|
||||
path := url.Path
|
||||
method := c.Request.Method
|
||||
|
||||
return ProxyContext{
|
||||
Host: host,
|
||||
Proto: proto,
|
||||
PathRaw: url.RequestURI(),
|
||||
Method: method,
|
||||
Type: AuthRequest,
|
||||
Host: host,
|
||||
Proto: proto,
|
||||
Path: path,
|
||||
Method: method,
|
||||
Type: AuthRequest,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -469,11 +469,11 @@ func (controller *ProxyController) getExtAuthzContext(c *gin.Context) (ProxyCont
|
||||
method := c.Request.Method
|
||||
|
||||
return ProxyContext{
|
||||
Host: host,
|
||||
Proto: proto,
|
||||
PathRaw: path,
|
||||
Method: method,
|
||||
Type: ExtAuthz,
|
||||
Host: host,
|
||||
Proto: proto,
|
||||
Path: path,
|
||||
Method: method,
|
||||
Type: ExtAuthz,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -552,8 +552,8 @@ func (controller *ProxyController) getProxyContext(c *gin.Context) (ProxyContext
|
||||
return ProxyContext{}, err
|
||||
}
|
||||
|
||||
// Parse the raw path to populate the cleaned path used for ACLs
|
||||
upath, err := url.Parse(ctx.PathRaw)
|
||||
// remove any query params from the request path
|
||||
upath, err := url.Parse(ctx.Path)
|
||||
|
||||
if err != nil {
|
||||
return ProxyContext{}, fmt.Errorf("failed to parse request path: %v", err)
|
||||
|
||||
@@ -95,38 +95,6 @@ func TestProxyController(t *testing.T) {
|
||||
assert.Contains(t, location, "https://tinyauth.example.com/login")
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Forward auth login redirect should preserve query parameters",
|
||||
middlewares: []gin.HandlerFunc{},
|
||||
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
|
||||
req := httptest.NewRequest("GET", "/api/auth/traefik", nil)
|
||||
req.Header.Set("x-forwarded-host", "test.example.com")
|
||||
req.Header.Set("x-forwarded-proto", "https")
|
||||
req.Header.Set("x-forwarded-uri", "/search?foo=bar")
|
||||
req.Header.Set("user-agent", browserUserAgent)
|
||||
router.ServeHTTP(recorder, req)
|
||||
|
||||
assert.Equal(t, http.StatusFound, recorder.Code)
|
||||
location := recorder.Header().Get("Location")
|
||||
assert.Contains(t, location, url.QueryEscape("https://test.example.com/search?foo=bar"))
|
||||
assert.Contains(t, location, "login_for=app")
|
||||
assert.Contains(t, location, "https://tinyauth.example.com/login")
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Auth request (nginx) login redirect should preserve query parameters",
|
||||
middlewares: []gin.HandlerFunc{},
|
||||
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
|
||||
req := httptest.NewRequest("GET", "/api/auth/nginx", nil)
|
||||
req.Header.Set("x-original-url", "https://test.example.com/search?foo=bar")
|
||||
router.ServeHTTP(recorder, req)
|
||||
assert.Equal(t, http.StatusUnauthorized, recorder.Code)
|
||||
location := recorder.Header().Get("x-tinyauth-location")
|
||||
assert.Contains(t, location, url.QueryEscape("https://test.example.com/search?foo=bar"))
|
||||
assert.Contains(t, location, "login_for=app")
|
||||
assert.Contains(t, location, "https://tinyauth.example.com/login")
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Auth request (nginx) should be detected and used",
|
||||
middlewares: []gin.HandlerFunc{},
|
||||
@@ -158,22 +126,6 @@ func TestProxyController(t *testing.T) {
|
||||
assert.Contains(t, location, "https://tinyauth.example.com/login")
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Ext authz (envoy) login redirect should preserve query parameters",
|
||||
middlewares: []gin.HandlerFunc{},
|
||||
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
|
||||
req := httptest.NewRequest("HEAD", "/api/auth/envoy?path=%2Fhello%3Ffoo%3Dbar", nil)
|
||||
req.Host = "test.example.com"
|
||||
req.Header.Set("x-forwarded-proto", "https")
|
||||
req.Header.Set("user-agent", browserUserAgent)
|
||||
router.ServeHTTP(recorder, req)
|
||||
assert.Equal(t, http.StatusFound, recorder.Code)
|
||||
location := recorder.Header().Get("Location")
|
||||
assert.Contains(t, location, url.QueryEscape("https://test.example.com/hello?foo=bar"))
|
||||
assert.Contains(t, location, "login_for=app")
|
||||
assert.Contains(t, location, "https://tinyauth.example.com/login")
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Forward auth with caddy should be detected and used",
|
||||
middlewares: []gin.HandlerFunc{},
|
||||
|
||||
@@ -20,8 +20,6 @@ var OverrideProviders = map[string]string{
|
||||
var ReservedProviderNames = []string{"local", "ldap", "tailscale"}
|
||||
|
||||
const SessionCookieName = "tinyauth-session"
|
||||
const CSRFCookieName = "tinyauth-csrf"
|
||||
const RedirectCookieName = "tinyauth-redirect"
|
||||
const OAuthSessionCookieName = "tinyauth-oauth"
|
||||
|
||||
const GracefulShutdownTimeout = 5 // seconds
|
||||
|
||||
@@ -5,8 +5,6 @@ type RuntimeConfig struct {
|
||||
UUID string
|
||||
CookieDomain string
|
||||
SessionCookieName string
|
||||
CSRFCookieName string
|
||||
RedirectCookieName string
|
||||
OAuthSessionCookieName string
|
||||
LocalUsers []LocalUser
|
||||
OAuthProviders map[string]OAuthServiceConfig
|
||||
|
||||
@@ -277,6 +277,80 @@ func TestMemoryStore(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Upsert creates a consent for each user+client pair",
|
||||
run: func(t *testing.T, s repository.Store) {
|
||||
_, err := s.UpsertOIDCConsent(ctx, repository.UpsertOIDCConsentParams{
|
||||
Username: "alice", ClientID: "client-a", Scope: "openid profile", CreatedAt: 1,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, err = s.UpsertOIDCConsent(ctx, repository.UpsertOIDCConsentParams{
|
||||
Username: "alice", ClientID: "client-b", Scope: "openid email", CreatedAt: 2,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
consents, err := s.ListOIDCConsents(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, consents, 2)
|
||||
|
||||
gotA, err := s.GetOIDCConsentByUsernameAndClientID(ctx, repository.GetOIDCConsentByUsernameAndClientIDParams{Username: "alice", ClientID: "client-a"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "openid profile", gotA.Scope)
|
||||
|
||||
gotB, err := s.GetOIDCConsentByUsernameAndClientID(ctx, repository.GetOIDCConsentByUsernameAndClientIDParams{Username: "alice", ClientID: "client-b"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "openid email", gotB.Scope)
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Upsert overwrites the same consent row",
|
||||
run: func(t *testing.T, s repository.Store) {
|
||||
_, err := s.UpsertOIDCConsent(ctx, repository.UpsertOIDCConsentParams{
|
||||
Username: "alice", ClientID: "client-a", Scope: "openid", CreatedAt: 1,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = s.UpsertOIDCConsent(ctx, repository.UpsertOIDCConsentParams{
|
||||
Username: "alice", ClientID: "client-a", Scope: "openid email", CreatedAt: 2,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
consents, err := s.ListOIDCConsents(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, consents, 1)
|
||||
|
||||
got, err := s.GetOIDCConsentByUsernameAndClientID(ctx, repository.GetOIDCConsentByUsernameAndClientIDParams{Username: "alice", ClientID: "client-a"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "openid email", got.Scope)
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Get consent by username and client not found",
|
||||
run: func(t *testing.T, s repository.Store) {
|
||||
_, err := s.GetOIDCConsentByUsernameAndClientID(ctx, repository.GetOIDCConsentByUsernameAndClientIDParams{Username: "alice", ClientID: "client-a"})
|
||||
assert.ErrorIs(t, err, repository.ErrNotFound)
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Delete consent by client id",
|
||||
run: func(t *testing.T, s repository.Store) {
|
||||
_, err := s.UpsertOIDCConsent(ctx, repository.UpsertOIDCConsentParams{
|
||||
Username: "alice", ClientID: "client-a", Scope: "openid", CreatedAt: 1,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, err = s.UpsertOIDCConsent(ctx, repository.UpsertOIDCConsentParams{
|
||||
Username: "alice", ClientID: "client-b", Scope: "openid", CreatedAt: 2,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, s.DeleteOIDCConsentByClientID(ctx, "client-a"))
|
||||
|
||||
consents, err := s.ListOIDCConsents(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, consents, 1)
|
||||
assert.Equal(t, "client-b", consents[0].ClientID)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
|
||||
@@ -94,3 +94,46 @@ func (s *Store) DeleteExpiredOIDCSessions(_ context.Context, arg repository.Dele
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func consentKey(username, clientID string) string {
|
||||
return username + "\x00" + clientID
|
||||
}
|
||||
|
||||
func (s *Store) UpsertOIDCConsent(_ context.Context, arg repository.UpsertOIDCConsentParams) (repository.OidcConsent, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
oc := repository.OidcConsent(arg)
|
||||
s.oidcConsents[consentKey(arg.Username, arg.ClientID)] = oc
|
||||
return oc, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetOIDCConsentByUsernameAndClientID(_ context.Context, arg repository.GetOIDCConsentByUsernameAndClientIDParams) (repository.OidcConsent, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
oc, ok := s.oidcConsents[consentKey(arg.Username, arg.ClientID)]
|
||||
if !ok {
|
||||
return repository.OidcConsent{}, repository.ErrNotFound
|
||||
}
|
||||
return oc, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteOIDCConsentByClientID(_ context.Context, clientID string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for key, oc := range s.oidcConsents {
|
||||
if oc.ClientID == clientID {
|
||||
delete(s.oidcConsents, key)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) ListOIDCConsents(_ context.Context) ([]repository.OidcConsent, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]repository.OidcConsent, 0, len(s.oidcConsents))
|
||||
for _, oc := range s.oidcConsents {
|
||||
out = append(out, oc)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ type Store struct {
|
||||
mu sync.RWMutex
|
||||
sessions map[string]repository.Session
|
||||
oidcSessions map[string]repository.OidcSession
|
||||
oidcConsents map[string]repository.OidcConsent
|
||||
}
|
||||
|
||||
// New returns a new empty in-memory Store.
|
||||
@@ -19,5 +20,6 @@ func New() repository.Store {
|
||||
return &Store{
|
||||
sessions: make(map[string]repository.Session),
|
||||
oidcSessions: make(map[string]repository.OidcSession),
|
||||
oidcConsents: make(map[string]repository.OidcConsent),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,3 +84,22 @@ type DeleteExpiredOIDCSessionsParams struct {
|
||||
TokenExpiresAt int64
|
||||
RefreshTokenExpiresAt int64
|
||||
}
|
||||
|
||||
type OidcConsent struct {
|
||||
Username string
|
||||
ClientID string
|
||||
Scope string
|
||||
CreatedAt int64
|
||||
}
|
||||
|
||||
type UpsertOIDCConsentParams struct {
|
||||
Username string
|
||||
ClientID string
|
||||
Scope string
|
||||
CreatedAt int64
|
||||
}
|
||||
|
||||
type GetOIDCConsentByUsernameAndClientIDParams struct {
|
||||
Username string
|
||||
ClientID string
|
||||
}
|
||||
|
||||
@@ -4,6 +4,13 @@
|
||||
|
||||
package postgres
|
||||
|
||||
type OidcConsent struct {
|
||||
Username string
|
||||
ClientID string
|
||||
Scope string
|
||||
CreatedAt int64
|
||||
}
|
||||
|
||||
type OidcSession struct {
|
||||
Sub string
|
||||
AccessTokenHash string
|
||||
|
||||
@@ -80,6 +80,16 @@ func (q *Queries) DeleteExpiredOIDCSessions(ctx context.Context, arg DeleteExpir
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteOIDCConsentByClientID = `-- name: DeleteOIDCConsentByClientID :exec
|
||||
DELETE FROM "oidc_consents"
|
||||
WHERE "client_id" = $1
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteOIDCConsentByClientID(ctx context.Context, clientID string) error {
|
||||
_, err := q.db.ExecContext(ctx, deleteOIDCConsentByClientID, clientID)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteOIDCSessionBySub = `-- name: DeleteOIDCSessionBySub :exec
|
||||
DELETE FROM "oidc_sessions"
|
||||
WHERE "sub" = $1
|
||||
@@ -90,6 +100,28 @@ func (q *Queries) DeleteOIDCSessionBySub(ctx context.Context, sub string) error
|
||||
return err
|
||||
}
|
||||
|
||||
const getOIDCConsentByUsernameAndClientID = `-- name: GetOIDCConsentByUsernameAndClientID :one
|
||||
SELECT username, client_id, scope, created_at FROM "oidc_consents"
|
||||
WHERE "username" = $1 AND "client_id" = $2
|
||||
`
|
||||
|
||||
type GetOIDCConsentByUsernameAndClientIDParams struct {
|
||||
Username string
|
||||
ClientID string
|
||||
}
|
||||
|
||||
func (q *Queries) GetOIDCConsentByUsernameAndClientID(ctx context.Context, arg GetOIDCConsentByUsernameAndClientIDParams) (OidcConsent, error) {
|
||||
row := q.db.QueryRowContext(ctx, getOIDCConsentByUsernameAndClientID, arg.Username, arg.ClientID)
|
||||
var i OidcConsent
|
||||
err := row.Scan(
|
||||
&i.Username,
|
||||
&i.ClientID,
|
||||
&i.Scope,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
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"
|
||||
WHERE "access_token_hash" = $1
|
||||
@@ -156,6 +188,38 @@ func (q *Queries) GetOIDCSessionBySub(ctx context.Context, sub string) (OidcSess
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listOIDCConsents = `-- name: ListOIDCConsents :many
|
||||
SELECT username, client_id, scope, created_at FROM "oidc_consents"
|
||||
`
|
||||
|
||||
func (q *Queries) ListOIDCConsents(ctx context.Context) ([]OidcConsent, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listOIDCConsents)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []OidcConsent
|
||||
for rows.Next() {
|
||||
var i OidcConsent
|
||||
if err := rows.Scan(
|
||||
&i.Username,
|
||||
&i.ClientID,
|
||||
&i.Scope,
|
||||
&i.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const updateOIDCSession = `-- name: UpdateOIDCSession :one
|
||||
UPDATE "oidc_sessions" SET
|
||||
"access_token_hash" = $1,
|
||||
@@ -208,3 +272,43 @@ func (q *Queries) UpdateOIDCSession(ctx context.Context, arg UpdateOIDCSessionPa
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const upsertOIDCConsent = `-- name: UpsertOIDCConsent :one
|
||||
INSERT INTO "oidc_consents" (
|
||||
"username",
|
||||
"client_id",
|
||||
"scope",
|
||||
"created_at"
|
||||
) VALUES (
|
||||
$1, $2, $3, $4
|
||||
)
|
||||
ON CONFLICT ("username", "client_id")
|
||||
DO UPDATE SET
|
||||
"scope" = excluded.scope,
|
||||
"created_at" = excluded.created_at
|
||||
RETURNING username, client_id, scope, created_at
|
||||
`
|
||||
|
||||
type UpsertOIDCConsentParams struct {
|
||||
Username string
|
||||
ClientID string
|
||||
Scope string
|
||||
CreatedAt int64
|
||||
}
|
||||
|
||||
func (q *Queries) UpsertOIDCConsent(ctx context.Context, arg UpsertOIDCConsentParams) (OidcConsent, error) {
|
||||
row := q.db.QueryRowContext(ctx, upsertOIDCConsent,
|
||||
arg.Username,
|
||||
arg.ClientID,
|
||||
arg.Scope,
|
||||
arg.CreatedAt,
|
||||
)
|
||||
var i OidcConsent
|
||||
err := row.Scan(
|
||||
&i.Username,
|
||||
&i.ClientID,
|
||||
&i.Scope,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
@@ -56,6 +56,10 @@ func (s *Store) DeleteExpiredSessions(ctx context.Context, expiry int64) error {
|
||||
return mapErr(s.q.DeleteExpiredSessions(ctx, expiry))
|
||||
}
|
||||
|
||||
func (s *Store) DeleteOIDCConsentByClientID(ctx context.Context, clientID string) error {
|
||||
return mapErr(s.q.DeleteOIDCConsentByClientID(ctx, clientID))
|
||||
}
|
||||
|
||||
func (s *Store) DeleteOIDCSessionBySub(ctx context.Context, sub string) error {
|
||||
return mapErr(s.q.DeleteOIDCSessionBySub(ctx, sub))
|
||||
}
|
||||
@@ -64,6 +68,14 @@ func (s *Store) DeleteSession(ctx context.Context, uuid string) error {
|
||||
return mapErr(s.q.DeleteSession(ctx, uuid))
|
||||
}
|
||||
|
||||
func (s *Store) GetOIDCConsentByUsernameAndClientID(ctx context.Context, arg repository.GetOIDCConsentByUsernameAndClientIDParams) (repository.OidcConsent, error) {
|
||||
r, err := s.q.GetOIDCConsentByUsernameAndClientID(ctx, GetOIDCConsentByUsernameAndClientIDParams(arg))
|
||||
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) {
|
||||
r, err := s.q.GetOIDCSessionByAccessTokenHash(ctx, accessTokenHash)
|
||||
if err != nil {
|
||||
@@ -96,6 +108,18 @@ func (s *Store) GetSession(ctx context.Context, uuid string) (repository.Session
|
||||
return repository.Session(r), nil
|
||||
}
|
||||
|
||||
func (s *Store) ListOIDCConsents(ctx context.Context) ([]repository.OidcConsent, error) {
|
||||
rows, err := s.q.ListOIDCConsents(ctx)
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
out := make([]repository.OidcConsent, len(rows))
|
||||
for i, row := range rows {
|
||||
out[i] = repository.OidcConsent(row)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateOIDCSession(ctx context.Context, arg repository.UpdateOIDCSessionParams) (repository.OidcSession, error) {
|
||||
r, err := s.q.UpdateOIDCSession(ctx, UpdateOIDCSessionParams(arg))
|
||||
if err != nil {
|
||||
@@ -111,3 +135,11 @@ func (s *Store) UpdateSession(ctx context.Context, arg repository.UpdateSessionP
|
||||
}
|
||||
return repository.Session(r), nil
|
||||
}
|
||||
|
||||
func (s *Store) UpsertOIDCConsent(ctx context.Context, arg repository.UpsertOIDCConsentParams) (repository.OidcConsent, error) {
|
||||
r, err := s.q.UpsertOIDCConsent(ctx, UpsertOIDCConsentParams(arg))
|
||||
if err != nil {
|
||||
return repository.OidcConsent{}, mapErr(err)
|
||||
}
|
||||
return repository.OidcConsent(r), nil
|
||||
}
|
||||
|
||||
@@ -4,6 +4,13 @@
|
||||
|
||||
package sqlite
|
||||
|
||||
type OidcConsent struct {
|
||||
Username string
|
||||
ClientID string
|
||||
Scope string
|
||||
CreatedAt int64
|
||||
}
|
||||
|
||||
type OidcSession struct {
|
||||
Sub string
|
||||
AccessTokenHash string
|
||||
|
||||
@@ -80,6 +80,16 @@ func (q *Queries) DeleteExpiredOIDCSessions(ctx context.Context, arg DeleteExpir
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteOIDCConsentByClientID = `-- name: DeleteOIDCConsentByClientID :exec
|
||||
DELETE FROM "oidc_consents"
|
||||
WHERE "client_id" = ?
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteOIDCConsentByClientID(ctx context.Context, clientID string) error {
|
||||
_, err := q.db.ExecContext(ctx, deleteOIDCConsentByClientID, clientID)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteOIDCSessionBySub = `-- name: DeleteOIDCSessionBySub :exec
|
||||
DELETE FROM "oidc_sessions"
|
||||
WHERE "sub" = ?
|
||||
@@ -90,6 +100,28 @@ func (q *Queries) DeleteOIDCSessionBySub(ctx context.Context, sub string) error
|
||||
return err
|
||||
}
|
||||
|
||||
const getOIDCConsentByUsernameAndClientID = `-- name: GetOIDCConsentByUsernameAndClientID :one
|
||||
SELECT username, client_id, scope, created_at FROM "oidc_consents"
|
||||
WHERE "username" = ? AND "client_id" = ?
|
||||
`
|
||||
|
||||
type GetOIDCConsentByUsernameAndClientIDParams struct {
|
||||
Username string
|
||||
ClientID string
|
||||
}
|
||||
|
||||
func (q *Queries) GetOIDCConsentByUsernameAndClientID(ctx context.Context, arg GetOIDCConsentByUsernameAndClientIDParams) (OidcConsent, error) {
|
||||
row := q.db.QueryRowContext(ctx, getOIDCConsentByUsernameAndClientID, arg.Username, arg.ClientID)
|
||||
var i OidcConsent
|
||||
err := row.Scan(
|
||||
&i.Username,
|
||||
&i.ClientID,
|
||||
&i.Scope,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
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"
|
||||
WHERE "access_token_hash" = ?
|
||||
@@ -156,6 +188,38 @@ func (q *Queries) GetOIDCSessionBySub(ctx context.Context, sub string) (OidcSess
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listOIDCConsents = `-- name: ListOIDCConsents :many
|
||||
SELECT username, client_id, scope, created_at FROM "oidc_consents"
|
||||
`
|
||||
|
||||
func (q *Queries) ListOIDCConsents(ctx context.Context) ([]OidcConsent, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listOIDCConsents)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []OidcConsent
|
||||
for rows.Next() {
|
||||
var i OidcConsent
|
||||
if err := rows.Scan(
|
||||
&i.Username,
|
||||
&i.ClientID,
|
||||
&i.Scope,
|
||||
&i.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const updateOIDCSession = `-- name: UpdateOIDCSession :one
|
||||
UPDATE "oidc_sessions" SET
|
||||
"access_token_hash" = ?,
|
||||
@@ -208,3 +272,43 @@ func (q *Queries) UpdateOIDCSession(ctx context.Context, arg UpdateOIDCSessionPa
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const upsertOIDCConsent = `-- name: UpsertOIDCConsent :one
|
||||
INSERT INTO "oidc_consents" (
|
||||
"username",
|
||||
"client_id",
|
||||
"scope",
|
||||
"created_at"
|
||||
) VALUES (
|
||||
?, ?, ?, ?
|
||||
)
|
||||
ON CONFLICT ("username", "client_id")
|
||||
DO UPDATE SET
|
||||
"scope" = excluded.scope,
|
||||
"created_at" = excluded.created_at
|
||||
RETURNING username, client_id, scope, created_at
|
||||
`
|
||||
|
||||
type UpsertOIDCConsentParams struct {
|
||||
Username string
|
||||
ClientID string
|
||||
Scope string
|
||||
CreatedAt int64
|
||||
}
|
||||
|
||||
func (q *Queries) UpsertOIDCConsent(ctx context.Context, arg UpsertOIDCConsentParams) (OidcConsent, error) {
|
||||
row := q.db.QueryRowContext(ctx, upsertOIDCConsent,
|
||||
arg.Username,
|
||||
arg.ClientID,
|
||||
arg.Scope,
|
||||
arg.CreatedAt,
|
||||
)
|
||||
var i OidcConsent
|
||||
err := row.Scan(
|
||||
&i.Username,
|
||||
&i.ClientID,
|
||||
&i.Scope,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
@@ -56,6 +56,10 @@ func (s *Store) DeleteExpiredSessions(ctx context.Context, expiry int64) error {
|
||||
return mapErr(s.q.DeleteExpiredSessions(ctx, expiry))
|
||||
}
|
||||
|
||||
func (s *Store) DeleteOIDCConsentByClientID(ctx context.Context, clientID string) error {
|
||||
return mapErr(s.q.DeleteOIDCConsentByClientID(ctx, clientID))
|
||||
}
|
||||
|
||||
func (s *Store) DeleteOIDCSessionBySub(ctx context.Context, sub string) error {
|
||||
return mapErr(s.q.DeleteOIDCSessionBySub(ctx, sub))
|
||||
}
|
||||
@@ -64,6 +68,14 @@ func (s *Store) DeleteSession(ctx context.Context, uuid string) error {
|
||||
return mapErr(s.q.DeleteSession(ctx, uuid))
|
||||
}
|
||||
|
||||
func (s *Store) GetOIDCConsentByUsernameAndClientID(ctx context.Context, arg repository.GetOIDCConsentByUsernameAndClientIDParams) (repository.OidcConsent, error) {
|
||||
r, err := s.q.GetOIDCConsentByUsernameAndClientID(ctx, GetOIDCConsentByUsernameAndClientIDParams(arg))
|
||||
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) {
|
||||
r, err := s.q.GetOIDCSessionByAccessTokenHash(ctx, accessTokenHash)
|
||||
if err != nil {
|
||||
@@ -96,6 +108,18 @@ func (s *Store) GetSession(ctx context.Context, uuid string) (repository.Session
|
||||
return repository.Session(r), nil
|
||||
}
|
||||
|
||||
func (s *Store) ListOIDCConsents(ctx context.Context) ([]repository.OidcConsent, error) {
|
||||
rows, err := s.q.ListOIDCConsents(ctx)
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
out := make([]repository.OidcConsent, len(rows))
|
||||
for i, row := range rows {
|
||||
out[i] = repository.OidcConsent(row)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateOIDCSession(ctx context.Context, arg repository.UpdateOIDCSessionParams) (repository.OidcSession, error) {
|
||||
r, err := s.q.UpdateOIDCSession(ctx, UpdateOIDCSessionParams(arg))
|
||||
if err != nil {
|
||||
@@ -111,3 +135,11 @@ func (s *Store) UpdateSession(ctx context.Context, arg repository.UpdateSessionP
|
||||
}
|
||||
return repository.Session(r), nil
|
||||
}
|
||||
|
||||
func (s *Store) UpsertOIDCConsent(ctx context.Context, arg repository.UpsertOIDCConsentParams) (repository.OidcConsent, error) {
|
||||
r, err := s.q.UpsertOIDCConsent(ctx, UpsertOIDCConsentParams(arg))
|
||||
if err != nil {
|
||||
return repository.OidcConsent{}, mapErr(err)
|
||||
}
|
||||
return repository.OidcConsent(r), nil
|
||||
}
|
||||
|
||||
@@ -27,4 +27,10 @@ type Store interface {
|
||||
GetOIDCSessionByRefreshTokenHash(ctx context.Context, refreshTokenHash string) (OidcSession, error)
|
||||
GetOIDCSessionBySub(ctx context.Context, sub string) (OidcSession, error)
|
||||
UpdateOIDCSession(ctx context.Context, arg UpdateOIDCSessionParams) (OidcSession, error)
|
||||
|
||||
// OIDC Consents
|
||||
UpsertOIDCConsent(ctx context.Context, arg UpsertOIDCConsentParams) (OidcConsent, error)
|
||||
GetOIDCConsentByUsernameAndClientID(ctx context.Context, arg GetOIDCConsentByUsernameAndClientIDParams) (OidcConsent, error)
|
||||
DeleteOIDCConsentByClientID(ctx context.Context, clientID string) error
|
||||
ListOIDCConsents(ctx context.Context) ([]OidcConsent, error)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"slices"
|
||||
@@ -163,6 +164,10 @@ type OIDCService struct {
|
||||
usedCode *cache.CacheStore[UsedCodeEntry]
|
||||
authorize *cache.CacheStore[AuthorizeRequest]
|
||||
}
|
||||
|
||||
mus struct {
|
||||
consent sync.RWMutex
|
||||
}
|
||||
}
|
||||
|
||||
type OIDCServiceInput struct {
|
||||
@@ -336,6 +341,11 @@ func NewOIDCService(i OIDCServiceInput) (*OIDCService, error) {
|
||||
issuer: issuer,
|
||||
}
|
||||
|
||||
// Remove consents for clients that are no longer configured
|
||||
if err := service.reconcileOIDCConsents(context.Background()); err != nil {
|
||||
i.Log.App.Warn().Err(err).Msg("Failed to reconcile OIDC consents")
|
||||
}
|
||||
|
||||
// Start cleanup routine
|
||||
i.Ding.Go(service.cleanupRoutine, ding.RingMinor)
|
||||
|
||||
@@ -920,7 +930,7 @@ func (service *OIDCService) DeleteAuthorizeRequestTicket(ticket string) {
|
||||
service.caches.authorize.Delete(ticket)
|
||||
}
|
||||
|
||||
// TODO: support signed request objects in the future
|
||||
// DecodeAuthorizeJWT TODO: support signed request objects in the future
|
||||
func (service *OIDCService) DecodeAuthorizeJWT(tokenString string) (*AuthorizeRequest, error) {
|
||||
var claims jwt.MapClaims
|
||||
|
||||
@@ -970,3 +980,114 @@ func (service *OIDCService) GetPrompt(prompt string) []OIDCPrompt {
|
||||
|
||||
return parsedPromps
|
||||
}
|
||||
|
||||
func (service *OIDCService) getOIDCConsentUnsafe(ctx context.Context, username, clientId string) (*repository.OidcConsent, error) {
|
||||
entry, err := service.queries.GetOIDCConsentByUsernameAndClientID(ctx, repository.GetOIDCConsentByUsernameAndClientIDParams{
|
||||
Username: username,
|
||||
ClientID: clientId,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get oidc consent: %w", err)
|
||||
}
|
||||
|
||||
return &entry, nil
|
||||
}
|
||||
|
||||
func (service *OIDCService) GetOIDCConsent(ctx context.Context, username, clientId string) (*repository.OidcConsent, error) {
|
||||
service.mus.consent.RLock()
|
||||
defer service.mus.consent.RUnlock()
|
||||
return service.getOIDCConsentUnsafe(ctx, username, clientId)
|
||||
}
|
||||
|
||||
func (service *OIDCService) UpsertOIDCConsent(ctx context.Context, username, scope, clientId string) (repository.OidcConsent, error) {
|
||||
service.mus.consent.Lock()
|
||||
defer service.mus.consent.Unlock()
|
||||
|
||||
existing, err := service.getOIDCConsentUnsafe(ctx, username, clientId)
|
||||
|
||||
if err != nil {
|
||||
return repository.OidcConsent{}, err
|
||||
}
|
||||
|
||||
merged := scope
|
||||
|
||||
if existing != nil {
|
||||
merged = mergeScopes(existing.Scope, scope)
|
||||
}
|
||||
|
||||
entry := repository.UpsertOIDCConsentParams{
|
||||
Username: username,
|
||||
Scope: merged,
|
||||
ClientID: clientId,
|
||||
CreatedAt: time.Now().Unix(),
|
||||
}
|
||||
|
||||
consent, err := service.queries.UpsertOIDCConsent(ctx, entry)
|
||||
|
||||
if err != nil {
|
||||
service.log.App.Error().Err(err).Msg("Failed to upsert OIDC consent")
|
||||
return repository.OidcConsent{}, err
|
||||
}
|
||||
|
||||
return consent, nil
|
||||
}
|
||||
|
||||
func (service *OIDCService) reconcileOIDCConsents(ctx context.Context) error {
|
||||
consents, err := service.queries.ListOIDCConsents(ctx)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list oidc consents: %w", err)
|
||||
}
|
||||
|
||||
cleaned := make(map[string]struct{})
|
||||
|
||||
for _, consent := range consents {
|
||||
if _, ok := cleaned[consent.ClientID]; ok {
|
||||
continue
|
||||
}
|
||||
|
||||
cleaned[consent.ClientID] = struct{}{}
|
||||
|
||||
if _, ok := service.clients[consent.ClientID]; ok {
|
||||
continue
|
||||
}
|
||||
|
||||
service.log.App.Info().Str("clientId", consent.ClientID).Msg("Removed OIDC client no longer in configuration, deleting its consents")
|
||||
|
||||
if err := service.queries.DeleteOIDCConsentByClientID(ctx, consent.ClientID); err != nil {
|
||||
service.log.App.Warn().Err(err).Str("clientId", consent.ClientID).Msg("Failed to delete OIDC consents for removed client")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func mergeScopes(existing, requested string) string {
|
||||
set := make(map[string]struct{})
|
||||
|
||||
for _, scope := range strings.Split(existing, " ") {
|
||||
if scope != "" {
|
||||
set[scope] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
for _, scope := range strings.Split(requested, " ") {
|
||||
if scope != "" {
|
||||
set[scope] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
scopes := make([]string, 0, len(set))
|
||||
|
||||
for scope := range set {
|
||||
scopes = append(scopes, scope)
|
||||
}
|
||||
|
||||
slices.Sort(scopes)
|
||||
|
||||
return strings.Join(scopes, " ")
|
||||
}
|
||||
|
||||
@@ -46,3 +46,29 @@ UPDATE "oidc_sessions" SET
|
||||
"userinfo_json" = $8
|
||||
WHERE "sub" = $9
|
||||
RETURNING *;
|
||||
|
||||
-- name: UpsertOIDCConsent :one
|
||||
INSERT INTO "oidc_consents" (
|
||||
"username",
|
||||
"client_id",
|
||||
"scope",
|
||||
"created_at"
|
||||
) VALUES (
|
||||
$1, $2, $3, $4
|
||||
)
|
||||
ON CONFLICT ("username", "client_id")
|
||||
DO UPDATE SET
|
||||
"scope" = excluded.scope,
|
||||
"created_at" = excluded.created_at
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetOIDCConsentByUsernameAndClientID :one
|
||||
SELECT * FROM "oidc_consents"
|
||||
WHERE "username" = $1 AND "client_id" = $2;
|
||||
|
||||
-- name: DeleteOIDCConsentByClientID :exec
|
||||
DELETE FROM "oidc_consents"
|
||||
WHERE "client_id" = $1;
|
||||
|
||||
-- name: ListOIDCConsents :many
|
||||
SELECT * FROM "oidc_consents";
|
||||
|
||||
@@ -9,3 +9,12 @@ CREATE TABLE IF NOT EXISTS "oidc_sessions" (
|
||||
"nonce" TEXT NOT NULL DEFAULT '',
|
||||
"userinfo_json" TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "oidc_consents" (
|
||||
"username" TEXT NOT NULL,
|
||||
"client_id" TEXT NOT NULL,
|
||||
"scope" TEXT NOT NULL,
|
||||
"created_at" BIGINT NOT NULL,
|
||||
PRIMARY KEY ("username", "client_id")
|
||||
);
|
||||
|
||||
|
||||
@@ -46,3 +46,29 @@ UPDATE "oidc_sessions" SET
|
||||
"userinfo_json" = ?
|
||||
WHERE "sub" = ?
|
||||
RETURNING *;
|
||||
|
||||
-- name: UpsertOIDCConsent :one
|
||||
INSERT INTO "oidc_consents" (
|
||||
"username",
|
||||
"client_id",
|
||||
"scope",
|
||||
"created_at"
|
||||
) VALUES (
|
||||
?, ?, ?, ?
|
||||
)
|
||||
ON CONFLICT ("username", "client_id")
|
||||
DO UPDATE SET
|
||||
"scope" = excluded.scope,
|
||||
"created_at" = excluded.created_at
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetOIDCConsentByUsernameAndClientID :one
|
||||
SELECT * FROM "oidc_consents"
|
||||
WHERE "username" = ? AND "client_id" = ?;
|
||||
|
||||
-- name: DeleteOIDCConsentByClientID :exec
|
||||
DELETE FROM "oidc_consents"
|
||||
WHERE "client_id" = ?;
|
||||
|
||||
-- name: ListOIDCConsents :many
|
||||
SELECT * FROM "oidc_consents";
|
||||
|
||||
@@ -9,3 +9,11 @@ CREATE TABLE IF NOT EXISTS "oidc_sessions" (
|
||||
"nonce" TEXT NOT NULL DEFAULT "",
|
||||
"userinfo_json" TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "oidc_consents" (
|
||||
"username" TEXT NOT NULL,
|
||||
"client_id" TEXT NOT NULL,
|
||||
"scope" TEXT NOT NULL,
|
||||
"created_at" INTEGER NOT NULL,
|
||||
PRIMARY KEY ("username", "client_id")
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user