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,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,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 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">← 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">← 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 & mkcert</a>.</p>
|
|
22
|
+
</article>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<p class="muted"><a href="<%= prefix %>/docs">← 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 => "/idp"</span>. See <a href="<%= prefix %>/docs/oidc">OIDC</a>.</p>
|
|
28
|
+
</article>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<p class="muted"><a href="<%= prefix %>/docs">← 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">&</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 & 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>
|