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.account_class_name = "User" # or "Account"
config.issuer = "https://your-app.com"
config.login_url = "/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.account_class_name = "User"
# OAuth issuer for ID tokens
config.issuer = "https://your-app.com"
# Login URL for redirects
config.login_url = "/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:) { account.email },
display_name: ->(account:, client:) {
"#{account.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..google_client_id = ENV["GOOGLE_CLIENT_ID"]
config..google_client_secret = ENV["GOOGLE_CLIENT_SECRET"]
# Apple Sign In
config..apple_mobile_client_id = ENV["APPLE_MOBILE_CLIENT_ID"]
config..apple_client_id = ENV["APPLE_CLIENT_ID"]
config..apple_private_key = ENV["APPLE_PRIVATE_KEY"]
config..apple_key_id = ENV["APPLE_KEY_ID"]
config..apple_team_id = ENV["APPLE_TEAM_ID"]
config..allowed_redirect_url_prefixes = ["sidekicklabs://"]
# Optional: adjust which attributes are persisted during social signup
config.. = ->(social_info:, provider:) {
{
email: [:email],
name: [:name] || [:given_name]
}
}
# Optional: run a callback whenever a social login completes
config.. = ->(social_info:, provider:, tokens:, account:) {
AuditLog.(
provider: provider,
email: [:email],
tokens: tokens,
account_id: account.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: current_account
end
end
Database Schema
StandardId creates the following tables:
standard_id_accounts- User accountsstandard_id_identifiers- Email/phone identifiers (STI)standard_id_sessions- Authentication sessions (STI)standard_id_clients- OAuth clientsstandard_id_client_secret_credentials- Client secretsstandard_id_password_credentials- Password storagestandard_id_code_challenges- OTP codes for authentication and verification
API Endpoints
Web Routes (mounted at /)
GET /login- Login formPOST /login- Process loginPOST /logout- LogoutGET /signup- Signup formPOST /signup- Process signupGET /account- Account managementGET /sessions- Active sessions
API Routes (mounted at /api)
GET /authorize- OAuth authorization endpointPOST /oauth/token- Token exchange endpointGET /userinfo- OpenID Connect userinfoPOST /passwordless/start- Start passwordless flowPOST /passwordless/verify- Verify OTP codeGET /oauth/callback/google- Google OAuth callbackPOST /oauth/callback/apple- Apple Sign In callback
Client Management
# Create OAuth client
client = StandardId::ClientApplication.create!(
owner: current_account,
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.drawcalls 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
- Fork the repository
- Create your feature branch (
git checkout -b feature/amazing-feature) - Write tests for your changes
- Ensure all tests pass (
bin/rspec) - Commit your changes (
git commit -am 'Add amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
License
The gem is available as open source under the terms of the MIT License.