diff --git a/certs/ca.crt b/certs/ca.crt new file mode 100644 index 000000000000..97978f3448da --- /dev/null +++ b/certs/ca.crt @@ -0,0 +1,76 @@ +-----BEGIN CERTIFICATE----- +MIIHMDCCBRigAwIBAgIQVnerP9ykL/7ZiwH5fkv9ujANBgkqhkiG9w0BAQsFADBJ +MQswCQYDVQQGEwJSVTEbMBkGA1UECgwSU2JlcmJhbmsgb2YgUnVzc2lhMR0wGwYD +VQQDDBRTYmVyQ0EgVGVzdCBSb290IEV4dDAeFw0yMzAxMTkxMDMzMDlaFw0zMzAx +MTYxMDMzMDlaMEcxCzAJBgNVBAYTAlJVMRswGQYDVQQKDBJTYmVyYmFuayBvZiBS +dXNzaWExGzAZBgNVBAMMElNiZXJDQSBUZXN0IEV4dCBHMjCCAiIwDQYJKoZIhvcN +AQEBBQADggIPADCCAgoCggIBALQGhBFRgp72U5X9ROfWnoz9uljxG45/ACylHRjV +WqQaTS+zfTlmywpqLx9/fmVWBMjHDyq07b+vkwO9BxDS4cCssmGN3tts4v+UpmXV +xLOCfUS/TvXPMU9NPvPnMG7JP4tMvY/T8Q2RW10jpkHb1KvPP1z2QWC6HR856ATg +B01spaqhFQSP+2vsjMqLq74EndkX+EfXU0N3ZHfL8RB+V2XwHgBRvtp8ateaa4qF +tFFCddFi/8dWGYp2oqi2UUJVBrmrck149z5+pbNfrOh//37JwG3asRcAha9QT/oY +rknfYAjgkxkIxRnE0u2PdioQbKA6abjU/iZ8efzGoNdXojIg5hGf7C9V3KwJiC73 +bkhwSy2s5lD9ZEYREaAg6Wnat7tmOj2MVhi6VxdqRagmzLV9OkoNDVjkI+MeVZXA +d2LHV/ZaiUcKhzEC45AXErHlmANV017ABTcm1QSoYc3o6reIxuw2+aipiIiQwgIY +1fLtLaMttpYYVSPZODVpH/ELYppvkfodoqbB3m1S16qXagjbLVLV/tYyyLG1Gf/k +0I07Ex2jEocV7od6MSvuAQrZ6sqchon/6nijN+X8pj9KGDWQXG92XoQWoqWDoyKg +YH24y8FQ2WP7l7BfHpnqR2ZRgInS31ZYxli/lnD2Ca6TXTrc5Kz0OyD5tFkco7fX +aob9AgMBAAGjggIUMIICEDAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBQGvaYk +eWA9u0FEN6nKCP5j7EwtwDB5BgNVHSMEcjBwgBR0XuRdqVSurQLnReFSsppW9Xo/ +UKFNpEswSTELMAkGA1UEBhMCUlUxGzAZBgNVBAoMElNiZXJiYW5rIG9mIFJ1c3Np +YTEdMBsGA1UEAwwUU2JlckNBIFRlc3QgUm9vdCBFeHSCCQDb8SSh53wn5zALBgNV +HQ8EBAMCAYYwgZ8GA1UdHwSBlzCBlDCBkaCBjqCBi4ZKaHR0cDovL3NiZXJjYS1w +cm94eS1kZnBkLnNpZ21hLnNicmYucnUvc2JlcmNhL2NkcC9zYmVyY2EtdGVzdC1y +b290LWV4dC5jcmyGPWh0dHA6Ly9zYmVyY2F0ZXN0LnNiZXIucnUvc2JlcmNhL2Nk +cC9zYmVyY2EtdGVzdC1yb290LWV4dC5jcmwwgbMGCCsGAQUFBwEBBIGmMIGjMFYG +CCsGAQUFBzAChkpodHRwOi8vc2JlcmNhLXByb3h5LWRmcGQuc2lnbWEuc2JyZi5y +dS9zYmVyY2EvYWlhL3NiZXJjYS10ZXN0LXJvb3QtZXh0LmNydDBJBggrBgEFBQcw +AoY9aHR0cDovL3NiZXJjYXRlc3Quc2Jlci5ydS9zYmVyY2EvYWlhL3NiZXJjYS10 +ZXN0LXJvb3QtZXh0LmNydDANBgkqhkiG9w0BAQsFAAOCAgEAR+x/j5x8W+2y46i3 +tR5SvkIL7ZOka/5dHJhXI79MKrDzcJlgDApAwNBw5ywBVp6vDy0xUxHiw91OX96w +0HU7Cn5ZD6rgGVZbm77euENC0QqQ8J7q3TNkXSoWcxjba22G6LiTDeEW1KdkFsLY +YJR6oDQgY4YFdbTkkr/5Ke942wQ8N6IqGkwtliKGN+l9dxXP/6tV+Qd63nf5wQ2A +y0OpHN3+vvOd5jysAdzFvDIVRJVaPvGAXo8DnEePvmET/unBI8P1ZL2gSeLXyAhj +s1w3PwvEDj246yrhA/wijnzMAgLOnLMjupMP2qx0s3adCSuj873i/NIctvm4oNvz +e49D09HX3eQDo9uUEyPWFQn7d50obnYV4BxW9G3LnXQldMaJFKpEqTD36oW4EDr0 +P0geuxQkrLehjbFrRpUlxepx/9yEnilILCkoU1NGCTQFtbsCOefAN8sxPCM9Ml+i +jzei/aJOUBwLb9RGfFGXNwd/BVt1qKmm/aDh5BNwobxZnpdBaOlbplatueFbZbF5 +6FBCDcnQfJifC49ve50MxDgVcydcx7QZVB6nHGzFUU+DU9X7lnWN8DAjBzJZ/j6h +A0YgQfAexkxcauGsJJbKMvKInEUj5aOoxu7KHM/FmLDbvB2VIB95A+K+bLu4x7Sl +BT62zSc8c/0qPN+TlLmzeVJVvJE= +-----END CERTIFICATE----- + +-----BEGIN CERTIFICATE----- +MIIF0TCCA7mgAwIBAgIJANvxJKHnfCfnMA0GCSqGSIb3DQEBCwUAMEkxCzAJBgNV +BAYTAlJVMRswGQYDVQQKDBJTYmVyYmFuayBvZiBSdXNzaWExHTAbBgNVBAMMFFNi +ZXJDQSBUZXN0IFJvb3QgRXh0MB4XDTIxMDgxMzEzMTA0MVoXDTQxMDgwODEzMTA0 +MVowSTELMAkGA1UEBhMCUlUxGzAZBgNVBAoMElNiZXJiYW5rIG9mIFJ1c3NpYTEd +MBsGA1UEAwwUU2JlckNBIFRlc3QgUm9vdCBFeHQwggIiMA0GCSqGSIb3DQEBAQUA +A4ICDwAwggIKAoICAQDBJdOQWK/QMgJKUSPxwRFs34Su4NkR+zXXVnD6F1Yypvi/ +mjK5ALZvaYGfa0Kbe13+cxHxEQgTX4k+8FVqSmJv/Jf8LYHp76bZ5CPC0y0XX0xw +gvM+coQH6FpQHTG+s9x6xDuZgOyh5vNUY9sND/wnJcoVZHf/DH+JbKobKJL+Pv4f +xcln+PPlPXrAOwo1G8sgfXUmqUAmyYK6o7pZBFAk3KdaVHnWyNIUvKRK3rfGuN2J +LxN9p3Kywbqw/9Fbanpy8YFvdpkKPuq+RKPuGxx6LWZpi7N4EWxHTnwzDX6QBdP6 +damHCGuEwPfzSdEKTu73pbmlnY3uAZ2Mf/llO4I/1RtebpDMwV1IbfZLaSKY4Vnw +/EZd/bSqpV7ZQ5HQUfNFkIohAU36UOSzKKxp+aWI3sB99uLpiHS4xntmION5b2XQ +8JVDnu28C5xx90TAKF40aiw36g8FT4+yFfw4ez8kQqEMqV9Me7TwLKdcjPa61uGK +HLPbOIhlrlNvf68JFB3hznIlW5xY70p4dKbHVFLa6m1zPwuw2PKnYFGjhtXquzw9 +nBSCW3p7VvwqvWuakF6boZvuCqsK0kaLQu+pcVS89PTPH7iVg5vW21VFq0MM5ssH +uTfGAChFH3tnRe+0gqcWX1de0tZ15yvi5tRtE6E5PqAWkGjVil7cofnmsGez5wID +AQABo4G7MIG4MB0GA1UdDgQWBBR0XuRdqVSurQLnReFSsppW9Xo/UDB5BgNVHSME +cjBwgBR0XuRdqVSurQLnReFSsppW9Xo/UKFNpEswSTELMAkGA1UEBhMCUlUxGzAZ +BgNVBAoMElNiZXJiYW5rIG9mIFJ1c3NpYTEdMBsGA1UEAwwUU2JlckNBIFRlc3Qg +Um9vdCBFeHSCCQDb8SSh53wn5zAPBgNVHRMBAf8EBTADAQH/MAsGA1UdDwQEAwIB +hjANBgkqhkiG9w0BAQsFAAOCAgEAAVGRPde6acAVe5CenhGk87Iqle//iXMlReMV +lvQiPxnBazUbrvW01I273pmULzlnAL8ihJ7twUMoSruazhbxhxOQ+ExqwXqZPazh +iu/WQAHiUmbzTGJW5Usi8EnbX3Q8BH/w9x32HOWd7+vOJbjTgFjVSZNIne9UYgbd +uzW53q+Wk/2eU3FMMCIDiJYZsL5RqAcJWNy6HQZ1Ku/JwOROzotOccUZsh/D+e2L +kX2Jz5zgTS/FudIW5ycao3jexakvWQxctEHeo4zsh2eyzExqKBADP73csuBlQS69 +ri2WO0IiYqA3FP4M2NTf8cKuuL/vqsRml+aI/RNBRvliHesAZa5sQH8ccdeI/jgM +EN+eGin7hCZRZHwvmQNJCSsKqSWvD7GIUP52CPk6Q1JGwZGdmTKkparljFMYWWD4 +LI3orb0b0WaUBDROBXdv+CbBrAlZ0R/G2PUmBq4z4Zoc2L6IX+Ater72yrnSyGs6 +jYqf6vabOxnYiJgY1w5wJNr9qXk+lH70pfNDWR0KO7zHXjxTjkImGbODvvFZj8pW +ND7QrwPqlGDuYZ9XJ5NHCIGGqqhEXt3rk9AQ1oWZm+gFWi7j0pXFU/M/WUOg8AxS +a7PGOWy1TIGcDqKXi9VKUDSD2kxnKOlTKNrX6hgQHa5PaVgsNbqIZ42y144vvyWB +cis2vV4= +-----END CERTIFICATE----- diff --git a/certs/cacerts.cer b/certs/cacerts.cer new file mode 100644 index 000000000000..b53f90199276 --- /dev/null +++ b/certs/cacerts.cer @@ -0,0 +1,80 @@ +Bag Attributes + friendlyName: SberCA Test1 Ext +subject=C=RU, O=Sberbank of Russia, CN=SberCA Test1 Ext +issuer=C=RU, O=Sberbank of Russia, CN=SberCA Test1 Root Ext +-----BEGIN CERTIFICATE----- +MIIGqjCCBJKgAwIBAgIRAMIMwGCgJtPpMU5YMhwleeMwDQYJKoZIhvcNAQELBQAw +SjELMAkGA1UEBhMCUlUxGzAZBgNVBAoMElNiZXJiYW5rIG9mIFJ1c3NpYTEeMBwG +A1UEAwwVU2JlckNBIFRlc3QxIFJvb3QgRXh0MB4XDTIxMDMxNzE2MTAzNFoXDTMx +MDMxNTE2MTAzNFowRTELMAkGA1UEBhMCUlUxGzAZBgNVBAoMElNiZXJiYW5rIG9m +IFJ1c3NpYTEZMBcGA1UEAwwQU2JlckNBIFRlc3QxIEV4dDCCAiIwDQYJKoZIhvcN +AQEBBQADggIPADCCAgoCggIBAN6HuZoD8FJNN3s0gxKJiZvGZF7W7+SwOpl1hOmw +W/iX+KIAZadHzEvofRuhnbxpNC+Eja8k4tODxfmcisZK/hQC3JcNpEjmwFV3nMo4 +IFCWHci/Me7VJEoLyjd2au4LfzGlTHmQ3Kus9X4oUYk05WC6AvsiFEZGGhEjiSAQ +WJy0hV1SnaFLtw19CcznTqvhyG2a4/zyu2lGbQrNf5iuDZ2A1T5mqVhUv7pcqIxT +SWpGDQRo1mUtJxIsGQV4jOJSO/x3jPcL3vMdMo9RyUbRBpD+VsdXLzTLuKKKws0l +/gG1fhj5q9SwM030vFDQVZUtUJemoJB3Yo1smGTdRwojQAEWUIlSx/k3MeHGOQq+ +1Wzv4SMV5BMr9TH8RmKD8k3QDuFbQR7db8PjvwIVoPHrNMsFsohhcABxfoRlctOy +GvTup6e0J4UvFKSQgLVcKPVXCETXeGpMhsPOqFmGfqqzIqC6UeYOMzeAPKnZ0ksG +r84MvZa0ZvFLinbl2LowFtfk21mkLAwqbgm33v+vgBKGJKpNKLzXi4Jn2roezuGW +/Dcvi3QhHSIsRjRpXxRFEpTU+ZhB/5usH/YTwZAuLjejACsYgX4ZXF3KVZXIwQK0 +6QwwnAoQ5wDbr2mKbdUhEw+65Zg2SUlOHH0lZtcmKTN939q0pvg3cnJXvjvrR/za +PK8JAgMBAAGjggGOMIIBijBhBgNVHR8EWjBYMFagVKBShlBodHRwOi8vd3d3LnNi +ZXJiYW5rLXJ1Lm9zLXN0YWdlLnNiZXJiYW5rLnJ1L3NiZXJjYS9jZHAvc2JlcmNh +LXRlc3QxLXJvb3QtZXh0LmNybDBsBggrBgEFBQcBAQRgMF4wXAYIKwYBBQUHMAKG +UGh0dHA6Ly93d3cuc2JlcmJhbmstcnUub3Mtc3RhZ2Uuc2JlcmJhbmsucnUvc2Jl +cmNhL2FpYS9zYmVyY2EtdGVzdDEtcm9vdC1leHQuY3J0MA8GA1UdEwEB/wQFMAMB +Af8wHQYDVR0OBBYEFODFaPa5RhVllTU9P2L1DxncUz6JMHoGA1UdIwRzMHGAFHOU +x0YzNGI2960h0UJJeRfN5WokoU6kTDBKMQswCQYDVQQGEwJSVTEbMBkGA1UECgwS +U2JlcmJhbmsgb2YgUnVzc2lhMR4wHAYDVQQDDBVTYmVyQ0EgVGVzdDEgUm9vdCBF +eHSCCQCkWJ+tHQZveDALBgNVHQ8EBAMCAYYwDQYJKoZIhvcNAQELBQADggIBAJid +pqdkuN+K1suB3J/ug6ko08pg5FlZTswNAn3E/VbCzm2zMeiJRwI2zLzWqzSBZv2j +sVn463TeUT0cgXiz/dEGLsyFg6NzRY9y09JfTcSeB/g2Fuqm0Agk+GN4Vo/XMBds +hnEG7ocUyAHy3FopZt1TY9vYHE0eanLkJD+s3yaA1tgpFN2Ivmjr0bgK9MUAd0AC +ewGxlGiEkv3qk2R9tkjY3g4TJwVNHfGBHWRl4tXiLKvohymJxRxXqF72HuCl++Xx +8ZtQ7gpQI1/L+XVZuzTdAF2in8v3HuZMsl4DSSrpvOgMqdlA6Tvp9NXfbjRLV1nz +ypjPFhhMcAZES6+32ubWurc0LgU6VQjARkCamDVuBS9bYsf512MNHlFcmIF8jG9O +wIrqGXtpEd0PnN/CpJgXZ8hLzBqvGhe2SiHis+ojjUZFuSKvicLjHiI9NI0qynke +mISFbNoUURjuYqlpk5qOGT0p/mtueoyam4lMFyYWq0ND3hzSrK/0nQbJEQr0u19X +OANA1RhWCkTVfdeBLTI+OcqVg3SnkArT/0EATnQ4H+/z6/Qdi4qfQgwL5hVJo8lj +s8aq/2+23xvniDmlfN1+jIJYpNh9/f+emw0xu9caDm4KflFc/2W6wZcp921HJE1u +Jl4BMf3idYZDjYmGoRvXN+fJNcOhNTn5r7H0tYQt +-----END CERTIFICATE----- +Bag Attributes + friendlyName: SberCA Test1 Root Ext +subject=C=RU, O=Sberbank of Russia, CN=SberCA Test1 Root Ext +issuer=C=RU, O=Sberbank of Russia, CN=SberCA Test1 Root Ext +-----BEGIN CERTIFICATE----- +MIIF1DCCA7ygAwIBAgIJAKRYn60dBm94MA0GCSqGSIb3DQEBCwUAMEoxCzAJBgNV +BAYTAlJVMRswGQYDVQQKDBJTYmVyYmFuayBvZiBSdXNzaWExHjAcBgNVBAMMFVNi +ZXJDQSBUZXN0MSBSb290IEV4dDAeFw0yMDEwMjkxNDUyMDRaFw00MDEwMjQxNDUy +MDRaMEoxCzAJBgNVBAYTAlJVMRswGQYDVQQKDBJTYmVyYmFuayBvZiBSdXNzaWEx +HjAcBgNVBAMMFVNiZXJDQSBUZXN0MSBSb290IEV4dDCCAiIwDQYJKoZIhvcNAQEB +BQADggIPADCCAgoCggIBANFtpAGCCrMNAW5e8fhrWzHIIw+tCf72+iHvQoCk/T0F +PLgOxdC1fiPLtqqdda7cNQlgXWhbo0vmIH9FOgy208Diif32qbTl2WI0MQ0oQsIt +Sef6/ME7vXU/TGCY+UIUDFDKSh+05lJYPAjDPShqNCXOmuPhsqLFvX4poIoF6Gqo +VJjgvmXEqm8XEC2YeXlwZl5UkZqDqeRiOsfAbOYo5244OWFZKi/Um605c7FKPM9k +25K4hJBj53vxnx2kty+xQtXh+dbBIc8TrjYSoMm/4vcqJNrdFIkzr8Iv432M82H9 +U2Y/PLXGTOvAVn839emr7gnn5hZxQqBRHiNjOVLNQ5hasXYpck63xzuaN0CM7kzs +IU+W9RJAV0Ur+Bye+IXRUh5yOAP3w0LXFrWEpgIbFTuFIAInEMvUanIzWCS2yv2f +YojmnZso7dZT8cLsajsPvAkXoHqoP/9L5SLvvhgSgp7FafGxrYXi7ihWSacYUGRr +EIKLse6Xyaakh4BNiTfvwYpC9aeUBz7BZaa6by2TUCne6yGqjwYW6bFalFTctb6d +ERSn/yycs3I0itYNWEf090T5jaLR/EFYadhzulac4btQlt/oWgPtluBQUoZ3NMzs +dVFu7pwEHg4dXVEpvKRUt4W9qOAsgc/lDextNo/qoXARkjL4z2uNrem92hlnkRPp +AgMBAAGjgbwwgbkwHQYDVR0OBBYEFHOUx0YzNGI2960h0UJJeRfN5WokMHoGA1Ud +IwRzMHGAFHOUx0YzNGI2960h0UJJeRfN5WokoU6kTDBKMQswCQYDVQQGEwJSVTEb +MBkGA1UECgwSU2JlcmJhbmsgb2YgUnVzc2lhMR4wHAYDVQQDDBVTYmVyQ0EgVGVz +dDEgUm9vdCBFeHSCCQCkWJ+tHQZveDAPBgNVHRMBAf8EBTADAQH/MAsGA1UdDwQE +AwIBhjANBgkqhkiG9w0BAQsFAAOCAgEAYAaiBgn0SpnAqNotZCqQyrVYzg2U/v1o +WFIkK3Bjmns9f4/x8cyv2ICz1vUMig/IDZ3GlM9MJHn65Zm1SR0tBSu+AVvcX+ek +FJGyvxNsFaOQRxsPilNipQTAITZjl+UqtZVYxrCf4odf8y0AiryLhW5NhVXIGx+K +nzGdSzrlm1aF9cjL4NaX3Fims6GLWbob5cRmCm7nfi1XrwMOM+ykT8j8Jx/Q05mk +22ZwZnY4ziwz1lqGIwfiaeKGzeGJ6m5Nk9ye7RJGaJXdqRFfsdm4ZsHvjcDQcd7s +jWNhyIkPeRN+tfd2E6Gp/aAJeQf1W4ADX8csBxSdtGCge9jp4Sjw7Tav2tPG/waS +lUORwgm7objuMZtVkbJd/XllWA6KcMU2xScJ5YyPnoZozYNyONoGa54C+5KEgZhW +dTJmvOs0nIl2rPoVg0U9fWVUAXnp7QmOU7FG83O9GjjRvkXgCmSBdmBkoQVIvAGC +wTwngzLiR53E1FO01WtHhzABhysOtiIAOCyrL0ljj6S2qkLmYGzOtNVLxHeBL7GW +cQqLkZW5z2ag6XEuBCwdLEdYD7irIm5dHvWcQfmPP6Zx6Gbd9AFQhdwRKzQvVeOA +EjgY1RTnYVGNtWvE+X5l6geLK0olF9xO+PomZzZCzGU+8IN/bqrOPQWmpfPq9PXU +59QtblGlDOw= +-----END CERTIFICATE----- diff --git a/certs/client_cert.crt b/certs/client_cert.crt new file mode 100644 index 000000000000..5343c8978650 --- /dev/null +++ b/certs/client_cert.crt @@ -0,0 +1,50 @@ +Bag Attributes + localKeyID: E2 25 74 6C 41 B7 A1 77 0E 1A FA A3 E9 1B 1F C5 0C D9 1F 83 + friendlyName: ede958f8-ea14-4287-ad8b-db0dfb10194e +subject=C=RU, ST=G.MOSKVA, L=G.MOSKVA, O=SFUTSK BLAGODARYA, OU=7736372057, OU=CI02440297, OU=sberid-client-1y, OU=sberid, CN=ede958f8-ea14-4287-ad8b-db0dfb10194e +issuer=C=RU, O=Sberbank of Russia, CN=SberCA Test1 Ext +-----BEGIN CERTIFICATE----- +MIIH7jCCBdagAwIBAgIUaZQLP+xefNUT4sICYhqx7ylo+LAwDQYJKoZIhvcNAQEL +BQAwRTELMAkGA1UEBhMCUlUxGzAZBgNVBAoMElNiZXJiYW5rIG9mIFJ1c3NpYTEZ +MBcGA1UEAwwQU2JlckNBIFRlc3QxIEV4dDAeFw0yNjA0MDIwNjIyNDRaFw0yNzA0 +MDIwNjI3NDRaMIHUMQswCQYDVQQGEwJSVTERMA8GA1UECBMIRy5NT1NLVkExETAP +BgNVBAcTCEcuTU9TS1ZBMRowGAYDVQQKExFTRlVUU0sgQkxBR09EQVJZQTETMBEG +A1UECxMKNzczNjM3MjA1NzETMBEGA1UECxMKQ0kwMjQ0MDI5NzEZMBcGA1UECxMQ +c2JlcmlkLWNsaWVudC0xeTEPMA0GA1UECxMGc2JlcmlkMS0wKwYDVQQDEyRlZGU5 +NThmOC1lYTE0LTQyODctYWQ4Yi1kYjBkZmIxMDE5NGUwggEiMA0GCSqGSIb3DQEB +AQUAA4IBDwAwggEKAoIBAQDEvgomMfzHpBhFfTKWdgxF/gclWEBqDgB/GyavNeay +CdaB4Wv9269HhQM9kaGWO2H7qUNjvwaX0qrA+IBAAOwsRwiAFkhdjPJLsCpmAOT7 +uB8euSKyDAIGoUg69oexBrhTMzeAwhE4GT+y3FfB/idYk07bB2xOV/29ztuxIqJA +eONc0XhTI8PJoDJSOURSHlUXprQvGJpLVW5D90aXBRl+5VYWWtWQH4ptsi2yPHfJ +Vjx3m+1YtvaS6H1N6hft0nYRVUwt7cQmvDOBRtEPceswiSh9p9/5xUibz0dMF9gG +6kFx/aH6jE1cqeWT4rf7QHjwcb63C76kHA20PffWnXDxAgMBAAGjggNEMIIDQDAJ +BgNVHRMEAjAAMB0GA1UdDgQWBBTiJXRsQbehdw4a+qPpGx/FDNkfgzCBggYDVR0j +BHsweYAU4MVo9rlGFWWVNT0/YvUPGdxTPomhTqRMMEoxCzAJBgNVBAYTAlJVMRsw +GQYDVQQKDBJTYmVyYmFuayBvZiBSdXNzaWExHjAcBgNVBAMMFVNiZXJDQSBUZXN0 +MSBSb290IEV4dIIRAMIMwGCgJtPpMU5YMhwleeMwggGDBggrBgEFBQcBAQSCAXUw +ggFxMFEGCCsGAQUFBzAChkVodHRwOi8vc2JlcmNhLXByb3h5LWlmdC5kZWx0YS5z +YnJmLnJ1L3NiZXJjYS9haWEvc2JlcmNhLXRlc3QxLWV4dC5jcnQwSwYIKwYBBQUH +MAKGP2h0dHA6Ly9zYmVyY2EtcHJveHktaWZ0LnNiZXIucnUvc2JlcmNhL2FpYS9z +YmVyY2EtdGVzdDEtZXh0LmNydDBNBggrBgEFBQcwAoZBaHR0cDovL2hhcHJveHkt +ZWR6MS5zaWdtYS5zYnJmLnJ1L3NiZXJjYS9haWEvc2JlcmNhLXRlc3QxLWV4dC5j +cnQwQgYIKwYBBQUHMAGGNmh0dHA6Ly9zYmVyY2EtcHJveHktaWZ0LmRlbHRhLnNi +cmYucnUvc2JlcmNhLXRlc3QxLWV4dDA8BggrBgEFBQcwAYYwaHR0cDovL3NiZXJj +YS1wcm94eS1pZnQuc2Jlci5ydS9zYmVyY2EtdGVzdDEtZXh0MA4GA1UdDwEB/wQE +AwIHgDAWBgNVHSUBAf8EDDAKBggrBgEFBQcDAjCB3wYDVR0fBIHXMIHUMIHRoIHO +oIHLhkVodHRwOi8vc2JlcmNhLXByb3h5LWlmdC5kZWx0YS5zYnJmLnJ1L3NiZXJj +YS9jZHAvc2JlcmNhLXRlc3QxLWV4dC5jcmyGP2h0dHA6Ly9zYmVyY2EtcHJveHkt +aWZ0LnNiZXIucnUvc2JlcmNhL2NkcC9zYmVyY2EtdGVzdDEtZXh0LmNybIZBaHR0 +cDovL2hhcHJveHktZWR6MS5zaWdtYS5zYnJmLnJ1L3NiZXJjYS9jZHAvc2JlcmNh +LXRlc3QxLWV4dC5jcmwwDQYJKoZIhvcNAQELBQADggIBAErVeWOsvYZFObiRYYK/ +IyZ0+tZsPS8ThFxHoCIgwU1aCffX0kvzDRjakbHOKdLhYElTCbzTmoVPCwQv3aEu +4PP6WD/ZtXnaepi5wAOvTyl5U5WW4W4p8kUof/G+lEBuJp+I+E0ZqZb/UhQj5R+g +hmKU88rTPV5xdEmTizrfeS2FeUewrrZWpekPJ7QktdljI755ZFC91nojtQW8W8Nm +ThgyVnazzKRkAeBgXXgceUleRHP//bAepX+7yiHVFdICkESPybEPP1LptW0oRDNI +oIrd7CGcewYSFupY79Q4hhRS5Ho/esxKuMHhzmzCloIFH6d1ywP9CzyiX4E4wu5w +1fjHk+iFYll/zgC33pOx+iT97n+uVa7750H4Ab6MQGoMAQ7tKRFGuKFT27s4tVt/ +40ls3OAgbBUD9tmkgR6iU6a6Tk6VCZp8rTNqtkwUXYLs7WEZyAExsFQMnOzgS6fD +IvPQyonhJCzXjKw/X+4Pe5ULKLkj9fNWhQi4wmmF8/Bwb0V++z0ZXfTATuhut/XO +t/+MgMsUv8YWB3jWAZhaF45JpLn6u91gg5HcnhtOlFCaQ68bKC0ilFxzpkbFsFqW +cQsVKhHgHIdeHW9iWlPLiC2DmqmuhBleOKlMQUwgjzgfO0Kdb2NnfHGlWlBq1tA8 +KRIi8ZNErdhx2xnyT9BVoK2Q +-----END CERTIFICATE----- diff --git a/certs/file.crt.pem b/certs/file.crt.pem new file mode 100644 index 000000000000..5343c8978650 --- /dev/null +++ b/certs/file.crt.pem @@ -0,0 +1,50 @@ +Bag Attributes + localKeyID: E2 25 74 6C 41 B7 A1 77 0E 1A FA A3 E9 1B 1F C5 0C D9 1F 83 + friendlyName: ede958f8-ea14-4287-ad8b-db0dfb10194e +subject=C=RU, ST=G.MOSKVA, L=G.MOSKVA, O=SFUTSK BLAGODARYA, OU=7736372057, OU=CI02440297, OU=sberid-client-1y, OU=sberid, CN=ede958f8-ea14-4287-ad8b-db0dfb10194e +issuer=C=RU, O=Sberbank of Russia, CN=SberCA Test1 Ext +-----BEGIN CERTIFICATE----- +MIIH7jCCBdagAwIBAgIUaZQLP+xefNUT4sICYhqx7ylo+LAwDQYJKoZIhvcNAQEL +BQAwRTELMAkGA1UEBhMCUlUxGzAZBgNVBAoMElNiZXJiYW5rIG9mIFJ1c3NpYTEZ +MBcGA1UEAwwQU2JlckNBIFRlc3QxIEV4dDAeFw0yNjA0MDIwNjIyNDRaFw0yNzA0 +MDIwNjI3NDRaMIHUMQswCQYDVQQGEwJSVTERMA8GA1UECBMIRy5NT1NLVkExETAP +BgNVBAcTCEcuTU9TS1ZBMRowGAYDVQQKExFTRlVUU0sgQkxBR09EQVJZQTETMBEG +A1UECxMKNzczNjM3MjA1NzETMBEGA1UECxMKQ0kwMjQ0MDI5NzEZMBcGA1UECxMQ +c2JlcmlkLWNsaWVudC0xeTEPMA0GA1UECxMGc2JlcmlkMS0wKwYDVQQDEyRlZGU5 +NThmOC1lYTE0LTQyODctYWQ4Yi1kYjBkZmIxMDE5NGUwggEiMA0GCSqGSIb3DQEB +AQUAA4IBDwAwggEKAoIBAQDEvgomMfzHpBhFfTKWdgxF/gclWEBqDgB/GyavNeay +CdaB4Wv9269HhQM9kaGWO2H7qUNjvwaX0qrA+IBAAOwsRwiAFkhdjPJLsCpmAOT7 +uB8euSKyDAIGoUg69oexBrhTMzeAwhE4GT+y3FfB/idYk07bB2xOV/29ztuxIqJA +eONc0XhTI8PJoDJSOURSHlUXprQvGJpLVW5D90aXBRl+5VYWWtWQH4ptsi2yPHfJ +Vjx3m+1YtvaS6H1N6hft0nYRVUwt7cQmvDOBRtEPceswiSh9p9/5xUibz0dMF9gG +6kFx/aH6jE1cqeWT4rf7QHjwcb63C76kHA20PffWnXDxAgMBAAGjggNEMIIDQDAJ +BgNVHRMEAjAAMB0GA1UdDgQWBBTiJXRsQbehdw4a+qPpGx/FDNkfgzCBggYDVR0j +BHsweYAU4MVo9rlGFWWVNT0/YvUPGdxTPomhTqRMMEoxCzAJBgNVBAYTAlJVMRsw +GQYDVQQKDBJTYmVyYmFuayBvZiBSdXNzaWExHjAcBgNVBAMMFVNiZXJDQSBUZXN0 +MSBSb290IEV4dIIRAMIMwGCgJtPpMU5YMhwleeMwggGDBggrBgEFBQcBAQSCAXUw +ggFxMFEGCCsGAQUFBzAChkVodHRwOi8vc2JlcmNhLXByb3h5LWlmdC5kZWx0YS5z +YnJmLnJ1L3NiZXJjYS9haWEvc2JlcmNhLXRlc3QxLWV4dC5jcnQwSwYIKwYBBQUH +MAKGP2h0dHA6Ly9zYmVyY2EtcHJveHktaWZ0LnNiZXIucnUvc2JlcmNhL2FpYS9z +YmVyY2EtdGVzdDEtZXh0LmNydDBNBggrBgEFBQcwAoZBaHR0cDovL2hhcHJveHkt +ZWR6MS5zaWdtYS5zYnJmLnJ1L3NiZXJjYS9haWEvc2JlcmNhLXRlc3QxLWV4dC5j +cnQwQgYIKwYBBQUHMAGGNmh0dHA6Ly9zYmVyY2EtcHJveHktaWZ0LmRlbHRhLnNi +cmYucnUvc2JlcmNhLXRlc3QxLWV4dDA8BggrBgEFBQcwAYYwaHR0cDovL3NiZXJj +YS1wcm94eS1pZnQuc2Jlci5ydS9zYmVyY2EtdGVzdDEtZXh0MA4GA1UdDwEB/wQE +AwIHgDAWBgNVHSUBAf8EDDAKBggrBgEFBQcDAjCB3wYDVR0fBIHXMIHUMIHRoIHO +oIHLhkVodHRwOi8vc2JlcmNhLXByb3h5LWlmdC5kZWx0YS5zYnJmLnJ1L3NiZXJj +YS9jZHAvc2JlcmNhLXRlc3QxLWV4dC5jcmyGP2h0dHA6Ly9zYmVyY2EtcHJveHkt +aWZ0LnNiZXIucnUvc2JlcmNhL2NkcC9zYmVyY2EtdGVzdDEtZXh0LmNybIZBaHR0 +cDovL2hhcHJveHktZWR6MS5zaWdtYS5zYnJmLnJ1L3NiZXJjYS9jZHAvc2JlcmNh +LXRlc3QxLWV4dC5jcmwwDQYJKoZIhvcNAQELBQADggIBAErVeWOsvYZFObiRYYK/ +IyZ0+tZsPS8ThFxHoCIgwU1aCffX0kvzDRjakbHOKdLhYElTCbzTmoVPCwQv3aEu +4PP6WD/ZtXnaepi5wAOvTyl5U5WW4W4p8kUof/G+lEBuJp+I+E0ZqZb/UhQj5R+g +hmKU88rTPV5xdEmTizrfeS2FeUewrrZWpekPJ7QktdljI755ZFC91nojtQW8W8Nm +ThgyVnazzKRkAeBgXXgceUleRHP//bAepX+7yiHVFdICkESPybEPP1LptW0oRDNI +oIrd7CGcewYSFupY79Q4hhRS5Ho/esxKuMHhzmzCloIFH6d1ywP9CzyiX4E4wu5w +1fjHk+iFYll/zgC33pOx+iT97n+uVa7750H4Ab6MQGoMAQ7tKRFGuKFT27s4tVt/ +40ls3OAgbBUD9tmkgR6iU6a6Tk6VCZp8rTNqtkwUXYLs7WEZyAExsFQMnOzgS6fD +IvPQyonhJCzXjKw/X+4Pe5ULKLkj9fNWhQi4wmmF8/Bwb0V++z0ZXfTATuhut/XO +t/+MgMsUv8YWB3jWAZhaF45JpLn6u91gg5HcnhtOlFCaQ68bKC0ilFxzpkbFsFqW +cQsVKhHgHIdeHW9iWlPLiC2DmqmuhBleOKlMQUwgjzgfO0Kdb2NnfHGlWlBq1tA8 +KRIi8ZNErdhx2xnyT9BVoK2Q +-----END CERTIFICATE----- diff --git a/certs/file.key.pem b/certs/file.key.pem new file mode 100644 index 000000000000..b8eb41132f0d --- /dev/null +++ b/certs/file.key.pem @@ -0,0 +1,32 @@ +Bag Attributes + localKeyID: E2 25 74 6C 41 B7 A1 77 0E 1A FA A3 E9 1B 1F C5 0C D9 1F 83 + friendlyName: ede958f8-ea14-4287-ad8b-db0dfb10194e +Key Attributes: +-----BEGIN PRIVATE KEY----- +MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDEvgomMfzHpBhF +fTKWdgxF/gclWEBqDgB/GyavNeayCdaB4Wv9269HhQM9kaGWO2H7qUNjvwaX0qrA ++IBAAOwsRwiAFkhdjPJLsCpmAOT7uB8euSKyDAIGoUg69oexBrhTMzeAwhE4GT+y +3FfB/idYk07bB2xOV/29ztuxIqJAeONc0XhTI8PJoDJSOURSHlUXprQvGJpLVW5D +90aXBRl+5VYWWtWQH4ptsi2yPHfJVjx3m+1YtvaS6H1N6hft0nYRVUwt7cQmvDOB +RtEPceswiSh9p9/5xUibz0dMF9gG6kFx/aH6jE1cqeWT4rf7QHjwcb63C76kHA20 +PffWnXDxAgMBAAECggEBAIR7P9RWhtRcoGdylfURitQ66c7w7Xc89IKi4trLHgy3 +aTXOeOzZ2N79J6B3B2tlk2ZmpVVuld74YjlNXPc8Z8ytDIFL//DW73WeK/7CDW+f +nX0Px9hDE74pLr1dsyO21bpY28AdboDrJ6SmkYW1QgN4NnpxNjJPODNvLyrJmp50 +WpwBR5tbsjgaMRyKVyTmtp0DjFvfP8IZK9QmoP9cV8GEZ0oAbW0mxF3AYvcgFsFr +yobiyjwCb/8dVSpsQyQdN3J3M+sLCQK7vtITBrlV2m4LmpkiFY1KdmUDHOdeUC37 +VtFNPV/OOjLp7q0E9Rjgmhite8rBr3gwxLyCUVPbzF0CgYEA6K9wlsYicSdzXjtP +eg8PqTMP4EMgb4SWFo71bSTooqaXWpnGzSr8HAkFt9/COVMuoYabgwPUjG+mXZ8E +/Ey5h6hJnqPOBWSJYK4wkvv/MHlJzNpRXBOIbT/mD2Vi9tDngzQeTXuemZtBuV6I +g4gv9aLgYzh2JD9qeHl9t6B7t6MCgYEA2HSic3UZDQwEIkOeRRVObSDvY167KTs2 +dXywd7HVDG2UGtsJP9NBh3Y3kVa2+u+cayHWcHPkZ36/lBRy8tLQnqNXS9zFFRhK +kD4kwe23N13KnGOrQzePhBBtNniscqTXBFwAgeHCze2qx/gM6y3bB1X+P9cTE1Nd +b3JRTbWKzlsCgYEAp8IlOG8tUcuRn/S+/k9xiRmpbpS3A+/hje4QAFrF5s6Y/Nc1 +v6IoFcZjewg2LcJNMmOsJy9RxNaSaZlGrOhcMvQf7+JFnRm4+h1cI/zPJZGspacZ +VXs3txyEr8D3Mt+2qp+e4VopJLINFqqTXdGIUl7VzHNeqg+Wobll7EgmKmUCgYEA +gmNP8GjbXEaevt0om8jH42jxi1RnPeETXxZrXs7a3Y+spbjIC5CAas9FjeFEfEiW +WtqZSEgnkEiDsvnWfHuNe+I9Fc+5UIm/cMBeeAtwUIPJJwfLBMSVSSJ0B1oN10mA +1HlvPM34AQBn3emILqsCw5qDe4VdUkjngdjFLSBsqv0CgYBz5wKEeikHMrdSfMUN +CRvR/ivt+VIp2nVEupmUo4WZFjzDrvQVVW/yobKkSCYxothETjDahoKo6wQ5xYe+ +Fk/ScnfcTMdbl9FUHnw7SK3kZ9IbzFZD2PTh7g/ZIc1nnsuOye3s7r+52SLtmuJq +y2/etSfNii1ilJseT+mMcbiP3g== +-----END PRIVATE KEY----- diff --git a/certs/private.key b/certs/private.key new file mode 100644 index 000000000000..b8eb41132f0d --- /dev/null +++ b/certs/private.key @@ -0,0 +1,32 @@ +Bag Attributes + localKeyID: E2 25 74 6C 41 B7 A1 77 0E 1A FA A3 E9 1B 1F C5 0C D9 1F 83 + friendlyName: ede958f8-ea14-4287-ad8b-db0dfb10194e +Key Attributes: +-----BEGIN PRIVATE KEY----- +MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDEvgomMfzHpBhF +fTKWdgxF/gclWEBqDgB/GyavNeayCdaB4Wv9269HhQM9kaGWO2H7qUNjvwaX0qrA ++IBAAOwsRwiAFkhdjPJLsCpmAOT7uB8euSKyDAIGoUg69oexBrhTMzeAwhE4GT+y +3FfB/idYk07bB2xOV/29ztuxIqJAeONc0XhTI8PJoDJSOURSHlUXprQvGJpLVW5D +90aXBRl+5VYWWtWQH4ptsi2yPHfJVjx3m+1YtvaS6H1N6hft0nYRVUwt7cQmvDOB +RtEPceswiSh9p9/5xUibz0dMF9gG6kFx/aH6jE1cqeWT4rf7QHjwcb63C76kHA20 +PffWnXDxAgMBAAECggEBAIR7P9RWhtRcoGdylfURitQ66c7w7Xc89IKi4trLHgy3 +aTXOeOzZ2N79J6B3B2tlk2ZmpVVuld74YjlNXPc8Z8ytDIFL//DW73WeK/7CDW+f +nX0Px9hDE74pLr1dsyO21bpY28AdboDrJ6SmkYW1QgN4NnpxNjJPODNvLyrJmp50 +WpwBR5tbsjgaMRyKVyTmtp0DjFvfP8IZK9QmoP9cV8GEZ0oAbW0mxF3AYvcgFsFr +yobiyjwCb/8dVSpsQyQdN3J3M+sLCQK7vtITBrlV2m4LmpkiFY1KdmUDHOdeUC37 +VtFNPV/OOjLp7q0E9Rjgmhite8rBr3gwxLyCUVPbzF0CgYEA6K9wlsYicSdzXjtP +eg8PqTMP4EMgb4SWFo71bSTooqaXWpnGzSr8HAkFt9/COVMuoYabgwPUjG+mXZ8E +/Ey5h6hJnqPOBWSJYK4wkvv/MHlJzNpRXBOIbT/mD2Vi9tDngzQeTXuemZtBuV6I +g4gv9aLgYzh2JD9qeHl9t6B7t6MCgYEA2HSic3UZDQwEIkOeRRVObSDvY167KTs2 +dXywd7HVDG2UGtsJP9NBh3Y3kVa2+u+cayHWcHPkZ36/lBRy8tLQnqNXS9zFFRhK +kD4kwe23N13KnGOrQzePhBBtNniscqTXBFwAgeHCze2qx/gM6y3bB1X+P9cTE1Nd +b3JRTbWKzlsCgYEAp8IlOG8tUcuRn/S+/k9xiRmpbpS3A+/hje4QAFrF5s6Y/Nc1 +v6IoFcZjewg2LcJNMmOsJy9RxNaSaZlGrOhcMvQf7+JFnRm4+h1cI/zPJZGspacZ +VXs3txyEr8D3Mt+2qp+e4VopJLINFqqTXdGIUl7VzHNeqg+Wobll7EgmKmUCgYEA +gmNP8GjbXEaevt0om8jH42jxi1RnPeETXxZrXs7a3Y+spbjIC5CAas9FjeFEfEiW +WtqZSEgnkEiDsvnWfHuNe+I9Fc+5UIm/cMBeeAtwUIPJJwfLBMSVSSJ0B1oN10mA +1HlvPM34AQBn3emILqsCw5qDe4VdUkjngdjFLSBsqv0CgYBz5wKEeikHMrdSfMUN +CRvR/ivt+VIp2nVEupmUo4WZFjzDrvQVVW/yobKkSCYxothETjDahoKo6wQ5xYe+ +Fk/ScnfcTMdbl9FUHnw7SK3kZ9IbzFZD2PTh7g/ZIc1nnsuOye3s7r+52SLtmuJq +y2/etSfNii1ilJseT+mMcbiP3g== +-----END PRIVATE KEY----- diff --git a/certs/russian_trusted_root_ca_pem.crt b/certs/russian_trusted_root_ca_pem.crt new file mode 100644 index 000000000000..a9b27a783c5a --- /dev/null +++ b/certs/russian_trusted_root_ca_pem.crt @@ -0,0 +1,33 @@ +-----BEGIN CERTIFICATE----- +MIIFwjCCA6qgAwIBAgICEAAwDQYJKoZIhvcNAQELBQAwcDELMAkGA1UEBhMCUlUx +PzA9BgNVBAoMNlRoZSBNaW5pc3RyeSBvZiBEaWdpdGFsIERldmVsb3BtZW50IGFu +ZCBDb21tdW5pY2F0aW9uczEgMB4GA1UEAwwXUnVzc2lhbiBUcnVzdGVkIFJvb3Qg +Q0EwHhcNMjIwMzAxMjEwNDE1WhcNMzIwMjI3MjEwNDE1WjBwMQswCQYDVQQGEwJS +VTE/MD0GA1UECgw2VGhlIE1pbmlzdHJ5IG9mIERpZ2l0YWwgRGV2ZWxvcG1lbnQg +YW5kIENvbW11bmljYXRpb25zMSAwHgYDVQQDDBdSdXNzaWFuIFRydXN0ZWQgUm9v +dCBDQTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAMfFOZ8pUAL3+r2n +qqE0Zp52selXsKGFYoG0GM5bwz1bSFtCt+AZQMhkWQheI3poZAToYJu69pHLKS6Q +XBiwBC1cvzYmUYKMYZC7jE5YhEU2bSL0mX7NaMxMDmH2/NwuOVRj8OImVa5s1F4U +zn4Kv3PFlDBjjSjXKVY9kmjUBsXQrIHeaqmUIsPIlNWUnimXS0I0abExqkbdrXbX +YwCOXhOO2pDUx3ckmJlCMUGacUTnylyQW2VsJIyIGA8V0xzdaeUXg0VZ6ZmNUr5Y +Ber/EAOLPb8NYpsAhJe2mXjMB/J9HNsoFMBFJ0lLOT/+dQvjbdRZoOT8eqJpWnVD +U+QL/qEZnz57N88OWM3rabJkRNdU/Z7x5SFIM9FrqtN8xewsiBWBI0K6XFuOBOTD +4V08o4TzJ8+Ccq5XlCUW2L48pZNCYuBDfBh7FxkB7qDgGDiaftEkZZfApRg2E+M9 +G8wkNKTPLDc4wH0FDTijhgxR3Y4PiS1HL2Zhw7bD3CbslmEGgfnnZojNkJtcLeBH +BLa52/dSwNU4WWLubaYSiAmA9IUMX1/RpfpxOxd4Ykmhz97oFbUaDJFipIggx5sX +ePAlkTdWnv+RWBxlJwMQ25oEHmRguNYf4Zr/Rxr9cS93Y+mdXIZaBEE0KS2iLRqa +OiWBki9IMQU4phqPOBAaG7A+eP8PAgMBAAGjZjBkMB0GA1UdDgQWBBTh0YHlzlpf +BKrS6badZrHF+qwshzAfBgNVHSMEGDAWgBTh0YHlzlpfBKrS6badZrHF+qwshzAS +BgNVHRMBAf8ECDAGAQH/AgEEMA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0BAQsF +AAOCAgEAALIY1wkilt/urfEVM5vKzr6utOeDWCUczmWX/RX4ljpRdgF+5fAIS4vH +tmXkqpSCOVeWUrJV9QvZn6L227ZwuE15cWi8DCDal3Ue90WgAJJZMfTshN4OI8cq +W9E4EG9wglbEtMnObHlms8F3CHmrw3k6KmUkWGoa+/ENmcVl68u/cMRl1JbW2bM+ +/3A+SAg2c6iPDlehczKx2oa95QW0SkPPWGuNA/CE8CpyANIhu9XFrj3RQ3EqeRcS +AQQod1RNuHpfETLU/A2gMmvn/w/sx7TB3W5BPs6rprOA37tutPq9u6FTZOcG1Oqj +C/B7yTqgI7rbyvox7DEXoX7rIiEqyNNUguTk/u3SZ4VXE2kmxdmSh3TQvybfbnXV +4JbCZVaqiZraqc7oZMnRoWrXRG3ztbnbes/9qhRGI7PqXqeKJBztxRTEVj8ONs1d +WN5szTwaPIvhkhO3CO5ErU2rVdUr89wKpNXbBODFKRtgxUT70YpmJ46VVaqdAhOZ +D9EUUn4YaeLaS8AjSF/h7UkjOibNc4qVDiPP+rkehFWM66PVnP1Msh93tc+taIfC +EYVMxjh8zNbFuoc7fzvvrFILLe7ifvEIUqSVIC/AzplM/Jxw7buXFeGP1qVCBEHq +391d/9RAfaZ12zkwFsl+IKwE/OZxW8AHa9i1p4GO0YSNuczzEm4= +-----END CERTIFICATE----- \ No newline at end of file diff --git a/certs/russian_trusted_sub_ca_pem.crt b/certs/russian_trusted_sub_ca_pem.crt new file mode 100644 index 000000000000..97769b59bdd2 --- /dev/null +++ b/certs/russian_trusted_sub_ca_pem.crt @@ -0,0 +1,41 @@ +-----BEGIN CERTIFICATE----- +MIIHQjCCBSqgAwIBAgICEAIwDQYJKoZIhvcNAQELBQAwcDELMAkGA1UEBhMCUlUx +PzA9BgNVBAoMNlRoZSBNaW5pc3RyeSBvZiBEaWdpdGFsIERldmVsb3BtZW50IGFu +ZCBDb21tdW5pY2F0aW9uczEgMB4GA1UEAwwXUnVzc2lhbiBUcnVzdGVkIFJvb3Qg +Q0EwHhcNMjIwMzAyMTEyNTE5WhcNMjcwMzA2MTEyNTE5WjBvMQswCQYDVQQGEwJS +VTE/MD0GA1UECgw2VGhlIE1pbmlzdHJ5IG9mIERpZ2l0YWwgRGV2ZWxvcG1lbnQg +YW5kIENvbW11bmljYXRpb25zMR8wHQYDVQQDDBZSdXNzaWFuIFRydXN0ZWQgU3Vi +IENBMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA9YPqBKOk19NFymrE +wehzrhBEgT2atLezpduB24mQ7CiOa/HVpFCDRZzdxqlh8drku408/tTmWzlNH/br +HuQhZ/miWKOf35lpKzjyBd6TPM23uAfJvEOQ2/dnKGGJbsUo1/udKSvxQwVHpVv3 +S80OlluKfhWPDEXQpgyFqIzPoxIQTLZ0deirZwMVHarZ5u8HqHetRuAtmO2ZDGQn +vVOJYAjls+Hiueq7Lj7Oce7CQsTwVZeP+XQx28PAaEZ3y6sQEt6rL06ddpSdoTMp +BnCqTbxW+eWMyjkIn6t9GBtUV45yB1EkHNnj2Ex4GwCiN9T84QQjKSr+8f0psGrZ +vPbCbQAwNFJjisLixnjlGPLKa5vOmNwIh/LAyUW5DjpkCx004LPDuqPpFsKXNKpa +L2Dm6uc0x4Jo5m+gUTVORB6hOSzWnWDj2GWfomLzzyjG81DRGFBpco/O93zecsIN +3SL2Ysjpq1zdoS01CMYxie//9zWvYwzI25/OZigtnpCIrcd2j1Y6dMUFQAzAtHE+ +qsXflSL8HIS+IJEFIQobLlYhHkoE3avgNx5jlu+OLYe0dF0Ykx1PGNjbwqvTX37R +Cn32NMjlotW2QcGEZhDKj+3urZizp5xdTPZitA+aEjZM/Ni71VOdiOP0igbw6asZ +2fxdozZ1TnSSYNYvNATwthNmZysCAwEAAaOCAeUwggHhMBIGA1UdEwEB/wQIMAYB +Af8CAQAwDgYDVR0PAQH/BAQDAgGGMB0GA1UdDgQWBBTR4XENCy2BTm6KSo9MI7NM +XqtpCzAfBgNVHSMEGDAWgBTh0YHlzlpfBKrS6badZrHF+qwshzCBxwYIKwYBBQUH +AQEEgbowgbcwOwYIKwYBBQUHMAKGL2h0dHA6Ly9yb3N0ZWxlY29tLnJ1L2NkcC9y +b290Y2Ffc3NsX3JzYTIwMjIuY3J0MDsGCCsGAQUFBzAChi9odHRwOi8vY29tcGFu +eS5ydC5ydS9jZHAvcm9vdGNhX3NzbF9yc2EyMDIyLmNydDA7BggrBgEFBQcwAoYv +aHR0cDovL3JlZXN0ci1wa2kucnUvY2RwL3Jvb3RjYV9zc2xfcnNhMjAyMi5jcnQw +gbAGA1UdHwSBqDCBpTA1oDOgMYYvaHR0cDovL3Jvc3RlbGVjb20ucnUvY2RwL3Jv +b3RjYV9zc2xfcnNhMjAyMi5jcmwwNaAzoDGGL2h0dHA6Ly9jb21wYW55LnJ0LnJ1 +L2NkcC9yb290Y2Ffc3NsX3JzYTIwMjIuY3JsMDWgM6Axhi9odHRwOi8vcmVlc3Ry +LXBraS5ydS9jZHAvcm9vdGNhX3NzbF9yc2EyMDIyLmNybDANBgkqhkiG9w0BAQsF +AAOCAgEARBVzZls79AdiSCpar15dA5Hr/rrT4WbrOfzlpI+xrLeRPrUG6eUWIW4v +Sui1yx3iqGLCjPcKb+HOTwoRMbI6ytP/ndp3TlYua2advYBEhSvjs+4vDZNwXr/D +anbwIWdurZmViQRBDFebpkvnIvru/RpWud/5r624Wp8voZMRtj/cm6aI9LtvBfT9 +cfzhOaexI/99c14dyiuk1+6QhdwKaCRTc1mdfNQmnfWNRbfWhWBlK3h4GGE9JK33 +Gk8ZS8DMrkdAh0xby4xAQ/mSWAfWrBmfzlOqGyoB1U47WTOeqNbWkkoAP2ys94+s +Jg4NTkiDVtXRF6nr6fYi0bSOvOFg0IQrMXO2Y8gyg9ARdPJwKtvWX8VPADCYMiWH +h4n8bZokIrImVKLDQKHY4jCsND2HHdJfnrdL2YJw1qFskNO4cSNmZydw0Wkgjv9k +F+KxqrDKlB8MZu2Hclph6v/CZ0fQ9YuE8/lsHZ0Qc2HyiSMnvjgK5fDc3TD4fa8F +E8gMNurM+kV8PT8LNIM+4Zs+LKEV8nqRWBaxkIVJGekkVKO8xDBOG/aN62AZKHOe +GcyIdu7yNMMRihGVZCYr8rYiJoKiOzDqOkPkLOPdhtVlgnhowzHDxMHND/E2WA5p +ZHuNM/m0TXt2wTTPL7JH2YC0gPz/BvvSzjksgzU5rLbRyUKQkgU= +-----END CERTIFICATE----- \ No newline at end of file diff --git a/oryx/watcherx/directory.go b/oryx/watcherx/directory.go index 1778a0452e0d..7ac6869b337b 100644 --- a/oryx/watcherx/directory.go +++ b/oryx/watcherx/directory.go @@ -53,7 +53,7 @@ type directoryWatcher struct { // potentially by external callers (e.g. tests) concurrently. subDirsMtx sync.RWMutex subDirs map[string]struct{} - w *fsnotify.Watcher + w *fsnotify.Watcher } func (w *directoryWatcher) handleEvent(ctx context.Context, e fsnotify.Event) { diff --git a/selfservice/strategy/oidc/error.go b/selfservice/strategy/oidc/error.go index 6cbb2bb0c893..77a88d5b94e8 100644 --- a/selfservice/strategy/oidc/error.go +++ b/selfservice/strategy/oidc/error.go @@ -6,24 +6,29 @@ package oidc import ( "io" "net/http" + "regexp" + "strings" "github.com/pkg/errors" + "golang.org/x/oauth2" "github.com/ory/herodot" "github.com/ory/x/logrusx" ) -func ErrScopeMissing() *herodot.DefaultError { - return herodot.ErrBadRequest(). - WithError("authentication failed because a required scope was not granted"). - WithReasonf(`Unable to finish because one or more permissions were not granted. Please retry and accept all permissions.`) -} +const maxUpstreamBodyLogBytes = 512 -func ErrIDTokenMissing() *herodot.DefaultError { - return herodot.ErrBadRequest(). - WithError("authentication failed because id_token is missing"). - WithReasonf(`Authentication failed because no id_token was returned. Please accept the "openid" permission and try again.`) -} +var sensitiveFragmentRE = regexp.MustCompile(`(?i)(access_token|refresh_token|id_token|client_secret|authorization)("?)\s*([:=])\s*("[^"]*"|[^,\s&]+)`) + +var ( + ErrScopeMissing = herodot.ErrBadRequest. + WithError("authentication failed because a required scope was not granted"). + WithReasonf(`Unable to finish because one or more permissions were not granted. Please retry and accept all permissions.`) + + ErrIDTokenMissing = herodot.ErrBadRequest. + WithError("authentication failed because id_token is missing"). + WithReasonf(`Authentication failed because no id_token was returned. Please accept the "openid" permission and try again.`) +) func logUpstreamError(l *logrusx.Logger, resp *http.Response) error { if resp.StatusCode == http.StatusOK { @@ -38,3 +43,35 @@ func logUpstreamError(l *logrusx.Logger, resp *http.Response) error { l.WithField("response_code", resp.StatusCode).WithField("response_body", string(body)).Error("The upstream OIDC provider returned a non 200 status code.") return errors.WithStack(herodot.ErrUpstreamError().WithReasonf("OpenID Connect provider returned a %d status code but 200 is expected.", resp.StatusCode)) } + +func extractOAuth2RetrieveError(err error) (statusCode int, safeBodyFragment string, ok bool) { + var retrieveErr *oauth2.RetrieveError + if !errors.As(err, &retrieveErr) { + return 0, "", false + } + + safeBodyFragment = safeBodyForLog(retrieveErr.Body, maxUpstreamBodyLogBytes) + if retrieveErr.Response != nil { + return retrieveErr.Response.StatusCode, safeBodyFragment, true + } + + return 0, safeBodyFragment, true +} + +func safeBodyForLog(body []byte, limit int) string { + if len(body) == 0 { + return "" + } + + if limit <= 0 { + limit = maxUpstreamBodyLogBytes + } + if len(body) > limit { + body = body[:limit] + } + + safe := strings.ReplaceAll(string(body), "\n", " ") + safe = strings.ReplaceAll(safe, "\r", " ") + safe = sensitiveFragmentRE.ReplaceAllString(safe, `$1$2$3***`) + return strings.TrimSpace(safe) +} diff --git a/selfservice/strategy/oidc/provider.go b/selfservice/strategy/oidc/provider.go index ab62bada4333..f912f482b89d 100644 --- a/selfservice/strategy/oidc/provider.go +++ b/selfservice/strategy/oidc/provider.go @@ -67,29 +67,23 @@ type Claims struct { Website string `json:"website,omitempty"` Email string `json:"email,omitempty"` // ConvertibleBoolean is used as Apple casually sends the email_verified field as a string. - EmailVerified x.ConvertibleBoolean `json:"email_verified,omitempty"` - Gender string `json:"gender,omitempty"` - Birthdate string `json:"birthdate,omitempty"` - Zoneinfo string `json:"zoneinfo,omitempty"` - Locale Locale `json:"locale,omitempty"` - PhoneNumber string `json:"phone_number,omitempty"` - PhoneNumberVerified bool `json:"phone_number_verified,omitempty"` - UpdatedAt int64 `json:"updated_at,omitempty"` - HD string `json:"hd,omitempty"` - Team string `json:"team,omitempty"` - Nonce string `json:"nonce,omitempty"` - NonceSupported bool `json:"nonce_supported,omitempty"` - - // ACR is the Authentication Context Class Reference reported by the - // upstream OIDC provider. See OpenID Connect Core 1.0, Section 2. - ACR string `json:"acr,omitempty"` - - // AMR is the list of Authentication Methods References reported by the - // upstream OIDC provider. See OpenID Connect Core 1.0, Section 2 and - // RFC 8176. - AMR []string `json:"amr,omitempty"` - - RawClaims map[string]any `json:"raw_claims,omitempty"` + EmailVerified x.ConvertibleBoolean `json:"email_verified,omitempty"` + Gender string `json:"gender,omitempty"` + Birthdate string `json:"birthdate,omitempty"` + Zoneinfo string `json:"zoneinfo,omitempty"` + Locale Locale `json:"locale,omitempty"` + PhoneNumber string `json:"phone_number,omitempty"` + PhoneNumberVerified bool `json:"phone_number_verified,omitempty"` + UpdatedAt int64 `json:"updated_at,omitempty"` + HD string `json:"hd,omitempty"` + Team string `json:"team,omitempty"` + City string `json:"city,omitempty"` + Address string `json:"address,omitempty"` + School string `json:"school,omitempty"` + University string `json:"university,omitempty"` + Nonce string `json:"nonce,omitempty"` + NonceSupported bool `json:"nonce_supported,omitempty"` + RawClaims map[string]interface{} `json:"raw_claims,omitempty"` } type Locale string diff --git a/selfservice/strategy/oidc/provider_config.go b/selfservice/strategy/oidc/provider_config.go index 4a1432917527..9a43616f82f0 100644 --- a/selfservice/strategy/oidc/provider_config.go +++ b/selfservice/strategy/oidc/provider_config.go @@ -87,6 +87,14 @@ type Configuration struct { // `provider` is set to `generic`. TokenURL string `json:"token_url"` + // UserInfoURL overrides the default userinfo endpoint for providers with fixed endpoints + // (e.g. sber/sber-ift). Intended for controlled environments and tests. + UserInfoURL string `json:"userinfo_url,omitempty"` + + // AuthCompletedURL overrides Sber "auth completed" endpoint. + // Intended for controlled environments and tests. + AuthCompletedURL string `json:"auth_completed_url,omitempty"` + // Tenant is the Azure AD Tenant to use for authentication, and must be set when `provider` is set to `microsoft`. // Can be either `common`, `organizations`, `consumers` for a multitenant application or a specific tenant like // `8eaef023-2b34-4da1-9baa-8bc8c9d6a490` or `contoso.onmicrosoft.com`. @@ -264,6 +272,8 @@ var supportedProviders = map[string]func(config *Configuration, reg Dependencies "fedcm-test": NewProviderTestFedcm, "amazon": NewProviderAmazon, "uaepass": NewProviderUAEPass, + "sber": NewProviderSber, + "sber-ift": NewProviderSberIft, } func (c ConfigurationCollection) Provider(id string, reg Dependencies) (Provider, error) { diff --git a/selfservice/strategy/oidc/provider_private_net_test.go b/selfservice/strategy/oidc/provider_private_net_test.go index 8dfa2f93a51f..646237cbce2c 100644 --- a/selfservice/strategy/oidc/provider_private_net_test.go +++ b/selfservice/strategy/oidc/provider_private_net_test.go @@ -90,6 +90,7 @@ func TestProviderPrivateIP(t *testing.T) { // Spotify uses a fixed token URL and does not use the issuer. // VK uses a fixed token URL and does not use the issuer. // Yandex uses a fixed token URL and does not use the issuer. + // Sber uses a fixed token URL and does not use the issuer. // NetID uses a fixed token URL and does not use the issuer. // X uses a fixed token URL and userinfoRL and does not use the issuer value. // Line v2.1 uses a fixed token URL and does not use the issuer. diff --git a/selfservice/strategy/oidc/provider_sber.go b/selfservice/strategy/oidc/provider_sber.go new file mode 100644 index 000000000000..b85196324839 --- /dev/null +++ b/selfservice/strategy/oidc/provider_sber.go @@ -0,0 +1,366 @@ +// Copyright © 2023 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "github.com/hashicorp/go-retryablehttp" + "github.com/pkg/errors" + "golang.org/x/oauth2" + + "github.com/ory/x/httpx" + "github.com/ory/x/randx" + + "github.com/ory/herodot" +) + +var _ OAuth2Provider = (*ProviderSber)(nil) + +const sberAPITimeout = 10 * time.Second + +type ProviderSber struct { + config *Configuration + reg Dependencies +} + +func NewProviderSber(config *Configuration, reg Dependencies) Provider { + return &ProviderSber{ + config: config, + reg: reg, + } +} + +func (g *ProviderSber) Config() *Configuration { + return g.config +} + +func (g *ProviderSber) AuthCodeURLOptions(r ider) []oauth2.AuthCodeOption { + return []oauth2.AuthCodeOption{ + oauth2.SetAuthURLParam("nonce", sberNonceFromRequest(g.config.ID, r)), + oauth2.SetAuthURLParam("client_type", "PRIVATE"), + oauth2.SetAuthURLParam("response_type", "code"), + } +} + +func (g *ProviderSber) OAuth2(ctx context.Context) (*oauth2.Config, error) { + oauthConfig := &oauth2.Config{ + ClientID: g.config.ClientID, + ClientSecret: g.config.ClientSecret, + Endpoint: oauth2.Endpoint{ + AuthURL: "https://id.sber.ru/CSAFront/oidc/sberbank_id/authorize.do", + TokenURL: "https://oauth.sber.ru/ru/prod/tokens/v2/oidc", + }, + Scopes: g.config.Scope, + RedirectURL: g.config.Redir(g.reg.Config().OIDCRedirectURIBase(ctx)), + } + + g.reg.Logger(). + WithField("oidc_provider", g.config.ID). + WithField("oidc_stage", "provider_config"). + WithField("token_url", oauthConfig.Endpoint.TokenURL). + Debug("OIDC provider config loaded") + + return oauthConfig, nil +} + +func (g *ProviderSber) Claims(ctx context.Context, exchange *oauth2.Token, _ url.Values) (*Claims, error) { + ctx, cancel := context.WithTimeout(ctx, sberAPITimeout) + defer cancel() + + o, err := g.OAuth2(ctx) + if err != nil { + return nil, err + } + + ctx, client := httpx.SetOAuth2(ctx, g.reg.HTTPClient(ctx), o, exchange) + userinfoURL := sberUserinfoURL("sber", g.config) + stageStart := time.Now() + if oauthHTTPClient := client.HTTPClient; oauthHTTPClient != nil { + mtlsTransport, mtlsCertPath, mtlsKeyPath, mtlsBaseType, mtlsErr := withSberMTLS(baseRoundTripper(oauthHTTPClient.Transport)) + oauthHTTPClient.Transport = mtlsTransport + fields := g.reg.Logger(). + WithField("oidc_provider", g.config.ID). + WithField("oidc_stage", "userinfo_claims"). + WithField("sber_debug_version", sberTokenDebugVersion). + WithField("mtls_cert_path", mtlsCertPath). + WithField("mtls_key_path", mtlsKeyPath). + WithField("mtls_base_transport_type", mtlsBaseType) + if mtlsErr != nil { + fields.WithError(mtlsErr).Error("Failed to attach mTLS certificate for Sber userinfo request") + } else { + fields.Debug("Attached mTLS certificate for Sber userinfo request") + } + } + + req, err := retryablehttp.NewRequestWithContext( + ctx, + "GET", + userinfoURL, + nil, + ) + if err != nil { + return nil, errors.WithStack(herodot.ErrInternalServerError.WithWrap(err).WithReasonf("%s", err)) + } + + var hexRunes = []rune("0123456789ABCDEF") + requestID := randx.MustString(32, hexRunes) + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", exchange.AccessToken)) + req.Header.Set("x-introspect-rquid", requestID) + + g.reg.Logger(). + WithField("oidc_provider", g.config.ID). + WithField("oidc_stage", "userinfo_claims"). + WithField("sber_debug_version", sberTokenDebugVersion). + WithField("request_id", requestID). + WithField("userinfo_url", userinfoURL). + WithField("access_token_present", exchange.AccessToken != ""). + WithField("access_token_len", len(exchange.AccessToken)). + Debug("Starting OIDC userinfo request") + + resp, err := client.Do(req) + if err != nil { + g.reg.Logger(). + WithError(err). + WithField("oidc_provider", g.config.ID). + WithField("oidc_stage", "userinfo_claims"). + WithField("request_id", requestID). + WithField("userinfo_url", userinfoURL). + WithField("latency_ms", time.Since(stageStart).Milliseconds()). + Debug("OIDC userinfo request failed") + return nil, errors.WithStack(herodot.ErrUpstreamError.WithWrap(err).WithReasonf("%s", err)) + } + defer func() { _ = resp.Body.Close() }() + + g.reg.Logger(). + WithField("oidc_provider", g.config.ID). + WithField("oidc_stage", "userinfo_claims"). + WithField("request_id", requestID). + WithField("userinfo_url", userinfoURL). + WithField("http_status", resp.StatusCode). + WithField("latency_ms", time.Since(stageStart).Milliseconds()). + Debug("OIDC userinfo response received") + + if resp.StatusCode != http.StatusOK { + body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1024)) + if readErr != nil { + g.reg.Logger(). + WithError(readErr). + WithField("oidc_provider", g.config.ID). + WithField("oidc_stage", "userinfo_claims"). + WithField("request_id", requestID). + WithField("userinfo_url", userinfoURL). + WithField("http_status", resp.StatusCode). + WithField("latency_ms", time.Since(stageStart).Milliseconds()). + Error("OIDC userinfo response read failed") + } + + bodyFragment := safeBodyForLog(body, maxUpstreamBodyLogBytes) + fields := g.reg.Logger(). + WithField("oidc_provider", g.config.ID). + WithField("oidc_stage", "userinfo_claims"). + WithField("request_id", requestID). + WithField("userinfo_url", userinfoURL). + WithField("http_status", resp.StatusCode). + WithField("latency_ms", time.Since(stageStart).Milliseconds()) + if bodyFragment != "" { + fields = fields.WithField("response_body_fragment", bodyFragment) + } + fields.Error("OIDC userinfo failed with upstream response") + + return nil, errors.WithStack( + herodot.ErrUpstreamError. + WithReasonf("OpenID Connect provider returned a %d status code but 200 is expected. debug_version=%s stage=userinfo_claims provider=%s request_id=%s userinfo_url=%s access_token_present=%t access_token_len=%d response=%q", + resp.StatusCode, + sberTokenDebugVersion, + g.config.ID, + requestID, + userinfoURL, + exchange.AccessToken != "", + len(exchange.AccessToken), + bodyFragment, + ), + ) + } + + var user struct { + Sub string `json:"sub"` + Email string `json:"email"` + PhoneNumber string `json:"phone_number"` + GivenName string `json:"given_name"` + FamilyName string `json:"family_name"` + MiddleName string `json:"middle_name"` + BirthDate string `json:"birthdate"` + Gender int `json:"gender"` + Picture string `json:"picture"` + AvatarURL string `json:"avatar_url"` + City string `json:"city"` + Address string `json:"address"` + School string `json:"school"` + University string `json:"university"` + } + + if err = json.NewDecoder(resp.Body).Decode(&user); err != nil { + return nil, errors.WithStack(herodot.ErrUpstreamError.WithWrap(err).WithReasonf("%s", err)) + } + + gender := "" + switch user.Gender { + case 1: + gender = "female" + case 2: + gender = "male" + } + + picture := user.Picture + if picture == "" { + picture = user.AvatarURL + } + + claims := &Claims{ + Issuer: "https://oauth.sber.ru/ru/prod/sberbankid/v2.1/userinfo", + Subject: user.Sub, + GivenName: normalizeNameTitle(user.GivenName), + FamilyName: normalizeNameTitle(user.FamilyName), + LastName: normalizeNameTitle(user.FamilyName), + MiddleName: normalizeNameTitle(user.MiddleName), + Email: normalizeEmailLower(user.Email), + PhoneNumber: normalizeRussianMobilePlus79(user.PhoneNumber), + Birthdate: user.BirthDate, + Gender: gender, + Picture: picture, + City: user.City, + Address: user.Address, + School: user.School, + University: user.University, + RawClaims: map[string]interface{}{ + "sub": user.Sub, + "email": user.Email, + "phone_number": user.PhoneNumber, + "given_name": user.GivenName, + "family_name": user.FamilyName, + "middle_name": user.MiddleName, + "birthdate": user.BirthDate, + }, + } + + g.reg.Logger(). + WithField("oidc_provider", g.config.ID). + WithField("oidc_stage", "userinfo_claims"). + WithField("request_id", requestID). + WithField("userinfo_url", userinfoURL). + WithField("userinfo_given_name_all_upper", isAllUpperText(user.GivenName)). + WithField("userinfo_family_name_all_upper", isAllUpperText(user.FamilyName)). + WithField("userinfo_email_all_upper", isAllUpperText(user.Email)). + WithSensitiveField("userinfo_claims_raw", user). + WithSensitiveField("userinfo_claims_mapped", claims). + WithField("latency_ms", time.Since(stageStart).Milliseconds()). + Debug("OIDC userinfo claims parsed") + + return claims, nil +} + +func (g *ProviderSber) Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) { + ctx, cancel := context.WithTimeout(ctx, sberAPITimeout) + defer cancel() + + o, err := g.OAuth2(ctx) + if err != nil { + return nil, err + } + + tokenURL, err := url.Parse(o.Endpoint.TokenURL) + if err != nil { + return nil, errors.WithStack(herodot.ErrInternalServerError.WithWrap(err).WithReasonf("%s", err)) + } + + tokenEndpoint := fmt.Sprintf("%s://%s%s", tokenURL.Scheme, tokenURL.Host, tokenURL.Path) + requestID := randx.MustString(32, []rune("0123456789ABCDEF")) + stageStart := time.Now() + exchangeTrace := &sberTokenExchangeTrace{} + client := g.reg.HTTPClient(ctx).HTTPClient + clientTimeout := client.Timeout + if clientTimeout == 0 || clientTimeout > sberAPITimeout { + clientTimeout = sberAPITimeout + } + + ctx = context.WithValue(ctx, oauth2.HTTPClient, &http.Client{ + Transport: &sberTokenLoggingTransport{ + base: client.Transport, + logger: g.reg.Logger(), + providerID: g.config.ID, + tokenURL: tokenEndpoint, + requestID: requestID, + startedAt: stageStart, + trace: exchangeTrace, + }, + Timeout: clientTimeout, + }) + + token, err := o.Exchange(ctx, code, opts...) + if err != nil { + fields := g.reg.Logger(). + WithError(err). + WithField("oidc_provider", g.config.ID). + WithField("oidc_stage", "token_exchange"). + WithField("request_id", requestID). + WithField("token_url", tokenEndpoint). + WithField("latency_ms", time.Since(stageStart).Milliseconds()) + + if statusCode, bodyFragment, ok := extractOAuth2RetrieveError(err); ok { + fields = fields.WithField("http_status", statusCode) + if bodyFragment != "" { + fields = fields.WithField("response_body_fragment", bodyFragment) + } + fields.Error("OIDC token exchange failed with upstream response") + curlCmd := formatSberTokenExchangeCurl(tokenEndpoint, requestID, exchangeTrace.RequestForm, exchangeTrace.TLSClientCertPresent) + return nil, errors.WithStack( + herodot.ErrUpstreamError. + WithWrap(err). + WithReasonf("sber token exchange failed: debug_version=%s stage=token_exchange provider=%s token_endpoint=%s request_id=%s http_status=%d response=%q tls_client_cert_present=%t tls_client_config_absent=%t mtls_cert_path=%q mtls_key_path=%q mtls_attach_error=%q curl_request=%q", + sberTokenDebugVersion, + g.config.ID, + tokenEndpoint, + requestID, + statusCode, + bodyFragment, + exchangeTrace.TLSClientCertPresent, + exchangeTrace.TLSClientConfigAbsent, + exchangeTrace.MTLSCertPath, + exchangeTrace.MTLSKeyPath, + exchangeTrace.MTLSAttachError, + curlCmd, + ), + ) + } + + fields.Error("OIDC token exchange failed") + return nil, errors.WithStack( + herodot.ErrUpstreamError. + WithWrap(err). + WithReasonf("Sber token exchange failed: stage=token_exchange provider=%s token_endpoint=%s request_id=%s", g.config.ID, tokenEndpoint, requestID), + ) + } + + if err := validateSberIDToken(token, g.config.ID, g.config.ClientID, sberFlowIDFromContext(ctx)); err != nil { + return nil, err + } + + g.reg.Logger(). + WithField("oidc_provider", g.config.ID). + WithField("oidc_stage", "token_exchange"). + WithField("request_id", requestID). + WithField("token_url", tokenEndpoint). + WithField("latency_ms", time.Since(stageStart).Milliseconds()). + Debug("OIDC token exchange succeeded") + + return token, nil +} diff --git a/selfservice/strategy/oidc/provider_sber_common.go b/selfservice/strategy/oidc/provider_sber_common.go new file mode 100644 index 000000000000..6f2fe07f7042 --- /dev/null +++ b/selfservice/strategy/oidc/provider_sber_common.go @@ -0,0 +1,240 @@ +// Copyright © 2026 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "math" + "strconv" + "strings" + "time" + + "github.com/gofrs/uuid" + "github.com/pkg/errors" + "golang.org/x/oauth2" + + "github.com/ory/herodot" +) + +const sberTokenDebugVersion = "sber-token-debug-v3" + +func sberNonceFromRequest(providerID string, req ider) string { + if req == nil { + return "" + } + return sberNonceFromFlowID(providerID, req.GetID()) +} + +func sberNonceFromFlowID(providerID string, flowID uuid.UUID) string { + sum := sha256.Sum256([]byte(providerID + ":" + flowID.String())) + nonce := hex.EncodeToString(sum[:]) + if len(nonce) > 32 { + return nonce[:32] + } + return nonce +} + +func validateSberIDToken(token *oauth2.Token, providerID, clientID string, flowID uuid.UUID) error { + if flowID == uuid.Nil { + return nil + } + + if token == nil { + return errors.WithStack(herodot.ErrUpstreamError.WithReasonf("sber id_token validation failed: token is missing")) + } + + rawIDToken, _ := token.Extra("id_token").(string) + if rawIDToken == "" { + return errors.WithStack(herodot.ErrUpstreamError.WithReasonf("sber id_token validation failed: id_token is missing")) + } + + claims, err := decodeJWTClaims(rawIDToken) + if err != nil { + return errors.WithStack(herodot.ErrUpstreamError.WithWrap(err).WithReasonf("sber id_token validation failed: unable to parse id_token")) + } + + aud, err := claimAudience(claims["aud"]) + if err != nil { + return errors.WithStack(herodot.ErrUpstreamError.WithWrap(err).WithReasonf("sber id_token validation failed: invalid aud claim")) + } + if aud != clientID { + return errors.WithStack(herodot.ErrUpstreamError.WithReasonf("sber id_token validation failed: aud mismatch for provider=%s", providerID)) + } + + now := time.Now().Unix() + iat, err := claimUnixTime(claims["iat"]) + if err != nil { + return errors.WithStack(herodot.ErrUpstreamError.WithWrap(err).WithReasonf("sber id_token validation failed: invalid iat claim")) + } + exp, err := claimUnixTime(claims["exp"]) + if err != nil { + return errors.WithStack(herodot.ErrUpstreamError.WithWrap(err).WithReasonf("sber id_token validation failed: invalid exp claim")) + } + if exp <= now { + return errors.WithStack(herodot.ErrUpstreamError.WithReasonf("sber id_token validation failed: id_token expired")) + } + if iat > now+60 { + return errors.WithStack(herodot.ErrUpstreamError.WithReasonf("sber id_token validation failed: iat is in the future")) + } + + expectedNonce := sberNonceFromFlowID(providerID, flowID) + nonce, _ := claims["nonce"].(string) + if expectedNonce == "" || nonce == "" || nonce != expectedNonce { + return errors.WithStack(herodot.ErrUpstreamError.WithReasonf("sber id_token validation failed: nonce mismatch for provider=%s", providerID)) + } + + return nil +} + +func decodeJWTClaims(rawToken string) (map[string]interface{}, error) { + parts := strings.Split(rawToken, ".") + if len(parts) < 2 { + return nil, errors.New("jwt has invalid format") + } + + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return nil, errors.WithStack(err) + } + + var claims map[string]interface{} + if err := json.Unmarshal(payload, &claims); err != nil { + return nil, errors.WithStack(err) + } + + return claims, nil +} + +func claimAudience(v interface{}) (string, error) { + switch aud := v.(type) { + case string: + if aud == "" { + return "", errors.New("aud is empty") + } + return aud, nil + case []interface{}: + if len(aud) == 0 { + return "", errors.New("aud array is empty") + } + audStr, ok := aud[0].(string) + if !ok || audStr == "" { + return "", errors.New("aud[0] is invalid") + } + return audStr, nil + default: + return "", errors.New("aud has unexpected type") + } +} + +func claimUnixTime(v interface{}) (int64, error) { + switch t := v.(type) { + case float64: + if math.IsNaN(t) || math.IsInf(t, 0) { + return 0, errors.New("invalid numeric time") + } + return int64(t), nil + case json.Number: + return t.Int64() + case string: + if t == "" { + return 0, errors.New("empty time string") + } + return strconv.ParseInt(t, 10, 64) + default: + return 0, fmt.Errorf("unexpected time type: %T", v) + } +} + +func sberAllSubjects(claims *Claims) []string { + if claims == nil { + return nil + } + + subjects := make([]string, 0, 2) + if claims.Subject != "" { + subjects = append(subjects, claims.Subject) + } + + if claims.RawClaims == nil { + return subjects + } + + subAlt, ok := claims.RawClaims["sub_alt"] + if !ok { + return subjects + } + + appendUnique := func(v string) { + if v == "" { + return + } + for _, s := range subjects { + if s == v { + return + } + } + subjects = append(subjects, v) + } + + switch raw := subAlt.(type) { + case string: + appendUnique(raw) + case []interface{}: + for _, item := range raw { + if s, ok := item.(string); ok { + appendUnique(s) + } + } + } + + return subjects +} + +type sberFlowIDContextKey struct{} + +func withSberFlowID(ctx context.Context, flowID uuid.UUID) context.Context { + if flowID == uuid.Nil { + return ctx + } + return context.WithValue(ctx, sberFlowIDContextKey{}, flowID) +} + +func sberFlowIDFromContext(ctx context.Context) uuid.UUID { + if ctx == nil { + return uuid.Nil + } + if flowID, ok := ctx.Value(sberFlowIDContextKey{}).(uuid.UUID); ok { + return flowID + } + return uuid.Nil +} + +func isSberProviderID(providerID string) bool { + return providerID == "sber" || providerID == "sber-ift" +} + +func sberUserinfoURL(providerID string, cfg *Configuration) string { + if cfg != nil && cfg.UserInfoURL != "" { + return cfg.UserInfoURL + } + if providerID == "sber-ift" { + return "https://oauth-ift.sber.ru/ru/prod/sberbankid/v2.1/userinfo" + } + return "https://oauth.sber.ru/ru/prod/sberbankid/v2.1/userinfo" +} + +func sberAuthCompletedURL(providerID string, cfg *Configuration) string { + if cfg != nil && cfg.AuthCompletedURL != "" { + return cfg.AuthCompletedURL + } + if providerID == "sber-ift" { + return "https://oauth-ift.sber.ru/api/v2/auth/completed" + } + return "https://oauth.sber.ru/api/v2/auth/completed" +} diff --git a/selfservice/strategy/oidc/provider_sber_ift.go b/selfservice/strategy/oidc/provider_sber_ift.go new file mode 100644 index 000000000000..77f6cccef377 --- /dev/null +++ b/selfservice/strategy/oidc/provider_sber_ift.go @@ -0,0 +1,392 @@ +// Copyright © 2023 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "github.com/hashicorp/go-retryablehttp" + "github.com/pkg/errors" + "golang.org/x/oauth2" + + "github.com/ory/x/httpx" + "github.com/ory/x/randx" + + "github.com/ory/herodot" +) + +var _ OAuth2Provider = (*ProviderSberIft)(nil) + +type ProviderSberIft struct { + config *Configuration + reg Dependencies + authURL string + tokenURL string + userinfoURL string +} + +func NewProviderSberIft(config *Configuration, reg Dependencies) Provider { + if config.PKCE == "" { + config.PKCE = "auto" + } + + authURL := "https://id-ift.sber.ru/CSAFront/oidc/sberbank_id/authorize.do" + if config.AuthURL != "" { + authURL = config.AuthURL + } + + tokenURL := "https://oauth-ift.sber.ru/ru/prod/tokens/v2/oidc" + if config.TokenURL != "" { + tokenURL = config.TokenURL + } + + return &ProviderSberIft{ + config: config, + reg: reg, + authURL: authURL, + tokenURL: tokenURL, + userinfoURL: sberUserinfoURL("sber-ift", config), + } +} + +func (g *ProviderSberIft) Config() *Configuration { + return g.config +} + +func (g *ProviderSberIft) AuthCodeURLOptions(r ider) []oauth2.AuthCodeOption { + return []oauth2.AuthCodeOption{ + oauth2.SetAuthURLParam("nonce", sberNonceFromRequest(g.config.ID, r)), + } +} + +func (g *ProviderSberIft) OAuth2(ctx context.Context) (*oauth2.Config, error) { + oauthConfig := &oauth2.Config{ + ClientID: g.config.ClientID, + ClientSecret: g.config.ClientSecret, + Endpoint: oauth2.Endpoint{ + AuthURL: g.authURL, + TokenURL: g.tokenURL, + AuthStyle: oauth2.AuthStyleInParams, + }, + Scopes: g.config.Scope, + RedirectURL: g.config.Redir(g.reg.Config().OIDCRedirectURIBase(ctx)), + } + + g.reg.Logger(). + WithField("oidc_provider", g.config.ID). + WithField("oidc_stage", "provider_config"). + WithField("token_url", oauthConfig.Endpoint.TokenURL). + Debug("OIDC provider config loaded") + + return oauthConfig, nil +} + +func (g *ProviderSberIft) Claims(ctx context.Context, exchange *oauth2.Token, _ url.Values) (*Claims, error) { + ctx, cancel := context.WithTimeout(ctx, sberAPITimeout) + defer cancel() + + o, err := g.OAuth2(ctx) + if err != nil { + return nil, err + } + + ctx, client := httpx.SetOAuth2(ctx, g.reg.HTTPClient(ctx), o, exchange) + userinfoURL := g.userinfoURL + stageStart := time.Now() + if oauthHTTPClient := client.HTTPClient; oauthHTTPClient != nil { + mtlsTransport, mtlsCertPath, mtlsKeyPath, mtlsBaseType, mtlsErr := withSberMTLS(baseRoundTripper(oauthHTTPClient.Transport)) + oauthHTTPClient.Transport = mtlsTransport + fields := g.reg.Logger(). + WithField("oidc_provider", g.config.ID). + WithField("oidc_stage", "userinfo_claims"). + WithField("sber_debug_version", sberTokenDebugVersion). + WithField("mtls_cert_path", mtlsCertPath). + WithField("mtls_key_path", mtlsKeyPath). + WithField("mtls_base_transport_type", mtlsBaseType) + if mtlsErr != nil { + fields.WithError(mtlsErr).Error("Failed to attach mTLS certificate for Sber userinfo request") + } else { + fields.Debug("Attached mTLS certificate for Sber userinfo request") + } + } + + req, err := retryablehttp.NewRequestWithContext( + ctx, + "GET", + userinfoURL, + nil, + ) + if err != nil { + return nil, errors.WithStack(herodot.ErrInternalServerError.WithWrap(err).WithReasonf("%s", err)) + } + + var hexRunes = []rune("0123456789ABCDEF") + requestID := randx.MustString(32, hexRunes) + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", exchange.AccessToken)) + req.Header.Set("x-introspect-rquid", requestID) + + g.reg.Logger(). + WithField("oidc_provider", g.config.ID). + WithField("oidc_stage", "userinfo_claims"). + WithField("sber_debug_version", sberTokenDebugVersion). + WithField("request_id", requestID). + WithField("userinfo_url", userinfoURL). + WithField("access_token_present", exchange.AccessToken != ""). + WithField("access_token_len", len(exchange.AccessToken)). + Debug("Starting OIDC userinfo request") + + resp, err := client.Do(req) + if err != nil { + g.reg.Logger(). + WithError(err). + WithField("oidc_provider", g.config.ID). + WithField("oidc_stage", "userinfo_claims"). + WithField("request_id", requestID). + WithField("userinfo_url", userinfoURL). + WithField("latency_ms", time.Since(stageStart).Milliseconds()). + Error("OIDC userinfo request failed") + return nil, errors.WithStack(herodot.ErrUpstreamError.WithWrap(err).WithReasonf("%s", err)) + } + defer func() { _ = resp.Body.Close() }() + + g.reg.Logger(). + WithField("oidc_provider", g.config.ID). + WithField("oidc_stage", "userinfo_claims"). + WithField("sber_debug_version", sberTokenDebugVersion). + WithField("request_id", requestID). + WithField("userinfo_url", userinfoURL). + WithField("http_status", resp.StatusCode). + WithField("latency_ms", time.Since(stageStart).Milliseconds()). + Debug("OIDC userinfo response received") + + if resp.StatusCode != http.StatusOK { + body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1024)) + if readErr != nil { + g.reg.Logger(). + WithError(readErr). + WithField("oidc_provider", g.config.ID). + WithField("oidc_stage", "userinfo_claims"). + WithField("request_id", requestID). + WithField("userinfo_url", userinfoURL). + WithField("http_status", resp.StatusCode). + WithField("latency_ms", time.Since(stageStart).Milliseconds()). + Error("OIDC userinfo response read failed") + } + + bodyFragment := safeBodyForLog(body, maxUpstreamBodyLogBytes) + fields := g.reg.Logger(). + WithField("oidc_provider", g.config.ID). + WithField("oidc_stage", "userinfo_claims"). + WithField("request_id", requestID). + WithField("userinfo_url", userinfoURL). + WithField("http_status", resp.StatusCode). + WithField("latency_ms", time.Since(stageStart).Milliseconds()) + if bodyFragment != "" { + fields = fields.WithField("response_body_fragment", bodyFragment) + } + fields.Error("OIDC userinfo failed with upstream response") + + return nil, errors.WithStack( + herodot.ErrUpstreamError. + WithReasonf("OpenID Connect provider returned a %d status code but 200 is expected. debug_version=%s stage=userinfo_claims provider=%s request_id=%s userinfo_url=%s access_token_present=%t access_token_len=%d response=%q", + resp.StatusCode, + sberTokenDebugVersion, + g.config.ID, + requestID, + userinfoURL, + exchange.AccessToken != "", + len(exchange.AccessToken), + bodyFragment, + ), + ) + } + + var user struct { + Sub string `json:"sub"` + Email string `json:"email"` + PhoneNumber string `json:"phone_number"` + GivenName string `json:"given_name"` + FamilyName string `json:"family_name"` + MiddleName string `json:"middle_name"` + BirthDate string `json:"birthdate"` + Gender int `json:"gender"` + Picture string `json:"picture"` + AvatarURL string `json:"avatar_url"` + City string `json:"city"` + Address string `json:"address"` + School string `json:"school"` + University string `json:"university"` + } + + if err = json.NewDecoder(resp.Body).Decode(&user); err != nil { + return nil, errors.WithStack(herodot.ErrUpstreamError.WithWrap(err).WithReasonf("reason %s", err)) + } + + gender := "" + switch user.Gender { + case 1: + gender = "female" + case 2: + gender = "male" + } + + picture := user.Picture + if picture == "" { + picture = user.AvatarURL + } + + claims := &Claims{ + Issuer: "https://oauth-ift.sber.ru/ru/prod/sberbankid/v2.1/userinfo", + Subject: user.Sub, + GivenName: normalizeNameTitle(user.GivenName), + FamilyName: normalizeNameTitle(user.FamilyName), + LastName: normalizeNameTitle(user.FamilyName), + MiddleName: normalizeNameTitle(user.MiddleName), + Email: normalizeEmailLower(user.Email), + PhoneNumber: normalizeRussianMobilePlus79(user.PhoneNumber), + Birthdate: user.BirthDate, + Gender: gender, + Picture: picture, + City: user.City, + Address: user.Address, + School: user.School, + University: user.University, + RawClaims: map[string]interface{}{ + "sub": user.Sub, + "email": user.Email, + "phone_number": user.PhoneNumber, + "given_name": user.GivenName, + "family_name": user.FamilyName, + "middle_name": user.MiddleName, + "birthdate": user.BirthDate, + }, + } + + g.reg.Logger(). + WithField("oidc_provider", g.config.ID). + WithField("oidc_stage", "userinfo_claims"). + WithField("request_id", requestID). + WithField("userinfo_url", userinfoURL). + WithField("userinfo_given_name_all_upper", isAllUpperText(user.GivenName)). + WithField("userinfo_family_name_all_upper", isAllUpperText(user.FamilyName)). + WithField("userinfo_email_all_upper", isAllUpperText(user.Email)). + WithSensitiveField("userinfo_claims_raw", user). + WithSensitiveField("userinfo_claims_mapped", claims). + WithField("latency_ms", time.Since(stageStart).Milliseconds()). + Debug("OIDC userinfo claims parsed") + + return claims, nil +} + +func (g *ProviderSberIft) Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) { + ctx, cancel := context.WithTimeout(ctx, sberAPITimeout) + defer cancel() + + o, err := g.OAuth2(ctx) + if err != nil { + return nil, err + } + + tokenURL, err := url.Parse(o.Endpoint.TokenURL) + if err != nil { + return nil, errors.WithStack(herodot.ErrInternalServerError.WithWrap(err).WithReasonf("%s", err)) + } + + tokenEndpoint := fmt.Sprintf("%s://%s%s", tokenURL.Scheme, tokenURL.Host, tokenURL.Path) + requestID := randx.MustString(32, []rune("0123456789ABCDEF")) + stageStart := time.Now() + exchangeTrace := &sberTokenExchangeTrace{} + client := g.reg.HTTPClient(ctx).HTTPClient + clientTimeout := client.Timeout + if clientTimeout == 0 || clientTimeout > sberAPITimeout { + clientTimeout = sberAPITimeout + } + + ctx = context.WithValue(ctx, oauth2.HTTPClient, &http.Client{ + Transport: &sberTokenLoggingTransport{ + base: client.Transport, + logger: g.reg.Logger(), + providerID: g.config.ID, + tokenURL: tokenEndpoint, + requestID: requestID, + startedAt: stageStart, + trace: exchangeTrace, + }, + Timeout: clientTimeout, + }) + + token, err := o.Exchange(ctx, code, opts...) + if err != nil { + fields := g.reg.Logger(). + WithError(err). + WithField("oidc_provider", g.config.ID). + WithField("oidc_stage", "token_exchange"). + WithField("request_id", requestID). + WithField("token_url", tokenEndpoint). + WithField("latency_ms", time.Since(stageStart).Milliseconds()) + + if statusCode, bodyFragment, ok := extractOAuth2RetrieveError(err); ok { + fields = fields.WithField("http_status", statusCode) + if bodyFragment != "" { + fields = fields.WithField("response_body_fragment", bodyFragment) + } + fields.Error("OIDC token exchange failed with upstream response") + + reason := "sber token exchange failed" + if statusCode == http.StatusUnauthorized { + reason = "sber token exchange unauthorized" + } + + curlCmd := formatSberTokenExchangeCurl(tokenEndpoint, requestID, exchangeTrace.RequestForm, exchangeTrace.TLSClientCertPresent) + + return nil, errors.WithStack( + herodot.ErrUpstreamError. + WithWrap(err). + WithReasonf("%s: debug_version=%s stage=token_exchange provider=%s token_endpoint=%s request_id=%s http_status=%d response=%q tls_client_cert_present=%t tls_client_config_absent=%t mtls_cert_path=%q mtls_key_path=%q mtls_attach_error=%q curl_request=%q", + reason, + sberTokenDebugVersion, + g.config.ID, + tokenEndpoint, + requestID, + statusCode, + bodyFragment, + exchangeTrace.TLSClientCertPresent, + exchangeTrace.TLSClientConfigAbsent, + exchangeTrace.MTLSCertPath, + exchangeTrace.MTLSKeyPath, + exchangeTrace.MTLSAttachError, + curlCmd, + ), + ) + } + + fields.Error("OIDC token exchange failed") + return nil, errors.WithStack( + herodot.ErrUpstreamError. + WithWrap(err). + WithReasonf("Sber token exchange failed: stage=token_exchange provider=%s token_endpoint=%s request_id=%s", g.config.ID, tokenEndpoint, requestID), + ) + } + + if err := validateSberIDToken(token, g.config.ID, g.config.ClientID, sberFlowIDFromContext(ctx)); err != nil { + return nil, err + } + + g.reg.Logger(). + WithField("oidc_provider", g.config.ID). + WithField("oidc_stage", "token_exchange"). + WithField("request_id", requestID). + WithField("token_url", tokenEndpoint). + WithField("latency_ms", time.Since(stageStart).Milliseconds()). + Debug("OIDC token exchange succeeded") + + return token, nil +} diff --git a/selfservice/strategy/oidc/provider_sber_ift_test.go b/selfservice/strategy/oidc/provider_sber_ift_test.go new file mode 100644 index 000000000000..25d49e1075ad --- /dev/null +++ b/selfservice/strategy/oidc/provider_sber_ift_test.go @@ -0,0 +1,127 @@ +// Copyright © 2026 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package oidc_test + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ory/herodot" + + "github.com/ory/kratos/pkg" + "github.com/ory/kratos/selfservice/strategy/oidc" +) + +func TestProviderSberIft_DefaultPKCE(t *testing.T) { + _, reg := pkg.NewVeryFastRegistryWithoutDB(t) + + cfg := &oidc.Configuration{ + ID: "sber-ift", + ClientID: "client", + ClientSecret: "secret", + } + p := oidc.NewProviderSberIft(cfg, reg) + + assert.Equal(t, "auto", p.Config().PKCE) +} + +func TestProviderSberIft_ExchangeRequest(t *testing.T) { + _, reg := pkg.NewVeryFastRegistryWithoutDB(t) + + var ( + gotMethod string + gotContentType string + gotAccept string + gotRqUID string + gotForm url.Values + ) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + gotContentType = r.Header.Get("Content-Type") + gotAccept = r.Header.Get("accept") + gotRqUID = r.Header.Get("RqUID") + + err := r.ParseForm() + require.NoError(t, err) + gotForm = r.Form + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "access_token": "token", + "token_type": "bearer", + "expires_in": 60, + "id_token": "id-token", + "refresh_token": "refresh-token", + }) + })) + t.Cleanup(ts.Close) + + cfg := &oidc.Configuration{ + ID: "sber-ift", + ClientID: "client-id", + ClientSecret: "client-secret", + Scope: []string{"openid"}, + AuthURL: ts.URL + "/authorize", + TokenURL: ts.URL, + } + p := oidc.NewProviderSberIft(cfg, reg).(oidc.OAuth2TokenExchanger) + + token, err := p.Exchange(context.Background(), "auth-code") + require.NoError(t, err) + require.NotNil(t, token) + + assert.Equal(t, http.MethodPost, gotMethod) + assert.Contains(t, gotContentType, "application/x-www-form-urlencoded") + assert.Equal(t, "application/json", gotAccept) + assert.Len(t, gotRqUID, 32) + + assert.Equal(t, "authorization_code", gotForm.Get("grant_type")) + assert.Equal(t, "auth-code", gotForm.Get("code")) + assert.Equal(t, "client-id", gotForm.Get("client_id")) + assert.Equal(t, "client-secret", gotForm.Get("client_secret")) + assert.NotEmpty(t, gotForm.Get("redirect_uri")) +} + +func TestProviderSberIft_ExchangeUnauthorized(t *testing.T) { + _, reg := pkg.NewVeryFastRegistryWithoutDB(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = io.WriteString(w, `{"error":"invalid_client","client_secret":"super-secret","access_token":"very-secret-token"}`) + })) + t.Cleanup(ts.Close) + + cfg := &oidc.Configuration{ + ID: "sber-ift", + ClientID: "client-id", + ClientSecret: "client-secret", + Scope: []string{"openid"}, + AuthURL: ts.URL + "/authorize", + TokenURL: ts.URL, + } + p := oidc.NewProviderSberIft(cfg, reg).(oidc.OAuth2TokenExchanger) + + _, err := p.Exchange(context.Background(), "auth-code") + require.Error(t, err) + + var he *herodot.DefaultError + require.ErrorAs(t, err, &he, "%+v", err) + reason := he.Reason() + assert.Contains(t, reason, "sber token exchange unauthorized") + assert.Contains(t, reason, "stage=token_exchange") + assert.Contains(t, reason, "http_status=401") + assert.Contains(t, reason, "provider=sber-ift") + assert.Contains(t, reason, "curl_request=") + assert.Contains(t, reason, "--request POST") + assert.Contains(t, reason, "--header 'rquid:") +} diff --git a/selfservice/strategy/oidc/provider_sber_test.go b/selfservice/strategy/oidc/provider_sber_test.go new file mode 100644 index 000000000000..ea18b2baa5d8 --- /dev/null +++ b/selfservice/strategy/oidc/provider_sber_test.go @@ -0,0 +1,59 @@ +// Copyright © 2023 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package oidc_test + +import ( + "context" + "net/url" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/oauth2" + + "github.com/ory/kratos/pkg" + "github.com/ory/kratos/selfservice/strategy/oidc" + + "testing" +) + +func TestProviderSber_OAuth2(t *testing.T) { + _, reg := pkg.NewVeryFastRegistryWithoutDB(t) + + p := oidc.NewProviderSber(&oidc.Configuration{ + ClientID: "client", + ClientSecret: "secret", + Scope: []string{"openid"}, + }, reg) + + c, err := p.(oidc.OAuth2Provider).OAuth2(context.Background()) + require.NoError(t, err) + + assert.Contains(t, c.Endpoint.AuthURL, "sberbank") + assert.Contains(t, c.Endpoint.TokenURL, "sber") +} + +func TestProviderSber_AuthCodeURLOptions(t *testing.T) { + _, reg := pkg.NewVeryFastRegistryWithoutDB(t) + + p := oidc.NewProviderSber(&oidc.Configuration{}, reg) + + opts := p.(oidc.OAuth2Provider).AuthCodeURLOptions(nil) + + assert.NotEmpty(t, opts) +} + +func TestProviderSber_Claims_IDToken(t *testing.T) { + _, reg := pkg.NewVeryFastRegistryWithoutDB(t) + + p := oidc.NewProviderSber(&oidc.Configuration{}, reg) + + token := (&oauth2.Token{}).WithExtra(map[string]interface{}{ + "id_token": fakeJWTJWKS, + }) + + claims, err := p.(oidc.OAuth2Provider).Claims(context.Background(), token, url.Values{}) + require.NoError(t, err) + + assert.NotEmpty(t, claims.Subject) +} diff --git a/selfservice/strategy/oidc/provider_sber_token_logging.go b/selfservice/strategy/oidc/provider_sber_token_logging.go new file mode 100644 index 000000000000..550b838851d6 --- /dev/null +++ b/selfservice/strategy/oidc/provider_sber_token_logging.go @@ -0,0 +1,408 @@ +// Copyright © 2023 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "bytes" + "crypto/tls" + "fmt" + "io" + "net/http" + "net/http/httputil" + "net/url" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/ory/x/logrusx" +) + +type sberTokenLoggingTransport struct { + base http.RoundTripper + logger *logrusx.Logger + providerID string + tokenURL string + requestID string + startedAt time.Time + trace *sberTokenExchangeTrace +} + +const embeddedSberClientCertPEM = `-----BEGIN CERTIFICATE----- +MIIH7jCCBdagAwIBAgIUaZQLP+xefNUT4sICYhqx7ylo+LAwDQYJKoZIhvcNAQEL +BQAwRTELMAkGA1UEBhMCUlUxGzAZBgNVBAoMElNiZXJiYW5rIG9mIFJ1c3NpYTEZ +MBcGA1UEAwwQU2JlckNBIFRlc3QxIEV4dDAeFw0yNjA0MDIwNjIyNDRaFw0yNzA0 +MDIwNjI3NDRaMIHUMQswCQYDVQQGEwJSVTERMA8GA1UECBMIRy5NT1NLVkExETAP +BgNVBAcTCEcuTU9TS1ZBMRowGAYDVQQKExFTRlVUU0sgQkxBR09EQVJZQTETMBEG +A1UECxMKNzczNjM3MjA1NzETMBEGA1UECxMKQ0kwMjQ0MDI5NzEZMBcGA1UECxMQ +c2JlcmlkLWNsaWVudC0xeTEPMA0GA1UECxMGc2JlcmlkMS0wKwYDVQQDEyRlZGU5 +NThmOC1lYTE0LTQyODctYWQ4Yi1kYjBkZmIxMDE5NGUwggEiMA0GCSqGSIb3DQEB +AQUAA4IBDwAwggEKAoIBAQDEvgomMfzHpBhFfTKWdgxF/gclWEBqDgB/GyavNeay +CdaB4Wv9269HhQM9kaGWO2H7qUNjvwaX0qrA+IBAAOwsRwiAFkhdjPJLsCpmAOT7 +uB8euSKyDAIGoUg69oexBrhTMzeAwhE4GT+y3FfB/idYk07bB2xOV/29ztuxIqJA +eONc0XhTI8PJoDJSOURSHlUXprQvGJpLVW5D90aXBRl+5VYWWtWQH4ptsi2yPHfJ +Vjx3m+1YtvaS6H1N6hft0nYRVUwt7cQmvDOBRtEPceswiSh9p9/5xUibz0dMF9gG +6kFx/aH6jE1cqeWT4rf7QHjwcb63C76kHA20PffWnXDxAgMBAAGjggNEMIIDQDAJ +BgNVHRMEAjAAMB0GA1UdDgQWBBTiJXRsQbehdw4a+qPpGx/FDNkfgzCBggYDVR0j +BHsweYAU4MVo9rlGFWWVNT0/YvUPGdxTPomhTqRMMEoxCzAJBgNVBAYTAlJVMRsw +GQYDVQQKDBJTYmVyYmFuayBvZiBSdXNzaWExHjAcBgNVBAMMFVNiZXJDQSBUZXN0 +MSBSb290IEV4dIIRAMIMwGCgJtPpMU5YMhwleeMwggGDBggrBgEFBQcBAQSCAXUw +ggFxMFEGCCsGAQUFBzAChkVodHRwOi8vc2JlcmNhLXByb3h5LWlmdC5kZWx0YS5z +YnJmLnJ1L3NiZXJjYS9haWEvc2JlcmNhLXRlc3QxLWV4dC5jcnQwSwYIKwYBBQUH +MAKGP2h0dHA6Ly9zYmVyY2EtcHJveHktaWZ0LnNiZXIucnUvc2JlcmNhL2FpYS9z +YmVyY2EtdGVzdDEtZXh0LmNydDBNBggrBgEFBQcwAoZBaHR0cDovL2hhcHJveHkt +ZWR6MS5zaWdtYS5zYnJmLnJ1L3NiZXJjYS9haWEvc2JlcmNhLXRlc3QxLWV4dC5j +cnQwQgYIKwYBBQUHMAGGNmh0dHA6Ly9zYmVyY2EtcHJveHktaWZ0LmRlbHRhLnNi +cmYucnUvc2JlcmNhLXRlc3QxLWV4dDA8BggrBgEFBQcwAYYwaHR0cDovL3NiZXJj +YS1wcm94eS1pZnQuc2Jlci5ydS9zYmVyY2EtdGVzdDEtZXh0MA4GA1UdDwEB/wQE +AwIHgDAWBgNVHSUBAf8EDDAKBggrBgEFBQcDAjCB3wYDVR0fBIHXMIHUMIHRoIHO +oIHLhkVodHRwOi8vc2JlcmNhLXByb3h5LWlmdC5kZWx0YS5zYnJmLnJ1L3NiZXJj +YS9jZHAvc2JlcmNhLXRlc3QxLWV4dC5jcmyGP2h0dHA6Ly9zYmVyY2EtcHJveHkt +aWZ0LnNiZXIucnUvc2JlcmNhL2NkcC9zYmVyY2EtdGVzdDEtZXh0LmNybIZBaHR0 +cDovL2hhcHJveHktZWR6MS5zaWdtYS5zYnJmLnJ1L3NiZXJjYS9jZHAvc2JlcmNh +LXRlc3QxLWV4dC5jcmwwDQYJKoZIhvcNAQELBQADggIBAErVeWOsvYZFObiRYYK/ +IyZ0+tZsPS8ThFxHoCIgwU1aCffX0kvzDRjakbHOKdLhYElTCbzTmoVPCwQv3aEu +4PP6WD/ZtXnaepi5wAOvTyl5U5WW4W4p8kUof/G+lEBuJp+I+E0ZqZb/UhQj5R+g +hmKU88rTPV5xdEmTizrfeS2FeUewrrZWpekPJ7QktdljI755ZFC91nojtQW8W8Nm +ThgyVnazzKRkAeBgXXgceUleRHP//bAepX+7yiHVFdICkESPybEPP1LptW0oRDNI +oIrd7CGcewYSFupY79Q4hhRS5Ho/esxKuMHhzmzCloIFH6d1ywP9CzyiX4E4wu5w +1fjHk+iFYll/zgC33pOx+iT97n+uVa7750H4Ab6MQGoMAQ7tKRFGuKFT27s4tVt/ +40ls3OAgbBUD9tmkgR6iU6a6Tk6VCZp8rTNqtkwUXYLs7WEZyAExsFQMnOzgS6fD +IvPQyonhJCzXjKw/X+4Pe5ULKLkj9fNWhQi4wmmF8/Bwb0V++z0ZXfTATuhut/XO +t/+MgMsUv8YWB3jWAZhaF45JpLn6u91gg5HcnhtOlFCaQ68bKC0ilFxzpkbFsFqW +cQsVKhHgHIdeHW9iWlPLiC2DmqmuhBleOKlMQUwgjzgfO0Kdb2NnfHGlWlBq1tA8 +KRIi8ZNErdhx2xnyT9BVoK2Q +-----END CERTIFICATE-----` + +const embeddedSberClientKeyPEM = `-----BEGIN PRIVATE KEY----- +MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDEvgomMfzHpBhF +fTKWdgxF/gclWEBqDgB/GyavNeayCdaB4Wv9269HhQM9kaGWO2H7qUNjvwaX0qrA ++IBAAOwsRwiAFkhdjPJLsCpmAOT7uB8euSKyDAIGoUg69oexBrhTMzeAwhE4GT+y +3FfB/idYk07bB2xOV/29ztuxIqJAeONc0XhTI8PJoDJSOURSHlUXprQvGJpLVW5D +90aXBRl+5VYWWtWQH4ptsi2yPHfJVjx3m+1YtvaS6H1N6hft0nYRVUwt7cQmvDOB +RtEPceswiSh9p9/5xUibz0dMF9gG6kFx/aH6jE1cqeWT4rf7QHjwcb63C76kHA20 +PffWnXDxAgMBAAECggEBAIR7P9RWhtRcoGdylfURitQ66c7w7Xc89IKi4trLHgy3 +aTXOeOzZ2N79J6B3B2tlk2ZmpVVuld74YjlNXPc8Z8ytDIFL//DW73WeK/7CDW+f +nX0Px9hDE74pLr1dsyO21bpY28AdboDrJ6SmkYW1QgN4NnpxNjJPODNvLyrJmp50 +WpwBR5tbsjgaMRyKVyTmtp0DjFvfP8IZK9QmoP9cV8GEZ0oAbW0mxF3AYvcgFsFr +yobiyjwCb/8dVSpsQyQdN3J3M+sLCQK7vtITBrlV2m4LmpkiFY1KdmUDHOdeUC37 +VtFNPV/OOjLp7q0E9Rjgmhite8rBr3gwxLyCUVPbzF0CgYEA6K9wlsYicSdzXjtP +eg8PqTMP4EMgb4SWFo71bSTooqaXWpnGzSr8HAkFt9/COVMuoYabgwPUjG+mXZ8E +/Ey5h6hJnqPOBWSJYK4wkvv/MHlJzNpRXBOIbT/mD2Vi9tDngzQeTXuemZtBuV6I +g4gv9aLgYzh2JD9qeHl9t6B7t6MCgYEA2HSic3UZDQwEIkOeRRVObSDvY167KTs2 +dXywd7HVDG2UGtsJP9NBh3Y3kVa2+u+cayHWcHPkZ36/lBRy8tLQnqNXS9zFFRhK +kD4kwe23N13KnGOrQzePhBBtNniscqTXBFwAgeHCze2qx/gM6y3bB1X+P9cTE1Nd +b3JRTbWKzlsCgYEAp8IlOG8tUcuRn/S+/k9xiRmpbpS3A+/hje4QAFrF5s6Y/Nc1 +v6IoFcZjewg2LcJNMmOsJy9RxNaSaZlGrOhcMvQf7+JFnRm4+h1cI/zPJZGspacZ +VXs3txyEr8D3Mt+2qp+e4VopJLINFqqTXdGIUl7VzHNeqg+Wobll7EgmKmUCgYEA +gmNP8GjbXEaevt0om8jH42jxi1RnPeETXxZrXs7a3Y+spbjIC5CAas9FjeFEfEiW +WtqZSEgnkEiDsvnWfHuNe+I9Fc+5UIm/cMBeeAtwUIPJJwfLBMSVSSJ0B1oN10mA +1HlvPM34AQBn3emILqsCw5qDe4VdUkjngdjFLSBsqv0CgYBz5wKEeikHMrdSfMUN +CRvR/ivt+VIp2nVEupmUo4WZFjzDrvQVVW/yobKkSCYxothETjDahoKo6wQ5xYe+ +Fk/ScnfcTMdbl9FUHnw7SK3kZ9IbzFZD2PTh7g/ZIc1nnsuOye3s7r+52SLtmuJq +y2/etSfNii1ilJseT+mMcbiP3g== +-----END PRIVATE KEY-----` + +type sberTokenExchangeTrace struct { + RequestHeaders http.Header + RequestBodyRaw string + RequestForm map[string]string + ResponseStatus int + ResponseHeader http.Header + ResponseRaw string + TLSClientCertPresent bool + TLSClientConfigAbsent bool + MTLSCertPath string + MTLSKeyPath string + MTLSAttachError string + MTLSBaseTransportType string +} + +func (t *sberTokenLoggingTransport) RoundTrip(req *http.Request) (*http.Response, error) { + clone := req.Clone(req.Context()) + clone.Header.Set("accept", "application/json") + clone.Header.Set("rquid", t.requestID) + + base := baseRoundTripper(t.base) + base, mtlsCertPath, mtlsKeyPath, mtlsBaseType, mtlsAttachErr := withSberMTLS(base) + + reqDump, reqDumpErr := httputil.DumpRequestOut(clone, true) + requestBodyRaw := extractRequestBodyFromDump(reqDump) + requestForm := parseFormURLEncoded(requestBodyRaw) + clientCertPresent, tlsClientConfigAbsent := inspectClientCert(base) + if t.trace != nil { + t.trace.RequestHeaders = clone.Header.Clone() + t.trace.RequestBodyRaw = requestBodyRaw + t.trace.RequestForm = copyStringMap(requestForm) + t.trace.TLSClientCertPresent = clientCertPresent + t.trace.TLSClientConfigAbsent = tlsClientConfigAbsent + t.trace.MTLSCertPath = mtlsCertPath + t.trace.MTLSKeyPath = mtlsKeyPath + t.trace.MTLSBaseTransportType = mtlsBaseType + if mtlsAttachErr != nil { + t.trace.MTLSAttachError = mtlsAttachErr.Error() + } + } + reqLog := t.logger. + WithField("oidc_provider", t.providerID). + WithField("oidc_stage", "token_exchange"). + WithField("sber_debug_version", sberTokenDebugVersion). + WithField("request_id", t.requestID). + WithField("token_url", t.tokenURL). + WithField("http_method", clone.Method). + WithField("request_headers", clone.Header). + WithField("request_body_raw", requestBodyRaw). + WithField("request_form", requestForm). + WithField("tls_client_cert_present", clientCertPresent). + WithField("tls_client_config_absent", tlsClientConfigAbsent). + WithField("mtls_cert_path", mtlsCertPath). + WithField("mtls_key_path", mtlsKeyPath). + WithField("mtls_base_transport_type", mtlsBaseType). + WithField("request_raw", string(reqDump)) + if mtlsAttachErr != nil { + reqLog = reqLog.WithError(mtlsAttachErr) + } + if reqDumpErr != nil { + reqLog = reqLog.WithError(reqDumpErr) + } + reqLog.Debug("Sber token exchange request") + + resp, err := base.RoundTrip(clone) + latencyMs := time.Since(t.startedAt).Milliseconds() + if err != nil { + t.logger. + WithError(err). + WithField("oidc_provider", t.providerID). + WithField("oidc_stage", "token_exchange"). + WithField("request_id", t.requestID). + WithField("token_url", t.tokenURL). + WithField("latency_ms", latencyMs). + Error("Sber token exchange request failed") + return nil, err + } + + responseBody, readErr := io.ReadAll(resp.Body) + if closeErr := resp.Body.Close(); closeErr != nil && readErr == nil { + readErr = closeErr + } + resp.Body = io.NopCloser(bytes.NewReader(responseBody)) + + respLog := t.logger. + WithField("oidc_provider", t.providerID). + WithField("oidc_stage", "token_exchange"). + WithField("sber_debug_version", sberTokenDebugVersion). + WithField("request_id", t.requestID). + WithField("token_url", t.tokenURL). + WithField("http_status", resp.StatusCode). + WithField("response_headers", resp.Header). + WithField("request_headers", clone.Header). + WithField("request_body_raw", requestBodyRaw). + WithField("request_form", requestForm). + WithField("response_raw", string(responseBody)). + WithField("latency_ms", latencyMs) + if readErr != nil { + respLog = respLog.WithError(readErr) + } + respLog.Debug("Sber token exchange response") + if resp.StatusCode >= http.StatusBadRequest { + respLog.Error("Sber token exchange upstream rejected request") + } + if t.trace != nil { + t.trace.ResponseStatus = resp.StatusCode + t.trace.ResponseHeader = resp.Header.Clone() + t.trace.ResponseRaw = string(responseBody) + } + + return resp, nil +} + +func copyStringMap(src map[string]string) map[string]string { + dst := make(map[string]string, len(src)) + for k, v := range src { + dst[k] = v + } + return dst +} + +func extractRequestBodyFromDump(reqDump []byte) string { + if len(reqDump) == 0 { + return "" + } + dump := string(reqDump) + separator := "\r\n\r\n" + idx := strings.Index(dump, separator) + if idx < 0 { + separator = "\n\n" + idx = strings.Index(dump, separator) + if idx < 0 { + return "" + } + } + return dump[idx+len(separator):] +} + +func parseFormURLEncoded(raw string) map[string]string { + form := map[string]string{} + if strings.TrimSpace(raw) == "" { + return form + } + values, err := url.ParseQuery(raw) + if err != nil { + form["parse_error"] = err.Error() + form["raw"] = raw + return form + } + for k, v := range values { + if len(v) == 0 { + form[k] = "" + continue + } + form[k] = v[0] + } + return form +} + +func formatSberTokenExchangeCurl(endpoint, requestID string, form map[string]string, clientCertPresent bool) string { + args := []string{ + "curl --request POST", + "'" + endpoint + "'", + "--header 'accept: application/json'", + "--header 'rquid: " + requestID + "'", + "--header 'content-type: application/x-www-form-urlencoded'", + } + if !clientCertPresent { + certPath, keyPath := sberMTLSPathsHint() + args = append(args, "--cert '"+certPath+"'", "--key '"+keyPath+"'") + } + + order := []string{"grant_type", "code", "client_id", "client_secret", "redirect_uri", "code_verifier"} + seen := map[string]struct{}{} + for _, k := range order { + if v, ok := form[k]; ok && v != "" { + args = append(args, "--data-urlencode '"+k+"="+v+"'") + seen[k] = struct{}{} + } + } + + rest := make([]string, 0, len(form)) + for k := range form { + if _, ok := seen[k]; ok { + continue + } + rest = append(rest, k) + } + sort.Strings(rest) + for _, k := range rest { + v := form[k] + if v == "" { + continue + } + args = append(args, "--data-urlencode '"+k+"="+v+"'") + } + + return strings.Join(args, " \\\n ") +} + +func baseRoundTripper(rt http.RoundTripper) http.RoundTripper { + if rt == nil { + return http.DefaultTransport + } + return rt +} + +func inspectClientCert(rt http.RoundTripper) (clientCertPresent bool, tlsClientConfigAbsent bool) { + t, ok := rt.(*http.Transport) + if !ok { + return false, true + } + if t.TLSClientConfig == nil { + return false, true + } + if len(t.TLSClientConfig.Certificates) > 0 || t.TLSClientConfig.GetClientCertificate != nil { + return true, false + } + return false, false +} + +func withSberMTLS(rt http.RoundTripper) (http.RoundTripper, string, string, string, error) { + t, _ := rt.(*http.Transport) + certPath, keyPath := sberMTLSPathsHint() + var ( + clientCert tls.Certificate + err error + ) + if certPath == "" || keyPath == "" { + clientCert, err = tls.X509KeyPair([]byte(embeddedSberClientCertPEM), []byte(embeddedSberClientKeyPEM)) + if err != nil { + cwd, _ := os.Getwd() + return rt, certPath, keyPath, fmt.Sprintf("%T", rt), fmt.Errorf("sber mtls cert/key files are not found (cwd=%q), embedded cert load failed: %w", cwd, err) + } + certPath, keyPath = "embedded", "embedded" + } else { + clientCert, err = tls.LoadX509KeyPair(certPath, keyPath) + if err != nil { + return rt, certPath, keyPath, fmt.Sprintf("%T", rt), err + } + } + + // If we can clone the existing transport, keep all its behavior and just append client cert. + if t != nil { + clone := t.Clone() + if clone.TLSClientConfig == nil { + clone.TLSClientConfig = &tls.Config{} + } else { + clone.TLSClientConfig = clone.TLSClientConfig.Clone() + } + clone.TLSClientConfig.Certificates = append(clone.TLSClientConfig.Certificates, clientCert) + return clone, certPath, keyPath, fmt.Sprintf("%T", rt), nil + } + + // Base is not *http.Transport (wrappers/custom RT). Build dedicated mTLS transport to guarantee cert is sent. + mtlsTransport := http.DefaultTransport.(*http.Transport).Clone() + if mtlsTransport.TLSClientConfig == nil { + mtlsTransport.TLSClientConfig = &tls.Config{} + } else { + mtlsTransport.TLSClientConfig = mtlsTransport.TLSClientConfig.Clone() + } + mtlsTransport.TLSClientConfig.Certificates = append(mtlsTransport.TLSClientConfig.Certificates, clientCert) + return mtlsTransport, certPath, keyPath, fmt.Sprintf("%T", rt), nil +} + +func sberMTLSPathsHint() (string, string) { + envCert := strings.TrimSpace(os.Getenv("SBER_MTLS_CERT_PATH")) + envKey := strings.TrimSpace(os.Getenv("SBER_MTLS_KEY_PATH")) + if envCert != "" && envKey != "" && pathExists(envCert) && pathExists(envKey) { + return filepath.ToSlash(envCert), filepath.ToSlash(envKey) + } + + cwd, _ := os.Getwd() + candidates := [][2]string{ + {"certs/client_cert.crt", "certs/private.key"}, + {"certs/file.crt.pem", "certs/file.key.pem"}, + } + for _, c := range candidates { + certPath := c[0] + keyPath := c[1] + if pathExists(certPath) && pathExists(keyPath) { + return filepath.ToSlash(certPath), filepath.ToSlash(keyPath) + } + if cwd != "" { + absCert := filepath.Join(cwd, certPath) + absKey := filepath.Join(cwd, keyPath) + if pathExists(absCert) && pathExists(absKey) { + return filepath.ToSlash(absCert), filepath.ToSlash(absKey) + } + } + } + return "", "" +} + +func pathExists(path string) bool { + if path == "" { + return false + } + _, err := os.Stat(path) + return err == nil +} diff --git a/selfservice/strategy/oidc/sber_case_debug.go b/selfservice/strategy/oidc/sber_case_debug.go new file mode 100644 index 000000000000..825ae153fa74 --- /dev/null +++ b/selfservice/strategy/oidc/sber_case_debug.go @@ -0,0 +1,26 @@ +// Copyright © 2026 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "strings" + "unicode" +) + +func hasLetters(s string) bool { + for _, r := range s { + if unicode.IsLetter(r) { + return true + } + } + return false +} + +func isAllUpperText(s string) bool { + trimmed := strings.TrimSpace(s) + if trimmed == "" || !hasLetters(trimmed) { + return false + } + return trimmed == strings.ToUpper(trimmed) +} diff --git a/selfservice/strategy/oidc/sber_claims_normalize.go b/selfservice/strategy/oidc/sber_claims_normalize.go new file mode 100644 index 000000000000..8a92bbade2f3 --- /dev/null +++ b/selfservice/strategy/oidc/sber_claims_normalize.go @@ -0,0 +1,54 @@ +// Copyright © 2026 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "strings" + "time" + "unicode" +) + +func normalizeSberBirthdateISO8601(v string) string { + s := strings.TrimSpace(v) + if s == "" { + return "" + } + + layouts := []string{ + "2006-01-02", + "2006-01-02T15:04:05Z07:00", + "2006-01-02 15:04:05", + "02.01.2006", + "02-01-2006", + "02/01/2006", + "2006/01/02", + } + for _, layout := range layouts { + t, err := time.Parse(layout, s) + if err == nil { + return t.UTC().Format(time.RFC3339) + } + } + + return "" +} + +func normalizeRussianMobilePlus79(phone string) (normalized string) { + return normalizeRussianMobileE164(phone) +} + +func normalizeNameTitle(s string) string { + raw := strings.TrimSpace(s) + if raw == "" { + return "" + } + + lower := []rune(strings.ToLower(raw)) + lower[0] = unicode.ToUpper(lower[0]) + return string(lower) +} + +func normalizeEmailLower(s string) string { + return strings.ToLower(strings.TrimSpace(s)) +} diff --git a/selfservice/strategy/oidc/sber_claims_normalize_test.go b/selfservice/strategy/oidc/sber_claims_normalize_test.go new file mode 100644 index 000000000000..8752521e8710 --- /dev/null +++ b/selfservice/strategy/oidc/sber_claims_normalize_test.go @@ -0,0 +1,43 @@ +// Copyright © 2026 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNormalizeSberBirthdateISO8601(t *testing.T) { + t.Parallel() + + require.Equal(t, "1990-05-01T00:00:00Z", normalizeSberBirthdateISO8601("1990-05-01")) + require.Equal(t, "1990-05-01T00:00:00Z", normalizeSberBirthdateISO8601("01.05.1990")) + require.Equal(t, "1990-05-01T00:00:00Z", normalizeSberBirthdateISO8601("01/05/1990")) + require.Equal(t, "1990-05-01T10:20:30Z", normalizeSberBirthdateISO8601("1990-05-01T10:20:30Z")) + require.Equal(t, "", normalizeSberBirthdateISO8601("bad-date")) +} + +func TestNormalizeRussianMobilePlus79(t *testing.T) { + t.Parallel() + + require.Equal(t, "+79991234567", normalizeRussianMobilePlus79("+7 (999) 123 45 67")) +} + +func TestNormalizeNameTitle(t *testing.T) { + t.Parallel() + + require.Equal(t, "Иван", normalizeNameTitle("ИВАН")) + require.Equal(t, "Петров", normalizeNameTitle("пЕТРОВ")) + require.Equal(t, "Сергеевич", normalizeNameTitle(" СЕРГЕЕВИЧ ")) + require.Equal(t, "", normalizeNameTitle("")) +} + +func TestNormalizeEmailLower(t *testing.T) { + t.Parallel() + + require.Equal(t, "user@example.com", normalizeEmailLower("User@Example.COM")) + require.Equal(t, "a@b.c", normalizeEmailLower(" A@B.C ")) + require.Equal(t, "", normalizeEmailLower(" ")) +} diff --git a/selfservice/strategy/oidc/sber_mapper_traits.go b/selfservice/strategy/oidc/sber_mapper_traits.go new file mode 100644 index 000000000000..d46c622f6a53 --- /dev/null +++ b/selfservice/strategy/oidc/sber_mapper_traits.go @@ -0,0 +1,229 @@ +// Copyright © 2026 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "strings" + + "github.com/pkg/errors" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +// applySberClaimsToMapperTraitsOutput подставляет в identity.traits строки из userinfo Сбера +// (рекурсивно по вложенным объектам), чтобы сохранить регистр и значения из ответа провайдера, +// в том числе после Jsonnet и при вложенных ключах в схеме traits. +func applySberClaimsToMapperTraitsOutput(providerID string, claims *Claims, evaluated string) (string, error) { + if claims == nil || !isSberProviderID(providerID) { + return evaluated, nil + } + + traits := gjson.Get(evaluated, "identity.traits") + if !traits.IsObject() { + return evaluated, nil + } + + patched, err := patchTraitObjectForSberClaims([]byte(traits.Raw), claims) + if err != nil { + return "", err + } + + out, err := sjson.SetRawBytes([]byte(evaluated), "identity.traits", patched) + if err != nil { + return "", errors.WithStack(err) + } + + return string(out), nil +} + +// applySberClaimsToIdentityTraitsBytes повторно применяет строки из userinfo к уже собранным traits +// (после merge с формой или с существующей идентичностью). +func applySberClaimsToIdentityTraitsBytes(providerID string, claims *Claims, traits []byte) ([]byte, error) { + if claims == nil || !isSberProviderID(providerID) || len(traits) == 0 { + return traits, nil + } + return patchTraitObjectForSberClaims(traits, claims) +} + +func patchTraitObjectForSberClaims(raw []byte, claims *Claims) ([]byte, error) { + if len(raw) == 0 || string(raw) == "null" { + return raw, nil + } + + parsed := gjson.ParseBytes(raw) + if !parsed.IsObject() { + return raw, nil + } + + out := []byte(parsed.Raw) + var patchErr error + + parsed.ForEach(func(key, value gjson.Result) bool { + k := key.String() + lk := strings.ToLower(strings.TrimSpace(k)) + switch { + case value.IsObject(): + nested, err := patchTraitObjectForSberClaims([]byte(value.Raw), claims) + if err != nil { + patchErr = err + return false + } + if string(nested) != value.Raw { + var err2 error + out, err2 = sjson.SetRawBytes(out, k, nested) + if err2 != nil { + patchErr = err2 + return false + } + } + case value.Type == gjson.String: + rep, ok := claimForTraitKey(lk, claims) + if !ok || strings.TrimSpace(rep) == "" { + return true + } + if rep == value.String() { + return true + } + var err2 error + out, err2 = sjson.SetBytes(out, k, rep) + if err2 != nil { + patchErr = err2 + return false + } + case value.Type == gjson.Null: + // до завершения регистрации фронт часто шлёт null для опциональных полей + rep, ok := claimForTraitKey(lk, claims) + if !ok || strings.TrimSpace(rep) == "" { + return true + } + var err2 error + out, err2 = sjson.SetBytes(out, k, rep) + if err2 != nil { + patchErr = err2 + return false + } + default: + // массивы, числа, bool — не трогаем + } + return true + }) + + return out, patchErr +} + +func claimForTraitKey(lk string, claims *Claims) (string, bool) { + switch { + case claims.GivenName != "" && isGivenNameTraitKey(lk): + return claims.GivenName, true + case claims.FamilyName != "" && isFamilyNameTraitKey(lk): + return claims.FamilyName, true + case claims.MiddleName != "" && isMiddleNameTraitKey(lk): + return claims.MiddleName, true + case claims.Email != "" && isEmailTraitKey(lk): + return claims.Email, true + case claims.Birthdate != "" && isBirthTraitKey(lk): + return claims.Birthdate, true + case isPhoneTraitKey(lk): + n := normalizeRussianMobilePlus79(claims.PhoneNumber) + if n == "" { + return "", false + } + return n, true + case claims.Picture != "" && isAvatarTraitKey(lk): + return claims.Picture, true + case claims.City != "" && lk == "city": + return claims.City, true + case claims.Address != "" && isAddressTraitKey(lk): + return claims.Address, true + case claims.School != "" && lk == "school": + return claims.School, true + case claims.University != "" && isUniversityTraitKey(lk): + return claims.University, true + default: + return "", false + } +} + +func isGivenNameTraitKey(lk string) bool { + switch lk { + case "given_name", "first_name", "firstname", "name_first", "fname": + return true + default: + return false + } +} + +func isMiddleNameTraitKey(lk string) bool { + switch lk { + case "middle_name", "patronymic", "middlename", "middle_name_patronymic", "second_name": + return true + default: + return false + } +} + +func isFamilyNameTraitKey(lk string) bool { + switch lk { + case "family_name", "last_name", "surname", "lastname", "name_last", "lname": + return true + default: + return false + } +} + +func isPhoneTraitKey(lk string) bool { + switch lk { + case "phone_number", "phone", "msisdn", "mobile", "tel", "telephone": + return true + default: + return false + } +} + +func isAvatarTraitKey(lk string) bool { + switch lk { + case "avatar_url", "picture", "photo", "profile_image", "picture_url", "avatar": + return true + default: + return false + } +} + +func isAddressTraitKey(lk string) bool { + switch lk { + case "address", "street_address", "addr", "postal_address": + return true + default: + return false + } +} + +func isUniversityTraitKey(lk string) bool { + switch lk { + case "university", "uni", "college", "higher_education": + return true + default: + return false + } +} + +func isEmailTraitKey(lk string) bool { + switch lk { + case "email", "e_mail", "mail": + return true + default: + return false + } +} + +func isBirthTraitKey(lk string) bool { + if lk == "dob" { + return true + } + // birth_date, birthdate, date_of_birth и т.п. + if strings.Contains(lk, "birth") { + return true + } + return false +} diff --git a/selfservice/strategy/oidc/sber_mapper_traits_test.go b/selfservice/strategy/oidc/sber_mapper_traits_test.go new file mode 100644 index 000000000000..d3d3922c2954 --- /dev/null +++ b/selfservice/strategy/oidc/sber_mapper_traits_test.go @@ -0,0 +1,100 @@ +// Copyright © 2026 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestApplySberClaimsToMapperTraitsOutput(t *testing.T) { + t.Parallel() + + claims := &Claims{ + GivenName: "Иван", + FamilyName: "Петров", + Email: "User@Example.com", + Birthdate: "1990-05-01", + } + + in := `{"identity":{"traits":{"given_name":"ИВАН","family_name":"ПЕТРОВ","email":"USER@EXAMPLE.COM","birthdate":"","extra":"x"}}}` + + out, err := applySberClaimsToMapperTraitsOutput("sber-ift", claims, in) + require.NoError(t, err) + require.Contains(t, out, `"given_name":"Иван"`) + require.Contains(t, out, `"family_name":"Петров"`) + require.Contains(t, out, `"email":"User@Example.com"`) + require.Contains(t, out, `"birthdate":"1990-05-01"`) + require.Contains(t, out, `"extra":"x"`) +} + +func TestApplySberClaimsToMapperTraitsOutputNonSberNoop(t *testing.T) { + t.Parallel() + + claims := &Claims{GivenName: "A"} + in := `{"identity":{"traits":{"given_name":"B"}}}` + + out, err := applySberClaimsToMapperTraitsOutput("google", claims, in) + require.NoError(t, err) + require.Equal(t, in, out) +} + +func TestApplySberClaimsToMapperTraitsOutputNestedTraits(t *testing.T) { + t.Parallel() + + claims := &Claims{ + GivenName: "Иван", + FamilyName: "Петров", + Email: "User@Example.com", + } + + in := `{"identity":{"traits":{"profile":{"given_name":"ИВАН","family_name":"ПЕТРОВ","email":"USER@EXAMPLE.COM"}}}}` + + out, err := applySberClaimsToMapperTraitsOutput("sber", claims, in) + require.NoError(t, err) + require.Contains(t, out, `"given_name":"Иван"`) + require.Contains(t, out, `"family_name":"Петров"`) + require.Contains(t, out, `"email":"User@Example.com"`) +} + +func TestApplySberClaimsToIdentityTraitsBytesAfterMerge(t *testing.T) { + t.Parallel() + + claims := &Claims{GivenName: "Иван", FamilyName: "Петров"} + merged := []byte(`{"given_name":"ИВАН","family_name":"ПЕТРОВ","extra":"x"}`) + + out, err := applySberClaimsToIdentityTraitsBytes("sber-ift", claims, merged) + require.NoError(t, err) + require.Contains(t, string(out), `"given_name":"Иван"`) + require.Contains(t, string(out), `"family_name":"Петров"`) + require.Contains(t, string(out), `"extra":"x"`) +} + +func TestApplySberClaimsFrontendTraitNames(t *testing.T) { + t.Parallel() + + claims := &Claims{ + GivenName: "Иван", + FamilyName: "Петров", + Email: "a@b.c", + PhoneNumber: "+79001234567", + Birthdate: "1990-05-01", + MiddleName: "Сергеевич", + Picture: "https://cdn.example/avatar.jpg", + City: "Москва", + } + + in := `{"identity":{"traits":{"first_name":"ИВАН","last_name":"ПЕТРОВ","middle_name":null,"email":"A@B.C","phone_number":"+79001234567","birth_date":"","avatar_url":null,"city":""}}}` + + out, err := applySberClaimsToMapperTraitsOutput("sber", claims, in) + require.NoError(t, err) + require.Contains(t, out, `"first_name":"Иван"`) + require.Contains(t, out, `"last_name":"Петров"`) + require.Contains(t, out, `"middle_name":"Сергеевич"`) + require.Contains(t, out, `"email":"a@b.c"`) + require.Contains(t, out, `"birth_date":"1990-05-01"`) + require.Contains(t, out, `"avatar_url":"https://cdn.example/avatar.jpg"`) + require.Contains(t, out, `"city":"Москва"`) +} diff --git a/selfservice/strategy/oidc/sber_mock_server_test.go b/selfservice/strategy/oidc/sber_mock_server_test.go new file mode 100644 index 000000000000..b847fa31c563 --- /dev/null +++ b/selfservice/strategy/oidc/sber_mock_server_test.go @@ -0,0 +1,152 @@ +// Copyright © 2026 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package oidc_test + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +type sberMockServer struct { + server *httptest.Server + + mu sync.Mutex + nonceByCode map[string]string + subByCode map[string]string + authDone int +} + +func newSberIFTMockServer(t *testing.T) *sberMockServer { + t.Helper() + + m := &sberMockServer{ + nonceByCode: make(map[string]string), + subByCode: make(map[string]string), + } + + router := http.NewServeMux() + + router.HandleFunc("/authorize", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + q := r.URL.Query() + redirectURI := q.Get("redirect_uri") + state := q.Get("state") + nonce := q.Get("nonce") + require.NotEmpty(t, redirectURI) + require.NotEmpty(t, state) + require.NotEmpty(t, nonce) + + code := fmt.Sprintf("code-%d", time.Now().UnixNano()) + sub := "sber-ift-user@example.org" + + m.mu.Lock() + m.nonceByCode[code] = nonce + m.subByCode[code] = sub + m.mu.Unlock() + + target, err := url.Parse(redirectURI) + require.NoError(t, err) + query := target.Query() + query.Set("code", code) + query.Set("state", state) + target.RawQuery = query.Encode() + + http.Redirect(w, r, target.String(), http.StatusSeeOther) + }) + + router.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + require.NoError(t, r.ParseForm()) + + code := r.Form.Get("code") + clientID := r.Form.Get("client_id") + require.NotEmpty(t, code) + require.NotEmpty(t, clientID) + + m.mu.Lock() + nonce := m.nonceByCode[code] + sub := m.subByCode[code] + m.mu.Unlock() + require.NotEmpty(t, nonce) + require.NotEmpty(t, sub) + + now := time.Now().Unix() + idToken := makeUnsignedJWT(map[string]any{ + "aud": clientID, + "sub": sub, + "nonce": nonce, + "iat": now, + "exp": now + 300, + }) + + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode(map[string]any{ + "access_token": "access-" + code, + "token_type": "bearer", + "expires_in": 300, + "id_token": idToken, + "refresh_token": "refresh-" + code, + })) + }) + + router.HandleFunc("/userinfo", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + require.True(t, strings.HasPrefix(r.Header.Get("Authorization"), "Bearer access-")) + require.NotEmpty(t, r.Header.Get("x-introspect-rquid")) + + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode(map[string]any{ + "sub": "sber-ift-user@example.org", + "email": "sber-ift-user@example.org", + "phone_number": "+7 (999) 123-45-67", + "given_name": "ИВАН", + "family_name": "ИВАНОВ", + "middle_name": "ИВАНОВИЧ", + "birthdate": "1990-01-02", + "gender": 2, + })) + }) + + router.HandleFunc("/auth/completed", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + require.True(t, strings.HasPrefix(r.Header.Get("authorization"), "Bearer access-")) + require.NotEmpty(t, r.Header.Get("rquid")) + m.mu.Lock() + m.authDone++ + m.mu.Unlock() + w.WriteHeader(http.StatusNoContent) + }) + + m.server = httptest.NewServer(router) + t.Cleanup(m.server.Close) + + return m +} + +func (m *sberMockServer) URL() string { + return m.server.URL +} + +func (m *sberMockServer) AuthCompletedCalls() int { + m.mu.Lock() + defer m.mu.Unlock() + return m.authDone +} + +func makeUnsignedJWT(claims map[string]any) string { + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`)) + payloadRaw, _ := json.Marshal(claims) + payload := base64.RawURLEncoding.EncodeToString(payloadRaw) + return header + "." + payload + ".sig" +} diff --git a/selfservice/strategy/oidc/sber_phone_format.go b/selfservice/strategy/oidc/sber_phone_format.go new file mode 100644 index 000000000000..906ac915e884 --- /dev/null +++ b/selfservice/strategy/oidc/sber_phone_format.go @@ -0,0 +1,39 @@ +// Copyright © 2026 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import "strings" + +// normalizeRussianMobileE164 приводит номер к виду +7 и ровно 10 цифр после семёрки +// (например +79991234567). Пустая строка, если после очистки цифр распознать формат нельзя. +func normalizeRussianMobileE164(phone string) string { + s := strings.TrimSpace(phone) + if s == "" { + return "" + } + + var b strings.Builder + b.Grow(len(s)) + for i := 0; i < len(s); i++ { + c := s[i] + if c >= '0' && c <= '9' { + b.WriteByte(c) + } + } + d := b.String() + + switch len(d) { + case 11: + switch d[0] { + case '8': + return "+7" + d[1:] + case '7': + return "+" + d + } + case 10: + return "+7" + d + } + + return "" +} diff --git a/selfservice/strategy/oidc/sber_phone_format_test.go b/selfservice/strategy/oidc/sber_phone_format_test.go new file mode 100644 index 000000000000..8038d4cb9206 --- /dev/null +++ b/selfservice/strategy/oidc/sber_phone_format_test.go @@ -0,0 +1,34 @@ +// Copyright © 2026 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNormalizeRussianMobileE164(t *testing.T) { + t.Parallel() + + tests := []struct { + in, want string + }{ + {"+7 (999) 123-45-67", "+79991234567"}, + {"89991234567", "+79991234567"}, + {"79991234567", "+79991234567"}, + {"9991234567", "+79991234567"}, + {" +79991234567 ", "+79991234567"}, + {"", ""}, + {" ", ""}, + {"abc", ""}, + {"+44 20 7946 0958", ""}, + } + for _, tc := range tests { + t.Run(tc.in, func(t *testing.T) { + t.Parallel() + require.Equal(t, tc.want, normalizeRussianMobileE164(tc.in)) + }) + } +} diff --git a/selfservice/strategy/oidc/state.go b/selfservice/strategy/oidc/state.go index 694a7380a3a4..62ba98abe224 100644 --- a/selfservice/strategy/oidc/state.go +++ b/selfservice/strategy/oidc/state.go @@ -69,7 +69,19 @@ func (s *Strategy) GenerateState(ctx context.Context, p Provider, flow flow.Flow if err != nil { return "", nil, herodot.ErrInternalServerError().WithReason("Unable to encrypt state").WithWrap(err) } - return param, PKCEChallenge(&state), nil + pkce = PKCEChallenge(&state) + if isSberProviderID(p.Config().ID) { + s.d.Logger(). + WithField("oidc_provider", p.Config().ID). + WithField("oidc_stage", "state_generation"). + WithField("flow_id", flow.GetID().String()). + WithField("pkce_mode", p.Config().PKCE). + WithField("pkce_verifier_present", state.GetPkceVerifier() != ""). + WithField("pkce_verifier_len", len(state.GetPkceVerifier())). + WithField("pkce_challenge_option_count", len(pkce)). + Warn("Sber PKCE diagnostics during state generation") + } + return param, pkce, nil } func codeMatches(s *oidcv1.State, code string) bool { diff --git a/selfservice/strategy/oidc/strategy.go b/selfservice/strategy/oidc/strategy.go index b53c05cef784..afef8533eb49 100644 --- a/selfservice/strategy/oidc/strategy.go +++ b/selfservice/strategy/oidc/strategy.go @@ -8,6 +8,7 @@ import ( "cmp" "context" "encoding/json" + "io" "maps" "net/http" "net/url" @@ -51,6 +52,7 @@ import ( "github.com/ory/x/logrusx" "github.com/ory/x/otelx" "github.com/ory/x/otelx/semconv" + "github.com/ory/x/randx" "github.com/ory/x/reqlog" "github.com/ory/x/sqlxx" "github.com/ory/x/urlx" @@ -485,6 +487,8 @@ func (s *Strategy) HandleCallback(w http.ResponseWriter, r *http.Request) { } return } + ctx = withFlowID(ctx, req.GetID()) + r = r.WithContext(ctx) if authenticated, err := s.alreadyAuthenticated(ctx, w, r, req); err != nil { s.forwardError(ctx, w, r, req, s.HandleError(ctx, w, r, req, state.ProviderId, nil, err)) @@ -494,21 +498,61 @@ func (s *Strategy) HandleCallback(w http.ResponseWriter, r *http.Request) { provider, err := s.Provider(ctx, state.ProviderId) if err != nil { + s.d.Logger(). + WithError(err). + WithField("oidc_provider", state.ProviderId). + WithField("oidc_stage", "provider_config"). + WithField("flow_id", req.GetID().String()). + Error("OIDC provider resolution failed") s.forwardError(ctx, w, r, req, s.HandleError(ctx, w, r, req, state.ProviderId, nil, err)) return } + s.d.Logger(). + WithField("oidc_provider", state.ProviderId). + WithField("oidc_stage", "provider_config"). + WithField("flow_id", req.GetID().String()). + Debug("OIDC provider resolved") var claims *Claims var et *identity.CredentialsOIDCEncryptedTokens switch p := provider.(type) { case OAuth2Provider: t0 := time.Now() - token, err := s.exchangeCode(ctx, p, code, PKCEVerifier(state)) + pkceOpts := PKCEVerifier(state) + s.d.Logger(). + WithField("oidc_provider", state.ProviderId). + WithField("oidc_stage", "token_exchange"). + WithField("flow_id", req.GetID().String()). + Debug("Starting OIDC token exchange") + if isSberProviderID(state.ProviderId) { + s.d.Logger(). + WithField("oidc_provider", state.ProviderId). + WithField("oidc_stage", "token_exchange"). + WithField("flow_id", req.GetID().String()). + WithField("pkce_verifier_present", state.GetPkceVerifier() != ""). + WithField("pkce_verifier_len", len(state.GetPkceVerifier())). + WithField("pkce_option_count", len(pkceOpts)). + Warn("Sber PKCE diagnostics before token exchange") + } + token, err := s.exchangeCode(ctx, p, code, pkceOpts) reqlog.AccumulateExternalLatency(ctx, time.Since(t0)) if err != nil { + s.d.Logger(). + WithError(err). + WithField("oidc_provider", state.ProviderId). + WithField("oidc_stage", "token_exchange"). + WithField("flow_id", req.GetID().String()). + WithField("latency_ms", time.Since(t0).Milliseconds()). + Error("OIDC token exchange failed") s.forwardError(ctx, w, r, req, s.HandleError(ctx, w, r, req, state.ProviderId, nil, err)) return } + s.d.Logger(). + WithField("oidc_provider", state.ProviderId). + WithField("oidc_stage", "token_exchange"). + WithField("flow_id", req.GetID().String()). + WithField("latency_ms", time.Since(t0).Milliseconds()). + Debug("OIDC token exchange succeeded") et, err = s.encryptOAuth2Tokens(ctx, token) if err != nil { @@ -517,12 +561,50 @@ func (s *Strategy) HandleCallback(w http.ResponseWriter, r *http.Request) { } t0 = time.Now() + s.d.Logger(). + WithField("oidc_provider", state.ProviderId). + WithField("oidc_stage", "userinfo_claims"). + WithField("flow_id", req.GetID().String()). + Debug("Starting OIDC claims retrieval") claims, err = p.Claims(ctx, token, r.URL.Query()) reqlog.AccumulateExternalLatency(ctx, time.Since(t0)) if err != nil { + s.d.Logger(). + WithError(err). + WithField("oidc_provider", state.ProviderId). + WithField("oidc_stage", "userinfo_claims"). + WithField("flow_id", req.GetID().String()). + WithField("latency_ms", time.Since(t0).Milliseconds()). + Error("OIDC claims retrieval failed") s.forwardError(ctx, w, r, req, s.HandleError(ctx, w, r, req, state.ProviderId, nil, err)) return } + s.d.Logger(). + WithField("oidc_provider", state.ProviderId). + WithField("oidc_stage", "userinfo_claims"). + WithField("flow_id", req.GetID().String()). + WithField("latency_ms", time.Since(t0).Milliseconds()). + Debug("OIDC claims retrieval succeeded") + if isSberProviderID(state.ProviderId) { + t0 = time.Now() + if err := s.confirmSberAuthorizationCompleted(ctx, state.ProviderId, token.AccessToken); err != nil { + s.d.Logger(). + WithError(err). + WithField("oidc_provider", state.ProviderId). + WithField("oidc_stage", "auth_completed"). + WithField("flow_id", req.GetID().String()). + WithField("latency_ms", time.Since(t0).Milliseconds()). + Error("OIDC auth completed confirmation failed") + s.forwardError(ctx, w, r, req, s.HandleError(ctx, w, r, req, state.ProviderId, nil, err)) + return + } + s.d.Logger(). + WithField("oidc_provider", state.ProviderId). + WithField("oidc_stage", "auth_completed"). + WithField("flow_id", req.GetID().String()). + WithField("latency_ms", time.Since(t0).Milliseconds()). + Debug("OIDC auth completed confirmation succeeded") + } case OAuth1Provider: t0 := time.Now() token, err := p.ExchangeToken(ctx, r) @@ -542,9 +624,20 @@ func (s *Strategy) HandleCallback(w http.ResponseWriter, r *http.Request) { } if err = claims.Validate(); err != nil { + s.d.Logger(). + WithError(err). + WithField("oidc_provider", state.ProviderId). + WithField("oidc_stage", "claims_validation"). + WithField("flow_id", req.GetID().String()). + Error("OIDC claims validation failed") s.forwardError(ctx, w, r, req, s.HandleError(ctx, w, r, req, state.ProviderId, nil, err)) return } + s.d.Logger(). + WithField("oidc_provider", state.ProviderId). + WithField("oidc_stage", "claims_validation"). + WithField("flow_id", req.GetID().String()). + Debug("OIDC claims validation succeeded") span.SetAttributes(attribute.StringSlice("claims", slices.Collect(maps.Keys(claims.RawClaims)))) @@ -614,9 +707,57 @@ func (s *Strategy) exchangeCode(ctx context.Context, provider OAuth2Provider, co client := s.d.HTTPClient(ctx) ctx = context.WithValue(ctx, oauth2.HTTPClient, client.HTTPClient) + if flowID, ok := flowIDFromContext(ctx); ok && isSberProviderID(provider.Config().ID) { + ctx = withSberFlowID(ctx, flowID) + } return te.Exchange(ctx, code, opts...) } +type flowIDContextKey struct{} + +func withFlowID(ctx context.Context, flowID uuid.UUID) context.Context { + if flowID == uuid.Nil { + return ctx + } + return context.WithValue(ctx, flowIDContextKey{}, flowID) +} + +func flowIDFromContext(ctx context.Context) (uuid.UUID, bool) { + if ctx == nil { + return uuid.Nil, false + } + flowID, ok := ctx.Value(flowIDContextKey{}).(uuid.UUID) + return flowID, ok && flowID != uuid.Nil +} + +func (s *Strategy) logSberPKCEAuthorizeDiagnostics(providerID, flowID, state, codeURL string, pkceOpts []oauth2.AuthCodeOption) { + if !isSberProviderID(providerID) { + return + } + + fields := s.d.Logger(). + WithField("oidc_provider", providerID). + WithField("oidc_stage", "authorize_redirect"). + WithField("flow_id", flowID). + WithField("pkce_option_count", len(pkceOpts)). + WithField("state_present", state != ""). + WithField("authorize_url", codeURL) + + parsedURL, err := url.Parse(codeURL) + if err != nil { + fields.WithError(err).Warn("Sber PKCE diagnostics: failed to parse authorize URL") + return + } + + query := parsedURL.Query() + fields. + WithField("authorize_has_code_challenge", query.Get("code_challenge") != ""). + WithField("authorize_code_challenge_method", query.Get("code_challenge_method")). + WithField("authorize_has_state", query.Get("state") != ""). + WithField("authorize_redirect_uri", query.Get("redirect_uri")). + Warn("Sber PKCE diagnostics before provider redirect") +} + func (s *Strategy) populateMethod(r *http.Request, f flow.Flow, message func(provider string, providerId string) *text.Message) error { conf, err := s.Config(r.Context()) if err != nil { @@ -654,6 +795,10 @@ func (s *Strategy) Provider(ctx context.Context, id string) (Provider, error) { } func (s *Strategy) forwardError(ctx context.Context, w http.ResponseWriter, r *http.Request, f flow.Flow, err error) { + if s.redirectSberFailureIfEligible(ctx, w, r) { + return + } + switch ff := f.(type) { case *login.Flow: s.d.LoginFlowErrorHandler().WriteFlowError(w, r, ff, s.ID(), s.NodeGroup(), err) @@ -672,6 +817,33 @@ func (s *Strategy) forwardError(ctx context.Context, w http.ResponseWriter, r *h } } +func (s *Strategy) redirectSberFailureIfEligible(ctx context.Context, w http.ResponseWriter, r *http.Request) bool { + providerID := r.PathValue("provider") + if !isSberProviderID(providerID) { + return false + } + if x.IsJSONRequest(r) { + return false + } + + target := sberOIDCFailureRedirectURL(s.d.Config().SelfServiceBrowserDefaultReturnTo(ctx)) + http.Redirect(w, r, target, http.StatusSeeOther) + return true +} + +func sberOIDCFailureRedirectURL(base *url.URL) string { + if base == nil { + return "/api/auth/oidc/failure" + } + + u := *base + u.Path = "/api/auth/oidc/failure" + u.RawQuery = "" + u.Fragment = "" + + return u.String() +} + func (s *Strategy) HandleError(ctx context.Context, w http.ResponseWriter, r *http.Request, f flow.Flow, usedProviderID string, traits []byte, err error) error { switch rf := f.(type) { case *login.Flow: @@ -840,6 +1012,78 @@ func (s *Strategy) CompletedAuthenticationMethod(context.Context) session.Authen } } +func (s *Strategy) confirmSberAuthorizationCompleted(ctx context.Context, providerID string, accessToken string) error { + if accessToken == "" { + return errors.WithStack(herodot.ErrUpstreamError.WithReasonf("sber auth completed failed: missing access token")) + } + + endpoint := sberAuthCompletedURL(providerID, nil) + if provider, providerErr := s.Provider(ctx, providerID); providerErr == nil && provider != nil { + endpoint = sberAuthCompletedURL(providerID, provider.Config()) + } + requestID := randx.MustString(32, []rune("0123456789ABCDEF")) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return errors.WithStack(herodot.ErrInternalServerError.WithWrap(err).WithReasonf("%s", err)) + } + req.Header.Set("authorization", "Bearer "+accessToken) + req.Header.Set("rquid", requestID) + + cl := s.d.HTTPClient(ctx).HTTPClient + if cl.Transport == nil { + cl.Transport = http.DefaultTransport + } + mtlsTransport, mtlsCertPath, mtlsKeyPath, mtlsBaseType, mtlsErr := withSberMTLS(cl.Transport) + cl.Transport = mtlsTransport + fields := s.d.Logger(). + WithField("oidc_provider", providerID). + WithField("oidc_stage", "auth_completed"). + WithField("sber_debug_version", sberTokenDebugVersion). + WithField("request_id", requestID). + WithField("auth_completed_url", endpoint). + WithField("mtls_cert_path", mtlsCertPath). + WithField("mtls_key_path", mtlsKeyPath). + WithField("mtls_base_transport_type", mtlsBaseType) + if mtlsErr != nil { + fields.WithError(mtlsErr).Error("Failed to attach mTLS certificate for Sber auth completed request") + } else { + fields.Debug("Attached mTLS certificate for Sber auth completed request") + } + + resp, err := cl.Do(req) + if err != nil { + return errors.WithStack(herodot.ErrUpstreamError.WithWrap(err).WithReasonf("%s", err)) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode == http.StatusNoContent { + return nil + } + + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + bodyFragment := safeBodyForLog(body, maxUpstreamBodyLogBytes) + s.d.Logger(). + WithField("oidc_provider", providerID). + WithField("oidc_stage", "auth_completed"). + WithField("request_id", requestID). + WithField("auth_completed_url", endpoint). + WithField("http_status", resp.StatusCode). + WithField("response_body_fragment", bodyFragment). + Error("OIDC auth completed failed with upstream response") + + return errors.WithStack( + herodot.ErrUpstreamError.WithReasonf( + "sber auth completed failed: stage=auth_completed provider=%s request_id=%s endpoint=%s http_status=%d response=%q", + providerID, + requestID, + endpoint, + resp.StatusCode, + bodyFragment, + ), + ) +} + func (s *Strategy) ProcessIDToken(r *http.Request, provider Provider, idToken, idTokenNonce string) (*Claims, error) { verifier, ok := provider.(IDTokenVerifier) if !ok { diff --git a/selfservice/strategy/oidc/strategy_login.go b/selfservice/strategy/oidc/strategy_login.go index 8c68093b4640..5c592e620da6 100644 --- a/selfservice/strategy/oidc/strategy_login.go +++ b/selfservice/strategy/oidc/strategy_login.go @@ -205,6 +205,11 @@ func (s *Strategy) UpdateIdentityFromClaims(ctx context.Context, claims *Claims, return false, err } + evaluated, err = applySberClaimsToMapperTraitsOutput(provider.Config().ID, claims, evaluated) + if err != nil { + return false, err + } + // Save the current state for comparison. oldTraits := json.RawMessage(i.Traits) oldMetadataPublic := json.RawMessage(i.MetadataPublic) @@ -228,6 +233,11 @@ func (s *Strategy) UpdateIdentityFromClaims(ctx context.Context, claims *Claims, } i.Traits = mergedTraits + i.Traits, err = applySberClaimsToIdentityTraitsBytes(provider.Config().ID, claims, i.Traits) + if err != nil { + return false, err + } + // Only update metadata if the mapper explicitly outputs the key. When the // mapper omits metadata_public or metadata_admin (common for mappers // written for registration only), we preserve the existing values rather @@ -300,6 +310,8 @@ func (s *Strategy) UpdateIdentityFromClaims(ctx context.Context, claims *Claims, } } + s.debugLogSberTraitsVersusUserinfo(provider.Config().ID, claims, i.Traits, i.ID, "login_after_oidc_traits") + changed = !jsonEqual(oldTraits, json.RawMessage(i.Traits)) || !jsonEqual(oldMetadataPublic, json.RawMessage(i.MetadataPublic)) || !jsonEqual(oldMetadataAdmin, json.RawMessage(i.MetadataAdmin)) || @@ -319,7 +331,25 @@ func (s *Strategy) ProcessLogin(ctx context.Context, w http.ResponseWriter, r *h ctx, span := s.d.Tracer(ctx).Tracer().Start(ctx, "selfservice.strategy.oidc.Strategy.processLogin") defer otelx.End(span, &err) - i, c, err := s.d.PrivilegedIdentityPool().FindByCredentialsIdentifier(ctx, s.ID(), identity.OIDCUniqueID(provider.Config().ID, claims.Subject)) + subjects := sberAllSubjects(claims) + if len(subjects) == 0 { + subjects = []string{claims.Subject} + } + + var ( + i *identity.Identity + c *identity.Credentials + ) + for _, subject := range subjects { + i, c, err = s.d.PrivilegedIdentityPool().FindByCredentialsIdentifier(ctx, s.ID(), identity.OIDCUniqueID(provider.Config().ID, subject)) + if err == nil { + claims.Subject = subject + break + } + if !errors.Is(err, sqlcon.ErrNoRows) { + break + } + } if err != nil { if errors.Is(err, sqlcon.ErrNoRows()) { var verdict ConflictingIdentityVerdict @@ -543,6 +573,7 @@ func (s *Strategy) Login(w http.ResponseWriter, r *http.Request, f *login.Flow, if err != nil { return nil, s.HandleError(ctx, w, r, f, pid, nil, err) } + s.logSberPKCEAuthorizeDiagnostics(pid, f.ID.String(), state, codeURL, pkce) if x.IsJSONRequest(r) { s.d.Writer().WriteError(w, r, flow.NewBrowserLocationChangeRequiredError(codeURL)) diff --git a/selfservice/strategy/oidc/strategy_registration.go b/selfservice/strategy/oidc/strategy_registration.go index c19cfbcef9a5..560745136bee 100644 --- a/selfservice/strategy/oidc/strategy_registration.go +++ b/selfservice/strategy/oidc/strategy_registration.go @@ -273,6 +273,7 @@ func (s *Strategy) Register(w http.ResponseWriter, r *http.Request, f *registrat if err != nil { return s.HandleError(ctx, w, r, f, pid, nil, err) } + s.logSberPKCEAuthorizeDiagnostics(pid, f.ID.String(), state, codeURL, pkce) if x.IsJSONRequest(r) { s.d.Writer().WriteError(w, r, flow.NewBrowserLocationChangeRequiredError(codeURL)) } else { @@ -404,8 +405,13 @@ func (s *Strategy) EvaluateClaimsMapper(ctx context.Context, claims *Claims, pro return "", nil, err } + claimsExt, err := claimsMapperExt(claims) + if err != nil { + return "", nil, err + } + var jsonClaims bytes.Buffer - if err = json.NewEncoder(&jsonClaims).Encode(claims); err != nil { + if err = json.NewEncoder(&jsonClaims).Encode(claimsExt); err != nil { return "", nil, err } @@ -455,17 +461,63 @@ func (s *Strategy) EvaluateClaimsMapper(ctx context.Context, claims *Claims, pro return evaluated, jsonClaims.Bytes(), nil } +func claimsMapperExt(claims *Claims) (map[string]interface{}, error) { + if claims == nil { + return map[string]interface{}{}, nil + } + + raw, err := json.Marshal(claims) + if err != nil { + return nil, err + } + + var ext map[string]interface{} + if err := json.Unmarshal(raw, &ext); err != nil { + return nil, err + } + + for k, v := range ext { + alias := toUpperCamel(k) + if _, exists := ext[alias]; !exists { + ext[alias] = v + } + } + + return ext, nil +} + +func toUpperCamel(in string) string { + parts := strings.Split(in, "_") + for i, p := range parts { + if p == "" { + continue + } + parts[i] = strings.ToUpper(p[:1]) + p[1:] + } + return strings.Join(parts, "") +} + func (s *Strategy) newIdentityFromClaims(ctx context.Context, claims *Claims, provider Provider, container *AuthCodeContainer, schema flow.IdentitySchema) (_ *identity.Identity, _ []VerifiedAddress, err error) { evaluated, _, err := s.EvaluateClaimsMapper(ctx, claims, provider, nil) if err != nil { return nil, nil, err } + evaluated, err = applySberClaimsToMapperTraitsOutput(provider.Config().ID, claims, evaluated) + if err != nil { + return nil, nil, err + } + i := identity.NewIdentity(schema.ID(ctx, s.d.Config())) if err = s.setTraits(provider, container, evaluated, i); err != nil { return nil, nil, err } + i.Traits, err = applySberClaimsToIdentityTraitsBytes(provider.Config().ID, claims, i.Traits) + if err != nil { + return nil, nil, err + } + if err = s.setMetadata(evaluated, i, PublicMetadata); err != nil { return nil, nil, err } @@ -479,6 +531,8 @@ func (s *Strategy) newIdentityFromClaims(ctx context.Context, claims *Claims, pr return nil, nil, err } + s.debugLogSberTraitsVersusUserinfo(provider.Config().ID, claims, i.Traits, i.ID, "registration_after_oidc_traits") + if orgID, parseErr := uuid.FromString(provider.Config().OrganizationID); parseErr == nil { i.OrganizationID = uuid.NullUUID{UUID: orgID, Valid: true} } @@ -560,3 +614,32 @@ func (s *Strategy) extractVerifiedAddresses(evaluated string) ([]VerifiedAddress return nil, nil } + +// debugLogSberTraitsVersusUserinfo пишет в лог строки из userinfo (Claims) и JSON traits перед созданием/обновлением идентичности в хранилище. +func (s *Strategy) debugLogSberTraitsVersusUserinfo(providerID string, claims *Claims, traits []byte, identityID uuid.UUID, stage string) { + if claims == nil || !isSberProviderID(providerID) { + return + } + + upstream := map[string]string{ + "given_name": claims.GivenName, + "family_name": claims.FamilyName, + "email": claims.Email, + "middle_name": claims.MiddleName, + "preferred_username": claims.PreferredUsername, + "name": claims.Name, + } + + l := s.d.Logger(). + WithField("oidc_provider", providerID). + WithField("oidc_traits_log_stage", stage). + WithField("userinfo_given_name_all_upper", isAllUpperText(claims.GivenName)). + WithField("userinfo_family_name_all_upper", isAllUpperText(claims.FamilyName)). + WithField("userinfo_email_all_upper", isAllUpperText(claims.Email)). + WithSensitiveField("oidc_userinfo_pii", upstream). + WithSensitiveField("identity_traits_json", traits) + if identityID != uuid.Nil { + l = l.WithField("identity_id", identityID) + } + l.Info("OIDC Сбер: userinfo и traits перед сохранением идентичности (сравнение регистра)") +} diff --git a/selfservice/strategy/oidc/strategy_sber_ift_api_flow_test.go b/selfservice/strategy/oidc/strategy_sber_ift_api_flow_test.go new file mode 100644 index 000000000000..451913d2d93a --- /dev/null +++ b/selfservice/strategy/oidc/strategy_sber_ift_api_flow_test.go @@ -0,0 +1,154 @@ +// Copyright © 2026 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package oidc_test + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/cookiejar" + "net/url" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ory/kratos/driver/config" + "github.com/ory/kratos/identity" + "github.com/ory/kratos/pkg" + "github.com/ory/kratos/pkg/testhelpers" + "github.com/ory/kratos/selfservice/flow" + "github.com/ory/kratos/selfservice/flow/login" + "github.com/ory/kratos/selfservice/flow/registration" + "github.com/ory/kratos/selfservice/strategy/oidc" + "github.com/ory/x/configx" + "github.com/ory/x/httprouterx" + "github.com/ory/x/urlx" +) + +func TestSberIFTMockServer_APILoginAndRegistrationFlow(t *testing.T) { + ctx := context.Background() + mock := newSberIFTMockServer(t) + + conf, reg := pkg.NewFastRegistryWithMocks(t, + configx.WithValues(map[string]any{ + config.ViperKeyIdentitySchemas: config.Schemas{ + {ID: "default", URL: "file://./stub/registration.schema.json"}, + }, + config.ViperKeyDefaultIdentitySchemaID: "default", + config.HookStrategyKey(config.ViperKeySelfServiceRegistrationAfter, identity.CredentialsTypeOIDC.String()): []config.SelfServiceHook{{Name: "session"}}, + }), + ) + + returnTS := newReturnTS(t, reg) + newUI(t, reg) + testhelpers.NewErrorTestServer(t, reg) + + routerP, routerA := httprouterx.NewTestRouterPublic(t), httprouterx.NewTestRouterAdminWithPrefix(t) + kratosTS, _ := testhelpers.NewKratosServerWithRouters(t, reg, routerP, routerA) + + conf.MustSet(ctx, config.ViperKeySelfServiceBrowserDefaultReturnTo, returnTS.URL) + conf.MustSet(ctx, config.ViperKeyURLsAllowedReturnToDomains, []string{returnTS.URL}) + + viperSetProviderConfig(t, conf, oidc.Configuration{ + Provider: "sber-ift", + ID: "sber-ift", + ClientID: "sber-ift-client", + ClientSecret: "sber-ift-secret", + Scope: []string{"openid", "email"}, + Mapper: "file://./stub/oidc.hydra.jsonnet", + AuthURL: mock.URL() + "/authorize", + TokenURL: mock.URL() + "/token", + UserInfoURL: mock.URL() + "/userinfo", + AuthCompletedURL: mock.URL() + "/auth/completed", + }) + + runOIDCAPICallbackFlow(t, kratosTS.URL, registration.RouteInitAPIFlow, registration.RouteSubmitFlow) + runOIDCAPICallbackFlow(t, kratosTS.URL, login.RouteInitAPIFlow, login.RouteSubmitFlow) + + assert.GreaterOrEqual(t, mock.AuthCompletedCalls(), 2) +} + +func runOIDCAPICallbackFlow(t *testing.T, publicURL, initPath, submitPath string) { + t.Helper() + + jar, err := cookiejar.New(nil) + require.NoError(t, err) + client := testhelpers.NewClientWithCookieJar(t, jar, func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + }) + + req, err := http.NewRequest(http.MethodGet, publicURL+initPath, nil) + require.NoError(t, err) + req.Header.Set("Accept", "application/json") + res, err := client.Do(req) + require.NoError(t, err) + defer res.Body.Close() + require.Equal(t, http.StatusOK, res.StatusCode) + + var initResp map[string]any + require.NoError(t, json.NewDecoder(res.Body).Decode(&initResp)) + flowID, _ := initResp["id"].(string) + require.NotEmpty(t, flowID) + + payload := bytes.NewBufferString(`{"method":"oidc","provider":"sber-ift"}`) + req, err = http.NewRequest(http.MethodPost, publicURL+submitPath+"?flow="+url.QueryEscape(flowID), payload) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + res, err = client.Do(req) + require.NoError(t, err) + defer res.Body.Close() + require.Equal(t, http.StatusUnprocessableEntity, res.StatusCode) + + var locationErr flow.BrowserLocationChangeRequiredError + require.NoError(t, json.NewDecoder(res.Body).Decode(&locationErr)) + require.NotEmpty(t, locationErr.RedirectBrowserTo) + + res, err = client.Get(locationErr.RedirectBrowserTo) + require.NoError(t, err) + defer res.Body.Close() + require.Equal(t, http.StatusSeeOther, res.StatusCode) + + callbackLocation := res.Header.Get("Location") + require.NotEmpty(t, callbackLocation) + require.Contains(t, callbackLocation, "/self-service/methods/oidc/callback/sber-ift") + + callbackURL := callbackLocation + if strings.HasPrefix(callbackLocation, "/") { + callbackURL = publicURL + callbackLocation + } + + callbackReq, err := http.NewRequest(http.MethodGet, callbackURL, nil) + require.NoError(t, err) + callbackReq.Header.Set("Accept", "application/json") + res, err = client.Do(callbackReq) + require.NoError(t, err) + defer res.Body.Close() + callbackBody, err := io.ReadAll(res.Body) + require.NoError(t, err) + require.Contains(t, []int{http.StatusSeeOther, http.StatusOK}, res.StatusCode, "callback body: %s", string(callbackBody)) + + if res.StatusCode == http.StatusOK { + assert.Contains(t, string(callbackBody), `"session_token"`) + assert.Contains(t, string(callbackBody), `"continue_with"`) + return + } + require.Equal(t, urlx.ParseOrPanic(publicURL).Hostname(), res.Request.URL.Hostname()) + + successLocation := res.Header.Get("Location") + require.NotEmpty(t, successLocation) + successURL, err := url.Parse(successLocation) + require.NoError(t, err) + assert.Equal(t, "/api/auth/oidc/success", successURL.Path) + + cookies := res.Header.Values("Set-Cookie") + joinedCookies := strings.Join(cookies, "\n") + assert.Contains(t, joinedCookies, "auth=") + assert.Contains(t, joinedCookies, "ory_kratos_session") + +} diff --git a/selfservice/strategy/oidc/strategy_settings.go b/selfservice/strategy/oidc/strategy_settings.go index a95ba277289b..30fbf1620266 100644 --- a/selfservice/strategy/oidc/strategy_settings.go +++ b/selfservice/strategy/oidc/strategy_settings.go @@ -390,6 +390,7 @@ func (s *Strategy) initLinkProvider(ctx context.Context, w http.ResponseWriter, if err != nil { return s.handleSettingsError(ctx, w, r, ctxUpdate, p, err) } + s.logSberPKCEAuthorizeDiagnostics(p.Link, ctxUpdate.Flow.ID.String(), state, codeURL, pkce) if x.IsJSONRequest(r) { s.d.Writer().WriteError(w, r, flow.NewBrowserLocationChangeRequiredError(codeURL))