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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 76e9316e637b4f310e3bd6a3de54774b7fd22ca365ae04942b0aae0196a218ad
4
+ data.tar.gz: 2c99082c990f28cabab4e57139dca3659839df1a3b6297a6f1aa3c42a175ec1d
5
+ SHA512:
6
+ metadata.gz: 71bc913525b42bc3ddad2762cc5dc6f869a694c26cbfb45830a9eb74923631a158b49eba54c95e7ed6e2c26f6507d6cc136af3539f73669357174b7a8c1823e4
7
+ data.tar.gz: 31fbca1b65dc8b02de55d4adf1acac07c12f4e59db1a712bdae0bac0da2e3c1b8f38d534eb2d5cb398e891cd49dbc08d192b817a7b75616e5c384ff4d4718f3d
data/CHANGELOG.md ADDED
@@ -0,0 +1,48 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format is based on
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres
5
+ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.1.0] - 2026-06-27
10
+
11
+ First release. A local identity provider for developing and testing auth/SSO
12
+ integrations, extracted and decoupled from the tap-v3 SSO emulator.
13
+
14
+ ### OIDC / OAuth2
15
+ - Authorization-code flow with PKCE (S256/plain), refresh-token grant (rotated,
16
+ single-use), RP-initiated logout, `nonce` in the id_token, `scope` echo.
17
+ - Token introspection (RFC 7662) and revocation (RFC 7009, revokes the paired
18
+ token). OIDC discovery + JWKS; HS256 (default) and RS256 signing.
19
+ - AWS Cognito hosted-UI + management-API emulation, Auth0 login + Management API
20
+ (`/api/v2/clients`, `/api/v2/connections`) for app provisioning/deprovisioning.
21
+ - Okta-style `/oauth2/v1/*` aliases for fixed-path clients; `aud` is the client_id.
22
+ - Optional client registry enabling `redirect_uri` / post-logout allowlists.
23
+
24
+ ### SAML 2.0
25
+ - Real IdP: signed Response + Assertion (XML-DSig / RSA-SHA256), optional
26
+ EncryptedAssertion (AES-256-CBC + RSA-OAEP), metadata, SP- and IdP-initiated SSO.
27
+ - Attribute names default to Microsoft/WS-Fed claim URIs; fully configurable
28
+ (`saml_attribute_names`). Optional ACS allowlist; deflate-bomb guards.
29
+
30
+ ### LDAP
31
+ - Simple-bind authentication and subtree search (equality / presence / substring /
32
+ `&` `|` `!`) over the same directory; implicit-TLS LDAPS and StartTLS.
33
+
34
+ ### Directory & storage
35
+ - LDAP-flavoured user directory (`DirectoryEntry`) projected onto OIDC claims,
36
+ with arbitrary custom attributes. Pluggable identity store (default JSON
37
+ `ConfigStore`; optional `SqliteStore`).
38
+
39
+ ### Operability & security
40
+ - Standalone HTTPS server + `identizer` CLI; mountable, `SCRIPT_NAME`-aware Rack
41
+ app. Seeded demo user, quick-start banner, request logging, `/healthz`, custom
42
+ domain (`--domain`).
43
+ - Thread-safe, TTL-enforced grant store; private keys written `0600`; uniform PKCE
44
+ enforcement; registered JWT claims protected from directory-attribute forging.
45
+ - `nokogiri` (SAML) and `net-ldap` (LDAP) load lazily; `sqlite3` is optional.
46
+
47
+ [Unreleased]: https://github.com/alex-andreiev/identizer/compare/v0.1.0...HEAD
48
+ [0.1.0]: https://github.com/alex-andreiev/identizer/releases/tag/v0.1.0
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Alex Andreiev
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,218 @@
1
+ # Identizer
2
+
3
+ [![CI](https://github.com/alex-andreiev/identizer/actions/workflows/ci.yml/badge.svg)](https://github.com/alex-andreiev/identizer/actions/workflows/ci.yml)
4
+
5
+ A local identity provider for developing and testing auth/SSO integrations.
6
+
7
+ **The problem it solves:** to test "Sign in with SSO", you normally need a real
8
+ Okta/Auth0/Azure/Cognito tenant, real metadata, real certificates. That's slow to
9
+ set up and impossible to script in CI. Identizer is a fake-but-real IdP you run
10
+ locally: point your app at it, sign in as a test user, done. No accounts, no cloud.
11
+
12
+ ## Quick start (60 seconds)
13
+
14
+ ```sh
15
+ gem install identizer
16
+ identizer # boots on https://localhost:9999
17
+ ```
18
+
19
+ It prints exactly where to point your app. Open the dashboard at
20
+ `https://localhost:9999/` — a demo user (`demo@example.com`, password `password`)
21
+ is already there, so login works immediately. Then in your app's SSO settings:
22
+
23
+ - **OIDC / OpenID Connect** → issuer `https://localhost:9999` (the client reads
24
+ everything else from `/.well-known/openid-configuration`).
25
+ - **SAML** → metadata `https://localhost:9999/metadata`.
26
+ - **OAuth2 / Auth0** → domain `localhost:9999`.
27
+
28
+ Trigger login in your app, sign in as the demo user, and you're testing the real
29
+ flow. (For browser/server TLS trust, see [TLS](#tls) — one `SSL_CERT_FILE` line.)
30
+
31
+ ## Which protocol do I need?
32
+
33
+ If you're not sure how your app talks to its IdP, match the setting it asks for:
34
+
35
+ | Your app's SSO config mentions… | Use | Point it at |
36
+ |---|---|---|
37
+ | "Issuer URL", "discovery", "client ID/secret", `openid` | **OIDC** | `https://localhost:9999` |
38
+ | "Metadata URL/XML", "ACS", "SAML", "certificate" | **SAML** | `https://localhost:9999/metadata` |
39
+ | "Auth0 domain", `/authorize` + `/userinfo` | **OAuth2/Auth0** | `localhost:9999` |
40
+ | "Cognito", "user pool", `COGNITO_ENDPOINT` | **Cognito** | `COGNITO_ENDPOINT=https://localhost:9999` |
41
+ | "LDAP bind", `ldap://` | **LDAP** | `identizer --ldap-port 1389` |
42
+
43
+ ## How it works (two halves)
44
+
45
+ - a **directory** of sign-in identities (the pluggable "users" store), and
46
+ - a **provider** that accepts auth requests, signs the user in, and hands the
47
+ profile back over whichever protocol your app expects (OIDC, OAuth2, SAML, …).
48
+
49
+ ## Why not an existing tool?
50
+
51
+ Generic OIDC mocks (e.g. `mock-oauth2-server`) are JVM/Docker, and SAML-only Ruby
52
+ gems (e.g. `saml_idp`) cover one protocol. Identizer is a single, zero-infra Ruby
53
+ gem that covers OIDC + OAuth2 + a Cognito/Auth0 **broker** with a pluggable user
54
+ directory — installable, mountable, and scriptable.
55
+
56
+ ## Install
57
+
58
+ ```ruby
59
+ # Gemfile (development/test)
60
+ gem "identizer", group: %i[development test]
61
+ ```
62
+
63
+ ```sh
64
+ bundle install
65
+ ```
66
+
67
+ ## Run standalone
68
+
69
+ ```sh
70
+ bundle exec identizer --port 9999
71
+ # open https://localhost:9999/ (dashboard: identities + provider cheatsheet)
72
+ ```
73
+
74
+ Common flags: `--port`, `--host`, `--url-host`, `--config-dir`, `--tls-cert`,
75
+ `--tls-key`, `--password`, `--rs256`. Anything not passed falls back to env vars
76
+ (`IDENTIZER_PORT`, `IDENTIZER_TLS_CERT/KEY`, `IDENTIZER_CONFIG_DIR`, …).
77
+
78
+ ## Mount inside a Rack/Rails app
79
+
80
+ `Identizer::App` is a plain Rack app, so it works mounted at any path — internal
81
+ links honour `SCRIPT_NAME`.
82
+
83
+ ```ruby
84
+ # config.ru
85
+ require "identizer"
86
+ run Identizer.app
87
+ ```
88
+
89
+ ```ruby
90
+ # Rails config/routes.rb (development only)
91
+ mount Identizer::App.new => "/idp" if Rails.env.development?
92
+ ```
93
+
94
+ ```ruby
95
+ # RSpec / rack-test
96
+ require "rack/test"
97
+
98
+ app = Identizer::App.new(
99
+ Identizer::Configuration.new.tap do |c|
100
+ c.config_dir = Dir.mktmpdir
101
+ c.seed_identities = [{ email: "alice@example.com", claims: { given_name: "Alice" } }]
102
+ end
103
+ )
104
+ ```
105
+
106
+ ## Configure
107
+
108
+ ```ruby
109
+ Identizer.configure do |config|
110
+ config.port = 9999
111
+ config.shared_password = "password" # type this to succeed; anything else exercises the error path
112
+ config.signing = :rs256 # :hs256 (default) or :rs256 + JWKS for clients that verify
113
+ config.seed_identities = [
114
+ { email: "alice@example.com", claims: { given_name: "Alice", family_name: "Doe" } }
115
+ ]
116
+ # config.identity_store = MyDbBackedStore.new # plug in any object exposing #emails / #identity_for(email)
117
+ end
118
+ ```
119
+
120
+ Other options (all have sane defaults): `code_ttl` / `access_token_ttl` /
121
+ `refresh_token_ttl` (grant lifetimes), `clients` (registry that enables
122
+ `redirect_uri` allowlisting), `saml_sign_response`, `saml_encrypt_assertion` +
123
+ `saml_sp_certificate`, `saml_allowed_acs`, `saml_attribute_names`, `ldap_port` /
124
+ `ldaps_port`, and `request_logging`. See `lib/identizer/configuration.rb`.
125
+
126
+ ### Identity store interface
127
+
128
+ Any object responding to this duck-typed interface can be a directory:
129
+
130
+ ```
131
+ #emails -> Array<String> addresses the login form accepts
132
+ #identity_for(email) -> Identity | nil resolve an address to an Identity
133
+ ```
134
+
135
+ For full management through the web admin, a store also exposes the directory
136
+ interface `#entries`, `#upsert(attrs)` and `#delete(email)` (the bundled
137
+ `ConfigStore` and `SqliteStore` do).
138
+
139
+ The default `Identizer::IdentityStore::ConfigStore` persists identities to a JSON
140
+ file the dashboard writes, seeded from `config.seed_identities`.
141
+
142
+ ### SQLite backend (optional)
143
+
144
+ Prefer a database? Add `gem "sqlite3"` to your Gemfile and use the bundled adapter
145
+ — it implements the same directory interface, so the web admin and LDAP work
146
+ against it unchanged:
147
+
148
+ ```sh
149
+ bundle exec identizer --sqlite ./dev.sqlite3
150
+ ```
151
+
152
+ ```ruby
153
+ require "identizer/identity_store/sqlite_store"
154
+ config.identity_store = Identizer::IdentityStore::SqliteStore.new(path: "dev.sqlite3")
155
+ ```
156
+
157
+ `sqlite3` is not a default dependency — JSON files remain the zero-infra default.
158
+
159
+ ## Endpoints
160
+
161
+ Most clients only need the issuer/metadata URL; the rest is discovered. Okta-style
162
+ `/oauth2/v1/*` aliases exist for the OIDC routes (authorize/token/userinfo/keys).
163
+
164
+ | Purpose | Route |
165
+ |---|---|
166
+ | Dashboard / config | `GET /` |
167
+ | Health | `GET /healthz` |
168
+ | Login form | `GET /login`, `/authorize`, `/v1/authorize` |
169
+ | Cognito hosted-UI token | `POST /oauth2/token` |
170
+ | Auth0 token + profile | `POST /oauth/token`, `GET /userinfo` |
171
+ | OIDC token / logout | `POST /v1/token`, `GET /v1/logout` |
172
+ | OIDC introspection / revocation | `POST /introspect`, `POST /revoke` |
173
+ | OIDC discovery / JWKS | `GET /.well-known/openid-configuration`, `/.well-known/jwks.json` |
174
+ | SAML metadata / SSO | `GET /metadata`, `GET|POST /saml/sso` |
175
+ | Cognito management API | `POST /` with `x-amz-target` (point `COGNITO_ENDPOINT` here) |
176
+ | Auth0 Management API | `POST/DELETE /api/v2/clients`, `/api/v2/connections` |
177
+
178
+ ## LDAP listener (optional)
179
+
180
+ Apps that authenticate via LDAP can bind and search against the same directory.
181
+ It's off unless you ask for it:
182
+
183
+ ```sh
184
+ bundle exec identizer --port 9999 --ldap-port 1389
185
+ # ldapsearch -x -H ldap://localhost:1389 -b dc=identizer,dc=local "(mail=alice@example.com)"
186
+ ```
187
+
188
+ Simple bind (user DN + shared password, or anonymous) and subtree search with
189
+ equality / presence / substring / `&` `|` `!` filters. Entries project to
190
+ `uid, cn, sn, givenName, mail, ou, memberOf, objectClass`. Plain TCP + simple
191
+ bind — a development listener, not LDAPS.
192
+
193
+ ## TLS
194
+
195
+ Login URLs must be `https` (browser popup guards reject `http`). Identizer uses a
196
+ provided cert (`--tls-cert/--tls-key`, ideally [mkcert](https://github.com/FiloSottile/mkcert)-generated
197
+ and locally trusted) or falls back to a self-signed cert written under
198
+ `config_dir`. For the app's server-to-server calls, trust it via
199
+ `export SSL_CERT_FILE=…/cert.pem`.
200
+
201
+ ## SAML 2.0
202
+
203
+ A real SAML IdP: it issues **signed** assertions (XML-DSig, RSA-SHA256) verifiable
204
+ by standard SPs. Metadata at `/metadata`, SSO at `/saml/sso` (Redirect & POST
205
+ bindings), SP- and IdP-initiated. Signing uses `nokogiri`, loaded only when a
206
+ Response is produced. A development IdP — convenient, not hardened.
207
+
208
+ ## Development
209
+
210
+ ```sh
211
+ bin/setup # install dependencies
212
+ bundle exec rake # rspec + rubocop
213
+ bin/console # an IRB session with identizer loaded
214
+ ```
215
+
216
+ ## License
217
+
218
+ MIT.
data/exe/identizer ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "identizer"
5
+ require "identizer/cli"
6
+
7
+ Identizer::CLI.start(ARGV)
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Identizer
4
+ # The Rack application. Mount it in a test harness or run it standalone via
5
+ # Identizer::Server. It serves three surfaces: the web admin (Overview /
6
+ # Directory / Settings / Docs), the runtime IdP endpoints, and the AWS Cognito
7
+ # management API (requests carrying x-amz-target).
8
+ class App
9
+ include Responses
10
+
11
+ Context = Struct.new(:config, :store, :minter, :codes, :refresh_tokens, :access_tokens, :renderer)
12
+
13
+ def initialize(config = Identizer.configuration)
14
+ @config = config
15
+ context = Context.new(config, config.identity_store, TokenMinter.new(config),
16
+ GrantStore.new, GrantStore.new, GrantStore.new, Renderer.new)
17
+ @overview = Handlers::Overview.new(context)
18
+ @directory = Handlers::Directory.new(context)
19
+ @settings = Handlers::Settings.new(context)
20
+ @docs = Handlers::Docs.new(context)
21
+ @login = Handlers::Login.new(context)
22
+ @cognito = Handlers::Cognito.new(context)
23
+ @auth0 = Handlers::Auth0.new(context)
24
+ @auth0_management = Handlers::Auth0Management.new(context)
25
+ @oidc = Handlers::Oidc.new(context)
26
+ @saml = Handlers::Saml.new(context)
27
+ end
28
+
29
+ def call(env)
30
+ request = Rack::Request.new(env)
31
+ target = env["HTTP_X_AMZ_TARGET"]
32
+
33
+ if target
34
+ @cognito.management_api(target, request)
35
+ else
36
+ route(request)
37
+ end
38
+ rescue StandardError => e
39
+ # Surface the failure to the console (this is a local dev tool) instead of
40
+ # silently swallowing it; still return a JSON 500 to the client.
41
+ env["rack.errors"]&.puts("[identizer] #{e.class}: #{e.message}\n #{e.backtrace&.first(8)&.join("\n ")}")
42
+ json(500, { error: e.message })
43
+ end
44
+
45
+ private
46
+
47
+ def route(request)
48
+ admin(request) || idp(request) || auth0_management(request) ||
49
+ not_found("No route for #{request.request_method} #{request.path_info}")
50
+ end
51
+
52
+ # Auth0 Management API: provision/deprovision applications and connections.
53
+ def auth0_management(request)
54
+ case [request.request_method, request.path_info]
55
+ in ["POST", "/api/v2/clients"] then @auth0_management.create_client(request)
56
+ in ["GET", "/api/v2/clients"] then @auth0_management.list_clients(request)
57
+ in ["POST", "/api/v2/connections"] then @auth0_management.create_connection(request)
58
+ in ["GET", "/api/v2/connections"] then @auth0_management.list_connections(request)
59
+ in ["PATCH", String => path] if path.start_with?("/api/v2/clients/")
60
+ @auth0_management.update_client(request, path.delete_prefix("/api/v2/clients/"))
61
+ in ["DELETE", String => path] if path.start_with?("/api/v2/clients/")
62
+ @auth0_management.delete_client(request, path.delete_prefix("/api/v2/clients/"))
63
+ in ["PATCH", String => path] if path.start_with?("/api/v2/connections/")
64
+ @auth0_management.update_connection(request, path.delete_prefix("/api/v2/connections/"))
65
+ in ["DELETE", String => path] if path.start_with?("/api/v2/connections/")
66
+ @auth0_management.delete_connection(request, path.delete_prefix("/api/v2/connections/"))
67
+ else nil
68
+ end
69
+ end
70
+
71
+ # Web admin surface.
72
+ def admin(request)
73
+ case [request.request_method, request.path_info]
74
+ in ["GET", "/healthz"] then json(200, { status: "ok", name: "identizer", version: Identizer::VERSION })
75
+ in ["GET", "/"] then @overview.index(request)
76
+ in ["GET", "/directory"] then @directory.index(request)
77
+ in ["POST", "/directory"] then @directory.create(request)
78
+ in ["POST", "/directory/delete"] then @directory.destroy(request)
79
+ in ["GET", "/settings"] then @settings.show(request)
80
+ in ["POST", "/settings"] then @settings.update(request)
81
+ in ["GET", "/docs"] then @docs.index(request)
82
+ in ["GET", String => path] if path.start_with?("/docs/")
83
+ @docs.show(request, path.delete_prefix("/docs/"))
84
+ else nil
85
+ end
86
+ end
87
+
88
+ # Runtime IdP + protocol surface.
89
+ def idp(request)
90
+ case [request.request_method, request.path_info]
91
+ in ["GET", "/metadata" | "/saml/metadata"] then @saml.metadata(request)
92
+ in ["GET" | "POST", "/saml/sso"] then @saml.sso(request)
93
+ in ["POST", "/saml/finish"] then @saml.finish(request)
94
+ # Includes the Okta-style /oauth2/v1/* paths (omniauth-okta and other
95
+ # fixed-path OAuth2 clients) alongside the canonical ones.
96
+ in ["GET", "/login" | "/authorize" | "/v1/authorize" | "/oauth2/v1/authorize"] then @login.form(request)
97
+ in ["GET", "/__select"] then @login.select(request)
98
+ in ["POST", "/oauth2/token"] then @cognito.token(request)
99
+ in ["POST", "/oauth/token"] then @auth0.token(request)
100
+ in ["POST", "/v1/token" | "/oauth2/v1/token"] then @oidc.token(request)
101
+ in ["GET", "/v1/logout"] then @oidc.logout(request)
102
+ in ["GET", "/userinfo" | "/oauth2/v1/userinfo"] then @auth0.userinfo(request)
103
+ in ["POST", "/introspect" | "/oauth2/v1/introspect"] then @oidc.introspect(request)
104
+ in ["POST", "/revoke" | "/oauth2/v1/revoke"] then @oidc.revoke(request)
105
+ in ["GET", "/.well-known/openid-configuration"] then @oidc.discovery
106
+ in ["GET", "/jwks" | "/.well-known/jwks.json" | "/oauth2/v1/keys"] then @oidc.jwks
107
+ else nil
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Identizer
4
+ # What an issued code/refresh token stands for: the signed-in identity plus the
5
+ # authorization-request parameters needed at token time (PKCE, scope, nonce).
6
+ Authorization = Struct.new(:identity, :code_challenge, :code_challenge_method, :scope, :nonce, :client_id,
7
+ :access_token, :refresh_token, keyword_init: true) do
8
+ # RFC 7636 PKCE check. No challenge issued -> nothing to verify.
9
+ def pkce_valid?(verifier)
10
+ return true if code_challenge.to_s.empty?
11
+
12
+ case code_challenge_method
13
+ when "S256"
14
+ digest = Digest::SHA256.digest(verifier.to_s)
15
+ Base64.urlsafe_encode64(digest, padding: false) == code_challenge
16
+ else # "plain" (or unspecified)
17
+ verifier.to_s == code_challenge
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module Identizer
6
+ # `identizer` command: configure from flags/env and boot the standalone server.
7
+ class CLI
8
+ # Seeded on first run so the directory isn't empty and login works immediately.
9
+ DEMO_USER = { mail: "demo@example.com", givenName: "Demo", sn: "User" }.freeze
10
+
11
+ def self.start(argv)
12
+ new(argv).run
13
+ end
14
+
15
+ def initialize(argv)
16
+ @argv = argv
17
+ @demo = true
18
+ end
19
+
20
+ def run
21
+ config = configure(Identizer.configuration)
22
+ start_ldap(config)
23
+ Server.start(config)
24
+ end
25
+
26
+ # Parse the flags onto a configuration and apply any saved settings, without
27
+ # starting the server. Separated out so it can be exercised in tests.
28
+ def configure(config = Identizer.configuration)
29
+ parser(config).parse!(@argv)
30
+ config.apply_persisted_settings! # web-admin saved password/signing
31
+ config.seed_identities = [DEMO_USER] if @demo && config.seed_identities.empty?
32
+ load_sqlite(config) if config.sqlite_path
33
+ config
34
+ end
35
+
36
+ private
37
+
38
+ def load_sqlite(config)
39
+ require "identizer/identity_store/sqlite_store"
40
+ config.identity_store = IdentityStore::SqliteStore.new(
41
+ path: config.sqlite_path, base_dn: config.ldap_base_dn, seed: config.seed_identities
42
+ )
43
+ rescue LoadError
44
+ abort "--sqlite needs the sqlite3 gem. Add `gem \"sqlite3\"` to your Gemfile or `gem install sqlite3`."
45
+ end
46
+
47
+ def start_ldap(config)
48
+ return unless config.ldap_port || config.ldaps_port
49
+
50
+ require "identizer/ldap"
51
+ Thread.new { Ldap::Server.new(config, port: config.ldap_port).start } if config.ldap_port
52
+ Thread.new { Ldap::Server.new(config, port: config.ldaps_port, tls: true).start } if config.ldaps_port
53
+ end
54
+
55
+ def parser(config)
56
+ OptionParser.new do |opts|
57
+ opts.banner = "Usage: identizer [options]"
58
+
59
+ opts.on("--port PORT", Integer, "Listen port (default 9999)") { |value| config.port = value }
60
+ opts.on("--host HOST", "Bind address (default 127.0.0.1)") { |value| config.host = value }
61
+ opts.on("--url-host HOST", "Hostname used in advertised URLs (default localhost)") do |value|
62
+ config.url_host = value
63
+ end
64
+ opts.on("--domain HOST", "Serve under a custom domain (add it to /etc/hosts -> 127.0.0.1)") do |value|
65
+ config.url_host = value
66
+ end
67
+ opts.on("--config-dir DIR", "Where identities + certs are stored") { |value| config.config_dir = value }
68
+ opts.on("--tls-cert PATH", "TLS certificate (PEM)") { |value| config.tls_cert_path = value }
69
+ opts.on("--tls-key PATH", "TLS private key (PEM)") { |value| config.tls_key_path = value }
70
+ opts.on("--password PASS", "Shared sign-in password (default 'password')") do |value|
71
+ config.shared_password = value
72
+ end
73
+ opts.on("--sqlite PATH", "Use a SQLite-backed directory at PATH (needs the sqlite3 gem)") do |value|
74
+ config.sqlite_path = value
75
+ end
76
+ opts.on("--rs256", "Sign id_tokens with RS256 + publish JWKS") { config.signing = :rs256 }
77
+ opts.on("--no-demo", "Don't seed the demo user on first run") { @demo = false }
78
+ opts.on("--quiet", "Don't log requests") { config.request_logging = false }
79
+ opts.on("--ldap-port PORT", Integer, "Also start an LDAP listener on PORT") { |value| config.ldap_port = value }
80
+ opts.on("--ldaps-port PORT", Integer, "Also start an LDAPS (TLS) listener on PORT") do |value|
81
+ config.ldaps_port = value
82
+ end
83
+ opts.on("--ldap-host HOST", "Bind address for the LDAP listener") { |value| config.ldap_host = value }
84
+ opts.on("-v", "--version", "Print version") do
85
+ puts Identizer::VERSION
86
+ exit
87
+ end
88
+ opts.on("-h", "--help", "Show this help") do
89
+ puts opts
90
+ exit
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end