WardrikOAuth
Copy MarkdownOAuth sign-in for WardrikCore agents. Each grant is sealed in a WardrikVault.
Install
def deps do
[
{:wardrik_oauth, "~> 0.1.0-rc.1"}
]
endStart it
Register an OAuth app with each service and add it as a provider. Pass WardrikOAuth to the vault
as its grants:
children = [
{WardrikStore, store},
{WardrikVault, name: MyApp.Vault, store: store, keys: keys, grants: {WardrikOAuth, MyApp.OAuth}},
{WardrikOAuth,
name: MyApp.OAuth,
vault: MyApp.Vault,
store: store,
page: "https://agents.example.com/oauth",
on_granted: {MyApp.SignIns, :granted, []},
providers: [
google: [
issuer: "https://accounts.google.com",
client_id: System.fetch_env!("GOOGLE_CLIENT_ID"),
client_secret: System.fetch_env!("GOOGLE_CLIENT_SECRET"),
scopes: ~w(openid email https://www.googleapis.com/auth/calendar.readonly),
authorization_params: %{access_type: "offline", prompt: "consent"}
],
linear: [
authorization_endpoint: "https://linear.app/oauth/authorize",
token_endpoint: "https://api.linear.app/oauth/token",
client_id: System.fetch_env!("LINEAR_CLIENT_ID"),
client_secret: System.fetch_env!("LINEAR_CLIENT_SECRET"),
scopes: ~w(read)
],
github: [
issuer: "https://github.com/login/oauth",
client_id: System.fetch_env!("GITHUB_CLIENT_ID"),
client_secret: {:secret, "GITHUB_CLIENT_SECRET"},
scopes: ~w(repo),
flow: :device
]
]}
]A provider has the same name as the credential it grants. A tool that needs google signs in
through the google provider.
Endpoints are discovered from the issuer's metadata. For a service without metadata, like
Linear, give authorization_endpoint and token_endpoint.
authorization_params are sent with a browser sign-in. Google only returns a refresh token when
asked for access_type: "offline".
The client secret is sent in the form by default. Some services, like Notion, want HTTP Basic
instead. Set client_auth: :basic:
notion: [
authorization_endpoint: "https://api.notion.com/v1/oauth/authorize",
token_endpoint: "https://api.notion.com/v1/oauth/token",
client_id: System.fetch_env!("NOTION_CLIENT_ID"),
client_secret: System.fetch_env!("NOTION_CLIENT_SECRET"),
client_auth: :basic,
authorization_params: %{owner: "user"}
]Provider options
| Option | Value | Default |
|---|---|---|
issuer | the authorization server, whose metadata names its endpoints | discovered from resource |
resource | the API or MCP server the token is for | not sent |
authorization_endpoint, token_endpoint | the endpoints, for a service without metadata | discovered |
client_id, client_secret | your OAuth app's credentials | Wardrik's own, if the service allows; see Without a client ID |
client_auth | :post or :basic, how the secret is sent | :post |
scopes | scopes to ask for | none |
flow | :browser or :device | :browser |
authorization_params | extra parameters for a browser sign-in | none |
A provider needs an issuer, a resource, or both endpoints.
Secrets in the vault
A client_secret of {:secret, name} is read from the vault's deployment secrets each time a
sign-in needs it. Changing it takes no restart:
WardrikVault.put(MyApp.Vault, WardrikVault.deployment(), "GITHUB_CLIENT_SECRET", secret)Give it to agents
Pass the vault as the agent's credentials. A tool asks for a credential by the provider's name,
as an MCP server's credential_name does:
children = [
# The store, vault and WardrikOAuth above, then:
{WardrikMCP,
name: MyApp.MCP,
servers: [
github: [
transport: {:streamable_http, base_url: "https://api.githubcopilot.com/mcp/"},
credential_name: "github"
]
]},
{WardrikCore, backend: {WardrikStore.Durable, store: store}}
]
WardrikCore.start_agent(
agent_id: "acme/@jon/digest",
model: "anthropic:claude-haiku-4-5",
principal: %WardrikCore.Principal{id: "acme/@jon"},
credentials: {WardrikVault, MyApp.Vault},
capabilities: [{WardrikMCP, name: MyApp.MCP, servers: [:github]}]
)If Jon hasn't signed in, the first call that needs github parks on a sign-in. Once Jon signs in,
on_granted is called with the provider's name and Jon's principal. Resume the waiting agents
there:
defmodule MyApp.SignIns do
alias WardrikCore.Authorization
def granted(provider, principal) do
WardrikCore.list()
|> Enum.map(&WardrikCore.whereis/1)
|> Enum.filter(&waits_on?(&1, to_string(provider), principal))
|> Enum.each(&WardrikCore.resume(&1, %{}, %{principal: principal}))
end
defp waits_on?(nil, _credential, _principal), do: false
defp waits_on?(agent, credential, principal) do
case WardrikCore.parked(agent) do
{:tool_calls, results} -> Enum.any?(results, &signs_in?(&1, credential, principal))
_reason -> false
end
end
defp signs_in?({_call, {:park, %Authorization{credential: credential, principal: %{id: id}}}}, credential, %{id: id}),
do: true
defp signs_in?(_result, _credential, _principal), do: false
endWardrik does all of this for you. See Credentials and sign-ins.
Add providers at runtime
Providers added at runtime are kept in the store, so every node sees them:
:ok = WardrikOAuth.add_provider(MyApp.OAuth, "linear", resource: "https://mcp.linear.app/mcp")
WardrikOAuth.authorize(MyApp.OAuth, "linear", maria)
#=> {:ok, "https://mcp.linear.app/authorize?client_id=...&state=..."}
WardrikOAuth.providers(MyApp.OAuth)
#=> ["github", "google", "linear"]
:ok = WardrikOAuth.remove_provider(MyApp.OAuth, "linear")A provider without a client_id gets one the first time someone signs in. See
Without a client ID. The client is kept in the store, so this happens
once. client_name is the name the service shows. "Wardrik" unless set.
Mount the pages
Sign-ins come back to your app. Mount WardrikOAuth.Plug at page. In a Phoenix router:
scope "/" do
forward "/oauth", WardrikOAuth.Plug, oauth: MyApp.OAuth
endIn a Plug.Router:
forward "/oauth", to: WardrikOAuth.Plug, init_opts: [oauth: MyApp.OAuth]It serves three paths under page:
| Path | What it does |
|---|---|
/callback | Finishes a browser sign-in. Register it as each OAuth app's redirect URI, as https://agents.example.com/oauth/callback. |
/device/:id | Shows a device sign-in's code and where to enter it. |
/client | Serves the Client ID Metadata Document for Wardrik's own client_id. |
To handle the callback in your own route, call WardrikOAuth.finish/2 with its query params. It
returns {:ok, provider} or {:error, reason}.
Without a client ID
A provider with no client_id gets Wardrik's own the first time someone signs in. It's kept in the
store for every node.
- If the service supports Client ID Metadata Documents (
client_id_metadata_document_supportedin its metadata) andpageishttpsand not localhost, theclient_idis the/clientURL, ashttps://agents.example.com/oauth/client. Nothing is registered. The consent screen showsclient_name. - Otherwise, Wardrik registers itself at the service's
registration_endpoint(RFC 7591). - If neither is possible, sign-in fails with
{:no_client, name}. Give the provider your ownclient_id.
Sign in
When a tool needs a credential its principal hasn't granted, the call parks on a sign-in URL.
- Browser sign-in (the default): the URL is the service's own page. The principal signs in there
and returns to
/callback. - Device sign-in (
flow: :device): the URL is/device/:id, which shows a code to enter on any device.WardrikOAuth.flow/2returns the same code, for a CLI to show.
Once the principal grants access:
- The token is sealed in the vault.
on_grantedis called with the provider's name and the principal. Resume parked turns there.
A sign-in expires after 15 minutes. Only one runs per provider and principal at a time. Asking again returns the same URL.
[:wardrik_oauth, :flow, :done] is emitted when each sign-in ends, with :provider, :principal
and :outcome in its metadata.
Renewing
An expired token is refreshed before the principal is asked again. A token the service refused is refreshed on its next use. If it can't be refreshed, the principal is asked to sign in again.
Refreshes take a cluster-wide lock, so two nodes never spend the same refresh token.