Skip to content

Instantly share code, notes, and snippets.

@matiaskorhonen
Created November 25, 2025 11:17
Show Gist options
  • Select an option

  • Save matiaskorhonen/b3cd4705912b2cf69e2b14102f95b831 to your computer and use it in GitHub Desktop.

Select an option

Save matiaskorhonen/b3cd4705912b2cf69e2b14102f95b831 to your computer and use it in GitHub Desktop.
Google OAuth in a CLI script

Google API OAuth Client Setup Guide

This guide walks you through creating OAuth 2.0 credentials in the Google Cloud Console to use with the Google OAuth authentication script.

Prerequisites

Step 1: Create or Select a Google Cloud Project

Option A: Create a New Project

  1. Go to the Google Cloud Console
  2. Click the project dropdown at the top of the page
  3. Click "New Project"
  4. Enter a project name (e.g., "My OAuth App")
  5. Optionally select a billing account and organization
  6. Click "Create"

Option B: Use an Existing Project

  1. Go to the Google Cloud Console
  2. Click the project dropdown at the top
  3. Select your existing project from the list

Step 2: Enable Required APIs

  1. In the Google Cloud Console, navigate to "APIs & Services" → "Library"
  2. Search for and enable the following APIs (click each, then click "Enable"):
    • Google+ API (for user profile information)
    • Google Drive API (if you plan to access Drive files)
    • Any other Google APIs you plan to use

Note: You can always enable additional APIs later as needed.

Step 3: Configure OAuth Consent Screen

Before creating credentials, you need to configure the OAuth consent screen:

  1. Navigate to "APIs & Services" → "OAuth consent screen"
  2. Choose your user type:
    • Internal: Only for Google Workspace users in your organization
    • External: For any Google account users (recommended for most cases)
  3. Click "Create"

Fill in Required Information

App Information:

  • App name: Enter a name for your application (e.g., "My OAuth Client")
  • User support email: Your email address
  • App logo: Optional, but recommended for production apps

App domain (Optional for testing):

  • Application home page: Your app's homepage URL
  • Application privacy policy link: Link to your privacy policy
  • Application terms of service link: Link to your terms of service

Developer contact information:

  • Email addresses: Your email address
  1. Click "Save and Continue"

Configure Scopes (Optional)

  1. On the "Scopes" page, click "Add or Remove Scopes"
  2. Select the scopes your application needs:
    • userinfo.email - Access to user's email address
    • userinfo.profile - Access to user's basic profile info
    • drive.readonly - Read-only access to Google Drive files
  3. Click "Update" then "Save and Continue"

Test Users (For External Apps in Testing Mode)

If you selected "External" and your app is in testing mode:

  1. Click "Add Users"
  2. Add email addresses of users who can test your app
  3. Click "Save and Continue"

Step 4: Create OAuth 2.0 Credentials

  1. Navigate to "APIs & Services" → "Credentials"
  2. Click "+ Create Credentials"
  3. Select "OAuth client ID"

Configure OAuth Client

  1. Application type: Select "Desktop application"

    • This is the correct type for command-line scripts and desktop apps
  2. Name: Enter a descriptive name (e.g., "OAuth Desktop Client")

  3. Click "Create"

Step 5: Download Credentials

  1. After creating the OAuth client, a dialog will appear with your credentials
  2. Click "Download JSON" to download the credentials file
  3. Save the file as credentials.json in the same directory as your OAuth script

Credentials File Structure

The downloaded JSON file will look like this:

{
  "installed": {
    "client_id": "your-client-id.apps.googleusercontent.com",
    "project_id": "your-project-id",
    "auth_uri": "https://accounts.google.com/o/oauth2/auth",
    "token_uri": "https://oauth2.googleapis.com/token",
    "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
    "client_secret": "your-client-secret",
    "redirect_uris": ["http://localhost"]
  }
}

Step 6: Security Considerations

For Development/Testing

  • Keep your credentials.json file secure and never commit it to version control
  • Add credentials.json to your .gitignore file
  • The "Desktop application" type is appropriate for local development

For Production

Consider these additional security measures:

  1. Restrict your OAuth client:

    • Go back to "Credentials" in the Google Cloud Console
    • Click on your OAuth client ID
    • Under "Authorized redirect URIs", add only the specific localhost URLs you need
  2. Use environment variables:

    export GOOGLE_CLIENT_ID="your-client-id"
    export GOOGLE_CLIENT_SECRET="your-client-secret"
  3. Implement proper token storage:

    • Store tokens securely (encrypted at rest)
    • Implement token rotation
    • Use short-lived access tokens when possible

Step 7: Test Your Setup

  1. Place the downloaded credentials.json file in your project directory
  2. Run your OAuth script:
    ./google_oauth_client.rb
  3. The script should:
    • Open your browser automatically
    • Redirect to Google's authorization page
    • Ask for permission to access your account
    • Redirect back to a success page
    • Store tokens in tokens.yaml

Common Issues and Solutions

"This app isn't verified" Warning

If you see a warning about an unverified app:

  1. Click "Advanced"
  2. Click "Go to [Your App Name] (unsafe)"
  3. For production apps, consider going through Google's verification process

"redirect_uri_mismatch" Error

This error occurs when the redirect URI doesn't match what's configured:

  1. Go to "APIs & Services" → "Credentials"
  2. Click on your OAuth client ID
  3. Add http://localhost to "Authorized redirect URIs"
  4. The script dynamically uses different ports, so http://localhost covers all ports

"invalid_client" Error

This usually means:

  • Wrong client ID or secret
  • Credentials file is malformed
  • Project doesn't have the required APIs enabled

Quota/Rate Limiting Issues

Google APIs have usage quotas:

  1. Go to "APIs & Services" → "Quotas"
  2. Check your current usage
  3. Request quota increases if needed

Managing Your OAuth App

Monitoring Usage

  1. Go to "APIs & Services" → "Dashboard"
  2. View API usage statistics
  3. Monitor errors and quotas

Revoking Access

Users can revoke access to your app at:

Updating Scopes

If you need additional permissions:

  1. Update the scopes in your OAuth consent screen
  2. Users will need to re-authorize your app
  3. Existing tokens may need to be refreshed

Additional Resources

Troubleshooting Checklist

  • Project created and selected
  • Required APIs enabled
  • OAuth consent screen configured
  • OAuth client ID created with "Desktop application" type
  • credentials.json downloaded and placed correctly
  • File permissions allow reading credentials.json
  • No firewall blocking localhost connections
  • Browser allows pop-ups from the script

Security Note: Never share your credentials.json file or commit it to version control. Treat it like a password and store it securely.

#!/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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment