comrade v0.2.0

A Crystal OAuth library inspired by Laravel Socialite for simple, elegant OAuth authentication.

Comrade

Crystal shard CI Status License: MIT

A Crystal OAuth library inspired by Laravel Socialite for simple, elegant OAuth authentication with enterprise SSO support.

Table of Contents

Background

Comrade provides a simple, elegant interface for OAuth authentication with various providers like GitHub, Google, and more. Inspired by Laravel Socialite, it offers a fluent API for handling OAuth flows in Crystal applications with enterprise-ready SSO capabilities through WorkOS.

The library follows a singleton pattern with a centralized Manager for provider configuration and supports both confidential and public client flows, including PKCE for enhanced security. It seamlessly integrates with popular OAuth providers while also offering enterprise SSO through SAML and OIDC connections.

Install

Add the dependency to your shard.yml:

dependencies:
  comrade:
    github: ButterbaseApp/comrade
    version: ~> 0.1.0

Run shards install to install the dependency.

Usage

Basic Setup

require "comrade"

# Configure a provider from environment variables
Comrade.register_provider(
  :github,
  "GITHUB_CLIENT_ID",
  "GITHUB_CLIENT_SECRET",
  "http://localhost:3000/auth/github/callback",
  ["user:email"]
)

# Configure WorkOS for enterprise SSO
Comrade.register_provider(
  :workos,
  "WORKOS_CLIENT_ID",
  "WORKOS_CLIENT_SECRET",
  "http://localhost:3000/auth/workos/callback"
)

# Or configure globally
Comrade.configure do |config|
  config.http_timeout = 30
end

Provider Configuration

Environment Variables

# Configure GitHub OAuth
Comrade.register_provider(
  :github,
  "GITHUB_CLIENT_ID",
  "GITHUB_CLIENT_SECRET",
  "http://localhost:3000/auth/github/callback",
  ["user:email"]
)

# Configure Google OAuth
Comrade.register_provider(
  :google,
  "GOOGLE_CLIENT_ID",
  "GOOGLE_CLIENT_SECRET",
  "http://localhost:3000/auth/google/callback",
  ["openid", "profile", "email"]
)

# Configure WorkOS enterprise SSO
Comrade.register_provider(
  :workos,
  "WORKOS_CLIENT_ID",
  "WORKOS_CLIENT_SECRET",
  "http://localhost:3000/auth/workos/callback"
)

# Configure Keycloak with realm
Comrade.register_provider(
  :keycloak,
  "KEYCLOAK_CLIENT_ID",
  "KEYCLOAK_CLIENT_SECRET",
  "http://localhost:3000/auth/keycloak/callback"
)

Hash Configuration

config = {
  "name"         => "github",
  "client_id"    => "your-github-client-id",
  "client_secret" => "your-github-client-secret",
  "redirect_uri" => "http://localhost:3000/auth/github/callback",
  "scopes"       => ["user:email"]
}

Comrade.register_provider(:github, config)

OAuth Flow

1. Redirect User for Authorization

# Get provider instance
github = Comrade.driver(:github)

# Generate authorization URL with state parameter
auth_url = github.redirect(
  scopes: ["user:email"],
  state: "random-state-string"
)

# Redirect user to auth_url
redirect_to auth_url

2. Handle Callback and Get User

# After user authorizes, exchange code for token
code = params["code"]
state = params["state"]

# Get access token
token = github.get_token(code, state: state)

# Get user information
user = github.user(token)

puts "User: #{user.name} (#{user.email})"
puts "Avatar: #{user.avatar}"

3. Token Management

# Check if token is expired
if token.expired?
  # Refresh token if available
  if token.refreshable?
    token = github.refresh_token(token.refresh_token.not_nil!)
  end
end

# Revoke token (if supported by provider)
github.revoke_token(token.access_token)

Supported Providers

Provider Implementation Status

  • GitHub - OAuth 2.0 with email fetching, basic scopes
  • Google - OAuth 2.0 + OpenID Connect, PKCE support, token revocation
  • Facebook/Meta - OAuth 2.0, Graph API, permissions system
  • Twitter/X - OAuth 2.0, user profile and tweets access
  • Discord - OAuth 2.0, rich user/guild data
  • WorkOS - Enterprise SSO via SAML/OIDC, organization-based authentication
  • Authentik - OAuth 2.0 + OpenID Connect, self-hosted identity provider, token revocation
  • Keycloak - OAuth 2.0 + OpenID Connect, enterprise identity management, realm-based architecture
  • Microsoft - OAuth 2.0 + OpenID Connect, Azure AD support
  • Slack - OAuth 2.0, workspace integration
  • LinkedIn - OAuth 2.0, professional profile data
  • Apple - OAuth 2.0 + OpenID Connect, Sign in with Apple
  • GitLab - OAuth 2.0, developer platform integration
  • Bitbucket - OAuth 2.0, Atlassian ecosystem
  • Spotify - OAuth 2.0, music and user data
  • Strava - OAuth 2.0, fitness and activity data

GitHub

github = Comrade.driver(:github)
auth_url = github.redirect(scopes: ["user", "repo"])
user = github.user(token)

# GitHub automatically fetches primary email if not included
# Default scopes: ["user:email"]

Google

google = Comrade.driver(:google)
auth_url = google.redirect(
  scopes: ["openid", "profile", "email"],
  # Google supports offline access for refresh tokens
  code_verifier: google.generate_code_verifier  # PKCE for public clients
)
user = google.user(token)

# Default scopes: ["openid", "profile", "email"]
# Supports token revocation

Facebook/Meta

facebook = Comrade.driver(:facebook)
auth_url = facebook.redirect(
  scopes: ["email", "public_profile"],
  state: "random-state-string"
)
user = facebook.user(token)

# Facebook uses Graph API for user data
# Default scopes: ["email", "public_profile"]
# Supports token revocation via permission removal
# Note: Requires Facebook App with OAuth configuration

Twitter/X

twitter = Comrade.driver(:twitter)
auth_url = twitter.redirect(
  scopes: ["tweet.read", "users.read"],
  state: "random-state-string"
)
user = twitter.user(token)

# Twitter/X uses OAuth 2.0 with v2 API
# Default scopes: ["tweet.read", "users.read"]
# Supports token revocation
# Note: Requires Twitter Developer App with OAuth 2.0 enabled

Discord

discord = Comrade.driver(:discord)
auth_url = discord.redirect(
  scopes: ["identify", "email", "guilds"],
  state: "random-state-string"
)
user = discord.user(token)

# Get user's guilds (servers)
guilds = discord.get_user_guilds(token)

# Discord uses OAuth 2.0 with rich user/guild data
# Default scopes: ["identify", "email"]
# Supports avatar URLs, guild information, and token revocation
# Note: Requires Discord Application with OAuth2 configured

WorkOS

WorkOS provides enterprise-ready SSO capabilities through SAML and OIDC connections, perfect for B2B applications.

# Configure WorkOS provider
Comrade.register_provider(
  :workos,
  "WORKOS_CLIENT_ID",
  "WORKOS_CLIENT_SECRET",
  "http://localhost:3000/auth/workos/callback"
)

# Connection-based SSO (recommended for specific organizations)
workos = Comrade.driver(:workos)
auth_url = workos.redirect(
  connection: "conn_123456789",  # Specific SSO connection
  domain_hint: "acme.com",       # Optional: help users select IdP
  login_hint: "user@acme.com",   # Optional: pre-fill email
  state: "random-state-string"
)

# Organization-based SSO (for enterprise customers)
auth_url = workos.redirect(
  organization: "org_123456789",  # WorkOS organization
  state: "random-state-string"
)

# Direct OAuth providers (via WorkOS)
auth_url = workos.redirect(
  provider: "GoogleOAuth",         # "GoogleOAuth", "MicrosoftOAuth", "GitHubOAuth", "AppleOAuth"
  state: "random-state-string"
)

# Exchange authorization code for user
code = params["code"]
token = workos.get_token(code, state: state)
user = workos.user(token)

# Access enterprise-specific data
user.raw["organization_id"]?.try(&.as_s)      # Organization ID
user.raw["connection_id"]?.try(&.as_s)        # Connection ID
user.raw["connection_type"]?.try(&.as_s)      # "SAML", "OIDC", etc.
user.raw["groups"]?.try(&.as_a)                # User groups from IdP

# WorkOS enterprise features
# - No refresh tokens (tokens are long-lived)
# - Supports SAML and OIDC providers
# - Organization and connection management
# - Group-based access control
# - Token revocation support
# Note: Requires WorkOS account with SSO connections configured

Authentik

Authentik is an open-source identity provider that supports OAuth 2.0 and OpenID Connect. Perfect for self-hosted authentication solutions.

# Configure Authentik provider with base URL
Comrade.register_provider(
  :authentik,
  {
    "name"         => "authentik",
    "client_id"    => ENV["AUTHENTIK_CLIENT_ID"],
    "client_secret" => ENV["AUTHENTIK_CLIENT_SECRET"],
    "redirect_uri" => "http://localhost:3000/auth/authentik/callback",
    "scopes"       => ["openid", "profile", "email"],
    "base_url"     => ENV["AUTHENTIK_BASE_URL"] # e.g., "https://authentik.company.com"
  }
)

# Or configure with domain-based redirect URI (base URL extracted automatically)
Comrade.register_provider(
  :authentik,
  "AUTHENTIK_CLIENT_ID",
  "AUTHENTIK_CLIENT_SECRET",
  "https://authentik.company.com/application/o/callback/"
)

# Get Authentik driver and start OAuth flow
authentik = Comrade.driver(:authentik)
auth_url = authentik.redirect(
  scopes: ["openid", "profile", "email"],
  state: "random-state-string"
)

# Exchange authorization code for user
code = params["code"]
token = authentik.get_token(code, state: state)
user = authentik.get_user(token)

# OpenID Connect user data
user.id                                            # Subject identifier (sub)
user.email                                         # User email
user.name                                          # User display name
user.nickname                                      # Preferred username
user.avatar                                        # Profile picture URL

# Authentik-specific features
authentik.default_scopes                           # ["openid", "profile", "email"]
authentik.revoke_token(token.access_token)         # Token revocation support

# PKCE support for public clients (mobile apps, SPAs)
code_verifier = authentik.generate_code_verifier
auth_url = authentik.redirect(
  scopes: ["openid", "email"],
  code_verifier: code_verifier,
  state: "random-state-string"
)
token = authentik.get_token(code, state: state, code_verifier: code_verifier)

# Note: Requires Authentik instance with OAuth2 provider configured

Keycloak

Keycloak is an open-source identity and access management solution focused on enterprise use cases. It provides comprehensive OAuth 2.0 and OpenID Connect support with realm-based architecture for multi-tenant scenarios.

# Configure Keycloak provider with base URL and realm
Comrade.register_provider(
  :keycloak,
  {
    "name"         => "keycloak",
    "client_id"    => ENV["KEYCLOAK_CLIENT_ID"],
    "client_secret" => ENV["KEYCLOAK_CLIENT_SECRET"],
    "redirect_uri" => "http://localhost:3000/auth/keycloak/callback",
    "scopes"       => ["openid", "profile", "email"],
    "base_url"     => ENV["KEYCLOAK_BASE_URL"],  # e.g., "https://keycloak.company.com"
    "realm"        => ENV["KEYCLOAK_REALM"]      # e.g., "myrealm" or "master"
  }
)

# Or configure with realm-based redirect URI (extracted automatically)
Comrade.register_provider(
  :keycloak,
  "KEYCLOAK_CLIENT_ID",
  "KEYCLOAK_CLIENT_SECRET",
  "https://keycloak.company.com/realms/myrealm/callback"
)

# Get Keycloak driver and start OAuth flow
keycloak = Comrade.driver(:keycloak)
auth_url = keycloak.redirect(
  scopes: ["openid", "profile", "email"],
  state: "random-state-string"
)

# Exchange authorization code for user
code = params["code"]
token = keycloak.get_token(code, state: state)
user = keycloak.get_user(token)

# OpenID Connect user data
user.id                                            # Subject identifier (sub)
user.email                                         # User email
user.name                                          # User display name
user.nickname                                      # Preferred username
user.avatar                                        # Profile picture URL

# Keycloak-specific features
keycloak.default_scopes                            # ["openid", "profile", "email"]
keycloak.revoke_token(token.access_token)          # Token revocation support

# End session (logout user)
logout_url = keycloak.end_session(
  id_token_hint: token.id_token,                   # Optional: ID token from token response
  post_logout_redirect_uri: "http://localhost:3000/logout"
)

# Get OpenID Connect configuration
oidc_config = keycloak.oidc_configuration
jwks = keycloak.jwks                               # JSON Web Key Set for token validation

# Token refresh with optional scope
refreshed_token = keycloak.refresh_token(
  token.refresh_token.not_nil!,
  scope: "openid profile email"                     # Optional: request different scopes
)

# PKCE support for public clients (mobile apps, SPAs)
code_verifier = keycloak.generate_code_verifier
auth_url = keycloak.redirect(
  scopes: ["openid", "email"],
  code_verifier: code_verifier,
  state: "random-state-string"
)
token = keycloak.get_token(code, state: state, code_verifier: code_verifier)

# Note: Requires Keycloak server with OpenID Connect client configured
# Typical Keycloak endpoints:
# - Authorization: https://keycloak.company.com/realms/{realm}/protocol/openid-connect/auth
# - Token:        https://keycloak.company.com/realms/{realm}/protocol/openid-connect/token
# - User Info:    https://keycloak.company.com/realms/{realm}/protocol/openid-connect/userinfo
# - Logout:       https://keycloak.company.com/realms/{realm}/protocol/openid-connect/logout
# - JWKS:         https://keycloak.company.com/realms/{realm}/protocol/openid-connect/certs

API

Comrade Module

  • Comrade.driver(name : Symbol) - Get configured provider instance
  • Comrade.configure(&block) - Configure global settings
  • Comrade.register_provider(name, ...) - Register provider configuration
  • Comrade.provider_configured?(name) - Check if provider is configured
  • Comrade.remove_provider(name) - Remove provider configuration

Provider Methods

All providers implement the following interface:

  • redirect(scopes, state, **options) - Generate authorization URL
  • get_token(code, state, **options) - Exchange code for access token
  • get_user(token) - Get user information using access token
  • refresh_token(refresh_token, **options) - Refresh access token
  • revoke_token(token) - Revoke access token (if supported)

User Object

user.id        # String - User ID from provider
user.nickname  # String? - Username/handle
user.name      # String? - Full display name
user.email     # String? - Email address
user.avatar    # String? - Avatar URL
user.raw       # JSON::Any - Raw provider response

# Helper methods
user.has_email?      # Bool
user.has_name?       # Bool
user.has_avatar?     # Bool
user.display_name    # String - name || nickname || id
user.to_h            # Hash - User data as hash
user.get_raw_field("field") # Get raw field from provider response

Token Object

token.access_token   # String - OAuth access token
token.refresh_token  # String? - Refresh token (if available)
token.expires_in     # Int32? - Token lifetime in seconds
token.scope          # String? - Granted scopes
token.token_type     # String? - Token type (usually "Bearer")
token.created_at     # Time - Token creation time

# Helper methods
token.expired?           # Bool
token.expiring_soon?     # Bool (within 5 minutes by default)
token.expires_at         # Time?
token.refreshable?       # Bool
token.to_h              # Hash - Token data as hash

Security

  • State Parameter: Always use state parameters to prevent CSRF attacks
  • PKCE Support: Use PKCE for public clients (mobile apps, SPAs)
  • Token Storage: Store tokens securely, consider encryption
  • HTTPS: Always use HTTPS in production for OAuth redirects
  • Scope Limitation: Request only necessary scopes
  • Token Expiration: Check token expiration before use
# Secure example with state and PKCE
provider = Comrade.driver(:google)
state = provider.generate_state
code_verifier = provider.generate_code_verifier

# Store state and code_verifier in session
session["oauth_state"] = state
session["code_verifier"] = code_verifier

auth_url = provider.redirect(
  scopes: ["openid", "profile", "email"],
  state: state,
  code_verifier: code_verifier
)

# In callback, verify state first
if params["state"] != session["oauth_state"]
  raise "Invalid state parameter"
end

Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

Development

  1. Clone the repository
  2. Create your feature branch (git checkout -b my-new-feature)
  3. Install dependencies (shards install)
  4. Run tests (crystal spec)
  5. Run linter (ameba)
  6. Commit your changes using Conventional Commits
  7. Push to the branch (git push origin my-new-feature)
  8. Create a new Pull Request

Commit Messages

This project follows the Conventional Commits specification. Commit messages should be structured as follows:

<type>[optional scope]: <description>

[optional body]

[optional footer(s)]

Common types:

  • feat: A new feature
  • fix: A bug fix
  • docs: Documentation only changes
  • style: Code formatting changes (white-space, etc)
  • refactor: Code change that neither fixes a bug nor adds a feature
  • test: Adding missing tests or correcting existing tests
  • build: Changes that affect the build system or external dependencies
  • ci: Changes to CI configuration files and scripts
  • chore: Other changes that don't modify src or test files

Examples:

  • feat: add GitHub OAuth provider
  • fix(auth): resolve token expiration issue
  • docs: update installation instructions
  • ci: add multi-version Crystal testing

Running Tests

# Run all tests
crystal spec

# Run specific test file
crystal spec spec/comrade_spec.cr

# Run with coverage
crystal spec --coverage

Code Style

This project uses Ameba for static analysis. Run ameba to check code style before submitting pull requests.

License

This project is licensed under the MIT License - see the LICENSE file for details.

Repository

comrade

Owner
Statistic
  • 2
  • 0
  • 0
  • 0
  • 2
  • 5 days ago
  • October 14, 2025
License

MIT License

Links
Synced at

Wed, 14 Jan 2026 13:17:19 GMT

Languages