Compare commits

..

142 Commits

Author SHA1 Message Date
renovate f3f69beac9 chore(deps): update golang:1.26 docker digest to ec4debb (#291)
auth0mock / build (push) Successful in 54s
Release / release (push) Successful in 1m6s
2026-04-07 23:04:03 +00:00
renovate c98eae2794 chore(deps): update golang:1.26 docker digest to cd78d88 (#289)
Release / release (push) Successful in 54s
auth0mock / build (push) Successful in 2m48s
2026-04-07 20:06:46 +00:00
renovate d122aa5f14 chore(deps): update golang:1.26 docker digest to 5e69504 (#287)
Release / release (push) Successful in 1m4s
auth0mock / build (push) Successful in 53s
2026-04-07 09:03:24 +00:00
renovate bb89d9bb9e chore(deps): update golang:1.26 docker digest to 42ebbf7 (#286)
auth0mock / build (push) Successful in 2m23s
Release / release (push) Failing after 12m27s
2026-04-07 05:05:24 +00:00
renovate 993632f2eb fix(deps): update module github.com/alecthomas/kong to v1.15.0 (#285)
Release / release (push) Failing after 58s
auth0mock / build (push) Successful in 1m35s
2026-04-01 23:03:57 +00:00
renovate b9e5f5903f chore(deps): update golang:1.26 docker digest to 595c784 (#283)
auth0mock / build (push) Successful in 54s
Release / release (push) Successful in 59s
2026-03-21 09:03:25 +00:00
renovate 5cd4163d64 chore(deps): update golang:1.26 docker digest to c42e4d7 (#281)
auth0mock / build (push) Successful in 54s
Release / release (push) Successful in 1m9s
2026-03-17 12:05:50 +00:00
renovate e4c2854da2 chore(deps): update golang:1.26 docker digest to dd25c49 (#279)
Release / release (push) Failing after 1m0s
auth0mock / build (push) Successful in 1m40s
2026-03-17 09:05:21 +00:00
renovate 8638a7dd11 chore(deps): update golang:1.26 docker digest to 16e774b (#277)
Release / release (push) Successful in 1m16s
auth0mock / build (push) Successful in 1m40s
2026-03-17 06:02:58 +00:00
renovate 3362f4011c chore(deps): update golang:1.26 docker digest to 318ba17 (#276)
Release / release (push) Failing after 59s
auth0mock / build (push) Successful in 1m32s
2026-03-17 03:01:08 +00:00
renovate f636bb47c2 chore(deps): update golang:1.26 docker digest to c7e98cc (#274)
Release / release (push) Successful in 1m3s
auth0mock / build (push) Successful in 1m9s
2026-03-11 02:06:08 +00:00
renovate d43e568bc8 chore(deps): update golang:1.26 docker digest to cdebbd5 (#272)
Release / release (push) Successful in 1m10s
auth0mock / build (push) Successful in 1m18s
2026-03-11 00:03:02 +00:00
renovate 4fc0160a9a chore(deps): update golang:1.26 docker digest to e2ddb15 (#271)
Release / release (push) Failing after 1m8s
auth0mock / build (push) Successful in 1m15s
2026-03-06 03:06:37 +00:00
renovate 585582f6c6 chore(deps): update golang:1.26 docker digest to fb612b7 (#270)
Release / release (push) Failing after 57s
auth0mock / build (push) Successful in 1m37s
2026-03-03 05:06:54 +00:00
renovate 91f3e851bf chore(deps): update golang:1.26 docker digest to 9edf713 (#269)
Release / release (push) Failing after 1m32s
auth0mock / build (push) Successful in 1m57s
2026-02-25 09:03:12 +00:00
renovate a45128e63d chore(deps): update golang:1.26 docker digest to a9c4aac (#267)
Release / release (push) Successful in 1m32s
auth0mock / build (push) Successful in 2m11s
2026-02-25 06:02:51 +00:00
renovate 80def9e067 chore(deps): update golang:1.26 docker digest to b751797 (#265)
Release / release (push) Successful in 1m38s
auth0mock / build (push) Successful in 3m40s
2026-02-25 02:03:18 +00:00
renovate dd572eb484 chore(deps): update golang:1.26 docker digest to b39810f (#264)
Release / release (push) Failing after 1m31s
auth0mock / build (push) Successful in 4m1s
2026-02-24 23:17:43 +00:00
renovate 38bc47eb08 chore(deps): update gcr.io/distroless/static-debian12 docker digest to 20bc6c0 (#262)
Release / release (push) Successful in 42s
auth0mock / build (push) Successful in 2m34s
2026-02-14 02:06:42 +00:00
argoyle 1b6f57ae4b Merge pull request 'chore(release): prepare for 0.7.2' (#261) from next-release into main
Release / release (push) Successful in 44s
auth0mock / build (push) Successful in 51s
Reviewed-on: #261
2026-02-12 11:23:15 +00:00
releaser d245cabd0b chore(release): prepare for 0.7.2
auth0mock / build (pull_request) Successful in 53s
2026-02-12 11:22:14 +00:00
releaser 94d6cbadd6 chore(release): prepare for 0.7.2 2026-02-12 11:22:11 +00:00
argoyle 2704c2c796 Merge pull request 'chore: add git-cliff configuration for changelog generation' (#260) from add-cliff-toml into main
auth0mock / build (push) Successful in 58s
Release / release (push) Successful in 39s
Reviewed-on: #260
2026-02-12 11:20:57 +00:00
argoyle 8e1a68cac1 chore: add git-cliff configuration for changelog generation
auth0mock / build (pull_request) Successful in 1m10s
2026-02-12 12:19:22 +01:00
renovate 85fc909881 chore(deps): update golang:1.26 docker digest to c83e68f (#258)
auth0mock / build (push) Failing after 2s
Release / release (push) Successful in 52s
2026-02-11 01:06:40 +00:00
renovate 9a376aecd5 chore(deps): update golang docker tag to v1.26 (#257)
Release / release (push) Failing after 59s
auth0mock / build (push) Successful in 3m9s
2026-02-10 22:06:25 +00:00
renovate 537791be88 chore(deps): update golang:1.25 docker digest to cc73743 (#256)
Release / release (push) Successful in 50s
auth0mock / build (push) Successful in 3m18s
2026-02-08 05:09:24 +00:00
renovate 9b4ef0f34e fix(deps): update module github.com/alecthomas/kong to v1.14.0 (#255)
Release / release (push) Failing after 1m12s
auth0mock / build (push) Successful in 3m34s
2026-02-07 00:04:37 +00:00
renovate 3906f72567 chore(deps): update golang:1.25 docker digest to d2e5acc (#254)
Release / release (push) Successful in 1m34s
auth0mock / build (push) Successful in 2m43s
2026-02-04 18:43:33 +00:00
renovate 0f544525cc chore(deps): update golang:1.25 docker digest to 06d1251 (#253)
Release / release (push) Successful in 2m55s
auth0mock / build (push) Successful in 6m4s
2026-02-03 16:04:59 +00:00
renovate e64e777871 chore(deps): update golang:1.25 docker digest to 0c87ea6 (#252)
Release / release (push) Successful in 4m31s
auth0mock / build (push) Successful in 5m12s
2026-02-03 09:28:42 +00:00
renovate 962c8f39ab chore(deps): update golang:1.25 docker digest to 4c973c7 (#251)
Release / release (push) Successful in 4m26s
auth0mock / build (push) Successful in 5m23s
2026-02-03 08:04:39 +00:00
argoyle d363357809 Merge pull request 'chore: remove unused .gitlab-ci.yml' (#249) from remove-gitlab-ci into main
Release / release (push) Successful in 3m26s
auth0mock / build (push) Successful in 4m59s
Reviewed-on: #249
2026-01-23 14:56:33 +00:00
argoyle b88afe07f4 chore: remove unused .gitlab-ci.yml
auth0mock / build (pull_request) Successful in 4m33s
No longer needed after migration to Gitea Actions.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 14:34:07 +01:00
renovate cd7a26a7a8 chore(deps): update golang:1.25 docker digest to ce63a16 (#248)
Release / release (push) Successful in 1m24s
auth0mock / build (push) Successful in 3m19s
2026-01-19 22:04:11 +00:00
renovate 67f51d58d8 chore(deps): update golang:1.25 docker digest to bc45dfd (#247)
Release / release (push) Successful in 1m28s
auth0mock / build (push) Successful in 2m23s
2026-01-15 22:05:54 +00:00
renovate 24a29822cb chore(deps): update golang:1.25 docker digest to 8bbd140 (#246)
Release / release (push) Successful in 1m28s
auth0mock / build (push) Successful in 2m13s
2026-01-14 06:43:59 +00:00
renovate 5176a9f81c chore(deps): update golang:1.25 docker digest to 581c059 (#245)
Release / release (push) Successful in 34s
auth0mock / build (push) Successful in 57s
2026-01-13 19:02:23 +00:00
argoyle d8022f79d9 Merge pull request 'chore(release): prepare for 0.7.1' (#244) from next-release into main
Release / release (push) Successful in 46s
auth0mock / build (push) Successful in 1m5s
Reviewed-on: #244
2026-01-13 16:51:52 +00:00
releaser d70b97b415 chore(release): prepare for 0.7.1
auth0mock / build (pull_request) Successful in 1m3s
2026-01-13 16:35:49 +00:00
releaser c3b0c8d1a7 chore(release): prepare for 0.7.1 2026-01-13 16:35:44 +00:00
argoyle 7c650e7a65 Merge pull request 'fix: change CI tag pattern to match all tags' (#243) from fix/ci-tag-pattern into main
auth0mock / build (push) Successful in 2m21s
Release / release (push) Successful in 1m43s
Reviewed-on: #243
2026-01-13 16:32:57 +00:00
argoyle 17e661fb96 fix: change CI tag pattern to match all tags
auth0mock / build (pull_request) Successful in 1m15s
2026-01-13 17:20:50 +01:00
argoyle 9be6275047 Merge pull request 'chore(release): prepare for 0.7.0' (#240) from next-release into main
Release / release (push) Successful in 33s
auth0mock / build (push) Successful in 1m1s
Reviewed-on: #240
2026-01-13 15:03:15 +00:00
releaser 231e864b41 chore(release): prepare for 0.7.0
auth0mock / build (pull_request) Successful in 59s
2026-01-13 15:01:00 +00:00
releaser f6a58358c7 chore(release): prepare for 0.7.0 2026-01-13 15:00:49 +00:00
releaser 5ca6b9f528 chore(release): prepare for 0.6.1
auth0mock / build (pull_request) Successful in 1m7s
2026-01-13 14:57:44 +00:00
releaser 9945b98c6d chore(release): prepare for 0.6.1 2026-01-13 14:57:44 +00:00
releaser 8e4b6598fd chore(release): prepare for 0.6.1 2026-01-13 14:57:44 +00:00
argoyle 581e325b7f Merge pull request 'feat: add tag trigger for CI build and push' (#242) from feat/ci-build-on-tags into main
auth0mock / build (push) Successful in 1m4s
Release / release (push) Successful in 57s
Reviewed-on: #242
2026-01-13 14:56:18 +00:00
argoyle 0d7be1c47e feat: add tag trigger for CI build and push
auth0mock / build (pull_request) Successful in 1m11s
2026-01-13 15:50:09 +01:00
renovate 6c52b7b084 chore(deps): update golang:1.25 docker digest to 0f406d3 (#241)
Release / release (push) Failing after 1m32s
auth0mock / build (push) Successful in 5m53s
2026-01-13 07:09:07 +00:00
renovate 58c0e1f9d1 fix(deps): update module github.com/lestrrat-go/jwx/v3 to v3.0.13 (#239)
Release / release (push) Successful in 1m18s
auth0mock / build (push) Successful in 1m46s
2026-01-12 07:06:53 +00:00
renovate 34e19d6f74 chore(deps): update gcr.io/distroless/static-debian12 docker digest to cd64bec (#238)
Release / release (push) Successful in 49s
auth0mock / build (push) Successful in 1m14s
2026-01-11 22:03:16 +00:00
argoyle edef584a85 Merge pull request 'chore(release): prepare for 0.6.0' (#237) from next-release into main
Release / release (push) Successful in 30s
auth0mock / build (push) Successful in 2m17s
Reviewed-on: #237
2026-01-09 16:13:13 +00:00
releaser 28135f06d6 chore(release): prepare for 0.6.0
auth0mock / build (pull_request) Successful in 1m45s
2026-01-09 16:10:21 +00:00
releaser 5079c57a05 chore(release): prepare for 0.6.0 2026-01-09 16:10:15 +00:00
argoyle 241309537a Merge pull request 'feat: add release workflow using shared workflow' (#236) from feat/add-release-workflow into main
Release / release (push) Successful in 1m13s
auth0mock / build (push) Successful in 1m26s
Reviewed-on: #236
2026-01-09 16:09:17 +00:00
argoyle bcbe17f010 feat: add release workflow using shared workflow
auth0mock / build (pull_request) Successful in 2m4s
2026-01-09 16:56:10 +01:00
argoyle 0bf64d97bf Merge pull request 'fix: remove incorrect digest pinning from image reference' (#235) from fix/remove-digest-pinning into main
auth0mock / build (push) Successful in 6m39s
Reviewed-on: #235
2026-01-09 12:01:31 +00:00
argoyle 941b2b4158 fix: remove incorrect digest pinning from image reference
auth0mock / build (pull_request) Successful in 4m29s
2026-01-09 11:56:10 +01:00
renovate 6533f064d5 chore(deps): update oci.unbound.se/unboundsoftware/auth0mock docker digest to c9d60b4 (#234)
auth0mock / build (push) Successful in 2m3s
2026-01-09 09:01:47 +00:00
renovate 64e0405e68 chore(deps): pin dependencies (#233)
auth0mock / build (push) Successful in 5m27s
2026-01-09 08:01:09 +00:00
argoyle ec3ea75db5 Merge pull request 'chore(deps): update actions/checkout action to v6' (#232) from renovate/actions-checkout-6.x into main
auth0mock / build (push) Successful in 1m30s
Reviewed-on: #232
2026-01-08 15:06:22 +00:00
renovate ce6c0e3d93 chore(deps): update actions/checkout action to v6
auth0mock / build (pull_request) Successful in 1m3s
2026-01-08 15:00:48 +00:00
argoyle a93952408b Merge pull request 'feat: migrate from GitLab CI to Gitea Actions' (#231) from feat/gitea-migration into main
auth0mock / build (push) Successful in 2m33s
Reviewed-on: #231
2026-01-08 14:02:59 +00:00
argoyle 0db830b3b1 feat: migrate from GitLab CI to Gitea Actions
auth0mock / build (pull_request) Successful in 1m17s
2026-01-08 14:42:25 +01:00
argoyle 64cfb98016 Merge branch 'renovate/golang-1.25' into 'main'
chore(deps): update golang:1.25 docker digest to 6cc2338

See merge request unboundsoftware/auth0mock!225
2026-01-03 14:51:37 +01:00
Renovate 222b5aaafb chore(deps): update golang:1.25 docker digest to 6cc2338 2026-01-02 22:02:04 +00:00
argoyle 773c6acc1b Merge branch 'renovate/golang-1.25' into 'main'
chore(deps): update golang:1.25 docker digest to 31c1e53

See merge request unboundsoftware/auth0mock!224
2025-12-30 16:01:27 +01:00
Renovate be19c98a02 chore(deps): update golang:1.25 docker digest to 31c1e53 2025-12-30 13:01:43 +00:00
argoyle 4b04ca638f Merge branch 'next-release' into 'main'
chore(release): prepare for 0.5.1

See merge request unboundsoftware/auth0mock!223
2025-12-29 19:12:22 +01:00
Unbound Release f6171bb2c1 chore(release): prepare for 0.5.1 2025-12-29 19:12:22 +01:00
argoyle 84f3ce58b4 Merge branch 'renovate/github.com-lestrrat-go-jwx-v2-3.x' into 'main'
fix(deps): update module github.com/lestrrat-go/jwx/v2 to v3

See merge request unboundsoftware/auth0mock!222
2025-12-29 17:16:41 +01:00
Renovate 058c818246 fix(deps): update module github.com/lestrrat-go/jwx/v2 to v3 2025-12-29 17:15:10 +01:00
argoyle c951b8b2a6 Merge branch 'renovate/golang-1.x' into 'main'
chore(deps): update golang docker tag to v1.25

See merge request unboundsoftware/auth0mock!221
2025-12-29 17:05:37 +01:00
Renovate 20f643451b chore(deps): update golang docker tag to v1.25 2025-12-29 16:03:34 +00:00
argoyle 7dc063d57e Merge branch 'next-release' into 'main'
chore(release): prepare for 0.5.0

See merge request unboundsoftware/auth0mock!219
2025-12-29 16:54:06 +01:00
Unbound Release 0789e3e3fb chore(release): prepare for 0.5.0 2025-12-29 16:54:06 +01:00
argoyle 0e85cfff29 Merge branch 'migrate-auth0mock-node-go' into 'main'
feat: migrate auth0mock from Node.js to Go

See merge request unboundsoftware/auth0mock!218
2025-12-29 16:49:36 +01:00
argoyle 9992fb4ef1 feat: migrate auth0mock from Node.js to Go
Refactor the application to a Go-based architecture for improved
performance and maintainability. Replace the Dockerfile to utilize a
multi-stage build process, enhancing image efficiency. Implement
comprehensive session store tests to ensure reliability and create
new OAuth handlers for managing authentication efficiently. Update 
documentation to reflect these structural changes.
2025-12-29 16:30:37 +01:00
argoyle 96453e1d15 Merge branch 'next-release' into 'main'
chore(release): prepare for 0.4.0

See merge request unboundsoftware/auth0mock!217
2025-12-29 13:19:27 +01:00
Unbound Release fd4f9c4052 chore(release): prepare for 0.4.0 2025-12-29 13:19:26 +01:00
argoyle ce5fd95bed Merge branch 'feat/session-expiration-cleanup' into 'main'
feat(session-cleanup): implement session expiration cleanup

See merge request unboundsoftware/auth0mock!216
2025-12-29 13:17:53 +01:00
argoyle 972cf3ba45 feat(session-cleanup): implement session expiration cleanup
adds a cleanup mechanism for expired sessions to prevent memory leaks by 
deleting sessions and challenges that exceed the defined TTL. Each session 
is assigned a creation timestamp, which is updated upon activity, and the 
cleanup process runs every minute to maintain optimal memory usage.
2025-12-29 13:14:46 +01:00
argoyle 9d410abe54 Merge branch 'next-release' into 'main'
chore(release): prepare for 0.3.0

See merge request unboundsoftware/auth0mock!187
2025-12-28 20:21:31 +01:00
Unbound Release e740676de2 chore(release): prepare for 0.3.0 2025-12-28 20:21:30 +01:00
argoyle acb0568725 Merge branch 'increase-resource-limits-improve-checks' into 'main'
feat: increase resource limits and improve readiness checks

See merge request unboundsoftware/auth0mock!215
2025-12-28 15:29:58 +01:00
argoyle 6558d027ca feat: increase resource limits and improve readiness checks
Increases memory requests and limits in the Kubernetes deployment 
configuration to ensure better performance under load. Enhances 
the readiness and liveness probes by correcting the path and 
adding a liveness probe to improve service reliability. Updates 
Code by cleaning up session data after a successful token exchange 
and formats OpenID configuration response for better readability. 
Adds `.claude/` to .gitignore to prevent unwanted files from 
being tracked.
2025-12-28 15:25:52 +01:00
argoyle 17bad3a988 Merge branch 'renovate/node-24.x' into 'main'
chore(deps): update node.js to v24.12.0

See merge request unboundsoftware/auth0mock!214
2025-12-12 08:42:17 +01:00
Renovate d50387feb1 chore(deps): update node.js to v24.12.0 2025-12-11 20:03:44 +00:00
argoyle 083125a5bb Merge branch 'renovate/node-24.11.1' into 'main'
chore(deps): update node.js to aa57b08

See merge request unboundsoftware/auth0mock!213
2025-12-09 09:56:50 +01:00
Renovate 66b4fb90cd chore(deps): update node.js to aa57b08 2025-12-09 03:14:32 +00:00
argoyle 617812a044 Merge branch 'renovate/jsonwebtoken-9.x' into 'main'
fix(deps): update dependency jsonwebtoken to v9.0.3

See merge request unboundsoftware/auth0mock!212
2025-12-04 20:36:48 +01:00
Renovate cc3a402fe4 fix(deps): update dependency jsonwebtoken to v9.0.3 2025-12-04 11:02:02 +00:00
argoyle fc6882624f Merge branch 'renovate/prettier-3.x' into 'main'
chore(deps): update dependency prettier to v3.7.4

See merge request unboundsoftware/auth0mock!211
2025-12-03 06:11:44 +01:00
Renovate 3825ee0656 chore(deps): update dependency prettier to v3.7.4 2025-12-03 05:00:52 +00:00
argoyle 237fa7e381 Merge branch 'renovate/express-5.x' into 'main'
fix(deps): update dependency express to v5.2.1

See merge request unboundsoftware/auth0mock!210
2025-12-02 07:15:04 +01:00
Renovate b953439d0f fix(deps): update dependency express to v5.2.1 2025-12-01 21:01:15 +00:00
argoyle a74bd86fe2 Merge branch 'renovate/express-5.x' into 'main'
fix(deps): update dependency express to v5.2.0

See merge request unboundsoftware/auth0mock!209
2025-12-01 18:14:33 +01:00
Renovate f52ccf28bc fix(deps): update dependency express to v5.2.0 2025-12-01 17:02:42 +00:00
argoyle de61dda74e Merge branch 'renovate/prettier-3.x' into 'main'
chore(deps): update dependency prettier to v3.7.3

See merge request unboundsoftware/auth0mock!207
2025-11-30 18:48:33 +01:00
Renovate eb8bb78766 chore(deps): update dependency prettier to v3.7.3 2025-11-29 18:01:10 +00:00
argoyle f3ff720eb6 Merge branch 'renovate/prettier-3.x' into 'main'
chore(deps): update dependency prettier to v3.7.2

See merge request unboundsoftware/auth0mock!206
2025-11-29 15:02:24 +01:00
Renovate b5fde43240 chore(deps): update dependency prettier to v3.7.2 2025-11-28 21:01:53 +00:00
argoyle 3bca5a318e Merge branch 'renovate/prettier-3.x' into 'main'
chore(deps): update dependency prettier to v3.7.1

See merge request unboundsoftware/auth0mock!205
2025-11-28 08:36:32 +01:00
Renovate 22dbf00182 chore(deps): update dependency prettier to v3.7.1 2025-11-27 12:01:41 +00:00
argoyle d1af6008fd Merge branch 'renovate/prettier-3.x' into 'main'
chore(deps): update dependency prettier to v3.7.0

See merge request unboundsoftware/auth0mock!204
2025-11-27 11:38:22 +01:00
Renovate bd54fd68f8 chore(deps): update dependency prettier to v3.7.0 2025-11-27 09:02:26 +00:00
argoyle 75035ae716 Merge branch 'renovate/body-parser-2.x' into 'main'
fix(deps): update dependency body-parser to v2.2.1

See merge request unboundsoftware/auth0mock!203
2025-11-24 20:43:07 +01:00
Renovate 933dd886ff fix(deps): update dependency body-parser to v2.2.1 2025-11-24 19:03:15 +00:00
argoyle ad85ce8347 Merge branch 'renovate/node-24.11.1' into 'main'
chore(deps): update node.js to 11a2e11

See merge request unboundsoftware/auth0mock!202
2025-11-18 21:09:57 +01:00
Renovate 361a2d23bc chore(deps): update node.js to 11a2e11 2025-11-18 16:04:22 +00:00
argoyle 1b656ce2f9 Merge branch 'renovate/node-24.x' into 'main'
chore(deps): update node.js to v24.11.1

See merge request unboundsoftware/auth0mock!201
2025-11-13 08:59:56 +01:00
Renovate eba28529a6 chore(deps): update node.js to v24.11.1 2025-11-12 20:14:42 +00:00
argoyle e8155bfd0a Merge branch 'renovate/nodemon-3.x' into 'main'
fix(deps): update dependency nodemon to v3.1.11

See merge request unboundsoftware/auth0mock!200
2025-11-11 21:12:48 +01:00
Renovate 7b8dfb2cff fix(deps): update dependency nodemon to v3.1.11 2025-11-11 17:03:12 +00:00
argoyle a5f091efa6 Merge branch 'renovate/node-24.11.0' into 'main'
chore(deps): update node.js to 0601cd0

See merge request unboundsoftware/auth0mock!199
2025-11-04 14:13:50 +01:00
Renovate df48b03d01 chore(deps): update node.js to 0601cd0 2025-11-04 12:06:17 +00:00
argoyle 98e8a2abb6 Merge branch 'renovate/node-24.x' into 'main'
chore(deps): update node.js

See merge request unboundsoftware/auth0mock!198
2025-10-29 07:38:41 +01:00
Renovate 854aef9b8d chore(deps): update node.js 2025-10-28 23:11:20 +00:00
argoyle 3f2e1b588f Merge branch 'renovate/node-24.x' into 'main'
chore(deps): update node.js to v24

See merge request unboundsoftware/auth0mock!197
2025-10-28 07:12:00 +01:00
Renovate f573a3a60c chore(deps): update node.js to v24 2025-10-28 00:11:34 +00:00
argoyle ee0e44671c Merge branch 'renovate/node-22.x' into 'main'
chore(deps): update node.js to v22.21.0

See merge request unboundsoftware/auth0mock!196
2025-10-22 11:00:30 +02:00
Renovate 75e5abe166 chore(deps): update node.js to v22.21.0 2025-10-21 22:00:54 +00:00
argoyle 3c5127d3c0 Merge branch 'renovate/node-22.20.0' into 'main'
chore(deps): update node.js to 1260a4a

See merge request unboundsoftware/auth0mock!195
2025-10-21 20:11:45 +02:00
Renovate 3aef5e33fa chore(deps): update node.js to 1260a4a 2025-10-21 13:03:32 +00:00
argoyle e2b0c6422e Merge branch 'renovate/node-22.20.0' into 'main'
chore(deps): update node.js to 4b5601e

See merge request unboundsoftware/auth0mock!193
2025-10-01 19:22:37 +02:00
Renovate 7f9320dff2 chore(deps): update node.js to 4b5601e 2025-10-01 14:05:23 +00:00
argoyle bfa14ce1bd Merge branch 'renovate/node-22.x' into 'main'
chore(deps): update node.js to v22.20.0

See merge request unboundsoftware/auth0mock!192
2025-09-25 08:00:39 +02:00
Renovate 950a706bb6 chore(deps): update node.js to v22.20.0 2025-09-25 01:12:26 +00:00
argoyle 7e7828777d Merge branch 'renovate/debug-4.x' into 'main'
fix(deps): update dependency debug to v4.4.3

See merge request unboundsoftware/auth0mock!190
2025-09-14 12:28:30 +02:00
Renovate 1f6c92b497 fix(deps): update dependency debug to v4.4.3 2025-09-13 18:02:31 +00:00
Renovate b6c2971563 chore(deps): update node.js to 2e68a73 2025-09-09 07:00:33 +00:00
argoyle 6d0327b01d fix: downgrade debug package to version 4.4.1
Update the `debug` dependency to version 4.4.1 in both 
`package.json` and `yarn.lock` to resolve compatibility issues 
and ensure stability in the application.
2025-09-09 08:44:28 +02:00
Renovate 46efd38035 fix(deps): update dependency debug to v4.4.2 2025-09-08 14:01:12 +00:00
Renovate bdb9fbd405 chore(deps): update node.js to v22.19.0 2025-08-29 01:11:10 +00:00
Renovate 68851ed10f chore(deps): update node.js to da46023 2025-08-13 00:01:44 +00:00
Renovate f20913a0ba chore(deps): update node.js to v22.18.0 2025-08-04 10:00:30 +00:00
Renovate 338528f219 chore(deps): update node.js to f17eb88 2025-07-22 06:00:00 +00:00
Renovate 172174bd3e chore(deps): update node.js to v22.17.1 2025-07-16 18:59:59 +00:00
Renovate e399262daa chore(deps): update node.js to 40c53e3 2025-07-07 21:57:42 +00:00
32 changed files with 2419 additions and 1970 deletions
+20
View File
@@ -0,0 +1,20 @@
name: auth0mock
on:
push:
branches: [main]
tags: ['*']
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
+9
View File
@@ -0,0 +1,9 @@
name: Release
on:
push:
branches: [main]
jobs:
release:
uses: unboundsoftware/shared-workflows/.gitea/workflows/Release.yml@main
+12 -1
View File
@@ -1,2 +1,13 @@
node_modules/
# IDE
.idea/
.vscode/
# Claude
.claude/
# Go
auth0mock
*.exe
*.test
*.out
coverage.txt
-17
View File
@@ -1,17 +0,0 @@
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
image: buildtool/build-tools:${BUILDTOOLS_VERSION}
build:
stage: build
script:
- build
- push
-1
View File
@@ -1 +0,0 @@
22
-2
View File
@@ -1,2 +0,0 @@
*.yaml
*.yml
-9
View File
@@ -1,9 +0,0 @@
{
"semi": false,
"singleQuote": true,
"trailingComma": "none",
"arrowParens": "always",
"quoteProps": "as-needed",
"bracketSpacing": true,
"bracketSameLine": false
}
+3
View File
@@ -0,0 +1,3 @@
{
"version": "0.7.2"
}
+263 -22
View File
@@ -2,6 +2,133 @@
All notable changes to this project will be documented in this file.
## [0.7.2] - 2026-02-12
### 🐛 Bug Fixes
- *(deps)* Update module github.com/alecthomas/kong to v1.14.0 (#255)
### ⚙️ Miscellaneous Tasks
- *(deps)* Update golang:1.25 docker digest to 581c059 (#245)
- *(deps)* Update golang:1.25 docker digest to 8bbd140 (#246)
- *(deps)* Update golang:1.25 docker digest to bc45dfd (#247)
- *(deps)* Update golang:1.25 docker digest to ce63a16 (#248)
- Remove unused .gitlab-ci.yml
- *(deps)* Update golang:1.25 docker digest to 4c973c7 (#251)
- *(deps)* Update golang:1.25 docker digest to 0c87ea6 (#252)
- *(deps)* Update golang:1.25 docker digest to 06d1251 (#253)
- *(deps)* Update golang:1.25 docker digest to d2e5acc (#254)
- *(deps)* Update golang:1.25 docker digest to cc73743 (#256)
- *(deps)* Update golang docker tag to v1.26 (#257)
- *(deps)* Update golang:1.26 docker digest to c83e68f (#258)
- Add git-cliff configuration for changelog generation
## [0.7.1] - 2026-01-13
### 🐛 Bug Fixes
- Change CI tag pattern to match all tags
## [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)
### ⚙️ Miscellaneous Tasks
- *(deps)* Update gcr.io/distroless/static-debian12 docker digest to cd64bec (#238)
- *(deps)* Update golang:1.25 docker digest to 0f406d3 (#241)
## [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
### ⚙️ Miscellaneous Tasks
- *(deps)* Update golang:1.25 docker digest to 31c1e53
- *(deps)* Update golang:1.25 docker digest to 6cc2338
- *(deps)* Update actions/checkout action to v6
- *(deps)* Pin dependencies (#233)
- *(deps)* Update oci.unbound.se/unboundsoftware/auth0mock docker digest to c9d60b4 (#234)
## [0.5.1] - 2025-12-29
### 🐛 Bug Fixes
- *(deps)* Update module github.com/lestrrat-go/jwx/v2 to v3
### ⚙️ Miscellaneous Tasks
- *(deps)* Update golang docker tag to v1.25
## [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
### ⚙️ Miscellaneous Tasks
- *(deps)* Update node.js to 40c53e3
- *(deps)* Update node.js to v22.17.1
- *(deps)* Update node.js to f17eb88
- *(deps)* Update node.js to v22.18.0
- *(deps)* Update node.js to da46023
- *(deps)* Update node.js to v22.19.0
- *(deps)* Update node.js to 2e68a73
- *(deps)* Update node.js to v22.20.0
- *(deps)* Update node.js to 4b5601e
- *(deps)* Update node.js to 1260a4a
- *(deps)* Update node.js to v22.21.0
- *(deps)* Update node.js to v24
- *(deps)* Update node.js
- *(deps)* Update node.js to 0601cd0
- *(deps)* Update node.js to v24.11.1
- *(deps)* Update node.js to 11a2e11
- *(deps)* Update dependency prettier to v3.7.0
- *(deps)* Update dependency prettier to v3.7.1
- *(deps)* Update dependency prettier to v3.7.2
- *(deps)* Update dependency prettier to v3.7.3
- *(deps)* Update dependency prettier to v3.7.4
- *(deps)* Update node.js to aa57b08
- *(deps)* Update node.js to v24.12.0
## [0.2.0] - 2025-06-29
### 🚀 Features
@@ -15,6 +142,19 @@ All notable changes to this project will be documented in this file.
- *(deps)* Update dependency serve-favicon to v2.5.1
- *(k8s)* Update ingress configuration for backend service
### ⚙️ Miscellaneous Tasks
- *(deps)* Update node.js to 4c7ba01
- *(deps)* Update node.js to v22.15.0
- *(deps)* Update node.js to ab3dc40
- *(deps)* Update node.js to v22.15.1
- *(deps)* Update node.js to v22.16.0
- *(deps)* Update node.js to 8d23574
- *(deps)* Update dependency prettier to v3.6.0
- *(deps)* Update dependency prettier to v3.6.1
- *(deps)* Update node.js to v22.17.0
- *(deps)* Update dependency prettier to v3.6.2
## [0.1.5] - 2025-04-03
### 🐛 Bug Fixes
@@ -25,7 +165,21 @@ All notable changes to this project will be documented in this file.
### ⚙️ Miscellaneous Tasks
- *(deps)* Update node.js to debe7ff
- *(deps)* Update node.js to 469d57f
- *(deps)* Update node.js to 3962f5a
- *(deps)* Update node.js to 5145c88
- *(deps)* Update dependency prettier to v3.5.0
- *(deps)* Update dependency prettier to v3.5.1
- *(deps)* Update node.js to 7c6b02a
- *(deps)* Update node.js to cfef443
- *(deps)* Update dependency prettier to v3.5.2
- *(deps)* Update node.js to a279671
- *(deps)* Update node.js to c3ef15a
- *(deps)* Update node.js to 2094ac6
- *(Dockerfile)* Update Node.js base image version
- *(deps)* Update dependency prettier to v3.5.3
- *(deps)* Update node.js to fab5fee
## [0.1.4] - 2025-01-24
@@ -33,6 +187,15 @@ All notable changes to this project will be documented in this file.
- *(k8s)* Update labels to adhere to best practices
### ⚙️ Miscellaneous Tasks
- *(deps)* Update node.js to 0e910f4
- *(deps)* Update node.js to 99981c3
- *(deps)* Update node.js to 4f7fb7f
- *(deps)* Update node.js to d77c645
- *(deps)* Update node.js to fa54405
- *(deps)* Update node.js to ae2f3d4
## [0.1.3] - 2024-12-18
### 🐛 Bug Fixes
@@ -48,7 +211,21 @@ All notable changes to this project will be documented in this file.
### ⚙️ Miscellaneous Tasks
- *(deps)* Update node.js to v22
- *(deps)* Update dependency node to v22
- Update renovate configuration to disable auth0mock updates
- *(deps)* Update node.js to f496dba
- *(deps)* Update node.js to db556c2
- *(deps)* Update node.js to f1f8564
- *(deps)* Update node.js to 6eb1af3
- *(deps)* Update node.js to 5c76d05
- *(deps)* Update dependency prettier to v3.4.0
- *(deps)* Update dependency prettier to v3.4.1
- *(deps)* Update node.js to cb24453
- *(deps)* Update node.js to fd453a2
- *(deps)* Update node.js to e605a19
- *(deps)* Update node.js to 35a5dd7
- *(deps)* Update dependency prettier to v3.4.2
- Remove Docker service from build stage configuration
## [0.1.2] - 2024-10-19
@@ -60,6 +237,7 @@ All notable changes to this project will be documented in this file.
### ⚙️ Miscellaneous Tasks
- *(deps)* Update dependency ingress to networking.k8s.io/v1
- Update Dockerfile to remove warnings
- Support issuer in openid-configuration
@@ -83,6 +261,10 @@ All notable changes to this project will be documented in this file.
### ⚙️ Miscellaneous Tasks
- *(deps)* Update dependency prettier to v3.3.0
- *(deps)* Update dependency prettier to v3.3.1
- *(deps)* Update dependency prettier to v3.3.2
- *(deps)* Update dependency prettier to v3.3.3
- Add release flow
## [0.1.0] - 2024-04-08
@@ -106,6 +288,28 @@ All notable changes to this project will be documented in this file.
- *(deps)* Update dependency express to v4.18.3
### ⚙️ Miscellaneous Tasks
- *(deps)* Bump jsonwebtoken from 9.0.0 to 9.0.1
- *(deps-dev)* Bump prettier from 2.8.8 to 3.0.0
- *(deps)* Bump nodemon from 2.0.22 to 3.0.0
- *(deps)* Bump nodemon from 3.0.0 to 3.0.1
- *(deps-dev)* Bump prettier from 3.0.0 to 3.0.1
- *(deps-dev)* Bump prettier from 3.0.1 to 3.0.2
- *(deps-dev)* Bump prettier from 3.0.2 to 3.0.3
- *(deps)* Bump jsonwebtoken from 9.0.1 to 9.0.2
- *(deps-dev)* Bump prettier from 3.0.3 to 3.1.0
- *(deps)* Bump nodemon from 3.0.1 to 3.0.2
- *(deps-dev)* Bump prettier from 3.1.0 to 3.1.1
- *(deps-dev)* Bump prettier from 3.1.1 to 3.2.1
- *(deps-dev)* Bump prettier from 3.2.1 to 3.2.2
- *(deps)* Bump nodemon from 3.0.2 to 3.0.3
- *(deps-dev)* Bump prettier from 3.2.2 to 3.2.3
- *(deps-dev)* Bump prettier from 3.2.3 to 3.2.4
- *(deps-dev)* Bump prettier from 3.2.4 to 3.2.5
- *(deps)* Bump nodemon from 3.0.3 to 3.1.0
- *(deps)* Update dependency node to v20
## [0.0.16] - 2023-06-01
### 🚀 Features
@@ -130,6 +334,15 @@ All notable changes to this project will be documented in this file.
- Add name and email to id token
### ⚙️ Miscellaneous Tasks
- *(deps-dev)* Bump prettier from 2.8.4 to 2.8.5
- *(deps-dev)* Bump prettier from 2.8.5 to 2.8.6
- *(deps)* Bump nodemon from 2.0.21 to 2.0.22
- *(deps-dev)* Bump prettier from 2.8.6 to 2.8.7
- *(deps-dev)* Bump prettier from 2.8.7 to 2.8.8
- *(deps)* Bump node from 18 to 20
## [0.0.12] - 2023-03-10
### 🐛 Bug Fixes
@@ -147,8 +360,27 @@ All notable changes to this project will be documented in this file.
- Use Docker DinD version from variable
- Change Dependabot rebase strategy
- *(deps)* Bump body-parser from 1.20.1 to 1.20.2
- *(deps)* Bump nodemon from 2.0.20 to 2.0.21
- Format code and add prettier
## [0.0.10] - 2022-12-22
### ⚙️ Miscellaneous Tasks
- *(deps)* Bump express from 4.18.0 to 4.18.1
- *(deps)* Bump nodemon from 2.0.15 to 2.0.16
- *(deps)* [security] bump ansi-regex from 4.1.0 to 4.1.1
- *(deps)* [security] bump minimist from 1.2.0 to 1.2.6
- *(deps)* [security] bump ini from 1.3.5 to 1.3.8
- *(deps)* [security] bump normalize-url from 4.5.0 to 4.5.1
- *(deps)* Bump nodemon from 2.0.16 to 2.0.18
- *(deps)* Bump nodemon from 2.0.18 to 2.0.19
- *(deps)* Bump nodemon from 2.0.19 to 2.0.20
- *(deps)* Bump body-parser from 1.20.0 to 1.20.1
- *(deps)* Bump express from 4.18.1 to 4.18.2
- *(deps)* Bump jsonwebtoken from 8.5.1 to 9.0.0
## [0.0.9] - 2022-04-28
### 🚀 Features
@@ -173,14 +405,14 @@ All notable changes to this project will be documented in this file.
- Make sure thumbPrint is a string
### 💼 Other
- *(deps)* Bump express from 4.17.3 to 4.18.0
### ⚙️ Miscellaneous Tasks
- Format code
### Chore
- *(deps)* Bump express from 4.17.3 to 4.18.0
## [0.0.5] - 2022-04-26
### 🐛 Bug Fixes
@@ -201,14 +433,14 @@ All notable changes to this project will be documented in this file.
## [0.0.2] - 2022-04-25
### 💼 Other
- *(deps)* Bump node from 17 to 18
### ⚙️ Miscellaneous Tasks
- Change admin-handling
### Chore
- *(deps)* Bump node from 17 to 18
## [0.0.1] - 2022-04-19
### 🚀 Features
@@ -225,7 +457,29 @@ All notable changes to this project will be documented in this file.
- Package.json & yarn.lock to reduce vulnerabilities
- Pipeline
### 💼 Other
### ⚙️ 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
- *(deps)* Bump node from 12 to 16
- *(deps)* Bump base64-url from 2.2.1 to 2.3.3
- *(deps)* Bump buffer from 5.2.1 to 6.0.3
- *(deps)* Bump debug from 2.6.9 to 4.3.2
- *(deps)* Bump https-localhost from 4.1.1 to 4.7.0
- *(deps)* Bump node-rsa from 1.0.5 to 1.1.1
- *(deps)* Bump cookie-parser from 1.4.4 to 1.4.5
- *(deps)* Bump nodemon from 2.0.3 to 2.0.14
- *(deps)* Bump node from 16 to 17
- Remove dependabot-standalone
- Cleanup and remove acctest triggering
### Chore
- *(deps)* Bump nodemon from 2.0.14 to 2.0.15
- *(deps)* Bump cookie-parser from 1.4.5 to 1.4.6
@@ -244,17 +498,4 @@ All notable changes to this project will be documented in this file.
- *(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
<!-- generated by git-cliff -->
+115
View File
@@ -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.
+24 -10
View File
@@ -1,12 +1,26 @@
FROM amd64/node:22.17.0@sha256:91cf1cc511d84f34d2c4763ffaea29f9e2f19c1da662740cbbbaf08b1d025eca
FROM golang:1.26@sha256:ec4debba7b371fb2eaa6169a72fc61ad93b9be6a9ae9da2a010cb81a760d36e7 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:20bc6c0bc4d625a22a8fde3e55f6515709b32055ef8fb9cfbddaa06d1760f838
ENV TZ=Europe/Stockholm
ENV AUDIENCE="https://shiny.unbound.se"
ENV ORIGIN_HOST="auth0mock"
ENV ORIGIN="https://auth0mock:3333"
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 *.js /app/
ADD public /app/public
RUN mkdir -p /root/.config
ENTRYPOINT ["yarn", "start"]
ENTRYPOINT ["/service"]
-446
View File
@@ -1,446 +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 jose = require('node-jose');
const favicon = require('serve-favicon')
const initialUsers = require('./users')
const issuer = process.env.ISSUER || 'localhost:3333'
const jwksOrigin = `https://${issuer}/`
const audience = process.env.AUDIENCE || 'https://generic-audience'
const adminCustomClaim =
process.env.ADMIN_CUSTOM_CLAIM || 'https://unbound.se/admin'
const emailCustomClaim =
process.env.EMAIL_CUSTOM_CLAIM || 'https://unbound.se/email'
const debug = Debug('app')
const keyStore = jose.JWK.createKeyStore()
keyStore.generate('RSA', 2048, { alg: 'RS256', use: 'sig' })
// let { privateKey, certDer, thumbprint, exponent, modulus } = cert(jwksOrigin)
const users = initialUsers(process.env.USERS_FILE || './users.json')
const sessions = {}
const challenges = {}
const corsOpts = (req, cb) => {
cb(null, { origin: req.headers.origin })
}
const addCustomClaims = (email, customClaims, token) => {
const emailClaim = {}
emailClaim[emailCustomClaim] = email
return [...customClaims, emailClaim].reduce((acc, claim) => {
return {
...acc,
...claim
}
}, token)
}
const signToken = async (token) => {
const [key] = keyStore.all({ use: 'sig' })
const opt = { compact: true, jwk: key, fields: { typ: 'jwt' } }
return await jose.JWS.createSign(opt, key)
.update(JSON.stringify(token))
.final()
}
// Configure our small auth0-mock-server
app
.options('*all', cors(corsOpts))
.use(cors())
.use(bodyParser.json({ strict: false }))
.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', async (req, res) => {
const date = Math.floor(Date.now() / 1000)
if (req.body.grant_type === 'client_credentials' && req.body.client_id) {
const accessToken = await signToken({
iss: jwksOrigin,
aud: [audience],
sub: 'auth0|management',
iat: date,
exp: date + 7200,
azp: req.body.client_id
})
const idToken = await signToken({
iss: jwksOrigin,
aud: req.body.client_id,
sub: 'auth0|management',
iat: date,
exp: date + 7200,
azp: req.body.client_id,
name: 'Management API'
})
debug('Signed token for management API')
res.json({
access_token: accessToken,
id_token: idToken,
scope: 'openid%20profile%20email',
expires_in: 7200,
token_type: 'Bearer'
})
} else if (req.body.code) {
const code = req.body.code
const session = sessions[code]
const accessToken = await signToken(
addCustomClaims(session.email, session.customClaims, {
iss: jwksOrigin,
aud: [audience],
sub: 'auth0|' + session.email,
iat: date,
exp: date + 7200,
azp: session.clientId
})
)
const idToken = await signToken(
addCustomClaims(session.email, session.customClaims, {
iss: jwksOrigin,
aud: session.clientId,
nonce: session.nonce,
sub: 'auth0|' + session.email,
iat: date,
exp: date + 7200,
azp: session.clientId,
name: 'Example Person',
given_name: 'Example',
family_name: 'Person',
email: session.email,
picture:
'https://cdn.playbuzz.com/cdn/5458360f-32ea-460e-a707-1a2d26760558/70bda687-cb84-4756-8a44-8cf735ed87b3.jpg'
})
)
debug('Signed token for ' + session.email)
res.json({
access_token: accessToken,
id_token: idToken,
scope: 'openid%20profile%20email',
expires_in: 7200,
token_type: 'Bearer'
})
} else {
res.status(401)
res.send('Missing client_id or client_secret')
}
})
// 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
const claim = {}
claim[adminCustomClaim] = req.body.admin === 'true'
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,
customClaims: [claim]
}
res.redirect(
`${req.body.redirect}?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 (responseMode === 'query') {
const code = req.cookies['auth0']
const session = sessions[code]
if (session) {
session.nonce = nonce
session.state = state
session.codeChallenge = codeChallenge
sessions[codeChallenge] = session
res.redirect(`${redirect}?code=${codeChallenge}&state=${state}`)
return
}
}
if (prompt === 'none' && responseMode === 'web_message') {
const code = req.cookies['auth0']
const session = sessions[code]
if (session) {
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>`)
return
}
}
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) => {
const code = req.cookies['auth0']
const session = sessions[code]
if (session) {
delete sessions[code]
}
res.redirect(req.query.returnTo)
})
app.get('/.well-known/openid-configuration', (req, res) => {
debug('Fetching OpenID configuration')
res.contentType('application/json').send(
JSON.stringify({
"issuer":
`${jwksOrigin}`,
"authorization_endpoint":
`${jwksOrigin}authorize`,
"token_endpoint":
`${jwksOrigin}oauth/token`,
"token_endpoint_auth_methods_supported":
["client_secret_basic", "private_key_jwt"],
"token_endpoint_auth_signing_alg_values_supported":
["RS256"],
"userinfo_endpoint":
`${jwksOrigin}userinfo`,
"check_session_iframe":
`${jwksOrigin}check_session`,
"end_session_endpoint":
`${jwksOrigin}end_session`,
"jwks_uri":
`${jwksOrigin}.well-known/jwks.json`,
"registration_endpoint":
`${jwksOrigin}register`,
"scopes_supported":
["openid", "profile", "email", "address",
"phone", "offline_access"],
"response_types_supported":
["code", "code id_token", "id_token", "id_token token"],
"acr_values_supported":
[],
"subject_types_supported":
["public", "pairwise"],
"userinfo_signing_alg_values_supported":
["RS256", "ES256", "HS256"],
"userinfo_encryption_alg_values_supported":
["RSA-OAEP-256", "A128KW"],
"userinfo_encryption_enc_values_supported":
["A128CBC-HS256", "A128GCM"],
"id_token_signing_alg_values_supported":
["RS256", "ES256", "HS256"],
"id_token_encryption_alg_values_supported":
["RSA-OAEP-256", "A128KW"],
"id_token_encryption_enc_values_supported":
["A128CBC-HS256", "A128GCM"],
"request_object_signing_alg_values_supported":
["none", "RS256", "ES256"],
"display_values_supported":
["page", "popup"],
"claim_types_supported":
["normal", "distributed"],
"claims_supported":
["sub", "iss", "auth_time", "acr",
"name", "given_name", "family_name", "nickname",
"profile", "picture", "website",
"email", "email_verified", "locale", "zoneinfo",
"https://unbound.se/email", "https://unbound.se/admin"],
"claims_parameter_supported":
true,
"service_documentation":
"http://auth0/",
"ui_locales_supported":
["en-US"]
})
)
})
app.get('/.well-known/jwks.json', (req, res) => {
debug('Fetching JWKS')
res.contentType('application/json').send(keyStore.toJSON())
})
// 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.get('/api/v2/users-by-email', (req, res) => {
const email = req.query.email
console.log('users', users)
const user = users[email]
if (user === undefined) {
res.json([])
} else {
res.json([user])
}
})
app.patch('/api/v2/users/:userid', (req, res) => {
const email = req.params.userid.slice(6)
console.log('patching user with id', email)
const user = users[email]
if (!user) {
res.sendStatus(404)
return
}
users[email] = {
email: email,
given_name: req.body.given_name || user.given_name,
family_name: req.body.family_name || user.family_name,
user_id: email,
picture: req.body.picture || user.picture
}
res.json({
user_id: `auth0|${email}`
})
})
app.post('/api/v2/users', (req, res) => {
const email = req.body.email
users[email] = {
email: email,
given_name: 'Given',
family_name: 'Last',
user_id: email
}
res.json({
user_id: `auth0|${email}`
})
})
app.post('/api/v2/tickets/password-change', (req, res) => {
res.json({
ticket: `https://some-url`
})
})
app.use(function (req, res, next) {
console.log('404', req.path)
res.status(404).send('error: 404 Not Found ' + req.path)
})
app.listen(3333, () => {
debug('Auth0-Mock-Server listening on port 3333!')
})
+212
View File
@@ -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
}
+151
View File
@@ -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"])
}
}
+49
View File
@@ -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, "=")
}
+74
View File
@@ -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")
}
}
+80
View File
@@ -0,0 +1,80 @@
# git-cliff ~ default configuration file
# https://git-cliff.org/docs/configuration
#
# Lines starting with "#" are comments.
# Configuration options are organized into tables and keys.
# See documentation for more information on available options.
[changelog]
# template for the changelog header
header = """
# Changelog\n
All notable changes to this project will be documented in this file.\n
"""
# template for the changelog body
# https://keats.github.io/tera/docs/#introduction
body = """
{% if version %}\
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
{% else %}\
## [unreleased]
{% endif %}\
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group | striptags | trim | upper_first }}
{% for commit in commits %}
- {% if commit.scope %}*({{ commit.scope }})* {% endif %}\
{% if commit.breaking %}[**breaking**] {% endif %}\
{{ commit.message | upper_first }}\
{% endfor %}
{% endfor %}\n
"""
# template for the changelog footer
footer = """
<!-- generated by git-cliff -->
"""
# remove the leading and trailing s
trim = true
# postprocessors
postprocessors = [
# { pattern = '<REPO>', replace = "https://github.com/orhun/git-cliff" }, # replace repository URL
]
# render body even when there are no releases to process
# render_always = true
# output file path
# output = "test.md"
[git]
# parse the commits based on https://www.conventionalcommits.org
conventional_commits = true
# filter out the commits that are not conventional
filter_unconventional = true
# process each line of a commit as an individual commit
split_commits = false
# regex for preprocessing the commit messages
commit_preprocessors = [
# Replace issue numbers
#{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](<REPO>/issues/${2}))"},
# Check spelling of the commit with https://github.com/crate-ci/typos
# If the spelling is incorrect, it will be automatically fixed.
#{ pattern = '.*', replace_command = 'typos --write-changes -' },
]
# regex for parsing and grouping commits
commit_parsers = [
{ message = "^feat", group = "<!-- 0 -->🚀 Features" },
{ message = "^fix", group = "<!-- 1 -->🐛 Bug Fixes" },
{ message = "^doc", group = "<!-- 3 -->📚 Documentation" },
{ message = "^perf", group = "<!-- 4 -->⚡ Performance" },
{ message = "^refactor", group = "<!-- 2 -->🚜 Refactor" },
{ message = "^style", group = "<!-- 5 -->🎨 Styling" },
{ message = "^test", group = "<!-- 6 -->🧪 Testing" },
{ message = "^chore\\(release\\): prepare for", skip = true },
{ message = "^chore|^ci", group = "<!-- 7 -->⚙️ Miscellaneous Tasks" },
{ body = ".*security", group = "<!-- 8 -->🛡️ Security" },
{ message = "^revert", group = "<!-- 9 -->◀️ Revert" },
]
# filter out the commits that are not matched by commit parsers
filter_commits = false
# sort the tags topologically
topo_order = false
# sort the commits inside sections by oldest/newest order
sort_commits = "oldest"
+194
View File
@@ -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)
}
+25
View File
@@ -0,0 +1,25 @@
module gitlab.com/unboundsoftware/auth0mock
go 1.24.0
require (
github.com/alecthomas/kong v1.15.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
)
+51
View File
@@ -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.15.0 h1:BVJstKbpO73zKpmIu+m/aLRrNmWwxXPIGTNin9VmLVI=
github.com/alecthomas/kong v1.15.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=
+77
View File
@@ -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)
}
+154
View File
@@ -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",
})
}
+337
View File
@@ -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)
}
}
+97
View File
@@ -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"))
}
+40
View File
@@ -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>
+10 -4
View File
@@ -14,13 +14,13 @@ spec:
spec:
containers:
- name: auth0mock
image: registry.gitlab.com/unboundsoftware/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,7 +29,13 @@ 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
-31
View File
@@ -1,31 +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",
"lint:prettier": "prettier --check .",
"lint": "yarn lint:prettier",
"lintfix": "prettier --write --list-different ."
},
"author": "",
"license": "MIT",
"dependencies": {
"body-parser": "2.2.0",
"cookie-parser": "1.4.7",
"cors": "2.8.5",
"debug": "4.4.1",
"express": "5.1.0",
"https-localhost": "4.7.1",
"jsonwebtoken": "9.0.2",
"node-jose": "2.2.0",
"nodemon": "3.1.10",
"serve-favicon": "2.5.1"
},
"devDependencies": {
"prettier": "3.6.2"
}
}
+133
View File
@@ -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)
}
}
}
}()
}
+161
View File
@@ -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
View File
@@ -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
}
-18
View File
@@ -1,18 +0,0 @@
const fs = require('fs')
const setup = (usersFile) => {
let users = {}
if (fs.existsSync(usersFile)) {
console.log(`initial users file "${usersFile}" exists, reading`)
const read = fs.readFileSync(usersFile, { encoding: 'utf8', flag: 'r' })
users = JSON.parse(read)
for (let key of Object.keys(users)) {
users[key] = { ...users[key], email: key }
}
console.log('users:', users)
} else {
console.log(`initial users file "${usersFile}" missing`)
}
return users
}
module.exports = setup
-1409
View File
File diff suppressed because it is too large Load Diff