export type AuthErrorCode = 'unauthenticated' | 'identity_mismatch' | 'credential_revoked'

export class AuthenticationError extends Error {
  readonly name = 'AuthenticationError'

  constructor(readonly code: AuthErrorCode) {
    super('Authentication failed.')
  }
}

export interface CallerIdentity {
  subject: string
  email: string
  edge_session_id: string
  application_credential_id: string
}

export interface EdgeIdentity {
  subject: string
  email: string
  sessionId: string
}

export interface VerifiedApplicationCredential {
  id: string
  subject: string
  revoked: boolean
  expiresAt: Date
}

export interface ApplicationCredentialVerifier {
  verify(credential: string): Promise<VerifiedApplicationCredential | undefined>
}

export interface AccessAssertionVerifier {
  verify(assertion: string): Promise<EdgeIdentity>
}

export interface GatewayAuthenticator {
  authenticate(input: AuthenticationInput): Promise<CallerIdentity>
}

export interface AuthenticationInput {
  accessAssertion: string
  applicationCredential: string
}

export interface CloudflareAccessVerifierOptions {
  issuer: string
  audience: string
  resolveSigningKey(keyId: string): Promise<JsonWebKey | CryptoKey | undefined>
  now?: () => Date
  clockSkewSeconds?: number
}

export interface RotatingCredentialRecord {
  id: string
  subject: string
  secretDigest: string
  expiresAt: Date
  revokedAt?: Date
}

const textEncoder = new TextEncoder()
const base64urlExpression = /^[A-Za-z0-9_-]+$/
const rsaAlgorithms = new Map<string, RsaHashedImportParams>([
  ['RS256', { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }],
  ['RS384', { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-384' }],
  ['RS512', { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-512' }],
])
const ecdsaAlgorithms = new Map<string, { import: EcKeyImportParams; verify: EcdsaParams }>([
  ['ES256', { import: { name: 'ECDSA', namedCurve: 'P-256' }, verify: { name: 'ECDSA', hash: 'SHA-256' } }],
  ['ES384', { import: { name: 'ECDSA', namedCurve: 'P-384' }, verify: { name: 'ECDSA', hash: 'SHA-384' } }],
  ['ES512', { import: { name: 'ECDSA', namedCurve: 'P-521' }, verify: { name: 'ECDSA', hash: 'SHA-512' } }],
])

export class CloudflareAccessAssertionVerifier implements AccessAssertionVerifier {
  private readonly now: () => Date
  private readonly clockSkewMilliseconds: number

  constructor(private readonly options: CloudflareAccessVerifierOptions) {
    if (!options.issuer || !options.audience) {
      throw new TypeError('Cloudflare Access issuer and audience are required.')
    }
    this.now = options.now ?? (() => new Date())
    this.clockSkewMilliseconds = (options.clockSkewSeconds ?? 30) * 1_000
    if (!Number.isFinite(this.clockSkewMilliseconds) || this.clockSkewMilliseconds < 0) {
      throw new TypeError('clockSkewSeconds must be a non-negative finite number.')
    }
  }

  async verify(assertion: string): Promise<EdgeIdentity> {
    try {
      const token = parseJwt(assertion)
      const signingKey = await this.options.resolveSigningKey(token.header.kid)
      if (!signingKey || !(await verifyJwtSignature(token, signingKey))) {
        throw new Error('Invalid assertion signature.')
      }
      validateJwtClaims(token.payload, this.options, this.now(), this.clockSkewMilliseconds)
      return {
        subject: requiredClaim(token.payload, 'sub'),
        email: requiredClaim(token.payload, 'email'),
        sessionId: requiredClaim(token.payload, 'sid'),
      }
    } catch {
      throw new AuthenticationError('unauthenticated')
    }
  }
}

export class RotatingApplicationCredentialVerifier implements ApplicationCredentialVerifier {
  private readonly now: () => Date

  constructor(
    private readonly records: readonly RotatingCredentialRecord[],
    now: () => Date = () => new Date(),
  ) {
    this.now = now
  }

  async verify(credential: string): Promise<VerifiedApplicationCredential | undefined> {
    if (typeof credential !== 'string' || credential.length === 0 || credential.length > 4_096) {
      return undefined
    }

    const digest = await digestCredential(credential)
    let matched: RotatingCredentialRecord | undefined
    for (const record of this.records) {
      if (constantTimeEqual(digest, record.secretDigest)) {
        matched = record
      }
    }
    if (!matched) {
      return undefined
    }
    return {
      id: matched.id,
      subject: matched.subject,
      revoked: matched.revokedAt !== undefined,
      expiresAt: matched.expiresAt,
    }
  }
}

export class DualFactorGatewayAuthenticator implements GatewayAuthenticator {
  private readonly now: () => Date

  constructor(
    private readonly edgeVerifier: AccessAssertionVerifier,
    private readonly credentialVerifier: ApplicationCredentialVerifier,
    now: () => Date = () => new Date(),
  ) {
    this.now = now
  }

  async authenticate(input: AuthenticationInput): Promise<CallerIdentity> {
    if (!isNonEmptyString(input?.accessAssertion) || !isNonEmptyString(input?.applicationCredential)) {
      throw new AuthenticationError('unauthenticated')
    }

    const edgeIdentity = await this.edgeVerifier.verify(input.accessAssertion)
    let credential: VerifiedApplicationCredential | undefined
    try {
      credential = await this.credentialVerifier.verify(input.applicationCredential)
    } catch {
      throw new AuthenticationError('unauthenticated')
    }
    if (!credential) {
      throw new AuthenticationError('unauthenticated')
    }
    if (credential.revoked || credential.expiresAt.getTime() <= this.now().getTime()) {
      throw new AuthenticationError('credential_revoked')
    }
    if (credential.subject !== edgeIdentity.subject) {
      throw new AuthenticationError('identity_mismatch')
    }
    return {
      subject: edgeIdentity.subject,
      email: edgeIdentity.email,
      edge_session_id: edgeIdentity.sessionId,
      application_credential_id: credential.id,
    }
  }
}

export async function digestApplicationCredential(credential: string): Promise<string> {
  if (typeof credential !== 'string' || credential.length === 0) {
    throw new TypeError('Credential must be a non-empty string.')
  }
  return digestCredential(credential)
}

async function digestCredential(credential: string): Promise<string> {
  const digest = await crypto.subtle.digest('SHA-256', textEncoder.encode(credential))
  return bytesToBase64url(new Uint8Array(digest))
}

function parseJwt(assertion: string): {
  encodedHeader: string
  encodedPayload: string
  signature: Uint8Array
  header: { alg: string; kid: string }
  payload: Record<string, unknown>
} {
  if (typeof assertion !== 'string' || assertion.length > 16_384) {
    throw new Error('Malformed assertion.')
  }
  const parts = assertion.split('.')
  if (parts.length !== 3 || parts.some((part) => part.length === 0 || !base64urlExpression.test(part))) {
    throw new Error('Malformed assertion.')
  }
  const header = parseJsonObject(decodeBase64url(parts[0]))
  const payload = parseJsonObject(decodeBase64url(parts[1]))
  const alg = requiredClaim(header, 'alg')
  const kid = requiredClaim(header, 'kid')
  if (!rsaAlgorithms.has(alg) && !ecdsaAlgorithms.has(alg)) {
    throw new Error('Unsupported assertion algorithm.')
  }
  return {
    encodedHeader: parts[0],
    encodedPayload: parts[1],
    signature: decodeBase64url(parts[2]),
    header: { alg, kid },
    payload,
  }
}

async function verifyJwtSignature(
  token: ReturnType<typeof parseJwt>,
  signingKey: JsonWebKey | CryptoKey,
): Promise<boolean> {
  const signedData = textEncoder.encode(`${token.encodedHeader}.${token.encodedPayload}`)
  if (rsaAlgorithms.has(token.header.alg)) {
    const algorithm = rsaAlgorithms.get(token.header.alg)!
    const key = signingKey instanceof CryptoKey
      ? signingKey
      : await crypto.subtle.importKey('jwk', signingKey, algorithm, false, ['verify'])
    return crypto.subtle.verify(algorithm, key, signatureBuffer(token.signature), signedData)
  }
  const algorithm = ecdsaAlgorithms.get(token.header.alg)!
  const key = signingKey instanceof CryptoKey
    ? signingKey
    : await crypto.subtle.importKey('jwk', signingKey, algorithm.import, false, ['verify'])
  return crypto.subtle.verify(algorithm.verify, key, signatureBuffer(token.signature), signedData)
}

function validateJwtClaims(
  payload: Record<string, unknown>,
  options: CloudflareAccessVerifierOptions,
  now: Date,
  clockSkewMilliseconds: number,
): void {
  if (payload.iss !== options.issuer || !hasAudience(payload.aud, options.audience)) {
    throw new Error('Invalid assertion issuer or audience.')
  }
  const expiration = numericDate(payload.exp)
  const issuedAt = numericDate(payload.iat)
  const nowMilliseconds = now.getTime()
  if (expiration * 1_000 <= nowMilliseconds - clockSkewMilliseconds || issuedAt * 1_000 > nowMilliseconds + clockSkewMilliseconds) {
    throw new Error('Expired assertion.')
  }
  requiredClaim(payload, 'sub')
  requiredClaim(payload, 'email')
  requiredClaim(payload, 'sid')
}

function hasAudience(value: unknown, audience: string): boolean {
  return value === audience || (Array.isArray(value) && value.every((item) => typeof item === 'string') && value.includes(audience))
}

function numericDate(value: unknown): number {
  if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
    throw new Error('Invalid numeric date.')
  }
  return value
}

function requiredClaim(payload: Record<string, unknown>, name: string): string {
  const value = payload[name]
  if (typeof value !== 'string' || value.length === 0 || value.length > 1_024) {
    throw new Error(`Missing ${name}.`)
  }
  return value
}

function parseJsonObject(value: Uint8Array): Record<string, unknown> {
  const parsed: unknown = JSON.parse(new TextDecoder().decode(value))
  if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
    throw new Error('Invalid JSON object.')
  }
  return parsed as Record<string, unknown>
}

function decodeBase64url(value: string): Uint8Array {
  const base64 = value.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (value.length % 4)) % 4)
  const decoded = atob(base64)
  return Uint8Array.from(decoded, (character) => character.charCodeAt(0))
}

function signatureBuffer(signature: Uint8Array): ArrayBuffer {
  return signature.buffer.slice(signature.byteOffset, signature.byteOffset + signature.byteLength) as ArrayBuffer
}

function bytesToBase64url(bytes: Uint8Array): string {
  let binary = ''
  for (const byte of bytes) {
    binary += String.fromCharCode(byte)
  }
  return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/u, '')
}

function constantTimeEqual(left: string, right: string): boolean {
  const leftBytes = textEncoder.encode(left)
  const rightBytes = textEncoder.encode(right)
  let difference = leftBytes.length ^ rightBytes.length
  const length = Math.max(leftBytes.length, rightBytes.length)
  for (let index = 0; index < length; index += 1) {
    difference |= (leftBytes[index % leftBytes.length] ?? 0) ^ (rightBytes[index % rightBytes.length] ?? 0)
  }
  return difference === 0
}

function isNonEmptyString(value: unknown): value is string {
  return typeof value === 'string' && value.length > 0
}
