Compare commits
263 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9be6275047 | |||
| 231e864b41 | |||
| f6a58358c7 | |||
| 5ca6b9f528 | |||
| 9945b98c6d | |||
| 8e4b6598fd | |||
| 581e325b7f | |||
|
0d7be1c47e
|
|||
| 6c52b7b084 | |||
| 58c0e1f9d1 | |||
| 34e19d6f74 | |||
| edef584a85 | |||
| 28135f06d6 | |||
| 5079c57a05 | |||
| 241309537a | |||
|
bcbe17f010
|
|||
| 0bf64d97bf | |||
|
941b2b4158
|
|||
| 6533f064d5 | |||
| 64e0405e68 | |||
| ec3ea75db5 | |||
| ce6c0e3d93 | |||
| a93952408b | |||
|
0db830b3b1
|
|||
| 64cfb98016 | |||
| 222b5aaafb | |||
| 773c6acc1b | |||
| be19c98a02 | |||
| 4b04ca638f | |||
| f6171bb2c1 | |||
| 84f3ce58b4 | |||
|
058c818246
|
|||
| c951b8b2a6 | |||
| 20f643451b | |||
| 7dc063d57e | |||
| 0789e3e3fb | |||
| 0e85cfff29 | |||
|
9992fb4ef1
|
|||
| 96453e1d15 | |||
| fd4f9c4052 | |||
| ce5fd95bed | |||
|
972cf3ba45
|
|||
| 9d410abe54 | |||
| e740676de2 | |||
| acb0568725 | |||
|
6558d027ca
|
|||
| 17bad3a988 | |||
| d50387feb1 | |||
| 083125a5bb | |||
| 66b4fb90cd | |||
| 617812a044 | |||
| cc3a402fe4 | |||
| fc6882624f | |||
| 3825ee0656 | |||
| 237fa7e381 | |||
| b953439d0f | |||
| a74bd86fe2 | |||
| f52ccf28bc | |||
| de61dda74e | |||
| eb8bb78766 | |||
| f3ff720eb6 | |||
| b5fde43240 | |||
| 3bca5a318e | |||
| 22dbf00182 | |||
| d1af6008fd | |||
| bd54fd68f8 | |||
| 75035ae716 | |||
| 933dd886ff | |||
| ad85ce8347 | |||
| 361a2d23bc | |||
| 1b656ce2f9 | |||
| eba28529a6 | |||
| e8155bfd0a | |||
| 7b8dfb2cff | |||
| a5f091efa6 | |||
| df48b03d01 | |||
| 98e8a2abb6 | |||
| 854aef9b8d | |||
| 3f2e1b588f | |||
| f573a3a60c | |||
| ee0e44671c | |||
| 75e5abe166 | |||
| 3c5127d3c0 | |||
| 3aef5e33fa | |||
| e2b0c6422e | |||
| 7f9320dff2 | |||
| bfa14ce1bd | |||
| 950a706bb6 | |||
| 7e7828777d | |||
| 1f6c92b497 | |||
| b6c2971563 | |||
|
6d0327b01d
|
|||
| 46efd38035 | |||
| bdb9fbd405 | |||
| 68851ed10f | |||
| f20913a0ba | |||
| 338528f219 | |||
| 172174bd3e | |||
| e399262daa | |||
| d2b3c8ee17 | |||
|
d146ab76d8
|
|||
| a986a5f261 | |||
| f02427b128 | |||
| ecab345fc4 | |||
| d6c2b9dc78 | |||
| 217e131d0b | |||
| bf28e3955b | |||
| 8648217bcc | |||
| f8834133b7 | |||
| 205fce3829 | |||
|
b42f77f58d
|
|||
| e25bb11dc5 | |||
| 77c59422dc | |||
| 96ff4d45b3 | |||
| b5bf44c687 | |||
| e4df8438cf | |||
|
a9fe91bd84
|
|||
| b0c639b509 | |||
| 03429f2421 | |||
| 7b3e192cf0 | |||
| 8f098d60bd | |||
|
fa022bb9e0
|
|||
| 004cfc400a | |||
| d8f853f544 | |||
| 382f04534f | |||
| 57e5c08c79 | |||
| afbda1fe24 | |||
| 9f043b25e6 | |||
| c18259ac3f | |||
| 613abecf2a | |||
| 3e98eee171 | |||
| b4f482d293 | |||
| 3ae4d5abd2 | |||
| 83fd1153d9 | |||
| 060d9529bd | |||
|
8dc80e28cd
|
|||
| 94514bc627 | |||
| 34897e0015 | |||
| 86d4176015 | |||
| 68da5aa9a1 | |||
| f9743ca018 | |||
| b87a6002fd | |||
| 36dab87552 | |||
| f959811b68 | |||
| 39588214a6 | |||
|
8c7b44974f
|
|||
| afe64a31f9 | |||
|
45224547d6
|
|||
| d8aa7d2a89 | |||
| 72f3787dc6 | |||
| 2b62a7ed77 | |||
| 9500f5569d | |||
| 0a1f3eb4e4 | |||
| 7136bda3a8 | |||
| 790399edca | |||
| 6b1ea50aac | |||
| 5edd0801e4 | |||
| 5775cb6006 | |||
| 11ec56bae9 | |||
| 4ae4966ce9 | |||
|
9f40b06ce9
|
|||
| d300500da7 | |||
| 82f4604e6f | |||
| 04e402cdae | |||
| e6e1b1e348 | |||
| aaab5b2cd5 | |||
| 32af389b7b | |||
| f60af84b41 | |||
| 9cb454c32a | |||
| 8f31c1d717 | |||
| 277b64019c | |||
|
66f83fa70d
|
|||
| 01ccab5024 | |||
| 656b10bab9 | |||
| 366113bbf5 | |||
| dde5e91968 | |||
| 21ccdc6809 | |||
| b6b5673ea4 | |||
| b40c5b85e8 | |||
| 295636e444 | |||
| 5c7a189bf1 | |||
| 60ae679d87 | |||
| bc1dcdc8b4 | |||
| 81673c01eb | |||
| 0e883190d5 | |||
| bb29d2f2ad | |||
| 55374c395d | |||
| 8d94a8f8af | |||
| 3478ad0e5b | |||
|
b9a1c0b285
|
|||
| 3382e03305 | |||
| f97c03772b | |||
| 488d3a8b7f | |||
| a42c8c3251 | |||
| 411dd3d222 | |||
| acbea4996f | |||
| e0df0465aa | |||
| e7c4bfcdb7 | |||
| 041b66a54e | |||
| ce5467682e | |||
| 860b658fd8 | |||
| 5e0922d84f | |||
| ce35c1eb50 | |||
| 44047f88e4 | |||
| a9633e9079 | |||
| ef9654adfa | |||
| e99a340485 | |||
| d362203165 | |||
| afa1893f1a | |||
| 791a29d5b8 | |||
| d34a738b71 | |||
| 6e0bb8e1ca | |||
| c29f8be74c | |||
| 349989bb67 | |||
| d940a04d05 | |||
| 192929229f | |||
| 397d60361e | |||
| bb8deb221e | |||
| a465ae543a | |||
| 8aad0d6308 | |||
| 0166537d2f | |||
| 9f6e63ea50 | |||
| a30cb4b08c | |||
| abbc55ea6e | |||
| 779d78cdc6 | |||
| cf958ffa85 | |||
| eb579289f0 | |||
| d1ff4ec278 | |||
| 14c2c8f366 | |||
| 7177ba9516 | |||
| 652483e2de | |||
| 4e43361217 | |||
| 6dda660e78 | |||
| 534772b315 | |||
| 3bdfe7bf0e | |||
| edba76d0ab | |||
| 5289b4fa23 | |||
| eef7168f37 | |||
| 596967ff72 | |||
| 5f2385a92f | |||
| a5653c8ea6 | |||
| 75ec899c99 | |||
| cb31381be2 | |||
| 9ee344311a | |||
| d7e3b10e80 | |||
| 7b306dd500 | |||
| 22d096a2be | |||
| 858cb96e10 | |||
| e8dd55208c | |||
| dbf5206c1b | |||
| 4229508bba | |||
| b4d5dbe9e3 | |||
| b476cf0e36 | |||
| 37ac46759d | |||
| 424e500d6b | |||
| 9539d9b38d | |||
| a9ea2dace4 | |||
| aad18ad000 | |||
| b009965908 | |||
| 68d3f0df82 | |||
| cc1b28f81f | |||
| 99a55a45e3 | |||
| 7b6ff1fa0e |
@@ -0,0 +1,20 @@
|
||||
name: auth0mock
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ['v*']
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
BUILDTOOLS_CONTENT: ${{ secrets.BUILDTOOLS_CONTENT }}
|
||||
GITEA_REPOSITORY: ${{ gitea.repository }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: buildtool/setup-buildtools-action@v1
|
||||
- name: Build and push
|
||||
run: unset GITEA_TOKEN && build && push
|
||||
@@ -0,0 +1,9 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
release:
|
||||
uses: unboundsoftware/shared-workflows/.gitea/workflows/Release.yml@main
|
||||
+12
-1
@@ -1,2 +1,13 @@
|
||||
node_modules/
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# Claude
|
||||
.claude/
|
||||
|
||||
# Go
|
||||
auth0mock
|
||||
*.exe
|
||||
*.test
|
||||
*.out
|
||||
coverage.txt
|
||||
|
||||
+4
-9
@@ -1,22 +1,17 @@
|
||||
include:
|
||||
- template: 'Workflows/MergeRequest-Pipelines.gitlab-ci.yml'
|
||||
- project: unboundsoftware/ci-templates
|
||||
file: Defaults.gitlab-ci.yml
|
||||
- project: unboundsoftware/ci-templates
|
||||
file: Release.gitlab-ci.yml
|
||||
|
||||
stages:
|
||||
- build
|
||||
|
||||
variables:
|
||||
DOCKER_HOST: tcp://docker:2376
|
||||
DOCKER_TLS_CERTDIR: "/certs"
|
||||
DOCKER_TLS_VERIFY: 1
|
||||
DOCKER_CERT_PATH: "$DOCKER_TLS_CERTDIR/client"
|
||||
DOCKER_DRIVER: overlay2
|
||||
|
||||
image: buildtool/build-tools:${BUILDTOOLS_VERSION}
|
||||
|
||||
build:
|
||||
stage: build
|
||||
services:
|
||||
- docker:dind
|
||||
script:
|
||||
- build
|
||||
- push
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
# To get started with Dependabot version updates, you'll need to specify which
|
||||
# package ecosystems to update and where the package manifests are located.
|
||||
# Please see the documentation for all configuration options:
|
||||
# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
open-pull-requests-limit: 20
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
open-pull-requests-limit: 20
|
||||
+282
@@ -0,0 +1,282 @@
|
||||
## [0.7.0] - 2026-01-13
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- Add tag trigger for CI build and push
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- *(deps)* Update module github.com/lestrrat-go/jwx/v3 to v3.0.13 (#239)
|
||||
## [0.6.0] - 2026-01-09
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- Migrate from GitLab CI to Gitea Actions
|
||||
- Add release workflow using shared workflow
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- Remove incorrect digest pinning from image reference
|
||||
## [0.5.1] - 2025-12-29
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- *(deps)* Update module github.com/lestrrat-go/jwx/v2 to v3
|
||||
## [0.5.0] - 2025-12-29
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- Migrate auth0mock from Node.js to Go
|
||||
## [0.4.0] - 2025-12-29
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- *(session-cleanup)* Implement session expiration cleanup
|
||||
## [0.3.0] - 2025-12-28
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- Increase resource limits and improve readiness checks
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- *(deps)* Update dependency debug to v4.4.2
|
||||
- Downgrade debug package to version 4.4.1
|
||||
- *(deps)* Update dependency debug to v4.4.3
|
||||
- *(deps)* Update dependency nodemon to v3.1.11
|
||||
- *(deps)* Update dependency body-parser to v2.2.1
|
||||
- *(deps)* Update dependency express to v5.2.0
|
||||
- *(deps)* Update dependency express to v5.2.1
|
||||
- *(deps)* Update dependency jsonwebtoken to v9.0.3
|
||||
## [0.2.0] - 2025-06-29
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- *(ci)* Add default CI configuration for pipelines
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- *(deps)* Update dependency nodemon to v3.1.10
|
||||
- *(deps)* Update dependency debug to v4.4.1
|
||||
- *(deps)* Update dependency serve-favicon to v2.5.1
|
||||
- *(k8s)* Update ingress configuration for backend service
|
||||
## [0.1.5] - 2025-04-03
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- *(deps)* Update dependency body-parser to v2
|
||||
- *(deps)* Update dependency express to v5
|
||||
- Update CORS options for better compatibility
|
||||
|
||||
### ⚙️ Miscellaneous Tasks
|
||||
|
||||
- *(Dockerfile)* Update Node.js base image version
|
||||
## [0.1.4] - 2025-01-24
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- *(k8s)* Update labels to adhere to best practices
|
||||
## [0.1.3] - 2024-12-18
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- *(deps)* Pin dependencies
|
||||
- *(deps)* Update dependency express to v4.21.2
|
||||
- *(deps)* Update dependency debug to v4.4.0
|
||||
- *(deps)* Update dependency nodemon to v3.1.9
|
||||
|
||||
### 🚜 Refactor
|
||||
|
||||
- *(ci)* Remove unused Docker variables from config
|
||||
|
||||
### ⚙️ Miscellaneous Tasks
|
||||
|
||||
- Update renovate configuration to disable auth0mock updates
|
||||
- Remove Docker service from build stage configuration
|
||||
## [0.1.2] - 2024-10-19
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- *(deps)* Update dependency cookie-parser to v1.4.7
|
||||
- *(deps)* Update dependency express to v4.21.1
|
||||
|
||||
### ⚙️ Miscellaneous Tasks
|
||||
|
||||
- Update Dockerfile to remove warnings
|
||||
- Support issuer in openid-configuration
|
||||
## [0.1.1] - 2024-10-05
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- *(deps)* Update dependency nodemon to v3.1.1
|
||||
- *(deps)* Update dependency nodemon to v3.1.2
|
||||
- *(deps)* Update dependency debug to v4.3.5
|
||||
- *(deps)* Update dependency nodemon to v3.1.3
|
||||
- *(deps)* Update dependency nodemon to v3.1.4
|
||||
- *(deps)* Update dependency debug to v4.3.6
|
||||
- *(deps)* Update dependency debug to v4.3.7
|
||||
- *(deps)* Update dependency body-parser to v1.20.3
|
||||
- *(deps)* Update dependency express to v4.20.0
|
||||
- *(deps)* Update dependency express to v4.21.0
|
||||
- *(deps)* Update dependency nodemon to v3.1.5
|
||||
- *(deps)* Update dependency nodemon to v3.1.6
|
||||
- *(deps)* Update dependency nodemon to v3.1.7
|
||||
|
||||
### ⚙️ Miscellaneous Tasks
|
||||
|
||||
- Add release flow
|
||||
## [0.1.0] - 2024-04-08
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- Replace keystore handling with node-jose
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- *(deps)* Update dependency express to v4.19.1
|
||||
- *(deps)* Update dependency express to v4.19.2
|
||||
## [0.0.17] - 2024-03-11
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- Support patching of user info
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- *(deps)* Update dependency express to v4.18.3
|
||||
## [0.0.16] - 2023-06-01
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- Initial users store
|
||||
## [0.0.15] - 2023-05-31
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- Return empty array
|
||||
## [0.0.14] - 2023-05-31
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- Remember created users
|
||||
## [0.0.13] - 2023-05-02
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- Add name and email to id token
|
||||
## [0.0.12] - 2023-03-10
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- Remove session on logout
|
||||
## [0.0.11] - 2023-03-10
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- Update image name to correct location
|
||||
- Handle response mode query
|
||||
|
||||
### ⚙️ Miscellaneous Tasks
|
||||
|
||||
- Use Docker DinD version from variable
|
||||
- Change Dependabot rebase strategy
|
||||
- Format code and add prettier
|
||||
## [0.0.9] - 2022-04-28
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- Add support for client id and secret tokens
|
||||
## [0.0.8] - 2022-04-26
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- Add dummy-implementation of management API
|
||||
## [0.0.7] - 2022-04-26
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- Use correct return-variable
|
||||
## [0.0.6] - 2022-04-26
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- Make sure thumbPrint is a string
|
||||
|
||||
### 💼 Other
|
||||
|
||||
- *(deps)* Bump express from 4.17.3 to 4.18.0
|
||||
|
||||
### ⚙️ Miscellaneous Tasks
|
||||
|
||||
- Format code
|
||||
## [0.0.5] - 2022-04-26
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- Add custom claims to both id and access token
|
||||
## [0.0.4] - 2022-04-26
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- Add email custom claim
|
||||
## [0.0.3] - 2022-04-26
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- Add env-property for default issuer
|
||||
## [0.0.2] - 2022-04-25
|
||||
|
||||
### 💼 Other
|
||||
|
||||
- *(deps)* Bump node from 17 to 18
|
||||
|
||||
### ⚙️ Miscellaneous Tasks
|
||||
|
||||
- Change admin-handling
|
||||
## [0.0.1] - 2022-04-19
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- Initial commit
|
||||
- Updated to be compatible with Auth0 SPA which uses the 'Authorization Code Grant using Proof Key for Code Exchange (PKCE)' flow
|
||||
- Add ENV-property for setting admin-role
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- Use correct envs
|
||||
- Use commit rather than latest
|
||||
- Package.json & yarn.lock to reduce vulnerabilities
|
||||
- Package.json & yarn.lock to reduce vulnerabilities
|
||||
- Pipeline
|
||||
|
||||
### 💼 Other
|
||||
|
||||
- *(deps)* Bump nodemon from 2.0.14 to 2.0.15
|
||||
- *(deps)* Bump cookie-parser from 1.4.5 to 1.4.6
|
||||
- *(deps)* Bump debug from 4.3.2 to 4.3.3
|
||||
- *(deps)* Bump body-parser from 1.19.0 to 1.19.1
|
||||
- *(deps)* Bump express from 4.17.1 to 4.17.2
|
||||
- *(deps)* Bump node-forge from 0.10.0 to 1.0.0
|
||||
- *(deps)* Bump node-forge from 1.0.0 to 1.1.0
|
||||
- *(deps)* Bump node-forge from 1.1.0 to 1.2.0
|
||||
- *(deps)* Bump node-forge from 1.2.0 to 1.2.1
|
||||
- *(deps)* Bump body-parser from 1.19.1 to 1.19.2
|
||||
- *(deps)* Bump https-localhost from 4.7.0 to 4.7.1
|
||||
- *(deps)* Bump express from 4.17.2 to 4.17.3
|
||||
- *(deps)* Bump debug from 4.3.3 to 4.3.4
|
||||
- *(deps)* Bump node-forge from 1.2.1 to 1.3.0
|
||||
- *(deps)* Bump node-forge from 1.3.0 to 1.3.1
|
||||
- *(deps)* Bump body-parser from 1.19.2 to 1.20.0
|
||||
|
||||
### ⚙️ Miscellaneous Tasks
|
||||
|
||||
- Add triggering of acctest
|
||||
- Add artifacts
|
||||
- Update to latest build-tools
|
||||
- Update to latest build-tools
|
||||
- Add ingress
|
||||
- Add CI workflows
|
||||
- Use buildtools version from env
|
||||
- Add dependabot config
|
||||
- Remove dependabot-standalone
|
||||
- Cleanup and remove acctest triggering
|
||||
@@ -0,0 +1,115 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
auth0mock is a Go application that simulates an Auth0 authentication server for local development. It provides OAuth 2.0 and OpenID Connect (OIDC) endpoints compatible with the Auth0 API, allowing developers to test authentication flows without connecting to the actual Auth0 service.
|
||||
|
||||
## Development Commands
|
||||
|
||||
```bash
|
||||
# Build the service
|
||||
go build -o auth0mock ./cmd/service
|
||||
|
||||
# Run the service
|
||||
go run ./cmd/service
|
||||
|
||||
# Run tests
|
||||
go test ./... -v
|
||||
|
||||
# Run tests with coverage
|
||||
go test ./... -coverprofile=coverage.txt -covermode=atomic
|
||||
|
||||
# Format code
|
||||
gofumpt -w .
|
||||
goimports -w .
|
||||
|
||||
# Run pre-commit hooks
|
||||
pre-commit run --all-files
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
auth0mock/
|
||||
├── cmd/service/ # Entry point, HTTP server setup, configuration
|
||||
├── auth/ # JWT/JWK generation and signing, PKCE verification
|
||||
├── handlers/ # HTTP handlers for all endpoints
|
||||
│ └── templates/ # Embedded HTML templates (login form)
|
||||
├── store/ # In-memory session and user storage
|
||||
├── public/ # Static files (favicon)
|
||||
├── k8s/ # Kubernetes deployment manifests
|
||||
└── Dockerfile # Multi-stage Go build
|
||||
```
|
||||
|
||||
**Key Packages:**
|
||||
- `auth/jwt.go` - RSA key generation, JWT signing using lestrrat-go/jwx/v2
|
||||
- `auth/pkce.go` - PKCE verification (S256 and plain methods)
|
||||
- `store/sessions.go` - Thread-safe session storage with TTL and cleanup
|
||||
- `store/users.go` - Thread-safe user storage with JSON file loading
|
||||
- `handlers/oauth.go` - OAuth token exchange, authorization, code generation
|
||||
- `handlers/discovery.go` - OIDC discovery and JWKS endpoints
|
||||
- `handlers/management.go` - Auth0 Management API endpoints
|
||||
|
||||
## HTTP Endpoints
|
||||
|
||||
**Authentication:**
|
||||
- `POST /oauth/token` - Token exchange (authorization code and client_credentials)
|
||||
- `GET /authorize` - Authorization endpoint with HTML login form
|
||||
- `POST /code` - Code generation for PKCE flow
|
||||
|
||||
**Discovery:**
|
||||
- `GET /.well-known/openid-configuration` - OIDC discovery document
|
||||
- `GET /.well-known/jwks.json` - JSON Web Key Set
|
||||
|
||||
**Management API:**
|
||||
- `GET /api/v2/users-by-email` - Get user by email
|
||||
- `POST /api/v2/users` - Create user
|
||||
- `PATCH /api/v2/users/{userid}` - Update user
|
||||
- `POST /api/v2/tickets/password-change` - Password change ticket
|
||||
|
||||
**Session:**
|
||||
- `GET /userinfo` - User information
|
||||
- `POST /tokeninfo` - Decode JWT token
|
||||
- `GET /v2/logout` - Logout and session cleanup
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|----------|---------|---------|
|
||||
| `PORT` | `3333` | HTTP listen port |
|
||||
| `ISSUER` | `localhost:3333` | JWT issuer (without https://) |
|
||||
| `AUDIENCE` | `https://generic-audience` | JWT audience |
|
||||
| `USERS_FILE` | `./users.json` | Path to initial users JSON file |
|
||||
| `ADMIN_CUSTOM_CLAIM` | `https://unbound.se/admin` | Admin custom claim key |
|
||||
| `EMAIL_CUSTOM_CLAIM` | `https://unbound.se/email` | Email custom claim key |
|
||||
| `LOG_LEVEL` | `info` | Log level (debug, info, warn, error) |
|
||||
| `LOG_FORMAT` | `text` | Log format (text, json) |
|
||||
|
||||
## Initial Users
|
||||
|
||||
Create a `users.json` file to seed users on startup:
|
||||
|
||||
```json
|
||||
{
|
||||
"email@test.com": {
|
||||
"given_name": "John",
|
||||
"family_name": "Doe",
|
||||
"user_id": "auth0|email@test.com",
|
||||
"email": "email@test.com"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Key Implementation Details
|
||||
|
||||
- RSA 2048-bit key pair generated at startup using `lestrrat-go/jwx/v2`
|
||||
- In-memory session storage with 5-minute TTL and automatic cleanup
|
||||
- Proper PKCE verification (S256 method with SHA256 hash)
|
||||
- Thread-safe stores using `sync.RWMutex`
|
||||
- Graceful shutdown with signal handling
|
||||
|
||||
## Integration with Shiny
|
||||
|
||||
This service is used for local development and acceptance testing of the Shiny platform. The gateway and frontend services are configured to accept tokens signed by this mock server when running locally.
|
||||
+25
-11
@@ -1,12 +1,26 @@
|
||||
FROM node:17
|
||||
ENV AUDIENCE "https://shiny.unbound.se"
|
||||
ENV ORIGIN_HOST "auth0mock"
|
||||
ENV ORIGIN "https://auth0mock:3333"
|
||||
FROM golang:1.25@sha256:0f406d34b7cb7255d0700af02ec28a2c88f1e00701055f4c282aa4c3ec0b3245 AS build
|
||||
|
||||
ARG GITLAB_USER
|
||||
ARG GITLAB_TOKEN
|
||||
|
||||
WORKDIR /build
|
||||
ENV CGO_ENABLED=0
|
||||
ENV GOPRIVATE=gitlab.com/unboundsoftware/*
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
RUN go build -ldflags="-s -w" -o /release/service ./cmd/service
|
||||
|
||||
FROM gcr.io/distroless/static-debian12@sha256:cd64bec9cec257044ce3a8dd3620cf83b387920100332f2b041f19c4d2febf93
|
||||
|
||||
ENV TZ=Europe/Stockholm
|
||||
ENV AUDIENCE="https://shiny.unbound.se"
|
||||
|
||||
COPY --from=build /release/service /service
|
||||
COPY public /public
|
||||
|
||||
EXPOSE 3333
|
||||
WORKDIR /app
|
||||
ADD package.json yarn.lock /app/
|
||||
RUN yarn install --frozen-lockfile
|
||||
ADD app.js cert.js /app/
|
||||
ADD public /app/public
|
||||
RUN mkdir -p /root/.config
|
||||
ENTRYPOINT yarn start
|
||||
|
||||
ENTRYPOINT ["/service"]
|
||||
|
||||
@@ -3,47 +3,68 @@
|
||||
> This server helps you to simulate auth0 server locally. So, you are able to use the `/tokeninfo` endpoint to verify your token.
|
||||
|
||||
## Getting Started
|
||||
### Prerequisites
|
||||
* Install [Node.js](http://nodejs.org)
|
||||
* on OSX use [homebrew](http://brew.sh) `brew install node`
|
||||
* on Windows use [chocolatey](https://chocolatey.org/) `choco install nodejs`
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Install [Node.js](http://nodejs.org)
|
||||
- on OSX use [homebrew](http://brew.sh) `brew install node`
|
||||
- on Windows use [chocolatey](https://chocolatey.org/) `choco install nodejs`
|
||||
|
||||
## Installing
|
||||
* `fork` this repo
|
||||
* `clone` your fork
|
||||
* `npm install` to install all dependencies
|
||||
|
||||
- `fork` this repo
|
||||
- `clone` your fork
|
||||
- `npm install` to install all dependencies
|
||||
|
||||
## Running the app
|
||||
|
||||
After you have installed all dependencies you can now run the app.
|
||||
Run `npm start` to start a local server.
|
||||
The port will be displayed to you as `http://0.0.0.0:3333` (or if you prefer IPv6, if you're using `express` server, then it's `http://[::1]:3333/`).
|
||||
|
||||
## Initial users
|
||||
|
||||
Adding a JSON file with the following layout will populate the users store when starting:
|
||||
|
||||
```json
|
||||
{
|
||||
"email@test.com": {
|
||||
"given_name": "name",
|
||||
"family_name": "family",
|
||||
"user_id": "id"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
By default `./users.json` will be read but this can be overridden by setting the environment variable `USERS_FILE`.
|
||||
|
||||
## API Documentation
|
||||
|
||||
### `GET` /token/:username
|
||||
|
||||
Returns a token with the given user(username). This token can the be used by your application.
|
||||
|
||||
### `POST` /tokeninfo
|
||||
|
||||
Returns the data of the token like the username.
|
||||
|
||||
**Body**
|
||||
|
||||
```
|
||||
{
|
||||
"id_token": "your-token-kjasdf6ashasl..."
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
## Related Projects
|
||||
* [express-typescript-boilerplate](https://github.com/w3tecch/express-typescript-boilerplate) - Boilerplate for an restful express-apllication written in TypeScript
|
||||
* [express-graphql-typescript-boilerplate](https://github.com/w3tecch/express-graphql-typescript-boilerplate) - A starter kit for building amazing GraphQL API's with TypeScript and express by @w3tecch
|
||||
|
||||
- [express-typescript-boilerplate](https://github.com/w3tecch/express-typescript-boilerplate) - Boilerplate for an restful express-apllication written in TypeScript
|
||||
- [express-graphql-typescript-boilerplate](https://github.com/w3tecch/express-graphql-typescript-boilerplate) - A starter kit for building amazing GraphQL API's with TypeScript and express by @w3tecch
|
||||
|
||||
## License
|
||||
|
||||
[MIT](/LICENSE)
|
||||
|
||||
|
||||
---
|
||||
Made with ♥ by Gery Hirschfeld ([@GeryHirschfeld1](https://twitter.com/GeryHirschfeld1))
|
||||
|
||||
Made with ♥ by Gery Hirschfeld ([@GeryHirschfeld1](https://twitter.com/GeryHirschfeld1))
|
||||
|
||||
@@ -1,269 +0,0 @@
|
||||
process.env.DEBUG = 'app*'
|
||||
|
||||
const express = require('express')
|
||||
const cookieParser = require('cookie-parser')
|
||||
const app = express()
|
||||
const jwt = require('jsonwebtoken')
|
||||
const Debug = require('debug')
|
||||
const path = require('path')
|
||||
const cors = require('cors')
|
||||
const bodyParser = require('body-parser')
|
||||
const favicon = require('serve-favicon')
|
||||
const cert = require('./cert')
|
||||
|
||||
let issuer = 'localhost:3333'
|
||||
let jwksOrigin = `https://${issuer}/`
|
||||
const audience = process.env.AUDIENCE || 'https://generic-audience'
|
||||
const adminRole = process.env.ADMIN_ROLE || 'admin'
|
||||
|
||||
const debug = Debug('app')
|
||||
|
||||
let { privateKey, certDer, thumbprint, exponent, modulus } = cert(jwksOrigin)
|
||||
|
||||
const sessions = {}
|
||||
const challenges = {}
|
||||
|
||||
const corsOpts = (req, cb) => {
|
||||
cb(null, { origin: req.headers.origin })
|
||||
}
|
||||
|
||||
// Configure our small auth0-mock-server
|
||||
app.options('*', cors(corsOpts))
|
||||
.use(cors())
|
||||
.use(bodyParser.json())
|
||||
.use(bodyParser.urlencoded({ extended: true }))
|
||||
.use(cookieParser())
|
||||
.use(express.static(`${__dirname}/public`))
|
||||
.use(favicon(path.join(__dirname, 'public', 'favicon.ico')))
|
||||
|
||||
// This route can be used to generate a valid jwt-token.
|
||||
app.post('/oauth/token', (req, res) => {
|
||||
const code = req.body.code
|
||||
const session = sessions[code]
|
||||
|
||||
let date = Math.floor(Date.now() / 1000)
|
||||
let accessToken = jwt.sign(Buffer.from(JSON.stringify({
|
||||
iss: jwksOrigin,
|
||||
aud: [audience],
|
||||
sub: 'auth0|' + session.email,
|
||||
iat: date,
|
||||
exp: date + 7200,
|
||||
azp: session.clientId
|
||||
})), privateKey, {
|
||||
algorithm: 'RS256',
|
||||
keyid: thumbprint
|
||||
})
|
||||
|
||||
let idToken = jwt.sign(Buffer.from(JSON.stringify({
|
||||
iss: jwksOrigin,
|
||||
aud: session.clientId,
|
||||
nonce: session.nonce,
|
||||
sub: 'auth0|' + session.email,
|
||||
iat: date,
|
||||
exp: date + 7200,
|
||||
azp: session.clientId,
|
||||
name: 'Example Person',
|
||||
picture: 'https://cdn.playbuzz.com/cdn/5458360f-32ea-460e-a707-1a2d26760558/70bda687-cb84-4756-8a44-8cf735ed87b3.jpg',
|
||||
'https://unbound.se/roles': session.roles
|
||||
})), privateKey, {
|
||||
algorithm: 'RS256',
|
||||
keyid: thumbprint
|
||||
})
|
||||
|
||||
debug('Signed token for ' + session.email)
|
||||
// res.json({ token });
|
||||
|
||||
res.json({
|
||||
access_token: accessToken,
|
||||
id_token: idToken,
|
||||
scope: 'openid%20profile%20email',
|
||||
expires_in: 7200,
|
||||
token_type: 'Bearer'
|
||||
})
|
||||
})
|
||||
|
||||
// This route can be used to generate a valid jwt-token.
|
||||
app.get('/token/:email', (req, res) => {
|
||||
if (!req.params.email) {
|
||||
debug('No user was given!')
|
||||
return res.status(400).send('user is missing')
|
||||
}
|
||||
const token = jwt.sign({
|
||||
user_id: 'auth0|' + req.params.email
|
||||
}, privateKey)
|
||||
debug('Signed token for ' + req.params.email)
|
||||
res.json({ token })
|
||||
})
|
||||
|
||||
app.post('/code', (req, res) => {
|
||||
if (!req.body.email || !req.body.password || !req.body.codeChallenge) {
|
||||
debug('Body is invalid!', req.body)
|
||||
return res.status(400).send('Email or password is missing!')
|
||||
}
|
||||
|
||||
const code = req.body.codeChallenge
|
||||
challenges[req.body.codeChallenge] = code
|
||||
const state = req.body.state
|
||||
let roles = []
|
||||
if (req.body.admin === 'true') {
|
||||
roles = [adminRole]
|
||||
}
|
||||
sessions[code] = {
|
||||
email: req.body.email,
|
||||
password: req.body.password,
|
||||
state: req.body.state,
|
||||
nonce: req.body.nonce,
|
||||
clientId: req.body.clientId,
|
||||
codeChallenge: req.body.codeChallenge,
|
||||
roles: roles
|
||||
}
|
||||
res.redirect(`${req.body.redirect}?domain=${issuer}&code=${code}&state=${encodeURIComponent(state)}`)
|
||||
})
|
||||
|
||||
app.get('/authorize', (req, res) => {
|
||||
const redirect = req.query.redirect_uri
|
||||
const state = req.query.state
|
||||
const nonce = req.query.nonce
|
||||
const clientId = req.query.client_id
|
||||
const codeChallenge = req.query.code_challenge
|
||||
const prompt = req.query.prompt
|
||||
const responseMode = req.query.response_mode
|
||||
if (prompt === 'none' && responseMode === 'web_message') {
|
||||
const code = req.cookies['auth0']
|
||||
const session = sessions[code]
|
||||
session.nonce = nonce
|
||||
session.state = state
|
||||
session.codeChallenge = codeChallenge
|
||||
res.send(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<script type="text/javascript">
|
||||
(() => {
|
||||
const msg = {
|
||||
type: 'authorization_response',
|
||||
response: {
|
||||
code: '${code}',
|
||||
state: '${state}'
|
||||
}
|
||||
}
|
||||
parent.postMessage(msg, "*")
|
||||
})()
|
||||
</script>
|
||||
</body>
|
||||
</html>`)
|
||||
} else {
|
||||
res.cookie('auth0', codeChallenge, {
|
||||
sameSite: 'None',
|
||||
secure: true,
|
||||
httpOnly: true
|
||||
})
|
||||
res.send(`
|
||||
<html lang='en'>
|
||||
<head>
|
||||
<meta charset='utf-8'>
|
||||
<meta name='viewport' content='width=device-width, initial-scale=1, shrink-to-fit=no'>
|
||||
<title>Auth</title>
|
||||
<link rel='stylesheet' href='https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css' integrity='sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh' crossorigin='anonymous'>
|
||||
</head>
|
||||
<body>
|
||||
<div class='container'>
|
||||
<form method='post' action='/code'>
|
||||
<div class='card' style='width: 18rem;'>
|
||||
<div class='card-body'>
|
||||
<h5 class='card-title'>Login</h5>
|
||||
<div class='form-group'>
|
||||
<label for='email'>Email</label>
|
||||
<input type='text' name='email' id='email' class='form-control'>
|
||||
</div>
|
||||
<div class='form-group'>
|
||||
<label for='password'>Password</label>
|
||||
<input type='password' name='password' id='password' class='form-control'>
|
||||
</div>
|
||||
<div class='form-check'>
|
||||
<input class='form-check-input' type='checkbox' name='admin' value='true' id='admin'>
|
||||
<label class='form-check-label' for='admin'>
|
||||
Admin
|
||||
</label>
|
||||
</div>
|
||||
<button type='submit' class='btn btn-primary'>Login</button>
|
||||
<input type='hidden' value='${redirect}' name='redirect'>
|
||||
<input type='hidden' value='${state}' name='state'>
|
||||
<input type='hidden' value='${nonce}' name='nonce'>
|
||||
<input type='hidden' value='${clientId}' name='clientId'>
|
||||
<input type='hidden' value='${codeChallenge}' name='codeChallenge'>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`)
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/userinfo', (req, res) => {
|
||||
res.contentType('application/json').send(JSON.stringify({ picture: 'https://cdn.playbuzz.com/cdn/5458360f-32ea-460e-a707-1a2d26760558/70bda687-cb84-4756-8a44-8cf735ed87b3.jpg' }))
|
||||
})
|
||||
|
||||
app.get('/v2/logout', (req, res) => {
|
||||
res.redirect(`${req.query.returnTo}?domain=${issuer}`)
|
||||
})
|
||||
|
||||
app.get('/.well-known/jwks.json', (req, res) => {
|
||||
res
|
||||
.contentType('application/json')
|
||||
.send(JSON.stringify({
|
||||
keys: [
|
||||
{
|
||||
alg: 'RS256',
|
||||
// e: 'AQAB',
|
||||
e: exponent,
|
||||
kid: thumbprint,
|
||||
kty: 'RSA',
|
||||
n: modulus,
|
||||
use: 'sig',
|
||||
x5c: [certDer],
|
||||
x5t: thumbprint
|
||||
}
|
||||
]
|
||||
}))
|
||||
})
|
||||
|
||||
// This route returns the inside of a jwt-token. Your main application
|
||||
// should use this route to keep the auth0-flow
|
||||
app.post('/tokeninfo', (req, res) => {
|
||||
if (!req.body.id_token) {
|
||||
debug('No token given in the body!')
|
||||
return res.status(401).send('missing id_token')
|
||||
}
|
||||
const data = jwt.decode(req.body.id_token)
|
||||
if (data) {
|
||||
debug('Return token data from ' + data.user_id)
|
||||
res.json(data)
|
||||
} else {
|
||||
debug('The token was invalid and could not be decoded!')
|
||||
res.status(401).send('invalid id_token')
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/issuer', (req, res) => {
|
||||
if (!req.body.issuer) {
|
||||
debug('No issuer given in the body!')
|
||||
return res.status(401).send('missing issuer')
|
||||
}
|
||||
issuer = req.body.issuer
|
||||
jwksOrigin = `https://${issuer}/`
|
||||
const { privateKey: key, certDer: der, thumbPrint: thumb, exponent: exp, modulus: mod } = cert(jwksOrigin)
|
||||
privateKey = key
|
||||
certDer = der
|
||||
thumbprint = thumb
|
||||
exponent = exp
|
||||
modulus = mod
|
||||
debug('Issuer set to ' + req.body.issuer)
|
||||
res.send('ok')
|
||||
})
|
||||
|
||||
app.listen(3333, () => {
|
||||
debug('Auth0-Mock-Server listening on port 3333!')
|
||||
})
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/lestrrat-go/jwx/v3/jwa"
|
||||
"github.com/lestrrat-go/jwx/v3/jwk"
|
||||
"github.com/lestrrat-go/jwx/v3/jws"
|
||||
"github.com/lestrrat-go/jwx/v3/jwt"
|
||||
)
|
||||
|
||||
const (
|
||||
// TokenExpiry is the default token expiration time
|
||||
TokenExpiry = 2 * time.Hour
|
||||
)
|
||||
|
||||
// JWTService handles JWT signing and JWKS generation
|
||||
type JWTService struct {
|
||||
privateKey *rsa.PrivateKey
|
||||
jwkSet jwk.Set
|
||||
issuer string
|
||||
audience string
|
||||
adminClaim string
|
||||
emailClaim string
|
||||
}
|
||||
|
||||
// NewJWTService creates a new JWT service with a generated RSA key pair
|
||||
func NewJWTService(issuer, audience, adminClaim, emailClaim string) (*JWTService, error) {
|
||||
// Generate RSA 2048-bit key pair
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate RSA key: %w", err)
|
||||
}
|
||||
|
||||
// Create JWK from private key
|
||||
key, err := jwk.Import(privateKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create JWK from private key: %w", err)
|
||||
}
|
||||
|
||||
// Set key metadata
|
||||
keyID := uuid.New().String()
|
||||
if err := key.Set(jwk.KeyIDKey, keyID); err != nil {
|
||||
return nil, fmt.Errorf("set key ID: %w", err)
|
||||
}
|
||||
if err := key.Set(jwk.AlgorithmKey, jwa.RS256()); err != nil {
|
||||
return nil, fmt.Errorf("set algorithm: %w", err)
|
||||
}
|
||||
if err := key.Set(jwk.KeyUsageKey, "sig"); err != nil {
|
||||
return nil, fmt.Errorf("set key usage: %w", err)
|
||||
}
|
||||
|
||||
// Create public key for JWKS
|
||||
publicKey, err := key.PublicKey()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get public key: %w", err)
|
||||
}
|
||||
|
||||
// Create JWKS with public key
|
||||
jwkSet := jwk.NewSet()
|
||||
if err := jwkSet.AddKey(publicKey); err != nil {
|
||||
return nil, fmt.Errorf("add key to set: %w", err)
|
||||
}
|
||||
|
||||
return &JWTService{
|
||||
privateKey: privateKey,
|
||||
jwkSet: jwkSet,
|
||||
issuer: issuer,
|
||||
audience: audience,
|
||||
adminClaim: adminClaim,
|
||||
emailClaim: emailClaim,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SignToken creates a signed JWT with the given claims
|
||||
func (s *JWTService) SignToken(claims map[string]interface{}) (string, error) {
|
||||
// Build JWT token
|
||||
builder := jwt.NewBuilder()
|
||||
|
||||
now := time.Now()
|
||||
builder.Issuer(s.issuer)
|
||||
builder.IssuedAt(now)
|
||||
builder.Expiration(now.Add(TokenExpiry))
|
||||
|
||||
// Add all claims
|
||||
for key, value := range claims {
|
||||
builder.Claim(key, value)
|
||||
}
|
||||
|
||||
token, err := builder.Build()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("build token: %w", err)
|
||||
}
|
||||
|
||||
// Create JWK from private key for signing
|
||||
key, err := jwk.Import(s.privateKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create signing key: %w", err)
|
||||
}
|
||||
|
||||
// Get key ID from JWKS
|
||||
pubKey, _ := s.jwkSet.Key(0)
|
||||
keyID, _ := pubKey.KeyID()
|
||||
if err := key.Set(jwk.KeyIDKey, keyID); err != nil {
|
||||
return "", fmt.Errorf("set key ID: %w", err)
|
||||
}
|
||||
|
||||
// Sign the token
|
||||
signed, err := jwt.Sign(token, jwt.WithKey(jwa.RS256(), key))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("sign token: %w", err)
|
||||
}
|
||||
|
||||
return string(signed), nil
|
||||
}
|
||||
|
||||
// SignAccessToken creates an access token for the given subject
|
||||
func (s *JWTService) SignAccessToken(subject, clientID, email string, customClaims []map[string]interface{}) (string, error) {
|
||||
claims := map[string]interface{}{
|
||||
"sub": subject,
|
||||
"aud": []string{s.audience},
|
||||
"azp": clientID,
|
||||
}
|
||||
|
||||
// Add custom claims
|
||||
for _, cc := range customClaims {
|
||||
for k, v := range cc {
|
||||
claims[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
// Add email claim
|
||||
claims[s.emailClaim] = email
|
||||
|
||||
return s.SignToken(claims)
|
||||
}
|
||||
|
||||
// SignIDToken creates an ID token for the given subject
|
||||
func (s *JWTService) SignIDToken(subject, clientID, nonce, email, name, givenName, familyName, picture string, customClaims []map[string]interface{}) (string, error) {
|
||||
claims := map[string]interface{}{
|
||||
"sub": subject,
|
||||
"aud": clientID,
|
||||
"azp": clientID,
|
||||
"name": name,
|
||||
"given_name": givenName,
|
||||
"family_name": familyName,
|
||||
"email": email,
|
||||
"picture": picture,
|
||||
}
|
||||
|
||||
if nonce != "" {
|
||||
claims["nonce"] = nonce
|
||||
}
|
||||
|
||||
// Add custom claims
|
||||
for _, cc := range customClaims {
|
||||
for k, v := range cc {
|
||||
claims[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
// Add email claim
|
||||
claims[s.emailClaim] = email
|
||||
|
||||
return s.SignToken(claims)
|
||||
}
|
||||
|
||||
// GetJWKS returns the JSON Web Key Set as JSON bytes
|
||||
func (s *JWTService) GetJWKS() ([]byte, error) {
|
||||
return json.Marshal(s.jwkSet)
|
||||
}
|
||||
|
||||
// DecodeToken decodes a JWT without verifying the signature
|
||||
func (s *JWTService) DecodeToken(tokenString string) (map[string]interface{}, error) {
|
||||
// Parse without verification
|
||||
msg, err := jws.Parse([]byte(tokenString))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse token: %w", err)
|
||||
}
|
||||
|
||||
var claims map[string]interface{}
|
||||
if err := json.Unmarshal(msg.Payload(), &claims); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal claims: %w", err)
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// Issuer returns the issuer URL
|
||||
func (s *JWTService) Issuer() string {
|
||||
return s.issuer
|
||||
}
|
||||
|
||||
// Audience returns the audience
|
||||
func (s *JWTService) Audience() string {
|
||||
return s.audience
|
||||
}
|
||||
|
||||
// AdminClaim returns the admin custom claim key
|
||||
func (s *JWTService) AdminClaim() string {
|
||||
return s.adminClaim
|
||||
}
|
||||
|
||||
// EmailClaim returns the email custom claim key
|
||||
func (s *JWTService) EmailClaim() string {
|
||||
return s.emailClaim
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewJWTService(t *testing.T) {
|
||||
service, err := NewJWTService("https://test.example.com/", "https://audience", "https://admin", "https://email")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create JWT service: %v", err)
|
||||
}
|
||||
|
||||
if service.Issuer() != "https://test.example.com/" {
|
||||
t.Errorf("expected issuer https://test.example.com/, got %s", service.Issuer())
|
||||
}
|
||||
|
||||
if service.Audience() != "https://audience" {
|
||||
t.Errorf("expected audience https://audience, got %s", service.Audience())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignToken(t *testing.T) {
|
||||
service, err := NewJWTService("https://test.example.com/", "https://audience", "https://admin", "https://email")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create JWT service: %v", err)
|
||||
}
|
||||
|
||||
claims := map[string]interface{}{
|
||||
"sub": "test-subject",
|
||||
"aud": "test-audience",
|
||||
}
|
||||
|
||||
token, err := service.SignToken(claims)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to sign token: %v", err)
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
t.Error("expected non-empty token")
|
||||
}
|
||||
|
||||
// Verify token can be decoded
|
||||
decoded, err := service.DecodeToken(token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to decode token: %v", err)
|
||||
}
|
||||
|
||||
if decoded["sub"] != "test-subject" {
|
||||
t.Errorf("expected sub=test-subject, got %v", decoded["sub"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignAccessToken(t *testing.T) {
|
||||
service, err := NewJWTService("https://test.example.com/", "https://audience", "https://admin", "https://email")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create JWT service: %v", err)
|
||||
}
|
||||
|
||||
customClaims := []map[string]interface{}{
|
||||
{"https://admin": true},
|
||||
}
|
||||
|
||||
token, err := service.SignAccessToken("auth0|user@example.com", "client-id", "user@example.com", customClaims)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to sign access token: %v", err)
|
||||
}
|
||||
|
||||
decoded, err := service.DecodeToken(token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to decode token: %v", err)
|
||||
}
|
||||
|
||||
if decoded["sub"] != "auth0|user@example.com" {
|
||||
t.Errorf("expected sub=auth0|user@example.com, got %v", decoded["sub"])
|
||||
}
|
||||
|
||||
if decoded["https://email"] != "user@example.com" {
|
||||
t.Errorf("expected email claim, got %v", decoded["https://email"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignIDToken(t *testing.T) {
|
||||
service, err := NewJWTService("https://test.example.com/", "https://audience", "https://admin", "https://email")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create JWT service: %v", err)
|
||||
}
|
||||
|
||||
token, err := service.SignIDToken(
|
||||
"auth0|user@example.com",
|
||||
"client-id",
|
||||
"test-nonce",
|
||||
"user@example.com",
|
||||
"Test User",
|
||||
"Test",
|
||||
"User",
|
||||
"https://example.com/picture.jpg",
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to sign ID token: %v", err)
|
||||
}
|
||||
|
||||
decoded, err := service.DecodeToken(token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to decode token: %v", err)
|
||||
}
|
||||
|
||||
if decoded["name"] != "Test User" {
|
||||
t.Errorf("expected name=Test User, got %v", decoded["name"])
|
||||
}
|
||||
|
||||
if decoded["nonce"] != "test-nonce" {
|
||||
t.Errorf("expected nonce=test-nonce, got %v", decoded["nonce"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetJWKS(t *testing.T) {
|
||||
service, err := NewJWTService("https://test.example.com/", "https://audience", "https://admin", "https://email")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create JWT service: %v", err)
|
||||
}
|
||||
|
||||
jwks, err := service.GetJWKS()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get JWKS: %v", err)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(jwks, &result); err != nil {
|
||||
t.Fatalf("failed to parse JWKS: %v", err)
|
||||
}
|
||||
|
||||
keys, ok := result["keys"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatal("expected keys array in JWKS")
|
||||
}
|
||||
|
||||
if len(keys) != 1 {
|
||||
t.Errorf("expected 1 key, got %d", len(keys))
|
||||
}
|
||||
|
||||
key := keys[0].(map[string]interface{})
|
||||
if key["kty"] != "RSA" {
|
||||
t.Errorf("expected kty=RSA, got %v", key["kty"])
|
||||
}
|
||||
|
||||
if key["use"] != "sig" {
|
||||
t.Errorf("expected use=sig, got %v", key["use"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// PKCEMethod represents the code challenge method
|
||||
type PKCEMethod string
|
||||
|
||||
const (
|
||||
// PKCEMethodPlain uses the verifier directly as the challenge
|
||||
PKCEMethodPlain PKCEMethod = "plain"
|
||||
// PKCEMethodS256 uses SHA256 hash of the verifier
|
||||
PKCEMethodS256 PKCEMethod = "S256"
|
||||
)
|
||||
|
||||
// VerifyPKCE verifies that the code verifier matches the code challenge
|
||||
func VerifyPKCE(verifier, challenge string, method PKCEMethod) bool {
|
||||
if verifier == "" || challenge == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
switch method {
|
||||
case PKCEMethodPlain, "":
|
||||
// Plain method or no method specified - direct comparison
|
||||
return verifier == challenge
|
||||
case PKCEMethodS256:
|
||||
// S256 method - SHA256 hash, base64url encoded
|
||||
computed := ComputeS256Challenge(verifier)
|
||||
return computed == challenge
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ComputeS256Challenge computes the S256 code challenge from a verifier
|
||||
func ComputeS256Challenge(verifier string) string {
|
||||
hash := sha256.Sum256([]byte(verifier))
|
||||
return base64URLEncode(hash[:])
|
||||
}
|
||||
|
||||
// base64URLEncode encodes bytes to base64url without padding
|
||||
func base64URLEncode(data []byte) string {
|
||||
encoded := base64.URLEncoding.EncodeToString(data)
|
||||
// Remove padding
|
||||
return strings.TrimRight(encoded, "=")
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestVerifyPKCE_Plain(t *testing.T) {
|
||||
verifier := "test-verifier-12345"
|
||||
challenge := "test-verifier-12345"
|
||||
|
||||
if !VerifyPKCE(verifier, challenge, PKCEMethodPlain) {
|
||||
t.Error("expected plain PKCE verification to succeed")
|
||||
}
|
||||
|
||||
if VerifyPKCE("wrong-verifier", challenge, PKCEMethodPlain) {
|
||||
t.Error("expected plain PKCE verification to fail with wrong verifier")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyPKCE_S256(t *testing.T) {
|
||||
// Test vector from RFC 7636
|
||||
verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
||||
challenge := ComputeS256Challenge(verifier)
|
||||
|
||||
if !VerifyPKCE(verifier, challenge, PKCEMethodS256) {
|
||||
t.Error("expected S256 PKCE verification to succeed")
|
||||
}
|
||||
|
||||
if VerifyPKCE("wrong-verifier", challenge, PKCEMethodS256) {
|
||||
t.Error("expected S256 PKCE verification to fail with wrong verifier")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyPKCE_EmptyValues(t *testing.T) {
|
||||
if VerifyPKCE("", "challenge", PKCEMethodS256) {
|
||||
t.Error("expected PKCE verification to fail with empty verifier")
|
||||
}
|
||||
|
||||
if VerifyPKCE("verifier", "", PKCEMethodS256) {
|
||||
t.Error("expected PKCE verification to fail with empty challenge")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyPKCE_DefaultMethod(t *testing.T) {
|
||||
verifier := "test-verifier"
|
||||
challenge := "test-verifier"
|
||||
|
||||
// Empty method should default to plain
|
||||
if !VerifyPKCE(verifier, challenge, "") {
|
||||
t.Error("expected PKCE verification with empty method to use plain")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeS256Challenge(t *testing.T) {
|
||||
// Known test case
|
||||
verifier := "abc123"
|
||||
challenge := ComputeS256Challenge(verifier)
|
||||
|
||||
// Challenge should be base64url encoded without padding
|
||||
if challenge == "" {
|
||||
t.Error("expected non-empty challenge")
|
||||
}
|
||||
|
||||
// Should not contain padding
|
||||
if len(challenge) > 0 && challenge[len(challenge)-1] == '=' {
|
||||
t.Error("challenge should not have padding")
|
||||
}
|
||||
|
||||
// Same verifier should produce same challenge
|
||||
challenge2 := ComputeS256Challenge(verifier)
|
||||
if challenge != challenge2 {
|
||||
t.Error("same verifier should produce same challenge")
|
||||
}
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
const base64url = require('base64-url');
|
||||
const createHash = require('crypto').createHash;
|
||||
const forge = require('node-forge');
|
||||
const NodeRSA = require('node-rsa');
|
||||
|
||||
const PRIVATE_KEY_PEM =
|
||||
'-----BEGIN RSA PRIVATE KEY-----\n' +
|
||||
'MIIEpAIBAAKCAQEApoocpO3bbUF6o8eyJlQCfwLahEsunWdVF++yOEyKu4Lp1j0m\n' +
|
||||
'2j/P7iHOtxBAkjdM2X2oW3qO1mR0sIFefqnm93g0q2nRuYEoS+W3o6X50wjOVm8f\n' +
|
||||
'r/tLqELzy5BoET0AQl7Axp1DNsb0HNOBcoIBt+xVY4I+k6uXJJJMzbgvahAgSLZ9\n' +
|
||||
'RW0Z0WT+dCHZpZUj0nLxNXIPdci65Bw6IognqXHP6AwKZXpT6jCzjzq9uyHxVcud\n' +
|
||||
'qw6j0kQw48/A5A6AN5fIVy1cKnd0sKdqRX1NUqVoiOrO4jaDB1IdLD+YmRE/JjOH\n' +
|
||||
'sWIMElYCPxKqnsNo6VCslGX/ziinArHhqRBrHwIDAQABAoIBAHAdmpsN5iLvafjI\n' +
|
||||
'f45+EBAhg6p8Uq102zx6CakNHniN8Y5hLL7RJtJRwDBNqKrGv93LUoQDRhXfGw+Y\n' +
|
||||
'iF0NVIhVTF/5pU8VPGOcCr0JB96ilwZpWRPIQW7NZAMu/GBeiMYls/IB/TXrSnv9\n' +
|
||||
'h6/nBfEkEXgkPqx7YA0m0L3NuV3U1lCY/LhBJY4Xvi0uRdqu3tTHXftehuPwC4UB\n' +
|
||||
'42eJTWv/qLeOlkCdUUV4f7+dNaES88Vdhj6lu/BusnNhvnwHQik4dNwzPCGeP8NV\n' +
|
||||
'5gaesWiNWFZuTURGKk1B65p5LzNPjsVT50RDuW8FnSZwIvNcohrX9ILPsmg/t0Kr\n' +
|
||||
'ozcOksECgYEA4XWOK4twx5RG162zveRHqU7H9RBWSz7/PzM9Eob9vx/tC/b1YqBR\n' +
|
||||
'VShk23vje19eNiYWAkxcpobIP4ek/0ZT8nHkJg8wl+J/hnXADcvwv2dKnoFnm5pn\n' +
|
||||
'rTBUKc8R3wrSlAV8XQAtdnxsfFa5AOQJ6WFVI9AdfH3Iw8XZk4gIIPMCgYEAvRlY\n' +
|
||||
'y80HnR3kwMOqY488V1qk41dmfNqa+YDL+zkPF1HhHI9VnK5BQuI7lyKJl984KwHu\n' +
|
||||
'0gbwx3Wp4XkD5JUboEpl5LnaLsjEWemjTaQWdvJHPd5wkJ0m/jRQ2YeT4g2gFu4y\n' +
|
||||
'Pi/pWkrzhnzQQVAmOdAm5Kj27LtDzp0lspw3uCUCgYEAw2YdvFGSgfZZW4147QeO\n' +
|
||||
'sAbON+9bysUjdMPUl10VR/LEgA0d6MdnFfX3S13Y7tDdlvJ1OrKxzcWcgaru7ism\n' +
|
||||
'kEXy5KVfiRNNUNx2gb6RvWEpA6zFfc9ZMXlkSAPlyjfX/1+tw/Bmdn0pjK2gk0wP\n' +
|
||||
'5wtrPameFInzWPD9O+a2nM8CgYBZ6UhgNs+M9B7FTQOiLQPa4R2PfwobCXIwef4D\n' +
|
||||
'KIE1bFgl1T02r2AWZi1BUkmr7ZXuVQ/xyx0HKbopm/mu4PruvxEtrPTB0/IQcleU\n' +
|
||||
'XhXUXqRjFXXePOrCaaubkqxNCn95B67aBLvmk8awxn3a4DocuQ0VIgWuT+gQwIWh\n' +
|
||||
'JEgWBQKBgQDKD+2Yh1/rUzu15lbPH0JSpozUinuFjePieR/4n+5CtEUxWJ2f0WeK\n' +
|
||||
's4XWWf2qgUccjpiGju2UR840mgWROoZ8BfSTd5tg1F7bo0HMgu2hu0RIRpZcRhsA\n' +
|
||||
'Cd0GrJvf1t0QIdDCXAy+RpgU1SLSq4Q6Lomc0WA5C5nBw9RKEUOV9A==\n' +
|
||||
'-----END RSA PRIVATE KEY-----\n';
|
||||
|
||||
const PUBLIC_KEY_PEM =
|
||||
'-----BEGIN PUBLIC KEY-----\n' +
|
||||
'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApoocpO3bbUF6o8eyJlQC\n' +
|
||||
'fwLahEsunWdVF++yOEyKu4Lp1j0m2j/P7iHOtxBAkjdM2X2oW3qO1mR0sIFefqnm\n' +
|
||||
'93g0q2nRuYEoS+W3o6X50wjOVm8fr/tLqELzy5BoET0AQl7Axp1DNsb0HNOBcoIB\n' +
|
||||
't+xVY4I+k6uXJJJMzbgvahAgSLZ9RW0Z0WT+dCHZpZUj0nLxNXIPdci65Bw6Iogn\n' +
|
||||
'qXHP6AwKZXpT6jCzjzq9uyHxVcudqw6j0kQw48/A5A6AN5fIVy1cKnd0sKdqRX1N\n' +
|
||||
'UqVoiOrO4jaDB1IdLD+YmRE/JjOHsWIMElYCPxKqnsNo6VCslGX/ziinArHhqRBr\n' +
|
||||
'HwIDAQAB\n' +
|
||||
'-----END PUBLIC KEY-----\n';
|
||||
|
||||
const createCertificate = ({
|
||||
publicKey,
|
||||
privateKey,
|
||||
jwksOrigin,
|
||||
}) => {
|
||||
const cert = forge.pki.createCertificate();
|
||||
cert.publicKey = publicKey;
|
||||
cert.serialNumber = '123';
|
||||
const attrs = [
|
||||
{
|
||||
name: 'commonName',
|
||||
value: `${jwksOrigin}`,
|
||||
},
|
||||
];
|
||||
cert.validity.notBefore = new Date();
|
||||
cert.validity.notAfter = new Date();
|
||||
cert.validity.notAfter.setFullYear(cert.validity.notBefore.getFullYear() + 1);
|
||||
cert.setSubject(attrs);
|
||||
cert.setIssuer(attrs);
|
||||
cert.sign(privateKey);
|
||||
return forge.pki.certificateToPem(cert)
|
||||
};
|
||||
|
||||
const getCertThumbprint = (certificate) => {
|
||||
const shasum = createHash('sha1');
|
||||
const der = Buffer.from(certificate).toString('binary');
|
||||
shasum.update(der);
|
||||
return shasum.digest('base64')
|
||||
};
|
||||
|
||||
const createKeyPair = () => {
|
||||
const privateKey = forge.pki.privateKeyFromPem(PRIVATE_KEY_PEM);
|
||||
const publicKey = forge.pki.publicKeyFromPem(PUBLIC_KEY_PEM);
|
||||
return {
|
||||
privateKey,
|
||||
publicKey,
|
||||
}
|
||||
};
|
||||
|
||||
const bnToB64 = (bn) => {
|
||||
let hex = BigInt(bn).toString(16);
|
||||
if (hex.length % 2) {
|
||||
hex = '0' + hex;
|
||||
}
|
||||
|
||||
const bin = [];
|
||||
let i = 0;
|
||||
let d;
|
||||
let b;
|
||||
while (i < hex.length) {
|
||||
d = parseInt(hex.slice(i, i + 2), 16);
|
||||
b = String.fromCharCode(d);
|
||||
bin.push(b);
|
||||
i += 2;
|
||||
}
|
||||
|
||||
return Buffer.from(bin.join(''), 'binary').toString('base64');
|
||||
};
|
||||
|
||||
const setup = (jwksOrigin) => {
|
||||
const {privateKey, publicKey} = createKeyPair();
|
||||
const certPem = createCertificate({
|
||||
jwksOrigin,
|
||||
privateKey,
|
||||
publicKey,
|
||||
});
|
||||
const certDer = forge.util.encode64(
|
||||
forge.asn1
|
||||
.toDer(forge.pki.certificateToAsn1(forge.pki.certificateFromPem(certPem)))
|
||||
.getBytes()
|
||||
);
|
||||
const thumbprint = base64url.encode(getCertThumbprint(certDer));
|
||||
|
||||
const helperKey = new NodeRSA();
|
||||
helperKey.importKey(forge.pki.privateKeyToPem(privateKey));
|
||||
const {n: modulus, e: exponent} = helperKey.exportKey('components');
|
||||
|
||||
return {
|
||||
privateKey: forge.pki.privateKeyToPem(privateKey),
|
||||
certDer: certDer,
|
||||
thumbPrint: thumbprint,
|
||||
exponent: bnToB64(exponent),
|
||||
modulus: modulus.toString('base64')
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = setup;
|
||||
@@ -0,0 +1,194 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/alecthomas/kong"
|
||||
"github.com/rs/cors"
|
||||
|
||||
"gitlab.com/unboundsoftware/auth0mock/auth"
|
||||
"gitlab.com/unboundsoftware/auth0mock/handlers"
|
||||
"gitlab.com/unboundsoftware/auth0mock/store"
|
||||
)
|
||||
|
||||
var (
|
||||
buildVersion = "dev"
|
||||
serviceName = "auth0mock"
|
||||
)
|
||||
|
||||
// CLI defines the command-line interface
|
||||
type CLI struct {
|
||||
Port int `name:"port" env:"PORT" help:"Listen port" default:"3333"`
|
||||
Issuer string `name:"issuer" env:"ISSUER" help:"JWT issuer (without https://)" default:"localhost:3333"`
|
||||
Audience string `name:"audience" env:"AUDIENCE" help:"JWT audience" default:"https://generic-audience"`
|
||||
UsersFile string `name:"users-file" env:"USERS_FILE" help:"Path to initial users JSON file" default:"./users.json"`
|
||||
AdminClaim string `name:"admin-claim" env:"ADMIN_CUSTOM_CLAIM" help:"Admin custom claim key" default:"https://unbound.se/admin"`
|
||||
EmailClaim string `name:"email-claim" env:"EMAIL_CUSTOM_CLAIM" help:"Email custom claim key" default:"https://unbound.se/email"`
|
||||
LogLevel string `name:"log-level" env:"LOG_LEVEL" help:"Log level" default:"info" enum:"debug,info,warn,error"`
|
||||
LogFormat string `name:"log-format" env:"LOG_FORMAT" help:"Log format" default:"text" enum:"json,text"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
var cli CLI
|
||||
_ = kong.Parse(&cli)
|
||||
|
||||
// Setup logger
|
||||
logger := setupLogger(cli.LogLevel, cli.LogFormat)
|
||||
logger.Info("starting auth0mock",
|
||||
"version", buildVersion,
|
||||
"port", cli.Port,
|
||||
"issuer", cli.Issuer,
|
||||
)
|
||||
|
||||
// Initialize stores
|
||||
userStore := store.NewUserStore()
|
||||
if err := userStore.LoadFromFile(cli.UsersFile); err != nil {
|
||||
logger.Warn("failed to load users file", "path", cli.UsersFile, "error", err)
|
||||
}
|
||||
|
||||
sessionStore := store.NewSessionStore(logger)
|
||||
|
||||
// Initialize JWT service
|
||||
issuerURL := fmt.Sprintf("https://%s/", cli.Issuer)
|
||||
jwtService, err := auth.NewJWTService(issuerURL, cli.Audience, cli.AdminClaim, cli.EmailClaim)
|
||||
if err != nil {
|
||||
logger.Error("failed to create JWT service", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Initialize handlers
|
||||
discoveryHandler := handlers.NewDiscoveryHandler(jwtService)
|
||||
oauthHandler, err := handlers.NewOAuthHandler(jwtService, sessionStore, logger)
|
||||
if err != nil {
|
||||
logger.Error("failed to create OAuth handler", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
managementHandler := handlers.NewManagementHandler(userStore, logger)
|
||||
sessionHandler := handlers.NewSessionHandler(jwtService, sessionStore, logger)
|
||||
|
||||
// Setup routes
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// CORS middleware
|
||||
corsHandler := cors.New(cors.Options{
|
||||
AllowedOrigins: []string{"*"},
|
||||
AllowedMethods: []string{"GET", "POST", "PATCH", "OPTIONS"},
|
||||
AllowedHeaders: []string{"*"},
|
||||
AllowCredentials: true,
|
||||
})
|
||||
|
||||
// Discovery endpoints
|
||||
mux.HandleFunc("GET /.well-known/openid-configuration", discoveryHandler.OpenIDConfiguration)
|
||||
mux.HandleFunc("GET /.well-known/jwks.json", discoveryHandler.JWKS)
|
||||
|
||||
// OAuth endpoints
|
||||
mux.HandleFunc("POST /oauth/token", oauthHandler.Token)
|
||||
mux.HandleFunc("GET /authorize", oauthHandler.Authorize)
|
||||
mux.HandleFunc("POST /code", oauthHandler.Code)
|
||||
|
||||
// Session endpoints
|
||||
mux.HandleFunc("GET /userinfo", sessionHandler.UserInfo)
|
||||
mux.HandleFunc("POST /tokeninfo", sessionHandler.TokenInfo)
|
||||
mux.HandleFunc("GET /v2/logout", sessionHandler.Logout)
|
||||
|
||||
// Management API endpoints
|
||||
mux.HandleFunc("GET /api/v2/users-by-email", managementHandler.GetUsersByEmail)
|
||||
mux.HandleFunc("POST /api/v2/users", managementHandler.CreateUser)
|
||||
mux.HandleFunc("PATCH /api/v2/users/", managementHandler.UpdateUser)
|
||||
mux.HandleFunc("POST /api/v2/tickets/password-change", managementHandler.PasswordChangeTicket)
|
||||
|
||||
// Health check
|
||||
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("OK"))
|
||||
})
|
||||
|
||||
// Static files
|
||||
mux.Handle("GET /favicon.ico", http.FileServer(http.Dir("public")))
|
||||
|
||||
// Create HTTP server
|
||||
httpSrv := &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", cli.Port),
|
||||
Handler: corsHandler.Handler(mux),
|
||||
}
|
||||
|
||||
// Start session cleanup
|
||||
rootCtx, rootCancel := context.WithCancel(context.Background())
|
||||
sessionStore.StartCleanup(rootCtx)
|
||||
|
||||
// Graceful shutdown
|
||||
wg := sync.WaitGroup{}
|
||||
sigint := make(chan os.Signal, 1)
|
||||
signal.Notify(sigint, os.Interrupt, syscall.SIGTERM)
|
||||
|
||||
// Signal handler goroutine
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
sig := <-sigint
|
||||
if sig != nil {
|
||||
signal.Reset(os.Interrupt, syscall.SIGTERM)
|
||||
logger.Info("received shutdown signal")
|
||||
rootCancel()
|
||||
}
|
||||
}()
|
||||
|
||||
// Shutdown handler goroutine
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-rootCtx.Done()
|
||||
|
||||
shutdownCtx, shutdownRelease := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer shutdownRelease()
|
||||
|
||||
if err := httpSrv.Shutdown(shutdownCtx); err != nil {
|
||||
logger.Error("failed to shutdown HTTP server", "error", err)
|
||||
}
|
||||
close(sigint)
|
||||
}()
|
||||
|
||||
// HTTP server goroutine
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer rootCancel()
|
||||
|
||||
logger.Info("listening", "port", cli.Port)
|
||||
if err := httpSrv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
|
||||
logger.Error("HTTP server error", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
logger.Info("shutdown complete")
|
||||
}
|
||||
|
||||
func setupLogger(level, format string) *slog.Logger {
|
||||
var leveler slog.LevelVar
|
||||
if err := leveler.UnmarshalText([]byte(level)); err != nil {
|
||||
leveler.Set(slog.LevelInfo)
|
||||
}
|
||||
|
||||
handlerOpts := &slog.HandlerOptions{
|
||||
Level: leveler.Level(),
|
||||
}
|
||||
|
||||
var handler slog.Handler
|
||||
switch format {
|
||||
case "json":
|
||||
handler = slog.NewJSONHandler(os.Stdout, handlerOpts)
|
||||
default:
|
||||
handler = slog.NewTextHandler(os.Stdout, handlerOpts)
|
||||
}
|
||||
|
||||
return slog.New(handler).With("service", serviceName, "version", buildVersion)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
module gitlab.com/unboundsoftware/auth0mock
|
||||
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/alecthomas/kong v1.13.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/lestrrat-go/jwx/v3 v3.0.13
|
||||
github.com/rs/cors v1.11.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
|
||||
github.com/goccy/go-json v0.10.3 // indirect
|
||||
github.com/lestrrat-go/blackmagic v1.0.4 // indirect
|
||||
github.com/lestrrat-go/dsig v1.0.0 // indirect
|
||||
github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect
|
||||
github.com/lestrrat-go/httpcc v1.0.1 // indirect
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.2 // indirect
|
||||
github.com/lestrrat-go/option/v2 v2.0.0 // indirect
|
||||
github.com/segmentio/asm v1.2.1 // indirect
|
||||
github.com/valyala/fastjson v1.6.7 // indirect
|
||||
golang.org/x/crypto v0.46.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,51 @@
|
||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/kong v1.13.0 h1:5e/7XC3ugvhP1DQBmTS+WuHtCbcv44hsohMgcvVxSrA=
|
||||
github.com/alecthomas/kong v1.13.0/go.mod h1:wrlbXem1CWqUV5Vbmss5ISYhsVPkBb1Yo7YKJghju2I=
|
||||
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
|
||||
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
||||
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
|
||||
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||
github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA=
|
||||
github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
|
||||
github.com/lestrrat-go/dsig v1.0.0 h1:OE09s2r9Z81kxzJYRn07TFM9XA4akrUdoMwr0L8xj38=
|
||||
github.com/lestrrat-go/dsig v1.0.0/go.mod h1:dEgoOYYEJvW6XGbLasr8TFcAxoWrKlbQvmJgCR0qkDo=
|
||||
github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7gcrVVMFPOzY=
|
||||
github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU=
|
||||
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
|
||||
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.2 h1:7u4HUaD0NQbf2/n5+fyp+T10hNCsAnwKfqn4A4Baif0=
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.2/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0=
|
||||
github.com/lestrrat-go/jwx/v3 v3.0.13 h1:AdHKiPIYeCSnOJtvdpipPg/0SuFh9rdkN+HF3O0VdSk=
|
||||
github.com/lestrrat-go/jwx/v3 v3.0.13/go.mod h1:2m0PV1A9tM4b/jVLMx8rh6rBl7F6WGb3EG2hufN9OQU=
|
||||
github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss=
|
||||
github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA=
|
||||
github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
|
||||
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
|
||||
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/valyala/fastjson v1.6.7 h1:ZE4tRy0CIkh+qDc5McjatheGX2czdn8slQjomexVpBM=
|
||||
github.com/valyala/fastjson v1.6.7/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
|
||||
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,77 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"gitlab.com/unboundsoftware/auth0mock/auth"
|
||||
)
|
||||
|
||||
// DiscoveryHandler handles OIDC discovery endpoints
|
||||
type DiscoveryHandler struct {
|
||||
jwtService *auth.JWTService
|
||||
}
|
||||
|
||||
// NewDiscoveryHandler creates a new discovery handler
|
||||
func NewDiscoveryHandler(jwtService *auth.JWTService) *DiscoveryHandler {
|
||||
return &DiscoveryHandler{
|
||||
jwtService: jwtService,
|
||||
}
|
||||
}
|
||||
|
||||
// OpenIDConfiguration returns the OIDC discovery document
|
||||
func (h *DiscoveryHandler) OpenIDConfiguration(w http.ResponseWriter, r *http.Request) {
|
||||
issuer := h.jwtService.Issuer()
|
||||
|
||||
config := map[string]interface{}{
|
||||
"issuer": issuer,
|
||||
"authorization_endpoint": issuer + "authorize",
|
||||
"token_endpoint": issuer + "oauth/token",
|
||||
"token_endpoint_auth_methods_supported": []string{"client_secret_basic", "private_key_jwt"},
|
||||
"token_endpoint_auth_signing_alg_values_supported": []string{"RS256"},
|
||||
"userinfo_endpoint": issuer + "userinfo",
|
||||
"check_session_iframe": issuer + "check_session",
|
||||
"end_session_endpoint": issuer + "end_session",
|
||||
"jwks_uri": issuer + ".well-known/jwks.json",
|
||||
"registration_endpoint": issuer + "register",
|
||||
"scopes_supported": []string{"openid", "profile", "email", "address", "phone", "offline_access"},
|
||||
"response_types_supported": []string{"code", "code id_token", "id_token", "id_token token"},
|
||||
"acr_values_supported": []string{},
|
||||
"subject_types_supported": []string{"public", "pairwise"},
|
||||
"userinfo_signing_alg_values_supported": []string{"RS256", "ES256", "HS256"},
|
||||
"userinfo_encryption_alg_values_supported": []string{"RSA-OAEP-256", "A128KW"},
|
||||
"userinfo_encryption_enc_values_supported": []string{"A128CBC-HS256", "A128GCM"},
|
||||
"id_token_signing_alg_values_supported": []string{"RS256", "ES256", "HS256"},
|
||||
"id_token_encryption_alg_values_supported": []string{"RSA-OAEP-256", "A128KW"},
|
||||
"id_token_encryption_enc_values_supported": []string{"A128CBC-HS256", "A128GCM"},
|
||||
"request_object_signing_alg_values_supported": []string{"none", "RS256", "ES256"},
|
||||
"display_values_supported": []string{"page", "popup"},
|
||||
"claim_types_supported": []string{"normal", "distributed"},
|
||||
"claims_supported": []string{
|
||||
"sub", "iss", "auth_time", "acr",
|
||||
"name", "given_name", "family_name", "nickname",
|
||||
"profile", "picture", "website",
|
||||
"email", "email_verified", "locale", "zoneinfo",
|
||||
h.jwtService.EmailClaim(), h.jwtService.AdminClaim(),
|
||||
},
|
||||
"claims_parameter_supported": true,
|
||||
"service_documentation": "http://auth0/",
|
||||
"ui_locales_supported": []string{"en-US"},
|
||||
"code_challenge_methods_supported": []string{"plain", "S256"},
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(config)
|
||||
}
|
||||
|
||||
// JWKS returns the JSON Web Key Set
|
||||
func (h *DiscoveryHandler) JWKS(w http.ResponseWriter, r *http.Request) {
|
||||
jwks, err := h.jwtService.GetJWKS()
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to get JWKS", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write(jwks)
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"gitlab.com/unboundsoftware/auth0mock/store"
|
||||
)
|
||||
|
||||
// ManagementHandler handles Auth0 Management API endpoints
|
||||
type ManagementHandler struct {
|
||||
userStore *store.UserStore
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewManagementHandler creates a new management handler
|
||||
func NewManagementHandler(userStore *store.UserStore, logger *slog.Logger) *ManagementHandler {
|
||||
return &ManagementHandler{
|
||||
userStore: userStore,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// UserResponse represents the user response format
|
||||
type UserResponse struct {
|
||||
Email string `json:"email,omitempty"`
|
||||
GivenName string `json:"given_name,omitempty"`
|
||||
FamilyName string `json:"family_name,omitempty"`
|
||||
UserID string `json:"user_id"`
|
||||
Picture string `json:"picture,omitempty"`
|
||||
}
|
||||
|
||||
// GetUsersByEmail handles GET /api/v2/users-by-email
|
||||
func (h *ManagementHandler) GetUsersByEmail(w http.ResponseWriter, r *http.Request) {
|
||||
email := r.URL.Query().Get("email")
|
||||
|
||||
h.logger.Debug("getting user by email", "email", email)
|
||||
|
||||
user, ok := h.userStore.GetByEmail(email)
|
||||
if !ok {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode([]interface{}{})
|
||||
return
|
||||
}
|
||||
|
||||
response := []UserResponse{
|
||||
{
|
||||
Email: user.Email,
|
||||
GivenName: user.GivenName,
|
||||
FamilyName: user.FamilyName,
|
||||
UserID: fmt.Sprintf("auth0|%s", user.UserID),
|
||||
Picture: user.Picture,
|
||||
},
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
// CreateUser handles POST /api/v2/users
|
||||
func (h *ManagementHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
GivenName string `json:"given_name"`
|
||||
FamilyName string `json:"family_name"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Email == "" {
|
||||
http.Error(w, "Email is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
user := &store.User{
|
||||
Email: req.Email,
|
||||
GivenName: req.GivenName,
|
||||
FamilyName: req.FamilyName,
|
||||
UserID: req.Email,
|
||||
}
|
||||
|
||||
// Set defaults if not provided
|
||||
if user.GivenName == "" {
|
||||
user.GivenName = "Given"
|
||||
}
|
||||
if user.FamilyName == "" {
|
||||
user.FamilyName = "Last"
|
||||
}
|
||||
|
||||
h.userStore.Create(req.Email, user)
|
||||
|
||||
h.logger.Info("created user", "email", req.Email)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"user_id": fmt.Sprintf("auth0|%s", req.Email),
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateUser handles PATCH /api/v2/users/{userid}
|
||||
func (h *ManagementHandler) UpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
// Extract user ID from path - format: /api/v2/users/auth0|email@example.com
|
||||
path := r.URL.Path
|
||||
userID := strings.TrimPrefix(path, "/api/v2/users/")
|
||||
|
||||
// Strip "auth0|" prefix to get email
|
||||
email := strings.TrimPrefix(userID, "auth0|")
|
||||
|
||||
h.logger.Debug("patching user", "userid", userID, "email", email)
|
||||
|
||||
var req struct {
|
||||
GivenName string `json:"given_name"`
|
||||
FamilyName string `json:"family_name"`
|
||||
Picture string `json:"picture"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
updates := &store.User{
|
||||
GivenName: req.GivenName,
|
||||
FamilyName: req.FamilyName,
|
||||
Picture: req.Picture,
|
||||
}
|
||||
|
||||
_, ok := h.userStore.Update(email, updates)
|
||||
if !ok {
|
||||
http.Error(w, "User not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("updated user", "email", email)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"user_id": fmt.Sprintf("auth0|%s", email),
|
||||
})
|
||||
}
|
||||
|
||||
// PasswordChangeTicket handles POST /api/v2/tickets/password-change
|
||||
func (h *ManagementHandler) PasswordChangeTicket(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"ticket": "https://some-url",
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"gitlab.com/unboundsoftware/auth0mock/auth"
|
||||
"gitlab.com/unboundsoftware/auth0mock/store"
|
||||
)
|
||||
|
||||
//go:embed templates/login.html
|
||||
var templateFS embed.FS
|
||||
|
||||
// OAuthHandler handles OAuth/OIDC endpoints
|
||||
type OAuthHandler struct {
|
||||
jwtService *auth.JWTService
|
||||
sessionStore *store.SessionStore
|
||||
loginTemplate *template.Template
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewOAuthHandler creates a new OAuth handler
|
||||
func NewOAuthHandler(jwtService *auth.JWTService, sessionStore *store.SessionStore, logger *slog.Logger) (*OAuthHandler, error) {
|
||||
tmpl, err := template.ParseFS(templateFS, "templates/login.html")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse login template: %w", err)
|
||||
}
|
||||
|
||||
return &OAuthHandler{
|
||||
jwtService: jwtService,
|
||||
sessionStore: sessionStore,
|
||||
loginTemplate: tmpl,
|
||||
logger: logger,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// TokenRequest represents the token endpoint request body
|
||||
type TokenRequest struct {
|
||||
GrantType string `json:"grant_type"`
|
||||
ClientID string `json:"client_id"`
|
||||
ClientSecret string `json:"client_secret"`
|
||||
Code string `json:"code"`
|
||||
CodeVerifier string `json:"code_verifier"`
|
||||
RedirectURI string `json:"redirect_uri"`
|
||||
}
|
||||
|
||||
// TokenResponse represents the token endpoint response
|
||||
type TokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
IDToken string `json:"id_token"`
|
||||
Scope string `json:"scope"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
TokenType string `json:"token_type"`
|
||||
}
|
||||
|
||||
// Token handles the POST /oauth/token endpoint
|
||||
func (h *OAuthHandler) Token(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
grantType := r.FormValue("grant_type")
|
||||
clientID := r.FormValue("client_id")
|
||||
code := r.FormValue("code")
|
||||
codeVerifier := r.FormValue("code_verifier")
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
switch grantType {
|
||||
case "client_credentials":
|
||||
h.handleClientCredentials(w, clientID)
|
||||
case "authorization_code", "":
|
||||
if code != "" {
|
||||
h.handleAuthorizationCode(w, code, codeVerifier, clientID)
|
||||
} else {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "Missing code"})
|
||||
}
|
||||
default:
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "Unsupported grant type"})
|
||||
}
|
||||
}
|
||||
|
||||
func (h *OAuthHandler) handleClientCredentials(w http.ResponseWriter, clientID string) {
|
||||
if clientID == "" {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "Missing client_id"})
|
||||
return
|
||||
}
|
||||
|
||||
adminClaim := map[string]interface{}{
|
||||
h.jwtService.AdminClaim(): true,
|
||||
}
|
||||
|
||||
accessToken, err := h.jwtService.SignAccessToken(
|
||||
"auth0|management",
|
||||
clientID,
|
||||
"management@example.org",
|
||||
[]map[string]interface{}{adminClaim},
|
||||
)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to sign access token", "error", err)
|
||||
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
idToken, err := h.jwtService.SignIDToken(
|
||||
"auth0|management",
|
||||
clientID,
|
||||
"",
|
||||
"management@example.org",
|
||||
"Management API",
|
||||
"Management",
|
||||
"API",
|
||||
"",
|
||||
[]map[string]interface{}{adminClaim},
|
||||
)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to sign ID token", "error", err)
|
||||
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("signed token for management API")
|
||||
|
||||
json.NewEncoder(w).Encode(TokenResponse{
|
||||
AccessToken: accessToken,
|
||||
IDToken: idToken,
|
||||
Scope: "openid%20profile%20email",
|
||||
ExpiresIn: 7200,
|
||||
TokenType: "Bearer",
|
||||
})
|
||||
}
|
||||
|
||||
func (h *OAuthHandler) handleAuthorizationCode(w http.ResponseWriter, code, codeVerifier, clientID string) {
|
||||
session, ok := h.sessionStore.Get(code)
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "Invalid code"})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify PKCE if code_verifier is provided
|
||||
if codeVerifier != "" && session.CodeChallenge != "" {
|
||||
// Determine method - default to S256 if challenge looks like a hash
|
||||
method := auth.PKCEMethodS256
|
||||
if len(session.CodeChallenge) < 43 {
|
||||
method = auth.PKCEMethodPlain
|
||||
}
|
||||
|
||||
if !auth.VerifyPKCE(codeVerifier, session.CodeChallenge, method) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "Invalid code_verifier"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
accessToken, err := h.jwtService.SignAccessToken(
|
||||
"auth0|"+session.Email,
|
||||
session.ClientID,
|
||||
session.Email,
|
||||
session.CustomClaims,
|
||||
)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to sign access token", "error", err)
|
||||
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
idToken, err := h.jwtService.SignIDToken(
|
||||
"auth0|"+session.Email,
|
||||
session.ClientID,
|
||||
session.Nonce,
|
||||
session.Email,
|
||||
"Example Person",
|
||||
"Example",
|
||||
"Person",
|
||||
"https://cdn.playbuzz.com/cdn/5458360f-32ea-460e-a707-1a2d26760558/70bda687-cb84-4756-8a44-8cf735ed87b3.jpg",
|
||||
session.CustomClaims,
|
||||
)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to sign ID token", "error", err)
|
||||
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("signed token", "email", session.Email)
|
||||
|
||||
// Clean up session after successful token exchange
|
||||
h.sessionStore.Delete(code)
|
||||
|
||||
json.NewEncoder(w).Encode(TokenResponse{
|
||||
AccessToken: accessToken,
|
||||
IDToken: idToken,
|
||||
Scope: "openid%20profile%20email",
|
||||
ExpiresIn: 7200,
|
||||
TokenType: "Bearer",
|
||||
})
|
||||
}
|
||||
|
||||
// Code handles the POST /code endpoint (form submission from login page)
|
||||
func (h *OAuthHandler) Code(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
email := r.FormValue("email")
|
||||
password := r.FormValue("password")
|
||||
codeChallenge := r.FormValue("codeChallenge")
|
||||
redirect := r.FormValue("redirect")
|
||||
state := r.FormValue("state")
|
||||
nonce := r.FormValue("nonce")
|
||||
clientID := r.FormValue("clientId")
|
||||
admin := r.FormValue("admin") == "true"
|
||||
|
||||
if email == "" || password == "" || codeChallenge == "" {
|
||||
h.logger.Debug("invalid code request", "email", email, "hasPassword", password != "", "hasChallenge", codeChallenge != "")
|
||||
http.Error(w, "Email, password, or code challenge is missing", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
adminClaim := map[string]interface{}{
|
||||
h.jwtService.AdminClaim(): admin,
|
||||
}
|
||||
|
||||
session := &store.Session{
|
||||
Email: email,
|
||||
Password: password,
|
||||
State: state,
|
||||
Nonce: nonce,
|
||||
ClientID: clientID,
|
||||
CodeChallenge: codeChallenge,
|
||||
CustomClaims: []map[string]interface{}{adminClaim},
|
||||
}
|
||||
|
||||
h.sessionStore.Create(codeChallenge, session)
|
||||
|
||||
redirectURL := fmt.Sprintf("%s?code=%s&state=%s", redirect, codeChallenge, url.QueryEscape(state))
|
||||
http.Redirect(w, r, redirectURL, http.StatusFound)
|
||||
}
|
||||
|
||||
// Authorize handles the GET /authorize endpoint
|
||||
func (h *OAuthHandler) Authorize(w http.ResponseWriter, r *http.Request) {
|
||||
redirect := r.URL.Query().Get("redirect_uri")
|
||||
state := r.URL.Query().Get("state")
|
||||
nonce := r.URL.Query().Get("nonce")
|
||||
clientID := r.URL.Query().Get("client_id")
|
||||
codeChallenge := r.URL.Query().Get("code_challenge")
|
||||
prompt := r.URL.Query().Get("prompt")
|
||||
responseMode := r.URL.Query().Get("response_mode")
|
||||
|
||||
// Try to get existing session from cookie
|
||||
cookie, err := r.Cookie("auth0")
|
||||
var existingCode string
|
||||
if err == nil {
|
||||
existingCode = cookie.Value
|
||||
}
|
||||
|
||||
// Handle response_mode=query with existing session
|
||||
if responseMode == "query" && existingCode != "" {
|
||||
if h.sessionStore.Update(existingCode, codeChallenge, func(s *store.Session) {
|
||||
s.Nonce = nonce
|
||||
s.State = state
|
||||
s.CodeChallenge = codeChallenge
|
||||
}) {
|
||||
redirectURL := fmt.Sprintf("%s?code=%s&state=%s", redirect, codeChallenge, state)
|
||||
http.Redirect(w, r, redirectURL, http.StatusFound)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Handle prompt=none with response_mode=web_message (silent auth)
|
||||
if prompt == "none" && responseMode == "web_message" && existingCode != "" {
|
||||
session, ok := h.sessionStore.Get(existingCode)
|
||||
if ok {
|
||||
h.sessionStore.Update(existingCode, existingCode, func(s *store.Session) {
|
||||
s.Nonce = nonce
|
||||
s.State = state
|
||||
s.CodeChallenge = codeChallenge
|
||||
})
|
||||
|
||||
// Send postMessage response
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
fmt.Fprintf(w, `<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<script type="text/javascript">
|
||||
(() => {
|
||||
const msg = {
|
||||
type: 'authorization_response',
|
||||
response: {
|
||||
code: '%s',
|
||||
state: '%s'
|
||||
}
|
||||
}
|
||||
parent.postMessage(msg, "*")
|
||||
})()
|
||||
</script>
|
||||
</body>
|
||||
</html>`, existingCode, session.State)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Set cookie for session tracking
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "auth0",
|
||||
Value: codeChallenge,
|
||||
Path: "/",
|
||||
SameSite: http.SameSiteNoneMode,
|
||||
Secure: true,
|
||||
HttpOnly: true,
|
||||
})
|
||||
|
||||
// Render login form
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
data := map[string]string{
|
||||
"Redirect": redirect,
|
||||
"State": state,
|
||||
"Nonce": nonce,
|
||||
"ClientID": clientID,
|
||||
"CodeChallenge": codeChallenge,
|
||||
}
|
||||
|
||||
if err := h.loginTemplate.Execute(w, data); err != nil {
|
||||
h.logger.Error("failed to render login template", "error", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"gitlab.com/unboundsoftware/auth0mock/auth"
|
||||
"gitlab.com/unboundsoftware/auth0mock/store"
|
||||
)
|
||||
|
||||
// SessionHandler handles session-related endpoints
|
||||
type SessionHandler struct {
|
||||
jwtService *auth.JWTService
|
||||
sessionStore *store.SessionStore
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewSessionHandler creates a new session handler
|
||||
func NewSessionHandler(jwtService *auth.JWTService, sessionStore *store.SessionStore, logger *slog.Logger) *SessionHandler {
|
||||
return &SessionHandler{
|
||||
jwtService: jwtService,
|
||||
sessionStore: sessionStore,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// UserInfo handles GET /userinfo
|
||||
func (h *SessionHandler) UserInfo(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"picture": "https://cdn.playbuzz.com/cdn/5458360f-32ea-460e-a707-1a2d26760558/70bda687-cb84-4756-8a44-8cf735ed87b3.jpg",
|
||||
})
|
||||
}
|
||||
|
||||
// TokenInfo handles POST /tokeninfo
|
||||
func (h *SessionHandler) TokenInfo(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
IDToken string `json:"id_token"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.IDToken == "" {
|
||||
h.logger.Debug("no token given in body")
|
||||
http.Error(w, "missing id_token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := h.jwtService.DecodeToken(req.IDToken)
|
||||
if err != nil {
|
||||
h.logger.Debug("failed to decode token", "error", err)
|
||||
http.Error(w, "invalid id_token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if userID, ok := claims["sub"].(string); ok {
|
||||
h.logger.Debug("returning token data", "user_id", userID)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(claims)
|
||||
}
|
||||
|
||||
// Logout handles GET /v2/logout
|
||||
func (h *SessionHandler) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
returnTo := r.URL.Query().Get("returnTo")
|
||||
|
||||
// Try to get session from cookie
|
||||
cookie, err := r.Cookie("auth0")
|
||||
if err == nil && cookie.Value != "" {
|
||||
h.sessionStore.Delete(cookie.Value)
|
||||
h.logger.Debug("deleted session", "code", cookie.Value)
|
||||
}
|
||||
|
||||
// Clear the cookie
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "auth0",
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
SameSite: http.SameSiteNoneMode,
|
||||
Secure: true,
|
||||
HttpOnly: true,
|
||||
})
|
||||
|
||||
if returnTo != "" {
|
||||
http.Redirect(w, r, returnTo, http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("Logged out"))
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<title>Auth</title>
|
||||
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css" integrity="sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh" crossorigin="anonymous">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<form method="post" action="/code">
|
||||
<div class="card" style="width: 18rem; margin-top: 2rem;">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Login</h5>
|
||||
<div class="form-group">
|
||||
<label for="email">Email</label>
|
||||
<input type="text" name="email" id="email" class="form-control">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" name="password" id="password" class="form-control">
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="admin" value="true" id="admin">
|
||||
<label class="form-check-label" for="admin">
|
||||
Admin
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary mt-3">Login</button>
|
||||
<input type="hidden" value="{{.Redirect}}" name="redirect">
|
||||
<input type="hidden" value="{{.State}}" name="state">
|
||||
<input type="hidden" value="{{.Nonce}}" name="nonce">
|
||||
<input type="hidden" value="{{.ClientID}}" name="clientId">
|
||||
<input type="hidden" value="{{.CodeChallenge}}" name="codeChallenge">
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
+14
-8
@@ -6,21 +6,21 @@ spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: auth0mock
|
||||
app.kubernetes.io/name: auth0mock
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: auth0mock
|
||||
app.kubernetes.io/name: auth0mock
|
||||
spec:
|
||||
containers:
|
||||
- name: auth0mock
|
||||
image: registry.gitlab.com/unboundsoftware/shiny/auth0mock:${COMMIT}
|
||||
image: oci.unbound.se/unboundsoftware/auth0mock:${COMMIT}
|
||||
imagePullPolicy: "IfNotPresent"
|
||||
resources:
|
||||
requests:
|
||||
memory: 200Mi
|
||||
memory: 256Mi
|
||||
limits:
|
||||
memory: 300Mi
|
||||
memory: 512Mi
|
||||
ports:
|
||||
- containerPort: 3333
|
||||
env:
|
||||
@@ -29,17 +29,23 @@ spec:
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
port: 3333
|
||||
path: .well-known/jwks.json
|
||||
path: /.well-known/jwks.json
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
port: 3333
|
||||
path: /.well-known/jwks.json
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 30
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: auth0mock
|
||||
labels:
|
||||
app: auth0mock
|
||||
app.kubernetes.io/name: auth0mock
|
||||
spec:
|
||||
ports:
|
||||
- port: 3333
|
||||
selector:
|
||||
app: auth0mock
|
||||
app.kubernetes.io/name: auth0mock
|
||||
type: ClusterIP
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
apiVersion: networking.k8s.io/v1beta1
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: auth0-ingress
|
||||
@@ -11,9 +11,12 @@ spec:
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
serviceName: auth0mock
|
||||
servicePort: 3333
|
||||
service:
|
||||
name: auth0mock
|
||||
port:
|
||||
number: 3333
|
||||
tls:
|
||||
- hosts:
|
||||
- auth0
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"name": "auth0-mock-server",
|
||||
"version": "1.0.0",
|
||||
"description": "Helps us to develop locally with seeded data and keep the flow of auth0.",
|
||||
"main": "app.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"dev": "nodemon ./app.js",
|
||||
"start": "node ./app.js"
|
||||
},
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-url": "^2.3.3",
|
||||
"body-parser": "^1.20.0",
|
||||
"buffer": "^6.0.3",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"cors": "^2.8.3",
|
||||
"debug": "^4.3.4",
|
||||
"express": "^4.17.3",
|
||||
"https-localhost": "^4.7.1",
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"node-forge": "^1.3.1",
|
||||
"node-rsa": "^1.1.1",
|
||||
"nodemon": "^2.0.15",
|
||||
"serve-favicon": "^2.4.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": [
|
||||
"config:recommended"
|
||||
],
|
||||
"packageRules": [
|
||||
{
|
||||
"matchManagers": [
|
||||
"kubernetes"
|
||||
],
|
||||
"matchPackageNames": [
|
||||
"registry.gitlab.com/unboundsoftware/auth0mock"
|
||||
],
|
||||
"enabled": false
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// SessionTTL is the time-to-live for sessions
|
||||
SessionTTL = 5 * time.Minute
|
||||
// CleanupInterval is how often expired sessions are cleaned up
|
||||
CleanupInterval = 60 * time.Second
|
||||
)
|
||||
|
||||
// Session represents an OAuth session
|
||||
type Session struct {
|
||||
Email string
|
||||
Password string
|
||||
State string
|
||||
Nonce string
|
||||
ClientID string
|
||||
CodeChallenge string
|
||||
CodeVerifier string
|
||||
CustomClaims []map[string]interface{}
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// SessionStore provides thread-safe session storage with TTL
|
||||
type SessionStore struct {
|
||||
mu sync.RWMutex
|
||||
sessions map[string]*Session
|
||||
challenges map[string]string
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewSessionStore creates a new session store
|
||||
func NewSessionStore(logger *slog.Logger) *SessionStore {
|
||||
return &SessionStore{
|
||||
sessions: make(map[string]*Session),
|
||||
challenges: make(map[string]string),
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Create stores a new session
|
||||
func (s *SessionStore) Create(code string, session *Session) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
session.CreatedAt = time.Now()
|
||||
s.sessions[code] = session
|
||||
s.challenges[code] = code
|
||||
}
|
||||
|
||||
// Get retrieves a session by code
|
||||
func (s *SessionStore) Get(code string) (*Session, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
session, ok := s.sessions[code]
|
||||
return session, ok
|
||||
}
|
||||
|
||||
// Update updates an existing session and optionally re-indexes it
|
||||
func (s *SessionStore) Update(oldCode, newCode string, updateFn func(*Session)) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
session, ok := s.sessions[oldCode]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
updateFn(session)
|
||||
session.CreatedAt = time.Now() // Refresh timestamp
|
||||
|
||||
if oldCode != newCode {
|
||||
s.sessions[newCode] = session
|
||||
s.challenges[newCode] = newCode
|
||||
delete(s.sessions, oldCode)
|
||||
delete(s.challenges, oldCode)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Delete removes a session
|
||||
func (s *SessionStore) Delete(code string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
delete(s.sessions, code)
|
||||
delete(s.challenges, code)
|
||||
}
|
||||
|
||||
// Cleanup removes expired sessions
|
||||
func (s *SessionStore) Cleanup() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
cleaned := 0
|
||||
|
||||
for code, session := range s.sessions {
|
||||
if now.Sub(session.CreatedAt) > SessionTTL {
|
||||
delete(s.sessions, code)
|
||||
delete(s.challenges, code)
|
||||
cleaned++
|
||||
}
|
||||
}
|
||||
|
||||
return cleaned
|
||||
}
|
||||
|
||||
// StartCleanup starts a background goroutine to clean up expired sessions
|
||||
func (s *SessionStore) StartCleanup(ctx context.Context) {
|
||||
ticker := time.NewTicker(CleanupInterval)
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
ticker.Stop()
|
||||
return
|
||||
case <-ticker.C:
|
||||
if cleaned := s.Cleanup(); cleaned > 0 {
|
||||
s.logger.Info("cleaned up expired sessions", "count", cleaned)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSessionStore_CreateAndGet(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
|
||||
store := NewSessionStore(logger)
|
||||
|
||||
session := &Session{
|
||||
Email: "test@example.com",
|
||||
ClientID: "client-123",
|
||||
CodeChallenge: "challenge-abc",
|
||||
}
|
||||
|
||||
store.Create("code-123", session)
|
||||
|
||||
retrieved, ok := store.Get("code-123")
|
||||
if !ok {
|
||||
t.Fatal("expected to find session")
|
||||
}
|
||||
|
||||
if retrieved.Email != "test@example.com" {
|
||||
t.Errorf("expected email test@example.com, got %s", retrieved.Email)
|
||||
}
|
||||
|
||||
if retrieved.CreatedAt.IsZero() {
|
||||
t.Error("expected CreatedAt to be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionStore_Delete(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
|
||||
store := NewSessionStore(logger)
|
||||
|
||||
session := &Session{Email: "test@example.com"}
|
||||
store.Create("code-123", session)
|
||||
|
||||
store.Delete("code-123")
|
||||
|
||||
_, ok := store.Get("code-123")
|
||||
if ok {
|
||||
t.Error("expected session to be deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionStore_Update(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
|
||||
store := NewSessionStore(logger)
|
||||
|
||||
session := &Session{
|
||||
Email: "test@example.com",
|
||||
Nonce: "old-nonce",
|
||||
}
|
||||
store.Create("old-code", session)
|
||||
|
||||
// Update and re-index
|
||||
ok := store.Update("old-code", "new-code", func(s *Session) {
|
||||
s.Nonce = "new-nonce"
|
||||
})
|
||||
|
||||
if !ok {
|
||||
t.Fatal("expected update to succeed")
|
||||
}
|
||||
|
||||
// Old code should not exist
|
||||
_, ok = store.Get("old-code")
|
||||
if ok {
|
||||
t.Error("expected old code to be removed")
|
||||
}
|
||||
|
||||
// New code should exist
|
||||
retrieved, ok := store.Get("new-code")
|
||||
if !ok {
|
||||
t.Fatal("expected to find session with new code")
|
||||
}
|
||||
|
||||
if retrieved.Nonce != "new-nonce" {
|
||||
t.Errorf("expected nonce new-nonce, got %s", retrieved.Nonce)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionStore_UpdateSameCode(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
|
||||
store := NewSessionStore(logger)
|
||||
|
||||
session := &Session{
|
||||
Email: "test@example.com",
|
||||
Nonce: "old-nonce",
|
||||
}
|
||||
store.Create("code-123", session)
|
||||
|
||||
originalTime := session.CreatedAt
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
// Update without re-indexing
|
||||
store.Update("code-123", "code-123", func(s *Session) {
|
||||
s.Nonce = "new-nonce"
|
||||
})
|
||||
|
||||
retrieved, _ := store.Get("code-123")
|
||||
if retrieved.Nonce != "new-nonce" {
|
||||
t.Errorf("expected nonce new-nonce, got %s", retrieved.Nonce)
|
||||
}
|
||||
|
||||
// CreatedAt should be refreshed
|
||||
if !retrieved.CreatedAt.After(originalTime) {
|
||||
t.Error("expected CreatedAt to be refreshed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionStore_UpdateNotFound(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
|
||||
store := NewSessionStore(logger)
|
||||
|
||||
ok := store.Update("nonexistent", "new-code", func(s *Session) {})
|
||||
if ok {
|
||||
t.Error("expected update to fail for nonexistent session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionStore_Cleanup(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
|
||||
store := NewSessionStore(logger)
|
||||
|
||||
// Create an expired session
|
||||
session := &Session{Email: "test@example.com"}
|
||||
store.Create("code-123", session)
|
||||
|
||||
// Manually set CreatedAt to expired time
|
||||
store.mu.Lock()
|
||||
store.sessions["code-123"].CreatedAt = time.Now().Add(-10 * time.Minute)
|
||||
store.mu.Unlock()
|
||||
|
||||
// Create a valid session
|
||||
validSession := &Session{Email: "valid@example.com"}
|
||||
store.Create("code-456", validSession)
|
||||
|
||||
// Run cleanup
|
||||
cleaned := store.Cleanup()
|
||||
|
||||
if cleaned != 1 {
|
||||
t.Errorf("expected 1 session cleaned, got %d", cleaned)
|
||||
}
|
||||
|
||||
// Expired session should be gone
|
||||
_, ok := store.Get("code-123")
|
||||
if ok {
|
||||
t.Error("expected expired session to be cleaned up")
|
||||
}
|
||||
|
||||
// Valid session should still exist
|
||||
_, ok = store.Get("code-456")
|
||||
if !ok {
|
||||
t.Error("expected valid session to still exist")
|
||||
}
|
||||
}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// User represents a user in the system
|
||||
type User struct {
|
||||
Email string `json:"email"`
|
||||
GivenName string `json:"given_name"`
|
||||
FamilyName string `json:"family_name"`
|
||||
UserID string `json:"user_id"`
|
||||
Picture string `json:"picture,omitempty"`
|
||||
}
|
||||
|
||||
// UserStore provides thread-safe user storage
|
||||
type UserStore struct {
|
||||
mu sync.RWMutex
|
||||
users map[string]*User
|
||||
}
|
||||
|
||||
// NewUserStore creates a new user store
|
||||
func NewUserStore() *UserStore {
|
||||
return &UserStore{
|
||||
users: make(map[string]*User),
|
||||
}
|
||||
}
|
||||
|
||||
// LoadFromFile loads users from a JSON file
|
||||
func (s *UserStore) LoadFromFile(path string) error {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil // File doesn't exist, start with empty store
|
||||
}
|
||||
return fmt.Errorf("read users file: %w", err)
|
||||
}
|
||||
|
||||
var rawUsers map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &rawUsers); err != nil {
|
||||
return fmt.Errorf("parse users file: %w", err)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
for email, raw := range rawUsers {
|
||||
var user User
|
||||
if err := json.Unmarshal(raw, &user); err != nil {
|
||||
return fmt.Errorf("parse user %s: %w", email, err)
|
||||
}
|
||||
user.Email = email // Ensure email is set
|
||||
if user.UserID == "" {
|
||||
user.UserID = email
|
||||
}
|
||||
s.users[email] = &user
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetByEmail retrieves a user by email
|
||||
func (s *UserStore) GetByEmail(email string) (*User, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
user, ok := s.users[email]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
// Return a copy to prevent external modification
|
||||
userCopy := *user
|
||||
return &userCopy, true
|
||||
}
|
||||
|
||||
// Create adds a new user
|
||||
func (s *UserStore) Create(email string, user *User) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
user.Email = email
|
||||
if user.UserID == "" {
|
||||
user.UserID = email
|
||||
}
|
||||
s.users[email] = user
|
||||
}
|
||||
|
||||
// Update modifies an existing user
|
||||
func (s *UserStore) Update(email string, updates *User) (*User, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
existing, ok := s.users[email]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Apply updates (only non-empty fields)
|
||||
if updates.GivenName != "" {
|
||||
existing.GivenName = updates.GivenName
|
||||
}
|
||||
if updates.FamilyName != "" {
|
||||
existing.FamilyName = updates.FamilyName
|
||||
}
|
||||
if updates.Picture != "" {
|
||||
existing.Picture = updates.Picture
|
||||
}
|
||||
|
||||
// Return a copy
|
||||
userCopy := *existing
|
||||
return &userCopy, true
|
||||
}
|
||||
|
||||
// List returns all users
|
||||
func (s *UserStore) List() []*User {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
users := make([]*User, 0, len(s.users))
|
||||
for _, user := range s.users {
|
||||
userCopy := *user
|
||||
users = append(users, &userCopy)
|
||||
}
|
||||
return users
|
||||
}
|
||||
Reference in New Issue
Block a user