forked from mystiq/dex
Compare commits
1687 commits
Author | SHA1 | Date | |
---|---|---|---|
|
2081f7d057 | ||
|
b9d88c723f | ||
|
e74acdff6c | ||
|
b479d26137 | ||
|
adb5454913 | ||
|
4bcdcf8e1e | ||
|
3df7c489ce | ||
|
ce11154529 | ||
|
e1a407830d | ||
|
83e2df821e | ||
|
454122ca22 | ||
|
4a0218e87c | ||
|
27c25d00be | ||
|
367487d7c5 | ||
|
3b7e56035a | ||
|
f53fab6b06 | ||
|
100246328b | ||
|
d564cc7200 | ||
|
a3e2946cfc | ||
|
f49e7bc218 | ||
|
9ebcd651ff | ||
|
1aaa7fa0b7 | ||
|
c561318baa | ||
|
5066414735 | ||
|
731d0d7d9d | ||
|
1cc26fab2f | ||
|
f34529b13f | ||
|
ffec99287b | ||
|
ea46fc39ca | ||
|
e253fa8efb | ||
|
c538f3d6a2 | ||
|
33483aa179 | ||
|
b6c4112c88 | ||
|
60228d8fd8 | ||
|
b07c8b1d8d | ||
|
9079c31637 | ||
|
a51d12056f | ||
|
6c99a9b99d | ||
|
3836196af2 | ||
|
b578e4d8e5 | ||
|
8360cbfbde | ||
|
465be883a3 | ||
|
870395971e | ||
|
ebb27418c4 | ||
|
15a516684b | ||
|
dcb25d0c3d | ||
|
59b69352e0 | ||
|
89d1c51e9b | ||
|
a0fd469e47 | ||
|
b6cc099305 | ||
|
6eeba947f1 | ||
|
a858ffbcf2 | ||
|
65592d0b5a | ||
|
cbf158bcc0 | ||
|
6da5187b47 | ||
|
957def7928 | ||
|
ec4ac04c41 | ||
|
bdfb10137a | ||
|
e9d17888d8 | ||
|
b4ccd92d65 | ||
|
505726e7d5 | ||
|
70e6cc2205 | ||
|
3df9cf2cb9 | ||
|
a02f2e8fac | ||
|
55d963ac77 | ||
|
c2f3bea207 | ||
|
1736f95024 | ||
|
ab02a2d714 | ||
|
a3dfe30a12 | ||
|
1884705b87 | ||
|
8e6d123772 | ||
|
999d3855c1 | ||
|
81818b9afe | ||
|
2baf728d1f | ||
|
7071480c2a | ||
|
071969f172 | ||
|
f881fb4b2e | ||
|
bbb3bba01a | ||
|
97c7f2491b | ||
|
6c5286cbfe | ||
|
a3880c7371 | ||
|
dc0dfa771f | ||
|
6759369e16 | ||
|
a7ca81f03a | ||
|
51f1ec441d | ||
|
a4fb0a089f | ||
|
c98646f004 | ||
|
9da59ce5dd | ||
|
502a2d0d4a | ||
|
f09af6102c | ||
|
3d5a3befb4 | ||
|
97254db62a | ||
|
0270536a2e | ||
|
861ad968c5 | ||
|
d26d4e15bc | ||
|
96e0229205 | ||
|
866f3e0c76 | ||
|
47411e9a75 | ||
|
f26181558c | ||
|
574650abe3 | ||
|
3a83b6ce39 | ||
|
a232af7f28 | ||
|
c74ad3bb66 | ||
|
a98ab893c2 | ||
|
2571ae9096 | ||
|
38fe0f5319 | ||
|
92161abfdc | ||
|
5fe1647fc7 | ||
|
7c335e9337 | ||
|
35f58dca73 | ||
|
9cd29bdee0 | ||
|
997ec94a4a | ||
|
f07a58a7f1 | ||
|
691f8be785 | ||
|
453504c450 | ||
|
fd15dd2248 | ||
|
ebe1c8b14a | ||
|
5c70f1227f | ||
|
0b5a9581cd | ||
|
7b75e1e0cc | ||
|
6f07a27fad | ||
|
42f8f91ebf | ||
|
27fb1cf3bd | ||
|
a9fb4ae7ef | ||
|
b8f2186593 | ||
|
ec9a57ee4b | ||
|
7c60f79f10 | ||
|
1067641e53 | ||
|
e9a43bf3cd | ||
|
75d198bd85 | ||
|
c5c88a688b | ||
|
b26e639515 | ||
|
ad89e01676 | ||
|
c8ff7ed40a | ||
|
3702525c86 | ||
|
8b2ce6252d | ||
|
6038af5044 | ||
|
95e81a925f | ||
|
4a5f2dbb4d | ||
|
aa35fa6580 | ||
|
0f5481a00a | ||
|
783a7621e0 | ||
|
169b5a59cc | ||
|
4088d4f897 | ||
|
2b262ff5d6 | ||
|
6822ad950f | ||
|
a2089dd8e7 | ||
|
fdc43a0c36 | ||
|
d8289d3429 | ||
|
23de36d721 | ||
|
a51ed2c4db | ||
|
9b1a8409f1 | ||
|
b51e73bc2e | ||
|
0c3c577b52 | ||
|
20b03b3f6d | ||
|
07a43f2d66 | ||
|
493c0eb8b7 | ||
|
50dc2f5518 | ||
|
ff68ca1aae | ||
|
cf78e741ca | ||
|
e462d69353 | ||
|
b163944ee5 | ||
|
a136c0141e | ||
|
2ebcd70d30 | ||
|
6692759586 | ||
|
897ae8d2a3 | ||
|
863416f0a3 | ||
|
090593b7f9 | ||
|
df1cb1cdbf | ||
|
20e2e429b3 | ||
|
c98636457b | ||
|
f1cc7133da | ||
|
111ce66bd0 | ||
|
57e9611ff6 | ||
|
cb9f0b5d5e | ||
|
a322f42a10 | ||
|
22a7d3acd3 | ||
|
5d9d68106a | ||
|
b83ba01c40 | ||
|
5f9abc5be8 | ||
|
98ed9b70a4 | ||
|
a190bba9e6 | ||
|
5b0cb0704a | ||
|
616e20b334 | ||
|
fd545e0493 | ||
|
575d935792 | ||
|
e22c24dba2 | ||
|
333b1d1971 | ||
|
d9535b8dc1 | ||
|
364f7954fd | ||
|
dde621980d | ||
|
2e2471b21f | ||
|
102762062b | ||
|
5f58d8e7d2 | ||
|
b97732f353 | ||
|
f8685d2e83 | ||
|
592a9f603f | ||
|
727b0101f7 | ||
|
75c27c8dba | ||
|
470327e002 | ||
|
8cee3927b4 | ||
|
238c07ac33 | ||
|
bf034906fd | ||
|
d03d229ddc | ||
|
f8fcae5f07 | ||
|
71d95d7aa2 | ||
|
6275eba9ce | ||
|
bf0025fbd3 | ||
|
2eedc5897c | ||
|
13f93cb785 | ||
|
254165d665 | ||
|
4ee9658dfe | ||
|
0f89054634 | ||
|
8519219dae | ||
|
2bc4ad6b56 | ||
|
cd44a3e4f3 | ||
|
49e15945a2 | ||
|
12a904afdd | ||
|
373bddaf73 | ||
|
593d03789d | ||
|
deac802c73 | ||
|
b434058f19 | ||
|
c205b49189 | ||
|
22d27c60e4 | ||
|
73ce1eb110 | ||
|
419db81c67 | ||
|
55605751f5 | ||
|
b28098dde8 | ||
|
1608b473eb | ||
|
2b6bb1997c | ||
|
14a0aecc81 | ||
|
45143c98b3 | ||
|
0394bf8cea | ||
|
a672ff9288 | ||
|
764ce711b6 | ||
|
ba1bd65c10 | ||
|
3e0f7c42b8 | ||
|
f44af5c8e9 | ||
|
eb26422bdc | ||
|
716eef83bc | ||
|
e8d8967a5b | ||
|
adaa31c0a5 | ||
|
97abc800fb | ||
|
cb916cdf43 | ||
|
84802f247f | ||
|
3bc6a45ee1 | ||
|
d112627564 | ||
|
7f17aae35d | ||
|
c8d55ce016 | ||
|
39ddadcd8e | ||
|
74dc922703 | ||
|
25f5b47272 | ||
|
79721196a8 | ||
|
243661155e | ||
|
3fa53bbc3d | ||
|
a407b5861b | ||
|
93b32c3500 | ||
|
4a42e80a7a | ||
|
a941593b8b | ||
|
9d3471e39b | ||
|
79233f41ef | ||
|
a413d9b383 | ||
|
b14b0fd127 | ||
|
ae1b50c26b | ||
|
ac02fb04cf | ||
|
ca615f7ad7 | ||
|
7ebc76b79e | ||
|
6256b863b0 | ||
|
131bf83699 | ||
|
578cb05f7b | ||
|
6d55fe1c80 | ||
|
40e21f14ca | ||
|
1f30080e6a | ||
|
e7c287a00d | ||
|
539e08ba50 | ||
|
e00e75b773 | ||
|
528ef18c2f | ||
|
f70015dfed | ||
|
f717c71d66 | ||
|
8b865169bd | ||
|
7c80e44caf | ||
|
45932bd38a | ||
|
f980d3e0a7 | ||
|
8ea121b45a | ||
|
49cb30af26 | ||
|
02860da8b6 | ||
|
60b8875780 | ||
|
9952851cc4 | ||
|
fdf19e8014 | ||
|
930b331a5b | ||
|
a087c05ebf | ||
|
9284ffb8c0 | ||
|
1cb4b32fcb | ||
|
98e7d7a99d | ||
|
bc9322ff44 | ||
|
d3c4a170e3 | ||
|
0aad109b6f | ||
|
e875745ee0 | ||
|
49f9853a89 | ||
|
1ecc17292b | ||
|
f45fe6d0c1 | ||
|
054e397177 | ||
|
ee76923443 | ||
|
1bfb1ab757 | ||
|
f92fc54b7a | ||
|
21a8ac6d3c | ||
|
12a2c2b104 | ||
|
a86beb8952 | ||
|
d6cf1704ea | ||
|
14fe699dcf | ||
|
d2eec79e48 | ||
|
588910468a | ||
|
aebe808162 | ||
|
71d5c3415f | ||
|
5854dd192d | ||
|
a1c1076137 | ||
|
84b241721e | ||
|
18311aa44d | ||
|
30bfb924c2 | ||
|
58cac422f5 | ||
|
5210e758d2 | ||
|
514c2f29c6 | ||
|
e2c40f8f71 | ||
|
66aba9c32b | ||
|
b9046ce566 | ||
|
ed5315bb2e | ||
|
f7d2bf38b2 | ||
|
9fad0602ec | ||
|
c319983ecc | ||
|
a48c8ea9a4 | ||
|
94597d8dc8 | ||
|
4aa7e6846f | ||
|
0dd5d65cc7 | ||
|
ee5b5b25bd | ||
|
a15cd8788f | ||
|
6bb627f3e3 | ||
|
2e0041f95f | ||
|
67ba7a1c70 | ||
|
7bc966217d | ||
|
8fd69c16f5 | ||
|
8593933883 | ||
|
ff6e7c7688 | ||
|
d4e82e3315 | ||
|
f92a6f4457 | ||
|
3456c3315d | ||
|
a417f5d1b0 | ||
|
5169c4317d | ||
|
551022046c | ||
|
f0186ff265 | ||
|
40b426b276 | ||
|
8a7c2b47f1 | ||
|
c6f6dd69e9 | ||
|
575742b137 | ||
|
096e229562 | ||
|
4d4edaf540 | ||
|
fb38e1235d | ||
|
eae3219e4d | ||
|
79ce4fdbad | ||
|
c9b4e8db3b | ||
|
0d5b2ac060 | ||
|
b4b74955bd | ||
|
1497e70225 | ||
|
1b8f544873 | ||
|
e412369851 | ||
|
b74af809fc | ||
|
656f5548d4 | ||
|
53c6eb6675 | ||
|
b11a2a5604 | ||
|
6dcf7a042d | ||
|
33ba1d3b74 | ||
|
b3b9c26e5a | ||
|
4488af24b6 | ||
|
ad13fdc523 | ||
|
a950a24811 | ||
|
691ecbbd9e | ||
|
e1c88b2598 | ||
|
22db25ef94 | ||
|
e472fe668e | ||
|
d5727600ae | ||
|
10a3ab9c4c | ||
|
fdc46d2bd3 | ||
|
4f3e410e33 | ||
|
091d9eae83 | ||
|
2a54eb4e4f | ||
|
6c61425e36 | ||
|
dc859e1ca9 | ||
|
27d4075f54 | ||
|
46a7f81f6d | ||
|
7c0fd3f804 | ||
|
c110f12441 | ||
|
f9f48016f4 | ||
|
532bc88a65 | ||
|
f02415a83d | ||
|
c4066b2153 | ||
|
fa18b14437 | ||
|
11a9476bef | ||
|
3009ae3b5d | ||
|
6865d84ae4 | ||
|
050339df86 | ||
|
0e2459c230 | ||
|
53c2dc30b5 | ||
|
3c1763a14f | ||
|
fd67978363 | ||
|
d6f14bd2d5 | ||
|
2109211d09 | ||
|
3fac2ab6bc | ||
|
766fc7ad99 | ||
|
1f7ddac4e2 | ||
|
3f5f6172af | ||
|
65edeff231 | ||
|
a6fbdc3ec8 | ||
|
f1d4fec45e | ||
|
e650aef331 | ||
|
e1f3bfe418 | ||
|
4ffaa60d21 | ||
|
138364ceeb | ||
|
ff10f84e42 | ||
|
d4bd37156d | ||
|
30c6ddd556 | ||
|
a9942794e5 | ||
|
1e61e9b1b4 | ||
|
5b7ec77538 | ||
|
3d3f275efb | ||
|
d413870f6e | ||
|
823484f024 | ||
|
033a8d89f2 | ||
|
2211c515a6 | ||
|
39f0e0e0b0 | ||
|
be492e97ce | ||
|
731e53913a | ||
|
9bb764b63c | ||
|
176a6738fd | ||
|
6cac94f720 | ||
|
eec8ed6182 | ||
|
f72602c3bd | ||
|
0780edbcbe | ||
|
0d3d787511 | ||
|
27cc11b4d6 | ||
|
ceb4324c18 | ||
|
e1ae7240f4 | ||
|
61881d751b | ||
|
2e5e1488e6 | ||
|
452d466481 | ||
|
59c7e20c3c | ||
|
81e884e903 | ||
|
cfc8d198ac | ||
|
215c3160f8 | ||
|
f7c09760f2 | ||
|
79e1f25f21 | ||
|
f6904c38ef | ||
|
5e6a8362c6 | ||
|
3981ac8aa6 | ||
|
76cb5f521c | ||
|
e51704e41a | ||
|
9fe031776e | ||
|
cba7d69577 | ||
|
5c315a3a4e | ||
|
0417789626 | ||
|
ddd19bf91d | ||
|
a207238491 | ||
|
7043d944cf | ||
|
831c0efe9c | ||
|
0754c30ac2 | ||
|
7a2472555a | ||
|
baec4f79ce | ||
|
fd2c86d36e | ||
|
6c8c336e9a | ||
|
753cff1764 | ||
|
aece0ce873 | ||
|
245a46e743 | ||
|
f45a89f6b3 | ||
|
21a01ee811 | ||
|
93ded5c406 | ||
|
97591861b2 | ||
|
00950eedd6 | ||
|
bc5371e730 | ||
|
5a48d8a82d | ||
|
6384af06e4 | ||
|
cdcf7a4694 | ||
|
5d996661ea | ||
|
4b54433ec2 | ||
|
95941506f5 | ||
|
8dbd0c6536 | ||
|
aef61cea8d | ||
|
0bef10ef80 | ||
|
5451188e29 | ||
|
ca02fc16bd | ||
|
dea1d3383c | ||
|
13a83d9bba | ||
|
cd0c24ec4d | ||
|
030a6459d6 | ||
|
88025b3d7c | ||
|
0284a4c3c9 | ||
|
cdbb5dd94d | ||
|
4a874cce89 | ||
|
461c5f687d | ||
|
4e4dad023c | ||
|
1220017f6c | ||
|
20875c972e | ||
|
47d029a51b | ||
|
18d1f70cee | ||
|
fe8085b886 | ||
|
283dd89f4d | ||
|
c65652ed8f | ||
|
49adc4e5bb | ||
|
19884d92ac | ||
|
ecea593ddd | ||
|
47bdbdb1a2 | ||
|
81c4dc7994 | ||
|
ba2cec3f72 | ||
|
fcca5f4b4f | ||
|
b1292bd630 | ||
|
8553309db3 | ||
|
94a2b3ed87 | ||
|
24fa4def5b | ||
|
2e61860d5a | ||
|
11859166d0 | ||
|
674631c9ab | ||
|
47b0a2bdf9 | ||
|
e2b56d0a09 | ||
|
4561214ab2 | ||
|
afa6f1e03e | ||
|
df9fc78d2d | ||
|
bf8c35ad2d | ||
|
59fcab281e | ||
|
05b61a3d86 | ||
|
551229a986 | ||
|
b1ac799073 | ||
|
31c18e557a | ||
|
5bc3cb2ad3 | ||
|
efd9839fd2 | ||
|
fa3a4d7f6b | ||
|
0b9b588c96 | ||
|
026d979073 | ||
|
e4065013a4 | ||
|
d4a2a362ab | ||
|
de6d1bea56 | ||
|
8fbbd4cec9 | ||
|
b79d9a84bc | ||
|
03db309337 | ||
|
c7549cce5b | ||
|
656798c8bd | ||
|
beb8911cf7 | ||
|
b73c406d21 | ||
|
4b924f1d86 | ||
|
55352575b8 | ||
|
d2eb1b04dc | ||
|
0f4ad150ce | ||
|
b57c8fa75b | ||
|
1076081f79 | ||
|
f5a29bcdbb | ||
|
e18510b16e | ||
|
95796b04a3 | ||
|
2bf728c6ec | ||
|
356ccecc24 | ||
|
8e7ce6353f | ||
|
d25051c867 | ||
|
41712bcbfa | ||
|
e3f8b0f2f6 | ||
|
8cba308b0e | ||
|
3adb4e74df | ||
|
1ec5cf07f2 | ||
|
3e12618f0c | ||
|
bbd8b3b3cd | ||
|
3ecdd57282 | ||
|
a050f3228a | ||
|
3b80d480e5 | ||
|
d1e8b085e2 | ||
|
78fcac7568 | ||
|
3f0ca9b361 | ||
|
2f28fc7451 | ||
|
4e569024fd | ||
|
7b50cbf0ac | ||
|
1eab25f89f | ||
|
10e9054811 | ||
|
d658c24e8f | ||
|
ec6f3a2f19 | ||
|
a1adf86e53 | ||
|
27dfbc0344 | ||
|
83ad7bc4e3 | ||
|
8fee3cd212 | ||
|
6be747142a | ||
|
dab9f98a15 | ||
|
d93a238a42 | ||
|
3ae53f7434 | ||
|
b9ff4dd9ae | ||
|
04b2f655e6 | ||
|
568fc06520 | ||
|
72d11017ce | ||
|
08647537e2 | ||
|
f7d1405cfd | ||
|
24a1103f11 | ||
|
9cffca70f2 | ||
|
3bd0e91a68 | ||
|
9ed5cc00cf | ||
|
1211a86d58 | ||
|
3c5a631ce3 | ||
|
c73057f93d | ||
|
84a07a7805 | ||
|
796d4c1e6b | ||
|
c166257cf4 | ||
|
ff60ac0c4f | ||
|
1f2771b57e | ||
|
0f68fadb9a | ||
|
b4238886b3 | ||
|
9162eace7a | ||
|
df86a1faca | ||
|
86ea49173c | ||
|
08a10b063f | ||
|
809ccaf4da | ||
|
02cf3db178 | ||
|
95d8a0cccb | ||
|
6f70272bc3 | ||
|
f11db50369 | ||
|
447841f513 | ||
|
39cb542cc3 | ||
|
ee10373993 | ||
|
71351b1f47 | ||
|
ce8b05b0be | ||
|
87ebbaf834 | ||
|
a8c7ed9f67 | ||
|
aa615c04c9 | ||
|
35cd09d481 | ||
|
7da0a89936 | ||
|
316da70545 | ||
|
d77147f7cf | ||
|
024f69b2c7 | ||
|
9340fee011 | ||
|
89295a5b4a | ||
|
4e73f39f57 | ||
|
0c75ed12e2 | ||
|
06c8ab5aa7 | ||
|
91de99d57e | ||
|
10597cf09f | ||
|
715fee7a01 | ||
|
5a667bbee0 | ||
|
9b1ecac0d9 | ||
|
a7a92b0513 | ||
|
1c9fb499b4 | ||
|
1c551fd86b | ||
|
728ae7b348 | ||
|
e50d9a908b | ||
|
6664b5702d | ||
|
8ff53d9d52 | ||
|
4dcce60d5c | ||
|
dd4a62e645 | ||
|
7f744598f5 | ||
|
3241fd4ae2 | ||
|
a6cb627763 | ||
|
226c91df06 | ||
|
d43053e11c | ||
|
e13aac4963 | ||
|
891fa1785f | ||
|
7784a4727c | ||
|
64e47cc22a | ||
|
b598eca785 | ||
|
31f26735ff | ||
|
27a43669a7 | ||
|
e9b83e0a45 | ||
|
65a8bf2af3 | ||
|
f82c217e12 | ||
|
d6b5105d9b | ||
|
a7667dff38 | ||
|
c41f970b16 | ||
|
bb651cc664 | ||
|
c939e51cb4 | ||
|
d2d0d4a1ea | ||
|
a33669e3ec | ||
|
7b2972a04b | ||
|
89f737329b | ||
|
feb90bd1b1 | ||
|
f7156c26eb | ||
|
b19fe5b49d | ||
|
fade69b5ae | ||
|
e49f6661f3 | ||
|
186a719ecb | ||
|
7cf43fdc7e | ||
|
30a5dade0f | ||
|
123185c456 | ||
|
283a87855a | ||
|
369e16e97e | ||
|
0ed680071c | ||
|
a55de6c991 | ||
|
bb503dbd81 | ||
|
a7978890c7 | ||
|
c12c340e3c | ||
|
b2e9f67edc | ||
|
3650fe2287 | ||
|
dbd42ae777 | ||
|
f9b3d8fcb4 | ||
|
a73670488c | ||
|
5bd55f6ee7 | ||
|
6400052265 | ||
|
a255bda911 | ||
|
566ba720a2 | ||
|
2cc8b2fe3c | ||
|
cb2c85b2a5 | ||
|
5bfe1a5d22 | ||
|
1866a7acc8 | ||
|
bf23d392b6 | ||
|
d51937b62d | ||
|
dc40d5b0df | ||
|
c952ba743d | ||
|
040950341d | ||
|
3c686b24ce | ||
|
afba7577bb | ||
|
ccbf6c6e0f | ||
|
8515dfb35d | ||
|
e2ed31656e | ||
|
0a88483409 | ||
|
48d78ec0ab | ||
|
0e1bc202c6 | ||
|
cff4a11b41 | ||
|
97606bd623 | ||
|
757a1eded5 | ||
|
e954e3f9d8 | ||
|
49c9c607c9 | ||
|
6742008fc2 | ||
|
c55d84b5d2 | ||
|
cdefd1f788 | ||
|
a189c25a6e | ||
|
7775a7e27a | ||
|
b8ac640c4f | ||
|
1fbfaa9951 | ||
|
827889ee05 | ||
|
eb10135774 | ||
|
35da73de38 | ||
|
8fdb207848 | ||
|
ba47aaba86 | ||
|
30c3d78365 | ||
|
f2f19fa0d7 | ||
|
4f326390aa | ||
|
ee50c09313 | ||
|
0d53fa2f42 | ||
|
c55f17ea64 | ||
|
4d246bc9dc | ||
|
fc7f1ef6cc | ||
|
b45a501c99 | ||
|
62abddca7d | ||
|
eb9ef3b0ec | ||
|
a825a22f7a | ||
|
e3383564b9 | ||
|
01f7bf73a0 | ||
|
4f0744ce80 | ||
|
4cb5577e11 | ||
|
31353d2ccf | ||
|
17c00e5a58 | ||
|
f2fcb2c989 | ||
|
0af41fb4ca | ||
|
bae6bbb171 | ||
|
1e88cca59a | ||
|
2b9ef8058b | ||
|
84e9cb6947 | ||
|
86526cd030 | ||
|
fecd979bab | ||
|
5d659a108c | ||
|
b155f66785 | ||
|
43b95a2d28 | ||
|
22de6da60b | ||
|
97d3e8fa7f | ||
|
801fd64a11 | ||
|
3f8fdbf314 | ||
|
791ad900cb | ||
|
8476e5acc0 | ||
|
c79b40ad56 | ||
|
6790aea260 | ||
|
d3d447fcf1 | ||
|
119e4d66c6 | ||
|
91e153780d | ||
|
040abe5dc1 | ||
|
1bfe2f6db2 | ||
|
a996c4ba54 | ||
|
a8851ceb1b | ||
|
3352e4e74f | ||
|
8905fb4a65 | ||
|
0288864da7 | ||
|
e164bb381e | ||
|
a1c7198738 | ||
|
f19bccfc92 | ||
|
a12a919d3e | ||
|
01befc00ff | ||
|
31839549cd | ||
|
da4fb97912 | ||
|
10c6eb3186 | ||
|
41c5916b97 | ||
|
2c8fb8a3f2 | ||
|
893413ac4f | ||
|
0f9e2888ab | ||
|
8ed9ef8ad8 | ||
|
c742b2a40a | ||
|
2b0f47306b | ||
|
324b1c886b | ||
|
6e5176822b | ||
|
57640cc7a9 | ||
|
706c3bba68 | ||
|
a136ab6969 | ||
|
845fb1e0f0 | ||
|
64d7156d5f | ||
|
5a87bc5d59 | ||
|
ac43200665 | ||
|
33e13c2aad | ||
|
d97d6de88c | ||
|
f7efe49e5e | ||
|
6ca0cbc857 | ||
|
19d7edd530 | ||
|
bcaddd4354 | ||
|
71bbbee075 | ||
|
9b629b6568 | ||
|
0520465207 | ||
|
b580ffad70 | ||
|
4c86a5e7fe | ||
|
85239d515d | ||
|
10ac93d42b | ||
|
5cc8b562ec | ||
|
b9bc0b8b11 | ||
|
b971415f0c | ||
|
6500fdbdd1 | ||
|
d62f312402 | ||
|
fb282c3506 | ||
|
1e14a33553 | ||
|
ef7e9e5c99 | ||
|
170794725d | ||
|
6fcd9b4887 | ||
|
40409eafe8 | ||
|
bca77245df | ||
|
349832b380 | ||
|
84ea790885 | ||
|
cafea292ca | ||
|
3841f05ba4 | ||
|
ed7b71a190 | ||
|
162073b33e | ||
|
c15e2887bc | ||
|
1ea481bb73 | ||
|
b894d9c888 | ||
|
7198f17d0e | ||
|
be378dd9a7 | ||
|
6cdbb59406 | ||
|
a5ad5eaf08 | ||
|
b5519695a6 | ||
|
2a282860fa | ||
|
c82d21b155 | ||
|
1d83e4749d | ||
|
4d63e9cd68 | ||
|
83673fb320 | ||
|
28b2350cd2 | ||
|
9c026107e6 | ||
|
4801b2c975 | ||
|
4da93e75fc | ||
|
3f41b26fb9 | ||
|
d1f599dd32 | ||
|
a28f5bb218 | ||
|
3288450b3e | ||
|
9187aa669d | ||
|
828a1c6ec2 | ||
|
ec66cedfcc | ||
|
3e5ff2f853 | ||
|
4a1fd77166 | ||
|
3b385ecf4a | ||
|
4b94469547 | ||
|
d4c3a3505d | ||
|
bd2234cd12 | ||
|
9781e56ba5 | ||
|
641615ff58 | ||
|
f3fc0c5395 | ||
|
5807011b6a | ||
|
8a1a1b8b5d | ||
|
549b67bccd | ||
|
c64ff34d11 | ||
|
458059cc89 | ||
|
a64e7c2986 | ||
|
e837475ca6 | ||
|
6745af7747 | ||
|
d4a67e43fa | ||
|
63098fe9fe | ||
|
ca0a9e821e | ||
|
058202d007 | ||
|
0494993326 | ||
|
07dddc7b0e | ||
|
c489a074c1 | ||
|
b1311baa3c | ||
|
336c73c0a2 | ||
|
a24f73c19f | ||
|
d39b77bda3 | ||
|
41207ba265 | ||
|
a783667c57 | ||
|
61312e726e | ||
|
52c39fb130 | ||
|
4812079647 | ||
|
d9afb7e59c | ||
|
9a4e0fcd00 | ||
|
f6cd778b60 | ||
|
6499f5bfd3 | ||
|
19cd9cc65c | ||
|
89c6ebafa2 | ||
|
ff1ed7afaa | ||
|
2fa5e33ae0 | ||
|
9a7926c19b | ||
|
a8cedc8bc3 | ||
|
6dadc26ca2 | ||
|
cb46a28c3c | ||
|
521954a3b9 | ||
|
705cf8bb6a | ||
|
334ecf0482 | ||
|
b4d22bf1b2 | ||
|
ce337661b9 | ||
|
1ea2892b79 | ||
|
1404477326 | ||
|
f91f294385 | ||
|
9882ea453f | ||
|
f6d8427f32 | ||
|
9c699b1028 | ||
|
9bbdc721d5 | ||
|
0d1a0e4129 | ||
|
6d343e059b | ||
|
11fc8568cb | ||
|
bad2a06960 | ||
|
6698f1f80a | ||
|
776aa9dd53 | ||
|
62efe7bf07 | ||
|
70505b258d | ||
|
1d892c6cac | ||
|
0be5232edd | ||
|
04c137e594 | ||
|
1b2ab6fa35 | ||
|
e84682d7b9 | ||
|
7bbda55225 | ||
|
dc3dcdd5c5 | ||
|
5160c659c8 | ||
|
0b067af8e7 | ||
|
eef8c0a60d | ||
|
8b089dc441 | ||
|
83d5f77495 | ||
|
d1b4443740 | ||
|
f29b54d11c | ||
|
df34848caa | ||
|
4792f0c59f | ||
|
371df97cbf | ||
|
415a4ea4f7 | ||
|
5b7a664e9d | ||
|
af9dfd4a29 | ||
|
dad8d6d687 | ||
|
e1a45ba33e | ||
|
e151af1b44 | ||
|
de871b3f8a | ||
|
9d7e472c63 | ||
|
2ca992e9b3 | ||
|
0a9f56527e | ||
|
442d3de11d | ||
|
d2c9305e0f | ||
|
709d4169d6 | ||
|
ba723caa0a | ||
|
c0dfeb7068 | ||
|
47b0d33142 | ||
|
521aa0802f | ||
|
4a0feaf589 | ||
|
d87cf1c924 | ||
|
336e284a46 | ||
|
c830d49884 | ||
|
0a85a97ba9 | ||
|
cd054c71af | ||
|
83d8853fd9 | ||
|
05b8acb974 | ||
|
cfae2eb720 | ||
|
f6476b62f2 | ||
|
ebef257dcd | ||
|
0513ce3d6b | ||
|
ec57e31103 | ||
|
7b7e2a040d | ||
|
3693b74791 | ||
|
db23367150 | ||
|
741bf029a1 | ||
|
ab5ea03025 | ||
|
277272502b | ||
|
8ab1ea9334 | ||
|
d820fd45d8 | ||
|
99c3ec6820 | ||
|
30ea963bb6 | ||
|
b7cf701032 | ||
|
2bd4886517 | ||
|
76bb453ff3 | ||
|
b9787d48ac | ||
|
fab0da7b69 | ||
|
edd3a40141 | ||
|
c7e9960c7e | ||
|
80749ffd3f | ||
|
1160649c31 | ||
|
d33a76fa19 | ||
|
f17fa67715 | ||
|
0f8c4db9f6 | ||
|
49e85a3cb1 | ||
|
11d91c144f | ||
|
cf4f88a06e | ||
|
2ec5e5463f | ||
|
3a3a2bcc86 | ||
|
7ef1179e75 | ||
|
0014ca3465 | ||
|
7319d3796f | ||
|
30cd592801 | ||
|
7c7c1de798 | ||
|
0f1927a1ba | ||
|
ca2d718fe4 | ||
|
573bbeb7de | ||
|
3c26c90dcc | ||
|
3b4cf282c8 | ||
|
2f10b81a20 | ||
|
52a084edd0 | ||
|
a098aa112b | ||
|
37d0b7465d | ||
|
0ce0393725 | ||
|
88fd211fb5 | ||
|
55b49063f8 | ||
|
121a55e0bc | ||
|
afbb62206f | ||
|
26061f9558 | ||
|
b09b7bbbba | ||
|
bb2733fbdd | ||
|
a6b5405c2e | ||
|
6951c2c269 | ||
|
fb0048d509 | ||
|
8894eed8d3 | ||
|
aca67b0839 | ||
|
ea43562793 | ||
|
1d3851b0c5 | ||
|
b85d7849ad | ||
|
e20a795a2a | ||
|
76825fef8f | ||
|
1cdb2b1d74 | ||
|
6104295d5e | ||
|
5db29eb087 | ||
|
0f9a74f1d0 | ||
|
13be146d2a | ||
|
321790870f | ||
|
2d5619e4e8 | ||
|
9560899496 | ||
|
3cbba11012 | ||
|
53897e831d | ||
|
f2e7823db9 | ||
|
6318c105ec | ||
|
383c2fe8b6 | ||
|
98f78db915 | ||
|
d31f6eabd4 | ||
|
296659cb50 | ||
|
5afa02644a | ||
|
789272a0c1 | ||
|
058e72ef50 | ||
|
075ab0938e | ||
|
7e89d8ca24 | ||
|
02c8f85e4d | ||
|
db7711d72a | ||
|
5881a2cfca | ||
|
48954ca716 | ||
|
92e63771ac | ||
|
664fdf76ca | ||
|
1baf48f83c | ||
|
f98332595e | ||
|
ac242a8bc7 | ||
|
a901e2f204 | ||
|
ff4dee5fdb | ||
|
8e0ae82034 | ||
|
3fb85ab009 | ||
|
e0c58d5449 | ||
|
309b33d05a | ||
|
050d5af937 | ||
|
65c77e9db2 | ||
|
2f8d1f8e42 | ||
|
f141f2133b | ||
|
9bd5ae5197 | ||
|
367b187cf4 | ||
|
142c96c210 | ||
|
8c3dc0ca66 | ||
|
e0f927c7a9 | ||
|
bcd47fc6f3 | ||
|
9346e328ef | ||
|
9aec1e7db2 | ||
|
1fd5dd7b0e | ||
|
af9c2880a6 | ||
|
91cbd466a5 | ||
|
64b269d1c1 | ||
|
3c7593f87b | ||
|
11c2499713 | ||
|
d2095bb2d8 | ||
|
532c120ba7 | ||
|
5d2529f0ad | ||
|
0773c6e9f3 | ||
|
1ac4f7fe42 | ||
|
d5b3fc0478 | ||
|
dc812f5341 | ||
|
9b5b604bab | ||
|
4bb4d49952 | ||
|
dbea20d078 | ||
|
a38e215891 | ||
|
c41035732f | ||
|
8fdcee7c14 | ||
|
e11b2ceeee | ||
|
658a2cc477 | ||
|
554870cea0 | ||
|
94bee18f6b | ||
|
9d9a1017e4 | ||
|
6a9bc889b5 | ||
|
c03c98b951 | ||
|
3f55e2da72 | ||
|
36370f8f2a | ||
|
97ffa21262 | ||
|
b1e98d8590 | ||
|
3156553843 | ||
|
c782ac809c | ||
|
c4e96dda32 | ||
|
d9095073c8 | ||
|
77fcf9ad77 | ||
|
f6077083c9 | ||
|
8b344fe4d3 | ||
|
19ad7daa7f | ||
|
45a40a13a3 | ||
|
236b25b68e | ||
|
41b7c855d0 | ||
|
9ce4393156 | ||
|
176ba709a4 | ||
|
fea048b3e8 | ||
|
d38909831c | ||
|
433bb2afec | ||
|
4076eed17b | ||
|
80995dff9b | ||
|
b9b315dd64 | ||
|
7a76c767fe | ||
|
c54f1656c7 | ||
|
c789c5808e | ||
|
7fc3f230df | ||
|
0857a0fe09 | ||
|
5c88713177 | ||
|
0352258093 | ||
|
575c792156 | ||
|
c392236f4f | ||
|
b793afd375 | ||
|
b7184be3dd | ||
|
6d41541964 | ||
|
f2590ee07d | ||
|
d5d3abca6a | ||
|
0b56a47571 | ||
|
799f29fdb5 | ||
|
a58d77a499 | ||
|
0b55f121b4 | ||
|
3f8fd74185 | ||
|
c1b421fa04 | ||
|
6e35f24399 | ||
|
efdb5de6d8 | ||
|
76c76a0b39 | ||
|
4bede5eb80 | ||
|
69d13b766d | ||
|
59beb7425f | ||
|
27944d4f8f | ||
|
839130f01c | ||
|
2c52c52686 | ||
|
42d61191c4 | ||
|
d2c33db8a8 | ||
|
21ab30d207 | ||
|
512cb3169e | ||
|
285c1f162e | ||
|
8427f0f15c | ||
|
42e8619830 | ||
|
3b7292a08f | ||
|
179cce36ef | ||
|
46f48b33a1 | ||
|
c854e760db | ||
|
3e2217b3f4 | ||
|
4f3ab1efb7 | ||
|
15ec95bca9 | ||
|
5c99525ed3 | ||
|
a48f73f14a | ||
|
133c2565be | ||
|
1f31d1889a | ||
|
bd61535cb6 | ||
|
2dccdc2a1a | ||
|
ab08d7b3a4 | ||
|
395febf808 | ||
|
ef08ad8317 | ||
|
aeb2861a40 | ||
|
6e5a2b5ea1 | ||
|
27b8426704 | ||
|
d328a5ebaa | ||
|
963b8e992d | ||
|
d9f6ab4a68 | ||
|
430357b14e | ||
|
b65966d744 | ||
|
e1afe771cb | ||
|
89e43c198b | ||
|
0dbb642f2c | ||
|
d458e882aa | ||
|
bc02006b45 | ||
|
43d1a044bd | ||
|
526e078366 | ||
|
c54ddc460d | ||
|
d36e6c26ee | ||
|
d03a43335e | ||
|
6ae11a1cfe | ||
|
291cd9e01c | ||
|
ea7fd6d470 | ||
|
076cd77469 | ||
|
7c1b4b3005 | ||
|
231e571c3c | ||
|
128d5da89e | ||
|
d9487e553b | ||
|
e2ddefff31 | ||
|
72f5596671 | ||
|
c067761df6 | ||
|
0aee5be625 | ||
|
ff34e570b4 | ||
|
6e98c04f9b | ||
|
fd53c0a3bb | ||
|
458585008b | ||
|
8561a66365 | ||
|
d7c7d42466 | ||
|
c4e0587df1 | ||
|
20a858da3b | ||
|
74023ba9ad | ||
|
9c211132b2 | ||
|
56f8e60545 | ||
|
6769a3b18e | ||
|
6ccb96ff74 | ||
|
e3203382fc | ||
|
7409d16541 | ||
|
bc27a617c5 | ||
|
b8cdc88803 | ||
|
a572ad8fec | ||
|
e53bdfabb9 | ||
|
447f24a81b | ||
|
af81297d4e | ||
|
421c26fdf5 | ||
|
07a77e0dac | ||
|
6379403a68 | ||
|
dd84e73c0e | ||
|
92920c86ea | ||
|
10611f3156 | ||
|
51f50fcad8 | ||
|
39dc5dcfb7 | ||
|
645a441527 | ||
|
8b4dbb9fe7 | ||
|
5b66bf05c8 | ||
|
59b6595c37 | ||
|
8959dc4275 | ||
|
21174c06a1 | ||
|
840065faaf | ||
|
46f5726d11 | ||
|
157c359f3e | ||
|
3dd1bac821 | ||
|
74f4e749b9 | ||
|
d7750b1e26 | ||
|
a8d059a237 | ||
|
d6fad19d95 | ||
|
c19ada3236 | ||
|
8613c78863 | ||
|
8c1716d356 | ||
|
dfb2dfd333 | ||
|
4e8cbf0f61 | ||
|
e137db978d | ||
|
11913a28c6 | ||
|
49e59fb54f | ||
|
9650836851 | ||
|
59560c9919 | ||
|
52d09a2dfa | ||
|
b189d07d53 | ||
|
cd3c6983da | ||
|
35f51957c0 | ||
|
06ec381082 | ||
|
0babb2df18 | ||
|
429bb9303f | ||
|
d8f9634afc | ||
|
55cebd58a8 | ||
|
7b416b5a8e | ||
|
a08a5811d4 | ||
|
b1931fc9bd | ||
|
34c7cfaf82 | ||
|
6ae76662de | ||
|
d6ad67a6de | ||
|
60f47c4228 | ||
|
c5f2871ab5 | ||
|
29d8428387 | ||
|
56f02b95c6 | ||
|
f6741d1837 | ||
|
fc723af0fe | ||
|
83a0326b88 | ||
|
ac290f77aa | ||
|
c113df2730 | ||
|
d877fca092 | ||
|
06521ffa49 | ||
|
aec2edb441 | ||
|
d1c8f8d095 | ||
|
e913a252cd | ||
|
c48cb36e8f | ||
|
8b4a9bf5ee | ||
|
6c71b330a8 | ||
|
be581fa7ff | ||
|
ca66289077 | ||
|
b96b02e506 | ||
|
7bd4071b4c | ||
|
815311fa19 | ||
|
8935a1479c | ||
|
fe247b106b | ||
|
9840fccdbb | ||
|
be171a2a53 | ||
|
8f113548a5 | ||
|
b6f4740a15 | ||
|
df18cb0c22 | ||
|
5822a5ce9e | ||
|
1911b52c6b | ||
|
03ffd0798c | ||
|
18b6b34b67 | ||
|
b5826e66f0 | ||
|
81f155882a | ||
|
59f8b02d47 | ||
|
4abf3b2102 | ||
|
2cfadcd95a | ||
|
337bbe5f09 | ||
|
1b7b3515d7 | ||
|
fbdb55aba9 | ||
|
2d7de4ec70 | ||
|
5ae094206b | ||
|
86eeee2ae2 | ||
|
f1581ff873 | ||
|
e8ba848907 | ||
|
88d1e2b041 | ||
|
a965365a2b | ||
|
0774a89066 | ||
|
2d1ac74ec0 | ||
|
ee54a50956 | ||
|
27f66e795e | ||
|
b78b8aeee0 | ||
|
4329f407dc | ||
|
bad15c1c02 | ||
|
a3cf7b63b7 | ||
|
74f84ce0be | ||
|
468c74d1d2 | ||
|
aafbaa36c5 | ||
|
60264d440c | ||
|
46296ab9d0 | ||
|
f5befb2e2d | ||
|
1a565266fc | ||
|
8a479707b3 | ||
|
eaeab218b8 | ||
|
172df9ccef | ||
|
17ac7c8a86 | ||
|
73fdf4f75b | ||
|
badbc8c738 | ||
|
d91f9fbc51 | ||
|
8f3cca7ba4 | ||
|
f3acec0b1b | ||
|
01c6b9dd91 | ||
|
007e4dae3c | ||
|
5355b81e2a | ||
|
9ea2ade208 | ||
|
e603a5e631 | ||
|
9b5122568a | ||
|
72c9cf43a9 | ||
|
45eb9b279b | ||
|
58093dbb29 | ||
|
e028b79c97 | ||
|
5f054fcf2e | ||
|
f7f7314fdb | ||
|
85dd0684ba | ||
|
587081a643 | ||
|
5d67da1472 | ||
|
aa068b667a | ||
|
9b9013a560 | ||
|
7e96021428 | ||
|
6182f213ef | ||
|
58b546a5be | ||
|
0740c2370d | ||
|
cbcb1f61f3 | ||
|
1d0568efe9 | ||
|
efb15205e9 | ||
|
d40043808b | ||
|
0b856d1a75 | ||
|
bb11a1ebee | ||
|
b1fd2fa8b1 | ||
|
f82b904d05 | ||
|
7c8a22443a | ||
|
84ea412ca6 | ||
|
f21e6a0f00 | ||
|
42997448a7 | ||
|
4738070951 | ||
|
7bd084bc07 | ||
|
7c63be4104 | ||
|
2425c6ea63 | ||
|
13a1679892 | ||
|
20bc6cd353 | ||
|
dcca427592 | ||
|
5236b2c819 | ||
|
e5ebcf518a | ||
|
ce3cd53a11 | ||
|
e876353128 | ||
|
a9f71e378f | ||
|
e10b8232d1 | ||
|
51d9b3d3ca | ||
|
ff8b44558e | ||
|
281ec27118 | ||
|
9d1ec6c36b | ||
|
c14b2fd5a5 | ||
|
3295084236 | ||
|
49fa5ee6e8 | ||
|
1723e13fed | ||
|
6536d97812 | ||
|
c9b18b2785 | ||
|
6531b256e2 | ||
|
9926a0dced | ||
|
57b1031352 | ||
|
505aac753d | ||
|
5f0a03a06b | ||
|
44e988fb41 | ||
|
9b5bec1ddf | ||
|
d2daa4e2ac | ||
|
65b0c91992 | ||
|
f903a2890e | ||
|
e11446aff0 | ||
|
50b96564f5 | ||
|
867130e22e | ||
|
5514805cf0 | ||
|
9f91ae0255 | ||
|
b9b21260bc | ||
|
0db538a4bb | ||
|
56eeb07e85 | ||
|
fcf4371b1b | ||
|
73498316c7 | ||
|
e1acb6d577 | ||
|
a1b6ba9bba | ||
|
74bfbcefbc | ||
|
50afa921b5 | ||
|
6ffc8fcd8d | ||
|
d26e23c16f | ||
|
2c024d8caf | ||
|
8c75d85b60 | ||
|
b58053eefc | ||
|
26c0206627 | ||
|
ff70c0453f | ||
|
317f433a14 | ||
|
2707302054 | ||
|
316acbee03 | ||
|
a823c021c8 | ||
|
06241eae9f | ||
|
bf39130bab | ||
|
29bc098620 | ||
|
1260c62a80 | ||
|
86a3346b64 | ||
|
666356d22d | ||
|
4a6da13097 | ||
|
1ea1d809a1 | ||
|
9cc85c447c | ||
|
1309c1f037 | ||
|
bb75dcd793 | ||
|
9f10e5d020 | ||
|
14b89029c9 | ||
|
b9f6594bf0 | ||
|
974617a426 | ||
|
6a2d4ab6b4 | ||
|
3bbc2c0bde | ||
|
4dc3347106 | ||
|
4caf82c1e9 | ||
|
cabdcb1eb0 | ||
|
e3e37504ca | ||
|
9de19cb899 | ||
|
5454a4729f | ||
|
49bbcd343f | ||
|
94bd948aac | ||
|
b71bec2ba1 | ||
|
32e9570116 | ||
|
780a359f8e | ||
|
036e5d050d | ||
|
384db1f33e | ||
|
0822f1d4d3 | ||
|
bf3ffb53a0 | ||
|
ca3d73c36d | ||
|
468b5e3f0a | ||
|
481f1276a8 | ||
|
fc8b20ba35 | ||
|
0d3edf2456 | ||
|
264484075a | ||
|
f2eac0e723 | ||
|
d92c21b9f9 | ||
|
218d671a96 | ||
|
608260d0f1 | ||
|
39a66d1496 | ||
|
822a10cede | ||
|
01d63b086f | ||
|
b03c85e56e | ||
|
ce686390a5 | ||
|
c0bcc81997 | ||
|
4aec353aec | ||
|
1dbecefadf | ||
|
f83c86cead | ||
|
ea2c63d7b0 | ||
|
2851b3c7a6 | ||
|
460f48320e | ||
|
2215158b2a | ||
|
6ef8cd512f | ||
|
0930b09e4e | ||
|
5f03479d29 | ||
|
053c476c4f | ||
|
0811d1a07a | ||
|
b5baf6b1ca | ||
|
6d4fef4b9a | ||
|
9d4b1041bd | ||
|
da45adcb6e | ||
|
f013a44581 | ||
|
ec5e2cc3c6 | ||
|
5ef1312b38 | ||
|
f18d7afc6f | ||
|
5172a46171 | ||
|
e7d57bb31b | ||
|
18da628842 | ||
|
32257bcf8e | ||
|
c5de6fa733 | ||
|
ab102b8189 | ||
|
35063da41e | ||
|
9948228e5b | ||
|
861d4ae447 | ||
|
c872938298 | ||
|
19cb2a5ffb | ||
|
47df6ea2ff | ||
|
6193bf5566 | ||
|
f4b6bf2ac3 | ||
|
bc01767212 | ||
|
41f663f70c | ||
|
b746ab4975 | ||
|
75a07f2bfa | ||
|
b09a13458f | ||
|
04e276f2df | ||
|
df075d8bda | ||
|
97d395e351 | ||
|
b58fba6753 | ||
|
ccf85a7269 | ||
|
e617197871 | ||
|
a2188bebf1 | ||
|
fc1c60ed8f | ||
|
e623bd626e | ||
|
3dfc4b430e | ||
|
42ef8fd802 | ||
|
3d2d92b31b | ||
|
b410622885 | ||
|
ca114f7812 | ||
|
943e23cd54 | ||
|
6475ce1f62 | ||
|
e3b96243b5 | ||
|
2b13bdd12d | ||
|
d5a9712aae | ||
|
3b5df52c0f | ||
|
ab06119431 | ||
|
3d65b774d6 | ||
|
13b4f84f79 | ||
|
4931f30a80 | ||
|
d099145921 | ||
|
751c565e9d | ||
|
a41d93db4a | ||
|
f3c85e6936 | ||
|
3849abb18a | ||
|
620695ed2b | ||
|
fcf00019de | ||
|
10c0ec0d48 | ||
|
9c176dd1bd | ||
|
5ea886473c | ||
|
eb14a8245c | ||
|
82879b3b3e | ||
|
4605fdd551 | ||
|
fe1516332c | ||
|
e3c9b49299 | ||
|
0aabf2d1ea | ||
|
0318cd99b0 | ||
|
fe2aee364c | ||
|
9d7b0b59bd | ||
|
904c3facd9 | ||
|
cca0275b0b | ||
|
2c468ea8a0 | ||
|
b6c3074ba0 | ||
|
64c97c7308 | ||
|
d2706fcab8 | ||
|
4c435db52e | ||
|
34dcf6c9a0 | ||
|
03de0ecbeb | ||
|
1311caf864 | ||
|
4d9f5dbaa1 | ||
|
4c0cac64eb | ||
|
980400db0b | ||
|
df8fc84851 | ||
|
146481375e | ||
|
38d0de20e3 | ||
|
f234e3707e | ||
|
e10fddee2e | ||
|
7079bb5316 | ||
|
9517d17ed2 | ||
|
9c6b6d565e | ||
|
a065533256 | ||
|
3445895647 | ||
|
fd4f57b5f3 | ||
|
e40c01ec39 | ||
|
50f2905cac | ||
|
9b46267659 | ||
|
763e174a7f | ||
|
ce9ac761a6 | ||
|
2b354c8fdb | ||
|
e59d67f466 | ||
|
99370b5880 | ||
|
e92f38f38f | ||
|
98f6a217d3 | ||
|
20fd3163d9 | ||
|
5894d017d5 | ||
|
b84721cbda | ||
|
484327fd5f | ||
|
7e580ec2b2 | ||
|
bb36c96674 | ||
|
48bb61cfe0 | ||
|
ca75470ae3 | ||
|
e361bc6c38 | ||
|
71de7e8414 | ||
|
5582232a03 | ||
|
aad328bb35 | ||
|
c45185f601 | ||
|
26527011ab | ||
|
b66b61fe8a | ||
|
45bf061236 | ||
|
9d154802a2 | ||
|
f493896030 | ||
|
4365d97162 | ||
|
05e8d50eca | ||
|
4bcb0aaae9 | ||
|
a0010d0f22 | ||
|
d9778fda4a | ||
|
4a88d0641a | ||
|
5e0bf8b65f | ||
|
0e0b4c53ef | ||
|
753526a506 | ||
|
2f0ac68d8f | ||
|
53835cabf9 | ||
|
714fea7f48 | ||
|
6f8968b66b | ||
|
b70f4c4f39 | ||
|
6f9127b4ae | ||
|
377d9b44b7 | ||
|
a5d218fd08 | ||
|
cc314690f4 | ||
|
6f2af269a1 | ||
|
c1a7285711 | ||
|
92a988e4cc | ||
|
842bd0ef00 | ||
|
43f0e8530b | ||
|
41a20dbb2a | ||
|
cbb007663f | ||
|
687bc9ca5c | ||
|
4194530cf3 | ||
|
3493e30f43 | ||
|
d6c1b0f42b | ||
|
15242a86b8 | ||
|
682d78f527 | ||
|
1d7abf8923 | ||
|
081e68a16a | ||
|
f4f7146f04 | ||
|
95334ad51d | ||
|
fcb9c5a1c4 | ||
|
583bf91ab4 | ||
|
0dd024d669 | ||
|
a7097c80e3 | ||
|
6e3e174100 | ||
|
81d24f180e | ||
|
dc4b97b851 | ||
|
951fb053a1 | ||
|
aefdd6e004 | ||
|
9514bf9afe | ||
|
8c9c2518f5 | ||
|
52b121a470 | ||
|
c400e860fe | ||
|
8a7665b5a1 | ||
|
aae9bb6477 | ||
|
0edd0b2fb4 | ||
|
47f48658c2 | ||
|
a4cb57ab5d | ||
|
4507e99ae3 | ||
|
5c56fb76df | ||
|
49e05b95c0 | ||
|
2b8caf9b39 | ||
|
2edfec5d45 | ||
|
a12d2f85a8 | ||
|
521dbff7cf | ||
|
4e99ec3eeb | ||
|
5859fe1091 | ||
|
ba1660ae1f | ||
|
ef376b9c69 | ||
|
2e10e5a9b1 | ||
|
57aa32562b | ||
|
e609de5018 | ||
|
74f5eaf47e | ||
|
00b5c99ffc |
872 changed files with 73598 additions and 402343 deletions
|
@ -1,3 +1,4 @@
|
|||
*
|
||||
!_output/bin
|
||||
!web
|
||||
.github/
|
||||
.gitpod.yml
|
||||
bin/
|
||||
tmp/
|
||||
|
|
21
.editorconfig
Normal file
21
.editorconfig
Normal file
|
@ -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
|
6
.envrc
Normal file
6
.envrc
Normal file
|
@ -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
|
2
.github/.editorconfig
vendored
Normal file
2
.github/.editorconfig
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
[{*.yml,*.yaml}]
|
||||
indent_size = 2
|
3
.github/CODE_OF_CONDUCT.md
vendored
Normal file
3
.github/CODE_OF_CONDUCT.md
vendored
Normal file
|
@ -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
DCO → .github/DCO
vendored
0
DCO → .github/DCO
vendored
102
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
Normal file
102
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
Normal file
|
@ -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
|
17
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
17
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
|
@ -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
|
40
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
Normal file
40
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
Normal file
|
@ -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.
|
35
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
35
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
|
@ -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
|
||||
|
||||
```
|
24
.github/SECURITY.md
vendored
Normal file
24
.github/SECURITY.md
vendored
Normal file
|
@ -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.
|
30
.github/dependabot.yaml
vendored
Normal file
30
.github/dependabot.yaml
vendored
Normal file
|
@ -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"
|
30
.github/release.yml
vendored
Normal file
30
.github/release.yml
vendored
Normal file
|
@ -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:
|
||||
- "*"
|
97
.github/workflows/artifacts.yaml
vendored
Normal file
97
.github/workflows/artifacts.yaml
vendored
Normal file
|
@ -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'
|
18
.github/workflows/checks.yaml
vendored
Normal file
18
.github/workflows/checks.yaml
vendored
Normal file
|
@ -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"
|
129
.github/workflows/ci.yaml
vendored
Normal file
129
.github/workflows/ci.yaml
vendored
Normal file
|
@ -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
|
67
.github/workflows/codeql-analysis.yaml
vendored
Normal file
67
.github/workflows/codeql-analysis.yaml
vendored
Normal file
|
@ -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
|
111
.github/workflows/docker.yaml
vendored
Normal file
111
.github/workflows/docker.yaml
vendored
Normal file
|
@ -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'
|
10
.gitignore
vendored
10
.gitignore
vendored
|
@ -1,3 +1,7 @@
|
|||
bin
|
||||
dist
|
||||
_output
|
||||
/.direnv/
|
||||
/.idea/
|
||||
/bin/
|
||||
/config.yaml
|
||||
/docker-compose.override.yaml
|
||||
/var/
|
||||
/vendor/
|
||||
|
|
3
.gitpod.yml
Normal file
3
.gitpod.yml
Normal file
|
@ -0,0 +1,3 @@
|
|||
tasks:
|
||||
- init: go get && go build ./... && go test ./... && make
|
||||
command: go run
|
90
.golangci.yml
Normal file
90
.golangci.yml
Normal file
|
@ -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
|
26
.travis.yml
26
.travis.yml
|
@ -1,26 +0,0 @@
|
|||
language: go
|
||||
|
||||
sudo: required
|
||||
|
||||
go:
|
||||
- 1.7.5
|
||||
- 1.8
|
||||
|
||||
services:
|
||||
- postgresql
|
||||
|
||||
env:
|
||||
- DEX_POSTGRES_DATABASE=postgres DEX_POSTGRES_USER=postgres DEX_POSTGRES_HOST="localhost" DEX_LDAP_TESTS=1 DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
|
||||
install:
|
||||
- go get -u github.com/golang/lint/golint
|
||||
- sudo -E apt-get install -y --force-yes slapd time ldap-utils
|
||||
- sudo /etc/init.d/slapd stop
|
||||
|
||||
|
||||
script:
|
||||
- make testall
|
||||
|
||||
notifications:
|
||||
email: false
|
15
ADOPTERS.md
Normal file
15
ADOPTERS.md
Normal file
|
@ -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,72 +0,0 @@
|
|||
# Custom scopes, claims and client features
|
||||
|
||||
This document describes the set of OAuth2 and OpenID Connect features implemented by dex.
|
||||
|
||||
## Scopes
|
||||
|
||||
The following is the exhaustive list of scopes supported by dex:
|
||||
|
||||
| Name | Description |
|
||||
| ---- | ------------|
|
||||
| `openid` | Required scope for all login requests. |
|
||||
| `email` | ID token claims should include the end user's email and if that email was verified by an upstream provider. |
|
||||
| `profile` | ID token claims should include the username of the end user. |
|
||||
| `groups` | ID token claims should include a list of groups the end user is a member of. |
|
||||
| `offline_access` | Token response should include a refresh token. Doesn't work in combinations with some connectors, notability the [SAML connector][saml-connector] ignores this scope. |
|
||||
| `audience:server:client_id:( client-id )` | Dynamic scope indicating that the ID token should be issued on behalf of another client. See the _"Cross-client trust and authorized party"_ section below. |
|
||||
|
||||
## Custom claims
|
||||
|
||||
Beyond the [required OpenID Connect claims][core-claims], and a handful of [standard claims][standard-claims], dex implements the following non-standard claims.
|
||||
|
||||
| Name | Description |
|
||||
| ---- | ------------|
|
||||
| `groups` | A list of strings representing the groups a user is a member of. |
|
||||
| `email` | The email of the user. |
|
||||
| `email_verified` | If the upstream provider has verified the email. |
|
||||
| `name` | User's display name. |
|
||||
|
||||
## Cross-client trust and authorized party
|
||||
|
||||
Dex has the ability to issue ID tokens to clients on behalf of other clients. In OpenID Connect terms, this means the ID token's `aud` (audience) claim being a different client ID than the client that performed the login.
|
||||
|
||||
For example, this feature could be used to allow a web app to generate an ID token on behalf of a command line tool:
|
||||
|
||||
```yaml
|
||||
staticClients:
|
||||
- id: web-app
|
||||
redirectURIs:
|
||||
- 'https://web-app.example.com/callback'
|
||||
name: 'Web app'
|
||||
secret: web-app-secret
|
||||
|
||||
- id: cli-app
|
||||
redirectURIs:
|
||||
- 'https://cli-app.example.com/callback'
|
||||
name: 'Command line tool'
|
||||
secret: cli-app-secret
|
||||
# The command line tool lets the web app issue ID tokens on its behalf.
|
||||
trustedPeers:
|
||||
- web-app
|
||||
```
|
||||
|
||||
Note that the command line tool must explicitly trust the web app using the `trustedPeers` field. The web app can then use the following scope to request an ID token that's issued for the command line tool.
|
||||
|
||||
```
|
||||
audience:server:client_id:cli-app
|
||||
```
|
||||
|
||||
The ID token claims will then include the following audience and authorized party:
|
||||
|
||||
```
|
||||
{
|
||||
"aud": "cli-app",
|
||||
"azp": "web-app",
|
||||
"email": "foo@bar.com",
|
||||
// other claims...
|
||||
}
|
||||
```
|
||||
|
||||
[saml-connector]: saml-connector.md
|
||||
[core-claims]: https://openid.net/specs/openid-connect-core-1_0.html#IDToken
|
||||
[standard-claims]: https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims
|
|
@ -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: saml
|
||||
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,67 +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 Enterprise
|
||||
|
||||
Users can use their GitHub Enterprise account to login to dex. The following configuration can be used to enable a GitHub Enterprise connector on dex:
|
||||
|
||||
```yaml
|
||||
connectors:
|
||||
- type: github
|
||||
# Required field for connector id.
|
||||
id: github
|
||||
# Required field for connector name.
|
||||
name: GitHub
|
||||
config:
|
||||
# Required fields. Dex must be pre-registered with GitHub Enterprise
|
||||
# to get the following values.
|
||||
# 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
|
||||
|
||||
# Required ONLY for GitHub Enterprise.
|
||||
# This is the Hostname of the GitHub Enterprise account listed on the
|
||||
# management console. Ensure this domain is routable on your network.
|
||||
hostName: git.example.com
|
||||
# ONLY for GitHub Enterprise. Optional field.
|
||||
# Used to support self-signed or untrusted CA root certificates.
|
||||
rootCA: /etc/dex/ca.crt
|
||||
```
|
||||
|
||||
[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,141 +0,0 @@
|
|||
# An overview of OpenID Connect
|
||||
|
||||
This document attempts to provide a general 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 enough information to get users up and running.
|
||||
|
||||
For an overview of custom claims, scopes, and client features implemented by dex, see [this document][scopes-claims-clients].
|
||||
|
||||
## 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
[scopes-claims-clients]: custom-scopes-claims-clients.md
|
|
@ -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,105 +0,0 @@
|
|||
# Authentication through SAML 2.0
|
||||
|
||||
## Overview
|
||||
|
||||
The SAML provider allows authentication through the SAML 2.0 HTTP POST binding. The connector maps attribute values in the SAML assertion to user info, such as username, email, and groups.
|
||||
|
||||
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.
|
||||
|
||||
Unlike some clients which will process unprompted AuthnResponses, dex must send the initial AuthnRequest and validates the response's InResponseTo value.
|
||||
|
||||
## Caveats
|
||||
|
||||
__The connector doesn't support refresh tokens__ since the SAML 2.0 protocol doesn't provide a way to requery a provider without interaction. If the "offline_access" scope is requested, it will be ignored.
|
||||
|
||||
The connector doesn't support signed AuthnRequests or encrypted attributes.
|
||||
|
||||
## Configuration
|
||||
|
||||
```yaml
|
||||
connectors:
|
||||
- type: saml
|
||||
# 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 signature of the SAML response.
|
||||
ca: /path/to/ca.pem
|
||||
|
||||
# Dex's callback URL.
|
||||
#
|
||||
# If the response assertion status value contains a Destination element, it
|
||||
# must match this value exactly.
|
||||
#
|
||||
# This is also used as the expected audience for AudienceRestriction elements
|
||||
# if entityIssuer isn't specified.
|
||||
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
|
||||
|
||||
# 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.
|
||||
#
|
||||
# insecureSkipSignatureValidation: true
|
||||
|
||||
# Optional: Manually specify dex's Issuer value.
|
||||
#
|
||||
# When provided dex will include this as the Issuer value during AuthnRequest.
|
||||
# It will also override the redirectURI as the required audience when evaluating
|
||||
# AudienceRestriction elements in the response.
|
||||
entityIssuer: https://dex.example.com/callback
|
||||
|
||||
# Optional: Issuer value expected in the SAML response.
|
||||
ssoIssuer: https://saml.example.com/sso
|
||||
|
||||
# Optional: Delimiter for splitting groups returned as a single string.
|
||||
#
|
||||
# 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: ", "
|
||||
|
||||
# Optional: Requested format of the NameID.
|
||||
#
|
||||
# The NameID value is is mapped to the user ID of the user. 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
|
||||
```
|
||||
|
||||
A minimal working configuration might look like:
|
||||
|
||||
```yaml
|
||||
connectors:
|
||||
- type: saml
|
||||
id: okta
|
||||
name: Okta
|
||||
config:
|
||||
ssoURL: https://dev-111102.oktapreview.com/app/foo/exk91cb99lKkKSYoy0h7/sso/saml
|
||||
ca: /etc/dex/saml-ca.pem
|
||||
redirectURI: http://127.0.0.1:5556/dex/callback
|
||||
usernameAttr: name
|
||||
emailAttr: email
|
||||
groupsAttr: groups
|
||||
```
|
|
@ -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
|
6
MAINTAINERS
Normal file
6
MAINTAINERS
Normal file
|
@ -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
|
||||
|
|
152
README.md
152
README.md
|
@ -1,61 +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)
|
||||
* [Custom scopes, claims, and client features](Documentation/custom-scopes-claims-clients.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)
|
||||
* [GitLab](Documentation/gitlab-connector.md)
|
||||
* [SAML 2.0](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).
|
||||
|
|
2262
api/api.pb.go
2262
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;
|
||||
|
@ -35,6 +38,20 @@ 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.
|
||||
|
@ -133,10 +150,22 @@ message RevokeRefreshResp {
|
|||
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) {};
|
||||
}
|
||||
|
|
487
api/api_grpc.pb.go
Normal file
487
api/api_grpc.pb.go
Normal file
|
@ -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",
|
||||
}
|
1965
api/v2/api.pb.go
Normal file
1965
api/v2/api.pb.go
Normal file
File diff suppressed because it is too large
Load diff
189
api/v2/api.proto
Normal file
189
api/v2/api.proto
Normal file
|
@ -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) {};
|
||||
}
|
487
api/v2/api_grpc.pb.go
Normal file
487
api/v2/api_grpc.pb.go
Normal file
|
@ -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",
|
||||
}
|
16
api/v2/go.mod
Normal file
16
api/v2/go.mod
Normal file
|
@ -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
|
||||
)
|
141
api/v2/go.sum
Normal file
141
api/v2/go.sum
Normal file
|
@ -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,30 +5,27 @@ import (
|
|||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/Sirupsen/logrus"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"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"`
|
||||
Telemetry Telemetry `json:"telemetry"`
|
||||
OAuth2 OAuth2 `json:"oauth2"`
|
||||
GRPC GRPC `json:"grpc"`
|
||||
Expiry Expiry `json:"expiry"`
|
||||
|
@ -36,6 +33,10 @@ type Config struct {
|
|||
|
||||
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,6 +51,38 @@ 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 {
|
||||
|
@ -58,6 +91,7 @@ func (p *password) UnmarshalJSON(b []byte) error {
|
|||
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,24 +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) },
|
||||
"saml": func() ConnectorConfig { return new(saml.Config) },
|
||||
// Keep around for backwards compatibility.
|
||||
"samlExperimental": func() ConnectorConfig { return new(saml.Config) },
|
||||
Config server.ConnectorConfig `json:"config"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON allows Connector to implement the unmarshaler interface to
|
||||
|
@ -203,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)
|
||||
}
|
||||
|
@ -224,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.
|
||||
|
@ -231,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.
|
||||
|
@ -241,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
|
||||
|
@ -57,8 +115,10 @@ staticPasswords:
|
|||
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)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
519
cmd/dex/serve.go
519
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 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)
|
||||
},
|
||||
}
|
||||
|
||||
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 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:
|
||||
}
|
||||
|
||||
configFile := args[0]
|
||||
configData, err := ioutil.ReadFile(configFile)
|
||||
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)
|
||||
|
||||
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 != "" {
|
||||
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)
|
||||
}
|
||||
|
||||
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,
|
||||
tlsConfig.ClientAuth = tls.RequireAndVerifyClientCert
|
||||
tlsConfig.ClientCAs = cPool
|
||||
|
||||
// Only add metrics if client auth is enabled
|
||||
grpcOptions = append(grpcOptions,
|
||||
grpc.StreamInterceptor(grpcMetrics.StreamServerInterceptor()),
|
||||
grpc.UnaryInterceptor(grpcMetrics.UnaryServerInterceptor()),
|
||||
)
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
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")
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
92
cmd/docker-entrypoint/main.go
Normal file
92
cmd/docker-entrypoint/main.go
Normal file
|
@ -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
|
||||
}
|
113
cmd/docker-entrypoint/main_test.go
Normal file
113
cmd/docker-entrypoint/main_test.go
Normal file
|
@ -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
|
||||
}
|
35
config.dev.yaml
Normal file
35
config.dev.yaml
Normal file
|
@ -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"
|
48
config.docker.yaml
Normal file
48
config.docker.yaml
Normal file
|
@ -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 }}
|
136
config.yaml.dist
Normal file
136
config.yaml.dist
Normal file
|
@ -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: []
|
449
connector/atlassiancrowd/atlassiancrowd.go
Normal file
449
connector/atlassiancrowd/atlassiancrowd.go
Normal file
|
@ -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
|
||||
}
|
189
connector/atlassiancrowd/atlassiancrowd_test.go
Normal file
189
connector/atlassiancrowd/atlassiancrowd_test.go
Normal file
|
@ -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)
|
||||
}
|
||||
}
|
80
connector/authproxy/authproxy.go
Normal file
80
connector/authproxy/authproxy.go
Normal file
|
@ -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
|
||||
}
|
468
connector/bitbucketcloud/bitbucketcloud.go
Normal file
468
connector/bitbucketcloud/bitbucketcloud.go
Normal file
|
@ -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
|
||||
}
|
137
connector/bitbucketcloud/bitbucketcloud_test.go
Normal file
137
connector/bitbucketcloud/bitbucketcloud_test.go
Normal file
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -25,6 +25,7 @@ type Scopes struct {
|
|||
type Identity struct {
|
||||
UserID string
|
||||
Username string
|
||||
PreferredUsername string
|
||||
Email string
|
||||
EmailVerified bool
|
||||
|
||||
|
@ -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
|
||||
|
|
424
connector/gitea/gitea.go
Normal file
424
connector/gitea/gitea.go
Normal file
|
@ -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
|
||||
}
|
72
connector/gitea/gitea_test.go
Normal file
72
connector/gitea/gitea_test.go
Normal file
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -8,9 +8,10 @@ import (
|
|||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
@ -19,35 +20,73 @@ import (
|
|||
"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 (
|
||||
apiURL = "https://api.github.com"
|
||||
// GitHub requires this scope to access '/user' and '/user/emails' API endpoints.
|
||||
scopeEmail = "user:email"
|
||||
// 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"`
|
||||
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) {
|
||||
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,
|
||||
useLoginAsID: c.UseLoginAsID,
|
||||
}
|
||||
|
||||
if c.HostName != "" {
|
||||
|
@ -70,7 +109,14 @@ func (c *Config) Open(logger logrus.FieldLogger) (connector.Connector, 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
|
||||
|
@ -89,29 +135,42 @@ 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 delcared rootCA cert.
|
||||
// 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{
|
||||
|
@ -125,12 +184,13 @@ func (c *githubConnector) oauth2Config(scopes connector.Scopes) *oauth2.Config {
|
|||
ClientSecret: c.clientSecret,
|
||||
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
|
||||
|
@ -148,10 +208,10 @@ func (e *oauth2Error) Error() string {
|
|||
return e.error + ": " + e.errorDescription
|
||||
}
|
||||
|
||||
// newHTTPClient returns a new HTTP client that trusts the custom delcared rootCA cert.
|
||||
// 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 := ioutil.ReadFile(rootCA)
|
||||
rootCABytes, err := os.ReadFile(rootCA)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read root-ca: %v", err)
|
||||
}
|
||||
|
@ -206,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,
|
||||
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
|
||||
}
|
||||
|
@ -233,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
|
||||
}
|
||||
return ident, nil
|
||||
identity.Groups = groups
|
||||
}
|
||||
|
||||
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"`
|
||||
|
@ -273,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", c.apiURL+"/user", nil)
|
||||
if err != nil {
|
||||
return u, fmt.Errorf("github: new req: %v", 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)
|
||||
}
|
||||
return u, fmt.Errorf("%s: %s", resp.Status, body)
|
||||
if _, err := get(ctx, client, c.apiURL+"/user", &u); err != nil {
|
||||
return u, err
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&u); err != nil {
|
||||
return u, fmt.Errorf("failed to decode response: %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, 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 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) userEmail(ctx context.Context, client *http.Client) (string, error) {
|
||||
apiURL := c.apiURL + "/user/emails"
|
||||
for {
|
||||
// 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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
if email.Verified && email.Primary {
|
||||
return email.Email, nil
|
||||
}
|
||||
}
|
||||
|
||||
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) teams(ctx context.Context, client *http.Client, org string) ([]string, error) {
|
||||
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)
|
||||
|
||||
groups := []string{}
|
||||
|
||||
// https://developer.github.com/v3/#pagination
|
||||
reNext := regexp.MustCompile("<(.*)>; rel=\"next\"")
|
||||
reLast := regexp.MustCompile("<(.*)>; rel=\"last\"")
|
||||
apiURL := c.apiURL + "/user/teams"
|
||||
|
||||
for {
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("github: new req: %v", err)
|
||||
return false, fmt.Errorf("github: new req: %v", err)
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("github: get teams: %v", err)
|
||||
return false, 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)
|
||||
}
|
||||
return nil, fmt.Errorf("%s: %s", resp.Status, body)
|
||||
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
|
||||
var teams []struct {
|
||||
type team struct {
|
||||
Name string `json:"name"`
|
||||
Org struct {
|
||||
Org org `json:"organization"`
|
||||
Slug string `json:"slug"`
|
||||
}
|
||||
|
||||
type 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)
|
||||
// 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)...)
|
||||
}
|
||||
}
|
||||
|
||||
links := resp.Header.Get("Link")
|
||||
if len(reLast.FindStringSubmatch(links)) > 1 {
|
||||
lastPageURL := reLast.FindStringSubmatch(links)[1]
|
||||
if apiURL == lastPageURL {
|
||||
if apiURL == "" {
|
||||
break
|
||||
}
|
||||
} else {
|
||||
break
|
||||
}
|
||||
|
||||
if len(reNext.FindStringSubmatch(links)) > 1 {
|
||||
apiURL = reNext.FindStringSubmatch(links)[1]
|
||||
} else {
|
||||
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}
|
||||
}
|
||||
}
|
||||
|
|
238
connector/github/github_test.go
Normal file
238
connector/github/github_test.go
Normal file
|
@ -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"`
|
||||
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.
|
||||
// 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,
|
||||
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)
|
||||
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: get user: %v", err)
|
||||
return ident, fmt.Errorf("gitlab: failed to get refresh token: %v", err)
|
||||
}
|
||||
return c.identity(ctx, s, token)
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
ident.Groups = groups
|
||||
}
|
||||
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)
|
||||
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)
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gitlab: get groups: %v", err)
|
||||
return nil, fmt.Errorf("gitlab: get URL %v", err)
|
||||
}
|
||||
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 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)
|
||||
var u userInfo
|
||||
if err := json.NewDecoder(resp.Body).Decode(&u); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
for _, group := range gitlabGroups {
|
||||
groups = append(groups, group.Name)
|
||||
return u.Groups, nil
|
||||
}
|
||||
|
||||
link := resp.Header.Get("Link")
|
||||
|
||||
if len(reLast.FindStringSubmatch(link)) > 1 {
|
||||
lastPageURL := reLast.FindStringSubmatch(link)[1]
|
||||
|
||||
if apiURL == lastPageURL {
|
||||
break
|
||||
}
|
||||
} else {
|
||||
break
|
||||
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(reNext.FindStringSubmatch(link)) > 1 {
|
||||
apiURL = reNext.FindStringSubmatch(link)[1]
|
||||
} else {
|
||||
break
|
||||
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 groups, nil
|
||||
return nil, nil
|
||||
}
|
||||
|
|
283
connector/gitlab/gitlab_test.go
Normal file
283
connector/gitlab/gitlab_test.go
Normal file
|
@ -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)
|
||||
}
|
||||
}
|
326
connector/google/google.go
Normal file
326
connector/google/google.go
Normal file
|
@ -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
|
||||
}
|
312
connector/keystone/keystone.go
Normal file
312
connector/keystone/keystone.go
Normal file
|
@ -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
|
||||
}
|
483
connector/keystone/keystone_test.go
Normal file
483
connector/keystone/keystone_test.go
Normal file
|
@ -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)
|
||||
}
|
||||
}
|
49
connector/ldap/gen-certs.sh
Executable file
49
connector/ldap/gen-certs.sh
Executable file
|
@ -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
|
||||
// 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
|
||||
// # 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)"
|
||||
|
@ -93,12 +119,16 @@ type Config struct {
|
|||
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,16 +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 logrus.FieldLogger) (*ldapConnector, error) {
|
||||
|
||||
func (c *Config) openConnector(logger log.Logger) (*ldapConnector, error) {
|
||||
requiredFields := []struct {
|
||||
name string
|
||||
val string
|
||||
|
@ -181,9 +248,9 @@ func (c *Config) openConnector(logger logrus.FieldLogger) (*ldapConnector, error
|
|||
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"
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -192,7 +259,7 @@ func (c *Config) openConnector(logger logrus.FieldLogger) (*ldapConnector, error
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
@ -202,14 +269,25 @@ func (c *Config) openConnector(logger logrus.FieldLogger) (*ldapConnector, error
|
|||
}
|
||||
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
|
||||
}
|
||||
|
||||
|
@ -221,7 +299,7 @@ type ldapConnector struct {
|
|||
|
||||
tlsConfig *tls.Config
|
||||
|
||||
logger logrus.FieldLogger
|
||||
logger log.Logger
|
||||
}
|
||||
|
||||
var (
|
||||
|
@ -232,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 {
|
||||
|
@ -249,7 +336,11 @@ 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)
|
||||
}
|
||||
|
||||
|
@ -285,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 == "" {
|
||||
|
@ -297,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
|
||||
|
@ -305,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)
|
||||
|
@ -320,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)
|
||||
|
@ -338,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.
|
||||
|
@ -367,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
|
||||
|
@ -414,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
|
||||
|
@ -459,8 +580,9 @@ func (c *ldapConnector) groups(ctx context.Context, user ldap.Entry) ([]string,
|
|||
}
|
||||
|
||||
var groups []*ldap.Entry
|
||||
for _, attr := range getAttrs(user, c.GroupSearch.UserAttr) {
|
||||
filter := fmt.Sprintf("(%s=%s)", c.GroupSearch.GroupAttr, ldap.EscapeFilter(attr))
|
||||
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)
|
||||
}
|
||||
|
@ -474,6 +596,8 @@ func (c *ldapConnector) groups(ctx context.Context, user ldap.Entry) ([]string,
|
|||
|
||||
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)
|
||||
|
@ -489,8 +613,9 @@ func (c *ldapConnector) groups(ctx context.Context, user ldap.Entry) ([]string,
|
|||
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 == "" {
|
||||
|
@ -506,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
|
||||
}
|
||||
|
|
|
@ -1,25 +1,27 @@
|
|||
package ldap
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io/ioutil"
|
||||
"net/url"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"github.com/Sirupsen/logrus"
|
||||
"github.com/kylelemons/godebug/pretty"
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/coreos/dex/connector"
|
||||
"github.com/dexidp/dex/connector"
|
||||
)
|
||||
|
||||
const envVar = "DEX_LDAP_TESTS"
|
||||
// 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 {
|
||||
|
@ -39,35 +41,8 @@ type subtest struct {
|
|||
}
|
||||
|
||||
func TestQuery(t *testing.T) {
|
||||
schema := `
|
||||
dn: dc=example,dc=org
|
||||
objectClass: dcObject
|
||||
objectClass: organization
|
||||
o: Example Company
|
||||
dc: example
|
||||
|
||||
dn: ou=People,dc=example,dc=org
|
||||
objectClass: organizationalUnit
|
||||
ou: People
|
||||
|
||||
dn: cn=jane,ou=People,dc=example,dc=org
|
||||
objectClass: person
|
||||
objectClass: inetOrgPerson
|
||||
sn: doe
|
||||
cn: jane
|
||||
mail: janedoe@example.com
|
||||
userpassword: foo
|
||||
|
||||
dn: cn=john,ou=People,dc=example,dc=org
|
||||
objectClass: person
|
||||
objectClass: inetOrgPerson
|
||||
sn: doe
|
||||
cn: john
|
||||
mail: johndoe@example.com
|
||||
userpassword: bar
|
||||
`
|
||||
c := &Config{}
|
||||
c.UserSearch.BaseDN = "ou=People,dc=example,dc=org"
|
||||
c.UserSearch.BaseDN = "ou=People,ou=TestQuery,dc=example,dc=org"
|
||||
c.UserSearch.NameAttr = "cn"
|
||||
c.UserSearch.EmailAttr = "mail"
|
||||
c.UserSearch.IDAttr = "DN"
|
||||
|
@ -79,7 +54,7 @@ userpassword: bar
|
|||
username: "jane",
|
||||
password: "foo",
|
||||
want: connector.Identity{
|
||||
UserID: "cn=jane,ou=People,dc=example,dc=org",
|
||||
UserID: "cn=jane,ou=People,ou=TestQuery,dc=example,dc=org",
|
||||
Username: "jane",
|
||||
Email: "janedoe@example.com",
|
||||
EmailVerified: true,
|
||||
|
@ -90,7 +65,7 @@ userpassword: bar
|
|||
username: "john",
|
||||
password: "bar",
|
||||
want: connector.Identity{
|
||||
UserID: "cn=john,ou=People,dc=example,dc=org",
|
||||
UserID: "cn=john,ou=People,ou=TestQuery,dc=example,dc=org",
|
||||
Username: "john",
|
||||
Email: "johndoe@example.com",
|
||||
EmailVerified: true,
|
||||
|
@ -110,63 +85,108 @@ userpassword: bar
|
|||
},
|
||||
}
|
||||
|
||||
runTests(t, schema, c, tests)
|
||||
runTests(t, connectLDAP, c, tests)
|
||||
}
|
||||
|
||||
func TestGroupQuery(t *testing.T) {
|
||||
schema := `
|
||||
dn: dc=example,dc=org
|
||||
objectClass: dcObject
|
||||
objectClass: organization
|
||||
o: Example Company
|
||||
dc: example
|
||||
|
||||
dn: ou=People,dc=example,dc=org
|
||||
objectClass: organizationalUnit
|
||||
ou: People
|
||||
|
||||
dn: cn=jane,ou=People,dc=example,dc=org
|
||||
objectClass: person
|
||||
objectClass: inetOrgPerson
|
||||
sn: doe
|
||||
cn: jane
|
||||
mail: janedoe@example.com
|
||||
userpassword: foo
|
||||
|
||||
dn: cn=john,ou=People,dc=example,dc=org
|
||||
objectClass: person
|
||||
objectClass: inetOrgPerson
|
||||
sn: doe
|
||||
cn: john
|
||||
mail: johndoe@example.com
|
||||
userpassword: bar
|
||||
|
||||
# Group definitions.
|
||||
|
||||
dn: ou=Groups,dc=example,dc=org
|
||||
objectClass: organizationalUnit
|
||||
ou: Groups
|
||||
|
||||
dn: cn=admins,ou=Groups,dc=example,dc=org
|
||||
objectClass: groupOfNames
|
||||
cn: admins
|
||||
member: cn=john,ou=People,dc=example,dc=org
|
||||
member: cn=jane,ou=People,dc=example,dc=org
|
||||
|
||||
dn: cn=developers,ou=Groups,dc=example,dc=org
|
||||
objectClass: groupOfNames
|
||||
cn: developers
|
||||
member: cn=jane,ou=People,dc=example,dc=org
|
||||
`
|
||||
func TestQueryWithEmailSuffix(t *testing.T) {
|
||||
c := &Config{}
|
||||
c.UserSearch.BaseDN = "ou=People,dc=example,dc=org"
|
||||
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.GroupSearch.BaseDN = "ou=Groups,dc=example,dc=org"
|
||||
c.GroupSearch.UserAttr = "DN"
|
||||
c.GroupSearch.GroupAttr = "member"
|
||||
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{
|
||||
|
@ -176,7 +196,7 @@ member: cn=jane,ou=People,dc=example,dc=org
|
|||
password: "foo",
|
||||
groups: true,
|
||||
want: connector.Identity{
|
||||
UserID: "cn=jane,ou=People,dc=example,dc=org",
|
||||
UserID: "cn=jane,ou=People,ou=TestGroupQuery,dc=example,dc=org",
|
||||
Username: "jane",
|
||||
Email: "janedoe@example.com",
|
||||
EmailVerified: true,
|
||||
|
@ -189,7 +209,7 @@ member: cn=jane,ou=People,dc=example,dc=org
|
|||
password: "bar",
|
||||
groups: true,
|
||||
want: connector.Identity{
|
||||
UserID: "cn=john,ou=People,dc=example,dc=org",
|
||||
UserID: "cn=john,ou=People,ou=TestGroupQuery,dc=example,dc=org",
|
||||
Username: "john",
|
||||
Email: "johndoe@example.com",
|
||||
EmailVerified: true,
|
||||
|
@ -198,74 +218,23 @@ member: cn=jane,ou=People,dc=example,dc=org
|
|||
},
|
||||
}
|
||||
|
||||
runTests(t, schema, c, tests)
|
||||
runTests(t, connectLDAP, c, tests)
|
||||
}
|
||||
|
||||
func TestGroupsOnUserEntity(t *testing.T) {
|
||||
schema := `
|
||||
dn: dc=example,dc=org
|
||||
objectClass: dcObject
|
||||
objectClass: organization
|
||||
o: Example Company
|
||||
dc: example
|
||||
|
||||
dn: ou=People,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,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,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,dc=example,dc=org
|
||||
objectClass: organizationalUnit
|
||||
ou: Groups
|
||||
|
||||
dn: cn=admins,ou=Groups,dc=example,dc=org
|
||||
objectClass: posixGroup
|
||||
cn: admins
|
||||
gidNumber: 1000
|
||||
|
||||
dn: cn=developers,ou=Groups,dc=example,dc=org
|
||||
objectClass: posixGroup
|
||||
cn: developers
|
||||
gidNumber: 1001
|
||||
|
||||
dn: cn=designers,ou=Groups,dc=example,dc=org
|
||||
objectClass: posixGroup
|
||||
cn: designers
|
||||
gidNumber: 1002
|
||||
`
|
||||
c := &Config{}
|
||||
c.UserSearch.BaseDN = "ou=People,dc=example,dc=org"
|
||||
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,dc=example,dc=org"
|
||||
c.GroupSearch.UserAttr = "departmentNumber"
|
||||
c.GroupSearch.GroupAttr = "gidNumber"
|
||||
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{
|
||||
{
|
||||
|
@ -274,7 +243,7 @@ gidNumber: 1002
|
|||
password: "foo",
|
||||
groups: true,
|
||||
want: connector.Identity{
|
||||
UserID: "cn=jane,ou=People,dc=example,dc=org",
|
||||
UserID: "cn=jane,ou=People,ou=TestGroupsOnUserEntity,dc=example,dc=org",
|
||||
Username: "jane",
|
||||
Email: "janedoe@example.com",
|
||||
EmailVerified: true,
|
||||
|
@ -287,7 +256,7 @@ gidNumber: 1002
|
|||
password: "bar",
|
||||
groups: true,
|
||||
want: connector.Identity{
|
||||
UserID: "cn=john,ou=People,dc=example,dc=org",
|
||||
UserID: "cn=john,ou=People,ou=TestGroupsOnUserEntity,dc=example,dc=org",
|
||||
Username: "john",
|
||||
Email: "johndoe@example.com",
|
||||
EmailVerified: true,
|
||||
|
@ -295,109 +264,271 @@ gidNumber: 1002
|
|||
},
|
||||
},
|
||||
}
|
||||
runTests(t, schema, c, tests)
|
||||
runTests(t, connectLDAP, c, tests)
|
||||
}
|
||||
|
||||
// runTests runs a set of tests against an LDAP schema. It does this by
|
||||
// setting up an OpenLDAP server and injecting the provided scheme.
|
||||
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 the slapd and ldapadd binaries available in the host
|
||||
// machine's PATH.
|
||||
//
|
||||
// The DEX_LDAP_TESTS must be set to "1"
|
||||
func runTests(t *testing.T, schema string, config *Config, tests []subtest) {
|
||||
if os.Getenv(envVar) != "1" {
|
||||
t.Skipf("%s not set. Skipping test (run 'export %s=1' to run tests)", envVar, envVar)
|
||||
}
|
||||
|
||||
for _, cmd := range []string{"slapd", "ldapadd"} {
|
||||
if _, err := exec.LookPath(cmd); err != nil {
|
||||
t.Errorf("%s not available", cmd)
|
||||
}
|
||||
}
|
||||
|
||||
tempDir, err := ioutil.TempDir("", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
configBytes := new(bytes.Buffer)
|
||||
|
||||
if err := slapdConfigTmpl.Execute(configBytes, tmplData{tempDir, includes(t)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
configPath := filepath.Join(tempDir, "ldap.conf")
|
||||
if err := ioutil.WriteFile(configPath, configBytes.Bytes(), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
schemaPath := filepath.Join(tempDir, "schema.ldap")
|
||||
if err := ioutil.WriteFile(schemaPath, []byte(schema), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
socketPath := url.QueryEscape(filepath.Join(tempDir, "ldap.unix"))
|
||||
|
||||
slapdOut := new(bytes.Buffer)
|
||||
|
||||
cmd := exec.Command(
|
||||
"slapd",
|
||||
"-d", "any",
|
||||
"-h", "ldap://localhost:10363/ ldaps://localhost:10636/ ldapi://"+socketPath,
|
||||
"-f", configPath,
|
||||
)
|
||||
cmd.Stdout = slapdOut
|
||||
cmd.Stderr = slapdOut
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var (
|
||||
// Wait group finishes once slapd has exited.
|
||||
//
|
||||
// Use a wait group because multiple goroutines can't listen on
|
||||
// cmd.Wait(). It triggers the race detector.
|
||||
wg = new(sync.WaitGroup)
|
||||
// Ensure only one condition can set the slapdFailed boolean.
|
||||
once = new(sync.Once)
|
||||
slapdFailed bool
|
||||
)
|
||||
|
||||
wg.Add(1)
|
||||
go func() { cmd.Wait(); wg.Done() }()
|
||||
|
||||
defer func() {
|
||||
if slapdFailed {
|
||||
// If slapd exited before it was killed, print its logs.
|
||||
t.Logf("%s\n", slapdOut)
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
wg.Wait()
|
||||
once.Do(func() { slapdFailed = true })
|
||||
}()
|
||||
|
||||
defer func() {
|
||||
once.Do(func() { slapdFailed = false })
|
||||
cmd.Process.Kill()
|
||||
wg.Wait()
|
||||
}()
|
||||
|
||||
// Wait for slapd to come up.
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
ldapadd := exec.Command(
|
||||
"ldapadd", "-x",
|
||||
"-D", "cn=admin,dc=example,dc=org",
|
||||
"-w", "admin",
|
||||
"-f", schemaPath,
|
||||
"-H", "ldap://localhost:10363/",
|
||||
)
|
||||
if out, err := ldapadd.CombinedOutput(); err != nil {
|
||||
t.Errorf("ldapadd: %s", out)
|
||||
return
|
||||
// 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.
|
||||
|
@ -405,12 +536,26 @@ func runTests(t *testing.T, schema string, config *Config, tests []subtest) {
|
|||
|
||||
// We need to configure host parameters but don't want to overwrite user or
|
||||
// group search configuration.
|
||||
c.Host = "localhost:10363"
|
||||
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: ioutil.Discard, Formatter: &logrus.TextFormatter{}}
|
||||
l := &logrus.Logger{Out: io.Discard, Formatter: &logrus.TextFormatter{}}
|
||||
|
||||
conn, err := c.openConnector(l)
|
||||
if err != nil {
|
||||
|
@ -469,82 +614,3 @@ func runTests(t *testing.T, schema string, config *Config, tests []subtest) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Standard OpenLDAP schema files to include.
|
||||
//
|
||||
// These are copied from the /etc/openldap/schema directory.
|
||||
var includeFiles = []string{
|
||||
"core.schema",
|
||||
"cosine.schema",
|
||||
"inetorgperson.schema",
|
||||
"misc.schema",
|
||||
"nis.schema",
|
||||
"openldap.schema",
|
||||
}
|
||||
|
||||
// tmplData is the struct used to execute the SLAPD config template.
|
||||
type tmplData struct {
|
||||
// Directory for database to be writen to.
|
||||
TempDir string
|
||||
// List of schema files to include.
|
||||
Includes []string
|
||||
}
|
||||
|
||||
// Config template copied from:
|
||||
// http://www.zytrax.com/books/ldap/ch5/index.html#step1-slapd
|
||||
var slapdConfigTmpl = template.Must(template.New("").Parse(`
|
||||
{{ range $i, $include := .Includes }}
|
||||
include {{ $include }}
|
||||
{{ end }}
|
||||
|
||||
# MODULELOAD definitions
|
||||
# not required (comment out) before version 2.3
|
||||
moduleload back_bdb.la
|
||||
|
||||
database bdb
|
||||
suffix "dc=example,dc=org"
|
||||
|
||||
# root or superuser
|
||||
rootdn "cn=admin,dc=example,dc=org"
|
||||
rootpw admin
|
||||
# The database directory MUST exist prior to running slapd AND
|
||||
# change path as necessary
|
||||
directory {{ .TempDir }}
|
||||
|
||||
# Indices to maintain for this directory
|
||||
# unique id so equality match only
|
||||
index uid eq
|
||||
# allows general searching on commonname, givenname and email
|
||||
index cn,gn,mail eq,sub
|
||||
# allows multiple variants on surname searching
|
||||
index sn eq,sub
|
||||
# sub above includes subintial,subany,subfinal
|
||||
# optimise department searches
|
||||
index ou eq
|
||||
# if searches will include objectClass uncomment following
|
||||
# index objectClass eq
|
||||
# shows use of default index parameter
|
||||
index default eq,sub
|
||||
# indices missing - uses default eq,sub
|
||||
index telephonenumber
|
||||
|
||||
# other database parameters
|
||||
# read more in slapd.conf reference section
|
||||
cachesize 10000
|
||||
checkpoint 128 15
|
||||
`))
|
||||
|
||||
func includes(t *testing.T) (paths []string) {
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("getting working directory: %v", err)
|
||||
}
|
||||
for _, f := range includeFiles {
|
||||
p := filepath.Join(wd, "testdata", f)
|
||||
if _, err := os.Stat(p); err != nil {
|
||||
t.Fatalf("failed to find schema file: %s %v", p, err)
|
||||
}
|
||||
paths = append(paths, p)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
|
19
connector/ldap/testdata/certs/ca.crt
vendored
Normal file
19
connector/ldap/testdata/certs/ca.crt
vendored
Normal file
|
@ -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-----
|
27
connector/ldap/testdata/certs/ca.key
vendored
Normal file
27
connector/ldap/testdata/certs/ca.key
vendored
Normal file
|
@ -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-----
|
8
connector/ldap/testdata/certs/dhparam.pem
vendored
Normal file
8
connector/ldap/testdata/certs/dhparam.pem
vendored
Normal file
|
@ -0,0 +1,8 @@
|
|||
-----BEGIN DH PARAMETERS-----
|
||||
MIIBCAKCAQEAx5y2viJKOAAcDYSj55odZsbA7dkSQ9afEPd9uaCLOvRYKLJY1S1V
|
||||
C4m1eVfna8JndSLdsBGDQe4BlBTkEYMYR8CJHtUuBxeAucOH8KlF8rIHXXi71oex
|
||||
T7kPtJEDINQKOn06bHqNcn0a7ZMWP8jiQ708OYr5P+1T/N82QTAFpDuqK42ZnBqf
|
||||
8qzQkkTN0UCktY2EWnFTbNIXcMKWQnYP8zt/CG3Q31b2bnQt2iLEa/DIF7RLNjfx
|
||||
9wPQBBAqgWbLmWfdPpHsAPtQxtItb+GRbPs3aLm06CFKlQuteDoP+suo0EtglHcV
|
||||
V9Ynvdz0cdJCJ7EPyET6CtLMzc/Puup/AwIBAg==
|
||||
-----END DH PARAMETERS-----
|
18
connector/ldap/testdata/certs/ldap.crt
vendored
Normal file
18
connector/ldap/testdata/certs/ldap.crt
vendored
Normal file
|
@ -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-----
|
27
connector/ldap/testdata/certs/ldap.key
vendored
Normal file
27
connector/ldap/testdata/certs/ldap.key
vendored
Normal file
|
@ -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-----
|
610
connector/ldap/testdata/core.schema
vendored
610
connector/ldap/testdata/core.schema
vendored
|
@ -1,610 +0,0 @@
|
|||
# OpenLDAP Core schema
|
||||
# $OpenLDAP$
|
||||
## This work is part of OpenLDAP Software <http://www.openldap.org/>.
|
||||
##
|
||||
## Copyright 1998-2016 The OpenLDAP Foundation.
|
||||
## All rights reserved.
|
||||
##
|
||||
## Redistribution and use in source and binary forms, with or without
|
||||
## modification, are permitted only as authorized by the OpenLDAP
|
||||
## Public License.
|
||||
##
|
||||
## A copy of this license is available in the file LICENSE in the
|
||||
## top-level directory of the distribution or, alternatively, at
|
||||
## <http://www.OpenLDAP.org/license.html>.
|
||||
#
|
||||
## Portions Copyright (C) The Internet Society (1997-2006).
|
||||
## All Rights Reserved.
|
||||
##
|
||||
## This document and translations of it may be copied and furnished to
|
||||
## others, and derivative works that comment on or otherwise explain it
|
||||
## or assist in its implementation may be prepared, copied, published
|
||||
## and distributed, in whole or in part, without restriction of any
|
||||
## kind, provided that the above copyright notice and this paragraph are
|
||||
## included on all such copies and derivative works. However, this
|
||||
## document itself may not be modified in any way, such as by removing
|
||||
## the copyright notice or references to the Internet Society or other
|
||||
## Internet organizations, except as needed for the purpose of
|
||||
## developing Internet standards in which case the procedures for
|
||||
## copyrights defined in the Internet Standards process must be
|
||||
## followed, or as required to translate it into languages other than
|
||||
## English.
|
||||
##
|
||||
## The limited permissions granted above are perpetual and will not be
|
||||
## revoked by the Internet Society or its successors or assigns.
|
||||
##
|
||||
## This document and the information contained herein is provided on an
|
||||
## "AS IS" basis and THE INTERNET SOCIETY AND THE INTERNET ENGINEERING
|
||||
## TASK FORCE DISCLAIMS ALL WARRANTIES, EXPRESS OR IMPLIED, INCLUDING
|
||||
## BUT NOT LIMITED TO ANY WARRANTY THAT THE USE OF THE INFORMATION
|
||||
## HEREIN WILL NOT INFRINGE ANY RIGHTS OR ANY IMPLIED WARRANTIES OF
|
||||
## MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE.
|
||||
|
||||
#
|
||||
#
|
||||
# Includes LDAPv3 schema items from:
|
||||
# RFC 2252/2256 (LDAPv3)
|
||||
#
|
||||
# Select standard track schema items:
|
||||
# RFC 1274 (uid/dc)
|
||||
# RFC 2079 (URI)
|
||||
# RFC 2247 (dc/dcObject)
|
||||
# RFC 2587 (PKI)
|
||||
# RFC 2589 (Dynamic Directory Services)
|
||||
# RFC 4524 (associatedDomain)
|
||||
#
|
||||
# Select informational schema items:
|
||||
# RFC 2377 (uidObject)
|
||||
|
||||
#
|
||||
# Standard attribute types from RFC 2256
|
||||
#
|
||||
|
||||
# system schema
|
||||
#attributetype ( 2.5.4.0 NAME 'objectClass'
|
||||
# DESC 'RFC2256: object classes of the entity'
|
||||
# EQUALITY objectIdentifierMatch
|
||||
# SYNTAX 1.3.6.1.4.1.1466.115.121.1.38 )
|
||||
|
||||
# system schema
|
||||
#attributetype ( 2.5.4.1 NAME ( 'aliasedObjectName' 'aliasedEntryName' )
|
||||
# DESC 'RFC2256: name of aliased object'
|
||||
# EQUALITY distinguishedNameMatch
|
||||
# SYNTAX 1.3.6.1.4.1.1466.115.121.1.12 SINGLE-VALUE )
|
||||
|
||||
attributetype ( 2.5.4.2 NAME 'knowledgeInformation'
|
||||
DESC 'RFC2256: knowledge information'
|
||||
EQUALITY caseIgnoreMatch
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{32768} )
|
||||
|
||||
# system schema
|
||||
#attributetype ( 2.5.4.3 NAME ( 'cn' 'commonName' )
|
||||
# DESC 'RFC2256: common name(s) for which the entity is known by'
|
||||
# SUP name )
|
||||
|
||||
attributetype ( 2.5.4.4 NAME ( 'sn' 'surname' )
|
||||
DESC 'RFC2256: last (family) name(s) for which the entity is known by'
|
||||
SUP name )
|
||||
|
||||
attributetype ( 2.5.4.5 NAME 'serialNumber'
|
||||
DESC 'RFC2256: serial number of the entity'
|
||||
EQUALITY caseIgnoreMatch
|
||||
SUBSTR caseIgnoreSubstringsMatch
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.44{64} )
|
||||
|
||||
# RFC 4519 definition ('countryName' in X.500 and RFC2256)
|
||||
attributetype ( 2.5.4.6 NAME ( 'c' 'countryName' )
|
||||
DESC 'RFC4519: two-letter ISO-3166 country code'
|
||||
SUP name
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.11
|
||||
SINGLE-VALUE )
|
||||
|
||||
#attributetype ( 2.5.4.6 NAME ( 'c' 'countryName' )
|
||||
# DESC 'RFC2256: ISO-3166 country 2-letter code'
|
||||
# SUP name SINGLE-VALUE )
|
||||
|
||||
attributetype ( 2.5.4.7 NAME ( 'l' 'localityName' )
|
||||
DESC 'RFC2256: locality which this object resides in'
|
||||
SUP name )
|
||||
|
||||
attributetype ( 2.5.4.8 NAME ( 'st' 'stateOrProvinceName' )
|
||||
DESC 'RFC2256: state or province which this object resides in'
|
||||
SUP name )
|
||||
|
||||
attributetype ( 2.5.4.9 NAME ( 'street' 'streetAddress' )
|
||||
DESC 'RFC2256: street address of this object'
|
||||
EQUALITY caseIgnoreMatch
|
||||
SUBSTR caseIgnoreSubstringsMatch
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{128} )
|
||||
|
||||
attributetype ( 2.5.4.10 NAME ( 'o' 'organizationName' )
|
||||
DESC 'RFC2256: organization this object belongs to'
|
||||
SUP name )
|
||||
|
||||
attributetype ( 2.5.4.11 NAME ( 'ou' 'organizationalUnitName' )
|
||||
DESC 'RFC2256: organizational unit this object belongs to'
|
||||
SUP name )
|
||||
|
||||
attributetype ( 2.5.4.12 NAME 'title'
|
||||
DESC 'RFC2256: title associated with the entity'
|
||||
SUP name )
|
||||
|
||||
# system schema
|
||||
#attributetype ( 2.5.4.13 NAME 'description'
|
||||
# DESC 'RFC2256: descriptive information'
|
||||
# EQUALITY caseIgnoreMatch
|
||||
# SUBSTR caseIgnoreSubstringsMatch
|
||||
# SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{1024} )
|
||||
|
||||
# Deprecated by enhancedSearchGuide
|
||||
attributetype ( 2.5.4.14 NAME 'searchGuide'
|
||||
DESC 'RFC2256: search guide, deprecated by enhancedSearchGuide'
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.25 )
|
||||
|
||||
attributetype ( 2.5.4.15 NAME 'businessCategory'
|
||||
DESC 'RFC2256: business category'
|
||||
EQUALITY caseIgnoreMatch
|
||||
SUBSTR caseIgnoreSubstringsMatch
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{128} )
|
||||
|
||||
attributetype ( 2.5.4.16 NAME 'postalAddress'
|
||||
DESC 'RFC2256: postal address'
|
||||
EQUALITY caseIgnoreListMatch
|
||||
SUBSTR caseIgnoreListSubstringsMatch
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.41 )
|
||||
|
||||
attributetype ( 2.5.4.17 NAME 'postalCode'
|
||||
DESC 'RFC2256: postal code'
|
||||
EQUALITY caseIgnoreMatch
|
||||
SUBSTR caseIgnoreSubstringsMatch
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{40} )
|
||||
|
||||
attributetype ( 2.5.4.18 NAME 'postOfficeBox'
|
||||
DESC 'RFC2256: Post Office Box'
|
||||
EQUALITY caseIgnoreMatch
|
||||
SUBSTR caseIgnoreSubstringsMatch
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{40} )
|
||||
|
||||
attributetype ( 2.5.4.19 NAME 'physicalDeliveryOfficeName'
|
||||
DESC 'RFC2256: Physical Delivery Office Name'
|
||||
EQUALITY caseIgnoreMatch
|
||||
SUBSTR caseIgnoreSubstringsMatch
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{128} )
|
||||
|
||||
attributetype ( 2.5.4.20 NAME 'telephoneNumber'
|
||||
DESC 'RFC2256: Telephone Number'
|
||||
EQUALITY telephoneNumberMatch
|
||||
SUBSTR telephoneNumberSubstringsMatch
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.50{32} )
|
||||
|
||||
attributetype ( 2.5.4.21 NAME 'telexNumber'
|
||||
DESC 'RFC2256: Telex Number'
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.52 )
|
||||
|
||||
attributetype ( 2.5.4.22 NAME 'teletexTerminalIdentifier'
|
||||
DESC 'RFC2256: Teletex Terminal Identifier'
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.51 )
|
||||
|
||||
attributetype ( 2.5.4.23 NAME ( 'facsimileTelephoneNumber' 'fax' )
|
||||
DESC 'RFC2256: Facsimile (Fax) Telephone Number'
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.22 )
|
||||
|
||||
attributetype ( 2.5.4.24 NAME 'x121Address'
|
||||
DESC 'RFC2256: X.121 Address'
|
||||
EQUALITY numericStringMatch
|
||||
SUBSTR numericStringSubstringsMatch
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.36{15} )
|
||||
|
||||
attributetype ( 2.5.4.25 NAME 'internationaliSDNNumber'
|
||||
DESC 'RFC2256: international ISDN number'
|
||||
EQUALITY numericStringMatch
|
||||
SUBSTR numericStringSubstringsMatch
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.36{16} )
|
||||
|
||||
attributetype ( 2.5.4.26 NAME 'registeredAddress'
|
||||
DESC 'RFC2256: registered postal address'
|
||||
SUP postalAddress
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.41 )
|
||||
|
||||
attributetype ( 2.5.4.27 NAME 'destinationIndicator'
|
||||
DESC 'RFC2256: destination indicator'
|
||||
EQUALITY caseIgnoreMatch
|
||||
SUBSTR caseIgnoreSubstringsMatch
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.44{128} )
|
||||
|
||||
attributetype ( 2.5.4.28 NAME 'preferredDeliveryMethod'
|
||||
DESC 'RFC2256: preferred delivery method'
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.14
|
||||
SINGLE-VALUE )
|
||||
|
||||
attributetype ( 2.5.4.29 NAME 'presentationAddress'
|
||||
DESC 'RFC2256: presentation address'
|
||||
EQUALITY presentationAddressMatch
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.43
|
||||
SINGLE-VALUE )
|
||||
|
||||
attributetype ( 2.5.4.30 NAME 'supportedApplicationContext'
|
||||
DESC 'RFC2256: supported application context'
|
||||
EQUALITY objectIdentifierMatch
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.38 )
|
||||
|
||||
attributetype ( 2.5.4.31 NAME 'member'
|
||||
DESC 'RFC2256: member of a group'
|
||||
SUP distinguishedName )
|
||||
|
||||
attributetype ( 2.5.4.32 NAME 'owner'
|
||||
DESC 'RFC2256: owner (of the object)'
|
||||
SUP distinguishedName )
|
||||
|
||||
attributetype ( 2.5.4.33 NAME 'roleOccupant'
|
||||
DESC 'RFC2256: occupant of role'
|
||||
SUP distinguishedName )
|
||||
|
||||
# system schema
|
||||
#attributetype ( 2.5.4.34 NAME 'seeAlso'
|
||||
# DESC 'RFC2256: DN of related object'
|
||||
# SUP distinguishedName )
|
||||
|
||||
# system schema
|
||||
#attributetype ( 2.5.4.35 NAME 'userPassword'
|
||||
# DESC 'RFC2256/2307: password of user'
|
||||
# EQUALITY octetStringMatch
|
||||
# SYNTAX 1.3.6.1.4.1.1466.115.121.1.40{128} )
|
||||
|
||||
# Must be transferred using ;binary
|
||||
# with certificateExactMatch rule (per X.509)
|
||||
attributetype ( 2.5.4.36 NAME 'userCertificate'
|
||||
DESC 'RFC2256: X.509 user certificate, use ;binary'
|
||||
EQUALITY certificateExactMatch
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.8 )
|
||||
|
||||
# Must be transferred using ;binary
|
||||
# with certificateExactMatch rule (per X.509)
|
||||
attributetype ( 2.5.4.37 NAME 'cACertificate'
|
||||
DESC 'RFC2256: X.509 CA certificate, use ;binary'
|
||||
EQUALITY certificateExactMatch
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.8 )
|
||||
|
||||
# Must be transferred using ;binary
|
||||
attributetype ( 2.5.4.38 NAME 'authorityRevocationList'
|
||||
DESC 'RFC2256: X.509 authority revocation list, use ;binary'
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.9 )
|
||||
|
||||
# Must be transferred using ;binary
|
||||
attributetype ( 2.5.4.39 NAME 'certificateRevocationList'
|
||||
DESC 'RFC2256: X.509 certificate revocation list, use ;binary'
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.9 )
|
||||
|
||||
# Must be stored and requested in the binary form
|
||||
attributetype ( 2.5.4.40 NAME 'crossCertificatePair'
|
||||
DESC 'RFC2256: X.509 cross certificate pair, use ;binary'
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.10 )
|
||||
|
||||
# system schema
|
||||
#attributetype ( 2.5.4.41 NAME 'name'
|
||||
# EQUALITY caseIgnoreMatch
|
||||
# SUBSTR caseIgnoreSubstringsMatch
|
||||
# SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{32768} )
|
||||
|
||||
attributetype ( 2.5.4.42 NAME ( 'givenName' 'gn' )
|
||||
DESC 'RFC2256: first name(s) for which the entity is known by'
|
||||
SUP name )
|
||||
|
||||
attributetype ( 2.5.4.43 NAME 'initials'
|
||||
DESC 'RFC2256: initials of some or all of names, but not the surname(s).'
|
||||
SUP name )
|
||||
|
||||
attributetype ( 2.5.4.44 NAME 'generationQualifier'
|
||||
DESC 'RFC2256: name qualifier indicating a generation'
|
||||
SUP name )
|
||||
|
||||
attributetype ( 2.5.4.45 NAME 'x500UniqueIdentifier'
|
||||
DESC 'RFC2256: X.500 unique identifier'
|
||||
EQUALITY bitStringMatch
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.6 )
|
||||
|
||||
attributetype ( 2.5.4.46 NAME 'dnQualifier'
|
||||
DESC 'RFC2256: DN qualifier'
|
||||
EQUALITY caseIgnoreMatch
|
||||
ORDERING caseIgnoreOrderingMatch
|
||||
SUBSTR caseIgnoreSubstringsMatch
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.44 )
|
||||
|
||||
attributetype ( 2.5.4.47 NAME 'enhancedSearchGuide'
|
||||
DESC 'RFC2256: enhanced search guide'
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.21 )
|
||||
|
||||
attributetype ( 2.5.4.48 NAME 'protocolInformation'
|
||||
DESC 'RFC2256: protocol information'
|
||||
EQUALITY protocolInformationMatch
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.42 )
|
||||
|
||||
# system schema
|
||||
#attributetype ( 2.5.4.49 NAME 'distinguishedName'
|
||||
# EQUALITY distinguishedNameMatch
|
||||
# SYNTAX 1.3.6.1.4.1.1466.115.121.1.12 )
|
||||
|
||||
attributetype ( 2.5.4.50 NAME 'uniqueMember'
|
||||
DESC 'RFC2256: unique member of a group'
|
||||
EQUALITY uniqueMemberMatch
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.34 )
|
||||
|
||||
attributetype ( 2.5.4.51 NAME 'houseIdentifier'
|
||||
DESC 'RFC2256: house identifier'
|
||||
EQUALITY caseIgnoreMatch
|
||||
SUBSTR caseIgnoreSubstringsMatch
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{32768} )
|
||||
|
||||
# Must be transferred using ;binary
|
||||
attributetype ( 2.5.4.52 NAME 'supportedAlgorithms'
|
||||
DESC 'RFC2256: supported algorithms'
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.49 )
|
||||
|
||||
# Must be transferred using ;binary
|
||||
attributetype ( 2.5.4.53 NAME 'deltaRevocationList'
|
||||
DESC 'RFC2256: delta revocation list; use ;binary'
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.9 )
|
||||
|
||||
attributetype ( 2.5.4.54 NAME 'dmdName'
|
||||
DESC 'RFC2256: name of DMD'
|
||||
SUP name )
|
||||
|
||||
attributetype ( 2.5.4.65 NAME 'pseudonym'
|
||||
DESC 'X.520(4th): pseudonym for the object'
|
||||
SUP name )
|
||||
|
||||
# Standard object classes from RFC2256
|
||||
|
||||
# system schema
|
||||
#objectclass ( 2.5.6.0 NAME 'top'
|
||||
# DESC 'RFC2256: top of the superclass chain'
|
||||
# ABSTRACT
|
||||
# MUST objectClass )
|
||||
|
||||
# system schema
|
||||
#objectclass ( 2.5.6.1 NAME 'alias'
|
||||
# DESC 'RFC2256: an alias'
|
||||
# SUP top STRUCTURAL
|
||||
# MUST aliasedObjectName )
|
||||
|
||||
objectclass ( 2.5.6.2 NAME 'country'
|
||||
DESC 'RFC2256: a country'
|
||||
SUP top STRUCTURAL
|
||||
MUST c
|
||||
MAY ( searchGuide $ description ) )
|
||||
|
||||
objectclass ( 2.5.6.3 NAME 'locality'
|
||||
DESC 'RFC2256: a locality'
|
||||
SUP top STRUCTURAL
|
||||
MAY ( street $ seeAlso $ searchGuide $ st $ l $ description ) )
|
||||
|
||||
objectclass ( 2.5.6.4 NAME 'organization'
|
||||
DESC 'RFC2256: an organization'
|
||||
SUP top STRUCTURAL
|
||||
MUST o
|
||||
MAY ( userPassword $ searchGuide $ seeAlso $ businessCategory $
|
||||
x121Address $ registeredAddress $ destinationIndicator $
|
||||
preferredDeliveryMethod $ telexNumber $ teletexTerminalIdentifier $
|
||||
telephoneNumber $ internationaliSDNNumber $
|
||||
facsimileTelephoneNumber $ street $ postOfficeBox $ postalCode $
|
||||
postalAddress $ physicalDeliveryOfficeName $ st $ l $ description ) )
|
||||
|
||||
objectclass ( 2.5.6.5 NAME 'organizationalUnit'
|
||||
DESC 'RFC2256: an organizational unit'
|
||||
SUP top STRUCTURAL
|
||||
MUST ou
|
||||
MAY ( userPassword $ searchGuide $ seeAlso $ businessCategory $
|
||||
x121Address $ registeredAddress $ destinationIndicator $
|
||||
preferredDeliveryMethod $ telexNumber $ teletexTerminalIdentifier $
|
||||
telephoneNumber $ internationaliSDNNumber $
|
||||
facsimileTelephoneNumber $ street $ postOfficeBox $ postalCode $
|
||||
postalAddress $ physicalDeliveryOfficeName $ st $ l $ description ) )
|
||||
|
||||
objectclass ( 2.5.6.6 NAME 'person'
|
||||
DESC 'RFC2256: a person'
|
||||
SUP top STRUCTURAL
|
||||
MUST ( sn $ cn )
|
||||
MAY ( userPassword $ telephoneNumber $ seeAlso $ description ) )
|
||||
|
||||
objectclass ( 2.5.6.7 NAME 'organizationalPerson'
|
||||
DESC 'RFC2256: an organizational person'
|
||||
SUP person STRUCTURAL
|
||||
MAY ( title $ x121Address $ registeredAddress $ destinationIndicator $
|
||||
preferredDeliveryMethod $ telexNumber $ teletexTerminalIdentifier $
|
||||
telephoneNumber $ internationaliSDNNumber $
|
||||
facsimileTelephoneNumber $ street $ postOfficeBox $ postalCode $
|
||||
postalAddress $ physicalDeliveryOfficeName $ ou $ st $ l ) )
|
||||
|
||||
objectclass ( 2.5.6.8 NAME 'organizationalRole'
|
||||
DESC 'RFC2256: an organizational role'
|
||||
SUP top STRUCTURAL
|
||||
MUST cn
|
||||
MAY ( x121Address $ registeredAddress $ destinationIndicator $
|
||||
preferredDeliveryMethod $ telexNumber $ teletexTerminalIdentifier $
|
||||
telephoneNumber $ internationaliSDNNumber $ facsimileTelephoneNumber $
|
||||
seeAlso $ roleOccupant $ preferredDeliveryMethod $ street $
|
||||
postOfficeBox $ postalCode $ postalAddress $
|
||||
physicalDeliveryOfficeName $ ou $ st $ l $ description ) )
|
||||
|
||||
objectclass ( 2.5.6.9 NAME 'groupOfNames'
|
||||
DESC 'RFC2256: a group of names (DNs)'
|
||||
SUP top STRUCTURAL
|
||||
MUST ( member $ cn )
|
||||
MAY ( businessCategory $ seeAlso $ owner $ ou $ o $ description ) )
|
||||
|
||||
objectclass ( 2.5.6.10 NAME 'residentialPerson'
|
||||
DESC 'RFC2256: an residential person'
|
||||
SUP person STRUCTURAL
|
||||
MUST l
|
||||
MAY ( businessCategory $ x121Address $ registeredAddress $
|
||||
destinationIndicator $ preferredDeliveryMethod $ telexNumber $
|
||||
teletexTerminalIdentifier $ telephoneNumber $ internationaliSDNNumber $
|
||||
facsimileTelephoneNumber $ preferredDeliveryMethod $ street $
|
||||
postOfficeBox $ postalCode $ postalAddress $
|
||||
physicalDeliveryOfficeName $ st $ l ) )
|
||||
|
||||
objectclass ( 2.5.6.11 NAME 'applicationProcess'
|
||||
DESC 'RFC2256: an application process'
|
||||
SUP top STRUCTURAL
|
||||
MUST cn
|
||||
MAY ( seeAlso $ ou $ l $ description ) )
|
||||
|
||||
objectclass ( 2.5.6.12 NAME 'applicationEntity'
|
||||
DESC 'RFC2256: an application entity'
|
||||
SUP top STRUCTURAL
|
||||
MUST ( presentationAddress $ cn )
|
||||
MAY ( supportedApplicationContext $ seeAlso $ ou $ o $ l $
|
||||
description ) )
|
||||
|
||||
objectclass ( 2.5.6.13 NAME 'dSA'
|
||||
DESC 'RFC2256: a directory system agent (a server)'
|
||||
SUP applicationEntity STRUCTURAL
|
||||
MAY knowledgeInformation )
|
||||
|
||||
objectclass ( 2.5.6.14 NAME 'device'
|
||||
DESC 'RFC2256: a device'
|
||||
SUP top STRUCTURAL
|
||||
MUST cn
|
||||
MAY ( serialNumber $ seeAlso $ owner $ ou $ o $ l $ description ) )
|
||||
|
||||
objectclass ( 2.5.6.15 NAME 'strongAuthenticationUser'
|
||||
DESC 'RFC2256: a strong authentication user'
|
||||
SUP top AUXILIARY
|
||||
MUST userCertificate )
|
||||
|
||||
objectclass ( 2.5.6.16 NAME 'certificationAuthority'
|
||||
DESC 'RFC2256: a certificate authority'
|
||||
SUP top AUXILIARY
|
||||
MUST ( authorityRevocationList $ certificateRevocationList $
|
||||
cACertificate ) MAY crossCertificatePair )
|
||||
|
||||
objectclass ( 2.5.6.17 NAME 'groupOfUniqueNames'
|
||||
DESC 'RFC2256: a group of unique names (DN and Unique Identifier)'
|
||||
SUP top STRUCTURAL
|
||||
MUST ( uniqueMember $ cn )
|
||||
MAY ( businessCategory $ seeAlso $ owner $ ou $ o $ description ) )
|
||||
|
||||
objectclass ( 2.5.6.18 NAME 'userSecurityInformation'
|
||||
DESC 'RFC2256: a user security information'
|
||||
SUP top AUXILIARY
|
||||
MAY ( supportedAlgorithms ) )
|
||||
|
||||
objectclass ( 2.5.6.16.2 NAME 'certificationAuthority-V2'
|
||||
SUP certificationAuthority
|
||||
AUXILIARY MAY ( deltaRevocationList ) )
|
||||
|
||||
objectclass ( 2.5.6.19 NAME 'cRLDistributionPoint'
|
||||
SUP top STRUCTURAL
|
||||
MUST ( cn )
|
||||
MAY ( certificateRevocationList $ authorityRevocationList $
|
||||
deltaRevocationList ) )
|
||||
|
||||
objectclass ( 2.5.6.20 NAME 'dmd'
|
||||
SUP top STRUCTURAL
|
||||
MUST ( dmdName )
|
||||
MAY ( userPassword $ searchGuide $ seeAlso $ businessCategory $
|
||||
x121Address $ registeredAddress $ destinationIndicator $
|
||||
preferredDeliveryMethod $ telexNumber $ teletexTerminalIdentifier $
|
||||
telephoneNumber $ internationaliSDNNumber $ facsimileTelephoneNumber $
|
||||
street $ postOfficeBox $ postalCode $ postalAddress $
|
||||
physicalDeliveryOfficeName $ st $ l $ description ) )
|
||||
|
||||
#
|
||||
# Object Classes from RFC 2587
|
||||
#
|
||||
objectclass ( 2.5.6.21 NAME 'pkiUser'
|
||||
DESC 'RFC2587: a PKI user'
|
||||
SUP top AUXILIARY
|
||||
MAY userCertificate )
|
||||
|
||||
objectclass ( 2.5.6.22 NAME 'pkiCA'
|
||||
DESC 'RFC2587: PKI certificate authority'
|
||||
SUP top AUXILIARY
|
||||
MAY ( authorityRevocationList $ certificateRevocationList $
|
||||
cACertificate $ crossCertificatePair ) )
|
||||
|
||||
objectclass ( 2.5.6.23 NAME 'deltaCRL'
|
||||
DESC 'RFC2587: PKI user'
|
||||
SUP top AUXILIARY
|
||||
MAY deltaRevocationList )
|
||||
|
||||
#
|
||||
# Standard Track URI label schema from RFC 2079
|
||||
# system schema
|
||||
#attributetype ( 1.3.6.1.4.1.250.1.57 NAME 'labeledURI'
|
||||
# DESC 'RFC2079: Uniform Resource Identifier with optional label'
|
||||
# EQUALITY caseExactMatch
|
||||
# SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )
|
||||
|
||||
objectclass ( 1.3.6.1.4.1.250.3.15 NAME 'labeledURIObject'
|
||||
DESC 'RFC2079: object that contains the URI attribute type'
|
||||
SUP top AUXILIARY
|
||||
MAY ( labeledURI ) )
|
||||
|
||||
#
|
||||
# Derived from RFC 1274, but with new "short names"
|
||||
#
|
||||
#attributetype ( 0.9.2342.19200300.100.1.1
|
||||
# NAME ( 'uid' 'userid' )
|
||||
# DESC 'RFC1274: user identifier'
|
||||
# EQUALITY caseIgnoreMatch
|
||||
# SUBSTR caseIgnoreSubstringsMatch
|
||||
# SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{256} )
|
||||
|
||||
attributetype ( 0.9.2342.19200300.100.1.3
|
||||
NAME ( 'mail' 'rfc822Mailbox' )
|
||||
DESC 'RFC1274: RFC822 Mailbox'
|
||||
EQUALITY caseIgnoreIA5Match
|
||||
SUBSTR caseIgnoreIA5SubstringsMatch
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26{256} )
|
||||
|
||||
objectclass ( 0.9.2342.19200300.100.4.19 NAME 'simpleSecurityObject'
|
||||
DESC 'RFC1274: simple security object'
|
||||
SUP top AUXILIARY
|
||||
MUST userPassword )
|
||||
|
||||
# RFC 1274 + RFC 2247
|
||||
attributetype ( 0.9.2342.19200300.100.1.25
|
||||
NAME ( 'dc' 'domainComponent' )
|
||||
DESC 'RFC1274/2247: domain component'
|
||||
EQUALITY caseIgnoreIA5Match
|
||||
SUBSTR caseIgnoreIA5SubstringsMatch
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 SINGLE-VALUE )
|
||||
|
||||
# RFC 2247
|
||||
objectclass ( 1.3.6.1.4.1.1466.344 NAME 'dcObject'
|
||||
DESC 'RFC2247: domain component object'
|
||||
SUP top AUXILIARY MUST dc )
|
||||
|
||||
# RFC 2377
|
||||
objectclass ( 1.3.6.1.1.3.1 NAME 'uidObject'
|
||||
DESC 'RFC2377: uid object'
|
||||
SUP top AUXILIARY MUST uid )
|
||||
|
||||
# RFC 4524
|
||||
# The 'associatedDomain' attribute specifies DNS [RFC1034][RFC2181]
|
||||
# host names [RFC1123] that are associated with an object. That is,
|
||||
# values of this attribute should conform to the following ABNF:
|
||||
#
|
||||
# domain = root / label *( DOT label )
|
||||
# root = SPACE
|
||||
# label = LETDIG [ *61( LETDIG / HYPHEN ) LETDIG ]
|
||||
# LETDIG = %x30-39 / %x41-5A / %x61-7A ; "0" - "9" / "A"-"Z" / "a"-"z"
|
||||
# SPACE = %x20 ; space (" ")
|
||||
# HYPHEN = %x2D ; hyphen ("-")
|
||||
# DOT = %x2E ; period (".")
|
||||
attributetype ( 0.9.2342.19200300.100.1.37
|
||||
NAME 'associatedDomain'
|
||||
DESC 'RFC1274: domain associated with object'
|
||||
EQUALITY caseIgnoreIA5Match
|
||||
SUBSTR caseIgnoreIA5SubstringsMatch
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 )
|
||||
|
||||
# RFC 2459 -- deprecated in favor of 'mail' (in cosine.schema)
|
||||
attributetype ( 1.2.840.113549.1.9.1
|
||||
NAME ( 'email' 'emailAddress' 'pkcs9email' )
|
||||
DESC 'RFC3280: legacy attribute for email addresses in DNs'
|
||||
EQUALITY caseIgnoreIA5Match
|
||||
SUBSTR caseIgnoreIA5SubstringsMatch
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26{128} )
|
||||
|
2571
connector/ldap/testdata/cosine.schema
vendored
2571
connector/ldap/testdata/cosine.schema
vendored
File diff suppressed because it is too large
Load diff
155
connector/ldap/testdata/inetorgperson.schema
vendored
155
connector/ldap/testdata/inetorgperson.schema
vendored
|
@ -1,155 +0,0 @@
|
|||
# inetorgperson.schema -- InetOrgPerson (RFC2798)
|
||||
# $OpenLDAP$
|
||||
## This work is part of OpenLDAP Software <http://www.openldap.org/>.
|
||||
##
|
||||
## Copyright 1998-2016 The OpenLDAP Foundation.
|
||||
## All rights reserved.
|
||||
##
|
||||
## Redistribution and use in source and binary forms, with or without
|
||||
## modification, are permitted only as authorized by the OpenLDAP
|
||||
## Public License.
|
||||
##
|
||||
## A copy of this license is available in the file LICENSE in the
|
||||
## top-level directory of the distribution or, alternatively, at
|
||||
## <http://www.OpenLDAP.org/license.html>.
|
||||
#
|
||||
# InetOrgPerson (RFC2798)
|
||||
#
|
||||
# Depends upon
|
||||
# Definition of an X.500 Attribute Type and an Object Class to Hold
|
||||
# Uniform Resource Identifiers (URIs) [RFC2079]
|
||||
# (core.schema)
|
||||
#
|
||||
# A Summary of the X.500(96) User Schema for use with LDAPv3 [RFC2256]
|
||||
# (core.schema)
|
||||
#
|
||||
# The COSINE and Internet X.500 Schema [RFC1274] (cosine.schema)
|
||||
|
||||
# carLicense
|
||||
# This multivalued field is used to record the values of the license or
|
||||
# registration plate associated with an individual.
|
||||
attributetype ( 2.16.840.1.113730.3.1.1
|
||||
NAME 'carLicense'
|
||||
DESC 'RFC2798: vehicle license or registration plate'
|
||||
EQUALITY caseIgnoreMatch
|
||||
SUBSTR caseIgnoreSubstringsMatch
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )
|
||||
|
||||
# departmentNumber
|
||||
# Code for department to which a person belongs. This can also be
|
||||
# strictly numeric (e.g., 1234) or alphanumeric (e.g., ABC/123).
|
||||
attributetype ( 2.16.840.1.113730.3.1.2
|
||||
NAME 'departmentNumber'
|
||||
DESC 'RFC2798: identifies a department within an organization'
|
||||
EQUALITY caseIgnoreMatch
|
||||
SUBSTR caseIgnoreSubstringsMatch
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )
|
||||
|
||||
# displayName
|
||||
# When displaying an entry, especially within a one-line summary list, it
|
||||
# is useful to be able to identify a name to be used. Since other attri-
|
||||
# bute types such as 'cn' are multivalued, an additional attribute type is
|
||||
# needed. Display name is defined for this purpose.
|
||||
attributetype ( 2.16.840.1.113730.3.1.241
|
||||
NAME 'displayName'
|
||||
DESC 'RFC2798: preferred name to be used when displaying entries'
|
||||
EQUALITY caseIgnoreMatch
|
||||
SUBSTR caseIgnoreSubstringsMatch
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15
|
||||
SINGLE-VALUE )
|
||||
|
||||
# employeeNumber
|
||||
# Numeric or alphanumeric identifier assigned to a person, typically based
|
||||
# on order of hire or association with an organization. Single valued.
|
||||
attributetype ( 2.16.840.1.113730.3.1.3
|
||||
NAME 'employeeNumber'
|
||||
DESC 'RFC2798: numerically identifies an employee within an organization'
|
||||
EQUALITY caseIgnoreMatch
|
||||
SUBSTR caseIgnoreSubstringsMatch
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15
|
||||
SINGLE-VALUE )
|
||||
|
||||
# employeeType
|
||||
# Used to identify the employer to employee relationship. Typical values
|
||||
# used will be "Contractor", "Employee", "Intern", "Temp", "External", and
|
||||
# "Unknown" but any value may be used.
|
||||
attributetype ( 2.16.840.1.113730.3.1.4
|
||||
NAME 'employeeType'
|
||||
DESC 'RFC2798: type of employment for a person'
|
||||
EQUALITY caseIgnoreMatch
|
||||
SUBSTR caseIgnoreSubstringsMatch
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )
|
||||
|
||||
# jpegPhoto
|
||||
# Used to store one or more images of a person using the JPEG File
|
||||
# Interchange Format [JFIF].
|
||||
# Note that the jpegPhoto attribute type was defined for use in the
|
||||
# Internet X.500 pilots but no referencable definition for it could be
|
||||
# located.
|
||||
attributetype ( 0.9.2342.19200300.100.1.60
|
||||
NAME 'jpegPhoto'
|
||||
DESC 'RFC2798: a JPEG image'
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.28 )
|
||||
|
||||
# preferredLanguage
|
||||
# Used to indicate an individual's preferred written or spoken
|
||||
# language. This is useful for international correspondence or human-
|
||||
# computer interaction. Values for this attribute type MUST conform to
|
||||
# the definition of the Accept-Language header field defined in
|
||||
# [RFC2068] with one exception: the sequence "Accept-Language" ":"
|
||||
# should be omitted. This is a single valued attribute type.
|
||||
attributetype ( 2.16.840.1.113730.3.1.39
|
||||
NAME 'preferredLanguage'
|
||||
DESC 'RFC2798: preferred written or spoken language for a person'
|
||||
EQUALITY caseIgnoreMatch
|
||||
SUBSTR caseIgnoreSubstringsMatch
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15
|
||||
SINGLE-VALUE )
|
||||
|
||||
# userSMIMECertificate
|
||||
# A PKCS#7 [RFC2315] SignedData, where the content that is signed is
|
||||
# ignored by consumers of userSMIMECertificate values. It is
|
||||
# recommended that values have a `contentType' of data with an absent
|
||||
# `content' field. Values of this attribute contain a person's entire
|
||||
# certificate chain and an smimeCapabilities field [RFC2633] that at a
|
||||
# minimum describes their SMIME algorithm capabilities. Values for
|
||||
# this attribute are to be stored and requested in binary form, as
|
||||
# 'userSMIMECertificate;binary'. If available, this attribute is
|
||||
# preferred over the userCertificate attribute for S/MIME applications.
|
||||
## OpenLDAP note: ";binary" transfer should NOT be used as syntax is binary
|
||||
attributetype ( 2.16.840.1.113730.3.1.40
|
||||
NAME 'userSMIMECertificate'
|
||||
DESC 'RFC2798: PKCS#7 SignedData used to support S/MIME'
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.5 )
|
||||
|
||||
# userPKCS12
|
||||
# PKCS #12 [PKCS12] provides a format for exchange of personal identity
|
||||
# information. When such information is stored in a directory service,
|
||||
# the userPKCS12 attribute should be used. This attribute is to be stored
|
||||
# and requested in binary form, as 'userPKCS12;binary'. The attribute
|
||||
# values are PFX PDUs stored as binary data.
|
||||
## OpenLDAP note: ";binary" transfer should NOT be used as syntax is binary
|
||||
attributetype ( 2.16.840.1.113730.3.1.216
|
||||
NAME 'userPKCS12'
|
||||
DESC 'RFC2798: personal identity information, a PKCS #12 PFX'
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.5 )
|
||||
|
||||
|
||||
# inetOrgPerson
|
||||
# The inetOrgPerson represents people who are associated with an
|
||||
# organization in some way. It is a structural class and is derived
|
||||
# from the organizationalPerson which is defined in X.521 [X521].
|
||||
objectclass ( 2.16.840.1.113730.3.2.2
|
||||
NAME 'inetOrgPerson'
|
||||
DESC 'RFC2798: Internet Organizational Person'
|
||||
SUP organizationalPerson
|
||||
STRUCTURAL
|
||||
MAY (
|
||||
audio $ businessCategory $ carLicense $ departmentNumber $
|
||||
displayName $ employeeNumber $ employeeType $ givenName $
|
||||
homePhone $ homePostalAddress $ initials $ jpegPhoto $
|
||||
labeledURI $ mail $ manager $ mobile $ o $ pager $
|
||||
photo $ roomNumber $ secretary $ uid $ userCertificate $
|
||||
x500uniqueIdentifier $ preferredLanguage $
|
||||
userSMIMECertificate $ userPKCS12 )
|
||||
)
|
75
connector/ldap/testdata/misc.schema
vendored
75
connector/ldap/testdata/misc.schema
vendored
|
@ -1,75 +0,0 @@
|
|||
# misc.schema -- assorted schema definitions
|
||||
# $OpenLDAP$
|
||||
## This work is part of OpenLDAP Software <http://www.openldap.org/>.
|
||||
##
|
||||
## Copyright 1998-2016 The OpenLDAP Foundation.
|
||||
## All rights reserved.
|
||||
##
|
||||
## Redistribution and use in source and binary forms, with or without
|
||||
## modification, are permitted only as authorized by the OpenLDAP
|
||||
## Public License.
|
||||
##
|
||||
## A copy of this license is available in the file LICENSE in the
|
||||
## top-level directory of the distribution or, alternatively, at
|
||||
## <http://www.OpenLDAP.org/license.html>.
|
||||
#
|
||||
# Assorted definitions from several sources, including
|
||||
# ''works in progress''. Contents of this file are
|
||||
# subject to change (including deletion) without notice.
|
||||
#
|
||||
# Not recommended for production use!
|
||||
# Use with extreme caution!
|
||||
|
||||
#-----------------------------------------------------------
|
||||
# draft-lachman-laser-ldap-mail-routing-02.txt !!!EXPIRED!!!
|
||||
# (a work in progress)
|
||||
#
|
||||
attributetype ( 2.16.840.1.113730.3.1.13
|
||||
NAME 'mailLocalAddress'
|
||||
DESC 'RFC822 email address of this recipient'
|
||||
EQUALITY caseIgnoreIA5Match
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26{256} )
|
||||
|
||||
attributetype ( 2.16.840.1.113730.3.1.18
|
||||
NAME 'mailHost'
|
||||
DESC 'FQDN of the SMTP/MTA of this recipient'
|
||||
EQUALITY caseIgnoreIA5Match
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26{256}
|
||||
SINGLE-VALUE )
|
||||
|
||||
attributetype ( 2.16.840.1.113730.3.1.47
|
||||
NAME 'mailRoutingAddress'
|
||||
DESC 'RFC822 routing address of this recipient'
|
||||
EQUALITY caseIgnoreIA5Match
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26{256}
|
||||
SINGLE-VALUE )
|
||||
|
||||
# I-D leaves this OID TBD.
|
||||
# iPlanet uses 2.16.840.1.113.730.3.2.147 but that is an
|
||||
# improperly delegated OID. A typo is likely.
|
||||
objectclass ( 2.16.840.1.113730.3.2.147
|
||||
NAME 'inetLocalMailRecipient'
|
||||
DESC 'Internet local mail recipient'
|
||||
SUP top AUXILIARY
|
||||
MAY ( mailLocalAddress $ mailHost $ mailRoutingAddress ) )
|
||||
|
||||
#-----------------------------------------------------------
|
||||
# draft-srivastava-ldap-mail-00.txt !!!EXPIRED!!!
|
||||
# (a work in progress)
|
||||
#
|
||||
attributetype ( 1.3.6.1.4.1.42.2.27.2.1.15
|
||||
NAME 'rfc822MailMember'
|
||||
DESC 'rfc822 mail address of group member(s)'
|
||||
EQUALITY caseIgnoreIA5Match
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 )
|
||||
|
||||
#-----------------------------------------------------------
|
||||
# !!!no I-D!!!
|
||||
# (a work in progress)
|
||||
#
|
||||
objectclass ( 1.3.6.1.4.1.42.2.27.1.2.5
|
||||
NAME 'nisMailAlias'
|
||||
DESC 'NIS mail alias'
|
||||
SUP top STRUCTURAL
|
||||
MUST cn
|
||||
MAY rfc822MailMember )
|
237
connector/ldap/testdata/nis.schema
vendored
237
connector/ldap/testdata/nis.schema
vendored
|
@ -1,237 +0,0 @@
|
|||
# $OpenLDAP$
|
||||
## This work is part of OpenLDAP Software <http://www.openldap.org/>.
|
||||
##
|
||||
## Copyright 1998-2016 The OpenLDAP Foundation.
|
||||
## All rights reserved.
|
||||
##
|
||||
## Redistribution and use in source and binary forms, with or without
|
||||
## modification, are permitted only as authorized by the OpenLDAP
|
||||
## Public License.
|
||||
##
|
||||
## A copy of this license is available in the file LICENSE in the
|
||||
## top-level directory of the distribution or, alternatively, at
|
||||
## <http://www.OpenLDAP.org/license.html>.
|
||||
|
||||
# Definitions from RFC2307 (Experimental)
|
||||
# An Approach for Using LDAP as a Network Information Service
|
||||
|
||||
# Depends upon core.schema and cosine.schema
|
||||
|
||||
# Note: The definitions in RFC2307 are given in syntaxes closely related
|
||||
# to those in RFC2252, however, some liberties are taken that are not
|
||||
# supported by RFC2252. This file has been written following RFC2252
|
||||
# strictly.
|
||||
|
||||
# OID Base is iso(1) org(3) dod(6) internet(1) directory(1) nisSchema(1).
|
||||
# i.e. nisSchema in RFC2307 is 1.3.6.1.1.1
|
||||
#
|
||||
# Syntaxes are under 1.3.6.1.1.1.0 (two new syntaxes are defined)
|
||||
# validaters for these syntaxes are incomplete, they only
|
||||
# implement printable string validation (which is good as the
|
||||
# common use of these syntaxes violates the specification).
|
||||
# Attribute types are under 1.3.6.1.1.1.1
|
||||
# Object classes are under 1.3.6.1.1.1.2
|
||||
|
||||
# Attribute Type Definitions
|
||||
|
||||
# builtin
|
||||
#attributetype ( 1.3.6.1.1.1.1.0 NAME 'uidNumber'
|
||||
# DESC 'An integer uniquely identifying a user in an administrative domain'
|
||||
# EQUALITY integerMatch
|
||||
# SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 SINGLE-VALUE )
|
||||
|
||||
# builtin
|
||||
#attributetype ( 1.3.6.1.1.1.1.1 NAME 'gidNumber'
|
||||
# DESC 'An integer uniquely identifying a group in an administrative domain'
|
||||
# EQUALITY integerMatch
|
||||
# SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 SINGLE-VALUE )
|
||||
|
||||
attributetype ( 1.3.6.1.1.1.1.2 NAME 'gecos'
|
||||
DESC 'The GECOS field; the common name'
|
||||
EQUALITY caseIgnoreIA5Match
|
||||
SUBSTR caseIgnoreIA5SubstringsMatch
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 SINGLE-VALUE )
|
||||
|
||||
attributetype ( 1.3.6.1.1.1.1.3 NAME 'homeDirectory'
|
||||
DESC 'The absolute path to the home directory'
|
||||
EQUALITY caseExactIA5Match
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 SINGLE-VALUE )
|
||||
|
||||
attributetype ( 1.3.6.1.1.1.1.4 NAME 'loginShell'
|
||||
DESC 'The path to the login shell'
|
||||
EQUALITY caseExactIA5Match
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 SINGLE-VALUE )
|
||||
|
||||
attributetype ( 1.3.6.1.1.1.1.5 NAME 'shadowLastChange'
|
||||
EQUALITY integerMatch
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 SINGLE-VALUE )
|
||||
|
||||
attributetype ( 1.3.6.1.1.1.1.6 NAME 'shadowMin'
|
||||
EQUALITY integerMatch
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 SINGLE-VALUE )
|
||||
|
||||
attributetype ( 1.3.6.1.1.1.1.7 NAME 'shadowMax'
|
||||
EQUALITY integerMatch
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 SINGLE-VALUE )
|
||||
|
||||
attributetype ( 1.3.6.1.1.1.1.8 NAME 'shadowWarning'
|
||||
EQUALITY integerMatch
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 SINGLE-VALUE )
|
||||
|
||||
attributetype ( 1.3.6.1.1.1.1.9 NAME 'shadowInactive'
|
||||
EQUALITY integerMatch
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 SINGLE-VALUE )
|
||||
|
||||
attributetype ( 1.3.6.1.1.1.1.10 NAME 'shadowExpire'
|
||||
EQUALITY integerMatch
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 SINGLE-VALUE )
|
||||
|
||||
attributetype ( 1.3.6.1.1.1.1.11 NAME 'shadowFlag'
|
||||
EQUALITY integerMatch
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 SINGLE-VALUE )
|
||||
|
||||
attributetype ( 1.3.6.1.1.1.1.12 NAME 'memberUid'
|
||||
EQUALITY caseExactIA5Match
|
||||
SUBSTR caseExactIA5SubstringsMatch
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 )
|
||||
|
||||
attributetype ( 1.3.6.1.1.1.1.13 NAME 'memberNisNetgroup'
|
||||
EQUALITY caseExactIA5Match
|
||||
SUBSTR caseExactIA5SubstringsMatch
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 )
|
||||
|
||||
attributetype ( 1.3.6.1.1.1.1.14 NAME 'nisNetgroupTriple'
|
||||
DESC 'Netgroup triple'
|
||||
SYNTAX 1.3.6.1.1.1.0.0 )
|
||||
|
||||
attributetype ( 1.3.6.1.1.1.1.15 NAME 'ipServicePort'
|
||||
EQUALITY integerMatch
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 SINGLE-VALUE )
|
||||
|
||||
attributetype ( 1.3.6.1.1.1.1.16 NAME 'ipServiceProtocol'
|
||||
SUP name )
|
||||
|
||||
attributetype ( 1.3.6.1.1.1.1.17 NAME 'ipProtocolNumber'
|
||||
EQUALITY integerMatch
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 SINGLE-VALUE )
|
||||
|
||||
attributetype ( 1.3.6.1.1.1.1.18 NAME 'oncRpcNumber'
|
||||
EQUALITY integerMatch
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 SINGLE-VALUE )
|
||||
|
||||
attributetype ( 1.3.6.1.1.1.1.19 NAME 'ipHostNumber'
|
||||
DESC 'IP address'
|
||||
EQUALITY caseIgnoreIA5Match
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26{128} )
|
||||
|
||||
attributetype ( 1.3.6.1.1.1.1.20 NAME 'ipNetworkNumber'
|
||||
DESC 'IP network'
|
||||
EQUALITY caseIgnoreIA5Match
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26{128} SINGLE-VALUE )
|
||||
|
||||
attributetype ( 1.3.6.1.1.1.1.21 NAME 'ipNetmaskNumber'
|
||||
DESC 'IP netmask'
|
||||
EQUALITY caseIgnoreIA5Match
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26{128} SINGLE-VALUE )
|
||||
|
||||
attributetype ( 1.3.6.1.1.1.1.22 NAME 'macAddress'
|
||||
DESC 'MAC address'
|
||||
EQUALITY caseIgnoreIA5Match
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26{128} )
|
||||
|
||||
attributetype ( 1.3.6.1.1.1.1.23 NAME 'bootParameter'
|
||||
DESC 'rpc.bootparamd parameter'
|
||||
SYNTAX 1.3.6.1.1.1.0.1 )
|
||||
|
||||
attributetype ( 1.3.6.1.1.1.1.24 NAME 'bootFile'
|
||||
DESC 'Boot image name'
|
||||
EQUALITY caseExactIA5Match
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 )
|
||||
|
||||
attributetype ( 1.3.6.1.1.1.1.26 NAME 'nisMapName'
|
||||
SUP name )
|
||||
|
||||
attributetype ( 1.3.6.1.1.1.1.27 NAME 'nisMapEntry'
|
||||
EQUALITY caseExactIA5Match
|
||||
SUBSTR caseExactIA5SubstringsMatch
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26{1024} SINGLE-VALUE )
|
||||
|
||||
# Object Class Definitions
|
||||
|
||||
objectclass ( 1.3.6.1.1.1.2.0 NAME 'posixAccount'
|
||||
DESC 'Abstraction of an account with POSIX attributes'
|
||||
SUP top AUXILIARY
|
||||
MUST ( cn $ uid $ uidNumber $ gidNumber $ homeDirectory )
|
||||
MAY ( userPassword $ loginShell $ gecos $ description ) )
|
||||
|
||||
objectclass ( 1.3.6.1.1.1.2.1 NAME 'shadowAccount'
|
||||
DESC 'Additional attributes for shadow passwords'
|
||||
SUP top AUXILIARY
|
||||
MUST uid
|
||||
MAY ( userPassword $ shadowLastChange $ shadowMin $
|
||||
shadowMax $ shadowWarning $ shadowInactive $
|
||||
shadowExpire $ shadowFlag $ description ) )
|
||||
|
||||
objectclass ( 1.3.6.1.1.1.2.2 NAME 'posixGroup'
|
||||
DESC 'Abstraction of a group of accounts'
|
||||
SUP top STRUCTURAL
|
||||
MUST ( cn $ gidNumber )
|
||||
MAY ( userPassword $ memberUid $ description ) )
|
||||
|
||||
objectclass ( 1.3.6.1.1.1.2.3 NAME 'ipService'
|
||||
DESC 'Abstraction an Internet Protocol service'
|
||||
SUP top STRUCTURAL
|
||||
MUST ( cn $ ipServicePort $ ipServiceProtocol )
|
||||
MAY ( description ) )
|
||||
|
||||
objectclass ( 1.3.6.1.1.1.2.4 NAME 'ipProtocol'
|
||||
DESC 'Abstraction of an IP protocol'
|
||||
SUP top STRUCTURAL
|
||||
MUST ( cn $ ipProtocolNumber $ description )
|
||||
MAY description )
|
||||
|
||||
objectclass ( 1.3.6.1.1.1.2.5 NAME 'oncRpc'
|
||||
DESC 'Abstraction of an ONC/RPC binding'
|
||||
SUP top STRUCTURAL
|
||||
MUST ( cn $ oncRpcNumber $ description )
|
||||
MAY description )
|
||||
|
||||
objectclass ( 1.3.6.1.1.1.2.6 NAME 'ipHost'
|
||||
DESC 'Abstraction of a host, an IP device'
|
||||
SUP top AUXILIARY
|
||||
MUST ( cn $ ipHostNumber )
|
||||
MAY ( l $ description $ manager ) )
|
||||
|
||||
objectclass ( 1.3.6.1.1.1.2.7 NAME 'ipNetwork'
|
||||
DESC 'Abstraction of an IP network'
|
||||
SUP top STRUCTURAL
|
||||
MUST ( cn $ ipNetworkNumber )
|
||||
MAY ( ipNetmaskNumber $ l $ description $ manager ) )
|
||||
|
||||
objectclass ( 1.3.6.1.1.1.2.8 NAME 'nisNetgroup'
|
||||
DESC 'Abstraction of a netgroup'
|
||||
SUP top STRUCTURAL
|
||||
MUST cn
|
||||
MAY ( nisNetgroupTriple $ memberNisNetgroup $ description ) )
|
||||
|
||||
objectclass ( 1.3.6.1.1.1.2.9 NAME 'nisMap'
|
||||
DESC 'A generic abstraction of a NIS map'
|
||||
SUP top STRUCTURAL
|
||||
MUST nisMapName
|
||||
MAY description )
|
||||
|
||||
objectclass ( 1.3.6.1.1.1.2.10 NAME 'nisObject'
|
||||
DESC 'An entry in a NIS map'
|
||||
SUP top STRUCTURAL
|
||||
MUST ( cn $ nisMapEntry $ nisMapName )
|
||||
MAY description )
|
||||
|
||||
objectclass ( 1.3.6.1.1.1.2.11 NAME 'ieee802Device'
|
||||
DESC 'A device with a MAC address'
|
||||
SUP top AUXILIARY
|
||||
MAY macAddress )
|
||||
|
||||
objectclass ( 1.3.6.1.1.1.2.12 NAME 'bootableDevice'
|
||||
DESC 'A device with boot parameters'
|
||||
SUP top AUXILIARY
|
||||
MAY ( bootFile $ bootParameter ) )
|
54
connector/ldap/testdata/openldap.schema
vendored
54
connector/ldap/testdata/openldap.schema
vendored
|
@ -1,54 +0,0 @@
|
|||
# $OpenLDAP$
|
||||
## This work is part of OpenLDAP Software <http://www.openldap.org/>.
|
||||
##
|
||||
## Copyright 1998-2016 The OpenLDAP Foundation.
|
||||
## All rights reserved.
|
||||
##
|
||||
## Redistribution and use in source and binary forms, with or without
|
||||
## modification, are permitted only as authorized by the OpenLDAP
|
||||
## Public License.
|
||||
##
|
||||
## A copy of this license is available in the file LICENSE in the
|
||||
## top-level directory of the distribution or, alternatively, at
|
||||
## <http://www.OpenLDAP.org/license.html>.
|
||||
|
||||
#
|
||||
# OpenLDAP Project's directory schema items
|
||||
#
|
||||
# depends upon:
|
||||
# core.schema
|
||||
# cosine.schema
|
||||
# inetorgperson.schema
|
||||
#
|
||||
# These are provided for informational purposes only.
|
||||
|
||||
objectIdentifier OpenLDAProot 1.3.6.1.4.1.4203
|
||||
|
||||
objectIdentifier OpenLDAP OpenLDAProot:1
|
||||
objectIdentifier OpenLDAPattributeType OpenLDAP:3
|
||||
objectIdentifier OpenLDAPobjectClass OpenLDAP:4
|
||||
|
||||
objectClass ( OpenLDAPobjectClass:3
|
||||
NAME 'OpenLDAPorg'
|
||||
DESC 'OpenLDAP Organizational Object'
|
||||
SUP organization
|
||||
MAY ( buildingName $ displayName $ labeledURI ) )
|
||||
|
||||
objectClass ( OpenLDAPobjectClass:4
|
||||
NAME 'OpenLDAPou'
|
||||
DESC 'OpenLDAP Organizational Unit Object'
|
||||
SUP organizationalUnit
|
||||
MAY ( buildingName $ displayName $ labeledURI $ o ) )
|
||||
|
||||
objectClass ( OpenLDAPobjectClass:5
|
||||
NAME 'OpenLDAPperson'
|
||||
DESC 'OpenLDAP Person'
|
||||
SUP ( pilotPerson $ inetOrgPerson )
|
||||
MUST ( uid $ cn )
|
||||
MAY ( givenName $ labeledURI $ o ) )
|
||||
|
||||
objectClass ( OpenLDAPobjectClass:6
|
||||
NAME 'OpenLDAPdisplayableObject'
|
||||
DESC 'OpenLDAP Displayable Object'
|
||||
AUXILIARY
|
||||
MAY displayName )
|
447
connector/ldap/testdata/schema.ldif
vendored
Normal file
447
connector/ldap/testdata/schema.ldif
vendored
Normal file
|
@ -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
|
242
connector/linkedin/linkedin.go
Normal file
242
connector/linkedin/linkedin.go
Normal file
|
@ -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
|
||||
}
|
515
connector/microsoft/microsoft.go
Normal file
515
connector/microsoft/microsoft.go
Normal file
|
@ -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
|
||||
}
|
146
connector/microsoft/microsoft_test.go
Normal file
146
connector/microsoft/microsoft_test.go
Normal file
|
@ -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
|
||||
}
|
||||
|
|
298
connector/oauth/oauth.go
Normal file
298
connector/oauth/oauth.go
Normal file
|
@ -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)
|
||||
}
|
299
connector/oauth/oauth_test.go
Normal file
299
connector/oauth/oauth_test.go
Normal file
|
@ -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
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue