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 ​