ask-auth 0.2.2 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d25e993c78fcede6b8d50972bfc8b99b5a30a03d85cf9c725eb5d5f3ae728725
4
- data.tar.gz: d9792c086a2d3f84ecdbd5760bfd5ec02bb0074d3cb57670d8df5c7b3280015d
3
+ metadata.gz: d8434cd70339a093050b7c19364a628d9b8e56048f41c1bb492f6268ed672b39
4
+ data.tar.gz: d5c377bb5480fb7e19d1dca9a0ca46bc1a29d09ef4dc7d3fc3f216fa38342552
5
5
  SHA512:
6
- metadata.gz: 29a7da04a8d494a59ace1c34840d7b57dead0277f19f02b6e824e30eb9511df5cd6453cfad4ce829a95666f4b9cd5d9f5ae3e43d6b67d549dcbb345c77f51c92
7
- data.tar.gz: 94757f55995fac37460342c50ccbcac891096dc9f5a4b6c429d5f431d181a13556364301c065f3c2c15208a20209de48d33149ba6987bfdd11b8476f4c2cc819
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
- **Credential resolution for the ask-rb ecosystem.**
3
+ [![Gem Version](https://badge.fury.io/rb/ask-auth.svg)](https://badge.fury.io/rb/ask-auth)
4
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.
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
- # With a user context (for per-user providers like Database)
21
+ # Per-user credentials (Database, OAuth providers)
33
22
  token = Ask::Auth.resolve(:openai_api_key, user: current_user)
34
23
  ```
35
24
 
36
- By default, the resolution chain checks these providers in order:
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
- 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)
27
+ ## Resolution chain
43
28
 
44
- ## Configuration
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
- Customize the provider chain with `Ask::Auth.configure`:
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
- ```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
49
+ ## Full documentation
161
50
 
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
- ```
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
- ```bash
174
- git clone https://github.com/ask-rb/ask-auth
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
@@ -12,6 +12,8 @@ module Ask
12
12
  class Env
13
13
  # Returns the credential value from ENV, or nil if not found.
14
14
  def call(name, user: nil)
15
+ return nil unless name.is_a?(String) || name.is_a?(Symbol)
16
+
15
17
  conventions(name).each do |key|
16
18
  value = ENV[key.to_s]
17
19
  return value unless value.nil?
@@ -24,7 +24,7 @@ module Ask
24
24
  data = load_file
25
25
  return nil unless data
26
26
 
27
- return nil unless name.respond_to?(:to_s)
27
+ return nil unless name.is_a?(String) || name.is_a?(Symbol)
28
28
  value = data[name.to_s] || data[name.to_sym]
29
29
  value
30
30
  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 authorization.
12
+ # Authorization-code PKCE OAuth flow for interactive credential
13
+ # authorization (e.g. bring-your-own-subscription).
11
14
  #
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)
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, then:
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
- def initialize(storage: nil, client_id: nil, authorize_url: nil, token_url: nil)
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
- def authorize_url(user:, verifier: nil, state: nil)
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
- # Store state and verifier for this user (requires a storage provider)
53
- if @storage && user
54
- @storage.call(:oauth_state, user: user)
55
- end
71
+ persist_oauth_state(user, verifier, state)
56
72
 
57
- uri = URI.parse(@authorize_url || "https://example.com/oauth/authorize")
58
- uri.query = URI.encode_www_form(
73
+ params = {
59
74
  response_type: "code",
60
- client_id: @client_id || "YOUR_CLIENT_ID",
61
- redirect_uri: "urn:ietf:wg:oauth:2.0:oob",
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
- # +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"
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module Auth
5
- VERSION = "0.2.2"
5
+ VERSION = "0.3.1"
6
6
  end
7
7
  end
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.2.2
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: