diff options
| author | Simen A. W. Olsen <hello@simenandre.no> | 2026-10-17 14:19:09 +0200 |
|---|---|---|
| committer | Simen A. W. Olsen <hello@simenandre.no> | 2026-10-17 14:19:09 +0200 |
| commit | c0a01ac65bb492640b169274614e0d8022f5076d (patch) | |
| tree | f7d3f0e81d1a9dc05288ba24f1ecc57387f3e0d5 /fikenauth/login.go | |
| download | fiken-c0a01ac65bb492640b169274614e0d8022f5076d.tar.gz fiken-c0a01ac65bb492640b169274614e0d8022f5076d.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.go | 217 |
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" +} |
