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,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Identizer
|
|
4
|
+
module Handlers
|
|
5
|
+
# Bundled, read-only documentation pages under /docs.
|
|
6
|
+
class Docs < Base
|
|
7
|
+
def index(request)
|
|
8
|
+
page("docs/index", request, nav: :docs, title: "Docs", pages: Identizer::Docs::PAGES)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def show(request, slug)
|
|
12
|
+
meta = Identizer::Docs.find(slug)
|
|
13
|
+
return not_found("No doc page: #{slug}") unless meta
|
|
14
|
+
|
|
15
|
+
page("docs/#{slug}", request, nav: :docs, title: meta[:title], config: config)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Identizer
|
|
4
|
+
module Handlers
|
|
5
|
+
# The interactive login surface: a form (shared by the Cognito hosted-UI,
|
|
6
|
+
# Auth0 and OIDC authorize endpoints) and the selection step that mints a
|
|
7
|
+
# code and redirects back to the app.
|
|
8
|
+
class Login < Base
|
|
9
|
+
# The authorization-request parameters that must survive the login form.
|
|
10
|
+
CARRIED_PARAMS = %w[redirect_uri state scope nonce code_challenge code_challenge_method client_id].freeze
|
|
11
|
+
|
|
12
|
+
def form(request)
|
|
13
|
+
render_login(
|
|
14
|
+
title: "Identizer — Sign in", heading: "Identizer — Sign in", note: sign_in_note,
|
|
15
|
+
form_method: "get", action: "#{request.script_name}/__select",
|
|
16
|
+
hidden: CARRIED_PARAMS.map { |name| [name, request.params[name]] },
|
|
17
|
+
config_link: "#{request.script_name}/"
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def select(request)
|
|
22
|
+
email = request.params["email"].to_s.strip
|
|
23
|
+
password = request.params["password"].to_s
|
|
24
|
+
redirect_uri = request.params["redirect_uri"].to_s
|
|
25
|
+
state = request.params["state"].to_s
|
|
26
|
+
|
|
27
|
+
# Validate the redirect target FIRST — never bounce to an unregistered URI,
|
|
28
|
+
# not even on the error paths below (that would be the open redirect).
|
|
29
|
+
unless config.redirect_uri_allowed?(request.params["client_id"], redirect_uri)
|
|
30
|
+
return notice_page("Sign-in blocked",
|
|
31
|
+
"redirect_uri <code>#{escape_html(redirect_uri)}</code> is not registered " \
|
|
32
|
+
"for this client.")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
unless password == config.shared_password
|
|
36
|
+
return error_redirect(redirect_uri, state, "access_denied", "Invalid credentials")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
unless store.emails.include?(email)
|
|
40
|
+
return error_redirect(redirect_uri, state, "access_denied", "Unknown user: #{email}")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
code = SecureRandom.hex(20)
|
|
44
|
+
codes.put(code, authorization_for(request, email), ttl: config.code_ttl)
|
|
45
|
+
auth_redirect(redirect_uri, state, code: code)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def authorization_for(request, email)
|
|
51
|
+
Authorization.new(
|
|
52
|
+
identity: store.identity_for(email),
|
|
53
|
+
code_challenge: request.params["code_challenge"],
|
|
54
|
+
code_challenge_method: request.params["code_challenge_method"],
|
|
55
|
+
scope: request.params["scope"],
|
|
56
|
+
nonce: request.params["nonce"],
|
|
57
|
+
client_id: request.params["client_id"]
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def sign_in_note
|
|
62
|
+
"Sign in as one of the configured identities. The password for every identity is " \
|
|
63
|
+
"<code>#{escape_html(config.shared_password)}</code> — use a wrong password or an " \
|
|
64
|
+
"unconfigured email to test the provider's error response."
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def error_redirect(redirect_uri, state, error, description)
|
|
68
|
+
auth_redirect(redirect_uri, state, error: error, error_description: description)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def auth_redirect(redirect_uri, state, params)
|
|
72
|
+
query = params.merge(state: state)
|
|
73
|
+
.map { |key, value| "#{key}=#{Rack::Utils.escape(value.to_s)}" }
|
|
74
|
+
.join("&")
|
|
75
|
+
separator = redirect_uri.include?("?") ? "&" : "?"
|
|
76
|
+
|
|
77
|
+
redirect("#{redirect_uri}#{separator}#{query}")
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Identizer
|
|
4
|
+
module Handlers
|
|
5
|
+
# OpenID Connect: the authorization-code + refresh-token grants, PKCE, the
|
|
6
|
+
# discovery and JWKS documents, and the end-session (logout) endpoint.
|
|
7
|
+
class Oidc < Base
|
|
8
|
+
def token(request)
|
|
9
|
+
case request.params["grant_type"]
|
|
10
|
+
when "refresh_token" then refresh(request)
|
|
11
|
+
else authorization_code(request)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def discovery
|
|
16
|
+
json(200, minter.discovery)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def jwks
|
|
20
|
+
json(200, minter.jwks)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# RFC 7662 token introspection (access or refresh token).
|
|
24
|
+
def introspect(request)
|
|
25
|
+
token = merged_params(request)["token"]
|
|
26
|
+
authorization = token && (access_tokens.get(token) || refresh_tokens.get(token))
|
|
27
|
+
return json(200, { active: false }) if authorization.nil?
|
|
28
|
+
|
|
29
|
+
identity = authorization.identity
|
|
30
|
+
json(200, {
|
|
31
|
+
active: true, sub: identity.sub, username: identity.email,
|
|
32
|
+
scope: authorization.scope, client_id: authorization.client_id, token_type: "Bearer"
|
|
33
|
+
}.compact)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# RFC 7009 token revocation: revoke the submitted token AND its paired
|
|
37
|
+
# access/refresh token. Always 200, even for unknown tokens.
|
|
38
|
+
def revoke(request)
|
|
39
|
+
token = merged_params(request)["token"]
|
|
40
|
+
authorization = token && (access_tokens.get(token) || refresh_tokens.get(token))
|
|
41
|
+
if authorization
|
|
42
|
+
access_tokens.take(authorization.access_token)
|
|
43
|
+
refresh_tokens.take(authorization.refresh_token)
|
|
44
|
+
end
|
|
45
|
+
if token
|
|
46
|
+
access_tokens.take(token)
|
|
47
|
+
refresh_tokens.take(token)
|
|
48
|
+
end
|
|
49
|
+
json(200, {})
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# RP-initiated logout: bounce back to post_logout_redirect_uri if given and allowed.
|
|
53
|
+
def logout(request)
|
|
54
|
+
target = request.params["post_logout_redirect_uri"].to_s
|
|
55
|
+
return notice_page("Signed out", "You have been signed out.") if target.empty?
|
|
56
|
+
unless config.post_logout_redirect_allowed?(request.params["client_id"], target)
|
|
57
|
+
return notice_page("Signed out", "The post_logout_redirect_uri is not registered.")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
state = request.params["state"]
|
|
61
|
+
separator = target.include?("?") ? "&" : "?"
|
|
62
|
+
location = state.to_s.empty? ? target : "#{target}#{separator}state=#{Rack::Utils.escape(state)}"
|
|
63
|
+
redirect(location)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def authorization_code(request)
|
|
69
|
+
return json(401, { error: "invalid_client" }) unless valid_client?(request)
|
|
70
|
+
|
|
71
|
+
authorization = redeem_code(request)
|
|
72
|
+
return json(400, { error: "invalid_grant", error_description: "bad code or PKCE" }) if authorization.nil?
|
|
73
|
+
|
|
74
|
+
issue(authorization)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def refresh(request)
|
|
78
|
+
authorization = refresh_tokens.take(request.params["refresh_token"]) # single-use, rotated
|
|
79
|
+
return json(400, { error: "invalid_grant" }) if authorization.nil?
|
|
80
|
+
|
|
81
|
+
issue(authorization)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def issue(authorization)
|
|
85
|
+
access_token = SecureRandom.hex(20)
|
|
86
|
+
refresh_token = SecureRandom.hex(20)
|
|
87
|
+
# Record the pair so revoking one revokes the other (RFC 7009).
|
|
88
|
+
authorization.access_token = access_token
|
|
89
|
+
authorization.refresh_token = refresh_token
|
|
90
|
+
access_tokens.put(access_token, authorization, ttl: config.access_token_ttl) # /userinfo resolves it
|
|
91
|
+
refresh_tokens.put(refresh_token, authorization, ttl: config.refresh_token_ttl)
|
|
92
|
+
|
|
93
|
+
body = {
|
|
94
|
+
access_token: access_token,
|
|
95
|
+
id_token: minter.id_token(authorization.identity, nonce: authorization.nonce,
|
|
96
|
+
audience: authorization.client_id),
|
|
97
|
+
token_type: "Bearer",
|
|
98
|
+
expires_in: config.access_token_ttl,
|
|
99
|
+
refresh_token: refresh_token
|
|
100
|
+
}
|
|
101
|
+
body[:scope] = authorization.scope unless authorization.scope.to_s.empty?
|
|
102
|
+
json(200, body)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Lenient by default: only enforced when clients are configured.
|
|
106
|
+
def valid_client?(request)
|
|
107
|
+
return true if config.clients.empty?
|
|
108
|
+
|
|
109
|
+
config.clients.any? { |client| client[:client_id] == request.params["client_id"] }
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Identizer
|
|
4
|
+
module Handlers
|
|
5
|
+
# The web-admin home: status, the provider cheatsheet, and links into the rest.
|
|
6
|
+
class Overview < Base
|
|
7
|
+
def index(request)
|
|
8
|
+
page("overview/index", request, nav: :overview, title: "Overview",
|
|
9
|
+
config: config, count: directory_size)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def directory_size
|
|
15
|
+
store.respond_to?(:entries) ? store.entries.size : store.emails.size
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rexml/document"
|
|
4
|
+
require "zlib"
|
|
5
|
+
|
|
6
|
+
module Identizer
|
|
7
|
+
module Handlers
|
|
8
|
+
# A real SAML 2.0 IdP: signed metadata, an SSO endpoint that handles SP- and
|
|
9
|
+
# IdP-initiated requests, and a signed-Response auto-POST back to the SP's
|
|
10
|
+
# assertion consumer service. Signing is done by Identizer::Saml (nokogiri),
|
|
11
|
+
# required lazily so it is only loaded when actually producing a Response.
|
|
12
|
+
class Saml < Base
|
|
13
|
+
EMAIL_FORMAT = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
|
|
14
|
+
MAX_AUTHN_REQUEST = 100_000 # reject oversized SAMLRequest (deflate-bomb guard)
|
|
15
|
+
|
|
16
|
+
def metadata(request)
|
|
17
|
+
headers = {}
|
|
18
|
+
headers["content-disposition"] = "attachment; filename=\"identizer-metadata.xml\"" if request.params["download"]
|
|
19
|
+
xml(metadata_xml, headers: headers)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# SP-initiated (a SAMLRequest) or IdP-initiated (?acs=...): show the login form.
|
|
23
|
+
def sso(request)
|
|
24
|
+
context = authn_context(request)
|
|
25
|
+
return json(400, { error: "no AssertionConsumerServiceURL" }) if context[:acs].to_s.empty?
|
|
26
|
+
return saml_error("ACS not allowed: #{escape_html(context[:acs])}") unless config.acs_allowed?(context[:acs])
|
|
27
|
+
|
|
28
|
+
login_form(request.script_name, context)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Validate the login and POST a signed SAML Response back to the SP.
|
|
32
|
+
def finish(request)
|
|
33
|
+
email = request.params["email"].to_s.strip
|
|
34
|
+
return saml_error("Invalid credentials") unless request.params["password"] == config.shared_password
|
|
35
|
+
return saml_error("Unknown user: #{escape_html(email)}") unless store.emails.include?(email)
|
|
36
|
+
|
|
37
|
+
acs = request.params["acs"].to_s
|
|
38
|
+
return saml_error("ACS not allowed: #{escape_html(acs)}") unless config.acs_allowed?(acs)
|
|
39
|
+
|
|
40
|
+
response = build_response(store.identity_for(email), acs, request)
|
|
41
|
+
html(auto_post(acs, response, request.params["relay_state"].to_s))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def build_response(identity, acs, request)
|
|
47
|
+
require "identizer/saml"
|
|
48
|
+
audience = present(request.params["audience"]) || acs
|
|
49
|
+
Identizer::Saml::ResponseBuilder.new(config, config.saml_keypair).build_base64(
|
|
50
|
+
identity: identity, acs_url: acs, audience: audience,
|
|
51
|
+
in_response_to: present(request.params["in_response_to"])
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def authn_context(request)
|
|
56
|
+
saml_request = request.params["SAMLRequest"].to_s
|
|
57
|
+
relay_state = request.params["RelayState"].to_s
|
|
58
|
+
base = { relay_state: relay_state }
|
|
59
|
+
|
|
60
|
+
if saml_request.empty?
|
|
61
|
+
base.merge(acs: request.params["acs"], audience: present(request.params["audience"]), in_response_to: nil)
|
|
62
|
+
else
|
|
63
|
+
parsed = parse_authn_request(saml_request, request.request_method)
|
|
64
|
+
base.merge(acs: parsed[:acs] || request.params["acs"], audience: parsed[:issuer], in_response_to: parsed[:id])
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def parse_authn_request(value, method)
|
|
69
|
+
return {} if value.bytesize > MAX_AUTHN_REQUEST
|
|
70
|
+
|
|
71
|
+
raw = Base64.decode64(value)
|
|
72
|
+
xml = method == "GET" ? inflate(raw) : raw
|
|
73
|
+
document = REXML::Document.new(xml)
|
|
74
|
+
root = document.root
|
|
75
|
+
issuer = REXML::XPath.first(document, "//*[local-name()='Issuer']")&.text
|
|
76
|
+
{ acs: root&.attributes&.[]("AssertionConsumerServiceURL"), id: root&.attributes&.[]("ID"), issuer: issuer }
|
|
77
|
+
rescue StandardError
|
|
78
|
+
{}
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def inflate(raw)
|
|
82
|
+
out = Zlib::Inflate.new(-Zlib::MAX_WBITS).inflate(raw)
|
|
83
|
+
raise "SAMLRequest too large" if out.bytesize > 5_000_000
|
|
84
|
+
|
|
85
|
+
out
|
|
86
|
+
rescue StandardError
|
|
87
|
+
raw
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def present(value)
|
|
91
|
+
value.to_s.empty? ? nil : value
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def metadata_xml
|
|
95
|
+
base = config.base_url
|
|
96
|
+
<<~XML
|
|
97
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
98
|
+
<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" entityID="#{base}/metadata">
|
|
99
|
+
<IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol" WantAuthnRequestsSigned="false">
|
|
100
|
+
<KeyDescriptor use="signing">
|
|
101
|
+
<KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#">
|
|
102
|
+
<X509Data><X509Certificate>#{config.saml_keypair.certificate_base64}</X509Certificate></X509Data>
|
|
103
|
+
</KeyInfo>
|
|
104
|
+
</KeyDescriptor>
|
|
105
|
+
<NameIDFormat>#{EMAIL_FORMAT}</NameIDFormat>
|
|
106
|
+
<SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="#{base}/saml/sso"/>
|
|
107
|
+
<SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="#{base}/saml/sso"/>
|
|
108
|
+
</IDPSSODescriptor>
|
|
109
|
+
</EntityDescriptor>
|
|
110
|
+
XML
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def login_form(prefix, context)
|
|
114
|
+
render_login(
|
|
115
|
+
title: "Identizer — SAML sign in", heading: "Identizer — SAML sign in",
|
|
116
|
+
note: "Signing in to <code>#{escape_html(context[:acs])}</code>. Password for every identity: " \
|
|
117
|
+
"<code>#{escape_html(config.shared_password)}</code>.",
|
|
118
|
+
form_method: "post", action: "#{prefix}/saml/finish",
|
|
119
|
+
hidden: [["acs", context[:acs]], ["audience", context[:audience]],
|
|
120
|
+
["in_response_to", context[:in_response_to]], ["relay_state", context[:relay_state]]],
|
|
121
|
+
config_link: ""
|
|
122
|
+
)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def saml_error(message_html)
|
|
126
|
+
notice_page("SAML error", message_html)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def auto_post(acs, saml_response, relay_state)
|
|
130
|
+
relay = relay_state.empty? ? "" : %(<input type="hidden" name="RelayState" value="#{escape_html(relay_state)}">)
|
|
131
|
+
<<~HTML
|
|
132
|
+
<!doctype html><html><body onload="document.forms[0].submit()">
|
|
133
|
+
<form method="post" action="#{escape_html(acs)}">
|
|
134
|
+
<input type="hidden" name="SAMLResponse" value="#{escape_html(saml_response)}">
|
|
135
|
+
#{relay}
|
|
136
|
+
<noscript><button type="submit">Continue</button></noscript>
|
|
137
|
+
</form>
|
|
138
|
+
</body></html>
|
|
139
|
+
HTML
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Identizer
|
|
4
|
+
module Handlers
|
|
5
|
+
# View and edit runtime settings (shared password, token signing), persisted
|
|
6
|
+
# to settings.json so the standalone server picks them up on the next boot.
|
|
7
|
+
class Settings < Base
|
|
8
|
+
def show(request)
|
|
9
|
+
page("settings/index", request, nav: :settings, title: "Settings", config: config)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def update(request)
|
|
13
|
+
password = request.params["shared_password"].to_s
|
|
14
|
+
config.shared_password = password unless password.empty?
|
|
15
|
+
config.signing = request.params["signing"] == "rs256" ? :rs256 : :hs256
|
|
16
|
+
config.persist_settings!
|
|
17
|
+
|
|
18
|
+
redirect("#{request.script_name}/settings")
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Identizer
|
|
4
|
+
# A signed-in identity: a subject id, an email, and an arbitrary bag of
|
|
5
|
+
# additional claims (given_name, family_name, groups, ...). `to_h` is what the
|
|
6
|
+
# provider encodes into id_tokens and returns from /userinfo.
|
|
7
|
+
class Identity
|
|
8
|
+
attr_reader :sub, :email, :claims
|
|
9
|
+
|
|
10
|
+
def initialize(email:, sub: nil, claims: {})
|
|
11
|
+
@email = email.to_s
|
|
12
|
+
@sub = (sub || "identizer|#{@email}").to_s
|
|
13
|
+
@claims = stringify(claims)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Coerce a Hash, String (email) or Identity into an Identity.
|
|
17
|
+
def self.from(value)
|
|
18
|
+
return value if value.is_a?(Identity)
|
|
19
|
+
|
|
20
|
+
attrs = value.is_a?(Hash) ? value.transform_keys(&:to_sym) : { email: value }
|
|
21
|
+
claims = attrs[:claims] || attrs.except(:email, :sub)
|
|
22
|
+
new(email: attrs[:email], sub: attrs[:sub], claims: claims)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def to_h
|
|
26
|
+
{ "sub" => sub, "email" => email }.merge(claims)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def ==(other)
|
|
30
|
+
other.is_a?(Identity) && other.to_h == to_h
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def stringify(hash)
|
|
36
|
+
(hash || {}).each_with_object({}) { |(k, v), acc| acc[k.to_s] = v }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "sqlite3"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Identizer
|
|
7
|
+
module IdentityStore
|
|
8
|
+
# Optional SQLite-backed directory. Same interface as ConfigStore, so it drops
|
|
9
|
+
# in via `config.identity_store`. Requires the `sqlite3` gem (not a default
|
|
10
|
+
# dependency) — add it to your Gemfile to use this adapter.
|
|
11
|
+
#
|
|
12
|
+
# require "identizer/identity_store/sqlite_store"
|
|
13
|
+
# config.identity_store = Identizer::IdentityStore::SqliteStore.new(path: "dev.sqlite3")
|
|
14
|
+
class SqliteStore
|
|
15
|
+
def initialize(path:, base_dn: DirectoryEntry::DEFAULT_BASE_DN, seed: [])
|
|
16
|
+
@base_dn = base_dn
|
|
17
|
+
@db = SQLite3::Database.new(path.to_s)
|
|
18
|
+
@db.execute("CREATE TABLE IF NOT EXISTS entries (mail TEXT PRIMARY KEY, data TEXT NOT NULL)")
|
|
19
|
+
seed_if_empty(Array(seed))
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def entries
|
|
23
|
+
@db.execute("SELECT data FROM entries ORDER BY mail").map do |row|
|
|
24
|
+
DirectoryEntry.from(JSON.parse(row[0]), base_dn: @base_dn)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def emails
|
|
29
|
+
entries.map(&:mail)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def identity_for(email)
|
|
33
|
+
email = email.to_s.strip
|
|
34
|
+
return nil if email.empty?
|
|
35
|
+
|
|
36
|
+
entry = entries.find { |candidate| candidate.mail == email }
|
|
37
|
+
(entry || DirectoryEntry.from({ "mail" => email }, base_dn: @base_dn)).to_identity
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def upsert(attrs)
|
|
41
|
+
entry = DirectoryEntry.from(attrs, base_dn: @base_dn)
|
|
42
|
+
@db.execute(<<~SQL, [entry.mail, JSON.generate(entry.to_h)])
|
|
43
|
+
INSERT INTO entries (mail, data) VALUES (?, ?)
|
|
44
|
+
ON CONFLICT(mail) DO UPDATE SET data = excluded.data
|
|
45
|
+
SQL
|
|
46
|
+
entry
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def delete(email)
|
|
50
|
+
@db.execute("DELETE FROM entries WHERE mail = ?", [email.to_s.strip])
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def seed_if_empty(seed)
|
|
56
|
+
return if seed.empty?
|
|
57
|
+
return if @db.get_first_value("SELECT COUNT(*) FROM entries").to_i.positive?
|
|
58
|
+
|
|
59
|
+
@db.transaction { seed.each { |entry| upsert(DirectoryEntry.from(entry, base_dn: @base_dn).to_h) } }
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Identizer
|
|
4
|
+
# An identity store is the "user directory" half of the provider. The provider
|
|
5
|
+
# only needs this duck-typed read interface, so any backend (the default JSON
|
|
6
|
+
# ConfigStore, a future SQLite adapter, an app's own DB) can be plugged in via
|
|
7
|
+
# `config.identity_store`:
|
|
8
|
+
#
|
|
9
|
+
# #emails -> Array<String> the addresses the login form accepts
|
|
10
|
+
# #identity_for(email) -> Identity|nil resolve an address to an Identity
|
|
11
|
+
#
|
|
12
|
+
# For full management through the web admin a store also exposes the directory
|
|
13
|
+
# interface: #entries, #upsert(attrs), #delete(email) (the default does).
|
|
14
|
+
module IdentityStore
|
|
15
|
+
# Default store: an LDAP-flavoured directory persisted to a JSON file the web
|
|
16
|
+
# admin writes, seeded from in-code DirectoryEntry seeds until the file fills.
|
|
17
|
+
class ConfigStore
|
|
18
|
+
def initialize(path:, seed: [], base_dn: DirectoryEntry::DEFAULT_BASE_DN)
|
|
19
|
+
@path = path
|
|
20
|
+
@base_dn = base_dn
|
|
21
|
+
@seed = Array(seed).map { |entry| DirectoryEntry.from(entry, base_dn: base_dn) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def entries
|
|
25
|
+
hashes = read
|
|
26
|
+
return @seed if hashes.nil? # no usable file yet -> fall back to the seed
|
|
27
|
+
|
|
28
|
+
hashes.map { |entry| DirectoryEntry.from(entry, base_dn: @base_dn) }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def emails
|
|
32
|
+
entries.map(&:mail)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def identity_for(email)
|
|
36
|
+
email = email.to_s.strip
|
|
37
|
+
return nil if email.empty?
|
|
38
|
+
|
|
39
|
+
entry = entries.find { |candidate| candidate.mail == email }
|
|
40
|
+
(entry || DirectoryEntry.from({ "mail" => email }, base_dn: @base_dn)).to_identity
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Create or replace a directory entry, keyed by mail.
|
|
44
|
+
def upsert(attrs)
|
|
45
|
+
entry = DirectoryEntry.from(attrs, base_dn: @base_dn)
|
|
46
|
+
remaining = current_hashes.reject { |hash| hash["mail"] == entry.mail }
|
|
47
|
+
write(remaining + [entry.to_h])
|
|
48
|
+
entry
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def delete(email)
|
|
52
|
+
email = email.to_s.strip
|
|
53
|
+
write(current_hashes.reject { |hash| hash["mail"] == email })
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def current_hashes
|
|
59
|
+
entries.map(&:to_h)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Returns the persisted entry hashes (possibly empty), or nil when there is
|
|
63
|
+
# no usable file yet — nil is what triggers the seed fallback.
|
|
64
|
+
def read
|
|
65
|
+
return nil unless File.exist?(@path)
|
|
66
|
+
|
|
67
|
+
data = JSON.parse(File.read(@path))
|
|
68
|
+
data["entries"] || data["identities"] || legacy(data) || []
|
|
69
|
+
rescue StandardError
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Accept the {"emails": [...]} shape too, so simple configs keep working.
|
|
74
|
+
def legacy(data)
|
|
75
|
+
return nil unless data["emails"].is_a?(Array)
|
|
76
|
+
|
|
77
|
+
data["emails"].map { |email| { "mail" => email } }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def write(entries)
|
|
81
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
82
|
+
File.write(@path, JSON.generate("entries" => entries))
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Identizer
|
|
4
|
+
module Ldap
|
|
5
|
+
# Matches an LDAP search filter (as parsed by Net::LDAP's BER reader) against
|
|
6
|
+
# an entry's attribute hash. Operates directly on the BER structure, keyed by
|
|
7
|
+
# ber_identifier, so it supports the compound filters Net::LDAP::Filter#match
|
|
8
|
+
# does not (and / or / not), plus equality, presence and substrings.
|
|
9
|
+
module Filter
|
|
10
|
+
AND = 0xa0
|
|
11
|
+
OR = 0xa1
|
|
12
|
+
NOT = 0xa2
|
|
13
|
+
EQUALITY = 0xa3
|
|
14
|
+
SUBSTRING = 0xa4
|
|
15
|
+
PRESENT = 0x87
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
def match?(filter, attributes)
|
|
20
|
+
case filter.ber_identifier
|
|
21
|
+
when AND then filter.all? { |sub| match?(sub, attributes) }
|
|
22
|
+
when OR then filter.any? { |sub| match?(sub, attributes) }
|
|
23
|
+
when NOT then !match?(filter.first, attributes)
|
|
24
|
+
when EQUALITY then equality?(filter, attributes)
|
|
25
|
+
when SUBSTRING then substring?(filter, attributes)
|
|
26
|
+
when PRESENT then present?(filter, attributes)
|
|
27
|
+
else true # unsupported filter types match everything (permissive for a test IdP)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def equality?(filter, attributes)
|
|
32
|
+
wanted = filter[1].to_s
|
|
33
|
+
values(attributes, filter[0].to_s).any? { |value| value.casecmp?(wanted) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def present?(filter, attributes)
|
|
37
|
+
name = filter.to_s
|
|
38
|
+
return true if name.casecmp?("objectclass")
|
|
39
|
+
|
|
40
|
+
!values(attributes, name).empty?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def substring?(filter, attributes)
|
|
44
|
+
parts = Array(filter[1]).map { |part| Regexp.escape(part.to_s) }
|
|
45
|
+
return true if parts.empty?
|
|
46
|
+
|
|
47
|
+
pattern = Regexp.new(parts.join(".*"), Regexp::IGNORECASE)
|
|
48
|
+
values(attributes, filter[0].to_s).any? { |value| pattern.match?(value) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Case-insensitive attribute lookup -> array of string values.
|
|
52
|
+
def values(attributes, name)
|
|
53
|
+
key = attributes.keys.find { |candidate| candidate.to_s.casecmp?(name) }
|
|
54
|
+
key ? Array(attributes[key]).map(&:to_s) : []
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|