Sign in through the server's single sign-on, with a code
The sign-in screen asks the server how it can be signed in to (GET /api/auth/config) and offers what it finds: the password form, and "Sign in with <provider>" when the server can do a device login. The TUI shows a link and a short code, the person approves it in any browser, and the next poll hands over the ordinary session, so it works over SSH where no browser can be opened. The terminal never talks to the identity provider. The password form is hidden when the server has turned password login off. `auth: sso` in config.yaml starts the SSO login straight away, but not right after signing out, where that would sign the person straight back in; any other value is refused when the config is read. Polling honours the server's interval, backs off on slow_down, and gives up after repeated failures rather than retrying forever. A server without /api/auth/config answers 404 and is treated as passwords only, so the sign-in screen is the one it had. Needs terdut-server v0.29.0 for SSO.
This commit is contained in:
@@ -79,6 +79,98 @@ func (c *Client) Login(username, password string) (string, error) {
|
|||||||
return "", fmt.Errorf("server signed us in but sent no %s cookie", SessionCookie)
|
return "", fmt.Errorf("server signed us in but sent no %s cookie", SessionCookie)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AuthConfig asks how the server can be signed in to. It is unauthenticated, so
|
||||||
|
// it works before anybody has signed in.
|
||||||
|
func (c *Client) AuthConfig() (AuthConfig, error) {
|
||||||
|
var cfg AuthConfig
|
||||||
|
req, err := http.NewRequest(http.MethodGet, c.baseURL+"/api/auth/config", nil)
|
||||||
|
if err != nil {
|
||||||
|
return cfg, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
err = c.do(req, &cfg)
|
||||||
|
return cfg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// The ways a device login poll can end other than with a session.
|
||||||
|
var (
|
||||||
|
// ErrDevicePending means nobody has approved yet: poll again after the
|
||||||
|
// interval.
|
||||||
|
ErrDevicePending = errors.New("waiting for approval")
|
||||||
|
|
||||||
|
// ErrDeviceSlowDown means the server was polled faster than it asked. It is
|
||||||
|
// not a failure; poll again, a little slower.
|
||||||
|
ErrDeviceSlowDown = errors.New("polling too fast")
|
||||||
|
|
||||||
|
// ErrDeviceExpired means the person took too long, or the server forgot the
|
||||||
|
// login. ErrDeviceDenied means they refused it.
|
||||||
|
ErrDeviceExpired = errors.New("the sign-in expired")
|
||||||
|
ErrDeviceDenied = errors.New("the sign-in was refused")
|
||||||
|
)
|
||||||
|
|
||||||
|
// StartDeviceLogin asks the server to begin a device login.
|
||||||
|
func (c *Client) StartDeviceLogin() (*DeviceLogin, error) {
|
||||||
|
req, err := c.newRequestWithBody(http.MethodPost, "/api/oidc/device", struct{}{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Del("Cookie")
|
||||||
|
var d DeviceLogin
|
||||||
|
if err := c.do(req, &d); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if d.DeviceCode == "" || d.UserCode == "" || d.VerificationURL == "" {
|
||||||
|
return nil, errors.New("server started a sign-in but sent no code")
|
||||||
|
}
|
||||||
|
return &d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PollDeviceLogin asks whether the person has approved. On approval it returns
|
||||||
|
// the session token, which the client also keeps; until then it returns one of
|
||||||
|
// the ErrDevice* errors.
|
||||||
|
func (c *Client) PollDeviceLogin(deviceCode string) (string, error) {
|
||||||
|
req, err := c.newRequestWithBody(http.MethodPost, "/api/oidc/device/token",
|
||||||
|
struct {
|
||||||
|
DeviceCode string `json:"device_code"`
|
||||||
|
}{deviceCode})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
req.Header.Del("Cookie")
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
switch resp.StatusCode {
|
||||||
|
case http.StatusAccepted:
|
||||||
|
return "", ErrDevicePending
|
||||||
|
case http.StatusTooManyRequests:
|
||||||
|
return "", ErrDeviceSlowDown
|
||||||
|
case http.StatusGone:
|
||||||
|
var e struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
_ = json.NewDecoder(resp.Body).Decode(&e)
|
||||||
|
if e.Error == "denied" {
|
||||||
|
return "", ErrDeviceDenied
|
||||||
|
}
|
||||||
|
return "", ErrDeviceExpired
|
||||||
|
}
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return "", statusError(resp)
|
||||||
|
}
|
||||||
|
for _, ck := range resp.Cookies() {
|
||||||
|
if ck.Name == SessionCookie && ck.Value != "" {
|
||||||
|
c.session = ck.Value
|
||||||
|
return ck.Value, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("server signed us in but sent no %s cookie", SessionCookie)
|
||||||
|
}
|
||||||
|
|
||||||
// Logout ends the session on the server and forgets it here.
|
// Logout ends the session on the server and forgets it here.
|
||||||
func (c *Client) Logout() error {
|
func (c *Client) Logout() error {
|
||||||
req, err := c.newRequest(http.MethodPost, "/api/logout")
|
req, err := c.newRequest(http.MethodPost, "/api/logout")
|
||||||
|
|||||||
@@ -584,3 +584,106 @@ func TestClient_StatusErrorKeepsCodeAndMessage(t *testing.T) {
|
|||||||
t.Errorf("message changed: %q", err.Error())
|
t.Errorf("message changed: %q", err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAuthConfig_ReadsWhatTheServerOffers(t *testing.T) {
|
||||||
|
c, got := stub(t, http.StatusOK,
|
||||||
|
`{"password_login":false,"oidc":{"enabled":true,"name":"Authentik"},"device_login":true}`)
|
||||||
|
cfg, err := c.AuthConfig()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.method != http.MethodGet || got.path != "/api/auth/config" {
|
||||||
|
t.Errorf("wrong request: %s %s", got.method, got.path)
|
||||||
|
}
|
||||||
|
if cfg.PasswordLogin || !cfg.OIDC.Enabled || cfg.OIDC.Name != "Authentik" || !cfg.DeviceLogin {
|
||||||
|
t.Errorf("config: %+v", cfg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthConfig_OldServerAnswers404(t *testing.T) {
|
||||||
|
c, _ := stub(t, http.StatusNotFound, `{"error":"not found"}`)
|
||||||
|
_, err := c.AuthConfig()
|
||||||
|
var se *StatusError
|
||||||
|
if !errors.As(err, &se) || se.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("want a 404 StatusError, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartDeviceLogin_SendsNoSessionAndReturnsTheCodes(t *testing.T) {
|
||||||
|
c, got := stub(t, http.StatusOK, `{"device_code":"dev","user_code":"BCDF-GHJK",
|
||||||
|
"verification_url":"https://terdut.example.com/device?code=BCDF-GHJK","interval":5,"expires_in":600}`)
|
||||||
|
d, err := c.StartDeviceLogin()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.method != http.MethodPost || got.path != "/api/oidc/device" {
|
||||||
|
t.Errorf("wrong request: %s %s", got.method, got.path)
|
||||||
|
}
|
||||||
|
// A stale session must not ride along on a request that replaces it.
|
||||||
|
if got.cookie != "" {
|
||||||
|
t.Errorf("sent the old session %q", got.cookie)
|
||||||
|
}
|
||||||
|
if d.DeviceCode != "dev" || d.UserCode != "BCDF-GHJK" || d.Interval != 5 || d.ExpiresIn != 600 ||
|
||||||
|
d.VerificationURL != "https://terdut.example.com/device?code=BCDF-GHJK" {
|
||||||
|
t.Errorf("login: %+v", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartDeviceLogin_AReplyWithoutCodesIsAnError(t *testing.T) {
|
||||||
|
c, _ := stub(t, http.StatusOK, `{}`)
|
||||||
|
if _, err := c.StartDeviceLogin(); err == nil {
|
||||||
|
t.Error("an empty reply must not be taken for a started login")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPollDeviceLogin_Outcomes(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
status int
|
||||||
|
body string
|
||||||
|
want error
|
||||||
|
}{
|
||||||
|
{"pending", http.StatusAccepted, `{"status":"pending"}`, ErrDevicePending},
|
||||||
|
{"slow down", http.StatusTooManyRequests, `{"error":"slow_down"}`, ErrDeviceSlowDown},
|
||||||
|
{"expired", http.StatusGone, `{"error":"expired"}`, ErrDeviceExpired},
|
||||||
|
{"denied", http.StatusGone, `{"error":"denied"}`, ErrDeviceDenied},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
c, got := stub(t, tc.status, tc.body)
|
||||||
|
tok, err := c.PollDeviceLogin("dev")
|
||||||
|
if !errors.Is(err, tc.want) || tok != "" {
|
||||||
|
t.Errorf("got %q, %v; want %v", tok, err, tc.want)
|
||||||
|
}
|
||||||
|
if got.path != "/api/oidc/device/token" || !strings.Contains(got.body, `"device_code":"dev"`) {
|
||||||
|
t.Errorf("wrong request: %s %s", got.path, got.body)
|
||||||
|
}
|
||||||
|
if c.HasSession() && c.session == "" {
|
||||||
|
t.Error("session state corrupted")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPollDeviceLogin_ApprovalKeepsTheSessionFromTheCookie(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.SetCookie(w, &http.Cookie{Name: SessionCookie, Value: "granted"})
|
||||||
|
io.WriteString(w, `{"user":{}}`)
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
c := NewClient(srv.URL)
|
||||||
|
tok, err := c.PollDeviceLogin("dev")
|
||||||
|
if err != nil || tok != "granted" {
|
||||||
|
t.Fatalf("got %q, %v", tok, err)
|
||||||
|
}
|
||||||
|
if !c.HasSession() {
|
||||||
|
t.Error("the client must keep the session it was given")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPollDeviceLogin_ApprovalWithoutACookieIsAnError(t *testing.T) {
|
||||||
|
c, _ := stub(t, http.StatusOK, `{"user":{}}`)
|
||||||
|
c.SetSession("")
|
||||||
|
if _, err := c.PollDeviceLogin("dev"); err == nil {
|
||||||
|
t.Error("a 200 with no session cookie is not a sign-in")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,6 +32,35 @@ type Alert struct {
|
|||||||
ResolutionSource *string `json:"resolution_source,omitempty"`
|
ResolutionSource *string `json:"resolution_source,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AuthConfig is how the server can be signed in to, from the unauthenticated
|
||||||
|
// GET /api/auth/config. A server too old to have the endpoint answers 404, which
|
||||||
|
// callers treat as "passwords only".
|
||||||
|
type AuthConfig struct {
|
||||||
|
PasswordLogin bool `json:"password_login"`
|
||||||
|
OIDC struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"oidc"`
|
||||||
|
|
||||||
|
// DeviceLogin is whether the server can sign in a client that has no browser,
|
||||||
|
// by showing a code (see StartDeviceLogin).
|
||||||
|
DeviceLogin bool `json:"device_login"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeviceLogin is a sign-in the server has started for this client: the person
|
||||||
|
// opens VerificationURL, checks UserCode, and approves; the client polls with
|
||||||
|
// DeviceCode until the server hands over a session.
|
||||||
|
type DeviceLogin struct {
|
||||||
|
DeviceCode string `json:"device_code"`
|
||||||
|
UserCode string `json:"user_code"`
|
||||||
|
VerificationURL string `json:"verification_url"`
|
||||||
|
|
||||||
|
// Interval is how many seconds to wait between polls, and ExpiresIn how many
|
||||||
|
// the person has to approve.
|
||||||
|
Interval int `json:"interval"`
|
||||||
|
ExpiresIn int `json:"expires_in"`
|
||||||
|
}
|
||||||
|
|
||||||
// Incident statuses.
|
// Incident statuses.
|
||||||
const (
|
const (
|
||||||
StatusTriggered = "triggered"
|
StatusTriggered = "triggered"
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ type Config struct {
|
|||||||
// Team is the team to start on, by name or id. Empty shows every team the
|
// Team is the team to start on, by name or id. Empty shows every team the
|
||||||
// key's user belongs to.
|
// key's user belongs to.
|
||||||
Team string
|
Team string
|
||||||
|
|
||||||
|
// Auth is how to sign in when the server offers a choice: "sso" starts a
|
||||||
|
// single sign-on login straight away, "password" (or empty) shows the
|
||||||
|
// password form. The server decides what is on offer; this only picks the
|
||||||
|
// default among it.
|
||||||
|
Auth string
|
||||||
}
|
}
|
||||||
|
|
||||||
type rawConfig struct {
|
type rawConfig struct {
|
||||||
@@ -33,6 +39,7 @@ type rawConfig struct {
|
|||||||
RefreshInterval int `yaml:"refresh_interval,omitempty"` // seconds
|
RefreshInterval int `yaml:"refresh_interval,omitempty"` // seconds
|
||||||
Theme string `yaml:"theme,omitempty"`
|
Theme string `yaml:"theme,omitempty"`
|
||||||
Team string `yaml:"team,omitempty"`
|
Team string `yaml:"team,omitempty"`
|
||||||
|
Auth string `yaml:"auth,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() (*Config, error) {
|
func Load() (*Config, error) {
|
||||||
@@ -45,7 +52,7 @@ func Load() (*Config, error) {
|
|||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
return nil, fmt.Errorf("config file not found at %s\n\nCreate it with:\n server_url: https://terdut.example.com\n username: <your-username> # optional, prefills the sign-in form\n theme: gruvbox-dark # optional\n team: Ops # optional, team to start on", path)
|
return nil, fmt.Errorf("config file not found at %s\n\nCreate it with:\n server_url: https://terdut.example.com\n username: <your-username> # optional, prefills the sign-in form\n theme: gruvbox-dark # optional\n team: Ops # optional, team to start on\n auth: sso # optional, sso or password: how to sign in by default", path)
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("cannot read config file: %w", err)
|
return nil, fmt.Errorf("cannot read config file: %w", err)
|
||||||
}
|
}
|
||||||
@@ -59,6 +66,12 @@ func Load() (*Config, error) {
|
|||||||
return nil, fmt.Errorf("config: 'server_url' is required")
|
return nil, fmt.Errorf("config: 'server_url' is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
switch raw.Auth {
|
||||||
|
case "", "password", "sso":
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("config: 'auth' must be sso or password, not %q", raw.Auth)
|
||||||
|
}
|
||||||
|
|
||||||
interval := defaultRefreshInterval
|
interval := defaultRefreshInterval
|
||||||
if raw.RefreshInterval > 0 {
|
if raw.RefreshInterval > 0 {
|
||||||
interval = time.Duration(raw.RefreshInterval) * time.Second
|
interval = time.Duration(raw.RefreshInterval) * time.Second
|
||||||
@@ -71,5 +84,6 @@ func Load() (*Config, error) {
|
|||||||
RefreshInterval: interval,
|
RefreshInterval: interval,
|
||||||
Theme: raw.Theme,
|
Theme: raw.Theme,
|
||||||
Team: raw.Team,
|
Team: raw.Team,
|
||||||
|
Auth: raw.Auth,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,3 +59,26 @@ func TestLoad_NoAPIKeyNeeded(t *testing.T) {
|
|||||||
t.Error("expected the leftover api_key to be noted")
|
t.Error("expected the leftover api_key to be noted")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoad_AuthIsOptionalAndChecked(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
yaml, want string
|
||||||
|
bad bool
|
||||||
|
}{
|
||||||
|
{"", "", false},
|
||||||
|
{"auth: password\n", "password", false},
|
||||||
|
{"auth: sso\n", "sso", false},
|
||||||
|
{"auth: oidc\n", "", true},
|
||||||
|
} {
|
||||||
|
writeConfig(t, "server_url: https://terdut.example.com\n"+tc.yaml)
|
||||||
|
cfg, err := Load()
|
||||||
|
switch {
|
||||||
|
case tc.bad && err == nil:
|
||||||
|
t.Errorf("%q: expected an error", tc.yaml)
|
||||||
|
case !tc.bad && err != nil:
|
||||||
|
t.Errorf("%q: %v", tc.yaml, err)
|
||||||
|
case !tc.bad && cfg.Auth != tc.want:
|
||||||
|
t.Errorf("%q: auth %q, want %q", tc.yaml, cfg.Auth, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,8 +27,21 @@ func TestStartsOnTheFormWithoutASession(t *testing.T) {
|
|||||||
if m.mode != modeLogin {
|
if m.mode != modeLogin {
|
||||||
t.Fatalf("expected the sign-in form, got mode %v", m.mode)
|
t.Fatalf("expected the sign-in form, got mode %v", m.mode)
|
||||||
}
|
}
|
||||||
if m.Init() != nil {
|
// Nothing is connected without a session. The one thing started is asking
|
||||||
t.Error("with no session there is nothing to connect with yet")
|
// how the server can be signed in to, and a server too old to be asked (404)
|
||||||
|
// must leave the password form as it was.
|
||||||
|
srv := httptest.NewServer(http.NotFoundHandler())
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
cmd := signedOut(srv.URL).Init()
|
||||||
|
if cmd == nil {
|
||||||
|
t.Fatal("expected the form to ask the server how it can be signed in to")
|
||||||
|
}
|
||||||
|
msg, ok := cmd().(authConfigMsg)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected authConfigMsg, got %#v", cmd())
|
||||||
|
}
|
||||||
|
if !msg.cfg.PasswordLogin || msg.cfg.DeviceLogin {
|
||||||
|
t.Errorf("an old server offers passwords only, got %+v", msg.cfg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+111
-1
@@ -126,6 +126,48 @@ type connectedMsg struct {
|
|||||||
type connectErrMsg struct{ err error }
|
type connectErrMsg struct{ err error }
|
||||||
type loginDoneMsg struct{}
|
type loginDoneMsg struct{}
|
||||||
type loginErrMsg struct{ err error }
|
type loginErrMsg struct{ err error }
|
||||||
|
|
||||||
|
// Single sign-on. A device login is a chain: the server hands out a code
|
||||||
|
// (deviceStartedMsg), the client waits out the interval (devicePollMsg), asks
|
||||||
|
// (devicePendingMsg, or loginDoneMsg on approval), and waits again. Every message
|
||||||
|
// carries the attempt it belongs to, so the late answers of an attempt that was
|
||||||
|
// cancelled or replaced are dropped rather than acted on.
|
||||||
|
type authConfigMsg struct{ cfg api.AuthConfig }
|
||||||
|
type deviceStartedMsg struct {
|
||||||
|
attempt int
|
||||||
|
login api.DeviceLogin
|
||||||
|
}
|
||||||
|
type devicePollMsg struct{ attempt int }
|
||||||
|
type devicePendingMsg struct {
|
||||||
|
attempt int
|
||||||
|
slower bool // the server asked for fewer polls
|
||||||
|
err error // a poll that failed in a way worth retrying (network, 5xx)
|
||||||
|
}
|
||||||
|
type deviceFailedMsg struct {
|
||||||
|
attempt int
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ssoLogin is a device login in progress; the zero value is none. attempt only
|
||||||
|
// ever goes up: starting, cancelling and finishing all bump it, which is what
|
||||||
|
// makes the messages of an earlier attempt stale.
|
||||||
|
type ssoLogin struct {
|
||||||
|
attempt int
|
||||||
|
active bool // started, and not yet cancelled, failed or finished
|
||||||
|
login *api.DeviceLogin // nil until the server has answered
|
||||||
|
// interval is the wait between polls: the server's, lengthened when it says
|
||||||
|
// it is being asked too often.
|
||||||
|
interval time.Duration
|
||||||
|
// failures counts polls in a row that failed for a reason other than "not
|
||||||
|
// yet", so a dead connection ends the wait instead of spinning forever.
|
||||||
|
failures int
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultDevicePoll = 5 * time.Second
|
||||||
|
maxPollFailures = 3
|
||||||
|
)
|
||||||
|
|
||||||
type logoutDoneMsg struct{}
|
type logoutDoneMsg struct{}
|
||||||
type incidentsFetchedMsg struct{ incidents []api.Incident }
|
type incidentsFetchedMsg struct{ incidents []api.Incident }
|
||||||
type archivedIncidentsFetchedMsg struct{ incidents []api.Incident }
|
type archivedIncidentsFetchedMsg struct{ incidents []api.Incident }
|
||||||
@@ -228,6 +270,12 @@ type Model struct {
|
|||||||
loginNote string
|
loginNote string
|
||||||
ticking bool
|
ticking bool
|
||||||
|
|
||||||
|
// How the server can be signed in to, nil until it has answered. authPref is
|
||||||
|
// the config's `auth`, which only chooses among what the server offers.
|
||||||
|
authInfo *api.AuthConfig
|
||||||
|
authPref string
|
||||||
|
sso ssoLogin
|
||||||
|
|
||||||
// Connection & dashboard
|
// Connection & dashboard
|
||||||
connected bool
|
connected bool
|
||||||
loading bool
|
loading bool
|
||||||
@@ -471,6 +519,13 @@ func (m Model) WithDefaultTeam(team string) Model {
|
|||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithAuth sets the default way to sign in, from the config's `auth`: "sso"
|
||||||
|
// starts a single sign-on login by itself when the server offers one.
|
||||||
|
func (m Model) WithAuth(pref string) Model {
|
||||||
|
m.authPref = pref
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
// WithLogin prefills the sign-in form's username and sets a note shown above it.
|
// WithLogin prefills the sign-in form's username and sets a note shown above it.
|
||||||
func (m Model) WithLogin(username, note string) Model {
|
func (m Model) WithLogin(username, note string) Model {
|
||||||
m.loginInputs[loginUsername].SetValue(username)
|
m.loginInputs[loginUsername].SetValue(username)
|
||||||
@@ -488,7 +543,10 @@ func (m Model) WithLogin(username, note string) Model {
|
|||||||
// already showing and there is nothing to do until it is submitted.
|
// already showing and there is nothing to do until it is submitted.
|
||||||
func (m Model) Init() tea.Cmd {
|
func (m Model) Init() tea.Cmd {
|
||||||
if m.mode == modeLogin {
|
if m.mode == modeLogin {
|
||||||
return nil
|
// Ask how the server can be signed in to, so the form offers the right
|
||||||
|
// thing. Nothing depends on the answer arriving: the password form is
|
||||||
|
// already usable.
|
||||||
|
return authConfigCmd(m.client)
|
||||||
}
|
}
|
||||||
return connectCmd(m.client)
|
return connectCmd(m.client)
|
||||||
}
|
}
|
||||||
@@ -1053,6 +1111,58 @@ func loginCmd(client *api.Client, serverURL, username, password string) tea.Cmd
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// authConfigCmd asks how the server can be signed in to. A server too old to be
|
||||||
|
// asked, or one that cannot be reached, is treated as offering passwords only: the
|
||||||
|
// form that always existed is the safe fallback, and it reports a real connection
|
||||||
|
// problem itself when it is submitted.
|
||||||
|
func authConfigCmd(client *api.Client) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
cfg, err := client.AuthConfig()
|
||||||
|
if err != nil {
|
||||||
|
cfg = api.AuthConfig{PasswordLogin: true}
|
||||||
|
}
|
||||||
|
return authConfigMsg{cfg}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// startDeviceCmd asks the server to begin a device login.
|
||||||
|
func startDeviceCmd(client *api.Client, attempt int) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
login, err := client.StartDeviceLogin()
|
||||||
|
if err != nil {
|
||||||
|
return deviceFailedMsg{attempt, err}
|
||||||
|
}
|
||||||
|
return deviceStartedMsg{attempt, *login}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// devicePollAfter waits out the interval before the next poll.
|
||||||
|
func devicePollAfter(attempt int, interval time.Duration) tea.Cmd {
|
||||||
|
return tea.Tick(interval, func(time.Time) tea.Msg { return devicePollMsg{attempt} })
|
||||||
|
}
|
||||||
|
|
||||||
|
// pollDeviceCmd asks whether the login has been approved. Approval saves the
|
||||||
|
// session the way a password sign-in does, and ends in the same loginDoneMsg.
|
||||||
|
func pollDeviceCmd(client *api.Client, serverURL string, attempt int, deviceCode string) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
token, err := client.PollDeviceLogin(deviceCode)
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
_ = session.Save(serverURL, token)
|
||||||
|
return loginDoneMsg{}
|
||||||
|
case errors.Is(err, api.ErrDevicePending):
|
||||||
|
return devicePendingMsg{attempt: attempt}
|
||||||
|
case errors.Is(err, api.ErrDeviceSlowDown):
|
||||||
|
return devicePendingMsg{attempt: attempt, slower: true}
|
||||||
|
case errors.Is(err, api.ErrDeviceExpired), errors.Is(err, api.ErrDeviceDenied):
|
||||||
|
return deviceFailedMsg{attempt, err}
|
||||||
|
}
|
||||||
|
// Anything else is the network or the server having a moment, which a
|
||||||
|
// person waiting on a browser should not have to start over for.
|
||||||
|
return devicePendingMsg{attempt: attempt, err: err}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// logoutCmd ends the session on the server and deletes the saved one. The saved
|
// logoutCmd ends the session on the server and deletes the saved one. The saved
|
||||||
// copy goes even when the server cannot be reached, because the person asked to
|
// copy goes even when the server cannot be reached, because the person asked to
|
||||||
// be signed out and a token left on disk would say otherwise.
|
// be signed out and a token left on disk would say otherwise.
|
||||||
|
|||||||
@@ -0,0 +1,378 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.ryuvia.com/niklas/terdut-tui/internal/api"
|
||||||
|
"git.ryuvia.com/niklas/terdut-tui/internal/session"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeServer is the device-login half of terdut-server: it starts a login,
|
||||||
|
// answers polls with whatever poll says, and records what it was asked.
|
||||||
|
type fakeServer struct {
|
||||||
|
*httptest.Server
|
||||||
|
mu sync.Mutex
|
||||||
|
polls int
|
||||||
|
// poll is called for each poll and writes the response.
|
||||||
|
poll func(w http.ResponseWriter, n int)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeServer(t *testing.T) *fakeServer {
|
||||||
|
t.Helper()
|
||||||
|
f := &fakeServer{}
|
||||||
|
f.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/api/oidc/device":
|
||||||
|
io.WriteString(w, `{"device_code":"dev-1","user_code":"BCDF-GHJK",
|
||||||
|
"verification_url":"https://terdut.example.com/device?code=BCDF-GHJK","interval":5,"expires_in":600}`)
|
||||||
|
case "/api/oidc/device/token":
|
||||||
|
f.mu.Lock()
|
||||||
|
f.polls++
|
||||||
|
n := f.polls
|
||||||
|
f.mu.Unlock()
|
||||||
|
var body struct {
|
||||||
|
DeviceCode string `json:"device_code"`
|
||||||
|
}
|
||||||
|
json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
if body.DeviceCode != "dev-1" {
|
||||||
|
t.Errorf("polled with %q", body.DeviceCode)
|
||||||
|
}
|
||||||
|
f.poll(w, n)
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(f.Close)
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func pending(w http.ResponseWriter, _ int) {
|
||||||
|
w.WriteHeader(http.StatusAccepted)
|
||||||
|
io.WriteString(w, `{"status":"pending"}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// offering is a signed-out model that has been told what the server offers.
|
||||||
|
func offering(t *testing.T, url string, cfg api.AuthConfig, pref string) (Model, tea.Cmd) {
|
||||||
|
t.Helper()
|
||||||
|
m := signedOut(url).WithAuth(pref)
|
||||||
|
next, cmd := m.Update(authConfigMsg{cfg})
|
||||||
|
return next.(Model), cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func both() api.AuthConfig {
|
||||||
|
c := api.AuthConfig{PasswordLogin: true, DeviceLogin: true}
|
||||||
|
c.OIDC.Enabled, c.OIDC.Name = true, "Authentik"
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func ssoOnly() api.AuthConfig {
|
||||||
|
c := both()
|
||||||
|
c.PasswordLogin = false
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func update(t *testing.T, m Model, msg tea.Msg) (Model, tea.Cmd) {
|
||||||
|
t.Helper()
|
||||||
|
next, cmd := m.Update(msg)
|
||||||
|
return next.(Model), cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func ctrlO() tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyCtrlO} }
|
||||||
|
|
||||||
|
func TestSSO_OfferedAlongsidePasswordsIsNotStartedByItself(t *testing.T) {
|
||||||
|
m, cmd := offering(t, "http://test", both(), "")
|
||||||
|
if cmd != nil || m.sso.active {
|
||||||
|
t.Fatal("with passwords on offer nothing should start until asked")
|
||||||
|
}
|
||||||
|
view := m.View()
|
||||||
|
for _, want := range []string{"Username:", "Password:", "ctrl+o to sign in with Authentik"} {
|
||||||
|
if !strings.Contains(view, want) {
|
||||||
|
t.Errorf("expected %q on the form:\n%s", want, view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSO_NotOfferedShowsNoSuchHint(t *testing.T) {
|
||||||
|
m, _ := offering(t, "http://test", api.AuthConfig{PasswordLogin: true}, "")
|
||||||
|
if strings.Contains(m.View(), "ctrl+o") {
|
||||||
|
t.Error("a server with no SSO must not advertise it")
|
||||||
|
}
|
||||||
|
if m, cmd := update(t, m, ctrlO()); cmd != nil || m.sso.active {
|
||||||
|
t.Error("ctrl+o must do nothing when the server has no SSO")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSO_FullFlow(t *testing.T) {
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||||
|
f := newFakeServer(t)
|
||||||
|
f.poll = func(w http.ResponseWriter, n int) {
|
||||||
|
if n < 3 {
|
||||||
|
pending(w, n)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.SetCookie(w, &http.Cookie{Name: api.SessionCookie, Value: "tok-sso", Path: "/"})
|
||||||
|
io.WriteString(w, `{"user":{}}`)
|
||||||
|
}
|
||||||
|
m, _ := offering(t, f.URL, both(), "")
|
||||||
|
|
||||||
|
// ctrl+o asks the server for a login.
|
||||||
|
m, cmd := update(t, m, ctrlO())
|
||||||
|
if !m.sso.active || cmd == nil {
|
||||||
|
t.Fatal("ctrl+o should start a single sign-on login")
|
||||||
|
}
|
||||||
|
if !strings.Contains(m.View(), "Contacting the server") {
|
||||||
|
t.Errorf("before the server answers:\n%s", m.View())
|
||||||
|
}
|
||||||
|
started, ok := cmd().(deviceStartedMsg)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected deviceStartedMsg, got %#v", cmd())
|
||||||
|
}
|
||||||
|
|
||||||
|
// The link and the code are shown, and a poll is scheduled.
|
||||||
|
m, cmd = update(t, m, started)
|
||||||
|
if cmd == nil || m.sso.interval != 5*time.Second {
|
||||||
|
t.Fatalf("expected a poll to be scheduled every 5s, got cmd %v interval %v", cmd != nil, m.sso.interval)
|
||||||
|
}
|
||||||
|
view := m.View()
|
||||||
|
for _, want := range []string{"Sign in with Authentik", "https://terdut.example.com/device?code=BCDF-GHJK",
|
||||||
|
"BCDF-GHJK", "Waiting for approval", "10 minutes", "esc·cancel"} {
|
||||||
|
if !strings.Contains(view, want) {
|
||||||
|
t.Errorf("expected %q while waiting:\n%s", want, view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(view, "Username:") {
|
||||||
|
t.Error("the password form must be out of the way while waiting")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two polls that find nothing, each scheduling the next.
|
||||||
|
for i := 1; i <= 2; i++ {
|
||||||
|
m, cmd = update(t, m, devicePollMsg{m.sso.attempt})
|
||||||
|
if cmd == nil {
|
||||||
|
t.Fatalf("poll %d: expected a request", i)
|
||||||
|
}
|
||||||
|
pend, ok := cmd().(devicePendingMsg)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("poll %d: expected devicePendingMsg", i)
|
||||||
|
}
|
||||||
|
if m, cmd = update(t, m, pend); cmd == nil || !m.sso.active {
|
||||||
|
t.Fatalf("poll %d: expected to keep waiting", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The third is approved: the session is saved and it moves on and connects.
|
||||||
|
m, cmd = update(t, m, devicePollMsg{m.sso.attempt})
|
||||||
|
done := cmd()
|
||||||
|
if _, ok := done.(loginDoneMsg); !ok {
|
||||||
|
t.Fatalf("expected loginDoneMsg, got %#v", done)
|
||||||
|
}
|
||||||
|
if got := session.Load(f.URL); got != "tok-sso" {
|
||||||
|
t.Errorf("session saved for next time: %q", got)
|
||||||
|
}
|
||||||
|
m, connect := update(t, m, done)
|
||||||
|
if m.mode != modeDashboard || m.sso.active || connect == nil {
|
||||||
|
t.Errorf("expected to move on and connect: mode %v active %v", m.mode, m.sso.active)
|
||||||
|
}
|
||||||
|
if f.polls != 3 {
|
||||||
|
t.Errorf("%d polls, want 3", f.polls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSO_EscCancelsBeforeItQuits(t *testing.T) {
|
||||||
|
m, _ := offering(t, "http://test", both(), "")
|
||||||
|
m, _ = update(t, m, ctrlO())
|
||||||
|
m, _ = update(t, m, deviceStartedMsg{m.sso.attempt, api.DeviceLogin{DeviceCode: "d", UserCode: "AAAA-BBBB", VerificationURL: "u", Interval: 5, ExpiresIn: 600}})
|
||||||
|
|
||||||
|
m, cmd := update(t, m, tea.KeyMsg{Type: tea.KeyEsc})
|
||||||
|
if cmd != nil {
|
||||||
|
t.Fatal("the first esc backs out of the wait; it must not quit")
|
||||||
|
}
|
||||||
|
if m.sso.active || m.mode != modeLogin || !strings.Contains(m.View(), "Username:") {
|
||||||
|
t.Errorf("expected the password form back, active %v", m.sso.active)
|
||||||
|
}
|
||||||
|
if _, cmd := update(t, m, tea.KeyMsg{Type: tea.KeyEsc}); cmd == nil {
|
||||||
|
t.Error("esc on the form quits, as it always did")
|
||||||
|
} else if _, ok := cmd().(tea.QuitMsg); !ok {
|
||||||
|
t.Errorf("expected a quit, got %#v", cmd())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The answers of an attempt that was cancelled arrive late and must change nothing.
|
||||||
|
func TestSSO_StaleMessagesAreIgnored(t *testing.T) {
|
||||||
|
m, _ := offering(t, "http://test", both(), "")
|
||||||
|
m, _ = update(t, m, ctrlO())
|
||||||
|
old := m.sso.attempt
|
||||||
|
m = m.cancelSSO()
|
||||||
|
|
||||||
|
for name, msg := range map[string]tea.Msg{
|
||||||
|
"started": deviceStartedMsg{old, api.DeviceLogin{DeviceCode: "d", UserCode: "X", Interval: 5}},
|
||||||
|
"poll": devicePollMsg{old},
|
||||||
|
"pending": devicePendingMsg{attempt: old},
|
||||||
|
"failed": deviceFailedMsg{old, api.ErrDeviceExpired},
|
||||||
|
} {
|
||||||
|
next, cmd := update(t, m, msg)
|
||||||
|
if cmd != nil || next.sso.active || next.sso.login != nil || next.loginErr != "" {
|
||||||
|
t.Errorf("%s from a cancelled attempt was acted on: %+v err %q", name, next.sso, next.loginErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A new attempt is not confused by the old one's messages either.
|
||||||
|
m, _ = update(t, m, ctrlO())
|
||||||
|
if m.sso.attempt == old {
|
||||||
|
t.Fatal("a new attempt must have a new number")
|
||||||
|
}
|
||||||
|
if _, cmd := update(t, m, devicePollMsg{old}); cmd != nil {
|
||||||
|
t.Error("the old attempt's poll must not run in the new one")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSO_NoPasswordsStartsByItselfAndEnterRestarts(t *testing.T) {
|
||||||
|
m, cmd := offering(t, "http://test", ssoOnly(), "")
|
||||||
|
if !m.sso.active || cmd == nil {
|
||||||
|
t.Fatal("a server with no passwords should start signing in with SSO straight away")
|
||||||
|
}
|
||||||
|
m = m.cancelSSO()
|
||||||
|
view := m.View()
|
||||||
|
if strings.Contains(view, "Username:") || !strings.Contains(view, "This server signs in with Authentik") {
|
||||||
|
t.Errorf("no password form on an SSO-only server:\n%s", view)
|
||||||
|
}
|
||||||
|
if !strings.Contains(view, "enter·sign in with Authentik") {
|
||||||
|
t.Errorf("the footer should say what enter does:\n%s", view)
|
||||||
|
}
|
||||||
|
if m, cmd = update(t, m, tea.KeyMsg{Type: tea.KeyEnter}); !m.sso.active || cmd == nil {
|
||||||
|
t.Error("enter should start it again")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSO_ConfigPrefersItWhenOffered(t *testing.T) {
|
||||||
|
if m, cmd := offering(t, "http://test", both(), "sso"); !m.sso.active || cmd == nil {
|
||||||
|
t.Error("auth: sso should start by itself when the server offers it")
|
||||||
|
}
|
||||||
|
// ...and shows the password form when it does not, rather than a dead end.
|
||||||
|
m, cmd := offering(t, "http://test", api.AuthConfig{PasswordLogin: true}, "sso")
|
||||||
|
if m.sso.active || cmd != nil || !strings.Contains(m.View(), "Username:") {
|
||||||
|
t.Error("auth: sso against a server without SSO must fall back to the form")
|
||||||
|
}
|
||||||
|
if m, cmd := offering(t, "http://test", both(), "password"); m.sso.active || cmd != nil {
|
||||||
|
t.Error("auth: password must not start SSO")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSO_ExpiredAndRefusedReturnToTheFormWithAReason(t *testing.T) {
|
||||||
|
for name, tc := range map[string]struct {
|
||||||
|
err error
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
"expired": {api.ErrDeviceExpired, "expired"},
|
||||||
|
"refused": {api.ErrDeviceDenied, "refused"},
|
||||||
|
"no sso": {&api.StatusError{Code: 404}, "does not offer"},
|
||||||
|
"limited": {&api.StatusError{Code: 429}, "too many"},
|
||||||
|
"other": {errors.New("dial tcp: refused"), "Authentik failed"},
|
||||||
|
} {
|
||||||
|
m, _ := offering(t, "http://test", both(), "")
|
||||||
|
m, _ = update(t, m, ctrlO())
|
||||||
|
m, cmd := update(t, m, deviceFailedMsg{m.sso.attempt, tc.err})
|
||||||
|
if cmd != nil || m.sso.active || !strings.Contains(m.loginErr, tc.want) {
|
||||||
|
t.Errorf("%s: active %v err %q, want it to contain %q", name, m.sso.active, m.loginErr, tc.want)
|
||||||
|
}
|
||||||
|
if !strings.Contains(m.View(), tc.want) {
|
||||||
|
t.Errorf("%s: the reason is not shown:\n%s", name, m.View())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSO_SlowDownLengthensTheInterval(t *testing.T) {
|
||||||
|
m, _ := offering(t, "http://test", both(), "")
|
||||||
|
m, _ = update(t, m, ctrlO())
|
||||||
|
m, _ = update(t, m, deviceStartedMsg{m.sso.attempt, api.DeviceLogin{DeviceCode: "d", UserCode: "A", VerificationURL: "u", Interval: 5, ExpiresIn: 60}})
|
||||||
|
m, cmd := update(t, m, devicePendingMsg{attempt: m.sso.attempt, slower: true})
|
||||||
|
if m.sso.interval != 10*time.Second || cmd == nil {
|
||||||
|
t.Errorf("interval %v, cmd %v; want 10s and another poll", m.sso.interval, cmd != nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSO_ADeadConnectionEndsTheWaitButABlipDoesNot(t *testing.T) {
|
||||||
|
m, _ := offering(t, "http://test", both(), "")
|
||||||
|
m, _ = update(t, m, ctrlO())
|
||||||
|
m, _ = update(t, m, deviceStartedMsg{m.sso.attempt, api.DeviceLogin{DeviceCode: "d", UserCode: "A", VerificationURL: "u", Interval: 5, ExpiresIn: 60}})
|
||||||
|
blip := errors.New("connection reset")
|
||||||
|
|
||||||
|
// Two failures, then a good answer: the count starts over.
|
||||||
|
for range 2 {
|
||||||
|
m, _ = update(t, m, devicePendingMsg{attempt: m.sso.attempt, err: blip})
|
||||||
|
}
|
||||||
|
m, _ = update(t, m, devicePendingMsg{attempt: m.sso.attempt})
|
||||||
|
if !m.sso.active || m.sso.failures != 0 {
|
||||||
|
t.Fatalf("a good answer should reset the failures: %+v", m.sso)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Three in a row is a dead connection.
|
||||||
|
var cmd tea.Cmd
|
||||||
|
for range maxPollFailures {
|
||||||
|
m, cmd = update(t, m, devicePendingMsg{attempt: m.sso.attempt, err: blip})
|
||||||
|
}
|
||||||
|
if m.sso.active || cmd != nil || !strings.Contains(m.loginErr, "connection reset") {
|
||||||
|
t.Errorf("expected to give up with the reason: active %v err %q", m.sso.active, m.loginErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSO_TypingGoesNowhereWhileWaiting(t *testing.T) {
|
||||||
|
m, _ := offering(t, "http://test", both(), "")
|
||||||
|
m, _ = update(t, m, ctrlO())
|
||||||
|
m = typeInto(t, m, "hunter2")
|
||||||
|
if got := m.loginInputs[m.loginFocus].Value(); got != "" {
|
||||||
|
t.Errorf("keys typed during the wait ended up in a field: %q", got)
|
||||||
|
}
|
||||||
|
if _, cmd := update(t, m, tea.KeyMsg{Type: tea.KeyEnter}); cmd != nil {
|
||||||
|
t.Error("enter during the wait must not start anything")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// After the session ends the form comes back; it must still know what the
|
||||||
|
// server offers, and follow the config's preference without a second question.
|
||||||
|
func TestSSO_SessionEndingReturnsToSSOWhenPreferred(t *testing.T) {
|
||||||
|
m, _ := offering(t, "http://test", both(), "sso")
|
||||||
|
m = m.cancelSSO()
|
||||||
|
m.mode = modeDashboard // signed in, as loginDoneMsg leaves it
|
||||||
|
m, _ = update(t, m, connectedMsg{})
|
||||||
|
m, cmd := update(t, m, fetchDataErrMsg{&api.StatusError{Code: 401}})
|
||||||
|
if m.mode != modeLogin || m.authInfo == nil {
|
||||||
|
t.Fatalf("expected the form with what the server offers kept: mode %v info %v", m.mode, m.authInfo)
|
||||||
|
}
|
||||||
|
if !m.sso.active || cmd == nil {
|
||||||
|
t.Error("with auth: sso an ended session should go straight to SSO")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSO_SigningOutDoesNotSignStraightBackIn(t *testing.T) {
|
||||||
|
m, _ := offering(t, "http://test", both(), "sso")
|
||||||
|
m = m.cancelSSO()
|
||||||
|
m.mode = modeDashboard
|
||||||
|
m, _ = update(t, m, connectedMsg{})
|
||||||
|
m, cmd := update(t, m, logoutDoneMsg{})
|
||||||
|
if m.mode != modeLogin || m.sso.active || cmd != nil {
|
||||||
|
t.Errorf("a deliberate sign-out must wait: mode %v active %v", m.mode, m.sso.active)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A session that ends before the server was ever asked (the TUI started on a
|
||||||
|
// saved session) still has to learn what to offer.
|
||||||
|
func TestSSO_LearnsWhatIsOfferedWhenTheSessionEndsFirst(t *testing.T) {
|
||||||
|
c := api.NewClient("http://test")
|
||||||
|
c.SetSession("saved")
|
||||||
|
m := NewModel(c, "http://test", time.Minute, signedOut("x").theme)
|
||||||
|
m.width, m.height = 120, 40
|
||||||
|
m, cmd := update(t, m, fetchDataErrMsg{&api.StatusError{Code: 401}})
|
||||||
|
if m.mode != modeLogin || m.authInfo != nil || cmd == nil {
|
||||||
|
t.Errorf("expected the form and a question to the server: mode %v info %v cmd %v", m.mode, m.authInfo, cmd != nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
+155
-5
@@ -7,6 +7,7 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.ryuvia.com/niklas/terdut-tui/internal/api"
|
"git.ryuvia.com/niklas/terdut-tui/internal/api"
|
||||||
"github.com/atotto/clipboard"
|
"github.com/atotto/clipboard"
|
||||||
@@ -20,7 +21,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
// was being done cannot succeed, so go back to the sign-in form and say why,
|
// was being done cannot succeed, so go back to the sign-in form and say why,
|
||||||
// rather than leaving every action to fail with "server returned 401".
|
// rather than leaving every action to fail with "server returned 401".
|
||||||
if err := msgError(msg); api.IsUnauthorized(err) && m.mode != modeLogin {
|
if err := msgError(msg); api.IsUnauthorized(err) && m.mode != modeLogin {
|
||||||
return m.requireLogin("your session has ended — sign in again"), forgetSessionCmd()
|
m, entry := m.requireLogin("your session has ended — sign in again").enterLogin(true)
|
||||||
|
return m, tea.Batch(forgetSessionCmd(), entry)
|
||||||
}
|
}
|
||||||
|
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
@@ -45,6 +47,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
case loginDoneMsg:
|
case loginDoneMsg:
|
||||||
m.loggingIn = false
|
m.loggingIn = false
|
||||||
|
m.sso = ssoLogin{attempt: m.sso.attempt + 1}
|
||||||
m.loginErr = ""
|
m.loginErr = ""
|
||||||
m.loginNote = ""
|
m.loginNote = ""
|
||||||
m.loginInputs[loginPassword].Reset()
|
m.loginInputs[loginPassword].Reset()
|
||||||
@@ -61,7 +64,57 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case logoutDoneMsg:
|
case logoutDoneMsg:
|
||||||
return m.requireLogin("you have signed out"), nil
|
// Not auto-started even with auth: sso: somebody who has just signed out
|
||||||
|
// did not ask to be signed straight back in.
|
||||||
|
return m.requireLogin("you have signed out").enterLogin(false)
|
||||||
|
|
||||||
|
case authConfigMsg:
|
||||||
|
cfg := msg.cfg
|
||||||
|
m.authInfo = &cfg
|
||||||
|
if m.mode == modeLogin && m.autoStartsSSO() {
|
||||||
|
return m.startSSO()
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case deviceStartedMsg:
|
||||||
|
if !m.sso.current(msg.attempt) {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
login := msg.login
|
||||||
|
m.sso.login = &login
|
||||||
|
m.sso.interval = time.Duration(login.Interval) * time.Second
|
||||||
|
if m.sso.interval <= 0 {
|
||||||
|
m.sso.interval = defaultDevicePoll
|
||||||
|
}
|
||||||
|
return m, devicePollAfter(m.sso.attempt, m.sso.interval)
|
||||||
|
|
||||||
|
case devicePollMsg:
|
||||||
|
if !m.sso.current(msg.attempt) || m.sso.login == nil {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
return m, pollDeviceCmd(m.client, m.serverURL, m.sso.attempt, m.sso.login.DeviceCode)
|
||||||
|
|
||||||
|
case devicePendingMsg:
|
||||||
|
if !m.sso.current(msg.attempt) {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
if msg.err != nil {
|
||||||
|
if m.sso.failures++; m.sso.failures >= maxPollFailures {
|
||||||
|
return m.failSSO(msg.err), nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
m.sso.failures = 0
|
||||||
|
}
|
||||||
|
if msg.slower {
|
||||||
|
m.sso.interval += defaultDevicePoll
|
||||||
|
}
|
||||||
|
return m, devicePollAfter(m.sso.attempt, m.sso.interval)
|
||||||
|
|
||||||
|
case deviceFailedMsg:
|
||||||
|
if !m.sso.current(msg.attempt) {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
return m.failSSO(msg.err), nil
|
||||||
|
|
||||||
case connectedMsg:
|
case connectedMsg:
|
||||||
firstConnect := len(m.teams) == 0
|
firstConnect := len(m.teams) == 0
|
||||||
@@ -376,7 +429,7 @@ func (m Model) routeKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
|||||||
|
|
||||||
case modeLogin:
|
case modeLogin:
|
||||||
var inputCmd tea.Cmd
|
var inputCmd tea.Cmd
|
||||||
if !m.loggingIn {
|
if !m.loggingIn && !m.sso.active && m.offersPasswords() {
|
||||||
m.loginInputs[m.loginFocus], inputCmd = m.loginInputs[m.loginFocus].Update(msg)
|
m.loginInputs[m.loginFocus], inputCmd = m.loginInputs[m.loginFocus].Update(msg)
|
||||||
}
|
}
|
||||||
m2, ourCmd := m.handleKey(msg)
|
m2, ourCmd := m.handleKey(msg)
|
||||||
@@ -1485,6 +1538,7 @@ func (m Model) requireLogin(note string) Model {
|
|||||||
fresh := NewModel(m.client, m.serverURL, m.refreshInterval, m.theme)
|
fresh := NewModel(m.client, m.serverURL, m.refreshInterval, m.theme)
|
||||||
fresh.width, fresh.height = m.width, m.height
|
fresh.width, fresh.height = m.width, m.height
|
||||||
fresh.defaultTeam = m.defaultTeam
|
fresh.defaultTeam = m.defaultTeam
|
||||||
|
fresh.authInfo, fresh.authPref = m.authInfo, m.authPref
|
||||||
fresh.ticking = m.ticking
|
fresh.ticking = m.ticking
|
||||||
fresh.mode = modeLogin
|
fresh.mode = modeLogin
|
||||||
fresh.loginInputs[loginUsername].SetValue(name)
|
fresh.loginInputs[loginUsername].SetValue(name)
|
||||||
@@ -1530,12 +1584,108 @@ func loginErrorText(err error) string {
|
|||||||
return err.Error()
|
return err.Error()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Single sign-on ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// current reports whether a message belongs to the attempt in progress. Anything
|
||||||
|
// else is the late answer of one that was cancelled, replaced or finished.
|
||||||
|
func (s ssoLogin) current(attempt int) bool { return s.active && attempt == s.attempt }
|
||||||
|
|
||||||
|
// canSSO is whether the server can sign in a client with no browser.
|
||||||
|
func (m Model) canSSO() bool { return m.authInfo != nil && m.authInfo.DeviceLogin }
|
||||||
|
|
||||||
|
// offersPasswords is whether the password form is worth showing. Until the
|
||||||
|
// server has answered it is: the form is what a server too old to be asked has.
|
||||||
|
func (m Model) offersPasswords() bool { return m.authInfo == nil || m.authInfo.PasswordLogin }
|
||||||
|
|
||||||
|
// autoStartsSSO is whether the form should start a single sign-on login by
|
||||||
|
// itself: when the config asks for it, and when the server has no passwords, so
|
||||||
|
// that there is nothing else to show.
|
||||||
|
func (m Model) autoStartsSSO() bool {
|
||||||
|
return m.canSSO() && !m.sso.active && (m.authPref == "sso" || !m.offersPasswords())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ssoName is what the provider is called on screen.
|
||||||
|
func (m Model) ssoName() string {
|
||||||
|
if m.authInfo != nil && m.authInfo.OIDC.Name != "" {
|
||||||
|
return m.authInfo.OIDC.Name
|
||||||
|
}
|
||||||
|
return "single sign-on"
|
||||||
|
}
|
||||||
|
|
||||||
|
// enterLogin is what to do on arriving at the sign-in form other than by
|
||||||
|
// starting up: learn how the server can be signed in to if that is not known,
|
||||||
|
// and, when auto is set, start a single sign-on login if the config or the
|
||||||
|
// server's lack of passwords calls for one.
|
||||||
|
func (m Model) enterLogin(auto bool) (Model, tea.Cmd) {
|
||||||
|
if m.authInfo == nil {
|
||||||
|
return m, authConfigCmd(m.client)
|
||||||
|
}
|
||||||
|
if auto && m.autoStartsSSO() {
|
||||||
|
return m.startSSO()
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// startSSO begins a device login, replacing any earlier attempt.
|
||||||
|
func (m Model) startSSO() (Model, tea.Cmd) {
|
||||||
|
m.sso = ssoLogin{attempt: m.sso.attempt + 1, active: true}
|
||||||
|
m.loginErr = ""
|
||||||
|
return m, startDeviceCmd(m.client, m.sso.attempt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cancelSSO abandons the attempt in progress. The server forgets the login when
|
||||||
|
// it expires; there is nothing to tell it.
|
||||||
|
func (m Model) cancelSSO() Model {
|
||||||
|
m.sso = ssoLogin{attempt: m.sso.attempt + 1}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// failSSO ends the attempt and says why on the form.
|
||||||
|
func (m Model) failSSO(err error) Model {
|
||||||
|
m = m.cancelSSO()
|
||||||
|
m.loginErr = ssoErrorText(err, m.ssoName())
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// ssoErrorText turns a failed single sign-on into something to act on.
|
||||||
|
func ssoErrorText(err error, name string) string {
|
||||||
|
var se *api.StatusError
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, api.ErrDeviceExpired):
|
||||||
|
return "the sign-in expired before it was approved — start it again"
|
||||||
|
case errors.Is(err, api.ErrDeviceDenied):
|
||||||
|
return "the sign-in was refused in the browser"
|
||||||
|
case errors.As(err, &se) && se.Code == http.StatusNotFound:
|
||||||
|
return "this server does not offer sign-in with " + name
|
||||||
|
case errors.As(err, &se) && se.Code == http.StatusTooManyRequests:
|
||||||
|
return "too many attempts — wait a few minutes and try again"
|
||||||
|
}
|
||||||
|
return "sign-in with " + name + " failed: " + err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
func (m Model) handleLoginKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
func (m Model) handleLoginKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
case "ctrl+c", "esc":
|
case "ctrl+c":
|
||||||
|
return m, tea.Quit
|
||||||
|
case "esc":
|
||||||
|
// Backs out of a single sign-on wait before it quits the program, so a
|
||||||
|
// wrong turn does not cost the session.
|
||||||
|
if m.sso.active {
|
||||||
|
return m.cancelSSO(), nil
|
||||||
|
}
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
}
|
}
|
||||||
if m.loggingIn {
|
if m.loggingIn || m.sso.active {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
if msg.String() == "ctrl+o" && m.canSSO() {
|
||||||
|
return m.startSSO()
|
||||||
|
}
|
||||||
|
// With no password form there is one thing to do, and enter does it.
|
||||||
|
if msg.String() == "enter" && !m.offersPasswords() {
|
||||||
|
if m.canSSO() {
|
||||||
|
return m.startSSO()
|
||||||
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+46
-1
@@ -42,17 +42,62 @@ func (m Model) renderLogin() string {
|
|||||||
if m.loginNote != "" {
|
if m.loginNote != "" {
|
||||||
b.WriteString(m.styles.Status.Render(" "+m.loginNote) + "\n\n")
|
b.WriteString(m.styles.Status.Render(" "+m.loginNote) + "\n\n")
|
||||||
}
|
}
|
||||||
|
if m.sso.active {
|
||||||
|
b.WriteString(m.renderSSOWait())
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
if m.offersPasswords() {
|
||||||
b.WriteString(" " + m.styles.Header.Render("Username: ") + m.loginInputs[loginUsername].View() + "\n")
|
b.WriteString(" " + m.styles.Header.Render("Username: ") + m.loginInputs[loginUsername].View() + "\n")
|
||||||
b.WriteString(" " + m.styles.Header.Render("Password: ") + m.loginInputs[loginPassword].View() + "\n\n")
|
b.WriteString(" " + m.styles.Header.Render("Password: ") + m.loginInputs[loginPassword].View() + "\n\n")
|
||||||
|
} else {
|
||||||
|
b.WriteString(m.styles.Muted.Render(" This server signs in with "+m.ssoName()+".") + "\n\n")
|
||||||
|
}
|
||||||
switch {
|
switch {
|
||||||
case m.loggingIn:
|
case m.loggingIn:
|
||||||
b.WriteString(m.styles.Muted.Render(" Signing in…") + "\n")
|
b.WriteString(m.styles.Muted.Render(" Signing in…") + "\n")
|
||||||
case m.loginErr != "":
|
case m.loginErr != "":
|
||||||
b.WriteString(m.styles.Error.Render(" "+m.loginErr) + "\n")
|
b.WriteString(m.styles.Error.Render(" "+m.loginErr) + "\n")
|
||||||
}
|
}
|
||||||
|
if m.canSSO() && m.offersPasswords() {
|
||||||
|
b.WriteString("\n" + m.styles.Muted.Render(" or press ctrl+o to sign in with "+m.ssoName()) + "\n")
|
||||||
|
}
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// renderSSOWait is the single sign-on screen: the link to open and the code to
|
||||||
|
// check against it, while the client waits for the approval.
|
||||||
|
func (m Model) renderSSOWait() string {
|
||||||
|
var b strings.Builder
|
||||||
|
l := m.sso.login
|
||||||
|
if l == nil {
|
||||||
|
b.WriteString(m.styles.Muted.Render(" Contacting the server…") + "\n")
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
b.WriteString(" " + m.styles.Header.Render("Sign in with "+m.ssoName()) + "\n\n")
|
||||||
|
b.WriteString(" Open this link in a browser, on any device, and approve the sign-in:\n\n")
|
||||||
|
b.WriteString(" " + m.styles.Accent.Render(l.VerificationURL) + "\n\n")
|
||||||
|
b.WriteString(" " + m.styles.Header.Render("Code: ") + m.styles.Bold.Render(l.UserCode) +
|
||||||
|
m.styles.Muted.Render(" it should match the code on that page") + "\n\n")
|
||||||
|
b.WriteString(m.styles.Muted.Render(fmt.Sprintf(" Waiting for approval… good for %d minutes", (l.ExpiresIn+59)/60)) + "\n")
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// loginHelp is the sign-in footer, which depends on what the server offers.
|
||||||
|
func (m Model) loginHelp() string {
|
||||||
|
switch {
|
||||||
|
case m.sso.active:
|
||||||
|
return " esc·cancel"
|
||||||
|
case !m.offersPasswords():
|
||||||
|
if m.canSSO() {
|
||||||
|
return " enter·sign in with " + m.ssoName() + " esc·quit"
|
||||||
|
}
|
||||||
|
return " esc·quit"
|
||||||
|
case m.canSSO():
|
||||||
|
return " tab·next field enter·sign in ctrl+o·" + m.ssoName() + " esc·quit"
|
||||||
|
}
|
||||||
|
return " tab·next field enter·sign in esc·quit"
|
||||||
|
}
|
||||||
|
|
||||||
// activeTeamLabel names what the lists are narrowed to.
|
// activeTeamLabel names what the lists are narrowed to.
|
||||||
func (m Model) activeTeamLabel() string {
|
func (m Model) activeTeamLabel() string {
|
||||||
if t, ok := m.activeTeam(); ok {
|
if t, ok := m.activeTeam(); ok {
|
||||||
@@ -187,7 +232,7 @@ func (m Model) renderFooter() string {
|
|||||||
return withStatus(" tab·next field enter·set password esc·cancel")
|
return withStatus(" tab·next field enter·set password esc·cancel")
|
||||||
|
|
||||||
case modeLogin:
|
case modeLogin:
|
||||||
return "\n" + m.styles.Footer.Render(" tab·next field enter·sign in esc·quit")
|
return "\n" + m.styles.Footer.Render(m.loginHelp())
|
||||||
|
|
||||||
default:
|
default:
|
||||||
switch m.activeSection {
|
switch m.activeSection {
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
model := tui.NewModel(client, cfg.ServerURL, cfg.RefreshInterval, th).
|
model := tui.NewModel(client, cfg.ServerURL, cfg.RefreshInterval, th).
|
||||||
WithDefaultTeam(cfg.Team).
|
WithDefaultTeam(cfg.Team).
|
||||||
|
WithAuth(cfg.Auth).
|
||||||
WithLogin(cfg.Username, note)
|
WithLogin(cfg.Username, note)
|
||||||
|
|
||||||
p := tea.NewProgram(model, tea.WithAltScreen())
|
p := tea.NewProgram(model, tea.WithAltScreen())
|
||||||
|
|||||||
Reference in New Issue
Block a user