ask-auth 0.2.3 → 0.3.1
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 +4 -4
- data/CHANGELOG.md +54 -0
- data/README.md +16 -136
- data/lib/ask/auth/oauth/http.rb +30 -0
- data/lib/ask/auth/providers/device_oauth.rb +96 -0
- data/lib/ask/auth/providers/github_copilot.rb +28 -0
- data/lib/ask/auth/providers/oauth.rb +107 -25
- data/lib/ask/auth/providers/openai_codex.rb +97 -0
- data/lib/ask/auth/providers/xai.rb +26 -0
- data/lib/ask/auth/version.rb +1 -1
- data/lib/ask/auth.rb +10 -0
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d8434cd70339a093050b7c19364a628d9b8e56048f41c1bb492f6268ed672b39
|
|
4
|
+
data.tar.gz: d5c377bb5480fb7e19d1dca9a0ca46bc1a29d09ef4dc7d3fc3f216fa38342552
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7676f10e858addff8c2ab69db3223aea179e11016ce8faa44422432dfcc2e567ff772c9d04e037b8370d4a32b894c0094a5e3a23debfdd43d02bb877283c775f
|
|
7
|
+
data.tar.gz: cacdcf31dbe213784f73c9ca0a70b8b17e280cd13697fb07e4cbcf5ac99261c340ed24f0591b637584dcc6d32be10bf83eac713b89751511a82ff2e2e6ecdfd1
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,57 @@
|
|
|
1
|
+
## [0.3.1] — 2026-08-07
|
|
2
|
+
|
|
3
|
+
### Added
|
|
4
|
+
|
|
5
|
+
- **Device authorization grant (RFC 8628) OAuth providers.** `Ask::Auth::Providers::DeviceOAuth` base — `start_device_flow` (device + user code from the provider), `complete_device_flow` (long-poll the token endpoint; `PendingAuthorization` until the user authorizes) — plus two providers:
|
|
6
|
+
- `Ask::Auth::Providers::Xai` — xAI/Grok device OAuth (public Grok-CLI client, `auth.x.ai/oauth2`), mirroring opencode's xai.ts.
|
|
7
|
+
- `Ask::Auth::Providers::GithubCopilot` — GitHub Copilot device OAuth (`github.com/login/device/code`), mirroring opencode's copilot.ts. Note: Copilot's device flow is a gray area of GitHub's terms.
|
|
8
|
+
|
|
9
|
+
## [0.3.0] — 2026-08-07
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **OpenAI Codex OAuth provider — bring your ChatGPT subscription.** `Ask::Auth::Providers::OpenaiCodex` completes the PKCE flow against `auth.openai.com` (public Codex client id, `openid profile email offline_access` scope): `authorize_url` (with `id_token_add_organizations`), `authorize!` (code exchange → `{token, refresh_token, expires_at, account_id, raw}`), and `refresh(refresh_token:)`. Includes `.allowed_model?` tier filtering (gpt-5.4+ general + codex family; pro-reasoning and gpt-5.6 excluded) and account-id extraction from the id_token — mirroring opencode's codex.ts.
|
|
14
|
+
- **Completed OAuth base provider.** `Ask::Auth::Providers::OAuth` now implements the token exchange (`authorize!`) and refresh grants via an injectable stdlib HTTP layer, with optional verifier/state persistence through a `store`/`fetch`-responding storage object. `Ask::Auth::OAuthError` raised on transport/provider failures.
|
|
15
|
+
- `Ask::Auth::OAuth::HTTP` — dependency-free form-encoded POST for token endpoints (swappable in tests).
|
|
16
|
+
|
|
17
|
+
## [0.2.3] — 2026-07-18
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- **`Providers::File#call` and `Providers::Env#call` safely return nil for Array names** — When `resolve` passes Array path segments (e.g., `[:opencode, :api_key]`), providers that don't support nested lookups now return nil instead of crashing on `.to_sym`.
|
|
22
|
+
|
|
23
|
+
## [0.2.2] — 2026-07-18
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- **`Providers::RailsCredentials` checks value, not just `respond_to?`** — `ActiveSupport::OrderedOptions#respond_to?` returns `true` for any method name. The provider now checks the actual returned value before returning it.
|
|
28
|
+
|
|
29
|
+
## [0.2.1] — 2026-07-18
|
|
30
|
+
|
|
31
|
+
### Added
|
|
32
|
+
|
|
33
|
+
- **`resolve(*names)` supports Array path segments for nested lookups** — Callers can pass Symbol/String for flat lookup or an Array for path segments:
|
|
34
|
+
```ruby
|
|
35
|
+
Ask::Auth.resolve(:opencode_api_key)
|
|
36
|
+
Ask::Auth.resolve(:opencode_go_api_key, [:opencode, :api_key])
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## [0.2.0] — 2026-07-18
|
|
40
|
+
|
|
41
|
+
### Added
|
|
42
|
+
|
|
43
|
+
- **`resolve(*names)` now accepts multiple credential names** — Tries each name in order through the provider chain, returns the first match. Backward-compatible with single-name calls.
|
|
44
|
+
|
|
45
|
+
### Fixed
|
|
46
|
+
|
|
47
|
+
- **`MissingCredential` shows all tried names** — Error message lists all credential names attempted.
|
|
48
|
+
|
|
49
|
+
## [0.1.4] — 2026-07-18
|
|
50
|
+
|
|
51
|
+
### Added
|
|
52
|
+
|
|
53
|
+
- **`Providers::RailsCredentials` progressive split strategy** — Tries progressively shorter left splits for credential names like `nvidia_nim_api_key`.
|
|
54
|
+
|
|
1
55
|
## [0.1.1] - 2026-06-25
|
|
2
56
|
|
|
3
57
|
### Changed
|
data/README.md
CHANGED
|
@@ -1,49 +1,38 @@
|
|
|
1
1
|
# ask-auth
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://badge.fury.io/rb/ask-auth)
|
|
4
4
|
|
|
5
|
-
A single API for resolving credentials across all ask-rb gems
|
|
6
|
-
|
|
7
|
-
Zero external dependencies for the core. Optional ActiveRecord integration for database-backed token storage.
|
|
5
|
+
Credential resolution for the ask-rb ecosystem. A single API for resolving credentials across all ask-rb gems: service gems call `Ask::Auth.resolve(:github_token)` and never touch env vars, files, or OAuth flows directly. Zero external dependencies.
|
|
8
6
|
|
|
9
7
|
## Installation
|
|
10
8
|
|
|
11
|
-
Add this line to your Gemfile:
|
|
12
|
-
|
|
13
9
|
```ruby
|
|
14
10
|
gem "ask-auth"
|
|
15
11
|
```
|
|
16
12
|
|
|
17
|
-
Or install it directly:
|
|
18
|
-
|
|
19
|
-
```bash
|
|
20
|
-
gem install ask-auth
|
|
21
|
-
```
|
|
22
|
-
|
|
23
13
|
## Quick Start
|
|
24
14
|
|
|
25
15
|
```ruby
|
|
26
16
|
require "ask-auth"
|
|
27
17
|
|
|
28
|
-
# Simple — works everywhere, no config needed
|
|
29
18
|
token = Ask::Auth.resolve(:github_token)
|
|
30
19
|
# => "ghp_abc123..."
|
|
31
20
|
|
|
32
|
-
#
|
|
21
|
+
# Per-user credentials (Database, OAuth providers)
|
|
33
22
|
token = Ask::Auth.resolve(:openai_api_key, user: current_user)
|
|
34
23
|
```
|
|
35
24
|
|
|
36
|
-
|
|
25
|
+
`resolve` walks the provider chain in order and returns the first match, or raises `Ask::Auth::MissingCredential`. Multiple names can be given for fallbacks, and arrays of symbols resolve nested paths (e.g. `resolve([:opencode, :api_key])`).
|
|
37
26
|
|
|
38
|
-
|
|
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)
|
|
27
|
+
## Resolution chain
|
|
43
28
|
|
|
44
|
-
|
|
29
|
+
1. **Env**: environment variables (`ENV["GITHUB_TOKEN"]`)
|
|
30
|
+
2. **File**: `~/.ask/credentials.yml`
|
|
31
|
+
3. **RailsCredentials**: `Rails.application.credentials` (when Rails is loaded)
|
|
32
|
+
4. **Database**: ActiveRecord-backed per-user token storage (when ActiveRecord is loaded)
|
|
33
|
+
5. **OAuth**: interactive PKCE flow (returns nil by default, requires explicit authorization)
|
|
45
34
|
|
|
46
|
-
|
|
35
|
+
## Configuration
|
|
47
36
|
|
|
48
37
|
```ruby
|
|
49
38
|
Ask::Auth.configure do |c|
|
|
@@ -55,125 +44,16 @@ Ask::Auth.configure do |c|
|
|
|
55
44
|
end
|
|
56
45
|
```
|
|
57
46
|
|
|
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
|
|
47
|
+
Any object responding to `call(name, user:)` can act as a provider. Once configured, the resolution chain is frozen and thread-safe.
|
|
153
48
|
|
|
154
|
-
|
|
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
|
|
49
|
+
## Full documentation
|
|
161
50
|
|
|
162
|
-
|
|
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
|
-
```
|
|
51
|
+
The full ask-rb documentation lives at https://ask-rb.github.io/ask-docs. [ask-auth in depth](https://ask-rb.github.io/ask-docs/core/auth) covers each provider, the OAuth flow, and error handling. API reference: https://ask-rb.github.io/ask-docs/reference/api.
|
|
170
52
|
|
|
171
53
|
## Development
|
|
172
54
|
|
|
173
|
-
```
|
|
174
|
-
|
|
175
|
-
cd ask-auth
|
|
176
|
-
bin/setup
|
|
55
|
+
```
|
|
56
|
+
bundle install
|
|
177
57
|
bundle exec rake test
|
|
178
58
|
```
|
|
179
59
|
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module Ask
|
|
7
|
+
module Auth
|
|
8
|
+
module OAuth
|
|
9
|
+
# Minimal form-encoded POST for token endpoints — stdlib only, so
|
|
10
|
+
# ask-auth stays dependency-free. Swappable in tests.
|
|
11
|
+
module HTTP
|
|
12
|
+
OPEN_TIMEOUT = 10
|
|
13
|
+
READ_TIMEOUT = 30
|
|
14
|
+
|
|
15
|
+
def self.post_form(url, params, headers: {})
|
|
16
|
+
uri = URI(url)
|
|
17
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
18
|
+
http.open_timeout = OPEN_TIMEOUT
|
|
19
|
+
http.read_timeout = READ_TIMEOUT
|
|
20
|
+
http.use_ssl = uri.scheme == "https"
|
|
21
|
+
req = Net::HTTP::Post.new(uri)
|
|
22
|
+
req.set_form_data(params)
|
|
23
|
+
headers.each { |k, v| req[k] = v }
|
|
24
|
+
res = http.request(req)
|
|
25
|
+
[res.code.to_i, res.body.to_s]
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "oauth"
|
|
5
|
+
|
|
6
|
+
module Ask
|
|
7
|
+
module Auth
|
|
8
|
+
module Providers
|
|
9
|
+
# RFC 8628 device authorization grant (OAuth 2.0 device flow) — the
|
|
10
|
+
# headless-friendly alternative to authorization-code PKCE: no
|
|
11
|
+
# redirect_uri, no callback route. The user opens a URL on any
|
|
12
|
+
# device, types a short code, and we long-poll the token endpoint.
|
|
13
|
+
#
|
|
14
|
+
# provider = Ask::Auth::Providers::Xai.new
|
|
15
|
+
# device = provider.start_device_flow(user: current_user)
|
|
16
|
+
# # render device[:verification_uri] + device[:user_code]
|
|
17
|
+
# tokens = provider.complete_device_flow(user:, device_code: device[:device_code])
|
|
18
|
+
# # raises PendingAuthorization until the user authorizes
|
|
19
|
+
#
|
|
20
|
+
# Subclasses set client_id, token_url, device_code_url, and scope.
|
|
21
|
+
class DeviceOAuth < OAuth
|
|
22
|
+
DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"
|
|
23
|
+
|
|
24
|
+
# The user hasn't authorized yet — the caller should wait and retry.
|
|
25
|
+
class PendingAuthorization < StandardError; end
|
|
26
|
+
|
|
27
|
+
def initialize(storage: nil, client_id: nil, token_url: nil, device_code_url: nil,
|
|
28
|
+
scope: nil, http: Ask::Auth::OAuth::HTTP)
|
|
29
|
+
super(storage: storage, client_id: client_id, authorize_url: nil, token_url: token_url,
|
|
30
|
+
redirect_uri: nil, scope: scope, http: http)
|
|
31
|
+
@device_code_url = device_code_url
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Step 1: ask the provider for a device + user code.
|
|
35
|
+
# Returns { device_code:, user_code:, verification_uri:, interval:, expires_at: }.
|
|
36
|
+
def start_device_flow(user: nil)
|
|
37
|
+
raise OAuthError, "device_code_url not configured" if @device_code_url.to_s.empty?
|
|
38
|
+
|
|
39
|
+
status, body = @http.post_form(@device_code_url, {client_id: @client_id, scope: @scope.to_s})
|
|
40
|
+
raise OAuthError, "device authorization request failed (#{status}): #{body.to_s[0, 200]}" unless status == 200
|
|
41
|
+
|
|
42
|
+
data = JSON.parse(body)
|
|
43
|
+
{
|
|
44
|
+
device_code: data["device_code"],
|
|
45
|
+
user_code: data["user_code"],
|
|
46
|
+
verification_uri: data["verification_uri"] || data["verification_url"],
|
|
47
|
+
interval: data["interval"].to_i,
|
|
48
|
+
expires_at: data["expires_in"].to_i > 0 ? Time.now + data["expires_in"].to_i : nil
|
|
49
|
+
}
|
|
50
|
+
rescue JSON::ParserError => e
|
|
51
|
+
raise OAuthError, "bad device authorization response: #{e.message}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Step 2: poll the token endpoint. Returns the token hash on
|
|
55
|
+
# success (same shape as #authorize!), or raises PendingAuthorization
|
|
56
|
+
# when the user hasn't authorized yet.
|
|
57
|
+
def complete_device_flow(user: nil, device_code:)
|
|
58
|
+
status, body = @http.post_form(token_url_for, {
|
|
59
|
+
grant_type: DEVICE_CODE_GRANT_TYPE,
|
|
60
|
+
device_code: device_code,
|
|
61
|
+
client_id: @client_id
|
|
62
|
+
})
|
|
63
|
+
data = parse_json(body)
|
|
64
|
+
|
|
65
|
+
case status
|
|
66
|
+
when 200
|
|
67
|
+
parse_token_response(body)
|
|
68
|
+
when 400
|
|
69
|
+
case data["error"]
|
|
70
|
+
when "authorization_pending", "slow_down"
|
|
71
|
+
raise PendingAuthorization
|
|
72
|
+
when "access_denied", "expired_token"
|
|
73
|
+
raise OAuthError, "device authorization #{data["error"]}"
|
|
74
|
+
else
|
|
75
|
+
raise OAuthError, "device authorization failed (#{status}): #{data["error_description"] || data["error"]}"
|
|
76
|
+
end
|
|
77
|
+
else
|
|
78
|
+
raise OAuthError, "device authorization failed (#{status}): #{body.to_s[0, 200]}"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def token_url_for
|
|
85
|
+
@token_url || raise(OAuthError, "token_url not configured")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def parse_json(body)
|
|
89
|
+
JSON.parse(body)
|
|
90
|
+
rescue JSON::ParserError
|
|
91
|
+
{}
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "device_oauth"
|
|
4
|
+
|
|
5
|
+
module Ask
|
|
6
|
+
module Auth
|
|
7
|
+
module Providers
|
|
8
|
+
# GitHub Copilot device OAuth — "bring your Copilot subscription".
|
|
9
|
+
# RFC 8628 device grant against github.com/login/device/code (the
|
|
10
|
+
# public Copilot CLI client id). Constants mirror opencode's
|
|
11
|
+
# github-copilot/copilot.ts.
|
|
12
|
+
#
|
|
13
|
+
# Note: Copilot's device flow is a gray area of GitHub's terms — it
|
|
14
|
+
# works, but treat it as a user-beware integration.
|
|
15
|
+
class GithubCopilot < DeviceOAuth
|
|
16
|
+
CLIENT_ID = "Ov23li8tweQw6odWQebz"
|
|
17
|
+
DEVICE_CODE_URL = "https://github.com/login/device/code"
|
|
18
|
+
ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token"
|
|
19
|
+
SCOPE = "read:user"
|
|
20
|
+
|
|
21
|
+
def initialize(storage: nil, client_id: CLIENT_ID, http: Ask::Auth::OAuth::HTTP)
|
|
22
|
+
super(storage: storage, client_id: client_id, token_url: ACCESS_TOKEN_URL,
|
|
23
|
+
device_code_url: DEVICE_CODE_URL, scope: SCOPE, http: http)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -1,28 +1,45 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "base64"
|
|
4
|
+
require "json"
|
|
4
5
|
require "openssl"
|
|
5
6
|
require "securerandom"
|
|
7
|
+
require_relative "../oauth/http"
|
|
6
8
|
|
|
7
9
|
module Ask
|
|
8
10
|
module Auth
|
|
9
11
|
module Providers
|
|
10
|
-
# PKCE OAuth flow for interactive credential
|
|
12
|
+
# Authorization-code PKCE OAuth flow for interactive credential
|
|
13
|
+
# authorization (e.g. bring-your-own-subscription).
|
|
11
14
|
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
# provider = Ask::Auth::Providers::OAuth.new(storage: database_provider)
|
|
15
|
+
# provider = Ask::Auth::Providers::OAuth.new(
|
|
16
|
+
# client_id: "...", authorize_url: "...", token_url: "...",
|
|
17
|
+
# redirect_uri: "https://app.example/oauth/callback", scope: "..."
|
|
18
|
+
# )
|
|
17
19
|
# url = provider.authorize_url(user: current_user)
|
|
18
|
-
# # redirect user to url
|
|
19
|
-
# provider.authorize!(user: current_user, code: params[:code])
|
|
20
|
+
# # redirect user to url; the callback receives ?code=...&state=...
|
|
21
|
+
# tokens = provider.authorize!(user: current_user, code: params[:code])
|
|
22
|
+
# # { token:, refresh_token:, expires_at:, raw: {...} }
|
|
23
|
+
#
|
|
24
|
+
# The code verifier is generated during #authorize_url. Persist it via
|
|
25
|
+
# a storage object that responds to #store(name, user:, value:) /
|
|
26
|
+
# #fetch(name, user:) — the app implements it (e.g. on its OAuth state
|
|
27
|
+
# table) — or pass code_verifier: back into #authorize! yourself.
|
|
28
|
+
#
|
|
29
|
+
# Subclasses override #parse_token_response to add provider-specific
|
|
30
|
+
# fields (e.g. an account id from the id_token).
|
|
20
31
|
class OAuth
|
|
21
|
-
|
|
32
|
+
attr_reader :client_id, :redirect_uri
|
|
33
|
+
|
|
34
|
+
def initialize(storage: nil, client_id: nil, authorize_url: nil, token_url: nil,
|
|
35
|
+
redirect_uri: nil, scope: nil, http: Ask::Auth::OAuth::HTTP)
|
|
22
36
|
@storage = storage
|
|
23
37
|
@client_id = client_id
|
|
24
38
|
@authorize_url = authorize_url
|
|
25
39
|
@token_url = token_url
|
|
40
|
+
@redirect_uri = redirect_uri
|
|
41
|
+
@scope = scope
|
|
42
|
+
@http = http
|
|
26
43
|
end
|
|
27
44
|
|
|
28
45
|
# Returns nil (no automatic resolution) — OAuth requires interactive flow.
|
|
@@ -43,35 +60,100 @@ module Ask
|
|
|
43
60
|
)
|
|
44
61
|
end
|
|
45
62
|
|
|
46
|
-
# Returns the authorization URL to redirect the user to.
|
|
47
|
-
|
|
63
|
+
# Returns the authorization URL to redirect the user to. The code
|
|
64
|
+
# verifier (and state) are persisted via the storage object when one
|
|
65
|
+
# is configured, so the callback can recover them.
|
|
66
|
+
def authorize_url(user:, verifier: nil, state: nil, extra_params: {})
|
|
48
67
|
verifier ||= generate_code_verifier
|
|
49
68
|
state ||= SecureRandom.hex(16)
|
|
50
69
|
challenge = generate_code_challenge(verifier)
|
|
51
70
|
|
|
52
|
-
|
|
53
|
-
if @storage && user
|
|
54
|
-
@storage.call(:oauth_state, user: user)
|
|
55
|
-
end
|
|
71
|
+
persist_oauth_state(user, verifier, state)
|
|
56
72
|
|
|
57
|
-
|
|
58
|
-
uri.query = URI.encode_www_form(
|
|
73
|
+
params = {
|
|
59
74
|
response_type: "code",
|
|
60
|
-
client_id: @client_id
|
|
61
|
-
redirect_uri:
|
|
62
|
-
scope:
|
|
75
|
+
client_id: @client_id,
|
|
76
|
+
redirect_uri: redirect_uri_value,
|
|
77
|
+
scope: @scope.to_s,
|
|
63
78
|
state: state,
|
|
64
79
|
code_challenge: challenge,
|
|
65
80
|
code_challenge_method: "S256"
|
|
66
|
-
)
|
|
81
|
+
}.merge(extra_params)
|
|
82
|
+
|
|
83
|
+
uri = URI.parse(@authorize_url || "https://example.com/oauth/authorize")
|
|
84
|
+
uri.query = URI.encode_www_form(params)
|
|
67
85
|
uri.to_s
|
|
68
86
|
end
|
|
69
87
|
|
|
70
88
|
# Exchange an authorization code for tokens.
|
|
71
|
-
#
|
|
72
|
-
#
|
|
73
|
-
|
|
74
|
-
|
|
89
|
+
#
|
|
90
|
+
# Returns { token:, refresh_token:, expires_at:, raw: {...} } (plus
|
|
91
|
+
# any subclass-added keys). Raises OAuthError on transport or
|
|
92
|
+
# provider errors.
|
|
93
|
+
def authorize!(user:, code:, code_verifier: nil, redirect_uri: nil)
|
|
94
|
+
verifier = code_verifier || fetch_oauth_state(user, :verifier)
|
|
95
|
+
if verifier.to_s.empty?
|
|
96
|
+
raise OAuthError, "missing code verifier — pass code_verifier or persist it via a storage object"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
body = token_exchange(
|
|
100
|
+
grant_type: "authorization_code",
|
|
101
|
+
code: code,
|
|
102
|
+
redirect_uri: redirect_uri || redirect_uri_value,
|
|
103
|
+
client_id: @client_id,
|
|
104
|
+
code_verifier: verifier
|
|
105
|
+
)
|
|
106
|
+
parse_token_response(body)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Refresh an access token using a refresh token grant.
|
|
110
|
+
def refresh(refresh_token:)
|
|
111
|
+
body = token_exchange(
|
|
112
|
+
grant_type: "refresh_token",
|
|
113
|
+
refresh_token: refresh_token,
|
|
114
|
+
client_id: @client_id
|
|
115
|
+
)
|
|
116
|
+
parse_token_response(body)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
def token_exchange(**params)
|
|
122
|
+
raise OAuthError, "token_url not configured" if @token_url.to_s.empty?
|
|
123
|
+
|
|
124
|
+
status, body = @http.post_form(@token_url, params)
|
|
125
|
+
raise OAuthError, "token exchange failed (#{status}): #{body.to_s[0, 200]}" unless status == 200
|
|
126
|
+
|
|
127
|
+
body
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def parse_token_response(body)
|
|
131
|
+
data = JSON.parse(body)
|
|
132
|
+
{
|
|
133
|
+
token: data["access_token"],
|
|
134
|
+
refresh_token: data["refresh_token"],
|
|
135
|
+
expires_at: data["expires_in"] ? Time.now + data["expires_in"].to_i : nil,
|
|
136
|
+
raw: data
|
|
137
|
+
}
|
|
138
|
+
rescue JSON::ParserError => e
|
|
139
|
+
raise OAuthError, "bad token response: #{e.message}"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def redirect_uri_value
|
|
143
|
+
@redirect_uri || "urn:ietf:wg:oauth:2.0:oob"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def persist_oauth_state(user, verifier, state)
|
|
147
|
+
return unless @storage.respond_to?(:store) && user
|
|
148
|
+
|
|
149
|
+
@storage.store(:oauth_state, user: user, value: {verifier: verifier, state: state})
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def fetch_oauth_state(user, key)
|
|
153
|
+
return nil unless @storage.respond_to?(:fetch) && user
|
|
154
|
+
|
|
155
|
+
value = @storage.fetch(:oauth_state, user: user)
|
|
156
|
+
value.is_a?(Hash) ? value[key] : nil
|
|
75
157
|
end
|
|
76
158
|
end
|
|
77
159
|
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "base64"
|
|
5
|
+
require_relative "oauth"
|
|
6
|
+
|
|
7
|
+
module Ask
|
|
8
|
+
module Auth
|
|
9
|
+
module Providers
|
|
10
|
+
# OpenAI Codex OAuth — "bring your ChatGPT subscription" (Plus/Pro).
|
|
11
|
+
#
|
|
12
|
+
# Users authenticate with their OpenAI account (PKCE against
|
|
13
|
+
# auth.openai.com, the public Codex client id) and get tokens that
|
|
14
|
+
# route requests through their subscription quota instead of the
|
|
15
|
+
# pay-per-token API — flat-rate, like Cline/Hermes/opencode.
|
|
16
|
+
#
|
|
17
|
+
# provider = Ask::Auth::Providers::OpenaiCodex.new(redirect_uri: "...")
|
|
18
|
+
# url = provider.authorize_url(user: current_user)
|
|
19
|
+
# tokens = provider.authorize!(user: current_user, code: params[:code])
|
|
20
|
+
# provider.refresh(refresh_token: tokens[:refresh_token])
|
|
21
|
+
#
|
|
22
|
+
# #authorize! returns { token:, refresh_token:, expires_at:, account_id:, raw: }.
|
|
23
|
+
# Models are tier-filtered with .allowed_model? (mirrors opencode's
|
|
24
|
+
# codex.ts: the OAuth path excludes pro-reasoning and gpt-5.6, and only
|
|
25
|
+
# gpt-5.4+ general models are allowed).
|
|
26
|
+
class OpenaiCodex < OAuth
|
|
27
|
+
CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
|
28
|
+
ISSUER = "https://auth.openai.com"
|
|
29
|
+
AUTHORIZE_URL = "#{ISSUER}/oauth/authorize"
|
|
30
|
+
TOKEN_URL = "#{ISSUER}/oauth/token"
|
|
31
|
+
SCOPE = "openid profile email offline_access"
|
|
32
|
+
|
|
33
|
+
ALLOWED_MODELS = %w[gpt-5.5 gpt-5.3-codex-spark gpt-5.4 gpt-5.4-mini].freeze
|
|
34
|
+
DISALLOWED_MODELS = %w[gpt-5.5-pro].freeze
|
|
35
|
+
|
|
36
|
+
def initialize(storage: nil, client_id: CLIENT_ID, redirect_uri: nil, http: Ask::Auth::OAuth::HTTP)
|
|
37
|
+
super(
|
|
38
|
+
storage: storage,
|
|
39
|
+
client_id: client_id,
|
|
40
|
+
authorize_url: AUTHORIZE_URL,
|
|
41
|
+
token_url: TOKEN_URL,
|
|
42
|
+
redirect_uri: redirect_uri,
|
|
43
|
+
scope: SCOPE,
|
|
44
|
+
http: http
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def authorize_url(user:, verifier: nil, state: nil)
|
|
49
|
+
super(user: user, verifier: verifier, state: state, extra_params: {id_token_add_organizations: "true"})
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def parse_token_response(body)
|
|
53
|
+
data = JSON.parse(body)
|
|
54
|
+
{
|
|
55
|
+
token: data["access_token"],
|
|
56
|
+
refresh_token: data["refresh_token"],
|
|
57
|
+
expires_at: data["expires_in"] ? Time.now + data["expires_in"].to_i : nil,
|
|
58
|
+
account_id: self.class.account_id_from(data["id_token"]),
|
|
59
|
+
raw: data
|
|
60
|
+
}
|
|
61
|
+
rescue JSON::ParserError => e
|
|
62
|
+
raise OAuthError, "bad token response: #{e.message}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Whether a model id is usable through a ChatGPT subscription's OAuth
|
|
66
|
+
# path. General gpt-5.4+ models and the codex family are allowed;
|
|
67
|
+
# pro-reasoning models (gpt-5.5-pro) and gpt-5.6 are not.
|
|
68
|
+
def self.allowed_model?(model_id)
|
|
69
|
+
return true if ALLOWED_MODELS.include?(model_id)
|
|
70
|
+
return false if DISALLOWED_MODELS.include?(model_id) || model_id == "gpt-5.6"
|
|
71
|
+
|
|
72
|
+
match = model_id.match(/\Agpt-(\d+\.\d+)/)
|
|
73
|
+
match ? match[1].to_f > 5.4 : false
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# The ChatGPT account id, from the id_token's JWT claims — sent as
|
|
77
|
+
# the ChatGPT-Account-Id header on API calls.
|
|
78
|
+
def self.account_id_from(id_token)
|
|
79
|
+
claims = decode_jwt_claims(id_token)
|
|
80
|
+
claims["chatgpt_account_id"] ||
|
|
81
|
+
claims.dig("https://api.openai.com/auth", "chatgpt_account_id") ||
|
|
82
|
+
claims.dig("organizations", 0, "id")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def self.decode_jwt_claims(token)
|
|
86
|
+
payload = token.to_s.split(".")[1]
|
|
87
|
+
return {} if payload.to_s.empty?
|
|
88
|
+
|
|
89
|
+
decoded = Base64.urlsafe_decode64(payload)
|
|
90
|
+
JSON.parse(decoded)
|
|
91
|
+
rescue ArgumentError, JSON::ParserError
|
|
92
|
+
{}
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "device_oauth"
|
|
4
|
+
|
|
5
|
+
module Ask
|
|
6
|
+
module Auth
|
|
7
|
+
module Providers
|
|
8
|
+
# xAI (Grok) device OAuth — "bring your xAI account": the public
|
|
9
|
+
# Grok-CLI OAuth client (RFC 8628 device grant against
|
|
10
|
+
# auth.x.ai/oauth2). Constants mirror opencode's xai.ts. The access
|
|
11
|
+
# token works as the bearer key for the standard xAI OpenAI-compatible
|
|
12
|
+
# API (api.x.ai/v1).
|
|
13
|
+
class Xai < DeviceOAuth
|
|
14
|
+
CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828"
|
|
15
|
+
TOKEN_URL = "https://auth.x.ai/oauth2/token"
|
|
16
|
+
DEVICE_AUTHORIZATION_URL = "https://auth.x.ai/oauth2/device/code"
|
|
17
|
+
SCOPE = "openid profile email offline_access grok-cli:access api:access"
|
|
18
|
+
|
|
19
|
+
def initialize(storage: nil, client_id: CLIENT_ID, http: Ask::Auth::OAuth::HTTP)
|
|
20
|
+
super(storage: storage, client_id: client_id, token_url: TOKEN_URL,
|
|
21
|
+
device_code_url: DEVICE_AUTHORIZATION_URL, scope: SCOPE, http: http)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
data/lib/ask/auth/version.rb
CHANGED
data/lib/ask/auth.rb
CHANGED
|
@@ -6,6 +6,10 @@ require_relative "auth/providers/file"
|
|
|
6
6
|
require_relative "auth/providers/rails_credentials"
|
|
7
7
|
require_relative "auth/providers/database"
|
|
8
8
|
require_relative "auth/providers/oauth"
|
|
9
|
+
require_relative "auth/providers/openai_codex"
|
|
10
|
+
require_relative "auth/providers/device_oauth"
|
|
11
|
+
require_relative "auth/providers/xai"
|
|
12
|
+
require_relative "auth/providers/github_copilot"
|
|
9
13
|
|
|
10
14
|
module Ask
|
|
11
15
|
# Credential resolution for the ask-rb ecosystem.
|
|
@@ -27,6 +31,12 @@ module Ask
|
|
|
27
31
|
end
|
|
28
32
|
end
|
|
29
33
|
|
|
34
|
+
class OAuthError < StandardError
|
|
35
|
+
def initialize(message)
|
|
36
|
+
super("OAuth flow failed: #{message}")
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
30
40
|
class InvalidCredential < RuntimeError
|
|
31
41
|
def initialize(name, reason = "invalid or expired")
|
|
32
42
|
super("Credential #{name.inspect} is #{reason}. " \
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ask-auth
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kaka Ruto
|
|
@@ -78,11 +78,16 @@ files:
|
|
|
78
78
|
- README.md
|
|
79
79
|
- lib/ask-auth.rb
|
|
80
80
|
- lib/ask/auth.rb
|
|
81
|
+
- lib/ask/auth/oauth/http.rb
|
|
81
82
|
- lib/ask/auth/providers/database.rb
|
|
83
|
+
- lib/ask/auth/providers/device_oauth.rb
|
|
82
84
|
- lib/ask/auth/providers/env.rb
|
|
83
85
|
- lib/ask/auth/providers/file.rb
|
|
86
|
+
- lib/ask/auth/providers/github_copilot.rb
|
|
84
87
|
- lib/ask/auth/providers/oauth.rb
|
|
88
|
+
- lib/ask/auth/providers/openai_codex.rb
|
|
85
89
|
- lib/ask/auth/providers/rails_credentials.rb
|
|
90
|
+
- lib/ask/auth/providers/xai.rb
|
|
86
91
|
- lib/ask/auth/version.rb
|
|
87
92
|
homepage: https://github.com/ask-rb/ask-auth
|
|
88
93
|
licenses:
|