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:
@@ -584,3 +584,106 @@ func TestClient_StatusErrorKeepsCodeAndMessage(t *testing.T) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user