Noizu.MCP.Auth.Server.Store behaviour (Noizu MCP v0.1.6)

Copy Markdown View Source

Persistence contract for the authorization-server facade.

Two adapters ship: Noizu.MCP.Auth.Server.Store.ETS (in-memory, single node, fine for development and for a single-replica deployment that can afford to lose sessions on restart) and Noizu.MCP.Auth.Server.Store.Ecto (raw SQL, no Ecto schemas). A host may implement its own; Noizu.MCP.Auth.Server.StoreConformance in the test support tree is the test battery that says whether it works.

The hashing rule

Every callback receives raw secrets and MUST hash them before persisting or comparing. Authorization codes, refresh tokens, login-state keys and access token identifiers arrive in the clear; Noizu.MCP.Auth.Server.Secret.token_hash/1 (SHA-256, lowercase hex) is what goes in the column. Hashing never lives in the caller — if it did, a caller that forgot would write plaintext and no adapter could tell.

Client secrets are the exception in the other direction: they arrive already hashed on %Client{secret_hash: …}, because they are hashed with PBKDF2 and a salt at registration time and the adapter must not re-hash them.

Argument order

Every callback takes the adapter's own options as its last argument — the store: {Module, opts} pair from Noizu.MCP.Auth.Server.config/1. For Store.Ecto that carries repo:; for Store.ETS it carries name:.

Optional callbacks

delete_client/2, revoke_subject_tokens/3, put_access_token/2, access_token_revoked?/2, revoke_access_token/2 and purge_expired/2 are optional. Noizu.MCP.Auth.Server.Store provides supports?/2 so callers can degrade rather than crash: with no access-token table an access token is valid until it expires, which is why the TTL is capped at 15 minutes.

Atomicity

take_authorization_code/2 and rotate_refresh_token/3 are the two callbacks where a race is a security bug rather than a glitch — two concurrent redemptions of one code, or of one refresh token, must not both succeed. Each must be a single atomic operation (one UPDATE … WHERE used_at IS NULL RETURNING *, or a serialized write), and each must distinguish "never existed" from "already used" — the latter is a replay, and a replay revokes the whole refresh family (RFC 6819 §5.2.2.3). An adapter that returns :not_found for a replay silently disables reuse detection.

Summary

Types

Adapter options — the second element of store: {Module, opts}.

Raw, unhashed credential as presented by a client.

Opaque per-adapter reason. Never rendered to a client: the endpoints map every store failure onto a fixed OAuth error.

Callbacks

Whether an access token has been revoked. Optional.

Attach a refresh family to an already-stored code.

Delete a client. Optional.

Fetch a client by its client_id.

Fetch a subject's recorded consent for a client.

Read login state without consuming it (the consent screen re-renders).

Look a refresh token up by its raw value, without consuming it.

Delete everything that expired before now. Optional.

Record an issued access token. Optional. jti is raw; store its hash.

Persist a pending authorization code. code is raw; store its hash.

Insert or replace a client. secret_hash arrives already hashed.

Record consent, replacing any prior row for the same {subject, client_id}.

Store the state carried across the upstream IdP round trip, keyed by a raw state value the adapter hashes. ttl is in seconds — login state is short-lived by construction, so an adapter may expire it eagerly.

Persist a refresh token. token is raw; store its hash.

Revoke a single access token by jti. Optional.

Withdraw consent. Withdrawing consent that was never given is :ok.

Revoke every refresh token in a family. Called on replay detection and on logout.

Revoke a single refresh token (RFC 7009). Revoking an unknown token is :ok.

Revoke every refresh token a subject holds, optionally narrowed to one client. Optional; client_id may be nil for "all clients".

Rotate old_token into new_token atomically, returning the old record.

Redeem a code atomically and exactly once.

Consume login state: read and delete atomically, so one callback cannot be replayed into two authorization codes.

Merge changes into stored login state — how a consent decision or a resolved upstream subject is recorded without a second round trip.

Functions

A random family/row identifier, formatted as a UUIDv4 string so it fits a Postgres uuid column without the library depending on a UUID package.

A random opaque credential — authorization codes, refresh tokens, state keys.

Whether an adapter implements an optional callback.

Types

opts()

@type opts() :: keyword()

Adapter options — the second element of store: {Module, opts}.

raw()

@type raw() :: String.t()

Raw, unhashed credential as presented by a client.

reason()

@type reason() :: atom() | {atom(), term()}

Opaque per-adapter reason. Never rendered to a client: the endpoints map every store failure onto a fixed OAuth error.

Callbacks

access_token_revoked?(jti, opts)

(optional)
@callback access_token_revoked?(jti :: raw(), opts()) :: boolean() | {:error, reason()}

Whether an access token has been revoked. Optional.

Fails closed for the caller: a verifier that cannot reach the store treats the token as revoked rather than as valid.

bind_authorization_code_family(code, family_id, opts)

@callback bind_authorization_code_family(code :: raw(), family_id :: String.t(), opts()) ::
  :ok | {:error, :not_found} | {:error, reason()}

Attach a refresh family to an already-stored code.

Used when the family is allocated after the code row is written (an upstream flow that resolves the subject late). Idempotent.

delete_client(client_id, opts)

(optional)
@callback delete_client(client_id :: String.t(), opts()) :: :ok | {:error, reason()}

Delete a client. Optional.

get_client(client_id, opts)

@callback get_client(client_id :: String.t(), opts()) ::
  {:ok, Noizu.MCP.Auth.Server.Client.t()}
  | {:error, :not_found}
  | {:error, reason()}

Fetch a client by its client_id.

For a CIMD client the client_id is an https URL; the adapter does not care. Returning {:error, :not_found} for an unresolvable client is what keeps the authorization endpoint from redirecting anywhere.

get_consent(subject, client_id, opts)

@callback get_consent(subject :: String.t(), client_id :: String.t(), opts()) ::
  {:ok, Noizu.MCP.Auth.Server.Store.Consent.t()}
  | {:error, :not_found}
  | {:error, reason()}

Fetch a subject's recorded consent for a client.

get_login_state(state, opts)

@callback get_login_state(state :: raw(), opts()) ::
  {:ok, map()} | {:error, :not_found} | {:error, reason()}

Read login state without consuming it (the consent screen re-renders).

get_refresh_token(token, opts)

@callback get_refresh_token(token :: raw(), opts()) ::
  {:ok, Noizu.MCP.Auth.Server.Store.RefreshToken.t()}
  | {:error, :not_found}
  | {:error, reason()}

Look a refresh token up by its raw value, without consuming it.

purge_expired(now, opts)

(optional)
@callback purge_expired(now :: DateTime.t(), opts()) ::
  {:ok, %{optional(atom()) => non_neg_integer()}} | {:error, reason()}

Delete everything that expired before now. Optional.

The host owns the schedule (a 15-minute Oban job); the library never starts a sweeper of its own. Returns per-table counts for logging.

put_access_token(t, opts)

(optional)
@callback put_access_token(Noizu.MCP.Auth.Server.Store.AccessToken.t(), opts()) ::
  :ok | {:error, reason()}

Record an issued access token. Optional. jti is raw; store its hash.

put_authorization_code(t, opts)

@callback put_authorization_code(
  Noizu.MCP.Auth.Server.Store.AuthorizationCode.t(),
  opts()
) ::
  :ok | {:error, reason()}

Persist a pending authorization code. code is raw; store its hash.

put_client(t, opts)

@callback put_client(Noizu.MCP.Auth.Server.Client.t(), opts()) ::
  {:ok, Noizu.MCP.Auth.Server.Client.t()} | {:error, reason()}

Insert or replace a client. secret_hash arrives already hashed.

put_consent(t, opts)

@callback put_consent(Noizu.MCP.Auth.Server.Store.Consent.t(), opts()) ::
  :ok | {:error, reason()}

Record consent, replacing any prior row for the same {subject, client_id}.

put_login_state(state, payload, ttl, opts)

@callback put_login_state(state :: raw(), payload :: map(), ttl :: pos_integer(), opts()) ::
  :ok | {:error, reason()}

Store the state carried across the upstream IdP round trip, keyed by a raw state value the adapter hashes. ttl is in seconds — login state is short-lived by construction, so an adapter may expire it eagerly.

The payload holds the pending authorization request (client_id, redirect_uri, scope, resource, PKCE challenge, the client's own state) plus CSRF material.

put_refresh_token(t, opts)

@callback put_refresh_token(Noizu.MCP.Auth.Server.Store.RefreshToken.t(), opts()) ::
  :ok | {:error, reason()}

Persist a refresh token. token is raw; store its hash.

revoke_access_token(jti, opts)

(optional)
@callback revoke_access_token(jti :: raw(), opts()) :: :ok | {:error, reason()}

Revoke a single access token by jti. Optional.

revoke_consent(subject, client_id, opts)

@callback revoke_consent(subject :: String.t(), client_id :: String.t(), opts()) ::
  :ok | {:error, reason()}

Withdraw consent. Withdrawing consent that was never given is :ok.

revoke_refresh_family(family_id, opts)

@callback revoke_refresh_family(family_id :: String.t(), opts()) ::
  :ok | {:error, reason()}

Revoke every refresh token in a family. Called on replay detection and on logout.

revoke_refresh_token(token, opts)

@callback revoke_refresh_token(token :: raw(), opts()) :: :ok | {:error, reason()}

Revoke a single refresh token (RFC 7009). Revoking an unknown token is :ok.

revoke_subject_tokens(subject, client_id, opts)

(optional)
@callback revoke_subject_tokens(
  subject :: String.t(),
  client_id :: String.t() | nil,
  opts()
) ::
  :ok | {:error, reason()}

Revoke every refresh token a subject holds, optionally narrowed to one client. Optional; client_id may be nil for "all clients".

rotate_refresh_token(old_token, new_token, opts)

@callback rotate_refresh_token(
  old_token :: raw(),
  new_token :: Noizu.MCP.Auth.Server.Store.RefreshToken.t(),
  opts()
) ::
  {:ok, Noizu.MCP.Auth.Server.Store.RefreshToken.t()}
  | {:error, :not_found}
  | {:error, {:replayed, Noizu.MCP.Auth.Server.Store.RefreshToken.t()}}
  | {:error, reason()}

Rotate old_token into new_token atomically, returning the old record.

  • {:ok, old} — rotated now, by this call
  • {:error, :not_found} — unknown, expired, or revoked
  • {:error, {:replayed, old}} — already rotated. This is the reuse-detection signal: the caller revokes old.family_id in full and answers invalid_grant, on the assumption that either the client or an attacker holds a copy, and there is no way to tell which.

new_token.token is raw; store its hash. The new row must carry the old row's family_id and family_expires_at.

take_authorization_code(code, opts)

@callback take_authorization_code(code :: raw(), opts()) ::
  {:ok, Noizu.MCP.Auth.Server.Store.AuthorizationCode.t()}
  | {:error, :not_found}
  | {:error, {:replayed, Noizu.MCP.Auth.Server.Store.AuthorizationCode.t()}}
  | {:error, reason()}

Redeem a code atomically and exactly once.

  • {:ok, code} — redeemed now, by this call, for the first time
  • {:error, :not_found} — no such code, or it expired
  • {:error, {:replayed, code}} — it existed and had already been redeemed. The caller revokes code.refresh_family_id and answers invalid_grant.

Collapsing the third case into the second disables replay detection, so it is part of the contract, not a nicety.

take_login_state(state, opts)

@callback take_login_state(state :: raw(), opts()) ::
  {:ok, map()} | {:error, :not_found} | {:error, reason()}

Consume login state: read and delete atomically, so one callback cannot be replayed into two authorization codes.

update_login_state(state, changes, opts)

@callback update_login_state(state :: raw(), changes :: map(), opts()) ::
  {:ok, map()} | {:error, :not_found} | {:error, reason()}

Merge changes into stored login state — how a consent decision or a resolved upstream subject is recorded without a second round trip.

Functions

generate_id()

@spec generate_id() :: String.t()

A random family/row identifier, formatted as a UUIDv4 string so it fits a Postgres uuid column without the library depending on a UUID package.

generate_token()

@spec generate_token() :: String.t()

A random opaque credential — authorization codes, refresh tokens, state keys.

supports?(adapter, arg)

@spec supports?(
  module(),
  {atom(), arity()}
) :: boolean()

Whether an adapter implements an optional callback.

Store.supports?(Store.ETS, {:purge_expired, 2})