Compare commits
1723 Commits
Author | SHA1 | Date |
---|---|---|
Maksim Nabokikh | 2081f7d057 | |
mayurwaghmode | b9d88c723f | |
dependabot[bot] | e74acdff6c | |
Márk Sági-Kazár | b479d26137 | |
dependabot[bot] | adb5454913 | |
dependabot[bot] | 4bcdcf8e1e | |
dependabot[bot] | 3df7c489ce | |
dependabot[bot] | ce11154529 | |
Bob Callaway | e1a407830d | |
Bob Callaway | 83e2df821e | |
dependabot[bot] | 454122ca22 | |
Björn Busse | 4a0218e87c | |
Joe Knight | 27c25d00be | |
dependabot[bot] | 367487d7c5 | |
dependabot[bot] | 3b7e56035a | |
dependabot[bot] | f53fab6b06 | |
dhaus67 | 100246328b | |
Maksim Nabokikh | d564cc7200 | |
Chance Zibolski | a3e2946cfc | |
Maksim Nabokikh | f49e7bc218 | |
Maksim Nabokikh | 9ebcd651ff | |
dependabot[bot] | 1aaa7fa0b7 | |
Maksim Nabokikh | c561318baa | |
Maksim Nabokikh | 5066414735 | |
Maksim Nabokikh | 731d0d7d9d | |
Márk Sági-Kazár | 1cc26fab2f | |
dependabot[bot] | f34529b13f | |
dependabot[bot] | ffec99287b | |
dependabot[bot] | ea46fc39ca | |
dependabot[bot] | e253fa8efb | |
Márk Sági-Kazár | c538f3d6a2 | |
dependabot[bot] | 33483aa179 | |
Maksim Nabokikh | b6c4112c88 | |
dependabot[bot] | 60228d8fd8 | |
Maksim Nabokikh | b07c8b1d8d | |
Michael Kelly | 9079c31637 | |
Michael Kelly | a51d12056f | |
Michael Kelly | 6c99a9b99d | |
Mark Sagi-Kazar | 3836196af2 | |
Márk Sági-Kazár | b578e4d8e5 | |
Mark Sagi-Kazar | 8360cbfbde | |
Márk Sági-Kazár | 465be883a3 | |
dependabot[bot] | 870395971e | |
Márk Sági-Kazár | ebb27418c4 | |
dependabot[bot] | 15a516684b | |
dependabot[bot] | dcb25d0c3d | |
dependabot[bot] | 59b69352e0 | |
dependabot[bot] | 89d1c51e9b | |
dependabot[bot] | a0fd469e47 | |
Maksim Nabokikh | b6cc099305 | |
Bob Callaway | 6eeba947f1 | |
Maksim Nabokikh | a858ffbcf2 | |
Shivansh Vij | 65592d0b5a | |
Shivansh Vij | cbf158bcc0 | |
Maksim Nabokikh | 6da5187b47 | |
Maksim Nabokikh | 957def7928 | |
Márk Sági-Kazár | ec4ac04c41 | |
m.nabokikh | bdfb10137a | |
dependabot[bot] | e9d17888d8 | |
Mark Sagi-Kazar | b4ccd92d65 | |
dependabot[bot] | 505726e7d5 | |
Márk Sági-Kazár | 70e6cc2205 | |
dependabot[bot] | 3df9cf2cb9 | |
Mark Sagi-Kazar | a02f2e8fac | |
dependabot[bot] | 55d963ac77 | |
dependabot[bot] | c2f3bea207 | |
Mark Sagi-Kazar | 1736f95024 | |
Mark Sagi-Kazar | ab02a2d714 | |
dependabot[bot] | a3dfe30a12 | |
dependabot[bot] | 1884705b87 | |
dependabot[bot] | 8e6d123772 | |
dependabot[bot] | 999d3855c1 | |
dependabot[bot] | 81818b9afe | |
dependabot[bot] | 2baf728d1f | |
dependabot[bot] | 7071480c2a | |
dependabot[bot] | 071969f172 | |
dependabot[bot] | f881fb4b2e | |
dependabot[bot] | bbb3bba01a | |
dependabot[bot] | 97c7f2491b | |
dependabot[bot] | 6c5286cbfe | |
dependabot[bot] | a3880c7371 | |
dependabot[bot] | dc0dfa771f | |
Márk Sági-Kazár | 6759369e16 | |
m.nabokikh | a7ca81f03a | |
dependabot[bot] | 51f1ec441d | |
dependabot[bot] | a4fb0a089f | |
dependabot[bot] | c98646f004 | |
Maksim Nabokikh | 9da59ce5dd | |
Michael Kelly | 502a2d0d4a | |
dependabot[bot] | f09af6102c | |
m.nabokikh | 3d5a3befb4 | |
m.nabokikh | 97254db62a | |
dependabot[bot] | 0270536a2e | |
dependabot[bot] | 861ad968c5 | |
dependabot[bot] | d26d4e15bc | |
dependabot[bot] | 96e0229205 | |
dependabot[bot] | 866f3e0c76 | |
dependabot[bot] | 47411e9a75 | |
dependabot[bot] | f26181558c | |
dependabot[bot] | 574650abe3 | |
Maksim Nabokikh | 3a83b6ce39 | |
dependabot[bot] | a232af7f28 | |
Maksim Nabokikh | c74ad3bb66 | |
m.nabokikh | a98ab893c2 | |
Maksim Nabokikh | 2571ae9096 | |
m.nabokikh | 38fe0f5319 | |
Márk Sági-Kazár | 92161abfdc | |
Anthony Brandelli | 5fe1647fc7 | |
Anthony Brandelli | 7c335e9337 | |
m.nabokikh | 35f58dca73 | |
Maksim Nabokikh | 9cd29bdee0 | |
Maksim Nabokikh | 997ec94a4a | |
Anthony Brandelli | f07a58a7f1 | |
Shuanglei Tao | 691f8be785 | |
Maksim Nabokikh | 453504c450 | |
dependabot[bot] | fd15dd2248 | |
dependabot[bot] | ebe1c8b14a | |
Márk Sági-Kazár | 5c70f1227f | |
Mark Sagi-Kazar | 0b5a9581cd | |
Shuanglei Tao | 7b75e1e0cc | |
Márk Sági-Kazár | 6f07a27fad | |
Mark Sagi-Kazar | 42f8f91ebf | |
Márk Sági-Kazár | 27fb1cf3bd | |
Mark Sagi-Kazar | a9fb4ae7ef | |
Mark Sagi-Kazar | b8f2186593 | |
Márk Sági-Kazár | ec9a57ee4b | |
Maksim Nabokikh | 7c60f79f10 | |
techknowlogick | 1067641e53 | |
dependabot[bot] | e9a43bf3cd | |
dependabot[bot] | 75d198bd85 | |
Mark Sagi-Kazar | c5c88a688b | |
Maksim Nabokikh | b26e639515 | |
m.nabokikh | ad89e01676 | |
Márk Sági-Kazár | c8ff7ed40a | |
Mark Sagi-Kazar | 3702525c86 | |
Mark Sagi-Kazar | 8b2ce6252d | |
Mark Sagi-Kazar | 6038af5044 | |
Mark Sagi-Kazar | 95e81a925f | |
Márk Sági-Kazár | 4a5f2dbb4d | |
Mark Sagi-Kazar | aa35fa6580 | |
Mark Sagi-Kazar | 0f5481a00a | |
Márk Sági-Kazár | 783a7621e0 | |
Mattias Gees | 169b5a59cc | |
Daniel Haus | 4088d4f897 | |
Daniel Haus | 2b262ff5d6 | |
m.nabokikh | 6822ad950f | |
Márk Sági-Kazár | a2089dd8e7 | |
Márk Sági-Kazár | fdc43a0c36 | |
dependabot[bot] | d8289d3429 | |
dependabot[bot] | 23de36d721 | |
Márk Sági-Kazár | a51ed2c4db | |
Mark Sagi-Kazar | 9b1a8409f1 | |
Mark Sagi-Kazar | b51e73bc2e | |
Mark Sagi-Kazar | 0c3c577b52 | |
Mark Sagi-Kazar | 20b03b3f6d | |
Mark Sagi-Kazar | 07a43f2d66 | |
dependabot[bot] | 493c0eb8b7 | |
Márk Sági-Kazár | 50dc2f5518 | |
dependabot[bot] | ff68ca1aae | |
dependabot[bot] | cf78e741ca | |
dependabot[bot] | e462d69353 | |
dependabot[bot] | b163944ee5 | |
dependabot[bot] | a136c0141e | |
dependabot[bot] | 2ebcd70d30 | |
dependabot[bot] | 6692759586 | |
Márk Sági-Kazár | 897ae8d2a3 | |
Mark Sagi-Kazar | 863416f0a3 | |
Márk Sági-Kazár | 090593b7f9 | |
dependabot[bot] | df1cb1cdbf | |
dependabot[bot] | 20e2e429b3 | |
dependabot[bot] | c98636457b | |
dependabot[bot] | f1cc7133da | |
dependabot[bot] | 111ce66bd0 | |
m.nabokikh | 57e9611ff6 | |
Maksim Nabokikh | cb9f0b5d5e | |
dependabot[bot] | a322f42a10 | |
dependabot[bot] | 22a7d3acd3 | |
Engin Diri | 5d9d68106a | |
dependabot[bot] | b83ba01c40 | |
Maksim Nabokikh | 5f9abc5be8 | |
dependabot[bot] | 98ed9b70a4 | |
dependabot[bot] | a190bba9e6 | |
Maksim Nabokikh | 5b0cb0704a | |
dependabot[bot] | 616e20b334 | |
dependabot[bot] | fd545e0493 | |
dependabot[bot] | 575d935792 | |
dependabot[bot] | e22c24dba2 | |
dependabot[bot] | 333b1d1971 | |
dependabot[bot] | d9535b8dc1 | |
dependabot[bot] | 364f7954fd | |
Márk Sági-Kazár | dde621980d | |
dependabot[bot] | 2e2471b21f | |
Mark Sagi-Kazar | 102762062b | |
Márk Sági-Kazár | 5f58d8e7d2 | |
Mark Sagi-Kazar | b97732f353 | |
Márk Sági-Kazár | f8685d2e83 | |
Mark Sagi-Kazar | 592a9f603f | |
Mark Sagi-Kazar | 727b0101f7 | |
Márk Sági-Kazár | 75c27c8dba | |
Márk Sági-Kazár | 470327e002 | |
Mark Sagi-Kazar | 8cee3927b4 | |
dependabot[bot] | 238c07ac33 | |
dependabot[bot] | bf034906fd | |
dependabot[bot] | d03d229ddc | |
dependabot[bot] | f8fcae5f07 | |
dependabot[bot] | 71d95d7aa2 | |
dependabot[bot] | 6275eba9ce | |
Maksim Nabokikh | bf0025fbd3 | |
dependabot[bot] | 2eedc5897c | |
Maksim Nabokikh | 13f93cb785 | |
m.nabokikh | 254165d665 | |
seuf | 4ee9658dfe | |
dependabot[bot] | 0f89054634 | |
Márk Sági-Kazár | 8519219dae | |
Rahul M Chheda | 2bc4ad6b56 | |
Mark Sagi-Kazar | cd44a3e4f3 | |
Márk Sági-Kazár | 49e15945a2 | |
Mark Sagi-Kazar | 12a904afdd | |
dependabot[bot] | 373bddaf73 | |
dependabot[bot] | 593d03789d | |
dependabot[bot] | deac802c73 | |
dependabot[bot] | b434058f19 | |
dependabot[bot] | c205b49189 | |
dependabot[bot] | 22d27c60e4 | |
Márk Sági-Kazár | 73ce1eb110 | |
Happy2C0de | 419db81c67 | |
Happy2C0de | 55605751f5 | |
Happy2C0de | b28098dde8 | |
Happy2C0de | 1608b473eb | |
Happy2C0de | 2b6bb1997c | |
Happy2C0de | 14a0aecc81 | |
Happy2C0de | 45143c98b3 | |
Andrew Keesler | 0394bf8cea | |
Andrew Keesler | a672ff9288 | |
Andrew Keesler | 764ce711b6 | |
Márk Sági-Kazár | ba1bd65c10 | |
Bob Callaway | 3e0f7c42b8 | |
Márk Sági-Kazár | f44af5c8e9 | |
Mark Sagi-Kazar | eb26422bdc | |
dependabot[bot] | 716eef83bc | |
dependabot[bot] | e8d8967a5b | |
dependabot[bot] | adaa31c0a5 | |
dependabot[bot] | 97abc800fb | |
dependabot[bot] | cb916cdf43 | |
dependabot[bot] | 84802f247f | |
dependabot[bot] | 3bc6a45ee1 | |
Márk Sági-Kazár | d112627564 | |
dependabot[bot] | 7f17aae35d | |
Márk Sági-Kazár | c8d55ce016 | |
dependabot[bot] | 39ddadcd8e | |
dependabot[bot] | 74dc922703 | |
dependabot[bot] | 25f5b47272 | |
Mark Sagi-Kazar | 79721196a8 | |
Stephen Augustus | 243661155e | |
dependabot[bot] | 3fa53bbc3d | |
dependabot[bot] | a407b5861b | |
dependabot[bot] | 93b32c3500 | |
dependabot[bot] | 4a42e80a7a | |
dependabot[bot] | a941593b8b | |
Maksim Nabokikh | 9d3471e39b | |
dependabot[bot] | 79233f41ef | |
dependabot[bot] | a413d9b383 | |
dependabot[bot] | b14b0fd127 | |
dependabot[bot] | ae1b50c26b | |
Maksim Nabokikh | ac02fb04cf | |
Maksim Nabokikh | ca615f7ad7 | |
Márk Sági-Kazár | 7ebc76b79e | |
Daniel Haus | 6256b863b0 | |
dependabot[bot] | 131bf83699 | |
m.nabokikh | 578cb05f7b | |
Daniel Haus | 6d55fe1c80 | |
dependabot[bot] | 40e21f14ca | |
dependabot[bot] | 1f30080e6a | |
Márk Sági-Kazár | e7c287a00d | |
Rui Yang | 539e08ba50 | |
Márk Sági-Kazár | e00e75b773 | |
iam-veeramalla | 528ef18c2f | |
dependabot[bot] | f70015dfed | |
dependabot[bot] | f717c71d66 | |
Rui Yang | 8b865169bd | |
Vlad Safronov | 7c80e44caf | |
Vlad Safronov | 45932bd38a | |
Rui Yang | f980d3e0a7 | |
Rui Yang | 8ea121b45a | |
Rui Yang | 49cb30af26 | |
Rui Yang | 02860da8b6 | |
Rui Yang | 60b8875780 | |
Rui Yang | 9952851cc4 | |
Rui Yang | fdf19e8014 | |
Rui Yang | 930b331a5b | |
Josh Winters | a087c05ebf | |
Joshua Winters | 9284ffb8c0 | |
Mark Sagi-Kazar | 1cb4b32fcb | |
Márk Sági-Kazár | 98e7d7a99d | |
Mark Sagi-Kazar | bc9322ff44 | |
dependabot[bot] | d3c4a170e3 | |
dependabot[bot] | 0aad109b6f | |
Mark Sagi-Kazar | e875745ee0 | |
Mark Sagi-Kazar | 49f9853a89 | |
Mark Sagi-Kazar | 1ecc17292b | |
Mark Sagi-Kazar | f45fe6d0c1 | |
Mark Sagi-Kazar | 054e397177 | |
Mark Sagi-Kazar | ee76923443 | |
Mark Sagi-Kazar | 1bfb1ab757 | |
Stephen Augustus (he/him) | f92fc54b7a | |
Márk Sági-Kazár | 21a8ac6d3c | |
Mark Sagi-Kazar | 12a2c2b104 | |
dependabot[bot] | a86beb8952 | |
dependabot[bot] | d6cf1704ea | |
dependabot[bot] | 14fe699dcf | |
dependabot[bot] | d2eec79e48 | |
Mark Sagi-Kazar | 588910468a | |
dependabot[bot] | aebe808162 | |
dependabot[bot] | 71d5c3415f | |
copperyp | 5854dd192d | |
copperyp | a1c1076137 | |
Maksim Nabokikh | 84b241721e | |
Márk Sági-Kazár | 18311aa44d | |
dependabot[bot] | 30bfb924c2 | |
dependabot[bot] | 58cac422f5 | |
dependabot[bot] | 5210e758d2 | |
dependabot[bot] | 514c2f29c6 | |
dependabot[bot] | e2c40f8f71 | |
dependabot[bot] | 66aba9c32b | |
dependabot[bot] | b9046ce566 | |
dependabot[bot] | ed5315bb2e | |
Stephen Augustus (he/him) | f7d2bf38b2 | |
m.nabokikh | 9fad0602ec | |
dependabot[bot] | c319983ecc | |
dependabot[bot] | a48c8ea9a4 | |
dependabot[bot] | 94597d8dc8 | |
Joel Speed | 4aa7e6846f | |
dependabot[bot] | 0dd5d65cc7 | |
Matt Hoey | ee5b5b25bd | |
dependabot[bot] | a15cd8788f | |
dependabot[bot] | 6bb627f3e3 | |
Bob Callaway | 2e0041f95f | |
Márk Sági-Kazár | 67ba7a1c70 | |
ariary | 7bc966217d | |
Bob Callaway | 8fd69c16f5 | |
dependabot[bot] | 8593933883 | |
Márk Sági-Kazár | ff6e7c7688 | |
m.nabokikh | d4e82e3315 | |
Maksim Nabokikh | f92a6f4457 | |
dependabot[bot] | 3456c3315d | |
dependabot[bot] | a417f5d1b0 | |
Maksim Nabokikh | 5169c4317d | |
Eng Zer Jun | 551022046c | |
Eng Zer Jun | f0186ff265 | |
dependabot[bot] | 40b426b276 | |
dependabot[bot] | 8a7c2b47f1 | |
ariary | c6f6dd69e9 | |
m.nabokikh | 575742b137 | |
m.nabokikh | 096e229562 | |
m.nabokikh | 4d4edaf540 | |
m.nabokikh | fb38e1235d | |
m.nabokikh | eae3219e4d | |
dependabot[bot] | 79ce4fdbad | |
dependabot[bot] | c9b4e8db3b | |
dependabot[bot] | 0d5b2ac060 | |
dependabot[bot] | b4b74955bd | |
kali | 1497e70225 | |
dependabot[bot] | 1b8f544873 | |
dependabot[bot] | e412369851 | |
dependabot[bot] | b74af809fc | |
dependabot[bot] | 656f5548d4 | |
dependabot[bot] | 53c6eb6675 | |
dependabot[bot] | b11a2a5604 | |
dependabot[bot] | 6dcf7a042d | |
dependabot[bot] | 33ba1d3b74 | |
dependabot[bot] | b3b9c26e5a | |
dependabot[bot] | 4488af24b6 | |
Márk Sági-Kazár | ad13fdc523 | |
Mark Sagi-Kazar | a950a24811 | |
Mark Sagi-Kazar | 691ecbbd9e | |
Mark Sagi-Kazar | e1c88b2598 | |
Mark Sagi-Kazar | 22db25ef94 | |
dependabot[bot] | e472fe668e | |
dependabot[bot] | d5727600ae | |
dependabot[bot] | 10a3ab9c4c | |
dependabot[bot] | fdc46d2bd3 | |
dependabot[bot] | 4f3e410e33 | |
dependabot[bot] | 091d9eae83 | |
Mark Sagi-Kazar | 2a54eb4e4f | |
dependabot[bot] | 6c61425e36 | |
dependabot[bot] | dc859e1ca9 | |
dependabot[bot] | 27d4075f54 | |
dependabot[bot] | 46a7f81f6d | |
dependabot[bot] | 7c0fd3f804 | |
dependabot[bot] | c110f12441 | |
Márk Sági-Kazár | f9f48016f4 | |
dependabot[bot] | 532bc88a65 | |
dependabot[bot] | f02415a83d | |
dependabot[bot] | c4066b2153 | |
dependabot[bot] | fa18b14437 | |
dependabot[bot] | 11a9476bef | |
Monis Khan | 3009ae3b5d | |
Radoslav Dimitrov | 6865d84ae4 | |
dependabot[bot] | 050339df86 | |
dependabot[bot] | 0e2459c230 | |
dependabot[bot] | 53c2dc30b5 | |
dependabot[bot] | 3c1763a14f | |
dependabot[bot] | fd67978363 | |
dependabot[bot] | d6f14bd2d5 | |
dependabot[bot] | 2109211d09 | |
Maksim Nabokikh | 3fac2ab6bc | |
Maksim Nabokikh | 766fc7ad99 | |
dependabot[bot] | 1f7ddac4e2 | |
dependabot[bot] | 3f5f6172af | |
Jesse Glick | 65edeff231 | |
dependabot[bot] | a6fbdc3ec8 | |
Jesse Glick | f1d4fec45e | |
Maksim Nabokikh | e650aef331 | |
Salman Ahmed | e1f3bfe418 | |
Tomasz Kleczek | 4ffaa60d21 | |
Henning | 138364ceeb | |
dependabot[bot] | ff10f84e42 | |
dependabot[bot] | d4bd37156d | |
dependabot[bot] | 30c6ddd556 | |
Márk Sági-Kazár | a9942794e5 | |
Mark Sagi-Kazar | 1e61e9b1b4 | |
Maksim Nabokikh | 5b7ec77538 | |
Maksim Nabokikh | 3d3f275efb | |
m.nabokikh | d413870f6e | |
Maksim Nabokikh | 823484f024 | |
Maksim Nabokikh | 033a8d89f2 | |
Maksim Nabokikh | 2211c515a6 | |
Márk Sági-Kazár | 39f0e0e0b0 | |
dependabot[bot] | be492e97ce | |
dependabot[bot] | 731e53913a | |
dependabot[bot] | 9bb764b63c | |
dependabot[bot] | 176a6738fd | |
dependabot[bot] | 6cac94f720 | |
dependabot[bot] | eec8ed6182 | |
dependabot[bot] | f72602c3bd | |
Márk Sági-Kazár | 0780edbcbe | |
Márk Sági-Kazár | 0d3d787511 | |
dependabot[bot] | 27cc11b4d6 | |
Mark Sagi-Kazar | ceb4324c18 | |
Mark Sagi-Kazar | e1ae7240f4 | |
Mark Sagi-Kazar | 61881d751b | |
Mark Sagi-Kazar | 2e5e1488e6 | |
Mark Sagi-Kazar | 452d466481 | |
Márk Sági-Kazár | 59c7e20c3c | |
Mark Sagi-Kazar | 81e884e903 | |
Márk Sági-Kazár | cfc8d198ac | |
Mark Sagi-Kazar | 215c3160f8 | |
dependabot[bot] | f7c09760f2 | |
dependabot[bot] | 79e1f25f21 | |
Márk Sági-Kazár | f6904c38ef | |
Márk Sági-Kazár | 5e6a8362c6 | |
dependabot[bot] | 3981ac8aa6 | |
dependabot[bot] | 76cb5f521c | |
noesberger | e51704e41a | |
dependabot[bot] | 9fe031776e | |
dependabot[bot] | cba7d69577 | |
dependabot[bot] | 5c315a3a4e | |
Márk Sági-Kazár | 0417789626 | |
dependabot[bot] | ddd19bf91d | |
Mark Sagi-Kazar | a207238491 | |
Mark Sagi-Kazar | 7043d944cf | |
Mark Sagi-Kazar | 831c0efe9c | |
m.nabokikh | 0754c30ac2 | |
m.nabokikh | 7a2472555a | |
Márk Sági-Kazár | baec4f79ce | |
Mark Sagi-Kazar | fd2c86d36e | |
dependabot[bot] | 6c8c336e9a | |
dependabot[bot] | 753cff1764 | |
dependabot[bot] | aece0ce873 | |
dependabot[bot] | 245a46e743 | |
Márk Sági-Kazár | f45a89f6b3 | |
m.nabokikh | 21a01ee811 | |
Maksim Nabokikh | 93ded5c406 | |
m.nabokikh | 97591861b2 | |
m.nabokikh | 00950eedd6 | |
m.nabokikh | bc5371e730 | |
m.nabokikh | 5a48d8a82d | |
Márk Sági-Kazár | 6384af06e4 | |
Márk Sági-Kazár | cdcf7a4694 | |
Maksim Nabokikh | 5d996661ea | |
m.nabokikh | 4b54433ec2 | |
Márk Sági-Kazár | 95941506f5 | |
Mark Sagi-Kazar | 8dbd0c6536 | |
Márk Sági-Kazár | aef61cea8d | |
Mark Sagi-Kazar | 0bef10ef80 | |
Márk Sági-Kazár | 5451188e29 | |
Mark Sagi-Kazar | ca02fc16bd | |
m.nabokikh | dea1d3383c | |
m.nabokikh | 13a83d9bba | |
Alastair Houghton | cd0c24ec4d | |
Alastair Houghton | 030a6459d6 | |
Alastair Houghton | 88025b3d7c | |
Alastair Houghton | 0284a4c3c9 | |
Alastair Houghton | cdbb5dd94d | |
dependabot[bot] | 4a874cce89 | |
dependabot[bot] | 461c5f687d | |
dependabot[bot] | 4e4dad023c | |
dependabot[bot] | 1220017f6c | |
Maksim Nabokikh | 20875c972e | |
dependabot[bot] | 47d029a51b | |
Márk Sági-Kazár | 18d1f70cee | |
Rui Yang | fe8085b886 | |
dependabot[bot] | 283dd89f4d | |
dependabot[bot] | c65652ed8f | |
m.nabokikh | 49adc4e5bb | |
m.nabokikh | 19884d92ac | |
Rui Yang | ecea593ddd | |
dependabot[bot] | 47bdbdb1a2 | |
Maksim Nabokikh | 81c4dc7994 | |
Márk Sági-Kazár | ba2cec3f72 | |
dependabot[bot] | fcca5f4b4f | |
dependabot[bot] | b1292bd630 | |
m.nabokikh | 8553309db3 | |
Márk Sági-Kazár | 94a2b3ed87 | |
m.nabokikh | 24fa4def5b | |
m.nabokikh | 2e61860d5a | |
m.nabokikh | 11859166d0 | |
Stephen Augustus | 674631c9ab | |
Márk Sági-Kazár | 47b0a2bdf9 | |
Mark Sagi-Kazar | e2b56d0a09 | |
m.nabokikh | 4561214ab2 | |
dependabot[bot] | afa6f1e03e | |
Mark Sagi-Kazar | df9fc78d2d | |
Mark Sagi-Kazar | bf8c35ad2d | |
Mark Sagi-Kazar | 59fcab281e | |
dependabot[bot] | 05b61a3d86 | |
Márk Sági-Kazár | 551229a986 | |
Márk Sági-Kazár | b1ac799073 | |
dependabot[bot] | 31c18e557a | |
dependabot[bot] | 5bc3cb2ad3 | |
dependabot[bot] | efd9839fd2 | |
dependabot[bot] | fa3a4d7f6b | |
Márk Sági-Kazár | 0b9b588c96 | |
m.nabokikh | 026d979073 | |
dependabot[bot] | e4065013a4 | |
dependabot[bot] | d4a2a362ab | |
dependabot[bot] | de6d1bea56 | |
dependabot[bot] | 8fbbd4cec9 | |
Márk Sági-Kazár | b79d9a84bc | |
Mark Sagi-Kazar | 03db309337 | |
Márk Sági-Kazár | c7549cce5b | |
dependabot[bot] | 656798c8bd | |
m.nabokikh | beb8911cf7 | |
dependabot[bot] | b73c406d21 | |
dependabot[bot] | 4b924f1d86 | |
dependabot[bot] | 55352575b8 | |
Márk Sági-Kazár | d2eb1b04dc | |
dependabot[bot] | 0f4ad150ce | |
dependabot[bot] | b57c8fa75b | |
dependabot[bot] | 1076081f79 | |
dependabot[bot] | f5a29bcdbb | |
Márk Sági-Kazár | e18510b16e | |
Mark Sagi-Kazar | 95796b04a3 | |
Márk Sági-Kazár | 2bf728c6ec | |
Mark Sagi-Kazar | 356ccecc24 | |
Márk Sági-Kazár | 8e7ce6353f | |
Mark Sagi-Kazar | d25051c867 | |
Mark Sagi-Kazar | 41712bcbfa | |
Márk Sági-Kazár | e3f8b0f2f6 | |
Márk Sági-Kazár | 8cba308b0e | |
Márk Sági-Kazár | 3adb4e74df | |
Márk Sági-Kazár | 1ec5cf07f2 | |
Mark Sagi-Kazar | 3e12618f0c | |
Salman Ahmed | bbd8b3b3cd | |
Mark Sagi-Kazar | 3ecdd57282 | |
Mark Sagi-Kazar | a050f3228a | |
Mark Sagi-Kazar | 3b80d480e5 | |
Mark Sagi-Kazar | d1e8b085e2 | |
Mark Sagi-Kazar | 78fcac7568 | |
Márk Sági-Kazár | 3f0ca9b361 | |
Rui Yang | 2f28fc7451 | |
Rui Yang | 4e569024fd | |
Rui Yang | 7b50cbf0ac | |
Rui Yang | 1eab25f89f | |
Rui Yang | 10e9054811 | |
Rui Yang | d658c24e8f | |
Josh Winters | ec6f3a2f19 | |
Márk Sági-Kazár | a1adf86e53 | |
Mark Sagi-Kazar | 27dfbc0344 | |
dependabot[bot] | 83ad7bc4e3 | |
dependabot[bot] | 8fee3cd212 | |
m.nabokikh | 6be747142a | |
dependabot[bot] | dab9f98a15 | |
dependabot[bot] | d93a238a42 | |
Márk Sági-Kazár | 3ae53f7434 | |
dependabot[bot] | b9ff4dd9ae | |
dependabot[bot] | 04b2f655e6 | |
Maksim Nabokikh | 568fc06520 | |
dependabot[bot] | 72d11017ce | |
dependabot[bot] | 08647537e2 | |
Márk Sági-Kazár | f7d1405cfd | |
Mark Sagi-Kazar | 24a1103f11 | |
Mark Sagi-Kazar | 9cffca70f2 | |
m.nabokikh | 3bd0e91a68 | |
m.nabokikh | 9ed5cc00cf | |
m.nabokikh | 1211a86d58 | |
Márk Sági-Kazár | 3c5a631ce3 | |
Márk Sági-Kazár | c73057f93d | |
m.nabokikh | 84a07a7805 | |
m.nabokikh | 796d4c1e6b | |
dependabot[bot] | c166257cf4 | |
dependabot[bot] | ff60ac0c4f | |
m.nabokikh | 1f2771b57e | |
Steffen Pøhner Henriksen | 0f68fadb9a | |
dependabot[bot] | b4238886b3 | |
dependabot[bot] | 9162eace7a | |
m.nabokikh | df86a1faca | |
dependabot[bot] | 86ea49173c | |
Márk Sági-Kazár | 08a10b063f | |
Mark Sagi-Kazar | 809ccaf4da | |
dependabot[bot] | 02cf3db178 | |
Joel Speed | 95d8a0cccb | |
Mark Sagi-Kazar | 6f70272bc3 | |
Mark Sagi-Kazar | f11db50369 | |
dependabot[bot] | 447841f513 | |
Márk Sági-Kazár | 39cb542cc3 | |
dependabot[bot] | ee10373993 | |
Stephen Augustus | 71351b1f47 | |
Márk Sági-Kazár | ce8b05b0be | |
m.nabokikh | 87ebbaf834 | |
Márk Sági-Kazár | a8c7ed9f67 | |
Brian Candler | aa615c04c9 | |
Márk Sági-Kazár | 35cd09d481 | |
Mark Sagi-Kazar | 7da0a89936 | |
Mark Sagi-Kazar | 316da70545 | |
Mark Sagi-Kazar | d77147f7cf | |
Mark Sagi-Kazar | 024f69b2c7 | |
m.nabokikh | 9340fee011 | |
m.nabokikh | 89295a5b4a | |
m.nabokikh | 4e73f39f57 | |
m.nabokikh | 0c75ed12e2 | |
m.nabokikh | 06c8ab5aa7 | |
m.nabokikh | 91de99d57e | |
Márk Sági-Kazár | 10597cf09f | |
m.nabokikh | 715fee7a01 | |
Márk Sági-Kazár | 5a667bbee0 | |
Márk Sági-Kazár | 9b1ecac0d9 | |
Márk Sági-Kazár | a7a92b0513 | |
Márk Sági-Kazár | 1c9fb499b4 | |
Márk Sági-Kazár | 1c551fd86b | |
Márk Sági-Kazár | 728ae7b348 | |
Márk Sági-Kazár | e50d9a908b | |
Maksim Nabokikh | 6664b5702d | |
dependabot[bot] | 8ff53d9d52 | |
dependabot[bot] | 4dcce60d5c | |
Maksim Nabokikh | dd4a62e645 | |
m.nabokikh | 7f744598f5 | |
m.nabokikh | 3241fd4ae2 | |
m.nabokikh | a6cb627763 | |
Maksim Nabokikh | 226c91df06 | |
m.nabokikh | d43053e11c | |
m.nabokikh | e13aac4963 | |
m.nabokikh | 891fa1785f | |
m.nabokikh | 7784a4727c | |
dependabot[bot] | 64e47cc22a | |
dependabot[bot] | b598eca785 | |
Márk Sági-Kazár | 31f26735ff | |
Mark Sagi-Kazar | 27a43669a7 | |
Mark Sagi-Kazar | e9b83e0a45 | |
Maksim Nabokikh | 65a8bf2af3 | |
m.nabokikh | f82c217e12 | |
m.nabokikh | d6b5105d9b | |
m.nabokikh | a7667dff38 | |
Stephen Augustus | c41f970b16 | |
Mark Sagi-Kazar | bb651cc664 | |
Mark Sagi-Kazar | c939e51cb4 | |
Mark Sagi-Kazar | d2d0d4a1ea | |
Mark Sagi-Kazar | a33669e3ec | |
Mark Sagi-Kazar | 7b2972a04b | |
Mark Sagi-Kazar | 89f737329b | |
Mark Sagi-Kazar | feb90bd1b1 | |
Márk Sági-Kazár | f7156c26eb | |
Mark Sagi-Kazar | b19fe5b49d | |
Mark Sagi-Kazar | fade69b5ae | |
Mark Sagi-Kazar | e49f6661f3 | |
Márk Sági-Kazár | 186a719ecb | |
Márk Sági-Kazár | 7cf43fdc7e | |
m.nabokikh | 30a5dade0f | |
m.nabokikh | 123185c456 | |
m.nabokikh | 283a87855a | |
dependabot[bot] | 369e16e97e | |
dependabot[bot] | 0ed680071c | |
Márk Sági-Kazár | a55de6c991 | |
m.nabokikh | bb503dbd81 | |
m.nabokikh | a7978890c7 | |
Martin Heide | c12c340e3c | |
m.nabokikh | b2e9f67edc | |
dependabot[bot] | 3650fe2287 | |
dependabot[bot] | dbd42ae777 | |
Mark Sagi-Kazar | f9b3d8fcb4 | |
Mark Sagi-Kazar | a73670488c | |
Mark Sagi-Kazar | 5bd55f6ee7 | |
dependabot[bot] | 6400052265 | |
dependabot[bot] | a255bda911 | |
Márk Sági-Kazár | 566ba720a2 | |
Mark Sagi-Kazar | 2cc8b2fe3c | |
dependabot[bot] | cb2c85b2a5 | |
Márk Sági-Kazár | 5bfe1a5d22 | |
dependabot[bot] | 1866a7acc8 | |
Márk Sági-Kazár | bf23d392b6 | |
Mark Sagi-Kazar | d51937b62d | |
Mark Sagi-Kazar | dc40d5b0df | |
Márk Sági-Kazár | c952ba743d | |
Mark Sagi-Kazar | 040950341d | |
Mark Sagi-Kazar | 3c686b24ce | |
Márk Sági-Kazár | afba7577bb | |
Márk Sági-Kazár | ccbf6c6e0f | |
Márk Sági-Kazár | 8515dfb35d | |
Mark Sagi-Kazar | e2ed31656e | |
Mark Sagi-Kazar | 0a88483409 | |
Mark Sagi-Kazar | 48d78ec0ab | |
Mark Sagi-Kazar | 0e1bc202c6 | |
Mark Sagi-Kazar | cff4a11b41 | |
Mark Sagi-Kazar | 97606bd623 | |
Mark Sagi-Kazar | 757a1eded5 | |
Mark Sagi-Kazar | e954e3f9d8 | |
Mark Sagi-Kazar | 49c9c607c9 | |
Mark Sagi-Kazar | 6742008fc2 | |
Mark Sagi-Kazar | c55d84b5d2 | |
Mark Sagi-Kazar | cdefd1f788 | |
Stephen Augustus | a189c25a6e | |
Mark Sagi-Kazar | 7775a7e27a | |
Mark Sagi-Kazar | b8ac640c4f | |
Márk Sági-Kazár | 1fbfaa9951 | |
Márk Sági-Kazár | 827889ee05 | |
m.nabokikh | eb10135774 | |
Maksim Nabokikh | 35da73de38 | |
m.nabokikh | 8fdb207848 | |
Erica Taylor | ba47aaba86 | |
m.nabokikh | 30c3d78365 | |
m.nabokikh | f2f19fa0d7 | |
Márk Sági-Kazár | 4f326390aa | |
Márk Sági-Kazár | ee50c09313 | |
Maik Brauer | 0d53fa2f42 | |
Maik Brauer | c55f17ea64 | |
Maik Brauer | 4d246bc9dc | |
Mark Sagi-Kazar | fc7f1ef6cc | |
Benjamin Ullian | b45a501c99 | |
Benjamin Ullian | 62abddca7d | |
Maik Brauer | eb9ef3b0ec | |
Mark Sagi-Kazar | a825a22f7a | |
Márk Sági-Kazár | e3383564b9 | |
Márk Sági-Kazár | 01f7bf73a0 | |
Márk Sági-Kazár | 4f0744ce80 | |
Martin Heide | 4cb5577e11 | |
Márk Sági-Kazár | 31353d2ccf | |
Alastair Houghton | 17c00e5a58 | |
IM CHAECHEOL | f2fcb2c989 | |
Isaac Parker | 0af41fb4ca | |
Márk Sági-Kazár | bae6bbb171 | |
m.nabokikh | 1e88cca59a | |
Márk Sági-Kazár | 2b9ef8058b | |
Josh Soref | 84e9cb6947 | |
Josh Soref | 86526cd030 | |
Josh Soref | fecd979bab | |
Josh Soref | 5d659a108c | |
Josh Soref | b155f66785 | |
Josh Soref | 43b95a2d28 | |
Josh Soref | 22de6da60b | |
Josh Soref | 97d3e8fa7f | |
Josh Soref | 801fd64a11 | |
Josh Soref | 3f8fdbf314 | |
Josh Soref | 791ad900cb | |
Josh Soref | 8476e5acc0 | |
Josh Soref | c79b40ad56 | |
Josh Soref | 6790aea260 | |
Josh Soref | d3d447fcf1 | |
Josh Soref | 119e4d66c6 | |
Josh Soref | 91e153780d | |
Josh Soref | 040abe5dc1 | |
Josh Soref | 1bfe2f6db2 | |
Josh Soref | a996c4ba54 | |
Josh Soref | a8851ceb1b | |
Josh Soref | 3352e4e74f | |
Josh Soref | 8905fb4a65 | |
Josh Soref | 0288864da7 | |
Thierry Sallé | e164bb381e | |
seuf | a1c7198738 | |
seuf | f19bccfc92 | |
seuf | a12a919d3e | |
Eric Chiang | 01befc00ff | |
Márk Sági-Kazár | 31839549cd | |
m.nabokikh | da4fb97912 | |
Márk Sági-Kazár | 10c6eb3186 | |
Mark Sagi-Kazar | 41c5916b97 | |
Mark Sagi-Kazar | 2c8fb8a3f2 | |
Mark Sagi-Kazar | 893413ac4f | |
Stephen Augustus | 0f9e2888ab | |
Stephen Augustus | 8ed9ef8ad8 | |
Stephen Augustus | c742b2a40a | |
Stephen Augustus | 2b0f47306b | |
Stephen Augustus | 324b1c886b | |
Stephen Augustus | 6e5176822b | |
Stephen Augustus | 57640cc7a9 | |
Stephan Renatus | 706c3bba68 | |
Stephen Augustus | a136ab6969 | |
Márk Sági-Kazár | 845fb1e0f0 | |
Peter Balogh | 64d7156d5f | |
Márk Sági-Kazár | 5a87bc5d59 | |
Chris Aniszczyk | ac43200665 | |
Stephen Augustus | 33e13c2aad | |
Márk Sági-Kazár | d97d6de88c | |
Martin Heide | f7efe49e5e | |
Márk Sági-Kazár | 6ca0cbc857 | |
A Gardner | 19d7edd530 | |
m.nabokikh | bcaddd4354 | |
Márk Sági-Kazár | 71bbbee075 | |
Mark Sagi-Kazar | 9b629b6568 | |
Mark Sagi-Kazar | 0520465207 | |
Mark Sagi-Kazar | b580ffad70 | |
Mark Sagi-Kazar | 4c86a5e7fe | |
Mark Sagi-Kazar | 85239d515d | |
Mark Sagi-Kazar | 10ac93d42b | |
Mark Sagi-Kazar | 5cc8b562ec | |
Mark Sagi-Kazar | b9bc0b8b11 | |
Mark Sagi-Kazar | b971415f0c | |
Mark Sagi-Kazar | 6500fdbdd1 | |
Mark Sagi-Kazar | d62f312402 | |
Mark Sagi-Kazar | fb282c3506 | |
Mark Sagi-Kazar | 1e14a33553 | |
Mark Sagi-Kazar | ef7e9e5c99 | |
Márk Sági-Kazár | 170794725d | |
Márk Sági-Kazár | 6fcd9b4887 | |
Márk Sági-Kazár | 40409eafe8 | |
Márk Sági-Kazár | bca77245df | |
Mark Sagi-Kazar | 349832b380 | |
Mark Sagi-Kazar | 84ea790885 | |
Mark Sagi-Kazar | cafea292ca | |
Mark Sagi-Kazar | 3841f05ba4 | |
Mark Sagi-Kazar | ed7b71a190 | |
Martin Heide | 162073b33e | |
Martin Heide | c15e2887bc | |
Martin Heide | 1ea481bb73 | |
Martin Heide | b894d9c888 | |
m.nabokikh | 7198f17d0e | |
m.nabokikh | be378dd9a7 | |
Márk Sági-Kazár | 6cdbb59406 | |
m.nabokikh | a5ad5eaf08 | |
Bernd Eckstein | b5519695a6 | |
Márk Sági-Kazár | 2a282860fa | |
Márk Sági-Kazár | c82d21b155 | |
m.nabokikh | 1d83e4749d | |
m.nabokikh | 4d63e9cd68 | |
Liviu Costea | 83673fb320 | |
Nándor István Krácser | 28b2350cd2 | |
Márk Sági-Kazár | 9c026107e6 | |
m.nabokikh | 4801b2c975 | |
Tom Quarendon | 4da93e75fc | |
Nate W | 3f41b26fb9 | |
Márk Sági-Kazár | d1f599dd32 | |
Márk Sági-Kazár | a28f5bb218 | |
Alastair Houghton | 3288450b3e | |
Alastair Houghton | 9187aa669d | |
Márk Sági-Kazár | 828a1c6ec2 | |
m.nabokikh | ec66cedfcc | |
Márk Sági-Kazár | 3e5ff2f853 | |
Mark Sagi-Kazar | 4a1fd77166 | |
Márk Sági-Kazár | 3b385ecf4a | |
m.nabokikh | 4b94469547 | |
Márk Sági-Kazár | d4c3a3505d | |
Rui Yang | bd2234cd12 | |
Márk Sági-Kazár | 9781e56ba5 | |
Márk Sági-Kazár | 641615ff58 | |
Mark Sagi-Kazar | f3fc0c5395 | |
Márk Sági-Kazár | 5807011b6a | |
Mark Sagi-Kazar | 8a1a1b8b5d | |
MIℂHΛΞL FѲRИΛRѲ | 549b67bccd | |
Johan Tordsson | c64ff34d11 | |
Márk Sági-Kazár | 458059cc89 | |
Márk Sági-Kazár | a64e7c2986 | |
Márk Sági-Kazár | e837475ca6 | |
Linda Zeng | 6745af7747 | |
Márk Sági-Kazár | d4a67e43fa | |
Mark Sagi-Kazar | 63098fe9fe | |
Márk Sági-Kazár | ca0a9e821e | |
Rui Yang | 058202d007 | |
Rui Yang | 0494993326 | |
Márk Sági-Kazár | 07dddc7b0e | |
Onkar Bhat | c489a074c1 | |
Tomasz Kleczek | b1311baa3c | |
Joel Speed | 336c73c0a2 | |
Joel Speed | a24f73c19f | |
Thomas Haines | d39b77bda3 | |
Rui Yang | 41207ba265 | |
Scott Lemmon | a783667c57 | |
Cyrille Nofficial | 61312e726e | |
Rui Yang | 52c39fb130 | |
Rui Yang | 4812079647 | |
Rui Yang | d9afb7e59c | |
Josh Winters | 9a4e0fcd00 | |
Bernd Eckstein | f6cd778b60 | |
batara666 | 6499f5bfd3 | |
Márk Sági-Kazár | 19cd9cc65c | |
Solan, Bryan | 89c6ebafa2 | |
Márk Sági-Kazár | ff1ed7afaa | |
Mark Sagi-Kazar | 2fa5e33ae0 | |
justin-slowik | 9a7926c19b | |
Márk Sági-Kazár | a8cedc8bc3 | |
Mark Sagi-Kazar | 6dadc26ca2 | |
Márk Sági-Kazár | cb46a28c3c | |
Martin Heide | 521954a3b9 | |
Martin Heide | 705cf8bb6a | |
justin-slowik | 334ecf0482 | |
Martin Heide | b4d22bf1b2 | |
Martin Heide | ce337661b9 | |
justin-slowik | 1ea2892b79 | |
justin-slowik | 1404477326 | |
justin-slowik | f91f294385 | |
justin-slowik | 9882ea453f | |
justin-slowik | f6d8427f32 | |
Justin Slowik | 9c699b1028 | |
Justin Slowik | 9bbdc721d5 | |
Justin Slowik | 0d1a0e4129 | |
Justin Slowik | 6d343e059b | |
Mark Sagi-Kazar | 11fc8568cb | |
Mark Sagi-Kazar | bad2a06960 | |
krishnadurai | 6698f1f80a | |
krishnadurai | 776aa9dd53 | |
Nándor István Krácser | 62efe7bf07 | |
m.nabokikh | 70505b258d | |
Márk Sági-Kazár | 1d892c6cac | |
Márk Sági-Kazár | 0be5232edd | |
Márk Sági-Kazár | 04c137e594 | |
Mark Sagi-Kazar | 1b2ab6fa35 | |
Mark Sagi-Kazar | e84682d7b9 | |
Mark Sagi-Kazar | 7bbda55225 | |
Mark Sagi-Kazar | dc3dcdd5c5 | |
Márk Sági-Kazár | 5160c659c8 | |
Mark Sagi-Kazar | 0b067af8e7 | |
Márk Sági-Kazár | eef8c0a60d | |
Mark Sagi-Kazar | 8b089dc441 | |
Mark Sagi-Kazar | 83d5f77495 | |
Mark Sagi-Kazar | d1b4443740 | |
Mark Sagi-Kazar | f29b54d11c | |
Márk Sági-Kazár | df34848caa | |
Mark Sagi-Kazar | 4792f0c59f | |
Nándor István Krácser | 371df97cbf | |
Márk Sági-Kazár | 415a4ea4f7 | |
Nandor Kracser | 5b7a664e9d | |
Mark Sagi-Kazar | af9dfd4a29 | |
Mark Sagi-Kazar | dad8d6d687 | |
Stephen Augustus | e1a45ba33e | |
Stephen Augustus | e151af1b44 | |
Stephen Augustus | de871b3f8a | |
Joel Speed | 9d7e472c63 | |
Márk Sági-Kazár | 2ca992e9b3 | |
techknowlogick | 0a9f56527e | |
Brian Candler | 442d3de11d | |
Brian Candler | d2c9305e0f | |
Márk Sági-Kazár | 709d4169d6 | |
Márk Sági-Kazár | ba723caa0a | |
Márk Sági-Kazár | c0dfeb7068 | |
m.nabokikh | 47b0d33142 | |
m.nabokikh | 521aa0802f | |
Stephan Renatus | 4a0feaf589 | |
poh chiat | d87cf1c924 | |
Márk Sági-Kazár | 336e284a46 | |
Tomasz Kleczek | c830d49884 | |
Martijn | 0a85a97ba9 | |
Mattias Sjöström | cd054c71af | |
Márk Sági-Kazár | 83d8853fd9 | |
Ken Perkins | 05b8acb974 | |
Kyle Travis | cfae2eb720 | |
Ken Perkins | f6476b62f2 | |
Nándor István Krácser | ebef257dcd | |
Tadeusz Magura-Witkowski | 0513ce3d6b | |
Nándor István Krácser | ec57e31103 | |
Tadeusz Magura-Witkowski | 7b7e2a040d | |
Márk Sági-Kazár | 3693b74791 | |
Nándor István Krácser | db23367150 | |
Nándor István Krácser | 741bf029a1 | |
Kyle Larose | ab5ea03025 | |
Nándor István Krácser | 277272502b | |
Nandor Kracser | 8ab1ea9334 | |
Nándor István Krácser | d820fd45d8 | |
Yann Soubeyrand | 99c3ec6820 | |
Joel Speed | 30ea963bb6 | |
Nándor István Krácser | b7cf701032 | |
Nándor István Krácser | 2bd4886517 | |
Andrew Block | 76bb453ff3 | |
Márk Sági-Kazár | b9787d48ac | |
Nándor István Krácser | fab0da7b69 | |
Nándor István Krácser | edd3a40141 | |
Nandor Kracser | c7e9960c7e | |
Nandor Kracser | 80749ffd3f | |
Nándor István Krácser | 1160649c31 | |
Chris Loukas | d33a76fa19 | |
Nándor István Krácser | f17fa67715 | |
Nándor István Krácser | 0f8c4db9f6 | |
sdarwin | 49e85a3cb1 | |
sdarwin | 11d91c144f | |
Nándor István Krácser | cf4f88a06e | |
Hidetake Iwata | 2ec5e5463f | |
Jerry Sun | 3a3a2bcc86 | |
Ivan Mikheykin | 7ef1179e75 | |
Márk Sági-Kazár | 0014ca3465 | |
Colleen Murphy | 7319d3796f | |
Joel Speed | 30cd592801 | |
Márk Sági-Kazár | 7c7c1de798 | |
Mark Sagi-Kazar | 0f1927a1ba | |
Márk Sági-Kazár | ca2d718fe4 | |
Mark Sagi-Kazar | 573bbeb7de | |
Mark Sagi-Kazar | 3c26c90dcc | |
Mark Sagi-Kazar | 3b4cf282c8 | |
Mark Sagi-Kazar | 2f10b81a20 | |
Mark Sagi-Kazar | 52a084edd0 | |
Mark Sagi-Kazar | a098aa112b | |
Mark Sagi-Kazar | 37d0b7465d | |
Mark Sagi-Kazar | 0ce0393725 | |
Mark Sagi-Kazar | 88fd211fb5 | |
Mark Sagi-Kazar | 55b49063f8 | |
Mark Sagi-Kazar | 121a55e0bc | |
Mark Sagi-Kazar | afbb62206f | |
Márk Sági-Kazár | 26061f9558 | |
Mark Sagi-Kazar | b09b7bbbba | |
Márk Sági-Kazár | bb2733fbdd | |
Márk Sági-Kazár | a6b5405c2e | |
Mark Sagi-Kazar | 6951c2c269 | |
Mark Sagi-Kazar | fb0048d509 | |
Márk Sági-Kazár | 8894eed8d3 | |
Nándor István Krácser | aca67b0839 | |
Nándor István Krácser | ea43562793 | |
linzhaoming | 1d3851b0c5 | |
Fabrice Rabaute | b85d7849ad | |
Vitaliy Dmitriev | e20a795a2a | |
Joshua Winters | 76825fef8f | |
Nándor István Krácser | 1cdb2b1d74 | |
Carl Henrik Lunde | 6104295d5e | |
Carl Henrik Lunde | 5db29eb087 | |
Rui Yang | 0f9a74f1d0 | |
Zach Brown | 13be146d2a | |
krishnadurai | 321790870f | |
krishnadurai | 2d5619e4e8 | |
Krishna Durai | 9560899496 | |
Nándor István Krácser | 3cbba11012 | |
Nándor István Krácser | 53897e831d | |
Vitaliy Dmitriev | f2e7823db9 | |
Nándor István Krácser | 6318c105ec | |
m.nabokikh | 383c2fe8b6 | |
Aiden Andrews-McDermott | 98f78db915 | |
Andrew Block | d31f6eabd4 | |
Andrew Block | 296659cb50 | |
Andrew Block | 5afa02644a | |
Márk Sági-Kazár | 789272a0c1 | |
m.nabokikh | 058e72ef50 | |
Andrew Block | 075ab0938e | |
Andrew Block | 7e89d8ca24 | |
Andrew Block | 02c8f85e4d | |
Andrew Block | db7711d72a | |
Andrew Block | 5881a2cfca | |
Andrew Block | 48954ca716 | |
Andrew Block | 92e63771ac | |
Márk Sági-Kazár | 664fdf76ca | |
Nándor István Krácser | 1baf48f83c | |
Nándor István Krácser | f98332595e | |
Nándor István Krácser | ac242a8bc7 | |
Nándor István Krácser | a901e2f204 | |
Márk Sági-Kazár | ff4dee5fdb | |
Lars Lehtonen | 8e0ae82034 | |
Mark Sagi-Kazar | 3fb85ab009 | |
Mark Sagi-Kazar | e0c58d5449 | |
Mark Sagi-Kazar | 309b33d05a | |
Mark Sagi-Kazar | 050d5af937 | |
Mark Sagi-Kazar | 65c77e9db2 | |
Mark Sagi-Kazar | 2f8d1f8e42 | |
Mark Sagi-Kazar | f141f2133b | |
Mark Sagi-Kazar | 9bd5ae5197 | |
Mark Sagi-Kazar | 367b187cf4 | |
Mark Sagi-Kazar | 142c96c210 | |
Mark Sagi-Kazar | 8c3dc0ca66 | |
Márk Sági-Kazár | e0f927c7a9 | |
Mark Sagi-Kazar | bcd47fc6f3 | |
Mark Sagi-Kazar | 9346e328ef | |
krishnadurai | 9aec1e7db2 | |
krishnadurai | 1fd5dd7b0e | |
krishnadurai | af9c2880a6 | |
krishnadurai | 91cbd466a5 | |
Nándor István Krácser | 64b269d1c1 | |
Mark Sagi-Kazar | 3c7593f87b | |
Mark Sagi-Kazar | 11c2499713 | |
Mark Sagi-Kazar | d2095bb2d8 | |
Mark Sagi-Kazar | 532c120ba7 | |
Mark Sagi-Kazar | 5d2529f0ad | |
Mark Sagi-Kazar | 0773c6e9f3 | |
Nándor István Krácser | 1ac4f7fe42 | |
Nándor István Krácser | d5b3fc0478 | |
Nándor István Krácser | dc812f5341 | |
Nandor Kracser | 9b5b604bab | |
Nándor István Krácser | 4bb4d49952 | |
Nandor Kracser | dbea20d078 | |
Nandor Kracser | a38e215891 | |
Nándor István Krácser | c41035732f | |
Nándor István Krácser | 8fdcee7c14 | |
YanJin | e11b2ceeee | |
Joel Speed | 658a2cc477 | |
Joel Speed | 554870cea0 | |
Joel Speed | 94bee18f6b | |
Joel Speed | 9d9a1017e4 | |
Joel Speed | 6a9bc889b5 | |
Joel Speed | c03c98b951 | |
Joel Speed | 3f55e2da72 | |
Joel Speed | 36370f8f2a | |
Joel Speed | 97ffa21262 | |
Nándor István Krácser | b1e98d8590 | |
Joel Speed | 3156553843 | |
Joel Speed | c782ac809c | |
Joel Speed | c4e96dda32 | |
Joel Speed | d9095073c8 | |
Joel Speed | 77fcf9ad77 | |
Joel Speed | f6077083c9 | |
Joel Speed | 8b344fe4d3 | |
Joel Speed | 19ad7daa7f | |
Joel Speed | 45a40a13a3 | |
Joel Speed | 236b25b68e | |
Joel Speed | 41b7c855d0 | |
Joel Speed | 9ce4393156 | |
Joel Speed | 176ba709a4 | |
Joel Speed | fea048b3e8 | |
Joel Speed | d38909831c | |
Joel Speed | 433bb2afec | |
Joel Speed | 4076eed17b | |
Joel Speed | 80995dff9b | |
Joel Speed | b9b315dd64 | |
Joel Speed | 7a76c767fe | |
Joel Speed | c54f1656c7 | |
Joel Speed | c789c5808e | |
Joel Speed | 7fc3f230df | |
Joel Speed | 0857a0fe09 | |
Joel Speed | 5c88713177 | |
Joel Speed | 0352258093 | |
Joel Speed | 575c792156 | |
Nándor István Krácser | c392236f4f | |
serhiimakogon | b793afd375 | |
Nándor István Krácser | b7184be3dd | |
Nándor István Krácser | 6d41541964 | |
Nándor István Krácser | f2590ee07d | |
Nándor István Krácser | d5d3abca6a | |
Nándor István Krácser | 0b56a47571 | |
Nándor István Krácser | 799f29fdb5 | |
Nándor István Krácser | a58d77a499 | |
Nándor István Krácser | 0b55f121b4 | |
Nándor István Krácser | 3f8fd74185 | |
Nandor Kracser | c1b421fa04 | |
Tony Hsu | 6e35f24399 | |
Chandan Rai | efdb5de6d8 | |
Ta-Ching Chen | 76c76a0b39 | |
Joel Speed | 4bede5eb80 | |
Yannis Zarkadas | 69d13b766d | |
Yannis Zarkadas | 59beb7425f | |
Yannis Zarkadas | 27944d4f8f | |
Yannis Zarkadas | 839130f01c | |
j.ai | 2c52c52686 | |
Tomasz Kleczek | 42d61191c4 | |
Nandor Kracser | d2c33db8a8 | |
Thomas Jackson | 21ab30d207 | |
Thomas Jackson | 512cb3169e | |
Ken Perkins | 285c1f162e | |
Stephan Renatus | 8427f0f15c | |
wassan128 | 42e8619830 | |
Stephan Renatus | 3b7292a08f | |
Joel Speed | 179cce36ef | |
Steven Danna | 46f48b33a1 | |
Stephan Renatus | c854e760db | |
erwinvaneyk | 3e2217b3f4 | |
Stephan Renatus | 4f3ab1efb7 | |
Stephan Renatus | 15ec95bca9 | |
Erwin van Eyk | 5c99525ed3 | |
j | a48f73f14a | |
Stephan Renatus | 133c2565be | |
Stephan Renatus | 1f31d1889a | |
Nandor Kracser | bd61535cb6 | |
Daniel Kürner | 2dccdc2a1a | |
Joel Speed | ab08d7b3a4 | |
Michael Venezia | 395febf808 | |
Nandor Kracser | ef08ad8317 | |
Stephan Renatus | aeb2861a40 | |
Stephan Renatus | 6e5a2b5ea1 | |
Stephan Renatus | 27b8426704 | |
Stephan Renatus | d328a5ebaa | |
Tom Downes | 963b8e992d | |
Stephan Renatus | d9f6ab4a68 | |
Michael Venezia | 430357b14e | |
Michael Venezia | b65966d744 | |
Stephan Renatus | e1afe771cb | |
Stephan Renatus | 89e43c198b | |
Marc-André Dufresne | 0dbb642f2c | |
Marc-André Dufresne | d458e882aa | |
Stephan Renatus | bc02006b45 | |
Mike O | 43d1a044bd | |
Nándor István Krácser | 526e078366 | |
Michael Venezia | c54ddc460d | |
Stephan Renatus | d36e6c26ee | |
Mike O | d03a43335e | |
Stephan Renatus | 6ae11a1cfe | |
Stephan Renatus | 291cd9e01c | |
Stephan Renatus | ea7fd6d470 | |
Stephan Renatus | 076cd77469 | |
Stephan Renatus | 7c1b4b3005 | |
Stephan Renatus | 231e571c3c | |
Stephan Renatus | 128d5da89e | |
Stephan Renatus | d9487e553b | |
Joel Speed | e2ddefff31 | |
Nándor István Krácser | 72f5596671 | |
mkontani | c067761df6 | |
Nándor István Krácser | 0aee5be625 | |
Nandor Kracser | ff34e570b4 | |
Stephan Renatus | 6e98c04f9b | |
Stephan Renatus | fd53c0a3bb | |
Maxime Desrosiers | 458585008b | |
Stephan Renatus | 8561a66365 | |
Stephan Renatus | d7c7d42466 | |
Stephan Renatus | c4e0587df1 | |
Joel Speed | 20a858da3b | |
Piotr | 74023ba9ad | |
Stephan Renatus | 9c211132b2 | |
Tanmay Mohapatra | 56f8e60545 | |
Sabith K Soopy | 6769a3b18e | |
Sabith K Soopy | 6ccb96ff74 | |
Stephan Renatus | e3203382fc | |
Stephan Renatus | 7409d16541 | |
Stephan Renatus | bc27a617c5 | |
Stephan Renatus | b8cdc88803 | |
Nandor Kracser | a572ad8fec | |
Pavel Borzenkov | e53bdfabb9 | |
Stephan Renatus | 447f24a81b | |
Stephan Renatus | af81297d4e | |
Stephan Renatus | 421c26fdf5 | |
LanceH | 07a77e0dac | |
Stephan Renatus | 6379403a68 | |
Tyler Cloke | dd84e73c0e | |
Stephan Renatus | 92920c86ea | |
Stephan Renatus | 10611f3156 | |
Stephan Renatus | 51f50fcad8 | |
Eric Chiang | 39dc5dcfb7 | |
Eric Chiang | 645a441527 | |
Stephan Renatus | 8b4dbb9fe7 | |
Andy Lindeman | 5b66bf05c8 | |
Andy Lindeman | 59b6595c37 | |
Andy Lindeman | 8959dc4275 | |
Andy Lindeman | 21174c06a1 | |
Andy Lindeman | 840065faaf | |
Andy Lindeman | 46f5726d11 | |
Andy Lindeman | 157c359f3e | |
mdbraber | 3dd1bac821 | |
Maarten den Braber | 74f4e749b9 | |
Maarten den Braber | d7750b1e26 | |
Maarten den Braber | a8d059a237 | |
Stephan Renatus | d6fad19d95 | |
Stephan Renatus | c19ada3236 | |
tan | 8613c78863 | |
flarno11 | 8c1716d356 | |
Stephan Renatus | dfb2dfd333 | |
Stephan Renatus | 4e8cbf0f61 | |
Stephan Renatus | e137db978d | |
Stephan Renatus | 11913a28c6 | |
Stephan Renatus | 49e59fb54f | |
cappyzawa | 9650836851 | |
Eric Chiang | 59560c9919 | |
Thomas Jackson | 52d09a2dfa | |
jimmythedog | b189d07d53 | |
Eric Chiang | cd3c6983da | |
Eric Chiang | 35f51957c0 | |
Eric Chiang | 06ec381082 | |
Eric Chiang | 0babb2df18 | |
Stephan Renatus | 429bb9303f | |
Stephan Renatus | d8f9634afc | |
Tomas Barton | 55cebd58a8 | |
Nandor Kracser | 7b416b5a8e | |
Nandor Kracser | a08a5811d4 | |
Nandor Kracser | b1931fc9bd | |
Andy Lindeman | 34c7cfaf82 | |
mkontani | 6ae76662de | |
Benoit Sigoure | d6ad67a6de | |
Eric Chiang | 60f47c4228 | |
Yann Soubeyrand | c5f2871ab5 | |
Eric Chiang | 29d8428387 | |
Stephen Augustus | 56f02b95c6 | |
Eric Chiang | f6741d1837 | |
Gerald Barker | fc723af0fe | |
Eric Chiang | 83a0326b88 | |
Takashi Okamoto | ac290f77aa | |
Eric Chiang | c113df2730 | |
Mark Sagi-Kazar | d877fca092 | |
Mark Sagi-Kazar | 06521ffa49 | |
Mark Sagi-Kazar | aec2edb441 | |
Mark Sagi-Kazar | d1c8f8d095 | |
Eric Chiang | e913a252cd | |
Mark Sagi-Kazar | c48cb36e8f | |
Eric Chiang | 8b4a9bf5ee | |
Nandor Kracser | 6c71b330a8 | |
Mark Sagi-Kazar | be581fa7ff | |
Stephan Renatus | ca66289077 | |
Lincoln Stoll | b96b02e506 | |
Stephan Renatus | 7bd4071b4c | |
Stephan Renatus | 815311fa19 | |
Eric Chiang | 8935a1479c | |
James Nord | fe247b106b | |
James Nord | 9840fccdbb | |
Stephan Renatus | be171a2a53 | |
Joel Speed | 8f113548a5 | |
Stephan Renatus | b6f4740a15 | |
Stephan Renatus | df18cb0c22 | |
James Nord | 5822a5ce9e | |
James Nord | 1911b52c6b | |
James Nord | 03ffd0798c | |
Haines Chan | 18b6b34b67 | |
Stephan Renatus | b5826e66f0 | |
Stephan Renatus | 81f155882a | |
Steven Danna | 59f8b02d47 | |
Stephan Renatus | 4abf3b2102 | |
Stephan Renatus | 2cfadcd95a | |
Takashi Okamoto | 337bbe5f09 | |
Takashi Okamoto | 1b7b3515d7 | |
Takashi Okamoto | fbdb55aba9 | |
Takashi Okamoto | 2d7de4ec70 | |
Stephan Renatus | 5ae094206b | |
Eric Chiang | 86eeee2ae2 | |
Stephan Renatus | f1581ff873 | |
Krzysztof Balka | e8ba848907 | |
joannano | 88d1e2b041 | |
Krzysztof Balka | a965365a2b | |
knangia | 0774a89066 | |
Stephan Renatus | 2d1ac74ec0 | |
Daniel Kessler | ee54a50956 | |
Eric Chiang | 27f66e795e | |
Haines Chan | b78b8aeee0 | |
Stephan Renatus | 4329f407dc | |
Eric Chiang | bad15c1c02 | |
Stephan Renatus | a3cf7b63b7 | |
Maximilian Gaß | 74f84ce0be | |
Maximilian Gaß | 468c74d1d2 | |
Stephan Renatus | aafbaa36c5 | |
Eric Chiang | 60264d440c | |
Joshua M. Dotson | 46296ab9d0 | |
Stephan Renatus | f5befb2e2d | |
Eric Chiang | 1a565266fc | |
Eric Chiang | 8a479707b3 | |
Joshua M. Dotson | eaeab218b8 | |
Joshua M. Dotson | 172df9ccef | |
Stephan Renatus | 17ac7c8a86 | |
Stephan Renatus | 73fdf4f75b | |
Alex Suraci | badbc8c738 | |
Stephan Renatus | d91f9fbc51 | |
Stephan Renatus | 8f3cca7ba4 | |
Stephan Renatus | f3acec0b1b | |
Cosmin Cojocar | 01c6b9dd91 | |
Stephan Renatus | 007e4dae3c | |
Stephan Renatus | 5355b81e2a | |
Owen Tuz | 9ea2ade208 | |
Owen Tuz | e603a5e631 | |
Owen Tuz | 9b5122568a | |
Owen Tuz | 72c9cf43a9 | |
Owen Tuz | 45eb9b279b | |
Owen Tuz | 58093dbb29 | |
Owen Tuz | e028b79c97 | |
Stephan Renatus | 5f054fcf2e | |
Stephan Renatus | f7f7314fdb | |
Alex Suraci | 85dd0684ba | |
Alex Suraci | 587081a643 | |
Alex Suraci | 5d67da1472 | |
Alex Suraci | aa068b667a | |
Alex Suraci | 9b9013a560 | |
Alex Suraci | 7e96021428 | |
Stephan Renatus | 6182f213ef | |
Stephan Renatus | 58b546a5be | |
Stephan Renatus | 0740c2370d | |
Stephan Renatus | cbcb1f61f3 | |
Stephan Renatus | 1d0568efe9 | |
Stephan Renatus | efb15205e9 | |
Stephan Renatus | d40043808b | |
Alex Suraci | 0b856d1a75 | |
Josh Winters | bb11a1ebee | |
Stephan Renatus | b1fd2fa8b1 | |
Divya Dadlani | f82b904d05 | |
Stephan Renatus | 7c8a22443a | |
Stephan Renatus | 84ea412ca6 | |
gypsydiver | f21e6a0f00 | |
Stephan Renatus | 42997448a7 | |
Stephan Renatus | 4738070951 | |
Alexander Matyushentsev | 7bd084bc07 | |
Alex Suraci | 7c63be4104 | |
Stephan Renatus | 2425c6ea63 | |
Stephan Renatus | 13a1679892 | |
Alexander Matyushentsev | 20bc6cd353 | |
Alex Suraci | dcca427592 | |
Stephan Renatus | 5236b2c819 | |
Alexander Matyushentsev | e5ebcf518a | |
Alexander Matyushentsev | ce3cd53a11 | |
Alexander Matyushentsev | e876353128 | |
Alexander Matyushentsev | a9f71e378f | |
Alexander Matyushentsev | e10b8232d1 | |
Alexander Matyushentsev | 51d9b3d3ca | |
Alexander Matyushentsev | ff8b44558e | |
Cosmin Cojocar | 281ec27118 | |
Cosmin Cojocar | 9d1ec6c36b | |
Stephan Renatus | c14b2fd5a5 | |
Stephan Renatus | 3295084236 | |
Cosmin Cojocar | 49fa5ee6e8 | |
Stephan Renatus | 1723e13fed | |
Cosmin Cojocar | 6536d97812 | |
Cosmin Cojocar | c9b18b2785 | |
Cosmin Cojocar | 6531b256e2 | |
Cosmin Cojocar | 9926a0dced | |
Eric Chiang | 57b1031352 | |
Eric Chiang | 505aac753d | |
Song.Jin | 5f0a03a06b | |
Tiago Matias | 44e988fb41 | |
Song.Jin | 9b5bec1ddf | |
Song.Jin | d2daa4e2ac | |
Stephan Renatus | 65b0c91992 | |
Stephan Renatus | f903a2890e | |
Eric Chiang | e11446aff0 | |
Eric Chiang | 50b96564f5 | |
Eric Chiang | 867130e22e | |
Eric Chiang | 5514805cf0 | |
Eric Chiang | 9f91ae0255 | |
Danny Sauer | b9b21260bc | |
Eric Chiang | 0db538a4bb | |
Ed Tan | 56eeb07e85 | |
Eric Chiang | fcf4371b1b | |
Eric Chiang | 73498316c7 | |
Stephan Renatus | e1acb6d577 | |
Stephan Renatus | a1b6ba9bba | |
Danny Sauer | 74bfbcefbc | |
Ed Tan | 50afa921b5 | |
Ed Tan | 6ffc8fcd8d | |
Ed Tan | d26e23c16f | |
Ed Tan | 2c024d8caf | |
Ed Tan | 8c75d85b60 | |
Stephan Renatus | b58053eefc | |
Stephan Renatus | 26c0206627 | |
Stephan Renatus | ff70c0453f | |
veily | 317f433a14 | |
Scott Reisor | 2707302054 | |
Eric Chiang | 316acbee03 | |
Fajran Iman Rusadi | a823c021c8 | |
Eric Chiang | 06241eae9f | |
Taras Burko | bf39130bab | |
Eric Chiang | 29bc098620 | |
Stephan Renatus | 1260c62a80 | |
Stephan Renatus | 86a3346b64 | |
Stephan Renatus | 666356d22d | |
Stephan Renatus | 4a6da13097 | |
Victor Sartori | 1ea1d809a1 | |
Stephan Renatus | 9cc85c447c | |
Stephan Renatus | 1309c1f037 | |
Eric Chiang | bb75dcd793 | |
Stephan Renatus | 9f10e5d020 | |
Stephan Renatus | 14b89029c9 | |
Stephan Renatus | b9f6594bf0 | |
Stephan Renatus | 974617a426 | |
Stephan Renatus | 6a2d4ab6b4 | |
rithu leena john | 3bbc2c0bde | |
Eric Chiang | 4dc3347106 | |
Stephan Renatus | 4caf82c1e9 | |
Stephan Renatus | cabdcb1eb0 | |
Stephan Renatus | e3e37504ca | |
rithu john | 9de19cb899 | |
Anian Z | 5454a4729f | |
tw3rp | 49bbcd343f | |
Gabe Conradi | 94bd948aac | |
Ahmed ElRefaey | b71bec2ba1 | |
Ahmed ElRefaey | 32e9570116 | |
Victor Sartori | 780a359f8e | |
Eric Chiang | 036e5d050d | |
Eric Chiang | 384db1f33e | |
Eric Chiang | 0822f1d4d3 | |
Eric Chiang | bf3ffb53a0 | |
Kevin Schuck | ca3d73c36d | |
silenceshell | 468b5e3f0a | |
Matthias Klan | 481f1276a8 | |
Joe Borg | fc8b20ba35 | |
Eric Chiang | 0d3edf2456 | |
Eric Chiang | 264484075a | |
Eric Chiang | f2eac0e723 | |
charles.deng | d92c21b9f9 | |
Eric Chiang | 218d671a96 | |
Stephan Renatus | 608260d0f1 | |
Eric Chiang | 39a66d1496 | |
Simon Knott | 822a10cede | |
Eric Chiang | 01d63b086f | |
Vy-Shane Xie | b03c85e56e | |
Eric Chiang | ce686390a5 | |
Eric Chiang | c0bcc81997 | |
pmcgrath | 4aec353aec | |
Eric Chiang | 1dbecefadf | |
Eric Chiang | f83c86cead | |
Eric Chiang | ea2c63d7b0 | |
Eric Chiang | 2851b3c7a6 | |
Eric Chiang | 460f48320e | |
Eric Chiang | 2215158b2a | |
Eric Chiang | 6ef8cd512f | |
Frederic Branczyk | 0930b09e4e | |
Frederic Branczyk | 5f03479d29 | |
Eric Chiang | 053c476c4f | |
Eric Chiang | 0811d1a07a | |
Eric Chiang | b5baf6b1ca | |
Diego Pontoriero | 6d4fef4b9a | |
Eric Chiang | 9d4b1041bd | |
Eric Buth | da45adcb6e | |
Stephan Renatus | f013a44581 | |
Eric Chiang | ec5e2cc3c6 | |
Pavel Moukhataev | 5ef1312b38 | |
Stephan Renatus | f18d7afc6f | |
Eric Chiang | 5172a46171 | |
Wyatt Alt | e7d57bb31b | |
Eric Chiang | 18da628842 | |
rithu leena john | 32257bcf8e | |
Eric Chiang | c5de6fa733 | |
Eric Chiang | ab102b8189 | |
Eric Chiang | 35063da41e | |
Kazumasa Kohtaka | 9948228e5b | |
Eric Chiang | 861d4ae447 | |
Eric Chiang | c872938298 | |
Vy-Shane Xie | 19cb2a5ffb | |
Pavel Borzenkov | 47df6ea2ff | |
Pavel Borzenkov | 6193bf5566 | |
Eric Chiang | f4b6bf2ac3 | |
rithu leena john | bc01767212 | |
Stephan Renatus | 41f663f70c | |
Eric Chiang | b746ab4975 | |
Eric Chiang | 75a07f2bfa | |
Stephan Renatus | b09a13458f | |
Eric Chiang | 04e276f2df | |
Eric Chiang | df075d8bda | |
Eric Chiang | 97d395e351 | |
Eric Chiang | b58fba6753 | |
Eric Chiang | ccf85a7269 | |
Daniel Dao | e617197871 | |
Daniel Dao | a2188bebf1 | |
Daniel Dao | fc1c60ed8f | |
Eric Chiang | e623bd626e | |
Geoff Greer | 3dfc4b430e | |
rithu leena john | 42ef8fd802 | |
Eric Chiang | 3d2d92b31b | |
Daniel Dao | b410622885 | |
Daniel Dao | ca114f7812 | |
rithu leena john | 943e23cd54 | |
Eric Chiang | 6475ce1f62 | |
Eric Chiang | e3b96243b5 | |
Daniel Dao | 2b13bdd12d | |
Pavel Borzenkov | d5a9712aae | |
Pavel Borzenkov | 3b5df52c0f | |
Pavel Borzenkov | ab06119431 | |
Eric Chiang | 3d65b774d6 | |
rithu leena john | 13b4f84f79 | |
Michael Stapelberg | 4931f30a80 | |
Eric Chiang | d099145921 | |
Eric Chiang | 751c565e9d | |
Michael Stapelberg | a41d93db4a | |
rithu leena john | f3c85e6936 | |
Eric Chiang | 3849abb18a | |
cpanato | 620695ed2b | |
Eric Chiang | fcf00019de | |
rithu leena john | 10c0ec0d48 | |
Eric Chiang | 9c176dd1bd | |
Eric Chiang | 5ea886473c | |
Devon Barrett | eb14a8245c | |
rithu john | 82879b3b3e | |
Lars Sjöström | 4605fdd551 | |
rithu leena john | fe1516332c | |
Damian Pacierpnik | e3c9b49299 | |
Eric Chiang | 0aabf2d1ea | |
Ryan Phillips | 0318cd99b0 | |
Eric Chiang | fe2aee364c | |
Chance Zibolski | 9d7b0b59bd | |
rithu leena john | 904c3facd9 | |
Laurent Rolaz | cca0275b0b | |
Eric Chiang | 2c468ea8a0 | |
Tobias Furuholm | b6c3074ba0 | |
rithu leena john | 64c97c7308 | |
rithu john | d2706fcab8 | |
rithu leena john | 4c435db52e | |
rithu john | 34dcf6c9a0 | |
rithu leena john | 03de0ecbeb | |
rithu john | 1311caf864 | |
Eric Chiang | 4d9f5dbaa1 | |
Eric Chiang | 4c0cac64eb | |
Eric Chiang | 980400db0b | |
Amruta Chitnis | df8fc84851 | |
rithu john | 146481375e | |
Eric Chiang | 38d0de20e3 | |
Eric Chiang | f234e3707e | |
rithu leena john | e10fddee2e | |
Eric Stroczynski | 7079bb5316 | |
Eric Stroczynski | 9517d17ed2 | |
Eric Stroczynski | 9c6b6d565e | |
Eric Stroczynski | a065533256 | |
rithu leena john | 3445895647 | |
rithu john | fd4f57b5f3 | |
rithu leena john | e40c01ec39 | |
Eric Chiang | 50f2905cac | |
Eric Stroczynski | 9b46267659 | |
Eric Stroczynski | 763e174a7f | |
Eric Stroczynski | ce9ac761a6 | |
Eric Stroczynski | 2b354c8fdb | |
rithu leena john | e59d67f466 | |
Chien Huey | 99370b5880 | |
Eric Stroczynski | e92f38f38f | |
Chien Huey | 98f6a217d3 | |
Eric Stroczynski | 20fd3163d9 | |
Eric Stroczynski | 5894d017d5 | |
Eric Stroczynski | b84721cbda | |
Eric Stroczynski | 484327fd5f | |
Eric Stroczynski | 7e580ec2b2 | |
Eric Stroczynski | bb36c96674 | |
Eric Stroczynski | 48bb61cfe0 | |
Eric Stroczynski | ca75470ae3 | |
rithu leena john | e361bc6c38 | |
Eric Stroczynski | 71de7e8414 | |
Eric Chiang | 5582232a03 | |
Eric Chiang | aad328bb35 | |
Eric Stroczynski | c45185f601 | |
Eric Stroczynski | 26527011ab | |
rithu leena john | b66b61fe8a | |
Eric Stroczynski | 45bf061236 | |
Eric Stroczynski | 9d154802a2 | |
Eric Stroczynski | f493896030 | |
Luk Burchard | 4365d97162 | |
rithu leena john | 05e8d50eca | |
Eric Stroczynski | 4bcb0aaae9 | |
Eric Stroczynski | a0010d0f22 | |
Eric Stroczynski | d9778fda4a | |
Eric Stroczynski | 4a88d0641a | |
rithu john | 5e0bf8b65f | |
rithu leena john | 0e0b4c53ef | |
rithu john | 753526a506 | |
Lucas Servén | 2f0ac68d8f | |
Lucas Serven | 53835cabf9 | |
rithu leena john | 714fea7f48 | |
Lucas Serven | 6f8968b66b | |
rithu leena john | b70f4c4f39 | |
rithu john | 6f9127b4ae | |
rithu leena john | 377d9b44b7 | |
rithu leena john | a5d218fd08 | |
Kazumasa Kohtaka | cc314690f4 | |
rithu leena john | 6f2af269a1 | |
Eric Chiang | c1a7285711 | |
rithu leena john | 92a988e4cc | |
rithu leena john | 842bd0ef00 | |
Zak Holt | 43f0e8530b | |
Zak Holt | 41a20dbb2a | |
Ben Navetta | cbb007663f | |
Ben Navetta | 687bc9ca5c | |
Ben Navetta | 4194530cf3 | |
rithu leena john | 3493e30f43 | |
rithu john | d6c1b0f42b | |
rithu leena john | 15242a86b8 | |
rithu john | 682d78f527 | |
rithu leena john | 1d7abf8923 | |
rithu john | 081e68a16a | |
Eric Chiang | f4f7146f04 | |
Eric Chiang | 95334ad51d | |
Eric Chiang | fcb9c5a1c4 | |
rithu leena john | 583bf91ab4 | |
rithu john | 0dd024d669 | |
rithu leena john | a7097c80e3 | |
rithu john | 6e3e174100 | |
rithu leena john | 81d24f180e | |
rithu john | dc4b97b851 | |
rithu leena john | 951fb053a1 | |
rithu john | aefdd6e004 | |
rithu leena john | 9514bf9afe | |
rithu john | 8c9c2518f5 | |
rithu leena john | 52b121a470 | |
Eric Chiang | c400e860fe | |
Eric Chiang | 8a7665b5a1 | |
rithu leena john | aae9bb6477 | |
Tom Gamble | 0edd0b2fb4 | |
Eric Chiang | 47f48658c2 | |
Eric Chiang | a4cb57ab5d | |
rithu leena john | 4507e99ae3 | |
rithu leena john | 5c56fb76df | |
Eric Chiang | 49e05b95c0 | |
Eric Chiang | 2b8caf9b39 | |
Eric Chiang | 2edfec5d45 | |
Eric Chiang | a12d2f85a8 | |
rithu leena john | 521dbff7cf | |
zhuguihua | 4e99ec3eeb | |
Eric Chiang | 5859fe1091 | |
Eric Chiang | ba1660ae1f | |
Eric Chiang | ef376b9c69 | |
Filip Haftek | 2e10e5a9b1 | |
Filip | 57aa32562b | |
Eric Chiang | e609de5018 | |
Eric Chiang | 74f5eaf47e | |
Eric Chiang | 00b5c99ffc | |
rithu leena john | 9b0af83604 | |
Eric Chiang | 3d7b1477e7 | |
rithu john | dd1e901dd9 | |
Eric Chiang | c3cafc8f39 | |
Eric Chiang | 5f377f07d4 | |
rithu leena john | d4274eb0ff | |
rithu leena john | 43ad9cc37c | |
rithu john | 76b9eb1db9 | |
Eric Chiang | cf089025ae | |
Eric Chiang | 97813ff4fc | |
Eric Chiang | 7395f05e95 | |
Eric Chiang | b8ba59d7f6 | |
Eric Chiang | 0ac11d93e6 | |
Eric Chiang | 4a93b55c8b | |
Eric Chiang | 943253fece | |
Eric Chiang | 362e0798a4 | |
Eric Chiang | 258ec4ff4b | |
Phu Kieu | bd754e2b2d | |
Eric Chiang | 53acaa9e7c | |
Phu Kieu | 47897f73fa | |
Eric Chiang | 40f0265ab4 | |
Phu Kieu | 8c0eb67ecd | |
Phu Kieu | 217b5ca2c7 | |
Eric Chiang | 207d20777b | |
Eric Chiang | a97cffcd52 | |
Eric Chiang | e0709dc2ac | |
Lucas Servén | a7d443ea2b | |
Lucas Serven | f3d9bd5008 | |
rithu leena john | f4865a354c | |
rithu john | 5abb4b3df6 | |
Eric Chiang | 5eb8210eb4 | |
Eric Chiang | 8902ddc061 | |
Eric Chiang | 5e34f0d1a6 | |
Eric Chiang | f734b140cd | |
rithu leena john | 42c1eed231 | |
rithu john | bc55b86d0d |
|
@ -1,3 +1,4 @@
|
|||
*
|
||||
!_output/bin
|
||||
!web
|
||||
.github/
|
||||
.gitpod.yml
|
||||
bin/
|
||||
tmp/
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_size = 4
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.go]
|
||||
indent_style = tab
|
||||
|
||||
[*.proto]
|
||||
indent_size = 2
|
||||
|
||||
[{Makefile,*.mk}]
|
||||
indent_style = tab
|
||||
|
||||
[{config.yaml.dist,config.dev.yaml}]
|
||||
indent_size = 2
|
|
@ -0,0 +1,6 @@
|
|||
if ! has nix_direnv_version || ! nix_direnv_version 1.5.0; then
|
||||
source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/1.5.0/direnvrc" "sha256-carKk9aUFHMuHt+IWh74hFj58nY4K3uywpZbwXX0BTI="
|
||||
fi
|
||||
use flake
|
||||
|
||||
dotenv_if_exists
|
|
@ -0,0 +1,2 @@
|
|||
[{*.yml,*.yaml}]
|
||||
indent_size = 2
|
|
@ -0,0 +1,3 @@
|
|||
## Community Code of Conduct
|
||||
|
||||
This project follows the [CNCF Code of Conduct](https://github.com/cncf/foundation/blob/master/code-of-conduct.md).
|
|
@ -0,0 +1,102 @@
|
|||
name: 🐛 Bug report
|
||||
description: Report a bug to help us improve Dex
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thank you for submitting a bug report!
|
||||
|
||||
Please fill out the template below to make it easier to debug your problem.
|
||||
|
||||
If you are not sure if it is a bug or not, you can contact us via the available [support channels](https://github.com/dexidp/dex/issues/new/choose).
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Preflight Checklist
|
||||
description: Please ensure you've completed all of the following.
|
||||
options:
|
||||
- label: I agree to follow the [Code of Conduct](https://github.com/dexidp/dex/blob/master/.github/CODE_OF_CONDUCT.md) that this project adheres to.
|
||||
required: true
|
||||
- label: I have searched the [issue tracker](https://www.github.com/dexidp/dex/issues) for an issue that matches the one I want to file, without success.
|
||||
required: true
|
||||
- label: I am not looking for support or already pursued the available [support channels](https://github.com/dexidp/dex/issues/new/choose) without success.
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: Version
|
||||
description: What version of Dex are you running?
|
||||
placeholder: 2.29.0
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Storage Type
|
||||
description: Which persistent storage type are you using?
|
||||
options:
|
||||
- etcd
|
||||
- Kubernetes
|
||||
- In-memory
|
||||
- Postgres
|
||||
- MySQL
|
||||
- SQLite
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Installation Type
|
||||
description: How did you install Dex?
|
||||
options:
|
||||
- Binary
|
||||
- Official container image
|
||||
- Official Helm chart
|
||||
- Custom container image
|
||||
- Custom Helm chart
|
||||
- Other (specify below)
|
||||
multiple: true
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
description: A clear and concise description of what you expected to happen.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Actual Behavior
|
||||
description: A clear description of what actually happens.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Steps To Reproduce
|
||||
description: Steps to reproduce the behavior if it is not self-explanatory.
|
||||
placeholder: |
|
||||
1. In this environment...
|
||||
2. With this config...
|
||||
3. Run '...'
|
||||
4. See error...
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Additional Information
|
||||
description: Links? References? Anything that will give us more context about the issue that you are encountering!
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Configuration
|
||||
description: Contents of your configuration file (if relevant).
|
||||
render: yaml
|
||||
placeholder: |
|
||||
issuer: http://127.0.0.1:5556/dex
|
||||
|
||||
storage:
|
||||
# ...
|
||||
|
||||
connectors:
|
||||
# ...
|
||||
|
||||
staticClients:
|
||||
# ...
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Logs
|
||||
description: Dex application logs (if relevant).
|
||||
render: shell
|
|
@ -0,0 +1,17 @@
|
|||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: ❓ Ask a question
|
||||
url: https://github.com/dexidp/dex/discussions/new?category=q-a
|
||||
about: Ask and discuss questions with other Dex community members
|
||||
|
||||
- name: 📚 Documentation
|
||||
url: https://dexidp.io/docs/
|
||||
about: Check the documentation for help
|
||||
|
||||
- name: 💬 Slack channel
|
||||
url: https://cloud-native.slack.com/messages/dexidp
|
||||
about: Please ask and answer questions here
|
||||
|
||||
- name: 💡 Dex Enhancement Proposal
|
||||
url: https://github.com/dexidp/dex/tree/master/enhancements/README.md
|
||||
about: Open a proposal for significant architectural change
|
|
@ -0,0 +1,40 @@
|
|||
name: 🎉 Feature request
|
||||
description: Suggest an idea for Dex
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thank you for submitting a feature request!
|
||||
|
||||
Please describe what you would like to change/add and why in detail by filling out the template below.
|
||||
|
||||
If you are not sure if your request fits into Dex, you can contact us via the available [support channels](https://github.com/dexidp/dex/issues/new/choose).
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Preflight Checklist
|
||||
description: Please ensure you've completed all of the following.
|
||||
options:
|
||||
- label: I agree to follow the [Code of Conduct](https://github.com/dexidp/dex/blob/master/.github/CODE_OF_CONDUCT.md) that this project adheres to.
|
||||
required: true
|
||||
- label: I have searched the [issue tracker](https://www.github.com/dexidp/dex/issues) for an issue that matches the one I want to file, without success.
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Problem Description
|
||||
description: A clear and concise description of the problem you are seeking to solve with this feature request.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Proposed Solution
|
||||
description: A clear and concise description of what would you like to happen.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Alternatives Considered
|
||||
description: A clear and concise description of any alternative solutions or features you've considered.
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Additional Information
|
||||
description: Add any other context about the problem here.
|
|
@ -0,0 +1,35 @@
|
|||
<!--
|
||||
Thank you for sending a pull request! Here are some tips for contributors:
|
||||
|
||||
1. Fill the description template below.
|
||||
2. Sign a DCO (if you haven't already signed it).
|
||||
3. Include appropriate tests (if necessary). Make sure that all CI checks passed.
|
||||
4. If the Pull Request is a work in progress, make use of GitHub's "Draft PR" feature and mark it as such.
|
||||
-->
|
||||
|
||||
#### Overview
|
||||
|
||||
<!-- Describe your changes briefly here. -->
|
||||
|
||||
#### What this PR does / why we need it
|
||||
|
||||
<!--
|
||||
- Please state in detail why we need this PR and what it solves.
|
||||
- If your PR closes some of the existing issues, please add links to them here.
|
||||
Mentioned issues will be automatically closed.
|
||||
Usage: "Closes #<issue number>", or "Closes (paste link of issue)"
|
||||
-->
|
||||
|
||||
#### Special notes for your reviewer
|
||||
|
||||
#### Does this PR introduce a user-facing change?
|
||||
|
||||
<!--
|
||||
If no, just write "NONE" in the release-note block below.
|
||||
If yes, a release note is required:
|
||||
Enter your extended release note in the block below. If the PR requires additional action from users switching to the new release, include the string "action required".
|
||||
-->
|
||||
|
||||
```release-note
|
||||
|
||||
```
|
|
@ -0,0 +1,24 @@
|
|||
# Security Policy
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
To report a vulnerability, send an email to [cncf-dex-maintainers@lists.cncf.io](mailto:cncf-dex-maintainers@lists.cncf.io)
|
||||
detailing the issue and steps to reproduce. The reporter(s) can expect a
|
||||
response within 48 hours acknowledging the issue was received. If a response is
|
||||
not received within 48 hours, please reach out to any maintainer directly
|
||||
to confirm receipt of the issue.
|
||||
|
||||
## Review Process
|
||||
|
||||
Once a maintainer has confirmed the relevance of the report, a draft security
|
||||
advisory will be created on Github. The draft advisory will be used to discuss
|
||||
the issue with maintainers, the reporter(s).
|
||||
If the reporter(s) wishes to participate in this discussion, then provide
|
||||
reporter Github username(s) to be invited to the discussion. If the reporter(s)
|
||||
does not wish to participate directly in the discussion, then the reporter(s)
|
||||
can request to be updated regularly via email.
|
||||
|
||||
If the vulnerability is accepted, a timeline for developing a patch, public
|
||||
disclosure, and patch release will be determined. The reporter(s) are expected
|
||||
to participate in the discussion of the timeline and abide by agreed upon dates
|
||||
for public disclosure.
|
|
@ -0,0 +1,30 @@
|
|||
version: 2
|
||||
|
||||
updates:
|
||||
- package-ecosystem: "gomod"
|
||||
directory: "/"
|
||||
labels:
|
||||
- "area/dependencies"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
|
||||
- package-ecosystem: "gomod"
|
||||
directory: "/api/v2"
|
||||
labels:
|
||||
- "area/dependencies"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/"
|
||||
labels:
|
||||
- "area/dependencies"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
labels:
|
||||
- "area/dependencies"
|
||||
schedule:
|
||||
interval: "daily"
|
|
@ -0,0 +1,30 @@
|
|||
changelog:
|
||||
exclude:
|
||||
labels:
|
||||
- release-note/ignore
|
||||
categories:
|
||||
- title: Exciting New Features 🎉
|
||||
labels:
|
||||
- kind/feature
|
||||
- release-note/new-feature
|
||||
- title: Enhancements 🚀
|
||||
labels:
|
||||
- kind/enhancement
|
||||
- release-note/enhancement
|
||||
- title: Bug Fixes 🐛
|
||||
labels:
|
||||
- kind/bug
|
||||
- release-note/bug-fix
|
||||
- title: Breaking Changes 🛠
|
||||
labels:
|
||||
- release-note/breaking-change
|
||||
- title: Deprecations ❌
|
||||
labels:
|
||||
- release-note/deprecation
|
||||
- title: Dependency Updates ⬆️
|
||||
labels:
|
||||
- area/dependencies
|
||||
- release-note/dependency-update
|
||||
- title: Other Changes
|
||||
labels:
|
||||
- "*"
|
|
@ -0,0 +1,97 @@
|
|||
name: Artifacts
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
tags:
|
||||
- v[0-9]+.[0-9]+.[0-9]+
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
container-images:
|
||||
name: Container images
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
variant:
|
||||
- alpine
|
||||
- distroless
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Gather metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/dexidp/dex
|
||||
dexidp/dex
|
||||
flavor: |
|
||||
latest = false
|
||||
tags: |
|
||||
type=ref,event=branch,enable=${{ matrix.variant == 'alpine' }}
|
||||
type=ref,event=pr,enable=${{ matrix.variant == 'alpine' }}
|
||||
type=semver,pattern={{raw}},enable=${{ matrix.variant == 'alpine' }}
|
||||
type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && matrix.variant == 'alpine' }}
|
||||
type=ref,event=branch,suffix=-${{ matrix.variant }}
|
||||
type=ref,event=pr,suffix=-${{ matrix.variant }}
|
||||
type=semver,pattern={{raw}},suffix=-${{ matrix.variant }}
|
||||
type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }},suffix=-${{ matrix.variant }}
|
||||
labels: |
|
||||
org.opencontainers.image.documentation=https://dexidp.io/docs/
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
with:
|
||||
platforms: all
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ github.token }}
|
||||
if: github.event_name == 'push'
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
if: github.event_name == 'push'
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le
|
||||
# cache-from: type=gha
|
||||
# cache-to: type=gha,mode=max
|
||||
push: ${{ github.event_name == 'push' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
build-args: |
|
||||
BASE_IMAGE=${{ matrix.variant }}
|
||||
VERSION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}
|
||||
COMMIT_HASH=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.revision'] }}
|
||||
BUILD_DATE=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.created'] }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
- name: Run Trivy vulnerability scanner
|
||||
uses: aquasecurity/trivy-action@0.6.1
|
||||
with:
|
||||
image-ref: "ghcr.io/dexidp/dex:${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}"
|
||||
format: "sarif"
|
||||
output: "trivy-results.sarif"
|
||||
if: github.event_name == 'push'
|
||||
|
||||
- name: Upload Trivy scan results to GitHub Security tab
|
||||
uses: github/codeql-action/upload-sarif@v2
|
||||
with:
|
||||
sarif_file: "trivy-results.sarif"
|
||||
if: github.event_name == 'push'
|
|
@ -0,0 +1,18 @@
|
|||
name: PR Checks
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, labeled, unlabeled, synchronize]
|
||||
|
||||
jobs:
|
||||
release-label:
|
||||
name: Release note label
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check minimum labels
|
||||
uses: mheap/github-action-required-labels@v2
|
||||
with:
|
||||
mode: minimum
|
||||
count: 1
|
||||
labels: "release-note/ignore, kind/feature, release-note/new-feature, kind/enhancement, release-note/enhancement, kind/bug, release-note/bug-fix, release-note/breaking-change, release-note/deprecation, area/dependencies, release-note/dependency-update"
|
|
@ -0,0 +1,129 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GOFLAGS: -mod=readonly
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:10.8
|
||||
ports:
|
||||
- 5432
|
||||
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
|
||||
|
||||
postgres-ent:
|
||||
image: postgres:10.8
|
||||
ports:
|
||||
- 5432
|
||||
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
|
||||
|
||||
mysql:
|
||||
image: mysql:5.7
|
||||
env:
|
||||
MYSQL_ROOT_PASSWORD: root
|
||||
MYSQL_DATABASE: dex
|
||||
ports:
|
||||
- 3306
|
||||
options: --health-cmd "mysql -proot -e \"show databases;\"" --health-interval 10s --health-timeout 5s --health-retries 5
|
||||
|
||||
mysql-ent:
|
||||
image: mysql:5.7
|
||||
env:
|
||||
MYSQL_ROOT_PASSWORD: root
|
||||
MYSQL_DATABASE: dex
|
||||
ports:
|
||||
- 3306
|
||||
options: --health-cmd "mysql -proot -e \"show databases;\"" --health-interval 10s --health-timeout 5s --health-retries 5
|
||||
|
||||
etcd:
|
||||
image: gcr.io/etcd-development/etcd:v3.5.0
|
||||
ports:
|
||||
- 2379
|
||||
env:
|
||||
ETCD_LISTEN_CLIENT_URLS: http://0.0.0.0:2379
|
||||
ETCD_ADVERTISE_CLIENT_URLS: http://0.0.0.0:2379
|
||||
options: --health-cmd "ETCDCTL_API=3 etcdctl --endpoints http://localhost:2379 endpoint health" --health-interval 10s --health-timeout 5s --health-retries 5
|
||||
|
||||
keystone:
|
||||
image: openio/openstack-keystone:rocky
|
||||
ports:
|
||||
- 5000
|
||||
- 35357
|
||||
options: --health-cmd "curl --fail http://localhost:5000/v3" --health-interval 10s --health-timeout 5s --health-retries 5
|
||||
|
||||
steps:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: 1.18
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Start services
|
||||
run: docker-compose -f docker-compose.test.yaml up -d
|
||||
|
||||
- name: Create kind cluster
|
||||
uses: helm/kind-action@v1.3.0
|
||||
with:
|
||||
version: v0.11.1
|
||||
node_image: kindest/node:v1.19.11@sha256:07db187ae84b4b7de440a73886f008cf903fcf5764ba8106a9fd5243d6f32729
|
||||
|
||||
- name: Download tool dependencies
|
||||
run: make deps
|
||||
|
||||
- name: Test
|
||||
run: make testall
|
||||
env:
|
||||
DEX_MYSQL_DATABASE: dex
|
||||
DEX_MYSQL_USER: root
|
||||
DEX_MYSQL_PASSWORD: root
|
||||
DEX_MYSQL_HOST: 127.0.0.1
|
||||
DEX_MYSQL_PORT: ${{ job.services.mysql.ports[3306] }}
|
||||
|
||||
DEX_MYSQL_ENT_DATABASE: dex
|
||||
DEX_MYSQL_ENT_USER: root
|
||||
DEX_MYSQL_ENT_PASSWORD: root
|
||||
DEX_MYSQL_ENT_HOST: 127.0.0.1
|
||||
DEX_MYSQL_ENT_PORT: ${{ job.services.mysql-ent.ports[3306] }}
|
||||
|
||||
DEX_POSTGRES_DATABASE: postgres
|
||||
DEX_POSTGRES_USER: postgres
|
||||
DEX_POSTGRES_PASSWORD: postgres
|
||||
DEX_POSTGRES_HOST: localhost
|
||||
DEX_POSTGRES_PORT: ${{ job.services.postgres.ports[5432] }}
|
||||
|
||||
DEX_POSTGRES_ENT_DATABASE: postgres
|
||||
DEX_POSTGRES_ENT_USER: postgres
|
||||
DEX_POSTGRES_ENT_PASSWORD: postgres
|
||||
DEX_POSTGRES_ENT_HOST: localhost
|
||||
DEX_POSTGRES_ENT_PORT: ${{ job.services.postgres-ent.ports[5432] }}
|
||||
|
||||
DEX_ETCD_ENDPOINTS: http://localhost:${{ job.services.etcd.ports[2379] }}
|
||||
|
||||
DEX_LDAP_HOST: localhost
|
||||
DEX_LDAP_PORT: 389
|
||||
DEX_LDAP_TLS_PORT: 636
|
||||
|
||||
DEX_KEYSTONE_URL: http://localhost:${{ job.services.keystone.ports[5000] }}
|
||||
DEX_KEYSTONE_ADMIN_URL: http://localhost:${{ job.services.keystone.ports[35357] }}
|
||||
DEX_KEYSTONE_ADMIN_USER: demo
|
||||
DEX_KEYSTONE_ADMIN_PASS: DEMO_PASS
|
||||
|
||||
DEX_KUBERNETES_CONFIG_PATH: ~/.kube/config
|
||||
|
||||
- name: Lint
|
||||
run: make lint
|
||||
|
||||
# Ensure proto generation doesn't depend on external packages.
|
||||
- name: Verify proto
|
||||
run: make verify-proto
|
|
@ -0,0 +1,67 @@
|
|||
# For most projects, this workflow file will not need changing; you simply need
|
||||
# to commit it to your repository.
|
||||
#
|
||||
# You may wish to alter this file to override the set of languages analyzed,
|
||||
# or to provide custom queries or build logic.
|
||||
#
|
||||
# ******** NOTE ********
|
||||
# We have attempted to detect the languages in your repository. Please check
|
||||
# the `language` matrix defined below to confirm you have the correct set of
|
||||
# supported CodeQL languages.
|
||||
#
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master, v1 ]
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [ master ]
|
||||
schedule:
|
||||
- cron: '28 10 * * 6'
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [ 'go' ]
|
||||
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
|
||||
# Learn more:
|
||||
# https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
# queries: ./path/to/local/query, your-org/your-repo/queries@main
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v2
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
|
||||
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
|
||||
# and modify them (or add more) to build your code if your project
|
||||
# uses a compiled language
|
||||
|
||||
#- run: |
|
||||
# make bootstrap
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
|
@ -0,0 +1,111 @@
|
|||
name: Docker
|
||||
|
||||
on:
|
||||
# push:
|
||||
# branches:
|
||||
# - master
|
||||
# tags:
|
||||
# - v[0-9]+.[0-9]+.[0-9]+
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
name: Docker
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Calculate Docker image tags
|
||||
id: tags
|
||||
env:
|
||||
DOCKER_IMAGES: "ghcr.io/dexidp/dex dexidp/dex"
|
||||
run: |
|
||||
case $GITHUB_REF in
|
||||
refs/tags/*) VERSION=${GITHUB_REF#refs/tags/};;
|
||||
refs/heads/*) VERSION=$(echo ${GITHUB_REF#refs/heads/} | sed -r 's#/+#-#g');;
|
||||
refs/pull/*) VERSION=pr-${{ github.event.number }};;
|
||||
*) VERSION=sha-${GITHUB_SHA::8};;
|
||||
esac
|
||||
|
||||
TAGS=()
|
||||
for image in $DOCKER_IMAGES; do
|
||||
TAGS+=("${image}:${VERSION}")
|
||||
|
||||
if [[ "${{ github.event.repository.default_branch }}" == "$VERSION" ]]; then
|
||||
TAGS+=("${image}:latest")
|
||||
fi
|
||||
done
|
||||
|
||||
echo ::set-output name=version::${VERSION}
|
||||
echo ::set-output name=tags::$(IFS=,; echo "${TAGS[*]}")
|
||||
echo ::set-output name=commit_hash::${GITHUB_SHA::8}
|
||||
echo ::set-output name=build_date::$(git show -s --format=%cI)
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
with:
|
||||
platforms: all
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
with:
|
||||
install: true
|
||||
version: latest
|
||||
# TODO: Remove driver-opts once fix is released docker/buildx#386
|
||||
driver-opts: image=moby/buildkit:master
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ github.token }}
|
||||
if: github.event_name == 'push'
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
if: github.event_name == 'push'
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le
|
||||
# cache-from: type=gha
|
||||
# cache-to: type=gha,mode=max
|
||||
push: ${{ github.event_name == 'push' }}
|
||||
tags: ${{ steps.tags.outputs.tags }}
|
||||
build-args: |
|
||||
VERSION=${{ steps.tags.outputs.version }}
|
||||
COMMIT_HASH=${{ steps.tags.outputs.commit_hash }}
|
||||
BUILD_DATE=${{ steps.tags.outputs.build_date }}
|
||||
labels: |
|
||||
org.opencontainers.image.title=${{ github.event.repository.name }}
|
||||
org.opencontainers.image.description=${{ github.event.repository.description }}
|
||||
org.opencontainers.image.url=${{ github.event.repository.html_url }}
|
||||
org.opencontainers.image.source=${{ github.event.repository.clone_url }}
|
||||
org.opencontainers.image.version=${{ steps.tags.outputs.version }}
|
||||
org.opencontainers.image.created=${{ steps.tags.outputs.build_date }}
|
||||
org.opencontainers.image.revision=${{ github.sha }}
|
||||
org.opencontainers.image.licenses=${{ github.event.repository.license.spdx_id }}
|
||||
org.opencontainers.image.documentation=https://dexidp.io/docs/
|
||||
|
||||
- name: Run Trivy vulnerability scanner
|
||||
uses: aquasecurity/trivy-action@0.6.1
|
||||
with:
|
||||
image-ref: "ghcr.io/dexidp/dex:${{ steps.tags.outputs.version }}"
|
||||
format: "template"
|
||||
template: "@/contrib/sarif.tpl"
|
||||
output: "trivy-results.sarif"
|
||||
if: github.event_name == 'push'
|
||||
|
||||
- name: Upload Trivy scan results to GitHub Security tab
|
||||
uses: github/codeql-action/upload-sarif@v2
|
||||
with:
|
||||
sarif_file: "trivy-results.sarif"
|
||||
if: github.event_name == 'push'
|
|
@ -1,3 +1,7 @@
|
|||
bin
|
||||
dist
|
||||
_output
|
||||
/.direnv/
|
||||
/.idea/
|
||||
/bin/
|
||||
/config.yaml
|
||||
/docker-compose.override.yaml
|
||||
/var/
|
||||
/vendor/
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
tasks:
|
||||
- init: go get && go build ./... && go test ./... && make
|
||||
command: go run
|
|
@ -0,0 +1,90 @@
|
|||
run:
|
||||
timeout: 4m
|
||||
|
||||
linters-settings:
|
||||
depguard:
|
||||
list-type: blacklist
|
||||
include-go-root: true
|
||||
packages:
|
||||
- io/ioutil
|
||||
packages-with-error-message:
|
||||
- io/ioutil: "The 'io/ioutil' package is deprecated. Use corresponding 'os' or 'io' functions instead."
|
||||
gci:
|
||||
local-prefixes: github.com/dexidp/dex
|
||||
goimports:
|
||||
local-prefixes: github.com/dexidp/dex
|
||||
|
||||
|
||||
linters:
|
||||
disable-all: true
|
||||
enable:
|
||||
- bodyclose
|
||||
- deadcode
|
||||
- depguard
|
||||
- dogsled
|
||||
- exhaustive
|
||||
- exportloopref
|
||||
- gci
|
||||
- gochecknoinits
|
||||
- gocritic
|
||||
- gofmt
|
||||
- gofumpt
|
||||
- goimports
|
||||
- goprintffuncname
|
||||
- gosimple
|
||||
- govet
|
||||
- ineffassign
|
||||
- misspell
|
||||
- nakedret
|
||||
- nolintlint
|
||||
- prealloc
|
||||
- revive
|
||||
- rowserrcheck
|
||||
- sqlclosecheck
|
||||
- staticcheck
|
||||
- structcheck
|
||||
- stylecheck
|
||||
- tparallel
|
||||
- unconvert
|
||||
- unparam
|
||||
- unused
|
||||
- varcheck
|
||||
- whitespace
|
||||
|
||||
# Disable temporarily until everything works with Go 1.18
|
||||
# - typecheck
|
||||
|
||||
# TODO: fix linter errors before enabling
|
||||
# - exhaustivestruct
|
||||
# - gochecknoglobals
|
||||
# - errorlint
|
||||
# - gocognit
|
||||
# - godot
|
||||
# - nlreturn
|
||||
# - noctx
|
||||
# - wrapcheck
|
||||
|
||||
# TODO: fix linter errors before enabling (from original config)
|
||||
# - dupl
|
||||
# - errcheck
|
||||
# - goconst
|
||||
# - gocyclo
|
||||
# - gosec
|
||||
# - lll
|
||||
# - scopelint
|
||||
|
||||
# unused
|
||||
# - goheader
|
||||
# - gomodguard
|
||||
|
||||
# don't enable:
|
||||
# - asciicheck
|
||||
# - funlen
|
||||
# - godox
|
||||
# - goerr113
|
||||
# - gomnd
|
||||
# - interfacer
|
||||
# - maligned
|
||||
# - nestif
|
||||
# - testpackage
|
||||
# - wsl
|
21
.travis.yml
21
.travis.yml
|
@ -1,21 +0,0 @@
|
|||
language: go
|
||||
|
||||
go:
|
||||
- 1.7.5
|
||||
- 1.8
|
||||
|
||||
services:
|
||||
- postgresql
|
||||
|
||||
env:
|
||||
- DEX_POSTGRES_DATABASE=postgres DEX_POSTGRES_USER=postgres DEX_POSTGRES_HOST="localhost"
|
||||
|
||||
|
||||
install:
|
||||
- go get -u github.com/golang/lint/golint
|
||||
|
||||
script:
|
||||
- make testall
|
||||
|
||||
notifications:
|
||||
email: false
|
|
@ -0,0 +1,15 @@
|
|||
# Adopters
|
||||
|
||||
This is a list of production adopters of Dex (in alphabetical order):
|
||||
|
||||
- [Aspect](https://www.aspect.com/) uses Dex for authenticating users across their Kubernetes infrastructure (using Kubernetes OIDC support).
|
||||
- [Banzai Cloud](https://banzaicloud.com) is using Dex for authenticating to its Pipeline control plane and also to authenticate users against provisioned Kubernetes clusters (via Kubernetes OIDC support).
|
||||
- [Chef](https://chef.io) uses Dex for authenticating users in [Chef Automate](https://automate.chef.io/). The code is Open Source, available at [`github.com/chef/automate`](https://github.com/chef/automate).
|
||||
- [Elastisys](https://elastisys.com) uses Dex for authentication in their [Compliant Kubernetes](https://compliantkubernetes.io) distribution, including SSO to the custom dashboard, Grafana, Kibana, and Harbor.
|
||||
- [Flant](https://flant.com) uses Dex for providing access to core components of [Managed Kubernetes as a Service](https://flant.com/services/managed-kubernetes-as-a-service), integration with various authentication providers, plugging custom applications.
|
||||
- [JuliaBox](https://juliabox.com/) is leveraging federated OIDC provided by Dex for authenticating users to their compute infrastructure based on Kubernetes.
|
||||
- [Kasten](https://www.kasten.io) is using Dex for authenticating access to the dashboard of [K10](https://www.kasten.io/product/), a Kubernetes-native platform for backup, disaster recovery and mobility of Kubernetes applications. K10 is widely used by a variety of customers including large enterprises, financial services, design firms, and IT companies.
|
||||
- [Kyma](https://kyma-project.io) is using Dex to authenticate access to Kubernetes API server (even for managed Kubernetes like Google Kubernetes Engine or Azure Kubernetes Service) and for protecting web UI of [Kyma Console](https://github.com/kyma-project/console) and other UIs integrated in Kyma ([Grafana](https://github.com/grafana/grafana), [Loki](https://github.com/grafana/loki), and [Jaeger](https://github.com/jaegertracing/jaeger)). Kyma is an open-source project ([`github.com/kyma-project`](https://github.com/kyma-project/kyma)) designed natively on Kubernetes, that allows you to extend and customize your applications in a quick and modern way, using serverless computing or microservice architecture.
|
||||
- [Pusher](https://pusher.com) uses Dex for authenticating users across their Kubernetes infrastructure (using Kubernetes OIDC support) in conjunction with the [OAuth2 Proxy](https://github.com/pusher/oauth2_proxy) for protecting web UIs.
|
||||
- [Pydio](https://pydio.com/) Pydio Cells is an open source sync & share platform written in Go. Cells is using Dex as an OIDC service for authentication and authorizations. Check out [Pydio Cells repository](https://github.com/pydio/cells) for more information and/or to contribute.
|
||||
- [sigstore](https://sigstore.dev) uses Dex for authentication in their public Fulcio instance, which is a certificate authority for code signing certificates bound to OIDC-based identities.
|
75
Dockerfile
75
Dockerfile
|
@ -1,23 +1,72 @@
|
|||
FROM alpine:3.4
|
||||
ARG BASE_IMAGE=alpine
|
||||
|
||||
MAINTAINER Ed Rooth <ed.rooth@coreos.com>
|
||||
MAINTAINER Eric Chiang <eric.chiang@coreos.com>
|
||||
MAINTAINER Rithu John <rithu.john@coreos.com>
|
||||
FROM golang:1.18.4-alpine3.15 AS builder
|
||||
|
||||
WORKDIR /usr/local/src/dex
|
||||
|
||||
RUN apk add --no-cache --update alpine-sdk ca-certificates openssl
|
||||
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
ARG TARGETVARIANT=""
|
||||
|
||||
ENV GOOS=${TARGETOS} GOARCH=${TARGETARCH} GOARM=${TARGETVARIANT}
|
||||
|
||||
ARG GOPROXY
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
COPY api/v2/go.mod api/v2/go.sum ./api/v2/
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN make release-binary
|
||||
|
||||
FROM alpine:3.16.2 AS stager
|
||||
|
||||
RUN mkdir -p /var/dex
|
||||
RUN mkdir -p /etc/dex
|
||||
COPY config.docker.yaml /etc/dex/
|
||||
|
||||
FROM alpine:3.16.2 AS gomplate
|
||||
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
ARG TARGETVARIANT
|
||||
|
||||
ENV GOMPLATE_VERSION=v3.11.2
|
||||
|
||||
RUN wget -O /usr/local/bin/gomplate \
|
||||
"https://github.com/hairyhenderson/gomplate/releases/download/${GOMPLATE_VERSION}/gomplate_${TARGETOS:-linux}-${TARGETARCH:-amd64}${TARGETVARIANT}" \
|
||||
&& chmod +x /usr/local/bin/gomplate
|
||||
|
||||
# For Dependabot to detect base image versions
|
||||
FROM alpine:3.16.2 AS alpine
|
||||
FROM gcr.io/distroless/static:latest AS distroless
|
||||
|
||||
FROM $BASE_IMAGE
|
||||
|
||||
# Dex connectors, such as GitHub and Google logins require root certificates.
|
||||
# Proper installations should manage those certificates, but it's a bad user
|
||||
# experience when this doesn't work out of the box.
|
||||
#
|
||||
# OpenSSL is required so wget can query HTTPS endpoints for health checking.
|
||||
RUN apk add --update ca-certificates openssl
|
||||
# See https://go.dev/src/crypto/x509/root_linux.go for Go root CA bundle locations.
|
||||
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
||||
|
||||
COPY _output/bin/dex /usr/local/bin/dex
|
||||
COPY --from=stager --chown=1001:1001 /var/dex /var/dex
|
||||
COPY --from=stager --chown=1001:1001 /etc/dex /etc/dex
|
||||
|
||||
# Import frontend assets and set the correct CWD directory so the assets
|
||||
# are in the default path.
|
||||
COPY web /web
|
||||
WORKDIR /
|
||||
# Copy module files for CVE scanning / dependency analysis.
|
||||
COPY --from=builder /usr/local/src/dex/go.mod /usr/local/src/dex/go.sum /usr/local/src/dex/
|
||||
COPY --from=builder /usr/local/src/dex/api/v2/go.mod /usr/local/src/dex/api/v2/go.sum /usr/local/src/dex/api/v2/
|
||||
|
||||
ENTRYPOINT ["dex"]
|
||||
COPY --from=builder /go/bin/dex /usr/local/bin/dex
|
||||
COPY --from=builder /go/bin/docker-entrypoint /usr/local/bin/docker-entrypoint
|
||||
COPY --from=builder /usr/local/src/dex/web /srv/dex/web
|
||||
|
||||
CMD ["version"]
|
||||
COPY --from=gomplate /usr/local/bin/gomplate /usr/local/bin/gomplate
|
||||
|
||||
USER 1001:1001
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/docker-entrypoint"]
|
||||
CMD ["dex", "serve", "/etc/dex/config.docker.yaml"]
|
||||
|
|
|
@ -1,117 +0,0 @@
|
|||
# The dex API
|
||||
|
||||
Dex provides a [gRPC][grpc] service for programmatic modification of dex's state. The API is intended to expose hooks for management applications and is not expected to be used by most installations.
|
||||
|
||||
This document is an overview of how to interact with the API.
|
||||
|
||||
## Configuration
|
||||
|
||||
Admins that wish to expose the gRPC service must add the following entry to the dex config file. This option is off by default.
|
||||
|
||||
```
|
||||
grpc:
|
||||
# Cannot be the same address as an HTTP(S) service.
|
||||
addr: 127.0.0.1:5557
|
||||
# Server certs. If TLS credentials aren't provided dex will generate self-signed ones.
|
||||
tlsCert: /etc/dex/grpc.crt
|
||||
tlsKey: /etc/dex/grpc.key
|
||||
# Client auth CA.
|
||||
tlsClientCA: /etc/dex/client.crt
|
||||
```
|
||||
|
||||
## Generating clients
|
||||
|
||||
gRPC is a suite of tools for generating client and server bindings from a common declarative language. The canonical schema for dex's API can be found in the source tree at [`api/api.proto`][api-proto]. Go bindings are generated and maintained in the same directory for internal use.
|
||||
|
||||
To generate a client for your own project install [`protoc`][protoc], install a protobuf generator for your project's language, and download the `api.proto` file. An example for a Go project:
|
||||
|
||||
```
|
||||
# Install protoc-gen-go.
|
||||
$ go get -u github.com/golang/protobuf/{proto,protoc-gen-go}
|
||||
|
||||
# Download api.proto for a given version.
|
||||
$ DEX_VERSION=v2.0.0-alpha.5
|
||||
$ wget https://raw.githubusercontent.com/coreos/dex/${DEX_VERSION}/api/api.proto
|
||||
|
||||
# Generate the Go client bindings.
|
||||
$ protoc --go_out=import_path=dexapi:. api.proto
|
||||
```
|
||||
|
||||
Client programs can then be written using the generated code. A Go client which uses dex's internally generated code might look like the following:
|
||||
|
||||
__NOTE:__ Because dex has the `google.golang.org/grpc` package in its `vendor` directory, gRPC code in `github.com/coreos/dex/api` refers to the vendored copy, not copies in a developers GOPATH. Clients must either regenerate the gRPC Go code or vendor dex and remove its `vendor` directory to run this program.
|
||||
|
||||
```
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/coreos/dex/api"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials"
|
||||
)
|
||||
|
||||
func newDexClient(hostAndPort, caPath string) (api.DexClient, error) {
|
||||
creds, err := credentials.NewClientTLSFromFile(caPath, "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load dex cert: %v", err)
|
||||
}
|
||||
|
||||
conn, err := grpc.Dial(hostAndPort, grpc.WithTransportCredentials(creds))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dail: %v", err)
|
||||
}
|
||||
return api.NewDexClient(conn), nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
client, err := newDexClient("127.0.0.1:5557", "/etc/dex/grpc.crt")
|
||||
if err != nil {
|
||||
log.Fatalf("failed creating dex client: %v ", err)
|
||||
}
|
||||
|
||||
req := &api.CreateClientReq{
|
||||
Client: &api.Client{
|
||||
Id: "example-app",
|
||||
Name: "Example App",
|
||||
Secret: "ZXhhbXBsZS1hcHAtc2VjcmV0",
|
||||
RedirectUris: []string{"http://127.0.0.1:5555/callback"},
|
||||
},
|
||||
}
|
||||
|
||||
if _, err := client.CreateClient(context.TODO(), req); err != nil {
|
||||
log.Fatalf("failed creating oauth2 client: %v", err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
A clear working example of the Dex gRPC client can be found [here][../examples/grpc-client/README.md].
|
||||
|
||||
## Authentication and access control
|
||||
|
||||
The dex API does not provide any authentication or authorization beyond TLS client auth.
|
||||
|
||||
Projects that wish to add access controls on top of the existing API should build apps which perform such checks. For example to provide a "Change password" screen, a client app could use dex's OpenID Connect flow to authenticate an end user, then call dex's API to update that user's password.
|
||||
|
||||
## dexctl?
|
||||
|
||||
Dex does not ship with a command line tool for interacting with the API. Command line tools are useful but hard to version, easy to design poorly, and expose another interface which can never be changed in the name of compatibility.
|
||||
|
||||
While the dex team would be open to re-implementing `dexctl` for v2 a majority of the work is writing a design document, not the actual programming effort.
|
||||
|
||||
## Why not REST or gRPC Gateway?
|
||||
|
||||
Between v1 and v2, dex switched from REST to gRPC. This largely stemmed from problems generating documentation, client bindings, and server frameworks that adequately expressed REST semantics. While [Google APIs][google-apis], [Open API/Swagger][open-api], and [gRPC Gateway][grpc-gateway] were evaluated, they often became clunky when trying to use specific HTTP error codes or complex request bodies. As a result, v2's API is entirely gRPC.
|
||||
|
||||
Many arguments _against_ gRPC cite short term convenience rather than production use cases. Though this is a recognized shortcoming, dex already implements many features for developer convenience. For instance, users who wish to manually edit clients during testing can use the `staticClients` config field instead of the API.
|
||||
|
||||
[grpc]: http://www.grpc.io/
|
||||
[api-proto]: ../api/api.proto
|
||||
[protoc]: https://github.com/google/protobuf/releases
|
||||
[protoc-gen-go]: https://github.com/golang/protobuf
|
||||
[google-apis]: https://github.com/google/apis-client-generator
|
||||
[open-api]: https://openapis.org/
|
||||
[grpc-gateway]: https://github.com/grpc-ecosystem/grpc-gateway
|
|
@ -1,35 +0,0 @@
|
|||
# Managing dependencies
|
||||
|
||||
Dex uses [glide][glide] and [glide-vc][glide-vc] to manage its [`vendor` directory][go-vendor]. A recent version of these are preferred but dex doesn't require any bleeding edge features. Either install these tools using `go get` or take an opportunity to update to a more recent version.
|
||||
|
||||
```
|
||||
go get -u github.com/Masterminds/glide
|
||||
go get -u github.com/sgotti/glide-vc
|
||||
```
|
||||
|
||||
To add a new dependency to dex or update an existing one:
|
||||
|
||||
* Make changes to dex's source code importing the new dependency.
|
||||
* Edit `glide.yaml` to include the new dependency at a given commit SHA or change a SHA.
|
||||
* Add all transitive dependencies of the package to prevent unpinned packages.
|
||||
|
||||
Tests will fail if transitive dependencies aren't included.
|
||||
|
||||
Once `glide.yaml` describes the desired state use `make` to update `glide.lock` and `vendor`. This calls both `glide` and `glide-vc` with the set of flags that dex requires.
|
||||
|
||||
```
|
||||
make revendor
|
||||
```
|
||||
|
||||
When composing commits make sure that updates to `vendor` are in a separate commit from the main changes. GitHub's UI makes commits with a large number of changes unreviewable.
|
||||
|
||||
Commit histories should look like the following:
|
||||
|
||||
```
|
||||
connector/ldap: add a LDAP connector
|
||||
vendor: revendor
|
||||
```
|
||||
|
||||
[glide]: https://github.com/Masterminds/glide
|
||||
[glide-vc]: https://github.com/sgotti/glide-vc
|
||||
[go-vendor]: https://golang.org/cmd/go/#hdr-Vendor_Directories
|
|
@ -1,138 +0,0 @@
|
|||
# Running integration tests
|
||||
|
||||
## Kubernetes
|
||||
|
||||
Kubernetes tests will only run if the `DEX_KUBECONFIG` environment variable is set.
|
||||
|
||||
```
|
||||
$ export DEX_KUBECONFIG=~/.kube/config
|
||||
$ go test -v -i ./storage/kubernetes
|
||||
$ go test -v ./storage/kubernetes
|
||||
```
|
||||
|
||||
Because third party resources creation isn't synchronized it's expected that the tests fail the first time. Fear not, and just run them again.
|
||||
|
||||
## Postgres
|
||||
|
||||
Running database tests locally require:
|
||||
|
||||
* A systemd based Linux distro.
|
||||
* A recent version of [rkt](https://github.com/coreos/rkt) installed.
|
||||
|
||||
The `standup.sh` script in the SQL directory is used to run databases in containers with systemd daemonizing the process.
|
||||
|
||||
```
|
||||
$ sudo ./storage/sql/standup.sh create postgres
|
||||
Starting postgres. To view progress run
|
||||
|
||||
journalctl -fu dex-postgres
|
||||
|
||||
Running as unit dex-postgres.service.
|
||||
To run tests export the following environment variables:
|
||||
|
||||
export DEX_POSTGRES_DATABASE=postgres; export DEX_POSTGRES_USER=postgres; export DEX_POSTGRES_PASSWORD=postgres; export DEX_POSTGRES_HOST=172.16.28.3:5432
|
||||
|
||||
```
|
||||
|
||||
Exporting the variables will cause the database tests to be run, rather than skipped.
|
||||
|
||||
```
|
||||
$ # sqlite3 takes forever to compile, be sure to install test dependencies
|
||||
$ go test -v -i ./storage/sql
|
||||
$ go test -v ./storage/sql
|
||||
```
|
||||
|
||||
When you're done, tear down the unit using the `standup.sh` script.
|
||||
|
||||
```
|
||||
$ sudo ./storage/sql/standup.sh destroy postgres
|
||||
```
|
||||
|
||||
## LDAP
|
||||
|
||||
To run LDAP tests locally, you require a container running OpenLDAP.
|
||||
|
||||
Run OpenLDAP docker image:
|
||||
|
||||
```
|
||||
$ sudo docker run --hostname ldap.example.org --name openldap-container --detach osixia/openldap:1.1.6
|
||||
```
|
||||
|
||||
By default TLS is enabled and a certificate is created with the container hostname, which in this case is "ldap.example.org". It will create an empty LDAP for the company Example Inc. and the domain example.org. By default the admin has the password admin.
|
||||
|
||||
Add new users and groups (sample .ldif file included at the end):
|
||||
|
||||
```
|
||||
$ sudo docker exec openldap-container ldapadd -x -D "cn=admin,dc=example,dc=org" -w admin -f <path to .ldif> -h ldap.example.org -ZZ
|
||||
```
|
||||
|
||||
Verify that the added entries are in your directory with ldapsearch :
|
||||
|
||||
```
|
||||
$ sudo docker exec openldap-container ldapsearch -x -h localhost -b dc=example,dc=org -D "cn=admin,dc=example,dc=org" -w admin
|
||||
```
|
||||
The .ldif file should contain seed data. Example file contents:
|
||||
|
||||
```
|
||||
dn: cn=Test1,dc=example,dc=org
|
||||
objectClass: organizationalRole
|
||||
cn: Test1
|
||||
|
||||
dn: cn=Test2,dc=example,dc=org
|
||||
objectClass: organizationalRole
|
||||
cn: Test2
|
||||
|
||||
dn: ou=groups,dc=example,dc=org
|
||||
ou: groups
|
||||
objectClass: top
|
||||
objectClass: organizationalUnit
|
||||
|
||||
dn: cn=tstgrp,ou=groups,dc=example,dc=org
|
||||
objectClass: top
|
||||
objectClass: groupOfNames
|
||||
member: cn=Test1,dc=example,dc=org
|
||||
cn: tstgrp
|
||||
```
|
||||
|
||||
## SAML
|
||||
|
||||
### Okta
|
||||
|
||||
The Okta identity provider supports free accounts for developers to test their implementation against. This document describes configuring an Okta application to test dex's SAML connector.
|
||||
|
||||
First, [sign up for a developer account][okta-sign-up]. Then, to create a SAML application:
|
||||
|
||||
* Go to the admin screen.
|
||||
* Click "Add application"
|
||||
* Click "Create New App"
|
||||
* Choose "SAML 2.0" and press "Create"
|
||||
* Configure SAML
|
||||
* Enter `http://127.0.0.1:5556/dex/callback` for "Single sign on URL"
|
||||
* Enter `http://127.0.0.1:5556/dex/callback` for "Audience URI (SP Entity ID)"
|
||||
* Under "ATTRIBUTE STATEMENTS (OPTIONAL)" add an "email" and "name" attribute. The values should be something like `user:email` and `user:firstName`, respectively.
|
||||
* Under "GROUP ATTRIBUTE STATEMENTS (OPTIONAL)" add a "groups" attribute. Use the "Regexp" filter `.*`.
|
||||
|
||||
After the application's created, assign yourself to the app.
|
||||
|
||||
* "Applications" > "Applications"
|
||||
* Click on your application then under the "People" tab press the "Assign to People" button and add yourself.
|
||||
|
||||
At the app, go to the "Sign On" tab and then click "View Setup Instructions". Use those values to fill out the following connector in `examples/config-dev.yaml`.
|
||||
|
||||
```yaml
|
||||
connectors:
|
||||
- type: samlExperimental
|
||||
id: saml
|
||||
name: Okta
|
||||
config:
|
||||
ssoURL: ( "Identity Provider Single Sign-On URL" )
|
||||
caData: ( base64'd value of "X.509 Certificate" )
|
||||
redirectURI: http://127.0.0.1:5556/dex/callback
|
||||
usernameAttr: name
|
||||
emailAttr: email
|
||||
groupsAttr: groups
|
||||
```
|
||||
|
||||
Start both dex and the example app, and try logging in (requires not requesting a refresh token).
|
||||
|
||||
[okta-sign-up]: https://www.okta.com/developer/signup/
|
|
@ -1,81 +0,0 @@
|
|||
# Releases
|
||||
|
||||
Making a dex release involves:
|
||||
|
||||
* Tagging a git commit and pushing the tag to GitHub.
|
||||
* Building and pushing a Docker image.
|
||||
|
||||
This requires the following tools.
|
||||
|
||||
* Docker
|
||||
|
||||
And the following permissions.
|
||||
|
||||
* Push access to the github.com/coreos/dex git repo.
|
||||
* Push access to the quay.io/coreos/dex Docker repo.
|
||||
|
||||
## Tagging the release
|
||||
|
||||
Make sure you've [uploaded your GPG key](https://github.com/settings/keys) and
|
||||
configured git to [use that signing key](
|
||||
https://git-scm.com/book/en/v2/Git-Tools-Signing-Your-Work) either globally or
|
||||
for the Dex repo. Note that the email the key is issued for must be the email
|
||||
you use for git.
|
||||
|
||||
```
|
||||
git config [--global] user.signingkey "{{ GPG key ID }}"
|
||||
git config [--global] user.email "{{ Email associated with key }}"
|
||||
```
|
||||
|
||||
Create a signed tag at the commit you wish to release. This action will prompt
|
||||
you to enter a tag message, which can just be the release version.
|
||||
|
||||
```
|
||||
git tag -s v2.0.0 ea4c04fde83bd6c48f4d43862c406deb4ea9dba2
|
||||
```
|
||||
|
||||
Push that tag to the CoreOS repo.
|
||||
|
||||
```
|
||||
git push git@github.com:coreos/dex.git v2.0.0
|
||||
```
|
||||
|
||||
Draft releases on GitHub and summarize the changes since the last release. See
|
||||
previous releases for the expected format.
|
||||
|
||||
https://github.com/coreos/dex/releases
|
||||
|
||||
## Minor releases - create a branch
|
||||
|
||||
If the release is a minor release (2.1.0, 2.2.0, etc.) create a branch for future patch releases.
|
||||
|
||||
```bash
|
||||
git checkout -b v2.1.x tags/v2.1.0
|
||||
git push git@github.com:coreos/dex.git v2.1.x
|
||||
```
|
||||
|
||||
## Patch releases - cherry pick required commits
|
||||
|
||||
If the release is a patch release (2.0.1, 2.0.2, etc.) checkout the desired release branch and cherry pick specific commits. A patch release is only meant for urgent bug or security fixes.
|
||||
|
||||
```bash
|
||||
RELEASE_BRANCH="v2.0.x"
|
||||
git checkout $RELEASE_BRANCH
|
||||
git checkout -b "cherry-picked-change"
|
||||
git cherry-pick (SHA of change)
|
||||
git push origin "cherry-picked-change"
|
||||
```
|
||||
|
||||
Open a PR onto $RELEASE_BRANCH to get the changes approved.
|
||||
|
||||
## Building the Docker image
|
||||
|
||||
Build the Docker image and push to Quay.
|
||||
|
||||
```bash
|
||||
# checkout the tag
|
||||
git checkout tags/v2.1.0
|
||||
# will prompt for sudo password
|
||||
make docker-image
|
||||
sudo docker push quay.io/coreos/dex:v2.1.0
|
||||
```
|
|
@ -1,47 +0,0 @@
|
|||
# Getting started
|
||||
|
||||
## Building the dex binary
|
||||
|
||||
Dex requires a Go installation and a GOPATH configured. For setting up a Go workspace, refer to the [official documentation][go-setup]. Clone it down the correct place, and simply type `make` to compile the dex binary.
|
||||
|
||||
```
|
||||
$ go get github.com/coreos/dex
|
||||
$ cd $GOPATH/src/github.com/coreos/dex
|
||||
$ make
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Dex exclusively pulls configuration options from a config file. Use the [example config][example-config] file found in the `examples/` directory to start an instance of dex with an in-memory data store and a set of predefined OAuth2 clients.
|
||||
|
||||
```
|
||||
./bin/dex serve examples/config-dev.yaml
|
||||
```
|
||||
|
||||
The [example config][example-config] file documents many of the configuration options through inline comments. For extra config options, look at that file.
|
||||
|
||||
## Running a client
|
||||
|
||||
Dex operates like most other OAuth2 providers. Users are redirected from a client app to dex to login. Dex ships with an example client app (also built with the `make` command), for testing and demos.
|
||||
|
||||
By default, the example client is configured with the same OAuth2 credentials defined in `examples/config-dev.yaml` to talk to dex. Running the example app will cause it to query dex's [discovery endpoint][oidc-discovery] and determine the OAuth2 endpoints.
|
||||
|
||||
```
|
||||
./bin/example-app
|
||||
```
|
||||
|
||||
Login to dex through the example app using the following steps.
|
||||
|
||||
1. Navigate to the example app in your browser at http://localhost:5555/ in your browser.
|
||||
2. Hit "login" on the example app to be redirected to dex.
|
||||
3. Choose the "Login with Email" and enter "admin@example.com" and "password"
|
||||
4. Approve the example app's request.
|
||||
5. See the resulting token the example app claims from dex.
|
||||
|
||||
## Further reading
|
||||
|
||||
Check out the Documentation directory for further reading on setting up different storages, interacting with the dex API, intros for OpenID Connect, and logging in through other identity providers such as Google, GitHub, or LDAP.
|
||||
|
||||
[go-setup]: https://golang.org/doc/install
|
||||
[example-config]: ../examples/config-dev.yaml
|
||||
[oidc-discovery]: https://openid.net/specs/openid-connect-discovery-1_0-17.html#ProviderMetadata
|
|
@ -1,34 +0,0 @@
|
|||
# Authentication through GitHub
|
||||
|
||||
## Overview
|
||||
|
||||
One of the login options for dex uses the GitHub OAuth2 flow to identify the end user through their GitHub account.
|
||||
|
||||
When a client redeems a refresh token through dex, dex will re-query GitHub to update user information in the ID Token. To do this, __dex stores a readonly GitHub access token in its backing datastore.__ Users that reject dex's access through GitHub will also revoke all dex clients which authenticated them through GitHub.
|
||||
|
||||
## Configuration
|
||||
|
||||
Register a new application with [GitHub][github-oauth2] ensuring the callback URL is `(dex issuer)/callback`. For example if dex is listening at the non-root path `https://auth.example.com/dex` the callback would be `https://auth.example.com/dex/callback`.
|
||||
|
||||
The following is an example of a configuration for `examples/config-dev.yaml`:
|
||||
|
||||
```yaml
|
||||
connectors:
|
||||
- type: github
|
||||
# Required field for connector id.
|
||||
id: github
|
||||
# Required field for connector name.
|
||||
name: GitHub
|
||||
config:
|
||||
# Credentials can be string literals or pulled from the environment.
|
||||
clientID: $GITHUB_CLIENT_ID
|
||||
clientSecret: $GITHUB_CLIENT_SECRET
|
||||
redirectURI: http://127.0.0.1:5556/dex/callback
|
||||
# Optional organization to pull teams from, communicate through the
|
||||
# "groups" scope.
|
||||
#
|
||||
# NOTE: This is an EXPERIMENTAL config option and will likely change.
|
||||
org: my-oranization
|
||||
```
|
||||
|
||||
[github-oauth2]: https://github.com/settings/applications/new
|
|
@ -1,29 +0,0 @@
|
|||
# Authentication through Gitlab
|
||||
|
||||
## Overview
|
||||
|
||||
GitLab is a web-based Git repository manager with wiki and issue tracking features, using an open source license, developed by GitLab Inc. One of the login options for dex uses the GitLab OAuth2 flow to identify the end user through their GitLab account. You can use this option with [gitlab.com](gitlab.com), GitLab community or enterprise edition.
|
||||
|
||||
When a client redeems a refresh token through dex, dex will re-query GitLab to update user information in the ID Token. To do this, __dex stores a readonly GitLab access token in its backing datastore.__ Users that reject dex's access through GitLab will also revoke all dex clients which authenticated them through GitLab.
|
||||
|
||||
## Configuration
|
||||
|
||||
Register a new application via `User Settings -> Applications` ensuring the callback URL is `(dex issuer)/callback`. For example if dex is listening at the non-root path `https://auth.example.com/dex` the callback would be `https://auth.example.com/dex/callback`.
|
||||
|
||||
The following is an example of a configuration for `examples/config-dev.yaml`:
|
||||
|
||||
```yaml
|
||||
connectors:
|
||||
- type: gitlab
|
||||
# Required field for connector id.
|
||||
id: gitlab
|
||||
# Required field for connector name.
|
||||
name: GitLab
|
||||
config:
|
||||
# optional, default = https://www.gitlab.com
|
||||
baseURL: https://www.gitlab.com
|
||||
# Credentials can be string literals or pulled from the environment.
|
||||
clientID: $GITLAB_APPLICATION_ID
|
||||
clientSecret: $GITLAB_CLIENT_SECRET
|
||||
redirectURI: http://127.0.0.1:5556/dex/callback
|
||||
```
|
|
@ -1,6 +0,0 @@
|
|||
# Integrations
|
||||
This document tracks the libraries and tools that are compatible with dex. [Join the community](https://github.com/coreos/dex/), and help us keep the list up-to-date.
|
||||
|
||||
## Tools
|
||||
|
||||
## Projects with a dex dependency
|
|
@ -1,124 +0,0 @@
|
|||
# Kubernetes authentication through dex
|
||||
|
||||
## Overview
|
||||
|
||||
This document covers setting up the [Kubernetes OpenID Connect token authenticator plugin][k8s-oidc] with dex.
|
||||
|
||||
Token responses from OpenID Connect providers include a signed JWT called an ID Token. ID Tokens contain names, emails, unique identifiers, and in dex's case, a set of groups that can be used to identify the user. OpenID Connect providers, like dex, publish public keys; the Kubernetes API server understands how to use these to verify ID Tokens.
|
||||
|
||||
The authentication flow looks like:
|
||||
|
||||
1. OAuth2 client logs a user in through dex.
|
||||
2. That client uses the returned ID Token as a bearer token when talking to the Kubernetes API.
|
||||
3. Kubernetes uses dex's public keys to verify the ID Token.
|
||||
4. A claim designated as the username (and optionally group information) will be associated with that request.
|
||||
|
||||
Username and group information can be combined with Kubernetes [authorization plugins][k8s-authz], such as roles based access control (RBAC), to enforce policy.
|
||||
|
||||
## Configuring the OpenID Connect plugin
|
||||
|
||||
Configuring the API server to use the OpenID Connect [authentication plugin][k8s-oidc] requires:
|
||||
|
||||
* Deploying an API server with specific flags.
|
||||
* Dex is running on HTTPS.
|
||||
* Custom CA files must be accessible by the API server (likely through volume mounts).
|
||||
* Dex is accessible to both your browser and the Kubernetes API server.
|
||||
|
||||
Use the following flags to point your API server(s) at dex. `dex.example.com` should be replaced by whatever DNS name or IP address dex is running under.
|
||||
|
||||
```
|
||||
--oidc-issuer-url=https://dex.example.com:32000
|
||||
--oidc-client-id=example-app
|
||||
--oidc-ca-file=/etc/kubernetes/ssl/openid-ca.pem
|
||||
--oidc-username-claim=email
|
||||
--oidc-groups-claim=groups
|
||||
```
|
||||
|
||||
Additional notes:
|
||||
|
||||
* The API server configured with OpenID Connect flags doesn't require dex to be available upfront.
|
||||
* Other authenticators, such as client certs, can still be used.
|
||||
* Dex doesn't need to be running when you start your API server.
|
||||
* Kubernetes only trusts ID Tokens issued to a single client.
|
||||
* As a work around dex allows clients to [trust other clients][trusted-peers] to mint tokens on their behalf.
|
||||
* If a claim other than "email" is used for username, for example "sub", it will be prefixed by `"(value of --oidc-issuer-url)#"`. This is to namespace user controlled claims which may be used for privilege escalation.
|
||||
|
||||
## Deploying dex on Kubernetes
|
||||
|
||||
The dex repo contains scripts for running dex on a Kubernetes cluster with authentication through GitHub. The dex service is exposed using a [node port][node-port] on port 32000. This likely requires a custom `/etc/hosts` entry pointed at one of the cluster's workers.
|
||||
|
||||
Because dex uses `ThirdPartyResources` to store state, no external database is needed. For more details see the [storage documentation](storage.md#kubernetes-third-party-resources).
|
||||
|
||||
There are many different ways to spin up a Kubernetes development cluster, each with different host requirements and support for API server reconfiguration. At this time, this guide does not have copy-pastable examples, but can recommend the following methods for spinning up a cluster:
|
||||
|
||||
* [coreos-kubernetes][coreos-kubernetes] repo for vagrant and VirtualBox users.
|
||||
* [coreos-baremetal][coreos-baremetal] repo for Linux QEMU/KVM users.
|
||||
|
||||
To run dex on Kubernetes perform the following steps:
|
||||
|
||||
1. Generate TLS assets for dex.
|
||||
2. Spin up a Kubernetes cluster with the appropriate flags and CA volume mount.
|
||||
3. Create a secret containing your [GitHub OAuth2 client credentials][github-oauth2].
|
||||
4. Deploy dex.
|
||||
|
||||
The TLS assets can be created using the following command:
|
||||
|
||||
```
|
||||
$ cd examles/k8s
|
||||
$ ./gencert.sh
|
||||
```
|
||||
|
||||
The created `ssl/ca.pem` must then be mounted into your API server deployment. Once the cluster is up and correctly configured, use kubectl to add the serving certs as secrets.
|
||||
|
||||
```
|
||||
$ kubectl create secret tls dex.example.com.tls --cert=ssl/cert.pem --key=ssl/key.pem
|
||||
```
|
||||
|
||||
Then create a secret for the GitHub OAuth2 client.
|
||||
|
||||
```
|
||||
$ kubectl create secret \
|
||||
generic github-client \
|
||||
--from-literal=client-id=$GITHUB_CLIENT_ID \
|
||||
--from-literal=client-secret=$GITHUB_CLIENT_SECRET
|
||||
```
|
||||
|
||||
Finally, create the dex deployment, configmap, and node port service.
|
||||
|
||||
```
|
||||
$ kubectl create -f dex.yaml
|
||||
```
|
||||
|
||||
__Caveats:__ No health checking is configured because dex does its own TLS termination complicating the setup. This is a known issue and can be tracked [here][dex-healthz].
|
||||
|
||||
## Logging into the cluster
|
||||
|
||||
The `example-app` can be used to log into the cluster and get an ID Token. To build the app, you can run `make` in the root of the repo and it will build the `example-app` binary in the repo's `bin` directory. To build the `example-app` requires at least a 1.7 version of Go.
|
||||
|
||||
```
|
||||
$ ./bin/example-app --issuer https://dex.example.com:32000 --issuer-root-ca examples/k8s/ssl/ca.pem
|
||||
```
|
||||
|
||||
Please note that the `example-app` will listen at http://127.0.0.1:5555 and can be changed with the `--listen` flag.
|
||||
|
||||
Once the example app is running, choose the GitHub option and grant access to dex to view your profile.
|
||||
|
||||
The default redirect uri is http://127.0.0.1:5555/callback and can be changed with the `--redirect-uri` flag and should correspond with your configmap.
|
||||
|
||||
The printed ID Token can then be used as a bearer token to authenticate against the API server.
|
||||
|
||||
```
|
||||
$ token='(id token)'
|
||||
$ curl -H "Authorization: Bearer $token" -k https://( API server host ):443/api/v1/nodes
|
||||
```
|
||||
|
||||
[k8s-authz]: http://kubernetes.io/docs/admin/authorization/
|
||||
[k8s-oidc]: http://kubernetes.io/docs/admin/authentication/#openid-connect-tokens
|
||||
[trusted-peers]: https://godoc.org/github.com/coreos/dex/storage#Client
|
||||
[coreos-kubernetes]: https://github.com/coreos/coreos-kubernetes/
|
||||
[coreos-baremetal]: https://github.com/coreos/coreos-baremetal/
|
||||
[dex-healthz]: https://github.com/coreos/dex/issues/682
|
||||
[github-oauth2]: https://github.com/settings/applications/new
|
||||
[node-port]: http://kubernetes.io/docs/user-guide/services/#type-nodeport
|
||||
[coreos-kubernetes]: https://github.com/coreos/coreos-kubernetes
|
||||
[coreos-baremetal]: https://github.com/coreos/coreos-baremetal
|
|
@ -1,218 +0,0 @@
|
|||
# Authentication through LDAP
|
||||
|
||||
## Overview
|
||||
|
||||
The LDAP connector allows email/password based authentication, backed by a LDAP directory.
|
||||
|
||||
The connector executes two primary queries:
|
||||
|
||||
1. Finding the user based on the end user's credentials.
|
||||
2. Searching for groups using the user entry.
|
||||
|
||||
## Security considerations
|
||||
|
||||
Dex attempts to bind with the backing LDAP server using the end user's _plain text password_. Though some LDAP implementations allow passing hashed passwords, dex doesn't support hashing and instead _strongly recommends that all administrators just use TLS_. This can often be achieved by using port 636 instead of 389, and administrators that choose 389 are actively leaking passwords.
|
||||
|
||||
Dex currently allows insecure connections because the project is still verifying that dex works with the wide variety of LDAP implementations. However, dex may remove this transport option, and _users who configure LDAP login using 389 are not covered by any compatibility guarantees with future releases._
|
||||
|
||||
## Configuration
|
||||
|
||||
User entries are expected to have an email attribute (configurable through `emailAttr`), and a display name attribute (configurable through `nameAttr`). `*Attr` attributes could be set to "DN" in situations where it is needed but not available elsewhere, and if "DN" attribute does not exist in the record.
|
||||
|
||||
The following is an example config file that can be used by the LDAP connector to authenticate a user.
|
||||
|
||||
```yaml
|
||||
connectors:
|
||||
- type: ldap
|
||||
# Required field for connector id.
|
||||
id: ldap
|
||||
# Required field for connector name.
|
||||
name: LDAP
|
||||
config:
|
||||
# Host and optional port of the LDAP server in the form "host:port".
|
||||
# If the port is not supplied, it will be guessed based on "insecureNoSSL".
|
||||
# 389 for insecure connections, 636 otherwise.
|
||||
host: ldap.example.com:636
|
||||
|
||||
# Following field is required if the LDAP host is not using TLS (port 389).
|
||||
# Because this option inherently leaks passwords to anyone on the same network
|
||||
# as dex, THIS OPTION MAY BE REMOVED WITHOUT WARNING IN A FUTURE RELEASE.
|
||||
# insecureNoSSL: true
|
||||
|
||||
# If a custom certificate isn't provide, this option can be used to turn on
|
||||
# TLS certificate checks. As noted, it is insecure and shouldn't be used outside
|
||||
# of explorative phases.
|
||||
# insecureSkipVerify: true
|
||||
|
||||
# Path to a trusted root certificate file. Default: use the host's root CA.
|
||||
rootCA: /etc/dex/ldap.ca
|
||||
|
||||
# A raw certificate file can also be provided inline.
|
||||
# rootCAData: ( base64 encoded PEM file )
|
||||
|
||||
# The DN and password for an application service account. The connector uses
|
||||
# these credentials to search for users and groups. Not required if the LDAP
|
||||
# server provides access for anonymous auth.
|
||||
bindDN: uid=seviceaccount,cn=users,dc=example,dc=com
|
||||
bindPW: password
|
||||
|
||||
# User search maps a username and password entered by a user to a LDAP entry.
|
||||
userSearch:
|
||||
# BaseDN to start the search from. It will translate to the query
|
||||
# "(&(objectClass=person)(uid=<username>))".
|
||||
baseDN: cn=users,dc=example,dc=com
|
||||
# Optional filter to apply when searching the directory.
|
||||
filter: "(objectClass=person)"
|
||||
|
||||
# username attribute used for comparing user entries. This will be translated
|
||||
# and combined with the other filter as "(<attr>=<username>)".
|
||||
username: uid
|
||||
# The following three fields are direct mappings of attributes on the user entry.
|
||||
# String representation of the user.
|
||||
idAttr: uid
|
||||
# Required. Attribute to map to Email.
|
||||
emailAttr: mail
|
||||
# Maps to display name of users. No default value.
|
||||
nameAttr: name
|
||||
|
||||
# Group search queries for groups given a user entry.
|
||||
groupSearch:
|
||||
# BaseDN to start the search from. It will translate to the query
|
||||
# "(&(objectClass=group)(member=<user uid>))".
|
||||
baseDN: cn=groups,dc=freeipa,dc=example,dc=com
|
||||
# Optional filter to apply when searching the directory.
|
||||
filter: "(objectClass=group)"
|
||||
|
||||
# Following two fields are used to match a user to a group. It adds an additional
|
||||
# requirement to the filter that an attribute in the group must match the user's
|
||||
# attribute value.
|
||||
userAttr: uid
|
||||
groupAttr: member
|
||||
|
||||
# Represents group name.
|
||||
nameAttr: name
|
||||
```
|
||||
|
||||
The LDAP connector first initializes a connection to the LDAP directory using the `bindDN` and `bindPW`. It then tries to search for the given `username` and bind as that user to verify their password.
|
||||
Searches that return multiple entries are considered ambiguous and will return an error.
|
||||
|
||||
## Example: Mapping a schema to a search config
|
||||
|
||||
Writing a search configuration often involves mapping an existing LDAP schema to the various options dex provides. To query an existing LDAP schema install the OpenLDAP tool `ldapsearch`. For `rpm` based distros run:
|
||||
|
||||
```
|
||||
sudo dnf install openldap-clients
|
||||
```
|
||||
|
||||
For `apt-get`:
|
||||
|
||||
```
|
||||
sudo apt-get install ldap-utils
|
||||
```
|
||||
|
||||
For smaller user directories it may be practical to dump the entire contents and search by hand.
|
||||
|
||||
```
|
||||
ldapsearch -x -h ldap.example.org -b 'dc=example,dc=org' | less
|
||||
```
|
||||
|
||||
First, find a user entry. User entries declare users who can login to LDAP connector using username and password.
|
||||
|
||||
```
|
||||
dn: uid=jdoe,cn=users,cn=compat,dc=example,dc=org
|
||||
cn: Jane Doe
|
||||
objectClass: posixAccount
|
||||
objectClass: ipaOverrideTarget
|
||||
objectClass: top
|
||||
gidNumber: 200015
|
||||
gecos: Jane Doe
|
||||
uidNumber: 200015
|
||||
loginShell: /bin/bash
|
||||
homeDirectory: /home/jdoe
|
||||
mail: jane.doe@example.com
|
||||
uid: janedoe
|
||||
```
|
||||
|
||||
Compose a user search which returns this user.
|
||||
|
||||
```yaml
|
||||
userSearch:
|
||||
# The directory directly above the user entry.
|
||||
baseDN: cn=users,cn=compat,dc=example,dc=org
|
||||
filter: "(objectClass=posixAccount)"
|
||||
|
||||
# Expect user to enter "janedoe" when logging in.
|
||||
username: uid
|
||||
|
||||
# Use the full DN as an ID.
|
||||
idAttr: DN
|
||||
|
||||
# When an email address is not available, use another value unique to the user, like uid.
|
||||
emailAttr: mail
|
||||
nameAttr: gecos
|
||||
```
|
||||
|
||||
Second, find a group entry.
|
||||
|
||||
```
|
||||
dn: cn=developers,cn=groups,cn=compat,dc=example,dc=org
|
||||
memberUid: janedoe
|
||||
memberUid: johndoe
|
||||
gidNumber: 200115
|
||||
objectClass: posixGroup
|
||||
objectClass: ipaOverrideTarget
|
||||
objectClass: top
|
||||
cn: developers
|
||||
```
|
||||
|
||||
Group searches must match a user attribute to a group attribute. In this example, the search returns users whose uid is found in the group's list of memberUid attributes.
|
||||
|
||||
```yaml
|
||||
groupSearch:
|
||||
# The directory directly above the group entry.
|
||||
baseDN: cn=groups,cn=compat,dc=example,dc=org
|
||||
filter: "(objectClass=posixGroup)"
|
||||
|
||||
# The group search needs to match the "uid" attribute on
|
||||
# the user with the "memberUid" attribute on the group.
|
||||
userAttr: uid
|
||||
groupAttr: memberUid
|
||||
|
||||
# Unique name of the group.
|
||||
nameAttr: cn
|
||||
```
|
||||
|
||||
## Example: Searching a FreeIPA server with groups
|
||||
|
||||
The following configuration will allow the LDAP connector to search a FreeIPA directory using an LDAP filter.
|
||||
|
||||
```yaml
|
||||
|
||||
connectors:
|
||||
- type: ldap
|
||||
id: ldap
|
||||
name: LDAP
|
||||
config:
|
||||
# host and port of the LDAP server in form "host:port".
|
||||
host: freeipa.example.com:636
|
||||
# freeIPA server's CA
|
||||
rootCA: ca.crt
|
||||
userSearch:
|
||||
# Would translate to the query "(&(objectClass=person)(uid=<username>))".
|
||||
baseDN: cn=users,dc=freeipa,dc=example,dc=com
|
||||
filter: "(objectClass=posixAccount)"
|
||||
username: uid
|
||||
idAttr: uid
|
||||
# Required. Attribute to map to Email.
|
||||
emailAttr: mail
|
||||
# Entity attribute to map to display name of users.
|
||||
groupSearch:
|
||||
# Would translate to the query "(&(objectClass=group)(member=<user uid>))".
|
||||
baseDN: cn=groups,dc=freeipa,dc=example,dc=com
|
||||
filter: "(objectClass=group)"
|
||||
userAttr: uid
|
||||
groupAttr: member
|
||||
nameAttr: name
|
||||
```
|
||||
|
||||
If the search finds an entry, it will attempt to use the provided password to bind as that user entry.
|
|
@ -1,49 +0,0 @@
|
|||
# Authentication through an OpenID Connect provider
|
||||
|
||||
## Overview
|
||||
|
||||
Dex is able to use another OpenID Connect provider as an authentication source. When logging in, dex will redirect to the upstream provider and perform the necessary OAuth2 flows to determine the end users email, username, etc. More details on the OpenID Connect protocol can be found in [_An overview of OpenID Connect_][oidc-doc].
|
||||
|
||||
Prominent examples of OpenID Connect providers include Google Accounts, Salesforce, and Azure AD v2 ([not v1][azure-ad-v1]).
|
||||
|
||||
## Caveats
|
||||
|
||||
Many OpenID Connect providers implement different restrictions on refresh tokens. For example, Google will only issue the first login attempt a refresh token, then not return one after. Because of this, this connector does not refresh the id_token claims when a client of dex redeems a refresh token, which can result in stale user info.
|
||||
|
||||
It's generally recommended to avoid using refresh tokens with the `oidc` connector.
|
||||
|
||||
Progress on this caveat can be tracked in [issue #863][google-refreshing].
|
||||
|
||||
## Configuration
|
||||
|
||||
```yaml
|
||||
connectors:
|
||||
- type: oidc
|
||||
id: google
|
||||
name: Google
|
||||
config:
|
||||
# Canonical URL of the provider, also used for configuration discovery.
|
||||
# This value MUST match the value returned in the provider config discovery.
|
||||
#
|
||||
# See: https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig
|
||||
issuer: https://accounts.google.com
|
||||
|
||||
# Connector config values starting with a "$" will read from the environment.
|
||||
clientID: $GOOGLE_CLIENT_ID
|
||||
clientSecret: $GOOGLE_CLIENT_SECRET
|
||||
|
||||
# Dex's issuer URL + "/callback"
|
||||
redirectURI: http://127.0.0.1:5556/callback
|
||||
|
||||
|
||||
# Some providers require passing client_secret via POST parameters instead
|
||||
# of basic auth, despite the OAuth2 RFC discouraging it. Many of these
|
||||
# cases are caught internally, but some may need to uncommented the
|
||||
# following field.
|
||||
#
|
||||
# basicAuthUnsupported: true
|
||||
```
|
||||
|
||||
[oidc-doc]: openid-connect.md
|
||||
[google-refreshing]: https://github.com/coreos/dex/issues/863
|
||||
[azure-ad-v1]: https://github.com/coreos/go-oidc/issues/133
|
|
@ -1,140 +0,0 @@
|
|||
# An overview of OpenID Connect
|
||||
|
||||
This document attempts to provide an overview of the [OpenID Connect protocol](
|
||||
https://openid.net/connect/), a flavor of OAuth2 that dex implements. While
|
||||
this document isn't complete we hope it provides a enough to get users up and
|
||||
running.
|
||||
|
||||
## OAuth2
|
||||
|
||||
OAuth2 should be familiar to anyone who's used something similar to a "Login
|
||||
with Facebook" button. In these cases an application has chosen to let an
|
||||
outside provider, in this case Facebook, attest to your identity instead of
|
||||
having you set a username and password with the app itself.
|
||||
|
||||
The general flow for server side apps is:
|
||||
|
||||
1. A new user visits an application.
|
||||
1. The application redirects the user to Facebook.
|
||||
1. The user logs into Facebook, then is asked if it's okay to let the
|
||||
application view the user's profile, post on their behalf, etc.
|
||||
1. If the user clicks okay, Facebook redirects the user back to the application
|
||||
with a code.
|
||||
1. The application redeems that code with provider for a token that can be used
|
||||
to access the authorized actions, such as viewing a users profile or posting on
|
||||
their wall.
|
||||
|
||||
In these cases, dex is acting as Facebook (called the "provider" in OpenID
|
||||
Connect) while clients apps redirect to it for the end user's identity.
|
||||
|
||||
## ID Tokens
|
||||
|
||||
Unfortunately the access token applications get from OAuth2 providers is
|
||||
completely opaque to the client and unique to the provider. The token you
|
||||
receive from Facebook will be completely different from the one you'd get from
|
||||
Twitter or GitHub.
|
||||
|
||||
OpenID Connect's primary extension of OAuth2 is an additional token returned in
|
||||
the token response called the ID Token. This token is a [JSON Web Token](
|
||||
https://tools.ietf.org/html/rfc7519) signed by the OpenID Connect server, with
|
||||
well known fields for user ID, name, email, etc. A typical token response from
|
||||
an OpenID Connect looks like (with less whitespace):
|
||||
|
||||
```
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json
|
||||
Cache-Control: no-store
|
||||
Pragma: no-cache
|
||||
|
||||
{
|
||||
"access_token": "SlAV32hkKG",
|
||||
"token_type": "Bearer",
|
||||
"refresh_token": "8xLOxBtZp8",
|
||||
"expires_in": 3600,
|
||||
"id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjFlOWdkazcifQ.ewogImlzc
|
||||
yI6ICJodHRwOi8vc2VydmVyLmV4YW1wbGUuY29tIiwKICJzdWIiOiAiMjQ4Mjg5
|
||||
NzYxMDAxIiwKICJhdWQiOiAiczZCaGRSa3F0MyIsCiAibm9uY2UiOiAibi0wUzZ
|
||||
fV3pBMk1qIiwKICJleHAiOiAxMzExMjgxOTcwLAogImlhdCI6IDEzMTEyODA5Nz
|
||||
AKfQ.ggW8hZ1EuVLuxNuuIJKX_V8a_OMXzR0EHR9R6jgdqrOOF4daGU96Sr_P6q
|
||||
Jp6IcmD3HP99Obi1PRs-cwh3LO-p146waJ8IhehcwL7F09JdijmBqkvPeB2T9CJ
|
||||
NqeGpe-gccMg4vfKjkM8FcGvnzZUN4_KSP0aAp1tOJ1zZwgjxqGByKHiOtX7Tpd
|
||||
QyHE5lcMiKPXfEIQILVq0pc_E2DzL7emopWoaoZTF_m0_N0YzFC6g6EJbOEoRoS
|
||||
K5hoDalrcvRYLSrQAZZKflyuVCyixEoV9GfNQC3_osjzw2PAithfubEEBLuVVk4
|
||||
XUVrWOLrLl0nx7RkKU8NXNHq-rvKMzqg"
|
||||
}
|
||||
```
|
||||
|
||||
That ID Token is a JWT with three base64'd fields separated by dots. The first
|
||||
is a header, the second is a payload, and the third is a signature of the first
|
||||
two fields. When parsed we can see the payload of this value is.
|
||||
|
||||
```
|
||||
{
|
||||
"iss": "http://server.example.com",
|
||||
"sub": "248289761001",
|
||||
"aud": "s6BhdRkqt3",
|
||||
"nonce": "n-0S6_WzA2Mj",
|
||||
"exp": 1311281970,
|
||||
"iat": 1311280970
|
||||
}
|
||||
```
|
||||
|
||||
This has a few interesting fields such as
|
||||
|
||||
* The server that issued this token (`iss`).
|
||||
* The token's subject (`sub`). In this case a unique ID of the end user.
|
||||
* The token's audience (`aud`). The ID of the OAuth2 client this was issued for.
|
||||
|
||||
TODO: Add examples of payloads with "email" fields.
|
||||
|
||||
## Discovery
|
||||
|
||||
OpenID Connect servers have a discovery mechanism for OAuth2 endpoints, scopes
|
||||
supported, and indications of various other OpenID Connect features.
|
||||
|
||||
```
|
||||
$ curl http://127.0.0.1:5556/.well-known/openid-configuration
|
||||
{
|
||||
"issuer": "http://127.0.0.1:5556",
|
||||
"authorization_endpoint": "http://127.0.0.1:5556/auth",
|
||||
"token_endpoint": "http://127.0.0.1:5556/token",
|
||||
"jwks_uri": "http://127.0.0.1:5556/keys",
|
||||
"response_types_supported": [
|
||||
"code"
|
||||
],
|
||||
"subject_types_supported": [
|
||||
"public"
|
||||
],
|
||||
"id_token_signing_alg_values_supported": [
|
||||
"RS256"
|
||||
],
|
||||
"scopes_supported": [
|
||||
"openid",
|
||||
"email",
|
||||
"profile"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Importantly, we've discovered the authorization endpoint, token endpoint, and
|
||||
the location of the server's public keys. OAuth2 clients should be able to use
|
||||
the token and auth endpoints immediately, while a JOSE library can be used to
|
||||
parse the keys. The keys endpoint returns a [JSON Web Key](
|
||||
https://tools.ietf.org/html/rfc7517) Set of public keys that will look
|
||||
something like this:
|
||||
|
||||
```
|
||||
$ curl http://127.0.0.1:5556/keys
|
||||
{
|
||||
"keys": [
|
||||
{
|
||||
"use": "sig",
|
||||
"kty": "RSA",
|
||||
"kid": "5d19a0fde5547960f4edaa1e1e8293e5534169ba",
|
||||
"alg": "RS256",
|
||||
"n": "5TAXCxkAQqHEqO0InP81z5F59PUzCe5ZNaDsD1SXzFe54BtXKn_V2a3K-BUNVliqMKhC2LByWLuI-A5ZlA5kXkbRFT05G0rusiM0rbkN2uvRmRCia4QlywE02xJKzeZV3KH6PldYqV_Jd06q1NV3WNqtcHN6MhnwRBfvkEIm7qWdPZ_mVK7vayfEnOCFRa7EZqr-U_X84T0-50wWkHTa0AfnyVvSMK1eKL-4yc26OWkmjh5ALfQFtnsz30Y2TOJdXtEfn35Y_882dNBDYBxtJV4PaSjXCxhiaIuBHp5uRS1INyMXCx2ve22ASNx_ERorv6BlXQoMDqaML2bSiN9N8Q",
|
||||
"e": "AQAB"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
|
@ -1,3 +0,0 @@
|
|||
# Production users
|
||||
|
||||
This document tracks people and use cases for dex in production. [Join the community](https://github.com/coreos/dex/), and help us keep the list up-to-date.
|
|
@ -1,82 +0,0 @@
|
|||
# Proposal: design for revoking refresh tokens.
|
||||
|
||||
Refresh tokens are issued to the client by the authorization server and are used
|
||||
to request a new access token when the current access token becomes invalid or expires.
|
||||
It is a common usecase for the end users to revoke client access to their identity.
|
||||
This proposal defines the changes needed in Dex v2 to support refresh token revocation.
|
||||
|
||||
## Motivation
|
||||
|
||||
1. Currently refresh tokens are not associated with the user. Need a new "session object" for this.
|
||||
2. Need an API to list refresh tokens based on the UserID.
|
||||
3. We need a way for users to login to dex and revoke a client.
|
||||
4. Limit the number refresh tokens for each user-client pair to 1.
|
||||
|
||||
## Details
|
||||
|
||||
Currently in Dex when an end user successfully logs in via a connector and has the OfflineAccess
|
||||
scope set to true, a refresh token is created and stored in the backing datastore. There is no
|
||||
association between the end user and the refresh token. Hence if we want to support the functionality
|
||||
of users being able to revoke refresh tokens, the first step is to have a structure in place that allows
|
||||
us retrieve a list of refresh tokens depending on the authenticated user.
|
||||
|
||||
```go
|
||||
// Reference object for RefreshToken containing only metadata.
|
||||
type RefreshTokenRef struct {
|
||||
// ID of the RefreshToken
|
||||
ID string
|
||||
CreatedAt time.Time
|
||||
LastUsed time.Time
|
||||
}
|
||||
|
||||
// Session objects pertaining to users with refresh tokens.
|
||||
//
|
||||
// Will have to handle garbage collection i.e. if no refresh token exists for a user,
|
||||
// this object must be cleaned up.
|
||||
type OfflineSession struct {
|
||||
// UserID of an end user who has logged in to the server.
|
||||
UserID string
|
||||
// The ID of the connector used to login the user.
|
||||
ConnID string
|
||||
// List of pointers to RefreshTokens issued for SessionID
|
||||
Refresh []*RefreshTokenRef
|
||||
}
|
||||
|
||||
// Retrieve OfflineSession obj for given userId and connID
|
||||
func getOfflineSession (userId string, connID string)
|
||||
|
||||
```
|
||||
|
||||
### Changes in Dex CodeFlows
|
||||
|
||||
1. Client requests a refresh token:
|
||||
Try to retrieve the `OfflineSession` object for the User with the given `UserID + ConnID`.
|
||||
This leads to two possibilities:
|
||||
* Object exists: This means a Refresh token already exists for the user.
|
||||
Update the existing `OffilineSession` object with the newly received token as follows:
|
||||
* CreateRefresh() will create a new `RefreshToken` obj in the storage.
|
||||
* Update the `Refresh` list with the new `RefreshToken` pointer.
|
||||
* Delete the old refresh token in storage.
|
||||
|
||||
* No object found: This implies that this will be the first refresh token for the user.
|
||||
* CreateRefresh() will create a new `RefreshToken` obj in the storage.
|
||||
* Create an OfflineSession for the user and add the new `RefreshToken` pointer to
|
||||
the `Refresh` list.
|
||||
|
||||
2. Refresh token rotation:
|
||||
There will be no change to this codeflow. When the client refreshes a refresh token, the `TokenID`
|
||||
still remains intact and only the `RefreshToken` obj gets updated with a new nonce. We do not need
|
||||
any additional checks in the OfflineSession objects as the `RefreshToken` pointers still remain intact.
|
||||
|
||||
3. User revokes a refresh token (New functionality):
|
||||
A user that has been authenticated externally will have the ability to revoke their refresh tokens.
|
||||
Please note that Dex's API does not perform the authentication, this will have to be done by an
|
||||
external app.
|
||||
Steps involved:
|
||||
* Get `OfflineSession` obj with given UserID + ConnID.
|
||||
* If a refresh token exists in `Refresh`, delete the `RefreshToken` (handle this in storage)
|
||||
and its pointer value in `Refresh`. Clean up the OfflineSession object.
|
||||
* If there is no refresh token found, handle error case.
|
||||
|
||||
NOTE: To avoid race conditions between “requesting a refresh token” and “revoking a refresh token”, use
|
||||
locking mechanism when updating an `OfflineSession` object.
|
|
@ -1,165 +0,0 @@
|
|||
# Proposal: upstream refreshing
|
||||
|
||||
## TL;DR
|
||||
|
||||
Today, if a user deletes their GitHub account, dex will keep allowing clients to
|
||||
refresh tokens on that user's behalf because dex never checks back in with
|
||||
GitHub.
|
||||
|
||||
This is a proposal to change the connector package so the dex can check back
|
||||
in with GitHub.
|
||||
|
||||
## The problem
|
||||
|
||||
When dex is federaing to an upstream identity provider (IDP), we want to ensure
|
||||
claims being passed onto clients remain fresh. This includes data such as Google
|
||||
accounts display names, LDAP group membership, account deactivations. Changes to
|
||||
these on an upstream IDP should always be reflected in the claims dex passes to
|
||||
its own clients.
|
||||
|
||||
Refresh tokens make this complicated. When refreshing a token, unlike normal
|
||||
logins, dex doesn't have the opportunity to prompt for user interaction. For
|
||||
example, if dex is proxying to a LDAP server, it won't have the user's username
|
||||
and passwords.
|
||||
|
||||
Dex can't do this today because connectors have no concept of checking back in
|
||||
with an upstream provider (with the sole exception of groups). They're only
|
||||
called during the initial login, and never consulted when dex needs to mint a
|
||||
new refresh token for a client. Additionally, connectors aren't actually aware
|
||||
of the scopes being requested by the client, so they don't know when they should
|
||||
setup the ability to check back in and have to treat every request identically.
|
||||
|
||||
## Changes to the connector package
|
||||
|
||||
The biggest changes proposed impact the connector package and connector
|
||||
implementations.
|
||||
|
||||
1. Connectors should be consulted when dex attempts to refresh a token.
|
||||
2. Connectors should be aware of the scopes requested by the client.
|
||||
|
||||
The second bullet is important because of the first. If a client isn't
|
||||
requesting a refresh token, the connector shouldn't do the extra work, such as
|
||||
requesting additional upstream scopes.
|
||||
|
||||
to address the first point, a top level `Scopes` object will be added to the
|
||||
connector package to express the scopes requested by the client. The
|
||||
`CallbackConnector` and `PasswordConnector` will be updated accordingly.
|
||||
|
||||
```go
|
||||
// Scopes represents additional data requested by the clients about the end user.
|
||||
type Scopes struct{
|
||||
// The client has requested a refresh token from the server.
|
||||
OfflineAccess bool
|
||||
|
||||
// The client has requested group information about the end user.
|
||||
Groups bool
|
||||
}
|
||||
|
||||
// CallbackConnector is an interface implemented by connectors which use an OAuth
|
||||
// style redirect flow to determine user information.
|
||||
type CallbackConnector interface {
|
||||
// The initial URL to redirect the user to.
|
||||
//
|
||||
// OAuth2 implementations should request different scopes from the upstream
|
||||
// identity provider based on the scopes requested by the downstream client.
|
||||
// For example, if the downstream client requests a refresh token from the
|
||||
// server, the connector should also request a token from the provider.
|
||||
LoginURL(s Scopes, callbackURL, state string) (string, error)
|
||||
|
||||
// Handle the callback to the server and return an identity.
|
||||
HandleCallback(s Scopes, r *http.Request) (identity Identity, state string, err error)
|
||||
}
|
||||
|
||||
// PasswordConnector is an interface implemented by connectors which take a
|
||||
// username and password.
|
||||
type PasswordConnector interface {
|
||||
Login(s Scopes, username, password string) (identity Identity, validPassword bool, err error)
|
||||
}
|
||||
```
|
||||
|
||||
The existing `GroupsConnector` plays two roles.
|
||||
|
||||
1. The connector only attempts to grab groups when the downstream client requests it.
|
||||
2. Allow group information to be refreshed.
|
||||
|
||||
The first issue is remedied by the added `Scopes` struct. This proposal also
|
||||
hopes to generalize the need of the second role by adding a more general
|
||||
`RefreshConnector`:
|
||||
|
||||
```go
|
||||
type Identity struct {
|
||||
// Existing fields...
|
||||
|
||||
// Groups are added to the identity object, since connectors are now told
|
||||
// if they're being requested.
|
||||
|
||||
// The set of groups a user is a member of.
|
||||
Groups []string
|
||||
}
|
||||
|
||||
|
||||
// RefreshConnector is a connector that can update the client claims.
|
||||
type RefreshConnector interface {
|
||||
// Refresh is called when a client attempts to claim a refresh token. The
|
||||
// connector should attempt to update the identity object to reflect any
|
||||
// changes since the token was last refreshed.
|
||||
Refresh(s Scopes, identity Identity) (Identity, error)
|
||||
|
||||
// TODO(ericchiang): Should we allow connectors to indicate that the user has
|
||||
// been delete or an upstream token has been revoked? This would allow us to
|
||||
// know when we should remove the downstream refresh token, and when there was
|
||||
// just a server error, but might be hard to determine for certain protocols.
|
||||
// Might be safer to always delete the downstream token if the Refresh()
|
||||
// method returns an error.
|
||||
}
|
||||
```
|
||||
|
||||
## Example changes to the "passwordDB" connector
|
||||
|
||||
The `passwordDB` connector is the internal connector maintained by the server.
|
||||
As an example, these are the changes to that connector if this change was
|
||||
accepted.
|
||||
|
||||
```go
|
||||
func (db passwordDB) Login(s connector.Scopes, username, password string) (connector.Identity, bool, error) {
|
||||
// No change to existing implementation. Scopes can be ignored since we'll
|
||||
// always have access to the password objects.
|
||||
|
||||
}
|
||||
|
||||
func (db passwordDB) Refresh(s connector.Scopes, identity connector.Identity) (connector.Identity, error) {
|
||||
// If the user has been deleted, the refresh token will be rejected.
|
||||
p, err := db.s.GetPassword(identity.Email)
|
||||
if err != nil {
|
||||
if err == storage.ErrNotFound {
|
||||
return connector.Identity{}, errors.New("user not found")
|
||||
}
|
||||
return connector.Identity{}, fmt.Errorf("get password: %v", err)
|
||||
}
|
||||
|
||||
// User removed but a new user with the same email exists.
|
||||
if p.UserID != identity.UserID {
|
||||
return connector.Identity{}, errors.New("user not found")
|
||||
}
|
||||
|
||||
// If a user has updated their username, that will be reflected in the
|
||||
// refreshed token.
|
||||
identity.Username = p.Username
|
||||
return identity, nil
|
||||
}
|
||||
```
|
||||
|
||||
## Caveats
|
||||
|
||||
Certain providers, such as Google, will only grant a single refresh token for each
|
||||
client + end user pair. The second time one's requested, no refresh token is
|
||||
returned. This means refresh tokens must be stored by dex as objects on an
|
||||
upstream identity rather than part of a downstream refresh even.
|
||||
|
||||
Right now `ConnectorData` is too general for this since it is only stored with a
|
||||
refresh token and can't be shared between sessions. This should be rethought in
|
||||
combination with the [`user-object.md`](./user-object.md) proposal to see if
|
||||
there are reasonable ways for us to do this.
|
||||
|
||||
This isn't a problem for providers like GitHub because they return the same
|
||||
refresh token every time. We don't need to track a token per client.
|
|
@ -1,146 +0,0 @@
|
|||
# Proposal: user objects for revoking refresh tokens and merging accounts
|
||||
|
||||
Certain operations require tracking users the have logged in through the server
|
||||
and storing them in the backend. Namely, allowing end users to revoke refresh
|
||||
tokens and merging existing accounts with upstream providers.
|
||||
|
||||
While revoking refresh tokens is relatively easy, merging accounts is a
|
||||
difficult problem. What if display names or emails are different? What happens
|
||||
to a user with two remote identities with the same upstream service? Should
|
||||
this be presented differently for a user with remote identities for different
|
||||
upstream services? This proposal only covers a minimal merging implementation
|
||||
by guaranteeing that merged accounts will always be presented to clients with
|
||||
the same user ID.
|
||||
|
||||
This proposal defines the following objects and methods to be added to the
|
||||
storage package to allow user information to be persisted.
|
||||
|
||||
```go
|
||||
// User is an end user which has logged in to the server.
|
||||
//
|
||||
// Users do not hold additional data, such as emails, because claim information
|
||||
// is always supplied by an upstream provider during the auth flow. The ID is
|
||||
// the only information from this object which overrides the claims produced by
|
||||
// connectors.
|
||||
//
|
||||
// Clients which wish to associate additional data with a user must do so on
|
||||
// their own. The server only guarantees that IDs will be constant for an end
|
||||
// user, no matter what backend they use to login.
|
||||
type User struct {
|
||||
// A string which uniquely identifies the user for the server. This overrides
|
||||
// the ID provided by the connector in the ID Token claims.
|
||||
ID string
|
||||
|
||||
// A list of clients who have been issued refresh tokens for this user.
|
||||
//
|
||||
// When a refresh token is redeemed, the server will check this field to
|
||||
// ensure that the client is still on this list. To revoke a client,
|
||||
// remove it from here.
|
||||
AuthorizedClients []AuthorizedClient
|
||||
|
||||
// A set of remote identities which are able to login as this user.
|
||||
RemoteIdentities []RemoteIdentity
|
||||
}
|
||||
|
||||
// AuthorizedClient is a client that has a refresh token out for this user.
|
||||
type AuthorizedClient struct {
|
||||
// The ID of the client.
|
||||
ClientID string
|
||||
// The last time a token was refreshed.
|
||||
LastRefreshed time.Time
|
||||
}
|
||||
|
||||
// RemoteIdentity is the smallest amount of information that identifies a user
|
||||
// with a remote service. It indicates which remote identities should be able
|
||||
// to login as a specific user.
|
||||
//
|
||||
// RemoteIdentity contains an username so an end user can be displayed this
|
||||
// object and reason about what upstream profile it represents. It is not used
|
||||
// to cache claims, such as groups or emails, because these are always provided
|
||||
// by the upstream identity system during login.
|
||||
type RemoteIdentity struct {
|
||||
// The ID of the connector used to login the user.
|
||||
ConnectorID string
|
||||
// A string which uniquely identifies the user with the remote system.
|
||||
ConnectorUserID stirng
|
||||
|
||||
// Optional, human readable name for this remote identity. Only used when
|
||||
// displaying the remote identity to the end user (e.g. when merging
|
||||
// accounts). NOT used for determining ID Token claims.
|
||||
Username string
|
||||
}
|
||||
```
|
||||
|
||||
`UserID` fields will be added to the `AuthRequest`, `AuthCode` and `RefreshToken`
|
||||
structs. When a user logs in successfully through a connector
|
||||
[here](https://github.com/coreos/dex/blob/95a61454b522edd6643ced36b9d4b9baa8059556/server/handlers.go#L227),
|
||||
the server will attempt to either get the user, or create one if none exists with
|
||||
the remote identity.
|
||||
|
||||
`AuthorizedClients` serves two roles. First is makes displaying the set of
|
||||
clients a user is logged into easy. Second, because we don't assume multi-object
|
||||
transactions, we can't ensure deleting all refresh tokens a client has for a
|
||||
user. Between listing the set of refresh tokens and deleting a token, a client
|
||||
may have already redeemed the token and created a new one.
|
||||
|
||||
When an OAuth2 client exchanges a code for a token, the following steps are
|
||||
taken to populate the `AuthorizedClients`:
|
||||
|
||||
1. Get token where the user has authorized the `offline_access` scope.
|
||||
1. Update the user checking authorized clients. If client is not in the list,
|
||||
add it.
|
||||
1. Create a refresh token and return the token.
|
||||
|
||||
When a OAuth2 client attempts to renew a refresh token, the server ensures that
|
||||
the token hasn't been revoked.
|
||||
|
||||
1. Check authorized clients and update the `LastRefreshed` timestamp. If client
|
||||
isn't in list error out and delete the refresh token.
|
||||
1. Continue renewing the refresh token.
|
||||
|
||||
When the end user revokes a client, the following steps are used to.
|
||||
|
||||
1. Update the authorized clients by removing the client from the list. This
|
||||
atomic action causes any renew attempts to fail.
|
||||
1. Iterate through list of refresh tokens and garbage collect any tokens issued
|
||||
by the user for the client. This isn't atomic, but exists so a user can
|
||||
re-authorize a client at a later time without authorizing old refresh tokens.
|
||||
|
||||
This is clunky due to the lack of multi-object transactions. E.g. we can't delete
|
||||
all the refresh tokens at once because we don't have that guarantee.
|
||||
|
||||
Merging accounts becomes extremely simple. Just add another remote identity to
|
||||
the user object.
|
||||
|
||||
We hope to provide a web interface that a user can login to to perform these
|
||||
actions. Perhaps using a well known client issued exclusively for the server.
|
||||
|
||||
The new `User` object requires adding the following methods to the storage
|
||||
interface, and (as a nice side effect) deleting the `ListRefreshTokens()` method.
|
||||
|
||||
```go
|
||||
type Storage interface {
|
||||
// ...
|
||||
|
||||
CreateUser(u User) error
|
||||
|
||||
DeleteUser(id string) error
|
||||
|
||||
GetUser(id string) error
|
||||
GetUserByRemoteIdentity(connectorID, connectorUserID string) (User, error)
|
||||
|
||||
// Updates are assumed to be atomic.
|
||||
//
|
||||
// When a UpdateUser is called, if clients are removed from the
|
||||
// AuthorizedClients list, the underlying storage SHOULD clean up refresh
|
||||
// tokens issued for the removed clients. This allows backends with
|
||||
// multi-transactional capabilities to utilize them, while key-value stores
|
||||
// only guarantee best effort.
|
||||
UpdateUser(id string, updater func(old User) (User, error)) error
|
||||
}
|
||||
```
|
||||
|
||||
Importantly, this will be the first object which has a secondary index.
|
||||
The Kubernetes client will simply list all the users in memory then iterate over
|
||||
them to support this (possibly followed by a "watch" based optimization). SQL
|
||||
implementations will have an easier time.
|
|
@ -1,77 +0,0 @@
|
|||
# Authentication through SAML 2.0
|
||||
|
||||
## Overview
|
||||
|
||||
The experimental SAML provider allows authentication through the SAML 2.0 HTTP POST binding.
|
||||
|
||||
The connector uses the value of the `NameID` element as the user's unique identifier which dex assumes is both unique and never changes. Use the `nameIDPolicyFormat` to ensure this is set to a value which satisfies these requirements.
|
||||
|
||||
## Caveats
|
||||
|
||||
There are known issues with the XML signature validation for this connector. In addition work is still being done to ensure this connector implements best security practices for SAML 2.0.
|
||||
|
||||
The connector doesn't support signed AuthnRequests or encrypted attributes.
|
||||
|
||||
The connector doesn't support refresh tokens since the SAML 2.0 protocol doesn't provide a way to requery a provider without interaction. Ensure that the "offline_access" scope is not requested in client apps.
|
||||
|
||||
## Configuration
|
||||
|
||||
```yaml
|
||||
connectors:
|
||||
- type: samlExperimental # will be changed to "saml" later without support for the "samlExperimental" value
|
||||
# Required field for connector id.
|
||||
id: saml
|
||||
# Required field for connector name.
|
||||
name: SAML
|
||||
config:
|
||||
# SSO URL used for POST value.
|
||||
ssoURL: https://saml.example.com/sso
|
||||
|
||||
# CA to use when validating the SAML response.
|
||||
ca: /path/to/ca.pem
|
||||
|
||||
# CA's can also be provided inline as a base64'd blob.
|
||||
#
|
||||
# caData: ( RAW base64'd PEM encoded CA )
|
||||
|
||||
# To skip signature validation, uncomment the following field. This should
|
||||
# only be used during testing and may be removed in the future.
|
||||
#
|
||||
# insucreSkipSignatureValidation: true
|
||||
|
||||
# Dex's callback URL. Must match the "Destination" attribute of all responses
|
||||
# exactly.
|
||||
redirectURI: https://dex.example.com/callback
|
||||
|
||||
# Name of attributes in the returned assertions to map to ID token claims.
|
||||
usernameAttr: name
|
||||
emailAttr: email
|
||||
groupsAttr: groups # optional
|
||||
|
||||
# By default, multiple groups are assumed to be represented as multiple
|
||||
# attributes with the same name.
|
||||
#
|
||||
# If "groupsDelim" is provided groups are assumed to be represented as a
|
||||
# single attribute and the delimiter is used to split the attribute's value
|
||||
# into multiple groups.
|
||||
#
|
||||
# groupsDelim: ", "
|
||||
|
||||
|
||||
# Requested format of the NameID. The NameID value is is mapped to the ID Token
|
||||
# 'sub' claim. This can be an abbreviated form of the full URI with just the last
|
||||
# component. For example, if this value is set to "emailAddress" the format will
|
||||
# resolve to:
|
||||
#
|
||||
# urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress
|
||||
#
|
||||
# If no value is specified, this value defaults to:
|
||||
#
|
||||
# urn:oasis:names:tc:SAML:2.0:nameid-format:persistent
|
||||
#
|
||||
nameIDPolicyFormat: persistent
|
||||
|
||||
# Optional issuer used for validating the SAML response. If provided the
|
||||
# connector will validate the Issuer in the response.
|
||||
# issuer: https://saml.example.com
|
||||
```
|
|
@ -1,168 +0,0 @@
|
|||
# Storage options
|
||||
|
||||
Dex requires persisting state to perform various tasks such as track refresh tokens, preventing replays, and rotating keys. This document is a summary of the storage configurations supported by dex.
|
||||
|
||||
Storage breaches are serious as they can affect applications that rely on dex. Dex saves sensitive data in its backing storage, including signing keys and bcrypt'd passwords. As such, transport security and database ACLs should both be used, no matter which storage option is chosen.
|
||||
|
||||
## Kubernetes third party resources
|
||||
|
||||
__NOTE:__ Dex requires Kubernetes version 1.4+.
|
||||
|
||||
Kubernetes third party resources are a way for applications to create new resources types in the Kubernetes API. This allows dex to run on top of an existing Kubernetes cluster without the need for an external database. While this storage may not be appropriate for a large number of users, it's extremely effective for many Kubernetes use cases.
|
||||
|
||||
The rest of this section will explore internal details of how dex uses `ThirdPartyResources`. __Admins should not interact with these resources directly__, except when debugging. These resources are only designed to store state and aren't meant to be consumed by humans. For modifying dex's state dynamically see the [API documentation](api.md).
|
||||
|
||||
The `ThirdPartyResource` type acts as a description for the new resource a user wishes to create. The following an example of a resource managed by dex:
|
||||
|
||||
```
|
||||
kind: ThirdPartyResource
|
||||
apiVersion: extensions/v1beta1
|
||||
metadata:
|
||||
name: o-auth2-client.oidc.coreos.com
|
||||
versions:
|
||||
- name: v1
|
||||
description: "An OAuth2 client."
|
||||
```
|
||||
|
||||
Once the `ThirdPartyResource` is created, custom resources can be created at a namespace level (though there will be a gap between the `ThirdPartyResource` being created and the API server accepting the custom resource). While most fields are user defined, the API server still respects the common `ObjectMeta` and `TypeMeta` values. For example names are still restricted to a small set of characters, and the `resourceVersion` field can be used for an [atomic compare and swap][k8s-api].
|
||||
|
||||
The following is an example of a custom `OAuth2Client` resource:
|
||||
|
||||
```
|
||||
# Standard Kubernetes resource fields
|
||||
kind: OAuth2Client
|
||||
apiVersion: oidc.coreos.com/v1
|
||||
metadata:
|
||||
namespace: foobar
|
||||
name: ( opaque hash )
|
||||
|
||||
# Custom fields defined by dex.
|
||||
clientID: "aclientid"
|
||||
clientSecret: "clientsecret"
|
||||
redirectURIs:
|
||||
- "https://app.example.com/callback"
|
||||
```
|
||||
|
||||
The `ThirdPartyResource` type and the custom resources can be queried, deleted, and edited like any other resource using `kubectl`.
|
||||
|
||||
```
|
||||
kubectl get thirdpartyresources # list third party resources registered on the clusters
|
||||
kubectl get --namespace=foobar oauth2clients # list oauth2 clients in a given namespace
|
||||
```
|
||||
|
||||
To reduce administrative overhead, dex creates and manages its own third party resources and may create new ones during upgrades. While not strictly required we feel this is important for reasonable updates. Though, as a result, dex requires access to the non-namespaced `ThirdPartyResource` type. For example, clusters using RBAC authorization would need to create the following roles and bindings:
|
||||
|
||||
```
|
||||
kind: ClusterRole
|
||||
apiVersion: rbac.authorization.k8s.io/v1alpha1
|
||||
metadata:
|
||||
name: dex
|
||||
rules:
|
||||
- apiGroups: ["oidc.coreos.com"] # API group created by dex
|
||||
resources: ["*"]
|
||||
verbs: ["*"]
|
||||
nonResourceURLs: []
|
||||
- apiGroups: ["extensions"]
|
||||
resources: ["thirdpartyresources"]
|
||||
verbs: ["create"] # To manage its own resources identity must be able to create thirdpartyresources.
|
||||
nonResourceURLs: []
|
||||
---
|
||||
kind: ClusterRoleBinding
|
||||
apiVersion: rbac.authorization.k8s.io/v1alpha1
|
||||
metadata:
|
||||
name: dex
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: dex # Service account assigned to the dex pod.
|
||||
namespace: demo-namespace # The namespace dex is running in.
|
||||
roleRef:
|
||||
kind: ClusterRole
|
||||
name: identity
|
||||
apiVersion: rbac.authorization.k8s.io/v1alpha1
|
||||
```
|
||||
|
||||
The storage configuration is extremely limited since installations running outside a Kubernetes cluster would likely prefer a different storage option. An example configuration for dex running inside Kubernetes:
|
||||
|
||||
```
|
||||
storage:
|
||||
type: kubernetes
|
||||
config:
|
||||
inCluster: true
|
||||
```
|
||||
|
||||
Dex determines the namespace it's running in by parsing the service account token automatically mounted into its pod.
|
||||
|
||||
## SQL
|
||||
|
||||
Dex supports two flavors of SQL, SQLite3 and Postgres. MySQL and CockroachDB may be added at a later time.
|
||||
|
||||
Migrations are performed automatically on the first connection to the SQL server (it does not support rolling back). Because of this dex requires privileges to add and alter the tables for its database.
|
||||
|
||||
__NOTE:__ Previous versions of dex required symmetric keys to encrypt certain values before sending them to the database. This feature has not yet been ported to dex v2. If it is added later there may not be a migration path for current v2 users.
|
||||
|
||||
### SQLite3
|
||||
|
||||
SQLite3 is the recommended storage for users who want to stand up dex quickly. It is __not__ appropriate for real workloads.
|
||||
|
||||
The SQLite3 configuration takes a single argument, the database file.
|
||||
|
||||
```
|
||||
storage:
|
||||
type: sqlite3
|
||||
config:
|
||||
file: /var/dex/dex.db
|
||||
```
|
||||
|
||||
Because SQLite3 uses file locks to prevent race conditions, if the ":memory:" value is provided dex will automatically disable support for concurrent database queries.
|
||||
|
||||
### Postgres
|
||||
|
||||
When using Postgres, admins may want to dedicate a database to dex for the following reasons:
|
||||
|
||||
1. Dex requires privileged access to its database because it performs migrations.
|
||||
2. Dex's database table names are not configurable; when shared with other applications there may be table name clashes.
|
||||
|
||||
```
|
||||
CREATE DATABASE dex_db;
|
||||
CREATE USER dex WITH PASSWORD '66964843358242dbaaa7778d8477c288';
|
||||
GRANT ALL PRIVILEGES ON DATABASE dex_db TO dex;
|
||||
```
|
||||
|
||||
An example config for Postgres setup using these values:
|
||||
|
||||
```
|
||||
storage:
|
||||
type: postgres
|
||||
config:
|
||||
database: dex_db
|
||||
user: dex
|
||||
password: 66964843358242dbaaa7778d8477c288
|
||||
ssl:
|
||||
mode: verify-ca
|
||||
caFile: /etc/dex/postgres.ca
|
||||
```
|
||||
|
||||
The SSL "mode" corresponds to the `github.com/lib/pq` package [connection options][psql-conn-options]. If unspecified, dex defaults to the strictest mode "verify-full".
|
||||
|
||||
## Adding a new storage options
|
||||
|
||||
Each storage implementation bears a large ongoing maintenance cost and needs to be updated every time a feature requires storing a new type. Bugs often require in depth knowledge of the backing software, and much of this work will be done by developers who are not the original author. Changes to dex which add new storage implementations are not merged lightly.
|
||||
|
||||
### New storage option references
|
||||
|
||||
Those who still want to construct a proposal for a new storage should review the following packages:
|
||||
|
||||
* `github.com/coreos/dex/storage`: Interface definitions which the storage must implement. __NOTE:__ This package is not stable.
|
||||
* `github.com/coreos/dex/storage/conformance`: Conformance tests which storage implementations must pass.
|
||||
|
||||
### New storage option requirements
|
||||
|
||||
Any proposal to add a new implementation must address the following:
|
||||
|
||||
* Integration testing setups (Travis and developer workstations).
|
||||
* Transactional requirements: atomic deletes, updates, etc.
|
||||
* Is there an established and reasonable Go client?
|
||||
|
||||
[issues-transaction-tests]: https://github.com/coreos/dex/issues/600
|
||||
[k8s-api]: https://github.com/kubernetes/kubernetes/blob/master/docs/devel/api-conventions.md#concurrency-control-and-consistency
|
||||
[psql-conn-options]: https://godoc.org/github.com/lib/pq#hdr-Connection_String_Parameters
|
|
@ -1,47 +0,0 @@
|
|||
# Dex v2
|
||||
|
||||
## Streamlined deployments
|
||||
|
||||
Many of the changes between v1 and v2 were aimed at making dex easier to deploy and manage, perhaps the biggest pain point for dex v1. Dex is now a single, scalable binary with a sole source of configuration. Many components which previously had to be set through the API, such as OAuth2 clients and IDP connectors can now be specified statically. The new architecture lacks a singleton component eliminating deployment ordering. There are no more special development modes; instructions for running dex on a workstation translate with minimal changes to a production system.
|
||||
|
||||
All of this results in a much simpler deployment story. Write a config file, run the dex binary, and that's it.
|
||||
|
||||
## More storage backends
|
||||
|
||||
Dex's internal storage interface has been improved to support multiple backing databases including Postgres, SQLite3, and the Kubernetes API through Third Party Resources. This allows dex to meet a more diverse set of use cases instead of insisting on one particular deployment pattern. For example, The Kubernetes API implementation, a [key value store][k8s-api-docs], allows dex to be run natively on top of a Kubernetes cluster with extremely little administrative overhead. Starting with support for multiple storage backends also should help ensure that the dex storage interface is actually pluggable, rather than being coupled too tightly with a single implementation.
|
||||
|
||||
A more in depth discussion of existing storage options and how to add new ones can be found [here][storage-docs].
|
||||
|
||||
## Additional improvements
|
||||
|
||||
The rewrite came with several, miscellaneous improvements including:
|
||||
|
||||
* More powerful connectors. For example the GitHub connector can now query for teams.
|
||||
* Combined the two APIs into a single [gRPC API][api-docs] with no complex authorization rules.
|
||||
* Expanded OAuth2 capabilities such as the implicit flow.
|
||||
* Simplified codebase and improved testing.
|
||||
|
||||
## Rethinking registration
|
||||
|
||||
Dex v1 performed well when it could manage users. It provided features such as registration, email invites, password resets, administrative abilities, etc. However, login flows and APIs remain tightly coupled with concepts like registration and admin users even when v1 federated to an upstream identity provider (IDP) where it likely only had read only access to the actual user database.
|
||||
|
||||
Many of v2's use cases focus on federation to other IPDs rather than managing users itself. Because of this, options associated with registration, such as SMTP credentials, have been removed. We hope to add registration and user management back into the project through orthogonal applications using the [gRPC API][api-docs], but in a way that doesn't impact other use cases.
|
||||
|
||||
## Removed features
|
||||
|
||||
Dex v2 lacks certain features present in v1. For the most part _we aim to add most of these features back into v2_, but in a way that installations have to _opt in_ to a feature instead of burdening every deployment with extra configuration.
|
||||
|
||||
Notable missing features include:
|
||||
|
||||
* Registration flows.
|
||||
* Local user management.
|
||||
* SMTP configuration and email verification.
|
||||
* Several of the login connectors that have yet to be ported.
|
||||
|
||||
## Support for dex v1
|
||||
|
||||
Dex v1 will continue to live under the `github.com/coreos/dex` repo on a branch. Bug fixes and minor changes will continue to be accepted, but development of new features by the dex team will largely cease.
|
||||
|
||||
[k8s-api-docs]: http://kubernetes.io/docs/api/
|
||||
[storage-docs]: ./storage.md
|
||||
[api-docs]: ./api.md
|
|
@ -0,0 +1,6 @@
|
|||
Joel Speed <Joel.speed@hotmail.co.uk> (@JoelSpeed)
|
||||
Maksim Nabokikh <max.nabokih@gmail.com> (@nabokihms)
|
||||
Mark Sagi-Kazar <mark.sagikazar@gmail.com> (@sagikazarmark)
|
||||
Nandor Kracser <bonifaido@gmail.com> (@bonifaido)
|
||||
Rithu John <rithujohn191@gmail.com> (@rithujohn191)
|
||||
Stephen Augustus <foo@auggie.dev> (@justaugustus)
|
183
Makefile
183
Makefile
|
@ -1,103 +1,162 @@
|
|||
OS = $(shell uname | tr A-Z a-z)
|
||||
|
||||
export PATH := $(abspath bin/protoc/bin/):$(abspath bin/):${PATH}
|
||||
|
||||
PROJ=dex
|
||||
ORG_PATH=github.com/coreos
|
||||
ORG_PATH=github.com/dexidp
|
||||
REPO_PATH=$(ORG_PATH)/$(PROJ)
|
||||
export PATH := $(PWD)/bin:$(PATH)
|
||||
|
||||
VERSION ?= $(shell ./scripts/git-version)
|
||||
|
||||
DOCKER_REPO=quay.io/coreos/dex
|
||||
DOCKER_REPO=quay.io/dexidp/dex
|
||||
DOCKER_IMAGE=$(DOCKER_REPO):$(VERSION)
|
||||
|
||||
$( shell mkdir -p bin )
|
||||
$( shell mkdir -p _output/images )
|
||||
$( shell mkdir -p _output/bin )
|
||||
|
||||
user=$(shell id -u -n)
|
||||
group=$(shell id -g -n)
|
||||
|
||||
export GOBIN=$(PWD)/bin
|
||||
# Prefer ./bin instead of system packages for things like protoc, where we want
|
||||
# to use the version dex uses, not whatever a developer has installed.
|
||||
export PATH=$(GOBIN):$(shell printenv PATH)
|
||||
export GO15VENDOREXPERIMENT=1
|
||||
|
||||
LD_FLAGS="-w -X $(REPO_PATH)/version.Version=$(VERSION)"
|
||||
LD_FLAGS="-w -X main.version=$(VERSION)"
|
||||
|
||||
build: bin/dex bin/example-app bin/grpc-client
|
||||
# Dependency versions
|
||||
|
||||
bin/dex: check-go-version
|
||||
KIND_NODE_IMAGE = "kindest/node:v1.19.11@sha256:07db187ae84b4b7de440a73886f008cf903fcf5764ba8106a9fd5243d6f32729"
|
||||
KIND_TMP_DIR = "$(PWD)/bin/test/dex-kind-kubeconfig"
|
||||
|
||||
.PHONY: generate
|
||||
generate:
|
||||
@go generate $(REPO_PATH)/storage/ent/
|
||||
|
||||
build: generate bin/dex
|
||||
|
||||
bin/dex:
|
||||
@mkdir -p bin/
|
||||
@go install -v -ldflags $(LD_FLAGS) $(REPO_PATH)/cmd/dex
|
||||
|
||||
bin/example-app: check-go-version
|
||||
@go install -v -ldflags $(LD_FLAGS) $(REPO_PATH)/cmd/example-app
|
||||
examples: bin/grpc-client bin/example-app
|
||||
|
||||
bin/grpc-client: check-go-version
|
||||
@go install -v -ldflags $(LD_FLAGS) $(REPO_PATH)/examples/grpc-client
|
||||
bin/grpc-client:
|
||||
@mkdir -p bin/
|
||||
@cd examples/ && go install -v -ldflags $(LD_FLAGS) $(REPO_PATH)/examples/grpc-client
|
||||
|
||||
bin/example-app:
|
||||
@mkdir -p bin/
|
||||
@cd examples/ && go install -v -ldflags $(LD_FLAGS) $(REPO_PATH)/examples/example-app
|
||||
|
||||
.PHONY: release-binary
|
||||
release-binary:
|
||||
release-binary: LD_FLAGS = "-w -X main.version=$(VERSION) -extldflags \"-static\""
|
||||
release-binary: generate
|
||||
@go build -o /go/bin/dex -v -ldflags $(LD_FLAGS) $(REPO_PATH)/cmd/dex
|
||||
@go build -o /go/bin/docker-entrypoint -v -ldflags $(LD_FLAGS) $(REPO_PATH)/cmd/docker-entrypoint
|
||||
|
||||
.PHONY: revendor
|
||||
revendor:
|
||||
@glide up -v
|
||||
@glide-vc --use-lock-file --no-tests --only-code
|
||||
docker-compose.override.yaml:
|
||||
cp docker-compose.override.yaml.dist docker-compose.override.yaml
|
||||
|
||||
.PHONY: up
|
||||
up: docker-compose.override.yaml ## Launch the development environment
|
||||
@ if [ docker-compose.override.yaml -ot docker-compose.override.yaml.dist ]; then diff -u docker-compose.override.yaml docker-compose.override.yaml.dist || (echo "!!! The distributed docker-compose.override.yaml example changed. Please update your file accordingly (or at least touch it). !!!" && false); fi
|
||||
docker-compose up -d
|
||||
|
||||
.PHONY: down
|
||||
down: clear ## Destroy the development environment
|
||||
docker-compose down --volumes --remove-orphans --rmi local
|
||||
|
||||
test:
|
||||
@go test -v -i $(shell go list ./... | grep -v '/vendor/')
|
||||
@go test -v $(shell go list ./... | grep -v '/vendor/')
|
||||
@go test -v ./...
|
||||
|
||||
testrace:
|
||||
@go test -v -i --race $(shell go list ./... | grep -v '/vendor/')
|
||||
@go test -v --race $(shell go list ./... | grep -v '/vendor/')
|
||||
@go test -v --race ./...
|
||||
|
||||
vet:
|
||||
@go vet $(shell go list ./... | grep -v '/vendor/')
|
||||
.PHONY: kind-up kind-down kind-tests
|
||||
kind-up:
|
||||
@mkdir -p bin/test
|
||||
@kind create cluster --image ${KIND_NODE_IMAGE} --kubeconfig ${KIND_TMP_DIR}
|
||||
|
||||
fmt:
|
||||
@go fmt $(shell go list ./... | grep -v '/vendor/')
|
||||
kind-down:
|
||||
@kind delete cluster
|
||||
rm ${KIND_TMP_DIR}
|
||||
|
||||
lint:
|
||||
@for package in $(shell go list ./... | grep -v '/vendor/' | grep -v '/api' | grep -v '/server/internal'); do \
|
||||
golint -set_exit_status $$package $$i || exit 1; \
|
||||
done
|
||||
kind-tests: export DEX_KUBERNETES_CONFIG_PATH=${KIND_TMP_DIR}
|
||||
kind-tests: testall
|
||||
|
||||
_output/bin/dex:
|
||||
@./scripts/docker-build
|
||||
@sudo chown $(user):$(group) _output/bin/dex
|
||||
.PHONY: lint lint-fix
|
||||
lint: ## Run linter
|
||||
golangci-lint run
|
||||
|
||||
.PHONY: fix
|
||||
fix: ## Fix lint violations
|
||||
golangci-lint run --fix
|
||||
|
||||
.PHONY: docker-image
|
||||
docker-image: clean-release _output/bin/dex
|
||||
docker-image:
|
||||
@sudo docker build -t $(DOCKER_IMAGE) .
|
||||
|
||||
.PHONY: proto
|
||||
proto: api/api.pb.go server/internal/types.pb.go
|
||||
.PHONY: verify-proto
|
||||
verify-proto: proto
|
||||
@./scripts/git-diff
|
||||
|
||||
api/api.pb.go: api/api.proto bin/protoc bin/protoc-gen-go
|
||||
@protoc --go_out=plugins=grpc:. api/*.proto
|
||||
|
||||
server/internal/types.pb.go: server/internal/types.proto bin/protoc bin/protoc-gen-go
|
||||
@protoc --go_out=. server/internal/*.proto
|
||||
|
||||
bin/protoc: scripts/get-protoc
|
||||
@./scripts/get-protoc bin/protoc
|
||||
|
||||
bin/protoc-gen-go:
|
||||
@go install -v $(REPO_PATH)/vendor/github.com/golang/protobuf/protoc-gen-go
|
||||
|
||||
.PHONY: check-go-version
|
||||
check-go-version:
|
||||
@./scripts/check-go-version
|
||||
|
||||
clean: clean-release
|
||||
clean:
|
||||
@rm -rf bin/
|
||||
|
||||
.PHONY: clean-release
|
||||
clean-release:
|
||||
@rm -rf _output/
|
||||
|
||||
testall: testrace vet fmt lint
|
||||
testall: testrace
|
||||
|
||||
FORCE:
|
||||
|
||||
.PHONY: test testrace vet fmt lint testall
|
||||
.PHONY: test testrace testall
|
||||
|
||||
.PHONY: proto
|
||||
proto:
|
||||
@protoc --go_out=paths=source_relative:. --go-grpc_out=paths=source_relative:. api/v2/*.proto
|
||||
@protoc --go_out=paths=source_relative:. --go-grpc_out=paths=source_relative:. api/*.proto
|
||||
#@cp api/v2/*.proto api/
|
||||
|
||||
.PHONY: proto-internal
|
||||
proto-internal:
|
||||
@protoc --go_out=paths=source_relative:. server/internal/*.proto
|
||||
|
||||
# Dependency versions
|
||||
GOLANGCI_VERSION = 1.46.0
|
||||
GOTESTSUM_VERSION ?= 1.7.0
|
||||
PROTOC_VERSION = 3.15.6
|
||||
PROTOC_GEN_GO_VERSION = 1.26.0
|
||||
PROTOC_GEN_GO_GRPC_VERSION = 1.1.0
|
||||
KIND_VERSION = 0.11.1
|
||||
|
||||
deps: bin/gotestsum bin/golangci-lint bin/protoc bin/protoc-gen-go bin/protoc-gen-go-grpc bin/kind
|
||||
|
||||
bin/gotestsum:
|
||||
@mkdir -p bin
|
||||
curl -L https://github.com/gotestyourself/gotestsum/releases/download/v${GOTESTSUM_VERSION}/gotestsum_${GOTESTSUM_VERSION}_$(shell uname | tr A-Z a-z)_amd64.tar.gz | tar -zOxf - gotestsum > ./bin/gotestsum
|
||||
@chmod +x ./bin/gotestsum
|
||||
|
||||
bin/golangci-lint:
|
||||
@mkdir -p bin
|
||||
curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | BINARY=golangci-lint bash -s -- v${GOLANGCI_VERSION}
|
||||
|
||||
bin/protoc:
|
||||
@mkdir -p bin/protoc
|
||||
ifeq ($(shell uname | tr A-Z a-z), darwin)
|
||||
curl -L https://github.com/protocolbuffers/protobuf/releases/download/v${PROTOC_VERSION}/protoc-${PROTOC_VERSION}-osx-x86_64.zip > bin/protoc.zip
|
||||
endif
|
||||
ifeq ($(shell uname | tr A-Z a-z), linux)
|
||||
curl -L https://github.com/protocolbuffers/protobuf/releases/download/v${PROTOC_VERSION}/protoc-${PROTOC_VERSION}-linux-x86_64.zip > bin/protoc.zip
|
||||
endif
|
||||
unzip bin/protoc.zip -d bin/protoc
|
||||
rm bin/protoc.zip
|
||||
|
||||
bin/protoc-gen-go:
|
||||
@mkdir -p bin
|
||||
curl -L https://github.com/protocolbuffers/protobuf-go/releases/download/v${PROTOC_GEN_GO_VERSION}/protoc-gen-go.v${PROTOC_GEN_GO_VERSION}.$(shell uname | tr A-Z a-z).amd64.tar.gz | tar -zOxf - protoc-gen-go > ./bin/protoc-gen-go
|
||||
@chmod +x ./bin/protoc-gen-go
|
||||
|
||||
bin/protoc-gen-go-grpc:
|
||||
@mkdir -p bin
|
||||
curl -L https://github.com/grpc/grpc-go/releases/download/cmd/protoc-gen-go-grpc/v${PROTOC_GEN_GO_GRPC_VERSION}/protoc-gen-go-grpc.v${PROTOC_GEN_GO_GRPC_VERSION}.$(shell uname | tr A-Z a-z).amd64.tar.gz | tar -zOxf - ./protoc-gen-go-grpc > ./bin/protoc-gen-go-grpc
|
||||
@chmod +x ./bin/protoc-gen-go-grpc
|
||||
|
||||
bin/kind:
|
||||
@mkdir -p bin
|
||||
curl -L https://github.com/kubernetes-sigs/kind/releases/download/v${KIND_VERSION}/kind-$(shell uname | tr A-Z a-z)-amd64 > ./bin/kind
|
||||
@chmod +x ./bin/kind
|
||||
|
|
150
README.md
150
README.md
|
@ -1,59 +1,143 @@
|
|||
# dex - A federated OpenID Connect provider
|
||||
|
||||
[![Travis](https://api.travis-ci.org/coreos/dex.svg)](https://travis-ci.org/coreos/dex)
|
||||
[![GoDoc](https://godoc.org/github.com/coreos/dex?status.svg)](https://godoc.org/github.com/coreos/dex)
|
||||
[![Go Report Card](https://goreportcard.com/badge/github.com/coreos/dex)](https://goreportcard.com/report/github.com/coreos/dex)
|
||||
![GitHub Workflow Status](https://img.shields.io/github/workflow/status/dexidp/dex/CI?style=flat-square)
|
||||
[![Go Report Card](https://goreportcard.com/badge/github.com/dexidp/dex?style=flat-square)](https://goreportcard.com/report/github.com/dexidp/dex)
|
||||
[![Gitpod ready-to-code](https://img.shields.io/badge/Gitpod-ready--to--code-blue?logo=gitpod&style=flat-square)](https://gitpod.io/#https://github.com/dexidp/dex)
|
||||
|
||||
![logo](Documentation/logos/dex-horizontal-color.png)
|
||||
![logo](docs/logos/dex-horizontal-color.png)
|
||||
|
||||
Dex is an OpenID Connect server that connects to other identity providers. Clients use a standards-based OAuth2 flow to login users, while the actual authentication is performed by established user management systems such as Google, GitHub, FreeIPA, etc.
|
||||
Dex is an identity service that uses [OpenID Connect][openid-connect] to drive authentication for other apps.
|
||||
|
||||
[OpenID Connect][openid-connect] is a flavor of OAuth that builds on top of OAuth2 using the JOSE standards. This allows dex to provide:
|
||||
Dex acts as a portal to other identity providers through ["connectors."](#connectors) This lets dex defer authentication to LDAP servers, SAML providers, or established identity providers like GitHub, Google, and Active Directory. Clients write their authentication logic once to talk to dex, then dex handles the protocols for a given backend.
|
||||
|
||||
* Short-lived, signed tokens with standard fields (such as email) issued on behalf of users.
|
||||
* "well-known" discovery of OAuth2 endpoints.
|
||||
* OAuth2 mechanisms such as refresh tokens and revocation for long term access.
|
||||
* Automatic signing key rotation.
|
||||
## ID Tokens
|
||||
|
||||
Standards-based token responses allows applications to interact with any OpenID Connect server instead of writing backend specific "access_token" dances. Systems that can already consume ID Tokens issued by dex include:
|
||||
ID Tokens are an OAuth2 extension introduced by OpenID Connect and dex's primary feature. ID Tokens are [JSON Web Tokens][jwt-io] (JWTs) signed by dex and returned as part of the OAuth2 response that attest to the end user's identity. An example JWT might look like:
|
||||
|
||||
```
|
||||
eyJhbGciOiJSUzI1NiIsImtpZCI6IjlkNDQ3NDFmNzczYjkzOGNmNjVkZDMyNjY4NWI4NjE4MGMzMjRkOTkifQ.eyJpc3MiOiJodHRwOi8vMTI3LjAuMC4xOjU1NTYvZGV4Iiwic3ViIjoiQ2djeU16UXlOelE1RWdabmFYUm9kV0kiLCJhdWQiOiJleGFtcGxlLWFwcCIsImV4cCI6MTQ5Mjg4MjA0MiwiaWF0IjoxNDkyNzk1NjQyLCJhdF9oYXNoIjoiYmk5NmdPWFpTaHZsV1l0YWw5RXFpdyIsImVtYWlsIjoiZXJpYy5jaGlhbmdAY29yZW9zLmNvbSIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJncm91cHMiOlsiYWRtaW5zIiwiZGV2ZWxvcGVycyJdLCJuYW1lIjoiRXJpYyBDaGlhbmcifQ.OhROPq_0eP-zsQRjg87KZ4wGkjiQGnTi5QuG877AdJDb3R2ZCOk2Vkf5SdP8cPyb3VMqL32G4hLDayniiv8f1_ZXAde0sKrayfQ10XAXFgZl_P1yilkLdknxn6nbhDRVllpWcB12ki9vmAxklAr0B1C4kr5nI3-BZLrFcUR5sQbxwJj4oW1OuG6jJCNGHXGNTBTNEaM28eD-9nhfBeuBTzzO7BKwPsojjj4C9ogU4JQhGvm_l4yfVi0boSx8c0FX3JsiB0yLa1ZdJVWVl9m90XmbWRSD85pNDQHcWZP9hR6CMgbvGkZsgjG32qeRwUL_eNkNowSBNWLrGNPoON1gMg
|
||||
```
|
||||
|
||||
ID Tokens contains standard claims assert which client app logged the user in, when the token expires, and the identity of the user.
|
||||
|
||||
```json
|
||||
{
|
||||
"iss": "http://127.0.0.1:5556/dex",
|
||||
"sub": "CgcyMzQyNzQ5EgZnaXRodWI",
|
||||
"aud": "example-app",
|
||||
"exp": 1492882042,
|
||||
"iat": 1492795642,
|
||||
"at_hash": "bi96gOXZShvlWYtal9Eqiw",
|
||||
"email": "jane.doe@coreos.com",
|
||||
"email_verified": true,
|
||||
"groups": [
|
||||
"admins",
|
||||
"developers"
|
||||
],
|
||||
"name": "Jane Doe"
|
||||
}
|
||||
```
|
||||
|
||||
Because these tokens are signed by dex and [contain standard-based claims][standard-claims] other services can consume them as service-to-service credentials. Systems that can already consume OpenID Connect ID Tokens issued by dex include:
|
||||
|
||||
* [Kubernetes][kubernetes]
|
||||
* [AWS STS][aws-sts]
|
||||
|
||||
## Kubernetes + dex
|
||||
For details on how to request or validate an ID Token, see [_"Writing apps that use dex"_][using-dex].
|
||||
|
||||
Dex's main production use is as an auth-N addon in CoreOS's enterprise Kubernetes solution, [Tectonic][tectonic]. Dex runs natively on top of any Kubernetes cluster using Third Party Resources and can drive API server authentication through the OpenID Connect plugin. Clients, such as the [Tectonic Console][tectonic-console] and `kubectl`, can act on behalf users who can login to the cluster through any identity provider dex supports.
|
||||
## Kubernetes and Dex
|
||||
|
||||
More docs for running dex as a Kubernetes authenticator can be found [here](Documentation/kubernetes.md).
|
||||
Dex runs natively on top of any Kubernetes cluster using Custom Resource Definitions and can drive API server authentication through the OpenID Connect plugin. Clients, such as the [`kubernetes-dashboard`](https://github.com/kubernetes/dashboard) and `kubectl`, can act on behalf of users who can login to the cluster through any identity provider dex supports.
|
||||
|
||||
* More docs for running dex as a Kubernetes authenticator can be found [here](https://dexidp.io/docs/kubernetes/).
|
||||
* You can find more about companies and projects, which uses dex, [here](./ADOPTERS.md).
|
||||
|
||||
## Connectors
|
||||
|
||||
When a user logs in through dex, the user's identity is usually stored in another user-management system: a LDAP directory, a GitHub org, etc. Dex acts as a shim between a client app and the upstream identity provider. The client only needs to understand OpenID Connect to query dex, while dex implements an array of protocols for querying other user-management systems.
|
||||
|
||||
![](docs/img/dex-flow.png)
|
||||
|
||||
A "connector" is a strategy used by dex for authenticating a user against another identity provider. Dex implements connectors that target specific platforms such as GitHub, LinkedIn, and Microsoft as well as established protocols like LDAP and SAML.
|
||||
|
||||
Depending on the connectors limitations in protocols can prevent dex from issuing [refresh tokens][scopes] or returning [group membership][scopes] claims. For example, because SAML doesn't provide a non-interactive way to refresh assertions, if a user logs in through the SAML connector dex won't issue a refresh token to its client. Refresh token support is required for clients that require offline access, such as `kubectl`.
|
||||
|
||||
Dex implements the following connectors:
|
||||
|
||||
| Name | supports refresh tokens | supports groups claim | supports preferred_username claim | status | notes |
|
||||
| ---- | ----------------------- | --------------------- | --------------------------------- | ------ | ----- |
|
||||
| [LDAP](https://dexidp.io/docs/connectors/ldap/) | yes | yes | yes | stable | |
|
||||
| [GitHub](https://dexidp.io/docs/connectors/github/) | yes | yes | yes | stable | |
|
||||
| [SAML 2.0](https://dexidp.io/docs/connectors/saml/) | no | yes | no | stable | WARNING: Unmaintained and likely vulnerable to auth bypasses ([#1884](https://github.com/dexidp/dex/discussions/1884)) |
|
||||
| [GitLab](https://dexidp.io/docs/connectors/gitlab/) | yes | yes | yes | beta | |
|
||||
| [OpenID Connect](https://dexidp.io/docs/connectors/oidc/) | yes | yes | yes | beta | Includes Salesforce, Azure, etc. |
|
||||
| [OAuth 2.0](https://dexidp.io/docs/connectors/oauth/) | no | yes | yes | alpha | |
|
||||
| [Google](https://dexidp.io/docs/connectors/google/) | yes | yes | yes | alpha | |
|
||||
| [LinkedIn](https://dexidp.io/docs/connectors/linkedin/) | yes | no | no | beta | |
|
||||
| [Microsoft](https://dexidp.io/docs/connectors/microsoft/) | yes | yes | no | beta | |
|
||||
| [AuthProxy](https://dexidp.io/docs/connectors/authproxy/) | no | yes | no | alpha | Authentication proxies such as Apache2 mod_auth, etc. |
|
||||
| [Bitbucket Cloud](https://dexidp.io/docs/connectors/bitbucketcloud/) | yes | yes | no | alpha | |
|
||||
| [OpenShift](https://dexidp.io/docs/connectors/openshift/) | no | yes | no | alpha | |
|
||||
| [Atlassian Crowd](https://dexidp.io/docs/connectors/atlassiancrowd/) | yes | yes | yes * | beta | preferred_username claim must be configured through config |
|
||||
| [Gitea](https://dexidp.io/docs/connectors/gitea/) | yes | no | yes | beta | |
|
||||
| [OpenStack Keystone](https://dexidp.io/docs/connectors/keystone/) | yes | yes | no | alpha | |
|
||||
|
||||
Stable, beta, and alpha are defined as:
|
||||
|
||||
* Stable: well tested, in active use, and will not change in backward incompatible ways.
|
||||
* Beta: tested and unlikely to change in backward incompatible ways.
|
||||
* Alpha: may be untested by core maintainers and is subject to change in backward incompatible ways.
|
||||
|
||||
All changes or deprecations of connector features will be announced in the [release notes][release-notes].
|
||||
|
||||
## Documentation
|
||||
|
||||
* [Getting started](Documentation/getting-started.md)
|
||||
* [What's new in v2](Documentation/v2.md)
|
||||
* [Storage options](Documentation/storage.md)
|
||||
* [Intro to OpenID Connect](Documentation/openid-connect.md)
|
||||
* [gRPC API](Documentation/api.md)
|
||||
* [Using Kubernetes with dex](Documentation/kubernetes.md)
|
||||
* Identity provider logins
|
||||
* [LDAP](Documentation/ldap-connector.md)
|
||||
* [GitHub](Documentation/github-connector.md)
|
||||
* [SAML 2.0 (experimental)](Documentation/saml-connector.md)
|
||||
* [OpenID Connect](Documentation/oidc-connector.md) (includes Google, Salesforce, Azure, etc.)
|
||||
* [Getting started](https://dexidp.io/docs/getting-started/)
|
||||
* [Intro to OpenID Connect](https://dexidp.io/docs/openid-connect/)
|
||||
* [Writing apps that use dex][using-dex]
|
||||
* [What's new in v2](https://dexidp.io/docs/v2/)
|
||||
* [Custom scopes, claims, and client features](https://dexidp.io/docs/custom-scopes-claims-clients/)
|
||||
* [Storage options](https://dexidp.io/docs/storage/)
|
||||
* [gRPC API](https://dexidp.io/docs/api/)
|
||||
* [Using Kubernetes with dex](https://dexidp.io/docs/kubernetes/)
|
||||
* Client libraries
|
||||
* [Go][go-oidc]
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
Please see our [security policy](.github/SECURITY.md) for details about reporting vulnerabilities.
|
||||
|
||||
## Getting help
|
||||
|
||||
* For bugs and feature requests (including documentation!), file an [issue][issues].
|
||||
* For general discussion about both using and developing dex, join the [dex-dev][dex-dev] mailing list.
|
||||
* For more details on dex development plans, check out the GitHub [milestones][milestones].
|
||||
- For feature requests and bugs, file an [issue](https://github.com/dexidp/dex/issues).
|
||||
- For general discussion about both using and developing Dex:
|
||||
- join the [#dexidp](https://cloud-native.slack.com/messages/dexidp) on the CNCF Slack
|
||||
- open a new [discussion](https://github.com/dexidp/dex/discussions)
|
||||
- join the [dex-dev](https://groups.google.com/forum/#!forum/dex-dev) mailing list
|
||||
|
||||
[openid-connect]: https://openid.net/connect/
|
||||
[standard-claims]: https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims
|
||||
[scopes]: https://dexidp.io/docs/custom-scopes-claims-clients/#scopes
|
||||
[using-dex]: https://dexidp.io/docs/using-dex/
|
||||
[jwt-io]: https://jwt.io/
|
||||
[kubernetes]: http://kubernetes.io/docs/admin/authentication/#openid-connect-tokens
|
||||
[aws-sts]: https://docs.aws.amazon.com/STS/latest/APIReference/Welcome.html
|
||||
[tectonic]: https://tectonic.com/
|
||||
[tectonic-console]: https://tectonic.com/enterprise/docs/latest/usage/index.html#tectonic-console
|
||||
[go-oidc]: https://github.com/coreos/go-oidc
|
||||
[issues]: https://github.com/coreos/dex/issues
|
||||
[dex-dev]: https://groups.google.com/forum/#!forum/dex-dev
|
||||
[milestones]: https://github.com/coreos/dex/milestones
|
||||
[issue-1065]: https://github.com/dexidp/dex/issues/1065
|
||||
[release-notes]: https://github.com/dexidp/dex/releases
|
||||
|
||||
## Development
|
||||
|
||||
When all coding and testing is done, please run the test suite:
|
||||
|
||||
```shell
|
||||
make testall
|
||||
```
|
||||
|
||||
For the best developer experience, install [Nix](https://builtwithnix.org/) and [direnv](https://direnv.net/).
|
||||
|
||||
Alternatively, install Go and Docker manually or using a package manager. Install the rest of the dependencies by running `make deps`.
|
||||
|
||||
## License
|
||||
|
||||
The project is licensed under the [Apache License, Version 2.0](LICENSE).
|
||||
|
|
2326
api/api.pb.go
2326
api/api.pb.go
File diff suppressed because it is too large
Load Diff
|
@ -2,6 +2,9 @@ syntax = "proto3";
|
|||
|
||||
package api;
|
||||
|
||||
option java_package = "com.coreos.dex.api";
|
||||
option go_package = "github.com/dexidp/dex/api";
|
||||
|
||||
// Client represents an OAuth2 client.
|
||||
message Client {
|
||||
string id = 1;
|
||||
|
@ -21,7 +24,7 @@ message CreateClientReq {
|
|||
// CreateClientResp returns the response from creating a client.
|
||||
message CreateClientResp {
|
||||
bool already_exists = 1;
|
||||
Client client = 2;
|
||||
Client client = 2;
|
||||
}
|
||||
|
||||
// DeleteClientReq is a request to delete a client.
|
||||
|
@ -30,11 +33,25 @@ message DeleteClientReq {
|
|||
string id = 1;
|
||||
}
|
||||
|
||||
// DeleteClientResp determines if the client is deleted successfully.
|
||||
// DeleteClientResp determines if the client is deleted successfully.
|
||||
message DeleteClientResp {
|
||||
bool not_found = 1;
|
||||
}
|
||||
|
||||
// UpdateClientReq is a request to update an existing client.
|
||||
message UpdateClientReq {
|
||||
string id = 1;
|
||||
repeated string redirect_uris = 2;
|
||||
repeated string trusted_peers = 3;
|
||||
string name = 4;
|
||||
string logo_url = 5;
|
||||
}
|
||||
|
||||
// UpdateClientResp returns the response from updating a client.
|
||||
message UpdateClientResp {
|
||||
bool not_found = 1;
|
||||
}
|
||||
|
||||
// TODO(ericchiang): expand this.
|
||||
|
||||
// Password is an email for password mapping managed by the storage.
|
||||
|
@ -65,7 +82,7 @@ message UpdatePasswordReq {
|
|||
string new_username = 3;
|
||||
}
|
||||
|
||||
// UpdatePasswordResp returns the response from modifying an existing password.
|
||||
// UpdatePasswordResp returns the response from modifying an existing password.
|
||||
message UpdatePasswordResp {
|
||||
bool not_found = 1;
|
||||
}
|
||||
|
@ -75,7 +92,7 @@ message DeletePasswordReq {
|
|||
string email = 1;
|
||||
}
|
||||
|
||||
// DeletePasswordResp returns the response from deleting a password.
|
||||
// DeletePasswordResp returns the response from deleting a password.
|
||||
message DeletePasswordResp {
|
||||
bool not_found = 1;
|
||||
}
|
||||
|
@ -127,16 +144,28 @@ message RevokeRefreshReq {
|
|||
string client_id = 2;
|
||||
}
|
||||
|
||||
// RevokeRefreshResp determines if the refresh token is revoked successfully.
|
||||
// RevokeRefreshResp determines if the refresh token is revoked successfully.
|
||||
message RevokeRefreshResp {
|
||||
// Set to true is refresh token was not found and token could not be revoked.
|
||||
bool not_found = 1;
|
||||
}
|
||||
|
||||
message VerifyPasswordReq {
|
||||
string email = 1;
|
||||
string password = 2;
|
||||
}
|
||||
|
||||
message VerifyPasswordResp {
|
||||
bool verified = 1;
|
||||
bool not_found = 2;
|
||||
}
|
||||
|
||||
// Dex represents the dex gRPC service.
|
||||
service Dex {
|
||||
// CreateClient creates a client.
|
||||
rpc CreateClient(CreateClientReq) returns (CreateClientResp) {};
|
||||
// UpdateClient updates an existing client
|
||||
rpc UpdateClient(UpdateClientReq) returns (UpdateClientResp) {};
|
||||
// DeleteClient deletes the provided client.
|
||||
rpc DeleteClient(DeleteClientReq) returns (DeleteClientResp) {};
|
||||
// CreatePassword creates a password.
|
||||
|
@ -155,4 +184,6 @@ service Dex {
|
|||
//
|
||||
// Note that each user-client pair can have only one refresh token at a time.
|
||||
rpc RevokeRefresh(RevokeRefreshReq) returns (RevokeRefreshResp) {};
|
||||
// VerifyPassword returns whether a password matches a hash for a specific email or not.
|
||||
rpc VerifyPassword(VerifyPasswordReq) returns (VerifyPasswordResp) {};
|
||||
}
|
||||
|
|
|
@ -0,0 +1,487 @@
|
|||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
context "context"
|
||||
grpc "google.golang.org/grpc"
|
||||
codes "google.golang.org/grpc/codes"
|
||||
status "google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// This is a compile-time assertion to ensure that this generated file
|
||||
// is compatible with the grpc package it is being compiled against.
|
||||
// Requires gRPC-Go v1.32.0 or later.
|
||||
const _ = grpc.SupportPackageIsVersion7
|
||||
|
||||
// DexClient is the client API for Dex service.
|
||||
//
|
||||
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
|
||||
type DexClient interface {
|
||||
// CreateClient creates a client.
|
||||
CreateClient(ctx context.Context, in *CreateClientReq, opts ...grpc.CallOption) (*CreateClientResp, error)
|
||||
// UpdateClient updates an existing client
|
||||
UpdateClient(ctx context.Context, in *UpdateClientReq, opts ...grpc.CallOption) (*UpdateClientResp, error)
|
||||
// DeleteClient deletes the provided client.
|
||||
DeleteClient(ctx context.Context, in *DeleteClientReq, opts ...grpc.CallOption) (*DeleteClientResp, error)
|
||||
// CreatePassword creates a password.
|
||||
CreatePassword(ctx context.Context, in *CreatePasswordReq, opts ...grpc.CallOption) (*CreatePasswordResp, error)
|
||||
// UpdatePassword modifies existing password.
|
||||
UpdatePassword(ctx context.Context, in *UpdatePasswordReq, opts ...grpc.CallOption) (*UpdatePasswordResp, error)
|
||||
// DeletePassword deletes the password.
|
||||
DeletePassword(ctx context.Context, in *DeletePasswordReq, opts ...grpc.CallOption) (*DeletePasswordResp, error)
|
||||
// ListPassword lists all password entries.
|
||||
ListPasswords(ctx context.Context, in *ListPasswordReq, opts ...grpc.CallOption) (*ListPasswordResp, error)
|
||||
// GetVersion returns version information of the server.
|
||||
GetVersion(ctx context.Context, in *VersionReq, opts ...grpc.CallOption) (*VersionResp, error)
|
||||
// ListRefresh lists all the refresh token entries for a particular user.
|
||||
ListRefresh(ctx context.Context, in *ListRefreshReq, opts ...grpc.CallOption) (*ListRefreshResp, error)
|
||||
// RevokeRefresh revokes the refresh token for the provided user-client pair.
|
||||
//
|
||||
// Note that each user-client pair can have only one refresh token at a time.
|
||||
RevokeRefresh(ctx context.Context, in *RevokeRefreshReq, opts ...grpc.CallOption) (*RevokeRefreshResp, error)
|
||||
// VerifyPassword returns whether a password matches a hash for a specific email or not.
|
||||
VerifyPassword(ctx context.Context, in *VerifyPasswordReq, opts ...grpc.CallOption) (*VerifyPasswordResp, error)
|
||||
}
|
||||
|
||||
type dexClient struct {
|
||||
cc grpc.ClientConnInterface
|
||||
}
|
||||
|
||||
func NewDexClient(cc grpc.ClientConnInterface) DexClient {
|
||||
return &dexClient{cc}
|
||||
}
|
||||
|
||||
func (c *dexClient) CreateClient(ctx context.Context, in *CreateClientReq, opts ...grpc.CallOption) (*CreateClientResp, error) {
|
||||
out := new(CreateClientResp)
|
||||
err := c.cc.Invoke(ctx, "/api.Dex/CreateClient", in, out, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *dexClient) UpdateClient(ctx context.Context, in *UpdateClientReq, opts ...grpc.CallOption) (*UpdateClientResp, error) {
|
||||
out := new(UpdateClientResp)
|
||||
err := c.cc.Invoke(ctx, "/api.Dex/UpdateClient", in, out, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *dexClient) DeleteClient(ctx context.Context, in *DeleteClientReq, opts ...grpc.CallOption) (*DeleteClientResp, error) {
|
||||
out := new(DeleteClientResp)
|
||||
err := c.cc.Invoke(ctx, "/api.Dex/DeleteClient", in, out, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *dexClient) CreatePassword(ctx context.Context, in *CreatePasswordReq, opts ...grpc.CallOption) (*CreatePasswordResp, error) {
|
||||
out := new(CreatePasswordResp)
|
||||
err := c.cc.Invoke(ctx, "/api.Dex/CreatePassword", in, out, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *dexClient) UpdatePassword(ctx context.Context, in *UpdatePasswordReq, opts ...grpc.CallOption) (*UpdatePasswordResp, error) {
|
||||
out := new(UpdatePasswordResp)
|
||||
err := c.cc.Invoke(ctx, "/api.Dex/UpdatePassword", in, out, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *dexClient) DeletePassword(ctx context.Context, in *DeletePasswordReq, opts ...grpc.CallOption) (*DeletePasswordResp, error) {
|
||||
out := new(DeletePasswordResp)
|
||||
err := c.cc.Invoke(ctx, "/api.Dex/DeletePassword", in, out, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *dexClient) ListPasswords(ctx context.Context, in *ListPasswordReq, opts ...grpc.CallOption) (*ListPasswordResp, error) {
|
||||
out := new(ListPasswordResp)
|
||||
err := c.cc.Invoke(ctx, "/api.Dex/ListPasswords", in, out, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *dexClient) GetVersion(ctx context.Context, in *VersionReq, opts ...grpc.CallOption) (*VersionResp, error) {
|
||||
out := new(VersionResp)
|
||||
err := c.cc.Invoke(ctx, "/api.Dex/GetVersion", in, out, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *dexClient) ListRefresh(ctx context.Context, in *ListRefreshReq, opts ...grpc.CallOption) (*ListRefreshResp, error) {
|
||||
out := new(ListRefreshResp)
|
||||
err := c.cc.Invoke(ctx, "/api.Dex/ListRefresh", in, out, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *dexClient) RevokeRefresh(ctx context.Context, in *RevokeRefreshReq, opts ...grpc.CallOption) (*RevokeRefreshResp, error) {
|
||||
out := new(RevokeRefreshResp)
|
||||
err := c.cc.Invoke(ctx, "/api.Dex/RevokeRefresh", in, out, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *dexClient) VerifyPassword(ctx context.Context, in *VerifyPasswordReq, opts ...grpc.CallOption) (*VerifyPasswordResp, error) {
|
||||
out := new(VerifyPasswordResp)
|
||||
err := c.cc.Invoke(ctx, "/api.Dex/VerifyPassword", in, out, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DexServer is the server API for Dex service.
|
||||
// All implementations must embed UnimplementedDexServer
|
||||
// for forward compatibility
|
||||
type DexServer interface {
|
||||
// CreateClient creates a client.
|
||||
CreateClient(context.Context, *CreateClientReq) (*CreateClientResp, error)
|
||||
// UpdateClient updates an existing client
|
||||
UpdateClient(context.Context, *UpdateClientReq) (*UpdateClientResp, error)
|
||||
// DeleteClient deletes the provided client.
|
||||
DeleteClient(context.Context, *DeleteClientReq) (*DeleteClientResp, error)
|
||||
// CreatePassword creates a password.
|
||||
CreatePassword(context.Context, *CreatePasswordReq) (*CreatePasswordResp, error)
|
||||
// UpdatePassword modifies existing password.
|
||||
UpdatePassword(context.Context, *UpdatePasswordReq) (*UpdatePasswordResp, error)
|
||||
// DeletePassword deletes the password.
|
||||
DeletePassword(context.Context, *DeletePasswordReq) (*DeletePasswordResp, error)
|
||||
// ListPassword lists all password entries.
|
||||
ListPasswords(context.Context, *ListPasswordReq) (*ListPasswordResp, error)
|
||||
// GetVersion returns version information of the server.
|
||||
GetVersion(context.Context, *VersionReq) (*VersionResp, error)
|
||||
// ListRefresh lists all the refresh token entries for a particular user.
|
||||
ListRefresh(context.Context, *ListRefreshReq) (*ListRefreshResp, error)
|
||||
// RevokeRefresh revokes the refresh token for the provided user-client pair.
|
||||
//
|
||||
// Note that each user-client pair can have only one refresh token at a time.
|
||||
RevokeRefresh(context.Context, *RevokeRefreshReq) (*RevokeRefreshResp, error)
|
||||
// VerifyPassword returns whether a password matches a hash for a specific email or not.
|
||||
VerifyPassword(context.Context, *VerifyPasswordReq) (*VerifyPasswordResp, error)
|
||||
mustEmbedUnimplementedDexServer()
|
||||
}
|
||||
|
||||
// UnimplementedDexServer must be embedded to have forward compatible implementations.
|
||||
type UnimplementedDexServer struct {
|
||||
}
|
||||
|
||||
func (UnimplementedDexServer) CreateClient(context.Context, *CreateClientReq) (*CreateClientResp, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method CreateClient not implemented")
|
||||
}
|
||||
func (UnimplementedDexServer) UpdateClient(context.Context, *UpdateClientReq) (*UpdateClientResp, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method UpdateClient not implemented")
|
||||
}
|
||||
func (UnimplementedDexServer) DeleteClient(context.Context, *DeleteClientReq) (*DeleteClientResp, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method DeleteClient not implemented")
|
||||
}
|
||||
func (UnimplementedDexServer) CreatePassword(context.Context, *CreatePasswordReq) (*CreatePasswordResp, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method CreatePassword not implemented")
|
||||
}
|
||||
func (UnimplementedDexServer) UpdatePassword(context.Context, *UpdatePasswordReq) (*UpdatePasswordResp, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method UpdatePassword not implemented")
|
||||
}
|
||||
func (UnimplementedDexServer) DeletePassword(context.Context, *DeletePasswordReq) (*DeletePasswordResp, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method DeletePassword not implemented")
|
||||
}
|
||||
func (UnimplementedDexServer) ListPasswords(context.Context, *ListPasswordReq) (*ListPasswordResp, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ListPasswords not implemented")
|
||||
}
|
||||
func (UnimplementedDexServer) GetVersion(context.Context, *VersionReq) (*VersionResp, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetVersion not implemented")
|
||||
}
|
||||
func (UnimplementedDexServer) ListRefresh(context.Context, *ListRefreshReq) (*ListRefreshResp, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ListRefresh not implemented")
|
||||
}
|
||||
func (UnimplementedDexServer) RevokeRefresh(context.Context, *RevokeRefreshReq) (*RevokeRefreshResp, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method RevokeRefresh not implemented")
|
||||
}
|
||||
func (UnimplementedDexServer) VerifyPassword(context.Context, *VerifyPasswordReq) (*VerifyPasswordResp, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method VerifyPassword not implemented")
|
||||
}
|
||||
func (UnimplementedDexServer) mustEmbedUnimplementedDexServer() {}
|
||||
|
||||
// UnsafeDexServer may be embedded to opt out of forward compatibility for this service.
|
||||
// Use of this interface is not recommended, as added methods to DexServer will
|
||||
// result in compilation errors.
|
||||
type UnsafeDexServer interface {
|
||||
mustEmbedUnimplementedDexServer()
|
||||
}
|
||||
|
||||
func RegisterDexServer(s grpc.ServiceRegistrar, srv DexServer) {
|
||||
s.RegisterService(&Dex_ServiceDesc, srv)
|
||||
}
|
||||
|
||||
func _Dex_CreateClient_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(CreateClientReq)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(DexServer).CreateClient(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: "/api.Dex/CreateClient",
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(DexServer).CreateClient(ctx, req.(*CreateClientReq))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _Dex_UpdateClient_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(UpdateClientReq)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(DexServer).UpdateClient(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: "/api.Dex/UpdateClient",
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(DexServer).UpdateClient(ctx, req.(*UpdateClientReq))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _Dex_DeleteClient_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(DeleteClientReq)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(DexServer).DeleteClient(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: "/api.Dex/DeleteClient",
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(DexServer).DeleteClient(ctx, req.(*DeleteClientReq))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _Dex_CreatePassword_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(CreatePasswordReq)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(DexServer).CreatePassword(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: "/api.Dex/CreatePassword",
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(DexServer).CreatePassword(ctx, req.(*CreatePasswordReq))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _Dex_UpdatePassword_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(UpdatePasswordReq)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(DexServer).UpdatePassword(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: "/api.Dex/UpdatePassword",
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(DexServer).UpdatePassword(ctx, req.(*UpdatePasswordReq))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _Dex_DeletePassword_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(DeletePasswordReq)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(DexServer).DeletePassword(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: "/api.Dex/DeletePassword",
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(DexServer).DeletePassword(ctx, req.(*DeletePasswordReq))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _Dex_ListPasswords_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ListPasswordReq)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(DexServer).ListPasswords(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: "/api.Dex/ListPasswords",
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(DexServer).ListPasswords(ctx, req.(*ListPasswordReq))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _Dex_GetVersion_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(VersionReq)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(DexServer).GetVersion(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: "/api.Dex/GetVersion",
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(DexServer).GetVersion(ctx, req.(*VersionReq))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _Dex_ListRefresh_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ListRefreshReq)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(DexServer).ListRefresh(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: "/api.Dex/ListRefresh",
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(DexServer).ListRefresh(ctx, req.(*ListRefreshReq))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _Dex_RevokeRefresh_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(RevokeRefreshReq)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(DexServer).RevokeRefresh(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: "/api.Dex/RevokeRefresh",
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(DexServer).RevokeRefresh(ctx, req.(*RevokeRefreshReq))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _Dex_VerifyPassword_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(VerifyPasswordReq)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(DexServer).VerifyPassword(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: "/api.Dex/VerifyPassword",
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(DexServer).VerifyPassword(ctx, req.(*VerifyPasswordReq))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
// Dex_ServiceDesc is the grpc.ServiceDesc for Dex service.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
var Dex_ServiceDesc = grpc.ServiceDesc{
|
||||
ServiceName: "api.Dex",
|
||||
HandlerType: (*DexServer)(nil),
|
||||
Methods: []grpc.MethodDesc{
|
||||
{
|
||||
MethodName: "CreateClient",
|
||||
Handler: _Dex_CreateClient_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "UpdateClient",
|
||||
Handler: _Dex_UpdateClient_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "DeleteClient",
|
||||
Handler: _Dex_DeleteClient_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "CreatePassword",
|
||||
Handler: _Dex_CreatePassword_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "UpdatePassword",
|
||||
Handler: _Dex_UpdatePassword_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "DeletePassword",
|
||||
Handler: _Dex_DeletePassword_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ListPasswords",
|
||||
Handler: _Dex_ListPasswords_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "GetVersion",
|
||||
Handler: _Dex_GetVersion_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ListRefresh",
|
||||
Handler: _Dex_ListRefresh_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "RevokeRefresh",
|
||||
Handler: _Dex_RevokeRefresh_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "VerifyPassword",
|
||||
Handler: _Dex_VerifyPassword_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{},
|
||||
Metadata: "api/api.proto",
|
||||
}
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,189 @@
|
|||
syntax = "proto3";
|
||||
|
||||
package api;
|
||||
|
||||
option java_package = "com.coreos.dex.api";
|
||||
option go_package = "github.com/dexidp/dex/api/v2;api";
|
||||
|
||||
// Client represents an OAuth2 client.
|
||||
message Client {
|
||||
string id = 1;
|
||||
string secret = 2;
|
||||
repeated string redirect_uris = 3;
|
||||
repeated string trusted_peers = 4;
|
||||
bool public = 5;
|
||||
string name = 6;
|
||||
string logo_url = 7;
|
||||
}
|
||||
|
||||
// CreateClientReq is a request to make a client.
|
||||
message CreateClientReq {
|
||||
Client client = 1;
|
||||
}
|
||||
|
||||
// CreateClientResp returns the response from creating a client.
|
||||
message CreateClientResp {
|
||||
bool already_exists = 1;
|
||||
Client client = 2;
|
||||
}
|
||||
|
||||
// DeleteClientReq is a request to delete a client.
|
||||
message DeleteClientReq {
|
||||
// The ID of the client.
|
||||
string id = 1;
|
||||
}
|
||||
|
||||
// DeleteClientResp determines if the client is deleted successfully.
|
||||
message DeleteClientResp {
|
||||
bool not_found = 1;
|
||||
}
|
||||
|
||||
// UpdateClientReq is a request to update an existing client.
|
||||
message UpdateClientReq {
|
||||
string id = 1;
|
||||
repeated string redirect_uris = 2;
|
||||
repeated string trusted_peers = 3;
|
||||
string name = 4;
|
||||
string logo_url = 5;
|
||||
}
|
||||
|
||||
// UpdateClientResp returns the response from updating a client.
|
||||
message UpdateClientResp {
|
||||
bool not_found = 1;
|
||||
}
|
||||
|
||||
// TODO(ericchiang): expand this.
|
||||
|
||||
// Password is an email for password mapping managed by the storage.
|
||||
message Password {
|
||||
string email = 1;
|
||||
|
||||
// Currently we do not accept plain text passwords. Could be an option in the future.
|
||||
bytes hash = 2;
|
||||
string username = 3;
|
||||
string user_id = 4;
|
||||
}
|
||||
|
||||
// CreatePasswordReq is a request to make a password.
|
||||
message CreatePasswordReq {
|
||||
Password password = 1;
|
||||
}
|
||||
|
||||
// CreatePasswordResp returns the response from creating a password.
|
||||
message CreatePasswordResp {
|
||||
bool already_exists = 1;
|
||||
}
|
||||
|
||||
// UpdatePasswordReq is a request to modify an existing password.
|
||||
message UpdatePasswordReq {
|
||||
// The email used to lookup the password. This field cannot be modified
|
||||
string email = 1;
|
||||
bytes new_hash = 2;
|
||||
string new_username = 3;
|
||||
}
|
||||
|
||||
// UpdatePasswordResp returns the response from modifying an existing password.
|
||||
message UpdatePasswordResp {
|
||||
bool not_found = 1;
|
||||
}
|
||||
|
||||
// DeletePasswordReq is a request to delete a password.
|
||||
message DeletePasswordReq {
|
||||
string email = 1;
|
||||
}
|
||||
|
||||
// DeletePasswordResp returns the response from deleting a password.
|
||||
message DeletePasswordResp {
|
||||
bool not_found = 1;
|
||||
}
|
||||
|
||||
// ListPasswordReq is a request to enumerate passwords.
|
||||
message ListPasswordReq {}
|
||||
|
||||
// ListPasswordResp returns a list of passwords.
|
||||
message ListPasswordResp {
|
||||
repeated Password passwords = 1;
|
||||
}
|
||||
|
||||
// VersionReq is a request to fetch version info.
|
||||
message VersionReq {}
|
||||
|
||||
// VersionResp holds the version info of components.
|
||||
message VersionResp {
|
||||
// Semantic version of the server.
|
||||
string server = 1;
|
||||
// Numeric version of the API. It increases everytime a new call is added to the API.
|
||||
// Clients should use this info to determine if the server supports specific features.
|
||||
int32 api = 2;
|
||||
}
|
||||
|
||||
// RefreshTokenRef contains the metadata for a refresh token that is managed by the storage.
|
||||
message RefreshTokenRef {
|
||||
// ID of the refresh token.
|
||||
string id = 1;
|
||||
string client_id = 2;
|
||||
int64 created_at = 5;
|
||||
int64 last_used = 6;
|
||||
}
|
||||
|
||||
// ListRefreshReq is a request to enumerate the refresh tokens of a user.
|
||||
message ListRefreshReq {
|
||||
// The "sub" claim returned in the ID Token.
|
||||
string user_id = 1;
|
||||
}
|
||||
|
||||
// ListRefreshResp returns a list of refresh tokens for a user.
|
||||
message ListRefreshResp {
|
||||
repeated RefreshTokenRef refresh_tokens = 1;
|
||||
}
|
||||
|
||||
// RevokeRefreshReq is a request to revoke the refresh token of the user-client pair.
|
||||
message RevokeRefreshReq {
|
||||
// The "sub" claim returned in the ID Token.
|
||||
string user_id = 1;
|
||||
string client_id = 2;
|
||||
}
|
||||
|
||||
// RevokeRefreshResp determines if the refresh token is revoked successfully.
|
||||
message RevokeRefreshResp {
|
||||
// Set to true is refresh token was not found and token could not be revoked.
|
||||
bool not_found = 1;
|
||||
}
|
||||
|
||||
message VerifyPasswordReq {
|
||||
string email = 1;
|
||||
string password = 2;
|
||||
}
|
||||
|
||||
message VerifyPasswordResp {
|
||||
bool verified = 1;
|
||||
bool not_found = 2;
|
||||
}
|
||||
|
||||
// Dex represents the dex gRPC service.
|
||||
service Dex {
|
||||
// CreateClient creates a client.
|
||||
rpc CreateClient(CreateClientReq) returns (CreateClientResp) {};
|
||||
// UpdateClient updates an existing client
|
||||
rpc UpdateClient(UpdateClientReq) returns (UpdateClientResp) {};
|
||||
// DeleteClient deletes the provided client.
|
||||
rpc DeleteClient(DeleteClientReq) returns (DeleteClientResp) {};
|
||||
// CreatePassword creates a password.
|
||||
rpc CreatePassword(CreatePasswordReq) returns (CreatePasswordResp) {};
|
||||
// UpdatePassword modifies existing password.
|
||||
rpc UpdatePassword(UpdatePasswordReq) returns (UpdatePasswordResp) {};
|
||||
// DeletePassword deletes the password.
|
||||
rpc DeletePassword(DeletePasswordReq) returns (DeletePasswordResp) {};
|
||||
// ListPassword lists all password entries.
|
||||
rpc ListPasswords(ListPasswordReq) returns (ListPasswordResp) {};
|
||||
// GetVersion returns version information of the server.
|
||||
rpc GetVersion(VersionReq) returns (VersionResp) {};
|
||||
// ListRefresh lists all the refresh token entries for a particular user.
|
||||
rpc ListRefresh(ListRefreshReq) returns (ListRefreshResp) {};
|
||||
// RevokeRefresh revokes the refresh token for the provided user-client pair.
|
||||
//
|
||||
// Note that each user-client pair can have only one refresh token at a time.
|
||||
rpc RevokeRefresh(RevokeRefreshReq) returns (RevokeRefreshResp) {};
|
||||
// VerifyPassword returns whether a password matches a hash for a specific email or not.
|
||||
rpc VerifyPassword(VerifyPasswordReq) returns (VerifyPasswordResp) {};
|
||||
}
|
|
@ -0,0 +1,487 @@
|
|||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
context "context"
|
||||
grpc "google.golang.org/grpc"
|
||||
codes "google.golang.org/grpc/codes"
|
||||
status "google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// This is a compile-time assertion to ensure that this generated file
|
||||
// is compatible with the grpc package it is being compiled against.
|
||||
// Requires gRPC-Go v1.32.0 or later.
|
||||
const _ = grpc.SupportPackageIsVersion7
|
||||
|
||||
// DexClient is the client API for Dex service.
|
||||
//
|
||||
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
|
||||
type DexClient interface {
|
||||
// CreateClient creates a client.
|
||||
CreateClient(ctx context.Context, in *CreateClientReq, opts ...grpc.CallOption) (*CreateClientResp, error)
|
||||
// UpdateClient updates an existing client
|
||||
UpdateClient(ctx context.Context, in *UpdateClientReq, opts ...grpc.CallOption) (*UpdateClientResp, error)
|
||||
// DeleteClient deletes the provided client.
|
||||
DeleteClient(ctx context.Context, in *DeleteClientReq, opts ...grpc.CallOption) (*DeleteClientResp, error)
|
||||
// CreatePassword creates a password.
|
||||
CreatePassword(ctx context.Context, in *CreatePasswordReq, opts ...grpc.CallOption) (*CreatePasswordResp, error)
|
||||
// UpdatePassword modifies existing password.
|
||||
UpdatePassword(ctx context.Context, in *UpdatePasswordReq, opts ...grpc.CallOption) (*UpdatePasswordResp, error)
|
||||
// DeletePassword deletes the password.
|
||||
DeletePassword(ctx context.Context, in *DeletePasswordReq, opts ...grpc.CallOption) (*DeletePasswordResp, error)
|
||||
// ListPassword lists all password entries.
|
||||
ListPasswords(ctx context.Context, in *ListPasswordReq, opts ...grpc.CallOption) (*ListPasswordResp, error)
|
||||
// GetVersion returns version information of the server.
|
||||
GetVersion(ctx context.Context, in *VersionReq, opts ...grpc.CallOption) (*VersionResp, error)
|
||||
// ListRefresh lists all the refresh token entries for a particular user.
|
||||
ListRefresh(ctx context.Context, in *ListRefreshReq, opts ...grpc.CallOption) (*ListRefreshResp, error)
|
||||
// RevokeRefresh revokes the refresh token for the provided user-client pair.
|
||||
//
|
||||
// Note that each user-client pair can have only one refresh token at a time.
|
||||
RevokeRefresh(ctx context.Context, in *RevokeRefreshReq, opts ...grpc.CallOption) (*RevokeRefreshResp, error)
|
||||
// VerifyPassword returns whether a password matches a hash for a specific email or not.
|
||||
VerifyPassword(ctx context.Context, in *VerifyPasswordReq, opts ...grpc.CallOption) (*VerifyPasswordResp, error)
|
||||
}
|
||||
|
||||
type dexClient struct {
|
||||
cc grpc.ClientConnInterface
|
||||
}
|
||||
|
||||
func NewDexClient(cc grpc.ClientConnInterface) DexClient {
|
||||
return &dexClient{cc}
|
||||
}
|
||||
|
||||
func (c *dexClient) CreateClient(ctx context.Context, in *CreateClientReq, opts ...grpc.CallOption) (*CreateClientResp, error) {
|
||||
out := new(CreateClientResp)
|
||||
err := c.cc.Invoke(ctx, "/api.Dex/CreateClient", in, out, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *dexClient) UpdateClient(ctx context.Context, in *UpdateClientReq, opts ...grpc.CallOption) (*UpdateClientResp, error) {
|
||||
out := new(UpdateClientResp)
|
||||
err := c.cc.Invoke(ctx, "/api.Dex/UpdateClient", in, out, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *dexClient) DeleteClient(ctx context.Context, in *DeleteClientReq, opts ...grpc.CallOption) (*DeleteClientResp, error) {
|
||||
out := new(DeleteClientResp)
|
||||
err := c.cc.Invoke(ctx, "/api.Dex/DeleteClient", in, out, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *dexClient) CreatePassword(ctx context.Context, in *CreatePasswordReq, opts ...grpc.CallOption) (*CreatePasswordResp, error) {
|
||||
out := new(CreatePasswordResp)
|
||||
err := c.cc.Invoke(ctx, "/api.Dex/CreatePassword", in, out, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *dexClient) UpdatePassword(ctx context.Context, in *UpdatePasswordReq, opts ...grpc.CallOption) (*UpdatePasswordResp, error) {
|
||||
out := new(UpdatePasswordResp)
|
||||
err := c.cc.Invoke(ctx, "/api.Dex/UpdatePassword", in, out, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *dexClient) DeletePassword(ctx context.Context, in *DeletePasswordReq, opts ...grpc.CallOption) (*DeletePasswordResp, error) {
|
||||
out := new(DeletePasswordResp)
|
||||
err := c.cc.Invoke(ctx, "/api.Dex/DeletePassword", in, out, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *dexClient) ListPasswords(ctx context.Context, in *ListPasswordReq, opts ...grpc.CallOption) (*ListPasswordResp, error) {
|
||||
out := new(ListPasswordResp)
|
||||
err := c.cc.Invoke(ctx, "/api.Dex/ListPasswords", in, out, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *dexClient) GetVersion(ctx context.Context, in *VersionReq, opts ...grpc.CallOption) (*VersionResp, error) {
|
||||
out := new(VersionResp)
|
||||
err := c.cc.Invoke(ctx, "/api.Dex/GetVersion", in, out, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *dexClient) ListRefresh(ctx context.Context, in *ListRefreshReq, opts ...grpc.CallOption) (*ListRefreshResp, error) {
|
||||
out := new(ListRefreshResp)
|
||||
err := c.cc.Invoke(ctx, "/api.Dex/ListRefresh", in, out, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *dexClient) RevokeRefresh(ctx context.Context, in *RevokeRefreshReq, opts ...grpc.CallOption) (*RevokeRefreshResp, error) {
|
||||
out := new(RevokeRefreshResp)
|
||||
err := c.cc.Invoke(ctx, "/api.Dex/RevokeRefresh", in, out, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *dexClient) VerifyPassword(ctx context.Context, in *VerifyPasswordReq, opts ...grpc.CallOption) (*VerifyPasswordResp, error) {
|
||||
out := new(VerifyPasswordResp)
|
||||
err := c.cc.Invoke(ctx, "/api.Dex/VerifyPassword", in, out, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DexServer is the server API for Dex service.
|
||||
// All implementations must embed UnimplementedDexServer
|
||||
// for forward compatibility
|
||||
type DexServer interface {
|
||||
// CreateClient creates a client.
|
||||
CreateClient(context.Context, *CreateClientReq) (*CreateClientResp, error)
|
||||
// UpdateClient updates an existing client
|
||||
UpdateClient(context.Context, *UpdateClientReq) (*UpdateClientResp, error)
|
||||
// DeleteClient deletes the provided client.
|
||||
DeleteClient(context.Context, *DeleteClientReq) (*DeleteClientResp, error)
|
||||
// CreatePassword creates a password.
|
||||
CreatePassword(context.Context, *CreatePasswordReq) (*CreatePasswordResp, error)
|
||||
// UpdatePassword modifies existing password.
|
||||
UpdatePassword(context.Context, *UpdatePasswordReq) (*UpdatePasswordResp, error)
|
||||
// DeletePassword deletes the password.
|
||||
DeletePassword(context.Context, *DeletePasswordReq) (*DeletePasswordResp, error)
|
||||
// ListPassword lists all password entries.
|
||||
ListPasswords(context.Context, *ListPasswordReq) (*ListPasswordResp, error)
|
||||
// GetVersion returns version information of the server.
|
||||
GetVersion(context.Context, *VersionReq) (*VersionResp, error)
|
||||
// ListRefresh lists all the refresh token entries for a particular user.
|
||||
ListRefresh(context.Context, *ListRefreshReq) (*ListRefreshResp, error)
|
||||
// RevokeRefresh revokes the refresh token for the provided user-client pair.
|
||||
//
|
||||
// Note that each user-client pair can have only one refresh token at a time.
|
||||
RevokeRefresh(context.Context, *RevokeRefreshReq) (*RevokeRefreshResp, error)
|
||||
// VerifyPassword returns whether a password matches a hash for a specific email or not.
|
||||
VerifyPassword(context.Context, *VerifyPasswordReq) (*VerifyPasswordResp, error)
|
||||
mustEmbedUnimplementedDexServer()
|
||||
}
|
||||
|
||||
// UnimplementedDexServer must be embedded to have forward compatible implementations.
|
||||
type UnimplementedDexServer struct {
|
||||
}
|
||||
|
||||
func (UnimplementedDexServer) CreateClient(context.Context, *CreateClientReq) (*CreateClientResp, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method CreateClient not implemented")
|
||||
}
|
||||
func (UnimplementedDexServer) UpdateClient(context.Context, *UpdateClientReq) (*UpdateClientResp, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method UpdateClient not implemented")
|
||||
}
|
||||
func (UnimplementedDexServer) DeleteClient(context.Context, *DeleteClientReq) (*DeleteClientResp, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method DeleteClient not implemented")
|
||||
}
|
||||
func (UnimplementedDexServer) CreatePassword(context.Context, *CreatePasswordReq) (*CreatePasswordResp, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method CreatePassword not implemented")
|
||||
}
|
||||
func (UnimplementedDexServer) UpdatePassword(context.Context, *UpdatePasswordReq) (*UpdatePasswordResp, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method UpdatePassword not implemented")
|
||||
}
|
||||
func (UnimplementedDexServer) DeletePassword(context.Context, *DeletePasswordReq) (*DeletePasswordResp, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method DeletePassword not implemented")
|
||||
}
|
||||
func (UnimplementedDexServer) ListPasswords(context.Context, *ListPasswordReq) (*ListPasswordResp, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ListPasswords not implemented")
|
||||
}
|
||||
func (UnimplementedDexServer) GetVersion(context.Context, *VersionReq) (*VersionResp, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetVersion not implemented")
|
||||
}
|
||||
func (UnimplementedDexServer) ListRefresh(context.Context, *ListRefreshReq) (*ListRefreshResp, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ListRefresh not implemented")
|
||||
}
|
||||
func (UnimplementedDexServer) RevokeRefresh(context.Context, *RevokeRefreshReq) (*RevokeRefreshResp, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method RevokeRefresh not implemented")
|
||||
}
|
||||
func (UnimplementedDexServer) VerifyPassword(context.Context, *VerifyPasswordReq) (*VerifyPasswordResp, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method VerifyPassword not implemented")
|
||||
}
|
||||
func (UnimplementedDexServer) mustEmbedUnimplementedDexServer() {}
|
||||
|
||||
// UnsafeDexServer may be embedded to opt out of forward compatibility for this service.
|
||||
// Use of this interface is not recommended, as added methods to DexServer will
|
||||
// result in compilation errors.
|
||||
type UnsafeDexServer interface {
|
||||
mustEmbedUnimplementedDexServer()
|
||||
}
|
||||
|
||||
func RegisterDexServer(s grpc.ServiceRegistrar, srv DexServer) {
|
||||
s.RegisterService(&Dex_ServiceDesc, srv)
|
||||
}
|
||||
|
||||
func _Dex_CreateClient_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(CreateClientReq)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(DexServer).CreateClient(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: "/api.Dex/CreateClient",
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(DexServer).CreateClient(ctx, req.(*CreateClientReq))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _Dex_UpdateClient_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(UpdateClientReq)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(DexServer).UpdateClient(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: "/api.Dex/UpdateClient",
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(DexServer).UpdateClient(ctx, req.(*UpdateClientReq))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _Dex_DeleteClient_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(DeleteClientReq)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(DexServer).DeleteClient(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: "/api.Dex/DeleteClient",
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(DexServer).DeleteClient(ctx, req.(*DeleteClientReq))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _Dex_CreatePassword_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(CreatePasswordReq)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(DexServer).CreatePassword(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: "/api.Dex/CreatePassword",
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(DexServer).CreatePassword(ctx, req.(*CreatePasswordReq))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _Dex_UpdatePassword_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(UpdatePasswordReq)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(DexServer).UpdatePassword(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: "/api.Dex/UpdatePassword",
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(DexServer).UpdatePassword(ctx, req.(*UpdatePasswordReq))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _Dex_DeletePassword_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(DeletePasswordReq)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(DexServer).DeletePassword(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: "/api.Dex/DeletePassword",
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(DexServer).DeletePassword(ctx, req.(*DeletePasswordReq))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _Dex_ListPasswords_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ListPasswordReq)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(DexServer).ListPasswords(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: "/api.Dex/ListPasswords",
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(DexServer).ListPasswords(ctx, req.(*ListPasswordReq))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _Dex_GetVersion_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(VersionReq)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(DexServer).GetVersion(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: "/api.Dex/GetVersion",
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(DexServer).GetVersion(ctx, req.(*VersionReq))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _Dex_ListRefresh_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ListRefreshReq)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(DexServer).ListRefresh(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: "/api.Dex/ListRefresh",
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(DexServer).ListRefresh(ctx, req.(*ListRefreshReq))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _Dex_RevokeRefresh_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(RevokeRefreshReq)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(DexServer).RevokeRefresh(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: "/api.Dex/RevokeRefresh",
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(DexServer).RevokeRefresh(ctx, req.(*RevokeRefreshReq))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _Dex_VerifyPassword_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(VerifyPasswordReq)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(DexServer).VerifyPassword(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: "/api.Dex/VerifyPassword",
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(DexServer).VerifyPassword(ctx, req.(*VerifyPasswordReq))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
// Dex_ServiceDesc is the grpc.ServiceDesc for Dex service.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
var Dex_ServiceDesc = grpc.ServiceDesc{
|
||||
ServiceName: "api.Dex",
|
||||
HandlerType: (*DexServer)(nil),
|
||||
Methods: []grpc.MethodDesc{
|
||||
{
|
||||
MethodName: "CreateClient",
|
||||
Handler: _Dex_CreateClient_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "UpdateClient",
|
||||
Handler: _Dex_UpdateClient_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "DeleteClient",
|
||||
Handler: _Dex_DeleteClient_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "CreatePassword",
|
||||
Handler: _Dex_CreatePassword_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "UpdatePassword",
|
||||
Handler: _Dex_UpdatePassword_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "DeletePassword",
|
||||
Handler: _Dex_DeletePassword_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ListPasswords",
|
||||
Handler: _Dex_ListPasswords_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "GetVersion",
|
||||
Handler: _Dex_GetVersion_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ListRefresh",
|
||||
Handler: _Dex_ListRefresh_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "RevokeRefresh",
|
||||
Handler: _Dex_RevokeRefresh_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "VerifyPassword",
|
||||
Handler: _Dex_VerifyPassword_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{},
|
||||
Metadata: "api/v2/api.proto",
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
module github.com/dexidp/dex/api/v2
|
||||
|
||||
go 1.17
|
||||
|
||||
require (
|
||||
google.golang.org/grpc v1.47.0
|
||||
google.golang.org/protobuf v1.28.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/golang/protobuf v1.5.2 // indirect
|
||||
golang.org/x/net v0.0.0-20220607020251-c690dde0001d // indirect
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a // indirect
|
||||
golang.org/x/text v0.3.7 // indirect
|
||||
google.golang.org/genproto v0.0.0-20220602131408-e326c6e8e9c8 // indirect
|
||||
)
|
|
@ -0,0 +1,141 @@
|
|||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
|
||||
github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI=
|
||||
github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
|
||||
github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
|
||||
github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
|
||||
github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1/go.mod h1:KJwIaB5Mv44NWtYuAOFCVOjcI94vtpEz2JU/D2v6IjE=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
|
||||
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
|
||||
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ=
|
||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
||||
golang.org/x/net v0.0.0-20220607020251-c690dde0001d h1:4SFsTMi4UahlKoloni7L4eYzhFRifURQLw+yv0QDCx8=
|
||||
golang.org/x/net v0.0.0-20220607020251-c690dde0001d/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/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-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a h1:dGzPydgVsqGcTRVwiLJ1jVbufYwmzD3LfVPLKsKg+0k=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
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/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.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||
google.golang.org/genproto v0.0.0-20220602131408-e326c6e8e9c8 h1:qRu95HZ148xXw+XeZ3dvqe85PxH4X8+jIo0iRPKcEnM=
|
||||
google.golang.org/genproto v0.0.0-20220602131408-e326c6e8e9c8/go.mod h1:yKyY4AMRwFiC8yMMNaMi+RkCnjZJt9LoWuvhXjMs+To=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||
google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0=
|
||||
google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
|
||||
google.golang.org/grpc v1.46.2/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
|
||||
google.golang.org/grpc v1.47.0 h1:9n77onPX5F3qfFCqjy9dhn8PbNQsIKeVU04J9G7umt8=
|
||||
google.golang.org/grpc v1.47.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw=
|
||||
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
|
@ -5,37 +5,38 @@ import (
|
|||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/Sirupsen/logrus"
|
||||
"github.com/coreos/dex/connector"
|
||||
"github.com/coreos/dex/connector/github"
|
||||
"github.com/coreos/dex/connector/gitlab"
|
||||
"github.com/coreos/dex/connector/ldap"
|
||||
"github.com/coreos/dex/connector/mock"
|
||||
"github.com/coreos/dex/connector/oidc"
|
||||
"github.com/coreos/dex/connector/saml"
|
||||
"github.com/coreos/dex/server"
|
||||
"github.com/coreos/dex/storage"
|
||||
"github.com/coreos/dex/storage/kubernetes"
|
||||
"github.com/coreos/dex/storage/memory"
|
||||
"github.com/coreos/dex/storage/sql"
|
||||
"github.com/dexidp/dex/pkg/log"
|
||||
"github.com/dexidp/dex/server"
|
||||
"github.com/dexidp/dex/storage"
|
||||
"github.com/dexidp/dex/storage/ent"
|
||||
"github.com/dexidp/dex/storage/etcd"
|
||||
"github.com/dexidp/dex/storage/kubernetes"
|
||||
"github.com/dexidp/dex/storage/memory"
|
||||
"github.com/dexidp/dex/storage/sql"
|
||||
)
|
||||
|
||||
// Config is the config format for the main application.
|
||||
type Config struct {
|
||||
Issuer string `json:"issuer"`
|
||||
Storage Storage `json:"storage"`
|
||||
Connectors []Connector `json:"connectors"`
|
||||
Web Web `json:"web"`
|
||||
OAuth2 OAuth2 `json:"oauth2"`
|
||||
GRPC GRPC `json:"grpc"`
|
||||
Expiry Expiry `json:"expiry"`
|
||||
Logger Logger `json:"logger"`
|
||||
Issuer string `json:"issuer"`
|
||||
Storage Storage `json:"storage"`
|
||||
Web Web `json:"web"`
|
||||
Telemetry Telemetry `json:"telemetry"`
|
||||
OAuth2 OAuth2 `json:"oauth2"`
|
||||
GRPC GRPC `json:"grpc"`
|
||||
Expiry Expiry `json:"expiry"`
|
||||
Logger Logger `json:"logger"`
|
||||
|
||||
Frontend server.WebConfig `json:"frontend"`
|
||||
|
||||
// StaticConnectors are user defined connectors specified in the ConfigMap
|
||||
// Write operations, like updating a connector, will fail.
|
||||
StaticConnectors []Connector `json:"connectors"`
|
||||
|
||||
// StaticClients cause the server to use this list of clients rather than
|
||||
// querying the storage. Write operations, like creating a client, will fail.
|
||||
StaticClients []storage.Client `json:"staticClients"`
|
||||
|
@ -50,14 +51,47 @@ type Config struct {
|
|||
StaticPasswords []password `json:"staticPasswords"`
|
||||
}
|
||||
|
||||
// Validate the configuration
|
||||
func (c Config) Validate() error {
|
||||
// Fast checks. Perform these first for a more responsive CLI.
|
||||
checks := []struct {
|
||||
bad bool
|
||||
errMsg string
|
||||
}{
|
||||
{c.Issuer == "", "no issuer specified in config file"},
|
||||
{!c.EnablePasswordDB && len(c.StaticPasswords) != 0, "cannot specify static passwords without enabling password db"},
|
||||
{c.Storage.Config == nil, "no storage supplied in config file"},
|
||||
{c.Web.HTTP == "" && c.Web.HTTPS == "", "must supply a HTTP/HTTPS address to listen on"},
|
||||
{c.Web.HTTPS != "" && c.Web.TLSCert == "", "no cert specified for HTTPS"},
|
||||
{c.Web.HTTPS != "" && c.Web.TLSKey == "", "no private key specified for HTTPS"},
|
||||
{c.GRPC.TLSCert != "" && c.GRPC.Addr == "", "no address specified for gRPC"},
|
||||
{c.GRPC.TLSKey != "" && c.GRPC.Addr == "", "no address specified for gRPC"},
|
||||
{(c.GRPC.TLSCert == "") != (c.GRPC.TLSKey == ""), "must specific both a gRPC TLS cert and key"},
|
||||
{c.GRPC.TLSCert == "" && c.GRPC.TLSClientCA != "", "cannot specify gRPC TLS client CA without a gRPC TLS cert"},
|
||||
}
|
||||
|
||||
var checkErrors []string
|
||||
|
||||
for _, check := range checks {
|
||||
if check.bad {
|
||||
checkErrors = append(checkErrors, check.errMsg)
|
||||
}
|
||||
}
|
||||
if len(checkErrors) != 0 {
|
||||
return fmt.Errorf("invalid Config:\n\t-\t%s", strings.Join(checkErrors, "\n\t-\t"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type password storage.Password
|
||||
|
||||
func (p *password) UnmarshalJSON(b []byte) error {
|
||||
var data struct {
|
||||
Email string `json:"email"`
|
||||
Username string `json:"username"`
|
||||
UserID string `json:"userID"`
|
||||
Hash string `json:"hash"`
|
||||
Email string `json:"email"`
|
||||
Username string `json:"username"`
|
||||
UserID string `json:"userID"`
|
||||
Hash string `json:"hash"`
|
||||
HashFromEnv string `json:"hashFromEnv"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &data); err != nil {
|
||||
return err
|
||||
|
@ -67,6 +101,9 @@ func (p *password) UnmarshalJSON(b []byte) error {
|
|||
Username: data.Username,
|
||||
UserID: data.UserID,
|
||||
})
|
||||
if len(data.Hash) == 0 && len(data.HashFromEnv) > 0 {
|
||||
data.Hash = os.Getenv(data.HashFromEnv)
|
||||
}
|
||||
if len(data.Hash) == 0 {
|
||||
return fmt.Errorf("no password hash provided")
|
||||
}
|
||||
|
@ -96,6 +133,10 @@ type OAuth2 struct {
|
|||
// If specified, do not prompt the user to approve client authorization. The
|
||||
// act of logging in implies authorization.
|
||||
SkipApprovalScreen bool `json:"skipApprovalScreen"`
|
||||
// If specified, show the connector selection screen even if there's only one
|
||||
AlwaysShowLoginScreen bool `json:"alwaysShowLoginScreen"`
|
||||
// This is the connector that can be used for password grant
|
||||
PasswordConnector string `json:"passwordConnector"`
|
||||
}
|
||||
|
||||
// Web is the config format for the HTTP server.
|
||||
|
@ -107,6 +148,13 @@ type Web struct {
|
|||
AllowedOrigins []string `json:"allowedOrigins"`
|
||||
}
|
||||
|
||||
// Telemetry is the config format for telemetry including the HTTP server config.
|
||||
type Telemetry struct {
|
||||
HTTP string `json:"http"`
|
||||
// EnableProfiling makes profiling endpoints available via web interface host:port/debug/pprof/
|
||||
EnableProfiling bool `json:"enableProfiling"`
|
||||
}
|
||||
|
||||
// GRPC is the config for the gRPC API.
|
||||
type GRPC struct {
|
||||
// The port to listen on.
|
||||
|
@ -114,6 +162,7 @@ type GRPC struct {
|
|||
TLSCert string `json:"tlsCert"`
|
||||
TLSKey string `json:"tlsKey"`
|
||||
TLSClientCA string `json:"tlsClientCA"`
|
||||
Reflection bool `json:"reflection"`
|
||||
}
|
||||
|
||||
// Storage holds app's storage configuration.
|
||||
|
@ -124,14 +173,52 @@ type Storage struct {
|
|||
|
||||
// StorageConfig is a configuration that can create a storage.
|
||||
type StorageConfig interface {
|
||||
Open(logrus.FieldLogger) (storage.Storage, error)
|
||||
Open(logger log.Logger) (storage.Storage, error)
|
||||
}
|
||||
|
||||
var (
|
||||
_ StorageConfig = (*etcd.Etcd)(nil)
|
||||
_ StorageConfig = (*kubernetes.Config)(nil)
|
||||
_ StorageConfig = (*memory.Config)(nil)
|
||||
_ StorageConfig = (*sql.SQLite3)(nil)
|
||||
_ StorageConfig = (*sql.Postgres)(nil)
|
||||
_ StorageConfig = (*sql.MySQL)(nil)
|
||||
_ StorageConfig = (*ent.SQLite3)(nil)
|
||||
_ StorageConfig = (*ent.Postgres)(nil)
|
||||
_ StorageConfig = (*ent.MySQL)(nil)
|
||||
)
|
||||
|
||||
func getORMBasedSQLStorage(normal, entBased StorageConfig) func() StorageConfig {
|
||||
return func() StorageConfig {
|
||||
switch os.Getenv("DEX_ENT_ENABLED") {
|
||||
case "true", "yes":
|
||||
return entBased
|
||||
default:
|
||||
return normal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var storages = map[string]func() StorageConfig{
|
||||
"etcd": func() StorageConfig { return new(etcd.Etcd) },
|
||||
"kubernetes": func() StorageConfig { return new(kubernetes.Config) },
|
||||
"memory": func() StorageConfig { return new(memory.Config) },
|
||||
"sqlite3": func() StorageConfig { return new(sql.SQLite3) },
|
||||
"postgres": func() StorageConfig { return new(sql.Postgres) },
|
||||
"sqlite3": getORMBasedSQLStorage(&sql.SQLite3{}, &ent.SQLite3{}),
|
||||
"postgres": getORMBasedSQLStorage(&sql.Postgres{}, &ent.Postgres{}),
|
||||
"mysql": getORMBasedSQLStorage(&sql.MySQL{}, &ent.MySQL{}),
|
||||
}
|
||||
|
||||
// isExpandEnvEnabled returns if os.ExpandEnv should be used for each storage and connector config.
|
||||
// Disabling this feature avoids surprises e.g. if the LDAP bind password contains a dollar character.
|
||||
// Returns false if the env variable "DEX_EXPAND_ENV" is a falsy string, e.g. "false".
|
||||
// Returns true if the env variable is unset or a truthy string, e.g. "true", or can't be parsed as bool.
|
||||
func isExpandEnvEnabled() bool {
|
||||
enabled, err := strconv.ParseBool(os.Getenv("DEX_EXPAND_ENV"))
|
||||
if err != nil {
|
||||
// Unset, empty string or can't be parsed as bool: Default = true.
|
||||
return true
|
||||
}
|
||||
return enabled
|
||||
}
|
||||
|
||||
// UnmarshalJSON allows Storage to implement the unmarshaler interface to
|
||||
|
@ -151,9 +238,13 @@ func (s *Storage) UnmarshalJSON(b []byte) error {
|
|||
|
||||
storageConfig := f()
|
||||
if len(store.Config) != 0 {
|
||||
data := []byte(os.ExpandEnv(string(store.Config)))
|
||||
data := []byte(store.Config)
|
||||
if isExpandEnvEnabled() {
|
||||
// Caution, we're expanding in the raw JSON/YAML source. This may not be what the admin expects.
|
||||
data = []byte(os.ExpandEnv(string(store.Config)))
|
||||
}
|
||||
if err := json.Unmarshal(data, storageConfig); err != nil {
|
||||
return fmt.Errorf("parse storace config: %v", err)
|
||||
return fmt.Errorf("parse storage config: %v", err)
|
||||
}
|
||||
}
|
||||
*s = Storage{
|
||||
|
@ -170,22 +261,7 @@ type Connector struct {
|
|||
Name string `json:"name"`
|
||||
ID string `json:"id"`
|
||||
|
||||
Config ConnectorConfig `json:"config"`
|
||||
}
|
||||
|
||||
// ConnectorConfig is a configuration that can open a connector.
|
||||
type ConnectorConfig interface {
|
||||
Open(logrus.FieldLogger) (connector.Connector, error)
|
||||
}
|
||||
|
||||
var connectors = map[string]func() ConnectorConfig{
|
||||
"mockCallback": func() ConnectorConfig { return new(mock.CallbackConfig) },
|
||||
"mockPassword": func() ConnectorConfig { return new(mock.PasswordConfig) },
|
||||
"ldap": func() ConnectorConfig { return new(ldap.Config) },
|
||||
"github": func() ConnectorConfig { return new(github.Config) },
|
||||
"gitlab": func() ConnectorConfig { return new(gitlab.Config) },
|
||||
"oidc": func() ConnectorConfig { return new(oidc.Config) },
|
||||
"samlExperimental": func() ConnectorConfig { return new(saml.Config) },
|
||||
Config server.ConnectorConfig `json:"config"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON allows Connector to implement the unmarshaler interface to
|
||||
|
@ -201,14 +277,18 @@ func (c *Connector) UnmarshalJSON(b []byte) error {
|
|||
if err := json.Unmarshal(b, &conn); err != nil {
|
||||
return fmt.Errorf("parse connector: %v", err)
|
||||
}
|
||||
f, ok := connectors[conn.Type]
|
||||
f, ok := server.ConnectorsConfig[conn.Type]
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown connector type %q", conn.Type)
|
||||
}
|
||||
|
||||
connConfig := f()
|
||||
if len(conn.Config) != 0 {
|
||||
data := []byte(os.ExpandEnv(string(conn.Config)))
|
||||
data := []byte(conn.Config)
|
||||
if isExpandEnvEnabled() {
|
||||
// Caution, we're expanding in the raw JSON/YAML source. This may not be what the admin expects.
|
||||
data = []byte(os.ExpandEnv(string(conn.Config)))
|
||||
}
|
||||
if err := json.Unmarshal(data, connConfig); err != nil {
|
||||
return fmt.Errorf("parse connector config: %v", err)
|
||||
}
|
||||
|
@ -222,6 +302,21 @@ func (c *Connector) UnmarshalJSON(b []byte) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// ToStorageConnector converts an object to storage connector type.
|
||||
func ToStorageConnector(c Connector) (storage.Connector, error) {
|
||||
data, err := json.Marshal(c.Config)
|
||||
if err != nil {
|
||||
return storage.Connector{}, fmt.Errorf("failed to marshal connector config: %v", err)
|
||||
}
|
||||
|
||||
return storage.Connector{
|
||||
ID: c.ID,
|
||||
Type: c.Type,
|
||||
Name: c.Name,
|
||||
Config: data,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Expiry holds configuration for the validity period of components.
|
||||
type Expiry struct {
|
||||
// SigningKeys defines the duration of time after which the SigningKeys will be rotated.
|
||||
|
@ -229,6 +324,15 @@ type Expiry struct {
|
|||
|
||||
// IdTokens defines the duration of time for which the IdTokens will be valid.
|
||||
IDTokens string `json:"idTokens"`
|
||||
|
||||
// AuthRequests defines the duration of time for which the AuthRequests will be valid.
|
||||
AuthRequests string `json:"authRequests"`
|
||||
|
||||
// DeviceRequests defines the duration of time for which the DeviceRequests will be valid.
|
||||
DeviceRequests string `json:"deviceRequests"`
|
||||
|
||||
// RefreshTokens defines refresh tokens expiry policy
|
||||
RefreshTokens RefreshToken `json:"refreshTokens"`
|
||||
}
|
||||
|
||||
// Logger holds configuration required to customize logging for dex.
|
||||
|
@ -239,3 +343,10 @@ type Logger struct {
|
|||
// Format specifies the format to be used for logging.
|
||||
Format string `json:"format"`
|
||||
}
|
||||
|
||||
type RefreshToken struct {
|
||||
DisableRotation bool `json:"disableRotation"`
|
||||
ReuseInterval string `json:"reuseInterval"`
|
||||
AbsoluteLifetime string `json:"absoluteLifetime"`
|
||||
ValidIfNotUsedFor string `json:"validIfNotUsedFor"`
|
||||
}
|
||||
|
|
|
@ -1,28 +1,83 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/coreos/dex/connector/mock"
|
||||
"github.com/coreos/dex/connector/oidc"
|
||||
"github.com/coreos/dex/storage"
|
||||
"github.com/coreos/dex/storage/sql"
|
||||
"github.com/ghodss/yaml"
|
||||
"github.com/kylelemons/godebug/pretty"
|
||||
|
||||
"github.com/dexidp/dex/connector/mock"
|
||||
"github.com/dexidp/dex/connector/oidc"
|
||||
"github.com/dexidp/dex/server"
|
||||
"github.com/dexidp/dex/storage"
|
||||
"github.com/dexidp/dex/storage/sql"
|
||||
)
|
||||
|
||||
var _ = yaml.YAMLToJSON
|
||||
|
||||
func TestValidConfiguration(t *testing.T) {
|
||||
configuration := Config{
|
||||
Issuer: "http://127.0.0.1:5556/dex",
|
||||
Storage: Storage{
|
||||
Type: "sqlite3",
|
||||
Config: &sql.SQLite3{
|
||||
File: "examples/dex.db",
|
||||
},
|
||||
},
|
||||
Web: Web{
|
||||
HTTP: "127.0.0.1:5556",
|
||||
},
|
||||
StaticConnectors: []Connector{
|
||||
{
|
||||
Type: "mockCallback",
|
||||
ID: "mock",
|
||||
Name: "Example",
|
||||
Config: &mock.CallbackConfig{},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := configuration.Validate(); err != nil {
|
||||
t.Fatalf("this configuration should have been valid: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidConfiguration(t *testing.T) {
|
||||
configuration := Config{}
|
||||
err := configuration.Validate()
|
||||
if err == nil {
|
||||
t.Fatal("this configuration should be invalid")
|
||||
}
|
||||
got := err.Error()
|
||||
wanted := `invalid Config:
|
||||
- no issuer specified in config file
|
||||
- no storage supplied in config file
|
||||
- must supply a HTTP/HTTPS address to listen on`
|
||||
if got != wanted {
|
||||
t.Fatalf("Expected error message to be %q, got %q", wanted, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalConfig(t *testing.T) {
|
||||
rawConfig := []byte(`
|
||||
issuer: http://127.0.0.1:5556/dex
|
||||
storage:
|
||||
type: sqlite3
|
||||
type: postgres
|
||||
config:
|
||||
file: examples/dex.db
|
||||
|
||||
host: 10.0.0.1
|
||||
port: 65432
|
||||
maxOpenConns: 5
|
||||
maxIdleConns: 3
|
||||
connMaxLifetime: 30
|
||||
connectionTimeout: 3
|
||||
web:
|
||||
http: 127.0.0.1:5556
|
||||
|
||||
frontend:
|
||||
dir: ./web
|
||||
extra:
|
||||
foo: bar
|
||||
|
||||
staticClients:
|
||||
- id: example-app
|
||||
redirectURIs:
|
||||
|
@ -30,6 +85,9 @@ staticClients:
|
|||
name: 'Example App'
|
||||
secret: ZXhhbXBsZS1hcHAtc2VjcmV0
|
||||
|
||||
oauth2:
|
||||
alwaysShowLoginScreen: true
|
||||
|
||||
connectors:
|
||||
- type: mockCallback
|
||||
id: mock
|
||||
|
@ -50,15 +108,17 @@ staticPasswords:
|
|||
hash: "$2a$10$33EMT0cVYVlPy6WAMCLsceLYjWhuHpbz5yuZxu/GAFj03J9Lytjuy"
|
||||
username: "admin"
|
||||
userID: "08a8684b-db88-4b73-90a9-3cd1661f5466"
|
||||
- email: "foo@example.com"
|
||||
- email: "foo@example.com"
|
||||
# base64'd value of the same bcrypt hash above. We want to be able to parse both of these
|
||||
hash: "JDJhJDEwJDMzRU1UMGNWWVZsUHk2V0FNQ0xzY2VMWWpXaHVIcGJ6NXl1Wnh1L0dBRmowM0o5THl0anV5"
|
||||
username: "foo"
|
||||
userID: "41331323-6f44-45e6-b3b9-2c4b60c02be5"
|
||||
|
||||
expiry:
|
||||
signingKeys: "6h"
|
||||
idTokens: "24h"
|
||||
signingKeys: "7h"
|
||||
idTokens: "25h"
|
||||
authRequests: "25h"
|
||||
deviceRequests: "10m"
|
||||
|
||||
logger:
|
||||
level: "debug"
|
||||
|
@ -68,14 +128,27 @@ logger:
|
|||
want := Config{
|
||||
Issuer: "http://127.0.0.1:5556/dex",
|
||||
Storage: Storage{
|
||||
Type: "sqlite3",
|
||||
Config: &sql.SQLite3{
|
||||
File: "examples/dex.db",
|
||||
Type: "postgres",
|
||||
Config: &sql.Postgres{
|
||||
NetworkDB: sql.NetworkDB{
|
||||
Host: "10.0.0.1",
|
||||
Port: 65432,
|
||||
MaxOpenConns: 5,
|
||||
MaxIdleConns: 3,
|
||||
ConnMaxLifetime: 30,
|
||||
ConnectionTimeout: 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
Web: Web{
|
||||
HTTP: "127.0.0.1:5556",
|
||||
},
|
||||
Frontend: server.WebConfig{
|
||||
Dir: "./web",
|
||||
Extra: map[string]string{
|
||||
"foo": "bar",
|
||||
},
|
||||
},
|
||||
StaticClients: []storage.Client{
|
||||
{
|
||||
ID: "example-app",
|
||||
|
@ -86,7 +159,10 @@ logger:
|
|||
},
|
||||
},
|
||||
},
|
||||
Connectors: []Connector{
|
||||
OAuth2: OAuth2{
|
||||
AlwaysShowLoginScreen: true,
|
||||
},
|
||||
StaticConnectors: []Connector{
|
||||
{
|
||||
Type: "mockCallback",
|
||||
ID: "mock",
|
||||
|
@ -121,8 +197,217 @@ logger:
|
|||
},
|
||||
},
|
||||
Expiry: Expiry{
|
||||
SigningKeys: "6h",
|
||||
IDTokens: "24h",
|
||||
SigningKeys: "7h",
|
||||
IDTokens: "25h",
|
||||
AuthRequests: "25h",
|
||||
DeviceRequests: "10m",
|
||||
},
|
||||
Logger: Logger{
|
||||
Level: "debug",
|
||||
Format: "json",
|
||||
},
|
||||
}
|
||||
|
||||
var c Config
|
||||
if err := yaml.Unmarshal(rawConfig, &c); err != nil {
|
||||
t.Fatalf("failed to decode config: %v", err)
|
||||
}
|
||||
if diff := pretty.Compare(c, want); diff != "" {
|
||||
t.Errorf("got!=want: %s", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalConfigWithEnvNoExpand(t *testing.T) {
|
||||
// If the env variable DEX_EXPAND_ENV is set and has a "falsy" value, os.ExpandEnv is disabled.
|
||||
// ParseBool: "It accepts 1, t, T, TRUE, true, True, 0, f, F, FALSE, false, False."
|
||||
checkUnmarshalConfigWithEnv(t, "0", false)
|
||||
checkUnmarshalConfigWithEnv(t, "f", false)
|
||||
checkUnmarshalConfigWithEnv(t, "F", false)
|
||||
checkUnmarshalConfigWithEnv(t, "FALSE", false)
|
||||
checkUnmarshalConfigWithEnv(t, "false", false)
|
||||
checkUnmarshalConfigWithEnv(t, "False", false)
|
||||
os.Unsetenv("DEX_EXPAND_ENV")
|
||||
}
|
||||
|
||||
func TestUnmarshalConfigWithEnvExpand(t *testing.T) {
|
||||
// If the env variable DEX_EXPAND_ENV is unset or has a "truthy" or unknown value, os.ExpandEnv is enabled.
|
||||
// ParseBool: "It accepts 1, t, T, TRUE, true, True, 0, f, F, FALSE, false, False."
|
||||
checkUnmarshalConfigWithEnv(t, "1", true)
|
||||
checkUnmarshalConfigWithEnv(t, "t", true)
|
||||
checkUnmarshalConfigWithEnv(t, "T", true)
|
||||
checkUnmarshalConfigWithEnv(t, "TRUE", true)
|
||||
checkUnmarshalConfigWithEnv(t, "true", true)
|
||||
checkUnmarshalConfigWithEnv(t, "True", true)
|
||||
// Values that can't be parsed as bool:
|
||||
checkUnmarshalConfigWithEnv(t, "UNSET", true)
|
||||
checkUnmarshalConfigWithEnv(t, "", true)
|
||||
checkUnmarshalConfigWithEnv(t, "whatever - true is default", true)
|
||||
os.Unsetenv("DEX_EXPAND_ENV")
|
||||
}
|
||||
|
||||
func checkUnmarshalConfigWithEnv(t *testing.T, dexExpandEnv string, wantExpandEnv bool) {
|
||||
// For hashFromEnv:
|
||||
os.Setenv("DEX_FOO_USER_PASSWORD", "$2a$10$33EMT0cVYVlPy6WAMCLsceLYjWhuHpbz5yuZxu/GAFj03J9Lytjuy")
|
||||
// For os.ExpandEnv ($VAR -> value_of_VAR):
|
||||
os.Setenv("DEX_FOO_POSTGRES_HOST", "10.0.0.1")
|
||||
os.Setenv("DEX_FOO_OIDC_CLIENT_SECRET", "bar")
|
||||
if dexExpandEnv != "UNSET" {
|
||||
os.Setenv("DEX_EXPAND_ENV", dexExpandEnv)
|
||||
} else {
|
||||
os.Unsetenv("DEX_EXPAND_ENV")
|
||||
}
|
||||
|
||||
rawConfig := []byte(`
|
||||
issuer: http://127.0.0.1:5556/dex
|
||||
storage:
|
||||
type: postgres
|
||||
config:
|
||||
# Env variables are expanded in raw YAML source.
|
||||
# Single quotes work fine, as long as the env variable doesn't contain any.
|
||||
host: '$DEX_FOO_POSTGRES_HOST'
|
||||
port: 65432
|
||||
maxOpenConns: 5
|
||||
maxIdleConns: 3
|
||||
connMaxLifetime: 30
|
||||
connectionTimeout: 3
|
||||
web:
|
||||
http: 127.0.0.1:5556
|
||||
|
||||
frontend:
|
||||
dir: ./web
|
||||
extra:
|
||||
foo: bar
|
||||
|
||||
staticClients:
|
||||
- id: example-app
|
||||
redirectURIs:
|
||||
- 'http://127.0.0.1:5555/callback'
|
||||
name: 'Example App'
|
||||
secret: ZXhhbXBsZS1hcHAtc2VjcmV0
|
||||
|
||||
oauth2:
|
||||
alwaysShowLoginScreen: true
|
||||
|
||||
connectors:
|
||||
- type: mockCallback
|
||||
id: mock
|
||||
name: Example
|
||||
- type: oidc
|
||||
id: google
|
||||
name: Google
|
||||
config:
|
||||
issuer: https://accounts.google.com
|
||||
clientID: foo
|
||||
# Env variables are expanded in raw YAML source.
|
||||
# Single quotes work fine, as long as the env variable doesn't contain any.
|
||||
clientSecret: '$DEX_FOO_OIDC_CLIENT_SECRET'
|
||||
redirectURI: http://127.0.0.1:5556/dex/callback/google
|
||||
|
||||
enablePasswordDB: true
|
||||
staticPasswords:
|
||||
- email: "admin@example.com"
|
||||
# bcrypt hash of the string "password"
|
||||
hash: "$2a$10$33EMT0cVYVlPy6WAMCLsceLYjWhuHpbz5yuZxu/GAFj03J9Lytjuy"
|
||||
username: "admin"
|
||||
userID: "08a8684b-db88-4b73-90a9-3cd1661f5466"
|
||||
- email: "foo@example.com"
|
||||
hashFromEnv: "DEX_FOO_USER_PASSWORD"
|
||||
username: "foo"
|
||||
userID: "41331323-6f44-45e6-b3b9-2c4b60c02be5"
|
||||
|
||||
expiry:
|
||||
signingKeys: "7h"
|
||||
idTokens: "25h"
|
||||
authRequests: "25h"
|
||||
|
||||
logger:
|
||||
level: "debug"
|
||||
format: "json"
|
||||
`)
|
||||
|
||||
// This is not a valid hostname. It's only used to check whether os.ExpandEnv was applied or not.
|
||||
wantPostgresHost := "$DEX_FOO_POSTGRES_HOST"
|
||||
wantOidcClientSecret := "$DEX_FOO_OIDC_CLIENT_SECRET"
|
||||
if wantExpandEnv {
|
||||
wantPostgresHost = "10.0.0.1"
|
||||
wantOidcClientSecret = "bar"
|
||||
}
|
||||
|
||||
want := Config{
|
||||
Issuer: "http://127.0.0.1:5556/dex",
|
||||
Storage: Storage{
|
||||
Type: "postgres",
|
||||
Config: &sql.Postgres{
|
||||
NetworkDB: sql.NetworkDB{
|
||||
Host: wantPostgresHost,
|
||||
Port: 65432,
|
||||
MaxOpenConns: 5,
|
||||
MaxIdleConns: 3,
|
||||
ConnMaxLifetime: 30,
|
||||
ConnectionTimeout: 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
Web: Web{
|
||||
HTTP: "127.0.0.1:5556",
|
||||
},
|
||||
Frontend: server.WebConfig{
|
||||
Dir: "./web",
|
||||
Extra: map[string]string{
|
||||
"foo": "bar",
|
||||
},
|
||||
},
|
||||
StaticClients: []storage.Client{
|
||||
{
|
||||
ID: "example-app",
|
||||
Secret: "ZXhhbXBsZS1hcHAtc2VjcmV0",
|
||||
Name: "Example App",
|
||||
RedirectURIs: []string{
|
||||
"http://127.0.0.1:5555/callback",
|
||||
},
|
||||
},
|
||||
},
|
||||
OAuth2: OAuth2{
|
||||
AlwaysShowLoginScreen: true,
|
||||
},
|
||||
StaticConnectors: []Connector{
|
||||
{
|
||||
Type: "mockCallback",
|
||||
ID: "mock",
|
||||
Name: "Example",
|
||||
Config: &mock.CallbackConfig{},
|
||||
},
|
||||
{
|
||||
Type: "oidc",
|
||||
ID: "google",
|
||||
Name: "Google",
|
||||
Config: &oidc.Config{
|
||||
Issuer: "https://accounts.google.com",
|
||||
ClientID: "foo",
|
||||
ClientSecret: wantOidcClientSecret,
|
||||
RedirectURI: "http://127.0.0.1:5556/dex/callback/google",
|
||||
},
|
||||
},
|
||||
},
|
||||
EnablePasswordDB: true,
|
||||
StaticPasswords: []password{
|
||||
{
|
||||
Email: "admin@example.com",
|
||||
Hash: []byte("$2a$10$33EMT0cVYVlPy6WAMCLsceLYjWhuHpbz5yuZxu/GAFj03J9Lytjuy"),
|
||||
Username: "admin",
|
||||
UserID: "08a8684b-db88-4b73-90a9-3cd1661f5466",
|
||||
},
|
||||
{
|
||||
Email: "foo@example.com",
|
||||
Hash: []byte("$2a$10$33EMT0cVYVlPy6WAMCLsceLYjWhuHpbz5yuZxu/GAFj03J9Lytjuy"),
|
||||
Username: "foo",
|
||||
UserID: "41331323-6f44-45e6-b3b9-2c4b60c02be5",
|
||||
},
|
||||
},
|
||||
Expiry: Expiry{
|
||||
SigningKeys: "7h",
|
||||
IDTokens: "25h",
|
||||
AuthRequests: "25h",
|
||||
},
|
||||
Logger: Logger{
|
||||
Level: "debug",
|
||||
|
@ -137,5 +422,4 @@ logger:
|
|||
if diff := pretty.Compare(c, want); diff != "" {
|
||||
t.Errorf("got!=want: %s", diff)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
533
cmd/dex/serve.go
533
cmd/dex/serve.go
|
@ -6,51 +6,78 @@ import (
|
|||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/pprof"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/Sirupsen/logrus"
|
||||
gosundheit "github.com/AppsFlyer/go-sundheit"
|
||||
"github.com/AppsFlyer/go-sundheit/checks"
|
||||
gosundheithttp "github.com/AppsFlyer/go-sundheit/http"
|
||||
"github.com/ghodss/yaml"
|
||||
grpcprometheus "github.com/grpc-ecosystem/go-grpc-prometheus"
|
||||
"github.com/oklog/run"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/collectors"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/reflection"
|
||||
|
||||
"github.com/coreos/dex/api"
|
||||
"github.com/coreos/dex/server"
|
||||
"github.com/coreos/dex/storage"
|
||||
"github.com/dexidp/dex/api/v2"
|
||||
"github.com/dexidp/dex/pkg/log"
|
||||
"github.com/dexidp/dex/server"
|
||||
"github.com/dexidp/dex/storage"
|
||||
)
|
||||
|
||||
func commandServe() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "serve [ config file ]",
|
||||
Short: "Connect to the storage and begin serving requests.",
|
||||
Long: ``,
|
||||
Example: "dex serve config.yaml",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if err := serve(cmd, args); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(2)
|
||||
}
|
||||
},
|
||||
}
|
||||
type serveOptions struct {
|
||||
// Config file path
|
||||
config string
|
||||
|
||||
// Flags
|
||||
webHTTPAddr string
|
||||
webHTTPSAddr string
|
||||
telemetryAddr string
|
||||
grpcAddr string
|
||||
}
|
||||
|
||||
func serve(cmd *cobra.Command, args []string) error {
|
||||
switch len(args) {
|
||||
default:
|
||||
return errors.New("surplus arguments")
|
||||
case 0:
|
||||
// TODO(ericchiang): Consider having a default config file location.
|
||||
return errors.New("no arguments provided")
|
||||
case 1:
|
||||
func commandServe() *cobra.Command {
|
||||
options := serveOptions{}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "serve [flags] [config file]",
|
||||
Short: "Launch Dex",
|
||||
Example: "dex serve config.yaml",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
|
||||
options.config = args[0]
|
||||
|
||||
return runServe(options)
|
||||
},
|
||||
}
|
||||
|
||||
configFile := args[0]
|
||||
configData, err := ioutil.ReadFile(configFile)
|
||||
flags := cmd.Flags()
|
||||
|
||||
flags.StringVar(&options.webHTTPAddr, "web-http-addr", "", "Web HTTP address")
|
||||
flags.StringVar(&options.webHTTPSAddr, "web-https-addr", "", "Web HTTPS address")
|
||||
flags.StringVar(&options.telemetryAddr, "telemetry-addr", "", "Telemetry address")
|
||||
flags.StringVar(&options.grpcAddr, "grpc-addr", "", "gRPC API address")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runServe(options serveOptions) error {
|
||||
configFile := options.config
|
||||
configData, err := os.ReadFile(configFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read config file %s: %v", configFile, err)
|
||||
}
|
||||
|
@ -60,111 +87,130 @@ func serve(cmd *cobra.Command, args []string) error {
|
|||
return fmt.Errorf("error parse config file %s: %v", configFile, err)
|
||||
}
|
||||
|
||||
applyConfigOverrides(options, &c)
|
||||
|
||||
logger, err := newLogger(c.Logger.Level, c.Logger.Format)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid config: %v", err)
|
||||
}
|
||||
|
||||
logger.Infof(
|
||||
"Dex Version: %s, Go Version: %s, Go OS/ARCH: %s %s",
|
||||
version,
|
||||
runtime.Version(),
|
||||
runtime.GOOS,
|
||||
runtime.GOARCH,
|
||||
)
|
||||
|
||||
if c.Logger.Level != "" {
|
||||
logger.Infof("config using log level: %s", c.Logger.Level)
|
||||
}
|
||||
|
||||
// Fast checks. Perform these first for a more responsive CLI.
|
||||
checks := []struct {
|
||||
bad bool
|
||||
errMsg string
|
||||
}{
|
||||
{c.Issuer == "", "no issuer specified in config file"},
|
||||
{len(c.Connectors) == 0 && !c.EnablePasswordDB, "no connectors supplied in config file"},
|
||||
{!c.EnablePasswordDB && len(c.StaticPasswords) != 0, "cannot specify static passwords without enabling password db"},
|
||||
{c.Storage.Config == nil, "no storage suppied in config file"},
|
||||
{c.Web.HTTP == "" && c.Web.HTTPS == "", "must supply a HTTP/HTTPS address to listen on"},
|
||||
{c.Web.HTTPS != "" && c.Web.TLSCert == "", "no cert specified for HTTPS"},
|
||||
{c.Web.HTTPS != "" && c.Web.TLSKey == "", "no private key specified for HTTPS"},
|
||||
{c.GRPC.TLSCert != "" && c.GRPC.Addr == "", "no address specified for gRPC"},
|
||||
{c.GRPC.TLSKey != "" && c.GRPC.Addr == "", "no address specified for gRPC"},
|
||||
{(c.GRPC.TLSCert == "") != (c.GRPC.TLSKey == ""), "must specific both a gRPC TLS cert and key"},
|
||||
{c.GRPC.TLSCert == "" && c.GRPC.TLSClientCA != "", "cannot specify gRPC TLS client CA without a gRPC TLS cert"},
|
||||
}
|
||||
|
||||
for _, check := range checks {
|
||||
if check.bad {
|
||||
return fmt.Errorf("invalid config: %s", check.errMsg)
|
||||
}
|
||||
if err := c.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Infof("config issuer: %s", c.Issuer)
|
||||
|
||||
var grpcOptions []grpc.ServerOption
|
||||
if c.GRPC.TLSCert != "" {
|
||||
if c.GRPC.TLSClientCA != "" {
|
||||
// Parse certificates from certificate file and key file for server.
|
||||
cert, err := tls.LoadX509KeyPair(c.GRPC.TLSCert, c.GRPC.TLSKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid config: error parsing gRPC certificate file: %v", err)
|
||||
}
|
||||
prometheusRegistry := prometheus.NewRegistry()
|
||||
err = prometheusRegistry.Register(collectors.NewGoCollector())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to register Go runtime metrics: %v", err)
|
||||
}
|
||||
|
||||
err = prometheusRegistry.Register(collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to register process metrics: %v", err)
|
||||
}
|
||||
|
||||
grpcMetrics := grpcprometheus.NewServerMetrics()
|
||||
err = prometheusRegistry.Register(grpcMetrics)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to register gRPC server metrics: %v", err)
|
||||
}
|
||||
|
||||
var grpcOptions []grpc.ServerOption
|
||||
|
||||
allowedTLSCiphers := []uint16{
|
||||
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
|
||||
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
|
||||
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
|
||||
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
|
||||
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
|
||||
tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,
|
||||
tls.TLS_RSA_WITH_AES_128_GCM_SHA256,
|
||||
tls.TLS_RSA_WITH_AES_256_GCM_SHA384,
|
||||
}
|
||||
|
||||
if c.GRPC.TLSCert != "" {
|
||||
// Parse certificates from certificate file and key file for server.
|
||||
cert, err := tls.LoadX509KeyPair(c.GRPC.TLSCert, c.GRPC.TLSKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid config: error parsing gRPC certificate file: %v", err)
|
||||
}
|
||||
|
||||
tlsConfig := tls.Config{
|
||||
Certificates: []tls.Certificate{cert},
|
||||
MinVersion: tls.VersionTLS12,
|
||||
CipherSuites: allowedTLSCiphers,
|
||||
PreferServerCipherSuites: true,
|
||||
}
|
||||
|
||||
if c.GRPC.TLSClientCA != "" {
|
||||
// Parse certificates from client CA file to a new CertPool.
|
||||
cPool := x509.NewCertPool()
|
||||
clientCert, err := ioutil.ReadFile(c.GRPC.TLSClientCA)
|
||||
clientCert, err := os.ReadFile(c.GRPC.TLSClientCA)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid config: reading from client CA file: %v", err)
|
||||
}
|
||||
if cPool.AppendCertsFromPEM(clientCert) != true {
|
||||
if !cPool.AppendCertsFromPEM(clientCert) {
|
||||
return errors.New("invalid config: failed to parse client CA")
|
||||
}
|
||||
|
||||
tlsConfig := tls.Config{
|
||||
Certificates: []tls.Certificate{cert},
|
||||
ClientAuth: tls.RequireAndVerifyClientCert,
|
||||
ClientCAs: cPool,
|
||||
}
|
||||
grpcOptions = append(grpcOptions, grpc.Creds(credentials.NewTLS(&tlsConfig)))
|
||||
} else {
|
||||
opt, err := credentials.NewServerTLSFromFile(c.GRPC.TLSCert, c.GRPC.TLSKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid config: load grpc certs: %v", err)
|
||||
}
|
||||
grpcOptions = append(grpcOptions, grpc.Creds(opt))
|
||||
}
|
||||
}
|
||||
tlsConfig.ClientAuth = tls.RequireAndVerifyClientCert
|
||||
tlsConfig.ClientCAs = cPool
|
||||
|
||||
connectors := make([]server.Connector, len(c.Connectors))
|
||||
for i, conn := range c.Connectors {
|
||||
if conn.ID == "" {
|
||||
return fmt.Errorf("invalid config: no ID field for connector %d", i)
|
||||
// Only add metrics if client auth is enabled
|
||||
grpcOptions = append(grpcOptions,
|
||||
grpc.StreamInterceptor(grpcMetrics.StreamServerInterceptor()),
|
||||
grpc.UnaryInterceptor(grpcMetrics.UnaryServerInterceptor()),
|
||||
)
|
||||
}
|
||||
if conn.Config == nil {
|
||||
return fmt.Errorf("invalid config: no config field for connector %q", conn.ID)
|
||||
}
|
||||
if conn.Name == "" {
|
||||
return fmt.Errorf("invalid config: no Name field for connector %q", conn.ID)
|
||||
}
|
||||
logger.Infof("config connector: %s", conn.ID)
|
||||
|
||||
connectorLogger := logger.WithField("connector", conn.Name)
|
||||
c, err := conn.Config.Open(connectorLogger)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create connector %s: %v", conn.ID, err)
|
||||
}
|
||||
connectors[i] = server.Connector{
|
||||
ID: conn.ID,
|
||||
DisplayName: conn.Name,
|
||||
Connector: c,
|
||||
}
|
||||
}
|
||||
if c.EnablePasswordDB {
|
||||
logger.Infof("config connector: local passwords enabled")
|
||||
grpcOptions = append(grpcOptions, grpc.Creds(credentials.NewTLS(&tlsConfig)))
|
||||
}
|
||||
|
||||
s, err := c.Storage.Config.Open(logger)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize storage: %v", err)
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
logger.Infof("config storage: %s", c.Storage.Type)
|
||||
|
||||
if len(c.StaticClients) > 0 {
|
||||
for _, client := range c.StaticClients {
|
||||
logger.Infof("config static client: %s", client.ID)
|
||||
for i, client := range c.StaticClients {
|
||||
if client.Name == "" {
|
||||
return fmt.Errorf("invalid config: Name field is required for a client")
|
||||
}
|
||||
if client.ID == "" && client.IDEnv == "" {
|
||||
return fmt.Errorf("invalid config: ID or IDEnv field is required for a client")
|
||||
}
|
||||
if client.IDEnv != "" {
|
||||
if client.ID != "" {
|
||||
return fmt.Errorf("invalid config: ID and IDEnv fields are exclusive for client %q", client.ID)
|
||||
}
|
||||
c.StaticClients[i].ID = os.Getenv(client.IDEnv)
|
||||
}
|
||||
if client.Secret == "" && client.SecretEnv == "" && !client.Public {
|
||||
return fmt.Errorf("invalid config: Secret or SecretEnv field is required for client %q", client.ID)
|
||||
}
|
||||
if client.SecretEnv != "" {
|
||||
if client.Secret != "" {
|
||||
return fmt.Errorf("invalid config: Secret and SecretEnv fields are exclusive for client %q", client.ID)
|
||||
}
|
||||
c.StaticClients[i].Secret = os.Getenv(client.SecretEnv)
|
||||
}
|
||||
logger.Infof("config static client: %s", client.Name)
|
||||
}
|
||||
s = storage.WithStaticClients(s, c.StaticClients)
|
||||
}
|
||||
|
@ -173,15 +219,47 @@ func serve(cmd *cobra.Command, args []string) error {
|
|||
for i, p := range c.StaticPasswords {
|
||||
passwords[i] = storage.Password(p)
|
||||
}
|
||||
s = storage.WithStaticPasswords(s, passwords)
|
||||
s = storage.WithStaticPasswords(s, passwords, logger)
|
||||
}
|
||||
|
||||
storageConnectors := make([]storage.Connector, len(c.StaticConnectors))
|
||||
for i, c := range c.StaticConnectors {
|
||||
if c.ID == "" || c.Name == "" || c.Type == "" {
|
||||
return fmt.Errorf("invalid config: ID, Type and Name fields are required for a connector")
|
||||
}
|
||||
if c.Config == nil {
|
||||
return fmt.Errorf("invalid config: no config field for connector %q", c.ID)
|
||||
}
|
||||
logger.Infof("config connector: %s", c.ID)
|
||||
|
||||
// convert to a storage connector object
|
||||
conn, err := ToStorageConnector(c)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize storage connectors: %v", err)
|
||||
}
|
||||
storageConnectors[i] = conn
|
||||
}
|
||||
|
||||
if c.EnablePasswordDB {
|
||||
storageConnectors = append(storageConnectors, storage.Connector{
|
||||
ID: server.LocalConnector,
|
||||
Name: "Email",
|
||||
Type: server.LocalConnector,
|
||||
})
|
||||
logger.Infof("config connector: local passwords enabled")
|
||||
}
|
||||
|
||||
s = storage.WithStaticConnectors(s, storageConnectors)
|
||||
|
||||
if len(c.OAuth2.ResponseTypes) > 0 {
|
||||
logger.Infof("config response types accepted: %s", c.OAuth2.ResponseTypes)
|
||||
}
|
||||
if c.OAuth2.SkipApprovalScreen {
|
||||
logger.Infof("config skipping approval screen")
|
||||
}
|
||||
if c.OAuth2.PasswordConnector != "" {
|
||||
logger.Infof("config using password grant connector: %s", c.OAuth2.PasswordConnector)
|
||||
}
|
||||
if len(c.Web.AllowedOrigins) > 0 {
|
||||
logger.Infof("config allowed origins: %s", c.Web.AllowedOrigins)
|
||||
}
|
||||
|
@ -189,17 +267,21 @@ func serve(cmd *cobra.Command, args []string) error {
|
|||
// explicitly convert to UTC.
|
||||
now := func() time.Time { return time.Now().UTC() }
|
||||
|
||||
healthChecker := gosundheit.New()
|
||||
|
||||
serverConfig := server.Config{
|
||||
SupportedResponseTypes: c.OAuth2.ResponseTypes,
|
||||
SkipApprovalScreen: c.OAuth2.SkipApprovalScreen,
|
||||
AlwaysShowLoginScreen: c.OAuth2.AlwaysShowLoginScreen,
|
||||
PasswordConnector: c.OAuth2.PasswordConnector,
|
||||
AllowedOrigins: c.Web.AllowedOrigins,
|
||||
Issuer: c.Issuer,
|
||||
Connectors: connectors,
|
||||
Storage: s,
|
||||
Web: c.Frontend,
|
||||
EnablePasswordDB: c.EnablePasswordDB,
|
||||
Logger: logger,
|
||||
Now: now,
|
||||
PrometheusRegistry: prometheusRegistry,
|
||||
HealthChecker: healthChecker,
|
||||
}
|
||||
if c.Expiry.SigningKeys != "" {
|
||||
signingKeys, err := time.ParseDuration(c.Expiry.SigningKeys)
|
||||
|
@ -217,44 +299,195 @@ func serve(cmd *cobra.Command, args []string) error {
|
|||
logger.Infof("config id tokens valid for: %v", idTokens)
|
||||
serverConfig.IDTokensValidFor = idTokens
|
||||
}
|
||||
if c.Expiry.AuthRequests != "" {
|
||||
authRequests, err := time.ParseDuration(c.Expiry.AuthRequests)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid config value %q for auth request expiry: %v", c.Expiry.AuthRequests, err)
|
||||
}
|
||||
logger.Infof("config auth requests valid for: %v", authRequests)
|
||||
serverConfig.AuthRequestsValidFor = authRequests
|
||||
}
|
||||
if c.Expiry.DeviceRequests != "" {
|
||||
deviceRequests, err := time.ParseDuration(c.Expiry.DeviceRequests)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid config value %q for device request expiry: %v", c.Expiry.AuthRequests, err)
|
||||
}
|
||||
logger.Infof("config device requests valid for: %v", deviceRequests)
|
||||
serverConfig.DeviceRequestsValidFor = deviceRequests
|
||||
}
|
||||
refreshTokenPolicy, err := server.NewRefreshTokenPolicy(
|
||||
logger,
|
||||
c.Expiry.RefreshTokens.DisableRotation,
|
||||
c.Expiry.RefreshTokens.ValidIfNotUsedFor,
|
||||
c.Expiry.RefreshTokens.AbsoluteLifetime,
|
||||
c.Expiry.RefreshTokens.ReuseInterval,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid refresh token expiration policy config: %v", err)
|
||||
}
|
||||
|
||||
serverConfig.RefreshTokenPolicy = refreshTokenPolicy
|
||||
serv, err := server.NewServer(context.Background(), serverConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize server: %v", err)
|
||||
}
|
||||
|
||||
errc := make(chan error, 3)
|
||||
if c.Web.HTTP != "" {
|
||||
logger.Infof("listening (http) on %s", c.Web.HTTP)
|
||||
go func() {
|
||||
err := http.ListenAndServe(c.Web.HTTP, serv)
|
||||
errc <- fmt.Errorf("listening on %s failed: %v", c.Web.HTTP, err)
|
||||
}()
|
||||
}
|
||||
if c.Web.HTTPS != "" {
|
||||
logger.Infof("listening (https) on %s", c.Web.HTTPS)
|
||||
go func() {
|
||||
err := http.ListenAndServeTLS(c.Web.HTTPS, c.Web.TLSCert, c.Web.TLSKey, serv)
|
||||
errc <- fmt.Errorf("listening on %s failed: %v", c.Web.HTTPS, err)
|
||||
}()
|
||||
}
|
||||
if c.GRPC.Addr != "" {
|
||||
logger.Infof("listening (grpc) on %s", c.GRPC.Addr)
|
||||
go func() {
|
||||
errc <- func() error {
|
||||
list, err := net.Listen("tcp", c.GRPC.Addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listening on %s failed: %v", c.GRPC.Addr, err)
|
||||
}
|
||||
s := grpc.NewServer(grpcOptions...)
|
||||
api.RegisterDexServer(s, server.NewAPI(serverConfig.Storage, logger))
|
||||
err = s.Serve(list)
|
||||
return fmt.Errorf("listening on %s failed: %v", c.GRPC.Addr, err)
|
||||
}()
|
||||
}()
|
||||
telemetryRouter := http.NewServeMux()
|
||||
telemetryRouter.Handle("/metrics", promhttp.HandlerFor(prometheusRegistry, promhttp.HandlerOpts{}))
|
||||
|
||||
// Configure health checker
|
||||
{
|
||||
handler := gosundheithttp.HandleHealthJSON(healthChecker)
|
||||
telemetryRouter.Handle("/healthz", handler)
|
||||
|
||||
// Kubernetes style health checks
|
||||
telemetryRouter.HandleFunc("/healthz/live", func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
})
|
||||
telemetryRouter.Handle("/healthz/ready", handler)
|
||||
}
|
||||
|
||||
return <-errc
|
||||
healthChecker.RegisterCheck(
|
||||
&checks.CustomCheck{
|
||||
CheckName: "storage",
|
||||
CheckFunc: storage.NewCustomHealthCheckFunc(serverConfig.Storage, serverConfig.Now),
|
||||
},
|
||||
gosundheit.ExecutionPeriod(15*time.Second),
|
||||
gosundheit.InitiallyPassing(true),
|
||||
)
|
||||
|
||||
var group run.Group
|
||||
|
||||
// Set up telemetry server
|
||||
if c.Telemetry.HTTP != "" {
|
||||
const name = "telemetry"
|
||||
|
||||
logger.Infof("listening (%s) on %s", name, c.Telemetry.HTTP)
|
||||
|
||||
l, err := net.Listen("tcp", c.Telemetry.HTTP)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listening (%s) on %s: %v", name, c.Telemetry.HTTP, err)
|
||||
}
|
||||
|
||||
if c.Telemetry.EnableProfiling {
|
||||
pprofHandler(telemetryRouter)
|
||||
}
|
||||
|
||||
server := &http.Server{
|
||||
Handler: telemetryRouter,
|
||||
}
|
||||
defer server.Close()
|
||||
|
||||
group.Add(func() error {
|
||||
return server.Serve(l)
|
||||
}, func(err error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
defer cancel()
|
||||
|
||||
logger.Debugf("starting graceful shutdown (%s)", name)
|
||||
if err := server.Shutdown(ctx); err != nil {
|
||||
logger.Errorf("graceful shutdown (%s): %v", name, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Set up http server
|
||||
if c.Web.HTTP != "" {
|
||||
const name = "http"
|
||||
|
||||
logger.Infof("listening (%s) on %s", name, c.Web.HTTP)
|
||||
|
||||
l, err := net.Listen("tcp", c.Web.HTTP)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listening (%s) on %s: %v", name, c.Web.HTTP, err)
|
||||
}
|
||||
|
||||
server := &http.Server{
|
||||
Handler: serv,
|
||||
}
|
||||
defer server.Close()
|
||||
|
||||
group.Add(func() error {
|
||||
return server.Serve(l)
|
||||
}, func(err error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
defer cancel()
|
||||
|
||||
logger.Debugf("starting graceful shutdown (%s)", name)
|
||||
if err := server.Shutdown(ctx); err != nil {
|
||||
logger.Errorf("graceful shutdown (%s): %v", name, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Set up https server
|
||||
if c.Web.HTTPS != "" {
|
||||
const name = "https"
|
||||
|
||||
logger.Infof("listening (%s) on %s", name, c.Web.HTTPS)
|
||||
|
||||
l, err := net.Listen("tcp", c.Web.HTTPS)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listening (%s) on %s: %v", name, c.Web.HTTPS, err)
|
||||
}
|
||||
|
||||
server := &http.Server{
|
||||
Handler: serv,
|
||||
TLSConfig: &tls.Config{
|
||||
CipherSuites: allowedTLSCiphers,
|
||||
PreferServerCipherSuites: true,
|
||||
MinVersion: tls.VersionTLS12,
|
||||
},
|
||||
}
|
||||
defer server.Close()
|
||||
|
||||
group.Add(func() error {
|
||||
return server.ServeTLS(l, c.Web.TLSCert, c.Web.TLSKey)
|
||||
}, func(err error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
defer cancel()
|
||||
|
||||
logger.Debugf("starting graceful shutdown (%s)", name)
|
||||
if err := server.Shutdown(ctx); err != nil {
|
||||
logger.Errorf("graceful shutdown (%s): %v", name, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Set up grpc server
|
||||
if c.GRPC.Addr != "" {
|
||||
logger.Infof("listening (grpc) on %s", c.GRPC.Addr)
|
||||
|
||||
grpcListener, err := net.Listen("tcp", c.GRPC.Addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listening (grcp) on %s: %w", c.GRPC.Addr, err)
|
||||
}
|
||||
|
||||
grpcSrv := grpc.NewServer(grpcOptions...)
|
||||
api.RegisterDexServer(grpcSrv, server.NewAPI(serverConfig.Storage, logger, version))
|
||||
|
||||
grpcMetrics.InitializeMetrics(grpcSrv)
|
||||
if c.GRPC.Reflection {
|
||||
logger.Info("enabling reflection in grpc service")
|
||||
reflection.Register(grpcSrv)
|
||||
}
|
||||
|
||||
group.Add(func() error {
|
||||
return grpcSrv.Serve(grpcListener)
|
||||
}, func(err error) {
|
||||
logger.Debugf("starting graceful shutdown (grpc)")
|
||||
grpcSrv.GracefulStop()
|
||||
})
|
||||
}
|
||||
|
||||
group.Add(run.SignalHandler(context.Background(), os.Interrupt, syscall.SIGTERM))
|
||||
if err := group.Run(); err != nil {
|
||||
if _, ok := err.(run.SignalError); !ok {
|
||||
return fmt.Errorf("run groups: %w", err)
|
||||
}
|
||||
logger.Infof("%v, shutdown now", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
|
@ -271,7 +504,7 @@ func (f *utcFormatter) Format(e *logrus.Entry) ([]byte, error) {
|
|||
return f.f.Format(e)
|
||||
}
|
||||
|
||||
func newLogger(level string, format string) (logrus.FieldLogger, error) {
|
||||
func newLogger(level string, format string) (log.Logger, error) {
|
||||
var logLevel logrus.Level
|
||||
switch strings.ToLower(level) {
|
||||
case "debug":
|
||||
|
@ -300,3 +533,33 @@ func newLogger(level string, format string) (logrus.FieldLogger, error) {
|
|||
Level: logLevel,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func applyConfigOverrides(options serveOptions, config *Config) {
|
||||
if options.webHTTPAddr != "" {
|
||||
config.Web.HTTP = options.webHTTPAddr
|
||||
}
|
||||
|
||||
if options.webHTTPSAddr != "" {
|
||||
config.Web.HTTPS = options.webHTTPSAddr
|
||||
}
|
||||
|
||||
if options.telemetryAddr != "" {
|
||||
config.Telemetry.HTTP = options.telemetryAddr
|
||||
}
|
||||
|
||||
if options.grpcAddr != "" {
|
||||
config.GRPC.Addr = options.grpcAddr
|
||||
}
|
||||
|
||||
if config.Frontend.Dir == "" {
|
||||
config.Frontend.Dir = os.Getenv("DEX_FRONTEND_DIR")
|
||||
}
|
||||
}
|
||||
|
||||
func pprofHandler(router *http.ServeMux) {
|
||||
router.HandleFunc("/debug/pprof/", pprof.Index)
|
||||
router.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
|
||||
router.HandleFunc("/debug/pprof/profile", pprof.Profile)
|
||||
router.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
|
||||
router.HandleFunc("/debug/pprof/trace", pprof.Trace)
|
||||
}
|
||||
|
|
|
@ -4,19 +4,23 @@ import (
|
|||
"fmt"
|
||||
"runtime"
|
||||
|
||||
"github.com/coreos/dex/version"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var version = "DEV"
|
||||
|
||||
func commandVersion() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Print the version and exit",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
fmt.Printf(`dex Version: %s
|
||||
Go Version: %s
|
||||
Go OS/ARCH: %s %s
|
||||
`, version.Version, runtime.Version(), runtime.GOOS, runtime.GOARCH)
|
||||
Run: func(_ *cobra.Command, _ []string) {
|
||||
fmt.Printf(
|
||||
"Dex Version: %s\nGo Version: %s\nGo OS/ARCH: %s %s\n",
|
||||
version,
|
||||
runtime.Version(),
|
||||
runtime.GOOS,
|
||||
runtime.GOARCH,
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,92 @@
|
|||
// Package main provides a utility program to launch the Dex container process with an optional
|
||||
// templating step (provided by gomplate).
|
||||
//
|
||||
// This was originally written as a shell script, but we rewrote it as a Go program so that it could
|
||||
// run as a raw binary in a distroless container.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Note that this docker-entrypoint program is args[0], and it is provided with the true process
|
||||
// args.
|
||||
args := os.Args[1:]
|
||||
|
||||
if err := run(args, realExec, realWhich); err != nil {
|
||||
fmt.Println("error:", err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func realExec(fork bool, args ...string) error {
|
||||
if fork {
|
||||
if output, err := exec.Command(args[0], args[1:]...).CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("cannot fork/exec command %s: %w (output: %q)", args, err, string(output))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
argv0, err := exec.LookPath(args[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot lookup path for command %s: %w", args[0], err)
|
||||
}
|
||||
|
||||
if err := syscall.Exec(argv0, args, os.Environ()); err != nil {
|
||||
return fmt.Errorf("cannot exec command %s (%q): %w", args, argv0, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func realWhich(path string) string {
|
||||
fullPath, err := exec.LookPath(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return fullPath
|
||||
}
|
||||
|
||||
func run(args []string, execFunc func(bool, ...string) error, whichFunc func(string) string) error {
|
||||
if args[0] != "dex" && args[0] != whichFunc("dex") {
|
||||
return execFunc(false, args...)
|
||||
}
|
||||
|
||||
if args[1] != "serve" {
|
||||
return execFunc(false, args...)
|
||||
}
|
||||
|
||||
newArgs := []string{}
|
||||
for _, tplCandidate := range args {
|
||||
if hasSuffixes(tplCandidate, ".tpl", ".tmpl", ".yaml") {
|
||||
tmpFile, err := os.CreateTemp("/tmp", "dex.config.yaml-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot create temp file: %w", err)
|
||||
}
|
||||
|
||||
if err := execFunc(true, "gomplate", "-f", tplCandidate, "-o", tmpFile.Name()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
newArgs = append(newArgs, tmpFile.Name())
|
||||
} else {
|
||||
newArgs = append(newArgs, tplCandidate)
|
||||
}
|
||||
}
|
||||
|
||||
return execFunc(false, newArgs...)
|
||||
}
|
||||
|
||||
func hasSuffixes(s string, suffixes ...string) bool {
|
||||
for _, suffix := range suffixes {
|
||||
if strings.HasSuffix(s, suffix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
|
@ -0,0 +1,113 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type execArgs struct {
|
||||
fork bool
|
||||
argPrefixes []string
|
||||
}
|
||||
|
||||
func TestRun(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
execReturns error
|
||||
whichReturns string
|
||||
wantExecArgs []execArgs
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "executable not dex",
|
||||
args: []string{"tuna", "fish"},
|
||||
wantExecArgs: []execArgs{{fork: false, argPrefixes: []string{"tuna", "fish"}}},
|
||||
},
|
||||
{
|
||||
name: "executable is full path to dex",
|
||||
args: []string{"/usr/local/bin/dex", "marshmallow", "zelda"},
|
||||
whichReturns: "/usr/local/bin/dex",
|
||||
wantExecArgs: []execArgs{{fork: false, argPrefixes: []string{"/usr/local/bin/dex", "marshmallow", "zelda"}}},
|
||||
},
|
||||
{
|
||||
name: "command is not serve",
|
||||
args: []string{"dex", "marshmallow", "zelda"},
|
||||
wantExecArgs: []execArgs{{fork: false, argPrefixes: []string{"dex", "marshmallow", "zelda"}}},
|
||||
},
|
||||
{
|
||||
name: "no templates",
|
||||
args: []string{"dex", "serve", "config.yaml.not-a-template"},
|
||||
wantExecArgs: []execArgs{{fork: false, argPrefixes: []string{"dex", "serve", "config.yaml.not-a-template"}}},
|
||||
},
|
||||
{
|
||||
name: "no templates",
|
||||
args: []string{"dex", "serve", "config.yaml.not-a-template"},
|
||||
wantExecArgs: []execArgs{{fork: false, argPrefixes: []string{"dex", "serve", "config.yaml.not-a-template"}}},
|
||||
},
|
||||
{
|
||||
name: ".tpl template",
|
||||
args: []string{"dex", "serve", "config.tpl"},
|
||||
wantExecArgs: []execArgs{
|
||||
{fork: true, argPrefixes: []string{"gomplate", "-f", "config.tpl", "-o", "/tmp/dex.config.yaml-"}},
|
||||
{fork: false, argPrefixes: []string{"dex", "serve", "/tmp/dex.config.yaml-"}},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: ".tmpl template",
|
||||
args: []string{"dex", "serve", "config.tmpl"},
|
||||
wantExecArgs: []execArgs{
|
||||
{fork: true, argPrefixes: []string{"gomplate", "-f", "config.tmpl", "-o", "/tmp/dex.config.yaml-"}},
|
||||
{fork: false, argPrefixes: []string{"dex", "serve", "/tmp/dex.config.yaml-"}},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: ".yaml template",
|
||||
args: []string{"dex", "serve", "some/path/config.yaml"},
|
||||
wantExecArgs: []execArgs{
|
||||
{fork: true, argPrefixes: []string{"gomplate", "-f", "some/path/config.yaml", "-o", "/tmp/dex.config.yaml-"}},
|
||||
{fork: false, argPrefixes: []string{"dex", "serve", "/tmp/dex.config.yaml-"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var gotExecForks []bool
|
||||
var gotExecArgs [][]string
|
||||
fakeExec := func(fork bool, args ...string) error {
|
||||
gotExecForks = append(gotExecForks, fork)
|
||||
gotExecArgs = append(gotExecArgs, args)
|
||||
return test.execReturns
|
||||
}
|
||||
|
||||
fakeWhich := func(_ string) string { return test.whichReturns }
|
||||
|
||||
gotErr := run(test.args, fakeExec, fakeWhich)
|
||||
if (test.wantErr == nil) != (gotErr == nil) {
|
||||
t.Errorf("wanted error %s, got %s", test.wantErr, gotErr)
|
||||
}
|
||||
if !execArgsMatch(test.wantExecArgs, gotExecForks, gotExecArgs) {
|
||||
t.Errorf("wanted exec args %+v, got %+v %+v", test.wantExecArgs, gotExecForks, gotExecArgs)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func execArgsMatch(wantExecArgs []execArgs, gotForks []bool, gotExecArgs [][]string) bool {
|
||||
if len(wantExecArgs) != len(gotForks) {
|
||||
return false
|
||||
}
|
||||
|
||||
for i := range wantExecArgs {
|
||||
if wantExecArgs[i].fork != gotForks[i] {
|
||||
return false
|
||||
}
|
||||
for j := range wantExecArgs[i].argPrefixes {
|
||||
if !strings.HasPrefix(gotExecArgs[i][j], wantExecArgs[i].argPrefixes[j]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
issuer: http://127.0.0.1:5556/dex
|
||||
|
||||
storage:
|
||||
type: sqlite3
|
||||
config:
|
||||
file: var/sqlite/dex.db
|
||||
|
||||
web:
|
||||
http: 127.0.0.1:5556
|
||||
|
||||
telemetry:
|
||||
http: 127.0.0.1:5558
|
||||
|
||||
grpc:
|
||||
addr: 127.0.0.1:5557
|
||||
|
||||
staticClients:
|
||||
- id: example-app
|
||||
redirectURIs:
|
||||
- 'http://127.0.0.1:5555/callback'
|
||||
name: 'Example App'
|
||||
secret: ZXhhbXBsZS1hcHAtc2VjcmV0
|
||||
|
||||
connectors:
|
||||
- type: mockCallback
|
||||
id: mock
|
||||
name: Example
|
||||
|
||||
enablePasswordDB: true
|
||||
|
||||
staticPasswords:
|
||||
- email: "admin@example.com"
|
||||
hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W"
|
||||
username: "admin"
|
||||
userID: "08a8684b-db88-4b73-90a9-3cd1661f5466"
|
|
@ -0,0 +1,48 @@
|
|||
{{- /* NOTE: This configuration file is an example and exists only for development purposes. */ -}}
|
||||
{{- /* To find more about gomplate formatting, please visit its documentation site - https://docs.gomplate.ca/ */ -}}
|
||||
issuer: {{ getenv "DEX_ISSUER" "http://127.0.0.1:5556/dex" }}
|
||||
|
||||
storage:
|
||||
type: sqlite3
|
||||
config:
|
||||
file: {{ getenv "DEX_STORAGE_SQLITE3_CONFIG_FILE" "/var/dex/dex.db" }}
|
||||
|
||||
web:
|
||||
{{- if getenv "DEX_WEB_HTTPS" "" }}
|
||||
https: {{ .Env.DEX_WEB_HTTPS }}
|
||||
tlsKey: {{ getenv "DEX_WEB_TLS_KEY" | required "$DEX_WEB_TLS_KEY in case of web.https is enabled" }}
|
||||
tlsCert: {{ getenv "DEX_WEB_TLS_CERT" | required "$DEX_WEB_TLS_CERT in case of web.https is enabled" }}
|
||||
{{- end }}
|
||||
http: {{ getenv "DEX_WEB_HTTP" "0.0.0.0:5556" }}
|
||||
|
||||
{{- if getenv "DEX_TELEMETRY_HTTP" }}
|
||||
telemetry:
|
||||
http: {{ .Env.DEX_TELEMETRY_HTTP }}
|
||||
{{- end }}
|
||||
|
||||
expiry:
|
||||
deviceRequests: {{ getenv "DEX_EXPIRY_DEVICE_REQUESTS" "5m" }}
|
||||
signingKeys: {{ getenv "DEX_EXPIRY_SIGNING_KEYS" "6h" }}
|
||||
idTokens: {{ getenv "DEX_EXPIRY_ID_TOKENS" "24h" }}
|
||||
authRequests: {{ getenv "DEX_EXPIRY_AUTH_REQUESTS" "24h" }}
|
||||
|
||||
logger:
|
||||
level: {{ getenv "DEX_LOG_LEVEL" "info" }}
|
||||
format: {{ getenv "DEX_LOG_FORMAT" "text" }}
|
||||
|
||||
oauth2:
|
||||
responseTypes: {{ getenv "DEX_OAUTH2_RESPONSE_TYPES" "[code]" }}
|
||||
skipApprovalScreen: {{ getenv "DEX_OAUTH2_SKIP_APPROVAL_SCREEN" "false" }}
|
||||
alwaysShowLoginScreen: {{ getenv "DEX_OAUTH2_ALWAYS_SHOW_LOGIN_SCREEN" "false" }}
|
||||
{{- if getenv "DEX_OAUTH2_PASSWORD_CONNECTOR" "" }}
|
||||
passwordConnector: {{ .Env.DEX_OAUTH2_PASSWORD_CONNECTOR }}
|
||||
{{- end }}
|
||||
|
||||
enablePasswordDB: {{ getenv "DEX_ENABLE_PASSWORD_DB" "true" }}
|
||||
|
||||
connectors:
|
||||
{{- if getenv "DEX_CONNECTORS_ENABLE_MOCK" }}
|
||||
- type: mockCallback
|
||||
id: mock
|
||||
name: Example
|
||||
{{- end }}
|
|
@ -0,0 +1,136 @@
|
|||
# The base path of Dex and the external name of the OpenID Connect service.
|
||||
# This is the canonical URL that all clients MUST use to refer to Dex. If a
|
||||
# path is provided, Dex's HTTP service will listen at a non-root URL.
|
||||
issuer: http://127.0.0.1:5556/dex
|
||||
|
||||
# The storage configuration determines where Dex stores its state.
|
||||
# Supported options include:
|
||||
# - SQL flavors
|
||||
# - key-value stores (eg. etcd)
|
||||
# - Kubernetes Custom Resources
|
||||
#
|
||||
# See the documentation (https://dexidp.io/docs/storage/) for further information.
|
||||
storage:
|
||||
type: memory
|
||||
|
||||
# type: sqlite3
|
||||
# config:
|
||||
# file: /var/dex/dex.db
|
||||
|
||||
# type: mysql
|
||||
# config:
|
||||
# host: 127.0.0.1
|
||||
# port: 3306
|
||||
# database: dex
|
||||
# user: mysql
|
||||
# password: mysql
|
||||
# ssl:
|
||||
# mode: "false"
|
||||
|
||||
# type: postgres
|
||||
# config:
|
||||
# host: 127.0.0.1
|
||||
# port: 5432
|
||||
# database: dex
|
||||
# user: postgres
|
||||
# password: postgres
|
||||
# ssl:
|
||||
# mode: disable
|
||||
|
||||
# type: etcd
|
||||
# config:
|
||||
# endpoints:
|
||||
# - http://127.0.0.1:2379
|
||||
# namespace: dex/
|
||||
|
||||
# type: kubernetes
|
||||
# config:
|
||||
# kubeConfigFile: $HOME/.kube/config
|
||||
|
||||
# HTTP service configuration
|
||||
web:
|
||||
http: 127.0.0.1:5556
|
||||
|
||||
# Uncomment to enable HTTPS endpoint.
|
||||
# https: 127.0.0.1:5554
|
||||
# tlsCert: /etc/dex/tls.crt
|
||||
# tlsKey: /etc/dex/tls.key
|
||||
|
||||
# Dex UI configuration
|
||||
# frontend:
|
||||
# issuer: dex
|
||||
# logoURL: theme/logo.png
|
||||
# dir: ""
|
||||
# theme: light
|
||||
|
||||
# Telemetry configuration
|
||||
# telemetry:
|
||||
# http: 127.0.0.1:5558
|
||||
|
||||
# logger:
|
||||
# level: "debug"
|
||||
# format: "text" # can also be "json"
|
||||
|
||||
# gRPC API configuration
|
||||
# Uncomment this block to enable the gRPC API.
|
||||
# See the documentation (https://dexidp.io/docs/api/) for further information.
|
||||
# grpc:
|
||||
# addr: 127.0.0.1:5557
|
||||
# tlsCert: examples/grpc-client/server.crt
|
||||
# tlsKey: examples/grpc-client/server.key
|
||||
# tlsClientCA: examples/grpc-client/ca.crt
|
||||
|
||||
# Expiration configuration for tokens, signing keys, etc.
|
||||
# expiry:
|
||||
# deviceRequests: "5m"
|
||||
# signingKeys: "6h"
|
||||
# idTokens: "24h"
|
||||
# refreshTokens:
|
||||
# disableRotation: false
|
||||
# reuseInterval: "3s"
|
||||
# validIfNotUsedFor: "2160h" # 90 days
|
||||
# absoluteLifetime: "3960h" # 165 days
|
||||
|
||||
# OAuth2 configuration
|
||||
# oauth2:
|
||||
# # use ["code", "token", "id_token"] to enable implicit flow for web-only clients
|
||||
# responseTypes: [ "code" ] # also allowed are "token" and "id_token"
|
||||
#
|
||||
# # By default, Dex will ask for approval to share data with application
|
||||
# # (approval for sharing data from connected IdP to Dex is separate process on IdP)
|
||||
# skipApprovalScreen: false
|
||||
#
|
||||
# # If only one authentication method is enabled, the default behavior is to
|
||||
# # go directly to it. For connected IdPs, this redirects the browser away
|
||||
# # from application to upstream provider such as the Google login page
|
||||
# alwaysShowLoginScreen: false
|
||||
#
|
||||
# # Uncomment to use a specific connector for password grants
|
||||
# passwordConnector: local
|
||||
|
||||
# Static clients registered in Dex by default.
|
||||
#
|
||||
# Alternatively, clients may be added through the gRPC API.
|
||||
# staticClients:
|
||||
# - id: example-app
|
||||
# redirectURIs:
|
||||
# - 'http://127.0.0.1:5555/callback'
|
||||
# name: 'Example App'
|
||||
# secret: ZXhhbXBsZS1hcHAtc2VjcmV0
|
||||
|
||||
# Connectors are used to authenticate users agains upstream identity providers.
|
||||
#
|
||||
# See the documentation (https://dexidp.io/docs/connectors/) for further information.
|
||||
# connectors: []
|
||||
|
||||
# Enable the password database.
|
||||
#
|
||||
# It's a "virtual" connector (identity provider) that stores
|
||||
# login credentials in Dex's store.
|
||||
enablePasswordDB: true
|
||||
|
||||
# If this option isn't chosen users may be added through the gRPC API.
|
||||
# A static list of passwords for the password connector.
|
||||
#
|
||||
# Alternatively, passwords my be added/updated through the gRPC API.
|
||||
# staticPasswords: []
|
|
@ -0,0 +1,449 @@
|
|||
// Package atlassiancrowd provides authentication strategies using Atlassian Crowd.
|
||||
package atlassiancrowd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dexidp/dex/connector"
|
||||
"github.com/dexidp/dex/pkg/groups"
|
||||
"github.com/dexidp/dex/pkg/log"
|
||||
)
|
||||
|
||||
// Config holds configuration options for Atlassian Crowd connector.
|
||||
// Crowd connectors require executing two queries, the first to find
|
||||
// the user based on the username and password given to the connector.
|
||||
// The second to use the user entry to search for groups.
|
||||
//
|
||||
// An example config:
|
||||
//
|
||||
// type: atlassian-crowd
|
||||
// config:
|
||||
// baseURL: https://crowd.example.com/context
|
||||
// clientID: applogin
|
||||
// clientSecret: appP4$$w0rd
|
||||
// # users can be restricted by a list of groups
|
||||
// groups:
|
||||
// - admin
|
||||
// # Prompt for username field
|
||||
// usernamePrompt: Login
|
||||
// preferredUsernameField: name
|
||||
//
|
||||
type Config struct {
|
||||
BaseURL string `json:"baseURL"`
|
||||
ClientID string `json:"clientID"`
|
||||
ClientSecret string `json:"clientSecret"`
|
||||
Groups []string `json:"groups"`
|
||||
|
||||
// PreferredUsernameField allows users to set the field to any of the
|
||||
// following values: "key", "name" or "email".
|
||||
// If unset, the preferred_username field will remain empty.
|
||||
PreferredUsernameField string `json:"preferredUsernameField"`
|
||||
|
||||
// UsernamePrompt allows users to override the username attribute (displayed
|
||||
// in the username/password prompt). If unset, the handler will use.
|
||||
// "Username".
|
||||
UsernamePrompt string `json:"usernamePrompt"`
|
||||
}
|
||||
|
||||
type crowdUser struct {
|
||||
Key string
|
||||
Name string
|
||||
Active bool
|
||||
Email string
|
||||
}
|
||||
|
||||
type crowdGroups struct {
|
||||
Groups []struct {
|
||||
Name string
|
||||
} `json:"groups"`
|
||||
}
|
||||
|
||||
type crowdAuthentication struct {
|
||||
Token string
|
||||
User struct {
|
||||
Name string
|
||||
} `json:"user"`
|
||||
CreatedDate uint64 `json:"created-date"`
|
||||
ExpiryDate uint64 `json:"expiry-date"`
|
||||
}
|
||||
|
||||
type crowdAuthenticationError struct {
|
||||
Reason string
|
||||
Message string
|
||||
}
|
||||
|
||||
// Open returns a strategy for logging in through Atlassian Crowd
|
||||
func (c *Config) Open(_ string, logger log.Logger) (connector.Connector, error) {
|
||||
if c.BaseURL == "" {
|
||||
return nil, fmt.Errorf("crowd: no baseURL provided for crowd connector")
|
||||
}
|
||||
return &crowdConnector{Config: *c, logger: logger}, nil
|
||||
}
|
||||
|
||||
type crowdConnector struct {
|
||||
Config
|
||||
logger log.Logger
|
||||
}
|
||||
|
||||
var (
|
||||
_ connector.PasswordConnector = (*crowdConnector)(nil)
|
||||
_ connector.RefreshConnector = (*crowdConnector)(nil)
|
||||
)
|
||||
|
||||
type refreshData struct {
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
func (c *crowdConnector) Login(ctx context.Context, s connector.Scopes, username, password string) (ident connector.Identity, validPass bool, err error) {
|
||||
// make this check to avoid empty passwords.
|
||||
if password == "" {
|
||||
return connector.Identity{}, false, nil
|
||||
}
|
||||
|
||||
// We want to return a different error if the user's password is incorrect vs
|
||||
// if there was an error.
|
||||
var incorrectPass bool
|
||||
var user crowdUser
|
||||
|
||||
client := c.crowdAPIClient()
|
||||
|
||||
if incorrectPass, err = c.authenticateWithPassword(ctx, client, username, password); err != nil {
|
||||
return connector.Identity{}, false, err
|
||||
}
|
||||
|
||||
if incorrectPass {
|
||||
return connector.Identity{}, false, nil
|
||||
}
|
||||
|
||||
if user, err = c.user(ctx, client, username); err != nil {
|
||||
return connector.Identity{}, false, err
|
||||
}
|
||||
|
||||
ident = c.identityFromCrowdUser(user)
|
||||
if s.Groups {
|
||||
userGroups, err := c.getGroups(ctx, client, s.Groups, ident.Username)
|
||||
if err != nil {
|
||||
return connector.Identity{}, false, fmt.Errorf("crowd: failed to query groups: %v", err)
|
||||
}
|
||||
ident.Groups = userGroups
|
||||
}
|
||||
|
||||
if s.OfflineAccess {
|
||||
refresh := refreshData{Username: username}
|
||||
// Encode entry for following up requests such as the groups query and refresh attempts.
|
||||
if ident.ConnectorData, err = json.Marshal(refresh); err != nil {
|
||||
return connector.Identity{}, false, fmt.Errorf("crowd: marshal refresh data: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return ident, true, nil
|
||||
}
|
||||
|
||||
func (c *crowdConnector) Refresh(ctx context.Context, s connector.Scopes, ident connector.Identity) (connector.Identity, error) {
|
||||
var data refreshData
|
||||
if err := json.Unmarshal(ident.ConnectorData, &data); err != nil {
|
||||
return ident, fmt.Errorf("crowd: failed to unmarshal internal data: %v", err)
|
||||
}
|
||||
|
||||
var user crowdUser
|
||||
client := c.crowdAPIClient()
|
||||
|
||||
user, err := c.user(ctx, client, data.Username)
|
||||
if err != nil {
|
||||
return ident, fmt.Errorf("crowd: get user %q: %v", data.Username, err)
|
||||
}
|
||||
|
||||
newIdent := c.identityFromCrowdUser(user)
|
||||
newIdent.ConnectorData = ident.ConnectorData
|
||||
|
||||
// If user exists, authenticate it to prolong sso session.
|
||||
err = c.authenticateUser(ctx, client, data.Username)
|
||||
if err != nil {
|
||||
return ident, fmt.Errorf("crowd: authenticate user: %v", err)
|
||||
}
|
||||
|
||||
if s.Groups {
|
||||
userGroups, err := c.getGroups(ctx, client, s.Groups, newIdent.Username)
|
||||
if err != nil {
|
||||
return connector.Identity{}, fmt.Errorf("crowd: failed to query groups: %v", err)
|
||||
}
|
||||
newIdent.Groups = userGroups
|
||||
}
|
||||
return newIdent, nil
|
||||
}
|
||||
|
||||
func (c *crowdConnector) Prompt() string {
|
||||
return c.UsernamePrompt
|
||||
}
|
||||
|
||||
func (c *crowdConnector) crowdAPIClient() *http.Client {
|
||||
return &http.Client{
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 30 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}).DialContext,
|
||||
MaxIdleConns: 100,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// authenticateWithPassword creates a new session for user and validates a password with Crowd API
|
||||
func (c *crowdConnector) authenticateWithPassword(ctx context.Context, client *http.Client, username string, password string) (invalidPass bool, err error) {
|
||||
req, err := c.crowdUserManagementRequest(ctx,
|
||||
"POST",
|
||||
"/session",
|
||||
struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}{Username: username, Password: password},
|
||||
)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("crowd: new auth pass api request %v", err)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("crowd: api request %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := c.validateCrowdResponse(resp)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
var authError crowdAuthenticationError
|
||||
if err := json.Unmarshal(body, &authError); err != nil {
|
||||
return false, fmt.Errorf("unmarshal auth pass response: %d %v %q", resp.StatusCode, err, string(body))
|
||||
}
|
||||
|
||||
if authError.Reason == "INVALID_USER_AUTHENTICATION" {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return false, fmt.Errorf("%s: %s", resp.Status, authError.Message)
|
||||
}
|
||||
|
||||
var authResponse crowdAuthentication
|
||||
|
||||
if err := json.Unmarshal(body, &authResponse); err != nil {
|
||||
return false, fmt.Errorf("decode auth response: %v", err)
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// authenticateUser creates a new session for user without password validations with Crowd API
|
||||
func (c *crowdConnector) authenticateUser(ctx context.Context, client *http.Client, username string) error {
|
||||
req, err := c.crowdUserManagementRequest(ctx,
|
||||
"POST",
|
||||
"/session?validate-password=false",
|
||||
struct {
|
||||
Username string `json:"username"`
|
||||
}{Username: username},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("crowd: new auth api request %v", err)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("crowd: api request %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := c.validateCrowdResponse(resp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
return fmt.Errorf("%s: %s", resp.Status, body)
|
||||
}
|
||||
|
||||
var authResponse crowdAuthentication
|
||||
|
||||
if err := json.Unmarshal(body, &authResponse); err != nil {
|
||||
return fmt.Errorf("decode auth response: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// user retrieves user info from Crowd API
|
||||
func (c *crowdConnector) user(ctx context.Context, client *http.Client, username string) (crowdUser, error) {
|
||||
var user crowdUser
|
||||
|
||||
req, err := c.crowdUserManagementRequest(ctx,
|
||||
"GET",
|
||||
fmt.Sprintf("/user?username=%s", username),
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return user, fmt.Errorf("crowd: new user api request %v", err)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return user, fmt.Errorf("crowd: api request %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := c.validateCrowdResponse(resp)
|
||||
if err != nil {
|
||||
return user, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return user, fmt.Errorf("%s: %s", resp.Status, body)
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &user); err != nil {
|
||||
return user, fmt.Errorf("failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// groups retrieves groups from Crowd API
|
||||
func (c *crowdConnector) groups(ctx context.Context, client *http.Client, username string) (userGroups []string, err error) {
|
||||
var crowdGroups crowdGroups
|
||||
|
||||
req, err := c.crowdUserManagementRequest(ctx,
|
||||
"GET",
|
||||
fmt.Sprintf("/user/group/nested?username=%s", username),
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("crowd: new groups api request %v", err)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("crowd: api request %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := c.validateCrowdResponse(resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("%s: %s", resp.Status, body)
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &crowdGroups); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
for _, group := range crowdGroups.Groups {
|
||||
userGroups = append(userGroups, group.Name)
|
||||
}
|
||||
|
||||
return userGroups, nil
|
||||
}
|
||||
|
||||
// identityFromCrowdUser converts crowdUser to Identity
|
||||
func (c *crowdConnector) identityFromCrowdUser(user crowdUser) connector.Identity {
|
||||
identity := connector.Identity{
|
||||
Username: user.Name,
|
||||
UserID: user.Key,
|
||||
Email: user.Email,
|
||||
EmailVerified: true,
|
||||
}
|
||||
|
||||
switch c.PreferredUsernameField {
|
||||
case "key":
|
||||
identity.PreferredUsername = user.Key
|
||||
case "name":
|
||||
identity.PreferredUsername = user.Name
|
||||
case "email":
|
||||
identity.PreferredUsername = user.Email
|
||||
default:
|
||||
if c.PreferredUsernameField != "" {
|
||||
c.logger.Warnf("preferred_username left empty. Invalid crowd field mapped to preferred_username: %s", c.PreferredUsernameField)
|
||||
}
|
||||
}
|
||||
|
||||
return identity
|
||||
}
|
||||
|
||||
// getGroups retrieves a list of user's groups and filters it
|
||||
func (c *crowdConnector) getGroups(ctx context.Context, client *http.Client, groupScope bool, userLogin string) ([]string, error) {
|
||||
crowdGroups, err := c.groups(ctx, client, userLogin)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(c.Groups) > 0 {
|
||||
filteredGroups := groups.Filter(crowdGroups, c.Groups)
|
||||
if len(filteredGroups) == 0 {
|
||||
return nil, fmt.Errorf("crowd: user %q is not in any of the required groups", userLogin)
|
||||
}
|
||||
return filteredGroups, nil
|
||||
} else if groupScope {
|
||||
return crowdGroups, nil
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// crowdUserManagementRequest create a http.Request with basic auth, json payload and Accept header
|
||||
func (c *crowdConnector) crowdUserManagementRequest(ctx context.Context, method string, apiURL string, jsonPayload interface{}) (*http.Request, error) {
|
||||
var body io.Reader
|
||||
if jsonPayload != nil {
|
||||
jsonData, err := json.Marshal(jsonPayload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("crowd: marshal API json payload: %v", err)
|
||||
}
|
||||
body = bytes.NewReader(jsonData)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, fmt.Sprintf("%s/rest/usermanagement/1%s", c.BaseURL, apiURL), body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new API req: %v", err)
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
// Crowd API requires a basic auth
|
||||
req.SetBasicAuth(c.ClientID, c.ClientSecret)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if jsonPayload != nil {
|
||||
req.Header.Set("Content-type", "application/json")
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// validateCrowdResponse validates unique not JSON responses from API
|
||||
func (c *crowdConnector) validateCrowdResponse(resp *http.Response) ([]byte, error) {
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("crowd: read user body: %v", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusForbidden && strings.Contains(string(body), "The server understood the request but refuses to authorize it.") {
|
||||
c.logger.Debugf("crowd response validation failed: %s", string(body))
|
||||
return nil, fmt.Errorf("dex is forbidden from making requests to the Atlassian Crowd application by URL %q", c.BaseURL)
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized && string(body) == "Application failed to authenticate" {
|
||||
c.logger.Debugf("crowd response validation failed: %s", string(body))
|
||||
return nil, fmt.Errorf("dex failed to authenticate Crowd Application with ID %q", c.ClientID)
|
||||
}
|
||||
return body, nil
|
||||
}
|
|
@ -0,0 +1,189 @@
|
|||
// Package atlassiancrowd provides authentication strategies using Atlassian Crowd.
|
||||
package atlassiancrowd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func TestUserGroups(t *testing.T) {
|
||||
s := newTestServer(map[string]TestServerResponse{
|
||||
"/rest/usermanagement/1/user/group/nested?username=testuser": {
|
||||
Body: crowdGroups{Groups: []struct{ Name string }{{Name: "group1"}, {Name: "group2"}}},
|
||||
Code: 200,
|
||||
},
|
||||
})
|
||||
defer s.Close()
|
||||
|
||||
c := newTestCrowdConnector(s.URL)
|
||||
groups, err := c.getGroups(context.Background(), newClient(), true, "testuser")
|
||||
|
||||
expectNil(t, err)
|
||||
expectEquals(t, groups, []string{"group1", "group2"})
|
||||
}
|
||||
|
||||
func TestUserGroupsWithFiltering(t *testing.T) {
|
||||
s := newTestServer(map[string]TestServerResponse{
|
||||
"/rest/usermanagement/1/user/group/nested?username=testuser": {
|
||||
Body: crowdGroups{Groups: []struct{ Name string }{{Name: "group1"}, {Name: "group2"}}},
|
||||
Code: 200,
|
||||
},
|
||||
})
|
||||
defer s.Close()
|
||||
|
||||
c := newTestCrowdConnector(s.URL)
|
||||
c.Groups = []string{"group1"}
|
||||
groups, err := c.getGroups(context.Background(), newClient(), true, "testuser")
|
||||
|
||||
expectNil(t, err)
|
||||
expectEquals(t, groups, []string{"group1"})
|
||||
}
|
||||
|
||||
func TestUserLoginFlow(t *testing.T) {
|
||||
s := newTestServer(map[string]TestServerResponse{
|
||||
"/rest/usermanagement/1/session?validate-password=false": {
|
||||
Body: crowdAuthentication{},
|
||||
Code: 201,
|
||||
},
|
||||
"/rest/usermanagement/1/user?username=testuser": {
|
||||
Body: crowdUser{Active: true, Name: "testuser", Email: "testuser@example.com"},
|
||||
Code: 200,
|
||||
},
|
||||
"/rest/usermanagement/1/user?username=testuser2": {
|
||||
Body: `<html>The server understood the request but refuses to authorize it.</html>`,
|
||||
Code: 403,
|
||||
},
|
||||
})
|
||||
defer s.Close()
|
||||
|
||||
c := newTestCrowdConnector(s.URL)
|
||||
user, err := c.user(context.Background(), newClient(), "testuser")
|
||||
expectNil(t, err)
|
||||
expectEquals(t, user.Name, "testuser")
|
||||
expectEquals(t, user.Email, "testuser@example.com")
|
||||
|
||||
err = c.authenticateUser(context.Background(), newClient(), "testuser")
|
||||
expectNil(t, err)
|
||||
|
||||
_, err = c.user(context.Background(), newClient(), "testuser2")
|
||||
expectEquals(t, err, fmt.Errorf("dex is forbidden from making requests to the Atlassian Crowd application by URL %q", s.URL))
|
||||
}
|
||||
|
||||
func TestUserPassword(t *testing.T) {
|
||||
s := newTestServer(map[string]TestServerResponse{
|
||||
"/rest/usermanagement/1/session": {
|
||||
Body: crowdAuthenticationError{Reason: "INVALID_USER_AUTHENTICATION", Message: "test"},
|
||||
Code: 401,
|
||||
},
|
||||
"/rest/usermanagement/1/session?validate-password=false": {
|
||||
Body: crowdAuthentication{},
|
||||
Code: 201,
|
||||
},
|
||||
})
|
||||
defer s.Close()
|
||||
|
||||
c := newTestCrowdConnector(s.URL)
|
||||
invalidPassword, err := c.authenticateWithPassword(context.Background(), newClient(), "testuser", "testpassword")
|
||||
|
||||
expectNil(t, err)
|
||||
expectEquals(t, invalidPassword, true)
|
||||
|
||||
err = c.authenticateUser(context.Background(), newClient(), "testuser")
|
||||
expectNil(t, err)
|
||||
}
|
||||
|
||||
func TestIdentityFromCrowdUser(t *testing.T) {
|
||||
user := crowdUser{
|
||||
Key: "12345",
|
||||
Name: "testuser",
|
||||
Active: true,
|
||||
Email: "testuser@example.com",
|
||||
}
|
||||
|
||||
c := newTestCrowdConnector("/")
|
||||
|
||||
// Sanity checks
|
||||
expectEquals(t, user.Name, "testuser")
|
||||
expectEquals(t, user.Email, "testuser@example.com")
|
||||
|
||||
// Test unconfigured behaviour
|
||||
i := c.identityFromCrowdUser(user)
|
||||
expectEquals(t, i.UserID, "12345")
|
||||
expectEquals(t, i.Username, "testuser")
|
||||
expectEquals(t, i.Email, "testuser@example.com")
|
||||
expectEquals(t, i.EmailVerified, true)
|
||||
|
||||
// Test for various PreferredUsernameField settings
|
||||
// unset
|
||||
expectEquals(t, i.PreferredUsername, "")
|
||||
|
||||
c.Config.PreferredUsernameField = "key"
|
||||
i = c.identityFromCrowdUser(user)
|
||||
expectEquals(t, i.PreferredUsername, "12345")
|
||||
|
||||
c.Config.PreferredUsernameField = "name"
|
||||
i = c.identityFromCrowdUser(user)
|
||||
expectEquals(t, i.PreferredUsername, "testuser")
|
||||
|
||||
c.Config.PreferredUsernameField = "email"
|
||||
i = c.identityFromCrowdUser(user)
|
||||
expectEquals(t, i.PreferredUsername, "testuser@example.com")
|
||||
|
||||
c.Config.PreferredUsernameField = "invalidstring"
|
||||
i = c.identityFromCrowdUser(user)
|
||||
expectEquals(t, i.PreferredUsername, "")
|
||||
}
|
||||
|
||||
type TestServerResponse struct {
|
||||
Body interface{}
|
||||
Code int
|
||||
}
|
||||
|
||||
func newTestCrowdConnector(baseURL string) crowdConnector {
|
||||
connector := crowdConnector{}
|
||||
connector.BaseURL = baseURL
|
||||
connector.logger = &logrus.Logger{
|
||||
Out: io.Discard,
|
||||
Level: logrus.DebugLevel,
|
||||
Formatter: &logrus.TextFormatter{DisableColors: true},
|
||||
}
|
||||
return connector
|
||||
}
|
||||
|
||||
func newTestServer(responses map[string]TestServerResponse) *httptest.Server {
|
||||
s := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
response := responses[r.RequestURI]
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
w.WriteHeader(response.Code)
|
||||
json.NewEncoder(w).Encode(response.Body)
|
||||
}))
|
||||
return s
|
||||
}
|
||||
|
||||
func newClient() *http.Client {
|
||||
tr := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
return &http.Client{Transport: tr}
|
||||
}
|
||||
|
||||
func expectNil(t *testing.T, a interface{}) {
|
||||
if a != nil {
|
||||
t.Errorf("Expected %+v to equal nil", a)
|
||||
}
|
||||
}
|
||||
|
||||
func expectEquals(t *testing.T, a interface{}, b interface{}) {
|
||||
if !reflect.DeepEqual(a, b) {
|
||||
t.Errorf("Expected %+v to equal %+v", a, b)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,80 @@
|
|||
// Package authproxy implements a connector which relies on external
|
||||
// authentication (e.g. mod_auth in Apache2) and returns an identity with the
|
||||
// HTTP header X-Remote-User as verified email.
|
||||
package authproxy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/dexidp/dex/connector"
|
||||
"github.com/dexidp/dex/pkg/log"
|
||||
)
|
||||
|
||||
// Config holds the configuration parameters for a connector which returns an
|
||||
// identity with the HTTP header X-Remote-User as verified email,
|
||||
// X-Remote-Group and configured staticGroups as user's group.
|
||||
// Headers retrieved to fetch user's email and group can be configured
|
||||
// with userHeader and groupHeader.
|
||||
type Config struct {
|
||||
UserHeader string `json:"userHeader"`
|
||||
GroupHeader string `json:"groupHeader"`
|
||||
Groups []string `json:"staticGroups"`
|
||||
}
|
||||
|
||||
// Open returns an authentication strategy which requires no user interaction.
|
||||
func (c *Config) Open(id string, logger log.Logger) (connector.Connector, error) {
|
||||
userHeader := c.UserHeader
|
||||
if userHeader == "" {
|
||||
userHeader = "X-Remote-User"
|
||||
}
|
||||
groupHeader := c.GroupHeader
|
||||
if groupHeader == "" {
|
||||
groupHeader = "X-Remote-Group"
|
||||
}
|
||||
|
||||
return &callback{userHeader: userHeader, groupHeader: groupHeader, logger: logger, pathSuffix: "/" + id, groups: c.Groups}, nil
|
||||
}
|
||||
|
||||
// Callback is a connector which returns an identity with the HTTP header
|
||||
// X-Remote-User as verified email.
|
||||
type callback struct {
|
||||
userHeader string
|
||||
groupHeader string
|
||||
groups []string
|
||||
logger log.Logger
|
||||
pathSuffix string
|
||||
}
|
||||
|
||||
// LoginURL returns the URL to redirect the user to login with.
|
||||
func (m *callback) LoginURL(s connector.Scopes, callbackURL, state string) (string, error) {
|
||||
u, err := url.Parse(callbackURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to parse callbackURL %q: %v", callbackURL, err)
|
||||
}
|
||||
u.Path += m.pathSuffix
|
||||
v := u.Query()
|
||||
v.Set("state", state)
|
||||
u.RawQuery = v.Encode()
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
// HandleCallback parses the request and returns the user's identity
|
||||
func (m *callback) HandleCallback(s connector.Scopes, r *http.Request) (connector.Identity, error) {
|
||||
remoteUser := r.Header.Get(m.userHeader)
|
||||
if remoteUser == "" {
|
||||
return connector.Identity{}, fmt.Errorf("required HTTP header %s is not set", m.userHeader)
|
||||
}
|
||||
groups := m.groups
|
||||
headerGroup := r.Header.Get(m.groupHeader)
|
||||
if headerGroup != "" {
|
||||
groups = append(groups, headerGroup)
|
||||
}
|
||||
return connector.Identity{
|
||||
UserID: remoteUser, // TODO: figure out if this is a bad ID value.
|
||||
Email: remoteUser,
|
||||
EmailVerified: true,
|
||||
Groups: groups,
|
||||
}, nil
|
||||
}
|
|
@ -0,0 +1,468 @@
|
|||
// Package bitbucketcloud provides authentication strategies using Bitbucket Cloud.
|
||||
package bitbucketcloud
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/bitbucket"
|
||||
|
||||
"github.com/dexidp/dex/connector"
|
||||
"github.com/dexidp/dex/pkg/groups"
|
||||
"github.com/dexidp/dex/pkg/log"
|
||||
)
|
||||
|
||||
const (
|
||||
apiURL = "https://api.bitbucket.org/2.0"
|
||||
// Switch to API v2.0 when the Atlassian platform services are fully available in Bitbucket
|
||||
legacyAPIURL = "https://api.bitbucket.org/1.0"
|
||||
// Bitbucket requires this scope to access '/user' API endpoints.
|
||||
scopeAccount = "account"
|
||||
// Bitbucket requires this scope to access '/user/emails' API endpoints.
|
||||
scopeEmail = "email"
|
||||
// Bitbucket requires this scope to access '/teams' API endpoints
|
||||
// which are used when a client includes the 'groups' scope.
|
||||
scopeTeams = "team"
|
||||
)
|
||||
|
||||
// Config holds configuration options for Bitbucket logins.
|
||||
type Config struct {
|
||||
ClientID string `json:"clientID"`
|
||||
ClientSecret string `json:"clientSecret"`
|
||||
RedirectURI string `json:"redirectURI"`
|
||||
Teams []string `json:"teams"`
|
||||
IncludeTeamGroups bool `json:"includeTeamGroups,omitempty"`
|
||||
}
|
||||
|
||||
// Open returns a strategy for logging in through Bitbucket.
|
||||
func (c *Config) Open(_ string, logger log.Logger) (connector.Connector, error) {
|
||||
b := bitbucketConnector{
|
||||
redirectURI: c.RedirectURI,
|
||||
teams: c.Teams,
|
||||
clientID: c.ClientID,
|
||||
clientSecret: c.ClientSecret,
|
||||
includeTeamGroups: c.IncludeTeamGroups,
|
||||
apiURL: apiURL,
|
||||
legacyAPIURL: legacyAPIURL,
|
||||
logger: logger,
|
||||
}
|
||||
|
||||
return &b, nil
|
||||
}
|
||||
|
||||
type connectorData struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
RefreshToken string `json:"refreshToken"`
|
||||
Expiry time.Time `json:"expiry"`
|
||||
}
|
||||
|
||||
var (
|
||||
_ connector.CallbackConnector = (*bitbucketConnector)(nil)
|
||||
_ connector.RefreshConnector = (*bitbucketConnector)(nil)
|
||||
)
|
||||
|
||||
type bitbucketConnector struct {
|
||||
redirectURI string
|
||||
teams []string
|
||||
clientID string
|
||||
clientSecret string
|
||||
logger log.Logger
|
||||
apiURL string
|
||||
legacyAPIURL string
|
||||
|
||||
// the following are used only for tests
|
||||
hostName string
|
||||
httpClient *http.Client
|
||||
|
||||
includeTeamGroups bool
|
||||
}
|
||||
|
||||
// groupsRequired returns whether dex requires Bitbucket's 'team' scope.
|
||||
func (b *bitbucketConnector) groupsRequired(groupScope bool) bool {
|
||||
return len(b.teams) > 0 || groupScope
|
||||
}
|
||||
|
||||
func (b *bitbucketConnector) oauth2Config(scopes connector.Scopes) *oauth2.Config {
|
||||
bitbucketScopes := []string{scopeAccount, scopeEmail}
|
||||
if b.groupsRequired(scopes.Groups) {
|
||||
bitbucketScopes = append(bitbucketScopes, scopeTeams)
|
||||
}
|
||||
|
||||
endpoint := bitbucket.Endpoint
|
||||
if b.hostName != "" {
|
||||
endpoint = oauth2.Endpoint{
|
||||
AuthURL: "https://" + b.hostName + "/site/oauth2/authorize",
|
||||
TokenURL: "https://" + b.hostName + "/site/oauth2/access_token",
|
||||
}
|
||||
}
|
||||
|
||||
return &oauth2.Config{
|
||||
ClientID: b.clientID,
|
||||
ClientSecret: b.clientSecret,
|
||||
Endpoint: endpoint,
|
||||
Scopes: bitbucketScopes,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *bitbucketConnector) LoginURL(scopes connector.Scopes, callbackURL, state string) (string, error) {
|
||||
if b.redirectURI != callbackURL {
|
||||
return "", fmt.Errorf("expected callback URL %q did not match the URL in the config %q", callbackURL, b.redirectURI)
|
||||
}
|
||||
|
||||
return b.oauth2Config(scopes).AuthCodeURL(state), nil
|
||||
}
|
||||
|
||||
type oauth2Error struct {
|
||||
error string
|
||||
errorDescription string
|
||||
}
|
||||
|
||||
func (e *oauth2Error) Error() string {
|
||||
if e.errorDescription == "" {
|
||||
return e.error
|
||||
}
|
||||
return e.error + ": " + e.errorDescription
|
||||
}
|
||||
|
||||
func (b *bitbucketConnector) HandleCallback(s connector.Scopes, r *http.Request) (identity connector.Identity, err error) {
|
||||
q := r.URL.Query()
|
||||
if errType := q.Get("error"); errType != "" {
|
||||
return identity, &oauth2Error{errType, q.Get("error_description")}
|
||||
}
|
||||
|
||||
oauth2Config := b.oauth2Config(s)
|
||||
|
||||
ctx := r.Context()
|
||||
if b.httpClient != nil {
|
||||
ctx = context.WithValue(r.Context(), oauth2.HTTPClient, b.httpClient)
|
||||
}
|
||||
|
||||
token, err := oauth2Config.Exchange(ctx, q.Get("code"))
|
||||
if err != nil {
|
||||
return identity, fmt.Errorf("bitbucket: failed to get token: %v", err)
|
||||
}
|
||||
|
||||
client := oauth2Config.Client(ctx, token)
|
||||
|
||||
user, err := b.user(ctx, client)
|
||||
if err != nil {
|
||||
return identity, fmt.Errorf("bitbucket: get user: %v", err)
|
||||
}
|
||||
|
||||
identity = connector.Identity{
|
||||
UserID: user.UUID,
|
||||
Username: user.Username,
|
||||
Email: user.Email,
|
||||
EmailVerified: true,
|
||||
}
|
||||
|
||||
if b.groupsRequired(s.Groups) {
|
||||
groups, err := b.getGroups(ctx, client, s.Groups, user.Username)
|
||||
if err != nil {
|
||||
return identity, err
|
||||
}
|
||||
identity.Groups = groups
|
||||
}
|
||||
|
||||
if s.OfflineAccess {
|
||||
data := connectorData{
|
||||
AccessToken: token.AccessToken,
|
||||
RefreshToken: token.RefreshToken,
|
||||
Expiry: token.Expiry,
|
||||
}
|
||||
connData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return identity, fmt.Errorf("bitbucket: marshal connector data: %v", err)
|
||||
}
|
||||
identity.ConnectorData = connData
|
||||
}
|
||||
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
// Refreshing tokens
|
||||
// https://github.com/golang/oauth2/issues/84#issuecomment-332860871
|
||||
type tokenNotifyFunc func(*oauth2.Token) error
|
||||
|
||||
// notifyRefreshTokenSource is essentially `oauth2.ReuseTokenSource` with `TokenNotifyFunc` added.
|
||||
type notifyRefreshTokenSource struct {
|
||||
new oauth2.TokenSource
|
||||
mu sync.Mutex // guards t
|
||||
t *oauth2.Token
|
||||
f tokenNotifyFunc // called when token refreshed so new refresh token can be persisted
|
||||
}
|
||||
|
||||
// Token returns the current token if it's still valid, else will
|
||||
// refresh the current token (using r.Context for HTTP client
|
||||
// information) and return the new one.
|
||||
func (s *notifyRefreshTokenSource) Token() (*oauth2.Token, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.t.Valid() {
|
||||
return s.t, nil
|
||||
}
|
||||
t, err := s.new.Token()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.t = t
|
||||
return t, s.f(t)
|
||||
}
|
||||
|
||||
func (b *bitbucketConnector) Refresh(ctx context.Context, s connector.Scopes, identity connector.Identity) (connector.Identity, error) {
|
||||
if len(identity.ConnectorData) == 0 {
|
||||
return identity, errors.New("bitbucket: no upstream access token found")
|
||||
}
|
||||
|
||||
var data connectorData
|
||||
if err := json.Unmarshal(identity.ConnectorData, &data); err != nil {
|
||||
return identity, fmt.Errorf("bitbucket: unmarshal access token: %v", err)
|
||||
}
|
||||
|
||||
tok := &oauth2.Token{
|
||||
AccessToken: data.AccessToken,
|
||||
RefreshToken: data.RefreshToken,
|
||||
Expiry: data.Expiry,
|
||||
}
|
||||
|
||||
client := oauth2.NewClient(ctx, ¬ifyRefreshTokenSource{
|
||||
new: b.oauth2Config(s).TokenSource(ctx, tok),
|
||||
t: tok,
|
||||
f: func(tok *oauth2.Token) error {
|
||||
data := connectorData{
|
||||
AccessToken: tok.AccessToken,
|
||||
RefreshToken: tok.RefreshToken,
|
||||
Expiry: tok.Expiry,
|
||||
}
|
||||
connData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("bitbucket: marshal connector data: %v", err)
|
||||
}
|
||||
identity.ConnectorData = connData
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
user, err := b.user(ctx, client)
|
||||
if err != nil {
|
||||
return identity, fmt.Errorf("bitbucket: get user: %v", err)
|
||||
}
|
||||
|
||||
identity.Username = user.Username
|
||||
identity.Email = user.Email
|
||||
|
||||
if b.groupsRequired(s.Groups) {
|
||||
groups, err := b.getGroups(ctx, client, s.Groups, user.Username)
|
||||
if err != nil {
|
||||
return identity, err
|
||||
}
|
||||
identity.Groups = groups
|
||||
}
|
||||
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
// Bitbucket pagination wrapper
|
||||
type pagedResponse struct {
|
||||
Size int `json:"size"`
|
||||
Page int `json:"page"`
|
||||
PageLen int `json:"pagelen"`
|
||||
Next *string `json:"next"`
|
||||
Previous *string `json:"previous"`
|
||||
}
|
||||
|
||||
// user holds Bitbucket user information (relevant to dex) as defined by
|
||||
// https://developer.atlassian.com/bitbucket/api/2/reference/resource/user
|
||||
type user struct {
|
||||
Username string `json:"username"`
|
||||
UUID string `json:"uuid"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
// user queries the Bitbucket API for profile information using the provided client.
|
||||
//
|
||||
// The HTTP client is expected to be constructed by the golang.org/x/oauth2 package,
|
||||
// which inserts a bearer token as part of the request.
|
||||
func (b *bitbucketConnector) user(ctx context.Context, client *http.Client) (user, error) {
|
||||
// https://developer.atlassian.com/bitbucket/api/2/reference/resource/user
|
||||
var (
|
||||
u user
|
||||
err error
|
||||
)
|
||||
|
||||
if err = get(ctx, client, b.apiURL+"/user", &u); err != nil {
|
||||
return user{}, err
|
||||
}
|
||||
|
||||
if u.Email, err = b.userEmail(ctx, client); err != nil {
|
||||
return user{}, err
|
||||
}
|
||||
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// userEmail holds Bitbucket user email information as defined by
|
||||
// https://developer.atlassian.com/bitbucket/api/2/reference/resource/user/emails
|
||||
type userEmail struct {
|
||||
IsPrimary bool `json:"is_primary"`
|
||||
IsConfirmed bool `json:"is_confirmed"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
type userEmailResponse struct {
|
||||
pagedResponse
|
||||
Values []userEmail
|
||||
}
|
||||
|
||||
// userEmail returns the users primary, confirmed email
|
||||
//
|
||||
// The HTTP client is expected to be constructed by the golang.org/x/oauth2 package,
|
||||
// which inserts a bearer token as part of the request.
|
||||
func (b *bitbucketConnector) userEmail(ctx context.Context, client *http.Client) (string, error) {
|
||||
apiURL := b.apiURL + "/user/emails"
|
||||
for {
|
||||
// https://developer.atlassian.com/bitbucket/api/2/reference/resource/user/emails
|
||||
var response userEmailResponse
|
||||
|
||||
if err := get(ctx, client, apiURL, &response); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
for _, email := range response.Values {
|
||||
if email.IsConfirmed && email.IsPrimary {
|
||||
return email.Email, nil
|
||||
}
|
||||
}
|
||||
|
||||
if response.Next == nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return "", errors.New("bitbucket: user has no confirmed, primary email")
|
||||
}
|
||||
|
||||
// getGroups retrieves Bitbucket teams a user is in, if any.
|
||||
func (b *bitbucketConnector) getGroups(ctx context.Context, client *http.Client, groupScope bool, userLogin string) ([]string, error) {
|
||||
bitbucketTeams, err := b.userWorkspaces(ctx, client)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(b.teams) > 0 {
|
||||
filteredTeams := groups.Filter(bitbucketTeams, b.teams)
|
||||
if len(filteredTeams) == 0 {
|
||||
return nil, fmt.Errorf("bitbucket: user %q is not in any of the required teams", userLogin)
|
||||
}
|
||||
return filteredTeams, nil
|
||||
} else if groupScope {
|
||||
return bitbucketTeams, nil
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
type workspaceSlug struct {
|
||||
Slug string `json:"slug"`
|
||||
}
|
||||
|
||||
type workspace struct {
|
||||
Workspace workspaceSlug `json:"workspace"`
|
||||
}
|
||||
|
||||
type userWorkspacesResponse struct {
|
||||
pagedResponse
|
||||
Values []workspace `json:"values"`
|
||||
}
|
||||
|
||||
func (b *bitbucketConnector) userWorkspaces(ctx context.Context, client *http.Client) ([]string, error) {
|
||||
var teams []string
|
||||
apiURL := b.apiURL + "/user/permissions/workspaces"
|
||||
|
||||
for {
|
||||
// https://developer.atlassian.com/cloud/bitbucket/rest/api-group-workspaces/#api-workspaces-get
|
||||
var response userWorkspacesResponse
|
||||
|
||||
if err := get(ctx, client, apiURL, &response); err != nil {
|
||||
return nil, fmt.Errorf("bitbucket: get user teams: %v", err)
|
||||
}
|
||||
|
||||
for _, value := range response.Values {
|
||||
teams = append(teams, value.Workspace.Slug)
|
||||
}
|
||||
|
||||
if response.Next == nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if b.includeTeamGroups {
|
||||
for _, team := range teams {
|
||||
teamGroups, err := b.userTeamGroups(ctx, client, team)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("bitbucket: %v", err)
|
||||
}
|
||||
teams = append(teams, teamGroups...)
|
||||
}
|
||||
}
|
||||
|
||||
return teams, nil
|
||||
}
|
||||
|
||||
type group struct {
|
||||
Slug string `json:"slug"`
|
||||
}
|
||||
|
||||
func (b *bitbucketConnector) userTeamGroups(ctx context.Context, client *http.Client, teamName string) ([]string, error) {
|
||||
apiURL := b.legacyAPIURL + "/groups/" + teamName
|
||||
|
||||
var response []group
|
||||
if err := get(ctx, client, apiURL, &response); err != nil {
|
||||
return nil, fmt.Errorf("get user team %q groups: %v", teamName, err)
|
||||
}
|
||||
|
||||
teamGroups := make([]string, 0, len(response))
|
||||
for _, group := range response {
|
||||
teamGroups = append(teamGroups, teamName+"/"+group.Slug)
|
||||
}
|
||||
|
||||
return teamGroups, nil
|
||||
}
|
||||
|
||||
// get creates a "GET `apiURL`" request with context, sends the request using
|
||||
// the client, and decodes the resulting response body into v.
|
||||
// Any errors encountered when building requests, sending requests, and
|
||||
// reading and decoding response data are returned.
|
||||
func get(ctx context.Context, client *http.Client, apiURL string, v interface{}) error {
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("bitbucket: new req: %v", err)
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("bitbucket: get URL %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("bitbucket: read body: %s: %v", resp.Status, err)
|
||||
}
|
||||
return fmt.Errorf("%s: %s", resp.Status, body)
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(v); err != nil {
|
||||
return fmt.Errorf("bitbucket: failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
|
@ -0,0 +1,137 @@
|
|||
package bitbucketcloud
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/dexidp/dex/connector"
|
||||
)
|
||||
|
||||
func TestUserGroups(t *testing.T) {
|
||||
teamsResponse := userWorkspacesResponse{
|
||||
pagedResponse: pagedResponse{
|
||||
Size: 3,
|
||||
Page: 1,
|
||||
PageLen: 10,
|
||||
},
|
||||
Values: []workspace{
|
||||
{Workspace: workspaceSlug{Slug: "team-1"}},
|
||||
{Workspace: workspaceSlug{Slug: "team-2"}},
|
||||
{Workspace: workspaceSlug{Slug: "team-3"}},
|
||||
},
|
||||
}
|
||||
|
||||
s := newTestServer(map[string]interface{}{
|
||||
"/user/permissions/workspaces": teamsResponse,
|
||||
"/groups/team-1": []group{{Slug: "administrators"}, {Slug: "members"}},
|
||||
"/groups/team-2": []group{{Slug: "everyone"}},
|
||||
"/groups/team-3": []group{},
|
||||
})
|
||||
|
||||
connector := bitbucketConnector{apiURL: s.URL, legacyAPIURL: s.URL}
|
||||
groups, err := connector.userWorkspaces(context.Background(), newClient())
|
||||
|
||||
expectNil(t, err)
|
||||
expectEquals(t, groups, []string{
|
||||
"team-1",
|
||||
"team-2",
|
||||
"team-3",
|
||||
})
|
||||
|
||||
connector.includeTeamGroups = true
|
||||
groups, err = connector.userWorkspaces(context.Background(), newClient())
|
||||
|
||||
expectNil(t, err)
|
||||
expectEquals(t, groups, []string{
|
||||
"team-1",
|
||||
"team-2",
|
||||
"team-3",
|
||||
"team-1/administrators",
|
||||
"team-1/members",
|
||||
"team-2/everyone",
|
||||
})
|
||||
|
||||
s.Close()
|
||||
}
|
||||
|
||||
func TestUserWithoutTeams(t *testing.T) {
|
||||
s := newTestServer(map[string]interface{}{
|
||||
"/user/permissions/workspaces": userWorkspacesResponse{},
|
||||
})
|
||||
|
||||
connector := bitbucketConnector{apiURL: s.URL}
|
||||
groups, err := connector.userWorkspaces(context.Background(), newClient())
|
||||
|
||||
expectNil(t, err)
|
||||
expectEquals(t, len(groups), 0)
|
||||
|
||||
s.Close()
|
||||
}
|
||||
|
||||
func TestUsernameIncludedInFederatedIdentity(t *testing.T) {
|
||||
s := newTestServer(map[string]interface{}{
|
||||
"/user": user{Username: "some-login"},
|
||||
"/user/emails": userEmailResponse{
|
||||
pagedResponse: pagedResponse{
|
||||
Size: 1,
|
||||
Page: 1,
|
||||
PageLen: 10,
|
||||
},
|
||||
Values: []userEmail{{
|
||||
Email: "some@email.com",
|
||||
IsConfirmed: true,
|
||||
IsPrimary: true,
|
||||
}},
|
||||
},
|
||||
"/site/oauth2/access_token": map[string]interface{}{
|
||||
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9",
|
||||
"expires_in": "30",
|
||||
},
|
||||
})
|
||||
|
||||
hostURL, err := url.Parse(s.URL)
|
||||
expectNil(t, err)
|
||||
|
||||
req, err := http.NewRequest("GET", hostURL.String(), nil)
|
||||
expectNil(t, err)
|
||||
|
||||
bitbucketConnector := bitbucketConnector{apiURL: s.URL, hostName: hostURL.Host, httpClient: newClient()}
|
||||
identity, err := bitbucketConnector.HandleCallback(connector.Scopes{}, req)
|
||||
|
||||
expectNil(t, err)
|
||||
expectEquals(t, identity.Username, "some-login")
|
||||
|
||||
s.Close()
|
||||
}
|
||||
|
||||
func newTestServer(responses map[string]interface{}) *httptest.Server {
|
||||
return httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(responses[r.URL.String()])
|
||||
}))
|
||||
}
|
||||
|
||||
func newClient() *http.Client {
|
||||
tr := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
return &http.Client{Transport: tr}
|
||||
}
|
||||
|
||||
func expectNil(t *testing.T, a interface{}) {
|
||||
if a != nil {
|
||||
t.Fatalf("Expected %+v to equal nil", a)
|
||||
}
|
||||
}
|
||||
|
||||
func expectEquals(t *testing.T, a interface{}, b interface{}) {
|
||||
if !reflect.DeepEqual(a, b) {
|
||||
t.Fatalf("Expected %+v to equal %+v", a, b)
|
||||
}
|
||||
}
|
|
@ -23,10 +23,11 @@ type Scopes struct {
|
|||
|
||||
// Identity represents the ID Token claims supported by the server.
|
||||
type Identity struct {
|
||||
UserID string
|
||||
Username string
|
||||
Email string
|
||||
EmailVerified bool
|
||||
UserID string
|
||||
Username string
|
||||
PreferredUsername string
|
||||
Email string
|
||||
EmailVerified bool
|
||||
|
||||
Groups []string
|
||||
|
||||
|
@ -39,7 +40,10 @@ type Identity struct {
|
|||
|
||||
// PasswordConnector is an interface implemented by connectors which take a
|
||||
// username and password.
|
||||
// Prompt() is used to inform the handler what to display in the password
|
||||
// template. If this returns an empty string, it'll default to "Username".
|
||||
type PasswordConnector interface {
|
||||
Prompt() string
|
||||
Login(ctx context.Context, s Scopes, username, password string) (identity Identity, validPassword bool, err error)
|
||||
}
|
||||
|
||||
|
@ -76,7 +80,7 @@ type SAMLConnector interface {
|
|||
//
|
||||
// POSTData should encode the provided request ID in the returned serialized
|
||||
// SAML request.
|
||||
POSTData(s Scopes, requestID string) (sooURL, samlRequest string, err error)
|
||||
POSTData(s Scopes, requestID string) (ssoURL, samlRequest string, err error)
|
||||
|
||||
// HandlePOST decodes, verifies, and maps attributes from the SAML response.
|
||||
// It passes the expected value of the "InResponseTo" response field, which
|
||||
|
|
|
@ -0,0 +1,424 @@
|
|||
// Package gitea provides authentication strategies using Gitea.
|
||||
package gitea
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"github.com/dexidp/dex/connector"
|
||||
"github.com/dexidp/dex/pkg/log"
|
||||
)
|
||||
|
||||
// Config holds configuration options for gitea logins.
|
||||
type Config struct {
|
||||
BaseURL string `json:"baseURL"`
|
||||
ClientID string `json:"clientID"`
|
||||
ClientSecret string `json:"clientSecret"`
|
||||
RedirectURI string `json:"redirectURI"`
|
||||
Orgs []Org `json:"orgs"`
|
||||
LoadAllGroups bool `json:"loadAllGroups"`
|
||||
UseLoginAsID bool `json:"useLoginAsID"`
|
||||
}
|
||||
|
||||
// Org holds org-team filters, in which teams are optional.
|
||||
type Org struct {
|
||||
// Organization name in gitea (not slug, full name). Only users in this gitea
|
||||
// organization can authenticate.
|
||||
Name string `json:"name"`
|
||||
|
||||
// Names of teams in a gitea organization. A user will be able to
|
||||
// authenticate if they are members of at least one of these teams. Users
|
||||
// in the organization can authenticate if this field is omitted from the
|
||||
// config file.
|
||||
Teams []string `json:"teams,omitempty"`
|
||||
}
|
||||
|
||||
type giteaUser struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"full_name"`
|
||||
Username string `json:"login"`
|
||||
Email string `json:"email"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
}
|
||||
|
||||
// Open returns a strategy for logging in through Gitea
|
||||
func (c *Config) Open(id string, logger log.Logger) (connector.Connector, error) {
|
||||
if c.BaseURL == "" {
|
||||
c.BaseURL = "https://gitea.com"
|
||||
}
|
||||
return &giteaConnector{
|
||||
baseURL: c.BaseURL,
|
||||
redirectURI: c.RedirectURI,
|
||||
orgs: c.Orgs,
|
||||
clientID: c.ClientID,
|
||||
clientSecret: c.ClientSecret,
|
||||
logger: logger,
|
||||
loadAllGroups: c.LoadAllGroups,
|
||||
useLoginAsID: c.UseLoginAsID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type connectorData struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
RefreshToken string `json:"refreshToken"`
|
||||
Expiry time.Time `json:"expiry"`
|
||||
}
|
||||
|
||||
var (
|
||||
_ connector.CallbackConnector = (*giteaConnector)(nil)
|
||||
_ connector.RefreshConnector = (*giteaConnector)(nil)
|
||||
)
|
||||
|
||||
type giteaConnector struct {
|
||||
baseURL string
|
||||
redirectURI string
|
||||
orgs []Org
|
||||
clientID string
|
||||
clientSecret string
|
||||
logger log.Logger
|
||||
httpClient *http.Client
|
||||
// if set to true and no orgs are configured then connector loads all user claims (all orgs and team)
|
||||
loadAllGroups bool
|
||||
// if set to true will use the user's handle rather than their numeric id as the ID
|
||||
useLoginAsID bool
|
||||
}
|
||||
|
||||
func (c *giteaConnector) oauth2Config(_ connector.Scopes) *oauth2.Config {
|
||||
giteaEndpoint := oauth2.Endpoint{AuthURL: c.baseURL + "/login/oauth/authorize", TokenURL: c.baseURL + "/login/oauth/access_token"}
|
||||
return &oauth2.Config{
|
||||
ClientID: c.clientID,
|
||||
ClientSecret: c.clientSecret,
|
||||
Endpoint: giteaEndpoint,
|
||||
RedirectURL: c.redirectURI,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *giteaConnector) LoginURL(scopes connector.Scopes, callbackURL, state string) (string, error) {
|
||||
if c.redirectURI != callbackURL {
|
||||
return "", fmt.Errorf("expected callback URL %q did not match the URL in the config %q", c.redirectURI, callbackURL)
|
||||
}
|
||||
return c.oauth2Config(scopes).AuthCodeURL(state), nil
|
||||
}
|
||||
|
||||
type oauth2Error struct {
|
||||
error string
|
||||
errorDescription string
|
||||
}
|
||||
|
||||
func (e *oauth2Error) Error() string {
|
||||
if e.errorDescription == "" {
|
||||
return e.error
|
||||
}
|
||||
return e.error + ": " + e.errorDescription
|
||||
}
|
||||
|
||||
func (c *giteaConnector) HandleCallback(s connector.Scopes, r *http.Request) (identity connector.Identity, err error) {
|
||||
q := r.URL.Query()
|
||||
if errType := q.Get("error"); errType != "" {
|
||||
return identity, &oauth2Error{errType, q.Get("error_description")}
|
||||
}
|
||||
|
||||
oauth2Config := c.oauth2Config(s)
|
||||
|
||||
ctx := r.Context()
|
||||
if c.httpClient != nil {
|
||||
ctx = context.WithValue(r.Context(), oauth2.HTTPClient, c.httpClient)
|
||||
}
|
||||
|
||||
token, err := oauth2Config.Exchange(ctx, q.Get("code"))
|
||||
if err != nil {
|
||||
return identity, fmt.Errorf("gitea: failed to get token: %v", err)
|
||||
}
|
||||
|
||||
client := oauth2Config.Client(ctx, token)
|
||||
|
||||
user, err := c.user(ctx, client)
|
||||
if err != nil {
|
||||
return identity, fmt.Errorf("gitea: get user: %v", err)
|
||||
}
|
||||
|
||||
username := user.Name
|
||||
if username == "" {
|
||||
username = user.Email
|
||||
}
|
||||
|
||||
identity = connector.Identity{
|
||||
UserID: strconv.Itoa(user.ID),
|
||||
Username: username,
|
||||
PreferredUsername: user.Username,
|
||||
Email: user.Email,
|
||||
EmailVerified: true,
|
||||
}
|
||||
if c.useLoginAsID {
|
||||
identity.UserID = user.Username
|
||||
}
|
||||
|
||||
// Only set identity.Groups if 'orgs', 'org', or 'groups' scope are specified.
|
||||
if c.groupsRequired() {
|
||||
groups, err := c.getGroups(ctx, client)
|
||||
if err != nil {
|
||||
return identity, err
|
||||
}
|
||||
identity.Groups = groups
|
||||
}
|
||||
|
||||
if s.OfflineAccess {
|
||||
data := connectorData{
|
||||
AccessToken: token.AccessToken,
|
||||
RefreshToken: token.RefreshToken,
|
||||
Expiry: token.Expiry,
|
||||
}
|
||||
connData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return identity, fmt.Errorf("gitea: marshal connector data: %v", err)
|
||||
}
|
||||
identity.ConnectorData = connData
|
||||
}
|
||||
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
// Refreshing tokens
|
||||
// https://github.com/golang/oauth2/issues/84#issuecomment-332860871
|
||||
type tokenNotifyFunc func(*oauth2.Token) error
|
||||
|
||||
// notifyRefreshTokenSource is essentially `oauth2.ReuseTokenSource` with `TokenNotifyFunc` added.
|
||||
type notifyRefreshTokenSource struct {
|
||||
new oauth2.TokenSource
|
||||
mu sync.Mutex // guards t
|
||||
t *oauth2.Token
|
||||
f tokenNotifyFunc // called when token refreshed so new refresh token can be persisted
|
||||
}
|
||||
|
||||
// Token returns the current token if it's still valid, else will
|
||||
// refresh the current token (using r.Context for HTTP client
|
||||
// information) and return the new one.
|
||||
func (s *notifyRefreshTokenSource) Token() (*oauth2.Token, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.t.Valid() {
|
||||
return s.t, nil
|
||||
}
|
||||
t, err := s.new.Token()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.t = t
|
||||
return t, s.f(t)
|
||||
}
|
||||
|
||||
func (c *giteaConnector) Refresh(ctx context.Context, s connector.Scopes, ident connector.Identity) (connector.Identity, error) {
|
||||
if len(ident.ConnectorData) == 0 {
|
||||
return ident, errors.New("gitea: no upstream access token found")
|
||||
}
|
||||
|
||||
var data connectorData
|
||||
if err := json.Unmarshal(ident.ConnectorData, &data); err != nil {
|
||||
return ident, fmt.Errorf("gitea: unmarshal access token: %v", err)
|
||||
}
|
||||
|
||||
tok := &oauth2.Token{
|
||||
AccessToken: data.AccessToken,
|
||||
RefreshToken: data.RefreshToken,
|
||||
Expiry: data.Expiry,
|
||||
}
|
||||
|
||||
client := oauth2.NewClient(ctx, ¬ifyRefreshTokenSource{
|
||||
new: c.oauth2Config(s).TokenSource(ctx, tok),
|
||||
t: tok,
|
||||
f: func(tok *oauth2.Token) error {
|
||||
data := connectorData{
|
||||
AccessToken: tok.AccessToken,
|
||||
RefreshToken: tok.RefreshToken,
|
||||
Expiry: tok.Expiry,
|
||||
}
|
||||
connData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("gitea: marshal connector data: %v", err)
|
||||
}
|
||||
ident.ConnectorData = connData
|
||||
return nil
|
||||
},
|
||||
})
|
||||
user, err := c.user(ctx, client)
|
||||
if err != nil {
|
||||
return ident, fmt.Errorf("gitea: get user: %v", err)
|
||||
}
|
||||
|
||||
username := user.Name
|
||||
if username == "" {
|
||||
username = user.Email
|
||||
}
|
||||
ident.Username = username
|
||||
ident.PreferredUsername = user.Username
|
||||
ident.Email = user.Email
|
||||
|
||||
// Only set identity.Groups if 'orgs', 'org', or 'groups' scope are specified.
|
||||
if c.groupsRequired() {
|
||||
groups, err := c.getGroups(ctx, client)
|
||||
if err != nil {
|
||||
return ident, err
|
||||
}
|
||||
ident.Groups = groups
|
||||
}
|
||||
|
||||
return ident, nil
|
||||
}
|
||||
|
||||
// getGroups retrieves Gitea orgs and teams a user is in, if any.
|
||||
func (c *giteaConnector) getGroups(ctx context.Context, client *http.Client) ([]string, error) {
|
||||
if len(c.orgs) > 0 {
|
||||
return c.groupsForOrgs(ctx, client)
|
||||
} else if c.loadAllGroups {
|
||||
return c.userGroups(ctx, client)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// formatTeamName returns unique team name.
|
||||
// Orgs might have the same team names. To make team name unique it should be prefixed with the org name.
|
||||
func formatTeamName(org string, team string) string {
|
||||
return fmt.Sprintf("%s:%s", org, team)
|
||||
}
|
||||
|
||||
// groupsForOrgs returns list of groups that user belongs to in approved list
|
||||
func (c *giteaConnector) groupsForOrgs(ctx context.Context, client *http.Client) ([]string, error) {
|
||||
groups, err := c.userGroups(ctx, client)
|
||||
if err != nil {
|
||||
return groups, err
|
||||
}
|
||||
|
||||
keys := make(map[string]bool)
|
||||
for _, o := range c.orgs {
|
||||
keys[o.Name] = true
|
||||
if o.Teams != nil {
|
||||
for _, t := range o.Teams {
|
||||
keys[formatTeamName(o.Name, t)] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
atLeastOne := false
|
||||
filteredGroups := make([]string, 0)
|
||||
for _, g := range groups {
|
||||
if _, value := keys[g]; value {
|
||||
filteredGroups = append(filteredGroups, g)
|
||||
atLeastOne = true
|
||||
}
|
||||
}
|
||||
|
||||
if !atLeastOne {
|
||||
return []string{}, fmt.Errorf("gitea: User does not belong to any of the approved groups")
|
||||
}
|
||||
return filteredGroups, nil
|
||||
}
|
||||
|
||||
type organization struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"username"`
|
||||
}
|
||||
|
||||
type team struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Organization *organization `json:"organization"`
|
||||
}
|
||||
|
||||
func (c *giteaConnector) userGroups(ctx context.Context, client *http.Client) ([]string, error) {
|
||||
apiURL := c.baseURL + "/api/v1/user/teams"
|
||||
groups := make([]string, 0)
|
||||
page := 1
|
||||
limit := 20
|
||||
for {
|
||||
var teams []team
|
||||
req, err := http.NewRequest("GET", fmt.Sprintf("%s?page=%d&limit=%d", apiURL, page, limit), nil)
|
||||
if err != nil {
|
||||
return groups, fmt.Errorf("gitea: new req: %v", err)
|
||||
}
|
||||
|
||||
req = req.WithContext(ctx)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return groups, fmt.Errorf("gitea: get URL %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return groups, fmt.Errorf("gitea: read body: %v", err)
|
||||
}
|
||||
return groups, fmt.Errorf("%s: %s", resp.Status, body)
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&teams); err != nil {
|
||||
return groups, fmt.Errorf("failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if len(teams) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
for _, t := range teams {
|
||||
groups = append(groups, t.Organization.Name)
|
||||
groups = append(groups, formatTeamName(t.Organization.Name, t.Name))
|
||||
}
|
||||
|
||||
page++
|
||||
}
|
||||
|
||||
// remove duplicate slice variables
|
||||
keys := make(map[string]struct{})
|
||||
list := []string{}
|
||||
for _, group := range groups {
|
||||
if _, exists := keys[group]; !exists {
|
||||
keys[group] = struct{}{}
|
||||
list = append(list, group)
|
||||
}
|
||||
}
|
||||
groups = list
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
// user queries the Gitea API for profile information using the provided client. The HTTP
|
||||
// client is expected to be constructed by the golang.org/x/oauth2 package, which inserts
|
||||
// a bearer token as part of the request.
|
||||
func (c *giteaConnector) user(ctx context.Context, client *http.Client) (giteaUser, error) {
|
||||
var u giteaUser
|
||||
req, err := http.NewRequest("GET", c.baseURL+"/api/v1/user", nil)
|
||||
if err != nil {
|
||||
return u, fmt.Errorf("gitea: new req: %v", err)
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return u, fmt.Errorf("gitea: get URL %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return u, fmt.Errorf("gitea: read body: %v", err)
|
||||
}
|
||||
return u, fmt.Errorf("%s: %s", resp.Status, body)
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&u); err != nil {
|
||||
return u, fmt.Errorf("failed to decode response: %v", err)
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// groupsRequired returns whether dex needs to request groups from Gitea.
|
||||
func (c *giteaConnector) groupsRequired() bool {
|
||||
return len(c.orgs) > 0 || c.loadAllGroups
|
||||
}
|
|
@ -0,0 +1,72 @@
|
|||
package gitea
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/dexidp/dex/connector"
|
||||
)
|
||||
|
||||
// tests that the email is used as their username when they have no username set
|
||||
func TestUsernameIncludedInFederatedIdentity(t *testing.T) {
|
||||
s := newTestServer(map[string]interface{}{
|
||||
"/api/v1/user": giteaUser{Email: "some@email.com", ID: 12345678},
|
||||
"/login/oauth/access_token": map[string]interface{}{
|
||||
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9",
|
||||
"expires_in": "30",
|
||||
},
|
||||
})
|
||||
defer s.Close()
|
||||
|
||||
hostURL, err := url.Parse(s.URL)
|
||||
expectNil(t, err)
|
||||
|
||||
req, err := http.NewRequest("GET", hostURL.String(), nil)
|
||||
expectNil(t, err)
|
||||
|
||||
c := giteaConnector{baseURL: s.URL, httpClient: newClient()}
|
||||
identity, err := c.HandleCallback(connector.Scopes{}, req)
|
||||
|
||||
expectNil(t, err)
|
||||
expectEquals(t, identity.Username, "some@email.com")
|
||||
expectEquals(t, identity.UserID, "12345678")
|
||||
|
||||
c = giteaConnector{baseURL: s.URL, httpClient: newClient()}
|
||||
identity, err = c.HandleCallback(connector.Scopes{}, req)
|
||||
|
||||
expectNil(t, err)
|
||||
expectEquals(t, identity.Username, "some@email.com")
|
||||
expectEquals(t, identity.UserID, "12345678")
|
||||
}
|
||||
|
||||
func newTestServer(responses map[string]interface{}) *httptest.Server {
|
||||
return httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
response := responses[r.RequestURI]
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}))
|
||||
}
|
||||
|
||||
func newClient() *http.Client {
|
||||
tr := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
return &http.Client{Transport: tr}
|
||||
}
|
||||
|
||||
func expectNil(t *testing.T, a interface{}) {
|
||||
if a != nil {
|
||||
t.Errorf("Expected %+v to equal nil", a)
|
||||
}
|
||||
}
|
||||
|
||||
func expectEquals(t *testing.T, a interface{}, b interface{}) {
|
||||
if !reflect.DeepEqual(a, b) {
|
||||
t.Errorf("Expected %+v to equal %+v", a, b)
|
||||
}
|
||||
}
|
|
@ -3,44 +3,123 @@ package github
|
|||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/github"
|
||||
|
||||
"github.com/Sirupsen/logrus"
|
||||
"github.com/coreos/dex/connector"
|
||||
"github.com/dexidp/dex/connector"
|
||||
groups_pkg "github.com/dexidp/dex/pkg/groups"
|
||||
"github.com/dexidp/dex/pkg/log"
|
||||
)
|
||||
|
||||
const (
|
||||
baseURL = "https://api.github.com"
|
||||
apiURL = "https://api.github.com"
|
||||
// GitHub requires this scope to access '/user' and '/user/emails' API endpoints.
|
||||
scopeEmail = "user:email"
|
||||
scopeOrgs = "read:org"
|
||||
// GitHub requires this scope to access '/user/teams' and '/orgs' API endpoints
|
||||
// which are used when a client includes the 'groups' scope.
|
||||
scopeOrgs = "read:org"
|
||||
)
|
||||
|
||||
// Pagination URL patterns
|
||||
// https://developer.github.com/v3/#pagination
|
||||
var (
|
||||
reNext = regexp.MustCompile("<([^>]+)>; rel=\"next\"")
|
||||
reLast = regexp.MustCompile("<([^>]+)>; rel=\"last\"")
|
||||
)
|
||||
|
||||
// Config holds configuration options for github logins.
|
||||
type Config struct {
|
||||
ClientID string `json:"clientID"`
|
||||
ClientSecret string `json:"clientSecret"`
|
||||
RedirectURI string `json:"redirectURI"`
|
||||
Org string `json:"org"`
|
||||
ClientID string `json:"clientID"`
|
||||
ClientSecret string `json:"clientSecret"`
|
||||
RedirectURI string `json:"redirectURI"`
|
||||
Org string `json:"org"`
|
||||
Orgs []Org `json:"orgs"`
|
||||
HostName string `json:"hostName"`
|
||||
RootCA string `json:"rootCA"`
|
||||
TeamNameField string `json:"teamNameField"`
|
||||
LoadAllGroups bool `json:"loadAllGroups"`
|
||||
UseLoginAsID bool `json:"useLoginAsID"`
|
||||
}
|
||||
|
||||
// Org holds org-team filters, in which teams are optional.
|
||||
type Org struct {
|
||||
// Organization name in github (not slug, full name). Only users in this github
|
||||
// organization can authenticate.
|
||||
Name string `json:"name"`
|
||||
|
||||
// Names of teams in a github organization. A user will be able to
|
||||
// authenticate if they are members of at least one of these teams. Users
|
||||
// in the organization can authenticate if this field is omitted from the
|
||||
// config file.
|
||||
Teams []string `json:"teams,omitempty"`
|
||||
}
|
||||
|
||||
// Open returns a strategy for logging in through GitHub.
|
||||
func (c *Config) Open(logger logrus.FieldLogger) (connector.Connector, error) {
|
||||
return &githubConnector{
|
||||
func (c *Config) Open(id string, logger log.Logger) (connector.Connector, error) {
|
||||
if c.Org != "" {
|
||||
// Return error if both 'org' and 'orgs' fields are used.
|
||||
if len(c.Orgs) > 0 {
|
||||
return nil, errors.New("github: cannot use both 'org' and 'orgs' fields simultaneously")
|
||||
}
|
||||
logger.Warn("github: legacy field 'org' being used. Switch to the newer 'orgs' field structure")
|
||||
}
|
||||
|
||||
g := githubConnector{
|
||||
redirectURI: c.RedirectURI,
|
||||
org: c.Org,
|
||||
orgs: c.Orgs,
|
||||
clientID: c.ClientID,
|
||||
clientSecret: c.ClientSecret,
|
||||
apiURL: apiURL,
|
||||
logger: logger,
|
||||
}, nil
|
||||
useLoginAsID: c.UseLoginAsID,
|
||||
}
|
||||
|
||||
if c.HostName != "" {
|
||||
// ensure this is a hostname and not a URL or path.
|
||||
if strings.Contains(c.HostName, "/") {
|
||||
return nil, errors.New("invalid hostname: hostname cannot contain `/`")
|
||||
}
|
||||
|
||||
g.hostName = c.HostName
|
||||
g.apiURL = "https://" + c.HostName + "/api/v3"
|
||||
}
|
||||
|
||||
if c.RootCA != "" {
|
||||
if c.HostName == "" {
|
||||
return nil, errors.New("invalid connector config: Host name field required for a root certificate file")
|
||||
}
|
||||
g.rootCA = c.RootCA
|
||||
|
||||
var err error
|
||||
if g.httpClient, err = newHTTPClient(g.rootCA); err != nil {
|
||||
return nil, fmt.Errorf("failed to create HTTP client: %v", err)
|
||||
}
|
||||
}
|
||||
g.loadAllGroups = c.LoadAllGroups
|
||||
|
||||
switch c.TeamNameField {
|
||||
case "name", "slug", "both", "":
|
||||
g.teamNameField = c.TeamNameField
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid connector config: unsupported team name field value `%s`", c.TeamNameField)
|
||||
}
|
||||
|
||||
return &g, nil
|
||||
}
|
||||
|
||||
type connectorData struct {
|
||||
|
@ -56,30 +135,64 @@ var (
|
|||
type githubConnector struct {
|
||||
redirectURI string
|
||||
org string
|
||||
orgs []Org
|
||||
clientID string
|
||||
clientSecret string
|
||||
logger logrus.FieldLogger
|
||||
logger log.Logger
|
||||
// apiURL defaults to "https://api.github.com"
|
||||
apiURL string
|
||||
// hostName of the GitHub enterprise account.
|
||||
hostName string
|
||||
// Used to support untrusted/self-signed CA certs.
|
||||
rootCA string
|
||||
// HTTP Client that trusts the custom declared rootCA cert.
|
||||
httpClient *http.Client
|
||||
// optional choice between 'name' (default) or 'slug'
|
||||
teamNameField string
|
||||
// if set to true and no orgs are configured then connector loads all user claims (all orgs and team)
|
||||
loadAllGroups bool
|
||||
// if set to true will use the user's handle rather than their numeric id as the ID
|
||||
useLoginAsID bool
|
||||
}
|
||||
|
||||
// groupsRequired returns whether dex requires GitHub's 'read:org' scope. Dex
|
||||
// needs 'read:org' if 'orgs' or 'org' fields are populated in a config file.
|
||||
// Clients can require 'groups' scope without setting 'orgs'/'org'.
|
||||
func (c *githubConnector) groupsRequired(groupScope bool) bool {
|
||||
return len(c.orgs) > 0 || c.org != "" || groupScope
|
||||
}
|
||||
|
||||
func (c *githubConnector) oauth2Config(scopes connector.Scopes) *oauth2.Config {
|
||||
var githubScopes []string
|
||||
if scopes.Groups {
|
||||
githubScopes = []string{scopeEmail, scopeOrgs}
|
||||
} else {
|
||||
githubScopes = []string{scopeEmail}
|
||||
// 'read:org' scope is required by the GitHub API, and thus for dex to ensure
|
||||
// a user is a member of orgs and teams provided in configs.
|
||||
githubScopes := []string{scopeEmail}
|
||||
if c.groupsRequired(scopes.Groups) {
|
||||
githubScopes = append(githubScopes, scopeOrgs)
|
||||
}
|
||||
|
||||
endpoint := github.Endpoint
|
||||
// case when it is a GitHub Enterprise account.
|
||||
if c.hostName != "" {
|
||||
endpoint = oauth2.Endpoint{
|
||||
AuthURL: "https://" + c.hostName + "/login/oauth/authorize",
|
||||
TokenURL: "https://" + c.hostName + "/login/oauth/access_token",
|
||||
}
|
||||
}
|
||||
|
||||
return &oauth2.Config{
|
||||
ClientID: c.clientID,
|
||||
ClientSecret: c.clientSecret,
|
||||
Endpoint: github.Endpoint,
|
||||
Endpoint: endpoint,
|
||||
Scopes: githubScopes,
|
||||
RedirectURL: c.redirectURI,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *githubConnector) LoginURL(scopes connector.Scopes, callbackURL, state string) (string, error) {
|
||||
if c.redirectURI != callbackURL {
|
||||
return "", fmt.Errorf("expected callback URL did not match the URL in the config")
|
||||
return "", fmt.Errorf("expected callback URL %q did not match the URL in the config %q", callbackURL, c.redirectURI)
|
||||
}
|
||||
|
||||
return c.oauth2Config(scopes).AuthCodeURL(state), nil
|
||||
}
|
||||
|
||||
|
@ -95,6 +208,34 @@ func (e *oauth2Error) Error() string {
|
|||
return e.error + ": " + e.errorDescription
|
||||
}
|
||||
|
||||
// newHTTPClient returns a new HTTP client that trusts the custom declared rootCA cert.
|
||||
func newHTTPClient(rootCA string) (*http.Client, error) {
|
||||
tlsConfig := tls.Config{RootCAs: x509.NewCertPool()}
|
||||
rootCABytes, err := os.ReadFile(rootCA)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read root-ca: %v", err)
|
||||
}
|
||||
if !tlsConfig.RootCAs.AppendCertsFromPEM(rootCABytes) {
|
||||
return nil, fmt.Errorf("no certs found in root CA file %q", rootCA)
|
||||
}
|
||||
|
||||
return &http.Client{
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tlsConfig,
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 30 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
DualStack: true,
|
||||
}).DialContext,
|
||||
MaxIdleConns: 100,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *githubConnector) HandleCallback(s connector.Scopes, r *http.Request) (identity connector.Identity, err error) {
|
||||
q := r.URL.Query()
|
||||
if errType := q.Get("error"); errType != "" {
|
||||
|
@ -102,7 +243,12 @@ func (c *githubConnector) HandleCallback(s connector.Scopes, r *http.Request) (i
|
|||
}
|
||||
|
||||
oauth2Config := c.oauth2Config(s)
|
||||
|
||||
ctx := r.Context()
|
||||
// GitHub Enterprise account
|
||||
if c.httpClient != nil {
|
||||
ctx = context.WithValue(r.Context(), oauth2.HTTPClient, c.httpClient)
|
||||
}
|
||||
|
||||
token, err := oauth2Config.Exchange(ctx, q.Get("code"))
|
||||
if err != nil {
|
||||
|
@ -120,17 +266,23 @@ func (c *githubConnector) HandleCallback(s connector.Scopes, r *http.Request) (i
|
|||
if username == "" {
|
||||
username = user.Login
|
||||
}
|
||||
|
||||
identity = connector.Identity{
|
||||
UserID: strconv.Itoa(user.ID),
|
||||
Username: username,
|
||||
Email: user.Email,
|
||||
EmailVerified: true,
|
||||
UserID: strconv.Itoa(user.ID),
|
||||
Username: username,
|
||||
PreferredUsername: user.Login,
|
||||
Email: user.Email,
|
||||
EmailVerified: true,
|
||||
}
|
||||
if c.useLoginAsID {
|
||||
identity.UserID = user.Login
|
||||
}
|
||||
|
||||
if s.Groups && c.org != "" {
|
||||
groups, err := c.teams(ctx, client, c.org)
|
||||
// Only set identity.Groups if 'orgs', 'org', or 'groups' scope are specified.
|
||||
if c.groupsRequired(s.Groups) {
|
||||
groups, err := c.getGroups(ctx, client, s.Groups, user.Login)
|
||||
if err != nil {
|
||||
return identity, fmt.Errorf("github: get teams: %v", err)
|
||||
return identity, err
|
||||
}
|
||||
identity.Groups = groups
|
||||
}
|
||||
|
@ -147,39 +299,240 @@ func (c *githubConnector) HandleCallback(s connector.Scopes, r *http.Request) (i
|
|||
return identity, nil
|
||||
}
|
||||
|
||||
func (c *githubConnector) Refresh(ctx context.Context, s connector.Scopes, ident connector.Identity) (connector.Identity, error) {
|
||||
if len(ident.ConnectorData) == 0 {
|
||||
return ident, errors.New("no upstream access token found")
|
||||
func (c *githubConnector) Refresh(ctx context.Context, s connector.Scopes, identity connector.Identity) (connector.Identity, error) {
|
||||
if len(identity.ConnectorData) == 0 {
|
||||
return identity, errors.New("no upstream access token found")
|
||||
}
|
||||
|
||||
var data connectorData
|
||||
if err := json.Unmarshal(ident.ConnectorData, &data); err != nil {
|
||||
return ident, fmt.Errorf("github: unmarshal access token: %v", err)
|
||||
if err := json.Unmarshal(identity.ConnectorData, &data); err != nil {
|
||||
return identity, fmt.Errorf("github: unmarshal access token: %v", err)
|
||||
}
|
||||
|
||||
client := c.oauth2Config(s).Client(ctx, &oauth2.Token{AccessToken: data.AccessToken})
|
||||
user, err := c.user(ctx, client)
|
||||
if err != nil {
|
||||
return ident, fmt.Errorf("github: get user: %v", err)
|
||||
return identity, fmt.Errorf("github: get user: %v", err)
|
||||
}
|
||||
|
||||
username := user.Name
|
||||
if username == "" {
|
||||
username = user.Login
|
||||
}
|
||||
ident.Username = username
|
||||
ident.Email = user.Email
|
||||
identity.Username = username
|
||||
identity.PreferredUsername = user.Login
|
||||
identity.Email = user.Email
|
||||
|
||||
if s.Groups && c.org != "" {
|
||||
groups, err := c.teams(ctx, client, c.org)
|
||||
// Only set identity.Groups if 'orgs', 'org', or 'groups' scope are specified.
|
||||
if c.groupsRequired(s.Groups) {
|
||||
groups, err := c.getGroups(ctx, client, s.Groups, user.Login)
|
||||
if err != nil {
|
||||
return ident, fmt.Errorf("github: get teams: %v", err)
|
||||
return identity, err
|
||||
}
|
||||
ident.Groups = groups
|
||||
identity.Groups = groups
|
||||
}
|
||||
return ident, nil
|
||||
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
// getGroups retrieves GitHub orgs and teams a user is in, if any.
|
||||
func (c *githubConnector) getGroups(ctx context.Context, client *http.Client, groupScope bool, userLogin string) ([]string, error) {
|
||||
switch {
|
||||
case len(c.orgs) > 0:
|
||||
return c.groupsForOrgs(ctx, client, userLogin)
|
||||
case c.org != "":
|
||||
return c.teamsForOrg(ctx, client, c.org)
|
||||
case groupScope && c.loadAllGroups:
|
||||
return c.userGroups(ctx, client)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// formatTeamName returns unique team name.
|
||||
// Orgs might have the same team names. To make team name unique it should be prefixed with the org name.
|
||||
func formatTeamName(org string, team string) string {
|
||||
return fmt.Sprintf("%s:%s", org, team)
|
||||
}
|
||||
|
||||
// groupsForOrgs enforces org and team constraints on user authorization
|
||||
// Cases in which user is authorized:
|
||||
// N orgs, no teams: user is member of at least 1 org
|
||||
// N orgs, M teams per org: user is member of any team from at least 1 org
|
||||
// N-1 orgs, M teams per org, 1 org with no teams: user is member of any team
|
||||
// from at least 1 org, or member of org with no teams
|
||||
func (c *githubConnector) groupsForOrgs(ctx context.Context, client *http.Client, userName string) ([]string, error) {
|
||||
groups := make([]string, 0)
|
||||
var inOrgNoTeams bool
|
||||
for _, org := range c.orgs {
|
||||
inOrg, err := c.userInOrg(ctx, client, userName, org.Name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !inOrg {
|
||||
continue
|
||||
}
|
||||
|
||||
teams, err := c.teamsForOrg(ctx, client, org.Name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// User is in at least one org. User is authorized if no teams are specified
|
||||
// in config; include all teams in claim. Otherwise filter out teams not in
|
||||
// 'teams' list in config.
|
||||
if len(org.Teams) == 0 {
|
||||
inOrgNoTeams = true
|
||||
} else if teams = groups_pkg.Filter(teams, org.Teams); len(teams) == 0 {
|
||||
c.logger.Infof("github: user %q in org %q but no teams", userName, org.Name)
|
||||
}
|
||||
|
||||
for _, teamName := range teams {
|
||||
groups = append(groups, formatTeamName(org.Name, teamName))
|
||||
}
|
||||
}
|
||||
if inOrgNoTeams || len(groups) > 0 {
|
||||
return groups, nil
|
||||
}
|
||||
return groups, fmt.Errorf("github: user %q not in required orgs or teams", userName)
|
||||
}
|
||||
|
||||
func (c *githubConnector) userGroups(ctx context.Context, client *http.Client) ([]string, error) {
|
||||
orgs, err := c.userOrgs(ctx, client)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
orgTeams, err := c.userOrgTeams(ctx, client)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
groups := make([]string, 0)
|
||||
for _, o := range orgs {
|
||||
groups = append(groups, o)
|
||||
if teams, ok := orgTeams[o]; ok {
|
||||
for _, t := range teams {
|
||||
groups = append(groups, formatTeamName(o, t))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
// userOrgs retrieves list of current user orgs
|
||||
func (c *githubConnector) userOrgs(ctx context.Context, client *http.Client) ([]string, error) {
|
||||
groups := make([]string, 0)
|
||||
apiURL := c.apiURL + "/user/orgs"
|
||||
for {
|
||||
// https://developer.github.com/v3/orgs/#list-your-organizations
|
||||
var (
|
||||
orgs []org
|
||||
err error
|
||||
)
|
||||
if apiURL, err = get(ctx, client, apiURL, &orgs); err != nil {
|
||||
return nil, fmt.Errorf("github: get orgs: %v", err)
|
||||
}
|
||||
|
||||
for _, o := range orgs {
|
||||
groups = append(groups, o.Login)
|
||||
}
|
||||
|
||||
if apiURL == "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
// userOrgTeams retrieves teams which current user belongs to.
|
||||
// Method returns a map where key is an org name and value list of teams under the org.
|
||||
func (c *githubConnector) userOrgTeams(ctx context.Context, client *http.Client) (map[string][]string, error) {
|
||||
groups := make(map[string][]string)
|
||||
apiURL := c.apiURL + "/user/teams"
|
||||
for {
|
||||
// https://developer.github.com/v3/orgs/teams/#list-user-teams
|
||||
var (
|
||||
teams []team
|
||||
err error
|
||||
)
|
||||
if apiURL, err = get(ctx, client, apiURL, &teams); err != nil {
|
||||
return nil, fmt.Errorf("github: get teams: %v", err)
|
||||
}
|
||||
|
||||
for _, t := range teams {
|
||||
groups[t.Org.Login] = append(groups[t.Org.Login], c.teamGroupClaims(t)...)
|
||||
}
|
||||
|
||||
if apiURL == "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
// get creates a "GET `apiURL`" request with context, sends the request using
|
||||
// the client, and decodes the resulting response body into v. A pagination URL
|
||||
// is returned if one exists. Any errors encountered when building requests,
|
||||
// sending requests, and reading and decoding response data are returned.
|
||||
func get(ctx context.Context, client *http.Client, apiURL string, v interface{}) (string, error) {
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("github: new req: %v", err)
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("github: get URL %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("github: read body: %v", err)
|
||||
}
|
||||
return "", fmt.Errorf("%s: %s", resp.Status, body)
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(v); err != nil {
|
||||
return "", fmt.Errorf("failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
return getPagination(apiURL, resp), nil
|
||||
}
|
||||
|
||||
// getPagination checks the "Link" header field for "next" or "last" pagination URLs,
|
||||
// and returns "next" page URL or empty string to indicate that there are no more pages.
|
||||
// Non empty next pages' URL is returned if both "last" and "next" URLs are found and next page
|
||||
// URL is not equal to last.
|
||||
//
|
||||
// https://developer.github.com/v3/#pagination
|
||||
func getPagination(apiURL string, resp *http.Response) string {
|
||||
if resp == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
links := resp.Header.Get("Link")
|
||||
if len(reLast.FindStringSubmatch(links)) > 1 {
|
||||
lastPageURL := reLast.FindStringSubmatch(links)[1]
|
||||
if apiURL == lastPageURL {
|
||||
return ""
|
||||
}
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
|
||||
if len(reNext.FindStringSubmatch(links)) > 1 {
|
||||
return reNext.FindStringSubmatch(links)[1]
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// user holds GitHub user information (relevant to dex) as defined by
|
||||
// https://developer.github.com/v3/users/#response-with-public-profile-information
|
||||
type user struct {
|
||||
Name string `json:"name"`
|
||||
Login string `json:"login"`
|
||||
|
@ -187,102 +540,169 @@ type user struct {
|
|||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
// user queries the GitHub API for profile information using the provided client. The HTTP
|
||||
// client is expected to be constructed by the golang.org/x/oauth2 package, which inserts
|
||||
// a bearer token as part of the request.
|
||||
// user queries the GitHub API for profile information using the provided client.
|
||||
//
|
||||
// The HTTP client is expected to be constructed by the golang.org/x/oauth2 package,
|
||||
// which inserts a bearer token as part of the request.
|
||||
func (c *githubConnector) user(ctx context.Context, client *http.Client) (user, error) {
|
||||
// https://developer.github.com/v3/users/#get-the-authenticated-user
|
||||
var u user
|
||||
req, err := http.NewRequest("GET", baseURL+"/user", nil)
|
||||
if err != nil {
|
||||
return u, fmt.Errorf("github: new req: %v", err)
|
||||
if _, err := get(ctx, client, c.apiURL+"/user", &u); err != nil {
|
||||
return u, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return u, fmt.Errorf("github: get URL %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return u, fmt.Errorf("github: read body: %v", err)
|
||||
// Only public user emails are returned by 'GET /user'. u.Email will be empty
|
||||
// if a users' email is private. We must retrieve private emails explicitly.
|
||||
if u.Email == "" {
|
||||
var err error
|
||||
if u.Email, err = c.userEmail(ctx, client); err != nil {
|
||||
return u, err
|
||||
}
|
||||
return u, fmt.Errorf("%s: %s", resp.Status, body)
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&u); err != nil {
|
||||
return u, fmt.Errorf("failed to decode response: %v", err)
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// teams queries the GitHub API for team membership within a specific organization.
|
||||
// userEmail holds GitHub user email information as defined by
|
||||
// https://developer.github.com/v3/users/emails/#response
|
||||
type userEmail struct {
|
||||
Email string `json:"email"`
|
||||
Verified bool `json:"verified"`
|
||||
Primary bool `json:"primary"`
|
||||
Visibility string `json:"visibility"`
|
||||
}
|
||||
|
||||
// userEmail queries the GitHub API for a users' email information using the
|
||||
// provided client. Only returns the users' verified, primary email (private or
|
||||
// public).
|
||||
//
|
||||
// The HTTP passed client is expected to be constructed by the golang.org/x/oauth2 package,
|
||||
// The HTTP client is expected to be constructed by the golang.org/x/oauth2 package,
|
||||
// which inserts a bearer token as part of the request.
|
||||
func (c *githubConnector) teams(ctx context.Context, client *http.Client, org string) ([]string, error) {
|
||||
|
||||
groups := []string{}
|
||||
|
||||
// https://developer.github.com/v3/#pagination
|
||||
reNext := regexp.MustCompile("<(.*)>; rel=\"next\"")
|
||||
reLast := regexp.MustCompile("<(.*)>; rel=\"last\"")
|
||||
apiURL := baseURL + "/user/teams"
|
||||
|
||||
func (c *githubConnector) userEmail(ctx context.Context, client *http.Client) (string, error) {
|
||||
apiURL := c.apiURL + "/user/emails"
|
||||
for {
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("github: new req: %v", err)
|
||||
// https://developer.github.com/v3/users/emails/#list-email-addresses-for-a-user
|
||||
var (
|
||||
emails []userEmail
|
||||
err error
|
||||
)
|
||||
if apiURL, err = get(ctx, client, apiURL, &emails); err != nil {
|
||||
return "", err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("github: get teams: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("github: read body: %v", err)
|
||||
for _, email := range emails {
|
||||
/*
|
||||
if GitHub Enterprise, set email.Verified to true
|
||||
This change being made because GitHub Enterprise does not
|
||||
support email verification. CircleCI indicated that GitHub
|
||||
advised them not to check for verified emails
|
||||
(https://circleci.com/enterprise/changelog/#1-47-1).
|
||||
In addition, GitHub Enterprise support replied to a support
|
||||
ticket with "There is no way to verify an email address in
|
||||
GitHub Enterprise."
|
||||
*/
|
||||
if c.hostName != "" {
|
||||
email.Verified = true
|
||||
}
|
||||
return nil, fmt.Errorf("%s: %s", resp.Status, body)
|
||||
}
|
||||
|
||||
// https://developer.github.com/v3/orgs/teams/#response-12
|
||||
var teams []struct {
|
||||
Name string `json:"name"`
|
||||
Org struct {
|
||||
Login string `json:"login"`
|
||||
} `json:"organization"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&teams); err != nil {
|
||||
return nil, fmt.Errorf("github: unmarshal groups: %v", err)
|
||||
}
|
||||
|
||||
for _, team := range teams {
|
||||
if team.Org.Login == org {
|
||||
groups = append(groups, team.Name)
|
||||
if email.Verified && email.Primary {
|
||||
return email.Email, nil
|
||||
}
|
||||
}
|
||||
|
||||
links := resp.Header.Get("Link")
|
||||
if len(reLast.FindStringSubmatch(links)) > 1 {
|
||||
lastPageURL := reLast.FindStringSubmatch(links)[1]
|
||||
if apiURL == lastPageURL {
|
||||
break
|
||||
}
|
||||
} else {
|
||||
break
|
||||
}
|
||||
|
||||
if len(reNext.FindStringSubmatch(links)) > 1 {
|
||||
apiURL = reNext.FindStringSubmatch(links)[1]
|
||||
} else {
|
||||
if apiURL == "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return "", errors.New("github: user has no verified, primary email")
|
||||
}
|
||||
|
||||
// userInOrg queries the GitHub API for a users' org membership.
|
||||
//
|
||||
// The HTTP passed client is expected to be constructed by the golang.org/x/oauth2 package,
|
||||
// which inserts a bearer token as part of the request.
|
||||
func (c *githubConnector) userInOrg(ctx context.Context, client *http.Client, userName, orgName string) (bool, error) {
|
||||
// requester == user, so GET-ing this endpoint should return 404/302 if user
|
||||
// is not a member
|
||||
//
|
||||
// https://developer.github.com/v3/orgs/members/#check-membership
|
||||
apiURL := fmt.Sprintf("%s/orgs/%s/members/%s", c.apiURL, orgName, userName)
|
||||
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("github: new req: %v", err)
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("github: get teams: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
switch resp.StatusCode {
|
||||
case http.StatusNoContent:
|
||||
case http.StatusFound, http.StatusNotFound:
|
||||
c.logger.Infof("github: user %q not in org %q or application not authorized to read org data", userName, orgName)
|
||||
default:
|
||||
err = fmt.Errorf("github: unexpected return status: %q", resp.Status)
|
||||
}
|
||||
|
||||
// 204 if user is a member
|
||||
return resp.StatusCode == http.StatusNoContent, err
|
||||
}
|
||||
|
||||
// teams holds GitHub a users' team information as defined by
|
||||
// https://developer.github.com/v3/orgs/teams/#response-12
|
||||
type team struct {
|
||||
Name string `json:"name"`
|
||||
Org org `json:"organization"`
|
||||
Slug string `json:"slug"`
|
||||
}
|
||||
|
||||
type org struct {
|
||||
Login string `json:"login"`
|
||||
}
|
||||
|
||||
// teamsForOrg queries the GitHub API for team membership within a specific organization.
|
||||
//
|
||||
// The HTTP passed client is expected to be constructed by the golang.org/x/oauth2 package,
|
||||
// which inserts a bearer token as part of the request.
|
||||
func (c *githubConnector) teamsForOrg(ctx context.Context, client *http.Client, orgName string) ([]string, error) {
|
||||
apiURL, groups := c.apiURL+"/user/teams", []string{}
|
||||
for {
|
||||
// https://developer.github.com/v3/orgs/teams/#list-user-teams
|
||||
var (
|
||||
teams []team
|
||||
err error
|
||||
)
|
||||
if apiURL, err = get(ctx, client, apiURL, &teams); err != nil {
|
||||
return nil, fmt.Errorf("github: get teams: %v", err)
|
||||
}
|
||||
|
||||
for _, t := range teams {
|
||||
if t.Org.Login == orgName {
|
||||
groups = append(groups, c.teamGroupClaims(t)...)
|
||||
}
|
||||
}
|
||||
|
||||
if apiURL == "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
// teamGroupClaims returns team slug if 'teamNameField' option is set to
|
||||
// 'slug', returns the slug *and* name if set to 'both', otherwise returns team
|
||||
// name.
|
||||
func (c *githubConnector) teamGroupClaims(t team) []string {
|
||||
switch c.teamNameField {
|
||||
case "both":
|
||||
return []string{t.Name, t.Slug}
|
||||
case "slug":
|
||||
return []string{t.Slug}
|
||||
default:
|
||||
return []string{t.Name}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,238 @@
|
|||
package github
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/dexidp/dex/connector"
|
||||
)
|
||||
|
||||
type testResponse struct {
|
||||
data interface{}
|
||||
nextLink string
|
||||
lastLink string
|
||||
}
|
||||
|
||||
func TestUserGroups(t *testing.T) {
|
||||
s := newTestServer(map[string]testResponse{
|
||||
"/user/orgs": {
|
||||
data: []org{{Login: "org-1"}, {Login: "org-2"}},
|
||||
nextLink: "/user/orgs?since=2",
|
||||
lastLink: "/user/orgs?since=2",
|
||||
},
|
||||
"/user/orgs?since=2": {data: []org{{Login: "org-3"}}},
|
||||
"/user/teams": {
|
||||
data: []team{
|
||||
{Name: "team-1", Org: org{Login: "org-1"}},
|
||||
{Name: "team-2", Org: org{Login: "org-1"}},
|
||||
},
|
||||
nextLink: "/user/teams?since=2",
|
||||
lastLink: "/user/teams?since=2",
|
||||
},
|
||||
"/user/teams?since=2": {
|
||||
data: []team{
|
||||
{Name: "team-3", Org: org{Login: "org-1"}},
|
||||
{Name: "team-4", Org: org{Login: "org-2"}},
|
||||
},
|
||||
nextLink: "/user/teams?since=2",
|
||||
lastLink: "/user/teams?since=2",
|
||||
},
|
||||
})
|
||||
defer s.Close()
|
||||
|
||||
c := githubConnector{apiURL: s.URL}
|
||||
groups, err := c.userGroups(context.Background(), newClient())
|
||||
|
||||
expectNil(t, err)
|
||||
expectEquals(t, groups, []string{
|
||||
"org-1",
|
||||
"org-1:team-1",
|
||||
"org-1:team-2",
|
||||
"org-1:team-3",
|
||||
"org-2",
|
||||
"org-2:team-4",
|
||||
"org-3",
|
||||
})
|
||||
}
|
||||
|
||||
func TestUserGroupsWithoutOrgs(t *testing.T) {
|
||||
s := newTestServer(map[string]testResponse{
|
||||
"/user/orgs": {data: []org{}},
|
||||
"/user/teams": {data: []team{}},
|
||||
})
|
||||
defer s.Close()
|
||||
|
||||
c := githubConnector{apiURL: s.URL}
|
||||
groups, err := c.userGroups(context.Background(), newClient())
|
||||
|
||||
expectNil(t, err)
|
||||
expectEquals(t, len(groups), 0)
|
||||
}
|
||||
|
||||
func TestUserGroupsWithTeamNameFieldConfig(t *testing.T) {
|
||||
s := newTestServer(map[string]testResponse{
|
||||
"/user/orgs": {
|
||||
data: []org{{Login: "org-1"}},
|
||||
},
|
||||
"/user/teams": {
|
||||
data: []team{
|
||||
{Name: "Team 1", Slug: "team-1", Org: org{Login: "org-1"}},
|
||||
},
|
||||
},
|
||||
})
|
||||
defer s.Close()
|
||||
|
||||
c := githubConnector{apiURL: s.URL, teamNameField: "slug"}
|
||||
groups, err := c.userGroups(context.Background(), newClient())
|
||||
|
||||
expectNil(t, err)
|
||||
expectEquals(t, groups, []string{
|
||||
"org-1",
|
||||
"org-1:team-1",
|
||||
})
|
||||
}
|
||||
|
||||
func TestUserGroupsWithTeamNameAndSlugFieldConfig(t *testing.T) {
|
||||
s := newTestServer(map[string]testResponse{
|
||||
"/user/orgs": {
|
||||
data: []org{{Login: "org-1"}},
|
||||
},
|
||||
"/user/teams": {
|
||||
data: []team{
|
||||
{Name: "Team 1", Slug: "team-1", Org: org{Login: "org-1"}},
|
||||
},
|
||||
},
|
||||
})
|
||||
defer s.Close()
|
||||
|
||||
c := githubConnector{apiURL: s.URL, teamNameField: "both"}
|
||||
groups, err := c.userGroups(context.Background(), newClient())
|
||||
|
||||
expectNil(t, err)
|
||||
expectEquals(t, groups, []string{
|
||||
"org-1",
|
||||
"org-1:Team 1",
|
||||
"org-1:team-1",
|
||||
})
|
||||
}
|
||||
|
||||
// tests that the users login is used as their username when they have no username set
|
||||
func TestUsernameIncludedInFederatedIdentity(t *testing.T) {
|
||||
s := newTestServer(map[string]testResponse{
|
||||
"/user": {data: user{Login: "some-login", ID: 12345678}},
|
||||
"/user/emails": {data: []userEmail{{
|
||||
Email: "some@email.com",
|
||||
Verified: true,
|
||||
Primary: true,
|
||||
}}},
|
||||
"/login/oauth/access_token": {data: map[string]interface{}{
|
||||
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9",
|
||||
"expires_in": "30",
|
||||
}},
|
||||
"/user/orgs": {
|
||||
data: []org{{Login: "org-1"}},
|
||||
},
|
||||
})
|
||||
defer s.Close()
|
||||
|
||||
hostURL, err := url.Parse(s.URL)
|
||||
expectNil(t, err)
|
||||
|
||||
req, err := http.NewRequest("GET", hostURL.String(), nil)
|
||||
expectNil(t, err)
|
||||
|
||||
c := githubConnector{apiURL: s.URL, hostName: hostURL.Host, httpClient: newClient()}
|
||||
identity, err := c.HandleCallback(connector.Scopes{Groups: true}, req)
|
||||
|
||||
expectNil(t, err)
|
||||
expectEquals(t, identity.Username, "some-login")
|
||||
expectEquals(t, identity.UserID, "12345678")
|
||||
expectEquals(t, 0, len(identity.Groups))
|
||||
|
||||
c = githubConnector{apiURL: s.URL, hostName: hostURL.Host, httpClient: newClient(), loadAllGroups: true}
|
||||
identity, err = c.HandleCallback(connector.Scopes{Groups: true}, req)
|
||||
|
||||
expectNil(t, err)
|
||||
expectEquals(t, identity.Username, "some-login")
|
||||
expectEquals(t, identity.UserID, "12345678")
|
||||
expectEquals(t, identity.Groups, []string{"org-1"})
|
||||
}
|
||||
|
||||
func TestLoginUsedAsIDWhenConfigured(t *testing.T) {
|
||||
s := newTestServer(map[string]testResponse{
|
||||
"/user": {data: user{Login: "some-login", ID: 12345678, Name: "Joe Bloggs"}},
|
||||
"/user/emails": {data: []userEmail{{
|
||||
Email: "some@email.com",
|
||||
Verified: true,
|
||||
Primary: true,
|
||||
}}},
|
||||
"/login/oauth/access_token": {data: map[string]interface{}{
|
||||
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9",
|
||||
"expires_in": "30",
|
||||
}},
|
||||
"/user/orgs": {
|
||||
data: []org{{Login: "org-1"}},
|
||||
},
|
||||
})
|
||||
defer s.Close()
|
||||
|
||||
hostURL, err := url.Parse(s.URL)
|
||||
expectNil(t, err)
|
||||
|
||||
req, err := http.NewRequest("GET", hostURL.String(), nil)
|
||||
expectNil(t, err)
|
||||
|
||||
c := githubConnector{apiURL: s.URL, hostName: hostURL.Host, httpClient: newClient(), useLoginAsID: true}
|
||||
identity, err := c.HandleCallback(connector.Scopes{Groups: true}, req)
|
||||
|
||||
expectNil(t, err)
|
||||
expectEquals(t, identity.UserID, "some-login")
|
||||
expectEquals(t, identity.Username, "Joe Bloggs")
|
||||
}
|
||||
|
||||
func newTestServer(responses map[string]testResponse) *httptest.Server {
|
||||
var s *httptest.Server
|
||||
s = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
response := responses[r.RequestURI]
|
||||
linkParts := make([]string, 0)
|
||||
if response.nextLink != "" {
|
||||
linkParts = append(linkParts, fmt.Sprintf("<%s%s>; rel=\"next\"", s.URL, response.nextLink))
|
||||
}
|
||||
if response.lastLink != "" {
|
||||
linkParts = append(linkParts, fmt.Sprintf("<%s%s>; rel=\"last\"", s.URL, response.lastLink))
|
||||
}
|
||||
if len(linkParts) > 0 {
|
||||
w.Header().Add("Link", strings.Join(linkParts, ", "))
|
||||
}
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response.data)
|
||||
}))
|
||||
return s
|
||||
}
|
||||
|
||||
func newClient() *http.Client {
|
||||
tr := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
return &http.Client{Transport: tr}
|
||||
}
|
||||
|
||||
func expectNil(t *testing.T, a interface{}) {
|
||||
if a != nil {
|
||||
t.Errorf("Expected %+v to equal nil", a)
|
||||
}
|
||||
}
|
||||
|
||||
func expectEquals(t *testing.T, a interface{}, b interface{}) {
|
||||
if !reflect.DeepEqual(a, b) {
|
||||
t.Errorf("Expected %+v to equal %+v", a, b)
|
||||
}
|
||||
}
|
|
@ -6,27 +6,34 @@ import (
|
|||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/Sirupsen/logrus"
|
||||
"github.com/coreos/dex/connector"
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"github.com/dexidp/dex/connector"
|
||||
"github.com/dexidp/dex/pkg/groups"
|
||||
"github.com/dexidp/dex/pkg/log"
|
||||
)
|
||||
|
||||
const (
|
||||
scopeEmail = "user:email"
|
||||
scopeOrgs = "read:org"
|
||||
// read operations of the /api/v4/user endpoint
|
||||
scopeUser = "read_user"
|
||||
// used to retrieve groups from /oauth/userinfo
|
||||
// https://docs.gitlab.com/ee/integration/openid_connect_provider.html
|
||||
scopeOpenID = "openid"
|
||||
)
|
||||
|
||||
// Config holds configuration options for gilab logins.
|
||||
// Config holds configuration options for gitlab logins.
|
||||
type Config struct {
|
||||
BaseURL string `json:"baseURL"`
|
||||
ClientID string `json:"clientID"`
|
||||
ClientSecret string `json:"clientSecret"`
|
||||
RedirectURI string `json:"redirectURI"`
|
||||
BaseURL string `json:"baseURL"`
|
||||
ClientID string `json:"clientID"`
|
||||
ClientSecret string `json:"clientSecret"`
|
||||
RedirectURI string `json:"redirectURI"`
|
||||
Groups []string `json:"groups"`
|
||||
UseLoginAsID bool `json:"useLoginAsID"`
|
||||
}
|
||||
|
||||
type gitlabUser struct {
|
||||
|
@ -38,16 +45,10 @@ type gitlabUser struct {
|
|||
IsAdmin bool
|
||||
}
|
||||
|
||||
type gitlabGroup struct {
|
||||
ID int
|
||||
Name string
|
||||
Path string
|
||||
}
|
||||
|
||||
// Open returns a strategy for logging in through GitLab.
|
||||
func (c *Config) Open(logger logrus.FieldLogger) (connector.Connector, error) {
|
||||
func (c *Config) Open(id string, logger log.Logger) (connector.Connector, error) {
|
||||
if c.BaseURL == "" {
|
||||
c.BaseURL = "https://www.gitlab.com"
|
||||
c.BaseURL = "https://gitlab.com"
|
||||
}
|
||||
return &gitlabConnector{
|
||||
baseURL: c.BaseURL,
|
||||
|
@ -55,12 +56,15 @@ func (c *Config) Open(logger logrus.FieldLogger) (connector.Connector, error) {
|
|||
clientID: c.ClientID,
|
||||
clientSecret: c.ClientSecret,
|
||||
logger: logger,
|
||||
groups: c.Groups,
|
||||
useLoginAsID: c.UseLoginAsID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type connectorData struct {
|
||||
// GitLab's OAuth2 tokens never expire. We don't need a refresh token.
|
||||
AccessToken string `json:"accessToken"`
|
||||
// Support GitLab's Access Tokens and Refresh tokens.
|
||||
AccessToken string `json:"accessToken"`
|
||||
RefreshToken string `json:"refreshToken"`
|
||||
}
|
||||
|
||||
var (
|
||||
|
@ -71,14 +75,21 @@ var (
|
|||
type gitlabConnector struct {
|
||||
baseURL string
|
||||
redirectURI string
|
||||
org string
|
||||
groups []string
|
||||
clientID string
|
||||
clientSecret string
|
||||
logger logrus.FieldLogger
|
||||
logger log.Logger
|
||||
httpClient *http.Client
|
||||
// if set to true will use the user's handle rather than their numeric id as the ID
|
||||
useLoginAsID bool
|
||||
}
|
||||
|
||||
func (c *gitlabConnector) oauth2Config(scopes connector.Scopes) *oauth2.Config {
|
||||
gitlabScopes := []string{"api"}
|
||||
gitlabScopes := []string{scopeUser}
|
||||
if c.groupsRequired(scopes.Groups) {
|
||||
gitlabScopes = []string{scopeUser, scopeOpenID}
|
||||
}
|
||||
|
||||
gitlabEndpoint := oauth2.Endpoint{AuthURL: c.baseURL + "/oauth/authorize", TokenURL: c.baseURL + "/oauth/token"}
|
||||
return &oauth2.Config{
|
||||
ClientID: c.clientID,
|
||||
|
@ -115,13 +126,22 @@ func (c *gitlabConnector) HandleCallback(s connector.Scopes, r *http.Request) (i
|
|||
}
|
||||
|
||||
oauth2Config := c.oauth2Config(s)
|
||||
|
||||
ctx := r.Context()
|
||||
if c.httpClient != nil {
|
||||
ctx = context.WithValue(r.Context(), oauth2.HTTPClient, c.httpClient)
|
||||
}
|
||||
|
||||
token, err := oauth2Config.Exchange(ctx, q.Get("code"))
|
||||
if err != nil {
|
||||
return identity, fmt.Errorf("gitlab: failed to get token: %v", err)
|
||||
}
|
||||
|
||||
return c.identity(ctx, s, token)
|
||||
}
|
||||
|
||||
func (c *gitlabConnector) identity(ctx context.Context, s connector.Scopes, token *oauth2.Token) (identity connector.Identity, err error) {
|
||||
oauth2Config := c.oauth2Config(s)
|
||||
client := oauth2Config.Client(ctx, token)
|
||||
|
||||
user, err := c.user(ctx, client)
|
||||
|
@ -133,15 +153,20 @@ func (c *gitlabConnector) HandleCallback(s connector.Scopes, r *http.Request) (i
|
|||
if username == "" {
|
||||
username = user.Email
|
||||
}
|
||||
|
||||
identity = connector.Identity{
|
||||
UserID: strconv.Itoa(user.ID),
|
||||
Username: username,
|
||||
Email: user.Email,
|
||||
EmailVerified: true,
|
||||
UserID: strconv.Itoa(user.ID),
|
||||
Username: username,
|
||||
PreferredUsername: user.Username,
|
||||
Email: user.Email,
|
||||
EmailVerified: true,
|
||||
}
|
||||
if c.useLoginAsID {
|
||||
identity.UserID = user.Username
|
||||
}
|
||||
|
||||
if s.Groups {
|
||||
groups, err := c.groups(ctx, client)
|
||||
if c.groupsRequired(s.Groups) {
|
||||
groups, err := c.getGroups(ctx, client, s.Groups, user.Username)
|
||||
if err != nil {
|
||||
return identity, fmt.Errorf("gitlab: get groups: %v", err)
|
||||
}
|
||||
|
@ -149,10 +174,10 @@ func (c *gitlabConnector) HandleCallback(s connector.Scopes, r *http.Request) (i
|
|||
}
|
||||
|
||||
if s.OfflineAccess {
|
||||
data := connectorData{AccessToken: token.AccessToken}
|
||||
data := connectorData{RefreshToken: token.RefreshToken, AccessToken: token.AccessToken}
|
||||
connData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return identity, fmt.Errorf("marshal connector data: %v", err)
|
||||
return identity, fmt.Errorf("gitlab: marshal connector data: %v", err)
|
||||
}
|
||||
identity.ConnectorData = connData
|
||||
}
|
||||
|
@ -161,36 +186,43 @@ func (c *gitlabConnector) HandleCallback(s connector.Scopes, r *http.Request) (i
|
|||
}
|
||||
|
||||
func (c *gitlabConnector) Refresh(ctx context.Context, s connector.Scopes, ident connector.Identity) (connector.Identity, error) {
|
||||
if len(ident.ConnectorData) == 0 {
|
||||
return ident, errors.New("no upstream access token found")
|
||||
}
|
||||
|
||||
var data connectorData
|
||||
if err := json.Unmarshal(ident.ConnectorData, &data); err != nil {
|
||||
return ident, fmt.Errorf("gitlab: unmarshal access token: %v", err)
|
||||
return ident, fmt.Errorf("gitlab: unmarshal connector data: %v", err)
|
||||
}
|
||||
oauth2Config := c.oauth2Config(s)
|
||||
|
||||
if c.httpClient != nil {
|
||||
ctx = context.WithValue(ctx, oauth2.HTTPClient, c.httpClient)
|
||||
}
|
||||
|
||||
client := c.oauth2Config(s).Client(ctx, &oauth2.Token{AccessToken: data.AccessToken})
|
||||
user, err := c.user(ctx, client)
|
||||
if err != nil {
|
||||
return ident, fmt.Errorf("gitlab: get user: %v", err)
|
||||
}
|
||||
|
||||
username := user.Name
|
||||
if username == "" {
|
||||
username = user.Email
|
||||
}
|
||||
ident.Username = username
|
||||
ident.Email = user.Email
|
||||
|
||||
if s.Groups {
|
||||
groups, err := c.groups(ctx, client)
|
||||
if err != nil {
|
||||
return ident, fmt.Errorf("gitlab: get groups: %v", err)
|
||||
switch {
|
||||
case data.RefreshToken != "":
|
||||
{
|
||||
t := &oauth2.Token{
|
||||
RefreshToken: data.RefreshToken,
|
||||
Expiry: time.Now().Add(-time.Hour),
|
||||
}
|
||||
token, err := oauth2Config.TokenSource(ctx, t).Token()
|
||||
if err != nil {
|
||||
return ident, fmt.Errorf("gitlab: failed to get refresh token: %v", err)
|
||||
}
|
||||
return c.identity(ctx, s, token)
|
||||
}
|
||||
ident.Groups = groups
|
||||
case data.AccessToken != "":
|
||||
{
|
||||
token := &oauth2.Token{
|
||||
AccessToken: data.AccessToken,
|
||||
}
|
||||
return c.identity(ctx, s, token)
|
||||
}
|
||||
default:
|
||||
return ident, errors.New("no refresh or access token found")
|
||||
}
|
||||
return ident, nil
|
||||
}
|
||||
|
||||
func (c *gitlabConnector) groupsRequired(groupScope bool) bool {
|
||||
return len(c.groups) > 0 || groupScope
|
||||
}
|
||||
|
||||
// user queries the GitLab API for profile information using the provided client. The HTTP
|
||||
|
@ -198,7 +230,7 @@ func (c *gitlabConnector) Refresh(ctx context.Context, s connector.Scopes, ident
|
|||
// a bearer token as part of the request.
|
||||
func (c *gitlabConnector) user(ctx context.Context, client *http.Client) (gitlabUser, error) {
|
||||
var u gitlabUser
|
||||
req, err := http.NewRequest("GET", c.baseURL+"/api/v3/user", nil)
|
||||
req, err := http.NewRequest("GET", c.baseURL+"/api/v4/user", nil)
|
||||
if err != nil {
|
||||
return u, fmt.Errorf("gitlab: new req: %v", err)
|
||||
}
|
||||
|
@ -210,7 +242,7 @@ func (c *gitlabConnector) user(ctx context.Context, client *http.Client) (gitlab
|
|||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return u, fmt.Errorf("gitlab: read body: %v", err)
|
||||
}
|
||||
|
@ -223,66 +255,56 @@ func (c *gitlabConnector) user(ctx context.Context, client *http.Client) (gitlab
|
|||
return u, nil
|
||||
}
|
||||
|
||||
// groups queries the GitLab API for group membership.
|
||||
type userInfo struct {
|
||||
Groups []string
|
||||
}
|
||||
|
||||
// userGroups queries the GitLab API for group membership.
|
||||
//
|
||||
// The HTTP passed client is expected to be constructed by the golang.org/x/oauth2 package,
|
||||
// which inserts a bearer token as part of the request.
|
||||
func (c *gitlabConnector) groups(ctx context.Context, client *http.Client) ([]string, error) {
|
||||
|
||||
apiURL := c.baseURL + "/api/v3/groups"
|
||||
|
||||
reNext := regexp.MustCompile("<(.*)>; rel=\"next\"")
|
||||
reLast := regexp.MustCompile("<(.*)>; rel=\"last\"")
|
||||
|
||||
groups := []string{}
|
||||
var gitlabGroups []gitlabGroup
|
||||
for {
|
||||
// 100 is the maximum number for per_page that allowed by gitlab
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gitlab: new req: %v", err)
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gitlab: get groups: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gitlab: read body: %v", err)
|
||||
}
|
||||
return nil, fmt.Errorf("%s: %s", resp.Status, body)
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&gitlabGroups); err != nil {
|
||||
return nil, fmt.Errorf("gitlab: unmarshal groups: %v", err)
|
||||
}
|
||||
|
||||
for _, group := range gitlabGroups {
|
||||
groups = append(groups, group.Name)
|
||||
}
|
||||
|
||||
link := resp.Header.Get("Link")
|
||||
|
||||
if len(reLast.FindStringSubmatch(link)) > 1 {
|
||||
lastPageURL := reLast.FindStringSubmatch(link)[1]
|
||||
|
||||
if apiURL == lastPageURL {
|
||||
break
|
||||
}
|
||||
} else {
|
||||
break
|
||||
}
|
||||
|
||||
if len(reNext.FindStringSubmatch(link)) > 1 {
|
||||
apiURL = reNext.FindStringSubmatch(link)[1]
|
||||
} else {
|
||||
break
|
||||
}
|
||||
|
||||
func (c *gitlabConnector) userGroups(ctx context.Context, client *http.Client) ([]string, error) {
|
||||
req, err := http.NewRequest("GET", c.baseURL+"/oauth/userinfo", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gitlab: new req: %v", err)
|
||||
}
|
||||
return groups, nil
|
||||
req = req.WithContext(ctx)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gitlab: get URL %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gitlab: read body: %v", err)
|
||||
}
|
||||
return nil, fmt.Errorf("%s: %s", resp.Status, body)
|
||||
}
|
||||
var u userInfo
|
||||
if err := json.NewDecoder(resp.Body).Decode(&u); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
return u.Groups, nil
|
||||
}
|
||||
|
||||
func (c *gitlabConnector) getGroups(ctx context.Context, client *http.Client, groupScope bool, userLogin string) ([]string, error) {
|
||||
gitlabGroups, err := c.userGroups(ctx, client)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(c.groups) > 0 {
|
||||
filteredGroups := groups.Filter(gitlabGroups, c.groups)
|
||||
if len(filteredGroups) == 0 {
|
||||
return nil, fmt.Errorf("gitlab: user %q is not in any of the required groups", userLogin)
|
||||
}
|
||||
return filteredGroups, nil
|
||||
} else if groupScope {
|
||||
return gitlabGroups, nil
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
|
|
@ -0,0 +1,283 @@
|
|||
package gitlab
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/dexidp/dex/connector"
|
||||
)
|
||||
|
||||
func TestUserGroups(t *testing.T) {
|
||||
s := newTestServer(map[string]interface{}{
|
||||
"/oauth/userinfo": userInfo{
|
||||
Groups: []string{"team-1", "team-2"},
|
||||
},
|
||||
})
|
||||
defer s.Close()
|
||||
|
||||
c := gitlabConnector{baseURL: s.URL}
|
||||
groups, err := c.getGroups(context.Background(), newClient(), true, "joebloggs")
|
||||
|
||||
expectNil(t, err)
|
||||
expectEquals(t, groups, []string{
|
||||
"team-1",
|
||||
"team-2",
|
||||
})
|
||||
}
|
||||
|
||||
func TestUserGroupsWithFiltering(t *testing.T) {
|
||||
s := newTestServer(map[string]interface{}{
|
||||
"/oauth/userinfo": userInfo{
|
||||
Groups: []string{"team-1", "team-2"},
|
||||
},
|
||||
})
|
||||
defer s.Close()
|
||||
|
||||
c := gitlabConnector{baseURL: s.URL, groups: []string{"team-1"}}
|
||||
groups, err := c.getGroups(context.Background(), newClient(), true, "joebloggs")
|
||||
|
||||
expectNil(t, err)
|
||||
expectEquals(t, groups, []string{
|
||||
"team-1",
|
||||
})
|
||||
}
|
||||
|
||||
func TestUserGroupsWithoutOrgs(t *testing.T) {
|
||||
s := newTestServer(map[string]interface{}{
|
||||
"/oauth/userinfo": userInfo{
|
||||
Groups: []string{},
|
||||
},
|
||||
})
|
||||
defer s.Close()
|
||||
|
||||
c := gitlabConnector{baseURL: s.URL}
|
||||
groups, err := c.getGroups(context.Background(), newClient(), true, "joebloggs")
|
||||
|
||||
expectNil(t, err)
|
||||
expectEquals(t, len(groups), 0)
|
||||
}
|
||||
|
||||
// tests that the email is used as their username when they have no username set
|
||||
func TestUsernameIncludedInFederatedIdentity(t *testing.T) {
|
||||
s := newTestServer(map[string]interface{}{
|
||||
"/api/v4/user": gitlabUser{Email: "some@email.com", ID: 12345678},
|
||||
"/oauth/token": map[string]interface{}{
|
||||
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9",
|
||||
"expires_in": "30",
|
||||
},
|
||||
"/oauth/userinfo": userInfo{
|
||||
Groups: []string{"team-1"},
|
||||
},
|
||||
})
|
||||
defer s.Close()
|
||||
|
||||
hostURL, err := url.Parse(s.URL)
|
||||
expectNil(t, err)
|
||||
|
||||
req, err := http.NewRequest("GET", hostURL.String(), nil)
|
||||
expectNil(t, err)
|
||||
|
||||
c := gitlabConnector{baseURL: s.URL, httpClient: newClient()}
|
||||
identity, err := c.HandleCallback(connector.Scopes{Groups: false}, req)
|
||||
|
||||
expectNil(t, err)
|
||||
expectEquals(t, identity.Username, "some@email.com")
|
||||
expectEquals(t, identity.UserID, "12345678")
|
||||
expectEquals(t, 0, len(identity.Groups))
|
||||
|
||||
c = gitlabConnector{baseURL: s.URL, httpClient: newClient()}
|
||||
identity, err = c.HandleCallback(connector.Scopes{Groups: true}, req)
|
||||
|
||||
expectNil(t, err)
|
||||
expectEquals(t, identity.Username, "some@email.com")
|
||||
expectEquals(t, identity.UserID, "12345678")
|
||||
expectEquals(t, identity.Groups, []string{"team-1"})
|
||||
}
|
||||
|
||||
func TestLoginUsedAsIDWhenConfigured(t *testing.T) {
|
||||
s := newTestServer(map[string]interface{}{
|
||||
"/api/v4/user": gitlabUser{Email: "some@email.com", ID: 12345678, Name: "Joe Bloggs", Username: "joebloggs"},
|
||||
"/oauth/token": map[string]interface{}{
|
||||
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9",
|
||||
"expires_in": "30",
|
||||
},
|
||||
"/oauth/userinfo": userInfo{
|
||||
Groups: []string{"team-1"},
|
||||
},
|
||||
})
|
||||
defer s.Close()
|
||||
|
||||
hostURL, err := url.Parse(s.URL)
|
||||
expectNil(t, err)
|
||||
|
||||
req, err := http.NewRequest("GET", hostURL.String(), nil)
|
||||
expectNil(t, err)
|
||||
|
||||
c := gitlabConnector{baseURL: s.URL, httpClient: newClient(), useLoginAsID: true}
|
||||
identity, err := c.HandleCallback(connector.Scopes{Groups: true}, req)
|
||||
|
||||
expectNil(t, err)
|
||||
expectEquals(t, identity.UserID, "joebloggs")
|
||||
expectEquals(t, identity.Username, "Joe Bloggs")
|
||||
}
|
||||
|
||||
func TestLoginWithTeamWhitelisted(t *testing.T) {
|
||||
s := newTestServer(map[string]interface{}{
|
||||
"/api/v4/user": gitlabUser{Email: "some@email.com", ID: 12345678, Name: "Joe Bloggs"},
|
||||
"/oauth/token": map[string]interface{}{
|
||||
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9",
|
||||
"expires_in": "30",
|
||||
},
|
||||
"/oauth/userinfo": userInfo{
|
||||
Groups: []string{"team-1"},
|
||||
},
|
||||
})
|
||||
defer s.Close()
|
||||
|
||||
hostURL, err := url.Parse(s.URL)
|
||||
expectNil(t, err)
|
||||
|
||||
req, err := http.NewRequest("GET", hostURL.String(), nil)
|
||||
expectNil(t, err)
|
||||
|
||||
c := gitlabConnector{baseURL: s.URL, httpClient: newClient(), groups: []string{"team-1"}}
|
||||
identity, err := c.HandleCallback(connector.Scopes{Groups: true}, req)
|
||||
|
||||
expectNil(t, err)
|
||||
expectEquals(t, identity.UserID, "12345678")
|
||||
expectEquals(t, identity.Username, "Joe Bloggs")
|
||||
}
|
||||
|
||||
func TestLoginWithTeamNonWhitelisted(t *testing.T) {
|
||||
s := newTestServer(map[string]interface{}{
|
||||
"/api/v4/user": gitlabUser{Email: "some@email.com", ID: 12345678, Name: "Joe Bloggs", Username: "joebloggs"},
|
||||
"/oauth/token": map[string]interface{}{
|
||||
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9",
|
||||
"expires_in": "30",
|
||||
},
|
||||
"/oauth/userinfo": userInfo{
|
||||
Groups: []string{"team-1"},
|
||||
},
|
||||
})
|
||||
defer s.Close()
|
||||
|
||||
hostURL, err := url.Parse(s.URL)
|
||||
expectNil(t, err)
|
||||
|
||||
req, err := http.NewRequest("GET", hostURL.String(), nil)
|
||||
expectNil(t, err)
|
||||
|
||||
c := gitlabConnector{baseURL: s.URL, httpClient: newClient(), groups: []string{"team-2"}}
|
||||
_, err = c.HandleCallback(connector.Scopes{Groups: true}, req)
|
||||
|
||||
expectNotNil(t, err, "HandleCallback error")
|
||||
expectEquals(t, err.Error(), "gitlab: get groups: gitlab: user \"joebloggs\" is not in any of the required groups")
|
||||
}
|
||||
|
||||
func TestRefresh(t *testing.T) {
|
||||
s := newTestServer(map[string]interface{}{
|
||||
"/api/v4/user": gitlabUser{Email: "some@email.com", ID: 12345678},
|
||||
"/oauth/token": map[string]interface{}{
|
||||
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9",
|
||||
"refresh_token": "oRzxVjCnohYRHEYEhZshkmakKmoyVoTjfUGC",
|
||||
"expires_in": "30",
|
||||
},
|
||||
"/oauth/userinfo": userInfo{
|
||||
Groups: []string{"team-1"},
|
||||
},
|
||||
})
|
||||
defer s.Close()
|
||||
|
||||
hostURL, err := url.Parse(s.URL)
|
||||
expectNil(t, err)
|
||||
|
||||
req, err := http.NewRequest("GET", hostURL.String(), nil)
|
||||
expectNil(t, err)
|
||||
|
||||
c := gitlabConnector{baseURL: s.URL, httpClient: newClient()}
|
||||
|
||||
expectedConnectorData, err := json.Marshal(connectorData{
|
||||
RefreshToken: "oRzxVjCnohYRHEYEhZshkmakKmoyVoTjfUGC",
|
||||
AccessToken: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9",
|
||||
})
|
||||
expectNil(t, err)
|
||||
|
||||
identity, err := c.HandleCallback(connector.Scopes{OfflineAccess: true}, req)
|
||||
expectNil(t, err)
|
||||
expectEquals(t, identity.Username, "some@email.com")
|
||||
expectEquals(t, identity.UserID, "12345678")
|
||||
expectEquals(t, identity.ConnectorData, expectedConnectorData)
|
||||
|
||||
identity, err = c.Refresh(context.Background(), connector.Scopes{OfflineAccess: true}, identity)
|
||||
expectNil(t, err)
|
||||
expectEquals(t, identity.Username, "some@email.com")
|
||||
expectEquals(t, identity.UserID, "12345678")
|
||||
expectEquals(t, identity.ConnectorData, expectedConnectorData)
|
||||
}
|
||||
|
||||
func TestRefreshWithEmptyConnectorData(t *testing.T) {
|
||||
s := newTestServer(map[string]interface{}{
|
||||
"/api/v4/user": gitlabUser{Email: "some@email.com", ID: 12345678},
|
||||
"/oauth/token": map[string]interface{}{
|
||||
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9",
|
||||
"refresh_token": "oRzxVjCnohYRHEYEhZshkmakKmoyVoTjfUGC",
|
||||
"expires_in": "30",
|
||||
},
|
||||
"/oauth/userinfo": userInfo{
|
||||
Groups: []string{"team-1"},
|
||||
},
|
||||
})
|
||||
defer s.Close()
|
||||
|
||||
emptyConnectorData, err := json.Marshal(connectorData{
|
||||
RefreshToken: "",
|
||||
AccessToken: "",
|
||||
})
|
||||
expectNil(t, err)
|
||||
|
||||
c := gitlabConnector{baseURL: s.URL, httpClient: newClient()}
|
||||
emptyIdentity := connector.Identity{ConnectorData: emptyConnectorData}
|
||||
|
||||
identity, err := c.Refresh(context.Background(), connector.Scopes{OfflineAccess: true}, emptyIdentity)
|
||||
expectNotNil(t, err, "Refresh error")
|
||||
expectEquals(t, emptyIdentity, identity)
|
||||
}
|
||||
|
||||
func newTestServer(responses map[string]interface{}) *httptest.Server {
|
||||
return httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
response := responses[r.RequestURI]
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}))
|
||||
}
|
||||
|
||||
func newClient() *http.Client {
|
||||
tr := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
return &http.Client{Transport: tr}
|
||||
}
|
||||
|
||||
func expectNil(t *testing.T, a interface{}) {
|
||||
if a != nil {
|
||||
t.Errorf("Expected %+v to equal nil", a)
|
||||
}
|
||||
}
|
||||
|
||||
func expectNotNil(t *testing.T, a interface{}, msg string) {
|
||||
if a == nil {
|
||||
t.Errorf("Expected %+v to not to be nil", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func expectEquals(t *testing.T, a interface{}, b interface{}) {
|
||||
if !reflect.DeepEqual(a, b) {
|
||||
t.Errorf("Expected %+v to equal %+v", a, b)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,326 @@
|
|||
// Package google implements logging in through Google's OpenID Connect provider.
|
||||
package google
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/google"
|
||||
admin "google.golang.org/api/admin/directory/v1"
|
||||
"google.golang.org/api/option"
|
||||
|
||||
"github.com/dexidp/dex/connector"
|
||||
pkg_groups "github.com/dexidp/dex/pkg/groups"
|
||||
"github.com/dexidp/dex/pkg/log"
|
||||
)
|
||||
|
||||
const (
|
||||
issuerURL = "https://accounts.google.com"
|
||||
)
|
||||
|
||||
// Config holds configuration options for Google logins.
|
||||
type Config struct {
|
||||
ClientID string `json:"clientID"`
|
||||
ClientSecret string `json:"clientSecret"`
|
||||
RedirectURI string `json:"redirectURI"`
|
||||
|
||||
Scopes []string `json:"scopes"` // defaults to "profile" and "email"
|
||||
|
||||
// Optional list of whitelisted domains
|
||||
// If this field is nonempty, only users from a listed domain will be allowed to log in
|
||||
HostedDomains []string `json:"hostedDomains"`
|
||||
|
||||
// Optional list of whitelisted groups
|
||||
// If this field is nonempty, only users from a listed group will be allowed to log in
|
||||
Groups []string `json:"groups"`
|
||||
|
||||
// Optional path to service account json
|
||||
// If nonempty, and groups claim is made, will use authentication from file to
|
||||
// check groups with the admin directory api
|
||||
ServiceAccountFilePath string `json:"serviceAccountFilePath"`
|
||||
|
||||
// Required if ServiceAccountFilePath
|
||||
// The email of a GSuite super user which the service account will impersonate
|
||||
// when listing groups
|
||||
AdminEmail string
|
||||
|
||||
// If this field is true, fetch direct group membership and transitive group membership
|
||||
FetchTransitiveGroupMembership bool `json:"fetchTransitiveGroupMembership"`
|
||||
}
|
||||
|
||||
// Open returns a connector which can be used to login users through Google.
|
||||
func (c *Config) Open(id string, logger log.Logger) (conn connector.Connector, err error) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
provider, err := oidc.NewProvider(ctx, issuerURL)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("failed to get provider: %v", err)
|
||||
}
|
||||
|
||||
scopes := []string{oidc.ScopeOpenID}
|
||||
if len(c.Scopes) > 0 {
|
||||
scopes = append(scopes, c.Scopes...)
|
||||
} else {
|
||||
scopes = append(scopes, "profile", "email")
|
||||
}
|
||||
|
||||
srv, err := createDirectoryService(c.ServiceAccountFilePath, c.AdminEmail)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("could not create directory service: %v", err)
|
||||
}
|
||||
|
||||
clientID := c.ClientID
|
||||
return &googleConnector{
|
||||
redirectURI: c.RedirectURI,
|
||||
oauth2Config: &oauth2.Config{
|
||||
ClientID: clientID,
|
||||
ClientSecret: c.ClientSecret,
|
||||
Endpoint: provider.Endpoint(),
|
||||
Scopes: scopes,
|
||||
RedirectURL: c.RedirectURI,
|
||||
},
|
||||
verifier: provider.Verifier(
|
||||
&oidc.Config{ClientID: clientID},
|
||||
),
|
||||
logger: logger,
|
||||
cancel: cancel,
|
||||
hostedDomains: c.HostedDomains,
|
||||
groups: c.Groups,
|
||||
serviceAccountFilePath: c.ServiceAccountFilePath,
|
||||
adminEmail: c.AdminEmail,
|
||||
fetchTransitiveGroupMembership: c.FetchTransitiveGroupMembership,
|
||||
adminSrv: srv,
|
||||
}, nil
|
||||
}
|
||||
|
||||
var (
|
||||
_ connector.CallbackConnector = (*googleConnector)(nil)
|
||||
_ connector.RefreshConnector = (*googleConnector)(nil)
|
||||
)
|
||||
|
||||
type googleConnector struct {
|
||||
redirectURI string
|
||||
oauth2Config *oauth2.Config
|
||||
verifier *oidc.IDTokenVerifier
|
||||
cancel context.CancelFunc
|
||||
logger log.Logger
|
||||
hostedDomains []string
|
||||
groups []string
|
||||
serviceAccountFilePath string
|
||||
adminEmail string
|
||||
fetchTransitiveGroupMembership bool
|
||||
adminSrv *admin.Service
|
||||
}
|
||||
|
||||
func (c *googleConnector) Close() error {
|
||||
c.cancel()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *googleConnector) LoginURL(s connector.Scopes, callbackURL, state string) (string, error) {
|
||||
if c.redirectURI != callbackURL {
|
||||
return "", fmt.Errorf("expected callback URL %q did not match the URL in the config %q", callbackURL, c.redirectURI)
|
||||
}
|
||||
|
||||
var opts []oauth2.AuthCodeOption
|
||||
if len(c.hostedDomains) > 0 {
|
||||
preferredDomain := c.hostedDomains[0]
|
||||
if len(c.hostedDomains) > 1 {
|
||||
preferredDomain = "*"
|
||||
}
|
||||
opts = append(opts, oauth2.SetAuthURLParam("hd", preferredDomain))
|
||||
}
|
||||
|
||||
if s.OfflineAccess {
|
||||
opts = append(opts, oauth2.AccessTypeOffline, oauth2.SetAuthURLParam("prompt", "consent"))
|
||||
}
|
||||
return c.oauth2Config.AuthCodeURL(state, opts...), nil
|
||||
}
|
||||
|
||||
type oauth2Error struct {
|
||||
error string
|
||||
errorDescription string
|
||||
}
|
||||
|
||||
func (e *oauth2Error) Error() string {
|
||||
if e.errorDescription == "" {
|
||||
return e.error
|
||||
}
|
||||
return e.error + ": " + e.errorDescription
|
||||
}
|
||||
|
||||
func (c *googleConnector) HandleCallback(s connector.Scopes, r *http.Request) (identity connector.Identity, err error) {
|
||||
q := r.URL.Query()
|
||||
if errType := q.Get("error"); errType != "" {
|
||||
return identity, &oauth2Error{errType, q.Get("error_description")}
|
||||
}
|
||||
token, err := c.oauth2Config.Exchange(r.Context(), q.Get("code"))
|
||||
if err != nil {
|
||||
return identity, fmt.Errorf("google: failed to get token: %v", err)
|
||||
}
|
||||
|
||||
return c.createIdentity(r.Context(), identity, s, token)
|
||||
}
|
||||
|
||||
func (c *googleConnector) Refresh(ctx context.Context, s connector.Scopes, identity connector.Identity) (connector.Identity, error) {
|
||||
t := &oauth2.Token{
|
||||
RefreshToken: string(identity.ConnectorData),
|
||||
Expiry: time.Now().Add(-time.Hour),
|
||||
}
|
||||
token, err := c.oauth2Config.TokenSource(ctx, t).Token()
|
||||
if err != nil {
|
||||
return identity, fmt.Errorf("google: failed to get token: %v", err)
|
||||
}
|
||||
|
||||
return c.createIdentity(ctx, identity, s, token)
|
||||
}
|
||||
|
||||
func (c *googleConnector) createIdentity(ctx context.Context, identity connector.Identity, s connector.Scopes, token *oauth2.Token) (connector.Identity, error) {
|
||||
rawIDToken, ok := token.Extra("id_token").(string)
|
||||
if !ok {
|
||||
return identity, errors.New("google: no id_token in token response")
|
||||
}
|
||||
idToken, err := c.verifier.Verify(ctx, rawIDToken)
|
||||
if err != nil {
|
||||
return identity, fmt.Errorf("google: failed to verify ID Token: %v", err)
|
||||
}
|
||||
|
||||
var claims struct {
|
||||
Username string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
EmailVerified bool `json:"email_verified"`
|
||||
HostedDomain string `json:"hd"`
|
||||
}
|
||||
if err := idToken.Claims(&claims); err != nil {
|
||||
return identity, fmt.Errorf("oidc: failed to decode claims: %v", err)
|
||||
}
|
||||
|
||||
if len(c.hostedDomains) > 0 {
|
||||
found := false
|
||||
for _, domain := range c.hostedDomains {
|
||||
if claims.HostedDomain == domain {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
return identity, fmt.Errorf("oidc: unexpected hd claim %v", claims.HostedDomain)
|
||||
}
|
||||
}
|
||||
|
||||
var groups []string
|
||||
if s.Groups && c.adminSrv != nil {
|
||||
groups, err = c.getGroups(claims.Email, c.fetchTransitiveGroupMembership)
|
||||
if err != nil {
|
||||
return identity, fmt.Errorf("google: could not retrieve groups: %v", err)
|
||||
}
|
||||
|
||||
if len(c.groups) > 0 {
|
||||
groups = pkg_groups.Filter(groups, c.groups)
|
||||
if len(groups) == 0 {
|
||||
return identity, fmt.Errorf("google: user %q is not in any of the required groups", claims.Username)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
identity = connector.Identity{
|
||||
UserID: idToken.Subject,
|
||||
Username: claims.Username,
|
||||
Email: claims.Email,
|
||||
EmailVerified: claims.EmailVerified,
|
||||
ConnectorData: []byte(token.RefreshToken),
|
||||
Groups: groups,
|
||||
}
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
// getGroups creates a connection to the admin directory service and lists
|
||||
// all groups the user is a member of
|
||||
func (c *googleConnector) getGroups(email string, fetchTransitiveGroupMembership bool) ([]string, error) {
|
||||
var userGroups []string
|
||||
var err error
|
||||
groupsList := &admin.Groups{}
|
||||
for {
|
||||
groupsList, err = c.adminSrv.Groups.List().
|
||||
UserKey(email).PageToken(groupsList.NextPageToken).Do()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not list groups: %v", err)
|
||||
}
|
||||
|
||||
for _, group := range groupsList.Groups {
|
||||
// TODO (joelspeed): Make desired group key configurable
|
||||
userGroups = append(userGroups, group.Email)
|
||||
|
||||
// getGroups takes a user's email/alias as well as a group's email/alias
|
||||
if fetchTransitiveGroupMembership {
|
||||
transitiveGroups, err := c.getGroups(group.Email, fetchTransitiveGroupMembership)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not list transitive groups: %v", err)
|
||||
}
|
||||
|
||||
userGroups = append(userGroups, transitiveGroups...)
|
||||
}
|
||||
}
|
||||
|
||||
if groupsList.NextPageToken == "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return uniqueGroups(userGroups), nil
|
||||
}
|
||||
|
||||
// createDirectoryService loads a google service account credentials file,
|
||||
// sets up super user impersonation and creates an admin client for calling
|
||||
// the google admin api
|
||||
func createDirectoryService(serviceAccountFilePath string, email string) (*admin.Service, error) {
|
||||
if serviceAccountFilePath == "" && email == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if serviceAccountFilePath == "" || email == "" {
|
||||
return nil, fmt.Errorf("directory service requires both serviceAccountFilePath and adminEmail")
|
||||
}
|
||||
jsonCredentials, err := os.ReadFile(serviceAccountFilePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error reading credentials from file: %v", err)
|
||||
}
|
||||
|
||||
config, err := google.JWTConfigFromJSON(jsonCredentials, admin.AdminDirectoryGroupReadonlyScope)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to parse client secret file to config: %v", err)
|
||||
}
|
||||
|
||||
// Impersonate an admin. This is mandatory for the admin APIs.
|
||||
config.Subject = email
|
||||
|
||||
ctx := context.Background()
|
||||
client := config.Client(ctx)
|
||||
|
||||
srv, err := admin.NewService(ctx, option.WithHTTPClient(client))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to create directory service %v", err)
|
||||
}
|
||||
return srv, nil
|
||||
}
|
||||
|
||||
// uniqueGroups returns the unique groups of a slice
|
||||
func uniqueGroups(groups []string) []string {
|
||||
keys := make(map[string]struct{})
|
||||
unique := []string{}
|
||||
for _, group := range groups {
|
||||
if _, exists := keys[group]; !exists {
|
||||
keys[group] = struct{}{}
|
||||
unique = append(unique, group)
|
||||
}
|
||||
}
|
||||
return unique
|
||||
}
|
|
@ -0,0 +1,312 @@
|
|||
// Package keystone provides authentication strategy using Keystone.
|
||||
package keystone
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/dexidp/dex/connector"
|
||||
"github.com/dexidp/dex/pkg/log"
|
||||
)
|
||||
|
||||
type conn struct {
|
||||
Domain string
|
||||
Host string
|
||||
AdminUsername string
|
||||
AdminPassword string
|
||||
Logger log.Logger
|
||||
}
|
||||
|
||||
type userKeystone struct {
|
||||
Domain domainKeystone `json:"domain"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type domainKeystone struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// Config holds the configuration parameters for Keystone connector.
|
||||
// Keystone should expose API v3
|
||||
// An example config:
|
||||
// connectors:
|
||||
// type: keystone
|
||||
// id: keystone
|
||||
// name: Keystone
|
||||
// config:
|
||||
// keystoneHost: http://example:5000
|
||||
// domain: default
|
||||
// keystoneUsername: demo
|
||||
// keystonePassword: DEMO_PASS
|
||||
type Config struct {
|
||||
Domain string `json:"domain"`
|
||||
Host string `json:"keystoneHost"`
|
||||
AdminUsername string `json:"keystoneUsername"`
|
||||
AdminPassword string `json:"keystonePassword"`
|
||||
}
|
||||
|
||||
type loginRequestData struct {
|
||||
auth `json:"auth"`
|
||||
}
|
||||
|
||||
type auth struct {
|
||||
Identity identity `json:"identity"`
|
||||
}
|
||||
|
||||
type identity struct {
|
||||
Methods []string `json:"methods"`
|
||||
Password password `json:"password"`
|
||||
}
|
||||
|
||||
type password struct {
|
||||
User user `json:"user"`
|
||||
}
|
||||
|
||||
type user struct {
|
||||
Name string `json:"name"`
|
||||
Domain domain `json:"domain"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type domain struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
type token struct {
|
||||
User userKeystone `json:"user"`
|
||||
}
|
||||
|
||||
type tokenResponse struct {
|
||||
Token token `json:"token"`
|
||||
}
|
||||
|
||||
type group struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type groupsResponse struct {
|
||||
Groups []group `json:"groups"`
|
||||
}
|
||||
|
||||
type userResponse struct {
|
||||
User struct {
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
ID string `json:"id"`
|
||||
} `json:"user"`
|
||||
}
|
||||
|
||||
var (
|
||||
_ connector.PasswordConnector = &conn{}
|
||||
_ connector.RefreshConnector = &conn{}
|
||||
)
|
||||
|
||||
// Open returns an authentication strategy using Keystone.
|
||||
func (c *Config) Open(id string, logger log.Logger) (connector.Connector, error) {
|
||||
return &conn{
|
||||
c.Domain,
|
||||
c.Host,
|
||||
c.AdminUsername,
|
||||
c.AdminPassword,
|
||||
logger,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *conn) Close() error { return nil }
|
||||
|
||||
func (p *conn) Login(ctx context.Context, scopes connector.Scopes, username, password string) (identity connector.Identity, validPassword bool, err error) {
|
||||
resp, err := p.getTokenResponse(ctx, username, password)
|
||||
if err != nil {
|
||||
return identity, false, fmt.Errorf("keystone: error %v", err)
|
||||
}
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return identity, false, fmt.Errorf("keystone login: error %v", resp.StatusCode)
|
||||
}
|
||||
if resp.StatusCode != 201 {
|
||||
return identity, false, nil
|
||||
}
|
||||
token := resp.Header.Get("X-Subject-Token")
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return identity, false, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
tokenResp := new(tokenResponse)
|
||||
err = json.Unmarshal(data, &tokenResp)
|
||||
if err != nil {
|
||||
return identity, false, fmt.Errorf("keystone: invalid token response: %v", err)
|
||||
}
|
||||
if scopes.Groups {
|
||||
groups, err := p.getUserGroups(ctx, tokenResp.Token.User.ID, token)
|
||||
if err != nil {
|
||||
return identity, false, err
|
||||
}
|
||||
identity.Groups = groups
|
||||
}
|
||||
identity.Username = username
|
||||
identity.UserID = tokenResp.Token.User.ID
|
||||
|
||||
user, err := p.getUser(ctx, tokenResp.Token.User.ID, token)
|
||||
if err != nil {
|
||||
return identity, false, err
|
||||
}
|
||||
if user.User.Email != "" {
|
||||
identity.Email = user.User.Email
|
||||
identity.EmailVerified = true
|
||||
}
|
||||
|
||||
return identity, true, nil
|
||||
}
|
||||
|
||||
func (p *conn) Prompt() string { return "username" }
|
||||
|
||||
func (p *conn) Refresh(
|
||||
ctx context.Context, scopes connector.Scopes, identity connector.Identity,
|
||||
) (connector.Identity, error) {
|
||||
token, err := p.getAdminToken(ctx)
|
||||
if err != nil {
|
||||
return identity, fmt.Errorf("keystone: failed to obtain admin token: %v", err)
|
||||
}
|
||||
ok, err := p.checkIfUserExists(ctx, identity.UserID, token)
|
||||
if err != nil {
|
||||
return identity, err
|
||||
}
|
||||
if !ok {
|
||||
return identity, fmt.Errorf("keystone: user %q does not exist", identity.UserID)
|
||||
}
|
||||
if scopes.Groups {
|
||||
groups, err := p.getUserGroups(ctx, identity.UserID, token)
|
||||
if err != nil {
|
||||
return identity, err
|
||||
}
|
||||
identity.Groups = groups
|
||||
}
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
func (p *conn) getTokenResponse(ctx context.Context, username, pass string) (response *http.Response, err error) {
|
||||
client := &http.Client{}
|
||||
jsonData := loginRequestData{
|
||||
auth: auth{
|
||||
Identity: identity{
|
||||
Methods: []string{"password"},
|
||||
Password: password{
|
||||
User: user{
|
||||
Name: username,
|
||||
Domain: domain{ID: p.Domain},
|
||||
Password: pass,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
jsonValue, err := json.Marshal(jsonData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// https://developer.openstack.org/api-ref/identity/v3/#password-authentication-with-unscoped-authorization
|
||||
authTokenURL := p.Host + "/v3/auth/tokens/"
|
||||
req, err := http.NewRequest("POST", authTokenURL, bytes.NewBuffer(jsonValue))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
return client.Do(req)
|
||||
}
|
||||
|
||||
func (p *conn) getAdminToken(ctx context.Context) (string, error) {
|
||||
resp, err := p.getTokenResponse(ctx, p.AdminUsername, p.AdminPassword)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
token := resp.Header.Get("X-Subject-Token")
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (p *conn) checkIfUserExists(ctx context.Context, userID string, token string) (bool, error) {
|
||||
user, err := p.getUser(ctx, userID, token)
|
||||
return user != nil, err
|
||||
}
|
||||
|
||||
func (p *conn) getUser(ctx context.Context, userID string, token string) (*userResponse, error) {
|
||||
// https://developer.openstack.org/api-ref/identity/v3/#show-user-details
|
||||
userURL := p.Host + "/v3/users/" + userID
|
||||
client := &http.Client{}
|
||||
req, err := http.NewRequest("GET", userURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("X-Auth-Token", token)
|
||||
req = req.WithContext(ctx)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user := userResponse{}
|
||||
err = json.Unmarshal(data, &user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (p *conn) getUserGroups(ctx context.Context, userID string, token string) ([]string, error) {
|
||||
client := &http.Client{}
|
||||
// https://developer.openstack.org/api-ref/identity/v3/#list-groups-to-which-a-user-belongs
|
||||
groupsURL := p.Host + "/v3/users/" + userID + "/groups"
|
||||
req, err := http.NewRequest("GET", groupsURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("X-Auth-Token", token)
|
||||
req = req.WithContext(ctx)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
p.Logger.Errorf("keystone: error while fetching user %q groups\n", userID)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
groupsResp := new(groupsResponse)
|
||||
|
||||
err = json.Unmarshal(data, &groupsResp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
groups := make([]string, len(groupsResp.Groups))
|
||||
for i, group := range groupsResp.Groups {
|
||||
groups[i] = group.Name
|
||||
}
|
||||
return groups, nil
|
||||
}
|
|
@ -0,0 +1,483 @@
|
|||
package keystone
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/dexidp/dex/connector"
|
||||
)
|
||||
|
||||
const (
|
||||
invalidPass = "WRONG_PASS"
|
||||
|
||||
testUser = "test_user"
|
||||
testPass = "test_pass"
|
||||
testEmail = "test@example.com"
|
||||
testGroup = "test_group"
|
||||
testDomain = "default"
|
||||
)
|
||||
|
||||
var (
|
||||
keystoneURL = ""
|
||||
keystoneAdminURL = ""
|
||||
adminUser = ""
|
||||
adminPass = ""
|
||||
authTokenURL = ""
|
||||
usersURL = ""
|
||||
groupsURL = ""
|
||||
)
|
||||
|
||||
type groupResponse struct {
|
||||
Group struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"group"`
|
||||
}
|
||||
|
||||
func getAdminToken(t *testing.T, adminName, adminPass string) (token, id string) {
|
||||
t.Helper()
|
||||
client := &http.Client{}
|
||||
|
||||
jsonData := loginRequestData{
|
||||
auth: auth{
|
||||
Identity: identity{
|
||||
Methods: []string{"password"},
|
||||
Password: password{
|
||||
User: user{
|
||||
Name: adminName,
|
||||
Domain: domain{ID: testDomain},
|
||||
Password: adminPass,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
body, err := json.Marshal(jsonData)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", authTokenURL, bytes.NewBuffer(body))
|
||||
if err != nil {
|
||||
t.Fatalf("keystone: failed to obtain admin token: %v\n", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
token = resp.Header.Get("X-Subject-Token")
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
tokenResp := new(tokenResponse)
|
||||
err = json.Unmarshal(data, &tokenResp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return token, tokenResp.Token.User.ID
|
||||
}
|
||||
|
||||
func createUser(t *testing.T, token, userName, userEmail, userPass string) string {
|
||||
t.Helper()
|
||||
client := &http.Client{}
|
||||
|
||||
createUserData := map[string]interface{}{
|
||||
"user": map[string]interface{}{
|
||||
"name": userName,
|
||||
"email": userEmail,
|
||||
"enabled": true,
|
||||
"password": userPass,
|
||||
"roles": []string{"admin"},
|
||||
},
|
||||
}
|
||||
|
||||
body, err := json.Marshal(createUserData)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", usersURL, bytes.NewBuffer(body))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("X-Auth-Token", token)
|
||||
req.Header.Add("Content-Type", "application/json")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
userResp := new(userResponse)
|
||||
err = json.Unmarshal(data, &userResp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
return userResp.User.ID
|
||||
}
|
||||
|
||||
// delete group or user
|
||||
func deleteResource(t *testing.T, token, id, uri string) {
|
||||
t.Helper()
|
||||
client := &http.Client{}
|
||||
|
||||
deleteURI := uri + id
|
||||
req, err := http.NewRequest("DELETE", deleteURI, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("error: %v", err)
|
||||
}
|
||||
req.Header.Set("X-Auth-Token", token)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("error: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
|
||||
func createGroup(t *testing.T, token, description, name string) string {
|
||||
t.Helper()
|
||||
client := &http.Client{}
|
||||
|
||||
createGroupData := map[string]interface{}{
|
||||
"group": map[string]interface{}{
|
||||
"name": name,
|
||||
"description": description,
|
||||
},
|
||||
}
|
||||
|
||||
body, err := json.Marshal(createGroupData)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", groupsURL, bytes.NewBuffer(body))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("X-Auth-Token", token)
|
||||
req.Header.Add("Content-Type", "application/json")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
groupResp := new(groupResponse)
|
||||
err = json.Unmarshal(data, &groupResp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
return groupResp.Group.ID
|
||||
}
|
||||
|
||||
func addUserToGroup(t *testing.T, token, groupID, userID string) error {
|
||||
t.Helper()
|
||||
uri := groupsURL + groupID + "/users/" + userID
|
||||
client := &http.Client{}
|
||||
req, err := http.NewRequest("PUT", uri, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("X-Auth-Token", token)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("error: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestIncorrectCredentialsLogin(t *testing.T) {
|
||||
setupVariables(t)
|
||||
c := conn{
|
||||
Host: keystoneURL, Domain: testDomain,
|
||||
AdminUsername: adminUser, AdminPassword: adminPass,
|
||||
}
|
||||
s := connector.Scopes{OfflineAccess: true, Groups: true}
|
||||
_, validPW, err := c.Login(context.Background(), s, adminUser, invalidPass)
|
||||
|
||||
if validPW {
|
||||
t.Fatal("Incorrect password check")
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("Error should be returned when invalid password is provided")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "401") {
|
||||
t.Fatal("Unrecognized error, expecting 401")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidUserLogin(t *testing.T) {
|
||||
setupVariables(t)
|
||||
token, _ := getAdminToken(t, adminUser, adminPass)
|
||||
|
||||
type tUser struct {
|
||||
username string
|
||||
domain string
|
||||
email string
|
||||
password string
|
||||
}
|
||||
|
||||
type expect struct {
|
||||
username string
|
||||
email string
|
||||
verifiedEmail bool
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input tUser
|
||||
expected expect
|
||||
}{
|
||||
{
|
||||
name: "test with email address",
|
||||
input: tUser{
|
||||
username: testUser,
|
||||
domain: testDomain,
|
||||
email: testEmail,
|
||||
password: testPass,
|
||||
},
|
||||
expected: expect{
|
||||
username: testUser,
|
||||
email: testEmail,
|
||||
verifiedEmail: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "test without email address",
|
||||
input: tUser{
|
||||
username: testUser,
|
||||
domain: testDomain,
|
||||
email: "",
|
||||
password: testPass,
|
||||
},
|
||||
expected: expect{
|
||||
username: testUser,
|
||||
email: "",
|
||||
verifiedEmail: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
userID := createUser(t, token, tt.input.username, tt.input.email, tt.input.password)
|
||||
defer deleteResource(t, token, userID, usersURL)
|
||||
|
||||
c := conn{
|
||||
Host: keystoneURL, Domain: tt.input.domain,
|
||||
AdminUsername: adminUser, AdminPassword: adminPass,
|
||||
}
|
||||
s := connector.Scopes{OfflineAccess: true, Groups: true}
|
||||
identity, validPW, err := c.Login(context.Background(), s, tt.input.username, tt.input.password)
|
||||
if err != nil {
|
||||
t.Fatal(err.Error())
|
||||
}
|
||||
t.Log(identity)
|
||||
if identity.Username != tt.expected.username {
|
||||
t.Fatalf("Invalid user. Got: %v. Wanted: %v", identity.Username, tt.expected.username)
|
||||
}
|
||||
if identity.UserID == "" {
|
||||
t.Fatalf("Didn't get any UserID back")
|
||||
}
|
||||
if identity.Email != tt.expected.email {
|
||||
t.Fatalf("Invalid email. Got: %v. Wanted: %v", identity.Email, tt.expected.email)
|
||||
}
|
||||
if identity.EmailVerified != tt.expected.verifiedEmail {
|
||||
t.Fatalf("Invalid verifiedEmail. Got: %v. Wanted: %v", identity.EmailVerified, tt.expected.verifiedEmail)
|
||||
}
|
||||
|
||||
if !validPW {
|
||||
t.Fatal("Valid password was not accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUseRefreshToken(t *testing.T) {
|
||||
setupVariables(t)
|
||||
token, adminID := getAdminToken(t, adminUser, adminPass)
|
||||
groupID := createGroup(t, token, "Test group description", testGroup)
|
||||
addUserToGroup(t, token, groupID, adminID)
|
||||
defer deleteResource(t, token, groupID, groupsURL)
|
||||
|
||||
c := conn{
|
||||
Host: keystoneURL, Domain: testDomain,
|
||||
AdminUsername: adminUser, AdminPassword: adminPass,
|
||||
}
|
||||
s := connector.Scopes{OfflineAccess: true, Groups: true}
|
||||
|
||||
identityLogin, _, err := c.Login(context.Background(), s, adminUser, adminPass)
|
||||
if err != nil {
|
||||
t.Fatal(err.Error())
|
||||
}
|
||||
|
||||
identityRefresh, err := c.Refresh(context.Background(), s, identityLogin)
|
||||
if err != nil {
|
||||
t.Fatal(err.Error())
|
||||
}
|
||||
|
||||
expectEquals(t, 1, len(identityRefresh.Groups))
|
||||
expectEquals(t, testGroup, identityRefresh.Groups[0])
|
||||
}
|
||||
|
||||
func TestUseRefreshTokenUserDeleted(t *testing.T) {
|
||||
setupVariables(t)
|
||||
token, _ := getAdminToken(t, adminUser, adminPass)
|
||||
userID := createUser(t, token, testUser, testEmail, testPass)
|
||||
|
||||
c := conn{
|
||||
Host: keystoneURL, Domain: testDomain,
|
||||
AdminUsername: adminUser, AdminPassword: adminPass,
|
||||
}
|
||||
s := connector.Scopes{OfflineAccess: true, Groups: true}
|
||||
|
||||
identityLogin, _, err := c.Login(context.Background(), s, testUser, testPass)
|
||||
if err != nil {
|
||||
t.Fatal(err.Error())
|
||||
}
|
||||
|
||||
_, err = c.Refresh(context.Background(), s, identityLogin)
|
||||
if err != nil {
|
||||
t.Fatal(err.Error())
|
||||
}
|
||||
|
||||
deleteResource(t, token, userID, usersURL)
|
||||
_, err = c.Refresh(context.Background(), s, identityLogin)
|
||||
|
||||
if !strings.Contains(err.Error(), "does not exist") {
|
||||
t.Errorf("unexpected error: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUseRefreshTokenGroupsChanged(t *testing.T) {
|
||||
setupVariables(t)
|
||||
token, _ := getAdminToken(t, adminUser, adminPass)
|
||||
userID := createUser(t, token, testUser, testEmail, testPass)
|
||||
defer deleteResource(t, token, userID, usersURL)
|
||||
|
||||
c := conn{
|
||||
Host: keystoneURL, Domain: testDomain,
|
||||
AdminUsername: adminUser, AdminPassword: adminPass,
|
||||
}
|
||||
s := connector.Scopes{OfflineAccess: true, Groups: true}
|
||||
|
||||
identityLogin, _, err := c.Login(context.Background(), s, testUser, testPass)
|
||||
if err != nil {
|
||||
t.Fatal(err.Error())
|
||||
}
|
||||
|
||||
identityRefresh, err := c.Refresh(context.Background(), s, identityLogin)
|
||||
if err != nil {
|
||||
t.Fatal(err.Error())
|
||||
}
|
||||
|
||||
expectEquals(t, 0, len(identityRefresh.Groups))
|
||||
|
||||
groupID := createGroup(t, token, "Test group", testGroup)
|
||||
addUserToGroup(t, token, groupID, userID)
|
||||
defer deleteResource(t, token, groupID, groupsURL)
|
||||
|
||||
identityRefresh, err = c.Refresh(context.Background(), s, identityLogin)
|
||||
if err != nil {
|
||||
t.Fatal(err.Error())
|
||||
}
|
||||
|
||||
expectEquals(t, 1, len(identityRefresh.Groups))
|
||||
}
|
||||
|
||||
func TestNoGroupsInScope(t *testing.T) {
|
||||
setupVariables(t)
|
||||
token, _ := getAdminToken(t, adminUser, adminPass)
|
||||
userID := createUser(t, token, testUser, testEmail, testPass)
|
||||
defer deleteResource(t, token, userID, usersURL)
|
||||
|
||||
c := conn{
|
||||
Host: keystoneURL, Domain: testDomain,
|
||||
AdminUsername: adminUser, AdminPassword: adminPass,
|
||||
}
|
||||
s := connector.Scopes{OfflineAccess: true, Groups: false}
|
||||
|
||||
groupID := createGroup(t, token, "Test group", testGroup)
|
||||
addUserToGroup(t, token, groupID, userID)
|
||||
defer deleteResource(t, token, groupID, groupsURL)
|
||||
|
||||
identityLogin, _, err := c.Login(context.Background(), s, testUser, testPass)
|
||||
if err != nil {
|
||||
t.Fatal(err.Error())
|
||||
}
|
||||
expectEquals(t, 0, len(identityLogin.Groups))
|
||||
|
||||
identityRefresh, err := c.Refresh(context.Background(), s, identityLogin)
|
||||
if err != nil {
|
||||
t.Fatal(err.Error())
|
||||
}
|
||||
expectEquals(t, 0, len(identityRefresh.Groups))
|
||||
}
|
||||
|
||||
func setupVariables(t *testing.T) {
|
||||
keystoneURLEnv := "DEX_KEYSTONE_URL"
|
||||
keystoneAdminURLEnv := "DEX_KEYSTONE_ADMIN_URL"
|
||||
keystoneAdminUserEnv := "DEX_KEYSTONE_ADMIN_USER"
|
||||
keystoneAdminPassEnv := "DEX_KEYSTONE_ADMIN_PASS"
|
||||
keystoneURL = os.Getenv(keystoneURLEnv)
|
||||
if keystoneURL == "" {
|
||||
t.Skipf("variable %q not set, skipping keystone connector tests\n", keystoneURLEnv)
|
||||
return
|
||||
}
|
||||
keystoneAdminURL = os.Getenv(keystoneAdminURLEnv)
|
||||
if keystoneAdminURL == "" {
|
||||
t.Skipf("variable %q not set, skipping keystone connector tests\n", keystoneAdminURLEnv)
|
||||
return
|
||||
}
|
||||
adminUser = os.Getenv(keystoneAdminUserEnv)
|
||||
if adminUser == "" {
|
||||
t.Skipf("variable %q not set, skipping keystone connector tests\n", keystoneAdminUserEnv)
|
||||
return
|
||||
}
|
||||
adminPass = os.Getenv(keystoneAdminPassEnv)
|
||||
if adminPass == "" {
|
||||
t.Skipf("variable %q not set, skipping keystone connector tests\n", keystoneAdminPassEnv)
|
||||
return
|
||||
}
|
||||
authTokenURL = keystoneURL + "/v3/auth/tokens/"
|
||||
usersURL = keystoneAdminURL + "/v3/users/"
|
||||
groupsURL = keystoneAdminURL + "/v3/groups/"
|
||||
}
|
||||
|
||||
func expectEquals(t *testing.T, a interface{}, b interface{}) {
|
||||
if !reflect.DeepEqual(a, b) {
|
||||
t.Errorf("Expected %v to be equal %v", a, b)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,49 @@
|
|||
#!/bin/bash -e
|
||||
|
||||
# Stolen from the coreos/matchbox repo.
|
||||
|
||||
echo "
|
||||
[req]
|
||||
req_extensions = v3_req
|
||||
distinguished_name = req_distinguished_name
|
||||
|
||||
[req_distinguished_name]
|
||||
|
||||
[ v3_req ]
|
||||
basicConstraints = CA:FALSE
|
||||
keyUsage = nonRepudiation, digitalSignature, keyEncipherment
|
||||
subjectAltName = @alt_names
|
||||
|
||||
[alt_names]
|
||||
DNS.101 = localhost
|
||||
" > openssl.config
|
||||
|
||||
openssl genrsa -out testdata/ca.key 2048
|
||||
openssl genrsa -out testdata/server.key 2048
|
||||
|
||||
openssl req \
|
||||
-x509 -new -nodes \
|
||||
-key testdata/ca.key \
|
||||
-days 10000 -out testdata/ca.crt \
|
||||
-subj "/CN=ldap-tests"
|
||||
|
||||
openssl req \
|
||||
-new \
|
||||
-key testdata/server.key \
|
||||
-out testdata/server.csr \
|
||||
-subj "/CN=localhost" \
|
||||
-config openssl.config
|
||||
|
||||
openssl x509 -req \
|
||||
-in testdata/server.csr \
|
||||
-CA testdata/ca.crt \
|
||||
-CAkey testdata/ca.key \
|
||||
-CAcreateserial \
|
||||
-out testdata/server.crt \
|
||||
-days 10000 \
|
||||
-extensions v3_req \
|
||||
-extfile openssl.config
|
||||
|
||||
rm testdata/server.csr
|
||||
rm testdata/ca.srl
|
||||
rm openssl.config
|
|
@ -7,13 +7,13 @@ import (
|
|||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net"
|
||||
"os"
|
||||
|
||||
"gopkg.in/ldap.v2"
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
|
||||
"github.com/Sirupsen/logrus"
|
||||
"github.com/coreos/dex/connector"
|
||||
"github.com/dexidp/dex/connector"
|
||||
"github.com/dexidp/dex/pkg/log"
|
||||
)
|
||||
|
||||
// Config holds the configuration parameters for the LDAP connector. The LDAP
|
||||
|
@ -29,7 +29,7 @@ import (
|
|||
// # The following field is required if using port 389.
|
||||
// # insecureNoSSL: true
|
||||
// rootCA: /etc/dex/ldap.ca
|
||||
// bindDN: uid=seviceaccount,cn=users,dc=example,dc=com
|
||||
// bindDN: uid=serviceaccount,cn=users,dc=example,dc=com
|
||||
// bindPW: password
|
||||
// userSearch:
|
||||
// # Would translate to the query "(&(objectClass=person)(uid=<username>))"
|
||||
|
@ -39,17 +39,30 @@ import (
|
|||
// idAttr: uid
|
||||
// emailAttr: mail
|
||||
// nameAttr: name
|
||||
// preferredUsernameAttr: uid
|
||||
// groupSearch:
|
||||
// # Would translate to the query "(&(objectClass=group)(member=<user uid>))"
|
||||
// # Would translate to the separate query per user matcher pair and aggregate results into a single group list:
|
||||
// # "(&(|(objectClass=posixGroup)(objectClass=groupOfNames))(memberUid=<user uid>))"
|
||||
// # "(&(|(objectClass=posixGroup)(objectClass=groupOfNames))(member=<user DN>))"
|
||||
// baseDN: cn=groups,dc=example,dc=com
|
||||
// filter: "(objectClass=group)"
|
||||
// userAttr: uid
|
||||
// # Use if full DN is needed and not available as any other attribute
|
||||
// # Will only work if "DN" attribute does not exist in the record
|
||||
// # userAttr: DN
|
||||
// groupAttr: member
|
||||
// filter: "(|(objectClass=posixGroup)(objectClass=groupOfNames))"
|
||||
// userMatchers:
|
||||
// - userAttr: uid
|
||||
// groupAttr: memberUid
|
||||
// # Use if full DN is needed and not available as any other attribute
|
||||
// # Will only work if "DN" attribute does not exist in the record:
|
||||
// - userAttr: DN
|
||||
// groupAttr: member
|
||||
// nameAttr: name
|
||||
//
|
||||
|
||||
// UserMatcher holds information about user and group matching.
|
||||
type UserMatcher struct {
|
||||
UserAttr string `json:"userAttr"`
|
||||
GroupAttr string `json:"groupAttr"`
|
||||
}
|
||||
|
||||
// Config holds configuration options for LDAP logins.
|
||||
type Config struct {
|
||||
// The host and optional port of the LDAP server. If port isn't supplied, it will be
|
||||
// guessed based on the TLS configuration. 389 or 636.
|
||||
|
@ -61,9 +74,17 @@ type Config struct {
|
|||
// Don't verify the CA.
|
||||
InsecureSkipVerify bool `json:"insecureSkipVerify"`
|
||||
|
||||
// Connect to the insecure port then issue a StartTLS command to negotiate a
|
||||
// secure connection. If unsupplied secure connections will use the LDAPS
|
||||
// protocol.
|
||||
StartTLS bool `json:"startTLS"`
|
||||
|
||||
// Path to a trusted root certificate file.
|
||||
RootCA string `json:"rootCA"`
|
||||
|
||||
// Path to a client cert file generated by rootCA.
|
||||
ClientCert string `json:"clientCert"`
|
||||
// Path to a client private key file generated by rootCA.
|
||||
ClientKey string `json:"clientKey"`
|
||||
// Base64 encoded PEM data containing root CAs.
|
||||
RootCAData []byte `json:"rootCAData"`
|
||||
|
||||
|
@ -72,9 +93,14 @@ type Config struct {
|
|||
BindDN string `json:"bindDN"`
|
||||
BindPW string `json:"bindPW"`
|
||||
|
||||
// UsernamePrompt allows users to override the username attribute (displayed
|
||||
// in the username/password prompt). If unset, the handler will use
|
||||
// "Username".
|
||||
UsernamePrompt string `json:"usernamePrompt"`
|
||||
|
||||
// User entry search configuration.
|
||||
UserSearch struct {
|
||||
// BsaeDN to start the search from. For example "cn=users,dc=example,dc=com"
|
||||
// BaseDN to start the search from. For example "cn=users,dc=example,dc=com"
|
||||
BaseDN string `json:"baseDN"`
|
||||
|
||||
// Optional filter to apply when searching the directory. For example "(objectClass=person)"
|
||||
|
@ -90,15 +116,19 @@ type Config struct {
|
|||
Scope string `json:"scope"`
|
||||
|
||||
// A mapping of attributes on the user entry to claims.
|
||||
IDAttr string `json:"idAttr"` // Defaults to "uid"
|
||||
EmailAttr string `json:"emailAttr"` // Defaults to "mail"
|
||||
NameAttr string `json:"nameAttr"` // No default.
|
||||
IDAttr string `json:"idAttr"` // Defaults to "uid"
|
||||
EmailAttr string `json:"emailAttr"` // Defaults to "mail"
|
||||
NameAttr string `json:"nameAttr"` // No default.
|
||||
PreferredUsernameAttrAttr string `json:"preferredUsernameAttr"` // No default.
|
||||
|
||||
// If this is set, the email claim of the id token will be constructed from the idAttr and
|
||||
// value of emailSuffix. This should not include the @ character.
|
||||
EmailSuffix string `json:"emailSuffix"` // No default.
|
||||
} `json:"userSearch"`
|
||||
|
||||
// Group search configuration.
|
||||
GroupSearch struct {
|
||||
// BsaeDN to start the search from. For example "cn=groups,dc=example,dc=com"
|
||||
// BaseDN to start the search from. For example "cn=groups,dc=example,dc=com"
|
||||
BaseDN string `json:"baseDN"`
|
||||
|
||||
// Optional filter to apply when searching the directory. For example "(objectClass=posixGroup)"
|
||||
|
@ -106,22 +136,41 @@ type Config struct {
|
|||
|
||||
Scope string `json:"scope"` // Defaults to "sub"
|
||||
|
||||
// These two fields are use to match a user to a group.
|
||||
// DEPRECATED config options. Those are left for backward compatibility.
|
||||
// See "UserMatchers" below for the current group to user matching implementation
|
||||
// TODO: should be eventually removed from the code
|
||||
UserAttr string `json:"userAttr"`
|
||||
GroupAttr string `json:"groupAttr"`
|
||||
|
||||
// Array of the field pairs used to match a user to a group.
|
||||
// See the "UserMatcher" struct for the exact field names
|
||||
//
|
||||
// It adds an additional requirement to the filter that an attribute in the group
|
||||
// Each pair adds an additional requirement to the filter that an attribute in the group
|
||||
// match the user's attribute value. For example that the "members" attribute of
|
||||
// a group matches the "uid" of the user. The exact filter being added is:
|
||||
//
|
||||
// (<groupAttr>=<userAttr value>)
|
||||
// (userMatchers[n].<groupAttr>=userMatchers[n].<userAttr value>)
|
||||
//
|
||||
UserAttr string `json:"userAttr"`
|
||||
GroupAttr string `json:"groupAttr"`
|
||||
UserMatchers []UserMatcher `json:"userMatchers"`
|
||||
|
||||
// The attribute of the group that represents its name.
|
||||
NameAttr string `json:"nameAttr"`
|
||||
} `json:"groupSearch"`
|
||||
}
|
||||
|
||||
func scopeString(i int) string {
|
||||
switch i {
|
||||
case ldap.ScopeBaseObject:
|
||||
return "base"
|
||||
case ldap.ScopeSingleLevel:
|
||||
return "one"
|
||||
case ldap.ScopeWholeSubtree:
|
||||
return "sub"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func parseScope(s string) (int, bool) {
|
||||
// NOTE(ericchiang): ScopeBaseObject doesn't really make sense for us because we
|
||||
// never know the user's or group's DN.
|
||||
|
@ -134,8 +183,26 @@ func parseScope(s string) (int, bool) {
|
|||
return 0, false
|
||||
}
|
||||
|
||||
// Build a list of group attr name to user attr value matchers.
|
||||
// Function exists here to allow backward compatibility between old and new
|
||||
// group to user matching implementations.
|
||||
// See "Config.GroupSearch.UserMatchers" comments for the details
|
||||
func userMatchers(c *Config, logger log.Logger) []UserMatcher {
|
||||
if len(c.GroupSearch.UserMatchers) > 0 && c.GroupSearch.UserMatchers[0].UserAttr != "" {
|
||||
return c.GroupSearch.UserMatchers
|
||||
}
|
||||
|
||||
log.Deprecated(logger, `LDAP: use groupSearch.userMatchers option instead of "userAttr/groupAttr" fields.`)
|
||||
return []UserMatcher{
|
||||
{
|
||||
UserAttr: c.GroupSearch.UserAttr,
|
||||
GroupAttr: c.GroupSearch.GroupAttr,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Open returns an authentication strategy using LDAP.
|
||||
func (c *Config) Open(logger logrus.FieldLogger) (connector.Connector, error) {
|
||||
func (c *Config) Open(id string, logger log.Logger) (connector.Connector, error) {
|
||||
conn, err := c.OpenConnector(logger)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
@ -149,12 +216,16 @@ type refreshData struct {
|
|||
}
|
||||
|
||||
// OpenConnector is the same as Open but returns a type with all implemented connector interfaces.
|
||||
func (c *Config) OpenConnector(logger logrus.FieldLogger) (interface {
|
||||
func (c *Config) OpenConnector(logger log.Logger) (interface {
|
||||
connector.Connector
|
||||
connector.PasswordConnector
|
||||
connector.RefreshConnector
|
||||
}, error) {
|
||||
}, error,
|
||||
) {
|
||||
return c.openConnector(logger)
|
||||
}
|
||||
|
||||
func (c *Config) openConnector(logger log.Logger) (*ldapConnector, error) {
|
||||
requiredFields := []struct {
|
||||
name string
|
||||
val string
|
||||
|
@ -177,9 +248,9 @@ func (c *Config) OpenConnector(logger logrus.FieldLogger) (interface {
|
|||
if host, _, err = net.SplitHostPort(c.Host); err != nil {
|
||||
host = c.Host
|
||||
if c.InsecureNoSSL {
|
||||
c.Host = c.Host + ":389"
|
||||
c.Host += ":389"
|
||||
} else {
|
||||
c.Host = c.Host + ":636"
|
||||
c.Host += ":636"
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -188,7 +259,7 @@ func (c *Config) OpenConnector(logger logrus.FieldLogger) (interface {
|
|||
data := c.RootCAData
|
||||
if len(data) == 0 {
|
||||
var err error
|
||||
if data, err = ioutil.ReadFile(c.RootCA); err != nil {
|
||||
if data, err = os.ReadFile(c.RootCA); err != nil {
|
||||
return nil, fmt.Errorf("ldap: read ca file: %v", err)
|
||||
}
|
||||
}
|
||||
|
@ -198,14 +269,25 @@ func (c *Config) OpenConnector(logger logrus.FieldLogger) (interface {
|
|||
}
|
||||
tlsConfig.RootCAs = rootCAs
|
||||
}
|
||||
|
||||
if c.ClientKey != "" && c.ClientCert != "" {
|
||||
cert, err := tls.LoadX509KeyPair(c.ClientCert, c.ClientKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ldap: load client cert failed: %v", err)
|
||||
}
|
||||
tlsConfig.Certificates = append(tlsConfig.Certificates, cert)
|
||||
}
|
||||
userSearchScope, ok := parseScope(c.UserSearch.Scope)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("userSearch.Scope unknown value %q", c.UserSearch.Scope)
|
||||
}
|
||||
groupSearchScope, ok := parseScope(c.GroupSearch.Scope)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("userSearch.Scope unknown value %q", c.GroupSearch.Scope)
|
||||
return nil, fmt.Errorf("groupSearch.Scope unknown value %q", c.GroupSearch.Scope)
|
||||
}
|
||||
|
||||
// TODO(nabokihms): remove it after deleting deprecated groupSearch options
|
||||
c.GroupSearch.UserMatchers = userMatchers(c, logger)
|
||||
return &ldapConnector{*c, userSearchScope, groupSearchScope, tlsConfig, logger}, nil
|
||||
}
|
||||
|
||||
|
@ -217,7 +299,7 @@ type ldapConnector struct {
|
|||
|
||||
tlsConfig *tls.Config
|
||||
|
||||
logger logrus.FieldLogger
|
||||
logger log.Logger
|
||||
}
|
||||
|
||||
var (
|
||||
|
@ -228,15 +310,24 @@ var (
|
|||
// do initializes a connection to the LDAP directory and passes it to the
|
||||
// provided function. It then performs appropriate teardown or reuse before
|
||||
// returning.
|
||||
func (c *ldapConnector) do(ctx context.Context, f func(c *ldap.Conn) error) error {
|
||||
func (c *ldapConnector) do(_ context.Context, f func(c *ldap.Conn) error) error {
|
||||
// TODO(ericchiang): support context here
|
||||
var (
|
||||
conn *ldap.Conn
|
||||
err error
|
||||
)
|
||||
if c.InsecureNoSSL {
|
||||
switch {
|
||||
case c.InsecureNoSSL:
|
||||
conn, err = ldap.Dial("tcp", c.Host)
|
||||
} else {
|
||||
case c.StartTLS:
|
||||
conn, err = ldap.Dial("tcp", c.Host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect: %v", err)
|
||||
}
|
||||
if err := conn.StartTLS(c.tlsConfig); err != nil {
|
||||
return fmt.Errorf("start TLS failed: %v", err)
|
||||
}
|
||||
default:
|
||||
conn, err = ldap.DialTLS("tcp", c.Host, c.tlsConfig)
|
||||
}
|
||||
if err != nil {
|
||||
|
@ -245,25 +336,33 @@ func (c *ldapConnector) do(ctx context.Context, f func(c *ldap.Conn) error) erro
|
|||
defer conn.Close()
|
||||
|
||||
// If bindDN and bindPW are empty this will default to an anonymous bind.
|
||||
if err := conn.Bind(c.BindDN, c.BindPW); err != nil {
|
||||
if c.BindDN == "" && c.BindPW == "" {
|
||||
if err := conn.UnauthenticatedBind(""); err != nil {
|
||||
return fmt.Errorf("ldap: initial anonymous bind failed: %v", err)
|
||||
}
|
||||
} else if err := conn.Bind(c.BindDN, c.BindPW); err != nil {
|
||||
return fmt.Errorf("ldap: initial bind for user %q failed: %v", c.BindDN, err)
|
||||
}
|
||||
|
||||
return f(conn)
|
||||
}
|
||||
|
||||
func getAttr(e ldap.Entry, name string) string {
|
||||
func getAttrs(e ldap.Entry, name string) []string {
|
||||
for _, a := range e.Attributes {
|
||||
if a.Name != name {
|
||||
continue
|
||||
}
|
||||
if len(a.Values) == 0 {
|
||||
return ""
|
||||
}
|
||||
return a.Values[0]
|
||||
return a.Values
|
||||
}
|
||||
if name == "DN" {
|
||||
return e.DN
|
||||
return []string{e.DN}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getAttr(e ldap.Entry, name string) string {
|
||||
if a := getAttrs(e, name); len(a) > 0 {
|
||||
return a[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
@ -277,11 +376,6 @@ func (c *ldapConnector) identityFromEntry(user ldap.Entry) (ident connector.Iden
|
|||
if ident.UserID = getAttr(user, c.UserSearch.IDAttr); ident.UserID == "" {
|
||||
missing = append(missing, c.UserSearch.IDAttr)
|
||||
}
|
||||
if ident.Email = getAttr(user, c.UserSearch.EmailAttr); ident.Email == "" {
|
||||
missing = append(missing, c.UserSearch.EmailAttr)
|
||||
}
|
||||
// TODO(ericchiang): Let this value be set from an attribute.
|
||||
ident.EmailVerified = true
|
||||
|
||||
if c.UserSearch.NameAttr != "" {
|
||||
if ident.Username = getAttr(user, c.UserSearch.NameAttr); ident.Username == "" {
|
||||
|
@ -289,6 +383,20 @@ func (c *ldapConnector) identityFromEntry(user ldap.Entry) (ident connector.Iden
|
|||
}
|
||||
}
|
||||
|
||||
if c.UserSearch.PreferredUsernameAttrAttr != "" {
|
||||
if ident.PreferredUsername = getAttr(user, c.UserSearch.PreferredUsernameAttrAttr); ident.PreferredUsername == "" {
|
||||
missing = append(missing, c.UserSearch.PreferredUsernameAttrAttr)
|
||||
}
|
||||
}
|
||||
|
||||
if c.UserSearch.EmailSuffix != "" {
|
||||
ident.Email = ident.Username + "@" + c.UserSearch.EmailSuffix
|
||||
} else if ident.Email = getAttr(user, c.UserSearch.EmailAttr); ident.Email == "" {
|
||||
missing = append(missing, c.UserSearch.EmailAttr)
|
||||
}
|
||||
// TODO(ericchiang): Let this value be set from an attribute.
|
||||
ident.EmailVerified = true
|
||||
|
||||
if len(missing) != 0 {
|
||||
err := fmt.Errorf("ldap: entry %q missing following required attribute(s): %q", user.DN, missing)
|
||||
return connector.Identity{}, err
|
||||
|
@ -297,7 +405,6 @@ func (c *ldapConnector) identityFromEntry(user ldap.Entry) (ident connector.Iden
|
|||
}
|
||||
|
||||
func (c *ldapConnector) userEntry(conn *ldap.Conn, username string) (user ldap.Entry, found bool, err error) {
|
||||
|
||||
filter := fmt.Sprintf("(%s=%s)", c.UserSearch.Username, ldap.EscapeFilter(username))
|
||||
if c.UserSearch.Filter != "" {
|
||||
filter = fmt.Sprintf("(&%s%s)", c.UserSearch.Filter, filter)
|
||||
|
@ -312,14 +419,24 @@ func (c *ldapConnector) userEntry(conn *ldap.Conn, username string) (user ldap.E
|
|||
Attributes: []string{
|
||||
c.UserSearch.IDAttr,
|
||||
c.UserSearch.EmailAttr,
|
||||
c.GroupSearch.UserAttr,
|
||||
// TODO(ericchiang): what if this contains duplicate values?
|
||||
},
|
||||
}
|
||||
|
||||
for _, matcher := range c.GroupSearch.UserMatchers {
|
||||
req.Attributes = append(req.Attributes, matcher.UserAttr)
|
||||
}
|
||||
|
||||
if c.UserSearch.NameAttr != "" {
|
||||
req.Attributes = append(req.Attributes, c.UserSearch.NameAttr)
|
||||
}
|
||||
|
||||
if c.UserSearch.PreferredUsernameAttrAttr != "" {
|
||||
req.Attributes = append(req.Attributes, c.UserSearch.PreferredUsernameAttrAttr)
|
||||
}
|
||||
|
||||
c.logger.Infof("performing ldap search %s %s %s",
|
||||
req.BaseDN, scopeString(req.Scope), req.Filter)
|
||||
resp, err := conn.Search(req)
|
||||
if err != nil {
|
||||
return ldap.Entry{}, false, fmt.Errorf("ldap: search with filter %q failed: %v", req.Filter, err)
|
||||
|
@ -330,13 +447,20 @@ func (c *ldapConnector) userEntry(conn *ldap.Conn, username string) (user ldap.E
|
|||
c.logger.Errorf("ldap: no results returned for filter: %q", filter)
|
||||
return ldap.Entry{}, false, nil
|
||||
case 1:
|
||||
return *resp.Entries[0], true, nil
|
||||
user = *resp.Entries[0]
|
||||
c.logger.Infof("username %q mapped to entry %s", username, user.DN)
|
||||
return user, true, nil
|
||||
default:
|
||||
return ldap.Entry{}, false, fmt.Errorf("ldap: filter returned multiple (%d) results: %q", n, filter)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *ldapConnector) Login(ctx context.Context, s connector.Scopes, username, password string) (ident connector.Identity, validPass bool, err error) {
|
||||
// make this check to avoid unauthenticated bind to the LDAP server.
|
||||
if password == "" {
|
||||
return connector.Identity{}, false, nil
|
||||
}
|
||||
|
||||
var (
|
||||
// We want to return a different error if the user's password is incorrect vs
|
||||
// if there was an error.
|
||||
|
@ -359,12 +483,17 @@ func (c *ldapConnector) Login(ctx context.Context, s connector.Scopes, username,
|
|||
if err := conn.Bind(user.DN, password); err != nil {
|
||||
// Detect a bad password through the LDAP error code.
|
||||
if ldapErr, ok := err.(*ldap.Error); ok {
|
||||
if ldapErr.ResultCode == ldap.LDAPResultInvalidCredentials {
|
||||
switch ldapErr.ResultCode {
|
||||
case ldap.LDAPResultInvalidCredentials:
|
||||
c.logger.Errorf("ldap: invalid password for user %q", user.DN)
|
||||
incorrectPass = true
|
||||
return nil
|
||||
case ldap.LDAPResultConstraintViolation:
|
||||
c.logger.Errorf("ldap: constraint violation for user %q: %s", user.DN, ldapErr.Error())
|
||||
incorrectPass = true
|
||||
return nil
|
||||
}
|
||||
}
|
||||
} // will also catch all ldap.Error without a case statement above
|
||||
return fmt.Errorf("ldap: failed to bind as dn %q: %v", user.DN, err)
|
||||
}
|
||||
return nil
|
||||
|
@ -406,7 +535,7 @@ func (c *ldapConnector) Login(ctx context.Context, s connector.Scopes, username,
|
|||
func (c *ldapConnector) Refresh(ctx context.Context, s connector.Scopes, ident connector.Identity) (connector.Identity, error) {
|
||||
var data refreshData
|
||||
if err := json.Unmarshal(ident.ConnectorData, &data); err != nil {
|
||||
return ident, fmt.Errorf("ldap: failed to unamrshal internal data: %v", err)
|
||||
return ident, fmt.Errorf("ldap: failed to unmarshal internal data: %v", err)
|
||||
}
|
||||
|
||||
var user ldap.Entry
|
||||
|
@ -450,36 +579,43 @@ func (c *ldapConnector) groups(ctx context.Context, user ldap.Entry) ([]string,
|
|||
return nil, nil
|
||||
}
|
||||
|
||||
filter := fmt.Sprintf("(%s=%s)", c.GroupSearch.GroupAttr, ldap.EscapeFilter(getAttr(user, c.GroupSearch.UserAttr)))
|
||||
if c.GroupSearch.Filter != "" {
|
||||
filter = fmt.Sprintf("(&%s%s)", c.GroupSearch.Filter, filter)
|
||||
}
|
||||
|
||||
req := &ldap.SearchRequest{
|
||||
BaseDN: c.GroupSearch.BaseDN,
|
||||
Filter: filter,
|
||||
Scope: c.groupSearchScope,
|
||||
Attributes: []string{c.GroupSearch.NameAttr},
|
||||
}
|
||||
|
||||
var groups []*ldap.Entry
|
||||
if err := c.do(ctx, func(conn *ldap.Conn) error {
|
||||
resp, err := conn.Search(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ldap: search failed: %v", err)
|
||||
for _, matcher := range c.GroupSearch.UserMatchers {
|
||||
for _, attr := range getAttrs(user, matcher.UserAttr) {
|
||||
filter := fmt.Sprintf("(%s=%s)", matcher.GroupAttr, ldap.EscapeFilter(attr))
|
||||
if c.GroupSearch.Filter != "" {
|
||||
filter = fmt.Sprintf("(&%s%s)", c.GroupSearch.Filter, filter)
|
||||
}
|
||||
|
||||
req := &ldap.SearchRequest{
|
||||
BaseDN: c.GroupSearch.BaseDN,
|
||||
Filter: filter,
|
||||
Scope: c.groupSearchScope,
|
||||
Attributes: []string{c.GroupSearch.NameAttr},
|
||||
}
|
||||
|
||||
gotGroups := false
|
||||
if err := c.do(ctx, func(conn *ldap.Conn) error {
|
||||
c.logger.Infof("performing ldap search %s %s %s",
|
||||
req.BaseDN, scopeString(req.Scope), req.Filter)
|
||||
resp, err := conn.Search(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ldap: search failed: %v", err)
|
||||
}
|
||||
gotGroups = len(resp.Entries) != 0
|
||||
groups = append(groups, resp.Entries...)
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !gotGroups {
|
||||
// TODO(ericchiang): Is this going to spam the logs?
|
||||
c.logger.Errorf("ldap: groups search with filter %q returned no groups", filter)
|
||||
}
|
||||
}
|
||||
groups = resp.Entries
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(groups) == 0 {
|
||||
// TODO(ericchiang): Is this going to spam the logs?
|
||||
c.logger.Errorf("ldap: groups search with filter %q returned no groups", filter)
|
||||
}
|
||||
|
||||
var groupNames []string
|
||||
|
||||
groupNames := make([]string, 0, len(groups))
|
||||
for _, group := range groups {
|
||||
name := getAttr(*group, c.GroupSearch.NameAttr)
|
||||
if name == "" {
|
||||
|
@ -495,3 +631,7 @@ func (c *ldapConnector) groups(ctx context.Context, user ldap.Entry) ([]string,
|
|||
}
|
||||
return groupNames, nil
|
||||
}
|
||||
|
||||
func (c *ldapConnector) Prompt() string {
|
||||
return c.UsernamePrompt
|
||||
}
|
||||
|
|
|
@ -0,0 +1,616 @@
|
|||
package ldap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/kylelemons/godebug/pretty"
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/dexidp/dex/connector"
|
||||
)
|
||||
|
||||
// connectionMethod indicates how the test should connect to the LDAP server.
|
||||
type connectionMethod int32
|
||||
|
||||
const (
|
||||
connectStartTLS connectionMethod = iota
|
||||
connectLDAPS
|
||||
connectLDAP
|
||||
connectInsecureSkipVerify
|
||||
)
|
||||
|
||||
// subtest is a login test against a given schema.
|
||||
type subtest struct {
|
||||
// Name of the sub-test.
|
||||
name string
|
||||
|
||||
// Password credentials, and if the connector should request
|
||||
// groups as well.
|
||||
username string
|
||||
password string
|
||||
groups bool
|
||||
|
||||
// Expected result of the login.
|
||||
wantErr bool
|
||||
wantBadPW bool
|
||||
want connector.Identity
|
||||
}
|
||||
|
||||
func TestQuery(t *testing.T) {
|
||||
c := &Config{}
|
||||
c.UserSearch.BaseDN = "ou=People,ou=TestQuery,dc=example,dc=org"
|
||||
c.UserSearch.NameAttr = "cn"
|
||||
c.UserSearch.EmailAttr = "mail"
|
||||
c.UserSearch.IDAttr = "DN"
|
||||
c.UserSearch.Username = "cn"
|
||||
|
||||
tests := []subtest{
|
||||
{
|
||||
name: "validpassword",
|
||||
username: "jane",
|
||||
password: "foo",
|
||||
want: connector.Identity{
|
||||
UserID: "cn=jane,ou=People,ou=TestQuery,dc=example,dc=org",
|
||||
Username: "jane",
|
||||
Email: "janedoe@example.com",
|
||||
EmailVerified: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "validpassword2",
|
||||
username: "john",
|
||||
password: "bar",
|
||||
want: connector.Identity{
|
||||
UserID: "cn=john,ou=People,ou=TestQuery,dc=example,dc=org",
|
||||
Username: "john",
|
||||
Email: "johndoe@example.com",
|
||||
EmailVerified: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalidpassword",
|
||||
username: "jane",
|
||||
password: "badpassword",
|
||||
wantBadPW: true,
|
||||
},
|
||||
{
|
||||
name: "invaliduser",
|
||||
username: "idontexist",
|
||||
password: "foo",
|
||||
wantBadPW: true, // Want invalid password, not a query error.
|
||||
},
|
||||
}
|
||||
|
||||
runTests(t, connectLDAP, c, tests)
|
||||
}
|
||||
|
||||
func TestQueryWithEmailSuffix(t *testing.T) {
|
||||
c := &Config{}
|
||||
c.UserSearch.BaseDN = "ou=People,ou=TestQueryWithEmailSuffix,dc=example,dc=org"
|
||||
c.UserSearch.NameAttr = "cn"
|
||||
c.UserSearch.EmailSuffix = "test.example.com"
|
||||
c.UserSearch.IDAttr = "DN"
|
||||
c.UserSearch.Username = "cn"
|
||||
|
||||
tests := []subtest{
|
||||
{
|
||||
name: "ignoremailattr",
|
||||
username: "jane",
|
||||
password: "foo",
|
||||
want: connector.Identity{
|
||||
UserID: "cn=jane,ou=People,ou=TestQueryWithEmailSuffix,dc=example,dc=org",
|
||||
Username: "jane",
|
||||
Email: "jane@test.example.com",
|
||||
EmailVerified: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nomailattr",
|
||||
username: "john",
|
||||
password: "bar",
|
||||
want: connector.Identity{
|
||||
UserID: "cn=john,ou=People,ou=TestQueryWithEmailSuffix,dc=example,dc=org",
|
||||
Username: "john",
|
||||
Email: "john@test.example.com",
|
||||
EmailVerified: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
runTests(t, connectLDAP, c, tests)
|
||||
}
|
||||
|
||||
func TestUserFilter(t *testing.T) {
|
||||
c := &Config{}
|
||||
c.UserSearch.BaseDN = "ou=TestUserFilter,dc=example,dc=org"
|
||||
c.UserSearch.NameAttr = "cn"
|
||||
c.UserSearch.EmailAttr = "mail"
|
||||
c.UserSearch.IDAttr = "DN"
|
||||
c.UserSearch.Username = "cn"
|
||||
c.UserSearch.Filter = "(ou:dn:=Seattle)"
|
||||
|
||||
tests := []subtest{
|
||||
{
|
||||
name: "validpassword",
|
||||
username: "jane",
|
||||
password: "foo",
|
||||
want: connector.Identity{
|
||||
UserID: "cn=jane,ou=People,ou=Seattle,ou=TestUserFilter,dc=example,dc=org",
|
||||
Username: "jane",
|
||||
Email: "janedoe@example.com",
|
||||
EmailVerified: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "validpassword2",
|
||||
username: "john",
|
||||
password: "bar",
|
||||
want: connector.Identity{
|
||||
UserID: "cn=john,ou=People,ou=Seattle,ou=TestUserFilter,dc=example,dc=org",
|
||||
Username: "john",
|
||||
Email: "johndoe@example.com",
|
||||
EmailVerified: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalidpassword",
|
||||
username: "jane",
|
||||
password: "badpassword",
|
||||
wantBadPW: true,
|
||||
},
|
||||
{
|
||||
name: "invaliduser",
|
||||
username: "idontexist",
|
||||
password: "foo",
|
||||
wantBadPW: true, // Want invalid password, not a query error.
|
||||
},
|
||||
}
|
||||
|
||||
runTests(t, connectLDAP, c, tests)
|
||||
}
|
||||
|
||||
func TestGroupQuery(t *testing.T) {
|
||||
c := &Config{}
|
||||
c.UserSearch.BaseDN = "ou=People,ou=TestGroupQuery,dc=example,dc=org"
|
||||
c.UserSearch.NameAttr = "cn"
|
||||
c.UserSearch.EmailAttr = "mail"
|
||||
c.UserSearch.IDAttr = "DN"
|
||||
c.UserSearch.Username = "cn"
|
||||
c.GroupSearch.BaseDN = "ou=Groups,ou=TestGroupQuery,dc=example,dc=org"
|
||||
c.GroupSearch.UserMatchers = []UserMatcher{
|
||||
{
|
||||
UserAttr: "DN",
|
||||
GroupAttr: "member",
|
||||
},
|
||||
}
|
||||
c.GroupSearch.NameAttr = "cn"
|
||||
|
||||
tests := []subtest{
|
||||
{
|
||||
name: "validpassword",
|
||||
username: "jane",
|
||||
password: "foo",
|
||||
groups: true,
|
||||
want: connector.Identity{
|
||||
UserID: "cn=jane,ou=People,ou=TestGroupQuery,dc=example,dc=org",
|
||||
Username: "jane",
|
||||
Email: "janedoe@example.com",
|
||||
EmailVerified: true,
|
||||
Groups: []string{"admins", "developers"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "validpassword2",
|
||||
username: "john",
|
||||
password: "bar",
|
||||
groups: true,
|
||||
want: connector.Identity{
|
||||
UserID: "cn=john,ou=People,ou=TestGroupQuery,dc=example,dc=org",
|
||||
Username: "john",
|
||||
Email: "johndoe@example.com",
|
||||
EmailVerified: true,
|
||||
Groups: []string{"admins"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
runTests(t, connectLDAP, c, tests)
|
||||
}
|
||||
|
||||
func TestGroupsOnUserEntity(t *testing.T) {
|
||||
c := &Config{}
|
||||
c.UserSearch.BaseDN = "ou=People,ou=TestGroupsOnUserEntity,dc=example,dc=org"
|
||||
c.UserSearch.NameAttr = "cn"
|
||||
c.UserSearch.EmailAttr = "mail"
|
||||
c.UserSearch.IDAttr = "DN"
|
||||
c.UserSearch.Username = "cn"
|
||||
c.GroupSearch.BaseDN = "ou=Groups,ou=TestGroupsOnUserEntity,dc=example,dc=org"
|
||||
c.GroupSearch.UserMatchers = []UserMatcher{
|
||||
{
|
||||
UserAttr: "departmentNumber",
|
||||
GroupAttr: "gidNumber",
|
||||
},
|
||||
}
|
||||
c.GroupSearch.NameAttr = "cn"
|
||||
tests := []subtest{
|
||||
{
|
||||
name: "validpassword",
|
||||
username: "jane",
|
||||
password: "foo",
|
||||
groups: true,
|
||||
want: connector.Identity{
|
||||
UserID: "cn=jane,ou=People,ou=TestGroupsOnUserEntity,dc=example,dc=org",
|
||||
Username: "jane",
|
||||
Email: "janedoe@example.com",
|
||||
EmailVerified: true,
|
||||
Groups: []string{"admins", "developers"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "validpassword2",
|
||||
username: "john",
|
||||
password: "bar",
|
||||
groups: true,
|
||||
want: connector.Identity{
|
||||
UserID: "cn=john,ou=People,ou=TestGroupsOnUserEntity,dc=example,dc=org",
|
||||
Username: "john",
|
||||
Email: "johndoe@example.com",
|
||||
EmailVerified: true,
|
||||
Groups: []string{"admins", "designers"},
|
||||
},
|
||||
},
|
||||
}
|
||||
runTests(t, connectLDAP, c, tests)
|
||||
}
|
||||
|
||||
func TestGroupFilter(t *testing.T) {
|
||||
c := &Config{}
|
||||
c.UserSearch.BaseDN = "ou=People,ou=TestGroupFilter,dc=example,dc=org"
|
||||
c.UserSearch.NameAttr = "cn"
|
||||
c.UserSearch.EmailAttr = "mail"
|
||||
c.UserSearch.IDAttr = "DN"
|
||||
c.UserSearch.Username = "cn"
|
||||
c.GroupSearch.BaseDN = "ou=TestGroupFilter,dc=example,dc=org"
|
||||
c.GroupSearch.UserMatchers = []UserMatcher{
|
||||
{
|
||||
UserAttr: "DN",
|
||||
GroupAttr: "member",
|
||||
},
|
||||
}
|
||||
c.GroupSearch.NameAttr = "cn"
|
||||
c.GroupSearch.Filter = "(ou:dn:=Seattle)" // ignore other groups
|
||||
|
||||
tests := []subtest{
|
||||
{
|
||||
name: "validpassword",
|
||||
username: "jane",
|
||||
password: "foo",
|
||||
groups: true,
|
||||
want: connector.Identity{
|
||||
UserID: "cn=jane,ou=People,ou=TestGroupFilter,dc=example,dc=org",
|
||||
Username: "jane",
|
||||
Email: "janedoe@example.com",
|
||||
EmailVerified: true,
|
||||
Groups: []string{"admins", "developers"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "validpassword2",
|
||||
username: "john",
|
||||
password: "bar",
|
||||
groups: true,
|
||||
want: connector.Identity{
|
||||
UserID: "cn=john,ou=People,ou=TestGroupFilter,dc=example,dc=org",
|
||||
Username: "john",
|
||||
Email: "johndoe@example.com",
|
||||
EmailVerified: true,
|
||||
Groups: []string{"admins"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
runTests(t, connectLDAP, c, tests)
|
||||
}
|
||||
|
||||
func TestGroupToUserMatchers(t *testing.T) {
|
||||
c := &Config{}
|
||||
c.UserSearch.BaseDN = "ou=People,ou=TestGroupToUserMatchers,dc=example,dc=org"
|
||||
c.UserSearch.NameAttr = "cn"
|
||||
c.UserSearch.EmailAttr = "mail"
|
||||
c.UserSearch.IDAttr = "DN"
|
||||
c.UserSearch.Username = "cn"
|
||||
c.GroupSearch.BaseDN = "ou=TestGroupToUserMatchers,dc=example,dc=org"
|
||||
c.GroupSearch.UserMatchers = []UserMatcher{
|
||||
{
|
||||
UserAttr: "DN",
|
||||
GroupAttr: "member",
|
||||
},
|
||||
{
|
||||
UserAttr: "uid",
|
||||
GroupAttr: "memberUid",
|
||||
},
|
||||
}
|
||||
c.GroupSearch.NameAttr = "cn"
|
||||
c.GroupSearch.Filter = "(|(objectClass=posixGroup)(objectClass=groupOfNames))" // search all group types
|
||||
|
||||
tests := []subtest{
|
||||
{
|
||||
name: "validpassword",
|
||||
username: "jane",
|
||||
password: "foo",
|
||||
groups: true,
|
||||
want: connector.Identity{
|
||||
UserID: "cn=jane,ou=People,ou=TestGroupToUserMatchers,dc=example,dc=org",
|
||||
Username: "jane",
|
||||
Email: "janedoe@example.com",
|
||||
EmailVerified: true,
|
||||
Groups: []string{"admins", "developers", "frontend"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "validpassword2",
|
||||
username: "john",
|
||||
password: "bar",
|
||||
groups: true,
|
||||
want: connector.Identity{
|
||||
UserID: "cn=john,ou=People,ou=TestGroupToUserMatchers,dc=example,dc=org",
|
||||
Username: "john",
|
||||
Email: "johndoe@example.com",
|
||||
EmailVerified: true,
|
||||
Groups: []string{"admins", "qa", "logger"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
runTests(t, connectLDAP, c, tests)
|
||||
}
|
||||
|
||||
// Test deprecated group to user matching implementation
|
||||
// which was left for backward compatibility.
|
||||
// See "Config.GroupSearch.UserMatchers" comments for the details
|
||||
func TestDeprecatedGroupToUserMatcher(t *testing.T) {
|
||||
c := &Config{}
|
||||
c.UserSearch.BaseDN = "ou=People,ou=TestDeprecatedGroupToUserMatcher,dc=example,dc=org"
|
||||
c.UserSearch.NameAttr = "cn"
|
||||
c.UserSearch.EmailAttr = "mail"
|
||||
c.UserSearch.IDAttr = "DN"
|
||||
c.UserSearch.Username = "cn"
|
||||
c.GroupSearch.BaseDN = "ou=TestDeprecatedGroupToUserMatcher,dc=example,dc=org"
|
||||
c.GroupSearch.UserAttr = "DN"
|
||||
c.GroupSearch.GroupAttr = "member"
|
||||
c.GroupSearch.NameAttr = "cn"
|
||||
c.GroupSearch.Filter = "(ou:dn:=Seattle)" // ignore other groups
|
||||
|
||||
tests := []subtest{
|
||||
{
|
||||
name: "validpassword",
|
||||
username: "jane",
|
||||
password: "foo",
|
||||
groups: true,
|
||||
want: connector.Identity{
|
||||
UserID: "cn=jane,ou=People,ou=TestDeprecatedGroupToUserMatcher,dc=example,dc=org",
|
||||
Username: "jane",
|
||||
Email: "janedoe@example.com",
|
||||
EmailVerified: true,
|
||||
Groups: []string{"admins", "developers"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "validpassword2",
|
||||
username: "john",
|
||||
password: "bar",
|
||||
groups: true,
|
||||
want: connector.Identity{
|
||||
UserID: "cn=john,ou=People,ou=TestDeprecatedGroupToUserMatcher,dc=example,dc=org",
|
||||
Username: "john",
|
||||
Email: "johndoe@example.com",
|
||||
EmailVerified: true,
|
||||
Groups: []string{"admins"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
runTests(t, connectLDAP, c, tests)
|
||||
}
|
||||
|
||||
func TestStartTLS(t *testing.T) {
|
||||
c := &Config{}
|
||||
c.UserSearch.BaseDN = "ou=People,ou=TestStartTLS,dc=example,dc=org"
|
||||
c.UserSearch.NameAttr = "cn"
|
||||
c.UserSearch.EmailAttr = "mail"
|
||||
c.UserSearch.IDAttr = "DN"
|
||||
c.UserSearch.Username = "cn"
|
||||
|
||||
tests := []subtest{
|
||||
{
|
||||
name: "validpassword",
|
||||
username: "jane",
|
||||
password: "foo",
|
||||
want: connector.Identity{
|
||||
UserID: "cn=jane,ou=People,ou=TestStartTLS,dc=example,dc=org",
|
||||
Username: "jane",
|
||||
Email: "janedoe@example.com",
|
||||
EmailVerified: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
runTests(t, connectStartTLS, c, tests)
|
||||
}
|
||||
|
||||
func TestInsecureSkipVerify(t *testing.T) {
|
||||
c := &Config{}
|
||||
c.UserSearch.BaseDN = "ou=People,ou=TestInsecureSkipVerify,dc=example,dc=org"
|
||||
c.UserSearch.NameAttr = "cn"
|
||||
c.UserSearch.EmailAttr = "mail"
|
||||
c.UserSearch.IDAttr = "DN"
|
||||
c.UserSearch.Username = "cn"
|
||||
|
||||
tests := []subtest{
|
||||
{
|
||||
name: "validpassword",
|
||||
username: "jane",
|
||||
password: "foo",
|
||||
want: connector.Identity{
|
||||
UserID: "cn=jane,ou=People,ou=TestInsecureSkipVerify,dc=example,dc=org",
|
||||
Username: "jane",
|
||||
Email: "janedoe@example.com",
|
||||
EmailVerified: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
runTests(t, connectInsecureSkipVerify, c, tests)
|
||||
}
|
||||
|
||||
func TestLDAPS(t *testing.T) {
|
||||
c := &Config{}
|
||||
c.UserSearch.BaseDN = "ou=People,ou=TestLDAPS,dc=example,dc=org"
|
||||
c.UserSearch.NameAttr = "cn"
|
||||
c.UserSearch.EmailAttr = "mail"
|
||||
c.UserSearch.IDAttr = "DN"
|
||||
c.UserSearch.Username = "cn"
|
||||
|
||||
tests := []subtest{
|
||||
{
|
||||
name: "validpassword",
|
||||
username: "jane",
|
||||
password: "foo",
|
||||
want: connector.Identity{
|
||||
UserID: "cn=jane,ou=People,ou=TestLDAPS,dc=example,dc=org",
|
||||
Username: "jane",
|
||||
Email: "janedoe@example.com",
|
||||
EmailVerified: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
runTests(t, connectLDAPS, c, tests)
|
||||
}
|
||||
|
||||
func TestUsernamePrompt(t *testing.T) {
|
||||
tests := map[string]struct {
|
||||
config Config
|
||||
expected string
|
||||
}{
|
||||
"with usernamePrompt unset it returns \"\"": {
|
||||
config: Config{},
|
||||
expected: "",
|
||||
},
|
||||
"with usernamePrompt set it returns that": {
|
||||
config: Config{UsernamePrompt: "Email address"},
|
||||
expected: "Email address",
|
||||
},
|
||||
}
|
||||
|
||||
for n, d := range tests {
|
||||
t.Run(n, func(t *testing.T) {
|
||||
conn := &ldapConnector{Config: d.config}
|
||||
if actual := conn.Prompt(); actual != d.expected {
|
||||
t.Errorf("expected %v, got %v", d.expected, actual)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func getenv(key, defaultVal string) string {
|
||||
if val := os.Getenv(key); val != "" {
|
||||
return val
|
||||
}
|
||||
return defaultVal
|
||||
}
|
||||
|
||||
// runTests runs a set of tests against an LDAP schema.
|
||||
//
|
||||
// The tests require LDAP to be runnning.
|
||||
// You can use the provided docker-compose file to setup an LDAP server.
|
||||
func runTests(t *testing.T, connMethod connectionMethod, config *Config, tests []subtest) {
|
||||
ldapHost := os.Getenv("DEX_LDAP_HOST")
|
||||
if ldapHost == "" {
|
||||
t.Skipf(`test environment variable "DEX_LDAP_HOST" not set, skipping`)
|
||||
}
|
||||
|
||||
// Shallow copy.
|
||||
c := *config
|
||||
|
||||
// We need to configure host parameters but don't want to overwrite user or
|
||||
// group search configuration.
|
||||
switch connMethod {
|
||||
case connectStartTLS:
|
||||
c.Host = fmt.Sprintf("%s:%s", ldapHost, getenv("DEX_LDAP_PORT", "389"))
|
||||
c.RootCA = "testdata/certs/ca.crt"
|
||||
c.StartTLS = true
|
||||
case connectLDAPS:
|
||||
c.Host = fmt.Sprintf("%s:%s", ldapHost, getenv("DEX_LDAP_TLS_PORT", "636"))
|
||||
c.RootCA = "testdata/certs/ca.crt"
|
||||
case connectInsecureSkipVerify:
|
||||
c.Host = fmt.Sprintf("%s:%s", ldapHost, getenv("DEX_LDAP_TLS_PORT", "636"))
|
||||
c.InsecureSkipVerify = true
|
||||
case connectLDAP:
|
||||
c.Host = fmt.Sprintf("%s:%s", ldapHost, getenv("DEX_LDAP_PORT", "389"))
|
||||
c.InsecureNoSSL = true
|
||||
}
|
||||
|
||||
c.BindDN = "cn=admin,dc=example,dc=org"
|
||||
c.BindPW = "admin"
|
||||
|
||||
l := &logrus.Logger{Out: io.Discard, Formatter: &logrus.TextFormatter{}}
|
||||
|
||||
conn, err := c.openConnector(l)
|
||||
if err != nil {
|
||||
t.Errorf("open connector: %v", err)
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
if test.name == "" {
|
||||
t.Fatal("go a subtest with no name")
|
||||
}
|
||||
|
||||
// Run the subtest.
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
s := connector.Scopes{OfflineAccess: true, Groups: test.groups}
|
||||
ident, validPW, err := conn.Login(context.Background(), s, test.username, test.password)
|
||||
if err != nil {
|
||||
if !test.wantErr {
|
||||
t.Fatalf("query failed: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if test.wantErr {
|
||||
t.Fatalf("wanted query to fail")
|
||||
}
|
||||
|
||||
if !validPW {
|
||||
if !test.wantBadPW {
|
||||
t.Fatalf("invalid password: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if test.wantBadPW {
|
||||
t.Fatalf("wanted invalid password")
|
||||
}
|
||||
got := ident
|
||||
got.ConnectorData = nil
|
||||
|
||||
if diff := pretty.Compare(test.want, got); diff != "" {
|
||||
t.Error(diff)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify that refresh tokens work.
|
||||
ident, err = conn.Refresh(context.Background(), s, ident)
|
||||
if err != nil {
|
||||
t.Errorf("refresh failed: %v", err)
|
||||
}
|
||||
|
||||
got = ident
|
||||
got.ConnectorData = nil
|
||||
|
||||
if diff := pretty.Compare(test.want, got); diff != "" {
|
||||
t.Errorf("after refresh: %s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
-----BEGIN CERTIFICATE-----
|
||||
MIIC/TCCAeWgAwIBAgIJAIrt+AlVUsXKMA0GCSqGSIb3DQEBCwUAMBUxEzARBgNV
|
||||
BAMMCmxkYXAtdGVzdHMwHhcNMTcwNDEyMjAxNzI5WhcNNDQwODI4MjAxNzI5WjAV
|
||||
MRMwEQYDVQQDDApsZGFwLXRlc3RzMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB
|
||||
CgKCAQEAzKJkt2WsALUDA3tQsedx7UJKIxis05+dU5FbBxf/BMSch8gCNh/cWErH
|
||||
IDljWGwLKbc9UefIz3BzbcNBPLgLGMp7t9Pf9HCBNf7lShLZB2BEGpgpCpd0urox
|
||||
xTqMEfchssJj75HOZRweHfBDDHk8LMHQYUBn5qTiuMYvBUbPVq69argE/kt5yAEW
|
||||
COZzzx38a11iY0gtPjY4Tc9vICsLHhTssNn/1wf+GFNzSTHqijC7NKW0txUneFQJ
|
||||
h6LAmKV/uZC84W1tqMDZKKpABiTpB+JbDvwsb9eXJ6YG6TgbKcrXjLy4ogbIrIRA
|
||||
s2DqMih792mxusIl6lRf3hTtCdyodwIDAQABo1AwTjAdBgNVHQ4EFgQUnfj9sAq4
|
||||
2xBbV4rf5FNvYaE2Bg0wHwYDVR0jBBgwFoAUnfj9sAq42xBbV4rf5FNvYaE2Bg0w
|
||||
DAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAFGnBH1qpLJLvrLWKNI5w
|
||||
u8pFYO3RGqmfJ3BGf60MQxdUaTIUNQxPfPATbth7t8GRJwpWESRDlaXWq9fM9rkt
|
||||
fbmuqjAMGTFloNd9ra6e2F0CKjwZWcn/3eG/mVw/5d1Ku9Ow8luKrZuzNzVJd13r
|
||||
hoNc1wYXN0pHWkNiRUuR/E4fE/sn+tYOpJ4XYQvKAcSrNrq8m5O9VG5gLvlTeNno
|
||||
6q9hBy+5XKYUdHlzbAGm9QL0e1R45Mu4qxcFluKEmzS1rXlLsLs4/pqHgreXlYgL
|
||||
f7K0cFvaJGnFRKaxa6Bpf1EPNtqSc/pQZh01Ww8CUu1xh2+5KufgJQjAHVG3a1ow
|
||||
dQ==
|
||||
-----END CERTIFICATE-----
|
|
@ -0,0 +1,27 @@
|
|||
-----BEGIN RSA PRIVATE KEY-----
|
||||
MIIEowIBAAKCAQEAzKJkt2WsALUDA3tQsedx7UJKIxis05+dU5FbBxf/BMSch8gC
|
||||
Nh/cWErHIDljWGwLKbc9UefIz3BzbcNBPLgLGMp7t9Pf9HCBNf7lShLZB2BEGpgp
|
||||
Cpd0uroxxTqMEfchssJj75HOZRweHfBDDHk8LMHQYUBn5qTiuMYvBUbPVq69argE
|
||||
/kt5yAEWCOZzzx38a11iY0gtPjY4Tc9vICsLHhTssNn/1wf+GFNzSTHqijC7NKW0
|
||||
txUneFQJh6LAmKV/uZC84W1tqMDZKKpABiTpB+JbDvwsb9eXJ6YG6TgbKcrXjLy4
|
||||
ogbIrIRAs2DqMih792mxusIl6lRf3hTtCdyodwIDAQABAoIBAHQpEucQbe0Q058c
|
||||
VxhF+2PlJ1R441JV3ubbMkL6mibIvNpO7QJwX5I3EIX4Ta6Z1lRd0g82dcVbXgrG
|
||||
tbeT+aie+E/Hk++cFZzjDqFXxZ7sRHycN1/tzbNZknsU2wIvuQ9STYxmxjSbG3V/
|
||||
N3BTOZdmhbVO7Cv/GTwuM+7Y3UWkc74HaXfAgo1UIO9MtqgqP3H1Tv6ZIeKzl+mP
|
||||
wrvei0eQe6jI4W6+vUOX3SlrlrMxMTLK/Ce2MP1pJx++m8Ga23+vtna+lkOWnwcD
|
||||
NmhYl4dL31sDcE6Hz/T6Wwfdlfyugw8vi3a3GEYGMIwy27CFf/ccYnWPOI3oIHDe
|
||||
RwlXLCECgYEA595xJmfUpwqgYY80pT3JG3+64NWJ7f/gH0Ey9fivZfnTegjkI2Kc
|
||||
Uf7+odCq9I1TFtx10M72N4pXT1uLzJtINYty4ZIfOLG7jSraVbOuf9AvMNCYw+cT
|
||||
Fcf/HGUJEE95TKYDrGfklOYFNs3ZCcKOCYJOWCuwki8Vm2vtJpV6gnkCgYEA4e5b
|
||||
DI+YworLjokY8eP4aOF5BMuiFdGkYDjVQZG45RwjJdLwBjaf+HA4pAuJAr2LWiLX
|
||||
cdKpk+3AlJ8UMLIM+hBP4hBqnrPaRTkEhTXpbUA1lvL9o0mVDFgNh90guu5TeJza
|
||||
sW7JLaStmAyCxYGxbW4LTjR8GX9DPOPmLs5ZRm8CgYAyFW5DaXIZksYJzLEGcE4c
|
||||
Tn7DSdy9N+PlXGPxlYHteQUg+wKsUgSKAZZmxXfn0w77hSs9qzar0IoDbjbIP1Jd
|
||||
nn12E+YCjQGCAJugn2s12HYZCTW2Oxd4QPbt3zUR/NiqocFxYA+TygueRuB2pzue
|
||||
+jKKAQXmzZzRMYLMLsWDoQKBgAnrCcoyX5VivG7ka9jqlhQcmdBxFAt7KYkj1ZDM
|
||||
Ud6U7qIRcYIEUd95JbNl4jzhj0WEtAqGIfWhgUvE9ADzQAiWQLt+1v9ii9lwGFe0
|
||||
tyuZnwCiaCoL5+Qj1Ww6c95g6f8oe51AbMp5KTm8it0axWw1YX+sZCpGYPBCXO9/
|
||||
FYI3AoGBAMacjjbPjjfOXxBRhRz1rEDTrIStDj5KM4fgslKVGysqpH/mw7gSC8SK
|
||||
qn0anL2s3SAe9PQpOzM3pFFRZx4XMOk4ojYRZtp3FjPFDRnYuYzkfkbU7eV04awO
|
||||
6nrua8KNLNK+ir9iCi46tP6Zr3F81zWGUoVArVUgCRDbA9e0swB0
|
||||
-----END RSA PRIVATE KEY-----
|
|
@ -0,0 +1,8 @@
|
|||
-----BEGIN DH PARAMETERS-----
|
||||
MIIBCAKCAQEAx5y2viJKOAAcDYSj55odZsbA7dkSQ9afEPd9uaCLOvRYKLJY1S1V
|
||||
C4m1eVfna8JndSLdsBGDQe4BlBTkEYMYR8CJHtUuBxeAucOH8KlF8rIHXXi71oex
|
||||
T7kPtJEDINQKOn06bHqNcn0a7ZMWP8jiQ708OYr5P+1T/N82QTAFpDuqK42ZnBqf
|
||||
8qzQkkTN0UCktY2EWnFTbNIXcMKWQnYP8zt/CG3Q31b2bnQt2iLEa/DIF7RLNjfx
|
||||
9wPQBBAqgWbLmWfdPpHsAPtQxtItb+GRbPs3aLm06CFKlQuteDoP+suo0EtglHcV
|
||||
V9Ynvdz0cdJCJ7EPyET6CtLMzc/Puup/AwIBAg==
|
||||
-----END DH PARAMETERS-----
|
|
@ -0,0 +1,18 @@
|
|||
-----BEGIN CERTIFICATE-----
|
||||
MIIC3DCCAcSgAwIBAgIJANsmsx7hUWnHMA0GCSqGSIb3DQEBCwUAMBUxEzARBgNV
|
||||
BAMMCmxkYXAtdGVzdHMwHhcNMTcwNDEyMjAxNzI5WhcNNDQwODI4MjAxNzI5WjAU
|
||||
MRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK
|
||||
AoIBAQDlWGC5X/TWgysEimM7n0hSkXRCITwAFxKG0C4EeppmL42DBcjQa0xrElRF
|
||||
h57EBZltbSfvTMDBZAyhx5oZKoETDfwy5jFzf4L4PazSkvfn4qWmCnrq4HNO5Vl7
|
||||
GBsW93bljsh2nfvoKDX2vBpEUe0qrZzJtRHq0ytfd6zXZ9+WFMsmhD9poADrH4hB
|
||||
/UOV3uCJPybOoy/WsANQpSgJPD886zakmF+54XQ3tExKzFA1rR4HJbU26h99U5kH
|
||||
346sV7/xKJLENQVIH1qsqyA1UPDZRWusABjdIPc9Racy0/MxTVE0k5lQbBvz9QSe
|
||||
HZvW+ct/aZX5tjxr9JlSY7tK2I9FAgMBAAGjMDAuMAkGA1UdEwQCMAAwCwYDVR0P
|
||||
BAQDAgXgMBQGA1UdEQQNMAuCCWxvY2FsaG9zdDANBgkqhkiG9w0BAQsFAAOCAQEA
|
||||
RZp/fNjoQNaO6KW0Ay0aaPW6jPrcqGjzFgeIXaw/0UaWm5jhptWtjOAILV+afIrd
|
||||
4cKDg65o4xRdQYYbqmutFMAO/DeyDyMi3IL60qk0osipPDIORx5Ai2ZBQvUsGtwV
|
||||
np9UwQGNO5AGeR9N5kndyldbpxaIJFhsKOV8uRSi+4PRbMH3G0kJIX6wwZU4Ri/k
|
||||
3lWJQfqULH0vtMQCWSJuaYHxWYFq4AM+H/zpLwg1WG2eKVgSMWotxMRi5LOFSBbG
|
||||
XuOxAb0SNBcXl6kjRYbQyHBxIJMsB1lk64g7dTJqXuYFUwmIGL/vTr6PL6EKYk65
|
||||
/aWO8cvwXOrYaf9umgcqvg==
|
||||
-----END CERTIFICATE-----
|
|
@ -0,0 +1,27 @@
|
|||
-----BEGIN RSA PRIVATE KEY-----
|
||||
MIIEpAIBAAKCAQEA5VhguV/01oMrBIpjO59IUpF0QiE8ABcShtAuBHqaZi+NgwXI
|
||||
0GtMaxJURYeexAWZbW0n70zAwWQMoceaGSqBEw38MuYxc3+C+D2s0pL35+Klpgp6
|
||||
6uBzTuVZexgbFvd25Y7Idp376Cg19rwaRFHtKq2cybUR6tMrX3es12fflhTLJoQ/
|
||||
aaAA6x+IQf1Dld7giT8mzqMv1rADUKUoCTw/POs2pJhfueF0N7RMSsxQNa0eByW1
|
||||
NuoffVOZB9+OrFe/8SiSxDUFSB9arKsgNVDw2UVrrAAY3SD3PUWnMtPzMU1RNJOZ
|
||||
UGwb8/UEnh2b1vnLf2mV+bY8a/SZUmO7StiPRQIDAQABAoIBAQDHBbKqK4MkxB8I
|
||||
ia8jhk4UmPTyjjSrP1pscyv75wkltA5xrQtfEj32jKlkzRQRt2o1c4w8NbbwHAp6
|
||||
OeSYAjKQfoplAS3YtMbK9XqMIc3QBPcK5/1S5gQqaw0DrR+VBpq/CvEbPm3kQUDT
|
||||
JNkGgLH3X0G4KNGrniT9a7UqGJIGgdBAr7bPESiDi9wuOwfhm/9TB8LOG8wB9cn4
|
||||
NcUipvjOcRxMFkyYtq056ZfGeoK2ooFe0lHi4j8sWXfII789OqN0plecAg8NGZsl
|
||||
klSncpTObE6eTXo9Jncio3pftvszEctKssK7vuL6opajtppT6C5FnKLb6NIAOo7j
|
||||
CPk1BRPhAoGBAPf8TMTr+l8MHRuVXEx52E1dBH46ZB8bMfvwb7cZ31Fn0EEmygCj
|
||||
wP9eKZ8MKmHVBbU6CbxYQMICTTwRrw9H0tNoaZBwzWMz/JDHcACfsPKtfrX8T4UQ
|
||||
wmVwbLctdC1Cbaxn1jYeSLoLfSe8IGPDnLpsMCzpRcQIgPS+gO69zr8vAoGBAOzB
|
||||
254TKd2OQPnvUvmAVYGRYyTu/+ShH9fZyDJYtjhQbuxt6eqh3poneWJOW+KPlqDd
|
||||
J0a8yv1pDXmCy5k1Oo8Nubt7cPI0y2z0nm5LvAaqPaFdUJs9nq9umH3svJh6du6Z
|
||||
+TZ6MDU/eyJRq7Mc5SQrssziJidS3cU21b560xvLAoGBAPYpZY9Ia7Uz0iUSY5eq
|
||||
j7Nj9VTT45UZKsnbRxnrvckSEyDJP1XZN3iG4Sv3KI8KpWrbHNTwif/Lxx0stKin
|
||||
dDjU+Y0e3FJwRXL19lE4M68B17kQp2MAWufU7KX8oclXmoS8YmBAOZMsWmU6ErDV
|
||||
eVt4j23VdaJ9inzoKhZTJcqTAoGAH9znJZsGo16lt/1ReWqgF1Ptt+bCYY6drnsM
|
||||
ylnODD4m74LLXFx0jOKLH4PUMeWJLBUXWBnIZ9pfid7kb7YOL3p1aJnwVWhtiDhT
|
||||
qhxfLbZznOfmFT5xwMJtm2Tk7NBueSYXuBExs7jbZX8AUJau7/NBmPlGkTxBxGzg
|
||||
z0XQa4kCgYBxYBXwFpLLjBO+bMMkoVOlMDj7feCOWP9CsnKQSHYqPbmmb+8mA7pN
|
||||
mIWfjSVynVe+Ncn0I5Uijbs9QDYqcfApJQ+iXeb+VGrg4QkLHHGd/5kIY28Evc6A
|
||||
KVyRIuiYNmgOXGpaFpMXSw718N4U7jWW7lqUxK2rvEupFhaL52oJFQ==
|
||||
-----END RSA PRIVATE KEY-----
|
|
@ -0,0 +1,447 @@
|
|||
dn: ou=TestQuery,dc=example,dc=org
|
||||
objectClass: organizationalUnit
|
||||
ou: TestQuery
|
||||
|
||||
dn: ou=People,ou=TestQuery,dc=example,dc=org
|
||||
objectClass: organizationalUnit
|
||||
ou: People
|
||||
|
||||
dn: cn=jane,ou=People,ou=TestQuery,dc=example,dc=org
|
||||
objectClass: person
|
||||
objectClass: inetOrgPerson
|
||||
sn: doe
|
||||
cn: jane
|
||||
mail: janedoe@example.com
|
||||
userpassword: foo
|
||||
|
||||
dn: cn=john,ou=People,ou=TestQuery,dc=example,dc=org
|
||||
objectClass: person
|
||||
objectClass: inetOrgPerson
|
||||
sn: doe
|
||||
cn: john
|
||||
mail: johndoe@example.com
|
||||
userpassword: bar
|
||||
|
||||
########################################################################
|
||||
|
||||
dn: ou=TestQueryWithEmailSuffix,dc=example,dc=org
|
||||
objectClass: organizationalUnit
|
||||
ou: TestQueryWithEmailSuffix
|
||||
|
||||
dn: ou=People,ou=TestQueryWithEmailSuffix,dc=example,dc=org
|
||||
objectClass: organizationalUnit
|
||||
ou: People
|
||||
|
||||
dn: cn=jane,ou=People,ou=TestQueryWithEmailSuffix,dc=example,dc=org
|
||||
objectClass: person
|
||||
objectClass: inetOrgPerson
|
||||
sn: doe
|
||||
cn: jane
|
||||
mail: janedoe@example.com
|
||||
userpassword: foo
|
||||
|
||||
dn: cn=john,ou=People,ou=TestQueryWithEmailSuffix,dc=example,dc=org
|
||||
objectClass: person
|
||||
objectClass: inetOrgPerson
|
||||
sn: doe
|
||||
cn: john
|
||||
userpassword: bar
|
||||
|
||||
########################################################################
|
||||
|
||||
dn: ou=TestUserFilter,dc=example,dc=org
|
||||
objectClass: organizationalUnit
|
||||
ou: TestUserFilter
|
||||
|
||||
dn: ou=Seattle,ou=TestUserFilter,dc=example,dc=org
|
||||
objectClass: organizationalUnit
|
||||
ou: Seattle
|
||||
|
||||
dn: ou=Portland,ou=TestUserFilter,dc=example,dc=org
|
||||
objectClass: organizationalUnit
|
||||
ou: Portland
|
||||
|
||||
dn: ou=People,ou=Seattle,ou=TestUserFilter,dc=example,dc=org
|
||||
objectClass: organizationalUnit
|
||||
ou: People
|
||||
|
||||
dn: ou=People,ou=Portland,ou=TestUserFilter,dc=example,dc=org
|
||||
objectClass: organizationalUnit
|
||||
ou: People
|
||||
|
||||
dn: cn=jane,ou=People,ou=Seattle,ou=TestUserFilter,dc=example,dc=org
|
||||
objectClass: person
|
||||
objectClass: inetOrgPerson
|
||||
sn: doe
|
||||
cn: jane
|
||||
mail: janedoe@example.com
|
||||
userpassword: foo
|
||||
|
||||
dn: cn=jane,ou=People,ou=Portland,ou=TestUserFilter,dc=example,dc=org
|
||||
objectClass: person
|
||||
objectClass: inetOrgPerson
|
||||
sn: doe
|
||||
cn: jane
|
||||
mail: janedoefromportland@example.com
|
||||
userpassword: baz
|
||||
|
||||
dn: cn=john,ou=People,ou=Seattle,ou=TestUserFilter,dc=example,dc=org
|
||||
objectClass: person
|
||||
objectClass: inetOrgPerson
|
||||
sn: doe
|
||||
cn: john
|
||||
mail: johndoe@example.com
|
||||
userpassword: bar
|
||||
|
||||
########################################################################
|
||||
|
||||
dn: ou=TestGroupQuery,dc=example,dc=org
|
||||
objectClass: organizationalUnit
|
||||
ou: TestGroupQuery
|
||||
|
||||
dn: ou=People,ou=TestGroupQuery,dc=example,dc=org
|
||||
objectClass: organizationalUnit
|
||||
ou: People
|
||||
|
||||
dn: cn=jane,ou=People,ou=TestGroupQuery,dc=example,dc=org
|
||||
objectClass: person
|
||||
objectClass: inetOrgPerson
|
||||
sn: doe
|
||||
cn: jane
|
||||
mail: janedoe@example.com
|
||||
userpassword: foo
|
||||
|
||||
dn: cn=john,ou=People,ou=TestGroupQuery,dc=example,dc=org
|
||||
objectClass: person
|
||||
objectClass: inetOrgPerson
|
||||
sn: doe
|
||||
cn: john
|
||||
mail: johndoe@example.com
|
||||
userpassword: bar
|
||||
|
||||
# Group definitions.
|
||||
|
||||
dn: ou=Groups,ou=TestGroupQuery,dc=example,dc=org
|
||||
objectClass: organizationalUnit
|
||||
ou: Groups
|
||||
|
||||
dn: cn=admins,ou=Groups,ou=TestGroupQuery,dc=example,dc=org
|
||||
objectClass: groupOfNames
|
||||
cn: admins
|
||||
member: cn=john,ou=People,ou=TestGroupQuery,dc=example,dc=org
|
||||
member: cn=jane,ou=People,ou=TestGroupQuery,dc=example,dc=org
|
||||
|
||||
dn: cn=developers,ou=Groups,ou=TestGroupQuery,dc=example,dc=org
|
||||
objectClass: groupOfNames
|
||||
cn: developers
|
||||
member: cn=jane,ou=People,ou=TestGroupQuery,dc=example,dc=org
|
||||
|
||||
########################################################################
|
||||
|
||||
dn: ou=TestGroupsOnUserEntity,dc=example,dc=org
|
||||
objectClass: organizationalUnit
|
||||
ou: TestGroupsOnUserEntity
|
||||
|
||||
dn: ou=People,ou=TestGroupsOnUserEntity,dc=example,dc=org
|
||||
objectClass: organizationalUnit
|
||||
ou: People
|
||||
|
||||
# Groups are enumerated as part of the user entity instead of the members being
|
||||
# a list on the group entity.
|
||||
|
||||
dn: cn=jane,ou=People,ou=TestGroupsOnUserEntity,dc=example,dc=org
|
||||
objectClass: person
|
||||
objectClass: inetOrgPerson
|
||||
sn: doe
|
||||
cn: jane
|
||||
mail: janedoe@example.com
|
||||
userpassword: foo
|
||||
departmentNumber: 1000
|
||||
departmentNumber: 1001
|
||||
|
||||
dn: cn=john,ou=People,ou=TestGroupsOnUserEntity,dc=example,dc=org
|
||||
objectClass: person
|
||||
objectClass: inetOrgPerson
|
||||
sn: doe
|
||||
cn: john
|
||||
mail: johndoe@example.com
|
||||
userpassword: bar
|
||||
departmentNumber: 1000
|
||||
departmentNumber: 1002
|
||||
|
||||
# Group definitions. Notice that they don't have any "member" field.
|
||||
|
||||
dn: ou=Groups,ou=TestGroupsOnUserEntity,dc=example,dc=org
|
||||
objectClass: organizationalUnit
|
||||
ou: Groups
|
||||
|
||||
dn: cn=admins,ou=Groups,ou=TestGroupsOnUserEntity,dc=example,dc=org
|
||||
objectClass: posixGroup
|
||||
cn: admins
|
||||
gidNumber: 1000
|
||||
|
||||
dn: cn=developers,ou=Groups,ou=TestGroupsOnUserEntity,dc=example,dc=org
|
||||
objectClass: posixGroup
|
||||
cn: developers
|
||||
gidNumber: 1001
|
||||
|
||||
dn: cn=designers,ou=Groups,ou=TestGroupsOnUserEntity,dc=example,dc=org
|
||||
objectClass: posixGroup
|
||||
cn: designers
|
||||
gidNumber: 1002
|
||||
|
||||
########################################################################
|
||||
|
||||
dn: ou=TestGroupFilter,dc=example,dc=org
|
||||
objectClass: organizationalUnit
|
||||
ou: TestGroupFilter
|
||||
|
||||
dn: ou=People,ou=TestGroupFilter,dc=example,dc=org
|
||||
objectClass: organizationalUnit
|
||||
ou: People
|
||||
|
||||
dn: cn=jane,ou=People,ou=TestGroupFilter,dc=example,dc=org
|
||||
objectClass: person
|
||||
objectClass: inetOrgPerson
|
||||
sn: doe
|
||||
cn: jane
|
||||
mail: janedoe@example.com
|
||||
userpassword: foo
|
||||
|
||||
dn: cn=john,ou=People,ou=TestGroupFilter,dc=example,dc=org
|
||||
objectClass: person
|
||||
objectClass: inetOrgPerson
|
||||
sn: doe
|
||||
cn: john
|
||||
mail: johndoe@example.com
|
||||
userpassword: bar
|
||||
|
||||
# Group definitions.
|
||||
|
||||
dn: ou=Seattle,ou=TestGroupFilter,dc=example,dc=org
|
||||
objectClass: organizationalUnit
|
||||
ou: Seattle
|
||||
|
||||
dn: ou=Portland,ou=TestGroupFilter,dc=example,dc=org
|
||||
objectClass: organizationalUnit
|
||||
ou: Portland
|
||||
|
||||
dn: ou=Groups,ou=Seattle,ou=TestGroupFilter,dc=example,dc=org
|
||||
objectClass: organizationalUnit
|
||||
ou: Groups
|
||||
|
||||
dn: ou=Groups,ou=Portland,ou=TestGroupFilter,dc=example,dc=org
|
||||
objectClass: organizationalUnit
|
||||
ou: Groups
|
||||
|
||||
dn: cn=qa,ou=Groups,ou=Portland,ou=TestGroupFilter,dc=example,dc=org
|
||||
objectClass: groupOfNames
|
||||
cn: qa
|
||||
member: cn=john,ou=People,ou=TestGroupFilter,dc=example,dc=org
|
||||
|
||||
dn: cn=admins,ou=Groups,ou=Seattle,ou=TestGroupFilter,dc=example,dc=org
|
||||
objectClass: groupOfNames
|
||||
cn: admins
|
||||
member: cn=john,ou=People,ou=TestGroupFilter,dc=example,dc=org
|
||||
member: cn=jane,ou=People,ou=TestGroupFilter,dc=example,dc=org
|
||||
|
||||
dn: cn=developers,ou=Groups,ou=Seattle,ou=TestGroupFilter,dc=example,dc=org
|
||||
objectClass: groupOfNames
|
||||
cn: developers
|
||||
member: cn=jane,ou=People,ou=TestGroupFilter,dc=example,dc=org
|
||||
|
||||
########################################################################
|
||||
|
||||
dn: ou=TestGroupToUserMatchers,dc=example,dc=org
|
||||
objectClass: organizationalUnit
|
||||
ou: TestGroupToUserMatchers
|
||||
|
||||
dn: ou=People,ou=TestGroupToUserMatchers,dc=example,dc=org
|
||||
objectClass: organizationalUnit
|
||||
ou: People
|
||||
|
||||
dn: cn=jane,ou=People,ou=TestGroupToUserMatchers,dc=example,dc=org
|
||||
objectClass: person
|
||||
objectClass: inetOrgPerson
|
||||
sn: doe
|
||||
cn: jane
|
||||
uid: janedoe
|
||||
mail: janedoe@example.com
|
||||
userpassword: foo
|
||||
|
||||
dn: cn=john,ou=People,ou=TestGroupToUserMatchers,dc=example,dc=org
|
||||
objectClass: person
|
||||
objectClass: inetOrgPerson
|
||||
sn: doe
|
||||
cn: john
|
||||
uid: johndoe
|
||||
mail: johndoe@example.com
|
||||
userpassword: bar
|
||||
|
||||
# Group definitions.
|
||||
|
||||
dn: ou=Seattle,ou=TestGroupToUserMatchers,dc=example,dc=org
|
||||
objectClass: organizationalUnit
|
||||
ou: Seattle
|
||||
|
||||
dn: ou=Portland,ou=TestGroupToUserMatchers,dc=example,dc=org
|
||||
objectClass: organizationalUnit
|
||||
ou: Portland
|
||||
|
||||
dn: ou=Groups,ou=Seattle,ou=TestGroupToUserMatchers,dc=example,dc=org
|
||||
objectClass: organizationalUnit
|
||||
ou: Groups
|
||||
|
||||
dn: ou=UnixGroups,ou=Seattle,ou=TestGroupToUserMatchers,dc=example,dc=org
|
||||
objectClass: organizationalUnit
|
||||
ou: UnixGroups
|
||||
|
||||
dn: ou=Groups,ou=Portland,ou=TestGroupToUserMatchers,dc=example,dc=org
|
||||
objectClass: organizationalUnit
|
||||
ou: Groups
|
||||
|
||||
dn: ou=UnixGroups,ou=Portland,ou=TestGroupToUserMatchers,dc=example,dc=org
|
||||
objectClass: organizationalUnit
|
||||
ou: UnixGroups
|
||||
|
||||
dn: cn=qa,ou=Groups,ou=Portland,ou=TestGroupToUserMatchers,dc=example,dc=org
|
||||
objectClass: groupOfNames
|
||||
cn: qa
|
||||
member: cn=john,ou=People,ou=TestGroupToUserMatchers,dc=example,dc=org
|
||||
|
||||
dn: cn=logger,ou=UnixGroups,ou=Portland,ou=TestGroupToUserMatchers,dc=example,dc=org
|
||||
objectClass: posixGroup
|
||||
gidNumber: 1000
|
||||
cn: logger
|
||||
memberUid: johndoe
|
||||
|
||||
dn: cn=admins,ou=Groups,ou=Seattle,ou=TestGroupToUserMatchers,dc=example,dc=org
|
||||
objectClass: groupOfNames
|
||||
cn: admins
|
||||
member: cn=john,ou=People,ou=TestGroupToUserMatchers,dc=example,dc=org
|
||||
member: cn=jane,ou=People,ou=TestGroupToUserMatchers,dc=example,dc=org
|
||||
|
||||
dn: cn=developers,ou=Groups,ou=Seattle,ou=TestGroupToUserMatchers,dc=example,dc=org
|
||||
objectClass: groupOfNames
|
||||
cn: developers
|
||||
member: cn=jane,ou=People,ou=TestGroupToUserMatchers,dc=example,dc=org
|
||||
|
||||
dn: cn=frontend,ou=UnixGroups,ou=Seattle,ou=TestGroupToUserMatchers,dc=example,dc=org
|
||||
objectClass: posixGroup
|
||||
gidNumber: 1001
|
||||
cn: frontend
|
||||
memberUid: janedoe
|
||||
|
||||
########################################################################
|
||||
|
||||
dn: ou=TestDeprecatedGroupToUserMatcher,dc=example,dc=org
|
||||
objectClass: organizationalUnit
|
||||
ou: TestDeprecatedGroupToUserMatcher
|
||||
|
||||
dn: ou=People,ou=TestDeprecatedGroupToUserMatcher,dc=example,dc=org
|
||||
objectClass: organizationalUnit
|
||||
ou: People
|
||||
|
||||
dn: cn=jane,ou=People,ou=TestDeprecatedGroupToUserMatcher,dc=example,dc=org
|
||||
objectClass: person
|
||||
objectClass: inetOrgPerson
|
||||
sn: doe
|
||||
cn: jane
|
||||
mail: janedoe@example.com
|
||||
userpassword: foo
|
||||
|
||||
dn: cn=john,ou=People,ou=TestDeprecatedGroupToUserMatcher,dc=example,dc=org
|
||||
objectClass: person
|
||||
objectClass: inetOrgPerson
|
||||
sn: doe
|
||||
cn: john
|
||||
mail: johndoe@example.com
|
||||
userpassword: bar
|
||||
|
||||
# Group definitions.
|
||||
|
||||
dn: ou=Seattle,ou=TestDeprecatedGroupToUserMatcher,dc=example,dc=org
|
||||
objectClass: organizationalUnit
|
||||
ou: Seattle
|
||||
|
||||
dn: ou=Portland,ou=TestDeprecatedGroupToUserMatcher,dc=example,dc=org
|
||||
objectClass: organizationalUnit
|
||||
ou: Portland
|
||||
|
||||
dn: ou=Groups,ou=Seattle,ou=TestDeprecatedGroupToUserMatcher,dc=example,dc=org
|
||||
objectClass: organizationalUnit
|
||||
ou: Groups
|
||||
|
||||
dn: ou=Groups,ou=Portland,ou=TestDeprecatedGroupToUserMatcher,dc=example,dc=org
|
||||
objectClass: organizationalUnit
|
||||
ou: Groups
|
||||
|
||||
dn: cn=qa,ou=Groups,ou=Portland,ou=TestDeprecatedGroupToUserMatcher,dc=example,dc=org
|
||||
objectClass: groupOfNames
|
||||
cn: qa
|
||||
member: cn=john,ou=People,ou=TestDeprecatedGroupToUserMatcher,dc=example,dc=org
|
||||
|
||||
dn: cn=admins,ou=Groups,ou=Seattle,ou=TestDeprecatedGroupToUserMatcher,dc=example,dc=org
|
||||
objectClass: groupOfNames
|
||||
cn: admins
|
||||
member: cn=john,ou=People,ou=TestDeprecatedGroupToUserMatcher,dc=example,dc=org
|
||||
member: cn=jane,ou=People,ou=TestDeprecatedGroupToUserMatcher,dc=example,dc=org
|
||||
|
||||
dn: cn=developers,ou=Groups,ou=Seattle,ou=TestDeprecatedGroupToUserMatcher,dc=example,dc=org
|
||||
objectClass: groupOfNames
|
||||
cn: developers
|
||||
member: cn=jane,ou=People,ou=TestDeprecatedGroupToUserMatcher,dc=example,dc=org
|
||||
|
||||
########################################################################
|
||||
|
||||
dn: ou=TestStartTLS,dc=example,dc=org
|
||||
objectClass: organizationalUnit
|
||||
ou: TestStartTLS
|
||||
|
||||
dn: ou=People,ou=TestStartTLS,dc=example,dc=org
|
||||
objectClass: organizationalUnit
|
||||
ou: People
|
||||
|
||||
dn: cn=jane,ou=People,ou=TestStartTLS,dc=example,dc=org
|
||||
objectClass: person
|
||||
objectClass: inetOrgPerson
|
||||
sn: doe
|
||||
cn: jane
|
||||
mail: janedoe@example.com
|
||||
userpassword: foo
|
||||
|
||||
########################################################################
|
||||
|
||||
dn: ou=TestInsecureSkipVerify,dc=example,dc=org
|
||||
objectClass: organizationalUnit
|
||||
ou: TestInsecureSkipVerify
|
||||
|
||||
dn: ou=People,ou=TestInsecureSkipVerify,dc=example,dc=org
|
||||
objectClass: organizationalUnit
|
||||
ou: People
|
||||
|
||||
dn: cn=jane,ou=People,ou=TestInsecureSkipVerify,dc=example,dc=org
|
||||
objectClass: person
|
||||
objectClass: inetOrgPerson
|
||||
sn: doe
|
||||
cn: jane
|
||||
mail: janedoe@example.com
|
||||
userpassword: foo
|
||||
|
||||
########################################################################
|
||||
|
||||
dn: ou=TestLDAPS,dc=example,dc=org
|
||||
objectClass: organizationalUnit
|
||||
ou: TestLDAPS
|
||||
|
||||
dn: ou=People,ou=TestLDAPS,dc=example,dc=org
|
||||
objectClass: organizationalUnit
|
||||
ou: People
|
||||
|
||||
dn: cn=jane,ou=People,ou=TestLDAPS,dc=example,dc=org
|
||||
objectClass: person
|
||||
objectClass: inetOrgPerson
|
||||
sn: doe
|
||||
cn: jane
|
||||
mail: janedoe@example.com
|
||||
userpassword: foo
|
|
@ -0,0 +1,242 @@
|
|||
// Package linkedin provides authentication strategies using LinkedIn
|
||||
package linkedin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"github.com/dexidp/dex/connector"
|
||||
"github.com/dexidp/dex/pkg/log"
|
||||
)
|
||||
|
||||
const (
|
||||
apiURL = "https://api.linkedin.com/v2"
|
||||
authURL = "https://www.linkedin.com/oauth/v2/authorization"
|
||||
tokenURL = "https://www.linkedin.com/oauth/v2/accessToken"
|
||||
)
|
||||
|
||||
// Config holds configuration options for LinkedIn logins.
|
||||
type Config struct {
|
||||
ClientID string `json:"clientID"`
|
||||
ClientSecret string `json:"clientSecret"`
|
||||
RedirectURI string `json:"redirectURI"`
|
||||
}
|
||||
|
||||
// Open returns a strategy for logging in through LinkedIn
|
||||
func (c *Config) Open(id string, logger log.Logger) (connector.Connector, error) {
|
||||
return &linkedInConnector{
|
||||
oauth2Config: &oauth2.Config{
|
||||
ClientID: c.ClientID,
|
||||
ClientSecret: c.ClientSecret,
|
||||
Endpoint: oauth2.Endpoint{
|
||||
AuthURL: authURL,
|
||||
TokenURL: tokenURL,
|
||||
},
|
||||
Scopes: []string{"r_liteprofile", "r_emailaddress"},
|
||||
RedirectURL: c.RedirectURI,
|
||||
},
|
||||
logger: logger,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type connectorData struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
}
|
||||
|
||||
type linkedInConnector struct {
|
||||
oauth2Config *oauth2.Config
|
||||
logger log.Logger
|
||||
}
|
||||
|
||||
// LinkedIn doesn't provide refresh tokens, so refresh tokens issued by Dex
|
||||
// will expire in 60 days (default LinkedIn token lifetime).
|
||||
var (
|
||||
_ connector.CallbackConnector = (*linkedInConnector)(nil)
|
||||
_ connector.RefreshConnector = (*linkedInConnector)(nil)
|
||||
)
|
||||
|
||||
// LoginURL returns an access token request URL
|
||||
func (c *linkedInConnector) LoginURL(scopes connector.Scopes, callbackURL, state string) (string, error) {
|
||||
if c.oauth2Config.RedirectURL != callbackURL {
|
||||
return "", fmt.Errorf("expected callback URL %q did not match the URL in the config %q",
|
||||
callbackURL, c.oauth2Config.RedirectURL)
|
||||
}
|
||||
|
||||
return c.oauth2Config.AuthCodeURL(state), nil
|
||||
}
|
||||
|
||||
// HandleCallback handles HTTP redirect from LinkedIn
|
||||
func (c *linkedInConnector) HandleCallback(s connector.Scopes, r *http.Request) (identity connector.Identity, err error) {
|
||||
q := r.URL.Query()
|
||||
if errType := q.Get("error"); errType != "" {
|
||||
return identity, &oauth2Error{errType, q.Get("error_description")}
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
token, err := c.oauth2Config.Exchange(ctx, q.Get("code"))
|
||||
if err != nil {
|
||||
return identity, fmt.Errorf("linkedin: get token: %v", err)
|
||||
}
|
||||
|
||||
client := c.oauth2Config.Client(ctx, token)
|
||||
profile, err := c.profile(ctx, client)
|
||||
if err != nil {
|
||||
return identity, fmt.Errorf("linkedin: get profile: %v", err)
|
||||
}
|
||||
|
||||
identity = connector.Identity{
|
||||
UserID: profile.ID,
|
||||
Username: profile.fullname(),
|
||||
Email: profile.Email,
|
||||
EmailVerified: true,
|
||||
}
|
||||
|
||||
if s.OfflineAccess {
|
||||
data := connectorData{AccessToken: token.AccessToken}
|
||||
connData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return identity, fmt.Errorf("linkedin: marshal connector data: %v", err)
|
||||
}
|
||||
identity.ConnectorData = connData
|
||||
}
|
||||
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
func (c *linkedInConnector) Refresh(ctx context.Context, s connector.Scopes, ident connector.Identity) (connector.Identity, error) {
|
||||
if len(ident.ConnectorData) == 0 {
|
||||
return ident, fmt.Errorf("linkedin: no upstream access token found")
|
||||
}
|
||||
|
||||
var data connectorData
|
||||
if err := json.Unmarshal(ident.ConnectorData, &data); err != nil {
|
||||
return ident, fmt.Errorf("linkedin: unmarshal access token: %v", err)
|
||||
}
|
||||
|
||||
client := c.oauth2Config.Client(ctx, &oauth2.Token{AccessToken: data.AccessToken})
|
||||
profile, err := c.profile(ctx, client)
|
||||
if err != nil {
|
||||
return ident, fmt.Errorf("linkedin: get profile: %v", err)
|
||||
}
|
||||
|
||||
ident.Username = profile.fullname()
|
||||
ident.Email = profile.Email
|
||||
|
||||
return ident, nil
|
||||
}
|
||||
|
||||
type profile struct {
|
||||
ID string `json:"id"`
|
||||
FirstName string `json:"localizedFirstName"`
|
||||
LastName string `json:"localizedLastName"`
|
||||
Email string `json:"emailAddress"`
|
||||
}
|
||||
|
||||
type emailresp struct {
|
||||
Elements []struct {
|
||||
Handle struct {
|
||||
EmailAddress string `json:"emailAddress"`
|
||||
} `json:"handle~"`
|
||||
} `json:"elements"`
|
||||
}
|
||||
|
||||
// fullname returns a full name of a person, or email if the resulting name is
|
||||
// empty
|
||||
func (p profile) fullname() string {
|
||||
fname := strings.TrimSpace(p.FirstName + " " + p.LastName)
|
||||
if fname == "" {
|
||||
return p.Email
|
||||
}
|
||||
|
||||
return fname
|
||||
}
|
||||
|
||||
func (c *linkedInConnector) primaryEmail(ctx context.Context, client *http.Client) (email string, err error) {
|
||||
req, err := http.NewRequest("GET", apiURL+"/emailAddress?q=members&projection=(elements*(handle~))", nil)
|
||||
if err != nil {
|
||||
return email, fmt.Errorf("new req: %v", err)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req.WithContext(ctx))
|
||||
if err != nil {
|
||||
return email, fmt.Errorf("get URL %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return email, fmt.Errorf("read body: %v", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return email, fmt.Errorf("%s: %s", resp.Status, body)
|
||||
}
|
||||
|
||||
var parsedResp emailresp
|
||||
err = json.Unmarshal(body, &parsedResp)
|
||||
if err == nil {
|
||||
for _, elem := range parsedResp.Elements {
|
||||
email = elem.Handle.EmailAddress
|
||||
}
|
||||
}
|
||||
|
||||
if email == "" {
|
||||
err = fmt.Errorf("email is not set")
|
||||
}
|
||||
|
||||
return email, err
|
||||
}
|
||||
|
||||
func (c *linkedInConnector) profile(ctx context.Context, client *http.Client) (p profile, err error) {
|
||||
// https://docs.microsoft.com/en-us/linkedin/shared/integrations/people/profile-api
|
||||
// https://docs.microsoft.com/en-us/linkedin/shared/integrations/people/primary-contact-api
|
||||
// https://docs.microsoft.com/en-us/linkedin/consumer/integrations/self-serve/migration-faq#how-do-i-retrieve-the-members-email-address
|
||||
req, err := http.NewRequest("GET", apiURL+"/me", nil)
|
||||
if err != nil {
|
||||
return p, fmt.Errorf("new req: %v", err)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req.WithContext(ctx))
|
||||
if err != nil {
|
||||
return p, fmt.Errorf("get URL %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return p, fmt.Errorf("read body: %v", err)
|
||||
}
|
||||
return p, fmt.Errorf("%s: %s", resp.Status, body)
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&p); err != nil {
|
||||
return p, fmt.Errorf("JSON decode: %v", err)
|
||||
}
|
||||
|
||||
email, err := c.primaryEmail(ctx, client)
|
||||
if err != nil {
|
||||
return p, fmt.Errorf("fetching email: %v", err)
|
||||
}
|
||||
p.Email = email
|
||||
|
||||
return p, err
|
||||
}
|
||||
|
||||
type oauth2Error struct {
|
||||
error string
|
||||
errorDescription string
|
||||
}
|
||||
|
||||
func (e *oauth2Error) Error() string {
|
||||
if e.errorDescription == "" {
|
||||
return e.error
|
||||
}
|
||||
return e.error + ": " + e.errorDescription
|
||||
}
|
|
@ -0,0 +1,515 @@
|
|||
// Package microsoft provides authentication strategies using Microsoft.
|
||||
package microsoft
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"github.com/dexidp/dex/connector"
|
||||
groups_pkg "github.com/dexidp/dex/pkg/groups"
|
||||
"github.com/dexidp/dex/pkg/log"
|
||||
)
|
||||
|
||||
// GroupNameFormat represents the format of the group identifier
|
||||
// we use type of string instead of int because it's easier to
|
||||
// marshall/unmarshall
|
||||
type GroupNameFormat string
|
||||
|
||||
// Possible values for GroupNameFormat
|
||||
const (
|
||||
GroupID GroupNameFormat = "id"
|
||||
GroupName GroupNameFormat = "name"
|
||||
)
|
||||
|
||||
const (
|
||||
// Microsoft requires this scope to access user's profile
|
||||
scopeUser = "user.read"
|
||||
// Microsoft requires this scope to list groups the user is a member of
|
||||
// and resolve their ids to groups names.
|
||||
scopeGroups = "directory.read.all"
|
||||
// Microsoft requires this scope to return a refresh token
|
||||
// see https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-permissions-and-consent#offline_access
|
||||
scopeOfflineAccess = "offline_access"
|
||||
)
|
||||
|
||||
// Config holds configuration options for microsoft logins.
|
||||
type Config struct {
|
||||
ClientID string `json:"clientID"`
|
||||
ClientSecret string `json:"clientSecret"`
|
||||
RedirectURI string `json:"redirectURI"`
|
||||
Tenant string `json:"tenant"`
|
||||
OnlySecurityGroups bool `json:"onlySecurityGroups"`
|
||||
Groups []string `json:"groups"`
|
||||
GroupNameFormat GroupNameFormat `json:"groupNameFormat"`
|
||||
UseGroupsAsWhitelist bool `json:"useGroupsAsWhitelist"`
|
||||
EmailToLowercase bool `json:"emailToLowercase"`
|
||||
|
||||
// PromptType is used for the prompt query parameter.
|
||||
// For valid values, see https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-auth-code-flow#request-an-authorization-code.
|
||||
PromptType string `json:"promptType"`
|
||||
DomainHint string `json:"domainHint"`
|
||||
|
||||
Scopes []string `json:"scopes"` // defaults to scopeUser (user.read)
|
||||
}
|
||||
|
||||
// Open returns a strategy for logging in through Microsoft.
|
||||
func (c *Config) Open(id string, logger log.Logger) (connector.Connector, error) {
|
||||
m := microsoftConnector{
|
||||
apiURL: "https://login.microsoftonline.com",
|
||||
graphURL: "https://graph.microsoft.com",
|
||||
redirectURI: c.RedirectURI,
|
||||
clientID: c.ClientID,
|
||||
clientSecret: c.ClientSecret,
|
||||
tenant: c.Tenant,
|
||||
onlySecurityGroups: c.OnlySecurityGroups,
|
||||
groups: c.Groups,
|
||||
groupNameFormat: c.GroupNameFormat,
|
||||
useGroupsAsWhitelist: c.UseGroupsAsWhitelist,
|
||||
logger: logger,
|
||||
emailToLowercase: c.EmailToLowercase,
|
||||
promptType: c.PromptType,
|
||||
domainHint: c.DomainHint,
|
||||
scopes: c.Scopes,
|
||||
}
|
||||
// By default allow logins from both personal and business/school
|
||||
// accounts.
|
||||
if m.tenant == "" {
|
||||
m.tenant = "common"
|
||||
}
|
||||
|
||||
// By default, use group names
|
||||
switch m.groupNameFormat {
|
||||
case "":
|
||||
m.groupNameFormat = GroupName
|
||||
case GroupID, GroupName:
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid groupNameFormat: %s", m.groupNameFormat)
|
||||
}
|
||||
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
type connectorData struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
RefreshToken string `json:"refreshToken"`
|
||||
Expiry time.Time `json:"expiry"`
|
||||
}
|
||||
|
||||
var (
|
||||
_ connector.CallbackConnector = (*microsoftConnector)(nil)
|
||||
_ connector.RefreshConnector = (*microsoftConnector)(nil)
|
||||
)
|
||||
|
||||
type microsoftConnector struct {
|
||||
apiURL string
|
||||
graphURL string
|
||||
redirectURI string
|
||||
clientID string
|
||||
clientSecret string
|
||||
tenant string
|
||||
onlySecurityGroups bool
|
||||
groupNameFormat GroupNameFormat
|
||||
groups []string
|
||||
useGroupsAsWhitelist bool
|
||||
logger log.Logger
|
||||
emailToLowercase bool
|
||||
promptType string
|
||||
domainHint string
|
||||
scopes []string
|
||||
}
|
||||
|
||||
func (c *microsoftConnector) isOrgTenant() bool {
|
||||
return c.tenant != "common" && c.tenant != "consumers" && c.tenant != "organizations"
|
||||
}
|
||||
|
||||
func (c *microsoftConnector) groupsRequired(groupScope bool) bool {
|
||||
return (len(c.groups) > 0 || groupScope) && c.isOrgTenant()
|
||||
}
|
||||
|
||||
func (c *microsoftConnector) oauth2Config(scopes connector.Scopes) *oauth2.Config {
|
||||
var microsoftScopes []string
|
||||
if len(c.scopes) > 0 {
|
||||
microsoftScopes = c.scopes
|
||||
} else {
|
||||
microsoftScopes = append(microsoftScopes, scopeUser)
|
||||
}
|
||||
if c.groupsRequired(scopes.Groups) {
|
||||
microsoftScopes = append(microsoftScopes, scopeGroups)
|
||||
}
|
||||
|
||||
if scopes.OfflineAccess {
|
||||
microsoftScopes = append(microsoftScopes, scopeOfflineAccess)
|
||||
}
|
||||
|
||||
return &oauth2.Config{
|
||||
ClientID: c.clientID,
|
||||
ClientSecret: c.clientSecret,
|
||||
Endpoint: oauth2.Endpoint{
|
||||
AuthURL: c.apiURL + "/" + c.tenant + "/oauth2/v2.0/authorize",
|
||||
TokenURL: c.apiURL + "/" + c.tenant + "/oauth2/v2.0/token",
|
||||
},
|
||||
Scopes: microsoftScopes,
|
||||
RedirectURL: c.redirectURI,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *microsoftConnector) LoginURL(scopes connector.Scopes, callbackURL, state string) (string, error) {
|
||||
if c.redirectURI != callbackURL {
|
||||
return "", fmt.Errorf("expected callback URL %q did not match the URL in the config %q", callbackURL, c.redirectURI)
|
||||
}
|
||||
|
||||
var options []oauth2.AuthCodeOption
|
||||
if c.promptType != "" {
|
||||
options = append(options, oauth2.SetAuthURLParam("prompt", c.promptType))
|
||||
}
|
||||
if c.domainHint != "" {
|
||||
options = append(options, oauth2.SetAuthURLParam("domain_hint", c.domainHint))
|
||||
}
|
||||
|
||||
return c.oauth2Config(scopes).AuthCodeURL(state, options...), nil
|
||||
}
|
||||
|
||||
func (c *microsoftConnector) HandleCallback(s connector.Scopes, r *http.Request) (identity connector.Identity, err error) {
|
||||
q := r.URL.Query()
|
||||
if errType := q.Get("error"); errType != "" {
|
||||
return identity, &oauth2Error{errType, q.Get("error_description")}
|
||||
}
|
||||
|
||||
oauth2Config := c.oauth2Config(s)
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
token, err := oauth2Config.Exchange(ctx, q.Get("code"))
|
||||
if err != nil {
|
||||
return identity, fmt.Errorf("microsoft: failed to get token: %v", err)
|
||||
}
|
||||
|
||||
client := oauth2Config.Client(ctx, token)
|
||||
|
||||
user, err := c.user(ctx, client)
|
||||
if err != nil {
|
||||
return identity, fmt.Errorf("microsoft: get user: %v", err)
|
||||
}
|
||||
|
||||
if c.emailToLowercase {
|
||||
user.Email = strings.ToLower(user.Email)
|
||||
}
|
||||
|
||||
identity = connector.Identity{
|
||||
UserID: user.ID,
|
||||
Username: user.Name,
|
||||
Email: user.Email,
|
||||
EmailVerified: true,
|
||||
}
|
||||
|
||||
if c.groupsRequired(s.Groups) {
|
||||
groups, err := c.getGroups(ctx, client, user.ID)
|
||||
if err != nil {
|
||||
return identity, fmt.Errorf("microsoft: get groups: %v", err)
|
||||
}
|
||||
identity.Groups = groups
|
||||
}
|
||||
|
||||
if s.OfflineAccess {
|
||||
data := connectorData{
|
||||
AccessToken: token.AccessToken,
|
||||
RefreshToken: token.RefreshToken,
|
||||
Expiry: token.Expiry,
|
||||
}
|
||||
connData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return identity, fmt.Errorf("microsoft: marshal connector data: %v", err)
|
||||
}
|
||||
identity.ConnectorData = connData
|
||||
}
|
||||
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
type tokenNotifyFunc func(*oauth2.Token) error
|
||||
|
||||
// notifyRefreshTokenSource is essentially `oauth2.ReuseTokenSource` with `TokenNotifyFunc` added.
|
||||
type notifyRefreshTokenSource struct {
|
||||
new oauth2.TokenSource
|
||||
mu sync.Mutex // guards t
|
||||
t *oauth2.Token
|
||||
f tokenNotifyFunc // called when token refreshed so new refresh token can be persisted
|
||||
}
|
||||
|
||||
// Token returns the current token if it's still valid, else will
|
||||
// refresh the current token (using r.Context for HTTP client
|
||||
// information) and return the new one.
|
||||
func (s *notifyRefreshTokenSource) Token() (*oauth2.Token, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.t.Valid() {
|
||||
return s.t, nil
|
||||
}
|
||||
t, err := s.new.Token()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.t = t
|
||||
return t, s.f(t)
|
||||
}
|
||||
|
||||
func (c *microsoftConnector) Refresh(ctx context.Context, s connector.Scopes, identity connector.Identity) (connector.Identity, error) {
|
||||
if len(identity.ConnectorData) == 0 {
|
||||
return identity, errors.New("microsoft: no upstream access token found")
|
||||
}
|
||||
|
||||
var data connectorData
|
||||
if err := json.Unmarshal(identity.ConnectorData, &data); err != nil {
|
||||
return identity, fmt.Errorf("microsoft: unmarshal access token: %v", err)
|
||||
}
|
||||
tok := &oauth2.Token{
|
||||
AccessToken: data.AccessToken,
|
||||
RefreshToken: data.RefreshToken,
|
||||
Expiry: data.Expiry,
|
||||
}
|
||||
|
||||
client := oauth2.NewClient(ctx, ¬ifyRefreshTokenSource{
|
||||
new: c.oauth2Config(s).TokenSource(ctx, tok),
|
||||
t: tok,
|
||||
f: func(tok *oauth2.Token) error {
|
||||
data := connectorData{
|
||||
AccessToken: tok.AccessToken,
|
||||
RefreshToken: tok.RefreshToken,
|
||||
Expiry: tok.Expiry,
|
||||
}
|
||||
connData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("microsoft: marshal connector data: %v", err)
|
||||
}
|
||||
identity.ConnectorData = connData
|
||||
return nil
|
||||
},
|
||||
})
|
||||
user, err := c.user(ctx, client)
|
||||
if err != nil {
|
||||
return identity, fmt.Errorf("microsoft: get user: %v", err)
|
||||
}
|
||||
|
||||
identity.Username = user.Name
|
||||
identity.Email = user.Email
|
||||
|
||||
if c.groupsRequired(s.Groups) {
|
||||
groups, err := c.getGroups(ctx, client, user.ID)
|
||||
if err != nil {
|
||||
return identity, fmt.Errorf("microsoft: get groups: %v", err)
|
||||
}
|
||||
identity.Groups = groups
|
||||
}
|
||||
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
// https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/resources/user
|
||||
// id - The unique identifier for the user. Inherited from
|
||||
// directoryObject. Key. Not nullable. Read-only.
|
||||
// displayName - The name displayed in the address book for the user.
|
||||
// This is usually the combination of the user's first name,
|
||||
// middle initial and last name. This property is required
|
||||
// when a user is created and it cannot be cleared during
|
||||
// updates. Supports $filter and $orderby.
|
||||
// userPrincipalName - The user principal name (UPN) of the user.
|
||||
// The UPN is an Internet-style login name for the user
|
||||
// based on the Internet standard RFC 822. By convention,
|
||||
// this should map to the user's email name. The general
|
||||
// format is alias@domain, where domain must be present in
|
||||
// the tenant’s collection of verified domains. This
|
||||
// property is required when a user is created. The
|
||||
// verified domains for the tenant can be accessed from the
|
||||
// verifiedDomains property of organization. Supports
|
||||
// $filter and $orderby.
|
||||
type user struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"displayName"`
|
||||
Email string `json:"userPrincipalName"`
|
||||
}
|
||||
|
||||
func (c *microsoftConnector) user(ctx context.Context, client *http.Client) (u user, err error) {
|
||||
// https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/api/user_get
|
||||
req, err := http.NewRequest("GET", c.graphURL+"/v1.0/me?$select=id,displayName,userPrincipalName", nil)
|
||||
if err != nil {
|
||||
return u, fmt.Errorf("new req: %v", err)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req.WithContext(ctx))
|
||||
if err != nil {
|
||||
return u, fmt.Errorf("get URL %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return u, newGraphError(resp.Body)
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&u); err != nil {
|
||||
return u, fmt.Errorf("JSON decode: %v", err)
|
||||
}
|
||||
|
||||
return u, err
|
||||
}
|
||||
|
||||
// https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/resources/group
|
||||
// displayName - The display name for the group. This property is required when
|
||||
// a group is created and it cannot be cleared during updates.
|
||||
// Supports $filter and $orderby.
|
||||
type group struct {
|
||||
Name string `json:"displayName"`
|
||||
}
|
||||
|
||||
func (c *microsoftConnector) getGroups(ctx context.Context, client *http.Client, userID string) ([]string, error) {
|
||||
userGroups, err := c.getGroupIDs(ctx, client)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if c.groupNameFormat == GroupName {
|
||||
userGroups, err = c.getGroupNames(ctx, client, userGroups)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// ensure that the user is in at least one required group
|
||||
filteredGroups := groups_pkg.Filter(userGroups, c.groups)
|
||||
if len(c.groups) > 0 && len(filteredGroups) == 0 {
|
||||
return nil, fmt.Errorf("microsoft: user %v not in any of the required groups", userID)
|
||||
} else if c.useGroupsAsWhitelist {
|
||||
return filteredGroups, nil
|
||||
}
|
||||
|
||||
return userGroups, nil
|
||||
}
|
||||
|
||||
func (c *microsoftConnector) getGroupIDs(ctx context.Context, client *http.Client) (ids []string, err error) {
|
||||
// https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/api/user_getmembergroups
|
||||
in := &struct {
|
||||
SecurityEnabledOnly bool `json:"securityEnabledOnly"`
|
||||
}{c.onlySecurityGroups}
|
||||
reqURL := c.graphURL + "/v1.0/me/getMemberGroups"
|
||||
for {
|
||||
var out []string
|
||||
var next string
|
||||
|
||||
next, err = c.post(ctx, client, reqURL, in, &out)
|
||||
if err != nil {
|
||||
return ids, err
|
||||
}
|
||||
|
||||
ids = append(ids, out...)
|
||||
if next == "" {
|
||||
return
|
||||
}
|
||||
reqURL = next
|
||||
}
|
||||
}
|
||||
|
||||
func (c *microsoftConnector) getGroupNames(ctx context.Context, client *http.Client, ids []string) (groups []string, err error) {
|
||||
if len(ids) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/api/directoryobject_getbyids
|
||||
in := &struct {
|
||||
IDs []string `json:"ids"`
|
||||
Types []string `json:"types"`
|
||||
}{ids, []string{"group"}}
|
||||
reqURL := c.graphURL + "/v1.0/directoryObjects/getByIds"
|
||||
for {
|
||||
var out []group
|
||||
var next string
|
||||
|
||||
next, err = c.post(ctx, client, reqURL, in, &out)
|
||||
if err != nil {
|
||||
return groups, err
|
||||
}
|
||||
|
||||
for _, g := range out {
|
||||
groups = append(groups, g.Name)
|
||||
}
|
||||
if next == "" {
|
||||
return
|
||||
}
|
||||
reqURL = next
|
||||
}
|
||||
}
|
||||
|
||||
func (c *microsoftConnector) post(ctx context.Context, client *http.Client, reqURL string, in interface{}, out interface{}) (string, error) {
|
||||
var payload bytes.Buffer
|
||||
|
||||
err := json.NewEncoder(&payload).Encode(in)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("microsoft: JSON encode: %v", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", reqURL, &payload)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("new req: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := client.Do(req.WithContext(ctx))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("post URL %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", newGraphError(resp.Body)
|
||||
}
|
||||
|
||||
var next string
|
||||
if err = json.NewDecoder(resp.Body).Decode(&struct {
|
||||
NextLink *string `json:"@odata.nextLink"`
|
||||
Value interface{} `json:"value"`
|
||||
}{&next, out}); err != nil {
|
||||
return "", fmt.Errorf("JSON decode: %v", err)
|
||||
}
|
||||
|
||||
return next, nil
|
||||
}
|
||||
|
||||
type graphError struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func (e *graphError) Error() string {
|
||||
return e.Code + ": " + e.Message
|
||||
}
|
||||
|
||||
func newGraphError(r io.Reader) error {
|
||||
// https://developer.microsoft.com/en-us/graph/docs/concepts/errors
|
||||
var ge graphError
|
||||
if err := json.NewDecoder(r).Decode(&struct {
|
||||
Error *graphError `json:"error"`
|
||||
}{&ge}); err != nil {
|
||||
return fmt.Errorf("JSON error decode: %v", err)
|
||||
}
|
||||
return &ge
|
||||
}
|
||||
|
||||
type oauth2Error struct {
|
||||
error string
|
||||
errorDescription string
|
||||
}
|
||||
|
||||
func (e *oauth2Error) Error() string {
|
||||
if e.errorDescription == "" {
|
||||
return e.error
|
||||
}
|
||||
return e.error + ": " + e.errorDescription
|
||||
}
|
|
@ -0,0 +1,146 @@
|
|||
package microsoft
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/dexidp/dex/connector"
|
||||
)
|
||||
|
||||
type testResponse struct {
|
||||
data interface{}
|
||||
}
|
||||
|
||||
const (
|
||||
tenant = "9b1c3439-a67e-4e92-bb0d-0571d44ca965"
|
||||
clientID = "a115ebf3-6020-4384-8eb1-c0c42e667b6f"
|
||||
)
|
||||
|
||||
var dummyToken = testResponse{data: map[string]interface{}{
|
||||
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9",
|
||||
"expires_in": "30",
|
||||
}}
|
||||
|
||||
func TestLoginURL(t *testing.T) {
|
||||
testURL := "https://test.com"
|
||||
testState := "some-state"
|
||||
|
||||
conn := microsoftConnector{
|
||||
apiURL: testURL,
|
||||
graphURL: testURL,
|
||||
redirectURI: testURL,
|
||||
clientID: clientID,
|
||||
tenant: tenant,
|
||||
}
|
||||
|
||||
loginURL, _ := conn.LoginURL(connector.Scopes{}, conn.redirectURI, testState)
|
||||
|
||||
parsedLoginURL, _ := url.Parse(loginURL)
|
||||
queryParams := parsedLoginURL.Query()
|
||||
|
||||
expectEquals(t, parsedLoginURL.Path, "/"+tenant+"/oauth2/v2.0/authorize")
|
||||
expectEquals(t, queryParams.Get("client_id"), clientID)
|
||||
expectEquals(t, queryParams.Get("redirect_uri"), testURL)
|
||||
expectEquals(t, queryParams.Get("response_type"), "code")
|
||||
expectEquals(t, queryParams.Get("scope"), "user.read")
|
||||
expectEquals(t, queryParams.Get("state"), testState)
|
||||
expectEquals(t, queryParams.Get("prompt"), "")
|
||||
expectEquals(t, queryParams.Get("domain_hint"), "")
|
||||
}
|
||||
|
||||
func TestLoginURLWithOptions(t *testing.T) {
|
||||
testURL := "https://test.com"
|
||||
promptType := "consent"
|
||||
domainHint := "domain.hint"
|
||||
|
||||
conn := microsoftConnector{
|
||||
apiURL: testURL,
|
||||
graphURL: testURL,
|
||||
redirectURI: testURL,
|
||||
clientID: clientID,
|
||||
tenant: tenant,
|
||||
|
||||
promptType: promptType,
|
||||
domainHint: domainHint,
|
||||
}
|
||||
|
||||
loginURL, _ := conn.LoginURL(connector.Scopes{}, conn.redirectURI, "some-state")
|
||||
|
||||
parsedLoginURL, _ := url.Parse(loginURL)
|
||||
queryParams := parsedLoginURL.Query()
|
||||
|
||||
expectEquals(t, queryParams.Get("prompt"), promptType)
|
||||
expectEquals(t, queryParams.Get("domain_hint"), domainHint)
|
||||
}
|
||||
|
||||
func TestUserIdentityFromGraphAPI(t *testing.T) {
|
||||
s := newTestServer(map[string]testResponse{
|
||||
"/v1.0/me?$select=id,displayName,userPrincipalName": {
|
||||
data: user{ID: "S56767889", Name: "Jane Doe", Email: "jane.doe@example.com"},
|
||||
},
|
||||
"/" + tenant + "/oauth2/v2.0/token": dummyToken,
|
||||
})
|
||||
defer s.Close()
|
||||
|
||||
req, _ := http.NewRequest("GET", s.URL, nil)
|
||||
|
||||
c := microsoftConnector{apiURL: s.URL, graphURL: s.URL, tenant: tenant}
|
||||
identity, err := c.HandleCallback(connector.Scopes{Groups: false}, req)
|
||||
expectNil(t, err)
|
||||
expectEquals(t, identity.Username, "Jane Doe")
|
||||
expectEquals(t, identity.UserID, "S56767889")
|
||||
expectEquals(t, identity.PreferredUsername, "")
|
||||
expectEquals(t, identity.Email, "jane.doe@example.com")
|
||||
expectEquals(t, identity.EmailVerified, true)
|
||||
expectEquals(t, len(identity.Groups), 0)
|
||||
}
|
||||
|
||||
func TestUserGroupsFromGraphAPI(t *testing.T) {
|
||||
s := newTestServer(map[string]testResponse{
|
||||
"/v1.0/me?$select=id,displayName,userPrincipalName": {data: user{}},
|
||||
"/v1.0/me/getMemberGroups": {data: map[string]interface{}{
|
||||
"value": []string{"a", "b"},
|
||||
}},
|
||||
"/" + tenant + "/oauth2/v2.0/token": dummyToken,
|
||||
})
|
||||
defer s.Close()
|
||||
|
||||
req, _ := http.NewRequest("GET", s.URL, nil)
|
||||
|
||||
c := microsoftConnector{apiURL: s.URL, graphURL: s.URL, tenant: tenant}
|
||||
identity, err := c.HandleCallback(connector.Scopes{Groups: true}, req)
|
||||
expectNil(t, err)
|
||||
expectEquals(t, identity.Groups, []string{"a", "b"})
|
||||
}
|
||||
|
||||
func newTestServer(responses map[string]testResponse) *httptest.Server {
|
||||
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
response, found := responses[r.RequestURI]
|
||||
if !found {
|
||||
fmt.Fprintf(os.Stderr, "Mock response for %q not found\n", r.RequestURI)
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response.data)
|
||||
}))
|
||||
return s
|
||||
}
|
||||
|
||||
func expectNil(t *testing.T, a interface{}) {
|
||||
if a != nil {
|
||||
t.Errorf("Expected %+v to equal nil", a)
|
||||
}
|
||||
}
|
||||
|
||||
func expectEquals(t *testing.T, a interface{}, b interface{}) {
|
||||
if !reflect.DeepEqual(a, b) {
|
||||
t.Errorf("Expected %+v to equal %+v", a, b)
|
||||
}
|
||||
}
|
|
@ -8,13 +8,13 @@ import (
|
|||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/Sirupsen/logrus"
|
||||
"github.com/coreos/dex/connector"
|
||||
"github.com/dexidp/dex/connector"
|
||||
"github.com/dexidp/dex/pkg/log"
|
||||
)
|
||||
|
||||
// NewCallbackConnector returns a mock connector which requires no user interaction. It always returns
|
||||
// the same (fake) identity.
|
||||
func NewCallbackConnector(logger logrus.FieldLogger) connector.Connector {
|
||||
func NewCallbackConnector(logger log.Logger) connector.Connector {
|
||||
return &Callback{
|
||||
Identity: connector.Identity{
|
||||
UserID: "0-385-28089-0",
|
||||
|
@ -32,13 +32,14 @@ var (
|
|||
_ connector.CallbackConnector = &Callback{}
|
||||
|
||||
_ connector.PasswordConnector = passwordConnector{}
|
||||
_ connector.RefreshConnector = passwordConnector{}
|
||||
)
|
||||
|
||||
// Callback is a connector that requires no user interaction and always returns the same identity.
|
||||
type Callback struct {
|
||||
// The returned identity.
|
||||
Identity connector.Identity
|
||||
Logger logrus.FieldLogger
|
||||
Logger log.Logger
|
||||
}
|
||||
|
||||
// LoginURL returns the URL to redirect the user to login with.
|
||||
|
@ -69,7 +70,7 @@ func (m *Callback) Refresh(ctx context.Context, s connector.Scopes, identity con
|
|||
type CallbackConfig struct{}
|
||||
|
||||
// Open returns an authentication strategy which requires no user interaction.
|
||||
func (c *CallbackConfig) Open(logger logrus.FieldLogger) (connector.Connector, error) {
|
||||
func (c *CallbackConfig) Open(id string, logger log.Logger) (connector.Connector, error) {
|
||||
return NewCallbackConnector(logger), nil
|
||||
}
|
||||
|
||||
|
@ -81,7 +82,7 @@ type PasswordConfig struct {
|
|||
}
|
||||
|
||||
// Open returns an authentication strategy which prompts for a predefined username and password.
|
||||
func (c *PasswordConfig) Open(logger logrus.FieldLogger) (connector.Connector, error) {
|
||||
func (c *PasswordConfig) Open(id string, logger log.Logger) (connector.Connector, error) {
|
||||
if c.Username == "" {
|
||||
return nil, errors.New("no username supplied")
|
||||
}
|
||||
|
@ -94,7 +95,7 @@ func (c *PasswordConfig) Open(logger logrus.FieldLogger) (connector.Connector, e
|
|||
type passwordConnector struct {
|
||||
username string
|
||||
password string
|
||||
logger logrus.FieldLogger
|
||||
logger log.Logger
|
||||
}
|
||||
|
||||
func (p passwordConnector) Close() error { return nil }
|
||||
|
@ -106,7 +107,14 @@ func (p passwordConnector) Login(ctx context.Context, s connector.Scopes, userna
|
|||
Username: "Kilgore Trout",
|
||||
Email: "kilgore@kilgore.trout",
|
||||
EmailVerified: true,
|
||||
ConnectorData: []byte(`{"test": "true"}`),
|
||||
}, true, nil
|
||||
}
|
||||
return identity, false, nil
|
||||
}
|
||||
|
||||
func (p passwordConnector) Prompt() string { return "" }
|
||||
|
||||
func (p passwordConnector) Refresh(_ context.Context, _ connector.Scopes, identity connector.Identity) (connector.Identity, error) {
|
||||
return identity, nil
|
||||
}
|
||||
|
|
|
@ -0,0 +1,298 @@
|
|||
package oauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"github.com/dexidp/dex/connector"
|
||||
"github.com/dexidp/dex/pkg/log"
|
||||
)
|
||||
|
||||
type oauthConnector struct {
|
||||
clientID string
|
||||
clientSecret string
|
||||
redirectURI string
|
||||
tokenURL string
|
||||
authorizationURL string
|
||||
userInfoURL string
|
||||
scopes []string
|
||||
userIDKey string
|
||||
userNameKey string
|
||||
preferredUsernameKey string
|
||||
emailKey string
|
||||
emailVerifiedKey string
|
||||
groupsKey string
|
||||
httpClient *http.Client
|
||||
logger log.Logger
|
||||
}
|
||||
|
||||
type connectorData struct {
|
||||
AccessToken string
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
ClientID string `json:"clientID"`
|
||||
ClientSecret string `json:"clientSecret"`
|
||||
RedirectURI string `json:"redirectURI"`
|
||||
TokenURL string `json:"tokenURL"`
|
||||
AuthorizationURL string `json:"authorizationURL"`
|
||||
UserInfoURL string `json:"userInfoURL"`
|
||||
Scopes []string `json:"scopes"`
|
||||
RootCAs []string `json:"rootCAs"`
|
||||
InsecureSkipVerify bool `json:"insecureSkipVerify"`
|
||||
UserIDKey string `json:"userIDKey"` // defaults to "id"
|
||||
ClaimMapping struct {
|
||||
UserNameKey string `json:"userNameKey"` // defaults to "user_name"
|
||||
PreferredUsernameKey string `json:"preferredUsernameKey"` // defaults to "preferred_username"
|
||||
GroupsKey string `json:"groupsKey"` // defaults to "groups"
|
||||
EmailKey string `json:"emailKey"` // defaults to "email"
|
||||
EmailVerifiedKey string `json:"emailVerifiedKey"` // defaults to "email_verified"
|
||||
} `json:"claimMapping"`
|
||||
}
|
||||
|
||||
func (c *Config) Open(id string, logger log.Logger) (connector.Connector, error) {
|
||||
var err error
|
||||
|
||||
userIDKey := c.UserIDKey
|
||||
if userIDKey == "" {
|
||||
userIDKey = "id"
|
||||
}
|
||||
|
||||
userNameKey := c.ClaimMapping.UserNameKey
|
||||
if userNameKey == "" {
|
||||
userNameKey = "user_name"
|
||||
}
|
||||
|
||||
preferredUsernameKey := c.ClaimMapping.PreferredUsernameKey
|
||||
if preferredUsernameKey == "" {
|
||||
preferredUsernameKey = "preferred_username"
|
||||
}
|
||||
|
||||
groupsKey := c.ClaimMapping.GroupsKey
|
||||
if groupsKey == "" {
|
||||
groupsKey = "groups"
|
||||
}
|
||||
|
||||
emailKey := c.ClaimMapping.EmailKey
|
||||
if emailKey == "" {
|
||||
emailKey = "email"
|
||||
}
|
||||
|
||||
emailVerifiedKey := c.ClaimMapping.EmailVerifiedKey
|
||||
if emailVerifiedKey == "" {
|
||||
emailVerifiedKey = "email_verified"
|
||||
}
|
||||
|
||||
oauthConn := &oauthConnector{
|
||||
clientID: c.ClientID,
|
||||
clientSecret: c.ClientSecret,
|
||||
tokenURL: c.TokenURL,
|
||||
authorizationURL: c.AuthorizationURL,
|
||||
userInfoURL: c.UserInfoURL,
|
||||
scopes: c.Scopes,
|
||||
redirectURI: c.RedirectURI,
|
||||
logger: logger,
|
||||
userIDKey: userIDKey,
|
||||
userNameKey: userNameKey,
|
||||
preferredUsernameKey: preferredUsernameKey,
|
||||
groupsKey: groupsKey,
|
||||
emailKey: emailKey,
|
||||
emailVerifiedKey: emailVerifiedKey,
|
||||
}
|
||||
|
||||
oauthConn.httpClient, err = newHTTPClient(c.RootCAs, c.InsecureSkipVerify)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return oauthConn, err
|
||||
}
|
||||
|
||||
func newHTTPClient(rootCAs []string, insecureSkipVerify bool) (*http.Client, error) {
|
||||
pool, err := x509.SystemCertPool()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tlsConfig := tls.Config{RootCAs: pool, InsecureSkipVerify: insecureSkipVerify}
|
||||
for _, rootCA := range rootCAs {
|
||||
rootCABytes, err := os.ReadFile(rootCA)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read root-ca: %v", err)
|
||||
}
|
||||
if !tlsConfig.RootCAs.AppendCertsFromPEM(rootCABytes) {
|
||||
return nil, fmt.Errorf("no certs found in root CA file %q", rootCA)
|
||||
}
|
||||
}
|
||||
|
||||
return &http.Client{
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tlsConfig,
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 30 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
DualStack: true,
|
||||
}).DialContext,
|
||||
MaxIdleConns: 100,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *oauthConnector) LoginURL(scopes connector.Scopes, callbackURL, state string) (string, error) {
|
||||
if c.redirectURI != callbackURL {
|
||||
return "", fmt.Errorf("expected callback URL %q did not match the URL in the config %q", callbackURL, c.redirectURI)
|
||||
}
|
||||
|
||||
oauth2Config := &oauth2.Config{
|
||||
ClientID: c.clientID,
|
||||
ClientSecret: c.clientSecret,
|
||||
Endpoint: oauth2.Endpoint{TokenURL: c.tokenURL, AuthURL: c.authorizationURL},
|
||||
RedirectURL: c.redirectURI,
|
||||
Scopes: c.scopes,
|
||||
}
|
||||
|
||||
return oauth2Config.AuthCodeURL(state), nil
|
||||
}
|
||||
|
||||
func (c *oauthConnector) HandleCallback(s connector.Scopes, r *http.Request) (identity connector.Identity, err error) {
|
||||
q := r.URL.Query()
|
||||
if errType := q.Get("error"); errType != "" {
|
||||
return identity, errors.New(q.Get("error_description"))
|
||||
}
|
||||
|
||||
oauth2Config := &oauth2.Config{
|
||||
ClientID: c.clientID,
|
||||
ClientSecret: c.clientSecret,
|
||||
Endpoint: oauth2.Endpoint{TokenURL: c.tokenURL, AuthURL: c.authorizationURL},
|
||||
RedirectURL: c.redirectURI,
|
||||
Scopes: c.scopes,
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), oauth2.HTTPClient, c.httpClient)
|
||||
|
||||
token, err := oauth2Config.Exchange(ctx, q.Get("code"))
|
||||
if err != nil {
|
||||
return identity, fmt.Errorf("OAuth connector: failed to get token: %v", err)
|
||||
}
|
||||
|
||||
client := oauth2.NewClient(ctx, oauth2.StaticTokenSource(token))
|
||||
|
||||
userInfoResp, err := client.Get(c.userInfoURL)
|
||||
if err != nil {
|
||||
return identity, fmt.Errorf("OAuth Connector: failed to execute request to userinfo: %v", err)
|
||||
}
|
||||
defer userInfoResp.Body.Close()
|
||||
|
||||
if userInfoResp.StatusCode != http.StatusOK {
|
||||
return identity, fmt.Errorf("OAuth Connector: failed to execute request to userinfo: status %d", userInfoResp.StatusCode)
|
||||
}
|
||||
|
||||
var userInfoResult map[string]interface{}
|
||||
err = json.NewDecoder(userInfoResp.Body).Decode(&userInfoResult)
|
||||
if err != nil {
|
||||
return identity, fmt.Errorf("OAuth Connector: failed to parse userinfo: %v", err)
|
||||
}
|
||||
|
||||
userID, found := userInfoResult[c.userIDKey]
|
||||
if !found {
|
||||
return identity, fmt.Errorf("OAuth Connector: not found %v claim", c.userIDKey)
|
||||
}
|
||||
|
||||
switch userID.(type) {
|
||||
case float64, int64, string:
|
||||
identity.UserID = fmt.Sprintf("%v", userID)
|
||||
default:
|
||||
return identity, fmt.Errorf("OAuth Connector: %v claim should be string or number, got %T", c.userIDKey, userID)
|
||||
}
|
||||
|
||||
identity.Username, _ = userInfoResult[c.userNameKey].(string)
|
||||
identity.PreferredUsername, _ = userInfoResult[c.preferredUsernameKey].(string)
|
||||
identity.Email, _ = userInfoResult[c.emailKey].(string)
|
||||
identity.EmailVerified, _ = userInfoResult[c.emailVerifiedKey].(bool)
|
||||
|
||||
if s.Groups {
|
||||
groups := map[string]struct{}{}
|
||||
|
||||
c.addGroupsFromMap(groups, userInfoResult)
|
||||
c.addGroupsFromToken(groups, token.AccessToken)
|
||||
|
||||
for groupName := range groups {
|
||||
identity.Groups = append(identity.Groups, groupName)
|
||||
}
|
||||
}
|
||||
|
||||
if s.OfflineAccess {
|
||||
data := connectorData{AccessToken: token.AccessToken}
|
||||
connData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return identity, fmt.Errorf("OAuth Connector: failed to parse connector data for offline access: %v", err)
|
||||
}
|
||||
identity.ConnectorData = connData
|
||||
}
|
||||
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
func (c *oauthConnector) addGroupsFromMap(groups map[string]struct{}, result map[string]interface{}) error {
|
||||
groupsClaim, ok := result[c.groupsKey].([]interface{})
|
||||
if !ok {
|
||||
return errors.New("cannot convert to slice")
|
||||
}
|
||||
|
||||
for _, group := range groupsClaim {
|
||||
if groupString, ok := group.(string); ok {
|
||||
groups[groupString] = struct{}{}
|
||||
}
|
||||
if groupMap, ok := group.(map[string]interface{}); ok {
|
||||
if groupName, ok := groupMap["name"].(string); ok {
|
||||
groups[groupName] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *oauthConnector) addGroupsFromToken(groups map[string]struct{}, token string) error {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) < 2 {
|
||||
return errors.New("invalid token")
|
||||
}
|
||||
|
||||
decoded, err := decode(parts[1])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var claimsMap map[string]interface{}
|
||||
err = json.Unmarshal(decoded, &claimsMap)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.addGroupsFromMap(groups, claimsMap)
|
||||
}
|
||||
|
||||
func decode(seg string) ([]byte, error) {
|
||||
if l := len(seg) % 4; l > 0 {
|
||||
seg += strings.Repeat("=", 4-l)
|
||||
}
|
||||
|
||||
return base64.URLEncoding.DecodeString(seg)
|
||||
}
|
|
@ -0,0 +1,299 @@
|
|||
package oauth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
jose "gopkg.in/square/go-jose.v2"
|
||||
|
||||
"github.com/dexidp/dex/connector"
|
||||
)
|
||||
|
||||
func TestOpen(t *testing.T) {
|
||||
tokenClaims := map[string]interface{}{}
|
||||
userInfoClaims := map[string]interface{}{}
|
||||
|
||||
testServer := testSetup(t, tokenClaims, userInfoClaims)
|
||||
defer testServer.Close()
|
||||
|
||||
conn := newConnector(t, testServer.URL)
|
||||
|
||||
sort.Strings(conn.scopes)
|
||||
|
||||
assert.Equal(t, conn.clientID, "testClient")
|
||||
assert.Equal(t, conn.clientSecret, "testSecret")
|
||||
assert.Equal(t, conn.redirectURI, testServer.URL+"/callback")
|
||||
assert.Equal(t, conn.tokenURL, testServer.URL+"/token")
|
||||
assert.Equal(t, conn.authorizationURL, testServer.URL+"/authorize")
|
||||
assert.Equal(t, conn.userInfoURL, testServer.URL+"/userinfo")
|
||||
assert.Equal(t, len(conn.scopes), 2)
|
||||
assert.Equal(t, conn.scopes[0], "groups")
|
||||
assert.Equal(t, conn.scopes[1], "openid")
|
||||
}
|
||||
|
||||
func TestLoginURL(t *testing.T) {
|
||||
tokenClaims := map[string]interface{}{}
|
||||
userInfoClaims := map[string]interface{}{}
|
||||
|
||||
testServer := testSetup(t, tokenClaims, userInfoClaims)
|
||||
defer testServer.Close()
|
||||
|
||||
conn := newConnector(t, testServer.URL)
|
||||
|
||||
loginURL, err := conn.LoginURL(connector.Scopes{}, conn.redirectURI, "some-state")
|
||||
assert.Equal(t, err, nil)
|
||||
|
||||
expectedURL, err := url.Parse(testServer.URL + "/authorize")
|
||||
assert.Equal(t, err, nil)
|
||||
|
||||
values := url.Values{}
|
||||
values.Add("client_id", "testClient")
|
||||
values.Add("redirect_uri", conn.redirectURI)
|
||||
values.Add("response_type", "code")
|
||||
values.Add("scope", "openid groups")
|
||||
values.Add("state", "some-state")
|
||||
expectedURL.RawQuery = values.Encode()
|
||||
|
||||
assert.Equal(t, loginURL, expectedURL.String())
|
||||
}
|
||||
|
||||
func TestHandleCallBackForGroupsInUserInfo(t *testing.T) {
|
||||
tokenClaims := map[string]interface{}{}
|
||||
|
||||
userInfoClaims := map[string]interface{}{
|
||||
"name": "test-name",
|
||||
"user_id_key": "test-user-id",
|
||||
"user_name_key": "test-username",
|
||||
"preferred_username": "test-preferred-username",
|
||||
"mail": "mod_mail",
|
||||
"has_verified_email": false,
|
||||
"groups_key": []string{"admin-group", "user-group"},
|
||||
}
|
||||
|
||||
testServer := testSetup(t, tokenClaims, userInfoClaims)
|
||||
defer testServer.Close()
|
||||
|
||||
conn := newConnector(t, testServer.URL)
|
||||
req := newRequestWithAuthCode(t, testServer.URL, "TestHandleCallBackForGroupsInUserInfo")
|
||||
|
||||
identity, err := conn.HandleCallback(connector.Scopes{Groups: true}, req)
|
||||
assert.Equal(t, err, nil)
|
||||
|
||||
sort.Strings(identity.Groups)
|
||||
assert.Equal(t, len(identity.Groups), 2)
|
||||
assert.Equal(t, identity.Groups[0], "admin-group")
|
||||
assert.Equal(t, identity.Groups[1], "user-group")
|
||||
assert.Equal(t, identity.UserID, "test-user-id")
|
||||
assert.Equal(t, identity.Username, "test-username")
|
||||
assert.Equal(t, identity.PreferredUsername, "test-preferred-username")
|
||||
assert.Equal(t, identity.Email, "mod_mail")
|
||||
assert.Equal(t, identity.EmailVerified, false)
|
||||
}
|
||||
|
||||
func TestHandleCallBackForGroupMapsInUserInfo(t *testing.T) {
|
||||
tokenClaims := map[string]interface{}{}
|
||||
|
||||
userInfoClaims := map[string]interface{}{
|
||||
"name": "test-name",
|
||||
"user_id_key": "test-user-id",
|
||||
"user_name_key": "test-username",
|
||||
"preferred_username": "test-preferred-username",
|
||||
"mail": "mod_mail",
|
||||
"has_verified_email": false,
|
||||
"groups_key": []interface{}{
|
||||
map[string]string{"name": "admin-group", "id": "111"},
|
||||
map[string]string{"name": "user-group", "id": "222"},
|
||||
},
|
||||
}
|
||||
|
||||
testServer := testSetup(t, tokenClaims, userInfoClaims)
|
||||
defer testServer.Close()
|
||||
|
||||
conn := newConnector(t, testServer.URL)
|
||||
req := newRequestWithAuthCode(t, testServer.URL, "TestHandleCallBackForGroupMapsInUserInfo")
|
||||
|
||||
identity, err := conn.HandleCallback(connector.Scopes{Groups: true}, req)
|
||||
assert.Equal(t, err, nil)
|
||||
|
||||
sort.Strings(identity.Groups)
|
||||
assert.Equal(t, len(identity.Groups), 2)
|
||||
assert.Equal(t, identity.Groups[0], "admin-group")
|
||||
assert.Equal(t, identity.Groups[1], "user-group")
|
||||
assert.Equal(t, identity.UserID, "test-user-id")
|
||||
assert.Equal(t, identity.Username, "test-username")
|
||||
assert.Equal(t, identity.PreferredUsername, "test-preferred-username")
|
||||
assert.Equal(t, identity.Email, "mod_mail")
|
||||
assert.Equal(t, identity.EmailVerified, false)
|
||||
}
|
||||
|
||||
func TestHandleCallBackForGroupsInToken(t *testing.T) {
|
||||
tokenClaims := map[string]interface{}{
|
||||
"groups_key": []string{"test-group"},
|
||||
}
|
||||
|
||||
userInfoClaims := map[string]interface{}{
|
||||
"name": "test-name",
|
||||
"user_id_key": "test-user-id",
|
||||
"user_name_key": "test-username",
|
||||
"preferred_username": "test-preferred-username",
|
||||
"email": "test-email",
|
||||
"email_verified": true,
|
||||
}
|
||||
|
||||
testServer := testSetup(t, tokenClaims, userInfoClaims)
|
||||
defer testServer.Close()
|
||||
|
||||
conn := newConnector(t, testServer.URL)
|
||||
req := newRequestWithAuthCode(t, testServer.URL, "TestHandleCallBackForGroupsInToken")
|
||||
|
||||
identity, err := conn.HandleCallback(connector.Scopes{Groups: true}, req)
|
||||
assert.Equal(t, err, nil)
|
||||
|
||||
assert.Equal(t, len(identity.Groups), 1)
|
||||
assert.Equal(t, identity.Groups[0], "test-group")
|
||||
assert.Equal(t, identity.PreferredUsername, "test-preferred-username")
|
||||
assert.Equal(t, identity.UserID, "test-user-id")
|
||||
assert.Equal(t, identity.Username, "test-username")
|
||||
assert.Equal(t, identity.Email, "")
|
||||
assert.Equal(t, identity.EmailVerified, false)
|
||||
}
|
||||
|
||||
func TestHandleCallbackForNumericUserID(t *testing.T) {
|
||||
tokenClaims := map[string]interface{}{}
|
||||
|
||||
userInfoClaims := map[string]interface{}{
|
||||
"name": "test-name",
|
||||
"user_id_key": 1000,
|
||||
"user_name_key": "test-username",
|
||||
"preferred_username": "test-preferred-username",
|
||||
"mail": "mod_mail",
|
||||
"has_verified_email": false,
|
||||
}
|
||||
|
||||
testServer := testSetup(t, tokenClaims, userInfoClaims)
|
||||
defer testServer.Close()
|
||||
|
||||
conn := newConnector(t, testServer.URL)
|
||||
req := newRequestWithAuthCode(t, testServer.URL, "TestHandleCallbackForNumericUserID")
|
||||
|
||||
identity, err := conn.HandleCallback(connector.Scopes{Groups: true}, req)
|
||||
assert.Equal(t, err, nil)
|
||||
|
||||
assert.Equal(t, identity.UserID, "1000")
|
||||
assert.Equal(t, identity.Username, "test-username")
|
||||
assert.Equal(t, identity.PreferredUsername, "test-preferred-username")
|
||||
assert.Equal(t, identity.Email, "mod_mail")
|
||||
assert.Equal(t, identity.EmailVerified, false)
|
||||
}
|
||||
|
||||
func testSetup(t *testing.T, tokenClaims map[string]interface{}, userInfoClaims map[string]interface{}) *httptest.Server {
|
||||
key, err := rsa.GenerateKey(rand.Reader, 1024)
|
||||
if err != nil {
|
||||
t.Fatal("Failed to generate rsa key", err)
|
||||
}
|
||||
|
||||
jwk := jose.JSONWebKey{
|
||||
Key: key,
|
||||
KeyID: "some-key",
|
||||
Algorithm: "RSA",
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
|
||||
token, err := newToken(&jwk, tokenClaims)
|
||||
if err != nil {
|
||||
t.Fatal("unable to generate token", err)
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(&map[string]string{
|
||||
"access_token": token,
|
||||
"id_token": token,
|
||||
"token_type": "Bearer",
|
||||
})
|
||||
})
|
||||
|
||||
mux.HandleFunc("/userinfo", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(userInfoClaims)
|
||||
})
|
||||
|
||||
return httptest.NewServer(mux)
|
||||
}
|
||||
|
||||
func newToken(key *jose.JSONWebKey, claims map[string]interface{}) (string, error) {
|
||||
signingKey := jose.SigningKey{Key: key, Algorithm: jose.RS256}
|
||||
|
||||
signer, err := jose.NewSigner(signingKey, &jose.SignerOptions{})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("new signer: %v", err)
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(claims)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshaling claims: %v", err)
|
||||
}
|
||||
|
||||
signature, err := signer.Sign(payload)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("signing payload: %v", err)
|
||||
}
|
||||
|
||||
return signature.CompactSerialize()
|
||||
}
|
||||
|
||||
func newConnector(t *testing.T, serverURL string) *oauthConnector {
|
||||
testConfig := Config{
|
||||
ClientID: "testClient",
|
||||
ClientSecret: "testSecret",
|
||||
RedirectURI: serverURL + "/callback",
|
||||
TokenURL: serverURL + "/token",
|
||||
AuthorizationURL: serverURL + "/authorize",
|
||||
UserInfoURL: serverURL + "/userinfo",
|
||||
Scopes: []string{"openid", "groups"},
|
||||
UserIDKey: "user_id_key",
|
||||
}
|
||||
|
||||
testConfig.ClaimMapping.UserNameKey = "user_name_key"
|
||||
testConfig.ClaimMapping.GroupsKey = "groups_key"
|
||||
testConfig.ClaimMapping.EmailKey = "mail"
|
||||
testConfig.ClaimMapping.EmailVerifiedKey = "has_verified_email"
|
||||
|
||||
log := logrus.New()
|
||||
|
||||
conn, err := testConfig.Open("id", log)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
oauthConn, ok := conn.(*oauthConnector)
|
||||
if !ok {
|
||||
t.Fatal(errors.New("failed to convert to oauthConnector"))
|
||||
}
|
||||
|
||||
return oauthConn
|
||||
}
|
||||
|
||||
func newRequestWithAuthCode(t *testing.T, serverURL string, code string) *http.Request {
|
||||
req, err := http.NewRequest("GET", serverURL, nil)
|
||||
if err != nil {
|
||||
t.Fatal("failed to create request", err)
|
||||
}
|
||||
|
||||
values := req.URL.Query()
|
||||
values.Add("code", code)
|
||||
req.URL.RawQuery = values.Encode()
|
||||
|
||||
return req
|
||||
}
|
|
@ -3,18 +3,19 @@ package oidc
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Sirupsen/logrus"
|
||||
"github.com/coreos/go-oidc"
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"github.com/coreos/dex/connector"
|
||||
"github.com/dexidp/dex/connector"
|
||||
"github.com/dexidp/dex/pkg/log"
|
||||
)
|
||||
|
||||
// Config holds configuration options for OpenID Connect logins.
|
||||
|
@ -33,16 +34,59 @@ type Config struct {
|
|||
|
||||
Scopes []string `json:"scopes"` // defaults to "profile" and "email"
|
||||
|
||||
// Override the value of email_verified to true in the returned claims
|
||||
InsecureSkipEmailVerified bool `json:"insecureSkipEmailVerified"`
|
||||
|
||||
// InsecureEnableGroups enables groups claims. This is disabled by default until https://github.com/dexidp/dex/issues/1065 is resolved
|
||||
InsecureEnableGroups bool `json:"insecureEnableGroups"`
|
||||
|
||||
// AcrValues (Authentication Context Class Reference Values) that specifies the Authentication Context Class Values
|
||||
// within the Authentication Request that the Authorization Server is being requested to use for
|
||||
// processing requests from this Client, with the values appearing in order of preference.
|
||||
AcrValues []string `json:"acrValues"`
|
||||
|
||||
// GetUserInfo uses the userinfo endpoint to get additional claims for
|
||||
// the token. This is especially useful where upstreams return "thin"
|
||||
// id tokens
|
||||
GetUserInfo bool `json:"getUserInfo"`
|
||||
|
||||
UserIDKey string `json:"userIDKey"`
|
||||
|
||||
UserNameKey string `json:"userNameKey"`
|
||||
|
||||
// PromptType will be used fot the prompt parameter (when offline_access, by default prompt=consent)
|
||||
PromptType string `json:"promptType"`
|
||||
|
||||
// OverrideClaimMapping will be used to override the options defined in claimMappings.
|
||||
// i.e. if there are 'email' and `preferred_email` claims available, by default Dex will always use the `email` claim independent of the ClaimMapping.EmailKey.
|
||||
// This setting allows you to override the default behavior of Dex and enforce the mappings defined in `claimMapping`.
|
||||
OverrideClaimMapping bool `json:"overrideClaimMapping"` // defaults to false
|
||||
|
||||
ClaimMapping struct {
|
||||
// Configurable key which contains the preferred username claims
|
||||
PreferredUsernameKey string `json:"preferred_username"` // defaults to "preferred_username"
|
||||
|
||||
// Configurable key which contains the email claims
|
||||
EmailKey string `json:"email"` // defaults to "email"
|
||||
|
||||
// Configurable key which contains the groups claims
|
||||
GroupsKey string `json:"groups"` // defaults to "groups"
|
||||
} `json:"claimMapping"`
|
||||
}
|
||||
|
||||
// Domains that don't support basic auth. golang.org/x/oauth2 has an internal
|
||||
// list, but it only matches specific URLs, not top level domains.
|
||||
var brokenAuthHeaderDomains = []string{
|
||||
// See: https://github.com/coreos/dex/issues/859
|
||||
// See: https://github.com/dexidp/dex/issues/859
|
||||
"okta.com",
|
||||
"oktapreview.com",
|
||||
}
|
||||
|
||||
// connectorData stores information for sessions authenticated by this connector
|
||||
type connectorData struct {
|
||||
RefreshToken []byte
|
||||
}
|
||||
|
||||
// Detect auth header provider issues for known providers. This lets users
|
||||
// avoid having to explicitly set "basicAuthUnsupported" in their config.
|
||||
//
|
||||
|
@ -58,21 +102,9 @@ func knownBrokenAuthHeaderProvider(issuerURL string) bool {
|
|||
return false
|
||||
}
|
||||
|
||||
// golang.org/x/oauth2 doesn't do internal locking. Need to do it in this
|
||||
// package ourselves and hope that other packages aren't calling it at the
|
||||
// same time.
|
||||
var registerMu = new(sync.Mutex)
|
||||
|
||||
func registerBrokenAuthHeaderProvider(url string) {
|
||||
registerMu.Lock()
|
||||
defer registerMu.Unlock()
|
||||
|
||||
oauth2.RegisterBrokenAuthHeaderProvider(url)
|
||||
}
|
||||
|
||||
// Open returns a connector which can be used to login users through an upstream
|
||||
// OpenID Connect provider.
|
||||
func (c *Config) Open(logger logrus.FieldLogger) (conn connector.Connector, err error) {
|
||||
func (c *Config) Open(id string, logger log.Logger) (conn connector.Connector, err error) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
provider, err := oidc.NewProvider(ctx, c.Issuer)
|
||||
|
@ -81,13 +113,15 @@ func (c *Config) Open(logger logrus.FieldLogger) (conn connector.Connector, err
|
|||
return nil, fmt.Errorf("failed to get provider: %v", err)
|
||||
}
|
||||
|
||||
endpoint := provider.Endpoint()
|
||||
|
||||
if c.BasicAuthUnsupported != nil {
|
||||
// Setting "basicAuthUnsupported" always overrides our detection.
|
||||
if *c.BasicAuthUnsupported {
|
||||
registerBrokenAuthHeaderProvider(provider.Endpoint().TokenURL)
|
||||
endpoint.AuthStyle = oauth2.AuthStyleInParams
|
||||
}
|
||||
} else if knownBrokenAuthHeaderProvider(c.Issuer) {
|
||||
registerBrokenAuthHeaderProvider(provider.Endpoint().TokenURL)
|
||||
endpoint.AuthStyle = oauth2.AuthStyleInParams
|
||||
}
|
||||
|
||||
scopes := []string{oidc.ScopeOpenID}
|
||||
|
@ -97,21 +131,38 @@ func (c *Config) Open(logger logrus.FieldLogger) (conn connector.Connector, err
|
|||
scopes = append(scopes, "profile", "email")
|
||||
}
|
||||
|
||||
// PromptType should be "consent" by default, if not set
|
||||
if c.PromptType == "" {
|
||||
c.PromptType = "consent"
|
||||
}
|
||||
|
||||
clientID := c.ClientID
|
||||
return &oidcConnector{
|
||||
provider: provider,
|
||||
redirectURI: c.RedirectURI,
|
||||
oauth2Config: &oauth2.Config{
|
||||
ClientID: clientID,
|
||||
ClientSecret: c.ClientSecret,
|
||||
Endpoint: provider.Endpoint(),
|
||||
Endpoint: endpoint,
|
||||
Scopes: scopes,
|
||||
RedirectURL: c.RedirectURI,
|
||||
},
|
||||
verifier: provider.Verifier(
|
||||
&oidc.Config{ClientID: clientID},
|
||||
),
|
||||
logger: logger,
|
||||
cancel: cancel,
|
||||
logger: logger,
|
||||
cancel: cancel,
|
||||
insecureSkipEmailVerified: c.InsecureSkipEmailVerified,
|
||||
insecureEnableGroups: c.InsecureEnableGroups,
|
||||
acrValues: c.AcrValues,
|
||||
getUserInfo: c.GetUserInfo,
|
||||
promptType: c.PromptType,
|
||||
userIDKey: c.UserIDKey,
|
||||
userNameKey: c.UserNameKey,
|
||||
overrideClaimMapping: c.OverrideClaimMapping,
|
||||
preferredUsernameKey: c.ClaimMapping.PreferredUsernameKey,
|
||||
emailKey: c.ClaimMapping.EmailKey,
|
||||
groupsKey: c.ClaimMapping.GroupsKey,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
@ -121,12 +172,23 @@ var (
|
|||
)
|
||||
|
||||
type oidcConnector struct {
|
||||
redirectURI string
|
||||
oauth2Config *oauth2.Config
|
||||
verifier *oidc.IDTokenVerifier
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
logger logrus.FieldLogger
|
||||
provider *oidc.Provider
|
||||
redirectURI string
|
||||
oauth2Config *oauth2.Config
|
||||
verifier *oidc.IDTokenVerifier
|
||||
cancel context.CancelFunc
|
||||
logger log.Logger
|
||||
insecureSkipEmailVerified bool
|
||||
insecureEnableGroups bool
|
||||
acrValues []string
|
||||
getUserInfo bool
|
||||
promptType string
|
||||
userIDKey string
|
||||
userNameKey string
|
||||
overrideClaimMapping bool
|
||||
preferredUsernameKey string
|
||||
emailKey string
|
||||
groupsKey string
|
||||
}
|
||||
|
||||
func (c *oidcConnector) Close() error {
|
||||
|
@ -136,9 +198,20 @@ func (c *oidcConnector) Close() error {
|
|||
|
||||
func (c *oidcConnector) LoginURL(s connector.Scopes, callbackURL, state string) (string, error) {
|
||||
if c.redirectURI != callbackURL {
|
||||
return "", fmt.Errorf("expected callback URL did not match the URL in the config")
|
||||
return "", fmt.Errorf("expected callback URL %q did not match the URL in the config %q", callbackURL, c.redirectURI)
|
||||
}
|
||||
return c.oauth2Config.AuthCodeURL(state), nil
|
||||
|
||||
var opts []oauth2.AuthCodeOption
|
||||
|
||||
if len(c.acrValues) > 0 {
|
||||
acrValues := strings.Join(c.acrValues, " ")
|
||||
opts = append(opts, oauth2.SetAuthURLParam("acr_values", acrValues))
|
||||
}
|
||||
|
||||
if s.OfflineAccess {
|
||||
opts = append(opts, oauth2.AccessTypeOffline, oauth2.SetAuthURLParam("prompt", c.promptType))
|
||||
}
|
||||
return c.oauth2Config.AuthCodeURL(state, opts...), nil
|
||||
}
|
||||
|
||||
type oauth2Error struct {
|
||||
|
@ -153,6 +226,13 @@ func (e *oauth2Error) Error() string {
|
|||
return e.error + ": " + e.errorDescription
|
||||
}
|
||||
|
||||
type caller uint
|
||||
|
||||
const (
|
||||
createCaller caller = iota
|
||||
refreshCaller
|
||||
)
|
||||
|
||||
func (c *oidcConnector) HandleCallback(s connector.Scopes, r *http.Request) (identity connector.Identity, err error) {
|
||||
q := r.URL.Query()
|
||||
if errType := q.Get("error"); errType != "" {
|
||||
|
@ -162,35 +242,152 @@ func (c *oidcConnector) HandleCallback(s connector.Scopes, r *http.Request) (ide
|
|||
if err != nil {
|
||||
return identity, fmt.Errorf("oidc: failed to get token: %v", err)
|
||||
}
|
||||
return c.createIdentity(r.Context(), identity, token, createCaller)
|
||||
}
|
||||
|
||||
// Refresh is used to refresh a session with the refresh token provided by the IdP
|
||||
func (c *oidcConnector) Refresh(ctx context.Context, s connector.Scopes, identity connector.Identity) (connector.Identity, error) {
|
||||
cd := connectorData{}
|
||||
err := json.Unmarshal(identity.ConnectorData, &cd)
|
||||
if err != nil {
|
||||
return identity, fmt.Errorf("oidc: failed to unmarshal connector data: %v", err)
|
||||
}
|
||||
|
||||
t := &oauth2.Token{
|
||||
RefreshToken: string(cd.RefreshToken),
|
||||
Expiry: time.Now().Add(-time.Hour),
|
||||
}
|
||||
token, err := c.oauth2Config.TokenSource(ctx, t).Token()
|
||||
if err != nil {
|
||||
return identity, fmt.Errorf("oidc: failed to get refresh token: %v", err)
|
||||
}
|
||||
return c.createIdentity(ctx, identity, token, refreshCaller)
|
||||
}
|
||||
|
||||
func (c *oidcConnector) createIdentity(ctx context.Context, identity connector.Identity, token *oauth2.Token, caller caller) (connector.Identity, error) {
|
||||
var claims map[string]interface{}
|
||||
|
||||
rawIDToken, ok := token.Extra("id_token").(string)
|
||||
if !ok {
|
||||
if ok {
|
||||
idToken, err := c.verifier.Verify(ctx, rawIDToken)
|
||||
if err != nil {
|
||||
return identity, fmt.Errorf("oidc: failed to verify ID Token: %v", err)
|
||||
}
|
||||
|
||||
if err := idToken.Claims(&claims); err != nil {
|
||||
return identity, fmt.Errorf("oidc: failed to decode claims: %v", err)
|
||||
}
|
||||
} else if caller != refreshCaller {
|
||||
// ID tokens aren't mandatory in the reply when using a refresh_token grant
|
||||
return identity, errors.New("oidc: no id_token in token response")
|
||||
}
|
||||
idToken, err := c.verifier.Verify(r.Context(), rawIDToken)
|
||||
if err != nil {
|
||||
return identity, fmt.Errorf("oidc: failed to verify ID Token: %v", err)
|
||||
|
||||
// We immediately want to run getUserInfo if configured before we validate the claims
|
||||
if c.getUserInfo {
|
||||
userInfo, err := c.provider.UserInfo(ctx, oauth2.StaticTokenSource(token))
|
||||
if err != nil {
|
||||
return identity, fmt.Errorf("oidc: error loading userinfo: %v", err)
|
||||
}
|
||||
if err := userInfo.Claims(&claims); err != nil {
|
||||
return identity, fmt.Errorf("oidc: failed to decode userinfo claims: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
var claims struct {
|
||||
Username string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
EmailVerified bool `json:"email_verified"`
|
||||
const subjectClaimKey = "sub"
|
||||
subject, found := claims[subjectClaimKey].(string)
|
||||
if !found {
|
||||
return identity, fmt.Errorf("missing \"%s\" claim", subjectClaimKey)
|
||||
}
|
||||
if err := idToken.Claims(&claims); err != nil {
|
||||
return identity, fmt.Errorf("oidc: failed to decode claims: %v", err)
|
||||
|
||||
userNameKey := "name"
|
||||
if c.userNameKey != "" {
|
||||
userNameKey = c.userNameKey
|
||||
}
|
||||
name, found := claims[userNameKey].(string)
|
||||
if !found {
|
||||
return identity, fmt.Errorf("missing \"%s\" claim", userNameKey)
|
||||
}
|
||||
|
||||
preferredUsername, found := claims["preferred_username"].(string)
|
||||
if (!found || c.overrideClaimMapping) && c.preferredUsernameKey != "" {
|
||||
preferredUsername, _ = claims[c.preferredUsernameKey].(string)
|
||||
}
|
||||
|
||||
hasEmailScope := false
|
||||
for _, s := range c.oauth2Config.Scopes {
|
||||
if s == "email" {
|
||||
hasEmailScope = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
var email string
|
||||
emailKey := "email"
|
||||
email, found = claims[emailKey].(string)
|
||||
if (!found || c.overrideClaimMapping) && c.emailKey != "" {
|
||||
emailKey = c.emailKey
|
||||
email, found = claims[emailKey].(string)
|
||||
}
|
||||
|
||||
if !found && hasEmailScope {
|
||||
return identity, fmt.Errorf("missing email claim, not found \"%s\" key", emailKey)
|
||||
}
|
||||
|
||||
emailVerified, found := claims["email_verified"].(bool)
|
||||
if !found {
|
||||
if c.insecureSkipEmailVerified {
|
||||
emailVerified = true
|
||||
} else if hasEmailScope {
|
||||
return identity, errors.New("missing \"email_verified\" claim")
|
||||
}
|
||||
}
|
||||
|
||||
var groups []string
|
||||
if c.insecureEnableGroups {
|
||||
groupsKey := "groups"
|
||||
vs, found := claims[groupsKey].([]interface{})
|
||||
if (!found || c.overrideClaimMapping) && c.groupsKey != "" {
|
||||
groupsKey = c.groupsKey
|
||||
vs, found = claims[groupsKey].([]interface{})
|
||||
}
|
||||
|
||||
if found {
|
||||
for _, v := range vs {
|
||||
if s, ok := v.(string); ok {
|
||||
groups = append(groups, s)
|
||||
} else {
|
||||
return identity, fmt.Errorf("malformed \"%v\" claim", groupsKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cd := connectorData{
|
||||
RefreshToken: []byte(token.RefreshToken),
|
||||
}
|
||||
|
||||
connData, err := json.Marshal(&cd)
|
||||
if err != nil {
|
||||
return identity, fmt.Errorf("oidc: failed to encode connector data: %v", err)
|
||||
}
|
||||
|
||||
identity = connector.Identity{
|
||||
UserID: idToken.Subject,
|
||||
Username: claims.Username,
|
||||
Email: claims.Email,
|
||||
EmailVerified: claims.EmailVerified,
|
||||
UserID: subject,
|
||||
Username: name,
|
||||
PreferredUsername: preferredUsername,
|
||||
Email: email,
|
||||
EmailVerified: emailVerified,
|
||||
Groups: groups,
|
||||
ConnectorData: connData,
|
||||
}
|
||||
|
||||
if c.userIDKey != "" {
|
||||
userID, found := claims[c.userIDKey].(string)
|
||||
if !found {
|
||||
return identity, fmt.Errorf("oidc: not found %v claim", c.userIDKey)
|
||||
}
|
||||
identity.UserID = userID
|
||||
}
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
// Refresh is implemented for backwards compatibility, even though it's a no-op.
|
||||
func (c *oidcConnector) Refresh(ctx context.Context, s connector.Scopes, identity connector.Identity) (connector.Identity, error) {
|
||||
return identity, nil
|
||||
}
|
||||
|
|
|
@ -1,6 +1,26 @@
|
|||
package oidc
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"gopkg.in/square/go-jose.v2"
|
||||
|
||||
"github.com/dexidp/dex/connector"
|
||||
)
|
||||
|
||||
func TestKnownBrokenAuthHeaderProvider(t *testing.T) {
|
||||
tests := []struct {
|
||||
|
@ -21,3 +41,516 @@ func TestKnownBrokenAuthHeaderProvider(t *testing.T) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCallback(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
userIDKey string
|
||||
userNameKey string
|
||||
overrideClaimMapping bool
|
||||
preferredUsernameKey string
|
||||
emailKey string
|
||||
groupsKey string
|
||||
insecureSkipEmailVerified bool
|
||||
scopes []string
|
||||
expectUserID string
|
||||
expectUserName string
|
||||
expectGroups []string
|
||||
expectPreferredUsername string
|
||||
expectedEmailField string
|
||||
token map[string]interface{}
|
||||
}{
|
||||
{
|
||||
name: "simpleCase",
|
||||
userIDKey: "", // not configured
|
||||
userNameKey: "", // not configured
|
||||
expectUserID: "subvalue",
|
||||
expectUserName: "namevalue",
|
||||
expectGroups: []string{"group1", "group2"},
|
||||
expectedEmailField: "emailvalue",
|
||||
token: map[string]interface{}{
|
||||
"sub": "subvalue",
|
||||
"name": "namevalue",
|
||||
"groups": []string{"group1", "group2"},
|
||||
"email": "emailvalue",
|
||||
"email_verified": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "customEmailClaim",
|
||||
userIDKey: "", // not configured
|
||||
userNameKey: "", // not configured
|
||||
emailKey: "mail",
|
||||
expectUserID: "subvalue",
|
||||
expectUserName: "namevalue",
|
||||
expectedEmailField: "emailvalue",
|
||||
token: map[string]interface{}{
|
||||
"sub": "subvalue",
|
||||
"name": "namevalue",
|
||||
"mail": "emailvalue",
|
||||
"email_verified": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "overrideWithCustomEmailClaim",
|
||||
userIDKey: "", // not configured
|
||||
userNameKey: "", // not configured
|
||||
overrideClaimMapping: true,
|
||||
emailKey: "custommail",
|
||||
expectUserID: "subvalue",
|
||||
expectUserName: "namevalue",
|
||||
expectedEmailField: "customemailvalue",
|
||||
token: map[string]interface{}{
|
||||
"sub": "subvalue",
|
||||
"name": "namevalue",
|
||||
"email": "emailvalue",
|
||||
"custommail": "customemailvalue",
|
||||
"email_verified": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "email_verified not in claims, configured to be skipped",
|
||||
insecureSkipEmailVerified: true,
|
||||
expectUserID: "subvalue",
|
||||
expectUserName: "namevalue",
|
||||
expectedEmailField: "emailvalue",
|
||||
token: map[string]interface{}{
|
||||
"sub": "subvalue",
|
||||
"name": "namevalue",
|
||||
"email": "emailvalue",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "withUserIDKey",
|
||||
userIDKey: "name",
|
||||
expectUserID: "namevalue",
|
||||
expectUserName: "namevalue",
|
||||
expectedEmailField: "emailvalue",
|
||||
token: map[string]interface{}{
|
||||
"sub": "subvalue",
|
||||
"name": "namevalue",
|
||||
"email": "emailvalue",
|
||||
"email_verified": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "withUserNameKey",
|
||||
userNameKey: "user_name",
|
||||
expectUserID: "subvalue",
|
||||
expectUserName: "username",
|
||||
expectedEmailField: "emailvalue",
|
||||
token: map[string]interface{}{
|
||||
"sub": "subvalue",
|
||||
"user_name": "username",
|
||||
"email": "emailvalue",
|
||||
"email_verified": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "withPreferredUsernameKey",
|
||||
preferredUsernameKey: "username_key",
|
||||
expectUserID: "subvalue",
|
||||
expectUserName: "namevalue",
|
||||
expectPreferredUsername: "username_value",
|
||||
expectedEmailField: "emailvalue",
|
||||
token: map[string]interface{}{
|
||||
"sub": "subvalue",
|
||||
"name": "namevalue",
|
||||
"username_key": "username_value",
|
||||
"email": "emailvalue",
|
||||
"email_verified": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "withoutPreferredUsernameKeyAndBackendReturns",
|
||||
expectUserID: "subvalue",
|
||||
expectUserName: "namevalue",
|
||||
expectPreferredUsername: "preferredusernamevalue",
|
||||
expectedEmailField: "emailvalue",
|
||||
token: map[string]interface{}{
|
||||
"sub": "subvalue",
|
||||
"name": "namevalue",
|
||||
"preferred_username": "preferredusernamevalue",
|
||||
"email": "emailvalue",
|
||||
"email_verified": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "withoutPreferredUsernameKeyAndBackendNotReturn",
|
||||
expectUserID: "subvalue",
|
||||
expectUserName: "namevalue",
|
||||
expectPreferredUsername: "",
|
||||
expectedEmailField: "emailvalue",
|
||||
token: map[string]interface{}{
|
||||
"sub": "subvalue",
|
||||
"name": "namevalue",
|
||||
"email": "emailvalue",
|
||||
"email_verified": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "emptyEmailScope",
|
||||
expectUserID: "subvalue",
|
||||
expectUserName: "namevalue",
|
||||
expectedEmailField: "",
|
||||
scopes: []string{"groups"},
|
||||
insecureSkipEmailVerified: true,
|
||||
token: map[string]interface{}{
|
||||
"sub": "subvalue",
|
||||
"name": "namevalue",
|
||||
"user_name": "username",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "emptyEmailScopeButEmailProvided",
|
||||
expectUserID: "subvalue",
|
||||
expectUserName: "namevalue",
|
||||
expectedEmailField: "emailvalue",
|
||||
scopes: []string{"groups"},
|
||||
insecureSkipEmailVerified: true,
|
||||
token: map[string]interface{}{
|
||||
"sub": "subvalue",
|
||||
"name": "namevalue",
|
||||
"user_name": "username",
|
||||
"email": "emailvalue",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "customGroupsKey",
|
||||
groupsKey: "cognito:groups",
|
||||
expectUserID: "subvalue",
|
||||
expectUserName: "namevalue",
|
||||
expectedEmailField: "emailvalue",
|
||||
expectGroups: []string{"group3", "group4"},
|
||||
scopes: []string{"groups"},
|
||||
insecureSkipEmailVerified: true,
|
||||
token: map[string]interface{}{
|
||||
"sub": "subvalue",
|
||||
"name": "namevalue",
|
||||
"user_name": "username",
|
||||
"email": "emailvalue",
|
||||
"cognito:groups": []string{"group3", "group4"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "customGroupsKeyButGroupsProvided",
|
||||
groupsKey: "cognito:groups",
|
||||
expectUserID: "subvalue",
|
||||
expectUserName: "namevalue",
|
||||
expectedEmailField: "emailvalue",
|
||||
expectGroups: []string{"group1", "group2"},
|
||||
scopes: []string{"groups"},
|
||||
insecureSkipEmailVerified: true,
|
||||
token: map[string]interface{}{
|
||||
"sub": "subvalue",
|
||||
"name": "namevalue",
|
||||
"user_name": "username",
|
||||
"email": "emailvalue",
|
||||
"groups": []string{"group1", "group2"},
|
||||
"cognito:groups": []string{"group3", "group4"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "customGroupsKeyDespiteGroupsProvidedButOverride",
|
||||
overrideClaimMapping: true,
|
||||
groupsKey: "cognito:groups",
|
||||
expectUserID: "subvalue",
|
||||
expectUserName: "namevalue",
|
||||
expectedEmailField: "emailvalue",
|
||||
expectGroups: []string{"group3", "group4"},
|
||||
scopes: []string{"groups"},
|
||||
insecureSkipEmailVerified: true,
|
||||
token: map[string]interface{}{
|
||||
"sub": "subvalue",
|
||||
"name": "namevalue",
|
||||
"user_name": "username",
|
||||
"email": "emailvalue",
|
||||
"groups": []string{"group1", "group2"},
|
||||
"cognito:groups": []string{"group3", "group4"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
idTokenDesired := true
|
||||
testServer, err := setupServer(tc.token, idTokenDesired)
|
||||
if err != nil {
|
||||
t.Fatal("failed to setup test server", err)
|
||||
}
|
||||
defer testServer.Close()
|
||||
|
||||
var scopes []string
|
||||
if len(tc.scopes) > 0 {
|
||||
scopes = tc.scopes
|
||||
} else {
|
||||
scopes = []string{"email", "groups"}
|
||||
}
|
||||
serverURL := testServer.URL
|
||||
basicAuth := true
|
||||
config := Config{
|
||||
Issuer: serverURL,
|
||||
ClientID: "clientID",
|
||||
ClientSecret: "clientSecret",
|
||||
Scopes: scopes,
|
||||
RedirectURI: fmt.Sprintf("%s/callback", serverURL),
|
||||
UserIDKey: tc.userIDKey,
|
||||
UserNameKey: tc.userNameKey,
|
||||
InsecureSkipEmailVerified: tc.insecureSkipEmailVerified,
|
||||
InsecureEnableGroups: true,
|
||||
BasicAuthUnsupported: &basicAuth,
|
||||
OverrideClaimMapping: tc.overrideClaimMapping,
|
||||
}
|
||||
config.ClaimMapping.PreferredUsernameKey = tc.preferredUsernameKey
|
||||
config.ClaimMapping.EmailKey = tc.emailKey
|
||||
config.ClaimMapping.GroupsKey = tc.groupsKey
|
||||
|
||||
conn, err := newConnector(config)
|
||||
if err != nil {
|
||||
t.Fatal("failed to create new connector", err)
|
||||
}
|
||||
|
||||
req, err := newRequestWithAuthCode(testServer.URL, "someCode")
|
||||
if err != nil {
|
||||
t.Fatal("failed to create request", err)
|
||||
}
|
||||
|
||||
identity, err := conn.HandleCallback(connector.Scopes{Groups: true}, req)
|
||||
if err != nil {
|
||||
t.Fatal("handle callback failed", err)
|
||||
}
|
||||
|
||||
expectEquals(t, identity.UserID, tc.expectUserID)
|
||||
expectEquals(t, identity.Username, tc.expectUserName)
|
||||
expectEquals(t, identity.PreferredUsername, tc.expectPreferredUsername)
|
||||
expectEquals(t, identity.Email, tc.expectedEmailField)
|
||||
expectEquals(t, identity.EmailVerified, true)
|
||||
expectEquals(t, identity.Groups, tc.expectGroups)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefresh(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
expectUserID string
|
||||
expectUserName string
|
||||
idTokenDesired bool
|
||||
token map[string]interface{}
|
||||
}{
|
||||
{
|
||||
name: "IDTokenOnRefresh",
|
||||
expectUserID: "subvalue",
|
||||
expectUserName: "namevalue",
|
||||
idTokenDesired: true,
|
||||
token: map[string]interface{}{
|
||||
"sub": "subvalue",
|
||||
"name": "namevalue",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "NoIDTokenOnRefresh",
|
||||
expectUserID: "subvalue",
|
||||
expectUserName: "namevalue",
|
||||
idTokenDesired: false,
|
||||
token: map[string]interface{}{
|
||||
"sub": "subvalue",
|
||||
"name": "namevalue",
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
testServer, err := setupServer(tc.token, tc.idTokenDesired)
|
||||
if err != nil {
|
||||
t.Fatal("failed to setup test server", err)
|
||||
}
|
||||
defer testServer.Close()
|
||||
|
||||
scopes := []string{"openid", "offline_access"}
|
||||
serverURL := testServer.URL
|
||||
config := Config{
|
||||
Issuer: serverURL,
|
||||
ClientID: "clientID",
|
||||
ClientSecret: "clientSecret",
|
||||
Scopes: scopes,
|
||||
RedirectURI: fmt.Sprintf("%s/callback", serverURL),
|
||||
GetUserInfo: true,
|
||||
}
|
||||
|
||||
conn, err := newConnector(config)
|
||||
if err != nil {
|
||||
t.Fatal("failed to create new connector", err)
|
||||
}
|
||||
|
||||
req, err := newRequestWithAuthCode(testServer.URL, "someCode")
|
||||
if err != nil {
|
||||
t.Fatal("failed to create request", err)
|
||||
}
|
||||
|
||||
refreshTokenStr := "{\"RefreshToken\":\"asdf\"}"
|
||||
refreshToken := []byte(refreshTokenStr)
|
||||
|
||||
identity := connector.Identity{
|
||||
UserID: tc.expectUserID,
|
||||
Username: tc.expectUserName,
|
||||
ConnectorData: refreshToken,
|
||||
}
|
||||
|
||||
refreshIdentity, err := conn.Refresh(req.Context(), connector.Scopes{OfflineAccess: true}, identity)
|
||||
if err != nil {
|
||||
t.Fatal("Refresh failed", err)
|
||||
}
|
||||
|
||||
expectEquals(t, refreshIdentity.UserID, tc.expectUserID)
|
||||
expectEquals(t, refreshIdentity.Username, tc.expectUserName)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func setupServer(tok map[string]interface{}, idTokenDesired bool) (*httptest.Server, error) {
|
||||
key, err := rsa.GenerateKey(rand.Reader, 1024)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate rsa key: %v", err)
|
||||
}
|
||||
|
||||
jwk := jose.JSONWebKey{
|
||||
Key: key,
|
||||
KeyID: "keyId",
|
||||
Algorithm: "RSA",
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("/keys", func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(&map[string]interface{}{
|
||||
"keys": []map[string]interface{}{{
|
||||
"alg": jwk.Algorithm,
|
||||
"kty": jwk.Algorithm,
|
||||
"kid": jwk.KeyID,
|
||||
"n": n(&key.PublicKey),
|
||||
"e": e(&key.PublicKey),
|
||||
}},
|
||||
})
|
||||
})
|
||||
|
||||
mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
|
||||
url := fmt.Sprintf("http://%s", r.Host)
|
||||
tok["iss"] = url
|
||||
tok["exp"] = time.Now().Add(time.Hour).Unix()
|
||||
tok["aud"] = "clientID"
|
||||
token, err := newToken(&jwk, tok)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
if idTokenDesired {
|
||||
json.NewEncoder(w).Encode(&map[string]string{
|
||||
"access_token": token,
|
||||
"id_token": token,
|
||||
"token_type": "Bearer",
|
||||
})
|
||||
} else {
|
||||
json.NewEncoder(w).Encode(&map[string]string{
|
||||
"access_token": token,
|
||||
"token_type": "Bearer",
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
mux.HandleFunc("/userinfo", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(tok)
|
||||
})
|
||||
|
||||
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
|
||||
url := fmt.Sprintf("http://%s", r.Host)
|
||||
|
||||
json.NewEncoder(w).Encode(&map[string]string{
|
||||
"issuer": url,
|
||||
"token_endpoint": fmt.Sprintf("%s/token", url),
|
||||
"authorization_endpoint": fmt.Sprintf("%s/authorize", url),
|
||||
"userinfo_endpoint": fmt.Sprintf("%s/userinfo", url),
|
||||
"jwks_uri": fmt.Sprintf("%s/keys", url),
|
||||
})
|
||||
})
|
||||
|
||||
return httptest.NewServer(mux), nil
|
||||
}
|
||||
|
||||
func newToken(key *jose.JSONWebKey, claims map[string]interface{}) (string, error) {
|
||||
signingKey := jose.SigningKey{
|
||||
Key: key,
|
||||
Algorithm: jose.RS256,
|
||||
}
|
||||
|
||||
signer, err := jose.NewSigner(signingKey, &jose.SignerOptions{})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create new signer: %v", err)
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(claims)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal claims: %v", err)
|
||||
}
|
||||
|
||||
signature, err := signer.Sign(payload)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to sign: %v", err)
|
||||
}
|
||||
return signature.CompactSerialize()
|
||||
}
|
||||
|
||||
func newConnector(config Config) (*oidcConnector, error) {
|
||||
logger := logrus.New()
|
||||
conn, err := config.Open("id", logger)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to open: %v", err)
|
||||
}
|
||||
|
||||
oidcConn, ok := conn.(*oidcConnector)
|
||||
if !ok {
|
||||
return nil, errors.New("failed to convert to oidcConnector")
|
||||
}
|
||||
|
||||
return oidcConn, nil
|
||||
}
|
||||
|
||||
func newRequestWithAuthCode(serverURL string, code string) (*http.Request, error) {
|
||||
req, err := http.NewRequest("GET", serverURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %v", err)
|
||||
}
|
||||
|
||||
values := req.URL.Query()
|
||||
values.Add("code", code)
|
||||
req.URL.RawQuery = values.Encode()
|
||||
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func n(pub *rsa.PublicKey) string {
|
||||
return encode(pub.N.Bytes())
|
||||
}
|
||||
|
||||
func e(pub *rsa.PublicKey) string {
|
||||
data := make([]byte, 8)
|
||||
binary.BigEndian.PutUint64(data, uint64(pub.E))
|
||||
return encode(bytes.TrimLeft(data, "\x00"))
|
||||
}
|
||||
|
||||
func encode(payload []byte) string {
|
||||
result := base64.URLEncoding.EncodeToString(payload)
|
||||
return strings.TrimRight(result, "=")
|
||||
}
|
||||
|
||||
func expectEquals(t *testing.T, a interface{}, b interface{}) {
|
||||
if !reflect.DeepEqual(a, b) {
|
||||
t.Errorf("Expected %+v to equal %+v", a, b)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,297 @@
|
|||
package openshift
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"github.com/dexidp/dex/connector"
|
||||
"github.com/dexidp/dex/pkg/groups"
|
||||
"github.com/dexidp/dex/pkg/log"
|
||||
"github.com/dexidp/dex/storage/kubernetes/k8sapi"
|
||||
)
|
||||
|
||||
const (
|
||||
wellKnownURLPath = "/.well-known/oauth-authorization-server"
|
||||
usersURLPath = "/apis/user.openshift.io/v1/users/~"
|
||||
)
|
||||
|
||||
// Config holds configuration options for OpenShift login
|
||||
type Config struct {
|
||||
Issuer string `json:"issuer"`
|
||||
ClientID string `json:"clientID"`
|
||||
ClientSecret string `json:"clientSecret"`
|
||||
RedirectURI string `json:"redirectURI"`
|
||||
Groups []string `json:"groups"`
|
||||
InsecureCA bool `json:"insecureCA"`
|
||||
RootCA string `json:"rootCA"`
|
||||
}
|
||||
|
||||
var (
|
||||
_ connector.CallbackConnector = (*openshiftConnector)(nil)
|
||||
_ connector.RefreshConnector = (*openshiftConnector)(nil)
|
||||
)
|
||||
|
||||
type openshiftConnector struct {
|
||||
apiURL string
|
||||
redirectURI string
|
||||
clientID string
|
||||
clientSecret string
|
||||
cancel context.CancelFunc
|
||||
logger log.Logger
|
||||
httpClient *http.Client
|
||||
oauth2Config *oauth2.Config
|
||||
insecureCA bool
|
||||
rootCA string
|
||||
groups []string
|
||||
}
|
||||
|
||||
type user struct {
|
||||
k8sapi.TypeMeta `json:",inline"`
|
||||
k8sapi.ObjectMeta `json:"metadata,omitempty"`
|
||||
Identities []string `json:"identities" protobuf:"bytes,3,rep,name=identities"`
|
||||
FullName string `json:"fullName,omitempty" protobuf:"bytes,2,opt,name=fullName"`
|
||||
Groups []string `json:"groups" protobuf:"bytes,4,rep,name=groups"`
|
||||
}
|
||||
|
||||
// Open returns a connector which can be used to login users through an upstream
|
||||
// OpenShift OAuth2 provider.
|
||||
func (c *Config) Open(id string, logger log.Logger) (conn connector.Connector, err error) {
|
||||
httpClient, err := newHTTPClient(c.InsecureCA, c.RootCA)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create HTTP client: %w", err)
|
||||
}
|
||||
|
||||
return c.OpenWithHTTPClient(id, logger, httpClient)
|
||||
}
|
||||
|
||||
// OpenWithHTTPClient returns a connector which can be used to login users through an upstream
|
||||
// OpenShift OAuth2 provider. It provides the ability to inject a http.Client.
|
||||
func (c *Config) OpenWithHTTPClient(id string, logger log.Logger,
|
||||
httpClient *http.Client,
|
||||
) (conn connector.Connector, err error) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
wellKnownURL := strings.TrimSuffix(c.Issuer, "/") + wellKnownURLPath
|
||||
req, err := http.NewRequest(http.MethodGet, wellKnownURL, nil)
|
||||
|
||||
openshiftConnector := openshiftConnector{
|
||||
apiURL: c.Issuer,
|
||||
cancel: cancel,
|
||||
clientID: c.ClientID,
|
||||
clientSecret: c.ClientSecret,
|
||||
insecureCA: c.InsecureCA,
|
||||
logger: logger,
|
||||
redirectURI: c.RedirectURI,
|
||||
rootCA: c.RootCA,
|
||||
groups: c.Groups,
|
||||
httpClient: httpClient,
|
||||
}
|
||||
|
||||
var metadata struct {
|
||||
Auth string `json:"authorization_endpoint"`
|
||||
Token string `json:"token_endpoint"`
|
||||
}
|
||||
|
||||
resp, err := openshiftConnector.httpClient.Do(req.WithContext(ctx))
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("failed to query OpenShift endpoint %w", err)
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&metadata); err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("discovery through endpoint %s failed to decode body: %w",
|
||||
wellKnownURL, err)
|
||||
}
|
||||
|
||||
openshiftConnector.oauth2Config = &oauth2.Config{
|
||||
ClientID: c.ClientID,
|
||||
ClientSecret: c.ClientSecret,
|
||||
Endpoint: oauth2.Endpoint{
|
||||
AuthURL: metadata.Auth, TokenURL: metadata.Token,
|
||||
},
|
||||
Scopes: []string{"user:info"},
|
||||
RedirectURL: c.RedirectURI,
|
||||
}
|
||||
return &openshiftConnector, nil
|
||||
}
|
||||
|
||||
func (c *openshiftConnector) Close() error {
|
||||
c.cancel()
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoginURL returns the URL to redirect the user to login with.
|
||||
func (c *openshiftConnector) LoginURL(scopes connector.Scopes, callbackURL, state string) (string, error) {
|
||||
if c.redirectURI != callbackURL {
|
||||
return "", fmt.Errorf("expected callback URL %q did not match the URL in the config %q",
|
||||
callbackURL, c.redirectURI)
|
||||
}
|
||||
return c.oauth2Config.AuthCodeURL(state), nil
|
||||
}
|
||||
|
||||
type oauth2Error struct {
|
||||
error string
|
||||
errorDescription string
|
||||
}
|
||||
|
||||
func (e *oauth2Error) Error() string {
|
||||
if e.errorDescription == "" {
|
||||
return e.error
|
||||
}
|
||||
return e.error + ": " + e.errorDescription
|
||||
}
|
||||
|
||||
// HandleCallback parses the request and returns the user's identity
|
||||
func (c *openshiftConnector) HandleCallback(s connector.Scopes,
|
||||
r *http.Request,
|
||||
) (identity connector.Identity, err error) {
|
||||
q := r.URL.Query()
|
||||
if errType := q.Get("error"); errType != "" {
|
||||
return identity, &oauth2Error{errType, q.Get("error_description")}
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
if c.httpClient != nil {
|
||||
ctx = context.WithValue(r.Context(), oauth2.HTTPClient, c.httpClient)
|
||||
}
|
||||
|
||||
token, err := c.oauth2Config.Exchange(ctx, q.Get("code"))
|
||||
if err != nil {
|
||||
return identity, fmt.Errorf("oidc: failed to get token: %v", err)
|
||||
}
|
||||
|
||||
return c.identity(ctx, s, token)
|
||||
}
|
||||
|
||||
func (c *openshiftConnector) Refresh(ctx context.Context, s connector.Scopes,
|
||||
oldID connector.Identity,
|
||||
) (connector.Identity, error) {
|
||||
var token oauth2.Token
|
||||
err := json.Unmarshal(oldID.ConnectorData, &token)
|
||||
if err != nil {
|
||||
return connector.Identity{}, fmt.Errorf("parsing token: %w", err)
|
||||
}
|
||||
if c.httpClient != nil {
|
||||
ctx = context.WithValue(ctx, oauth2.HTTPClient, c.httpClient)
|
||||
}
|
||||
return c.identity(ctx, s, &token)
|
||||
}
|
||||
|
||||
func (c *openshiftConnector) identity(ctx context.Context, s connector.Scopes,
|
||||
token *oauth2.Token,
|
||||
) (identity connector.Identity, err error) {
|
||||
client := c.oauth2Config.Client(ctx, token)
|
||||
user, err := c.user(ctx, client)
|
||||
if err != nil {
|
||||
return identity, fmt.Errorf("openshift: get user: %v", err)
|
||||
}
|
||||
|
||||
if len(c.groups) > 0 {
|
||||
validGroups := validateAllowedGroups(user.Groups, c.groups)
|
||||
|
||||
if !validGroups {
|
||||
return identity, fmt.Errorf("openshift: user %q is not in any of the required groups", user.Name)
|
||||
}
|
||||
}
|
||||
|
||||
identity = connector.Identity{
|
||||
UserID: user.UID,
|
||||
Username: user.Name,
|
||||
PreferredUsername: user.Name,
|
||||
Email: user.Name,
|
||||
Groups: user.Groups,
|
||||
}
|
||||
|
||||
if s.OfflineAccess {
|
||||
connData, err := json.Marshal(token)
|
||||
if err != nil {
|
||||
return identity, fmt.Errorf("marshal connector data: %v", err)
|
||||
}
|
||||
identity.ConnectorData = connData
|
||||
}
|
||||
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
// user function returns the OpenShift user associated with the authenticated user
|
||||
func (c *openshiftConnector) user(ctx context.Context, client *http.Client) (u user, err error) {
|
||||
url := c.apiURL + usersURLPath
|
||||
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return u, fmt.Errorf("new req: %v", err)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req.WithContext(ctx))
|
||||
if err != nil {
|
||||
return u, fmt.Errorf("get URL %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return u, fmt.Errorf("read body: %v", err)
|
||||
}
|
||||
return u, fmt.Errorf("%s: %s", resp.Status, body)
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&u); err != nil {
|
||||
return u, fmt.Errorf("JSON decode: %v", err)
|
||||
}
|
||||
|
||||
return u, err
|
||||
}
|
||||
|
||||
func validateAllowedGroups(userGroups, allowedGroups []string) bool {
|
||||
matchingGroups := groups.Filter(userGroups, allowedGroups)
|
||||
|
||||
return len(matchingGroups) != 0
|
||||
}
|
||||
|
||||
// newHTTPClient returns a new HTTP client
|
||||
func newHTTPClient(insecureCA bool, rootCA string) (*http.Client, error) {
|
||||
tlsConfig := tls.Config{}
|
||||
if insecureCA {
|
||||
tlsConfig = tls.Config{InsecureSkipVerify: true}
|
||||
} else if rootCA != "" {
|
||||
tlsConfig = tls.Config{RootCAs: x509.NewCertPool()}
|
||||
rootCABytes, err := os.ReadFile(rootCA)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read root-ca: %w", err)
|
||||
}
|
||||
if !tlsConfig.RootCAs.AppendCertsFromPEM(rootCABytes) {
|
||||
return nil, fmt.Errorf("no certs found in root CA file %q", rootCA)
|
||||
}
|
||||
}
|
||||
|
||||
return &http.Client{
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tlsConfig,
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 30 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
DualStack: true,
|
||||
}).DialContext,
|
||||
MaxIdleConns: 100,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
},
|
||||
}, nil
|
||||
}
|
|
@ -0,0 +1,297 @@
|
|||
package openshift
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"github.com/dexidp/dex/connector"
|
||||
"github.com/dexidp/dex/storage/kubernetes/k8sapi"
|
||||
)
|
||||
|
||||
func TestOpen(t *testing.T) {
|
||||
s := newTestServer(map[string]interface{}{})
|
||||
defer s.Close()
|
||||
|
||||
hostURL, err := url.Parse(s.URL)
|
||||
expectNil(t, err)
|
||||
|
||||
_, err = http.NewRequest("GET", hostURL.String(), nil)
|
||||
expectNil(t, err)
|
||||
|
||||
c := Config{
|
||||
Issuer: s.URL,
|
||||
ClientID: "testClientId",
|
||||
ClientSecret: "testClientSecret",
|
||||
RedirectURI: "https://localhost/callback",
|
||||
InsecureCA: true,
|
||||
}
|
||||
|
||||
logger := logrus.New()
|
||||
|
||||
oconfig, err := c.Open("id", logger)
|
||||
|
||||
oc, ok := oconfig.(*openshiftConnector)
|
||||
|
||||
expectNil(t, err)
|
||||
expectEquals(t, ok, true)
|
||||
expectEquals(t, oc.apiURL, s.URL)
|
||||
expectEquals(t, oc.clientID, "testClientId")
|
||||
expectEquals(t, oc.clientSecret, "testClientSecret")
|
||||
expectEquals(t, oc.redirectURI, "https://localhost/callback")
|
||||
expectEquals(t, oc.oauth2Config.Endpoint.AuthURL, fmt.Sprintf("%s/oauth/authorize", s.URL))
|
||||
expectEquals(t, oc.oauth2Config.Endpoint.TokenURL, fmt.Sprintf("%s/oauth/token", s.URL))
|
||||
}
|
||||
|
||||
func TestGetUser(t *testing.T) {
|
||||
s := newTestServer(map[string]interface{}{
|
||||
"/apis/user.openshift.io/v1/users/~": user{
|
||||
ObjectMeta: k8sapi.ObjectMeta{
|
||||
Name: "jdoe",
|
||||
},
|
||||
FullName: "John Doe",
|
||||
Groups: []string{"users"},
|
||||
},
|
||||
})
|
||||
defer s.Close()
|
||||
|
||||
hostURL, err := url.Parse(s.URL)
|
||||
expectNil(t, err)
|
||||
|
||||
_, err = http.NewRequest("GET", hostURL.String(), nil)
|
||||
expectNil(t, err)
|
||||
|
||||
h, err := newHTTPClient(true, "")
|
||||
|
||||
expectNil(t, err)
|
||||
|
||||
oc := openshiftConnector{apiURL: s.URL, httpClient: h}
|
||||
u, err := oc.user(context.Background(), h)
|
||||
|
||||
expectNil(t, err)
|
||||
expectEquals(t, u.Name, "jdoe")
|
||||
expectEquals(t, u.FullName, "John Doe")
|
||||
expectEquals(t, len(u.Groups), 1)
|
||||
}
|
||||
|
||||
func TestVerifySingleGroupFn(t *testing.T) {
|
||||
allowedGroups := []string{"users"}
|
||||
groupMembership := []string{"users", "org1"}
|
||||
|
||||
validGroupMembership := validateAllowedGroups(groupMembership, allowedGroups)
|
||||
|
||||
expectEquals(t, validGroupMembership, true)
|
||||
}
|
||||
|
||||
func TestVerifySingleGroupFailureFn(t *testing.T) {
|
||||
allowedGroups := []string{"admins"}
|
||||
groupMembership := []string{"users"}
|
||||
|
||||
validGroupMembership := validateAllowedGroups(groupMembership, allowedGroups)
|
||||
|
||||
expectEquals(t, validGroupMembership, false)
|
||||
}
|
||||
|
||||
func TestVerifyMultipleGroupFn(t *testing.T) {
|
||||
allowedGroups := []string{"users", "admins"}
|
||||
groupMembership := []string{"users", "org1"}
|
||||
|
||||
validGroupMembership := validateAllowedGroups(groupMembership, allowedGroups)
|
||||
|
||||
expectEquals(t, validGroupMembership, true)
|
||||
}
|
||||
|
||||
func TestVerifyGroup(t *testing.T) {
|
||||
s := newTestServer(map[string]interface{}{
|
||||
"/apis/user.openshift.io/v1/users/~": user{
|
||||
ObjectMeta: k8sapi.ObjectMeta{
|
||||
Name: "jdoe",
|
||||
},
|
||||
FullName: "John Doe",
|
||||
Groups: []string{"users"},
|
||||
},
|
||||
})
|
||||
defer s.Close()
|
||||
|
||||
hostURL, err := url.Parse(s.URL)
|
||||
expectNil(t, err)
|
||||
|
||||
_, err = http.NewRequest("GET", hostURL.String(), nil)
|
||||
expectNil(t, err)
|
||||
|
||||
h, err := newHTTPClient(true, "")
|
||||
|
||||
expectNil(t, err)
|
||||
|
||||
oc := openshiftConnector{apiURL: s.URL, httpClient: h}
|
||||
u, err := oc.user(context.Background(), h)
|
||||
|
||||
expectNil(t, err)
|
||||
expectEquals(t, u.Name, "jdoe")
|
||||
expectEquals(t, u.FullName, "John Doe")
|
||||
expectEquals(t, len(u.Groups), 1)
|
||||
}
|
||||
|
||||
func TestCallbackIdentity(t *testing.T) {
|
||||
s := newTestServer(map[string]interface{}{
|
||||
"/apis/user.openshift.io/v1/users/~": user{
|
||||
ObjectMeta: k8sapi.ObjectMeta{
|
||||
Name: "jdoe",
|
||||
UID: "12345",
|
||||
},
|
||||
FullName: "John Doe",
|
||||
Groups: []string{"users"},
|
||||
},
|
||||
"/oauth/token": map[string]interface{}{
|
||||
"access_token": "oRzxVjCnohYRHEYEhZshkmakKmoyVoTjfUGC",
|
||||
"expires_in": "30",
|
||||
},
|
||||
})
|
||||
defer s.Close()
|
||||
|
||||
hostURL, err := url.Parse(s.URL)
|
||||
expectNil(t, err)
|
||||
|
||||
req, err := http.NewRequest("GET", hostURL.String(), nil)
|
||||
expectNil(t, err)
|
||||
|
||||
h, err := newHTTPClient(true, "")
|
||||
|
||||
expectNil(t, err)
|
||||
|
||||
oc := openshiftConnector{apiURL: s.URL, httpClient: h, oauth2Config: &oauth2.Config{
|
||||
Endpoint: oauth2.Endpoint{
|
||||
AuthURL: fmt.Sprintf("%s/oauth/authorize", s.URL),
|
||||
TokenURL: fmt.Sprintf("%s/oauth/token", s.URL),
|
||||
},
|
||||
}}
|
||||
identity, err := oc.HandleCallback(connector.Scopes{Groups: true}, req)
|
||||
|
||||
expectNil(t, err)
|
||||
expectEquals(t, identity.UserID, "12345")
|
||||
expectEquals(t, identity.Username, "jdoe")
|
||||
expectEquals(t, identity.PreferredUsername, "jdoe")
|
||||
expectEquals(t, identity.Email, "jdoe")
|
||||
expectEquals(t, len(identity.Groups), 1)
|
||||
expectEquals(t, identity.Groups[0], "users")
|
||||
}
|
||||
|
||||
func TestRefreshIdentity(t *testing.T) {
|
||||
s := newTestServer(map[string]interface{}{
|
||||
usersURLPath: user{
|
||||
ObjectMeta: k8sapi.ObjectMeta{
|
||||
Name: "jdoe",
|
||||
UID: "12345",
|
||||
},
|
||||
FullName: "John Doe",
|
||||
Groups: []string{"users"},
|
||||
},
|
||||
})
|
||||
defer s.Close()
|
||||
|
||||
h, err := newHTTPClient(true, "")
|
||||
expectNil(t, err)
|
||||
|
||||
oc := openshiftConnector{apiURL: s.URL, httpClient: h, oauth2Config: &oauth2.Config{
|
||||
Endpoint: oauth2.Endpoint{
|
||||
AuthURL: fmt.Sprintf("%s/oauth/authorize", s.URL),
|
||||
TokenURL: fmt.Sprintf("%s/oauth/token", s.URL),
|
||||
},
|
||||
}}
|
||||
|
||||
data, err := json.Marshal(oauth2.Token{AccessToken: "fFAGRNJru1FTz70BzhT3Zg"})
|
||||
expectNil(t, err)
|
||||
|
||||
oldID := connector.Identity{ConnectorData: data}
|
||||
|
||||
identity, err := oc.Refresh(context.Background(), connector.Scopes{Groups: true}, oldID)
|
||||
|
||||
expectNil(t, err)
|
||||
expectEquals(t, identity.UserID, "12345")
|
||||
expectEquals(t, identity.Username, "jdoe")
|
||||
expectEquals(t, identity.PreferredUsername, "jdoe")
|
||||
expectEquals(t, identity.Email, "jdoe")
|
||||
expectEquals(t, len(identity.Groups), 1)
|
||||
expectEquals(t, identity.Groups[0], "users")
|
||||
}
|
||||
|
||||
func TestRefreshIdentityFailure(t *testing.T) {
|
||||
s := newTestServer(map[string]interface{}{
|
||||
usersURLPath: user{
|
||||
ObjectMeta: k8sapi.ObjectMeta{
|
||||
Name: "jdoe",
|
||||
UID: "12345",
|
||||
},
|
||||
FullName: "John Doe",
|
||||
Groups: []string{"users"},
|
||||
},
|
||||
})
|
||||
defer s.Close()
|
||||
|
||||
h, err := newHTTPClient(true, "")
|
||||
expectNil(t, err)
|
||||
|
||||
oc := openshiftConnector{apiURL: s.URL, httpClient: h, oauth2Config: &oauth2.Config{
|
||||
Endpoint: oauth2.Endpoint{
|
||||
AuthURL: fmt.Sprintf("%s/oauth/authorize", s.URL),
|
||||
TokenURL: fmt.Sprintf("%s/oauth/token", s.URL),
|
||||
},
|
||||
}}
|
||||
|
||||
data, err := json.Marshal(oauth2.Token{AccessToken: "oRzxVjCnohYRHEYEhZshkmakKmoyVoTjfUGC", Expiry: time.Now().Add(-time.Hour)})
|
||||
expectNil(t, err)
|
||||
|
||||
oldID := connector.Identity{ConnectorData: data}
|
||||
|
||||
identity, err := oc.Refresh(context.Background(), connector.Scopes{Groups: true}, oldID)
|
||||
expectNotNil(t, err)
|
||||
expectEquals(t, connector.Identity{}, identity)
|
||||
}
|
||||
|
||||
func newTestServer(responses map[string]interface{}) *httptest.Server {
|
||||
var s *httptest.Server
|
||||
s = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
responses["/.well-known/oauth-authorization-server"] = map[string]interface{}{
|
||||
"issuer": s.URL,
|
||||
"authorization_endpoint": fmt.Sprintf("%s/oauth/authorize", s.URL),
|
||||
"token_endpoint": fmt.Sprintf("%s/oauth/token", s.URL),
|
||||
"scopes_supported": []string{"user:full", "user:info", "user:check-access", "user:list-scoped-projects", "user:list-projects"},
|
||||
"response_types_supported": []string{"token", "code"},
|
||||
"grant_types_supported": []string{"authorization_code", "implicit"},
|
||||
"code_challenge_methods_supported": []string{"plain", "S256"},
|
||||
}
|
||||
|
||||
response := responses[r.RequestURI]
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}))
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func expectNil(t *testing.T, a interface{}) {
|
||||
if a != nil {
|
||||
t.Errorf("Expected %+v to equal nil", a)
|
||||
}
|
||||
}
|
||||
|
||||
func expectEquals(t *testing.T, a interface{}, b interface{}) {
|
||||
if !reflect.DeepEqual(a, b) {
|
||||
t.Errorf("Expected %+v to equal %+v", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
func expectNotNil(t *testing.T, a interface{}) {
|
||||
if a == nil {
|
||||
t.Errorf("Expected %+v to not equal nil", a)
|
||||
}
|
||||
}
|
|
@ -2,26 +2,29 @@
|
|||
package saml
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"bytes"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/pem"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Sirupsen/logrus"
|
||||
"github.com/beevik/etree"
|
||||
xrv "github.com/mattermost/xml-roundtrip-validator"
|
||||
"github.com/pkg/errors"
|
||||
dsig "github.com/russellhaering/goxmldsig"
|
||||
"github.com/russellhaering/goxmldsig/etreeutils"
|
||||
|
||||
"github.com/coreos/dex/connector"
|
||||
"github.com/dexidp/dex/connector"
|
||||
"github.com/dexidp/dex/pkg/groups"
|
||||
"github.com/dexidp/dex/pkg/log"
|
||||
)
|
||||
|
||||
// nolint
|
||||
const (
|
||||
bindingRedirect = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
|
||||
bindingPOST = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
|
||||
|
@ -59,20 +62,9 @@ var (
|
|||
nameIDformatTransient,
|
||||
}
|
||||
nameIDFormatLookup = make(map[string]string)
|
||||
)
|
||||
|
||||
func init() {
|
||||
suffix := func(s, sep string) string {
|
||||
if i := strings.LastIndex(s, sep); i > 0 {
|
||||
return s[i+1:]
|
||||
}
|
||||
return s
|
||||
}
|
||||
for _, format := range nameIDFormats {
|
||||
nameIDFormatLookup[suffix(format, ":")] = format
|
||||
nameIDFormatLookup[format] = format
|
||||
}
|
||||
}
|
||||
lookupOnce sync.Once
|
||||
)
|
||||
|
||||
// Config represents configuration options for the SAML provider.
|
||||
type Config struct {
|
||||
|
@ -81,8 +73,9 @@ type Config struct {
|
|||
//
|
||||
// https://www.oasis-open.org/committees/download.php/35391/sstc-saml-metadata-errata-2.0-wd-04-diff.pdf
|
||||
|
||||
Issuer string `json:"issuer"`
|
||||
SSOURL string `json:"ssoURL"`
|
||||
EntityIssuer string `json:"entityIssuer"`
|
||||
SSOIssuer string `json:"ssoIssuer"`
|
||||
SSOURL string `json:"ssoURL"`
|
||||
|
||||
// X509 CA file or raw data to verify XML signatures.
|
||||
CA string `json:"ca"`
|
||||
|
@ -97,9 +90,10 @@ type Config struct {
|
|||
// If GroupsDelim is supplied the connector assumes groups are returned as a
|
||||
// single string instead of multiple attribute values. This delimiter will be
|
||||
// used split the groups string.
|
||||
GroupsDelim string `json:"groupsDelim"`
|
||||
|
||||
RedirectURI string `json:"redirectURI"`
|
||||
GroupsDelim string `json:"groupsDelim"`
|
||||
AllowedGroups []string `json:"allowedGroups"`
|
||||
FilterGroups bool `json:"filterGroups"`
|
||||
RedirectURI string `json:"redirectURI"`
|
||||
|
||||
// Requested format of the NameID. The NameID value is is mapped to the ID Token
|
||||
// 'sub' claim.
|
||||
|
@ -126,13 +120,11 @@ func (c certStore) Certificates() (roots []*x509.Certificate, err error) {
|
|||
|
||||
// Open validates the config and returns a connector. It does not actually
|
||||
// validate connectivity with the provider.
|
||||
func (c *Config) Open(logger logrus.FieldLogger) (connector.Connector, error) {
|
||||
func (c *Config) Open(id string, logger log.Logger) (connector.Connector, error) {
|
||||
return c.openConnector(logger)
|
||||
}
|
||||
|
||||
func (c *Config) openConnector(logger logrus.FieldLogger) (interface {
|
||||
connector.SAMLConnector
|
||||
}, error) {
|
||||
func (c *Config) openConnector(logger log.Logger) (*provider, error) {
|
||||
requiredFields := []struct {
|
||||
name, val string
|
||||
}{
|
||||
|
@ -156,15 +148,18 @@ func (c *Config) openConnector(logger logrus.FieldLogger) (interface {
|
|||
}
|
||||
|
||||
p := &provider{
|
||||
issuer: c.Issuer,
|
||||
ssoURL: c.SSOURL,
|
||||
now: time.Now,
|
||||
usernameAttr: c.UsernameAttr,
|
||||
emailAttr: c.EmailAttr,
|
||||
groupsAttr: c.GroupsAttr,
|
||||
groupsDelim: c.GroupsDelim,
|
||||
redirectURI: c.RedirectURI,
|
||||
logger: logger,
|
||||
entityIssuer: c.EntityIssuer,
|
||||
ssoIssuer: c.SSOIssuer,
|
||||
ssoURL: c.SSOURL,
|
||||
now: time.Now,
|
||||
usernameAttr: c.UsernameAttr,
|
||||
emailAttr: c.EmailAttr,
|
||||
groupsAttr: c.GroupsAttr,
|
||||
groupsDelim: c.GroupsDelim,
|
||||
allowedGroups: c.AllowedGroups,
|
||||
filterGroups: c.FilterGroups,
|
||||
redirectURI: c.RedirectURI,
|
||||
logger: logger,
|
||||
|
||||
nameIDPolicyFormat: c.NameIDPolicyFormat,
|
||||
}
|
||||
|
@ -172,6 +167,19 @@ func (c *Config) openConnector(logger logrus.FieldLogger) (interface {
|
|||
if p.nameIDPolicyFormat == "" {
|
||||
p.nameIDPolicyFormat = nameIDFormatPersistent
|
||||
} else {
|
||||
lookupOnce.Do(func() {
|
||||
suffix := func(s, sep string) string {
|
||||
if i := strings.LastIndex(s, sep); i > 0 {
|
||||
return s[i+1:]
|
||||
}
|
||||
return s
|
||||
}
|
||||
for _, format := range nameIDFormats {
|
||||
nameIDFormatLookup[suffix(format, ":")] = format
|
||||
nameIDFormatLookup[format] = format
|
||||
}
|
||||
})
|
||||
|
||||
if format, ok := nameIDFormatLookup[p.nameIDPolicyFormat]; ok {
|
||||
p.nameIDPolicyFormat = format
|
||||
} else {
|
||||
|
@ -186,7 +194,7 @@ func (c *Config) openConnector(logger logrus.FieldLogger) (interface {
|
|||
|
||||
var caData []byte
|
||||
if c.CA != "" {
|
||||
data, err := ioutil.ReadFile(c.CA)
|
||||
data, err := os.ReadFile(c.CA)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read ca file: %v", err)
|
||||
}
|
||||
|
@ -202,6 +210,10 @@ func (c *Config) openConnector(logger logrus.FieldLogger) (interface {
|
|||
for {
|
||||
block, caData = pem.Decode(caData)
|
||||
if block == nil {
|
||||
caData = bytes.TrimSpace(caData)
|
||||
if len(caData) > 0 { // if there's some left, we've been given bad caData
|
||||
return nil, fmt.Errorf("parse cert: trailing data: %q", string(caData))
|
||||
}
|
||||
break
|
||||
}
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
|
@ -219,8 +231,9 @@ func (c *Config) openConnector(logger logrus.FieldLogger) (interface {
|
|||
}
|
||||
|
||||
type provider struct {
|
||||
issuer string
|
||||
ssoURL string
|
||||
entityIssuer string
|
||||
ssoIssuer string
|
||||
ssoURL string
|
||||
|
||||
now func() time.Time
|
||||
|
||||
|
@ -228,20 +241,21 @@ type provider struct {
|
|||
validator *dsig.ValidationContext
|
||||
|
||||
// Attribute mappings
|
||||
usernameAttr string
|
||||
emailAttr string
|
||||
groupsAttr string
|
||||
groupsDelim string
|
||||
usernameAttr string
|
||||
emailAttr string
|
||||
groupsAttr string
|
||||
groupsDelim string
|
||||
allowedGroups []string
|
||||
filterGroups bool
|
||||
|
||||
redirectURI string
|
||||
|
||||
nameIDPolicyFormat string
|
||||
|
||||
logger logrus.FieldLogger
|
||||
logger log.Logger
|
||||
}
|
||||
|
||||
func (p *provider) POSTData(s connector.Scopes, id string) (action, value string, err error) {
|
||||
|
||||
r := &authnRequest{
|
||||
ProtocolBinding: bindingPOST,
|
||||
ID: id,
|
||||
|
@ -253,10 +267,10 @@ func (p *provider) POSTData(s connector.Scopes, id string) (action, value string
|
|||
},
|
||||
AssertionConsumerServiceURL: p.redirectURI,
|
||||
}
|
||||
if p.issuer != "" {
|
||||
if p.entityIssuer != "" {
|
||||
// Issuer for the request is optional. For example, okta always ignores
|
||||
// this value.
|
||||
r.Issuer = &issuer{Issuer: p.issuer}
|
||||
r.Issuer = &issuer{Issuer: p.entityIssuer}
|
||||
}
|
||||
|
||||
data, err := xml.MarshalIndent(r, "", " ")
|
||||
|
@ -269,13 +283,32 @@ func (p *provider) POSTData(s connector.Scopes, id string) (action, value string
|
|||
return p.ssoURL, base64.StdEncoding.EncodeToString(data), nil
|
||||
}
|
||||
|
||||
// HandlePOST interprets a request from a SAML provider attempting to verify a
|
||||
// user's identity.
|
||||
//
|
||||
// The steps taken are:
|
||||
//
|
||||
// * Validate XML document does not contain malicious inputs.
|
||||
// * Verify signature on XML document (or verify sig on assertion elements).
|
||||
// * Verify various parts of the Assertion element. Conditions, audience, etc.
|
||||
// * Map the Assertion's attribute elements to user info.
|
||||
//
|
||||
func (p *provider) HandlePOST(s connector.Scopes, samlResponse, inResponseTo string) (ident connector.Identity, err error) {
|
||||
rawResp, err := base64.StdEncoding.DecodeString(samlResponse)
|
||||
if err != nil {
|
||||
return ident, fmt.Errorf("decode response: %v", err)
|
||||
}
|
||||
|
||||
byteReader := bytes.NewReader(rawResp)
|
||||
if xrvErr := xrv.Validate(byteReader); xrvErr != nil {
|
||||
return ident, errors.Wrap(xrvErr, "validating XML response")
|
||||
}
|
||||
|
||||
// Root element is allowed to not be signed if the Assertion element is.
|
||||
rootElementSigned := true
|
||||
if p.validator != nil {
|
||||
if rawResp, err = verify(p.validator, rawResp); err != nil {
|
||||
rawResp, rootElementSigned, err = verifyResponseSig(p.validator, rawResp)
|
||||
if err != nil {
|
||||
return ident, fmt.Errorf("verify signature: %v", err)
|
||||
}
|
||||
}
|
||||
|
@ -285,98 +318,148 @@ func (p *provider) HandlePOST(s connector.Scopes, samlResponse, inResponseTo str
|
|||
return ident, fmt.Errorf("unmarshal response: %v", err)
|
||||
}
|
||||
|
||||
if p.issuer != "" && resp.Issuer != nil && resp.Issuer.Issuer != p.issuer {
|
||||
return ident, fmt.Errorf("expected Issuer value %s, got %s", p.issuer, resp.Issuer.Issuer)
|
||||
}
|
||||
// If the root element isn't signed, there's no reason to inspect these
|
||||
// elements. They're not verified.
|
||||
if rootElementSigned {
|
||||
if p.ssoIssuer != "" && resp.Issuer != nil && resp.Issuer.Issuer != p.ssoIssuer {
|
||||
return ident, fmt.Errorf("expected Issuer value %s, got %s", p.ssoIssuer, resp.Issuer.Issuer)
|
||||
}
|
||||
|
||||
// Verify InResponseTo value matches the expected ID associated with
|
||||
// the RelayState.
|
||||
if resp.InResponseTo != inResponseTo {
|
||||
return ident, fmt.Errorf("expected InResponseTo value %s, got %s", inResponseTo, resp.InResponseTo)
|
||||
}
|
||||
// Verify InResponseTo value matches the expected ID associated with
|
||||
// the RelayState.
|
||||
if resp.InResponseTo != inResponseTo {
|
||||
return ident, fmt.Errorf("expected InResponseTo value %s, got %s", inResponseTo, resp.InResponseTo)
|
||||
}
|
||||
|
||||
// Destination is optional.
|
||||
if resp.Destination != "" && resp.Destination != p.redirectURI {
|
||||
return ident, fmt.Errorf("expected destination %q got %q", p.redirectURI, resp.Destination)
|
||||
// Destination is optional.
|
||||
if resp.Destination != "" && resp.Destination != p.redirectURI {
|
||||
return ident, fmt.Errorf("expected destination %q got %q", p.redirectURI, resp.Destination)
|
||||
}
|
||||
|
||||
}
|
||||
// Status is a required element.
|
||||
if resp.Status == nil {
|
||||
return ident, fmt.Errorf("response did not contain a Status element")
|
||||
}
|
||||
|
||||
if err = p.validateStatus(&resp); err != nil {
|
||||
return ident, err
|
||||
if err = p.validateStatus(resp.Status); err != nil {
|
||||
return ident, err
|
||||
}
|
||||
}
|
||||
|
||||
assertion := resp.Assertion
|
||||
if assertion == nil {
|
||||
return ident, fmt.Errorf("response did not contain an assertion")
|
||||
}
|
||||
|
||||
// Subject is usually optional, but we need it for the user ID, so complain
|
||||
// if it's not present.
|
||||
subject := assertion.Subject
|
||||
if subject == nil {
|
||||
return ident, fmt.Errorf("response did not contain a subject")
|
||||
}
|
||||
|
||||
if err = p.validateConditions(assertion); err != nil {
|
||||
// Validate that the response is to the request we originally sent.
|
||||
if err = p.validateSubject(subject, inResponseTo); err != nil {
|
||||
return ident, err
|
||||
}
|
||||
if err = p.validateSubjectConfirmation(subject); err != nil {
|
||||
return ident, err
|
||||
|
||||
// Conditions element is optional, but must be validated if present.
|
||||
if assertion.Conditions != nil {
|
||||
// Validate that dex is the intended audience of this response.
|
||||
if err = p.validateConditions(assertion.Conditions); err != nil {
|
||||
return ident, err
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case subject.NameID != nil:
|
||||
if ident.UserID = subject.NameID.Value; ident.UserID == "" {
|
||||
return ident, fmt.Errorf("NameID element does not contain a value")
|
||||
return ident, fmt.Errorf("element NameID does not contain a value")
|
||||
}
|
||||
default:
|
||||
return ident, fmt.Errorf("subject does not contain an NameID element")
|
||||
}
|
||||
|
||||
// After verifying the assertion, map data in the attribute statements to
|
||||
// various user info.
|
||||
attributes := assertion.AttributeStatement
|
||||
if attributes == nil {
|
||||
return ident, fmt.Errorf("response did not contain a AttributeStatement")
|
||||
}
|
||||
|
||||
// Log the actual attributes we got back from the server. This helps debug
|
||||
// configuration errors on the server side, where the SAML server doesn't
|
||||
// send us the correct attributes.
|
||||
p.logger.Infof("parsed and verified saml response attributes %s", attributes)
|
||||
|
||||
// Grab the email.
|
||||
if ident.Email, _ = attributes.get(p.emailAttr); ident.Email == "" {
|
||||
return ident, fmt.Errorf("no attribute with name %q: %s", p.emailAttr, attributes.names())
|
||||
}
|
||||
// TODO(ericchiang): Does SAML have an email_verified equivalent?
|
||||
ident.EmailVerified = true
|
||||
|
||||
// Grab the username.
|
||||
if ident.Username, _ = attributes.get(p.usernameAttr); ident.Username == "" {
|
||||
return ident, fmt.Errorf("no attribute with name %q: %s", p.usernameAttr, attributes.names())
|
||||
}
|
||||
|
||||
if s.Groups && p.groupsAttr != "" {
|
||||
if p.groupsDelim != "" {
|
||||
groupsStr, ok := attributes.get(p.groupsAttr)
|
||||
if !ok {
|
||||
return ident, fmt.Errorf("no attribute with name %q: %s", p.groupsAttr, attributes.names())
|
||||
}
|
||||
// TODO(ericchiang): Do we need to further trim whitespace?
|
||||
ident.Groups = strings.Split(groupsStr, p.groupsDelim)
|
||||
} else {
|
||||
groups, ok := attributes.all(p.groupsAttr)
|
||||
if !ok {
|
||||
return ident, fmt.Errorf("no attribute with name %q: %s", p.groupsAttr, attributes.names())
|
||||
}
|
||||
ident.Groups = groups
|
||||
}
|
||||
if len(p.allowedGroups) == 0 && (!s.Groups || p.groupsAttr == "") {
|
||||
// Groups not requested or not configured. We're done.
|
||||
return ident, nil
|
||||
}
|
||||
|
||||
if len(p.allowedGroups) > 0 && (!s.Groups || p.groupsAttr == "") {
|
||||
// allowedGroups set but no groups or groupsAttr. Disallowing.
|
||||
return ident, fmt.Errorf("user not a member of allowed groups")
|
||||
}
|
||||
|
||||
// Grab the groups.
|
||||
if p.groupsDelim != "" {
|
||||
groupsStr, ok := attributes.get(p.groupsAttr)
|
||||
if !ok {
|
||||
return ident, fmt.Errorf("no attribute with name %q: %s", p.groupsAttr, attributes.names())
|
||||
}
|
||||
// TODO(ericchiang): Do we need to further trim whitespace?
|
||||
ident.Groups = strings.Split(groupsStr, p.groupsDelim)
|
||||
} else {
|
||||
groups, ok := attributes.all(p.groupsAttr)
|
||||
if !ok {
|
||||
return ident, fmt.Errorf("no attribute with name %q: %s", p.groupsAttr, attributes.names())
|
||||
}
|
||||
ident.Groups = groups
|
||||
}
|
||||
|
||||
if len(p.allowedGroups) == 0 {
|
||||
// No allowed groups set, just return the ident
|
||||
return ident, nil
|
||||
}
|
||||
|
||||
// Look for membership in one of the allowed groups
|
||||
groupMatches := groups.Filter(ident.Groups, p.allowedGroups)
|
||||
|
||||
if len(groupMatches) == 0 {
|
||||
// No group membership matches found, disallowing
|
||||
return ident, fmt.Errorf("user not a member of allowed groups")
|
||||
}
|
||||
|
||||
if p.filterGroups {
|
||||
ident.Groups = groupMatches
|
||||
}
|
||||
|
||||
// Otherwise, we're good
|
||||
return ident, nil
|
||||
}
|
||||
|
||||
// Validate that the StatusCode of the Response is success.
|
||||
// Otherwise return a human readable message to the end user
|
||||
func (p *provider) validateStatus(resp *response) error {
|
||||
// Status is mandatory in the Response type
|
||||
status := resp.Status
|
||||
if status == nil {
|
||||
return fmt.Errorf("response did not contain a Status")
|
||||
}
|
||||
// validateStatus verifies that the response has a good status code or
|
||||
// formats a human readable error based on the bad status.
|
||||
func (p *provider) validateStatus(status *status) error {
|
||||
// StatusCode is mandatory in the Status type
|
||||
statusCode := status.StatusCode
|
||||
if statusCode == nil {
|
||||
return fmt.Errorf("response did not contain a StatusCode")
|
||||
}
|
||||
|
||||
if statusCode.Value != statusCodeSuccess {
|
||||
parts := strings.Split(statusCode.Value, ":")
|
||||
lastPart := parts[len(parts)-1]
|
||||
|
@ -390,152 +473,173 @@ func (p *provider) validateStatus(resp *response) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// Multiple subject SubjectConfirmation can be in the assertion
|
||||
// and at least one SubjectConfirmation must be valid.
|
||||
// validateSubject ensures the response is to the request we expect.
|
||||
//
|
||||
// This is described in the spec "Profiles for the OASIS Security
|
||||
// Assertion Markup Language" in section 3.3 Bearer.
|
||||
// see https://www.oasis-open.org/committees/download.php/35389/sstc-saml-profiles-errata-2.0-wd-06-diff.pdf
|
||||
func (p *provider) validateSubjectConfirmation(subject *subject) error {
|
||||
validSubjectConfirmation := false
|
||||
subjectConfirmations := subject.SubjectConfirmations
|
||||
if subjectConfirmations != nil && len(subjectConfirmations) > 0 {
|
||||
for _, subjectConfirmation := range subjectConfirmations {
|
||||
// skip if method is wrong
|
||||
method := subjectConfirmation.Method
|
||||
if method != "" && method != subjectConfirmationMethodBearer {
|
||||
continue
|
||||
//
|
||||
// Some of these fields are optional, but we're going to be strict here since
|
||||
// we have no other way of guaranteeing that this is actually the response to
|
||||
// the request we expect.
|
||||
func (p *provider) validateSubject(subject *subject, inResponseTo string) error {
|
||||
// Optional according to the spec, but again, we're going to be strict here.
|
||||
if len(subject.SubjectConfirmations) == 0 {
|
||||
return fmt.Errorf("subject contained no SubjectConfirmations")
|
||||
}
|
||||
|
||||
errs := make([]error, 0, len(subject.SubjectConfirmations))
|
||||
// One of these must match our assumptions, not all.
|
||||
for _, c := range subject.SubjectConfirmations {
|
||||
err := func() error {
|
||||
if c.Method != subjectConfirmationMethodBearer {
|
||||
return fmt.Errorf("unexpected subject confirmation method: %v", c.Method)
|
||||
}
|
||||
subjectConfirmationData := subjectConfirmation.SubjectConfirmationData
|
||||
if subjectConfirmationData == nil {
|
||||
continue
|
||||
|
||||
data := c.SubjectConfirmationData
|
||||
if data == nil {
|
||||
return fmt.Errorf("no SubjectConfirmationData field found in SubjectConfirmation")
|
||||
}
|
||||
inResponseTo := subjectConfirmationData.InResponseTo
|
||||
if inResponseTo != "" {
|
||||
// TODO also validate InResponseTo if present
|
||||
if data.InResponseTo != inResponseTo {
|
||||
return fmt.Errorf("expected SubjectConfirmationData InResponseTo value %q, got %q", inResponseTo, data.InResponseTo)
|
||||
}
|
||||
// only validate that subjectConfirmationData is not expired
|
||||
|
||||
notBefore := time.Time(data.NotBefore)
|
||||
notOnOrAfter := time.Time(data.NotOnOrAfter)
|
||||
now := p.now()
|
||||
notOnOrAfter := time.Time(subjectConfirmationData.NotOnOrAfter)
|
||||
if !notOnOrAfter.IsZero() {
|
||||
if now.After(notOnOrAfter) {
|
||||
continue
|
||||
}
|
||||
if !notBefore.IsZero() && before(now, notBefore) {
|
||||
return fmt.Errorf("at %s got response that cannot be processed before %s", now, notBefore)
|
||||
}
|
||||
// validate recipient if present
|
||||
recipient := subjectConfirmationData.Recipient
|
||||
if recipient != "" && recipient != p.redirectURI {
|
||||
continue
|
||||
if !notOnOrAfter.IsZero() && after(now, notOnOrAfter) {
|
||||
return fmt.Errorf("at %s got response that cannot be processed because it expired at %s", now, notOnOrAfter)
|
||||
}
|
||||
validSubjectConfirmation = true
|
||||
if r := data.Recipient; r != "" && r != p.redirectURI {
|
||||
return fmt.Errorf("expected Recipient %q got %q", p.redirectURI, r)
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
if err == nil {
|
||||
// Subject is valid.
|
||||
return nil
|
||||
}
|
||||
errs = append(errs, err)
|
||||
}
|
||||
if !validSubjectConfirmation {
|
||||
return fmt.Errorf("no valid SubjectConfirmation was found on this Response")
|
||||
|
||||
if len(errs) == 1 {
|
||||
return fmt.Errorf("failed to validate subject confirmation: %v", errs[0])
|
||||
}
|
||||
return nil
|
||||
return fmt.Errorf("failed to validate subject confirmation: %v", errs)
|
||||
}
|
||||
|
||||
// Validates the Conditions element and all of it's content
|
||||
// validationConditions ensures that dex is the intended audience
|
||||
// for the request, and not another service provider.
|
||||
//
|
||||
// See: https://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf
|
||||
// "2.3.3 Element <Assertion>"
|
||||
func (p *provider) validateConditions(assertion *assertion) error {
|
||||
// Checks if a Conditions element exists
|
||||
conditions := assertion.Conditions
|
||||
if conditions == nil {
|
||||
return nil
|
||||
}
|
||||
// Validates Assertion timestamps
|
||||
func (p *provider) validateConditions(conditions *conditions) error {
|
||||
// Ensure the conditions haven't expired.
|
||||
now := p.now()
|
||||
notBefore := time.Time(conditions.NotBefore)
|
||||
if !notBefore.IsZero() {
|
||||
if now.Add(allowedClockDrift).Before(notBefore) {
|
||||
return fmt.Errorf("at %s got response that cannot be processed before %s", now, notBefore)
|
||||
}
|
||||
if !notBefore.IsZero() && before(now, notBefore) {
|
||||
return fmt.Errorf("at %s got response that cannot be processed before %s", now, notBefore)
|
||||
}
|
||||
|
||||
notOnOrAfter := time.Time(conditions.NotOnOrAfter)
|
||||
if !notOnOrAfter.IsZero() {
|
||||
if now.After(notOnOrAfter.Add(allowedClockDrift)) {
|
||||
return fmt.Errorf("at %s got response that cannot be processed because it expired at %s", now, notOnOrAfter)
|
||||
}
|
||||
if !notOnOrAfter.IsZero() && after(now, notOnOrAfter) {
|
||||
return fmt.Errorf("at %s got response that cannot be processed because it expired at %s", now, notOnOrAfter)
|
||||
}
|
||||
// Validates audience
|
||||
audienceRestriction := conditions.AudienceRestriction
|
||||
if audienceRestriction != nil {
|
||||
audiences := audienceRestriction.Audiences
|
||||
if audiences != nil && len(audiences) > 0 {
|
||||
values := make([]string, len(audiences))
|
||||
issuerInAudiences := false
|
||||
for i, audience := range audiences {
|
||||
if audience.Value == p.redirectURI {
|
||||
issuerInAudiences = true
|
||||
break
|
||||
}
|
||||
values[i] = audience.Value
|
||||
}
|
||||
if !issuerInAudiences {
|
||||
return fmt.Errorf("required audience %s was not in Response audiences %s", p.redirectURI, values)
|
||||
|
||||
// Sometimes, dex's issuer string can be different than the redirect URI,
|
||||
// but if dex's issuer isn't explicitly provided assume the redirect URI.
|
||||
expAud := p.entityIssuer
|
||||
if expAud == "" {
|
||||
expAud = p.redirectURI
|
||||
}
|
||||
|
||||
// AudienceRestriction elements indicate the intended audience(s) of an
|
||||
// assertion. If dex isn't in these audiences, reject the assertion.
|
||||
//
|
||||
// Note that if there are multiple AudienceRestriction elements, each must
|
||||
// individually contain dex in their audience list.
|
||||
for _, r := range conditions.AudienceRestriction {
|
||||
values := make([]string, len(r.Audiences))
|
||||
issuerInAudiences := false
|
||||
for i, aud := range r.Audiences {
|
||||
if aud.Value == expAud {
|
||||
issuerInAudiences = true
|
||||
break
|
||||
}
|
||||
values[i] = aud.Value
|
||||
}
|
||||
|
||||
if !issuerInAudiences {
|
||||
return fmt.Errorf("required audience %s was not in Response audiences %s", expAud, values)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// verify checks the signature info of a XML document and returns
|
||||
// the signed elements.
|
||||
// The Validate function of the goxmldsig library only looks for
|
||||
// signatures on the root element level. But a saml Response is valid
|
||||
// if the complete message is signed, or only the Assertion is signed,
|
||||
// or but elements are signed. Therefore we first check a possible
|
||||
// signature of the Response than of the Assertion. If one of these
|
||||
// is successful the Response is considered as valid.
|
||||
func verify(validator *dsig.ValidationContext, data []byte) (signed []byte, err error) {
|
||||
// verifyResponseSig attempts to verify the signature of a SAML response or
|
||||
// the assertion.
|
||||
//
|
||||
// If the root element is properly signed, this method returns it.
|
||||
//
|
||||
// The SAML spec requires supporting responses where the root element is
|
||||
// unverified, but the sub <Assertion> elements are signed. In these cases,
|
||||
// this method returns rootVerified=false to indicate that the <Assertion>
|
||||
// elements should be trusted, but all other elements MUST be ignored.
|
||||
//
|
||||
// Note: we still don't support multiple <Assertion> tags. If there are
|
||||
// multiple present this code will only process the first.
|
||||
func verifyResponseSig(validator *dsig.ValidationContext, data []byte) (signed []byte, rootVerified bool, err error) {
|
||||
doc := etree.NewDocument()
|
||||
if err = doc.ReadFromBytes(data); err != nil {
|
||||
return nil, fmt.Errorf("parse document: %v", err)
|
||||
return nil, false, fmt.Errorf("parse document: %v", err)
|
||||
}
|
||||
verified := false
|
||||
|
||||
response := doc.Root()
|
||||
transformedResponse, err := validator.Validate(response)
|
||||
if err == nil {
|
||||
verified = true
|
||||
// Root element is verified, return it.
|
||||
doc.SetRoot(transformedResponse)
|
||||
signed, err = doc.WriteToBytes()
|
||||
return signed, true, err
|
||||
}
|
||||
|
||||
// Ensures xmlns are copied down to the assertion element when they are defined in the root
|
||||
//
|
||||
// TODO: Only select from child elements of the root.
|
||||
assertion, err := etreeutils.NSSelectOne(response, "urn:oasis:names:tc:SAML:2.0:assertion", "Assertion")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("response does not contain an Assertion element")
|
||||
return nil, false, fmt.Errorf("response does not contain an Assertion element")
|
||||
}
|
||||
transformedAssertion, err := validator.Validate(assertion)
|
||||
if err == nil {
|
||||
verified = true
|
||||
response.RemoveChild(assertion)
|
||||
response.AddChild(transformedAssertion)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("response does not contain a valid signature element: %v", err)
|
||||
}
|
||||
if verified != true {
|
||||
return nil, fmt.Errorf("response does not contain a valid Signature element")
|
||||
|
||||
// Verified an assertion but not the response. Can't trust any child elements,
|
||||
// except the assertion. Remove them all.
|
||||
for _, el := range response.ChildElements() {
|
||||
response.RemoveChild(el)
|
||||
}
|
||||
return doc.WriteToBytes()
|
||||
|
||||
// We still return the full <Response> element, even though it's unverified
|
||||
// because the <Assertion> element is not a valid XML document on its own.
|
||||
// It still requires the root element to define things like namespaces.
|
||||
response.AddChild(transformedAssertion)
|
||||
signed, err = doc.WriteToBytes()
|
||||
return signed, false, err
|
||||
}
|
||||
|
||||
func uuidv4() string {
|
||||
u := make([]byte, 16)
|
||||
if _, err := rand.Read(u); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
u[6] = (u[6] | 0x40) & 0x4F
|
||||
u[8] = (u[8] | 0x80) & 0xBF
|
||||
|
||||
r := make([]byte, 36)
|
||||
r[8] = '-'
|
||||
r[13] = '-'
|
||||
r[18] = '-'
|
||||
r[23] = '-'
|
||||
hex.Encode(r, u[0:4])
|
||||
hex.Encode(r[9:], u[4:6])
|
||||
hex.Encode(r[14:], u[6:8])
|
||||
hex.Encode(r[19:], u[8:10])
|
||||
hex.Encode(r[24:], u[10:])
|
||||
|
||||
return string(r)
|
||||
// before determines if a given time is before the current time, with an
|
||||
// allowed clock drift.
|
||||
func before(now, notBefore time.Time) bool {
|
||||
return now.Add(allowedClockDrift).Before(notBefore)
|
||||
}
|
||||
|
||||
// after determines if a given time is after the current time, with an
|
||||
// allowed clock drift.
|
||||
func after(now, notOnOrAfter time.Time) bool {
|
||||
return now.After(notOnOrAfter.Add(allowedClockDrift))
|
||||
}
|
||||
|
|
|
@ -5,28 +5,394 @@ import (
|
|||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"io/ioutil"
|
||||
"strings"
|
||||
"os"
|
||||
"sort"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Sirupsen/logrus"
|
||||
|
||||
"github.com/kylelemons/godebug/pretty"
|
||||
dsig "github.com/russellhaering/goxmldsig"
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/coreos/dex/connector"
|
||||
"github.com/dexidp/dex/connector"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultIssuer = "http://www.okta.com/exk91cb99lKkKSYoy0h7"
|
||||
defaultRedirectURI = "http://localhost:5556/dex/callback"
|
||||
// responseTest maps a SAML 2.0 response object to a set of expected values.
|
||||
//
|
||||
// Tests are defined in the "testdata" directory and are self-signed using xmlsec1.
|
||||
//
|
||||
// To add a new test, define a new, unsigned SAML 2.0 response that exercises some
|
||||
// case, then sign it using the "testdata/gen.sh" script.
|
||||
//
|
||||
// cp testdata/good-resp.tmpl testdata/( testname ).tmpl
|
||||
// vim ( testname ).tmpl # Modify your template for your test case.
|
||||
// vim testdata/gen.sh # Add a xmlsec1 command to the generation script.
|
||||
// ./testdata/gen.sh # Sign your template.
|
||||
//
|
||||
// To install xmlsec1 on Fedora run:
|
||||
//
|
||||
// sudo dnf install xmlsec1 xmlsec1-openssl
|
||||
//
|
||||
// On mac:
|
||||
//
|
||||
// brew install Libxmlsec1
|
||||
//
|
||||
type responseTest struct {
|
||||
// CA file and XML file of the response.
|
||||
caFile string
|
||||
respFile string
|
||||
|
||||
// Response ID embedded in our testdata.
|
||||
testDataResponseID = "_fd1b3ef9-ec09-44a7-a66b-0d39c250f6a0"
|
||||
)
|
||||
// Values that should be used to validate the signature.
|
||||
now string
|
||||
inResponseTo string
|
||||
redirectURI string
|
||||
entityIssuer string
|
||||
|
||||
// Attribute customization.
|
||||
usernameAttr string
|
||||
emailAttr string
|
||||
groupsAttr string
|
||||
allowedGroups []string
|
||||
filterGroups bool
|
||||
|
||||
// Expected outcome of the test.
|
||||
wantErr bool
|
||||
wantIdent connector.Identity
|
||||
}
|
||||
|
||||
func TestGoodResponse(t *testing.T) {
|
||||
test := responseTest{
|
||||
caFile: "testdata/ca.crt",
|
||||
respFile: "testdata/good-resp.xml",
|
||||
now: "2017-04-04T04:34:59.330Z",
|
||||
usernameAttr: "Name",
|
||||
emailAttr: "email",
|
||||
inResponseTo: "6zmm5mguyebwvajyf2sdwwcw6m",
|
||||
redirectURI: "http://127.0.0.1:5556/dex/callback",
|
||||
wantIdent: connector.Identity{
|
||||
UserID: "eric.chiang+okta@coreos.com",
|
||||
Username: "Eric",
|
||||
Email: "eric.chiang+okta@coreos.com",
|
||||
EmailVerified: true,
|
||||
},
|
||||
}
|
||||
test.run(t)
|
||||
}
|
||||
|
||||
func TestGroups(t *testing.T) {
|
||||
test := responseTest{
|
||||
caFile: "testdata/ca.crt",
|
||||
respFile: "testdata/good-resp.xml",
|
||||
now: "2017-04-04T04:34:59.330Z",
|
||||
usernameAttr: "Name",
|
||||
emailAttr: "email",
|
||||
groupsAttr: "groups",
|
||||
inResponseTo: "6zmm5mguyebwvajyf2sdwwcw6m",
|
||||
redirectURI: "http://127.0.0.1:5556/dex/callback",
|
||||
wantIdent: connector.Identity{
|
||||
UserID: "eric.chiang+okta@coreos.com",
|
||||
Username: "Eric",
|
||||
Email: "eric.chiang+okta@coreos.com",
|
||||
EmailVerified: true,
|
||||
Groups: []string{"Admins", "Everyone"},
|
||||
},
|
||||
}
|
||||
test.run(t)
|
||||
}
|
||||
|
||||
func TestGroupsWhitelist(t *testing.T) {
|
||||
test := responseTest{
|
||||
caFile: "testdata/ca.crt",
|
||||
respFile: "testdata/good-resp.xml",
|
||||
now: "2017-04-04T04:34:59.330Z",
|
||||
usernameAttr: "Name",
|
||||
emailAttr: "email",
|
||||
groupsAttr: "groups",
|
||||
allowedGroups: []string{"Admins"},
|
||||
inResponseTo: "6zmm5mguyebwvajyf2sdwwcw6m",
|
||||
redirectURI: "http://127.0.0.1:5556/dex/callback",
|
||||
wantIdent: connector.Identity{
|
||||
UserID: "eric.chiang+okta@coreos.com",
|
||||
Username: "Eric",
|
||||
Email: "eric.chiang+okta@coreos.com",
|
||||
EmailVerified: true,
|
||||
Groups: []string{"Admins", "Everyone"},
|
||||
},
|
||||
}
|
||||
test.run(t)
|
||||
}
|
||||
|
||||
func TestGroupsWhitelistWithFiltering(t *testing.T) {
|
||||
test := responseTest{
|
||||
caFile: "testdata/ca.crt",
|
||||
respFile: "testdata/good-resp.xml",
|
||||
now: "2017-04-04T04:34:59.330Z",
|
||||
usernameAttr: "Name",
|
||||
emailAttr: "email",
|
||||
groupsAttr: "groups",
|
||||
allowedGroups: []string{"Admins"},
|
||||
filterGroups: true,
|
||||
inResponseTo: "6zmm5mguyebwvajyf2sdwwcw6m",
|
||||
redirectURI: "http://127.0.0.1:5556/dex/callback",
|
||||
wantIdent: connector.Identity{
|
||||
UserID: "eric.chiang+okta@coreos.com",
|
||||
Username: "Eric",
|
||||
Email: "eric.chiang+okta@coreos.com",
|
||||
EmailVerified: true,
|
||||
Groups: []string{"Admins"}, // "Everyone" is filtered
|
||||
},
|
||||
}
|
||||
test.run(t)
|
||||
}
|
||||
|
||||
func TestGroupsWhitelistEmpty(t *testing.T) {
|
||||
test := responseTest{
|
||||
caFile: "testdata/ca.crt",
|
||||
respFile: "testdata/good-resp.xml",
|
||||
now: "2017-04-04T04:34:59.330Z",
|
||||
usernameAttr: "Name",
|
||||
emailAttr: "email",
|
||||
groupsAttr: "groups",
|
||||
allowedGroups: []string{},
|
||||
inResponseTo: "6zmm5mguyebwvajyf2sdwwcw6m",
|
||||
redirectURI: "http://127.0.0.1:5556/dex/callback",
|
||||
wantIdent: connector.Identity{
|
||||
UserID: "eric.chiang+okta@coreos.com",
|
||||
Username: "Eric",
|
||||
Email: "eric.chiang+okta@coreos.com",
|
||||
EmailVerified: true,
|
||||
Groups: []string{"Admins", "Everyone"},
|
||||
},
|
||||
}
|
||||
test.run(t)
|
||||
}
|
||||
|
||||
func TestGroupsWhitelistDisallowed(t *testing.T) {
|
||||
test := responseTest{
|
||||
wantErr: true,
|
||||
caFile: "testdata/ca.crt",
|
||||
respFile: "testdata/good-resp.xml",
|
||||
now: "2017-04-04T04:34:59.330Z",
|
||||
usernameAttr: "Name",
|
||||
emailAttr: "email",
|
||||
groupsAttr: "groups",
|
||||
allowedGroups: []string{"Nope"},
|
||||
inResponseTo: "6zmm5mguyebwvajyf2sdwwcw6m",
|
||||
redirectURI: "http://127.0.0.1:5556/dex/callback",
|
||||
wantIdent: connector.Identity{
|
||||
UserID: "eric.chiang+okta@coreos.com",
|
||||
Username: "Eric",
|
||||
Email: "eric.chiang+okta@coreos.com",
|
||||
EmailVerified: true,
|
||||
Groups: []string{"Admins", "Everyone"},
|
||||
},
|
||||
}
|
||||
test.run(t)
|
||||
}
|
||||
|
||||
func TestGroupsWhitelistDisallowedNoGroupsOnIdent(t *testing.T) {
|
||||
test := responseTest{
|
||||
wantErr: true,
|
||||
caFile: "testdata/ca.crt",
|
||||
respFile: "testdata/good-resp.xml",
|
||||
now: "2017-04-04T04:34:59.330Z",
|
||||
usernameAttr: "Name",
|
||||
emailAttr: "email",
|
||||
groupsAttr: "groups",
|
||||
allowedGroups: []string{"Nope"},
|
||||
inResponseTo: "6zmm5mguyebwvajyf2sdwwcw6m",
|
||||
redirectURI: "http://127.0.0.1:5556/dex/callback",
|
||||
wantIdent: connector.Identity{
|
||||
UserID: "eric.chiang+okta@coreos.com",
|
||||
Username: "Eric",
|
||||
Email: "eric.chiang+okta@coreos.com",
|
||||
EmailVerified: true,
|
||||
Groups: []string{},
|
||||
},
|
||||
}
|
||||
test.run(t)
|
||||
}
|
||||
|
||||
// TestOkta tests against an actual response from Okta.
|
||||
func TestOkta(t *testing.T) {
|
||||
test := responseTest{
|
||||
caFile: "testdata/okta-ca.pem",
|
||||
respFile: "testdata/okta-resp.xml",
|
||||
now: "2017-04-04T04:34:59.330Z",
|
||||
usernameAttr: "Name",
|
||||
emailAttr: "email",
|
||||
inResponseTo: "6zmm5mguyebwvajyf2sdwwcw6m",
|
||||
redirectURI: "http://127.0.0.1:5556/dex/callback",
|
||||
wantIdent: connector.Identity{
|
||||
UserID: "eric.chiang+okta@coreos.com",
|
||||
Username: "Eric",
|
||||
Email: "eric.chiang+okta@coreos.com",
|
||||
EmailVerified: true,
|
||||
},
|
||||
}
|
||||
test.run(t)
|
||||
}
|
||||
|
||||
func TestBadStatus(t *testing.T) {
|
||||
test := responseTest{
|
||||
caFile: "testdata/ca.crt",
|
||||
respFile: "testdata/bad-status.xml",
|
||||
now: "2017-04-04T04:34:59.330Z",
|
||||
usernameAttr: "Name",
|
||||
emailAttr: "email",
|
||||
inResponseTo: "6zmm5mguyebwvajyf2sdwwcw6m",
|
||||
redirectURI: "http://127.0.0.1:5556/dex/callback",
|
||||
wantErr: true,
|
||||
}
|
||||
test.run(t)
|
||||
}
|
||||
|
||||
func TestInvalidCA(t *testing.T) {
|
||||
test := responseTest{
|
||||
caFile: "testdata/bad-ca.crt", // Not the CA that signed this response.
|
||||
respFile: "testdata/good-resp.xml",
|
||||
now: "2017-04-04T04:34:59.330Z",
|
||||
usernameAttr: "Name",
|
||||
emailAttr: "email",
|
||||
inResponseTo: "6zmm5mguyebwvajyf2sdwwcw6m",
|
||||
redirectURI: "http://127.0.0.1:5556/dex/callback",
|
||||
wantErr: true,
|
||||
}
|
||||
test.run(t)
|
||||
}
|
||||
|
||||
func TestUnsignedResponse(t *testing.T) {
|
||||
test := responseTest{
|
||||
caFile: "testdata/ca.crt",
|
||||
respFile: "testdata/good-resp.tmpl", // Use the unsigned template, not the signed document.
|
||||
now: "2017-04-04T04:34:59.330Z",
|
||||
usernameAttr: "Name",
|
||||
emailAttr: "email",
|
||||
inResponseTo: "6zmm5mguyebwvajyf2sdwwcw6m",
|
||||
redirectURI: "http://127.0.0.1:5556/dex/callback",
|
||||
wantErr: true,
|
||||
}
|
||||
test.run(t)
|
||||
}
|
||||
|
||||
func TestExpiredAssertion(t *testing.T) {
|
||||
test := responseTest{
|
||||
caFile: "testdata/ca.crt",
|
||||
respFile: "testdata/assertion-signed.xml",
|
||||
now: "2020-04-04T04:34:59.330Z", // Assertion has expired.
|
||||
usernameAttr: "Name",
|
||||
emailAttr: "email",
|
||||
inResponseTo: "6zmm5mguyebwvajyf2sdwwcw6m",
|
||||
redirectURI: "http://127.0.0.1:5556/dex/callback",
|
||||
wantErr: true,
|
||||
}
|
||||
test.run(t)
|
||||
}
|
||||
|
||||
// TestAssertionSignedNotResponse ensures the connector validates SAML 2.0
|
||||
// responses where the assertion is signed but the root element, the
|
||||
// response, isn't.
|
||||
func TestAssertionSignedNotResponse(t *testing.T) {
|
||||
test := responseTest{
|
||||
caFile: "testdata/ca.crt",
|
||||
respFile: "testdata/assertion-signed.xml",
|
||||
now: "2017-04-04T04:34:59.330Z",
|
||||
usernameAttr: "Name",
|
||||
emailAttr: "email",
|
||||
inResponseTo: "6zmm5mguyebwvajyf2sdwwcw6m",
|
||||
redirectURI: "http://127.0.0.1:5556/dex/callback",
|
||||
wantIdent: connector.Identity{
|
||||
UserID: "eric.chiang+okta@coreos.com",
|
||||
Username: "Eric",
|
||||
Email: "eric.chiang+okta@coreos.com",
|
||||
EmailVerified: true,
|
||||
},
|
||||
}
|
||||
test.run(t)
|
||||
}
|
||||
|
||||
func TestInvalidSubjectInResponseTo(t *testing.T) {
|
||||
test := responseTest{
|
||||
caFile: "testdata/ca.crt",
|
||||
respFile: "testdata/assertion-signed.xml",
|
||||
now: "2017-04-04T04:34:59.330Z",
|
||||
usernameAttr: "Name",
|
||||
emailAttr: "email",
|
||||
inResponseTo: "invalid-id", // Bad InResponseTo value.
|
||||
redirectURI: "http://127.0.0.1:5556/dex/callback",
|
||||
wantErr: true,
|
||||
}
|
||||
test.run(t)
|
||||
}
|
||||
|
||||
func TestInvalidSubjectRecipient(t *testing.T) {
|
||||
test := responseTest{
|
||||
caFile: "testdata/ca.crt",
|
||||
respFile: "testdata/assertion-signed.xml",
|
||||
now: "2017-04-04T04:34:59.330Z",
|
||||
usernameAttr: "Name",
|
||||
emailAttr: "email",
|
||||
inResponseTo: "6zmm5mguyebwvajyf2sdwwcw6m",
|
||||
redirectURI: "http://bad.com/dex/callback", // Doesn't match Recipient value.
|
||||
wantErr: true,
|
||||
}
|
||||
test.run(t)
|
||||
}
|
||||
|
||||
func TestInvalidAssertionAudience(t *testing.T) {
|
||||
test := responseTest{
|
||||
caFile: "testdata/ca.crt",
|
||||
respFile: "testdata/assertion-signed.xml",
|
||||
now: "2017-04-04T04:34:59.330Z",
|
||||
usernameAttr: "Name",
|
||||
emailAttr: "email",
|
||||
inResponseTo: "6zmm5mguyebwvajyf2sdwwcw6m",
|
||||
redirectURI: "http://127.0.0.1:5556/dex/callback",
|
||||
// EntityIssuer overrides RedirectURI when determining the expected
|
||||
// audience. In this case, ensure the audience is invalid.
|
||||
entityIssuer: "http://localhost:5556/dex/callback",
|
||||
wantErr: true,
|
||||
}
|
||||
test.run(t)
|
||||
}
|
||||
|
||||
// TestTwoAssertionFirstSigned tries to catch an edge case where an attacker
|
||||
// provides a second assertion that's not signed.
|
||||
func TestTwoAssertionFirstSigned(t *testing.T) {
|
||||
test := responseTest{
|
||||
caFile: "testdata/ca.crt",
|
||||
respFile: "testdata/two-assertions-first-signed.xml",
|
||||
now: "2017-04-04T04:34:59.330Z",
|
||||
usernameAttr: "Name",
|
||||
emailAttr: "email",
|
||||
inResponseTo: "6zmm5mguyebwvajyf2sdwwcw6m",
|
||||
redirectURI: "http://127.0.0.1:5556/dex/callback",
|
||||
wantIdent: connector.Identity{
|
||||
UserID: "eric.chiang+okta@coreos.com",
|
||||
Username: "Eric",
|
||||
Email: "eric.chiang+okta@coreos.com",
|
||||
EmailVerified: true,
|
||||
},
|
||||
}
|
||||
test.run(t)
|
||||
}
|
||||
|
||||
func TestTamperedResponseNameID(t *testing.T) {
|
||||
test := responseTest{
|
||||
caFile: "testdata/ca.crt",
|
||||
respFile: "testdata/tampered-resp.xml",
|
||||
now: "2017-04-04T04:34:59.330Z",
|
||||
usernameAttr: "Name",
|
||||
emailAttr: "email",
|
||||
inResponseTo: "6zmm5mguyebwvajyf2sdwwcw6m",
|
||||
redirectURI: "http://127.0.0.1:5556/dex/callback",
|
||||
wantErr: true,
|
||||
}
|
||||
test.run(t)
|
||||
}
|
||||
|
||||
func loadCert(ca string) (*x509.Certificate, error) {
|
||||
data, err := ioutil.ReadFile(ca)
|
||||
data, err := os.ReadFile(ca)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -37,6 +403,145 @@ func loadCert(ca string) (*x509.Certificate, error) {
|
|||
return x509.ParseCertificate(block.Bytes)
|
||||
}
|
||||
|
||||
func (r responseTest) run(t *testing.T) {
|
||||
c := Config{
|
||||
CA: r.caFile,
|
||||
UsernameAttr: r.usernameAttr,
|
||||
EmailAttr: r.emailAttr,
|
||||
GroupsAttr: r.groupsAttr,
|
||||
RedirectURI: r.redirectURI,
|
||||
EntityIssuer: r.entityIssuer,
|
||||
AllowedGroups: r.allowedGroups,
|
||||
FilterGroups: r.filterGroups,
|
||||
// Never logging in, don't need this.
|
||||
SSOURL: "http://foo.bar/",
|
||||
}
|
||||
now, err := time.Parse(timeFormat, r.now)
|
||||
if err != nil {
|
||||
t.Fatalf("parse test time: %v", err)
|
||||
}
|
||||
|
||||
conn, err := c.openConnector(logrus.New())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conn.now = func() time.Time { return now }
|
||||
resp, err := os.ReadFile(r.respFile)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
samlResp := base64.StdEncoding.EncodeToString(resp)
|
||||
|
||||
scopes := connector.Scopes{
|
||||
OfflineAccess: false,
|
||||
Groups: true,
|
||||
}
|
||||
ident, err := conn.HandlePOST(scopes, samlResp, r.inResponseTo)
|
||||
if err != nil {
|
||||
if !r.wantErr {
|
||||
t.Fatalf("handle response: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if r.wantErr {
|
||||
t.Fatalf("wanted error")
|
||||
}
|
||||
sort.Strings(ident.Groups)
|
||||
sort.Strings(r.wantIdent.Groups)
|
||||
if diff := pretty.Compare(ident, r.wantIdent); diff != "" {
|
||||
t.Error(diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigCAData(t *testing.T) {
|
||||
logger := logrus.New()
|
||||
validPEM, err := os.ReadFile("testdata/ca.crt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
valid2ndPEM, err := os.ReadFile("testdata/okta-ca.pem")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// copy helper, avoid messing with the byte slice among different cases
|
||||
c := func(bs []byte) []byte {
|
||||
return append([]byte(nil), bs...)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
caData []byte
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "one valid PEM entry",
|
||||
caData: c(validPEM),
|
||||
},
|
||||
{
|
||||
name: "one valid PEM entry with trailing newline",
|
||||
caData: append(c(validPEM), []byte("\n")...),
|
||||
},
|
||||
{
|
||||
name: "one valid PEM entry with trailing spaces",
|
||||
caData: append(c(validPEM), []byte(" ")...),
|
||||
},
|
||||
{
|
||||
name: "one valid PEM entry with two trailing newlines",
|
||||
caData: append(c(validPEM), []byte("\n\n")...),
|
||||
},
|
||||
{
|
||||
name: "two valid PEM entries",
|
||||
caData: append(c(validPEM), c(valid2ndPEM)...),
|
||||
},
|
||||
{
|
||||
name: "two valid PEM entries with newline in between",
|
||||
caData: append(append(c(validPEM), []byte("\n")...), c(valid2ndPEM)...),
|
||||
},
|
||||
{
|
||||
name: "two valid PEM entries with trailing newline",
|
||||
caData: append(c(valid2ndPEM), append(c(validPEM), []byte("\n")...)...),
|
||||
},
|
||||
{
|
||||
name: "empty",
|
||||
caData: []byte{},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "one valid PEM entry with trailing data",
|
||||
caData: append(c(validPEM), []byte("yaddayadda")...),
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "one valid PEM entry with bad data before",
|
||||
caData: append([]byte("yaddayadda"), c(validPEM)...),
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
c := Config{
|
||||
CAData: tc.caData,
|
||||
UsernameAttr: "user",
|
||||
EmailAttr: "email",
|
||||
RedirectURI: "http://127.0.0.1:5556/dex/callback",
|
||||
SSOURL: "http://foo.bar/",
|
||||
}
|
||||
_, err := (&c).Open("samltest", logger)
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Error("expected error, got nil")
|
||||
}
|
||||
} else if err != nil {
|
||||
t.Errorf("expected no error, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Deprecated: Use testing framework established above.
|
||||
func runVerify(t *testing.T, ca string, resp string, shouldSucceed bool) {
|
||||
cert, err := loadCert(ca)
|
||||
if err != nil {
|
||||
|
@ -46,42 +551,22 @@ func runVerify(t *testing.T, ca string, resp string, shouldSucceed bool) {
|
|||
|
||||
validator := dsig.NewDefaultValidationContext(s)
|
||||
|
||||
data, err := ioutil.ReadFile(resp)
|
||||
data, err := os.ReadFile(resp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := verify(validator, data); err != nil {
|
||||
if _, _, err := verifyResponseSig(validator, data); err != nil {
|
||||
if shouldSucceed {
|
||||
t.Fatal(err)
|
||||
}
|
||||
} else {
|
||||
if !shouldSucceed {
|
||||
t.Fatalf("expected an invalid signatrue but verification has been successful")
|
||||
t.Fatalf("expected an invalid signature but verification has been successful")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newProvider(issuer string, redirectURI string) *provider {
|
||||
if issuer == "" {
|
||||
issuer = defaultIssuer
|
||||
}
|
||||
if redirectURI == "" {
|
||||
redirectURI = defaultRedirectURI
|
||||
}
|
||||
now, _ := time.Parse(time.RFC3339, "2017-01-24T20:48:41Z")
|
||||
timeFunc := func() time.Time { return now }
|
||||
return &provider{
|
||||
issuer: issuer,
|
||||
ssoURL: "http://idp.org/saml/sso",
|
||||
now: timeFunc,
|
||||
usernameAttr: "user",
|
||||
emailAttr: "email",
|
||||
redirectURI: redirectURI,
|
||||
logger: logrus.New(),
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerify(t *testing.T) {
|
||||
runVerify(t, "testdata/okta-ca.pem", "testdata/okta-resp.xml", true)
|
||||
}
|
||||
|
@ -105,174 +590,3 @@ func TestVerifySignedMessageAndSignedAssertion(t *testing.T) {
|
|||
func TestVerifyUnsignedMessageAndUnsignedAssertion(t *testing.T) {
|
||||
runVerify(t, "testdata/idp-cert.pem", "testdata/idp-resp.xml", false)
|
||||
}
|
||||
|
||||
func TestHandlePOST(t *testing.T) {
|
||||
p := newProvider("", "")
|
||||
scopes := connector.Scopes{
|
||||
OfflineAccess: false,
|
||||
Groups: true,
|
||||
}
|
||||
data, err := ioutil.ReadFile("testdata/idp-resp.xml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ident, err := p.HandlePOST(scopes, base64.StdEncoding.EncodeToString(data), testDataResponseID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ident.UserID != "eric.chiang+okta@coreos.com" {
|
||||
t.Fatalf("unexpected UserID %q", ident.UserID)
|
||||
}
|
||||
if ident.Username != "admin" {
|
||||
t.Fatalf("unexpected Username: %q", ident.UserID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStatus(t *testing.T) {
|
||||
p := newProvider("", "")
|
||||
var err error
|
||||
resp := response{}
|
||||
// Test missing Status element
|
||||
err = p.validateStatus(&resp)
|
||||
if err == nil || !strings.HasSuffix(err.Error(), `Status`) {
|
||||
t.Fatalf("validation should fail with missing Status")
|
||||
}
|
||||
// Test missing StatusCode element
|
||||
resp.Status = &status{}
|
||||
err = p.validateStatus(&resp)
|
||||
if err == nil || !strings.HasSuffix(err.Error(), `StatusCode`) {
|
||||
t.Fatalf("validation should fail with missing StatusCode")
|
||||
}
|
||||
// Test failed request without StatusMessage
|
||||
resp.Status.StatusCode = &statusCode{
|
||||
Value: ":Requester",
|
||||
}
|
||||
err = p.validateStatus(&resp)
|
||||
if err == nil || !strings.HasSuffix(err.Error(), `"Requester"`) {
|
||||
t.Fatalf("validation should fail with code %q", "Requester")
|
||||
}
|
||||
// Test failed request with StatusMessage
|
||||
resp.Status.StatusMessage = &statusMessage{
|
||||
Value: "Failed",
|
||||
}
|
||||
err = p.validateStatus(&resp)
|
||||
if err == nil || !strings.HasSuffix(err.Error(), `"Requester" -> Failed`) {
|
||||
t.Fatalf("validation should fail with code %q and message %q", "Requester", "Failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSubjectConfirmation(t *testing.T) {
|
||||
p := newProvider("", "")
|
||||
var err error
|
||||
var notAfter time.Time
|
||||
subj := &subject{}
|
||||
// Subject without any SubjectConfirmation
|
||||
err = p.validateSubjectConfirmation(subj)
|
||||
if err == nil {
|
||||
t.Fatalf("validation of %q should fail", "Subject without any SubjectConfirmation")
|
||||
}
|
||||
// SubjectConfirmation without Method and SubjectConfirmationData
|
||||
subj.SubjectConfirmations = []subjectConfirmation{subjectConfirmation{}}
|
||||
err = p.validateSubjectConfirmation(subj)
|
||||
if err == nil {
|
||||
t.Fatalf("validation of %q should fail", "SubjectConfirmation without Method and SubjectConfirmationData")
|
||||
}
|
||||
// SubjectConfirmation with invalid Method and no SubjectConfirmationData
|
||||
subj.SubjectConfirmations = []subjectConfirmation{subjectConfirmation{
|
||||
Method: "invalid",
|
||||
}}
|
||||
err = p.validateSubjectConfirmation(subj)
|
||||
if err == nil {
|
||||
t.Fatalf("validation of %q should fail", "SubjectConfirmation with invalid Method and no SubjectConfirmationData")
|
||||
}
|
||||
// SubjectConfirmation with valid Method and empty SubjectConfirmationData
|
||||
subjConfirmationData := subjectConfirmationData{}
|
||||
subj.SubjectConfirmations = []subjectConfirmation{subjectConfirmation{
|
||||
Method: "urn:oasis:names:tc:SAML:2.0:cm:bearer",
|
||||
SubjectConfirmationData: &subjConfirmationData,
|
||||
}}
|
||||
err = p.validateSubjectConfirmation(subj)
|
||||
if err != nil {
|
||||
t.Fatalf("validation of %q should succeed", "SubjectConfirmation with valid Method and empty SubjectConfirmationData")
|
||||
}
|
||||
// SubjectConfirmationData with invalid Recipient
|
||||
subjConfirmationData.Recipient = "invalid"
|
||||
err = p.validateSubjectConfirmation(subj)
|
||||
if err == nil {
|
||||
t.Fatalf("validation of %q should fail", "SubjectConfirmationData with invalid Recipient")
|
||||
}
|
||||
// expired SubjectConfirmationData
|
||||
notAfter = p.now().Add(-time.Duration(60) * time.Second)
|
||||
subjConfirmationData.NotOnOrAfter = xmlTime(notAfter)
|
||||
subjConfirmationData.Recipient = defaultRedirectURI
|
||||
err = p.validateSubjectConfirmation(subj)
|
||||
if err == nil {
|
||||
t.Fatalf("validation of %q should fail", " expired SubjectConfirmationData")
|
||||
}
|
||||
// valid SubjectConfirmationData
|
||||
notAfter = p.now().Add(+time.Duration(60) * time.Second)
|
||||
subjConfirmationData.NotOnOrAfter = xmlTime(notAfter)
|
||||
subjConfirmationData.Recipient = defaultRedirectURI
|
||||
err = p.validateSubjectConfirmation(subj)
|
||||
if err != nil {
|
||||
t.Fatalf("validation of %q should succed", "valid SubjectConfirmationData")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateConditions(t *testing.T) {
|
||||
p := newProvider("", "")
|
||||
var err error
|
||||
var notAfter, notBefore time.Time
|
||||
cond := conditions{
|
||||
AudienceRestriction: &audienceRestriction{},
|
||||
}
|
||||
assert := &assertion{}
|
||||
// Assertion without Conditions
|
||||
err = p.validateConditions(assert)
|
||||
if err != nil {
|
||||
t.Fatalf("validation of %q should succeed", "Assertion without Conditions")
|
||||
}
|
||||
// Assertion with empty Conditions
|
||||
assert.Conditions = &cond
|
||||
err = p.validateConditions(assert)
|
||||
if err != nil {
|
||||
t.Fatalf("validation of %q should succeed", "Assertion with empty Conditions")
|
||||
}
|
||||
// Conditions with valid timestamps
|
||||
notBefore = p.now().Add(-time.Duration(60) * time.Second)
|
||||
notAfter = p.now().Add(+time.Duration(60) * time.Second)
|
||||
cond.NotBefore = xmlTime(notBefore)
|
||||
cond.NotOnOrAfter = xmlTime(notAfter)
|
||||
err = p.validateConditions(assert)
|
||||
if err != nil {
|
||||
t.Fatalf("validation of %q should succeed", "Conditions with valid timestamps")
|
||||
}
|
||||
// Conditions where notBefore is 45 seconds after now
|
||||
notBefore = p.now().Add(+time.Duration(45) * time.Second)
|
||||
cond.NotBefore = xmlTime(notBefore)
|
||||
err = p.validateConditions(assert)
|
||||
if err == nil {
|
||||
t.Fatalf("validation of %q should fail", "Conditions where notBefore is 45 seconds after now")
|
||||
}
|
||||
// Conditions where notBefore is 15 seconds after now
|
||||
notBefore = p.now().Add(+time.Duration(15) * time.Second)
|
||||
cond.NotBefore = xmlTime(notBefore)
|
||||
err = p.validateConditions(assert)
|
||||
if err != nil {
|
||||
t.Fatalf("validation of %q should succeed", "Conditions where notBefore is 15 seconds after now")
|
||||
}
|
||||
// Audiences contains the redirectURI
|
||||
validAudience := audience{Value: p.redirectURI}
|
||||
cond.AudienceRestriction.Audiences = []audience{validAudience}
|
||||
err = p.validateConditions(assert)
|
||||
if err != nil {
|
||||
t.Fatalf("validation of %q should succeed: %v", "Audiences contains the redirectURI", err)
|
||||
}
|
||||
// Audiences is not empty and not contains the issuer
|
||||
invalidAudience := audience{Value: "invalid"}
|
||||
cond.AudienceRestriction.Audiences = []audience{invalidAudience}
|
||||
err = p.validateConditions(assert)
|
||||
if err == nil {
|
||||
t.Fatalf("validation of %q should succeed", "Audiences is not empty and not contains the issuer")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,56 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<saml2p:Response xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:xs="http://www.w3.org/2001/XMLSchema" Destination="http://127.0.0.1:5556/dex/callback" ID="id19906521125278359305566047" InResponseTo="6zmm5mguyebwvajyf2sdwwcw6m" IssueInstant="2017-04-04T04:34:59.330Z" Version="2.0">
|
||||
<saml2:Issuer xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion" Format="urn:oasis:names:tc:SAML:2.0:nameid-format:entity">http://www.okta.com/exk91cb99lKkKSYoy0h7</saml2:Issuer>
|
||||
<saml2p:Status xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol">
|
||||
<saml2p:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
|
||||
</saml2p:Status>
|
||||
<saml2:Assertion xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion" xmlns:xs="http://www.w3.org/2001/XMLSchema" ID="id199065211253338521862321146" IssueInstant="2017-04-04T04:34:59.330Z" Version="2.0">
|
||||
<Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
|
||||
<SignedInfo>
|
||||
<CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
|
||||
<SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/>
|
||||
<Reference URI="#id199065211253338521862321146">
|
||||
<Transforms>
|
||||
<Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/>
|
||||
<Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
|
||||
</Transforms>
|
||||
<DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/>
|
||||
<DigestValue/>
|
||||
</Reference>
|
||||
</SignedInfo>
|
||||
<SignatureValue/>
|
||||
<KeyInfo>
|
||||
<X509Data/>
|
||||
</KeyInfo>
|
||||
</Signature>
|
||||
<saml2:Issuer xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion" Format="urn:oasis:names:tc:SAML:2.0:nameid-format:entity">http://www.okta.com/exk91cb99lKkKSYoy0h7</saml2:Issuer>
|
||||
<saml2:Subject xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">
|
||||
<saml2:NameID Format="urn:oasis:names:tc:SAML:2.0:nameid-format:persistent">eric.chiang+okta@coreos.com</saml2:NameID>
|
||||
<saml2:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
|
||||
<saml2:SubjectConfirmationData InResponseTo="6zmm5mguyebwvajyf2sdwwcw6m" NotOnOrAfter="2017-04-04T04:39:59.330Z" Recipient="http://127.0.0.1:5556/dex/callback"/>
|
||||
</saml2:SubjectConfirmation>
|
||||
</saml2:Subject>
|
||||
<saml2:Conditions xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion" NotBefore="2017-04-04T04:29:59.330Z" NotOnOrAfter="2017-04-04T04:39:59.330Z">
|
||||
<saml2:AudienceRestriction>
|
||||
<saml2:Audience>http://127.0.0.1:5556/dex/callback</saml2:Audience>
|
||||
</saml2:AudienceRestriction>
|
||||
</saml2:Conditions>
|
||||
<saml2:AuthnStatement xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion" AuthnInstant="2017-04-04T04:34:59.330Z" SessionIndex="6zmm5mguyebwvajyf2sdwwcw6m">
|
||||
<saml2:AuthnContext>
|
||||
<saml2:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</saml2:AuthnContextClassRef>
|
||||
</saml2:AuthnContext>
|
||||
</saml2:AuthnStatement>
|
||||
<saml2:AttributeStatement xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">
|
||||
<saml2:Attribute Name="email" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:unspecified">
|
||||
<saml2:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xs:string">eric.chiang+okta@coreos.com</saml2:AttributeValue>
|
||||
</saml2:Attribute>
|
||||
<saml2:Attribute Name="Name" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:unspecified">
|
||||
<saml2:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xs:string">Eric</saml2:AttributeValue>
|
||||
</saml2:Attribute>
|
||||
<saml2:Attribute Name="groups" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:unspecified">
|
||||
<saml2:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xs:string">Everyone</saml2:AttributeValue>
|
||||
<saml2:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xs:string">Admins</saml2:AttributeValue>
|
||||
</saml2:Attribute>
|
||||
</saml2:AttributeStatement>
|
||||
</saml2:Assertion>
|
||||
</saml2p:Response>
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue