identizer 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +48 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +218 -0
  5. data/exe/identizer +7 -0
  6. data/lib/identizer/app.rb +111 -0
  7. data/lib/identizer/authorization.rb +21 -0
  8. data/lib/identizer/cli.rb +95 -0
  9. data/lib/identizer/configuration.rb +186 -0
  10. data/lib/identizer/directory_entry.rb +101 -0
  11. data/lib/identizer/docs.rb +22 -0
  12. data/lib/identizer/grant_store.rb +66 -0
  13. data/lib/identizer/handlers/auth0.rb +32 -0
  14. data/lib/identizer/handlers/auth0_management.rb +66 -0
  15. data/lib/identizer/handlers/base.rb +91 -0
  16. data/lib/identizer/handlers/cognito.rb +50 -0
  17. data/lib/identizer/handlers/directory.rb +76 -0
  18. data/lib/identizer/handlers/docs.rb +19 -0
  19. data/lib/identizer/handlers/login.rb +81 -0
  20. data/lib/identizer/handlers/oidc.rb +113 -0
  21. data/lib/identizer/handlers/overview.rb +19 -0
  22. data/lib/identizer/handlers/saml.rb +143 -0
  23. data/lib/identizer/handlers/settings.rb +22 -0
  24. data/lib/identizer/identity.rb +39 -0
  25. data/lib/identizer/identity_store/sqlite_store.rb +63 -0
  26. data/lib/identizer/identity_store.rb +86 -0
  27. data/lib/identizer/ldap/filter.rb +58 -0
  28. data/lib/identizer/ldap/handler.rb +66 -0
  29. data/lib/identizer/ldap/server.rb +178 -0
  30. data/lib/identizer/ldap.rb +16 -0
  31. data/lib/identizer/providers.rb +54 -0
  32. data/lib/identizer/renderer.rb +52 -0
  33. data/lib/identizer/responses.rb +46 -0
  34. data/lib/identizer/saml/encryptor.rb +66 -0
  35. data/lib/identizer/saml/keypair.rb +53 -0
  36. data/lib/identizer/saml/response_builder.rb +138 -0
  37. data/lib/identizer/saml/signer.rb +96 -0
  38. data/lib/identizer/saml.rb +17 -0
  39. data/lib/identizer/server.rb +134 -0
  40. data/lib/identizer/tls.rb +61 -0
  41. data/lib/identizer/token_minter.rb +89 -0
  42. data/lib/identizer/version.rb +5 -0
  43. data/lib/identizer/web/views/directory/index.html.erb +69 -0
  44. data/lib/identizer/web/views/docs/broker-app.html.erb +67 -0
  45. data/lib/identizer/web/views/docs/cognito.html.erb +22 -0
  46. data/lib/identizer/web/views/docs/getting-started.html.erb +28 -0
  47. data/lib/identizer/web/views/docs/index.html.erb +9 -0
  48. data/lib/identizer/web/views/docs/ldap.html.erb +38 -0
  49. data/lib/identizer/web/views/docs/oidc.html.erb +40 -0
  50. data/lib/identizer/web/views/docs/saml.html.erb +52 -0
  51. data/lib/identizer/web/views/docs/tls.html.erb +29 -0
  52. data/lib/identizer/web/views/docs/troubleshooting.html.erb +25 -0
  53. data/lib/identizer/web/views/layout.html.erb +58 -0
  54. data/lib/identizer/web/views/login.html.erb +19 -0
  55. data/lib/identizer/web/views/overview/index.html.erb +40 -0
  56. data/lib/identizer/web/views/settings/index.html.erb +28 -0
  57. data/lib/identizer.rb +64 -0
  58. metadata +282 -0
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Identizer
4
+ module Ldap
5
+ # Turns the directory into LDAP semantics: simple-bind authentication and
6
+ # subtree search with attribute projection. Protocol-agnostic — the Server
7
+ # handles BER; this handler only deals in DNs, attributes and result codes.
8
+ class Handler
9
+ SUCCESS = 0
10
+ INVALID_CREDENTIALS = 49
11
+
12
+ OBJECT_CLASSES = %w[top person organizationalPerson inetOrgPerson].freeze
13
+
14
+ def initialize(config)
15
+ @config = config
16
+ end
17
+
18
+ # Simple bind: anonymous (empty dn+password) succeeds; otherwise the DN must
19
+ # resolve to a directory entry and the password must match the shared one.
20
+ def bind(dn, password)
21
+ return SUCCESS if dn.to_s.empty? && password.to_s.empty?
22
+ return INVALID_CREDENTIALS unless password == @config.shared_password
23
+
24
+ entry_for_dn(dn) ? SUCCESS : INVALID_CREDENTIALS
25
+ end
26
+
27
+ # Returns [{ dn:, attributes: }] for entries under `base` matching `filter`.
28
+ def search(base, filter)
29
+ base = base.to_s.downcase
30
+ store.entries.filter_map do |entry|
31
+ attributes = attributes_for(entry)
32
+ next unless within_base?(entry, base)
33
+ next unless Filter.match?(filter, attributes)
34
+
35
+ { dn: entry.dn, attributes: attributes }
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ def store
42
+ @config.identity_store
43
+ end
44
+
45
+ def entry_for_dn(dn)
46
+ dn = dn.to_s.downcase
47
+ store.entries.find { |entry| entry.dn.casecmp?(dn) }
48
+ end
49
+
50
+ def within_base?(entry, base)
51
+ base.empty? || entry.dn.downcase.end_with?(base)
52
+ end
53
+
54
+ def attributes_for(entry)
55
+ attributes = {
56
+ "objectClass" => OBJECT_CLASSES,
57
+ "uid" => [entry.uid], "cn" => [entry["cn"]], "mail" => [entry.mail],
58
+ "ou" => [entry.ou], "givenName" => [entry["givenName"]], "sn" => [entry["sn"]],
59
+ "memberOf" => entry.groups
60
+ }
61
+ attributes.transform_values { |values| Array(values).compact.map(&:to_s) }
62
+ .reject { |_, values| values.empty? }
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Identizer
4
+ module Ldap
5
+ # A minimal LDAP v3 listener so apps that authenticate via LDAP can bind and
6
+ # search against the directory. Speaks BER over a plain TCP socket using
7
+ # Net::LDAP's codec. Supports simple bind, search (with the filters in
8
+ # Identizer::Ldap::Filter) and unbind — enough to develop LDAP auth locally.
9
+ class Server
10
+ # protocolOp application tags (request side).
11
+ BIND_REQUEST = 0x60
12
+ SEARCH_REQUEST = 0x63
13
+ UNBIND_REQUEST = 0x42
14
+ EXTENDED_REQUEST = 0x77
15
+
16
+ # protocolOp application tags (response side).
17
+ BIND_RESPONSE = 1
18
+ SEARCH_ENTRY = 4
19
+ SEARCH_DONE = 5
20
+ EXTENDED_RESPONSE = 24
21
+
22
+ PROTOCOL_ERROR = 2
23
+ STARTTLS_OID = "1.3.6.1.4.1.1466.20037"
24
+
25
+ # Net::LDAP's client syntax doesn't map the ExtendedRequest tag, so add it
26
+ # (as an array) — otherwise read_ber raises on a StartTLS request.
27
+ SYNTAX = Net::LDAP::AsnSyntax.dup.tap { |syntax| syntax[EXTENDED_REQUEST] = :array }.freeze
28
+
29
+ def initialize(config, host: nil, port: nil, tls: false)
30
+ @config = config
31
+ @host = host || config.ldap_host || config.host
32
+ @port = port || config.ldap_port || 1389
33
+ @tls = tls
34
+ @handler = Handler.new(config)
35
+ end
36
+
37
+ attr_reader :host, :port
38
+
39
+ def start
40
+ @socket = TCPServer.new(@host, @port)
41
+ @ssl_context = build_ssl_context if @tls
42
+ @running = true
43
+ accept_loop
44
+ end
45
+
46
+ def stop
47
+ @running = false
48
+ @socket&.close
49
+ rescue IOError
50
+ nil
51
+ end
52
+
53
+ private
54
+
55
+ def accept_loop
56
+ while @running
57
+ # accept returns nil when the socket is closed by stop (then @running is
58
+ # false and the loop exits) or when a TLS handshake fails (skip, keep serving).
59
+ client = accept
60
+ next unless client
61
+
62
+ Thread.new(client) { |connection| serve(connection) }
63
+ end
64
+ end
65
+
66
+ def accept
67
+ client = @socket.accept
68
+ @tls ? wrap_tls(client) : client
69
+ rescue IOError, Errno::EBADF
70
+ nil
71
+ end
72
+
73
+ def wrap_tls(client)
74
+ ssl = OpenSSL::SSL::SSLSocket.new(client, ssl_context)
75
+ ssl.sync_close = true
76
+ ssl.accept
77
+ ssl
78
+ rescue OpenSSL::SSL::SSLError, IOError
79
+ client.close
80
+ nil
81
+ end
82
+
83
+ def ssl_context
84
+ @ssl_context ||= build_ssl_context
85
+ end
86
+
87
+ def build_ssl_context
88
+ cert, key, = TLS.resolve(@config)
89
+ context = OpenSSL::SSL::SSLContext.new
90
+ context.cert = cert
91
+ context.key = key
92
+ context
93
+ end
94
+
95
+ def serve(connection)
96
+ readable(connection)
97
+ while (pdu = connection.read_ber(SYNTAX))
98
+ case dispatch(connection, pdu[0], pdu[1])
99
+ when :close then break
100
+ when :starttls
101
+ connection = upgrade_to_tls(connection)
102
+ break if connection.nil?
103
+ end
104
+ end
105
+ rescue StandardError
106
+ nil
107
+ ensure
108
+ connection.close if connection && !connection.closed?
109
+ end
110
+
111
+ # Net::BER's read_ber is mixed into IO/StringIO; an SSLSocket needs it added.
112
+ def readable(connection)
113
+ connection.extend(Net::BER::BERParser) unless connection.respond_to?(:read_ber)
114
+ end
115
+
116
+ def dispatch(connection, message_id, operation)
117
+ case operation.ber_identifier
118
+ when BIND_REQUEST then handle_bind(connection, message_id, operation)
119
+ when SEARCH_REQUEST then handle_search(connection, message_id, operation)
120
+ when EXTENDED_REQUEST then handle_extended(connection, message_id, operation)
121
+ when UNBIND_REQUEST then :close
122
+ else write(connection, message_id, result(PROTOCOL_ERROR, SEARCH_DONE))
123
+ end
124
+ end
125
+
126
+ # StartTLS (RFC 4513): acknowledge, then upgrade the plain socket to TLS.
127
+ def handle_extended(connection, message_id, operation)
128
+ if operation[0].to_s == STARTTLS_OID
129
+ write(connection, message_id, result(Handler::SUCCESS, EXTENDED_RESPONSE))
130
+ :starttls
131
+ else
132
+ write(connection, message_id, result(PROTOCOL_ERROR, EXTENDED_RESPONSE))
133
+ end
134
+ end
135
+
136
+ def upgrade_to_tls(connection)
137
+ ssl = OpenSSL::SSL::SSLSocket.new(connection, ssl_context)
138
+ ssl.sync_close = true
139
+ ssl.accept
140
+ readable(ssl)
141
+ ssl
142
+ rescue OpenSSL::SSL::SSLError, IOError
143
+ nil
144
+ end
145
+
146
+ def handle_bind(connection, message_id, operation)
147
+ code = @handler.bind(operation[1].to_s, operation[2].to_s)
148
+ write(connection, message_id, result(code, BIND_RESPONSE))
149
+ end
150
+
151
+ def handle_search(connection, message_id, operation)
152
+ base = operation[0].to_s
153
+ filter = operation[6]
154
+ @handler.search(base, filter).each do |entry|
155
+ write(connection, message_id, search_entry(entry))
156
+ end
157
+ write(connection, message_id, result(Handler::SUCCESS, SEARCH_DONE))
158
+ end
159
+
160
+ def search_entry(entry)
161
+ attributes = entry[:attributes].map do |name, values|
162
+ [name.to_ber, values.map(&:to_ber).to_ber_set].to_ber_sequence
163
+ end
164
+ [entry[:dn].to_ber, attributes.to_ber_sequence].to_ber_appsequence(SEARCH_ENTRY)
165
+ end
166
+
167
+ # An LDAPResult (resultCode, matchedDN, diagnosticMessage) under a tag.
168
+ def result(code, tag)
169
+ [code.to_ber_enumerated, "".to_ber, "".to_ber].to_ber_appsequence(tag)
170
+ end
171
+
172
+ def write(connection, message_id, operation_ber)
173
+ connection.write([message_id.to_ber, operation_ber].to_ber_sequence)
174
+ nil
175
+ end
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Optional LDAP listener. Required on demand (e.g. by the CLI when --ldap-port is
4
+ # set) so the net-ldap dependency is only loaded when the feature is used.
5
+ require "net/ldap"
6
+ require "socket"
7
+
8
+ module Identizer
9
+ # A minimal LDAP v3 server backed by the identity directory.
10
+ module Ldap
11
+ end
12
+ end
13
+
14
+ require_relative "ldap/filter"
15
+ require_relative "ldap/handler"
16
+ require_relative "ldap/server"
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Identizer
4
+ # The default provider cheatsheet rendered on the dashboard. Kept out of
5
+ # Configuration so the config object stays about settings, not view copy.
6
+ module Providers
7
+ module_function
8
+
9
+ def default(base_url)
10
+ [
11
+ {
12
+ title: "OpenID Connect",
13
+ note: nil,
14
+ fields: [
15
+ ["Issuer URL", base_url],
16
+ ["Authorization endpoint", "#{base_url}/v1/authorize"],
17
+ ["Token endpoint", "#{base_url}/v1/token"],
18
+ ["Discovery", "#{base_url}/.well-known/openid-configuration"],
19
+ ["Client ID", "dev-client"],
20
+ ["Client secret", "dev-secret"]
21
+ ]
22
+ },
23
+ {
24
+ title: "OAuth2 / Auth0-style",
25
+ note: "Exchange the code at /oauth/token, then fetch the profile at /userinfo.",
26
+ fields: [
27
+ ["Authorization endpoint", "#{base_url}/authorize"],
28
+ ["Token endpoint", "#{base_url}/oauth/token"],
29
+ ["Userinfo endpoint", "#{base_url}/userinfo"],
30
+ ["Domain (bare, no scheme)", base_url.sub(%r{\Ahttps?://}, "")]
31
+ ]
32
+ },
33
+ {
34
+ title: "SAML 2.0",
35
+ note: "A real signed IdP. Point your SP at the SSO endpoint and metadata below.",
36
+ fields: [
37
+ ["Metadata URL", "#{base_url}/metadata"],
38
+ ["SSO URL (Redirect/POST)", "#{base_url}/saml/sso"],
39
+ ["NameID", "emailAddress"]
40
+ ]
41
+ },
42
+ {
43
+ title: "AWS Cognito broker",
44
+ note: "Point COGNITO_ENDPOINT at this server so the management API is stubbed.",
45
+ fields: [
46
+ ["Endpoint", base_url],
47
+ ["Hosted UI login", "#{base_url}/login"],
48
+ ["Token endpoint", "#{base_url}/oauth2/token"]
49
+ ]
50
+ }
51
+ ]
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+
5
+ module Identizer
6
+ # Minimal ERB renderer with a shared layout. Templates live under web/views and
7
+ # are rendered in a Context that exposes the passed locals plus an `h` escaping
8
+ # helper. No template-engine dependency — stdlib ERB only.
9
+ class Renderer
10
+ VIEWS_DIR = File.expand_path("web/views", __dir__)
11
+
12
+ def initialize(layout: "layout")
13
+ @layout = layout
14
+ @cache = {}
15
+ end
16
+
17
+ def render(template, **locals)
18
+ content = render_template(template, locals)
19
+ render_template(@layout, locals.merge(content: content))
20
+ end
21
+
22
+ # Render a standalone template without the admin layout (e.g. the login form).
23
+ def render_bare(template, **locals)
24
+ render_template(template, locals)
25
+ end
26
+
27
+ private
28
+
29
+ def render_template(name, locals)
30
+ template(name).result(Context.new(locals).binding_for)
31
+ end
32
+
33
+ def template(name)
34
+ @cache[name] ||= ERB.new(File.read(File.join(VIEWS_DIR, "#{name}.html.erb")), trim_mode: "-")
35
+ end
36
+
37
+ # Evaluation context: locals become reader methods; `h` escapes HTML.
38
+ class Context
39
+ def initialize(locals)
40
+ locals.each { |key, value| define_singleton_method(key) { value } }
41
+ end
42
+
43
+ def h(value)
44
+ CGI.escapeHTML(value.to_s)
45
+ end
46
+
47
+ def binding_for
48
+ binding
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Identizer
4
+ # Tiny helpers for building Rack responses ([status, headers, body]).
5
+ module Responses
6
+ def json(status, payload)
7
+ [status, { "content-type" => "application/json" }, [JSON.generate(payload)]]
8
+ end
9
+
10
+ # AWS SDK expects this content type back from the Cognito management API.
11
+ def amz_json(payload)
12
+ [200, { "content-type" => "application/x-amz-json-1.1" }, [JSON.generate(payload)]]
13
+ end
14
+
15
+ def html(body, status: 200)
16
+ [status, { "content-type" => "text/html; charset=utf-8" }, [body]]
17
+ end
18
+
19
+ def xml(body, headers: {})
20
+ [200, { "content-type" => "application/xml; charset=utf-8" }.merge(headers), [body]]
21
+ end
22
+
23
+ def redirect(location, status: 302)
24
+ [status, { "location" => location }, []]
25
+ end
26
+
27
+ def not_found(message)
28
+ json(404, { error: message })
29
+ end
30
+
31
+ def no_content
32
+ [204, {}, []]
33
+ end
34
+
35
+ def escape_html(value)
36
+ CGI.escapeHTML(value.to_s)
37
+ end
38
+
39
+ # A small standalone HTML page (errors/notices). `body_html` is inserted raw,
40
+ # so callers must escape any user-controlled values they put in it.
41
+ def notice_page(heading, body_html)
42
+ html("<!doctype html><html><body style=\"font-family:sans-serif;max-width:480px;margin:64px auto\">" \
43
+ "<h2>#{escape_html(heading)}</h2><p>#{body_html}</p></body></html>")
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+ require "openssl"
5
+ require "base64"
6
+
7
+ module Identizer
8
+ module Saml
9
+ # XML-Encryption of a (signed) SAML Assertion into an <EncryptedAssertion>:
10
+ # AES-256-CBC for the assertion, RSA-OAEP key transport of the AES key under
11
+ # the SP's certificate. Decryptable by standard SPs (validated with ruby-saml).
12
+ class Encryptor
13
+ XENC = "http://www.w3.org/2001/04/xmlenc#"
14
+ AES256_CBC = "#{XENC}aes256-cbc".freeze
15
+ RSA_OAEP = "#{XENC}rsa-oaep-mgf1p".freeze
16
+
17
+ def initialize(certificate)
18
+ @certificate = certificate
19
+ end
20
+
21
+ # Replaces `assertion` in its document with an <EncryptedAssertion> node.
22
+ def encrypt!(assertion)
23
+ document = assertion.document
24
+ plaintext = assertion.to_xml(save_with: Nokogiri::XML::Node::SaveOptions::AS_XML)
25
+
26
+ cipher = OpenSSL::Cipher.new("aes-256-cbc").encrypt
27
+ key = cipher.random_key
28
+ iv = cipher.random_iv
29
+ ciphertext = cipher.update(plaintext) + cipher.final
30
+
31
+ encrypted = encrypted_assertion_node(
32
+ document,
33
+ cipher_value: Base64.strict_encode64(iv + ciphertext),
34
+ encrypted_key: Base64.strict_encode64(transport_key(key)),
35
+ certificate: Base64.strict_encode64(@certificate.to_der)
36
+ )
37
+ assertion.replace(encrypted)
38
+ encrypted
39
+ end
40
+
41
+ private
42
+
43
+ # RSA-OAEP (SHA-1 / MGF1, i.e. rsa-oaep-mgf1p) wrap of the AES key.
44
+ def transport_key(key)
45
+ @certificate.public_key.public_encrypt(key, OpenSSL::PKey::RSA::PKCS1_OAEP_PADDING)
46
+ end
47
+
48
+ def encrypted_assertion_node(document, cipher_value:, encrypted_key:, certificate:)
49
+ fragment = [
50
+ %(<saml:EncryptedAssertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">),
51
+ %(<xenc:EncryptedData xmlns:xenc="#{XENC}" Type="#{XENC}Element">),
52
+ %(<xenc:EncryptionMethod Algorithm="#{AES256_CBC}"/>),
53
+ %(<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><xenc:EncryptedKey>),
54
+ %(<xenc:EncryptionMethod Algorithm="#{RSA_OAEP}"/>),
55
+ %(<ds:KeyInfo><ds:X509Data><ds:X509Certificate>#{certificate}</ds:X509Certificate>),
56
+ %(</ds:X509Data></ds:KeyInfo>),
57
+ %(<xenc:CipherData><xenc:CipherValue>#{encrypted_key}</xenc:CipherValue></xenc:CipherData>),
58
+ %(</xenc:EncryptedKey></ds:KeyInfo>),
59
+ %(<xenc:CipherData><xenc:CipherValue>#{cipher_value}</xenc:CipherValue></xenc:CipherData>),
60
+ %(</xenc:EncryptedData></saml:EncryptedAssertion>)
61
+ ].join
62
+ document.parse(fragment).first
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Identizer
4
+ module Saml
5
+ # The RSA key + self-signed certificate the IdP signs assertions with,
6
+ # persisted under the config dir so metadata stays stable across restarts.
7
+ class Keypair
8
+ def self.load_or_generate(config_dir)
9
+ key_path = File.join(config_dir, "saml_signing_key.pem")
10
+ cert_path = File.join(config_dir, "saml_signing_cert.pem")
11
+
12
+ if File.exist?(key_path) && File.exist?(cert_path)
13
+ return new(OpenSSL::PKey::RSA.new(File.read(key_path)),
14
+ OpenSSL::X509::Certificate.new(File.read(cert_path)))
15
+ end
16
+
17
+ key = OpenSSL::PKey::RSA.new(2048)
18
+ certificate = self_signed(key)
19
+ FileUtils.mkdir_p(config_dir)
20
+ File.write(key_path, key.to_pem)
21
+ File.chmod(0o600, key_path)
22
+ File.write(cert_path, certificate.to_pem)
23
+ new(key, certificate)
24
+ end
25
+
26
+ def self.self_signed(key)
27
+ name = OpenSSL::X509::Name.parse("/CN=identizer-saml")
28
+ cert = OpenSSL::X509::Certificate.new
29
+ cert.version = 2
30
+ cert.serial = 1
31
+ cert.subject = name
32
+ cert.issuer = name
33
+ cert.public_key = key.public_key
34
+ cert.not_before = Time.now - 60
35
+ cert.not_after = Time.now + (10 * 365 * 24 * 60 * 60)
36
+ cert.sign(key, OpenSSL::Digest.new("SHA256"))
37
+ cert
38
+ end
39
+
40
+ attr_reader :key, :certificate
41
+
42
+ def initialize(key, certificate)
43
+ @key = key
44
+ @certificate = certificate
45
+ end
46
+
47
+ # Base64 DER, the form embedded in SAML metadata and <ds:X509Certificate>.
48
+ def certificate_base64
49
+ Base64.strict_encode64(@certificate.to_der)
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+ require "securerandom"
5
+
6
+ module Identizer
7
+ module Saml
8
+ # Builds a SAML 2.0 Response containing a signed Assertion for a signed-in
9
+ # identity, ready to POST to the SP's assertion consumer service.
10
+ class ResponseBuilder
11
+ PROTOCOL = "urn:oasis:names:tc:SAML:2.0:protocol"
12
+ ASSERTION = "urn:oasis:names:tc:SAML:2.0:assertion"
13
+ SUCCESS = "urn:oasis:names:tc:SAML:2.0:status:Success"
14
+ BEARER = "urn:oasis:names:tc:SAML:2.0:cm:bearer"
15
+ EMAIL_FORMAT = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
16
+ BASIC_FORMAT = "urn:oasis:names:tc:SAML:2.0:attrname-format:basic"
17
+ URI_FORMAT = "urn:oasis:names:tc:SAML:2.0:attrname-format:uri"
18
+ PASSWORD_CONTEXT = "urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport"
19
+ VALIDITY = 300
20
+
21
+ def initialize(config, keypair)
22
+ @config = config
23
+ @keypair = keypair
24
+ end
25
+
26
+ # Returns the signed Response XML string.
27
+ def build(identity:, acs_url:, audience:, in_response_to: nil, now: Time.now)
28
+ document = document_for(identity, acs_url, audience, in_response_to, now)
29
+ signer = Signer.new(@keypair)
30
+ signer.sign!(document.at_xpath("//saml:Assertion", "saml" => ASSERTION))
31
+ encrypt_assertion(document) if encrypt?
32
+ signer.sign!(document.root) if @config.saml_sign_response # sign the Response too
33
+ document.to_xml(save_with: Nokogiri::XML::Node::SaveOptions::AS_XML |
34
+ Nokogiri::XML::Node::SaveOptions::NO_DECLARATION)
35
+ end
36
+
37
+ def build_base64(**)
38
+ Base64.strict_encode64(build(**))
39
+ end
40
+
41
+ private
42
+
43
+ def encrypt?
44
+ @config.saml_encrypt_assertion && @config.saml_sp_certificate
45
+ end
46
+
47
+ def encrypt_assertion(document)
48
+ assertion = document.at_xpath("//saml:Assertion", "saml" => ASSERTION)
49
+ Encryptor.new(@config.saml_sp_certificate).encrypt!(assertion)
50
+ end
51
+
52
+ def document_for(identity, acs_url, audience, in_response_to, now)
53
+ response_id = "_#{SecureRandom.hex(16)}"
54
+ assertion_id = "_#{SecureRandom.hex(16)}"
55
+
56
+ builder = Nokogiri::XML::Builder.new do |xml|
57
+ xml["samlp"].Response(response_attributes(response_id, acs_url, in_response_to, now)) do
58
+ xml["saml"].Issuer(@config.issuer)
59
+ xml["samlp"].Status { xml["samlp"].StatusCode(Value: SUCCESS) }
60
+ assertion(xml, identity, assertion_id, acs_url, audience, in_response_to, now)
61
+ end
62
+ end
63
+ builder.doc
64
+ end
65
+
66
+ def assertion(xml, identity, assertion_id, acs_url, audience, in_response_to, now)
67
+ xml["saml"].Assertion(ID: assertion_id, Version: "2.0", IssueInstant: iso(now)) do
68
+ xml["saml"].Issuer(@config.issuer)
69
+ subject(xml, identity, acs_url, in_response_to, now)
70
+ conditions(xml, audience, now)
71
+ authn_statement(xml, assertion_id, now)
72
+ attribute_statement(xml, identity)
73
+ end
74
+ end
75
+
76
+ def subject(xml, identity, acs_url, in_response_to, now)
77
+ xml["saml"].Subject do
78
+ xml["saml"].NameID(identity.email, Format: EMAIL_FORMAT)
79
+ xml["saml"].SubjectConfirmation(Method: BEARER) do
80
+ xml["saml"].SubjectConfirmationData(confirmation_attributes(acs_url, in_response_to, now))
81
+ end
82
+ end
83
+ end
84
+
85
+ def conditions(xml, audience, now)
86
+ xml["saml"].Conditions(NotBefore: iso(now - VALIDITY), NotOnOrAfter: iso(now + VALIDITY)) do
87
+ xml["saml"].AudienceRestriction { xml["saml"].Audience(audience) }
88
+ end
89
+ end
90
+
91
+ def authn_statement(xml, assertion_id, now)
92
+ xml["saml"].AuthnStatement(AuthnInstant: iso(now), SessionIndex: assertion_id) do
93
+ xml["saml"].AuthnContext { xml["saml"].AuthnContextClassRef(PASSWORD_CONTEXT) }
94
+ end
95
+ end
96
+
97
+ def attribute_statement(xml, identity)
98
+ names = @config.saml_attribute_names
99
+ xml["saml"].AttributeStatement do
100
+ identity.to_h.each do |claim, value|
101
+ xml["saml"].Attribute(attribute_naming(claim, names)) do
102
+ Array(value).each { |item| xml["saml"].AttributeValue(item.to_s) }
103
+ end
104
+ end
105
+ end
106
+ end
107
+
108
+ # Map a claim to its SAML Attribute name/format, keeping the short claim
109
+ # name as FriendlyName when a URI name is configured.
110
+ def attribute_naming(claim, names)
111
+ mapped = names[claim]
112
+ return { Name: claim, NameFormat: BASIC_FORMAT } unless mapped
113
+
114
+ { Name: mapped, FriendlyName: claim, NameFormat: URI_FORMAT }
115
+ end
116
+
117
+ def response_attributes(response_id, acs_url, in_response_to, now)
118
+ attributes = {
119
+ "xmlns:samlp" => PROTOCOL, "xmlns:saml" => ASSERTION,
120
+ "ID" => response_id, "Version" => "2.0",
121
+ "IssueInstant" => iso(now), "Destination" => acs_url
122
+ }
123
+ attributes["InResponseTo"] = in_response_to if in_response_to
124
+ attributes
125
+ end
126
+
127
+ def confirmation_attributes(acs_url, in_response_to, now)
128
+ attributes = { "NotOnOrAfter" => iso(now + VALIDITY), "Recipient" => acs_url }
129
+ attributes["InResponseTo"] = in_response_to if in_response_to
130
+ attributes
131
+ end
132
+
133
+ def iso(time)
134
+ time.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
135
+ end
136
+ end
137
+ end
138
+ end