gemstack-auth 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 07da1fb0891a46be619884f39979d2f3be08c87fbc629b5a001cdfc20bc20ab1
4
+ data.tar.gz: aadbd0585749e3e9ec04895cb720391bdf9187de4326e27fb91b83a94cb7e253
5
+ SHA512:
6
+ metadata.gz: 2adc7daafeceafa6e4a8242b32f53941ee148a242529b53b83d6272371afa323b3cf3062896ee342b090b65fdc761829a63a6149bd342df280f0ad52ac31fb79
7
+ data.tar.gz: 808dd380d6312bcfdf198b326901b88c12220bf98cd4991a0682bee4b7f66a31b1635a5c36d03cb58c795d43eceff22b04100035b5e94c9e3d3abba201e58305
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ First release. See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Shoaib Malik
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 all
13
+ 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 THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,28 @@
1
+ # gemstack-auth
2
+
3
+ GemStack auth: Argon2id passwords, cookie sessions, API tokens, policies.
4
+
5
+ Part of [GemStack](https://github.com/gemstack-rb/gemstack), a modular Ruby API framework for Next.js
6
+ applications. All GemStack gems are developed together in that repository and released with the same
7
+ version.
8
+
9
+ ## Installation
10
+
11
+ Optional module — `gemstack add auth`:
12
+
13
+ ```ruby
14
+ gem "gemstack-auth", "~> 0.1"
15
+ ```
16
+
17
+ ## Documentation
18
+
19
+ - [Guide](https://github.com/gemstack-rb/gemstack/blob/main/docs/authentication.md)
20
+ - [All guides](https://github.com/gemstack-rb/gemstack/tree/main/docs) ·
21
+ [Architecture](https://github.com/gemstack-rb/gemstack/blob/main/ARCHITECTURE.md)
22
+
23
+ Source, issues and pull requests: [gemstack-rb/gemstack](https://github.com/gemstack-rb/gemstack)
24
+ (this gem lives in `gems/gemstack-auth`).
25
+
26
+ ## License
27
+
28
+ MIT — see [LICENSE.txt](LICENSE.txt).
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemStack
4
+ module Auth
5
+ # Authentication for controllers:
6
+ #
7
+ # class ApplicationController < GemStack::Controller
8
+ # include GemStack::Auth::Controller
9
+ # end
10
+ #
11
+ # class OrdersController < ApplicationController
12
+ # before :require_login
13
+ # def index = render(current_user.orders_dataset)
14
+ # end
15
+ #
16
+ # A request is authenticated by the session cookie (browsers) or by
17
+ # `Authorization: Bearer <api token>` (scripts, other services).
18
+ #
19
+ # Cross-site request forgery: state-changing requests (POST, PUT, PATCH,
20
+ # DELETE) from other sites are refused with 403 unless the origin is in
21
+ # config.auth.trusted_origins. Browsers label every request with
22
+ # Sec-Fetch-Site / Origin; clients that send neither (curl, servers) are
23
+ # not browsers and can't carry a victim's cookie (DECISIONS D-051).
24
+ module Controller
25
+ SAFE_METHODS = %w[GET HEAD OPTIONS].freeze
26
+
27
+ def self.included(base)
28
+ base.include(RateLimit)
29
+ base.include(Policy::Authorization)
30
+ base.before :verify_request_origin
31
+ end
32
+
33
+ # The signed-in user, or nil.
34
+ def current_user
35
+ return @current_user if defined?(@current_user)
36
+
37
+ @current_user = authenticate_request
38
+ end
39
+
40
+ def signed_in? = !current_user.nil?
41
+
42
+ # :session, :token, or nil.
43
+ def authenticated_by
44
+ current_user
45
+ @authenticated_by
46
+ end
47
+
48
+ # The current `sessions` row (cookie authentication only).
49
+ def current_session
50
+ current_user
51
+ @current_session
52
+ end
53
+
54
+ # before :require_login — 401 for anonymous requests.
55
+ def require_login
56
+ raise Unauthorized.new("Sign in to continue.", code: "unauthenticated") unless current_user
57
+ end
58
+
59
+ # Starts a session: a new token (never reuses one the browser already
60
+ # had, which would allow session fixation) in an HttpOnly cookie.
61
+ def sign_in(user)
62
+ Sessions.revoke(session_cookie) if session_cookie
63
+ token = Sessions.create(user.id, ip: request.ip, user_agent: request.user_agent)
64
+ write_session_cookie(token)
65
+ @current_session = Sessions.find(token)
66
+ @authenticated_by = :session
67
+ @current_user = user
68
+ end
69
+
70
+ def sign_out
71
+ Sessions.revoke(session_cookie) if session_cookie
72
+ delete_session_cookie
73
+ @current_session = nil
74
+ @authenticated_by = nil
75
+ @current_user = nil
76
+ end
77
+
78
+ private
79
+
80
+ def authenticate_request
81
+ if (token = bearer_token)
82
+ row = Tokens.find(token, purpose: "api") or return nil
83
+ @authenticated_by = :token
84
+ Auth.user_class[row[:user_id]]
85
+ elsif (token = session_cookie)
86
+ session = Sessions.find(token)
87
+ unless session
88
+ delete_session_cookie # expired or revoked: stop sending it
89
+ return nil
90
+ end
91
+
92
+ write_session_cookie(token) if session[:touched] # slide the cookie's expiry too
93
+ @current_session = session
94
+ @authenticated_by = :session
95
+ Auth.user_class[session[:user_id]]
96
+ end
97
+ end
98
+
99
+ def bearer_token
100
+ header = request.get_header("HTTP_AUTHORIZATION").to_s
101
+ header[/\ABearer\s+(\S+)\z/i, 1]
102
+ end
103
+
104
+ def session_cookie
105
+ value = request.cookies[Auth.config.cookie_name]
106
+ value.nil? || value.empty? ? nil : value
107
+ end
108
+
109
+ def write_session_cookie(token)
110
+ config = Auth.config
111
+ replace_cookie(Rack::Utils.set_cookie_header(
112
+ config.cookie_name,
113
+ value: token, path: "/", httponly: true, secure: config.cookie_secure,
114
+ same_site: config.cookie_same_site, max_age: config.session_ttl.to_s
115
+ ))
116
+ end
117
+
118
+ def delete_session_cookie
119
+ config = Auth.config
120
+ replace_cookie(Rack::Utils.set_cookie_header(
121
+ config.cookie_name,
122
+ value: "", path: "/", httponly: true, secure: config.cookie_secure,
123
+ same_site: config.cookie_same_site, max_age: "0", expires: Time.at(0)
124
+ ))
125
+ end
126
+
127
+ def replace_cookie(line)
128
+ existing = headers["set-cookie"]
129
+ name = line[/\A[^=]+/]
130
+ kept = Array(existing).reject { |cookie| cookie.start_with?("#{name}=") }
131
+ headers["set-cookie"] = kept.empty? ? line : kept + [line]
132
+ end
133
+
134
+ def verify_request_origin
135
+ return if SAFE_METHODS.include?(request.request_method)
136
+ return if bearer_token && !session_cookie # custom headers can't be sent cross-site without CORS
137
+ return if same_origin_request?
138
+
139
+ raise Forbidden.new("Cross-site request refused.", code: "cross_site_request")
140
+ end
141
+
142
+ def same_origin_request?
143
+ origin = request.get_header("HTTP_ORIGIN")
144
+ return true if origin && Array(Auth.config.trusted_origins).include?(origin)
145
+
146
+ site = request.get_header("HTTP_SEC_FETCH_SITE")
147
+ return %w[same-origin none].include?(site) if site
148
+ return true if origin.nil? || origin.empty?
149
+
150
+ hosts = [request.host_with_port, request.get_header("HTTP_X_FORWARDED_HOST")].compact
151
+ host = URI.parse(origin).then { |uri| [uri.host, uri.port].join(":") }
152
+ hosts.any? { |value| normalize_host(value, origin) == host }
153
+ rescue URI::InvalidURIError
154
+ false
155
+ end
156
+
157
+ def normalize_host(value, origin)
158
+ host, port = value.split(",").first.strip.split(":", 2)
159
+ port ||= origin.start_with?("https:") ? "443" : "80"
160
+ "#{host}:#{port}"
161
+ end
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "argon2"
4
+
5
+ module GemStack
6
+ module Auth
7
+ # Password hashing with Argon2id (DECISIONS D-049). Hashes from other
8
+ # systems in bcrypt format still verify (add gem "bcrypt") and are
9
+ # upgraded to Argon2id on the next successful login.
10
+ module Password
11
+ class << self
12
+ def create(plain)
13
+ config = Auth.config
14
+ Argon2::Password.new(t_cost: config.argon2_t_cost, m_cost: config.argon2_m_cost, p_cost: 1).create(plain)
15
+ end
16
+
17
+ def verify(plain, digest)
18
+ return false unless plain.is_a?(String) && digest.is_a?(String)
19
+ return false if plain.bytesize > 1024 # never spend hashing time on megabyte "passwords"
20
+
21
+ if digest.start_with?("$argon2")
22
+ Argon2::Password.verify_password(plain, digest)
23
+ elsif digest.match?(/\A\$2[aby]\$/)
24
+ bcrypt.new(digest) == plain
25
+ else
26
+ false
27
+ end
28
+ rescue Argon2::ArgonHashFail
29
+ false
30
+ end
31
+
32
+ # True for bcrypt hashes and Argon2 hashes weaker than the current settings.
33
+ def needs_rehash?(digest)
34
+ match = digest.to_s.match(/\A\$argon2id\$v=\d+\$m=(\d+),t=(\d+),p=\d+\$/) or return true
35
+ config = Auth.config
36
+ match[1].to_i < (1 << config.argon2_m_cost) || match[2].to_i < config.argon2_t_cost
37
+ end
38
+
39
+ # Spends the same time as a real verification, so "no such user" and
40
+ # "wrong password" can't be told apart by timing.
41
+ def verify_dummy(plain)
42
+ @dummy ||= create(SecureRandom.hex(16))
43
+ verify(plain.to_s[0, 1024], @dummy)
44
+ false
45
+ end
46
+
47
+ # Validation messages for a new password (length only, per NIST SP 800-63B).
48
+ def errors(plain)
49
+ config = Auth.config
50
+ length = plain.to_s.length
51
+ if plain.to_s.strip.empty? then ["is required"]
52
+ elsif length < config.password_min_length
53
+ ["is too short (minimum #{config.password_min_length} characters)"]
54
+ elsif length > config.password_max_length
55
+ ["is too long (maximum #{config.password_max_length} characters)"]
56
+ else []
57
+ end
58
+ end
59
+
60
+ def reset_dummy! = @dummy = nil
61
+
62
+ private
63
+
64
+ def bcrypt
65
+ require "bcrypt"
66
+ BCrypt::Password
67
+ rescue LoadError
68
+ raise ConfigurationError, "this password hash is bcrypt; add gem \"bcrypt\" to verify it"
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module GemStack
6
+ module Auth
7
+ # Fixed-window rate limits for controller actions, counted in
8
+ # GemStack.cache (use a shared store — :redis — with several servers).
9
+ #
10
+ # rate_limit to: 10, within: 60, only: :create # per client IP
11
+ # rate_limit to: 3, within: 3600, only: :create, name: "forgot-email",
12
+ # by: -> { params[:email].to_s.downcase }
13
+ #
14
+ # Over the limit: 429 with Retry-After.
15
+ module RateLimit
16
+ def self.included(base) = base.extend(ClassMethods)
17
+
18
+ module ClassMethods
19
+ def rate_limit(to:, within:, by: -> { request.ip }, only: nil, except: nil, name: nil)
20
+ limit = Integer(to)
21
+ period = Integer(within)
22
+ before(only: only, except: except) { enforce_rate_limit(limit, period, by, name) }
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def enforce_rate_limit(limit, period, by, name)
29
+ subject = instance_exec(&by).to_s
30
+ return if subject.empty?
31
+
32
+ now = Time.now.to_i
33
+ key = ["rate-limit", name || "#{self.class.name}##{action_name}", Digest::SHA256.hexdigest(subject)[0, 32],
34
+ now / period]
35
+ return if GemStack.cache.increment(key, expires_in: period) <= limit
36
+
37
+ retry_after = period - (now % period)
38
+ raise TooManyRequests.new("Too many attempts. Try again in #{retry_after} seconds.",
39
+ code: "rate_limited", headers: { "retry-after" => retry_after.to_s })
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemStack
4
+ module Auth
5
+ # Browser sessions, one row per signed-in device in the `sessions` table.
6
+ # Being rows (not signed cookies), they can be listed and revoked:
7
+ # signing out everywhere or resetting a password really ends them.
8
+ module Sessions
9
+ class << self
10
+ def dataset = Auth.db[:sessions]
11
+
12
+ # Returns the token to put in the cookie.
13
+ def create(user_id, ip: nil, user_agent: nil)
14
+ token = Token.generate
15
+ now = Time.now
16
+ dataset.insert(token_digest: Token.digest(token), user_id: user_id, ip: ip,
17
+ user_agent: user_agent&.slice(0, 255), created_at: now, last_seen_at: now,
18
+ expires_at: now + Auth.config.session_ttl)
19
+ token
20
+ end
21
+
22
+ # The live session for a token, or nil. Extends the expiry (at most
23
+ # once per session_touch_interval); row[:touched] tells the caller to
24
+ # re-send the cookie with the new expiry.
25
+ def find(token)
26
+ return nil unless Token.plausible?(token)
27
+
28
+ now = Time.now
29
+ row = dataset.where(token_digest: Token.digest(token)).where { expires_at > now }.first or return nil
30
+ if row[:last_seen_at] <= now - Auth.config.session_touch_interval
31
+ expires = now + Auth.config.session_ttl
32
+ dataset.where(id: row[:id]).update(last_seen_at: now, expires_at: expires)
33
+ row = row.merge(last_seen_at: now, expires_at: expires, touched: true)
34
+ end
35
+ row
36
+ end
37
+
38
+ def revoke(token) = Token.plausible?(token) ? dataset.where(token_digest: Token.digest(token)).delete : 0
39
+
40
+ # Ends every session of a user (optionally keeping one, by id).
41
+ def revoke_all(user_id, except: nil)
42
+ scope = dataset.where(user_id: user_id)
43
+ scope = scope.exclude(id: except) if except
44
+ scope.delete
45
+ end
46
+
47
+ def for_user(user_id)
48
+ dataset.where(user_id: user_id).where { expires_at > Time.now }.order(Sequel.desc(:last_seen_at)).all
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "gemstack/auth"
4
+
5
+ module GemStack
6
+ module Auth
7
+ # Test helpers (added to GemStack::TestCase by `gemstack add auth`):
8
+ #
9
+ # sign_in_as(user) # later requests carry the session cookie
10
+ # get_json "/api/auth/me"
11
+ # get_json "/api/orders", {}, bearer_headers(user)
12
+ module Testing
13
+ def sign_in_as(user)
14
+ token = Sessions.create(user.id, ip: "127.0.0.1", user_agent: "test")
15
+ set_cookie("#{Auth.config.cookie_name}=#{token}")
16
+ token
17
+ end
18
+
19
+ def api_token_for(user, name: "test") = Tokens.issue(user.id, purpose: "api", name: name).first
20
+
21
+ def bearer_headers(user) = { "HTTP_AUTHORIZATION" => "Bearer #{api_token_for(user)}" }
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemStack
4
+ module Auth
5
+ # Tokens in the `auth_tokens` table:
6
+ # "api" long-lived bearer tokens (Authorization: Bearer gs_…)
7
+ # "password_reset" single use, short-lived, emailed
8
+ # "email_verification" single use, emailed, bound to the address it was sent to
9
+ module Tokens
10
+ PURPOSES = %w[api password_reset email_verification].freeze
11
+ PREFIX = { "api" => "gs_" }.freeze
12
+ # last_used_at is written at most once a minute per token.
13
+ TOUCH_INTERVAL = 60
14
+
15
+ class << self
16
+ def dataset = Auth.db[:auth_tokens]
17
+
18
+ # Returns [token, row id]. The plain token is shown to the user once
19
+ # and never stored. Issuing a reset/verification token invalidates the
20
+ # user's earlier ones for the same purpose.
21
+ def issue(user_id, purpose:, name: nil, email: nil, expires_in: default_ttl(purpose))
22
+ check_purpose!(purpose)
23
+ dataset.where(user_id: user_id, purpose: purpose).delete unless purpose == "api"
24
+ token = Token.generate(PREFIX.fetch(purpose, ""))
25
+ now = Time.now
26
+ id = dataset.insert(user_id: user_id, purpose: purpose, token_digest: Token.digest(token), name: name,
27
+ email: email, created_at: now, expires_at: expires_in && (now + expires_in))
28
+ [token, id]
29
+ end
30
+
31
+ # The live token row, or nil. API tokens record when they were last used.
32
+ def find(token, purpose:)
33
+ check_purpose!(purpose)
34
+ return nil unless Token.plausible?(token)
35
+
36
+ now = Time.now
37
+ row = live(token, purpose, now).first or return nil
38
+ if purpose == "api" && (row[:last_used_at].nil? || row[:last_used_at] <= now - TOUCH_INTERVAL)
39
+ dataset.where(id: row[:id]).update(last_used_at: now)
40
+ end
41
+ row
42
+ end
43
+
44
+ # Uses up a single-use token: deletes and returns its row, atomically,
45
+ # so two concurrent requests can't both use one reset link.
46
+ def consume(token, purpose:)
47
+ check_purpose!(purpose)
48
+ return nil unless Token.plausible?(token)
49
+
50
+ live(token, purpose, Time.now).returning.delete.first
51
+ end
52
+
53
+ def revoke(id, user_id:) = dataset.where(id: id, user_id: user_id).delete.positive?
54
+ def revoke_all(user_id, purpose:) = dataset.where(user_id: user_id, purpose: purpose).delete
55
+
56
+ def for_user(user_id, purpose: "api")
57
+ dataset.where(user_id: user_id, purpose: purpose).order(Sequel.desc(:created_at))
58
+ .select(:id, :name, :created_at, :last_used_at, :expires_at).all
59
+ end
60
+
61
+ private
62
+
63
+ def live(token, purpose, now)
64
+ dataset.where(token_digest: Token.digest(token), purpose: purpose)
65
+ .where(Sequel.|({ expires_at: nil }, Sequel[:expires_at] > now))
66
+ end
67
+
68
+ def default_ttl(purpose)
69
+ config = Auth.config
70
+ { "api" => config.api_token_ttl, "password_reset" => config.password_reset_ttl,
71
+ "email_verification" => config.email_verification_ttl }.fetch(purpose)
72
+ end
73
+
74
+ def check_purpose!(purpose)
75
+ raise ArgumentError, "unknown token purpose #{purpose.inspect}" unless PURPOSES.include?(purpose)
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemStack
4
+ module Auth
5
+ # Password and email behaviour for the user model:
6
+ #
7
+ # class User < GemStack::Model
8
+ # include GemStack::Auth::User
9
+ # end
10
+ #
11
+ # User.create(email: "Ada@Example.com", password: "correct horse battery")
12
+ # User.authenticate_by(email: "ada@example.com", password: "…") # => user or nil
13
+ #
14
+ # Needs the columns email (unique) and password_digest; email_verified_at
15
+ # for verification.
16
+ module User
17
+ EMAIL_FORMAT = /\A[^@\s]+@[^@\s.]+(\.[^@\s.]+)+\z/
18
+
19
+ def self.included(model) = model.extend(ClassMethods)
20
+
21
+ module ClassMethods
22
+ def normalize_email(email) = email.to_s.unicode_normalize(:nfkc).strip.downcase
23
+
24
+ # The user when the password matches; nil otherwise, in about the same
25
+ # time whether or not the email exists.
26
+ def authenticate_by(email:, password:)
27
+ user = first(email: normalize_email(email))
28
+ return user if user&.authenticate(password)
29
+
30
+ Password.verify_dummy(password) unless user
31
+ nil
32
+ end
33
+ end
34
+
35
+ attr_reader :password
36
+
37
+ def email=(value)
38
+ self[:email] = value.nil? ? nil : self.class.normalize_email(value)
39
+ end
40
+
41
+ # Hashed right away (when the length is acceptable); the plain text is
42
+ # kept in memory only for validation.
43
+ def password=(plain)
44
+ @password = plain
45
+ self[:password_digest] = Password.errors(plain).empty? ? Password.create(plain) : nil
46
+ end
47
+
48
+ # Verifies a password; upgrades the stored hash when the settings changed.
49
+ def authenticate(plain)
50
+ return false unless Password.verify(plain, self[:password_digest])
51
+
52
+ if Password.needs_rehash?(self[:password_digest])
53
+ digest = Password.create(plain)
54
+ this.update(password_digest: digest)
55
+ self[:password_digest] = digest
56
+ end
57
+ true
58
+ end
59
+
60
+ def email_verified? = !self[:email_verified_at].nil?
61
+
62
+ def validate
63
+ super
64
+ errors.add(:email, "is invalid") unless self[:email].to_s.match?(EMAIL_FORMAT) || errors.on(:email)
65
+ Password.errors(@password).each { |message| errors.add(:password, message) } if new? || !@password.nil?
66
+ return unless self[:email] && (new? || changed_columns.include?(:email))
67
+
68
+ validates_unique(:email,
69
+ message: "is already taken")
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "securerandom"
5
+ require "gemstack/core"
6
+ require "gemstack/cache"
7
+ require "gemstack/http"
8
+ require "gemstack/db"
9
+ require "gemstack/mail"
10
+
11
+ module GemStack
12
+ # Authentication (DECISIONS D-048..D-052): Argon2id passwords, database
13
+ # sessions in an HttpOnly cookie for the Next.js frontend, and bearer API
14
+ # tokens for scripts and other services. `gemstack add auth` generates the
15
+ # tables, the User model, the controllers, the emails and the Next.js pages
16
+ # that use this module — all of it application code you can change.
17
+ module Auth
18
+ class Config < Settings
19
+ # The model that includes GemStack::Auth::User.
20
+ setting :user_class, default: "User"
21
+ # Sessions expire after this many seconds without use (sliding).
22
+ setting :session_ttl, default: 30 * 24 * 3600
23
+ # last_seen_at / expiry are updated at most this often (one UPDATE per session per interval).
24
+ setting :session_touch_interval, default: 300
25
+ # Secure cookies are HTTPS-only; local development runs on http://localhost.
26
+ setting :cookie_secure, default: -> { !GemStack.env.local? }
27
+ # The __Host- prefix makes browsers refuse the cookie unless it is Secure,
28
+ # host-only and Path=/ — no subdomain can set or overwrite it.
29
+ setting :cookie_name, default: -> { cookie_secure ? "__Host-session" : "gemstack_session" }
30
+ setting :cookie_same_site, default: :lax
31
+ setting :password_reset_ttl, default: 3600
32
+ setting :email_verification_ttl, default: 3 * 24 * 3600
33
+ # nil: API tokens don't expire (they can be revoked); or seconds.
34
+ setting :api_token_ttl, default: nil
35
+ setting :password_min_length, default: 12
36
+ setting :password_max_length, default: 128
37
+ # Argon2id cost: t = iterations, m = log2(memory KiB). 2 / 2^15 (32 MiB)
38
+ # takes ~35 ms per hash on a 2024 laptop (OWASP's minimum is 19 MiB).
39
+ setting :argon2_t_cost, default: -> { GemStack.env.test? ? 1 : 2 }
40
+ setting :argon2_m_cost, default: -> { GemStack.env.test? ? 8 : 15 }
41
+ # Other origins allowed to make cookie-authenticated, state-changing
42
+ # requests (e.g. "https://admin.example.com"). Same-origin always works.
43
+ setting :trusted_origins, default: []
44
+ # Where the frontend lives, for links in emails.
45
+ setting :app_url, default: -> { ENV.fetch("APP_URL", "http://localhost:#{ENV.fetch("PORT", "3000")}") }
46
+ end
47
+
48
+ class << self
49
+ def config = GemStack.config.auth
50
+
51
+ def user_class
52
+ name = config.user_class
53
+ name.is_a?(String) ? Object.const_get(name) : name
54
+ end
55
+
56
+ def db = DB.connection
57
+
58
+ # Deletes expired sessions and tokens; run it daily from a job.
59
+ def cleanup!
60
+ now = Time.now
61
+ { sessions: db[:sessions].where { expires_at <= now }.delete,
62
+ auth_tokens: db[:auth_tokens].exclude(expires_at: nil).where { expires_at <= now }.delete }
63
+ end
64
+
65
+ # A link into the frontend: Auth.url("/reset-password", token: t)
66
+ def url(path, **query)
67
+ base = config.app_url.to_s.chomp("/")
68
+ query.empty? ? "#{base}#{path}" : "#{base}#{path}?#{URI.encode_www_form(query)}"
69
+ end
70
+ end
71
+
72
+ # Opaque random tokens; only their SHA-256 digests are stored, so a
73
+ # database leak doesn't hand out working sessions or reset links.
74
+ module Token
75
+ def self.generate(prefix = "") = "#{prefix}#{SecureRandom.urlsafe_base64(32)}"
76
+ def self.digest(token) = OpenSSL::Digest::SHA256.hexdigest(token.to_s)
77
+
78
+ # Tokens are 43–50 characters; anything else is not worth a query.
79
+ def self.plausible?(token) = token.is_a?(String) && token.bytesize.between?(20, 200)
80
+ end
81
+ end
82
+ end
83
+
84
+ require_relative "auth/password"
85
+ require_relative "auth/sessions"
86
+ require_relative "auth/tokens"
87
+ require_relative "auth/user"
88
+ require_relative "auth/rate_limit"
89
+ require_relative "auth/controller"
90
+ require_relative "policy"
91
+
92
+ GemStack::Config.namespace(:auth, GemStack::Auth::Config)
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemStack
4
+ # Authorization rules, one policy class per model (DECISIONS D-052):
5
+ #
6
+ # class OrderPolicy < GemStack::Policy
7
+ # def show? = owner? || user&.admin?
8
+ # def update? = owner?
9
+ #
10
+ # class Scope < Scope
11
+ # def resolve = user&.admin? ? scope : scope.where(user_id: user&.id)
12
+ # end
13
+ #
14
+ # private
15
+ #
16
+ # def owner? = user && record.user_id == user.id
17
+ # end
18
+ #
19
+ # class OrdersController < ApplicationController
20
+ # def index = render(paginate(policy_scope(Order.order(:id))))
21
+ # def show = render(authorize!(Order.find(params[:id]))) # OrderPolicy#show?
22
+ # end
23
+ #
24
+ # Everything is denied unless a policy method says otherwise.
25
+ class Policy
26
+ class NotDefined < Error; end
27
+
28
+ attr_reader :user, :record
29
+
30
+ def initialize(user, record)
31
+ @user = user
32
+ @record = record
33
+ end
34
+
35
+ def index? = false
36
+ def show? = false
37
+ def create? = false
38
+ def update? = false
39
+ def destroy? = false
40
+
41
+ # Narrows a dataset to the records the user may see.
42
+ class Scope
43
+ attr_reader :user, :scope
44
+
45
+ def initialize(user, scope)
46
+ @user = user
47
+ @scope = scope
48
+ end
49
+
50
+ def resolve
51
+ raise NotDefined, "#{self.class.name}#resolve is not defined; it decides which records a user may list"
52
+ end
53
+ end
54
+
55
+ # The policy class for a record, a model class or a dataset: Order,
56
+ # Order.where(...) and an order all use OrderPolicy.
57
+ def self.for(subject)
58
+ model = if subject.is_a?(Class) then subject
59
+ elsif subject.respond_to?(:model) && subject.model.is_a?(Class) then subject.model
60
+ else subject.class
61
+ end
62
+ name = "#{model.name}Policy"
63
+ Object.const_get(name)
64
+ rescue NameError => e
65
+ raise unless e.name.to_s == name.split("::").last || e.name.to_s == name
66
+
67
+ raise NotDefined, "#{name} is not defined (create app/policies/#{Inflector.underscore(name)}.rb " \
68
+ "or run `gemstack generate policy #{model.name}`)"
69
+ end
70
+
71
+ # Controller helpers (included with GemStack::Auth::Controller by `gemstack add auth`).
72
+ module Authorization
73
+ # Returns the record when allowed; 403 otherwise. The rule defaults to
74
+ # "<action>?" (show → show?); a custom action maps to its own name.
75
+ def authorize!(record, rule = nil, policy: nil)
76
+ rule ||= :"#{action_name}?"
77
+ allowed = (policy || Policy.for(record)).new(policy_user, record).public_send(rule)
78
+ return record if allowed
79
+
80
+ raise Forbidden.new("You are not allowed to do that.", code: "forbidden")
81
+ end
82
+
83
+ def policy_scope(scope, policy: nil) = (policy || Policy.for(scope))::Scope.new(policy_user, scope).resolve
84
+
85
+ def policy(record) = Policy.for(record).new(policy_user, record)
86
+
87
+ private
88
+
89
+ def policy_user = respond_to?(:current_user) ? current_user : nil
90
+ end
91
+ end
92
+ end
metadata ADDED
@@ -0,0 +1,140 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: gemstack-auth
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Shoaib Malik
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: argon2
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '2.3'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '2.3'
26
+ - !ruby/object:Gem::Dependency
27
+ name: gemstack-cache
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - '='
31
+ - !ruby/object:Gem::Version
32
+ version: 0.1.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.1.0
40
+ - !ruby/object:Gem::Dependency
41
+ name: gemstack-core
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - '='
45
+ - !ruby/object:Gem::Version
46
+ version: 0.1.0
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - '='
52
+ - !ruby/object:Gem::Version
53
+ version: 0.1.0
54
+ - !ruby/object:Gem::Dependency
55
+ name: gemstack-db
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - '='
59
+ - !ruby/object:Gem::Version
60
+ version: 0.1.0
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - '='
66
+ - !ruby/object:Gem::Version
67
+ version: 0.1.0
68
+ - !ruby/object:Gem::Dependency
69
+ name: gemstack-http
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - '='
73
+ - !ruby/object:Gem::Version
74
+ version: 0.1.0
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - '='
80
+ - !ruby/object:Gem::Version
81
+ version: 0.1.0
82
+ - !ruby/object:Gem::Dependency
83
+ name: gemstack-mail
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - '='
87
+ - !ruby/object:Gem::Version
88
+ version: 0.1.0
89
+ type: :runtime
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - '='
94
+ - !ruby/object:Gem::Version
95
+ version: 0.1.0
96
+ email:
97
+ - gemstack26@gmail.com
98
+ executables: []
99
+ extensions: []
100
+ extra_rdoc_files: []
101
+ files:
102
+ - CHANGELOG.md
103
+ - LICENSE.txt
104
+ - README.md
105
+ - lib/gemstack/auth.rb
106
+ - lib/gemstack/auth/controller.rb
107
+ - lib/gemstack/auth/password.rb
108
+ - lib/gemstack/auth/rate_limit.rb
109
+ - lib/gemstack/auth/sessions.rb
110
+ - lib/gemstack/auth/testing.rb
111
+ - lib/gemstack/auth/tokens.rb
112
+ - lib/gemstack/auth/user.rb
113
+ - lib/gemstack/policy.rb
114
+ homepage: https://github.com/gemstack-rb/gemstack
115
+ licenses:
116
+ - MIT
117
+ metadata:
118
+ rubygems_mfa_required: 'true'
119
+ source_code_uri: https://github.com/gemstack-rb/gemstack/tree/main/gems/gemstack-auth
120
+ changelog_uri: https://github.com/gemstack-rb/gemstack/blob/main/gems/gemstack-auth/CHANGELOG.md
121
+ bug_tracker_uri: https://github.com/gemstack-rb/gemstack/issues
122
+ documentation_uri: https://github.com/gemstack-rb/gemstack/tree/main/docs
123
+ rdoc_options: []
124
+ require_paths:
125
+ - lib
126
+ required_ruby_version: !ruby/object:Gem::Requirement
127
+ requirements:
128
+ - - ">="
129
+ - !ruby/object:Gem::Version
130
+ version: '4.0'
131
+ required_rubygems_version: !ruby/object:Gem::Requirement
132
+ requirements:
133
+ - - ">="
134
+ - !ruby/object:Gem::Version
135
+ version: '0'
136
+ requirements: []
137
+ rubygems_version: 4.0.20
138
+ specification_version: 4
139
+ summary: 'GemStack auth: Argon2id passwords, cookie sessions, API tokens, policies'
140
+ test_files: []