2016-07-25 20:00:28 +00:00
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" )
}
2016-08-08 18:45:51 +00:00
idToken , err := verifier . Verify ( rawIDToken )
2016-07-25 20:00:28 +00:00
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" `
}
2016-08-08 18:45:51 +00:00
if err := idToken . Claims ( & claims ) ; err != nil {
2016-07-25 20:00:28 +00:00
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 )
}