aboutsummaryrefslogtreecommitdiffstats
path: root/fikenauth/login.go
blob: ac8eee9d948e25a9732c17b7f40dc57ed46b0e7e (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
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("redirect URL %q is not a URL: %w", c.redirectURL(), err)
	}
	if !isLoopback(redirect.Hostname()) {
		return Token{}, fmt.Errorf(
			"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"
}