himari 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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