summaryrefslogtreecommitdiffstats
path: root/fikenauth/login.go
diff options
context:
space:
mode:
authorSimen A. W. Olsen <hello@simenandre.no>2026-10-17 14:19:09 +0200
committerSimen A. W. Olsen <hello@simenandre.no>2026-10-17 14:19:09 +0200
commitc0a01ac65bb492640b169274614e0d8022f5076d (patch)
treef7d3f0e81d1a9dc05288ba24f1ecc57387f3e0d5 /fikenauth/login.go
downloadfiken-0.1.0.tar.gz
fiken-0.1.0.zip
Fiken API client and OAuth2 for Gov0.1.0
Extracted from gcbill, where it was internal/fiken and internal/fikenauth, so adaa and sokkel-billing can use the same client instead of each carrying its own. The login command named when a grant cannot be renewed is now injected (Source.LoginCommand): it was hardcoded to gcbill's, which would have sent every other caller's operator to a command that does not exist.
Diffstat (limited to 'fikenauth/login.go')
-rw-r--r--fikenauth/login.go217
1 files changed, 217 insertions, 0 deletions
diff --git a/fikenauth/login.go b/fikenauth/login.go
new file mode 100644
index 0000000..e438755
--- /dev/null
+++ b/fikenauth/login.go
@@ -0,0 +1,217 @@
+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 "<!doctype html><meta charset=\"utf-8\"><title>" + html.EscapeString(title) +
+ "</title><body style=\"font-family:system-ui;margin:4rem auto;max-width:32rem\">" +
+ "<h1>" + html.EscapeString(title) + "</h1><p>" + html.EscapeString(message) + "</p>"
+}
+
+// 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"
+}