Commit 0704a77

mo khan <mo@mokhan.ca>
2025-03-25 15:51:32
feat: exchange saml assertion for an access token
1 parent c866f5e
Changed files (3)
bin/idp
@@ -99,8 +99,20 @@ module Authn
         end
       end
 
-      def find_by_username(username)
+      def find_by
         all.find do |user|
+          yield user
+        end
+      end
+
+      def find_by_email(email)
+        find_by do |user|
+          user[:email] == email
+        end
+      end
+
+      def find_by_username(username)
+        find_by do |user|
           user[:username] == username
         end
       end
@@ -443,7 +455,7 @@ module Authz
           client_credentials_grant(params)
         when 'password'
           password_grant(params[:username], params[:password])
-        when 'urn:ietf:params:oauth:grant-type:saml2-bearer' # RFC7522
+        when "urn:ietf:params:oauth:grant-type:saml2-bearer" # RFC-7522
           saml_assertion_grant(params[:assertion])
         when 'urn:ietf:params:oauth:grant-type:jwt-bearer' # RFC7523
           jwt_bearer_grant(params)
@@ -469,8 +481,19 @@ module Authz
         raise NotImplementedError
       end
 
-      def saml_assertion_grant(saml_assertion)
-        raise NotImplementedError
+      def saml_assertion_grant(encoded_saml_assertion)
+        xml = Base64.decode64(encoded_saml_assertion)
+        saml_response = Saml::Kit::Document.to_saml_document(xml)
+        saml_assertion = saml_response.assertion
+        # TODO:: Validate signature and prevent assertion reuse
+
+        user = case saml_assertion.name_id_format
+        when Saml::Kit::Namespaces::EMAIL_ADDRESS
+          ::Authn::User.find_by_email(saml_assertion.name_id)
+        when Saml::Kit::Namespaces::PERSISTENT
+          ::Authn::User.find(saml_assertion.name_id)
+        end
+        new(user, saml_assertion: saml_assertion)
       end
 
       def jwt_bearer_grant(params)
@@ -517,7 +540,7 @@ module Authz
         expires_in: 3600,
         refresh_token: SecureRandom.hex(32)
       }.tap do |body|
-        if params['scope'].include?("openid")
+        if params["scope"]&.include?("openid")
           body[:id_token] = user.create_id_token.to_jwt
         end
       end
bin/ui
@@ -388,6 +388,7 @@ class UI
         <body style="background-color: pink;">
           <h2>Received SAML Response</h2>
           <textarea readonly="readonly" disabled="disabled" cols=220 rows=40><%=- saml_response.to_xml(pretty: true) -%></textarea>
+          <pre id="saml-response"><%= request.params["SAMLResponse"] %></pre>
         </body>
       </html>
     ERB
test/e2e_test.go
@@ -3,6 +3,7 @@ package main
 import (
 	"bytes"
 	"context"
+	"encoding/base64"
 	"net/http"
 	"net/url"
 	"strings"
@@ -58,6 +59,20 @@ func TestAuthx(t *testing.T) {
 			assert.Equal(t, "http://ui.example.com:8080/saml/assertions", action)
 			assert.NoError(t, page.Locator("#submit-button").Click())
 			assert.Contains(t, x.Must(page.Content()), "Received SAML Response")
+
+			t.Run("exchange SAML assertion for access token", func(t *testing.T) {
+				samlAssertion := x.Must(page.Locator("#saml-response").TextContent())
+				io := bytes.NewBuffer(nil)
+				assert.NoError(t, serde.ToJSON(io, map[string]string{
+					"assertion":  samlAssertion,
+					"grant_type": "urn:ietf:params:oauth:grant-type:saml2-bearer",
+				}))
+				request := x.Must(http.NewRequestWithContext(t.Context(), "POST", "http://idp.example.com:8080/oauth/token", io))
+				request.Header.Add("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("client_id:client_secret")))
+				request.Header.Add("Content-Type", "application/json ")
+				response := x.Must(client.Do(request))
+				require.Equal(t, http.StatusOK, response.StatusCode)
+			})
 		})
 	})