package main

import (
	"crypto/rand"
	"encoding/gob"
	"flag"
	"fmt"
	"io"
	"log"
	"net/http"
	"net/http/httputil"
	"net/url"
	"os"
	"regexp"
	"strconv"
	"strings"
	"time"

	"github.com/ericchiang/oidc"
	"github.com/gorilla/securecookie"

	"golang.org/x/net/context"
	"golang.org/x/oauth2"
)

const (
	cookieName = "oidc-proxy"
	// This header will be set by oidcproxy during authentication and
	// passed to the backend.
	emailHeaderName = "X-User-Email"
)

// Session represents a logged in user's active session.
type Session struct {
	Email   string
	Expires time.Time
}

func init() {
	gob.Register(&Session{})
}

var (
	// Flags.
	issuer    string
	backend   string
	scopes    string
	allow     string
	httpAddr  string
	httpsAddr string
	cookieExp time.Duration

	// Set up during initial configuration.
	oauth2Config   = new(oauth2.Config)
	oidcProvider   *oidc.Provider
	backendHandler *httputil.ReverseProxy
	verifier       *oidc.IDTokenVerifier

	// Regexps of emails to allow.
	allowEmail []*regexp.Regexp

	nonceSource *memNonceSource

	cookieEncrypter *securecookie.SecureCookie
)

func main() {
	flag.StringVar(&issuer, "issuer", "https://accounts.google.com", "The issuer URL of the OpenID Connect provider.")
	flag.StringVar(&backend, "backend", "", "The URL of the backened to proxy to.")
	flag.StringVar(&oauth2Config.RedirectURL, "redirect-url", "", "A full OAuth2 redirect URL.")
	flag.StringVar(&oauth2Config.ClientID, "client-id", "", "The client ID of the OAuth2 client.")
	flag.StringVar(&oauth2Config.ClientSecret, "client-secret", "", "The client secret of the OAuth2 client.")
	flag.StringVar(&scopes, "scopes", "openid,email,profile", `A comma seprated list of OAuth2 scopes to request ("openid" required).`)
	flag.StringVar(&allow, "allow-email", ".*", "Comma seperated list of email regexp's to match for access to the backend.")
	flag.StringVar(&httpAddr, "http", "127.0.0.1:5556", "Default address to listen on.")
	flag.DurationVar(&cookieExp, "cookie-exp", time.Hour*24, "Duration for which a login cookie is valid for.")
	flag.Parse()

	// Set flags from environment variables.
	flag.VisitAll(func(f *flag.Flag) {
		if f.Value.String() != f.DefValue {
			return
		}

		// Convert flag name, e.g. "redirect-url" becomes "OIDC_PROXY_REDIRECT_URL"
		envVar := "OIDC_PROXY_" + strings.ToUpper(strings.Replace(f.Name, "-", "_", -1))

		if envVal := os.Getenv(envVar); envVal != "" {
			if err := flag.Set(f.Name, envVal); err != nil {
				log.Fatal(err)
			}
		}
		// All flags are manditory.
		if f.Value.String() == "" {
			flag.Usage()
			os.Exit(2)
		}
	})

	// compile email regexps
	for _, expr := range strings.Split(allow, ",") {
		allowEmailRegexp, err := regexp.Compile(expr)
		if err != nil {
			log.Fatalf("invalid regexp: %q %v", expr, err)
		}
		allowEmail = append(allowEmail, allowEmailRegexp)
	}

	// configure reverse proxy
	backendURL, err := url.Parse(backend)
	if err != nil {
		log.Fatalf("failed to parse backend: %v", err)
	}
	backendHandler = httputil.NewSingleHostReverseProxy(backendURL)

	redirectURL, err := url.Parse(oauth2Config.RedirectURL)
	if err != nil {
		log.Fatalf("failed to parse redirect URL: %v", err)
	}

	// Query for the provider.
	oidcProvider, err = oidc.NewProvider(context.TODO(), issuer)
	if err != nil {
		log.Fatalf("failed to get provider: %v", err)
	}

	nonceSource = newNonceSource(context.TODO())
	verifier = oidcProvider.NewVerifier(context.TODO(), oidc.VerifyNonce(nonceSource))

	oauth2Config.Endpoint = oidcProvider.Endpoint()
	oauth2Config.Scopes = strings.Split(scopes, ",")

	// Initialize secure cookies.
	// TODO(ericchiang): make these configurable
	hashKey := make([]byte, 64)
	blockKey := make([]byte, 32)
	if _, err := io.ReadFull(rand.Reader, hashKey); err != nil {
		log.Fatalf("failed to initialize hash key: %v", err)
	}
	if _, err := io.ReadFull(rand.Reader, blockKey); err != nil {
		log.Fatalf("failed to initialize block key: %v", err)
	}
	cookieEncrypter = securecookie.New(hashKey, blockKey)

	mux := http.NewServeMux()
	mux.HandleFunc("/", handleProxy)
	mux.HandleFunc("/login", handleRedirect)
	mux.HandleFunc("/logout", handleLogout)
	mux.HandleFunc(redirectURL.Path, handleCallback)

	log.Printf("Listening on: %s", httpAddr)
	http.ListenAndServe(httpAddr, mux)
}

// httpRedirect returns a handler which redirects to the provided path.
func httpRedirect(path string) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		http.Redirect(w, r, path, http.StatusFound)
	})
}

// httpError returns a handler which presents an error to the end user.
func httpError(status int, format string, a ...interface{}) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		http.Error(w, fmt.Sprintf(format, a...), http.StatusInternalServerError)
	})
}

func handleCallback(w http.ResponseWriter, r *http.Request) {
	func() http.Handler {
		state := r.URL.Query().Get("state")
		if state == "" {
			log.Printf("State not set")
			return httpError(http.StatusInternalServerError, "Authentication failed")
		}
		if err := nonceSource.ClaimNonce(state); err != nil {
			log.Printf("Failed to claim nonce: %v", err)
			return httpError(http.StatusInternalServerError, "Authentication failed")
		}

		oauth2Token, err := oauth2Config.Exchange(context.TODO(), r.URL.Query().Get("code"))
		if err != nil {
			log.Printf("Failed to exchange token: %v", err)
			return httpError(http.StatusInternalServerError, "Authentication failed")
		}

		// Extract the ID Token from oauth2 token.
		rawIDToken, ok := oauth2Token.Extra("id_token").(string)
		if !ok {
			log.Println("No ID Token found")
			return httpError(http.StatusInternalServerError, "Authentication failed")
		}

		idToken, err := verifier.Verify(rawIDToken)
		if err != nil {
			log.Printf("Failed to verify token: %v", err)
			return httpError(http.StatusInternalServerError, "Authentication failed")
		}
		var claims struct {
			Email         string `json:"email"`
			EmailVerified bool   `json:"email_verified"`
		}

		if err := idToken.Claims(&claims); err != nil {
			log.Printf("Failed to decode claims: %v", err)
			return httpError(http.StatusInternalServerError, "Authentication failed")
		}
		if !claims.EmailVerified || claims.Email == "" {
			log.Println("Failed to verify email")
			return httpError(http.StatusInternalServerError, "Authentication failed")
		}

		s := Session{Email: claims.Email, Expires: time.Now().Add(cookieExp)}
		encoded, err := cookieEncrypter.Encode(cookieName, s)
		if err != nil {
			log.Printf("Failed to encrypt session: %v", err)
			return httpError(http.StatusInternalServerError, "Authentication failed")
		}

		// Set the encoded cookie
		cookie := &http.Cookie{Name: cookieName, Value: encoded, HttpOnly: true, Path: "/"}
		http.SetCookie(w, cookie)
		return httpRedirect("/")

	}().ServeHTTP(w, r)
}

func handleRedirect(w http.ResponseWriter, r *http.Request) {
	// TODO(ericchiang): since arbitrary requests can create nonces, rate limit this endpoint.
	func() http.Handler {
		nonce, err := nonceSource.Nonce()
		if err != nil {
			log.Printf("Failed to create nonce: %v", err)
			return httpError(http.StatusInternalServerError, "Failed to generate redirect")
		}
		state, err := nonceSource.Nonce()
		if err != nil {
			log.Printf("Failed to create state: %v", err)
			return httpError(http.StatusInternalServerError, "Failed to generate redirect")
		}
		return httpRedirect(oauth2Config.AuthCodeURL(state, oauth2.ApprovalForce, oidc.Nonce(nonce)))
	}().ServeHTTP(w, r)
}

func handleLogout(w http.ResponseWriter, r *http.Request) {
	cookie := &http.Cookie{Name: cookieName, Value: "", HttpOnly: true, Path: "/"}
	http.SetCookie(w, cookie)
	httpRedirect("/login").ServeHTTP(w, r)
}

func handleProxy(w http.ResponseWriter, r *http.Request) {
	func() http.Handler {
		cookie, err := r.Cookie(cookieName)
		if err != nil {
			// Only error can be ErrNoCookie https://goo.gl/o5fZ49
			return httpRedirect("/login")
		}
		var s Session
		if err := cookieEncrypter.Decode(cookieName, cookie.Value, &s); err != nil {
			log.Printf("Failed to decode cookie: %v", err)
			return http.HandlerFunc(handleLogout) // clear the cookie
		}
		if time.Now().After(s.Expires) {
			log.Printf("Cookie for %q expired", s.Email)
			return http.HandlerFunc(handleLogout) // clear the cookie
		}

		for _, allow := range allowEmail {
			if allow.MatchString(s.Email) {
				r.Header.Set(emailHeaderName, s.Email)
				return backendHandler
			}
		}
		log.Printf("Denying %q", s.Email)
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			resp := []byte(`<html><head></head><body>Provided email does not have permission to login. <a href="/logout">Try a different account.</a></body></html>`)
			w.Header().Set("Content-Type", "text/html")
			w.Header().Set("Content-Length", strconv.Itoa(len(resp)))
			w.WriteHeader(http.StatusForbidden)
			w.Write(resp)
		})
	}().ServeHTTP(w, r)
}
