Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/console-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ The ModelSphere community portal. A full-stack BFF that **owns identity** and
**federates everything else**. It exists because the open-source inference stack
(swiss and friends) has no user management, login, or unified entry point.

## Login failures

| Failure | Token endpoint | Login page |
|---|---|---|
| Missing or invalid credentials, unknown or inactive user | `401 invalid_grant` | Generic incorrect-credentials message |
| Kubernetes user lookup or token issuance fails | `500 server_error`; cause in server logs, service-failure audit reason | Service-unavailable guidance; check connectivity/VPN or contact an administrator |
| Browser cannot reach the endpoint, or receives an unusable response | No session is stored | The same service-unavailable guidance |

## Why a separate service (not part of swiss)

swiss is a deploy control plane: near-stateless, "the cluster is the only source
Expand Down
8 changes: 7 additions & 1 deletion internal/iam/authenticator.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ package iam
import (
"context"
"errors"
"fmt"
"log/slog"

apierrors "k8s.io/apimachinery/pkg/api/errors"
)

// ErrAuth is returned for every credential failure, without distinguishing
Expand Down Expand Up @@ -45,9 +48,12 @@ func (a *Authenticator) Login(ctx context.Context, username, password string) (*
return nil, newAuthFailure("username or password missing")
}
u, err := a.store.GetUser(ctx, username)
if err != nil {
if apierrors.IsNotFound(err) {
return nil, newAuthFailure("user not found")
}
if err != nil {
return nil, fmt.Errorf("get user: %w", err)
}
if u.Status.State != "" && u.Status.State != UserActive {
return nil, newAuthFailure("user is not active")
}
Expand Down
45 changes: 45 additions & 0 deletions internal/iam/authenticator_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package iam

import (
"context"
"errors"
"log/slog"
"testing"
"time"

apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/runtime"
dynamicfake "k8s.io/client-go/dynamic/fake"
k8stesting "k8s.io/client-go/testing"
)

func TestLoginPreservesUserReadErrors(t *testing.T) {
for _, cause := range []error{
context.DeadlineExceeded,
context.Canceled,
errors.New("connection refused"),
apierrors.NewForbidden(UsersGVR.GroupResource(), "admin", errors.New("access denied")),
apierrors.NewUnauthorized("cluster credentials expired"),
} {
t.Run(cause.Error(), func(t *testing.T) {
dyn := dynamicfake.NewSimpleDynamicClient(runtime.NewScheme())
dyn.PrependReactor("get", "users", func(k8stesting.Action) (bool, runtime.Object, error) {
return true, nil, cause
})
authn := NewAuthenticator(NewStore(dyn), NewSigner("https://issuer.example.com", "test-only", time.Hour), slog.New(slog.DiscardHandler))
token, err := authn.Login(t.Context(), "admin", "test-password")
if token != nil || !errors.Is(err, cause) || errors.Is(err, ErrAuth) {
t.Fatalf("Login() = %v, %v; want the user-read error, not ErrAuth", token, err)
}
})
}
}

func TestLoginMissingUserRemainsACredentialFailure(t *testing.T) {
dyn := dynamicfake.NewSimpleDynamicClient(runtime.NewScheme())
authn := NewAuthenticator(NewStore(dyn), NewSigner("https://issuer.example.com", "test-only", time.Hour), slog.New(slog.DiscardHandler))
token, err := authn.Login(t.Context(), "missing", "test-password")
if token != nil || !errors.Is(err, ErrAuth) || err.Error() != ErrAuth.Error() {
t.Fatalf("Login() = %v, %v; want the generic credential error", token, err)
}
}
4 changes: 2 additions & 2 deletions internal/server/oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@ func (s *Server) handleToken(w http.ResponseWriter, r *http.Request) {
writeOAuthError(w, http.StatusUnauthorized, "invalid_grant")
return
}
reason = "token issuance failed"
s.log.Error("token issue", "err", err)
reason = "login service failed"
s.log.Error("login", "err", err)
writeOAuthError(w, http.StatusInternalServerError, "server_error")
return
}
Expand Down
75 changes: 75 additions & 0 deletions internal/server/oauth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package server

import (
"bytes"
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"

"github.com/modelsphere/console/internal/iam"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/runtime"
dynamicfake "k8s.io/client-go/dynamic/fake"
k8stesting "k8s.io/client-go/testing"
)

func TestTokenUserReadFailureIsAServerError(t *testing.T) {
for _, cause := range []error{
context.DeadlineExceeded,
errors.New("connection refused"),
apierrors.NewForbidden(iam.UsersGVR.GroupResource(), "admin", errors.New("access denied")),
apierrors.NewUnauthorized("cluster credentials expired"),
} {
t.Run(cause.Error(), func(t *testing.T) {
srv := testServer(t)
dyn := srv.kube.Dynamic().(*dynamicfake.FakeDynamicClient)
dyn.PrependReactor("get", "users", func(k8stesting.Action) (bool, runtime.Object, error) {
return true, nil, cause
})
var logs bytes.Buffer
srv.log = slog.New(slog.NewJSONHandler(&logs, nil))
form := url.Values{"grant_type": {"password"}, "username": {"admin"}, "password": {"test-password"}}
req := httptest.NewRequest("POST", "/oauth/token", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
var body map[string]string
decodeErr := json.Unmarshal(rec.Body.Bytes(), &body)
if rec.Code != http.StatusInternalServerError || decodeErr != nil || len(body) != 1 || body["error"] != "server_error" {
t.Fatalf("token response = %d %s; want generic server_error", rec.Code, rec.Body.String())
}
var entry map[string]any
logErr := json.NewDecoder(&logs).Decode(&entry)
loggedCause, _ := entry["err"].(string)
if logErr != nil || !strings.Contains(loggedCause, cause.Error()) || strings.Contains(loggedCause, "test-password") {
t.Fatalf("logs should preserve the cause without the password: %s", logs.String())
}
records, err := srv.store.ListLoginRecords(t.Context(), "admin")
if err != nil || len(records) != 1 || records[0].Spec.Success || records[0].Spec.Reason != "login service failed" {
t.Fatalf("login audit = %v, %v; want a service failure", records, err)
}
})
}
}

func TestTokenCredentialFailuresStayIndistinguishable(t *testing.T) {
srv := testServer(t)
for _, credentials := range [][2]string{{"admin", "wrong"}, {"missing", "wrong"}, {"", "wrong"}, {"admin", ""}} {
form := url.Values{"grant_type": {"password"}, "username": {credentials[0]}, "password": {credentials[1]}}
req := httptest.NewRequest("POST", "/oauth/token", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
var body map[string]string
decodeErr := json.Unmarshal(rec.Body.Bytes(), &body)
if rec.Code != http.StatusUnauthorized || decodeErr != nil || len(body) != 1 || body["error"] != "invalid_grant" {
t.Fatalf("token response = %d %s; want generic invalid_grant", rec.Code, rec.Body.String())
}
}
}
76 changes: 76 additions & 0 deletions web/src/shell/api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { login } from "@/shell/api";
import { DEFAULT_LOCALE, getT, setLocale, type Locale } from "@/shell/i18n";

afterEach(() => {
vi.unstubAllGlobals();
setLocale(DEFAULT_LOCALE);
});

describe.each<Locale>(["en-US", "zh-CN"])("login in %s", (locale) => {
beforeEach(() => {
setLocale(locale);
vi.stubGlobal("document", { cookie: "token=existing" });
});

it("keeps invalid credentials generic", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(Response.json({ error: "invalid_grant" }, { status: 401 })));
await expect(login("missing", "wrong")).rejects.toMatchObject({
status: 401,
message: getT("shell")("login.badCredentials"),
});
expect(document.cookie).toBe("token=existing");
});

it.each([
[500, '{"error":"server_error"}'],
[503, '{"error":"invalid_grant"}'],
[502, "<html>upstream unavailable</html>"],
[401, '{"error":"upstream_auth_failed"}'],
[401, "unauthorized"],
[400, '{"error":"invalid_request"}'],
[429, '{"error":"rate_limited"}'],
])("reports HTTP %s service failures without exposing the response body", async (status, body) => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(body, { status })));
const message = getT("shell")("login.serviceUnavailable");
expect(message).not.toBe("login.serviceUnavailable");
await expect(login("admin", "test-password")).rejects.toMatchObject({
status,
message,
});
expect(document.cookie).toBe("token=existing");
});

it("gives recovery guidance when the browser cannot reach the service", async () => {
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("Failed to fetch")));
await expect(login("admin", "test-password")).rejects.toMatchObject({
status: 0,
message: getT("shell")("login.serviceUnavailable"),
});
expect(document.cookie).toBe("token=existing");
});

it.each(["<html>proxy login</html>", "{}", '{"access_token":""}', '{"access_token":123}', "null"])(
"does not store a malformed token response: %s",
async (body) => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(body, { status: 200 })));
await expect(login("admin", "test-password")).rejects.toMatchObject({
status: 200,
message: getT("shell")("login.serviceUnavailable"),
});
expect(document.cookie).toBe("token=existing");
},
);

it("keeps the password grant and stores a successful token", async () => {
const fetch = vi.fn().mockResolvedValue(Response.json({ access_token: "test/token" }));
vi.stubGlobal("fetch", fetch);
await login("name+space", "pass&word");
expect(fetch).toHaveBeenCalledWith("/oauth/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: "grant_type=password&username=name%2Bspace&password=pass%26word",
});
expect(document.cookie).toBe("token=test%2Ftoken; path=/; SameSite=Lax");
});
});
22 changes: 16 additions & 6 deletions web/src/shell/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,21 +80,31 @@ export interface ChangePasswordInput {
// login exchanges credentials for a token at the OAuth2 password-grant endpoint
// (form-encoded, exactly as Rise Global expects) and stores it.
export async function login(username: string, password: string): Promise<void> {
const form = new URLSearchParams({ grant_type: "password", username, password });
const form = new URLSearchParams({
grant_type: "password",
username,
password,
});
const unavailable = getT("shell")("login.serviceUnavailable");
const res = await fetch("/oauth/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: form.toString(),
}).catch(() => {
throw new ApiError(0, unavailable);
});
const body = await res.json().catch(() => null);
if (!res.ok) {
throw new ApiError(res.status, getT("shell")("login.badCredentials"));
const message = res.status === 401 && body?.error === "invalid_grant" ? getT("shell")("login.badCredentials") : unavailable;
throw new ApiError(res.status, message);
}
if (typeof body?.access_token !== "string" || !body.access_token) {
throw new ApiError(res.status, unavailable);
}
const tok = (await res.json()) as { access_token: string };
setToken(tok.access_token);
setToken(body.access_token);
}

export const api = {
me: () => request<Me>("GET", "/api/me"),
changePassword: (passwords: ChangePasswordInput) =>
request<{ status: string }>("POST", "/api/me/password", passwords),
changePassword: (passwords: ChangePasswordInput) => request<{ status: string }>("POST", "/api/me/password", passwords),
};
3 changes: 2 additions & 1 deletion web/src/shell/locales/shell.en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@
"submit": "Sign in",
"submitting": "Signing in…",
"failed": "Sign-in failed",
"badCredentials": "Incorrect username or password"
"badCredentials": "Incorrect username or password",
"serviceUnavailable": "The sign-in service is unavailable. Check your network or VPN connection and try again, or contact your administrator."
},
"password": {
"current": "Current password",
Expand Down
3 changes: 2 additions & 1 deletion web/src/shell/locales/shell.zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@
"submit": "登录",
"submitting": "登录中…",
"failed": "登录失败",
"badCredentials": "用户名或密码错误"
"badCredentials": "用户名或密码错误",
"serviceUnavailable": "登录服务暂时不可用。请检查网络或 VPN 连接后重试,或联系管理员。"
},
"password": {
"current": "当前密码",
Expand Down