О SAML

2023-09-18

Вспомним базу.

Есть аутентификация — проверка того, что пользователь является действительно тем, за кого себя выдаёт. Есть авторизация — проверка того, что пользователь (про которого мы уже точно знаем, что это он) действительно может делать то, что он собирается сделать.

Есть фреймворк авторизации OAuth 2.0. Фреймворк он потому, что описывает лишь саму концепцию, не сильно вдаваясь в конкретные детали. Авторизации потому, что речь в нём идет о предоставлении прав некоторому пользователю к ресурсам на некотором сервере. Самое популярное приложение OAuth 2.0 — это «логин» через социальные сети. В данном случае клиент (наш сайт) запрашивает у сервера (социальной сети) доступ на получение ресурса, которым является идентификатор или емейл пользователя. Если мы смогли получить емейл, значит, кто-то там (сервер авторизации) проверил, что это именно тот пользователь, и что нам можно показать его емейл. Значит, так уж и быть, поверим и мы.

Есть стандарт авторизации OpenID connect, на основе OAuth 2.0, реализованный, например, в Keycloak. Стандарт, потому что в нём прописаны все детали, что отсутствуют в OAuth 2.0. Точные адреса эндпоинтов, правила передачи параметров, форматы токенов.

А ещё есть SAML — Security Assertion Markup Language. Такое странное название, потому что в этом протоколе осуществляется обмен сообщениями в формате XML, который тоже Markup Language. Протокол существует тоже в нескольких версиях, актуальная на текущий момент — версия 2.0. Удивительно, но Майкрософт вроде не приложили руку к SAML, это изначально детище OASIS.

SAML называют протоколом аутентификации. Его задача — сообщить заинтересованным сущностям, что личность этого пользователя установлена. А эти сущности уже сами решат, что с этим делать, и куда пользователя пускать. Ну и с помощью SAML делают SSO (Single Sign-On). Когда паролями и прочими штуками аутентификации занимается какой-то один (тот самый Single) компонент системы. А остальные компоненты лишь делегируют эти вещи, и доверяют этому компоненту.

Суть SAML сводится к обмену сообщениями. Между Service Provider (SP) — вашим сайтом, которому нужно аутентифицировать пользователя, чтобы предоставить ему какой-то сервис. И Identity Provider (IdP) — тем самым центральным компонентом, который берёт на себя аутентификацию пользователей.

SAML entities

На уровне сообщений всё просто.

Service Provider хочет понять, что за юзер к нему пришёл. И он отправляет так называемый AuthnRequest к Identity Provider.

AuthnRequest может выглядеть так:

<samlp:AuthnRequest 
    xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" 
    xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" 
    ID="ONELOGIN_d4cb296ba14031e6ca4a3ecc122b0ef076e96985" 
    Version="2.0" 
    IssueInstant="2023-09-17T06:22:00Z" 
    Destination="https://idp.saml.example.net/idp/endpoint/HttpRedirect"
    ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
    AssertionConsumerServiceURL="https://my.cool.site.example.net/saml/acs">
    <saml:Issuer>
        https://my.cool.site.example.net/saml/metadata
    </saml:Issuer>
    <samlp:NameIDPolicy 
        Format="urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified" 
        AllowCreate="true"/>
    <samlp:RequestedAuthnContext Comparison="exact">
        <saml:AuthnContextClassRef>
            urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport
        </saml:AuthnContextClassRef>
    </samlp:RequestedAuthnContext>
</samlp:AuthnRequest>

xmlns:* — это неймспейсы в XML. Они придают «смысла» последующим тегам.
ID — это уникальный идентификатор данного запроса.
Version — это версия SAML.
IssueInstant — дата-время данного запроса.
Destination — URL, куда этот запрос направляется, некий эндпоинт IdP.
ProtocolBinding — это enum, обозначающий, как «забиндить» ответ. Об этом чуть попозже...
AssertionConsumerServiceURL — URL эндпоинта под названием Assertion Consumer Service (ACS) у нашего Service Provider, адрес, куда направлять ответ.
Issuer — в данном случае идентификатор отправителя запроса. Обычно это URL, по которому можно получить некоторые метаданные, которые описывают нашего отправителя: URL эндпоинтов и т.п. В виде XML документа, конечно же.
RequestedAuthnContext — в данном случае значение PasswordProtectedTransport означает, что мы просим Identity Provider спросить у пользователя пароль.

Здесь запрос не подписан. Но Service Provider может добавить ещё и криптографическую подпись. Чтобы Identity Provider мог удостовериться, что запрос пришёл не абы от кого. Хотя обычно IdP воспринимает запросы только с заранее оговорёнными/настроенными ACS URL. Грубо говоря, можно считать, что Issuer соответствует Client ID из OAuth 2.0, а ACS URL — Redirect URI.

Identity Provider проверяет, каким-то способом, например, запрашивая пароль, идентичность пользователя. И после успешной проверки отправляет ответ.

Ответ может выглядеть так:

<saml2p:Response 
    xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol" 
    Destination="https://my.cool.site.example.net/saml/acs" 
    ID="_14c658df9e3c277f21113d5a3c1d40ef1694580083159" 
    InResponseTo="ONELOGIN_d4cb296ba14031e6ca4a3ecc122b0ef076e96985" 
    IssueInstant="22023-09-17T06:22:05Z" 
    Version="2.0" 
    xmlns:xsd="http://www.w3.org/2001/XMLSchema">
    <saml2:Issuer 
        xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion" 
        Format="urn:oasis:names:tc:SAML:2.0:nameid-format:entity">
        https://idp.saml.example.net
    </saml2:Issuer>
    <ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
        <ds:SignedInfo>
            <ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
            <ds:SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/>
            <ds:Reference URI="#_14c658df9e3c277f21113d5a3c1d40ef1694580083159">
                <ds:Transforms>
                    <ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/>
                    <ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#">
                        <ec:InclusiveNamespaces xmlns:ec="http://www.w3.org/2001/10/xml-exc-c14n#" PrefixList="xsd"/>
                    </ds:Transform>
                </ds:Transforms>
                <ds:DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/>
                <ds:DigestValue>
                    ...
                </ds:DigestValue>
            </ds:Reference>
        </ds:SignedInfo>
        <ds:SignatureValue>
            ...
        </ds:SignatureValue>
        <ds:KeyInfo>
            <ds:X509Data>
                <ds:X509Certificate>
                    ...
                </ds:X509Certificate>
            </ds:X509Data>
        </ds:KeyInfo>
    </ds:Signature>
    <saml2p:Status>
        <saml2p:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
    </saml2p:Status>
    <saml2:Assertion 
        xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion" 
        ID="_e3ac8fc176e96fb1d61c3c225016013f1694580083159" 
        IssueInstant="22023-09-17T06:22:05Z" 
        Version="2.0" 
        xmlns:xsd="http://www.w3.org/2001/XMLSchema">
        <saml2:Issuer Format="urn:oasis:names:tc:SAML:2.0:nameid-format:entity">
            https://idp.saml.example.net
        </saml2:Issuer>
        <ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
            <ds:SignedInfo>
                <ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
                <ds:SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/>
                <ds:Reference URI="#_e3ac8fc176e96fb1d61c3c225016013f1694580083159">
                    <ds:Transforms>
                        <ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/>
                        <ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#">
                            <ec:InclusiveNamespaces xmlns:ec="http://www.w3.org/2001/10/xml-exc-c14n#" PrefixList="xsd"/>
                        </ds:Transform>
                    </ds:Transforms>
                    <ds:DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/>
                    <ds:DigestValue>
                        ...
                    </ds:DigestValue>
                </ds:Reference>
            </ds:SignedInfo>
            <ds:SignatureValue>
                ...
            </ds:SignatureValue>
            <ds:KeyInfo>
                <ds:X509Data>
                    <ds:X509Certificate>
                        ...
                    </ds:X509Certificate>
                </ds:X509Data>
            </ds:KeyInfo>
        </ds:Signature>
        <saml2:Subject>
            <saml2:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified">
                123456
            </saml2:NameID>
            <saml2:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
                <saml2:SubjectConfirmationData 
                    InResponseTo="ONELOGIN_d4cb296ba14031e6ca4a3ecc122b0ef076e96985" 
                    NotOnOrAfter="22023-09-17T06:22:10Z" 
                    Recipient="https://my.cool.site.example.net/saml/acs"/>
            </saml2:SubjectConfirmation>
        </saml2:Subject>
        <saml2:Conditions NotBefore="22023-09-17T06:22:05Z" NotOnOrAfter="22023-09-17T06:22:10Z">
            <saml2:AudienceRestriction>
                <saml2:Audience>
                    https://my.cool.site.example.net/saml/metadata
                </saml2:Audience>
            </saml2:AudienceRestriction>
        </saml2:Conditions>
        <saml2:AuthnStatement AuthnInstant="22023-09-17T06:22:05Z">
            <saml2:AuthnContext>
                <saml2:AuthnContextClassRef>
                    urn:oasis:names:tc:SAML:2.0:ac:classes:unspecified
                </saml2:AuthnContextClassRef>
            </saml2:AuthnContext>
        </saml2:AuthnStatement>
        <saml2:AttributeStatement>
            <saml2:Attribute Name="userId" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:unspecified">
                <saml2:AttributeValue xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xsd:anyType">
                    0050xxxxxxxx
                </saml2:AttributeValue>
            </saml2:Attribute>
            <saml2:Attribute Name="username" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:unspecified">
                <saml2:AttributeValue xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xsd:anyType">
                    vasil.pupkin@saml.example.net
                </saml2:AttributeValue>
            </saml2:Attribute>
            <saml2:Attribute Name="email" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:unspecified">
                <saml2:AttributeValue xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xsd:anyType">
                    vasil.pupkin@example.com
                </saml2:AttributeValue>
            </saml2:Attribute>
        </saml2:AttributeStatement>
    </saml2:Assertion>
</saml2p:Response>

Выглядит сложно, как оно обычно и бывает с XML.
Destination — это кому направлен ответ, в данном случае это ACS URL нашего Service Provider.
ID — это ID ответа.
InResponseTo — это ID запроса, на который дан этот ответ.
Issuer — это Entity ID отправителя запроса (и того, кто поставил подпись).
Signature — это цифровая подпись. В формате XML Signature. SAML сообщение, кроме непосредственно подписи, может также включать публичный ключ для проверки этой подписи. Соответственно, проверяющая сторона должна держать список доверенных ключей. Подписывается как всё сообщение в целом, так и отдельно Assertion.
Status — статус ответа. В данном случае StatusCode содержит значение Success. Может быть и ошибка.

Assertion — самая важная часть ответа. В принципе, может быть запрошена отдельно, через отдельный специальный эндпоинт. Но здесь возвращается сразу в ответе.
У Assertion есть свой Issuer и своя Signature.
NameID — это, по сути, идентификатор пользователя в IdP.
Атрибут NotOnOrAfter указывает срок действия данного Assertion. В данном случае лишь пять минут.
Присутствуют всякие ограничения, кому именно и на какой срок выдано это Assertion.

AttributeStatement — это набор атрибутов пользователя. Ключ-значение. Какие атрибуты есть зависит исключительно от IdP. В SAML 2.0 атрибуты можно получить отдельным запросом.

Эти запросы и ответы — типичный XML. Куча всяких штуковин и разнообразных атрибутов. Часто дублирующих друг друга. Чтобы покрыть все возможные случаи, даже не встречающиеся в дикой природе. Просто смиритесь с тем, что вот так вот оно устроено.

Интереснее другое. Каким образом Service Provider и Identity Provider обмениваются этими XML сообщениями?

В первых версиях SAML обмен осуществлялся по протоколу SOAP. Это RPC протокол с сериализацией данных в XML. Ещё больше XML.

Но, для нашего Веба придумали и другие способы. Их в SAML называют Binding. В Вебе чаще всего используют два: HTTP-Redirect и HTTP-POST. В обоих случаях обмен данными происходит через браузер, SP и IdP не взаимодействуют друг с другом напрямую.

SAML login sequence diagram

Пользователь тыкает на ссылку или кнопку Login. И браузер переходит на некий эндпоинт нашего Service Provider. Провайдер формирует AuthnRequest, сжимает его с помощью Deflate, кодирует его с помощью Base64 и получает некоторую не сильно длинную строчку. Берёт URL эндпоинта Identity Provider, куда положено направлять наш запрос. Добавляет к этому URL query parameter SAMLRequest, где в качестве значения берёт тот самый закодированный AuthnRequest. И редиректит туда браузер. То есть возвращает в браузер статус HTTP 302 Found и заголовок Location с нашим новым URL. Это и есть HTTP-Redirect.

Часто добавляют ещё параметр RelayState. Он вернётся неизменным от IdP. Что может пригодиться, например, чтобы передать ID текущей сессии или URL, куда нужно вернуть пользователя после логина.

Identity Provider получает (HTTP GET) запрос из пользовательского браузера. Где есть параметр SAMLRequest, в котором закодирован AuthnRequest. IdP может обнаружить, например, по кукам, что этот пользователь/браузер уже залогинен. Тогда последующие шаги можно пропустить и сразу перейти к отправке ответа. Либо же пользователю нужно показать форму логина, проверить его пароль. И убедиться, что такой пользователь всё ещё активен.

Если с пользователем всё хорошо, и Identity Provider уверен, что это тот самый пользователь, то можно формировать Response. Провайдер пишет в Assertion то, что известно о пользователе. Выставляет в несильно далёкое будущее NotOnOrAfter. Подписывает своим приватным ключом. Прикладывает свой публичный ключ. Сжимает всё это с Deflate, кодирует с Base64. Получает уже довольно длинную строку.

Отправляет в браузер пользователю HTML форму со скрытым полем SAMLResponse, куда помещает закодированный ответ. Добавляет ещё поле RelayState. Навешивает немного JavaScript, чтобы форма сразу же засабмитилась на URL Service Provider под названием AssertionConsumerService (ACS). Это и есть HTTP-POST.

Service Provider получает HTTP POST запрос, где в полях формы есть закодированный SAMLResponse. Провайдер проверяет формат ответа, подписи, и что они сделаны доверенным сертификатом. Сохраняет Assertion куда-нибудь до лучших времён. О пользователе известны его ID на IdP, имя, емейл. Возможно, и назначенные роли. Этого достаточно, чтобы начать работать с этим пользователем.

Ну и с RelayState можно что-то сделать, как минимум проверить, что он тот же самый, что был отправлен. И редиректнуть пользователя туда.

Все эти передачки отлично видны в девелоперской консоли браузера. А для раскодирования всех этих Base64 сообщений есть samltool.io

Вот, вкратце, и весь SAML. Его часть про логин. Очень похоже на OAuth. Так же перенаправляем пользователя на страницы Identity Provider, где он должен залогиниться. А обратно получаем какие-то утверждённые сведения о пользователе. Ну не через редирект (то есть GET запрос), а через POST.

Более глубинная разница в том, что в случае OAuth у вас остаётся на руках токен. С этим токеном можно делать какие-то запросы на API, получать дополнительные сведения о пользователе, совершать какие-то действия там, где этот токен смогут принять и проверить. В случае SAML у вас остаётся лишь Assertion. Это полезные сведения о пользователе. Но больше с ними ничего сделать нельзя. Вроде как можно послать запрос для получения дополнительных атрибутов, но про это я не расскажу. Но нельзя что-либо сделать с какой-нибудь третьей сущностью, SAML подразумевает взаимодействие только с Identity Provider.

Но зато в SAML есть забавная фишка с логаутом. Если пользователь вылогинивается из Service Provider, тот может сообщить об этом в Identity Provider. А Identity Provider, в свою очередь, может сообщить об этом всем известным ему Service Provider. Получается такой глобальный логаут. Это может быть полезно.

Что-то мне больше нравится OpenID Connect (реализация OAuth 2.0). Всё чётко и понятно. И компактно. И функционально. А SAML выглядит слишком жирным для той кучки возможностей, что он предоставляет.