Kemal OAuth2 Integration
This skill provides expert guidance on integrating OAuth2 authentication (e.g., GitHub, Google) into Kemal applications, strictly following patterns from kemal-by-example/oauth-login.
Core Mandates
Dependencies: The route examples below use
env.sessionandenv.flash, both provided bykemal-session(env.flashsince kemal-session 1.4.0). Add it toshard.ymlandrequire "kemal-session".Configuration: Use environment variables for client IDs and secrets:
def client_id : String ENV["GITHUB_CLIENT_ID"]? || "" end def client_secret : String ENV["GITHUB_CLIENT_SECRET"]? || "" end def redirect_uri : String ENV["OAUTH_REDIRECT_URI"]? || "http://127.0.0.1:3000/auth/github/callback" endAuthorization URL: Use
URI::Paramsto construct the authorization URL with required scopes and a state parameter:def authorize_url(state : String) : String params = URI::Params.build do |form| form.add "client_id", client_id form.add "redirect_uri", redirect_uri form.add "scope", "read:user user:email" form.add "state", state end "https://github.com/login/oauth/authorize?#{params}" endState Parameter: Generate state with
Random::Secure.random_bytes(16).hexstring, store inenv.session, verify in callback, then delete:# In authorization route: state = Random::Secure.random_bytes(16).hexstring env.session.string("oauth_state", state) # In callback: stored = env.session.string?("oauth_state") env.session.delete_string("oauth_state") halt env.status(:forbidden) unless state == storedExchanging Code: Use
HTTP::Clientto exchange the authorization code for an access token. Always setAccept,Content-Type, andUser-Agentheaders:response = HTTP::Client.post( "https://github.com/login/oauth/access_token", headers: HTTP::Headers{ "Accept" => "application/json", "Content-Type" => "application/x-www-form-urlencoded", "User-Agent" => "my-app", }, body: URI::Params.encode({ "client_id" => client_id, "client_secret" => client_secret, "code" => code, "redirect_uri" => redirect_uri, }) ) return nil unless response.success? json = JSON.parse(response.body) json["access_token"]?.try(&.as_s?) rescue JSON::ParseException nil
Patterns from Source Code
OAuth Service Module (oauth-login/src/services/github_oauth.cr)
require "http/client"
require "json"
require "uri"
module OauthLogin
module GithubOauth
extend self
USER_AGENT = "kemal-oauth-login"
def client_id : String
ENV["GITHUB_CLIENT_ID"]? || ""
end
def client_secret : String
ENV["GITHUB_CLIENT_SECRET"]? || ""
end
def redirect_uri : String
ENV["OAUTH_REDIRECT_URI"]? || "http://127.0.0.1:3000/auth/github/callback"
end
def configured? : Bool
!client_id.empty? && !client_secret.empty?
end
def authorize_url(state : String) : String
params = URI::Params.build do |form|
form.add "client_id", client_id
form.add "redirect_uri", redirect_uri
form.add "scope", "read:user user:email"
form.add "state", state
end
"https://github.com/login/oauth/authorize?#{params}"
end
def exchange_code(code : String) : String?
body = URI::Params.encode({
"client_id" => client_id,
"client_secret" => client_secret,
"code" => code,
"redirect_uri" => redirect_uri,
})
response = HTTP::Client.post(
"https://github.com/login/oauth/access_token",
headers: HTTP::Headers{
"Accept" => "application/json",
"Content-Type" => "application/x-www-form-urlencoded",
"User-Agent" => USER_AGENT,
},
body: body
)
return nil unless response.success?
json = JSON.parse(response.body)
json["access_token"]?.try(&.as_s?)
rescue JSON::ParseException
nil
end
def fetch_github_user(access_token : String) : Hash(String, JSON::Any)?
response = HTTP::Client.get(
"https://api.github.com/user",
headers: HTTP::Headers{
"Authorization" => "Bearer #{access_token}",
"Accept" => "application/vnd.github+json",
"User-Agent" => USER_AGENT,
}
)
return nil unless response.success?
JSON.parse(response.body).as_h?
rescue JSON::ParseException
nil
end
end
end
OAuth Routes (oauth-login/src/routes/oauth.cr)
env.flash below is kemal-session's one-time flash message helper (see Dependencies above).
require "random"
get "/auth/github" do |env|
unless OauthLogin::GithubOauth.configured?
env.flash["error"] = "Set GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET. See README."
env.redirect "/"
next
end
state = Random::Secure.random_bytes(16).hexstring
env.session.string("oauth_state", state)
env.redirect OauthLogin::GithubOauth.authorize_url(state)
end
get "/auth/github/callback" do |env|
code = env.params.query["code"]?
state = env.params.query["state"]?
stored = env.session.string?("oauth_state")
env.session.delete_string("oauth_state")
unless code && state && stored && state == stored
env.flash["error"] = "OAuth state mismatch or missing code."
env.redirect "/"
next
end
token = OauthLogin::GithubOauth.exchange_code(code)
unless token
env.flash["error"] = "Could not exchange code for token."
env.redirect "/"
next
end
# ... fetch user, sign in, redirect
end
Best Practices
- Service Isolation: Encapsulate OAuth logic within dedicated service modules (e.g.,
GithubOauth). - Error Handling: Gracefully handle API errors and JSON parsing failures during the OAuth flow using specific exception types.
- Session Security: Use
env.sessionfor state management throughout the OAuth lifecycle. Always clean up state after verification. - Profile Fetching: Use the access token to fetch user profile information using
HTTP::Clientwith appropriateAuthorization: Bearerheaders. - Configured Check: Check if OAuth is configured before redirecting to avoid errors.
When to Use
- When implementing "Login with GitHub/Google" features.
- When interacting with external APIs that require OAuth2 authentication.
- When managing OAuth flows and callbacks in a Kemal application.