// Package oidc implements logging in through OpenID Connect providers.
package oidc

import (
	"errors"
	"fmt"
	"net/http"
	"os"

	"github.com/ericchiang/oidc"
	"golang.org/x/net/context"
	"golang.org/x/oauth2"

	"github.com/coreos/dex/connector"
)

// Config holds configuration options for OpenID Connect logins.
type Config struct {
	Issuer       string `yaml:"issuer"`
	ClientID     string `yaml:"clientID"`
	ClientSecret string `yaml:"clientSecret"`
	RedirectURI  string `yaml:"redirectURI"`

	Scopes []string `yaml:"scopes"` // defaults to "profile" and "email"
}

// Open returns a connector which can be used to login users through an upstream
// OpenID Connect provider.
func (c *Config) Open() (conn connector.Connector, err error) {
	ctx, cancel := context.WithCancel(context.Background())

	provider, err := oidc.NewProvider(ctx, c.Issuer)
	if err != nil {
		cancel()
		return nil, fmt.Errorf("failed to get provider: %v", err)
	}

	scopes := []string{oidc.ScopeOpenID}
	if len(c.Scopes) > 0 {
		scopes = append(scopes, c.Scopes...)
	} else {
		scopes = append(scopes, "profile", "email")
	}

	clientID := os.ExpandEnv(c.ClientID)
	return &oidcConnector{
		redirectURI: c.RedirectURI,
		oauth2Config: &oauth2.Config{
			ClientID:     clientID,
			ClientSecret: os.ExpandEnv(c.ClientSecret),
			Endpoint:     provider.Endpoint(),
			Scopes:       scopes,
			RedirectURL:  c.RedirectURI,
		},
		verifier: provider.NewVerifier(ctx,
			oidc.VerifyExpiry(),
			oidc.VerifyAudience(clientID),
		),
	}, nil
}

var (
	_ connector.CallbackConnector = (*oidcConnector)(nil)
)

type oidcConnector struct {
	redirectURI  string
	oauth2Config *oauth2.Config
	verifier     *oidc.IDTokenVerifier
	ctx          context.Context
	cancel       context.CancelFunc
}

func (c *oidcConnector) Close() error {
	c.cancel()
	return nil
}

func (c *oidcConnector) LoginURL(callbackURL, state string) (string, error) {
	if c.redirectURI != callbackURL {
		return "", fmt.Errorf("expected callback URL did not match the URL in the config")
	}
	return c.oauth2Config.AuthCodeURL(state), nil
}

type oauth2Error struct {
	error            string
	errorDescription string
}

func (e *oauth2Error) Error() string {
	if e.errorDescription == "" {
		return e.error
	}
	return e.error + ": " + e.errorDescription
}

func (c *oidcConnector) HandleCallback(r *http.Request) (identity connector.Identity, state string, err error) {
	q := r.URL.Query()
	if errType := q.Get("error"); errType != "" {
		return identity, "", &oauth2Error{errType, q.Get("error_description")}
	}
	token, err := c.oauth2Config.Exchange(c.ctx, q.Get("code"))
	if err != nil {
		return identity, "", fmt.Errorf("oidc: failed to get token: %v", err)
	}

	rawIDToken, ok := token.Extra("id_token").(string)
	if !ok {
		return identity, "", errors.New("oidc: no id_token in token response")
	}
	idToken, err := c.verifier.Verify(rawIDToken)
	if err != nil {
		return identity, "", fmt.Errorf("oidc: failed to verify ID Token: %v", err)
	}

	var claims struct {
		Username      string `json:"name"`
		Email         string `json:"email"`
		EmailVerified bool   `json:"email_verified"`
	}
	if err := idToken.Claims(&claims); err != nil {
		return identity, "", fmt.Errorf("oidc: failed to decode claims: %v", err)
	}

	identity = connector.Identity{
		UserID:        idToken.Subject,
		Username:      claims.Username,
		Email:         claims.Email,
		EmailVerified: claims.EmailVerified,
	}
	return identity, q.Get("state"), nil
}
