supabase-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: 4f59194c543746f17fee64c695a67376d979b29d84cef81927f2e0ee28b2c3c0
4
+ data.tar.gz: 0645f3fa4daca0abd64124e61735f2b95e04fca4b2a9395e519882fd15b50150
5
+ SHA512:
6
+ metadata.gz: 95dec921b37d44b44a94f36dcc9ab5e6466a2f9fc774657f2b526c003cba2acf867ed2103093ff5b2bd4b01064f8db271b5298f07b6dab5621cc83e511679d67
7
+ data.tar.gz: 10a3996b51836f65cbabc33514ae57c1a11f997b35885144bd818a208f8625e65138231fe335b9b5cf65c89cf7872702927600b4abd779c571ed9bb30dd392a5
data/README.md ADDED
@@ -0,0 +1,233 @@
1
+ # supabase-auth
2
+
3
+ [![Gem Version](https://img.shields.io/gem/v/supabase-auth)](https://rubygems.org/gems/supabase-auth)
4
+ [![CI](https://github.com/supabase/supabase-rb/actions/workflows/ci.yml/badge.svg)](https://github.com/supabase/supabase-rb/actions/workflows/ci.yml)
5
+ [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.0-red)](https://www.ruby-lang.org)
6
+ [![License](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
7
+
8
+ Ruby client for [Supabase Auth](https://supabase.com/docs/guides/auth) (GoTrue API).
9
+
10
+ ## Features
11
+
12
+ - Email/password, phone, and anonymous sign-in
13
+ - OAuth and SSO provider support
14
+ - Magic link and OTP verification
15
+ - PKCE authentication flow
16
+ - MFA (TOTP and phone)
17
+ - JWT verification with JWKS support
18
+ - Session management with auto-refresh
19
+ - Admin API for user management
20
+ - Auth state change subscriptions
21
+
22
+ ## Installation
23
+
24
+ Add to your Gemfile:
25
+
26
+ ```ruby
27
+ gem "supabase-auth"
28
+ ```
29
+
30
+ Then run:
31
+
32
+ ```bash
33
+ bundle install
34
+ ```
35
+
36
+ ## Quick Start
37
+
38
+ ```ruby
39
+ require "supabase/auth"
40
+
41
+ client = Supabase::Auth::Client.new(
42
+ url: "https://your-project.supabase.co/auth/v1",
43
+ headers: { "apiKey" => "your-anon-key" }
44
+ )
45
+
46
+ # Sign up
47
+ response = client.sign_up(email: "user@example.com", password: "secure-password")
48
+
49
+ # Sign in
50
+ response = client.sign_in_with_password(email: "user@example.com", password: "secure-password")
51
+
52
+ # Get session and user
53
+ session = client.get_session
54
+ user = client.get_user
55
+ ```
56
+
57
+ ## Usage
58
+
59
+ ### Authentication
60
+
61
+ ```ruby
62
+ # Email/password
63
+ client.sign_in_with_password(email: "user@example.com", password: "password")
64
+
65
+ # Magic link (OTP)
66
+ client.sign_in_with_otp(email: "user@example.com")
67
+
68
+ # Phone OTP
69
+ client.sign_in_with_otp(phone: "+1234567890")
70
+
71
+ # OAuth
72
+ response = client.sign_in_with_oauth(provider: "google")
73
+
74
+ # SSO
75
+ response = client.sign_in_with_sso(domain: "company.com")
76
+
77
+ # ID token (e.g. from Google Sign-In)
78
+ response = client.sign_in_with_id_token(provider: "google", token: "id-token")
79
+
80
+ # Anonymous
81
+ response = client.sign_in_anonymously
82
+
83
+ # Sign out
84
+ client.sign_out
85
+ ```
86
+
87
+ ### Session Management
88
+
89
+ ```ruby
90
+ # Set session from existing tokens
91
+ client.set_session("access_token", "refresh_token")
92
+
93
+ # Refresh session
94
+ client.refresh_session
95
+
96
+ # Exchange code for session (PKCE)
97
+ client.exchange_code_for_session(auth_code: "code")
98
+
99
+ # Listen for auth state changes
100
+ subscription = client.on_auth_state_change do |event, session|
101
+ puts "Auth event: #{event}"
102
+ end
103
+
104
+ # Unsubscribe
105
+ subscription.unsubscribe.call
106
+ ```
107
+
108
+ Auth events: `SIGNED_IN`, `SIGNED_OUT`, `TOKEN_REFRESHED`, `USER_UPDATED`, `MFA_CHALLENGE_VERIFIED`, `PASSWORD_RECOVERY`
109
+
110
+ ### User Management
111
+
112
+ ```ruby
113
+ # Get current user
114
+ user = client.get_user
115
+
116
+ # Update user
117
+ client.update_user(data: { name: "Jane Doe" })
118
+
119
+ # Reset password
120
+ client.reset_password_for_email("user@example.com")
121
+
122
+ # Verify OTP
123
+ client.verify_otp(type: "email", email: "user@example.com", token: "123456")
124
+
125
+ # Identity linking
126
+ client.link_identity(provider: "github")
127
+ client.unlink_identity(identity)
128
+ ```
129
+
130
+ ### MFA (Multi-Factor Authentication)
131
+
132
+ ```ruby
133
+ # Enroll a TOTP factor
134
+ enrolled = client.mfa.enroll(factor_type: "totp")
135
+
136
+ # Challenge
137
+ challenge = client.mfa.challenge(factor_id: enrolled["id"])
138
+
139
+ # Verify
140
+ client.mfa.verify(factor_id: enrolled["id"], challenge_id: challenge.id, code: "123456")
141
+
142
+ # List factors
143
+ factors = client.mfa.list_factors
144
+
145
+ # Get assurance level
146
+ aal = client.mfa.get_authenticator_assurance_level
147
+
148
+ # Unenroll
149
+ client.mfa.unenroll(factor_id: enrolled["id"])
150
+ ```
151
+
152
+ ### JWT Verification
153
+
154
+ ```ruby
155
+ # Verify and extract JWT claims (supports HS256, RS256, ES256, PS256+)
156
+ claims = client.get_claims(jwt: "eyJhbG...")
157
+ claims.claims # decoded payload
158
+ claims.headers # JWT headers
159
+ ```
160
+
161
+ ### Admin API
162
+
163
+ Requires a service role key.
164
+
165
+ ```ruby
166
+ admin = Supabase::Auth::AdminApi.new(
167
+ url: "https://your-project.supabase.co/auth/v1",
168
+ headers: { "Authorization" => "Bearer #{service_role_key}", "apiKey" => service_role_key }
169
+ )
170
+
171
+ # CRUD operations
172
+ user = admin.create_user(email: "new@example.com", password: "password")
173
+ users = admin.list_users(page: 1, per_page: 50)
174
+ user = admin.get_user_by_id("uuid")
175
+ admin.update_user_by_id("uuid", email: "updated@example.com")
176
+ admin.delete_user("uuid")
177
+
178
+ # Invite user
179
+ admin.invite_user_by_email("user@example.com")
180
+
181
+ # Generate links
182
+ admin.generate_link(type: "signup", email: "user@example.com", password: "password")
183
+
184
+ # Sign out a user
185
+ admin.sign_out("access_token")
186
+ ```
187
+
188
+ ### Configuration Options
189
+
190
+ ```ruby
191
+ client = Supabase::Auth::Client.new(
192
+ url: "https://your-project.supabase.co/auth/v1",
193
+ headers: { "apiKey" => "your-anon-key" },
194
+ auto_refresh_token: true, # Auto-refresh expiring tokens (default: true)
195
+ persist_session: true, # Persist session to storage (default: true)
196
+ detect_session_in_url: true, # Detect OAuth callback in URL (default: true)
197
+ flow_type: "implicit", # "implicit" or "pkce" (default: "implicit")
198
+ storage: custom_storage # Custom storage backend (default: in-memory)
199
+ )
200
+ ```
201
+
202
+ ## Development
203
+
204
+ ### Prerequisites
205
+
206
+ - Ruby >= 3.0
207
+ - Docker & Docker Compose (for integration tests)
208
+
209
+ ### Setup
210
+
211
+ ```bash
212
+ bundle install
213
+ ```
214
+
215
+ ### Running Tests
216
+
217
+ Start the GoTrue infrastructure:
218
+
219
+ ```bash
220
+ docker compose -f infra/docker-compose.yml up -d
221
+ ```
222
+
223
+ Run the test suite:
224
+
225
+ ```bash
226
+ bundle exec rspec
227
+ ```
228
+
229
+ Coverage reports are generated automatically via SimpleCov. After running tests, open `coverage/index.html`.
230
+
231
+ ## License
232
+
233
+ MIT
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Supabase
6
+ module Auth
7
+ # Admin API for managing users with a service role key.
8
+ # Provides CRUD operations on users, link generation, and MFA management.
9
+ class AdminApi < Api
10
+ # @param url [String] The GoTrue API base URL
11
+ # @param headers [Hash] Headers including Authorization bearer token
12
+ # @param http_client [Faraday::Connection, nil] Optional custom Faraday client
13
+ def initialize(url:, headers: {}, http_client: nil)
14
+ super(url: url, headers: headers, http_client: http_client)
15
+ end
16
+
17
+ # Creates a new user via the admin API.
18
+ # @param attributes [Hash] user attributes (email, password, user_metadata, app_metadata, etc.)
19
+ # @return [Types::UserResponse]
20
+ def create_user(attributes)
21
+ data = post("admin/users", body: attributes)
22
+ Helpers.parse_user_response(data)
23
+ end
24
+
25
+ # Lists all users.
26
+ # @param page [Integer, nil] page number
27
+ # @param per_page [Integer, nil] users per page
28
+ # @return [Array<Types::User>]
29
+ def list_users(page: nil, per_page: nil)
30
+ params = {}
31
+ params[:page] = page if page
32
+ params[:per_page] = per_page if per_page
33
+ data = get("admin/users", params: params)
34
+ users = data["users"] || []
35
+ users.map { |u| Types::User.from_hash(u) }
36
+ end
37
+
38
+ # Gets a user by their ID.
39
+ # @param uid [String] user UUID
40
+ # @return [Types::UserResponse]
41
+ # @raise [ArgumentError] if uid is not a valid UUID
42
+ def get_user_by_id(uid)
43
+ _validate_uuid(uid)
44
+ data = get("admin/users/#{uid}")
45
+ Helpers.parse_user_response(data)
46
+ end
47
+
48
+ # Updates a user by their ID.
49
+ # @param uid [String] user UUID
50
+ # @param attributes [Hash] attributes to update
51
+ # @return [Types::UserResponse]
52
+ # @raise [ArgumentError] if uid is not a valid UUID
53
+ def update_user_by_id(uid, attributes)
54
+ _validate_uuid(uid)
55
+ data = put("admin/users/#{uid}", body: attributes)
56
+ Helpers.parse_user_response(data)
57
+ end
58
+
59
+ # Deletes a user by their ID.
60
+ # @param uid [String] user UUID
61
+ # @param should_soft_delete [Boolean] soft delete instead of hard delete
62
+ # @raise [ArgumentError] if uid is not a valid UUID
63
+ def delete_user(uid, should_soft_delete: false)
64
+ _validate_uuid(uid)
65
+ _request("DELETE", "admin/users/#{uid}", body: { should_soft_delete: should_soft_delete })
66
+ end
67
+
68
+ # Generates email links and OTPs.
69
+ def generate_link(params)
70
+ options = params[:options] || params["options"] || {}
71
+ body = {
72
+ type: params[:type] || params["type"],
73
+ email: params[:email] || params["email"],
74
+ password: params[:password] || params["password"],
75
+ new_email: params[:new_email] || params["new_email"],
76
+ data: options[:data] || options["data"]
77
+ }
78
+ redirect_to = options[:redirect_to] || options["redirect_to"]
79
+ query = {}
80
+ query["redirect_to"] = redirect_to if redirect_to
81
+ data = post("admin/generate_link", body: body, params: query)
82
+ Helpers.parse_link_response(data)
83
+ end
84
+
85
+ # Invites a user by email.
86
+ def invite_user_by_email(email, options = {})
87
+ body = { email: email, data: options[:data] || options["data"] }
88
+ redirect_to = options[:redirect_to] || options["redirect_to"]
89
+ query = {}
90
+ query["redirect_to"] = redirect_to if redirect_to
91
+ data = post("invite", body: body, params: query)
92
+ Helpers.parse_user_response(data)
93
+ end
94
+
95
+ # Signs out a user by revoking their session via the admin API.
96
+ def sign_out(access_token, scope = "global")
97
+ _request("POST", "logout", jwt: access_token, params: { "scope" => scope }, no_resolve_json: true)
98
+ end
99
+
100
+ # Lists MFA factors for a user (admin).
101
+ # @param params [Hash] :user_id (required)
102
+ # @return [Types::AuthMFAAdminListFactorsResponse]
103
+ def _list_factors(params)
104
+ user_id = params[:user_id] || params["user_id"]
105
+ _validate_uuid(user_id)
106
+ data = get("admin/users/#{user_id}/factors")
107
+ Types::AuthMFAAdminListFactorsResponse.from_hash(data)
108
+ end
109
+
110
+ # Deletes an MFA factor for a user (admin).
111
+ # @param params [Hash] :user_id and :id (both required)
112
+ # @return [Types::AuthMFAAdminDeleteFactorResponse]
113
+ def _delete_factor(params)
114
+ user_id = params[:user_id] || params["user_id"]
115
+ factor_id = params[:id] || params["id"]
116
+ _validate_uuid(user_id)
117
+ _validate_uuid(factor_id)
118
+ data = delete("admin/users/#{user_id}/factors/#{factor_id}")
119
+ Types::AuthMFAAdminDeleteFactorResponse.from_hash(data)
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "json"
5
+
6
+ module Supabase
7
+ module Auth
8
+ class Api
9
+ CONTENT_TYPE = "application/json;charset=UTF-8"
10
+ UUID_REGEX = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i
11
+
12
+ attr_reader :url, :headers
13
+
14
+ # @param url [String] The GoTrue API base URL
15
+ # @param headers [Hash] Default headers to include on every request (e.g., apikey)
16
+ # @param http_client [Faraday::Connection, nil] Optional custom Faraday client
17
+ def initialize(url:, headers: {}, http_client: nil)
18
+ @url = url
19
+ @headers = headers
20
+ @http_client = http_client
21
+ end
22
+
23
+ # Central HTTP dispatch method. Builds URL, merges headers (including API version
24
+ # and Authorization), handles redirect_to as query param, parses JSON, applies
25
+ # optional transform, and maps errors via Helpers.handle_exception.
26
+ #
27
+ # @param method [String, Symbol] HTTP method (GET, POST, PUT, DELETE)
28
+ # @param path [String] Request path (relative to base URL)
29
+ # @param jwt [String, nil] Bearer token for Authorization header
30
+ # @param body [Hash, nil] Request body (serialized to JSON)
31
+ # @param params [Hash] Query parameters
32
+ # @param headers [Hash] Additional headers for this request
33
+ # @param redirect_to [String, nil] If present, added as redirect_to query param
34
+ # @param xform [Proc, nil] Optional transform function applied to parsed response
35
+ # @param no_resolve_json [Boolean] If true, return raw Faraday::Response
36
+ # @return [Hash, Object] Parsed JSON response, transformed result, or raw response
37
+ def _request(method, path, jwt: nil, body: nil, params: {}, headers: {}, redirect_to: nil, xform: nil, no_resolve_json: false)
38
+ merged_headers = @headers.merge(headers)
39
+ merged_headers["Content-Type"] ||= CONTENT_TYPE
40
+ merged_headers[Constants::API_VERSION_HEADER_NAME] ||= Constants::API_VERSIONS.keys.last
41
+ merged_headers["Authorization"] = "Bearer #{jwt}" if jwt
42
+
43
+ query = params.dup
44
+ query["redirect_to"] = redirect_to if redirect_to
45
+
46
+ full_path = build_path(path)
47
+ json_body = body ? JSON.generate(body) : nil
48
+
49
+ response = connection.run_request(method.to_s.downcase.to_sym, full_path, json_body, merged_headers) do |req|
50
+ req.params.update(query) unless query.empty?
51
+ end
52
+
53
+ result = no_resolve_json ? response : parse_response(response)
54
+
55
+ xform ? xform.call(result) : result
56
+ rescue Faraday::Error => e
57
+ raise Helpers.handle_exception(e)
58
+ rescue StandardError => e
59
+ raise Helpers.handle_exception(e)
60
+ end
61
+
62
+ # @param id [String] UUID to validate
63
+ # @raise [ArgumentError] if not a valid UUID format
64
+ def _validate_uuid(id)
65
+ unless id.is_a?(String) && id.match?(UUID_REGEX)
66
+ raise ArgumentError, "Invalid id, '#{id}' is not a valid uuid"
67
+ end
68
+ end
69
+
70
+ # Convenience methods that delegate to _request
71
+
72
+ def get(path, headers: {}, params: {})
73
+ _request(:get, path, headers: headers, params: params)
74
+ end
75
+
76
+ def post(path, body: {}, headers: {}, params: {})
77
+ _request(:post, path, body: body, headers: headers, params: params)
78
+ end
79
+
80
+ def put(path, body: {}, headers: {}, params: {})
81
+ _request(:put, path, body: body, headers: headers, params: params)
82
+ end
83
+
84
+ def delete(path, headers: {}, params: {})
85
+ _request(:delete, path, headers: headers, params: params)
86
+ end
87
+
88
+ private
89
+
90
+ def connection
91
+ @connection ||= @http_client || build_connection
92
+ end
93
+
94
+ def build_connection
95
+ Faraday.new(url: @url) do |f|
96
+ f.response :raise_error
97
+ f.adapter Faraday.default_adapter
98
+ end
99
+ end
100
+
101
+ def build_path(path)
102
+ base_path = URI.parse(@url).path.chomp("/")
103
+ "#{base_path}/#{path.sub(%r{^/}, '')}"
104
+ end
105
+
106
+ def parse_response(response)
107
+ return {} if response.body.nil? || response.body.empty?
108
+
109
+ JSON.parse(response.body)
110
+ rescue JSON::ParserError
111
+ {}
112
+ end
113
+ end
114
+ end
115
+ end