mirror of
https://github.com/steveiliop56/tinyauth.git
synced 2026-03-22 14:37:53 +00:00
Compare commits
10 Commits
v5.0.3
...
refactor/o
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db73c56dfe | ||
|
|
4a85a9d010 | ||
|
|
7bead41ae9 | ||
|
|
2491d453cf | ||
|
|
1a1712eaeb | ||
|
|
dc3fa58d21 | ||
|
|
03f13efc77 | ||
|
|
f8a0f6c98c | ||
|
|
b3de69e5d6 | ||
|
|
016a954963 |
@@ -16,7 +16,7 @@
|
|||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"i18next": "^25.8.17",
|
"i18next": "^25.8.18",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"i18next-resources-to-backend": "^1.2.1",
|
"i18next-resources-to-backend": "^1.2.1",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-hook-form": "^7.71.2",
|
"react-hook-form": "^7.71.2",
|
||||||
"react-i18next": "^16.5.6",
|
"react-i18next": "^16.5.8",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router": "^7.13.1",
|
"react-router": "^7.13.1",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
@@ -637,7 +637,7 @@
|
|||||||
|
|
||||||
"html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
|
"html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
|
||||||
|
|
||||||
"i18next": ["i18next@25.8.17", "", { "dependencies": { "@babel/runtime": "^7.28.6" }, "peerDependencies": { "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-vWtCttyn5bpOK4hWbRAe1ZXkA+Yzcn2OcACT+WJavtfGMcxzkfvXTLMeOU8MUhRmAySKjU4VVuKlo0sSGeBokA=="],
|
"i18next": ["i18next@25.8.18", "", { "dependencies": { "@babel/runtime": "^7.28.6" }, "peerDependencies": { "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-lzY5X83BiL5AP77+9DydbrqkQHFN9hUzWGjqjLpPcp5ZOzuu1aSoKaU3xbBLSjWx9dAzW431y+d+aogxOZaKRA=="],
|
||||||
|
|
||||||
"i18next-browser-languagedetector": ["i18next-browser-languagedetector@8.2.1", "", { "dependencies": { "@babel/runtime": "^7.23.2" } }, "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw=="],
|
"i18next-browser-languagedetector": ["i18next-browser-languagedetector@8.2.1", "", { "dependencies": { "@babel/runtime": "^7.23.2" } }, "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw=="],
|
||||||
|
|
||||||
@@ -843,7 +843,7 @@
|
|||||||
|
|
||||||
"react-hook-form": ["react-hook-form@7.71.2", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA=="],
|
"react-hook-form": ["react-hook-form@7.71.2", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA=="],
|
||||||
|
|
||||||
"react-i18next": ["react-i18next@16.5.6", "", { "dependencies": { "@babel/runtime": "^7.28.4", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 25.6.2", "react": ">= 16.8.0", "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-Ua7V2/efA88ido7KyK51fb8Ki8M/sRfW8LR/rZ/9ZKr2luhuTI7kwYZN5agT1rWG7aYm5G0RYE/6JR8KJoCMDw=="],
|
"react-i18next": ["react-i18next@16.5.8", "", { "dependencies": { "@babel/runtime": "^7.28.4", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 25.6.2", "react": ">= 16.8.0", "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-2ABeHHlakxVY+LSirD+OiERxFL6+zip0PaHo979bgwzeHg27Sqc82xxXWIrSFmfWX0ZkrvXMHwhsi/NGUf5VQg=="],
|
||||||
|
|
||||||
"react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="],
|
"react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="],
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"i18next": "^25.8.17",
|
"i18next": "^25.8.18",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"i18next-resources-to-backend": "^1.2.1",
|
"i18next-resources-to-backend": "^1.2.1",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-hook-form": "^7.71.2",
|
"react-hook-form": "^7.71.2",
|
||||||
"react-i18next": "^16.5.6",
|
"react-i18next": "^16.5.8",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router": "^7.13.1",
|
"react-router": "^7.13.1",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
|
|||||||
10
go.mod
10
go.mod
@@ -19,7 +19,7 @@ require (
|
|||||||
github.com/rs/zerolog v1.34.0
|
github.com/rs/zerolog v1.34.0
|
||||||
github.com/traefik/paerser v0.2.2
|
github.com/traefik/paerser v0.2.2
|
||||||
github.com/weppos/publicsuffix-go v0.50.3
|
github.com/weppos/publicsuffix-go v0.50.3
|
||||||
golang.org/x/crypto v0.48.0
|
golang.org/x/crypto v0.49.0
|
||||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546
|
||||||
golang.org/x/oauth2 v0.36.0
|
golang.org/x/oauth2 v0.36.0
|
||||||
gotest.tools/v3 v3.5.2
|
gotest.tools/v3 v3.5.2
|
||||||
@@ -114,10 +114,10 @@ require (
|
|||||||
go.opentelemetry.io/otel/trace v1.37.0 // indirect
|
go.opentelemetry.io/otel/trace v1.37.0 // indirect
|
||||||
golang.org/x/arch v0.22.0 // indirect
|
golang.org/x/arch v0.22.0 // indirect
|
||||||
golang.org/x/net v0.51.0 // indirect
|
golang.org/x/net v0.51.0 // indirect
|
||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
golang.org/x/sys v0.41.0 // indirect
|
golang.org/x/sys v0.42.0 // indirect
|
||||||
golang.org/x/term v0.40.0 // indirect
|
golang.org/x/term v0.41.0 // indirect
|
||||||
golang.org/x/text v0.34.0 // indirect
|
golang.org/x/text v0.35.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.10 // indirect
|
google.golang.org/protobuf v1.36.10 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
modernc.org/libc v1.67.6 // indirect
|
modernc.org/libc v1.67.6 // indirect
|
||||||
|
|||||||
28
go.sum
28
go.sum
@@ -309,13 +309,13 @@ golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
|||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
|
golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
|
||||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
||||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
@@ -326,8 +326,8 @@ golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
|||||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
@@ -338,26 +338,26 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc
|
|||||||
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
|
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
|
||||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4=
|
google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c h1:AtEkQdl5b6zsybXcbz00j1LwNodDuH6hVifIaNqk7NQ=
|
google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c h1:AtEkQdl5b6zsybXcbz00j1LwNodDuH6hVifIaNqk7NQ=
|
||||||
|
|||||||
@@ -22,16 +22,17 @@ import (
|
|||||||
type BootstrapApp struct {
|
type BootstrapApp struct {
|
||||||
config config.Config
|
config config.Config
|
||||||
context struct {
|
context struct {
|
||||||
appUrl string
|
appUrl string
|
||||||
uuid string
|
uuid string
|
||||||
cookieDomain string
|
cookieDomain string
|
||||||
sessionCookieName string
|
sessionCookieName string
|
||||||
csrfCookieName string
|
csrfCookieName string
|
||||||
redirectCookieName string
|
redirectCookieName string
|
||||||
users []config.User
|
oauthSessionCookieName string
|
||||||
oauthProviders map[string]config.OAuthServiceConfig
|
users []config.User
|
||||||
configuredProviders []controller.Provider
|
oauthProviders map[string]config.OAuthServiceConfig
|
||||||
oidcClients []config.OIDCClientConfig
|
configuredProviders []controller.Provider
|
||||||
|
oidcClients []config.OIDCClientConfig
|
||||||
}
|
}
|
||||||
services Services
|
services Services
|
||||||
}
|
}
|
||||||
@@ -113,6 +114,7 @@ func (app *BootstrapApp) Setup() error {
|
|||||||
app.context.sessionCookieName = fmt.Sprintf("%s-%s", config.SessionCookieName, cookieId)
|
app.context.sessionCookieName = fmt.Sprintf("%s-%s", config.SessionCookieName, cookieId)
|
||||||
app.context.csrfCookieName = fmt.Sprintf("%s-%s", config.CSRFCookieName, cookieId)
|
app.context.csrfCookieName = fmt.Sprintf("%s-%s", config.CSRFCookieName, cookieId)
|
||||||
app.context.redirectCookieName = fmt.Sprintf("%s-%s", config.RedirectCookieName, cookieId)
|
app.context.redirectCookieName = fmt.Sprintf("%s-%s", config.RedirectCookieName, cookieId)
|
||||||
|
app.context.oauthSessionCookieName = fmt.Sprintf("%s-%s", config.OAuthSessionCookieName, cookieId)
|
||||||
|
|
||||||
// Dumps
|
// Dumps
|
||||||
tlog.App.Trace().Interface("config", app.config).Msg("Config dump")
|
tlog.App.Trace().Interface("config", app.config).Msg("Config dump")
|
||||||
@@ -190,12 +192,12 @@ func (app *BootstrapApp) Setup() error {
|
|||||||
|
|
||||||
// Start db cleanup routine
|
// Start db cleanup routine
|
||||||
tlog.App.Debug().Msg("Starting database cleanup routine")
|
tlog.App.Debug().Msg("Starting database cleanup routine")
|
||||||
go app.dbCleanup(queries)
|
go app.dbCleanupRoutine(queries)
|
||||||
|
|
||||||
// If analytics are not disabled, start heartbeat
|
// If analytics are not disabled, start heartbeat
|
||||||
if app.config.Analytics.Enabled {
|
if app.config.Analytics.Enabled {
|
||||||
tlog.App.Debug().Msg("Starting heartbeat routine")
|
tlog.App.Debug().Msg("Starting heartbeat routine")
|
||||||
go app.heartbeat()
|
go app.heartbeatRoutine()
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we have an socket path, bind to it
|
// If we have an socket path, bind to it
|
||||||
@@ -226,7 +228,7 @@ func (app *BootstrapApp) Setup() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (app *BootstrapApp) heartbeat() {
|
func (app *BootstrapApp) heartbeatRoutine() {
|
||||||
ticker := time.NewTicker(time.Duration(12) * time.Hour)
|
ticker := time.NewTicker(time.Duration(12) * time.Hour)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
@@ -280,7 +282,7 @@ func (app *BootstrapApp) heartbeat() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (app *BootstrapApp) dbCleanup(queries *repository.Queries) {
|
func (app *BootstrapApp) dbCleanupRoutine(queries *repository.Queries) {
|
||||||
ticker := time.NewTicker(time.Duration(30) * time.Minute)
|
ticker := time.NewTicker(time.Duration(30) * time.Minute)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|||||||
@@ -77,12 +77,13 @@ func (app *BootstrapApp) setupRouter() (*gin.Engine, error) {
|
|||||||
contextController.SetupRoutes()
|
contextController.SetupRoutes()
|
||||||
|
|
||||||
oauthController := controller.NewOAuthController(controller.OAuthControllerConfig{
|
oauthController := controller.NewOAuthController(controller.OAuthControllerConfig{
|
||||||
AppURL: app.config.AppURL,
|
AppURL: app.config.AppURL,
|
||||||
SecureCookie: app.config.Auth.SecureCookie,
|
SecureCookie: app.config.Auth.SecureCookie,
|
||||||
CSRFCookieName: app.context.csrfCookieName,
|
CSRFCookieName: app.context.csrfCookieName,
|
||||||
RedirectCookieName: app.context.redirectCookieName,
|
RedirectCookieName: app.context.redirectCookieName,
|
||||||
CookieDomain: app.context.cookieDomain,
|
CookieDomain: app.context.cookieDomain,
|
||||||
}, apiRouter, app.services.authService, app.services.oauthBrokerService)
|
OAuthSessionCookieName: app.context.oauthSessionCookieName,
|
||||||
|
}, apiRouter, app.services.authService)
|
||||||
|
|
||||||
oauthController.SetupRoutes()
|
oauthController.SetupRoutes()
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,16 @@ func (app *BootstrapApp) initServices(queries *repository.Queries) (Services, er
|
|||||||
|
|
||||||
services.accessControlService = accessControlsService
|
services.accessControlService = accessControlsService
|
||||||
|
|
||||||
|
oauthBrokerService := service.NewOAuthBrokerService(app.context.oauthProviders)
|
||||||
|
|
||||||
|
err = oauthBrokerService.Init()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return Services{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
services.oauthBrokerService = oauthBrokerService
|
||||||
|
|
||||||
authService := service.NewAuthService(service.AuthServiceConfig{
|
authService := service.NewAuthService(service.AuthServiceConfig{
|
||||||
Users: app.context.users,
|
Users: app.context.users,
|
||||||
OauthWhitelist: app.config.OAuth.Whitelist,
|
OauthWhitelist: app.config.OAuth.Whitelist,
|
||||||
@@ -70,7 +80,7 @@ func (app *BootstrapApp) initServices(queries *repository.Queries) (Services, er
|
|||||||
SessionCookieName: app.context.sessionCookieName,
|
SessionCookieName: app.context.sessionCookieName,
|
||||||
IP: app.config.Auth.IP,
|
IP: app.config.Auth.IP,
|
||||||
LDAPGroupsCacheTTL: app.config.Ldap.GroupCacheTTL,
|
LDAPGroupsCacheTTL: app.config.Ldap.GroupCacheTTL,
|
||||||
}, dockerService, services.ldapService, queries)
|
}, dockerService, services.ldapService, queries, services.oauthBrokerService)
|
||||||
|
|
||||||
err = authService.Init()
|
err = authService.Init()
|
||||||
|
|
||||||
@@ -80,16 +90,6 @@ func (app *BootstrapApp) initServices(queries *repository.Queries) (Services, er
|
|||||||
|
|
||||||
services.authService = authService
|
services.authService = authService
|
||||||
|
|
||||||
oauthBrokerService := service.NewOAuthBrokerService(app.context.oauthProviders)
|
|
||||||
|
|
||||||
err = oauthBrokerService.Init()
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return Services{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
services.oauthBrokerService = oauthBrokerService
|
|
||||||
|
|
||||||
oidcService := service.NewOIDCService(service.OIDCServiceConfig{
|
oidcService := service.NewOIDCService(service.OIDCServiceConfig{
|
||||||
Clients: app.config.OIDC.Clients,
|
Clients: app.config.OIDC.Clients,
|
||||||
PrivateKeyPath: app.config.OIDC.PrivateKeyPath,
|
PrivateKeyPath: app.config.OIDC.PrivateKeyPath,
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ var BuildTimestamp = "0000-00-00T00:00:00Z"
|
|||||||
var SessionCookieName = "tinyauth-session"
|
var SessionCookieName = "tinyauth-session"
|
||||||
var CSRFCookieName = "tinyauth-csrf"
|
var CSRFCookieName = "tinyauth-csrf"
|
||||||
var RedirectCookieName = "tinyauth-redirect"
|
var RedirectCookieName = "tinyauth-redirect"
|
||||||
|
var OAuthSessionCookieName = "tinyauth-oauth"
|
||||||
|
|
||||||
// Main app config
|
// Main app config
|
||||||
|
|
||||||
|
|||||||
@@ -21,26 +21,25 @@ type OAuthRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type OAuthControllerConfig struct {
|
type OAuthControllerConfig struct {
|
||||||
CSRFCookieName string
|
CSRFCookieName string
|
||||||
RedirectCookieName string
|
OAuthSessionCookieName string
|
||||||
SecureCookie bool
|
RedirectCookieName string
|
||||||
AppURL string
|
SecureCookie bool
|
||||||
CookieDomain string
|
AppURL string
|
||||||
|
CookieDomain string
|
||||||
}
|
}
|
||||||
|
|
||||||
type OAuthController struct {
|
type OAuthController struct {
|
||||||
config OAuthControllerConfig
|
config OAuthControllerConfig
|
||||||
router *gin.RouterGroup
|
router *gin.RouterGroup
|
||||||
auth *service.AuthService
|
auth *service.AuthService
|
||||||
broker *service.OAuthBrokerService
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewOAuthController(config OAuthControllerConfig, router *gin.RouterGroup, auth *service.AuthService, broker *service.OAuthBrokerService) *OAuthController {
|
func NewOAuthController(config OAuthControllerConfig, router *gin.RouterGroup, auth *service.AuthService) *OAuthController {
|
||||||
return &OAuthController{
|
return &OAuthController{
|
||||||
config: config,
|
config: config,
|
||||||
router: router,
|
router: router,
|
||||||
auth: auth,
|
auth: auth,
|
||||||
broker: broker,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,21 +62,30 @@ func (controller *OAuthController) oauthURLHandler(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
service, exists := controller.broker.GetService(req.Provider)
|
sessionId, session, err := controller.auth.NewOAuthSession(req.Provider)
|
||||||
|
|
||||||
if !exists {
|
if err != nil {
|
||||||
tlog.App.Warn().Msgf("OAuth provider not found: %s", req.Provider)
|
tlog.App.Error().Err(err).Msg("Failed to create OAuth session")
|
||||||
c.JSON(404, gin.H{
|
c.JSON(500, gin.H{
|
||||||
"status": 404,
|
"status": 500,
|
||||||
"message": "Not Found",
|
"message": "Internal Server Error",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
service.GenerateVerifier()
|
authUrl, err := controller.auth.GetOAuthURL(sessionId)
|
||||||
state := service.GenerateState()
|
|
||||||
authURL := service.GetAuthURL(state)
|
if err != nil {
|
||||||
c.SetCookie(controller.config.CSRFCookieName, state, int(time.Hour.Seconds()), "/", fmt.Sprintf(".%s", controller.config.CookieDomain), controller.config.SecureCookie, true)
|
tlog.App.Error().Err(err).Msg("Failed to get OAuth URL")
|
||||||
|
c.JSON(500, gin.H{
|
||||||
|
"status": 500,
|
||||||
|
"message": "Internal Server Error",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.SetCookie(controller.config.OAuthSessionCookieName, sessionId, int(time.Hour.Seconds()), "/", fmt.Sprintf(".%s", controller.config.CookieDomain), controller.config.SecureCookie, true)
|
||||||
|
c.SetCookie(controller.config.CSRFCookieName, session.State, int(time.Hour.Seconds()), "/", fmt.Sprintf(".%s", controller.config.CookieDomain), controller.config.SecureCookie, true)
|
||||||
|
|
||||||
redirectURI := c.Query("redirect_uri")
|
redirectURI := c.Query("redirect_uri")
|
||||||
isRedirectSafe := utils.IsRedirectSafe(redirectURI, controller.config.CookieDomain)
|
isRedirectSafe := utils.IsRedirectSafe(redirectURI, controller.config.CookieDomain)
|
||||||
@@ -95,7 +103,7 @@ func (controller *OAuthController) oauthURLHandler(c *gin.Context) {
|
|||||||
c.JSON(200, gin.H{
|
c.JSON(200, gin.H{
|
||||||
"status": 200,
|
"status": 200,
|
||||||
"message": "OK",
|
"message": "OK",
|
||||||
"url": authURL,
|
"url": authUrl,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,6 +120,16 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sessionIdCookie, err := c.Cookie(controller.config.OAuthSessionCookieName)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
tlog.App.Warn().Err(err).Msg("OAuth session cookie missing")
|
||||||
|
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.SetCookie(controller.config.OAuthSessionCookieName, "", -1, "/", fmt.Sprintf(".%s", controller.config.CookieDomain), controller.config.SecureCookie, true)
|
||||||
|
|
||||||
state := c.Query("state")
|
state := c.Query("state")
|
||||||
csrfCookie, err := c.Cookie(controller.config.CSRFCookieName)
|
csrfCookie, err := c.Cookie(controller.config.CSRFCookieName)
|
||||||
|
|
||||||
@@ -125,29 +143,16 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
|
|||||||
c.SetCookie(controller.config.CSRFCookieName, "", -1, "/", fmt.Sprintf(".%s", controller.config.CookieDomain), controller.config.SecureCookie, true)
|
c.SetCookie(controller.config.CSRFCookieName, "", -1, "/", fmt.Sprintf(".%s", controller.config.CookieDomain), controller.config.SecureCookie, true)
|
||||||
|
|
||||||
code := c.Query("code")
|
code := c.Query("code")
|
||||||
service, exists := controller.broker.GetService(req.Provider)
|
_, err = controller.auth.GetOAuthToken(sessionIdCookie, code)
|
||||||
|
|
||||||
if !exists {
|
|
||||||
tlog.App.Warn().Msgf("OAuth provider not found: %s", req.Provider)
|
|
||||||
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err = service.VerifyCode(code)
|
|
||||||
if err != nil {
|
|
||||||
tlog.App.Error().Err(err).Msg("Failed to verify OAuth code")
|
|
||||||
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
user, err := controller.broker.GetUser(req.Provider)
|
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
tlog.App.Error().Err(err).Msg("Failed to get user from OAuth provider")
|
tlog.App.Error().Err(err).Msg("Failed to exchange code for token")
|
||||||
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
|
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
user, err := controller.auth.GetOAuthUserinfo(sessionIdCookie)
|
||||||
|
|
||||||
if user.Email == "" {
|
if user.Email == "" {
|
||||||
tlog.App.Error().Msg("OAuth provider did not return an email")
|
tlog.App.Error().Msg("OAuth provider did not return an email")
|
||||||
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
|
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
|
||||||
@@ -192,13 +197,21 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
|
|||||||
username = strings.Replace(user.Email, "@", "_", 1)
|
username = strings.Replace(user.Email, "@", "_", 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
service, err := controller.auth.GetOAuthService(sessionIdCookie)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
tlog.App.Error().Err(err).Msg("Failed to get OAuth service for session")
|
||||||
|
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
sessionCookie := repository.Session{
|
sessionCookie := repository.Session{
|
||||||
Username: username,
|
Username: username,
|
||||||
Name: name,
|
Name: name,
|
||||||
Email: user.Email,
|
Email: user.Email,
|
||||||
Provider: req.Provider,
|
Provider: req.Provider,
|
||||||
OAuthGroups: utils.CoalesceToString(user.Groups),
|
OAuthGroups: utils.CoalesceToString(user.Groups),
|
||||||
OAuthName: service.GetName(),
|
OAuthName: service.Name(),
|
||||||
OAuthSub: user.Sub,
|
OAuthSub: user.Sub,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,6 +227,9 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
|
|||||||
|
|
||||||
tlog.AuditLoginSuccess(c, sessionCookie.Username, sessionCookie.Provider)
|
tlog.AuditLoginSuccess(c, sessionCookie.Username, sessionCookie.Provider)
|
||||||
|
|
||||||
|
// Clear OAuth session
|
||||||
|
controller.auth.EndOAuthSession(sessionIdCookie)
|
||||||
|
|
||||||
redirectURI, err := c.Cookie(controller.config.RedirectCookieName)
|
redirectURI, err := c.Cookie(controller.config.RedirectCookieName)
|
||||||
|
|
||||||
if err != nil || !utils.IsRedirectSafe(redirectURI, controller.config.CookieDomain) {
|
if err != nil || !utils.IsRedirectSafe(redirectURI, controller.config.CookieDomain) {
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"slices"
|
"net/url"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/steveiliop56/tinyauth/internal/config"
|
"github.com/steveiliop56/tinyauth/internal/config"
|
||||||
@@ -15,12 +17,29 @@ import (
|
|||||||
"github.com/google/go-querystring/query"
|
"github.com/google/go-querystring/query"
|
||||||
)
|
)
|
||||||
|
|
||||||
var SupportedProxies = []string{"nginx", "traefik", "caddy", "envoy"}
|
type AuthModuleType int
|
||||||
|
|
||||||
|
const (
|
||||||
|
AuthRequest AuthModuleType = iota
|
||||||
|
ExtAuthz
|
||||||
|
ForwardAuth
|
||||||
|
)
|
||||||
|
|
||||||
|
var BrowserUserAgentRegex = regexp.MustCompile("Chrome|Gecko|AppleWebKit|Opera|Edge")
|
||||||
|
|
||||||
type Proxy struct {
|
type Proxy struct {
|
||||||
Proxy string `uri:"proxy" binding:"required"`
|
Proxy string `uri:"proxy" binding:"required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ProxyContext struct {
|
||||||
|
Host string
|
||||||
|
Proto string
|
||||||
|
Path string
|
||||||
|
Method string
|
||||||
|
Type AuthModuleType
|
||||||
|
IsBrowser bool
|
||||||
|
}
|
||||||
|
|
||||||
type ProxyControllerConfig struct {
|
type ProxyControllerConfig struct {
|
||||||
AppURL string
|
AppURL string
|
||||||
}
|
}
|
||||||
@@ -43,75 +62,30 @@ func NewProxyController(config ProxyControllerConfig, router *gin.RouterGroup, a
|
|||||||
|
|
||||||
func (controller *ProxyController) SetupRoutes() {
|
func (controller *ProxyController) SetupRoutes() {
|
||||||
proxyGroup := controller.router.Group("/auth")
|
proxyGroup := controller.router.Group("/auth")
|
||||||
// There is a later check to control allowed methods per proxy
|
|
||||||
proxyGroup.Any("/:proxy", controller.proxyHandler)
|
proxyGroup.Any("/:proxy", controller.proxyHandler)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (controller *ProxyController) proxyHandler(c *gin.Context) {
|
func (controller *ProxyController) proxyHandler(c *gin.Context) {
|
||||||
var req Proxy
|
// Load proxy context based on the request type
|
||||||
|
proxyCtx, err := controller.getProxyContext(c)
|
||||||
|
|
||||||
err := c.BindUri(&req)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
tlog.App.Error().Err(err).Msg("Failed to bind URI")
|
tlog.App.Warn().Err(err).Msg("Failed to get proxy context")
|
||||||
c.JSON(400, gin.H{
|
c.JSON(400, gin.H{
|
||||||
"status": 400,
|
"status": 400,
|
||||||
"message": "Bad Request",
|
"message": "Bad request",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !slices.Contains(SupportedProxies, req.Proxy) {
|
tlog.App.Trace().Interface("ctx", proxyCtx).Msg("Got proxy context")
|
||||||
tlog.App.Warn().Str("proxy", req.Proxy).Msg("Invalid proxy")
|
|
||||||
c.JSON(400, gin.H{
|
|
||||||
"status": 400,
|
|
||||||
"message": "Bad Request",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only allow GET for non-envoy proxies.
|
|
||||||
// Envoy uses the original client method for the external auth request
|
|
||||||
// so we allow Any standard HTTP method for /api/auth/envoy
|
|
||||||
if req.Proxy != "envoy" && c.Request.Method != http.MethodGet {
|
|
||||||
tlog.App.Warn().Str("method", c.Request.Method).Msg("Invalid method for proxy")
|
|
||||||
c.Header("Allow", "GET")
|
|
||||||
c.JSON(405, gin.H{
|
|
||||||
"status": 405,
|
|
||||||
"message": "Method Not Allowed",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
isBrowser := strings.Contains(c.Request.Header.Get("Accept"), "text/html")
|
|
||||||
|
|
||||||
if isBrowser {
|
|
||||||
tlog.App.Debug().Msg("Request identified as (most likely) coming from a browser")
|
|
||||||
} else {
|
|
||||||
tlog.App.Debug().Msg("Request identified as (most likely) coming from a non-browser client")
|
|
||||||
}
|
|
||||||
|
|
||||||
uri, ok := controller.requireHeader(c, "x-forwarded-uri")
|
|
||||||
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
host, ok := controller.requireHeader(c, "x-forwarded-host")
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
proto, ok := controller.requireHeader(c, "x-forwarded-proto")
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get acls
|
// Get acls
|
||||||
acls, err := controller.acls.GetAccessControls(host)
|
acls, err := controller.acls.GetAccessControls(proxyCtx.Host)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
tlog.App.Error().Err(err).Msg("Failed to get access controls for resource")
|
tlog.App.Error().Err(err).Msg("Failed to get access controls for resource")
|
||||||
controller.handleError(c, req, isBrowser)
|
controller.handleError(c, proxyCtx)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,11 +102,11 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
authEnabled, err := controller.auth.IsAuthEnabled(uri, acls.Path)
|
authEnabled, err := controller.auth.IsAuthEnabled(proxyCtx.Path, acls.Path)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
tlog.App.Error().Err(err).Msg("Failed to check if auth is enabled for resource")
|
tlog.App.Error().Err(err).Msg("Failed to check if auth is enabled for resource")
|
||||||
controller.handleError(c, req, isBrowser)
|
controller.handleError(c, proxyCtx)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,7 +121,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !controller.auth.CheckIP(acls.IP, clientIP) {
|
if !controller.auth.CheckIP(acls.IP, clientIP) {
|
||||||
if req.Proxy == "nginx" || !isBrowser {
|
if !controller.useFriendlyError(proxyCtx) {
|
||||||
c.JSON(401, gin.H{
|
c.JSON(401, gin.H{
|
||||||
"status": 401,
|
"status": 401,
|
||||||
"message": "Unauthorized",
|
"message": "Unauthorized",
|
||||||
@@ -156,7 +130,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
queries, err := query.Values(config.UnauthorizedQuery{
|
queries, err := query.Values(config.UnauthorizedQuery{
|
||||||
Resource: strings.Split(host, ".")[0],
|
Resource: strings.Split(proxyCtx.Host, ".")[0],
|
||||||
IP: clientIP,
|
IP: clientIP,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -189,9 +163,9 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
|
|||||||
userAllowed := controller.auth.IsUserAllowed(c, userContext, acls)
|
userAllowed := controller.auth.IsUserAllowed(c, userContext, acls)
|
||||||
|
|
||||||
if !userAllowed {
|
if !userAllowed {
|
||||||
tlog.App.Warn().Str("user", userContext.Username).Str("resource", strings.Split(host, ".")[0]).Msg("User not allowed to access resource")
|
tlog.App.Warn().Str("user", userContext.Username).Str("resource", strings.Split(proxyCtx.Host, ".")[0]).Msg("User not allowed to access resource")
|
||||||
|
|
||||||
if req.Proxy == "nginx" || !isBrowser {
|
if !controller.useFriendlyError(proxyCtx) {
|
||||||
c.JSON(403, gin.H{
|
c.JSON(403, gin.H{
|
||||||
"status": 403,
|
"status": 403,
|
||||||
"message": "Forbidden",
|
"message": "Forbidden",
|
||||||
@@ -200,7 +174,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
queries, err := query.Values(config.UnauthorizedQuery{
|
queries, err := query.Values(config.UnauthorizedQuery{
|
||||||
Resource: strings.Split(host, ".")[0],
|
Resource: strings.Split(proxyCtx.Host, ".")[0],
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -229,9 +203,9 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !groupOK {
|
if !groupOK {
|
||||||
tlog.App.Warn().Str("user", userContext.Username).Str("resource", strings.Split(host, ".")[0]).Msg("User groups do not match resource requirements")
|
tlog.App.Warn().Str("user", userContext.Username).Str("resource", strings.Split(proxyCtx.Host, ".")[0]).Msg("User groups do not match resource requirements")
|
||||||
|
|
||||||
if req.Proxy == "nginx" || !isBrowser {
|
if !controller.useFriendlyError(proxyCtx) {
|
||||||
c.JSON(403, gin.H{
|
c.JSON(403, gin.H{
|
||||||
"status": 403,
|
"status": 403,
|
||||||
"message": "Forbidden",
|
"message": "Forbidden",
|
||||||
@@ -240,7 +214,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
queries, err := query.Values(config.UnauthorizedQuery{
|
queries, err := query.Values(config.UnauthorizedQuery{
|
||||||
Resource: strings.Split(host, ".")[0],
|
Resource: strings.Split(proxyCtx.Host, ".")[0],
|
||||||
GroupErr: true,
|
GroupErr: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -282,7 +256,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Proxy == "nginx" || !isBrowser {
|
if !controller.useFriendlyError(proxyCtx) {
|
||||||
c.JSON(401, gin.H{
|
c.JSON(401, gin.H{
|
||||||
"status": 401,
|
"status": 401,
|
||||||
"message": "Unauthorized",
|
"message": "Unauthorized",
|
||||||
@@ -291,7 +265,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
queries, err := query.Values(config.RedirectQuery{
|
queries, err := query.Values(config.RedirectQuery{
|
||||||
RedirectURI: fmt.Sprintf("%s://%s%s", proto, host, uri),
|
RedirectURI: fmt.Sprintf("%s://%s%s", proxyCtx.Proto, proxyCtx.Host, proxyCtx.Path),
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -321,8 +295,8 @@ func (controller *ProxyController) setHeaders(c *gin.Context, acls config.App) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (controller *ProxyController) handleError(c *gin.Context, req Proxy, isBrowser bool) {
|
func (controller *ProxyController) handleError(c *gin.Context, proxyCtx ProxyContext) {
|
||||||
if req.Proxy == "nginx" || !isBrowser {
|
if !controller.useFriendlyError(proxyCtx) {
|
||||||
c.JSON(500, gin.H{
|
c.JSON(500, gin.H{
|
||||||
"status": 500,
|
"status": 500,
|
||||||
"message": "Internal Server Error",
|
"message": "Internal Server Error",
|
||||||
@@ -333,15 +307,195 @@ func (controller *ProxyController) handleError(c *gin.Context, req Proxy, isBrow
|
|||||||
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
|
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (controller *ProxyController) requireHeader(c *gin.Context, header string) (string, bool) {
|
func (controller *ProxyController) getHeader(c *gin.Context, header string) (string, bool) {
|
||||||
val := c.Request.Header.Get(header)
|
val := c.Request.Header.Get(header)
|
||||||
if strings.TrimSpace(val) == "" {
|
return val, strings.TrimSpace(val) != ""
|
||||||
tlog.App.Error().Str("header", header).Msg("Header not found")
|
}
|
||||||
c.JSON(400, gin.H{
|
|
||||||
"status": 400,
|
func (controller *ProxyController) useFriendlyError(proxyCtx ProxyContext) bool {
|
||||||
"message": "Bad Request",
|
return (proxyCtx.Type == ForwardAuth || proxyCtx.Type == ExtAuthz) && proxyCtx.IsBrowser
|
||||||
})
|
}
|
||||||
return "", false
|
|
||||||
}
|
// Code below is inspired from https://github.com/authelia/authelia/blob/master/internal/handlers/handler_authz.go
|
||||||
return val, true
|
// and thus it may be subject to Apache 2.0 License
|
||||||
|
func (controller *ProxyController) getForwardAuthContext(c *gin.Context) (ProxyContext, error) {
|
||||||
|
host, ok := controller.getHeader(c, "x-forwarded-host")
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
return ProxyContext{}, errors.New("x-forwarded-host not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
uri, ok := controller.getHeader(c, "x-forwarded-uri")
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
return ProxyContext{}, errors.New("x-forwarded-uri not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
proto, ok := controller.getHeader(c, "x-forwarded-proto")
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
return ProxyContext{}, errors.New("x-forwarded-proto not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normally we should only allow GET for forward auth but since it's a fallback
|
||||||
|
// for envoy we should allow everything, not a big deal
|
||||||
|
method := c.Request.Method
|
||||||
|
|
||||||
|
return ProxyContext{
|
||||||
|
Host: host,
|
||||||
|
Proto: proto,
|
||||||
|
Path: uri,
|
||||||
|
Method: method,
|
||||||
|
Type: ForwardAuth,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (controller *ProxyController) getAuthRequestContext(c *gin.Context) (ProxyContext, error) {
|
||||||
|
xOriginalUrl, ok := controller.getHeader(c, "x-original-url")
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
return ProxyContext{}, errors.New("x-original-url not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
url, err := url.Parse(xOriginalUrl)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return ProxyContext{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
host := url.Host
|
||||||
|
|
||||||
|
if strings.TrimSpace(host) == "" {
|
||||||
|
return ProxyContext{}, errors.New("host not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
proto := url.Scheme
|
||||||
|
|
||||||
|
if strings.TrimSpace(proto) == "" {
|
||||||
|
return ProxyContext{}, errors.New("proto not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
path := url.Path
|
||||||
|
method := c.Request.Method
|
||||||
|
|
||||||
|
return ProxyContext{
|
||||||
|
Host: host,
|
||||||
|
Proto: proto,
|
||||||
|
Path: path,
|
||||||
|
Method: method,
|
||||||
|
Type: AuthRequest,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (controller *ProxyController) getExtAuthzContext(c *gin.Context) (ProxyContext, error) {
|
||||||
|
// We hope for the someone to set the x-forwarded-proto header
|
||||||
|
proto, ok := controller.getHeader(c, "x-forwarded-proto")
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
return ProxyContext{}, errors.New("x-forwarded-proto not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// It sets the host to the original host, not the forwarded host
|
||||||
|
host := c.Request.Host
|
||||||
|
|
||||||
|
if strings.TrimSpace(host) == "" {
|
||||||
|
return ProxyContext{}, errors.New("host not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// We get the path from the query string
|
||||||
|
path := c.Query("path")
|
||||||
|
|
||||||
|
// For envoy we need to support every method
|
||||||
|
method := c.Request.Method
|
||||||
|
|
||||||
|
return ProxyContext{
|
||||||
|
Host: host,
|
||||||
|
Proto: proto,
|
||||||
|
Path: path,
|
||||||
|
Method: method,
|
||||||
|
Type: ExtAuthz,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (controller *ProxyController) determineAuthModules(proxy string) []AuthModuleType {
|
||||||
|
switch proxy {
|
||||||
|
case "traefik", "caddy":
|
||||||
|
return []AuthModuleType{ForwardAuth}
|
||||||
|
case "envoy":
|
||||||
|
return []AuthModuleType{ExtAuthz, ForwardAuth}
|
||||||
|
case "nginx":
|
||||||
|
return []AuthModuleType{AuthRequest, ForwardAuth}
|
||||||
|
default:
|
||||||
|
return []AuthModuleType{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (controller *ProxyController) getContextFromAuthModule(c *gin.Context, module AuthModuleType) (ProxyContext, error) {
|
||||||
|
switch module {
|
||||||
|
case ForwardAuth:
|
||||||
|
ctx, err := controller.getForwardAuthContext(c)
|
||||||
|
if err != nil {
|
||||||
|
return ProxyContext{}, err
|
||||||
|
}
|
||||||
|
return ctx, nil
|
||||||
|
case ExtAuthz:
|
||||||
|
ctx, err := controller.getExtAuthzContext(c)
|
||||||
|
if err != nil {
|
||||||
|
return ProxyContext{}, err
|
||||||
|
}
|
||||||
|
return ctx, nil
|
||||||
|
case AuthRequest:
|
||||||
|
ctx, err := controller.getAuthRequestContext(c)
|
||||||
|
if err != nil {
|
||||||
|
return ProxyContext{}, err
|
||||||
|
}
|
||||||
|
return ctx, nil
|
||||||
|
}
|
||||||
|
return ProxyContext{}, fmt.Errorf("unsupported auth module: %v", module)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (controller *ProxyController) getProxyContext(c *gin.Context) (ProxyContext, error) {
|
||||||
|
var req Proxy
|
||||||
|
|
||||||
|
err := c.BindUri(&req)
|
||||||
|
if err != nil {
|
||||||
|
return ProxyContext{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
tlog.App.Debug().Msgf("Proxy: %v", req.Proxy)
|
||||||
|
|
||||||
|
authModules := controller.determineAuthModules(req.Proxy)
|
||||||
|
|
||||||
|
if len(authModules) == 0 {
|
||||||
|
return ProxyContext{}, fmt.Errorf("no auth modules supported for proxy: %v", req.Proxy)
|
||||||
|
}
|
||||||
|
|
||||||
|
var ctx ProxyContext
|
||||||
|
|
||||||
|
for _, module := range authModules {
|
||||||
|
tlog.App.Debug().Msgf("Trying auth module: %v", module)
|
||||||
|
ctx, err = controller.getContextFromAuthModule(c, module)
|
||||||
|
if err == nil {
|
||||||
|
tlog.App.Debug().Msgf("Auth module %v succeeded", module)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
tlog.App.Debug().Err(err).Msgf("Auth module %v failed", module)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return ProxyContext{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// We don't care if the header is empty, we will just assume it's not a browser
|
||||||
|
userAgent, _ := controller.getHeader(c, "user-agent")
|
||||||
|
isBrowser := BrowserUserAgentRegex.MatchString(userAgent)
|
||||||
|
|
||||||
|
if isBrowser {
|
||||||
|
tlog.App.Debug().Msg("Request identified as coming from a browser")
|
||||||
|
} else {
|
||||||
|
tlog.App.Debug().Msg("Request identified as coming from a non-browser client")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.IsBrowser = isBrowser
|
||||||
|
return ctx, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package controller_test
|
package controller_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -9,21 +10,26 @@ import (
|
|||||||
"github.com/steveiliop56/tinyauth/internal/controller"
|
"github.com/steveiliop56/tinyauth/internal/controller"
|
||||||
"github.com/steveiliop56/tinyauth/internal/repository"
|
"github.com/steveiliop56/tinyauth/internal/repository"
|
||||||
"github.com/steveiliop56/tinyauth/internal/service"
|
"github.com/steveiliop56/tinyauth/internal/service"
|
||||||
"github.com/steveiliop56/tinyauth/internal/utils/tlog"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"gotest.tools/v3/assert"
|
"gotest.tools/v3/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
func setupProxyController(t *testing.T, middlewares *[]gin.HandlerFunc) (*gin.Engine, *httptest.ResponseRecorder, *service.AuthService) {
|
var loggedInCtx = config.UserContext{
|
||||||
tlog.NewSimpleLogger().Init()
|
Username: "test",
|
||||||
|
Name: "Test",
|
||||||
|
Email: "test@example.com",
|
||||||
|
IsLoggedIn: true,
|
||||||
|
Provider: "local",
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupProxyController(t *testing.T, middlewares []gin.HandlerFunc) (*gin.Engine, *httptest.ResponseRecorder) {
|
||||||
// Setup
|
// Setup
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
router := gin.Default()
|
router := gin.Default()
|
||||||
|
|
||||||
if middlewares != nil {
|
if len(middlewares) > 0 {
|
||||||
for _, m := range *middlewares {
|
for _, m := range middlewares {
|
||||||
router.Use(m)
|
router.Use(m)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -48,7 +54,13 @@ func setupProxyController(t *testing.T, middlewares *[]gin.HandlerFunc) (*gin.En
|
|||||||
assert.NilError(t, dockerService.Init())
|
assert.NilError(t, dockerService.Init())
|
||||||
|
|
||||||
// Access controls
|
// Access controls
|
||||||
accessControlsService := service.NewAccessControlsService(dockerService, map[string]config.App{})
|
accessControlsService := service.NewAccessControlsService(dockerService, map[string]config.App{
|
||||||
|
"whoami": {
|
||||||
|
Path: config.AppPath{
|
||||||
|
Allow: "/allow",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
assert.NilError(t, accessControlsService.Init())
|
assert.NilError(t, accessControlsService.Init())
|
||||||
|
|
||||||
@@ -73,111 +85,218 @@ func setupProxyController(t *testing.T, middlewares *[]gin.HandlerFunc) (*gin.En
|
|||||||
LoginTimeout: 300,
|
LoginTimeout: 300,
|
||||||
LoginMaxRetries: 3,
|
LoginMaxRetries: 3,
|
||||||
SessionCookieName: "tinyauth-session",
|
SessionCookieName: "tinyauth-session",
|
||||||
}, dockerService, nil, queries)
|
}, dockerService, nil, queries, &service.OAuthBrokerService{})
|
||||||
|
|
||||||
// Controller
|
// Controller
|
||||||
ctrl := controller.NewProxyController(controller.ProxyControllerConfig{
|
ctrl := controller.NewProxyController(controller.ProxyControllerConfig{
|
||||||
AppURL: "http://localhost:8080",
|
AppURL: "http://tinyauth.example.com",
|
||||||
}, group, accessControlsService, authService)
|
}, group, accessControlsService, authService)
|
||||||
ctrl.SetupRoutes()
|
ctrl.SetupRoutes()
|
||||||
|
|
||||||
return router, recorder, authService
|
return router, recorder
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Needs tests for context middleware
|
// TODO: Needs tests for context middleware
|
||||||
|
|
||||||
func TestProxyHandler(t *testing.T) {
|
func TestProxyHandler(t *testing.T) {
|
||||||
// Setup
|
// Test logged out user traefik/caddy (forward_auth)
|
||||||
router, recorder, _ := setupProxyController(t, nil)
|
router, recorder := setupProxyController(t, nil)
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", "/api/auth/traefik", nil)
|
||||||
|
assert.NilError(t, err)
|
||||||
|
|
||||||
|
req.Header.Set("x-forwarded-host", "whoami.example.com")
|
||||||
|
req.Header.Set("x-forwarded-proto", "http")
|
||||||
|
req.Header.Set("x-forwarded-uri", "/")
|
||||||
|
|
||||||
// Test invalid proxy
|
|
||||||
req := httptest.NewRequest("GET", "/api/auth/invalidproxy", nil)
|
|
||||||
router.ServeHTTP(recorder, req)
|
router.ServeHTTP(recorder, req)
|
||||||
|
assert.Equal(t, recorder.Code, http.StatusUnauthorized)
|
||||||
|
|
||||||
assert.Equal(t, 400, recorder.Code)
|
// Test logged out user nginx (auth_request)
|
||||||
|
router, recorder = setupProxyController(t, nil)
|
||||||
|
|
||||||
|
req, err = http.NewRequest("GET", "/api/auth/nginx", nil)
|
||||||
|
assert.NilError(t, err)
|
||||||
|
|
||||||
|
req.Header.Set("x-original-url", "http://whoami.example.com/")
|
||||||
|
|
||||||
// Test invalid method for non-envoy proxy
|
|
||||||
recorder = httptest.NewRecorder()
|
|
||||||
req = httptest.NewRequest("POST", "/api/auth/traefik", nil)
|
|
||||||
router.ServeHTTP(recorder, req)
|
router.ServeHTTP(recorder, req)
|
||||||
|
assert.Equal(t, recorder.Code, http.StatusUnauthorized)
|
||||||
|
|
||||||
assert.Equal(t, 405, recorder.Code)
|
// Test logged out user envoy (ext_authz)
|
||||||
assert.Equal(t, "GET", recorder.Header().Get("Allow"))
|
router, recorder = setupProxyController(t, nil)
|
||||||
|
|
||||||
|
req, err = http.NewRequest("GET", "/api/auth/envoy?path=/", nil)
|
||||||
|
assert.NilError(t, err)
|
||||||
|
|
||||||
|
req.Host = "whoami.example.com"
|
||||||
|
req.Header.Set("x-forwarded-proto", "http")
|
||||||
|
|
||||||
// Test logged out user (traefik/caddy)
|
|
||||||
recorder = httptest.NewRecorder()
|
|
||||||
req = httptest.NewRequest("GET", "/api/auth/traefik", nil)
|
|
||||||
req.Header.Set("X-Forwarded-Proto", "https")
|
|
||||||
req.Header.Set("X-Forwarded-Host", "example.com")
|
|
||||||
req.Header.Set("X-Forwarded-Uri", "/somepath")
|
|
||||||
req.Header.Set("Accept", "text/html")
|
|
||||||
router.ServeHTTP(recorder, req)
|
router.ServeHTTP(recorder, req)
|
||||||
|
assert.Equal(t, recorder.Code, http.StatusUnauthorized)
|
||||||
|
|
||||||
assert.Equal(t, 307, recorder.Code)
|
// Test logged in user traefik/caddy (forward_auth)
|
||||||
assert.Equal(t, "http://localhost:8080/login?redirect_uri=https%3A%2F%2Fexample.com%2Fsomepath", recorder.Header().Get("Location"))
|
router, recorder = setupProxyController(t, []gin.HandlerFunc{
|
||||||
|
|
||||||
// Test logged out user (envoy - POST method)
|
|
||||||
recorder = httptest.NewRecorder()
|
|
||||||
req = httptest.NewRequest("POST", "/api/auth/envoy", nil)
|
|
||||||
req.Header.Set("X-Forwarded-Proto", "https")
|
|
||||||
req.Header.Set("X-Forwarded-Host", "example.com")
|
|
||||||
req.Header.Set("X-Forwarded-Uri", "/somepath")
|
|
||||||
req.Header.Set("Accept", "text/html")
|
|
||||||
router.ServeHTTP(recorder, req)
|
|
||||||
|
|
||||||
assert.Equal(t, 307, recorder.Code)
|
|
||||||
assert.Equal(t, "http://localhost:8080/login?redirect_uri=https%3A%2F%2Fexample.com%2Fsomepath", recorder.Header().Get("Location"))
|
|
||||||
|
|
||||||
// Test logged out user (envoy - DELETE method)
|
|
||||||
recorder = httptest.NewRecorder()
|
|
||||||
req = httptest.NewRequest("DELETE", "/api/auth/envoy", nil)
|
|
||||||
req.Header.Set("X-Forwarded-Proto", "https")
|
|
||||||
req.Header.Set("X-Forwarded-Host", "example.com")
|
|
||||||
req.Header.Set("X-Forwarded-Uri", "/somepath")
|
|
||||||
req.Header.Set("Accept", "text/html")
|
|
||||||
router.ServeHTTP(recorder, req)
|
|
||||||
|
|
||||||
assert.Equal(t, 307, recorder.Code)
|
|
||||||
assert.Equal(t, "http://localhost:8080/login?redirect_uri=https%3A%2F%2Fexample.com%2Fsomepath", recorder.Header().Get("Location"))
|
|
||||||
|
|
||||||
// Test logged out user (nginx)
|
|
||||||
recorder = httptest.NewRecorder()
|
|
||||||
req = httptest.NewRequest("GET", "/api/auth/nginx", nil)
|
|
||||||
req.Header.Set("X-Forwarded-Proto", "https")
|
|
||||||
req.Header.Set("X-Forwarded-Host", "example.com")
|
|
||||||
req.Header.Set("X-Forwarded-Uri", "/somepath")
|
|
||||||
router.ServeHTTP(recorder, req)
|
|
||||||
|
|
||||||
assert.Equal(t, 401, recorder.Code)
|
|
||||||
|
|
||||||
// Test logged in user
|
|
||||||
router, recorder, _ = setupProxyController(t, &[]gin.HandlerFunc{
|
|
||||||
func(c *gin.Context) {
|
func(c *gin.Context) {
|
||||||
c.Set("context", &config.UserContext{
|
c.Set("context", &loggedInCtx)
|
||||||
Username: "testuser",
|
|
||||||
Name: "testuser",
|
|
||||||
Email: "testuser@example.com",
|
|
||||||
IsLoggedIn: true,
|
|
||||||
OAuth: false,
|
|
||||||
Provider: "local",
|
|
||||||
TotpPending: false,
|
|
||||||
OAuthGroups: "",
|
|
||||||
TotpEnabled: false,
|
|
||||||
})
|
|
||||||
c.Next()
|
c.Next()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
req = httptest.NewRequest("GET", "/api/auth/traefik", nil)
|
req, err = http.NewRequest("GET", "/api/auth/traefik", nil)
|
||||||
req.Header.Set("X-Forwarded-Proto", "https")
|
assert.NilError(t, err)
|
||||||
req.Header.Set("X-Forwarded-Host", "example.com")
|
|
||||||
req.Header.Set("X-Forwarded-Uri", "/somepath")
|
req.Header.Set("x-forwarded-host", "whoami.example.com")
|
||||||
req.Header.Set("Accept", "text/html")
|
req.Header.Set("x-forwarded-proto", "http")
|
||||||
|
req.Header.Set("x-forwarded-uri", "/")
|
||||||
|
|
||||||
router.ServeHTTP(recorder, req)
|
router.ServeHTTP(recorder, req)
|
||||||
assert.Equal(t, 200, recorder.Code)
|
assert.Equal(t, recorder.Code, http.StatusOK)
|
||||||
|
|
||||||
assert.Equal(t, "testuser", recorder.Header().Get("Remote-User"))
|
// Test logged in user nginx (auth_request)
|
||||||
assert.Equal(t, "testuser", recorder.Header().Get("Remote-Name"))
|
router, recorder = setupProxyController(t, []gin.HandlerFunc{
|
||||||
assert.Equal(t, "testuser@example.com", recorder.Header().Get("Remote-Email"))
|
func(c *gin.Context) {
|
||||||
|
c.Set("context", &loggedInCtx)
|
||||||
|
c.Next()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
req, err = http.NewRequest("GET", "/api/auth/nginx", nil)
|
||||||
|
assert.NilError(t, err)
|
||||||
|
|
||||||
|
req.Header.Set("x-original-url", "http://whoami.example.com/")
|
||||||
|
|
||||||
|
router.ServeHTTP(recorder, req)
|
||||||
|
assert.Equal(t, recorder.Code, http.StatusOK)
|
||||||
|
|
||||||
|
// Test logged in user envoy (ext_authz)
|
||||||
|
router, recorder = setupProxyController(t, []gin.HandlerFunc{
|
||||||
|
func(c *gin.Context) {
|
||||||
|
c.Set("context", &loggedInCtx)
|
||||||
|
c.Next()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
req, err = http.NewRequest("GET", "/api/auth/envoy?path=/", nil)
|
||||||
|
assert.NilError(t, err)
|
||||||
|
|
||||||
|
req.Host = "whoami.example.com"
|
||||||
|
req.Header.Set("x-forwarded-proto", "http")
|
||||||
|
|
||||||
|
router.ServeHTTP(recorder, req)
|
||||||
|
assert.Equal(t, recorder.Code, http.StatusOK)
|
||||||
|
|
||||||
|
// Test ACL allow caddy/traefik (forward_auth)
|
||||||
|
router, recorder = setupProxyController(t, nil)
|
||||||
|
|
||||||
|
req, err = http.NewRequest("GET", "/api/auth/traefik", nil)
|
||||||
|
assert.NilError(t, err)
|
||||||
|
|
||||||
|
req.Header.Set("x-forwarded-host", "whoami.example.com")
|
||||||
|
req.Header.Set("x-forwarded-proto", "http")
|
||||||
|
req.Header.Set("x-forwarded-uri", "/allow")
|
||||||
|
|
||||||
|
router.ServeHTTP(recorder, req)
|
||||||
|
assert.Equal(t, recorder.Code, http.StatusOK)
|
||||||
|
|
||||||
|
// Test ACL allow nginx
|
||||||
|
router, recorder = setupProxyController(t, nil)
|
||||||
|
|
||||||
|
req, err = http.NewRequest("GET", "/api/auth/nginx", nil)
|
||||||
|
assert.NilError(t, err)
|
||||||
|
|
||||||
|
req.Header.Set("x-original-url", "http://whoami.example.com/allow")
|
||||||
|
|
||||||
|
router.ServeHTTP(recorder, req)
|
||||||
|
assert.Equal(t, recorder.Code, http.StatusOK)
|
||||||
|
|
||||||
|
// Test ACL allow envoy
|
||||||
|
router, recorder = setupProxyController(t, nil)
|
||||||
|
|
||||||
|
req, err = http.NewRequest("GET", "/api/auth/envoy?path=/allow", nil)
|
||||||
|
assert.NilError(t, err)
|
||||||
|
|
||||||
|
req.Host = "whoami.example.com"
|
||||||
|
req.Header.Set("x-forwarded-proto", "http")
|
||||||
|
|
||||||
|
router.ServeHTTP(recorder, req)
|
||||||
|
assert.Equal(t, recorder.Code, http.StatusOK)
|
||||||
|
|
||||||
|
// Test traefik/caddy (forward_auth) without required headers
|
||||||
|
router, recorder = setupProxyController(t, nil)
|
||||||
|
|
||||||
|
req, err = http.NewRequest("GET", "/api/auth/traefik", nil)
|
||||||
|
assert.NilError(t, err)
|
||||||
|
|
||||||
|
router.ServeHTTP(recorder, req)
|
||||||
|
assert.Equal(t, recorder.Code, http.StatusBadRequest)
|
||||||
|
|
||||||
|
// Test nginx (forward_auth) without required headers
|
||||||
|
router, recorder = setupProxyController(t, nil)
|
||||||
|
|
||||||
|
req, err = http.NewRequest("GET", "/api/auth/nginx", nil)
|
||||||
|
assert.NilError(t, err)
|
||||||
|
|
||||||
|
router.ServeHTTP(recorder, req)
|
||||||
|
assert.Equal(t, recorder.Code, http.StatusBadRequest)
|
||||||
|
|
||||||
|
// Test envoy (forward_auth) without required headers
|
||||||
|
router, recorder = setupProxyController(t, nil)
|
||||||
|
|
||||||
|
req, err = http.NewRequest("GET", "/api/auth/envoy", nil)
|
||||||
|
assert.NilError(t, err)
|
||||||
|
|
||||||
|
router.ServeHTTP(recorder, req)
|
||||||
|
assert.Equal(t, recorder.Code, http.StatusBadRequest)
|
||||||
|
|
||||||
|
// Test nginx (auth_request) with forward_auth fallback with ACLs
|
||||||
|
router, recorder = setupProxyController(t, nil)
|
||||||
|
|
||||||
|
req, err = http.NewRequest("GET", "/api/auth/nginx", nil)
|
||||||
|
assert.NilError(t, err)
|
||||||
|
|
||||||
|
req.Header.Set("x-forwarded-host", "whoami.example.com")
|
||||||
|
req.Header.Set("x-forwarded-proto", "http")
|
||||||
|
req.Header.Set("x-forwarded-uri", "/allow")
|
||||||
|
|
||||||
|
router.ServeHTTP(recorder, req)
|
||||||
|
assert.Equal(t, recorder.Code, http.StatusOK)
|
||||||
|
|
||||||
|
// Test envoy (ext_authz) with forward_auth fallback with ACLs
|
||||||
|
router, recorder = setupProxyController(t, nil)
|
||||||
|
|
||||||
|
req, err = http.NewRequest("GET", "/api/auth/envoy", nil)
|
||||||
|
assert.NilError(t, err)
|
||||||
|
|
||||||
|
req.Header.Set("x-forwarded-host", "whoami.example.com")
|
||||||
|
req.Header.Set("x-forwarded-proto", "http")
|
||||||
|
req.Header.Set("x-forwarded-uri", "/allow")
|
||||||
|
|
||||||
|
router.ServeHTTP(recorder, req)
|
||||||
|
assert.Equal(t, recorder.Code, http.StatusOK)
|
||||||
|
|
||||||
|
// Test envoy (ext_authz) with empty path
|
||||||
|
router, recorder = setupProxyController(t, nil)
|
||||||
|
|
||||||
|
req, err = http.NewRequest("GET", "/api/auth/envoy", nil)
|
||||||
|
assert.NilError(t, err)
|
||||||
|
|
||||||
|
req.Host = "whoami.example.com"
|
||||||
|
req.Header.Set("x-forwarded-proto", "http")
|
||||||
|
|
||||||
|
router.ServeHTTP(recorder, req)
|
||||||
|
assert.Equal(t, recorder.Code, http.StatusUnauthorized)
|
||||||
|
|
||||||
|
// Ensure forward_auth fallback works with path (should ignore)
|
||||||
|
router, recorder = setupProxyController(t, nil)
|
||||||
|
|
||||||
|
req, err = http.NewRequest("GET", "/api/auth/traefik?path=/allow", nil)
|
||||||
|
assert.NilError(t, err)
|
||||||
|
|
||||||
|
req.Header.Set("x-forwarded-proto", "http")
|
||||||
|
req.Header.Set("x-forwarded-host", "whoami.example.com")
|
||||||
|
req.Header.Set("x-forwarded-uri", "/allow")
|
||||||
|
|
||||||
|
router.ServeHTTP(recorder, req)
|
||||||
|
assert.Equal(t, recorder.Code, http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ func setupUserController(t *testing.T, middlewares *[]gin.HandlerFunc) (*gin.Eng
|
|||||||
LoginTimeout: 300,
|
LoginTimeout: 300,
|
||||||
LoginMaxRetries: 3,
|
LoginMaxRetries: 3,
|
||||||
SessionCookieName: "tinyauth-session",
|
SessionCookieName: "tinyauth-session",
|
||||||
}, nil, nil, queries)
|
}, nil, nil, queries, &service.OAuthBrokerService{})
|
||||||
|
|
||||||
// Controller
|
// Controller
|
||||||
ctrl := controller.NewUserController(controller.UserControllerConfig{
|
ctrl := controller.NewUserController(controller.UserControllerConfig{
|
||||||
|
|||||||
@@ -17,8 +17,17 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"golang.org/x/oauth2"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type OAuthPendingSession struct {
|
||||||
|
State string
|
||||||
|
Verifier string
|
||||||
|
Token *oauth2.Token
|
||||||
|
Service *OAuthServiceImpl
|
||||||
|
ExpiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
type LdapGroupsCache struct {
|
type LdapGroupsCache struct {
|
||||||
Groups []string
|
Groups []string
|
||||||
Expires time.Time
|
Expires time.Time
|
||||||
@@ -45,28 +54,34 @@ type AuthServiceConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AuthService struct {
|
type AuthService struct {
|
||||||
config AuthServiceConfig
|
config AuthServiceConfig
|
||||||
docker *DockerService
|
docker *DockerService
|
||||||
loginAttempts map[string]*LoginAttempt
|
loginAttempts map[string]*LoginAttempt
|
||||||
ldapGroupsCache map[string]*LdapGroupsCache
|
ldapGroupsCache map[string]*LdapGroupsCache
|
||||||
loginMutex sync.RWMutex
|
oauthPendingSessions map[string]*OAuthPendingSession
|
||||||
ldapGroupsMutex sync.RWMutex
|
oauthMutex sync.RWMutex
|
||||||
ldap *LdapService
|
loginMutex sync.RWMutex
|
||||||
queries *repository.Queries
|
ldapGroupsMutex sync.RWMutex
|
||||||
|
ldap *LdapService
|
||||||
|
queries *repository.Queries
|
||||||
|
oauthBroker *OAuthBrokerService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAuthService(config AuthServiceConfig, docker *DockerService, ldap *LdapService, queries *repository.Queries) *AuthService {
|
func NewAuthService(config AuthServiceConfig, docker *DockerService, ldap *LdapService, queries *repository.Queries, oauthBroker *OAuthBrokerService) *AuthService {
|
||||||
return &AuthService{
|
return &AuthService{
|
||||||
config: config,
|
config: config,
|
||||||
docker: docker,
|
docker: docker,
|
||||||
loginAttempts: make(map[string]*LoginAttempt),
|
loginAttempts: make(map[string]*LoginAttempt),
|
||||||
ldapGroupsCache: make(map[string]*LdapGroupsCache),
|
ldapGroupsCache: make(map[string]*LdapGroupsCache),
|
||||||
ldap: ldap,
|
oauthPendingSessions: make(map[string]*OAuthPendingSession),
|
||||||
queries: queries,
|
ldap: ldap,
|
||||||
|
queries: queries,
|
||||||
|
oauthBroker: oauthBroker,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (auth *AuthService) Init() error {
|
func (auth *AuthService) Init() error {
|
||||||
|
go auth.CleanupOAuthSessionsRoutine()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -553,3 +568,137 @@ func (auth *AuthService) IsBypassedIP(acls config.AppIP, ip string) bool {
|
|||||||
tlog.App.Debug().Str("ip", ip).Msg("IP not in bypass list, continuing with authentication")
|
tlog.App.Debug().Str("ip", ip).Msg("IP not in bypass list, continuing with authentication")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (auth *AuthService) NewOAuthSession(serviceName string) (string, OAuthPendingSession, error) {
|
||||||
|
service, ok := auth.oauthBroker.GetService(serviceName)
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
return "", OAuthPendingSession{}, fmt.Errorf("oauth service not found: %s", serviceName)
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionId, err := uuid.NewRandom()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return "", OAuthPendingSession{}, fmt.Errorf("failed to generate session ID: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
state := service.NewRandom()
|
||||||
|
verifier := service.NewRandom()
|
||||||
|
|
||||||
|
session := OAuthPendingSession{
|
||||||
|
State: state,
|
||||||
|
Verifier: verifier,
|
||||||
|
Service: &service,
|
||||||
|
ExpiresAt: time.Now().Add(1 * time.Hour),
|
||||||
|
}
|
||||||
|
|
||||||
|
auth.oauthMutex.Lock()
|
||||||
|
auth.oauthPendingSessions[sessionId.String()] = &session
|
||||||
|
auth.oauthMutex.Unlock()
|
||||||
|
|
||||||
|
return sessionId.String(), session, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (auth *AuthService) GetOAuthURL(sessionId string) (string, error) {
|
||||||
|
session, err := auth.getOAuthPendingSession(sessionId)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return (*session.Service).GetAuthURL(session.State, session.Verifier), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (auth *AuthService) GetOAuthToken(sessionId string, code string) (*oauth2.Token, error) {
|
||||||
|
session, err := auth.getOAuthPendingSession(sessionId)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := (*session.Service).GetToken(code, session.Verifier)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to exchange code for token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
auth.oauthMutex.Lock()
|
||||||
|
session.Token = token
|
||||||
|
auth.oauthMutex.Unlock()
|
||||||
|
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (auth *AuthService) GetOAuthUserinfo(sessionId string) (config.Claims, error) {
|
||||||
|
session, err := auth.getOAuthPendingSession(sessionId)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return config.Claims{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if session.Token == nil {
|
||||||
|
return config.Claims{}, fmt.Errorf("oauth token not found for session: %s", sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
userinfo, err := (*session.Service).GetUserinfo(session.Token)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return config.Claims{}, fmt.Errorf("failed to get userinfo: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return userinfo, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (auth *AuthService) GetOAuthService(sessionId string) (OAuthServiceImpl, error) {
|
||||||
|
session, err := auth.getOAuthPendingSession(sessionId)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return *session.Service, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (auth *AuthService) EndOAuthSession(sessionId string) {
|
||||||
|
auth.oauthMutex.Lock()
|
||||||
|
delete(auth.oauthPendingSessions, sessionId)
|
||||||
|
auth.oauthMutex.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (auth *AuthService) CleanupOAuthSessionsRoutine() {
|
||||||
|
ticker := time.NewTicker(30 * time.Minute)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for range ticker.C {
|
||||||
|
auth.oauthMutex.Lock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
for sessionId, session := range auth.oauthPendingSessions {
|
||||||
|
if now.After(session.ExpiresAt) {
|
||||||
|
delete(auth.oauthPendingSessions, sessionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
auth.oauthMutex.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (auth *AuthService) getOAuthPendingSession(sessionId string) (*OAuthPendingSession, error) {
|
||||||
|
auth.oauthMutex.RLock()
|
||||||
|
session, exists := auth.oauthPendingSessions[sessionId]
|
||||||
|
auth.oauthMutex.RUnlock()
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
return &OAuthPendingSession{}, fmt.Errorf("oauth session not found: %s", sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
if time.Now().After(session.ExpiresAt) {
|
||||||
|
auth.oauthMutex.Lock()
|
||||||
|
delete(auth.oauthPendingSessions, sessionId)
|
||||||
|
auth.oauthMutex.Unlock()
|
||||||
|
return &OAuthPendingSession{}, fmt.Errorf("oauth session expired: %s", sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return session, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,132 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/rand"
|
|
||||||
"crypto/tls"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/steveiliop56/tinyauth/internal/config"
|
|
||||||
"github.com/steveiliop56/tinyauth/internal/utils/tlog"
|
|
||||||
|
|
||||||
"golang.org/x/oauth2"
|
|
||||||
)
|
|
||||||
|
|
||||||
type GenericOAuthService struct {
|
|
||||||
config oauth2.Config
|
|
||||||
context context.Context
|
|
||||||
token *oauth2.Token
|
|
||||||
verifier string
|
|
||||||
insecureSkipVerify bool
|
|
||||||
userinfoUrl string
|
|
||||||
name string
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewGenericOAuthService(config config.OAuthServiceConfig) *GenericOAuthService {
|
|
||||||
return &GenericOAuthService{
|
|
||||||
config: oauth2.Config{
|
|
||||||
ClientID: config.ClientID,
|
|
||||||
ClientSecret: config.ClientSecret,
|
|
||||||
RedirectURL: config.RedirectURL,
|
|
||||||
Scopes: config.Scopes,
|
|
||||||
Endpoint: oauth2.Endpoint{
|
|
||||||
AuthURL: config.AuthURL,
|
|
||||||
TokenURL: config.TokenURL,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
insecureSkipVerify: config.Insecure,
|
|
||||||
userinfoUrl: config.UserinfoURL,
|
|
||||||
name: config.Name,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (generic *GenericOAuthService) Init() error {
|
|
||||||
transport := &http.Transport{
|
|
||||||
TLSClientConfig: &tls.Config{
|
|
||||||
InsecureSkipVerify: generic.insecureSkipVerify,
|
|
||||||
MinVersion: tls.VersionTLS12,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
httpClient := &http.Client{
|
|
||||||
Transport: transport,
|
|
||||||
Timeout: 30 * time.Second,
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient)
|
|
||||||
|
|
||||||
generic.context = ctx
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (generic *GenericOAuthService) GenerateState() string {
|
|
||||||
b := make([]byte, 128)
|
|
||||||
_, err := rand.Read(b)
|
|
||||||
if err != nil {
|
|
||||||
return base64.RawURLEncoding.EncodeToString(fmt.Appendf(nil, "state-%d", time.Now().UnixNano()))
|
|
||||||
}
|
|
||||||
state := base64.RawURLEncoding.EncodeToString(b)
|
|
||||||
return state
|
|
||||||
}
|
|
||||||
|
|
||||||
func (generic *GenericOAuthService) GenerateVerifier() string {
|
|
||||||
verifier := oauth2.GenerateVerifier()
|
|
||||||
generic.verifier = verifier
|
|
||||||
return verifier
|
|
||||||
}
|
|
||||||
|
|
||||||
func (generic *GenericOAuthService) GetAuthURL(state string) string {
|
|
||||||
return generic.config.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(generic.verifier))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (generic *GenericOAuthService) VerifyCode(code string) error {
|
|
||||||
token, err := generic.config.Exchange(generic.context, code, oauth2.VerifierOption(generic.verifier))
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
generic.token = token
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (generic *GenericOAuthService) Userinfo() (config.Claims, error) {
|
|
||||||
var user config.Claims
|
|
||||||
|
|
||||||
client := generic.config.Client(generic.context, generic.token)
|
|
||||||
|
|
||||||
res, err := client.Get(generic.userinfoUrl)
|
|
||||||
if err != nil {
|
|
||||||
return user, err
|
|
||||||
}
|
|
||||||
defer res.Body.Close()
|
|
||||||
|
|
||||||
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
|
||||||
return user, fmt.Errorf("request failed with status: %s", res.Status)
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := io.ReadAll(res.Body)
|
|
||||||
if err != nil {
|
|
||||||
return user, err
|
|
||||||
}
|
|
||||||
|
|
||||||
tlog.App.Trace().Str("body", string(body)).Msg("Userinfo response body")
|
|
||||||
|
|
||||||
err = json.Unmarshal(body, &user)
|
|
||||||
if err != nil {
|
|
||||||
return user, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return user, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (generic *GenericOAuthService) GetName() string {
|
|
||||||
return generic.name
|
|
||||||
}
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/rand"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/steveiliop56/tinyauth/internal/config"
|
|
||||||
|
|
||||||
"golang.org/x/oauth2"
|
|
||||||
"golang.org/x/oauth2/endpoints"
|
|
||||||
)
|
|
||||||
|
|
||||||
var GithubOAuthScopes = []string{"user:email", "read:user"}
|
|
||||||
|
|
||||||
type GithubEmailResponse []struct {
|
|
||||||
Email string `json:"email"`
|
|
||||||
Primary bool `json:"primary"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type GithubUserInfoResponse struct {
|
|
||||||
Login string `json:"login"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
ID int `json:"id"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type GithubOAuthService struct {
|
|
||||||
config oauth2.Config
|
|
||||||
context context.Context
|
|
||||||
token *oauth2.Token
|
|
||||||
verifier string
|
|
||||||
name string
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewGithubOAuthService(config config.OAuthServiceConfig) *GithubOAuthService {
|
|
||||||
return &GithubOAuthService{
|
|
||||||
config: oauth2.Config{
|
|
||||||
ClientID: config.ClientID,
|
|
||||||
ClientSecret: config.ClientSecret,
|
|
||||||
RedirectURL: config.RedirectURL,
|
|
||||||
Scopes: GithubOAuthScopes,
|
|
||||||
Endpoint: endpoints.GitHub,
|
|
||||||
},
|
|
||||||
name: config.Name,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (github *GithubOAuthService) Init() error {
|
|
||||||
httpClient := &http.Client{
|
|
||||||
Timeout: 30 * time.Second,
|
|
||||||
}
|
|
||||||
ctx := context.Background()
|
|
||||||
ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient)
|
|
||||||
github.context = ctx
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (github *GithubOAuthService) GenerateState() string {
|
|
||||||
b := make([]byte, 128)
|
|
||||||
_, err := rand.Read(b)
|
|
||||||
if err != nil {
|
|
||||||
return base64.RawURLEncoding.EncodeToString(fmt.Appendf(nil, "state-%d", time.Now().UnixNano()))
|
|
||||||
}
|
|
||||||
state := base64.RawURLEncoding.EncodeToString(b)
|
|
||||||
return state
|
|
||||||
}
|
|
||||||
|
|
||||||
func (github *GithubOAuthService) GenerateVerifier() string {
|
|
||||||
verifier := oauth2.GenerateVerifier()
|
|
||||||
github.verifier = verifier
|
|
||||||
return verifier
|
|
||||||
}
|
|
||||||
|
|
||||||
func (github *GithubOAuthService) GetAuthURL(state string) string {
|
|
||||||
return github.config.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(github.verifier))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (github *GithubOAuthService) VerifyCode(code string) error {
|
|
||||||
token, err := github.config.Exchange(github.context, code, oauth2.VerifierOption(github.verifier))
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
github.token = token
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (github *GithubOAuthService) Userinfo() (config.Claims, error) {
|
|
||||||
var user config.Claims
|
|
||||||
|
|
||||||
client := github.config.Client(github.context, github.token)
|
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", "https://api.github.com/user", nil)
|
|
||||||
if err != nil {
|
|
||||||
return user, err
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Set("Accept", "application/vnd.github+json")
|
|
||||||
|
|
||||||
res, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return user, err
|
|
||||||
}
|
|
||||||
defer res.Body.Close()
|
|
||||||
|
|
||||||
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
|
||||||
return user, fmt.Errorf("request failed with status: %s", res.Status)
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := io.ReadAll(res.Body)
|
|
||||||
if err != nil {
|
|
||||||
return user, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var userInfo GithubUserInfoResponse
|
|
||||||
|
|
||||||
err = json.Unmarshal(body, &userInfo)
|
|
||||||
if err != nil {
|
|
||||||
return user, err
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err = http.NewRequest("GET", "https://api.github.com/user/emails", nil)
|
|
||||||
if err != nil {
|
|
||||||
return user, err
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Set("Accept", "application/vnd.github+json")
|
|
||||||
|
|
||||||
res, err = client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return user, err
|
|
||||||
}
|
|
||||||
defer res.Body.Close()
|
|
||||||
|
|
||||||
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
|
||||||
return user, fmt.Errorf("request failed with status: %s", res.Status)
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err = io.ReadAll(res.Body)
|
|
||||||
if err != nil {
|
|
||||||
return user, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var emails GithubEmailResponse
|
|
||||||
|
|
||||||
err = json.Unmarshal(body, &emails)
|
|
||||||
if err != nil {
|
|
||||||
return user, err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, email := range emails {
|
|
||||||
if email.Primary {
|
|
||||||
user.Email = email.Email
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(emails) == 0 {
|
|
||||||
return user, errors.New("no emails found")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use first available email if no primary email was found
|
|
||||||
if user.Email == "" {
|
|
||||||
user.Email = emails[0].Email
|
|
||||||
}
|
|
||||||
|
|
||||||
user.PreferredUsername = userInfo.Login
|
|
||||||
user.Name = userInfo.Name
|
|
||||||
user.Sub = strconv.Itoa(userInfo.ID)
|
|
||||||
|
|
||||||
return user, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (github *GithubOAuthService) GetName() string {
|
|
||||||
return github.name
|
|
||||||
}
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/rand"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/steveiliop56/tinyauth/internal/config"
|
|
||||||
|
|
||||||
"golang.org/x/oauth2"
|
|
||||||
"golang.org/x/oauth2/endpoints"
|
|
||||||
)
|
|
||||||
|
|
||||||
var GoogleOAuthScopes = []string{"openid", "email", "profile"}
|
|
||||||
|
|
||||||
type GoogleOAuthService struct {
|
|
||||||
config oauth2.Config
|
|
||||||
context context.Context
|
|
||||||
token *oauth2.Token
|
|
||||||
verifier string
|
|
||||||
name string
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewGoogleOAuthService(config config.OAuthServiceConfig) *GoogleOAuthService {
|
|
||||||
return &GoogleOAuthService{
|
|
||||||
config: oauth2.Config{
|
|
||||||
ClientID: config.ClientID,
|
|
||||||
ClientSecret: config.ClientSecret,
|
|
||||||
RedirectURL: config.RedirectURL,
|
|
||||||
Scopes: GoogleOAuthScopes,
|
|
||||||
Endpoint: endpoints.Google,
|
|
||||||
},
|
|
||||||
name: config.Name,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (google *GoogleOAuthService) Init() error {
|
|
||||||
httpClient := &http.Client{
|
|
||||||
Timeout: 30 * time.Second,
|
|
||||||
}
|
|
||||||
ctx := context.Background()
|
|
||||||
ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient)
|
|
||||||
google.context = ctx
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (oauth *GoogleOAuthService) GenerateState() string {
|
|
||||||
b := make([]byte, 128)
|
|
||||||
_, err := rand.Read(b)
|
|
||||||
if err != nil {
|
|
||||||
return base64.RawURLEncoding.EncodeToString(fmt.Appendf(nil, "state-%d", time.Now().UnixNano()))
|
|
||||||
}
|
|
||||||
state := base64.RawURLEncoding.EncodeToString(b)
|
|
||||||
return state
|
|
||||||
}
|
|
||||||
|
|
||||||
func (google *GoogleOAuthService) GenerateVerifier() string {
|
|
||||||
verifier := oauth2.GenerateVerifier()
|
|
||||||
google.verifier = verifier
|
|
||||||
return verifier
|
|
||||||
}
|
|
||||||
|
|
||||||
func (google *GoogleOAuthService) GetAuthURL(state string) string {
|
|
||||||
return google.config.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(google.verifier))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (google *GoogleOAuthService) VerifyCode(code string) error {
|
|
||||||
token, err := google.config.Exchange(google.context, code, oauth2.VerifierOption(google.verifier))
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
google.token = token
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (google *GoogleOAuthService) Userinfo() (config.Claims, error) {
|
|
||||||
var user config.Claims
|
|
||||||
|
|
||||||
client := google.config.Client(google.context, google.token)
|
|
||||||
|
|
||||||
res, err := client.Get("https://openidconnect.googleapis.com/v1/userinfo")
|
|
||||||
if err != nil {
|
|
||||||
return config.Claims{}, err
|
|
||||||
}
|
|
||||||
defer res.Body.Close()
|
|
||||||
|
|
||||||
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
|
||||||
return user, fmt.Errorf("request failed with status: %s", res.Status)
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := io.ReadAll(res.Body)
|
|
||||||
if err != nil {
|
|
||||||
return config.Claims{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = json.Unmarshal(body, &user)
|
|
||||||
if err != nil {
|
|
||||||
return config.Claims{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
user.PreferredUsername = strings.SplitN(user.Email, "@", 2)[0]
|
|
||||||
|
|
||||||
return user, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (google *GoogleOAuthService) GetName() string {
|
|
||||||
return google.name
|
|
||||||
}
|
|
||||||
@@ -1,60 +1,48 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
|
|
||||||
"github.com/steveiliop56/tinyauth/internal/config"
|
"github.com/steveiliop56/tinyauth/internal/config"
|
||||||
"github.com/steveiliop56/tinyauth/internal/utils/tlog"
|
"github.com/steveiliop56/tinyauth/internal/utils/tlog"
|
||||||
|
|
||||||
"golang.org/x/exp/slices"
|
"golang.org/x/exp/slices"
|
||||||
|
"golang.org/x/oauth2"
|
||||||
)
|
)
|
||||||
|
|
||||||
type OAuthService interface {
|
type OAuthServiceImpl interface {
|
||||||
Init() error
|
Name() string
|
||||||
GenerateState() string
|
NewRandom() string
|
||||||
GenerateVerifier() string
|
GetAuthURL(state string, verifier string) string
|
||||||
GetAuthURL(state string) string
|
GetToken(code string, verifier string) (*oauth2.Token, error)
|
||||||
VerifyCode(code string) error
|
GetUserinfo(token *oauth2.Token) (config.Claims, error)
|
||||||
Userinfo() (config.Claims, error)
|
|
||||||
GetName() string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type OAuthBrokerService struct {
|
type OAuthBrokerService struct {
|
||||||
services map[string]OAuthService
|
services map[string]OAuthServiceImpl
|
||||||
configs map[string]config.OAuthServiceConfig
|
configs map[string]config.OAuthServiceConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var presets = map[string]func(config config.OAuthServiceConfig) *OAuthService{
|
||||||
|
"github": newGitHubOAuthService,
|
||||||
|
"google": newGoogleOAuthService,
|
||||||
|
}
|
||||||
|
|
||||||
func NewOAuthBrokerService(configs map[string]config.OAuthServiceConfig) *OAuthBrokerService {
|
func NewOAuthBrokerService(configs map[string]config.OAuthServiceConfig) *OAuthBrokerService {
|
||||||
return &OAuthBrokerService{
|
return &OAuthBrokerService{
|
||||||
services: make(map[string]OAuthService),
|
services: make(map[string]OAuthServiceImpl),
|
||||||
configs: configs,
|
configs: configs,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (broker *OAuthBrokerService) Init() error {
|
func (broker *OAuthBrokerService) Init() error {
|
||||||
for name, cfg := range broker.configs {
|
for name, cfg := range broker.configs {
|
||||||
switch name {
|
if presetFunc, exists := presets[name]; exists {
|
||||||
case "github":
|
broker.services[name] = presetFunc(cfg)
|
||||||
service := NewGithubOAuthService(cfg)
|
tlog.App.Debug().Str("service", name).Msg("Loaded OAuth service from preset")
|
||||||
broker.services[name] = service
|
} else {
|
||||||
case "google":
|
broker.services[name] = NewOAuthService(cfg)
|
||||||
service := NewGoogleOAuthService(cfg)
|
tlog.App.Debug().Str("service", name).Msg("Loaded OAuth service from config")
|
||||||
broker.services[name] = service
|
|
||||||
default:
|
|
||||||
service := NewGenericOAuthService(cfg)
|
|
||||||
broker.services[name] = service
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for name, service := range broker.services {
|
|
||||||
err := service.Init()
|
|
||||||
if err != nil {
|
|
||||||
tlog.App.Error().Err(err).Msgf("Failed to initialize OAuth service: %s", name)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
tlog.App.Info().Str("service", name).Msg("Initialized OAuth service")
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,15 +55,7 @@ func (broker *OAuthBrokerService) GetConfiguredServices() []string {
|
|||||||
return services
|
return services
|
||||||
}
|
}
|
||||||
|
|
||||||
func (broker *OAuthBrokerService) GetService(name string) (OAuthService, bool) {
|
func (broker *OAuthBrokerService) GetService(name string) (OAuthServiceImpl, bool) {
|
||||||
service, exists := broker.services[name]
|
service, exists := broker.services[name]
|
||||||
return service, exists
|
return service, exists
|
||||||
}
|
}
|
||||||
|
|
||||||
func (broker *OAuthBrokerService) GetUser(service string) (config.Claims, error) {
|
|
||||||
oauthService, exists := broker.services[service]
|
|
||||||
if !exists {
|
|
||||||
return config.Claims{}, errors.New("oauth service not found")
|
|
||||||
}
|
|
||||||
return oauthService.Userinfo()
|
|
||||||
}
|
|
||||||
|
|||||||
102
internal/service/oauth_extractors.go
Normal file
102
internal/service/oauth_extractors.go
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/steveiliop56/tinyauth/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GithubEmailResponse []struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Primary bool `json:"primary"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GithubUserInfoResponse struct {
|
||||||
|
Login string `json:"login"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
ID int `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultExtractor(client *http.Client, url string) (config.Claims, error) {
|
||||||
|
return simpleReq[config.Claims](client, url, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func githubExtractor(client *http.Client, url string) (config.Claims, error) {
|
||||||
|
var user config.Claims
|
||||||
|
|
||||||
|
userInfo, err := simpleReq[GithubUserInfoResponse](client, "https://api.github.com/user", map[string]string{
|
||||||
|
"accept": "application/vnd.github+json",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return config.Claims{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
userEmails, err := simpleReq[GithubEmailResponse](client, "https://api.github.com/user/emails", map[string]string{
|
||||||
|
"accept": "application/vnd.github+json",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return config.Claims{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(userEmails) == 0 {
|
||||||
|
return user, errors.New("no emails found")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, email := range userEmails {
|
||||||
|
if email.Primary {
|
||||||
|
user.Email = email.Email
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use first available email if no primary email was found
|
||||||
|
if user.Email == "" {
|
||||||
|
user.Email = userEmails[0].Email
|
||||||
|
}
|
||||||
|
|
||||||
|
user.PreferredUsername = userInfo.Login
|
||||||
|
user.Name = userInfo.Name
|
||||||
|
user.Sub = strconv.Itoa(userInfo.ID)
|
||||||
|
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func simpleReq[T any](client *http.Client, url string, headers map[string]string) (T, error) {
|
||||||
|
var decodedRes T
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return decodedRes, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, value := range headers {
|
||||||
|
req.Header.Add(key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return decodedRes, err
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
|
||||||
|
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
||||||
|
return decodedRes, fmt.Errorf("request failed with status: %s", res.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(res.Body)
|
||||||
|
if err != nil {
|
||||||
|
return decodedRes, err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = json.Unmarshal(body, &decodedRes)
|
||||||
|
if err != nil {
|
||||||
|
return decodedRes, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return decodedRes, nil
|
||||||
|
}
|
||||||
23
internal/service/oauth_presets.go
Normal file
23
internal/service/oauth_presets.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/steveiliop56/tinyauth/internal/config"
|
||||||
|
"golang.org/x/oauth2/endpoints"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newGoogleOAuthService(config config.OAuthServiceConfig) *OAuthService {
|
||||||
|
scopes := []string{"openid", "email", "profile"}
|
||||||
|
config.Scopes = scopes
|
||||||
|
config.AuthURL = endpoints.Google.AuthURL
|
||||||
|
config.TokenURL = endpoints.Google.TokenURL
|
||||||
|
config.UserinfoURL = "https://openidconnect.googleapis.com/v1/userinfo"
|
||||||
|
return NewOAuthService(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newGitHubOAuthService(config config.OAuthServiceConfig) *OAuthService {
|
||||||
|
scopes := []string{"read:user", "user:email"}
|
||||||
|
config.Scopes = scopes
|
||||||
|
config.AuthURL = endpoints.GitHub.AuthURL
|
||||||
|
config.TokenURL = endpoints.GitHub.TokenURL
|
||||||
|
return NewOAuthService(config).WithUserinfoExtractor(githubExtractor)
|
||||||
|
}
|
||||||
78
internal/service/oauth_service.go
Normal file
78
internal/service/oauth_service.go
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/steveiliop56/tinyauth/internal/config"
|
||||||
|
"golang.org/x/oauth2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UserinfoExtractor func(client *http.Client, url string) (config.Claims, error)
|
||||||
|
|
||||||
|
type OAuthService struct {
|
||||||
|
serviceCfg config.OAuthServiceConfig
|
||||||
|
config *oauth2.Config
|
||||||
|
ctx context.Context
|
||||||
|
userinfoExtractor UserinfoExtractor
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewOAuthService(config config.OAuthServiceConfig) *OAuthService {
|
||||||
|
httpClient := &http.Client{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
Transport: &http.Transport{
|
||||||
|
TLSClientConfig: &tls.Config{
|
||||||
|
InsecureSkipVerify: config.Insecure,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient)
|
||||||
|
|
||||||
|
return &OAuthService{
|
||||||
|
serviceCfg: config,
|
||||||
|
config: &oauth2.Config{
|
||||||
|
ClientID: config.ClientID,
|
||||||
|
ClientSecret: config.ClientSecret,
|
||||||
|
RedirectURL: config.RedirectURL,
|
||||||
|
Scopes: config.Scopes,
|
||||||
|
Endpoint: oauth2.Endpoint{
|
||||||
|
AuthURL: config.AuthURL,
|
||||||
|
TokenURL: config.TokenURL,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ctx: ctx,
|
||||||
|
userinfoExtractor: defaultExtractor,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OAuthService) WithUserinfoExtractor(extractor UserinfoExtractor) *OAuthService {
|
||||||
|
s.userinfoExtractor = extractor
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OAuthService) Name() string {
|
||||||
|
return s.serviceCfg.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OAuthService) NewRandom() string {
|
||||||
|
// The generate verifier function just creates a random string,
|
||||||
|
// so we can use it to generate a random state as well
|
||||||
|
random := oauth2.GenerateVerifier()
|
||||||
|
return random
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OAuthService) GetAuthURL(state string, verifier string) string {
|
||||||
|
return s.config.AuthCodeURL(state, oauth2.AccessTypeOnline, oauth2.S256ChallengeOption(verifier))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OAuthService) GetToken(code string, verifier string) (*oauth2.Token, error) {
|
||||||
|
return s.config.Exchange(s.ctx, code, oauth2.VerifierOption(verifier))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OAuthService) GetUserinfo(token *oauth2.Token) (config.Claims, error) {
|
||||||
|
client := oauth2.NewClient(s.ctx, oauth2.StaticTokenSource(token))
|
||||||
|
return s.userinfoExtractor(client, s.serviceCfg.UserinfoURL)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user