import test from 'ava'
import timekeeper from 'timekeeper'

const root = !('WEBCRYPTO' in process.env) ? '#dist' : '#dist/webcrypto'
const { SignJWT, jwtVerify, CompactSign } = await import(root)

const now = 1604416038

test.before(async (t) => {
  t.context.secret = new Uint8Array(32)
  t.context.payload = { 'urn:example:claim': true }

  timekeeper.freeze(now * 1000)
})

test.after(timekeeper.reset)

test('Basic JWT Claims Set verification', async (t) => {
  const issuer = 'urn:example:issuer'
  const subject = 'urn:example:subject'
  const audience = 'urn:example:audience'
  const jti = 'urn:example:jti'
  const nbf = now - 10
  const iat = now - 20
  const exp = now + 10
  const typ = 'urn:example:typ'
  const jwt = await new SignJWT(t.context.payload)
    .setProtectedHeader({ alg: 'HS256', typ })
    .setIssuer(issuer)
    .setSubject(subject)
    .setAudience(audience)
    .setJti(jti)
    .setNotBefore(nbf)
    .setExpirationTime(exp)
    .setIssuedAt(iat)
    .sign(t.context.secret)

  await t.notThrowsAsync(
    jwtVerify(jwt, t.context.secret, {
      issuer,
      subject,
      audience,
      jti,
      typ,
      maxTokenAge: '30s',
    }),
  )
  await t.notThrowsAsync(jwtVerify(new TextEncoder().encode(jwt), t.context.secret))
})

test('Payload must be an object', async (t) => {
  const encode = TextEncoder.prototype.encode.bind(new TextEncoder())
  for (const value of [0, 1, -1, true, false, null, [], '']) {
    const token = await new CompactSign(encode(JSON.stringify(value)))
      .setProtectedHeader({ alg: 'HS256' })
      .sign(t.context.secret)
    await t.throwsAsync(jwtVerify(token, t.context.secret), {
      code: 'ERR_JWT_INVALID',
      message: 'JWT Claims Set must be a top-level JSON object',
    })
  }
})

test('incorrect hmac signature lengths', async (t) => {
  const jwt = await new SignJWT(t.context.payload)
    .setProtectedHeader({ alg: 'HS256' })
    .sign(t.context.secret)

  await t.throwsAsync(jwtVerify(jwt.slice(0, -3), t.context.secret), {
    code: 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED',
    message: 'signature verification failed',
  })
})

test('Payload must JSON parseable', async (t) => {
  const encode = TextEncoder.prototype.encode.bind(new TextEncoder())
  const token = await new CompactSign(encode('{'))
    .setProtectedHeader({ alg: 'HS256' })
    .sign(t.context.secret)
  await t.throwsAsync(jwtVerify(token, t.context.secret), {
    code: 'ERR_JWT_INVALID',
    message: 'JWT Claims Set must be a top-level JSON object',
  })
})

test('algorithms options', async (t) => {
  const jwt = await new SignJWT(t.context.payload)
    .setProtectedHeader({ alg: 'HS256' })
    .sign(t.context.secret)

  await t.throwsAsync(
    jwtVerify(jwt, t.context.secret, {
      algorithms: ['PS256'],
    }),
    {
      code: 'ERR_JOSE_ALG_NOT_ALLOWED',
      message: '"alg" (Algorithm) Header Parameter not allowed',
    },
  )
  await t.throwsAsync(
    jwtVerify(jwt, t.context.secret, {
      algorithms: [null],
    }),
    {
      instanceOf: TypeError,
      message: '"algorithms" option must be an array of strings',
    },
  )
})

test('typ verification', async (t) => {
  {
    const typ = 'urn:example:typ'
    const jwt = await new SignJWT(t.context.payload)
      .setProtectedHeader({ alg: 'HS256', typ })
      .sign(t.context.secret)

    await t.notThrowsAsync(
      jwtVerify(jwt, t.context.secret, {
        typ: 'application/urn:example:typ',
      }),
    )

    await t.throwsAsync(
      jwtVerify(jwt, t.context.secret, {
        typ: 'urn:example:typ:2',
      }),
      { code: 'ERR_JWT_CLAIM_VALIDATION_FAILED', message: 'unexpected "typ" JWT header value' },
    )

    await t.throwsAsync(
      jwtVerify(jwt, t.context.secret, {
        typ: 'application/urn:example:typ:2',
      }),
      { code: 'ERR_JWT_CLAIM_VALIDATION_FAILED', message: 'unexpected "typ" JWT header value' },
    )
  }
  {
    const typ = 'application/urn:example:typ'
    const jwt = await new SignJWT(t.context.payload)
      .setProtectedHeader({ alg: 'HS256', typ })
      .sign(t.context.secret)

    await t.notThrowsAsync(
      jwtVerify(jwt, t.context.secret, {
        typ: 'urn:example:typ',
      }),
    )

    await t.throwsAsync(
      jwtVerify(jwt, t.context.secret, {
        typ: 'application/urn:example:typ:2',
      }),
      { code: 'ERR_JWT_CLAIM_VALIDATION_FAILED', message: 'unexpected "typ" JWT header value' },
    )

    await t.throwsAsync(
      jwtVerify(jwt, t.context.secret, {
        typ: 'urn:example:typ:2',
      }),
      { code: 'ERR_JWT_CLAIM_VALIDATION_FAILED', message: 'unexpected "typ" JWT header value' },
    )
  }
})

test('Issuer[] verification', async (t) => {
  const issuer = 'urn:example:issuer'
  const jwt = await new SignJWT(t.context.payload)
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuer(issuer)
    .sign(t.context.secret)

  await t.notThrowsAsync(
    jwtVerify(jwt, t.context.secret, {
      issuer: [issuer],
    }),
  )
})

test('Issuer[] verification failed', async (t) => {
  const issuer = 'urn:example:issuer'
  const jwt = await new SignJWT(t.context.payload)
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuer(issuer)
    .sign(t.context.secret)

  await t.throwsAsync(
    jwtVerify(jwt, t.context.secret, {
      issuer: [],
    }),
    { code: 'ERR_JWT_CLAIM_VALIDATION_FAILED', message: 'unexpected "iss" claim value' },
  )
})

test('Issuer[] verification failed []', async (t) => {
  const issuer = 'urn:example:issuer'
  const jwt = await new SignJWT(t.context.payload)
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuer([issuer])
    .sign(t.context.secret)

  await t.throwsAsync(
    jwtVerify(jwt, t.context.secret, {
      issuer: [],
    }),
    { code: 'ERR_JWT_CLAIM_VALIDATION_FAILED', message: 'unexpected "iss" claim value' },
  )
})

test('Audience[] verification', async (t) => {
  const audience = 'urn:example:audience'
  const jwt = await new SignJWT(t.context.payload)
    .setProtectedHeader({ alg: 'HS256' })
    .setAudience(audience)
    .sign(t.context.secret)

  await t.notThrowsAsync(
    jwtVerify(jwt, t.context.secret, {
      audience: [audience],
    }),
  )
})

test('Audience[] verification failed', async (t) => {
  const audience = 'urn:example:audience'
  const jwt = await new SignJWT(t.context.payload)
    .setProtectedHeader({ alg: 'HS256' })
    .setAudience(audience)
    .sign(t.context.secret)

  await t.throwsAsync(
    jwtVerify(jwt, t.context.secret, {
      audience: [],
    }),
    { code: 'ERR_JWT_CLAIM_VALIDATION_FAILED', message: 'unexpected "aud" claim value' },
  )
})

test('Audience[] verification failed []', async (t) => {
  const audience = 'urn:example:audience'
  const jwt = await new SignJWT(t.context.payload)
    .setProtectedHeader({ alg: 'HS256' })
    .setAudience([audience])
    .sign(t.context.secret)

  await t.throwsAsync(
    jwtVerify(jwt, t.context.secret, {
      audience: [],
    }),
    { code: 'ERR_JWT_CLAIM_VALIDATION_FAILED', message: 'unexpected "aud" claim value' },
  )
})

test('Subject verification failed', async (t) => {
  const subject = 'urn:example:subject'
  const jwt = await new SignJWT(t.context.payload)
    .setProtectedHeader({ alg: 'HS256' })
    .setSubject(subject)
    .sign(t.context.secret)

  await t.throwsAsync(
    jwtVerify(jwt, t.context.secret, {
      subject: 'urn:example:subject:2',
    }),
    { code: 'ERR_JWT_CLAIM_VALIDATION_FAILED', message: 'unexpected "sub" claim value' },
  )
})

async function numericDateNumber(t, claim) {
  const jwt = await new SignJWT({ [claim]: null })
    .setProtectedHeader({ alg: 'HS256' })
    .sign(t.context.secret)

  await t.throwsAsync(jwtVerify(jwt, t.context.secret), {
    code: 'ERR_JWT_CLAIM_VALIDATION_FAILED',
    message: `"${claim}" claim must be a number`,
  })
}
numericDateNumber.title = (t, claim) => `${claim} must be a number`

test('clockTolerance num', async (t) => {
  const jwt = await new SignJWT({ exp: now })
    .setProtectedHeader({ alg: 'HS256' })
    .sign(t.context.secret)

  await t.notThrowsAsync(jwtVerify(jwt, t.context.secret, { clockTolerance: 1 }))
  await t.notThrowsAsync(jwtVerify(jwt, t.context.secret, { clockTolerance: '1s' }))
})

async function failingNumericDate(t, claims, assertion, verifyOptions) {
  const jwt = await new SignJWT({ ...claims })
    .setProtectedHeader({ alg: 'HS256' })
    .sign(t.context.secret)

  await t.throwsAsync(jwtVerify(jwt, t.context.secret, { ...verifyOptions }), assertion)
}

test(
  'exp must be in the future',
  failingNumericDate,
  { exp: now },
  {
    code: 'ERR_JWT_EXPIRED',
    message: '"exp" claim timestamp check failed',
  },
)

test(
  'nbf must be at least now',
  failingNumericDate,
  { nbf: now + 1 },
  {
    code: 'ERR_JWT_CLAIM_VALIDATION_FAILED',
    message: '"nbf" claim timestamp check failed',
  },
)

test(
  'iat must be in the past (maxTokenAge, no exp)',
  failingNumericDate,
  { iat: now + 1 },
  {
    code: 'ERR_JWT_CLAIM_VALIDATION_FAILED',
    message: '"iat" claim timestamp check failed (it should be in the past)',
  },
  {
    maxTokenAge: 5,
  },
)

test(
  'iat must be in the past (maxTokenAge, with exp)',
  failingNumericDate,
  { iat: now + 1, exp: now + 10 },
  {
    code: 'ERR_JWT_CLAIM_VALIDATION_FAILED',
    message: '"iat" claim timestamp check failed (it should be in the past)',
  },
  {
    maxTokenAge: 5,
  },
)

test(
  'iat must be in the past (maxTokenAge, with exp, as a string)',
  failingNumericDate,
  { iat: now + 1, exp: now + 10 },
  {
    code: 'ERR_JWT_CLAIM_VALIDATION_FAILED',
    message: '"iat" claim timestamp check failed (it should be in the past)',
  },
  {
    maxTokenAge: '5s',
  },
)

test(
  'maxTokenAge option',
  failingNumericDate,
  { iat: now - 31 },
  {
    code: 'ERR_JWT_EXPIRED',
    message: '"iat" claim timestamp check failed (too far in the past)',
  },
  {
    maxTokenAge: '30s',
  },
)

for (const claim of ['iat', 'nbf', 'exp']) {
  test(numericDateNumber, claim)
}

test('Signed JWTs cannot use unencoded payload', async (t) => {
  await t.throwsAsync(
    jwtVerify(
      'eyJhbGciOiJIUzI1NiIsImI2NCI6ZmFsc2UsImNyaXQiOlsiYjY0Il19.foo.VklKdp4tVYD61VNPDBTqxqdEQcUL3JK-D4dGXu9NvWs',
      t.context.secret,
    ),
    { code: 'ERR_JWT_INVALID', message: 'JWTs MUST NOT use unencoded payload' },
  )
})
