Skip to main content

guides/authentication.md

# Authentication

barrel_mcp provides a pluggable authentication system following OAuth 2.1 patterns
as recommended by the MCP specification. Authentication is optional and configurable
per HTTP server instance.

## Overview

Authentication in barrel_mcp is handled by **providers** - modules implementing the
`barrel_mcp_auth` behaviour. The library includes several built-in providers:

| Provider | Use Case |
|----------|----------|
| `barrel_mcp_auth_none` | No authentication (default) |
| `barrel_mcp_auth_bearer` | JWT tokens or opaque Bearer tokens |
| `barrel_mcp_auth_apikey` | API key authentication |
| `barrel_mcp_auth_basic` | HTTP Basic authentication |

## Bearer Token Authentication

The most common pattern for MCP servers, supporting both JWT and opaque tokens.

### JWT with HS256

```erlang
barrel_mcp:start_http(#{
    port => 9090,
    auth => #{
        provider => barrel_mcp_auth_bearer,
        provider_opts => #{
            %% HMAC secret for HS256 signature verification
            secret => <<"your-256-bit-secret-key-here">>,

            %% Optional: Validate issuer claim
            issuer => <<"https://auth.example.com">>,

            %% Optional: Validate audience claim
            audience => <<"https://api.example.com">>,

            %% Optional: Clock skew tolerance in seconds (default: 60)
            clock_skew => 120,

            %% Optional: Custom scope claim name (default: <<"scope">>)
            scope_claim => <<"permissions">>
        },
        %% Optional: Require specific scopes
        required_scopes => [<<"mcp:read">>, <<"mcp:write">>]
    }
}).
```

### JWT with RS256/ES256 (Custom Verifier)

For asymmetric algorithms, provide a custom verifier function:

```erlang
%% Using jose library for RS256
Verifier = fun(Token) ->
    try
        JWK = jose_jwk:from_pem_file("public_key.pem"),
        case jose_jwt:verify(JWK, Token) of
            {true, {jose_jwt, Claims}, _} -> {ok, Claims};
            {false, _, _} -> {error, invalid_token}
        end
    catch
        _:_ -> {error, invalid_token}
    end
end,

barrel_mcp:start_http(#{
    port => 9090,
    auth => #{
        provider => barrel_mcp_auth_bearer,
        provider_opts => #{verifier => Verifier}
    }
}).
```

### Opaque Tokens (Token Introspection)

For tokens that require server-side validation:

```erlang
Verifier = fun(Token) ->
    %% Call your auth server's introspection endpoint
    case httpc:request(post, {
        "https://auth.example.com/introspect",
        [{"Authorization", "Bearer " ++ SecretKey}],
        "application/x-www-form-urlencoded",
        "token=" ++ binary_to_list(Token)
    }, [], []) of
        {ok, {{_, 200, _}, _, Body}} ->
            Claims = jsx:decode(list_to_binary(Body), [return_maps]),
            case maps:get(<<"active">>, Claims, false) of
                true -> {ok, Claims};
                false -> {error, invalid_token}
            end;
        _ ->
            {error, invalid_token}
    end
end,

barrel_mcp:start_http(#{
    port => 9090,
    auth => #{
        provider => barrel_mcp_auth_bearer,
        provider_opts => #{verifier => Verifier}
    }
}).
```

## API Key Authentication

Simple and effective for server-to-server communication.

### Static Key Map

```erlang
barrel_mcp:start_http(#{
    port => 9090,
    auth => #{
        provider => barrel_mcp_auth_apikey,
        provider_opts => #{
            keys => #{
                <<"ak_prod_abc123">> => #{
                    subject => <<"service-a">>,
                    scopes => [<<"read">>, <<"write">>],
                    metadata => #{team => <<"platform">>}
                },
                <<"ak_prod_xyz789">> => #{
                    subject => <<"service-b">>,
                    scopes => [<<"read">>]
                }
            }
        }
    }
}).
```

### Hashed Keys (Recommended for Production)

The recommended format is a peppered HMAC-SHA-256 digest. The
`pepper` is a server-side secret mixed into the hash so a leak of
the stored hash table on its own isn't enough to forge keys.

```erlang
Pepper = <<"random-32-byte-pepper-loaded-from-env">>,
Hash1 = barrel_mcp_auth_apikey:hash_key(<<"ak_prod_abc123">>,
                                         #{pepper => Pepper}),
Hash2 = barrel_mcp_auth_apikey:hash_key(<<"ak_prod_xyz789">>,
                                         #{pepper => Pepper}),

barrel_mcp:start_http(#{
    port => 9090,
    auth => #{
        provider => barrel_mcp_auth_apikey,
        provider_opts => #{
            keys => #{
                Hash1 => #{subject => <<"service-a">>},
                Hash2 => #{subject => <<"service-b">>}
            },
            hash_keys => true,
            pepper => Pepper
        }
    }
}).
```

Stored values look like
`<<"hmac-sha256$<base64-encoded-hash>">>`.

For verification outside the auth pipeline (config tooling,
tests), use `barrel_mcp_auth_apikey:verify_key/2` — it does a
constant-time comparison and accepts both the new format and
legacy unsalted hex SHA-256 digests for one release.

#### Migrating from legacy SHA-256

`hash_key/1` (no pepper) still produces the legacy hex SHA-256
format and is still accepted by the verifier. To migrate:

1. Generate a `pepper` and load it from a secret store.
2. Re-hash each existing key with `hash_key/2`.
3. Replace the entries in your `keys` map.
4. Drop the legacy entries.

### Custom Header Name

```erlang
barrel_mcp:start_http(#{
    port => 9090,
    auth => #{
        provider => barrel_mcp_auth_apikey,
        provider_opts => #{
            header_name => <<"X-Service-Key">>,  %% Custom header
            keys => #{<<"my-key">> => #{subject => <<"service">>}}
        }
    }
}).
```

### Dynamic Key Validation

```erlang
Verifier = fun(ApiKey) ->
    case my_db:lookup_api_key(ApiKey) of
        {ok, #{user_id := UserId, scopes := Scopes}} ->
            {ok, #{subject => UserId, scopes => Scopes}};
        not_found ->
            {error, invalid_credentials}
    end
end,

barrel_mcp:start_http(#{
    port => 9090,
    auth => #{
        provider => barrel_mcp_auth_apikey,
        provider_opts => #{verifier => Verifier}
    }
}).
```

## Basic Authentication

HTTP Basic auth - simple but requires TLS in production.

### Static Credentials

```erlang
barrel_mcp:start_http(#{
    port => 9090,
    auth => #{
        provider => barrel_mcp_auth_basic,
        provider_opts => #{
            credentials => #{
                <<"admin">> => <<"secret123">>,
                <<"readonly">> => <<"viewer456">>
            },
            realm => <<"MCP Server">>
        }
    }
}).
```

### Hashed Passwords

`hash_password/1,2` defaults to **PBKDF2-SHA256** (100k
iterations, random 16-byte salt). Stored values look like
`<<"pbkdf2-sha256$<iters>$<base64(salt)>$<base64(hash)>">>`.

```erlang
%% Hash passwords once, store the resulting binary, use that in
%% the credentials map.
AdminHash = barrel_mcp_auth_basic:hash_password(<<"secret123">>),
UserHash  = barrel_mcp_auth_basic:hash_password(<<"viewer456">>),

barrel_mcp:start_http(#{
    port => 9090,
    auth => #{
        provider => barrel_mcp_auth_basic,
        provider_opts => #{
            credentials => #{
                <<"admin">>    => AdminHash,
                <<"readonly">> => UserHash
            },
            hash_passwords => true
        }
    }
}).
```

`hash_password/2` accepts an options map:

- `algorithm``pbkdf2-sha256` (default) or `sha256-hex` (legacy,
  for migration only).
- `iterations` — PBKDF2 iteration count (default 100000).

The verification path is the public `verify_password/2`. It
accepts the modern format and legacy hex SHA-256 digests for one
release; the legacy code path logs a deprecation warning on every
match.

### With Scopes and Metadata

```erlang
barrel_mcp:start_http(#{
    port => 9090,
    auth => #{
        provider => barrel_mcp_auth_basic,
        provider_opts => #{
            credentials => #{
                <<"admin">> => #{
                    password => <<"secret123">>,
                    scopes => [<<"read">>, <<"write">>, <<"admin">>],
                    metadata => #{role => <<"administrator">>}
                }
            }
        }
    }
}).
```

## Custom Authentication Provider

Implement the `barrel_mcp_auth` behaviour for custom authentication:

```erlang
-module(my_oauth_provider).
-behaviour(barrel_mcp_auth).

-export([init/1, authenticate/2, challenge/2]).

%% Initialize provider state
init(Opts) ->
    ClientId = maps:get(client_id, Opts),
    ClientSecret = maps:get(client_secret, Opts),
    IntrospectUrl = maps:get(introspect_url, Opts),
    {ok, #{
        client_id => ClientId,
        client_secret => ClientSecret,
        introspect_url => IntrospectUrl
    }}.

%% Authenticate a request
authenticate(Request, State) ->
    Headers = maps:get(headers, Request, #{}),
    case barrel_mcp_auth:extract_bearer_token(Headers) of
        {ok, Token} ->
            introspect_token(Token, State);
        {error, no_token} ->
            {error, unauthorized}
    end.

%% Generate challenge response for failed auth
challenge(unauthorized, State) ->
    Realm = maps:get(realm, State, <<"mcp">>),
    {401, #{
        <<"www-authenticate">> => <<"Bearer realm=\"", Realm/binary, "\"">>
    }, <<"{\"error\":\"unauthorized\"}">>};
challenge(invalid_token, _State) ->
    {401, #{
        <<"www-authenticate">> => <<"Bearer error=\"invalid_token\"">>
    }, <<"{\"error\":\"invalid_token\"}">>};
challenge(insufficient_scope, _State) ->
    {403, #{
        <<"www-authenticate">> => <<"Bearer error=\"insufficient_scope\"">>
    }, <<"{\"error\":\"insufficient_scope\"}">>}.

%% Internal: Token introspection
introspect_token(Token, #{introspect_url := Url} = State) ->
    %% Your introspection logic here
    case call_introspection_endpoint(Token, Url, State) of
        {ok, #{<<"active">> := true} = Claims} ->
            {ok, #{
                subject => maps:get(<<"sub">>, Claims),
                scopes => parse_scopes(maps:get(<<"scope">>, Claims, <<>>)),
                claims => Claims
            }};
        _ ->
            {error, invalid_token}
    end.
```

## Accessing Auth Info in Handlers

After successful authentication, auth info is available in the request:

```erlang
my_tool_handler(Args) ->
    case maps:get(<<"_auth">>, Args, undefined) of
        undefined ->
            %% No auth (using barrel_mcp_auth_none)
            do_something_anonymous();
        #{subject := Subject, scopes := Scopes} = AuthInfo ->
            %% Authenticated request
            case lists:member(<<"admin">>, Scopes) of
                true -> do_admin_action(Subject);
                false -> do_user_action(Subject)
            end
    end.
```

## Error Responses

Authentication failures return proper HTTP status codes and WWW-Authenticate headers:

| Error | Status | Description |
|-------|--------|-------------|
| `unauthorized` | 401 | No credentials provided |
| `invalid_token` | 401 | Token is malformed or signature invalid |
| `expired_token` | 401 | Token has expired |
| `invalid_credentials` | 401 | Wrong username/password or API key |
| `insufficient_scope` | 403 | Token lacks required scopes |

## OAuth grant flows

`barrel_mcp_client` ships three grants plus a registration
pre-step. Pick by **who is in the loop and when**:

### Authorization Code + PKCE — interactive

For flows where a real user authorises the host. Browser
redirect, PKCE prevents code interception, refresh on 401.

```
User    Host                   AS                  MCP server
 │  click "connect"             │                       │
 │──►│ redirect + PKCE          │                       │
 │   │─────────────────────────►│                       │
 │   │ login + consent          │                       │
 │   │◄─────────────────────────│                       │
 │   │ exchange_code +          │                       │
 │   │ code_verifier            │                       │
 │   │─────────────────────────►│                       │
 │   │ access_token +           │                       │
 │   │ refresh_token            │                       │
 │   │◄─────────────────────────│                       │
 │   │ Bearer access_token                              │
 │   │──────────────────────────────────────────────────►│
 │   │       (on 401: refresh_token grant)              │
```

```erlang
auth => {oauth, #{
    access_token   => <<"...">>,            % required
    refresh_token  => <<"...">>,            % optional, enables refresh
    token_endpoint => <<"https://idp/oauth/token">>,
    client_id      => <<"...">>,
    client_secret  => <<"...">>,            % optional confidential client
    resource       => <<"https://mcp/...">>,
    scopes         => [<<"mcp.read">>, <<"mcp.write">>]
}}
```

The host drives the browser dance and feeds the resulting tokens
in. The library handles the refresh.

### Client Credentials — unattended (M2M)

For agent hosts running without a human. The host already has
its own credentials.

```
Host                              AS                  MCP server
 │  POST /token                    │                       │
 │  grant=client_credentials       │                       │
 │  + Basic <client_id:secret>     │                       │
 │────────────────────────────────►│                       │
 │  access_token                   │                       │
 │◄────────────────────────────────│                       │
 │  Bearer access_token                                    │
 │────────────────────────────────────────────────────────►│
 │             (on 401: re-acquire via same grant)         │
```

```erlang
auth => {oauth_client_credentials, #{
    token_endpoint   => <<"https://idp/oauth/token">>,
    client_id        => <<"my-agent-host">>,
    client_secret    => <<"...">>,          % OR client_assertion (JWT)
    resource         => <<"https://mcp/...">>,
    scopes           => [<<"mcp.read">>]
}}
```

Eager fetch on init — a misconfigured client fails up front.
Re-acquires via the same grant on 401. No `refresh_token` is
involved.

### Enterprise-Managed Authorization — SSO chain

For SSO-driven hosts. The user already has a session at the org
IdP; their identity flows into a short-lived MCP access token
without re-prompting. Two-step chain (RFC 8693 → RFC 7523).

```
User    Host        IdP             AS                MCP server
 │  active SSO session              │                       │
 │  ID Token                        │                       │
 │ ─────────►│                      │                       │
 │           │ POST /token          │                       │
 │           │ grant=token-exchange │                       │
 │           │ subject_token=<id_token>                     │
 │           │ audience=<AS issuer> │                       │
 │           │ resource=<MCP url>   │                       │
 │           │─────────────────────►│                       │
 │           │ ID-JAG (signed JWT)  │                       │
 │           │◄─────────────────────│                       │
 │           │ POST /token          │                       │
 │           │ grant=jwt-bearer     │                       │
 │           │ assertion=<ID-JAG>   │                       │
 │           │─────────────────────►│                       │
 │           │  access_token        │                       │
 │           │◄─────────────────────│                       │
 │           │  Bearer access_token                         │
 │           │─────────────────────────────────────────────►│
 │           │ (on 401: re-walk the chain)                 │
 │           │ (id_token expires →                         │
 │           │  {error, subject_token_expired})            │
```

```erlang
auth => {oauth_enterprise, #{
    idp_token_endpoint => <<"https://idp/oauth/token">>,
    as_token_endpoint  => <<"https://as/oauth/token">>,
    client_id          => <<"...">>,
    client_secret      => <<"...">>,        % OR client_assertion
    subject_token      => <IdToken>,        % from IdP, opaque
    subject_token_type =>
        <<"urn:ietf:params:oauth:token-type:id_token">>, % or saml2
    audience           => <<"https://as">>,
    resource           => <<"https://mcp/...">>,
    scopes             => [<<"mcp.read">>]
}}
```

The library treats `subject_token` as opaque — both OIDC and
SAML modes hit the same code path. The browser flow at the IdP
stays a host concern.

### Dynamic Client Registration — pre-step

Not a grant. Used **before** any of the others when the host
doesn't have a `client_id` yet (fresh deployment, distributed
host, dev sandbox). RFC 7591.

```
Host                                    AS
 │  POST /register                       │
 │  { client_name, redirect_uris,        │
 │    grant_types, ... }                 │
 │──────────────────────────────────────►│
 │  { client_id, client_secret?,         │
 │    client_id_issued_at, ... }         │
 │◄──────────────────────────────────────│
```

```erlang
{ok, Info} = barrel_mcp_client_auth_oauth:register_client(
    <<"https://idp/oauth/register">>,
    #{<<"client_name">> => <<"my-host">>, ...}),
ClientId     = maps:get(<<"client_id">>, Info),
ClientSecret = maps:get(<<"client_secret">>, Info, undefined).
```

For protected registration endpoints (RFC 7591 section 3) pass
the AS-issued initial access token via `register_client/3`:

```erlang
{ok, Info} = barrel_mcp_client_auth_oauth:register_client(
    <<"https://idp/oauth/register">>,
    #{<<"client_name">> => <<"my-host">>, ...},
    #{initial_access_token => <<"...">>}).
```

Feed the returned credentials into one of the grants above. The
library does not persist them; that's a host concern.

### Where they overlap on the wire

All grants hit the same OAuth-server token endpoint with
`application/x-www-form-urlencoded` bodies. Confidential clients
authenticate with HTTP Basic; `private_key_jwt` clients pass a
`client_assertion` instead. RFC 8707 `resource` is attached on
every grant. The MCP `2025-11-25` auth sub-spec layers
[RFC 9728 PRM](#oauth-protected-resource-metadata-rfc-9728) on
top so any of the grants can be auto-discovered from a `401`
response.

### When to pick which

| Situation | Use |
|---|---|
| Real user, browser available, host wants their identity | `auth_code` (`{oauth, ...}`) |
| Background agent / cron / unattended host | `client_credentials` (`{oauth_client_credentials, ...}`) |
| Enterprise SSO; user identity must flow to MCP | `enterprise_managed` (`{oauth_enterprise, ...}`) |
| No `client_id` yet | `register_client/2` first, then one of the above |

## OAuth Protected Resource Metadata (RFC 9728)

For OAuth-protected deployments, MCP clients auto-discover the
authorization server by:

1. Hitting any endpoint and receiving a 401 with
   `WWW-Authenticate: Bearer ... resource_metadata="<URL>"`.
2. Following the URL to fetch the
   [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728)
   Protected Resource Metadata document.
3. Reading `authorization_servers` from the document and
   completing the OAuth dance against one of them.

The server side of this loop is opt-in via a `resource_metadata`
option on `barrel_mcp:start_http_stream/1` (and
`start_http/1`):

```erlang
{ok, _} = barrel_mcp:start_http_stream(#{
    port => 8080,
    auth => #{provider => barrel_mcp_auth_bearer,
              provider_opts => #{secret => Secret}},
    resource_metadata => #{
        resource              => <<"http://localhost:8080/mcp">>,
        authorization_servers => [<<"https://idp.example.com">>]
    }
}).
```

When set:

- `/.well-known/oauth-protected-resource` is served by the HTTP
  transport as a JSON metadata document.
- The bearer challenge on 401 emits
  `resource_metadata="<absolute PRM URL>"`. The PRM URL is
  derived from `resource` by default; pass
  `metadata_url => <<"https://...">>` in the option map to
  override.

The audience-claim string in `state.resource` (used for token
verification by `barrel_mcp_auth_bearer`) is unaffected; only
the wire emission of `WWW-Authenticate` changed.

The client side is implemented by
`barrel_mcp_client_auth_oauth:parse_www_authenticate/1` and
`discover_protected_resource/1` — together with the server side
above, the MCP authorization sub-spec discovery flow works
end-to-end.

## Dynamic Client Registration (RFC 7591)

Hosts that don't have a pre-issued `client_id` for the target
authorization server can register one programmatically. The AS
metadata document advertises the endpoint via
`registration_endpoint`.

```erlang
{ok, Info} = barrel_mcp_client_auth_oauth:register_client(
    <<"https://idp.example.com/oauth/register">>,
    #{<<"client_name">>   => <<"my-mcp-host">>,
      <<"redirect_uris">> => [<<"http://localhost:5173/callback">>],
      <<"grant_types">>   => [<<"authorization_code">>,
                              <<"refresh_token">>],
      <<"response_types">> => [<<"code">>],
      <<"token_endpoint_auth_method">> => <<"none">>}).

%% Info now contains:
%%   #{<<"client_id">>           => <<"...">>,
%%     <<"client_secret">>       => <<"...">>,   % if confidential
%%     <<"client_id_issued_at">> => 1700000000,  % UNIX epoch
%%     ...}
```

Feed the returned credentials into a subsequent
`{oauth, ...}` / `{oauth_client_credentials, ...}` connect spec.
This stays a standalone exchanger — the library doesn't persist
the issued credentials. That's a host concern (file, DB, secret
manager).

## Security Best Practices

1. **Always use TLS** in production
2. **Hash stored credentials** using the provided hash functions
3. **Use short-lived tokens** with refresh capability
4. **Validate audience claims** to prevent token misuse
5. **Implement rate limiting** at the transport layer
6. **Log authentication failures** for security monitoring