|
#!/usr/bin/env ruby |
|
|
|
# frozen_string_literal: true |
|
|
|
# Single-file Ruby script for Google OAuth authentication |
|
# This script uses Bundler inline to manage dependencies |
|
|
|
require "bundler/inline" |
|
|
|
gemfile do |
|
source "https://rubygems.org" |
|
gem "http", "~> 5.1" |
|
gem "launchy", "~> 2.5" |
|
gem "webrick", "~> 1.8" |
|
gem "tty-logger", "~> 0.6" |
|
gem "openssl", "3.2.2" |
|
end |
|
|
|
require "http" |
|
require "launchy" |
|
require "webrick" |
|
require "json" |
|
require "uri" |
|
require "tty-logger" |
|
require "yaml" |
|
require "fileutils" |
|
|
|
class GoogleOAuthClient |
|
# Common Google API scopes - modify as needed |
|
SCOPES = %w[ |
|
https://www.googleapis.com/auth/userinfo.email |
|
https://www.googleapis.com/auth/userinfo.profile |
|
https://www.googleapis.com/auth/drive.readonly |
|
].freeze |
|
|
|
CALLBACK_PATH = "/oauth2callback" |
|
CREDENTIALS_FILE = "credentials.json" |
|
TOKEN_STORE_FILE = "tokens.yaml" |
|
|
|
attr_accessor :client_id, :client_secret, :callback_uri, :authorization_code |
|
attr_reader :logger, :server, :http_client |
|
attr_writer :server |
|
|
|
def initialize |
|
self.client_id = nil |
|
self.client_secret = nil |
|
self.callback_uri = nil |
|
self.server = nil |
|
self.authorization_code = nil |
|
|
|
# Configure HTTP client with reasonable defaults |
|
@http_client = |
|
HTTP.timeout(connect: 30, write: 30, read: 30).headers( |
|
"User-Agent" => "GoogleOAuthClient/1.0 (Ruby HTTP gem)", |
|
"Accept" => "application/json" |
|
) |
|
|
|
@logger = TTY::Logger.new |
|
end |
|
|
|
def authenticate |
|
logger.info "Google OAuth Authentication Script", app: "oauth-client" |
|
logger.info "=" * 40 |
|
|
|
# Step 1: Load or create credentials |
|
load_credentials |
|
|
|
# Step 2: Check for existing valid token |
|
if valid_token_exists? |
|
logger.success "Valid token found! You're already authenticated." |
|
test_authentication |
|
return |
|
end |
|
|
|
# Step 3: Start OAuth flow |
|
logger.info "Starting OAuth flow...", step: "oauth" |
|
start_oauth_flow |
|
|
|
logger.success "Authentication successful!" |
|
test_authentication |
|
end |
|
|
|
private |
|
|
|
def make_token_request(url, params) |
|
logger.debug "Making POST request to #{url}" |
|
|
|
begin |
|
response = http_client.post(url, form: params) |
|
|
|
logger.debug "Response status: #{response.status}" |
|
|
|
unless response.status.success? |
|
error_body = response.body.to_s |
|
logger.error "HTTP request failed", |
|
status: response.status, |
|
body: error_body |
|
raise "HTTP #{response.status}: #{error_body}" |
|
end |
|
|
|
response |
|
rescue HTTP::Error => e |
|
logger.error "HTTP gem error", error: e.message, class: e.class |
|
raise "Network request failed: #{e.message}" |
|
rescue => e |
|
logger.error "Unexpected error during HTTP request", |
|
error: e.message, |
|
class: e.class |
|
raise |
|
end |
|
end |
|
|
|
def make_api_request(url, token) |
|
logger.debug "Making GET request to #{url}" |
|
|
|
begin |
|
response = http_client.auth("Bearer #{token}").get(url) |
|
|
|
logger.debug "Response status: #{response.status}" |
|
|
|
unless response.status.success? |
|
error_body = response.body.to_s |
|
logger.error "API request failed", |
|
status: response.status, |
|
body: error_body |
|
raise "API request failed: HTTP #{response.status}" |
|
end |
|
|
|
response |
|
rescue HTTP::Error => e |
|
logger.error "HTTP gem error during API request", |
|
error: e.message, |
|
class: e.class |
|
raise "API request failed: #{e.message}" |
|
rescue => e |
|
logger.error "Unexpected error during API request", |
|
error: e.message, |
|
class: e.class |
|
raise |
|
end |
|
end |
|
|
|
def load_credentials |
|
if File.exist?(CREDENTIALS_FILE) |
|
logger.info "Loading credentials", file: CREDENTIALS_FILE |
|
begin |
|
credentials = JSON.parse(File.read(CREDENTIALS_FILE)) |
|
|
|
if credentials["web"] |
|
self.client_id = credentials["web"]["client_id"] |
|
self.client_secret = credentials["web"]["client_secret"] |
|
elsif credentials["installed"] |
|
self.client_id = credentials["installed"]["client_id"] |
|
self.client_secret = credentials["installed"]["client_secret"] |
|
else |
|
raise "Invalid credentials file format" |
|
end |
|
|
|
logger.success "Credentials loaded successfully" |
|
rescue JSON::ParserError => e |
|
logger.fatal "Invalid JSON in credentials file", error: e.message |
|
exit 1 |
|
rescue => e |
|
logger.fatal "Error loading credentials", error: e.message |
|
exit 1 |
|
end |
|
else |
|
logger.fatal "Credentials file not found!", file: CREDENTIALS_FILE |
|
logger.info "Setup Instructions:" |
|
logger.info "1. Go to https://console.cloud.google.com/apis/credentials" |
|
logger.info "2. Create OAuth 2.0 Client IDs (Desktop application type)" |
|
logger.info "3. Download the JSON file and save it as '#{CREDENTIALS_FILE}'" |
|
exit 1 |
|
end |
|
end |
|
|
|
def valid_token_exists? |
|
return false unless File.exist?(TOKEN_STORE_FILE) |
|
|
|
logger.debug "Checking existing token", file: TOKEN_STORE_FILE |
|
|
|
begin |
|
stored_tokens = YAML.load_file(TOKEN_STORE_FILE) |
|
stored_token = stored_tokens["default"] |
|
rescue => e |
|
logger.warn "Error reading token file", error: e.message |
|
return false |
|
end |
|
|
|
return false unless stored_token |
|
|
|
# Check if token is expired |
|
if stored_token["expiry_time"] && |
|
Time.at(stored_token["expiry_time"]) > Time.now |
|
logger.debug "Found valid unexpired token" |
|
return true |
|
end |
|
|
|
# Try to refresh the token if we have a refresh token |
|
if stored_token["refresh_token"] |
|
logger.info "Token expired, attempting refresh..." |
|
begin |
|
refresh_token(stored_token) |
|
return true |
|
rescue => e |
|
logger.warn "Failed to refresh token", error: e.message |
|
return false |
|
end |
|
end |
|
|
|
logger.debug "No valid token found" |
|
false |
|
end |
|
|
|
def refresh_token(stored_token) |
|
url = "https://oauth2.googleapis.com/token" |
|
params = { |
|
client_id: client_id, |
|
client_secret: client_secret, |
|
refresh_token: stored_token["refresh_token"], |
|
grant_type: "refresh_token" |
|
} |
|
|
|
response = make_token_request(url, params) |
|
token_data = JSON.parse(response.body.to_s) |
|
|
|
# Store the refreshed token |
|
store_token( |
|
{ |
|
"access_token" => token_data["access_token"], |
|
"refresh_token" => stored_token["refresh_token"], # Keep original refresh token |
|
"expiry_time" => (Time.now + token_data["expires_in"]).to_i |
|
} |
|
) |
|
|
|
logger.success "Token refreshed successfully" |
|
end |
|
|
|
def start_oauth_flow |
|
# Start local server for callback |
|
start_callback_server |
|
|
|
# Generate authorization URL |
|
auth_url = generate_auth_url |
|
|
|
logger.info "Opening browser for authentication..." |
|
logger.info "If browser doesn't open automatically, visit:", url: auth_url |
|
|
|
# Open browser |
|
begin |
|
Launchy.open(auth_url) |
|
logger.debug "Browser opened successfully" |
|
rescue => e |
|
logger.warn "Failed to open browser automatically", error: e.message |
|
logger.info "Please manually open the URL above" |
|
end |
|
|
|
# Wait for callback |
|
wait_for_callback |
|
|
|
# Exchange code for token |
|
exchange_code_for_token |
|
|
|
# Stop server |
|
server&.shutdown |
|
logger.debug "Local server stopped" |
|
end |
|
|
|
def start_callback_server |
|
port = find_available_port |
|
self.callback_uri = "http://localhost:#{port}#{CALLBACK_PATH}" |
|
|
|
self.server = |
|
WEBrick::HTTPServer.new( |
|
Port: port, |
|
Logger: WEBrick::Log.new("/dev/null"), |
|
AccessLog: [] |
|
) |
|
|
|
server.mount_proc CALLBACK_PATH do |request, response| |
|
if request.query["code"] |
|
self.authorization_code = request.query["code"] |
|
response.content_type = "text/html;charset=utf-8" |
|
response.body = success_html |
|
logger.debug "Authorization code received" |
|
elsif request.query["error"] |
|
response.content_type = "text/html;charset=utf-8" |
|
response.body = error_html(request.query["error"]) |
|
logger.error "OAuth error received", error: request.query["error"] |
|
raise "Authentication error: #{request.query["error"]}" |
|
end |
|
end |
|
|
|
Thread.new do |
|
begin |
|
server.start |
|
rescue => e |
|
unless e.is_a?(WEBrick::HTTPServerError) |
|
logger.error "Server error", error: e.message |
|
end |
|
end |
|
end |
|
|
|
logger.info "Local callback server started", url: callback_uri |
|
end |
|
|
|
def find_available_port |
|
server = TCPServer.new("localhost", 0) |
|
port = server.addr[1] |
|
server.close |
|
port |
|
rescue => e |
|
logger.error "Failed to find available port", error: e.message |
|
raise |
|
end |
|
|
|
def generate_auth_url |
|
params = { |
|
client_id: client_id, |
|
redirect_uri: callback_uri, |
|
scope: SCOPES.join(" "), |
|
response_type: "code", |
|
access_type: "offline", |
|
prompt: "consent" |
|
} |
|
|
|
query_string = URI.encode_www_form(params) |
|
url = "https://accounts.google.com/o/oauth2/auth?#{query_string}" |
|
logger.debug "Generated auth URL", scopes: SCOPES.size |
|
url |
|
end |
|
|
|
def wait_for_callback |
|
logger.info "Waiting for authentication callback...", timeout: "5 minutes" |
|
|
|
timeout = 300 # 5 minutes |
|
start_time = Time.now |
|
|
|
sleep 1 until authorization_code || (Time.now - start_time) > timeout |
|
|
|
unless authorization_code |
|
logger.fatal "Authentication timeout - no response received" |
|
raise "Authentication timeout" |
|
end |
|
|
|
logger.success "Authorization code received" |
|
end |
|
|
|
def exchange_code_for_token |
|
logger.info "Exchanging authorization code for access token..." |
|
|
|
url = "https://oauth2.googleapis.com/token" |
|
params = { |
|
client_id: client_id, |
|
client_secret: client_secret, |
|
code: authorization_code, |
|
grant_type: "authorization_code", |
|
redirect_uri: callback_uri |
|
} |
|
|
|
begin |
|
response = make_token_request(url, params) |
|
token_data = JSON.parse(response.body.to_s) |
|
|
|
# Store token |
|
store_token( |
|
{ |
|
"access_token" => token_data["access_token"], |
|
"refresh_token" => token_data["refresh_token"], |
|
"expiry_time" => (Time.now + token_data["expires_in"]).to_i |
|
} |
|
) |
|
|
|
logger.success "Token stored successfully", file: TOKEN_STORE_FILE |
|
rescue JSON::ParserError => e |
|
logger.error "Invalid JSON response from Google", error: e.message |
|
raise |
|
rescue => e |
|
logger.error "Token exchange error", error: e.message |
|
raise |
|
end |
|
end |
|
|
|
def store_token(token_data) |
|
tokens = |
|
File.exist?(TOKEN_STORE_FILE) ? YAML.load_file(TOKEN_STORE_FILE) : {} |
|
tokens["default"] = token_data |
|
|
|
File.write(TOKEN_STORE_FILE, YAML.dump(tokens)) |
|
end |
|
|
|
def load_stored_token |
|
return nil unless File.exist?(TOKEN_STORE_FILE) |
|
|
|
begin |
|
tokens = YAML.load_file(TOKEN_STORE_FILE) |
|
tokens["default"] |
|
rescue => e |
|
logger.warn "Error loading stored token", error: e.message |
|
nil |
|
end |
|
end |
|
|
|
def test_authentication |
|
logger.info "Testing authentication with Google APIs..." |
|
|
|
stored_token = load_stored_token |
|
|
|
unless stored_token |
|
logger.error "No token found for testing" |
|
return |
|
end |
|
|
|
# Test with Google UserInfo API |
|
url = "https://www.googleapis.com/oauth2/v2/userinfo" |
|
|
|
begin |
|
response = make_api_request(url, stored_token["access_token"]) |
|
user_info = JSON.parse(response.body.to_s) |
|
|
|
logger.success "Authentication test successful!" |
|
logger.info "Authenticated user", |
|
name: user_info["name"], |
|
email: user_info["email"], |
|
id: user_info["id"] |
|
rescue => e |
|
logger.error "Authentication test error", error: e.message |
|
end |
|
end |
|
|
|
def success_html |
|
<<~HTML |
|
<!DOCTYPE html> |
|
<html> |
|
<head> |
|
<title>Authentication Successful</title> |
|
<style> |
|
body { |
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; |
|
text-align: center; |
|
padding: 50px; |
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
|
color: white; |
|
margin: 0; |
|
min-height: 100vh; |
|
display: flex; |
|
flex-direction: column; |
|
justify-content: center; |
|
} |
|
.success { color: #4CAF50; font-size: 3em; margin: 0; } |
|
.message { font-size: 1.2em; margin-top: 20px; } |
|
</style> |
|
</head> |
|
<body> |
|
<div> |
|
<div class="success">✅</div> |
|
<h1>Authentication Successful!</h1> |
|
<p class="message">You can close this window and return to your terminal.</p> |
|
</div> |
|
</body> |
|
</html> |
|
HTML |
|
end |
|
|
|
def error_html(error) |
|
<<~HTML |
|
<!DOCTYPE html> |
|
<html> |
|
<head> |
|
<title>Authentication Error</title> |
|
<style> |
|
body { |
|
font-family: -apple-system, BlinkMacSystemFont, 'Segous UI', Roboto, sans-serif; |
|
text-align: center; |
|
padding: 50px; |
|
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a24 100%); |
|
color: white; |
|
margin: 0; |
|
min-height: 100vh; |
|
display: flex; |
|
flex-direction: column; |
|
justify-content: center; |
|
} |
|
.error { color: #ff4757; font-size: 3em; margin: 0; } |
|
.message { font-size: 1.2em; margin-top: 20px; } |
|
</style> |
|
</head> |
|
<body> |
|
<div> |
|
<div class="error">❌</div> |
|
<h1>Authentication Error</h1> |
|
<p class="message">Error: #{error}</p> |
|
<p>Please close this window and try again.</p> |
|
</div> |
|
</body> |
|
</html> |
|
HTML |
|
end |
|
end |
|
|
|
# HTTP Client helper for advanced usage |
|
class HTTPClientBuilder |
|
attr_reader :client |
|
|
|
def initialize |
|
@client = HTTP |
|
end |
|
|
|
def with_timeout(connect: 30, write: 30, read: 30) |
|
@client = @client.timeout(connect: connect, write: write, read: read) |
|
self |
|
end |
|
|
|
def with_headers(headers = {}) |
|
@client = @client.headers(headers) |
|
self |
|
end |
|
|
|
def with_ssl_context(verify: true, ca_file: nil) |
|
ssl_context = { |
|
verify_mode: |
|
verify ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE |
|
} |
|
ssl_context[:ca_file] = ca_file if ca_file |
|
|
|
@client = @client.use(ssl_context: ssl_context) |
|
self |
|
end |
|
|
|
def with_retry(times: 3, delay: 1) |
|
# HTTP gem doesn't have built-in retry, but we can add it |
|
original_client = @client |
|
|
|
@client = |
|
Class |
|
.new do |
|
def initialize(client, times, delay) |
|
@client = client |
|
@times = times |
|
@delay = delay |
|
end |
|
|
|
def method_missing(method, *args, **kwargs, &block) |
|
attempt = 0 |
|
begin |
|
@client.send(method, *args, **kwargs, &block) |
|
rescue HTTP::Error => e |
|
attempt += 1 |
|
if attempt < @times |
|
sleep(@delay) |
|
retry |
|
else |
|
raise |
|
end |
|
end |
|
end |
|
|
|
def respond_to_missing?(method, include_private = false) |
|
@client.respond_to?(method, include_private) |
|
end |
|
end |
|
.new(original_client, times, delay) |
|
|
|
self |
|
end |
|
|
|
def build |
|
@client |
|
end |
|
end |
|
|
|
# Usage example and main execution |
|
if __FILE__ == $0 |
|
# Set up signal handling for graceful shutdown |
|
trap("INT") do |
|
puts "\n" |
|
logger = TTY::Logger.new |
|
logger.warn "Script interrupted by user" |
|
exit 1 |
|
end |
|
|
|
trap("TERM") do |
|
logger = TTY::Logger.new |
|
logger.warn "Script terminated" |
|
exit 1 |
|
end |
|
|
|
begin |
|
client = GoogleOAuthClient.new |
|
client.authenticate |
|
|
|
logger = client.logger |
|
logger.success "Script completed successfully!" |
|
logger.info "Token storage: #{GoogleOAuthClient::TOKEN_STORE_FILE}" |
|
logger.info "" |
|
logger.info "Next Steps:" |
|
logger.info "1. Load the token from the YAML file in your applications" |
|
logger.info "2. Use it with Google API client libraries" |
|
logger.info "3. The script will automatically refresh expired tokens" |
|
logger.info "4. Run this script again anytime to re-authenticate" |
|
|
|
# Show example of how to use the stored token with HTTP gem |
|
logger.info "" |
|
logger.info "Example usage with HTTP gem in your code:" |
|
logger.info "require 'http'" |
|
logger.info "tokens = YAML.load_file('#{GoogleOAuthClient::TOKEN_STORE_FILE}')" |
|
logger.info "access_token = tokens['default']['access_token']" |
|
logger.info "response = HTTP.auth(\"Bearer \#{access_token}\").get('https://www.googleapis.com/oauth2/v2/userinfo')" |
|
logger.info "user_info = JSON.parse(response.body)" |
|
rescue => e |
|
logger = SimpleLogger.new |
|
logger.fatal "Unexpected error: #{e.message}" |
|
|
|
if ENV["DEBUG"] || ENV["VERBOSE"] |
|
logger.debug "Stack trace:" |
|
e.backtrace.each { |line| logger.debug line } |
|
else |
|
logger.info "Run with DEBUG=1 for full stack trace" |
|
end |
|
exit 1 |
|
end |
|
end |