Connecting via SDK
An app connects to masks with one of three SDKs, registers through a handshake, and signs people in. From there it can act for a person at another provider, or trade a token for a narrower one.
Choosing an SDK
Section titled “Choosing an SDK”| SDK | For |
|---|---|
Masks::Rails |
A Rails app. An engine that mounts sign-in, the handshake, and token checks. |
Masks::Client |
Any Ruby process. Masks::Rails is built on it. |
@masks/client |
A single-page app, in the browser. |
The masks gem holds both Ruby SDKs, and Masks::Rails loads only when Rails does.
Masks::Rails |
Masks::Client |
@masks/client |
|
|---|---|---|---|
| Signing in with the code flow and PKCE | Yes | Yes | Yes, through a backend or in the page |
| The handshake | Yes | Yes | Starts it through a backend |
| Refreshing tokens | Yes | Yes | Yes |
| Token exchange and delegation | Asks for delegation in its handshake | Yes | No |
| Client credentials, introspection, and revocation | No | Yes | No |
| Checking access tokens, including DPoP-bound ones | Yes | Yes | No |
@masks/client runs only in a browser and holds no client secret. It verifies ID tokens, and leaves
access tokens to the API that receives them. Neither SDK sends DPoP proofs as a client.
Connecting an app
Section titled “Connecting an app”- Install the SDK. For Rails, run
bin/rails generate masks:install. See Rails apps for the whole walkthrough. - Set the issuer, such as
https://acme.auth.example.com, and the app’s resource identifier, such ashttps://app.example.com. The handshake requires a resource, and the app’s redirect URIs must share its origin. - Open the handshake. The browser goes to masks’ approval screen.
- Approve it as a person holding
masks:manageormasks:handshake. The credentials go to the app server to server, so no secret passes through the browser.
The app is then an approved client. Sign people in with the SDK’s login URL, and check access tokens on your API with its verifier.
Delegation
Section titled “Delegation”A delegation lets an app act as one person at a provider while nobody is signed in, such as syncing their Google Drive at 3 a.m. or calling their Notion MCP server.
- masks stores and refreshes the provider’s tokens.
- The app gets a token for one person’s account, after that person consented to that app.
- The app calls the provider directly, and returns to masks only when its token expires.
Each person sees every app using each of their accounts on their account page, and can stop any of them there.
Set up the provider
Section titled “Set up the provider”In /manage, on the provider’s page:
- Switch on Let applications use somebody’s Google account. The label names the provider.
- Fill in What applications may do with the provider’s scopes beyond sign-in.
- Fill in Extra parameters when connecting if the provider needs them for a refresh token.
| Provider | What applications may do | Extra parameters |
|---|---|---|
https://www.googleapis.com/auth/drive.readonly |
access_type=offline prompt=consent |
|
| Microsoft | offline_access Files.Read.All |
|
| An MCP server | nothing |
Presets fill these in. Delegation reuses the redirect URI the provider has for sign-in.
Let the app ask
Section titled “Let the app ask”Only an approved client can receive a delegation. It needs:
allowed_scopes |
masks:delegate: (every provider) or masks:delegate:google (one) |
grant_types |
refresh_token and urn:ietf:params:oauth:grant-type:token-exchange |
A handshake that asks for masks:delegate: sets both. With the Rails engine:
config.delegates = trueconfig.delegation_redirect_uri = ->(request) { "#{request.base_url}/connect/callback" }Connect an account
Section titled “Connect an account”-
Send the browser to
/authorize, keepingstateand the PKCE verifier in the session:GET https://auth.example.com/authorize?response_type=code&client_id=CLIENT_ID&redirect_uri=https://app.example.com/connect/callback&scope=openid offline_access masks:delegate:google&state=STATE&code_challenge=CHALLENGE&code_challenge_method=S256 -
masks signs the person in, asks for consent, sends them to Google if it holds no usable token, and redirects back:
GET https://app.example.com/connect/callback?code=CODE&state=STATE -
Check
state, then redeem the code as the client:POST https://auth.example.com/tokenAuthorization: Basic base64(CLIENT_ID:CLIENT_SECRET)Content-Type: application/x-www-form-urlencodedgrant_type=authorization_code&code=CODE&redirect_uri=https://app.example.com/connect/callback&code_verifier=VERIFIER{"access_token": "eyJ…","refresh_token": "Xk2…","delegations": [{ "connection": "3f1c9a0e-…", "provider": "google", "provider_name": "Google","label": "ada@example.com", "subject": "8f4c…" }]} -
Store
delegations[].connection(the account) andrefresh_token(the secret), encrypted.
subject is the same sub your sign-in receives, so you can refuse a connection finished by
someone other than who started it.
error |
Means |
|---|---|
access_denied |
The person declined, or chose a provider account belonging to someone else in masks. |
login_required |
Connecting needs a sign-in within 15 minutes. Send them again with prompt=login. |
interaction_required |
You sent prompt=none, and masks has to ask something. |
unauthorized_client |
The client is not approved. |
invalid_scope |
No provider with that key allows delegation. |
Get a provider token
Section titled “Get a provider token”When you have no provider token, or it is about to expire:
-
Refresh the secret:
POST https://auth.example.com/tokenAuthorization: Basic base64(CLIENT_ID:CLIENT_SECRET)grant_type=refresh_token&refresh_token=SECRET{ "access_token": "eyJ…", "refresh_token": "NEW_SECRET", "expires_in": 3600 }Save
NEW_SECRETfirst. The old one is spent. -
Exchange the masks access token for the provider’s:
POST https://auth.example.com/tokenAuthorization: Basic base64(CLIENT_ID:CLIENT_SECRET)grant_type=urn:ietf:params:oauth:grant-type:token-exchange&subject_token=eyJ…&subject_token_type=urn:ietf:params:oauth:token-type:access_token&requested_token_type=urn:masks:params:oauth:token-type:upstream_access_token&audience=3f1c9a0e-…{"access_token": "ya29.…","issued_token_type": "urn:masks:params:oauth:token-type:upstream_access_token","token_type": "Bearer","expires_in": 3542,"scope": "https://www.googleapis.com/auth/drive.readonly"}
Call Google with ya29.… until expires_in runs out. On a 401, discard it and repeat.
| Response | Means | Do |
|---|---|---|
400 invalid_grant |
The person stopped the app or disconnected, or the provider refused. | Ask them to connect again. |
403 insufficient_scope |
The token lacks masks:delegate:<provider>. |
Request the full scope on refresh. |
503 temporarily_unavailable |
The provider did not respond. | Retry later. Nothing was lost. |
Rules for the secret
Section titled “Rules for the secret”- Keep every new secret. Each refresh replaces it, even when the exchange after it fails.
- Refresh each secret from one place at a time. Spending it twice revokes the chain, and the person has to connect again. Lock around the refresh if more than one worker can reach it.
- Use it within 30 days. An unused secret expires, and each refresh restarts the clock.
What masks checks
Section titled “What masks checks”masks releases a provider token only when:
- the client is approved and registered for token exchange,
- the masks token is live, issued to this client, and holds
masks:delegate:<provider>, - the connection is live and belongs to that token’s person,
- the provider still allows delegation,
- the person’s delegation to this client for this connection is live,
- the exchange sends no
scope,resource, orrequested_lifetime.
Every release is recorded as a delegation.released event. Provider tokens are encrypted at rest,
and masks refreshes one within a minute of its expiry, once per connection at a time. Changing what a
provider allows makes everyone connect it again.
MCP servers
Section titled “MCP servers”An MCP server that is its own OAuth server, such as https://mcp.notion.com/mcp, is a provider
with the protocol mcp. Given its URL, masks:
- reads
/.well-known/oauth-protected-resourcefor its authorization server (RFC 9728), - reads that server’s metadata, which must offer PKCE with S256 and dynamic registration,
- registers itself (RFC 7591), with a secret when the server accepts one.
Every request names the server’s URL as resource (RFC 8707).
Connect an account and get a token as above, then send it to the MCP server as
Authorization: Bearer.
From Ruby
Section titled “From Ruby”delegations = Masks::Client.delegations(issuer, client_id:, client_secret:, redirect_uri:)
started = delegations.start(provider: "google")session[:connecting] = startedredirect_to started["url"]
held = delegations.finish(params: params, started: session.delete(:connecting))save(held.connection, held.secret)
upstream = delegations.token(secret, connection: connection)save(connection, upstream.secret)upstream.access_tokentoken refreshes and exchanges in one call, and upstream.secret replaces the secret passed in.
Delegations::Refused means the person has to connect again, and Delegations::Unavailable is worth
retrying. Both carry secret when masks had already rotated it. See Masks::Client
for the test fake.
Calling a provider while the person is present
Section titled “Calling a provider while the person is present”An app that calls a provider only while the person uses it can run its own OAuth flow there. The
identities scope names the person’s connected accounts, so the flow is a redirect with a hint:
{ "sub": "8f4c…", "identities": [ { "provider": "google", "protocol": "oidc", "sub": "10769150350006150715", "email": "ada@gmail.com" } ]}https://accounts.google.com/o/oauth2/v2/auth?…&login_hint=ada@gmail.comA pairwise client never receives identities. An
upstream subject is the same for every client, so it would undo pairwise separation.
Exchanging a token
Section titled “Exchanging a token”A token exchange (RFC 8693) trades one token for
another at /token:
- a gateway gives a downstream service a token for that service only,
- an agent holds a token recording that it acts for a person,
- a backend trades its frontend’s ID token for an access token of its own.
The client must be registered for urn:ietf:params:oauth:grant-type:token-exchange.
curl -u "$CLIENT_ID:$CLIENT_SECRET" https://auth.example.com/token \ -d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \ -d subject_token="$ACCESS_TOKEN" \ -d subject_token_type=urn:ietf:params:oauth:token-type:access_token \ -d scope="xixo:catalog:read" \ -d resource=https://xixo.example/mcpMasks::Client::Session#exchange sends the same request from Ruby.
What can be exchanged
Section titled “What can be exchanged”subject_token_type |
At most | Expires no later than | Refused when |
|---|---|---|---|
…:access_token |
The subject token’s scopes and resources. | The subject token. | It is not live, or another issuer signed it. |
…:id_token |
What the person consented to for this client. | The ID token. | Its session ended, or it was issued to another client. |
urn:masks:params:oauth:token-type:upstream_access_token (requested) |
See delegation. |
An exchange only narrows. scope, resource, and requested_lifetime can only ask for less.
Revoking the subject token revokes everything exchanged from it.
Who is acting
Section titled “Who is acting”Each exchanged token has an act claim naming its holder, nested so the chain is kept:
{ "sub": "8b1f…", "client_id": "search-service", "act": { "sub": "search-service", "act": { "sub": "gateway" } }}A client acting as a more specific party sends that party’s token as actor_token, with
actor_token_type. It must be live and issued to this client. An agent that
signs in as itself and acts for a person sends its
own token as the actor and the person’s as the subject.
A client exchanging its own ID token acts for itself, so the new token has no act unless an actor
token was sent.
masks does not exchange refresh tokens, SAML assertions, or other issuers’ tokens, and does not read
may_act.