StandardId

A comprehensive authentication engine for Rails applications, built on the security primitives introduced in Rails 7/8. StandardId provides a complete, secure-by-default solution for identity management, reducing boilerplate and eliminating common security pitfalls.

Features

🔐 Complete Authentication System

  • Web Authentication: Cookie-based sessions with CSRF protection
  • API Authentication: JWT-based tokens for API access
  • Dual Engine Architecture: Separate web (/) and API (/api) endpoints
  • Session Management: Browser sessions, device sessions, and service sessions with STI

🚀 OAuth 2.0 & OpenID Connect

  • Authorization Code Flow: Standard OAuth flow with PKCE support
  • Implicit Flow: For single-page applications
  • Client Credentials Flow: For service-to-service authentication
  • Password Flow: Direct username/password authentication
  • Refresh Token Flow: Automatic token renewal
  • Social Login: Google OAuth and Apple Sign In integration

📱 Passwordless Authentication

  • Email OTP: Send one-time passwords via email
  • SMS OTP: Send one-time passwords via SMS
  • Configurable Delivery: Host app controls message delivery
  • 10-minute Expiry: Secure time-limited codes

🏢 Multi-Tenant Support

  • Client Management: OAuth clients with secret rotation
  • Polymorphic Ownership: Clients can belong to accounts, organizations, etc.
  • Scope Management: Fine-grained permission control
  • Redirect URI Validation: Secure callback handling

🔑 Advanced Security

  • PKCE Support: Proof Key for Code Exchange
  • JWT Tokens: Stateless authentication with configurable expiry
  • Secret Rotation: Client secret management with audit trail
  • Remember Me: Extended session support
  • Account Lockout: Protection against brute force attacks

Installation

Add this line to your application's Gemfile:

gem "standard_id"

And then execute:

$ bundle install

Quick Start

1. Generate Configuration

rails generate standard_id:install

2. Configure Your Account Model

# config/initializers/standard_id.rb
StandardId.configure do |config|
  config. = "User" # or "Account"
  config.issuer = "https://your-app.com"
  config. = "/login"
end

3. Mount the Engines

# config/routes.rb
Rails.application.routes.draw do
  mount StandardId::WebEngine, at: "/", as: :standard_id_web

  namespace :api do
    mount StandardId::ApiEngine, at: "/", as: :standard_id_api
  end
end

4. Include Authentication in Controllers

# For web controllers
class ApplicationController < ActionController::Base
  include StandardId::WebAuthentication
end

# For API controllers
class ApiController < ActionController::API
  include StandardId::ApiAuthentication
end

Configuration

Basic Configuration

StandardId.configure do |config|
  # Required: Your account model
  config. = "User"

  # OAuth issuer for ID tokens
  config.issuer = "https://your-app.com"

  # Login URL for redirects
  config. = "/login"

  # Custom layout for web views
  config.web_layout = "application"

  # Passwordless delivery callbacks
  # config.passwordless_email_sender = ->(email, code) { UserMailer.send_code(email, code).deliver_now }
  # config.passwordless_sms_sender   = ->(phone, code) { SmsService.send_code(phone, code) }

  # Subset configuration
  # config.password.minimum_length = 12
  # config.password.require_special_chars = true
  # config.passwordless.code_ttl = 600
  # config.oauth.default_token_lifetime = 3600
  # config.oauth.refresh_token_lifetime = 2_592_000
  # config.oauth.token_lifetimes = {
  #   password: 8.hours.to_i,
  #   implicit: 15.minutes.to_i
  # }
end

default_token_lifetime is applied to every OAuth grant unless you override it in oauth.token_lifetimes. Keys map to OAuth grant types (for example :password, :client_credentials, :refresh_token) and should return durations in seconds. Non-token endpoint flows such as the implicit flow can be customized with their symbol key (e.g. :implicit). Refresh tokens can be tuned separately through oauth.refresh_token_lifetime.

Custom Token Claims

You can add additional JWT claims for any token issued through the OAuth token endpoint by mapping scopes to claim names and providing callbacks to resolve each claim. Scopes listed in oauth.scope_claims are evaluated against the requested token scopes; when a scope matches, every claim listed for that scope is resolved via the callable defined in oauth.claim_resolvers.

StandardId.configure do |config|
  config.oauth.scope_claims = {
    profile: %i[email display_name]
  }

  config.oauth.claim_resolvers = {
    email: ->(account:) { .email },
    display_name: ->(account:, client:) {
      "#{.name} for #{client.client_id}"
    }
  }
end

Resolvers receive keyword arguments with the context containing client, account, and request, so you can reference only what you need. This lets you, for example, pull organization info off the client application or decorate claims with account attributes.

Social Login Setup

StandardId.configure do |config|
  # Google OAuth
  config.social.google_client_id = ENV["GOOGLE_CLIENT_ID"]
  config.social.google_client_secret = ENV["GOOGLE_CLIENT_SECRET"]

  # Apple Sign In
  config.social.apple_mobile_client_id = ENV["APPLE_MOBILE_CLIENT_ID"]
  config.social.apple_client_id = ENV["APPLE_CLIENT_ID"]
  config.social.apple_private_key = ENV["APPLE_PRIVATE_KEY"]
  config.social.apple_key_id = ENV["APPLE_KEY_ID"]
  config.social.apple_team_id = ENV["APPLE_TEAM_ID"]
  config.social.allowed_redirect_url_prefixes = ["sidekicklabs://"]

  # Optional: adjust which attributes are persisted during social signup
  config.social. = ->(social_info:, provider:) {
    {
      email: social_info[:email],
      name: social_info[:name] || social_info[:given_name]
    }
  }

  # Optional: run a callback whenever a social login completes
  config.social.social_callback = ->(social_info:, provider:, tokens:, account:) {
    AuditLog.(
      provider: provider,
      email: social_info[:email],
      tokens: tokens,
      account_id: .id,
    )
  }
end

social_info is an indifferent-access hash containing at least email, name, and provider_id.

Passwordless Authentication

StandardId.configure do |config|
  # Email delivery
  config.passwordless_email_sender = ->(email, code) {
    UserMailer.send_code(email, code).deliver_now
  }

  # SMS delivery
  config.passwordless_sms_sender = ->(phone, code) {
    SmsService.send_code(phone, code)
  }
end

Usage Examples

Web Authentication

<!-- Login form -->
<%= form_with url: login_path, local: true do |f| %>
  <%= f.email_field :email, placeholder: "Email" %>
  <%= f.password_field :password, placeholder: "Password" %>
  <%= f.check_box :remember_me %>
  <%= f.label :remember_me, "Remember me" %>
  <%= f.submit "Sign In" %>
<% end %>

OAuth Authorization

# Redirect to authorization endpoint
redirect_to "/api/authorize?" + {
  response_type: "code",
  client_id: "your_client_id",
  redirect_uri: "https://your-app.com/callback",
  scope: "openid profile email",
  state: "random_state_value"
}.to_query

Social Login

# Google login
redirect_to "/api/authorize?" + {
  response_type: "code",
  client_id: "your_client_id",
  redirect_uri: "https://your-app.com/callback",
  connection: "google"
}.to_query

# Apple login
redirect_to "/api/authorize?" + {
  response_type: "code",
  client_id: "your_client_id",
  redirect_uri: "https://your-app.com/callback",
  connection: "apple"
}.to_query

Passwordless Authentication

# Start passwordless flow
POST /api/passwordless/start
{
  "connection": "email",
  "username": "user@example.com"
}

# Verify code
POST /api/passwordless/verify
{
  "connection": "email",
  "username": "user@example.com",
  "otp": "123456"
}

API Authentication

# In your API controllers
class Api::UsersController < ApiController
  before_action :authenticate_account!

  def show
    render json: 
  end
end

Database Schema

StandardId creates the following tables:

  • standard_id_accounts - User accounts
  • standard_id_identifiers - Email/phone identifiers (STI)
  • standard_id_sessions - Authentication sessions (STI)
  • standard_id_clients - OAuth clients
  • standard_id_client_secret_credentials - Client secrets
  • standard_id_password_credentials - Password storage
  • standard_id_code_challenges - OTP codes for authentication and verification

API Endpoints

Web Routes (mounted at /)

  • GET /login - Login form
  • POST /login - Process login
  • POST /logout - Logout
  • GET /signup - Signup form
  • POST /signup - Process signup
  • GET /account - Account management
  • GET /sessions - Active sessions

API Routes (mounted at /api)

  • GET /authorize - OAuth authorization endpoint
  • POST /oauth/token - Token exchange endpoint
  • GET /userinfo - OpenID Connect userinfo
  • POST /passwordless/start - Start passwordless flow
  • POST /passwordless/verify - Verify OTP code
  • GET /oauth/callback/google - Google OAuth callback
  • POST /oauth/callback/apple - Apple Sign In callback

Client Management

# Create OAuth client
client = StandardId::ClientApplication.create!(
  owner: ,
  name: "My Application",
  redirect_uris: "https://app.com/callback",
  grant_types: ["authorization_code", "refresh_token"],
  response_types: ["code"],
  scopes: ["openid", "profile", "email"]
)

# Generate client secret
secret = client.create_client_secret!(name: "Production Secret")

# Rotate client secret
new_secret = client.rotate_client_secret!

Schema DSL

Schema is declared using a routes-like DSL and can be extended by provider gems:

# core gem (already provided)
require "standard_id/config/schema"

StandardConfig.schema.draw do
  scope :base do
    field :account_class_name, type: :string, default: "User"
  end

  scope :social do
    field :google_client_id, type: :string, default: nil
  end
end

# provider gem
require "standard_id/config/schema"

StandardConfig.schema.draw do
  scope :social do
    field :my_provider_client_id, type: :string, default: nil
  end
end

Notes:

  • Multiple schema.draw calls are additive; the same scope can be extended in multiple files/gems.
  • Redefining an existing field will emit a warning; last definition wins.

Testing

StandardId includes comprehensive test coverage:

# Run all tests
bundle exec rspec

# Run specific test suites
bundle exec rspec spec/models/
bundle exec rspec spec/controllers/

Security Considerations

  • All passwords are hashed using bcrypt
  • JWT tokens are signed and verified
  • CSRF protection enabled for web requests
  • Secure session management with proper expiry
  • Client secrets are rotatable with audit trail
  • PKCE support for public clients
  • Rate limiting on authentication endpoints

Contributing

  1. Fork the repository
  2. Create your feature branch (git checkout -b feature/amazing-feature)
  3. Write tests for your changes
  4. Ensure all tests pass (bin/rspec)
  5. Commit your changes (git commit -am 'Add amazing feature')
  6. Push to the branch (git push origin feature/amazing-feature)
  7. Open a Pull Request

License

The gem is available as open source under the terms of the MIT License.