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,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