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