package fikenauth import ( "context" "crypto/rand" "encoding/base64" "errors" "fmt" "html" "io" "net" "net/http" "net/url" "strings" "time" ) // loginTimeout bounds how long the local server waits for the browser. Long // enough to find a password, short enough that a forgotten terminal frees the // port again. const loginTimeout = 5 * time.Minute // shutdownGrace is how long the local server is given to finish writing the // page the browser is waiting on. const shutdownGrace = 5 * time.Second // NewState returns an unguessable value to tie the redirect back to this run. func NewState() (string, error) { buf := make([]byte, 32) if _, err := rand.Read(buf); err != nil { return "", fmt.Errorf("generate an OAuth state: %w", err) } return base64.RawURLEncoding.EncodeToString(buf), nil } // callback is what the browser came back with. type callback struct { code string state string err error } // Login runs the loopback flow: a local server listens on the redirect URL's // port, announce is called with the URL to visit, and the token is exchanged // once the browser returns. // // The redirect URL must be registered on the Fiken app exactly as configured. func (c Config) Login(ctx context.Context, announce func(authURL string)) (Token, error) { if err := c.Validate(); err != nil { return Token{}, err } redirect, err := url.Parse(c.redirectURL()) if err != nil { return Token{}, fmt.Errorf("fiken.redirect_url %q is not a URL: %w", c.redirectURL(), err) } if !isLoopback(redirect.Hostname()) { return Token{}, fmt.Errorf( "fiken.redirect_url %q does not point at this machine; "+ "use a localhost URL, or log in with --manual", c.redirectURL()) } listener, err := net.Listen("tcp", net.JoinHostPort(redirect.Hostname(), portOf(redirect))) if err != nil { return Token{}, fmt.Errorf( "listen on %s for the Fiken redirect: %w; "+ "close whatever is using the port, or log in with --manual", redirect.Host, err) } state, err := NewState() if err != nil { return Token{}, err } results := make(chan callback, 1) server := &http.Server{ Handler: handler(redirect.Path, results), ReadHeaderTimeout: 10 * time.Second, } go server.Serve(listener) //nolint:errcheck // Serve always ends in an error on close // Shut down rather than close: the browser is still being sent the page // that says how it went, and closing the moment the code arrives leaves // the operator looking at a blank tab. defer func() { grace, cancel := context.WithTimeout(context.WithoutCancel(ctx), shutdownGrace) defer cancel() server.Shutdown(grace) //nolint:errcheck // nothing useful to do if it will not }() announce(c.AuthCodeURL(state)) timeout := time.NewTimer(loginTimeout) defer timeout.Stop() select { case <-ctx.Done(): return Token{}, ctx.Err() case <-timeout.C: return Token{}, fmt.Errorf("gave up waiting for the Fiken login after %s", loginTimeout) case result := <-results: if result.err != nil { return Token{}, result.err } if result.state != state { return Token{}, errors.New("the Fiken redirect carried the wrong state; the login was not completed here") } return c.Exchange(ctx, result.code, state) } } // handler answers the one request that matters and ignores the rest, so a // browser fetching a favicon does not end the login. func handler(path string, results chan<- callback) http.Handler { mux := http.NewServeMux() mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { if path != "" && r.URL.Path != path { http.NotFound(w, r) return } result := fromQuery(r.URL.Query()) w.Header().Set("Content-Type", "text/html; charset=utf-8") if result.err != nil { w.WriteHeader(http.StatusBadRequest) io.WriteString(w, page("Innlogging avbrutt", result.err.Error())) //nolint:errcheck } else { io.WriteString(w, page("Innlogging fullført", //nolint:errcheck "Du kan lukke dette vinduet og gå tilbake til terminalen.")) } select { case results <- result: default: // a second request cannot change the outcome } }) return mux } // fromQuery reads the redirect's parameters, turning Fiken's error reply into // an error rather than an empty code. func fromQuery(query url.Values) callback { if code := query.Get("error"); code != "" { return callback{err: &Error{Code: code, Description: query.Get("error_description")}} } code := query.Get("code") if code == "" { return callback{err: errors.New("the Fiken redirect carried no authorization code")} } return callback{code: code, state: query.Get("state")} } // page renders the one-shot browser response. The message can contain an error // straight from Fiken, so it is escaped rather than trusted. func page(title, message string) string { return "" + html.EscapeString(title) + "" + "

" + html.EscapeString(title) + "

" + html.EscapeString(message) + "

" } // ManualAuthCodeURL starts a login that does not need a local server: the user // visits the URL, then pastes back where the browser ended up. This is the way // in over SSH, or when the redirect port is unavailable. func (c Config) ManualAuthCodeURL() (authURL, state string, err error) { if err := c.Validate(); err != nil { return "", "", err } state, err = NewState() if err != nil { return "", "", err } return c.AuthCodeURL(state), state, nil } // CompleteManual finishes a manual login from the URL the browser was // redirected to. A bare authorization code is accepted too, for the case where // the browser showed one instead of navigating. func (c Config) CompleteManual(ctx context.Context, state, redirected string) (Token, error) { redirected = strings.TrimSpace(redirected) if redirected == "" { return Token{}, errors.New("nothing pasted") } // A bare authorization code is accepted as well as a whole URL, because // some browsers show the code rather than navigating anywhere. if !strings.Contains(redirected, "?") && !strings.Contains(redirected, "://") { return c.Exchange(ctx, redirected, state) } parsed, err := url.Parse(redirected) if err != nil { return Token{}, fmt.Errorf("that is not a URL: %w", err) } result := fromQuery(parsed.Query()) if result.err != nil { return Token{}, result.err } if result.state != "" && result.state != state { return Token{}, errors.New("the pasted URL is from a different login attempt") } return c.Exchange(ctx, result.code, state) } func isLoopback(host string) bool { if host == "localhost" { return true } ip := net.ParseIP(host) return ip != nil && ip.IsLoopback() } func portOf(u *url.URL) string { if port := u.Port(); port != "" { return port } return "80" }