ask-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: b1cfb133ab080dc2840a380b4039d783fa982a676c05b3b10cb597264d0da899
4
+ data.tar.gz: 042e1afabf24c3543eef2cab5e54c8918e97c5bbccf489c8af4f1fd1211e3739
5
+ SHA512:
6
+ metadata.gz: ee48024db5d7ffaf38afa8244017202c42b917aa8233bd600f0895bc100e32ac5d1089067666eb6ee8fb159d59c88e31236daf9fdca4ef085078a2ecb0f07e9e
7
+ data.tar.gz: 9d6ba6e6f408b522f57da8793638812105dd3c0b691f24a98ff29ff316e31b82078d9ea84f77a1a71be48e8f360b1edb22f07b674138b29c1aa227e0b585c33d
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kaka Ruto
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,182 @@
1
+ # ask-auth
2
+
3
+ **Credential resolution for the ask-rb ecosystem.**
4
+
5
+ A single API for resolving credentials across all ask-rb gems. Service gems call `Ask::Auth.resolve(:github_token)` — they never touch env vars, files, or OAuth flows directly. The resolution chain walks configured providers in order and returns the first match.
6
+
7
+ Zero external dependencies for the core. Optional ActiveRecord integration for database-backed token storage.
8
+
9
+ ## Installation
10
+
11
+ Add this line to your Gemfile:
12
+
13
+ ```ruby
14
+ gem "ask-auth"
15
+ ```
16
+
17
+ Or install it directly:
18
+
19
+ ```bash
20
+ gem install ask-auth
21
+ ```
22
+
23
+ ## Quick Start
24
+
25
+ ```ruby
26
+ require "ask-auth"
27
+
28
+ # Simple — works everywhere, no config needed
29
+ token = Ask::Auth.resolve(:github_token)
30
+ # => "ghp_abc123..."
31
+
32
+ # With a user context (for per-user providers like Database)
33
+ token = Ask::Auth.resolve(:openai_api_key, user: current_user)
34
+ ```
35
+
36
+ By default, the resolution chain checks these providers in order:
37
+
38
+ 1. **Env** — environment variables
39
+ 2. **File** — `~/.ask/credentials.yml`
40
+ 3. **RailsCredentials** — `Rails.application.credentials` (if Rails is loaded)
41
+ 4. **Database** — ActiveRecord-backed token storage (if ActiveRecord is loaded)
42
+ 5. **OAuth** — interactive PKCE flow (returns nil by default — requires explicit authorization)
43
+
44
+ ## Configuration
45
+
46
+ Customize the provider chain with `Ask::Auth.configure`:
47
+
48
+ ```ruby
49
+ Ask::Auth.configure do |c|
50
+ c.providers = [
51
+ Ask::Auth::Providers::Env.new,
52
+ Ask::Auth::Providers::File.new(path: "~/.myapp/creds.yml"),
53
+ Ask::Auth::Providers::Database.new(model: AccessToken)
54
+ ]
55
+ end
56
+ ```
57
+
58
+ Once configured, the resolution chain is frozen and thread-safe.
59
+
60
+ ## Providers
61
+
62
+ ### Env (`Ask::Auth::Providers::Env`)
63
+
64
+ Resolves credentials from environment variables by convention. Tries multiple naming styles:
65
+
66
+ ```ruby
67
+ Ask::Auth.resolve(:github_token)
68
+ # Checks (in order): ENV["GITHUB_TOKEN"], ENV["GITHUBTOKEN"], ENV["github_token"]
69
+ ```
70
+
71
+ No configuration needed.
72
+
73
+ ### File (`Ask::Auth::Providers::File`)
74
+
75
+ Reads credentials from a YAML file:
76
+
77
+ ```yaml
78
+ # ~/.ask/credentials.yml
79
+ github_token: ghp_abc123...
80
+ openai_api_key: sk-...
81
+ ```
82
+
83
+ ```ruby
84
+ Ask::Auth::Providers::File.new
85
+ Ask::Auth::Providers::File.new(path: "~/.custom/credentials.yml")
86
+ ```
87
+
88
+ The file is created with `0600` permissions when written (the provider is read-only; use your editor or a setup script).
89
+
90
+ ### RailsCredentials (`Ask::Auth::Providers::RailsCredentials`)
91
+
92
+ Wraps `Rails.application.credentials`. Converts `snake_case` names to dot-separated paths:
93
+
94
+ ```ruby
95
+ Ask::Auth.resolve(:github_token)
96
+ # Looks up: Rails.application.credentials.github.token
97
+ ```
98
+
99
+ Safely returns nil when Rails is not loaded.
100
+
101
+ ### Database (`Ask::Auth::Providers::Database`)
102
+
103
+ ActiveRecord-backed token storage per user. Expects a model with `user_id`, `name`, `token`, `expires_at`, and `refresh_token` columns.
104
+
105
+ ```ruby
106
+ # app/models/credential.rb
107
+ class Credential < ApplicationRecord
108
+ belongs_to :user
109
+ end
110
+ ```
111
+
112
+ ```ruby
113
+ Ask::Auth::Providers::Database.new
114
+ Ask::Auth::Providers::Database.new(model: AccessToken)
115
+ ```
116
+
117
+ Handles token expiry automatically: calls `refresh!` when a token has expired and a `refresh_token` is available.
118
+
119
+ ### OAuth (`Ask::Auth::Providers::OAuth`)
120
+
121
+ PKCE OAuth flow for interactive credential authorization. This provider does not resolve automatically — it provides the authorization interface:
122
+
123
+ ```ruby
124
+ provider = Ask::Auth::Providers::OAuth.new(
125
+ client_id: "your-client-id",
126
+ authorize_url: "https://provider.com/oauth/authorize",
127
+ token_url: "https://provider.com/oauth/token"
128
+ )
129
+
130
+ # Step 1: Generate the authorization URL
131
+ url = provider.authorize_url(user: current_user)
132
+
133
+ # Step 2: Exchange the code for tokens
134
+ provider.authorize!(user: current_user, code: params[:code])
135
+ ```
136
+
137
+ The full token exchange flow requires configuration. The PKCE utility methods (`generate_code_verifier`, `generate_code_challenge`) are ready for use.
138
+
139
+ ## Custom Providers
140
+
141
+ Any object that responds to `call(name, user:)` can be a provider:
142
+
143
+ ```ruby
144
+ Ask::Auth.configure do |c|
145
+ c.providers = [
146
+ Ask::Auth::Providers::Env.new,
147
+ ->(name, user: nil) { user&.api_key_for(name) }
148
+ ]
149
+ end
150
+ ```
151
+
152
+ ## Error Handling
153
+
154
+ ```ruby
155
+ begin
156
+ token = Ask::Auth.resolve(:missing_key)
157
+ rescue Ask::Auth::MissingCredential => e
158
+ puts e.message
159
+ # "No credential found for :missing_key. Set MISSING_KEY in your environment..."
160
+ end
161
+
162
+ begin
163
+ # At usage time
164
+ raise Ask::Auth::InvalidCredential.new(:github_token, "rate limited")
165
+ rescue Ask::Auth::InvalidCredential => e
166
+ puts e.message
167
+ # "Credential :github_token is rate limited..."
168
+ end
169
+ ```
170
+
171
+ ## Development
172
+
173
+ ```bash
174
+ git clone https://github.com/ask-rb/ask-auth
175
+ cd ask-auth
176
+ bin/setup
177
+ bundle exec rake test
178
+ ```
179
+
180
+ ## License
181
+
182
+ MIT
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Auth
5
+ module Providers
6
+ # ActiveRecord-backed token storage per user.
7
+ #
8
+ # Expects a model with +user_id+, +name+, +token+, +expires_at+, +refresh_token+.
9
+ # Handles expiry: if the token has expired and a refresh token is available,
10
+ # calls +refresh!+ using the stored refresh token.
11
+ #
12
+ # Only used when a model is configured or available. Safely returns nil otherwise.
13
+ class Database
14
+ # The model class used for credential storage.
15
+ attr_reader :model
16
+
17
+ def initialize(model: default_model)
18
+ @model = model
19
+ end
20
+
21
+ def call(name, user: nil)
22
+ return nil unless @model
23
+ return nil unless user.respond_to?(:id)
24
+
25
+ record = @model.respond_to?(:find_by) ? @model.find_by(user_id: user.id, name: name.to_s) : nil
26
+ return nil unless record
27
+
28
+ if record.respond_to?(:expired?) && record.expired?
29
+ return nil unless record.respond_to?(:refresh!)
30
+
31
+ record.refresh!
32
+ record.reload if record.respond_to?(:reload)
33
+ end
34
+
35
+ record.respond_to?(:token) ? record.token : record
36
+ end
37
+
38
+ private
39
+
40
+ def default_model
41
+ defined?(::ActiveRecord) && defined?(::Credential) ? ::Credential : nil
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Auth
5
+ module Providers
6
+ # Resolves credentials from environment variables.
7
+ #
8
+ # Conventions tested (in order):
9
+ # resolve(:github_token) -> ENV["GITHUB_TOKEN"], ENV["GITHUBTOKEN"], ENV["github_token"]
10
+ #
11
+ # No configuration needed — just a convention.
12
+ class Env
13
+ # Returns the credential value from ENV, or nil if not found.
14
+ def call(name, user: nil)
15
+ conventions(name).each do |key|
16
+ value = ENV[key.to_s]
17
+ return value unless value.nil?
18
+ end
19
+ nil
20
+ end
21
+
22
+ private
23
+
24
+ def conventions(name)
25
+ name = name.to_s
26
+ [
27
+ name.upcase,
28
+ name.upcase.delete("_"),
29
+ name
30
+ ].uniq
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "fileutils"
5
+
6
+ module Ask
7
+ module Auth
8
+ module Providers
9
+ # Reads credentials from a YAML file (default: +~/.ask/credentials.yml+).
10
+ #
11
+ # Ask::Auth::Providers::File.new(path: "~/.ask/credentials.yml")
12
+ #
13
+ # The file is automatically created (with 0600 permissions) when writing,
14
+ # but this provider is read-only by design.
15
+ class File
16
+ DEFAULT_PATH = "~/.ask/credentials.yml"
17
+
18
+ def initialize(path: DEFAULT_PATH)
19
+ @path = path
20
+ end
21
+
22
+ # Returns the credential value from the YAML file, or nil.
23
+ def call(name, user: nil)
24
+ data = load_file
25
+ return nil unless data
26
+
27
+ value = data[name.to_s] || data[name.to_sym]
28
+ value
29
+ end
30
+
31
+ # The path this provider reads from.
32
+ attr_reader :path
33
+
34
+ private
35
+
36
+ def load_file
37
+ expanded = ::File.expand_path(@path)
38
+ return nil unless ::File.exist?(expanded)
39
+
40
+ YAML.safe_load(::File.read(expanded), permitted_classes: [Symbol]) || {}
41
+ rescue Psych::SyntaxError
42
+ nil
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "openssl"
5
+ require "securerandom"
6
+
7
+ module Ask
8
+ module Auth
9
+ module Providers
10
+ # PKCE OAuth flow for interactive credential authorization.
11
+ #
12
+ # This provider implements the OAuth interface. The full interactive flow
13
+ # can be deferred — the interface is wired for integration and the PKCE
14
+ # utility methods are ready.
15
+ #
16
+ # provider = Ask::Auth::Providers::OAuth.new(storage: database_provider)
17
+ # url = provider.authorize_url(user: current_user)
18
+ # # redirect user to url, then:
19
+ # provider.authorize!(user: current_user, code: params[:code])
20
+ class OAuth
21
+ def initialize(storage: nil, client_id: nil, authorize_url: nil, token_url: nil)
22
+ @storage = storage
23
+ @client_id = client_id
24
+ @authorize_url = authorize_url
25
+ @token_url = token_url
26
+ end
27
+
28
+ # Returns nil (no automatic resolution) — OAuth requires interactive flow.
29
+ def call(name, user: nil)
30
+ nil
31
+ end
32
+
33
+ # Generate a PKCE code verifier (128-char alphanumeric string).
34
+ def generate_code_verifier
35
+ SecureRandom.alphanumeric(128)
36
+ end
37
+
38
+ # Generate a PKCE code challenge (SHA256 base64 digest of verifier).
39
+ def generate_code_challenge(verifier)
40
+ ::Base64.urlsafe_encode64(
41
+ OpenSSL::Digest.digest("SHA256", verifier),
42
+ padding: false
43
+ )
44
+ end
45
+
46
+ # Returns the authorization URL to redirect the user to.
47
+ def authorize_url(user:, verifier: nil, state: nil)
48
+ verifier ||= generate_code_verifier
49
+ state ||= SecureRandom.hex(16)
50
+ challenge = generate_code_challenge(verifier)
51
+
52
+ # Store state and verifier for this user (requires a storage provider)
53
+ if @storage && user
54
+ @storage.call(:oauth_state, user: user)
55
+ end
56
+
57
+ uri = URI.parse(@authorize_url || "https://example.com/oauth/authorize")
58
+ uri.query = URI.encode_www_form(
59
+ response_type: "code",
60
+ client_id: @client_id || "YOUR_CLIENT_ID",
61
+ redirect_uri: "urn:ietf:wg:oauth:2.0:oob",
62
+ scope: "",
63
+ state: state,
64
+ code_challenge: challenge,
65
+ code_challenge_method: "S256"
66
+ )
67
+ uri.to_s
68
+ end
69
+
70
+ # Exchange an authorization code for tokens.
71
+ # +user+:: The user to associate the credential with
72
+ # +code+:: The authorization code from the redirect
73
+ def authorize!(user:, code:)
74
+ raise NotImplementedError, "Token exchange requires a configured token_url and client_id"
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Auth
5
+ module Providers
6
+ # Resolves credentials from Rails encrypted credentials.
7
+ #
8
+ # Convention: +resolve(:github_token)+ looks up +Rails.application.credentials.github.token+
9
+ # (dot-separated path from the credential name).
10
+ #
11
+ # Safely returns nil when Rails is not loaded.
12
+ class RailsCredentials
13
+ def call(name, user: nil)
14
+ return nil unless defined?(::Rails) && ::Rails.application.respond_to?(:credentials)
15
+
16
+ parts = name.to_s.split("_")
17
+ value = parts.reduce(::Rails.application.credentials) do |obj, part|
18
+ break nil unless obj.respond_to?(part)
19
+ obj.public_send(part)
20
+ end
21
+
22
+ value
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Auth
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
data/lib/ask/auth.rb ADDED
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "auth/version"
4
+ require_relative "auth/providers/env"
5
+ require_relative "auth/providers/file"
6
+ require_relative "auth/providers/rails_credentials"
7
+ require_relative "auth/providers/database"
8
+ require_relative "auth/providers/oauth"
9
+
10
+ module Ask
11
+ # Credential resolution for the ask-rb ecosystem.
12
+ #
13
+ # Resolves credentials by walking a configured chain of providers (Env → File →
14
+ # RailsCredentials → Database → OAuth) and returning the first match.
15
+ #
16
+ # Ask::Auth.resolve(:github_token)
17
+ # Ask::Auth.resolve(:openai_api_key, user: current_user)
18
+ #
19
+ module Auth
20
+ class MissingCredential < KeyError
21
+ def initialize(name)
22
+ super("No credential found for #{name.inspect}. " \
23
+ "Set #{name.to_s.upcase} in your environment, add it to ~/.ask/credentials.yml, " \
24
+ "or configure a provider.")
25
+ end
26
+ end
27
+
28
+ class InvalidCredential < RuntimeError
29
+ def initialize(name, reason = "invalid or expired")
30
+ super("Credential #{name.inspect} is #{reason}. " \
31
+ "Please update your token and try again.")
32
+ end
33
+ end
34
+
35
+ class << self
36
+ def configure
37
+ config = Configuration.new
38
+ yield config
39
+ @configuration = config
40
+ @configuration.freeze
41
+ end
42
+
43
+ def configuration
44
+ @configuration ||= default_configuration
45
+ end
46
+
47
+ def reset_configuration!
48
+ @configuration = nil
49
+ end
50
+
51
+ # Walk providers in order and return the first non-nil credential.
52
+ # +name+:: Symbol or String identifying the credential (e.g. +:github_token+)
53
+ # +user+:: Optional user record for per-user providers (Database, OAuth)
54
+ def resolve(name, user: nil)
55
+ name = name.to_s.strip
56
+ return nil if name.empty?
57
+
58
+ configuration.providers.each do |provider|
59
+ value = provider.call(name, user: user)
60
+ next if value.nil?
61
+
62
+ normalized = normalize(value)
63
+ return normalized unless normalized.nil?
64
+ end
65
+
66
+ raise MissingCredential, name
67
+ end
68
+
69
+ private
70
+
71
+ def default_configuration
72
+ Configuration.new(
73
+ providers: [
74
+ Providers::Env.new,
75
+ Providers::File.new,
76
+ Providers::RailsCredentials.new,
77
+ Providers::Database.new,
78
+ Providers::OAuth.new
79
+ ]
80
+ )
81
+ end
82
+
83
+ # Blank normalization: empty strings and whitespace-only normalize to nil
84
+ def normalize(value)
85
+ value = value.to_s.strip
86
+ value.empty? ? nil : value
87
+ end
88
+ end
89
+
90
+ # Holds configuration for the resolution chain.
91
+ class Configuration
92
+ attr_accessor :providers
93
+
94
+ def initialize(providers: [])
95
+ @providers = providers
96
+ end
97
+ end
98
+ end
99
+ end
data/lib/ask-auth.rb ADDED
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ask/auth"
metadata ADDED
@@ -0,0 +1,107 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ask-auth
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Kaka Ruto
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: base64
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.2'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.2'
26
+ - !ruby/object:Gem::Dependency
27
+ name: minitest
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '5.25'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '5.25'
40
+ - !ruby/object:Gem::Dependency
41
+ name: mocha
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '3.1'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '3.1'
54
+ - !ruby/object:Gem::Dependency
55
+ name: rake
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '13.0'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '13.0'
68
+ description: Env, file, Rails credentials, database, and OAuth providers. Zero external
69
+ dependencies.
70
+ email:
71
+ - kaka@myrrlabs.com
72
+ executables: []
73
+ extensions: []
74
+ extra_rdoc_files: []
75
+ files:
76
+ - LICENSE
77
+ - README.md
78
+ - lib/ask-auth.rb
79
+ - lib/ask/auth.rb
80
+ - lib/ask/auth/providers/database.rb
81
+ - lib/ask/auth/providers/env.rb
82
+ - lib/ask/auth/providers/file.rb
83
+ - lib/ask/auth/providers/oauth.rb
84
+ - lib/ask/auth/providers/rails_credentials.rb
85
+ - lib/ask/auth/version.rb
86
+ homepage: https://github.com/ask-rb/ask-auth
87
+ licenses:
88
+ - MIT
89
+ metadata: {}
90
+ rdoc_options: []
91
+ require_paths:
92
+ - lib
93
+ required_ruby_version: !ruby/object:Gem::Requirement
94
+ requirements:
95
+ - - ">="
96
+ - !ruby/object:Gem::Version
97
+ version: '3.2'
98
+ required_rubygems_version: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - ">="
101
+ - !ruby/object:Gem::Version
102
+ version: '0'
103
+ requirements: []
104
+ rubygems_version: 4.0.3
105
+ specification_version: 4
106
+ summary: Credential resolution for the ask-rb ecosystem
107
+ test_files: []