Merge pull request #814 from goauthentik/plex-auth
sources/plex: rewrite plex source
This commit is contained in:
commit
07b001bc2b
1
Pipfile
1
Pipfile
|
@ -59,3 +59,4 @@ pylint-django = "*"
|
|||
pytest = "*"
|
||||
pytest-django = "*"
|
||||
selenium = "*"
|
||||
requests-mock = "*"
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"_meta": {
|
||||
"hash": {
|
||||
"sha256": "24f00363590649f2442c6ac28dfe8692f0f317e0a5b91c0696b84610cef299d2"
|
||||
"sha256": "17be2923cf8d281e430ec1467aea723806ac6f7c58fc6553ede92317e43f4d14"
|
||||
},
|
||||
"pipfile-spec": 6,
|
||||
"requires": {
|
||||
|
@ -56,6 +56,7 @@
|
|||
"sha256:f881853d2643a29e643609da57b96d5f9c9b93f62429dcc1cbb413c7d07f0e1a",
|
||||
"sha256:fe60131d21b31fd1a14bd43e6bb88256f69dfc3188b3a89d736d6c71ed43ec95"
|
||||
],
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==3.7.4.post0"
|
||||
},
|
||||
"aioredis": {
|
||||
|
@ -70,6 +71,7 @@
|
|||
"sha256:03e16e94f2b34c31f8bf1206d8ddd3ccaa4c315f7f6a1879b7b1210d229568c2",
|
||||
"sha256:493a2ac6788ce270a2f6a765b017299f60c1998f5a8617908ee9be082f7300fb"
|
||||
],
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==5.0.6"
|
||||
},
|
||||
"asgiref": {
|
||||
|
@ -77,6 +79,7 @@
|
|||
"sha256:92906c611ce6c967347bbfea733f13d6313901d54dcca88195eaeb52b2a8e8ee",
|
||||
"sha256:d1216dfbdfb63826470995d31caed36225dcaf34f182e0fa257a4dd9e86f1b78"
|
||||
],
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==3.3.4"
|
||||
},
|
||||
"async-timeout": {
|
||||
|
@ -84,6 +87,7 @@
|
|||
"sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f",
|
||||
"sha256:4291ca197d287d274d0b6cb5d6f8f8f82d434ed288f962539ff18cc9012f9ea3"
|
||||
],
|
||||
"markers": "python_full_version >= '3.5.3'",
|
||||
"version": "==3.0.1"
|
||||
},
|
||||
"attrs": {
|
||||
|
@ -91,6 +95,7 @@
|
|||
"sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6",
|
||||
"sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==20.3.0"
|
||||
},
|
||||
"autobahn": {
|
||||
|
@ -98,6 +103,7 @@
|
|||
"sha256:9195df8af03b0ff29ccd4b7f5abbde957ee90273465942205f9a1bad6c3f07ac",
|
||||
"sha256:e126c1f583e872fb59e79d36977cfa1f2d0a8a79f90ae31f406faae7664b8e03"
|
||||
],
|
||||
"markers": "python_version >= '3.7'",
|
||||
"version": "==21.3.1"
|
||||
},
|
||||
"automat": {
|
||||
|
@ -127,6 +133,7 @@
|
|||
"sha256:e4f8cb923edf035c2ae5f6169c70e77e31df70b88919b92b826a6b9bd14511b1",
|
||||
"sha256:f7c2c5c5ed5212b2628d8fb1c587b31c6e8d413ecbbd1a1cdf6f96ed6f5c8d5e"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'",
|
||||
"version": "==1.20.62"
|
||||
},
|
||||
"cachetools": {
|
||||
|
@ -134,6 +141,7 @@
|
|||
"sha256:2cc0b89715337ab6dbba85b5b50effe2b0c74e035d83ee8ed637cf52f12ae001",
|
||||
"sha256:61b5ed1e22a0924aed1d23b478f37e8d52549ff8a961de2909c69bf950020cff"
|
||||
],
|
||||
"markers": "python_version ~= '3.5'",
|
||||
"version": "==4.2.2"
|
||||
},
|
||||
"cbor2": {
|
||||
|
@ -220,6 +228,7 @@
|
|||
"sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa",
|
||||
"sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
|
||||
"version": "==4.0.0"
|
||||
},
|
||||
"click": {
|
||||
|
@ -227,6 +236,7 @@
|
|||
"sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a",
|
||||
"sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
|
||||
"version": "==7.1.2"
|
||||
},
|
||||
"click-didyoumean": {
|
||||
|
@ -300,6 +310,7 @@
|
|||
"sha256:76ffae916ba3aa66b46996c14fa713e46004788167a4873d647544e750e0e99f",
|
||||
"sha256:a9af943c79717bc52fe64a3c236ae5d3adccc8b5be19c881b442d2c3db233393"
|
||||
],
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==3.0.2"
|
||||
},
|
||||
"defusedxml": {
|
||||
|
@ -425,6 +436,7 @@
|
|||
"hashes": [
|
||||
"sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d"
|
||||
],
|
||||
"markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==0.18.2"
|
||||
},
|
||||
"geoip2": {
|
||||
|
@ -440,6 +452,7 @@
|
|||
"sha256:588bdb03a41ecb4978472b847881e5518b5d9ec6153d3d679aa127a55e13b39f",
|
||||
"sha256:9ad25fba07f46a628ad4d0ca09f38dcb262830df2ac95b217f9b0129c9e42206"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'",
|
||||
"version": "==1.30.0"
|
||||
},
|
||||
"gunicorn": {
|
||||
|
@ -455,6 +468,7 @@
|
|||
"sha256:36a3cb8c0a032f56e2da7084577878a035d3b61d104230d4bd49c0c6b555a9c6",
|
||||
"sha256:47222cb6067e4a307d535814917cd98fd0a57b6788ce715755fa2b6c28b56042"
|
||||
],
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==0.12.0"
|
||||
},
|
||||
"hiredis": {
|
||||
|
@ -501,6 +515,7 @@
|
|||
"sha256:f52010e0a44e3d8530437e7da38d11fb822acfb0d5b12e9cd5ba655509937ca0",
|
||||
"sha256:f8196f739092a78e4f6b1b2172679ed3343c39c61a3e9d722ce6fcf1dac2824a"
|
||||
],
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==2.0.0"
|
||||
},
|
||||
"httptools": {
|
||||
|
@ -549,6 +564,7 @@
|
|||
"sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417",
|
||||
"sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2"
|
||||
],
|
||||
"markers": "python_version >= '3.5'",
|
||||
"version": "==0.5.1"
|
||||
},
|
||||
"itypes": {
|
||||
|
@ -563,6 +579,7 @@
|
|||
"sha256:03e47ad063331dd6a3f04a43eddca8a966a26ba0c5b7207a9a9e4e08f1b29419",
|
||||
"sha256:a6d58433de0ae800347cab1fa3043cebbabe8baa9d29e668f1c768cb87a333c6"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
|
||||
"version": "==2.11.3"
|
||||
},
|
||||
"jmespath": {
|
||||
|
@ -570,6 +587,7 @@
|
|||
"sha256:b85d0567b8666149a93172712e68920734333c0ce7e89b78b3e987f71e5ed4f9",
|
||||
"sha256:cdf6525904cc597730141d61b36f2e4b8ecc257c420fa2f4549bac2c2d0cb72f"
|
||||
],
|
||||
"markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==0.10.0"
|
||||
},
|
||||
"jsonschema": {
|
||||
|
@ -584,6 +602,7 @@
|
|||
"sha256:6dc509178ac4269b0e66ab4881f70a2035c33d3a622e20585f965986a5182006",
|
||||
"sha256:f4965fba0a4718d47d470beeb5d6446e3357a62402b16c510b6a2f251e05ac3c"
|
||||
],
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==5.0.2"
|
||||
},
|
||||
"kubernetes": {
|
||||
|
@ -596,8 +615,11 @@
|
|||
},
|
||||
"ldap3": {
|
||||
"hashes": [
|
||||
"sha256:afc6fc0d01f02af82cd7bfabd3bbfd5dc96a6ae91e97db0a2dab8a0f1b436056",
|
||||
"sha256:18c3ee656a6775b9b0d60f7c6c5b094d878d1d90fc03d56731039f0a4b546a91",
|
||||
"sha256:c1df41d89459be6f304e0ceec4b00fdea533dbbcd83c802b1272dcdb94620b57"
|
||||
"sha256:c1df41d89459be6f304e0ceec4b00fdea533dbbcd83c802b1272dcdb94620b57",
|
||||
"sha256:8c949edbad2be8a03e719ba48bd6779f327ec156929562814b3e84ab56889c8c",
|
||||
"sha256:4139c91f0eef9782df7b77c8cbc6243086affcb6a8a249b768a9658438e5da59"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==2.9"
|
||||
|
@ -709,12 +731,14 @@
|
|||
"sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be",
|
||||
"sha256:feb7b34d6325451ef96bc0e36e1a6c0c1c64bc1fbec4b854f4529e51887b1621"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==1.1.1"
|
||||
},
|
||||
"maxminddb": {
|
||||
"hashes": [
|
||||
"sha256:47e86a084dd814fac88c99ea34ba3278a74bc9de5a25f4b815b608798747c7dc"
|
||||
],
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==2.0.3"
|
||||
},
|
||||
"msgpack": {
|
||||
|
@ -790,6 +814,7 @@
|
|||
"sha256:f21756997ad8ef815d8ef3d34edd98804ab5ea337feedcd62fb52d22bf531281",
|
||||
"sha256:fc13a9524bc18b6fb6e0dbec3533ba0496bbed167c56d0aabefd965584557d80"
|
||||
],
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==5.1.0"
|
||||
},
|
||||
"oauthlib": {
|
||||
|
@ -797,6 +822,7 @@
|
|||
"sha256:bee41cc35fcca6e988463cacc3bcb8a96224f470ca547e697b604cc697b2f889",
|
||||
"sha256:df884cd6cbe20e32633f1db1072e9356f53638e4361bef4e8b03c9127c9328ea"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==3.1.0"
|
||||
},
|
||||
"packaging": {
|
||||
|
@ -812,6 +838,7 @@
|
|||
"sha256:030e4f9df5f53db2292eec37c6255957eb76168c6f974e4176c711cf91ed34aa",
|
||||
"sha256:b6c5a9643e3545bcbfd9451766cbaa5d9c67e7303c7bc32c750b6fa70ecb107d"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==0.10.1"
|
||||
},
|
||||
"prompt-toolkit": {
|
||||
|
@ -819,6 +846,7 @@
|
|||
"sha256:bf00f22079f5fadc949f42ae8ff7f05702826a97059ffcc6281036ad40ac6f04",
|
||||
"sha256:e1b4f11b9336a28fa11810bc623c357420f69dfdb6d2dac41ca2c21a55c033bc"
|
||||
],
|
||||
"markers": "python_full_version >= '3.6.1'",
|
||||
"version": "==3.0.18"
|
||||
},
|
||||
"psycopg2-binary": {
|
||||
|
@ -864,15 +892,37 @@
|
|||
},
|
||||
"pyasn1": {
|
||||
"hashes": [
|
||||
"sha256:6e7545f1a61025a4e58bb336952c5061697da694db1cae97b116e9c46abcf7c8",
|
||||
"sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba",
|
||||
"sha256:014c0e9976956a08139dc0712ae195324a75e142284d5f87f1a87ee1b068a359",
|
||||
"sha256:e89bf84b5437b532b0803ba5c9a5e054d21fec423a89952a74f87fa2c9b7bce2",
|
||||
"sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d",
|
||||
"sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba"
|
||||
"sha256:5c9414dcfede6e441f7e8f81b43b34e834731003427e5b09e4e00e3172a10f00",
|
||||
"sha256:99fcc3c8d804d1bc6d9a099921e39d827026409a58f2a720dcdb89374ea0c776",
|
||||
"sha256:7ab8a544af125fb704feadb008c99a88805126fb525280b2270bb25cc1d78a12",
|
||||
"sha256:0458773cfe65b153891ac249bcf1b5f8f320b7c2ce462151f8fa74de8934becf",
|
||||
"sha256:08c3c53b75eaa48d71cf8c710312316392ed40899cb34710d092e96745a358b7",
|
||||
"sha256:03840c999ba71680a131cfaee6fab142e1ed9bbd9c693e285cc6aca0d555e576",
|
||||
"sha256:fec3e9d8e36808a28efb59b489e4528c10ad0f480e57dcc32b4de5c9d8c9fdf3",
|
||||
"sha256:78fa6da68ed2727915c4767bb386ab32cdba863caa7dbe473eaae45f9959da86"
|
||||
],
|
||||
"version": "==0.4.8"
|
||||
},
|
||||
"pyasn1-modules": {
|
||||
"hashes": [
|
||||
"sha256:f39edd8c4ecaa4556e989147ebf219227e2cd2e8a43c7e7fcb1f1c18c5fd6a3d",
|
||||
"sha256:426edb7a5e8879f1ec54a1864f16b882c2837bfd06eee62f2c982315ee2473ed",
|
||||
"sha256:15b7c67fabc7fc240d87fb9aabf999cf82311a6d6fb2c70d00d3d0604878c811",
|
||||
"sha256:fe0644d9ab041506b62782e92b06b8c68cca799e1a9636ec398675459e031405",
|
||||
"sha256:b80486a6c77252ea3a3e9b1e360bc9cf28eaac41263d173c032581ad2f20fe45",
|
||||
"sha256:65cebbaffc913f4fe9e4808735c95ea22d7a7775646ab690518c056784bc21b4",
|
||||
"sha256:905f84c712230b2c592c19470d3ca8d552de726050d1d1716282a1f6146be65e",
|
||||
"sha256:a50b808ffeb97cb3601dd25981f6b016cbb3d31fbf57a8b8a87428e6158d0c74"
|
||||
"sha256:a50b808ffeb97cb3601dd25981f6b016cbb3d31fbf57a8b8a87428e6158d0c74",
|
||||
"sha256:c29a5e5cc7a3f05926aff34e097e84f8589cd790ce0ed41b67aed6857b26aafd",
|
||||
"sha256:a99324196732f53093a84c4369c996713eb8c89d360a496b599fb1a9c47fc3eb",
|
||||
"sha256:0fe1b68d1e486a1ed5473f1302bd991c1611d319bba158e98b106ff86e1d7199",
|
||||
"sha256:cbac4bc38d117f2a49aeedec4407d23e8866ea4ac27ff2cf7fb3e5b570df19e0",
|
||||
"sha256:0845a5582f6a02bb3e1bde9ecfc4bfcae6ec3210dd270522fee602365430c3f8"
|
||||
],
|
||||
"version": "==0.2.8"
|
||||
},
|
||||
|
@ -881,6 +931,7 @@
|
|||
"sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0",
|
||||
"sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==2.20"
|
||||
},
|
||||
"pycryptodome": {
|
||||
|
@ -924,6 +975,7 @@
|
|||
"sha256:412e00137858f04bde0729913874a48485665f2d36fe9ee449f26be864af9316",
|
||||
"sha256:7ead136e03655af85069b6f47b23eb7c3e5c221aa9f022a4fbb499f5b7308f29"
|
||||
],
|
||||
"markers": "python_version >= '3.5'",
|
||||
"version": "==2.0.2"
|
||||
},
|
||||
"pyjwt": {
|
||||
|
@ -946,12 +998,14 @@
|
|||
"sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1",
|
||||
"sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"
|
||||
],
|
||||
"markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==2.4.7"
|
||||
},
|
||||
"pyrsistent": {
|
||||
"hashes": [
|
||||
"sha256:2e636185d9eb976a18a8a8e96efce62f2905fea90041958d8cc2a189756ebf3e"
|
||||
],
|
||||
"markers": "python_version >= '3.5'",
|
||||
"version": "==0.17.3"
|
||||
},
|
||||
"python-dateutil": {
|
||||
|
@ -959,6 +1013,7 @@
|
|||
"sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c",
|
||||
"sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==2.8.1"
|
||||
},
|
||||
"python-dotenv": {
|
||||
|
@ -1015,6 +1070,7 @@
|
|||
"sha256:0e7e0cfca8660dea8b7d5cd8c4f6c5e29e11f31158c0b0ae91a397f00e5a05a2",
|
||||
"sha256:432b788c4530cfe16d8d943a09d40ca6c16149727e4afe8c2c9d5580c59d9f24"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
|
||||
"version": "==3.5.3"
|
||||
},
|
||||
"requests": {
|
||||
|
@ -1022,12 +1078,14 @@
|
|||
"sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804",
|
||||
"sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
|
||||
"version": "==2.25.1"
|
||||
},
|
||||
"requests-oauthlib": {
|
||||
"hashes": [
|
||||
"sha256:7f71572defaecd16372f9006f33c2ec8c077c3cfa6f5911a9a90202beb513f3d",
|
||||
"sha256:b4261601a71fd721a8bd6d7aa1cc1d6a8a93b4a9f5e96626f8e4d91e8beeaa6a"
|
||||
"sha256:b4261601a71fd721a8bd6d7aa1cc1d6a8a93b4a9f5e96626f8e4d91e8beeaa6a",
|
||||
"sha256:fa6c47b933f01060936d87ae9327fead68768b69c6c9ea2109c48be30f2d4dbc"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.3.0"
|
||||
|
@ -1045,6 +1103,7 @@
|
|||
"sha256:44bc6b54fddd45e4bc0619059196679f9e8b79c027f4131bb072e6a22f4d5e28",
|
||||
"sha256:ac79fb25f5476e8e9ed1c53b8a2286d2c3f5dde49eb37dbcee5c7eb6a8415a22"
|
||||
],
|
||||
"markers": "python_version >= '3'",
|
||||
"version": "==0.17.4"
|
||||
},
|
||||
"ruamel.yaml.clib": {
|
||||
|
@ -1081,7 +1140,7 @@
|
|||
"sha256:e9f7d1d8c26a6a12c23421061f9022bb62704e38211fe375c645485f38df34a2",
|
||||
"sha256:f6061a31880c1ed6b6ce341215336e2f3d0c1deccd84957b6fa8ca474b41e89f"
|
||||
],
|
||||
"markers": "platform_python_implementation == 'CPython' and python_version < '3.10'",
|
||||
"markers": "python_version < '3.10' and platform_python_implementation == 'CPython'",
|
||||
"version": "==0.2.2"
|
||||
},
|
||||
"s3transfer": {
|
||||
|
@ -1112,6 +1171,7 @@
|
|||
"sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259",
|
||||
"sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==1.15.0"
|
||||
},
|
||||
"sqlparse": {
|
||||
|
@ -1119,6 +1179,7 @@
|
|||
"sha256:017cde379adbd6a1f15a61873f43e8274179378e95ef3fede90b5aa64d304ed0",
|
||||
"sha256:0f91fd2e829c44362cbcfab3e9ae12e22badaa8a29ad5ff599f9ec109f0454e8"
|
||||
],
|
||||
"markers": "python_version >= '3.5'",
|
||||
"version": "==0.4.1"
|
||||
},
|
||||
"structlog": {
|
||||
|
@ -1174,6 +1235,7 @@
|
|||
"sha256:7d6f89745680233f1c4db9ddb748df5e88d2a7a37962be174c0fd04c8dba1dc8",
|
||||
"sha256:c16b55f9a67b2419cfdf8846576e2ec9ba94fe6978a83080c352a80db31c93fb"
|
||||
],
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==21.2.1"
|
||||
},
|
||||
"typing-extensions": {
|
||||
|
@ -1189,6 +1251,7 @@
|
|||
"sha256:07620c3f3f8eed1f12600845892b0e036a2420acf513c53f7de0abd911a5894f",
|
||||
"sha256:5af8ad10cec94f215e3f48112de2022e1d5a37ed427fbd88652fa908f2ab7cae"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==3.0.1"
|
||||
},
|
||||
"urllib3": {
|
||||
|
@ -1233,6 +1296,7 @@
|
|||
"sha256:4c9dceab6f76ed92105027c49c823800dd33cacce13bdedc5b914e3514b7fb30",
|
||||
"sha256:7d3b1624a953da82ef63462013bbd271d3eb75751489f9807598e8f340bd637e"
|
||||
],
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==5.0.0"
|
||||
},
|
||||
"watchgod": {
|
||||
|
@ -1262,6 +1326,7 @@
|
|||
"sha256:44b5df8f08c74c3d82d28100fdc81f4536809ce98a17f0757557813275fbb663",
|
||||
"sha256:63509b41d158ae5b7f67eb4ad20fecbb4eee99434e73e140354dc3ff8e09716f"
|
||||
],
|
||||
"markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==0.58.0"
|
||||
},
|
||||
"websockets": {
|
||||
|
@ -1348,6 +1413,7 @@
|
|||
"sha256:f0b059678fd549c66b89bed03efcabb009075bd131c248ecdf087bdb6faba24a",
|
||||
"sha256:fcbb48a93e8699eae920f8d92f7160c03567b421bc17362a9ffbbd706a816f71"
|
||||
],
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==1.6.3"
|
||||
},
|
||||
"zope.interface": {
|
||||
|
@ -1404,6 +1470,7 @@
|
|||
"sha256:f44e517131a98f7a76696a7b21b164bcb85291cee106a23beccce454e1f433a4",
|
||||
"sha256:f7ee479e96f7ee350db1cf24afa5685a5899e2b34992fb99e1f7c1b0b758d263"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
|
||||
"version": "==5.4.0"
|
||||
}
|
||||
},
|
||||
|
@ -1420,6 +1487,7 @@
|
|||
"sha256:4db03ab5fc3340cf619dbc25e42c2cc3755154ce6009469766d7143d1fc2ee4e",
|
||||
"sha256:8a398dfce302c13f14bab13e2b14fe385d32b73f4e4853b9bdfb64598baa1975"
|
||||
],
|
||||
"markers": "python_version ~= '3.6'",
|
||||
"version": "==2.5.6"
|
||||
},
|
||||
"attrs": {
|
||||
|
@ -1427,6 +1495,7 @@
|
|||
"sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6",
|
||||
"sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==20.3.0"
|
||||
},
|
||||
"bandit": {
|
||||
|
@ -1452,11 +1521,27 @@
|
|||
"index": "pypi",
|
||||
"version": "==1.0.1"
|
||||
},
|
||||
"certifi": {
|
||||
"hashes": [
|
||||
"sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c",
|
||||
"sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830"
|
||||
],
|
||||
"version": "==2020.12.5"
|
||||
},
|
||||
"chardet": {
|
||||
"hashes": [
|
||||
"sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa",
|
||||
"sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
|
||||
"version": "==4.0.0"
|
||||
},
|
||||
"click": {
|
||||
"hashes": [
|
||||
"sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a",
|
||||
"sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
|
||||
"version": "==7.1.2"
|
||||
},
|
||||
"colorama": {
|
||||
|
@ -1530,14 +1615,23 @@
|
|||
"sha256:6c4cc71933456991da20917998acbe6cf4fb41eeaab7d6d67fbc05ecd4c865b0",
|
||||
"sha256:96bf5c08b157a666fec41129e6d327235284cca4c81e92109260f353ba138005"
|
||||
],
|
||||
"markers": "python_version >= '3.4'",
|
||||
"version": "==4.0.7"
|
||||
},
|
||||
"gitpython": {
|
||||
"hashes": [
|
||||
"sha256:05af150f47a5cca3f4b0af289b73aef8cf3c4fe2385015b06220cbcdee48bb6e",
|
||||
"sha256:a77824e516d3298b04fb36ec7845e92747df8fcfee9cacc32dd6239f9652f867"
|
||||
"sha256:3283ae2fba31c913d857e12e5ba5f9a7772bbc064ae2bb09efafa71b0dd4939b",
|
||||
"sha256:be27633e7509e58391f10207cd32b2a6cf5b908f92d9cd30da2e514e1137af61"
|
||||
],
|
||||
"version": "==3.1.15"
|
||||
"markers": "python_version >= '3.4'",
|
||||
"version": "==3.1.14"
|
||||
},
|
||||
"idna": {
|
||||
"hashes": [
|
||||
"sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6",
|
||||
"sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"
|
||||
],
|
||||
"version": "==2.10"
|
||||
},
|
||||
"iniconfig": {
|
||||
"hashes": [
|
||||
|
@ -1551,6 +1645,7 @@
|
|||
"sha256:0a943902919f65c5684ac4e0154b1ad4fac6dcaa5d9f3426b732f1c8b5419be6",
|
||||
"sha256:2bb1680aad211e3c9944dbce1d4ba09a989f04e238296c87fe2139faa26d655d"
|
||||
],
|
||||
"markers": "python_version >= '3.6' and python_version < '4.0'",
|
||||
"version": "==5.8.0"
|
||||
},
|
||||
"lazy-object-proxy": {
|
||||
|
@ -1578,6 +1673,7 @@
|
|||
"sha256:ed361bb83436f117f9917d282a456f9e5009ea12fd6de8742d1a4752c3017e93",
|
||||
"sha256:f5144c75445ae3ca2057faac03fda5a902eff196702b0a24daf1d6ce0650514b"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'",
|
||||
"version": "==1.6.0"
|
||||
},
|
||||
"mccabe": {
|
||||
|
@ -1614,6 +1710,7 @@
|
|||
"sha256:42df03e7797b796625b1029c0400279c7c34fd7df24a7d7818a1abb5b38710dd",
|
||||
"sha256:c68c661ac5cc81058ac94247278eeda6d2e6aecb3e227b0387c30d277e7ef8d4"
|
||||
],
|
||||
"markers": "python_version >= '2.6'",
|
||||
"version": "==5.6.0"
|
||||
},
|
||||
"pluggy": {
|
||||
|
@ -1621,6 +1718,7 @@
|
|||
"sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0",
|
||||
"sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==0.13.1"
|
||||
},
|
||||
"py": {
|
||||
|
@ -1628,6 +1726,7 @@
|
|||
"sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3",
|
||||
"sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==1.10.0"
|
||||
},
|
||||
"pylint": {
|
||||
|
@ -1658,6 +1757,7 @@
|
|||
"sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1",
|
||||
"sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"
|
||||
],
|
||||
"markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==2.4.7"
|
||||
},
|
||||
"pytest": {
|
||||
|
@ -1757,6 +1857,22 @@
|
|||
],
|
||||
"version": "==2021.4.4"
|
||||
},
|
||||
"requests": {
|
||||
"hashes": [
|
||||
"sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804",
|
||||
"sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
|
||||
"version": "==2.25.1"
|
||||
},
|
||||
"requests-mock": {
|
||||
"hashes": [
|
||||
"sha256:33296f228d8c5df11a7988b741325422480baddfdf5dd9318fd0eb40c3ed8595",
|
||||
"sha256:5c8ef0254c14a84744be146e9799dc13ebc4f6186058112d9aeed96b131b58e2"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.9.2"
|
||||
},
|
||||
"selenium": {
|
||||
"hashes": [
|
||||
"sha256:2d7131d7bc5a5b99a2d9b04aaf2612c411b03b8ca1b1ee8d3de5845a9be2cb3c",
|
||||
|
@ -1770,6 +1886,7 @@
|
|||
"sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259",
|
||||
"sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==1.15.0"
|
||||
},
|
||||
"smmap": {
|
||||
|
@ -1777,6 +1894,7 @@
|
|||
"sha256:7e65386bd122d45405ddf795637b7f7d2b532e7e401d46bbe3fb49b9986d5182",
|
||||
"sha256:a9a7479e4c572e2e775c404dcd3080c8dc49f39918c2cf74913d30c4c478e3c2"
|
||||
],
|
||||
"markers": "python_version >= '3.5'",
|
||||
"version": "==4.0.0"
|
||||
},
|
||||
"stevedore": {
|
||||
|
@ -1784,6 +1902,7 @@
|
|||
"sha256:3a5bbd0652bf552748871eaa73a4a8dc2899786bc497a2aa1fcb4dcdb0debeee",
|
||||
"sha256:50d7b78fbaf0d04cd62411188fa7eedcb03eb7f4c4b37005615ceebe582aa82a"
|
||||
],
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==3.3.0"
|
||||
},
|
||||
"toml": {
|
||||
|
@ -1791,6 +1910,7 @@
|
|||
"sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b",
|
||||
"sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"
|
||||
],
|
||||
"markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==0.10.2"
|
||||
},
|
||||
"typed-ast": {
|
||||
|
|
|
@ -63,6 +63,7 @@ from authentik.sources.oauth.api.source import OAuthSourceViewSet
|
|||
from authentik.sources.oauth.api.source_connection import (
|
||||
UserOAuthSourceConnectionViewSet,
|
||||
)
|
||||
from authentik.sources.plex.api import PlexSourceViewSet
|
||||
from authentik.sources.saml.api import SAMLSourceViewSet
|
||||
from authentik.stages.authenticator_static.api import (
|
||||
AuthenticatorStaticStageViewSet,
|
||||
|
@ -136,6 +137,7 @@ router.register("sources/oauth_user_connections", UserOAuthSourceConnectionViewS
|
|||
router.register("sources/ldap", LDAPSourceViewSet)
|
||||
router.register("sources/saml", SAMLSourceViewSet)
|
||||
router.register("sources/oauth", OAuthSourceViewSet)
|
||||
router.register("sources/plex", PlexSourceViewSet)
|
||||
|
||||
router.register("policies/all", PolicyViewSet)
|
||||
router.register("policies/bindings", PolicyBindingViewSet)
|
||||
|
|
|
@ -45,6 +45,7 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer):
|
|||
"verbose_name",
|
||||
"verbose_name_plural",
|
||||
"policy_engine_mode",
|
||||
"user_matching_mode",
|
||||
]
|
||||
|
||||
|
||||
|
|
|
@ -0,0 +1,40 @@
|
|||
# Generated by Django 3.2 on 2021-05-03 17:06
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_core", "0019_source_managed"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="source",
|
||||
name="user_matching_mode",
|
||||
field=models.TextField(
|
||||
choices=[
|
||||
("identifier", "Use the source-specific identifier"),
|
||||
(
|
||||
"email_link",
|
||||
"Link to a user with identical email address. Can have security implications when a source doesn't validate email addresses.",
|
||||
),
|
||||
(
|
||||
"email_deny",
|
||||
"Use the user's email address, but deny enrollment when the email address already exists.",
|
||||
),
|
||||
(
|
||||
"username_link",
|
||||
"Link to a user with identical username address. Can have security implications when a username is used with another source.",
|
||||
),
|
||||
(
|
||||
"username_deny",
|
||||
"Use the user's username, but deny enrollment when the username already exists.",
|
||||
),
|
||||
],
|
||||
default="identifier",
|
||||
help_text="How the source determines if an existing user should be authenticated or a new user enrolled.",
|
||||
),
|
||||
),
|
||||
]
|
|
@ -240,6 +240,30 @@ class Application(PolicyBindingModel):
|
|||
verbose_name_plural = _("Applications")
|
||||
|
||||
|
||||
class SourceUserMatchingModes(models.TextChoices):
|
||||
"""Different modes a source can handle new/returning users"""
|
||||
|
||||
IDENTIFIER = "identifier", _("Use the source-specific identifier")
|
||||
EMAIL_LINK = "email_link", _(
|
||||
(
|
||||
"Link to a user with identical email address. Can have security implications "
|
||||
"when a source doesn't validate email addresses."
|
||||
)
|
||||
)
|
||||
EMAIL_DENY = "email_deny", _(
|
||||
"Use the user's email address, but deny enrollment when the email address already exists."
|
||||
)
|
||||
USERNAME_LINK = "username_link", _(
|
||||
(
|
||||
"Link to a user with identical username address. Can have security implications "
|
||||
"when a username is used with another source."
|
||||
)
|
||||
)
|
||||
USERNAME_DENY = "username_deny", _(
|
||||
"Use the user's username, but deny enrollment when the username already exists."
|
||||
)
|
||||
|
||||
|
||||
class Source(ManagedModel, SerializerModel, PolicyBindingModel):
|
||||
"""Base Authentication source, i.e. an OAuth Provider, SAML Remote or LDAP Server"""
|
||||
|
||||
|
@ -272,6 +296,17 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
|
|||
related_name="source_enrollment",
|
||||
)
|
||||
|
||||
user_matching_mode = models.TextField(
|
||||
choices=SourceUserMatchingModes.choices,
|
||||
default=SourceUserMatchingModes.IDENTIFIER,
|
||||
help_text=_(
|
||||
(
|
||||
"How the source determines if an existing user should be authenticated or "
|
||||
"a new user enrolled."
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
objects = InheritanceManager()
|
||||
|
||||
@property
|
||||
|
@ -301,6 +336,8 @@ class UserSourceConnection(CreatedUpdatedModel):
|
|||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
source = models.ForeignKey(Source, on_delete=models.CASCADE)
|
||||
|
||||
objects = InheritanceManager()
|
||||
|
||||
class Meta:
|
||||
|
||||
unique_together = (("user", "source"),)
|
||||
|
|
|
@ -0,0 +1,261 @@
|
|||
"""Source decision helper"""
|
||||
from enum import Enum
|
||||
from typing import Any, Optional, Type
|
||||
|
||||
from django.contrib import messages
|
||||
from django.db.models.query_utils import Q
|
||||
from django.http import HttpRequest, HttpResponse, HttpResponseBadRequest
|
||||
from django.shortcuts import redirect
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.core.models import (
|
||||
Source,
|
||||
SourceUserMatchingModes,
|
||||
User,
|
||||
UserSourceConnection,
|
||||
)
|
||||
from authentik.core.sources.stage import (
|
||||
PLAN_CONTEXT_SOURCES_CONNECTION,
|
||||
PostUserEnrollmentStage,
|
||||
)
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.flows.models import Flow, Stage, in_memory_stage
|
||||
from authentik.flows.planner import (
|
||||
PLAN_CONTEXT_PENDING_USER,
|
||||
PLAN_CONTEXT_REDIRECT,
|
||||
PLAN_CONTEXT_SOURCE,
|
||||
PLAN_CONTEXT_SSO,
|
||||
FlowPlanner,
|
||||
)
|
||||
from authentik.flows.views import NEXT_ARG_NAME, SESSION_KEY_GET, SESSION_KEY_PLAN
|
||||
from authentik.lib.utils.urls import redirect_with_qs
|
||||
from authentik.policies.utils import delete_none_keys
|
||||
from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND
|
||||
from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
|
||||
|
||||
|
||||
class Action(Enum):
|
||||
"""Actions that can be decided based on the request
|
||||
and source settings"""
|
||||
|
||||
LINK = "link"
|
||||
AUTH = "auth"
|
||||
ENROLL = "enroll"
|
||||
DENY = "deny"
|
||||
|
||||
|
||||
class SourceFlowManager:
|
||||
"""Help sources decide what they should do after authorization. Based on source settings and
|
||||
previous connections, authenticate the user, enroll a new user, link to an existing user
|
||||
or deny the request."""
|
||||
|
||||
source: Source
|
||||
request: HttpRequest
|
||||
|
||||
identifier: str
|
||||
|
||||
connection_type: Type[UserSourceConnection] = UserSourceConnection
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
source: Source,
|
||||
request: HttpRequest,
|
||||
identifier: str,
|
||||
enroll_info: dict[str, Any],
|
||||
) -> None:
|
||||
self.source = source
|
||||
self.request = request
|
||||
self.identifier = identifier
|
||||
self.enroll_info = enroll_info
|
||||
self._logger = get_logger().bind(source=source, identifier=identifier)
|
||||
|
||||
# pylint: disable=too-many-return-statements
|
||||
def get_action(self, **kwargs) -> tuple[Action, Optional[UserSourceConnection]]:
|
||||
"""decide which action should be taken"""
|
||||
new_connection = self.connection_type(
|
||||
source=self.source, identifier=self.identifier
|
||||
)
|
||||
# When request is authenticated, always link
|
||||
if self.request.user.is_authenticated:
|
||||
new_connection.user = self.request.user
|
||||
new_connection = self.update_connection(new_connection, **kwargs)
|
||||
new_connection.save()
|
||||
return Action.LINK, new_connection
|
||||
|
||||
existing_connections = self.connection_type.objects.filter(
|
||||
source=self.source, identifier=self.identifier
|
||||
)
|
||||
if existing_connections.exists():
|
||||
connection = existing_connections.first()
|
||||
return Action.AUTH, self.update_connection(connection, **kwargs)
|
||||
# No connection exists, but we match on identifier, so enroll
|
||||
if self.source.user_matching_mode == SourceUserMatchingModes.IDENTIFIER:
|
||||
# We don't save the connection here cause it doesn't have a user assigned yet
|
||||
return Action.ENROLL, self.update_connection(new_connection, **kwargs)
|
||||
|
||||
# Check for existing users with matching attributes
|
||||
query = Q()
|
||||
# Either query existing user based on email or username
|
||||
if self.source.user_matching_mode in [
|
||||
SourceUserMatchingModes.EMAIL_LINK,
|
||||
SourceUserMatchingModes.EMAIL_DENY,
|
||||
]:
|
||||
if not self.enroll_info.get("email", None):
|
||||
self._logger.warning("Refusing to use none email", source=self.source)
|
||||
return Action.DENY, None
|
||||
query = Q(email__exact=self.enroll_info.get("email", None))
|
||||
if self.source.user_matching_mode in [
|
||||
SourceUserMatchingModes.USERNAME_LINK,
|
||||
SourceUserMatchingModes.USERNAME_DENY,
|
||||
]:
|
||||
if not self.enroll_info.get("username", None):
|
||||
self._logger.warning(
|
||||
"Refusing to use none username", source=self.source
|
||||
)
|
||||
return Action.DENY, None
|
||||
query = Q(username__exact=self.enroll_info.get("username", None))
|
||||
matching_users = User.objects.filter(query)
|
||||
# No matching users, always enroll
|
||||
if not matching_users.exists():
|
||||
return Action.ENROLL, self.update_connection(new_connection, **kwargs)
|
||||
|
||||
user = matching_users.first()
|
||||
if self.source.user_matching_mode in [
|
||||
SourceUserMatchingModes.EMAIL_LINK,
|
||||
SourceUserMatchingModes.USERNAME_LINK,
|
||||
]:
|
||||
new_connection.user = user
|
||||
new_connection = self.update_connection(new_connection, **kwargs)
|
||||
new_connection.save()
|
||||
return Action.LINK, new_connection
|
||||
if self.source.user_matching_mode in [
|
||||
SourceUserMatchingModes.EMAIL_DENY,
|
||||
SourceUserMatchingModes.USERNAME_DENY,
|
||||
]:
|
||||
return Action.DENY, None
|
||||
return Action.DENY, None
|
||||
|
||||
def update_connection(
|
||||
self, connection: UserSourceConnection, **kwargs
|
||||
) -> UserSourceConnection:
|
||||
"""Optionally make changes to the connection after it is looked up/created."""
|
||||
return connection
|
||||
|
||||
def get_flow(self, **kwargs) -> HttpResponse:
|
||||
"""Get the flow response based on user_matching_mode"""
|
||||
action, connection = self.get_action()
|
||||
if action == Action.LINK:
|
||||
self._logger.debug("Linking existing user")
|
||||
return self.handle_existing_user_link()
|
||||
if not connection:
|
||||
return redirect("/")
|
||||
if action == Action.AUTH:
|
||||
self._logger.debug("Handling auth user")
|
||||
return self.handle_auth_user(connection)
|
||||
if action == Action.ENROLL:
|
||||
self._logger.debug("Handling enrollment of new user")
|
||||
return self.handle_enroll(connection)
|
||||
return redirect("/")
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def get_stages_to_append(self, flow: Flow) -> list[Stage]:
|
||||
"""Hook to override stages which are appended to the flow"""
|
||||
if flow.slug == self.source.enrollment_flow.slug:
|
||||
return [
|
||||
in_memory_stage(PostUserEnrollmentStage),
|
||||
]
|
||||
return []
|
||||
|
||||
def _handle_login_flow(self, flow: Flow, **kwargs) -> HttpResponse:
|
||||
"""Prepare Authentication Plan, redirect user FlowExecutor"""
|
||||
# Ensure redirect is carried through when user was trying to
|
||||
# authorize application
|
||||
final_redirect = self.request.session.get(SESSION_KEY_GET, {}).get(
|
||||
NEXT_ARG_NAME, "authentik_core:if-admin"
|
||||
)
|
||||
kwargs.update(
|
||||
{
|
||||
# Since we authenticate the user by their token, they have no backend set
|
||||
PLAN_CONTEXT_AUTHENTICATION_BACKEND: "django.contrib.auth.backends.ModelBackend",
|
||||
PLAN_CONTEXT_SSO: True,
|
||||
PLAN_CONTEXT_SOURCE: self.source,
|
||||
PLAN_CONTEXT_REDIRECT: final_redirect,
|
||||
}
|
||||
)
|
||||
if not flow:
|
||||
return HttpResponseBadRequest()
|
||||
# We run the Flow planner here so we can pass the Pending user in the context
|
||||
planner = FlowPlanner(flow)
|
||||
plan = planner.plan(self.request, kwargs)
|
||||
for stage in self.get_stages_to_append(flow):
|
||||
plan.append(stage)
|
||||
self.request.session[SESSION_KEY_PLAN] = plan
|
||||
return redirect_with_qs(
|
||||
"authentik_core:if-flow",
|
||||
self.request.GET,
|
||||
flow_slug=flow.slug,
|
||||
)
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def handle_auth_user(
|
||||
self,
|
||||
connection: UserSourceConnection,
|
||||
) -> HttpResponse:
|
||||
"""Login user and redirect."""
|
||||
messages.success(
|
||||
self.request,
|
||||
_(
|
||||
"Successfully authenticated with %(source)s!"
|
||||
% {"source": self.source.name}
|
||||
),
|
||||
)
|
||||
flow_kwargs = {PLAN_CONTEXT_PENDING_USER: connection.user}
|
||||
return self._handle_login_flow(self.source.authentication_flow, **flow_kwargs)
|
||||
|
||||
def handle_existing_user_link(
|
||||
self,
|
||||
) -> HttpResponse:
|
||||
"""Handler when the user was already authenticated and linked an external source
|
||||
to their account."""
|
||||
Event.new(
|
||||
EventAction.SOURCE_LINKED,
|
||||
message="Linked Source",
|
||||
source=self.source,
|
||||
).from_http(self.request)
|
||||
messages.success(
|
||||
self.request,
|
||||
_("Successfully linked %(source)s!" % {"source": self.source.name}),
|
||||
)
|
||||
return redirect(
|
||||
reverse(
|
||||
"authentik_core:if-admin",
|
||||
)
|
||||
+ f"#/user;page-{self.source.slug}"
|
||||
)
|
||||
|
||||
def handle_enroll(
|
||||
self,
|
||||
connection: UserSourceConnection,
|
||||
) -> HttpResponse:
|
||||
"""User was not authenticated and previous request was not authenticated."""
|
||||
messages.success(
|
||||
self.request,
|
||||
_(
|
||||
"Successfully authenticated with %(source)s!"
|
||||
% {"source": self.source.name}
|
||||
),
|
||||
)
|
||||
|
||||
# We run the Flow planner here so we can pass the Pending user in the context
|
||||
if not self.source.enrollment_flow:
|
||||
self._logger.warning("source has no enrollment flow")
|
||||
return HttpResponseBadRequest()
|
||||
return self._handle_login_flow(
|
||||
self.source.enrollment_flow,
|
||||
**{
|
||||
PLAN_CONTEXT_PROMPT: delete_none_keys(self.enroll_info),
|
||||
PLAN_CONTEXT_SOURCES_CONNECTION: connection,
|
||||
},
|
||||
)
|
|
@ -1,32 +1,30 @@
|
|||
"""OAuth Stages"""
|
||||
"""Source flow manager stages"""
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
|
||||
from authentik.core.models import User
|
||||
from authentik.core.models import User, UserSourceConnection
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
|
||||
from authentik.flows.stage import StageView
|
||||
from authentik.sources.oauth.models import UserOAuthSourceConnection
|
||||
|
||||
PLAN_CONTEXT_SOURCES_OAUTH_ACCESS = "sources_oauth_access"
|
||||
PLAN_CONTEXT_SOURCES_CONNECTION = "goauthentik.io/sources/connection"
|
||||
|
||||
|
||||
class PostUserEnrollmentStage(StageView):
|
||||
"""Dynamically injected stage which saves the OAuth Connection after
|
||||
"""Dynamically injected stage which saves the Connection after
|
||||
the user has been enrolled."""
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||
"""Stage used after the user has been enrolled"""
|
||||
access: UserOAuthSourceConnection = self.executor.plan.context[
|
||||
PLAN_CONTEXT_SOURCES_OAUTH_ACCESS
|
||||
connection: UserSourceConnection = self.executor.plan.context[
|
||||
PLAN_CONTEXT_SOURCES_CONNECTION
|
||||
]
|
||||
user: User = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER]
|
||||
access.user = user
|
||||
access.save()
|
||||
UserOAuthSourceConnection.objects.filter(pk=access.pk).update(user=user)
|
||||
connection.user = user
|
||||
connection.save()
|
||||
Event.new(
|
||||
EventAction.SOURCE_LINKED,
|
||||
message="Linked OAuth Source",
|
||||
source=access.source,
|
||||
message="Linked Source",
|
||||
source=connection.source,
|
||||
).from_http(self.request)
|
||||
return self.executor.stage_ok()
|
|
@ -1,11 +1,14 @@
|
|||
"""authentik core models tests"""
|
||||
from time import sleep
|
||||
from typing import Callable, Type
|
||||
|
||||
from django.test import TestCase
|
||||
from django.utils.timezone import now
|
||||
from guardian.shortcuts import get_anonymous_user
|
||||
|
||||
from authentik.core.models import Token
|
||||
from authentik.core.models import Provider, Source, Token
|
||||
from authentik.flows.models import Stage
|
||||
from authentik.lib.utils.reflection import all_subclasses
|
||||
|
||||
|
||||
class TestModels(TestCase):
|
||||
|
@ -24,3 +27,40 @@ class TestModels(TestCase):
|
|||
)
|
||||
sleep(0.5)
|
||||
self.assertFalse(token.is_expired)
|
||||
|
||||
|
||||
def source_tester_factory(test_model: Type[Stage]) -> Callable:
|
||||
"""Test source"""
|
||||
|
||||
def tester(self: TestModels):
|
||||
model_class = None
|
||||
if test_model._meta.abstract:
|
||||
model_class = test_model.__bases__[0]()
|
||||
else:
|
||||
model_class = test_model()
|
||||
model_class.slug = "test"
|
||||
self.assertIsNotNone(model_class.component)
|
||||
_ = model_class.ui_login_button
|
||||
_ = model_class.ui_user_settings
|
||||
|
||||
return tester
|
||||
|
||||
|
||||
def provider_tester_factory(test_model: Type[Stage]) -> Callable:
|
||||
"""Test provider"""
|
||||
|
||||
def tester(self: TestModels):
|
||||
model_class = None
|
||||
if test_model._meta.abstract:
|
||||
model_class = test_model.__bases__[0]()
|
||||
else:
|
||||
model_class = test_model()
|
||||
self.assertIsNotNone(model_class.component)
|
||||
|
||||
return tester
|
||||
|
||||
|
||||
for model in all_subclasses(Source):
|
||||
setattr(TestModels, f"test_model_{model.__name__}", source_tester_factory(model))
|
||||
for model in all_subclasses(Provider):
|
||||
setattr(TestModels, f"test_model_{model.__name__}", provider_tester_factory(model))
|
||||
|
|
|
@ -2,9 +2,10 @@
|
|||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from rest_framework.fields import CharField
|
||||
from rest_framework.fields import CharField, DictField
|
||||
|
||||
from authentik.core.api.utils import PassiveSerializer
|
||||
from authentik.flows.challenge import Challenge
|
||||
|
||||
|
||||
@dataclass
|
||||
|
@ -14,8 +15,8 @@ class UILoginButton:
|
|||
# Name, ran through i18n
|
||||
name: str
|
||||
|
||||
# URL Which Button points to
|
||||
url: str
|
||||
# Challenge which is presented to the user when they click the button
|
||||
challenge: Challenge
|
||||
|
||||
# Icon URL, used as-is
|
||||
icon_url: Optional[str] = None
|
||||
|
@ -25,7 +26,7 @@ class UILoginButtonSerializer(PassiveSerializer):
|
|||
"""Serializer for Login buttons of sources"""
|
||||
|
||||
name = CharField()
|
||||
url = CharField()
|
||||
challenge = DictField()
|
||||
icon_url = CharField(required=False, allow_null=True)
|
||||
|
||||
|
||||
|
|
|
@ -16,17 +16,14 @@ def model_tester_factory(test_model: Type[Stage]) -> Callable:
|
|||
"""Test a form"""
|
||||
|
||||
def tester(self: TestModels):
|
||||
try:
|
||||
model_class = None
|
||||
if test_model._meta.abstract:
|
||||
model_class = test_model.__bases__[0]()
|
||||
else:
|
||||
model_class = test_model()
|
||||
self.assertTrue(issubclass(model_class.type, StageView))
|
||||
self.assertIsNotNone(test_model.component)
|
||||
_ = test_model.ui_user_settings
|
||||
except NotImplementedError:
|
||||
pass
|
||||
model_class = None
|
||||
if test_model._meta.abstract:
|
||||
model_class = test_model.__bases__[0]()
|
||||
else:
|
||||
model_class = test_model()
|
||||
self.assertTrue(issubclass(model_class.type, StageView))
|
||||
self.assertIsNotNone(test_model.component)
|
||||
_ = test_model.ui_user_settings
|
||||
|
||||
return tester
|
||||
|
||||
|
|
|
@ -0,0 +1,84 @@
|
|||
# Generated by Django 3.2 on 2021-05-02 17:06
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_policies_event_matcher", "0012_auto_20210323_1339"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="eventmatcherpolicy",
|
||||
name="app",
|
||||
field=models.TextField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("authentik.admin", "authentik Admin"),
|
||||
("authentik.api", "authentik API"),
|
||||
("authentik.events", "authentik Events"),
|
||||
("authentik.crypto", "authentik Crypto"),
|
||||
("authentik.flows", "authentik Flows"),
|
||||
("authentik.outposts", "authentik Outpost"),
|
||||
("authentik.lib", "authentik lib"),
|
||||
("authentik.policies", "authentik Policies"),
|
||||
("authentik.policies.dummy", "authentik Policies.Dummy"),
|
||||
(
|
||||
"authentik.policies.event_matcher",
|
||||
"authentik Policies.Event Matcher",
|
||||
),
|
||||
("authentik.policies.expiry", "authentik Policies.Expiry"),
|
||||
("authentik.policies.expression", "authentik Policies.Expression"),
|
||||
("authentik.policies.hibp", "authentik Policies.HaveIBeenPwned"),
|
||||
("authentik.policies.password", "authentik Policies.Password"),
|
||||
("authentik.policies.reputation", "authentik Policies.Reputation"),
|
||||
("authentik.providers.proxy", "authentik Providers.Proxy"),
|
||||
("authentik.providers.oauth2", "authentik Providers.OAuth2"),
|
||||
("authentik.providers.saml", "authentik Providers.SAML"),
|
||||
("authentik.recovery", "authentik Recovery"),
|
||||
("authentik.sources.ldap", "authentik Sources.LDAP"),
|
||||
("authentik.sources.oauth", "authentik Sources.OAuth"),
|
||||
("authentik.sources.plex", "authentik Sources.Plex"),
|
||||
("authentik.sources.saml", "authentik Sources.SAML"),
|
||||
(
|
||||
"authentik.stages.authenticator_static",
|
||||
"authentik Stages.Authenticator.Static",
|
||||
),
|
||||
(
|
||||
"authentik.stages.authenticator_totp",
|
||||
"authentik Stages.Authenticator.TOTP",
|
||||
),
|
||||
(
|
||||
"authentik.stages.authenticator_validate",
|
||||
"authentik Stages.Authenticator.Validate",
|
||||
),
|
||||
(
|
||||
"authentik.stages.authenticator_webauthn",
|
||||
"authentik Stages.Authenticator.WebAuthn",
|
||||
),
|
||||
("authentik.stages.captcha", "authentik Stages.Captcha"),
|
||||
("authentik.stages.consent", "authentik Stages.Consent"),
|
||||
("authentik.stages.deny", "authentik Stages.Deny"),
|
||||
("authentik.stages.dummy", "authentik Stages.Dummy"),
|
||||
("authentik.stages.email", "authentik Stages.Email"),
|
||||
(
|
||||
"authentik.stages.identification",
|
||||
"authentik Stages.Identification",
|
||||
),
|
||||
("authentik.stages.invitation", "authentik Stages.User Invitation"),
|
||||
("authentik.stages.password", "authentik Stages.Password"),
|
||||
("authentik.stages.prompt", "authentik Stages.Prompt"),
|
||||
("authentik.stages.user_delete", "authentik Stages.User Delete"),
|
||||
("authentik.stages.user_login", "authentik Stages.User Login"),
|
||||
("authentik.stages.user_logout", "authentik Stages.User Logout"),
|
||||
("authentik.stages.user_write", "authentik Stages.User Write"),
|
||||
("authentik.core", "authentik Core"),
|
||||
("authentik.managed", "authentik Managed"),
|
||||
],
|
||||
default="",
|
||||
help_text="Match events created by selected application. When left empty, all applications are matched.",
|
||||
),
|
||||
),
|
||||
]
|
|
@ -107,6 +107,7 @@ INSTALLED_APPS = [
|
|||
"authentik.recovery",
|
||||
"authentik.sources.ldap",
|
||||
"authentik.sources.oauth",
|
||||
"authentik.sources.plex",
|
||||
"authentik.sources.saml",
|
||||
"authentik.stages.authenticator_static",
|
||||
"authentik.stages.authenticator_totp",
|
||||
|
|
|
@ -2,11 +2,21 @@
|
|||
from importlib import import_module
|
||||
|
||||
from django.apps import AppConfig
|
||||
from django.conf import settings
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
AUTHENTIK_SOURCES_OAUTH_TYPES = [
|
||||
"authentik.sources.oauth.types.discord",
|
||||
"authentik.sources.oauth.types.facebook",
|
||||
"authentik.sources.oauth.types.github",
|
||||
"authentik.sources.oauth.types.google",
|
||||
"authentik.sources.oauth.types.reddit",
|
||||
"authentik.sources.oauth.types.twitter",
|
||||
"authentik.sources.oauth.types.azure_ad",
|
||||
"authentik.sources.oauth.types.oidc",
|
||||
]
|
||||
|
||||
|
||||
class AuthentikSourceOAuthConfig(AppConfig):
|
||||
"""authentik source.oauth config"""
|
||||
|
@ -18,7 +28,7 @@ class AuthentikSourceOAuthConfig(AppConfig):
|
|||
|
||||
def ready(self):
|
||||
"""Load source_types from config file"""
|
||||
for source_type in settings.AUTHENTIK_SOURCES_OAUTH_TYPES:
|
||||
for source_type in AUTHENTIK_SOURCES_OAUTH_TYPES:
|
||||
try:
|
||||
import_module(source_type)
|
||||
LOGGER.debug("Loaded OAuth Source Type", type=source_type)
|
||||
|
|
|
@ -1,23 +0,0 @@
|
|||
"""authentik oauth_client Authorization backend"""
|
||||
from typing import Optional
|
||||
|
||||
from django.contrib.auth.backends import ModelBackend
|
||||
from django.http import HttpRequest
|
||||
|
||||
from authentik.core.models import User
|
||||
from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection
|
||||
|
||||
|
||||
class AuthorizedServiceBackend(ModelBackend):
|
||||
"Authentication backend for users registered with remote OAuth provider."
|
||||
|
||||
def authenticate(
|
||||
self, request: HttpRequest, source: OAuthSource, identifier: str
|
||||
) -> Optional[User]:
|
||||
"Fetch user for a given source by id."
|
||||
access = UserOAuthSourceConnection.objects.filter(
|
||||
source=source, identifier=identifier
|
||||
).select_related("user")
|
||||
if not access.exists():
|
||||
return None
|
||||
return access.first().user
|
|
@ -9,6 +9,7 @@ from rest_framework.serializers import Serializer
|
|||
|
||||
from authentik.core.models import Source, UserSourceConnection
|
||||
from authentik.core.types import UILoginButton, UserSettingSerializer
|
||||
from authentik.flows.challenge import ChallengeTypes, RedirectChallenge
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from authentik.sources.oauth.types.manager import SourceType
|
||||
|
@ -67,9 +68,14 @@ class OAuthSource(Source):
|
|||
@property
|
||||
def ui_login_button(self) -> UILoginButton:
|
||||
return UILoginButton(
|
||||
url=reverse(
|
||||
"authentik_sources_oauth:oauth-client-login",
|
||||
kwargs={"source_slug": self.slug},
|
||||
challenge=RedirectChallenge(
|
||||
instance={
|
||||
"type": ChallengeTypes.REDIRECT.value,
|
||||
"to": reverse(
|
||||
"authentik_sources_oauth:oauth-client-login",
|
||||
kwargs={"source_slug": self.slug},
|
||||
),
|
||||
}
|
||||
),
|
||||
icon_url=static(f"authentik/sources/{self.provider_type}.svg"),
|
||||
name=self.name,
|
||||
|
@ -163,16 +169,6 @@ class OpenIDOAuthSource(OAuthSource):
|
|||
verbose_name_plural = _("OpenID OAuth Sources")
|
||||
|
||||
|
||||
class PlexOAuthSource(OAuthSource):
|
||||
"""Login using plex.tv."""
|
||||
|
||||
class Meta:
|
||||
|
||||
abstract = True
|
||||
verbose_name = _("Plex OAuth Source")
|
||||
verbose_name_plural = _("Plex OAuth Sources")
|
||||
|
||||
|
||||
class UserOAuthSourceConnection(UserSourceConnection):
|
||||
"""Authorized remote OAuth provider."""
|
||||
|
||||
|
|
|
@ -1,13 +0,0 @@
|
|||
"""Oauth2 Client Settings"""
|
||||
|
||||
AUTHENTIK_SOURCES_OAUTH_TYPES = [
|
||||
"authentik.sources.oauth.types.discord",
|
||||
"authentik.sources.oauth.types.facebook",
|
||||
"authentik.sources.oauth.types.github",
|
||||
"authentik.sources.oauth.types.google",
|
||||
"authentik.sources.oauth.types.reddit",
|
||||
"authentik.sources.oauth.types.twitter",
|
||||
"authentik.sources.oauth.types.azure_ad",
|
||||
"authentik.sources.oauth.types.oidc",
|
||||
"authentik.sources.oauth.types.plex",
|
||||
]
|
|
@ -1,7 +1,7 @@
|
|||
"""Discord Type tests"""
|
||||
from django.test import TestCase
|
||||
|
||||
from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection
|
||||
from authentik.sources.oauth.models import OAuthSource
|
||||
from authentik.sources.oauth.types.discord import DiscordOAuth2Callback
|
||||
|
||||
# https://discord.com/developers/docs/resources/user#user-object
|
||||
|
@ -33,9 +33,7 @@ class TestTypeDiscord(TestCase):
|
|||
|
||||
def test_enroll_context(self):
|
||||
"""Test discord Enrollment context"""
|
||||
ak_context = DiscordOAuth2Callback().get_user_enroll_context(
|
||||
self.source, UserOAuthSourceConnection(), DISCORD_USER
|
||||
)
|
||||
ak_context = DiscordOAuth2Callback().get_user_enroll_context(DISCORD_USER)
|
||||
self.assertEqual(ak_context["username"], DISCORD_USER["username"])
|
||||
self.assertEqual(ak_context["email"], DISCORD_USER["email"])
|
||||
self.assertEqual(ak_context["name"], DISCORD_USER["username"])
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
"""GitHub Type tests"""
|
||||
from django.test import TestCase
|
||||
|
||||
from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection
|
||||
from authentik.sources.oauth.models import OAuthSource
|
||||
from authentik.sources.oauth.types.github import GitHubOAuth2Callback
|
||||
|
||||
# https://developer.github.com/v3/users/#get-the-authenticated-user
|
||||
|
@ -63,9 +63,7 @@ class TestTypeGitHub(TestCase):
|
|||
|
||||
def test_enroll_context(self):
|
||||
"""Test GitHub Enrollment context"""
|
||||
ak_context = GitHubOAuth2Callback().get_user_enroll_context(
|
||||
self.source, UserOAuthSourceConnection(), GITHUB_USER
|
||||
)
|
||||
ak_context = GitHubOAuth2Callback().get_user_enroll_context(GITHUB_USER)
|
||||
self.assertEqual(ak_context["username"], GITHUB_USER["login"])
|
||||
self.assertEqual(ak_context["email"], GITHUB_USER["email"])
|
||||
self.assertEqual(ak_context["name"], GITHUB_USER["name"])
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
"""google Type tests"""
|
||||
from django.test import TestCase
|
||||
|
||||
from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection
|
||||
from authentik.sources.oauth.models import OAuthSource
|
||||
from authentik.sources.oauth.types.google import GoogleOAuth2Callback
|
||||
|
||||
# https://developers.google.com/identity/protocols/oauth2/openid-connect?hl=en
|
||||
|
@ -32,9 +32,7 @@ class TestTypeGoogle(TestCase):
|
|||
|
||||
def test_enroll_context(self):
|
||||
"""Test Google Enrollment context"""
|
||||
ak_context = GoogleOAuth2Callback().get_user_enroll_context(
|
||||
self.source, UserOAuthSourceConnection(), GOOGLE_USER
|
||||
)
|
||||
ak_context = GoogleOAuth2Callback().get_user_enroll_context(GOOGLE_USER)
|
||||
self.assertEqual(ak_context["username"], GOOGLE_USER["email"])
|
||||
self.assertEqual(ak_context["email"], GOOGLE_USER["email"])
|
||||
self.assertEqual(ak_context["name"], GOOGLE_USER["name"])
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
"""Twitter Type tests"""
|
||||
from django.test import Client, TestCase
|
||||
|
||||
from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection
|
||||
from authentik.sources.oauth.models import OAuthSource
|
||||
from authentik.sources.oauth.types.twitter import TwitterOAuthCallback
|
||||
|
||||
# https://developer.twitter.com/en/docs/twitter-api/v1/accounts-and-users/manage-account-settings/ \
|
||||
|
@ -104,9 +104,7 @@ class TestTypeGitHub(TestCase):
|
|||
|
||||
def test_enroll_context(self):
|
||||
"""Test Twitter Enrollment context"""
|
||||
ak_context = TwitterOAuthCallback().get_user_enroll_context(
|
||||
self.source, UserOAuthSourceConnection(), TWITTER_USER
|
||||
)
|
||||
ak_context = TwitterOAuthCallback().get_user_enroll_context(TWITTER_USER)
|
||||
self.assertEqual(ak_context["username"], TWITTER_USER["screen_name"])
|
||||
self.assertEqual(ak_context["email"], TWITTER_USER.get("email", None))
|
||||
self.assertEqual(ak_context["name"], TWITTER_USER["name"])
|
||||
|
|
|
@ -2,7 +2,6 @@
|
|||
from typing import Any, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection
|
||||
from authentik.sources.oauth.types.manager import MANAGER, SourceType
|
||||
from authentik.sources.oauth.views.callback import OAuthCallback
|
||||
|
||||
|
@ -10,7 +9,7 @@ from authentik.sources.oauth.views.callback import OAuthCallback
|
|||
class AzureADOAuthCallback(OAuthCallback):
|
||||
"""AzureAD OAuth2 Callback"""
|
||||
|
||||
def get_user_id(self, source: OAuthSource, info: dict[str, Any]) -> Optional[str]:
|
||||
def get_user_id(self, info: dict[str, Any]) -> Optional[str]:
|
||||
try:
|
||||
return str(UUID(info.get("objectId")).int)
|
||||
except TypeError:
|
||||
|
@ -18,8 +17,6 @@ class AzureADOAuthCallback(OAuthCallback):
|
|||
|
||||
def get_user_enroll_context(
|
||||
self,
|
||||
source: OAuthSource,
|
||||
access: UserOAuthSourceConnection,
|
||||
info: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
mail = info.get("mail", None) or info.get("otherMails", [None])[0]
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
"""Discord OAuth Views"""
|
||||
from typing import Any
|
||||
|
||||
from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection
|
||||
from authentik.sources.oauth.types.manager import MANAGER, SourceType
|
||||
from authentik.sources.oauth.views.callback import OAuthCallback
|
||||
from authentik.sources.oauth.views.redirect import OAuthRedirect
|
||||
|
@ -21,8 +20,6 @@ class DiscordOAuth2Callback(OAuthCallback):
|
|||
|
||||
def get_user_enroll_context(
|
||||
self,
|
||||
source: OAuthSource,
|
||||
access: UserOAuthSourceConnection,
|
||||
info: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
|
|
|
@ -4,7 +4,6 @@ from typing import Any, Optional
|
|||
from facebook import GraphAPI
|
||||
|
||||
from authentik.sources.oauth.clients.oauth2 import OAuth2Client
|
||||
from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection
|
||||
from authentik.sources.oauth.types.manager import MANAGER, SourceType
|
||||
from authentik.sources.oauth.views.callback import OAuthCallback
|
||||
from authentik.sources.oauth.views.redirect import OAuthRedirect
|
||||
|
@ -34,8 +33,6 @@ class FacebookOAuth2Callback(OAuthCallback):
|
|||
|
||||
def get_user_enroll_context(
|
||||
self,
|
||||
source: OAuthSource,
|
||||
access: UserOAuthSourceConnection,
|
||||
info: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
"""GitHub OAuth Views"""
|
||||
from typing import Any
|
||||
|
||||
from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection
|
||||
from authentik.sources.oauth.types.manager import MANAGER, SourceType
|
||||
from authentik.sources.oauth.views.callback import OAuthCallback
|
||||
|
||||
|
@ -11,8 +10,6 @@ class GitHubOAuth2Callback(OAuthCallback):
|
|||
|
||||
def get_user_enroll_context(
|
||||
self,
|
||||
source: OAuthSource,
|
||||
access: UserOAuthSourceConnection,
|
||||
info: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
"""Google OAuth Views"""
|
||||
from typing import Any
|
||||
|
||||
from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection
|
||||
from authentik.sources.oauth.types.manager import MANAGER, SourceType
|
||||
from authentik.sources.oauth.views.callback import OAuthCallback
|
||||
from authentik.sources.oauth.views.redirect import OAuthRedirect
|
||||
|
@ -21,8 +20,6 @@ class GoogleOAuth2Callback(OAuthCallback):
|
|||
|
||||
def get_user_enroll_context(
|
||||
self,
|
||||
source: OAuthSource,
|
||||
access: UserOAuthSourceConnection,
|
||||
info: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
"""OpenID Connect OAuth Views"""
|
||||
from typing import Any
|
||||
|
||||
from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection
|
||||
from authentik.sources.oauth.models import OAuthSource
|
||||
from authentik.sources.oauth.types.manager import MANAGER, SourceType
|
||||
from authentik.sources.oauth.views.callback import OAuthCallback
|
||||
from authentik.sources.oauth.views.redirect import OAuthRedirect
|
||||
|
@ -19,13 +19,11 @@ class OpenIDConnectOAuthRedirect(OAuthRedirect):
|
|||
class OpenIDConnectOAuth2Callback(OAuthCallback):
|
||||
"""OpenIDConnect OAuth2 Callback"""
|
||||
|
||||
def get_user_id(self, source: OAuthSource, info: dict[str, str]) -> str:
|
||||
def get_user_id(self, info: dict[str, str]) -> str:
|
||||
return info.get("sub", "")
|
||||
|
||||
def get_user_enroll_context(
|
||||
self,
|
||||
source: OAuthSource,
|
||||
access: UserOAuthSourceConnection,
|
||||
info: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
|
|
|
@ -1,134 +0,0 @@
|
|||
"""Plex OAuth Views"""
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from django.http.response import Http404
|
||||
from requests import post
|
||||
from requests.api import get
|
||||
from requests.exceptions import RequestException
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik import __version__
|
||||
from authentik.sources.oauth.clients.oauth2 import OAuth2Client
|
||||
from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection
|
||||
from authentik.sources.oauth.types.manager import MANAGER, SourceType
|
||||
from authentik.sources.oauth.views.callback import OAuthCallback
|
||||
from authentik.sources.oauth.views.redirect import OAuthRedirect
|
||||
|
||||
LOGGER = get_logger()
|
||||
SESSION_ID_KEY = "PLEX_ID"
|
||||
SESSION_CODE_KEY = "PLEX_CODE"
|
||||
DEFAULT_PAYLOAD = {
|
||||
"X-Plex-Product": "authentik",
|
||||
"X-Plex-Version": __version__,
|
||||
"X-Plex-Device-Vendor": "BeryJu.org",
|
||||
}
|
||||
|
||||
|
||||
class PlexRedirect(OAuthRedirect):
|
||||
"""Plex Auth redirect, get a pin then redirect to a URL to claim it"""
|
||||
|
||||
headers = {}
|
||||
|
||||
def get_pin(self, **data) -> dict:
|
||||
"""Get plex pin that the user will claim
|
||||
https://forums.plex.tv/t/authenticating-with-plex/609370"""
|
||||
return post(
|
||||
"https://plex.tv/api/v2/pins.json?strong=true",
|
||||
data=data,
|
||||
headers=self.headers,
|
||||
).json()
|
||||
|
||||
def get_redirect_url(self, **kwargs) -> str:
|
||||
slug = kwargs.get("source_slug", "")
|
||||
self.headers = {"Origin": self.request.build_absolute_uri("/")}
|
||||
try:
|
||||
source: OAuthSource = OAuthSource.objects.get(slug=slug)
|
||||
except OAuthSource.DoesNotExist:
|
||||
raise Http404(f"Unknown OAuth source '{slug}'.")
|
||||
else:
|
||||
payload = DEFAULT_PAYLOAD.copy()
|
||||
payload["X-Plex-Client-Identifier"] = source.consumer_key
|
||||
# Get a pin first
|
||||
pin = self.get_pin(**payload)
|
||||
LOGGER.debug("Got pin", **pin)
|
||||
self.request.session[SESSION_ID_KEY] = pin["id"]
|
||||
self.request.session[SESSION_CODE_KEY] = pin["code"]
|
||||
qs = {
|
||||
"clientID": source.consumer_key,
|
||||
"code": pin["code"],
|
||||
"forwardUrl": self.request.build_absolute_uri(
|
||||
self.get_callback_url(source)
|
||||
),
|
||||
}
|
||||
return f"https://app.plex.tv/auth#!?{urlencode(qs)}"
|
||||
|
||||
|
||||
class PlexOAuthClient(OAuth2Client):
|
||||
"""Retrive the plex token after authentication, then ask the plex API about user info"""
|
||||
|
||||
def check_application_state(self) -> bool:
|
||||
return SESSION_ID_KEY in self.request.session
|
||||
|
||||
def get_access_token(self, **request_kwargs) -> Optional[dict[str, Any]]:
|
||||
payload = dict(DEFAULT_PAYLOAD)
|
||||
payload["X-Plex-Client-Identifier"] = self.source.consumer_key
|
||||
payload["Accept"] = "application/json"
|
||||
response = get(
|
||||
f"https://plex.tv/api/v2/pins/{self.request.session[SESSION_ID_KEY]}",
|
||||
headers=payload,
|
||||
)
|
||||
response.raise_for_status()
|
||||
token = response.json()["authToken"]
|
||||
return {"plex_token": token}
|
||||
|
||||
def get_profile_info(self, token: dict[str, str]) -> Optional[dict[str, Any]]:
|
||||
"Fetch user profile information."
|
||||
qs = {"X-Plex-Token": token["plex_token"]}
|
||||
try:
|
||||
response = self.do_request(
|
||||
"get", f"https://plex.tv/users/account.json?{urlencode(qs)}"
|
||||
)
|
||||
response.raise_for_status()
|
||||
except RequestException as exc:
|
||||
LOGGER.warning("Unable to fetch user profile", exc=exc)
|
||||
return None
|
||||
else:
|
||||
return response.json().get("user", {})
|
||||
|
||||
|
||||
class PlexOAuth2Callback(OAuthCallback):
|
||||
"""Plex OAuth2 Callback"""
|
||||
|
||||
client_class = PlexOAuthClient
|
||||
|
||||
def get_user_id(
|
||||
self, source: UserOAuthSourceConnection, info: dict[str, Any]
|
||||
) -> Optional[str]:
|
||||
return info.get("uuid")
|
||||
|
||||
def get_user_enroll_context(
|
||||
self,
|
||||
source: OAuthSource,
|
||||
access: UserOAuthSourceConnection,
|
||||
info: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"username": info.get("username"),
|
||||
"email": info.get("email"),
|
||||
"name": info.get("title"),
|
||||
}
|
||||
|
||||
|
||||
@MANAGER.type()
|
||||
class PlexType(SourceType):
|
||||
"""Plex Type definition"""
|
||||
|
||||
redirect_view = PlexRedirect
|
||||
callback_view = PlexOAuth2Callback
|
||||
name = "Plex"
|
||||
slug = "plex"
|
||||
|
||||
authorization_url = ""
|
||||
access_token_url = "" # nosec
|
||||
profile_url = ""
|
|
@ -4,7 +4,6 @@ from typing import Any
|
|||
from requests.auth import HTTPBasicAuth
|
||||
|
||||
from authentik.sources.oauth.clients.oauth2 import OAuth2Client
|
||||
from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection
|
||||
from authentik.sources.oauth.types.manager import MANAGER, SourceType
|
||||
from authentik.sources.oauth.views.callback import OAuthCallback
|
||||
from authentik.sources.oauth.views.redirect import OAuthRedirect
|
||||
|
@ -36,8 +35,6 @@ class RedditOAuth2Callback(OAuthCallback):
|
|||
|
||||
def get_user_enroll_context(
|
||||
self,
|
||||
source: OAuthSource,
|
||||
access: UserOAuthSourceConnection,
|
||||
info: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
"""Twitter OAuth Views"""
|
||||
from typing import Any
|
||||
|
||||
from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection
|
||||
from authentik.sources.oauth.types.manager import MANAGER, SourceType
|
||||
from authentik.sources.oauth.views.callback import OAuthCallback
|
||||
|
||||
|
@ -11,8 +10,6 @@ class TwitterOAuthCallback(OAuthCallback):
|
|||
|
||||
def get_user_enroll_context(
|
||||
self,
|
||||
source: OAuthSource,
|
||||
access: UserOAuthSourceConnection,
|
||||
info: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
|
|
|
@ -4,35 +4,14 @@ from typing import Any, Optional
|
|||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.http import Http404, HttpRequest, HttpResponse
|
||||
from django.http.response import HttpResponseBadRequest
|
||||
from django.shortcuts import redirect
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.generic import View
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.core.models import User
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.flows.models import Flow, in_memory_stage
|
||||
from authentik.flows.planner import (
|
||||
PLAN_CONTEXT_PENDING_USER,
|
||||
PLAN_CONTEXT_REDIRECT,
|
||||
PLAN_CONTEXT_SOURCE,
|
||||
PLAN_CONTEXT_SSO,
|
||||
FlowPlanner,
|
||||
)
|
||||
from authentik.flows.views import NEXT_ARG_NAME, SESSION_KEY_GET, SESSION_KEY_PLAN
|
||||
from authentik.lib.utils.urls import redirect_with_qs
|
||||
from authentik.policies.utils import delete_none_keys
|
||||
from authentik.sources.oauth.auth import AuthorizedServiceBackend
|
||||
from authentik.core.sources.flow_manager import SourceFlowManager
|
||||
from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection
|
||||
from authentik.sources.oauth.views.base import OAuthClientMixin
|
||||
from authentik.sources.oauth.views.flows import (
|
||||
PLAN_CONTEXT_SOURCES_OAUTH_ACCESS,
|
||||
PostUserEnrollmentStage,
|
||||
)
|
||||
from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND
|
||||
from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
@ -40,8 +19,7 @@ LOGGER = get_logger()
|
|||
class OAuthCallback(OAuthClientMixin, View):
|
||||
"Base OAuth callback view."
|
||||
|
||||
source_id = None
|
||||
source = None
|
||||
source: OAuthSource
|
||||
|
||||
# pylint: disable=too-many-return-statements
|
||||
def get(self, request: HttpRequest, *_, **kwargs) -> HttpResponse:
|
||||
|
@ -60,47 +38,27 @@ class OAuthCallback(OAuthClientMixin, View):
|
|||
# Fetch access token
|
||||
token = client.get_access_token()
|
||||
if token is None:
|
||||
return self.handle_login_failure(self.source, "Could not retrieve token.")
|
||||
return self.handle_login_failure("Could not retrieve token.")
|
||||
if "error" in token:
|
||||
return self.handle_login_failure(self.source, token["error"])
|
||||
return self.handle_login_failure(token["error"])
|
||||
# Fetch profile info
|
||||
info = client.get_profile_info(token)
|
||||
if info is None:
|
||||
return self.handle_login_failure(self.source, "Could not retrieve profile.")
|
||||
identifier = self.get_user_id(self.source, info)
|
||||
raw_info = client.get_profile_info(token)
|
||||
if raw_info is None:
|
||||
return self.handle_login_failure("Could not retrieve profile.")
|
||||
identifier = self.get_user_id(raw_info)
|
||||
if identifier is None:
|
||||
return self.handle_login_failure(self.source, "Could not determine id.")
|
||||
return self.handle_login_failure("Could not determine id.")
|
||||
# Get or create access record
|
||||
defaults = {
|
||||
"access_token": token.get("access_token"),
|
||||
}
|
||||
existing = UserOAuthSourceConnection.objects.filter(
|
||||
source=self.source, identifier=identifier
|
||||
enroll_info = self.get_user_enroll_context(raw_info)
|
||||
sfm = OAuthSourceFlowManager(
|
||||
source=self.source,
|
||||
request=self.request,
|
||||
identifier=identifier,
|
||||
enroll_info=enroll_info,
|
||||
)
|
||||
|
||||
if existing.exists():
|
||||
connection = existing.first()
|
||||
connection.access_token = token.get("access_token")
|
||||
UserOAuthSourceConnection.objects.filter(pk=connection.pk).update(
|
||||
**defaults
|
||||
)
|
||||
else:
|
||||
connection = UserOAuthSourceConnection(
|
||||
source=self.source,
|
||||
identifier=identifier,
|
||||
access_token=token.get("access_token"),
|
||||
)
|
||||
user = AuthorizedServiceBackend().authenticate(
|
||||
source=self.source, identifier=identifier, request=request
|
||||
return sfm.get_flow(
|
||||
access_token=token.get("access_token"),
|
||||
)
|
||||
if user is None:
|
||||
if self.request.user.is_authenticated:
|
||||
LOGGER.debug("Linking existing user", source=self.source)
|
||||
return self.handle_existing_user_link(self.source, connection, info)
|
||||
LOGGER.debug("Handling enrollment of new user", source=self.source)
|
||||
return self.handle_enroll(self.source, connection, info)
|
||||
LOGGER.debug("Handling existing user", source=self.source)
|
||||
return self.handle_existing_user(self.source, user, connection, info)
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def get_callback_url(self, source: OAuthSource) -> str:
|
||||
|
@ -114,132 +72,35 @@ class OAuthCallback(OAuthClientMixin, View):
|
|||
|
||||
def get_user_enroll_context(
|
||||
self,
|
||||
source: OAuthSource,
|
||||
access: UserOAuthSourceConnection,
|
||||
info: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Create a dict of User data"""
|
||||
raise NotImplementedError()
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def get_user_id(
|
||||
self, source: UserOAuthSourceConnection, info: dict[str, Any]
|
||||
) -> Optional[str]:
|
||||
def get_user_id(self, info: dict[str, Any]) -> Optional[str]:
|
||||
"""Return unique identifier from the profile info."""
|
||||
if "id" in info:
|
||||
return info["id"]
|
||||
return None
|
||||
|
||||
def handle_login_failure(self, source: OAuthSource, reason: str) -> HttpResponse:
|
||||
def handle_login_failure(self, reason: str) -> HttpResponse:
|
||||
"Message user and redirect on error."
|
||||
LOGGER.warning("Authentication Failure", reason=reason)
|
||||
messages.error(self.request, _("Authentication Failed."))
|
||||
return redirect(self.get_error_redirect(source, reason))
|
||||
return redirect(self.get_error_redirect(self.source, reason))
|
||||
|
||||
def handle_login_flow(
|
||||
self, flow: Flow, *stages_to_append, **kwargs
|
||||
) -> HttpResponse:
|
||||
"""Prepare Authentication Plan, redirect user FlowExecutor"""
|
||||
# Ensure redirect is carried through when user was trying to
|
||||
# authorize application
|
||||
final_redirect = self.request.session.get(SESSION_KEY_GET, {}).get(
|
||||
NEXT_ARG_NAME, "authentik_core:if-admin"
|
||||
)
|
||||
kwargs.update(
|
||||
{
|
||||
# Since we authenticate the user by their token, they have no backend set
|
||||
PLAN_CONTEXT_AUTHENTICATION_BACKEND: "django.contrib.auth.backends.ModelBackend",
|
||||
PLAN_CONTEXT_SSO: True,
|
||||
PLAN_CONTEXT_SOURCE: self.source,
|
||||
PLAN_CONTEXT_REDIRECT: final_redirect,
|
||||
}
|
||||
)
|
||||
if not flow:
|
||||
return HttpResponseBadRequest()
|
||||
# We run the Flow planner here so we can pass the Pending user in the context
|
||||
planner = FlowPlanner(flow)
|
||||
plan = planner.plan(self.request, kwargs)
|
||||
for stage in stages_to_append:
|
||||
plan.append(stage)
|
||||
self.request.session[SESSION_KEY_PLAN] = plan
|
||||
return redirect_with_qs(
|
||||
"authentik_core:if-flow",
|
||||
self.request.GET,
|
||||
flow_slug=flow.slug,
|
||||
)
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def handle_existing_user(
|
||||
class OAuthSourceFlowManager(SourceFlowManager):
|
||||
"""Flow manager for oauth sources"""
|
||||
|
||||
connection_type = UserOAuthSourceConnection
|
||||
|
||||
def update_connection(
|
||||
self,
|
||||
source: OAuthSource,
|
||||
user: User,
|
||||
access: UserOAuthSourceConnection,
|
||||
info: dict[str, Any],
|
||||
) -> HttpResponse:
|
||||
"Login user and redirect."
|
||||
messages.success(
|
||||
self.request,
|
||||
_(
|
||||
"Successfully authenticated with %(source)s!"
|
||||
% {"source": self.source.name}
|
||||
),
|
||||
)
|
||||
flow_kwargs = {PLAN_CONTEXT_PENDING_USER: user}
|
||||
return self.handle_login_flow(source.authentication_flow, **flow_kwargs)
|
||||
|
||||
def handle_existing_user_link(
|
||||
self,
|
||||
source: OAuthSource,
|
||||
access: UserOAuthSourceConnection,
|
||||
info: dict[str, Any],
|
||||
) -> HttpResponse:
|
||||
"""Handler when the user was already authenticated and linked an external source
|
||||
to their account."""
|
||||
# there's already a user logged in, just link them up
|
||||
user = self.request.user
|
||||
access.user = user
|
||||
access.save()
|
||||
UserOAuthSourceConnection.objects.filter(pk=access.pk).update(user=user)
|
||||
Event.new(
|
||||
EventAction.SOURCE_LINKED, message="Linked OAuth Source", source=source
|
||||
).from_http(self.request)
|
||||
messages.success(
|
||||
self.request,
|
||||
_("Successfully linked %(source)s!" % {"source": self.source.name}),
|
||||
)
|
||||
return redirect(
|
||||
reverse(
|
||||
"authentik_core:if-admin",
|
||||
)
|
||||
+ f"#/user;page-{self.source.slug}"
|
||||
)
|
||||
|
||||
def handle_enroll(
|
||||
self,
|
||||
source: OAuthSource,
|
||||
access: UserOAuthSourceConnection,
|
||||
info: dict[str, Any],
|
||||
) -> HttpResponse:
|
||||
"""User was not authenticated and previous request was not authenticated."""
|
||||
messages.success(
|
||||
self.request,
|
||||
_(
|
||||
"Successfully authenticated with %(source)s!"
|
||||
% {"source": self.source.name}
|
||||
),
|
||||
)
|
||||
|
||||
# We run the Flow planner here so we can pass the Pending user in the context
|
||||
if not source.enrollment_flow:
|
||||
LOGGER.warning("source has no enrollment flow", source=source)
|
||||
return HttpResponseBadRequest()
|
||||
return self.handle_login_flow(
|
||||
source.enrollment_flow,
|
||||
in_memory_stage(PostUserEnrollmentStage),
|
||||
**{
|
||||
PLAN_CONTEXT_PROMPT: delete_none_keys(
|
||||
self.get_user_enroll_context(source, access, info)
|
||||
),
|
||||
PLAN_CONTEXT_SOURCES_OAUTH_ACCESS: access,
|
||||
},
|
||||
)
|
||||
connection: UserOAuthSourceConnection,
|
||||
access_token: Optional[str] = None,
|
||||
) -> UserOAuthSourceConnection:
|
||||
"""Set the access_token on the connection"""
|
||||
connection.access_token = access_token
|
||||
return connection
|
||||
|
|
|
@ -0,0 +1,75 @@
|
|||
"""Plex Source Serializer"""
|
||||
from django.http import Http404
|
||||
from django.shortcuts import get_object_or_404
|
||||
from drf_yasg import openapi
|
||||
from drf_yasg.utils import swagger_auto_schema
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.fields import CharField
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from authentik.api.decorators import permission_required
|
||||
from authentik.core.api.sources import SourceSerializer
|
||||
from authentik.core.api.utils import PassiveSerializer
|
||||
from authentik.flows.challenge import RedirectChallenge
|
||||
from authentik.flows.views import to_stage_response
|
||||
from authentik.sources.plex.models import PlexSource
|
||||
from authentik.sources.plex.plex import PlexAuth
|
||||
|
||||
|
||||
class PlexSourceSerializer(SourceSerializer):
|
||||
"""Plex Source Serializer"""
|
||||
|
||||
class Meta:
|
||||
model = PlexSource
|
||||
fields = SourceSerializer.Meta.fields + ["client_id", "allowed_servers"]
|
||||
|
||||
|
||||
class PlexTokenRedeemSerializer(PassiveSerializer):
|
||||
"""Serializer to redeem a plex token"""
|
||||
|
||||
plex_token = CharField()
|
||||
|
||||
|
||||
class PlexSourceViewSet(ModelViewSet):
|
||||
"""Plex source Viewset"""
|
||||
|
||||
queryset = PlexSource.objects.all()
|
||||
serializer_class = PlexSourceSerializer
|
||||
lookup_field = "slug"
|
||||
|
||||
@permission_required(None)
|
||||
@swagger_auto_schema(
|
||||
request_body=PlexTokenRedeemSerializer(),
|
||||
responses={200: RedirectChallenge(), 404: "Token not found"},
|
||||
manual_parameters=[
|
||||
openapi.Parameter(
|
||||
name="slug",
|
||||
in_=openapi.IN_QUERY,
|
||||
type=openapi.TYPE_STRING,
|
||||
)
|
||||
],
|
||||
)
|
||||
@action(
|
||||
methods=["POST"],
|
||||
detail=False,
|
||||
pagination_class=None,
|
||||
filter_backends=[],
|
||||
permission_classes=[AllowAny],
|
||||
)
|
||||
def redeem_token(self, request: Request) -> Response:
|
||||
"""Redeem a plex token, check it's access to resources against what's allowed
|
||||
for the source, and redirect to an authentication/enrollment flow."""
|
||||
source: PlexSource = get_object_or_404(
|
||||
PlexSource, slug=request.query_params.get("slug", "")
|
||||
)
|
||||
plex_token = request.data.get("plex_token", None)
|
||||
if not plex_token:
|
||||
raise Http404
|
||||
auth_api = PlexAuth(source, plex_token)
|
||||
if not auth_api.check_server_overlap():
|
||||
raise Http404
|
||||
response = auth_api.get_user_url(request)
|
||||
return to_stage_response(request, response)
|
|
@ -0,0 +1,10 @@
|
|||
"""authentik plex config"""
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AuthentikSourcePlexConfig(AppConfig):
|
||||
"""authentik source plex config"""
|
||||
|
||||
name = "authentik.sources.plex"
|
||||
label = "authentik_sources_plex"
|
||||
verbose_name = "authentik Sources.Plex"
|
|
@ -0,0 +1,77 @@
|
|||
# Generated by Django 3.2 on 2021-05-03 18:59
|
||||
|
||||
import django.contrib.postgres.fields
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("authentik_core", "0020_source_user_matching_mode"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="PlexSource",
|
||||
fields=[
|
||||
(
|
||||
"source_ptr",
|
||||
models.OneToOneField(
|
||||
auto_created=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
parent_link=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
to="authentik_core.source",
|
||||
),
|
||||
),
|
||||
(
|
||||
"client_id",
|
||||
models.TextField(
|
||||
default="yOuPQQvgNfBGreZZ38WoOY1d3qk3Xso2AuQHi6RG",
|
||||
help_text="Client identifier used to talk to Plex.",
|
||||
),
|
||||
),
|
||||
(
|
||||
"allowed_servers",
|
||||
django.contrib.postgres.fields.ArrayField(
|
||||
base_field=models.TextField(),
|
||||
default=list,
|
||||
help_text="Which servers a user has to be a member of to be granted access. Empty list allows every server.",
|
||||
size=None,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Plex Source",
|
||||
"verbose_name_plural": "Plex Sources",
|
||||
},
|
||||
bases=("authentik_core.source",),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="PlexSourceConnection",
|
||||
fields=[
|
||||
(
|
||||
"usersourceconnection_ptr",
|
||||
models.OneToOneField(
|
||||
auto_created=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
parent_link=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
to="authentik_core.usersourceconnection",
|
||||
),
|
||||
),
|
||||
("plex_token", models.TextField()),
|
||||
("identifier", models.TextField()),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "User Plex Source Connection",
|
||||
"verbose_name_plural": "User Plex Source Connections",
|
||||
},
|
||||
bases=("authentik_core.usersourceconnection",),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,80 @@
|
|||
"""Plex source"""
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.db import models
|
||||
from django.templatetags.static import static
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework.fields import CharField
|
||||
from rest_framework.serializers import BaseSerializer
|
||||
|
||||
from authentik.core.models import Source, UserSourceConnection
|
||||
from authentik.core.types import UILoginButton
|
||||
from authentik.flows.challenge import Challenge, ChallengeTypes
|
||||
from authentik.providers.oauth2.generators import generate_client_id
|
||||
|
||||
|
||||
class PlexAuthenticationChallenge(Challenge):
|
||||
"""Challenge shown to the user in identification stage"""
|
||||
|
||||
client_id = CharField()
|
||||
slug = CharField()
|
||||
|
||||
|
||||
class PlexSource(Source):
|
||||
"""Authenticate against plex.tv"""
|
||||
|
||||
client_id = models.TextField(
|
||||
default=generate_client_id(),
|
||||
help_text=_("Client identifier used to talk to Plex."),
|
||||
)
|
||||
allowed_servers = ArrayField(
|
||||
models.TextField(),
|
||||
default=list,
|
||||
help_text=_(
|
||||
(
|
||||
"Which servers a user has to be a member of to be granted access. "
|
||||
"Empty list allows every server."
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
@property
|
||||
def component(self) -> str:
|
||||
return "ak-source-plex-form"
|
||||
|
||||
@property
|
||||
def serializer(self) -> BaseSerializer:
|
||||
from authentik.sources.plex.api import PlexSourceSerializer
|
||||
|
||||
return PlexSourceSerializer
|
||||
|
||||
@property
|
||||
def ui_login_button(self) -> UILoginButton:
|
||||
return UILoginButton(
|
||||
challenge=PlexAuthenticationChallenge(
|
||||
{
|
||||
"type": ChallengeTypes.NATIVE.value,
|
||||
"component": "ak-flow-sources-plex",
|
||||
"client_id": self.client_id,
|
||||
"slug": self.slug,
|
||||
}
|
||||
),
|
||||
icon_url=static("authentik/sources/plex.svg"),
|
||||
name=self.name,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
|
||||
verbose_name = _("Plex Source")
|
||||
verbose_name_plural = _("Plex Sources")
|
||||
|
||||
|
||||
class PlexSourceConnection(UserSourceConnection):
|
||||
"""Connect user and plex source"""
|
||||
|
||||
plex_token = models.TextField()
|
||||
identifier = models.TextField()
|
||||
|
||||
class Meta:
|
||||
|
||||
verbose_name = _("User Plex Source Connection")
|
||||
verbose_name_plural = _("User Plex Source Connections")
|
|
@ -0,0 +1,112 @@
|
|||
"""Plex Views"""
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from django.http.request import HttpRequest
|
||||
from django.http.response import Http404, HttpResponse
|
||||
from requests import Session
|
||||
from requests.exceptions import RequestException
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik import __version__
|
||||
from authentik.core.sources.flow_manager import SourceFlowManager
|
||||
from authentik.sources.plex.models import PlexSource, PlexSourceConnection
|
||||
|
||||
LOGGER = get_logger()
|
||||
SESSION_ID_KEY = "PLEX_ID"
|
||||
SESSION_CODE_KEY = "PLEX_CODE"
|
||||
|
||||
|
||||
class PlexAuth:
|
||||
"""Plex authentication utilities"""
|
||||
|
||||
_source: PlexSource
|
||||
_token: str
|
||||
|
||||
def __init__(self, source: PlexSource, token: str):
|
||||
self._source = source
|
||||
self._token = token
|
||||
self._session = Session()
|
||||
self._session.headers.update(
|
||||
{"Accept": "application/json", "Content-Type": "application/json"}
|
||||
)
|
||||
self._session.headers.update(self.headers)
|
||||
|
||||
@property
|
||||
def headers(self) -> dict[str, str]:
|
||||
"""Get common headers"""
|
||||
return {
|
||||
"X-Plex-Product": "authentik",
|
||||
"X-Plex-Version": __version__,
|
||||
"X-Plex-Device-Vendor": "BeryJu.org",
|
||||
}
|
||||
|
||||
def get_resources(self) -> list[dict]:
|
||||
"""Get all resources the plex-token has access to"""
|
||||
qs = {
|
||||
"X-Plex-Token": self._token,
|
||||
"X-Plex-Client-Identifier": self._source.client_id,
|
||||
}
|
||||
response = self._session.get(
|
||||
f"https://plex.tv/api/v2/resources?{urlencode(qs)}",
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def get_user_info(self) -> tuple[dict, int]:
|
||||
"""Get user info of the plex token"""
|
||||
qs = {
|
||||
"X-Plex-Token": self._token,
|
||||
"X-Plex-Client-Identifier": self._source.client_id,
|
||||
}
|
||||
response = self._session.get(
|
||||
f"https://plex.tv/api/v2/user?{urlencode(qs)}",
|
||||
)
|
||||
response.raise_for_status()
|
||||
raw_user_info = response.json()
|
||||
return {
|
||||
"username": raw_user_info.get("username"),
|
||||
"email": raw_user_info.get("email"),
|
||||
"name": raw_user_info.get("title"),
|
||||
}, raw_user_info.get("id")
|
||||
|
||||
def check_server_overlap(self) -> bool:
|
||||
"""Check if the plex-token has any server overlap with our configured servers"""
|
||||
try:
|
||||
resources = self.get_resources()
|
||||
except RequestException as exc:
|
||||
LOGGER.warning("Unable to fetch user resources", exc=exc)
|
||||
raise Http404
|
||||
else:
|
||||
for resource in resources:
|
||||
if resource["provides"] != "server":
|
||||
continue
|
||||
if resource["clientIdentifier"] in self._source.allowed_servers:
|
||||
LOGGER.info(
|
||||
"Plex allowed access from server", name=resource["name"]
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_user_url(self, request: HttpRequest) -> HttpResponse:
|
||||
"""Get a URL to a flow executor for either enrollment or authentication"""
|
||||
user_info, identifier = self.get_user_info()
|
||||
sfm = PlexSourceFlowManager(
|
||||
source=self._source,
|
||||
request=request,
|
||||
identifier=str(identifier),
|
||||
enroll_info=user_info,
|
||||
)
|
||||
return sfm.get_flow(plex_token=self._token)
|
||||
|
||||
|
||||
class PlexSourceFlowManager(SourceFlowManager):
|
||||
"""Flow manager for plex sources"""
|
||||
|
||||
connection_type = PlexSourceConnection
|
||||
|
||||
def update_connection(
|
||||
self, connection: PlexSourceConnection, plex_token: str
|
||||
) -> PlexSourceConnection:
|
||||
"""Set the access_token on the connection"""
|
||||
connection.plex_token = plex_token
|
||||
return connection
|
|
@ -0,0 +1,64 @@
|
|||
"""plex Source tests"""
|
||||
from django.test import TestCase
|
||||
from requests_mock import Mocker
|
||||
|
||||
from authentik.providers.oauth2.generators import generate_client_secret
|
||||
from authentik.sources.plex.models import PlexSource
|
||||
from authentik.sources.plex.plex import PlexAuth
|
||||
|
||||
USER_INFO_RESPONSE = {
|
||||
"id": 1234123419,
|
||||
"uuid": "qwerqewrqewrqwr",
|
||||
"username": "username",
|
||||
"title": "title",
|
||||
"email": "foo@bar.baz",
|
||||
}
|
||||
RESOURCES_RESPONSE = [
|
||||
{
|
||||
"name": "foo",
|
||||
"clientIdentifier": "allowed",
|
||||
"provides": "server",
|
||||
},
|
||||
{
|
||||
"name": "foo",
|
||||
"clientIdentifier": "denied",
|
||||
"provides": "server",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class TestPlexSource(TestCase):
|
||||
"""plex Source tests"""
|
||||
|
||||
def setUp(self):
|
||||
self.source: PlexSource = PlexSource.objects.create(
|
||||
name="test",
|
||||
slug="test",
|
||||
)
|
||||
|
||||
def test_get_user_info(self):
|
||||
"""Test get_user_info"""
|
||||
token = generate_client_secret()
|
||||
api = PlexAuth(self.source, token)
|
||||
with Mocker() as mocker:
|
||||
mocker.get("https://plex.tv/api/v2/user", json=USER_INFO_RESPONSE)
|
||||
self.assertEqual(
|
||||
api.get_user_info(),
|
||||
(
|
||||
{"username": "username", "email": "foo@bar.baz", "name": "title"},
|
||||
1234123419,
|
||||
),
|
||||
)
|
||||
|
||||
def test_check_server_overlap(self):
|
||||
"""Test check_server_overlap"""
|
||||
token = generate_client_secret()
|
||||
api = PlexAuth(self.source, token)
|
||||
with Mocker() as mocker:
|
||||
mocker.get("https://plex.tv/api/v2/resources", json=RESOURCES_RESPONSE)
|
||||
self.assertFalse(api.check_server_overlap())
|
||||
self.source.allowed_servers = ["allowed"]
|
||||
self.source.save()
|
||||
with Mocker() as mocker:
|
||||
mocker.get("https://plex.tv/api/v2/resources", json=RESOURCES_RESPONSE)
|
||||
self.assertTrue(api.check_server_overlap())
|
|
@ -10,6 +10,7 @@ from rest_framework.serializers import Serializer
|
|||
from authentik.core.models import Source
|
||||
from authentik.core.types import UILoginButton
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.flows.challenge import ChallengeTypes, RedirectChallenge
|
||||
from authentik.flows.models import Flow
|
||||
from authentik.lib.utils.time import timedelta_string_validator
|
||||
from authentik.sources.saml.processors.constants import (
|
||||
|
@ -169,10 +170,16 @@ class SAMLSource(Source):
|
|||
@property
|
||||
def ui_login_button(self) -> UILoginButton:
|
||||
return UILoginButton(
|
||||
name=self.name,
|
||||
url=reverse(
|
||||
"authentik_sources_saml:login", kwargs={"source_slug": self.slug}
|
||||
challenge=RedirectChallenge(
|
||||
instance={
|
||||
"type": ChallengeTypes.REDIRECT.value,
|
||||
"to": reverse(
|
||||
"authentik_sources_saml:login",
|
||||
kwargs={"source_slug": self.slug},
|
||||
),
|
||||
}
|
||||
),
|
||||
name=self.name,
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
|
|
|
@ -112,7 +112,9 @@ class IdentificationStageView(ChallengeStageView):
|
|||
for source in sources:
|
||||
ui_login_button = source.ui_login_button
|
||||
if ui_login_button:
|
||||
ui_sources.append(asdict(ui_login_button))
|
||||
button = asdict(ui_login_button)
|
||||
button["challenge"] = ui_login_button.challenge.data
|
||||
ui_sources.append(button)
|
||||
challenge.initial_data["sources"] = ui_sources
|
||||
return challenge
|
||||
|
||||
|
|
|
@ -117,7 +117,10 @@ class TestIdentificationStage(TestCase):
|
|||
{
|
||||
"icon_url": "/static/authentik/sources/.svg",
|
||||
"name": "test",
|
||||
"url": "/source/oauth/login/test/",
|
||||
"challenge": {
|
||||
"to": "/source/oauth/login/test/",
|
||||
"type": "redirect",
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
|
@ -158,9 +161,12 @@ class TestIdentificationStage(TestCase):
|
|||
"title": self.flow.title,
|
||||
"sources": [
|
||||
{
|
||||
"challenge": {
|
||||
"to": "/source/oauth/login/test/",
|
||||
"type": "redirect",
|
||||
},
|
||||
"icon_url": "/static/authentik/sources/.svg",
|
||||
"name": "test",
|
||||
"url": "/source/oauth/login/test/",
|
||||
}
|
||||
],
|
||||
},
|
||||
|
|
414
swagger.yaml
414
swagger.yaml
|
@ -10213,6 +10213,238 @@ paths:
|
|||
description: A unique integer value identifying this User OAuth Source Connection.
|
||||
required: true
|
||||
type: integer
|
||||
/sources/plex/:
|
||||
get:
|
||||
operationId: sources_plex_list
|
||||
description: Plex source Viewset
|
||||
parameters:
|
||||
- name: ordering
|
||||
in: query
|
||||
description: Which field to use when ordering the results.
|
||||
required: false
|
||||
type: string
|
||||
- name: search
|
||||
in: query
|
||||
description: A search term.
|
||||
required: false
|
||||
type: string
|
||||
- name: page
|
||||
in: query
|
||||
description: Page Index
|
||||
required: false
|
||||
type: integer
|
||||
- name: page_size
|
||||
in: query
|
||||
description: Page Size
|
||||
required: false
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
schema:
|
||||
required:
|
||||
- results
|
||||
- pagination
|
||||
type: object
|
||||
properties:
|
||||
pagination:
|
||||
required:
|
||||
- next
|
||||
- previous
|
||||
- count
|
||||
- current
|
||||
- total_pages
|
||||
- start_index
|
||||
- end_index
|
||||
type: object
|
||||
properties:
|
||||
next:
|
||||
type: number
|
||||
previous:
|
||||
type: number
|
||||
count:
|
||||
type: number
|
||||
current:
|
||||
type: number
|
||||
total_pages:
|
||||
type: number
|
||||
start_index:
|
||||
type: number
|
||||
end_index:
|
||||
type: number
|
||||
results:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/definitions/PlexSource'
|
||||
'403':
|
||||
description: Authentication credentials were invalid, absent or insufficient.
|
||||
schema:
|
||||
$ref: '#/definitions/GenericError'
|
||||
tags:
|
||||
- sources
|
||||
post:
|
||||
operationId: sources_plex_create
|
||||
description: Plex source Viewset
|
||||
parameters:
|
||||
- name: data
|
||||
in: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/PlexSource'
|
||||
responses:
|
||||
'201':
|
||||
description: ''
|
||||
schema:
|
||||
$ref: '#/definitions/PlexSource'
|
||||
'400':
|
||||
description: Invalid input.
|
||||
schema:
|
||||
$ref: '#/definitions/ValidationError'
|
||||
'403':
|
||||
description: Authentication credentials were invalid, absent or insufficient.
|
||||
schema:
|
||||
$ref: '#/definitions/GenericError'
|
||||
tags:
|
||||
- sources
|
||||
parameters: []
|
||||
/sources/plex/redeem_token/:
|
||||
post:
|
||||
operationId: sources_plex_redeem_token
|
||||
description: |-
|
||||
Redeem a plex token, check it's access to resources against what's allowed
|
||||
for the source, and redirect to an authentication/enrollment flow.
|
||||
parameters:
|
||||
- name: data
|
||||
in: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/PlexTokenRedeem'
|
||||
- name: slug
|
||||
in: query
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
schema:
|
||||
$ref: '#/definitions/RedirectChallenge'
|
||||
'404':
|
||||
description: Token not found
|
||||
'400':
|
||||
description: Invalid input.
|
||||
schema:
|
||||
$ref: '#/definitions/ValidationError'
|
||||
'403':
|
||||
description: Authentication credentials were invalid, absent or insufficient.
|
||||
schema:
|
||||
$ref: '#/definitions/GenericError'
|
||||
tags:
|
||||
- sources
|
||||
parameters: []
|
||||
/sources/plex/{slug}/:
|
||||
get:
|
||||
operationId: sources_plex_read
|
||||
description: Plex source Viewset
|
||||
parameters: []
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
schema:
|
||||
$ref: '#/definitions/PlexSource'
|
||||
'403':
|
||||
description: Authentication credentials were invalid, absent or insufficient.
|
||||
schema:
|
||||
$ref: '#/definitions/GenericError'
|
||||
'404':
|
||||
description: Object does not exist or caller has insufficient permissions
|
||||
to access it.
|
||||
schema:
|
||||
$ref: '#/definitions/APIException'
|
||||
tags:
|
||||
- sources
|
||||
put:
|
||||
operationId: sources_plex_update
|
||||
description: Plex source Viewset
|
||||
parameters:
|
||||
- name: data
|
||||
in: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/PlexSource'
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
schema:
|
||||
$ref: '#/definitions/PlexSource'
|
||||
'400':
|
||||
description: Invalid input.
|
||||
schema:
|
||||
$ref: '#/definitions/ValidationError'
|
||||
'403':
|
||||
description: Authentication credentials were invalid, absent or insufficient.
|
||||
schema:
|
||||
$ref: '#/definitions/GenericError'
|
||||
'404':
|
||||
description: Object does not exist or caller has insufficient permissions
|
||||
to access it.
|
||||
schema:
|
||||
$ref: '#/definitions/APIException'
|
||||
tags:
|
||||
- sources
|
||||
patch:
|
||||
operationId: sources_plex_partial_update
|
||||
description: Plex source Viewset
|
||||
parameters:
|
||||
- name: data
|
||||
in: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/PlexSource'
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
schema:
|
||||
$ref: '#/definitions/PlexSource'
|
||||
'400':
|
||||
description: Invalid input.
|
||||
schema:
|
||||
$ref: '#/definitions/ValidationError'
|
||||
'403':
|
||||
description: Authentication credentials were invalid, absent or insufficient.
|
||||
schema:
|
||||
$ref: '#/definitions/GenericError'
|
||||
'404':
|
||||
description: Object does not exist or caller has insufficient permissions
|
||||
to access it.
|
||||
schema:
|
||||
$ref: '#/definitions/APIException'
|
||||
tags:
|
||||
- sources
|
||||
delete:
|
||||
operationId: sources_plex_delete
|
||||
description: Plex source Viewset
|
||||
parameters: []
|
||||
responses:
|
||||
'204':
|
||||
description: ''
|
||||
'403':
|
||||
description: Authentication credentials were invalid, absent or insufficient.
|
||||
schema:
|
||||
$ref: '#/definitions/GenericError'
|
||||
'404':
|
||||
description: Object does not exist or caller has insufficient permissions
|
||||
to access it.
|
||||
schema:
|
||||
$ref: '#/definitions/APIException'
|
||||
tags:
|
||||
- sources
|
||||
parameters:
|
||||
- name: slug
|
||||
in: path
|
||||
description: Internal source name, used in URLs.
|
||||
required: true
|
||||
type: string
|
||||
format: slug
|
||||
pattern: ^[-a-zA-Z0-9_]+$
|
||||
/sources/saml/:
|
||||
get:
|
||||
operationId: sources_saml_list
|
||||
|
@ -16210,6 +16442,7 @@ definitions:
|
|||
- authentik.recovery
|
||||
- authentik.sources.ldap
|
||||
- authentik.sources.oauth
|
||||
- authentik.sources.plex
|
||||
- authentik.sources.saml
|
||||
- authentik.stages.authenticator_static
|
||||
- authentik.stages.authenticator_totp
|
||||
|
@ -17056,6 +17289,17 @@ definitions:
|
|||
enum:
|
||||
- all
|
||||
- any
|
||||
user_matching_mode:
|
||||
title: User matching mode
|
||||
description: How the source determines if an existing user should be authenticated
|
||||
or a new user enrolled.
|
||||
type: string
|
||||
enum:
|
||||
- identifier
|
||||
- email_link
|
||||
- email_deny
|
||||
- username_link
|
||||
- username_deny
|
||||
UserSetting:
|
||||
required:
|
||||
- object_uid
|
||||
|
@ -17136,6 +17380,17 @@ definitions:
|
|||
enum:
|
||||
- all
|
||||
- any
|
||||
user_matching_mode:
|
||||
title: User matching mode
|
||||
description: How the source determines if an existing user should be authenticated
|
||||
or a new user enrolled.
|
||||
type: string
|
||||
enum:
|
||||
- identifier
|
||||
- email_link
|
||||
- email_deny
|
||||
- username_link
|
||||
- username_deny
|
||||
server_uri:
|
||||
title: Server URI
|
||||
type: string
|
||||
|
@ -17316,6 +17571,17 @@ definitions:
|
|||
enum:
|
||||
- all
|
||||
- any
|
||||
user_matching_mode:
|
||||
title: User matching mode
|
||||
description: How the source determines if an existing user should be authenticated
|
||||
or a new user enrolled.
|
||||
type: string
|
||||
enum:
|
||||
- identifier
|
||||
- email_link
|
||||
- email_deny
|
||||
- username_link
|
||||
- username_deny
|
||||
provider_type:
|
||||
title: Provider type
|
||||
type: string
|
||||
|
@ -17386,6 +17652,132 @@ definitions:
|
|||
type: string
|
||||
maxLength: 255
|
||||
minLength: 1
|
||||
PlexSource:
|
||||
required:
|
||||
- name
|
||||
- slug
|
||||
type: object
|
||||
properties:
|
||||
pk:
|
||||
title: Pbm uuid
|
||||
type: string
|
||||
format: uuid
|
||||
readOnly: true
|
||||
name:
|
||||
title: Name
|
||||
description: Source's display Name.
|
||||
type: string
|
||||
minLength: 1
|
||||
slug:
|
||||
title: Slug
|
||||
description: Internal source name, used in URLs.
|
||||
type: string
|
||||
format: slug
|
||||
pattern: ^[-a-zA-Z0-9_]+$
|
||||
maxLength: 50
|
||||
minLength: 1
|
||||
enabled:
|
||||
title: Enabled
|
||||
type: boolean
|
||||
authentication_flow:
|
||||
title: Authentication flow
|
||||
description: Flow to use when authenticating existing users.
|
||||
type: string
|
||||
format: uuid
|
||||
x-nullable: true
|
||||
enrollment_flow:
|
||||
title: Enrollment flow
|
||||
description: Flow to use when enrolling new users.
|
||||
type: string
|
||||
format: uuid
|
||||
x-nullable: true
|
||||
component:
|
||||
title: Component
|
||||
type: string
|
||||
readOnly: true
|
||||
verbose_name:
|
||||
title: Verbose name
|
||||
type: string
|
||||
readOnly: true
|
||||
verbose_name_plural:
|
||||
title: Verbose name plural
|
||||
type: string
|
||||
readOnly: true
|
||||
policy_engine_mode:
|
||||
title: Policy engine mode
|
||||
type: string
|
||||
enum:
|
||||
- all
|
||||
- any
|
||||
user_matching_mode:
|
||||
title: User matching mode
|
||||
description: How the source determines if an existing user should be authenticated
|
||||
or a new user enrolled.
|
||||
type: string
|
||||
enum:
|
||||
- identifier
|
||||
- email_link
|
||||
- email_deny
|
||||
- username_link
|
||||
- username_deny
|
||||
client_id:
|
||||
title: Client id
|
||||
description: Client identifier used to talk to Plex.
|
||||
type: string
|
||||
minLength: 1
|
||||
allowed_servers:
|
||||
description: Which servers a user has to be a member of to be granted access.
|
||||
Empty list allows every server.
|
||||
type: array
|
||||
items:
|
||||
title: Allowed servers
|
||||
type: string
|
||||
minLength: 1
|
||||
PlexTokenRedeem:
|
||||
required:
|
||||
- plex_token
|
||||
type: object
|
||||
properties:
|
||||
plex_token:
|
||||
title: Plex token
|
||||
type: string
|
||||
minLength: 1
|
||||
RedirectChallenge:
|
||||
required:
|
||||
- type
|
||||
- to
|
||||
type: object
|
||||
properties:
|
||||
type:
|
||||
title: Type
|
||||
type: string
|
||||
enum:
|
||||
- native
|
||||
- shell
|
||||
- redirect
|
||||
component:
|
||||
title: Component
|
||||
type: string
|
||||
minLength: 1
|
||||
title:
|
||||
title: Title
|
||||
type: string
|
||||
minLength: 1
|
||||
background:
|
||||
title: Background
|
||||
type: string
|
||||
minLength: 1
|
||||
response_errors:
|
||||
title: Response errors
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/definitions/ErrorDetail'
|
||||
to:
|
||||
title: To
|
||||
type: string
|
||||
minLength: 1
|
||||
SAMLSource:
|
||||
required:
|
||||
- name
|
||||
|
@ -17445,6 +17837,17 @@ definitions:
|
|||
enum:
|
||||
- all
|
||||
- any
|
||||
user_matching_mode:
|
||||
title: User matching mode
|
||||
description: How the source determines if an existing user should be authenticated
|
||||
or a new user enrolled.
|
||||
type: string
|
||||
enum:
|
||||
- identifier
|
||||
- email_link
|
||||
- email_deny
|
||||
- username_link
|
||||
- username_deny
|
||||
pre_authentication_flow:
|
||||
title: Pre authentication flow
|
||||
description: Flow used before authentication.
|
||||
|
@ -18190,6 +18593,17 @@ definitions:
|
|||
enabled:
|
||||
title: Enabled
|
||||
type: boolean
|
||||
user_matching_mode:
|
||||
title: User matching mode
|
||||
description: How the source determines if an existing user should
|
||||
be authenticated or a new user enrolled.
|
||||
type: string
|
||||
enum:
|
||||
- identifier
|
||||
- email_link
|
||||
- email_deny
|
||||
- username_link
|
||||
- username_deny
|
||||
authentication_flow:
|
||||
title: Authentication flow
|
||||
description: Flow to use when authenticating existing users.
|
||||
|
|
|
@ -147,11 +147,11 @@ class TestSourceOAuth2(SeleniumTestCase):
|
|||
|
||||
wait.until(
|
||||
ec.presence_of_element_located(
|
||||
(By.CLASS_NAME, "pf-c-login__main-footer-links-item-link")
|
||||
(By.CSS_SELECTOR, ".pf-c-login__main-footer-links-item > button")
|
||||
)
|
||||
)
|
||||
identification_stage.find_element(
|
||||
By.CLASS_NAME, "pf-c-login__main-footer-links-item-link"
|
||||
By.CSS_SELECTOR, ".pf-c-login__main-footer-links-item > button"
|
||||
).click()
|
||||
|
||||
# Now we should be at the IDP, wait for the login field
|
||||
|
@ -206,11 +206,11 @@ class TestSourceOAuth2(SeleniumTestCase):
|
|||
|
||||
wait.until(
|
||||
ec.presence_of_element_located(
|
||||
(By.CLASS_NAME, "pf-c-login__main-footer-links-item-link")
|
||||
(By.CSS_SELECTOR, ".pf-c-login__main-footer-links-item > button")
|
||||
)
|
||||
)
|
||||
identification_stage.find_element(
|
||||
By.CLASS_NAME, "pf-c-login__main-footer-links-item-link"
|
||||
By.CSS_SELECTOR, ".pf-c-login__main-footer-links-item > button"
|
||||
).click()
|
||||
|
||||
# Now we should be at the IDP, wait for the login field
|
||||
|
@ -245,11 +245,11 @@ class TestSourceOAuth2(SeleniumTestCase):
|
|||
|
||||
wait.until(
|
||||
ec.presence_of_element_located(
|
||||
(By.CLASS_NAME, "pf-c-login__main-footer-links-item-link")
|
||||
(By.CSS_SELECTOR, ".pf-c-login__main-footer-links-item > button")
|
||||
)
|
||||
)
|
||||
identification_stage.find_element(
|
||||
By.CLASS_NAME, "pf-c-login__main-footer-links-item-link"
|
||||
By.CSS_SELECTOR, ".pf-c-login__main-footer-links-item > button"
|
||||
).click()
|
||||
|
||||
# Now we should be at the IDP, wait for the login field
|
||||
|
@ -338,17 +338,18 @@ class TestSourceOAuth1(SeleniumTestCase):
|
|||
|
||||
wait.until(
|
||||
ec.presence_of_element_located(
|
||||
(By.CLASS_NAME, "pf-c-login__main-footer-links-item-link")
|
||||
(By.CSS_SELECTOR, ".pf-c-login__main-footer-links-item > button")
|
||||
)
|
||||
)
|
||||
identification_stage.find_element(
|
||||
By.CLASS_NAME, "pf-c-login__main-footer-links-item-link"
|
||||
By.CSS_SELECTOR, ".pf-c-login__main-footer-links-item > button"
|
||||
).click()
|
||||
|
||||
# Now we should be at the IDP, wait for the login field
|
||||
self.wait.until(ec.presence_of_element_located((By.NAME, "username")))
|
||||
self.driver.find_element(By.NAME, "username").send_keys("example-user")
|
||||
self.driver.find_element(By.NAME, "username").send_keys(Keys.ENTER)
|
||||
sleep(2)
|
||||
|
||||
# Wait until we're logged in
|
||||
self.wait.until(
|
||||
|
|
|
@ -140,11 +140,11 @@ class TestSourceSAML(SeleniumTestCase):
|
|||
|
||||
wait.until(
|
||||
ec.presence_of_element_located(
|
||||
(By.CLASS_NAME, "pf-c-login__main-footer-links-item-link")
|
||||
(By.CSS_SELECTOR, ".pf-c-login__main-footer-links-item > button")
|
||||
)
|
||||
)
|
||||
identification_stage.find_element(
|
||||
By.CLASS_NAME, "pf-c-login__main-footer-links-item-link"
|
||||
By.CSS_SELECTOR, ".pf-c-login__main-footer-links-item > button"
|
||||
).click()
|
||||
|
||||
# Now we should be at the IDP, wait for the username field
|
||||
|
@ -208,11 +208,11 @@ class TestSourceSAML(SeleniumTestCase):
|
|||
|
||||
wait.until(
|
||||
ec.presence_of_element_located(
|
||||
(By.CLASS_NAME, "pf-c-login__main-footer-links-item-link")
|
||||
(By.CSS_SELECTOR, ".pf-c-login__main-footer-links-item > button")
|
||||
)
|
||||
)
|
||||
identification_stage.find_element(
|
||||
By.CLASS_NAME, "pf-c-login__main-footer-links-item-link"
|
||||
By.CSS_SELECTOR, ".pf-c-login__main-footer-links-item > button"
|
||||
).click()
|
||||
sleep(1)
|
||||
|
||||
|
@ -289,11 +289,11 @@ class TestSourceSAML(SeleniumTestCase):
|
|||
|
||||
wait.until(
|
||||
ec.presence_of_element_located(
|
||||
(By.CLASS_NAME, "pf-c-login__main-footer-links-item-link")
|
||||
(By.CSS_SELECTOR, ".pf-c-login__main-footer-links-item > button")
|
||||
)
|
||||
)
|
||||
identification_stage.find_element(
|
||||
By.CLASS_NAME, "pf-c-login__main-footer-links-item-link"
|
||||
By.CSS_SELECTOR, ".pf-c-login__main-footer-links-item > button"
|
||||
).click()
|
||||
|
||||
# Now we should be at the IDP, wait for the username field
|
||||
|
|
|
@ -6,7 +6,8 @@
|
|||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:lit/recommended"
|
||||
"plugin:lit/recommended",
|
||||
"plugin:custom-elements/recommended"
|
||||
],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
|
@ -15,7 +16,8 @@
|
|||
},
|
||||
"plugins": [
|
||||
"@typescript-eslint",
|
||||
"lit"
|
||||
"lit",
|
||||
"custom-elements"
|
||||
],
|
||||
"rules": {
|
||||
"indent": "off",
|
||||
|
|
|
@ -18,7 +18,7 @@ stages:
|
|||
steps:
|
||||
- task: NodeTool@0
|
||||
inputs:
|
||||
versionSpec: '12.x'
|
||||
versionSpec: '14.x'
|
||||
displayName: 'Install Node.js'
|
||||
- task: CmdLine@2
|
||||
inputs:
|
||||
|
@ -37,7 +37,7 @@ stages:
|
|||
steps:
|
||||
- task: NodeTool@0
|
||||
inputs:
|
||||
versionSpec: '12.x'
|
||||
versionSpec: '14.x'
|
||||
displayName: 'Install Node.js'
|
||||
- task: DownloadPipelineArtifact@2
|
||||
inputs:
|
||||
|
@ -59,7 +59,7 @@ stages:
|
|||
steps:
|
||||
- task: NodeTool@0
|
||||
inputs:
|
||||
versionSpec: '12.x'
|
||||
versionSpec: '14.x'
|
||||
displayName: 'Install Node.js'
|
||||
- task: DownloadPipelineArtifact@2
|
||||
inputs:
|
||||
|
@ -83,7 +83,7 @@ stages:
|
|||
steps:
|
||||
- task: NodeTool@0
|
||||
inputs:
|
||||
versionSpec: '12.x'
|
||||
versionSpec: '14.x'
|
||||
displayName: 'Install Node.js'
|
||||
- task: DownloadPipelineArtifact@2
|
||||
inputs:
|
||||
|
|
|
@ -3723,6 +3723,14 @@
|
|||
"resolved": "https://registry.npmjs.org/eslint-config-google/-/eslint-config-google-0.14.0.tgz",
|
||||
"integrity": "sha512-WsbX4WbjuMvTdeVL6+J3rK1RGhCTqjsFjX7UMSMgZiyxxaNLkoJENbrGExzERFeoTpGw3F3FypTiWAP9ZXzkEw=="
|
||||
},
|
||||
"eslint-plugin-custom-elements": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-custom-elements/-/eslint-plugin-custom-elements-0.0.2.tgz",
|
||||
"integrity": "sha512-lIRBhxh0M/1seyMzSPJwdfdNtlVSPArJ+erF2xqjPsd/6SdCuT43hCQNV2A2te3GqBWhgh/unXSVRO09c1kyPA==",
|
||||
"requires": {
|
||||
"eslint-rule-documentation": ">=1.0.0"
|
||||
}
|
||||
},
|
||||
"eslint-plugin-lit": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-lit/-/eslint-plugin-lit-1.3.0.tgz",
|
||||
|
@ -3733,6 +3741,11 @@
|
|||
"requireindex": "^1.2.0"
|
||||
}
|
||||
},
|
||||
"eslint-rule-documentation": {
|
||||
"version": "1.0.23",
|
||||
"resolved": "https://registry.npmjs.org/eslint-rule-documentation/-/eslint-rule-documentation-1.0.23.tgz",
|
||||
"integrity": "sha512-pWReu3fkohwyvztx/oQWWgld2iad25TfUdi6wvhhaDPIQjHU/pyvlKgXFw1kX31SQK2Nq9MH+vRDWB0ZLy8fYw=="
|
||||
},
|
||||
"eslint-scope": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz",
|
||||
|
|
|
@ -67,6 +67,7 @@
|
|||
"construct-style-sheets-polyfill": "^2.4.16",
|
||||
"eslint": "^7.25.0",
|
||||
"eslint-config-google": "^0.14.0",
|
||||
"eslint-plugin-custom-elements": "0.0.2",
|
||||
"eslint-plugin-lit": "^1.3.0",
|
||||
"flowchart.js": "^1.15.0",
|
||||
"lit-element": "^2.5.0",
|
||||
|
|
|
@ -272,7 +272,7 @@ body {
|
|||
.pf-c-login__main-header-desc {
|
||||
color: var(--ak-dark-foreground);
|
||||
}
|
||||
.pf-c-login__main-footer-links-item-link > img {
|
||||
.pf-c-login__main-footer-links-item img {
|
||||
filter: invert(1);
|
||||
}
|
||||
.pf-c-login__main-footer-band {
|
||||
|
|
|
@ -23,6 +23,7 @@ import "./stages/email/EmailStage";
|
|||
import "./stages/identification/IdentificationStage";
|
||||
import "./stages/password/PasswordStage";
|
||||
import "./stages/prompt/PromptStage";
|
||||
import "./sources/plex/PlexLoginInit";
|
||||
import { ShellChallenge, RedirectChallenge } from "../api/Flows";
|
||||
import { IdentificationChallenge } from "./stages/identification/IdentificationStage";
|
||||
import { PasswordChallenge } from "./stages/password/PasswordStage";
|
||||
|
@ -44,6 +45,7 @@ import { AccessDeniedChallenge } from "./access_denied/FlowAccessDenied";
|
|||
import { PFSize } from "../elements/Spinner";
|
||||
import { TITLE_DEFAULT } from "../constants";
|
||||
import { configureSentry } from "../api/Sentry";
|
||||
import { PlexAuthenticationChallenge } from "./sources/plex/PlexLoginInit";
|
||||
|
||||
@customElement("ak-flow-executor")
|
||||
export class FlowExecutor extends LitElement implements StageHost {
|
||||
|
@ -223,6 +225,8 @@ export class FlowExecutor extends LitElement implements StageHost {
|
|||
return html`<ak-stage-authenticator-webauthn .host=${this} .challenge=${this.challenge as WebAuthnAuthenticatorRegisterChallenge}></ak-stage-authenticator-webauthn>`;
|
||||
case "ak-stage-authenticator-validate":
|
||||
return html`<ak-stage-authenticator-validate .host=${this} .challenge=${this.challenge as AuthenticatorValidateStageChallenge}></ak-stage-authenticator-validate>`;
|
||||
case "ak-flow-sources-plex":
|
||||
return html`<ak-flow-sources-plex .host=${this} .challenge=${this.challenge as PlexAuthenticationChallenge}></ak-flow-sources-plex>`;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,95 @@
|
|||
import { VERSION } from "../../../constants";
|
||||
|
||||
export interface PlexPinResponse {
|
||||
// Only has the fields we care about
|
||||
authToken?: string;
|
||||
code: string;
|
||||
id: number;
|
||||
}
|
||||
|
||||
export interface PlexResource {
|
||||
name: string;
|
||||
provides: string;
|
||||
clientIdentifier: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_HEADERS = {
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"X-Plex-Product": "authentik",
|
||||
"X-Plex-Version": VERSION,
|
||||
"X-Plex-Device-Vendor": "BeryJu.org",
|
||||
};
|
||||
|
||||
export function popupCenterScreen(url: string, title: string, w: number, h: number): Window | null {
|
||||
const top = (screen.height - h) / 4, left = (screen.width - w) / 2;
|
||||
const popup = window.open(url, title, `scrollbars=yes,width=${w},height=${h},top=${top},left=${left}`);
|
||||
return popup;
|
||||
}
|
||||
|
||||
export class PlexAPIClient {
|
||||
|
||||
token: string;
|
||||
|
||||
constructor(token: string) {
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
static async getPin(clientIdentifier: string): Promise<{ authUrl: string, pin: PlexPinResponse }> {
|
||||
const headers = { ...DEFAULT_HEADERS, ...{
|
||||
"X-Plex-Client-Identifier": clientIdentifier
|
||||
}};
|
||||
const pinResponse = await fetch("https://plex.tv/api/v2/pins.json?strong=true", {
|
||||
method: "POST",
|
||||
headers: headers
|
||||
});
|
||||
const pin: PlexPinResponse = await pinResponse.json();
|
||||
return {
|
||||
authUrl: `https://app.plex.tv/auth#!?clientID=${encodeURIComponent(clientIdentifier)}&code=${pin.code}`,
|
||||
pin: pin
|
||||
};
|
||||
}
|
||||
|
||||
static async pinStatus(clientIdentifier: string, id: number): Promise<string | undefined> {
|
||||
const headers = { ...DEFAULT_HEADERS, ...{
|
||||
"X-Plex-Client-Identifier": clientIdentifier
|
||||
}};
|
||||
const pinResponse = await fetch(`https://plex.tv/api/v2/pins/${id}`, {
|
||||
headers: headers
|
||||
});
|
||||
const pin: PlexPinResponse = await pinResponse.json();
|
||||
return pin.authToken || "";
|
||||
}
|
||||
|
||||
static async pinPoll(clientIdentifier: string, id: number): Promise<string> {
|
||||
const executePoll = async (
|
||||
resolve: (authToken: string) => void,
|
||||
reject: (e: Error) => void
|
||||
) => {
|
||||
try {
|
||||
const response = await PlexAPIClient.pinStatus(clientIdentifier, id);
|
||||
|
||||
if (response) {
|
||||
resolve(response);
|
||||
} else {
|
||||
setTimeout(executePoll, 500, resolve, reject);
|
||||
}
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
};
|
||||
|
||||
return new Promise(executePoll);
|
||||
}
|
||||
|
||||
async getServers(): Promise<PlexResource[]> {
|
||||
const resourcesResponse = await fetch(`https://plex.tv/api/v2/resources?X-Plex-Token=${this.token}&X-Plex-Client-Identifier=authentik`, {
|
||||
headers: DEFAULT_HEADERS
|
||||
});
|
||||
const resources: PlexResource[] = await resourcesResponse.json();
|
||||
return resources.filter(r => {
|
||||
return r.provides === "server";
|
||||
});
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,69 @@
|
|||
import { t } from "@lingui/macro";
|
||||
import { Challenge } from "authentik-api";
|
||||
import PFLogin from "@patternfly/patternfly/components/Login/login.css";
|
||||
import PFForm from "@patternfly/patternfly/components/Form/form.css";
|
||||
import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css";
|
||||
import PFTitle from "@patternfly/patternfly/components/Title/title.css";
|
||||
import PFButton from "@patternfly/patternfly/components/Button/button.css";
|
||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
import AKGlobal from "../../../authentik.css";
|
||||
import { CSSResult, customElement, property } from "lit-element";
|
||||
import { html, TemplateResult } from "lit-html";
|
||||
import { BaseStage } from "../../stages/base";
|
||||
import { PlexAPIClient, popupCenterScreen } from "./API";
|
||||
import { DEFAULT_CONFIG } from "../../../api/Config";
|
||||
import { SourcesApi } from "authentik-api";
|
||||
|
||||
export interface PlexAuthenticationChallenge extends Challenge {
|
||||
|
||||
client_id: string;
|
||||
slug: string;
|
||||
|
||||
}
|
||||
|
||||
@customElement("ak-flow-sources-plex")
|
||||
export class PlexLoginInit extends BaseStage {
|
||||
|
||||
@property({ attribute: false })
|
||||
challenge?: PlexAuthenticationChallenge;
|
||||
|
||||
static get styles(): CSSResult[] {
|
||||
return [PFBase, PFLogin, PFForm, PFFormControl, PFButton, PFTitle, AKGlobal];
|
||||
}
|
||||
|
||||
async firstUpdated(): Promise<void> {
|
||||
const authInfo = await PlexAPIClient.getPin(this.challenge?.client_id || "");
|
||||
const authWindow = popupCenterScreen(authInfo.authUrl, "plex auth", 550, 700);
|
||||
PlexAPIClient.pinPoll(this.challenge?.client_id || "", authInfo.pin.id).then(token => {
|
||||
authWindow?.close();
|
||||
new SourcesApi(DEFAULT_CONFIG).sourcesPlexRedeemToken({
|
||||
data: {
|
||||
plexToken: token,
|
||||
},
|
||||
slug: this.challenge?.slug || "",
|
||||
}).then(r => {
|
||||
window.location.assign(r.to);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
return html`<header class="pf-c-login__main-header">
|
||||
<h1 class="pf-c-title pf-m-3xl">
|
||||
${t`Authenticating with Plex...`}
|
||||
</h1>
|
||||
</header>
|
||||
<div class="pf-c-login__main-body">
|
||||
<form class="pf-c-form">
|
||||
<ak-empty-state
|
||||
?loading="${true}">
|
||||
</ak-empty-state>
|
||||
</form>
|
||||
</div>
|
||||
<footer class="pf-c-login__main-footer">
|
||||
<ul class="pf-c-login__main-footer-links">
|
||||
</ul>
|
||||
</footer>`;
|
||||
}
|
||||
|
||||
}
|
|
@ -1,6 +1,8 @@
|
|||
import { Challenge } from "authentik-api";
|
||||
import { LitElement } from "lit-element";
|
||||
|
||||
export interface StageHost {
|
||||
challenge?: Challenge;
|
||||
submit<T>(formData?: T): Promise<void>;
|
||||
}
|
||||
|
||||
|
|
|
@ -35,7 +35,7 @@ export interface IdentificationChallenge extends Challenge {
|
|||
|
||||
export interface UILoginButton {
|
||||
name: string;
|
||||
url: string;
|
||||
challenge: Challenge;
|
||||
icon_url?: string;
|
||||
}
|
||||
|
||||
|
@ -49,7 +49,11 @@ export class IdentificationStage extends BaseStage {
|
|||
return [PFBase, PFLogin, PFForm, PFFormControl, PFTitle, PFButton, AKGlobal].concat(
|
||||
css`
|
||||
/* login page's icons */
|
||||
.pf-c-login__main-footer-links-item-link img {
|
||||
.pf-c-login__main-footer-links-item button {
|
||||
background-color: transparent;
|
||||
border: 0;
|
||||
}
|
||||
.pf-c-login__main-footer-links-item img {
|
||||
fill: var(--pf-c-login__main-footer-links-item-link-svg--Fill);
|
||||
width: 100px;
|
||||
max-width: var(--pf-c-login__main-footer-links-item-link-svg--Width);
|
||||
|
@ -131,9 +135,12 @@ export class IdentificationStage extends BaseStage {
|
|||
icon = html`<img src="${source.icon_url}" alt="${source.name}">`;
|
||||
}
|
||||
return html`<li class="pf-c-login__main-footer-links-item">
|
||||
<a href="${source.url}" class="pf-c-login__main-footer-links-item-link">
|
||||
<button type="button" @click=${() => {
|
||||
if (!this.host) return;
|
||||
this.host.challenge = source.challenge;
|
||||
}}>
|
||||
${icon}
|
||||
</a>
|
||||
</button>
|
||||
</li>`;
|
||||
}
|
||||
|
||||
|
|
|
@ -156,6 +156,10 @@ msgstr "Allow users to use Applications based on properties, enforce Password Cr
|
|||
msgid "Allowed count"
|
||||
msgstr "Allowed count"
|
||||
|
||||
#: src/pages/sources/plex/PlexSourceForm.ts:119
|
||||
msgid "Allowed servers"
|
||||
msgstr "Allowed servers"
|
||||
|
||||
#: src/pages/sources/saml/SAMLSourceForm.ts:144
|
||||
msgid "Allows authentication flows initiated by the IdP. This can be a security risk, as no validation of the request ID is done."
|
||||
msgstr "Allows authentication flows initiated by the IdP. This can be a security risk, as no validation of the request ID is done."
|
||||
|
@ -277,11 +281,16 @@ msgstr "Attributes"
|
|||
msgid "Audience"
|
||||
msgstr "Audience"
|
||||
|
||||
#: src/flows/sources/plex/PlexLoginInit.ts:56
|
||||
msgid "Authenticating with Plex..."
|
||||
msgstr "Authenticating with Plex..."
|
||||
|
||||
#: src/pages/flows/FlowForm.ts:55
|
||||
msgid "Authentication"
|
||||
msgstr "Authentication"
|
||||
|
||||
#: src/pages/sources/oauth/OAuthSourceForm.ts:189
|
||||
#: src/pages/sources/plex/PlexSourceForm.ts:149
|
||||
#: src/pages/sources/saml/SAMLSourceForm.ts:245
|
||||
msgid "Authentication flow"
|
||||
msgstr "Authentication flow"
|
||||
|
@ -395,8 +404,8 @@ msgstr "Binding Type"
|
|||
msgid "Build hash: {0}"
|
||||
msgstr "Build hash: {0}"
|
||||
|
||||
#: src/pages/sources/SourcesListPage.ts:103
|
||||
#: src/pages/sources/SourcesListPage.ts:105
|
||||
#: src/pages/sources/SourcesListPage.ts:104
|
||||
#: src/pages/sources/SourcesListPage.ts:106
|
||||
msgid "Built-in"
|
||||
msgstr "Built-in"
|
||||
|
||||
|
@ -544,6 +553,7 @@ msgstr "Click to copy token"
|
|||
|
||||
#: src/pages/providers/oauth2/OAuth2ProviderForm.ts:107
|
||||
#: src/pages/providers/oauth2/OAuth2ProviderViewPage.ts:99
|
||||
#: src/pages/sources/plex/PlexSourceForm.ts:113
|
||||
msgid "Client ID"
|
||||
msgstr "Client ID"
|
||||
|
||||
|
@ -744,8 +754,8 @@ msgstr "Copy Key"
|
|||
#: src/pages/providers/ProviderListPage.ts:116
|
||||
#: src/pages/providers/RelatedApplicationButton.ts:27
|
||||
#: src/pages/providers/RelatedApplicationButton.ts:35
|
||||
#: src/pages/sources/SourcesListPage.ts:113
|
||||
#: src/pages/sources/SourcesListPage.ts:122
|
||||
#: src/pages/sources/SourcesListPage.ts:114
|
||||
#: src/pages/sources/SourcesListPage.ts:123
|
||||
#: src/pages/stages/StageListPage.ts:119
|
||||
#: src/pages/stages/StageListPage.ts:128
|
||||
#: src/pages/stages/invitation/InvitationListPage.ts:77
|
||||
|
@ -842,7 +852,7 @@ msgstr "Create provider"
|
|||
#: src/pages/policies/PolicyListPage.ts:136
|
||||
#: src/pages/property-mappings/PropertyMappingListPage.ts:125
|
||||
#: src/pages/providers/ProviderListPage.ts:119
|
||||
#: src/pages/sources/SourcesListPage.ts:125
|
||||
#: src/pages/sources/SourcesListPage.ts:126
|
||||
#: src/pages/stages/StageListPage.ts:131
|
||||
msgid "Create {0}"
|
||||
msgstr "Create {0}"
|
||||
|
@ -898,7 +908,7 @@ msgstr "Define how notifications are sent to users, like Email or Webhook."
|
|||
#: src/pages/policies/PolicyListPage.ts:115
|
||||
#: src/pages/property-mappings/PropertyMappingListPage.ts:104
|
||||
#: src/pages/providers/ProviderListPage.ts:98
|
||||
#: src/pages/sources/SourcesListPage.ts:94
|
||||
#: src/pages/sources/SourcesListPage.ts:95
|
||||
#: src/pages/stages/StageListPage.ts:110
|
||||
#: src/pages/stages/invitation/InvitationListPage.ts:68
|
||||
#: src/pages/stages/prompt/PromptListPage.ts:87
|
||||
|
@ -1008,7 +1018,7 @@ msgstr "Disable Static Tokens"
|
|||
msgid "Disable Time-based OTP"
|
||||
msgstr "Disable Time-based OTP"
|
||||
|
||||
#: src/pages/sources/SourcesListPage.ts:63
|
||||
#: src/pages/sources/SourcesListPage.ts:64
|
||||
msgid "Disabled"
|
||||
msgstr "Disabled"
|
||||
|
||||
|
@ -1049,7 +1059,7 @@ msgstr "Each provider has a different issuer, based on the application slug."
|
|||
#: src/pages/providers/oauth2/OAuth2ProviderViewPage.ts:128
|
||||
#: src/pages/providers/proxy/ProxyProviderViewPage.ts:127
|
||||
#: src/pages/providers/saml/SAMLProviderViewPage.ts:121
|
||||
#: src/pages/sources/SourcesListPage.ts:82
|
||||
#: src/pages/sources/SourcesListPage.ts:83
|
||||
#: src/pages/sources/ldap/LDAPSourceViewPage.ts:105
|
||||
#: src/pages/sources/oauth/OAuthSourceViewPage.ts:125
|
||||
#: src/pages/sources/saml/SAMLSourceViewPage.ts:111
|
||||
|
@ -1086,7 +1096,7 @@ msgstr "Edit User"
|
|||
msgid "Either no applications are defined, or you don't have access to any."
|
||||
msgstr "Either no applications are defined, or you don't have access to any."
|
||||
|
||||
#: src/flows/stages/identification/IdentificationStage.ts:138
|
||||
#: src/flows/stages/identification/IdentificationStage.ts:146
|
||||
#: src/pages/events/TransportForm.ts:46
|
||||
#: src/pages/stages/identification/IdentificationStageForm.ts:81
|
||||
#: src/pages/user-settings/UserDetailsPage.ts:71
|
||||
|
@ -1099,7 +1109,7 @@ msgstr "Email"
|
|||
msgid "Email address"
|
||||
msgstr "Email address"
|
||||
|
||||
#: src/flows/stages/identification/IdentificationStage.ts:145
|
||||
#: src/flows/stages/identification/IdentificationStage.ts:153
|
||||
msgid "Email or username"
|
||||
msgstr "Email or username"
|
||||
|
||||
|
@ -1136,6 +1146,7 @@ msgstr "Enable this if you don't want to use this provider as a proxy, and want
|
|||
#: src/pages/policies/PolicyBindingForm.ts:199
|
||||
#: src/pages/sources/ldap/LDAPSourceForm.ts:69
|
||||
#: src/pages/sources/oauth/OAuthSourceForm.ts:115
|
||||
#: src/pages/sources/plex/PlexSourceForm.ts:102
|
||||
#: src/pages/sources/saml/SAMLSourceForm.ts:69
|
||||
msgid "Enabled"
|
||||
msgstr "Enabled"
|
||||
|
@ -1145,6 +1156,7 @@ msgid "Enrollment"
|
|||
msgstr "Enrollment"
|
||||
|
||||
#: src/pages/sources/oauth/OAuthSourceForm.ts:210
|
||||
#: src/pages/sources/plex/PlexSourceForm.ts:170
|
||||
#: src/pages/sources/saml/SAMLSourceForm.ts:266
|
||||
#: src/pages/stages/identification/IdentificationStageForm.ts:106
|
||||
msgid "Enrollment flow"
|
||||
|
@ -1357,16 +1369,19 @@ msgid "Flow Overview"
|
|||
msgstr "Flow Overview"
|
||||
|
||||
#: src/pages/sources/oauth/OAuthSourceForm.ts:185
|
||||
#: src/pages/sources/plex/PlexSourceForm.ts:145
|
||||
#: src/pages/sources/saml/SAMLSourceForm.ts:220
|
||||
msgid "Flow settings"
|
||||
msgstr "Flow settings"
|
||||
|
||||
#: src/pages/sources/oauth/OAuthSourceForm.ts:207
|
||||
#: src/pages/sources/plex/PlexSourceForm.ts:167
|
||||
#: src/pages/sources/saml/SAMLSourceForm.ts:263
|
||||
msgid "Flow to use when authenticating existing users."
|
||||
msgstr "Flow to use when authenticating existing users."
|
||||
|
||||
#: src/pages/sources/oauth/OAuthSourceForm.ts:228
|
||||
#: src/pages/sources/plex/PlexSourceForm.ts:188
|
||||
#: src/pages/sources/saml/SAMLSourceForm.ts:284
|
||||
msgid "Flow to use when enrolling new users."
|
||||
msgstr "Flow to use when enrolling new users."
|
||||
|
@ -1410,7 +1425,7 @@ msgstr "Force the user to configure an authenticator"
|
|||
msgid "Forgot password?"
|
||||
msgstr "Forgot password?"
|
||||
|
||||
#: src/flows/stages/identification/IdentificationStage.ts:124
|
||||
#: src/flows/stages/identification/IdentificationStage.ts:132
|
||||
msgid "Forgot username or password?"
|
||||
msgstr "Forgot username or password?"
|
||||
|
||||
|
@ -1510,6 +1525,7 @@ msgstr "Hide managed mappings"
|
|||
#: src/pages/providers/saml/SAMLProviderForm.ts:177
|
||||
#: src/pages/sources/ldap/LDAPSourceForm.ts:167
|
||||
#: src/pages/sources/ldap/LDAPSourceForm.ts:193
|
||||
#: src/pages/sources/plex/PlexSourceForm.ts:132
|
||||
#: src/pages/stages/authenticator_validate/AuthenticatorValidateStageForm.ts:114
|
||||
#: src/pages/stages/identification/IdentificationStageForm.ts:85
|
||||
#: src/pages/stages/password/PasswordStageForm.ts:86
|
||||
|
@ -1692,9 +1708,13 @@ msgstr "Let the user identify themselves with their username or Email address."
|
|||
msgid "Library"
|
||||
msgstr "Library"
|
||||
|
||||
#: src/pages/sources/plex/PlexSourceForm.ts:137
|
||||
msgid "Load servers"
|
||||
msgstr "Load servers"
|
||||
|
||||
#: src/elements/table/Table.ts:120
|
||||
#: src/flows/FlowExecutor.ts:167
|
||||
#: src/flows/FlowExecutor.ts:213
|
||||
#: src/flows/FlowExecutor.ts:168
|
||||
#: src/flows/FlowExecutor.ts:216
|
||||
#: src/flows/access_denied/FlowAccessDenied.ts:27
|
||||
#: src/flows/stages/authenticator_static/AuthenticatorStaticStage.ts:43
|
||||
#: src/flows/stages/authenticator_totp/AuthenticatorTOTPStage.ts:33
|
||||
|
@ -1705,7 +1725,7 @@ msgstr "Library"
|
|||
#: src/flows/stages/consent/ConsentStage.ts:28
|
||||
#: src/flows/stages/dummy/DummyStage.ts:27
|
||||
#: src/flows/stages/email/EmailStage.ts:26
|
||||
#: src/flows/stages/identification/IdentificationStage.ts:171
|
||||
#: src/flows/stages/identification/IdentificationStage.ts:179
|
||||
#: src/flows/stages/password/PasswordStage.ts:31
|
||||
#: src/flows/stages/prompt/PromptStage.ts:126
|
||||
#: src/pages/applications/ApplicationViewPage.ts:43
|
||||
|
@ -1750,6 +1770,8 @@ msgstr "Loading"
|
|||
#: src/pages/sources/oauth/OAuthSourceForm.ts:177
|
||||
#: src/pages/sources/oauth/OAuthSourceForm.ts:205
|
||||
#: src/pages/sources/oauth/OAuthSourceForm.ts:226
|
||||
#: src/pages/sources/plex/PlexSourceForm.ts:165
|
||||
#: src/pages/sources/plex/PlexSourceForm.ts:186
|
||||
#: src/pages/sources/saml/SAMLSourceForm.ts:126
|
||||
#: src/pages/sources/saml/SAMLSourceForm.ts:240
|
||||
#: src/pages/sources/saml/SAMLSourceForm.ts:261
|
||||
|
@ -1780,7 +1802,7 @@ msgstr "Log the currently pending user in."
|
|||
msgid "Login password is synced from LDAP into authentik automatically. Enable this option only to write password changes in authentik back to LDAP."
|
||||
msgstr "Login password is synced from LDAP into authentik automatically. Enable this option only to write password changes in authentik back to LDAP."
|
||||
|
||||
#: src/flows/stages/identification/IdentificationStage.ts:183
|
||||
#: src/flows/stages/identification/IdentificationStage.ts:191
|
||||
msgid "Login to continue to {0}."
|
||||
msgstr "Login to continue to {0}."
|
||||
|
||||
|
@ -1913,11 +1935,12 @@ msgstr "Monitor"
|
|||
#: src/pages/providers/saml/SAMLProviderForm.ts:53
|
||||
#: src/pages/providers/saml/SAMLProviderImportForm.ts:38
|
||||
#: src/pages/providers/saml/SAMLProviderViewPage.ts:66
|
||||
#: src/pages/sources/SourcesListPage.ts:51
|
||||
#: src/pages/sources/SourcesListPage.ts:52
|
||||
#: src/pages/sources/ldap/LDAPSourceForm.ts:54
|
||||
#: src/pages/sources/ldap/LDAPSourceViewPage.ts:64
|
||||
#: src/pages/sources/oauth/OAuthSourceForm.ts:100
|
||||
#: src/pages/sources/oauth/OAuthSourceViewPage.ts:64
|
||||
#: src/pages/sources/plex/PlexSourceForm.ts:87
|
||||
#: src/pages/sources/saml/SAMLSourceForm.ts:54
|
||||
#: src/pages/sources/saml/SAMLSourceViewPage.ts:66
|
||||
#: src/pages/stages/StageListPage.ts:65
|
||||
|
@ -1957,7 +1980,7 @@ msgstr "NameID Policy"
|
|||
msgid "NameID Property Mapping"
|
||||
msgstr "NameID Property Mapping"
|
||||
|
||||
#: src/flows/stages/identification/IdentificationStage.ts:119
|
||||
#: src/flows/stages/identification/IdentificationStage.ts:127
|
||||
msgid "Need an account?"
|
||||
msgstr "Need an account?"
|
||||
|
||||
|
@ -2348,7 +2371,7 @@ msgstr "Post binding"
|
|||
msgid "Post binding (auto-submit)"
|
||||
msgstr "Post binding (auto-submit)"
|
||||
|
||||
#: src/flows/FlowExecutor.ts:255
|
||||
#: src/flows/FlowExecutor.ts:258
|
||||
msgid "Powered by authentik"
|
||||
msgstr "Powered by authentik"
|
||||
|
||||
|
@ -2412,6 +2435,7 @@ msgstr "Property mappings used to user creation."
|
|||
#: src/pages/providers/proxy/ProxyProviderForm.ts:123
|
||||
#: src/pages/providers/saml/SAMLProviderForm.ts:78
|
||||
#: src/pages/sources/oauth/OAuthSourceForm.ts:122
|
||||
#: src/pages/sources/plex/PlexSourceForm.ts:109
|
||||
#: src/pages/sources/saml/SAMLSourceForm.ts:76
|
||||
msgid "Protocol settings"
|
||||
msgstr "Protocol settings"
|
||||
|
@ -2602,7 +2626,7 @@ msgstr "Retry Task"
|
|||
msgid "Retry authentication"
|
||||
msgstr "Retry authentication"
|
||||
|
||||
#: src/flows/FlowExecutor.ts:145
|
||||
#: src/flows/FlowExecutor.ts:146
|
||||
msgid "Return"
|
||||
msgstr "Return"
|
||||
|
||||
|
@ -2710,7 +2734,7 @@ msgstr "Select all rows"
|
|||
msgid "Select an identification method."
|
||||
msgstr "Select an identification method."
|
||||
|
||||
#: src/flows/stages/identification/IdentificationStage.ts:134
|
||||
#: src/flows/stages/identification/IdentificationStage.ts:142
|
||||
msgid "Select one of the sources below to login."
|
||||
msgstr "Select one of the sources below to login."
|
||||
|
||||
|
@ -2722,6 +2746,10 @@ msgstr "Select users to add"
|
|||
msgid "Select which scopes can be used by the client. The client stil has to specify the scope to access the data."
|
||||
msgstr "Select which scopes can be used by the client. The client stil has to specify the scope to access the data."
|
||||
|
||||
#: src/pages/sources/plex/PlexSourceForm.ts:131
|
||||
msgid "Select which server a user has to be a member of to be allowed to authenticate."
|
||||
msgstr "Select which server a user has to be a member of to be allowed to authenticate."
|
||||
|
||||
#: src/pages/events/RuleForm.ts:92
|
||||
msgid "Select which transports should be used to notify the user. If none are selected, the notification will only be shown in the authentik UI."
|
||||
msgstr "Select which transports should be used to notify the user. If none are selected, the notification will only be shown in the authentik UI."
|
||||
|
@ -2820,7 +2848,7 @@ msgstr "Show matched user"
|
|||
msgid "Shown as the Title in Flow pages."
|
||||
msgstr "Shown as the Title in Flow pages."
|
||||
|
||||
#: src/flows/stages/identification/IdentificationStage.ts:120
|
||||
#: src/flows/stages/identification/IdentificationStage.ts:128
|
||||
msgid "Sign up."
|
||||
msgstr "Sign up."
|
||||
|
||||
|
@ -2854,16 +2882,17 @@ msgstr "Skip path regex"
|
|||
#: src/pages/flows/FlowForm.ts:94
|
||||
#: src/pages/sources/ldap/LDAPSourceForm.ts:60
|
||||
#: src/pages/sources/oauth/OAuthSourceForm.ts:106
|
||||
#: src/pages/sources/plex/PlexSourceForm.ts:93
|
||||
#: src/pages/sources/saml/SAMLSourceForm.ts:60
|
||||
msgid "Slug"
|
||||
msgstr "Slug"
|
||||
|
||||
#: src/flows/FlowExecutor.ts:138
|
||||
#: src/flows/FlowExecutor.ts:139
|
||||
msgid "Something went wrong! Please try again later."
|
||||
msgstr "Something went wrong! Please try again later."
|
||||
|
||||
#: src/pages/providers/ProviderListPage.ts:91
|
||||
#: src/pages/sources/SourcesListPage.ts:87
|
||||
#: src/pages/sources/SourcesListPage.ts:88
|
||||
msgid "Source"
|
||||
msgstr "Source"
|
||||
|
||||
|
@ -2872,11 +2901,11 @@ msgid "Source {0}"
|
|||
msgstr "Source {0}"
|
||||
|
||||
#: src/interfaces/AdminInterface.ts:20
|
||||
#: src/pages/sources/SourcesListPage.ts:30
|
||||
#: src/pages/sources/SourcesListPage.ts:31
|
||||
msgid "Sources"
|
||||
msgstr "Sources"
|
||||
|
||||
#: src/pages/sources/SourcesListPage.ts:33
|
||||
#: src/pages/sources/SourcesListPage.ts:34
|
||||
msgid "Sources of identities, which can either be synced into authentik's database, like LDAP, or can be used by users to authenticate and enroll themselves, like OAuth and social logins"
|
||||
msgstr "Sources of identities, which can either be synced into authentik's database, like LDAP, or can be used by users to authenticate and enroll themselves, like OAuth and social logins"
|
||||
|
||||
|
@ -3073,6 +3102,7 @@ msgstr "Successfully created service-connection."
|
|||
|
||||
#: src/pages/sources/ldap/LDAPSourceForm.ts:47
|
||||
#: src/pages/sources/oauth/OAuthSourceForm.ts:51
|
||||
#: src/pages/sources/plex/PlexSourceForm.ts:60
|
||||
#: src/pages/sources/saml/SAMLSourceForm.ts:47
|
||||
msgid "Successfully created source."
|
||||
msgstr "Successfully created source."
|
||||
|
@ -3209,6 +3239,7 @@ msgstr "Successfully updated service-connection."
|
|||
|
||||
#: src/pages/sources/ldap/LDAPSourceForm.ts:44
|
||||
#: src/pages/sources/oauth/OAuthSourceForm.ts:48
|
||||
#: src/pages/sources/plex/PlexSourceForm.ts:57
|
||||
#: src/pages/sources/saml/SAMLSourceForm.ts:44
|
||||
msgid "Successfully updated source."
|
||||
msgstr "Successfully updated source."
|
||||
|
@ -3464,7 +3495,7 @@ msgstr "Transports"
|
|||
#: src/pages/policies/PolicyListPage.ts:57
|
||||
#: src/pages/property-mappings/PropertyMappingListPage.ts:55
|
||||
#: src/pages/providers/ProviderListPage.ts:54
|
||||
#: src/pages/sources/SourcesListPage.ts:52
|
||||
#: src/pages/sources/SourcesListPage.ts:53
|
||||
#: src/pages/stages/prompt/PromptForm.ts:97
|
||||
#: src/pages/stages/prompt/PromptListPage.ts:48
|
||||
msgid "Type"
|
||||
|
@ -3543,7 +3574,7 @@ msgstr "Up-to-date!"
|
|||
#: src/pages/providers/oauth2/OAuth2ProviderViewPage.ts:118
|
||||
#: src/pages/providers/proxy/ProxyProviderViewPage.ts:117
|
||||
#: src/pages/providers/saml/SAMLProviderViewPage.ts:111
|
||||
#: src/pages/sources/SourcesListPage.ts:69
|
||||
#: src/pages/sources/SourcesListPage.ts:70
|
||||
#: src/pages/sources/ldap/LDAPSourceViewPage.ts:95
|
||||
#: src/pages/sources/oauth/OAuthSourceViewPage.ts:115
|
||||
#: src/pages/sources/saml/SAMLSourceViewPage.ts:101
|
||||
|
@ -3646,7 +3677,7 @@ msgstr "Update details"
|
|||
#: src/pages/policies/PolicyListPage.ts:80
|
||||
#: src/pages/property-mappings/PropertyMappingListPage.ts:69
|
||||
#: src/pages/providers/ProviderListPage.ts:76
|
||||
#: src/pages/sources/SourcesListPage.ts:72
|
||||
#: src/pages/sources/SourcesListPage.ts:73
|
||||
#: src/pages/stages/StageListPage.ts:88
|
||||
#: src/pages/users/UserActiveForm.ts:41
|
||||
msgid "Update {0}"
|
||||
|
@ -3750,7 +3781,7 @@ msgstr "User/Group Attribute used for the user part of the HTTP-Basic Header. If
|
|||
msgid "Userinfo URL"
|
||||
msgstr "Userinfo URL"
|
||||
|
||||
#: src/flows/stages/identification/IdentificationStage.ts:142
|
||||
#: src/flows/stages/identification/IdentificationStage.ts:150
|
||||
#: src/pages/stages/identification/IdentificationStageForm.ts:78
|
||||
#: src/pages/user-settings/UserDetailsPage.ts:57
|
||||
#: src/pages/users/UserForm.ts:47
|
||||
|
@ -3903,7 +3934,7 @@ msgstr "When selected, incoming assertion's Signatures will be validated against
|
|||
msgid "When this option is enabled, all executions of this policy will be logged. By default, only execution errors are logged."
|
||||
msgstr "When this option is enabled, all executions of this policy will be logged. By default, only execution errors are logged."
|
||||
|
||||
#: src/flows/FlowExecutor.ts:134
|
||||
#: src/flows/FlowExecutor.ts:135
|
||||
msgid "Whoops!"
|
||||
msgstr "Whoops!"
|
||||
|
||||
|
|
|
@ -156,6 +156,10 @@ msgstr ""
|
|||
msgid "Allowed count"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/sources/plex/PlexSourceForm.ts:119
|
||||
msgid "Allowed servers"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/sources/saml/SAMLSourceForm.ts:144
|
||||
msgid "Allows authentication flows initiated by the IdP. This can be a security risk, as no validation of the request ID is done."
|
||||
msgstr ""
|
||||
|
@ -273,11 +277,16 @@ msgstr ""
|
|||
msgid "Audience"
|
||||
msgstr ""
|
||||
|
||||
#: src/flows/sources/plex/PlexLoginInit.ts:56
|
||||
msgid "Authenticating with Plex..."
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/flows/FlowForm.ts:55
|
||||
msgid "Authentication"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/sources/oauth/OAuthSourceForm.ts:189
|
||||
#: src/pages/sources/plex/PlexSourceForm.ts:149
|
||||
#: src/pages/sources/saml/SAMLSourceForm.ts:245
|
||||
msgid "Authentication flow"
|
||||
msgstr ""
|
||||
|
@ -391,8 +400,8 @@ msgstr ""
|
|||
msgid "Build hash: {0}"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/sources/SourcesListPage.ts:103
|
||||
#: src/pages/sources/SourcesListPage.ts:105
|
||||
#: src/pages/sources/SourcesListPage.ts:104
|
||||
#: src/pages/sources/SourcesListPage.ts:106
|
||||
msgid "Built-in"
|
||||
msgstr ""
|
||||
|
||||
|
@ -538,6 +547,7 @@ msgstr ""
|
|||
|
||||
#: src/pages/providers/oauth2/OAuth2ProviderForm.ts:107
|
||||
#: src/pages/providers/oauth2/OAuth2ProviderViewPage.ts:99
|
||||
#: src/pages/sources/plex/PlexSourceForm.ts:113
|
||||
msgid "Client ID"
|
||||
msgstr ""
|
||||
|
||||
|
@ -738,8 +748,8 @@ msgstr ""
|
|||
#: src/pages/providers/ProviderListPage.ts:116
|
||||
#: src/pages/providers/RelatedApplicationButton.ts:27
|
||||
#: src/pages/providers/RelatedApplicationButton.ts:35
|
||||
#: src/pages/sources/SourcesListPage.ts:113
|
||||
#: src/pages/sources/SourcesListPage.ts:122
|
||||
#: src/pages/sources/SourcesListPage.ts:114
|
||||
#: src/pages/sources/SourcesListPage.ts:123
|
||||
#: src/pages/stages/StageListPage.ts:119
|
||||
#: src/pages/stages/StageListPage.ts:128
|
||||
#: src/pages/stages/invitation/InvitationListPage.ts:77
|
||||
|
@ -836,7 +846,7 @@ msgstr ""
|
|||
#: src/pages/policies/PolicyListPage.ts:136
|
||||
#: src/pages/property-mappings/PropertyMappingListPage.ts:125
|
||||
#: src/pages/providers/ProviderListPage.ts:119
|
||||
#: src/pages/sources/SourcesListPage.ts:125
|
||||
#: src/pages/sources/SourcesListPage.ts:126
|
||||
#: src/pages/stages/StageListPage.ts:131
|
||||
msgid "Create {0}"
|
||||
msgstr ""
|
||||
|
@ -892,7 +902,7 @@ msgstr ""
|
|||
#: src/pages/policies/PolicyListPage.ts:115
|
||||
#: src/pages/property-mappings/PropertyMappingListPage.ts:104
|
||||
#: src/pages/providers/ProviderListPage.ts:98
|
||||
#: src/pages/sources/SourcesListPage.ts:94
|
||||
#: src/pages/sources/SourcesListPage.ts:95
|
||||
#: src/pages/stages/StageListPage.ts:110
|
||||
#: src/pages/stages/invitation/InvitationListPage.ts:68
|
||||
#: src/pages/stages/prompt/PromptListPage.ts:87
|
||||
|
@ -1000,7 +1010,7 @@ msgstr ""
|
|||
msgid "Disable Time-based OTP"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/sources/SourcesListPage.ts:63
|
||||
#: src/pages/sources/SourcesListPage.ts:64
|
||||
msgid "Disabled"
|
||||
msgstr ""
|
||||
|
||||
|
@ -1041,7 +1051,7 @@ msgstr ""
|
|||
#: src/pages/providers/oauth2/OAuth2ProviderViewPage.ts:128
|
||||
#: src/pages/providers/proxy/ProxyProviderViewPage.ts:127
|
||||
#: src/pages/providers/saml/SAMLProviderViewPage.ts:121
|
||||
#: src/pages/sources/SourcesListPage.ts:82
|
||||
#: src/pages/sources/SourcesListPage.ts:83
|
||||
#: src/pages/sources/ldap/LDAPSourceViewPage.ts:105
|
||||
#: src/pages/sources/oauth/OAuthSourceViewPage.ts:125
|
||||
#: src/pages/sources/saml/SAMLSourceViewPage.ts:111
|
||||
|
@ -1078,7 +1088,7 @@ msgstr ""
|
|||
msgid "Either no applications are defined, or you don't have access to any."
|
||||
msgstr ""
|
||||
|
||||
#: src/flows/stages/identification/IdentificationStage.ts:138
|
||||
#: src/flows/stages/identification/IdentificationStage.ts:146
|
||||
#: src/pages/events/TransportForm.ts:46
|
||||
#: src/pages/stages/identification/IdentificationStageForm.ts:81
|
||||
#: src/pages/user-settings/UserDetailsPage.ts:71
|
||||
|
@ -1091,7 +1101,7 @@ msgstr ""
|
|||
msgid "Email address"
|
||||
msgstr ""
|
||||
|
||||
#: src/flows/stages/identification/IdentificationStage.ts:145
|
||||
#: src/flows/stages/identification/IdentificationStage.ts:153
|
||||
msgid "Email or username"
|
||||
msgstr ""
|
||||
|
||||
|
@ -1128,6 +1138,7 @@ msgstr ""
|
|||
#: src/pages/policies/PolicyBindingForm.ts:199
|
||||
#: src/pages/sources/ldap/LDAPSourceForm.ts:69
|
||||
#: src/pages/sources/oauth/OAuthSourceForm.ts:115
|
||||
#: src/pages/sources/plex/PlexSourceForm.ts:102
|
||||
#: src/pages/sources/saml/SAMLSourceForm.ts:69
|
||||
msgid "Enabled"
|
||||
msgstr ""
|
||||
|
@ -1137,6 +1148,7 @@ msgid "Enrollment"
|
|||
msgstr ""
|
||||
|
||||
#: src/pages/sources/oauth/OAuthSourceForm.ts:210
|
||||
#: src/pages/sources/plex/PlexSourceForm.ts:170
|
||||
#: src/pages/sources/saml/SAMLSourceForm.ts:266
|
||||
#: src/pages/stages/identification/IdentificationStageForm.ts:106
|
||||
msgid "Enrollment flow"
|
||||
|
@ -1349,16 +1361,19 @@ msgid "Flow Overview"
|
|||
msgstr ""
|
||||
|
||||
#: src/pages/sources/oauth/OAuthSourceForm.ts:185
|
||||
#: src/pages/sources/plex/PlexSourceForm.ts:145
|
||||
#: src/pages/sources/saml/SAMLSourceForm.ts:220
|
||||
msgid "Flow settings"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/sources/oauth/OAuthSourceForm.ts:207
|
||||
#: src/pages/sources/plex/PlexSourceForm.ts:167
|
||||
#: src/pages/sources/saml/SAMLSourceForm.ts:263
|
||||
msgid "Flow to use when authenticating existing users."
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/sources/oauth/OAuthSourceForm.ts:228
|
||||
#: src/pages/sources/plex/PlexSourceForm.ts:188
|
||||
#: src/pages/sources/saml/SAMLSourceForm.ts:284
|
||||
msgid "Flow to use when enrolling new users."
|
||||
msgstr ""
|
||||
|
@ -1402,7 +1417,7 @@ msgstr ""
|
|||
msgid "Forgot password?"
|
||||
msgstr ""
|
||||
|
||||
#: src/flows/stages/identification/IdentificationStage.ts:124
|
||||
#: src/flows/stages/identification/IdentificationStage.ts:132
|
||||
msgid "Forgot username or password?"
|
||||
msgstr ""
|
||||
|
||||
|
@ -1502,6 +1517,7 @@ msgstr ""
|
|||
#: src/pages/providers/saml/SAMLProviderForm.ts:177
|
||||
#: src/pages/sources/ldap/LDAPSourceForm.ts:167
|
||||
#: src/pages/sources/ldap/LDAPSourceForm.ts:193
|
||||
#: src/pages/sources/plex/PlexSourceForm.ts:132
|
||||
#: src/pages/stages/authenticator_validate/AuthenticatorValidateStageForm.ts:114
|
||||
#: src/pages/stages/identification/IdentificationStageForm.ts:85
|
||||
#: src/pages/stages/password/PasswordStageForm.ts:86
|
||||
|
@ -1684,9 +1700,13 @@ msgstr ""
|
|||
msgid "Library"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/sources/plex/PlexSourceForm.ts:137
|
||||
msgid "Load servers"
|
||||
msgstr ""
|
||||
|
||||
#: src/elements/table/Table.ts:120
|
||||
#: src/flows/FlowExecutor.ts:167
|
||||
#: src/flows/FlowExecutor.ts:213
|
||||
#: src/flows/FlowExecutor.ts:168
|
||||
#: src/flows/FlowExecutor.ts:216
|
||||
#: src/flows/access_denied/FlowAccessDenied.ts:27
|
||||
#: src/flows/stages/authenticator_static/AuthenticatorStaticStage.ts:43
|
||||
#: src/flows/stages/authenticator_totp/AuthenticatorTOTPStage.ts:33
|
||||
|
@ -1697,7 +1717,7 @@ msgstr ""
|
|||
#: src/flows/stages/consent/ConsentStage.ts:28
|
||||
#: src/flows/stages/dummy/DummyStage.ts:27
|
||||
#: src/flows/stages/email/EmailStage.ts:26
|
||||
#: src/flows/stages/identification/IdentificationStage.ts:171
|
||||
#: src/flows/stages/identification/IdentificationStage.ts:179
|
||||
#: src/flows/stages/password/PasswordStage.ts:31
|
||||
#: src/flows/stages/prompt/PromptStage.ts:126
|
||||
#: src/pages/applications/ApplicationViewPage.ts:43
|
||||
|
@ -1742,6 +1762,8 @@ msgstr ""
|
|||
#: src/pages/sources/oauth/OAuthSourceForm.ts:177
|
||||
#: src/pages/sources/oauth/OAuthSourceForm.ts:205
|
||||
#: src/pages/sources/oauth/OAuthSourceForm.ts:226
|
||||
#: src/pages/sources/plex/PlexSourceForm.ts:165
|
||||
#: src/pages/sources/plex/PlexSourceForm.ts:186
|
||||
#: src/pages/sources/saml/SAMLSourceForm.ts:126
|
||||
#: src/pages/sources/saml/SAMLSourceForm.ts:240
|
||||
#: src/pages/sources/saml/SAMLSourceForm.ts:261
|
||||
|
@ -1772,7 +1794,7 @@ msgstr ""
|
|||
msgid "Login password is synced from LDAP into authentik automatically. Enable this option only to write password changes in authentik back to LDAP."
|
||||
msgstr ""
|
||||
|
||||
#: src/flows/stages/identification/IdentificationStage.ts:183
|
||||
#: src/flows/stages/identification/IdentificationStage.ts:191
|
||||
msgid "Login to continue to {0}."
|
||||
msgstr ""
|
||||
|
||||
|
@ -1905,11 +1927,12 @@ msgstr ""
|
|||
#: src/pages/providers/saml/SAMLProviderForm.ts:53
|
||||
#: src/pages/providers/saml/SAMLProviderImportForm.ts:38
|
||||
#: src/pages/providers/saml/SAMLProviderViewPage.ts:66
|
||||
#: src/pages/sources/SourcesListPage.ts:51
|
||||
#: src/pages/sources/SourcesListPage.ts:52
|
||||
#: src/pages/sources/ldap/LDAPSourceForm.ts:54
|
||||
#: src/pages/sources/ldap/LDAPSourceViewPage.ts:64
|
||||
#: src/pages/sources/oauth/OAuthSourceForm.ts:100
|
||||
#: src/pages/sources/oauth/OAuthSourceViewPage.ts:64
|
||||
#: src/pages/sources/plex/PlexSourceForm.ts:87
|
||||
#: src/pages/sources/saml/SAMLSourceForm.ts:54
|
||||
#: src/pages/sources/saml/SAMLSourceViewPage.ts:66
|
||||
#: src/pages/stages/StageListPage.ts:65
|
||||
|
@ -1949,7 +1972,7 @@ msgstr ""
|
|||
msgid "NameID Property Mapping"
|
||||
msgstr ""
|
||||
|
||||
#: src/flows/stages/identification/IdentificationStage.ts:119
|
||||
#: src/flows/stages/identification/IdentificationStage.ts:127
|
||||
msgid "Need an account?"
|
||||
msgstr ""
|
||||
|
||||
|
@ -2340,7 +2363,7 @@ msgstr ""
|
|||
msgid "Post binding (auto-submit)"
|
||||
msgstr ""
|
||||
|
||||
#: src/flows/FlowExecutor.ts:255
|
||||
#: src/flows/FlowExecutor.ts:258
|
||||
msgid "Powered by authentik"
|
||||
msgstr ""
|
||||
|
||||
|
@ -2404,6 +2427,7 @@ msgstr ""
|
|||
#: src/pages/providers/proxy/ProxyProviderForm.ts:123
|
||||
#: src/pages/providers/saml/SAMLProviderForm.ts:78
|
||||
#: src/pages/sources/oauth/OAuthSourceForm.ts:122
|
||||
#: src/pages/sources/plex/PlexSourceForm.ts:109
|
||||
#: src/pages/sources/saml/SAMLSourceForm.ts:76
|
||||
msgid "Protocol settings"
|
||||
msgstr ""
|
||||
|
@ -2594,7 +2618,7 @@ msgstr ""
|
|||
msgid "Retry authentication"
|
||||
msgstr ""
|
||||
|
||||
#: src/flows/FlowExecutor.ts:145
|
||||
#: src/flows/FlowExecutor.ts:146
|
||||
msgid "Return"
|
||||
msgstr ""
|
||||
|
||||
|
@ -2702,7 +2726,7 @@ msgstr ""
|
|||
msgid "Select an identification method."
|
||||
msgstr ""
|
||||
|
||||
#: src/flows/stages/identification/IdentificationStage.ts:134
|
||||
#: src/flows/stages/identification/IdentificationStage.ts:142
|
||||
msgid "Select one of the sources below to login."
|
||||
msgstr ""
|
||||
|
||||
|
@ -2714,6 +2738,10 @@ msgstr ""
|
|||
msgid "Select which scopes can be used by the client. The client stil has to specify the scope to access the data."
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/sources/plex/PlexSourceForm.ts:131
|
||||
msgid "Select which server a user has to be a member of to be allowed to authenticate."
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/events/RuleForm.ts:92
|
||||
msgid "Select which transports should be used to notify the user. If none are selected, the notification will only be shown in the authentik UI."
|
||||
msgstr ""
|
||||
|
@ -2812,7 +2840,7 @@ msgstr ""
|
|||
msgid "Shown as the Title in Flow pages."
|
||||
msgstr ""
|
||||
|
||||
#: src/flows/stages/identification/IdentificationStage.ts:120
|
||||
#: src/flows/stages/identification/IdentificationStage.ts:128
|
||||
msgid "Sign up."
|
||||
msgstr ""
|
||||
|
||||
|
@ -2846,16 +2874,17 @@ msgstr ""
|
|||
#: src/pages/flows/FlowForm.ts:94
|
||||
#: src/pages/sources/ldap/LDAPSourceForm.ts:60
|
||||
#: src/pages/sources/oauth/OAuthSourceForm.ts:106
|
||||
#: src/pages/sources/plex/PlexSourceForm.ts:93
|
||||
#: src/pages/sources/saml/SAMLSourceForm.ts:60
|
||||
msgid "Slug"
|
||||
msgstr ""
|
||||
|
||||
#: src/flows/FlowExecutor.ts:138
|
||||
#: src/flows/FlowExecutor.ts:139
|
||||
msgid "Something went wrong! Please try again later."
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/providers/ProviderListPage.ts:91
|
||||
#: src/pages/sources/SourcesListPage.ts:87
|
||||
#: src/pages/sources/SourcesListPage.ts:88
|
||||
msgid "Source"
|
||||
msgstr ""
|
||||
|
||||
|
@ -2864,11 +2893,11 @@ msgid "Source {0}"
|
|||
msgstr ""
|
||||
|
||||
#: src/interfaces/AdminInterface.ts:20
|
||||
#: src/pages/sources/SourcesListPage.ts:30
|
||||
#: src/pages/sources/SourcesListPage.ts:31
|
||||
msgid "Sources"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/sources/SourcesListPage.ts:33
|
||||
#: src/pages/sources/SourcesListPage.ts:34
|
||||
msgid "Sources of identities, which can either be synced into authentik's database, like LDAP, or can be used by users to authenticate and enroll themselves, like OAuth and social logins"
|
||||
msgstr ""
|
||||
|
||||
|
@ -3065,6 +3094,7 @@ msgstr ""
|
|||
|
||||
#: src/pages/sources/ldap/LDAPSourceForm.ts:47
|
||||
#: src/pages/sources/oauth/OAuthSourceForm.ts:51
|
||||
#: src/pages/sources/plex/PlexSourceForm.ts:60
|
||||
#: src/pages/sources/saml/SAMLSourceForm.ts:47
|
||||
msgid "Successfully created source."
|
||||
msgstr ""
|
||||
|
@ -3201,6 +3231,7 @@ msgstr ""
|
|||
|
||||
#: src/pages/sources/ldap/LDAPSourceForm.ts:44
|
||||
#: src/pages/sources/oauth/OAuthSourceForm.ts:48
|
||||
#: src/pages/sources/plex/PlexSourceForm.ts:57
|
||||
#: src/pages/sources/saml/SAMLSourceForm.ts:44
|
||||
msgid "Successfully updated source."
|
||||
msgstr ""
|
||||
|
@ -3452,7 +3483,7 @@ msgstr ""
|
|||
#: src/pages/policies/PolicyListPage.ts:57
|
||||
#: src/pages/property-mappings/PropertyMappingListPage.ts:55
|
||||
#: src/pages/providers/ProviderListPage.ts:54
|
||||
#: src/pages/sources/SourcesListPage.ts:52
|
||||
#: src/pages/sources/SourcesListPage.ts:53
|
||||
#: src/pages/stages/prompt/PromptForm.ts:97
|
||||
#: src/pages/stages/prompt/PromptListPage.ts:48
|
||||
msgid "Type"
|
||||
|
@ -3531,7 +3562,7 @@ msgstr ""
|
|||
#: src/pages/providers/oauth2/OAuth2ProviderViewPage.ts:118
|
||||
#: src/pages/providers/proxy/ProxyProviderViewPage.ts:117
|
||||
#: src/pages/providers/saml/SAMLProviderViewPage.ts:111
|
||||
#: src/pages/sources/SourcesListPage.ts:69
|
||||
#: src/pages/sources/SourcesListPage.ts:70
|
||||
#: src/pages/sources/ldap/LDAPSourceViewPage.ts:95
|
||||
#: src/pages/sources/oauth/OAuthSourceViewPage.ts:115
|
||||
#: src/pages/sources/saml/SAMLSourceViewPage.ts:101
|
||||
|
@ -3634,7 +3665,7 @@ msgstr ""
|
|||
#: src/pages/policies/PolicyListPage.ts:80
|
||||
#: src/pages/property-mappings/PropertyMappingListPage.ts:69
|
||||
#: src/pages/providers/ProviderListPage.ts:76
|
||||
#: src/pages/sources/SourcesListPage.ts:72
|
||||
#: src/pages/sources/SourcesListPage.ts:73
|
||||
#: src/pages/stages/StageListPage.ts:88
|
||||
#: src/pages/users/UserActiveForm.ts:41
|
||||
msgid "Update {0}"
|
||||
|
@ -3738,7 +3769,7 @@ msgstr ""
|
|||
msgid "Userinfo URL"
|
||||
msgstr ""
|
||||
|
||||
#: src/flows/stages/identification/IdentificationStage.ts:142
|
||||
#: src/flows/stages/identification/IdentificationStage.ts:150
|
||||
#: src/pages/stages/identification/IdentificationStageForm.ts:78
|
||||
#: src/pages/user-settings/UserDetailsPage.ts:57
|
||||
#: src/pages/users/UserForm.ts:47
|
||||
|
@ -3891,7 +3922,7 @@ msgstr ""
|
|||
msgid "When this option is enabled, all executions of this policy will be logged. By default, only execution errors are logged."
|
||||
msgstr ""
|
||||
|
||||
#: src/flows/FlowExecutor.ts:134
|
||||
#: src/flows/FlowExecutor.ts:135
|
||||
msgid "Whoops!"
|
||||
msgstr ""
|
||||
|
||||
|
|
|
@ -17,6 +17,7 @@ import { ifDefined } from "lit-html/directives/if-defined";
|
|||
import "./ldap/LDAPSourceForm";
|
||||
import "./saml/SAMLSourceForm";
|
||||
import "./oauth/OAuthSourceForm";
|
||||
import "./plex/PlexSourceForm";
|
||||
|
||||
@customElement("ak-source-list")
|
||||
export class SourceListPage extends TablePage<Source> {
|
||||
|
|
|
@ -0,0 +1,183 @@
|
|||
import { PlexSource, SourcesApi, FlowsApi, FlowDesignationEnum } from "authentik-api";
|
||||
import { t } from "@lingui/macro";
|
||||
import { customElement, property } from "lit-element";
|
||||
import { html, TemplateResult } from "lit-html";
|
||||
import { DEFAULT_CONFIG } from "../../../api/Config";
|
||||
import { Form } from "../../../elements/forms/Form";
|
||||
import "../../../elements/forms/FormGroup";
|
||||
import "../../../elements/forms/HorizontalFormElement";
|
||||
import { ifDefined } from "lit-html/directives/if-defined";
|
||||
import { until } from "lit-html/directives/until";
|
||||
import { first, randomString } from "../../../utils";
|
||||
import { PlexAPIClient, PlexResource, popupCenterScreen} from "../../../flows/sources/plex/API";
|
||||
|
||||
|
||||
@customElement("ak-source-plex-form")
|
||||
export class PlexSourceForm extends Form<PlexSource> {
|
||||
|
||||
set sourceSlug(value: string) {
|
||||
new SourcesApi(DEFAULT_CONFIG).sourcesPlexRead({
|
||||
slug: value,
|
||||
}).then(source => {
|
||||
this.source = source;
|
||||
});
|
||||
}
|
||||
|
||||
@property({attribute: false})
|
||||
source: PlexSource = {
|
||||
clientId: randomString(40)
|
||||
} as PlexSource;
|
||||
|
||||
@property()
|
||||
plexToken?: string;
|
||||
|
||||
@property({attribute: false})
|
||||
plexResources?: PlexResource[];
|
||||
|
||||
getSuccessMessage(): string {
|
||||
if (this.source) {
|
||||
return t`Successfully updated source.`;
|
||||
} else {
|
||||
return t`Successfully created source.`;
|
||||
}
|
||||
}
|
||||
|
||||
send = (data: PlexSource): Promise<PlexSource> => {
|
||||
if (this.source.slug) {
|
||||
return new SourcesApi(DEFAULT_CONFIG).sourcesPlexUpdate({
|
||||
slug: this.source.slug,
|
||||
data: data
|
||||
});
|
||||
} else {
|
||||
return new SourcesApi(DEFAULT_CONFIG).sourcesPlexCreate({
|
||||
data: data
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
async doAuth(): Promise<void> {
|
||||
const authInfo = await PlexAPIClient.getPin(this.source?.clientId);
|
||||
const authWindow = popupCenterScreen(authInfo.authUrl, "plex auth", 550, 700);
|
||||
PlexAPIClient.pinPoll(this.source?.clientId || "", authInfo.pin.id).then(token => {
|
||||
authWindow?.close();
|
||||
this.plexToken = token;
|
||||
this.loadServers();
|
||||
});
|
||||
}
|
||||
|
||||
async loadServers(): Promise<void> {
|
||||
if (!this.plexToken) {
|
||||
return;
|
||||
}
|
||||
this.plexResources = await new PlexAPIClient(this.plexToken).getServers();
|
||||
}
|
||||
|
||||
renderForm(): TemplateResult {
|
||||
return html`<form class="pf-c-form pf-m-horizontal">
|
||||
<ak-form-element-horizontal
|
||||
label=${t`Name`}
|
||||
?required=${true}
|
||||
name="name">
|
||||
<input type="text" value="${ifDefined(this.source?.name)}" class="pf-c-form-control" required>
|
||||
</ak-form-element-horizontal>
|
||||
<ak-form-element-horizontal
|
||||
label=${t`Slug`}
|
||||
?required=${true}
|
||||
name="slug">
|
||||
<input type="text" value="${ifDefined(this.source?.slug)}" class="pf-c-form-control" required>
|
||||
</ak-form-element-horizontal>
|
||||
<ak-form-element-horizontal name="enabled">
|
||||
<div class="pf-c-check">
|
||||
<input type="checkbox" class="pf-c-check__input" ?checked=${first(this.source?.enabled, true)}>
|
||||
<label class="pf-c-check__label">
|
||||
${t`Enabled`}
|
||||
</label>
|
||||
</div>
|
||||
</ak-form-element-horizontal>
|
||||
|
||||
<ak-form-group .expanded=${true}>
|
||||
<span slot="header">
|
||||
${t`Protocol settings`}
|
||||
</span>
|
||||
<div slot="body" class="pf-c-form">
|
||||
<ak-form-element-horizontal
|
||||
label=${t`Client ID`}
|
||||
?required=${true}
|
||||
name="clientId">
|
||||
<input type="text" value="${first(this.source?.clientId)}" class="pf-c-form-control" required>
|
||||
</ak-form-element-horizontal>
|
||||
<ak-form-element-horizontal
|
||||
label=${t`Allowed servers`}
|
||||
?required=${true}
|
||||
name="allowedServers">
|
||||
<select class="pf-c-form-control" multiple>
|
||||
${this.plexResources?.map(r => {
|
||||
const selected = Array.from(this.source?.allowedServers || []).some(server => {
|
||||
return server == r.clientIdentifier;
|
||||
});
|
||||
return html`<option value=${r.clientIdentifier} ?selected=${selected}>${r.name}</option>`;
|
||||
})}
|
||||
</select>
|
||||
<p class="pf-c-form__helper-text">${t`Select which server a user has to be a member of to be allowed to authenticate.`}</p>
|
||||
<p class="pf-c-form__helper-text">${t`Hold control/command to select multiple items.`}</p>
|
||||
<p class="pf-c-form__helper-text">
|
||||
<button class="pf-c-button pf-m-primary" type="button" @click=${() => {
|
||||
this.doAuth();
|
||||
}}>
|
||||
${t`Load servers`}
|
||||
</button>
|
||||
</p>
|
||||
</ak-form-element-horizontal>
|
||||
</div>
|
||||
</ak-form-group>
|
||||
<ak-form-group>
|
||||
<span slot="header">
|
||||
${t`Flow settings`}
|
||||
</span>
|
||||
<div slot="body" class="pf-c-form">
|
||||
<ak-form-element-horizontal
|
||||
label=${t`Authentication flow`}
|
||||
?required=${true}
|
||||
name="authenticationFlow">
|
||||
<select class="pf-c-form-control">
|
||||
${until(new FlowsApi(DEFAULT_CONFIG).flowsInstancesList({
|
||||
ordering: "pk",
|
||||
designation: FlowDesignationEnum.Authentication,
|
||||
}).then(flows => {
|
||||
return flows.results.map(flow => {
|
||||
let selected = this.source?.authenticationFlow === flow.pk;
|
||||
if (!this.source?.pk && !this.source?.authenticationFlow && flow.slug === "default-source-authentication") {
|
||||
selected = true;
|
||||
}
|
||||
return html`<option value=${ifDefined(flow.pk)} ?selected=${selected}>${flow.name} (${flow.slug})</option>`;
|
||||
});
|
||||
}), html`<option>${t`Loading...`}</option>`)}
|
||||
</select>
|
||||
<p class="pf-c-form__helper-text">${t`Flow to use when authenticating existing users.`}</p>
|
||||
</ak-form-element-horizontal>
|
||||
<ak-form-element-horizontal
|
||||
label=${t`Enrollment flow`}
|
||||
?required=${true}
|
||||
name="enrollmentFlow">
|
||||
<select class="pf-c-form-control">
|
||||
${until(new FlowsApi(DEFAULT_CONFIG).flowsInstancesList({
|
||||
ordering: "pk",
|
||||
designation: FlowDesignationEnum.Enrollment,
|
||||
}).then(flows => {
|
||||
return flows.results.map(flow => {
|
||||
let selected = this.source?.enrollmentFlow === flow.pk;
|
||||
if (!this.source?.pk && !this.source?.enrollmentFlow && flow.slug === "default-source-enrollment") {
|
||||
selected = true;
|
||||
}
|
||||
return html`<option value=${ifDefined(flow.pk)} ?selected=${selected}>${flow.name} (${flow.slug})</option>`;
|
||||
});
|
||||
}), html`<option>${t`Loading...`}</option>`)}
|
||||
</select>
|
||||
<p class="pf-c-form__helper-text">${t`Flow to use when enrolling new users.`}</p>
|
||||
</ak-form-element-horizontal>
|
||||
</div>
|
||||
</ak-form-group>
|
||||
</form>`;
|
||||
}
|
||||
|
||||
}
|
Reference in New Issue