Skip to content

API Key Auth — vyuh_server_plugin_auth_apikey

AuthPlugin that contributes a single ApiKeyStrategy. The strategy extracts a key from the X-API-Key header (or a configurable query param), calls your lookup callback, and maps the result to an Actor.

The right shape for partner integrations, webhook deliveries, machine- to-machine traffic, and anything where issuing a JWT is overkill.

Install

yaml
dependencies:
  vyuh_server_plugin_auth_apikey:
    hosted: https://pub.vyuh.tech
    version: ^0.1.0

Basic Wiring

dart
import 'package:vyuh_server/vyuh_server.dart';
import 'package:vyuh_server_plugin_auth_apikey/vyuh_server_plugin_auth_apikey.dart';

Future<Actor?> lookupApiKey(String key) async {
  final rows = await vyuh.db.execute(
    '''
    SELECT ak.partner_id, ak.tenant_id, ak.scopes
    FROM api_keys ak
    WHERE ak.key_hash = digest(@key, 'sha256')
      AND ak.revoked_at IS NULL
      AND (ak.expires_at IS NULL OR ak.expires_at > NOW())
    ''',
    parameters: {'key': key},
  );

  if (rows.isEmpty) return null; // key not found / revoked / expired

  final row = rows.rowAsMap(0);
  return ServiceActor(
    id: row['partner_id'] as String,
    serviceName: row['partner_name'] as String? ?? 'partner',
    tenantId: row['tenant_id'] as String?,
    roles: Set<String>.from(row['scopes'] as List? ?? const []),
  );
}

final runtime = await VyuhServer.bootstrap(
  plugins: [
    ApiKeyAuthPlugin(lookup: lookupApiKey),
  ],
);

Lookup Semantics

The lookup callback follows the auth-strategy contract:

ReturnMeaning
ActorKey valid; the request is authenticated
nullKey not recognized; the chain continues to the next strategy
Throws AuthFailedKey present but invalid (revoked, expired, malformed); the chain stops

Use the distinction:

  • A request without an X-API-Key header gets null (no credentials presented). If you stack JwtAuthPlugin first, the request can still authenticate via JWT.
  • A request with an X-API-Key that's missing from your store also gets null — same reason. The next strategy might recognize the bearer token.
  • A request with an X-API-Key that exists but was revoked should throw AuthFailed — the credential was deliberately invalidated.

Custom Header / Query Param

The defaults are X-API-Key (header) and api_key (query). Customize per-deployment:

dart
ApiKeyAuthPlugin(
  lookup: lookupApiKey,
  headerName: 'authorization',           // accept "Authorization: <key>"
  queryParam: 'token',
);

Hash-Storage Pattern

Don't store raw keys. Hash on insert; hash again on lookup. The example above uses Postgres' digest() from pgcrypto:

sql
CREATE EXTENSION IF NOT EXISTS pgcrypto;

CREATE TABLE api_keys (
  id            UUID PRIMARY KEY,
  partner_id    TEXT NOT NULL,
  tenant_id     TEXT,
  key_hash      BYTEA NOT NULL UNIQUE,
  scopes        JSONB NOT NULL DEFAULT '[]',
  created_at    TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  expires_at    TIMESTAMPTZ,
  revoked_at    TIMESTAMPTZ,
  last_used_at  TIMESTAMPTZ
);

When issuing a new key:

dart
final raw = generateRandomKey(); // 32+ bytes, hex or base64url

await vyuh.db.execute(
  '''
  INSERT INTO api_keys (id, partner_id, tenant_id, key_hash, scopes)
  VALUES (@id, @partner, @tenant, digest(@key, 'sha256'), @scopes::jsonb)
  ''',
  parameters: {
    'id': Uuid.v4(),
    'partner': partnerId,
    'tenant': tenantId,
    'key': raw,
    'scopes': jsonEncode(scopes),
  },
);

// Return `raw` to the caller exactly once. You can never recover it.

Caching for Hot Paths

Hot lookup paths (every webhook delivery) benefit from a short cache:

dart
final _cache = <String, _Entry>{};

Future<Actor?> lookupApiKey(String key) async {
  final cached = _cache[key];
  if (cached != null && cached.fetchedAt.isAfter(_oldestValid)) {
    return cached.actor;
  }
  final actor = await _dbLookup(key);
  _cache[key] = _Entry(actor, DateTime.now());
  return actor;
}

For multi-instance deployments, back the cache with Redis or use a short Postgres prepared statement to amortize the round-trip.

Stacking Strategies

A common pattern: user-facing routes use JWTs; partner routes use API keys. Stack both — the strategies live on different paths through their respective RouteModule.protectedPaths:

dart
plugins: [
  JwtAuthPlugin(verifier: verifyToken),
  ApiKeyAuthPlugin(lookup: lookupApiKey),
],
features: [
  // user-facing routes
  FeatureDescriptor(
    name: 'app',
    extend: (scope) => userRoutes.applyTo(scope),
  ),
  // partner webhook receivers
  FeatureDescriptor(
    name: 'partners',
    extend: (scope) => partnerRoutes.applyTo(scope),
  ),
],

Both strategies sit in the chain. A user request presents Authorization: Bearer <jwt>; JWT strategy returns an Actor, API key strategy never sees it. A webhook presents X-API-Key: <key>; JWT strategy returns null, API key strategy resolves it.

Where to Go Next