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,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+
5
+ module Identizer
6
+ module Saml
7
+ # Enveloped XML-DSig signing (exclusive C14N, RSA-SHA256, SHA-256 digest) of a
8
+ # SAML element. The Signature is inserted right after the element's Issuer, as
9
+ # the SAML schema requires. Validated against ruby-saml in the specs.
10
+ class Signer
11
+ DS = "http://www.w3.org/2000/09/xmldsig#"
12
+ EXC_C14N = "http://www.w3.org/2001/10/xml-exc-c14n#"
13
+ ENVELOPED = "http://www.w3.org/2000/09/xmldsig#enveloped-signature"
14
+ SHA256 = "http://www.w3.org/2001/04/xmlenc#sha256"
15
+ RSA_SHA256 = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"
16
+
17
+ def initialize(keypair)
18
+ @keypair = keypair
19
+ end
20
+
21
+ # Signs `element` (a Nokogiri node with an "ID" attribute) in place.
22
+ def sign!(element)
23
+ reference_id = element["ID"]
24
+ # Digest is over the element BEFORE the signature exists (enveloped transform).
25
+ digest_value = base64(OpenSSL::Digest::SHA256.digest(canonicalize(element)))
26
+
27
+ signature = build_signature(element.document, reference_id, digest_value)
28
+ insert_signature(element, signature)
29
+
30
+ # Canonicalize SignedInfo in its FINAL document context, then sign it — the
31
+ # namespace context must match what the verifier sees.
32
+ signed_info = signature.at_xpath("./ds:SignedInfo", "ds" => DS)
33
+ signature_value = base64(@keypair.key.sign(OpenSSL::Digest.new("SHA256"), canonicalize(signed_info)))
34
+ signature.at_xpath("./ds:SignatureValue", "ds" => DS).content = signature_value
35
+
36
+ element
37
+ end
38
+
39
+ private
40
+
41
+ def canonicalize(node)
42
+ node.canonicalize(Nokogiri::XML::XML_C14N_EXCLUSIVE_1_0)
43
+ end
44
+
45
+ def base64(bytes)
46
+ Base64.strict_encode64(bytes)
47
+ end
48
+
49
+ def insert_signature(element, signature)
50
+ issuer = element.at_xpath("./*[local-name()='Issuer']")
51
+ issuer ? issuer.after(signature) : element.prepend_child(signature)
52
+ end
53
+
54
+ def build_signature(document, reference_id, digest_value)
55
+ node = Nokogiri::XML::Node.new("Signature", document)
56
+ node.default_namespace = DS
57
+ node.add_child(signed_info_xml(document, reference_id, digest_value))
58
+ node.add_child(node_with(document, "SignatureValue", ""))
59
+ node.add_child(key_info_xml(document))
60
+ node
61
+ end
62
+
63
+ # Built as compact single-line fragments: any inter-tag whitespace would
64
+ # become text nodes inside the canonicalized SignedInfo and break the digest.
65
+ def signed_info_xml(document, reference_id, digest_value)
66
+ fragment = [
67
+ %(<SignedInfo xmlns="#{DS}">),
68
+ %(<CanonicalizationMethod Algorithm="#{EXC_C14N}"/>),
69
+ %(<SignatureMethod Algorithm="#{RSA_SHA256}"/>),
70
+ %(<Reference URI="##{reference_id}"><Transforms>),
71
+ %(<Transform Algorithm="#{ENVELOPED}"/>),
72
+ %(<Transform Algorithm="#{EXC_C14N}"/></Transforms>),
73
+ %(<DigestMethod Algorithm="#{SHA256}"/>),
74
+ %(<DigestValue>#{digest_value}</DigestValue></Reference></SignedInfo>)
75
+ ].join
76
+ document.parse(fragment).first
77
+ end
78
+
79
+ def key_info_xml(document)
80
+ fragment = [
81
+ %(<KeyInfo xmlns="#{DS}"><X509Data>),
82
+ %(<X509Certificate>#{@keypair.certificate_base64}</X509Certificate>),
83
+ %(</X509Data></KeyInfo>)
84
+ ].join
85
+ document.parse(fragment).first
86
+ end
87
+
88
+ def node_with(document, name, content)
89
+ node = Nokogiri::XML::Node.new(name, document)
90
+ node.default_namespace = DS
91
+ node.content = content
92
+ node
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Optional real SAML IdP support (signed assertions). Required on demand so the
4
+ # nokogiri dependency is only loaded when SAML signing is actually used.
5
+ require "nokogiri"
6
+ require "securerandom"
7
+
8
+ module Identizer
9
+ # A minimal SAML 2.0 identity provider: signed Response/Assertion building.
10
+ module Saml
11
+ end
12
+ end
13
+
14
+ require_relative "saml/keypair"
15
+ require_relative "saml/signer"
16
+ require_relative "saml/encryptor"
17
+ require_relative "saml/response_builder"
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "webrick"
4
+ require "webrick/https"
5
+ require "stringio"
6
+
7
+ module Identizer
8
+ # Runs the Rack App standalone over HTTPS (WEBrick). This is only needed for
9
+ # the standalone/CLI use case — when mounting Identizer inside an existing
10
+ # Rack/Rails app you use App directly and never touch this.
11
+ class Server
12
+ def self.start(config = Identizer.configuration, app: nil)
13
+ new(config, app: app).start
14
+ end
15
+
16
+ def initialize(config = Identizer.configuration, app: nil)
17
+ @config = config
18
+ @app = app || App.new(config)
19
+ end
20
+
21
+ def start
22
+ $stdout.sync = true
23
+ server = mounted_server
24
+ trap("INT") { server.shutdown }
25
+ trap("TERM") { server.shutdown }
26
+ print_banner(@cert_path)
27
+ server.start
28
+ end
29
+
30
+ # Build the WEBrick server with the Rack app mounted, without starting it.
31
+ # Exposed so tests can boot/stop it on an ephemeral port.
32
+ def mounted_server
33
+ cert, key, @cert_path = TLS.resolve(@config)
34
+ server = build_server(cert, key)
35
+ server.mount_proc("/") { |request, response| dispatch(request, response) }
36
+ server
37
+ end
38
+
39
+ private
40
+
41
+ def build_server(cert, key)
42
+ WEBrick::HTTPServer.new(
43
+ Port: @config.port,
44
+ BindAddress: @config.host,
45
+ SSLEnable: true,
46
+ SSLCertificate: cert,
47
+ SSLPrivateKey: key,
48
+ Logger: WEBrick::Log.new($stderr, WEBrick::Log::WARN),
49
+ AccessLog: []
50
+ )
51
+ end
52
+
53
+ def dispatch(request, response)
54
+ status, headers, body = @app.call(rack_env(request))
55
+ log_request(request, status)
56
+ response.status = status
57
+ headers.each { |key, value| response[key] = value }
58
+ response.body = +""
59
+ body.each { |chunk| response.body << chunk }
60
+ ensure
61
+ body.close if body.respond_to?(:close)
62
+ end
63
+
64
+ # A concise request line so you can watch the SSO flow as it happens.
65
+ def log_request(request, status)
66
+ return unless @config.request_logging
67
+
68
+ puts "[identizer] #{Time.now.strftime('%H:%M:%S')} #{request.request_method} #{request.path} -> #{status}"
69
+ end
70
+
71
+ # Translate a WEBrick request into a minimal Rack env.
72
+ def rack_env(request)
73
+ body = request.body.to_s
74
+ env = {
75
+ "REQUEST_METHOD" => request.request_method,
76
+ "SCRIPT_NAME" => "",
77
+ "PATH_INFO" => request.path,
78
+ "QUERY_STRING" => request.query_string.to_s,
79
+ "SERVER_NAME" => (request.host || @config.url_host).to_s,
80
+ "SERVER_PORT" => @config.port.to_s,
81
+ "CONTENT_LENGTH" => body.bytesize.to_s,
82
+ "rack.input" => StringIO.new(body),
83
+ "rack.errors" => $stderr,
84
+ "rack.url_scheme" => "https"
85
+ }
86
+ request.header.each do |key, values|
87
+ env["HTTP_#{key.upcase.tr('-', '_')}"] = values.join(", ")
88
+ end
89
+ env["CONTENT_TYPE"] = request.content_type if request.content_type
90
+ env
91
+ end
92
+
93
+ def print_banner(cert_path)
94
+ base = @config.base_url
95
+ puts <<~BANNER
96
+ ────────────────────────────────────────────────────────────
97
+ 🔑 Identizer is running — a local identity provider for SSO testing
98
+
99
+ Dashboard #{base}/
100
+ (manage users & settings, copy provider values)
101
+ Sign in any directory user · password: "#{@config.shared_password}"
102
+ #{hosts_hint}
103
+
104
+ Point your app's SSO config at:
105
+ OIDC issuer #{base} (discovery at /.well-known/openid-configuration)
106
+ SAML metadata #{base}/metadata · SSO #{base}/saml/sso
107
+ OAuth2 authorize / token / userinfo under #{base}
108
+ Cognito COGNITO_ENDPOINT=#{base}
109
+ #{ldap_banner_line}
110
+ New to SSO? Open the dashboard → Docs → "Getting started".
111
+
112
+ TLS: self-signed cert at #{cert_path}
113
+ trust it for server-to-server calls: export SSL_CERT_FILE=#{cert_path}
114
+ (or use mkcert + --tls-cert/--tls-key). Press Ctrl-C to stop.
115
+ ────────────────────────────────────────────────────────────
116
+ BANNER
117
+ end
118
+
119
+ def hosts_hint
120
+ return "" if @config.url_host == "localhost"
121
+
122
+ " Custom domain → add to /etc/hosts: 127.0.0.1 #{@config.url_host}\n " \
123
+ "(the self-signed cert already covers it)\n"
124
+ end
125
+
126
+ def ldap_banner_line
127
+ host = @config.ldap_host || @config.host
128
+ lines = []
129
+ lines << "LDAP listener: ldap://#{host}:#{@config.ldap_port}" if @config.ldap_port
130
+ lines << "LDAPS listener: ldaps://#{host}:#{@config.ldaps_port}" if @config.ldaps_port
131
+ lines.empty? ? "" : "#{lines.join("\n")}\n"
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Identizer
4
+ # Resolves the TLS material the standalone server listens with. The login URLs
5
+ # must be https (browser popup guards reject http), so we either use a provided
6
+ # (e.g. mkcert-generated, locally-trusted) cert or fall back to a self-signed
7
+ # one written under config_dir, which the app can trust via SSL_CERT_FILE.
8
+ module TLS
9
+ module_function
10
+
11
+ # Returns [OpenSSL::X509::Certificate, OpenSSL::PKey::RSA, cert_path].
12
+ def resolve(config)
13
+ if config.tls_cert_path && config.tls_key_path
14
+ cert = OpenSSL::X509::Certificate.new(File.read(config.tls_cert_path))
15
+ key = OpenSSL::PKey::RSA.new(File.read(config.tls_key_path))
16
+ return [cert, key, config.tls_cert_path]
17
+ end
18
+
19
+ generate_self_signed(config)
20
+ end
21
+
22
+ def generate_self_signed(config)
23
+ key = OpenSSL::PKey::RSA.new(2048)
24
+ host = config.url_host
25
+ name = OpenSSL::X509::Name.parse("/CN=#{host}")
26
+
27
+ cert = OpenSSL::X509::Certificate.new
28
+ cert.version = 2
29
+ cert.serial = 1
30
+ cert.subject = name
31
+ cert.issuer = name
32
+ cert.public_key = key.public_key
33
+ cert.not_before = Time.now - 60
34
+ cert.not_after = Time.now + (365 * 24 * 60 * 60)
35
+
36
+ factory = OpenSSL::X509::ExtensionFactory.new
37
+ factory.subject_certificate = cert
38
+ factory.issuer_certificate = cert
39
+ cert.add_extension(factory.create_extension("basicConstraints", "CA:TRUE", true))
40
+ cert.add_extension(factory.create_extension("subjectAltName", subject_alt_names(host), false))
41
+ cert.sign(key, OpenSSL::Digest.new("SHA256"))
42
+
43
+ FileUtils.mkdir_p(config.config_dir)
44
+ cert_path = File.join(config.config_dir, "cert.pem")
45
+ key_path = File.join(config.config_dir, "key.pem")
46
+ File.write(cert_path, cert.to_pem)
47
+ File.write(key_path, key.to_pem)
48
+ File.chmod(0o600, key_path)
49
+
50
+ [cert, key, cert_path]
51
+ end
52
+
53
+ # Always covers localhost/127.0.0.1, plus a custom advertised host so HTTPS
54
+ # works when the app reaches Identizer by that name (via /etc/hosts).
55
+ def subject_alt_names(host)
56
+ names = ["DNS:localhost", "IP:127.0.0.1"]
57
+ names << "DNS:#{host}" unless host.nil? || host.empty? || host == "localhost"
58
+ names.join(",")
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Identizer
4
+ # Mints id_tokens and serves the OIDC discovery + JWKS documents.
5
+ #
6
+ # Two signing modes:
7
+ # :hs256 (default) — a shared symmetric key. Matches the original emulator's
8
+ # "consumers don't verify" behaviour; simplest for local dev.
9
+ # :rs256 — an RSA keypair (persisted under config_dir) with a published JWKS,
10
+ # so real OIDC clients that DO verify signatures work out of the box.
11
+ class TokenMinter
12
+ def initialize(config)
13
+ @config = config
14
+ end
15
+
16
+ def id_token(identity, nonce: nil, audience: nil)
17
+ payload = payload(identity, nonce: nonce, audience: audience)
18
+ if @config.rs256?
19
+ JWT.encode(payload, rsa_key, "RS256", { kid: jwk.kid })
20
+ else
21
+ JWT.encode(payload, @config.hs256_key, "HS256")
22
+ end
23
+ end
24
+
25
+ def payload(identity, nonce: nil, audience: nil)
26
+ now = Time.now.to_i
27
+ registered = {
28
+ "iss" => @config.issuer,
29
+ # Audience is the requesting client_id when known, so OIDC clients that
30
+ # validate `aud == client_id` accept the token; falls back to a constant.
31
+ "aud" => audience.to_s.empty? ? "identizer" : audience,
32
+ "iat" => now,
33
+ "exp" => now + 3600 # id_token lifetime (intentionally separate from access_token_ttl)
34
+ }
35
+ registered["nonce"] = nonce unless nonce.to_s.empty?
36
+ # Registered claims always win over identity-derived ones, so a directory
37
+ # attribute can never forge iss/aud/exp/iat/nonce.
38
+ identity.to_h.merge(registered)
39
+ end
40
+
41
+ def jwks
42
+ return { "keys" => [] } unless @config.rs256?
43
+
44
+ { "keys" => [jwk.export] }
45
+ end
46
+
47
+ def discovery
48
+ base = @config.base_url
49
+ {
50
+ "issuer" => @config.issuer,
51
+ "authorization_endpoint" => "#{base}/v1/authorize",
52
+ "token_endpoint" => "#{base}/v1/token",
53
+ "userinfo_endpoint" => "#{base}/userinfo",
54
+ "jwks_uri" => "#{base}/.well-known/jwks.json",
55
+ "introspection_endpoint" => "#{base}/introspect",
56
+ "revocation_endpoint" => "#{base}/revoke",
57
+ "end_session_endpoint" => "#{base}/v1/logout",
58
+ "response_types_supported" => ["code"],
59
+ "grant_types_supported" => %w[authorization_code refresh_token],
60
+ "code_challenge_methods_supported" => %w[S256 plain],
61
+ "subject_types_supported" => ["public"],
62
+ "id_token_signing_alg_values_supported" => [@config.rs256? ? "RS256" : "HS256"],
63
+ "scopes_supported" => %w[openid email profile]
64
+ }
65
+ end
66
+
67
+ private
68
+
69
+ def jwk
70
+ @jwk ||= JWT::JWK.new(rsa_key)
71
+ end
72
+
73
+ def rsa_key
74
+ @rsa_key ||= load_or_generate_rsa_key
75
+ end
76
+
77
+ # Persist the signing key so the JWKS stays stable across restarts.
78
+ def load_or_generate_rsa_key
79
+ path = File.join(@config.config_dir, "signing_key.pem")
80
+ return OpenSSL::PKey::RSA.new(File.read(path)) if File.exist?(path)
81
+
82
+ key = OpenSSL::PKey::RSA.new(2048)
83
+ FileUtils.mkdir_p(@config.config_dir)
84
+ File.write(path, key.to_pem)
85
+ File.chmod(0o600, path)
86
+ key
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Identizer
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,69 @@
1
+ <% editing = !entry.mail.to_s.empty? %>
2
+ <h1>Directory</h1>
3
+ <p class="lead">An LDAP-flavoured user directory. Entries are projected onto OIDC claims at login
4
+ (<span class="mono">givenName→given_name</span>, <span class="mono">sn→family_name</span>,
5
+ <span class="mono">memberOf→groups</span>). Base DN <span class="mono"><%= h(base_dn) %></span>.</p>
6
+
7
+ <div class="card">
8
+ <table>
9
+ <thead>
10
+ <tr><th>DN</th><th>mail</th><th>cn</th><th>groups</th><th></th></tr>
11
+ </thead>
12
+ <tbody>
13
+ <% if entries.empty? %>
14
+ <tr><td colspan="5" class="muted">No entries yet — add one below.</td></tr>
15
+ <% end %>
16
+ <% entries.each do |item| %>
17
+ <tr>
18
+ <td class="mono"><%= h(item.dn) %></td>
19
+ <td class="mono"><%= h(item.mail) %></td>
20
+ <td><%= h(item["cn"]) %></td>
21
+ <td><% item.groups.each do |group| %><span class="tag"><%= h(group) %></span><% end %></td>
22
+ <td>
23
+ <div class="actions">
24
+ <a class="btn" href="<%= prefix %>/directory?edit=<%= h(item.mail) %>">Edit</a>
25
+ <form method="post" action="<%= prefix %>/directory/delete"
26
+ onsubmit="return confirm('Delete <%= h(item.mail) %>?')">
27
+ <input type="hidden" name="mail" value="<%= h(item.mail) %>">
28
+ <button class="danger" type="submit">Delete</button>
29
+ </form>
30
+ </div>
31
+ </td>
32
+ </tr>
33
+ <% end %>
34
+ </tbody>
35
+ </table>
36
+ </div>
37
+
38
+ <h2><%= editing ? "Edit entry" : "Add entry" %></h2>
39
+ <form class="card" method="post" action="<%= prefix %>/directory">
40
+ <% if editing %><input type="hidden" name="original_mail" value="<%= h(entry.mail) %>"><% end %>
41
+ <div class="row">
42
+ <div><label>mail (login email)</label>
43
+ <input type="email" name="mail" required value="<%= h(editing ? entry.mail : "") %>"></div>
44
+ <div><label>uid</label>
45
+ <input type="text" name="uid" placeholder="auto from mail" value="<%= h(editing ? entry["uid"] : "") %>"></div>
46
+ </div>
47
+ <div class="row">
48
+ <div><label>givenName</label>
49
+ <input type="text" name="givenName" value="<%= h(editing ? entry["givenName"] : "") %>"></div>
50
+ <div><label>sn (surname)</label>
51
+ <input type="text" name="sn" value="<%= h(editing ? entry["sn"] : "") %>"></div>
52
+ </div>
53
+ <div class="row">
54
+ <div><label>cn (common name)</label>
55
+ <input type="text" name="cn" placeholder="auto from name" value="<%= h(editing ? entry["cn"] : "") %>"></div>
56
+ <div><label>ou (org unit)</label>
57
+ <input type="text" name="ou" placeholder="people" value="<%= h(editing ? entry["ou"] : "") %>"></div>
58
+ </div>
59
+ <label>memberOf / groups (one per line)</label>
60
+ <textarea name="memberOf"><%= h(editing ? entry.groups.join("\n") : "") %></textarea>
61
+ <label>Custom attributes (one per line, <span class="mono">name = value</span>)</label>
62
+ <textarea name="custom_attributes" placeholder="custom_1 = 12345&#10;department = Engineering"><%=
63
+ h(editing ? entry.custom_attributes.map { |name, value| "#{name} = #{Array(value).join(', ')}" }.join("\n") : "")
64
+ %></textarea>
65
+ <p>
66
+ <button class="primary" type="submit"><%= editing ? "Save entry" : "Add entry" %></button>
67
+ <% if editing %><a class="btn" href="<%= prefix %>/directory">Cancel</a><% end %>
68
+ </p>
69
+ </form>
@@ -0,0 +1,67 @@
1
+ <p class="muted"><a href="<%= prefix %>/docs">&larr; Docs</a></p>
2
+ <article>
3
+ <h1>Cheatsheet: Cognito-brokered app</h1>
4
+ <p class="lead">A common pattern: your app federates external IdPs through an AWS Cognito user pool, where each
5
+ tenant's provider is either OIDC or SAML. Identizer stands in for Cognito (provider-save) and for the
6
+ upstream IdP (login). Here are the exact values to put in each provider type.</p>
7
+
8
+ <h2>0. Point the broker at Identizer</h2>
9
+ <pre>COGNITO_ENDPOINT=<%= h(config.base_url) %> # provider-save: management API is stubbed</pre>
10
+ <p>Add a user on the <a href="<%= prefix %>/directory">Directory</a> page; the password is the shared
11
+ password from <a href="<%= prefix %>/settings">Settings</a>.</p>
12
+
13
+ <h2>OIDC provider</h2>
14
+ <table>
15
+ <tr><th>OIDC issuer URL</th><td class="mono"><%= h(config.base_url) %></td></tr>
16
+ <tr><th>Client ID</th><td class="mono">dev-client (any value)</td></tr>
17
+ <tr><th>Client secret</th><td class="mono">dev-secret (any value)</td></tr>
18
+ <tr><th>Authorize scopes</th><td class="mono">openid</td></tr>
19
+ <tr><th>Attribute request method</th><td class="mono">GET</td></tr>
20
+ <tr><th>Email / Given name / Family name attribute</th><td class="mono">email / given_name / family_name</td></tr>
21
+ </table>
22
+ <p class="muted">Endpoints are auto-discovered from <span class="mono"><%= h(config.base_url) %>/.well-known/openid-configuration</span>,
23
+ so the exact paths don't matter to the client.</p>
24
+
25
+ <h2>SAML provider</h2>
26
+ <table>
27
+ <tr><th>Metadata URL</th><td class="mono"><%= h(config.base_url) %>/metadata</td></tr>
28
+ <tr><th>SSO URL</th><td class="mono"><%= h(config.base_url) %>/saml/sso</td></tr>
29
+ <tr><th>NameID</th><td class="mono">emailAddress</td></tr>
30
+ <tr><th>Email / Given name / Family name attribute</th><td class="mono">email / given_name / family_name</td></tr>
31
+ </table>
32
+ <p class="muted">Or download the metadata from the link on <a href="<%= prefix %>/metadata?download=1">/metadata</a>
33
+ to test a "metadata file" upload field.</p>
34
+
35
+ <h2>SAML brokered via Auth0 (encryption flow)</h2>
36
+ <p>For providers that exchange the code at <span class="mono">/oauth/token</span> and read the profile from
37
+ <span class="mono">/userinfo</span>, set the Auth0 domain to Identizer:</p>
38
+ <pre>SSO_AUTH0_DOMAIN=<%= h(config.base_url.sub(%r{\Ahttps?://}, "")) %> # bare host, no scheme</pre>
39
+ <p>Identizer also emulates the <strong>Auth0 Management API</strong> at that domain — the
40
+ <span class="mono">client_credentials</span> token grant plus <span class="mono">/api/v2/clients</span> and
41
+ <span class="mono">/api/v2/connections</span> (create / list / update / delete). So your app can provision
42
+ the application + SAML connection on provider save and tear them down on remove, exactly like real Auth0
43
+ (returns generated <span class="mono">client_id</span>/<span class="mono">client_secret</span> and a
44
+ <span class="mono">con_…</span> connection id).</p>
45
+
46
+ <h2>Matching attribute names</h2>
47
+ <p>The broker maps each provider's attributes onto your app's fields (e.g. <span class="mono">email</span>,
48
+ <span class="mono">given_name</span>, <span class="mono">family_name</span>, or custom ones). Identizer emits
49
+ attributes/claims named after the directory entry's attributes:</p>
50
+ <ul>
51
+ <li><span class="mono">mail → email</span>, <span class="mono">givenName → given_name</span>,
52
+ <span class="mono">sn → family_name</span> (set these on the Directory page).</li>
53
+ <li>For any extra/custom attribute name a provider would send (e.g. <span class="mono">custom_1</span>),
54
+ add it in the <a href="<%= prefix %>/directory">Directory</a> entry's
55
+ <em>Custom attributes</em> field (<span class="mono">name = value</span>) or via
56
+ <span class="mono">config.seed_identities</span> — Identizer passes it through under that exact name
57
+ in both OIDC claims and SAML attributes.</li>
58
+ </ul>
59
+
60
+ <pre>Identizer.configure do |c|
61
+ c.seed_identities = [
62
+ # standard + any custom attributes as plain keys; they pass straight through
63
+ { mail: "alice@example.com", givenName: "Alice", sn: "Doe",
64
+ custom_1: "12345", department: "eng" }
65
+ ]
66
+ end</pre>
67
+ </article>
@@ -0,0 +1,22 @@
1
+ <p class="muted"><a href="<%= prefix %>/docs">&larr; Docs</a></p>
2
+ <article>
3
+ <h1>AWS Cognito broker</h1>
4
+ <p class="lead">Identizer emulates the two Cognito surfaces an app's SSO integration depends on, so you can
5
+ configure providers and log in without a real user pool.</p>
6
+
7
+ <h2>1. Management API (provider-save time)</h2>
8
+ <p>Point the AWS SDK at Identizer and the management calls (<span class="mono">CreateUserPoolClient</span>,
9
+ <span class="mono">CreateIdentityProvider</span>, …) are stubbed:</p>
10
+ <pre>COGNITO_ENDPOINT=<%= h(config.base_url) %></pre>
11
+ <p>Your "Add provider" UI now succeeds offline, returning fake client ids/secrets.</p>
12
+
13
+ <h2>2. Hosted UI (login time)</h2>
14
+ <pre>Login <%= h(config.base_url) %>/login
15
+ Token <%= h(config.base_url) %>/oauth2/token</pre>
16
+ <p>Set your Cognito hosted-UI domain to <span class="mono"><%= h(config.base_url) %></span>. The token
17
+ endpoint returns an <span class="mono">id_token</span> for the signed-in directory entry.</p>
18
+
19
+ <h2>Trust the TLS cert</h2>
20
+ <p>The AWS SDK makes server-to-server calls, so it must trust Identizer's certificate. See
21
+ <a href="<%= prefix %>/docs/tls">TLS &amp; mkcert</a>.</p>
22
+ </article>
@@ -0,0 +1,28 @@
1
+ <p class="muted"><a href="<%= prefix %>/docs">&larr; Docs</a></p>
2
+ <article>
3
+ <h1>Getting started</h1>
4
+ <p class="lead">Identizer is a local identity provider. Point your app's SSO/OIDC config at it
5
+ instead of a real tenant, sign in as a directory entry, and develop the whole flow offline.</p>
6
+
7
+ <h2>1. Run it</h2>
8
+ <pre>bundle exec identizer --port 9999
9
+ # dashboard: <%= h(config.base_url) %>/</pre>
10
+
11
+ <h2>2. Add users</h2>
12
+ <p>A demo user (<span class="mono">demo@example.com</span>) is seeded on first run, so login works right
13
+ away. Add more on the <a href="<%= prefix %>/directory">Directory</a> page (at minimum a
14
+ <span class="mono">mail</span>). The password for every entry is the shared password from
15
+ <a href="<%= prefix %>/settings">Settings</a> (default <code>password</code>).</p>
16
+
17
+ <h2>3. Point your app at it</h2>
18
+ <p>Use the values from the <a href="<%= prefix %>/">Overview</a> cheatsheet for your protocol —
19
+ issuer/authorize/token URLs all live under <span class="mono"><%= h(config.base_url) %></span>.</p>
20
+
21
+ <h2>4. Sign in</h2>
22
+ <p>Trigger your app's login. Identizer shows a sign-in form; enter a configured email + the shared
23
+ password. A wrong password or unknown email exercises the provider's error path.</p>
24
+
25
+ <h2>Mounted vs standalone</h2>
26
+ <p>Run standalone via the CLI, or mount the Rack app inside your own stack —
27
+ <span class="mono">mount Identizer::App.new =&gt; "/idp"</span>. See <a href="<%= prefix %>/docs/oidc">OIDC</a>.</p>
28
+ </article>
@@ -0,0 +1,9 @@
1
+ <h1>Docs</h1>
2
+ <p class="lead">Bundled guides for wiring your app to Identizer.</p>
3
+ <div class="card">
4
+ <ul>
5
+ <% pages.each do |page| %>
6
+ <li><a href="<%= prefix %>/docs/<%= page[:slug] %>"><%= h(page[:title]) %></a></li>
7
+ <% end %>
8
+ </ul>
9
+ </div>
@@ -0,0 +1,38 @@
1
+ <p class="muted"><a href="<%= prefix %>/docs">&larr; Docs</a></p>
2
+ <article>
3
+ <h1>LDAP listener</h1>
4
+ <p class="lead">Identizer can also expose the directory over LDAP, so apps that authenticate via
5
+ <span class="mono">ldap://</span> (simple bind + search) can test against it — the same users you
6
+ manage in the <a href="<%= prefix %>/directory">Directory</a>.</p>
7
+
8
+ <h2>Enable it</h2>
9
+ <pre>bundle exec identizer --port 9999 --ldap-port 1389</pre>
10
+ <p>The listener is off unless <span class="mono">--ldap-port</span> (or
11
+ <span class="mono">IDENTIZER_LDAP_PORT</span>) is set.</p>
12
+
13
+ <h2>Bind</h2>
14
+ <p>Simple bind with a user's DN and the shared password:</p>
15
+ <pre>uid=alice,ou=people,<%= h(config.ldap_base_dn) %> / password = the shared sign-in password</pre>
16
+ <p>An empty DN + empty password is accepted as an anonymous bind.</p>
17
+
18
+ <h2>Search</h2>
19
+ <p>Subtree search under the base DN <span class="mono"><%= h(config.ldap_base_dn) %></span>. Supported
20
+ filters: equality (<span class="mono">(mail=a@b.com)</span>), presence
21
+ (<span class="mono">(objectClass=*)</span>), substrings, and <span class="mono">&amp;</span> /
22
+ <span class="mono">|</span> / <span class="mono">!</span> combinations.</p>
23
+ <p>Entries are projected to standard attributes: <span class="mono">uid, cn, sn, givenName, mail, ou,
24
+ memberOf, objectClass</span>.</p>
25
+
26
+ <h2>LDAPS (TLS)</h2>
27
+ <pre>bundle exec identizer --port 9999 --ldaps-port 1636</pre>
28
+ <p>Adds an implicit-TLS listener reusing the same certificate as the HTTPS server (see
29
+ <a href="<%= prefix %>/docs/tls">TLS &amp; mkcert</a>). You can run plain LDAP and LDAPS together.</p>
30
+
31
+ <h2>StartTLS</h2>
32
+ <p>Clients that upgrade a plain LDAP connection with StartTLS are supported on the plain
33
+ <span class="mono">--ldap-port</span> listener — Identizer acknowledges the extended operation and upgrades
34
+ the socket to TLS (reusing the same certificate).</p>
35
+
36
+ <h2>Scope</h2>
37
+ <p>A development listener (simple bind). Not intended for production directories.</p>
38
+ </article>