foobara-auth 0.0.1 → 0.0.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6e3b1336d92a8f2a54bde80403ab435d225b0ae14ff90495c8581ab4659d8817
4
- data.tar.gz: 517afe9b1c6eb093991d5e191d741e25eb4d4728ffdfa56bf2c52d610db84710
3
+ metadata.gz: 29222c9e1b4418b49c88107de989af8ad1f082e26416cb9ce0e51de8f1625b17
4
+ data.tar.gz: a729451915a9baf22f982f2488677b3695cf7d08b2184a42d11ffaaaafc4b75d
5
5
  SHA512:
6
- metadata.gz: 9953981b89689108443efd635cf6486f01c6c139f7fb3671167032cca24b5b8c838b102385de5b1588f38d077dae35e3346311843df50c7d41080f297a03b36a
7
- data.tar.gz: 05a7d280774e1a66ad2d4572e1870448de8f0421346a330b09938faeca9eb1f275634548d20ba3fa8fd271ca52a559cffdba285c058d2c4a21cf7ef376934ac9
6
+ metadata.gz: 30b806538de1cd5202fd979cbb10dc235841bb8c9cf358ffacdafcee8f7f3b2f9db5857ff4c54efd70e8002a1b73ccfefc8e1e7876e9327a79113c744240160a
7
+ data.tar.gz: f17ef76c86bd23b76019b90bcbad937e98719deb896b2e2499e4aa86fc6737cbd8f07240b81018074f75b90e403629aff75ec22bdb15b192e3917725e5ce8435
data/CHANGELOG.md CHANGED
@@ -1,3 +1,8 @@
1
+ ## [0.0.2] - 2025-03-21
2
+
3
+ - Implement/test lots of basic auth behavior such as
4
+ registering/login access tokens/login refresh tokens/api keys/passwords
5
+
1
6
  ## [0.0.1] - 2025-03-08
2
7
 
3
8
  - Release as a gem
data/README.md CHANGED
@@ -22,3 +22,10 @@ at https://github.com/foobara/auth
22
22
 
23
23
  This project is dual licensed under your choice of the Apache-2.0 license and the MIT license.
24
24
  Please see LICENSE.txt for more info.
25
+
26
+ ## Concepts
27
+
28
+ 1. token: a string of the form <token_id>-<token_secret>
29
+ 2. password: a string
30
+ 3. secret: either a password or a token_secret. Never stored in the database
31
+ 4. hashed_secret: value stored in the database that can be checked against the secret passed in.
@@ -0,0 +1,21 @@
1
+ module Foobara
2
+ module Auth
3
+ class ApproveToken < Foobara::Command
4
+ inputs do
5
+ token_record Types::Token, :required
6
+ end
7
+
8
+ result Types::Token
9
+
10
+ def execute
11
+ approve_token
12
+
13
+ token_record
14
+ end
15
+
16
+ def approve_token
17
+ token_record.approve!
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,46 @@
1
+ require "argon2"
2
+
3
+ module Foobara
4
+ module Auth
5
+ class BuildSecret < Foobara::Command
6
+ inputs do
7
+ secret :string, :required, :sensitive_exposed
8
+ end
9
+ result Types::Secret, :sensitive
10
+
11
+ def execute
12
+ generate_hashed_secret
13
+ build_secret
14
+
15
+ secret_model
16
+ end
17
+
18
+ attr_accessor :hashed_secret, :secret_model
19
+
20
+ def generate_hashed_secret
21
+ self.hashed_secret = Argon2::Password.create(secret, **argon_params)
22
+ end
23
+
24
+ def build_secret
25
+ self.secret_model = Types::Secret.new(
26
+ hashed_secret:,
27
+ parameters: argon_params.merge(other_params),
28
+ created_at: Time.now
29
+ )
30
+ end
31
+
32
+ def argon_params
33
+ {
34
+ t_cost: 2,
35
+ m_cost: 16,
36
+ parallelism: 1,
37
+ type: :argon2id
38
+ }
39
+ end
40
+
41
+ def other_params
42
+ { method: :argon2id }
43
+ end
44
+ end
45
+ end
46
+ end
@@ -1,77 +1,39 @@
1
1
  require "securerandom"
2
2
  require "base64"
3
3
 
4
- require_relative "build_password"
4
+ require_relative "build_secret"
5
+ require_relative "create_token"
5
6
 
6
7
  module Foobara
7
8
  module Auth
8
9
  class CreateApiKey < Foobara::Command
9
- result :string
10
+ inputs do
11
+ user Types::User, :required
12
+ needs_approval :boolean, default: false
13
+ end
14
+ result :string, :sensitive_exposed
10
15
 
11
- depends_on BuildPassword
12
- depends_on_entity Types::ApiKey
16
+ depends_on CreateToken
17
+ depends_on_entity Types::Token
13
18
 
14
19
  def execute
15
- generate_prefix
16
- generate_raw_key
17
- generate_key_for_user
18
- generate_hashed_key
19
- create_api_key
20
- prepend_prefix
20
+ create_token
21
+ associate_token_with_user
21
22
 
22
23
  key_for_user
23
24
  end
24
25
 
25
- attr_accessor :raw_key, :key_for_user, :hashed_key, :prefix, :build_password_params
26
-
27
- def generate_prefix
28
- bytes = SecureRandom.hex(prefix_bytes)
29
- self.prefix = Base64.strict_encode64(bytes)[0..prefix_length - 1]
30
- end
31
-
32
- def generate_raw_key
33
- self.raw_key = SecureRandom.hex(bytes)
34
- end
35
-
36
- def bytes
37
- 24
38
- end
39
-
40
- def generate_key_for_user
41
- self.key_for_user = Base64.strict_encode64(raw_key)
42
- end
43
-
44
- def generate_hashed_key
45
- password = run_subcommand!(BuildPassword, plaintext_password: key_for_user)
46
- self.hashed_key = password.hashed_password
47
- self.build_password_params = password.parameters
48
- end
49
-
50
- def create_api_key
51
- Types::ApiKey.create(
52
- hashed_token: hashed_key,
53
- prefix: prefix,
54
- token_length: key_for_user.length,
55
- token_parameters: build_password_params.merge(other_params),
56
- expires_at: nil,
57
- created_at: Time.now
58
- )
59
- end
60
-
61
- def prepend_prefix
62
- self.key_for_user = "#{prefix}#{key_for_user}"
63
- end
26
+ attr_accessor :token, :key_for_user
64
27
 
65
- def other_params
66
- { bytes:, prefix_length:, prefix_bytes: }
67
- end
28
+ def create_token
29
+ result = run_subcommand!(CreateToken, needs_approval:)
68
30
 
69
- def prefix_length
70
- 5
31
+ self.token = result[:token_record]
32
+ self.key_for_user = result[:token_string]
71
33
  end
72
34
 
73
- def prefix_bytes
74
- 4
35
+ def associate_token_with_user
36
+ user.api_keys = [*user.api_keys, token]
75
37
  end
76
38
  end
77
39
  end
@@ -0,0 +1,111 @@
1
+ require "securerandom"
2
+ require "base64"
3
+
4
+ require_relative "build_secret"
5
+
6
+ module Foobara
7
+ module Auth
8
+ class CreateToken < Foobara::Command
9
+ inputs do
10
+ expires_at :datetime
11
+ token_group :string
12
+ needs_approval :boolean, default: false
13
+ end
14
+
15
+ result do
16
+ token_string :string, :required, :sensitive
17
+ token_record Types::Token, :required, :sensitive
18
+ end
19
+
20
+ depends_on BuildSecret
21
+ depends_on_entity Types::Token
22
+
23
+ def execute
24
+ generate_token_secret
25
+ generate_hashed_secret
26
+ generate_token_id
27
+ construct_token_string
28
+ create_token_record
29
+
30
+ unless needs_approval?
31
+ activate_token
32
+ end
33
+
34
+ token_string_and_record
35
+ end
36
+
37
+ attr_accessor :token_id, :token_secret, :token_string, :hashed_secret, :build_password_params, :token_record
38
+
39
+ def generate_token_id
40
+ bytes = SecureRandom.random_bytes(token_id_bytes)
41
+
42
+ begin
43
+ token_id = Base64.strict_encode64(bytes)
44
+ end while Types::Token.exists?(token_id)
45
+
46
+ self.token_id = token_id
47
+ end
48
+
49
+ def generate_token_secret
50
+ bytes = SecureRandom.random_bytes(secret_bytes)
51
+ self.token_secret = Base64.strict_encode64(bytes)
52
+ end
53
+
54
+ def secret_bytes
55
+ 32
56
+ end
57
+
58
+ def generate_hashed_secret
59
+ secret = run_subcommand!(BuildSecret, secret: token_secret)
60
+ self.hashed_secret = secret.hashed_secret
61
+ self.build_password_params = secret.parameters
62
+ end
63
+
64
+ def create_token_record
65
+ attributes = {
66
+ hashed_secret:,
67
+ id: token_id,
68
+ token_parameters: build_password_params.merge(other_params),
69
+ created_at: Time.now
70
+ }
71
+
72
+ if expires_at
73
+ attributes[:expires_at] = expires_at
74
+ end
75
+
76
+ if token_group
77
+ attributes[:token_group] = token_group
78
+ end
79
+
80
+ self.token_record = Types::Token.create(attributes)
81
+ end
82
+
83
+ def needs_approval?
84
+ needs_approval
85
+ end
86
+
87
+ def activate_token
88
+ token_record.approve!
89
+ end
90
+
91
+ def construct_token_string
92
+ self.token_string = "#{token_id}_#{token_secret}"
93
+ end
94
+
95
+ def token_string_and_record
96
+ {
97
+ token_string:,
98
+ token_record:
99
+ }
100
+ end
101
+
102
+ def other_params
103
+ { secret_bytes:, token_id_bytes: }
104
+ end
105
+
106
+ def token_id_bytes
107
+ 6
108
+ end
109
+ end
110
+ end
111
+ end
data/src/create_user.rb CHANGED
@@ -3,18 +3,36 @@ module Foobara
3
3
  class CreateUser < Foobara::Command
4
4
  inputs Types::User.attributes_for_create
5
5
 
6
+ add_inputs do
7
+ plaintext_password :string, :sensitive_exposed
8
+ end
9
+
6
10
  result Types::User
7
11
 
12
+ depends_on SetPassword
13
+
8
14
  def execute
9
15
  create_user
10
16
 
17
+ if password_present?
18
+ set_password
19
+ end
20
+
11
21
  user
12
22
  end
13
23
 
14
24
  attr_accessor :user
15
25
 
16
26
  def create_user
17
- self.user = Types::User.create(inputs)
27
+ self.user = Types::User.create(inputs.except(:plaintext_password))
28
+ end
29
+
30
+ def password_present?
31
+ plaintext_password && !plaintext_password.empty?
32
+ end
33
+
34
+ def set_password
35
+ run_subcommand!(SetPassword, user:, plaintext_password:)
18
36
  end
19
37
  end
20
38
  end
data/src/find_user.rb ADDED
@@ -0,0 +1,41 @@
1
+ module Foobara
2
+ module Auth
3
+ class FindUser < Foobara::Command
4
+ class UserNotFoundError < Foobara::RuntimeError
5
+ context do
6
+ id :integer
7
+ username :string
8
+ email :email
9
+ end
10
+
11
+ def message
12
+ "No user found for #{context}"
13
+ end
14
+ end
15
+
16
+ inputs do
17
+ id :integer
18
+ username :string
19
+ email :email
20
+ end
21
+
22
+ result Types::User
23
+
24
+ def execute
25
+ load_user
26
+
27
+ user
28
+ end
29
+
30
+ attr_accessor :user
31
+
32
+ def load_user
33
+ self.user = Types::User.find_by(inputs)
34
+
35
+ unless user
36
+ add_runtime_error(UserNotFoundError.new(context: inputs))
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
data/src/login.rb ADDED
@@ -0,0 +1,130 @@
1
+ require "jwt"
2
+ require "securerandom"
3
+
4
+ require_relative "create_token"
5
+ require_relative "verify_password"
6
+ require_relative "verify_token"
7
+
8
+ module Foobara
9
+ module Auth
10
+ class Login < Foobara::Command
11
+ class InvalidPasswordError < Foobara::RuntimeError
12
+ context({})
13
+ message "Invalid password"
14
+ end
15
+
16
+ class NoUserIdEmailOrUsernameGivenError < Foobara::RuntimeError
17
+ context({})
18
+ message "No user id, email, or username given"
19
+ end
20
+
21
+ depends_on CreateToken, VerifyPassword, FindUser
22
+
23
+ inputs do
24
+ user Types::User, :allow_nil
25
+ username :string, :allow_nil
26
+ email :string, :allow_nil
27
+ username_or_email :string, :allow_nil
28
+ plaintext_password :string, :required, :sensitive_exposed
29
+ # Configure these instead of defaulting them here?
30
+ token_ttl :integer, default: 30 * 60
31
+ refresh_token_ttl :integer, default: 7 * 24 * 60 * 60
32
+ end
33
+
34
+ result do
35
+ access_token :string, :required, :sensitive_exposed
36
+ refresh_token :string, :required, :sensitive_exposed
37
+ end
38
+
39
+ def execute
40
+ find_user_to_login
41
+ verify_password
42
+ # TODO: DRY these 5 up
43
+ determine_timestamps
44
+ generate_access_token
45
+ determine_token_group
46
+ generate_new_refresh_token
47
+ save_new_refresh_token_on_user
48
+
49
+ tokens
50
+ end
51
+
52
+ attr_accessor :access_token, :new_refresh_token, :now, :expires_at, :token_group, :user_to_login
53
+
54
+ def find_user_to_login
55
+ if user
56
+ self.user_to_login = user
57
+ elsif username
58
+ self.user_to_login = run_subcommand!(FindUser, username:)
59
+ elsif email
60
+ self.user_to_login = run_subcommand!(FindUser, email:)
61
+ elsif username_or_email
62
+ begin
63
+ self.user_to_login = run_subcommand!(FindUser, username: username_or_email)
64
+ rescue Halt
65
+ # I'm a bit nervous about rescuing Halt and clearing the errors, but I'm more nervous bout
66
+ # introducing a #run_subcommand method.
67
+ if error_collection.size == 1 && error_collection.errors.first.is_a?(FindUser::UserNotFoundError) &&
68
+ username_or_email.include?("@")
69
+ error_collection.clear
70
+ self.user_to_login = run_subcommand!(FindUser, email: username_or_email)
71
+ else
72
+ raise
73
+ end
74
+ end
75
+ else
76
+ add_runtime_error(NoUserIdEmailOrUsernameGivenError)
77
+ end
78
+ end
79
+
80
+ def verify_password
81
+ unless run_subcommand!(VerifyPassword, user: user_to_login, plaintext_password:)
82
+ add_runtime_error(InvalidPasswordError)
83
+ end
84
+ end
85
+
86
+ def determine_timestamps
87
+ self.now = Time.now
88
+ self.expires_at = now + token_ttl
89
+ end
90
+
91
+ def generate_access_token
92
+ payload = { sub: user_to_login.id, exp: expires_at.to_i }
93
+
94
+ self.access_token = JWT.encode(payload, jwt_secret, "HS256")
95
+ end
96
+
97
+ def jwt_secret
98
+ jwt_secret_text = ENV.fetch("JWT_SECRET", nil)
99
+
100
+ unless jwt_secret_text
101
+ # :nocov:
102
+ raise "You must set the JWT_SECRET environment variable"
103
+ # :nocov:
104
+ end
105
+
106
+ jwt_secret_text
107
+ end
108
+
109
+ def determine_token_group
110
+ self.token_group = SecureRandom.uuid
111
+ end
112
+
113
+ def generate_new_refresh_token
114
+ self.new_refresh_token = run_subcommand!(CreateToken, expires_at:, token_group:)
115
+ end
116
+
117
+ def save_new_refresh_token_on_user
118
+ # TODO: maybe override #<< on these objects to dirty the entity??
119
+ user_to_login.refresh_tokens += [*user_to_login.refresh_tokens, new_refresh_token[:token_record]]
120
+ end
121
+
122
+ def tokens
123
+ {
124
+ access_token:,
125
+ refresh_token: new_refresh_token[:token_string]
126
+ }
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,129 @@
1
+ require "jwt"
2
+ require "securerandom"
3
+
4
+ require_relative "create_token"
5
+ require_relative "verify_token"
6
+
7
+ module Foobara
8
+ module Auth
9
+ class RefreshLogin < Foobara::Command
10
+ class InvalidRefreshTokenError < Foobara::RuntimeError
11
+ context refresh_token_id: :string
12
+ message "Invalid refresh token"
13
+ end
14
+
15
+ class RefreshTokenNotOwnedByUser < Foobara::RuntimeError
16
+ context refresh_token_id: :string
17
+ message "This refresh token is not owned by this user"
18
+ end
19
+
20
+ depends_on CreateToken, VerifyToken
21
+
22
+ inputs do
23
+ user Types::User, :required
24
+ refresh_token_text :string, :required, :sensitive
25
+ # Can we get these TTLs off of the refresh token?
26
+ token_ttl :integer, default: 30 * 60
27
+ refresh_token_ttl :integer, default: 7 * 24 * 60 * 60
28
+ end
29
+
30
+ result do
31
+ access_token :string, :required, :sensitive_exposed
32
+ refresh_token :string, :required, :sensitive_exposed
33
+ end
34
+
35
+ def execute
36
+ determine_refresh_token_id_and_secret
37
+ load_refresh_token
38
+ validate_refresh_token_belongs_to_user
39
+ verify_refresh_token
40
+ # Delete it instead maybe?
41
+ mark_refresh_token_as_used
42
+ determine_timestamps
43
+ determine_token_group
44
+
45
+ generate_access_token
46
+ generate_new_refresh_token
47
+ save_new_refresh_token_on_user
48
+
49
+ tokens
50
+ end
51
+
52
+ attr_accessor :access_token, :new_refresh_token, :now, :expires_at, :refresh_token,
53
+ :refresh_token_id, :refresh_token_secret, :token_group
54
+
55
+ def determine_refresh_token_id_and_secret
56
+ self.refresh_token_id, self.refresh_token_secret = refresh_token_text.split("_")
57
+ end
58
+
59
+ def load_refresh_token
60
+ self.refresh_token = Types::Token.load(refresh_token_id)
61
+ end
62
+
63
+ def validate_refresh_token_belongs_to_user
64
+ unless user.refresh_tokens.any? { |token| token.id == refresh_token_id }
65
+ add_runtime_error(RefreshTokenNotOwnedByUser.new(context: { refresh_token_id: }))
66
+ end
67
+ end
68
+
69
+ def verify_refresh_token
70
+ valid = run_subcommand!(VerifyToken, token_string: refresh_token_text)
71
+
72
+ unless valid[:verified]
73
+ add_runtime_error(InvalidRefreshTokenError.new(context: { refresh_token_id: }))
74
+ end
75
+ end
76
+
77
+ def mark_refresh_token_as_used
78
+ refresh_token.use_up!
79
+ end
80
+
81
+ def determine_timestamps
82
+ self.now = Time.now
83
+ self.expires_at = now + token_ttl
84
+ end
85
+
86
+ def generate_access_token
87
+ payload = {
88
+ user_id: user.id,
89
+ username: user.username,
90
+ exp: expires_at.to_i
91
+ }
92
+
93
+ self.access_token = JWT.encode(payload, jwt_secret, "HS256")
94
+ end
95
+
96
+ def jwt_secret
97
+ jwt_secret_text = ENV.fetch("JWT_SECRET", nil)
98
+
99
+ unless jwt_secret_text
100
+ # :nocov:
101
+ raise "You must set the JWT_SECRET environment variable"
102
+ # :nocov:
103
+ end
104
+
105
+ jwt_secret_text
106
+ end
107
+
108
+ def determine_token_group
109
+ self.token_group = refresh_token&.token_group || SecureRandom.uuid
110
+ end
111
+
112
+ def generate_new_refresh_token
113
+ self.new_refresh_token = run_subcommand!(CreateToken, expires_at:, token_group:)
114
+ end
115
+
116
+ def save_new_refresh_token_on_user
117
+ # TODO: maybe override #<< on these objects to dirty the entity??
118
+ user.refresh_tokens += [*user.refresh_tokens, new_refresh_token[:token_record]]
119
+ end
120
+
121
+ def tokens
122
+ {
123
+ access_token:,
124
+ refresh_token: new_refresh_token[:token_string]
125
+ }
126
+ end
127
+ end
128
+ end
129
+ end
data/src/register.rb ADDED
@@ -0,0 +1,40 @@
1
+ require_relative "types/user"
2
+
3
+ module Foobara
4
+ module Auth
5
+ class Register < Foobara::Command
6
+ depends_on CreateUser, SetPassword
7
+
8
+ inputs do
9
+ username :string, :required
10
+ email :email, :required
11
+ plaintext_password :string, :allow_nil, :sensitive_exposed
12
+ end
13
+
14
+ result Types::User
15
+
16
+ def execute
17
+ create_user
18
+ if password?
19
+ set_password
20
+ end
21
+
22
+ user
23
+ end
24
+
25
+ attr_accessor :user
26
+
27
+ def create_user
28
+ self.user = run_subcommand!(CreateUser, username:, email:)
29
+ end
30
+
31
+ def password?
32
+ plaintext_password && !plaintext_password.empty?
33
+ end
34
+
35
+ def set_password
36
+ run_subcommand!(SetPassword, user:, plaintext_password:)
37
+ end
38
+ end
39
+ end
40
+ end
data/src/set_password.rb CHANGED
@@ -1,15 +1,13 @@
1
- require "argon2"
2
-
3
1
  module Foobara
4
2
  module Auth
5
3
  class SetPassword < Foobara::Command
6
4
  inputs do
7
5
  user Types::User
8
- plaintext_password :string, :required
6
+ plaintext_password :string, :required, :sensitive_exposed
9
7
  end
10
8
  result Types::User
11
9
 
12
- depends_on BuildPassword
10
+ depends_on BuildSecret
13
11
 
14
12
  # TODO: should we enforce certain password requirements?
15
13
  def execute
@@ -19,14 +17,14 @@ module Foobara
19
17
  user
20
18
  end
21
19
 
22
- attr_accessor :password
20
+ attr_accessor :password_secret
23
21
 
24
22
  def build_password
25
- self.password = run_subcommand!(BuildPassword, plaintext_password:)
23
+ self.password_secret = run_subcommand!(BuildSecret, secret: plaintext_password)
26
24
  end
27
25
 
28
26
  def set_password_on_user
29
- user.password = password
27
+ user.password_secret = password_secret
30
28
  end
31
29
  end
32
30
  end
@@ -1,9 +1,9 @@
1
1
  module Foobara
2
2
  module Auth
3
3
  module Types
4
- class Password < Foobara::Model
4
+ class Secret < Foobara::Model
5
5
  attributes do
6
- hashed_password :string, :required
6
+ hashed_secret :string, :required, :sensitive
7
7
  parameters :duck, :required
8
8
  created_at :datetime, :required
9
9
  end
@@ -0,0 +1,11 @@
1
+ module Foobara
2
+ module Auth
3
+ module Types
4
+ class Token < Foobara::Entity
5
+ State = Foobara::Enumerated.make_module(StateMachine.states)
6
+ end
7
+ end
8
+
9
+ foobara_register_type(:token_state, :symbol, one_of: Types::Token::State)
10
+ end
11
+ end
@@ -1,15 +1,17 @@
1
1
  module Foobara
2
2
  module Auth
3
3
  module Types
4
- class ApiKey < Foobara::Entity
4
+ class Token < Foobara::Entity
5
5
  class StateMachine < Foobara::StateMachine
6
6
  set_transition_map({
7
7
  needs_approval: {
8
- approve: :approved,
8
+ approve: :active,
9
9
  reject: :rejected
10
10
  },
11
- approved: {
12
- revoke: :revoked
11
+ active: {
12
+ revoke: :revoked,
13
+ use_up: :inactive,
14
+ expire: :expired
13
15
  }
14
16
  })
15
17
  end
@@ -0,0 +1,43 @@
1
+ module Foobara
2
+ module Auth
3
+ module Types
4
+ class Token < Foobara::Entity
5
+ attributes do
6
+ id :string, :required
7
+ hashed_secret :string, :required, :sensitive
8
+ state :token_state, :required, default: :needs_approval
9
+ token_parameters :duck, :required
10
+ token_group :string, :allow_nil
11
+ expires_at :datetime, :allow_nil
12
+ created_at :datetime, :required
13
+ end
14
+
15
+ primary_key :id
16
+
17
+ def state_machine
18
+ @state_machine ||= StateMachine.new(owner: self, target_attribute: :state)
19
+ end
20
+
21
+ def approve!
22
+ state_machine.approve!
23
+ end
24
+
25
+ def use_up!
26
+ state_machine.use_up!
27
+ end
28
+
29
+ def expire!
30
+ state_machine.expire!
31
+ end
32
+
33
+ def inactive?
34
+ state_machine.current_state == State::INACTIVE
35
+ end
36
+
37
+ def active?
38
+ state_machine.current_state == State::ACTIVE
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
data/src/types/user.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  require_relative "role"
2
- require_relative "password"
2
+ require_relative "secret"
3
3
 
4
4
  module Foobara
5
5
  module Auth
@@ -10,8 +10,9 @@ module Foobara
10
10
  username :string, :required
11
11
  email :email, :required
12
12
  roles [Types::Role], default: []
13
- api_key [Types::ApiKey], default: []
14
- password Types::Password, :allow_nil
13
+ api_keys [Types::Token], :sensitive, default: []
14
+ refresh_tokens [Types::Token], :sensitive, default: []
15
+ password_secret Types::Secret, :sensitive, :allow_nil
15
16
  end
16
17
 
17
18
  primary_key :id
@@ -0,0 +1,61 @@
1
+ require "jwt"
2
+
3
+ module Foobara
4
+ module Auth
5
+ class VerifyAccessToken < Foobara::Command
6
+ inputs do
7
+ # TODO: we should add a processor that flags an attribute as sensitive so we can scrub
8
+ access_token :string, :required, :sensitive
9
+ end
10
+
11
+ result do
12
+ verified :boolean, :required
13
+ failure_reason :string, :allow_nil, one_of: %w[invalid expired]
14
+ payload :associative_array, :allow_nil
15
+ headers :associative_array, :allow_nil
16
+ end
17
+
18
+ def execute
19
+ decode_access_token
20
+ set_verified_flag
21
+
22
+ verified_flag_payload_and_headers
23
+ end
24
+
25
+ attr_accessor :verified, :payload, :headers, :failure_reason
26
+
27
+ def decode_access_token
28
+ self.payload, self.headers = JWT.decode(access_token, jwt_secret)
29
+ rescue JWT::VerificationError
30
+ self.failure_reason = "invalid"
31
+ rescue JWT::ExpiredSignature
32
+ self.failure_reason = "expired"
33
+ end
34
+
35
+ def set_verified_flag
36
+ self.verified = !failure_reason
37
+ end
38
+
39
+ def verified_flag_payload_and_headers
40
+ {
41
+ verified:,
42
+ failure_reason:,
43
+ payload:,
44
+ headers:
45
+ }
46
+ end
47
+
48
+ def jwt_secret
49
+ jwt_secret_text = ENV.fetch("JWT_SECRET", nil)
50
+
51
+ unless jwt_secret_text
52
+ # :nocov:
53
+ raise "You must set the JWT_SECRET environment variable"
54
+ # :nocov:
55
+ end
56
+
57
+ jwt_secret_text
58
+ end
59
+ end
60
+ end
61
+ end
@@ -1,4 +1,4 @@
1
- require "argon2"
1
+ require_relative "verify_token"
2
2
 
3
3
  module Foobara
4
4
  module Auth
@@ -6,11 +6,11 @@ module Foobara
6
6
  inputs do
7
7
  user Types::User, :required
8
8
  # TODO: we should add a processor that flags an attribute as sensitive so we can scrub
9
- plaintext_password :string, :required
9
+ plaintext_password :string, :required, :sensitive
10
10
  end
11
11
  result :boolean
12
12
 
13
- depends_on_entity Types::ApiKey
13
+ depends_on VerifySecret
14
14
 
15
15
  def execute
16
16
  # TODO: result in error if no password set yet?
@@ -26,9 +26,10 @@ module Foobara
26
26
  end
27
27
 
28
28
  def check_for_valid_password
29
- hashed_password = user.password.hashed_password
30
- self.valid_password = if hashed_password
31
- Argon2::Password.verify_password(plaintext_password, user.password.hashed_password)
29
+ hashed_secret = user.password_secret.hashed_secret
30
+
31
+ self.valid_password = if hashed_secret
32
+ run_subcommand!(VerifySecret, secret: plaintext_password, hashed_secret:)
32
33
  end
33
34
  end
34
35
  end
@@ -0,0 +1,30 @@
1
+ require "argon2"
2
+
3
+ module Foobara
4
+ module Auth
5
+ class VerifySecret < Foobara::Command
6
+ inputs do
7
+ secret :string, :required # TODO: we should add a processor that flags an attribute as sensitive so we can scrub
8
+ hashed_secret :string, :required, :sensitive
9
+ end
10
+
11
+ result :boolean
12
+
13
+ def execute
14
+ verify_secret_against_hashed_secret
15
+
16
+ verified?
17
+ end
18
+
19
+ attr_accessor :verified
20
+
21
+ def verified?
22
+ !!verified
23
+ end
24
+
25
+ def verify_secret_against_hashed_secret
26
+ self.verified = Argon2::Password.verify_password(secret, hashed_secret)
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,104 @@
1
+ require_relative "verify_secret"
2
+
3
+ module Foobara
4
+ module Auth
5
+ class VerifyToken < Foobara::Command
6
+ class InactiveTokenError < Foobara::RuntimeError
7
+ context do
8
+ state :token_state, :required
9
+ end
10
+
11
+ def message
12
+ "Expected token to be active but it is #{context[:state]}"
13
+ end
14
+ end
15
+
16
+ class ExpiredTokenError < Foobara::RuntimeError
17
+ context({})
18
+ message "Token is expired"
19
+ end
20
+
21
+ inputs do
22
+ # TODO: we should add a processor that flags an attribute as sensitive so we can scrub
23
+ token_string :string, :required, :sensitive
24
+ token_record Types::Token, "Instead of finding a persisted token, check against a specific token record"
25
+ end
26
+
27
+ result do
28
+ verified :boolean, :required
29
+ token_record Types::Token, :required
30
+ end
31
+
32
+ depends_on VerifySecret
33
+
34
+ def execute
35
+ determine_token_id_and_hashed_secret
36
+
37
+ unless token_record?
38
+ load_token_record
39
+ end
40
+
41
+ validate_token_is_active
42
+ validate_token_is_not_expired
43
+ verify_hashed_secret_against_token_record
44
+
45
+ verified_and_token_record
46
+ end
47
+
48
+ attr_accessor :verified, :token_id, :secret
49
+ attr_writer :token_record_to_verify_against
50
+
51
+ def token_record_to_verify_against
52
+ @token_record_to_verify_against || token_record
53
+ end
54
+
55
+ def token_record?
56
+ !!token_record
57
+ end
58
+
59
+ def determine_token_id_and_hashed_secret
60
+ self.token_id, self.secret = token_string.split("_")
61
+ end
62
+
63
+ def load_token_record
64
+ self.token_record_to_verify_against = Types::Token.load(token_id)
65
+ # TODO: handle no record found...
66
+ end
67
+
68
+ def verify_hashed_secret_against_token_record
69
+ hashed_secret = token_record_to_verify_against.hashed_secret
70
+
71
+ self.verified = run_subcommand!(VerifySecret, secret:, hashed_secret:)
72
+ end
73
+
74
+ def validate_token_is_not_expired
75
+ if token_record_to_verify_against.expires_at&.<(Time.now)
76
+ Types::Token.transaction(mode: :open_nested) do
77
+ token = Types::Token.load(token_record_to_verify_against.id)
78
+ token.expire!
79
+ end
80
+ add_runtime_error(ExpiredTokenError)
81
+ end
82
+ end
83
+
84
+ def validate_token_is_active
85
+ unless token_record_to_verify_against.active?
86
+ add_runtime_error(
87
+ InactiveTokenError.new(
88
+ context: {
89
+ state: token_record_to_verify_against.state_machine.current_state
90
+ }
91
+ )
92
+ )
93
+ end
94
+ end
95
+
96
+ def verified_and_token_record
97
+ {
98
+ verified:,
99
+ token_record: token_record_to_verify_against
100
+ }
101
+ end
102
+ end
103
+ end
104
+ end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: foobara-auth
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1
4
+ version: 0.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Miles Georgi
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2025-03-08 00:00:00.000000000 Z
10
+ date: 2025-03-21 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: argon2
@@ -23,6 +23,20 @@ dependencies:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
25
  version: '0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: jwt
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
26
40
  - !ruby/object:Gem::Dependency
27
41
  name: foobara
28
42
  requirement: !ruby/object:Gem::Requirement
@@ -49,20 +63,27 @@ files:
49
63
  - LICENSE.txt
50
64
  - README.md
51
65
  - lib/foobara/auth.rb
52
- - src/approve_api_key.rb
53
- - src/build_password.rb
66
+ - src/approve_token.rb
67
+ - src/build_secret.rb
54
68
  - src/create_api_key.rb
55
69
  - src/create_role.rb
70
+ - src/create_token.rb
56
71
  - src/create_user.rb
72
+ - src/find_user.rb
73
+ - src/login.rb
74
+ - src/refresh_login.rb
75
+ - src/register.rb
57
76
  - src/set_password.rb
58
- - src/types/api_key.rb
59
- - src/types/api_key/state.rb
60
- - src/types/api_key/state_machine.rb
61
- - src/types/password.rb
62
77
  - src/types/role.rb
78
+ - src/types/secret.rb
79
+ - src/types/token.rb
80
+ - src/types/token/state.rb
81
+ - src/types/token/state_machine.rb
63
82
  - src/types/user.rb
64
- - src/verify_api_key.rb
83
+ - src/verify_access_token.rb
65
84
  - src/verify_password.rb
85
+ - src/verify_secret.rb
86
+ - src/verify_token.rb
66
87
  homepage: https://github.com/foobara/auth
67
88
  licenses:
68
89
  - Apache-2.0 OR MIT
@@ -85,7 +106,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
85
106
  - !ruby/object:Gem::Version
86
107
  version: '0'
87
108
  requirements: []
88
- rubygems_version: 3.6.5
109
+ rubygems_version: 3.6.6
89
110
  specification_version: 4
90
111
  summary: Provides various auth domain commands and models
91
112
  test_files: []
@@ -1,21 +0,0 @@
1
- module Foobara
2
- module Auth
3
- class ApproveApiKey < Foobara::Command
4
- inputs do
5
- api_key Types::ApiKey, :required
6
- end
7
-
8
- result Types::ApiKey
9
-
10
- def execute
11
- approve_api_key
12
-
13
- api_key
14
- end
15
-
16
- def approve_api_key
17
- api_key.approve!
18
- end
19
- end
20
- end
21
- end
@@ -1,46 +0,0 @@
1
- require "argon2"
2
-
3
- module Foobara
4
- module Auth
5
- class BuildPassword < Foobara::Command
6
- inputs do
7
- plaintext_password :string, :required
8
- end
9
- result Types::Password
10
-
11
- def execute
12
- generate_hashed_password
13
- build_password
14
-
15
- password
16
- end
17
-
18
- attr_accessor :hashed_password, :password
19
-
20
- def generate_hashed_password
21
- self.hashed_password = Argon2::Password.create(plaintext_password, **argon_params)
22
- end
23
-
24
- def build_password
25
- self.password = Types::Password.new(
26
- hashed_password:,
27
- parameters: argon_params.merge(other_params),
28
- created_at: Time.now
29
- )
30
- end
31
-
32
- def argon_params
33
- {
34
- t_cost: 2,
35
- m_cost: 16,
36
- parallelism: 1,
37
- type: :argon2id
38
- }
39
- end
40
-
41
- def other_params
42
- { method: :argon2id }
43
- end
44
- end
45
- end
46
- end
@@ -1,11 +0,0 @@
1
- module Foobara
2
- module Auth
3
- module Types
4
- class ApiKey < Foobara::Entity
5
- State = Foobara::Enumerated.make_module(%i[needs_approval approved rejected revoked])
6
- end
7
- end
8
-
9
- foobara_register_type(:state, :symbol, one_of: Types::ApiKey::State)
10
- end
11
- end
data/src/types/api_key.rb DELETED
@@ -1,28 +0,0 @@
1
- module Foobara
2
- module Auth
3
- module Types
4
- class ApiKey < Foobara::Entity
5
- attributes do
6
- id :integer
7
- hashed_token :string, :required
8
- prefix :string, :required
9
- token_length :integer, :required
10
- state :state, :required, default: :needs_approval
11
- token_parameters :duck, :required
12
- expires_at :datetime, :allow_nil
13
- created_at :datetime, :required
14
- end
15
-
16
- primary_key :id
17
-
18
- def state_machine
19
- @state_machine ||= ApiKey::StateMachine.new(owner: self, target_attribute: :state)
20
- end
21
-
22
- def approve!
23
- state_machine.approve!
24
- end
25
- end
26
- end
27
- end
28
- end
@@ -1,38 +0,0 @@
1
- require "argon2"
2
-
3
- module Foobara
4
- module Auth
5
- class VerifyApiKey < Foobara::Command
6
- inputs do
7
- token :string, :required # TODO: we should add a processor that flags an attribute as sensitive so we can scrub
8
- end
9
- result :boolean
10
-
11
- depends_on_entity Types::ApiKey
12
-
13
- def execute
14
- check_for_valid_key
15
-
16
- valid_key?
17
- end
18
-
19
- attr_accessor :valid_key
20
-
21
- def valid_key?
22
- !!valid_key
23
- end
24
-
25
- def check_for_valid_key
26
- prefix = token[..4]
27
- hash_for_user = token[5..]
28
-
29
- Types::ApiKey.find_all_by_attribute(:prefix, prefix).each do |key|
30
- if Argon2::Password.verify_password(hash_for_user, key.hashed_token)
31
- self.valid_key = true
32
- break
33
- end
34
- end
35
- end
36
- end
37
- end
38
- end