Skip to content

JWT Auth — vyuh_server_plugin_auth_jwt

AuthPlugin that contributes a single JwtBearerStrategy. The strategy extracts a Bearer <token> from the Authorization header, calls your verifier callback, and maps the resulting claims to an Actor.

Install

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

Wiring

The plugin asks one thing of you: a verifier callback that, given a token, returns claims (or throws on invalid token). Use any JWT library — dart_jsonwebtoken, jose, an OAuth provider SDK.

dart
import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart';
import 'package:vyuh_server/vyuh_server.dart';
import 'package:vyuh_server_plugin_auth_jwt/vyuh_server_plugin_auth_jwt.dart';

Future<Map<String, Object?>> verifyToken(String token) async {
  final jwt = JWT.verify(token, RSAPublicKey(publicKeyPem));
  return jwt.payload as Map<String, Object?>;
}

final runtime = await VyuhServer.bootstrap(
  name: 'saas-api',
  plugins: [
    JwtAuthPlugin(verifier: verifyToken),
  ],
);

Claim Mapping

By default the plugin maps these JWT claims onto Actor:

ClaimActor field
subactor.id
tenant_idactor.tenantId
rolesactor.roles
emailactor.email

Customize per-deployment:

dart
JwtAuthPlugin(
  verifier: verifyToken,
  subjectClaim: 'user_id',          // override sub
  tenantClaim: 'org_id',            // override tenant_id
  rolesClaim: 'https://app/roles',  // namespaced Auth0 claim
  emailClaim: 'preferred_email',
);

Any unmapped claims are still available as actor.claims['...'].

JWKS / OAuth Providers

For Auth0, Cognito, Firebase, Okta, and similar identity providers, write a verifier that fetches and caches the provider's JWKS:

dart
import 'package:http/http.dart' as http;
import 'package:jose/jose.dart';

class JwksVerifier {
  JwksVerifier({required this.jwksUri, required this.audience, required this.issuer});

  final Uri jwksUri;
  final String audience;
  final String issuer;

  JsonWebKeyStore? _store;
  DateTime? _fetchedAt;
  static const _cacheTtl = Duration(hours: 12);

  Future<Map<String, Object?>> verify(String token) async {
    final store = await _getStore();
    final jwt = await JsonWebToken.decodeAndVerify(token, store);

    final claims = jwt.claims;
    if (claims.issuer.toString() != issuer) {
      throw const AuthFailed(reason: 'bad_issuer');
    }
    if (!claims.audience!.any((a) => a == audience)) {
      throw const AuthFailed(reason: 'bad_audience');
    }
    if (claims.expiry == null || claims.expiry!.isBefore(DateTime.now().toUtc())) {
      throw const AuthFailed(reason: 'expired');
    }

    return claims.toJson();
  }

  Future<JsonWebKeyStore> _getStore() async {
    final now = DateTime.now();
    if (_store == null ||
        _fetchedAt == null ||
        now.difference(_fetchedAt!) > _cacheTtl) {
      final res = await http.get(jwksUri);
      _store = JsonWebKeyStore()
        ..addKeySet(JsonWebKeySet.fromJson(jsonDecode(res.body)));
      _fetchedAt = now;
    }
    return _store!;
  }
}

// Wire it:
final verifier = JwksVerifier(
  jwksUri: Uri.parse('https://auth.example.com/.well-known/jwks.json'),
  audience: 'https://api.example.com',
  issuer: 'https://auth.example.com/',
);

final runtime = await VyuhServer.bootstrap(
  plugins: [
    JwtAuthPlugin(verifier: verifier.verify),
  ],
);

Fallback Actor

By default an unauthenticated request gets AnonymousActor. Override for services that should reject every anonymous request:

dart
JwtAuthPlugin(
  verifier: verifyToken,
  fallback: const ServiceActor(id: 'reject', serviceName: 'reject'),
);

GlobalMiddleware.auth() resolves an actor for every request when you include it in runtime.start. Only protected paths reject the fallback actor; public endpoints can still see it without 401-ing.

Combining with API Keys

Multiple auth plugins coexist. Stack them:

dart
plugins: [
  JwtAuthPlugin(verifier: verifyToken),       // user-facing
  ApiKeyAuthPlugin(lookup: lookupApiKey),     // partner integrations
],

A request walks both strategies in order. The first one to return an Actor wins. The first to throw AuthFailed stops the chain (the framework surfaces the structured error).

Protecting Routes

The plugin itself doesn't enforce anything — auth runs on paths the framework knows are protected. Declare them through RouteModule:

dart
RouteModule(
  name: 'app.profiles',
  basePath: '/profiles',
  protectedPaths: ['/'],
  setup: (scope) {
    scope.get('/me', _meHandler);
  },
);

Or imperatively in extend:

dart
extend: (scope) {
  scope.protect(['/admin', '/internal']);
  scope.get('/admin/users', _listUsersHandler);
}

The framework installs GlobalMiddleware.requireAuthenticated() on protected paths. Inside the handler:

dart
Future<Response> meHandler(Request req) async {
  final actor = req.actor;
  return jsonResponse(body: {
    'success': true,
    'data': {
      'user_id': actor.id,
      'email': actor.claims['email'],
      'tenant_id': actor.tenantId,
    },
  });
}

Where to Go Next