Sign in through the server's single sign-on, with a code
CI / test (push) Successful in 17s
Release / test (push) Successful in 3s
Release / binaries (push) Successful in 23s

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:
Niklas Ye
2026-09-26 21:47:13 +02:00
parent 057302cb39
commit ee25552a53
11 changed files with 970 additions and 12 deletions
+92
View File
@@ -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)
}
// 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.
func (c *Client) Logout() error {
req, err := c.newRequest(http.MethodPost, "/api/logout")
+103
View File
@@ -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")
}
}
+29
View File
@@ -32,6 +32,35 @@ type Alert struct {
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.
const (
StatusTriggered = "triggered"