// Copyright (C) 2018 Marius Schellenberger

// Package jwt provides a easy to use JSON Web Token and blacklisting library
package jwt

import (
	"crypto/hmac"
	"crypto/rand"
	"encoding/json"
	"errors"
	"io"
	"sync"
	"time"
)

const (
	// HTTPHeader is the default HTTP Authorization header name
	HTTPHeader = "Authorization"
	// DefaultExpiry is the default token expiration time
	DefaultExpiry = time.Hour * 12
	// KeySize is the secret key size
	KeySize = 64

	// Typ is the JWT type
	Typ = "JWT"
	// TypClaim is the typ claim name
	TypClaim = "typ"
	// AlgClaim is the alg claim name
	AlgClaim = "alg"
	// ExpClaim is the exp claim name
	ExpClaim = "exp"
	// NbfClaim is the nbf claim name
	NbfClaim = "nbf"
	// TokenSeparator is the tokens separator char
	TokenSeparator = "."
)

// DefaultSecretReader is the default secret key generator
var DefaultSecretReader = rand.Reader

const jwtErr = "jwt: "

var (
	ErrNoJWT               = errors.New(jwtErr + "not a json web token")
	ErrEmptyToken          = errors.New(jwtErr + "token is empty")
	ErrUnsupportedAlg      = errors.New(jwtErr + "unsupported algorithm")
	ErrInvalid             = errors.New(jwtErr + "token validation failed")
	ErrBlacklisted         = errors.New(jwtErr + "token blacklisted")
	ErrBlacklistNotEnabled = errors.New(jwtErr + "blacklisting is not enabled")
	ErrNbf                 = errors.New(jwtErr + "token not valid yet")
	ErrExp                 = errors.New(jwtErr + "token expired")
	ErrMissingNbf          = errors.New(jwtErr + "missing nbf claim")
	ErrMissingExp          = errors.New(jwtErr + "missing exp claim")
	ErrMissingTokenParts   = errors.New(jwtErr + "missing token parts")
	ErrEmptySignature      = errors.New(jwtErr + "token signature is empty")
	ErrTokenIsNil          = errors.New(jwtErr + "token is nil")
	ErrInvalidKeySize      = errors.New(jwtErr + "invalid secret key size")
)

// JWT represents the JSON Web Token signing and blacklisting infrastructure
type JWT struct {
	key    []byte
	expiry time.Duration

	blacklist Blacklist
	stopOnce  sync.Once
	done      chan struct{}
}

// New returns a new JWT object with the given expiry timeout.
// If the timeout is less or equal to zero the default expiry (12 hours) is used.
// The secret key size needs to be at least 64 bytes.
// If secret is nil the DefaultSecretReader is used.
// If blacklisting is enabled, the JWT object leaks a goroutine to garbage-collect expired blacklisted tokens.
// Call the Stop() method to exit the goroutine.
func New(expiry time.Duration, blacklist Blacklist, secret io.Reader) (*JWT, error) {
	if expiry <= 0 {
		expiry = DefaultExpiry
	}
	if secret == nil {
		secret = DefaultSecretReader
	}
	secret = io.LimitReader(secret, KeySize)
	key := make([]byte, KeySize)
	i, err := secret.Read(key)
	if err != nil {
		return nil, errors.New(jwtErr + "secret reader error: " + err.Error())
	}
	if i < KeySize {
		return nil, ErrInvalidKeySize
	}
	jwt := &JWT{
		key:       key,
		expiry:    expiry,
		blacklist: blacklist,
	}
	if blacklist != nil {
		jwt.done = make(chan struct{})
		go jwt.clean()
	}
	return jwt, nil
}

// Expiry returns the configured expiry
func (jwt *JWT) Expiry() time.Duration {
	return jwt.expiry
}

// sum calculates the HMAC hash sum of the token
func (jwt *JWT) sum(token string, h Hash) []byte {
	mac := hmac.New(h.Hash, jwt.key)
	mac.Write([]byte(token))
	return mac.Sum(nil)
}

// Invalidate checks if a token is already blacklisted
// If the token is not blacklisted, it will get blacklisted
func (jwt *JWT) Invalidate(t *Token) error {
	if jwt.blacklist == nil {
		return ErrBlacklistNotEnabled
	}
	if t == nil {
		return ErrTokenIsNil
	}
	if err := jwt.blacklisted(t.Sig()); err != nil {
		return err
	}
	if t.Header.GetString(TypClaim) != Typ {
		return ErrNoJWT
	}
	h := ParseHash(t.Header.GetString(AlgClaim))
	if h == nil {
		return ErrUnsupportedAlg
	}
	exp := t.Claims.GetInt64(ExpClaim)
	switch {
	case exp == 0:
		return ErrMissingExp
	case Now() > exp:
		return ErrExp
	}
	if !hmac.Equal(jwt.sum(t.Data(), h), t.RawSig()) {
		return ErrInvalid
	}
	return jwt.blacklist.Add(t.Sig(), exp)
}

// blacklisted checks if a token is blacklisted
func (jwt *JWT) blacklisted(sig string) error {
	if sig == "" {
		return ErrEmptySignature
	}
	if jwt.blacklist.Check(sig) {
		return ErrBlacklisted
	}
	return nil
}

// clean looks for expired blacklisted tokens and removes them
func (jwt *JWT) clean() {
	for {
		t := time.NewTimer(time.Hour)
		select {
		case <-t.C:
		case <-jwt.done:
			t.Stop()
			return
		}
		now := Now()
		m, err := jwt.blacklist.Map()
		if err != nil {
			continue
		}
		for k, v := range m {
			if now > v {
				jwt.blacklist.Remove(k)
			}
		}
	}
}

// Sign will sign the provided token using the secret key
// If the 'exp' and 'nbf' claims do not exist, they will be written to the default values in UNIX format:
// 'exp': Now + expiry specified at New() or DefaultExpiry (UTC)
// 'nbf': Now (UTC)
func (jwt *JWT) Sign(t *Token) (err error) {
	if t == nil {
		return ErrTokenIsNil
	}
	if t.Header.GetString(TypClaim) != Typ {
		return ErrNoJWT
	}
	h := ParseHash(t.Header.GetString(AlgClaim))
	if h == nil {
		return ErrUnsupportedAlg
	}
	now := time.Now()
	if _, ok := t.Claims.Get(ExpClaim); !ok {
		t.Claims.Set(ExpClaim, newExp(now, jwt.expiry))
	}
	if _, ok := t.Claims.Get(NbfClaim); !ok {
		t.Claims.Set(NbfClaim, NewNbf(now))
	}
	head, err := json.Marshal(t.Header)
	if err != nil {
		return
	}
	claims, err := json.Marshal(t.Claims)
	if err != nil {
		return
	}
	t.data = enc.EncodeToString(head) + TokenSeparator + enc.EncodeToString(claims)
	t.rawSignature = jwt.sum(t.data, h)
	t.signature = enc.EncodeToString(t.rawSignature)
	t.raw = t.data + TokenSeparator + t.signature
	return
}

// Verify will verify the provided token using the secret key
func (jwt *JWT) Verify(t *Token) error {
	if t == nil {
		return ErrTokenIsNil
	}
	if jwt.blacklist != nil {
		if err := jwt.blacklisted(t.Sig()); err != nil {
			return err
		}
	}
	now := Now()
	if t.Header.GetString(TypClaim) != Typ {
		return ErrNoJWT
	}
	h := ParseHash(t.Header.GetString(AlgClaim))
	if h == nil {
		return ErrUnsupportedAlg
	}
	exp := t.Claims.GetInt64(ExpClaim)
	switch {
	case exp == 0:
		return ErrMissingExp
	case now > exp:
		return ErrExp
	}
	nbf := t.Claims.GetInt64(NbfClaim)
	switch {
	case nbf == 0:
		return ErrMissingNbf
	case now < nbf:
		return ErrNbf
	}
	if !hmac.Equal(jwt.sum(t.Data(), h), t.RawSig()) {
		return ErrInvalid
	}
	return nil
}

// Stop will end the cleaner goroutine execution
func (jwt *JWT) Stop() error {
	if jwt.blacklist == nil {
		return ErrBlacklistNotEnabled
	}
	jwt.stopOnce.Do(func() {
		close(jwt.done)
	})
	return nil
}
