himari 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 (47) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/Gemfile +11 -0
  4. data/Gemfile.lock +152 -0
  5. data/LICENSE.txt +21 -0
  6. data/Rakefile +8 -0
  7. data/himari.gemspec +44 -0
  8. data/lib/himari/access_token.rb +119 -0
  9. data/lib/himari/app.rb +193 -0
  10. data/lib/himari/authorization_code.rb +83 -0
  11. data/lib/himari/client_registration.rb +47 -0
  12. data/lib/himari/config.rb +39 -0
  13. data/lib/himari/decisions/authentication.rb +16 -0
  14. data/lib/himari/decisions/authorization.rb +48 -0
  15. data/lib/himari/decisions/base.rb +63 -0
  16. data/lib/himari/decisions/claims.rb +58 -0
  17. data/lib/himari/id_token.rb +57 -0
  18. data/lib/himari/item_provider.rb +11 -0
  19. data/lib/himari/item_providers/static.rb +20 -0
  20. data/lib/himari/log_line.rb +9 -0
  21. data/lib/himari/middlewares/authentication_rule.rb +24 -0
  22. data/lib/himari/middlewares/authorization_rule.rb +24 -0
  23. data/lib/himari/middlewares/claims_rule.rb +24 -0
  24. data/lib/himari/middlewares/client.rb +24 -0
  25. data/lib/himari/middlewares/config.rb +24 -0
  26. data/lib/himari/middlewares/signing_key.rb +24 -0
  27. data/lib/himari/provider_chain.rb +26 -0
  28. data/lib/himari/rule.rb +7 -0
  29. data/lib/himari/rule_processor.rb +81 -0
  30. data/lib/himari/services/downstream_authorization.rb +73 -0
  31. data/lib/himari/services/jwks_endpoint.rb +40 -0
  32. data/lib/himari/services/oidc_authorization_endpoint.rb +82 -0
  33. data/lib/himari/services/oidc_provider_metadata_endpoint.rb +56 -0
  34. data/lib/himari/services/oidc_token_endpoint.rb +86 -0
  35. data/lib/himari/services/oidc_userinfo_endpoint.rb +73 -0
  36. data/lib/himari/services/upstream_authentication.rb +106 -0
  37. data/lib/himari/session_data.rb +7 -0
  38. data/lib/himari/signing_key.rb +128 -0
  39. data/lib/himari/storages/base.rb +57 -0
  40. data/lib/himari/storages/filesystem.rb +36 -0
  41. data/lib/himari/storages/memory.rb +31 -0
  42. data/lib/himari/version.rb +5 -0
  43. data/lib/himari.rb +4 -0
  44. data/public/public/index.css +74 -0
  45. data/sig/himari.rbs +4 -0
  46. data/views/login.erb +37 -0
  47. metadata +174 -0
@@ -0,0 +1,39 @@
1
+ require 'logger'
2
+ require 'time'
3
+ require 'json'
4
+ require 'himari/log_line'
5
+
6
+ module Himari
7
+ class Config
8
+ def initialize(issuer:, storage:, providers: [], log_output: $stdout, log_level: Logger::INFO, preserve_rack_logger: false)
9
+ @issuer = issuer
10
+ @providers = providers
11
+ @storage = storage
12
+
13
+ @log_output = log_output
14
+ @log_level = log_level
15
+ @preserve_rack_logger = preserve_rack_logger
16
+ end
17
+
18
+ attr_reader :issuer, :providers, :storage, :preserve_rack_logger
19
+
20
+ def logger
21
+ @logger ||= Logger.new(@log_output).tap do |l|
22
+ l.level = @log_level
23
+ l.formatter = proc do |severity, datetime, progname, msg|
24
+ log = {time: datetime.xmlschema, severity: severity.to_s, pid: Process.pid}
25
+
26
+ case msg
27
+ when Himari::LogLine
28
+ log[:message] = msg.message
29
+ log[:data] = msg.data
30
+ else
31
+ log[:message] = msg.to_s
32
+ end
33
+
34
+ "#{JSON.generate(log)}\n"
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,16 @@
1
+ require 'himari/decisions/base'
2
+ require 'himari/session_data'
3
+
4
+ module Himari
5
+ module Decisions
6
+ class Authentication < Base
7
+ Context = Struct.new(:provider, :claims, :user_data, :request, keyword_init: true)
8
+
9
+ allow_effects(:allow, :deny, :skip)
10
+
11
+ def to_evolve_args
12
+ {}
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,48 @@
1
+ require 'himari/decisions/base'
2
+
3
+ module Himari
4
+ module Decisions
5
+ class Authorization < Base
6
+ DEFAULT_ALLOWED_CLAIMS = %i(
7
+ sub
8
+ name
9
+ nickname
10
+ preferred_username
11
+ profile
12
+ picture
13
+ website
14
+ email
15
+ email_verified
16
+ )
17
+
18
+ Context = Struct.new(:claims, :user_data, :request, :client, keyword_init: true)
19
+
20
+ allow_effects(:allow, :deny, :continue, :skip)
21
+
22
+ def initialize(claims: {}, allowed_claims: DEFAULT_ALLOWED_CLAIMS, lifetime: 3600 * 12)
23
+ super()
24
+ @claims = claims
25
+ @allowed_claims = allowed_claims
26
+ @lifetime = lifetime
27
+ end
28
+
29
+ attr_reader :claims, :allowed_claims, :lifetime
30
+
31
+ def to_evolve_args
32
+ {
33
+ claims: @claims.dup,
34
+ allowed_claims: @allowed_claims.dup,
35
+ lifetime: @lifetime&.to_i,
36
+ }
37
+ end
38
+
39
+ def as_log
40
+ to_h.merge(claims: output, lifetime: @lifetime&.to_i)
41
+ end
42
+
43
+ def output
44
+ claims.select { |k,_v| allowed_claims.include?(k) }
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,63 @@
1
+ module Himari
2
+ module Decisions
3
+ class Base
4
+ class DecisionAlreadyMade < StandardError; end
5
+ class InvalidEffect < StandardError; end
6
+
7
+ def self.allow_effects(*effects)
8
+ @valid_effects = effects
9
+ end
10
+
11
+ def self.valid_effects
12
+ @valid_effects
13
+ end
14
+
15
+ def initialize
16
+ @rule_name = nil
17
+ @effect = nil
18
+ raise "#{self.class.name}.valid_effects is missing [BUG]" unless self.class.valid_effects
19
+ end
20
+
21
+ attr_reader :effect, :effect_comment, :rule_name
22
+
23
+ def to_evolve_args
24
+ raise NotImplementedError
25
+ end
26
+
27
+ def to_h
28
+ {
29
+ rule_name: rule_name,
30
+ effect: effect,
31
+ effect_comment: effect_comment,
32
+ }
33
+ end
34
+
35
+ def as_log
36
+ to_h
37
+ end
38
+
39
+ def evolve(rule_name)
40
+ self.class.new(**to_evolve_args).set_rule_name(rule_name)
41
+ end
42
+
43
+ def set_rule_name(rule_name)
44
+ raise "cannot override rule_name" if @rule_name
45
+ @rule_name = rule_name
46
+ self
47
+ end
48
+
49
+ def decide!(effect, comment = "")
50
+ raise DecisionAlreadyMade, "decision can only be made once per rule (#{rule_name})" if @effect
51
+ raise InvalidEffect, "this effect is not valid under this rule. Valid effects: #{self.class.valid_effects.inspect} (#{rule_name})" unless self.class.valid_effects.include?(effect)
52
+ @effect = effect
53
+ @effect_comment = comment
54
+ nil
55
+ end
56
+
57
+ def allow!(comment = ""); decide!(:allow, comment); end
58
+ def continue!(comment = ""); decide!(:continue, comment); end
59
+ def deny!(comment = ""); decide!(:deny, comment); end
60
+ def skip!(comment = ""); decide!(:skip, comment); end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,58 @@
1
+ require 'himari/decisions/base'
2
+ require 'himari/session_data'
3
+
4
+ module Himari
5
+ module Decisions
6
+ class Claims < Base
7
+ class UninitializedError < StandardError; end
8
+ class AlreadyInitializedError < StandardError; end
9
+
10
+ Context = Struct.new(:request, :auth, keyword_init: true) do
11
+ def provider; auth[:provider]; end
12
+ end
13
+
14
+ allow_effects(:continue, :skip)
15
+
16
+ def initialize(claims: nil, user_data: nil)
17
+ super()
18
+ @claims = claims
19
+ @user_data = user_data
20
+ end
21
+
22
+ def to_evolve_args
23
+ {
24
+ claims: @claims.dup,
25
+ user_data: @user_data.dup,
26
+ }
27
+ end
28
+
29
+ def as_log
30
+ to_h.merge(claims: @claims)
31
+ end
32
+
33
+ def output
34
+ Himari::SessionData.new(claims: claims, user_data: user_data)
35
+ end
36
+
37
+ def initialize_claims!(claims = {})
38
+ if @claims
39
+ raise AlreadyInitializedError, "Claims already initialized; use decision.claims to make modification, or rule might be behaving wrong"
40
+ end
41
+ @claims = claims.dup
42
+ @user_data = {}
43
+ end
44
+
45
+ def claims
46
+ unless @claims
47
+ raise UninitializedError, "Claims uninitialized; use decision.initialize_claims! to declare claims first (or rule order might be unintentional)" unless @claims
48
+ end
49
+ @claims
50
+ end
51
+
52
+ def user_data
53
+ claims # to raise UninitializedError
54
+ @user_data
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,57 @@
1
+ require 'rack/oauth2'
2
+ require 'openid_connect'
3
+ require 'base64'
4
+ require 'json/jwt'
5
+
6
+ module Himari
7
+ class IdToken
8
+ # @param authz [Himari::AuthorizationCode]
9
+ def self.from_authz(authz, **kwargs)
10
+ new(
11
+ claims: authz.claims,
12
+ client_id: authz.client_id,
13
+ nonce: authz.nonce,
14
+ **kwargs
15
+ )
16
+ end
17
+
18
+ def initialize(claims:, client_id:, nonce:, signing_key:, issuer:, access_token: nil, time: Time.now)
19
+ @claims = claims
20
+ @client_id = client_id
21
+ @nonce = nonce
22
+ @signing_key = signing_key
23
+ @issuer = issuer
24
+ @access_token = access_token
25
+ @time = time
26
+ end
27
+
28
+ attr_reader :claims, :nonce, :signing_key
29
+
30
+ def final_claims
31
+ # https://openid.net/specs/openid-connect-core-1_0.html#IDToken
32
+ claims.merge(
33
+ iss: @issuer,
34
+ aud: @client_id,
35
+ iat: @time.to_i,
36
+ nbf: @time.to_i,
37
+ exp: (@time + 3600).to_i, # TODO: lifetime
38
+ ).merge(
39
+ @nonce ? { nonce: @nonce } : {}
40
+ ).merge(
41
+ @access_token ? { at_hash: at_hash } : {}
42
+ )
43
+ end
44
+
45
+ def at_hash
46
+ return nil unless @access_token
47
+ dgst = @signing_key.hash_function.digest(@access_token)
48
+ Base64.urlsafe_encode64(dgst[0, dgst.size/2], padding: false)
49
+ end
50
+
51
+ def to_jwt
52
+ jwt = JSON::JWT.new(final_claims)
53
+ jwt.kid = @signing_key.id
54
+ jwt.sign(@signing_key.pkey, @signing_key.alg.to_sym).to_s
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,11 @@
1
+ module Himari
2
+ module ItemProvider
3
+ # :nocov:
4
+ # Return items searched by hints. This method can perform fuzzy match with hints. OTOH is not expected to return exact match results.
5
+ # Use Item#match_hint? to do exact match in later process. See also: ProviderChain
6
+ def collect(**hints)
7
+ raise NotImplementedError
8
+ end
9
+ # :nocov:
10
+ end
11
+ end
@@ -0,0 +1,20 @@
1
+ require 'himari/item_provider'
2
+
3
+ module Himari
4
+ module ItemProviders
5
+ class Static
6
+ include Himari::ItemProvider
7
+
8
+ # @param items [Array<Object>] List of static configuration items
9
+ def initialize(items)
10
+ @items = items.dup.freeze
11
+ end
12
+
13
+ attr_reader :items
14
+
15
+ def collect(**_hint)
16
+ @items
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,9 @@
1
+ require 'json'
2
+
3
+ module Himari
4
+ LogLine = Struct.new(:message, :data) do
5
+ def to_s
6
+ "#{message} -- #{JSON.generate(data || {})}"
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,24 @@
1
+ require 'himari/rule'
2
+ require 'himari/item_providers/static'
3
+
4
+ module Himari
5
+ module Middlewares
6
+ class AuthenticationRule
7
+ RACK_KEY = 'himari.authn_rule'
8
+
9
+ def initialize(app, kwargs = {}, &block)
10
+ @app = app
11
+ @rule = Himari::Rule.new(block: block, **kwargs)
12
+ @provider = Himari::ItemProviders::Static.new([@rule])
13
+ end
14
+
15
+ attr_reader :app, :client
16
+
17
+ def call(env)
18
+ env[RACK_KEY] ||= []
19
+ env[RACK_KEY] += [@provider]
20
+ @app.call(env)
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,24 @@
1
+ require 'himari/rule'
2
+ require 'himari/item_providers/static'
3
+
4
+ module Himari
5
+ module Middlewares
6
+ class AuthorizationRule
7
+ RACK_KEY = 'himari.authz_rule'
8
+
9
+ def initialize(app, kwargs = {}, &block)
10
+ @app = app
11
+ @rule = Himari::Rule.new(block: block, **kwargs)
12
+ @provider = Himari::ItemProviders::Static.new([@rule])
13
+ end
14
+
15
+ attr_reader :app, :client
16
+
17
+ def call(env)
18
+ env[RACK_KEY] ||= []
19
+ env[RACK_KEY] += [@provider]
20
+ @app.call(env)
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,24 @@
1
+ require 'himari/rule'
2
+ require 'himari/item_providers/static'
3
+
4
+ module Himari
5
+ module Middlewares
6
+ class ClaimsRule
7
+ RACK_KEY = 'himari.claims_rule'
8
+
9
+ def initialize(app, kwargs = {}, &block)
10
+ @app = app
11
+ @rule = Himari::Rule.new(block: block, **kwargs)
12
+ @provider = Himari::ItemProviders::Static.new([@rule])
13
+ end
14
+
15
+ attr_reader :app, :client
16
+
17
+ def call(env)
18
+ env[RACK_KEY] ||= []
19
+ env[RACK_KEY] += [@provider]
20
+ @app.call(env)
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,24 @@
1
+ require 'himari/client_registration'
2
+ require 'himari/item_providers/static'
3
+
4
+ module Himari
5
+ module Middlewares
6
+ class Client
7
+ RACK_KEY = 'himari.clients'
8
+
9
+ def initialize(app, kwargs = {})
10
+ @app = app
11
+ @client = Himari::ClientRegistration.new(**kwargs)
12
+ @provider = Himari::ItemProviders::Static.new([@client])
13
+ end
14
+
15
+ attr_reader :app, :client
16
+
17
+ def call(env)
18
+ env[RACK_KEY] ||= []
19
+ env[RACK_KEY] += [@provider]
20
+ @app.call(env)
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,24 @@
1
+ require 'himari/config'
2
+
3
+ module Himari
4
+ module Middlewares
5
+ class Config
6
+ RACK_KEY = 'himari.config'
7
+
8
+ def initialize(app, kwargs = {})
9
+ @app = app
10
+ @config = Himari::Config.new(**kwargs)
11
+ end
12
+
13
+ attr_reader :app, :config
14
+
15
+ def call(env)
16
+ env[RACK_KEY] = config
17
+ unless config.preserve_rack_logger
18
+ env['rack.logger'] = config.logger
19
+ end
20
+ @app.call(env)
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,24 @@
1
+ require 'himari/signing_key'
2
+ require 'himari/item_providers/static'
3
+
4
+ module Himari
5
+ module Middlewares
6
+ class SigningKey
7
+ RACK_KEY = 'himari.signing_keys'
8
+
9
+ def initialize(app, kwargs = {})
10
+ @app = app
11
+ @signing_key = Himari::SigningKey.new(**kwargs)
12
+ @provider = Himari::ItemProviders::Static.new([@signing_key])
13
+ end
14
+
15
+ attr_reader :app, :signing_key
16
+
17
+ def call(env)
18
+ env[RACK_KEY] ||= []
19
+ env[RACK_KEY] += [@provider]
20
+ @app.call(env)
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,26 @@
1
+ module Himari
2
+ class ProviderChain
3
+ # @param providers [Array<ItemProvider>]
4
+ def initialize(providers)
5
+ @providers = providers
6
+ end
7
+
8
+ attr_reader :providers
9
+
10
+ def find(**hint, &block)
11
+ block ||= proc { |i,h| i.match_hint?(**h) } # ItemProvider#collect doesn't guarantee exact matches, so do exact match by match_hint?
12
+ @providers.each do |provider|
13
+ provider.collect(**hint).each do |item|
14
+ return item if block.call(item, hint)
15
+ end
16
+ end
17
+ nil
18
+ end
19
+
20
+ def collect(**hint)
21
+ @providers.flat_map do |provider|
22
+ provider.collect(**hint)
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,7 @@
1
+ module Himari
2
+ Rule = Struct.new(:name, :block, keyword_init: true) do
3
+ def call(context, decision)
4
+ block.call(context, decision)
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,81 @@
1
+ module Himari
2
+ class RuleProcessor
3
+ class MissingDecisionError < StandardError; end
4
+
5
+ Result = Struct.new(:rule_name, :allowed, :explicit_deny, :decision, :decision_log, keyword_init: true) do
6
+ def as_log
7
+ {
8
+ rule_name: rule_name,
9
+ allowed: allowed,
10
+ explicit_deny: explicit_deny,
11
+ decision: decision&.as_log,
12
+ decision_log: decision_log.map(&:to_h),
13
+ }
14
+ end
15
+ end
16
+
17
+ # @param context [Object] Context data
18
+ # @param initial_decision [Himari::Decisions::Base] Initial decision
19
+ def initialize(context, initial_decision)
20
+ @context = context
21
+ @initial_decision = initial_decision
22
+
23
+ @result = Result.new(rule_name: nil, allowed: false, explicit_deny: false, decision: nil, decision_log: [])
24
+ @decision = initial_decision
25
+ @final = false
26
+ end
27
+
28
+ attr_reader :rules, :context, :initial_decision
29
+ attr_reader :result
30
+
31
+ def final?; @final; end
32
+
33
+ # @param rules [Himari::Rule] rules
34
+ def process(rule)
35
+ raise "cannot process rule for finalized result [BUG]" if final?
36
+
37
+ decision = @decision.evolve(rule.name)
38
+
39
+ rule.call(context, decision)
40
+ raise MissingDecisionError, "rule '#{rule.name}' returned no decision; rule must use one of decision.allow!, deny!, continue!, skip!" unless decision.effect
41
+ result.decision_log.push(decision)
42
+
43
+ case decision.effect
44
+ when :allow
45
+ @decision = decision
46
+ result.rule_name ||= rule.name
47
+ result.decision = decision
48
+ result.allowed = true
49
+ result.explicit_deny = false
50
+
51
+ when :continue
52
+ @decision = decision
53
+ result.decision = decision
54
+
55
+ when :skip
56
+ # do nothing
57
+
58
+ when :deny
59
+ @final = true
60
+ result.rule_name = rule.name
61
+ result.decision = nil
62
+ result.allowed = false
63
+ result.explicit_deny = true
64
+
65
+ else
66
+ raise "Unknown effect #{decision.effect} [BUG]"
67
+ end
68
+ end
69
+
70
+ # @param rules [Array<Himari::Rule>] rules
71
+ def run(rules)
72
+ rules.each do |rule|
73
+ process(rule)
74
+ break if final?
75
+ end
76
+ @final = true
77
+ result.decision ||= @initial_decision unless result.explicit_deny
78
+ result
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,73 @@
1
+ require 'himari/decisions/authorization'
2
+ require 'himari/middlewares/authorization_rule'
3
+ require 'himari/rule_processor'
4
+ require 'himari/session_data'
5
+ require 'himari/provider_chain'
6
+
7
+ module Himari
8
+ module Services
9
+ class DownstreamAuthorization
10
+ class ForbiddenError < StandardError
11
+ # @param result [Himari::RuleProcessor::Result]
12
+ def initialize(result)
13
+ @result = result
14
+ super("Forbidden")
15
+ end
16
+
17
+ attr_reader :result
18
+
19
+ def as_log
20
+ result.as_log
21
+ end
22
+ end
23
+
24
+ Result = Struct.new(:client, :claims, :authz_result) do
25
+ def as_log
26
+ {
27
+ client: client.as_log,
28
+ claims: claims,
29
+ decision: {
30
+ authorization: authz_result.as_log,
31
+ },
32
+ }
33
+ end
34
+ end
35
+
36
+ # @param session [Himari::SessionData]
37
+ # @param client [Himari::ClientRegistration]
38
+ # @param request [Rack::Request]
39
+ # @param authz_rules [Array<Himari::Rule>] Authorization Rules
40
+ # @param logger [Logger]
41
+ def initialize(session:, client:, request: nil, authz_rules: [], logger: nil)
42
+ @session = session
43
+ @client = client
44
+ @request = request
45
+ @authz_rules = authz_rules
46
+ @logger = logger
47
+ end
48
+
49
+ # @param session [Himari::SessionData]
50
+ # @param client [Himari::ClientRegistration]
51
+ # @param request [Rack::Request]
52
+ def self.from_request(session:, client:, request:)
53
+ new(
54
+ session: session,
55
+ client: client,
56
+ request: request,
57
+ authz_rules: Himari::ProviderChain.new(request.env[Himari::Middlewares::AuthorizationRule::RACK_KEY] || []).collect,
58
+ logger: request.env['rack.logger'],
59
+ )
60
+ end
61
+
62
+ def perform
63
+ context = Himari::Decisions::Authorization::Context.new(claims: @session.claims, user_data: @session.user_data, request: @request, client: @client).freeze
64
+
65
+ authorization = Himari::RuleProcessor.new(context, Himari::Decisions::Authorization.new(claims: @session.claims.dup)).run(@authz_rules)
66
+ raise ForbiddenError.new(Result.new(@client, nil, authorization)) unless authorization.allowed
67
+
68
+ claims = authorization.decision.output
69
+ Result.new(@client, claims, authorization)
70
+ end
71
+ end
72
+ end
73
+ end