authenticate method

Map<String, String> authenticate(
  1. String assertion, {
  2. DateTime? currentTime,
})

Processes the "assertion" POST parameter received by the callback URL.

Parses the assertion and validates it.

If provided, the currentTime is used for validating time values in the token. Otherwise, the time when this method is invoked is used. The currentTime is usually only used for testing.

Returns a map if the assertion is acceptable. That is, if a login has successfully authenticated themselves. The map contains the attributes names to values, which can be used to create a ClaimStandard or ClaimWithSharedToken.

Throws a subclass of the AafException exception if the assertion is not acceptable. To discover why an assertion was rejected, examine the exception or set the loggers to log more details.

Implementation

Map<String, String> authenticate(String assertion, {DateTime? currentTime}) {
  try {
    if (assertion.isEmpty) {
      throw BadJwt('POST missing "assertion"');
    }

    _logAssertion.finest('assertion: $assertion');

    // Verify the JWT.
    //
    // Note: custom headerCheck provided, since AAF Rapid Connect sets
    // the 'typ' to 'JsonWebToken', instead of the 'JWT' (the default expected
    // by jaguar_jwt.
    final cs = verifyJwtHS256Signature(assertion, secret,
        defaultIatExp: false, headerCheck: (h) {
      _logJwt.finest('header=$h');

      final dynamic typ = h['typ']; // get the value of the "typ" header

      if (typ is String) {
        // The "typ" header has a string value, which is the expected type
        if (typ != 'JsonWebToken' && typ != 'JWT') {
          return false; // reject: value is not one of the expected values
        }
      } else if (typ != null) {
        // The "typ" header exists, but value is (strangely) not a string
        return false; // reject: unexpected value type for 'typ'
      }

      // There is no "typ" header, or it exists and has an expected value
      return true; // header is ok
    });
    _logJwt.finer('body=$cs');

    final when = (currentTime ?? DateTime.now()).toUtc();
    try {
      cs.validate(
          issuer: issuer,
          audience: audience,
          allowedClockSkew: allowedClockSkew,
          currentTime: when);
    } on JwtException catch (e) {
      // Extra information that might be useful for debugging failures

      switch (e) {
        case JwtException.tokenExpired:
          _logJwt.fine('expired'
              ': checkedAt=$when'
              ', out by ${_fmtDur(when.difference(cs.expiry!))}'
              ' > skew=${_fmtDur(allowedClockSkew)}');
          break;
        case JwtException.tokenNotYetAccepted:
          _logJwt.fine('notYetAccepted'
              ': checkedAt=$when'
              ', out by ${_fmtDur(cs.notBefore!.difference(when))}'
              ' > skew=${_fmtDur(allowedClockSkew)}');
          break;
        case JwtException.tokenNotYetIssued:
          // Note: jaguar_jwt 3.0.0 does not detect this situation, so this
          // exception never occurs.
          //
          // This need to be confirmed, but the JWT specification might be
          // silent on whether this situation is an error or not.
          //_logJwt.fine('notYetIssued'
          //    ': checkedAt=$when'
          //    ', out by ${_fmtDur(cs.issuedAt!.difference(when))}'
          //    ' ? skew=${_fmtDur(allowedClockSkew)}');
          break;
        case JwtException.audienceNotAllowed:
          _logJwt.fine('audienceNotAllowed:'
              ' expecting "$audience" got "${cs.audience}"');
          break;
        case JwtException.incorrectIssuer:
          _logJwt.fine(
              'incorrectIssuer: expecting "$issuer" got "${cs.issuer}"');
          break;
      }

      rethrow;
    }

    // JWT ID

    final jwtID = cs.jwtId;
    if (jwtID == null) {
      throw BadJwt('Missing JWT ID');
    }
    if (jwtID.isEmpty) {
      throw BadJwt('Blank JWT ID');
    }

    if (_seenJti.containsKey(jwtID)) {
      throw BadJwt('JWT ID not unique'); // replay attack?
    }

    // Record JWT ID as seen

    final expiry = cs.expiry;
    if (expiry == null) {
      throw BadJwt('Missing expiry');
    }

    final durationToLive = expiry.difference(DateTime.now());
    if (maxAllowedLifetime < durationToLive) {
      // Unrealistic expiry time: reject long lived token
      throw BadJwt('TTL is too long');
    }

    // This timer will remove the JWT ID entry, once it has reached its
    // expiry time. This is to prevent the list from growing forever.
    // Although it means the uniqueness check is not complete, since it
    // won't check any JWT ID values beyond the lifetime of the JWTs.
    _seenJti[jwtID] = Timer(durationToLive, () {
      _seenJti.remove(jwtID);
    });

    // Type

    if (!cs.containsKey('typ') || cs['typ'] != 'authnresponse') {
      throw BadAafClaim('bad typ');
    }

    // Check "sub" exists and has a non-blank value

    // Make sure the AAF entry has a "sub"

    if (cs.subject == null) {
      throw BadAafClaim('sub missing');
    }
    if (cs.subject!.isEmpty) {
      throw BadAafClaim('sub is empty string');
    }
    if (cs.subject!.trim().isEmpty) {
      throw BadAafClaim('sub is all whitespace');
    }

    // Create the implementation-independent AafAttributes and populate it

    if (!cs.containsKey(_aafClaimName)) {
      throw BadAafClaim('missing AAF claims');
    }
    final dynamic aafClaims = cs[_aafClaimName];
    if (aafClaims is Map) {
      final aafAttr = <String, String>{}; // the result

      for (final k in aafClaims.keys) {
        if (k is String) {
          final Object? v = aafClaims[k];

          if (v is String) {
            final trimmedValue = v.trim();
            if (trimmedValue.isNotEmpty) {
              aafAttr[k] = trimmedValue; // record the value
            } else {
              // Treat attributes with empty strings as not present.
              // The edupersonorcid is often an empty string.
              // In the past some IdPs used an empty string for the
              // edupersonprincipalname identifier.
              _logJwt.finest('blank string ignored: $k');
            }
          } else {
            throw BadAafClaim('non-string value: $k');
          }
        } else {
          throw BadAafClaim('non-string key');
        }
      }

      // Since this is AAF Rapid Connect, the 'edupersontargetdid' must always
      // be present and must have the same value as the 'sub'.

      const targetName = ClaimStandard._attrEdupersontargetedid;
      final targetId = aafAttr[targetName];
      if (targetId == null) {
        throw BadAafClaim('$targetName missing');
      }
      if (cs.subject != targetId) {
        throw BadAafClaim('$targetName != sub');
      }

      _logAuthenticate.fine('success: ${aafAttr[ClaimStandard._attrMail]}');

      return aafAttr; // success: return result
    } else if (aafClaims == null) {
      throw BadAafClaim('AAF claims missing from JWT');
    } else {
      throw BadAafClaim('AAF claims not a JSON object');
    }
  } on AafException catch (e) {
    // High level exception thrown by above code
    _logAuthenticate.fine('invalid assertion: $e');
    rethrow;
  } on JwtException catch (e) {
    _logAuthenticate.fine('invalid JWT: $e');
    throw BadJwt(e.toString());
  } on Exception catch (e) {
    _logAuthenticate.fine('unexpected exception (${e.runtimeType}): $e');
    throw BadJwt('unexpected exception ${e.runtimeType}: $e');
  }
}