package app import ( "bytes" "context" "database/sql" "database/sql/driver" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "net/url" "strings" "testing" ) // Small in-memory SQL adapter keeps these HTTP policy tests independent of a // developer's database and credentials. Unexpected queries fail the test. type oauthTestDB struct { values map[string]string code userOAuthLoginCode codeUsed bool stateHash []byte stateProvider, stateVerifier, statePlatform string stateProof []byte stateUsed bool } type oauthTestConnector struct{ db *oauthTestDB } func (c oauthTestConnector) Connect(context.Context) (driver.Conn, error) { return c.db, nil } func (c oauthTestConnector) Driver() driver.Driver { return oauthTestDriver{} } type oauthTestDriver struct{} func (oauthTestDriver) Open(string) (driver.Conn, error) { return nil, fmt.Errorf("use connector") } func (*oauthTestDB) Prepare(string) (driver.Stmt, error) { return nil, fmt.Errorf("unexpected prepare") } func (*oauthTestDB) Close() error { return nil } func (*oauthTestDB) Begin() (driver.Tx, error) { return nil, fmt.Errorf("unexpected transaction") } type oauthTestRows struct { columns []string values [][]driver.Value } func (r *oauthTestRows) Columns() []string { return r.columns } func (*oauthTestRows) Close() error { return nil } func (r *oauthTestRows) Next(dest []driver.Value) error { if len(r.values) == 0 { return io.EOF } copy(dest, r.values[0]) r.values = r.values[1:] return nil } func oauthRow(values ...driver.Value) driver.Rows { columns := make([]string, len(values)) for i := range columns { columns[i] = fmt.Sprintf("c%d", i) } return &oauthTestRows{columns: columns, values: [][]driver.Value{values}} } func (db *oauthTestDB) QueryContext(_ context.Context, query string, args []driver.NamedValue) (driver.Rows, error) { switch { case strings.Contains(query, "FROM system_configs"): value, ok := db.values[args[0].Value.(string)] if !ok { return &oauthTestRows{columns: []string{"config_value", "value_type"}}, nil } return oauthRow(value, "string"), nil case strings.Contains(query, "SELECT hits FROM api_rate_limits"): return oauthRow(int64(1)), nil case strings.Contains(query, "SELECT provider,code_verifier,client_platform,app_proof_hash FROM user_oauth_states"): if db.stateUsed || !bytes.Equal(db.stateHash, args[0].Value.([]byte)) { return &oauthTestRows{columns: []string{"provider", "verifier", "platform", "proof"}}, nil } return oauthRow(db.stateProvider, db.stateVerifier, db.statePlatform, db.stateProof), nil case strings.Contains(query, "SELECT user_id FROM user_oauth_identities"): return &oauthTestRows{columns: []string{"user_id"}}, nil case strings.Contains(query, "FROM user_oauth_login_codes"): c := db.code return oauthRow(c.Provider, c.Subject, c.Email, c.DisplayName, c.AvatarURL, nil, c.Platform, c.IdentityScope, c.AppProofHash), nil case strings.Contains(query, "SELECT p.nickname,u.status"): return oauthRow("tester", int64(1)), nil default: return nil, fmt.Errorf("unexpected query: %s", query) } } func (db *oauthTestDB) ExecContext(_ context.Context, query string, args []driver.NamedValue) (driver.Result, error) { switch { case strings.Contains(query, "INSERT INTO user_oauth_states"): db.stateHash = args[0].Value.([]byte) db.stateProvider, db.stateVerifier, db.statePlatform = args[1].Value.(string), args[2].Value.(string), args[4].Value.(string) db.stateProof, _ = args[5].Value.([]byte) return driver.RowsAffected(1), nil case strings.Contains(query, "UPDATE user_oauth_states"): if db.stateUsed || !bytes.Equal(db.stateHash, args[0].Value.([]byte)) { return driver.RowsAffected(0), nil } db.stateUsed = true return driver.RowsAffected(1), nil case strings.Contains(query, "INSERT INTO user_oauth_login_codes"): db.code.Provider, db.code.Subject, db.code.Platform, db.code.IdentityScope = args[1].Value.(string), args[2].Value.(string), args[8].Value.(string), args[9].Value.(string) db.code.AppProofHash, _ = args[10].Value.([]byte) return driver.RowsAffected(1), nil case strings.HasPrefix(query, "DELETE FROM user_oauth_"): return driver.RowsAffected(0), nil case strings.Contains(query, "api_rate_limits"), strings.Contains(query, "UPDATE user_oauth_identities SET last_login_at"): return driver.RowsAffected(1), nil case strings.Contains(query, "UPDATE user_oauth_login_codes SET used_at"): if db.codeUsed { return driver.RowsAffected(0), nil } db.codeUsed = true return driver.RowsAffected(1), nil default: return nil, fmt.Errorf("unexpected exec: %s", query) } } func oauthTestApp(t *testing.T, values map[string]string) (*App, *oauthTestDB) { t.Helper() store := &oauthTestDB{values: values} db := sql.OpenDB(oauthTestConnector{store}) t.Cleanup(func() { _ = db.Close() }) return &App{db: db, config: Config{Environment: "development"}}, store } func oauthPost(handler http.HandlerFunc, body string) *httptest.ResponseRecorder { r := httptest.NewRequest("POST", "/api/v1/auth/oauth/test", strings.NewReader(body)) r.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() handler(w, r) return w } func TestAppOAuthProviderSwitchesAndConfiguration(t *testing.T) { app, store := oauthTestApp(t, map[string]string{ "oauth.user.qq.enabled": "true", "oauth.app.qq.enabled": "false", "oauth.app.qq.client_id": "mobile-id", "oauth.app.wechat.enabled": "true", }) list := func(platform string) string { w := httptest.NewRecorder() app.userOAuthProviders(w, httptest.NewRequest("GET", "/?platform="+platform, nil)) return w.Body.String() } if strings.Contains(list("app"), `"code":"qq"`) { t.Fatal("H5 switch must not enable App") } store.values["oauth.app.qq.enabled"] = "true" body := list("app") if !strings.Contains(body, `"code":"qq"`) || strings.Contains(body, `"code":"wechat"`) { t.Fatalf("only complete, enabled channels should be returned: %s", body) } if strings.Contains(body, "mobile-id") { t.Fatal("provider discovery must not return credentials") } w := httptest.NewRecorder() app.userOAuthProviders(w, httptest.NewRequest("GET", "/?platform=unknown", nil)) if w.Code != 400 { t.Fatal("unknown platform must be rejected") } } func TestDisabledAppOAuthBlocksEveryLoginEntry(t *testing.T) { app, store := oauthTestApp(t, map[string]string{"oauth.user.qq.enabled": "true", "oauth.app.qq.enabled": "false"}) store.code = userOAuthLoginCode{Provider: "qq", Platform: "app", Subject: "openid"} for name, handler := range map[string]http.HandlerFunc{"native": app.userOAuthNative, "exchange": app.userOAuthExchange, "link": app.userOAuthLink} { bodies := map[string]string{ "native": `{"provider":"qq","accessToken":"token"}`, "exchange": `{"code":"ticket"}`, "link": `{"code":"ticket","phone":"13800138000","smsCode":"123456"}`, } w := oauthPost(handler, bodies[name]) if w.Code != 400 || !(strings.Contains(w.Body.String(), "停用") || strings.Contains(w.Body.String(), "未启用")) { t.Fatalf("%s accepted a disabled App provider: %d %s", name, w.Code, w.Body.String()) } } } func TestAppOAuthCallbackProofAndCodeReplay(t *testing.T) { proof := strings.Repeat("p", 43) app, store := oauthTestApp(t, map[string]string{"oauth.app.github.enabled": "true"}) store.code = userOAuthLoginCode{Provider: "github", Platform: "app", AppProofHash: oauthHash(proof)} for _, supplied := range []string{"", strings.Repeat("x", 43)} { w := oauthPost(app.userOAuthExchange, `{"code":"intercepted-ticket","appProof":"`+supplied+`"}`) if w.Code != 400 { t.Fatal("intercepted callback must not authorize a different App instance") } } w := oauthPost(app.userOAuthExchange, `{"code":"ticket","appProof":"`+proof+`"}`) if w.Code != 200 || !strings.Contains(w.Body.String(), `"requiresLink":true`) { t.Fatalf("correct proof rejected: %s", w.Body.String()) } if _, _, err := app.consumeUserOAuthCode(context.Background(), "ticket", 42); err != nil { t.Fatal(err) } if _, _, err := app.consumeUserOAuthCode(context.Background(), "ticket", 42); err == nil { t.Fatal("login ticket reused") } } func TestAppOAuthConfigurationAndRouting(t *testing.T) { values := map[string]string{ "oauth.admin.frontend_callback_url": "http://localhost:5560/auth/social-callback", "oauth.user.frontend_callback_url": "http://localhost:5174/#/pages/auth/oauth-callback", "oauth.app.qq.enabled": "true", "oauth.app.qq.client_id": "mobile-id", } app, _ := oauthTestApp(t, map[string]string{}) app.config.Environment = "production" if err := app.validateAdminOAuthConfigValues(context.Background(), values, nil); err != nil { t.Fatalf("native-only config should not require QQ website credentials: %v", err) } delete(values, "oauth.app.qq.client_id") if err := app.validateAdminOAuthConfigValues(context.Background(), values, nil); err == nil { t.Fatal("enabled incomplete native config accepted") } if validAppOAuthProof(oauthHash("required"), "") { t.Fatal("missing callback proof accepted") } if googleTokenAudienceAllowed("other", "other", "ours") || googleTokenAudienceAllowed("ours", "other", "ours") { t.Fatal("foreign Google application accepted") } if !googleTokenAudienceAllowed("android", "ios", "android, ios") { t.Fatal("allowed app clients rejected") } app, _ = oauthTestApp(t, map[string]string{"oauth.app.frontend_callback_url": "javascript:alert(1)"}) if _, err := app.appOAuthFrontendURL(context.Background()); err == nil { t.Fatal("unsafe callback accepted") } } type oauthRoundTripper func(*http.Request) (*http.Response, error) func (f oauthRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } func mockOAuthHTTP(t *testing.T, responder func(*http.Request) string) { t.Helper() original := http.DefaultTransport http.DefaultTransport = oauthRoundTripper(func(r *http.Request) (*http.Response, error) { if r.URL.Scheme != "https" { t.Fatal("credentials sent over insecure transport") } return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(responder(r)))}, nil }) t.Cleanup(func() { http.DefaultTransport = original }) } func TestNativeQQValidatesTokenOwner(t *testing.T) { app := &App{} clientID := "foreign-app" profileCalls := 0 mockOAuthHTTP(t, func(r *http.Request) string { if r.URL.Path == "/oauth2.0/me" { return `{"openid":"verified-user","client_id":"` + clientID + `"}` } profileCalls++ if r.URL.Query().Get("openid") != "verified-user" { t.Fatal("unverified OpenID used") } return `{"ret":0,"nickname":"tester","figureurl_qq_2":"https://example.com/avatar.png"}` }) p := adminOAuthProvider{Code: "qq", ClientID: "our-app", OpenIDURL: "https://graph.qq.com/oauth2.0/me", UserInfoURL: "https://graph.qq.com/user/get_user_info"} if _, err := app.fetchNativeTokenIdentity(context.Background(), p, "token"); err == nil || profileCalls != 0 { t.Fatal("foreign QQ token was accepted") } clientID = "our-app" identity, err := app.fetchNativeTokenIdentity(context.Background(), p, "token") if err != nil || identity.Subject != "verified-user" { t.Fatalf("valid QQ token rejected: %v", err) } } func TestNativeGoogleChecksAudienceExpiryAndSubject(t *testing.T) { app, _ := oauthTestApp(t, map[string]string{"oauth.app.google.client_ids": "our-app"}) audience, expires, subject := "other-app", "3600", "verified-user" mockOAuthHTTP(t, func(r *http.Request) string { if r.URL.Path == "/oauth2/v1/tokeninfo" { return `{"audience":"` + audience + `","issued_to":"` + audience + `","user_id":"verified-user","expires_in":` + expires + `}` } if r.Header.Get("Authorization") != "Bearer token" { t.Fatal("missing bearer token") } return `{"sub":"` + subject + `","name":"tester","email":"unverified@example.com","email_verified":false}` }) p := adminOAuthProvider{Code: "google", UserInfoURL: "https://openidconnect.googleapis.com/v1/userinfo"} checkRejected := func() { t.Helper() if _, err := app.fetchNativeTokenIdentity(context.Background(), p, "token"); err == nil { t.Fatal("invalid Google token accepted") } } checkRejected() audience, expires = "our-app", "0" checkRejected() expires, subject = "3600", "another-user" checkRejected() subject = "verified-user" identity, err := app.fetchNativeTokenIdentity(context.Background(), p, "token") if err != nil || identity.Subject != subject || identity.Email != "" { t.Fatalf("verified Google token failed: %#v %v", identity, err) } } func TestAppGitHubAuthorizationRoundTrip(t *testing.T) { app, store := oauthTestApp(t, map[string]string{ "oauth.app.github.enabled": "true", "oauth.app.frontend_callback_url": appOAuthCallbackURL, "oauth.github.client_id": "our-github-app", "oauth.github.client_secret": "server-secret", "oauth.github.authorization_url": "https://github.com/login/oauth/authorize", "oauth.github.token_url": "https://github.com/login/oauth/access_token", "oauth.github.userinfo_url": "https://api.github.com/user", "oauth.github.scope": "read:user", "oauth.github.redirect_uri": "https://api.example.com/api/v1/auth/oauth/callback", }) mockOAuthHTTP(t, func(r *http.Request) string { if r.URL.Path == "/login/oauth/access_token" { _ = r.ParseForm() if r.Form.Get("code_verifier") != store.stateVerifier || r.Form.Get("code") != "provider-code" { t.Fatal("missing PKCE verifier or code") } return `{"access_token":"provider-token"}` } return `{"id":123,"login":"tester"}` }) w := oauthPost(app.userOAuthStart, `{"provider":"github","platform":"app"}`) var start struct { Data map[string]string `json:"data"` } if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &start) != nil { t.Fatalf("start failed: %s", w.Body.String()) } authURL, _ := url.Parse(start.Data["authorizationUrl"]) if authURL.Query().Get("state") != start.Data["requestId"] || authURL.Query().Get("code_challenge") != pkceChallenge(store.stateVerifier) { t.Fatal("authorization is not bound to the request") } if !bytes.Equal(store.stateProof, oauthHash(start.Data["appProof"])) { t.Fatal("proof not hashed at rest") } callback := "/api/v1/auth/oauth/callback?state=" + url.QueryEscape(start.Data["requestId"]) + "&code=provider-code" w = httptest.NewRecorder() app.userOAuthCallback(w, httptest.NewRequest("GET", callback, nil)) resultURL, err := url.Parse(w.Header().Get("Location")) if w.Code != 302 || err != nil || resultURL.Scheme != "xingyuim" { t.Fatalf("App callback failed: %d %s", w.Code, w.Body.String()) } if resultURL.Query().Get("requestId") != start.Data["requestId"] || resultURL.Query().Get("oauthCode") == "" { t.Fatal("App callback missing correlation or ticket") } if strings.Contains(resultURL.String(), start.Data["appProof"]) { t.Fatal("private proof leaked into browser redirect") } if store.code.Platform != "app" || !bytes.Equal(store.code.AppProofHash, store.stateProof) { t.Fatal("login ticket lost its App binding") } w = httptest.NewRecorder() app.userOAuthCallback(w, httptest.NewRequest("GET", callback, nil)) if w.Code != 400 { t.Fatal("authorization callback replay accepted") } }