Docs menu: Custom strategy guide

Writing a custom strategy

Build a Vestibule OAuth2 provider strategy from scratch.

What the core handles

Vestibule generates and validates CSRF state. It manages PKCE and resolves configured scopes. It also adds PKCE parameters and sends token-refresh operations to the strategy.

  • Your strategy builds provider URLs.
  • Your strategy exchanges codes for credentials.
  • Your strategy fetches and normalizes user info.

The Strategy type

The provider name, default scopes, and builder functions define the contract. Vestibule carries the custom error payload type through AuthError(e).

pub opaque type Strategy(e)
pub fn new(
provider provider: String,
default_scopes default_scopes: List(String),
authorize_url authorize_url: fn(
ClientConfig,
AuthorizeOptions,
List(String),
String,
) ->
Result(String, AuthError(e)),
exchange_code exchange_code: fn(ClientConfig, String, Option(String)) ->
Result(ExchangeResult, AuthError(e)),
fetch_user fetch_user: fn(ClientConfig, ExchangeResult) ->
Result(UserResult, AuthError(e)),
) -> Strategy(e)
1

Create the provider package

Put a provider in a separate package. This example creates a Twitch strategy package. It uses Vestibule and the OAuth helpers that the built-in strategies use.

name = "vestibule_twitch"
version = "0.1.0"
description = "Twitch OAuth strategy for Vestibule"
licences = ["MIT"]
gleam = ">= 1.7.0"
[dependencies]
vestibule = { git = "https://github.com/tylerbutler/vestibule.git", ref = "v0" }
gleam_stdlib = ">= 0.48.0 and < 2.0.0"
gleam_http = ">= 4.3.0 and < 5.0.0"
gleam_httpc = ">= 5.0.0 and < 6.0.0"
gleam_json = ">= 3.1.0 and < 4.0.0"
glow_auth = ">= 1.0.1 and < 2.0.0"
2

Build the authorization URL

The function receives the resolved scopes and the state value. Return the provider URL. Vestibule adds the PKCE challenge.

fn do_authorize_url(
cfg: ClientConfig,
options: AuthorizeOptions,
scopes: List(String),
state: String,
) -> Result(String, AuthError(e)) {
let assert Ok(site) = uri.parse("https://id.twitch.tv")
use redirect <- result.try(
provider_support.parse_redirect_uri(config.redirect_uri(cfg)),
)
use secret <- result.try(config.client_secret(cfg))
let client =
glow_auth.Client(
id: config.client_id(cfg),
secret: secret,
site: site,
)
let url =
authorize_uri.build(
client,
uri_builder.RelativePath("/oauth2/authorize"),
redirect,
)
|> authorize_uri.set_scope(string.join(scopes, " "))
|> authorize_uri.set_state(state)
|> authorize_uri.to_code_authorization_uri()
|> uri.to_string()
|> provider_support.append_query_params(
dict.to_list(config.extra_params(options)),
)
Ok(url)
}
3

Exchange the code and fetch the user

Send the authorization code to the provider’s token endpoint. Parse the response into ExchangeResult. Then use the credentials and artifacts to get a stable provider UID and normalized UserInfo.

fn do_exchange_code(
cfg: ClientConfig,
code: String,
code_verifier: Option(String),
) -> Result(strategy.ExchangeResult, AuthError(e)) {
// Build and send the provider token request, then parse credentials.
use body <- result.try(post_token_request(cfg, code, code_verifier))
provider_support.parse_oauth_token_response(
body,
provider_support.OptionalScope(" "),
)
|> result.map(strategy.exchange_result)
}
fn do_fetch_user(
_cfg: ClientConfig,
exchange: strategy.ExchangeResult,
) -> Result(strategy.UserResult, AuthError(e)) {
use auth_header <- result.try(
strategy.authorization_header(strategy.exchange_credentials(exchange)),
)
use #(uid, info) <- result.try(provider_support.fetch_json_with_auth(
"https://id.twitch.tv/oauth2/userinfo",
auth_header,
parse_user_response,
"Twitch userinfo",
))
Ok(strategy.user_result(uid: uid, info: info, extra: dict.new()))
}
4

Expose the final strategy value

Export a strategy() function. Return strategy.Strategy(e) if the provider uses only built-in error kinds. Return strategy.Strategy(YourError) if the provider wraps a domain error payload with error.custom.

pub fn strategy() -> Strategy(e) {
strategy.new(
provider: "twitch",
default_scopes: ["user:read:email"],
authorize_url: do_authorize_url,
exchange_code: do_exchange_code,
fetch_user: do_fetch_user,
)
|> strategy.with_refresh(do_refresh_token)
}

Authoring checklist

  • Use provider_support helpers. Do not copy built-in internals unless necessary.
  • Keep scopes minimal and provider-owned.
  • Keep optional provider fields optional when you normalize user info.
  • Document refresh-token behavior. Providers use different rotation rules and returned scopes.
  • Add tests for URL building, token parsing, profile normalization, and failure responses.