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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +48 -0
- data/LICENSE.txt +21 -0
- data/README.md +218 -0
- data/exe/identizer +7 -0
- data/lib/identizer/app.rb +111 -0
- data/lib/identizer/authorization.rb +21 -0
- data/lib/identizer/cli.rb +95 -0
- data/lib/identizer/configuration.rb +186 -0
- data/lib/identizer/directory_entry.rb +101 -0
- data/lib/identizer/docs.rb +22 -0
- data/lib/identizer/grant_store.rb +66 -0
- data/lib/identizer/handlers/auth0.rb +32 -0
- data/lib/identizer/handlers/auth0_management.rb +66 -0
- data/lib/identizer/handlers/base.rb +91 -0
- data/lib/identizer/handlers/cognito.rb +50 -0
- data/lib/identizer/handlers/directory.rb +76 -0
- data/lib/identizer/handlers/docs.rb +19 -0
- data/lib/identizer/handlers/login.rb +81 -0
- data/lib/identizer/handlers/oidc.rb +113 -0
- data/lib/identizer/handlers/overview.rb +19 -0
- data/lib/identizer/handlers/saml.rb +143 -0
- data/lib/identizer/handlers/settings.rb +22 -0
- data/lib/identizer/identity.rb +39 -0
- data/lib/identizer/identity_store/sqlite_store.rb +63 -0
- data/lib/identizer/identity_store.rb +86 -0
- data/lib/identizer/ldap/filter.rb +58 -0
- data/lib/identizer/ldap/handler.rb +66 -0
- data/lib/identizer/ldap/server.rb +178 -0
- data/lib/identizer/ldap.rb +16 -0
- data/lib/identizer/providers.rb +54 -0
- data/lib/identizer/renderer.rb +52 -0
- data/lib/identizer/responses.rb +46 -0
- data/lib/identizer/saml/encryptor.rb +66 -0
- data/lib/identizer/saml/keypair.rb +53 -0
- data/lib/identizer/saml/response_builder.rb +138 -0
- data/lib/identizer/saml/signer.rb +96 -0
- data/lib/identizer/saml.rb +17 -0
- data/lib/identizer/server.rb +134 -0
- data/lib/identizer/tls.rb +61 -0
- data/lib/identizer/token_minter.rb +89 -0
- data/lib/identizer/version.rb +5 -0
- data/lib/identizer/web/views/directory/index.html.erb +69 -0
- data/lib/identizer/web/views/docs/broker-app.html.erb +67 -0
- data/lib/identizer/web/views/docs/cognito.html.erb +22 -0
- data/lib/identizer/web/views/docs/getting-started.html.erb +28 -0
- data/lib/identizer/web/views/docs/index.html.erb +9 -0
- data/lib/identizer/web/views/docs/ldap.html.erb +38 -0
- data/lib/identizer/web/views/docs/oidc.html.erb +40 -0
- data/lib/identizer/web/views/docs/saml.html.erb +52 -0
- data/lib/identizer/web/views/docs/tls.html.erb +29 -0
- data/lib/identizer/web/views/docs/troubleshooting.html.erb +25 -0
- data/lib/identizer/web/views/layout.html.erb +58 -0
- data/lib/identizer/web/views/login.html.erb +19 -0
- data/lib/identizer/web/views/overview/index.html.erb +40 -0
- data/lib/identizer/web/views/settings/index.html.erb +28 -0
- data/lib/identizer.rb +64 -0
- 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
|