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,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Identizer
4
+ # Everything the provider needs to run, with sensible dev defaults. Replaces
5
+ # the Rails.* / ENV reads of the original emulator with one explicit object.
6
+ class Configuration
7
+ def initialize
8
+ @host = "127.0.0.1"
9
+ @port = int_env("IDENTIZER_PORT", "SSO_MOCK_PORT", default: 9999)
10
+ @tls_cert_path = env_presence("IDENTIZER_TLS_CERT", "SSO_MOCK_TLS_CERT")
11
+ @tls_key_path = env_presence("IDENTIZER_TLS_KEY", "SSO_MOCK_TLS_KEY")
12
+ @config_dir = ENV.fetch("IDENTIZER_CONFIG_DIR", File.join(Dir.pwd, "tmp", "identizer"))
13
+ @shared_password = "password"
14
+ @signing = :hs256 # :hs256 (unsigned-style parity) or :rs256 (verifiable)
15
+ @hs256_key = "identizer-development-key"
16
+ @scheme = "https"
17
+ @url_host = "localhost"
18
+ @ldap_base_dn = "dc=identizer,dc=local"
19
+ @ldap_host = nil
20
+ @ldap_port = optional_int_env("IDENTIZER_LDAP_PORT") # nil = LDAP listener off
21
+ @ldaps_port = optional_int_env("IDENTIZER_LDAPS_PORT") # nil = LDAPS listener off
22
+ @seed_identities = []
23
+ # Optional client registry: [{ client_id:, redirect_uris:, post_logout_redirect_uris: }].
24
+ # A client_secret may be present but is NOT verified (dev tool). Separate from
25
+ # the apps provisioned at runtime via the Auth0 Management API.
26
+ @clients = []
27
+ @saml_allowed_acs = [] # optional allowlist of SAML ACS URLs ([] = allow any, dev default)
28
+ @saml_sign_response = true # sign the SAML Response in addition to the Assertion
29
+ @saml_encrypt_assertion = false # encrypt the assertion when an SP certificate is set
30
+ @saml_sp_certificate = nil # SP cert (PEM) used to encrypt the assertion
31
+ @code_ttl = 600
32
+ @access_token_ttl = 3600
33
+ @refresh_token_ttl = 86_400
34
+ @request_logging = true # standalone server logs a concise request line
35
+ end
36
+
37
+ # All plain settings, grouped (defaults are set in #initialize).
38
+ attr_accessor :host, :port, :tls_cert_path, :tls_key_path, :config_dir, :shared_password, :signing, :hs256_key,
39
+ :scheme, :url_host, :request_logging,
40
+ :ldap_base_dn, :ldap_host, :ldap_port, :ldaps_port,
41
+ :clients, # OAuth client registry ([] = lenient); enables redirect_uri allowlisting
42
+ :sqlite_path, # set by --sqlite to swap in the SQLite-backed directory
43
+ :code_ttl, :access_token_ttl, :refresh_token_ttl, # grant lifetimes (seconds)
44
+ :saml_allowed_acs, :saml_sign_response, :saml_encrypt_assertion
45
+
46
+ # The SP certificate used to encrypt the assertion (PEM string or cert object).
47
+ def saml_sp_certificate
48
+ cert = @saml_sp_certificate
49
+ return nil if cert.nil?
50
+
51
+ cert.is_a?(OpenSSL::X509::Certificate) ? cert : OpenSSL::X509::Certificate.new(cert.to_s)
52
+ end
53
+
54
+ # The IdP's SAML signing key + certificate, generated/persisted on first use.
55
+ def saml_keypair
56
+ @saml_keypair ||= begin
57
+ require "identizer/saml/keypair"
58
+ Saml::Keypair.load_or_generate(config_dir)
59
+ end
60
+ end
61
+
62
+ # Claim -> SAML Attribute Name. Defaults to the Microsoft/WS-Fed claim URIs
63
+ # that real SAML IdPs (Azure AD, ADFS, Okta) emit and SPs match on; the short
64
+ # claim name is kept as the FriendlyName. Override to suit a specific SP.
65
+ SAML_ATTRIBUTE_NAMES = {
66
+ "email" => "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
67
+ "given_name" => "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname",
68
+ "family_name" => "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname",
69
+ "name" => "http://schemas.microsoft.com/identity/claims/displayname",
70
+ "groups" => "http://schemas.microsoft.com/ws/2008/06/identity/claims/groups"
71
+ }.freeze
72
+
73
+ def saml_attribute_names
74
+ @saml_attribute_names ||= SAML_ATTRIBUTE_NAMES.dup
75
+ end
76
+
77
+ attr_writer :saml_sp_certificate, :identity_store, :base_url, :issuer, :seed_identities, :providers, :saml_keypair,
78
+ :saml_attribute_names
79
+
80
+ # Public URL the provider advertises in metadata, discovery and redirects.
81
+ def base_url
82
+ @base_url ||= "#{scheme}://#{url_host}:#{port}"
83
+ end
84
+
85
+ def issuer
86
+ @issuer ||= base_url
87
+ end
88
+
89
+ def rs256?
90
+ signing == :rs256
91
+ end
92
+
93
+ def seed_identities
94
+ Array(@seed_identities).map { |entry| DirectoryEntry.from(entry, base_dn: ldap_base_dn) }
95
+ end
96
+
97
+ def identity_store
98
+ @identity_store ||= IdentityStore::ConfigStore.new(
99
+ path: File.join(config_dir, "config.json"),
100
+ seed: seed_identities,
101
+ base_dn: ldap_base_dn
102
+ )
103
+ end
104
+
105
+ # Cheatsheet rendered on the dashboard. Override to match your app's stack.
106
+ def providers
107
+ @providers || Providers.default(base_url)
108
+ end
109
+
110
+ # Open-redirect guard. Lenient (true) until clients are registered; then the
111
+ # redirect_uri must match one registered for that client.
112
+ def redirect_uri_allowed?(client_id, redirect_uri)
113
+ return true if clients.empty?
114
+
115
+ client = clients.find { |entry| entry[:client_id] == client_id }
116
+ return false unless client
117
+
118
+ allowed = Array(client[:redirect_uris])
119
+ allowed.empty? || allowed.include?(redirect_uri)
120
+ end
121
+
122
+ # RP-initiated-logout guard, mirroring redirect_uri_allowed?.
123
+ def post_logout_redirect_allowed?(client_id, uri)
124
+ return true if clients.empty?
125
+
126
+ client = clients.find { |entry| entry[:client_id] == client_id }
127
+ return false unless client
128
+
129
+ allowed = Array(client[:post_logout_redirect_uris])
130
+ allowed.empty? || allowed.include?(uri)
131
+ end
132
+
133
+ # SAML ACS guard. Lenient until an allowlist is configured.
134
+ def acs_allowed?(acs)
135
+ saml_allowed_acs.empty? || saml_allowed_acs.include?(acs)
136
+ end
137
+
138
+ def settings_path
139
+ File.join(config_dir, "settings.json")
140
+ end
141
+
142
+ # Apply settings previously saved from the web admin (password, signing mode).
143
+ # Called at boot; explicit flags/config still override afterwards.
144
+ def apply_persisted_settings!
145
+ data = JSON.parse(File.read(settings_path))
146
+ self.shared_password = data["shared_password"] if data["shared_password"]
147
+ self.signing = data["signing"].to_sym if data["signing"]
148
+ self
149
+ rescue StandardError
150
+ self
151
+ end
152
+
153
+ def persist_settings!
154
+ FileUtils.mkdir_p(config_dir)
155
+ File.write(settings_path, JSON.generate("shared_password" => shared_password, "signing" => signing.to_s))
156
+ end
157
+
158
+ private
159
+
160
+ def env_presence(*keys)
161
+ keys.each do |key|
162
+ value = ENV.fetch(key, nil)
163
+ return value if value && !value.empty?
164
+ end
165
+ nil
166
+ end
167
+
168
+ def optional_int_env(key)
169
+ raw = env_presence(key)
170
+ return nil if raw.nil?
171
+
172
+ Integer(raw)
173
+ rescue ArgumentError
174
+ raise ArgumentError, "#{key} must be an integer (got #{raw.inspect})"
175
+ end
176
+
177
+ def int_env(*keys, default:)
178
+ raw = env_presence(*keys)
179
+ return default if raw.nil?
180
+
181
+ Integer(raw)
182
+ rescue ArgumentError
183
+ raise ArgumentError, "#{keys.first} must be an integer (got #{raw.inspect})"
184
+ end
185
+ end
186
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Identizer
4
+ # An LDAP-flavoured directory entry: an attribute bag (uid, cn, sn, givenName,
5
+ # mail, ou, memberOf, ...) with a computed DN. This is the unit the directory
6
+ # stores and the web admin edits. `to_identity` projects it onto the token-
7
+ # facing Identity, mapping LDAP attributes to standard OIDC claims.
8
+ class DirectoryEntry
9
+ DEFAULT_BASE_DN = "dc=identizer,dc=local"
10
+ DEFAULT_OU = "people"
11
+
12
+ # LDAP attribute -> OIDC claim mapping for the token projection.
13
+ CLAIM_MAP = {
14
+ "givenName" => "given_name",
15
+ "sn" => "family_name",
16
+ "cn" => "name",
17
+ "memberOf" => "groups",
18
+ "uid" => "preferred_username"
19
+ }.freeze
20
+
21
+ # Attributes surfaced as editable fields in the web admin (in order).
22
+ EDITABLE_ATTRIBUTES = %w[mail uid givenName sn cn ou memberOf].freeze
23
+
24
+ attr_reader :attributes
25
+
26
+ def initialize(attributes = {}, base_dn: DEFAULT_BASE_DN)
27
+ @base_dn = base_dn
28
+ @attributes = normalize(attributes)
29
+ backfill!
30
+ end
31
+
32
+ def self.from(value, base_dn: DEFAULT_BASE_DN)
33
+ return value if value.is_a?(DirectoryEntry)
34
+
35
+ attributes = value.is_a?(Hash) ? value : { "mail" => value }
36
+ new(attributes, base_dn: base_dn)
37
+ end
38
+
39
+ def [](key)
40
+ @attributes[key.to_s]
41
+ end
42
+
43
+ def mail = self["mail"]
44
+ def uid = self["uid"]
45
+ def ou = self["ou"] || DEFAULT_OU
46
+ def groups = Array(self["memberOf"]).reject { |group| group.to_s.empty? }
47
+
48
+ # Attributes beyond the standard editable set — provider-specific names a SP
49
+ # might expect (e.g. custom_1, department). Emitted verbatim as claims.
50
+ def custom_attributes
51
+ @attributes.except(*EDITABLE_ATTRIBUTES)
52
+ end
53
+
54
+ def dn
55
+ "uid=#{uid},ou=#{ou},#{@base_dn}"
56
+ end
57
+
58
+ # Token-facing projection: email + OIDC-mapped claims (+ dn).
59
+ def to_identity
60
+ claims = { "dn" => dn }
61
+ @attributes.each do |key, value|
62
+ next if %w[mail userPassword].include?(key)
63
+
64
+ claims[CLAIM_MAP[key] || key] = value
65
+ end
66
+ Identity.new(email: mail, sub: dn, claims: claims)
67
+ end
68
+
69
+ def to_h
70
+ @attributes
71
+ end
72
+
73
+ private
74
+
75
+ def normalize(attributes)
76
+ attributes.each_with_object({}) do |(key, value), acc|
77
+ key = key.to_s
78
+ key = "mail" if key == "email" # email is an alias for the mail attribute
79
+ next if value.nil? || value == ""
80
+
81
+ acc[key] = key == "memberOf" ? Array(value).reject { |group| group.to_s.empty? } : value
82
+ end
83
+ end
84
+
85
+ def backfill!
86
+ @attributes["mail"] = mail.to_s
87
+ @attributes["uid"] ||= default_uid
88
+ @attributes["cn"] ||= default_cn
89
+ end
90
+
91
+ def default_uid
92
+ local = mail.to_s.split("@").first
93
+ local.nil? || local.empty? ? "user" : local
94
+ end
95
+
96
+ def default_cn
97
+ name = [self["givenName"], self["sn"]].compact.join(" ").strip
98
+ name.empty? ? uid : name
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Identizer
4
+ # Registry of the bundled, read-only documentation pages shown under /docs.
5
+ # Each slug maps to a web/views/docs/<slug>.html.erb template.
6
+ module Docs
7
+ PAGES = [
8
+ { slug: "getting-started", title: "Getting started" },
9
+ { slug: "oidc", title: "OIDC integration" },
10
+ { slug: "cognito", title: "AWS Cognito broker" },
11
+ { slug: "broker-app", title: "Cheatsheet: Cognito-brokered app" },
12
+ { slug: "saml", title: "SAML" },
13
+ { slug: "ldap", title: "LDAP listener" },
14
+ { slug: "tls", title: "TLS & mkcert" },
15
+ { slug: "troubleshooting", title: "Troubleshooting" }
16
+ ].freeze
17
+
18
+ def self.find(slug)
19
+ PAGES.find { |page| page[:slug] == slug }
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Identizer
4
+ # A small thread-safe, TTL'd key -> value store for the short-lived grants the
5
+ # provider issues (authorization codes, access tokens, refresh tokens). Entries
6
+ # expire and are pruned lazily on access, plus an opportunistic sweep once the
7
+ # store grows, so even never-redeemed grants don't accumulate without bound. The
8
+ # advertised lifetimes are enforced. Uses a monotonic clock (immune to wall-clock
9
+ # changes).
10
+ class GrantStore
11
+ SWEEP_THRESHOLD = 1000
12
+
13
+ def initialize
14
+ @entries = {}
15
+ @mutex = Mutex.new
16
+ end
17
+
18
+ def put(key, value, ttl:)
19
+ @mutex.synchronize do
20
+ sweep if @entries.size >= SWEEP_THRESHOLD
21
+ @entries[key] = [value, monotonic + ttl]
22
+ end
23
+ value
24
+ end
25
+
26
+ # Read without consuming; nil if missing or expired.
27
+ def get(key)
28
+ @mutex.synchronize { fetch(key) }
29
+ end
30
+
31
+ # Read and remove (single-use); nil if missing or expired.
32
+ def take(key)
33
+ @mutex.synchronize do
34
+ value = fetch(key)
35
+ @entries.delete(key)
36
+ value
37
+ end
38
+ end
39
+
40
+ def size
41
+ @mutex.synchronize { @entries.size }
42
+ end
43
+
44
+ private
45
+
46
+ def fetch(key)
47
+ entry = @entries[key]
48
+ return nil unless entry
49
+
50
+ value, expires_at = entry
51
+ return value if monotonic < expires_at
52
+
53
+ @entries.delete(key)
54
+ nil
55
+ end
56
+
57
+ def sweep
58
+ now = monotonic
59
+ @entries.delete_if { |_, (_, expires_at)| now >= expires_at }
60
+ end
61
+
62
+ def monotonic
63
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Identizer
4
+ module Handlers
5
+ # Auth0-style flow: the code is exchanged for an access_token (no id_token by
6
+ # design — the original integration only verifies a JWT when one is returned
7
+ # and a certificate is configured), then the profile is fetched at /userinfo.
8
+ class Auth0 < Base
9
+ def token(request)
10
+ # The Management API authenticates with a client_credentials grant.
11
+ if merged_params(request)["grant_type"] == "client_credentials"
12
+ return json(200, { access_token: SecureRandom.hex(32), token_type: "Bearer", expires_in: 86_400 })
13
+ end
14
+
15
+ authorization = redeem_code(request) # single-use code, PKCE-checked
16
+ return json(400, { error: "invalid_grant" }) if authorization.nil?
17
+
18
+ # Mint a distinct access_token that /userinfo resolves to the profile.
19
+ access_token = SecureRandom.hex(20)
20
+ access_tokens.put(access_token, authorization, ttl: config.access_token_ttl)
21
+ json(200, { access_token: access_token, token_type: "Bearer", expires_in: config.access_token_ttl })
22
+ end
23
+
24
+ def userinfo(request)
25
+ authorization = access_tokens.get(bearer(request))
26
+ return json(401, { error: "invalid_token" }) if authorization.nil?
27
+
28
+ json(200, authorization.identity.to_h)
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Identizer
4
+ module Handlers
5
+ # Emulates the slice of the Auth0 Management API a brokering app uses to
6
+ # provision/deprovision SSO: creating and deleting applications (clients) and
7
+ # SAML connections. Reached by pointing the Auth0 domain at Identizer; the
8
+ # management bearer token (from the client_credentials grant) is accepted as-is.
9
+ #
10
+ # Created objects are kept in memory so list/delete behave consistently within
11
+ # a running process.
12
+ class Auth0Management < Base
13
+ def initialize(context)
14
+ super
15
+ @clients = {}
16
+ @connections = {}
17
+ @mutex = Mutex.new # the WEBrick server is multithreaded
18
+ end
19
+
20
+ def create_client(request)
21
+ client = parse_json(request).merge(
22
+ "client_id" => SecureRandom.alphanumeric(32),
23
+ "client_secret" => SecureRandom.alphanumeric(64)
24
+ )
25
+ @mutex.synchronize { @clients[client["client_id"]] = client }
26
+ json(201, client)
27
+ end
28
+
29
+ def update_client(request, id)
30
+ body = parse_json(request)
31
+ updated = @mutex.synchronize { @clients[id] = (@clients[id] || { "client_id" => id }).merge(body) }
32
+ json(200, updated)
33
+ end
34
+
35
+ def delete_client(_request, id)
36
+ @mutex.synchronize { @clients.delete(id) }
37
+ no_content
38
+ end
39
+
40
+ def list_clients(_request)
41
+ json(200, @mutex.synchronize { @clients.values })
42
+ end
43
+
44
+ def create_connection(request)
45
+ connection = parse_json(request).merge("id" => "con_#{SecureRandom.alphanumeric(24)}")
46
+ @mutex.synchronize { @connections[connection["id"]] = connection }
47
+ json(201, connection)
48
+ end
49
+
50
+ def update_connection(request, id)
51
+ body = parse_json(request)
52
+ updated = @mutex.synchronize { @connections[id] = (@connections[id] || { "id" => id }).merge(body) }
53
+ json(200, updated)
54
+ end
55
+
56
+ def delete_connection(_request, id)
57
+ @mutex.synchronize { @connections.delete(id) }
58
+ no_content
59
+ end
60
+
61
+ def list_connections(_request)
62
+ json(200, @mutex.synchronize { @connections.values })
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Identizer
4
+ module Handlers
5
+ # Shared base for the protocol handlers. Each handler is constructed with a
6
+ # context carrying the configuration, identity store, token minter and the
7
+ # in-memory session map (opaque code/token -> Identity).
8
+ class Base
9
+ include Responses
10
+
11
+ def initialize(context)
12
+ @context = context
13
+ @config = context.config
14
+ @store = context.store
15
+ @minter = context.minter
16
+ @codes = context.codes
17
+ @refresh_tokens = context.refresh_tokens
18
+ @access_tokens = context.access_tokens
19
+ @renderer = context.renderer
20
+ end
21
+
22
+ private
23
+
24
+ attr_reader :config, :store, :minter, :codes, :refresh_tokens, :access_tokens, :renderer
25
+
26
+ # Render a web-admin page through the shared layout.
27
+ def page(template, request, nav:, title:, **locals)
28
+ html(renderer.render(template, nav: nav, title: title, prefix: request.script_name, **locals))
29
+ end
30
+
31
+ # Render the shared sign-in form (used by the OIDC and SAML login surfaces).
32
+ def render_login(title:, heading:, note:, form_method:, action:, hidden:, config_link:)
33
+ html(renderer.render_bare("login", emails: store.emails, title: title, heading: heading, note: note,
34
+ form_method: form_method, action: action, hidden: hidden,
35
+ config_link: config_link))
36
+ end
37
+
38
+ def consume(code)
39
+ codes.take(code)
40
+ end
41
+
42
+ # Consume a one-time authorization code and enforce PKCE when a challenge
43
+ # was issued — uniformly, so a code can't be redeemed at a different token
44
+ # endpoint to skip the check. Returns the Authorization, or nil if the code
45
+ # is unknown or PKCE verification fails.
46
+ def redeem_code(request)
47
+ authorization = consume(code_param(request))
48
+ return nil if authorization.nil?
49
+ return nil unless authorization.pkce_valid?(request.params["code_verifier"])
50
+
51
+ authorization
52
+ end
53
+
54
+ def code_param(request)
55
+ if json_request?(request)
56
+ parse_json(request)["code"]
57
+ else
58
+ request.params["code"]
59
+ end
60
+ end
61
+
62
+ def bearer(request)
63
+ request.get_header("HTTP_AUTHORIZATION").to_s.sub(/\ABearer\s+/i, "")
64
+ end
65
+
66
+ def parse_json(request)
67
+ raw = request.body.read
68
+ request.body.rewind
69
+ safe_json(raw)
70
+ end
71
+
72
+ def json_request?(request)
73
+ request.content_type.to_s.include?("json")
74
+ end
75
+
76
+ # Form params merged with a JSON body, so handlers work for either encoding.
77
+ def merged_params(request)
78
+ json = json_request?(request) ? parse_json(request) : {}
79
+ request.params.merge(json)
80
+ rescue StandardError
81
+ request.params
82
+ end
83
+
84
+ def safe_json(raw)
85
+ JSON.parse(raw.to_s)
86
+ rescue JSON::ParserError
87
+ {}
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Identizer
4
+ module Handlers
5
+ # Emulates the two AWS Cognito surfaces the original integration depends on:
6
+ # the management API (used at provider-save time via the AWS SDK, reached by
7
+ # pointing COGNITO_ENDPOINT here) and the hosted-UI token endpoint.
8
+ class Cognito < Base
9
+ # Provider-save time. The AWS SDK marks the operation with x-amz-target.
10
+ def management_api(target, request)
11
+ operation = target.split(".").last
12
+ body = parse_json(request)
13
+ name = body["ProviderName"] || body["ClientName"] || "identizer"
14
+
15
+ amz_json(payload_for(operation, name, body))
16
+ end
17
+
18
+ # Cognito hosted-UI code exchange.
19
+ def token(request)
20
+ authorization = redeem_code(request)
21
+ return json(400, { error: "invalid_grant" }) if authorization.nil?
22
+
23
+ id_token = minter.id_token(authorization.identity, audience: authorization.client_id)
24
+ json(200, { id_token: id_token, token_type: "Bearer" })
25
+ end
26
+
27
+ private
28
+
29
+ def payload_for(operation, name, body)
30
+ case operation
31
+ when "CreateUserPoolClient"
32
+ {
33
+ "UserPoolClient" => {
34
+ "ClientId" => SecureRandom.hex(13),
35
+ "ClientSecret" => SecureRandom.hex(32),
36
+ "ClientName" => body["ClientName"],
37
+ "UserPoolId" => body["UserPoolId"]
38
+ }
39
+ }
40
+ when "CreateIdentityProvider"
41
+ { "IdentityProvider" => { "ProviderName" => name, "ProviderType" => body["ProviderType"] } }
42
+ when "ListIdentityProviders"
43
+ { "Providers" => [] }
44
+ else
45
+ {}
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Identizer
4
+ module Handlers
5
+ # CRUD over the LDAP-flavoured user directory. Requires a store exposing the
6
+ # management interface (#entries, #upsert, #delete) — the default does.
7
+ class Directory < Base
8
+ # Reserved/standard names a custom attribute must not set — otherwise it
9
+ # could overwrite a form field or forge a registered token claim.
10
+ BLOCKED_ATTRIBUTES = (
11
+ DirectoryEntry::EDITABLE_ATTRIBUTES +
12
+ %w[iss aud exp iat nbf jti nonce sub email given_name family_name name groups preferred_username dn]
13
+ ).map(&:downcase).freeze
14
+
15
+ def index(request)
16
+ editing = request.params["edit"]
17
+ page("directory/index", request, nav: :directory, title: "Directory",
18
+ entries: store.entries,
19
+ entry: entry_for(editing),
20
+ base_dn: config.ldap_base_dn)
21
+ end
22
+
23
+ def create(request)
24
+ attributes = entry_params(request)
25
+ # On rename (mail changed while editing), drop the old row so we don't
26
+ # leave a duplicate behind.
27
+ original = request.params["original_mail"].to_s
28
+ store.delete(original) if !original.empty? && original != attributes["mail"]
29
+ store.upsert(attributes)
30
+ redirect("#{request.script_name}/directory")
31
+ end
32
+
33
+ def destroy(request)
34
+ store.delete(request.params["mail"])
35
+ redirect("#{request.script_name}/directory")
36
+ end
37
+
38
+ private
39
+
40
+ def entry_for(mail)
41
+ return DirectoryEntry.new(base_dn: config.ldap_base_dn) if mail.to_s.empty?
42
+
43
+ store.entries.find { |entry| entry.mail == mail } ||
44
+ DirectoryEntry.new(base_dn: config.ldap_base_dn)
45
+ end
46
+
47
+ def entry_params(request)
48
+ params = request.params
49
+ {
50
+ "mail" => params["mail"], "uid" => params["uid"],
51
+ "givenName" => params["givenName"], "sn" => params["sn"],
52
+ "cn" => params["cn"], "ou" => params["ou"],
53
+ "memberOf" => split_multi(params["memberOf"])
54
+ }.merge(custom_attributes(params["custom_attributes"]))
55
+ end
56
+
57
+ def split_multi(value)
58
+ value.to_s.split(/[\n,]/).map(&:strip).reject(&:empty?)
59
+ end
60
+
61
+ # Parse the free-form "name = value" (or "name: value") textarea into extra
62
+ # attributes, so any provider-specific claim name can be set from the UI.
63
+ def custom_attributes(text)
64
+ text.to_s.lines.each_with_object({}) do |line, acc|
65
+ key, value = line.split(/[:=]/, 2)
66
+ next if key.nil? || value.nil?
67
+
68
+ name = key.strip
69
+ next if name.empty? || value.strip.empty? || BLOCKED_ATTRIBUTES.include?(name.downcase)
70
+
71
+ acc[name] = value.strip
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end