Skip to content
Merged
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
3,643 changes: 2,081 additions & 1,562 deletions api/openapi.json

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions api/openapi.provenance.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"repository": "Life-USTC/server",
"commit": "e32d01a31d2e3b79765448aed8665eb197b93977",
"sha256": "d56af5ae5da8dc89e5c3ef618bc39cd0ea9b64e93a8e0808e4e2ab6ffabd58f4"
"commit": "dabcb0aae2b3ca19d36fc340f86ec2627d88a5c5",
"sha256": "fba8624277f766ac924cf070807bb071059157f75849c996ea4a943258ba402a"
}
17 changes: 16 additions & 1 deletion internal/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ func (t *authTransport) RoundTrip(req *http.Request) (*http.Response, error) {
req.Header.Set("Authorization", "Bearer "+cred.AccessToken)
}

output.VerboseF("→ %s %s", req.Method, req.URL)
output.VerboseF("→ %s %s", req.Method, redactedRequestURL(req.URL))
start := time.Now()

resp, err := t.base.RoundTrip(req)
Expand Down Expand Up @@ -127,6 +127,21 @@ func (t *authTransport) RoundTrip(req *http.Request) (*http.Response, error) {
return resp, nil
}

func redactedRequestURL(requestURL *url.URL) string {
if requestURL == nil {
return ""
}
redacted := *requestURL
const calendarFeedPrefix = "/api/calendar-feeds/"
if strings.HasPrefix(redacted.Path, calendarFeedPrefix) {
redacted.Path = calendarFeedPrefix + "[redacted].ics"
redacted.RawPath = ""
redacted.RawQuery = ""
redacted.ForceQuery = false
}
return redacted.String()
}

func (t *authTransport) ensureToken() bool {
t.mu.Lock()
defer t.mu.Unlock()
Expand Down
31 changes: 31 additions & 0 deletions internal/api/client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package api

import (
"net/url"
"strings"
"testing"
)

func TestRedactedRequestURLHidesCalendarFeedCredential(t *testing.T) {
requestURL, err := url.Parse("https://life.example/api/calendar-feeds/private-token.ics?token=query-secret&download=1")
if err != nil {
t.Fatal(err)
}
got := redactedRequestURL(requestURL)
if strings.Contains(got, "private-token") || strings.Contains(got, "query-secret") {
t.Fatalf("redacted URL leaked feed credential: %s", got)
}
if got != "https://life.example/api/calendar-feeds/%5Bredacted%5D.ics" {
t.Fatalf("redacted URL = %q", got)
}
}

func TestRedactedRequestURLLeavesOrdinaryPathsIntact(t *testing.T) {
requestURL, err := url.Parse("https://life.example/api/workspace/subscriptions/current")
if err != nil {
t.Fatal(err)
}
if got := redactedRequestURL(requestURL); got != requestURL.String() {
t.Fatalf("redacted URL = %q, want %q", got, requestURL)
}
}
54 changes: 39 additions & 15 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,33 +88,52 @@ func oauthResource(server string, meta map[string]any) string {
return strings.TrimRight(server, "/")
}

var cliOAuthScopes = []string{
"email",
"offline_access",
"account.profile:read",
"catalog.bus:read",
"catalog.course:read",
"catalog.link:read",
"catalog.schedule:read",
"catalog.section:read",
"catalog.teacher:read",
"community.comment:write",
"community.description:write",
"community.section-homework:write",
"workspace.bus-preferences:write",
"workspace.calendar-feed:read",
"workspace.homework:write",
"workspace.link-pin:write",
"workspace.overview:read",
"workspace.schedule:read",
"workspace.subscription:write",
"workspace.todo:write",
"workspace.upload:write",
}

func oauthScopesFromMetadata(meta map[string]any) ([]string, error) {
rawScopes, ok := meta["scopes_supported"].([]any)
if !ok || len(rawScopes) == 0 {
return nil, fmt.Errorf("server OAuth metadata does not advertise scopes_supported")
}

scopes := make([]string, 0, len(rawScopes))
seen := make(map[string]struct{}, len(rawScopes))
for _, rawScope := range rawScopes {
scope, ok := rawScope.(string)
if !ok || strings.TrimSpace(scope) == "" {
return nil, fmt.Errorf("server OAuth metadata contains an invalid supported scope")
}
scope = strings.TrimSpace(scope)
if scope == "openid" || scope == "profile" || scope == "email" {
continue
}
if _, duplicate := seen[scope]; duplicate {
continue
}
seen[scope] = struct{}{}
scopes = append(scopes, scope)
}
if len(scopes) == 0 {
return nil, fmt.Errorf("server OAuth metadata does not advertise usable API scopes")

for _, scope := range cliOAuthScopes {
if _, supported := seen[scope]; !supported {
return nil, fmt.Errorf("server OAuth metadata does not advertise required scope %q", scope)
}
}
return scopes, nil
return append([]string(nil), cliOAuthScopes...), nil
}

func registerPublicClient(endpoint string, scopes, redirectURIs, grantTypes, responseTypes []string) (map[string]any, error) {
Expand Down Expand Up @@ -255,6 +274,14 @@ func callbackRedirectURI(addr net.Addr) string {
return fmt.Sprintf("http://%s/callback", addr.String())
}

func browserAuthorizationURL(conf *oauth2.Config, state, challenge string) string {
return conf.AuthCodeURL(state,
oauth2.SetAuthURLParam("code_challenge", challenge),
oauth2.SetAuthURLParam("code_challenge_method", "S256"),
oauth2.SetAuthURLParam("prompt", "login"),
)
}

type callbackResult struct {
code string
state string
Expand Down Expand Up @@ -345,10 +372,7 @@ func Login(server string) (*config.Credential, error) {

ctx := oauth2Context(context.Background(), &http.Client{Timeout: 15 * time.Second})

authURL := conf.AuthCodeURL(state,
oauth2.SetAuthURLParam("code_challenge", challenge),
oauth2.SetAuthURLParam("code_challenge_method", "S256"),
)
authURL := browserAuthorizationURL(conf, state, challenge)

ch := make(chan callbackResult, 1)

Expand Down
86 changes: 58 additions & 28 deletions internal/auth/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
"time"

Expand Down Expand Up @@ -92,35 +93,52 @@ func TestRegisterPublicClientOmitsUnusedDeviceRedirectMetadata(t *testing.T) {
}

func TestOAuthScopesFromMetadata(t *testing.T) {
advertised := make([]any, 0, len(cliOAuthScopes)+3)
advertised = append(advertised,
"openid",
"profile",
"account.client-activity:read",
)
for _, scope := range cliOAuthScopes {
advertised = append(advertised, scope)
}
scopes, err := oauthScopesFromMetadata(map[string]any{
"scopes_supported": []any{
"openid",
"profile",
"email",
"offline_access",
"workspace.todo:read",
"workspace.todo:write",
"workspace.todo:read",
},
"scopes_supported": advertised,
})
if err != nil {
t.Fatal(err)
}
want := []string{
"offline_access",
"workspace.todo:read",
"workspace.todo:write",
if strings.Join(scopes, " ") != strings.Join(cliOAuthScopes, " ") {
t.Fatalf("scopes = %#v, want %#v", scopes, cliOAuthScopes)
}
if len(scopes) != len(want) {
t.Fatalf("scopes = %#v, want %#v", scopes, want)
granted := make(map[string]bool, len(scopes))
for _, scope := range scopes {
granted[scope] = true
}
for i := range want {
if scopes[i] != want[i] {
t.Fatalf("scopes = %#v, want %#v", scopes, want)
for _, forbidden := range []string{"account.client-activity:read", "openid", "profile"} {
if granted[forbidden] {
t.Fatalf("scopes unexpectedly include %q: %#v", forbidden, scopes)
}
}
}

func TestOAuthScopesFromMetadataRequiresCalendarFeedAndEmail(t *testing.T) {
for _, missing := range []string{"email", "workspace.calendar-feed:read"} {
t.Run(missing, func(t *testing.T) {
advertised := make([]any, 0, len(cliOAuthScopes)-1)
for _, scope := range cliOAuthScopes {
if scope != missing {
advertised = append(advertised, scope)
}
}
_, err := oauthScopesFromMetadata(map[string]any{"scopes_supported": advertised})
if err == nil || !strings.Contains(err.Error(), missing) {
t.Fatalf("error = %v, want missing required scope %q", err, missing)
}
})
}
}

func TestOAuthScopesFromMetadataRejectsMissingOrInvalidScopes(t *testing.T) {
tests := []struct {
name string
Expand All @@ -131,7 +149,7 @@ func TestOAuthScopesFromMetadataRejectsMissingOrInvalidScopes(t *testing.T) {
{name: "empty", meta: map[string]any{"scopes_supported": []any{}}},
{name: "invalid item", meta: map[string]any{"scopes_supported": []any{"offline_access", 42}}},
{name: "blank item", meta: map[string]any{"scopes_supported": []any{"offline_access", " "}}},
{name: "identity only", meta: map[string]any{"scopes_supported": []any{"openid", "profile", "email"}}},
{name: "missing required", meta: map[string]any{"scopes_supported": []any{"openid", "profile", "email"}}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Expand All @@ -158,13 +176,7 @@ func TestLoginDeviceCodeAcceptsOAuthTokenWithoutIDToken(t *testing.T) {
"registration_endpoint": server.URL + "/api/auth/oauth2/register",
"device_authorization_endpoint": server.URL + "/api/auth/oauth2/device-authorization",
"token_endpoint": server.URL + "/api/auth/oauth2/token",
"scopes_supported": []string{
"openid",
"profile",
"email",
"offline_access",
"workspace.todo:read",
},
"scopes_supported": append([]string{"openid", "profile", "account.client-activity:read"}, cliOAuthScopes...),
})
case "/api/auth/oauth2/register":
var body map[string]any
Expand Down Expand Up @@ -201,7 +213,7 @@ func TestLoginDeviceCodeAcceptsOAuthTokenWithoutIDToken(t *testing.T) {
"refresh_token": "device-refresh",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "offline_access workspace.todo:read",
"scope": strings.Join(cliOAuthScopes, " "),
})
default:
http.NotFound(w, r)
Expand All @@ -216,7 +228,7 @@ func TestLoginDeviceCodeAcceptsOAuthTokenWithoutIDToken(t *testing.T) {
if cred.AccessToken != "device-access" || cred.RefreshToken != "device-refresh" {
t.Fatalf("credential = %#v", cred)
}
const wantScope = "offline_access workspace.todo:read"
wantScope := strings.Join(cliOAuthScopes, " ")
if got := <-registrationScopes; got != wantScope {
t.Fatalf("registration scope = %q, want %q", got, wantScope)
}
Expand All @@ -231,6 +243,24 @@ func TestLoginDeviceCodeAcceptsOAuthTokenWithoutIDToken(t *testing.T) {
}
}

func TestBrowserAuthorizationURLForcesFreshLogin(t *testing.T) {
conf := &oauth2.Config{
ClientID: "client-1",
Endpoint: oauth2.Endpoint{AuthURL: "https://example.test/oauth/authorize"},
}
authURL, err := url.Parse(browserAuthorizationURL(conf, "state-1", "challenge-1"))
if err != nil {
t.Fatal(err)
}
query := authURL.Query()
if query.Get("prompt") != "login" {
t.Fatalf("prompt = %q, want login", query.Get("prompt"))
}
if query.Get("code_challenge") != "challenge-1" || query.Get("code_challenge_method") != "S256" {
t.Fatalf("PKCE query = %q", authURL.RawQuery)
}
}

func TestOAuthCallbackHandlerDeliversOnlyFirstRequest(t *testing.T) {
results := make(chan callbackResult, 1)
handler := oauthCallbackHandler(results)
Expand Down
1 change: 1 addition & 0 deletions internal/cmd/apicmd/api_paths_gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 13 additions & 6 deletions internal/cmd/calendar/calendar.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,12 +74,7 @@ func runCalendarGet(cmd *cobra.Command) error {
return nil
}

calURL, _ := sub["calendarUrl"].(string)
note, _ := sub["note"].(string)
output.KVWithTitle([]output.KVPair{
{Key: "URL", Value: output.Hyperlink(calURL, calURL)},
{Key: "Note", Value: note},
}, "Calendar subscription")
output.KVWithTitle(calendarSubscriptionDetails(sub), "Calendar subscription")

if sections, ok := sub["sections"].([]any); ok && len(sections) > 0 {
fmt.Println()
Expand All @@ -95,6 +90,18 @@ func runCalendarGet(cmd *cobra.Command) error {
return nil
}

func calendarSubscriptionDetails(sub map[string]any) []output.KVPair {
calendarFeed := "Unavailable (log in again to grant workspace.calendar-feed:read)"
if calendarURL, ok := sub["calendarUrl"].(string); ok && strings.TrimSpace(calendarURL) != "" {
calendarFeed = output.Hyperlink(calendarURL, calendarURL)
}
note, _ := sub["note"].(string)
return []output.KVPair{
{Key: "URL", Value: calendarFeed},
{Key: "Note", Value: note},
}
}

func newCmdGet() *cobra.Command {
return &cobra.Command{
Use: "get",
Expand Down
27 changes: 27 additions & 0 deletions internal/cmd/calendar/calendar_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,33 @@ import (
"testing"
)

func TestCalendarSubscriptionDetailsUseOnlyScopeGatedURL(t *testing.T) {
const calendarURL = "https://life.example/api/calendar-feeds/private-token.ics"
details := calendarSubscriptionDetails(map[string]any{
"calendarUrl": calendarURL,
"userId": "must-not-be-used-to-build-a-url",
})
if got := details[0].Value; !strings.Contains(got.(string), calendarURL) {
t.Fatalf("URL detail = %#v, want returned calendarUrl", got)
}
}

func TestCalendarSubscriptionDetailsExplainMissingScopeGatedURL(t *testing.T) {
for _, calendarURL := range []any{nil, ""} {
details := calendarSubscriptionDetails(map[string]any{
"calendarUrl": calendarURL,
"userId": "must-not-be-used-to-build-a-url",
})
got, _ := details[0].Value.(string)
if !strings.Contains(got, "Unavailable") || !strings.Contains(got, "workspace.calendar-feed:read") {
t.Fatalf("URL detail = %q, want clear missing-scope message", got)
}
if strings.Contains(got, "must-not-be-used") {
t.Fatalf("URL detail synthesized a fallback feed: %q", got)
}
}
}

func TestSetRequiresSemesterID(t *testing.T) {
for _, args := range [][]string{
{"999999"},
Expand Down
Loading